PythonにC言語っぽい文法を追加する

※このブログはeeicの実験「大規模ソフトウェアを手探る」のレポートとして書かれたものです。

リンク

導入

Pythonにおいて論理式ではandor,notが用いられていて、C言語で使われているような&&,||,!が使えません。この仕様はC言語ユーザーには間違いやすいと感じました(事実、筆者の一人は今でも間違えることがあります)。 そこで、C言語で使われている文法も許容するように改造しました。

公式のドキュメントを読む

CPythonを改造するに当たって、改造する方法がまとまった公式の開発者ガイドを見つけました。

  • 文法を変えるときにはGrammar/python.gramを変更してmake regen-pegenコマンドを打つ

  • 新しい記号類を追加するにはGrammar/Tokensを変更してmake regen-tokenコマンドを打つ

ことがわかります。

このドキュメントはCPython3.9以降のバージョンを対象としています。CPython3.8以前ではGrammerフォルダの中身が異なり、違う変更が求められるため注意が必要です(実は最初はCPython3.8.6を改造していたのですが、これを理由に改造の対象をCPython3.10に変更しました)。

この変更はCPython3.9からパーサがLL(1)パーサからPEGパーサに変更されたことによるそうです (PEP 617 -- New PEG parser for CPython)

make regen-tokenがうまくいかないとき

公式ドキュメントでは、Tokenspython.gramを変更した際、make regen-token,make regen-pegenを実行することで自動で他の関連するファイルが書き換わると書かれています。しかし、このコマンドにはセイウチ演算子(:=)が用いられているため、Python3.7以降を用いる必要があります(CPythonをビルドするためにPythonが必要です)。 作業を行った環境ではPython3.6がデフォルトで入っていたため、make regen-pegenを実行しようとするとこの処理ができずにエラーを吐いてしまいました。

エラーの内容

Traceback (most recent call last):
  File "/usr/lib/python3.6/runpy.py", line 193, in _run_module_as_main
    "__main__", mod_spec)
  File "/usr/lib/python3.6/runpy.py", line 85, in _run_code
    exec(code, run_globals)
  File "/home/denjo/cpython/Tools/peg_generator/pegen/__main__.py", line 16, in <module>
    from pegen.build import Grammar, Parser, Tokenizer, ParserGenerator
  File "/home/denjo/cpython/Tools/peg_generator/pegen/build.py", line 10, in <module>
    from pegen.c_generator import CParserGenerator
  File "/home/denjo/cpython/Tools/peg_generator/pegen/c_generator.py", line 2, in <module>
    from dataclasses import field, dataclass
ModuleNotFoundError: No module named 'dataclasses'
Makefile:840: recipe for target 'regen-pegen' failed
make: *** [regen-pegen] Error 1


ここではpyenvを用いてPythonのバージョンを3.8.0に変更します。 以下のサイトを参考にターミナル上でコマンドを叩いたところバージョンをPython3.8.0として指定することができました。
pyenvのインストール、使い方、pythonのバージョン切り替えできない時の対処法

$ git clone git://github.com/yyuu/pyenv.git ~/.pyenv
$ echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bash_profile
$ echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bash_profile
$ echo 'eval "$(pyenv init -)"' >> ~/.bash_profile
$ source ~/.bash_profile
$ pyenv install 3.8.0
$ pyenv local 3.8.0

実際にバージョンが変更できたかはpython3 --versionで確認できます。 自分たちの環境では、ターミナルを起動するたびにsource ~/.bash_profileを実行する必要がありました。

&&を追加する

記号を追加する

実際にコードを書き換えていきます。ここでは、&&andとして解釈してもらうため&&がCPythonの記号であると定義します。

Grammar/Tokens

RARROW                  '->'
ELLIPSIS                '...'
COLONEQUAL              ':='

↓ AND_ANDを追加する

RARROW                  '->'
ELLIPSIS                '...'
COLONEQUAL              ':='
AND_AND                 '&&'

ちなみに記号として登録すると前後の要素との間にスペースを必要としなくなります。例えばif AandB:では認識されませんがif A&&B:では認識されるというわけです。    

文法を定義する

記号の追加が終わったら次は文法の定義を行います。&&が実際にandと同じ動作をするように定義します。Grammar/python.gram内にandの動作が記述してあるので、そこを参考にand&&に変えたコードを追加します。
Grammar/python.gram

