Tuplas e Sets: imutabilidade e unicidade

Tuplas e Sets: imutabilidade e unicidade

Tuplas e sets parecem variações da lista, mas resolvem problemas distintos: uma congela os dados, o outro garante unicidade e consulta instantânea. Veja onde cada um cabe, por que a imutabilidade da tupla é rasa e por que a ordem de um set muda a cada execução do programa.
Python

• • 20 min de leitura

Python oferece mais de uma forma de agrupar dados. Enquanto listas são flexíveis e mutáveis, existem situações em que você precisa de coleções que não mudam — ou que garantam que não haja valores duplicados. Para isso existem as tuplas e os sets.

Tuplas

Uma tupla é uma sequência imutável de valores. Uma vez criada, não pode ser alterada — nenhum elemento pode ser adicionado, removido ou modificado.

coordenadas = (10.5, -23.4)
rgb = (255, 128, 0)
pessoa = ("Ricardo", 35, "São Paulo")

# Também pode ser criada sem parênteses
ponto = 4.0, 9.0

print(type(coordenadas))  # <class 'tuple'>

Acessando elementos

O acesso é idêntico ao de listas — índices e fatiamento funcionam normalmente:

pessoa = ("Ricardo", 35, "São Paulo")

print(pessoa[0])    # Ricardo
print(pessoa[-1])   # São Paulo
print(pessoa[1:])   # (35, 'São Paulo')

Por que usar tuplas?

A imutabilidade não é uma limitação — é uma garantia. Tuplas comunicam ao leitor do código que aquele conjunto de dados não deve mudar. Além disso:

  • São criadas mais rápido: o literal (1, 2, 3) vira uma constante única no bytecode, enquanto [1, 2, 3] precisa ser montada elemento a elemento a cada execução — 8,7× de diferença na criação, medido na 3.14. Já na iteração, ao contrário do que se repete por aí, a diferença é ruído: 1,02×, e a memória é praticamente igual (8.048 contra 8.056 bytes para mil elementos)
  • Podem ser usadas como chaves de dicionário (listas não podem)
  • Protegem dados que não devem ser modificados acidentalmente
# Tupla como chave de dicionário — válido
localizacoes = {
    (40.7128, -74.0060): "Nova York",
    (-23.5505, -46.6333): "São Paulo",
}

# Lista como chave — gera TypeError
# {[1, 2]: "valor"}  # ERRO

Desempacotamento

Uma das operações mais elegantes com tuplas:

pessoa = ("Ana", 28, "Recife")
nome, idade, cidade = pessoa

print(nome)   # Ana
print(idade)  # 28

# Ignorando valores com _
primeiro, _, ultimo = (1, 2, 3)
print(primeiro, ultimo)  # 1 3

# Capturando o restante com *
a, *resto = (1, 2, 3, 4, 5)
print(a)      # 1
print(resto)  # [2, 3, 4, 5]

Tupla com um único elemento

Um erro comum ao criar tuplas de um elemento:

nao_e_tupla = (42)       # isso é apenas int com parênteses
e_tupla     = (42,)      # a vírgula define que é tupla
tambem      = 42,

print(type(nao_e_tupla))  # <class 'int'>
print(type(e_tupla))      # <class 'tuple'>

A imutabilidade é rasa

Esta é a parte que quase todo material omite, e a que mais derruba código em produção: o que a tupla congela são as referências que ela guarda, não os objetos apontados por elas. Se um dos elementos for mutável, ele continua mutável dentro da tupla.

t = (1, [2, 3])
t[1].append(4)
print(t)          # (1, [2, 3, 4]) — a tupla imutável mudou

t[1] = [9]        # TypeError: 'tuple' object does not
                  # support item assignment

Não há contradição entre as duas linhas: t[1] = [9] falha porque trocaria a referência guardada pela tupla; t[1].append(4) passa porque mexe no objeto para onde a referência aponta. É o mesmo princípio de que nome é rótulo, visto em Variáveis e Tipos de Dados, e o mesmo motivo pelo qual copiar lista com copy() produz cópia rasa, como está em Listas: criação, manipulação e métodos.

A consequência prática aparece na hora de usar a tupla como chave de dicionário. A promessa da seção anterior vale apenas se todos os elementos forem hasheáveis:

hash((1, 2))        # funciona
hash((1, [2, 3]))   # TypeError: unhashable type: 'list'

{(1, [2]): "x"}     # TypeError: cannot use 'tuple' as a dict key
                    # (unhashable type: 'list')

Sets (Conjuntos)

