pythonのclassのメソッドの引数の省略を試みる

はじめに

この記事は,、東京大学工学部電子情報工学科・電気電子工学科(通称EEIC)の3年後期実験の1つである「大規模ソフトウェアを手探る」のレポートとして書かれたものです。当実験は任意のOSSを1つ選んでその改造を試みるというものであり、私たちの班「cuatro」はpythonを改造するOSSに選びました。

概要

私達の班「cuatro」では、以下の3点の改変を行いました。

1つめと2つめについても班のメンバーが記事を書いています。興味のある方は合わせてご覧ください。なお改変したコードについては、本実験のgitlabリポジトリにあります。本記事の改変コードはディレクトpython self omitにあります。

環境

Ubuntu 18.04 LTS

準備

cpythonを以下のgithubリポジトリよりcloneします。ターミナルから以下のgitコマンドを打ちます。

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

今回はC言語で書かれたpythonの実装のコード(cpython)をダウンロードし、これを改変するという方針をとります。

pythonを実行する際は以下のコマンドによりビルドを行います。

$ CFLAGS="-O0 -g" ./configure --prefix="インストール先のパス"
$ make
$ make install

classのメソッドとは?

pythonではclassを定義することによって、変数や関数をひとまとめにしたオブジェクトをつくることができます。classの定義はそのオブジェクトの設計図のようなもので、class定義内の関数をメソッドと呼びます。このメソッドの第一引数をselfとするのが慣例になっており、この引数selfがメソッドが呼び出されるオブジェクト自身となります。例えば以下のようにクラスを定義することができます。

class Testclass:
    x = 'test'
    def method1(self):
        print(self.x)
    def method2(self, y):
        print(self.x + y)

このクラス定義を用いてオブジェクトをつくり、メソッドを実行すると以下のようになります。

obj = Testclass()
obj.method1()
obj.method2('desuyo')

実行結果

test
testdesuyo

上記の例を見るとわかるようにメソッドを定義するたびに第一引数selfを入力する必要があり、やや面倒です。今回試みた改良はこの第一引数selfを書いていなくても、selfがもともとあるように暗黙に仮定して動くようにしようというものです。

手探る

失敗した

最終的には当該の機能を実装するまでには至りませんでした。しかしここまでの過程でわかったことも多かったのでそれを以下に記します。

まずどうするか?~python.gram~

pythonでは入力した文字列を以下の流れに従ってバイトコードに変換し、実行しています。この流れの過程をcpython内のファイルがそれぞれ担っており、そのいずれかの中身を改変する必要があるわけです。なおASTやCFGについては「do-while文を実装してみた」に説明を譲りたいと思います。

f:id:rachmanix:20211103185250p:plain
図1:入力から出力への流れ

私達がまず目をつけたのがGrammar/python.gramというファイルです。このファイルはAST木をつくる前のpythonの文法を定義している部分であり、EBNF記法と呼ばれるメタ文法記法で書かれています。この中でclassについての文法を定義している部分を見つけて、そこの引数部分に改変を加えれば実装できるのでは?という考えでした。実際python.gramの中にclassについて定義している部分が見つかり、以下のようになっていました。

# Class definitions
# -----------------

class_def[stmt_ty]:
    | a=decorators b=class_def_raw { _PyPegen_class_def_decorators(p, a, b) }
    | class_def_raw

class_def_raw[stmt_ty]:
    | invalid_class_def_raw
    | 'class' a=NAME b=['(' z=[arguments] ')' { z }] &&':' c=block {
        _PyAST_ClassDef(a->v.Name.id,
                     (b) ? ((expr_ty) b)->v.Call.args : NULL,
                     (b) ? ((expr_ty) b)->v.Call.keywords : NULL,
                     c, NULL, EXTRA) }

