Nim Tutorial (Part III)

Author: Arne Döring

Introduction

“大いなる力には、大いなる責任が伴う” – Spider Man’s Uncle

このドキュメントは、Nimのマクロシステムについてのチュートリアルです。マクロとは、コンパイル時に実行され、Nim の構文木を別の木に変換する関数です。

マクロで実装できるものの例。

  • アサーションが失敗した場合に比較演算子の両辺を表示する assert マクロ。myAssert(a == b) は、if a != b: quit($a " != " $b) に変換されます。
  • シンボルの値と名前を表示するデバッグマクロです。myDebugEcho(a) は、echo "a." , aに変換されます。
  • 式を記号的に微分すること。diff(apow(x,3) + bpow(x,2) + cx + d, x) は、 3apow(x,2) + 2b*x + c` に変換されます。

マクロ引数

マクロ引数の種類は2つの顔を持つ。1 つの面はオーバーロードの解決に使用され、もう 1 つの面はマクロ本体内で使用されます。例えば、macro foo(arg: int)foo(x) という式の中で呼ばれた場合、x は int と互換性のある型でなければなりませんが、マクロ本体の中では argint ではなく NimNode という型を持っています! なぜこのようにするのかは、後で具体的な例を見たときに明らかになります。

マクロに引数を渡すには2つの方法があります。引数には typeduntyped のどちらかを指定することができます。

未定義引数

マクロの未定義引数は、セマンティックチェックされる前にマクロに渡されます。つまり、マクロに渡される構文木は Nim にとって意味をなす必要はなく、唯一の制限は構文解析可能であることです。通常、マクロは引数をチェックしませんが、何らかの方法で変換結果の中でそれを使用します。マクロ展開の結果は常にコンパイラによってチェックされるので、奇妙なエラーメッセージを除けば、何も悪いことは起こりません。

untyped 引数の欠点は Nim のオーバーロードの解決と相性が悪いことです。

型付けされていない引数の利点はシンタックスツリーが非常に予測しやすく、 typed と比較して複雑でないことです。

型付き引数

型付き引数の場合、セマンティックチェッカーは引数上で実行され、マクロに渡される前に変換を行います。識別子ノードはシンボルとして解決され、暗黙の型変換は呼び出しとしてツリーに表示され、テンプレートは展開され、そしておそらく最も重要なのは、ノードが型情報を持つことです。型付き引数は引数リストで typed という型を持つことができます。しかし、 int, float, MyObjectType などの他のすべての型も型付き引数であり、それらはシンタックスツリーとしてマクロに渡されます。

静的引数

静的引数は、マクロに構文木ノードとしてではなく、値として値を渡す方法です。例えば、macro foo(arg: static[int]) の式 foo(x) では、x は整数の定数である必要がありますが、マクロ本体では arg は通常の int 型のパラメータと同じようになります。

import std/macros

macro myMacro(arg: static[int]): untyped =
  echo arg # 単なる int (7) で、`NimNode` ではありません。

myMacro(1 + 2 * 3)

引数としてのコードブロック

呼び出し式の最後の引数をインデントして別のコードブロックに渡すことが可能である。例えば、次のコード例は echo を呼び出す有効な方法です(推奨はしませんが)。

echo "Hello ":
  let a = "Wor"
  let b = "ld!"
  a & b

マクロの場合、この呼び出し方は非常に便利で、この表記法を使えば、任意の複雑な構文木をマクロに渡すことができる。

シンタックスツリー

Nim の構文木を作るには、Nim のソースコードがどのように構文木として表現されるのか、また、Nim のコンパイラが理解できるようにするには、その構文木がどのように見える必要があるのかを知る必要があります。Nim のシンタックスツリーのノードは macros モジュールに記述されています。しかし、Nim のシンタックスツリーをよりインタラクティブに調べるには、 macros.treeRepr を使います。これは、引数式がどのようにツリー形式で表現されるかを調べたり、生成されたシンタックスツリーのデバッグ印刷に使用することができます。dumpTree` はあらかじめ定義されたマクロで、引数をツリー表現で表示するだけで、それ以外のことは何も行いません。以下は、そのような木表現の例である。

dumpTree:
  var mt: MyType = MyType(a:123.456, b:"abcdef")

# output:
#   StmtList
#     VarSection
#       IdentDefs
#         Ident "mt"
#         Ident "MyType"
#         ObjConstr
#           Ident "MyType"
#           ExprColonExpr
#             Ident "a"
#             FloatLit 123.456
#           ExprColonExpr
#             Ident "b"
#             StrLit "abcdef"

カスタムセマンティックチェック

マクロが引数に対して行うべき最初のことは、引数が正しい形式であるかどうかをチェックすることです。すべてのタイプの間違った入力をここでキャッチする必要はありませんが、マクロ評価中にクラッシュを引き起こす可能性があるものはすべてキャッチし、適切なエラー メッセージを作成する必要があります。macros.expectKindmacros.expectLen は良いスタートです。もっと複雑なチェックが必要な場合は、 macros.error proc で任意のエラーメッセージを作成することができます。

macro myAssert(arg: untyped): untyped =
  arg.expectKind nnkInfix

コードの生成

