关于性能:为什么在python 3中”100000000000000在范围内(100000000000000001)”这么快?

Why is “1000000000000000 in range(1000000000000001)” so fast in Python 3?

据我所知,range()函数实际上是Python3中的对象类型,它可以动态生成其内容,类似于生成器。

在这种情况下,我希望下面一行花费的时间不多,因为为了确定1万亿是否在范围内,必须生成一个万亿值:

1
1000000000000000 in range(1000000000000001)

此外:看起来无论我加多少个零,计算或多或少都需要相同的时间(基本上是瞬时的)。

我也尝试过类似的方法,但计算几乎是即时的:

1
1000000000000000000000 in range(0,1000000000000000000001,10) # count by tens

如果我尝试实现自己的范围函数,结果就不那么好了!!

1
2
3
4
5
6
def my_crappy_range(N):
    i = 0
    while i < N:
        yield i
        i += 1
    return

range()物体在发动机罩下做什么使它如此快速?

Martijn-Pieters的答案之所以被选择是因为它的完整性,但也请参见Abarner的第一个答案,以便对range在python 3中是一个完整的序列意味着什么进行很好的讨论,以及有关在python实现中__contains__函数优化的潜在不一致性的一些信息/警告。Abarnett的另一个答案更为详细,并为那些对python 3优化背后的历史感兴趣的人(以及python 2中缺少xrange的优化)提供了链接。poke和wim的答案为感兴趣的人提供了相关的C源代码和解释。


python 3 range()对象不会立即生成数字;它是一个按需生成数字的智能序列对象。它所包含的只是您的开始、停止和步骤值,然后在对象上迭代时,每次迭代都会计算下一个整数。

对象还实现了object.__contains__钩子,并计算您的数字是否是其范围的一部分。计算是一个O(1)常量时间操作。不需要扫描范围内所有可能的整数。

来自range()对象文档:

The advantage of the range type over a regular list or tuple is that a range object will always take the same (small) amount of memory, no matter the size of the range it represents (as it only stores the start, stop and step values, calculating individual items and subranges as needed).

