Colorindo grafos — ou, como escolher cores para um mapa automaticamente

Imagine que você tenha um mapa com algumas áreas delimitadas (países, estados, etc), e deseja colorir cada área com uma cor diferente das áreas vizinhas.

Para um mapa pequeno (digamos, com no máximo 7 áreas), você pode simplesmente atribuir uma cor para cada área e se dar por satisfeito. Mas para um mapa com muitas áreas, você provavelmente quer usar um número mínimo de cores: muitas cores diferentes vão deixar o mapa confuso.

Esse é um típico problema a ser resolvido com coloração de grafos, uma área da ciência da computação explorada ativamente ainda hoje. Existe uma gama de problemas que podem ser resolvidos com técnicas desse tipo — outro exemplo popular é o quebra-cabeça Sudoku.

Hoje vamos mostrar um exemplo resolvendo esse problema usando um algoritmo simples para achar a configuração de cores mínima.

Veja a representação visual do grafo para colorir um mapa dos países da América do Sul:

Grafo colorido dos países num mapa da América do Sul

Colorização do grafo dos países da América do Sul usando 4 cores

Ao fim desse post, você terá aprendido a gerar colorizações e imagens para grafos como esse. 🙂

Escolhendo uma representação para o grafo

Existem várias maneiras de representar grafos com diferentes relações custo-benefício por operação, você escolhe a mais apropriada dependendo do tipo de grafo e do problema que você vai resolver. As duas representações mais comuns são matriz de adjacências e lista de adjacências — as demais são geralmente variações dessas.

Para o nosso problema, vamos simplesmente usar um dicionário Python mapeando os nós adjacentes:

grafo = {
    'A': ['B', 'C'],
    'B': ['A'],
    'C': ['A'],
}

Essa representação contém um pouco de duplicação, mas é interessante porque deixa trivial obter os nós do grafo fazendo grafo.keys() e consultar os nós adjacentes de um nó com grafo[nó].

Implementando o algoritmo

Vamos usar um algoritmo de colorização de grafos simples: começamos testando uma configuração com N cores, e caso detectamos que não seja possível, tentamos com N+1. Repetimos isso até encontrarmos uma solução ou atingirmos o limite de tentativas válidas.

Veja o código:

def try_coloring(graph, num_colors):
    assert num_colors >= 0, "Invalid number of colors: %s" % num_colors
    colors = {}

    def neighbors_have_different_colors(nodes, color):
        return all(color != colors.get(n) for n in nodes)

    for node, adjacents in graph.items():

        found_color = False

        for color in range(num_colors):
            if neighbors_have_different_colors(adjacents, color):
                found_color = True
                colors[node] = color
                break

        if not found_color:
            return None

    return colors


def find_graph_colors(graph):
    for num_colors in range(1, len(graph)):
        colors = try_coloring(graph, num_colors)
        if colors:
            return colors

Temos duas funções:

try_coloring() recebe um grafo e um número de cores para tentar. Ela tenta construir uma configuração de cores para o grafo, atualizando um dicionário que mapeia as cores para cada nó. Caso encontre uma configuração de cor válida a função devolve o dicionário; caso contrário, devolve None.

find_graph_colors() recebe um grafo e simplesmente aciona a função try_coloring() com diferentes valores para o número de cores, até que encontre uma configuração válida (ou esgote as tentativas). Também devolve a configuração encontrada ou None caso não for possível.

Coloque o código acima em um arquivo graph_coloring.py, e experimente chamar a função try_coloring() usando o shell para o nosso grafo exemplo:

>>> from graph_coloring import *
>>> grafo = {
...     'A': ['B', 'C'],
...     'B': ['A'],
...     'C': ['A'],
... }
>>> try_coloring(grafo, 1)
>>> try_coloring(grafo, 2)
{'A': 0, 'C': 1, 'B': 1}

Repare como a tentativa de colorir com apenas uma cor não funciona, mas a segunda já fornece uma configuração de cores válida para o nosso pequeno grafo. A propósito, a sessão acima está suplicando para ser usada como doctest para a função try_coloring(). 😉

Bem, esse algoritmo é um pouco ingênuo e definitivamente não-otimizado, pois envolve um certo retrabalho a cada vez que tenta uma configuração de cores nova. Isso não é um problema para os grafos que vamos usar, então se preocupar com performance agora é desnecessário, mas é legal perceber onde podemos mexer caso seja necessário melhorá-lo.

