Опубликован: 23.10.2005 | Доступ: свободный | Студентов: 4086 / 201 | Оценка: 4.44 / 4.19 | Длительность: 33:04:00
Специальности: Программист
Дополнительный материал 2:

Универсальность и (versus) наследование

< Дополнительный материал 1 || Дополнительный материал 2: 123456
Ключевые слова: simula, Ada, универсальность, наследование, анализ, поиск, нотация, параметризация, подпрограмма, размерность массива, genericity, статический контроль типов, swapping, character string, формальный аргумент, совместимость типов, фактический аргумент, множественные объявления, статическая типизация, фактический параметр, объектно-ориентированное проектирование, контроль соответствия типов, универсальность определений, универсальная функция, объявление функции, minimum, соответствие шаблону, перегрузка операций, формальный параметр, дистрибутивность, класс, tape, rewind, полиморфизм, динамическое связывание, расширяемость, граф, дерево, файл, файл устройства, абстрактный класс, Простой путь, перегрузка, перечислимый тип, список, архитектура, решение обратной задачи, comparator, постусловие, infix, anchor, потеря памяти, согласование типов, атрибут класса, типизация, универсальный класс, complexity, clone, equalizer, множественное наследование

Последующий материал и его появление в приложении требует некоторых пояснений. Начальным толчком, приведшим в итоге к появлению этой книги, было исследование, проведенное в 1984 году при подготовке курса для студентов " Концепции в языках программирования ", в котором я сравнивал "горизонтальный" механизм универсальности с "вертикальным" механизмом наследования, введенным в Simula. Первый механизм модульного расширения рассматривался на примере родовых языков, таких как Ada, Z, LPG. Анализировалось, чем отличаются эти техники, в чем они соревнуются, в чем дополняют друг друга. Это привело к статье с одноименным данному приложению названием [M1986], представленной на конференции OOPSLA, и к главе в первом издании этой книги.

При подготовке второго издания я полагал, что универсальность и наследование теперь достаточно хорошо понятны и им уделено достаточное внимание в остальной части книги. Поэтому глава была удалена как слишком специальная и полезная в основном для читателей, интересующихся проблемами разработки языков или ОО-теории. Однако анализ публикаций показывает, что данная проблема до сих пор многих приводит в замешательство. Это особенно проявляется в контексте C++, где множество людей ведет поиск рекомендаций, когда следует использовать "шаблоны", а когда наследование. Поэтому такое обсуждение должно присутствовать в общем рассмотрении объектной технологии, хотя бы в виде приложения.

Рассматриваемые здесь темы даются в следующем порядке: универсальность, наследование, эмуляция одного из этих механизмов с помощью другого и, в заключение, способы их наилучшего согласования.

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

Универсальность

Начнем с оценки достоинств универсальности, присутствующей в различных языках. Для удобства будет использована нотация самого известного не объектно-ориентированного языка с поддержкой универсальности - Ada, точнее Ada 83. Так что в оставшейся части этого раздела на минуточку забудьте о ОО-языках и соответствующей технике.

Будем рассматривать только наиболее важную форму универсальности Ada - параметризацию типа, возможность параметризации программных элементов (в языке Ada это пакет или подпрограмма) одним или более типами. Родовые параметры имеют и другое, менее важное использование в Ada, допуская параметризацию размерности массивов. Будем также отличать неограниченную универсальность ( unconstrained genericity) и ограниченную ( constrained genericity), накладывающую ограничения на родовые параметры.

Неограниченная универсальность

Неограниченная универсальность частично ослабляет жесткий статический контроль типов. Тривиальный пример - подпрограмма обмена значений двух переменных (на языке, подобном Ada, но без явных объявлений типов):

procedure swap (x, y) is
local t;
begin
    t := x; x := y; y := t;
end swap;

В этой форме не специфицируются типы обмениваемых элементов и локальной переменной t. Здесь слишком много свободы, так вызов swap (a, b), где a имеет тип integer, а b - character string, не будет отвергнут, хотя и приведет к ошибке.

Для устранения этой проблемы статически типизируемые языки, такие как Pascal и Ada, требуют от разработчиков явного задания типов всех переменных и формальных аргументов и вводят статически проверяемое ограничение на совместимость типов формальных и фактических аргументов в вызовах подпрограмм и между целью и источником при присваиваниях. Процедура, обменивающая значения двух переменных типа G, в этом случае принимает вид:

procedure G_swap (x, y: in out G) is
    t: G;
begin
    t := x; x := y; y := t;
end swap;