所以至少,你的range()对象会:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class my_range(object):
    def __init__(self, start, stop=None, step=1):
        if stop is None:
            start, stop = 0, start
        self.start, self.stop, self.step = start, stop, step
        if step < 0:
            lo, hi = stop, start
        else:
            lo, hi = start, stop
        self.length = ((hi - lo - 1) // abs(step)) + 1

    def __iter__(self):
        current = self.start
        if self.step < 0:
            while current > self.stop:
                yield current
                current += self.step
        else:
            while current < self.stop:
                yield current
                current += self.step

    def __len__(self):
        return self.length

    def __getitem__(self, i):
        if i < 0:
            i += self.length
        if 0 <= i < self.length:
            return self.start + i * self.step
        raise IndexError('Index out of range: {}'.format(i))

    def __contains__(self, num):
        if self.step < 0:
            if not (self.stop < num <= self.start):
                return False
        else:
            if not (self.start <= num < self.stop):
                return False
        return (num - self.start) % self.step == 0

这仍然缺少真实的range()支持的一些东西(例如.index().count()方法、散列、相等测试或切片),但应该给您一个想法。

我还简化了__contains__的实现,只关注整数测试;如果您给一个实际的range()对象一个非整数值(包括int的子类),则会启动一个慢扫描来查看是否有匹配,就像对所有包含值的列表使用包含测试一样。这样做是为了继续支持其他数值类型,这些类型恰好支持对整数进行相等性测试,但不希望也支持整数算术。请参阅实现包含测试的原始python问题。


这里的基本误解是认为range是一个生成器。不是这样。实际上,它不是任何类型的迭代器。

你可以很容易地分辨出:

1
2
3
4
5
>>> a = range(5)
>>> print(list(a))
[0, 1, 2, 3, 4]
>>> print(list(a))
[0, 1, 2, 3, 4]

如果它是一个生成器,迭代一次就会耗尽它:

1
2
3
4
5
>>> b = my_crappy_range(5)
>>> print(list(b))
[0, 1, 2, 3, 4]
>>> print(list(b))
[]

range实际上是一个序列,就像一个列表。您甚至可以测试:

1
2
3
>>> import collections.abc
>>> isinstance(a, collections.abc.Sequence)
True

这意味着它必须遵循作为一个序列的所有规则:

1
2
3
4
5
6
7
8
9
10
11
12
>>> a[3]         # indexable
3
>>> len(a)       # sized
5
>>> 3 in a       # membership
True
>>> reversed(a)  # reversible
<range_iterator at 0x101cd2360>
>>> a.index(3)   # implements 'index'
3
>>> a.count(3)   # implements 'count'
1

rangelist的区别在于,range是一个懒惰的或动态的序列;它不记得所有的值,只记得它的startstopstep,并根据需要创建__getitem__的值。

(作为补充说明,如果您使用print(iter(a)),您会注意到range使用与list相同的listiterator类型。这是怎么回事?一个listiterator除了提供__getitem__的C实现外,没有使用任何关于list的特别的东西,因此它对range也很好。)

事实上,没有什么能说明Sequence.__contains__必须是恒定时间,对于像list这样的序列的明显例子来说,它不是。但是没有什么能说明它不可能是恒定时间。与实际生成和测试所有值相比,实现range.__contains__只需数学检查((val - start) % step,但处理负面步骤的额外复杂性),要容易得多,那么为什么它不应该做得更好呢?

但语言中似乎没有任何东西可以保证这一点。正如Ashwini Chaudhari指出的那样,如果你给它一个非整数值,而不是转换成整数并进行数学测试,它将返回到对所有值进行迭代并逐一比较。仅仅因为cpython 3.2+和pypy 3.x版本恰好包含了这种优化,这是一个明显的好主意,而且很容易做到,所以Ironpython或Newkickasspython 3.x没有理由不能忽略它。(事实上,CPython 3.0-3.1没有包括在内。)

如果range实际上是一个发电机,就像my_crappy_range那样,那么用这种方法测试__contains__是没有意义的,或者至少它的意义不明显。如果已经迭代了前3个值,那么1仍然是in生成器吗?对1的测试是否会导致它迭代并消耗所有到1或到第一个值>= 1的值?


利用源头,卢克!

在cpython中,EDOCX1(方法包装器)最终将委托给一个简单的计算,该计算检查值是否可能在范围内。这里速度的原因是我们使用的是关于边界的数学推理,而不是距离对象的直接迭代。要解释使用的逻辑:

  • 检查数字是否在startstop之间,以及
  • 检查跨步值是否没有"跨过"我们的数字。
  • 例如,994range(4, 1000, 2)中,因为:

  • 4 <= 994 < 1000
  • (994 - 4) % 2 == 0
  • 完整的C代码包含在下面,由于内存管理和引用计数详细信息,这有点冗长,但基本思想是:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    static int
    range_contains_long(rangeobject *r, PyObject *ob)
    {
        int cmp1, cmp2, cmp3;
        PyObject *tmp1 = NULL;
        PyObject *tmp2 = NULL;
        PyObject *zero = NULL;
        int result = -1;

        zero = PyLong_FromLong(0);
        if (zero == NULL) /* MemoryError in int(0) */
            goto end;

        /* Check if the value can possibly be in the range. */

        cmp1 = PyObject_RichCompareBool(r->step, zero, Py_GT);
        if (cmp1 == -1)
            goto end;
        if (cmp1 == 1) { /* positive steps: start <= ob < stop */
            cmp2 = PyObject_RichCompareBool(r->start, ob, Py_LE);
            cmp3 = PyObject_RichCompareBool(ob, r->stop, Py_LT);
        }
        else { /* negative steps: stop < ob <= start */
            cmp2 = PyObject_RichCompareBool(ob, r->start, Py_LE);
            cmp3 = PyObject_RichCompareBool(r->stop, ob, Py_LT);
        }

        if (cmp2 == -1 || cmp3 == -1) /* TypeError */
            goto end;
        if (cmp2 == 0 || cmp3 == 0) { /* ob outside of range */
            result = 0;
            goto end;
        }

        /* Check that the stride does not invalidate ob's membership. */
        tmp1 = PyNumber_Subtract(ob, r->start);
        if (tmp1 == NULL)
            goto end;
        tmp2 = PyNumber_Remainder(tmp1, r->step);
        if (tmp2 == NULL)
            goto end;
        /* result = ((int(ob) - start) % step) == 0 */
        result = PyObject_RichCompareBool(tmp2, zero, Py_EQ);
      end:
        Py_XDECREF(tmp1);
        Py_XDECREF(tmp2);
        Py_XDECREF(zero);
        return result;
    }

    static int
    range_contains(rangeobject *r, PyObject *ob)
    {
        if (PyLong_CheckExact(ob) || PyBool_Check(ob))
            return range_contains_long(r, ob);

        return (int)_PySequence_IterSearch((PyObject*)r, ob,
                                           PY_ITERSEARCH_CONTAINS);
    }

    这一想法的"肉"在这一行中提到:

    1
    /* result = ((int(ob) - start) % step) == 0 */

    最后一点要注意的是,查看代码段底部的range_contains函数。如果精确的类型检查失败,那么我们不使用所描述的聪明算法,而是使用_PySequence_IterSearch返回到范围的一个愚蠢的迭代搜索!您可以在解释器中检查这种行为(我在这里使用的是v3.5.0):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    >>> x, r = 1000000000000000, range(1000000000000001)
    >>> class MyInt(int):
    ...     pass
    ...
    >>> x_ = MyInt(x)
    >>> x in r  # calculates immediately :)
    True
    >>> x_ in r  # iterates for ages.. :(
    ^\Quit (core dumped)


    要添加到martijn的答案中,这是源代码的相关部分(在C中,因为range对象是用本机代码编写的):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    static int
    range_contains(rangeobject *r, PyObject *ob)
    {
        if (PyLong_CheckExact(ob) || PyBool_Check(ob))
            return range_contains_long(r, ob);

        return (int)_PySequence_IterSearch((PyObject*)r, ob,
                                           PY_ITERSEARCH_CONTAINS);
    }

    因此,对于PyLong对象(在python 3中是int对象),它将使用range_contains_long函数来确定结果。这个函数基本上检查ob是否在指定的范围内(尽管在c中看起来有点复杂)。

    如果它不是int对象,则返回到迭代,直到找到值(或不是)。

    整个逻辑可以转换为伪Python,如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    def range_contains (rangeObj, obj):
        if isinstance(obj, int):
            return range_contains_long(rangeObj, obj)

        # default logic by iterating
        return any(obj == x for x in rangeObj)

    def range_contains_long (r, num):
        if r.step > 0:
            # positive step: r.start <= num < r.stop
            cmp2 = r.start <= num
            cmp3 = num < r.stop
        else:
            # negative step: r.start >= num > r.stop
            cmp2 = num <= r.start
            cmp3 = r.stop < num

        # outside of the range boundaries
        if not cmp2 or not cmp3:
            return False

        # num must be on a valid step inside the boundaries
        return (num - r.start) % r.step == 0


    如果您想知道为什么将此优化添加到range.__contains__中,以及为什么在2.7中不将其添加到xrange.__contains__中:

    首先,正如Ashwini Chaudhary发现的那样,1766304号问题被明确地打开,以优化[x]range.__contains__。一个补丁被接受并在3.2版本中签入,但没有返回到2.7版本,因为"xrange的行为如此之久,以至于我看不到它会给我们带来什么,让我们这么晚才提交补丁。"(2.7在那时就快过时了。)

    同时:

    最初,xrange是一个不完全序列的对象。如3.1文件所述:

    Range objects have very little behavior: they only support indexing, iteration, and the len function.

    这并不完全正确;一个xrange对象实际上支持一些其他的自动索引和len,包括__contains__(通过线性搜索)。但当时没人认为制作完整的序列是值得的。

    然后,作为实现抽象基类PEP的一部分,重要的是要弄清楚哪些内置类型应该标记为实现哪些ABC,而xrange/range声称实现collections.Sequence,尽管它仍然只处理相同的"很少的行为"。在9213版之前,没有人注意到这个问题。该版本的补丁不仅将indexcount添加到3.2的range中,还重新运行了优化的__contains__(与index共享相同的数学,并由count直接使用)。**这一变化也应用到了3.2中,并且没有返回到2.x,因为"这是一个添加新方法的错误修复程序"(此时,2.7已经超过了RC状态。)

    所以,有两次机会将这个优化返回到2.7,但都被拒绝了。

    实际上,您甚至可以使用len和indexing免费获得迭代,但在2.3 xrange对象中获得自定义迭代器。然后在3.x中丢失,它使用与list相同的listiterator类型。

    第一个版本实际上重新实现了它,并且得到了错误的细节——例如,它会给你MyIntSubclass(2) in range(5) == False。但是Daniel Stutzbach的补丁更新版本恢复了之前的大部分代码,包括对通用的、缓慢的_PySequence_IterSearch的回退,即3.2版之前的range.__contains__在优化不适用时被隐式使用。


    其他答案已经很好地解释了这一点,但我想提供另一个实验来说明靶场物体的性质:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    >>> r = range(5)
    >>> for i in r:
            print(i, 2 in r, list(r))

    0 True [0, 1, 2, 3, 4]
    1 True [0, 1, 2, 3, 4]
    2 True [0, 1, 2, 3, 4]
    3 True [0, 1, 2, 3, 4]
    4 True [0, 1, 2, 3, 4]

    如您所见,range对象是一个记住其范围的对象,可以多次使用(即使是在对其进行迭代时),而不仅仅是一个一次性生成器。


    这一切都是关于对range的评估和一些额外优化的懒惰方法。在实际使用之前,不需要计算范围中的值,或者由于额外的优化而进一步计算。

    顺便说一句,你的整数不是这么大,以sys.maxsize为例。

    sys.maxsize in range(sys.maxsize)相当快

    由于优化-很容易将给定整数与最小和最大范围进行比较。

    但是:

    float(sys.maxsize) in range(sys.maxsize)相当慢。

    (在这种情况下,range中没有优化,因此如果python收到意外的float,python将比较所有的数字)

    您应该知道一个实现细节,但不应该依赖它,因为这在将来可能会改变。


    C#中也有类似的实现。您可以看到在O(1)时间内Contains是如何完成的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public struct Range
    {

        private readonly int _start;
        private readonly int _stop;
        private readonly int _step;


        //other methods/properties omitted


        public bool Contains(int number)
        {
            // precheck: if the number isn't in a valid point, return false
            // for example, if start is 5 and step is 10, then it'
    s impossible for 163 to be in range (due to modulo)

            if ((_start % _step + _step) % _step != (number % _step + _step) % _step)
                return false;

            // v is vector: 1 means positive step, -1 means negative step
            // this value makes final checking formula straightforward.

            int v = Math.Abs(_step) / _step;

            // since we have vector, no need to write if/else to handle both cases: negative and positive step
            return number * v >= _start * v && number * v < _stop * v;
        }
    }

    DR

    range()返回的对象实际上是range对象。这个对象实现了迭代器接口,这样您就可以像生成器一样按顺序迭代它的值,但它也实现了__contains__接口,当对象出现在in运算符的右侧时,它实际上就是被调用的接口。__contains__()方法返回一个bool,说明该项是否在对象中。由于range对象知道它们的边界和步幅,所以在o(1)中很容易实现。