Модули: module, use и контроль доступа

Модуль — это единица, которая собирает данные, типы и процедуры в одно именованное пространство и предоставляет их другим частям программы.

module — программная единица Fortran, которая инкапсулирует объявления (переменные, параметры, производные типы, интерфейсы) и процедуры, делая их доступными через оператор use с проверкой типов на этапе компиляции.

Зачем вообще нужны модули

До Fortran 90 крупные программы держались на двух подпорках: COMMON-блоках для разделяемых данных и текстовых include-файлах для общих объявлений. Обе подпорки были опасны. COMMON-блок — это просто кусок памяти, который разные подпрограммы интерпретировали каждая по-своему: если в одной подпрограмме блок описан как массив из 100 вещественных, а в другой — как 50 целых и 50 вещественных, компилятор молчал, а программа считала мусор. Согласованность раскладки памяти лежала целиком на программисте, и любая правка грозила тихой катастрофой в дальнем углу проекта.

Модуль решает обе проблемы разом. Он даёт единственный источник истины: тип, параметр или процедуру вы описываете один раз внутри модуля, а все потребители получают ровно то же определение через use. Компилятор знает интерфейс каждой процедуры модуля и проверяет каждый вызов: число аргументов, их типы, ранг массивов, намерения. Ошибка, которая в эпоху COMMON проявлялась как загадочный сбой во время счёта, теперь ловится на компиляции. Именно поэтому современный Fortran — это в первую очередь Fortran модулей: новый код почти не содержит ни COMMON, ни внешних процедур без интерфейса.

Анатомия модуля

Модуль состоит из двух частей. Сначала идёт спецификационная часть: объявления типов, констант, переменных уровня модуля. Затем — после ключевого слова containsчасть процедур: функции и подпрограммы, которые модуль предоставляет. Разберём небольшой модуль геометрии.

module geometry
  implicit none
  real, parameter :: pi = 3.14159265358979_8

contains

  pure function circle_area(r) result(area)
    real, intent(in) :: r
    real :: area
    area = pi * r**2
  end function circle_area

  pure function circle_circumference(r) result(c)
    real, intent(in) :: r
    real :: c
    c = 2.0 * pi * r
  end function circle_circumference

end module geometry

Здесь pi — именованная константа уровня модуля, доступная всем процедурам внутри и (по умолчанию) всем, кто подключит модуль. Две функции вычисляют площадь и длину окружности. Слово contains — это граница: всё до него описывает «что хранит модуль», всё после — «что модуль умеет». Обратите внимание на implicit none в начале: его стоит ставить в каждом модуле, чтобы отключить старое неявное правило типизации и заставить компилятор требовать явного объявления всех имён.

Оператор use и его уточнения

Потребитель подключает модуль оператором use. В простейшей форме это втягивает все публичные сущности модуля в текущую область видимости.

program areas
  use geometry
  implicit none
  print *, "Площадь круга r=2: ", circle_area(2.0)
  print *, "Число pi: ", pi
end program areas

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

use geometry, only: circle_area, pi

Теперь видны лишь circle_area и pi; circle_circumference остаётся «снаружи». Списком only стоит пользоваться по умолчанию в серьёзных проектах: он документирует зависимости (видно, что именно код берёт из модуля) и защищает от случайных коллизий при росте библиотек.

Второе уточнение — переименование через =>. Если имя из модуля конфликтует с локальным или просто неудобно, его можно подключить под другим именем.

use geometry, only: area => circle_area
! теперь функция доступна как area(...)

Переименование и only комбинируются в одном операторе, и именно так разрешают коллизии между библиотеками: use lib_a, only: solve => lib_a_solve и use lib_b, only: solve => lib_b_solve дали бы два разных имени, но это сделать нельзя в одной области для одного имени — для разных модулей используют разные псевдонимы.

public и private: проектирование интерфейса

По умолчанию всё, что объявлено в модуле, имеет атрибут public — видно снаружи. Но хорошая библиотека прячет внутренности. Атрибут доступа private делает сущность видимой только внутри модуля. Грамотный приём — поставить private по умолчанию для всего модуля, а затем явно открыть лишь то, что составляет публичный API.

module stats
  implicit none
  private                       ! всё закрыто по умолчанию
  public :: mean, variance      ! открываем только это

  real, parameter :: tiny_eps = 1.0e-12   ! приватная деталь

contains

  pure function mean(x) result(m)
    real, intent(in) :: x(:)
    real :: m
    m = sum(x) / max(size(x), 1)
  end function mean

  pure function variance(x) result(v)
    real, intent(in) :: x(:)
    real :: v, m
    integer :: n
    n = size(x)
    if (n <= 1) then
      v = 0.0
      return
    end if
    m = mean(x)
    v = sum((x - m)**2) / (n - 1)
  end function variance

end module stats

Снаружи доступны только mean и variance. Константа tiny_eps и любые вспомогательные процедуры остаются скрытыми. Это даёт свободу: внутреннюю реализацию можно переписать, не сломав ни одного потребителя, потому что граница API зафиксирована явно. Такой стиль — «private by default, public по списку» — считается признаком зрелого модуля.

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

Когда компилятор обрабатывает модуль, он порождает не только объектный код процедур, но и особый файл с описанием интерфейса — у gfortran это файл .mod (по сути сериализованное дерево объявлений). Когда другая единица компиляции пишет use geometry, компилятор читает geometry.mod и узнаёт сигнатуры всех публичных сущностей. Отсюда два практических следствия. Во-первых, порядок компиляции важен: модуль должен быть скомпилирован раньше тех, кто его использует, иначе .mod-файла ещё нет. Системы сборки (make, fpm, CMake) выстраивают граф зависимостей именно по операторам use. Во-вторых, изменение публичного интерфейса модуля делает устаревшими все зависящие объектные файлы — их нужно перекомпилировать, и хорошая сборочная система это отслеживает.

Важно не путать .mod (информация для компилятора) с библиотекой или объектным файлом (исполняемый код). .mod нельзя «слинковать» — это чисто метаданные. При раздаче скомпилированной библиотеки поставляют и .mod-файлы (чтобы пользователь мог делать use), и собственно объектный код или статическую/динамическую библиотеку (чтобы линковщик нашёл тела процедур).

Переменные уровня модуля и их время жизни

Помимо процедур, типов и констант, модуль может объявлять обычные переменные — и это мощный, но коварный инструмент. Переменная, объявленная в спецификационной части модуля, существует всё время работы программы (имеет атрибут save неявно) и разделяется между всеми, кто подключает модуль. По сути это глобальная переменная, но с проверкой типов и контролем доступа — куда безопаснее старого COMMON. Такие переменные удобны для общих настроек, разделяемых таблиц, счётчиков. Однако глобальное состояние всегда несёт риск: если две процедуры из разных мест меняют одну модульную переменную, отследить логику становится трудно, а в многопоточном или параллельном коде это прямой источник гонок. Поэтому модульные переменные стоит делать private и менять только через процедуры модуля, инкапсулируя состояние. Ещё одно правило безопасности — инициализатор в объявлении (integer :: counter = 0) выполняется один раз при старте программы, а не при каждом обращении; это удобно, но важно понимать, что повторного обнуления не происходит.

Отдельного упоминания заслуживают именованные константы (parameter) уровня модуля. В отличие от переменных, они не занимают изменяемой памяти и вычисляются на этапе компиляции, поэтому их публичность безопасна и даже желательна: математические постоянные, размерности, коды состояний удобно держать в модуле и импортировать. Хороший приём — собрать все «магические числа» проекта в один модуль констант, чтобы изменить значение в единственном месте. Именно так оформляют, например, модуль с параметром точности dp или модуль физических постоянных, к которому обращается весь расчётный код.

Модули как единица проектирования

За техникой стоит более глубокая идея: модуль — это не просто контейнер, а единица архитектуры. Грамотно спроектированный модуль обладает высокой связностью (всё внутри относится к одной теме) и слабой связанностью с другими (минимум зависимостей через use). Когда вы разбиваете большую программу на модули, вы фактически проводите границы ответственности: модуль сетки, модуль решателя, модуль ввода-вывода, модуль граничных условий. Каждый скрывает свои детали и предоставляет узкий, осмысленный интерфейс. Это даёт три практических выигрыша. Во-первых, понятность: чтобы разобраться в части системы, достаточно прочитать один модуль и его публичный API, а не всю программу. Во-вторых, тестируемость: модуль с чётким интерфейсом легко покрыть тестами в изоляции. В-третьих, заменяемость: реализацию модуля можно переписать (сменить алгоритм, оптимизировать), не трогая остальной код, пока сохраняется контракт. Эти принципы — те же, что в любой развитой инженерии ПО, и современный Fortran полностью их поддерживает. Поэтому проектирование начинают не с процедур, а с вопроса «какие модули и какие границы между ними», а уже потом наполняют каждый модуль реализацией. Такой подход отличает поддерживаемый научный код от «скрипта, который разросся» — и именно модульная декомпозиция позволяет большим расчётным проектам жить и развиваться десятилетиями силами сменяющихся команд.

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

  • Забыть implicit none в модуле. Без него опечатка в имени переменной создаёт новую переменную неявного типа, и ошибка уходит в рантайм. Ставьте implicit none сразу после module имя.
  • Полагать, что use без only «дешевле». На производительность это не влияет — use не копирует код, а лишь делает имена видимыми. Но без only вы рискуете коллизиями и теряете читаемость зависимостей.
  • Циклические зависимости модулей. Если модуль A использует B, а B использует A, компилятор не сможет построить ни один .mod первым. Разорвать цикл помогает либо вынесение общих типов в третий модуль, либо механизм submodules (о нём — отдельный урок).
  • Менять публичный интерфейс и забывать пересобрать потребителей. При ручной компиляции это даёт рассинхрон .mod и объектных файлов и странные ошибки линковки. Используйте систему сборки, которая отслеживает зависимости.

Итоги

  • Модуль — современная замена COMMON-блокам и include: единый источник истины с проверкой типов на компиляции.
  • Структура: спецификация (типы, константы, переменные) → contains → процедуры.
  • use подключает публичные сущности; only ограничивает импорт, => переименовывает и разрешает коллизии.
  • Стиль «private by default + public по списку» фиксирует API и даёт свободу менять реализацию.
  • Под капотом — .mod-файл с метаданными; отсюда требование правильного порядка компиляции.
Проверьте себя
1. Что делает оператор use geometry, only: circle_area, pi?
AИмпортирует все публичные сущности модуля geometry
BИмпортирует только circle_area и pi, остальное оставляет недоступным
CСоздаёт копию модуля geometry в текущей программе
DДелает circle_area и pi приватными в модуле geometry
2. Зачем в начале модуля часто пишут private, а затем public :: ...?
AЧтобы ускорить компиляцию модуля
BЧтобы все процедуры стали недоступны снаружи
CЧтобы по умолчанию закрыть всё и явно открыть только публичный API
DЭто устаревший синтаксис из Fortran 77