Para o caso específico de mapas, existe um teorema afirmando que é sempre possível resolver esse problema usando 4 cores. Isso funciona porque os grafos que representam mapas são sempre grafos planares, isto é, podem ser representados num plano sem nenhuma aresta se cruzando — o que reduz as possibilidades de conexões entre os vértices.

Gerando uma representação visual

Uma maneira interessante de validar o nosso trabalho acima (e mais divertida do que usando testes de unidade) é gerar uma representação visual do grafo com as respectivas cores.

Para isso, vamos usar a suite open source de software para visualização de grafos Graphviz (instale no Debian/Ubuntu/Mint com sudo apt-get install graphviz; há pacotes também para Windows e Mac).

Iniciação ao uso do GraphViz

O Graphviz usa uma linguagem própria para descrever grafos chamada DOT, que você pode explorar usando a aplicação GraphViz Workspace.

Você também pode criar um arquivo.dot manualmente usando seu editor de texto favorito, e testar a saída com o comando dot. Crie um arquivo com o seguinte conteúdo que descreve nosso grafo de exemplo:

graph {
    A -- B;
    A -- C;
}

Compile uma imagem PNG com o comando dot:

dot -Tpng -o resultado.png arquivo.dot

Se você tem o ImageMagick instalado (no Debian/Ubuntu/Mint: sudo apt-get install imagemagick), pode visualizar a imagem diretamente fazendo pipe do comando dot para o comando display:

dot -Tpng arquivo.dot | display

grafo1

Para gerarmos grafos coloridos, vamos gerar uma representação do grafo que lista as conexões/arestas do grafo e imprime a configuração de cores por último, semelhante ao exemplo seguinte:

graph {
    A -- B;
    A -- C;
    A [style="filled",fillcolor="#ffffb2"];
    B [style="filled",fillcolor="#fd5d3c"];
    C [style="filled",fillcolor="#41b6c4"];
}

grafo2

Para uma documentação mais completa sobre como usar a ferramenta dot para desenhar grafos, veja o documento Drawing graphs with dot.

Gerando a representação DOT

Eis a nossa gloriosa função para gerar a representação do nosso grafo usando a linguagem DOT:

PALETTE = ('#8dd3c7', '#ffffb3', '#bebada', '#fb8072', '#80b1d3', '#fdb462',
           '#b3de69', '#fccde5', '#d9d9d9', '#bc80bd', '#ccebc5', '#ffed6f')


def generate_dot(graph, colors=None, palette=PALETTE):
    assert len(set(colors.values())) <= len(palette), (
        "Too few colors in the palette")

    edges = []
    for node, adjacents in graph.items():
        for n in adjacents:
            if not ((node, n) in edges or (n, node) in edges):
                edges.append((node, n))

    result = 'graph {\n'
    result += ''.join('    "{}" -- "{}";\n'.format(*e) for e in edges)

    if colors:
        style = '    "{}" [style="filled",fillcolor="{}",shape=box];\n'
        result += ''.join(style.format(node, palette[color])
                          for node, color in colors.items())
    result += '}\n'

    return result

A função recebe um grafo na representação de dicionário que combinamos no começo, um dicionário mapeando os números das cores para os nós do grafo (opcional) e uma paleta de cores (usada para obter as cores propriamente ditas, indexadas pelos números do dicionário de cores).

Nota: as cores da paleta fornecida são de uma das paletas disponíveis no site do GraphViz baseadas no fantástico trabalho da Cynthia Brewer.

Exemplo de saída da função generate_dot() para o nosso grafo de exemplo, usando uma paleta própria:

>>> from graph_coloring import *
>>> grafo = {
... 'A': ['B', 'C'],
... 'B': ['A'],
... 'C': ['A'],
... }
>>> colors = try_coloring(grafo, 2)
>>> colors
{'A': 0, 'C': 1, 'B': 1}
>>> print(generate_dot(grafo, colors, palette=['red', 'yellow']))
graph {
 "A" -- "B";
 "A" -- "C";
 "A" [style="filled",fillcolor="red",shape=box];
 "C" [style="filled",fillcolor="yellow",shape=box];
 "B" [style="filled",fillcolor="yellow",shape=box];
}

Gerando a imagem com GraphViz para essa mesma saída, obtemos a imagem:

grafo3

Finalizando

Veja o exemplo completo aqui (baixar graph_coloring.py), contendo o código mostrado nesse post mais a geração do grafo do mapa da América Latina mostrado no começo do post.

Desafio: experimente rodar com Python 2 e Python 3, tem uma sutil diferença no resultado. Consegue sacar o quê e por quê? Poste nos comentários. 😉

Dicas para lidar com JSON

