Безопасные типы данных
В данной главе речь пойдет не об ускорении набора текста программы, а о механизме, помогающем обнаружить еще больше ошибок и опечаток.
Этот специально разработанный и встроенный в язык механизм часто называют механизмом строгой типизации.
В ранних языках, таких как Fortran и Algol, все обрабатываемые данные имели числовой тип. В конце концов, ведь компьютер изначально способен обращаться только с числами, зачастую закодированными в бинарном виде целыми либо числами с плавающей точкой. В более поздних языках, начиная с Pascal, появилась возможность оперировать объектами на более абстрактном уровне. Так, использование перечислимых типов (в Pascal их называют скалярными) дает нам несомненное преимущество обращаться с цветами, как с цветами, хотя они в итоге будут обрабатываться компьютером, как числа.
Эта идея в языке Ада получила дальнейшее развитие, в то время как другие языки продолжают трактовать скалярные типы, как числовые, упуская ключевую идею абстракции, суть которой в разделении смыслового предназначения и машинного представления.
Использование индивидуальных типов
Допустим, мы наблюдаем за качеством производимой продукции и подсчитываем число испорченных изделий. Для этого мы считаем годные и негодные образцы. Нам нужно остановить производство, если количество негодных образцов привысит некоторый лимит, либо если мы изготовим заданное количество годных изделий. В C и C++ мы могли бы иметь следующие переменные:
int badcount, goodcount;
int b_limit, g_limit;
и далее:
badcount = badcount + 1;
...
if (badcount == b_limit) { ... };
Аналогично для годных образцов. Но, т. к. все это целые числа, ничто не помешает нам случайно написать по ошибке:
if (goodcount == b_limit) { ... };
где нам на самом деле нужно было бы написать g_limit. Это может быть результатом приема «скопировать и вставить», либо просто опечатка (b и g находятся близко на клавиатуре). Как бы то ни было, компилятор будет доволен, а мы — едва ли.
Так может случиться в любом языке. Но в Аде есть способ выразить наши действия более точно. Мы можем написать:
type Goods is new Integer;
type Bads is new Integer;
Эти определения вводят новые типы с теми же характеристиками, что и предопределенный тип Integer (например будут иметь операции + и -). Хотя реализация этих типов ничем не отличается, сами же типы различны. Теперь мы можем написать:
Good_Count, G_Limit : Goods;
Bad_Count, B_Limit : Bads;
Разделив таким образом понятия на две группы, мы достигнем того, что компилятор обнаружит ошибки, когда мы перепутаем сущности разных групп и предотвратит запуск некорректной программы. Следующий код не вызовет нареканий:
Bad_Count := Bad_Count + 1;
if Bad_Count = B_Limit then
Но следующая ошибка будет обнаружена
if Good_Count = B_Limit then -- Illegal
ввиду несовпадения типов.
Когда нам потребуется действительно смешать типы, например, чтобы сравнить количество годных и негодных образцов, мы можем использовать преобразование типов. Например:
if Good_Count = Goods (B_Limit) then
Другим примером может служить вычисление разницы счетчиков образцов:
Diff : Integer := Integer (Good_Count) - Integer (Bad_Count);
Аналогично можно избежать путаницы с типами с плавающей точкой. Например, имея дело с весом и ростом, как в примере из предыдущей главы, вместо того, чтобы писать:
My_Height, My_Weight : Float;
было бы лучше написать:
type Inches is new Float;
type Pounds is new Float;
My_Height : Inches := 68.0;
My_Weight : Pounds := 168.0;
И это позволит компилятору предотвратить путаницу.
Перечисления и целые
В главе «Безопасный синтаксис» мы обсуждали пример с железной дорогой, в котором была следующая проверка:
if (the_signal == clean) { ... }
if The_Signal = Clean then ... end if;
на языке C и Ада соответственно. На C переменная the_signal и соответствующие константы могут быть определены так:
enum signal {
danger,
caution,
clear
};
enum signal the_signal;
Эта удобная запись на самом деле всего лишь сокращение для описания констант danger, caution и clear типа int. И сама переменная the_signal имеет тип int. Как следствие, ничего не помешает нам присвоить переменной the_signal абсолютно бессмысленное значение, например 4. В частности, такие значения могут возникать при использовании не инициализированных переменных. Хуже того, если мы в другой части программы оперируем химическими элементами и используем имена anion, cation, нам ничего не помешает перепутать cation и caution. Мы можем также использовать где-то женские имена betty и clare, либо названия оружия dagger и spear. И снова ничто не предотвратит опечатки типа dagger вместо danger и clare вместо clear.
В Аде мы пишем:
type Signal is (Danger, Caution, Clear);
The_Signal : Signal := Danger;
и путаница исключена, потому, что перечислимый тип в Аде - это совершенно отдельный тип и он не имеет отношения к целому типу. Если мы также где-то имеем:
type Ions is (Anion, Caution);
type Names is (Anne, Betty, Clare, ...);
type Weapons is (Arrow, Bow, Dagger, Spear);
то компилятор предотвратит путаницу этих понятий в момент компиляции. Более того, компилятор не даст присвоить Clear значение Danger, так как оба они литералы и присваивание для них также бессмысленно, как попытка поменять значение литерала 5 написав:
5 := 2 + 2;
На машинном уровне все перечисленные типы кодируются как целые и мы можем получить код для кодировки по умолчанию, используя атрибут Pos, когда нам это действительно необходимо:
Danger_Code : Integer := Signal'Pos (Danger);
Мы также можем ввести свою кодировку. Позже мы остановимся на этом в главе «Безопасная коммуникация».
Между прочим, один из важнейших типов Ады, Boolean, имеет следующее определение:
type Boolean is (False, True);
Результат операций сравнения, таких как The_Signal = Clear имеет тип Boolean. Также существуют предопределенные операции and, or, not для этого типа. В Аде невозможно использовать числовые значение вместо Boolean и наоборот. В то время, как в C, как мы помним, результат сравнения вернет значение целого типа, ноль означает ложно, а не нулевое значение — истинно. Снова возникает опасность в коде:
if (the_signal == clean) { ... }
Как ранее упоминалось, пропустив один знак равенства, вместо выражения сравнения мы получим присваивание. Поскольку целочисленный результат воспринимается в C как значение условия, ошибка остается необнаруженной. Таким образом, данный пример иллюстрирует сразу несколько возможных мест для ошибки:
использование знака = для присваивания;
допустимость присваивания там, где ожидается выражение;
использование числовых значений вместо Boolean в условных выражениях.
Большинство из этого просочилось в C++. Ни одного пункта не присутствует в Аде.
Ограничения и подтипы
Довольно часто значение некоторой переменной должно находится в определенном диапазоне, чтобы иметь какой-то смысл. Хорошо бы иметь возможность обозначить это в программе, выразив, таким образом, наши представления об ограничениях окружающего мира в явном виде. Например, мой вес My_Weight не может быть меньше нуля и, я искренне надеюсь, никогда не привысит 300 фунтов. Таким образом мы можем определить:
My_Weight : Float range 0.0 .. 300.0;
а если мы продвинутые программисты и заранее определили тип Phounds, то:
My_Weight : Phounds range 0.0 .. 300.0;
Далее, если программа ошибочно рассчитает вес, не вписывающийся в заданный диапазон, и попытается присвоить его переменной My_Weight так:
My_Weight := Compute_Weight (...);
то во время исполнения будет возбуждено исключение Constraint_Error. Мы можем перехватить это исключение в каком-то месте программы и предпринять некоторые корректирующие действия. Если мы не сделаем этого, программа завершиться, выдав диагностическое сообщение с указанием, где произошло нарушение ограничения. Все это происходит автоматически — необходимые проверки компилятор сам вставит во всех нужных местах. (Придирчивый читатель, знакомый с языком Ада, заметит, что наша формулировка «программа завершится» нуждается в уточнении, поскольку верна только для последовательных программ. Ситуация в параллельном программировании несколько отличается от описанной, но это выходит за границы темы, обсуждаемой в этой главе.)
Идея ввести поддиапазоны впервые появилась в Pascal и была далее развита в Аде. Она не доступна в других языках, где нам бы пришлось повсюду вписывать свои проверки, и вряд ли мы бы стали этим озадачиваться. Как следствие, любые ошибки, приводящие к нарушению этих границ, становится обнаружить гораздо труднее. Если бы мы знали, что любое значения веса, которым оперирует программа, находится в указанном диапазоне, то вместо того, чтобы добавлять ограничение в определение каждой переменной, мы могли бы наложить его прямо на тип Pounds:
type Pounds is new Float range 0.0 .. 300.0;
С другой стороны, если некоторые значения веса в программе неограничены, а известно лишь, что значение веса человека находится в указанном диапазоне, то мы можем написать:
type Pounds is new Float;
subtype People_Pounds is Pounds range 0.0 .. 300.0;
My_Weight : People_Pounds;
Аналогично мы можем накладывать ограничения на целочисленные и перечислимые типы. Так, при подсчете образцов мы подразумеваем, что их количество не будет меньше нуля или больше 1000. Тогда мы напишем:
type Goods is new Integer range 0 .. 1000;
Но если мы хотим лишь убедиться, что значения неотрицательные и не хотим накладывать ограничение на верхнюю границу диапазона, то мы напишем:
type Goods is new Integer range 0 .. Integer'Last;
где Integer'Last обозначает наибольшее значение типа Integer. Подмножества положительных и неотрицательных целых чисел используются повсеместно, поэтому для них Ада предоставляет стандартные подтипы:
subtype Natural is Integer range 0 .. Integer'Last;
subtype Positive is Integer range 1 .. Integer'Last;
Можно определить тип Goods:
type Goods is new Natural;
где ограничена только нижняя граница диапазона, что нам и нужно.
Пример ограничения для перечислимого типа может быть следующим:
type Day is (Monday, Tuesday, Wednesday, Thursday,
Friday, Saturday, Sunday);
subtype Weekday is Day range Monday .. Friday;
Далее автоматические проверки предотвратят присваивание Sunday переменным типа Weekday.
Введение ограничений, подобных описанным выше, может показаться утомительным занятием, но это делает программу более понятной. Более того, это позволяет во время компиляции и во время исполнения убедиться, что наши предположения, выраженные в коде, действительно верны.
Предикаты подтипов
Подтипы в Аде очень полезны, они позволяют заранее обнаружить такие ошибки, которые в других языках могут остаться незамеченными и привести затем к краху программы. Но, при всей полезности, механизм подтипов несколько ограничен, т. к. разрешает указывать лишь непрерывные диапазоны значений для числовых и перечислимых типов.
Это подтолкнуло разработчиков языка Ада 2012 ввести предикаты подтипов, которые можно добавлять к определениям типов и подтипов. Как показала практика, необходимо иметь два различных механизма в зависимости от того, является ли предикат статическим или динамическим. Оба используют выражения типа Boolean, но статический предикат разрешает лишь некоторые типы выражений, в то время как динамический применим в более общем случае.
Допустим мы оперируем сезонами года и имеем следующее определение месяцев:
type Month is (Jan, Feb, Mar, Apr, May, Jun,
Jul, Aug, Sep, Oct, Nov, Dec);
Мы хотим иметь отдельные подтипы для каждого сезона. Для северного полушария зима включает декабрь, январь и февраль. (С точки зрения солнцестояния и равноденствия зима длиться с 21 декабря по 21 марта, но, как по мне, март больше относится к весне, чем к зиме, а декабрь ближе к зиме, чем к осени.) Поэтому нам нужен подтип, включающий значения Dec , Jan и Feb. Мы не можем воспользоваться ограничением диапазона здесь, но можем использовать статический предикат следующим образом:
subtype Winter is Month
with Static_Predicate => Winter in Dec | Jan | Feb;
Это гарантирует, что объекты типа Winter могут содержать только Dec , Jan и Feb. Заметьте, что имя подтипа (Winter) в выражении означает текущее значение подтипа.
Подобная синтаксическая конструкция со словом with введена в Ада 2012 и называется аспектом.
Данный аспект проверяется при инициализации переменной по умолчанию, присваивании, преобразовании типа, передаче параметра и т. д. Если проверка не проходит, то возбуждается исключение Assertion_Error. (Мы можем включить или отключить проверку предиката при помощи pragma Assertion_Policy; включает проверку аргумент с именем Check.)
Если условие проверки не является статическим, то необходимо использовать Dynamic_Predicatre аспект. Например:
type T is ...;
function Is_Good (X : T) return Boolean;
subtype Good_T is T
with Dynamic_Predicate => Is_Good (Good_T);
Заметьте, что подтип с предикатом невозможно использовать в некоторых ситуациях, таких как ограничения индекса. Это позволяет избежать таких странных вещей, как массивы «с дырками». Однако подтипы со статическим предикатом можно использовать в for-циклах для перебора всех значений подтипа. Т.е. мы можем написать:
for M in Winter loop ...
В цикле M получит последовательно значения Jan, Feb, Dec, т. е. по порядку определения литералов перечислимого типа.
Массивы и ограничения
Массив - это множество элементов с доступом по индексу. Предположим, что у нас есть пара игральных костей, и мы хотим подсчитать, сколько раз выпало каждое возможное значение (от 2 до 12). Так как всего возможных значений 11, на C мы бы написали:
int counters[11];
int throw;
объявив таким образом 11 переменных с именами от counters[0] до counters[10] и целочисленную переменную throw.
При подсчете очередного значения мы бы написали:
throw = ...;
counters[throw-2] = counters[throw-2] + 1;
Заметьте, что нам пришлось уменьшить значение на 2, т. к. индексы массивов в C всегда отсчитываются от нуля (иначе говоря, нижняя граница массива — всегда ноль). Предположим, что-то пошло не так (или какой-то шутник подсунул нам кость с 7 точками, или используемый нами генератор случайных чисел был неправильно написан) и throw стало равно 13. Что произойдет? Программа на C не обнаружит ошибку. Просто высчитает, где могло бы располагаться counters[11] и прибавит туда единицу. Вероятнее всего, будет увеличено значение переменной throw, т. к. она объявлена сразу после массива. Дальше все пойдет непредсказуемо.
Этот пример демонстрирует печально известную проблему переполнения буфера. Она является причиной множества серьезных и трудно обнаруживаемых неисправностей. В конечном счете это может привести к появлению бреши в защите через которую проходят атаки вирусов на системы, такие как Windows. Мы обсудим это подробнее в главе 7 Безопасное управление памятью.
Давайте теперь рассмотрим аналогичную программу на Аде:
Counters : array (2 .. 12) of Integer;
Throw : Integer;
затем:
Throw := ...;
Counters (Throw) := Counters (Throw) + 1;
Во время исполнения программы на Аде выполняются проверки, запрещающие нам читать/писать элементы за границами массива, поэтому если Throw случайно станет 13, то будет возбуждено исключение Constraint_Error и мы избежим непредсказуемого поведения программы.
Заметим, что в Аде можно определить не только верхнюю, но и нижнюю границу массива. Нет нужды отсчитывать элементы от нуля. Массивы в реальных программах чаще имеют нижнюю границу равную единице, чем нулю. Задав нижнюю границу массива равную двум, мы получаем возможность в качестве индекса использовать непосредственно значение переменной Throw без необходимости вычитать соответствующее смещение, как в C версии.
Настоящая ошибка данной программы случается не в тот момент, когда происходит выход за пределы массива, а когда Throw выходит за корректный диапазон значений. Эту ситуацию можно выявить раньше, если наложить ограничение на Throw:
Throw : Integer range 2 .. 12;
и теперь исключение Constraint_Error будет возбуждено в момент, когда Throw станет 13. Как следствие, компилятор будет в состоянии определить, что значение Throw всегда попадает в границы массива и соответствующие проверки при доступе к массиву не нужны там, где в качестве индекса используется Throw. В итоге, мы можем избавиться от дублирования кода, написав:
subtype Dice_Range is Integer range 2 .. 12;
Throw : Dice_Range;
Counters : array (Dice_Range) of Integer;
Преимущество в том, что если нам в дальнейшем нужно будет поменять диапазон (например, добавив третью кость мы получим значения в диапазоне 3 .. 18), то это нужно будет сделать только в одном месте.
Значение проверок диапазона во время тестирования огромно. Но для программ в промышленной эксплуатации, при желании, эти проверки можно отключить. Подобные проверки применяются не только в Аде. Еще в 1962 компилятор Whetstone Algol 60 мог делать так же. Проверки диапазона определены в стандарте языка (как и в Java, C#).
Наверное стоит упомянуть, что мы можем давать имена и для типов-массивов. Их называют индексируемыми типами. Если у нас есть несколько множеств счетчиков, то будет лучше написать:
type Counter_Array is array (Dice_Range) of Integer;
Counters : Counter_Array;
Old_Counter : Counter_Array;
и затем, когда нам потребуется скопировать все элементы массива Counters в соответствующие элементы массива Old_Counters, мы просто напишем:
Old_Counters := Counters;
Именованные индексируемые типы есть далеко не во всех языках. Преимущество именованных типов в том, что они вводят явную абстракцию, как в примере с подсчетом годных и негодных образцов. Чем больше мы даем компилятору информации о том, что мы хотим сделать, тем больше у него возможностей проверить, что наша программа имеет смысл.
Все объекты типа Counter_Array имеют равное количество элементов, определенное типом Dice_Range. Соответственно такой тип называется ограниченным индексируемым типом. Иногда удобнее определить более гибкий тип для объектов, имеющих одинаковый тип индекса и тип элементов, но различное количество элементов. К примеру:
type Float_Array is array (Positive range <>) of Integer;
Тип Float_Array назвается неограниченным индексируемым типом. При создании объекта такого типа необходимо указать нижнюю и верхнюю границы при помощи ограничения либо задав начальное значение массива.
My_Array : Float_Array (1 .. N);
Любознательный читатель может спросить, что будет если верхняя граница меньше нижней, например, если N равно 0. Это вполне допустимо и приведет к созданию пустого массива. Интересно то, что верхняя граница может быть меньше чем нижняя граница подтипа индекса.
Неограниченные индексируемые типы очень полезны для аргументов, т. к. позволяют писать подпрограммы, обрабатывающие массивы любого размера. Мы рассмотрим примеры позже.
Установка начальных значений по умолчанию
Для устойчивой работы предикатов подтипа (а также инвариантов типа, как мы увидим далее) может потребоваться, чтобы объект при создании имел осмысленное начальное значение. Изначально язык Ада предлагал лишь частичное решение этого вопроса. Для значений ссылочных типов («указателей») гарантированно начальное значение в виде null. Для записей (record) программист может определить значение по умолчанию следующим образом:
type Font is (Arial, Bookman, Times_New_Roman);
type Size is range 1 .. 100;
type Formatted_Character is record
C : Character;
F : Font := Times_New_Roman;
S : Size := 12;
end record;
FC : Formatted_Character;
-- Здесь FC.F = Times_New_Roman, FC.S = 12
-- FC.C не инициализировано
К начальным значениям можно относиться по-разному. Есть мнение, что иметь начальные значения (например, ноль) плохо, поскольку это может затруднить поиск плавающих ошибок. Контраргумент заключается в том, что это дает нам уверенность, что объект имеет согласованное начальное состояние, что может помочь предотвратить разного рода уязвимости.
Как бы то ни было, это довольно странно, что в ранних версиях Ады можно было задать значения по умолчанию для компонент записи, но нельзя было — для скалярных типов или массивов. В версии Ада 2012 это было исправлено при помощи аспектов Default_Value и Default_Component_Value. Новая версия предыдущего примера может выглядеть так:
type Font is (Arial, Bookman, Times_New_Roman)
with Default_Value => Times_New_Roman;
type Size is range 1 .. 100
with Default_Value => 12;
При таком объявлении типов мы можем опустить начальные значения для компонент Formatted_Character:
type Formatted_Character is record
C : Character;
F : Font; -- Times_New_Roman по умолчанию
S : Size; -- 12 по умолчанию
end record;
Для массива можно указать значение по умолчанию для его компонент:
type Text is new String
with Default_Component_Value =>
Ada.Characters.Latin_1.Space;
Следует заметить, что в отличии от начальных значений компонент записи, здесь используются только статические значения.
«Вещественные ошибки»
Название этого раздела — дословный перевод термина real errors, обозначающего ошибки округления при работе с вещественными числами. В оригинале используется как каламбур.
Для операций над числами с плавающей точкой (используя такие типы, как real в Pascal, float в C и Float в Аде) используется отдельные вычислительные устройства процессора. При этом, само представление числа имеет относительную точность. Так в 32 разрядном слове под мантиссу может быть выделено 23 бита, один бит под знак и 8 бит под экспоненту. Это дает точность 23 двоичных цифры, т. е. примерно 7 десятичных.
При этом для больших чисел, таких как 123456.7 точность будет в одну десятую, а для маленьких, как 0.01234567 — восемь знаков после запятой, но в любом случае число значимых цифр всегда остается 7. Другими словами, точность связана с величиной значения.
Относительная точность подходит во многих случаях, но не во всех. Возьмем к примеру представление угла направления траектории корабля или ракеты. Допустим, мы хотим иметь точность в одну секунду. Полная окружность включает в себя 360 градусов, в каждом градусе 60 минут, в каждой минуте 60 секунд.
Если мы храним угол, как число с плавающей точкой:
float bearing;
тогда для значения 360 градусов точность будет примерно 8 секунд, что недостаточно, в то время как для 1 градуса — точность 1/45 секунды, что излишне. Мы могли бы хранить значение, как целое число секунд, используя целочисленный тип:
int bearingsecs;
Это бы сработало, но нам пришлось бы не забывать выполнять соответствующее масштабирование каждый раз при вводе и отображении значения.
Однако, настоящая проблема чисел с плавающей точкой в том, что точность операций, таких как сложение и вычитание, страдает от ошибок округления. Если мы находим разницу чисел приблизительно одной величины, мы получим существенную потерю точности. К тому же некоторые числа не имеют точного представления. К примеру, у нас есть шаговый двигатель с шагом 1/10 градуса. Мы отмеряем 10 шагов. Но так как 0.1 не имеет точного представления в двоичной форме, в результате мы никогда не получим ровно один градус. Таким образом, даже когда нам не требуется высокая точность, а точность используемого типа больше требуемой, суммарный эффект множества небольших вычислительных погрешностей может быть неограничен.
Ручное масштабирование для использования целочисленных типов допустимо в простых приложениях, но когда у нас несколько таких типов и нам приходится оперировать ими одновременно начинаются проблемы. Ситуация еще более усложняется, если применять для масштабирования более быстрые операции сдвига. Сложность результирующего кода легко может стать причиной ошибок и затрудняет поддержку. Ада среди тех немногих языков, которые предоставляют арифметику с фиксированной точкой. По своей сути, это автоматический вариант масштабирования целых чисел. Так, для шагового мотора мы могли бы определить:
type Angle is delata 0.1 range -360.0 .. 360.0;
for Angle'Small use 0.1;
Результат будет представлен в виде масштабированных (с коэффициентом 0.1) значений, хранимых в виде целых чисел. Но нам удобней думать о них как о соответствующих абстрактных величинах, таких как градусы и их десятые доли. Такая арифметика не страдает от ошибок округления.
Таким образом, Ада имеет две формы для вещественной арифметики:
числа с плавающей точкой имеющие относительную погрешность;
числа с фиксированной точкой, имеющие абсолютную погрешность.
Также поддерживается разновидность чисел с фиксированной точкой для десятичной арифметики — стандартная модель для финансовых расчетов.
Тема этого раздела довольно узкоспециализированная, но она иллюстрирует размах возможностей языка Ада и особое внимание к поддержке безопасных численных вычислений.