マクロと戯れるだけの回@Nim


ゆるふわにNimを使おうっていう回です。息抜きにどうぞ。
この記事に記載している例は、すべて最新バージョンのNim(C言語バックエンド)で動作を確認しています。

おしながき

  • マクロを活用していない人たちへ
  • マクロの簡単な解説
  • Nimのマクロのユースケース
  • マクロのレシピ

マクロを活用していない人たちへ

言語によっては黒魔術扱いされていて、使ってはいけないみたいなイメージがあるのかもしれません。
マクロを使うメリットや、使う場面が良くわからないなんて人もいるのかも。
ですが、マクロはNimの中核的な機能です。使わないなんてもったいないですし、適切にマクロを使うことでむしろ開発者体験を向上させることができます。
この記事では、そんなマクロの使い方や使用場面、注意点などをつらつらと書いていきます。記事を通じて、実はメタプログラミングはそんなに怖いものじゃないってことが伝わればうれしく思います。

マクロの簡単な解説

Nimにおいてマクロというのは、コンパイル時に実行されて、コードを書き換えたり生成したりする関数のことです。

Nimにおいて、マクロの中でAST(Abstract Syntax Tree: 抽象構文木)を組み立てる方法は大まかに3つあります。
1つめは、NimNodeオブジェクトを直接組み立てていく方法です。ですがこの方法では、IDEの恩恵を受けづらく、また、書くコードの量もかなり増えてしまいます。しかも、コンパイルエラーを出したときのエラーメッセージがとても読みづらくなります。

import macros

macro nimNodeSample(head: untyped, body: untyped): untyped =
  result = newStmtList()
  result.add(
    block:
      newNimNode(nnkForStmt)
        .add(newIdentNode("i"))
        .add(
          newNimNode(nnkInfix)
            .add(newIdentNode(".."))
            .add(newIntLitNode(0))
            .add(newIntLitNode(5))
        )
        .add(newCall(newIdentNode("echo"), head))

  )
  result.add(body)

nimNodeSample "test message":
  echo "Bang!"

# output:
# test message
# test message
# test message
# test message
# test message
# test message
# Bang!

こんな感じです。はっきり言って地獄。

2つ目の方法は、parseStmt関数を利用し、文字列をコンパイル時にASTへとパースして組み立てる方法です。しかしながら、この方法でもIDEの恩恵を受けづらいです。コンパイラのエラーメッセージがやばくなるのも1つ目の方法と全く変わらず、メリットはNimのコードを直接かけることくらいしかないです。というか、この方法が一番修羅の道だと思います。操作する対象が文字列なので、コード補完などが一切動きません。なので、この方法は全くお勧めできないです。

import macros
import strformat

macro parseStmtSample(ident: untyped): untyped =
  parseStmt &"""
for i in 0..5:
  echo "{ident.strVal}"
"""

parseStmtSample HelloWorld

# output:
# HelloWorld
# HelloWorld
# HelloWorld
# HelloWorld
# HelloWorld
# HelloWorld

3つ目の方法は、quoteマクロを使う方法です。他の言語の準引用、例えばLispにおける準引用と似た機能を提供するマクロで、個人的おすすめは圧倒的にこれです。少し癖はありますがIDEの恩恵を受けることができますし、コンパイラのメッセージも読みやすいです。これを使わない理由がむしろないと思います。この記事では、このquoteマクロを使って、マクロを記述します。

import macros

macro quoteDoSample(head: untyped, body: untyped): untyped =
  let arg = newIdentNode("arg1")
  quote do:
    proc `head`*(`arg`: Natural) =
      for i in 0..`arg`:
        `body`

quoteDoSample testFun:
  echo "It's so easy!"

testFun(4)
# output:
# It's so easy!
# It's so easy!
# It's so easy!
# It's so easy!
# It's so easy!

Nimのマクロのユースケース

マクロの使用場面はいくつかあります。

たとえば、型などが違うだけの、まったく同じような処理を複数書く場合にはmacroが有効です。

import macros

macro `$$`(node: untyped): untyped =
  quote do:
    block:
      var res = "["
      for item in `node`:
        res = res & $item & ", "
      res[0 .. ^2] & "]"

