Учебное пособие по Nim (часть 2)

Original author: Andreas Rumpf
  • Translation
  • Tutorial
Примечание от переводчика
Первая часть находится здесь: «Учебное пособие по Nim (часть 1)»

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


Введение


«Повторение придаёт нелепости вид благоразумия.» – Норман Вайлдбергер

(в оригинале: "Repetition renders the ridiculous reasonable." – Norman Wildberger)

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

Прагмы


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

Объектно-ориентированное программирование


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

Объекты


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

Объекты имеют доступ к своему типу во время выполнения. Существует оператор of, при помощи которого можно проверить тип объекта:

type
  Person = ref object of RootObj
    name*: string  # эта * означает, что `name` будет доступно из других модулей
    age: int       # а это поле будет недоступно из других модулей

  Student = ref object of Person # Student унаследован от Person
    id: int                      # с дополнительным полем id

var
  student: Student
  person: Person
assert(student of Student) # вернёт true
# конструируем объект:
student = Student(name: "Anton", age: 5, id: 2)
echo student[]

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

Наследование делается с помощью синтаксиса object of. Множественное наследование на данный момент не поддерживается. Если для объектного типа нет подходящего предка, то можно сделать предком RootObj, но это всего лишь соглашение. Объекты, не имеющие предка, неявно объявляются как final. Чтобы ввести новый объект, не унаследованный от system.RootObj, можно использовать прагму inheritable (это используется, например, в обёртке GTK).

Ссылочные объекты могут использоваться независимо от наследования. Это не строго обязательно, но в случае присвоения не-ссылочных объектов, например, let person: Person = Student(id: 123) поля дочернего класса будут обрезаны.
Примечание: для простого повторного использования кода композиция (отношение «входит в состав») часто предпочтительнее наследования (отношение «является»).. Поскольку объекты в Nim являются типами-значениями, композиция столь же эффективна, как и наследование.

Взаимно рекурсивные типы


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

Пример:

type
  Node = ref NodeObj # отслеживаемая ссылка на NodeObj
  NodeObj = object
    le, ri: Node     # левое и правое поддеревья
    sym: ref Sym     # листья, содержащие ссылку на Sym

  Sym = object       # символ
    name: string     # имя символа
    line: int        # строка, в которой символ был объявлен
    code: PNode      # абстрактное синтаксическое дерево символа

Преобразование типов


Nim различает приведение типов (type casts) и преобразование типов (type conversions). Приведение делается при помощи оператора cast и заставляет компилятор интерпретировать двоичные данные как указанный тип.

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

Синтаксис для преобразования типов выглядит так: destination_type(expression_to_convert) (напоминает обычный вызов).

proc getID(x: Person): int =
  Student(x).id

Если x не является экземпляром Student, то будет выброшено исключение InvalidObjectConversionError.

Вариантные объекты


Бывают ситуации, для которых объектная иерархия – излишество, и всё можно решить простыми вариантными типами.

Например:

# Это пример того, как абстрактное синтаксическое дерево могло бы быть
# смоделировано в Nim
type
  NodeKind = enum  # типы для различных узлов
    nkInt,          # лист с числовым значением
    nkFloat,        # лист со значением с плавающей запятой
    nkString,       # лист со строковым значением
    nkAdd,          # сложение
    nkSub,          # вычитание
    nkIf            # команда if
  Node = ref NodeObj
  NodeObj = object
    case kind: NodeKind  # поле ``kind`` является дискриминатором
    of nkInt: intVal: int
    of nkFloat: floatVal: float
    of nkString: strVal: string
    of nkAdd, nkSub:
      leftOp, rightOp: PNode
    of nkIf:
      condition, thenPart, elsePart: PNode

var n = PNode(kind: nkFloat, floatVal: 1.0)
# следующая команда вызовет исключение `FieldError`, поскольку значение
# n.kind не соответствует:
n.strVal = ""

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

Методы


В обычных объектно-ориентированных языках процедуры (также называемые методами) привязаны к классу. У этого подхода есть следующие недостатки:
  • добавив метод к классу, программист либо теряет над ним контроль, либо городит корявые обходные пути, если надо работать с методом отдельно от класса;
  • часто бывает неясно, к чему должен относиться метод: join это метод строки или массива?

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

Синтаксис вызова методов


Для вызова подпрограмм в Nim есть особый синтаксический сахар: конструкция obj.method(args) означает то же, что и method(obj, args). Если аргументов нет, то скобки можно пропустить: obj.len вместо len(obj).

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

