開拓馬の厩

いろいろやる

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

Pythonは実行時にソースコードから機械語を生成するインタプリタ型言語の一種です。この記事ではPythonがコードを実行する際に、内部でどのような処理を行っているのかを調査した結果を紹介します。

しかし、ただソースコードやドキュメントを読むだけでは面白くありません。また、Pythonオープンソースで誰でも自由にソースコードをいじることができます。なので「Pythonを改造して新しい予約語を追加する」という方法でインタプリタの仕組を追ってみることにしました。

このページではソースコードをダウンロードしてビルドする所までの様子をお伝えします。 実際に改造を行った部分の説明は以下の記事からご覧いただけます。

これらの記事は大規模ソフトウェアを手探るという大学の講義のレポートを兼ねています。

また、ソースコードをいじる作業やブログ記事の作成はブログ主(id:pf_siedler)ともう一名の学生の二人組で行いました。

環境

ビルド

Pythonの公式サイトからソースコード(tarball形式)をダウンロードして適当なディレクトリに展開します。 ターミナルでソースコードを展開したディレクトリに移動しconfigureを叩きます。ここで、デバッグを行いやすいように、CFLAGS-g -O0オプションを与えます((-gデバッグシンボルを付けるオプション。これを付けないとデバッガを使うことができない。-O0は最適化を行わないオプション。デバッグ時にソースコードがそのままの状態で読める。))。また、--prefixオプションでインストール先のディレクトリをホーム直下のmypythonディレクトリに指定します。

その後、makemake installを行いインストールを行いました。makeには-jオプションで複数のプロセッサを割り当て高速化しました。

% tar zxvf Python3.5.2

% cd Python3.5.2

% CFLAGS="-g -O0" ./configure --prefix=/home/(username)/mypython/

% make -j8

% make install

ビルドは問題なく完了しました。~/mypython/bin/python3を実行することでPythonを起動できます。

% ~/mypython/bin/python3
Python 3.5.2 (<<>>)
[GCC 4.8.4] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

ドキュメントを読む

とりあえずビルドはできましたが、どこから手をつければいいかわかりません。そこでネットの記事を漁って情報を仕入れることにしました。 すると、昨年度の「大規模ソフトウェア」受講者にPythonを改造した方がいたので記事を読んでみると、Changing CPython’s Grammarなるガイドラインが紹介されていました。
この講義は「同じことをしようとしている誰かの役に立つため」に最終レポートをブログ形式で一般公開することを要求されているのですが、早速後輩の役に立つとは……

ガイドによると新しい予約語を追加する場合、おおよそ以下のような変更を加えればよいそうです。

  1. Grammar/Grammarをいじる(文法をの規定)
  2. Parser/Python.asdlをいじる(構文解析部分の自動生成に使うらしい?)
  3. Python/ast.cをいじる(AST生成)
  4. Python/symtable.c(をいじるASTに意味付けを行いコンパイラに渡す)
  5. Python/compile.cをいじる(コードに従ってバイトコードを生成)

後ろ3つはC言語のコードですが、GrammarPython.asdlは独特な記法で書かれていて大きな変更しづらそうです。また、C言語のファイル3つもマクロを使用して関数を呼び出しているので、既存のコードから大幅に外れた変更は難しそうです。

なので 既存の構文とほとんど同じ動きをする構文を追加する

慣れたら少し変わったことをする
という順序で開発を行っていくことにしました。

参考資料

Python公式ページ
Developer's Guideの23. Changing CPython’s Grammarに文法を変更する手順が記載されています。

大規模ソフトウェアを手探る
この記事を書くきっかけとなった大学の講義ページです。

CPythonに後置インクリメントを加えてみた
昨年度の大規模ソフトウェアを手探る受講者の方が書いたページです。Pythonのディベロッパーズガイドはこの記事を読んで知りました。

Python internals: adding a new statement to Python
「Pythonにuntil文を追加してみた」という内容の記事です。とても参考になりました。

dis/inspect モジュールを使った Python のハッキング
disassembleモジュール等を使ってPythonの言語仕様を解析する内容の記事です

