Closed31

Nimのマクロを調べてみる

hasturhastur

目的

Nimでのマクロ定義を調べてNimでのマクロについて学ぶ。
具体的にはECSで渡された関数を解析して自動的に引数を割り当てて呼び出すという処理を構築する。

hasturhastur

マクロ

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")
    )
  )

参考

公式ドキュメント
https://nim-lang.org/docs/macros.html

hasturhastur

マクロを解析・構築するための機能

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"

(見やすくするために空の行を追加している)

hasturhastur

staticは実行時では無くコンパイル時に実行するのを明示するために必要になる。

hasturhastur

繰り返しになるがNimのマクロはASTを操作する処理である。
なので何を実現するにも最終的にはASTを構築してNimの文法的に正しい形に収める必要がある。

「ASTノードを操作するためのプログラムを組む」のがマクロの構築。

hasturhastur

補足:
C言語のdefineマクロに相当するモノも存在するが今回は言及しない(templateキーワードで構築する)

hasturhastur

マクロの構築

macro helloMacro(): untyped =
  nnkStmtList.newTree(
    nnkCommand.newTree(
      newIdentNode("echo"),
      newLit("hello")
    )
  )

を例にマクロを構築する方法を見ていく。

まずマクロは通常の関数と違いprocではなくmacroで定義する。
構築したASTを返す場合はuntypedで返す。untypedは未評価な値を指しコンパイル時に展開されてその後に評価される。
またAST自体を関数で受け取りたい場合もuntypedで受け取る。

Nimプログラム内でASTはNimNode型として扱われていて、実際の操作はこのNimNode型を通して行う(詳しい型の要件は公式ページを参照)

hasturhastur

マクロというと難しいイメージも有るが結局のところASTを操作するだけであり、いわゆるノードを組み替えるだけとも言える。

問題はASTが普段打ち込んでいるテキストの形式と大幅に異なっている点でテキストからASTを、ASTからテキストを想像することが難しい点で、コレがマクロの構築難易度を大きく上げている。
dumpTree,dumpAstGen,treeReprをうまく使って理解を深めていきたい。

hasturhastur

余談:
Lispのマクロが優れていると言われるのは単にASTを操作するだけではなくプログラムの構造自体が、ほぼそのままASTと一致しているからというのもある。

そもそもASTがどうなるとかプログラムを書いてて意識なんてしないわけで、言語の実装でもやってなかったら、まずそこで詰む。
マクロを構築するためにプログラミング言語自体の処理の流れ(字句解析、抽象構文木etc)を理解していないといけないのが非常に面倒。

hasturhastur

マクロを組む

proc updatePosition(pos: var Position, vel: Velocity) =
  pos.x += vel.x
  pos.y += vel.y

world.system(updatePosition)

みたいな事を実現する。

何で態々やるかというと

  • componentの情報をできるだけ外部に漏らしたくない
  • 型情報を基にcomponentを取得する方が便利
  • 趣味(型を活かしたメタプログラミングを構築する場合にタプルを使用して型情報を伝播させる方式がrust, zigでは主流に感じるのでNimでも同じような事が出来ないか気になったから)
hasturhastur

ゲームプログラミングにあるまじきことだがパフォーマンスは気にしない、冗長でもよし、とにかく実現できるのかを確認することを目的とする。

hasturhastur

関数の引数の解析

まずは関数の引数をタプル型として生成する。

hasturhastur

タプル型の生成

macro argsToTupleType(): type =
  result = newNimNode(nnkTupleTy)
hasturhastur

解析して型として返すマクロ

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)))
hasturhastur
let typ = getTypeInst(prc)

受け取ったNimNodeでは関数の引数を解析できない場合があるのでgetTypeInstで元の宣言のASTを取得。
testProcという関数を渡した場合に、そのままだとシンボル情報しか渡されないので、その対策。

以下はそれぞれprctyptreeReprした場合の出力

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
hasturhastur
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に宣言部分を追加。

hasturhastur

余談:
ASTを解析して構築するだけなので簡単といえば簡単。
ASTを解析して構築するとか普段やらないので面倒と言えば面倒(getTypeInstのところとか)。

マクロはコンパイル時に展開して始めてエラーを吐き出すので、ちゃんとテストしないと特定のASTしか受け付けないマクロになる。
が、テストケースを考えるには書いているコードがASTに展開されたときにどうなるかを想像しなくちゃいけないのがハードル高い。

