静的型検査器を実装する——スコープ付き環境と「Unknown型」で不正な演算を検出する

型検査器の役割

字句解析でトークン列を作り、構文解析でASTを組み立てても、そのASTが「意味的に正しい」とは限りません。1 + trueは文法上は正しい二項演算式ですが、整数と真偽値を足し合わせるという操作に意味はありません。こうした、構文としては正しいが型の観点では不正なプログラムを、実行前に検出するのが型検査器(Type Checker)です。今回はこの言語のASTを走査し、letの型推論・スコープに基づく変数解決・演算子の型整合性チェックを行う型検査器を実装しました。

型の表現

この言語で扱う型は、int/float/bool/stringのプリミティブと、それらを要素に持つ配列だけです。

#[derive(Debug, Clone, PartialEq)]
pub enum Type {
    Int,
    Float,
    Bool,
    String,
    Array(Box<Type>),
    Unknown,
}

Array(Box<Type>)が要素の型を保持しているのは、ExprArrayが任意の式のリストを保持しているのと同じ理由です。配列は[1, 2, 3]Array<Int>)と[true, false]Array<Bool>)のように要素の型ごとに別の型になるため、単に「配列である」というフラグだけでは足りません。

Unknownは少し特殊な存在で、後述する関数呼び出しの扱いのためだけに用意しました。

スコープ付き型環境

letで宣言した変数の型を覚えておき、後から参照されたときにその型を返す仕組みが必要です。ここで単純なHashMap<String, Type>を1つ用意するだけでは不十分です。ifwhileのブロックの中でletされた変数は、そのブロックを抜けたら見えなくなるべきですし(ブロックスコープ)、内側のブロックで外側と同じ名前の変数をletし直す(シャドーイング)ことも許したいためです。

そこで、スコープをスタックとして管理するEnvを用意しました。

struct Env {
    scopes: Vec<HashMap<String, Type>>,
}

impl Env {
    fn new() -> Self {
        Env { scopes: vec![HashMap::new()] }
    }

    fn push_scope(&mut self) {
        self.scopes.push(HashMap::new());
    }

    fn pop_scope(&mut self) {
        self.scopes.pop();
    }

    fn define(&mut self, name: String, ty: Type) {
        self.scopes.last_mut().expect("at least one scope is always present").insert(name, ty);
    }

    fn lookup(&self, name: &str) -> Option<&Type> {
        for scope in self.scopes.iter().rev() {
            if let Some(ty) = scope.get(name) {
                return Some(ty);
            }
        }
        None
    }
}

lookupはスタックの末尾(一番内側のスコープ)から順に外側へ向かって探すので、内側のスコープに同名の変数があればそちらが優先され、シャドーイングが自然に実現されます。ブロックに入るときにpush_scope、抜けるときにpop_scopeするのは、以下のヘルパーに集約しました。

fn in_new_scope<F>(&mut self, f: F) -> Result<(), TypeError>
where
    F: FnOnce(&mut Self) -> Result<(), TypeError>,
{
    self.env.push_scope();
    let result = f(self);
    self.env.pop_scope();
    result
}

fn check_block(&mut self, block: &Block) -> Result<(), TypeError> {
    self.in_new_scope(|checker| checker.check_stmts(block))
}

fの実行結果がエラーであっても、pop_scopeは必ず呼ばれます。型検査自体はエラーが出た時点で処理を打ち切りますが、こうしておくことで「エラーの有無に関わらずスコープの出入りが必ず対称になる」という不変条件が保て、将来この型検査器を拡張する際に事故が起きにくくなります。

文の型検査

ifwhileは、条件式がbool型であることを要求します。

StmtKind::If { condition, then_branch, else_branch } => {
    let condition_ty = self.check_expr(condition, line, column)?;
    self.expect_bool(&condition_ty, line, column)?;
    self.check_block(then_branch)?;
    if let Some(else_stmt) = else_branch {
        self.check_stmt(else_stmt)?;
    }
    Ok(())
}

forは少し特殊で、ループ対象(iterable)がArray<T>型であることを要求し、そこから取り出した要素の型Tをループ変数の型としてスコープに登録します。

StmtKind::For { var, iterable, body } => {
    let iterable_ty = self.check_expr(iterable, line, column)?;
    let element_ty = match iterable_ty {
        Type::Array(element) => *element,
        Type::Unknown => Type::Unknown,
        other => {
            return Err(self.error(line, column, format!("for-loop expects an array, found {other:?}")));
        }
    };
    self.in_new_scope(|checker| {
        checker.env.define(var.clone(), element_ty);
        checker.check_stmts(body)
    })
}

for (n in [1, 2, 3]) { ... }と書けば、Array<Int>からIntが取り出され、本体の中でnIntとして扱われます。この「配列の要素型をループ変数の型として伝播させる」という処理は、型推論らしい型推論の一つです。

式の型検査——演算子の整合性チェック

二項演算子の型検査は、演算子の種類ごとに許される型の組み合わせを列挙する形にしました。

fn check_binary_op(&self, op: &BinaryOp, left: Type, right: Type, line: usize, column: usize) -> Result<Type, TypeError> {
    if left == Type::Unknown || right == Type::Unknown {
        return Ok(self.binary_op_result_for_unknown(op));
    }

    match op {
        BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => match (&left, &right) {
            (Type::Int, Type::Int) => Ok(Type::Int),
            (Type::Float, Type::Float) => Ok(Type::Float),
            _ => Err(self.error(line, column, format!("operator '{op:?}' cannot be applied to {left:?} and {right:?}"))),
        },
        // ...(比較演算子・論理演算子も同様に列挙)
    }
}

ここでは意図的にintfloatの暗黙変換を許していません。1 + 1.0のような式は型エラーになります。C++やRustも同様に暗黙の数値変換を避ける立場を取っており、この言語も「シンプルな型システム」という方針に沿って、まずは同じ型同士の演算だけを許す最小限のルールにしています。文字列の+による連結も同じ理由で今回は実装していません。

配列リテラルは、全要素が同じ型であることを検査します。

fn check_array(&mut self, elements: &[Expr], line: usize, column: usize) -> Result<Type, TypeError> {
    let mut elements = elements.iter();
    let Some(first) = elements.next() else {
        return Err(self.error(line, column, "cannot infer the type of an empty array".to_string()));
    };

    let element_ty = self.check_expr(first, line, column)?;
    for element in elements {
        let ty = self.check_expr(element, line, column)?;
        if ty != element_ty && ty != Type::Unknown && element_ty != Type::Unknown {
            return Err(self.error(
                line, column,
                format!("array elements must have the same type: expected {element_ty:?}, found {ty:?}"),
            ));
        }
    }
    Ok(Type::Array(Box::new(element_ty)))
}

[1, true, 3]のように型が混在した配列は型エラーになります。また、[]のように要素が1つもない配列リテラルは、要素の型を推論する手がかりが文脈上どこにもないため、これも型エラーとして扱っています。letに型注釈を書ける構文がまだない(右辺の式から型を推論するしかない)今の言語仕様では、これが最も素直な結論です。

「Unknown型」——まだ存在しない機能を型システムから隔離する

この型検査器を実装する上で最も悩ましかったのが、関数呼び出し式Expr::Callの扱いです。式のパーサはPhase 2の時点でadd(1, 2)のような呼び出し構文を解釈できますが、fnで関数を定義する構文はまだパーサに実装されていません(TokenKind::Fnというトークン自体は字句解析の時点で予約済みですが、それを消費する構文はまだ無いということです)。つまり「呼び出す構文」だけが先に存在し、「定義する構文」も「関数のシグネチャを記録する仕組み」も存在しない、という非対称な状態です。

この状態でadd(1, 2)の型を検査しようとすると、addが何を指すのか調べる手段がありません。取り得る選択肢は大きく2つです。1つは、関数呼び出しが出てきた時点で「関数はまだ未定義な言語機能」として常に型エラーにしてしまう方法。もう1つは、呼び出し式の型を「不明」として扱い、他のどんな型とも矛盾しないものとして通してしまう方法です。今回は後者を採用し、そのための特別な値としてType::Unknownを用意しました。

// Functions don't exist as a declared concept yet (no `fn` syntax),
// so a call's argument types can't be checked against anything.
// Its result is treated as Unknown until Phase 7 introduces `fn`.
Expr::Call { .. } => Ok(Type::Unknown),

Unknownは、二項演算・単項演算・比較・配列の要素型チェック・forループの対象チェックなど、あらゆる型の整合性チェックにおいて「どちらの型でもあり得るので通す」という特別扱いをします。

fn check_binary_op(&self, op: &BinaryOp, left: Type, right: Type, line: usize, column: usize) -> Result<Type, TypeError> {
    if left == Type::Unknown || right == Type::Unknown {
        return Ok(self.binary_op_result_for_unknown(op));
    }
    // ...
}

