Submodules: разделение спецификации и реализации
Submodule отделяет реализацию процедур от их объявления, ускоряя пересборку и пряча детали без изменения публичного интерфейса.
Submodule — введённая в Fortran 2008 программная единица, которая содержит тела процедур, объявленных в родительском модуле как module procedure-интерфейсы; изменение submodule не требует перекомпиляции потребителей модуля.
Проблема, которую решают submodules
Обычный модуль смешивает две вещи: что он предоставляет (сигнатуры процедур) и как это реализовано (тела процедур после contains). Пока проект мал, это удобно. Но в крупной кодовой базе возникает болезненный эффект: любое изменение тела процедуры в модуле порождает новый .mod-файл, а значит компилятор считает устаревшими все единицы, которые делают use этого модуля, и пересобирает их — даже если публичный интерфейс не менялся ни на байт. В большом числовом коде, где базовые модули используются сотнями файлов, правка одной строки в реализации запускает каскадную перекомпиляцию, которая длится минутами. Это прямой налог на скорость разработки.
Submodules разрывают эту связь. Идея: в модуле оставить только интерфейсы процедур (объявления без тел), а сами тела вынести в отдельную единицу — submodule. Тогда .mod-файл модуля зависит лишь от интерфейсов. Меняя тело процедуры в submodule, вы пересобираете только этот submodule и линкуете заново — потребители модуля трогать не нужно, потому что для них ничего не изменилось. Так Fortran получил то, что в C++ дают пары «заголовок/реализация» (.h/.cpp), но с проверкой типов и без текстового препроцессора.
Объявление интерфейса в модуле
В родительском модуле тела процедур заменяются на interface body внутри блока interface, помеченного особым образом — через module subroutine / module function. Слово module здесь сигнализирует: «реализация будет предоставлена в submodule».
module solver
implicit none
private
public :: solve_linear
interface
module subroutine solve_linear(a, b, x, ok)
real, intent(in) :: a(:,:)
real, intent(in) :: b(:)
real, intent(out) :: x(:)
logical, intent(out) :: ok
end subroutine solve_linear
end interface
end module solver
Обратите внимание: после interface нет contains и нет тел — только сигнатура с ключевым словом module перед subroutine. Этот модуль полностью описывает контракт solve_linear, и потребитель может делать use solver и вызывать процедуру, хотя её тело ещё не написано в этом файле.
Реализация в submodule
Тело живёт в submodule, который объявляет свою принадлежность родителю синтаксисом submodule (родитель) имя. Внутри после contains процедура реализуется через module procedure (повторять список аргументов не нужно — он берётся из интерфейса родителя).
submodule (solver) solver_impl
implicit none
contains
module procedure solve_linear
! аргументы a, b, x, ok унаследованы из интерфейса в module solver
integer :: n
n = size(b)
! ... здесь настоящий алгоритм (например, метод Гаусса) ...
if (n <= 0) then
ok = .false.
return
end if
x = b ! заглушка-демонстрация
ok = .true.
end procedure solve_linear
end submodule solver_impl
Конструкция module procedure solve_linear ... end procedure — это и есть «здесь тело той процедуры, чей интерфейс объявлен в родителе». Список и типы аргументов не дублируются: единственный источник истины для сигнатуры — родительский модуль. Это устраняет риск рассинхрона, типичный для ручных блоков interface и для C-заголовков.
Иерархия и приватные детали реализации
Submodules образуют дерево: у модуля может быть несколько submodule, а у submodule — свои дочерние submodule (синтаксис submodule (parent:child) grandchild). Важное свойство: submodule видит все приватные сущности родителя и предков, но снаружи (для потребителей модуля) остаётся невидимым. Это идеальное место для вспомогательных процедур и данных, которые нужны реализации, но не должны попадать в публичный API. Вы можете завести в submodule приватные функции-хелперы, временные буферы, таблицы — и ничто из этого не утечёт наружу и не вызовет пересборку потребителей при изменении.
| Аспект | Модуль (интерфейс) | Submodule (реализация) |
| Что содержит | Сигнатуры module procedure | Тела процедур |
| Видим потребителям | Да (через use) | Нет |
| Изменение запускает пересборку потребителей | Да (если меняется интерфейс) | Нет |
| Доступ к приватным предка | — | Полный |
Как работает под капотом
Компилятор разделяет зависимости на два уровня. .mod-файл родительского модуля содержит только публичный интерфейс — он меняется, лишь когда меняются сигнатуры. Для submodule gfortran порождает дополнительный файл с описанием (.smod), фиксирующий связь с родителем. Поскольку потребители читают только .mod родителя, а он стабилен при правках реализации, каскад пересборки обрывается. Линковка же по-прежнему собирает объектные файлы submodule с остальными — поэтому новое тело процедуры попадает в финальный бинарник. По сути submodules переносят границу «что вызывает пересборку» с уровня «любое изменение модуля» на уровень «изменение публичного контракта», и это даёт основной выигрыш во времени сборки больших проектов.
Разрыв циклических зависимостей
Submodules помогают и с циклами. Если модуль A в реализации одной процедуры нуждается в модуле B, а B — в A, прямой use на уровне модулей создал бы цикл, который компилятор не разрешит. Но если перенести проблемные use в submodule (то есть в реализацию, а не в интерфейс), цикл на уровне интерфейсов исчезает: интерфейсы A и B самодостаточны, а зависимость между реализациями допустима, потому что submodule компилируется после обоих родительских .mod. Это законный приём разрыва взаимных зависимостей без искусственного «третьего модуля общих типов».
Сравнение с подходом C и преимущество единого источника
Полезно сопоставить submodules с тем, как разделение интерфейса и реализации устроено в C и C++. Там для этого служат пары файлов: заголовок (.h) с объявлениями и файл реализации (.c/.cpp) с телами. Механизм работает через текстовый препроцессор: #include буквально вставляет содержимое заголовка в каждую единицу компиляции. У этого подхода два врождённых изъяна. Первый — дублирование: сигнатуру функции пишут дважды (в заголовке и в реализации), и они могут разойтись, причём компилятор не всегда это поймает. Второй — отсутствие проверки: препроцессор не понимает семантику, он лишь подставляет текст, поэтому несоответствие объявления и определения вылавливается поздно и не всегда. Submodules Fortran лишены обоих изъянов. Сигнатура существует в единственном экземпляре — в родительском модуле; submodule лишь предоставляет тело через module procedure без повтора аргументов. Компилятор знает связь интерфейса и реализации семантически, а не через текстовую подстановку, и гарантирует их согласованность. По сути Fortran получил преимущества раздельной компиляции C, избавившись от его исторических болячек — это редкий случай, когда более молодая возможность языка спроектирована «правильно с первого раза», учтя чужой опыт.
Когда применять submodules, а когда нет
Submodules — инструмент для крупных проектов, и применять их повсеместно не нужно. Для маленького модуля из пары процедур разделение на интерфейс и реализацию только добавит файлов и церемоний без всякой пользы: время пересборки и так ничтожно, циклов нет, прятать нечего. Польза submodules проявляется при выполнении хотя бы одного из условий. Первое — масштаб сборки: базовый модуль используется десятками или сотнями файлов, и частые правки его реализации запускают мучительную каскадную перекомпиляцию. Вынос тел в submodule обрывает каскад. Второе — циклические зависимости: два модуля нуждаются друг в друге, и перенос проблемных use в реализацию разрывает цикл на уровне интерфейсов. Третье — сокрытие тяжёлых зависимостей: если реализация тянет громоздкую внешнюю библиотеку, держать её use в submodule означает, что потребители основного модуля не обязаны знать об этой зависимости и компилироваться с ней.
Есть и организационный аспект: submodules позволяют разным людям параллельно работать над интерфейсом и реализацией, а также держать несколько альтернативных реализаций одного интерфейса в разных submodule (хотя одновременно линкуется одна). На практике многие команды вводят простое правило: «модули — это контракты, submodules — это код». Публичный API проекта живёт в тонких модулях-интерфейсах, которые меняются редко и обдуманно, а вся изменчивая реализация — в submodules, правка которых дёшева. Такое разделение делает архитектуру наглядной: по составу модулей видно, что система предоставляет, а submodules можно перерабатывать, не тревожа этот публичный скелет. Для долгоживущего научного кода, который десятилетиями обрастает оптимизациями и новыми алгоритмами при стабильном интерфейсе, это очень ценное свойство.
Submodules в большой кодовой базе на практике
Чтобы закрепить ценность submodules, представим реальную ситуацию из жизни большого научного проекта. Допустим, есть фундаментальный модуль mesh (расчётная сетка), который используют буквально все остальные части кода — решатели, ввод-вывод, постобработка, граничные условия: сотни файлов содержат use mesh. Однажды вы оптимизируете внутренний алгоритм одной из процедур сетки — скажем, ускоряете поиск соседних ячеек, меняя лишь тело процедуры, но не её сигнатуру. Без submodules компилятор, увидев новый .mod-файл модуля mesh, пометит как устаревшие все сотни зависящих файлов и пересоберёт их — полная пересборка проекта, минуты или десятки минут ожидания на каждую такую правку. При итеративной оптимизации, где вы вносите десятки мелких изменений и каждый раз перезапускаете, это превращается в часы простоя. С submodules картина иная: сигнатуры процедур сетки живут в тонком модуле mesh, а их тела — в submodule (mesh) mesh_impl. Правка тела затрагивает только этот submodule; его пересборка и перелинковка занимают секунды, а сотни потребителей не трогаются вовсе, потому что для них интерфейс не изменился. Разница между минутами и секундами на каждой итерации радикально меняет продуктивность: оптимизация, профилирование, эксперименты с реализацией становятся быстрыми и приятными вместо мучительных. Именно поэтому крупные Fortran-проекты, перейдя на submodules, нередко сообщают о кратном сокращении времени инкрементальной сборки. Это та возможность, чья польза почти незаметна на маленьких примерах, но становится решающей ровно тогда, когда проект вырастает до размера, при котором время сборки начинает диктовать темп работы команды. Стратегически правильное вложение — заложить разделение «интерфейс-модуль + реализация-submodule» для ключевых, широко используемых модулей с самого начала, пока проект ещё мал и рефакторинг дёшев.
Частые ошибки
- Дублировать список аргументов в submodule. В
module procedure имяаргументы не повторяют — они наследуются из интерфейса. Повтор — лишний риск рассинхрона и часто ошибка компиляции. - Забыть слово
moduleпередsubroutine/functionв интерфейсе. Без него это обычный внешний интерфейс, и связать его с submodule не получится. - Ожидать, что submodule виден снаружи. Он принципиально невидим потребителям; туда кладут детали, а не публичный API.
- Игнорировать порядок сборки. Submodule компилируется после родительского модуля (нужен его
.smod/.mod). Система сборки должна это учитывать.
Итоги
- Submodule отделяет реализацию от интерфейса: модуль хранит сигнатуры, submodule — тела.
- Главный выигрыш — правка тела не пересобирает потребителей модуля (обрыв каскада компиляции).
- Сигнатуру объявляют как
module subroutine/functionвinterface, тело — какmodule procedureвsubmodule (родитель) имя. - Submodule видит приватные сущности предков, но невидим снаружи — идеальное место для деталей реализации.
- Перенос
useв submodule помогает разрывать циклические зависимости между модулями.