conjunction[expr_ty] (memo):
    | a=inversion b=('and' c=inversion { c })+ { _Py_BoolOp(
        And,
        CHECK(_PyPegen_seq_insert_in_front(p, a, b)),
        EXTRA) }
    | inversion

&&の文法を定義する

conjunction[expr_ty] (memo):
    | a=inversion b=('and' c=inversion { c })+ { _Py_BoolOp(
        And,
        CHECK(_PyPegen_seq_insert_in_front(p, a, b)),
        EXTRA) }
    | a=inversion b=('&&' c=inversion { c })+ { _Py_BoolOp(
        And,
        CHECK(_PyPegen_seq_insert_in_front(p, a, b)),
        EXTRA) }
    | inversion

あとはターミナル上でmake regen-tokenmake regen-pegenを叩けば関連ファイルが書き換えられ、ビルドをすれば&&がCPythonに認識されます!!

他の記号(||!)の追加

同様にして||!を追加していきます。 Tokensに記号を追加して、python.gramからor,notを定義している部分を見つけてそれを真似て追加するだけです。 (変更自体は&&とほとんど同じことをするだけなので変更後だけ乗せます。)

Grammar/Tokens

OR_OR                   '||'
NOT_ALIAS               '!'

Grammar/python.gram

disjunction[expr_ty] (memo):
    | a=conjunction b=('or' c=conjunction { c })+ { _Py_BoolOp(
        Or,
        CHECK(_PyPegen_seq_insert_in_front(p, a, b)),
        EXTRA) }
    | a=conjunction b=('||' c=conjunction { c })+ { _Py_BoolOp(
        Or,
        CHECK(_PyPegen_seq_insert_in_front(p, a, b)),
        EXTRA) }
    | conjunction
    
(省略)

inversion[expr_ty] (memo):
    | '!' a=inversion { _Py_UnaryOp(Not, a, EXTRA) }
    | 'not' a=inversion { _Py_UnaryOp(Not, a, EXTRA) }
    | comparison

コード変更後も&&と同じでmake regen-tokenmake regen-pegenを叩くだけです。

True,Falseの小文字化

python.gramからTrue,Falseを定義している部分を見つけてそれを真似て追加するだけです。 記号類の変更はないのでTokensは変更する必要はありません。

Grammar/python.gram

atom[expr_ty]:
    | NAME
    | 'True' { _Py_Constant(Py_True, NULL, EXTRA) }
    | 'true' { _Py_Constant(Py_True, NULL, EXTRA) }
    | 'False' { _Py_Constant(Py_False, NULL, EXTRA) }
    | 'false' { _Py_Constant(Py_False, NULL, EXTRA) }
    | 'None' { _Py_Constant(Py_None, NULL, EXTRA) }
    | &STRING strings
    | NUMBER
    | &'(' (tuple | group | genexp)
    | &'[' (list | listcomp)
    | &'{' (dict | set | dictcomp | setcomp)
    | '...' { _Py_Constant(Py_Ellipsis, NULL, EXTRA) }

else ifも許容する

同様に変更します。

Grammar/python.gram

elif_stmt[stmt_ty]:
    | 'elif' a=named_expression ':' b=block c=elif_stmt { _Py_If(a, b, CHECK(_PyPegen_singleton_seq(p, c)), EXTRA) }
    | 'elif' a=named_expression ':' b=block c=[else_block] { _Py_If(a, b, c, EXTRA) }
    | 'else''if' a=named_expression ':' b=block c=elif_stmt { _Py_If(a, b, CHECK(_PyPegen_singleton_seq(p, c)), EXTRA) }
    | 'else''if' a=named_expression ':' b=block c=[else_block] { _Py_If(a, b, c, EXTRA) }
else_block[asdl_stmt_seq*]: 'else' ':' b=block { b }

ここで追加したコードではelse ifの間の空白については判別を行っていないので、空白が複数あっても実行できるプログラムになっています。

実行結果

実際に以下の三点が実現できていることがわかると思います。
・論理演算子として&&や||や!を追加する
・論理値のTrue,Falseをtrue,falseにも対応させる
・elifだけでなくelse ifにも対応させる

まとめ・感想

CPythonはGrammarフォルダの内部で予約語や記号の定義が行われているので、既存の文法を参考にしたものを追加するだけならば簡単に実装できます。CPython3.9からパーサが変更されたおかげで先輩達が使っていたバージョンのときよりも簡単に文法をいじれるようになっている気がします。