🙌

Codex CLIに静的型付き関数型言語TreePを作らせてみた

に公開

挨拶

こんにちは!株式会社ネクストビートでテクノロジー・エバンジェリストなる肩書きでお仕事をしている水島です。最近は以前からやっている業務に加えて、(もちろん業務ですが)LLMのLoRA作成のPoCにも挑戦したりして、少しワクワクする日々です。

最近は皆さんもコーディングAIを使う機会が増えているかと思いますが、私はClaude CodeでなくCodex CLIに浮気中です。もちろん、Claude Codeの能力が低いわけではありません。ただ、「深い理解」を必要とするタスクにはCodex CLI(GPT-5)の方がミスを犯しにくいという実感があるからです。

そんなCodex CLIを使って、トイ言語より難易度が高めの静的型付き関数型プログラミング言語を作らせてみたというのが今回の記事です。ネタとしては以前かいたこの記事と似ていますが、今回作らせたTreePの方がより実用的な仕様だといえます。

TreePの概要

TreeP: Tree Processorは、静的型を持った関数型プログラミング言語で、かつ、抽象構文木を使ったマクロを簡単に定義できるプログラミング言語です。わかる方ならわかるかと思いますが、TreePという名前自体がそもそもLisp(List Processor)のもじりです。

TreePのコンセプトとして

  • 小さいメタ構文: XMLのような要素+属性モデル
  • 具象構文は普通に書ける
    • LispやXML系のプログラミング言語と違って「具象構文は普通に見える」
  • 抽象構文木に対するマクロが簡単に作れる
    • Lisp系言語っぽさ
  • Hindley-Milner型推論をもった関数型プログラミング言語
    • 引数や返り値の型を推論可能
    • Standard MLやOCamlなどの型推論と同じ原理に基づくものです

があります。これくらいリッチな言語を人間が書くと1週間はかかってしまいそうですが、Codex CLIなら1日や2日でいけるだろうと楽観的に考えて作らせてみることにしました。

できたリポジトリがこちらです。

AIとのペアプログラミング

Codex CLI(GPT-5)に加えて、終盤はClaude Code(Sonnet 4.5)に整えてもらう方向で実装を進めました。言語の骨格や型システムの主要な部分はCodex CLIで、細かい調整やデバッグはClaude Codeで、という感じです。

当初はCodex CLIだけでやろうと思ったのですが、計画を詰める能力についてはClaude Codeの方がまだ優れている印象で、一長一短あることを痛感しました。これはモデル自体の能力よりもツールとしての作り込みの問題かもしれません。

実装の進め方

最初は「Hindley-Milner型推論を持っていて、LispのS式のようなメタ構文EASTを持っている言語」くらいの大まかな指示から始めました。すると、Codex CLIはだいたい以下のような構成を提案してくれました(細かいところで指示を入れましたが割愛):

  1. 文字列 → Lexer(字句解析)→ Parser(構文解析)→ CST(具象構文木)
  2. CST → Normalize(正規化)→ EAST(Element AST:要素ベースの抽象構文木)
  3. EAST → Macro Expansion(マクロ展開)→ EAST
  4. EAST → Type Checker(型検査)→ (検査結果, EAST)
  5. EAST → Interpreter(インタプリタ)→ 結果

このパイプライン構成は割と王道な形ですが、抽象構文木の代わりに「EAST」という中間表現を挟むことで、マクロ定義と展開を簡単に実行できるようにしています。そのような中間表現を作るにしても「Lispのようなマクロを」みたいな雑な表現から意図を汲んでくれるのはさすがですね。

EASTとは

EASTは、すべてのノードがElement(kind, name, attrs, children, span)という統一された構造を持つAST形式です。XMLの要素モデルに似ていますが、TreeP専用に設計されています。

case class Element(
  kind: String,                    // "def", "let", "call" など
  name: Option[String] = None,     // 識別子
  attrs: List[Attr] = Nil,         // 属性(型情報など)
  children: List[Element] = Nil,   // 子要素
  span: Option[SourceSpan] = None  // ソース位置
)

たとえば、次のようなTreePにおける単純な関数定義を考えてみます。ざっと見ただけで大体の意味は取れるかと思います。

def add(a: Int, b: Int) returns: Int {
  return a + b
}

これをEAST形式に変形すると次のようになります。

def name="fib" a="Int" b="Int" returns="Int" {
  return {
    call name="+" {
      var name="a"
      var name="b"
    }
  }
}

EAST形式のポイントは、各要素が必ずkindを持っており、かつ、省略可能なnameを持っていることです。XML的なラベル付き木構造をベースにしつつ、不要な冗長性を減らすように工夫してあるのです。

この統一されたデータ構造のおかげで、マクロ展開が非常にシンプルに書けます…と言いたいところなのですが、EASTもさすがに、そのままテキストで書くのがしんどかったので、プログラマはEASTをよりわかりやすくした、C-EASTとでも呼ぶ形式で読み書きをして、それがEASTに変換されるようにしました。