Um set é uma coleção não ordenada e sem elementos duplicados. É baseado na estrutura matemática de conjuntos.

linguagens = {"Python", "JavaScript", "Go", "Python", "Go"}
print(linguagens)  # {'JavaScript', 'Go', 'Python'} — duplicatas removidas

numeros = {1, 2, 3, 4, 5}
vazio = set()  # não use {} — isso cria um dicionário vazio

Como sets não têm ordem, não suportam indexação:

s = {10, 20, 30}
s[0]  # TypeError — sets não têm índice

Operações de Conjunto

A verdadeira força dos sets está nas operações matemáticas:

python_devs  = {"Ana", "Bruno", "Carla", "Diego"}
java_devs    = {"Bruno", "Diego", "Eduardo", "Flávia"}

# União — todos os elementos de ambos
print(python_devs | java_devs)
# {'Ana', 'Bruno', 'Carla', 'Diego', 'Eduardo', 'Flávia'}

# Interseção — elementos em comum
print(python_devs & java_devs)
# {'Bruno', 'Diego'}

# Diferença — em python_devs mas não em java_devs
print(python_devs - java_devs)
# {'Ana', 'Carla'}

# Diferença simétrica — em um ou outro, mas não em ambos
print(python_devs ^ java_devs)
# {'Ana', 'Carla', 'Eduardo', 'Flávia'}

Métodos de Sets

s = {1, 2, 3}

s.add(4)           # adiciona um elemento
s.update([5, 6])   # adiciona múltiplos elementos

s.remove(2)        # remove — gera KeyError se não existir
s.discard(99)      # remove — silencioso se não existir

print(3 in s)      # True — pertinência em tempo praticamente constante
print(len(s))      # quantidade de elementos

A expressão muito eficiente é vaga demais para sustentar uma decisão de projeto, então aqui vai o número. Procurando cem mil vezes por um elemento que está no fim de uma coleção de cem mil itens:

set:   0,0022 s
lista: 62,06 s

São quatro ordens de grandeza. A lista compara item por item até achar; o set calcula o hash e vai direto na posição. É por isso que vale converter uma lista para set antes de um laço que faz muitas verificações de pertinência, mesmo pagando o custo da conversão — ela se paga já nas primeiras dezenas de consultas.

O que pode entrar num set

Set e dicionário compartilham a mesma exigência: todo elemento precisa ser hasheável. Lista dentro de set não entra.

{[1, 2]}    # TypeError: cannot use 'list' as a set element
            # (unhashable type: 'list')

{(1, 2)}    # a tupla entra, porque é hasheável

E há um caso que surpreende quem valida dados: bool é subclasse de int, e True == 1. Para o set, os dois são o mesmo elemento.

print({1, True})            # {1}
print({0, False})           # {0}
print(len({1, True, 1.0}))  # 1 — o float 1.0 colapsa junto

Operador ou método: a diferença que só aparece em produção

Os operadores |, &, - e ^ exigem set dos dois lados. Os métodos equivalentes aceitam qualquer iterável — e é por isso que o código passa nos testes e quebra quando alguém passa a lista que veio do banco.

{1, 2} | [2, 3]         # TypeError: unsupported operand type(s)
                        # for |: 'set' and 'list'

{1, 2}.union([2, 3])    # {1, 2, 3} — aceita lista, tupla, gerador...

A equivalência é direta: | é union, & é intersection, - é difference e ^ é symmetric_difference. Quando a entrada não é garantidamente um set, prefira o método. Sobre como esses operadores se combinam entre si, veja Operadores e Expressões.

Verificações de subconjunto

a = {1, 2, 3}
b = {1, 2, 3, 4, 5}

print(a.issubset(b))    # True — a está contido em b
print(b.issuperset(a))  # True — b contém a
print(a.isdisjoint({6, 7}))  # True — nenhum elemento em comum

Removendo duplicatas de uma lista

Um uso clássico e elegante de sets:

emails = [
    "ana@email.com",
    "bruno@email.com",
    "ana@email.com",
    "carla@email.com",
    "bruno@email.com",
]

emails_unicos = list(set(emails))
print(emails_unicos)
# a ordem muda a cada execução do programa — veja abaixo

Atenção: a conversão para set não preserva a ordem original, e isso é mais forte do que costuma se dizer. Como o Python aleatoriza o hash de strings a cada processo, a ordem muda de uma execução para a outra. O mesmo programa, rodado cinco vezes seguidas:

