Pythonにプライベート変数を実装しようと試みた話。

  • 3
    いいね
  • 0
    コメント

※この記事はEEICの「大規模ソフトウェアを手探る」という実験の成果レポートを兼ねてます
※この記事は
http://qiita.com/STN_monologue/items/dad149a87f8a3027bd0c
の続きと成りますので、そちらをまだ読んでいない方はぜひもう片方の記事も読んでみてください!

前回のあらすじ

大規模ソフトウェアを手探るという講義の一環で、以前から興味を持っていたpythonを手探ることに。
python2系のprint文を復活させるという課題は達成できてしまったので、今回は新しく、pythonにprivate変数を導入する、という課題に取り組むことに。

背景

Pythonのクラスには、private変数が存在しません。その代わり、変数名の先頭に"_"をつけると、その変数はprivate変数扱いする、という慣習があります。プログラマーの良心に任されるというわけです。これは、Javaなどのオブジェクト指向言語とはかなり異なった思想です。その背景は、以下のstack overflowのポスト
http://stackoverflow.com/questions/1641219/does-python-have-private-variables-in-classes
を参考にしてください。
ただし、pythonの慣習を知らず、クラス外部から勝手に書き換えてはいけない変数を書き換えてしまう、そんな新米プログラマーもいるだろうと(勝手に)考え、今回はpythonの思想などお構いなく、図々しくもprivate変数の慣習をより厳密にな形で実装しようと考えました。

問題の定義

本来のprivate変数というのは、外部からアクセスが起きると、エラーを吐いて処理を停止する、という仕様になってます(多分)。ただし、やってみるとわかりますが、pythonの内部処理で、private変数に外部からアクセスする処理がいろいろとあるようなので、エラーで落ちるようにすると、pythonが勝手に落ちます(というかコンパイル通りません)。したがって、今回はprivate変数にアクセスするときにwarningを発する、という形で実装することにしました。それ、private変数を導入したことになるのか、というツッコミもあるかと思いますが、これは一応pythonの慣習を知らないユーザーというターゲットを手助けするためには十分だと思い、妥協しました。

手探り

まず、今回取り組む問題を細分化すると
1. クラスの変数へのアクセスのタイミングを捉える
2. 変数名の先頭に"_"が付いているかを確認する
3. 変数にアクセスしようとしている対象が、その変数をインスタンス変数として持つオブジェクトであるかを確認する
という3つのプロセスに分けられます。

手探るために、前回に引き続き基本的にgdbを中心として作業を進めました。

変数へのアクセスのタイミングを捉える

前回コンパイラをいじっていたことに引きずられて、最初はコンパイラを手探っていました。しかし、よく考えてみれば、この方法はうまく行くはずがありません。
なぜならば、pythonのクラスは所詮辞書でしかなく、コンパイルの時点では必ずしもなんのキー(メンバー変数)を持つかがわからないからです。したがって、コンパイルの時点ではインスタンスのメンバー変数の情報にはアクセスせず、メンバー変数が存在するかどうかも変数にアクセスして初めてわかります。
つまり、本当に手探るべきは、コンパイルされたオペコードを元に、メンバー変数に実際にアクセスが発生したタイミングの処理です。したがって、コンパイラではなく、pythonのVirtual Machineを手探ることになります。
ちょっと調べてみると、ceval.cというファイルがオペコードを評価していることがわかりました。以下のサイトにその周辺の処理が詳しく書いてあります。
http://www.nasuinfo.or.jp/FreeSpace/kenji/sf/python/virtualMachine/PyVM.html

次に、どのオペコードを調べるべきかを見極めるために、pythonの便利な組み込みモジュールであるdisを利用しました。disは、渡されたpythonのコードをpython interpreter内部で利用しているオペコードに変換してくれるので、実際に以下のコードに対するオペコードを生成してみました。

変換前
class Foo:
    def __init__(self, foo):
        self._foo = foo
    def mymethod(self, foo):
        self._foo = foo

