Nimのマクロを調べてみる
目的
Nimでのマクロ定義を調べてNimでのマクロについて学ぶ。
具体的にはECSで渡された関数を解析して自動的に引数を割り当てて呼び出すという処理を構築する。
動機
で使われているマクロが意味不明だったので知りたくなった。
型指定(タプル)でComponentを取得する形が便利なので再現したかった。
マクロ
Nimでのマクロはコンパイル時に動作してASTを操作する機能。
なので何をするにしてもASTを変更または構築することになる。
Example
echo "test"
をASTにすると
StmtList
Command
Ident "echo"
StrLit "hello"
プログラムでASTを構築する場合は
macro helloMacro(): untyped =
nnkStmtList.newTree(
nnkCommand.newTree(
newIdentNode("echo"),
newLit("hello")
)
)
参考
公式ドキュメント
マクロを解析・構築するための機能
dumpTree
: Nimの構文がどういうASTとして構築されるかを表示する。
dumpAstGen
: Nimの構文を構築するためのASTの記述方法を表示する。
treeRepr
: ASTノード(NimNode)をツリー形式のstringに変換する。
使用方法
dumpTree:
echo "hello"
dumpAstGen:
echo "hello"
static:
let astStr =
nnkStmtList.newTree(
nnkCommand.newTree(
newIdentNode("echo"),
newLit("Hello")
)
).treeRepr
echo astStr
実行結果
StmtList
Command
Ident "echo"
StrLit "hello"
nnkStmtList.newTree(
nnkCommand.newTree(
newIdentNode("echo"),
newLit("Hello")
)
)
StmtList
Command
Ident "echo"
StrLit "Hello"
(見やすくするために空の行を追加している)
static
は実行時では無くコンパイル時に実行するのを明示するために必要になる。
繰り返しになるがNimのマクロはASTを操作する処理である。
なので何を実現するにも最終的にはASTを構築してNimの文法的に正しい形に収める必要がある。
「ASTノードを操作するためのプログラムを組む」のがマクロの構築。
補足:
C言語のdefineマクロに相当するモノも存在するが今回は言及しない(template
キーワードで構築する)
マクロの構築
macro helloMacro(): untyped =
nnkStmtList.newTree(
nnkCommand.newTree(
newIdentNode("echo"),
newLit("hello")
)
)
を例にマクロを構築する方法を見ていく。
まずマクロは通常の関数と違いproc
ではなくmacro
で定義する。
構築したASTを返す場合はuntyped
で返す。untyped
は未評価な値を指しコンパイル時に展開されてその後に評価される。
またAST自体を関数で受け取りたい場合もuntyped
で受け取る。
Nimプログラム内でASTはNimNode型として扱われていて、実際の操作はこのNimNode型を通して行う(詳しい型の要件は公式ページを参照)
マクロというと難しいイメージも有るが結局のところASTを操作するだけであり、いわゆるノードを組み替えるだけとも言える。
問題はASTが普段打ち込んでいるテキストの形式と大幅に異なっている点でテキストからASTを、ASTからテキストを想像することが難しい点で、コレがマクロの構築難易度を大きく上げている。
dumpTree
,dumpAstGen
,treeRepr
をうまく使って理解を深めていきたい。
余談:
Lispのマクロが優れていると言われるのは単にASTを操作するだけではなくプログラムの構造自体が、ほぼそのままASTと一致しているからというのもある。
そもそもASTがどうなるとかプログラムを書いてて意識なんてしないわけで、言語の実装でもやってなかったら、まずそこで詰む。
マクロを構築するためにプログラミング言語自体の処理の流れ(字句解析、抽象構文木etc)を理解していないといけないのが非常に面倒。
マクロを組む
proc updatePosition(pos: var Position, vel: Velocity) =
pos.x += vel.x
pos.y += vel.y
world.system(updatePosition)
みたいな事を実現する。
何で態々やるかというと
- componentの情報をできるだけ外部に漏らしたくない
- 型情報を基にcomponentを取得する方が便利
- 趣味(型を活かしたメタプログラミングを構築する場合にタプルを使用して型情報を伝播させる方式がrust, zigでは主流に感じるのでNimでも同じような事が出来ないか気になったから)
ゲームプログラミングにあるまじきことだがパフォーマンスは気にしない、冗長でもよし、とにかく実現できるのかを確認することを目的とする。
関数の引数の解析
まずは関数の引数をタプル型として生成する。
タプル型の生成
macro argsToTupleType(): type =
result = newNimNode(nnkTupleTy)
解析して型として返すマクロ
macro argsToTupleType(prc: typedesc[proc]): type =
let typ = getTypeInst(prc)
let prcTyp = typ[1]
let params = prcTyp[0]
result = newNimNode(nnkTupleTy)
for node in params:
if node.kind != nnkIdentDefs:
continue
let param = node[0]
let paramTyp = if node[1].kind == nnkVarTy: node[1][0] else: node[1]
result.add(newIdentDefs(ident(param.strVal), copyNimTree(paramTyp)))
let typ = getTypeInst(prc)
受け取ったNimNodeでは関数の引数を解析できない場合があるのでgetTypeInst
で元の宣言のASTを取得。
testProc
という関数を渡した場合に、そのままだとシンボル情報しか渡されないので、その対策。
以下はそれぞれprc
とtyp
をtreeRepr
した場合の出力
prc:
TypeOfExpr
Sym "testProc"
typ:
BracketExpr
Sym "typeDesc"
ProcTy
FormalParams
Empty
IdentDefs
Sym "a"
Sym "int"
Empty
IdentDefs
Sym "b"
Sym "int32"
Empty
IdentDefs
Sym "c"
Sym "float"
Empty
Empty
let prcTyp = typ[1]
let params = prcTyp[0]
treeRepr
した内容を基にASTから引数宣言のASTを取得。
for node in params:
if node.kind != nnkIdentDefs:
continue
let param = node[0]
let paramTyp = if node[1].kind == nnkVarTy: node[1][0] else: node[1]
result.add(newIdentDefs(ident(param.strVal), copyNimTree(paramTyp)))
取得したASTをループで解析。
let paramTyp = if node[1].kind == nnkVarTy: node[1][0] else: node[1]
a: var int
のようにvar
宣言された引数が有るがタプルの型ではvar
は不要なので飛ばす。
result.add(newIdentDefs(ident(param.strVal), copyNimTree(paramTyp)))
タプルのASTに宣言部分を追加。
余談:
ASTを解析して構築するだけなので簡単といえば簡単。
ASTを解析して構築するとか普段やらないので面倒と言えば面倒(getTypeInst
のところとか)。
マクロはコンパイル時に展開して始めてエラーを吐き出すので、ちゃんとテストしないと特定のASTしか受け付けないマクロになる。
が、テストケースを考えるには書いているコードがASTに展開されたときにどうなるかを想像しなくちゃいけないのがハードル高い。
言語の仕様に対する理解度を試されている気分。
任意の関数に独自の引数を渡して呼び出す
関数に渡された関数へ関数の引数を基に取得したデータを渡す。
関数を呼び出すコードをdumpAstGen
を使って確認する。
dumpAstGen:
testProc(int(0), 0.int32, 0.float)
nnkStmtList.newTree(
nnkCall.newTree(
newIdentNode("testProc"),
nnkCall.newTree(
newIdentNode("int"),
newLit(0)
),
nnkDotExpr.newTree(
newLit(0),
newIdentNode("int32")
),
nnkDotExpr.newTree(
newLit(0),
newIdentNode("float")
)
)
)
生成されたコードを参考にマクロを実装。
macro callProcWithArgsTuple(cb: proc, tpl: typedesc[tuple]): untyped =
var call = newCall(cb)
var cnt = 0
for arg in tpl:
let typ = arg[1]
call.add(nnkCall.newTree(typ.copyNimTree, newLit(cnt)))
cnt += 1
return call
関数と関数の引数情報を保持したタプルを受け取ってループのカウンタを引数に追加して呼び出す。
呼び出し
callProcWithArgsTuple(testProc, argsToTupleType(testProc.type))
実行結果
testProc: 0 1 2.0
わざわざタプルを渡すのは冗長なので省略したいのだがマクロ内でargsToTupleType(cb.type)
を呼びだすとcb.type
がproc
型ではなくNimNode
型として処理されてしまうので省略できない。
proc callProc(cb: proc) =
callProcWithArgsTuple(cb, argsToTupleType(cb.type))
として展開のタイミングを変更することで一応、対応可能。
タプルを基にしてComponentを取得してCallbackを呼び出す
物凄く適当なECS
import tables
import typetraits
# component
type Position* = object
x*: int
y*: int
type Velocity* = object
x*: int
y*: int
type CharacterStatus* = object
hp*: int
mp*: int
type AbstructComponentCollection* = ref object of RootObj
entities*: seq[int]
type ComponentCollection*[T] = ref object of AbstructComponentCollection
components*: seq[T]
proc newComponentCollection[T](): ComponentCollection[T] =
result.new
type World* = object
components*: Table[string, AbstructComponentCollection]
proc init*(world: var World) =
world.components = initTable[string, AbstructComponentCollection]()
world.components[Position.name] = newComponentCollection[Position]()
world.components[Position.name].entities = @[1, 2, 3]
cast[ComponentCollection[Position]](world.components[Position.name]).components = @[
Position(x:1, y:1),
Position(x:2, y:2),
Position(x:3, y:3)]
world.components[Velocity.name] = newComponentCollection[Velocity]()
world.components[Velocity.name].entities = @[1, 2, 3]
cast[ComponentCollection[Velocity]](world.components[Velocity.name]).components = @[
Velocity(x:10, y:10),
Velocity(x:20, y:20),
Velocity(x:30, y:30)]
world.components[CharacterStatus.name] = newComponentCollection[CharacterStatus]()
world.components[CharacterStatus.name].entities = @[1, 2, 3]
cast[ComponentCollection[CharacterStatus]](world.components[CharacterStatus.name]).components = @[
CharacterStatus(hp:10, mp:10),
CharacterStatus(hp:20, mp:20),
CharacterStatus(hp:30, mp:30)]
# systems
proc updatePosition*(pos: var Position, vel: Velocity) =
pos.x += vel.x
pos.y += vel.y
echo "updatePosition = x: ", pos.x, ", y: ", pos.y
proc updateStatus*(status: var CharacterStatus) =
echo "updateStatus"
updatePosition
をマクロを使って呼び出す。
proc getComponent(world: World, typ: typedesc, entity: int): ptr typ.type =
result = cast[ComponentCollection[typ.type]](world.components[typ.type.name]).components[entity].addr
dumpAstGen:
world.getComponentByIndex(Velocity, 0)[]
でASTを確認しながらマクロを構築する。
いきなり完成形。
macro updateSystemMacro(world: World, cb: proc, tpl: typedesc[tuple], entity: int): untyped =
var call = newCall(cb)
for arg in tpl:
let typ = arg[1]
call.add(
nnkBracketExpr.newTree(
nnkCall.newTree(
nnkDotExpr.newTree(
newIdentNode("world"),
newIdentNode("getComponent")
),
newIdentNode(typ.strVal),
entity.copyNimTree
)
)
)
return call
proc updateSystem(world: World, cb: proc, entity: int) =
updateSystemMacro(world, cb, cb.type.argsToTupleType(), entity)
実行例
var world = World()
world.init()
for i in 0..<3:
updateSystem(world, updatePosition, i)
出力
updatePosition = x: 11, y: 11
updatePosition = x: 22, y: 22
updatePosition = x: 33, y: 33
dumpAstGen
で表示されたコードを基に必要な情報を置き換えて実装。
この方式ならテキストから直接ASTを生成できるparseStmt
関数を使った方が分かりやすいと思われる。
直接getComponent
何かを呼び出すNimNodeを構築するのはなんか違う気がするので必要最小限の部分だけマクロとして後は通常の関数内で呼び出す方が良さそう。
感想
物凄く雑な実装だが目的は達成できたので感想。
構築しやすかったかというと構築しやすくはない。ただASTを直接構築できるので出来ることの幅は非常に広い。
ただ、今回みたいな使い方との相性はイマイチに感じる。
DSLを構築したり構文を拡張する目的なら問題ないんだろうけど型情報を活かしたいのにコンパイル時に動作する関係で受け取った変数が大体NimNodeになって逆に型情報が失われてしまうという。
書きあがった後は問題ないが構築している最中はNimNodeになってしまうので色々と困った。
マクロに限った話ではないが1つの関数は極力小さくして必要な情報だけ適宜持ってくるようにした方が良さそう(長いマクロでNimNodeにどんどん情報を追加していると最終的にどうなるのか分からなくなりそう)
良かった点としては、ASTを出力したり変換する関数が多数用意されているのでASTが分からなくてもとりあえず出力して確認できる点。もう1つはコンパイルエラーが比較的分かりやすい点。
まあコンパイルエラーはマクロ展開後にエラーだったら出るだけなのでマクロのどこがおかしいかを直接出してくれるわけでは無いんだけど素直なエラー(C++のtemplate
と比較して)が多かったので推測しやすかった。
書いてて思ったこととしてはZigのcomptime
のほうがシンプルだよなと。
マクロがコンパイル時に実行する関係上、呼び出す関数や値などはコンパイル時に確定している必要がありコンパイル時に動作するかどうかを考える必要があった。
Nimはマクロ(macro
, template
)とcompileTime
プラグマとstatic
の挙動を意識して構築する必要がある一方でZigだとコンパイル時に動作するかだけを考えればよいのでZigのほうがシンプルだなと。
ただZigはNimのようにASTを構築することはできないので、あくまで型情報を渡すという点に関してはZigのcomptime
のほうが考えることが少ないという話。
Nimのマクロに対する理解は深まったが今後積極的にマクロを書くかというと書かない。
考え方は色々だが個人的には冗長でも良いからマクロを使わずに基本的な言語機能だけで書いた方が良いかなと。
ただDSLを構築したいとか抽象化目的なら話は変わってくるかもしれない。
継続を実装出来たりマクロ(同図像性)自体には可能性を感じるんだけど如何せん分かりにくい。
テキストベースでは無くASTベースでコードを解釈して表記するようなシステムが流行ったらまた違うのかもしれない(その場合のマクロの取り扱いがどうなるかは分からないが)