为什么在Python 3中“范围(1000000000000000(1000000000000001))”这么快?


2111

据我了解,该range()函数实际上是Python 3中的一种对象类型,它会生成器一样动态生成其内容。

在这种情况下,我本以为下一行会花费过多的时间,因为要确定1个四舍五入是否在该范围内,必须生成一个四舍五入值:

1000000000000000 in range(1000000000000001)

此外:似乎无论我添加多少个零,计算多少都花费相同的时间(基本上是瞬时的)。

我也尝试过这样的事情,但是计算仍然是即时的:

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

如果我尝试实现自己的范围函数,结果将不是很好!

def my_crappy_range(N):
    i = 0
    while i < N:
        yield i
        i += 1
    return

使range()物体如此之快的物体在做什么?


选择Martijn Pieters的答案是因为它的完整性,但也看到了abarnert的第一个答案,它很好地讨论了在Python 3中range成为完整序列的含义,以及一些有关__contains__跨Python实现的函数优化潜在不一致的信息/警告。。abarnert的其他答案更加详细,并为那些对Python 3优化背后的历史(以及xrangePython 2中缺乏优化)感兴趣的人提供了链接。pokewim的答案感兴趣的人提供了相关的C源代码和说明。


70
请注意,只有当我们检查的项目是a boollongtype时,才是这种情况,对于其他对象类型,它将变得疯狂。尝试:100000000000000.0 in range(1000000000000001)
Ashwini Chaudhary

10
谁告诉你这range是发电机?
abarnert,2015年

7
@abarnert我想我所做的编辑没有造成混乱。
里克

5
@AshwiniChaudhary Python2 xrange与Python3不同range吗?
极好的

28
@Superbest xrange()对象没有__contains__方法,因此项目检查必须遍历所有项目。另外,中的其他更改很少range(),例如它支持切片(再次返回一个range对象),现在还具有countindex方法使其与collections.SequenceABC 兼容。
Ashwini Chaudhary,2015年

Answers:


2167

Python 3 range()对象不会立即产生数字。它是一个智能序列对象,可按需生成数字。它包含的只是您的开始,结束和步长值,然后在对对象进行迭代时,每次迭代都会计算下一个整数。

该对象还实现了object.__contains__hook,并计算您的电话号码是否在其范围内。计算是一个(近)恒定时间运算*。永远不需要扫描范围内的所有可能整数。

range()对象文档中

所述的优点range类型通过常规listtuple是一个范围对象将始终以相同的内存(小)数量,无论它代表的范围内的大小(因为它仅存储startstopstep值,计算各个项目和子范围如所须)。