f = Foo("constructor")
f._foo = "external access"
f.mymethod("internal access")
変換後
  2           0 LOAD_BUILD_CLASS
              1 LOAD_CONST               1 (<code object Foo at 0x118a0bdb0,line 2>)
              4 LOAD_CONST               2 ('Foo')
              7 MAKE_FUNCTION            0
             10 LOAD_CONST               2 ('Foo')
             13 CALL_FUNCTION            2 (2 positional, 0 keyword pair)
             16 STORE_FAST               0 (Foo)

  8          19 LOAD_FAST                0 (Foo)
             22 LOAD_CONST               3 ('constructor')
             25 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             28 STORE_FAST               1 (f)

  9          31 LOAD_CONST               4 ('external access')
             34 LOAD_FAST                1 (f)
             37 STORE_ATTR               0 (_foo)

 10          40 LOAD_FAST                1 (f)
             43 LOAD_ATTR                1 (mymethod)
             46 LOAD_CONST               5 ('internal access')
             49 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             52 POP_TOP
             53 LOAD_CONST               0 (None)
             56 RETURN_VALUE

見た感じ、STORE_ATTRというオペコードが怪しいということがわかりました。
これでひとまず材料が出揃いました。ここから、gdbを活用しながらceval.cを手探っていきます。

変数へのアクセスを探る

まず、愚直にceval.c内のSTORE_ATTRという文字列を検索してみると、以下の部分にヒットしました。

TARGET(STORE_ATTR) {
       PyObject *name = GETITEM(names, oparg);
       PyObject *owner = TOP();
       PyObject *v = SECOND();
       int err;
       STACKADJ(-2);
       err = PyObject_SetAttr(owner, name, v);
       Py_DECREF(v);
       Py_DECREF(owner);
       if (err != 0)
           goto error;
       DISPATCH();
}

ここでSTORE_ATTRのオペコードを処理しているようです。そして、このコードを眺めることで幾つかわかることがありました。
まず、nameというのがアクセスしたい変数の名前で、ownerがセットするべきインスタンスであることが、変数名から推測されます。そして実際に変数をセットしているのは、PyObject_SetAttrという関数のようです。PyObject_SetAttr関数の引数は、name, owner, vなので、vはおそらくセットしたい値(value)であることが推測されます。
まず、これらの仮説を確認するために、とりあえずここにbreakpointを設置し、以下のコードtest.pyをgdbで実行してみました。

test.py
class Foo:
    def __init__(self, foo):
        self._foo = foo
    def mymethod(self, foo):
        self._foo = foo

f = Foo("constructor")
f._foo = "external access"
f.mymethod("internal access")

しかし、実際にやってみると、pythonはモジュールの読み込みなどでもSTORE_ATTRを多用しているようで、これだとあまりにも多くの処理が引っかかってしまい、作業が進みません。"_foo"というattributeが呼び出された時に限って止まるようにしないと、手がつけられません。問題は、nameはPyObject型、すなわちPythonのオブジェクト(今回はunicode型)なので、単純に変数名にアクセスできません。
そこで、unicodeobject.h、unicodeobject.cに記述してある、pythonのunicode型の実装を覗いて、Cのstring(char配列)に変換できないかを探ってみました。
すると、unicodeobject.h内に、いかにもそれっぽい関数がありました。


#ifndef Py_LIMITED_API
PyAPI_FUNC(char *) PyUnicode_AsUTF8(PyObject *unicode);
#define _PyUnicode_AsString PyUnicode_AsUTF8
#endif

実際、試しにこの関数をnameオブジェクトに適用すると、nameが指す文字列を、char* 型として取得することが出来ました。これを用いて、nameが"_foo"である時のみにgdbが止まるように、nameと"_foo"が一致するという条件で成立するif文の内部にbreakpointを設置し、gdbを走らせてみたところ、うまくいきました。ここで、nameが実際にアクセスしようとしている変数の名前だろう、という仮説が確かに立証できました。

