人間でもわかるPythonバイトコード(1) ~ バイトコード命令について ~


皆さんの中には、Pythonを実行したとき__pycache__というディレクトリができることを知っている方がいるかもしれません。
これはPythonスクリプトをバイトコードコンパイルしてできた.pycファイルをキャッシュしておくためのフォルダです。Pythonは直接的にはバイナリを読み取りながら実行しているのです。.pycファイルはVSCodeのhex editorで中を見てみるとこんな感じになっています。

Decoded textで解読可能なアルファベット以外は意味不明ですが、これから解説していくので恐れることはないです。この記事(シリーズ)を見た後のあなたはPythonバイトコードを完全に理解し、スクリプトのデバッギングに活用すらしているかもしれません。

動作確認済み環境

OS: Windows 10, Ubuntu 18.04
Python: CPython 3.9.0

逆アセンブラを使う

Pythonにはdisという標準モジュールがあります。これはdisassemblerの略で、Pythonバイトコードを逆アセンブルして人間にも理解できる形にしてくれます。タイトルで人間でもわかるとか書いておいて早速ツール任せですが今は気にせず使っていきましょう。まずはシンプルにHello worldをコンパイルして逆アセンブルです。

print("Hello, world!")

逆アセンブル結果だけほしいので、ターミナルでpython -m dis <filename>.pyと打ってください。-mオプションは続く引数で指定したモジュールを__main__モジュールとして実行できます。そして結果がこちら。

  1           0 LOAD_NAME                0 (print)
              2 LOAD_CONST               0 ('Hello, world!')
              4 CALL_FUNCTION            1
              6 POP_TOP
              8 LOAD_CONST               1 (None)
             10 RETURN_VALUE

     ↑行番号        ↑バイトオフセット、命令        ↑引数

次の章で一体何が起こっているのか解説しましょう。

inside Python

Pythonは実際の機械語のように命令(1バイト)+引数1つ(1バイト)を1ペアとして読み取り順番に実行します。これは固定長なので、1バイトの整数=0~255までなら引数として指定できますが、文字列などを直接指定するわけにはいきません。どうやってオブジェクトを取り扱っているのでしょうか。

先ほどのコードの解説に戻りましょう。まずはじめにLOAD_NAME 0という命令があります。
Pythonインタープリタは一時計算結果をスタックで保持するスタックマシンです。一時的でないデータはテーブル(PythonでいうところのList/Tupleです。ていうか実際にListとTupleを使って実装されてます)を使って名前を保持します。その実体はPythonインタープリタがDictとして持っています。
引数に指定されている0は名前テーブル内でのインデックスです。Pythonはコードの実行単位としてコードオブジェクトなるものを使用しており、そのオブジェクト内には変数の名前や定数オブジェクトなどのテーブルが存在しています。これらのテーブルへインデックスごしにアクセスしているので、命令が固定長になるというわけです。
以下はCPythonでのコードオブジェクトの実装の一部分です。

cpython/Include/cpython/code.h
struct PyCodeObject {
    PyObject_HEAD
    int co_argcount;            /* #arguments, except *args */
    int co_posonlyargcount;     /* #positional only arguments */
    int co_kwonlyargcount;      /* #keyword only arguments */
    int co_nlocals;             /* #local variables */
    int co_stacksize;           /* #entries needed for evaluation stack */
    int co_flags;               /* CO_..., see below */
    int co_firstlineno;         /* first source line number */
    PyObject *co_code;          /* instruction opcodes */
    PyObject *co_consts;        /* list (constants used) */
    PyObject *co_names;         /* list of strings (names used) */
    PyObject *co_varnames;      /* tuple of strings (local variable names) */
    PyObject *co_freevars;      /* tuple of strings (free variable names) */
    PyObject *co_cellvars;      /* tuple of strings (cell variable names) */
...

};

co_namesに変数の名前が格納されていて、LOAD_NAME 0ではその0番目の要素をとってきてスタックに載せるというわけです。disではご丁寧に0番目の要素が"print"という文字列であることまで表示してくれています。そしてco_constsには定数(リテラル)が格納されていて、LOAD_CONST 0でその0番目の要素をとってきてまたスタックに載せるというわけですね。
で、CALL_FUNCTIONでいよいよprint関数を呼びます。引数の1は関数がとる引数の数を指定しています。この命令はスタックの要素をn+1個消費して戻り値1個をスタックに返します。n個は引数、後の1個は関数の名前です。

次に終了処理です。スタックに残ったオブジェクトを取り除きます。
print関数はNoneを返すので、POP_TOPでスタックトップからpopします(ちなみにこの命令は引数がいりませんが、引数は0としてあり合計2バイトであることに変わりはありません)。
それからPythonはスタックにNoneを載せてreturnし実行を終了するので、LOAD_CONSTでNoneを載せてRETURN_VALUEしています。結果としてNoneを取り除いてまたNoneを載せるという無駄な手間をかけていますが、最後に実行される関数がprintみたくNoneを返すとは限らないのでこのようになっているようです。

他にもやってみる

次にこのようなコードを逆アセンブルしてみました。

from typing import Final

i: Final[int] = 1

FinalはPython3.8で追加された、変数(var)を定数(let)化する修飾子です。i = 2などをこの後の行に追加するとエラーを吐きます。吐かないようです。意味ねえ。
結果は以下のようになりました。

  1           0 SETUP_ANNOTATIONS
              2 LOAD_CONST               0 (0)
              4 LOAD_CONST               1 (('Final',))
              6 IMPORT_NAME              0 (typing)
              8 IMPORT_FROM              1 (Final)
             10 STORE_NAME               1 (Final)
             12 POP_TOP

  3          14 LOAD_CONST               2 (1)
             16 STORE_NAME               2 (i)
             18 LOAD_NAME                1 (Final)
             20 LOAD_NAME                3 (int)
             22 BINARY_SUBSCR
             24 LOAD_NAME                4 (__annotations__)
             26 LOAD_CONST               3 ('i')
             28 STORE_SUBSCR

1行目のモジュールのimportは面倒になるので次回以降解説します(予定)。
注目すべきは3行目です。STORE_NAMEは大体想像がつくと思いますがオブジェクトをco_namesの2番目の変数i(実体はPythonインタープリタのDictにある)に格納STOREします。LOAD_NAMEは逆に変数をロードするわけです。
そのあとのBINARY_SUBSCRは少しトリッキーです。実はこの命令、スタックトップのオブジェクトをキーにしてスタックの二番目にあるオブジェクトにアクセスし、その結果をスタックトップに置く命令なのです。つまり、
TOS = TOS1[TOS]
ということです。TOSはTop Of Stackの略です。
なのでFinal[int]という型指定は、実行上はFinalという名前のDict(-like object)にintという型オブジェクトをキーにしてアクセスすることと変わらないということになります。
そしてSTORE_SUBSCRTOS1[TOS] = TOS2を実行します。つまり、__annotations__['i'] = Final[int]ですね。__annotations__というのは特殊なグローバル変数で、これによってPythonは後付けの静的型付け機能を実現しているようです。実行上は意味ねえ飾りですが。

おわりに

今回は以上です。ここまで読んでいただきありがとうございました。
次回は.pycファイルのフォーマットについて解説する予定です。