Melhorando seu código Python com truques simples

Nosso código às vezes está lento. Às vezes, está consumindo muita memória. Talvez não esteja tão legível quanto gostaríamos que fosse. Neste post, veremos como utilizar algumas funções da biblioteca padrão para melhorar o nosso código. Todo o código usado neste post está disponível aqui. Embora eu apresente apenas algumas funções que uso com frequência, há muitas mais que podem ser usadas para melhorar seu código. Encorajo você a verificar a documentação oficial para descobrir o que mais está disponível.

Use list comprehension sempre que possível

O que isso significa?

List comprehension é basicamente outra maneira de criar uma lista. Suponha que queremos criar uma lista de valores em um intervalo:

# forma tradicional
tmp = []
for i in range(10_000_000):
  tmp.append(i)

# list comprehension
tmp = [i for i in range(10_000_000)]

Por que isso importa?

List comprehension geralmente é muito mais rápido do que o loop tradicional. Vamos compará-los:

tmp = []
for i in range(10_000_000):
  tmp.append(i)
1.04 s ± 89.8 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 293.57 MiB
tmp = [i for i in range(10_000_000)]
731 ms ± 71.6 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 285.82 MiB

Outros exemplos

Criando uma lista com uma condição if

tmp = []
for i in range(10_000_000):
  if i % 2 == 0:
    tmp.append(i)
1.11 s ± 24 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: -0.25 MiB
tmp = [i for i in range(10_000_000) if i % 2 == 0]
944 ms ± 6.79 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 115.54 MiB

Criando uma lista com uma condição if else

tmp = []
for i in range(10_000_000):
  if i % 2 == 0:
    tmp.append(i)
  else:
    tmp.append(i+1)
1.61 s ± 90.5 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 32.89 MiB
tmp = [i if i % 2 == 0 else i + 1 for i in range(10_000_000)]
1.33 s ± 11.4 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 273.82 MiB

A maneira mais rápida de criar uma lista

Ao gerar uma lista a partir de um gerador (range neste caso), é mais rápido usar o construtor list().

tmp = list(range(10_000_000))

Para validar isso, vamos comparar o código para 7 execuções, 10 loops cada:

  loop list comprehension construtor de lista
média ± desvio padrão. por loop 1.04 s ± 89.8 ms 731 ms ± 71.6 ms 301 ms ± 18.4 ms
incremento de memória 293.57 MiB 285.82 MiB 75.12 MiB

Por que o construtor list() é mais rápido? De acordo com esta resposta no StackOverflow:

A list comprehension executa o loop em bytecode Python, como um loop for regular. A chamada list() itera inteiramente em código C, o que é muito mais rápido.

Para comparar todas essas soluções, vamos verificar os bytecodes equivalentes. Para o caso do loop:

1           0 BUILD_LIST               0
            2 STORE_NAME               0 (tmp)

2           4 LOAD_NAME                1 (range)
            6 LOAD_CONST               0 (10000000)
            8 CALL_FUNCTION            1
            10 GET_ITER
    >>   12 FOR_ITER                14 (to 28)
            14 STORE_NAME               2 (i)
            16 LOAD_NAME                0 (tmp)
            18 LOAD_METHOD              3 (append)
            20 LOAD_NAME                2 (i)
            22 CALL_METHOD              1
            24 POP_TOP
            26 JUMP_ABSOLUTE           12
    >>   28 LOAD_CONST               1 (None)
            30 RETURN_VALUE

Para a solução de list comprehension:

1           0 LOAD_CONST               0 (<code object <listcomp> at 0x7f272c8eaf50, file "<stdin>", line 1>)
            2 LOAD_CONST               1 ('<listcomp>')
            4 MAKE_FUNCTION            0
            6 LOAD_NAME                0 (range)
            8 LOAD_NAME                1 (10_000_000)
            10 CALL_FUNCTION            1
            12 GET_ITER
            14 CALL_FUNCTION            1
            16 POP_TOP
            18 LOAD_CONST               2 (None)
            20 RETURN_VALUE

