Language Implementation PatternsのLL(1) LexerをPythonで実装してみる
Language Implementation Patternsという本がある。翻訳もされていて、邦題は「言語実装パターン―コンパイラ技術によるテキスト処理から言語実装まで」。作者はTerrence Parrで、ANTLRというJavaベースのパーサ・ジェネレータの開発者。
パーサ作成から始めて、構文木の歩き方から型検査、バイトコードインタプリタやトランスパイラなどの話題を実装例を出しながら解説している。以前ぱらっと読んで大変面白かったのだが、手を動かして実装はしたことがなかった。実装言語がJavaで、私はJavaはなんとか読めるけどほとんど書いたことがないので少し敬遠してしまっていた。
最近Python3.10にパターンマッチが入ったので、それを使ってちょっとした言語処理系を実装してみたいという気分になっている。PythonだったらJavaコードのオブジェクト指向もそれなりに忠実に実装できるのでは?と思ってLanguage Implementation Patternsの最初の実装例であるLL(1) Lexerを試してみた。
パースしたい「言語」
今回のパースしたい言語(≠プログラミング言語)は「ネスト可能なリスト。アトミックな要素は一個以上のアルファベットからなる」というもの。
以下がその文法(Language Implementation Patternsより引用):
list : '[' elements ']'; elements : element (',' element)*; element : NAME | list; NAME : ('a'..'z' | 'A'..'Z')+;
今回はLexerのみの実装なので'['、']'、','、'a'..'z'|'A'..'Z'の部分をトークンとして出力できるようになれば完了。
トークン
Pythonのdataclass
を使ってトークンを表す(dataclass
にするとプリント出力を自動的にいい感じに整えてくれたりする。今回の実装では出てこないがパターンマッチとの相性も非常に良さそうで、今後Pythonで代数的データ型を表現する場合はdataclass
を使うのが主流になるんじゃないか?):
from dataclasses import dataclass @dataclass class Eof: pass @dataclass class Comma: pass @dataclass class Lbrack: pass @dataclass class Rbrack: pass @dataclass class Name: s : str
これはLanguage Implementation Patternsの実装とは結構違っている。LIPではTokenクラスが定義されていて、トークンの種類を表す整数とLexされた文字列の二つをデータとして保持している。
Lexer実装1
というわけでLexer自体の実装にうつる。
まずLanguage Implementation PatternsのJava実装にまあまあ忠実なやり方:
import string from liptoken import Eof, Comma, Lbrack, Rbrack, Name class Lex: def __init__(self, s): self.s = s self.p = 0 self.c = s[0] if s else 'EOF' def next_token(self): while True: match self.c: case c if c in string.whitespace: self._consume() continue case 'EOF': return Eof() case ',': self._consume() return Comma() case '[': self._consume() return Lbrack() case ']': self._consume() return Rbrack() case c if _is_letter(c): return self._name() case c: raise ValueError(f"Invalid character {c}") def _consume(self): self.p += 1 try: self.c = self.s[self.p] except IndexError: self.c = 'EOF' def _name(self): cs = [] while _is_letter(self.c): cs.append(self.c) self._consume() return Name(''.join(cs)) def _is_letter(c): return c in string.ascii_letters
Lexerクラスに入力を文字列self.s
として持たせ、どの文字まで消費したかを表す「ポインタ」的なデータself.p
をself._consume
メンバ関数を使って進めながら、1トークンごとにself.next_token
で返していく。
self.next_token
では現在の文字であるself.c
にパターンマッチして何のトークンを出力するかを決めていく。アルファベットに遭遇した場合はself._name
関数を呼び出して複数の文字を消費するが、それ以外だと1文字につき1トークンなので楽だ。
ファイル終端に関してはちょっとPythonのダイナミックさやら文字列・文字の関係やらを少し悪用して、self.c
に"EOF"という文字列をそのまま入れる形で扱っている。複数の文字からなる文字列はこのケース以外ではself.c
に入らないので、この文字列に対してパターンマッチしても入力とかぶるリスクはない。
(ちなみに「Language Implementation Patternsに忠実」とは言っても、LIPではLexerをベースクラスにして継承したNameListLexerを作成しているのに対してここではそのまま一つのクラスとして実装しているので微妙な違いはある)
使ってみる:
>>> tokens = [] >>> lexer = Lex("[abc, def,[g,h,i],[]]") >>> while not tokens or tokens[-1] != Eof(): ... tokens.append(lexer.next_token()) ... >>> tokens [Lbrack(), Name(s='abc'), Comma(), Name(s='def'), Comma(), Lbrack(), Name(s='g'), Comma(), Name(s='h'), Comma(), Name(s='i'), Rbrack(), Comma(), Lbrack(), Rbrack(), Rbrack(), Eof()]
Lexer実装2 - Iterator Protocol
next_token
のように一個ずつオブジェクトを返してくるストリーム的なものはIterator Protocolに従うように実装した方がよりPythonっぽい:
import string from liptoken import Eof, Comma, Lbrack, Rbrack, Name class Lex: def __init__(self, s): self.s = s self.p = 0 self.c = s[0] if s else 'EOF' self.done = False def __iter__(self): return self def __next__(self): while not self.done: match self.c: case c if c in string.whitespace: self._consume() continue case 'EOF': self.done = True return Eof() case ',': self._consume() return Comma() case '[': self._consume() return Lbrack() case ']': self._consume() return Rbrack() case c if _is_letter(c): return self._name() case c: raise ValueError(f"Invalid character {c}") raise StopIteration def _consume(self): self.p += 1 try: self.c = self.s[self.p] except IndexError: self.c = 'EOF' def _name(self): cs = [] while _is_letter(self.c): cs.append(self.c) self._consume() return Name(''.join(cs)) def _is_letter(c): return c in string.ascii_letters
実装1とほとんど変わっていないが、これでLexオブジェクトはIteratorになる。
あるオブジェクトがIteratorであるためには__iter__
と__next__
が定義されている必要がある。__iter__
はオブジェクトその物を返し、__next__
はストリームの次の要素を返すか、ストリーム終端を表すStopIteration
例外を投げる。
まず__iter__
は簡単:
__iter__(self):
return self
__next__
はnext_token
を微修正しただけ。self.done
フラグを追加、EOF
のパターンでTrue
にし、フラグがTrue
だと大元のwhile
ループから出てStopIteration
を投げる。
Iteratorにすることで、こんな使いかたができる:
>>> for token in Lex("[abc, def,[g,h,i],[]]"): ... print(token) ... Lbrack() Name(s='abc') Comma() Name(s='def') Comma() Lbrack() Name(s='g') Comma() Name(s='h') Comma() Name(s='i') Rbrack() Comma() Lbrack() Rbrack() Rbrack() Eof()
Lexer実装3 - Character Stackを使ったGenerator
Generator関数を使うとIterator Protocolを持ったクラスを定義するよりも手軽にIteratorオブジェクトを作成できる。
Lex
クラスが保持している状態というのは単に文字列とその中での位置であり、さらにいうと一旦消費してしまった文字には戻る必要がない。なのでその状態を「文字のスタック」として表してしまえば、あとはクラスである必要はない。
というわけでgenerator関数としてのLexer実装:
import string from liptoken import Eof, Comma, Lbrack, Rbrack, Name def lex(s): char_stack = list(reversed(s)) while char_stack: match char_stack.pop(): case c if c in string.whitespace: continue case ',': yield Comma() case '[': yield Lbrack() case ']': yield Rbrack() case c if _is_letter(c): yield _name(c, char_stack) case c: raise ValueError(f"Invalid character {c}") yield Eof() def _name(c, char_stack): name_cs = [c] while char_stack and _is_letter(char_stack[-1]): name_cs.append(char_stack.pop()) return Name(''.join(name_cs)) def _is_letter(c): return c in string.ascii_letters
Pythonのリストは末尾からのpopがO(1)なので、入力文字列のすべての文字を逆順に入れたリストを「文字スタック」として扱い、self.consume
の代わりにchar_stack.pop
していく。あとはnext_token
がlex
そのものになり、return
の代わりにyield
することでgenerator関数としてトークンのストリームを返すようになる。
>>> for token in lex("[abc, def,[g,h,i],[]]"): ... print(token) ... Lbrack() Name(s='abc') Comma() Name(s='def') Comma() Lbrack() Name(s='g') Comma() Name(s='h') Comma() Name(s='i') Rbrack() Comma() Lbrack() Rbrack() Rbrack() Eof()
結論
あまり複雑な状態管理が必要ないLexer(やパーサ)ならPythonだったらgenerator関数でスッキリ書ける。
ただし実際に言語処理系の実装などでLexerを自前で書く場合はエラーメッセージの出力だとかで複雑化してクラスにする必要が出てきそうだ。
どちらにせよ、Lexerのような遅延ストリームはまさにIterator Protocolで表現されるべきものなので、クラスで書く場合なら__iter__
と__next__
を実装するようにしたい。
疑問点
Language Implementation PatternsではこのLexerのことをLL(1)だとかrecursive descentだとか書いてあるのだが、Lexerに文法があるわけでもないのでLL(1)なのか?という点と構文木のようなネストした構造を出力するわけでもないので再帰する必要ないのでは?という点がもやもやしている。