Поддиапазоны и производные типы

Поддиапазоны и производные типы: как ограничить значения диапазоном прямо в типе, в чём разница между «совместимым» подтипом и «несовместимым» производным типом, и почему это решает реальные инженерные проблемы.

Поддиапазон (subtype) сужает множество значений существующего типа, оставаясь с ним совместимым; производный тип (derived type) создаёт совершенно новый, несовместимый тип на основе старого.

Объявление собственных типов

До сих пор мы пользовались готовыми типами вроде Integer. Но настоящая сила Ada — в том, что вы создаёте свои типы под конкретную задачу. Самый частый приём — целочисленный тип с заданным диапазоном:

type Day_Of_Month is range 1 .. 31;
type Temperature  is range -273 .. 1_000;
type Percent      is range 0 .. 100;

Конструкция type Имя is range Низ .. Верх; создаёт новый целочисленный тип с указанными границами. Две точки .. задают диапазон включительно. Теперь переменная типа Percent физически не может выйти за пределы 0–100: попытка присвоить 150 либо отвергается компилятором (если он это докажет статически), либо в рантайме поднимает Constraint_Error. Вы закодировали ограничение прямо в типе, и оно стало проверяемым законом, а не комментарием-пожеланием.

Поддиапазоны: сужение без разрыва родства

Иногда вы хотите ограничить значения, но при этом сохранить совместимость с базовым типом, чтобы свободно смешивать величины. Для этого служит подтип (subtype):

subtype Working_Hour is Integer range 0 .. 23;
subtype Adult_Age    is Integer range 18 .. 150;

H : Working_Hour := 9;
N : Integer      := 100;
R : Integer      := H + N;     -- ОК: Working_Hour совместим с Integer

Ключевое: Working_Hour — это тот же самый Integer, просто с дополнительным ограничением диапазона. Значения подтипа и базового типа взаимозаменяемы в выражениях — никаких преобразований не нужно. Подтип лишь добавляет проверку границ. Это и есть смысл слова «subtype»: подмножество значений того же типа. Кстати, знакомые Natural и Positive определены в стандарте именно так: subtype Natural is Integer range 0 .. Integer'Last; и subtype Positive is Integer range 1 .. Integer'Last;. Теперь понятно, почему их можно свободно смешивать с обычными целыми.

Производные типы: намеренная несовместимость

А теперь — противоположный инструмент. Иногда вы хотите создать тип, который, наоборот, нельзя случайно смешать с другими, даже если в основе тот же Integer. Это нужно, чтобы развести величины разной природы. Для этого служат производные типы через type ... is new:

type Meters  is new Integer;
type Seconds is new Integer;

Distance : Meters  := 100;
Duration : Seconds := 30;
Bad      : Meters  := Distance + Duration;  -- ОШИБКА: Meters + Seconds запрещено

Вот это — одна из самых важных идиом надёжного программирования на Ada. Meters и Seconds оба «сделаны из» Integer, но это разные, несовместимые типы. Сложить метры с секундами невозможно — компилятор отвергнет бессмыслицу. Вы физически не сможете перепутать дистанцию и время, потому что система типов охраняет их разделение. Если же сложение метров с секундами действительно нужно (скажем, по особой формуле), вы делаете это явным преобразованием — и эта явность сигнализирует читателю: «здесь происходит нетривиальный переход, обрати внимание».

Различие между подтипом и производным типом — фундаментальное, и его стоит закрепить таблицей:

Аспектsubtype (подтип)type ... is new (производный)
Совместимость с базойсовместим, смешивается свободнонесовместим, нужно явное преобразование
Зачем нуженсузить диапазон, оставив родстворазвести величины разной природы
Примерsubtype Hour is Integer range 0..23;type Meters is new Integer;

Производные типы наследуют операции

Тонкость, которая радует: производный тип наследует операции базового. type Meters is new Integer; автоматически получает сложение, вычитание, сравнение — всё, что умеет Integer — но эти операции работают только в пределах Meters. То есть метры можно складывать с метрами (Distance + Distance законно), результат — тоже метры. А вот метры с секундами — нет. Вы получаете полноценную арифметику, замкнутую внутри смыслового типа. Можно ограничить и диапазон при наследовании: type Small_Meters is new Integer range 0 .. 1000; — новый несовместимый тип, да ещё с границами.

Как работает под капотом «одинаковое представление, разные типы»

Возникает законный вопрос: если Meters и Seconds оба представлены как Integer, чем они отличаются в машине? Ответ: в машине — ничем, различие существует только на этапе компиляции. Это так называемая «номинальная типизация»: типы различаются по имени, а не по внутреннему устройству. Во время исполнения метры и секунды — это просто целые той же разрядности, и арифметика над ними столь же быстра. Но компилятор, видя имена типов, не даёт их смешать. Получается, что безопасность достаётся почти бесплатно: вы платите лишь дисциплиной написания преобразований там, где переходите между типами, а в рантайме никаких накладных расходов нет. Это блестящий инженерный компромисс: смысл величин охраняется, скорость не страдает.

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

Динамические поддиапазоны и предикаты: ограничения, вычисляемые на лету

До сих пор границы наших типов были константами, известными при компиляции. Но Ada позволяет границам быть динамическими — вычисляемыми во время выполнения. Это раздвигает применимость поддиапазонов далеко за пределы фиксированных лимитов:

procedure Process (Limit : Integer) is
   subtype Valid_Index is Integer range 1 .. Limit;   -- граница из параметра!
   I : Valid_Index;
begin
   I := Limit;        -- ОК
   -- I := Limit + 1; -- Constraint_Error: вне динамической границы
   null;
end Process;

Здесь верхняя граница подтипа Valid_Index — это Limit, значение которого станет известно лишь при вызове Process. Подтип формируется заново при каждом входе в процедуру, под актуальное значение параметра. Проверки границ при этом работают так же: попытка выйти за динамическую границу поднимет Constraint_Error. Это исключительно полезно для работы с данными, чей размер известен только в рантайме, — и снова без единого ручного if для проверки границ: ограничение живёт в типе, проверка автоматическая.

Ada 2012 пошла дальше и ввела предикаты подтипов — произвольные логические условия, которым должны удовлетворять значения, не сводящиеся к простому диапазону. Хотите тип «только чётные числа» или «только символы-цифры»? Предикат это позволяет:

subtype Even is Integer
   with Dynamic_Predicate => Even mod 2 = 0;

subtype Digit_Char is Character
   with Static_Predicate => Digit_Char in '0' .. '9';

Аспект Dynamic_Predicate => Even mod 2 = 0 требует, чтобы любое значение подтипа Even было чётным; присваивание нечётного поднимет Constraint_Error. (Стрелка => в HTML экранируется.) Предикаты превращают подтип из «диапазона» в «произвольное проверяемое множество значений», радикально расширяя выразительность системы типов. Теперь смысловое ограничение почти любой сложности можно закодировать прямо в типе и заставить язык его соблюдать. Это прямое продолжение центральной идеи Ada — знание о допустимых данных живёт в типах, а не в разбросанных проверках, — доведённое до логического предела: не только «число от и до», но и «число, удовлетворяющее любому условию». Вместе с производными типами и динамическими границами предикаты дают инженеру богатейший арсенал, чтобы сделать неправильные данные непредставимыми, а ошибки — невозможными по построению. Заметьте, что в условии предиката встретился оператор in и диапазон '0' .. '9' — типичная для Ada проверка принадлежности значению множеству.

Соберём практический вывод из всего арсенала — поддиапазонов, производных типов, динамических границ, предикатов. Все они служат одной стратегической цели: сделать неправильные данные непредставимыми. Это сдвиг мышления, который отличает зрелого разработчика на Ada. Вместо того чтобы повсюду расставлять проверки «а вдруг здесь отрицательное / вне диапазона / нечётное», вы один раз кодируете ограничение в типе, и дальше компилятор и среда исполнения соблюдают его за вас, во всех точках программы. Проверка перестаёт быть вашей заботой и становится свойством данных. Чем точнее тип описывает реальные допустимые значения, тем больше ошибок становятся попросту невыразимыми: их нельзя написать так, чтобы скомпилировалось и при этом нарушало инвариант. Это и есть инженерная философия «дорого ошибиться» в действии — превентивно закрывать классы ошибок проектированием типов, а не латать их проверками постфактум. Освоив этот образ мысли, вы начнёте проектировать программы, которые трудно использовать неправильно, потому что неправильное использование просто не имеет представления в системе типов.

Частые ошибки и заблуждения

  • Путать subtype и type ... is new. Подтип совместим с базой (смешивается свободно), производный тип — несовместим (нужно преобразование). Это разные цели.
  • Думать, что производный тип «медленнее». Различие чисто компиляционное (номинальное); в рантайме представление и скорость те же, что у базового типа.
  • Ожидать, что метры сложатся с секундами «потому что оба целые». Производные типы для того и созданы, чтобы это было ошибкой; смешивать величины разной природы нельзя.
  • Забывать, что границы проверяются. Присваивание вне диапазона подтипа/типа даёт Constraint_Error, а не тихо «обрезается».
  • Не пользоваться этим инструментом. Заводить отдельные типы под разные величины — идиома Ada, а не излишество; именно она ловит ошибки «не то число не туда».

Итоги

  • type Имя is range Низ .. Верх; создаёт собственный целочисленный тип с проверяемыми границами.
  • Подтип (subtype) сужает диапазон, оставаясь совместимым с базовым типом; так определены Natural и Positive.
  • Производный тип (type ... is new) создаёт новый несовместимый тип на той же основе — идеально, чтобы развести метры и секунды.
  • Производные типы наследуют операции базового, но замыкают их внутри себя: метры складываются с метрами, но не с секундами.
  • Различие типов номинальное (по имени, на этапе компиляции); в рантайме представление и скорость не меняются — безопасность почти бесплатна.
Проверьте себя
1. В чём ключевая разница между subtype и type ... is new?
AНет разницы
Bsubtype совместим с базовым типом, а производный тип несовместим (нужно явное преобразование)
Csubtype медленнее
DПроизводный тип нельзя складывать сам с собой
2. Если объявить type Meters is new Integer; и type Seconds is new Integer;, можно ли сложить метры с секундами?
AДа, оба ведь целые
BНет — это разные несовместимые типы, сложение даст ошибку компиляции
CТолько через use
DДа, но результат в Float
3. Замедляет ли производный тип программу по сравнению с базовым?
AДа, вдвое
BНет — различие номинальное (по имени, при компиляции); в рантайме представление и скорость те же
CДа, нужна доп. память
DЗависит от ОС