Версия от: 03.05.2002
Написать автору: mailto:dgaev@mail.ru
Аннотация
Настоящий документ представляет собой краткий неформальный обзор
основных возможностей языка программирования Ксерион (по состоянию на весну
2002 г.). Он не претендует на формальную строгость и полноту изложения и
оставляет за пределами рассмотрения многие тонкости и "темные места" языка.
Не рассмотрены стандартные языковые библиотеки, совершенно не затронуты
реализационные аспекты Ксерион-системы и многое другое. Тем не менее, он
представляется авторам вполне удовлетворительным в качестве начального
ознакомительного курса.
Предполагается, что читатель имеет представление о базовых принципах
объектно-ориентированного программирования, и в минимальной степени знаком с
каким-либо из современных ОО-языков (предпочтительно, C++ или Java).
Содержание
o Введение
o Лексический состав языка
o Примитивные типы и операции над ними
o Массивы, или векторные типы
o Указатели и ссылки
o Функционалы и функции
o Другие разновидности описаний
o Инструкции и поток управления
o Объекты и классы
o Определение операций
o Импорт и экспорт. Прагматы
o Перспективные возможности языка
o Заключение
Ксерион: язык и технология программирования
Если ксерион бросить в расплавленную медь, получится серебро. Если в
серебро, то -- золото.
Если в никель, то -- палладий. Если в палладий, то -- платина...
А. Лазарчук, М. Успенский
"Посмотри в глаза чудовищ"
Введение
Ксерион -- это современный, полнофункциональный
объектно-ориентированный язык программирования. При разработке языка
основными источниками идей послужили: C и C++, Паскаль (включая его
объектно-ориентированные диалекты, такие как Delphi) и Java. Помимо
перечисленных, в определенной степени на язык также оказали влияние
Algol-68, Simula, BCPL, CLU, Eiffel и некоторые другие менее известные
языки.
Ксерион является гибридным (или процедурно-объектным) языком
программирования, напоминая в этом отношении C++ и (в меньшей степени) Java.
Он не является "чистым" объектно-ориентированным языком, подобным SmallTalk
или Actor: в языке не существует понятий "метакласса", "методов классов" и
механизмов для динамического создания классов во время выполнения программы.
Большая часть атрибутов для объектов классов жестко задается во время
компиляции и не может быть изменена во время выполнения программы.
Ксерион -- строго типизованный язык. Это означает, что большая часть
проверок типов осуществляется во время компиляции, и лишь отдельные
специфические атрибуты объектной типизации могут проверяться при выполнении.
Система типизации языка основана на четком разделении, проводимым между
примитивными (простыми), производными и объектными типами данных.
Ксерион не является языком "сверхвысокого уровня", т.е. не содержит в
явном виде таких высокоуровневых структур данных, как списки, кортежи,
множества, ассоциативные массивы и т.п.. Однако, все перечисленные механизмы
могут быть эффективно реализованы средствами самого языка (и их реализация,
безусловно, будут предоставляться стандартными библиотеками).
Ксерион обладает рядом важных особенностей, специфичных для этого языка
или реализованных в нем лучше, чем во многих других языках программирования.
Для языка в целом характерны:
-- мощность и гибкость. В языке присутствуют практически все
возможности, характерные для традиционных процедурных языков, таких как C++
или Паскаль, без произвольных ограничений на их использование, характерных,
например, для Java. При этом многие из этих возможностей становятся намного
мощнее и потенциально ценнее. Например, в Ксерионе допустимо динамическое
определение размеров массивов, произвольные инициализаторы для массивов,
аргументов функций и компонент классов и многое другое.
-- иерархическому подходу к разработке и реализации программы во многом
содействует принятый в языке принцип локальности. Любую Ксерион-программу
можно рассматривать как иерархию вложенных друг в друга областей действия, а
любое описание (декларация) имеет локальный характер, т.е. действует только
в пределах самой внутренней из содержащих ее областей действия. Самой
внешней всегда является глобальная область действия, но ее использование
лучше максимально ограничить. Правильный подход к использованию принципа
локальности, предполагающий описание переменных, функций, типов данных,
классов и т.п. только там, где они нужны, является важным фактором улучшения
как надежности, так и эффективности программы.
-- вопросам эффективности при разработке языка уделялось особое
внимание. Так, контроль над такими принципиальными для эффективности
моментами, как распределение памяти, находится в руках разработчика.
Некоторые средства языка, такие, как специфические атрибуты указательных
типов static и strict, дают программисту явную возможность улучшать
эффективность программного кода за счет его общности. Не менее важно наличие
агрегатных операций присваивания и сравнения для векторных и объектных
типов.
-- надежность. Язык является надежным в том смысле, что на нем
невозможно написать "сбойный" код. Например, обеспечивается полная проверка
диапазона при обращениях к массивам, проверка валидности используемых
указателей на объекты данных и ссылок на функции, предусмотрен надежный
механизм преобразования между родственными объектными типами и т.п. Все
ошибки подобного рода, выявленные при выполнении программы, возбуждают
исключительные ситуации, которые могут быть перехвачены и корректно
обработаны.
-- последовательность и ясность. Предположительно, многие конструкции
языка имеют более последовательный и компактный синтаксис, чем их аналоги в
Паскале, Java и C++. Можно сказать, что для синтаксиса языка характерна
большая "ортогональность", чем для многих других языков. Если некая
конструкция языка синтаксически правильна, она почти всегда имеет какую-то
разумную семантику и может быть полезна в определенной ситуации. Кроме того,
язык минимизирует или полностью исключает необходимость в дублирующих
описаниях: каждый объект языка должен быть описан только один раз. Многие
часто употребляемые языковые конструкции могут быть записаны более
компактно. Активное использование макроопределений let также может
значительно "уплотнить" программу (возможно, в ущерб ее понятности).
-- переносимость реализации -- один из самых важных аспектов языка.
Результатом работы компилятора является внутренний код Ксерион-системы
(здесь не описанный). Этот код может быть выполнен в режиме интерпретации с
достаточно высокой эффективностью на любой 32-битовой платформе, но
ориентирован в основном на трансляцию в машинный код целевого процессора.
Лексический состав языка
На самом базовом уровне любая Ксерион-программа может рассматриваться
просто как поток лексем. Последние подразделяются на: ограничители,
разделители и знаки операций, ключевые слова, идентификаторы, литералы и
комментарии. В промежутках между лексемами могут присутствовать пробельные
символы (пробелы, концы строк и большинство управляющих символов),
игнорируемые при компиляции.
В том месте, где допустим пробельный символ, всегда может встретиться и
комментарий. Комментарии определяются двумя способами:
· как (непустая) последовательность символов, ограниченная двумя
символами ‘!';
· как последовательность от двух символов ‘!' до конца текущей строки.
В случае, когда применяются комментарии первого типа, рекомендуется
использовать внутри них пару дополнительных скобок
("()","[]","{}","<>" или что-нибудь в этом роде), чтобы начало и конец
комментария легко различались. Вот примеры:
!! допустимый комментарий
! и это тоже ... !
!{ но такая форма предпочтительней }!
Идентификаторы -- это символические имена, которые имеют все объекты (в
широком смысле) языка: переменные, константы, типы данных, функции, классы,
макроопределения, метки и пр. К идентификаторам предъявляются практически те
же требования, что и в C: это последовательности латинских букв, десятичных
цифр и знаков подчеркивания, начинающиеся не с цифры. Как минимум первые 127
символов идентификатора являются значащими; заглавные и строчные буквы
различаются. Помимо этого, идентификатор должен отличаться от ключевого
слова. Следующие идентификаторы являются ключевыми словами:
abstract, alloc, assert
bool, break
case, char, class, conceal, const, constructor, continue
destructor, do, double
else, enum, export
false, float, for
goto
if, import, inline, instate, int, interface
keyword
label, let, limited, long, loop
mediator
native, nil
opdef
package, pragma, private, protected
qual, quad
realloc, return
shared, short, static, strict, switch
tiny, this, true, type
u_int, u_long, u_short, u_tiny, unless, until
virtual, void
w_char, with, while
Помимо этого, законным идентификатором является последовательность
символов, заключенная в обратные кавычки. Внутри кавычек допустимы
произвольные символы, в т.ч. национальные, управляющие и пробельные и т.п.
Сами кавычки -- ограничители, а не часть идентификатора (alpha и `alpha` --
это один и тот же идентификатор).
!! примеры идентификаторов:
x;
ABACUS:
file_12;
ActiveApplet;
`Предельный допуск`
Для представления значений большинства примитивных типов в языке есть
литералы. Целые литералы по умолчанию представляют собой последовательности
десятичных цифр. Литералы могут быть не только десятичными: префикс ‘$o'
указывает на восьмеричный литерал, ‘$h' (или ‘$x') -- на шестнадцатеричный,
‘$b' -- на двоичный. Все целые литералы могут также иметь суффикс, явно
задающий их тип (см. следующий раздел): 't' (для u_tiny), 's' (для u_short),
'i', (для u_int, по умолчанию), 'l' (для u_long). Литералы с плавающей
точкой определены как в C/C++, но также могут иметь явный суффикс типа: 'f'
(для float, по умолчанию), 'd' (для double), 'q' (для quad). Символьные
литералы (тип char) ограничены простыми кавычками. Строковые литералы (тип
char [], массив из символов) ограничены двойными кавычками. В отличие от
C/C++, они могут содержать физические управляющие символы (такие, как
перевод строки) и не завершаются автоматически нулевым байтом (последнее не
требуется, т.к. библиотечные средства языка определяют длину массивов по
другому). Вот примеры литералов (в скобках даны их типы):
37t; !! 37 (u_tiny)
37s; !! 37 (u_short)
$b100101; !! 37 (u_int)
$o45; !! то же, что и выше
$x25; !! то же, что и выше
3.14159; !! 3.14159 (float)
3.14159d; !! 3.14159 (double)
true; !! истина (bool)
false; !! ложь (bool)
'@'; !! символ ‘@' (char)
"Это строка"; !! строка (char [])
"Это -- еще один пример строки,
которая займет несколько строк
при выводе" !! еще одна строка (char [])
Примитивные типы и операции над ними
Примитивные типы данных играют фундаментальную роль в системе типов
языка, поскольку они являются теми простейшими "кирпичиками", из которых
строится все остальное. Их можно разделить на числовые, символьные,
логический и пустой. В свою очередь, числовые типы представлены восемью
целочисленными и тремя "плавающими" типами. Целочисленные типы данных -- это
четыре вида значений, имеющих знак (tiny, short, int, long) и их беззнаковые
аналоги (u_tiny, u_short, u_int, u_long). "Знаковые" значения представляют
целые числа в дополнительном коде и различаются разрядностью: тип tiny
обеспечивает только 8 двоичных разрядов, short -- 16, int -- 32 и long --
64. Соответствующие им типы без знака имеют ту же разрядность, но
представляют только неотрицательные числа. Три типа представляют значения с
плавающей точкой (в соответствии со стандартом IEEE-754): float -- плавающее
со стандартной точностью, double -- с двойной точностью и quad
(зарезервирован на будущее, в настоящее время с точки зрения реализации
неотличим от double). Два простых типа предназначены для работы с символами:
тип char представляет 8-битовые символы набора ASCII/ISO, а тип w_char --
16-битовые набора Unicode. Логический (булевский) тип bool представляет лишь
два логических значения: истину (true) и ложь (false). В завершение упомянем
тип void (пустой), вообще не имеющий значений и предназначенный, в основном,
для описания функций-процедур, не возвращающих какого-либо результата.
Любая переменная в языке должна быть описана (декларирована) перед
использованием. Для простейших типов синтаксис деклараций прост (и, в
основном, C-подобен):
!! I, J, K -- беззнаковые целые переменные
u_int I = 1, J = 2, K = 3;
!! X, Y, Z -- плавающие переменные стандартной точности
float X, Y, Z = 0.001;
!! DONE -- логическая переменная
bool DONE = false
Из этих примеров также видно, что описание переменной может
сопровождаться ее инициализацией (и это рекомендуемая практика). Если
переменная примитивного типа не инициализирована явно, она будет содержать
неопределенное значение (мусор).
Наряду с обычными переменными, в языке присутствуют константы --
переменные, значение которых после описания не может быть изменено. Описание
константы предваряется ключевым словом const. Понятно, что константа
непременно должна быть инициализирована:
!! space -- символьная константа
char const space = ‘ ‘;
!! factor -- плавающая константа
float const factor = 2 * PI * PI;
!! median -- целая константа
int const median = (low + high) // 2
Наряду с константностью, важным атрибутом переменной является режим
размещения, указывающий каким именно образом переменная будет создана, и
сколько времени она просуществует. По умолчанию, режим размещения определяет
контекст описания: в зависимости от того места, где встретилось описание,
переменная может быть объявлена глобальной, локальной (в функции) или
компонентной (в классе). Однако, любая переменная или константа может быть
явно описана как статическая (static), т.е. имеющая время жизни, совпадающее
со временем выполнения программы:
u_int i, j, static counter = 0
Заметьте, что ключевое слово static -- атрибут описываемой переменной
(в данном случае, counter), а не описания в целом, как принято в C.
Для примитивных типов данных определено множество операций. Подробно
рассматривать систему операций мы не будем, так как она во многом
позаимствована из C. Отметим лишь наиболее существенные различия. Так, в
отличие от C, в Ксерионе различаются операции плавающего ('/') и
целочисленного ('//') деления (а взятие остатка от деления выполняется
операцией '-/'). Наряду с привычными для C-программиста операциями битовых
сдвигов вправо и влево ('<<' и '>>'), существуют также бинарные
операции битового вращения ('<.<' и '>.>'), и унарная операция
транспозиции ('>.<'). (Последнюю операцию можно описать как
"перестановку половинок": для значения типа u_tiny она меняет местами
старшие и младшие 4 бита, для u_short - старшие и младшие 8 битов и т.д.)
Разумеется, предусмотрены привычные для C-программиста операции инкремента
('++') и декремента ('-- ') в префиксной и постфиксной форме.
Все операции сравнения возвращают значение типа bool. Для всех типов
определены операции сравнения на равенство ('--') и неравенство
('<>'), а для многих типов данных определены также сравнения на
упорядоченность ('<', '<=', '>', '>='). В частности, все
примитивные типы являются упорядоченными. (Для числовых типов это
самоочевидно, символьные типы упорядочены в соответствии с внутренней
кодировкой, а для логических значений принято false < true). Кроме того,
для всех примитивных типов определены бинарные операции "максимум" ('?>')
и "минимум" ('?<'), возвращающие, соответственно, больший и меньший из
своих операндов.
Обычные бинарные арифметико-логические операции "и" ('&'), "или"
('|') и "исключающее или" ('~') применимы как к логическим, так и к целым
значениям (в последнем случае они выполняются побитно). Это же справедливо и
для унарной операции "не" ('~'). Только для типа bool определены
условно-логические операции "и" и "или" ('&&' и '||'), которые, как
и в C, по возможности избегают вычисления второго операнда. Есть и
C-подобная тернарная операция выбора: X ? Y : Z понимается как "если X
(выражение типа bool), то Y, иначе Z". Наконец, операция присваивания ('=')
как и в C возвращает присвоенное значение в качестве результата (она
определена не только для примитивных типов, но об этом позже). Есть также
операции присваивания, совмещенного с большинством бинарных операций, такие
как '+=', '-=', '*=', '/=' и т.п.
В отличие от C и Java, в языке отсутствует бинарная операция ','
(последовательность). Но вместо нее имеется более мощное средство: выражение
может дополняться встроенным блоком кода, выполняющимся до или после его
вычисления:
!! "встроенный блок", префиксная форма
({ STMT_LIST } EXPR);
!! "встроенный блок", постфиксная форма
(EXPR { STMT_LIST })
В обеих формах это выражение возвращает значение выражения EXPR.
Однако, при этом еще и выполняются инструкции из STMT_LIST -- до вычисления
EXPR (в префиксной форме) или после него (в постфиксной). К сожалению, блок
инструкций не может напрямую вернуть значение, которое можно было бы
использовать в выражении.
Система операций языка всем перечисленным не ограничивается, но
операции, определенные для производных и объектных типов мы рассмотрим чуть
позже. Наконец, в языке имеются бинарные операции ввода ('<:') и вывода
(':>'), которые необычны тем, что вообще не имеют никакой
предопределенной семантики, и предназначены исключительно для
переопределения (например, для операций ввода-вывода в системных
библиотеках).
Система приоритетов несколько отличается от принятой в C. Скажем,
операции сдвигов и вращения считаются мультипликативными, т.е. имеют тот же
приоритетный уровень, что и умножение и деление. Приоритет логических и
условно-логических операций одинаков (и более низок, чем у сравнений). Все
бинарные операции, кроме операций присваивания, имеют левую ассоциативность.
Значения примитивных типов могут неявно преобразовываться друг в друга,
но правила этих преобразований приняты более жесткие, нежели в C. Допустимы
лишь те преобразования, которые не приводят к потере информации. Так,
младшие целочисленные типы могут обобщаться до старших (tiny → short
→ int → long), так же, как и все плавающие (float → double
→ quad) и все символьные (char → w_char), а целочисленные
значения неявно обобщаются до плавающих. Другие неявные преобразования
запрещены: в частности, нельзя неявно использовать символьные и логические
значения в качестве целых (и наоборот). Большинство операций также не
позволяют смешивать целые операнды со знаком и без знака: они должны быть
приведены к единой знаковости во избежание возможной неоднозначности.
Когда неявные преобразования не работают, можно прибегнуть к операции
явного приведения типов, имеющей такой вид:
:TYPE EXPR !! преобразовать выражение EXPR к типу TYPE
Семантика подобного преобразования также не таит в себе особых
сюрпризов: плавающие значения преобразуются в целые путем отбрасывания
дробной части, символьные в числовые -- в соответствии со своей кодировкой,
а логические значения false и true считаются эквивалентными 0 и -1. Очень
важно заметить, что эта операция преобразования определена только для
примитивных типов, и к производным, в отличие от C, она неприменима.
Массивы (векторные типы)
Массивы -- однородные наборы значений единого типа, обеспечивающие
произвольный доступ к любому из этих значений (элементов) по целочисленному
индексу -- это одно из принципиально важных средств языка. В отличие от Java
и многих других языков, массивы не являются объектами в смысле ООП. Они
могут иметь те же свойства и атрибуты (режим размещения, константность и
пр.), что и переменные примитивных типов.
Вот примеры описаний массивов:
!! intvec -- массив из LENGTH целых
int [LENGTH] intvec;
!! text -- матрица символов, (HEIGHT строк) * (WIDTH столбцов)
char [WIDTH][HEIGHT] text
Обратите внимание на то, что синтаксис описания массивов -- префиксный:
конструкция вида [SIZE] называется префиксом описания (декларатором)
массива. Она означает, что тип декларируемых далее объектов меняется с TYPE
на TYPE [SIZE] (массив из SIZE элементов типа TYPE). Вкладывая векторные
деклараторы друг в друга, можно описывать двух- и более мерные массивы.
Строго говоря, понятие "многомерный массив" в языке отсутствует -- их с
успехом заменяют массивы, состоящие из массивов, и так далее. Именно это мы
будем подразумевать, говоря об n-мерных массивах (при этом число n мы будем
называть размерностью, или рангом массива). Однако, никакими специальными
свойствами многомерные массивы не обладают (т.е. семантика всех операций над
ними выводится из семантики операций над одномерными массивами).
Заметим, что префиксный синтаксис в описаниях массивов -- это не
исключение. Все производные типы языка вводятся с помощью аналогичных
префиксных конструкций, благодаря чему даже самые сложные и запутанные
описания читаются достаточно легко и единообразно -- справа налево (от
переменной или другого описываемого объекта к "корню" описания). Как и в C,
префикс(ы) описаний имеют более высокий приоритет, чем запятая, разделяющая
декларации в списке:
int [10] aa, bb !! aa -- массив из 10 целых, bb -- целое
Однако, часто необходимо описать несколько массивов одинаковой
размерности. Тогда префикс массива (как и любой общий префикс производного
типа) можно "вынести за скобки" (фигурные). Этот прием, называемый
факторизацией, очень упрощает сложные описания:
int [10] { aa, bb } !! aa и bb -- массивы из 10 целых
Факторизацию можно применять и рекурсивно:
int i, [10] { v, [20] { vv, [30] vvv } };
!! более громоздкая форма предыдущего описания:
int i, [10] v, [10][20] vv, [10][20][30] vvv
В качестве размера массива требуется некое выражение типа u_int. На
него не накладывается других ограничений -- в частности, не требуется, чтобы
оно было вычисляемым во время компиляции. В общем случае размер массива
определяется только при выполнении программы. Однако, он фиксирован в том
смысле, что вычисляется один раз, после чего уже не может измениться (в
языке нет настоящих гибких массивов, размеры которых можно менять "на
лету"). В жесткой системе типов языка размеры массивов рассматриваются как
особый случай: все, что связано с ними, обычно проверяется только при
выполнении программы. Массив может даже оказаться пустым, т.к. нулевой
размер не считается ошибкой.
Для массивов определен ряд операций. Так, поскольку размер массива
всегда известен компилятору и исполняющей системе языка, его нетрудно узнать
с помощью унарной постфиксной операции '#'. Для переменных, описанных выше:
intvec#; !! возвращает значение LENGTH (u_int)
text#; !! возвращает значение HEIGHT (u_int)
vvv# !! возвращает 30 (u_int)
Часто работа с массивом осуществляется поэлементно. Бинарная операция
индексирования позволяет в любой момент получить доступ к любому элементу
массива. Так же, как и в C, отсчет индексов ведется с нуля:
!! первый элемент массива intvec (int)
intvec [0];
!! последний элемент массива intvec (int)
intvec [LENGTH - 1];
!! "верхняя" строка матрицы text (char [WIDTH])
text [0];
!! "нижняя" строка матрицы text (char [WIDTH])
text [HEIGHT - 1];
!! "левый верхний" символ матрицы text (char)
text [0][0];
!! "правый нижний" символ матрицы text (char)
text [HEIGHT - 1][WIDTH - 1]
Операция индексирования всегда проверяет корректность индекса, не
позволяя обратиться к несуществующему элементу. Если при вычислении A [I] не
соблюдается условие I < A#, нормальное выполнение программы прервется и
будет возбуждена исключительная ситуация (ArraySizeException). Заметим
также, что хотя при описании массива мы использовали префиксный синтаксис,
для доступа к элементу используется привычная постфиксная нотация.
(Известный по языку C принцип "декларация имитирует использование" в
Ксерионе верен "с точностью до наоборот": описатели для массивов, указателей
и функционалов используют префиксный синтаксис, но соответствующие операции
над этими типами (индексирование, разыменование, вызов функции) -- только
постфиксный).
Возможен не только поэлементный доступ к массивам: в языке определен
ряд агрегатных операций, позволяющих работать с массивами, как с единым
целым. Но прежде заметим, что там, где можно работать с массивом,
допускается работа и с любым его непрерывным фрагментом (отрезком).
Тернарная операция взятия отрезка -- A [FROM..TO] -- возвращает отрезок
массива A от (включительно) элемента с индексом FROM до (не включая)
элемента с индексом TO (т.е. справедливо тождество: A [FROM..TO]# -- TO --
FROM). Разумеется, корректность индексов проверяется (если нарушено условие
FROM <= TO && TO <= A#, возбуждается знакомое нам исключение
ArraySizeException). Впрочем, отрезок нулевой длины допустим, также как и
массив.
V [0 .. N] !! отрезок: начальные N элементов массива V
V [V#-N .. V#] !! отрезок: конечные N элементов массива V
В отличие от индексирования, операция получения отрезка никогда не
понижает ранг массива: результат всегда имеет ту же размерность, что и
операнд. Отрезок длиной 1 -- это массив длины 1, а не один элемент.
Вследствие этого, при работе с многомерным массивом можно получить отрезок
только по самому внешнему измерению, т.к. все внутренние для этой операции
недоступны. Наконец, отметим, что операции взятия индекса и отрезка
сохраняют такие особенности своего операнда, как константность и L-контекст
(т.е. если массив константен, то любой его элемент также является константой
и т.п.).
Завершая разговор об индексировании массивов, следует упомянуть особую
операцию "пустой индекс". Она полезна в основном для получения внутренних
размеров многомерных массивов:
text [0]# !! возвращает WIDTH
text []# !! то же самое
Вторая запись немного короче, а главное -- явно подчеркивает, что
операция индексирования здесь носит фиктивный характер, т.к. нам нужен не
определенный элемент массива text, а лишь доступ к общему типу его
элементов. Результат, выдаваемый операцией [] -- т.н. неопределенное
выражение, имеющее тип, но не значение. Подробнее о семантике неопределенных
выражений, и случаях, когда они могут потребоваться, мы поговорим позже.
Для массивов, как и для примитивных типов, доступно присваивание:
float [25] { VA, VB };
VA = VB !! скопировать все элементы из массива VA в массив VB
Для присваивания массивов требуется, чтобы типы их элементов точно
совпадали (т.к. неявные приведения, доступные для примитивных типов, не
обобщаются на массивы из них). Помимо этого, должны совпадать и размеры
присваиваемых массивов (по всем измерениям, если они многомерные). Заметьте,
что в приведенном случае их совпадение очевидно, и потому проверка периода
выполнения будет опущена. Однако, вот пример более общей ситуации:
char [SIZE1] str1, [SIZE2] str2;
str1 = str2
Здесь перед присваиванием произойдет проверка условия SIZE1 -- SIZE2,
и, если оно окажется ложным, будет возбуждено все то же исключение
ArraySizeException.
Не менее важно то, что массиву можно присвоить скаляр. В этом случае
его значение (вычисленное один раз) будет "размножено" и присвоено всем
элементам массива. Этот прием называется векторизацией и обобщается на
многомерные массивы: массиву может быть присвоен массив меньшего ранга --
при этом он "размножается" по одному или большему числу измерений. Как и при
обычном присваивании, требуется идентичность базовых типов массивов, а все
"внутренние" размеры обязательно будут проверены на равенство:
!{ Присваивает str1 всем HEIGHT строкам матрицы text
(предварительно убедившись, что text []# -- str1#,
т.е. WIDTH -- SIZE1) }!
text = str1
Порядок присваивания элементов в массиве считается неопределенным.
Часто это действительно не принципиально, однако при присваивании
перекрывающихся отрезков одного и того же массива он оказывается
существенным. Поэтому существуют две специальные формы операции
присваивания: инкрементная ('=#') и декрементная ('=#@') (они определены
только для массивов):
A [10..19] =# A [15..24]; !! инкрементное присваивание
A [10..19] =#@ A [15..24] !! декрементное присваивание
Здесь операндами являются два перекрывающихся отрезка массива A. В
первом случае присваивание будет осуществляться от первого элемента к
последнему, т.е. будет неразрушающим и все элементы "уцелеют" при
копировании. Во втором случае, копирование произойдет в обратном порядке,
при этом отрезок частично перезапишет сам себя. Это не обязательно ошибка.
Например, если необходимо "размножить" небольшой отрезок на всю длину
массива, присваивание с одновременной "автоматической" перезаписью является
вполне допустимым (и весьма эффективным) техническим приемом.
Как и переменные примитивных типов, массивы могут быть (а константные
-- и должны быть) инициализированы. Конечно, все, что может быть присвоено
массиву, является и законным инициализатором для него. Однако, помимо этого,
допускается еще одна форма инициализации массива -- списковая.
int [5] List1 = { 1, 2, 3, 4, 5 };
int [5] List2 = { 1, List1[2]*3, List1[0]*List1[4] + 2, 4, List1# }
Как легко видеть из второго примера, инициализаторы -- любые выражения,
соответствующие типу элементов массива. Они вычисляются только при
выполнении инициализации. Столь же гибкий подход допустим и при
инициализации многомерных массивов. Вот вполне законный, хотя и несколько
надуманный пример:
int [3][5] Matrix = {
!! строка #0: зададим списком
{ 100, 200, 300 },
!! строка #1: возьмем из List1
List1 [0..3],
!! строка #2: зададим списком
{ List1[0]*List2[2], List1[1]*List2[1], List1[2]*List2[0] },
!! строка #3: возьмем из List2
List2 [2..5],
!! строка #4: векторизуем 100 на 3 элемента
100
}
Списковые инициализаторы массивов -- пример т.н. инициализирующих
выражений, определенных и для некоторых других типов. Их можно использовать
только в контексте инициализации для переменной данного типа, т.е.
использовать список элементов, скажем, как присваиваемое значение, нельзя:
List1 = { 10, 20, 30, 40, 50 } !! ошибка!
В языке имеется не только агрегатное присваивание, но и агрегатное
сравнение. Для того, чтобы два массива были сравнимыми, требуется, как и при
присваивании, точное совпадение их базовых типов. Однако, различие в
размерах при сравнении не считается фатальной ошибкой. Проще всего описать
семантику сравнений на равенство/неравенство: два массива считаются равными,
если равны их размеры и соответствующие элементы попарно; в противном случае
они не равны:
str1 -- str2; !! истинно, если str1# -- str2#
!! И str1 [I] -- str2 [I] для любого I
str1 <> str2 !! в противном случае
Если же базовый тип массивов упорядочен (например, является примитивным
типом), допустимо также сравнение массивов на упорядоченность. При этом
семантика сравнения определена аналогично лексикографическому ("словарному")
сравнению символьных строк. Вот строгое определение операций "больше" и
"меньше" для массивов:
str1 < str2; !! истинно, если существует такое N, что
!! 1) str1 [0..N] -- str2 [0..N]
!! 2) str1#
-- N && str2# > N
!! ИЛИ ЖЕ
!! str1 [N]
< str2 [N]
str1 > str2 !! истинно, если существует такое N, что
!! 1) str1 [0..N] -- str2 [0..N]
!! 2) str1# > N &&
str2# -- N
!! ИЛИ ЖЕ
!! str1 [N]
> str2 [N]
Другими словами: массив str1 меньше [больше] массива str2, если первый
отличающийся элемент массива str1 меньше [больше] соответствующего элемента
массива str2, или же если все элементы str1 равны элементам str2, а длина
str1 меньше длины str2 [... все элементы str2 равны элементам str1, а длина
str2 меньше длины str1].
Правила сравнения массивов рекурсивно обобщаются на массивы более
высоких размерностей. Если один из операндов сравнения имеет меньший ранг,
чем другой, он неявно подвергается векторизации по всем "недостающим"
внешним измерениям. Продемонстрируем все это на примерах:
str1 -- ‘ ‘ !! истинно, если все символы str1 -- пробелы
str1 <> ‘ ‘ !! истинно, если хотя бы один символ str1 отличен от
пробела
str1 -- text !! истинно, если str1# -- text []#
!! И все строки text совпадают с str1
str1 <> text !! истинно, если str1# <> text []#
!! ИЛИ хотя бы одна строка text отлична от str1
Возможность сравнения массивов, безусловно, ценна, но не менее важно
знать, в каком именно месте они различаются. Для этого предусмотрены
операции сканирующего сравнения (сканирования). Для каждой из операций
простого сравнения ('--', '<>', '<', '>' ...) имеется
соответствующая операция инкрементного ('--#', '<>#', '<#', '>#'
...) и декрементного ('--#@', '<>#@', '<#@', '>#@' ...)
сканирования. Во многом они подобны соответствующим им операциям сравнения,
в частности, они предъявляют абсолютно те же требования к типам операндов и
выполняются практически таким же образом. Главное отличие -- возвращаемое
ими значение имеет не тип bool, а тип u_int -- и означает оно,
соответственно не истинность/ложность операции сравнения в целом, а число
элементов массива (начальных для инкрементных операций, конечных -- для
декрементных), для которых соответствующее условие удовлетворяется. Так, для
сканирования на равенство:
!! в инкрементной форме:
VAL -- A --# B; !! означает, что:
!! A [0..VAL] -- B [0..VAL]
!! И
!! A [VAL] <> B [VAL]
!! (если они существуют).
!! в декрементной форме:
VAL -- A --#@ B; !! означает, что:
!! A [A#-VAL..A#] -- B [B#-VAL..B#]
!! И
!! A [A#-VAL-1] <> B [B#-VAL-1]
!! (если они существуют).
Как и при сравнении, операнды сканирования могут подвергаться
векторизации. Таким образом, сканирование можно использовать и в качестве
операции поиска элемента в массиве:
!! найти первый пробел в массиве str1:
if (first_count = str1 <># ‘ ‘) -- str1#
{ !( пробелы не найдены ... )! }
else { !( str1 [first_count] -- первый пробел )! }
!! найти последний пробел в массиве str1:
if (last_count = str1 <>#@ ‘ ‘) -- str1#
{ !( пробелы не найдены ... )! }
else { !( str1 [str# - last_count - 1] -- последний пробел )! }
Резюмируя заметим, что система векторных операций языка может поначалу
показаться довольно сложной. Тем не менее, возможность относительно
компактной записи довольно сложных операций над массивами слишком ценна,
чтобы ею пренебрегать. Кроме того, все агрегатные операции реализованы
максимально эффективно, и их использование может дать весьма существенный
выигрыш, особенно в библиотеках и других системно-значимых компонентах.
Указательные и ссылочные типы
Реализация нетривиальных структур данных, таких, как линейные и
кольцевые списки, деревья, графы и сети была бы практически нереальна без
указателей. В том или ином виде такой механизм предусмотрен в любом языке.
Даже в Java, где декларирован отказ от указателей, эта концепция неявно
присутствует, т.к. все массивы и объекты доступны только через ссылки. В
Ксерионе подход является более традиционным: как и в C и Паскале, доступны
указатели на переменные любых типов. Правда, в отличие от C, в использование
указателей внесен ряд ограничений, продиктованных соображениями
безопасности.
Все указательные типы данных вводятся с помощью префиксного описателя
'^'. Например:
int ^ip; !! ip - указатель на целое
int ^^ipp !! ipp - указатель на указатель на целое
Эти два описания легко объединить с помощью факторизации:
int ^{ ip, ^ ipp } !! то же, что и выше
Префикс '^' может предваряться ключевыми словами const, limited и
strict, смысл которых мы рассмотрим чуть позже. Для всех указательных типов
определен единственный литерал -- nil, означающий отсутствие ссылочного
значения.
С указателями прямо связаны две операции: именование и разыменование.
Так, L-выражение любого типа легко превратить в указатель на этот тип с
помощью операции именования (постфикс '@'):
int a; double b;
a@; !! указатель на переменную a (int ^)
b@ !! указатель на переменную b (float ^)
Обратная операция -- разыменование (постфикс '^') -- позволяет перейти
от указателя к переменной (константе), на которую он указывает (результат
этой операции -- L-выражение). Понятно, что попытка разыменования значения
nil вызовет ошибку периода выполнения (NilDerefException).
ip^; !! разыменовать ip (int)
ipp^; !! разыменовать ipp (int ^)
ipp^^ !! разыменовать ipp дважды (int)
Традиционно указатели считаются довольно опасным языковым механизмом.
По этой причине в Ксерионе имеется ряд ограничений на их использование.
Прежде всего, в отличие от примитивных типов, для указательных типов
действует принудительная инициализация: если указательная переменная не
инициализирована явно, она инициализируется значением nil, благодаря чему
указатели всегда содержат некое осмысленное значение. Это правило, конечно,
распространяется и на массивы из указателей.
Далее, система типов языка надежно обеспечивает типобезопасность
указателей. В отличие от C, не существует никакой операции, позволяющей
приводить указатель на один тип к указателю на другой (кроме механизма qual,
обеспечивающего безопасное преобразование указателей на родственные
объектные типы, который мы рассмотрим позже).
Помимо типизационного контроля, всегда действует и контроль
актуальности указателей. Этот механизм периода компиляции не позволяет
присвоить ссылку на переменную указателю, имеющему более широкую область
существования, предупреждая таким образом опасность появления "висячих"
ссылок.
int iv1, ^ip1;
{
int iv2, ^ip2;
ip1 = iv1@; !! законно
ip2 = iv2@; !! законно
ip1 = iv2@; !! ошибка!
ip2 = iv1@; !! законно
ip1 = ip2; !! ошибка!
ip2 = ip1 !! законно
}
Предусмотрен также контроль константности, связанный с понятием
константных указателей. Указатель, декларированный как константный (const),
может указывать только на константные значения. Результат именования
константы порождает константный указатель, а результат разыменования
константного указателя -- константное значение. Если присваивание обычного
указателя константному допустимо, то обратное запрещается. Таким образом,
обойти константность значения нельзя, даже прибегая к указателям.
Наконец, немаловажную роль играет отсутствие потенциально опасных
операций над указателями. Так, в противоположность C, для указателей не
определены инкремент, декремент, аддитивные операции и даже сравнения на
упорядоченность. Помимо именования и разыменования для указателей доступны
только инициализация, присваивание, и сравнение на равенство/неравенство. В
общем случае для присваивания и/или сравнения указателей требуется точное
совпадение всех промежуточных типов (за отдельными мелкими послаблениями, на
которых мы подробно останавливаться не будем).
Указатели особенно важны как средство для работы с динамическими
переменными, создаваемыми во время выполнения программы. Для создания
подобной переменной используется специальный терм описания -- аллокатор,
эффект выполнения которого состоит в создании динамической переменной с
немедленным сохранением указателя на нее. Приведем пример:
!! сперва надо декларировать указатели ...
int ^ip, [4] ^ivp;
!! теперь создадим объекты, на которые они будут указывать ...
int alloc (ip) = 5, [4] alloc (ivp) = { 0, 10, 20, 30 };
!! ... после чего их можно использовать:
ip^; !! 5 (int)
ivp^#; !! 4 (u_int)
ivp^ [3]; !! 30 (int)
Используемый синтаксис может показаться непривычным. Если бы в Ксерионе
был C++ подобный оператор new, эти действия записывались бы примерно так:
ip = new int;
ip^ = 5;
ivp = new int [4];
ivp^ = { 0, 10, 20, 30 }
Синтаксически конструкция alloc (PTR) является термом описания, т.е.
она может быть использована везде, где допустимо описание обычной переменной
или константы. Если тип контекста описания TYPE, то операнд аллокатора PTR
-- произвольное L-выражение типа TYPE ^, играющее роль "приемника" для
указателя на созданную динамическую переменную. При этом аллокатор -- чисто
исполняемая конструкция, не имеющая никакого декларативного эффекта.
Благодаря тому, что она помещена в контекст описания, к динамической
переменной можно применять инициализаторы, имеющие привычный синтаксис.
Созданная динамическая переменная изначально доступна только через
указатель PTR. Операции, обратной alloc, не существует и не требуется,
поскольку управление памятью в языке осуществляется динамически. Исполняющая
система поддерживает счетчик актуальных ссылок на динамические переменные.
Когда последняя ссылка теряет актуальность, переменная автоматически
уничтожается.
Существуют ограниченные указатели, при описании которых задавался
атрибут limited. Они способны указывать только на объекты с локальным или
статическим размещением, но не на динамические. Введение в язык таких
"неполноценных" указателей продиктовано соображениями эффективности: они
требуют меньше места (32 бита вместо 64) и большинство операций над ними
выполняется немного быстрее. Присваивание ограниченных указателей обычным
всегда допустимо, но обратное присваивание может вызвать исключение: если
при выполнении программы происходит попытка присвоить ограниченному
указателю ссылку на динамическую переменную, возбуждается исключение
PointerDomainException.
Существует еще один тонкий аспект указателей, связанный с указателями
на массивы. В контексте указательного типа массив может быть "безразмерным"
(полностью или частично), т.е. какие-то из его размеров могут быть явно не
заданы:
float [] ^fv, [][] ^fvv
Здесь fv и fvv -- указатели на одномерный и двумерный массивы из
плавающих, имеющих произвольные размеры. Никакие проверки размеров при этом
не отменяются -- просто информация о них будет храниться вместе с самими
указателями. Если fv присвоить указатель на какой-нибудь массив, информация
об его длине будет также сохранена в отдельном поле fv, а при разыменовании
fv она будет извлечена оттуда для проверки. Таким образом, за
универсальность "безразмерных" указателей на массивы приходится платить тем,
что каждое "пропущенное" измерение увеличивает размер указателя на 32 бита
(и немного уменьшает эффективность работы с ним). Однако, без "безразмерных"
указателей создание многих библиотек функций и классов общего назначения
(скажем, символьных строк) было бы просто невозможным.
В завершение необходимо упомянуть о специальной разновидности
указателей -- ссылках. В общем-то ссылки отличаются от обычных указателей в
двух аспектах: при инициализации ссылки к инициализатору неявно применяется
операция именования, а при использовании ссылки в любом контексте она неявно
разыменовывается. Во всех остальных отношениях ссылки аналогичны указателям,
и могут иметь те же свойства и атрибуты. При описании ссылок вместо префикса
'^' используется префикс '@'. Вот пример работы с ссылками:
char ch1 = ‘A', ch2 = ‘B'; !! символьные переменные
char ^pc = ch1@; !! pc: указатель на ch1
pc^ = ‘C'; !! теперь ch1 -- ‘C'
char @rc = ch1; !! rc: ссылка на ch1
rc = ‘D'; !! теперь ch1 -- ‘D'
Ссылки Ксериона весьма похожи на аналогичный механизм C++, но не менее
важны и различия. Если в C++ ссылки -- специальный языковый механизм (строго
говоря, они не переменные), то в Ксерионе им соответствуют обычные
переменные (или константы), имеющие ссылочный тип. Он может использоваться
как любой другой производный тип (допустимы даже ссылки на ссылки и т.п.).
Наконец, в отличие от C++, ссылка не иммутабельна: если ссылочная переменная
не константна, ее можно изменить (т.е. заставить ссылаться на другой объект
подходящего типа), используя тот факт, что операция именования для ссылки
возвращает L-выражение, подходящее для присваивания:
rc@ = ch2@; !! теперь rc ссылается на ch2
rc = ‘E'; !! теперь ch2 -- ‘E'
В Ксерионе ссылки и простые указатели полностью взаимозаменяемы. В
общем и целом, ссылки можно считать "архитектурным излишеством" -- однако
они, как и в C++, представляют собой существенное нотационное удобство во
многих случаях -- например при использовании функций, ожидающих параметр(ы)
указательных типов.
Функциональные типы и функции
Как и в любом языке программирования, в Ксерионе имеется механизм
функций, и близко связанное с ними понятие функциональных типов данных
(функционалов). Это еще один механизм создания производных типов данных,
представляющих фрагменты программы, к которым можно обратиться (вызвать их).
Важнейшими атрибутами функционального типа являются список параметров (с
определенными именами и типами), передаваемых функционалу при вызове и
значение определенного типа, возвращаемое как результат его выполнения.
Функциональный тип вводится как производный от типа возвращаемого
значения с помощью префиксного описателя, имеющего вид ‘(' <список
параметров> ‘)':
!! int_op - функционал с двумя целыми
!! параметрами (a, b), возвращающий int
int (int a, b) int_op;
!! f_func -- функционал с тремя параметрами разных типов,
!! возвращающий float
float (float [] ^farray; char ch1, ch2; bool flag) f_func;
Список параметров -- это последовательность стандартных описаний,
разделенная точками с запятой. Все переменные и константы, описанные в
деклараторе, приобретают статус параметров функционала. Обратите внимание на
то, что описанные здесь int_op и f_func -- переменные функциональных типов
(не "прототипы функций", как могли бы подумать знакомые с С++). Конечно, в
существовании функциональных переменных и констант не было бы смысла, если
бы в языке не было собственно функций:
int (int a, b) op_add { return a + b }; !! сумма параметров
int (int a, b) op_sub { return a -- b } !! разность параметров
Если терм описания имеет вид <имя> ‘{‘ <список инструкций>
‘}', он описывает функцию <имя>, имеющую соответствующий тип (он
должен быть функциональным) и выполняющую блок инструкций. Как легко видеть,
функции op_add и op_sub возвращают сумму и разность своих параметров (хотя
инструкцию return мы еще "не проходили", смысл ее вполне очевиден). Еще раз
подчеркнем, что описание функции -- частный случай терма описания, т.е.
может встретиться везде, где допустимо описание переменной, и может
сочетаться с другими описаниями, основанными на том же типе (но не пытайтесь
описать "функцию" не функционального типа -- это, конечно, семантическая
ошибка). Допустимы и обычные приемы, такие, как факторизация в описании:
!! можно добавить умножение и деление ...
int (int a, b) { op_mul { return a * b }, op_div { return a // b } }
Идентификатор функции является литералом соответствующего
функционального типа. Операции, доступные для функционалов, помимо вызова,
включают присваивание, инициализацию и сравнение (только на
равенство/неравенство). Вот примеры:
op_add (6, 5); !! 11
int_op = op_add; !! теперь int_op -- это op_add
int_op (5, 4); !! 9
int_op -- op_add; !! true
int_op = op_mul; !! теперь int_op -- это op_mul
int_op (10, 5); !! возвращает 50
int_op <> op_add; !! true
int_op -- op_mul !! true
op_sub = int_op !! ошибка! (op_sub -- литерал, а не переменная)
Обратите внимание: при использовании функционального типа не нужно
каких-либо явных операций именования/разыменования. Конечно, технически
функциональный тип реализован как указатель на некий блок кода, однако
программист не обязан задумываться над этим. Кое-что, безусловно, роднит
функциональные типы с указателями и ссылками. Так, к ним также применимо
значение nil (отсутствие ссылки) и, подобно указателям, все функциональные
переменные и массивы неявно инициализируются им. Конечно, попытка "вызвать"
nil вызывает исключение при выполнении программы (NilInvokeException). Как и
в случае указателей, для присваивания и сравнения функциональных типов
требуется их полная типизационная совместимость: два функционала совместимы,
если совместимы возвращаемые ими значения, количество и типы их параметров.
Имеется и аналог "прототипов функций" в языках C и C++. Терм описания
вида ‘#'<имя> -- это предекларирование (предописание) функции
<имя>. Оно задает список параметров и тип возвращаемого значения,
предполагая, что реализация данной функции будет выполнена позднее. Вот
пример предописания:
float (float x, y) #power; !! предекларируем функцию power
Хотя функция power еще не реализована, ее уже можно использовать:
float result = power (x, 0.5) !! квадратный корень из x
В конце концов, предекларированную функцию необходимо реализовать (в
той же области действия, где была ее предекларация) с помощью конструкции
вида ‘#'<имя><тело функции>. Например:
#power { return exp (y * log (x)) }
Обратите внимание на то, что при реализации не надо повторно задавать
список параметров и возвращаемый тип -- компилятору они уже известны. Более
того, попытка полностью описать уже предекларированную функцию power была бы
ошибкой, т.к. воспринималась бы компилятором как попытка переопределить ее!
Здесь соблюден один из принципов языка: каждый объект должен быть описан
только однажды, а дублирование описаний не нужно и не допускается. В случае
предекларированной функции, строго говоря, мы имеем дело не с двумя
описаниями, а с единым, разбитым на две части: декларативную и
реализационную. В данном случае явной необходимости использовать
предекларирование нет, поскольку можно было бы написать сразу:
float (float x, y) power { return exp (y * log (x)) }
Но без предекларирования невозможно обойтись, когда описывается
семейство взаимно-рекурсивных функций, каждая из которых вызывает (прямо или
косвенным образом) все другие.
Синтаксис и семантику вызова функционалов следует рассмотреть
подробнее. Обычно вызов является N-арной операцией, имеющей первым операндом
вызываемое значение функционального типа. Далее следует список аргументов,
каждый из которых задает значение для одного из параметров функционала.
Традиционно соответствие между ними устанавливается по позиционному
принципу, т.е. порядок аргументов вызова соответствует порядку параметров в
декларации функционального типа:
void (float x, y; bool p, q) z_func;
z_func (0.5, 1.5, true, true)
!! (т.е. x ← 0.5, y ← 1.5, p ← true, q ← true)
Однако, допустим также и именной принцип, когда имя параметра для
текущего аргумента задается явно с помощью префикса вида <параметр>
‘:'. Например, как здесь:
z_func (p: false, q: true, x: 0.0, y: 1.0)
!! (x ← 0.0, y ← 1.0, p ← false, q ← true)
Оба вида спецификации можно комбинировать в одном вызове. Задание
аргумента без префикса означает, что он относится к следующему по порядку
параметру (к самому первому, если предшествующих не было). Наконец, элемент
списка аргументов может быть пустым, что означает пропуск соответствующего
параметра (который может быть заполнен позже):
z_func (3.14, , false, false, y: 8.9)
!! (x ← 3.14, y ← 8.9, p ← false, q ← false)
При неосторожном сочетании всех этих приемов вполне может оказаться
так, что при вызове функции параметр оставлен без значения, или же
инициализирован два (или более) раза. Второе является безусловной ошибкой, а
вот первое может считаться допустимым. Дело в том, что к параметрам функции,
как и к любым переменным, может быть применена инициализация по умолчанию.
Любой явно заданный аргумент "вытесняет" неявное значение параметра.
Аналогичная возможность имеется и в C++, но там инициализация по умолчанию
может относиться лишь к последним аргументам в списке, а инициализаторами
обязаны быть литеральные значения. В Ксерионе оба этих ограничения
отсутствуют. Более того, один неочевидный (но весьма полезный) аспект
описаний состоит в том, что инициализатор для параметра может содержать
другие параметры, описания которых предшествуют ему. Применение этого метода
лучше показать на примере:
!! Заметьте, что здесь три описания нельзя объединить
void (int a = 5; int b = a; int c = a + b) x_func;
x_func (11, 12, 13); !! все аргументы задано явно
!! (a ← 11, b ← 12, c ← 13)
x_func (10, 20); !! a и b заданы, c по умолчанию
!! (a ← 10, b ← 20, c ← 30)
x_func (10); !! a задано, b и c по умолчанию
!! (a ← 10, b ← 10, c ← 20)
x_func (); !! все по умолчанию
!! (a ← 5, b ← 5, c ← 10)
Даже в качестве размеров параметров-массивов могут использоваться
выражения, содержащие ранее декларированные параметры. Это тоже может
оказаться полезным:
!! матричное произведение: C = A (*) B
void (u_int L, M, N; double [L][M] @A, [M][N] @B, [L][N] @C) MatrixProduct {
! ... ! }
Семантика передачи аргументов -- это всегда семантика инициализации,
т.е. допустимы не только простые выражения, но и любые инициализаторы,
подходящие по типу. То же относится к значению, возвращаемому инструкцией
return. Заметим, что параметры-массивы (в отличие от C, C++ и Java) также
передаются (и возвращаются) по значению, что может быть весьма дорогим
удовольствием. Как правило, массивы лучше передавать через указатель или
ссылку, а передачу по значению использовать лишь в тех случаях, когда это
действительно оправдано. Помимо своих параметров, функции доступна вся
внешняя среда -- т.е. все переменные и константы (независимо от режима их
размещения) и прочие виды описаний, доступные в точке, где дано описание
функции.
В языке не существует перегруженных (overloaded) функций, подобных
имеющимся в C++. Имя каждой функции в своей области действия должно быть
уникально (как и для любого другого субъекта описания).
В заключение отметим, что функциональный тип допускает отдельную форму
инициализатора, прямо задающего тело безымянной функции. (Некоторые языки
программирования называют подобное "лямбда-нотацией"). Неявный инициализатор
имеет вид ‘#' <тело функции>. Имена и типы параметров и возвращаемого
значения явно не задаются, а определяются автоматически, исходя из контекста
инициализации. Например:
int (float a, b, c) t_func = #{ return :int (a * b * c) };
t_func (2, 3, 4) !! 24 (int)
Дополнительные разновидности описаний
Чтобы завершить разговор об описаниях, мы рассмотрим некоторые
специальные декларативные конструкции. Все они имеют скорее вспомогательное,
чем принципиальное значение, но все-таки они полезны при создании реальных
программ.
Прежде всего, в Ксерионе имеется свой аналог описания typedef в C,
позволяющий вводить новые типы. Однако, это не самостоятельная конструкция,
а лишь еще один вид терма описания (type <имя типа>), который, как
всегда, может совмещаться с другими термами. Например:
!! flt -- синоним float,
!! pflt -- указатель на float
!! ppflt -- указатель на указатель на float
float type flt, ^ type pflt, ^^ type ppflt
Ключевое слово type слишком громоздко, поэтому его можно сократить до
символа ‘%' (что обычно на практике и делается). Для того, чтобы
использовать новоопределенный тип в качестве корня описания, он тоже должен
предваряться словом type (или символом ‘%'):
%flt x, y, z; !! т.е. float x, y, z
%pflt p1, p2; !! т.е. float ^ {p1, p2}
%ppft pp1, pp2, pp3 !! т.е. float ^^ {pp1, pp2, pp3}
С точки зрения семантики подобная запись -- не более, чем средство
сократить длинные описания. В отличие от объектных типов, никакими
принципиально новыми свойствами тип, введенный через описание type, обладать
не будет.
Приведенные выше описания -- это частный случай более общего подхода,
позволяющего использовать в качестве корня описания не только определенный
программистом тип, но и произвольное выражение, имеющее смысл. Вот несколько
тривиальных примеров:
%(2 * 2) xx, yy, zz; !! т.е. u_int xx, yy, zz
%(10 < 20) pp, qq; !! т.е. bool pp, qq
%("text" []) cc !! т.е. char cc
Выражение в корне описания (если это не просто идентификатор, оно
должно быть заключено в скобки) вычисляется, но его значение игнорируется, и
в качестве базы описания используется только его тип. Наконец, отметим, что
имена определенных пользователем (но не встроенных!) типов -- это также
законные (но неопределенные) выражения. Все это открывает возможности для
многих полезных трюков. Так, использование имен производных типов в
выражениях (и выражений -- в корнях описаний) дает простой механизм
типизационной декомпозиции, т.е. перехода от производных типов к их базовым.
Вот пример того, как это можно использовать на практике:
!! если v_type -- векторный тип:
%(v_type []) %v_type_elem; !! v_type_elem -- это тип элементов v_type
!! если p_type -- указательный тип:
%(p_type ^) %p_type_ref; !! p_type_ref -- это тип,
!!
получаемый разыменованием p_type
!! если f_type -- функциональный тип:
%(f_type ()) %f_type_result !! f_type_result -- это тип значения,
!!
возвращаемого f_type при вызове
Существует еще одна важная форма описаний -- это макроопределения
(let-определения). В основном, они применимы для тех же целей, что и
определения #define в C/C++, т.е. как макроподстановки повторяющихся
фрагментов исходного кода программы. Но не менее важны и различия. Если
средства C-препроцессора -- это надстройка над языком, то let-определения --
это часть языка Ксерион, а объектом let-подстановки может быть не всякая
строка символов -- это должно быть законное выражение языка. Общий синтаксис
макроопределения имеет такой вид:
let NAME1 ‘=' EXPR1 (‘,' NAME2 ‘=' EXPR2) ...
Это определение делает все идентификаторы NAME# синонимами для
соответствующих выражений EXPR#. Как и прочие виды определений,
макроопределения локальны для содержащего их блока или области действия.
Важно также то, что выражение EXPR должно быть корректно не только
синтаксически, но и семантически: в частности, все идентификаторы,
упомянутые в EXPR, должны иметь смысл. В целом механизм макроопределений
обеспечивает не только текстуальную, но и семантическую подстановку: все
имена будут иметь в точке обращения к макро тот же смысл, который они имели
в точке его определения. Например:
int value; !! целая переменная
let v1 = value; !! v1 -- синоним value
{ float value; !! переопределение value в подблоке
value; !! (float value)
v1 !! (а это -- int value)
}
Наконец, если EXPR является L-выражением, то NAME -- также L-выражение.
Механизм макроопределений является довольно мощным средством, используемым
для самых разных целей: от определения символических литералов (в отличие от
констант-переменных, для них не требуется дополнительная память) до простого
сокращения слишком длинных идентификаторов переменных, функций, типов и
классов:
%err_no (%string FileName) #SystemOpenFile;
let SysOpen = SystemOpenFile !! сокращение
В завершение рассмотрим описание conceal -- механизм "скрытия" имен.
Если идентификатор, определенный в некой внешней области действия (например,
глобальный) необходимо сделать недоступным в некой внутренней (и всех
областях, вложенных в нее), этого легко добиться с помощью специального
описателя conceal:
conceal NAME (‘,' NAME1) ...
Описатель conceal делает все перечисленные в нем имена локально
недоступными (от описателя до конца внутренней области действия, содержащей
его). В сущности, описание conceal NAME работает примерно как let
NAME=<nothing>. В основном, механизм conceal предназначен для работы с
объектами и иерархиями классов (например, скрытия каких-нибудь атрибутов
базового класса в производных классах), что, конечно, не означает, что его
нельзя использовать для других целей.
Инструкции и поток управления
Собственно программа состоит в основном из операторов или инструкций
языка (последний термин кажется нам предпочтительным, поэтому им мы и будем
пользоваться). Простейшие виды инструкций мы уже рассмотрели. Так, все виды
описаний являются законными инструкциями, допустимыми в любом месте
программы. Любое выражение -- это также инструкция (возвращаемое значение,
если оно есть, игнорируется). В языке предусмотрен такой механизм
группировки инструкций, как блок, т.е. последовательность инструкций,
разделенных точками с запятой (‘;') и заключенная в фигурные скобки ("{}").
Блок рассматривается как единая инструкция и является областью локализации
для всех содержащихся в нем описаний. Заметьте, что в этом отношении язык
следует традициям Паскаля: тоска с запятой -- это синтаксический разделитель
инструкций (но ни одна инструкция не завершается этим символом). Во многих
случаях избыточная точка с запятой не считается ошибкой, т.к. в языке
определена пустая инструкция, не содержащая ни одного символа (и, очевидно,
не выполняющая никаких действий). Любая инструкция может быть помечена
меткой вида LABEL ‘:', что позволяет инструкциям break, continue и goto на
нее ссылаться. Рассмотрим другие виды инструкций.
Инструкция утверждения (assert) имеет вид:
assert CND
Семантика ее проста: вычисляется CND (выражение типа bool). Если оно
истинно, ничего не происходит, в противном случае возбуждается
исключительная ситуация AssertException. Эта инструкция нужна в основном для
"отлова" логических ошибок в процессе отладки программы.
Конечно же, имеется условная инструкция (if/unless), имеющая следующий
вид:
(if P_CND | unless N_CND) BLOCK
[else E_STMT]
Если (для if-формы) выражение P_CND истинно или (для unless-формы)
выражение N_CND ложно, выполняется блок BLOCK. В противном случае, если
присутствует необязательная часть else, будет выполнена инструкция E_STMT.
Заметим, что тело условной инструкции -- это всегда блок, ограниченный
фигурными скобками (что снимает проблему неоднозначности "висящего else").
Однако, круглые скобки вокруг условия (как в C) не требуются (хотя, конечно,
ничему и не помешают). В части else допустима произвольная инструкция
(например, другой if/unless). Очевидно, что формы if и unless полностью
взаимозаменяемы, и какую из них использовать -- вопрос конкретного случая.
В отличие от большинства языков, в Ксерионе имеется только одна (зато
довольно мощная) инструкция цикла. Вот ее самый общий синтаксис:
[for I_EXPR]
(while P_CND_PRE | until N_CND_PRE | loop)
[do R_EXPR]
BLOCK
[while P_CND_POST | until N_CND_POST]
Хотя она выглядит довольно громоздкой, большая часть ее компонент
необязательна. Необязательная часть for задает инициализатор цикла --
выражение I_EXPR, которое всегда вычисляется один раз перед самым началом
работы цикла. Далее всегда следует заголовок цикла, задающей его
предусловие, проверяемое перед каждой итерацией цикла. Если (в форме while)
P_CND_PRE ложно или (в форме until) N_CND_PRE истинно, цикл завершит свою
работу. Если же заголовок цикла сводится к loop, предусловие отсутствует.
Телом цикла является блок BLOCK, обычно выполняющий основную работу.
Необязательная часть do задает поститерацию цикла: выражение R_STMT будет
вычисляться на каждой итерации после тела цикла. Наконец, цикл может иметь и
постусловие: если (в форме while) P_CND_POST ложно или (в форме until)
N_CND_POST истинно, цикл также завершится. Какую из двух форм использовать
для пред- и постусловия -- это, опять-таки, вопрос предпочтения. Предусловие
и постусловие могут присутствовать одновременно -- в этом случае, цикл
прерывается, когда перестает соблюдаться хотя бы одно из них. Наконец
заметим, что вместо выражения I_EXPR может быть дано любое описание, и при
этом цикл становится областью локализации для него (т.е. как бы неявно
заключается в блок). Элементы for и do логически избыточны -- они нужны
только для того, чтобы можно было ради наглядности собрать в заголовке всю
логику управления циклом. Так, если нужен цикл с переменной i, меняющей
значение от (включая) 0 до (исключая) N; это обычно записывается так:
for u_int i = 0 while i < N do ++ i { !( тело цикла )! }
Нередко необходимо прервать выполнение цикла где-нибудь посередине. Для
этого удобно использовать инструкцию прерывания break:
break [LABEL]
Она прерывает выполнение содержащего ее цикла, помеченного меткой LABEL
(равно как и всех вложенных в него циклов, если они есть). Если элемент
LABEL опущен, прерывается самый внутренний из циклов, содержащих инструкцию
break. Инструкция продолжения continue:
continue [LABEL]
вызовет прерывание текущей итерации цикла LABEL (или, если метка
опущена, самого вложенного цикла) и переход к его следующей итерации
(включая выполнение поститерации и проверку постусловия, если они есть).
В завершение упомянем об инструкции перехода goto:
goto [LABEL]
передающей управление инструкции, помеченной меткой LABEL. О вредности
подобных инструкций классики структурного программирования написали столько,
что нет смысла их повторять. Инструкция goto в языке есть, а использовать ли
ее в программе -- дело вашей совести и личных предпочтений.
Для завершения работы функции применяется уже знакомая нам инструкция
return:
return [EXPR]
Она допустима только в определении функции и обеспечивает выход из нее
с возвратом значения EXPR (подходящего типа). Выражение EXPR опускается,
если тип функции -- void.
Наконец, в языке имеется инструкция with, тесно связанная с объектами и
потому рассмотренная в следующем разделе.
Объекты и классы
Ксерион -- это объектно-ориентированный язык. В нем присутствует
концепция объекта -- ключевого механизма абстракции данных, обеспечивающего
для них инкапсуляцию, наследование и полиморфизм.
Каждый объект языка относится к одному из классов, определяющих
специфичные для него свойства и атрибуты. Самый общий синтаксис описания
класса таков:
class CLASS_NAME [‘:' SUPERCLASS_NAME]
{
CLASS_DECLS
}
[instate INSTATE_LIST]
[destructor DESTRUCTOR_BODY]
Рассмотрим все элементы описания по порядку. Прежде всего, каждый класс
обязан иметь уникальное в своей области действия имя (CLASS_NAME). Класс
может быть либо корневым, либо же производным от уже определенного
суперкласса (класса SUPERCLASS_NAME). Далее следует заключенное в фигурные
скобки тело описания класса, представляющее собой список CLASS_DECLS. Его
элементами могут быть практически все виды описаний языка (включая и
некоторые другие, рассмотренные ниже). В большинстве случаев в описании
класса присутствуют переменные, константы и функции.
Любая переменная, описание которой содержится в декларации класса, по
умолчанию считается его компонентой. Это значит, что для каждого объекта
класса существует собственная копия этой переменной. Если же переменная
имеет явно специфицированный режим размещения static или shared, она
является переменной класса, т.е., в отличие от его компонент, существует в
единственном экземпляре, вне зависимости от того, сколько объектов данного
класса было создано. Разница между режимами static и shared состоит в том,
что static-переменные существуют глобально (время их существования совпадает
со временем выполнения программы), а для shared область действия, равно как
и время существования, определяются декларацией класса.
В декларации класса могут присутствовать вложенные блоки личных
(private) и защищенных (protected) описаний. Как и в C++, имена всех
объектов, декларированных в private-блоке, доступны только внутри декларации
класса, а в protected-блоке -- также и внутри деклараций всех его
подклассов. Все прочие декларации являются публичными, т.е. доступными извне
без каких-либо ограничений.
Синтаксически описание класса играет роль корня описания. Заметим, что
после того, как класс декларирован, для ссылок на него (как и на все прочие
производные типы) используется ключевое слово type или ‘%' (а не class).
В семантике объектов уникальным (и весьма важным) является понятие
текущего экземпляра объекта. Для каждого класса определен один и только один
текущий экземпляр. Его можно рассматривать как неявную переменную класса с
типом CLASS_NAME^ и режимом размещения shared, инициализируемую, как и все
указатели, значением nil. В процессе выполнения программы текущий экземпляр
класса может временно меняться. Обратиться к текущему экземпляру некоторого
класса (скажем, CLASS_NAME), можно очень просто: по имени этого класса. В
контексте описания любого класса вместо его имени можно использовать
ключевой слово this:
CLASS_NAME; !! текущий экземпляр класса CLASS_NAME
this !! текущий экземпляр текущего класса
Рассмотрим теперь бинарную операцию доступа к классу ‘.' (точка).
Первым операндом этой операции всегда является объект некоторого класса, а
второй операнд (произвольное выражение) -- это результат операции (от него
выражение также заимствует L-контекстность и константность). Как и в C++ и
Паскале, она может использоваться, например, для доступа к отдельным
компонентам объекта, но в Ксерионе ее семантика значительно шире. Формально
она имеет два независимых аспекта: декларативный и процедурный.
Декларативный аспект операции состоит в том, что ее второй операнд
вычисляется в контексте пространства имен данного класса (т.е. в нем
доступны имена компонент, переменных, функций и иные атрибуты класса).
Процедурный аспект -- в том, что она (на время вычисления своего второго
операнда) делает свой первый операнд-объект текущим экземпляром для своего
класса. Оба перечисленных аспекта сочетаются естественным образом, как видно
из примеров:
!! тривиальный вектор из трех компонент
class VECTOR { float x, y, z };
%VECTOR vec1, vec2; !! пара объектов класса VECTOR
vec1.x; !! x-компонента vec1
vec2.(x + y + z); !! сумма компонент vec2
vec1.(x*x + y*y + z*z) !! норма вектора vec1
Если же первый операнд -- это ссылка на текущий объект (иными словами,
имя класса), то декларативная семантика остается неизменной, но процедурная
вырождается в пустую операцию (т.к. текущий объект уже является таковым).
Таким образом, операция доступа к классу становится практически точным
аналогом операции ‘::' (квалификации) из C++:
VECTOR.x !! x-компонента текущего экземпляра VECTOR
this.x !! то же самое в контексте класса VECTOR
В системе инструкций языка имеется свой аналог операции доступа к
классу -- инструкция присоединения with:
with OBJ_EXPR BLOCK
Ее семантика практически та же: выполнить блок инструкций BLOCK в
контексте класса, определенного OBJ_EXPR (декларативная), и с OBJ_EXPR в
качестве текущего экземпляра этого класса (процедурная). К примеру:
with vec1 { x = y = z = 0f }; !! обнулить компоненты vec1
with VECTOR { x = y = z = 0f } !! то же с текущим экземпляром VECTOR
В языке не существует специального понятия метода класса -- в основном
потому, что они и не требуются. Методы классов в C++ и Java характеризуются
тем, что вместе с другими аргументами они неявно получают указатель на
текущий объект класса, с которым должны работать. Однако, в Ксерионе понятие
текущего объекта является глобальным и равно применимым ко всем функциям.
Функции, декларированные внутри класса, отличаются от других только тем, что
имеют непосредственный доступ ко всем атрибутам класса (включая его личную и
защищенную часть). Если же последнее не требуется, функции, работающие с
объектами определенного класса, могут быть декларированы и за его пределами.
Приведем пример для описанного нами класса VECTOR:
!! Умножение вектора на скаляр `a`
void (float a) scale_VECTOR
{ with VECTOR { x *= a; y *= a; z *= a } }
Описанный нами "псевдо-метод" scale_VECTOR использовать на практике так
же просто, как и функции, декларированные вместе с самим классом:
vec2.Scale_VECTOR (1.5) !! Умножить vec2 на 1.5
with vec2 { Scale_VECTOR (1.5) } !! то же, что и выше
Scale_VECTOR (2f) !! Умножить текущий экземпляр VECTOR на 2
Помимо этого, для каждого класса автоматически определяются операции
присваивания, инициализации и сравнения (на равенство и неравенство).
Присваивание объектов состоит в последовательном присваивании всех их
компонент. Аналогичным образом определяется экземплярная инициализация:
объект всегда может быть инициализирован присваиванием ему другого объекта
того же класса. Операция сравнения также определена как покомпонентная: если
все соответствующие компоненты равны, два объекта считаются равными; в
противном случае они различны. Эти операции над объектами всегда доступны; в
отличие от C++ их невозможно переопределить или же "разопределить".
Конечно же, помимо экземплярной инициализации предусмотрены и другие
законные способы инициализировать объект класса. Для классов всегда
определена списковая инициализация, а может быть доступен и вызов
конструктора. Рассмотрим эти возможности по порядку.
Самый тривиальный способ инициализации создаваемого объекта -- это
инициализация его списком компонент. В принципе, этот способ аналогичен
списковой инициализации классов и структур в C и C++, но он допускает больше
возможностей.
Общий синтаксис спискового инициализатора объекта имеет примерно такой
вид:
‘#' ‘(' <COMP_LIST> ‘)'
где COMP_LIST -- это список инициализаторов для компонент объекта. Его
синтаксис мы подробно рассматривать не будем, поскольку он полностью
идентичен списку аргументов функций. Единственное различие: список здесь
применяется не к параметрам функционала, а к компонентам объекта. В списке
допустимы и позиционные инициализаторы, и именные. Практически ничем не
отличается и семантика. Компоненты объекта, как и параметры функции, могут
иметь инициализацию по умолчанию (в том числе, и с использованием ранее
описанных компонент), и явная инициализация переопределяет неявную. Наконец,
заметим, что при инициализации декларируемой переменной может использоваться
сокращенная форма: вместо VAR = #( LIST ) можно написать просто VAR ( LIST
). Приведем примеры для класса VECTOR:
%VECTOR null = #(0f, 0f, 0f); !! нулевой вектор
%VECTOR null (0f, 0f, 0f) !! (то же, короче)
%VECTOR null (x: 0f, y: 0f, z: 0f) !! (то же, очень развернуто)
!! координатные векторы-орты
%VECTOR PX (1f, 0f, 0f), PY (0f, 1f, 0f), PZ (0f, 0f, 1f)
%VECTOR NX (-1f, 0f, 0f), NY (0f, -1f, 0f), NZ (0f, 0f, -1f)
Для наиболее тривиальных классов, подобных классу VECTOR, списковая
инициализация является самым простым и удобным способом создания объекта.
Однако, часто нужны и классы-"черные ящики", имеющие нетривиальную
внутреннюю структуру, целостность которой должна всегда быть обеспечена.
Списковая инициализация для них неудобна и ненадежна, и лучше использовать
специальные функции -- конструкторы.
Все конструкторы декларируются внутри соответствующего класса.
Синтаксис описания такой же, как и у функций, только в качестве
возвращаемого типа используется фиктивный тип constructor (на самом деле,
конструкторы не возвращают значения вообще). В отличие от C++ и Java, все
конструкторы в Ксерионе -- именованные: класс может иметь произвольное
количество конструкторов, но их имена должны различаться (и ни одно из них
не совпадает с именем класса). Так, к описанию класса VECTOR мы могли бы
добавить конструктор:
!! инициализация вектора полярными координатами
!! (len -- модуль, phi -- долгота, theta -- широта)
сonstructor (float len, phi, theta) polar
{ x = len * sin(phi) * cos(theta), y = len * cos(phi) * cos(theta), z = len
* sin(theta) }
Тот же конструктор может быть более компактно записан так:
сonstructor (float len, phi, theta) polar :
(len * sin(phi) * cos(theta), len * cos(phi) * cos(theta), len * sin(theta)
) {}
Конструкция в круглых скобках после двоеточия -- это тот же списковый
инициализатор для объекта, элементы которого могут обращаться к параметрам
конструктора. В данном случае можно выбрать, какую именно форму
использовать, но если какие-то компоненты класса требуют нетривиальной
инициализации (например, сами являются объектами), использовать
список-инициализатор в конструкторе -- это единственный корректный способ
задать им начальное значение. Независимо от того, как конструктор polar
определен, использовать его можно так:
%VECTOR anyvec = :polar (200f, PI/4f, PI/6f)
Обратите внимание на двоеточие перед вызовом конструктора: оно явно
указывает на то, что при инициализации будет использован конструктор для
этого класса.
Как и в C++, в Ксерионе существуют временные объекты. Временный объект
создается либо указанием списка компонент, либо обращением к конструктору
(обычно квалифицированному с помощью операции ‘.'). Например:
VECTOR (0.5, 0.3, -0.7) !! временный вектор
VECTOR.polar (10.0, 2f*PI, PI/2f) !! другой вариант
Существование временных объектов обычно длится не дольше, чем
выполняется инструкция, в которой они были созданы.
Не только инициализация, но и деинициализация объекта может потребовать
нетривиальных действий, поэтому для класса может быть задан деструктор. Это
-- просто блок кода, определяющий действия, неявно выполняемые при
завершении существования любого объекта класса. У класса не бывает более
одного деструктора. Даже если деструктор не задан явно, компилятор часто
создает неявный деструктор в тех случаях, когда это необходимо. Действия,
описанные в явном деструкторе, всегда выполняются до вызова неявного.
Собственно, явные деструкторы нужны редко: в основном они требуются лишь в
тех случаях, когда объект задействует какие-то внешние по отношению к
программе ресурсы (скажем, открывает файлы или устанавливает сетевые
соединения), а также для отладочных целей и статистики.
Очень кратко рассмотрим аспекты языка, связанные с наследованием. Как
уже говорилось, класс может иметь суперкласс, и в этом случае он наследует
все атрибуты суперкласса, в дополнение к тем, которые определяет сам.
Область видимости класса вложена в область видимости суперкласса, поэтому
любые атрибуты суперкласса могут быть переопределены в производном классе.
Подклассу доступны все публичные и все защищенные (но не приватные!)
декларации суперкласса. Механизмы let и conceal дают гибкие возможности
управления видимостью атрибутов суперкласса, позволяя скрывать их или давать
им альтернативные имена.
Любая функция, декларированная в некотором классе, может иметь
спецификатор virtual. Он означает, что данная функция является виртуальной
функцией данного класса, т.е. может иметь альтернативную реализацию в любом
из его подклассов. Механизм виртуализации вызовов функций обеспечивает т.н.
динамическое связывание: в отличие от обычного связывания, основанного на
информации о типах времени компиляции, для виртуальной функции всегда
вызывается именно та версия, которая необходима, исходя из динамической
информации о реальном типе объекта данного класса, доступной при выполнении
программы. Переопределить виртуальную функцию очень просто. Для этого ее имя
должно быть включено в список переопределения instate, обычно завершающий
декларацию подкласса. Параметры и тип функции повторно задавать не нужно:
они жестко определяются virtual-декларацией суперкласса. Нередко в списке
instate дается и реализация новой версии виртуальной функции; в противном
случае реализация должна быть дана позднее.
Если виртуальная функция не переопределена в подклассе, наследуется ее
версия из суперкласса. Фактически, имя виртуальной функции -- это интерфейс,
за которым скрывается множество различных функций. Наконец, как и в C++,
подкласс может явно вызвать версию из какого-нибудь суперкласса с помощью
полностью квалифицированного имени.
Наконец, говоря о наследовании классов, нельзя не упомянуть об
абстрактных классах (или просто абстрактах). Абстрактный класс -- это класс,
для которого не существует ни одного объекта (и, соответственно, не
определен текущий экземпляр) и который может использоваться только в
качестве производителя классов-потомков. При описании абстрактного класса
используется ключевое слово abstract вместо class. Абстрактные суперклассы
предназначены для реализации базовых концепций, которые лежат в основе некой
группы родственных объектов, но сами не могут иметь никакого "реального
воплощения".
Как обычно, мы продемонстрируем наследование, полиморфизм и абстракты
на более-менее реалистичном примере (работа с простейшими геометрическими
объектами).
!! Геометрическая фигура (абстрактный класс)
abstract Figure {
!! фигура обычно имеет...
!! -- некий периметр:
float () virtual perimeter;
!! -- некую площадь:
float () virtual area;
};
!! Точка
class Point : Figure {
} instate #perimeter { return 0f }, #area { return 0f };
!Отрезок (длины L)
class Line : Figure {
float L !! длина
} instate #perimeter { return L }, #area { return 0f };
!! Квадрат (со стороной S)
class Square : Figure {
float S !! сторона
} instate #perimeter { return 4 * S }, #area { return S * S };
!! Прямоугольник (со сторонами A, B)
class Rectangle : Figure {
float A, B
} instate #perimeter { return 2 * (A + B) }, #area { return A * B };
!! Круг (с радиусом R)
class Circle : Figure {
float R
} instate #perimeter { return 2 * PI * R }, #area { return PI * R * R };
При всей примитивности определенной нами иерархии объектов, с ней уже
можно делать что-то содержательное. К примеру следующий фрагмент
подсчитывает суммарную площадь фигур в массиве ссылок на фигуры fig_vec:
%Figure @ []@ fig_vec; !! ссылка на вектор ссылок на фигуры
float total_area = 0f; !! суммарная площадь
for u_int i = 0 while i <> fig_vec# do ++ i
{ total_area += fig_vec [i].area () }
Наконец мы отметим, что виртуальные функции -- это не единственный
полиморфный механизм в языке. При необходимости можно использовать
специальную операцию явного приведения указателя на суперкласс к указателю
на подкласс. Бинарная операция квалификации:
CLASS qual OBJ_PTR_EXPR
предпринимает попытку преобразовать OBJ_PTR_EXPR (указатель на некий
объект) к указателю на класс CLASS (который должен быть подклассом
OBJ_PTR_EXPR^). Операция возвращает выражение типа CLASS^: если объект, на
который указывает второй операнд, действительно является экземпляром класса
CLASS, возвращается указатель на него, в противном случае возвращается
значение nil. Вот почему возвращаемое значение всегда должно проверяться
прежде, чем с ним предпринимаются дальнейшие вычисления.
%Figure ^fig_ptr; !! указывает на фигуру
%Rectangle some_rect (10f, 20f); !! прямоугольник 10 * 20
%Circle some_circ (50f); !! окружность радиуса 50
fig_ptr = some_rect@; !! fig_ptr указывает на прямоугольник
Rectangle qual fig_ptr; !! вернет указатель на some_rect
Circle qual fig_ptr; !! вернет nil
fig_ptr = some_circ@; !! fig_ptr указывает на окружность
Rectangle qual fig_ptr; !! вернет nil
Circle qual fig_ptr; !! вернет указатель на some_circ
Квалификация с помощью qual очень похожа на динамическое приведение
типов dynamic_cast в последних версиях языка C++.
Определение операций
Как и в C++, в Ксерионе предусмотрены средства для переопределения
операций. Сразу же заметим, что на самом деле корректнее говорить об их
доопределении: не существует способа переопределить операцию, уже имеющую
смысл (например, определить операцию ‘-‘ так, чтобы она складывала целые
числа). Однако, если операция не определена для некоторой комбинации типов
операндов, то в этом случае ей может быть приписана некоторая семантика.
Операции -- практически единственный механизм языка, где допустима
перегрузка в зависимости от типов операндов, и язык позволяет распространить
этот принцип и на производные типы. (Синтаксис, приоритет или
ассоциативность операции переопределять, конечно, нельзя.)
Новая семантика операции задается с помощью специального описателя
opdef:
opdef OP_DEF1 ‘=' EXPR1 (‘,' OP_DEF2 ‘=' EXPR2) ...
Как и все прочие описания, определения операций имеют локальный
характер. Каждый элемент OPDEF -- это конструкция, имитирующая синтаксис
соответствующей операции, но вместо операндов-выражений в ней задаются типы
данных. (Гарантированно могут использоваться любые примитивные типы и имена
классов, но возможно, в будущем можно будет использовать любые производные
типы).
Соответствующее выражение EXPR будет подставляться вместо комбинации
OPDEF. При этом в EXPR допустимо использование специальных термов вида
(<1>), (<2>)..., соответствующих первому операнду, второму и
т.п. Пример:
opdef VECTOR + VECTOR = VECTOR.add (<1>, <2>)
Здесь определяется новая семантика операции ‘+' для двух объектов
класса VECTOR. Вместо этой операции будет подставлен вызов функции add
(предположительно определенной в классе VECTOR) с обоими операндами в
качестве аргументов.
Фактически определение операции -- это разновидность макроопределения,
и в семантике макроподстановки имеется очень много общего с
let-определениями. Так, подстановка является семантической, а не
текстуальной. Но определенная операция -- это не вызов функции: и для самого
определения и для всех его операндов действует семантика подстановки, а не
вызова. Громоздкое определение вызовет генерацию большого количества лишнего
кода, а если в теле opdef-определения ссылка на параметр встречается
многократно, соответствующий ей операнд также будет подставлен несколько раз
(что, вообще-то, весьма нежелательно).
Наконец, отметим, что для того, чтобы определение операции было
задействовано, требуется точное соответствие реальных типов операндов типам
в opdef-декларации. Приведения типов не допускаются. (В дальнейшем, правда,
это ограничение может быть ослаблено.)
Приведем содержательный пример определения операций. Пусть у нас
имеется класс String, реализующий символьные строки, грубая модель которого
дана ниже:
class String {
!! (определения...)
!! длина текущей строки
u_int () #length;
!! конкатенация (сцепление) строк head & tail
%String (%String head, tail) #concat;
!! репликация (повторение n раз) строки str
%String (%String str; u_int n) #repl;
!! подстрока строки str (от from до to)
%String (%String str; u_int from, to) #substr;
!! ...
}
Теперь определим набор операций, позволяющих работать со строками
проще.
!! для компактности ...
let Str = String;
!! ‘#' как длина строки:
opdef Str# = (<1>).len ();
!! ‘+' как конкатенация:
opdef Str + Str = Str.concat ((<1>), (<2>));
!! ‘*' как репликация:
opdef Str * u_int = Str.repl ((<1>), (<2>));
opdef u_int * Str = Str.repl ((<2>), (<1>));
!! отрезок как подстрока
opdef Str [u_int..u_int] = Str.substr (<1>, <2>, <3>);
Определенные так операции довольно удобно использовать:
Str("ABBA")#; !! 4
Str("Hello, ") + Str("world!"); !! Str("Hello, world!")
Str("A") * 5; !! Str("AAAAA")
3 * Str("Ha ") + Str("!"); !! Str("Ha Ha Ha !")
Str("Main program entry") [5..12]; !! Str("program")
Как уже говорилось, имеющиеся в языке операции ввода и вывода
предназначены исключительно для переопределения. Для большинства примитивных
типов (и для многих объектных) эти операции переопределены в стандартных
библиотеках ввода-вывода, что делает их использование очень простым. Их
разумное определение для пользовательских классов -- рекомендуемая практика.
Так, для упомянутого класса VECTOR мы можем определить операцию вывода
(OFile -- класс выходных потоков):
opdef OFile <: VECTOR =
(<1>) <: ‘(‘ <: (<2>).x <: ‘,' <: (<2>).y
<: ‘,' <: (<2>).z <: ‘)'
Заметим, что поскольку операция вывода лево- ассоциативна и возвращает
в качестве значения свой левый операнд (поток вывода), определенная нами
операция также будет обладать этим свойством, что очень хорошо. Но у этого
определения есть и недостаток: правый операнд вычисляется три раза, что
неэффективно и чревато побочными эффектами. В данном случае это легко
поправить:
opdef OFile <: VECTOR =
(<2>).((<1>) <: ‘(‘ <: x <: ‘,' <: y <: ‘,' <:
z <: ‘)')
Но, вообще-то говоря, если определенная так операция вывода будет
использоваться интенсивно, это приведет к заметному переизбытку
сгенерированного кода. Лучшим решением будет определить функцию для вывода
объектов VECTOR, а потом, уже через нее, операцию.
Импорт и экспорт.
Прагматы.
В завершение нашего обзора рассмотрим механизмы, обеспечивающие
взаимодействие между Ксерион-программой и внешней средой. Понятно, что ни
одна реальная программа не может обойтись без них: например, стандартные
средства ввода-вывода и взаимодействия с ОС, математические функции,
средства обработки исключений -- все это находится в стандартных библиотеках
языка.
Программа состоит из логически независимых, но взаимодействующих между
собой структурных единиц, называемых модулями. Обычно один модуль
соответствует одному файлу исходного кода программы. Каждый из модулей может
взаимодействовать с другими с помощью механизмов экспорта (позволяющего ему
предоставлять свои ресурсы другим модулям) и импорта (позволяющего ему
использовать ресурсы, предоставленные другими модулями).
Любые внешние объекты модуля (например, глобальные переменные, функции,
типы данных и классы) могут быть экспортированы во внешнюю среду. Это
делается за счет помещения их в блок декларации экспорта, имеющей вид:
export { DECLARATION_LIST }
В модуле может быть много деклараций экспорта, но только на самом
верхнем (глобальном) уровне иерархии описаний. Все внешние объекты,
определенные в списке описаний DECLARATION_LIST, станут доступными другим
модулям. Чтобы получить к ним доступ, модуль должен воспользоваться
декларацией импорта, имеющей вид:
import MODULE { STMT_LIST }
В отличие от декларации экспорта, декларация импорта может быть
локальной: она может встретиться в любом блоке или, к примеру, в декларации
класса. Здесь MODULE -- это текстовая строка, задающая имя модуля. В более
общем случае, это имя импортируемого ресурса, который может быть глобальным
(общесистемным) или даже сетевым (синтаксис MODULE зависит от реализации и
здесь не рассмотрен). STMT_LIST -- произвольный список инструкций, в котором
будет доступно все, экспортированное ресурсом MODULE. В частности, он может
содержать другие декларации import, что позволяет импортировать описания из
нескольких модулей.
Точная семантика механизма импорта/экспорта -- слишком сложная тема,
чтобы рассматривать ее здесь в деталях. Если кратко, то передаче через этот
механизм могут подвергаться декларации переменных и функций, классов, все
определенные пользователем типы, макроопределения и операции. Заметим, что
каждый модуль фактически состоит из внешней (декларативной) и внутренней
(реализационной) частей. Для правильной компиляции всех импортеров этого
модуля требуется лишь знание первой из них; реализационная часть модуля (в
виде сгенерированного кода) остается приватной.
Наконец, существует специальное служебное средство для управления
процессом компиляции -- прагматы:
pragma PRAGMA_STR
Литеральная строка PRAGMA_STR содержит директивы компилятору, набор
которых также может сильно зависеть от реализации и пока определен очень
приблизительно. Предполагается, что прагматы будут задавать опции
компилятора, такие, как режимы кодогенерации, обработки предупреждений и
ошибок, вывода листинга и т.п.
Перспективы развития и нереализованные возможности языка
Ксерион -- язык пока еще очень молодой и весьма далекий от
совершенства. В процессе разработки языка у его создателей возникали самые
разные идеи относительно возможностей его дальнейшего развития -- как в
краткосрочной, так и в "стратегической" перспективе. На некоторых из этих
идей стоит остановиться подробнее.
Так, практически неизбежным представляется включение в язык
let-макроопределений с параметрами. Функционально они будут похожи на
параметризованные #define C-препроцессора -- но, в отличие от последних, они
будут, подобно opdef'ам, иметь строго типизованные параметры и аналогичную
семантику подстановки. Не исключено, что параметризованные макроопределения
будут даже допускать перегрузку и выбор одного из вариантов на основе типов
аргументов.
В более отдаленной перспективе, возможно, появится и столь мощный
макро-механизм, как шаблоны (template) для деклараций классов и функций,
подобные аналогичным средствам в C++. Однако, пока трудно уверенно сказать,
какой вид примет этот механизм в окончательной форме.
Сейчас в языке отсутствуют какие-либо формы инструкции выбора,
аналогичной switch/case в C и C++, но их отсутствие очень чувствуется.
Скорее всего, когда аналогичный механизм будет включен в язык, он будет
существенно более мощным. В частности, он будет допускать нелинейную логику
перебора и более сложные критерии проверки "случаев".
Безусловно, было бы очень полезным также введение в язык механизма
перечислимых типов (enum), подобного имеющимся и в Паскале, и в C.
На повестке дня стоят и более сложные вопросы. Должно ли в Ксерионе
быть реализовано множественное наследование, как в C++? Этот вопрос является
одним из самых спорных. Возможны разные варианты: полный запрет
множественного наследования (что вряд ли приемлимо), множественное
наследование только от специальных абстрактных классов-интерфейсов (такой
подход принят в Java), наследование только от неродственных
классов-родителей, и, наконец, наследование без каких-либо ограничений.
Есть достаточно много неясных вопросов, связанных с аспектами защиты
содержимого классов. В настоящей редакции языка принят намного более
либеральный подход к этому вопросу, чем в C++ и Java. Язык допускает
разнообразные механизмы инициализации экземпляра класса (экземпляром,
списком компонент, конструктором и, наконец, всегда доступна автоматическая
неявная инициализация). Как правило, объекты всегда инициализируются неким
"разумным" образом, однако может возникнуть потребность и в классах --
"черных ящиках", инициализация которых происходит исключительно через
посредство конструкторов. С самой семантикой конструкторов также есть
некоторые неясности.
Наконец, дискуссионным является вопрос о том, какие средства должны
быть встроены в язык, а какие -- реализованы в стандартных библиотеках.
Например, обработка исключений (а в будущем, возможно, и многопоточность)
планировалось реализовать как внешние библиотечные средства -- но против
такого подхода также есть серьезные возражения.
Впрочем, что бы не планировали разработчики -- окончательный выбор, как
мы надеемся, будет принадлежать самим пользователям языка.
Заключение
В заключение приведем небольшой, но вполне реалистичный пример
завершенного Ксерион-модуля, реализующего простейшие операции над
комплексными числами.
!!
!! Исходный файл: "complex.xrn"
!! Реализация класса `complex`:
!! комплексные числа (иммутабельные)
!!
!! внешние функции (в реальной программе импортируемые):
double (double x, y) #atan2; !! двухаргументный арктангенс
double (double x, y) #hypot; !! гипотенуза
double (double x) #sqrt; !! квадратный корень
class complex {
!! компоненты класса
double Re, Im; !! (real, imag)
!! [Унарные операции над %complex]
%complex (%complex op1) %opUnary;
%opUnary #conj; !! Сопряжение
%opUnary #neg; !! Отрицание
%opUnary #sqrt; !! Квадратный корень
!! [Бинарные операции над %complex]
%complex (%complex op1, op2) %opBinary;
%opBinary #add; !! Сложение
%opBinary #sub; !! Вычитание
%opBinary #mul; !! Умножение
%opBinary #div; !! Деление
!! Проверка на нуль
bool () is_zero { return Re -- 0f && Im -- 0f };
!! [Сравнения для %complex]
bool (%complex op1, op2) %opCompare;
!! (на равенство):
%opCompare eq { return op1.Re -- op2.Re && op1.Im -- op2.Im };
!! (на неравенство):
%opCompare ne { return op1.Re <> op2.Re || op1.Im <> op2.Im
};
!! Модуль
double (%complex op) mod { return hypot (op.Re, op.Im) };
!! Аргумент
double (%complex op) arg { return atan2 (op.Re, op.Im) };
};
!! Реализация предекларированных функций
!! Сопряженное для op1
#complex.conj { return #(op1.Re, - op1.Im) };
!! Отрицание op1
#complex.neg { return #(- op1.Re, - op1.Im) };
!! Сложение op1 и op2
#complex.add { return #(op1.Re + op2.Re, op1.Im + op2.Im) };
!! Вычитание op1 и op2
#complex.sub { return #(op1.Re - op2.Re, op1.Im - op2.Im) };
!! Произведение op1 и op2
#complex.mul {
return #(op1.Re * op2.Re - op1.Im * op2.Im,
op1.Im * op2.Re + op1.Re * op2.Im)
};
!! Частное op1 и op2
#complex.div {
!! (делитель должен быть ненулевой)
assert ~op2.is_zero ();
double denom = op2.Re * op2.Re + op2.Im * op2.Im;
return # ((op1.Re * op2.Re + op1.Im * op2.Im) / denom,
- (op1.Re * op2.Im + op2.Re * op1.Im) / denom)
};
let g_sqrt = sqrt; !! (глобальная функция `sqrt`)
!! Квадратный корень из op1 (одно из значений)
#complex.sqrt {
double norm = complex.mod (op1);
return #(g_sqrt ((norm + op1.Re) / 2f), g_sqrt ((norm - op1.Re) / 2f))
};
!!
!! Операции для работы с complex
!!
!! унарный '-' как отрицание
opdef -complex = complex.neg ((<1>));
!! унарный '~' как сопряжение
opdef ~complex = complex.conj ((<1>));
!! бинарный '+' как сложение
opdef complex + complex = complex.add ((<1>), (<2>));
!! бинарный '-' как вычитание
opdef complex - complex = complex.sub ((<1>), (<2>));
!! бинарный '*' как умножение
opdef complex * complex = complex.mul ((<1>), (<2>));
!! бинарный '/' как деление
opdef complex / complex = complex.div ((<1>), (<2>));
Популярность: 13, Last-modified: Sun, 08 Sep 2002 05:41:28 GmT