['bruno@email.com', 'ana@email.com', 'carla@email.com']
['carla@email.com', 'bruno@email.com', 'ana@email.com']
['bruno@email.com', 'carla@email.com', 'ana@email.com']
['carla@email.com', 'bruno@email.com', 'ana@email.com']
['ana@email.com', 'bruno@email.com', 'carla@email.com']

A armadilha é que com inteiros pequenos a ordem costuma sair estável — set([3, 1, 2, 5, 4]) imprime {1, 2, 3, 4, 5} em toda execução — e daí nasce a crença de que set é ordenado. Não é: é coincidência de como inteiros pequenos são hasheados. Nunca escreva teste, nem saída para o usuário, que dependa da ordem de um set.

Se a ordem importa, o padrão idiomático passa por dicionário, que preserva a ordem de inserção desde a 3.7:

emails_unicos = list(dict.fromkeys(emails))
print(emails_unicos)
# ['ana@email.com', 'bruno@email.com', 'carla@email.com']
# esta ordem, sim, é garantida

frozenset: o set que não muda

O set resolve unicidade, mas é mutável — e por isso não pode ser elemento de outro set nem chave de dicionário. Quando você precisa das duas coisas ao mesmo tempo, existe o frozenset: mesma interface de consulta, sem os métodos que alteram.

{{1, 2}}               # TypeError: cannot use 'set' as a set element
                       # (unhashable type: 'set')

{frozenset({1, 2})}    # {frozenset({1, 2})} — funciona

permissoes = {
    frozenset({"ler"}):             "visitante",
    frozenset({"ler", "escrever"}): "editor",
}
print(permissoes[frozenset({"escrever", "ler"})])  # editor

Repare na última linha: a ordem em que as permissões foram escritas não importa para encontrar a chave, porque conjunto não tem ordem. É exatamente o que se quer de um conjunto de permissões — e é a mesma relação que a tupla tem com a lista, um degrau acima.

Comparação: Lista, Tupla e Set

Característica Lista Tupla Set
Ordenada ✅ ✅ ❌
Mutável ✅ ❌ ✅
Permite duplicatas ✅ ✅ ❌
Indexável ✅ ✅ ❌
Hasheável (usável como chave) ❌ ✅ * ❌ **

* A tupla só é hasheável se todos os seus elementos também forem: (1, 2) serve de chave, (1, [2]) não.
** O set não serve, mas o frozenset serve — é para isso que ele existe.

Exemplo Completo: Análise de Participação

presenca_semana1 = {"Ana", "Bruno", "Carla", "Diego", "Eduardo"}
presenca_semana2 = {"Bruno", "Carla", "Flávia", "Gustavo", "Ana"}
presenca_semana3 = {"Ana", "Carla", "Bruno", "Henrique"}

# Alunos presentes nas três semanas
sempre_presentes = presenca_semana1 & presenca_semana2 & presenca_semana3
print(f"Presentes nas 3 semanas: {sempre_presentes}")

# Todos os alunos que apareceram ao menos uma vez
todos = presenca_semana1 | presenca_semana2 | presenca_semana3
print(f"Total de alunos únicos: {len(todos)}")

# Alunos que faltaram na semana 3
faltaram = todos - presenca_semana3
print(f"Ausentes na semana 3: {faltaram}")

# Registrando datas como tuplas imutáveis
registros = [
    ("Ana",    (2024, 3, 1)),
    ("Bruno",  (2024, 3, 1)),
    ("Carla",  (2024, 3, 8)),
]

for aluno, data in registros:
    ano, mes, dia = data
    print(f"{aluno} — {dia:02d}/{mes:02d}/{ano}")

Tuplas e sets resolvem problemas diferentes, e a escolha entre eles quase nunca é questão de gosto. A tupla serve quando o conjunto de valores não deve mudar e quando ele precisa virar chave de dicionário — com as duas ressalvas que este artigo mediu: a imutabilidade é rasa, e a chave só funciona se todos os elementos forem hasheáveis. O set serve quando a pergunta é de pertinência ou de unicidade, e aí o ganho não é estético: consultar um set de cem mil itens custou quatro ordens de grandeza menos do que percorrer a lista equivalente.

O preço do set é a ordem, que ele não tem e não finge ter — a mesma coleção sai numa sequência diferente a cada execução do programa, e depender disso é um defeito esperando a hora de aparecer. Quando unicidade e ordem são exigidas juntas, o caminho é dict.fromkeys; quando o conjunto precisa ser imutável para virar chave ou elemento de outro conjunto, é frozenset. Entre a lista que muda, a tupla que não muda e o set que não repete, a estrutura certa costuma ser a que torna o erro impossível, não a que deixa o código mais curto.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um sistema guarda os parâmetros de cobrança numa tupla, com o comentário de que assim ninguém altera por engano. Meses depois, a lista de feriados que estava dentro dessa tupla aparece com dois itens a mais em produção, e o código que fez isso não recebeu nenhuma reclamação do Python. Explique como isso é possível e diga o que a tupla, afinal, protegeu.

