何かを書き留める何か

数学や読んだ本について書く何かです。最近は社会人として生き残りの術を学ぶ日々です。

float(".384")と".384".isdigit()の振る舞いの違い

str.isdigit()浮動小数点数を判定できない

野球の人としてよく知られている@shinyorkeさんが興味深いツイートをしていた。

恐らくC言語レベルで違う処理が行われていると思い、調べてみた。

float()の挙動

組み込み函数float()に文字列を渡すとfloat型が返される。 渡せる文字列の規則はfloatのドキュメント 及び浮動小数点数リテラルに記述されている。 打率を意味する".384"という文字列は規則を満たす文字列である。

cpython/Objects/floatobject.cは次のようになっている。

PyObject *
PyFloat_FromString(PyObject *v)
{
    const char *s;
    PyObject *s_buffer = NULL;
    Py_ssize_t len;
    Py_buffer view = {NULL, NULL};
    PyObject *result = NULL;

    if (PyUnicode_Check(v)) {
        s_buffer = _PyUnicode_TransformDecimalAndSpaceToASCII(v);
        if (s_buffer == NULL)
            return NULL;
        s = PyUnicode_AsUTF8AndSize(s_buffer, &len);
        if (s == NULL) {
            Py_DECREF(s_buffer);
            return NULL;
        }
    }
    // 中略
    else {
        PyErr_Format(PyExc_TypeError,
            "float() argument must be a string or a number, not '%.200s'",
            Py_TYPE(v)->tp_name);
        return NULL;
    }
    result = _Py_string_to_number_with_underscores(s, len, "float", v, v,
                                                   float_from_string_inner);
    PyBuffer_Release(&view);
    Py_XDECREF(s_buffer);
    return result;
}

詳細はもっとC言語のソースに踏み込まないとわからないが、_Py_string_to_number_with_underscoresで上手くドキュメントの記述通りに処理されていることが推測できる。

str.isdigit()の挙動

str.isdigit()ドキュメントから引用する。

文字列中の全ての文字が数字で、かつ 1 文字以上あるなら真を、そうでなければ偽を返します。 ここでの数字とは、十進数字に加えて、互換上付き数字のような特殊操作を必要とする数字を含みます。 また 10 を基数とした表現ができないカローシュティー数字のような体系の文字も含みます。 正式には、数字とは、プロパティ値 Numeric_Type=Digit または Numeric_Type=Decimal を持つ文字です。

この説明から".384"の小数点が小数点と認識されないからFalseとなってしまう、と予想できる。

cpython/Objects/unicodeobject.cを調べると、str.isdigit()は次のような実装である。

/*[clinic input]
str.isdigit as unicode_isdigit
Return True if the string is a digit string, False otherwise.
A string is a digit string if all characters in the string are digits and there
is at least one character in the string.
[clinic start generated code]*/

static PyObject *
unicode_isdigit_impl(PyObject *self)
/*[clinic end generated code: output=10a6985311da6858 input=901116c31deeea4c]*/
{
    Py_ssize_t i, length;
    int kind;
    void *data;

    if (PyUnicode_READY(self) == -1)
        return NULL;
    length = PyUnicode_GET_LENGTH(self);
    kind = PyUnicode_KIND(self);
    data = PyUnicode_DATA(self);

    /* Shortcut for single character strings */
    if (length == 1) {
        const Py_UCS4 ch = PyUnicode_READ(kind, data, 0);
        return PyBool_FromLong(Py_UNICODE_ISDIGIT(ch));
    }

    /* Special case for empty strings */
    if (length == 0)
        return PyBool_FromLong(0);

    for (i = 0; i < length; i++) {
        if (!Py_UNICODE_ISDIGIT(PyUnicode_READ(kind, data, i)))
            return PyBool_FromLong(0);
    }
    return PyBool_FromLong(1);
}

Py_UNICODE_ISDIGITは恐らくヘッダーで定義されたマクロであろう、と推測した。 実際、cpython/Include/unicodeobject.hに定義されていた。

#define Py_UNICODE_ISDIGIT(ch) _PyUnicode_IsDigit(ch)

実体は次のように実装されている。

typedef struct {
    /*
       These are either deltas to the character or offsets in
       _PyUnicode_ExtendedCase.
    */
    const int upper;
    const int lower;
    const int title;
    /* Note if more flag space is needed, decimal and digit could be unified. */
    const unsigned char decimal;
    const unsigned char digit;
    const unsigned short flags;
} _PyUnicode_TypeRecord;

#include "unicodetype_db.h"

static const _PyUnicode_TypeRecord *
gettyperecord(Py_UCS4 code)
{
    int index;

    if (code >= 0x110000)
        index = 0;
    else
    {
        index = index1[(code>>SHIFT)];
        index = index2[(index<<SHIFT)+(code&((1<<SHIFT)-1))];
    }

    return &_PyUnicode_TypeRecords[index];
}

/* Returns the integer digit (0-9) for Unicode characters having
   this property, -1 otherwise. */

int _PyUnicode_ToDigit(Py_UCS4 ch)
{
    const _PyUnicode_TypeRecord *ctype = gettyperecord(ch);

    return (ctype->flags & DIGIT_MASK) ? ctype->digit : -1;
}

int _PyUnicode_IsDigit(Py_UCS4 ch)
{
    if (_PyUnicode_ToDigit(ch) < 0)
        return 0;
    return 1;
}

str.isdigit()の場合、頭から文字を一つずつ調べてstr.isdigit()のドキュメントにある「数字」であるかどうかを調べている。 ピリオド(U+002E)はここの意味で「数字」ではないので、".384".isdigit()Falseを返してしまう。 そのため、"0.384"と頭に0を補っても意味がない。 例えば、

>>> "0.384".isdigit()
False
>>> "0.384".isdecimal()
False
>>> "0.384".isnumeric()
False

指数表記でも同じ運命をたどる。

>>> "384e-3".isdigit()
False
>>> "384e-3".isdecimal()
False
>>> "384e-3".isnumeric()
False
>>> float("384e-3")
0.384

結論

str.isdigit()浮動小数点数を判定するのはダメゼッタイ。