Сильная типизация: почему 1 и 1.0 несовместимы

Почему сильная типизация — это не ограничение, а главное оружие Ada, и почему 1 и 1.0 здесь — принципиально разные значения, которые нельзя сложить без разрешения.

Сильная типизация в Ada означает, что каждое значение имеет точный тип, операции между несовместимыми типами запрещены, а любое преобразование делается явно — это превращает компилятор в инструмент поиска ошибок.

Тип как смысл, а не просто размер

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

Главный принцип: значения разных типов несовместимы, даже если в памяти выглядят одинаково. Компилятор не позволит смешать их в одной операции без вашего явного разрешения. Это называется сильной (строгой) типизацией, и Ada доводит её до предела, дальше большинства известных языков.

Скалярные типы: атомы системы

Начнём с фундамента — скалярных типов. Это «атомарные» типы, значения которых не имеют внутренней структуры (в отличие от массивов и записей). Скалярные типы Ada делятся на две большие группы:

  • Дискретные — значения можно пересчитать по одному: целые (Integer), перечисления, символы (Character), булевы (Boolean).
  • Вещественные — непрерывные приближения чисел: плавающая точка (Float) и фиксированная точка.

Несколько предопределённых скалярных типов есть «из коробки»:

ТипЧто хранит
Integerцелые числа (диапазон зависит от платформы, обычно 32 бита)
Naturalцелые от 0 и выше (поддиапазон Integer)
Positiveцелые от 1 и выше (поддиапазон Integer)
Floatчисла с плавающей точкой
Booleanзначения True и False
Characterодин символ

Уже здесь видна забота о смысле: Natural и Positive — это не отдельные «беззнаковые» типы из мира С, а поддиапазоны Integer с осмысленными границами. Объявив счётчик как Natural, вы говорите компилятору и читателю: «это число не бывает отрицательным», и попытка сделать его отрицательным поднимет Constraint_Error.

Сердце сердца: почему 1 и 1.0 несовместимы

Теперь — ключевой и поначалу шокирующий факт. В Ada целое 1 и вещественное 1.0 — это значения разных типов, и сложить их напрямую нельзя:

declare
   N : Integer := 1;
   X : Float   := 1.0;
   R : Float;
begin
   R := X + N;        -- ОШИБКА КОМПИЛЯЦИИ: Float + Integer запрещено
end;

Новичок из мира Python или C недоумевает: почему язык не «доведёт» целое до вещественного автоматически? Ответ — в философии. Автоматическое приведение типов (неявное преобразование) удобно, но это источник коварных ошибок: где-то незаметно теряется дробная часть, где-то меняется точность, где-то смешиваются величины разной природы. Ada считает: если вы переходите из одной числовой области в другую, вы обязаны сказать об этом явно. Тогда читатель кода видит точку перехода, а компилятор — ваше осознанное намерение.

Правильно — преобразовать явно, назвав целевой тип как функцию:

R := X + Float (N);    -- явное преобразование N в Float, теперь верно

Запись Float (N) читается как «значение N, преобразованное к типу Float». Это преобразование типа (type conversion), и оно разрешено между «родственными» числовыми типами. Обратное тоже работает: Integer (X) превратит вещественное в целое (с округлением). Симметрично, нельзя смешать литералы:

Y : Float := 2.0 + 1;   -- ОШИБКА: 2.0 это Float, 1 это Integer
Y : Float := 2.0 + 1.0; -- верно: оба вещественные

Поэтому в коде Ada вы постоянно видите точку: 0.0, 1.0, 2.0. Это не педантизм автора, а требование типобезопасности.

Зачем такая строгость на практике

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

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

Вся проверка совместимости типов происходит статически, на этапе компиляции, до запуска программы. Компилятор для каждого выражения вычисляет тип каждого подвыражения и проверяет, что операции применяются к совместимым операндам. Если X имеет тип Float, а NInteger, то X + N отвергается ещё до того, как программа хоть раз запустится. Это даром: проверка ничего не стоит во время исполнения, потому что вся работа сделана заранее. Преобразование Float (N) же порождает реальную операцию в рантайме (перевод целого в формат с плавающей точкой), но она явная и осознанная.

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

Универсальные типы и литералы: как Ada обходит свою же строгость