Требование определенности типа G предотвращает ошибки несовместимости, но в постоянном споре между безопасностью и гибкостью пострадала гибкость в угоду безопасности. Теперь для элементов каждого типа необходима новая процедура, например INTEGER_swap, STRING_swap и так далее. Такие множественные объявления удлиняют и затеняют программы. Выбранный пример особенно показателен, так как все объявления подобных процедур будут отличаться лишь двумя вхождениями G.

Статическая типизация в данном случае накладывает избыточные ограничения. Единственное реальное требование - идентичность типов фактических параметров и локальной переменной t. Конкретный тип не имеет значения.

В дополнение к этому аргументы должны иметь статус in out, чтобы процедура могла изменить их значения. Это разрешено в Ada.

Универсальность обеспечивает компромисс между избыточной свободой бестиповых языков и излишней строгостью, свойственной Pascal. В родовых языках можно объявить G как родовой параметр процедуры swap или охватывающего модуля. Язык Ada предлагает как родовые подпрограммы, так и родовые пакеты, описанные в лекции 15 курса "Основы объектно-ориентированного проектирования". На квази-Ada можно написать так:

generic
    type G is private;
procedure swap (x, y: in out G) is
    t: G;
begin
    t := x; x := y; y := t;
end swap;

Единственное отличие от реальной записи на Ada выражается в необходимости отделения интерфейса от реализации. Поскольку скрытие информации несущественно для обсуждения в этой лекции, интерфейсы и реализации объединены для простоты представления.

Предложение generic... вводит тип в качестве параметра. Определяя G как "private", автор процедуры позволяет применять к сущностям типа G (x, y, t) операции, применимые ко всем типам, такие как присваивание или сравнение, и только их.

Приведенное объявление не подпрограмма, а ее шаблон. Для получения подпрограммы, пригодной для непосредственного использования, необходимо указать конкретный тип параметров:

procedure int_swap is new swap (INTEGER);
procedure str_swap is new swap (STRING);

и т. д. Если теперь i и j переменные типа INTEGER, а s и t - STRING, то из следующих вызовов:

int_swap (i, j); str_swap (s, t);
int_swap (i, s); str_swap (s, j); str_swap (i, j);

допустимы только два первых, а остальные будут отклонены компилятором.

Параметризованные пакеты интереснее подпрограмм. Немного модифицировав пример стека, рассмотрим пакет, задающий очередь, в котором определены основные операции: добавление и удаление элемента, получение его значения и т. д. Интерфейс пакета:

generic
    type G is private;
package QUEUES is
    type QUEUE (capacity: POSITIVE) is private;
    function empty (s: in QUEUE) return BOOLEAN;
    procedure add (t: in G; s: in out QUEUE);
    procedure remove (s: in out QUEUE);
    function oldest (s: in QUEUE) return G;
private
    type QUEUE (capacity: POSITIVE) is
            -- Пакет использует массив для представления очереди
        record
            implementation: array (0 .. capacity) of G;
            count: NATURAL;
        end record;
end QUEUES;

Здесь опять-таки определен не пакет, а шаблон пакета. Пригодный для непосредственного использования пакет получается после соответствующей настройки:

package INT_QUEUES is new QUEUES (INTEGER);
package STR_QUEUES is new QUEUES (STRING);

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

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

Зачастую универсальное определение имеет смысл, только если фактические параметры удовлетворяют некоторым условиям. Эту форму можно назвать ограниченной универсальностью.

Ограниченная универсальность

Примеры ограниченной универсальности будут включать подпрограмму и пакет, как и в предыдущем случае.

Предположим, необходима универсальная функция для вычисления минимального из двух значений. Можно попробовать привлечь шаблон swap:

generic
    type G is private;
function minimum (x, y: G) return G is begin
        if x <= y then return x; else return y; end if;
end minimum;

Такое объявление функции имеет смысл только для таких типов G, для которых определена операция сравнения "<=". При статическом контроле типов соответствие этому требованию необходимо проверить на этапе компиляции, не дожидаясь выполнения. Нужен способ проверки того, поддерживается ли данная операция для типа G.

В Ada сама операция <= трактуется как родовой параметр. Синтаксически, операция - это функция, которую можно вызывать, используя обычную инфиксную форму, если в объявлении ее имя размещено в двойных кавычках - " <= ". Следующее объявление становится допустимым в Ada, объединив интерфейс и реализацию.

generic
    type G is private;
    with function "<=" (a, b: G) return BOOLEAN is <>;