因此,您的range()对象至少可以做到:

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, step = stop, start, -step
        else:
            lo, hi = start, stop
        self.length = 0 if lo > hi else ((hi - lo - 1) // 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问题


* 由于Python整数是无界的,所以时间接近恒定,因此数学运算也随着N的增长而及时增长,这使其成为O(log N)运算。由于所有操作均以优化的C代码执行,并且Python将整数值存储在30位块中,因此,由于此处涉及的整数大小,您会用光内存,然后再看到任何性能影响。


58
有趣的事实:因为您有__getitem__and 的有效实现__len__,所以__iter__实际上不需要实现。
Lucretiel'5

2
@Lucretiel:在Python 2.3中xrangeiterator添加了一个特殊内容,因为它不够快。然后在3.x中的某个位置(我不确定是3.0还是3.2)扔掉了,它们使用的是相同的listiterator类型list
abarnert,2015年

1
我将构造函数定义为def __init__(self, *start_stop_step),然后从那里解析出来。现在标记参数的方式现在有点令人困惑。不过,+ 1;您仍然肯定会解释这种行为。
Cody Piersall,2015年

1
@CodyPiersall:不幸的是,这是真实类的初始化程序的签名。range*args(比起argclinicC-API函数具有完整的Python签名的API 少)。其他一些旧功能(以及一些新功能,如xrangesliceitertools.islice,为了保持一致性)以相同的方式工作,但在大多数情况下,Guido和其他核心开发人员似乎都同意您的看法。2.0或更高版本的文档甚至将其range和朋友描述为C ++风格的重载,而不是显示出真正令人困惑的签名。
abarnert,2015年

2
@CodyPiersall:实际上,这是Guido在argclinic讨论中引用的一句话,当时Nick Coghlan提出了一种range明确定义的方式:“请不要让人们更容易复制我最糟糕的设计决定。” 因此,我敢肯定,他同意range书面规定令人困惑。
abarnert,2015年

843

此处的根本误解是认为range是生成器。不是。实际上,它不是任何迭代器。

您可以很容易地说出这一点:

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

如果它是一个生成器,则对其进行一次迭代将耗尽它:

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

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

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

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

>>> 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

一个之间的差range和一list在于,range动态序列; 它不记得所有的价值,它只是记住它startstopstep,并根据需要创建的值__getitem__

(作为一个旁注,如果您使用print(iter(a)),则会注意到range使用与相同的listiterator类型list。它是如何工作的?A 除了listiterator使用listC的C实现这一事实外,没有使用任何其他特殊方法__getitem__,因此对于range太。)


现在,没有什么可以说Sequence.__contains__必须是恒定时间的-实际上,对于类似的明显示例list,事实并非如此。但是没有什么可以说是不可能的。与range.__contains__(val - start) % step实际进行计算和测试所有值相比,仅对其进行数学检查(,但具有一些额外的复杂性来处理否定步骤)要容易实现,那么为什么这样做会更好呢?

但是似乎没有什么语言可以保证会发生这种情况。正如Ashwini Chaudhari指出的那样,如果您给它提供一个非整数值,而不是转换为整数并进行数学测试,它将落到对所有值进行迭代并逐一进行比较的过程中。不仅因为CPython 3.2+和PyPy 3.x版本恰好包含此优化,而且这是一个显而易见的好主意且易于实现,所以Iron Iron或NewKickAssPython 3.x没有理由不能放弃它。(实际上,CPython 3.0-3.1 并未包含它。)


如果range实际上是一个生成器(如)my_crappy_range,那么以__contains__这种方式进行测试就没有意义,或者至少有一种合理的方式并不明显。如果您已经迭代了前三个值,那么生成器1仍然in是吗?测试是否应该1使其迭代并消耗所有值1(或直到第一个值>= 1)?


10
这是很重要的事情。我认为Python 2和3之间的差异可能导致我对此感到困惑。无论如何,我应该意识到,因为range(与list和一起tuple)被列为序列类型
瑞克

4
@RickTeachey:实际上,在2.6+(我认为,也许是2.5+)中,xrange也是一个序列。参见2.7文档。实际上,它总是一个几乎是序列的。
2015年

5
@RickTeachey:其实我错了;在2.6-2.7(和3.0-3.1)中,它声称是一个序列,但它仍然只是一个几乎序列。看到我的其他答案。
abarnert,2015年

2
它不是一个迭代器,而是一个序列(就Java而言是可迭代的,C#的IEnumerable)-具有.__iter__()返回迭代器的方法的东西。依次只能使用一次。
史密斯·约翰斯,2016年

4
@ThomasAhle:因为range在不是整数时不检查类型,因为它总是有可能__eq__与兼容int。当然,str显然不会起作用,但是他们不想通过显式检查所有在那里存在的类型来放慢速度(毕竟,str子类可以重写__eq__并包含在中range)。
ShadowRanger

377

使用消息来源,卢克!

在CPython中,range(...).__contains__(方法包装器)最终将委托给一个简单的计算,该计算将检查该值是否可以在该范围内。速度之所以如此,是因为我们使用关于边界的数学推理,而不是range对象的直接迭代。解释所使用的逻辑:

  1. 检查数字在start和之间stop,以及
  2. 检查步幅值是否不会“超过”我们的数字。

例如,994range(4, 1000, 2)因为:

  1. 4 <= 994 < 1000
  2. (994 - 4) % 2 == 0

完整的C代码包含在下面,由于内存管理和引用计数的详细信息,因此较为冗长,但这里存在基本思想:

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);
}

该行的“实质”在该行中提到:

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

最后一点-查看range_contains代码段底部的函数。如果确切的类型检查失败,那么我们将不使用描述的巧妙算法,而是使用_PySequence_IterSearch!退回到该范围的愚蠢迭代搜索。您可以在解释器中检查此行为(我在这里使用v3.5.0):

>>> 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)

144

为了补充Martijn的答案,这是源代码的相关部分(在C中,因为range对象是用本机代码编写的):

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对象(int在Python 3中是),它将使用该range_contains_long函数确定结果。该函数实际上检查是否ob在指定范围内(尽管在C语言中看起来更复杂)。

如果不是int对象,它将退回到迭代,直到找到(或没有)值为止。

整个逻辑可以像这样转换为伪Python:

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

11
@ChrisWesseling:我认为这是完全不同的信息(足够多了),因此编辑Martijn的答案在这里不合适。这是一个判断电话,但是人们通常会犯错误,而不是对他人的答案进行大刀阔斧的改变。
2015年

105

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

首先,正如Ashwini Chaudhary所发现的, 发行1766304已明确打开以进行优化[x]range.__contains__接受了此修补程序并签入了3.2版本,但没有回迁到2.7版本,因为“ xrange表现得如此之久,以至于我看不到它为什么让我们提交最新的修补程序。” (当时2.7快要淘汰了。)

与此同时:

最初xrange是一个非相当序列的对象。如 3.1文档所说:

范围对象的行为很少:它们仅支持索引,迭代和 len功能。

这不是真的。一个xrange对象实际支持,与索引和自动出现一些其他的东西len *包括__contains__(通过线性搜索)。但是,没有人认为有必要在那时将它们完整地序列化。

