Лекции (1129116), страница 12
Текст из файла (страница 12)
// то и переменная передастся как бы по ссылке
С++, с этой точки зрения, не менее мощный язык, но более опасный. В таких случаях, он предлагает использовать явным образом ссылки. Заменителем операции ToString в С++ служит оператор преобразования к типу данных char* или CString. Но для преобразования в CString, программист должен сам задать оператор преобразования типа в CString, и тогда компилятор сможет автоматически подставлять это преобразование. К сожалению, за все надо платить, и иногда, неявные преобразования выходят боком.
Язык Delphi, с точки зрения средств развития, кажется не менее мощным, чем С++ и Java. Как и в Java, создателям Delphi, необходимо было интегрировать тип данных String в язык. В языке Delphi нельзя написать класс, который эквивалентен по функциональности классу String. Кроме того, нельзя переопределять операций работы со сроками, и поэтому теряется гибкость. С этой точки зрения, концепция типов, встроенных в язык несколько порочна, потому что для любой самой изысканной реализации подобного рода встроенных типов данных, всегда можно найти вариант реализации, который будет работать для вашего приложения значительно лучше. В этом смысле, подход С++ значительно лучше.
Лекция 10
Мы начинаем рассматривать cредства развития в современных ЯП, традиционных (то есть процедурных). Кроме определения новых типов мы будем рассматривать:
-
управление доступом и абстрактные типы данных;
-
раздельная трансляция;
-
статическая параметризация (механизм шаблонов в C++ и разрывных модулей в Ada)
-
исключения (тема, которая стоит особняком, но интересная и важная с практической и теоретической точек зрения)
Это наша программа на часть, касающуюся традиционных ЯП.
Определение новых типов данных
Основными атрибутам типа данных являются:
-
множество значений
-
набор операций
Для таких типов, как массивы или записи, мы, прежде всего имели атрибут «набор операций». Например, тип:
Type T= record
Re, Im: real;
End;
связывается с операцией доступа по полю: 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;
Какой стиль программирования поддерживает такие пакеты? В курсе Технологии Программирования рассказывается о том, что существуют различные способы построения программ, один из них – снизу вверх:
когда сначала выделяются некие базовые понятия, они реализуются в виде некоторых библиотек, затем реализуются другие, более абстрактные понятия и так далее, пока не будет построена вся система.
Сверху вниз:
в данном случае мы сначала создаем абстракцию, а затем уточняем ее до более конкретных понятий.
Подход сверху вниз более концептуальный, но, честно говоря, в чистом виде ни один из этих подходов не используется, обычно их увязывают друг с другом.