Перечисления и модульные типы

Перечисления и модульные типы: как 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; вместе с двоичными/шестнадцатеричными литералами это даёт типобезопасное низкоуровневое программирование.
Проверьте себя
1. Что произойдёт при переполнении модульного типа (например, mod 256)?
AConstraint_Error
BЗначение заворачивается по модулю (260 mod 256 = 4) без ошибки
CПрограмма зависнет
DТип изменится
2. Чем по сути является предопределённый тип Boolean в Ada?
AЦелым числом
BПеречислением (False, True), значения которого упорядочены
CСтрокой
DМодульным типом
3. Зачем кодировать состояния перечислением, а не числами?
AПеречисления быстрее
BРади читаемости и типобезопасности: имена самодокументируют код и их нельзя перепутать с произвольным числом
CЧисла запрещены
DЧтобы занять больше памяти