expandMacros:
  echo $$[1, 2, 3]

# echo [block:
#   var res`gensym0 = "["
#   for item`gensym0 in items([1, 2, 3]):
#     res`gensym0 = res`gensym0 & $item`gensym0 & ", "
#   res`gensym0[0 .. BackwardsIndex(2)] & "]"]

こんな一般的な処理、イテレータやイテラブルの型ごとになんて書いていられませんよね。
こういう処理は得てしてあるもので、マクロを使えばボイラープレートをかなり減らすことができます。

上記の例であればtemplate/genericでも良いのですが、この2つと違って、macroはAST(抽象構文木)を操作できます。
たとえば、次の例はtemplate/genericを代用することはできません。

import macros

macro msg(name: untyped, body: untyped): untyped =
  let arg1 = newIdentNode("message")
  let arg2 = newIdentNode("to")
  quote do:
    proc `name`*(`arg1`: string, `arg2`: string) =
      `body`

msg test:
  echo "to ", to, ": ", message

test("hello", "hoge")
# output:
# to hoge: hello

なぜなら、この例では直接的にASTを操作しているからです。
直接ASTを操作しない場合は、templateを使った方がいいかもしれません。
ですが上記の例のように、直接ASTを弄るならmacroが必要です。

マクロのレシピ

マクロを扱ううえで、重要なpragmaが3つあります。
{.compileTime.}{.inject.}{.gensym.}です。

compileTime プラグマ

compileTimeプラグマを付けた関数や変数は、コンパイル時にのみ利用可能になります。

import macros

var nodes {.compileTime.}: seq[NimNode] = @[]

macro onInit(body: untyped): untyped =
  let name = genSym(nskProc, "init")
  result = quote do:
    proc `name`() =
      `body`
  nodes.add(
   quote do:
     `name`()
  )

macro initialize(): untyped =
  result = newStmtList()
  for node in nodes:
    result.add(node)

onInit:
  echo "Hello"
onInit:
  echo "World"

initialize()
# output:
# Hello
# World

inject プラグマ

injectプラグマは、識別子がmacroおよびtemplateの外側でも有効であることを明示するプラグマです。

import macros

macro vars(): untyped =
  quote do:
    let hoge {.inject.}: int = 1

vars()
echo $hoge

この例において、varsマクロ内のhogeの宣言についているinjectプラグマを外すと、コンパイルエラーが発生するようになります。このプラグマは、マクロを使って型や変数、定数を定義したいときに有効です。

ただし、procやfunc、methodの引数にはinjectプラグマが使えず、かつシンボル生成が有効となります。これらの場合でinjectのようなことをしたい場合は、以下のようなワークアラウンドを利用することになります。

import macros

macro defineProc(name: untyped, body: untyped): untyped =
  let arg = newIdentNode("arg")
  quote do:
    proc `name`*(`arg`: int): int =
      `body`

defineProc testProc:
  arg * 2

echo $testProc(5)
# output:
# 10

引数の識別子部分は別途生成し、それも一緒に埋め込めば期待したような動作になります。

gensym プラグマ

gensymプラグマは、injectプラグマとは逆に、識別子がtemplateおよびmacroの外側で有効ではないことを明示するプラグマです。
型や変数、定数以外のものによって利用される識別子のうち、macroやtemplateの外側において有効であるべきではないものにつけます。

一時的に利用する型や関数につけるというような用途があるのですが、injectに比べるとやや使用頻度が少ない印象があります。

import math
import macros

macro calcOnce(name: untyped, ftype: untyped, body: untyped): untyped =
  quote do:
    proc calc(): `ftype` {.gensym.} =
      `body`
    let `name` = calc()

calcOnce test, int:
  2 ^ 10 - 1

echo $test

おわりに

Nimにおいて、macroというものはとても大きな地位を占めています。C/C++でほかの言語で嫌われているからといってメタプログラミングを忌避するのでは、真にNimを活用しているとは言えないと思います。
この記事を読んで、macroの便利さがちょっとでも伝わっていたら幸いです。