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で異なるデータ型を等価比較したときに, 警告を表示する機能を追加することにした.

ビルド

Python 3.7.9のアーカイブを入手し, 解凍する.

$ 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_EQPy_NEの場合はvwが等価であるかが判定され, それ以外の場合はPyErr_Format()という, エラーに関係のありそうな関数が呼び出されているようである. よって, Py_EQPy_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箇所とも全く同じ内容である. vwのデータ型が異なる場合に, 警告文を表示する. 警告文には, 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デバッグすると, いろいろな関数を行き来することになるので, 「この関数で何が行われるのか」「この関数は飛ばしても大丈夫か」を 推測する能力が求められると感じた.