はじめに
以前RustPythonの記事を書いてからOSSに興味を持ち
それ以降RustPythonにコントリビュートするようになりました。
主にドキュメント修正、リファクタリング等のプルリクを提出していました。
本記事にはissueに上がったバグを調査して修正するまでの過程をまとめてあります。
とあるバグ
ある日、そのissueは起票されました。
内容は「多重内包表記内でセイウチ演算子を使うとパニックが発生する」というものです。
例えば以下のコードを実行するとその事象が再現します。
def f():
[[x := i + j for j in range(5)] for i in range(5)]
ログは以下の通りです。
thread 'main' panicked at 'internal error: entered unreachable code: var x in a Function should be free or cell but it's Local', compiler/codegen/src/compile.rs:1215:22
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
ふーん、なるほどなるほど・・・全く分からん。
調査
これだけでは何が原因か特定できません。
まずはログから手掛かりになりそうなヒントを見つけます。
分かったのは以下のことです。
-
free
やcell
、local
とあるので変数のスコープ管理に関係がありそう
変数の管理はシンボルテーブルが行っています。
なので次にシンボルテーブル周りを調べることにしました。
シンボルテーブル
シンボルテーブルとは、変数名や関数名などの識別子(シンボル)のスコープや状態を管理するデータ構造です。
例えば以下のf関数のシンボルテーブルは3つのシンボルを持っています。
x = 2
def f():
y = 4
print(x + y)
f関数のシンボルテーブル
シンボル名 | スコープ |
---|---|
x | Global |
y | Local |
Global |
※実際はもっと様々な状態を管理していますが、ここでは簡略化しています
Globalとは最上位のスコープで定義されているシンボルに付与されるスコープ属性です。
Localとはそのスコープ内で定義されているシンボルに付与されるスコープ属性です。
また、シンボルテーブルは各スコープ毎に用意されています。
例えば以下のf関数内ではシンボルテーブルは2つあります。
def f():
print(add(3, 5))
def add(x, y):
return x + y
f関数のシンボルテーブル
シンボル名 | スコープ |
---|---|
Global | |
add | Local |
add関数のシンボルテーブル
シンボル名 | スコープ |
---|---|
x | Local |
y | Local |
f関数のシンボルテーブルがadd関数のシンボルテーブルを所持するというネスト構造になっています。
難航する調査
正常に動作しているCPythonのコードに手がかりがあると考えました。
そこでCPythonのソースコードを読んでシンボルテーブルの実装を確認することにしました。
全く読んだことないけど、C言語は昔勉強していたのでヨシ!
static int
analyze_name(PySTEntryObject *ste, PyObject *scopes, PyObject *name, long flags,
PyObject *bound, PyObject *local, PyObject *free,
PyObject *global)
{
if (flags & DEF_GLOBAL) {
if (flags & DEF_NONLOCAL) {
PyErr_Format(PyExc_SyntaxError,
"name '%U' is nonlocal and global",
name);
return error_at_directive(ste, name);
}
SET_SCOPE(scopes, name, GLOBAL_EXPLICIT);
if (PySet_Add(global, name) < 0)
return 0;
if (bound && (PySet_Discard(bound, name) < 0))
return 0;
return 1;
}
...
🤪❗❓
・・・
そこから先の調査はあらゆる試行錯誤の繰り返しでした。
- CPythonとRustPythonのコードの読み比べ
- CPythonのマクロの解析
- CPythonの解説ページを読む
- printデバッグで途中の値を確認
Cell
苦しい調査の結果、Cellが重要な要素であることが分かりました。
Cellとは複数のスコープから参照、代入される変数に付与されるスコープ属性です。
例えば以下のコードでは変数xにCellが付与されています。
def f():
x = 5 # inner関数内で参照されているのでCell
def inner():
print(x) # 外側のスコープの変数xを参照している
f関数のシンボルテーブル
シンボル名 | スコープ |
---|---|
x | Cell |
inner | Local |
inner関数のシンボルテーブル
シンボル名 | スコープ |
---|---|
x | Free |
Global |
新しく出てきたFreeとは、スコープ外のGlobalでないシンボルに付与されるスコープ属性です。
バグの原因
内包表記は内部の別スコープとして扱われます。
ですが内包表記内のセイウチ演算子で宣言した変数は、上位のスコープで宣言された変数と見なされます。
そのため、内包表記後もその変数を使えます。
def f():
# f関数の2つ下のスコープ内でセイウチ演算子を使用しているが
# 変数xはf関数のスコープ内で宣言されたものと見なされる
[[x := i + j for j in range(5)] for i in range(5)]
print(x) # Cell変数xの値は8
RustPythonではスコープ属性がCellではなくLocalになっていました。
そのせいで、バイトコード生成時の変数のスコープチェックでパニックが発生していました。
プルリク提出
原因が分かったので、RustPythonで間違った属性を付与している場所を探して修正しました。
そしてissueに調査結果を追記してプルリクを提出しました。
このプルリクは提出した日にマージされました。
こうしてバグ修正が完了しました!!
おわりに
正直、最初はそこまで難しくはないだろうと思っていました。
ですが実際に調査するとPythonの内部実装を理解していないと原因が分からないバグだと分かりました。
今回のコントリビュートでPythonの理解がより深まりました。
参考文献