次に、PyObject_SetAttr関数の処理を見ていき、実際にownerが値をセットしようとしているインスタンス、vがセットしようとしている値であるという仮説を検証しました。
しばらく潜っていくと、ownerを第一引数として、以下の関数が呼び出されていることがわかりました。


int
_PyObject_GenericSetAttrWithDict(PyObject *obj, PyObject *name,
                                 PyObject *value, PyObject *dict)
{
    PyTypeObject *tp = Py_TYPE(obj);
    PyObject *descr;
    descrsetfunc f;
    PyObject **dictptr;
    int res = -1;

    if (!PyUnicode_Check(name)){
        PyErr_Format(PyExc_TypeError,
                     "attribute name must be string, not '%.200s'",
                     name->ob_type->tp_name);
        return -1;
    }

    if (tp->tp_dict == NULL && PyType_Ready(tp) < 0)
        return -1;

    Py_INCREF(name);

    descr = _PyType_Lookup(tp, name);
    Py_XINCREF(descr);

    f = NULL;
    if (descr != NULL) {
        f = descr->ob_type->tp_descr_set;
        if (f != NULL && PyDescr_IsData(descr)) {
            res = f(descr, obj, value);
            goto done;
        }
    }

    if (dict == NULL) {
        dictptr = _PyObject_GetDictPtr(obj);
        if (dictptr != NULL) {
            res = _PyObjectDict_SetItem(Py_TYPE(obj), dictptr, name, value);
            if (res < 0 && PyErr_ExceptionMatches(PyExc_KeyError))
                PyErr_SetObject(PyExc_AttributeError, name);
            goto done;
        }
    }
    if (dict != NULL) {
        Py_INCREF(dict);
        if (value == NULL)
            res = PyDict_DelItem(dict, name);
        else
            res = PyDict_SetItem(dict, name, value);
        Py_DECREF(dict);
        if (res < 0 && PyErr_ExceptionMatches(PyExc_KeyError))
            PyErr_SetObject(PyExc_AttributeError, name);
        goto done;
    }

    if (f != NULL) {
        res = f(descr, obj, value);
        goto done;
    }

    if (descr == NULL) {
        PyErr_Format(PyExc_AttributeError,
                     "'%.100s' object has no attribute '%U'",
                     tp->tp_name, name);
        goto done;
    }

    PyErr_Format(PyExc_AttributeError,
                 "'%.50s' object attribute '%U' is read-only",
                 tp->tp_name, name);
  done:
    Py_XDECREF(descr);
    Py_DECREF(name);
    return res;
}

重要なのは、ここで第一引数(obj)の辞書を取得していることです。pythonのクラスインスタンスは基本的に辞書なので、これはobjそのものにアクセスしていることを意味します。つまり、ownerが実際にアクセス対象のインスタンスであることが確認できました。そして、PyDict_SetItemで辞書にvを、nameというkeyでセットしていることがわかるので、vがセットするべき値であることも確認できました。

したがって、ここからやるべきは、呼び出し元のインスタンスの情報を探し出し、それをownerと照合する(pointerを比較する)ことです。というわけで、呼び出し元のインスタンスの情報を探し始めました。

インスタンスの情報を探す

Pythonのメソッドは、第一引数に必ず、勝手にインスタンス自身がセットされます。つまり、メソッドからインスタンスの情報は逆算できるはずです。メソッドはどのように自分が所属するインスタンスを知るのかを調べるために、公式のドキュメントを参照しました。
https://docs.python.org/3/c-api/method.html
どうやら、PyMethod_GET_SELFという関数で、自分が所属するインスタンスを取得しているようです。
そしてもう一つわかったこととして、メソッドは基本的に実行する関数と自身の所属するインスタンスの情報を持つ構造体に過ぎないということです。これはのちに重要になってきます。
この情報を元に、改めてgdbでtest.pyを実行して、インスタンス情報を探りました。
結論から言えば、メソッドを呼び出しているインスタンスの情報はどんなにスタックを下っていっても見つかりませんでした。
そしてスタックトレースを登ると、その理由はわかりました。

