Публикации Разное

Экскурс в историю, или — Что нам делать с интерфейсами?

18.04.2005
Иван Шихалев

Как явствует из названия, начну я издалека. История программирования хотя и относительно молода, однако будет постарше ме­ня, да и читателя, думается, тоже...

Совершенно очевидной представляется тенденция к все большему и большему структурированию как в теории, так и в практике про­грам­ми­ро­ва­ния. Хотелось бы рассмотреть некоторые этапы.

Отделение данных от кода

Первым таким этапом можно назвать отделение данных от кода. Хотя это будет и не совсем верно: языки высокого уровня по­яви­лись задолго до вычислительных машин, и в них данные от кода отделены по определению. С другой стороны, развитие прак­ти­чес­ко­го программирования и архитектуры кода через этот этап прошло, пусть и как через очевидный и сам собой разумеющийся. Это произошло потому, что и Машина Тьюринга и фон-неймановская архитектура предполагают неразличимое хранение в памяти как кода, так и данных. С другой стороны, разделение их на разные логические области совершенно естественно и на­пра­ши­ва­ет­ся само собой. Что и нашло отражение в сегментной модели памяти и области данных (переменных) в языках высокого уровня.

Тем не менее, отделение даных от кода я лично все-таки считаю первым шагом в общей тенденции структуризации. Главным об­ра­зом — по его результатам: большая ясность программы и большая безопасность программирования. Вместе с возможностью по­втор­но­го использования — это, на мой взгляд, и есть основные цели рассматриваемой тенденции структуризации.

В дальнейшем, чтобы не путаться в длинных словосочетаниях, я буду называть возможность повторного использования кода — ре­юза­бель­ностью. Как бы я ни любил русский язык, но писать в несколько раз больше ради чистоты языка и благозвучия как-то не хочется.

Следующий этап — структурное программирование

Собственно, что принесло структурное программирование? Во-первых, это структуры данных на уровне языковых средств. (Хотя мож­но структурно программировать и без соответствующих конструкций, просто держа их в уме.) Во-вторых, это структуризация ко­да, посредством подпрограмм. И в-третьих, определение основных конструкций, описывающих логическую структуру ал­го­рит­ма — сами конструкции существовали и до того, однако именно в структурном программировании пошла речь уже не о кон­струк­ци­ях того или иного языка, а о элементах алгоритма, выражаемых этими конструкциями.

Именно структурное программирование ввело в наш обиход два важных понятия, без которых, насколько мне известно, сейчас не об­хо­дит­ся (по крайней мере — совсем) ни один язык программирования: тип и подпрограмма.

Итак, что такое тип данных? Это формальное (средствами формального языка программирования) качественное описание блока дан­ных, однозначно определяющее его внутреннюю структуру и интерпретацию в выражениях языка (обычно говорится о "на­бо­ре допустимых операций", но мне такое выражение кажется не совсем точным и даже не верным — как правило набор до­пус­ти­мых операций уже после определения типа расширяется за счет подпрограмм, его использующих).

Теперь: что такое подпрограмма? Это не просто выделенный кусок кода — для этого куска кода определены входные и вы­ход­ные параметры, а от прочих данных он, в идеале, изолирован. Конечно, реальные языки программирования позволяют об­ра­щать­ся в подпрограммах к глобальным переменным, однако, как правило, такое считается плохим стилем программирования (исключая дос­туп только на чтение и запись кода ошибки). Определяя для подпрограммы список ее формальных параметров, мы по сути уже выделяем ее интерфейс, даже если такого слова и не произносим. И именно ее интерфейс полностью определяет вза­имо­дей­ст­вие данной подпрограммы с другими частями кода.

Замечу, что вышеупомянутые нововведения вполне соответствуют основным целям. Очевидно, что осмысленные имена и явно опи­сан­ная структура типов, как и описание параметров подпрограмм повышают логическую ясность и читаемость кода; ис­поль­зо­ва­ние данных в соответствии с заданным типом, который ограничивает допустимые операции над переменными, как и работа с изо­ли­ро­ван­ны­ми подпрограммами, повышает безопасность программрования (изоляция здесь позволяет контролировать зна­чи­тель­но меньшее количество возможных переменных на предмет возникновения некорректных значений); описание типа поз­во­ля­ет не описывать структуру каждой переменной самостоятельно, а выделение подпрограммы — не писать многократно один и тот же код, что, собственно, уже и есть реюзабельность.

Снова структурное программирование — модульность

