Чистые и элементные процедуры
Атрибуты 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).- Элементными могут быть и функции, и субрутины; тело должно быть скалярным и чистым.
- Эти атрибуты — не про скорость напрямую, а про предсказуемость, дающую компилятору свободу.