Система Orphus
Версия для печати

N2 – языковый фрeймворк

Автор: Чистяков Владислав Юрьевич
Опубликовано: 17.05.2012
Версия текста: 1.0
Что?
Что позволяет?
Рантайм N2
Как использовать?
Что получается в результате?
История
Почему фреймворк?
Синтаксический модуль
Импорт синтаксических модулей
Расширяемые правила
Описание правил
Токены и их классификация
Классы токенов
Тело правила
Имена полей
Дополнительные поля
Сила связывания, приоритет и ассоциативность
Области видимости
Связи между областями видимости
Типизация
Цели типизации
Организация иерархии метаинформации
Построение иерархии символов
Классы символов и области видимости
Описание правил типизации для правила
Трансформация
Перегрузка синтаксических конструкций на основе информации о типах и синтаксических предикатов
Расширение внешних правил
Бэкэнды
Реализация языка
Список литературы

Что?

Н2 – это фреймворк или тулкит, позволяющий:

  1. Реализовывать новые или имеющиеся (C#, Nemerle, Java, Delphi, C++, F#,...) языки программирования (ЯП) или DSL.
  2. Описывать модули расширения для ЯП с динамически (вовремя компиляции) расширяемым синтаксисом.

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

Описание языка производится с помощью специализированных DSL. Примерами таких DSL являются:


Рисунок 1.

Что позволяет?

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

Автоматизирует создание всей необходимой для ЯП инфраструктуры – компиляторов, средств интеграции с IDE, документации и т.п.

Рантайм N2

Рантайм-часть N2 будет предоставлять следующие универсальные компоненты:

Установив рантайм N2, можно расширять его модулями, предоставляющими поддержку конкретных ЯП, DSL, а также их расширений.

Как использовать?

N2 позволяет описать независимые модули, включающие:

Такие модули могут помещаться в отдельные библиотеки или объединяться с другими модулями в общую библиотеку.

Модуль может:

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

Модули N2 похожи на макросы Nemerle, но более гибки и универсальны.

Что получается в результате?

С помощью N2 можно создавать следующие модули, расширяющие приведенные выше рантайм-подсистемы:

Модуль интеграции с IDE предоставляет (для конструкции из соответствующего модуля расширения компилятора):

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

История

Проект N2 родился в результате развития идей, зародившихся и сформировавшихся в процессе работы над языком Nemerle (а также над его интеграцией с Microsoft Visual Studio, VS). С некоторой точки зрения Nemerle можно рассматривать как прототип для N2.

ПРИМЕЧАНИЕ

Язык Nemerle – это гибридный, статически типизированный язык высокого уровня, сочетающий в себе возможности функционального, объектно-ориентированного программирования, и метапрограммирования/DSL-естроения. Nemerle реализован для платформ .NET и Mono (язык компилируется в CIL и является CLS-совместимым) и тесным образом интегрирован с этими платформами. Главная особенность языка — развитая система метапрограммирования и возможность расширения синтаксиса языка.

Более подробно о Nemerle можно узнать здесь или на официальном сайте языка: http://nemerle.org.

В 2006 году группа разработчиков с сайта RSDN примкнула к разработчикам Nemerle. Вначале команда RSDN занималась созданием интеграции с VS, но впоследствии начала развивать и сам компилятор. К 2008 году группа RSDN стала основной группой разработки языка Nemerle и всего, что с ним связано.

При работе над Nemerle нами (группой RSDN) было выявлено несколько архитектурных и множество проектных недостатков Nemerle. Не будут вдаваться в подробности, но именно желание их устранить привело нас к проекту N2. N2 изначально задумывался как принципиально новая версия языка Nemerle, но в итоге он превратился в нечто большее, чем отдельный язык программирования. Он превратился в языковый фреймворк или Toolkit.

Почему фреймворк?

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

Средством реализации первой версии фреймворка N2 будет Nemerle. Это связано с тем, что N2 в основном будет строиться на DSL (DSL описания грамматики, DSL типизации, DSL преобразования кода), а Nemerle предоставляет отличные средства для реализации DSL и вообще отлично подходит для реализации всего, что связано с анализом и обработкой кода.

В будущем на Framework-е N2 будет воспроизведено подмножество языка Nemerle, и Framework будет переведен на него (с использованием bootstrapping-а – самокомпиляции).

N2, так же, можно рассматривать как макросистему новой версии языка Nemerle, так как N2 преследует те же цели, что и макросы Nemerle – синтаксические/семантические расширения языка. Однако, в отличие от Nemerle, N2 будет предоставлять практически неограниченные возможности по расширению синтаксиса, что позволяет не только расширять базовый синтаксис (как это было в Nemerle), но и создавать новые языки или воспроизводить имеющиеся (например, C#, C++, F# или Java).

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

В то время как Nemerle обеспечивал существенное упрощение в создании внутренних DSL, N2 будет (еще более существенно) упрощать создание как внутренних, так и внешних DSL.

Примерами внешних DSL могут являться языки шаблонов вроде Razor (.cshtml, .vbhtml и им подобные) или ASP, грамматики, описывающие языки программирования, ресурсные файлы различных форматов, языки описания диаграмм.

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

Синтаксический модуль

N2 позволяет расширять функциональность языков на модульной основе. Единицей расширения является синтаксический модуль.

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

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

syntax module Literal
{
  token Digit  = ['0'..'9'];
  token Number = Digit+ ('.' Digit+)?;
  void s = ' '*;

  NumberLiteral = number s
  {
    where number : Number;
    Value : decimal = decimal.Parse(GetText(number));
  }
}

В данном модуле описаны четыре правила. Два из них, «Digit» и «Number», являются так называемыми лексическими правилами. Такие правила помечаются ключевым словом «token». Они не порождают дерева разбора (Parse Tree). Вместо этого лексическое правило возвращает местоположение разобранного в соответствии с ним исходного кода. Местоположение в N2 описывается типом NToken.

Правило «s» также является специальным видом правила – void-правилом. Результат разбора void-правила всегда игнорируется. Void-правило нельзя использовать из лексических правил.

ПРЕДУПРЕЖДЕНИЕ

Правило «s», пропускающее 0 или более пробельных символов, заранее предопределено. Его явное определение заменяет исходную реализацию (что может быть нужно для изменения списка пропускаемых символов). При переопределении правила «s» обязательно нужно дать ему тип void.

Для упрощения работы с пробельными символами используется следующий подход. В лексерных правилах (помеченных ключевым словом token) пробельные символы необходимо указывать явно (реально это нужно делать очень редко).

В обычных правилах после каждого подправила будет вставляться правило «s». Например, если в приведенном выше правиле Number удалить использование правила «s», то оно все равно будет подставлено автоматически.

После «литералов», т.е. последовательности Unicode-букв ставится другое предопределенное правило – «S». Оно, так же как и «s», игнорирует необязательные символы, но так же проверяет (с помощью предиката), что за подправилом не следует Unicode-буква. Это делается, чтобы парсер не распознавал префиксы в идентификаторах (или других конструкций языка).

Чтобы отключить автоматическую расстановку «s» и «S», можно использовать атрибут [ExplicitSpaces].

Если в правиле появилось явное использование предопределенного правила «s» (или «S»), N2 будет ожидать явного указания пробельных символов (не будет вставлять правило «s» самостоятельно).

Правило NumberLiteral является обычным (не лексерным) правилом. Такие правила вводят новый узел дерева разбора – ParseTree. В данном примере новый узел будет иметь два поля: «number» типа «NToken» и «Value» типа «decimal».

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

Ns.Module.RuleName

где:

Импорт синтаксических модулей

Один синтаксический модуль может быть импортирован в другой. Это позволяет использовать правила, объявленные в импортируемом модуле.

syntax module Expression
{
  using Literal;

  abstract Expr; // расширяемое правило

  Num        is Expr = NumberLiteral; // использование импортированного правила

  Rounds     is Expr = '(' expr  ')';
  Seq        is Expr = '{' expr* '}';

  Neg        is Expr = '-'  expr       { Precedence = Unary; }
  PrefixDec  is Expr = "--" expr       { Precedence = Unary; }
  PostfixDec is Expr =      expr "--"  { Precedence = Primary; }

  Add        is Expr = expr1 '+' expr2 { Precedence = Additive; }
  Sub        is Expr = expr1 '-' expr2 { Precedence = Additive; }
  Mul        is Expr = expr1 '*' expr2 { Precedence = Multiplicative; }
  Div        is Expr = expr1 '/' expr2 { Precedence = Multiplicative; }
  Mod        is Expr = expr1 '%' expr2 { Precedence = Multiplicative; }
  Pow        is Expr = expr1 '^' expr2 { Precedence = Power;
                                         Associativity = Right; }
}

В приведенном выше модуле реализована грамматика арифметических выражений (более подробна она будет описана ниже). В ней используется правило «NumberLiteral», объявленное в модуле «Literal».

Расширяемые правила

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

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

Есть два способа расширения правила, описанного в другом синтаксическом модуле:

  1. Импортировать модуль, содержащий расширяющие правила, в модуль, содержащий расширяемое правило (при этом выполняется взаимный импорт).
  2. Использовать динамическую загрузку расширений.

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

Второй – в случае разработки динамически расширяемых языков программирования (таких как Nemerle). Следующая версия Nemerle будет реализована с использованием этого метода.

Ниже демонстрируется первый способ расширения.

syntax module Expression
{
  using ExpressionPlusesExtension; // импорт расширяющих правил
  ... // все как в примере, приведенном выше
}

syntax module ExpressionPlusesExtension
{
  using e = Expression; // псевдоним для синтаксического модуля Expression

  Plus       is e.Expr = '+'  expr @ Unary
  PrefixInc  is e.Expr = "++" expr @ Unary;
  PostfixInc is e.Expr = expr @ Primary "++";
}
ПРИМЕЧАНИЕ

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

Второй способ демонстрируется в разделе «Области видимости».

Описание правил

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

Правило задает:

Синтаксис лексических правил:

LexRule = Arrtributes? "token" RuleName "=" Choice ";";
Choice = Sequence ('|' Sequence)*

Остальные правила можно увидеть здесь. К лексическим правилам N2 неприменимы: OrderedChoice, MethodHandler, SkipRule, FailureRecovery, ScopedRule, Precedence.

Синтаксис синтаксических правил:

SyntaxRule     = Attributes? RuleName ReturnType? '=' Sequence (";" | Body);
ExpandableRule = Attributes? OperatorRuleName ReturnType? (";" | Body);
ExpandRule     = Attributes? OperatorRuleName "is" RuleName '=' Sequence (";" | Body);

Остальные правила можно увидеть здесь.

Основным отличием грамматики N2 является отказ от использования оператора приоритетного выбора (правило OrderedChoice). Вместо него в лексических правилах введены операторы «|» (альтернативы). В синтаксических же правилах предлагается использовать расширяемые правила и их расширение.

При этом все синтаксические правила, вводящие альтернативу, автоматически становятся расширяемыми.

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

Токены и их классификация

Токены описывают отдельные «слова» языка. Хотя N2 основан на безлексерном парсере (парсере, в котором нет выделенного шага лексического разбора), все же понятие токена (или лексемы) в нем явно выделяется. Сделано это по двум причинам:

Токены могут быть описаны двумя способами:

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

If is Expr = "if" "(" Condition ")" Expr1 "else" Expr2;

имеется четыре литеральных токена: "if", "(", ")" и "else". "if" и "(" рассматриваются как отдельные токены, между которыми может быть ноль или более пробельных символов (если за ними нет явного указания правил s или S, описывающих пробельные символы).

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

Классы токенов

Классы токенов описываются аналогично перечислениям.

ПРИМЕЧАНИЕ

Это сделано неспроста, так как в итоге они превращаются в перечисления, значениями которых помечаются токены.

Вот как выглядят стандартные классы токенов:

token class Default
{
  | None
  | Keyword
  | BracketOpen
  | BracketClose
  | String
  | Comment
  | Identifier
  | Literal
  | Operator
  | Punctuation
  | Type
  | TypeUnbound
}

token class Preprocessor
{
  | PlainText
  | Keyword
}

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

Для токена, объявленного отдельным правилом, класс токена задается с помощью атрибута TokenClass:

syntax module CSharp
{
  [TokenClass(Default.Comment)]
  token SingleLineComment = "//" (!NewLine Any)* NewLine;

  [TokenClass(Default.Comment)]
  token MultiLineComment = "/*" (!"*/" Any)* "*/";

  [TokenClass(Default.String)]
  token StringLiteral = '"' StringPart* '"' s;
  
  ...
}

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

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

Если настройка задана в импортируемом синтаксическом модуле и в текущем, то текущие настройки переопределяют импортированные.

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

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

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

Ниже приведен пример, демонстрирующий использование опций LiteralTokenClass, BracketsTokenClass и конструкции brackets:

syntax module CSharp
{
  option LiteralTokenClass = Default.Keyword;
  option BracketsTokenClass = Default.BracketOpen, Default.BracketClose;

  brackets "(" ")", "{" "}", "[" "]";

  If is Expr = "if" "(" Condition ")" Expr1 "else" Expr2;
  ...
}

В данном примере литеральным токенам "if" и "else" будет автоматически задан класс токенов – Default.Keyword, токену "(" – Default.BracketOpen, а токену ")" – Default.BracketClose. Это позволит IDE подкрасить ключевые слова нужным цветом, а также позволить пользователю осуществлять подсветку парных скобок и навигацию по ним.

Подсветка кода зависит от классов токенов, заданных при определении языковых конструкций. Автор этих конструкций может как использовать предопределенные классы токенов описанные выше, так и ввести свои. Кроме того, на подсветку кода может оказывать влияние описание типизации. При этом для задания типа подсветки также используются классы токенов. Конкретные цвета, используемые при подсветке токенов, задаются в настройках IDE.

Тело правила

В грамматике правила можно заметить подправило – Body. Body описывает блок (состоящий из фигурных скобок), содержащий определения дополнительных полей, а также дополнительные секции. Такими секциями могут быть:

Имена полей

Как было сказано выше, синтаксическое правило описывает тип для новой ветки дерева разбора. Поля этого типа выводятся из описания тела правила. Так для правила:

  syntax NumberLiteral = number
  {
    where number : Number;
    Value : decimal = decimal.Parse(GetText(number));
  }

будет создан тип NumberLiteral с полем number типа Number и Value типа decimal. А для правила:

  Add is Expr = expr1 @ Additive '+' expr2 @ Additive;

будет создан тип Add с полями expr1 типа Expr, expr2 типа Expr.

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

Тип же полей выводится компилятором или указывается явно.

Если тип для подвыражения не задан явно в расширяющем правиле, то считается, что он соответствует типу расширяемого правила (указанному за «is» в описании расширяющего правила).

В иных случаях тип подправила нужно задавать явно. Например, при описании правила для директивы «using» (языка Nemerle или C#), вводящей псевдоним для типа или пространства имен, типы нужно задавать явно:

UsingAliasDeclaration is NamespaceMember = "using" Alias : Identifier "="
                                           NsOrTypeName : QualifiedIdentifier ";";

Здесь Alias и NsOrTypeName – это имена подправил, а Identifier и QualifiedIdentifier – это типы этих правил (соответственно).

Описание типов подправил можно вынести в тело правила. Вот то же описание директивы «using», но с внесенными описаниями полей:

UsingAliasDeclaration is NamespaceMember = "using" Alias "=" NsOrTypeName ";"
{
  where
  {
    Alias        : Identifier;
    NsOrTypeName : QualifiedIdentifier;
  }
}

В результате для обоих вариантов будет сформировано одинаковое дерева разбора, для которого будет сгенерирован класс:

public class UsingAliasDeclaration : UsingAliasDeclarationBase
{
  public Alias        : Identifier { get; }
  public NsOrTypeName : QualifiedIdentifier { get; }
  ...
}

Дополнительные поля

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

Дополнительные поля – это несколько необычная для императивного программиста сущность. Описание вида:

Expr1.Field = Expr2.Field;

рассматривается не как присвоение значения одного поля другому, а как описание эквивалентности.

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

Expr2.Field = Expr1.Field;

Будучи однажды задано (даже опосредовано), значение уже не может быть изменено.

Попытка написать:

Expr2.Field = 42;
Expr2.Field = 66;

Приведет к выдаче сообщения об ошибке и является ошибкой разработчика синтаксического модуля.

Точно так же будет выдано сообщение об ошибке и в следующем случае:

Expr1.Field1 = Expr2.Field1;
Expr3.Field2 = Expr2.Field3;
Expr2.Field3 = Expr2.Field1;
Expr2.Field3 = 42;
Expr3.Field2 = 8;

А вот в таком случае:

Expr2.Field3 = 42;
Expr1.Field1 = Expr2.Field1;
Expr3.Field2 = Expr2.Field3;
Expr2.Field3 = Expr2.Field1;
Expr3.Field2 = 42;

ошибки не будет.

Если значение связано с полем при его объявлении:

Expr2.Field = 42;

оно уже не может быть изменено. Однако оно может быть связано с тем же значением повторно. Это не будет считаться ошибкой.

В общем, семантика оператора «=» для дополнительных полей аналогична семантике оператора «=» в математике.

Дополнительные поля можно использовать для двух целей:

  1. Для агрегации (или синтеза) значений. При этом обычно используется синтаксис объявления поля с инициализатором, а значения, используемые в инициализаторе, берутся из подправил.
  2. Для «распространения» значений по веткам дерева разбора (передачи в соседние подправила, спуска вниз по подправилам).

Примером первого случая использования может являться реализация свертки констант (когда константное выражение сворачивается в константу):

variant ConstValue
{
  | String { Value : string; }
  | Int    { Value : int; }
  | None

  public static TrySumLiterals(v1 : ConstValue, v2 : ConstValue) : ConstValue
  {
    | (ConstValue.Int(x),    ConstValue.Int(y))    => ConstValue.Int(x + y)
    | (ConstValue.String(x), ConstValue.String(y)) => ConstValue.String(x + y)
    | _                                            => ConstValue.None()
  }
}
...
syntax module Expression
{
  ...
  abstract Expr
  {
    ConstValue : ConstValue;
  }

  IntegerLiteral is Expr = Digits : Digits
  {
    Value : int = int.Parse(GetText(Digits));
    ConstValue = ConstValue.Int(Value);
  }

  StringLiteral is Expr = '"' String : StringBody '"'
  {
    Value : string = Unquot(GetText(String));
    ConstValue = ConstValue.String(Value);
  }

  Add is Expr = Expr1 '+' Expr2
  {
    Precedence = Additive;
    ConstValue = ConstValue.TrySumLiterals(Expr1.ConstValue, Expr2.ConstValue);
  }
}

Дополнительные поля могут вводиться и с помощью специализированных DSL, облегчающих преобразование дерева разбора в значения (например, в enum-ы или Set-ы).

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

Сила связывания, приоритет и ассоциативность

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

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

Приоритет и ассоциативность задаются для первого «оператора».

ПРИМЕЧАНИЕ

Термин «оператор» взят в кавычки, так как он используется довольно условно. Оператором в N2 именуется любая допустимая грамматика (набор подправил) между двумя рекурсивными обращениями к расширяемому правилу изнутри расширяющего правила. Обычно такое подправило одно, и оно является литералом (например, "+").

По умолчанию оператору задается минимально возможный приоритет.

Ассоциативность задается установкой поля Associativity. Оно может иметь два значения: Right и Left. Значение Left является принятым по умолчанию, так что если значение Associativity не задано, то считается, что оператор имеет левую ассоциативность.

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

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

Сила связывания может быть задана с помощью бинарного оператора «@». Перед ним ставится имя подправила, для которого нужно задать силу связывания, а за ним – сама сила связывания. В качестве силы связывания используется имя группы приоритетов (как и в случае приоритета).

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

Ниже приведено два идентичных описания оператора Cons, первое – с использованием приоритета и ассоциативности, а второе – с применением силы ввязывания.

Использование приоритета и ассоциативности:

Cons is Expr = expr1 "::" expr2 { Precedence = Cons; Associativity = Right; }

Использование силы связывания:

Cons is Expr = expr1 @ Cons+ "::" expr2 @ Cons;

Оба описания приводят к одному и тому же результату – описанию правоассоциативного оператора «::».

Первое описание позволяет получить более простое для чтения описание грамматики. Второе позволяет более точно управлять силой связывания. В случае n-арных операторов, когда n > 2, только прямое указание силы связывания позволит задать оную для третьего и последующих рекурсивных обращений. Однако на практике такая необходимость может возникать очень редко.

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

N2 поддерживает области видимости (scopes). Области видимости нужны для организации разного рода контекстных сервисов и действий. В частности области видимости нужны для работы сервиса разрешения имен (таблицы символов) или, например, для динамической загрузки синтаксических расширений.

Для каждого «цикла» (правила, после которого идут операторы цикла «*» или «+») N2 автоматически создает новую область видимости.

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

Если после ключевого слова «scope» указать имя, то N2 создаст (в текущем правиле) дополнительное поле с этим именем, в которое будут помещены все ветки дерева разбора, попадающие в эту область видимости. Это позволяет упростить реализацию синтаксических конструкций вроде use в F#.

Ниже приведен пример реализации директивы «using» языка Nemerle.

ПРЕДУПРЕЖДЕНИЕ

Обратите внимание на то, что речь идет о директиве «using», открывающей пространство имен:

using Sysmem;

а не об операторе «using», управляющем автоматическим освобождением ресурсов. Для оператора «using» описывать секцию «scope» нет необходимости, так как областью видимости символов, объявленных в нем, является вложенное выражение.

syntax module NamespaceBody
{
  UsingImportDeclaration is NamespaceMember = "using" NsOrTypeName : QualifiedIdentifier ";"
  {
    FullName : string = NsOrTypeName.ToString();

    scope Body // вводит поле типа list[NamespaceMember]
    {
      Open(FullName);
      AddLoadedGrammarsByName(FullName);
    }
  }

  UsingAliasDeclaration is NamespaceMember = "using" Alias : Identifier "=" NsOrTypeName : QualifiedIdentifier ";"
  {
    scope Body // вводит поле типа list[NamespaceMember]
      Symbols.AddAlias(Alias.Text, NsOrTypeName.Text);
  }
}

Строка:

      Open(FullName);

«открывает» в новую область видимости содержимое ветки иерархии метаданных, описывающую пространство имен или тип Nemerle (о работе с метаданными будет сказано ниже). Это приводит к заполнению таблицы символов информацией о символах (именах), объявленных в пространстве имен, или типе, имя которого помещено в дополнительное поле FullName. Так как в момент создания области видимости вся информация о типах может еще не быть доступна (типы и пространства имен могут быть распарсены впоследствии), загрузка осуществляется отложенно (точнее – виртуально). Реальная информация о метаданных будет вычислена только в момент обращения к этим самым метаданным. Когда это произойдет, зависит от языка программирования. Так, для C++ это может случиться еще при парсинге (при разрешении неоднозначности), что не проблематично для C++, так как в этом языке все типы должны быть объявлены (или предекларированны) выше (заранее). Для языка вроде Java это случится только после окончания парсинга. В это время метаинформация по разобранным типам уже будет доступна, так что опять же проблем не возникнет.

Строка:

AddLoadedGrammarsByName(FullName);

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

Связи между областями видимости

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

N2 позволяет в рамках правила отображать символы, вводимые одними подправилами в другие. Этот процесс называется публикацией символов и описывается секциями «publish symbols».

Ниже приведен пример описывающий оператор «foreach» языка Nemerle:

ForEach is Expr = "foreach" '(' Pattern : Pattern "in" Collection ForEachWith? ')' Body
{
  symbols
    publish Pattern, ForEachWith => Body;
  ...
}

В операторе «foreach» подправила Pattern и ForEachWith вводят новые символы для локальных переменных. Эти переменные должны быть видны только внутри этих подправил и в подправиле Body. Именно это и описывает секция «publish symbols». До «=>» в ней описываются подправила, публикующие имена, а после – правила в которых эти имена публикуются. Если тех или иных правил больше одного, то они перечисляются через запятую.

Кроме того, секция «publish symbols» позволяет решать и проблему указания последовательности обработки. Например, в грамматике C# подправило, описывающее параметры типов, стоит до подправила, описывающего тип возвращаемого значения функции. При обычном порядке вычислений (справа налево, сверху вниз) при обработке возвращаемого значения символы, добавляемые при обработке параметров типов, будут еще недоступны. Это можно исправить, указав в секции «publish symbols», что подправило, описывающее параметры типов, публикует символы для всего правила (в данном случае «Method»):

Method is Member = ReturnType TypeArgs '(' (Args, ",")* ')' Constrains Body
{
  symbols 
    publish TypeArgs => this;
  scope Method;
  ...
}

Обратите внимание, что хотя подправило Args также публикует символы в область видимости метода, но заниматься их публикацией нет необходимости, так как видимость имен из Args совпадает со стандартным порядком вычислений символов (они видны только в Body).

Типизация

ПРЕДУПРЕЖДЕНИЕ

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

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

Поддержка типизации делится на:

Цели типизации

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

Задачей процесса типизации является установление конкретных значений для каждого из имен, присутствующих в программе, и точное определение всех конструкций программы. Например, в языке Nemerle конструкция:

X()

может означать:

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

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

В результате процесса типизации ветки дерева разбора снабжаются дополнительной метаинформацией, которую можно использовать при трансформации кода (см. раздел «Трансформация») или в IDE.

Организация иерархии метаинформации

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

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

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

N2 предоставляет два предопределенных класса символов Symbol и SymbolTree:

abstract symbol class Symbol
{
  Name : string;
}

abstract symbol class SymbolTree : Symbol
{
  Members : symbols[Symbol];
}

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

SymbolTree является наследником Symbol и содержит (вдобавок к имени) поле Members, описывающее список вложенных символов (своих членов). SymbolTree используется как базовый класс символов для описания иерархии типов в языках.

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

Вот как может выглядеть фрагмент описания системы типов Java-подобного языка:

abstract symbol class TypeParameters : Symbol;
{
  TypeParameters : symbols[Type];  
}

abstract symbol class NamespaceMember : SymbolTree;
abstract symbol class TypeMember      : SymbolTree;

symbol class Namespace : NamespaceMember
{
  override Members : symbols[NamespaceMember];
}

abstract symbol class Type : N2.NominativeType, NamespaceMember, TypeMember, TypeParameters
{
  override Members : symbols[TypeMember];
}

symbol class Class : Type
{
  Extend     : Class;
  Implements : symbols[Interface];
}

symbol class Interface : Type
{
  Extends : symbols[Interface];
}

symbol class Method : TypeMember, TypeParametersProvider
{
  override Members : symbols[Parameter];
  ReturnType       : Type;
}

symbol class Field : TypeMember
{
  Type        : Type;
  Initializer : Expr;
}

Любое поле может быть переопределено в потомках symbol class. При переопределении поля в symbol class можно уточнять тип элемента переопределяемых полей (т.е. поддерживается ковариантность).

Тип symbols используется для описания списков символов. Этот тип поддерживает реактивную модель программирования. Любой код, получивший ссылку на symbols, может получать оповещения об изменении его элементов. Таким образом, можно отлавливать изменения тех или иных частей дерева типов и производить некоторые изменения. Например, можно отлавливать изменение состава конструкторов некоторого типа и генерировать для каждого из них метод для «фабрики классов».

Построение иерархии символов

Иерархия символов строится двумя путями:

Классы символов и области видимости

В конкретной области видимости можно «открыть» или «закрыть» тот или иной объект из иерархии метаинформации.

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

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

Описание правил типизации для правила

При описании правил типизации преследуется две цели:

  1. Описать тип текущего правила (а стало быть, и ветки дерева разбора). Тип правила задается в предопределенном поле Type.
  2. Задать отношения (связи) между типами подправил данного правила.

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

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

«x» и «y» являются переменными типов или конкретными (фиксированными) типами. Унификация между ними возможна при условии, что типы являются эквивалентами/подтипами/супертипами (в зависимости от оператора), один из аргументов является неконкретизированной переменной типов, или частично конкретизированной переменной типов, допускающей такую конкретизацию.

Если конкретизация невозможна, выдается сообщение об ошибке.

Операторы «>:» и «<:» доступны только для языков, поддерживающих subtyping.

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

Для описания конкретных типов можно использовать специальный синтаксис #имя_типа. Например, #void.

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

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

Пример:

If is Expr = "if" "(" condition ")" expr1 "else" expr2
{
  type
  {
    typevar R;
    condition.Type =: #bool;
    expr1.Type >: R;
    expr2.Type >: R;
    Type =: R;
  }
}

Те же правила типизации можно задать «функциональным» (упрощенным) синтаксисом:

If is Expr = "if" "(" condition ")" expr1 "else" expr2
{
  type[R](condition : #bool, expr1 : +R, expr2 : +R) : R;
}

Здесь:

R – локальная переменная типа.

«+» – означает, что тип может быть супертипом типа, перед которым он стоит. Также можно использовать «-», что означает, что тип может быть подтипом типа, перед которым он стоит.

Возвращаемое значение такой псевдо-функции описывает тип данного правила.

Подразумевается, что все ограничения накладываются на значение дополнительного поля Type (хотя оно явно и не указывается при описании).

Трансформация

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

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

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

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

Секция «transform to» предназначена для трансформации дерева разбора в другой язык. Обычно язык, в который производится трансформация, является более низкоуровневневым. Однако можно производить трансформацию в высокоуровневый язык, или даже в тот же самый язык. В последнем случае, по сути, мы имеем дело с макросами, аналогичными макросам Nemerle или Lisp.

Секция «transform to» задает трансформацию в один язык. Язык, в который производится трансформация, указывается непосредственно после ключевого слова «transform to»:

transform to Msil
{
  ... // код трасформации
}

Язык, в который производится трансформация, должен быть описан заранее. Это может быть как пользовательский язык, так и язык, предоставляемый N2.

Ниже приведен пример, преобразующий оператор if языка Nemerle в выражение match того же самого языка:

If is Expr = "if" "(" Condition ")" Expr1 "else" Expr2
{
  type
    type[R](Condition : #bool, Expr1 : +R, Expr2 : +R) : R;
    
  transform to Nemerle
    <[
      match ($Condition)
      {
        | true  => $Expr1
        | false => $Expr2
      }
    ]>
}

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

Ниже приведен пример, трансформирующий тот же оператор if сразу в MSIL:

If is Expr = "if" "(" Condition ")" Expr1 "else" Expr2
{
  type
    type[R](Condition : #bool, Expr1 : +R, Expr2 : +R) : R;
    
  transform to Msil
    <[
      $Condition
      brfalse.s Label_1;
      $Expr1;
      goto Label_2;
    Label_1:
      $Expr2;
    Label_2:
    ]>
}

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

ПРЕДУПРЕЖДЕНИЕ

Данный пример приведен для иллюстрации. В реальных условиях вряд ли понадобится генерировать низкоуровневый код вроде MSIL или ассемблера непосредственно по дереву разбора высокоуровневого языка (и тем более, DSL). N2 будет предоставлять языки промежуточного уровня. Генерация низкоуровневого кода (MSIL, ассемблер, байт-код Java, коды LLVM и т.п.) будет производиться по промежуточному языку, а высокоуровневые языки будут преобразовываться в языки промежуточного уровня.

Перегрузка синтаксических конструкций на основе информации о типах и синтаксических предикатов

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

Следующий пример демонстрирует описание оператора foreach языка Nemerle. В данном примере имеется два перегруженных варианта трансформации. Оба применяются в случае, когда коллекция является массивом. Первый вариант применяется, когда в foreach присутствует секция «whith», а второй – когда она отсутствует.

// позволяет ссылаться на выражения языка Nemerle по коротким именам
using N2.Languages.Nemerle.Expr;
  
ForEach is Expr = "foreach" '(' Pattern : Pattern "in" Collection ForEachWith? ')' Body
{
  type
  {
    type[T, Collection](Pattern : T, Collection : Collection, Body : #void) : #void
      where Collection : IEnumerable[T];
      
    type[T, Collection, Enumerator](el : T, collection : Collection, Body : #void) : #void
      where Collection { GetEnumerator() : Enumerator; },
            Enumerator { MoveNext() : bool; Current : T { get; }; };
              
    check (!(Pattern is Pattern.Variable) && Body is MatchBody)
      error(Pattern, "Expected simple name. You can't use complex pattern "
                     "if 'foreach' body contains patterns.");
  }
    
  // если присутствует "with x" и тип подправила Collection - это массив
  transform to Nemerle when ForEachWith is Some(forEachWith) 
                         && Collection.Type =: #array[_]
  {
    <[
      def arr = $Collection;
      for (mutable i = 0; i < arr.Length; i++)
      {
        def current = arr[i];
        // генерируем переменную для счетчика
        def $(forEachWith.VarName) = i;
        $(Common.MakeForEachBody(Pattern, Body));
      }
    ]>
  }

  // если отсутствует "with x" и тип подправила Collection - это массив
  transform to Nemerle when ForEachWith is None
                         && Collection.Type =: #array[_]
  {
    <[
      def arr = $Collection;
      for (mutable i = 0; i < arr.Length; i++)
      {
        def current = arr[i];
        $(Common.MakeForEachBody(Pattern, Body));
      }
    ]>
  }
}
  
ForEachWith = "whith" VarName : Identifier;

...

// Обычный модуль Nemerle, содержащий общий код трасформации
module Common
{
  MakeForEachBody(Pattern : Pattern, body : Expr)
  {
    if (body is MatchBody) // если тело цикла содержит паттерны...
    {
      assert(Pattern is Identifier); // паттерн должен быть простым именем
      <[  match (current)
            ..$body ]>
    }
    else 
      <[  match (current)
          {
            | $Pattern => $body
            | _ => ()
          } ]>;
  }
}

Расширение внешних правил

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

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

Следующий пример расширяет конструкцию «foreach» (описанную в предыдущем разделе), добавляя для нее специализированный вариант типизации и трансформации:

syntax module MyExtentions
{
  extend ForEach
  {
    // если присутствует "with x" и тип подправила Collection - это список
    transform to Nemerle when ForEachWith is Some(forEachWith) && Collection.Type =: #list[_]
    {
      <[
        def loop(lst : list[_], i : int) : void
        {
          match (lst)
          {
            | current :: tail => 
              $(MakeForEachBody(Pattern, Body));
              loop(tail, i + 1)
              
            | [] | null => ()
          }
        }

        loop($Collection, 0);
      ]>
    }
}

Бэкэнды

Бэкэнды предназначены для реализации частей компиляторов и модулей поддержки IDE, зависящих от платформы. К таковым относятся:

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

Бэкэнды генерируют код с промежуточного или низкоуровневого языка, реализуемого средствами N2.

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

Реализация языка

Реализацией языка называется совокупность модулей, предоставляющих:

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

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

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

Также трансляция языка в самого себя может применяться для реализации в языке макросов (наподобие макросов Nemerle).

Список литературы

  1. http://nemerle.org
  2. http://ru.wikipedia.org/wiki/Nemerle


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