Você já deve ter descoberto como funciona o formato JSON — muito usado para trocar informações entre aplicações Web, como já foi mostrado aqui no blog anteriormente. Hoje vamos mostrar algumas dicas para facilitar sua vida quando estiver lidando com esse formato.

1) Use python -mjson.tool para formatar saídas JSON na linha de comando.

Às vezes cai no nosso colo um conteúdo JSON não-formatado, com todo o conteúdo em uma linha só, algo parecido com isso:

{"assunto":"Dicas para lidar com JSON","metadados":{"data":"07/07/2013 14:10","site":"https://pythonhelp.wordpress.com","numero_acessos":3},"conteudo":"Voc\u00ea j\u00e1 deve ter descoberto como funciona o formato JSON -- muito usado para trocar informa\u00e7\u00f5es entre aplica\u00e7\u00f5es Web..."}

Geralmente, trata-se da resposta de uma API, que é “limpado” para economizar alguns bytes e por conseguinte, reduzir o uso de banda do servidor. Até aí tudo bem, o problema é que fica bem mais complicado de ver a estrutura dos dados retornados. Fear not! O módulo json da API padrão de Python contém uma ferramenta para você formatar os resultados diretamente na linha de comando. Se o conteúdo acima estiver dentro de um arquivo com o nome post.json, você pode fazer:

$ python -m json.tool post.json
{
    "assunto": "Dicas para lidar com JSON",
    "conteudo": "Voc\u00ea j\u00e1 deve ter descoberto como funciona o formato JSON -- muito usado para trocar informa\u00e7\u00f5es entre aplica\u00e7\u00f5es Web...",
    "metadados": {
        "data": "07/07/2013 14:10",
        "numero_acessos": 3,
        "site": "https://pythonhelp.wordpress.com"
    }
}

That’s cool, right?

Se você é que nem eu, provavelmente vai querer colocar um alias (apelido ou atalho) no ~/.bashrc, para ficar ainda mais fácil:

$ echo "alias jsonfmt='python -mjson.tool'" >> ~/.bashrc
$ source ~/.bashrc
$ echo "[1, 2, 3]" | jsonfmt
[
    1,
    2,
    3
]
$ curl -s http://api.joind.in | jsonfmt
{
    "events": "http://api.joind.in/v2.1/events",
    "hot-events": "http://api.joind.in/v2.1/events?filter=hot",
    "open-cfps": "http://api.joind.in/v2.1/events?filter=cfp",
    "past-events": "http://api.joind.in/v2.1/events?filter=past",
    "upcoming-events": "http://api.joind.in/v2.1/events?filter=upcoming"
}

2) Estenda json.JSONEncoder para converter objetos em JSON:

Como você já sabe, é fácil converter dicionários Python no formato JSON. Mas e no caso de variáveis de classes que você mesmo definiu?

Observe esse exemplo:

import json, datetime

class BlogPost:
    def __init__(self, titulo):
        self.titulo = titulo
        self.data = datetime.datetime.now()

post = BlogPost('Dicas para lidar com JSON')

Se tentarmos fazer:

print json.dumps(post)

obtemos um erro parecido com:

TypeError: <__main__.BlogPost instance at 0x1370ab8> is not JSON serializable

Isto é porque o método json.dumps não sabe converter objetos do tipo BlogPost em strings no formato JSON. Felizmente, o método json.dumps permite que você informe um “encodificador” JSON alternativo, de forma que você pode customizar a geração do resultado JSON para permitir a conversão de outros objetos:

class BlogPostEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, BlogPost):
            return {'titulo': obj.titulo, 'data': obj.data}
        if isinstance(obj, datetime.datetime):
            return obj.isoformat()
        return json.JSONEncoder.default(self, obj)

print json.dumps(post, cls=BlogPostEncoder)

Agora sim, funciona:

{"titulo": "Dicas para lidar com JSON", "data": "2013-07-07T16:26:40.636950"}

Minha sugestão é usar um encodificador mais genérico, que permita converter em JSON qualquer objeto que implemente um método to_json, segue um exemplo completo:

import json, datetime

class Site:
    def __init__(self, url):
        self.url = url
    def to_json(self):
        return {"url": self.url}

class BlogPost:
    def __init__(self, titulo, site):
        self.titulo = titulo
        self.data = datetime.datetime.now()
        self.site = site
    def to_json(self):
        return {"titulo": self.titulo, "data": self.data.isoformat(), "site": self.site}

class GenericJsonEncoder(json.JSONEncoder):
    def default(self, obj):
        if hasattr(obj, 'to_json'):
            return obj.to_json()
        if isinstance(obj, datetime.datetime):
            return obj.isoformat()
        return json.JSONEncoder.default(self, obj)

