Перейти к содержимому
Topos Uranos

Краткое содержание

Два предыдущих урока объяснили, что такое функция и как в неё входят и из неё выходят данные; этот урок учит решать, какие функции писать. Метод называется пошаговой детализацией: пишется короткая main, которая вызывает ещё не существующие функции, каждую со своим контрактом, и затем мы спускаемся к каждой из них, пока не дойдём до частей, которые пишутся в несколько строк. Урок устанавливает критерии хорошего интерфейса, правило «одна функция, одна задача» с признаками, выдающими его нарушение, и перегрузку, которая даёт одно имя одной и той же задаче над разными типами. С этими критериями он переписывает алгоритмы раздела 4, жившие внутри main, и тестирует их по отдельности. Объединяющее понятие здесь нисходящая декомпозиция: хорошо спроектированная программа читается как собственная спецификация.

Цели обучения

  1. Разбивать задачу на подзадачи методом пошаговой детализации и записывать дерево декомпозиции.
  2. Проектировать интерфейс каждой функции (имя, параметры, возвращаемое значение и контракт) до того, как написано её тело.
  3. Переписывать монолитную программу из раздела 4 как набор связных функций и тестировать каждую по отдельности с помощью тестовой main.
  4. Оценивать проект по правилу «одна функция, одна задача» и по отсутствию скрытых эффектов.
  5. Перегружать функцию, предсказывать, какую версию выбирает каждый вызов, и объяснять, почему вызов оказывается неоднозначным.

Пошаговая детализация

От условия задачи к main из нескольких строк

Программы раздела 4 были целиком написаны внутри main. Пока они помещаются на одном экране, с этим можно мириться; однако программа в двести строк, написанная так, требует держать в голове всё сразу и допускает только тестирование целиком. Пошаговая детализация, или нисходящее проектирование, меняет порядок работы на обратный: сначала записывается, что делает программа, на языке задачи, как последовательность вызовов функций, которых ещё нет; затем каждая из этих функций рассматривается как новая, меньшая задача, и процедура повторяется, пока не дойдём до функций настолько простых, что их тело пишется без всякой декомпозиции.

Примером послужит такая задача. Целое число n≥1n \geq 1 называется совершенным, если сумма его собственных делителей (положительных делителей, отличных от nn) равна nn, недостаточным, если она меньше, и избыточным, если больше: 28=1+2+4+7+1428 = 1 + 2 + 4 + 7 + 14, и потому 28 совершенное; 8, где 1+2+4=71 + 2 + 4 = 7, недостаточное; 12, где 1+2+3+4+6=161 + 2 + 3 + 4 + 6 = 16, избыточное. Нужна программа, которая читает nn и выводит сумму его собственных делителей и его вид. Первый уровень проекта есть main, и она пишется раньше всего остального:

Программа на C++
#include <cstdint>#include <iostream>#include <print>#include <string>​enum class Kind { deficient, perfect, abundant };​// Сумма положительных делителей n.// Предусловие: n >= 1.// Постусловие: возвращает сумму всех положительных делителей n, включая само n.std::int64_t divisorSum(int n);​// Вид числа n по сумме его собственных делителей.// Предусловие: n >= 1.// Постусловие: возвращает deficient, perfect или abundant, если сумма меньше n, равна n или больше n.Kind classify(int n);​// Название вида, чтобы его вывести.// Предусловие: нет.// Постусловие: возвращает «недостаточное», «совершенное» или «избыточное».std::string kindName(Kind kind);​int main(){    int n{};    std::cin >> n;    std::println("Собственные делители числа {}: сумма {}", n, divisorSum(n) - n);    std::println("{}: {} число", n, kindName(classify(n)));}
Сообщения компоновщика
/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':main.cpp:27:(.text+0x22a): undefined reference to `divisorSum(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x3fd): undefined reference to `classify(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x40d): undefined reference to `kindName[abi:cxx11](Kind)'collect2: error: ld returned 1 exit status

