Рекурсия, optional-аргументы и save
Рекурсия, optional-аргументы и атрибут save — инструменты для процедур, которые умнее простого «принять и вернуть».
Рекурсивная процедура вызывает саму себя для решения подзадачи; optional-аргумент можно опустить при вызове; атрибут save заставляет локальную переменную сохранять значение между вызовами.
Базовых процедур достаточно для простых задач, но реальные алгоритмы требуют большего: разделяй-и-властвуй через рекурсию, гибкие интерфейсы с необязательными параметрами, накопление состояния между вызовами. Fortran даёт для этого аккуратные механизмы, и в этом уроке мы разберём три из них. Каждый несёт тонкости, незнание которых ведёт к багам: рекурсия в старом Fortran требовала явного ключевого слова, optional требует проверки наличия, а save прячет коварную ловушку инициализации. Освоив их, вы выйдете на уровень действительно гибких процедур.
Рекурсия
Рекурсивная процедура вызывает себя. Классический пример — факториал. В современном Fortran (2018) процедуры рекурсивны по умолчанию, но для ясности и совместимости часто пишут ключевое слово recursive. Обязательно нужен базовый случай, иначе рекурсия бесконечна.
program recursion_demo
implicit none
print *, "5! =", factorial(5)
print *, "Фибоначчи(10) =", fib(10)
contains
recursive function factorial(n) result(f)
integer, intent(in) :: n
integer :: f
if (n <= 1) then
f = 1 ! базовый случай
else
f = n * factorial(n - 1) ! рекурсивный шаг
end if
end function factorial
recursive function fib(n) result(r)
integer, intent(in) :: n
integer :: r
if (n < 2) then
r = n
else
r = fib(n - 1) + fib(n - 2)
end if
end function fib
end program recursion_demo
Вывод:
5! = 120 Фибоначчи(10) = 55
Слово result(f) здесь особенно важно: при рекурсии без него имя функции было бы неоднозначным (вызов или результат?). Базовый случай (n <= 1) останавливает рекурсию. Помните: наивная рекурсия Фибоначчи экспоненциально медленна — это иллюстрация механизма, а не рецепт для больших n.
Optional-аргументы и present
Атрибут optional делает аргумент необязательным: его можно не передавать. Внутри процедуры проверяют наличие функцией present. Это даёт гибкие интерфейсы со значениями по умолчанию.
program optional_demo
implicit none
print *, "Степень по умолчанию (2):", power(3.0)
print *, "Степень 3:", power(3.0, 3)
contains
function power(base, exp) result(p)
real, intent(in) :: base
integer, intent(in), optional :: exp ! необязательный
real :: p
integer :: e
if (present(exp)) then
e = exp ! аргумент передан
else
e = 2 ! значение по умолчанию
end if
p = base ** e
end function power
end program optional_demo
Вывод:
Степень по умолчанию (2): 9.00000000 Степень 3: 27.0000000
Функцию power можно вызвать с одним аргументом (power(3.0)) или с двумя. Внутри present(exp) сообщает, передан ли exp; если нет — берётся значение по умолчанию. Критическое правило: опущенный optional-аргумент нельзя использовать, не проверив present — иначе вы обращаетесь к несуществующему значению. Это самая частая ошибка с optional.
Именованные (keyword) аргументы
С optional-аргументами тесно связана возможность передавать аргументы по имени, а не только по позиции. Это позволяет пропускать средние необязательные аргументы и делает вызовы читаемыми.
program keyword_args
implicit none
call draw(width=10, height=5) ! по именам, любой порядок
call draw(height=3, width=8) ! порядок не важен
contains
subroutine draw(width, height, fill)
integer, intent(in) :: width, height
character(len=1), intent(in), optional :: fill
character(len=1) :: ch
integer :: i, j
ch = "*"
if (present(fill)) ch = fill
do i = 1, height
do j = 1, width
write(*, '(A)', advance='no') ch
end do
print *
end do
print *
end subroutine draw
end program keyword_args
Вывод:
********** ********** ********** ********** ********** ******** ******** ********
Передача width=10, height=5 по именам делает вызов самодокументируемым и позволяет менять порядок. В сочетании с optional это даёт интерфейсы, где необязательные параметры легко пропускать, указывая лишь нужные по имени.
У именованных аргументов есть и техническое правило, о котором стоит помнить. Как только в списке вызова появился аргумент по имени, все последующие тоже обязаны передаваться по имени — смешивать «сначала по именам, потом снова по позиции» нельзя. Поэтому на практике сложилась естественная дисциплина: обязательные позиционные аргументы перечисляют первыми и по порядку, а необязательные передают по имени в конце. Такой стиль особенно ценен, когда у процедуры несколько необязательных параметров: вместо того чтобы выписывать длинную цепочку значений и гадать, какое из них за что отвечает, вы указываете лишь нужные, называя их явно. Это резко снижает число ошибок «перепутал порядок аргументов» — одну из самых частых и обидных в процедурах с длинными списками параметров.
save: состояние между вызовами и его ловушка
Обычно локальные переменные процедуры исчезают при выходе и создаются заново при следующем вызове. Атрибут save заставляет переменную сохранять значение между вызовами — полезно для счётчиков и кэширования. Но здесь прячется самая известная ловушка Fortran.
program save_demo
implicit none
integer :: i
do i = 1, 3
call count_calls()
end do
contains
subroutine count_calls()
integer, save :: calls = 0 ! инициализация ТОЛЬКО при первом входе
calls = calls + 1
print *, "Вызов номер", calls
end subroutine count_calls
end program save_demo
Вывод:
Вызов номер 1 Вызов номер 2 Вызов номер 3
Переменная calls сохраняет значение, считая вызовы. Коварная тонкость: в Fortran локальная переменная с инициализацией при объявлении (integer :: x = 0) неявно получает атрибут save! То есть x = 0 в объявлении срабатывает лишь однажды, при первом вызове, а не на каждом входе, как ждёт программист из C. Это источник бесчисленных багов: код «работает один раз». Чтобы переменная сбрасывалась каждый вызов, инициализируйте её отдельным оператором в теле, а не в объявлении.
Анатомия ловушки неявного save
Эта ловушка заслуживает того, чтобы рассмотреть её во всех деталях, потому что на ней спотыкаются даже опытные инженеры, пришедшие из C, C++ или Java. В этих языках строка вроде int x = 0; внутри функции означает совершенно однозначную вещь: при каждом входе в функцию создаётся новая локальная переменная и заново инициализируется нулём. Перенося эту интуицию на Fortran, программист пишет integer :: x = 0 и искренне верит, что x будет обнуляться при каждом вызове. На деле же стандарт Fortran трактует наличие инициализатора в объявлении как сигнал придать переменной атрибут save — а значит, инициализация выполняется ровно один раз за весь запуск программы.
Последствия бывают почти комичными в своей запутанности. Процедура, которую вызвали впервые, отрабатывает идеально: x равен нулю, как и задумано. При втором вызове x хранит значение, оставшееся от первого вызова, — и логика, рассчитанная на чистый старт, начинает выдавать неверные результаты. Самое неприятное, что тесты, прогоняющие функцию однократно, проходят, а сбой проявляется лишь в реальном сценарии с повторными вызовами. Отлаживать такое тяжело: глядя на строку integer :: x = 0, программист видит «обнуление», а на самом деле там скрыт save.
Правильный приём прост и его стоит довести до автоматизма: если переменная должна стартовать с известного значения при каждом вызове, не инициализируйте её в объявлении — объявите без значения, а присвоение сделайте отдельным исполняемым оператором в теле. Тогда integer :: x и следом x = 0 дадут именно то поведение, которое ожидается от локальной переменной. Инициализатор же в объявлении оставляйте только для тех случаев, когда вы сознательно хотите эффект save — например, для счётчика или флага «уже инициализировано», который должен помнить состояние между вызовами.
Цена рекурсии: кадры стека и переполнение
Рекурсия выглядит элегантно, но важно понимать её стоимость в терминах памяти, иначе красивое решение однажды обернётся аварией. Каждый рекурсивный вызов — это полноценный вход в процедуру, а значит, в стек кладётся новый кадр: адрес возврата, место под аргументы и под все локальные переменные данного уровня. Пока глубина рекурсии невелика, эти кадры почти не заметны. Но стек — ограниченный ресурс: его размер задаётся при запуске программы и обычно измеряется единицами мегабайт.
Если рекурсия уходит слишком глубоко — скажем, вы обрабатываете структуру с сотнями тысяч уровней вложенности или, того хуже, забыли базовый случай и спуск стал бесконечным, — кадры накапливаются, пока не упрутся в границу стека. Происходит переполнение стека (stack overflow), и программа аварийно завершается, как правило без внятного сообщения, просто падая. В отличие от ошибки компиляции, эту беду не поймать заранее: всё зависит от данных в рантайме.
Отсюда два практических правила. Во-первых, базовый случай в рекурсивной процедуре — не формальность, а единственное, что отделяет программу от падения; продумывайте его в первую очередь. Во-вторых, там, где задача одинаково естественно решается и циклом, и рекурсией, на больших данных предпочитайте цикл: итеративный вариант использует фиксированный объём памяти и не рискует переполнить стек. Рекурсию же приберегите для случаев, где она радикально упрощает код, а глубина заведомо ограничена. Именно поэтому, например, наивную рекурсию Фибоначчи показывают как учебную иллюстрацию, но в серьёзном коде заменяют циклом или мемоизацией.
Как работает под капотом
Где живут save-переменные и почему рекурсия требует особого обращения? Обычная локальная переменная размещается в кадре стека процедуры и исчезает при возврате — поэтому при следующем вызове она «новая». save-переменная, напротив, размещается в статической памяти (как глобальная), существующей всё время работы программы, — оттого она и помнит значение. Инициализация в объявлении выполняется как часть статической подготовки — однократно при загрузке, что и объясняет неявный save: значение задаётся раз и навсегда. Рекурсия же опирается на стек: каждый вложенный вызов factorial получает свой кадр со своей копией локальных переменных и аргументов, поэтому пять вложенных вызовов не мешают друг другу. А вот save-переменная в рекурсивной процедуре общая для всех уровней — это почти всегда ошибка, поэтому save и рекурсия плохо сочетаются. Глубокая рекурсия может переполнить стек (stack overflow) — ещё одна причина предпочитать итерацию там, где она естественна.
Частые ошибки
- Неявный
saveпри инициализации в объявлении.integer :: x = 0инициализируется однажды; для сброса каждый вызов пишитеx = 0отдельной строкой. - Использование optional-аргумента без
present. Обращение к опущенному аргументу — ошибка; всегда проверяйтеpresent. - Рекурсия без базового случая. Бесконечный спуск переполнит стек; обязателен останавливающий случай.
saveв рекурсивной процедуре. Переменная общая для всех уровней рекурсии — обычно баг.- Глубокая рекурсия на больших данных. Риск переполнения стека; где уместно, заменяйте итерацией.
Итоги
- Рекурсивная процедура вызывает себя; нужен базовый случай и (для ясности) ключевое слово
recursiveсresult. optionalделает аргумент необязательным; перед использованием проверяйтеpresent(arg).- Аргументы можно передавать по имени (
width=10), меняя порядок и пропуская необязательные. saveсохраняет значение локальной переменной между вызовами (статическая память).- Ловушка: инициализация в объявлении (
x = 0) неявно даётsaveи выполняется лишь однажды. - Каждый рекурсивный вызов получает свой кадр стека;
saveи рекурсия несовместимы по смыслу.