nand2tetrisで演算子の優先順位をつける
先日終わらせたnand2tetris
で仮想マシンのコンパイラを作成したらとても俺俺コンパイラを作ってみたくなった。そこで今MSX
用の俺俺クロスコンパイラを作っている最中なのだが、演算子の優先順位がない状態だと非常に使いにくいので優先順位に従って式が評価されるようにしてみた。
環境
- 言語:python 3.8
- PC: iMac 2019
- OS: macOS 10.15.7
難しいことはしていないのでpython 3
なら問題なく動くはず。
サンプル
俺俺クロスコンパイラからパーサの該当箇所を抜粋してgithub
にアップロードした。
main.py
を起動すればrepl
になるので、適当に式(以下expression
)を入れるとpush
と演算子(以下operator
)で実行コードが表示される。
例えば1+2*3;
と入力すると下記のようになる。
exp > 1+2*3;
push ('constant', '1', 'int')
push ('constant', '2', 'int')
push ('constant', '3', 'int')
arithmetic ('call Math.multiply 2',)
arithmetic ('add',)
1
をpush
して、2
をpush
して、3
をpush
して、2
と3
を掛けて、1
を加える。
掛け算はライブラリで処理しているのでcall Math.multiply
になっている。
足し算を先にするために括弧を付けてみると。
exp > (1+2)*3;
push ('constant', '1', 'int')
push ('constant', '2', 'int')
arithmetic ('add',)
push ('constant', '3', 'int')
arithmetic ('call Math.multiply 2',)
1
をpush
して、2
をpush
して、1
と2
を足して、3
をpush
して掛け算する。
ちゃんと括弧の中が先に評価されている。
もう少し複雑にしてみる。
exp > 1+(2+3)*4+-1;
push ('constant', '1', 'int')
push ('constant', '2', 'int')
push ('constant', '3', 'int')
arithmetic ('add',)
push ('constant', '4', 'int')
arithmetic ('call Math.multiply 2',)
arithmetic ('add',)
push ('constant', '1', 'int')
arithmetic ('neg',)
arithmetic ('add',)
2
と3
を先に足して4
を掛けて1
を加えて最後に-1
を加える。
-1
はunary_operator
で処理されて1
のneg
になっている。
コード
アップロードしてあるファイルは下記の通り。
- main.py
- Token.py
- Tokenizer.py
- Parser.py
Parser.py
以外はパースには関係ないので今回説明はなし。
Parser.py
処理の流れとしては、まずトークンに分割された式がparser.parse(tokens)
に渡される。そこで最初のトークンを引数にしてdo_expression(token, priority)
が呼ばれる。このdo_expression
は再帰的に呼び出され最終的にはdo_term(token, priority)
で値が評価される。
do_expression
、do_term
共にもう一つの引数priority
がoperator
の優先順位。
優先順位はToken.py
でそれぞれの演算子について設定してあり、値は以下のページのC
の優先順位をそのまま使っている。
expression
の評価は主にdo_expression
で行われている。
以下、優先順位がない場合とある場合で簡単に説明する。
優先順位なしの場合
nand2tetris
の教科書とちょっと違ってexpression
の定義がexpression: term (op expression)*
と再帰的になっている。
if not token.is_term():
self.error_message('Expression', 'Term', token)
return token
token = self.do_term(token, priority)
while token.is_operator():
opcodes = token.operator_code()
token = self.get_token()
token = self.do_expression(token)
for opcode in opcodes:
self.code_write('arithmetic', (opcode, ))
まず最初にトークンがterm
かどうかを確認してる。
トークンがterm
であれば、self.do_term(token, priority)
でそれを処理して次のトークンが返ってくる。
そして、ここでトークンが演算子であれば再帰的にまた式が続くことになる。
operator
にexpression
が続くパターンが繰り返される限りどんどん再起が深くなっていき、最終的に;
を読み込むと元に戻りつつoperator
で評価がなされる。
このコードでは全てが右側から順番に計算されるようになってしまう。
優先順位ありの場合
最初のdo_expression
の呼び出しは優先順位が低い20で行われる。俺俺コンパイラで実装している最低の優先順位が10なので11でも良かったのだが、今後他の演算子も加わるかもしれないので20にしてある。
ここで優先順位がない場合との違いはwhile
の条件で「トークンの優先順位が今の優先順位よりも高ければ(値が小さければ)」が加わる。要は今処理している式より優先順位の高いoperator
が来たら先にそのexpression
を再帰的に処理している。
while token.is_operator() and token.priority() < priority:
opcodes = token.operator_code()
new_priority = token.priority()
token = self.get_token()
token = self.do_expression(token, new_priority)
for opcode in opcodes:
self.code_write('arithmetic', (opcode, ))
例えば1+2*3
を考えると、1+2
までトークンを読み終わって*
が次のトークンの場合、1+2
までの優先順位4
より*
の3
の方が高いので*
の式を先に処理する。
下記の書き方だとスペースの左側が最初のdo_expression
呼び出しで、右側が2段目のdo_expression
呼び出しになる。
1+2 ; 1+2まで読み込んだ状態
1+ 2*3; *を読み込むと1段深いdo_expressionで2*3が先に処理される
この結果、優先順位の高いexpression
が先に評価され、それが終わると帰って来て元の優先順位の低いexpression
が評価される。
while
で同じ優先順位のoperator
が来た場合は、そこまでのexpression
の評価は終了して1段浅いdo_expression
での評価が始まる。これで同じ優先順位の場合は左から順番に評価される。
1+2+3
の場合
1+2 ; 1+2まで読み込んだ状態
1+2 +3; +を読み込むと1+2が終了し1段浅いdo_expressionに戻って+3が処理される
これを繰り返して優先順位を考慮したexpression
の評価ができる。
do_termの処理
今回のサンプルではdo_term
で処理しているのは3種類、整数定数と括弧の式、そしてunary_perator
。
整数定数
これ以上の処理は必要ないので、そのままその数をpush
している。
if token.kind == 'integerConstant':
term_value = token.value
self.code_write('push', ('constant', term_value, 'int'))
token = self.get_token()
括弧の式
トークンとして左括弧が来たら、その後からはそれまでの優先順位とは関係なく最低の優先順位の状態で新しいexpression
が始まる。
これで括弧の中はそれまでの優先順位とは関係なく先に評価されるようになる。
expression
の処理が終わったら次のトークンが右括弧であることを確認して評価は終了。
elif token.is_left_par():
token = self.get_token()
token = self.do_expression(token, LOWEST_PRIORITY)
if not token.is_right_par():
self.error_message('term left_par', '(', token)
return token
token = self.get_token()
unary operator
nary operator
は教科書の定義ではunaryOp term
となっているので、これをそのままコードにしてある。
nary operator
の次にくるterm
を評価して、それに対してnary operator
の処理をしている。
elif token.is_unary_op():
op = token
token = self.get_token()
if not token.is_term():
self.error_message('term unaryOp', 'term', token)
return token
token = self.do_term(token, priority)
self.code_write('arithmetic', (op.unary_code(),))
return token
右結合
今のところterm
で処理しているunary operator
以外には右結合のoperator
はサポートしていない。が、もしも右結合をサポートするのであれば、優先順位なしの場合のコードのように、どんどん再帰を深くしていけば右側から演算するようになる。この場合の再帰を深くする条件は「優先順位が同じかそれ以上」の場合になる。これで右結合のoperator
もサポートできる(んじゃないかな)。
最後に
やってみたら思いの外簡単なコードになった演算子の優先順位。
あまりに簡単なので本当にこのコードで正しいのか非常に不安。思い付く限りいろいろな式で動作確認はしているのだがどうだろう…。バグレポートお待ちしています!
というわけで、俺俺クロスコンパイラの作成は思いの外楽しく、コンパイルされたZ80
のマシン語をmsx
のエミュレータで動かして遊んでいる。が、すぐにあれもやりたいこれもやりたいと仕様変更をしたくなってしまいmsx
で動かすアプリの作成はなかなか先に進まないのが問題…
Discussion