E aí, pessoal! Pra comemorar a reinauguração do blog (pra quem não sabe, eu migrei o site todo do WordPress.com pro Hostinger recentemente), resolvi começar uma série de posts que batizei informalmente de “Tipos de dados além do básico em Python”. Uma série para aqueles que já dominam o arroz com feijão do Python e querem avançar mais na linguagem.
Ao longo dos próximos posts sobre Python, pretendo abordar tipos de dados além dos já conhecidos dicionários, conjuntos, tuplas, listas, etc. que podem ajudar um bocado nas suas mais variadas tarefas. Nesse post inaugural, vou falar um pouco sobre o defaultdict.
Contêineres e dicionários
Em Python, costumamos chamar qualquer tipo de objeto capaz de armazenar outros objetos dentro dele de contêineres. Alguns contêineres básicos vêm prontos para uso no Python, como dicionários, tuplas, listas e conjuntos.
Mas além desses tipos básicos, a biblioteca padrão do Python também fornece alguns tipos mais especializados de contêineres. Alguns desses tipos podem ser encontrados em um módulo chamado collections(vale a pena dar uma olhada na documentação dele). E o defaultdict pertence justamente a esse módulo.
Só pra refrescar a memória, o dicionário (dict) é um tipo de contêiner que armazena pares chave/valor. Ou seja, a cada chave é associado um certo valor, e quando precisamos acessar esse valor, procuramos no dicionário a partir da chave. Assim:
>>> a = dict() >>> a["usuario"] = "amadorprograma" >>> a["senha"] = "naodigo" >>> a["usuario"] 'amadorprograma' >>> a["senha"] 'naodigo'
Porém, se você tenta acessar uma chave inexistente em um dicionário normal, o interpretador lançará um KeyError (erro de chave):
>>> a["password"] Traceback (most recent call last): File "<stdin>", line 1, in <module> KeyError: 'password'
(Nota: se você tem a curiosidade de saber como dicionários são implementados, o último post do blog foi justamente sobre implementação de dicionários em C)
O que é o defaultdict
O defaultdict é muito parecido com o dict, a diferença é que quando buscamos por uma chave inexistente num defaultdict, ele cria uma entrada com essa chave e atribui a ela um valor padrão, ao invés de lançar um erro. Esse valor padrão é definido no momento de criação do defaultdict.
Pra entender melhor a vantagem desse tipo especial de dicionário, imaginemos a seguinte tarefa. Temos uma lista que contém nomes de pessoas e suas respectivas cidades. Mais ou menos desse jeito aqui:
pessoas = [("João", "São Paulo - SP"), ("Paulo", "Maricá - RJ"), ("Maria", "Nilópolis - RJ"), ("Pedro", "São Paulo - SP"), ("Ana", "Maricá - RJ"), ("Pablo", "Salvador - BA"), ("Ricardo", "São Paulo – SP")]
(Imagine uma lista bem maior que essa).
Nosso trabalho é agrupar as pessoas por cidade. Então queremos uma estrutura em que podemos consultar o nome de uma cidade e receber a lista de moradores de lá. Podemos fazer isso utilizando um dicionário comum, em que as chaves são os nomes das cidades, e os valores são as listas com seus respectivos moradores.
Para alimentar o dicionário, podemos iterar sobre essa lista inicial contendo os pares pessoa/cidade e inserimos cada pessoa na entrada do dicionário correspondente a sua cidade. Assim:
cidades = dict() for pessoa, cidade in pessoas: if cidade in estados.keys(): cidades[cidade].append(pessoa) else: cidades[cidade] = [pessoa,]
No trecho acima, fazemos um loop no qual primeiro “desempacotamos” cada tupla da lista de pessoas para obter a pessoa e sua respectiva cidade em duas variáveis diferentes. Depois precisamos testar se aquela chave já existe, para saber se devemos dar um append ou criar uma lista nova.
Se não fizermos esse teste, vamos receber um KeyError quando não houver uma chave com o nome que estamos buscando no dicionário.
Funcionamento do defaultdict
Agora vejamos como funciona o defaultdict e como podemos utilizá-lo para simplificar esse código. Antes de utilizá-lo, precisamos adicioná-lo ao nosso projeto a partir do módulo collections:
from collections import defaultdict
Agora precisamos incializá-lo. Quando queremos inicializar um dicionário vazio, fazemos isso escrevendo algo como cidades = dict() ou cidades = {}. Mas quando vamos inicializar um defaultdict, precisamos passar como argumento uma função, denominada de default factory (traduzindo ao pé da letra, seria algo como uma “fábrica de valores padrão”.
Essa pode ser qualquer função desde que não precise de argumentos e tenha um valor de retorno. Pois ela será utilizada para gerar os valores padrão das entradas do dicionário quando elas não existirem.
No nosso caso, gostaríamos de uma função que retornasse uma lista vazia quando a chave não fosse localizada no dicionário. Felizmente, a função list() faz exatamente isso. Vejamos o código abaixo:
from collections import defaultdict cidades = defaultdict(list) for pessoa, cidade in pessoas: cidades[cidade].append(pessoa)
Veja como o código ficou bem mais simples. Quando o tentamos acessar uma chave inexistente com o defaultdict, ele chama a função list, cria uma lista vazia e a associa essa nova lista à chave. Por conta disso, em momento algum precisamos inicializar a lista manualmente. Assim, não precisamos mais testar a existência das chaves com o if…else a fim de evitar um KeyError.
Perceba que ao passar a função list como parâmetro, não usamos parênteses, como fazemos quando chamamos a função, assim: list(). Isso é porque quando usamos parênteses, estamos, na verdade, chamando a função e passando o seu valor de retorno como argumento. Ou seja, se fizéssemos:
cidades = defaultdict(list()) #Errado!
Estaríamos passando uma lista vazia como argumento para defaultdict(), e não a função em si.
Se precisamos inicializar o valor das chaves com listas, podemos utilizar a função list. De maneira análoga, se precisamos de conjuntos (sets), dicionários (dicts), etc., podemos usar as funções do Python que criam esses objetos. Abaixo, alguns exemplos mais comuns:
list() | Retorna uma lista vazia |
set() | Retorna um conjunto vazio |
bool() | Sem argumentos, retorna False |
int() | Sem argumentos, retorna 0 |
float() | Sem argumentos, retorna 0.0 |
string() | Sem argumentos, retorna ‘’ (a string vazia) |
Note que algumas dessas funções, como int() e bool() normalmente recebem argumentos. O int(), por exemplo, converte uma string ou número de um tipo qualquer para inteiro. Porém, quando não é passado nenhum argumento para a função, ela retorna 0.
Utilizando funções personalizadas com o defaultdict
Mas e se você quiser retornar algum outro tipo de valor? Vamos imaginar agora que você resolveu criar um programa que diz para a pessoa o significado do nome dela. Porém você é muito preguiçoso para pesquisar o significado de centenas de nomes, e resolveu colocar apenas meia dúzia de significados e dar uma resposta aleatória caso o nome não constasse no banco de dados. Poderíamos fazer algo assim:
#choice recebe uma sequência e retorna um elemento #aleatório dela from random import choice from collections import defaultdict def significado_aleatorio(): significados_aleatorios = ["Flor da manhã", "O forte imperador", "Escolhido de Deus"] return choice(significados_aleatorios) significados = defaultdict(significado_aleatorio) significados["Anderson"] = "Filho de André" significados["Heitor"] = "Aquele que guarda" significados["Beatriz"] = "A que traz felicidade" significados["Benjamim"] = "Filho da felicidade" significados["Matias"] = "Dádiva de Deus" significados["Helena"] = "A resplandecente" while True: nome = input("Qual é o seu nome?") print("O significado de", nome, "é:", significados[nome])
Exemplo de saída do programa:
Qual é o seu nome?Matias O significado de Matias é: Dádiva de Deus Qual é o seu nome?Edemerval O significado de Edemerval é: Flor da manhã Qual é o seu nome?Beatriz O significado de Beatriz é: A que traz felicidade Qual é o seu nome?Juvêncio O significado de Juvêncio é: O forte imperador
Funções lambda e sua utilidade
Nesse exemplo, você pode observar que criamos uma função apenas para ser usada como argumento do defaultdict. Esse script tem tudo pra fazer muito sucesso do jeito que está, mas podemos deixá-lo um pouquinho mais enxuto fazendo uso de um recurso do Python criado especialmente para essas situações em que usamos funções descartáveis e curtas: as funções lambda.
Se você ainda não conhece, as funções lambda fornecem uma maneira enxuta de declarar funções anônimas em Python e têm essa estrutura:
lambda <parâmetros> : <expressão de retorno>
Onde os parâmetros são realmente as variáveis que constituem os parâmetros da função, separados por vírgula, e a expressão de retorno é uma expressão válida em Python que será utilizada como valor de retorno da função.
Pra ilustrar, vejamos abaixo um exemplo executado no terminal interativo do Python:
>>> a = lambda x,y: x+y >>> a(1,2) 3 >>> a(5,6) 11
Aqui, atribuímos à variável a, uma função anônima que recebe dois números e retorna sua soma.
Também podemos criar funções lambda sem argumentos. Por exemplo:
>>> from random import choice >>> b = lambda: choice(["Flor da manhã", "O forte imperador", "Escolhido de Deus"]) >>> b <function <lambda> at 0x7f9c100a5430> >>> b() 'Flor da manhã' >>> b() 'Escolhido de Deus'
A moral da história é que podemos substituir toda a função significado_aleatorio() por uma função lambda diretamente como argumento do defaultdict. Então nosso código ficaria assim:
from random import choice from collections import defaultdict significados_aleatorios = ["Flor da manhã", "O forte imperador", "Escolhido de Deus"] significados = defaultdict(lambda: choice(significados_aleatorios)) significados["Anderson"] = "Filho de André" significados["Heitor"] = "Aquele que guarda" significados["Beatriz"] = "A que traz felicidade" significados["Benjamim"] = "Filho da felicidade" significados["Matias"] = "Dádiva de Deus" significados["Helena"] = "A resplandecente" while True: nome = input("Qual é o seu nome?") print("O significado de", nome, "é:", significados[nome])
Perceba como a combinação do uso do defaultdict e da função lambda deixam o código bem mais enxuto!
Counter versus defaultdict
Para encerrar o post, vou falar um pouco sobre outro “dicionário especializado” presente no módulo collections. Mas antes disso, vamos pensar que você foi incumbido da seguinte tarefa: contar o número de ocorrências de cada palavra em um determinado texto (coisa muito comum na área de processamento de linguagem natural). Porém, você não sabe de antemão quais as palavras ocorrem no texto.
Como você acabou de ler um artigo sobre defaultdicts em Python, você resolveu dar conta da tarefa da seguinte maneira:
from collections import defaultdict palavras = defaultdict(int) with open("texto.txt", "r") as arquivo: texto = arquivo.read() texto = texto.lower().split() for palavra in texto: palavras[palavra] += 1 for palavra, contagem in palavras.items(): print(palavra, ":", contagem)
Podemos enxergar o objeto palavras como um dicionário de contadores. A cada ocorrência de uma palavra no texto, o total de ocorrências é incrementado em um. Repare que aqui você fez uso da função int, que retorna 0 quando chamada sem argumentos.
Esse é um código legítimo, não tem nada de errado em solucionar o problema dessa forma.
Acontece que o collections fornece um tipo de contêiner projetado exatamente pra esse tipo de situação: o Counter. Este é um tipo especial de dicionário que recebe como parâmetro algum objeto iterável, como por exemplo listas (ele também pode ser inicializado de outras formas também, como você pode ver na documentação). Então ele popula o dicionário com cada elemento contido nesse iterável e sua respectiva contagem.
Então poderíamos simplificar o script acima utilizando um Counter, como exemplificado abaixo:
from collections import Counter with open("texto.txt", "r") as arquivo: texto = arquivo.read() texto = texto.lower().split() palavras = Counter(texto) for palavra, contagem in palavras.items(): print(palavra, ":", contagem)
Além de facilitar o trabalho, o Counter possui alguns métodos específicos para facilitar nossa vida, como por exemplo o most_common(). Essa função retorna uma lista com os pares elemento / contagem do dicionário, organizados por ordem de maior para menor contagem. Ela também pode receber um número como parâmetro para retornar apenas os n elementos mais frequentes.
Por exemplo:
>>> from collections import Counter >>> a = Counter([1, 10, 2, 5, 6, 6, 8, 1, 7, 45, 12]) >>> a.most_common(3) [(1, 2), (6, 2), (10, 1)]
O exemplo acima mostra que os elementos mais frequentes da lista são o 1 (que aparece duas vezes), o 6 (que aparece duas vezes) e o 10 (que é o primeiro dos números que só aparecem uma vez).
Além deste, outros dois métodos interessantes são o total(), que soma a contagem de todos os elementos do Counter (disponível da versão 3.10 pra cima), e o método elements() que traz um iterador com todos os elementos repetidos de acordo com suas contagens. Por exemplo:
>>> a = Counter([0, 3, 2, 5, 0, 3, 4, 5, 1, 0, 3]) >>> [x for x in a.elements()] [0, 0, 0, 3, 3, 3, 2, 5, 5, 4, 1]
E é isso! Espero que tenha curtido o post e continue acompanhando para saber mais sobre tipos de dados além do básico em Python. 🙂