Ver resposta

✓ Resposta: A imutabilidade da tupla é rasa: o que ela congela são as referências que guarda, não os objetos apontados por elas. Com config = (0.05, ["01-01", "25-12"]), a atribuição config[1] = [] levanta TypeError: 'tuple' object does not support item assignment, porque trocaria a referência — mas config[1].append("07-09") passa sem um ruído, porque mexe no objeto para onde a referência aponta, e esse objeto é uma lista, que é mutável. A tupla protegeu a estrutura, não o conteúdo: garante que continuam sendo dois campos, na mesma ordem, apontando para os mesmos objetos. Nada além disso. A consequência mais visível aparece no dicionário: hash((1, 2)) funciona, e hash((1, [2, 3])) levanta TypeError: unhashable type: 'list', de modo que a promessa de que tupla serve como chave vale apenas quando todos os elementos também são imutáveis. Para congelar de verdade, a lista de dentro precisa virar tupla — (0.05, ("01-01", "25-12")) — e aí a estrutura inteira passa a ser hasheável. Vale desconfiar do argumento de que a tupla foi escolhida por desempenho: medido na 3.14, criar o literal é cerca de 8,7 vezes mais rápido que o da lista, porque a tupla é uma constante única no bytecode, mas percorrer uma e outra dá praticamente o mesmo número, 1,02 vezes, e a memória é igual a menos de dez bytes em mil elementos. Tupla se escolhe pela garantia que ela comunica, não pelo cronômetro.

Exercício 2

Um relatório diário lista os clientes únicos do dia com list(set(nomes)). O teste automatizado compara a saída com uma lista fixa e passou por semanas. Hoje ele começou a falhar de forma intermitente: às vezes passa, às vezes não, sem ninguém ter tocado no código. O que está acontecendo, e por que só agora?

Ver resposta

✓ Resposta: Set não tem ordem, e no caso de strings isso é ainda mais forte do que parece: o Python aleatoriza a semente de hash a cada processo, como defesa contra ataques de colisão, e por isso a ordem em que os elementos saem muda de uma execução do programa para a outra. Rodando cinco vezes o mesmo list(set(emails)) com três endereços, saíram quatro ordens diferentes. O teste nunca esteve correto; ele vinha passando por sorte, e provavelmente começou a falhar quando o conjunto de nomes mudou de tamanho e as colisões passaram a cair em posições diferentes. Fixar PYTHONHASHSEED faz a ordem voltar a ser estável e é exatamente a correção errada: esconde o defeito e ainda desliga a proteção. O que engana muita gente é que com inteiros pequenos a ordem costuma sair estável — set([3, 1, 2, 5, 4]) imprime {1, 2, 3, 4, 5} em toda execução, porque o hash de um inteiro pequeno é ele mesmo — e daí nasce a crença de que set é ordenado. Há duas saídas, conforme o que se quer: se a ordem precisa ser a de primeira aparição, list(dict.fromkeys(nomes)) resolve, porque dicionário preserva ordem de inserção desde a 3.7; se o relatório deve sair em ordem alfabética, sorted(set(nomes)) diz isso explicitamente. Em qualquer dos casos, o teste deve comparar set com set, ou uma lista ordenada com outra — nunca a saída crua de um set.

Exercício 3

Uma rotina recebe identificadores vindos de duas origens e os junta para remover repetidos: ids = {1, 2, 3} da primeira, e da segunda chega True, porque um campo booleano foi lido na mesma coluna. O time esperava quatro elementos no set final e encontrou três, sem erro nenhum. Explique.

Ver resposta

✓ Resposta: Em Python, bool é subclasse de int: isinstance(True, int) devolve True, e True == 1. Como set e dicionário decidem identidade por hash e igualdade, e hash(True) == hash(1), os dois são literalmente o mesmo elemento para essas estruturas. Daí {1, True} resultar em {1}, e {0, False} em {0}. O efeito não para no booleano: len({1, True, 1.0}) é 1, porque o float 1.0 também é igual a 1 e colapsa junto. Qual dos três sobrevive depende de quem entrou primeiro, já que o set mantém o elemento que já estava e descarta o novo — o que significa que o tipo do que ficou no conjunto depende da ordem de inserção, um detalhe traiçoeiro quando esse valor é usado adiante. O mesmo vale para chaves de dicionário: {1: 'a'}[True] devolve 'a'. A correção não é no set, é na fronteira: identificador é dado de domínio e precisa ser convertido e validado na entrada, com int() explícito e uma checagem que rejeite booleano — lembrando que isinstance(valor, int) sozinho aceita True de bom grado, e por isso é preciso testar type(valor) is int ou barrar bool antes. Vale ainda notar que {[1, 2]} nem chega a ser aceito: levanta TypeError: cannot use 'list' as a set element, porque todo elemento de set precisa ser hasheável.

