Исторически сложилось так, что модульное программирование в своём классическом понимании оказалось для большинства программистов землёй неизведанной, terra incognita. Из ведущих языков программирования на начало 2000 гг., пожалуй, только Delphi (со времён Turbo Pascal 4.x) имеет средства (unit), отдалённо приближённые к классике. Концепция модуля, кстати, отсутствует в таких известных языках, как Smalltalk-80 и Eiffel.
Как же так произошло? Дело в том, что доминирующая сейчас ветвь языков C-семейства (C, C++, Java, C#) не опиралась на эту концепцию, вытеснив её в конце 1980-х годов другой, возведённой в разряд абсолюта — объектно-ориентированным программированием (ООП). Туда же рванул и модный интерпретируемый Python.
Кстати, по состоянию на февраль 2024 г. в рейтинге TIOBE Index первая пятёрка популярных языков программирования выглядит именно так: Python (15,16%), C (10,97%), C++ (10,53%), Java (8,88%), C# (7,53%). Эти лидеры в сумме контролируют 53,07% всего корпуса интереса аудитории. На удивление, Modula-2 даже входит в Топ-100 рейтинга TIOBE. Чего нельзя сказать о Pascal и Oberon. Впрочем, диалект Delphi/Object Pascal в текущем рейтинге занимает 12-ю позицию (1,40%).
Если предельно упрощать, ООП опирается на идею совмещения в концепции класса (class) понятий модуля (module), типа (type) и механизма расширения (extension; наследования, обогащения). При этом в рамках ООП на основе классов непросто добиться полноценного воплощения возможностей триады «модуль-тип-расширение», исповедуемой, в частности, языками Oberon-семейства. Приходится добавлять понятие пространства имён (namespace), охватывающее вместо одного несколько файлов, да ещё и с поддержкой вложенности, вводить вместо понятного механизма экспорта-импорта запутанный набор средств по разграничению областей видимости (public, private, friend, protected и т. п.).
Модуль можно сравнить с устройством, имеющим средства сопряжения (подключения), через которые и происходит как управление самим устройством, так и обмен данными. Это своего рода чёрный ящик, для которого экспорт и импорт определяют его интерфейс и зависимость от других устройств.
Принципиальное отличие модулей от классов: модули — это уникальные экземпляры (второго такого в системе нет), не допускающие обобщения (generic-модули здесь не рассматриваем, они находятся вне подхода Вирта и в его языках не поддерживаются). Т.е. на их основе ничего порождать нельзя.
Модули не только определяют чёткие синтаксические границы кода (процедур) и данных, но также являются единицами этапа компиляции (unit of compilation) и единицами этапа загрузки (а также выполнения, замены — unit of replacement). Эти единицы можно редактировать, документировать, распространять и компилировать по отдельности. В модульном программировании связывание происходит на этапе загрузки (динамическое связывание, dynamic linking) и абсолютно невидимо пользователю.
Модуль — это контейнер для набора объектов, при этом он является средством абстрагирования кода и данных, ибо имеет две части: интерфейс (definition) и реализацию (implementation). Целостность данных и их защита обеспечивается в модулях за счёт сокрытия информации (information hiding, или инкапсуляции) и физического вычленения данных и кода из создаваемой системы (программы). Причём при сочленении модулей гарантируется соблюдение всех требований безопасности типов (type safety).
Интерфейс в модульном программировании рассматривается как жёсткая спецификация, контракт, который должен неукоснительно соблюдаться клиентами модуля (импортирующими его) и реализацией данного модуля (или несколькими альтернативными реализациями). Любое изменение контракта требует перекомпиляции всех зависимых модулей. Стоит отметить, что компилятор языка Oberon в системе ETH Oberon поддерживает режим расширения интерфейса, когда чистое расширение (добавление процедур) не влияет на ранее оттранслированные модули. В этом случае создаётся расширение бинарного представления интерфейса (символьного файла), что позволяет подстыковывать расширенную версию модуля без перекомпиляции ранее оттранслированных клиентских модулей.
Для модульного программирования характерно использование раздельной компиляции (separate compilation), когда компиляция интерфейса и реализации модуля делается отдельно от реализации других модулей, но с обязательным участием интерфейсов всех прямо и косвенно импортируемых модулей.
Высокая гибкость в модульном программировании достигается за счёт совмещения фазы компоновки и загрузки. А в некоторых случаях (реализация Oberon на уровне подхода Oberon Module Interchange, предложенного Микаэлем Францем в 1994 г.) совмещается на этом этапе (перед компоновкой) и фаза генерирования машинного кода для конкретной целевой архитектуры (то, что впоследствии после выхода Oberon получило распространение как JIT-компиляция).
Модуль (и его данные) существует всё то время, пока загружен в память. Область его видимости регулируется средствами явного экспорта-импорта идентификаторов (импорта модулей; экспорта процедур, типов, констант, переменных, отдельных полей составных переменных).
Инициализация всех модулей, затрагиваемых данной программой, производится в порядке, обратном их глубине зависимости по импорту. Другими словами, перед началом работы программы производится т. н. топологическая сортировка, которая позволяет выявить последовательность проведения инициализации в направлении от абсолютно независимых по импорту модулей к максимально зависимым.
Модуль на уровне своего содержимого устанавливает важный принцип No Paranoia Rule. Иными словами, внутри одного модуля можно размещать несколько классов, при этом общение между их полями и методами абсолютно прозрачно внутри, тогда как снаружи может регулироваться средствами избирательного экспорта (т.е. спецификаторами экспорта). Схема инкапсуляции в одном модуле строго одного класса практически соответствует общепринятой модели ООП. Иными словами, программист волен выбирать степень концентрации нескольких классов в одном модуле. Это важное преимущество классического Oberon по сравнению с другими языками ООП.
В языке Modula-2 для получения возможностей тонкого управления экспортом-импортом на локальном уровне (в рамках программных и исполнительных модулей) Вирт ввёл понятие локального модуля. Однако практика показала, что это излишний и запутанный механизм, который в языке Oberon был уже исключён.
В системе Oberon появилось весьма интересное решение, связанное с модульным программированием. Понятие программы исчезло. Вместо этого проф. Вирт ввёл концепцию команд (command). Команды — это экспортируемые процедуры без параметров, определяющие точки входа (вызова) программы. Другими словами, программа превратилась в модуль, экспортирующий по сути сервисы. В Oberon System команды становятся полноправными командами операционной системы, которые можно напрямую запускать, связывать в последовательность обработки и т. п. Любые модули (библиотечные и программные — разницы между ними нет) можно динамически загружать и выгружать (без перекомпиляции).
Чтобы детальнее разобраться в природе модульного программирования, центрального в философии проф. Вирта, давайте обратимся к истории.
До начала 1970-х годов программы создавались в виде монолитных блоков, либо делались из независимых частей, сопряжение которых было достаточно примитивным — на уровне вызовов подпрограмм (процедур). Отсюда и пошли два известных понятия — целостная компиляция (whole compilation) и независимая компиляция (independent compilation). Первый случай простой и пояснений наверняка не требует (транслируется вся программа целиком). Во втором каждый блок (файл) транслируется отдельно, фактически без наличия информации о точках сопряжения. Все проблемы увязки возлагались на компоновщик (linker). Именно он состыковывал оттранслированные части, соединял программу с библиотеками, которые та использовала.
Лобовое решение этой проблемы нашло своё отражение в языке C в виде знаменитых директив препроцессора (#include). Здесь проблема не решалась, а запрятывалась вглубь. Вместо строгого контроля и чёткого взаимодействия механизмов экспорта-импорта, выделения областей видимости и существования предлагалось использовать сокращённую запись операций скрытого расширения исходного текста на этапе, непосредственно предшествующем компиляции (препроцессинга).
Не буду вдаваться в подробности относительно проблем директивы #include, унаследованных другими языками C-семейства, особенно C++. Об этом написано предостаточно. См. напр., P.Moylan «The Case Against C» (1992), M. Sakkinen «The Darker Side of C++» (1992), I.Joyner «C++? A Critique of C++ and Programming and Language Trends of the 1990s» (1996).
В каноническом Pascal (в трактовке Вирта) данный вопрос вообще никак не решался, и это было безусловно ахиллесовой пятой языка. Но в конце 1970-х годов сразу три языка включили в свой арсенал эффективный механизм модуля. Это сделали CLU (1973, Барбара Лисков, Массачусетский технологический институт, США) — понятие кластера (cluster), Modula-2 (1979, Никлаус Вирт, ETH Zurich) — понятие модуля (module) и Ada (Джин Ихбиа и др., 1980, Министерство обороны США) — понятие пакета (package).
Однако, пожалуй, первым наиболее явно это сделал язык Mesa (1973, Джеймс Митчелл и др., Xerox PARC), — язык, положенный Виртом в основу Modula (1976), а потом и Modula-2 (1979) после года работы Никлауса Вирта (1976-1977) в стенах Xerox PARC.
Всем этим языкам предшествовали научные исследования, отчёты о которых выходили в очень компактный промежуток времени (1971-1974).
Сначала в апреле 1971 г. в Communications of the ACM появилась статья Никлауса Вирта «Разработка программ методом пошагового уточнения» (Niklaus Wirth «Program Development by Stepwise Refinement»). В ней не использовалось слово «модуль», но при этом на примере классической задачи по расстановке 8 ферзей на шахматной доске были сформулированы подходы к декомпозиции (разбиению) монолитной программы на набор задач (действий, инструкций), каждая из которых при очередном шаге уточнения получает всё более высокую степень конкретизации, при этом затрагивая конкретизацию и данных. Это устанавливало иерархию абстракций на уровне операций и данных.
В декабре 1972 г. в Communications of the ACM была опубликована статья Дэвида Парнаса из университета Карнеги-Меллон «О критериях по декомпозиции систем на модули» (David Parnas «On the Criteria To Be Used in Decomposing Systems into Modules»).
Помимо собственно декомпозиции системы важным критерием модуляризации Парнас назвал сокрытие информации (information hiding, «скрывать решение от других»). За счёт использования средств внешних модулей (слова «использует», «зависит от») формируется иерархическая структура, которая задаёт отношение частичного порядка, подразумевающего проведение топологической сортировки.
В работе «A History of CLU» Барбара Лисков (Barbara Liskov) из Массачусетского технологического института вспоминает: «В 1972 г. я предложила идею разделов (partitions). Система делится на иерархию разделов, каждый из которых представляет один уровень абстракции и состоит из одной или нескольких функций, оперирующих общими ресурсами… Связь на уровне данных между разделами ограничена использованием явных аргументов, передаваемых из функций одного раздела во (внешние) функции другого раздела. Неявное взаимодействие с общими данными осуществляется только среди функций, лежащих внутри соответствующего раздела…»
Далее она продолжает: «Это привело меня к пониманию связывания модулей с типами данных и к идее абстрактных типов, имеющих инкапсулированное представление и операции, которые могут быть использованы для доступа и манипулирования объектами… Я называла типы абстрактными, поскольку они не предоставляются напрямую языком программирования, а вместо этого должны реализовываться пользователем. Абстрактный тип является абстрактным точно в таком же смысле, как-то, что процедура является абстрактной операцией».
В начале 1973 г. Барбара Лисков на фоне разочарования работами по методологии программирования и зачатками модульного программирования в развитие идеи разделов создала новую концепцию — кластер (cluster), что и привело к образованию нового языка — CLU.
Концепция модуля как основы сокрытия информации (information hiding) тесно переплелась с концепцией абстрактных типов данных (ADT, abstract data types), поскольку введение различных уровней абстракции и обеспечивалось средствами контроля областей видимости со стороны модуля.
В октябре 1974 г. вышла статья Тони Хоара «Monitor: An Operating System Structuring Concept». Годом ранее в работе «Operating System Principles» Пер Бринч Хансен ввёл аналогичное понятие «shared». Впоследствии этой идее дали название мониторы Хансена-Хоара. Это особая форма модуля, в который заключены процедуры и соответствующие структуры данных, при этом доступ к модулю (его процедурам) для внешних процессов является взаимоисключающим.
Таким образом, к середине 1970-х годов понятие модуля стало обретать всё более ясные очертания, при этом сформировались две специфики его применения — мультипрограммирование (монитор) и абстрактные типы данных (кластер). Всё это нашло отражение в языке Modula-2, где роль мониторов выполняли модули с приоритетами (в них реализовывались драйверы устройств), а абстрактные типы данных воплощались в понятии скрытых типов (opaque type). Последние на уровне описательного модуля (DEFINITION MODULE) выглядели простым названием без объявления структуры, а на уровне исполнительного модуля (IMPLEMENTATION MODULE) реализовывались, как правило, указателями на запись (RECORD). В языке Oberon мониторы, как и другие средства мультипрограммирования Modula-2, вынесены за пределы языка, а абстрактные типы данных реализуются средствами частичного (избирательного) экспорта.
Руслан Богатырев из цикла "Никлаус Вирт. Заветы смиренного зодчего".