classobject.c
static PyObject *
method_call(PyObject *func, PyObject *arg, PyObject *kw)
{
    PyObject *self = PyMethod_GET_SELF(func);
    PyObject *result;

    func = PyMethod_GET_FUNCTION(func);
    if (self == NULL) {
        PyErr_BadInternalCall();
        return NULL;
    }
    else {
        Py_ssize_t argcount = PyTuple_Size(arg);
        PyObject *newarg = PyTuple_New(argcount + 1);
        int i;
        if (newarg == NULL)
            return NULL;
        Py_INCREF(self);
        PyTuple_SET_ITEM(newarg, 0, self);
        for (i = 0; i < argcount; i++) {
            PyObject *v = PyTuple_GET_ITEM(arg, i);
            Py_XINCREF(v);
            PyTuple_SET_ITEM(newarg, i+1, v);
        }
        arg = newarg;
    }
    result = PyObject_Call((PyObject *)func, arg, kw);
    if(PyDict_Contains(funcdict, PyUnicode_FromString("__parent_instance__"))){
        PyDict_DelItem(funcdict, PyUnicode_FromString("__parent_instance__"));
    }
    Py_DECREF(arg);
    return result;
}

メソッドの呼び出しが、上の関数で実行されているらしいことがわかります。そして、この処理をよく見ると、メソッドの呼び出しは大まかに見て、以下の手順で行われていることがわかります:
1. メソッドは、関数とインスタンスの情報の組み合わせに過ぎないので、関数とインスタンスを分離する。
2. 関数に渡す引数を、タプルとして初期化する
3. そのタプルの第一引数に、インスタンスをセットする。
4. 関数の実行

よく考えてみましょう。インスタンスはただの関数の引数です。これは、メソッドがmethod_callによって実行された時点で、メソッドの所属するインスタンスの情報はもう失われているということを意味します。そして、STORE_ATTRの処理が実行されるのは、method_callが呼ばれた後です。つまり、このままだと、インスタンスのメンバー変数にアクセするタイミングで、以下のような外部の関数によるアクセスと、内部のメソッドによるアクセスの区別がつかないということです。

class Foo:
    def inner_method(self, foo):
        self._foo = foo

def outer_function(hoge, foo):
    hoge._foo = foo

f = Foo()

f.inner_method(100)

outer_function(f, 100)

これでは困ります。どうにかして、STORE_ATTRを実行する処理に、呼び出し元インスタンスの情報を渡してやる必要があります。こうなったら、無理やり自分で渡すしかありません。

インスタンス情報の受け渡し

今回自分がとった方法は、method_callの時点でグローバル変数に無理やりインスタンスを"__parent_instance__"という変数名で登録する、という方法です。本当は新たなスコープでも定義してやるべきなのでしょうが、さすがにそれは面倒なので、今回はこんな強引な方法で突破することにしました。
グローバル変数は、関数のプロパティの1つで、pythonの辞書として実装されています。辞書型の実装を、dictobject.h、dictobject.cで確認しつつ、以下のように、グローバル変数に登録しました。

