Наследование, диспетчеризация и интерфейсы

Наследование расширяет тип новыми полями и переопределяет операции; диспетчеризация по 'Class выбирает нужную реализацию во время выполнения. Интерфейсы добавляют множественное наследование контракта.

Расширение типа и диспетчеризация: потомок объявляется как расширение тэгированного родителя (new Parent with record ...), может добавлять поля и переопределять (overriding) примитивные операции; вызов через class-wide тип (Parent'Class) диспетчеризуется к реализации фактического типа объекта в рантайме.

Тэгированный тип сам по себе — лишь заготовка для иерархии. Настоящая объектная модель раскрывается, когда мы строим дерево типов и заставляем операции вести себя полиморфно. Ada выражает наследование, переопределение и динамический выбор метода с присущей ей явностью: каждое решение видно в коде, ничто не происходит «по умолчанию» втихую. Этот урок — сердце объектной модели языка: расширение типов, class-wide-полиморфизм и интерфейсы как способ множественного наследования контракта.

Расширение типа: наследование с добавлением полей

Потомок объявляется конструкцией «производный тип new родитель with record новые поля». Он автоматически получает все поля и все примитивные операции родителя, а в части with record добавляет своё. Это и есть наследование данных и поведения одновременно.

--  Потомки Shape добавляют поля и ПЕРЕОПРЕДЕЛЯЮТ Area
type Circle is new Shape with record
   Radius : Float := 1.0;
end record;

overriding function Area (C : Circle) return Float is
   (3.141592653589793 * C.Radius * C.Radius);   -- своя площадь

type Rectangle is new Shape with record
   Width, Height : Float := 1.0;
end record;

overriding function Area (R : Rectangle) return Float is
   (R.Width * R.Height);                          -- своя площадь

Слово overriding перед объявлением — не обязательное, но крайне ценное средство Ada 2005: оно явно заявляет «эта операция переопределяет унаследованную». Если вы ошиблись в сигнатуре (скажем, опечатались в типе параметра), и операция на деле не переопределяет родительскую, компилятор поймает несоответствие. Симметрично, ключевое слово not overriding заявляет «это новая операция, не переопределение». Этот контроль устраняет коварную ошибку «случайной перегрузки вместо переопределения», годами мучившую программистов на C++ (до override из C++11) и Java.

Унаследованные операции, которые потомок не переопределяет, работают как есть: Circle наследует Move от Shape без изменений, ведь перемещение координат одинаково для всех фигур.

Class-wide типы и диспетчеризация

Ключевой вопрос полиморфизма: как в одной коллекции хранить разные фигуры и вызывать «правильную» Area для каждой? Ответ Ada — class-wide тип, обозначаемый атрибутом 'Class. Тип Shape'Class охватывает Shape и все его потомки сразу. Переменная или параметр такого типа может ссылаться на объект любого типа из иерархии, а вызов примитивной операции через class-wide значение диспетчеризуется: фактический тип объекта определяется в рантайме по тегу, и вызывается соответствующая реализация.

with Ada.Text_IO; use Ada.Text_IO;
...
--  Параметр типа Shape'Class принимает ЛЮБУЮ фигуру; вызов Area диспетчеризуется
procedure Print_Area (S : Shape'Class) is
begin
   Put_Line ("Площадь =" & Float'Image (S.Area));   -- ДИНАМИЧЕСКИЙ вызов
end Print_Area;

declare
   C : Circle    := (X | Y => 0.0, Radius => 2.0);
   R : Rectangle := (X | Y => 0.0, Width => 3.0, Height => 4.0);
begin
   Print_Area (C);   -- вызовет Area для Circle    → ~12.566
   Print_Area (R);   -- вызовет Area для Rectangle → 12.0
end;

Вывод:

Площадь = 1.25664E+01
Площадь = 1.20000E+01

Тонкое, но фундаментальное различие: вызов S.Area, где S имеет тип Shape (конкретный), связывается статически — всегда вызывается Area для Shape. Вызов S.Area, где S имеет тип Shape'Class, связывается динамически — выбирается реализация фактического типа. Ada делает выбор механизма явным через присутствие 'Class, тогда как в C++ и Java диспетчеризация — поведение по умолчанию (через virtual или просто для всех методов). Явность Ada означает, что, читая код, вы всегда видите, статический это вызов или динамический, — никаких догадок.

Хранение разнородных объектов

Поскольку объекты разных типов имеют разный размер, прямо в массив Shape'Class их сложить нельзя — нужен уровень косвенности. Идиома — массив указателей на class-wide тип (access Shape'Class), и тогда в одной коллекции живут круги, прямоугольники и любые будущие фигуры, а обход с вызовом Area диспетчеризует каждый по его типу. Это и есть полиморфная коллекция — то, чего нельзя достичь обобщёнными единицами (см. урок о двух полиморфизмах).

type Shape_Ptr is access all Shape'Class;
Figures : array (1 .. 2) of Shape_Ptr :=
   (new Circle'(X | Y => 0.0, Radius => 1.0),
    new Rectangle'(X | Y => 0.0, Width => 2.0, Height => 5.0));
...
for F of Figures loop
   Put_Line (Float'Image (F.Area));   -- каждый диспетчеризуется по своему типу
end loop;

Абстрактные типы и интерфейсы

Часто корень иерархии не должен иметь экземпляров — «фигура вообще» бессмысленна без конкретики. Такой тип объявляют abstract, а операции без реализации — abstract-операциями: создать объект абстрактного типа нельзя, а потомок обязан реализовать все абстрактные операции. Это аналог абстрактного класса.

Ada (с 2005) поддерживает и интерфейсы — чистые контракты без полей и реализации, допускающие множественное наследование (в отличие от типов, у которых родитель один). Тип может расширять один тэгированный родитель и реализовывать несколько интерфейсов — безопасная форма множественного наследования, избегающая «ромбовидной проблемы» данных, потому что интерфейсы не несут полей.

--  Интерфейсы: только операции, без полей, допускают множественность
type Drawable is interface;
procedure Draw (D : Drawable) is abstract;        -- контракт: уметь рисоваться

type Printable is interface;
procedure Print (P : Printable) is abstract;      -- контракт: уметь печататься

--  Тип реализует НЕСКОЛЬКО интерфейсов сразу:
type Widget is new Shape and Drawable and Printable with record
   Label : String (1 .. 16);
end record;

overriding procedure Draw  (W : Widget);          -- обязаны реализовать
overriding procedure Print (W : Widget);
overriding function  Area  (W : Widget) return Float;

Как работает под капотом

Диспетчеризуемый вызов реализуется через таблицу методов, на которую указывает тег объекта. Когда вызывается S.Area для S : Shape'Class, генерируется код «возьми тег S, найди в его таблице слот операции Area, вызови по найденному адресу» — это косвенный вызов, как виртуальный в C++. Для конкретного (не class-wide) типа тот же синтаксис компилируется в прямой вызов известной операции — без косвенности. Интерфейсы реализуются дополнительными таблицами диспетчеризации (по одной на интерфейс), чтобы вызов Draw через Drawable'Class находил правильный слот независимо от полного типа объекта. Стоимость диспетчеризации — один косвенный переход, ограниченный и предсказуемый по времени, что важно для реального времени: в отличие от поиска метода в динамических языках, здесь нет хеш-таблиц и перебора — только индексирование в таблице по фиксированному смещению.

Полиморфизм на практике: расширяемые системы

Чтобы оценить ценность диспетчеризации, рассмотрим типовую инженерную задачу: система должна обрабатывать разнородные сущности с общим интерфейсом, причём набор сущностей со временем растёт. Например, графический редактор работает с фигурами; завтра добавится новый вид фигуры. Без полиморфизма обработка свелась бы к гигантскому разбору вариантов (case по типу) в каждой операции, и добавление нового вида потребовало бы править каждый такой разбор по всему коду — хрупко и чревато пропусками. Class-wide полиморфизм решает это элегантно: операция Print_Area (S : Shape'Class) написана один раз и работает с любой фигурой, а новый вид добавляется отдельным потомком с его реализацией Area — существующий код не меняется вовсе.

Это и есть практическое выражение принципа открытости/закрытости: система открыта для расширения (добавляй потомков) и закрыта для изменения (старый код не трогаешь). В долгоживущих системах, где требования постоянно дополняются, такая архитектура экономит огромные усилия и снижает риск регрессий: добавление функциональности локализовано в новом модуле, а не размазано правками по всей кодовой базе. Полиморфная коллекция (массив access Shape'Class) довершает картину, позволяя хранить и единообразно обрабатывать объекты всех видов сразу, диспетчеризуя каждый по его фактическому типу.

Явность механизма как преимущество Ada

Отдельно стоит подчеркнуть фирменную черту Ada — явность выбора между статическим и динамическим связыванием. В Java все методы экземпляра виртуальны по умолчанию; в C++ виртуальность включается словом virtual, но место вызова не показывает, статический он или динамический. В Ada динамическая диспетчеризация происходит только при вызове через class-wide тип (T'Class), и это видно прямо в коде: если параметр объявлен Shape — связывание статическое, если Shape'Class — динамическое. Читатель всегда знает, какой именно вызов перед ним, без догадок и без заглядывания в объявления типов. Эта прозрачность важна вдвойне в системах реального времени и в верифицируемом коде, где стоимость и предсказуемость каждого вызова имеют значение: динамические вызовы видны, поддаются учёту в анализе времени, а при необходимости их можно сознательно ограничить или заменить статически связанными. Явность — не педантизм, а инструмент контроля над поведением и стоимостью программы.

Перенаправление в родителя и инварианты иерархии

Частая потребность при переопределении — выполнить работу родителя, а затем дополнить её своей. В Ada переопределяющая операция может явно вызвать родительскую версию, квалифицировав вызов именем родительского типа: внутри переопределённой операции Circle можно обратиться к Shape-версии, преобразовав объект к родительскому типу. Это аналог super из Java и важен для аккуратного расширения поведения: потомок не дублирует логику родителя, а надстраивает над ней. Типичный пример — операция инициализации или вывода, где потомок сперва зовёт родительскую (она обрабатывает общие поля), а потом добавляет обработку собственных полей.

С этим связана и поддержка инвариантов в иерархии. Если базовый тип объявляет Type_Invariant, потомки обязаны его соблюдать — расширение не должно нарушать обещаний родителя (принцип подстановки Лисков, выраженный средствами языка). Контракты операций при наследовании тоже ведут себя дисциплинированно: переопределяющая операция должна оставаться совместимой с контрактом унаследованной, чтобы код, работающий через class-wide тип, мог полагаться на обещания базового типа независимо от фактического потомка. Эта согласованность контрактов вдоль иерархии — то, что делает полиморфизм Ada не только гибким, но и надёжным: вызывая операцию через Shape'Class, вы получаете гарантии, заявленные на уровне Shape, какой бы конкретный потомок ни оказался за абстракцией. Так наследование в Ada служит не только переиспользованию кода, но и сохранению корректности на всех уровнях иерархии.

Частые ошибки

  • Забыть overriding и случайно создать перегрузку. Без явного overriding опечатка в сигнатуре породит новую операцию вместо переопределения; пишите overriding, чтобы компилятор проверил намерение.
  • Ожидать диспетчеризации от конкретного типа. Динамический вызов происходит только через class-wide тип ('Class); вызов через переменную конкретного типа связывается статически.
  • Класть разнородные объекты прямо в массив. Объекты разных потомков имеют разный размер; для полиморфной коллекции используйте указатели access Shape'Class.
  • Пытаться наследовать поля от нескольких родителей. Множественное наследование в Ada — только через интерфейсы (без полей); полноценный родитель с данными может быть лишь один, что и исключает ромбовидную проблему.

Итоги

  • Потомок объявляется как расширение: type Circle is new Shape with record ... — наследует поля и операции, добавляет свои.
  • overriding/not overriding явно заявляют намерение, и компилятор ловит случайную перегрузку вместо переопределения.
  • Class-wide тип T'Class охватывает тип и всех потомков; вызов операции через него диспетчеризуется по тегу в рантайме.
  • Вызов через конкретный тип статичен, через 'Class — динамичен; Ada делает выбор механизма явным.
  • Абстрактные типы запрещают экземпляры; интерфейсы — контракты без полей — дают безопасное множественное наследование без ромбовидной проблемы.
Проверьте себя
1. Когда вызов примитивной операции в Ada диспетчеризуется динамически (по фактическому типу объекта)?
AВсегда, для любого тэгированного типа автоматически
BКогда вызов идёт через class-wide тип (T'Class); через конкретный тип вызов связывается статически
CТолько если операция помечена словом virtual
DНикогда — в Ada вся диспетчеризация статическая
2. Зачем в Ada писать overriding перед переопределяемой операцией потомка?
AЭто обязательно, иначе код не скомпилируется
BЧтобы компилятор проверил, что операция действительно переопределяет унаследованную, и поймал случайную перегрузку из-за ошибки в сигнатуре
CЧтобы сделать операцию абстрактной
DЧтобы запретить наследование этой операции
3. Как Ada обеспечивает множественное наследование, избегая ромбовидной проблемы наследования данных?
AРазрешает несколько полноценных родителей с полями
BЧерез интерфейсы — контракты без полей и реализации; полноценный родитель с данными может быть только один
CПолностью запрещает любое наследование
DКопирует поля всех родителей в потомка