print json.dumps(Site("http://www.google.com.br"), cls=GenericJsonEncoder)
post = BlogPost('Dicas para lidar com JSON', Site('https://pythonhelp.wordpress.com'))

print json.dumps(post, cls=GenericJsonEncoder)

Note que você não precisa de nada disso se estiver usando o framework Django e tentando serializar uma instância de um modelo. Nesse caso, basta usar o mecanismo de serialização para XML e JSON do próprio Django.

3) Para necessidades mais complexas de serialização, mude a estratégia

Se você precisar converter objetos em JSON e vice-versa para coisas mais complicadas que o nosso exemplo, você pode considerar usar o módulo jsonpickle. Este módulo consegue converter quase qualquer objeto Python em JSON e vice-versa, usando uma representação própria baseada no módulo pickle para serialização de objetos. Essa representação própria acaba tendo algumas desvantagens, porque nem sempre o JSON gerado fica legível ou facilmente tratado no caso de outras linguagens, a portabilidade é garantida praticamente só para outras aplicações Python.

Por isso, se a serialização e deserialização de objetos complicados for um ponto muito importante para o seu projeto, considere usar outros formatos — JSON pode não ser o formato mais adequado. (UPDATE: aqui tem uma visão geral sobre alternativas de serialização em Python).

all() e any()

A linguagem Python é recheada de atalhos úteis, que não só facilitam nossa vida na hora de resolver problemas, mas que também fazem você pensar soluções de forma diferente. Este post é sobre duas funções Python que agem sobre listas de booleanos, e se revelam úteis na prática.

Imagine que você tenha uma lista de inteiros, e você precisa verificar se todos são pares. Você pode pensar: “Fácil, é só percorrer a lista, e verificar para cada elemento se ele é divisível por 2. Se algum não for, posso setar uma variável avisando que é falso e tá feito!” Tendo o raciocínio pronto, você senta o traseiro na cadeira e escreve um código tipo esse:

listaDeInteiros = [2, 6, 4, 7, -2]
todosSaoPares = True
for i in listaDeInteiros:
    if i % 2 == 0:
        todosSaoPares = False
        break
if todosSaoPares:
    print 'Todos sao pares'
else:
    print 'Tem algum impar'

E está ótimo, o código funciona numa boa, as variáveis estão claras, e tudo o mais. Todavia, com o passar do tempo você se dá conta que esse tipo de problema começa a repetir. Seguidamente você precisa verificar se uma condição é verdadeira (ou falsa) para os elementos de uma lista, e acaba fazendo muitas muitas versõezinhas parecidas desse código. Agora você pode precisar descobrir se todos os elementos de uma lista são inteiros positivos, e lá vai você de novo:

listaDeInteiros = [2, 6, 4, 7, -2]
todosSaoPositivos = True
for i in listaDeInteiros:
    if i <= 0:
        todosSaoPositivos = False
        break
if todosSaoPositivos:
    print 'Todos sao positivos'
else:
    print 'Tem algum negativo'

Perceba como o código é bem similar ao anterior, praticamente só altera a condição, e os nomes das coisas. Devido a frequência com que aparecem problemas parecidos com esse, Python fornece um jeito mais sucinto de escrever esse tipo de lógica. Usando as funções pré-definidas all e any, seu código ficará mais fácil tanto de escrever como de ler, uma vez que você as aprende. Veja como ficaria o código para verificar se todos os elementos são pares usando all:

listaDeInteiros = [2, 6, 4, 7, -2]
if all(i % 2 == 0 for i in listaDeInteiros):
    print 'Todos sao pares'
else:
    print 'Tem algum impar'

Podemos obter resultado semelhante usando a função any. Tente perceber todas as diferenças:

listaDeInteiros = [2, 6, 4, 7, -2]
if not any(i % 2 != 0 for i in listaDeInteiros):
    print 'Todos sao pares'
else:
    print 'Tem algum impar'

all()

A função all() é muito simples e também muito útil. A documentação oficial da função diz o seguinte:

`all(iterável)`
Retorna `True` se todos os elementos do iterável forem verdadeiros (ou se o iterável for vazio).

Ou seja, a função recebe como parâmetro um objeto iterável (uma lista ou uma tupla, por exemplo) e verifica se todos os elementos desse iterável possuem o valor True. Como o próprio nome diz, all() (todos, em português) verifica se todos os elementos contidos em uma sequência são verdadeiros.