class_def_rawが本質的にclassを定義している部分だと推測され、a=NAMEがクラス名、b=['('z=[arguments]')'{z}&&';'がクラスの継承の際に使われるクラスの引数部分だとおおよそ見当がつきます。では肝心のメソッド部分がどこなのか?ということになるのですが、この中にメソッドを定義していそうな部分が見当たらず、_PyAST_ClassDef関数でASTを処理している層に情報を渡していそうであることを考えると、消去法的にc=blockの部分にメソッドも含めたクラス内の要素すべてが集約していると考えざるを得なくなります。したがってこの部分には改変できる箇所がないことが判明します。

ここで方針を変更します。というのも目的はあくまでメソッドの第一引数が存在するように見せかけることであり、関数の引数という機能には特に変わりはないので文法の変更にこだわる必要がなかったわけです。

AST→CFGで掴む~symtable.c~

メソッドの引数の情報はバイトコードになる前に必ずどこかで配列のようにまとめて管理されているはずであり、その可能性が高いのがコンパイラに情報を渡す直前である「ASTからCFGをつくる部分」だと考え、この部分の変更を試みました。実際にこの役割を担っているファイルはsymtable.c(図1を参照)というものでこの中身を変えることを考えます。python developer's guide(AST to CFG to Bytecodeの項を参照)を読むと「symtable_visit_xxという関数が呼び出されてAST木が歩かれている」と書かれており、当該の関数を探してみるとその一つであるsymtable_visit_stmtという関数の中にクラス定義に関わる記述が見つかりました。

static int
symtable_visit_stmt(struct symtable *st, stmt_ty s)
{
    if (++st->recursion_depth > st->recursion_limit) {
        PyErr_SetString(PyExc_RecursionError,
                        "maximum recursion depth exceeded durisymng compilation");
        VISIT_QUIT(st, 0);
    }
    switch (s->kind) {
    case FunctionDef_kind:
        if (!symtable_add_def(st, s->v.FunctionDef.name, DEF_LOCAL))
            VISIT_QUIT(st, 0);
        if (s->v.FunctionDef.args->defaults)
            VISIT_SEQ(st, expr, s->v.FunctionDef.args->defaults);
        if (s->v.FunctionDef.args->kw_defaults)
            VISIT_SEQ_WITH_NULL(st, expr, s->v.FunctionDef.args->kw_defaults);
        if (!symtable_visit_annotations(st, s, s->v.FunctionDef.args,
                                        s->v.FunctionDef.returns))
            VISIT_QUIT(st, 0);
        if (s->v.FunctionDef.decorator_list)
            VISIT_SEQ(st, expr, s->v.FunctionDef.decorator_list);
        if (!symtable_enter_block(st, s->v.FunctionDef.name,
                                  FunctionBlock, (void *)s,
                                  s->lineno, s->col_offset,
                                  s->end_lineno, s->end_col_offset))
            VISIT_QUIT(st, 0);
        VISIT(st, arguments, s->v.FunctionDef.args);
        VISIT_SEQ(st, stmt, s->v.FunctionDef.body);
        if (!symtable_exit_block(st))
            VISIT_QUIT(st, 0);
        break;
    case ClassDef_kind: {
        PyObject *tmp;
        if (!symtable_add_def(st, s->v.ClassDef.name, DEF_LOCAL))
            VISIT_QUIT(st, 0);
        VISIT_SEQ(st, expr, s->v.ClassDef.bases);
        VISIT_SEQ(st, keyword, s->v.ClassDef.keywords);
        if (s->v.ClassDef.decorator_list)
            VISIT_SEQ(st, expr, s->v.ClassDef.decorator_list);
        if (!symtable_enter_block(st, s->v.ClassDef.name, ClassBlock,
                                  (void *)s, s->lineno, s->col_offset,
                                  s->end_lineno, s->end_col_offset))
            VISIT_QUIT(st, 0);
        tmp = st->st_private;
        st->st_private = s->v.ClassDef.name;
        VISIT_SEQ(st, stmt, s->v.ClassDef.body);
        st->st_private = tmp;
        if (!symtable_exit_block(st))
            VISIT_QUIT(st, 0);
        break;
    }

case ClassDef_kindの中身を見ると、引数に関する処理がされていそうな部分は見当たりません。一方でその上にはcase FunctionDef_kindという関数の定義についての処理もあり、その中のVISIT(st, arguments, s->v.FunctionDef.args);で引数の処理を行っていそうです。両者の中身には共通点もあり、どちらも自身のブロック(FunctionBlock,ClassBlock)を引数にもつsymtable_enter_block関数の処理とsymtable_exit_block関数の処理を行っています。

symtable_enter_block関数の中を見るとste_new関数にブロックを渡して、その返り値をPyList_Append関数にst->st_stackというスタックとともに渡して処理しています。

static int
symtable_enter_block(struct symtable *st, identifier name, _Py_block_ty block,
                     void *ast, int lineno, int col_offset,
                     int end_lineno, int end_col_offset)
{
    PySTEntryObject *prev = NULL, *ste;

    ste = ste_new(st, name, block, ast, lineno, col_offset, end_lineno, end_col_offset);
    if (ste == NULL)
        return 0;
    if (PyList_Append(st->st_stack, (PyObject *)ste) < 0) {
        Py_DECREF(ste);
        return 0;
    }
    prev = st->st_cur;
    /* bpo-37757: For now, disallow *all* assignment expressions in the
     * outermost iterator expression of a comprehension, even those inside
     * a nested comprehension or a lambda expression.
     */
    if (prev) {
        ste->ste_comp_iter_expr = prev->ste_comp_iter_expr;
    }
    /* The entry is owned by the stack. Borrow it for st_cur. */
    Py_DECREF(ste);
    st->st_cur = ste;

    /* Annotation blocks shouldn't have any affect on the symbol table since in
     * the compilation stage, they will all be transformed to strings. They are
     * only created if future 'annotations' feature is activated. */
    if (block == AnnotationBlock) {
        return 1;
    }

    if (block == ModuleBlock)
        st->st_global = st->st_cur->ste_symbols;

    if (prev) {
        if (PyList_Append(prev->ste_children, (PyObject *)ste) < 0) {
            return 0;
        }
    }
    return 1;
}

以上の事実から以下のことがわかります(推測されます)。

  • 入力された文のブロックの識別をスタックを利用することで判定している。
  • クラス内のメソッドはクラス定義ではなく関数定義の部分で処理されている。
  • スタックにおいてClassBlockの上にFunctionBlockが積まれているときに、関数定義をクラス内のメソッドと認識している。
  • メソッドの引数の処理に関わっているのはVISITというマクロ。

VISITマクロの実態はsymtable_visit_argumentsという名前の関数であり、ここが目標としていた変更部分なのでは!ということになりました。

終点~symtable_visit_arguments~

この関数は以下のようになっており、引数の種類に応じてその都度symtable_visit_paramsという関数が呼び出されています。

static int
symtable_visit_arguments(struct symtable *st, arguments_ty a)
{
    /* skip default arguments inside function block
       XXX should ast be different?
    */
    if (a->posonlyargs && !symtable_visit_params(st, a->posonlyargs))
        return 0;
    if (a->args && !symtable_visit_params(st, a->args))
        return 0;
    if (a->kwonlyargs && !symtable_visit_params(st, a->kwonlyargs))
        return 0;
    if (a->vararg) {
        if (!symtable_add_def(st, a->vararg->arg, DEF_PARAM))
            return 0;
        st->st_cur->ste_varargs = 1;
    }
    if (a->kwarg) {
        if (!symtable_add_def(st, a->kwarg->arg, DEF_PARAM))
            return 0;
        st->st_cur->ste_varkeywords = 1;
    }
    return 1;
}

そのsymtable_visit_params関数は以下のようになっています。

static int
symtable_visit_params(struct symtable *st, asdl_arg_seq *args)
{
    int i;

    if (!args)
        return -1;

    for (i = 0; i < asdl_seq_LEN(args); i++) {
        arg_ty arg = (arg_ty)asdl_seq_GET(args, i);
        if (!symtable_add_def(st, arg->arg, DEF_PARAM))
            return 0;
    }

    return 1;
}

for文がある時点で”いかにも”という感じですね。ここでasdl_seq_GET(args, i)マクロの実態は(args)->typed_elements[(i)]であり、typed_elementsという配列のi番目を参照しています。ということはこのtyped_elements配列がメソッド(関数)の引数をまとめて管理している実態であり、この先頭にselfのような変数を予め入れておけば該当の機能を実装できるはず!ということになったのですが...

問題はこのtyped_elementsという配列にどのようにselfを追加すれば良いのかという点です。というのもtyped_elementsの要素の型はarg_tyという独自に定義されたもので、その定義を追っていくも実態がつかめず、ここで行き詰まるという結果になってしまいました。この型の実態を掴むためにはASTが作られる部分を探る必要があったわけですが、AST関連のファイルの中身もまた複雑で、時間をかければ理解できる感じでもないのがボトルネックとなりました。一応このfor文のループ回数を1回減らして、挙動を掴もうとしましたが、そうするとビルドが上手く行かず、手がかりを得られませんでした。

得られた教訓

  • pythonの中身は型の定義が何度も繰り返されていたり、マクロが多く使われている点で思っていたより理解しづらい。
  • gdbでmainから順に処理を追っていくのは時間的にも精神的にもきつい。
  • 言語処理系の改変で、その言語の根本部分に関わる部分をテーマに選ぶとかなり大変になる可能性が高い。(メッセージの変更など表層的な部分の改変のほうがよっぽど楽)
  • まず全体の処理を大きくみて、機能の実装に必要な変更がどの部分であるかを最初に明確にするのが肝心だと思った。今回は最初にそこを曖昧にしたままミクロな部分をとりあえず追っていたので、時間が余分にかかってしまった。

感想

先例がないテーマだったので過去の先輩の記事で直接的に参考にできる部分が少なく、初動で非常に苦労しました。ただその中でTAの方に何度も有用なアドバイスを頂き、最終的に実装にはたどり着かなくても、その前まではなんとか進んでいくことができたことは一定の達成感につながりました。この場を借りて御礼申し上げます。