Disassembly of <code object <listcomp> at 0x7f272c8eaf50, file "<stdin>", line 1>:
1           0 BUILD_LIST               0
            2 LOAD_FAST                0 (.0)
    >>    4 FOR_ITER                 8 (to 14)
            6 STORE_FAST               1 (i)
            8 LOAD_FAST                1 (i)
            10 LIST_APPEND              2
            12 JUMP_ABSOLUTE            4
    >>   14 RETURN_VALUE

E para a solução do construtor de lista:

1           0 LOAD_NAME                0 (list)
            2 LOAD_NAME                1 (range)
            4 LOAD_NAME                2 (10_000_000)
            6 CALL_FUNCTION            1
            8 CALL_FUNCTION            1
            10 POP_TOP
            12 LOAD_CONST               0 (None)
            14 RETURN_VALUE

Podemos ver que o construtor list() gera menos bytecodes.

Use geradores (generators) e iteradores (iterators) sempre que possível

Para criar um gerador como uma list comprehension (chamada expressão geradora ou generator expression), basta substituir os colchetes [ ] por parênteses ( ).

Por que isso importa?

Geradores parecem uma função normal, porém contém expressões yield para produzir uma série de valores utilizáveis em um loop for ou que podem ser recuperados um de cada vez com a função next(). Ele retorna um iterador de gerador (generator iterator), que suspende temporariamente o processamento, lembrando o estado local de execução (incluindo variáveis locais e instruções try pendentes).

Vamos fazer algumas comparações:

tmp = sum([i for i in range(10_000_000)])
860 ms ± 30.4 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
tmp = sum((i for i in range(10_000_000)))
609 ms ± 2.93 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Não é tão diferente, certo? Agora, vamos verificar o consumo de memória. Vamos focar no incremento, pois ele representa a diferença de memória entre o início e o fim desta execução.

memory increment: 263.44 MiB
memory increment: 0.00 MiB

O que aconteceu? O gerador retorna apenas um elemento por vez, que por sua vez é fornecido à função sum. Dessa forma, não precisamos gerar previamente toda a lista para realizar a soma dos elementos. Na verdade, como a função sum recebe um iterador como parâmetro, poderíamos usá-la assim:

tmp = sum(i for i in range(10_000_000))
593 ms ± 90.4 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.01 MiB

Ou ainda assim:

tmp = sum(range(10_000_000))
168 ms ± 4.68 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.00 MiB

Que executa bem mais rápido.

Evite gerar todos os valores sempre que possível

Para os exemplos seguintes, suponha que temos uma lista ordenada de valores.

O que fazer se quisermos apenas os valores inferiores a um limite?

A maneira utilizando list comprehension de conseguir isso é esta:

tmp = [i for i in range(10_000_000) if i < 1_000_000]
596 ms ± 7.16 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.02 MiB

Agora, com loops:

tmp = []
for i in range(10_000_000):
  if i < 1_000_000:
    tmp.append(i)
  else:
    break
116 ms ± 2.37 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.01 MiB

Por que é mais rápido com loop?

Porque usando list comprehension toda a lista deve ser gerada antes de selecionar os elementos. O mesmo não acontece para o loop, que só percorre alguns dos valores.

Podemos fazer melhor?

Sim, com takewhile.

from itertools import takewhile

tmp = list(takewhile(lambda x: x < 1_000_000, range(10_000_000)))
107 ms ± 2.37 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.01 MiB

Nota: takewhile só é mais rápido quando você sabe que a condição será satisfeita “em breve”.


tmp = []
for i in range(10_000_000):
  if i < 9_000_000:
    tmp.append(i)
  else:
    break
1.12 s ± 27.5 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 3.70 MiB
tmp = [i for i in range(10_000_000) if i < 9_000_000]
1.05 s ± 51 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 246.21 MiB
tmp = list(takewhile(lambda x: x < 9_000_000, range(10_000_000)))
1.06 s ± 8.41 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.00 MiB

