🦋
Rustで自作言語ために字句解析器を作った話
リポジトリ
字句解析器(Lexer)とは
文字をトークンの列に変換を行う機構のものです。
たとえば下記のトークンの定義があり、
-
const
はキーワードで1
と定義する -
a
は変数名でキーワード以外は2
と定義する -
=
は演算子で3
と定義する -
1
は数列で4
と定義する
const a = 1
上記ののプログラムをトークンの列に変更すると
[1, 2, 3, 4]
このようになります(実際はすべてが数列に置き換わるわけではありません)。
こうすることにより構文解析という次の処理により適した形にすることができます。
実装する際の考え方
トークンの定義は状況と同じ物を使います。
-
const
はキーワードで1
と定義する -
a
は変数名でキーワード以外は2
と定義する -
=
は演算子で3
と定義する -
1
は数列で4
と定義する
const a = 1
↑
まずカーソルを用意して一番前の文字から見ていきます。
先頭から空白が見つかるまで移動します。
const a = 1
↑
この時点この文字がconst
であることがわかるので1
を配列に格納します。
const a = 1
↑
そして変数を定義するキーワードのあとには変数名がくるので次の文字が変数名であることがわかり、キーワード以外の文字なので2
を配列に格納します。
const a = 1
↑
次に来るのは演算子なので3
を配列に格納します。
const a = 1
↑
次は1
で数列なので4
を配列に格納します
[1, 2, 3, 4]
最終的アウトプットは以上になります。
実装
トークンの定義
pub struct Token {
pub _if: i64,
pub _then: i64,
pub _else: i64,
pub _for: i64,
pub _fun: i64,
pub _print: i64,
pub _string: i64,
pub _number: i64,
pub _comment: i64,
pub _identifier: i64,
pub _let: i64,
pub _bool:i64,
pub _return: i64,
pub _vec: i64,
pub _import: i64,
pub _const: i64,
}
impl Token {
pub const fn new() -> Token {
Token {
_if: -1,
_then: -2,
_else: -3,
_for: -4,
_fun: -5,
_print: -6,
_string: -7,
_number: -8,
_comment: -9,
_identifier: -10,
_let: -11,
_bool:-12,
_return: -13,
_vec: -14,
_import: -15,
_const:-16,
}
}
}
#[derive(Debug, Clone)]
pub struct TokenValue {
pub token: i64,
pub val: String,
}
impl TokenValue {
pub fn new(token:i64, val:&str) -> TokenValue {
let value = val.to_string();
TokenValue{
token:token,
val:value,
}
}
}
トークンは構造体で定義しています。
キーワードは負の値を使い、演算子はバイトに変換しASCIIコードをトークンとして扱います。キーワード、文字、数字、演算子以外のものは変数として扱います。
またTokenValue
でtokenとvalueに文字そのものを格納します。
字句解析器(リポジトリから一部抜粋)
match reg.captures(&last_str) {
Some(_) => {
loop {
let text = &content.chars().nth(index).expect("Failed").to_string();
let reg = Regex::new(r"(\d|[a-zA-Z])+").expect("Failed");
let res = match reg.captures(text) {
Some(_) => true,
None => false,
};
if !res {
break;
}
identifier_str += text;
index += 1;
}
if identifier_str == "print" {
let token_value = token::TokenValue::new(TOKEN._print, &identifier_str);
return (token_value, index);
}
...
このコードの場合文字を一つずつみて行きprint
が完成して瞬間にトークンが発行されます。
キーワードに当てはまらない場合は変数として扱われます。
use regex::Regex;
Regex::new(r"[0-9]+") //数字
Regex::new(r#"""#) //文字列
文字列、数列の判断には正規表現を用いています。
キーワードに当てはまらない場合は数字、文字列を判定する処理に入ります。
let ascii_code = content
.chars()
.nth(index)
.expect("Failed")
.to_string()
.as_bytes()[0];
最後に演算子だった場合はASCIIコードに変換されて格納されます。
まとめ
昔、自作言語を作ったのでそれをアウトプットをしようと思った次第です!!
字句解析器は概念もやることも難しくないのでチャレンジしてみてはどうでしょうか??
Discussion
content.chars().nth(index)
というコードが気になります。これはUTF-8の文字数のindex
番目を数えているので、毎回文字列全体を読み直す必要があり、長い入力文字列に対しては非常に非効率的になります。お勧めは
chars()
を直接ループで回すか、イテレータオブジェクトを維持しつつ
next()
を一度ずつ呼ぶか、文字列スライスを前に進めてしまう方法です。
次のコードでベンチマークを取ってみました。まず
chars().nth()
を毎回使う方法です。次に
next()
を一度ずつ呼ぶ方法です。また、文字列スライスの代わりにバイト列の配列にしてしまう方法もあります。これだとマルチバイト文字に対応できませんが。
結果は次のようになりました。横軸がダミーの入力データのサイズで、縦軸が時間です。
chars.rs
だけが2次関数的に時間が伸びていることが分かります。