Чистые и элементные процедуры

Атрибуты pure и elemental превращают обычные процедуры в чистые функции, которые компилятор может смело распараллеливать.

Чистая (pure) процедура не имеет побочных эффектов: её результат зависит только от аргументов, и она не меняет глобального состояния. Элементная (elemental) процедура — это pure-процедура, написанная для скаляра, но применимая и к массивам поэлементно.

Мы подошли к вершине культуры процедур в Fortran — атрибутам pure и elemental. Они не добавляют новой вычислительной мощи, но придают процедурам свойства, бесценные для оптимизации и параллелизма: предсказуемость и отсутствие побочных эффектов. Именно благодаря им встроенные функции вроде sqrt умеют работать с массивами, а do concurrent может безопасно распараллеливаться. Этот урок завершает раздел о процедурах, показывая, как писать собственные чистые и элементные процедуры и почему это признак зрелого инженерного кода на современном Fortran.

Что такое чистая процедура

Процедура чиста, если её единственный эффект — вычисление результата из входных аргументов. Она не печатает, не меняет глобальные переменные, не пишет в файлы, не трогает save-переменные. Атрибут pure объявляет это намерение, и компилятор его проверяет.

program pure_demo
  implicit none
  real :: x
  x = 4.0
  print *, "Гипотенуза:", hypotenuse(3.0, 4.0)
contains
  pure function hypotenuse(a, b) result(h)
    real, intent(in) :: a, b
    real :: h
    h = sqrt(a**2 + b**2)
    ! print *, h   ! ОШИБКА: print — побочный эффект, в pure нельзя
  end function hypotenuse
end program pure_demo

Вывод:

 Гипотенуза:   5.00000000

Внутри pure-функции запрещены любые побочные эффекты: нельзя печатать, менять intent(in)-аргументы (это и так нельзя) или обращаться к глобальному состоянию. Все аргументы должны быть intent(in) (для функции). Это строгий контракт, но именно строгость делает процедуру предсказуемой: при одинаковых аргументах — всегда одинаковый результат, без скрытых зависимостей.

Зачем нужна чистота

Преимущества pure — концептуальные и практические. Главное практическое — безопасный параллелизм: только чистые процедуры разрешено вызывать внутри do concurrent и в массивных контекстах, потому что компилятор уверен — порядок вызовов и их распараллеливание не изменят результата.

Свойство pureЧто даёт
Нет побочных эффектоврезультат зависит только от аргументов
Детерминизмодинаковый вход → одинаковый выход
Можно в do concurrentбезопасное распараллеливание
Проще тестировать и рассуждатьнет скрытого состояния
Свобода оптимизатораможно переупорядочить/закэшировать вызовы

Есть и более мягкий вариант — elemental подразумевает pure, а атрибут simple (Fortran 2023) ещё строже. Но для большинства задач pure — золотой стандарт: помечайте им любую функцию-вычисление, и код станет надёжнее.

Элементные процедуры

Самое красивое — elemental. Вы пишете процедуру для скаляра, а вызывать её можно и со скаляром, и с массивом любой формы — тогда она применится поэлементно, как встроенный sin. Это устраняет циклы и делает ваши функции такими же удобными, как стандартные.

program elemental_demo
  implicit none
  real :: scalar
  real :: vec(4)
  scalar = 100.0
  vec = [0.0, 50.0, 75.0, 100.0]
  print *, "Скаляр:", to_celsius(212.0)        ! на скаляре
  print *, "Массив:", to_celsius([32.0, 212.0, 98.6])  ! на массиве!
contains
  elemental function to_celsius(f) result(c)
    real, intent(in) :: f
    real :: c
    c = (f - 32.0) * 5.0 / 9.0
  end function to_celsius
end program elemental_demo

Вывод:

 Скаляр:   100.000000
 Массив:   0.00000000  100.000000  37.0000000

Функция to_celsius написана так, будто принимает одно число, но elemental позволяет передать ей целый массив [32.0, 212.0, 98.6] — и она вернёт массив результатов. Это огромное удобство: одна короткая функция работает и со скаляром, и с вектором, и с матрицей. Ограничение: тело elemental-процедуры должно быть скалярным (аргументы и результат — скаляры) и чистым.