Isso pode ser muito útil quando temos uma lista e precisamos, antes de qualquer coisa, verificar se os elementos dessa lista satisfazem determinada condição.

Por exemplo, antes de passarmos uma lista de strings para uma função que não trata strings vazias, poderíamos fazer o seguinte:

if all(s != '' for s in lista_de_strings):
    funcao_que_nao_trata_strings_vazias(lista_de_strings)
else:
    print "Erro: strings vazias existem na lista!"

No exemplo acima usamos uma generator expression (s != '' for s in lista_de_strings) para gerar o iterável contendo booleanos para passar como entrada para a função all().

Simples, expressivo e eficiente.

any()

A função builtin any() é parecida com a função all(), porém ela retorna True se algum dos elementos do iterável for True. Por exemplo, poderíamos usá-la para verificar se pelo menos um dos elementos de lista_de_strings é uma string vazia.

if any(s == '' for s in lista_de_strings):
    print "Erro: strings vazias existem na lista!"
else:
    funcao_que_nao_trata_strings_vazias(lista_de_strings)

all() e any() como alternativas a and e or

Uma expressão booleana como:

if cond1 and cond2 and cond3 and cond4 and cond5:
    # faça algo

será verdadeira somente se todas as condições testadas forem verdadeiras. Como poderíamos escrever a expressão acima utilizando a função all()?

if all([cond1, cond2, cond3, cond4, cond5]):
    # faça algo

E uma expressão composta por ors lógicos, como poderia ser reescrita?

if cond1 or cond2 or cond3 or cond4 or cond5:
    # faça algo

A expressão acima (cond1 or cond2 or cond3 or cond4 or cond5) será verdadeira se alguma das condições for verdadeira. Ou seja, poderíamos usar a função any():

if any([cond1, cond2, cond3, cond4, cond5]):
    # faça algo

As funções all() e any() podem então ser vistas como aplicações dos operadores and e or aos elementos de sequências.

all() e any() combinados com map()

Já vimos como as funções all() e any() são práticas. Vamos ver agora que, quando combinadas com outros recursos que Python oferece, essas funções passam a ser mais poderosas, dando maior expressividade ao código escrito.

Anteriormente, vimos o funcionamento da função map(): ela aplica uma função a cada elemento de uma sequência e retorna o resultado disso em uma nova lista. Por exemplo:

>>> import math
>>> lista1 = [1, 4, 9, 16, 25]
>>> lista2 = map(math.sqrt, lista1)
>>> print lista2
[1.0, 2.0, 3.0, 4.0, 5.0]

Como poderíamos combinar a função map() com as funções all() ou any()? Vamos a um exemplo.

Considere que temos duas listas contendo números inteiros ([1,2,3,4,5] e [1,4,2,3,5]) e precisamos saber se essas listas possuem elementos de mesmo valor posicionados nas mesmas posições em ambas as listas. Isso poderia ser feito da seguinte forma:

>>> import operator
>>> map(operator.eq, [1,2,3,4,5], [1,4,2,3,5])
[True, False, False, False, True]
>>> any( map(operator.eq, [1,2,3,4,5], [1,4,2,3,5]) )
True

Na segunda linha, usamos a função map() para comparar a igualdade (operator.eq) dos elementos das duas listas (mais informações sobre o módulo operator aqui). Tal linha aplica a função eq (que recebe dois argumentos, os elementos a serem comparados) a pares de elementos das duas listas passadas como argumentos, iniciando por 1 e 1, passando por 2 e 4, 3 e 2, 4 e 3, e, por fim, 5 e 5. Cada comparação gera um booleano que é adicionado à lista que a função map() retorna como resultado. Então, basta que apliquemos a função any() para verificar se para algum dos pares de elementos o eq retornou True. Se em qualquer das posições a lista de resultado do map tiver o valor True, significa que as duas listas possuem elemento cujo valor e posição sejam iguais nas duas listas.

Para finalizar

all() e any() fazem parte do grupo de recursos de Python que todo pythonista deve ter no bolso para utilizar sempre que for preciso. Além de evitar longas linhas de expressões booleanas, essas funções nos permitem dar maior legibilidade ao nosso código.

Ah, fique atento, pois ambas as funções estão disponíveis somente a partir da versão 2.5 do Python.

Tratamento de Exceções

Exceções em Python

Alguma vez, enquanto testando seus programas Python, você recebeu na tela uma mensagem de erro parecida com a seguinte?

Traceback (most recent call last):
File "<stdin>", line 1, in \<module\>
IndexError: list index out of range

Ou com essa?

