为什么 ('x',) 中的 'x' 比 'x' == 'x' 快?

281
>>> timeit.timeit("'x' in ('x',)")
0.04869917374131205
>>> timeit.timeit("'x' == 'x'")
0.06144205736110564

对于含有多个元素的元组同样适用,这两个版本似乎都呈线性增长:

>>> timeit.timeit("'x' in ('x', 'y')")
0.04866674801541748
>>> timeit.timeit("'x' == 'x' or 'x' == 'y'")
0.06565782838087131
>>> timeit.timeit("'x' in ('y', 'x')")
0.08975995576448526
>>> timeit.timeit("'x' == 'y' or 'x' == 'y'")
0.12992391047427532

基于这个,我认为我应该完全开始使用in而不是==


171
请勿滥用 in 取代 ==。这是一种过早的优化,有损可读性。 - Colonel Thirty Two
4
x ="!foo" x in ("!foo",)x == "!foo" - Padraic Cunningham
2
A在B中的值为Value,C与D的值和类型进行比较。 - dsgdfg
7
使用C语言比使用in替代==更为合理。 - Mad Physicist
1
如果你在编写Python代码时,因为速度而选择一种结构而不是另一种,那么你做错了。 - Veky
2个回答

266

正如我对David Wolever提到的那样,这个问题并不像表面看起来的那么简单;这两种方法都使用了is(是)函数;您可以通过执行以下操作来证明:

min(Timer("x == x", setup="x = 'a' * 1000000").repeat(10, 10000))
#>>> 0.00045456900261342525

min(Timer("x == y", setup="x = 'a' * 1000000; y = 'a' * 1000000").repeat(10, 10000))
#>>> 0.5256857610074803

第一个只能这么快,因为它按标识检查。

要找出为什么一个比另一个需要更长时间,让我们跟踪执行。

它们都从ceval.c开始,在COMPARE_OP中启动,因为那是涉及到的字节码。

TARGET(COMPARE_OP) {
    PyObject *right = POP();
    PyObject *left = TOP();
    PyObject *res = cmp_outcome(oparg, left, right);
    Py_DECREF(left);
    Py_DECREF(right);
    SET_TOP(res);
    if (res == NULL)
        goto error;
    PREDICT(POP_JUMP_IF_FALSE);
    PREDICT(POP_JUMP_IF_TRUE);
    DISPATCH();
}

这会从堆栈中弹出值(严格来说,它只会弹出一个值)

PyObject *right = POP();
PyObject *left = TOP();

并运行比较:

PyObject *res = cmp_outcome(oparg, left, right);

cmp_outcome 是这样的:

static PyObject *
cmp_outcome(int op, PyObject *v, PyObject *w)
{
    int res = 0;
    switch (op) {
    case PyCmp_IS: ...
    case PyCmp_IS_NOT: ...
    case PyCmp_IN:
        res = PySequence_Contains(w, v);
        if (res < 0)
            return NULL;
        break;
    case PyCmp_NOT_IN: ...
    case PyCmp_EXC_MATCH: ...
    default:
        return PyObject_RichCompare(v, w, op);
    }
    v = res ? Py_True : Py_False;
    Py_INCREF(v);
    return v;
}

这是路径分叉的地方。 PyCmp_IN 分支执行

int
PySequence_Contains(PyObject *seq, PyObject *ob)
{
    Py_ssize_t result;
    PySequenceMethods *sqm = seq->ob_type->tp_as_sequence;
    if (sqm != NULL && sqm->sq_contains != NULL)
        return (*sqm->sq_contains)(seq, ob);
    result = _PySequence_IterSearch(seq, ob, PY_ITERSEARCH_CONTAINS);
    return Py_SAFE_DOWNCAST(result, Py_ssize_t, int);
}

请注意,元组的定义为:

static PySequenceMethods tuple_as_sequence = {
    ...
    (objobjproc)tuplecontains,                  /* sq_contains */
};

PyTypeObject PyTuple_Type = {
    ...
    &tuple_as_sequence,                         /* tp_as_sequence */
    ...
};

因此,分支

if (sqm != NULL && sqm->sq_contains != NULL)

将会调用*sqm->sq_contains函数,该函数是(objobjproc)tuplecontains函数。
这样做会。
static int
tuplecontains(PyTupleObject *a, PyObject *el)
{
    Py_ssize_t i;
    int cmp;

    for (i = 0, cmp = 0 ; cmp == 0 && i < Py_SIZE(a); ++i)
        cmp = PyObject_RichCompareBool(el, PyTuple_GET_ITEM(a, i),
                                           Py_EQ);
    return cmp;
}