修正後のclassobject.c
static PyObject *
method_call(PyObject *func, PyObject *arg, PyObject *kw)
{
    PyObject *self = PyMethod_GET_SELF(func);
    PyObject *result;
    #if PRIVATE_ATTRIBUTE
    PyFunctionObject *temp;
    PyObject *funcdict;
    #endif

    func = PyMethod_GET_FUNCTION(func);
    if (self == NULL) {
        PyErr_BadInternalCall();
        return NULL;
    }
    else {
        Py_ssize_t argcount = PyTuple_Size(arg);
        PyObject *newarg = PyTuple_New(argcount + 1);
        int i;
        if (newarg == NULL)
            return NULL;
        Py_INCREF(self);
        PyTuple_SET_ITEM(newarg, 0, self);
        for (i = 0; i < argcount; i++) {
            PyObject *v = PyTuple_GET_ITEM(arg, i);
            Py_XINCREF(v);
            PyTuple_SET_ITEM(newarg, i+1, v);
        }
        arg = newarg;
    }
    #if PRIVATE_ATTRIBUTE
    temp = (PyFunctionObject *)func;
    funcdict = temp->func_globals;
    if(funcdict == NULL){
        funcdict = PyDict_New();
    }
    PyDict_SetItem(funcdict, PyUnicode_FromString("__parent_instance__"), self);
    #endif
    result = PyObject_Call((PyObject *)func, arg, kw);
    if(PyDict_Contains(funcdict, PyUnicode_FromString("__parent_instance__"))){
        PyDict_DelItem(funcdict, PyUnicode_FromString("__parent_instance__"));
    }
    Py_DECREF(arg);
    return result;
}

#if PRIVATE_ATTRIBUTE#endifで囲まれた部分が、追加したコードになります。関数から、グローバル変数が登録されている辞書を取得し、そこに無理やり変数を登録していることがわかるかと思います。
一つ注意が必要な点として、メソッドの実行が終わった時点で__parent_instance__をグローバル変数から削除しないと、一回内部のメソッドからプライベート変数がアクセスされたらそれ以降アクセスし放題になっていまいます。そのため、メソッドの実行が終わった時点で最後にPyDict_DelItemで__parent_instance__を削除しています。

次に、STORE_ATTRの実行時に以下のように処理を加え、修正しました。

修正後のceval.c
TARGET(STORE_ATTR) {
            PyObject *name = GETITEM(names, oparg);
            PyObject *owner = TOP();
            PyObject *v = SECOND();
            int err;
            #if PRIVATE_ATTRIBUTE
              char *name_as_cstr;
              PyObject *parent_instance;
              if(PyDict_Contains(f->f_globals, PyUnicode_FromString("__parent_instance__"))){
                parent_instance = PyDict_GetItem(f->f_globals, PyUnicode_FromString("__parent_instance__"));
              }else{
                parent_instance = NULL;
              }
            #endif
            STACKADJ(-2);

            #if PRIVATE_ATTRIBUTE
              name_as_cstr = _PyUnicode_AsString(name);
              if(name_as_cstr[0] == '_'){
                if(!parent_instance || (parent_instance - owner) != 0){
                  printf("Warning: Illegal access to a private attribute!\n");
                }
              }
            #endif

            err = PyObject_SetAttr(owner, name, v);
            Py_DECREF(v);
            Py_DECREF(owner);
            if (err != 0)
                goto error;
            DISPATCH();
        }

nameの先頭文字が_の場合、アクセス対象がprivate変数だと判断されます。そして、グローバル変数に__parent_instance__がある、かつownerと__parent_instance__が一致している場合に限り正当なアクセスとみなし、それ以外の場合はWarningをprintfで表示する、という単純な処理を書き加えました。
そして、configでPRIVATE_ATTRIBUTEを1にしてコンパイルしてみると...

>>> class Foo:
...     def __init__(self, foo):
...             self._foo = foo
...     def mymethod(self, foo):
...             self._foo = foo
...
>>>
>>>
>>> f = Foo(1)
>>> # no warnings!
>>>
>>> f._foo = 2
Warning: Attempting to access private attribute illegally

f=Foo(1)の行では、コンストラクタが呼び出されています。ここでwarningが出ず、外部からのアクセスが起きた時にwarningが発生するということは、一見うまく行っているように見えます。しかし、ここで問題が発生しました。

>>> f.mymethod(3)
Warning: Attempting to access private attribute illegally

なぜか、内部からのアクセスなのに怒られました。原因を調べるために、gdbでtest.pyを実行してみると、__parent_instance__がグローバル変数に登録されていません。どこかで処理がうまくいっていません。スタックトレースを遡ると、なんとf.mymethod(3)では、method_callが呼び出されていないことが判明。
その代わり、fast_functionという謎の関数が呼び出されていました。