echo("abc".len) # то же, что и echo(len("abc"))
echo("abc".toUpper())
echo({'a', 'b', 'c'}.card)
stdout.writeLine("Hallo") # то же, что и writeLine(stdout, "Hallo")

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

Это даёт возможность легко писать «чистый объектно-ориентированный код»:

import strutils, sequtils

stdout.writeLine("Give a list of numbers (separated by spaces): ")
stdout.write(stdin.readLine.split.map(parseInt).max.`$`)
stdout.writeLine(" is the maximum!")

Свойства


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

type
  Socket* = ref object of RootObj
    host: int # недоступен извне, нет звёздочки

proc `host=`*(s: var Socket, value: int) {.inline.} =
  ## сеттер адреса хоста
  s.host = value

proc host*(s: Socket): int {.inline.} =
  ## геттер адреса хоста
  s.host

var s: Socket
new s
s.host = 34  # то же, что и `host=`(s, 34)

(В примере также показаны inline-процедуры.)

Для реализации свойств-массивов можно перегрузить оператор доступа к массиву []:

type
  Vector* = object
    x, y, z: float

proc `[]=`* (v: var Vector, i: int, value: float) =
  # setter
  case i
  of 0: v.x = value
  of 1: v.y = value
  of 2: v.z = value
  else: assert(false)

proc `[]`* (v: Vector, i: int): float =
  # getter
  case i
  of 0: result = v.x
  of 1: result = v.y
  of 2: result = v.z
  else: assert(false)

Пример корявый, поскольку вектор лучше моделировать кортежем, у которого уже есть доступ к v[].

Динамическая привязка (dynamic dispatch)


Процедуры всегда используют статическую привязку. Для динамической привязки замените ключевое слово proc на method:

type
  PExpr = ref object of RootObj ## абстрактный базовый класс для выражения
  PLiteral = ref object of PExpr
    x: int
  PPlusExpr = ref object of PExpr
    a, b: PExpr

# обратите внимание: 'eval' полагается на динамическое связывание
method eval(e: PExpr): int =
  # перекрываем базовый метод
  quit "to override!"

method eval(e: PLiteral): int = e.x
method eval(e: PPlusExpr): int = eval(e.a) + eval(e.b)

proc newLit(x: int): PLiteral = PLiteral(x: x)
proc newPlus(a, b: PExpr): PPlusExpr = PPlusExpr(a: a, b: b)

echo eval(newPlus(newPlus(newLit(1), newLit(2)), newLit(4)))

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

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

type
  Thing = ref object of RootObj
  Unit = ref object of Thing
    x: int

method collide(a, b: Thing) {.inline.} =
  quit "to override!"

method collide(a: Thing, b: Unit) {.inline.} =
  echo "1"

method collide(a: Unit, b: Thing) {.inline.} =
  echo "2"

var a, b: Unit
new a
new b
collide(a, b) # на выходе: 2

Из примера видно, что вызов мультиметода не может быть неоднозначным: collide 2 предпочтительнее collide 1, поскольку разрешение работает слева направо. Таким образом, Unit, Thing предпочтительнее, чем Thing, Unit.
Примечание о производительности: Nim не создаёт таблицу виртуальных методов, а генерирует деревья привязки (dispatch trees). Это позволяет избежать затратного непрямого ветвления при вызовах методов и позволяет встраивание. Но другие оптимизации, такие как вычисления на этапе компиляции или удаление «мёртвого» кода не работают с методами.

Исключения


В Nim исключения являются объектами. По соглашению типы исключений заканчиваются на «Error». Модуль system определяет иерархию исключений, к которой вы можете привязаться. Исключения происходят от system.Exception, предоставляющего общий интерфейс.

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

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

Команда raise


Исключения возбуждаются с помощью команды raise:

var
  e: ref OSError
new(e)
e.msg = "the request to the OS failed"
raise e

Если за ключевым словом raise не следует выражение, то повторно возбуждается последнее исключение. Чтобы не писать приведённый выше код, можно использовать шаблон newException из модуля system:

raise newException(OSError, "the request to the OS failed")

Команда try


Команда try обрабатывает исключения:

# читаем первые две строки текстового файла, которые должны содержать числа, и
# пытаемся сложить их
var
  f: File
