ミニ言語に代入文を足す

前回、四則演算だけができるミニ言語 minimini_lang を作ってWASMを出力しました。今回はここに変数を足します。a = 1 のように書ける代入です。

文法定義

まず開始記号をExprからProgramに変えます。ファイル全体は文の並びで、文と文の間は改行1つ以上で区切ります。

use std::str::FromStr;
use crate::ast::{Expr, BinOp, Stmt};

grammar;

match {
    r"[ \t\r]+" => {}, // horizontal whitespace (newline is a token)
    r"//[^\n\r]*" => {}, // line comments
    _
}

pub Program: Vec<Stmt> = {
    "\n"* <stmts:(<Stmt> "\n"+)*> => stmts,
};

Stmt: Stmt = {
    <n:Ident> "=" <e:Expr> => Stmt::Assign { name: n, expr: e },
    Expr => Stmt::Expr(<>),
};

pub Expr: Expr = {
    <l:Expr> "+" <r:Term> => Expr::binop(l, BinOp::Add, r),
    <l:Expr> "-" <r:Term> => Expr::binop(l, BinOp::Sub, r),
    Term,
};

Term: Expr = {
    <l:Term> "*" <r:Factor> => Expr::binop(l, BinOp::Mul, r),
    <l:Term> "/" <r:Factor> => Expr::binop(l, BinOp::Div, r),
    Factor,
};

Factor: Expr = {
    Num => Expr::Int(<>),
    Ident => Expr::Var(<>),
    "(" <Expr> ")",
};

Num: i64 = {
    r"[0-9]+" => i64::from_str(<>).unwrap(),
};

Ident: String = {
    r"[a-z][a-z0-9]*" => <>.to_string(),
};

ポイントは3つです。

  • Program(Stmt "\n"+)*。空行やコメントだけの行は"\n"*側で吸収されるので、ファイル先頭の空行や、文の間の空行も許容されます。
  • Stmtは「代入」か「式」のどちらか。代入はIdent "=" Exprという形で、Expr側には混ぜていません。これでx = (y = 1)のような代入の入れ子はパースエラーになります。
  • 改行が意味を持つトークンになったので、レキサーが空白を丸ごと読み飛ばしていたr"\s*"r"[ \t\r]+"(水平方向の空白だけ)に変更しています。これをやらないと改行もただの空白として消えてしまい、文の区切りが取れません。

変数名を認識するIdentr"[a-z][a-z0-9]*"で、小文字英数字のみ・先頭は英字、というだけのシンプルな定義です。数字始まりを許すと数値リテラルと衝突するため先頭は英字に限定しています。

AST

Exprに変数参照Var(String)を足したのに加えて、新しくStmtという列挙型が増えました。

#[derive(Debug, Clone, PartialEq)]
pub enum Stmt {
    Assign { name: String, expr: Expr },
    Expr(Expr),
}

#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
    Int(i64),
    Var(String),
    BinOp { lhs: Box<Expr>, op: BinOp, rhs: Box<Expr> },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinOp {
    Add,
    Sub,
    Mul,
    Div,
}

impl Expr {
    pub fn binop(lhs: Expr, op: BinOp, rhs: Expr) -> Expr {
        Expr::BinOp {
            lhs: Box::new(lhs),
            op,
            rhs: Box::new(rhs),
        }
    }

    pub fn get_children(&self) -> Vec<&Expr> {
        match self {
            Expr::Int(_) => vec![],
            Expr::Var(_) => vec![],
            Expr::BinOp { lhs, rhs, .. } => vec![lhs.as_ref(), rhs.as_ref()],
        }
    }
}

AssignはASTレベルではExprではなくStmtのvariantです。文法定義での「代入は式ではなく文」という判断がそのままASTの形に表れています。

ローカル変数の事前宣言

コンパイラは、代入文をlocal.set、変数参照をlocal.getに変換するだけ……のつもりだったのですが、最初に書いたコードをそのままwat2wasmに通すとこう怒られました。

$ wat2wasm assign.wat -o assign.wasm
assign.wat:4:16: error: undefined local variable "$a"
    (local.set $a)
               ^^

WATではlocal.set/local.getを使う前に、関数の中で(local $a i64)という宣言が必要です。JavaScriptの変数のように「代入した瞬間に生える」ということはなく、事前に型付きで宣言しておく必要があります。

というわけで、compile_to_watの最初に代入文をスキャンして使われている変数名を集め、関数本体の先頭にlocal宣言をまとめて出力するようにしました。


