Губанов С. Ю. Секреты модульных систем
(C) 2006, Губанов С.Ю.
Для публикации текста требуется письменное разрешение автора.
Разнообразие в толкованиях понятий модуль и модульный язык программирования порождает споры. Что только еще ни было названо модулем, какой язык программирования еще ни был назван модульным. Кто-то называет модулем class, кто-то другой сравнивает модули с объектами. Вообще, создается такое впечатление, что сейчас модно быть модульным и компонентным. Причина различных толкований модульности заключена в самом способе которым оно зачастую дается — внутренним способом. Внутренний — в том смысле, что определение дается на основании каких-либо внутренних свойств (модуль агрегирует данные, код, типы; обеспечивает инкапсуляцию; обладает интерфейсом и т. п.). Существует много способов выделить ряд существенных внутренних свойств присущих модулям, значит существует много способов дать определение модульности. В этом корень проблемы. В будущем количество внутренних свойств, возможно, изменится в большую сторону — появится еще больше способов давать различные определения, стало быть, поводов для споров будет еще больше. Это тупик. Выход в смене внутреннего определения на внешнее.
Внешнее — в том смысле, что сначала надо описать что такое модульная система, а понятие модуля возникнет само (автоматически) как компонента модульной системы. Модульная система — первична, модуль — вторичен. Модуль — он не сам по себе, а модуль системы. Тот язык программирования следует называть модульным языком, который специально предназначен, удобен, оптимизирован, приспособлен, «заточен» для написания модульных систем. Какова модульная система, таковы модули и языки программирования, предназначенные для написания этих систем. Итак, смысл модульных языков программирования — написание модульных систем. Смысл модульных систем раскрывается далее.
Модульные программные системы пришли на смену монолитным программам. Смысл использования модульных систем в отличие от монолитных программ — динамическая расширяемость. Динамически расширять функциональность модульной системы можно даже двумя путями. Во-первых, добавляя в неё новые модули. Во-вторых, заменяя в ней старые модули новыми с расширенными возможностями или с какими-то другими усовершенствованиями. Расширяемая модульная система ни когда не завершена, она эволюционирует. Какие-то модули в ней заменяются другими, какие-то новые динамически добавляются. Время жизни всей системы может быть больше чем время жизни каждого из её компонентов. Пользователь системы сам решает как её расширять. В идеале, существует рынок компонентов системы - различные производители предлагают свои реализации модулей, их-то и приобретает пользователь системы. Отсутствие рынка компонентов может препятствовать самой идее перехода от монолитных программ к модульным системам. Но, спрос рождает предложение.
Рассмотрим устройство (динамически расширяемой) модульной системы. Взаимодействие между модулями осуществляется благодаря механизму импорта одним модулем нескольких других — тех которые он использует, т. е. является их клиентом. Граф импорта модулей всегда ацикличен. Ацикличность графа импорта связана с тем, что модули являются единицами загрузки (и исполнения), в то время как в случае циклической зависимости единицей загрузки, исполнения и выгрузки был бы весь цикл, то есть модулем (компонентом системы) следовало бы тогда называть его целиком. Модульная система подразумевает наличие среды времени исполнения, одна из задач которой — динамическая загрузка (позднее связывание), исполнение и выгрузка модулей. Другая задача — контроль за целостностью: проверка правильности динамического приведения типов, динамическая проверка индексов массивов и т. д.. Возможность динамической выгрузки модулей необходима для замены старых модулей новыми. Кстати, модуль нельзя выгрузить из системы до тех пор, пока не выгружены все его клиенты. Хотя это и очевидно, но почему-то иногда об этом забывают. С загрузкой и выгрузкой модулей можно сравнить построение башни из кубиков, разбирать которую надо сверху, а не выдёргивать кубик из середины, иначе она рухнет. Так же очевидно, что выгрузить модуль можно только по явной команде, а не автоматически на основании отсутствия у него клиентов - сейчас клиентов нет, а через некоторое время появятся, состояние же модуля должно сохраниться. Добавление нового или замена старого модуля не должно приводить к разрушению целостности системы. Поскольку модули могут поставляться различными производителями, то следить за совместимостью модулей друг с другом должна сама среда времени исполнения и не допускать загрузку несовместимых модулей. Совместимость устанавливается путём анализа изменений интерфейса модуля. Модуль агрегирует в себе все программные сущности: константы, типы, переменные, процедуры. Некоторые программные сущности модуль делает доступными для клиентов — экспортирует, остальные скрывает — инкапсулирует. Кстати, такую инкапсуляцию можно считать самой сильной, ибо единственный способ ее нарушения — изучение бинарного кода модуля и небезопасные манипуляции с ним. Совокупность экспортируемых модулем сущностей определяет его интерфейс. Посредством него осуществляется взаимодействие с клиентами. Если два модуля (пусть даже от разных производителей) имеют одинаковый интерфейс, то они взаимозаменяемы. Если из двух модулей второй имеет расширенный по сравнению с первым интерфейс (т. е. строго включает в себя интерфейс первого модуля, плюс к этому экспортирует дополнительные сущности), то второй модуль взаимозаменяем с первым.
Остановимся более подробно на вопросе: как среда времени исполнения может принимать решение о совместимости модулей? Самый простой способ — помечать каждый модуль какой-либо меткой о его версии (будь-то, явно указанная версия, или время последней компиляции — timestamp). Старые модульные системы использовали механизм меток — timestamp. Но явное указание версии более предпочтительно, поскольку более поздний timestamp говорит лишь о том, что данный модуль скомпилировали в более позднее время, но не говорит о том, что его интерфейс реально был изменён. Однако техника выставления какой-либо метки версии модулю обладает следующим серьёзным недостатком. Допустим, новый модуль отличается от старого модуля только тем, что в нём изменена весьма небольшая часть интерфейса (например, поменяли значение одной экспортируемой константы, а всё остальное оставили без изменений). Формально, этот модуль теперь должен быть помечен другой версией. Но существуют такие модули-клиенты, которые используют только неизменившуюся часть интерфейса. Из-за смены версии всего модуля все модули-клиенты будут инвалидированы — среда времени исполнения откажется загружать их (запускать на выполнение) совместно с изменённым модулем. Такой простой механизм проверки совместимости модулей необоснованно жесток со стороны среды времени исполнения, которая будет инвалидировать все модули-клиенты без разбора. К счастью, существуют грамотные способы проверки совместимости модулей. Секрет такой проверки состоит в том, что каждая экспортируемая модулем сущность (каждая константа, каждая переменная, каждый тип, каждая процедура) снабжается своей собственной меткой — «отпечатком пальцев» (fingerprint) получаемой как некая «контрольная сумма» вычисленная над определёнными структурными инвариантными свойствами данной сущности. Найти правильный способ вычисления fingerprint-меток, вообще говоря, непростая задача, особенно для сложных расширяемых типов данных. Более десяти лет назад в ETH на тему грамотного вычисления fingerprint-меток была защищена докторская диссертация (Regis Crelier «Separate Compilation and Module Extension», научные руководители: Вирт и Мёссенбёк — соавторы языка Oberon-2). Отсутствие контроля со стороны среды времени исполнения за совместимостью модулей может привести к непредсказуемым последствиям. Так, например, среда времени исполнения (просто загрузчик) модульной системы MS Windows, в которой модулями являются DLL-файлы (Win32 DLL) не проверяет совместимость модулей. Как следствие, существует, так называемый, печально известный «dll hell».
Создавать модульные системы, в принципе, можно на любых языках программирования. Скажем, модульная система Win32 DLL может быть запрограммирована на очень большом количестве языков. Однако едва ли есть хоть один язык, который был бы для этого специально приспособлен. Здесь обратите внимание на разницу между лёгкостью создания одного DLL-модуля (это-то, как раз, как правило, очень легко, так как всю заботу обычно берёт на себя интегрированная среда разработки) и сложностью написания системы, скажем, из нескольких десятков или даже сотен динамически загружаемых и выгружаемых DLL-модулей. Сонмище вызовов функций LoadLibrary, GetProcAddress никак нельзя назвать удобством, и язык, использующий их, нельзя считать специально предназначенным для написания модульной системы Win32 DLL («статическая» же линковка является полумерой — выгрузить и заменить статически прилинкованный модуль нельзя). Как уже было сказано, модульными языками программирования естественно называть только такие языки, которые удобны, специально предназначены, приспособлены, «заточены» для написания соответствующих модульных систем, в этом их смысл. Но, как мы только что убедились, модульные системы, вообще говоря, можно создавать и на языках не предназначенных для этого (вопрос лишь в трудозатратах). Как это ни парадоксально, но существует и обратная ситуация. Есть языки программирования, которые, казалось бы, очень подходят для написания не только монолитных программ, но и модульных систем. Но в то же самое время не существует ни одной модульной системы для написания которой они были бы предназначены. Имеется ввиду язык Delphi. Казалось бы, такие синтаксические конструкции как: unit, interface, implementation, uses; были бы очень полезны при создании модульных систем, модулями которых были бы юниты, точнее результаты их компиляции — dcu-файлы (Delphi compiled unit). Но, к сожалению, dcu-файлы не являются модулями, ни какой модульной системы, они «полуфабрикаты». Не смотря на то, что все внешние синтаксические атрибуты модульности у этого языка есть, но его невозможно считать модульным, так как сейчас в мире не существует ни одной модульной системы, для написания которой он был бы специально приспособлен. Если, гипотетически, когда-нибудь в будущем будет создана специальная среда времени исполнения, динамически загружающая, исполняющая и выгружающая dcu-файлы, вот только тогда язык Delphi можно будет называть модульным — в смысле специально предназначенным, удобным, «заточенным» для написания этой гипотетической модульной системы. Возможно, такие умозаключения покажутся как минимум неожиданными, но они следуют из определённого в начале этой заметки смысла модульных языков. Специальные синтаксические атрибуты модульности конечно необходимы, но не достаточны, поэтому никакого парадокса здесь на самом деле нет.
Хотя наличие специальных синтаксических атрибутов модульности в языке программирования ещё не достаточно для того, чтобы называть его модульным, но они необходимы. Необходимы они для того, чтобы язык был приспособлен для написания модульных систем. В модульном языке программирования синтаксически должны быть выражены, как минимум, три вещи: сам модуль, средство импорта модулей, средства экспорта программных сущностей из модуля. Для контроля за целостностью модульной системы на как можно более ранней стадии, должна применяться раздельная (separate) компиляция, а не независимая (independent). Просто очевидно, что модуль должен быть единицей компиляции. Иначе в чём удобство? Сущности внутри модуля сильно связаны. Если сделать некую более мелкую единицу компиляции чем модуль, то (с учётом раздельной компиляции) придётся придумывать некие скрытые интерфейсы для взаимодействия этих сущностей друг с другом. Это явно лишняя сложность (быть может, даже уязвимость), да и просто смысла в этом нет.
Программирование модульных систем, вообще говоря, не требует обязательного использования объектно-ориентированного подхода и наоборот. Однако, одновременное использование этих двух подходов для построения расширяемых систем привело к появлению новой парадигмы программирования. Дело в том, что ООП, в его наиболее распространённой трактовке, основано на понятии наследования. При совместном использовании ООП и модульности возникнет межмодульное наследование, но оно является препятствием к взаимозаменяемости модулей от различных производителей. Если часть типа определена в одном модуле, а другая часть типа — в другом, то весьма проблематично модуль с базовым типом поменять на аналогичный модуль от другого производителя из-за возможных тонких различий в реализации. В 1995 году, профессор Клеменс Шиперски (Clemens Szyperski) сформулировал основные положения Компонентно-ориентированного подхода (КОП) через ограничения, накладываемые на модульный подход и на ООП для их непротиворечивого одновременного сосуществования («Component-Oriented Programming A Refined Variation on Object-Oriented Programming», The Oberon Tribune, Vol 1, No 2, December 1995). Цель КОП — построение расширяемых (в полном смысле этого слова) систем с помощью модулей и ООП. Таким образом, КОП находится (по мнению Шиперского) «За пределами ООП» (так называется его уже дважды изданная за рубежом книга-бестселлер, к сожалению, до сих пор не переведённая на русский язык). Фундамент КОП образуют: полиморфизм, позднее связывание, настоящая инкапсуляция, полный контроль безопасности со стороны среды времени исполнения. Среда времени исполнения теперь обязательно осуществляет автоматическую сборку мусора. Автоматический сбор мусора — это не роскошь, а необходимость — одна из гарантий целостности компонентной системы (вспомним, что компоненты системы — модули создаются, вообще говоря, разными производителями). Теперь, обратите внимание на то, что понятие «наследование» (т. е. расширение типа), так распространённое в ряде современных ООП языков, не включено в фундамент КОП вовсе. Объяснение этому тривиальное. Если расширение типов используется внутри модулей, то это обычное ООП — там свои правила. Если же используется межмодульное расширение типов, то модули, типы между которыми связаны отношением наследования, становятся более жестко сцепленными друг с другом. Трудно заменить модуль с базовым типом на аналогичный модуль от другого производителя не инвалидировав при этом модули-клиенты, где этот тип расширен. КОП рекомендует использовать межмодульное наследование только от абстрактных типов (интерфейсов) — это разумный компромисс между ООП и модульными системами. Но программист, конечно, вправе сам решать, стоит ли ему в его конкретной системе использовать межмодульное наследование типов без ограничений «по полной программе» (тем самым жестко сцепляя модули) или же предпочесть вариант взаимозаменяемых расширяемых модулей, создаваемых различными производителями.
В рамках КОП, слово «компонент» становится термином. Компонентная система (в трактовке КОП) есть разновидность (частный случай) модульной системы, в которой используется ООП. Поскольку, сейчас ООП используется практически везде, то разница между словами компонентная или модульная система практически отсутствует. С другой стороны, слово «компонент» сильно перегружено значениями, поэтому можно услышать массу других толкований компонентности. Так как это сейчас модно, то каждый толкует на свой лад. Скажем, в Delphi есть свои так называемые «компоненты» не имеющие никакого отношения к модульным системам.
Завершая эту заметку, рассмотрим, какие компонентные системы мы имеем на сегодняшний день. Компания Microsoft реализовала своё собственное видение компонентной системы — платформу .Net с каноническим для этой платформы языком программирования C#. Строго говоря, система .Net вообще не является динамически расширяемой модульной системой, поскольку в ней просто отсутствует возможность выгружать модули. .Net системы скорее похожи на монотонно растущие монолитные программы с докомпиляцией и догрузкой частей по мере надобности. Ситуация, на самом деле более сложная, поскольку в .Net есть разница между единицей компиляции (модуль dll или exe) и единицей загрузки (сборка). Сборка — логическое объединение нескольких модулей в одну подсистему. Чтобы не запутаться в разнице между dll и exe-модулями, а также сборками, для простоты, но без потери общности, далее предполагается, что сборка состоит из одного модуля, и между ними нет разницы. Случай с несколькими модулями внутри одной сборки ничего принципиально не меняет (кроме добавления сложности), поэтому дальше речь пойдет только о модулях. Так вот, среда времени исполнения .Net для проверки совместимости модулей не пользуется описанным выше fingerprint механизмом. Вместо этого используется техника явного указания номера версии модуля. Это, конечно, лучше, чем ничего (как это было в Win32 DLL), но создаёт свои проблемы. Например, если .Net модуль кроме всего прочего экспортирует константу (которую, быть может, мало кто использует), то, внеся изменение в интерфейс этого модуля — поменяв значение константы, надо поменять и номер версии модуля, тем самым инвалидировав все модули-клиенты (даже те, которые этой константой не пользовались). Образно говоря, это «dll hell наоборот». Если в случае обычного Win32 DLL загрузчик все модули считал совместимыми, то в случае .Net загрузчик слишком многое будет считать несовместимым (надо же менять номер версии всего модуля при малейшем внесении изменений в его интерфейс). В языке C#, который, казалось бы, по своей логике должен быть специально предназначен для программирования модульных систем, отсутствует само понятие модуля на синтаксическом уровне (стало быть, и импорта модулей тоже). Глядя на исходный текст модуля на языке C# невозможно определить: а) какие модули он импортирует; б) в каком модуле определён тот или иной использованный в этом модуле идентификатор. Применяемый механизм namespace — не связан с именами модулей. С# — язык «старого образца», удобный для программирования монолитных программ и не приспособленный для создания модульных систем. Хотя, возможно, так строго судить язык C# напрасно. Ведь .Net система больше похожа на монотонно растущую монолитную программу, чем на динамически расширяемую модульную систему. Для удобства программирования «на больших масштабах» предлагается использовать не средства языка, а вспомогательные инструменты интегрированной среды разработки.
Существуют компонентные системы и компонентные языки программирования, практически свободные от недостатков. Может вызвать удивление, но первые модульные системы и языки появились еще в конце 70-тых годов прошлого века. Например, это операционная система для компьютера Lilith написанная на языке Modula-2 профессором Никлаусом Виртом. Затем, им же и Юргом Гуткхнетом была создана операционная система Oberon (на одноимённом языке Oberon), которую уже можно считать компонентной, хотя работа над ней началась еще в 1985 году, т. е. за 10 лет до формального провозглашения принципов КОП. В наши дни, например, под Windows существует бесплатно распространяемая (с открытыми исходными текстами) компонентная среда BlackBox Component Builder производства Швейцарской компании AG Oberon Microsystems, Inc основанной в 1994 году. Никлаус Вирт входит в состав совета директоров, а одним из сооснователей этой компании является «идеологический отец» КОП — профессор Клеменс Шиперски (правда, в настоящее время он трудится на должности software architect в Microsoft Research). Изначально система BlackBox носила имя Oberon/F и разрабатывалась на языке Oberon-2 для MacOS и Windows одновременно (кросплатформенная система). Затем язык Oberon-2 был немного модифицирован в согласии с модным направлением КОП. Новый язык получил название Component Pascal (1997 г) — это промышленный вариант языка Oberon-2. В 2004 году вариант BlackBox под Windows был объявлен бесплатным и были открыты его исходные тексты. Добровольцами ведутся работы по созданию варианта BlackBox под Linux (уже есть альфа версия). Сама Oberon Microsystems в настоящее время ведет разработку следующей версии системы. В BlackBox создана система, осуществляющая наблюдение за крупнейшей на планете ГЭС, расположенной на Амазонке (для этого был создан специальный вариант BlackBox под 64-битный UNIX). По заказу компании Borland компания Oberon Microsystems написала для неё JIT-компилятор Java. В Oberon Microsystems на Component Pascal в BlackBox создана операционная система жесткого реального времени Portos (не путать с PortOS — совершенно другая операционная система, случайно названная аналогично и не имеющая ни какого отношения к рассматриваемым здесь вопросам). После создания Portos от Oberon Microsystems отпочковалась дочерняя компания Esmertec. Она переименовала Portos в JBed. Система JBed больше известна как операционная система реального времени для встраиваемых систем «написанная» на Java. Учитывая раскрученность Java и слабую известность Component Pascal надо признать, что это удачный маркетинговый ход. У нас в России, недавно появилась первая компания ООО «Метасистемы» (г. Орёл) объявившая об использовании BlackBox Component Builder в качестве основного средства разработки своих программных продуктов. Существует международный проект «Информатика 21», одной из целей которого является продвижение системы BlackBox в школы на смену морально устаревшего Turbo Pascal. Если попытаться ответить на вопрос, что лучше BlackBox или .Net & Java, то надо учесть, что сложность есть уязвимость, а .Net & Java гораздо сложнее. Время всё расставит на свои места. Кроме промышленной системы BlackBox существует ряд исследовательских академических модульных систем. Хотелось бы отметить операционную систему Aos BlueBottle — первая и пока единственная операционная система, построенная на базе активных объектов, живущих непосредственно поверх «железа». Она целиком написана на высокоуровневом языке Active Oberon, со сборкой мусора и т. п. Его среда времени исполнения была реализована как раз поверх «железа». Впрочем, это уже совсем другая история.