Traceback (most recent call last):
File "<stdin>", line 1, in \<module\>
NameError: name 'x' is not defined

Em caso positivo, Python lhe atirou uma exceção e você nada fez com ela. Imagine que você esteja escrevendo uma calculadora bem simples usando Python como linguagem para a implementação. Tudo que ela faz são as quatro operações básicas (soma, subtração, multiplicação, divisão) e o cálculo da raiz quadrada. Ao terminar a implementação, você mostra ao seu irmão, cheio de orgulho a calculadora que acabou de implementar. O guri já vai direto ao ponto, seleciona a opção de raiz quadrada e fornece o valor:

-1

Antes de ele pressionar o enter para confirmar a operação, você já percebeu que esqueceu de tratar a entrada do usuário em operações de raiz quadrada. Você já dever saber que raiz quadrada de número negativo só é possível se utilizarmos números complexos, não? E como tal, já imagina que a função sqrt() do módulo math que você usou vai ter problemas para lidar com isso. Quando seu irmão confirma a operação, lá vem a mensagem:

Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: math domain error

ValueError. Guarde esse nome para daqui a pouco…

Prontamente, você abre o seu editor de texto e corrige o código, verificando se número do qual será calculada a raiz quadrada é menor que zero.

if op1 < 0:
    print 'Atenção! Não é possível calcular a raiz quadrada de um número negativo!'

OK, tudo certo. Seu programa voltou a funcionar e o chato do seu irmão pode parar de te incomodar.

Mas e o tal do ValueError que pedi para você lembrar? Então, ele é uma exceção que Python usa para lhe indicar que algo anormal aconteceu durante a execução do seu código. E ela não serve apenas como uma simples mensagem de erro. Muito pelo contrário, o objetivo é que o programador, sabendo que determinado trecho de código pode disparar uma exceção dessas, utilize uma estrutura específica para tratamento de exceções (try-except, para os íntimos).

try-except

A estrutura básica do bloco try-except é:

try:
    # código a ser executado "sob vigilância"
except Exception:
    # caso ocorrer alguma exceção no código acima,
    # trate-a aqui.

O código localizado entre o try: e o except é executado de forma que se algo de errado acontecer ali, o except Exception vai imediatamente chamar o código localizado abaixo de si para realizar o tratamento da exceção. Ou seja, se algo de errado acontecer na linha 2, as linhas 4-5 serão executadas para tratar esse erro.

Resolvendo o problema

Sabendo um pouco como as exceções funcionam, e sabendo que a tentativa de calcular a raiz quadrada de um número negativo gera um ValueError, você irá envolver o trecho de código que poderá gerar tal exceção em um bloco try-except. Veja:

try:
    result = math.sqrt(op1)
except Exception:
    print 'Atenção! Não é possível calcular a raiz quadrada de um número negativo!'

Ao executar o código acima e passar um valor negativo para o segundo operando da divisão, você vê que funcionou. Agora, ao invés da mensagem feia na tela, você recebe sua mensagem de erro personalizada (mais tarde veremos que o mecanismo de exceção não serve só para mandarmos mensagens de erro para o usuário, mas também (e principalmente) para tratar as exceções.).

E aí, a mala do seu irmão vem testar o programa novamente. Desta vez, ele fornece o próprio nome como entrada para a raiz quadrada. Você já imagina que lá vem outro erro, afinal Python não é mágico para calcular a raiz quadrada de "Joaozinho". 😛 Para sua surpresa, eis que aparece na tela a mensagem:

Atenção! Não é possível calcular a raiz quadrada de um número negativo!

Aí você e seu irmão não entendem mais nada, afinal ninguém passou um número negativo como entrada. Na realidade, o que aconteceu é que o bloco:

except Exception:
    print 'Atenção! Não é possível calcular a raiz quadrada de um número negativo!'

significa mais ou menos o seguinte: se ocorrer uma exceção qualquer, nas linhas entre o try e a linha except Exception:, trate-a com o bloco de código que vem após o : (que é o nosso print).

Perceba, except Exception: irá agarrar QUALQUER exceção que Python atirar, pois Exception é a classe-base da qual todas as outras exceções são descendentes. Se você quiser que a mensagem 'Atenção! Não é possível calcular a raiz quadrada de um número negativo!' seja mostrada ao usuário apenas quando for passado um valor negativo à raiz quadrada, então teremos que especializar o nosso tratamento de exceções, isto é, especificar qual é a exceção que queremos tratar realmente. Como vimos anteriormente, a exceção que Python atira quando ocorre uma tentativa de cálculo de raiz quadrada de um valor negativo se chama ValueError. Então, fazemos:

except ValueError:
    print 'Atenção! Não é possível calcular a raiz quadrada de um número negativo!'

Opa, agora sim, a mensagem só vai aparecer quando houver o erro supracitado. É claro que Python não fornece uma exceção para cada um de todos os possíveis tipos de erros, mas ele já vem de fábrica com um bocado. Às vezes, pode ser preciso que você implemente sua própria exceção, como por exemplo, se o seu programa recebesse do usuário o valor do CPF. Você poderia criar uma exceção CPFInvalidError, ou seja qual for o nome que quisesse dar. Mas, criação de exceções customizadas é assunto para outro post.

Tratando as Exceções

Talvez você esteja pensando: “mas jogar uma mensagem de erro na tela não é bem o que eu entendia por tratamento de exceção”. E você está certo, podemos fazer muito mais do que isso. No exemplo anterior, podemos corrigir o erro do usuário, transformando o número negativo em um número positivo. O código a ser executado ao tratarmos uma exceção é código Python como qualquer outro, então você pode fazer o que for preciso para corrigir o problema.

try:
    result = math.sqrt(op1)
except ValueError:
    print 'Atenção! Tranformando entrada negativa em número positivo.'
    op1 = -op1
    result = math.sqrt(op1)

Nota: Esse exemplo é meramente didático, para você entender como funcionam as exceções e como elas podem ter vários usos, mas ele não é um bom exemplo do uso de exceções. Leia a seção das boas práticas e entenda por quê.

Assim, além de avisar ao usuário sobre o erro, já estamos fazendo a correção, de forma que o programa possa seguir seu fluxo normal de execução.

Por fim, talvez você esteja se perguntando como vai adivinhar o nome da exceção para colocar no except. Uma das formas de descobrir quais exceções determinadas funções/operações disparam quando acontece uma situação imprevista, é lendo a documentação da função/operação que você estiver utilizando. Outra modo de descobrir é encarando a mensagem de erro quando acontece uma exceção não tratada. Por exemplo:

>>> math.sqrt("hello")
Traceback (most recent call last):
File "", line 1, in
TypeError: a float is required

Ao executar o comando math.sqrt("hello"), acabamos de descobrir que ele atira uma exceção do tipo TypeError quando recebe uma entrada de um tipo diferente do numérico.

Boas práticas no tratamento de exceções

Agora que você (e o seu irmão) já estão manjando como funcionam exceções e o que dá pra fazer com elas, está na hora de conhecerem algumas boas práticas, alguns conselhos que geralmente é uma boa idéia seguir, na labuta diária com exceções.

  1. Não use exceções para o fluxo principal da aplicação. Ou, dizendo de outro modo, use exceções apenas para o que é realmente excepcional, inesperado. A idéia é que, enquanto seu programa esteja executando o caminho feliz, isto é, enquanto está tudo dentro do esperado — o céu está azul, os planetas estão girando em torno do Sol e a Lua em torno da Terra — ele não precise voltar atrás na sua execução devido a uma exceção ter sido lançada. Isso é uma boa idéia por que o seu código ficará mais direto ao ponto e você ainda evita o eventual custo adicional que programar “orientado a exceções” acarreta (por exemplo, por tentar fazer uma coisa, voltar e tentar de novo). No exemplo utilizado nesse post, poderíamos simplesmente evitar que a exceção ocorra fazendo uma simples verificação sobre o valor, com um if.
  2. Interceptar exceções somente quando você sabe como tratá-la. Ou seja, não adianta de muita coisa encher o seu código de try-except se tudo o que o seu tratamento de exceção faz é imprimir uma mensagem de erro na tela. Trate exceções específicas, e quando você souber como lidar com ela. Senão você pode acabar obscurecendo uma exceção importante (como KeyboardInterrupt ou SystemExit, por exemplo) que era melhor lançá-la mesmo.
  3. Conhecer as exceções da biblioteca-padrão. Tire um tempo para dar uma olhada com cuidado nos tipos de exceções que Python já traz de fábrica, pois além de conhecer algumas exceções que você pode usar no seu programa, você passa a conhecer os principais tipos de situações inesperadas que o seu programa está sujeito.

Programe defensivamente com asserções

O trânsito é um dos ambientes em que o ser humano reconhece que precisa de ajuda pra que funcione direito pra todo mundo. Digo aqui “reconhece” querendo dizer na verdade o contrário: todo mundo acha que é o único bom motorista no mundo, que os outros é que estão querendo ferrá-lo furando sinal fechado, correndo que nem loucos e mudando de pista sem sinalizar, bando egoístas e mal-educados… Enfim, por causa disso a gente aprende a dirigir defensivamente, isto é, se prevenir dos problemas antes que eles aconteçam, evitando situações que coloquem em risco você ou os demais. 1

