Перечисления и модульные типы
Перечисления и модульные типы: как Ada даёт именам значения вместо «магических чисел», почему Boolean и Character — это перечисления, и зачем нужны целые с циклическим переполнением (mod).
Перечисление — тип, значения которого заданы списком именованных литералов; модульный тип — беззнаковый целый, чья арифметика «заворачивается» по модулю вместо переполнения.
Перечисления: имена вместо чисел
Один из самых выразительных инструментов Ada — перечислимые типы. Они позволяют завести тип, значениями которого служат осмысленные имена, а не числа:
type Day is (Mon, Tue, Wed, Thu, Fri, Sat, Sun);
type Color is (Red, Green, Blue);
type State is (Idle, Running, Paused, Stopped);
Today : Day := Wed;
Light : Color := Red;
Теперь Today может принимать только одно из семи значений дня недели — ни число, ни что-либо иное туда не запишешь. Сравните с распространённым антипаттерном «магических чисел», где день кодируют как Integer от 0 до 6 и потом гадают, что значит «3». Перечисление делает код самодокументируемым: if Today = Sat читается мгновенно, а if Today = 5 требует комментария и провоцирует ошибки. Это прямое следствие принципа Steelman о читаемости.
Важнейшая деталь: значения перечисления упорядочены в порядке объявления. Mon идёт раньше Tue, поэтому работают сравнения: Mon < Fri истинно. (В коде это оператор «меньше», в HTML — <.) Каждому литералу соответствует порядковый номер (позиция), начиная с нуля: Mon — 0, Tue — 1 и так далее. Но вы работаете с именами, а не с номерами — числа остаются деталью реализации.
Boolean и Character — тоже перечисления
Красота подхода в его универсальности. В Ada предопределённые типы Boolean и Character — это тоже перечисления, просто встроенные. Boolean определён по сути как type Boolean is (False, True);. Отсюда следует приятное: False < True истинно (False имеет позицию 0, True — 1), и булевы значения можно сравнивать и упорядочивать, как любое перечисление. Character — перечисление из всех символов в порядке их кодов. Эта единообразность — признак продуманного дизайна: не нужно учить отдельные правила для булевых, символов и пользовательских перечислений, они все подчиняются одной модели.
Атрибуты перечислений
К перечислениям применимы те же атрибуты, что мы видели у чисел, плюс специфические. Вот самые полезные (подробно об атрибутах — в отдельном уроке):
Day'First -- первое значение: Mon
Day'Last -- последнее значение: Sun
Day'Succ (Mon) -- следующее за Mon: Tue
Day'Pred (Sun) -- предыдущее перед Sun: Sat
Day'Pos (Wed) -- позиция Wed: 2
Day'Val (4) -- значение с позицией 4: Fri
Day'Image (Wed) -- строковое представление: "WED"
Атрибуты 'Succ и 'Pred (от successor/predecessor) дают соседние значения — удобно для перебора. 'Pos и 'Val переводят между значением и его позицией-числом, когда это действительно нужно. 'Image даёт текст для печати. Особенно ценно, что всё это работает единообразно для любого дискретного типа: чисел, символов, перечислений. Например, напечатать состояние машины можно так:
with Ada.Text_IO; use Ada.Text_IO;
procedure Show_State is
type State is (Idle, Running, Paused, Stopped);
Current : State := Running;
begin
Put_Line ("Текущее состояние: " & State'Image (Current));
Put_Line ("Следующее по списку: " & State'Image (State'Succ (Current)));
end Show_State;
Вывод:
Текущее состояние: RUNNING Следующее по списку: PAUSED
Модульные типы: целые, которые заворачиваются
Теперь о другом инструменте — модульных типах. Обычный целочисленный тип при переполнении (выходе за границу) поднимает Constraint_Error: это безопасно, ошибка не проходит молча. Но иногда нужна противоположная семантика — арифметика «по кругу», как у стрелки часов: после максимума снова идёт минимум. Это нужно для счётчиков, хеш-функций, работы с битами, аппаратных регистров. Для таких задач есть модульные типы:
type Byte is mod 256; -- значения 0 .. 255, арифметика по модулю 256
B : Byte := 250;
B := B + 10; -- НЕ ошибка! 260 mod 256 = 4
-- теперь B = 4
Конструкция type Имя is mod N; создаёт беззнаковый целочисленный тип со значениями от 0 до N−1, у которого арифметика замкнута по модулю N: результат всегда «заворачивается» в диапазон, без исключений. 250 + 10 = 260, а 260 mod 256 = 4, поэтому B станет 4, а не вызовет ошибку. Модуль часто берут степенью двойки (mod 256, mod 65536), потому что тогда тип точно моделирует машинное беззнаковое слово нужной разрядности — идеально для низкоуровневой работы с битами и байтами.
Битовые операции на модульных типах
Модульные типы — единственные в Ada, к которым применимы побитовые логические операции: and, or, xor, not работают над их битами. Это делает их естественным инструментом для масок и флагов:
type Flags is mod 256;
Read_Flag : constant Flags := 2#0000_0001#; -- двоичный литерал: бит 0
Write_Flag : constant Flags := 2#0000_0010#; -- бит 1
Mask : Flags := Read_Flag or Write_Flag; -- 2#0000_0011# = 3
Запись 2#0000_0001# — это двоичный литерал: основание 2 указано перед решёткой, само число между решётками, подчёркивания группируют биты. Ada поддерживает литералы в любой системе счисления так же: 16#FF# — это 255 в шестнадцатеричной. Комбинация модульных типов и таких литералов делает Ada выразительным языком и для низкоуровневого программирования железа, не теряя при этом типобезопасности: Flags остаётся отдельным типом, его не спутать с обычным числом.
Как работает под капотом выбор семантики переполнения
Глубокая мысль этого урока: Ada даёт вам выбрать поведение при переполнении прямо через тип. Хотите, чтобы выход за границу был ошибкой (потому что он означает баг)? Используйте обычный range-тип — получите Constraint_Error. Хотите циклическую арифметику (потому что она по смыслу правильна, как часы)? Используйте mod-тип — получите заворачивание. В большинстве языков семантика переполнения единая и навязанная: либо всё молча заворачивается (и баги прячутся), либо всё кидает ошибку. Ada разделяет эти намерения на уровне типов, и тип сам документирует, какое поведение задумано. Прочитав type Counter is mod 2**32;, инженер сразу понимает: здесь заворачивание ожидаемо. Прочитав type Altitude is range 0 .. 60000; — что выход за границу есть ошибка. Семантика стала частью контракта типа.
Перечисления под капотом: представление и связь с железом
Углубимся в то, как перечисления существуют в машине, потому что здесь Ada снова сочетает высокоуровневую безопасность с низкоуровневым контролем. По умолчанию литералы перечисления нумеруются подряд с нуля: Mon — 0, Tue — 1 и так далее. Это и есть их внутреннее представление — компактные целые. Но программист работает с именами, и компилятор гарантирует, что в переменную типа Day попадёт только корректный код одного из семи значений. Атрибуты 'Pos и 'Val дают мост между миром имён и миром чисел, когда он действительно нужен (например, для сериализации).
Иногда, однако, числовые коды значений должны быть конкретными — потому что их диктует аппаратура или протокол. Регистр устройства может кодировать состояние числами 1, 2, 4, 8 (битовые позиции), а не 0, 1, 2, 3. Для этого Ada позволяет задать представление перечисления явно, через спецификацию representation:
type Command is (Read, Write, Reset, Halt);
for Command use (Read => 1, Write => 2, Reset => 4, Halt => 8);
Конструкция for Command use (...) предписывает, какими именно числами представлены значения. (Стрелка => в HTML экранируется.) Теперь Reset в памяти — это 4, а не 2 (как было бы по умолчанию). При этом на уровне программы вы по-прежнему пишете осмысленные имена Read, Halt, а соответствие конкретным кодам железа берёт на себя компилятор. Это блестящее разделение: смысл выражается именами, представление подгоняется под внешние требования, и одно не мешает другому. Программист читает понятный код, аппаратура получает точные биты.
Эта возможность — часть большой темы спецификаций представления в Ada, которые позволяют точно управлять раскладкой данных в памяти (размер типа, порядок и положение полей записи, представление перечислений) без отказа от типобезопасности. Именно поэтому Ada применима для прямого программирования аппаратных регистров, разбора бинарных протоколов и взаимодействия с оборудованием — областей, которые в «безопасных» высокоуровневых языках обычно требуют ухода в небезопасный код. В Ada вы остаётесь в типобезопасном мире: Command по-прежнему отдельный тип с проверками, его нельзя случайно спутать с произвольным числом, но при этом он точно ложится на биты реального устройства. Этот мост между абстракцией и железом — одна из причин, по которой язык так хорошо чувствует себя во встраиваемых системах, где и надёжность, и контроль над каждым битом одинаково критичны.
Подытожим контраст двух героев урока, потому что он иллюстрирует общий принцип Ada. Перечисления и модульные типы решают, казалось бы, разные задачи: первые — про осмысленные именованные значения, вторые — про целочисленную арифметику особого рода. Но оба воплощают одну идею — тип должен точно выражать намерение. Перечисление говорит «эта величина — одно из таких-то состояний, и ничего другого»; модульный тип говорит «эта величина — беззнаковое слово, чья арифметика заворачивается». В обоих случаях прочитавший объявление инженер сразу понимает смысл и ограничения данных, а компилятор соблюдает их. Это противоположность подходу «всё есть int», где намерение теряется и приходится держать его в голове или в комментариях. Богатство скалярных типов Ada — перечисления, модульные, поддиапазоны, производные — это, по сути, богатый словарь для точного выражения того, что за число (или не-число) перед нами. Чем точнее словарь, тем меньше места для ошибок и тем самодокументированнее код. Привычка выбирать правильный тип под каждую величину, а не хвататься за универсальный Integer, — один из главных навыков, который даёт этот раздел.
Частые ошибки и заблуждения
- Кодировать состояния числами вместо перечислений. «Магические числа» нечитаемы и провоцируют ошибки; перечисление делает код самодокументируемым и типобезопасным.
- Забывать, что перечисления упорядочены. Литералы сравнимы (
Mon < Fri) и имеют позиции с нуля; это позволяет перебор и сравнения. - Ждать
Constraint_Errorот модульного типа. Модульная арифметика заворачивается по модулю и не поднимает ошибку переполнения — в этом её смысл. - Применять битовые
and/or/xorк обычным целым. Побитовые операции в Ada определены на модульных типах; для флагов и масок заводятmod-тип. - Не различать намерения переполнения.
range-тип говорит «выход за границу — ошибка»,mod-тип — «заворачивание ожидаемо»; выбирайте тип под смысл задачи.
Итоги
- Перечисления задают тип списком именованных литералов, заменяя «магические числа» читаемыми именами; значения упорядочены и имеют позиции с нуля.
BooleanиCharacter— это встроенные перечисления, подчиняющиеся той же модели, что и пользовательские.- Атрибуты
'First/'Last/'Succ/'Pred/'Pos/'Val/'Imageединообразно работают для любого дискретного типа. - Модульные типы (
mod N) — беззнаковые целые с арифметикой по модулю (заворачивание вместо ошибки), идеальные для счётчиков, битов и моделирования железа. - Только к модульным типам применимы побитовые
and/or/xor/not; вместе с двоичными/шестнадцатеричными литералами это даёт типобезопасное низкоуровневое программирование.