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