自作インタープリタ構築ガイド:ゼロから「MiniGo」を書く

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

魔法を超えて:なぜインタープリタを構築するのか?

多くの開発者にとって、コンピュータが x = 10 + 5 のような文字列をメモリ上の実際の値に変換する仕組みは、魔法のように感じられるかもしれません。しかし、そのプロセスを解剖することは、エンジニアリングスキルを向上させる最短ルートの一つです。スコープ、クロージャ、変数のシャドウイングを自ら実装することで、お気に入りの言語が内部でどのように動作しているかを推測する必要がなくなります。構文だけでなく、その背後にあるパターンが見えてくるようになります。

Goはこのプロジェクトに最適なツールです。コンパイル言語の生のスピードを提供しながら、Pythonのように読みやすいからです。厳格な型システムにより、抽象構文木(AST)のロジックエラーを、実行時の悪夢になる前にキャッチできます。以前のプロジェクトの一つでは、肥大化したサードパーティ製のロジックエンジンを、Goで構築した600行のカスタムDSL(ドメイン固有言語)に置き換えました。この変更により、ルールの実行時間が35%短縮され、3つの重い依存関係を排除できました。

標準的なインタープリタは、Lexer(字句解析器)、Parser(構文解析器)、Evaluator(評価器)という明確なパイプラインに従います。今回は「ツリーウォーク型インタープリタ」を構築します。これは、複雑なバイトコードやJITコンパイルに飛び込む前に、これらの概念を学ぶための最も直接的な方法です。

セットアップ:Goワークスペースの準備

まず、Go 1.21以降がインストールされていることを確認してください。最新の機能に大きく依存することはありませんが、最新のツールチェーンを使用することで、後のパフォーマンス追跡が容易になります。ロジックを分離しテスト可能に保つため、プロジェクトを明確なパッケージに整理します。

mkdir go-interpreter && cd go-interpreter
go mod init go-interpreter
mkdir -p token lexer ast parser evaluator repl

このレイアウトは、システム内のデータフローを反映しています。テキストはLexerに入力され、Token(トークン)として出力されます。ParserはそれらのTokenを消費してASTを構築します。最後に、Evaluatorがその木を走査して結果を生成します。私はいつも token パッケージから始めます。これは、私たちの言語が理解する基本的な語彙を定義するものです。

主要コンポーネント:テキストから実行まで

1. ソースの分類:トークン

Lexerは、自分が見ているものを正確に知る必要があります。すべての文字はカテゴリに分類されなければなりません。token/token.go で定数を定義します。これが言語全体の設計図となります。

package token

type TokenType string

const (
    ILLEGAL = "ILLEGAL"
    EOF     = "EOF"
    IDENT   = "IDENT"
    INT     = "INT"
    ASSIGN  = "="
    PLUS    = "+"
    COMMA     = ","
    SEMICOLON = ";"
    FUNCTION = "FUNCTION"
    LET      = "LET"
)

type Token struct {
    Type    TokenType
    Literal string
}

2. Lexer:フロントエンドスキャナ

Lexerはシンプルなステートマシンです。ソースコードを1文字ずつスキャンし、空白をスキップしながら、先ほど定義したトークンに文字をグループ化します。この段階ではまだロジックは気にしません。letLET トークンに、5INT トークンに変換することだけに集中します。

func (l *Lexer) NextToken() token.Token {
    l.skipWhitespace()
    var tok token.Token

    switch l.ch {
    case '=':
        tok = token.Token{Type: token.ASSIGN, Literal: string(l.ch)}
    case '+':
        tok = token.Token{Type: token.PLUS, Literal: string(l.ch)}
    case 0:
        tok.Type = token.EOF
        tok.Literal = ""
    default:
        if isLetter(l.ch) {
            tok.Literal = l.readIdentifier()
            tok.Type = token.LookupIdent(tok.Literal)
            return tok
        }
        // ここで整数処理を追加...
    }
    l.readChar()
    return tok
}

3. Parser:構造ロジック

ここが最も重要な部分です。今回は **Pratt Parser**(プラット・パーサー)を使用します。計算式で複雑になりがちな従来の再帰下降パーサーとは異なり、Pratt Parserは演算子の優先順位にルックアップテーブルを使用します。これにより、2 + 3 * 4 のような式の処理がクリーンになり、デバッグも容易になります。目標は、すべてのノードがプログラム構造の一部を表すツリーを生成することです。

各ノードは、Evaluatorが処理方法を理解できるように、シンプルなインターフェースを実装する必要があります。

type Node interface {
    TokenLiteral() string
}

type Statement interface {
    Node
    statementNode()
}

type Expression interface {
    Node
    expressionNode()
}

4. Evaluator:コードの実行

Evaluatorはエンジンです。ASTを再帰的に走査(ウォーク)します。IntegerLiteral に到達するとその値を返し、10 + 20 のような InfixExpression に到達すると、両辺を評価して + 演算子を適用します。これは、ロジックツリーを処理する巨大な switch 文です。

func Eval(node ast.Node) object.Object {
    switch node := node.(type) {
    case *ast.Program:
        return evalStatements(node.Statements)
    case *ast.IntegerLiteral:
        return &object.Integer{Value: node.Value}
    case *ast.InfixExpression:
        left := Eval(node.Left)
        right := Eval(node.Right)
        return evalInfixExpression(node.Operator, left, right)
    }
    return nil
}

検証:REPLとベンチマーク

新しい言語と対話する方法が必要です。REPL(Read-Eval-Print Loop)を使えば、コードを入力して即座に結果を確認できます。演算子一つを確認するために毎回フルテストスイートを実行するよりもはるかに高速です。Goでは、bufio.Scanner を使用して50行未満で基本的なREPLを構築できます。

パーサーのテストには精度が求められます。テーブル駆動テストの使用をお勧めします。入力と期待される出力のペアを定義することで、数秒で何十ものエッジケースを検証できます。これにより、文字列サポートなどの新機能を追加した際に、誤って整数の計算を壊してしまう「デグレード(退行バグ)」を防ぐことができます。

動作するようになったら、go test -bench を使用してボトルネックを見つけましょう。ツリーウォークは理解しやすい反面、オブジェクトを大量に割り当てると低速になる可能性があります。単純なスクリプトでインタープリタが50MB以上のメモリを消費している場合は、object パッケージを確認してください。truefalse、小さな整数(0-100)などの頻繁に使用される値に対してオブジェクトを再利用することで、メモリ使用量を大幅に削減できます。このモジュール化されたアプローチにより、あなたの言語を単純な計算機からフル機能のDSLへと成長させることができます。

Share: