не
инициализирована явно, она инициализируется значением 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, списковая
инициализация является самым простым и удобным способом создания объекта.
Однако, часто нужны и кла