Flat Leon Works

アプリやゲームを作ってます。

【Nim】クラス定義マクロを作ってみる

Nimにはクラスという概念がありません。しかし、オブジェクト指向プログラミング自体はサポートされており、object型を使えばクラスとほぼ同じことが可能になっています。ただし、object型の定義が微妙に面倒なことに加え、やはりクラスが欲しいということで、簡潔にクラス(object型)を定義できるマクロを作ってみました。

注意

  • とりあえず作ってみたレベルなので、実際に使ってみると何か問題が出てくるかもしれません
  • 執筆時のNimバージョン : 0.17.2

クラス定義マクロ

できあがったクラス定義マクロを使ったコードがこちらです。

# クラス定義
class Hoge:
    # クラススコープの型定義 (Hoge.Flagsなどでアクセス可能)
    type Flags = enum On, Off
    type MyInt = int

    # メンバ変数定義
    var x: int
    var y: int

    # メンバ関数定義(第一引数としてself:Hogeが挿入される)
    proc test(a:int,y:int):bool =
        echo "Hoge.x=", self.x
        return false

今回作ったクラス定義マクロは以下のような特徴があります

  • 通常の方法でobject型を定義するより簡素な記述方法で定義可能
  • メンバ関数の定義が可能
  • クラススコープの型定義が可能

それぞれ紹介します。

簡素な記述

まず、マクロを使わないで普通にobject型を定義する方法を紹介します。

type Hoge = ref object of RootObj
    x: int
    y: int

問題はref object of RootObjです。1つのobject型を定義するのにこれを毎回入力するのはかなり面倒です。いちおう、type hoge = objectという記述でもobject型を定義できるのですが、派生できない、代入時にコピーされてしまう、デストラクタが定義できない、などいろいろな挙動が変わってしまうので、Nimではref object of RootObjが基本的なobject型の定義方法だと思っています。

これをclassマクロを使えば以下のように簡潔に記述できます。

class Hoge:
    var x: int
    var y: int

基底クラスを指定したい場合は以下のように2通りの記述方法が利用できます。

class Hoge of Base:
    var x: int
    var y: int

class Hoge(Base):
    var x: int
    var y: int

メンバ関数の定義が可能

Nimのobject型にはメンバ関数という概念はありません。そのかわりにメソッド構文というプロシージャをメンバ関数のように呼び出すことができる構文があります。この構文は第一引数.プロシージャ名(第二引数...)というような記述方法になります。なのでNimでメンバ関数相当のものを定義するには、第一引数でそのクラスのインスタンスを受け取るようにします。

proc test(x:Hoge) = discard # Hoge型.test()でアクセスできる -> つまり、Hoge型のメンバ関数として扱える

classマクロでは、第一引数にクラス型引数を自動で挿入することで楽にメンバ関数(相当のもの)を定義できるようにしています。また、classブロック内に記述することになるので、メンバ関数ということがよりわかりやすくなります。

class Hoge:
    var x: int
    var y: int
    proc test() = discard
    # マクロによって以下のように第一引数にself:Hogeが追加される
    # proc test(self:Hoge) = discard

クラススコープの型定義が可能

C++ではクラス定義内でtypedefやenum定義をするとそのクラスに属するものとして扱われます。つまりクラススコープというものが存在します。Nimにはこのようなものが存在しません*1。ですが、templateを使うことで似たようなものを実現することができます。

type Hoge = ref object of RootObj
    x: int
    y: int

template Flags(T:typedesc[Hoge]) : untyped = `T Flags` # Hoge.Flags を HogeFlagsへと変換するtemplate
type HogeFlags = enum On, Off # Hoge.FlagsはHogeFlagsへと変換されるのでHogeFlagsという名前にしておく

var f: Hoge.Flags # クラススコープのようなものを実現

イデアとしては、メソッド記法とtemplateの識別子結合機能(Identifier construction)を使って、クラス名.型名というコードをクラス名型名に変換するというものです。これによってクラス名.型名という記述方法が可能になり、利用者側からみたらクラススコープとして扱うことができるようになります。型の定義側は、型名の頭にクラス名を付けることと、クラス名.型名というアクセスを可能にするためのtemplateを定義する必要があります。classマクロはこの作業を自動化します。