fast_functionとの格闘

fast_functionとは何か、調べてみると以下のサイトにためになる説明が書いてありました。
http://eli.thegreenplace.net/2012/03/23/python-internals-how-callables-work

To understand what fast_function does, it's important to first consider what happens when a Python function is executed. Simply put, its code object is evaluated (with PyEval_EvalCodeEx itself). This code expects its arguments to be on the stack. Therefore, in most cases there's no point packing the arguments into containers and unpacking them again. With some care, they can just be left on the stack and a lot of precious CPU cycles can be spared.

どうやら、いちいち引数のタプルを作るのは無駄が多いので、pythonでは変数をスタックから取ってくることで高速化を図っているようです。それを担当しているのがfast_functionという関数ということです。つまり、method_callを介さないことで高速化しているということです。
しかし、それでもどこかのタイミングでmethodは呼ばれ、インスタンスが取得されているはずです。そのタイミングでグローバル変数に登録してやれば、今までの実装の延長線上で同じことが達成できるはずです。
というわけで、メソッドの所属するインスタンスにアクセスしている箇所、すなわちPyMethod_GET_SELFが呼び出されている箇所を検索してみたところ、ceval.cのcall_function関数の、以下の部分がヒットしました。

call_function関数内部
if (PyMethod_Check(func) && PyMethod_GET_SELF(func) != NULL) {
            /* optimize access to bound methods */
            PyObject *self = PyMethod_GET_SELF(func);
            PCALL(PCALL_METHOD);
            PCALL(PCALL_BOUND_METHOD);
            Py_INCREF(self);
            func = PyMethod_GET_FUNCTION(func);
            Py_INCREF(func);
            Py_SETREF(*pfunc, self);
            na++;
            n++;
}

というわけで、安易な発想で、以下のように書き換えてみました。

修正後のcall_function関数
if (PyMethod_Check(func) && PyMethod_GET_SELF(func) != NULL) {
            /* optimize access to bound methods */
            PyObject *self = PyMethod_GET_SELF(func);
            PCALL(PCALL_METHOD);
            PCALL(PCALL_BOUND_METHOD);
            Py_INCREF(self);
            func = PyMethod_GET_FUNCTION(func);
            Py_INCREF(func);
            Py_SETREF(*pfunc, self);
            na++;
            n++;
            #if PRIVATE_ATTRIBUTE
            temp = (PyFunctionObject *)func;
            funcdict = temp->func_globals;
            if(funcdict == NULL){
                funcdict = PyDict_New();
            }
            PyDict_SetItem(funcdict, PyUnicode_FromString("__parent_instance__"), self);
            #endif
}

なお、tempとfuncdictは、classobject.cの修正の時と型、意味含め同じです。関数の冒頭部に定義しないとコンパイラに怒られるので定義がこのコードの中にはないだけです。削除は、関数の末尾で以下のように行いました。


assert((x != NULL) ^ (PyErr_Occurred() != NULL));
    #if PRIVATE_ATTRIBUTE
    temp = (PyFunctionObject *)func;
    funcdict = temp->func_globals;
    if(funcdict == NULL){
        funcdict = PyDict_New();
    }
    if(PyDict_Contains(funcdict, PyUnicode_FromString("__parent_instance__"))){
        PyDict_DelItem(funcdict, PyUnicode_FromString("__parent_instance__"));
    }
    #endif
    return x;
}

これで試しにコンパイルして実行してみます。すると、Mac OS上では以下のように、うまくいきました。

>>> class Foo:
...     def __init__(self, foo):
...             self._foo = foo
...     def mymethod(self, foo):
...             self._foo = foo
...
>>> f = Foo(10)
>>> f._foo = 100
Warning: Illegal access to a private attribute!
>>> f.mymethod(200)
>>>
>>> def outerfunc(f, foo):
...     f._foo = foo
...
>>> outerfunc(f, 100)
Warning: Illegal access to a private attribute!