Pythonを改造してみた unless文を追加してみた

プログラム言語Rubyにはunlessという構文があります。これはif文の反対で与えられた条件式が偽の場合に処理を行います。

unless cond then
    print 'OK'
end
#上記の場合、condがFalseならOKが表示されます

この記事ではPythonに上記のようなunless文を実装した結果を紹介します。

文法定義をいじる

ビルドまでの経緯はこちら

Pythonの公式ドキュメントに文法変更のガイドラインがあるので、これに沿って改造を施していきます。 まず、ソースファイル内のGrammar/Grammarを書き換えてunless文の定義を追加します。 GrammarファイルはPythonの文法を規定したテキストファイルです。 Grammarは独自の記法で書かれていましたが、unless文の文法構造はif文とほとんど同じなので、if文を真似れば問題なさそうです。

# #if IF_INV

compound_stmt: if_stmt | unless_stmt | while_stmt | for_stmt | try_stmt | with_stmt | funcdef | classdef | decorated | async_stmt
async_stmt: ASYNC (funcdef | with_stmt | for_stmt)
if_stmt: 'if' test ':' suite ('elif' test ':' suite)* ['else' ':' suite]
# #if IF_INV
unless_stmt: 'unless' test ':' suite ('elunless' test ':' suite)* ['else' ':' suite]

if文には、else ifの省略形であるelifという構文が実装されています。unless文にもとりあえずこれに対応するelunlessという構文を追加しました。なお、unless文の元ネタであるRubyにはelsifに対応するelsunlessは存在しません。今回は細かいことは気にせずにif文の実装を完全コピーしました。(実際にelunlessを使うとコードが読みにくくなるので実用性はありませんが…)

Grammarファイルでは#以降の文字列は無視されるようなので、変更した部分を検索しやすいように#if IF_INVという文字列を置いておきました。 ちなみに、Grammar内にascii文字以外の文字を書くと、たとえコメントアウトされている部分でもエラーが発生するため、日本語のコメントは書けないようです。

次にPaser/Python.asdlを書き換えます。このファイルはMake時の文法解析プログラムの自走生成に使われます。

  | If(expr test, stmt* body, stmt* orelse)
  -- #if IF_INV
  | Unless(expr test, stmt* body, stmt* orelse)

以上の2つのファイルの変更後、自動生成ファイルを書き換えてもらうために一度makeを行いました。makeは失敗しましたが(unless文が存在するが動作が定義されていないみたいなエラーが出た)自動生成ファイルの書き換えが行われました。

//Python-ast.c

static PyTypeObject *If_type;
static char *If_fields[]={
    "test",
    "body",
    "orelse",
};
//unless文に対応する部分が追加された
static PyTypeObject *Unless_type;
static char *Unless_fields[]={
    "test",
    "body",
    "orelse",
};

構文木、記号表を作り変える

Pythonは与えられた構文からAST(抽象構文木)を作成します。構文木生成プログラムはPython/ast.cに記載されていますが、このファイルはmake時に自動で書き換えてくれないので手動で書き換えます。 ast.cunless文処理用の関数を追加します。

#if IF_INV
static stmt_ty
ast_for_unless_stmt(struct compiling *c, const node *n)
{
    /* unless_stmt: 'unless' test ':' suite ('elunless' test ':' suite)* ['else' ':' suite]
    */
    char *s;
//if文処理用の関数をコピーしifをunlessに書き換える
    REQ(n, unless_stmt);

    :
    :

    //elseとelifを区別する処理
    //elifの部分をelunlessに対応させる
    s = STR(CHILD(n, 4));
    /* s[2], the third character in the string, will be
       's' for el_s_e, or
       'u' for el_u_nless
    */
    if (s[2] == 's') {
        :
        :
    }
    else if (s[2] == 'u') {
        :
        :
    }

        :
        :

}
#endif

書き換えた部分には#if IF_INVというフラグを置いておき、コンパイルオプションでOn/Offを切り替えられるようにしておきました。 コンパイルオプションで-DIF_INV=1を指定すると変更部分が反映されます。

Python/symtable.cも書き換えます。symtable.cは記号表(抽象構文木に意味付けするときに使われる?)を作るプログラムです。これもif文の真似をしました。

    case If_kind:
        /* XXX if 0: and lookup_yield() hacks */
        VISIT(st, expr, s->v.If.test);
        VISIT_SEQ(st, stmt, s->v.If.body);
        if (s->v.If.orelse)
            VISIT_SEQ(st, stmt, s->v.If.orelse);
        break;
#if IF_INV
    case Unless_kind:
        VISIT(st, expr, s->v.Unless.test);
    VISIT_SEQ(st, stmt,s->v.Unless.body);
    if (s->v.Unless.orelse)
        VISIT_SEQ(st, stmt, s->v.Unless.body);
    break;
#endif

コンパイラを改造

ast.csymtable.cを書き換えたことでunless文が正しく分解されるようになりました。今度は分解された文に沿って正しいオペコードを生成するようにPython/comlipe.cを書き換えます。 これも真偽値を反転させること以外はif文の実装部分と同じなので真似して書きます。

#if IF_INV
static int
compiler_unless(struct compiler *c, stmt_ty s)
{
    :
    :
    constant = expr_constant(c, s->v.Unless.test);
    /* constant = 0: "if 0"
     * constant = 1: "if 1", "if 2", ...
     * constant = -1: rest */
    if (constant == 1) {//0と1を入れ替え
        if (s->v.Unless.orelse)
            VISIT_SEQ(c, stmt, s->v.Unless.orelse);
    } else if (constant == 0) {//0と1を入れ替え
        VISIT_SEQ(c, stmt, s->v.Unless.body);
    } else {
        if (asdl_seq_LEN(s->v.Unless.orelse)) {
            next = compiler_new_block(c);
            if (next == NULL)
                return 0;
        }
        else
            next = end;
        VISIT(c, expr, s->v.Unless.test);
        //生成するオペコードの真偽を入れ替え
        ADDOP_JABS(c, POP_JUMP_IF_TRUE, next);
        VISIT_SEQ(c, stmt, s->v.Unless.body);
        if (asdl_seq_LEN(s->v.Unless.orelse)) {
            ADDOP_JREL(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;
}
#endif

実際に使ってみる

以上でunless文が実装できた。早速ビルドしてテストしてみます。

$ CFLAGS="-O0 -g -DIF_INV=1" ./configure --prefix='/home/pf-siedler/mypython/'
#コンパイルオプションに -DIF_INV=1 を追加

$ make -j8

$ make install

実際にunless文を実行してみましょう。

>>> unless False:
...     print("OK")
... 
OK
>>> unless True:
...     print("NO")
... elunless False:
...     print("YES")
... else:
...     print("NO'")
... 
YES
>>> unless True:
...     print("NO")
... else:
...     print("DONE!")
... 
DONE!
>>> 

ちゃんと条件式が偽のときに内部の命令が実行されました!

次にdisassembleモジュールでunless文のオペコードを見てみましょう。

>>> import dis
>>> def f(a):
...     unless a:
...         print("OK")
...
>>> dis.dis(f)
  2           0 LOAD_FAST                0 (a)
              3 POP_JUMP_IF_TRUE        16

  3           6 LOAD_GLOBAL              0 (print)
              9 LOAD_CONST               1 ('OK')
             12 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             15 POP_TOP
        >>   16 LOAD_CONST               0 (None)
             19 RETURN_VALUE
>>>

3行目POP_JUMP_IF_TRUEは直前にロードした変数がTrueなら16行目に移動(つまりunless文から抜ける)、 Falseなら次の行(unless文の中身)を実行という処理で、正しくオペコードが生成されていることがわかります。

まとめ

CPythonはGrammarPython.asdlで文法を規定し、ast.csymtable.cに沿って構文木を作成、構文木に沿ってcompile.cがオペコードを生成していることがわかりました。

既存の構文に似た予約語の追加は、既存コードを少し改造するだけで実装できるので簡単に行なえます。

より複雑な構文を実装する場合、正しくオペコードを吐き出すようにcompile.cの改造に工夫を凝らす必要がありそうです。