これにより、let x = add(1, 2) + 3;のようなコードは、addの呼び出し自体は検査せずに素通しした上で、その結果を3と足し合わせる部分だけは通常通り検査される、という状態になります。関数がまだ存在しない以上これは限定的な型検査ですが、パーサが受理できる構文を型検査器がいたずらにエラーにしてしまう事態は避けられます。関数を導入する際には、addという名前から関数シグネチャを引く仕組みと、引数の型・個数を検証する仕組みをUnknownの代わりに実装することになります。

型システムを段階的に育てていく上で、「まだ実装していない言語機能を、エラーにするのでも中途半端に本実装するのでもなく、明示的な『不明の型』として型システムの外側に置いておく」というのは汎用的に使える設計の型だと感じました。

エラー位置を報告するためのASTの拡張

型エラーを報告する際、「1行目でエラーが起きた」という情報がなければ、実用上ほとんど役に立ちません。ところが、これまでのExprStmtは構文解析の結果として組み立てられるASTのノードであり、元になったトークンの行・列情報は保持していませんでした。

そこで、Stmtをenumから構造体に変更し、中身(StmtKind)と位置情報を分離して持たせる形にしました。

pub enum StmtKind {
    Let { name: String, value: Expr },
    Expr(Expr),
    // ...(既存のバリアントはそのまま)
}

#[derive(Debug, Clone)]
pub struct Stmt {
    pub kind: StmtKind,
    pub line: usize,
    pub column: usize,
}

位置情報をExprにまでは付けず、Stmt単位に留めているのは、型エラーの報告としては「どの文でエラーが起きたか」が分かれば実用上十分だからです。式の入れ子の奥深くでエラーが起きても、それを含む文の先頭位置さえ分かれば、ユーザーは該当箇所を十分に特定できます。式1つ1つに位置情報を持たせることは技術的には可能ですが、Exprの全バリアントを構造体化する必要がありAST全体への影響が大きいため、今回は見送りました。

Stmtの比較(PartialEq)はkindのみで行うよう手動実装しています。

impl PartialEq for Stmt {
    fn eq(&self, other: &Self) -> bool {
        self.kind == other.kind
    }
}

構文解析のテストでは「パース結果の構造」だけを検証したいので、実際の行・列番号を比較対象から除外しています。これにより、パーサのテストコードは位置情報を気にせずStmt { kind: ..., line: 0, column: 0 }という形でダミーの位置を与えて期待値を組み立てられます。

動作確認

.naファイルを字句解析・構文解析した後、型検査を通してからASTを表示するようにCLIを更新しました。

$ cargo run -- examples/hello.na
Stmt { kind: Let { name: "x", ... }, line: 1, column: 1 }
Stmt { kind: If { ... }, line: 2, column: 1 }

意図的に型の合わない式を書くと、行番号付きでエラーが報告されます。

$ echo 'let x = 1 + true;' > bad.na
$ cargo run -- bad.na
na: type error (1:1): operator 'Add' cannot be applied to Int and Bool

テストは、スコープ・シャドーイング・配列の型統一・forループの型推論・Unknown型の伝播などを含めて43件(既存のLexer/Parserのテストを含む)が通ることを確認しています。

備考

代入文がまだ無いため、型環境は「一度決まった型が変わらない」前提で作れている

現状この言語にはx = 2;のような再代入の構文がありません(この点はPhase 3の時点から未実装のままです)。そのためEnvに登録した変数の型は、シャドーイングによる登録し直しを除けば途中で変化することがなく、型環境の実装がシンプルに保てています。将来的に再代入を導入する場合は、代入先の型と代入する式の型が一致するかを検査する処理が別途必要になります。

break/continueがループの外にあるかどうかは検査していない

break;continue;をループの外(トップレベルなど)に書いても、現在の型検査器はエラーにしません。これは型の整合性の問題ではなく制御フローの妥当性の問題であり、「静的型検査」というこのフェーズの範囲からは意図的に外しています。バイトコードへのコンパイル時に、ループの外のbreak/continueをどう扱うか(コンパイルエラーにする、あるいはVMの命令列上そもそも意味を持たなくする、など)を含めて改めて検討する予定です。

型エラーは最初の1件で報告を打ち切る

Lexer・Parserと同様、型検査も最初に見つかったエラーだけをResultで返し、そこで検査を打ち切ります。実用的なコンパイラの多くは複数の型エラーをまとめて報告しますが、実装の単純さを優先し、字句解析・構文解析からの一貫性を保つ形にしています。