Rustで作る自作コンパイラ入門シリーズ
Part1【字句解析】| Part2【構文解析】 | Part3【意味解析】 | Part4【コード生成】 | Part5【完結】
はじめに
プログラミング言語を作る...って言われると、なんかすごく難しそうですよね。
でも実際に作ってみると、意外とシンプルな処理の組み合わせだったりします。
今回のシリーズでは、RustでFizzBuzzがコンパイルできるミニ言語を作っていきます!
目次
コンパイラの全体像
コンパイラは大きく分けて4つのフェーズで構成されます:
ソースコード
↓
┌─────────────────┐
│ 1. 字句解析 │ ← 今回ここ!
│ (Lexer) │
└─────────────────┘
↓ トークン列
┌─────────────────┐
│ 2. 構文解析 │ ← Part2
│ (Parser) │
└─────────────────┘
↓ 抽象構文木(AST)
┌─────────────────┐
│ 3. 意味解析 │ ← Part3
│ (Analyzer) │
└─────────────────┘
↓ 検証済みAST
┌─────────────────┐
│ 4. コード生成 │ ← Part4
│ (Generator) │
└─────────────────┘
↓
機械語 / アセンブリ
字句解析とは
字句解析(Lexical Analysis) は、ソースコードの文字列を トークン(Token) という単位に分解する処理です。
例
let x = 42;
これを字句解析すると:
"let" → Let(キーワード)
"x" → Ident("x")(識別子)
"=" → Eq(等号)
"42" → Number(42)(数値)
";" → Semicolon(セミコロン)
人間が「空白で区切られた単語」を認識するのと同じような処理を、プログラムでやっているわけです。
正規表現の気持ちがわかる
字句解析を書いていると気づくのですが、やっていることは基本的に正規表現と同じなんです。
- 数字が連続している → 数値リテラル
- 英字+英数字が連続 → 識別子
- 特定の文字列 → キーワード
正規表現が裏で何をやっているのか、実感できます。
トークンを設計する
まず、どんなトークンが必要か考えます。
今回作る言語の文法:
- 変数宣言:
let x = 10; - 関数定義:
fn add(a, b) { return a + b; } - if文:
if x > 0 { ... } else { ... } - while文:
while x > 0 { ... }
トークンの種類
/// トークンの種類
#[derive(Debug, Clone, PartialEq)]
pub enum TokenKind {
// リテラル
Number(i64), // 数値: 42, 123
Ident(String), // 識別子: foo, bar
// キーワード
Let, // let
Fn, // fn
If, // if
Else, // else
While, // while
Return, // return
True, // true
False, // false
// 演算子
Plus, // +
Minus, // -
Star, // *
Slash, // /
Percent, // %
Eq, // =
EqEq, // ==
NotEq, // !=
Lt, // <
LtEq, // <=
Gt, // >
GtEq, // >=
And, // &&
Or, // ||
Not, // !
// 区切り文字
LParen, // (
RParen, // )
LBrace, // {
RBrace, // }
Comma, // ,
Semicolon, // ;
Colon, // :
Arrow, // ->
// 特殊
Eof, // 入力の終わり
}
Rustのenumが最適!値を持てる(Number(i64))のが便利すぎる。
トークン構造体
位置情報も持たせると、エラーメッセージで行番号を表示できます:
/// トークン(種類 + 位置情報)
#[derive(Debug, Clone)]
pub struct Token {
pub kind: TokenKind,
pub line: usize, // 行番号
pub column: usize, // 列番号
}
Lexerを実装する
基本構造
/// 字句解析器
pub struct Lexer {
input: Vec<char>, // 入力文字列
pos: usize, // 現在位置
line: usize, // 現在行
column: usize, // 現在列
}
Vec<char>にしているのは、日本語コメントにも対応できるようにするため。
ヘルパーメソッド
impl Lexer {
/// 現在の文字を取得
fn current(&self) -> Option<char> {
self.input.get(self.pos).copied()
}
/// 次の文字を先読み(peek)
fn peek(&self) -> Option<char> {
self.input.get(self.pos + 1).copied()
}
/// 1文字進める
fn advance(&mut self) -> Option<char> {
let ch = self.current()?;
self.pos += 1;
if ch == '\n' {
self.line += 1;
self.column = 1;
} else {
self.column += 1;
}
Some(ch)
}
}
peek()で先読みできるのがポイント!==と=を区別するのに必要です。
数値を読む
fn read_number(&mut self) -> Token {
let start_line = self.line;
let start_column = self.column;
let mut num_str = String::new();
// 数字が続く限り読み取る
while let Some(ch) = self.current() {
if ch.is_ascii_digit() {
num_str.push(ch);
self.advance();
} else {
break;
}
}
let value: i64 = num_str.parse().expect("Invalid number");
Token::new(TokenKind::Number(value), start_line, start_column)
}
while let Some(ch)パターン、Rustっぽくて好き。
識別子 or キーワードを読む
fn read_ident_or_keyword(&mut self) -> Token {
let start_line = self.line;
let start_column = self.column;
let mut ident = String::new();
// 英数字とアンダースコアが続く限り読み取る
while let Some(ch) = self.current() {
if ch.is_alphanumeric() || ch == '_' {
ident.push(ch);
self.advance();
} else {
break;
}
}
// キーワードか識別子か判定
let kind = match ident.as_str() {
"let" => TokenKind::Let,
"fn" => TokenKind::Fn,
"if" => TokenKind::If,
"else" => TokenKind::Else,
"while" => TokenKind::While,
"return" => TokenKind::Return,
"true" => TokenKind::True,
"false" => TokenKind::False,
_ => TokenKind::Ident(ident),
};
Token::new(kind, start_line, start_column)
}
matchでキーワードをパターンマッチ!これがRustの醍醐味。
演算子・記号を読む
pub fn next_token(&mut self) -> Token {
self.skip_whitespace();
let start_line = self.line;
let start_column = self.column;
let Some(ch) = self.current() else {
return Token::new(TokenKind::Eof, start_line, start_column);
};
// 数値
if ch.is_ascii_digit() {
return self.read_number();
}
// 識別子またはキーワード
if ch.is_alphabetic() || ch == '_' {
return self.read_ident_or_keyword();
}
// 記号類
self.advance(); // 1文字消費
let kind = match ch {
'+' => TokenKind::Plus,
'-' => {
// -> か - かを判定
if self.current() == Some('>') {
self.advance();
TokenKind::Arrow
} else {
TokenKind::Minus
}
}
'*' => TokenKind::Star,
'/' => TokenKind::Slash,
'=' => {
// == か = かを判定
if self.current() == Some('=') {
self.advance();
TokenKind::EqEq
} else {
TokenKind::Eq
}
}
'!' => {
// != か ! かを判定
if self.current() == Some('=') {
self.advance();
TokenKind::NotEq
} else {
TokenKind::Not
}
}
// ... 他の記号も同様
_ => panic!("Unknown character '{}'", ch),
};
Token::new(kind, start_line, start_column)
}
1文字の演算子と2文字の演算子を区別するのが少しトリッキー。
peek()で先読みして判断します。
コメントのスキップ
fn skip_whitespace(&mut self) {
while let Some(ch) = self.current() {
if ch.is_whitespace() {
self.advance();
} else if ch == '/' && self.peek() == Some('/') {
// 行コメントをスキップ
while let Some(c) = self.current() {
if c == '\n' {
break;
}
self.advance();
}
} else {
break;
}
}
}
// コメントは改行まで読み飛ばします。
動かしてみる
サンプルプログラム
fn main() {
let source = r#"
// 足し算する関数
fn add(a, b) {
return a + b;
}
fn main() {
let x = 10;
let y = 20;
let result = add(x, y);
if result > 25 {
return 1;
} else {
return 0;
}
}
"#;
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize();
for (i, token) in tokens.iter().enumerate() {
println!("{:3}: {:?}", i, token.kind);
}
}
出力
=== Tokens ===
0: Fn at 3:1
1: Ident("add") at 3:4
2: LParen at 3:7
3: Ident("a") at 3:8
4: Comma at 3:9
5: Ident("b") at 3:11
6: RParen at 3:12
7: LBrace at 3:14
8: Return at 4:5
9: Ident("a") at 4:12
10: Plus at 4:14
11: Ident("b") at 4:16
12: Semicolon at 4:17
13: RBrace at 5:1
...(以下略)
Total: 56 tokens
ちゃんとトークンに分解されています!
let fizzbuzz = 1 + 2 * 3 - 4 / 2;
この1行が:
Let, Ident("fizzbuzz"), Eq, Number(1), Plus, Number(2), Star, Number(3),
Minus, Number(4), Slash, Number(2), Semicolon, Eof
こう分解されます。人間が目で見て区切っているのと同じことを、プログラムでやっているんですね。
テストを書く
#[test]
fn test_simple_program() {
let mut lexer = Lexer::new("let x = 42;");
let tokens = lexer.tokenize();
assert_eq!(tokens[0].kind, TokenKind::Let);
assert_eq!(tokens[1].kind, TokenKind::Ident("x".to_string()));
assert_eq!(tokens[2].kind, TokenKind::Eq);
assert_eq!(tokens[3].kind, TokenKind::Number(42));
assert_eq!(tokens[4].kind, TokenKind::Semicolon);
assert_eq!(tokens[5].kind, TokenKind::Eof);
}
#[test]
fn test_operators() {
let mut lexer = Lexer::new("== != <= >=");
let tokens = lexer.tokenize();
assert_eq!(tokens[0].kind, TokenKind::EqEq);
assert_eq!(tokens[1].kind, TokenKind::NotEq);
assert_eq!(tokens[2].kind, TokenKind::LtEq);
assert_eq!(tokens[3].kind, TokenKind::GtEq);
}
テスト実行:
cargo test
running 7 tests
test lexer::tests::test_arrow ... ok
test lexer::tests::test_comment ... ok
test lexer::tests::test_function_def ... ok
test lexer::tests::test_keywords ... ok
test lexer::tests::test_numbers ... ok
test lexer::tests::test_operators ... ok
test lexer::tests::test_simple_program ... ok
test result: ok. 7 passed; 0 failed
全部通った!
まとめ
今回やったこと
| 項目 | 内容 |
|---|---|
| トークン設計 |
enum TokenKindで30種類以上 |
| Lexer実装 | 約300行 |
| 機能 | 数値、識別子、キーワード、演算子、コメント |
学び
-
字句解析は正規表現と同じことをしている
- パターンマッチで文字列を分類
-
Rustの
enumが最強- 値を持てる(
Number(42)) - パターンマッチと組み合わせて最高
- 値を持てる(
-
先読み(peek)の重要性
-
=と==を区別するには先読みが必要
-
次回予告
Part2では**構文解析(Parser)**を実装します!
トークン列を**木構造(AST)**に変換する処理です。
// これが...
let x = 1 + 2 * 3;
// こうなる!
Let {
name: "x",
value: Add(
Number(1),
Mul(Number(2), Number(3))
)
}
演算子の優先順位(*が+より先)をどう扱うか、お楽しみに!
質問・コメントあればぜひ!