このEAST形式を挟んでいるおかげで、TreePでは割と簡単にマクロが書けます。たとえば、whenマクロ(elseのないif)は以下のように定義できます。

macro when {
  pattern: when($cond, $body)
  expand: {
    if (cond) {
      body()
    }
  }
}

これのEAST形式の表現は以下のようになります:

macro name="when" {
  pattern name="when" pattern="$cond, $body"
  expand {
    if cond="cond" {
      body
    }
  }
}

EAST形式に変換したあとにマクロ展開するようになっているので、内部的な処理は結構シンプルです。

使用例:

when(x > 0) {
  println("x is positive")
}

これが内部的には以下のように展開されます。

if (x > 0) {
  (() -> { println("x is positive") })()
}

展開形に() -> ...つまりラムダ式相当が出てくるのが不格好ですが、これには理由があります。上記のwhileはいったん以下のようになるのです。

when(x > 0, () -> {
  println("x is positive")
})

さらにこれが展開されて

if (x > 0) {
  (() -> { println("x is positive") })()
}

となるわけです。何故こうしたかというと、できるだけマクロを格好よく読み書きしたいと思って、

<name>(...) {
  ...
}

<name>(..., () -> {
})

とする特殊ルールを私が加えたせいなのですが、ちょっとイケてないかもしれません。

実装された主要機能

1. 型推論システム

TreePの最大の特徴は、Hindley-Milner型推論です。関数の引数や返り値の型を明示的に書かなくても、使用箇所から自動的に推論してくれます。ML系言語やHaskellが採用している型推論方式(のベースとなるもの)です。

def add(x, y) {
  return x + y
}
// x と y は Int、返り値も Int と推論される

def main() returns: Int {
  let result = add(10, 20)  // result は Int
  println(result)           // 30
  return 0
}

実はHindley-Milner型推論は手で書くのがなかなかに大変でバグりやすい代物なのですが、最新のコーディングAIは特にミスなくやってくれるのは驚きです。

2. ブロック引数構文

先ほど少しでてきましたが、マクロを使いやすくするために、ブロック引数構文を実装しました。これにより、ラムダ式を明示的に書かなくても、自然な構文でマクロを呼び出せます。

// Before
when(x > 0, () -> {
  println("positive")
})

// After
when(x > 0) {
  println("positive")
}

内部的には、パーサーでname(args) { block }パターンを認識し、正規化時にname(args, () -> { block })へと変換します。些細ですが、使い勝手が大幅に向上します。

3. マクロシステム

TreePでは自由にマクロを定義できるうえに、9個の組み込みマクロがあります:

マクロ 用途 使用例
assert アサーション assert(x > 0)
debug デバッグ出力 debug(x)
log ロギング log("message")
trace トレーシング trace(func())
inc/dec インクリメント inc(x)
ifZero ゼロチェック ifZero(x) { ... }
ifPositive 正数チェック ifPositive(x) { ... }
until untilループ until(x >= 5) { ... }
when elseなしif when(cond) { ... }

マクロ展開は、パターンマッチングと変数置換で実現しています。いわゆる衛生的なマクロに相当するもので、古典Lispマクロでありがちな変数捕捉を避けられます。

苦労した点

書こうと思ったのですが、ほとんど苦労してないですね。強いて言うなら指示出しの仕方とかくらいでしょうか。

所感:AIとプログラミング言語開発

正直、想像以上にうまくいきました。

当初の楽観的な見積もり「1〜2日」は少しオーバーしましたが、トータルでも3日程度で、以下の機能を持つ言語が完成しました:

  • Hindley-Milner型推論
  • 衛生的マクロシステム
  • 列多相
  • パターンマッチング
  • イテレータ
  • 拡張メソッド
  • その他色々

これらを私一人で実装したら、1週間はかかるでしょう。

しかし、当たり前ですが、現時点でもまだAIは万能ではありません……と一見教訓めいたことを書こうかと最初考えていたのですが、EAST形式についての指示を行った以外はほとんどCodex CLIにお任せ状態で開発が進みました。GPT-5世代にもなれば、AIも典型的な言語処理系の書き方はよくわかっているので、言語処理系についての用語さえ知っていればほぼ詰まらず進められます。

また言語処理系のテストも全部AIに書かせました。これも「テスト書いてください」くらいの雑な指示でなんとかなるわけですから、最近のコーディングAIの進歩はかなりのものと言えます。

まとめ

TreePという、型推論とマクロシステムを持つ関数型プログラミング言語を、Codex CLI/Claude Codeと協力して実装しました。

リポジトリ:https://github.com/kmizu/treep

今回の経験から、以下のことを学びました……とできればよかったのですが、事前にできそうだとわかっていて、実際にできただけなので「最新世代のコーディングAIは凄い」という月並な感想で終わることにしようかと思います。

皆さんも、AIとペアプロしながら「作ってみたかったけど時間がなかった」プロジェクトに挑戦してみてはいかがでしょうか?思ったよりも早く、楽しく実現できるかもしれません。

それでは、また!

nextbeat Tech Blog

Discussion