Em programação não é diferente. Programadores são humanos, cometem muitos erros, e em geral têm dificuldade em reconhecer que seu código não está legal, colocam a culpa no código dos outros, na linguagem, na biblioteca utilizada, no compilador e até no sistema operacional — não obstante o fato de existirem muitos outros programas usando as mesmas coisas e sem apresentar o problema do programa deles.

Assim como a direção defensiva envolve não só seguir as regras de trânsito mas também se preparar para condições adversas, na programação defensiva nosso código precisa mais do que compilar na nossa máquina e funcionar com os testes “caminho feliz” que a gente faz usualmente.

Uma boa prática de programação defensiva é verificar pré-condições do seu código, isto é, coisas que o código precisa que sejam verdadeiras para poder funcionar corretamente. Por exemplo, você pode querer verificar para um certo método se um argumento é não-nulo, ou se não é vazio para o caso de uma lista, ou se representa um objeto de determinado tipo. Em Python, você pode fazer isso criando uma asserção, usando o comando assert:

def buscaPorCpf(cpf):
    assert cpf != None, "argumento cpf nao pode ser None"
    print 'cpf:', cpf

A asserção é uma afirmação sobre o que deve ser verdadeiro em dado momento da execução do seu código. Na prática, ela funciona parecido com uma validação para se defender de erros de um usuário: a diferença é que a asserção é pra se defender de erros do programador, incluindo você mesmo. Quando a condição de uma asserção é verificada sendo falsa, o interpretador Python lança uma AssertionError, opcionalmente com a mensagem que especifique o erro.

É sempre bom usar mensagens que explicitem o tipo de erro, principalmente quando a condição é mais específica, para facilitar a detecção dos problemas, além de facilitar a leitura do código. Por exemplo, você pode querer também verificar se o argumento cpf é do tipo string:

def buscaPorCpf(cpf):
    assert cpf != None, "argumento cpf nao pode ser None"
    assert isinstance(cpf, basestring), "argumento cpf precisa ser uma string"
    print 'cpf:', cpf

Nota: A checagem aqui é feita de tal forma que aceita tanto instâncias de str (ex.: '000.000.000-00') e de unicode (ex.: u'000.000.000-00'), que são subclasses de basestring

Repare que a asserção aqui está se defendendo de coisas básicas, pré-condições que se forem falsas, são obviamente um erro na programação. Nesse exemplo que estamos lidando com um CPF, é importante notar a diferença para uma validação de dados digitados pelo usuário, por exemplo, se ele digita um CPF inválido (que também é uma forma de programação defensiva). Para a validação, o seu programa deve tratar o erro (por exemplo, lançando uma exceção pré-definida ou simplesmente retornando None) de forma consistente com o restante do seu código. Asserções são para se defender de erros de programação e documentar pré-condições ou pós-condições do código, e não para implementar lógica de tratamento de erros.

Outra coisa a ser tomada nota, que pode ser vista como desvantagem de usar asserções para checar pré-condições, é que elas não são propagadas para subclasses do seu código, por exemplo. É muito fácil alguém criar uma classe que estende a sua, e avacalhar a linda defesa que você colocou no __init__. Se você acha interessante esse tipo de recurso, você talvez queira usar ferramentas que implementem programação por contrato, como a pydbc ou a pycontract. 2

Finalizando, se o seu código tiver muitas asserções e você acha que elas podem estar fazendo processamento desnecessário, você pode rodar o interpretador Python com a opção -O que habilita otimizações básicas como pular execução dos comandos assert. Mas… não faça isso! Existe uma lei da natureza que diz que alguns problemas só acontecem quando o cliente usa seu programa, então você provavelmente quer as asserções ajudando lá também. 🙂 3

Notas:

1 Li a metáfora do trânsito a primeira vez no livro O Programador Pragmático, altamente recomendado por sinal!
2 pycontract é a implementação de referência da PEP-0316, uma proposta de embutir tratamento de programação por contrato na linguagem, colocando as pré-condições e pós-condições nas strings de documentação dos métodos. A PEP é antiga e ainda não foi aceita, mas a implementação parece ser estável, e é facilmente instalável no Ubuntu com apt-get install python-contract.
3 Citação quase-literal roubada descaradamente do texto em inglês: Usando Asserções de Forma Efetiva.