class Hoge:
    x: int
    y: int
    type Flags = enum On, Off # クラススコープで型(enum)定義
    # classマクロによりFlagsはHogeFlagsという型名になる
    # また、Hoge.Flagsでのアクセスを可能にするためのtemplateが自動で挿入される

var f: Hoge.Flags # Hoge.Flagsでアクセス可能

classマクロによるコード展開例

冒頭のclassマクロを使ったコード例はclassマクロによって以下のように展開されます*2

type Hoge = ref object of RootObj
    x: int
    y: int
type HogeFlags = enum On, Off
type HogeMyInt = int
proc test(self:Hoge,a:int,y:int):bool =
    echo "Hoge.x=", self.x
    return false
template Flags(T:typedesc[Hoge]) : untyped = `T Flags`
template MyInt(T:typedesc[Hoge]) : untyped = `T MyInt`

classマクロ実装コード

とくにライセンスとかは設定しないので、ご自由にお使いください。ただし動作保証はしません。

{.experimental.}

import typetraits
import macros
import strutils

type TraverseOp = enum Continue, Break, SkipChild, SkipSibling, Finished
proc traverse(n: NimNode; action:proc(n:NimNode;parents:ref seq[NimNode]):TraverseOp; parents:ref seq[NimNode] = nil ):TraverseOp {.discardable.} =
    var parents = parents
    if parents == nil:
        parents.new
        parents.newseq( 0 )
    parents.add( n )
    defer:
        echo "discard parents.pop()"
        discard parents.pop()
        assert( false ) # Nimコンパイラのバグでdeferブロックが実行されない(コンパイル時処理のみっぽい?)

    for it in n.children:
        case action( it, parents )
        of Continue: discard
        of Break: return Break
        of SkipSibling: return SkipSibling
        of SkipChild: continue
        else: assert false

        case traverse( it, action, parents )
        of Break: return Break
        else: discard
    return Finished

proc findNode(n:NimNode,kind:NimNodeKind):NimNode=
    var ret: NimNode
    n.traverse do (n:NimNode;parents:ref seq[NimNode]) -> TraverseOp:
        if n.kind == kind:
            ret = n
            return Break
    result = ret

proc convertToMemberProc( procDefNode:NimNode, className:string ) =
    ## プロシージャをメンバ関数化する -> 第一引数に指定の型のselfパラメータを追加するだけ

    var formalParamsNode = procDefNode[3]
    formalParamsNode.expectKind( nnkFormalParams )
    var selfIdentNode = newIdentDefs( ident("self"), ident(className) )
    formalParamsNode.insert( 1, selfIdentNode )

macro classproc(className:untyped,stmtList:untyped):untyped=
    var classNameStr = `$`(className.ident)
    stmtList.traverse do (n:NimNode;parents:ref seq[NimNode]) -> TraverseOp:
        case n.kind
        of nnkProcDef, nnkMethodDef, nnkIteratorDef:
            n.convertToMemberProc( classNameStr )
        else: discard
    result = stmtList

