Programação funcional
Funções livres de efeitos, ordem superior, lambda, closures, map, filter e reduce.
Funções livres de efeitos
Uma função pura devolve o mesmo resultado para os mesmos argumentos e não tem efeitos observáveis fora do cálculo. Ler a hora, pedir entrada, imprimir, alterar uma lista recebida ou atualizar estado global quebra essa condição.
sorted(notas) cria outra lista, enquanto notas.sort() altera a existente. Uma solução livre de efeitos devolve os dados transformados e deixa a apresentação para outra parte do programa.
def com_bonus(notas, bonus):
resultado = []
for nota in notas:
resultado.append(nota + bonus)
return resultado
notas = [10, 14]
print(com_bonus(notas, 2))
print(notas)
A saída é [12, 16] e [10, 14]; o ciclo calcula nota + bonus para cada entrada e constrói outra lista, como a compreensão equivalente da lição de compreensões.
Observa o contrato: cada resultado depende dos argumentos e as notas originais permanecem iguais.
Python permite misturar estilos. Se um exercício exige um estilo sem atribuições nem mutação, esse requisito é mais restrito do que a pureza. Nesse caso, usa operações sobre valores e funções de ordem superior em vez de atualizar uma coleção.
Funções são valores
O nome de uma função pode ser guardado, passado ou devolvido. quadrado é o objeto função; quadrado(3) é o resultado da chamada. Passar quadrado(3) onde se espera uma função passa o número 9 e impede a chamada posterior.
def quadrado(x):
return x * x
def aplicar_duas_vezes(funcao, x):
return funcao(funcao(x))
print(aplicar_duas_vezes(quadrado, 2))
O resultado é 16, pois 2 passa primeiro a 4 e depois a 16. Uma função que recebe ou devolve outras funções é uma função de ordem superior, e um predicado é uma função usada para decidir se um valor satisfaz uma condição, como x > 0.
Funções de ordem superior e lambda
lambda x: x * x cria uma função com uma expressão como corpo. Não é uma instrução para executar já. Usa def quando precisas de vários passos ou quando dar um nome à operação ajuda a ler.
alunos = [("Ana", 15), ("Bruno", 12), ("Carla", 15)]
ordenados = sorted(alunos, key=lambda aluno: (-aluno[1], aluno[0]))
print(ordenados)
print(alunos)Executa o código para ver a saída e os gráficos.
A chave é um tuplo: Python compara primeiro o primeiro campo, só usando o segundo em caso de empate.
| Aluno | Chave |
|---|---|
("Ana", 15) | (-15, "Ana") |
("Bruno", 12) | (-12, "Bruno") |
("Carla", 15) | (-15, "Carla") |
Como -15 é menor que -12, o negativo da nota põe as notas maiores primeiro e o nome desempata por ordem crescente; a entrada permanece igual. sorted é estável: elementos com chaves iguais conservam a ordem relativa que tinham na entrada.
Map, filter e reduce
map(f, xs) aplica f a cada elemento, enquanto filter(p, xs) conserva os elementos para os quais o predicado p dá um valor verdadeiro. Em Python 3, ambos devolvem iteradores, como os da lição de compreensões, e list materializa os resultados, por isso depois de converteres um filter em lista uma segunda conversão dá [].
reduce, de functools, combina os elementos da esquerda para a direita com um acumulador: com valor inicial z e elementos a, b, c, calcula f(f(f(z, a), b), c). Esse percurso também se chama fold à esquerda; sem valor inicial, a primeira entrada torna-se o acumulador e a sequência vazia causa TypeError.
from functools import reduce
valores = [1, 2, 3, 4, 5]
impares = filter(lambda x: x % 2 != 0, valores)
quadrados = map(lambda x: x * x, impares)
soma = reduce(lambda acumulado, x: acumulado + x, quadrados, 0)
print(soma)
print(valores)
print(reduce(lambda a, x: a + x, [], 0))Executa o código para ver a saída e os gráficos.
Segue o exemplo com valores = [1, 2, 3, 4, 5]:
| Etapa | Entrada consumida | Saída |
|---|---|---|
filter ímpares | 1, 2, 3, 4, 5 | 1, 3, 5 |
map quadrados | 1, 3, 5 | 1, 9, 25 |
reduce soma de 0 | 1, 9, 25 | 1, 10, 35 |
Os iteradores não guardam estas listas intermédias: quando reduce pede o próximo quadrado, map pede o próximo ímpar a filter, que avança na entrada até o encontrar.
A tabela separa as etapas para mostrar os valores, mas uma chamada a list(impares) antes do cálculo consumiria o filtro e deixaria a redução sem termos.
O valor inicial também define a resposta para a entrada vazia. Para somar, sum é uma alternativa direta; aqui usamos reduce para seguir o acumulador.
A ordem importa se a operação não for associativa. reduce(lambda a, x: a - x, [2, 3], 10) calcula (10 - 2) - 3, que vale 5. Não calcula 10 - (2 - 3).
Any, all e condições sobre coleções
any decide se existe pelo menos um valor verdadeiro num iterável; all
decide se todos os valores são verdadeiros. Ambas param logo que a resposta
fica determinada: any no primeiro verdadeiro, all no primeiro falso.
any([]) é False, pois não há testemunho; all([]) é True, pois não
há um elemento que viole a condição. Uma regra que também exige pelo menos
um elemento precisa de verificar essa existência separadamente.
Suponhamos que cada caixa guarda um conjunto de peças. Queremos saber se cada peça pedida existe em alguma caixa, podendo usar caixas diferentes:
caixas = [{"parafuso"}, {"porca", "anilha"}]
pedidas = ["parafuso", "porca"]
print(all(any(peca in caixa for caixa in caixas) for peca in pedidas))
A saída é True. Para parafuso há uma caixa que o contém; para porca também,
ainda que seja outra. Trocar a ordem para
any(all(peca in caixa for peca in pedidas) for caixa in caixas) pede uma
caixa que contenha todas as peças e dá False neste exemplo.
A ordem dos percursos exprime duas condições diferentes.
Se não há peças pedidas, a primeira condição é verdadeira mesmo sem caixas. Com peças pedidas e nenhuma caixa, é falsa. Usámos listas e conjuntos que permitem novos percursos; reutilizar um único iterador de caixas já consumido poderia perder candidatos na segunda peça.
Devolver funções e âmbito léxico
Uma função interior pode conservar acesso ao contexto onde foi criada. Chamamos closure a essa combinação de função e contexto.
def multiplicador(fator):
def multiplicar(x):
return fator * x
return multiplicar
dobro = multiplicador(2)
triplo = multiplicador(3)
print(dobro(5), triplo(5))Executa o código para ver a saída e os gráficos.
As duas funções conservam fatores diferentes. return multiplicar devolve a função, sem a chamar. Depois de multiplicador regressar, o fator de cada contexto continua acessível.
A closure consulta as associações dos nomes conservados. Não faz uma cópia automática dos seus valores. Este contraexemplo cria três funções que consultam o mesmo nome fator:
funcoes = [lambda x: fator * x for fator in range(1, 4)]
print([f(10) for f in funcoes])
A saída é [30, 30, 30]. Quando chamamos as funções, o percurso já acabou e fator vale 3. Com [multiplicador(fator) for fator in range(1, 4)], cada chamada cria um contexto próprio e a saída passa a [10, 20, 30].
Compor sem perder o contrato
Para compor f e g, calculamos primeiro g(x) e passamos esse resultado a f, e a função devolvida conserva ambas:
def compor(f, g):
return lambda x: f(g(x))
quadrado_depois = compor(lambda x: x * x, lambda x: x + 1)
incremento_depois = compor(lambda x: x + 1, lambda x: x * x)
print(quadrado_depois(3), incremento_depois(3))
A saída é 16 10: difere de , por isso o resultado de g tem de ser uma entrada válida para f.
Transformar, filtrar e acumular são decisões diferentes. A ordem pode mudar o resultado: elevar ao quadrado antes de filtrar os positivos conserva quadrados de valores originalmente negativos. Escreve primeiro a condição sobre os dados de entrada, depois escolhe a sequência de operações. Uma função chamada por map ou filter também deve respeitar a ausência de efeitos quando esse é o contrato do programa.
Exercícios
Passar a função ou o seu resultado
Prevê o resultado da primeira chamada e explica a falha da segunda. Não alteres as definições; corrige apenas o argumento da chamada que falha.
def f(x):
return x + 3
def aplicar_duas_vezes(funcao, x):
return funcao(funcao(x))
print(aplicar_duas_vezes(f, 2))
print(aplicar_duas_vezes(f(2), 2))Solução
A primeira chamada calcula f(f(2)), isto é, f(5), e escreve 8. Na segunda,
Python calcula f(2) antes de entrar em aplicar_duas_vezes, passando o inteiro
5 como funcao. Ao tentar executar funcao(x), lança TypeError: esse inteiro
não é chamável. Passar f em vez de f(2) entrega o objeto função e corrige a
chamada.
Confere a tua resposta:
- Obtive 8 na primeira chamada.
- Identifiquei o inteiro 5 na segunda chamada.
- Expliquei o TypeError ao tentar chamar 5.
Ordenar com um desempate
Define classificacao(alunos): recebe uma lista de pares (nome, nota) e
devolve uma lista por nota decrescente e, nos empates, nome crescente.
Conserva a entrada. Procura uma key que represente as duas regras; os testes
verificam o comportamento.
def classificacao(alunos):
return alunosVerifica o código para comparar o resultado com os testes.
Testes
Desempatar pelo nome
Código do testeprint(classificacao([("B", 10), ("C", 12), ("A", 12)]))Esperado:[('A', 12), ('C', 12), ('B', 10)]Lista vazia
Código do testeprint(classificacao([]))Esperado:[]
Notas diferentes
Código do testeprint(classificacao([("A", 0), ("B", 20), ("C", 10)]))Esperado:[('B', 20), ('C', 10), ('A', 0)]Entrada preservada
Código do testexs = [("B", 10), ("A", 12)] print(classificacao(xs)) print(xs)Esperado:[('A', 12), ('B', 10)] [('B', 10), ('A', 12)]
print(classificacao([("B", 10), ("C", 12), ("A", 12)]))[('A', 12), ('C', 12), ('B', 10)]print(classificacao([]))[]
print(classificacao([("A", 0), ("B", 20), ("C", 10)]))[('B', 20), ('C', 10), ('A', 0)]xs = [("B", 10), ("A", 12)]
print(classificacao(xs))
print(xs)[('A', 12), ('B', 10)]
[('B', 10), ('A', 12)]Pista
Uma chave em tuplo compara o primeiro campo e usa o segundo para desempatar.
Mais uma pista
Nega só a nota. O nome deve continuar crescente.
Solução
O negativo da nota coloca notas maiores primeiro. O nome conserva a ordem
crescente. sorted devolve outra lista, sem alterar alunos.
def classificacao(alunos):
return sorted(alunos, key=lambda aluno: (-aluno[1], aluno[0]))Erros frequentes
Usar reverse=True na chave completa e inverter também os nomes, ou usar sort
e alterar a entrada.
Comparar a ordem de seleção e transformação
Com valores = [-2, 1, 3], calcula a soma dos quadrados depois de filtrar os valores positivos. Compara com elevar todos ao quadrado e só depois filtrar os resultados positivos. Segue o acumulador de reduce, com inicial 0. Esta mudança de ordem também muda os elementos aceites se o filtro selecionar ímpares?
Pista
Solução
Ao filtrar positivos primeiro, aceitamos [1, 3], obtemos [1, 9] e o acumulador passa por 0, 1, 10. Ao transformar primeiro, obtemos [4, 1, 9]: os três quadrados são positivos e o acumulador passa por 0, 4, 5, 14. O valor -2, originalmente rejeitado, passou a ter quadrado positivo.
Para inteiros, x é ímpar exatamente quando x * x é ímpar. O filtro de paridade aceita os mesmos elementos nas duas ordens; neste exemplo, rejeita -2 e conserva 1 e 3. Não podemos transferir o contraexemplo do sinal para a paridade.
Confere a tua resposta:
- Obtive 10 ao filtrar positivos antes dos quadrados.
- Obtive 14 ao filtrar quadrados positivos.
- Distingui seleção por sinal de seleção por paridade.
Conservar a ordem do fold
Quanto vale reduce(lambda a, x: a - x, [2, 4], 20)?
Pista
Mais uma pista
Solução
Erros frequentes
Distinguir contextos de funções
Prevê a saída e explica por que não é [10, 20, 30]. Usa a função multiplicador(fator) da lição para obter esse resultado, sem alterar a chamada final.
funcoes = [lambda x: fator * x for fator in range(1, 4)]
print([f(10) for f in funcoes])Solução
A saída é [30, 30, 30]: as lambdas consultam o mesmo nome fator, cujo valor final é 3. A criação de uma função não congela automaticamente esse valor.
Com funcoes = [multiplicador(fator) for fator in range(1, 4)], cada chamada a multiplicador cria o seu próprio parâmetro local. As funções devolvidas conservam 1, 2 e 3 em contextos diferentes. A chamada final produz [10, 20, 30].
Confere a tua resposta:
- Previ [30, 30, 30] na primeira versão.
- Usei multiplicador para criar contextos diferentes.
- Obtive [10, 20, 30] depois da correção.
Seguir o acumulador
Define soma_quadrados_impares(valores): recebe uma lista de
inteiros e devolve a soma dos quadrados dos ímpares, incluindo os negativos.
A soma vazia é zero. Usa filter, map e reduce, sem alterar a entrada.
Para [1, 2, 3, 4, 5], indica também os estados do acumulador.
from functools import reduce
def soma_quadrados_impares(valores):
return 0Verifica o código para comparar o resultado com os testes.
Testes
Exemplo
Código do testeprint(soma_quadrados_impares([1, 2, 3, 4, 5]))Esperado:35
Vazia
Código do testeprint(soma_quadrados_impares([]))Esperado:0
Só pares
Código do testeprint(soma_quadrados_impares([2, 4]))Esperado:0
Ímpares negativos
Código do testeprint(soma_quadrados_impares([-3, -2, 1]))Esperado:10
print(soma_quadrados_impares([1, 2, 3, 4, 5]))35
print(soma_quadrados_impares([]))0
print(soma_quadrados_impares([2, 4]))0
print(soma_quadrados_impares([-3, -2, 1]))10
Pista
Filtra ímpares, eleva ao quadrado e acumula de zero.
Mais uma pista
Sem valor inicial, reduce falha na lista vazia; com inicial 0, a entrada
vazia devolve 0. Um inteiro negativo também pode ser ímpar: -3 % 2 vale 1.
Solução
O filtro dá 1, 3, 5; os quadrados dão 1, 9, 25; o acumulador passa por 1, 10 e 35.
from functools import reduce
def soma_quadrados_impares(valores):
impares = filter(lambda x: x % 2 != 0, valores)
quadrados = map(lambda x: x * x, impares)
return reduce(lambda acumulado, x: acumulado + x, quadrados, 0)Erros frequentes
Omitir o valor inicial e falhar na lista vazia; usar x % 2 == 1 and x > 0
e perder os ímpares negativos. Elevar ao quadrado conserva a paridade de
inteiros, por isso trocar essas duas etapas não é, por si só, um erro neste problema.
Distinguir cada peça de uma caixa com tudo
Define ha_pecas(pedidas, caixas): pedidas é uma lista de strings e caixas
uma lista de conjuntos de strings. Devolve True se cada peça pedida existe
em pelo menos uma caixa, podendo usar caixas diferentes. Só verificas a
existência, sem consumir quantidades: pedidos repetidos não retiram peças.
Se não há pedidos, devolve True. Usa any e all, sem alterar as entradas,
e explica por que trocar os percursos muda o problema.
def ha_pecas(pedidas, caixas):
return FalseVerifica o código para comparar o resultado com os testes.
Testes
Peças distribuídas por caixas
Código do testeprint(ha_pecas(["a", "b"], [{"a"}, {"b"}]))Esperado:True
Uma peça ausente
Código do testeprint(ha_pecas(["a", "c"], [{"a", "b"}, {"a"}]))Esperado:False
Sem pedidos nem caixas
Código do testeprint(ha_pecas([], []))Esperado:True
Pedido com caixas vazias
Código do testeprint(ha_pecas(["a"], [set()]), ha_pecas(["a"], []))Esperado:False False
Reutilizar as caixas para o pedido seguinte
Código do testeprint(ha_pecas(["b", "a", "b"], [{"a"}, {"b"}]))Esperado:True
print(ha_pecas(["a", "b"], [{"a"}, {"b"}]))True
print(ha_pecas(["a", "c"], [{"a", "b"}, {"a"}]))False
print(ha_pecas([], []))True
print(ha_pecas(["a"], [set()]), ha_pecas(["a"], []))False False
print(ha_pecas(["b", "a", "b"], [{"a"}, {"b"}]))True
Solução
Para cada peça, any procura uma caixa que a contenha. all exige que essa
procura tenha sucesso para todas as peças.
def ha_pecas(pedidas, caixas):
return all(any(peca in caixa for caixa in caixas) for peca in pedidas)Com ['a', 'b'] e [{'a'}, {'b'}], as duas procuras têm sucesso em caixas
diferentes. any(all(...)) exigiria uma só caixa com as duas peças e
responderia False. Sem pedidos, all recebe um percurso vazio e responde
True; com pedidos e sem caixas, a procura interior dá False.
Cada peça inicia um percurso novo sobre a lista de caixas. Se reutilizasses
um único iterador que já chegou à segunda caixa para encontrar "b",
poderias perder a caixa com "a" no pedido seguinte.