Nesse caso, é mais rápido gerar a lista inteira e depois filtrá-la. Mas observe que, embora usar list comprehension seja mais rápido, takewhile ocupa menos memória, pois não precisa armazenar a lista inteira, mesmo que momentaneamente.

E se quisermos apenas os valores superiores a um limite?

Primeiro, vamos tentar com loops:

tmp = []
for i in range(10_000_000):
  if i > 1_000_000:
    tmp.append(i)
1.3 s ± 93.3 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.15 MiB

Agora, com list comprehension:

tmp = [i for i in range(10_000_000) if i > 1_000_000]
978 ms ± 11.2 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 169.84 MiB

Neste caso, como o loop percorrerá todos os elementos, ele é mais lento que list comprehension. Porém, ocupa menos memória, pois não precisa armazenar toda a lista.

Podemos fazer melhor?

Sim, com dropwhile

from itertools import dropwhile

tmp = list(dropwhile(lambda x: x < 1_000_000, range(10_000_000)))
442 ms ± 10.2 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.05 MiB

Nota: dropwhile também só é mais rápido quando você sabe que a condição será satisfeita “em breve”.


tmp = []
for i in range(10_000_000):
  if i > 9_000_000:
    tmp.append(i)
654 ms ± 9.3 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.00 MiB
tmp = [i for i in range(10_000_000) if i > 9_000_000]
623 ms ± 13.6 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.01 MiB
tmp = list(dropwhile(lambda x: x < 9_000_000, range(10_000_000)))
924 ms ± 104 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.01 MiB

E quando queremos obter as primeiras N amostras?

Com loops:

tmp = []
for n, i in enumerate(range(10_000_000)):
  if n < 1_000_000:
    tmp.append(i)
  else:
    break
147 ms ± 3.09 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.00 MiB

Fazendo com list comprehension:

tmp = [i for i in range(10_000_000)][:1_000_000]
523 ms ± 10.8 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.01 MiB

Por que é mais rápido com loops?

Porque usando list comprehension toda a lista deve ser gerada antes de fazer a operação de seleção. O mesmo não é verdade para o loop, que só percorre alguns dos valores.

Podemos fazer melhor?

Sim, com islice

from itertools import islice

tmp = list(islice((i for i in range(10_000_000)), 1_000_000))
72.5 ms ± 1.82 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.01 MiB

E quando queremos obter as últimas N amostras?

Podemos obter o mesmo resultado com islice. Vamos direto às comparações:

tmp = []
for n, i in enumerate(range(10_000_000)):
  if n > 9_000_000:
    tmp.append(i)
1.44 s ± 95.3 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 0.00 MiB
tmp = [i for i in range(10_000_000)][9_000_000:]
743 ms ± 8.52 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: 177.31 MiB
tmp = list(islice((i for i in range(10_000_000)), 9_000_000, None))
796 ms ± 11.8 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
memory increment: -0.16 MiB

Observe novamente que, assim como aconteceu no caso do dropwhile quando a condição demora mais para ser satisfeita, usar islice é mais lento do que fazer com list comprehension, porém ocupa muito menos memória.

E se quisermos apenas contar o número de elementos que serão gerados?

Suponha que queremos saber quantos elementos serão gerados a partir de uma condição. Normalmente, faríamos assim:

tmp = [i for i in range(value) if i % 2 == 0]
count = len(tmp)
1.02 s ± 63.6 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
increment: 0.04 MiB

Mas o problema é que estamos armazenando uma lista inteira na memória apenas para obter seu tamanho.

Podemos fazer melhor?

Sim, criando um gerador que gere 1 toda vez que a condição for verdadeira, e somando tudo.

count = sum(1 for i in range(value) if i % 2 == 0)
991 ms ± 18.9 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
increment: 0.00 MiB

Outras funções úteis de itertools

Já introduzimos 3 das funções mais úteis: dropwhile, islice e takewhile. Vamos verificar outras funções úteis.

cycle

Repete indefinidamente uma determinada sequência.

from itertools import cycle

