лекции (1998) (Буров) (1161123), страница 12
Текст из файла (страница 12)
связывается с операцией доступа по полю: X.Re=…
То есть подобное определение нового типа вводит лишь множество значений и небольшой набор операций, который встроен в соответствующий базисный тип данных.
Заметим, что мы хотели данным типом описать комплексное число, которому, вообще говоря, соответствует не только определенное множество значений, но и определенный набор операций (+, -, * и .т. д.). Если мы захотим описать это на ЯП Pascal, то встает два вопроса: а) как логически связать тип данных с соответствующим набором операций? б) насколько естественно впишется новый тип в структуру языка, действительно ли это будет расширением или же некий полиотип? В стандартном Pascal ответ на эти оба вопроса будет отрицательный, так как в нем полностью отсутствуют средства, позволяющие связывать множество значений и набор операций, следовательно о втором вопросе говорить даже не приходится.
Естественным образом возникает понятие логического модуля, в котором описывается новый тип. Заметим, что речь идет не о физическом модуле (мы будем разбирать их, когда коснемся раздельной компиляции).
Давайте по очереди рассмотрим, как реализованы эти логические модули в различных ЯП. Про стандартный Pascal мы ничего сказать не можем, точно также мы ничего не можем сказать о языке С, в нем тоже отсутствуют механизмы логического группирования.
Поговорим о модульной структуре языков и о том, как она применяется для формирования новых типов. Начнем с самых простых вариантов, созданных профессором Виртом: Модула-2 и Oberon.
В Модула-2 существует три вида модулей:
-
главный (некий аналог функции main), он может быть только один.
MODULE имя;
Набор определений;
BEGIN
Набор операторов;
END имя;
-
библиотечный (он будет интересовать нас больше всего, т.к. одно из его предназаначений - введение новых типов данных)
-
локальный модуль (обязательно вложен либо в набор определений главного модуля, либо в процедуру)
Библиботечный модуль разделяется на две части:
-
модуль определений
-
модуль реализаций
Эти два модуля имеют одинаковое имя, но различную структуру. Модуль определений служит для того, чтобы показать интерфейс модуля (каждый модуль имеет интерфейс для связи с внешним миром) – это набор имен и их свойств (атрибуты, если это тип данных; тип, если это переменная; значение, если константа и т.д.), которые видны извне этого модуля, т.е. только этими именами можно пользоваться. Напишем модуль определений, который реализует модуль Stacks (он представляет некий механизм работы со стеками):
DEFINITION MODULE Stacks;
Type Stack =
RECORD
Body: ARRAY [1..50] OF CHAR;
Top: 1..51;
END;
PROCEDURE PUSH (VAR S: Stack; X: CHAR);
PROCEDURE POP (VAR S: Stack): CHAR;
VAR errcode: INTEGER;
…
PROCEDURE INIT (VAR S: Stack);
END Stacks.
В этом модуле мы указываем только прототипы. На случай, когда у нас возникает ошибка (например, переполнение стека) мы вводим переменную errcode, которая фактически является частью типа, а с другой стороны – ему не принадлежит. Такие переменные называются членами класса, она принадлежит одновременно всем переменным класса. В C++ и Java такие переменные называются статическими. В этой переменной «сидит» последний код ошибки.
Процедура INIT инициализирует стек. Тем самым мы ввели новый тип и набор операций над ним.
IMPLEMENTATION MODULE Stacks;
{ Определения процедур и функций из Defenition Module
Здесь также могут быть и другие дополнительные определения}
[ BEGIN
Операторы; Errcode=0;
END Stacks. ]
Часть в квадратных скобках может отсутствовать. В качестве инициализации мы сопоставляем переменной Errcode Значение 0. Эта инициализирующая часть выполняется лишь один раз в момент загрузки модуля. Обычно она очень короткая или может вообще отсутствовать.
Зачем нужен модуль реализации? Зачем выделять его отдельно? Дело в том, что все, что находится внутри модуля реализации недоступно извне. Программист, использующий конкретный библиотечный модуль имеет доступ только к определениям, описанным в интерфейсной части. Это есть некое управление доступом. Позже мы заметим, что такое разделение имеет отношение еще и к механизму раздельной компиляции в ЯП Модула-2. Что нужно компилятору, чтобы корректно откомпилировать клиентский модуль (модуль, использующий Stacks)? Ему нужен только модуль определений, так как в нем есть все необходимые данные. Модуль реализации нужен лишь для того, чтобы собрать готовую работающую программу. Заметим, что Н.Вирт убил здесь сразу нескольких зайцев.
В Turbo Pascal, например, такого разбиения на два модуля нет, там две части – объявления и реализация объединены в одной физической части:
Unit имя;
Interface
{…}
implementation
{…}
end.
Можно ли упростить эту схему? Рассмотрим язык Oberon. Вирт для создания этого языка выкинул из Modula-2 все не необходимые вещи, то есть еще минимизировал этот и без того простой язык и добавил одну концепцию, а именно, концепцию расширения функций. Что выкинул Вирт? Деление на две физические части, понятие локального модуля и понятие главного модуля. В Обероне отсутствует понятие главного модуля вообще. В нем есть только одно понятие – модуля.
MODULE имя;
Набор определений;
BEGIN
Инициализация
END имя.
В данной ситуации всплывает один существенный минус – а именно, все определения, которые мы укажем в наборе определений модуля будут экспортироваться во внешний мир. В случаях, когда нам требуются какие-то внутренние типы или функции, мы не можем препятствовать их использованию извне. Понятие интерфейса становится неопределенным.
У возникшей проблемы есть некоторое решение. Дело в том, что современные ЯП представляют из себя не столько компилятор, сколько интегрированную среду, включающую в себя редактор, компилятор, управление проектами, помощью и т.д. И с этой точки зрения в Обероне принято следующее – у нас есть только один модуль, например:
MODULE Stacks;
TYPE Stack * =
RECORD
Top: INTEGER;
Body: ARRAY 50 OF CHAR;
END;
PROCEDURE PUSH * (VAR S: Stack; X: CHAR);
…
END Stacks;
Звездочка после имени говорит о том, что имя является экспортируемым.
Что хорошо и что плохо?
Хорошо то, что мы освобождаемся еще от одного языкового понятия. И еще (этого нельзя сделать в Модула-2), заметим, что у Top и Body отсутствуют звездочки, это говорит о том, что тип данных Stack доступен только через свое имя и экспортируемый набор операций. То есть мы упрятали поля Top и Body от несанкционированного доступа. Заметим, что в Модула-2, если мы что-то сделаем с элементом Top, то стек испортится. Здесь же мы имеем очень хорошую и естественную защиту от случайной порчи. К тому же, можно давать доступ к отдельным полям записи.
А плохо то, что если иметь маломальски нормальный модуль, то разобраться по тексту, что там происходит, что экспортируется, а что нет – весьма тяжело. От этой проблемы нас как раз и избавляет интегрированная среда Oberon’а, которая включает в себя средство генерирования модуля определения. По нашему модулю она выдаст следующее:
DEFENITION Stacks;
TYPE Stack = RECORD END;
PROCEDURE PUSH;
END Stacks;
И далее можно пользоваться только вот этим сгенерированным модулем определений. Это решение тоже влечет некоторые недостатки с точки зрения раздельной компиляции. Но об этом будем говорить позже.
Перейдем к языку Ada. Мы начали с простых схем – языков Pascal, Modula-2, Oberon. Ada представляет некоторое усложнение. В нем существует понятие логического модуля, которое называется Package:
Package Stacks is
Record
Body: array (1..50) of character;
Top: 1..51;
End record;
Procedure Push(S: inout Stack; X: character);
Procedure Pop(S: inout Stack; X: out character);
End Stacks
Процедуру Pop, вообще говоря, следует описать как функцию, но тут есть один ньюанс: в Ada функции могут иметь только IN параметры, а в нашем случае параметр типа Stack следует передавать, как INOUT.
Заметим, что, не считая синтаксической разницы, мы получили в чистом виде модуль определения языка Modula-2. И, следовательно, должна быть конструкция, которая идейно соответствует модулю реализаций, где будут конкретизироваться процедуры и функции, а также будут вводиться какие-то данные (типы, процедуры и т.п.) необходимые для внутренней реализации. Такая конструкция есть и называется она «Телом пакета»:
Package body Stacks is
…
end Stacks;
Несмотря на похожесть с Modula-2 в Ada имеются и различия (с точки зрения организации доступа, плюс некоторые усложнения). Дело в том, что в Ada существуют так называемые «локальные пакеты». Уже было сказано о локальных модулях в Modula-2 – это крайне ограниченная конструкция, он имеет следующий вид:
MODULE имя;
EXPORT список имен;
Определения
BEGIN
Инициализация
END имя;
- этот модуль может быть вложен в тело функции, в модуль реализации, в раздел описаний главного модуля, сам в себя. Видно, что эта концепция лишь вспомогательная. Реально эту конструкцию почти никто не использовал, так как у нее слишком ограниченная область действия. Он использовался лишь для организации параллельных или квазипараллельных процессов в соответствующих средах.
В Ada ситуация немного другая, мы можем создавать вложенные пакеты:
package M is
…
package M_IN is
…
end M_IN;
end M;
Причем вложенный пакет имеет свой нормальный интерфейс и свое тело, причем тело будет реализовано по структуре там же, где и описание:
package body M is
…
package body M_IN is
…
end M_IN;
end M;
Какой стиль программирования поддерживает такие пакеты? В курсе Технологии Программирования рассказывается о том, что существуют различные способы построения программ, один из них – снизу вверх:
когда сначала выделяются некие базовые понятия, они реализуются в виде некоторых библиотек, затем реализуются другие, более абстрактные понятия и так далее, пока не будет построена вся система.
Сверху вниз:
в данном случае мы сначала создаем абстракцию, а затем уточняем ее до более конкретных понятий.
Подход сверху вниз более концептуальный, но, честно говоря, в чистом виде ни один из этих подходов не используется, обычно их увязывают друг с другом.
С точки зрения модульной организации Modula-2 соответствует больше схеме “снизу вверх”, потому что мы, например, не можем откомпилировать программу, использующую модуль Stacks, если он еще не готов. Поэтому в данном случае мы над базсисом постоянно надстраиваем более общие конструкции.
Ada же предоставляет возможности работы, как «снизу-вверх», так и «сверху-вниз». Таким образом Ada – технологически более преимущественный язык, но с другой стороны – оправдываются ли эти преимущества усложнениями? Вопрос интересный.
Заметим, что все эти языки без наследования. Каким образом можно эмулировать наследование в языке, где его нет? Видимо, как раз с помощью локальных модулей. На этом мы пока закончим с языком Ada. Мы не обсуждали еще вопросы видимости и раздельной компиляции – это тема для будущих рассуждений.
Поговорим про языки C++ и Java. Чем хорош C++? В нем назначение модуля не сводится только к определению типа данных, хотя может к нему сводиться, но модули могут использоваться и для других целей. Одно и преимуществ – нам не надо придумывать два похожих имени для типа данных и его “обертки” – модуля. Очевидно, мы должны задавать различные имена, но это – накладной расход.
Концепция классов в C++ и Java свободна от этих недостатков: класс управляет правами доступа, видимостью. С другой стороны класс – это тип данных. То есть «фантик» и содержимое в одном флаконе. Общее определение класса в C++ имеет такой вид:
class имя {
определения членов класса
};
строго говоря, если мы будем компилировать программу на С, используя компилятор C++, то мы окажемся в положении мольеровского господина Журдена, который обнаружил, что всю жизнь говорил прозой. Ибо окажется, что мы всю жизнь программировали в терминах классов, сами того не замечая. Дело в том, что новую концепцию классов оказалось возможным совместисть со старой концепции структуры. Дело в том, что определения класса и структуры в C++ эквивалентны во всем за исключением области прав доступа.
struct S {
…
}
по умолчанию все элементы класса являются приватными (недоступными), а структуры – доступными, если не указано обратного.
Теперь поговорим об определении членов класса. Если класс – аналог модуля, то он должен сводить воедино множество значений типа данных, его структуру, набор операций, плюс еще, желательно, управлял бы доступом. Посмотрим, как он это делает:
struct stack {