Программа компилируется без единого предупреждения, потому что каждый вызов согласуется со своим объявлением, а только это компилятор и проверяет, как объяснил урок Функции. Не удаётся компоновка: компоновщик ищет определения и не находит их («undefined reference», ссылка без определения; добавка [abi:cxx11] есть внутренняя пометка GCC для функций, возвращающих std::string). Это состояние в точности совпадает с состоянием проекта на данном этапе: интерфейсы решены, тела нет. Если это так, main уже читается как спецификация программы, а три объявления представляют собой отложенные задачи, каждая меньше исходной.

Дерево декомпозиции

Второй уровень спускается к каждому объявлению. kindName есть выбор из трёх случаев. classify опирается на divisorSum: число совершенно, когда сумма всех его делителей равна 2n2n. А divisorSum есть цикл перебора делителей парами (d,n/d)(d, n/d) из урока Численные алгоритмы, который останавливается на n\sqrt{n}. Результат изображается в виде дерева: корень есть вся задача; потомки каждого узла суть подзадачи, которые его решают; листья суть операции, которые язык уже предоставляет.

ПримерДерево классификатора целых чисел

Рисунок разворачивает дерево уровень за уровнем, в том порядке, в каком оно проектировалось.

Доказательство

  1. Первый уровень есть вся задача целиком, порученная main, которая ещё не разбита на части.

  2. Второй уровень есть main из пяти операций: прочитать, просуммировать, классифицировать, назвать и вывести. Каждый узел есть вызов; вызовы библиотеки уже существуют, остальные объявлены и ждут своей очереди.

  3. Третий уровень разбивает classify, которая только вызывает divisorSum и сравнивает. Одна и та же функция встречается в дереве дважды, а пишется один раз: повторное использование видно в дереве ещё до того, как написан код.

  4. Последний уровень составляют листья: тест делимости и граница перебора пар, операции самого языка. Когда мы до них доходим, детализация заканчивается, потому что разбивать больше нечего.

Когда дерево готово, определения пишутся снизу вверх, так что каждая функция опирается на уже написанные:

Программа на C++
#include <cstdint>#include <iostream>#include <print>#include <string>​enum class Kind { deficient, perfect, abundant };​// Сумма положительных делителей n.// Предусловие: n >= 1.// Постусловие: возвращает сумму всех положительных делителей n, включая само n.std::int64_t divisorSum(int n){    std::int64_t sum{0};    for (int d{1}; d <= n / d; ++d) {        if (n % d == 0) {            sum += d;            if (d != n / d) {                sum += n / d;            }        }    }    return sum;}​// Вид числа n по сумме его собственных делителей.// Предусловие: n >= 1.// Постусловие: возвращает deficient, perfect или abundant, если сумма меньше n, равна n или больше n.Kind classify(int n){    const std::int64_t proper{divisorSum(n) - n};    if (proper < n) {        return Kind::deficient;    }    if (proper == n) {        return Kind::perfect;    }    return Kind::abundant;}​// Название вида, чтобы его вывести.// Предусловие: нет.// Постусловие: возвращает «недостаточное», «совершенное» или «избыточное».std::string kindName(Kind kind){    if (kind == Kind::deficient) {        return "недостаточное";    }    if (kind == Kind::perfect) {        return "совершенное";    }    return "избыточное";}​int main(){    int n{};    std::cin >> n;    std::println("Собственные делители числа {}: сумма {}", n, divisorSum(n) - n);    std::println("{}: {} число", n, kindName(classify(n)));}
Ввод программы
28
Вывод
Собственные делители числа 28: сумма 2828: совершенное число

Три черты результата обязаны методу. Во-первых, main не изменилась, пока писалось то, что ниже. Во-вторых, сумма возвращается как std::int64_t (из <cstdint>, 64-битное целое, которым пользуется и урок Рекурсия), потому что при nn, близком к максимуму int, сумма его делителей может его превысить, и решение, принятое в интерфейсе, защищает всех вызывающих. В-третьих, каждая функция тестируется по отдельности: divisorSum(28) должна дать 56, divisorSum(1) должна дать 1, а divisorSum(36) должна дать 91, где пара (6,6)(6, 6) учитывается один раз; последний параграф урока превращает эти тесты в программу.