mymethodでエラーが吐かれていないことがわかります。意外とあっさり終わりました。
しかし、同じ修正を加えたソースを、Ubuntu上でコンパイルしようとすると、Global変数をいじるな、と怒られて、エラーでコンパイルが失敗してしまいました。
本来であれば、Ubuntuでも動くように修正するべきなんでしょうが、今回はそこまでの時間がなかったので、ここをとりあえずの妥協点としました。

学んだこと

この実験を通し、大規模なソフトウェアをいじる上で、大切な教訓を幾つか得たので、自分のためにも言語化してまとめておきます。
1.公式ドキュメントを頼るべき
今回、公式ドキュメントをちょっと読むだけで一気に理解が進む場面がなんどもありました。もちろん、公式ドキュメントがちゃんと整備されていなければそういうわけには行きませんが、可能な限り公式ドキュメントは早めに目を通しておくことが吉だと思います。当たり前の話ですが。
ただし、pythonに限らず、多くのソフトの内部処理に関するドキュメントは、単純に検索をかけるだけではなかなか見つからなかったりします。公式ドキュメントの見つけ方として、contributor, developerというキーワードを使って探すのが良いかと思います。
もし大規模なソフトを手探るのであれば、「ソフト名 contributor」「ソフト名 developer guide」などと検索をかけて、自分がcontributeしようとしている部分(文法ならgrammar、パフォーマンスならperformanceなど)に対するガイドを探してみることから始めると良いと思います。そこで目星をつけてからコードを追うのと、ただ単に追うのとでは全く効率が違います。

2.処理を言語化することの大切さ
自分がやりたいことを、明確に言語化することで、初めてやるべきことが明確になると自分は思います。例えば、プライベート変数実装の方針を、

「インスタンスの変数に、そのインスタンスの内部からしかアクセスできないようにする」

というのと、

「インスタンスの変数へのアクセスのタイミングで、変数名の先頭に"_"が付いているかを判定し、変数にアクセスしようとしている対象がその変数をインスタンス変数として持つオブジェクトであるかを確認する」

というのとでは、考えられる方針が全く違います。前者の説明だとだと、まずどこから手をつければいいのかが不明確です。それに対して後者の説明だと、まずは変数へのアクセスのタイミングで処理をかけるので、そのタイミングを探ろう、という思考に自然と発展します。
実際、最初にやるべきことを明確に言語化していなかったせいで、1日をコンパイラの探索に消費してしまいました。後から考えると、Virtual Machineをいじるべきだというのは当たり前ですが、このような当たり前のことは言語化して頭を整理しないと見逃しがちです。よく言われることですが、他者に説明する気持ちで、自分がやろうとしていることをわかりやすく、具体的に書き出してみることから作業を始めると良いと思います。

3.データ構造の把握の大切さ
参考にしたサイトには、以下の格言が載っていました

"The key to understanding a program is to understand its data structures. With that in hand, the algorithms usually become obvious."

このことを痛感する場面が多くありました。コンパイラ周りでは特にそうですが、(AST、DFAなどのデータ構造を理解できた瞬間、全体の流れが一気に見えてきました)、プライベート変数の実装でも、例えばメソッドが関数とインスタンスを含むデータ構造であることや、グローバル変数が辞書であることを知ることが、全体の処理の流れを理解する上でかなり重要でした。
この教訓からわかるより実践的な教訓は、headerファイル(.hファイル)を甘く見るべきでない、ということです。headerファイルでの構造体の定義を見るだけで、動いているコードをみるよりも多くのことがわかることもザラにあります。

参考にした、参考になるサイト

Pythonのdeveloper用公式ガイド
https://docs.python.org/devguide/

Pythonの内部処理についてわかりやすく、詳細に書いてある記事がたくさんあるブログ
http://eli.thegreenplace.net/tag/python-internals