Занятие 5. Управление программой: функции и потоки выполнения
Программа — это не список действий, а граф решений и повторений. Сегодня разбираем инструменты управления этим графом: функции (и их скрытую механику — стек вызовов), ветвления и циклы — вместе с полным каталогом способов застрять в цикле навечно. Все числа в конспекте — реальные замеры (Apple M-серия, clang).
Функции
Единица смысла, а не экономии строк
Функция нужна не для того, чтобы «не повторять код» (это приятный бонус), а чтобы:
- декомпозировать: большая задача превращается в композицию маленьких, каждую можно понять отдельно;
- зафиксировать контракт: сигнатура обещает «что», скрывая «как» — вспомните разделение интерфейса и реализации через
.h/.cppиз занятия 1; - тестировать в изоляции: функцию можно прогнать на краевых случаях отдельно от всей программы.
Эмпирическое правило: функция делает одну вещь и умещается на экран. Если в имени просится союз «и» — read_and_validate_and_save() — это три функции. Имя — глагол или вопрос: parse(), is_prime(); если имя не придумывается, вы ещё не поняли, что делает функция.
Анатомия
Терминология: параметры — имена внутри функции, аргументы — то, что передали при вызове. Параметр — новая переменная, живущая от вызова до возврата. return завершает функцию немедленно, из любого места. Функция не-void обязана вернуть значение на каждом пути исполнения — «дойти до конца без return» это UB (ловится -Wall через -Wreturn-type).
Стек вызовов
Каждый вызов функции кладёт на стек кадр (stack frame): параметры, адрес возврата, локальные переменные. return снимает кадр и продолжает исполнение с адреса возврата. Рекурсия работает ровно поэтому: каждый уровень получает независимый кадр со своими значениями.
Стек конечен. По умолчанию — около 8 МБ (ulimit -s → 8176 КБ):
8 МБ ÷ ~32 байта на кадр ≈ 260 тысяч вызовов — арифметика сходится с экспериментом. Переполнение стека — мгновенный segfault, его нельзя поймать и обработать.
Правило
Рекурсия хороша, когда глубина логарифмическая (двоичный поиск, быстрое возведение в степень) или заведомо мала. Линейная глубина порядка $10^5$ и больше — переписываем циклом или явным стеком.
Хвостовая рекурсия
Если рекурсивный вызов — последнее действие функции, кадр можно не сохранять: компилятор заменяет вызов переходом (tail call optimization), и рекурсия превращается в цикл:
В листинге clang++ -S -O2 этой функции нет ни одной инструкции вызова — только сравнения и переходы: стек не растёт. Но заметьте: fib_rec из занятия 2 — не хвостовая рекурсия (после вызовов ещё сложение), а стандарт C++ TCO не гарантирует — на -O0 та же функция честно упадёт. Не стройте корректность программы на оптимизации.
Параметры: передача и умолчания
Способы передачи — шпаргалка из занятия 3: мелкое копией (int x), крупное для чтения — const T&, для изменения — T&, «может отсутствовать» — T*. Новое — значения по умолчанию:
Умолчания задаются в объявлении (в заголовке) — вызывающие видят только его; вычисляются заново при каждом вызове (запомните это до Python-интерлюдии). Много параметров — запах: связанные настройки собирайте в структуру.
Перегрузка
Перегрузки различаются типами параметров (возвращаемый тип не участвует). Компилятор выбирает лучшее соответствие: точное → продвижения (char→int, float→double) → преобразования (int→double); если лучших несколько — честная ошибка компиляции. Под капотом работает манглинг из занятия 1: __Z3maxii и __Z3maxdd — разные символы для линковщика.
Стиль
Перегрузки должны делать одно и то же для разных типов. Если print(int) печатает, а print(bool) пишет в файл — вы готовите коллегам сюрприз.
Возврат: одно значение, несколько, ни одного
Возврат больших объектов дёшев: copy elision / RVO строит объект сразу на месте результата (гарантировано стандартом с C++17). Несколько значений — std::pair/std::tuple со structured bindings, а для осмысленных наборов — маленькая struct с именованными полями. Атрибут [[nodiscard]] заставляет вызывающего использовать результат — вешайте его на функции, игнорирование результата которых почти наверняка баг:
Ветвления
if/else — и init-statement из C++17
Условие — любое выражение, приводимое к bool (отсюда идиомы if (ptr), if (!v.empty())). else if — это просто if внутри else. Init-statement сужает область видимости: имя не «протекает» за пределы проверки.
Две привычки: скобки {} вокруг веток — всегда (история знает баг Apple goto fail, случившийся из-за «второй строки без скобок»); и помните про if (x = 0) — присваивание вместо сравнения компилируется, ловится -Wall.
Тернарный оператор
усл ? a : b — выражение: у него есть значение и тип. Главный кейс — инициализация const-переменных, где if бессилен. Вычисляется только выбранная ветка. Тернарный — для выбора значения; для выбора действия — обычный if. Вложенные тернарные не пишем: два уровня уже требуют расстановки скобок в уме.
switch
switch сравнивает целочисленное выражение (включая enum и char) с константами времени компиляции. Без break исполнение проваливается в следующий case — источник классических багов; осознанное проваливание помечайте [[fallthrough]];. Приём: switch по enum class без default — тогда при добавлении нового значения enum компилятор укажет все места, где разбор неполон.
Циклы
for: три секции
Порядок: init → cond → тело → step → cond → … Проверка условия — до первой итерации: тело может не выполниться ни разу. Счётчик живёт только внутри цикла. Любая секция опциональна: for (;;) — идиома осознанной бесконечности.
while и do-while
Соглашение о читаемости: for — когда есть счётчик или диапазон; while — когда есть условие продолжения; do-while — когда тело обязано выполниться хотя бы раз (ввод, меню). Кстати, цикл в примере — гипотеза Коллатца: его завершимость для всех n — открытая математическая проблема.
range-based for
Работает со всем, у чего есть begin()/end() — под капотом это в точности цикл по итераторам (тема занятия 16). Выбор формы — та же логика, что у параметров функций. Нет индекса — нет ошибок ±1; нужен индекс — берите классический for. Запрещено добавлять и удалять элементы контейнера внутри range-for: итераторы «протухают», это UB.
break, continue и вложенные циклы
break покидает ближайший цикл или switch; continue перепрыгивает к следующей итерации (у for — через step). Ранние выходы упрощают условие цикла: вместо трёх флагов в cond — понятные точки выхода в теле.
Из вложенных циклов break не выведет. Лучший выход — функция:
А goto? Существует; его легитимная ниша в C — как раз выход из вложенных циклов. В C++ функция с return решает то же чище — в курсе goto не используем.
Ловушки
Бесконечные циклы: каталог
Диагностика: программа «висит» — это почти всегда цикл; отладчик (пауза → где стоим?) или печать счётчика раз в миллион итераций находят виновника за минуту.
Инвариант цикла
Инвариант — утверждение, истинное перед каждой проверкой условия цикла. Это главный инструмент рассуждения о корректности:
Схема: инвариант истинен до цикла (база) → тело его сохраняет (шаг) → после выхода инвариант вместе с отрицанием условия дают требуемое. Это индукция, замаскированная под программирование. Навык понадобится немедленно: корректный двоичный поиск (занятие 12) без инварианта невозможно ни написать, ни отладить.
Области видимости и затенение
Блок {} — граница видимости. Затенение (shadowing) легально и почти всегда случайно: включите -Wshadow в боевой набор флагов. Правило гигиены: объявляйте переменные в минимальной области и как можно ближе к использованию.
Порядок вычислений
Два изменения одной переменной без упорядочивания между ними — UB. C++17 навёл частичный порядок (в a[i] = b[j], цепочках <<), но порядок вычисления аргументов функции по-прежнему не определён: в f(read_next(), read_next()) неизвестно, какой вызов первый. Рабочее правило: не более одного побочного эффекта на выражение; сложное выражение — разбить на строки с именованными переменными.
Стиль: ранний выход против лестницы
Проверки-«вышибалы» в начале функции вместо четырёхэтажной лестницы if-ов: основной сценарий читается сверху вниз без скроллинга вправо.
Взгляд со стороны: Python
Отличия от C++: умолчания вычисляются один раз при определении функции (в C++ — при каждом вызове), поэтому mutable-умолчание живёт между вызовами. Перегрузки по типам нет — функция это объект, имя указывает на последний def. Лимит рекурсии мягкий: ~1000 вызовов и ловимый RecursionError вместо segfault. А for в Python — всегда range-based; классического трёхсекционного нет.
Самопроверка
- Почему переполнение стека нельзя обработать, а
RecursionErrorв Python — можно? f(3)при перегрузкахf(int)иf(double)— какая версия вызовется для аргумента3.5f(float)? Почему?- Чем
while (cond)отличается отdo … while (cond)по числу гарантированных итераций? - Сформулируйте инвариант цикла суммирования массива.
Практика
- Напишите
is_prime(n),gcd(a, b)циклом иpower(base, exp)за $O(\log exp)$ — с обработкой краевых случаев. - Перепишите рекурсивный
gcdитеративно; сравните ассемблер обеих версий на godbolt.org при-O2. - Измерьте глубину стека своей машины программой
dive(); объясните полученное число черезulimit -s. - Сформулируйте инвариант цикла своей
powerи докажите её корректность.