等等,另一个分支使用的不是PyObject_RichCompareBool吗?不是,那是PyObject_RichCompare

那段代码很短,所以很可能只是这两者速度的比较。让我们来比较一下。

int
PyObject_RichCompareBool(PyObject *v, PyObject *w, int op)
{
    PyObject *res;
    int ok;

    /* Quick result when objects are the same.
       Guarantees that identity implies equality. */
    if (v == w) {
        if (op == Py_EQ)
            return 1;
        else if (op == Py_NE)
            return 0;
    }

    ...
}

PyObject_RichCompareBool 中的代码路径几乎立即终止。 对于 PyObject_RichCompare,它则不是这样。

PyObject *
PyObject_RichCompare(PyObject *v, PyObject *w, int op)
{
    PyObject *res;

    assert(Py_LT <= op && op <= Py_GE);
    if (v == NULL || w == NULL) { ... }
    if (Py_EnterRecursiveCall(" in comparison"))
        return NULL;
    res = do_richcompare(v, w, op);
    Py_LeaveRecursiveCall();
    return res;
}

Py_EnterRecursiveCall/Py_LeaveRecursiveCall 组合在上一个路径中并未触发,但这些都是比较快的宏,在递增和递减一些全局变量后就会短路。

do_richcompare 的作用为:

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 && ...) { ... }
    if ((f = v->ob_type->tp_richcompare) != NULL) {
        res = (*f)(v, w, op);
        if (res != Py_NotImplemented)
            return res;
        ...
    }
    ...
}

这会进行一些快速检查,调用v->ob_type->tp_richcompare,这个函数是

PyTypeObject PyUnicode_Type = {
    ...
    PyUnicode_RichCompare,      /* tp_richcompare */
    ...
};

它执行的功能是

PyObject *
PyUnicode_RichCompare(PyObject *left, PyObject *right, int op)
{
    int result;
    PyObject *v;

    if (!PyUnicode_Check(left) || !PyUnicode_Check(right))
        Py_RETURN_NOTIMPLEMENTED;

    if (PyUnicode_READY(left) == -1 ||
        PyUnicode_READY(right) == -1)
        return NULL;

    if (left == right) {
        switch (op) {
        case Py_EQ:
        case Py_LE:
        case Py_GE:
            /* a string is equal to itself */
            v = Py_True;
            break;
        case Py_NE:
        case Py_LT:
        case Py_GT:
            v = Py_False;
            break;
        default:
            ...
        }
    }
    else if (...) { ... }
    else { ...}
    Py_INCREF(v);
    return v;
}

换句话说,这个快捷方式只适用于 left == right ……但在执行完以下操作后才适用。
    if (!PyUnicode_Check(left) || !PyUnicode_Check(right))

    if (PyUnicode_READY(left) == -1 ||
        PyUnicode_READY(right) == -1)

总之,这些路径看起来大致如下(手动递归地进行内联、展开和修剪已知的分支)。
POP()                           # Stack stuff
TOP()                           #
                                #
case PyCmp_IN:                  # Dispatch on operation
                                #
sqm != NULL                     # Dispatch to builtin op
sqm->sq_contains != NULL        #
*sqm->sq_contains               #
                                #
cmp == 0                        # Do comparison in loop
i < Py_SIZE(a)                  #
v == w                          #
op == Py_EQ                     #
++i                             # 
cmp == 0                        #
                                #
res < 0                         # Convert to Python-space
res ? Py_True : Py_False        #
Py_INCREF(v)                    #
                                #
Py_DECREF(left)                 # Stack stuff
Py_DECREF(right)                #
SET_TOP(res)                    #
res == NULL                     #
DISPATCH()                      #

vs

POP()                           # Stack stuff
TOP()                           #
                                #
default:                        # Dispatch on operation
                                #
Py_LT <= op                     # Checking operation
op <= Py_GE                     #
v == NULL                       #
w == NULL                       #
Py_EnterRecursiveCall(...)      # Recursive check
                                #
v->ob_type != w->ob_type        # More operation checks
f = v->ob_type->tp_richcompare  # Dispatch to builtin op
f != NULL                       #
                                #
!PyUnicode_Check(left)          # ...More checks
!PyUnicode_Check(right))        #
PyUnicode_READY(left) == -1     #
PyUnicode_READY(right) == -1    #
left == right                   # Finally, doing comparison
case Py_EQ:                     # Immediately short circuit
Py_INCREF(v);                   #
                                #
res != Py_NotImplemented        #
                                #
Py_LeaveRecursiveCall()         # Recursive check
                                #
