Pythonにunless文を追加する

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

リンク

導入

実験をするにあたって、まずは先輩のunless文を追加してみたのレポートを参考にすることにしました。しかしPythonのバージョンの違いによりうまく行きませんでした。 実験終了後、途中まで手探ったunless文を今なら実装できるのではと思いいじってみたらうまくいったのでおまけとして記録しておきたいと思います。

流れ

Pythonコンパイルの流れは上記の公式ドキュメントに記されています。簡単に書くと以下の通りです。

ソースコード
↓ Parser/tokenizer.c
トークン列
↓ Parser/parser.c
AST(抽象構文木)
↓ Python/compile.c
CFG(制御フローグラフ)
↓ Python/compile.c
バイトコード(Python仮想マシンが読み取るアセンブリ言語のようなもの)

ここで、ソースコードからASTへの変換をいじるには、Grammar/python.gramGrammar/Tokensに変更を加え、make regen-tokenmake regen-pegenコマンドで関連ファイルを書き換えます(PythonにC言語っぽい文法を追加するを参照)。また、ASTからバイトコードへの変換はPython/compile.cに変更を加えればよさそうです。

パーサの変更

PythonにC言語っぽい文法を追加するでも書いたとおり、Grammar/python.gramを変更してunlessを解析できるようにします。ここではifの定義を参考にしたいので、ファイル内検索でif文に関連した部分を探しました。

Grammar/python.gram

compound_stmt[stmt_ty]:
    | &('def' | '@' | ASYNC) function_def
    | &'if' if_stmt
    | &'unless' unless_stmt
    | &('class' | '@') class_def
    | &('with' | ASYNC) with_stmt
    | &('for' | ASYNC) for_stmt
    | &'try' try_stmt
    | &'while' while_stmt
    
(省略)

if_stmt[stmt_ty]:
    | 'if' a=named_expression ':' b=block c=elif_stmt { _Py_If(a, b, CHECK((asdl_stmt_seq*)_PyPegen_singleton_seq(p, c)), EXTRA) }
    | 'if' a=named_expression ':' b=block c=[else_block] { _Py_If(a, b, c, EXTRA) }
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 }

unless_stmt[stmt_ty]:
    | 'unless' a=named_expression ':' b=block c=elun_stmt { _Py_Unless(a, b, CHECK((asdl_stmt_seq*)_PyPegen_singleton_seq(p, c)), EXTRA) }
    | 'unless' a=named_expression ':' b=block c=[else2_block] { _Py_Unless(a, b, c, EXTRA) }
elun_stmt[stmt_ty]:
    | 'elun' a=named_expression ':' b=block c=elun_stmt { _Py_Unless(a, b, CHECK(_PyPegen_singleton_seq(p, c)), EXTRA) }
    | 'elun' a=named_expression ':' b=block c=[else2_block] { _Py_Unless(a, b, c, EXTRA) }
    | 'else''unless' a=named_expression ':' b=block c=elun_stmt { _Py_Unless(a, b, CHECK(_PyPegen_singleton_seq(p, c)), EXTRA) }
    | 'else''unless' a=named_expression ':' b=block c=[else2_block] { _Py_Unless(a, b, c, EXTRA) }
else2_block[asdl_stmt_seq*]: 'else' ':' b=block { b }

変更をしてmake regen-pegenmakeをすると、以下のようなエラーが出ました。

Parser/parser.c:4012:20: error: implicit declaration of function ‘_Py_Unless’; did you mean ‘_Py_alias’? [-Werror=implicit-function-declaration]
             _res = _Py_Unless ( a , b , CHECK ( ( asdl_stmt_seq * ) _PyPegen_singleton_seq ( p , c ) ) , EXTRA );
                    ^~~~~~~~~~
                    _Py_alias

