Ela. Разработка интерпретируемого языка программирования на .NET Framework

Автор: Воронков Василий Владимирович
Источник: RSDN Magazine #2-2010
Опубликовано: 23.12.2010
Версия текста: 1.0

Введение
Дизайн языка
Динамическая и статическая типизация
Объявлять или не объявлять переменные?
Область видимости
Синтаксис
Как жить без классов?
Функциональный или императивный
Алгебраические типы данных и динамическая типизация
Модули
Предварительные итоги
Разработка языка программирования
Основные компоненты
Парсер и AST
Компилятор и байт-код
Линкер
Виртуальная машина
Что же в итоге

Введение

В данной статье я расскажу о создании интерпретируемого функционального языка с использованием C# и .NET Framework. Язык при этом не относится к числу эзотерических и создавался с весьма практичным целями на уме. Он обладает понятным С-подобным синтаксисом, поддерживает программирование в императивном и функциональном стиле, а также имеет немало возможностей вроде генераторов, сопоставления с образцом (pattern matching, см. http://en.wikipedia.org/wiki/Pattern_matching), отложенных и асинхронных вычислений и многого другого. Побудило меня на создание этого языка вполне традиционная причина – а именно тот факт, что мной были обнаружены «фатальные недостатки» в других языках подобного плана.

Статью можно логически разделить на две части – в первой я расскажу о том, как формировался «дизайн-проект» языка, какие возможности мною было решено включить, а какие, наоборот, категорически исключить из языка, а во второй – я дам краткий обзор реализации языка и опишу основные хитрости и решения, сделанные при разработке основных его компонентов.

Замечу, что в данной статье я довольно много рассказываю о дизайне и разработке языка, однако о самом языке – довольно мало. Поэтому желательно – но, впрочем, совершенно необязательно – для начала прочитать статью «Краткий обзор языка Ela», которая доступна в электронном приложении к журналу и которая даст вам некоторое предварительное представление о том, что же представляет из себя Ela. Там же вы найдете и последнюю на текущий момент версию языка 0.7, включающую утилиту командной строки Ela Console, которую можно использовать как для запуска исполнимых файлов, так и в интерактивном режиме.

Но довольно. Честно говоря, я не очень люблю длинные введения – поэтому давайте перейдем сразу к сути, а именно, к дизайну языка.

Дизайн языка

Динамическая и статическая типизация

Ниже будет высказано исключительно мое субъективное мнение, – собственно, было бы странно высказывать чье-либо чужое, – поэтому прошу воспринимать это не как критику каких-либо известных концепций и популярных языков программирования, а скорее как набор предпосылок, которые, собственно, и привели к созданию Ela именно в том виде, в котором она существует на настоящий момент.

В споре приверженцев языков с динамической типизацией с теми, кто предпочитает типизацию статическую, я скорее отношусь к первой партии, несмотря на то, что основным моим языком программирования на настоящий момент является вполне себе статически-типизированный (по крайней мере до версии 4.0) C#.

Я люблю динамические языки.

Бесспорно, есть задачи и даже целые области, где их использование может быть весьма затруднительно – из-за динамической ли типизации или же из-за производительности, которой неизбежно приходится расплачиваться за динамику, – однако очень часто такие языки оказываются ничем не хуже того же C#, позволяют решить задачу меньшими силами и за меньшее время, причем итоговый результат ничем не уступает в стабильности, качестве и понятности кода аналогичным решениям на языках со статической типизацией, а часто и превосходит их.

В чем же секрет?

Дело в том, что в языках вроде C# компилятор хоть и позволяет выявить немалое количество ошибок еще на стадии компиляции, без необходимости запускать программу на выполнение и проводить тесты, однако огромное количество вещей попросту неизвестны компилятору, и даже когда вы совершаете операцию вроде доступа к элементу массива по индексу, никто не застрахует вас от исключения выхода за границы массива – и для того, чтобы убедиться, что ваша программа работает корректно, вам нужно писать тестовые сценарии и тестировать, тестировать и еще раз тестировать. А раз юнит-тестирование является необходимым в обоих случаях, то так ли страшен черт, как его малюют? По факту, в случае с динамически-типизируемыми языками, речь идет лишь о том, что просто некоторая часть ошибок не будет выявлена на стадии компиляции (причем это даже не означает, что компилятор такого языка совсем не сможет вам помочь выявить ошибки в вашем коде).

Таким образом, получается, что разница между статической и динамической типизацией вовсе не качественная, как это может показаться на первый взгляд, а всего лишь количественная – в одном случае компилятор сможет найти больше классов ошибок, чем в другом, однако все потенциальные ошибки не найдет даже самый умный компилятор на свете.

Однако тот факт, что в динамическом языке большая часть информации о типах недоступна на этапе компиляции, а следовательно, и нет возможности выявить ошибки в коде как можно раньше, сложно назвать преимуществом. Так что же такое нам дает динамика, что ради этого приходится жертвовать возможностью раннего обнаружения ошибок? Но прежде всего лучше ответить на другой вопрос – а откуда компиляторы таких статически-типизированных языков, как C#, берут ту самую информацию о типах, которая позволяет им отслеживать ошибки? Все просто – эту информацию вы явным образом указываете сами.

Просматривая написанный мной самим код на языке C#, я нередко замечал, что сопроводительная информация о типах данных, подсказки и указания компилятору о том, какой именно мы используем тип и как он описан, занимают больше половины всего проекта. Причем, повторюсь, есть множество случаев, когда без этой информации действительно не обойтись, когда по сути основной целью проекта является создание этакой пачки контрактов да спецификаций – на типы данных, протоколы сообщения и т.д., – однако есть и множество других случаев, когда подобная информация попросту избыточна и заставляет нас тратить больше времени на разработку.

Последнее время во многих популярных в коммерческой среде языках программирования стал появляться так называемый вывод типов – механизм, позволяющий компилятору как бы «догадываться» о том, какие именно типы данных мы используем в нашем коде, а соответственно, и избавляющий наш код от избыточных деталей. Однако вывод типов – это очень сложный и ресурсоемкий механизм, сильно замедляющий время компиляции (что, например, совершенно неприемлемо для интерпретируемых языков, где с точки зрения пользователя вообще никакой компиляции нет). Наконец, ни один из широко распространенных в индустрии языков вроде C++ со всеми его диалектами или C# не предоставляет действительно мощный механизм вывода типов, который позволил бы серьезно сократить количество сопроводительного кода. Вывод типов в том же C# 3.0 работает по довольно простой схеме и может угадывать типы лишь в самых простых сценариях, тогда как во всех остальных, которых зачастую большинство, вы по-прежнему аннотируете типы сами.

Наконец, вывод типов, даже такой мощный как в языках Haskell или Nemerle, которые индустрия пока еще не оценила до конца, не решает всех проблем. Да, компиляторы становятся умнее и теперь не только могут находить ошибки, но и сами определяют, с какими типами данных вы работаете. Однако все же искусственным интеллектом эти компиляторы не обладают и для того, чтобы угадать используемый тип, вам сначала необходимо этот тип где-либо описать. Вот и получается – что особенно характерно для объектно-ориентированных языков, – что сотни и даже тысячи файлов в ваших проектах содержат описания классов, интерфейсов, коллекций и прочая, и прочая. А ведь зачастую без этого можно обойтись. И вместо того, чтобы строчить описания сотен интерфейсов, сразу писать именно тот код, который нам нужен.

Наконец есть и еще одно важное преимущество динамических языков. Это полиморфизм. Ведь раз у нас нет обязательных аннотаций типов, и информация о типе становится доступна только на этапе исполнения, то мы с легкостью может описать, к примеру, функцию, которая может работать с любыми объектами – достаточно лишь того, чтобы эти объекты имели необходимые поля или методы. Это называется утиной типизацией. Подобный эффект можно достичь в С++ при помощи шаблонов, в C# же вам придется попотеть и явно описать все интерфейсы, которым должны удовлетворять ваши классы.

Итак, было уже немало сказано о преимуществах динамически-типизированных языков и совсем мало о недостатках. В качестве таковых часто приводят те особенности, которые в действительности присущи лишь некоторым динамическим языкам, но вовсе не характеризуют весь «жанр» в целом. Это будет легче пояснить с помощью примеров на всем известном языке JavaScript:

        var obj = { FirstName: "Basil", LastName: "Voronkov" };
var user = obj.Lastname; //досадная опечатка

Итак, я создал объект с двумя полями - FirstName и LastName, – однако, когда (положим несколько десятков строк кода спустя, возможно, в совсем другой функции) захотел получить значение поля LastName, то совершил досадную опечатку и набрал «name» с маленькой буквы. Что произошло в итоге? Код не только успешно «скомпилировался», но также и выполнился без ошибок. А значением переменной «user» оказалось загадочное «undefined».

Или другой пример:

        var displayUser = obj.LastName; //на сей раз уже все правильноif (displayUser == "") {
  displaUser = getUserLastName(); //а вот тут опять сделал опечатку
}

А сейчас я пропустил «y» в названии переменной «displayUser», и код опять успешно выполнился. Вместо того, чтобы присвоить значение переменной «displayUser», была автоматически объявлена (причем в глобальной области видимости!) переменная «displaUser», которая в действительности и получила требуемое нам значение.

Или вот такой пример:

        function Foo() { 

}

var foo = 12;
var sum = Foo * 2; //опять опечатка, Foo вместо foo

Из-за опечатки мы начинаем умножать функцию на число «2» – казалось бы, какой вообще смысл может быть в таком коде? – но даже и здесь мы не получаем ошибки. JavaScript честно пытается выполнить запрошенную арифметическую операцию, естественно, не может в результате такого умножения получить что-либо вменяемое, и в итоге просто присваивает переменной «sum» значение «NaN», что означает «значение не является числом» (с другой стороны, хорошо хоть, что он не «сосчитал» нам что-нибудь в данном случае).

Спорить не буду – поведение языка, показанное в этих примерах, жизнь программистам не упростит и может привести к весьма трудноуловимым ошибкам, которые не всегда получается найти даже во время юнит-тестирования. Однако же все эти особенности никакого отношения к собственно динамической типизации не имеют, это особенности конкретного языка под названием JavaScript.

Здесь мы подходим к другому водоразделу между языками, а именно – на слабо-типизированные и строго-типизированные языки. JavaScript (как, скажем, и PHP) – это слабо-типизированный язык, в чем легко убедиться на основе вышеприведенных примеров. А вот, например, Ruby – строго-типизированный (хотя он тоже подвержен некоторым из описанных здесь болячек).

Очевидно, что возможность обращаться к несуществующему полю объекта или же, скажем, умножать функции на целые вряд ли вам пригодится во время разработки, поэтому нет никакого смысла в том, чтобы считать подобный код корректным. Поэтому представим, как мог бы вести себя идеальный строго-типизированный язык в вышеуказанных случаях:

        var obj = { FirstName: "Basil", LastName: "Voronkov" };
var user = obj.Lastname; //ошибка, нет такого поля Lastname

Или же тут:

        var displayUser = obj.LastName;
if (displayUser == "") {
  displaUser = getUserLastName(); //ошибка, переменная displaUser не объявлена
}

Или тут:

        function Foo() { 

}

var foo = 12;
var sum = Foo * 2; //ошибка, мы не умеем умножать функции на целые числа

Как видите, опасность получить трудно уловимые ошибки теперь значительно снижается, и все эти досадные опечатки в большинстве случаев можно отследить при первом же тестировании.

Однако признаюсь, второй пример с опечаткой в названии переменной «displayUser» хоть и был приведен здесь для «красного словца», однако относится больше не к особенностям типизации языка, а к другой интересной проблеме, а именно...

Объявлять или не объявлять переменные?

В борьбе за лаконичность кода и избавление программиста от всяких ненужных формальностей, многие динамические языки не только отказываются от аннотаций типов, но и разрешают (а то и вовсе не имеют такой возможности) не объявлять используемые переменные явно. Многие программисты на VB.NET знают такую интересную опцию как Option Strict, отключение которой позволяет избавить нас от утомительного труда описывать объявление для каждой используемой переменной. А теперь представьте, что существуют языки, в которых этот самый Option Strict отключен «по умолчанию», более того, и включить его не представляется возможным.

Казалось бы, шаг вполне логичный. Раз уж у нас нет теперь необходимости описывать используемые типы данных – среда исполнения и так поймет, что у нас за типы, когда это будет нужно, – то зачем оставлять такой «анахронизм» статически-типизированных языков как объявления переменных?

Однако не все так просто.

Итак, попробуем подойти к данной проблеме прагматично. А что мы, собственно, выигрываем, отказавшись от явного объявления переменных? Сколько здесь ни думай – ответ будет один. Экономия ровно на одно ключевое слово, причем в случае таких языков как JavaScript, весьма короткое. Конечно, можно сказать, что избавление от этого ключевого слова является вполне последовательным шагом, что оно как бы синтаксически избыточно и попросту загрязняет код. Однако так ли это в действительности?

И тут настает время задать второй вопрос – а что мы выигрываем, если делаем объявления переменных строго обязательными перед их использованием? С технической точки зрения – немало. Ведь когда у нас нет объявлений переменных, компилятору придется потратить довольно много времени и, так сказать, умственной энергии для того, чтобы попросту посчитать, сколько всего переменных используется в нашей программе, какая у них область видимости и т.д. Можно, конечно, и эту функцию доверить среде исполнения – попалась переменная с именем, под которое не отведен специальный «слот», автоматически заводим этот «слот» и продолжаем выполнение программы дальше. Однако это неизбежно (и весьма плачевным образом – поверьте, я проверял) скажется на производительности, к тому же сделает невозможным ряд оптимизаций.

Далее, раз уж компилятору тяжело посчитать, сколько всего переменных в нашей программе, какого будет разбираться в объявлениях переменных программисту.И тут мы вплотную подходим к затронутому ранее вопросу – а именно, является ли явное объявление переменной избыточной частью языка. И теперь на него можно утвердительно и, так сказать, без толики сомнения ответить строгое и однозначное «нет».

Представьте, что у нас есть следующий код:

x = 2;

Что делает данный код? Изменяет значение переменной, объявленной где-то раньше? Вводит новую переменную? Если эта переменная уже объявлена раньше, и мы просто изменяем ее значение, то где именно она объявлена? Смотрим выше, тщательно сканируя строчки кода, натыкаемся на такое:

x = 0;

Ну наконец-то, нашли. Хотя погодите. А что именно делает данный код? Он действительно вводит новую переменную? А может, просто изменяет объявленную ранее? Как же это определить?

Думаю, идею вы поняли. Можно, конечно, сказать, что это пример надуманный – зачем нам, собственно, знать «объявляет» ли код переменную или «изменяет» эту переменную. Надо мыслить в новых категориях. Нет никаких «объявлений», явных или неявных. Есть просто переменная, называется «х». Значением этой переменной является целое число «2». Вот и все – больше нам ничего не нужно.

Однако так ли это в действительности?

Дело в том, что мы-то, конечно, можем делать вид, что никакой разницы между объявлением и изменением переменной нет, однако на самом-то деле у переменных бывает разная область видимости (о чем мы тоже подробно поговорим в следующем разделе), на самом деле, помимо того кода, который мы пишем прямо сейчас, есть вообще-то и код написанный до нас, который может полагаться на корректное значение тех или иных переменных, а отказавшись признавать необходимость разбираться в том, изменяет или же объявляет переменную какая-то конкретная инструкция, мы рискуем насоздавать кучу побочных эффектов и, написав одну работающую функцию, привести десять других в совершенно не работоспособное состояние.

А в итоге и получается, что для того, чтобы избежать подобного «конфликта имен» (о котором ни компилятор, ни среда исполнения нас предупредить не смогут), приходится вместо коротких и понятных названий переменных выдумывать что-то вроде «myFunction_сount» – чтобы не дай бог название не совпало с уже существующей переменной «count» и не дай бог мы бы не изменили какое-нибудь значение, которое нам изменять не следовало.

Ну и, спрашивается, где здесь экономия? Мы избавились от коротенького ключевого слова, усложнили жизнь компилятору, сделали ряд оптимизаций попросту невозможными, а также усложнили жизнь и программистам, заставив их восполнять отсутствие явного объявления переменных за счет длинных_мнемонических_идентификаторов.

Но даже это еще не все.

Ведь когда мы объявляем переменную явно, мы можем также указать дополнительную информацию об этой переменной, о которой в данном случае никакой компилятор догадаться уже не сможет. Например, мы можем указать, что переменная должна быть неизменяемой. Или что у переменной должна быть «частная» область видимости – т.е., что к ней нельзя обратиться из другого модуля.

Все эти возможности активно используются в Ela:

        var x = 0; //изменяемая переменнаяlet y = 0; //неизменяемая переменная, или константаletprivate z = 0; //частная переменная, невидимая снаружи модуля

Согласитесь, что когда есть столько доводов «за» и практически ни одного «против», то выбор правильного решения не заставляет себя долго ждать.