コードを生成する方法は2つあります。newTreenewLit の呼び出しを多く含む式でシンタックスツリーを作成する方法と、 quote do: 式でシンタックスツリーを作成する方法があります。最初のオプションはシンタックスツリーの生成を低レベルでコントロールするのに適していますが、2番目のオプションはより冗長ではありません。もし、 newTreenewLit のコールでシンタックスツリーを作成する場合は、マクロ macros.dumpAstGen が冗長性を保つための手助けをしてくれます。

quote do: では、文字通り生成したいコードを書くことができます。バックティックは、NimNode シンボルから生成される式にコードを挿入するために使用されます。

macro a(i) = quote do: let `i` = 0
a b

バックティックが必要な場合は、カスタムのプリフィックス演算子を定義することができる。

macro a(i) = quote("@") do: assert @i == 0
let b = 0
a b

注入されたシンボルは、シンボルに解決するときにアクセントの引用符が必要です。

macro a(i) = quote("@") do: let `@i` == 0
a b

生成された構文木には NimNode 型のシンボルだけを注入するようにしてください。任意の値を NimNode 型の式木に変換するために newLit を使用すると、安全に注入することができます。

import std/macros

type
  MyType = object
    a: float
    b: string

macro myMacro(arg: untyped): untyped =
  var mt: MyType = MyType(a:123.456, b:"abcdef")

  # ...

  let mtLit = newLit(mt)

  result = quote do:
    echo `arg`
    echo `mtLit`

myMacro("Hallo")

myMacro を呼び出すと、次のようなコードが生成されます。

echo "Hallo"
echo MyType(a: 123.456'f64, b: "abcdef")

最初のマクロを作る

マクロを書くための出発点として、先ほどの myDebug マクロを実装する方法を紹介します。まず最初に行うべきことは、マクロの使い方の簡単な例を作って、引数を表示することです。こうすることで、正しい引数がどのようなものであるべきかを知ることができます。

import std/macros

macro myAssert(arg: untyped): untyped =
  echo arg.treeRepr

let a = 1
let b = 2

myAssert(a != b)
Infix
  Ident "!="
  Ident "a"
  Ident "b"

出力から、引数が infix 演算子であること(node kind が “Infix” )と、2 つのオペランドがインデックス 1 と 2 であることを確認することができる。この情報をもとに、実際のマクロを記述することができる。

import std/macros

macro myAssert(arg: untyped): untyped =
  # すべてのノードの種類識別子の先頭に "nnk "が付く。
  arg.expectKind nnkInfix
  arg.expectLen 3
  # 文字列リテラルとしての演算子
  let op  = newLit(" " & arg[0].repr & " ")
  let lhs = arg[1]
  let rhs = arg[2]

  result = quote do:
    if not `arg`:
      raise newException(AssertionDefect,$`lhs` & `op` & $`rhs`)

let a = 1
let b = 2

myAssert(a != b)
myAssert(a == b)

これが生成されるコードです。マクロが実際に生成したものをデバッグするには、マクロの最後の行で echo result.repr というステートメントを使用します。これは、この出力を得るために使用されたステートメントでもあります。

if not (a != b):
raise newException(AssertionDefect, $a & " != " & $b)

パワーには責任が伴う

マクロは非常に強力です。良いアドバイスとしては、できるだけ使わないで、必要なだけ使うことです。マクロは式のセマンティクスを変えてしまうことがあり、マクロが何をするのか正確に知らない人には理解不能なコードになってしまいます。ですから、マクロが必要なく、同じロジックがテンプレートやジェネリックスを使って実装できる場合は、マクロを使用しないほうがよいでしょう。また、マクロを使用する場合は、そのマクロのドキュメントが適切に記述されている必要があります。完全に説明可能なコードしか書かないと主張するすべての人々へ:マクロに関しては、実装だけではドキュメントとして不十分なのです。

制限事項

マクロはNimVM内のコンパイラで評価されるため、マクロはNimVMの制限をすべて共有しています。マクロは純粋なNimコードで実装されなければならない。マクロはシェル上で外部プロセスを起動することができますが、コンパイラでビルドされたもの以外のC関数を呼び出すことはできません。

その他の例

このチュートリアルでは、マクロシステムの基本的な部分のみを説明します。このチュートリアルで紹介するのは、マクロの基本的な使い方だけです。

文字列フォーマット

Nim 標準ライブラリの strformat ライブラリは、コンパイル時に文字列リテラルをパースするマクロを提供します。このようなマクロで文字列をパースすることは一般に推奨されません。パースされたASTは型情報を持つことができず、VM上に実装されたパースは一般にあまり高速ではない。ASTのノードで作業するのが、ほとんど常に推奨される方法です。しかし、それでも strformatassert マクロより少し複雑なマクロの実用的な使用例として良い例である。

Strformat

アストパターンマッチング

Ast Pattern Matchingは、複雑なマクロの作成を支援するためのマクロライブラリです。これは、Nimのシンタックスツリーを新しいセマンティクスで再利用する方法の良い例として見ることができます。

Ast Pattern Matching

OpenGLサンドボックス

このプロジェクトは、完全にマクロで書かれたNimからGLSLへのコンパイラが動作しています。使用されているすべての関数シンボルを再帰的にスキャンしてコンパイルし、クロスライブラリ関数がGPU上で実行できるようにします。

OpenGL Sandbox