implicit declaration of function ‘_Py_Unless’、つまり_Py_Unlessが宣言されていないようです。_Py_Ifがどこかに宣言されていないかと思い、grep _Py_If -r .で検索をかけてみると、Include/Python-ast.hにありました。ここで再び公式ドキュメントを参照すると、Parser/Python.asdlを変更してmake regen-astを実行することでInclude/Python-ast.hなどの関連ファイルが書き換えられるようです。以下のように変更してみます。

Parser/Python.asdl

          | If(expr test, stmt* body, stmt* orelse)
          | Unless(expr test, stmt* body, stmt* orelse)

make regen-astmakeをするとビルドに成功しました!

コンパイラの変更

ここまででソースコードをASTに変換することはできました。しかし意味を定義していないので、unless文のプログラムを実行しても何も表示されません。次はPython/compile.cを変更します。 ファイルを見てみると、compiler_if関数でif文が定義されているようです。compiler_ifで検索すると2ヶ所見つかるので、それを参考にcompiler_unlessを追加します。コメントを読んで、constantの真偽値は逆になるようにしました。

Python/compile.c

static int
compiler_unless(struct compiler *c, stmt_ty s)
{
    basicblock *end, *next;
    int constant;
    assert(s->kind == Unless_kind);
    end = compiler_new_block(c);
    if (end == NULL)
        return 0;

    constant = expr_constant(s->v.Unless.test);
    /* constant = 1: "unless 1", "unless 2", ...
     * constant = 0: "unless 0"
     * constant = -1: rest */
    if (constant == 1) { //真偽値を反転
        BEGIN_DO_NOT_EMIT_BYTECODE
        VISIT_SEQ(c, stmt, s->v.Unless.body);
        END_DO_NOT_EMIT_BYTECODE
        if (s->v.Unless.orelse) {
            VISIT_SEQ(c, stmt, s->v.Unless.orelse);
        }
    } else if (constant == 0) {
        VISIT_SEQ(c, stmt, s->v.Unless.body);
        if (s->v.Unless.orelse) {
            BEGIN_DO_NOT_EMIT_BYTECODE
            VISIT_SEQ(c, stmt, s->v.Unless.orelse);
            END_DO_NOT_EMIT_BYTECODE
        }
    } else {
        if (asdl_seq_LEN(s->v.Unless.orelse)) {
            next = compiler_new_block(c);
            if (next == NULL)
                return 0;
        }
        else {
            next = end;
        }
        if (!compiler_jump_if(c, s->v.Unless.test, next, 0)) {
            return 0;
        }
        VISIT_SEQ(c, stmt, s->v.Unless.body);
        if (asdl_seq_LEN(s->v.Unless.orelse)) {
            ADDOP_JUMP(c, JUMP_FORWARD, end);
            compiler_use_next_block(c, next);
            VISIT_SEQ(c, stmt, s->v.Unless.orelse);
        }
    }
    compiler_use_next_block(c, end);
    return 1;
}

(省略)

    case If_kind:
        return compiler_if(c, s);
    case Unless_kind:
        return compiler_unless(c, s);

makeしてpython3を実行してみると、unlessはif文と同じ挙動をしました。もう一度compiler_unless関数内をよく見ると、compiler_jump_if関数というものがあり、4つ目の引数がcondとなっていることがわかります。これは条件だとにらんで0を1に変えてみると…

Python/compile.c