Итак, переменные нужно объявлять явно. Причем всегда. Не объявили переменную, допустили опечатку – получите ошибку, причем, несмотря на всю нашу динамическую типизацию, ошибку времени компиляции. Ведь компилятору теперь известно, какие переменные объявлены, а какие – нет, и он сможет отрапортовать нам о том, что мы пытаемся манипулировать с переменной, которая еще не объявлялась.

Область видимости

Область видимости – следующий на повестке дня вопрос, напрямую касающийся переменных. Различают два, так сказать, подхода к организации области видимости – динамический и лексический. Динамическая область видимости означает, что по сути у нас в программе есть одно глобальное пространство, в котором и «живут» все переменные. Как только «на сцену» выходит новая переменная, она тут же добавляется в это самое глобальное пространство, а соответственно, к переменной, объявленной где угодно, и обратиться можно откуда угодно.

В реальности языков с чистой динамической областью видимости не так много. JavaScript, которому часто приписывают такую черту характера, в действительности является языком с лексической областью видимости, который лишь ограниченно поддерживает динамическую область видимости.

Лексическая область видимости означает, что у нас в программе вводятся специальные лексические блоки (как, например, функции) и переменные, объявленные внутри таких лексических блоков, и видны, соответственно, только внутри этих блоков, а «снаружи» к ним достучаться никак нельзя. Классическим, если можно так выразиться, языком с лексической областью видимости является С. Причем в С лексическую область видимости задают не только функции, но также и другие конструкции языка как, например, условный оператор.

JavaScript же тут занимает некую промежуточную позицию – полностью заимствовав синтаксис из языка С, он в то же время нарушил десятилетиями связанную с этим синтаксисом семантику и сделал все по-своему. В итоге лексическую область задают только функцию, привычные же по языку С конструкции работают и ведут себя иначе, более того, благодаря неявным объявлениям переменных можно пользоваться и динамической областью видимости:

        function Foo() 
{
  if (x) {
    var y = 0; //Переменная y видна за пределами if
  }

  z = y; //а здесь мы неявно объявлем глобальную переменную z//а тут мы удаляем эту переменную – именно переменную, а не значениеdelete z; 
}

Вряд ли такой код будет понятен и удобен программисту, привыкшему к «настоящим» С-подобным языкам. Конечно, можно возразить, что не все программисты отличаются такими привычками, но зачем в таком случае было использовать «проверенный временем» С-подобный синтаксис, если язык ориентируется на совершенно другую аудиторию?

По этой причине Ela использует полноценную лексическую область видимости – на манер языков С и С++. Уточню, что тут есть некоторые отличия от того, к чему привыкли программисты на том же C#, где реализация области видимости (в частности – работа механизма затенения) переменных отличается от языков С и С++.

Затенение позволяет нам объявлять переменные, которые совпадают по имени с переменными в родительском блоке и как бы «затеняют» их. В C# данную возможность сочли вредной, однако из-за этого язык, на мой взгляд, ведет себя не всегда логично. К примеру, я никогда не понимал, почему данный код (аналогичный которому будет полностью корректен в С) приведет к ошибке компиляции:

{
  int x = 0;
} 

int x = 1;

Зачем нужна эта ошибка? От чего она нас предостерегает? Какой потенциальный вред у этого кода? Непонятно. В Ela, разумеется, код, аналогичный вышеприведенному, будет тоже полностью корректен.

Будет корректен и код, подобный этому:

        int x = 1;

{
  int x = 0;
} 

В текущий момент Ela допускает затенение как изменяемых, так и неизменяемых переменных, хотя я рассматриваю возможность добавить специальное предупреждение, которое будет генерироваться, если происходит перекрытие изменяемых переменных (как это сделано, к примеру, в Nemerle).

Синтаксис

Вопрос выбора синтаксиса мучил меня ни одну неделю, пока я наконец не пришел к простой и, казалось бы, очевидной идее – если не знаешь как сделать лучше, то оставь все как есть. «Как есть» в данном случае это так, как сделано в наиболее популярных на сегодняшний момент языках программирования – Java, C, C++ и C#. Да, это упоминавшийся выше С-подобный синтаксис, возраст которого уже исчисляется десятилетиями. С другой стороны, раз, несмотря на свои немолодые годы, он до сих пор пользуется немалой популярностью, не свидетельствует ли это о том, что данный синтаксис оказался весьма удобен большинству программистов?

Однако использовать С-подобный синтаксис не значит полностью дублировать все языковые конструкции из того же С или C#.

Прежде всего стоит сказать о том, что и в С++, и в Java, и в самом молодом из этой троицы C# используется принятое еще по языку С разделения всех конструкций языка на так называемые предложения (statements) и выражения (expressions). Весь код программы состоит из предложений, оформленных и законченных, прямо как в естественном языке, и с точкой в конце (вернее – с точкой и запятой). Выражения – это то, из чего состоят предложения. К тому же выражения всегда возвращают какое-либо значение. Казалось бы, все логично. Но проблема в том, что на практике подобное разделение весьма сильно сужает выразительность языка.

Во-первых, и разделение это само по себе не всегда легко сделать. К примеру, вызов функции – это выражение или предложение? В принципе может быть и так, и так. А присвоение переменной значения? В итоге получается, что у нас уже есть специальные выражения-предложения и даже наоборот – предложения-выражения, что несколько портит внешний вид этой концепции.

Во-вторых, а те оставшиеся предложения, которые никогда в роли выражений не выступают, по каким критериям были занесены именно в свою категорию? Ну, скажем, с объявлением переменной все понятно, а как быть с условным оператором? Ведь как удобно было бы писать на том же C# код вида:

        var x = 
  if (isAdmin())
    getSecretCode();
  else
    getPublicCode();

Впрочем, многим C# программистом такой код наверняка покажется непривычным. Зато будет привычным такой:

        var x = 
  isAdmin() ? getSecretCode() : getPublicCode();

Здесь используется тернарный оператор – единственный оператор языка C#, работающий сразу с тремя операндами. Отлично. А чем его поведение отличается от условного оператора (конструкции if/else), который нами занесен в категорию предложений, а не выражений? Только тем, что условный оператор if/else всегда возвращает void.

И сомнения в правомочности разделения на выражения и предложения становятся все сильнее. Не будем же мы дублировать одни и те же конструкции и там, и там только потому что они могут быть удобны в обоих качествах?

Итак, убираем это разделение. Теперь все – это выражение. Вся программа состоит из выражений (или вообще может состоять из одного-единственного выражения, например, «2 + 2»). Любая конструкция языка потенциально возвращает значение. Причем даже блок из фигурных скобок – это тоже выражение, который возвращает значение самого последнего выражения в нем. А это означает, что мы можем сделать оператор return необязательным – ведь и так понятно, что последнее выражение в теле функции будет возвращать значение этой самой функции:

        let fold(seq, init, fun) {
  var ret = init;

  for (e in seq)
    ret = fun(e, ret);
    
  ret
}