Есть и правило согласования формы, которое полезно держать в уме при нескольких аргументах. Если элементная процедура принимает два массивных аргумента, они должны быть одинаковой формы — процедура применится к парам соответствующих элементов. А вот скаляр разрешено свободно смешивать с массивом: скалярное значение тогда «распространяется» на все элементы, как если бы его повторили нужное число раз. Скажем, элементная функция сложения, вызванная как сумма массива и одного числа, прибавит это число к каждому элементу. Такое поведение в точности повторяет то, как ведут себя встроенные операции над массивами, и потому ощущается совершенно естественным. Несоответствие же форм двух массивных аргументов компилятор отвергнет — это защищает от бессмысленных вычислений над данными разной длины.

Чистые субрутины и комбинация атрибутов

Чистыми могут быть и субрутины — тогда им разрешены intent(out)/intent(inout)-аргументы (это не «побочный эффект», а объявленный выход), но запрещены печать и глобальное состояние. Элементной можно сделать и субрутину — например, чтобы поэлементно модифицировать массив.

program pure_sub
  implicit none
  real :: data(4)
  data = [1.0, -2.0, 3.0, -4.0]
  call clamp_all(data)
  print *, "После clamp:", data
contains
  elemental subroutine clamp_all(x)
    real, intent(inout) :: x
    if (x < 0.0) x = 0.0           ! поэлементно к каждому
  end subroutine clamp_all
end program pure_sub

Вывод:

 После clamp:   1.00000000  0.00000000  3.00000000  0.00000000

Элементная субрутина clamp_all объявлена для скаляра с intent(inout), но применяется ко всему массиву data поэлементно — каждый отрицательный элемент обнуляется. Это идиоматичный современный способ обработать массив без явного цикла, сохранив ясность скалярной логики.

Что именно запрещает pure и почему

Чтобы пользоваться pure уверенно, нужно ясно представлять полный список того, что внутри чистой процедуры недопустимо, — и понимать, что каждый запрет не произволен, а вытекает из единственной идеи: чистая процедура не должна наблюдать внешний мир и не должна на него влиять, кроме как через свои аргументы и результат. Отсюда конкретно следует: запрещён любой ввод-вывод — ни print, ни read, ни write, ни работа с файлами, потому что это и наблюдение, и воздействие на внешнее состояние. Запрещено изменять глобальные данные — переменные из модулей, доступные снаружи. Запрещено обращаться к save-переменным с записью, ведь они хранят состояние между вызовами и делают результат зависящим от истории, а не только от аргументов.

Запрещён и ещё один тонкий момент: чистая процедура не может вызывать нечистую процедуру. Это логично — если бы чистая функция позвала функцию с побочными эффектами, то через неё она нарушила бы собственную чистоту чужими руками. Поэтому чистота «заразна вниз»: чтобы функция была pure, всё, что она вызывает, тоже должно быть pure. Наконец, для функций все аргументы обязаны быть intent(in) — чистая функция вычисляет результат, но не возвращает ничего «вбок» через аргументы.

Эти ограничения поначалу кажутся стеснительными, но именно они и есть источник ценности. Чистая процедура детерминирована: при одних и тех же аргументах она всегда даёт один и тот же ответ, без оговорок «смотря в каком состоянии программа». Это бесценно для тестируемости: чтобы проверить чистую функцию, достаточно подать ей входы и сравнить выход с ожидаемым — не нужно готовить окружение, подменять файлы или сбрасывать глобальные переменные. О чистой функции легко рассуждать: её поведение целиком описывается сигнатурой и телом, без скрытых зависимостей, которые приходится держать в голове. Эта предсказуемость — то, ради чего стоит дисциплинированно помечать pure каждую функцию-вычисление.

Детерминизм как фундамент параллелизма