static int
compiler_unless(struct compiler *c, stmt_ty s)
{
    basicblock *end, *next;
    int constant;
    assert(s->kind == Unless_kind);
    end = compiler_new_block(c);
    if (end == NULL)
        return 0;

    constant = expr_constant(s->v.Unless.test);
    /* constant = 1: "unless 1", "unless 2", ...
     * constant = 0: "unless 0"
     * constant = -1: rest */
    if (constant == 1) { //真偽値を反転
        BEGIN_DO_NOT_EMIT_BYTECODE
        VISIT_SEQ(c, stmt, s->v.Unless.body);
        END_DO_NOT_EMIT_BYTECODE
        if (s->v.Unless.orelse) {
            VISIT_SEQ(c, stmt, s->v.Unless.orelse);
        }
    } else if (constant == 0) {
        VISIT_SEQ(c, stmt, s->v.Unless.body);
        if (s->v.Unless.orelse) {
            BEGIN_DO_NOT_EMIT_BYTECODE
            VISIT_SEQ(c, stmt, s->v.Unless.orelse);
            END_DO_NOT_EMIT_BYTECODE
        }
    } else {
        if (asdl_seq_LEN(s->v.Unless.orelse)) {
            next = compiler_new_block(c);
            if (next == NULL)
                return 0;
        }
        else {
            next = end;
        }
        if (!compiler_jump_if(c, s->v.Unless.test, next, 1)) { //4つ目の引数condを0から1にする
            return 0;
        }
        VISIT_SEQ(c, stmt, s->v.Unless.body);
        if (asdl_seq_LEN(s->v.Unless.orelse)) {
            ADDOP_JUMP(c, JUMP_FORWARD, end);
            compiler_use![](https://i.imgur.com/0ZPZAAT.png)
_next_block(c, next);
            VISIT_SEQ(c, stmt, s->v.Unless.orelse);
        }
    }
    compiler_use_next_block(c, end);
    return 1;
}

(省略)

    case If_kind:
        return compiler_if(c, s);
    case Unless_kind:
        return compiler_unless(c, s);

できました!

まとめ・感想

Python構文解析のサポートがかなり手厚く、頑張れば文法を変更できるというのが体験できて楽しかったです。

Pythonにオートインデント機能をつける

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

リンク

導入

ターミナルでPythonを使っていると、以下の画像のようにインデントを自分で入れる必要があります。 jupyter notebookでは自動でインデントを追加する機能があるのでターミナルでもこの機能を導入してあげたいと考えました。

入力を探す

オートインデント機能を実装するにあたって、まずユーザーからの入力を受け付ける部分を見つけます。具体的には、Emacs上でgdbを走らせて入力を受け付けていそうな関数の中にひたすらstepで入っていきます。入力を求められたらブレークポイントを設定して最初から走らせるという流れを繰り返しているとPython/pythonrun.c内のPyRun_InertactiveLoopFlags関数で、このようなコードを発見しました。

pythonrun.c/PyRun_InteractiveLoopFlags

    do {
        ret = PyRun_InteractiveOneObjectEx(fp, filename, flags);
        if (ret == -1 && PyErr_Occurred()) {
            /* Prevent an endless loop after multiple consecutive MemoryErrors
             * while still allowing an interactive command to fail with a
             * MemoryError. */
            if (PyErr_ExceptionMatches(PyExc_MemoryError)) {
                if (++nomem_count > 16) {
                    PyErr_Clear();
                    err = -1;
                    break;
                }
            } else {
                nomem_count = 0;
            }
            PyErr_Print();
            flush_io();
        } else {
            nomem_count = 0;
        }
#ifdef Py_REF_DEBUG
        if (show_ref_count) {
            _PyDebug_PrintTotalRefs();
        }
#endif
    } while (ret != E_EOF);

このdo-whileループによって入力、処理→エラーチェックを何度も繰り返しています。 この中で入力と処理を行っているのはPyRun_InteractiveOneObjectEx関数なので、この関数をまたstepしていくとModule/readline.creadline_until_enter_or_signal関数 といういかにも入力を受け付けていそうな関数に入ります。

readline.c/readline_until_enter_or_signal

static char *
readline_until_enter_or_signal(const char *prompt, int *signal)
{
    char * not_done_reading = "";
    fd_set selectset;

    *signal = 0;
#ifdef HAVE_RL_CATCH_SIGNAL
    rl_catch_signals = 0;
#endif

    rl_callback_handler_install (prompt, rlhandler);
    FD_ZERO(&selectset);

    completed_input_string = not_done_reading;

    while (completed_input_string == not_done_reading) {
        int has_input = 0, err = 0;

        while (!has_input)
        {               struct timeval timeout = {0, 100000}; /* 0.1 seconds */

            /* [Bug #1552726] Only limit the pause if an input hook has been
               defined.  */
            struct timeval *timeoutp = NULL;
            if (PyOS_InputHook)
                timeoutp = &timeout;
#ifdef HAVE_RL_RESIZE_TERMINAL
            /* Update readline's view of the window size after SIGWINCH */
            if (sigwinch_received) {
                sigwinch_received = 0;
                rl_resize_terminal();
            }
#endif
            FD_SET(fileno(rl_instream), &selectset);
            /* select resets selectset if no input was available */
            has_input = select(fileno(rl_instream) + 1, &selectset,
                               NULL, NULL, timeoutp);
            err = errno;
            if(PyOS_InputHook) PyOS_InputHook();
        }

        if (has_input > 0) {
            rl_callback_read_char();
        }
        else if (err == EINTR) {
            int s;
            PyEval_RestoreThread(_PyOS_ReadlineTState);
            s = PyErr_CheckSignals();
            PyEval_SaveThread();
            if (s < 0) {
                rl_free_line_state();
#if defined(RL_READLINE_VERSION) && RL_READLINE_VERSION >= 0x0700
                rl_callback_sigcleanup();
#endif
                rl_cleanup_after_signal();
                rl_callback_handler_remove();
                *signal = 1;
                completed_input_string = NULL;
            }
        }
    }

    return completed_input_string;
}

上のコードのうちrl_callback_read_char関数 で入力を受け付けていること、rl_callback_handler_install関数 でターミナル上の>>>...が入力されていることがわかりました。(正確には変数prompt>>>...が格納されていて、rl_callback_handler_install関数で出力している。)

GNU Readlineについて

rl_callback_read_char関数を調べたところ、GNU Readlineと呼ばれるライブラリの関数であることがわかりました。rlはreadlineの略だそうです。

CPythonに入力させる

それでは、実際にインデントをプログラム側から入力するように変えていきます。 rl_callback_read_char関数の前に rl_insert_text("␣␣␣␣") (␣は空白)を追加すればインデントを挿入することができますが、これだけではキーボードから入力されるまでインデントが表示されませんでした。 そこで、ドキュメントを眺めていたところ、rl_line_bufferの現在の内容を画面上に反映させるrl_redisplay() 関数を見つけました。(これはバッファにたまった文字列を画面上に出力するC言語のfflush関数を想起させます。) rl_insert_textの後にrl_redisplay()を入れると、即座にインデントが表示されるようになりました。 変数promptにはターミナル上で表示される>>>...が格納されているので、とりあえず「prompt...ならばインデントを行う」とすればブロック内ならインデントを1個だけ置くという処理が実装できます。

readline.c/readline_until_enter_or_signal

    if(strcmp(prompt,"... ") == 0){
        rl_insert_text("    ");
    }
    rl_redisplay();

直前の入力を読み込む

上記の実装だけではインデントが1個補完されるだけで、Python入れ子構造に対応できていません。そのため、ユーザの直前の入力を持ってきてインデントの数を管理する必要があります。 Module/readline.cについて眺めていると直前の行の入力を受け取る部分がありました。直前の行の入力は変数lineに格納されています。 (以下のコード参照、直前と重複しない限り入力を履歴に追加するというコードのようです)

readline.c/call_readline

    /* we have a valid line */
    n = strlen(p);
    if (should_auto_add_history && n > 0) {
        const char *line;
        int length = _py_get_history_length();
        if (length > 0) {
            HIST_ENTRY *hist_ent;
            if (using_libedit_emulation) {
                /* handle older 0-based or newer 1-based indexing */
                hist_ent = history_get(length + libedit_history_start - 1);
            } else
                hist_ent = history_get(length);
            line = hist_ent ? hist_ent->line : "";
        } else
            line = "";
        if (strcmp(p, line))
            add_history(p);
    }

このコードをコピーして不必要な部分を削れば直前の入力を文字列lineに格納するコードの出来上がりです。

readline.c/readline_until_enter_or_signal

        const char *line;
        int length = _py_get_history_length();
            HIST_ENTRY *hist_ent;
            if (using_libedit_emulation) {
                /* handle older 0-based or newer 1-based indexing */
                hist_ent = history_get(length + libedit_history_start - 1);
            } else
                hist_ent = history_get(length);
            line = hist_ent ? hist_ent->line : "";

インデントの数を変更する

直前の入力lineを読み込んだので、先頭の文字からインデントを読みだしていきます。直前の入力のインデントと同じだけインデントを追加して、直前の入力に:があったらさらにインデントを1つ追加します。また、コメントアウトされていた場合は、それ以上インデントの追加を行わないようにします。

readline.c/readline_until_enter_or_signal

    if(strcmp(prompt,"... ") == 0){
        const char *line;
        int length = _py_get_history_length();
            HIST_ENTRY *hist_ent;
            if (using_libedit_emulation) {
                /* handle older 0-based or newer 1-based indexing */
                hist_ent = history_get(length + libedit_history_start - 1);
            } else
                hist_ent = history_get(length);
            line = hist_ent ? hist_ent->line : "";

        int len = strlen(line);
        for(int i = 0; i < len; i++){
            if(line[i] == ' '){
                rl_insert_text(" ");
            }else if(line[i] == '\t'){
                rl_insert_text("\t");
            }else{
                break;
            }
        }
        for (int i=0;i<len;i++){
            if(line[i] == '#'){
                break;
            } else if(line[i]==':') {
                rl_insert_text("    ");
                break;
            }
        }
    }

    rl_redisplay();

これで望んだ動作を実現できました!

実装結果

最終的なコード

readline.c/readline_until_enter_or_signal

static char *
readline_until_enter_or_signal(const char *prompt, int *signal)
{
    char * not_done_reading = "";
    fd_set selectset;

    *signal = 0;
#ifdef HAVE_RL_CATCH_SIGNAL
    rl_catch_signals = 0;
#endif
    rl_callback_handler_install (prompt, rlhandler);
    FD_ZERO(&selectset);

    completed_input_string = not_done_reading;


    if(strcmp(prompt,"... ") == 0){
        const char *line;
        int length = _py_get_history_length();
            HIST_ENTRY *hist_ent;
            if (using_libedit_emulation) {
                /* handle older 0-based or newer 1-based indexing */
                hist_ent = history_get(length + libedit_history_start - 1);
            } else
                hist_ent = history_get(length);
            line = hist_ent ? hist_ent->line : "";

        int len = strlen(line);
        for(int i = 0; i < len; i++){
            if(line[i] == ' '){
                rl_insert_text(" ");
            }else if(line[i] == '\t'){
                rl_insert_text("\t");
            }else{
                break;
            }
        }
        for (int i=0;i<len;i++){
            if(line[i] == '#'){
                break;
            } else if(line[i]==':') {
                rl_insert_text("    ");
                break;
            }
        }
    }

    rl_redisplay();
    while (completed_input_string == not_done_reading) {
        int has_input = 0, err = 0;
        while (!has_input)
        {               struct timeval timeout = {0, 100000}; /* 0.1 seconds */

            /* [Bug #1552726] Only limit the pause if an input hook has been
               defined.  */
            struct timeval *timeoutp = NULL;
            if (PyOS_InputHook)
                timeoutp = &timeout;
#ifdef HAVE_RL_RESIZE_TERMINAL
            /* Update readline's view of the window size after SIGWINCH */
            if (sigwinch_received) {
                sigwinch_received = 0;
                rl_resize_terminal();
            }
#endif
            FD_SET(fileno(rl_instream), &selectset);
            /* select resets selectset if no input was available */
            has_input = select(fileno(rl_instream) + 1, &selectset,
                               NULL, NULL, timeoutp);
            err = errno;
            if(PyOS_InputHook) PyOS_InputHook();
        }
        if (has_input > 0) {
            rl_callback_read_char();
        }
        else if (err == EINTR) {
            int s;
            PyEval_RestoreThread(_PyOS_ReadlineTState);
            s = PyErr_CheckSignals();
            PyEval_SaveThread();
            if (s < 0) {
                rl_free_line_state();
#if defined(RL_READLINE_VERSION) && RL_READLINE_VERSION >= 0x0700
                rl_callback_sigcleanup();
#endif
                rl_cleanup_after_signal();
                rl_callback_handler_remove();
                *signal = 1;
                completed_input_string = NULL;
            }
        }
    }
    return completed_input_string;
}

下のgif画像から、実際にインデントが必要な数だけ自動補完されていることがわかります。

まとめ・感想

  • gdbを使ってプログラムの動作を一行ずつ見ながら特定の場所を探るということが「大規模ソフトウェアを手探」っている感があって面白く感じられました。
  • 入出力に用いられるGNU readlineというモジュールの存在を知ることができたことはいい収穫でした。とくにrl_insert_text関数やrl_redisplay関数、history_get関数などを通してどのように入出力が行われているのか深く切り込むことができたのは非常にためになりました。
  • CPythonという巨大なプログラムを手探る中で、readline_until_enter_or_signal関数というように関数名などが理解しやすく定義されていて、一人だけで扱うものでないプログラムにおいて後継を意識してコーディングすることの意義を感じられました。

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からパーサが変更されたおかげで先輩達が使っていたバージョンのときよりも簡単に文法をいじれるようになっている気がします。

Pythonを改造して機能を追加した話

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

東京大学工学部電子情報工学科/電気電子工学科(eeic)の選択できる実験として「大規模ソフトウェアを手探る」があります。これは、OSS(オープンソースソフトウェア)として公開されている大規模なプログラムを改良/機能拡張することで、普段授業で扱うような小規模なプログラムでは触れられない、全容を把握することが困難なプログラムを扱う方法を身につけるというものです。 今回、自分たちは授業でも使い馴染みのあるPythonを手探ることにしました。

リンク

環境

準備

まず作業用のディレクトリを作成します(ここでは~/cpython)。 Github上のPython3.10(cpython)からgit cloneを用いてリポジトリを複製します。

$ mkdir cpython
$ cd cpython
$ git clone https://github.com/python/cpython

ビルド

configureMakefileを作成します。このとき--prefixオプションをもちいて最終的にプログラムをどこに配置するかを決定します(ここでは~/python-install)。また環境変数CFLAGS-O0を付けることで最適化レベルを落とし、さらに-gを付けることで実行可能ファイルに「デバッグシンボル」を含めます。こうすることで、gdbを使ってプログラムの挙動を1行ずつ追うことができます。

configureMakefileが作成できたら、makemake installコンパイルとインストールを行います。

$ CFLAGS="-g -O0" ./configure --prefix=/home/[username]/python-install/
$ make 
$ make install

これでビルドが完了し、~/python-install/bin/python3を実行することで、pythonが起動できます。 コードを変更したときは、make cleanをしてからmakeをする必要があります。

デバッガで追跡

Emacsを起動し、M-x shellコマンドで~/python-install/bin/に移動します。 移動できたらM-x gud-gdbgdb --fullname python3を叩いてgdbを起動、これを用いてプログラムの追跡を行いました。

参考資料

大規模ソフトウェアを手探る

  • 実験のホームページです。

CPythonの公式ドキュメント(Changing CPython’s Grammar)

CPythonの公式ドキュメント(Design of CPython’s Compiler)

  • pythonに文法を追加する際にどのファイルを変更すればよいかが記載されています。

GNU readline公式ドキュメント

Pythonを改造してみた はじめに

  • unless文をPythonに追加したEEICの先輩の記事です。Pythonを改造するにあたって、何から始めればいいかを参考にしました。