Само собой некоторые выражения не могут возвращать каких-либо «полезных» значений – например, императивные конструкции типа break, continue и пр., – и в таких случаях мы считаем, что они возвращают специальное значение типа unit (хорошо знакомое тем, кто программировал на функциональных языках, и больше известное программистам C# под псевдонимом void).

Так как выражения можно использовать в качестве предложений – т.е. когда возвращаемое ими значение никак не используется, – то мы будем считать, что если выражение возвращает значение типа unit, то все в порядке, и его можно смело игнорировать. Если же это какое-то другое, отличное от unit значение, то не грех вывести предупреждение при компиляции. Тем, кому доводилось программировать на таких языках как Nemerle или F#, должен быть знаком такой подход.

Итак, на мой взгляд, благодаря отказу от разделения на выражения и предложения, значительно усиливается выразительность языка. Более того, мы можем избавиться от «лишних» синтаксических конструкций, вроде того же тернарного оператора, так как они по сути не вносят в язык ничего нового. Однако вслед за признанием всех конструкций языка выражениями, следуют и другие, прямо вытекающие из них отклонения от канонического С-подобного синтаксиса.

Наверняка многим из вас знакома проблема – не решусь переводить на русский – так называемого «dangling else», за которую часто критикуют С сторонники синтаксиса, используемого в языках вроде Pyhon, где лексические блоки выделяются отступами, а не фигурными скобками. Собственно, вот пример, показывающий суть этой проблемы:

        if (isAdmin())
  if (isAdvancedMode())
    openAdvancedAdminConsole();
  else
    openAdminConsole();

Как должен выполняться следующий код? Если текущий пользователь является администратором, и консоль с расширенными возможностями доступна, то открыть ее, а в противном случае открыть обычную административную консоль? Вовсе нет. Просто-напросто форматирование кода вводит нас в заблуждение. На самом деле этот код выполняется так:

        if (isAdmin()) 
{
  if (isAdvancedMode())
    openAdvancedAdminConsole();
}
else 
{
  openAdminConsole();
}

Конечно же, данную проблему можно решить, как и в этом примере – попросту сделать фигурные скобки обязательными. Однако заставлять писать фигурные скобки даже в тех случаях, когда внутри if есть лишь одно выражение, несколько несправедливо. К тому же, учитывая, что и конструкция if сама по себе является выражением, представьте, насколько уродливо и тяжеловесно будет выглядеть код с ее использованием:

        var x = if (condition) { y } else { z };

Наконец есть и еще одна проблема. Какое значение должно возвращать следующее выражение:

        if (isAdmin())
  getSecretCode();

Иногда – значение, возвращаемое из функции getSecretCode. А иногда – не очень понятно, какое значение. Так как же быть?

В действительности и проблема с «dangling else» и со значением, которое возвращает условный оператор, решается очень просто – блок else делается обязательным. В итоге вы уже не напишите непонятный код, форматирование которого отличается от его фактической работы, как в первом примере, да и всегда найдется, что возвращать.

В тех же случаях когда все-таки требуется if без else можно использовать специальный оператор when, который ведет себя аналогично if, но при этом никогда не может иметь блока else. К тому же, так как нам заранее неизвестно, выполнится ли условие для when, то мы считаем, что when всегда возвращает значение типа unit.

Другая конструкция из языка С, которая претерпела заметные изменения, это цикл for. Прежде всего в Ela отсутствует отдельный оператор для организации перебора элементов в коллекциях (подобный foreach в C#). Зачем вводить новое ключевое слово, если синтаксис с for оказывается не менее выразительным:

        for (x in list) 
{
  cout x;
}

Далее, как вы видите, ключевые слова var и let можно опускать – в данном случае и так очевидно, что мы вводим новую переменную, поэтому вполне допустимо и немного подсократить синтаксис. По умолчанию такая переменная объявляется как изменяемая (т.е. запись выше полностью равносильна записи с использованием var).

Если же говорить о стандартном синтаксисе for, то его инкарнация в Ela скорее напоминает о языках вроде Basic или F#, чем аналогичную конструкцию в C:

        for (x to 10) 
{
  for (y = x downto 0) 
{
    cout y;
  }
}

Почему так было сделано? Классический цикл for в С-подобных языках позволяет, собственно, использовать любые выражения – для объявления переменной, проверки значения, изменения значения. Без досконального анализа компилятор попросту не знает, что, собственно, происходит в нашем цикле, а соответственно, и не может оттранслировать его в более эффективный байт-код. Но дело здесь не только в лености компилятора. Синтаксис циклов становится более строгим, а в итоге и сами циклы выглядят более наглядно, можно сказать, декларативно. Благодаря этому, циклы for-in поддерживают полноценное сопоставление с образцом, а циклы for-to и for-downto – условия (guards, http://en.wikipedia.org/wiki/Guard_%28computing%29). Например:

        for (x = 1 when x % 2 == 0 to 10) {
  cout x;
}

А это открывает нам и еще одну возможность – Ela поддерживает list и array comprehension (http://en.wikipedia.org/wiki/List_comprehension), не вводя при этом никакого дополнительного синтаксиса, как делают некоторые другие функциональные языки. Используется все тот же синтаксис циклов:

        let list = [ for (x = 1 when x % 2 == 0 to 10) x ];

А теперь представьте, как выглядела бы эта запись с использованием старого С-подобного for.

Претерпел изменения и синтаксис объявления функций. Базовый синтаксис функций в Ela напоминает синтаксис лямбд в C#, однако в качестве лямбда оператора используется более «правильная» математическая стрелочка:

        let f = x -> x + 1;
let f2 = () -> val;
let f3 = (x, y) -> x + y;

Такой синтаксис удобно использовать для объявления анонимных функций, однако если нам требуются функции не анонимные (как в вышеприведенном примере), то он может показаться несколько громоздким. Поэтому был введен альтернативный синтаксис по аналогии с языками из группы ML:

        let f(x) x + 1;
let f2() val;
let f3(x, y) x + y;

Как вы могли заметить, здесь даже опущены фигурные скобки, которые необязательны, так как наши функции состоят из одного выражения. Собственно, с этим связан последний момент, которого хотелось бы коснуться в связи с синтаксисом – а нужно ли нам столько точек с запятой и фигурных скобок?

Не будучи новичком в программировании, я нередко замечал, что забываю иногда вставить в C# точку с запятой (особенно после появления в нем модных нынче лямбд), исключительно потому что она мне кажется попросту избыточной.

Например, так ли нужна точка с запятой в следующем коде после вызова функции getSecretCode:

        var x = 
  if (isAdmin())
    getSecretCode();
  else
    getPublicCode();

Ведь если ее убрать, то код будет выглядеть, на мой взгляд, несколько чище. Аналогично в данном примере на C#:

Func<Int32,Int32> f = 
  x => { var p1 = Foo(x); Bar(x, p1); };

Некоторые знаки препинания мне здесь определенно кажутся излишними.

Поэтому можно взять за основу простое правило – если выражение является последним или единственным в блоке, то точка с запятой необязательна. Вы, конечно же, по-прежнему можете ее указать, если вам так больше нравится.

Аналогично и с фигурными скобками. По аналогии с конструкциями вроде if/else или for, где и в самом С фигурные скобки являются необязательными, если данные конструкции содержат лишь одно выражение, вы можете, скажем, опускать фигурные скобки при объявлении функций, и в конструкциях вроде try/catch (которая, кстати говоря, также является выражением и может возвращать либо значение из блока try, либо значение из блока catch).

Как жить без классов?

Во второй и, надеюсь, последний раз повторюсь – моей целью не является критика каких-либо существующих языков программирования (к примеру, таких как Python или Ruby), я просто объясняю, исходя из каких позиций разрабатывался дизайн языка.

Кстати, по поводу Python и Ruby.

Являясь динамическими интерпретируемыми языками, эти двое также поддерживают и полноценное ООП «с классами». В Ela же никаких классов нет да и не предвидится. Почему так?

Начнем издалека. Что такое, собственно, «класс»? В объектно-ориентированном языке класс – это своего рода контракт, обязательство, которое должны соблюдать все его экземпляры. В языках вроде C# переменные являются, если можно так выразиться, типизированными, т.е. информация о типе связывается с самой переменной и, таким образом, благодаря наличию пачки старательно описанных контрактов, компилятор знает, какие операции можно совершать над той или иной переменной. Контракты важны, так как зачастую сущности с разным поведением могут реализовывать один и тот же контракт, т.е., благодаря контракту, мы можем как бы абстрагироваться от деталей реализации и писать более обобщенный код. Также, с помощью таких механизмов как наследование и виртуальные методы, мы можем расширять поведение уже имеющихся сущностей, совершенно не трогая их контракт, а как следствие и не меняя код, который работает с этими сущностями.

Казалось бы, все это очень полезные возможности и, сомневаюсь, что тот же C# пользовался бы популярностью, если бы поддерживал только процедурный стиль программирования. Зачем же отказываться от этого?

Но давайте вспомним о тех особенностях динамических языков, о которых была речь ранее. Тип на этапе компиляции нам неизвестен, переменные у нас нетипизированные – это просто имена, которые связываются с какими-либо значениями. А тип этих значений становится известен лишь во время исполнения кода – собственно, именно в тот момент, когда над этими значениями необходимо совершить те или иные операции. Что это все означает? А то, что в динамических языках (даже в таких как Python и Ruby) правит бал утиная типизация. Более того, мы совершаем операции не над типами данных, как в статически типизированных языках, а лишь над именами, а какие там за этими именами кроются значения и типы мы не узнаем, пока не попробуем что-либо с ними сделать. Вы, например, можете объявить переменную «х» и присвоить ей целочисленное значение, а потом попробовать вызвать ее как функцию, и с точки зрения компилятора это будет совершенно корректный код, который приведет к ошибке лишь во время исполнения.

Зачем же нам в таком случае контракты?

Ведь контракты в динамическом языке теряют свою основную функцию – они перестают быть тем лекалом, с помощью которого мы работаем с объектами. Объявляете ли вы классы или не объявляете – в языке по-прежнему используется утиная типизация, которая всегда идет под руку с типизацией динамической.

Потеряв свою роль в качестве контрактов, классы становятся некими конструкторами для объектов – т.е. функциями особого вида, с альтернативным синтаксисом, которые на входе принимают энное количество аргументов (как бы параметры конструктора класса), а на выходе возвращают объект с тем или иным количеством проинициализированных полей. Так почему бы в таком случае не называть вещи своими именами? Конструкторы так конструкторы. Зачем имитировать наследование, виртуальные вызовы, когда и то и другое в языке с динамической типизацией сводится лишь к тому, чтобы создать еще один объект, структура которого совпадает с уже созданным. И совершенно справедливо считать, что это, так сказать, «экземпляры одно и того же класса».

Другой подход исповедуется в JavaScript. JavaScript – язык «без классов», однако поддерживающий ООП, вернее, определенный его подвид, ООП прототипное. В сущности, прототипное ООП означает, что мы, отказавшись от классов как «образцов» для наших объектов, создаем новые объекты путем клонирования существующих. Однако в таком минималистичном виде (который иногда называют concatenative prototype based OOP) поддержку прототипного ООП можно добавить в язык, реализовав одну-единственную библиотечную функцию – clone. Но в JavaScript используется немного другой подход – прототипное ООП через делегацию. Вместо того, чтобы полностью клонировать существующие объекты, что может, очевидно, привести к перерасходу памяти, когда под одни и те же данные память выделяется несколько раз, каждый новый, созданный через такое клонирование объект, фактически хранит в себе указатель на своего родителя и не копирует данные родителя до тех пор, пока это не потребуется.

Фактическая реализация этого механизма очень проста. Представим, что у нас есть объект, имеющий два поля – название продукта и его цена. Склонировав этот объект и попробовав прочитать то же название продукта у клона, мы фактически обратимся к первоначальному экземпляру этого объекта. А если мы поменяем цену продукта у родителя нашего клона, то, соответственно, цена поменяется и у самого клона – ведь никакой информации о цене он сам не хранит, а только делегирует вызов к родителю. А вот если мы попробуем поменять цену или название продукта уже у самого клона, то в этот момент в объекте-клоне будет выделено специальное «место» под это поле (его называют «слот»), и теперь уже изменения цены через объект-родитель на клона никак влиять не будут. Такое поведение, как вы сами понимаете, с помощью библиотечной функции, никак не меняя «ядро» языка, реализовать уже затруднительно.

Прототипное ООП в JavaScript мне нравится и не нравится одновременно. Дело в этом, что этот механизм, достаточно бесхитростный в реализации, дает программисту очень большие возможности – особенно, если язык позволяет менять прототипы объектов «на лету» – но в то же время получается, что абсолютно вся логика нашего приложения, если оно целиком полагается на эту парадигму, состоит из сложно отслеживаемых побочных эффектов, причем «распространяются» эти побочные эффекты примерно по той же модели, по которой происходит ядерная реакция.

Учитывая, что наиболее приоритетной парадигмой для Ela была функциональная, у меня возникли вполне закономерные, как мне кажется, сомнения в том, что симбиоз ФП и прототипного ООП получится удачным. Вы вот видели, к примеру, чтобы код на JavaScript писали, одновременно используя «на полную катушку» приемы функционального и прототипного программирования? Я – нет. Да и представить подобное мне сложновато. И получается, что язык по сути предлагает два взаимоисключающих стиля. Мне же не хотелось прийти в итоге к такой же ситуации. А учитывая, что даже в JavaScript большинство программистов «прототипному» стилю предпочитают все-таки функциональный, прототипное ООП в Ela в итоге не было включено. Зато были включены конструкторы объектов и полиморфные варианты, о чем расскажу далее.

Однако тут может возникнуть закономерный вопрос – а почему именно функциональный? Вот, к примеру, императивный стиль никак не мешают «прототипному». Почему все-таки именно функциональная парадигма стала приоритетной?

Функциональный или императивный

А что такое вообще «функциональные языки»? Пожалуй, однозначное и исчерпывающее определение здесь придумать сложно. Да, бесспорно, это языки, в которых, полностью оправдывая название парадигмы, функции играют очень-очень большую роль. А что за критерии еще могут быть для выявления, какие именно языки являются функциональными? Функции как первоклассные объекты должны быть в таких языках? Несомненно. Сопоставление с образцом и алгебраические типы? Да вроде как необязательно. Является ли Lisp функциональным языком? Наверняка вы не будете долго думать, прежде чем ответить на этот вопрос. А Python? Ведь по количеству «функциональных возможностей» разница между ними не такая уж и большая.

Осмелюсь все-таки предложить свое определение – или, лучше сказать, свое понимание – того, что все же из себя представляет функциональный язык. Помимо того, что функции очень важны, являются первоклассными объектами, и над ними можно совершать операции так же, как и над другими значениями, функциональный язык – это такой, в котором наиболее выразительным является код, записанный в декларативном ключе, т.е. когда мы показываем, что нам нужно сделать, а не инструктируем компилятор, как именно нужно добиться необходимого нам результата, как это делается при императивном программировании. А соответственно, все языковые возможности, которые повышают или упрощают декларативную запись, тоже очень важны. Ни одна из них (вроде того же сопоставления с образцом) не является строго необходимой, однако их практически полное отсутствие как раз и позволяет сделать вывод, что несмотря на все первоклассные функции, язык все же к функциональным отнести сложно (поэтому прощай Python, здравствуй Lisp). Нет, нет, я опять же не хочу с кем-то спорить, но согласитесь, что вряд ли кому-то нужен функциональный язык, на котором писать в декларативном ключе менее удобно, чем в императивном. Вернее, вряд ли такой язык можно с чистой совестью считать функциональным.

Но тут резонно спросить – а в чем преимущество декларативной записи перед императивной? А преимущества простые – код получается короче (его можно быстрее написать) и более понятным (ведь мы же описываем что именно нам нужно получить, а не кодируем последовательность действий, в которой читающему наш код человеку придется долго разбираться). Сдается мне, что не в последнюю очередь по этим причинам функциональное программирование сейчас и становится все более популярным.

Разумеется, в большом мире, где на первых местах в рейтингах до сих пор стоят С и С++, где важна скорость исполнения кода, функциональное программирование не всегда оказывается приемлемым. Ведь как только мы перестаем описывать точную цепочку действий, компилятору приходится «додумывать» эту цепочку самостоятельно, в результате чего неизбежно страдает скорость исполнения кода. Однако справедливости ради стоит сказать, что в современных бизнес-приложениях скорость кода, которую обеспечивают функциональные языки вроде Ocaml, F#, Nemerle, является более чем достаточной. Наконец, когда речь идет о скриптовых языках и интерпретаторах, то ситуация вообще зачастую переворачивается с ног на голову.

Во-первых, раз уж вы выбрали такой язык, то это означает, что скорость исполнения кода для вас уже не является главным приоритетом. Во-вторых, так как у нас есть собственная виртуальная машина, а компилятор имеет весьма ограниченное время на свою работу, так как компиляции по сути происходит в период исполнения, сгенерировать эффективный байт-код для декларативных конструкций зачастую оказывается куда проще, чем для императивных по той причине, что декларативные конструкции не только понятнее самому программисту, но еще и куда более легки для машинного анализа.

Получается, что для интерпретируемого языка поддержка функционального, декларативного программирования дает одни лишь плюсы и ни одного минуса. Разумеется, функциональный стиль вовсе не исключает императивного, поэтому вопрос, вынесенный в заголовок данного раздела, не представляет собой, так сказать, строгую дизъюнкцию. Более того, практически все – за исключением совсем уж экзотических – функциональные языки поддерживают также и императивное программирование, ведь бывают задачи, когда все-таки именно императивный стиль оказывается самым удобным для программиста. Поэтому императивный код на Ela будет выглядеть практически так же, как на каком-нибудь C# (с учетом некоторых синтаксических отличий, о которых я рассказал ранее), т.е. будет привычен и понятен программистам, до этого ни о каких функциональных языках не слышавшим. Но при этом у них появятся и новые возможности – настоящие первоклассные функции, которые можно передавать в качестве параметров в другие функции, объединять в композиции, каррировать и пр., сопоставление с образцом, алгебраические типы данных. Вернее, не совсем алгебраические типы данных.

Алгебраические типы данных и динамическая типизация

Если вы не знаете, что такое алгебраический тип, то желательно для начала почитать об этом какие-нибудь соответствующие материалы. Объяснять подробно мне, увы, не позволяет формат статьи. Скажу лишь, что с точки зрения ООП алгебраический тип можно представить как одноуровневую иерархию классов. Представим, к примеру, что у нас есть базовый класс Product и три его прямых наследника – Cellphone, Laptop и, скажем, Printer. Вот три продукта, которые продает наша воображаемая компания. Причем у этой компании есть такая особенность – ввиду определенных никому не известных причин она умеет продавать только телефоны, ноутбуки и принтеры. И ничего более. Даже смартфоны не умеет. Или обычные настольные компьютеры. Вот для такой ситуации алгебраический тип очень даже подойдет. С помощью алгебраического типа вы сможете описать всю продукцию нашей компании с помощью одной-единственной строчки:

        type Product = Cellphone | Laptop | Printer
let x = Cellphone //создаем экземпляр продукта типа Cellphone

Как вы уже догадываетесь, наша компания имеет ограничения на ассортимент продукции не спроста, а потому что раз описанный алгебраический тип – в отличие от объектно-ориентированных иерархий – расширить уже никак нельзя. Т.е. вы, конечно, можете дописать после «Printer» новый вид продукции, однако ваш тип изменится, и вам придется поменять также и весь код, который с ним работает.

Данная особенность алгебраических типов хорошо известна, и в ряде языков с их ограничениями даже пытались бороться – мы еще коснемся этого в дальнейшем. Пока же логичней ответить на другой вопрос – ведь если алгебраический тип это своего рода закрытая одноуровневая иерархия классов, а от классов мы отказались, решив, что они не нужны в языке с динамической типизацией, то зачем мы вновь возвращаемся к этому же вопросу, но лишь под другим «заголовком»?

А все дело в том, что в Ela поддерживается так называемое сопоставление с образцом – очень мощный механизм, как раз позволяющий значительно повысить декларативность кода. Что такое сопоставление с образцом, я тоже объяснять детально не буду, скажу лишь, что в отличие от конструкций вроде switch/case или цепочки условных операторов, сопоставление с образцом используется не столько для сравнения выражений с какими-либо значениями, сколько для разбора, или деконструкции, этих выражений на составляющие. Например:

        let x = match (expr)
  on [x, 2, y] -> x + y  
  on _ -> 0;

В данном случае мы разбираем выражение «expr» и, если данное выражение является списком из трех элементов, причем второй элемент равен целому числу «2», то складываем первый и последние элементы и возвращаем результат, в противном случае – возвращаем «0». Согласитесь, что запись получается гораздо более лаконичной, чем если бы мы написали то же самое в императивном ключе.

Возможность, бесспорно замечательная. Однако причем здесь алгебраические типы?

Правда в том, что сопоставление с образцом, так хорошо соответствующее идеологии функционального программирования, пришло из языков, где оно неразрывно сосуществовало с этими самыми алгебраическими типами. Более того, зачастую единственным способом разбора алгебраического типа было именно сопоставление с образцом. Неудивительно, что алгебраические типы так удобно анализировать с помощью конструкций сопоставления с образцом а, соответственно, и выражать те или иные структуры данных через алгебраические типы.

Предположим, что вы хотите представить на языке Ela все ту же иерархию продуктов компании, причем у каждого продукта есть еще и параметр «цена», и вам нужно написать функцию, которая вычисляет итоговую цену на основе каких-нибудь сезонных скидок, которые отличаются в зависимости от типа выбранного продукта. Вот как это могло бы выглядеть с алгебраическими типами:

        let price = match (product)
  on Cellphone(x) -> x – 5.25
  on Laptop(x) -> x – 29.99
  on Printer(x) -> x – 18.24;

(Само собой не стоит воспринимать данный пример всерьез и так же указывать скидки прямо в своем коде, это просто пример, не более того).

Если записать нечто аналогичное без алгебраических типов, то даже с использованием сопоставления с образцом код получится куда более запутанным. Вообще полезность алгебраических типов сложно переоценить. Собственно, даже Nullable-типы в .NET созданы как раз под влиянием алгебраических типов, где обычно используется подобная структура:

        type Option = Some(x) | None

К примеру, есть у вас функция, которая может вернуть значение, а может и не вернуть (по аналогии с методами вроде TryGetValue из .NET Framework). Вместо того, чтобы передавать дополнительные булевы флажки или, не дай бог, генерировать исключения, куда удобнее возвращать экземпляр алгебраического (или вариантного, как их еще называют) типа данных Option. Есть значение – возвращаем Some(x), где «х» – это, собственно, само значение, нет значения – возвращаем None.

Бесспорно, такие структуры данных оказались бы весьма полезны в языке и, признаюсь, первым моим порывом было реализовать вполне классические вариантные типы, однако потом я все же понял, что конструкция, подобная вышеприведенному описанию Option, в Ela полностью лишена смысла. Причем по причине прямо противоположной той, которая не дает возможности «расширять» существующие объявления алгебраических типов в классических функциональных языках.

Предположим, что у нас есть два таких вот объявления:

        type Option = Some(x) | None
type Fake = Foo(x) | Bar

В чем здесь проблема? А проблема в том, что язык просто не увидит между ними никакой разницы. Ведь у нас же нет никаких типов, нет никаких Option и Fake, они в данном случае выступают как своего рода пространства имен для конструкторов, а то, что получается в результате вызова такого конструктора уже не несет в себе никаких следов «изначального» типа. Говоря проще, продукции Foo и Bar можно рассматривать как расширение типа Option, к которому теперь добавляются два новых конструктора, те самые Foo и Bar. Конечно, мы можем попробовать «сымитировать» статическую типизацию, с которой обычно связаны алгебраические типы, однако это будет именно что имитация, не слишком хорошо сочетающаяся с остальным дизайном языка.

Что же делать?

Но подождите – разве все так плохо? Ведь проблемой в нашем случае является то, что алгебраические типы являются всегда «открытыми» (тогда как в других языках как раз, напротив, зачастую пытаются бороться с «закрытостью» алгебраических типов и ищут способы, какими можно было бы расширить вариантные типы данных с помощью новых продукций, не меняя при этом существующий код).

К примеру, в языке OCaml есть такая концепция как полиморфные варианты. Они во всем похожи на обычные варианты, кроме одной детали – их не нужно предварительно объявлять (при этом заметим, что OCaml статически и очень строго типизированный язык, в котором даже для вещественных и целых чисел используются разные операторы). Говоря другими словами, мы считаем, что в нашей программе есть один-единственный алгебраический тип, и конструкторы, они же продукции, этого типа объявляются по первому месту использования.

В OCaml для того, чтобы отличать полиморфные варианты от обычных, название конструкторов у первых пишется, начиная с апострофа. Я решил заимствовать этот синтаксис, чтобы была синтаксическая разница между вызовом функции и конструктора:

        let product = `Cellphone(40);
let price = match (product)
  on `Cellphone(x) -> x – 5.25
  on `Laptop(x) -> x – 29.99
  on `Printer(x) -> x – 18.24;

Здесь приводится полный код программы. Как видите, никаких объявлений типов не требуется. Компилятор сам распознает, нужно ли генерировать новый конструктор или же можно использовать уже существующий.

Конечно, у полиморфных вариантов есть и свои недостатки. В частности, они являются слабо-типизированными, и никто не застрахует вас от опечаток вроде:

        let productA = `Cellphone(40);
let productB = `Celphone(20); //пропустил одно 'l'

Однако подобная проблема присутствует и в прародителе этой концепции OCaml, при этом OCaml, повторюсь, является статически-типизированным языком. В Ela же, благодаря ее динамической природе, слабая типизация полиморфных вариантов воспринимается не так остро. При этом они органично вписываются в дизайн языка.

Фактически в Ela нет такого специального типа данных как полиморфный вариант. Есть просто кортежи (иначе – индексированные массивы, доступные только для чтения) и записи (которые уместнее всего сравнить с анонимными типами в C#). И те, и другие могут быть созданы как с помощью обычной литеральной записи, так и в виде полиморфных вариантов. Во втором случае они несут в себе специальную «метку» (tag), информацию о создавшем их конструкторе, которую можно использовать во время сопоставления с образцом. В остальном же это обычные кортежи и записи.

Вы спросите – каким именно образом компилятор определяет, что нужно создавать в конкретном случае – массив или кортеж. Все просто. Помимо автоматически генерируемых конструкторов, Ela также поддерживает и пользовательские конструкторы. Их синтаксис очень похож на синтаксис объявления функций, только название всегда начинается с апострофа:

        let `Cellphone(price) 
  (
    Price: price,
    Model: …
  );

Пользовательские конструкторы могут «затенять» автоматически сгенерированные. Вы также имеете возможность поместить любую логику инициализации в такой конструктор и вернуть из него как кортеж, так и запись. А вот автоматически генерируемые конструкторы умеют создавать только кортежи.

Модули

Очевидно, что весь код приложения в один файл уместить проблематично. Вернее, конечно, можно при должной сноровке, но результат будет не самым впечатляющим (а если вы еще пользуетесь системой контроля версий, то коллеги вас точно по голове не погладят). Наиболее прямолинейным решением данной проблемы была бы поддержка исполнения кода, содержащегося в нескольких файлах, но при этом файлы фактически бы объединялись в один большой поток, и с точки зрения интерпретатора никакой бы разницы не было – сотню файлов вы ему скормили или же всего один.

Но как обычно самое простое решение оказывается далеко не самым лучшим.

Во-первых, производительность. Ваше приложение может состоять из сотен и даже тысяч исполнимых файлов, при этом далеко не весь код может быть задействован при каждом запуске. Однако вам в любом случае придется компилировать сразу все файлы проекта, причем с точки зрения пользователя происходить это будет в процессе исполнения, что явно заметно повлияет на скорость и приведет к ощутимым задержкам. Вам также будет затруднительно кэшировать уже скомпилированные ранее файлы, которые не изменялись.

Во-вторых, вы получите серьезную проблему с зависимостями. Ведь с позиции компилятора код в тысяче файлов равносилен коду в одном-единственном огромном файле, соответственно, распределенные по разным файлам участки кода могут иметь сильные зависимости друг от друга. Представьте, к примеру, что в файле под номером 538 вы будете читать значение глобальной переменной, объявленной в файле 122.

Конечно же, с такими проблемами можно бороться – как это делают в том же JavaScript, имитируя модули на замыканиях, – однако зачем нужно что-то имитировать, когда можно сразу реализовать полноценную поддержку раздельной компиляции?

Подход к организации модулей в Ela неоригинален – можно сказать, использует старые проверенные решения, – и похож на тот, что реализован, скажем, в Python. Каждый исполнимый файл в Ela является модулем, причем названием модуля как правило является название файла, и другого способа объявить модуль в языке нет. Трансляция каждого модуля является независимой, т.е. для компиляции любого отдельно взятого файла вам никакие другие файлы не нужны, даже если они используются внутри компилируемого вами.

Зачем это сделано и как это работает?

В Ela, как и в Python, считается, что модуль, доступный (если вообще доступный) на этапе компиляции необязательно должен быть именно тем модулем, который будет доступен на этапе исполнения. Говоря другими словами, в Ela происходит позднее связывание с модулями. Благодаря данной особенности каждый файл в Ela является независимой единицей трансляции, что значительно упрощает такие вещи как кэширование скомпилированных модулей с целью предотвратить их повторную компиляцию в том случае, если никаких изменений внутри модуля нет.

Предварительные итоги

Итак, язык будет интерпретируемым.Типизация – динамическая. Чего греха таить – основное предназначение языка не написание больших корпоративных систем, а создание скриптов, встраивание в приложения, возможно, веб-разработка. Во всех этих случаях динамическая типизация, позволяющая «вытрясти» из кода ненужные аннотации типов, является только плюсом.

Синтаксис используем С-подобный – самый, пожалуй, распространенный среди языков программирования. Популизм? Возможно. Зато большинству будет привычно и удобно. Однако использование С-подобного синтаксиса не означает, что мы будем слепо копировать все конструкции, в некоторых случаях можно и отойти немного от «эталона» данного синтаксиса, языка С. Все же лет этому языку уже немало. Однако, что мы точно возьмем из С – так это полноценную лексическую область видимости. Да и обязательные объявления переменных.

Объектно-ориентированное программирование не поддерживаем. На свете и так хватает объектно-ориентированных языков (взять хотя бы Ruby с Python-ом). Зато поддерживаем функциональное, позволяющее писать код в декларативном ключе, максимально лаконично и наглядно. А вместе с функциональной парадигмой добавляем в язык сопоставление с образцом и полиморфные варианты.

Вроде ничего не забыл.

В следующей части статьи я расскажу уже непосредственно о том, как разрабатывался язык программирования Ela – опишу каждый из трех его компонент, включая LL(1) парсер, компилятор в промежуточный байт-код, виртуальную машину, реализованную в виду стек-машины, а также линкер, который используется для связывания нескольких модулей на языке Ela в единую программу. Я постараюсь объяснить основные решения, которые были приняты мной при дизайне этих компонент, рассказать о том, какие возникали трудности при реализации и как я пытался их решать. Ну и в заключении статьи мы попробуем оценить то, что в итоге получилось и сравним производительность Ela с каким-нибудь популярным интерпретируемым языком (как, например, Python или Ruby).

Разработка языка программирования

Основные компоненты

Реализация языка Ela представляет собой интерпретатор компилирующего типа, т.е. технически текущая реализация имеет явно выделенную стадию компиляции, во время которой формируется промежуточный байт-код (Ela Intermediate Language), который, в свою очередь, исполняется на специально разработанной для Ela виртуальной машине.

В целом реализацию языка можно разбить на четыре основных компонента:

В данном разделе я подробно опишу каждый из этих компонентов – какие решения были использованы, с каким пришлось столкнуться трудностями и так далее.

Парсер и AST

Парсер – одна из самых сложных частей любого языка программирования. На написание и отладку парсера может уйти огромное количество времени – особенно, в том случае, когда не все решения по дизайну языка являются окончательными, и многое, включая синтаксис, может подвергаться переработке.

Для упрощения создания парсеров существует целый вид программ – генераторов парсеров. Общий принцип их работы достаточно прост. Как правило, есть некий специальный язык (DSL), с помощью которого вы должны описать грамматику своего языка. Данный DSL может соответствовать, к примеру, EBNF нотации. И на основе описанной вами грамматики генерируется исходный код парсера – например, на языке C#. Более того, генератор позволяет отследить потенциальные проблемы в вашей грамматике – такие, как возможные неоднозначности, которые в противном случае пришлось бы искать самостоятельно.

Существуют разные типы парсеров – например, LL-анализаторы, которые разбирают входной поток слева направо и имеют возможность «заглянуть» вперед на определенное количество токенов. Самый простой вариант LL-парсера – это LL(1) парсер, позволяющий заглядывать вперед лишь на один-единственный токен. (К примеру, в С-подобной грамматике с помощью такого заглядывания мы можем отличить унарный плюс от операции пре-инкремента). Соответственно, и грамматика, которую вы пишите для генератора, должна быть совместима с тем парсером, который умеет создавать ваш генератор. Вот как может выглядеть простейшая грамматика:

COMPILER TestLanguage
  CHARACTERS
    digit = "0123456789".
  TOKENS
    intToken = digit { digit }.
  PRODUCTIONS
    IfExpr = "if" "(" Expr ")" [ "else" Expr ].
    Expr = intToken | IfExpr.
END TestLanguage.

Здесь мы описали язык, в котором есть только целые числа и одно-единственное выражение if, имеющее опциональный блок else. В начальной части нашей грамматике мы описываем классы символов, затем – токены, за обработку которых отвечает сканер, и наконец так называемые продукции, т.е. фактически конструкции вашего языка.

В Ela для создания парсера я использовал Coco/R. Причина для такого выбора очень проста. Я уже несколько лет подряд вполне успешно использовал Coco/R на различных проектах – он весьма стабилен, прост в использовании и генерирует довольно быстрый код на C#.

Coco/R генерирует парсер и сканер, отвечающий за токенизацию, причем позволяет использовать рукописный сканер вместо автоматически генерируемого. Ela на текущий момент довольствуется тем, который генерирует сам Coco/R.

Одним из недостатков Coco/R можно считать то, что в сущности он умеет создавать лишь LL(1) парсеры (см. http://ru.wikipedia.org/wiki/LL). Для того, чтобы обойти эту проблему, в Coco/R добавили специальную конструкцию для разрешения конфликтов (IF), которая, за счет неограниченного ручного заглядывания вперед, позволяет создавать парсеры, распознающие грамматики более сложные нежели LL(1) (возможность эта крайне недоработана, так как не согласована с табличным конечным автоматом; это приводит к большим проблемам при разработке языка, грамматика которого выходит за рамки LL(1) – прим.ред.). Однако, учитывая то, что грамматика Ela на текущий момент является LL(1) совместимой, я не буду останавливаться на этом подробно.

Еще одна потенциальная сложность при создании парсеров с помощью Coco/R заключается в том, что Coco/R не позволяет декларативно задать приоритеты операторов – хотя я бы сказал, что это является, скорее, особенностью генерируемого парсера, с которой вполне можно справиться, просто структурировав особым образом продукции.

Другим недостатком Coco/R является то, что грамматика для него описывается в виде единого файла, в котором собственно описание синтаксиса смешивается с семантическими действиями (т.е. с тем, что необходимо выполнить, когда парсер находит ту или иную конструкцию). Вот как, к примеру, выглядит (с небольшими упрощениями) реальная продукция для условного оператора в языке Ela:

IfExpr<out ElaExpression exp> = 
  "if" 
  (. 
    var cond = new ElaCondition(); 
    var cexp = default(ElaExpression);  
    exp = cond;
  .)
  "(" Expr<out cexp> (. cond.Condition = cexp; .) ")"    
  Expr<out cexp> (. cond.True = cexp; .) 
  "else" Expr<out cexp> (. cond.False = cexp; .).

То, что вы видите между скобок «(.» и «.)», это и есть семантические действия – в данном случае, создание соответствующего объекта AST и установка его параметров.

Наконец, последний момент, который можно отнести к числу минусов Coco/R, это то, что данный генератор хотя и поможет вам при создании парсера и сканера, однако сгенерировать для вас AST не сможет, поэтому объектную модель для синтаксического дерева вам придется описывать самостоятельно.

Почему же, несмотря на все вышеперечисленные недостатки, для реализации парсера был все же выбран именно Coco/R.

Во-первых, как я уже упоминал, грамматика Ela легко описывается как LL(1) грамматика. Более того, если в будущем выразительности такой грамматики будет не хватать, то Coco/R все же позволяет использовать и LL(k) грамматики, благодаря механизму разрешения конфликтов, поэтому в нашем случае недостатком эта особенность Coco/R не является.

Для задания приоритетов операторов в Coco/R необходимо описывать специальные вложенные продукции, что в общем случае больших проблем не составляет. Приведу для наглядности сокращенный (без семантических действий) отрывок грамматики Ela:

ShiftExpr =
  AddExpr
  {
    ( ">>" | "<<" )
    AddExpr
  }.
  
AddExpr =
  MulExpr
  {
    ( "+" | "-" )
    MulExpr
  }.
  
MulExpr =
  CastExpr
  {
    ("*" | "/" | "%" | "**" )
    CastExpr
  }.

С помощью подобной структуры продукций мы задаем для парсера последовательность разбора выражений – к примеру, если парсер находит в нашем коде операцию сложения, то он будет считать, что каждый операнд потенциально может являться операцией умножения, деления, взятия остатка или возведения в степень, благодаря чему AST будет сформировано соответственно корректному приоритету операций.

Необходимость смешивать описание синтаксиса и семантические действия – это, бесспорно, серьезный минус Coco/R, однако большинство остальных генераторов парсеров для C# также не лишены этого недостатка. Особняком стоит, пожалуй, лишь Irony, который позволяет описывать грамматику на самом языке C#, однако тот факт, что Irony до сих пор не вышел из стадии Альфа-версии и умеет генерировать только LALR(1) парсеры (а, соответственно, имеет менее понятные, на мой взгляд, грамматики, в отличие от более естественных грамматик рекурсивного спуска для LL), несколько снижает энтузиазм в отношении данного генератора.

Наконец Coco/R не умеет строить за вас AST, это неизбежно приводит к необходимости потратить на разработку парсера больше времени, однако у рукописного AST тоже есть свои преимущества.

Для начала посмотрим, как устроено AST в Ela. Так как в языке нет разделения на предложения (statements) и выражения (expressions), то все классы синтаксического дерева имеют одного общего родителя – ElaExpression.

Вот как описан этот базовый класс:

        public
        abstract
        class ElaExpression
{
  internal ElaExpression(Token tok, ElaNodeType type)
  {
    Type = type;

    if (tok != null)
    {
      Line = tok.line;
      Column = tok.col;
    }
  }
  
  publicint Line { get; privateset; }

  publicint Column { get; privateset; }
  
  public ElaExpressionFlags Flags { get; protectedset; }
  
  public ElaNodeType Type { get; protectedset; }

  publicvirtualint Placeholders { get { return 0; } }

  publicvirtualbool HasYield { get { returnfalse; } }
}

Наверняка некоторые из свойств данного класса могут показаться вам странными. Начнем с того, что при инициализации в конструктор передается экземпляр класса Token (этот класс генерируется Coco/R и содержит «координаты» текущего токена), а также значение перечисления ElaNodeType, которая перечисляет все элементы AST языка и выглядит примерно так:

        public
        enum ElaNodeType
{
  Assign,

  Unary,

  Binary,

  FunctionCall,

  ...
}

Данное перечисление используется для упрощения анализа AST, которое производит компилятор.

Итак, назначение одного из свойств, Type, понятно.

Но теперь резонным может быть вопрос, почему информация о «координатах» токена хранится непосредственно в свойствах класса ElaExpression (соответственно, Line и Column), а не в виде отдельной структуры, скажем, Location, и почему я не храню имя файла. Необходимости хранить имя файла нет, так как каждый файл, как вы помните, является в Ela модулем, а трансляция модулей происходит независимо друг от друга. Таким образом совершенно избыточно хранить имя файла в каждом классе AST. Собственно говоря, парсер вообще ничего о файлах не знает и знать не хочет, так как и умеет парсить один единственный модуль. Хотите, получить AST двух модулей – вызывайте парсер дважды. Файлами в Ela занимается уже линкер, о котором будет рассказано отдельно.

Причиной же по которой информация о строке и столбце оформлена в виде свойств класса ElaExpression является банальная оптимизация. Дело в том, что в компиляторе данные о координатах токена постоянно передаются из одного метода в другой, причем инлайниг этих методов не всегда возможен по ряду причин (например, JIT в .NET не инлайнит методы с комплексными условными конструкциями, каковой является, к примеру, switch/case), поэтому оформление этих свойств в виде отдельной структуры приведет к увеличению копирования данных в стеке, не давая при этом практически никаких преимуществ. Если же сделать структуру Location классом, то производительность нашего парсера заметно пострадает, так как практически на каждый токен ему придется делать лишнее выделение памяти.

Другие свойства класса ElaExpession интереснее.

Для начала замечу, что, так как язык интерпретируемый, то с точки зрения пользователя никакой выделенной стадии трансляции-компиляции у него нет. Написал код и запустил его на исполнение. Вот и все. При этом, для того, чтобы выполнить даже банальное «2 + 2», нам нужно сначала построить синтаксическое дерево, затем скомпилировать это дерево в промежуточный байт-код, и только потом приступить, собственно, к исполнению. Какой бы быстрой ни была виртуальная машина, если предыдущие два компонента не отличаются высоким быстродействием, вряд ли кто-то начнет серьезно использовать такой язык. Поэтому весьма важной задачей является сокращение и упрощение анализа синтаксического дерева для его компиляции – там, где обычный компилируемый язык вполне может позволить себе роскошь потратить несколько лишних минут на дополнительный анализ, интерпретируемый должен беречь каждую секунду. Говоря другими словами, в Ela парсер помогает компилятору.

К примеру, перечисление ElaExpressionFlags содержит специальные подсказки компилятору. Вот как, к примеру, выглядит описание класса ElaVariableReference:

        public
        sealed
        class ElaVariableReference : ElaExpression
{
  internal ElaVariableReference(Token tok) : 
    base(tok, ElaNodeType.VariableReference)
  {
    Flags = ElaExpressionFlags.Assignable;
  }
  
  publicstring VariableName { get; internalset; }
}

Флажок Assignable используется для того, чтобы определить, можно ли присваивать значение тому или иному выражению или нет. Учитывая особенности синтаксиса Ela и работы Coco/R, это нельзя декларативно указать через грамматику, поэтому «отдуваться» в данном случае приходится компилятору. Таким образом, вместо того, чтобы производить анализ – который, к тому же, нужно делать в нескольких местах – компилятору для определения того, что выражению можно присваивать значение, достаточно лишь проверить наличие флажка Assignable. Точно так же мы можем определить, что выражение, к примеру, всегда возвращает значение типа unit (что может быть использовано для проверки кода на корректность).

Интереснее флажок HasYield. Данный флаг помогает компилятору в реализации генераторов (более известных как итераторы в мире C#). Точно так же, как и в C#, функция является генератором, если в ней значение возвращается с помощью ключевого слова yield. Иначе говоря, если внутри функции у нас хоть раз попался yield, то данная функция автоматически расценивается как генератор, и логика ее компиляции изменяется. И вместо того, чтобы заниматься пробежкой по синтаксическому дереву, компилятор просто может проверить, установлен ли у функции соответствующий флаг (флаг же в свою очередь устанавливается парсером в процессе построения AST).

Виртуальное свойство Placeholders работает похожим образом и используется для реализации механизма частичного применения, реализованного в Ela по аналогии с языком Nemerle. Для тех, Кто не знаком с этой возможностью, скажу, что механизм частичного применения позволяет объявлять новые функции, используя специальный оператор «_». Например:

        let sum = _ + _;
sum(2, 2);

Здесь бы создали функцию, принимающую два параметра и складывающую их, используя стандартный оператор «+». Данный механизм может также использоваться для каррирования. Для его реализации нужно в первую очередь подсчитать, какое количество вхождений специального токена «_» содержит выражение – за это и отвечает свойство Placeholders.

Таким образом, написанное вручную AST позволяет все же немного упростить жизнь компилятору.

Парсер в Ela представлен в виде класса ElaParser и реализует следующий интерфейс:

        public
        interface IElaParser
{  
  ParserResult Parse(string source);
  
  ParserResult Parse(FileInfo file);  
}

Как вы видите, он может работать с файлом или же разбирать код непосредственно из строки (что весьма удобно при работе с языком в интерактивном режиме). Оба метода возвращают экземпляр класса ParserResult, который содержит сформированное AST (представленное экземпляром класса ElaCodeUnit), а также коллекцию, содержащую ошибки трансляции. Парсер не генерирует исключения, если находит синтаксическую ошибку в коде, а добавляет сообщение об этой ошибке в специальную коллекцию, что часто позволяет обнаружить сразу несколько ошибок подряд. Если же происходит что-то совсем непредвиденное – например, парсер не может открыть указанный файл, – то генерируется исключение типа ElaException.

Сам по себе интерфейс ElaParser используется линкером, позволяя сделать реализацию линкера независимой от конкретной реализации парсера. Но об этом позже.

Компилятор и байт-код

Итак, теперь, вместо исходного кода, мы имеем красивое синтаксическое дерево да еще и с удобными подсказками. Казалось бы зачем нам AST транслировать в еще какой-то формат, когда можно сразу приступать к исполнению, анализируя это самое AST? Ведь тогда и реализация языка станет проще (как-никак минус один компонент) да и времени на трансляцию потребуется гораздо меньше.

На самом деле и правда есть языки, в которых никаких компиляторов в байт-код не используется. Ярким примером такого языка является Ruby. Однако у такого подхода есть ряд весьма существенных минусов, в силу которых я и решил остановиться на текущей реализации.

Прежде всего, хотя в вашей системе и становится меньше на один компонент, реализация самого интерпретатора существенно усложняется, т.е. действует так называемый «закон сохранения сложности» – упростим решение задачи в одном месте, обязательно усложним его в другом. Ведь байт-код по сравнению с AST является куда более низкоуровневым, фактически, это разложение нашего синтаксического дерева на атомарные операции, и там, где в случае с анализом непосредственно AST нам придется бегать по дереву разнородных классов, виртуальной машине нужно будет исполнить лишь несколько примитивных инструкций – ADD, PUSH, POP и так далее.

Во-вторых, мы не сможем кэшировать скомпилированный код – собственно, говоря никакого скомпилированного кода-то у нас и не будет, поэтому и кэшировать будет нечего. Можно, конечно, попробовать сериализовать AST, однако и логика сериализации получится в данном случае очень сложной (если ее писать самостоятельно) да и результирующий размер файла получится весьма большим. А вслед за этим и скорость десериализации будет желать лучшего и не факт, что окажется сильно быстрее парсинга. От этой проблемы, кстати, и страдает Ruby. А это означает, что, попытавшись сэкономить на времени трансляции, мы в итоге, наоборот, увеличиваем это время, так как делаем практически невозможной реализацию специального формата для объектных файлов, в которых можно хранить байт-код и считывать его сразу, минуя стадии парсинга и компиляции.

Ну и в-третьих – производительность. Виртуальная машина имеет таки склонность быть быстрее, чем анализатор AST, так как последнему приходится работать с куда большим объемом данных. Да и совершать некоторые оптимизации будет гораздо сложнее.

Так, определившись с подходом – с тем, что нам-таки придется реализовать компилятор в байт-код – дело остается за малым. Еще до того, как мы примемся разрабатывать компилятор, нам нужно этот байт-код спроектировать, а также решить, как он будет кодироваться. А для того, чтобы спроектировать байт-код нужно определиться, какого типа виртуальная машина будет этот байт-код исполнять.

Подробнее об устройстве виртуальной машины я напишу в отдельном разделе, пока же скажу, что в Ela используется стек-машина, т.е. код исполняется через операционный стек, и, соответственно, нужно перевести наше AST в термины стек-машины – положить значение на стек, снять значение со стека и т.д.

Признаюсь честно, проектируя байт-код для Ela, я довольно-таки много позаимствовал у MSIL – промежуточного языка, в который компилируются все .NET-языки. MSIL также ориентирован на стек-машину, а тот факт, что мне не раз приходилось с ним работать, сделал заимствования практически неизбежными. Но довольно оправданий.

Начнем с того, что в Ela все инструкции можно условно разделить на две большие категории – те, которые имеют аргумент и те, которые никаких аргументов не имеют. Аргумент в байт-коде для Ela (будем называть его EIL, Ela Intermediate Language) всегда занимает четыре байта – почему так было сделано я расскажу чуть позже.

На настоящий момент в EIL порядка ста инструкций – список их до сих пор изменяется, какие-то инструкции добавляются, какие-то, напротив, удаляются. Причем изрядная доля от этих ста инструкций была введена в целях оптимизации. Например, есть инструкция PushI4, помещающая на вершину стека целочисленное значение размером 4 байта и имеющая, соответственно, один аргумент, а есть инструкция PushI4_0, которая всегда помещает на вершину на стек ноль и не имеет аргументов, что позволяет сделать байт-код более компактным. Есть даже специальные инструкции для инкремента и декремента, причем введенные уже на поздних стадиях с целью ускорить исполнение некоторых языковых конструкций. Короче говоря, минимальный «джентльменский набор» оказался бы на самом деле гораздо меньше.

Далее в EIL можно выделить инструкции исключительно для манипуляции со стеком, в сущности их всего две – это Dup (копировать текущее значение на верхушке стека) и Pop (снять значение с верхушки стека и «забыть» о нем). Dup используется для того, чтобы не загружать значение на стек повторно, если данная операция может быть ресурсоемкой, а нам нужна копия именно того значения, которое сейчас на верхушке стека. Pop – чтобы снять со стека ненужное значение (к примеру, вы вызвали функцию, а возвращаемый ей результат проигнорировали).

Другая серия инструкций занимается уже более полезными вещами – помещает различные данные в стек и считывает их со стека. К примеру, есть такие инструкции как уже упомянутый PushI4, PushI1 (поместить один байт), PushStr (поместить строку), PushCh (поместить символ), PushR4 (поместить вещественное число размером 4 байта) и так далее. Наверняка вам интересно, каким образом работает инструкция PushStr, когда аргумент каждой инструкции не должен превышать в размере четырех байт. На самом деле все просто. Как несложно догадаться, при компиляции строится специальная таблица строк, и аргументом инструкции PushStr является специальный целочисленный указатель на строку в этой таблице.

Интереснее другое. Ela поддерживает не только 32-битные, но также и 64-битные типы данных – целые и вещественные числа с плавающей запятой. Каким же образом они помещаются в стек? Тоже через таблицу? Вовсе нет. Представим, что у вас есть такой код:

        let x = 9223372036854775800L;

При компиляции он превратится в примерно следующий набор инструкций:

PushI4 -8
PushI4 2147483647
NewI8
Popvar 0

Как видите, значение для 64-битного целого помещается в стек в два этапа, под видом двух 32-битных значений, которые потом «собираются» вместе с помощью инструкции NewI8, создающей уже специальный объект, с помощью которого в Ela представлены 64-битные целые (о объектной модели языка я расскажу в разделе, посвященном виртуальной машине). Аналогично все происходит и с 64-битными числами с плавающей запятой.

Наверняка вы заметили в конце предыдущего листинга инструкцию Popvar. Данная инструкция размещает значение с верхушки стека в переменной, адрес которой указывается в качестве аргумента (в данном случае у нас в программе одна-единственная переменная, и ее адрес – ноль). Для того, чтобы поместить на вершину стека значение переменной с указанным адресом, используется, соответственно, инструкция Pushvar. (Про особенности адресации переменных я расскажу чуть позже).

Следующая категория инструкций байт-кода – это бинарные и унарные операции. В их число входят и арифметические операции (такие как Add, Sub, Div, Mul и т.д.), битовые, включая операции сдвига, операции сравнения (Neq, Ceq, Cgt, Clt и пр.), а также булевые And и Or. При этом для логических «И» и «Или» специальных инструкций нет – данные операции являются ленивыми, как и в C# (т.е. если значением левого операнд для логического «И» является «Ложь», то правый не вычисляется вообще), поэтому их реализация не является, так сказать, атомарной, и при компиляции логических инструкций используются операция для условного перехода.

Операции для перехода – это еще один большой подвид инструкций в байт-коде Ela. В текущий момент есть одна инструкция для безусловного перехода (она называется Br) и множество инструкций для условных переходов, с помощью которых реализуются такие конструкции языка как условный оператор, циклы и даже сопоставление с образцом.

Попробуем посмотреть, во что компилируется простейший цикл вида:

        for (x to 10) {
  cout x;
}

Цикл превратится в такой набор инструкций:

000: PushI4_0
001: Popvar [256]
002: PushI4 10
003: Popvar [0]
004: Pushvar [256]
005: Br 7 006: Incr [256]
007: Pushvar [0]
008: Br_gt 12 009: Pushvar [256] 010: Cout 011: Br 6
012: Pushunit 

Как видите, кода получилось уже несколько больше, чем в прошлый раз. Также я проставил номера для каждой инструкции, чтобы было видно, куда происходят переходы.

В нулевой инструкции на стек помещается число «0», которым (уже в первой инструкции) инициализируется переменная «x». Затем (вторая и третья инструкции) на стек помещается число «10», которое записывается в специальную служебную переменную. В четвертой инструкции на стек снова помещается значение переменной «х», а в пятой – осуществляется переход на инструкцию под индексом семь, и начинается, собственно, цикл.

На этот момент в операционном стеке остается только одно значение переменной «х». Седьмая инструкция помещается на стек значение скрытой переменной (в которую у нас записано «10»). Восьмая инструкция, Br_gt, совершает условный переход только в том случае, если левый операнд больше правого (правый в данном случае – это тот, который выше по стеку), т.е. цикл у нас прервется в тот момент, когда значение «х» будет больше «10». Если левый операнд меньше либо равен правому, то переход не осуществляется, и мы просто приступаем к исполнению следующей в очереди инструкции, т.е. девятой. Заметьте, что инструкция Br_gt снимает со стека оба значения (и значение переменной «х», и «десятку»), соответственно, на момент выполнения девятой инструкции операционный стек снова пуст.

Девятая инструкция вновь помещается на стек значение «х», а десятая – производит распечатку этого значения на консоль (т.е. вновь снимает верхнее значение со стека). Одиннадцатая инструкция всегда, без всяких условий, перебрасывает нас на шестую, которая совершает целую серию операций – находит переменную по указанному адресу, увеличивает ее значение на единичку, записывает новое значение в эту переменную и помещает итоговый результат на стек. Далее идет выполнение инструкций, которые я уже описывал.

Когда наконец значение переменной «х» становится равно «11», то инструкция Br_gt перебрасывает нас на последнюю инструкцию в листинге, которая и завершает программу. Так как программа состоит из единственного выражения – цикла for, – а Ela считает, что последнее выражение в программе всегда должно возвращать какое-либо значение, for же, в свою очередь, ничего кроме unit вернуть не может, то этот самый unit и помещается на стек с помощью инструкции Pushunit.

СОВЕТ

Вы сами можете проводить подобные эксперименты, пользуясь утилитой Ela Console (elac.exe), которая распространяется вместе с языком. Если запустить эту утилиту с ключиком -eil, то, вместо исполнения кода, она выведет все инструкции ассемблера на консоль.

До сих пор мы рассматривали только достаточно общие инструкции, которые могут пригодиться при разработке любого языка программирования, однако EIL вряд ли назывался бы именно так, если бы не содержал инструкции, специфичные для Ela. К числу таких инструкций можно отнести, к примеру, Yield, о назначении несложно догадаться, Settag, устанавливающую «метку» для полиморфных вариантов, Newfun и Newfuns, которые, соответственно, создают функции-замыкания (причем вторая создает «статические» функции – в том смысле этого термина, который использовал еще в Visual Basic 6, – умеющие сохранять свое состояние между вызовами). Есть инструкции Runmod (запустить модуль на выполнение), Calla (сделать асинхронный вызов), Valueof (взять значение у объекта, инкапсулирующего отложенное вычисление) и многие другие. В этом, собственно, и заключается преимущество своей виртуальной машины – она позволяет разработать инструкции, специфичные для вашего языка, вместо того, чтобы пытаться «подстроиться» под некий универсальный ассемблер.

В заключение разговоре о байт-коде я расскажу о том, как кодируются инструкции. Я уже упоминал о том, что инструкции могут иметь либо не иметь аргументов, причем длина аргументов всегда равна 4 байтам (даже в случае таких инструкций как PushI1, которым требуется, очевидно, несколько меньшее количество байт). Соответственно, при сериализации размер одного байт-кода равен либо одному, либо пяти байтам. Однако в памяти все байт-коды занимают ровно 8 байт, и фактически для их хранения используется два целочисленных массива – в одном лежат идентификаторы инструкций, а в другом – аргументы. Причем если инструкция аргумента не имеет, то в массиве для аргументов для нее все равно будет выделено место, в котором будет храниться «нолик».

При таком подходе, бесспорно, расходуется лишняя память, однако у него есть и одно важное преимущество – он значительно упрощает процесс декодирования инструкций. Мы всегда знаем, что аргумент для инструкции под номером 12 лежит в массиве аргументов под индексом 12 – а это позволяет избавиться от лишних вычислений в самом узком для производительности месте, в логике исполнения инструкций виртуальной машиной.

Итак, с набором инструкций мы определились, теперь дело осталось за малым – научиться транслировать AST в этот самый набор инструкций.

Общий принцип работы компилятора достаточно прост – он совершает рекурсивный обход синтаксического дерева и, собственно, генерирует EIL. Однако помимо этого компилятору также нужно формировать и отладочную информацию, которая будет использоваться при раскрутке стека для исключений, а также, собственно, при отладке.

Для упрощения этих двух задач были введены два класса-помощника – CodeWriter и DebugWriter, о предназначении которых можно догадаться по названию.

В классе CodeWriter определены следующие члены:

        internal
        void CompileOpList();

internal Label DefineLabel();

internalvoid MarkLabel(Label label);

internalvoid Emit(Op op, Label label);

internalvoid Emit(Op op, int data);

internalvoid Emit(Op op);

internalint Offset { get; }

Op представляет собой обычное перечисление, в котором «перечислены» все инструкции EIL. Методы Emit(Op,int) и Emit(Op) используются для генерирования инструкций с аргументом и без аргумента. Свойство Offset возвращает адрес, который будет у следующей инструкции. С остальными методами не так просто.

Дело в том, что, как вы уже могли заметить по листингам байт-кода, в EIL принята прямая адресация инструкций, т.е. если вы хотите сгенерировать инструкцию перехода Br, то должны в качестве аргумента указать точный индекс инструкции, на которую совершается переход. Сделать это вручную не очень-то просто, ведь, когда мы генерируем инструкцию Br, нам еще неизвестен индекс, так сказать, целевой инструкции, на которую должен совершаться переход. Для упрощения работы с этим механизмом была введена специальная структура Label:

        internal
        struct Label
{
  #region Construction
  internalstaticreadonly Label Empty = new Label(EmptyLabel);
  internalconstint EmptyLabel = -1;
  privateint index;

  internal Label(int index)
  {
    this.index = index;
  }
  #endregion#region Methods
  publicoverridestring ToString()
  {
    return index.ToString();
  }


  internalbool IsEmpty()
  {
    return index == EmptyLabel;
  }


  internalint GetIndex()
  {
    return index;
  }
  #endregion
}

Как вы видите, размер этой структуры равен всего четырем байтам и используется она для инкапсуляции прямого индекса инструкции.

Как это работает.

Для того, чтобы осуществить переход на какую-либо инструкцию, вам нужно пометить эту инструкцию с помощью «метки» (т.е. с помощью Label). Поэтому для начала вы создаете такую метку, используя метод DefineLabel. Когда у вас возникает необходимость сгенерировать инструкцию Br с переходом на эту метку, вы используете метод Emit(Op,Label), т.е. метка становится аргументом вашей инструкции. А когда наконец вы доходите до той инструкции, которую, собственно, и нужно было «пометить», то, перед добавлением этой инструкции, вы вызываете метод MarkLabel(Label), который ассоциирует следующий оп-код с указанной меткой.

«Под капотом» все тоже довольно просто. Когда новая метка создается с помощью метода DefineLabel, то в нее «заворачивается» не индекс инструкции, а уникальный целочисленный идентификатор. При этом в классе CodeWriter есть специальный массив, в котором хранятся все созданные метки, причем массив этот организован таким образом, что индексы массива являются одновременно и идентификаторами меток:

        internal Label DefineLabel()
{
  var lab = new Label(labels.Count);
  labels.Add(Label.EmptyLabel);
  return lab;
}

Когда вы с помощью метода Emit(Op,Label) добавляете инструкцию, аргументом которой является метка, то адрес этой инструкции помещается в еще один массив – массив инструкций, для которых значения аргументов будут фактически установлены позже. Наконец, при вызове метода MarkLabel(Label), когда мы уже точно знаем адрес целевой инструкции, происходит запись этого самого адреса в массив labels:

        internal
        void MarkLabel(Label label)
{
  labels[label.GetIndex()] = ops.Count;
}

А метод CompileOpList производит корректировку тех инструкций, которые в этом нуждаются, заменяя абстрактный идентификатор метки на реальный адрес инструкции.

Вот и все, больше никаких секретов у класса CodeWriter нет.

Класс DebugWriter и вовсе не имеет в своем запасе никаких хитростей. Он используется для генерирования отладочной информации о функциях, лексических блоках, переменных, а также ассоциирует номер строки и столбца с теми или иными инструкциями EIL:

        internal
        void StartFunction(string name, int handle, int offset);

internalvoid EndFunction(int offset);

internalvoid StartScope(int offset);

internalvoid EndScope(int offset);

internalvoid AddVarSym(string name, int address, int offset);

internalvoid AddLineSym(int offset, int line, int col);

Методы StartFunction и EndFunction создают специальную структуру FunSym, в которой помимо названия и уникального идентификатора функции, хранится также информация об индексах начальной и конечной инструкции этой функции. StartScope и EndScope создают структуру ScopeSym, которая описывает лексические блоки языка – их размер в количестве инструкций, а также учитывает иерархию блоков, которую DebugWriter отслеживает самостоятельно через внутренний стек. Метод AddVarSym сохраняет информацию о переменной – имя, адрес и индекс инструкции, в которой она объявлена. AddLineSym – связывает с определенной инструкцией номера строки и столбца. Результатом работы DebugWriter является экземпляр класса DebugInfo, который и содержит всю отладочную информацию для скомпилированного кода.

Сам компилятор, как вы уже догадываетесь, обходит синтаксическое дерево, определяя тип его узлов не с помощью операторов проверки типа, а используя упомянутое ранее свойство Type типа ElaNodeType, которое есть в каждом классе AST. Основная логика компиляции, собственно, и представляет собой большой метод со switch/case. Компиляция наиболее сложных конструкций вынесена в отдельные методы. Компилятор организован так, что трансляция отдельных узлов AST происходит независимо друг от друга. Т.е., к примеру, логика для компиляции операции сложения ничего не знает о контексте, в котором она компилируется, а это делает отдельные участки кода слабо зависимыми друг от друга, и в итоге компилятор достаточно легко поддерживать и развивать, несмотря на то, что «самое сердце» его представляет собой большой switch/case.

Однако я немного лукавлю. Не все так просто на самом деле. Хотя бы потому что контекст для компиляции выражений в действительности есть, и его, естественно, нужно учитывать.

Учет контекста в Ela реализован следующим образом – каждый из методов компилятора, CompileExpression, CompileFunction, CompileMatch и т.д., принимает в качестве одного из параметров специальные флажки-подсказки, которые так и называются – Hints. Вот как описывается это перечисление:

        [Flags]
        internal
        enum Hints
{
  None = 0x00,

  Left = 0x01,

  Scope = 0x02,

  Comp = 0x04,

  Tail = 0x08,

  Throw = 0x10
}

Самый распространенный случай, при котором необходимо учитывать контекст компиляции, это выражение вида «x = y». Здесь и «х», и «у» представляют собой один и тот же элемент AST, ссылку на переменную, однако «х» находится в левой части основного выражения (и, соответственно, нам нужно превратить его в инструкцию Popvar, т.е. записать значение в переменную), а «у» – в правой части выражения (и уже должен быть скомпилирован в Pushvar, т.е. нам нужно поднять значение этой переменной на стек).

Для простоты все выражения можно разделить на «левые» и «правые». «Правые» – это такие, которые должны возвращать значение, а «левые», – соответственно, получать (или, по крайней мере, ничего не возвращать). Для того, чтобы указать, каким именно является выражение, используется флажок Left. Если данный флажок установлен, то это означает, что значение выражения нам не требуется, что позволяет компилятору сгенерировать в некоторых случаях предупреждение (например, попробуйте написать «2 + 2» в середине файла с кодом – получите вычисление, результаты которого игнорируются, а соответственно, и предупреждение).

Флаг Scope используется для того, чтобы сообщить вложенному выражению, что лексический блок уже задан, и не нужно задавать его повторно. Это легче пояснить на примере. Представим, что у нас есть такой код:

        if (x)
{

}

Здесь мы видим сразу две языковые конструкции, которые задают лексический блок – это условный оператор if и конструкция «блок», которая, как и в С, может использоваться независимо от других конструкций. Обычная логика компиляции привела бы к тому, что у нас получилось два нанизанных друг на друга лексических блока, была бы сгенерирована дополнительная и ненужная в данном случае отладочная информация, усложнилось бы объявление и поиск переменных. Вот для того, чтобы не задавать избыточные лексические блоки и используется хинт Scope. Более интересным примером будет оператор is, который в Ela, в отличие от C#, также имеет свою отдельную лексическую область видимости, так как позволяет совершать не только проверку типа, но и сопоставление с образцом, а следовательно, и вводить новые переменные. Поэтому если вы запишете такой код, то он приведет к ошибке:

        let result = x is [y, 1];
cout y; //Ошибка, переменная y не определена

Однако в таком вот случае ошибка явно не будет желаемым результатом:

        if (x is [y, 1])
  cout y; //Все в порядке, выводится значение y

Для того, чтобы реализовать такую логику компиляции также использует хинт Scope.

Хинт Comp используется при создании list и array comprehension. Как уже упоминалось в разделе про синтаксис, для «конструкторов» списков и массивов в Ela используется синтаксис обычных циклов. Более того, не только используется синтаксис циклов, но и их логика компиляции. Однако цикл, используемый как часть comprehension, конечно, будет иметь небольшие отличия в компиляции от обычного цикла. Например, такой вот код:

        for (x to 10)
  x;

Приведет к тому, что невостребованное значение переменной «х» будет снято со стека, а компилятор сгенерирует предупреждение. Однако если записать тот же самый цикл таким вот образом:

[| for (x to 10) x |]

то значение переменной «х» со стека сниматься не будет до тех пор, пока не завершится цикл, после чего все поднятые на стек значения будут записаны в массив. Хинт Comp как раз используется, чтобы подсказать коду, отвечающему за компиляцию циклов, как ему следует вести себя в том или ином случае.

Хинт Tail передается выражению в том случае, если данное выражение является последним в функции. Многие выражения игнорируют этот хинт, другие – как, например, выражение вызова функцию – используют его для выявления и, соответственно, оптимизации хвостовой рекурсии (когда вызов функцией самой себя, являющийся самым последние выражением в теле этой функции, заменяется на простой переход, этакое goto, что весьма благоприятным образом сказывается на производительности).

Хинт Throw передается в функцию, отвечающую за компиляцию сопоставления с образцом, когда это самое сопоставление с образцом является телом блока catch в конструкции try/catch – и тогда, если не происходит сопоставление ни с одним из образцов, генерируется не стандартное исключение, сообщающее о неудавшемся сопоставлении, а повторно перебрасывается исключение, пойманное блоком catch.

Используются и другие механизмы. Например, метод, отвечающий за компиляцию функций, принимает в качестве одного из параметров перечисление, которое помогает определить, какого типа функцию нам необходимо скомпилировать – это может быть обычная функция, генератор, конструктор для полиморфных вариантов и так далее.

Кстати, по поводу функций.

Как я уже неоднократно упоминал, функции в Ela являются первоклассными объектами, т.е. по сути функция – это такое же значение, как целое число или строка. Объявление функции означает, что вы вводите новую переменную, значением которой становится функция. В этом смысле функции в Ela сами по себе лишены имен, имена есть только у переменных. Каким же образом идентифицируются различные функции? Как несложно догадаться, с помощью особого целочисленного идентификатора.

В Ela для описания функции используется специальный класс MemoryLayout, экземпляры которого создаются компилятором:

        internal
        sealed
        class MemoryLayout
{
  #region Construction
  internal MemoryLayout(int size, int address)
  {
    Size = size;
    Address = address;
  }
  #endregion#region Properties
  internalint Size { get; set; }

  internalint Address { get; privateset; }
  #endregion
}

Свойство Size возвращает общее количество слотов для хранения переменных функции, а свойство Address – абсолютный индекс инструкции, с которой, собственно, и начинается выполнение функции.

Функции в Ela всегда компилируются по месту объявления. Это будет проще пояснить на примере. Представим, что мы написали следующий код:

        let foo() "Hello, world!";

Данный код будет скомпилирован примерно в следующий набор инструкций (для наглядности вместо индексов переменных и строк я использую их значения):

000: Br 3
001: Pushstr "Hello, world!"
002: Ret
003: PushI4_0 
004: Newfun 1
005: Poploc foo

Как видите, в самом начале функции вставляется инструкция для безусловного перехода, которая позволяет «перепрыгнуть» через код функции (ведь в противном случае он бы выполнился, даже если мы его не вызывали). После «прыжка» начинают выполняться следующие инструкции – PushI4_0 (поднимает на стек значение «0», которое описывает количество аргументов нашей функции – в данном случае их нет), Newfun (который считывает со стека количество аргументов функции и на основе этой информации, а также уникального идентификатора функции, который передается в качестве аргумента этой инструкции и всегда начинается с «1», так как «0» – это, собственно, глобальный блок, создает объект типа «Функция») и Poploc (записывающий поднятый на стек объект «Функция» в переменную «foo»).

Вызов же функции представляет собой еще более простую операцию:

Pushloc foo
Call 0

Мы поднимаем на стек значение переменной «foo» и вызываем ее, передавая в качестве аргумента инструкции Call количество параметров функции (в данном случае – ноль).

Писать о реализации компилятора можно довольно долго, поэтому последняя важная особенность, о которой я здесь расскажу – это способ, с помощью которого вычисляются адреса переменных.

Не скрою, что в первой «наброске» интерпретатора, когда даже инструкции EIL кодировались и выглядели совсем по-другому, а поддержка лексической области видимости была более ограниченной, для поиска переменных использовались обычные хэш-таблицы. Учитывая, что Ela поддерживает замыкания – строго говоря, все функции в Ela, как бы вы их ни объявляли, замыкаются на родительских контекст, – и что функции могут вложенными в другие функции, причем многократно, то поиск нужной переменной происходил рекурсивно: сначала смотрим в хэш-таблице, содержащей только локальные переменные, затем смотрим в «родительской» хэш-таблице, потом еще выше и так далее. Думаю, не стоит объяснять, что производительность такого решения оставляла желать лучшего. Обращение к захваченным переменным – часто используемая возможность, поэтому в большинстве случаев поиск одной хэш-таблицей не ограничивался. Не говоря уж о том, что поиск по хэш-таблице тоже не самая скоростная операция по сравнению, например, с доступом к элементу массива по индексу. Да и перерасход памяти налицо – во-первых, на внутренние структуры хэш-таблицы, во-вторых, на имена для переменных, которые приходилось придумывать в любом случае, даже если мы заводим скрытую сервисную переменную, о существовании которой пользователь никогда не узнает.

В общем, сплошные минусы.

А когда я задался целью реализовать настоящую С-подобную лексическую область видимости, то хэш-таблицы окончательно продемонстрировали свою несостоятельность. Ведь в случае с хеш-таблицами области видимости образуются путем построения иерархии из этих самых хеш-таблиц, а сейчас вот, к примеру, виртуальная машина знает один-единственный блок видимости переменных – функцию, и это очень даже правильный подход, так как иная реализация привела бы к избыточному усложнению кода и деградации производительности. Таким образом, отслеживать область видимости для таких конструкций как if, for и т.д. должен компилятор. И благодаря тому, что объявления переменных у нас обязательные, это не составляет для компилятора большого труда.

Т.е. получается, что уже на этапе компиляции мы прекрасно знаем, где, в каком лексическом блоке, в какой именно функции объявлена та или иная переменная, является ли она локальной или захвачена из родительского контекста.

Таким образом, чтобы уникально идентифицировать переменную нам требуется два индекса – первый указывает, в каком именно «блоке» эта переменная объявлена (причем на уровне EIL кода сохраняются только блоки-функции, а от других лексических блоках остается лишь отладочная информация), а второй – каков, собственно, порядковый номер этой переменной в карте локальных переменных функции. Т.е., к примеру, переменная с индексами 0 и 2, это локальная переменная, которую следует искать в карте локальных переменных под индексом 2. Переменная с индексами 1 и 0 – это переменная прямого «родителя», т.е. захваченная. Переменная с индексами 2 и 2 – это переменная «родителя родителя» и так далее.

Остается последняя проблема – как сохранять два этих индекса в качестве аргументов инструкций, когда под аргументы у нас всего выделяется четыре байта? Однако поразмыслив, я решил, что четыре байта для кодирования этой информации более чем достаточно. Сейчас для указания «области» переменной используется один байт (а это означает, что вы не сможете создать более 255 вложенных функций, что, как мне кажется, с лихвой должно хватать любому программисту), а три оставшихся байта используются для указания индекса переменной в карте локалей («упереться» в данное ограничение тоже будет сложновато).

В итоге хэш-таблицу для поиска переменных я использую не на этапе исполнения, а на этапе компиляция, а поиск переменной происходит с помощью такой вот функции:

        private ScopeVar GetVariable(string name, Scope startScope, int startShift)
{
  var cur = startScope;
  var shift = startShift;

  do
  {
    varvar = default(ScopeVar);

    if (cur.Locals.TryGetValue(name, outvar))
    {
      var.Address = shift | var.Address << 8;
      returnvar;
    }

    if (cur.Function)
      shift++;

    cur = cur.Parent;
  }
  while (cur != null);

  return ScopeVar.Empty;
}

ScopeVar – это структура, которая, помимо адреса, содержит также метаданные переменной. На настоящий момент метаданные включают всего два флажка, определяющие, является ли переменная неизменяемой (что тоже целиком и полностью отслеживается на этапе компиляции) и является ли переменная «частной» (private, т.е. видима ли она из других модулей и будет ли включать в таблицу экспорта).

Компилятор реализован в виде класса ElaCompiler и реализует следующий интерфейс:

        public
        interface IElaCompiler
{
  CompilerResult Compile(ElaCodeUnit unit, CompilerOptions options);

  CompilerResult Compile(ElaCodeUnit unit, CompilerOptions options, 
    CodeFrame frame, Scope globalScope);
}

ElaCodeUnit, как вы помните, содержит AST, а класс CompilerOptions используется для описания настроек компилятора. Например, вы можете указать, стоит ли генерировать расширенную отладочную информацию и применять ли различные оптимизации, а также, какие следует выводить диагностические сообщения. Есть у компилятора также особый режим работы – StrictMode (устанавливается с помощью флажка -strict, если вы используете приложение ElaConsole), в котором запрещается объявлять изменяемые глобальные переменные. По сути изменяемые глобальные переменные вообще весьма сомнительная возможность, которая в общем случае приводит к сложно уловимым побочным эффектам и затрудняет поддержку кода. В Ela такие переменные поддерживаются по одной-единственной причине – для того, чтобы упростить поддержку интерактивного режима. Во всех остальных случаях имеет смысл включать «строгий» режим.

Второй метод интерфейса с дополнительными параметрами типов CodeFrame и Scope как раз и используется для интерактивного режима, т.е. если вы хотите скомпилировать код, который будет учитывать код, скомпилированный ранее (о работе интерактивного режима я подробнее расскажу в разделе, посвященном линкеру).

Оба метода возвращают CompilerResult, который содержит экземпляр класса CodeFrame и диагностические сообщения. Внутри CodeFrame можно найти не только инструкции EIL вместе с аргументами, но также отладочную информацию, таблицу строк и карты памяти для функций, а вместе с ними и таблицы экспорта и импорта. Таблица экспорта содержит ссылки на все глобальные переменные, при объявлении которых не был указан модификатор доступа private. Таблица импорта – ссылки на другие модули.

Линкер

Итак, зачем нужны эти таблицы и какую функцию, собственно, выполняет линкер?

В каком-то смысле линкер можно считать фасадным классом для парсера и компилятора – вместо того, чтобы сначала вызывать парсер, получать AST, а затем передавать его в компилятор, вы можете сразу сообщить всю необходимую информацию линкеру и получить на выходе «сборку», CodeAssembly.

Базовый класс линкера содержит следующие члены:

        public
        class ElaLinker<P,C> 
  where P : IElaParser, new()
  where C : IElaCompiler, new()
{
  ElaLinker(LinkerOptions linkerOptions, CompilerOptions compOptions, 
    FileInfo rootFile);

  virtual LinkerResult Build();
  
  public LinkerOptions LinkerOptions { get; privateset; }
  
  public CompilerOptions CompilerOptions { get; privateset; }

  public FileInfo RootFile { get; privateset; }
}

Как вы видите, благодаря введению интерфейсов IElaParser и IElaCompiler, реализация линкера не зависит от конкретных реализаций парсера и компилятора – вы вполне можете сделать свои собственные и «подсунуть» их линкеру.

Однако основная задача линкера не в этом.

Как видите, при инициализации класса вам необходимо указать не только настройки для компилятора и линкера, но также и путь к файлу, который можно считать «входной точкой» нашего приложения. Линкер компилирует этот файл, используя специфицированные парсер и компилятор, и смотрит, есть ли какие-либо ссылки на другие модули в его таблице импорта. Если ссылки есть, то находит каждый из указанных в них модулей и повторяет вышеописанную операцию.

Более того, так как Ela поддерживает специальный формат объектных файлов, линкер сначала проверяет, не находится ли рядом с исходным файлом (*.ela) бинарный (*.elaobj). Если такой файл находится, и дата его изменения равна дате изменения «исходника», то вместо компиляции происходит быстрая десериализация байт-кода.

Каким именно образом линкер находит модули? Обязательно ли для этого указывать их абсолютные пути? Конечно же, нет. Вот как выглядят возможные варианты импорта модуля на Ela:

        open Foo;
open Foo at"modules\\new"as Foo2;
open Foo[mydll];

В первом случае указывается только название модуля (которое, как вы помните, является одновременно и названием файла). В настройках линкера можно указать, в каких именно директориях следует производить поиск модулей, а также следует ли включать директорию, в которой находится изначальный файл с кодом. Руководствуясь этой информацией, линкер и производит поиск файлов «Foo.elaobj» или «Foo.ela».

Однако как быть, если у нас есть два разных модуля с одинаковым названием, а переименовывать мы их не хотим? Есть решение и для такого случая. Мы можем указать относительный путь к модулю, а также алиас для этого модуля. Теперь уже линкер будет искать файл «modules\new\Foo.ela» или «modules\new\Foo.elajob» в тех же самых директориях, где происходит поиск в обычном случае.

Последняя директива самая интересная.

Дело в том, что модули для Ela можно писать как на самой Ela, так и на любом .NET-языке. В этом случае вам нужно всего-лишь отнаследоваться от класса ForeignModule, определить в нем абстрактные методы, а с помощью атрибута уровня сборки ElaModuleAttribute указать название вашего модуля и реализующий его тип. Такие модули можно подключать практически так же, как и обычные, при этом в квадратных скобках нужно указывать название динамической библиотеки DLL, которая ищется по обычному алгоритму (допустимо указывать относительный путь к библиотеке, как в предыдущем примере), и название самого модуля.

Вот пример минимального модуля на C#:

[assembly:ElaModule("Math", typeof(MathModule)]

publicclass MathModule : Ela.Linking.ForeignModule
{
  privatesealedclass RandomizeFunction : ElaFunction
  {
    //здесь мы передаем в конструктор базового класса количество//аргументов функции (в данном случае – один)internal RandomizeFunction() : base(1) { }
            
    publicoverride RuntimeValue Call(params RuntimeValue[] args)
    {
      var rnd = new Random();
      var ret = rnd.Next(arg[0].ToInt32(null));
      returnnew RuntimeValue(ret);
    }
  }
  
  publicoverridevoid Initialize()
  {  
    base.Add("rnd", new RandomizeFunction(this));  
  }
}

Класс MathModule наследуется от ForeignModule и переопределяет метод Initialize, внутри которого просто-напросто вручную создает функцию и регистрирует ее как «переменную» модуля, используя метод Add, у которого в классе ForeignModule есть перегрузки для всех основных типов, начиная от примитивов и заканчивая функциями. Сам же класс RanmodizeFunction расширяет стандартный класс системы типов ElaFunction, об устройстве которого я более подробно расскажу в разделе, посвященном виртуальной машине.

Для того, чтобы «скомпилировать» такой модуль, линкеру просто нужно вызвать метод Compile, определенный в классе ForeignModule:

        internal IntrinsicFrame Compile()
{
  var frame = new IntrinsicFrame(locals.ToArray());
  frame.Layouts.Add(new MemoryLayout(locals.Count, 0));
  frame.GlobalScope = scope;
  return frame;
}

Данный метод возвращает экземпляр класса IntrinsicFrame, который расширяет класс CodeFrame и отличается от него лишь тем, что хранит в себе массив уже проинициализированных значений (а соответственно, в отличие от обычного модуля, внешний модуль уже «исполнять» не нужно – все искомые значения доступны и так).

Конечно, реализация функций Ela на C# может вам показаться немного многословной, однако с многословием в данном случае легко бороться с помощью бесхитростного кодогенератора, каковой, кстати, и используется при разработке стандартной библиотеки для Ela.

Вот, собственно, и все. Завершив свой труд, линкер возвращает экземпляр класса LinkerResult, который содержит диагностические сообщения (если они имеются) и «сборку», CodeAssembly, где находятся ссылки на все скомпилированные модули, а также «корневой» модуль, с которого нужно начинать исполнение.

Однако это еще не все.

Как упоминалось ранее, Ela поддерживает интерактивный режим – когда код выполняется по мере ввода, при этом результаты выполнения предыдущих инструкций «запоминаются», – а также компиляцию из строки, поэтому, помимо стандартного линкера, существует и второй, инкрементальный.

ElaIncrementalLinker также является генерик-классом и наследуется от ElaLinker, добавляя к нему новый метод – SetSource(string). При создании инкрементального линкера вы можете и вовсе не указывать путь к «корневому» файлу, а непосредственно передать его код в виде строки, используя метод SetSource.

Сгенерировав сборку и исполнив ее, вы имеете возможность добавить новый фрагмент кода, и этот код будет как бы присоединен к уже существующему. Пояснить это будет легче всего с помощью упрощенных примеров кода из приложения Ela Console, которое полностью поддерживает интерактивный режим:

        private
        static
        int InterpretString(string source)
{
  linker.SetSource(source);
  var res = linker.Build();
  return Execute(res.Assembly);
}

privatestaticint Execute(CodeAssembly asm)
{
  var mod = asm.Modules[0];

  if (vm == null)
    vm = new ElaMachine(asm);
  else
    vm.RefreshState();

  var os = lastOffset;
  lastOffset = mod.Ops.Count;
  var exer = vm.Run(os);
}

Как видите, экземпляр инкрементального линкера создается один-единственный раз, после чего кэшируется. То же самое делается и с виртуальной машиной. При этом при повторных вызовах у виртуальной машины вызывается метод RefreshState, который обновляет внутренние структуры памяти соответственно произошедшим изменениям, а при вызове метода Run, запускающего наш код на исполнение, указывается индекс инструкции, начиная с которой нам, собственно, и нужно начать выполнять код.

Ну а теперь настало время рассказать о том, что же, собственно, происходит, когда мы вызываем этот самый метод Run у нашей виртуальной машины.

Виртуальная машина

На первый взгляд может показаться, что создание своей виртуальной машины весьма нетривиальная задача, которая значительно усложняет проект в целом, и куда более простым путем было бы ориентироваться на какую-либо существующую реализацию – хотя бы на тот же CLR. Но, как вы помните из рассказа о EIL, собственная виртуальная машина куда более гибкий инструмент, в отличие от универсальной, и позволяет создать именно тот набор промежуточных инструкций, который максимально полно соответствует разрабатываемому языку. Тот же MSIL, к примеру, это по сути объектно-ориентированный ассемблер, и при генерации MSIL пришлось бы потратить большую часть времени на адаптацию языка, который не находится в ООП даже в отдаленных родственных отношениях, под имеющийся набор инструкций.

Наконец, еще один момент. Как вы думаете, какой из трех основных компонентов – парсер, компилятор и виртуальная машина – доставляет меньше всего проблем при разработке? Генерируемый парсер? Но ведь на доводку грамматики языка приходится потратить довольно много времени и сил, а у Coco/R тоже не всегда получается отслеживать потенциальные проблемы, так что его диагностические средства совершенно не исключают необходимость в упорной и добросовестной отладке. Компилятор и вовсе самая сложная часть проекта. А вот в виртуальной машине действительно находится наименьшее количество ошибок, а когда ошибки и попадаются, их как правило довольно легко обнаружить и исправить, просто просматривая дампы сгенерированного EIL.

Как же так получается?

Дело в том, что при разработке промежуточного языка, EIL, было сделано главное – исполнение программы по большей части упростилось до тривиальных операций, которые (теперь уже без всяких оговорок) не имеют никаких внутренних зависимостей, т.е. исполняются совершенно автономно друг от друга, в результате чего сложно уловимых ошибок, со сложными «побочными эффектами» в виртуальной машине практически не встречается.

«Сердце» виртуальной машины представляет собой один большой – на сей раз действительно большой – метод Execute с конструкцией switch/case, которая исполняется внутри бесконечного цикла. Вот как, примерно, это выглядит:

        private
        void Execute(WorkerThread thread)
{
  for (;;)
  {
    var op = ops[thread.Offset++];

    switch (op.OpCode)
    {
      case Op.Nop:
        break;
      ...
      //И еще чуть больше ста инструкций
    }
  }
}

Конечно, этот код, написанный на C# 3.0, мало чем отличался бы от аналогичного кода на С, но, боюсь, стек-машина – это как раз тот случай, когда максимально простой, можно даже сказать, тупой способ реализации является самым верным. Причина проста – производительность. Так как у нас стек-ориентированная машина, то количество инструкций получается довольно большим. Представьте, к примеру, что у вас есть цикл на миллион итераций – сама по себе логика цикла будет скомпилирована в десятки инструкций, а ведь внутри у вас тоже может содержаться какой-то полезный код. Поэтому в ряде случаев количество «проходов» по этому switch-y измеряется десятками и сотнями миллионов раз, и разного рода «спец-эффекты» C# 3.0 здесь неуместны. Даже цикл сделан бесконечным (выход из него осуществляется при исполнении инструкции Term, которая завершает работу модуля), чтобы убрать лишние проверки. А конструкция switch компилируется C# в довольно-таки быстрый код – в специальную конструкцию MSIL Switch, – который имеет почти константное время доступа, более того, порядок, в котором вы располагаете инструкции, никак не влияет на производительность тех или иных команд.

В общем и целом это все, что можно рассказать о виртуальной машине. Но, как вы понимаете, дьявол в деталях.

Прежде всего, как вы наверняка заметили, индекс текущей инструкции хранится не напрямую, а инкрементируется через поле некоторого объекта «thread». Данный объект представляет собой экземпляр класса WorkerThread, который инкапсулирует все данные, необходимые для функционирования виртуальной машины – загруженные модули, а также операционный стек и стек вызовов. Ниже приведены основные члены этого класса:

        public
        class WorkerThread
{
  internalvoid SwitchModule(int index);
  
  internal WorkerThread Clone();

  internal CodeAssembly Assembly { get; privateset; }

  internal EvalStack EvalStack { get; privateset; }

  internal FastStack<CallPoint> CallStack { get; privateset; }

  internalint Offset { get; set; }

  internalbool Busy { get; set; }

  internal CodeFrame CurrentModule { get; privateset; }

  internalint CurrentModuleIndex { get; privateset; }
  
  internal ElaLazy ReturnValue { get; set; }
}

Сделано это по той простой причине, что Ela поддерживает асинхронное программирование, потоки же изолированы друг от друга, для каждого из потоков создаются свои стеки ну и, естественно, индекс текущей инструкции EIL у разных потоков будет отличаться. Вообще потоки в Ela реализованы поверх реальных, а кооперативная многопоточность поддерживается только через генераторы. Когда возникает необходимость создать новый поток, то текущий экземпляр WorkerThread клонируется (с помощью метода Clone), все стеки обнуляются, после чего вызывается в новом потоке все тот же метод Execute, в которой передается новосозданный экземпляр класса WorkerThread.

Еще одна важная функция WorkerThread заключается в «переключении» модулей. Если, к примеру, необходимо вызвать функцию, которая находится в другом модуле – а следовательно, будет выполняться совершенно другой набор инструкций, – то нужно вызвать метод SwitchModule и передать в него индекс модуля, в результате чего свойства CurrentModule и CurrentModuleIndex будут уже возвращать экземпляр и индекс целевого модуля, соответственно. Набор же инструкций изменяется в момент вызова. Для этого в основном switch-е есть специальное вхождение, на которое происходит перенаправление всякий раз когда меняется текущая функция:

{
  var mem = callStack.Peek();
  locals = mem.Locals;
  captures = mem.Captures;
  ops = thread.CurrentModule.Ops;
  frame = thread.CurrentModule;
}

Возможно, вам такие оптимизации могут показаться излишними, однако они в действительности весьма заметно влияют на время исполнения.

Как вы уже заметили, виртуальная машина Ela использует два стека – операционный и стек вызовов. На операционном стеке совершаются все операции – туда загружаются данные, аргументы функций, через операционный стек происходят математические вычисления и так далее. Данные в операционном стека «заворачиваются» в специальную структуру RuntimeValue. Ниже приводится ее сокращенная реализация (сокращенная по той причине, что эта структура реализует интерфейсы IComparable и IConvertible, а также имеет ряд служебных методов, которые не очень интересны, но занимают довольно много места):

        public
        struct RuntimeValue : IComparable<RuntimeValue>, IConvertible
{
  internalreadonlyint I4;

  internal ElaObject Ref;

  public ObjectType DataType
  {
    get { return Ref != null ? (ObjectType)Type : ObjectType.None; }
  }

  internalint Type
  {
    get { return Ref.Type; }
  }
}

Размер этой структуры равен либо 8, либо 12 байтам, в зависимости от того, исполняется ли код Ela в 32-битной или в 64-битной системе. Поле Ref возвращает ссылку на объект типа ElaObject, который является базовым в системе типов Ela (т.е. все остальные типы данных, включая целые и вещественные числа наследуют от ElaObject). При таком подходе вполне резонным будет вопрос, а зачем нужно поле с загадочным названием I4 – да и вообще вся эта нелепая структура RuntimeValue.

Бесспорно, хранить на операционном стеке объекты под видом ElaObject было бы гораздо удобнее и как бы «красивее» с архитектурной точки зрения, однако это означало бы, что даже такие простые стуктуры данные как 32-битное целое или символ создавались бы не на стеке, а в куче, что весьма заметно повлияло бы на производительность ряда алгоритмов. Поэтому я пошел на одну хитрость.

В Ela есть пять типов данных, которые размещаются на стеке – это 32-битные целые и 32-битные вещественные числа, булевые, модули (которые тоже являются типом данным, а следовательно, и первоклассными объектами), а также символы (при этом, заметьте, что 64-битные целые и 64-битные вещественные числа ну и, естественно, любые строки создаются уже в куче). Всем этим трем типам данных также соответствуют свои классы в объектной модели типов – ElaInteger, ElaSingle, ElaBoolean, ElaModule и ElaChar, соответственно. Однако эти классы являются синглтонами, т.е. создается один-единственный экземпляр каждого из них, ссылка на который доступна непосредственно через соответствующее статическое поле ElaObject. При этом, конечно же, реальное значение в этих классах не хранится, а «упаковывается» в поле I4. Отсюда и следует ограничение, что все объекты в Ela, размещаемые на стеке, должны занимать не более четырех байт. В случае же с модулями четыре байта отводятся на хранение уникального идентификатора модуля.

Конечно, вместо 32-битного поля I4 можно было бы ввести 64-битное поле I8, однако это увеличило бы количество копируемых на стеке данных и привело бы к неизбежной деградации производительности (что, собственно, проверялось мной в тестах).

Таким образом, когда вы создаете, к примеру, 32-битное целое число, то реальное значение записывается в поле I4, а поле Ref получает ссылку на синглтон-объект ElaInteger. С помощью данной ссылки происходит определение типа (ведь Ela язык динамический, и неизбежно огромное количество проверок осуществляется во время исполнения) – т.е. попросту вызывается свойство Type, реализацию которого вы можете увидеть в примере кода выше.

Второй стек, стек вызовов, используется для отслеживания вызовов функций. На вершине стека вызовов всегда находится структура, которая «описывает» текущую функцию – в первую очередь ее локальные и захваченные переменные.

В упрощенном виде эта структура выглядит так:

        internal
        sealed
        class CallPoint
{
  internal CallPoint(int retAddr, int modHandle, int offset,
    RuntimeValue[] locals, FastList<RuntimeValue[]> captures);

  internalint ReturnAddress;
      
  internalreadonlyint ModuleHandle;
  
  internalreadonlyint StackOffset;
  
  internalreadonly RuntimeValue[] Locals;

  internalreadonly FastList<RuntimeValue[]> Captures;
}

Для чего нужны все эти поля? Начнем по порядку. Поле ReturnAddress содержит индекс инструкции, на которую нужно совершить переход, когда исполнение функции прекратится, т.е. «адрес», куда нам нужно вернуться. Подобный «возврат» осуществляет инструкция Ret, которой всегда завершается функция. Второе поле, ModuleHandle, содержит уникальный идентификатор модуля, в котором определена функция (если функция определена в «главном» модуле, являющемся точкой входа приложения, то это поле всегда равно нулю). Поле StackOffset содержит количество элементов на операционном стеке до вызова функции. Это поле используется для того, чтобы привести стек в то же состояние, в котором он был до вызова функции, если исполнение функции было прервано в «аварийном» порядке (например, было сгенерировано исключение).

Массив Locals – это хранилище для локальных переменных функции. Как вы помните, компилятор формировал для каждой функции специальную «карту памяти», MemoryLayout, в которой содержалась информация о количестве локальных переменных – именно это и позволяет использовать в данном случае массив фиксированного размера.

Наконец, поле Captures содержит коллекцию захваченных переменных. Логика их хранения достаточно проста. Прежде всего любая функция, как я уже говорил, является замыканием – даже объявленная в глобальном блоке. При этом функции могут быть вложенными. Представим, что у нас есть такой код:

        var x = 0;

let fun1() {
  var x = 1;

  let fun2() {
    var x = 2;
  }
}

Для функции «fun2» коллекция Captures будет состоять из двух элементов: под индексом 0 будет храниться массив с глобальными переменными, а под индексом 1 – массив с переменными функции «fun1». Локальные же переменные функции «fun2» оказываются в массиве Locals.

Здесь уместно подробнее рассказать о том, как происходит вызов функции.

Функция, как и любой другой тип данных, реализована в виде класса-наследника ElaObject. В данном классе содержатся следующие члены:

        public
        class ElaFunction : ElaObject
{
  publicvirtual RuntimeValue Call(params RuntimeValue[] args);

  internalint Handle { get; privateset; }

  internalint ModuleHandle { get; privateset; }

  internalint ParameterCount { get; privateset; }

  internal FastList<RuntimeValue[]> Captures { get; privateset; }
  
  internal RuntimeValue[] Memory { get; set; }
}

Пусть вас не смущает виртуальный метод Call – он используется при вызове функции извне (например, из кода на C#) и никогда не вызывается самой виртуальной машиной для функций, реализованных на Ela. При этом, как вы видите, можно создать наследника класса ElaFunction и, переопределив данный метод Call, «подсунуть» под видом функции, скажем, код на том же C#. Каким образом виртуальная машина определяет, нужно ли вызывать функцию через метод Call или следует проинициализировать структуру CallPoint, поместить ее на стек и совершить простой переход к инструкции с определенным адресом (к чему, собственно, и сводится обычный вызов функции)?

Дело в том, что, так как все внутренние функции являются замыканиями, свойство Captures для них обязательно будет возвращать непустой массив. Для внешних же функций это свойство всегда возвращает null.

Три свойства Handle, ModuleHandle и ParameterCount являются, так сказать, основными «описателями» функции. Handle – это уникальный индекс функции, с помощью которой мы можем найти «карту памяти» для этой функции. ModuleHandle – индекс модуля, в котором определена функция. Ведь если функция определена в другом модуле нам сначала следует переключить контекст исполнения на этот модуль (что делается с помощью класса WorkerThread, как я уже объяснял ранее). Наконец, ParameterCount – это количество параметров функции.

Свойство Memory используется только функциями особого типа, о которых я уже упоминал при описании EIL – это статические функции, которые сохраняют свое состояние между вызовами. Данное свойство, собственно, и хранит именно это состояние, т.е. массив локальных переменных, который для обычных функций пересоздается при каждом вызове.

Как видите, объект типа ElaFunction содержит все необходимое для того, что найти нужную нам «карту памяти» для функции, проинициализировать класс CallPoint и совершить переход на первую инструкцию функции. В случае же с внешней функцией все происходит еще проще – все, что нужно сделать, это вызвать метод Call, передав в него снятые со стека параметры функции.

В Ela, разумеется, есть также и другие типы данных – например, связные списки, индексированные динамические массивы, кортежи, записи и пр. Реализация большинства этих типов данных достаточно тривиальна, однако большинство из них имеют встроенные поля и методы, которые упрощают работу с ними. К примеру, если бы у массивов не было свойства «length», то вы никак не смогли бы узнать их длину. А помимо «length», у тех же массивов есть методы «add», «remove», «insert» и «clear».

Фактически любой тип в объектной системе Ela может иметь встроенные поля и функции. Реализуется это благодаря тому, что в базовом для всех классе ElaObject определены такие вот методы:

        public
        virtual RuntimeValue GetField(string key)
{
  returnnew RuntimeValue(ElaObject.Invalid);
}

publicvirtualbool HasField(string key)
{
  returnfalse;
}

Наследнику всего лишь нужно их переопределить, как делает, к примеру, тот же ElaTuple, представляющий собой реализацию кортежа:

        public
        override
        bool HasField(string key)
{
  return key == LENGTH;
}

publicoverride RuntimeValue GetField(string key)
{
  return key == LENGTH ? new RuntimeValue(Length) :
    new RuntimeValue(ElaObject.Invalid);
}
ПРИМЕЧАНИЕ

ElaObject.Invalid – это специальное внутреннее значение, с помощью которого виртуальная машина определяет, что произошла ошибка и может сгенерировать соответствующее исключение.

Конечно, когда типов данных много, а операций мало, одна и та же операция должна уметь совершать абсолютно разные действия. К примеру, оператор «+» может как складывать числа, так и объединять списки. Причем выполняться все эти действия будут с помощью одной-единственной инструкции Add, так как во время компиляции мы попросту не знаем, какую конкретно операцию нам нужно совершить. При этом данная инструкция должна еще и убедиться в том, что над указанными типами в принципе можно совершать операцию сложения и, в случае чего, сгенерировать понятную ошибку.

В результате код для операции Add выглядит следующим образом:

        case OpCode.Add:
{
  right = evalStack.Pop();
  left = evalStack.Peek();
  var aff = opAffinity[left.Type, right.Type];

  if (aff == INT)
    res = new RuntimeValue(left.I4 + right.I4);
  elseif (aff == REA)
    res = new RuntimeValue(left.GetReal() + right.GetReal());
  elseif (aff == STR)
    res = new RuntimeValue(left.ToString() + right.ToString());
  elseif (aff == CHR)
    res = new RuntimeValue(new String(newchar[] {
      (Char)left.I4, (Char)right.I4 }));
  elseif (aff == LNG)
    res = new RuntimeValue(left.GetLong() + right.GetLong());
  elseif (aff == DBL)
    res = new RuntimeValue(left.GetDouble() + right.GetDouble());
  elseif (aff == ARR)
    res = ConcatArrays(left, right);
  elseif (aff == LST)
    res = ConcatLists(left, right);
  else
  {
    InvalidOperation("+", left, right, thread);
    gotodefault;
  }

  evalStack.Replace(res);
}
break;

Причем заметьте, это еще не весь код – ведь наиболее сложные операции, вроде объединения списков и массивов, вынесены в отдельные функции.

Неудивительно, что в такой ситуации хочется, чтобы код, который будет определять, как именно должно происходить наше вычисление, был как можно более быстрым и лаконичным. Фактически нужно, во-первых, определить, какую конкретно операцию нам нужно совершить (арифметическое ли сложение или же конкатенацию строк) и, во-вторых, какой будет тип у результата этой операции (так как, к примеру, сложение int и int и сложение int и long – это в обоих случаях сложение целых чисел, но в первом результатом будет 32-битное целое, а во втором – 64-битное).

В Ela для этого используется специальная таблица соответствия типов. Таблица строится на основе двумерного массива, причем длина обоих массивов равна количеству типов, и соответственно, индекс каждого массива соответствует тому или иному типу. Для определения результирующего типа операции в качестве первого значение в индексере массива нужно указать идентификатор типа левого операнда, а в качестве второго – правого, как вы и видите в начале реализации инструкции Add. Если же операция над типами невозможна (например, мы пытаемся сложить строку и число), то возвращается специальное служебное значение ERR и генерируется ошибка InvalidOperation.

В целом это, пожалуй, все, что я хотел рассказать об устройстве виртуальной машины в Ela.

Виртуальная машина реализована в виде класса ElaMachine, который имеет следующие открытые конструкторы и методы:

        public
        sealed
        class ElaMachine
{
  ElaMachine(CodeFrame frame);

  ElaMachine(CodeAssembly asm);
  
  ExecutionResult Run();

  ExecutionResult Run(int offset);

  void RefreshState();
}

При инициализации машины вы можете передать в конструктор как экземпляр класса CodeAssembly, который является результатом работы линкера, так и экземпляр CodeFrame, если по каким-либо причинам использовать линкер вы не хотите. Для обычного запуска на исполнение используется метод Run, а метод Run(int) используется для поддержки уже обсуждавшегося ранее интерактивного режима и в качестве параметра принимает индекс инструкции, с которой нужно начать исполнение. Метод RefreshState также используется для поддержки интерактивного режима – его необходимо вызывать в том случае, если вы воспользовались возможностью инкрементальной компиляции, и в ваш код были добавлены новые инструкции. Данный метод преимущественно изменяет размеры карт памяти, которые отводятся под хранение глобальных переменных.

По завершении исполнения кода ElaMachine возвращает экземпляр класса ExecutionResult, который на текущий момент содержит лишь одно свойство ReturnValue типа RuntimeValue. Данное свойство содержит значение, которое вернул выполненный код. Так как ранее уже говорилось, что любая инструкция в Ela может потенциально вернуть какое-либо значение, а последнее (или единственное) выражение в глобальном блоке всегда считается выражением, которое должно что-либо вернуть, то значение RuntimeValue будет установлено в любом случае, однако иногда это может быть и значение типа unit.

В отличие от парсера, компилятора и линкера виртуальная машина может генерировать исключения типа ElaCodeException, если при исполнении кода возникло необработанное исключение. Также может быть сгенерировано исключение типа ElaFatalException, если произошло повреждение операционного стека (например, по завершению исполнения кода на стеке остались какие-либо значения).

Что же в итоге

А в итоге получился интерпретируемый функциональный язык, кроссплатформенный (на текущий момент поддерживаются .NET 3.5 или выше и Mono 2), обладающий довольно-таки широким спектром возможностей, в числе которых:

и многое другое. К сожалению я не успел в рамках этой статьи рассказать о реализации всех возможностей языка – да, собственно, и о самом-то языке толком не рассказал, – но в противном случае это получилась бы уже не статья, а целая книга.

Думаю, Ela неизбежно вытеснит из моей «личной жизни» неоднократно упомянутый здесь JavaScript, т.к. позволяет решать те же самые задачи, но куда логичнее и, на мой взгляд, элегантнее. Неплохо подходит она и на роль встраиваемого языка – в ней нет ни капли «неуправляемого» кода, никаких небезопасных конструкций, и вся реализация умещается в одну библиотеку размером в 200 килобайт. Так что, думаю, ей еще предстоит «проверка боем».

Однако наверняка вас волнует другой вопрос. .NET, C# да еще и интерпретатор – с какой же скоростью все это работает? Признаюсь сразу – я не стремился создать самый быстрый язык на свете. Собственно, выбирать для этого C# в любом случае было бы не самой лучшей затеей, так как это достаточно высокоуровневый язык, что нередко мешало при создании той же виртуальной машины. Однако все же я планировал создать язык с приемлемой производительностью, который можно будет использовать в реальных проектах.

Поэтому я просто обязан завершить статью тестами на производительность.

Первым моим порывом было сравнить Ela с Conscript – другим интерпретируемым языком, реализованным на .NET. Однако Conscript оказался настолько медленным, что его пришлось дисквалифицировать еще до начала серьезных тестов. К тому же мне все-таки хотелось сравнить Ela с чем-то таким, чем реально пользуются люди.

Поэтому «жертвой» моих опытов стал Ruby.

Итак, дано: Ela, версии 0.7 (последней на текущий момент) на .NET Framework 3.5. Ruby, версии 1.8.7. Процессор Core i5 750.

Ruby и Ela языки довольно-таки разные, но я изо всех сил старался писать максимально одинаковый код. Всего я рассмотрю в этом разделе два теста – пузырьковую сортировку и быструю сортировку по алгоритму Хоара.

Тест 1. Пузырьковая сортировка

В данном тесте я произвожу простую сортировку пузырьком массива из тысячи элементов, причем замеряется как время сортировки, так и время заполнения массива, т.е. в итоге показывается общее время исполнения программы. Методология тестирования простая – каждый пример кода я запускал три раза подряд и вычислял среднее время исполнения.

Код на Ruby:

      def bsort(list)
  for i in 0..(list.length - 1)
    for j in 0..(list.length - i - 2)
      if (list[j + 1] <=> list[j] ) == -1
        list[j], list[j + 1] = list[j + 1], list[j]
      endendendend

list = Array.new(1000)
for i in 0..1000
  list[1000 - i] = i
end
bsort(list)

Код на Ela:

      let bubbleSort(item) 
  for (i to item.length - 1)
    for (j when item[j + 1] < item[j] to item.length - 2 - i)
      item[j] <=> item[j + 1];
    
let size = 1000;
let arr = [| for (i = size downto 0) i |];
bubbleSort(arr);

Время исполнения:

Ruby – 1.153 сек.
Ela – 0.425 сек.

Признаться, я немного опасался за Ela в данном тесте, так как цикл for-in в Ruby является лишь сахаром для итеративного вызова метода each у объекта и, ввиду этого фактора, выполняется очень быстро, тогда как Ela приходится честно совершать все вычисления. В самом же тестовом примере основное время по сути и уходит на циклы. Однако даже это преимущество не помогло Ruby.

Тест 2. Быстрая сортировка

В данном тесте производится быстрая сортировка массива из миллиона элементов и, аналогично предыдущему тесту, измеряется полное время выполнения программы. Методология тестирования тоже такая же, как и в первом тесте.

Код на Ruby:

      def quickSort(item, left, right)
  i = left
  j = right
  center = item[(left + right) / 2]
  
  while i <= j dowhile item[i] < center and i < right do
      i += 1
    endwhile item[j] > center and j > left do
      j -= 1
    endif i <= j
      item[i], item[j] = item[j], item[i]
      i += 1
      j -= 1
    endendif left < j
    quickSort(item, left, j)
  elsif right > i
    quickSort(item, i, right)
  endend

arr = Array.new(1000000)

for i in 0..1000000
  arr[1000000 - i] = i
end

quickSort(arr, 0, arr.size - 1)

Код на Ela:

      let size = 1000000;
let arr = [| for (i = size downto 0) i |];

let quickSort(item, left, right)
{
  var (i, j) = (left, right);
  var center = item[(left + right) / 2];
  
  while (i <= j)
  {
    while (item[i] < center && i < right)
      i++;
   
    while (item[j] > center && j > left)
      j--;

    when (i <= j) {
      item[i] <=> item[j];  
      i++;
      j--;
    }
  }

  if (left < j)
    quickSort(item, left, j);  
  elseif (right > i)
    quickSort(item, i, right);
  else
    ();
}

quickSort(arr, 0, arr.length - 1);

Время исполнения:

Ruby – 2.739 сек.
Ela – 1.213 сек.

При этом замечу, что Ruby реализован на С, тогда как интерпретатор Ela – кроссплатформенный и работает без перекомпиляции под Windows, Linux и MacOS, под 32-битными версиями .NET Framework или Mono запускается как 32-битное приложение, а под 64-битными – как 64-битное.

Я вовсе не стремлюсь как-то принизить достоинства Ruby, к тому же, начиная с версии 2.0, его тоже собираются перевести на настоящую виртуальную машину, вместо текущего интерпретатора по AST, и тогда, думаю, Ruby-таки сможет оторваться от Ela по скорости кода. Впрочем, и для Ela текущая версия еще не окончательная, и я надеюсь, что к релизу смогу сделать код еще немного более оптимальным.

Однако даже по текущим результатам тестов можно уверенно утверждать, что на .NET и C# вполне можно разработать интерпретируемый язык с приемлемой производительностью, если сделать правильные архитектурные решения. Как видите, даже более высокая скорость исполнения кода не позволяет интерпретатору на С оторваться от интерпретатора на «управляемом» языке. Как говорил кто-то, пузырьковая сортировка на С не будет быстрее быстрой сортировки на Ruby, т.е. выбираемые нами алгоритмы зачастую важнее инструментов (впрочем, судя по тестам, я не уверен, что это утверждение на самом деле соответствует действительности, однако афоризм, бесспорно, красивый).

Ela на настоящий момент находится в процессе активной разработки – планируется добавлять новые возможности, исправлять работу старых, реализовать полноценную поддержку отладки, а также создать для языка стандартную библиотеку.


Эта статья опубликована в журнале RSDN Magazine #2-2010. Информацию о журнале можно найти здесь