O estranho caso do else em loops

Uma das coisas que me chamou a atenção quando comecei a usar Python é o else. O seu uso “natural”, para definir um caminho alternativo para um if, não tem nada demais. O que é um pouco estranho é o fato de Python aceitar else em expressões de loop como for e while. Por exemplo, o código abaixo é perfeitamente válido:

# -*- coding:utf-8 -*-
import random
segredo = random.randint(0, 10)
for tentativa in range(1, 4):
    numero = input('Digite um número entre 0 e 9 (tentativa %d de 3):' % tentativa)
    if numero == segredo:
        print('você acertou!')
        break
else:
    print('você esgotou as suas tentativas')

Perceba que o else está alinhado com o for e não com o if. Neste caso, os comandos contidos nele somente serão executados se o loop não tiver sido encerrado por um break. O mesmo vale para o else em loops while. No exemplo abaixo, o else nunca será executado pois o loop é sempre encerrado por um break:

while True:
    break
else:
    print('nunca serei!')

Confesso que sempre tive uma certa dificuldade para lembrar o significado do else nesses casos, até porque raramente me deparo com eles. Então, certo dia assisti a palestra Transforming Code into Beautiful, Idiomatic Python do Raymond Hettinger, em que ele diz em um certo momento:

O else de loops em Python deveria ser chamado ‘nobreak’.

Pronto, nunca mais esqueci o significado do else em loops! 🙂

Funções anônimas em Python

As funções anônimas — em Python também chamadas de expressões lambda — representam um recurso bem interessante da linguagem Python, mas cuja utilidade pode não ser muito óbvia à primeira vista.

Uma função anônima é útil principalmente nos casos em que precisamos de uma função para ser passada como parâmetro para outra função, e que não será mais necessária após isso, como se fosse “descartável”.

Um exemplo

A builtin map recebe como parâmetros: uma função e uma sequência de dados nos quais a função será aplicada. Agora, imagine que temos uma lista com os números entre 1 e 100 e precisamos de outra lista que contenha os dobros dos números de 1 a 100.

numeros = list(range(1, 101))  # compatível com python 3

Para obter a lista de dobros, poderíamos chamar a função map, passando a ela uma função que retorna o dobro do elemento recebido como parâmetro. Sem funções anônimas, faríamos assim:

def dobro(x):
    return x*2

dobrados = map(dobro, numeros)

Assim, a função dobro será aplicada para cada elemento de numeros e o resultado de cada chamada será adicionado à lista dobrados. Porém, a função dobro será usada somente aqui nessa parte do programa, e o desenvolvedor pode achar que sua presença ali polui o código de forma desnecessária. Como não irá utilizá-la mais em lugar algum, essa função pode ser transformada em uma função anônima, usando a expressão lambda:

dobrados = map(lambda x: x * 2, numeros)

A expressão lambda x: x * 2 cria uma função anônima que recebe um valor como entrada e retorna como resultado tal valor multiplicado por 2. Esse tipo de função é assim chamada porque não podemos nos referir a ela através de um nome, diferentemente da função dobro, por exemplo. As funções anônimas que, em Python, são obtidas através das expressões lambda, são bastante limitadas e devem ser utilizadas com cautela, pois o seu abuso pode comprometer a legibilidade do código. Veja alguns exemplos de abuso das expressões lambda: http://wiki.python.org/moin/DubiousPython#Overuse_of_lambda.

Caso você ainda não tenha compreendido o tal do anonimato da função, costumo pensar em um exemplo que usamos com frequência onde também existe anonimato. Você já passou uma lista literal para uma função, como faço no exemplo a seguir?

soma = sum( [1, 1, 2, 3, 5, 8, 13, 21] )

Você concorda que a lista passada como argumento é também um objeto anônimo? A ideia é semelhante a da função anônima, pois passamos objetos “descartáveis”, como no trecho acima, quando sabemos que não vamos precisar daquele objeto em outros trechos do código.