pub fn compile_to_wat(program: &[crate::ast::Stmt]) -> String {
    let mut locals: Vec<&str> = Vec::new();
    for stmt in program {
        if let crate::ast::Stmt::Assign { name, .. } = stmt {
            if !locals.contains(&name.as_str()) {
                locals.push(name.as_str());
            }
        }
    }
    let local_decls = locals
        .iter()
        .map(|name| format!("(local ${} i64)", name))
        .collect::<Vec<_>>();

    let statements = program.iter().flat_map(|stmt| {
        match stmt {
            crate::ast::Stmt::Expr(expr) => ast_to_wat_string(expr),
            crate::ast::Stmt::Assign { name, expr } =>{
                let mut result = Vec::new();
                result.append(&mut ast_to_wat_string(expr));
                result.push(format!("(local.set ${})", name));
                result
            }
        }
    }).collect::<Vec<_>>();

    format!(
        "(module\n  (func (export \"main\") (result i64)\n    {}\n    {}\n  )\n)\n",
        local_decls.join("\n    "),
        statements.join("\n    ")
    )
}

fn ast_to_wat_string(ast: &crate::ast::Expr) -> Vec<String> {
    match ast {
        crate::ast::Expr::Int(n) => vec![format!("(i64.const {})", n)],
        crate::ast::Expr::BinOp { lhs, op, rhs } => {
            let lhs_str = ast_to_wat_string(lhs);
            let rhs_str = ast_to_wat_string(rhs);
            let op_str = match op {
                crate::ast::BinOp::Add => "i64.add",
                crate::ast::BinOp::Sub => "i64.sub",
                crate::ast::BinOp::Mul => "i64.mul",
                crate::ast::BinOp::Div => "i64.div_s",
            };
            let mut result = Vec::new();
            result.extend(lhs_str);
            result.extend(rhs_str);
            result.push(op_str.to_string());
            result
        }
        // 構文定義のみ先行導入。コード生成は未対応。
        crate::ast::Expr::Var(var_name) => {
            vec![format!("(local.get ${})", var_name)]
        },
    }
}

#[cfg(test)]
mod tests{
    use super::*;

    #[test]
    fn test_ast_to_wat_string() {
        let ast = crate::ast::Expr::binop(
            crate::ast::Expr::Int(1),
            crate::ast::BinOp::Add,
            crate::ast::Expr::binop(
                crate::ast::Expr::Int(2),
                crate::ast::BinOp::Mul,
                crate::ast::Expr::Int(3),
            ),
        );
        let wat_strings = ast_to_wat_string(&ast);
        let expected = vec![
            "(i64.const 1)".to_string(),
            "(i64.const 2)".to_string(),
            "(i64.const 3)".to_string(),
            "i64.mul".to_string(),
            "i64.add".to_string(),
        ];
        assert_eq!(wat_strings, expected);
    }
}```

`ast_to_wat_string`自体は前回とほぼ同じで、`Expr::Var`が増えた分だけ`local.get`を返すケースが増えています。変わったのは`compile_to_wat`側で、`Stmt::Assign`を見つけるたびに変数名を(重複なく)集めて`(local $x i64)`の並びを作り、命令列の前に差し込んでいる点です。

## 実際に動かしてみる

以下のようなプログラムを書いてみます。

a = 1 b = a + 2 a + b


コンパイルして実行すると:

```sh
$ cargo run -- assign.mmm
wrote assign.wat
$ wat2wasm assign.wat -o assign.wasm
$ node -e "
    const bytes = require('fs').readFileSync('assign.wasm');
    WebAssembly.instantiate(bytes).then(({instance}) => {
      console.log(instance.exports.main());
    });
  "
4n

a = 1b = a + 2(つまり3)、最後のa + b1 + 3 = 4。狙い通りの値が返ってきました。生成されたassign.watを見ると、想定通り関数の先頭にlocal宣言がまとまっています。

(module
  (func (export "main") (result i64)
    (local $a i64)
    (local $b i64)
    (i64.const 1)
    (local.set $a)
    (local.get $a)
    (i64.const 2)
    i64.add
    (local.set $b)
    (local.get $a)
    (local.get $b)
    i64.add
  )
)

最後の文(式文)だけがスタックに値を1つ残し、それより前の文(代入文)はすべてlocal.setで値を消費してスタックを空にする、という形になっているのがわかります。ここが崩れる書き方(たとえば途中に代入ではない式文を挟む)をするとスタックの数が合わずwat2wasmの検証に落ちますが、今のところパーサー側ではそこまで縛っていません。

まとめ

  • WATではlocal変数は使う前に型付きで宣言しておく必要がある。
  • 変数の Read / Write は local.get, local.setを使用する。