tmp = []

for counter, i in enumerate(cycle(range(4))):
  if counter == 10:
    break

  tmp.append(i)

print(tmp)
[0, 1, 2, 3, 0, 1, 2, 3, 0, 1]

repeat

Repete indefinidamente um determinado valor, a menos que o argumento times seja especificado.

from itertools import repeat

tmp = list(repeat(10, 5))
print(tmp)
[10, 10, 10, 10, 10]

product

Equivalente a um loop for aninhado.

tmp = []

for i in range(2):
  for j in range(2):
    for k in range(2):
      for l in range(2):
        tmp.append(sum([i, j, k, l]))

print(tmp)
[0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4]
from itertools import product

tmp = [sum(i) for i in product(range(2), range(2), range(2), range(2))]
print(tmp)
[0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4]

Como neste caso estamos usando a mesma sequência para todos os loops, podemos usar repeat para simplificar o código:

from itertools import product

tmp = [sum(i) for i in product(range(2), repeat=4)]
print(tmp)
[0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4]

itertools tem todas as funções combinatórias implementadas

arranjo permutação combinação com repetição combinação
AA   AA  
AB AB AB AB
AC AC AC AC
AD AD AD AD
BA BA    
BB   BB  
BC BC BC BC
BD BD BD BD
CA CA    
CB CB    
CC   CC  
CD CD CD CD
DA DA    
DB DB    
DC DC    
DD   DD  

Melhorando o código com functools

Armazenando chamadas de função com lru_cache

Vamos pegar a função de Fibonacci, por exemplo, que chama a si mesma recursivamente.

def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

Vamos usá-la em uma list comprehension para obter os primeiros 16 números de Fibonacci:

tmp = [fib(i) for i in range(16)]
698 µs ± 152 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Leva um bom tempo para executar para um número pequeno. Mas e se pudéssemos salvar automaticamente os resultados das chamadas anteriores à função? É disso que se trata a lru_cache. Ela armazena chamadas anteriores, com seus parâmetros fornecidos e saída calculada, como uma cache que remove os elementos menos recentemente usados (cache LRU). Dessa forma, sempre que chamarmos a função e essa chamada já tiver sido feita (e seus resultados ainda estiverem armazenados na cache), simplesmente pegamos os resultados da cache.

from functools import lru_cache

@lru_cache
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

Vamos tentar novamente:

tmp = [fib(i) for i in range(16)]
3.34 µs ± 719 ns per loop (mean ± std. dev. of 7 runs, 10 loops each)

Agora, vamos verificar as informações da cache:

  • hits: número de vezes que a função foi chamada e os resultados já estavam lá;
  • misses: número de vezes que a função foi chamada e o resultado não estava armazenado;
  • maxsize: tamanho máximo permitido para a cache;
  • currsize: tamanho atual da cache (resultados armazenados).
print(fib.cache_info())
CacheInfo(hits=1132, misses=16, maxsize=128, currsize=16)

Criando funções com valores padrões com partial

Suponha que temos uma função chamada divide_by que realiza a divisão. É uma função bastante genérica, mas geralmente é chamada com alguns valores específicos, como divisão por dois ou por três.

def divide_by(x, y):
  return x / y

print(divide_by(12, 2))
print(divide_by(12, 3))
6.0
4.0

E se, em vez de criar uma função totalmente nova, pudéssemos apenas criar assinaturas diferentes para a função, uma para cada valor mais comum de y? É para isso que serve partial:

from functools import partial

divide_by_two = partial(divide_by, y=2)
divide_by_three = partial(divide_by, y=3)

print(divide_by_two(12))
print(divide_by_three(12))
6.0
4.0



    Gostou de Ler este Artigo?

    Aqui estão alguns artigos relacionados que você pode gostar de ler:

  • sli.dev para desenvolvedores não web
  • O problema da reproducibilidade de códigos de pesquisa
  • Criando postagens de blog traduzidas
  • Criando páginas de projetos traduzidas
  • Criando páginas de CV traduzidas