if open(f, "numbers.txt"):
  try:
    let a = readLine(f)
    let b = readLine(f)
    echo "sum: ", parseInt(a) + parseInt(b)
  except OverflowError:
    echo "overflow!"
  except ValueError:
    echo "could not convert string to integer"
  except IOError:
    echo "IO error!"
  except:
    echo "Unknown exception!"
    # reraise the unknown exception:
    raise
  finally:
    close(f)

Команды после try выполняются до тех пор, пока не возникнет исключения. В этом случае будет выполнена соответствующая ветка except.

Пустой блок except выполняется, если возникшее исключение не входит в список перечисленных явно. Это аналогично ветке else в команде if.

Если присутствует ветка finally, то она выполняется всегда после выполнения обработчиков исключений.

Исключение поглощается в ветке except. Если исключение не обработано, оно распространяется по стеку вызовов. Это значит, что если возникнет исключение, то оставшаяся часть процедуры, которая не находится внутри блока finally, не будет выполняться.

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

try:
  doSomethingHere()
except:
  let
    e = getCurrentException()
    msg = getCurrentExceptionMsg()
  echo "Got exception ", repr(e), " with message ", msg

Аннотирование процедур возбуждаемыми исключениями


С помощью необязательной прагмы {.raises.} вы можете указать, что процедура может возбуждать определённый набор исключений или не возбуждает исключений вообще. Если прагма {.raises.} используется, компилятор проверит, что она соответствует действительности. Например, если вы указали, что процедура возбуждает IOError, а в какой-то точке она (или одна из вызываемых процедур) возбуждает другое исключение, компилятор откажется её компилировать. Пример использования:

proc complexProc() {.raises: [IOError, ArithmeticError].} =
  ...

proc simpleProc() {.raises: [].} =
  ...

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

Если вы хотите добавить прагму {.raises.} к существующему коду, компилятор также может помочь вам. Вы можете добавить к процедуре команду прагмы {.effects.} и компилятор выведет все эффекты, проявляющиеся в этой точке (отслеживание исключений является частью системы эффектов Nim). Ещё один обходной путь для получения списка исключений, возбуждаемых процедурой, это использование команды Nim doc2, которая генерирует документацию для всего модуля и декорирует все процедуры списком возбуждаемых исключений. Вы можете прочесть больше о системе эффектов и соответствующих прагмах в руководстве.

Обобщения


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

type
  BinaryTreeObj[T] = object # BinaryTree это обобщённый тип с обобщённым
                            # параметром ``T``
    le, ri: BinaryTree[T]   # левое и правое поддерево; могут быть nil
    data: T                 # данные хранятся в узле
  BinaryTree*[T] = ref BinaryTreeObj[T] # тип, который экспортируется

proc newNode*[T](data: T): BinaryTree[T] =
  # конструктор узла
  new(result)
  result.data = data

proc add*[T](root: var BinaryTree[T], n: BinaryTree[T]) =
  # вставляем узел в дерево
  if root == nil:
    root = n
  else:
    var it = root
    while it != nil:
      # сравниваем данные элементов; используем обобщённую процедуру ``cmp``
      # которая работает с любым типом, имеющим операторы ``==`` и ``<``
      var c = cmp(it.data, n.data)
      if c < 0:
        if it.le == nil:
          it.le = n
          return
        it = it.le
      else:
        if it.ri == nil:
          it.ri = n
          return
        it = it.ri

proc add*[T](root: var BinaryTree[T], data: T) =
  # удобная процедура:
  add(root, newNode(data))

iterator preorder*[T](root: BinaryTree[T]): T =
  # Предварительно упорядоченный обход двоичного дерева. Поскольку рекурсивные
  # итераторы пока не реализованы, используется явный стек (который ещё и более
  # эффективен):
  var stack: seq[BinaryTree[T]] = @[root]
  while stack.len > 0:
    var n = stack.pop()
    while n != nil:
      yield n.data
      add(stack, n.ri)  # кладём правое поддерево на стек
      n = n.le          # и переходим по левому указателю

var
  root: BinaryTree[string] # инстанцируем BinaryTree как ``string``
add(root, newNode("hello")) # инстанцируем ``newNode`` и добавляем его
add(root, "world")          # инстанцируем вторую процедуру добавления
for str in preorder(root):
  stdout.writeLine(str)

