web-dev-qa-db-pt.com

Por que "1000000000000000 no intervalo (1000000000000001)" é tão rápido no Python 3?

Entendo que a função range(), que na verdade é um tipo de objeto no Python 3 , gera seu conteúdo em tempo real, semelhante a um gerador. 

Sendo este o caso, eu teria esperado que a linha a seguir levasse uma quantidade excessiva de tempo, porque para determinar se 1 quadrilhão está no intervalo, um quadrilhão de valores teria que ser gerado: 

1000000000000000 in range(1000000000000001)

Além disso: parece que, não importa quantos zeros eu acrescente, o cálculo mais ou menos leva a mesma quantidade de tempo (basicamente instantâneo). 

Eu também tentei coisas assim, mas o cálculo ainda é quase instantâneo: 

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

Se eu tentar implementar minha própria função de faixa, o resultado não é tão bom !! 

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

O que o objeto range() está fazendo sob o capô, o que o torna tão rápido? 


A resposta de Martijn Pieters foi escolhida pela sua inteireza, mas também ver a primeira resposta de abarnert para uma boa discussão do que significa para range ser um full-fledge sequence em Python 3, e algumas informações/avisos sobre possíveis inconsistências para a otimização de função __contains__ através de implementações em Python. outra resposta de abarnert entra em mais detalhes e fornece links para aqueles interessados ​​na história por trás da otimização em Python 3 (e falta de otimização de xrange em Python 2). Respostas por poke e por wim fornecer o código fonte relevante C e explicações para aqueles que estão interessados. 

1572
Rick Teachey

O objeto Python 3 range() não produz números imediatamente; é um objeto de sequência inteligente que produz números sob demanda. Tudo o que ele contém são seus valores de início, parada e etapa e, conforme você percorre o objeto, o próximo inteiro é calculado a cada iteração.

O objeto também implementa o gancho object.__contains__ , e calculates se o seu número fizer parte de seu intervalo. O cálculo é uma operação de tempo constante O(1). Nunca é necessário verificar todos os inteiros possíveis no intervalo.

Da documentação do objeto range() :

A vantagem do tipo range sobre uma list ou Tuple regular é que um objeto de intervalo sempre terá a mesma quantidade (pequena) de memória, independentemente do tamanho do intervalo que representa (pois armazena apenas os valores start, stop e step , calculando itens individuais e sub-intervalos conforme necessário).

Então, no mínimo, seu objeto range() faria:

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

Isso ainda está faltando várias coisas que um range() real suporta (como os métodos .index() ou .count(), hashing, teste de igualdade ou fatiamento), mas deve lhe dar uma idéia.

Eu também simplifiquei a implementação de __contains__ para focar somente em testes inteiros; se você der a um objeto range() real um valor não-inteiro (incluindo subclasses de int), uma varredura lenta será iniciada para ver se há uma correspondência, como se você usasse um teste de contenção contra uma lista de todos os valores contidos. Isso foi feito para continuar a oferecer suporte a outros tipos numéricos que apenas suportam testes de igualdade com números inteiros, mas também não devem suportar a aritmética inteira. Veja o original problema em Python que implementou o teste de contenção.

1586
Martijn Pieters

Use a fonte , Luke!

No CPython, range(...).__contains__ (um wrapper de método) acabará delegando a um cálculo simples que verifica se o valor pode estar no intervalo. A razão para a velocidade aqui é que estamos usando o raciocínio matemático sobre os limites, em vez de uma iteração direta do objeto de intervalo. Para explicar a lógica usada: 

  1. Verifique se o número está entre start e stop e
  2. Verifique se o valor da passada não "ultrapassa" nosso número. 

Por exemplo, 994 está em range(4, 1000, 2) porque:

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

O código C completo está incluído abaixo, que é um pouco mais detalhado por causa do gerenciamento de memória e dos detalhes da contagem de referência, mas a ideia básica está lá:

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

A "carne" da ideia é mencionada em a linha :

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

Como nota final - observe a função range_contains na parte inferior do snippet de código. Se a verificação do tipo exato falhar, então não usaremos o algoritmo inteligente descrito, em vez disso, voltaremos para uma pesquisa de iteração estúpida do intervalo usando _PySequence_IterSearch! Você pode verificar esse comportamento no interpretador (estou usando a v3.5.0 aqui):

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

Para adicionar à resposta de Martijn, esta é a parte relevante de a fonte (em C, como o objeto de intervalo é escrito em código nativo):

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