Py_DECREF(left)                 # Stack stuff
Py_DECREF(right)                #
SET_TOP(res)                    #
res == NULL                     #
DISPATCH()                      #

现在,PyUnicode_CheckPyUnicode_READY非常便宜,因为它们只检查了几个字段,但显然顶部的那个是更小的代码路径,它具有较少的函数调用,只有一个switch语句,并且只是略微细一点。

TL;DR:

两者都会分派到if(left_pointer == right_pointer);区别在于它们到达该点需要做的工作量。 in只是做得更少。


20
这是一个令人难以置信的答案。你与Python项目有什么关系? - kdbanman
10
没有什么特别的,虽然我已经设法有点“强行闯入”了;)。 - Veedrac
21
哎呀,但是那样就没有人会仔细阅读实际的帖子了!这个问题的重点不在于答案,而是用来 得出 答案的过程 - 希望不会有太多人在生产中使用这个技巧! - Veedrac

180

这里有三个因素同时起作用,从而产生了这种令人惊讶的行为。

首先:in 运算符会在检查相等性 (x == y) 之前先进行身份验证(x is y):

>>> n = float('nan')
>>> n in (n, )
True
>>> n == n
False
>>> n is n
True

第二:由于Python字符串的内部化,"x" in ("x", )中的两个"x"将是相同的:

>>> "x" is "x"
True

重要提示:这是一种特定于实现的行为!is 永远不应该用于比较字符串,因为有时它会给出令人惊讶的答案,例如 "x" * 100 is "x" * 100 ==> False

第三点:正如Veedrac的精彩回答中详细说明的那样,tuple.__contains__x in (y, )大致等于(y, ).__contains__(x))比str.__eq__(同样地,x == y大致等于x.__eq__(y))更快地执行了身份检查的操作。

你可以看到这个证据,因为逻辑上等价的x == y明显比x in (y, )更慢:

In [18]: %timeit 'x' in ('x', )
10000000 loops, best of 3: 65.2 ns per loop

In [19]: %timeit 'x' == 'x'    
10000000 loops, best of 3: 68 ns per loop

In [20]: %timeit 'x' in ('y', ) 
10000000 loops, best of 3: 73.4 ns per loop

In [21]: %timeit 'x' == 'y'    
10000000 loops, best of 3: 56.2 ns per loop

当使用x in (y, )语法时,由于在is比较失败后,in运算符会退回到普通的等值检查(即使用==),因此比较需要和==一样的时间,再加上创建元组、遍历成员等开销,整个操作变得更慢。

同时请注意,只有在a is b时,a in (b, )才会更快。

In [48]: a = 1             

In [49]: b = 2

In [50]: %timeit a is a or a == a
10000000 loops, best of 3: 95.1 ns per loop

In [51]: %timeit a in (a, )      
10000000 loops, best of 3: 140 ns per loop

In [52]: %timeit a is b or a == b
10000000 loops, best of 3: 177 ns per loop

In [53]: %timeit a in (b, )      
10000000 loops, best of 3: 169 ns per loop
为什么a in (b, )a is b or a == b更快?我猜是因为虚拟机指令更少,a in (b, )只有约3个指令,而a is b or a == b则需要更多的虚拟机指令。
Veedrac 的答案——https://dev59.com/cF4b5IYBdhLWcg3woS68#28889838——更详细地讨论了每种操作(==in)具体发生了什么,值得一读。

3
它这样做的原因可能是为了让 X in [X,Y,Z] 正确运行,而无需 XYZ 定义相等方法(或者说默认相等性是 is,因此可以避免在没有用户定义的 __eq__ 的对象上调用 __eq__,并且 is 为真应该意味着值相等)。 - aruisdante
1
使用 float('nan') 可能会存在误导性。nan 的一个特性是它不等于自身。这可能会改变计时。 - dawg
@dawg 哦,说得好 — nan 的例子只是为了说明 in 在成员测试中所采取的快捷方式。我会更改变量名称以澄清。 - David Wolever
3
据我所了解,在CPython 3.4.3中,tuple.__contains__是由tuplecontains实现的,它调用PyObject_RichCompareBool,并在识别身份时立即返回。unicode底层有PyUnicode_RichCompare,对于身份也有相同的快捷方式。 - Cristian Ciupitu
你说字符串驻留是实现特定的。这是否意味着 'x' in ('x',) 可能为假,或者 in 实际上使用 a is b || a == b - Bergi
3
这句话的意思是 "x" 是 "x" 不一定总是 True。而 'x' in ('x', ) 总是为 True,但它可能不会比 == 更快。 - David Wolever

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接