Para aprender mais sobre as expressões Lambda de Python, leia:

Como funcionam as listas de Python?

Nas primeiras vezes em que ouvi falar das listas de Python, por alguma razão eu acreditava que elas eram implementadas como listas encadeadas. Porém, se assim fosse, teríamos um problema: o custo para acessar um elemento dependeria diretamente da posição em que ele estivesse dentro da lista — isto é, um acesso ao milésimo elemento de uma lista seria mais custoso do que um acesso ao primeiro elemento. Isso porque as listas encadeadas são estruturas de acesso sequencial, onde para acessar o elemento da posição i, é preciso antes percorrer os i-1 elementos que o precedem.

Vamos executar alguns testes para ver se o custo para acesso a um elemento está relacionado à posição do elemento na lista. Nos testes, vamos gerar listas de um milhão de elementos e acessar elementos de diferentes posições em cada teste. Para isso, vamos usar o módulo timeit:


$ python -m timeit -s "lista = range(1000000)" "lista[0] = 0"
10000000 loops, best of 3: 0.0276 usec per loop
$ python -m timeit -s "lista = range(1000000)" "lista[10] = 0"
10000000 loops, best of 3: 0.0276 usec per loop
$ python -m timeit -s "lista = range(1000000)" "lista[1000] = 0"
10000000 loops, best of 3: 0.0276 usec per loop
$ python -m timeit -s "lista = range(1000000)" "lista[100000] = 0"
10000000 loops, best of 3: 0.0276 usec per loop

Os experimentos nos mostram que obtivemos tempos de acesso constantes para as diversas posições da lista. Chamamos de acesso aleatório essa capacidade de termos tempos de acesso iguais para todos os elementos contidos na sequência, independentemente das suas posições. Em estruturas de acesso aleatório, o custo para acessar o primeiro, o i-ésimo ou o último elemento deverá ser o mesmo, diferentemente de estruturas de acesso sequencial.

Para que conseguir o desempenho apresentado nos testes acima, é bem provável que as listas de Python não sejam implementadas sobre listas encadeadas, mas sim sobre arrays, que são estruturas de acesso aleatório. Para confirmar tal hipótese, vamos verificar o código-fonte do CPython. O arquivo listobject.h contém a definição da estrutura de dados que representa uma lista em Python:

typedef struct {
    PyObject_VAR_HEAD
    /* Vector of pointers to list elements. list[0] is ob_item[0], etc. */
    PyObject **ob_item;
    Py_ssize_t allocated;
} PyListObject;

Na estrutura acima, dá pra ver que a lista de Python é realmente implementada sobre um array (campo ob_item da estrutura) e, para entendermos melhor porque os arrays são eficientes, vamos dar uma olhada mais a fundo na próxima seção.

Como funcionam os Arrays?

Um array é uma área contígua de memória utilizada para armazenar vários elementos de um mesmo tipo em uma única variável. Exemplo de array:

array

Arrays são bons porque são simples e porque o acesso a qualquer elemento dentro dele é muito rápido, pois é feito através de índices, que são utilizados como deslocamentos com relação ao endereço inicial do array na memória. Podemos dizer que os arrays fornecem acesso aleatório, pois o tempo e a complexidade para acessar qualquer elemento independem do tamanho do array e da posição do elemento desejado. Vamos ver agora o porquê disso.

Imagine que o array acima esteja na memória do computador, a partir do endereço 2048, como mostra a imagem abaixo.

array2

O endereço na memória do elemento contido em uma posição i do array pode ser calculado de uma forma bem simples:

endereço base + i * tamanho em bytes do tipo

O acesso a uma posição de um array nada mais é do que um acesso ao endereço de memória calculado através da expressão acima. Desse modo, o tempo de acesso a qualquer um dos elementos de um array é igual, pois tudo que é preciso para isso é calcular o endereço de memória do elemento através de uma expressãozinha aritmética e fazer um acesso ao endereço de memória calculado. Ou seja, um array tem tempo de acesso constante — O(1) — para acesso a um elemento qualquer (diferentemente das listas encadeadas, que tem tempo de acesso linear – O(n)).