Пример показывает обобщённое двоичное дерево. В зависимости от контекста, квадратные скобки используются либо для ввода параметров-типов, либо для инстанцирования обобщённой процедуры, итератора или типа. Как видно из примера, обобщения работают с перегрузкой: используется наилучшее совпадение add. Встроенная процедура add для последовательностей не прячется и используется в итераторе preorder.

Шаблоны


Шаблоны это простой механизм замещения, который оперирует абстрактными синтаксическими деревьями (AST) Nim. Шаблоны обрабатываются на семантическом проходе компиляции. Они хорошо интегрированы с остальными частями языка и у них нет обычных недостатков C-шных макросов препроцессора.

Чтобы вызвать шаблон, вызывайте его как процедуру.

Пример:

template `!=` (a, b: expr): expr =
  # это определение существует в модуле System
  not (a == b)

assert(5 != 6) # компилятор перепишет это как: assert(not (5 == 6))

Операторы !=, >, >=, in, notin, isnot фактически являются шаблонами: в результате, если вы перегрузили оператор ==, то оператор != становится доступен автоматически и правильно работает (кроме чисел с плавающей запятой IEEE – NaN ломает строгую булевскую логику).

a > b превращается в b < a. a in b трансформируется в contains(b, a). notin и isnot получают очевидный смысл.

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

const
  debug = true

proc log(msg: string) {.inline.} =
  if debug: stdout.writeLine(msg)

var
  x = 4
log("x has the value: " & $x)

В этом коде есть недостаток: если debug однажды выставят в false, то довольно затратные операции $ и & по-прежнему будут выполняться! (Вычисление аргументов для процедур сделано «жадным».)

Превращение процедуры log в шаблон решает эту проблему:

const
  debug = true

template log(msg: string) =
  if debug: stdout.writeLine(msg)

var
  x = 4
log("x has the value: " & $x)

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

Если есть параметр stmt, то он должен быть последним в объявлении шаблона. Причина в том, что команды передаются в шаблон с помощью специального синтаксиса с двоеточием (:):

template withFile(f: expr, filename: string, mode: FileMode,
                  body: stmt): stmt {.immediate.} =
  let fn = filename
  var f: File
  if open(f, fn, mode):
    try:
      body
    finally:
      close(f)
  else:
    quit("cannot open: " & fn)

withFile(txt, "ttempl3.txt", fmWrite):
  txt.writeLine("line 1")
  txt.writeLine("line 2")

В примере две команды writeLine привязываются к параметру body. Шаблон withFile содержит служебный код и помогает избежать распространённой проблемы: забыть закрыть файл. Отметим, что команда let fn = filename гарантирует, что filename будет вычислен только один раз.

Макросы


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

Есть два способа написания макросов: либо генерирование исходного кода Nim и передача его компилятору для разбора, либо ручное создание абстрактного синтаксического дерева (AST), которое скармливается компилятору. Для построения AST необходимо знать, каким образом конкретный синтаксис Nim преобразуется в абстрактное синтаксическое дерево. AST документировано в модуле macros.

Когда ваш макрос готов, есть два способа его вызвать:
  1. вызов макроса как процедуры (макрос выражения)
  2. вызов макроса с помощью специального синтаксиса macrostmt (макрос команд)

Макросы выражений


Следующий пример реализует мощную команду debug, которая принимает любое количество аргументов:

# чтобы работать с синтаксическими деревьями Nim нам нужен API, который
# определен в модуле``macros``:
import macros

macro debug(n: varargs[expr]): stmt =
  # `n` это AST Nim, содержащее список выражений;
  # этот макрос возвращает список выражений:
  result = newNimNode(nnkStmtList, n)
  # перебираем аргументы, переданные в макрос:
  for i in 0..n.len-1:
    # добавляем в список команд вызов, который выведет выражение;
    # `toStrLit` конвертирует AST в его строковое представление:
    result.add(newCall("write", newIdentNode("stdout"), toStrLit(n[i])))
    # добавляем в список команд вызов, который выведет ": "
    result.add(newCall("write", newIdentNode("stdout"), newStrLitNode(": ")))
    # добавляем в список команд вызов, который выведет значение выражения:
    result.add(newCall("writeLine", newIdentNode("stdout"), n[i]))

var
  a: array[0..10, int]
  x = "some string"
a[0] = 42
a[1] = 45

debug(a[0], a[1], x)

Вызов макроса разворачивается в:

write(stdout, "a[0]")
write(stdout, ": ")
writeLine(stdout, a[0])

write(stdout, "a[1]")
write(stdout, ": ")
writeLine(stdout, a[1])

