Экскурс в историю, или — Что нам делать с интерфейсами? |
18.04.2005 Иван Шихалев |
Как явствует из названия, начну я издалека. История программирования хотя и относительно молода, однако будет постарше меня, да и читателя, думается, тоже...
Совершенно очевидной представляется тенденция к все большему и большему структурированию как в теории, так и в практике программирования. Хотелось бы рассмотреть некоторые этапы.
Первым таким этапом можно назвать отделение данных от кода. Хотя это будет и не совсем верно: языки высокого уровня появились задолго до вычислительных машин, и в них данные от кода отделены по определению. С другой стороны, развитие практического программирования и архитектуры кода через этот этап прошло, пусть и как через очевидный и сам собой разумеющийся. Это произошло потому, что и Машина Тьюринга и фон-неймановская архитектура предполагают неразличимое хранение в памяти как кода, так и данных. С другой стороны, разделение их на разные логические области совершенно естественно и напрашивается само собой. Что и нашло отражение в сегментной модели памяти и области данных (переменных) в языках высокого уровня.
Тем не менее, отделение даных от кода я лично все-таки считаю первым шагом в общей тенденции структуризации. Главным образом — по его результатам: большая ясность программы и большая безопасность программирования. Вместе с возможностью повторного использования — это, на мой взгляд, и есть основные цели рассматриваемой тенденции структуризации.
В дальнейшем, чтобы не путаться в длинных словосочетаниях, я буду называть возможность повторного использования кода — реюзабельностью. Как бы я ни любил русский язык, но писать в несколько раз больше ради чистоты языка и благозвучия как-то не хочется.
Собственно, что принесло структурное программирование? Во-первых, это структуры данных на уровне языковых средств. (Хотя можно структурно программировать и без соответствующих конструкций, просто держа их в уме.) Во-вторых, это структуризация кода, посредством подпрограмм. И в-третьих, определение основных конструкций, описывающих логическую структуру алгоритма — сами конструкции существовали и до того, однако именно в структурном программировании пошла речь уже не о конструкциях того или иного языка, а о элементах алгоритма, выражаемых этими конструкциями.
Именно структурное программирование ввело в наш обиход два важных понятия, без которых, насколько мне известно, сейчас не обходится (по крайней мере — совсем) ни один язык программирования: тип и подпрограмма.
Итак, что такое тип данных? Это формальное (средствами формального языка программирования) качественное описание блока данных, однозначно определяющее его внутреннюю структуру и интерпретацию в выражениях языка (обычно говорится о "наборе допустимых операций", но мне такое выражение кажется не совсем точным и даже не верным — как правило набор допустимых операций уже после определения типа расширяется за счет подпрограмм, его использующих).
Теперь: что такое подпрограмма? Это не просто выделенный кусок кода — для этого куска кода определены входные и выходные параметры, а от прочих данных он, в идеале, изолирован. Конечно, реальные языки программирования позволяют обращаться в подпрограммах к глобальным переменным, однако, как правило, такое считается плохим стилем программирования (исключая доступ только на чтение и запись кода ошибки). Определяя для подпрограммы список ее формальных параметров, мы по сути уже выделяем ее интерфейс, даже если такого слова и не произносим. И именно ее интерфейс полностью определяет взаимодействие данной подпрограммы с другими частями кода.
Замечу, что вышеупомянутые нововведения вполне соответствуют основным целям. Очевидно, что осмысленные имена и явно описанная структура типов, как и описание параметров подпрограмм повышают логическую ясность и читаемость кода; использование данных в соответствии с заданным типом, который ограничивает допустимые операции над переменными, как и работа с изолированными подпрограммами, повышает безопасность программрования (изоляция здесь позволяет контролировать значительно меньшее количество возможных переменных на предмет возникновения некорректных значений); описание типа позволяет не описывать структуру каждой переменной самостоятельно, а выделение подпрограммы — не писать многократно один и тот же код, что, собственно, уже и есть реюзабельность.
Настоящая модульность появилась несколько позже структурного программирования как такового, поэтому я рассматриваю ее как следующий шаг структуризации. Хотя, в целом, новой парадигмы при этом не возникло. Скорее, этот шаг можно назвать завершающим в развитии структурного программирования. К настоящей модульности я не отношу разбиение исходных текстов программы на куски, склеиваемые различными директивами включения ({$include ...}
, #include
... и т.д.), а только собственно модульность, которая реализована модулями Pascal, статическими и динамическими библиотеками, раздельно компилируемыми объектными файлами и т.д.
Если взять наиболее чистый вариант — модули Turbo Pascal, то мы видим, что программные объекты — те же типы, подпрограммы, а с ними — константы, переменные — в модуле разделяются на две группы: глобальные — доступные извне модуля, и локальные — доступные только в самом модуле и нигде более. Иначе говоря, модуль имеет секции интерфейса и реализации. Кроме того, соглашения о видимости имен позволяют всегда различать программные объекты, если те объявлены в разных модулях с одинаковым именем.
Два важных следствия модульности: во-первых, для того, чтобы использовать модуль, нам необходимо знать только его интерфейс, что упрощает логическую структуру программы и уменьшает нагрузку на усталые программистские мозги. И во-вторых, модули не зависят друг от друга, если это не указано явно. Что резко улучшает как ясность, так и безопасность. Про реюзабельность я и не говорю — для того модульность и задумывалась.
Давайте рассмотрим "классический" в некотором смысле модуль. В некотором — это вот в каком: в модуле определяется один (или несколько связанных) структурированный тип и, возможно, несколько вспомогательных. Затем определяется множество подпрограмм, работающих с этим типом (типами).
Написание подобного модуля часто дают студентам в качестве практического задания — я имею в виду модуль для работы с комплексными числами.
В таком модуле мы видим как раз то задание типа, которое обычно встречается в определениях — определение внутренней структуры и набора допустимых операций. По большому счету, здесь мы впервые сталкиваемся с объединением данных и методов работы с ними (здесь пока слово "метод" используется в общем, а не специфическом смысле) в одной программной единице, в данном случае — модуле.
Следующим интересующим нас шагом структуризации является возникновение объектно-ориентированного программирования, которое является уже новой парадигмой по сравнению с программированием структурным, хотя и сохраняет его качества.
Как первый этап объектно-ориентированного программирования можно рассматривать объектные типы, появившиеся в Turbo Pascal, если я не ошибаюсь, с пятой версии. По сути, это расширяемые записи с привязкой методов уже в специальном смысле этого слова. Основные черты ООП можно наблюдать уже на них:
public
— доступ извне разрешен, и private
— доступа извне нет, такие поля и методы доступны только из методов данного типа и его потомков. Современное же объектно-ориентированное программирование в основном оперирует не понятием "тип", а понятием "класс". Казалось бы разница непринципиальна, однако, это далеко не так. С введением понятия класса мы можем гораздо более четко представить себе математические основы объектно-ориентированного программирования, которые восходят к теории множеств.
Разберемся с этими основами чуть позже, а сейчас рассмотрим, что еще возникло в ООП вместе с классами.
Во-первых, класс намного более самостоятельная программная единица, чем объектный тип — поля и методы класса делают его схожим с модулем. Правда в Object Pascal поля класса не поддерживаются, что, конечно, печально.
Во-вторых, весьма существенно улучшает изоляцию данных механизм свойств. В языках, поддерживающих этот механизм, возможность прямого доступа к полям данных объекта считается дурным тоном, и совершенно справедливо. Конечно, можно использовать и просто get/set-методы, однако читаемость кода при этом падает значительно.
И в-третьих, хотелось бы выделить так называемые абстрактные методы — т.е. по сути шаблоны для последующих виртуальных методов. Очевидно, что абстрактные методы являются механизмом полиморфизма.
Вернемся к теории.
Класс как таковой — это некоторое множество объектов, заданное определением (замечу, что множество может задаваться различными способами, например — перечислением). При таком рассмотрении видно, что класс-потомок данного как множество является подмножеством своего класса-предка. Становится естественным и очевидным тот факт, что объект, принадлежащий классу-потомку, принадлежит и классу-предку.
Соответственно, хотелось бы прояснить, каким образом механизм классов в ООП отображает основные операции над множествами.
Собственно, операций известно три: объединение, пересечение и разность. Первой и третьей как таковых в ООП не существует — это и понятно, поскольку, будучи определенными для множеств вообще, они не определены для классов — объединение (как и разность) двух классов совершенно не обязательно само является классом. Зато классом является пересечение двух классов — тут-то собака и порылась...
Собака тут порылась в том, что пересечение классов, ни один из которых не является потомком другого, реализуется исключительно путем множественного наследования. А этот механизм весьма не однозначен — сразу возникают проблемы конфликта имен, конфликта виртуальных методов и так далее. Если же два класса предка, в свою очередь, восходят к какому-то общему предку, то возникает еще и проблема дублирования данных.
Посредством различных дополнительных соглашений можно избежать указанных конфликтов, однако: во-первых, требуется помнить и учитывать эти соглашения при написании программ, во-вторых, эти соглашения делают различные классы-предки неравноправными друг с другом, то есть — теоретическая чистота все равно теряется.
В результате развития моделей ООП, можно выделить следующие основные проблемы:
Одно из существующих решений всех трех описанных выше проблем — интерфейсная модель, о которой мне бы и хотелось поговорить поподробнее.
Исходный признак интерфейсной модели — выделение интерфейса класса в отдельную программную единицу. Реализации на данный момент таковы, что все интерфейсные методы все-таки остаются виртуальными, однако по сравнению с "разведением" абстрактных классов интерфейсная модель имеет ряд преимуществ:
Есть и чисто техническое преимущество: существующая интерфейсная модель одинакова в разных языках. Таким образом, реализация интерфейса может быть вынесена в динамическую библиотеку и использоваться независимо от того, на каком языке написана программа.
Ну и о недостатках. Как уже говорилось, все методы интерфейса вызываются как виртуальные (адрес метода определяется в run-time), что конечно влечет дополнительные накладные расходы, порядка трех-четырех машинных команд для каждого вызова. Честно говоря, для большинства практических применений такую проблему нельзя назвать критической.
Еще одна проблема существующей модели — отсутствие поддержки механизма свойств. Конечно, Object Pascal, например, позволяет объявлять свойства в интерфейсах, однако не позволяет при этом скрыть от использования методы, осуществляющие чтение/запись этих свойств, что не есть хорошо, но и не слишком плохо — скорее мелкая придирка.
Таким образом, хочу сказать, что механизм интерфейсов, во-первых, является закономерным итогом (промежуточным, надеюсь) развития ООП; во-вторых, позволяет решить многие задачи, которые другими средствами не решаются, или решаются более сложно и трудоемко; ну, и в-третьих, является весьма гибким и красивым (что важно) инструментом.
В целом, есть у меня большое подозрение, что скепсис в отношении данного механизма в большинстве случаев вызван скорее здоровым (или не очень) консерватизмом, а не объективными недостатками самого механизма.
Что касается поддержки интерфесов в Free Pascal, то она уже существует в версии 1.1.