Essa característica faz com que o array seja uma estrutura mais atrativa devido ao tempo de acesso a um elemento aleatório quando comparado a estruturas como as listas encadeadas, nas quais, para acessar um elemento k, é necessário antes passar por todos os k-1 elementos que o precedem. Essa diferença é bem significativa quando estivermos lidando com listas ou arrays com alguns milhares ou milhões de elementos.

Entretanto, se for preciso redimensionar um array, pode ser necessário mover todo o conteúdo do mesmo para outra área que contenha espaço contíguo suficiente para acomodar o array antigo, além do espaço extra que precisamos. Já com uma lista encadeada, esse problema não existe, pois os elementos não precisam estar em posições contíguas de memória.

O que acontece quando fazemos um append numa lista?

As listas de Python são estruturas que crescem dinamicamente, alocando mais espaço para novos elementos sempre que for necessário. Isso poderia implicar em uma perda de desempenho ao fazer a inserção de novos elementos na lista, pois seria necessário realocar o array subjacente para que os novos elementos caibam dentro dele.

A cada vez em que tentamos fazer a inserção de um novo elemento em uma lista cheia, é preciso:

  1. Alocar um novo espaço contíguo que seja capaz de acomodar nosso array com os novos elementos adicionais.
  2. Copiar todos os elementos do array para a nova área de memória.
  3. Liberar o espaço anteriormente ocupado pelo array, para que o mesmo possa ser reutilizado.

Porém, de acordo com o código fonte do CPython, sempre que for necessário realocar o array subjacente, é incluído um pouco de espaço extra para evitar a realocação desse array nas próximas inserções. O espaço extra a ser alocado é proporcional ao tamanho atual da lista e é calculado assim:

new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);

De acordo com os comentários no código do CPython, essa sobre-alocação vale a pena e permite que o tempo para alocação acabe sendo amortizado quando ocorre uma grande sequência de appends na lista.

Enfim…

As listas são as estruturas padrão para representação de sequências de dados em Python e é importante que saibamos o que está acontecendo quando trabalhamos com elas. Para conhecer as listas mais a fundo, você pode ler o código fonte do CPython.

Vários outros posts aqui no blog já trataram sobre a utilização de listas; confira alguns:

Funções como objetos de primeira classe

Quando você começa a ler um pouquinho mais a fundo sobre linguagens de programação em geral, ou Python em específico, é comum encontrar referências dizendo que funções em python são objetos de primeira classe. Mas o que significa isso?

Dizer que funções são objetos de primeira classe em uma linguagem de programação significa que naquela linguagem uma função é um objeto como qualquer outro, podendo ser tratada da mesma forma que os demais objetos. Ou seja, podemos atribuir uma função a uma variável (ou melhor, dar um nome a uma função), passar uma função como parâmetro para outra função, além de outras operações. Como em Python as funções são objetos de primeira classe, vamos ver alguns exemplos:

 def soma(x, y): return x + y
>>> type(soma)
function
>>> s = soma
>>> s(1, 2)
3
>>> soma(1, 2)
3

No exemplo acima, criamos uma função, que nada mais é do que um objeto do tipo function, e inicialmente referenciamos ela pelo nome soma. Como essa função é um objeto como qualquer outro, podemos então criar uma nova referência a ela (s no exemplo acima).

O mais legal de podermos tratar funções assim é que podemos passar funções como argumentos para outras funções. Como exemplo, vamos criar uma função semelhante à função builtin filter. Ela irá receber uma lista de elementos (de qualquer tipo) e uma função que determina se o elemento deve ser incluso na lista de resultados ou não.

def filtra(lista, aceita):
    result = []
    for elemento in lista:
        if aceita(elemento):
            result.append(elemento)
    return result

