Рекурсия, 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 и рекурсия несовместимы по смыслу.
Проверьте себя
1. Что нужно сделать перед использованием optional-аргумента внутри процедуры?
AНичего, он всегда доступен
BПроверить его наличие функцией present()
CОбнулить его
DОбъявить его глобально
2. В чём коварная ловушка инициализации integer :: x = 0 в объявлении процедуры?
AЭто синтаксическая ошибка
BПеременная неявно получает атрибут save и инициализируется лишь однажды, а не на каждом вызове
Cx всегда остаётся равным 0
DИнициализация запрещена
3. Где размещается локальная переменная с атрибутом save?
AВ кадре стека, исчезает при возврате
BВ статической памяти, существует всё время работы программы
CВ регистре процессора
DВ куче, требует deallocate