write(stdout, "x")
write(stdout, ": ")
writeLine(stdout, x)

Макросы команд


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

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

macro case_token(n: stmt): stmt =
  # создаёт лексический анализатор из регулярных выражений
  # ... (реализация -- упражнение для читателя :-)
  discard

case_token: # это двоеточие сообщает парсеру, что это макрос команды
of r"[A-Za-z_]+[A-Za-z_0-9]*":
  return tkIdentifier
of r"0-9+":
  return tkInteger
of r"[\+\-\*\?]+":
  return tkOperator
else:
  return tkUnknown

Создаём свой первый макрос


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

import strutils, tables

proc readCfgAtRuntime(cfgFilename: string): Table[string, string] =
  let
    inputString = readFile(cfgFilename)
  var
    source = ""

  result = initTable[string, string]()
  for line in inputString.splitLines:
    # Игнорируем пустые строки
    if line.len < 1: continue
    var chunks = split(line, ',')
    if chunks.len != 2:
      quit("Input needs comma split values, got: " & line)
    result[chunks[0]] = chunks[1]

  if result.len < 1: quit("Input file empty!")

let info = readCfgAtRuntime("data.cfg")

when isMainModule:
  echo info["licenseOwner"]
  echo info["licenseKey"]
  echo info["version"]

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

version,1.1
licenseOwner,Hyori Lee
licenseKey,M1Tl3PjBWO2CC48m

Процедура readCfgAtRuntime будет открывать данное имя файла и возвращать Table из модуля tables. Разбор файла делается (без обработки ошибок или граничных случаев) с помощью процедуры splitLines из модуля strutils. Есть много вещей, которые могут пойти не так; помните, что здесь объясняется, как запускать код на этапе компиляции, а не как правильно реализовать защиту от копирования.

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

Генерация исходного кода


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

 1  import macros, strutils
 2
 3  macro readCfgAndBuildSource(cfgFilename: string): stmt =
 4    let
 5      inputString = slurp(cfgFilename.strVal)
 6    var
 7      source = ""
 8
 9    for line in inputString.splitLines:
10      # Ignore empty lines
11      if line.len < 1: continue
12      var chunks = split(line, ',')
13      if chunks.len != 2:
14        error("Input needs comma split values, got: " & line)
15      source &= "const cfg" & chunks[0] & "= \"" & chunks[1] & "\"\n"
16
17    if source.len < 1: error("Input file empty!")
18    result = parseStmt(source)
19
20  readCfgAndBuildSource("data.cfg")
21
22  when isMainModule:
23    echo cfglicenseOwner
24    echo cfglicenseKey
25    echo cfgversion

Здесь хорошо то, что почти ничего не изменилось! Во-первых, изменилась обработка входного параметра (строка 3). В динамической версии процедура readCfgAtRuntime получает строковый параметр. Однако в версии макроса он хотя и объявлен строковым, но это лишь внешний интерфейс макроса. Когда макрос запускается, он на самом деле получает объект PNimNode, а не строку, и нам нужно вызвать процедуру strVal из модуля macros (строка 5), чтобы получить строку, переданную в макрос.

Во-вторых, мы не можем использовать процедуру readFile из модуля system из-за ограничений FFI на этапе компиляции. Если мы попробуем использовать эту процедуру (или любую другую, зависящую от FFI), компилятор выдаст ошибку с сообщением, что не может вычислить дамп исходного кода макроса и добавит к нему распечатку стека, показывающую, где находился компилятор на момент ошибки. Мы можем обойти это ограничение, воспользовавшись процедурой slurp из модуля system, которая сделана специально для этапа компиляции (там же есть похожая процедура gorge, выполняющая внешнюю программу и перехватывающая её вывод).

Что интересно, наш макрос не возвращает объекта времени выполнения Table. Вместо этого он формирует исходный код Nim в исходной переменной. Для каждой строки конфигурационного файла будет сгенерирована константная переменная (строка 15). Чтобы избежать конфликтов, мы снабдили эти переменные префиксом cfg. В целом, всё что делает компилятор – это заменяет строку вызова макроса следующим фрагментом кода:

const cfgversion= "1.1"
const cfglicenseOwner= "Hyori Lee"
const cfglicenseKey= "M1Tl3PjBWO2CC48m"

