Язык, разработанный по заказу Министерства обороны США и названный в честь первой в мире программистки Ады Лавлейс, окружают много мифов и непонимания. Ты наверняка о нем слышал, но, скорее всего, это были мифы об устаревшем, сложном и медленном языке. Однако ада активно используется для управления самолетами, поездами, космическими аппаратами и прочими интересными штуками. Давай посмотрим на язык без призмы мифов и разберемся, какую пользу мы можем из него извлечь, даже если пока не собираемся в космос.
Несмотря на свое американское происхождение, в разгар холодной войны ада использовалась и в СССР. На нее даже существует ГОСТ, который стоит почитать ради одной только терминологии: например, исключения там «возбуждаются».
Мифы о языке программирования Ада
Миф об устаревшем языке опровергается одним запросом к поисковику: последняя редакция вышла в 2012 году. Если судить о сложности языка по внешним признакам, то все тоже не так страшно: спецификация ады содержит чуть менее тысячи страниц, тогда как спецификация C++ — около 1400 страниц.
Миф о низкой производительности пошел со времен первой редакции 1983 года, когда массовому пользователю были доступны разве что ZX Spectrum и IBM PC с i8086, на которых любой современный язык был бы медленным. Ада компилируется в машинный код, и любители успешно пишут на ней для Arduino с ATmega328 и прочих микроконтроллеров.
Распространенный миф о том, что по вине ады упала ракета Ariane 5 в 1996 году, нужно рассмотреть отдельно. Ракета действительно упала из-за ошибки, но проблема была в другом: компьютер, который управлял траекторией полета, был взят из Ariane 4 без изменений, несмотря на то что Ariane 5 поддерживала более широкий диапазон траекторий. Хуже того, проверка на выход значений за возможный диапазон была намеренно отключена, поэтому, когда навигационный компьютер выдал недопустимую с точки зрения Ariane 4 команду, закончилось все предсказуемо. От этой проблемы, увы, не смог бы защитить ни один язык или какое-либо программное решение вообще. Сама Ariane 4 совершила 113 успешных полетов из 116 за свою историю, а Ariane 5 уже 96 успешных из 101.
Если интересно, почитай отчет об аварии Ariane 5.
Языки и надежность программ
Ракеты — это предельный случай требований к надежности программ, но и в куда более приземленном коде ошибки могут обойтись пользователям очень дорого. В уязвимостях вроде Heartbleed можно винить разработчиков, но разве смысл компьютеров не в том, чтобы автоматизировать нудную работу и позволить людям сосредоточиться на творческих задачах?
Ада разрабатывалась именно для написания надежных и безопасных программ. Когда говорят о безопасности, прежде всего думают, какие ограничения язык или другой инструмент накладывает на пользователя. На мой взгляд, в первую очередь нужно говорить о том, какие выразительные средства инструмент дает разработчику, чтобы точно отразить объекты реального мира в коде и определить законы их взаимодействия. Наблюдение за выполнением этих законов лучше поручить компилятору — он не устает к концу рабочего дня.
РЕКОМЕНДУЕМ:
Как определиться с языком программирования
В первую очередь, конечно, инструмент не должен делать работу человека сложнее, чем она и так есть. Когда Министерство обороны США разрабатывало требования к новому языку для конкурса, в котором победила ада, они в первую очередь упомянули об этом. Документ с требованиями известен как Steelman и содержит, например, такую фразу: «Одни и те же символы и ключевые слова не должны иметь разные значения в разном контексте». Почти вся первая часть рассказывает о необходимости однозначности синтаксиса, удобочитаемости кода, определенности семантики и поведения (вспомним i++ + ++i
).
Но и требования к выразительным средствам для своего времени там передовые. Любопытно, что обработка исключений и средства обобщенного программирования были еще в первой редакции, задолго до С++.
Давай напишем первую несложную программу, а потом рассмотрим, какие средства ада предоставляет, чтобы точнее выразить в коде свои намерения.
Реализации Ады
Далеко идти за реализацией не придется: компилятор ады включен в GCC под названием GNAT (GNU New [York University] Ada Translator) и доступен на всех системах, где есть GCC.
Если у тебя Linux или FreeBSD, можешь ставить из стандартных репозиториев. В Debian/Ubuntu пиши apt-get install gnat
, в Fedora — dnf install gnat
.
Компания AdaCore предоставляет коммерческую поддержку для GNAT и занимается другими связанными проектами. Например, там работают над графической средой разработки GNAT Programming Studio (GPS). AdaCore является, по сути, основным разработчиком GNAT и распространяет две версии компилятора: сертифицированный GNAT Pro за деньги и GNAT Libre бесплатно, но с рантайм-библиотекой под лицензией GPLv3.
Использование GPLv3 не позволяет разрабатывать программы с любыми лицензиями, кроме GPL. Однако в дистрибутивы свободных ОС включена версия FSF GNAT, лицензия которой делает исключение для библиотек. Так что ее можно использовать для разработки программ с любой лицензией.
Есть еще проприетарные реализации ады вроде Irvine и Green Hills, но для пользователей вне аэрокосмической отрасли и ВПК они малодоступны и особого интереса не представляют.
Первая программа
Традиционный Hello world дает очень мало представлений о языке, поэтому для первой программы мы возьмем что-нибудь более реалистичное, например алгоритм Пардо — Кнута. Дональд Кнут и Луис Трабб Пардо предложили его как раз для этой цели.
- Прочитать одиннадцать чисел со стандартного ввода.
- Применить к ним всем некоторую функцию и вывести результаты в обратном порядке.
- Если применение функции вызвало переполнение, вывести сообщение об ошибке.
С помощью такой программы уже можно показать, как определить и заполнить массив, как написать и вызвать функцию, как использовать циклы и условия и как использовать ввод-вывод. Опять же, если Hello world у любого программиста выглядит почти одинаково, то тут уже есть возможность применить разные способы и показать разные возможности языка.
Мы немного усложним задачу и будем заодно проверять правильность ввода значения и запрашивать их заново, если ввод был некорректным. Вернее, на уровне системы типов ограничим диапазон допустимых значений и обработаем возникшие исключения.
Вот наша программа. Ее нужно будет сохранить в файл с названием pardo_knuth.adb
. Несовпадение имени файла с именем основной процедуры, которая служит точкой входа, вызовет предупреждение компилятора.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
-- Trabb Pardo-Knuth program with Ada.Text_IO; use Ada.Text_IO; with Ada.Strings.Unbounded; with Ada.Text_IO.Unbounded_IO; procedure Pardo_Knuth is package UIO renames Ada.Text_IO.Unbounded_IO; package US renames Ada.Strings.Unbounded; type Small_Float is new Float range -100.0 .. 100.0; package Float_IO is new Ada.Text_IO.Float_IO (Small_Float); function Square (X : Small_Float) return Small_Float is begin return X * X; end Square; Input : Array (0 .. 10) of Small_Float; Index : Integer := 0; Debug : Boolean := False; begin if Debug then Put_Line ("Pardo-Knuth program is started"); else Put_Line ("Welcome to Pardo-Knuth program written in Ada!"); end if; Input_Loop: while Index <= Input'Last loop declare Raw_Value : US.Unbounded_String; begin Put ("Enter a value: "); UIO.Get_Line (Raw_Value); Input(Index) := Small_Float'Value (US.To_String (Raw_Value)); Index := Index + 1; exception when Constraint_Error => begin Put_Line ("Incorrect value! Enter a number from -100 to 100"); end; end; end loop Input_Loop; Put_Line ("Results:"); for I in reverse Input'Range loop declare -- No declarations begin Float_IO.Put (Square (Input(I)), Exp => 0, Fore => 4, Aft => 2); exception when Constraint_Error => Put_Line ("Overflow occured!"); end; New_Line; end loop; end Pardo_Knuth; |
Скомпилировать программу можно командой gnatmake pardo_knuth.adb
. Созданный исполняемый файл будет называться pardo_knuth
.
Замечу, что синтаксис ады нечувствителен к регистру символов. В стандартной библиотеке GNAT по каким-то причинам укоренился непривычный Смешанный_Регистр
, и я следую стилю стандартной библиотеки. Но если кому-то он кажется неэстетичным, можно использовать любой другой по вкусу — на работу программ это не повлияет.
Заголовок программы
Перед началом программы находится комментарий. Все комментарии в аде однострочные и начинаются с символов --
.
Программа начинается с подключения модулей. Теперь гибкими возможностями их подключения никого не удивишь, но ада была одним из первых языков, где такое стало возможно.
Ключевое слово use
делает модули видимыми в пространстве имен программы. Например, после with Ada.Text_IO
мы могли бы вызывать процедуру Ada.Text_IO.Put_Line
по ее полному имени. Но мы использовали конструкцию use Ada.Text_IO
, которая импортирует все публичные символы этого модуля в наше пространство имен, поэтому мы можем вызвать Put_Line
, Put
и New_Line
без имени модуля.
Распространенные функции из Ada.Text_IO
Put_Line
— выводит данные с символом новой строки на конце.Put
— выводит данные без переноса строки.New_Line
— выводит символ переноса строки.
Модули Ada.Strings.Unbounded
и Ada.Text_IO.Unbounded_IO
предназначены для работы со строками произвольной длины. По умолчанию строки в аде фиксированной длины, что не всегда удобно для обработки пользовательского ввода. Строки произвольной длины легко преобразовать в фиксированные, что нередко приходится делать, потому что многие функции стандартной библиотеки ожидают именно фиксированные.
Дальше мы определяем основную процедуру, которая служит точкой входа в нашу программу. Желательно, чтобы ее имя совпадало с именем файла, хотя и не обязательно. В аде есть различие между функциями и процедурами: функции могут возвращать значения, а процедуры — только изменять переменные, которые им передают в аргументах.
Внутри нашей процедуры мы переименовали модули с длинными именами для удобства, аналогично import foo as bar
в Python. Теперь мы сможем вызывать, к примеру, Ada.Strings.Unbounded.To_String
как US.To_String
.
Алгоритм Пардо — Кнута требует сообщать пользователю о переполнении. Чтобы упростить появление переполнений, мы создали намеренно ограниченный тип-диапазон Small_Float
, с возможными значениями от -100.0 до +100.0. Для таких типов у ады есть встроенные проверки: присвоение недопустимой константы в коде приведет к ошибке компиляции, а появление недопустимых значений в ходе работы программы вызовет исключение.
Ада использует именную, а не структурную эквивалентность типов, то есть два типа с разными именами несовместимы, даже если объявлены одинаковым образом, и их нельзя использовать в одном выражении без явного приведения. Эту особенность часто используют, чтобы выразить логическую несовместимость величин, например предотвратить случайное сложение дюймов с сантиметрами.
Строка package Float_IO is new Ada.Text_IO.Float_IO (Small_Float)
заслуживает отдельного внимания. Это уже не простое переименование, а специализация обобщенного (generic) модуля для нашего типа Small_Float
.
В аде нет аналога printf
, что бывает очень неудобным, но для ее целей выглядит оправданным — типобезопасный форматированный вывод возможен только в языках с совершенно другой системой типов. В С printf("%d", "foo")
вызовет segmentation fault, в Go аналогичная конструкция приведет к ошибке времени выполнения — ни то ни другое в критичной по надежности программе не принесет пользователю особой радости. Поэтому хотя бы для относительного удобства авторы стандартной библиотеки ады написали несколько модулей для ввода и вывода значений распространенных типов.
Для алгоритма Пардо — Кнута нам требуется определенная пользователем функция. Функции и процедуры в аде могут быть вложенными, чем мы и воспользуемся и определим простейшую функцию Square
в заголовке основной процедуры нашей программы. Тип возвращаемых значений указан с помощью return Small_Float
. Это обязательная часть синтаксиса — еще раз отметим, что создать функцию, которая не возвращает значений, невозможно, для этой цели нужно использовать процедуры.
Далее идут объявления переменных. В аде все переменные должны быть объявлены перед использованием. Глобальных переменных в смысле C в ней нет, их область видимости всегда чем-то ограничена: пакетом (то есть модулем), процедурой, функцией или блоком declare
, который мы рассмотрим позже. Видимые внутри всей процедуры переменные должны быть объявлены в ее заголовке, что мы и сделаем.
С помощью Input : Array (0 .. 10) of Small_Float
мы объявляем массив Input
, где будут храниться введенные пользователем значения. Мы используем диапазон индексов от 0 до 10, но вообще индексы могут быть любыми, в том числе отрицательными, например от -5 до 5. Еще вместо явных индексов можно было бы создать целочисленный тип-диапазон и сослаться на него.
Переменная Index
, очевидно, будет использоваться как индекс элемента массива в цикле — мы могли бы объявить ее позже, но для демонстрации оставим ее здесь. Ее начальное значение — ноль, присваивается одновременно с объявлением переменной. Все присваивания в языке производятся с помощью оператора :=
, оператор =
используется только для проверки равенства.
Для оператора «не равно» используется символ
/=
. Вполне вероятно, что Haskell заимствовал его из ады.
Переменная Debug
будет служить только для демонстрации условного оператора. Она имеет тип Boolean
с возможными значениями True
и False
. Условный оператор в аде требует выражения логического типа и никакого другого, привычное в C-подобных языках if(0)
вызовет ошибку — никакие неоднозначности, связанные с интерпретацией произвольных значений в логическом контексте, в этом языке возникнуть не могут.
Заголовок основной процедуры нашей программы наконец закончился — переходим к ее телу.
Тело программы
Мы начинаем программу с довольно глупой демонстрации условного оператора — выводим другое приветствие, если переменная Debug
выставлена в True
. Этого не произойдет, если не поменять ее начальное значение руками, но суть не в этом, а в синтаксисе. Условный оператор имеет вид if <условие> then <высказывание> else <высказывание> end if
. Часть про end if
обязательна. Вообще, в аде почти нигде нельзя написать просто end
, не указав, что именно здесь закончилось. Читать такой исходный код куда проще, хоть писать и дольше, но правильная настройка редактора решает эту проблему. В объявлении функции Square
мы уже видели, что она кончается словами end Square
, хоть и не заостряли на этом внимание.
Компилятор удаляет заведомо недостижимый код, как внутри
if Debug
, из исполняемых файлов, но только после проверки всех типов и всего прочего, что можно проверить на этапе компиляции. Такие константы — стандартный способ реализации feature toggles и отладочного кода, куда более безопасная альтернатива условной компиляции с помощью препроцессора. Конечно, такой подход не работает для машинно зависимого кода, который просто не скомпилируется вне его целевой платформы. В этом случае можно использовать внешний препроцессор — gnatprep.
Для циклов можно даже указать имена: цикл ввода переменных объявлен с помощью Input_Loop: while ... loop
и закончен end loop Input_Loop
. Перепутать границы вложенных циклов при таком подходе очень сложно. Синтаксис циклов радует своим единообразием — циклы с предусловием и циклы с параметром отличаются только словами перед ключевым словом loop
.
Бесконечный цикл
Бесконечный цикл создать очень просто: не указывать ни тип, ни условия.
123 loopPut_Line ("This loop never ends!");end loop;Циклов с постусловием в явном виде нет, но можно указать условие выхода внутри тела цикла:
12345 Counter := 0;My_Loop: loopCounter := Counter + 1;exit My_Loop when Counter > 10;end loop My_Loop;
Условие цикла — while Index <= Input'Last
— требует некоторых пояснений. Мы могли бы явно указать максимальное значение индекса, но вместо этого мы используем атрибут нашего массива Last
, возвращающий максимальное значение его индекса. Минимальное значение можно получить с помощью атрибута First
, так что эту часть нудной работы компилятор делает за нас. Чуть позже мы остановимся на атрибутах подробнее.
Внутри цикла Input_Loop
мы читаем переменные и помещаем их в наш массив Input
. Специально для промежуточного хранения пользовательского ввода мы создадим локальную переменную Raw_Value
. Поскольку объявлять переменные где придется в аде нельзя, нам потребуется блок declare
, который создает отдельную область видимости.
Между declare
и begin
можно добавить любые объявления, которые мы могли бы поместить в заголовок процедуры. В нашем случае это единственная переменная Raw_Value : Ada.Strings.Unbounded.Unbounded_String
(пакет, который мы для удобства переименовали в US
). Это тип динамических строк неограниченной длины, которые мы читаем с помощью функции UIO.Get_Line
.
РЕКОМЕНДУЕМ:
Лучшие клиенты Git GUI для Windows
В вычислениях с плавающей точкой от строковых данных никакой пользы, поэтому нам нужно конвертировать эти строки в числа. Мы могли бы использовать функции из пакета Float_IO
или даже определить потоки ввода-вывода, но мы пойдем другим путем — используем функцию-атрибут нашего типа Small_Float
.
Атрибуты — одна из самых необычных концепций ады. В общих чертах это ассоциированные с типами функции. Имя типа можно рассматривать как своеобразное пространство имен — атрибут отделяется от него апострофом. В данном случае мы используем атрибут Small_Float'Value
, который представляет собой функцию от типа String
и возвращает значения типа Small_Float
.
Нашу динамическую строку мы для этого конвертируем в статическую, потому все выражение имеет вид Small_Float'Value (US.To_String (Raw_Value))
.
Противоположность атрибуту Value
— атрибут Image
, который конвертирует значение в строку. Существует множество других атрибутов, например для получения размера значений типа в битах, но мы не будем их рассматривать — про них можно прочитать в документации.
Что произойдет, если ввод будет некорректным, например если пользователь введет foo вместо числа или число, выходящее за границы диапазона? Функция Small_Float'Value
завершится с исключением Constraint_Error
. Это исключение нужно обработать, что мы и делаем в конце блока declare
, после ключевого слова exception
. Эквивалента try ... catch
в аде нет, обработку исключений можно производить только в конце процедур и функций или, если мы хотим обработать их внутри функции, в конце блоков declare
.
Исключения в аде не являются ни классами, ни объектами. Это делает невозможным создание иерархий исключений, но уменьшает накладные расходы на их обработку. Создать свое исключение можно с помощью вот такой конструкции в заголовке процедуры:
Not_My_Fault : exception;
.
После того как ввод от пользователя получен, остается только применить нашу функцию Square
к каждому значению и вывести результаты. Для этого мы используем цикл с параметром. С помощью атрибута нашего массива Input'Range
мы можем это сделать с не меньшим удобством, чем предоставляют итераторы в объектно ориентированных языках, — этот атрибут возвращает диапазон индексов.
Поскольку наш тип Small_Float
ограничен, исключение Constraint_Error
может возникнуть и при возведении значений в квадрат, если они выйдут за границы диапазона, поэтому мы обрабатываем их аналогичным образом в конце блока declare
, в этот раз уже без объявления каких-либо переменных в его заголовке перед begin
.
Выражение Float_IO.Put (Square (Input(I)), Exp => 0, Fore => 4, Aft => 2)
, очевидно, вызывает функцию для вывода значений с плавающей точкой. Аргументы Exp
, Fore
и Aft
— это число знаков экспоненты, число знаков до точки и число знаков после точки соответственно.
По умолчанию используется научный формат вывода в стиле 1.2E2
, но установка параметра Exp
в ноль отключает это. Интересный момент: в аде любые параметры функций и процедур можно использовать как именованные. К примеру, нашу функцию Square
мы могли бы вызывать с помощью Square (X => Input(I))
.
Если порядок фактических параметров в вызове совпадает с порядком формальных параметров функции, то имена можно не указывать, но в Float_IO.Put
мы использовали именованные параметры, чтобы поменять порядок, — вызов без именованных параметров выглядел бы так: Float_IO.Put (Square (X => Input(I)), 4, 2, 0)
.
Модули и обобщенное программирование
Обобщенное программирование
Мы начнем с простейшего примера обобщенного программирования, потому что оно не ограничивается модулями — обобщенными могут быть и отдельные процедуры и функции. Мы напишем функцию Do_Nothing
, которая просто возвращает переданное ей значение в неизменном виде и может быть специализирована для любого типа.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
with Ada.Text_IO; use Ada.Text_IO; procedure Simple_Generic is generic type T is private; function Do_Nothing (X : T) return T; function Do_Nothing (X : T) return T is begin return X; end Do_Nothing; function Do_Nothing_To_Integer is new Do_Nothing (Integer); begin Put_Line (Integer'Image (Do_Nothing_To_Integer (65535))); end Simple_Generic; |
Как видно, обобщенные конструкции создаются с помощью ключевой конструкции generic
, у которой есть раздел объявлений типов. Тип T
в данном случае — это тип-параметр, для которого наша процедура может быть специализирована. Затем мы описываем нашу функцию с использованием типа T
. В function Do_Nothing_To_Integer is new Do_Nothing (Integer)
она специализируется для типа Integer
. Мы уже видели это ранее, когда подключали обобщенные пакеты из стандартной библиотеки.
Ключевое слово new
, в отличие от многих других языков, здесь не имеет никакого отношения к объектам — объектов в традиционном понимании в аде нет, хотя инкапсуляция, наследование и полиморфизм есть, просто они в значительной степени отделены друг от друга и реализуются другими способами.
Модули
Модули, или пакеты, — встроенная и очень важная часть языка. Модули разделены на интерфейс и реализацию на уровне языка. Интерфейсы модулей хранятся в файлах с расширением .ads
, а реализации — в файлах с расширением .adb
.
РЕКОМЕНДУЕМ:
Программирование в консоли
Для демонстрации мы напишем модуль для работы с условной базой данных пользователей. У каждой учетной записи будут идентификатор, имя пользователя (строка до 255 символов) и флаг блокировки. Мы сделаем модуль обобщенным, чтобы в качестве идентификатора можно было использовать значения разных типов.
Кроме того, мы сделаем работу с типом учетных записей только через функции самого модуля.
Рассмотрим интерфейс нашего модуля, файл accounts.ads
.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
generic type Identifier_T is private; package Accounts is type User_Record is private; function Create (Identifier : Identifier_T; Name : String) return User_Record; procedure Disable (User : in out User_Record); private type User_Record is record Identifier : Identifier_T; Name : String (1..255); Disabled : Boolean; end record; end Accounts; |
С помощью слова generic
мы делаем его обобщенным, с типом-параметром Identifier_T
. После слов package Accounts is
идут описания публичных полей модуля, которые будут доступны при его импорте, а после ключевого слова private
— описания закрытых полей.
Можно заметить, что тип User_Record
описан дважды: первый раз в публичной части модуля как type User_Record is private
и второй раз в его закрытой части как настоящий тип-запись. И вот почему: первое описание говорит, что тип User_Record
— абстрактный и за пределами модуля будет известно только его имя, но не детали реализации. Это сделает невозможным прямой доступ к полям нашей записи для любых функций, которые не принадлежат модулю.
Более того, даже если мы знаем, как на самом деле устроен тип, создать значения типа Accounts.User_Record
можно будет только с помощью функции Accounts.Create
. Любые другие значения будут несовместимы с остальными функциями из него. Так реализуется инкапсуляция. Если бы мы включили описание типа в секцию private
, но не включили type User_Record is private
в публичную часть, этот тип был бы вовсе не виден за пределами модуля и воспользоваться им было бы невозможно.
Реализацию мы сделаем тривиальной, вот файл accounts.adb
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
package body Accounts is function Create (Identifier : Identifier_T; Name : String) return User_Record is begin return (Identifier, Name, False); end Create; procedure Disable (User : in out User_Record) is begin User.Disabled := True; end; end Accounts; |
В завершение приведем простейшую программу с использованием нашего модуля, файл user_test.adb
. Для сборки проекта достаточно положить все три файла в один каталог и выполнить команду gnatmake user_test.adb
.
1 2 3 4 5 6 7 8 9 |
with Accounts; procedure User_Test is package My_Accounts is new Accounts(Integer); User : My_Accounts.User_Record; begin User := My_Accounts.Create (0, "root"); end User_Test; |
Заключение
Многие возможности языка остались за кадром, такие как наследование с помощью расширений типов или многозадачность. Заинтересованные читатели могут продолжить изучение языка по курсам компании AdaCore или вики-книге.
РЕКОМЕНДУЕМ:
Пример программирования на языке Ада
Конечно, многие языки хорошо выглядят только на бумаге, но любые попытки их использования разбиваются о суровую реальность. В следующий раз мы рассмотрим историю создания небольшого проекта с открытым исходным кодом и ознакомимся с использованием ады на практике.