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