Страница:
понижает ранг массива: результат всегда имеет ту же размерность, что и
операнд. Отрезок длиной 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)
Оба вида спецификации можно комбинировать в одном вызове. Задание
аргумента без префикса означает, что он относится к следующему по порядку
параметру (к самому первому, если предшествующих не было). Наконец, элемент
списка аргументов может быть пустым, что означает пропуск соответствующего
операнд. Отрезок длиной 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)
Оба вида спецификации можно комбинировать в одном вызове. Задание
аргумента без префикса означает, что он относится к следующему по порядку
параметру (к самому первому, если предшествующих не было). Наконец, элемент
списка аргументов может быть пустым, что означает пропуск соответствующего