然后,作为实现抽象基类 PEP的一部分,重要的是弄清楚应将哪些内置类型标记为实现哪些ABC和xrange/ range声称实现collections.Sequence,即使它仍仅处理相同的“非常少的行为”。在发布9213之前,没有人注意到这个问题。该问题的补丁不仅增加indexcount3.2的range,它也重新工作的优化__contains__(共享相同的数学index,并直接使用count)。** 此更改也适用于3.2,并且没有回移植到2.x,因为“这是一个添加了新方法的错误修正”。(此时,2.7已经超过了rc状态。)

因此,有两次机会可以将此优化回溯到2.7,但都被拒绝了。


*实际上,您甚至可以单独使用索引免费获得迭代,但是在2.3 xrange对象中获得了自定义迭代器。

**第一个版本实际上是重新实现了它,并且弄错了细节-例如,它将给您MyIntSubclass(2) in range(5) == False。但是Daniel Stutzbach的补丁更新版本恢复了以前的大部分代码,包括对通用代码的后备支持,_PySequence_IterSearchrange.__contains__在不应用优化的情况下会缓慢地降低3.2 之前版本的隐式使用。


4
从这里的评论:改进xrange.__contains__,似乎他们没有将其回移植到Python 2只是给用户留下了惊喜的元素,而o_O为时已晚。在countindex 补丁后来添加上的。当时的文件:hg.python.org/cpython/file/d599a3f2e72d/Objects/rangeobject.c
Ashwini Chaudhary,

12
我有一个险恶的怀疑,一些核心的python开发人员偏向于对python 2.x的“强硬的爱”,因为他们想鼓励人们改用更高级的python3 :)
wim 2015年

4
我也敢打赌,必须在旧版本中添加新功能是一个巨大的负担。想象一下,如果您去过Oracle并说:“看,我使用的是Java 1.4,我应该得到lambda表达式!不需移植就可以了。”
罗布·格兰特

2
@RickTeachey是的,这只是一个例子。如果我说1.7,它仍然适用。这是数量上的差异,而不是定性的。基本上,(未付费的)开发人员永远无法在3.x中制作出很棒的新东西,并将其移植到2.x,以供那些不想升级的人使用。这是一个巨大而荒谬的负担。您认为我的推理仍有问题吗?
罗布·格兰特

3
@RickTeachey:2.7在3.1到3.2之间,而不是3.3左右。这意味着当对3.2的最后更改进入时2.7位于rc中,这使得错误注释更易于理解。无论如何,我认为他们在回想起来时犯了一些错误(特别是假设人们将2to3在诸如之类的库的帮助下通过迁移而不是通过双版本代码进行迁移six,这就是为什么我们会得到dict.viewkeys没人用的东西了)在3.2中进行的一些更改为时已晚,但在大多数情况下2.7是令人印象深刻的“有史以来的最新2.x”版本。
abarnert,2015年

47

其他答案已经很好地说明了这一点,但是我想提供另一个实验来说明范围对象的性质:

>>> 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]

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


27

这是关于一个偷懒的办法来评估和一些额外的优化range。直到实际使用时才需要计算范围内的值,或者由于额外的优化甚至不需要进一步计算。

顺便说一句,您的整数不是那么大,请考虑 sys.maxsize

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

由于优化-比较给定的整数和范围的最小值和最大值很容易。

但:

Decimal(sys.maxsize) in range(sys.maxsize) 很慢

(在这种情况下, range,因此,如果python收到意外的Decimal,则python将比较所有数字)

您应该了解实现细节,但不应依赖它,因为将来可能会改变。


4
小心浮动大整数。在大多数机器上,float(sys.maxsize) != sys.maxsize)即使sys.maxsize-float(sys.maxsize) == 0
holdenweb

18

TL; DR

传回的物件range()实际上是range对象。该对象实现了迭代器接口,因此您可以按顺序迭代其值,就像生成器,列表或元组一样。

但是,它实现了__contains__接口,该接口实际上是当对象出现在in操作员右侧时调用的接口。该__contains__()方法返回a bool左侧项目是否in在对象中。由于range对象知道其边界和步幅,因此在O(1)中非常容易实现。


0
  1. 由于优化,将给定的整数与最小和最大范围进行比较非常容易。
  2. 在Python3 中range()函数之所以如此之快,是因为这里我们对边界使用数学推理,而不是范围对象的直接迭代。
  3. 所以在这里解释逻辑:
    • 检查数字是否在开始和停止之间。
    • 检查步长精度值是否不超过我们的数字。
  4. 例如,997在range(4,1000,3)内是因为:

    4 <= 997 < 1000, and (997 - 4) % 3 == 0.


1
您可以共享源代码吗?即使这声音合法的,这将是很好的实际代码来支持这些说法
尼科·哈泽

我认为这是可以实施的一个例子。不是确切的实现方式。尽管没有提供参考,但它很好地暗示了足以理解为什么范围的包含检查比列表或元组要快得多的原因
Mohammed Shareef C

0

尝试x-1 in (i for i in range(x))使用较大的x值,该值使用生成器理解来避免调用range.__contains__优化。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.