言語の仕様に対する理解度を試されている気分。

hasturhastur

任意の関数に独自の引数を渡して呼び出す

関数に渡された関数へ関数の引数を基に取得したデータを渡す。

hasturhastur

関数を呼び出すコードを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")
    )
  )
)
hasturhastur

生成されたコードを参考にマクロを実装。

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

関数と関数の引数情報を保持したタプルを受け取ってループのカウンタを引数に追加して呼び出す。

hasturhastur

呼び出し

callProcWithArgsTuple(testProc, argsToTupleType(testProc.type))

実行結果

testProc: 0 1 2.0
hasturhastur

わざわざタプルを渡すのは冗長なので省略したいのだがマクロ内でargsToTupleType(cb.type)を呼びだすとcb.typeproc型ではなくNimNode型として処理されてしまうので省略できない。

proc callProc(cb: proc) =
  callProcWithArgsTuple(cb, argsToTupleType(cb.type))

として展開のタイミングを変更することで一応、対応可能。

hasturhastur

タプルを基にして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をマクロを使って呼び出す。

hasturhastur
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を確認しながらマクロを構築する。

hasturhastur

いきなり完成形。

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
hasturhastur

dumpAstGenで表示されたコードを基に必要な情報を置き換えて実装。
この方式ならテキストから直接ASTを生成できるparseStmt関数を使った方が分かりやすいと思われる。

直接getComponent何かを呼び出すNimNodeを構築するのはなんか違う気がするので必要最小限の部分だけマクロとして後は通常の関数内で呼び出す方が良さそう。

hasturhastur

感想

物凄く雑な実装だが目的は達成できたので感想。

構築しやすかったかというと構築しやすくはない。ただASTを直接構築できるので出来ることの幅は非常に広い。
ただ、今回みたいな使い方との相性はイマイチに感じる。

DSLを構築したり構文を拡張する目的なら問題ないんだろうけど型情報を活かしたいのにコンパイル時に動作する関係で受け取った変数が大体NimNodeになって逆に型情報が失われてしまうという。
書きあがった後は問題ないが構築している最中はNimNodeになってしまうので色々と困った。

マクロに限った話ではないが1つの関数は極力小さくして必要な情報だけ適宜持ってくるようにした方が良さそう(長いマクロでNimNodeにどんどん情報を追加していると最終的にどうなるのか分からなくなりそう)

良かった点としては、ASTを出力したり変換する関数が多数用意されているのでASTが分からなくてもとりあえず出力して確認できる点。もう1つはコンパイルエラーが比較的分かりやすい点。
まあコンパイルエラーはマクロ展開後にエラーだったら出るだけなのでマクロのどこがおかしいかを直接出してくれるわけでは無いんだけど素直なエラー(C++のtemplateと比較して)が多かったので推測しやすかった。

hasturhastur

書いてて思ったこととしてはZigのcomptimeのほうがシンプルだよなと。

マクロがコンパイル時に実行する関係上、呼び出す関数や値などはコンパイル時に確定している必要がありコンパイル時に動作するかどうかを考える必要があった。
Nimはマクロ(macro, template)とcompileTimeプラグマとstaticの挙動を意識して構築する必要がある一方でZigだとコンパイル時に動作するかだけを考えればよいのでZigのほうがシンプルだなと。

ただZigはNimのようにASTを構築することはできないので、あくまで型情報を渡すという点に関してはZigのcomptimeのほうが考えることが少ないという話。

hasturhastur

Nimのマクロに対する理解は深まったが今後積極的にマクロを書くかというと書かない。
考え方は色々だが個人的には冗長でも良いからマクロを使わずに基本的な言語機能だけで書いた方が良いかなと。

ただDSLを構築したいとか抽象化目的なら話は変わってくるかもしれない。

hasturhastur

継続を実装出来たりマクロ(同図像性)自体には可能性を感じるんだけど如何せん分かりにくい。
https://github.com/nim-works/cps

テキストベースでは無くASTベースでコードを解釈して表記するようなシステムが流行ったらまた違うのかもしれない(その場合のマクロの取り扱いがどうなるかは分からないが)
https://dion.systems/gallery.html

このスクラップは2021/10/28にクローズされました