Сообщений 23    Оценка 280        Оценить  
Система Orphus

Язык Nemerle

Часть 3

Автор: Чистяков Владислав Юрьевич
Источник: RSDN Magazine #1-2010
Опубликовано: 25.07.2010
Исправлено: 10.12.2016
Версия текста: 1.0
Вывод результата лексического разбора на консоль
Сопоставление с образцом вариантных типов данных – образец «Конструктор»
Парсинг (Parsing) и вычисление выражений
Необязательные значения – тип option[T]
Описание функции parseAndEval
Подфункция loop
Развиваем пример «Кальуклятор»
Улучшаем диагностические сообщения калькулятора
Модификаторы доступа
Конструктор
Наследование конструкторов вариантных типов их вхождениями
И еще раз сопоставление с образцом
Тестируем результат
Приоритеты выражений
Рефакторинг
Устранение дублирования путем выделения кода в отдельную функцию
Лямбды
Частичное применение
Устранение дублирования кода с использованием множественных образцов
Продолжаем рефакторинг
И еще раз обработка ошибок
Последние «штрихи»
Модули... или еще один рефакторинг
Перегрузка
Встроенные DSL-и и пример калькулятора на базе PEG-парсера
Список литературы

Вывод результата лексического разбора на консоль

Инсталлятор Nemerle (для .Net 3.5) + интеграция с MS VS 2008

Код к статье

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

      def readInput()
{
  def inputString = ReadLine();
  
  unless (inputString == "")
  {
    deflexemes = lexer(inputString);
      
    WriteLine("Вы ввели:");
    
    foreach (lexemeinlexemes)
    {      | Token.Number(value)  => WriteLine(value);      | Token.Operator(name) => WriteLine(name);      | Token.Error          => WriteLine("Ошибка ввода!");    }
      
    readInput();
  }
}

Новая версия функции readInput передает считанную из консоли строку (переменная inputString) в функцию lexer. Функция lexer, как было описано раньше, разбирает строку и преобразует ее в список лексем. Этот список помещается в переменную lexemes. Далее элементы этого списка перебираются с помощью цикла foreach.

Макрос foreach вам уже знаком, однако в этот раз используется интересная возможность данного макроса, о которой я еще не упоминал. Дело в том, что макрос foreach организован весьма нетривиально. В частности, он позволяет использовать сокращенную форму сопоставления с образцом. Следующий код из функции readInput:

      foreach (lexeme in lexemes)
    {
      | Token.Number(value)  => WriteLine(value);
      | Token.Operator(name) => WriteLine(name);
      | Token.Error          => WriteLine("Ошибка ввода!");
    }

это сокращенная форма для:

      foreach (lexeme in lexemes)
      match (lexeme)
      {
        | Token.Number(value)  => WriteLine(value);
        | Token.Operator(name) => WriteLine(name);
        | Token.Error          => WriteLine("Ошибка ввода!");
      }

Сопоставление с образцом вариантных типов данных – образец «Конструктор»

Тело оператора match из приведенного выше примера содержит сопоставление с образцом, в котором образцами являются вхождения вариантов. Переменная lexeme в этом примере имеет тип Token, который является вариантным типом. Number, Operator и Error являются вхождениями вариантного типа Token, а значит являются подтипами (subtypes). Данный вид сопоставления с образцом позволяет выявить (распознать), какой конкретный подтип имеет объект типа Token, ссылка на который помещена в переменную lexeme. Если переменная lexeme указывает на вхождение типа Token.Number, то значение будет сопоставлено с образцом «Token.Number(value)» и соответственно, будет выполнено выражение «WriteLine(value);». Если переменная lexeme ссылается Token.Operator, будет выполнено выражение «WriteLine(name);», и соответственно, если переменная lexeme указывает на Token.Error, выполнится выражение «WriteLine("Ошибка ввода!");».

Думаю, что единственным непонятным моментом тут является сама запись вида «Number(value)». Это так называемый образец «конструктор». Суть этого вида образца заключается в том, что он описывает экземпляр некоторого объекта в виде конструктора, создающего такой объект. Например, образец:

| Token.Number(1)

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

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

Переменные, введенные в образце, видны только в рамках обработчиков вхождений оператора match, то есть они видны после «=>» и до следующего образца или до закрывающей фигурной сбоки оператора match.

Обратите внимание на то, что в образце «переменная» может использоваться любое имя, начинающееся с маленькой буквы или подчеркивания (большую букву в образцах компилятор Nemerle рассматривает как имя типа). Например, вхождение оператора match с образцом «Token.Number(value)» можно переписать следующим образом:

| Token.Number(val) => WriteLine(val);

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

Вопреки ожиданиям, образец «Token.Error» не является сокращенной записью для «Token.Error()». Такой вид образца называется «образцом проверки типа». Для вхождения Token.Error он ничем не отличается от образца «Token.Error()», так как данное вхождение не имеет полей. Но, скажем, для вхождений Token.Number или Token.Operator он будет отличаться от образца «конструктор», так как он всего лишь проверяет тип переданного в оператор match значения, не обращая внимание на значения его полей. Естественно, этот вид образца не вводит никаких переменных.

Вернемся к функции readInput. Если запустить новый вариант нашего примера на исполнения, ввести в консоль, например «3 + 4 * w», то на консоль будет выведено следующее:

3 + 4 * w
        ^
ожидается число или оператор
Вы ввели:
3
+
4
*
Ошибка ввода! 

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

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

Парсинг (Parsing) и вычисление выражений

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

      def parseAndEval(tokens : list[Token]) : option[int]
{
  def loop(firstValue : int, tokens : list[Token]) : option[int]
  {
    match (tokens)
    {
      | Token.Operator("+") :: Token.Number(secondValue) :: tail => loop(firstValue + secondValue, tail)
      | Token.Operator("-") :: Token.Number(secondValue) :: tail => loop(firstValue - secondValue, tail)
      | Token.Operator("*") :: Token.Number(secondValue) :: tail => loop(firstValue * secondValue, tail)
      | Token.Operator("/") :: Token.Number(0)           :: _    => None()
      | Token.Operator("/") :: Token.Number(secondValue) :: tail => loop(firstValue / secondValue, tail)
      | []                                                       => Some(firstValue)
      | _                                                        => None()
    }
  }
  
  match (tokens)
  {
    | Token.Number(n) :: tail => loop(n, tail)
    | _                       => None()
  }
}

и немного изменив readInput, чтобы вызвать функцию parseAndEval:

      def readInput()
{
  def inputString = ReadLine();
  
  unless (inputString == "")
  {
    def lexemes = lexer(inputString);
    
    match(parseAndEval(lexemes)){      | Some(value) => WriteLine(value);      | None        => WriteLine("Ошибка в выражении!");    }
      
    readInput();
  }
}

Необязательные значения – тип option[T]

Первое что должно броситься вам в глаза – это тип возвращаемого значения функции parseAndEval. Эта функция возвращает тип option[int].

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

Тип «option» – это вариантный тип данных, который объявлен в стандартной библиотеке Nemerle следующим образом:

        variant option[T]
{
  | None
  | Some { val : T; }
}

Вхождение «option[T].Some» используется, когда есть реальный результат, который требуется возвратить, а вхождение «option[T].None» – в случае, когда возвращать нечего (нужно показать, что результата нет).

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

option[int].Some(1)

или

option[int].None()

Естественно, что при этом можно задействовать вывод типов:

option[_].Some(1)
option[_].None()

или даже так:

option.Some(1)
option.None()

так как Nemerle умеет выводить не только типы параметров типов, но и количество параметров типов.

Внимательный читатель должен был заметить, что в коде функции parseAndEval вместо приведенных выше вариантов используются Some(...) и None(), то есть сам тип option вообще не указывается. Это происходит потому, что тип option неявно открыт (как будто в любой файл добавлен директива «using»).

Описание функции parseAndEval