Связь между чистотой и параллелизмом настолько важна для современного Fortran, что её стоит проговорить отдельно и до конца. Главная трудность любого распараллеливания — это разделяемое изменяемое состояние: если два потока одновременно читают и пишут одну переменную, результат зависит от непредсказуемого порядка их выполнения (это называют состоянием гонки). Бороться с этим вручную — через блокировки и синхронизацию — сложно и чревато ошибками. Чистота решает проблему радикально: если процедура ничего не разделяет и ни на что не влияет, кроме своих локальных данных, то двум её вызовам просто нечего делить, и гонке неоткуда взяться.

Поэтому конструкция do concurrent, которой программист сообщает «итерации этого цикла независимы, выполняй их в любом порядке или параллельно», требует, чтобы вызываемые в ней процедуры были чистыми. Компилятор должен иметь железную гарантию независимости итераций — иначе распараллеливание изменит результат. Атрибут pure и есть эта гарантия, выраженная в коде и проверенная компилятором. Таким образом, помечая функцию pure, вы не просто документируете её — вы открываете ей дорогу в параллельные и векторные контексты, недоступные обычным процедурам. В эпоху многоядерных процессоров это превращает скромный, казалось бы, атрибут в ключ к производительности.

Как работает под капотом

Почему чистота включает параллелизм, и как компилятор реализует elemental? Когда процедура объявлена pure, компилятор проверяет отсутствие запрещённых действий (ввод-вывод, изменение глобального/save-состояния, вызов нечистых процедур) и, убедившись, получает гарантию: вызов зависит только от аргументов и не взаимодействует с другими вызовами. Это снимает все вопросы о порядке: f(a) и f(b) можно вычислять одновременно, в любом порядке, на разных ядрах — результат тот же. Поэтому do concurrent требует чистоты вызываемых процедур. Для elemental компилятор фактически генерирует скрытый поэлементный цикл: вызов to_celsius(array) разворачивается в применение скалярного тела к каждому элементу с записью в соответствующий элемент результата. Поскольку тело чисто и скалярно, эти применения независимы — и компилятор волен их векторизовать теми же SIMD-инструкциями, что и встроенные функции. Так пользовательская elemental-функция получает ту же эффективность и удобство, что и sqrt или sin, без единой строчки про массивы в её теле.

Частые ошибки

  • Побочные эффекты в pure. print, запись в файл, изменение глобальных переменных внутри pure — ошибка компиляции.
  • Вызов нечистой процедуры из чистой. Чистая может вызывать только чистые; иначе теряется гарантия — компилятор это запрещает.
  • Массивные аргументы в elemental. Тело элементной процедуры должно оперировать скалярами; «массивность» обеспечивает сам механизм, а не ваш код.
  • Ожидание, что pure ускорит код. pure сам по себе не ускоряет — он включает оптимизации и параллелизм, давая компилятору свободу.
  • Забыть пометить функцию pure. Без атрибута её нельзя использовать в do concurrent и ряде массивных контекстов.

Итоги

  • pure-процедура не имеет побочных эффектов: результат зависит только от аргументов.
  • В pure запрещены ввод-вывод, изменение глобального/save-состояния и вызов нечистых процедур.
  • Только чистые процедуры можно вызывать в do concurrent — отсюда безопасный параллелизм.
  • elemental = pure + написана для скаляра, но применяется и к массивам поэлементно (как sin).
  • Элементными могут быть и функции, и субрутины; тело должно быть скалярным и чистым.
  • Эти атрибуты — не про скорость напрямую, а про предсказуемость, дающую компилятору свободу.
Проверьте себя
1. Что запрещено внутри pure-процедуры?
AАрифметика
BПобочные эффекты: print, запись в файл, изменение глобального состояния
CОбъявление локальных переменных
DВозврат значения
2. Что особенного даёт атрибут elemental?
AПроцедура становится быстрее в 10 раз
BПроцедура, написанная для скаляра, применяется и к массивам поэлементно
CПроцедура может менять глобальные переменные
DПроцедура становится рекурсивной
3. Почему только pure-процедуры можно вызывать внутри do concurrent?
AОни короче
BОтсутствие побочных эффектов гарантирует, что параллельное выполнение в любом порядке не изменит результат
CОни не принимают аргументов
DЭто произвольное ограничение