Rustで作る自作コンパイラ入門シリーズ
Part1【字句解析】 | Part2【構文解析】 | Part3【意味解析】 | Part4【コード生成】 | Part5【完結】
はじめに
前回まででソースコードをASTに変換できるようになりました。
でもASTだけでは、こんなエラーは検出できません:
fn main() {
return x; // x って何?
}
fn add(a, b) { return a + b; }
fn main() {
return add(1); // 引数が足りない!
}
今回は 意味解析(Semantic Analysis) を実装して、こういうエラーを検出します!
目次
意味解析とは
意味解析(Semantic Analysis)は、プログラムの意味的な正しさをチェックします。
構文解析との違い
| フェーズ | チェック内容 |
|---|---|
| 構文解析 | 文法的に正しいか |
| 意味解析 | 意味的に正しいか |
例:
// 構文的にはOK、意味的にNG
fn main() {
return undefined_variable;
}
構文解析は通るけど、意味解析で「変数が未定義」とエラーになります。
今回チェックする内容
- 未定義変数の使用
- 未定義関数の呼び出し
- 引数の数の不一致
- 変数の重複定義
- main関数の存在
スコープとは
スコープ(Scope) は、変数が有効な範囲のことです。
fn main() {
let x = 10; // xはここから有効
if true {
let y = 20; // yはこのブロック内でのみ有効
println(x); // xは見える
}
println(x); // xは見える
println(y); // エラー!yはスコープ外
}
スコープの入れ子
┌─────────────────────────────────┐
│ グローバルスコープ │
│ │
│ ┌───────────────────────────┐ │
│ │ main関数のスコープ │ │
│ │ x = 10 │ │
│ │ │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ ifブロックのスコープ │ │ │
│ │ │ y = 20 │ │ │
│ │ └─────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────┘ │
│ │
└─────────────────────────────────┘
シンボルテーブルを実装する
スコープを管理するためのシンボルテーブルを実装します。
変数情報
/// 変数の情報
#[derive(Debug, Clone)]
pub struct VarInfo {
pub name: String,
pub is_param: bool, // 関数パラメータかどうか
}
スコープ構造体
/// スコープ(変数の有効範囲)
#[derive(Debug)]
pub struct Scope {
variables: HashMap<String, VarInfo>,
parent: Option<Box<Scope>>, // 親スコープへの参照
}
親スコープへの参照がポイント!これで入れ子構造を表現します。
スコープの操作
impl Scope {
/// 新しいグローバルスコープを作成
pub fn new() -> Self {
Self {
variables: HashMap::new(),
parent: None,
}
}
/// 子スコープを作成
pub fn child(parent: Scope) -> Self {
Self {
variables: HashMap::new(),
parent: Some(Box::new(parent)),
}
}
/// 変数を定義
pub fn define(&mut self, name: &str, is_param: bool) {
self.variables.insert(name.to_string(), VarInfo { ... });
}
/// 変数を検索(親スコープも探す)
pub fn lookup(&self, name: &str) -> Option<&VarInfo> {
if let Some(var) = self.variables.get(name) {
Some(var)
} else if let Some(ref parent) = self.parent {
parent.lookup(name) // 親スコープを再帰的に検索
} else {
None
}
}
}
lookupが再帰的に親を辿るのがポイント!
関数テーブル
関数も同様に管理:
#[derive(Debug)]
pub struct FunctionTable {
functions: HashMap<String, FunctionInfo>,
}
#[derive(Debug, Clone)]
pub struct FunctionInfo {
pub name: String,
pub param_count: usize, // 引数の数
}
エラー検出を実装する
アナライザーの構造
pub struct Analyzer {
function_table: FunctionTable,
errors: Vec<AnalyzeError>,
}
エラーは即座に失敗せず、全て集めてから報告します。
解析の流れ
pub fn analyze(&mut self, program: &Program) -> AnalyzeResult<()> {
// フェーズ1: 全関数を登録(前方参照のため)
for func in &program.functions {
self.function_table.define(&func.name, func.params.len());
}
// フェーズ2: 各関数の本体を解析
for func in &program.functions {
self.analyze_function(func)?;
}
// main関数の存在チェック
if self.function_table.lookup("main").is_none() {
self.errors.push(AnalyzeError::new(
"Function 'main' is not defined"
));
}
// エラーがあれば報告
if !self.errors.is_empty() {
return Err(...);
}
Ok(())
}
フェーズ1で先に全関数を登録することで、相互再帰などの前方参照に対応!
式の解析
fn analyze_expr(&mut self, expr: &Expr, scope: &Scope) -> AnalyzeResult<()> {
match expr {
Expr::Number(_) | Expr::Bool(_) => Ok(()), // リテラルはOK
Expr::Var(name) => {
// 変数がスコープにあるか確認
if scope.lookup(name).is_none() {
self.errors.push(AnalyzeError::new(
format!("Undefined variable '{}'", name)
));
}
Ok(())
}
Expr::Call { name, args } => {
// 関数が存在するかチェック
match self.function_table.lookup(name) {
Some(func_info) => {
// 引数の数をチェック
if args.len() != func_info.param_count {
self.errors.push(AnalyzeError::new(
format!(
"Function '{}' expects {} arguments, but got {}",
name, func_info.param_count, args.len()
)
));
}
}
None => {
self.errors.push(AnalyzeError::new(
format!("Undefined function '{}'", name)
));
}
}
// 引数も解析
for arg in args {
self.analyze_expr(arg, scope)?;
}
Ok(())
}
// ... 他のケース
}
}
文の解析(スコープ管理)
fn analyze_stmt(&mut self, stmt: &Stmt, mut scope: Scope) -> AnalyzeResult<Scope> {
match stmt {
Stmt::Let { name, value } => {
// 値を先に解析(自己参照を防ぐ)
self.analyze_expr(value, &scope)?;
// 変数を定義
if scope.is_defined_here(name) {
self.errors.push(AnalyzeError::new(
format!("Variable '{}' is already defined", name)
));
} else {
scope.define(name, false);
}
}
Stmt::If { cond, then_body, else_body } => {
self.analyze_expr(cond, &scope)?;
// then節は新しいスコープ
let then_scope = Scope::child(scope);
let then_scope = self.analyze_block(then_body, then_scope)?;
// 元のスコープを取り戻す
scope = *then_scope.parent.unwrap();
// else節も同様
// ...
}
// ... 他のケース
}
Ok(scope)
}
ブロックに入るときに新しいスコープを作成し、出るときに親に戻る!
動かしてみる
正しいプログラム
fn add(a, b) {
return a + b;
}
fn main() {
let x = 10;
let y = 20;
let result = add(x, y);
return result;
}
出力:
✅ Analysis passed! (2 function(s), 44 token(s))
エラーのあるプログラム
1. 未定義変数
fn main() {
return x;
}
❌ Semantic error:
Undefined variable 'x'
2. 未定義関数
fn main() {
let x = foo();
return x;
}
❌ Semantic error:
Undefined function 'foo'
3. 引数の数が違う
fn add(a, b) {
return a + b;
}
fn main() {
return add(1); // 引数1つしかない!
}
❌ Semantic error:
Function 'add' expects 2 arguments, but got 1
4. main関数がない
fn foo() {
return 42;
}
❌ Semantic error:
Function 'main' is not defined
テスト
#[test]
fn test_undefined_variable() {
let result = analyze_source(r#"
fn main() {
return x;
}
"#);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Undefined variable 'x'"));
}
#[test]
fn test_scope_shadowing() {
// ブロックスコープでの再定義はOK(シャドウイング)
let result = analyze_source(r#"
fn main() {
let x = 10;
if true {
let x = 20; // 新しいスコープなのでOK
}
return x;
}
"#);
assert!(result.is_ok());
}
テスト実行:
cargo test
running 19 tests
test analyzer::tests::test_assign_to_undefined ... ok
test analyzer::tests::test_duplicate_variable ... ok
test analyzer::tests::test_no_main ... ok
test analyzer::tests::test_scope_shadowing ... ok
test analyzer::tests::test_undefined_function ... ok
test analyzer::tests::test_undefined_variable ... ok
test analyzer::tests::test_valid_program ... ok
test analyzer::tests::test_wrong_arg_count ... ok
... (lexer, parser tests) ...
test result: ok. 19 passed; 0 failed
Rustの気持ちがわかった
意味解析を実装してみて、Rustのコンパイラがやっていることが少しわかりました。
Rustが厳しい理由
fn main() {
let x;
println!("{}", x); // エラー: 初期化前に使用
}
Rustは「初期化されていない変数」もエラーにします。
今回作ったコンパイラではそこまでやっていませんが、同じ仕組みで実装できます。
借用チェッカーの気持ち
fn main() {
let s = String::from("hello");
let r = &s;
drop(s); // エラー: 借用されている間は解放できない
println!("{}", r);
}
スコープと変数の状態を追跡することで、こういうチェックも可能になります。
まとめ
今回やったこと
| 項目 | 内容 |
|---|---|
| シンボルテーブル | スコープの入れ子管理 |
| 関数テーブル | 関数の登録と引数数チェック |
| エラー検出 | 未定義変数/関数、引数不一致 |
| コード量 | 約200行 |
学び
-
スコープは木構造
- 親への参照で入れ子を表現
-
2パスが必要
- 1パス目: 関数登録
- 2パス目: 本体解析
-
エラーは集める
- 1つ見つけたら終わりじゃなく、全部報告
次回予告
Part4ではコード生成を実装します!
ASTから実際に動くコードを生成します。ついに...
fn main() {
return 42;
}
が本当に 42 を返すようになる!
スタックマシンのコード生成、お楽しみに!