лекции (2007) (1160825), страница 8
Текст из файла (страница 8)
END stacks;
IMPORT Stacks;
VAR S: Stacks.Stack;
Stacks.Push(S,I);
I:=Stacks.Pop(S);
MODULE Stacks;
const S_SIZE=128;
TYPE Stack * = RECORD
B: …
TOP: INTEGER;
END;
PROCEDURE PUSH*(…)
PROCEDURE POP*(…)
VAR Done *-: Boolean;…
Оберон2
END stacks;
Язык Ada - понятие пакета (некий аналог модуля в языке Modula-2).
Спецификация пакета:
package имя is
объявления
end имя
Тело пакета:
package body имя is
реализация
end имя
Имена пакетов должны быть уникальными. Пакет, в котором определён ТД, называется определяющим пакетом. Главное отличие от других ЯП (Modula-2, Oberon, Delphi) - нет простой, линейной структуры модулей; модули могут вкладываться друг в друга. Есть "главный" пакет STANDART, в который вкладываются все остальные модули.
Видимость: имена самих пакетов видимы непосредственно, имена из пакетов видимы потенциально (по имя_пакета.имя)
Пример пакета:
package Complex_numbers is
type Complex is record
Re, Im : Float;
end record;
function "+" (X,Y : in Complex) return Complex;
...
end Complex_numbers;
Как видно, можно перекрывать операции, причём как пользовательские, так и стандартные.
Но возникает проблема, связанная с видимостью:
X, Y, Z: Complex_numbers.complex;
Поскольку имя "+" из пакета Complex_numbers видимо только потенциально, нельзя просто написать
X := Y + Z;
Придётся писать так:
X := Complex_numbers."+"(Y, Z);
Получается, что весь смысл перекрытия стандартных операций теряется: нет никакой разницы между перекрытием "+" или объявлением новой функции Plus, если в обоих случаях к ним придётся обращаться одинаково, через Complex_numbers.
Поэтому в языке Ada реализован механизм, который реализует непосредственную видимость к нужным именам. Это конструкция "use":
use список_имён_пакетов;
Тогда все имена из пакетов, указанных в этом списке, станут видимы непосредственно. В нашем примере:
use Complex_numbers;
X, Y, Z : Complex;
X := Y + Z;
Но при использовании конструкции "use" может возникнуть другая проблема - конфликт имён. Правда, если конфликт возникает в именах операций (например, в двух разных пакетах определены функции или процедуры с одинаковыми именами), то, если профили функций разные, компилятор может различить их по типу операндов.
Таким образом, пока производится use только тех модулей, в которых одноимённые функции имеют разные профили, конфликта не возникает. А вот как только будет подключен модуль, в котором у одноимённой функции профиль повторяется или описан тип с таким же именем, это имя вообще перестанет быть видимым во избежание конфликта.
Ситуация ещё больше усложняется, когда пакеты вложены друг в друга, и конфликтуют имена, описанные на разных уровнях.
Из-за этого во многих компаниях, занимавшихся промышленным программированием на Ada, для повышения надёжности проектов даже вводились особые правила, запрещающие использование "use".
Во многом именно поэтому язык Ada и не стал стандартом промышленного программирования: фактически, все преимущества развитой модульной системы сводятся на нет проблемами, которые возникают при их использовании.
Как уже было сказано, в Ada допускается вложенность пакетов. Вот как это выглядит:
package P is
...
package PP is
...
end PP;
...
end P;
package body P is
...
package body PP is
...
end PP;
...
end P;
Существует два противоположных метода проектирования программ: "сверху-вниз" ("top-bottom") и "снизу-вверх" ("bottom-up"):
"Сверху-вниз": сначала проектируются модули верхнего уровня, потом они "разбиваются" на более мелкие модули, и так далее; вместо модулей нижнего уровня пишутся "заглушки".
С одной стороны, такая схема позволяет быстро получать работающие прототипы проекта и показывать их заказчику с целью оперативного изменения программы (поскольку часто встречаются ситуации, когда заказчик хочет изменить требования к программе). С другой стороны, отлаживать "заглушки" достаточно сложно.
"Снизу-вверх"- всё наоборот: сначала проектируются модули нижнего уровня, потом на их основе создаются модули более высокого уровня, и так далее, заканчивая верхним уровнем.
Здесь, напротив, модули низкого уровня отлаживать значительно проще, но пока система не завершена полностью, ничего нельзя сказать о её работоспособности и тем показывать её заказчику.
Классы
Класс - это ещё одно средство описания ТД. Преимущество перед модулем состоит в том, что класс одновременно является и структурой, и типом данных (в то время как, например, в языке Ada приходится описывать пакет и определять внутри него тип данных).
Члены класса делятся на два типа: члены-данные и члены-функции.
Синтаксис объявления класса очень похож во всех языках (в основном мы будем рассматривать C++, C#, Java и Delphi). Например, на языке Delphi объявление класса выглядит так:
type T = class (base_class)
procedure P;
объявления_других_членов
end;
procedure T.P;
begin
...
end;
То есть в Delphi четко выполняется принцип РОРИ (разделения, определения, реализации и использования): в объявлении класса могут присутствовать только переменные и прототипы.
В C# и Java принцип РОРИ не выполняется: если описывается функция-член, то необходимо сразу же и указать её реализацию (исключение - чисто виртуальные функции). Это неудобно с точки зрения читабельности, но значительно облегчает отладку. А читабельность достигается другими методами: C# и Java "погружены" в специальные среды разработки, которые предоставляют возможность отображения только интерфейсной части классов, скрывая реализацию.
В C++ принцип РОРИ есть, но необязателен. Тем не менее его обычно выполняют: принято объявление класса помещать в заголовочные файлы (.h, header-файлы), а реализацию - в файлы кода (.cpp).
Члены локализованы внутри классов. Синтаксис обращения везде одинаков: если есть класс X и у него есть член y, то доступ к y осуществляется по X.y (так же, как и у записи). Кроме того, есть модификаторы доступа к членам (напрмер, в C++ это public, private и protected), но это отдельная тема.
Члены класса делятся на статические и нестатические. Статические члены необходимы для работы со статическими данными.
В языке Smalltalk присутствует понятие экземпляра класса. Соответственно, члены делились на члены класса, которые размещались в единственном числе (и могут существовать вне зависимости от того, созданы ли экземпляры этого класса или нет), и на члены экземпляра, которые для каждого экземпляра были свои.
В C++ при работе со статическими данными возникает проблема их размещения: из-за раздельной трансляции компилятор не знает, где находится класс - в заголовочном файле или в файле кода. Поэтому, если у класса X есть статическая переменная static int n, то в файле кода будет необходимо явно её инициализировать: int X::n = -1 .
Члены-функции отличаются от членов-данных практически лишь тем, что им неявно передаётся указатель/ссылка на объект класса, и в теле функции определяется дополнительная переменная:
C++ - указатель this
Delphi, Java – ссылка Self
С# - ссылка this
Со статическими членами-функциями всё аналогично, синтаксис следующий:
C++ - X::f()
C#, Java - X.f()
Если в C++ функция main () должна быть глобальной, то в C# и Java необходимо определить её как статическую в одном из классов:
public static int main (string [] args){...};
Ещё один пример использования статических функций: если в программе на C++ необходимо размещать все объекты класса только в динамической памяти, то это можно реализовать, закрыв доступ ко всем констукторам и сделать публичной функцию, в которой объект класса будет создаваться динамически:
class X
{
private:
X(){...}
...
public:
static X * Make () {... return new X();}
...
}
Ещё одно понятие, которое появилось в C++ - встраиваемые функции (inline). Это функции, тела которых подставляются в местах их вызова. Это ускоряет быстродействие, так как исчезают операторы перехода, ломающие конвейер команд. Inline-функции похожи на макросы, но более удобны в использовании:
1) Их удобнее описывать. Пример - нахождение максимального из двух чисел:
макрос - #define max (a,b) ((a) < (b)) ? (b) : (a);
inline-функция - inline T max (T a, T b) { return a < b ? b : a;}
2) Inline-функции более надёжны, при их вызове производится проверка и преобразование типов. Если же происходит ошибка, то при использовании inline-фукнкций программист получит сообщение о ней в терминах вызова функции, в то время как макрос выдаст ошибку в терминах макроподстановки.
Наконец, класс может содержать в качестве своего подчлена другой класс. При этом вложенный класс с точки зрения видимости ничем не отличается от члена-функции объемлющего класса. Вложенность классов фактически аналогична вложенности модулей.
Один из наиболее распространённых примеров вложенности классов - библиотека стандартных шаблонов STL. Внутри любого контейнера STL определяется вложенный класс iterator. Это удобно, так как при работе с STL можно легко заменить один контейнер на другой.
В языке Java для обозначения вложенных классов специально введено понятие статического класса (смысл остаётся прежним):
class X
{
...
public static class Y
{
...
}
...
}
Теперь рассмотрим средства описания особых ссылочных типов, являющихся как бы указателями на функции:
В C++:
int * p; void (* f)();
int X::* p; void (X::* f)();
В Delphi:
type PR e = procedure (int) of T;
Наиболее развитый механизм - в C#: введено понятие делегатного типа (delegate). Каждый объект-делегат представляет собой некоторый список операций ("invocation list"). Пустой список представляется как null. Изменять этот список можно при помощи операторов +, +=, - и -=.
Пример:
class X
{
...
public delegate void g_delegate (int);
...
}
class Y
{
...
public void f (int x) {...};
public static void h (int i) {...};
...
}
Y yobj;
g_delegate gg = new g_delegate (Y.h);
gg += new g_delegate (yobj.f);
gg (1);
Вернёмся к рассмотрению механизма классов. Ещё одна его возможность - управление инициализацией, преобразованием и разрушением объектов. Это осуществляется с помощью специальных функций - конструкторов, деструкторов и операторов преобразований (операторы преобразования есть не только в объектно-ориентированных ЯП).
Конструктор вызывается при создании объекта класса, деструктор - при его уничтожении.
Наиболее сложная и развитая система в C++, так как существует три класса памяти (статическая, квазистатическая и динамическая). В C#, Java и Delphi используется только динамическая память.
В C++ конструктор объекта какого-то класса должен иметь то же имя, что и этот класс. У конструктора нет возвращаемого значения. Конструкторы могут иметь разную семантику (мы рассматриваем какой-то класс X):
X () {...}
X (int i) {...}
X (int i, int j) {...}
X (X &) {...}
X (const X &) {...}
X (T) {...}
X (T &) {...}
X (const T &) {...}
...
Конструктор X () - это конструктор по умолчанию, который вызывается, если явно не был указан вызов какого-либо другого конструктора.
Конструкторы наподобие X (int i) и X (int i, int j) используются, когда необходимо создать объект, инициализированный каким-о специальным образом.
Конструкторы X (X &) и X (const X &) - это конструкторы копирования. При присваивании, когда необходимо сделать копию объекта класса, не всегда правильно просто скопировать значения полей, так как в объектах могут быть, например, ссылки на какие-то динамические структуры, и тогда копия объекта не создаст копии этих структур, а будет ссылаться на структуры исходного объекта. Поэтому принято различать два вида копирования: поверхностное копирование (shallow copy), когда все поля просто копируются побитово (то есть копируется только верхний уровень структуры данных), и глубокое копирование (deep copy), когда вся структура данных копируется полностью.
Конструкторы X (T), X (T &) и X (const T &) - это конструкторы преобразования. Для чего они нужны? Вообще, одной из причин введения классов стала необходимость в средствах лёгкого расширения возможностей языка. Например, программисту может потребоваться комплексный тип данных, и тогда он пишет класс Complex с описанием комплексного типа (скажем, вещественной и мнимой частям соответствуют переменные Re и Im типа float). Для создания комплексного числа из двух вещественных достаточно написать простой конструктор преобразования:
Complex (float a, float b) {Re = a; Im = b;}
Для работы с комплексными числами программист может просто определить в классе функции Plus, Minus, Mult и т.д., но это неудобно (поскольку записывать выражения вроде "a + b * c" в виде "Plus(a, Mult(b, c))" неудобно и ненаглядно). Поэтому обычным приёмом является перекрытие операций. Для рассматриваемого примера в классе достаточно написать короткое перекрытие для каждой операции:
Сomplex operator + (Complex a &, Complex b &) {return Complex(a.Re + b.Re, a.Im + b.Im);}