Перегрузка, именованные аргументы и условные выражения
Три выразительных средства Ada: перегрузка имён (одно имя — много реализаций по типам), именованные аргументы (вызов как самодокументация) и условные выражения if/case, которые возвращают значение.
Перегрузка позволяет нескольким подпрограммам носить одно имя, различаясь типами параметров; компилятор выбирает нужную по контексту вызова.
Перегрузка: одно имя, много смыслов
Мы уже незаметно пользовались перегрузкой: Put печатает и строки, и символы, и числа — это разные подпрограммы с одним именем. Перегрузка (overloading) — это возможность дать нескольким подпрограммам одинаковое имя, если они различаются сигнатурой (типами или числом параметров, типом результата). Компилятор по контексту вызова сам понимает, какую из них вы имеете в виду:
function Max (A, B : Integer) return Integer is
(if A > B then A else B);
function Max (A, B : Float) return Float is
(if A > B then A else B);
I : Integer := Max (3, 7); -- вызовется целая версия
F : Float := Max (2.5, 1.5); -- вызовется вещественная версия
Обе функции зовутся Max, но одна работает с целыми, другая — с вещественными. Когда вы пишете Max (3, 7), компилятор видит целые аргументы и выбирает первую; для Max (2.5, 1.5) — вторую. Это называется разрешением перегрузки (overload resolution). Зачем оно? Ради естественности: операция «максимум» концептуально одна, и заставлять программиста придумывать Max_Int, Max_Float, Max_Day было бы громоздко. Перегрузка даёт единое осмысленное имя для семейства родственных операций. Заметьте, что внутри использована форма выражения-функции с условным выражением if ... then ... else — к нему мы вернёмся ниже.
Перегрузка учитывает и тип результата
Особенность Ada, отличающая её от многих языков: при разрешении перегрузки учитывается не только типы аргументов, но и ожидаемый тип результата. Если две функции различаются только типом возврата, Ada всё равно сможет выбрать нужную по контексту, в котором используется результат:
function Zero return Integer is (0);
function Zero return Float is (0.0);
X : Integer := Zero; -- выбрана целая версия по типу X
Y : Float := Zero; -- выбрана вещественная по типу Y
Здесь обе Zero не имеют параметров и различаются лишь типом результата, но компилятор смотрит, куда присваивается значение, и выбирает подходящую. Эта «двусторонняя» разрешающая способность (по входу и по ожидаемому выходу) делает систему типов Ada особенно гибкой. Литералы перечислений, кстати, тоже могут быть перегружены: если в двух разных типах есть значение Red, компилятор разберётся по контексту.
Именованные аргументы: вызов как документация
Второе средство мы тоже мельком видели — именованные аргументы. При вызове можно указывать параметры по имени, а не только по позиции, используя стрелку =>:
procedure Configure (Width : Integer;
Height : Integer;
Color : Integer) is ... ;
-- позиционный вызов: что есть что — непонятно без заглядывания в объявление
Configure (800, 600, 5);
-- именованный вызов: самодокументирующийся
Configure (Width => 800, Height => 600, Color => 5);
Сравните две формы. Позиционный вызов Configure (800, 600, 5) заставляет читателя помнить порядок параметров и гадать, что значит 5. Именованный вызов Configure (Width => 800, Height => 600, Color => 5) читается сам по себе: ясно, что 800 — ширина, а 5 — цвет. (Стрелка => в HTML экранируется.) Это особенно ценно, когда у подпрограммы много параметров или среди них есть «загадочные» булевы флаги: Set_Mode (Verbose => True, Strict => False) понятнее, чем Set_Mode (True, False). Именованные аргументы также позволяют переставлять параметры местами и удобно работать со значениями по умолчанию. Это прямой вклад в читаемость — а читаемость, как мы помним, центральная ценность Ada.
Параметры по умолчанию
Параметрам можно задавать значения по умолчанию, и тогда при вызове их можно опускать:
procedure Greet (Name : String; Polite : Boolean := True) is
begin
if Polite then
Put_Line ("Здравствуйте, " & Name);
else
Put_Line ("Привет, " & Name);
end if;
end Greet;
-- вызовы:
Greet ("Ада"); -- Polite по умолчанию True
Greet ("Ада", Polite => False); -- явно переопределили
Значение по умолчанию := True в объявлении параметра означает: если аргумент не передан, используется это значение. Вместе с именованными аргументами это даёт гибкие, читаемые вызовы: вы указываете только то, что хотите изменить.
Условные выражения: if и case, возвращающие значение
Третье средство — условные выражения, появившиеся в Ada 2012. Раньше if и case были только операторами (управляли действиями). Теперь они могут быть и выражениями, возвращающими значение, что делает код компактнее:
-- условное ВЫРАЖЕНИЕ if присваивает результат
Sign : Integer := (if X > 0 then 1 elsif X < 0 then -1 else 0);
-- условное ВЫРАЖЕНИЕ case
Category : String :=
(case Score is
when 90 .. 100 => "Отлично",
when 70 .. 89 => "Хорошо",
when others => "Иначе");
Выражение (if X > 0 then 1 elsif X < 0 then -1 else 0) вычисляется в одно из трёх чисел и присваивается переменной. Обратите внимание: у условного выражения ветвь else обязательна (значение должно быть всегда), а скобки вокруг него требуются. Аналогично case-выражение выбирает значение и обязано покрыть все варианты (как и оператор case). Условные выражения особенно хороши в инициализациях, аргументах и выражениях-функциях, заменяя громоздкие конструкции с временной переменной и оператором if. Это пример того, как современная Ada становится выразительнее, не теряя строгости: полнота покрытия и обязательный else сохраняют безопасность.
Как работает под капотом разрешение перегрузки
Глубокий вопрос: как компилятор не запутается, выбирая среди перегруженных имён? Он действует как решатель ограничений. Для каждого вызова он рассматривает все видимые подпрограммы с этим именем (кандидатов), отбрасывает те, чьи параметры несовместимы с переданными аргументами, и учитывает ожидаемый тип результата. Если остаётся ровно один кандидат — он выбран. Если ни одного — ошибка «нет подходящей версии». Если несколько одинаково подходящих — ошибка «неоднозначный вызов», и тогда программист должен уточнить (например, явным преобразованием или указанием типа). Принципиально, что всё это происходит статически, на этапе компиляции: в готовой программе никакого «выбора версии в рантайме» нет, вызов уже намертво связан с конкретной подпрограммой. Это и быстро (нет накладных расходов), и безопасно (неоднозначность поймана до запуска). Сильная типизация снова работает на нас: именно потому, что типы всех выражений точно известны, компилятор способен однозначно разрешить перегрузку. В языках со слабыми типами надёжное разрешение по типу результата было бы невозможным.
Операторы — это тоже подпрограммы, и их можно перегружать
Перегрузка в Ada простирается дальше обычных подпрограмм: сами операторы — это функции с особыми именами, и их тоже можно перегрузить для своих типов. Это глубокая идея: когда вы пишете A + B, компилятор на самом деле вызывает функцию с именем "+". Для предопределённых типов такие функции уже есть, но для собственного типа вы вправе определить, что значит +, *, = или любой другой оператор:
type Vector2 is record
X, Y : Float;
end record;
function "+" (Left, Right : Vector2) return Vector2 is
((X => Left.X + Right.X, Y => Left.Y + Right.Y));
function "*" (Scalar : Float; V : Vector2) return Vector2 is
((X => Scalar * V.X, Y => Scalar * V.Y));
Имя функции — оператор в кавычках: "+", "*". (Стрелки => в HTML экранируются.) Теперь для двумерных векторов работает естественная запись: V3 := V1 + V2; сложит их покомпонентно, а Scaled := 2.0 * V1; умножит на скаляр — потому что мы определили, что значат эти операторы для Vector2. Под капотом V1 + V2 — это просто вызов нашей функции "+", разрешённый тем же механизмом перегрузки: компилятор видит операнды типа Vector2 и выбирает нашу версию, а для чисел — встроенную. Никакой магии, лишь функции с операторными именами.
Зачем это нужно и почему уместно именно здесь? Потому что это логическое завершение темы перегрузки: единое имя для семейства родственных операций, доведённое до синтаксиса операторов. Перегрузка операторов делает пользовательские типы столь же выразительными, как встроенные: комплексные числа, векторы, матрицы, денежные суммы, физические величины можно складывать и сравнивать привычной нотацией, а не через громоздкие вызовы вроде Add (V1, V2). Код, работающий с математическими абстракциями, читается как математика. При этом сохраняется вся строгость: ваш оператор + определён только для Vector2, его нельзя случайно применить к несовместимым типам, типобезопасность не страдает.
Важна и мера: Ada позволяет перегружать существующие операторы, но не придумывать новые символы и не менять приоритеты — это сознательное ограничение против злоупотреблений, когда оператор начинает значить нечто неожиданное и код становится загадкой. Хорошая практика — перегружать оператор только тогда, когда его смысл для нового типа интуитивно совпадает с привычным (сложение векторов — да; «сложение» двух не связанных сущностей — нет). В этих рамках перегрузка операторов — мощный инструмент построения чистых, читаемых предметных абстракций, и вместе с обычной перегрузкой, именованными аргументами и условными выражениями она составляет арсенал выразительности Ada, ни в одной точке не жертвующий безопасностью.
Частые ошибки и заблуждения
- Создавать неоднозначные перегрузки. Если по контексту нельзя выбрать одну версию, компилятор сообщит о неоднозначности; различайте подпрограммы значимо (по типам параметров или результата).
- Забывать
elseв условном выражении. Уif-выражения ветвьelseобязательна — оно всегда должно давать значение; это не оператор, гдеelseнеобязателен. - Путать оператор и выражение
case. Оба требуют полноты покрытия, но выражение возвращает значение и пишется в скобках с запятыми между ветвями. - Не использовать именованные аргументы для флагов.
Foo (True, False)нечитаемо;Foo (Strict => True, Verbose => False)самодокументируется. - Думать, что перегрузка решается в рантайме. Выбор версии статический; готовый вызов связан с конкретной подпрограммой без накладных расходов.
Итоги
- Перегрузка позволяет давать одно имя нескольким подпрограммам, различающимся сигнатурой; компилятор выбирает нужную по типам аргументов и даже по ожидаемому типу результата.
- Именованные аргументы (
Width => 800) делают вызовы самодокументирующимися и работают вместе со значениями по умолчанию. - Условные выражения
ifиcase(Ada 2012) возвращают значение; у них обязателенelse/ полнота покрытия и нужны скобки. - Эти средства повышают выразительность и читаемость, не жертвуя строгостью языка.
- Разрешение перегрузки полностью статическое: оно безопасно (неоднозначность ловится при компиляции) и бесплатно в рантайме.