Python 3で異なるデータ型を等価比較したときに警告を表示する機能を追加してみた
Python 3で異なるデータ型を等価比較したときに警告を表示する機能を追加してみた
はじめに
この記事はEEIC(東京大学工学部 電気電子工学科/電子情報工学科)3年の後期実験「大規模ソフトウェアを手探る」のレポートとして書かれています。
環境
動機
Python 3では, 異なるデータ型の等価比較(==
および!=
)ができる.
異なるデータ型を等価比較すると, 等しくないと判定される.
>>> 1 == '1' False >>> 1 != [1] True
しかし, 異なるデータ型の等価比較ができることは, バグの原因になりうるのではないだろうか.
たとえば, 標準入力から受け取った整数が, 1
と等しいか判定する場合を考える.
この場合, 以下のコードを書けばよいだろう.
# ok.py a == int(input()) if a == 1: print('Yes') else: print('No')
実行例を以下に示す.
$ python3 ok.py 1 Yes $ python3 ok.py 0 No
だが, (少なくとも私は)2行目のint()
を書き忘れ, 以下のコードを書いてしまうことがある.
# ng.py a = input() if a == 1: print('Yes') else: print('No')
実行例を以下に示す
$ python3 ng.py 1 No $ python3 ng.py 0 No
上のように, ng.py
では, 1
を入力してもNo
と出力される.
str型の'1'
と, int型の1
という, 異なるデータ型を等価比較していることが原因である.
だが, このときエラーや警告が発生しないため, 原因をすぐには突き止められない可能性がある.
このように, 異なるデータ型を等価比較できることで, 予期せぬ挙動を示すことが起こりうる.
そこで, Python 3で異なるデータ型を等価比較したときに, 警告を表示する機能を追加することにした.
ビルド
$ cd ~ $ wget https://www.python.org/ftp/python/3.7.9/Python-3.7.9 $ tar xvf Python-3.7.9.tgz
解凍してできたPython-3.7.9
ディレクトリに移動する.
$ cd ~/Python-3.7.9
インストール先のディレクトリを指定し, コンパイルする.
ここでは, インストール先を, /home/d01phn/python_install
とする.
$ CFLAGS="-O0 -g" ./configure --prefix=/home/d01phn/python_install $ make $ make install
すると, 指定したディレクトリにインストールされる.
コード変更 1
前述のとおり, Python 3では, 異なるデータ型の等価比較(==
および!=
)ができる.
しかし, 順序比較(<
, >
, <=
, >=
)については, エラーが生じる.
>>> 1 < '1' Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: '<' not supported between instances of 'int' and 'str' >>> 1 >= [1] Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: '>=' not supported between instances of 'int' and 'list'
このときに表示されるエラー文を検索すれば, 異なるデータ型の比較を行う箇所を特定できると考えられる.
そこで, Python-3.7.9
ディレクトリ内で, grep
コマンドを用いて, エラー文を検索する.
$ grep -I -r -n "not supported between instances of" Lib/test/test_dataclasses.py:284: f"not supported between instances of '{cls.__name__}' and '{cls.__name__}'"): Lib/test/test_dataclasses.py:313: f"not supported between instances of '{cls.__name__}' and '{cls.__name__}'"): Lib/test/test_dataclasses.py:350: f"not supported between instances of '{cls.__name__}' and '{cls.__name__}'"): Lib/test/test_dataclasses.py:402: "not supported between instances of 'B' and 'C'"): Objects/object.c:720: "'%s' not supported between instances of '%.100s' and '%.100s'", Doc/howto/argparse.rst:550: TypeError: '>=' not supported between instances of 'NoneType' and 'int' Doc/library/pathlib.rst:203: TypeError: '<' not supported between instances of 'PureWindowsPath' and 'PurePosixPath' Doc/library/enum.rst:325: TypeError: '<' not supported between instances of 'Color' and 'Color'
Objects/object.c
の720行目が怪しそうなので, 見てみると, do_richcompare()
関数の内部だとわかる.
/* Perform a rich comparison, raising TypeError when the requested comparison operator is not supported. */ static PyObject * do_richcompare(PyObject *v, PyObject *w, int op) { richcmpfunc f; PyObject *res; int checked_reverse_op = 0; if (v->ob_type != w->ob_type && PyType_IsSubtype(w->ob_type, v->ob_type) && (f = w->ob_type->tp_richcompare) != NULL) { checked_reverse_op = 1; res = (*f)(w, v, _Py_SwappedOp[op]); if (res != Py_NotImplemented) return res; Py_DECREF(res); } if ((f = v->ob_type->tp_richcompare) != NULL) { res = (*f)(v, w, op); if (res != Py_NotImplemented) return res; Py_DECREF(res); } if (!checked_reverse_op && (f = w->ob_type->tp_richcompare) != NULL) { res = (*f)(w, v, _Py_SwappedOp[op]); if (res != Py_NotImplemented) return res; Py_DECREF(res); } /* If neither object implements it, provide a sensible default for == and !=, but raise an exception for ordering. */ switch (op) { case Py_EQ: res = (v == w) ? Py_True : Py_False; break; case Py_NE: res = (v != w) ? Py_True : Py_False; break; default: PyErr_Format(PyExc_TypeError, "'%s' not supported between instances of '%.100s' and '%.100s'", opstrings[op], v->ob_type->tp_name, w->ob_type->tp_name); return NULL; } Py_INCREF(res); return res; }
switch文で比較演算子op
に応じて場合分けがされている.
Py_EQ
とPy_NE
の場合はv
とw
が等価であるかが判定され, それ以外の場合はPyErr_Format()
という, エラーに関係のありそうな関数が呼び出されているようである.
よって, Py_EQ
とPy_NE
の場合に, 警告を表示するコードを加えれば, 異なるデータ型を等価比較したときに, 警告を表示する機能を追加できそうである.
また, エラー文と, PyErr_Format()
とを見比べると, opstrings[op]
, v->ob_type->tp_name
, w->ob_type->tp_name
が,
それぞれ, 比較演算子を表す文字列, v
のデータ型を表す文字列, w
のデータ型を表す文字列に, 対応していると考えられる.
以上の内容を踏まえ, do_richcompare()
関数に, 以下の変更を加えた.
static PyObject * do_richcompare(PyObject *v, PyObject *w, int op) { ... switch (op) { case Py_EQ: res = (v == w) ? Py_True : Py_False; #if 1 if (strcmp(v->ob_type->tp_name, w->ob_type->tp_name) != 0) { printf("Warning: '%.100s' and '%.100s' are different types but compared by '%s'\n", v->ob_type->tp_name, w->ob_type->tp_name, opstrings[op]); } #endif break; case Py_NE: res = (v != w) ? Py_True : Py_False; #if 1 if (strcmp(v->ob_type->tp_name, w->ob_type->tp_name) != 0) { printf("Warning: '%.100s' and '%.100s' are different types but compared by '%s'\n", v->ob_type->tp_name, w->ob_type->tp_name, opstrings[op]); } #endif break; ... }
追加したコードは, #if 1
と#endif
で囲まれた2箇所であり, 2箇所とも全く同じ内容である.
v
とw
のデータ型が異なる場合に, 警告文を表示する.
警告文には, v
のデータ型, w
のデータ型, 比較演算子
の情報が含まれる.
動作検証 1
変更を加えたソースコードを再びビルドする.
$ cd ~/Python-3.7.9 $ make clean $ make $ make install
インストール先のディレクトリ内の, bin
ディレクトリに移動する.
$ cd ~/python_install/bin
変更後のpython3.7
を起動する.
$ ./python3.7
すると,
Warning: 'NoneType' and 'list' are different types but compared by '=='
という警告がたくさん表示されるが, いったん無視する. 異なるデータ型を比較したときに, 警告が表示されることを確認する.
>>> 1 == '1' Warning: 'int' and 'str' are different types but compared by '==' False >>> 1 != [1] Warning: 'int' and 'list' are different types but compared by '!=' True
コード変更 2
異なるデータ型を比較したときに, 警告を表示する機能は追加できた.
しかし, 新たに生じた問題として, python3.7
の起動時に,
Warning: 'NoneType' and 'list' are different types but compared by '=='
という警告がたくさん表示されてしまう.
どうやら, Python 3では, 起動時にNoneType型とlist型の比較が行われるようである.
起動時に警告がたくさん表示されるのは鬱陶しいので, NoneType型を等価比較した場合は, 警告を表示しないようにしたい.
そこで, do_richcompare()
関数に, さらなる変更を加えた.
static PyObject * do_richcompare(PyObject *v, PyObject *w, int op) { ... switch (op) { case Py_EQ: res = (v == w) ? Py_True : Py_False; #if 1 if (strcmp(v->ob_type->tp_name, w->ob_type->tp_name) != 0 && strcmp(v->ob_type->tp_name, "NoneType") != 0 && // added strcmp(w->ob_type->tp_name, "NoneType") != 0 ) { // added printf("Warning: '%.100s' and '%.100s' are different types but compared by '%s'\n", v->ob_type->tp_name, w->ob_type->tp_name, opstrings[op]); } #endif break; case Py_NE: res = (v != w) ? Py_True : Py_False; #if 1 if (strcmp(v->ob_type->tp_name, w->ob_type->tp_name) != 0 && strcmp(v->ob_type->tp_name, "NoneType") != 0 && // added strcmp(w->ob_type->tp_name, "NoneType") != 0 ) { // added printf("Warning: '%.100s' and '%.100s' are different types but compared by '%s'\n", v->ob_type->tp_name, w->ob_type->tp_name, opstrings[op]); } #endif break; ... }
if文の条件式に, 新たに2行ずつ加えた.
動作検証 2
動作検証1と同様に, ビルドし, python3.7
を起動する.
$ cd ~/Python-3.7.9 $ make clean $ make $ make install $ cd ~/python_install/bin $ ./python3.7
すると, python3.7
の起動時に, 警告が表示されないことが確認できる.
感想
「Pythonならよく使ってるし, 新しい機能を追加するのも余裕でしょ?」などという軽い気持ちで挑戦したが, 決して余裕ではなかった.
苦戦しながらも, こうして新たな機能を追加できたことはよかった(実用的かはさておき).
Pythonを実行してGDBでデバッグすると, いろいろな関数を行き来することになるので, 「この関数で何が行われるのか」「この関数は飛ばしても大丈夫か」を
推測する能力が求められると感じた.