function minimum (x, y: G) return G is begin
        if x <= y then return x; else return y end if;
end minimum;

Ключевое слово with вводит родовые параметры, представляющие подпрограммы, аналогичные " <= ".

Родовое порождение minimum можно выполнить для любого типа T1, если для него определена функция T1_le с сигнатурой: function (a, b: T1) return BOOLEAN.

function T1_minimum is new minimum (T1, T1_le);

Если функция T1_le действительно называется " <= ", точнее, если ее название и сигнатура соответствуют шаблону, то ее включение в список фактических параметров не требуется. Так, поскольку тип INTEGER имеет предопределенную функцию " <= " с правильной сигнатурой, то можно просто объявить:

function int_minimum is new minimum (INTEGER);

Такое использование заданных по умолчанию подпрограмм с соответствующими именами и типами возможно благодаря предложению is <> в объявлении формальной подпрограммы. Разрешенная и фактически поощряемая в Ada перегрузка операций играет существенную роль, и функция " <= " определена для различных типов.

От обсуждения ограниченной универсальности для подпрограмм легко перейти к пакетам. Предположим, что требуется универсальный пакет для работы с матрицами объектов любого типа G, где над матрицами определены операции суммирования и умножения. Такое определение имеет смысл, только если эти операции определены для типа G и каждая из этих операций имеет нулевой элемент. Эти свойства необходимы для реализации операций над матрицами. Интерфейсная часть пакета может быть написана следующим образом:

generic
    type G is private;
    zero: G;
    unity: G;
    with function "+"(a, b: G) return G is <>;
    with function "*"(a, b: G) return G is <>;
package MATRICES is
    type MATRIX (lines, columns: POSITIVE) is private;
    function "+"(m1, m2: MATRIX) return MATRIX;
    function "*"(m1, m2: MATRIX) return MATRIX;
private
    type MATRIX (lines, columns: POSITIVE) is
        array (1 .. lines, 1 .. columns) of G;
end MATRICES;

Вот типичные родовые порождения:

package INTEGER_MATRICES is new MATRICES (INTEGER, 0, 1);
package BOOLEAN_MATRICES is
    new MATRICES (BOOLEAN, false, true, "or", "and");

Для типа INTEGER опущены фактические параметры + и *, поскольку определены соответствующие операции. Однако их пришлось явно указать в случае BOOLEAN. (Параметры, опускаемые по умолчанию, лучше всего помещать в конец списка формальных параметров.)

Интересно рассмотреть реализацию такого пакета:

package body MATRICES is
    ... Остальные объявления ...
    function "*"(m1, m2: G) is
        result: MATRIX (m1'lines, m2'columns);
    begin
        if m1'columns /= m2'lines then
            raise incompatible_sizes;
        end if;
        for i in m1'RANGE(1) loop
            for j in m2'RANGE(2) loop
                result (i, j):= zero;
                for k in m1'RANGE(2) loop
                    result (i, j):= result (i, j) + m1 (i, k) * m2 (k, j)
                end loop;
            end loop;
        end loop;
        return result
    end "*";
end MATRICES;

В этом фрагменте использованы некоторые специфические особенности Ada:

  • Для параметризованных типов, подобных MATRIX (lines, columns: POSITIVE), объявление переменной должно сопровождаться фактическими параметрами, например mm: MATRIX (100, 75). Далее можно получить их значения, используя нотацию с апострофом: mm'lines в этом случае имеет значение 100.
  • Если a - массив, то a'RANGE(i) обозначает диапазон значений в его i -ом измерении; например, m1'RANGE(1) в приведенном примере - то же самое, что и 1.. m1'lines.
  • Если перемножаются две несовместимые по размерности матрицы, то возбуждается исключение.

Приведенные примеры демонстрируют реализацию ограниченной универсальности в Ada. Они также показывают серьезные ограничения этой техники: выразимы только синтаксические ограничения. Программист может потребовать только существования некоторых подпрограмм ( <=, +, * ) с заданной сигнатурой, но, если эти подпрограммы не удовлетворяют семантическим ограничениям, эти объявления становятся бессмысленными. Функция minimum имеет смысл, только если <= является отношением полного порядка на G. Для родового порождения MATRICES с заданным типом G, следует быть уверенным, что операции + и * имеют не только сигнатуру G x G -> G, но обладают и подходящими свойствами - ассоциативности, дистрибутивности, имеют нулевой элемент. Мы можем использовать математический термин "кольцо" для структур, обладающих этими свойствами.

< Дополнительный материал 1 || Дополнительный материал 2: 123456