Portanto, para objetos PyLong (que é int no Python 3), ele usará a função range_contains_long para determinar o resultado. E essa função essencialmente verifica se ob está no intervalo especificado (embora pareça um pouco mais complexo em C).

Se não for um objeto int, voltará a iterar até encontrar o valor (ou não).

Toda a lógica poderia ser traduzida para pseudo-Python assim:

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

Se você está se perguntando por que esta otimização foi adicionada ao range.__contains__, e por que ela não foi adicionada ao xrange.__contains__ em 2.7:

Primeiro, como Ashwini Chaudhary descobriu, edição 1766304 foi aberta explicitamente para otimizar [x]range.__contains__. Um patch para isso foi aceito e verificado em 3.2 , mas não backported para 2.7 porque "xrange tem se comportado assim há tanto tempo que eu não vejo o que nos compra para cometer o patch tão tarde. " (2.7 estava quase fora naquele momento.)

Enquanto isso:

Originalmente, xrange era um objeto não-bastante-sequência. Como o 3.1 docs diz:

Objetos de intervalo têm muito pouco comportamento: eles suportam apenas indexação, iteração e a função len.

Isso não era bem verdade; um objeto xrange na verdade suportava algumas outras coisas que vêm automaticamente com indexação e len,* incluindo __contains__ (via pesquisa linear). Mas ninguém achou que valeria a pena fazer sequências completas na época.

Então, como parte da implementação do Abstract Base Classes PEP, era importante descobrir quais tipos internos deveriam ser marcados como implementando quais ABCs, e xrange/range declarado para implementar collections.Sequence, mesmo que ainda manipulasse o mesmo "muito pouco comportamento". Ninguém percebeu esse problema até edição 9213 . O patch para esse problema não só adicionou index e count a 3.2's range, ele também re-trabalhou o __contains__ otimizado (que compartilha a mesma matemática com index, e é usado diretamente pelo count).** Esta mudança entrou em 3.2 também, e não foi backported para 2.x, porque "é um bugfix que adiciona novos métodos". (Neste ponto, 2,7 já estava passado status rc.)

Então, houve duas chances de obter essa otimização backported para 2.7, mas ambos foram rejeitados.


* Na verdade, você ainda obtém iteração gratuitamente com len e indexação, mas em 2.3xrange os objetos possuem um iterador customizado. O que eles perderam em 3.x, que usa o mesmo tipo listiterator como list.

** A primeira versão, na verdade, reimplementou-a e ficou com os detalhes errados - por exemplo, ela forneceria MyIntSubclass(2) in range(5) == False. Mas a versão atualizada do patch de Daniel Stutzbach restaurou a maior parte do código anterior, incluindo o fallback para o _PySequence_IterSearch genérico e lento que o range.__contains__ pré-3.2 estava usando implicitamente quando a otimização não se aplica.

88
abarnert

As outras respostas já explicaram bem, mas gostaria de oferecer outro experimento ilustrando a natureza dos objetos de alcance:

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

Como você pode ver, um objeto de intervalo é um objeto que lembra seu alcance e pode ser usado muitas vezes (até mesmo durante iterações), e não apenas um gerador de uma só vez.

40
Stefan Pochmann

É tudo sobre uma abordagem preguiçosa para a avaliação e alguma otimização extra de range. Valores em intervalos não precisam ser computados até o uso real, ou ainda mais devido à otimização extra.

By the way, seu inteiro não é tão grande, considere sys.maxsize

sys.maxsize in range(sys.maxsize) é bem rápido

devido à otimização - é fácil comparar dado inteiro apenas com min e max de intervalo.

mas:

float(sys.maxsize) in range(sys.maxsize) é bem lento .

(nesse caso, não há otimização em range, portanto, se python receber flutuação inesperada, o python comparará todos os números)

Você deve estar ciente de um detalhe de implementação, mas não deve ser confiável, porque isso pode mudar no futuro.

11
Sławomir Lenart

Aqui está implementação semelhante em C#. Você pode ver como Contains feito em O(1) hora.

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 isnt in valid point, return false
        // for example, if start is 5 and step is 10, then its impossible that 163 be in range at any interval      

        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;
    }
}
5
Sanan Fataliyev

TL; DR

O objeto retornado por range() é na verdade um objeto range. Este objeto implementa a interface do iterador para que você possa iterar seus valores sequencialmente, assim como um gerador, mas também implementa a interface __contains__ que é realmente chamada quando um objeto aparece no lado direito do operador in . O método __contains__() retorna um bool de se o item está ou não no objeto. Como os objetos range conhecem seus limites e stride, isso é muito fácil de implementar em O (1). 

0
RBF06