Exercício 4

Uma função calcula quais permissões faltam a um usuário com necessarias - usuario.permissoes. Passa em todos os testes, onde as permissões são escritas como {"ler", "escrever"}. Em produção, o objeto vem do banco e o campo chega como lista. A função quebra. Diga qual é o erro e qual a correção mais robusta.

Ver resposta

✓ Resposta: O erro é TypeError: unsupported operand type(s) for -: 'set' and 'list'. Os operadores de conjunto — |, &, - e ^ — exigem set dos dois lados, e recusam qualquer outro iterável. Os métodos equivalentes não: union, intersection, difference e symmetric_difference aceitam lista, tupla, gerador, qualquer coisa percorrível. Por isso {1, 2} | [2, 3] levanta TypeError enquanto {1, 2}.union([2, 3]) devolve {1, 2, 3} tranquilamente. Esse é um defeito de fronteira clássico, e a assimetria entre teste e produção é o que o torna invisível: no teste alguém escreve o literal com chaves e obtém um set; em produção o dado atravessa serialização — JSON não tem tipo conjunto, então toda viagem por JSON devolve lista. Trocar o operador pelo método faz a função parar de quebrar, e é a correção certa quando a função é uma fronteira que aceita o que vier. A correção mais robusta, porém, é normalizar na entrada: converter para set logo ao ler do banco, de modo que o tipo esteja garantido em todo o resto do código e os operadores voltem a ser seguros e legíveis. A regra prática: use o método quando não puder confiar no tipo que chega, e o operador depois de ter normalizado — nunca deixe a escolha ao acaso do caminho que o dado percorreu.

Exercício 5

Um script cruza duas bases: para cada um dos cem mil registros da primeira, verifica se o documento aparece na segunda, que também tem cem mil e está numa lista. O script leva mais de um minuto. Um colega sugere trocar a lista por um set. Estime o ganho e explique de onde ele vem. Depois diga o que fazer se a chave de busca precisar ser um conjunto de campos, e não um valor só.

Ver resposta

✓ Resposta: O ganho é de quatro ordens de grandeza. Medindo cem mil buscas por um elemento que está no fim de uma coleção de cem mil itens, a lista levou 62,06 s e o set, 0,0022 s — cerca de 28 mil vezes mais rápido. A razão é estrutural: x in lista compara item por item até achar, e portanto o custo cresce junto com o tamanho; x in set calcula o hash de x e vai direto à posição, com custo praticamente constante seja qual for o tamanho. Converter a segunda base para set custa uma passada única sobre ela, e esse custo se paga já nas primeiras dezenas de consultas — com cem mil, nem entra na conta. Vale o alerta de que o ganho só existe para pertinência, unicidade e operações de conjunto; se o código precisa de ordem ou de índice, o set não serve, porque não tem nem um nem outro. Para a segunda parte: se a chave é um conjunto de campos, a escolha depende de a ordem importar. Sendo uma combinação fixa e ordenada, como CPF mais data, o natural é a tupla, que é hasheável e serve de chave direto. Sendo um conjunto de verdade, em que a ordem não deve contar — um jogo de permissões, um grupo de etiquetas —, aí é frozenset, a versão imutável do set: {{1, 2}} levanta TypeError: cannot use 'set' as a set element, mas {frozenset({1, 2})} funciona, e permissoes[frozenset({"escrever", "ler"})] encontra a chave gravada como frozenset({"ler", "escrever"}), porque conjunto não tem ordem.

Comentários

Mais em Python

Strings em Profundidade: métodos, formatação e expressões regulares
Strings em Profundidade: métodos, formatação e expressões regulares

Texto é o dado que mais chega torto. Além do repertório de métodos, formatação…

Dominando o Python
Dominando o Python

A série começa pelo terreno: o que Python é hoje, onde a linguagem de fato…

Estruturas de Controle: if, elif e else
Estruturas de Controle: if, elif e else

Decidir é o que separa um script de um programa. O if, o elif e o else, a…