Настоящая модульность появилась несколько позже структурного программирования как такового, поэтому я рассматриваю ее как следующий шаг структуризации. Хотя, в целом, новой парадигмы при этом не возникло. Скорее, этот шаг можно назвать за­вер­ша­ю­щим в развитии структурного программирования. К настоящей модульности я не отношу разбиение исходных текстов про­грам­мы на куски, склеиваемые различными директивами включения ({$include ...}, #include ... и т.д.), а только собственно мо­дуль­ность, которая реализована модулями Pascal, статическими и динамическими библиотеками, раздельно компилируемыми объ­ект­ны­ми файлами и т.д.

Если взять наиболее чистый вариант — модули Turbo Pascal, то мы видим, что программные объекты — те же типы, подпрограммы, а с ними — константы, переменные — в модуле разделяются на две группы: глобальные — доступные извне модуля, и локальные — доступные только в самом модуле и нигде более. Иначе говоря, модуль имеет секции интерфейса и реализации. Кроме того, со­гла­ше­ния о видимости имен позволяют всегда различать программные объекты, если те объявлены в разных модулях с оди­на­ко­вым именем.

Два важных следствия модульности: во-первых, для того, чтобы использовать модуль, нам необходимо знать только его ин­тер­фейс, что упрощает логическую структуру программы и уменьшает нагрузку на усталые программистские мозги. И во-вторых, мо­ду­ли не зависят друг от друга, если это не указано явно. Что резко улучшает как ясность, так и безопасность. Про ре­юза­бель­ность я и не говорю — для того модульность и задумывалась.

"Классический" модуль

Давайте рассмотрим "классический" в некотором смысле модуль. В некотором — это вот в каком: в модуле определяется один (или несколько связанных) структурированный тип и, возможно, несколько вспомогательных. Затем определяется множество под­про­грамм, работающих с этим типом (типами).

Написание подобного модуля часто дают студентам в качестве практического задания — я имею в виду модуль для работы с ком­плекс­ны­ми числами.

В таком модуле мы видим как раз то задание типа, которое обычно встречается в определениях — определение внутренней струк­ту­ры и набора допустимых операций. По большому счету, здесь мы впервые сталкиваемся с объединением данных и ме­то­дов работы с ними (здесь пока слово "метод" используется в общем, а не специфическом смысле) в одной программной единице, в данном случае — модуле.

Объектно-ориентированное программирование

Следующим интересующим нас шагом структуризации является возникновение объектно-ориентированного программирования, ко­то­рое является уже новой парадигмой по сравнению с программированием структурным, хотя и сохраняет его качества.

Как первый этап объектно-ориентированного программирования можно рассматривать объектные типы, появившиеся в Turbo Pascal, если я не ошибаюсь, с пятой версии. По сути, это расширяемые записи с привязкой методов уже в специальном смысле это­го слова. Основные черты ООП можно наблюдать уже на них:

  • Объединение данных и методов — данные и методы уже совместно принадлежат некоторому объектному типу. Причем — это объединение производится средствами языка, т.о. добавив поле или метод объекта мы привязываем его к данному типу жестко, тогда как модули могли содержать и независимые друг от друга блоки.
  • Изоляция (инкапсуляция) — поля и методы могут быть недоступными извне. Насколько я помню, в Turbo Pascal поддерживалось два уровня изоляции для полей и методов: public — доступ извне разрешен, и private — доступа извне нет, такие поля и методы доступны только из методов данного типа и его потомков.
  • Наследование — тип-потомок может использовать все поля и методы типа-предка. Замечу, что в случае модулей ничего подобного не наблюдалось.
  • Полиморфизм — в случае объектов Turbo Pascal реализовывался с помощью виртуальных методов и использования динамически создаваемых объектов.

Понятие класса и иже с ним

Современное же объектно-ориентированное программирование в основном оперирует не понятием "тип", а понятием "класс". Ка­за­лось бы разница непринципиальна, однако, это далеко не так. С введением понятия класса мы можем гораздо более четко пред­ста­вить себе математические основы объектно-ориентированного программирования, которые восходят к теории множеств.

Разберемся с этими основами чуть позже, а сейчас рассмотрим, что еще возникло в ООП вместе с классами.

Во-первых, класс намного более самостоятельная программная единица, чем объектный тип — поля и методы класса делают его схо­жим с модулем. Правда в Object Pascal поля класса не поддерживаются, что, конечно, печально.

Во-вторых, весьма существенно улучшает изоляцию данных механизм свойств. В языках, поддерживающих этот механизм, воз­мож­ность прямого доступа к полям данных объекта считается дурным тоном, и совершенно справедливо. Конечно, можно ис­поль­зо­вать и просто get/set-методы, однако читаемость кода при этом падает значительно.

И в-третьих, хотелось бы выделить так называемые абстрактные методы — т.е. по сути шаблоны для последующих виртуальных ме­то­дов. Очевидно, что абстрактные методы являются механизмом полиморфизма.

Класс как множество

Вернемся к теории.

Класс как таковой — это некоторое множество объектов, заданное определением (замечу, что множество может задаваться раз­лич­ны­ми способами, например — перечислением). При таком рассмотрении видно, что класс-потомок данного как множество яв­ля­ет­ся подмножеством своего класса-предка. Становится естественным и очевидным тот факт, что объект, принадлежащий классу-по­том­ку, принадлежит и классу-предку.

Соответственно, хотелось бы прояснить, каким образом механизм классов в ООП отображает основные операции над мно­жест­вами.

Собственно, операций известно три: объединение, пересечение и разность. Первой и третьей как таковых в ООП не существует — это и понятно, поскольку, будучи определенными для множеств вообще, они не определены для классов — объединение (как и разность) двух классов совершенно не обязательно само является классом. Зато классом является пересечение двух классов — тут-то собака и порылась...

Собака тут порылась в том, что пересечение классов, ни один из которых не является потомком другого, реализуется ис­клю­чи­тель­но путем множественного наследования. А этот механизм весьма не однозначен — сразу возникают проблемы конфликта имен, конфликта виртуальных методов и так далее. Если же два класса предка, в свою очередь, восходят к какому-то общему пред­ку, то возникает еще и проблема дублирования данных.

Посредством различных дополнительных соглашений можно избежать указанных конфликтов, однако: во-первых, требуется пом­нить и учитывать эти соглашения при написании программ, во-вторых, эти соглашения делают различные классы-предки не­рав­но­прав­ны­ми друг с другом, то есть — теоретическая чистота все равно теряется.

Проблемы модели классов

В результате развития моделей ООП, можно выделить следующие основные проблемы:

  • Нарушение изоляции с точки зрения исходного кода — закрытые поля и методы тем не менее объявляются в интерфейсной части модуля. Конечно можно этого избежать, дублируя каждый класс его абстрактным двойником в интерфейсной части, перемещая реальное объявление в часть реализации и вынося в интерфейс модуля функцию, вызывающую конструктор данного класса.
    Нельзя назвать такой вариант совсем уж не приемлемым, однако, назвать его некрасивым не то, что можно, а еще и слишком мягко — во-первых, мы таким образом "множим сущности", а во-вторых, все интерфейсные методы становятся виртуальными.
  • Нарушение изоляции с точки зрения логики — продолжение предыдущего пункта — интерфейсные элементы класса объявляются там же, где и локальные. Соответственно, требуется больше усилий, видя исходное объявление, выделить эти самые интерфейсные элементы для дальнейшего использования.
  • Пресловутое множественное наследование — или оно неполное (накладываем дополнительные ограничения, например, на общего предка двух предков данного класса), или неравноправное, или возникают конфликты. Идеальной модели попросту не может быть.
    В рассуждениях по этому поводу мне доводилось встречать мнение, согласно которому, проблемы множественного наследования решаются отказом от него и применением агрегатной модели построения классов. Однако, такой подход говорит только о непонимании сути проблемы.

Интерфейсная модель

Одно из существующих решений всех трех описанных выше проблем — интерфейсная модель, о которой мне бы и хотелось по­го­во­рить поподробнее.

Исходный признак интерфейсной модели — выделение интерфейса класса в отдельную программную единицу. Реализации на дан­ный момент таковы, что все интерфейсные методы все-таки остаются виртуальными, однако по сравнению с "разведением" абс­тракт­ных классов интерфейсная модель имеет ряд преимуществ:

  • Интерфейс полностью отделен от реализации. Изоляция внутренней структуры объекта максимальна.
  • Иерархия интерфейсов не зависит от иерархии классов. Вместо кадавра, где абстрактные классы перемешаны с нормальными мы получаем две иерархии — интерфесов и классов, где вторая строится, исходя из наследования реализации.
  • Класс может реализовать не один интерфейс а несколько, что с теоретической точки зрения и обозначает пересечение множеств (множеств объектов, обладающих данными интерфейсами).
  • Так называемая реализация делегированием позволяет множественное наследование уже и реализации.
  • При этом не возникает конфликта имен и виртуальных методов, поскольку одноименные элементы разных интерфейсов вызываются каждый раз однозначно, будучи уникальными в пределах интерфейса.

Есть и чисто техническое преимущество: существующая интерфейсная модель одинакова в разных языках. Таким образом, ре­али­за­ция интерфейса может быть вынесена в динамическую библиотеку и использоваться независимо от того, на каком языке на­пи­са­на программа.

Ну и о недостатках. Как уже говорилось, все методы интерфейса вызываются как виртуальные (адрес метода определяется в run-time), что конечно влечет дополнительные накладные расходы, порядка трех-четырех машинных команд для каждого вы­зо­ва. Честно говоря, для большинства практических применений такую проблему нельзя назвать критической.

Еще одна проблема существующей модели — отсутствие поддержки механизма свойств. Конечно, Object Pascal, например, поз­во­ля­ет объявлять свойства в интерфейсах, однако не позволяет при этом скрыть от использования методы, осуществляющие чте­ние/запись этих свойств, что не есть хорошо, но и не слишком плохо — скорее мелкая придирка.

Резюме

Таким образом, хочу сказать, что механизм интерфейсов, во-первых, является закономерным итогом (промежуточным, надеюсь) раз­ви­тия ООП; во-вторых, позволяет решить многие задачи, которые другими средствами не решаются, или решаются более слож­но и трудоемко; ну, и в-третьих, является весьма гибким и красивым (что важно) инструментом.

В целом, есть у меня большое подозрение, что скепсис в отношении данного механизма в большинстве случаев вызван скорее здо­ро­вым (или не очень) консерватизмом, а не объективными недостатками самого механизма.

Что касается поддержки интерфесов в Free Pascal, то она уже существует в версии 1.1.

Актуальные версии
FPC3.2.2release
Lazarus3.2release
MSE5.10.0release
fpGUI1.4.1release
links