No exemplo acima, aceita deve ser um objeto do tipo function, que verifique algo em um objeto qualquer e retorne True ou False (ou outros valores que podem ser avaliados como tal). Feito isso, agora vamos usar a função filtra recém definida.

def eh_positivo(valor):
    return valor > 0

lista_de_int = [-10, 10, 2, -7, 3, 5, 1]
nova_lista = filtra(lista_de_int, eh_positivo)
print nova_lista  # imprime [10, 2, 3, 5, 1]

No exemplo acima, passamos à função filtra uma lista de inteiros e uma função apropriada para aceitação de elementos do tipo int. O mais interessante de a função filtra receber uma função como parâmetro é que isso a deixa muito mais flexível, pois ela poderá funcionar para elementos de qualquer tipo e para as mais variadas semânticas de aceitação de valores, desde que seja fornecida uma função apropriada para isso. Abaixo, definimos uma função que aceita strings que contém espaços e rejeita as que não contém.

def tem_espacos(s):
    return ' ' in s

lista_de_str = ["olá mundo", "hello", "testando 123"]
print filtra(lista_de_str, tem_espacos)  # imprime ["olá mundo", "testando 123"]

Existem várias funções prontas que aceitam outras funções como argumentos. Anteriormente, aqui no blog, já vimos map, reduce e filter. Ao ordenar uma lista podemos passar uma função que será usada para comparar os elementos, e existem muitos outros exemplos.

Com a possibilidade de tratar funções como objetos, podemos escrever funções genéricas para lidar com dados independentemente do tipo.

Baixar página de servidor com tolerância a falhas simples

Um dia desses precisei fazer um script Python que baixava uma cacetada de páginas HTML de um servidor, que às vezes respondia com um erro para algumas das requisições. As requisições estavam corretas, as páginas existiam, mas por alguma falha no servidor elas não respondiam no momento exato da execução do script.

A solução foi fazer uma função que tenta carregar a URL, e caso não consiga, espera alguns segundos e tenta de novo:

import time
import urllib2

def get_url(url, retry=3, timeout=3):
    try:
        return urllib2.urlopen(url).read()
    except:
        if retry > 0:
            time.sleep(timeout)
            return get_url(url, retry - 1, timeout)
        else:
            raise

Gerenciando vários objetos com um with

Aviso: Python 2.7+

Hoje descobri que é possível gerenciar vários objetos em um context manager sem a necessidade de aninhar várias cláusulas with. Ou seja, pude transformar o código¹ que eu estava escrevendo de:

def ajeita_csv(path):
    with open(path, 'r') as fr:
        with open('u' + path, 'w') as fw:
            reader, writer = csv.reader(fr), csv.writer(fw)
            for row in reader:
                for i in range(3, len(row), 5):
                    new_row = row[:3] + row[i:i+5]
                    writer.writerow(new_row)

para:

def ajeita_csv(path):
    with open(path, 'r') as fr, open('u' + path, 'w') as fw:
        reader, writer = csv.reader(fr), csv.writer(fw)
        for row in reader:
            for i in range(3, len(row), 5):
                new_row = row[:3] + row[i:i+5]
                writer.writerow(new_row)

Ou seja, é possível gerenciar a abertura/fechamento de vários objetos/recursos em uma única cláusula with, separando os objetos por uma vírgula.

Fica a dica.

¹Código para dar uma ajustada em um CSV desformatado, que tinha vários registros por linha.

each_cons — percorrendo sequências em N elementos por vez

Recentemente descobri o each_cons, um método interessante da API de Ruby no mixin Enumerable. Esse método permite você percorrer uma sequência de tantos em tantos elementos por vez.

Por exemplo, se você tem uma lista [1, 3, 5, 7, 9] você pode usar o each_cons para percorrer de 2 em 2 elementos:

(1, 3), (3, 5), (5, 7), (7, 9)

ou de 3 em 3 elementos de cada vez:

(1, 3, 5), (3, 5, 7), (5, 7, 9)

Ele ajuda a criar operações que envolvem uma espécie de “janela deslizante” (sliding window), um conjunto de elementos da sequência sobre o qual podemos inferir alguma informação.

Essa abstração se revela bem útil na hora de escrever certos algoritmos e por isso é interessante tê-la como carta na manga.

Implementando em Python

Python não tem um método ou função equivalente ao each_cons na API padrão, mas encontrei algumas sugestões de implementação nas respostas a uma pergunta no StackOverflow. Eis uma delas, que funciona para listas e tuplas:

import itertools
def each_cons(xs, n):
    return itertools.izip(*(xs[i:] for i in xrange(n)))

Para entender como esse código funciona, vamos por partes. Acompanhe:

>>> xs = [1, 2, 3, 4, 5]
>>> xs[0:] # primeiro slice
[1, 2, 3, 4, 5]
>>> xs[1:] # segundo slice
[2, 3, 4, 5]
>>> xs[2:] # terceiro slice
[3, 4, 5]
>>> zip(xs[0:], xs[1:], xs[2:]) # zip dos slices
[(1, 2, 3), (2, 3, 4), (3, 4, 5)]

Os passos acima delineiam o algoritmo dessa implementação do each_cons. Basicamente, obtemos slices da lista na quantidade de elementos que queremos na nossa janela deslizante, e aplicamos zip() neles.

O código mostrado usa uma generator expression para gerar os slices, o truque da estrela nos argumentos para passar os argumentos pro zip e a função izip do módulo itertools que é o equivalente da builtin zip() para generators.

Exemplos de utilidade

1) Encontrar os pares de números com distância entre si maior que 1:

>>> numeros = [1, 2, 3, 5, 9, 10]
>>> [(a, b) for a, b in each_cons(numeros, 2) if b - a > 1]
[(3, 5), (5, 9)]

2) Descobrir se os números de uma lista formam uma sequência:

>>> all([a + 1 == b for a, b in
                        each_cons([1, 2, 3, 4, 5], 2)])
True
>>> all([a + 1 == b for a, b in
                        each_cons([1, 3, 5, 7], 2)])
False

3) Calcular as médias das vendas de cada trimestre:

>>> totais_mensais = [123.45, 54.3, 428, 144.2, 245.45]
>>> [float(a+b+c)/3 for a, b, c in
                        each_cons(totais_mensais, 3)]
[201.91666666666666, 208.83333333333334, 272.55]

Esse tipo de média é conhecido por média móvel, é comum em aplicações financeiras e também é útil para amaciar as linhas de um gráfico.

4) Percorrer as linhas de um arquivo procurando por duplicatas:

>>> linhas = open('arquivo.txt').readlines()
>>> [(num, a) for num,(a,b) in
                  enumerate(each_cons(linhas, 2), 1)
                  if a == b]
[(3, 'tres\n'), (5, 'quatro\n')]

Para um arquivo.txt contendo o texto:

um
dois
tres
tres
quatro
quatro
cinco

Generalizando para generators

A função each_cons que apresentamos acima, apesar de funcionar bem para listas e tuplas, não funciona para generators nem para objetos xrange(). Veja o erro que acontece quando tentamos rodar com um generator:

>>> each_cons((a for a in xrange(10)), 2) # testando com um generator
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in each_cons
TypeError: type object argument after * must be a sequence, not generator

É interessante termos uma solução que funcione também para generators, para o caso de estarmos lidando com um grande volume de dados vindo de um banco de dados, por exemplo.

Para obter isso, basta usar mais algumas funções do módulo itertools na brincadeira:

import itertools
def each_cons(xs, n):
    return itertools.izip(*(itertools.islice(g, i, None)
                          for i,g in
                          enumerate(itertools.tee(xs, n))))

A implementação mostrada acima funciona com sequências (listas, tuplas, strings), generators e objetos xrange() também.