⚜️

NimでTypeScriptのPick<T, Keys>を実装する

2023/09/01に公開

こんにちは。今回は、TypeScriptのユーティリティ型の1つのPick<T, Keys>を、Nimのマクロを使って実装します。

Pick<T, Keys>

サバイバルTypeScriptでは、Pick<T, Keys>はこのように説明されています。

Pick<T, Keys>は、型TからKeysに指定したキーだけを含むオブジェクトの型を返すユーティリティ型です。

Pick<T, Keys>はある型を元にしてデータ構造を定義したいときに便利です。

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, "title" | "completed">;

const todo: TodoPreview = {
  title: "Clean room",
  completed: false,
};

TypeScriptでは型の1つとして振舞いますが、NimではTypeScriptほど高度な型パズルはできないため、マクロを用いて実装します。

実装

まずは引数から考えます。型を1つ、プロパティ名を無制限に受け付ける次のようなシグネチャにします。

macro Pick*(T: typed, keys: varargs[untyped]): type

引数は、1つ目に元となる型T、それ以降に複数の識別子を受け付けるvarargskeysです。keysではプロパティ名だけ取得できればいいので、untypedの識別子を取るようにしています。

次に、返り値について考えます。

type
  Todo = ref object
    title: string
    description: string
    completed: bool

type
  TodoPreview = Pick(Todo, title, completed)

Pickマクロはオリジナル同様新しい型を作れるようにしたいので、このような形で実装するのが理想です。

最後の行の右辺は次のように展開されます。

type
  TodoPreview = ref object
    title: string
    completed: bool

このコードは、抽象構文木に直すとこうなります。

TypeSection
    TypeDef
      Ident "TodoPreview"
      Empty
      RefTy
        ObjectTy
          Empty
          Empty
          RecList
            IdentDefs
              Ident "title"
              Ident "string"
              Empty
            IdentDefs
              Ident "completed"
              Ident "bool"
              Empty

この構文義に従えば、RefTyから始まるノードを返せば実装できそうです。これらの情報を元に定義したPickマクロがこちらになります。

Pickマクロ(試作)
proc decomposeIdentDefs*(identDefs: NimNode): seq[NimNode] {.compileTime.} =
  for name in identDefs[0..^3]:
    result.add newIdentDefs(
      name = name,
      kind = identDefs[^2],
      default = identDefs[^1]
    )

proc pickMatchingIdentDefs(
    identDefs, keys: seq[NimNode]
): seq[NimNode] {.compileTime.} =
  for k in keys:
    var res: seq[NimNode]
    for identDef in identDefs:
      if k.eqIdent identDef[0].basename:
        res.add identDef

    if res.len == 0:
      error fmt"Key {k} does not exist", k

    result.add res

macro Pick*(T: typed, keys: varargs[untyped]): type =
  # Tから型定義のノードを取得
  let implNode = getImpl(T)
  # T型のプロパティのidentDefsを格納するseq
  var variables: seq[NimNode]

  for identDefs in implNode[2][0][2][0..^1]:
    variables.add decomposeIdentDefs(identDefs)

  result = nnkRefTy.newTree(
    nnkObjectTy.newTree(
      newEmptyNode(),
      newEmptyNode(),
      newEmptyNode()
    )
  )

  let recList = nnkRecList.newNimNode()
  recList.add variables.pickMatchingIdentDefs(keys.toSeq())
  result[0][0][2][0][2] = recList

受け取った型がref objectである前提で処理しています。
decomposeIdentDefs()プロシージャは、そのままでは扱いにくい下記のようなidentDefsをすべて分解し同じ構造にして返します。

type ...
  a, b: int
  c, d: string
type ...
  # すべて分解
  a: int
  b: int
  c: string
  d: string

pickMatchingIdentDefs()は、名前の通り指定した名前のidentDefsを返し、存在しない名前の場合はエラーを吐くプロシージャです。

