Универсальность и (versus) наследование
Последующий материал и его появление в приложении требует некоторых пояснений. Начальным толчком, приведшим в итоге к появлению этой книги, было исследование, проведенное в 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, но обладают и подходящими свойствами - ассоциативности, дистрибутивности, имеют нулевой элемент. Мы можем использовать математический термин "кольцо" для структур, обладающих этими свойствами.