Язык, разработанный по заказу Министерства обороны США и названный в честь первой в мире программистки Ады Лавлейс, окружают много мифов и непонимания. Ты наверняка о нем слышал, но, скорее всего, это были мифы об устаревшем, сложном и медленном языке. Однако ада активно используется для управления самолетами, поездами, космическими аппаратами и прочими интересными штуками. Давай посмотрим на язык без призмы мифов и разберемся, какую пользу мы можем из него извлечь, даже если пока не собираемся в космос.
Несмотря на свое американское происхождение, в разгар холодной войны ада использовалась и в СССР. На нее даже существует ГОСТ, который стоит почитать ради одной только терминологии: например, исключения там «возбуждаются».
Мифы о языке программирования Ада
Миф об устаревшем языке опровергается одним запросом к поисковику: последняя редакция вышла в 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 или вики-книге.
РЕКОМЕНДУЕМ:
Пример программирования на языке Ада
Конечно, многие языки хорошо выглядят только на бумаге, но любые попытки их использования разбиваются о суровую реальность. В следующий раз мы рассмотрим историю создания небольшого проекта с открытым исходным кодом и ознакомимся с использованием ады на практике.