Примечание от переводчика
Первая часть находится здесь: «Учебное пособие по 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
.Когда ваш макрос готов, есть два способа его вызвать:
- вызов макроса как процедуры (макрос выражения)
- вызов макроса с помощью специального синтаксиса
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
. Вызывать его именно в конце необязательно, можете вызвать его в любой точке макроса, с которым у вас возникли проблемы.
NeoCode
Концептуально все верно (параметризованные типы, шаблоны как средства подстановки, макросы как средства кодогенерации), но синтаксически ничего не понятно. Даже сложно сформулировать конкретные претензии, наверное питонистам это все ближе для понимания…
Но когда я читал аналогичные статьи про Nemerle, понятно было все, хотя с некоторыми мелочами я там мог не согласиться — но вполне осознанно и мог обосновать свою точку зрения. Здесь — не могу.
Morthan
Это про перевод или про сам язык?
NeoCode
Про идеи правильного метапрограммирования.
NeoCode
отсутствие комментариев кстати косвенно подтверждает непонятность:) К первой статье комментариев было гораздо больше.
Morthan
У первой статьи и просмотров больше. А вторая слишком поздно появилась на главной. :-)