しかし、このPickマクロを実行してみるとエラーを吐いてしまいます。実は、型定義の式の中での展開は一筋縄ではいきません。
一見するとPickマクロはnnkRefTyから始まるノードを返せばいいだけのように思えますが、型定義の式の中ではマクロの扱いが難しいためこの実装ではコンパイルが通りません。しかし、裏技のような解決策として、NimNodeKindの1つであるnnkStmtListTypeを使うことができます。
これはNim forum内のType macro returning a transformed type def + other definitionというスレッド内のあるリプライで言及されていますが、大まかに言えばnnkStmtListTypeを返すマクロ/テンプレートなら型定義の式の右辺に書くことができるというものです。マイナーかつ裏技的な手法のため書き方も多少複雑になりますが、大体のロジックは完成しているのであとはこの手法に沿って返り値をつくればいいでしょう。
そして、完成したPickマクロがこちらです。

Pickマクロ(二代目)
macro Pick*(T: typed, keys: varargs[untyped]): type =
  let implNode = getImpl(T)
  var variables: seq[NimNode]
  var internal: NimNode

  result = nnkStmtListType.newNimNode()

  for identDefs in implNode[2][0][2][0..^1]:
    variables.add decomposeIdentDefs(identDefs)

  internal = quote do:
    type Internal = ref object
    Internal

  let recList = nnkRecList.newNimNode()
  recList.add variables.pickMatchingIdentDefs(keys.toSeq())
  internal[0][0][2][0][2] = recList

  internal.copyChildrenTo result

最初のものと同様、受け取った型をref objectとみなして処理しています。
Internalという型を定義していたり、最後にcopyChildrenTo()を使っていたりするのはnnkStmtListType固有の書き方なので、詳しくはリンク先をご覧ください。

これでようやくコンパイルが通り、きちんと実装できました!

type
  User = ref object
    name: string
    age: int
    address: string

type
  Person = Pick(User, name, age)

proc new(_: type Person, name: string, age: int): Person =
  result = Person(name: name, age: age)

let p {.used.} = Person.new("Steve", 100)

最後にobject型にも対応し、エラーメッセージを加えたコード全文を置いておきます。

コード全文
Pickマクロ(完成形)
import
  std/macros,
  std/sequtils,
  std/strformat

proc decomposeIdentDefs*(identDefs: NimNode): seq[NimNode] {.compileTime.} =
  for name in identDefs[0..^3]:
    result.add newIdentDefs(
      name = name,
      kind = identDefs[^2],
      default = identDefs[^1]
    )

proc pickMatchingIdentDefs(
    identDefs, keys: seq[NimNode]
): seq[NimNode] {.compileTime.} =
  for k in keys:
    var res: seq[NimNode]
    for identDef in identDefs:
      if k.eqIdent identDef[0].basename:
        res.add identDef

    if res.len == 0:
      error fmt"Key {k} does not exist", k

    result.add res

macro Pick*(T: typed, keys: varargs[untyped]): type =
  let implNode = getImpl(T)
  var variables: seq[NimNode]
  var internal: NimNode

  result = nnkStmtListType.newNimNode()

  case implNode[2].kind
  of nnkRefTy:
    for identDefs in implNode[2][0][2][0..^1]:
      variables.add decomposeIdentDefs(identDefs)

    internal = quote do:
      type Internal = ref object
      Internal

    let recList = nnkRecList.newNimNode()
    recList.add variables.pickMatchingIdentDefs(keys.toSeq())
    internal[0][0][2][0][2] = recList

  of nnkObjectTy:
    for identDefs in implNode[2][2][0..^1]:
      variables.add decomposeIdentDefs(identDefs)

    internal = quote do:
      type Internal = object
      Internal

    let recList = nnkRecList.newNimNode()
    recList.add variables.pickMatchingIdentDefs(keys.toSeq())
    internal[0][0][2][2] = recList

  else:
    error "only ref object and object are acceptable", implNode[2]

  internal.copyChildrenTo result

終わりに

ネットサーフィンでnnkStmtListTypeを見かけたときにユーティリティ型のPick<T, Keys>のことを思い出したので実装してみました。他のユーティリティ型もなんとかして実装できれば、Nimで擬似型パズルも可能なのでは…と夢見てしまいます。

GitHubで編集を提案

Discussion