Функция parseAndEval состоит из вложенной функции loop и оператора match:

        match (tokens)
  {
    | Token.Number(n) :: tail => loop(n, tail)
    | _                       => None()
  }

Первое вхождение оператора match:

    | Token.Number(n) :: tail => loop(n, tail)

распознает случай, когда первый токен в списке является числом (Token.Number). Это необходимо потому, что распознавание выражений является рекурсивной (или итеративной) операцией. На каждом шаге рекурсии выражение выглядит следующим образом:

«вычисленное значение» «оператор» «число»

Второе вхождение:

    | _                       => None()

Описывает случай, когда список токенов пуст или начинается не с Token.Number. Это является ошибочной ситуацией, что заставляет нас возвратить None(), означающее отсутствие результата.

Подфункция loop

Само выражение разбирается функцией loop. Она получает два параметра match :

На каждой итерации функция loop распознает один операторный токен (Token.Operator) и идущее за ним число (то есть токен Token.Number), и производит сложение, вычитание, умножение или деление (в зависимости от значения поля «name» в Token.Operator.

В операторе match имеются четыре вхождения:

| Token.Operator("+") :: Token.Number(secondValue) :: tail => loop(firstValue + secondValue, tail)

отличающиеся только оператором (см. выделенное).

Давайте разберем более подробно устройство образца, примененного в этих вхождениях:

Token.Operator("+") :: Token.Number(secondValue) :: tail

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

«первый элемент» :: «второй элемент» :: «конец списка»

Конец списка в данном образце связывается с переменной tail. Такое применение вы уже видели, так что не будем на нем останавливаться. А вот первый и второй элементы содержат вложенный образец «конструктор» вхождения вариантного типа. Первый элемент содержит образец, сопоставляющийся с оператором, например, «Token.Operator("+")». Значение поля «name» оператора жестко задано строковым литералом. Это значит, что данный образец может сопоставиться только с экземпляром типа Token.Operator, поле «name» которого содержит указанный литерал ("+","-","*" или "/"). Второй элемент должен сопоставляться с образцом «Token.Number(secondValue)». Это значит, что второй элемент должен иметь тип Token.Number, поле «value» которого может иметь любое значение. При этом значение поля «value» связывается с переменной «secondValue». Таким образом, внутри обработчика вхождения оператора match (то есть после =>) значение этого поля второго элемента списка будет доступно через переменную «secondValue».

Если приглядеться повнимательнее, то нетрудно понять насколько мощная вещь сопоставление с образцом! Судите сами. Одним образцом мы не только распознали, что список в данный момент времени состоит как минимум из двух элементов, но мы также смогли распознать, что первый элемент списка является оператором конкретного типа, а второй элемент – числом (Token.Number). Но и это еще не все! Кроме этого мы одновременно связали локальные переменные со значениями полей, необходимыми нам для расчетов. Таким образом, мы в один присест распознали довольно сложную структуру данных и получили доступ к необходимым нам значениям. Если бы этот код писался на одном из господствующих языков программирования (Java, C# или C++), то вместо одной лаконичной и легко читаемой строчки пришлось бы написать много нетривиального кода.

Вхождение:

    | Token.Operator("/") :: Token.Number(0) :: _ => None()

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

    | Token.Operator("/") :: Token.Number(secondValue) :: tail => loop(firstValue / secondValue, tail)

поэтому оно не допускает вычисления деления на ноль. Так как деление на ноль недопустимо, все выражение является некорректным. А так как выражение некорректно, то мы не может вернуть какое-либо значение. Вместо этого возвращается None(), что символизирует отсутствие результата.

Вхождение:

    | [] => Some(firstValue)

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

Наконец, если ни один образец не был сопоставлен, то мы имеем дело с некорректной последовательностью токенов, например, с двумя идущими подряд операторами (Token.Operator), или с двумя числами (Token.Number). Так как все корректные ситуации были распознаны другими паттернами, мы можем воспользоваться «_», чтобы одним махом распознать все остальные значения. Именно это и происходит в последнем вхождении оператора match:

    | _  => None()

Значение None() опять же символизирует отсутствие корректного результата.

Развиваем пример «Кальуклятор»

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

Введите в консоли выражение «2 * 3 + 4» и нажмите Enter. В следующей строке вы получите результат:

10

Неплохо!

Теперь попробуем ввести «2 * 3 + 4 + error». При этом мы получим:

2 * 3 + 4 + error
            ^
ожидается число или оператор
Ошибка в выражении!

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

Теперь попробуем ввести «2 * 3 + 4 + * 5 - 6 / 7». В результате наш калькулятор выведет сообщение:

2 * 3 + 4 + * 5 - 6 / 7
Ошибка в выражении!

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

Давайте улучшим наш калькулятор, добавив более точную диагностику ошибок.

Улучшаем диагностические сообщения калькулятора

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

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

        variant Token
{
  | Number   { value : int; }
  | Operator { name  : string; }
  | Error
  
  public StartPos : int;
}

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

Кроме того, поле помечено модификатором public (см. следующий раздел).

Общий синтаксис объявления поля таков:

пользовательские_атрибуты? модификатор_доступа? «mutable»? Идентификатор «:» тип («=» инициализирующее_выражение)?
ПРИМЕЧАНИЕ

Здесь и далее я буду приводить синтаксис в нотации EBNF.

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

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

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

«?» – означает, что идущее перед ним правило необязательное (может встречаться ноль или один раз).

Круглые скобки образуют подправило. К такому подправилу могут применяться перечисленные выше EBNF-операторы.

Описанная выше конструкция говорит, что поле может состоять из:

Если при описании члена типа не указан модификатор доступа, то по умолчанию считается, что член имеет модификатор доступа private (о модификаторах доступа см. следующий раздел). Это правило нарушается для полей вариантных типов и полей перечислений (о них мы поговорим позже). Для них по умолчанию задается публичный доступ (т.е. считается, что если модификатор доступа не задан, то задан модификатор «public»).

Модификатор «mutable» позволяет указать, что поле должно быть изменяемым. Это позволит менять значение поля везде, где к этому полю возможен доступ (что определяется модификаторами доступа).

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

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

Модификаторы доступа

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

В Nemerle поддерживаются следующие модификаторы доступа:

Таким образом, модификатор «public» у поля StartPos означает, что поле доступно где угодно.

Конструктор

Так как поле неизменяемое (не помечено как mutable), то изменить его невозможно, а его начальное значение может быть задано только при создании объекта (в нашем случае объекта типа Token). Инициализацией объектов (в том числе и вариантных типов) занимается специальный метод под названием «конструктор». Общий синтаксис конструктора таков:

пользовательские_атрибуты? модификатор_доступа? this «(» параметры? «)» блок

Здесь «this» – это ключевое слово.

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

Конструкторов может быть любое количество (от нуля до бесконечности). Если у типа нет конструктора, то компилятор создает конструктор без параметров с модификатором доступа «public».

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

        variant Token
{
  | Number   { value : int; }
  | Operator { name  : string; }
  | Error
  
  public this(startPos : int)  {    StartPos = startPos;  }public StartPos : int;
}

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

Как видите, конструктор имеет параметр «startPos», через который передается начальное значение для поля StartPos. Все что делает конструктор – копирует значение параметра в поле.

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

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

Наследование конструкторов вариантных типов их вхождениями

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

Например, если у нас есть вариантный тип X:

        variant X
{
  | A { z : int; y : string; }
  | B
}

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

для типа X.A:

        public
        this(z : int, y : string)
{
  this.z = z;
  this.y = y
}

для типа X.B:

        public
        this()
{
}

Если добавить в тип X конструктор:

        variant X
{
  | A { z : int; y : string; }
  | B

  publicthis(w : double)
  {
  }
}

то для типа X.A будет создан конструктор:

        public
        this(w : double, z : int, y : string)
{
  base(w);
  this.z = z;
  this.y = y
}

а для типа X.B:

        public
        this(w : double)
{
  base(w);
}
ПРИМЕЧАНИЕ

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

«base(w)» – это вызов конструктора базового типа (вхождение вариантного типа является подтипом вариантного типа, в котором оно объявлено). Если у типа есть базовый тип, то в конструкторе требуется вызвать один из конструкторов базового типа.

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

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

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

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

        def lexer(text : string) : list[Token]
{
  mutable index = 0;
  
  def peek() : char ...
  def read() : char ...
  def isDigit(ch)   ...

  def loop(res : list[Token]) : list[Token]
  {
    def number(ch : char, accumulator : int = 0) : int ...

    def error(startPos)
    {
      WriteLine(string(' ', index - 1) + "^");
      WriteLine("ожидается число или оператор");
      (Token.Error(startPos) :: res).Reverse()
    }
    
    def startPos = index;def ch       = read();

    match (ch)
    {
      | ' ' | '\t'            => loop(res) // игнорируем пробелы
      | '+' | '-' | '*' | '/' => loop(Token.Operator(startPos, ch.ToString()) :: res)
      | '\0'                  => res.Reverse()
      | _ when isDigit(ch)    => loop(Token.Number(startPos, number(ch)) :: res)
      | _                     => error(startPos)
    }
  }
  
  loop([])
}

def parseAndEval(tokens : list[Token]) : option[int]
{
  def loop(firstValue : int, tokens : list[Token]) : option[int]
  {
    match (tokens)
    {
      | Token.Operator("+") :: Token.Number(secondValue) :: tail => loop(firstValue + secondValue, tail)
      | Token.Operator("-") :: Token.Number(secondValue) :: tail => loop(firstValue - secondValue, tail)
      | Token.Operator("*") :: Token.Number(secondValue) :: tail => loop(firstValue * secondValue, tail)
      | Token.Operator("/") :: (Token.Number(0) as zeroNum) :: _ =>
        WriteLine(string(' ', zeroNum.StartPos) + "^");        WriteLine("Деление на ноль");
        None()
      
      | Token.Operator("/") :: Token.Number(secondValue) :: tail => loop(firstValue / secondValue, tail)

      | [] => Some(firstValue)
      
      | _ :: Token.Error :: _  | Token.Error :: _  => None() // сообщение об ошибке уже выведено!
      
      | unexpectedToken :: _  => 
        WriteLine(string(' ', unexpectedToken.StartPos) + "^");        WriteLine("Ожидается '+', '-', '*' или '/' и идущее за ним число");
        None()
    }
  }
  
  match (tokens)
  {
    | Token.Number(n) :: tail => loop(n, tail)
    | _                       => None()
  }
}

def readInput()
{
  def inputString = ReadLine();
  
  unless (inputString == "")
  {
    def lexemes = lexer(inputString);
    
    match (parseAndEval(lexemes))
    {
      | Some(value) => WriteLine(value);
      | None        => ();
    }
      
    readInput();
  }
}

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

В функции loop, вложенной в parseAndEval, изменения более существенны, но также весьма простые. Они сводятся к тому, что теперь, при разборе выражения, мы не только распознаем ошибочные ситуации, но и выводим осмысленные сообщения об ошибках с указанием конкретного места, в которых они случились. Информация о местоположении ошибок как раз и берется из нового поля «StartPos».

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

И еще раз сопоставление с образцом

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

Первая – это возможность задать два образца для одного вхождения оператора match:

| _ :: Token.Error :: _  | Token.Error :: _  => None() // сообщение об ошибке уже выведено!

В общем случае, для одного вхождения оператора match можно задать один или более образец. Если образцов более одного, то каждый последующий образец должен начинаться с вертикальной черты «|». За последним вхождением должен идти уже знакомый вам оператор «=>». Таким образом, в приведенном выше фрагменте присутствует два образца:

_ :: Token.Error :: _

и

Token.Error :: _

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

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

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

Вторая новая возможность – это задание имени части образца:

| Token.Operator("/") :: (Token.Number(0) aszeroNum) :: _ =>

Практически любую часть образца можно связать с именем. Точнее, с именем связывается выражение, сопоставляемое с частью образца, для которого задано имя. При связывании создается локальная переменная, к которой можно обращаться из обработчика вхождения оператора match. В данном случае имя «zeroNum» было связано со вторым токеном списка. Этот токен нужен для того, чтобы получить значение его позиции (значение его поля StartPos). Оно нужно для формирования сообщения об ошибке, указывающего на конкретное место строки, в котором обнаружена ошибка.

СОВЕТ

Скобки, в которые взята конструкция «Token.Number(0) as zeroNum», нужны для того чтобы компилятор мог понять, к чему относится оператор «as». Данные скобки не являются частью паттерна. Они просто помогают преодолеть приоритеты операторов.

Тестируем результат

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

1/0
  ^
Деление на ноль

1/w
  ^
ожидается число или оператор

4 / 2 + 5 7
          ^
Ожидается '+', '-', '*' или '/' и идущее за ним число

И конечно же, если ввести выражение, не содержащее ошибок, то будет выдан корректный результат:

4 / 2 + 5
7

Приоритеты выражений

Запустите наш калькулятор, введите следующее выражение и нажмите Enter:

4 + 3 * 2

Не удивлюсь, если ответ вас удивит:

14

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

4 + (3 * 2) = 4 + 6 = 10

Почему же наш калькулятор считает неверно? Ответ очевиден – он не учитывает те самые приоритеты операторов и рассматривает выражение иным (неверным с нашей точки зрения) образом:

(4 + 3) * 2 = 7 * 2 = 14

Давайте перепишем калькулятор так, чтобы он учитывал приоритеты.

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

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

Грамматику выражения можно рассматривать как набор сложений и вычитаний:

A + B - C + ...

Где A, B, C и т.д. – это операции умножения или деления. Причем такие операции умножения могут встречаться один или более раз. Если в выражении вообще не присутствует сложения, то это будет единственная операция умножения «A». Если в выражении присутствует одна операция сложения, то «A + B» и т.п. В терминах грамматики это можно описать так:

сложениеИлиВычитание = умножениеИлиДеление (('+' | '-') умножениеИлиДеление)*

Это правило можно описать на словах так. Каждое сложение можно рассматривать или как единичную операцию умножения, за которой ноль или более раз идет знак «+», или "-" и еще одна операция умножения.

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

умножениеИлиДеление = простоеВыражение (('*' | '/') простоеВыражение)*

Где «простоеВыражение» – это самое элементарное выражение в нашей грамматике. Пока что оно будет соответствовать токену числа (Token.Number в случае нашего калькулятора), но в дальнейшем мы расширим понятие «простого выражения», введя поддержку унарного минуса и выражений в скобках.

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

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

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

Вот реализация данной грамматики методом рекурсивного спуска:

      def parseAndEval(tokens : list[Token]) : option[int]
{
  def parseSimpleExpr(tokens : list[Token]) : option[int] * list[Token]
  {
    | Token.Number(value) :: tail => (Some(value), tail)
    | _                           => (None(),      tokens)
  }

  def parseMulOrDiv(tokens : list[Token]) : option[int] * list[Token]
  {
    def loop(firstValueOpt, tokens)
    {
      | (Some(first), Token.Operator("*") :: tokens) =>
        match (parseSimpleExpr(tokens))
        {
          | (Some(second), tokens) => loop(Some(first * second), tokens)
          | (None, _)              => (None(), tokens)
        }

      | (Some(first), Token.Operator("/") :: tokens) =>
        match (parseSimpleExpr(tokens))
        {
          | (Some(second), tokens) => loop(Some(first / second), tokens)
          | (None, _)              => (None(), tokens)
        }
        
      | (Some, tokens) => (firstValueOpt, tokens)
      | (None, tokens) => (None(), tokens)
    }
    
    loop(parseSimpleExpr(tokens))
  }

  def parseSumOrSub(tokens : list[Token]) : option[int] * list[Token]
  {
    def loop(firstValueOpt, tokens)
    {
      | (Some(first), Token.Operator("+") :: tokens) =>
        match (parseMulOrDiv(tokens))
        {
          | (Some(second), tokens) => loop(Some(first + second), tokens)
          | (None, _)              => (None(), tokens)
        }

      | (Some(first), Token.Operator("-") :: tokens) =>
        match (parseMulOrDiv(tokens))
        {
          | (Some(second), tokens) => loop(Some(first - second), tokens)
          | (None, _)              => (None(), tokens)
        }
        
      | (Some, tokens) => (firstValueOpt, tokens)
      | (None, tokens) => (None(), tokens)
    }
    
    loop(parseMulOrDiv(tokens))
  }
  
  parseSumOrSub(tokens)[0]
}

В этом коде есть только одно место, с которым вы еще не знакомы. Это строка:

  parseSumOrSub(tokens)[0]

а точнее, операция [0], применяемая к возвращаемому значению функции parseSumOrSub. Это операция индексирования. В общем-то она уже использовалась в данном примере для получения значения символа в строке. Эта операция также применима к кортежам. Только в отличие от применения индексирования к строкам, при индексировании кортежей в качестве индексов можно использовать исключительно целочисленные константы (точнее, литерал). Индекс со значением «0» позволяет получить первый элемент кортежа, со значением «1» – второй, и так далее. Значение индекса проверяется во время компиляции, так что невозможно передать в него значение большее, чем количество элементов в кортеже минус единица и меньшее нуля.

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

ПРИМЕЧАНИЕ

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

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

Внутри функции parseAndEval имеется три вложенных функции, соответствующие трем описанным мною выше правилам:

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

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

Функция parseSimpleExpr совсем проста:

      def parseSimpleExpr(tokens : list[Token]) : option[int] * list[Token]
  {
    | Token.Number(value) :: tail => (Some(value), tail)
    | _                           => (None(),      tokens)
  }

Она проверяет, является ли первый токен в списке числом (то есть имеет ли он тип Token.Number), и если это так, возвращает значение числа, обернутое в Some, и остаток списка, который связывается образцом с переменной tail.

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

Две остальные функции более сложны, но организованы сходным образом. Они состоят из вызова другого правила (parseMulOrDiv для parseSumOrSub и parseSimpleExpr для parseSumOrSub), результаты разбора которого передаются в функцию loop.

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

loop(parseMulOrDiv(tokens))

в отсутствие данной возможности пришлось бы описывать следующим образом:

      def (valueOpt, tokens) = parseMulOrDiv(tokens);

loop(valueOpt, tokens)

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

Функция loop (ниже приведена ее версия из parseSumOrSub) организует цикл, описанный в правилах (см. выше).

      def loop(firstValueOpt, tokens)
{
  | (Some(first), Token.Operator("+") :: tokens) =>
    match (parseMulOrDiv(tokens))
    {
      | (Some(second), tokens) => loop(Some(first + second), tokens)
      | (None, _)              => (None(), tokens)
    }

  | (Some(first), Token.Operator("-") :: tokens) =>
    match (parseMulOrDiv(tokens))
    {
      | (Some(second), tokens) => loop(Some(first - second), tokens)
      | (None, _)              => (None(), tokens)
    }
    
  | (Some, tokens) => (firstValueOpt, tokens)
  | (None, tokens) => (None(), tokens)
}

Образец:

(Some(first), Token.Operator("+") :: tokens)

сопоставляется, если значение параметра firstValueOpt содержит корректное значение (то есть является вхождением Some), и первым элементом списка является оператор сложения. В этом случае вызывается вложенное правило (в данном случае parseMulOrDiv), результат которого также сопоставляется с образцом. Если разбор вложенного правила прошел успешно, выполняется вхождение match:

| (Some(second), tokens) => loop(Some(first + second), tokens)

В его обработчике первое значение складывается со вторым и снова запаковывается в Some. Затем оно вместе с остатком списка токенов передается рекурсивному вызову функции loop.

Если parseMulOrDiv потерпит неудачу, в первом элементе кортежа будет возвращен None(), что приведет к сопоставлению со следующим вхождением:

| (None, _)              => (None(), tokens)

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

Рефакторинг

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

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

Давайте же проведем рефакторинг нашего проекта калькулятора. Целью рефакторинга будет устранение дублирования кода. Для начала нужно выявить части кода, которые дублируются. Начнем с вложенной функции loop (из функции parseSumOrSub):

        def loop(firstValueOpt, tokens)
{
  | (Some(first), Token.Operator("+") :: tokens) =>    match (parseMulOrDiv(tokens))    {      | (Some(second), tokens) => loop(Some(first + second), tokens)      | (None, _)              => (None(), tokens)    }

  | (Some(first), Token.Operator("-") :: tokens) =>
    match (parseMulOrDiv(tokens))
    {
      | (Some(second), tokens) => loop(Some(first - second), tokens)
      | (None, _)              => (None(), tokens)
    }
    
  | (Some, tokens) => (firstValueOpt, tokens)
  | (None, tokens) => (None(), tokens)
}

Выделенный фрагмент дублируется с фрагментом, идущим ниже. Кроме того, он дублируется с аналогичным кодом из функции parseMulOrDiv, за исключением вызова (parseMulOrDiv), строкового литерала в образце ("+" / "-" / "*" / "/") и собственно операций, выполняемых над first и second.

Для устранения этого дублирования можно сделать одно из двух.

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

Устранение дублирования путем выделения кода в отдельную функцию

Великолепная поддержка функционального стиля программирования в Nemerle позволяет выделять в отдельные функции практически любые фрагменты кода.

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

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

Вот как функция parseSumOrSub может выглядеть после выноса указанного фрагмента в отдельную функцию parseOperation.

        def parseOperation(first, subRuleParser, tokens, operator, loop)
{
  match (subRuleParser(tokens))
  {
    | (Some(second), tokens) => loop(Some(operator(first, second)), tokens)
    | (None, _)              => (None(), tokens)
  }
}
def parseSumOrSub(tokens : list[Token]) : option[int] * list[Token]
{
  def loop(firstValueOpt, tokens)
  {
    | (Some(first), Token.Operator("+") :: tokens) =>
      defplus(a, b) { a + b }
      parseOperation(first, parseMulOrDiv, tokens, plus, loop)

    | (Some(first), Token.Operator("-") :: tokens) =>
      defminus(a, b) { a + b }
      parseOperation(first, parseMulOrDiv, tokens, minus, loop)
      
    | (Some, tokens) => (firstValueOpt, tokens)
    | (None, tokens) => (None(), tokens)
  }
  
  loop(parseMulOrDiv(tokens))
}

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

Самым простым и показательным примером является параметр operator функции parseOperation. Он позволяет абстрагироваться от конкретного оператора.

ПРИМЕЧАНИЕ

Запомните и научитесь применять этот подход. Конечно, это не панацея, но он обеспечивает невероятную гибкость.

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

Лямбды

Лямбды – это безымянная функция, объявляемая по месту. Nemerle поддерживает два варианта синтаксиса лямбд:

«fun» «(» аргументы «)»  («:» тип_возвращамого_значенич)? блок

и

«(» аргументы «)» «=>» выражение | аргумент «=>» выражение
ПРИМЕЧАНИЕ

Знак «|» в грамматике означает альтернативу «или». Это значит, что синтаксис лямбды может начинаться или с одного аргумента, или с любого количества аргументов, заключенных в скобки.

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

        def plus(a, b) { a + b }
parseOperation(first, parseMulOrDiv, tokens, plus, loop)

можно будет написать:

parseOperation(first, parseMulOrDiv, tokens, fun(a, b) { a + b }, loop)

Фактически лямбды преобразуются компилятором в локальные функции, так что в итоге в код будет подставлено нечто вроде:

parseOperation(first, parseMulOrDiv, tokens, 
  { def уникальное_имя(a, b) { a + b }; уникальное_имя }, loop)

Так что можете смело считать лямбды синтаксическим сахаром к локальным функциям.

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

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

parseOperation(first, parseMulOrDiv, tokens, (a, b) => a + b, loop)

Если лямбды не содержит параметров, то указываются пустые скобки:

() => ...

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

x => x + 1

При использовании обоих типов синтаксиса можно указывать (или не указывать) тип параметров:

        fun(a : int, b : int) { a + b }
(a : int, b : int) => a + b

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

        fun(a : int, b : int) : int { a + b }

В Nemerle это не является особой проблемой, так как, во-первых, практически всегда выручает вывод типов, а во-вторых, тип можно указать прямо в выражении:

(a, b) => (a + b) : int

Частичное применение

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

В случаях, когда нужно превратить в функцию отдельный оператор или отдельный вызов функции, в Nemerle можно использовать «частичное применение» (partial application). Суть этой возможности очень проста. Если мы имеем некоторый бинарный оператор, например, оператор «+», то его можно превратить в функцию, просто подставив вместо операндов подстановочный символ «_». Таким образом, чтобы получить аналог приведенных выше лямбд, нужно всего лишь написать:

_ + _

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

parseOperation(first, parseMulOrDiv, tokens, _ + _, loop)

Чтобы получить функцию, аналогичную функции «инкремент» (x => x + 1), можно задать в качестве одного из операндов конкретное значение – единицу:

_ + 1

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

Позже я покажу, как частичное применение может применяться к функциям и методам.

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

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

| (Some(first), Token.Operator("+") :: tokens) withoperator = _ + _
| (Some(first), Token.Operator("-") :: tokens) withoperator = _ - _ =>
  match (parseMulOrDiv(tokens))
  {
    | (Some(second), tokens) => loop(Some(operator(first, second)), tokens)
    | (None, tokens)         => (None(), tokens)
  }

Если в первом случае (когда дублирующийся код выносился в функцию) абстрагирование от конкретного оператора происходило за счет передачи функции-оператора через параметр локальной функции, то в данном случае используется переменная «operator», которая вводится с помощью ключевого слова «with».

Обратите внимание, что для каждого образца задается свое значение переменной. Для образца, распознающего оператор «+», переменная принимает значение «_ + _», а для образца, распознающего оператор «-» – «_ - _».

Задать тип для переменной, вводимой через «with», невозможно. Компилятор будет сам выводить их типы, исходя из типов инициализирующих выражений (идущих непосредственно за «=»). Если типы выражений различаются, компилятор попытается вывести наибольший общий тип (о том, что это означает, вы узнаете чуть позже), а если это не удастся, то компилятор выдаст сообщение об ошибке.

В данном примере тип переменной будет выведен как:

        int * int -> int

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

Продолжаем рефакторинг

Мы устранили дублирование небольшого фрагмента кода внутри parseSumOrSub, но у нас осталось дублирование довольно большого участка кода. Я говорю о вложенных функциях loop в функциях parseSumOrSub и parseMulOrDiv.

Точно так же, как и в случае выноса части функциональности в функцию parseOperation, мы можем вынести код функций loop из функций parseSumOrSub и parseMulOrDiv, предварительно абстрагировав его.

Однако это не так просто, так как в этих функциях различаются образцы:

| (Some(first), Token.Operator("+") :: tokens) with operator = _ + _
| (Some(first), Token.Operator("-") :: tokens) with operator = _ - _ =>

и

| (Some(first), Token.Operator("*") :: tokens) with operator = _ * _
| (Some(first), Token.Operator("/") :: tokens) with operator = _ / _ =>

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

Чтобы устранить эту проблему, придется вынести код проверки типа оператора (т.е. строковых литералов "+", "-", "*" и "/") в защитники (guard) образцов, а сами значения передавать в качестве параметров. Вот как выглядит такое решение:

      def loop(input : option[int] * list[Token], parseSubExpr, op1, f1, op2, f2)
{
  match (input)
  {
    | (Some(first), Token.Operator(op) :: tokens) whenop == op1with f = f1
    | (Some(first), Token.Operator(op) :: tokens) whenop == op2with f = f2 =>
      match (parseSubExpr(tokens))
      {
        | (Some(second), tokens) => 
          loop((Some(f(first, second)), tokens), parseSubExpr, op1, f1, op2, f2)
          
        | (None, _)              => (None(), tokens)
      }

    | (Some as firstOpt, tokens) => (firstOpt, tokens)
    | (None, tokens)             => (None(), tokens)
  }
}

def parseSimpleExpr(tokens : list[Token]) : option[int] * list[Token]
{
  | Token.Number(value) :: tail => (Some(value), tail)
  | _                           => (None(),      tokens)
}
def parseMulOrDiv(tokens : list[Token]) : option[int] * list[Token]
{
  loop(parseSimpleExpr(tokens), parseSimpleExpr, "*", _ * _, "/", _ / _)
}
def parseSumOrSub(tokens : list[Token]) : option[int] * list[Token]
{
  loop(parseMulOrDiv(tokens), parseMulOrDiv, "+", _ + _, "-", _ - _)
}

В параметры op1 и op2 передаются строки, описывающие операторы, а в f1 и f2 функции соответствующие этим операторам. Их названия сокращены для краткости.

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

(Some(first), Token.Operator(op) :: tokens)

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

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

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

И еще раз обработка ошибок

Новая версия функции parseAndEval, в отличие от старой, вычисляет выражения с учетом приоритетов. Однако в ней снова нет вывода сообщений об ошибках, что является деградацией функциональности. Давайте исправим этот недочет. Для этого придется изменить функции parseSimpleExpr и parseMulOrDiv:

        def parseSimpleExpr(tokens : list[Token]) : option[int] * list[Token]
{
  | Token.Number(value) :: tail => (Some(value), tail)
  | Token.Error :: _            => (None(), tokens)  | unexpectedToken :: _        =>    WriteLine(string(' ', unexpectedToken.StartPos) + "^");    WriteLine("Ожидается число");    (None(), tokens)  | _                           =>    WriteLine("В конце выражения ожидается число");    (None(), tokens)
  
}
def parseMulOrDiv(tokens : list[Token]) : option[int] * list[Token]
{
  def div(a, b)  {    if (b == 0)    {      WriteLine("деление на 0");      0    }    else a / b  }

  loop(parseSimpleExpr(tokens), parseSimpleExpr, "*", _ * _, "/", div)
}

Тот факт, что в качестве параметра функции loop передаются ссылки на функции, позволил создать расширенную функцию, отвечающую за деление. В этой функции осуществляется проверка значения делителя. Если делитель равен нулю, то выводится сообщение об ошибке и возвращается «0».

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

Давайте еще раз изменим наш код, чтобы устранить эту проблему. Для этого нам нужно сделать две версии функции loop, одна из которых будет реализована посредством другой. Одна версия будет работать так же, как старая, а вторая будет отличаться типом функций f1 и f2. Исходный тип этих функций – «int * int -> int». В новой версии loop эти параметры будут иметь тип «int * int * int -> option[int]». Через новый (последний) параметр будет передаваться позиция текущего токена, а оборачивание возвращаемого значения в option[] позволит сообщить вызывающей функции, что вычисление закончилось неудачей, или вернуть результат, завернутый в Some, если вычисление завершилось успешно.

Так как локальные функции в Nemerle переопределяют ранее определенные имена, вторую функцию «loop» придется назвать как-то иначе. Не будем мудрствовать и просто добавим к ее названию «2». Вот как будет выглядеть новый вариант функции parseAndEval:

        def parseAndEval(tokens : list[Token]) : option[int]
{
  def loop2(input : option[int] * list[Token], parseSubExpr, op1, f1, op2, f2)
  {
    match (input)
    {
      | (Some(first), Token.Operator(op) as tok :: tokens) when op == op1 with f = f1
      | (Some(first), Token.Operator(op) as tok :: tokens) when op == op2 with f = f2 =>
        match (parseSubExpr(tokens))
        {
          | (Some(second), tokens) => 
            loop2((f(first, second, tok.StartPos), tokens), parseSubExpr, op1, f1, op2, f2)
            
          | (None, _)              => (None(), tokens)
        }

      | (Some as firstOpt, tokens) => (firstOpt, tokens)
      | (None, tokens)             => (None(), tokens)
    }
  }
  def loop(input : option[int] * list[Token], parseSubExpr, op1, f1, op2, f2)
  {
    loop2(input, parseSubExpr, op1, (a, b, _) => Some(f1(a, b)), 
                               op2, (a, b, _) => Some(f2(a, b)))
  }
  def parseSimpleExpr(tokens : list[Token]) : option[int] * list[Token]
  {
    | Token.Number(value) :: tail => (Some(value), tail)
    | Token.Error :: _            => (None(), tokens)
    | unexpectedToken :: _        =>
      WriteLine(string(' ', unexpectedToken.StartPos) + "^");
      WriteLine("Ожидается число");
      (None(), tokens)
    
    | _                           =>
      WriteLine("В конце выражения ожидается число");
      (None(), tokens)
    
  }
  def parseMulOrDiv(tokens : list[Token]) : option[int] * list[Token]
  {
    def div(a, b, currentTokenPos)
    {
      if (b == 0)
      {
        WriteLine(string(' ', currentTokenPos) + "^");
        WriteLine("деление на 0");
        None()
      }
      elseSome(a / b)
    }

    loop2(parseSimpleExpr(tokens), parseSimpleExpr, "*", (a, b, _) => Some(a * b), "/", div)
  }
  def parseSumOrSub(tokens : list[Token]) : option[int] * list[Token]
  {
    loop(parseMulOrDiv(tokens), parseMulOrDiv, "+", _ + _, "-", _ - _)
  }
  
  parseSumOrSub(tokens)[0]
}

Чтобы лучше понять суть происходящего, давайте опишем типы функций loop2, loop и div явно.

        def loop2(
  input          : option[int] * list[Token],
  parseSubExpr   : list[Token] -> option[int] * list[Token],
  op1            : string,
  f1             : int * int * int -> option[int],
  op2            : string,
  f2             : int * int * int -> option[int]
)
def loop(
  input          : option[int] * list[Token],
  parseSubExpr   : list[Token] -> option[int] * list[Token],
  op1            : string,
  f1             : int * int -> int,
  op2            : string,
  f2             : int * int -> int
)
def div(a : int, b : int, currentTokenPos : int) : option[int]

Я выделил отличия функции loop2 и loop, чтобы вам была более четко видна разница между ними.

Функция loop реализована через loop2. Для параметров f1 и f2 используются простенькие лямбды-адапторы:

(a, b, _) => Some(f1(a, b))

преобразующие функцию типа «int * int -> int» в функцию типа «int * int * int -> option[int]».

Третий параметр лямбд не используется. Чтобы компилятор не выдавал диагностических сообщений по этому поводу, вместо имени последнего параметра задан символ-заместитель «_». Его можно использовать, даже если неиспользуемых параметров несколько. Компилятор, встретив «_» в качестве имени параметра, будет генерировать для каждого параметра уникальное имя вида «_N_wilcard_1234», где вместо 1234 будет уникальный номер. В IDE такие параметры не будут видны, так что сослаться на них будет невозможно. Если параметр не используется в функции, но хочется видеть его значение в отладчике, то можно дать такому параметру имя, начинающееся с подчеркивания:

(a, b, _currentTokenPos) => Some(f1(a, b))

Последние «штрихи»

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

Это совсем не сложно.

Чтобы реализовать поддержку унарного минуса, нужно всего лишь немного расширить функцию parseSimpleExpr, введя в нее еще одно вхождение оператора match:

      def parseSimpleExpr(tokens : list[Token]) : option[int] * list[Token]
{
  | Token.Operator("-") :: tail => 
    match (parseSimpleExpr(tail))
    {
      | (Some(value), tokens) => (Some(-value), tokens)
      | failResult            => failResult
    }
...

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

-1 + -2 = -3
--1 + 2 = 3

Несколько сложнее добавить поддержку скобок. Первое, что для этого нужно сделать – добавить новые типы токенов – OpenBrace (открывающая скобка) и CloseBrace (закрывающая скобка).

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

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

Я недаром сказал «почти все». Дел в том, что функция parseSumOrSub объявлена ниже функции parseSimpleExpr, а значит, не будет в ней видна (доступна). Переставить эти функции местами мы также не можем, так как parseSumOrSub использует функцию parseMulOrDiv, а та в свою очередь использует parseSimpleExpr. Получается, что наши функции взаимно рекурсивны. Чтобы локальные функции могли обращаться не только к локальным функциям, объявленным выше, но и к функциям, объявленным за ними, можно явно объявить несколько функций как взаимно рекурсивные. Для этого вторую и последующие взаимно-рекурсивные функции нужно объявлять не с помощью ключевого слова «def», а с помощью ключевого слова «and»:

      def parseSimpleExpr(tokens : list[Token]) : option[int] * list[Token]
{
  ...
}
and parseMulOrDiv(tokens : list[Token]) : option[int] * list[Token]
{
  ...
}
and parseSumOrSub(tokens : list[Token]) : option[int] * list[Token]
{
  ...
}

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

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

      using Nemerle.Collections;
using Nemerle.Text;
using Nemerle.Utility;

using System;
using System.Collections.Generic;
using System.Console;
using System.Linq;

variant Token
{
  | Number   { value : int; }
  | Operator { name  : string; }
  | OpenBrace  | CloseBrace
  | Error

  publicthis(startPos : int)
  {
    StartPos = startPos;
  }
  
  public StartPos : int;
}

module Program
{
  Main() : void
  {
    def lexer(text : string) : list[Token]
    {
      mutable index = 0;
      
      def peek() : char
      {
        if (index >= text.Length) '\0'else text[index]
      }

      def read() : char
      {
        def ch = peek();

        when (index < text.Length)
          index++;

        ch
      }

      def isDigit(ch) { ch >= '0' && ch <= '9' }

      def loop(res : list[Token]) : list[Token]
      {
        def number(ch : char, accumulator : int = 0) : int
        {
          def highOrderValue    = accumulator * 10;
          def currentOrderValue = ch - '0' : int;
          def currentValue      = highOrderValue + currentOrderValue;
      
          if (isDigit(peek()))
            number(read(), currentValue);
          else
            currentValue
        }

        def error(startPos)
        {
          WriteLine(string(' ', index - 1) + "^");
          WriteLine("ожидается число или оператор");
          (Token.Error(startPos) :: res).Reverse()
        }
        
        def startPos = index;
        def ch       = read();

        match (ch)
        {
          | '('                   => loop(Token.OpenBrace (startPos) :: res)          | ')'                   => loop(Token.CloseBrace(startPos) :: res)
          | ' ' | '\t'            => loop(res) // игнорируем пробелы
          | '+' | '-' | '*' | '/' => loop(Token.Operator(startPos, ch.ToString()) :: res)
          | '\0'                  => res.Reverse()
          | _ when isDigit(ch)    => loop(Token.Number(startPos, number(ch)) :: res)
          | _                     => error(startPos)
        }
      }
      
      loop([])
    }
  
    def parseAndEval(tokens : list[Token]) : option[int]
    {
      def loop2(input : option[int] * list[Token], parseSubExpr, op1, f1, op2, f2)
      {
        match (input)
        {
          | (Some(first), Token.Operator(op) as tok :: tokens) when op == op1 with f = f1
          | (Some(first), Token.Operator(op) as tok :: tokens) when op == op2 with f = f2 =>
            match (parseSubExpr(tokens))
            {
              | (Some(second), tokens) => loop2((f(first, second, tok.StartPos), tokens), parseSubExpr, op1, f1, op2, f2)
              | (None, _)              => (None(), tokens)
            }

          | (Some as firstOpt, tokens) => (firstOpt, tokens)
          | (None, tokens)             => (None(), tokens)
        }
      }
      def loop(input : option[int] * list[Token], parseSubExpr, op1, f1, op2, f2)
      {
        loop2(input, parseSubExpr, op1, (a, b, _) => Some(f1(a, b)), 
                                   op2, (a, b, _) => Some(f2(a, b)))
      }
      def parseSimpleExpr(tokens : list[Token]) : option[int] * list[Token]
      {
        | Token.Operator("-") :: tail => 
          match (parseSimpleExpr(tail))
          {
            | (Some(value), tokens) => (Some(-value), tokens)
            | failResult            => failResult
          }
          
        | Token.OpenBrace as openBraceTok :: tail =>           match (parseSumOrSub(tail))          {            | (Some(value), Token.CloseBrace :: tail) => (Some(value), tail)            | (Some, _) => // за подвыражением не идет закрывающей скобки!              WriteLine(string(' ', openBraceTok.StartPos) + "^");              WriteLine("Не закрытая скобка");              (None(), tokens)            | failResult => failResult          }
        
        | Token.Number(value) :: tail => (Some(value), tail)
        | Token.Error         :: _    => (None(), tokens)
        | unexpectedToken :: _        =>
          WriteLine(string(' ', unexpectedToken.StartPos) + "^");
          WriteLine("Ожидается число");
          (None(), tokens)
        
        | _                           =>
          WriteLine("В конце выражения ожидается число");
          (None(), tokens)
        
      }
      and parseMulOrDiv(tokens : list[Token]) : option[int] * list[Token]
      {
        def div(a, b, pos)
        {
          if (b == 0)
          {
            WriteLine(string(' ', pos) + "^");
            WriteLine("деление на 0");
            None()
          }
          else Some(a / b)
        }
        loop2(parseSimpleExpr(tokens), parseSimpleExpr, "*", (a, b, _) => Some(a * b), "/", div)
      }
      and parseSumOrSub(tokens : list[Token]) : option[int] * list[Token]
      {
        loop(parseMulOrDiv(tokens), parseMulOrDiv, "+", _ + _, "-", _ - _)
      }
      
      match (parseSumOrSub(tokens))
      {        | (None, _)                     => None()        // Если все выражение успешно разобрано,         // список токенов должен быть пуст!        | (Some as value, [])           => value        // отлавливает лишние открывающие скобки и т.п.        | (Some, unexpectedToken :: _)  =>           WriteLine(string(' ', unexpectedToken.StartPos) + "^");          WriteLine("Нераспозннаый токен");          None()      }
    }
    
    def readInput()
    {
      def inputString = ReadLine();
      
      unless (inputString == "")
      {
        def lexemes = lexer(inputString);
        
        match (parseAndEval(lexemes))
        {
          | Some(value) => WriteLine(value);
          | None        => ();
        }
          
        readInput();
      }
    }

    readInput();
  }
}
СОВЕТ

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

Особенно хорош этот метод в тандеме с применением модульного (Unit) и интеграционного тестирования.

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

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

Протестируем наш калькулятор:

2 * (3 + 4)
14
2 * (3 + 4
    ^
Не закрытая скобка
2 * (3 + 4))
           ^
Нераспозннаый токен
2 * (3 + -4)
-2
2 * (3 + -4) + 5
3

Модули... или еще один рефакторинг

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

Конечно же, можно просто скопировать код функций lexer и parseAndEval в новый проект, но это плохое решение. Ведь в дальнейшем может понадобиться изменить код калькулятора (например, добавить еще одну возможность или поправить ошибку). В результате придется либо изменять сразу несколько версий кода (в разных проектах), либо версии начнут различаться. Если вы опытный программист, то прекрасно понимаете, чем это чревато, в противном случае поверьте на слово: повторное использование кода методом Copy & Past – это самое плохое, что можно придумать в программировании.

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

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

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

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

Давайте преобразуем локальные функции lexer и parseAndEval в статические методы (глобальные функции) Lexer и ParseAndEval:

      using Nemerle.Collections;
using Nemerle.Text;
using Nemerle.Utility;

using System;
using System.Collections.Generic;
using System.Console;
using System.Linq;

public variant Token
{
  ...
}

public module CalcParser{  public Lexer(text : string) : list[Token]  {    ...  }  public ParseAndEval(tokens : list[Token]) : option[int]  {    ...  }}module Program
{
  Main() : void
  {
    def readInput()
    {
      def inputString = ReadLine();
      
      unless (inputString == "")
      {
        def lexemes = CalcParser.Lexer(inputString);
        
        match (CalcParser.ParseAndEval(lexemes))
        {
          | Some(value) => WriteLine(value);
          | None        => ();
        }
          
        readInput();
      }
    }

    readInput();
  }
}

Как всегда, изменения выделены. Кроме того, я не привожу тела методов CalcParser.Lexer и CalcParser.ParseAndEval, так как они полностью аналогичны телам соответствующих локальных функций.

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

Прошу обратить внимание на модификаторы доступа «public». Я уже описывал их выше, но упомянул, что они действуют на члены типов. Но это не вся правда. Модификаторы доступа действуют так же и на типы. «public» у методов означает, что методы можно использовать извне типа. «public» у типа означает, что тип (а значит, и все его публичные члены) можно использовать извне данной сборки.

По умолчанию (то есть, если модификатор доступа не задан явно) в модулях (а также классах и структурах) уровень доступа членов является «private». Таким образом, если мы хотим использовать некоторый член модуля извне, то нам нужно пометить его модификатором «public».

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

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

Кроме того, модификатором доступа «public» пришлось пометить и вариантный тип «Token». Это необходимо потому, что публичные методы модуля CalcParser используют тип в своей сигнатуре (описание функции без ее тела). Тип, используемый в сигнатуре публичных членов другого публичного типа, также обязан быть публичным. В противном случае компилятор выдаст сообщение об ошибке.

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

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

Перегрузка

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

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

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

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

Nemerle позволяет описать в одном типе две функции, имеющие одно имя, но различающиеся по числу и/или типу параметров.

Такой подход называется перегрузкой (overloading).

Ниже приведен немного измененный вариант калькулятора

        public
        variant Token
{
  ...
}

publicmodule CalcParser
{
  public Lexer(text : string) : list[Token]
  {
    ...
      def error(startPos)
      {
        Error = (startPos, "ожидается число или оператор");
        (Token.Error(startPos) :: res).Reverse()
      }
    ...
  }
  
  public ParseAndEval(tokens : list[Token]) : option[int]
  {
    ...
  }

  public ParseAndEval(text : string) : option[int]  {    ParseAndEval(Lexer(text))  }
}

module Program
{
  Main() : void
  {
    def readInput()
    {
      def inputString = ReadLine();
      
      unless (inputString == "")
      {
        match (CalcParser.ParseAndEval(inputString))
        {
          | Some(value) => WriteLine(value);
          | None        => ();
        }
          
        readInput();
      }
    }

    readInput();
  }
}

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

При этом из функции readInput удален вызов функции «Lexer», а «ParseAndEval» передается строка «inputString». Это приводит к тому, что вызывается вторая (по порядку расположения) функция «ParseAndEval».

Про функцию «ParseAndEval» говорят, что она перегружена. Точнее, перегружена не функция, а ее имя.

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

Встроенные DSL-и и пример калькулятора на базе PEG-парсера

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

В данном разделе хочется привести еще одну реализацию калькулятора. Эта реализация использует макрос PegGrammar. Этот макрос позволяет описать грамматику языка в виде PEG (Parsing Expression Grammar). PEG позволяет описывать формальные языки в терминах правил распознавания строк этих языков. По сути, этот язык описывает рекурсивный нисходящий парсер (похожий на тот, что мы писали вручную в предыдущих разделах). Поэтому этот язык очень хорошо подходит для описания парсеров и автоматической генерации кода парсеров по этим описаниям.

Чтобы создать парсер с использованием макроса PegGrammar, нужно сделать две вещи:

1. Описать грамматику языка (с учетом обработки ошибочных ситуаций).

2. Реализовать обработчики для именованных правил.

Описание языка (его правил) помещается в метаатрибут класса PegGrammar.

ПРИМЕЧАНИЕ

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

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

Обработчики оформляются в виде методов класса, который помечен макросом PegGrammar. Имена обработчиков должны соответствовать именам правил. Если парсер распознает некоторое правило, то он вызывает обработчик с соответствующим именем. Параметры обработчиков должны соответствовать подправилам обрабатываемого правила.

Правила делятся на терминальные и нетерминальные. Терминальные правила описывают символьные последовательности (можно сказать – «слова» языка). Они не могут быть рекурсивными. Нетерминальные правила описывают грамматику языка. Они могут быть сложными и рекурсивными. Например, число – это терминальный символ, операция сложения – нетерминальный. В PEG граница между терминальными правилами и нетерминальными весьма зыбкая. После распознавания парсером терминального правила создается объект типа Nemerle.Peg.NToken. В случае распознавания нетерминального правила создается объект Nemerle.Peg.VToken[T]. Упрощенно можно сказать, что NToken описывает распознанную последовательность символов, а VToken[T] – значение, формируемое обработчиком.

Если для правила имеется обработчик, то требуется описать тип возвращаемого значения этого правила. Он указывается непосредственно за именем правила (после двоеточия). В остальном синтаксис соответствует PEG-нотации. Я не буду вдаваться здесь в тонкости этой нотации. Скажу только что она очень похожа на EBNF. Основное отличие заключается в том, что вместо оператора «|», позволяющего перечислять равноправные альтернативные правила, в PEG используется так называемый оператор приоритетного выбора (ordered choice) – «/». Так, запись «правило1 / правило2» означает, что парсер сначала должен попытаться распознать правило1, и если это не удастся, попытаться распознать правило2. Таким образом, устраняется неоднозначность. Ведь если правило1 успешно распознается, то, несмотря на то, что правило2 также может его распознать, разбор признается успешным и попытка разобрать второе правило попросту не предпринимается.

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

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

Реализация парсера арифметических выражений с использованием макроса PegGrammar
      using Nemerle.Collections;
using Nemerle.Peg;
using Nemerle.Text;
using Nemerle.Utility;
using Nemerle;

using System;
using System.Collections.Generic;
using LRPEGCC;

namespace Calculator
{
  type LoopTokens = NToken * NToken * VToken[int];
  
  [Record] publicclass ParserFatalError : Exception
  {
    public Pos : int;
  }
  
  [PegGrammar(start,
  grammar
  {  
    any                   = ['\u0000'..'\uFFFF']; // распознают любой символ
    digit                 = ['0'..'9']+; // распознает число 
    spaces                = (' ' / '\t')*; // распознает пробельные сивоволы
    
    num                   : int = digit spaces;
    unaryMinus            : int = '-' spaces simplExpr;
    parenthesesExpr       : int = '(' spaces sumOrSub ')' spaces;
    parenthesesExprError  : int = '(' spaces sumOrSub (any / !any);
    simplExpr             : int = num / parenthesesExpr / unaryMinus 
                                  / parenthesesExprError / simplExprError;
    simplExprError        : int = any;
    inputError            : int = any;
    mulOrDiv              : int = simplExpr (('*' / '/') spaces simplExpr)*;
    sumOrSub              : int = mulOrDiv  (('+' / '-') spaces mulOrDiv )*;
    mainRule              : int = sumOrSub inputError?;
    start                 : int = spaces mainRule !any;
  })]
  publicclass CalcParser
  {    
    private num(digit : NToken, _ : NToken) : int
    {
      int.Parse(digit.GetText())
    }
    
    private unaryMinus(_ : NToken, _ : NToken, simplExpr : VToken[int]) : int
    {
      - simplExpr.Value
    }
    
    private parenthesesExpr(_ : NToken, _ : NToken, simplExpr : VToken[int], 
                            _ : NToken, _ : NToken) : int
    {
      simplExpr.Value
    }
    
    private parenthesesExprError(_ : NToken, _ : NToken, last : VToken[int], 
                                 _ : NToken) : int
    {
      throw ParserFatalError("Ожидается закрывающая скобка или "
        + "'+', '-', '*', '/', за которым следует число или выражение",
        last.EndPos);
    }
    
    private inputError(tok : NToken) : int
    {
      throw ParserFatalError("Ожидается '+', '-', '*', '/', за которым "
        + "следует число или выражение", tok.StartPos);
    }
    
    private simplExprError(tok : NToken) : int
    {
      throw ParserFatalError("Ожидается число или выражение в скобках", 
        tok.StartPos);
    }
    
    private mainRule(expr : VToken[int], _ : option[VToken[int]]) : int
    {
      expr.Value
    }

    private simplExpr(expr : VToken[int]) : int
    {
      expr.Value
    }
    
    private start(_ : NToken, expr : VToken[int], _ : NToken) : int
    {
      expr.Value
    }
    
    private mulOrDiv(firstExpr : VToken[int], lst : List[LoopTokens]) : int
    {
      DoOpHelper(firstExpr, lst)
    }
    
    private sumOrSub(firstExpr : VToken[int], lst : List[LoopTokens]) : int
    { 
      DoOpHelper(firstExpr, lst)
    }
     
    private DoOpHelper(firstExpr : VToken[int], lst : List[LoopTokens]) : int
    {
      def doOp(x : int, y : int, op : string) : int
      {
        match (op)
        {
          | "*" => x * y
          | "/" => x / y
          | "+" => x + y
          | "-" => x - y
          | _   => assert(false);
        }
      }
           
      mutable r = firstExpr.Value;
      
      foreach ((opTok, _, secondTok) in lst)
        r = doOp(r, secondTok.Value, opTok.GetText());
    
      r  
    }
  }
}
      using Calculator;
using System.Console;

module Program
{
  Main() : void
  {
    def calcParser = CalcParser();
    
    def calc(text : string) : void
    {

      try
      {
        match (calcParser.Parse(text))
        {
          | Some(result) => WriteLine(result);
          | None         => ()
        }
      }
      catch 
      { | e is ParserFatalError => 
          WriteLine(string(' ', e.Pos) +  "^");
          WriteLine(e.Message);
      }
    }
    
    def readInput()
    {
      def res = ReadLine();
      
      unless (res == "")
      {
        calc(res);
        readInput();
      }
    }
    
    readInput();
  }
}

Как видите, код парсера состоит из двух частей:

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

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

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

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

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

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

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

Исключения позволяют прервать нормальный ход выполнения программы и передать управление в обработчик исключений или прервать выполнение программы (если соответствующих исключений нет). Генерация (возбуждение) исключений производится с помощью оператора throw. Обработка исключений производится с помощью оператора try/catch.

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

      throw ParserFatalError(...)

управление сразу же переходит в обработчик исключения:

      catch 
      { | e is ParserFatalError => 

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

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

  1. www.nemerle.org
  2. В.Ю.Чистяков, Язык Nemerle, часть 1 // RSDN Magazine. М.: К-Пресс, 2009 – №2. – стр. 36-49. http://rsdn.ru/article/Nemerle/TheNemerleLanguage.xml
  3. В.Ю.Чистяков, Язык Nemerle, часть 2 // RSDN Magazine. М.: К-Пресс, 2009 – №3. – стр. 55-70. http://rsdn.ru/article/Nemerle/TheNemerleLanuage-prt-2.xml


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