У внимательного читателя возникает законное возражение: если 1 и 1.0 — несовместимые типы, то как вообще работает X := 1.0, ведь X может быть и Float, и каким-то нашим производным вещественным типом? Ответ раскрывает изящную внутреннюю механику языка — универсальные типы. Числовые литералы в Ada имеют не конкретный тип вроде Float, а особый «универсальный» тип: целые литералы — universal_integer, вещественные — universal_real. Эти универсальные значения автоматически приводятся к любому подходящему конкретному типу в контексте использования.

Поэтому Speed : Float := 2.0; и Ratio : My_Real := 2.0; оба работают: литерал 2.0 универсален и подстраивается под целевой тип. То же с целыми: Count : Integer := 5; и Idx : My_Index := 5; — литерал 5 универсален. Универсальность — это «дозированное» послабление строгости специально для литералов, потому что записать число в исходнике нужно постоянно, и заставлять программиста писать Float(2) повсюду было бы абсурдно. Но обратите внимание: послабление касается литералов, а не переменных. Переменная N типа Integer универсальной не является — она конкретного типа, и потому X + N по-прежнему запрещено. Граница проведена точно: гибкость там, где она безопасна (константы, известные при компиляции), строгость там, где важна (переменные, носители смысла).

Сюда же относятся именованные числа из урока про константы. Объявление Half : constant := 0.5; (без указания типа) создаёт именованный universal_real — константу, которая, подобно литералу, дружит с любым вещественным типом. А Half : constant Float := 0.5; привязывает её именно к Float. Это даёт инженеру выбор: математические константы, которые должны работать с разными точностями, делают универсальными, а конкретные величины привязывают к типу. Понимание универсальных типов снимает кажущееся противоречие и показывает глубину дизайна: Ada не «иногда строга, иногда нет» случайным образом — у неё есть точная, продуманная модель, где именно строгость уместна. Целочисленные литералы вдобавок участвуют в проверках диапазона при компиляции: записав Percent_Var := 150; при типе с диапазоном 0..100, вы получите ошибку прямо на этапе компиляции, потому что универсальный литерал 150 заведомо вне границ целевого типа. Строгость и удобство здесь не противоречат, а дополняют друг друга.

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

  • Складывать Integer и Float напрямую. Запрещено; нужно явное преобразование Float (N) или Integer (X).
  • Писать целые литералы в вещественных выражениях. 2.0 + 1 не скомпилируется; пишите 2.0 + 1.0. Точка в литерале определяет его тип.
  • Считать Natural и Positive отдельными типами. Это поддиапазоны Integer с границами от 0 и от 1; выход за границу даёт Constraint_Error.
  • Ждать неявного приведения «как в Python». Ada намеренно не приводит типы сама: переход между числовыми областями всегда явный, чтобы был виден в коде.
  • Думать, что строгость замедляет программу. Проверка типов статическая и бесплатна в рантайме; накладные расходы несут только реальные преобразования, которые вы пишете явно.

Итоги

  • В Ada тип — это прежде всего смысл и множество допустимых значений, а не просто размер ячейки памяти.
  • Скалярные типы делятся на дискретные (целые, перечисления, символы, булевы) и вещественные (плавающая и фиксированная точка).
  • Целое 1 и вещественное 1.0 — значения разных типов; смешивать их нельзя без явного преобразования Float(...) или Integer(...).
  • Строгость позволяет кодировать физический смысл величин (литры, секунды) отдельными типами, превращая бессмыслицу в ошибку компиляции.
  • Проверка типов статическая и бесплатная в рантайме; она ловит целый класс ошибок до запуска программы.
Проверьте себя
1. Что произойдёт при попытке сложить Integer и Float напрямую?
AЦелое автоматически станет вещественным
BОшибка компиляции — нужно явное преобразование Float(N)
CРезультат всегда 0
DПрограмма упадёт в рантайме
2. Чем по сути являются типы Natural и Positive?
AОтдельными беззнаковыми типами
BПоддиапазонами Integer с границами от 0 и от 1
CВещественными типами
DПеречислениями
3. Когда происходит проверка совместимости типов в Ada?
AВо время исполнения, замедляя программу
BСтатически, на этапе компиляции — бесплатно для рантайма
CНикогда
DТолько в тестах