proc newClassDef(classNameIdent,baseNameIdent,classBody:NimNode):NimNode=
    ## クラス名ノード、基底クラス名ノード、フィールド定義ノードからクラス定義ノード(実際はtypeセクション)を作成する

    # [1].とりあえずフィールド無しのobject型定義ノードを作成
    # [2].classBodyを走査しidentDefsを見つけ次第、[1]内のRecListへコピーしていく
    #     また、ProcDefを見つけた場合は、第一引数にselfを追加してから、resultノードへProcDefを追加する

    # [1]
    result = quote:
        type `classNameIdent` = ref object of `baseNameIdent`

    if classBody == nil: return

    # RecListノードを取得(なければ作る)
    var objectTyNode = result.findNode( nnkObjectTy )
    if objectTyNode[2].kind == nnkEmpty:
        objectTyNode.del( 2 )
        objectTyNode.add( newNimNode( nnkRecList ) )
    var recListNode = objectTyNode[2]

    # [2]
    var result2 = result
    classBody.traverse do (n:NimNode;parents:ref seq[NimNode]) -> TraverseOp:
        case n.kind
        # プロシージャの引数にselfを追加
        of nnkProcDef, nnkMethodDef, nnkIteratorDef:
            var newNode = n.copyNimTree()
            result2.add( newNode )
            newNode.convertToMemberProc( `$`(classNameIdent.ident) )
            return SkipChild
        # 変数定義はフィールド定義へ追加する
        of nnkIdentDefs:
            recListNode.add( n )
            return SkipChild
        # 型定義は型名をクラス名+型名に変更する
        of nnkTypeSection:
            # TypeSection内の識別子にクラス名を挿入
            n.traverse do (n:NimNode;parents:ref seq[NimNode]) -> TraverseOp:
                case n.kind
                of nnkTypeDef:
                    var parentNode = parents[^1]
                    if parentNode.kind == nnkTypeSection:

                        # クラス名.型名でアクセスできるようにするためのヘルパーtemplateを定義
                        var helperTemplateStr = "template $1(T:typedesc[$2]) : untyped = `T $1`".format( `$`(n[0].ident), `$`(classNameIdent.ident) )

                        n[0].ident = !(`$`(classNameIdent.ident) & `$`(n[0].ident))
                        result2.add( parseStmt( helperTemplateStr ) )
                    return SkipChild
                else:discard
            result2.add( n.copyNimTree() ) # TypeSectionまるごとコピー
            return SkipChild
        else: discard

macro class(className:untyped,classBody:untyped):untyped=
    # クラス名と基底クラス名を取得
    var classNameStr:string
    var baseNameStr:string
    case className.len()
    of 0: # class a
        classNameStr = `$`(ident(className))
    of 2: # class a(b)
        classNameStr = `$`(ident(className[0]))
        baseNameStr = `$`(ident(className[1]))
    of 3: # class a of b
        classNameStr = `$`(ident(className[1]))
        baseNameStr = `$`(ident(className[2]))
    else: assert false
    result = newClassDef( ident(classNameStr), if baseNameStr!=nil:ident(baseNameStr) else:ident("RootObj"),classBody)

#######################
# 使用例
#######################
class Hoge:
    # クラススコープの型定義 (Hoge.Flagsなどでアクセス可能)
    type Flags = enum On, Off
    type MyInt = int

    # メンバ変数定義
    var x: int
    var y: int

    # メンバ関数定義(第一引数としてself:Hogeが挿入される)
    proc test(a:int,y:int):bool =
        echo "Hoge.x=", self.x
        return false

# クラススコープで定義したenumを利用
var f: Hoge.Flags
echo f # On

# クラススコープで定義した型を利用
var myInt: Hoge.MyInt = 10
echo myInt # 10

# あとからメンバ関数を追加するためのマクロもあり
classproc Hoge:
    proc test2(a:int,y:int):bool =
        echo "Hoge.test2()"
        return false

# クラス(object型)として普通に利用できる
var temp = Hoge(x:77)
echo "$1, $2".format( temp.x, temp.y )
discard temp.test(1,2)
discard temp.test2(1,2)

改善案

  • コンストラクタを定義可能に
  • メンバ変数の初期値指定
  • staticメンバ変数
  • staticメンバ関数
  • メンバのアクセス制御(といってもNimはモジュール内外のアクセス制御しかないが…)
  • Self型、Base型エイリアス

Nim by Example の OOPマクロとの比較

このようなクラス定義マクロは公式?の Nim by Example にも存在しています。今回作ったマクロも「Nim by Example」のOOPマクロを参考にしています*3

Nim by Example - OOP Macro

「Nim by Example」のOOPマクロと今回作成したclassマクロの違いは以下とおりです。

  • OOPマクロは基底クラスの指定が必須になっている
  • classマクロの方は基底クラスの指定方法としてof以外にclass クラス名(基底クラス名):の形式にも対応している
  • classマクロの方はクラススコープの型定義機能がある

*1:ちなみに名前空間もモジュールごとの名前空間しか存在しません。

*2:実際にはこのようにテキストで展開されるのではなく、ASTとして展開される

*3:コードはマクロの勉強も兼ねて一から書き上げました