Вы можете проверить это самостоятельно, добавив строчку с выводом исходного кода на экран в конце макроса и скомпилировав программу. Ещё одно различие состоит в том, что вместо вызова обычной процедуры quit для выхода (которую мы могли бы вызвать) эта версия вызывает процедуру error (строка 14). Процедура error делает то же, что и quit но кроме того ещё и выводит исходный код и номер строки файла, где произошла ошибка, что помогает программисту найти ошибку при компиляции. В этой ситуации нам указали бы на строчку, вызывающую макрос, а не на строчку data.cfg, которую мы обрабатываем: это мы должны контролировать самостоятельно.

Генерация AST вручную


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

import macros

dumpTree:
  const cfgversion: string = "1.1"
  const cfglicenseOwner= "Hyori Lee"
  const cfglicenseKey= "M1Tl3PjBWO2CC48m"

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

StmtList
  ConstSection
    ConstDef
      Ident !"cfgversion"
      Ident !"string"
      StrLit 1.1
  ConstSection
    ConstDef
      Ident !"cfglicenseOwner"
      Empty
      StrLit Hyori Lee
  ConstSection
    ConstDef
      Ident !"cfglicenseKey"
      Empty
      StrLit M1Tl3PjBWO2CC48m

С этой информацией мы уже лучше представляем, какие данные нужны компилятору от нас. Нам нужно сгенерировать список команд. Для каждой константы исходного кода генерируется ConstSection и ConstDef. Если бы мы перенесли все эти константы в единый блок const, то увидели бы только одну ConstSection с тремя потомками.

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

 1  import macros, strutils
 2  
 3  macro readCfgAndBuildAST(cfgFilename: string): stmt =
 4    let
 5      inputString = slurp(cfgFilename.strVal)
 6  
 7    result = newNimNode(nnkStmtList)
 8    for line in inputString.splitLines:
 9      # Игнорируем пустые строки
10      if line.len < 1: continue
11      var chunks = split(line, ',')
12      if chunks.len != 2:
13        error("Input needs comma split values, got: " & line)
14      var
15        section = newNimNode(nnkConstSection)
16        constDef = newNimNode(nnkConstDef)
17      constDef.add(newIdentNode("cfg" & chunks[0]))
18      constDef.add(newEmptyNode())
19      constDef.add(newStrLitNode(chunks[1]))
20      section.add(constDef)
21      result.add(section)
22  
23    if result.len < 1: error("Input file empty!")
24  
25  readCfgAndBuildAST("data.cfg")
26  
27  when isMainModule:
28    echo cfglicenseOwner
29    echo cfglicenseKey
30    echo cfgversion

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

Для каждой входной строки мы создаём определение константы (nnkConstDef) и оборачиваем его секцией констант (nnkConstSection). Как только эти переменные созданы, мы заполняем их иерархически (строка 17), как показано в предыдущем дампе дерева AST: определение константы является потомком определения секции и содержит узел идентификатора, пустой узел (пусть компилятор сам догадается, какой здесь тип) и строковый литерал со значением.

Последний совет по написанию макросов: если вы не уверены, что построенное вами AST выглядит нормально, вы можете попробовать воспользоваться макросом dumpTree. Но его нельзя использовать внутри макроса, который вы пишете или отлаживаете. Вместо этого выводите на экран строку, сгенерированную treeRepr. Если в конце этого примера вы добавите echo treeRepr(result), то увидите тот же вывод, что и при использовании макроса dumpTree. Вызывать его именно в конце необязательно, можете вызвать его в любой точке макроса, с которым у вас возникли проблемы.
Share post

Comments 5

    +1
    Концептуально все верно (параметризованные типы, шаблоны как средства подстановки, макросы как средства кодогенерации), но синтаксически ничего не понятно. Даже сложно сформулировать конкретные претензии, наверное питонистам это все ближе для понимания…
    Но когда я читал аналогичные статьи про Nemerle, понятно было все, хотя с некоторыми мелочами я там мог не согласиться — но вполне осознанно и мог обосновать свою точку зрения. Здесь — не могу.
      0
      Концептуально все верно (...), но синтаксически ничего не понятно.

      Это про перевод или про сам язык?
        0
        Про идеи правильного метапрограммирования.
          0
          отсутствие комментариев кстати косвенно подтверждает непонятность:) К первой статье комментариев было гораздо больше.
            0
            У первой статьи и просмотров больше. А вторая слишком поздно появилась на главной. :-)

      Only users with full accounts can post comments. Log in, please.