PythonでLanguage Implementation Patternsのパーサと戯れる lexerとPeekableストリームオブジェクト
パーサを作るにあたってまずはlexerから。
lexerと簡単なパーサで共通しているのは「消費可能なストリームの先頭を『消費することなく』先読みする必要がある」という点。lexerだと入力の文字のストリーム、パーサだとlexerの返すトークンストリーム、と対象は異なるがどちらも「先読み」が必要。
Peekable
Pythonのストリームであるiteratorは__next__
で消費しないと次の要素が何かわからない。iteratorを拡張してpeekが可能なクラスをまず定義する:
class Peekable: def __init__(self, input_, k, sentinel=None): self.sentinel = sentinel self._k = k self._stream = iter(input_) self._peek = [next(self._stream, sentinel) for _ in range(k)] def __getitem__(self, n): if isinstance(n, int) and n >= self._k: raise IndexError(f"Invalid lookahead index {n} on Peekable with k={self._k}") return self._peek[n] def __iter__(self): return self def __next__(self): if self._peek[0] == self.sentinel: raise StopIteration res = self._peek[0] self._peek = self._peek[1:] self._peek.append(next(self._stream, self.sentinel)) return res
__iter__
と__next__
でiteratorにしてあり、さらに__getitem__
を定義して先頭k
個の要素を消費せずに先読みできるようにしてある。
正直Peekable
は__getitem__
を実装しないで利用者側にpeekable._peek
に直接アクセスさせてもいい気もするのだけど、あとで出てくるBacktrackable
との兼ね合いもあって定義しておいた。
使い方としてはこんな感じ:
>>> p = Peekable("abcdefg", 2) >>> p[0] 'a' >>> p[1] 'b' >>> next(p) 'a' >>> p[0] ‘B’ >>> p[1] 'c' >>> p[2] Traceback (most recent call last): ... IndexError: Invalid lookahead 2 on Peekable with k=2 >>> list(p) ['b', 'c', 'd', 'e', 'f', 'g']
トークン
Lexerが返すトークンは以下の通り:
LBRACK : [ RBRACK : ] EQUAL : = COMMA : , EOF : EOF NAME : ('a'..'z' | 'A'..'Z')+;
これらを愚直にdataclass
として別モジュールxtokens.py
に定義:
from dataclasses import dataclass @dataclass class Lbrack: pass @dataclass class Rbrack: pass @dataclass class Equal: pass @dataclass class Comma: pass @dataclass class Eof: pass @dataclass class Ident: s : str
Lexer
LexerはPeekableを使えば非常に簡単な非再帰関数として書ける:
import string from utils import Peekable import xtokens as t def lex(char_iterable): stream = Peekable(char_iterable, 1) return _lex(stream)
lex
関数が外部インタフェースとなっており、文字iterableを引数にとる(まあ大抵は文字列だけど、例えばfile
をラップして1文字ずつのストリームにしたものでも受け取れる)。そのiterableを先頭要素1個が先読み可能なPeekableにして、generatorを返す_lex
に渡している。
def _lex(stream): while True: match stream[0]: case stream.sentinel: yield t.Eof() break case '[': next(stream) yield t.Lbrack() case ']': next(stream) yield t.Rbrack() case '=': next(stream) yield t.Equal() case ',': next(stream) yield t.Comma() case c if _is_letter(c): yield _lex_ident(stream) case c if c in string.whitespace: next(stream) case c: raise ValueError(f"Invalid character {c}") def _lex_ident(stream): cs = [] while _is_letter(stream[0]): cs.append(next(stream)) return t.Ident(''.join(cs)) def _is_letter(c): return c in string.ascii_letters
_lex
関数は無限ループでPeekableな文字ストリームの先頭にパターンマッチしてトークンをyield
していく。Peekableの終端要素であるsentinel
に当たったらEof
トークンを返して終了。
そもそもなぜlexerに先読みが必要になるかというと_lex_ident
関数の中でwhile _is_letter(stream[0]):
とやっているから。このループ条件チェックで文字を消費してしまうと、ascii_letters
ではない文字に当たってループ停止した後で、その文字をトークン化するのに困ってしまう。
_lex
で使われているのがPython3.10のStructural Pattern Matchingで、ここではあまり構造の分解などはしていないのであくまでswitch的な使い方をしている。if ... elif ... else
といった構文でも問題ないが、パターンマッチの方がスッキリする印象。
使ってみる
>>> lex("[abc, def, ghi , j, k, l]") <generator object _lex at 0x106c5a180> >>> list(lex("[abc, def, ghi , j, k, l]")) [Lbrack(), Ident(s='abc'), Comma(), Ident(s='def'), Comma(), Ident(s='ghi'), Comma(), Ident(s='j'), Comma(), Ident(s='k'), Comma(), Ident(s='l'), Rbrack(), Eof()] >>> list(lex("[abc = def, ghi , [j, k], l]")) [Lbrack(), Ident(s='abc'), Equal(), Ident(s='def'), Comma(), Ident(s='ghi'), Comma(), Lbrack(), Ident(s='j'), Comma(), Ident(s='k'), Rbrack(), Comma(), Ident(s='l'), Rbrack(), Eof()]
今回のコード
Python Implementation of LL(1) Lexer based loosely on Language Implementation Patterns · GitHub
次回
lexerができたので次はネストしないリストのための非常に簡単なパーサを実装する。