def function(parameter):
return f"{parameter} {parameter}"
def other_function(function):
return function("parameter")
other_function(function)
'parameter parameter'
Por que usar “apenas” funções
Enzo Shiraishi
8 de janeiro de 2025
Nesse tutorial, é feita uma introdução aos principais conceitos de programação funcional, com exemplos práticos de como aplicar esses conceitos em Python, além de algumas considerações sobre o paradigma na linguagem.
Programação funcional é um paradigma de programação, ou seja, um princípio a seguir quando estiver desenvolvendo algum código em algum escopo específico.
Linguagens de programação podem ser desenvolvidas seguindo um paradigma específico que define quais funcionalidades serão adicionadas (ou não) à linguagem. Por exemplo:
Já linguagens como Python, JavaScript, C++, Scala e Rust são linguagens multiparadigma (embora existam argumentos para chamar diversas das linguagens acima de multiparadigma), combinando elementos de diferentes paradigmas simultaneamente para melhorar a experiência de desenvolvimento.
Embora a maioria dos paradigmas crie abstrações que permitem organizar a mesma lógica de programação de formas diferentes, sem alterar a lógica de um algoritmo, programação funcional introduz uma abordagem diferente, onde a lógica de um programa é alterada para que possam ser usadas menos abstrações na definição de um programa. Isso acontece pelos princípios que baseiam o paradigma em relação aos demais:
Existem apenas funções e tipos. Todas as outras abstrações são construídas do zero usando apenas funções.
Ao invés de criar abstrações, linguagens puramente funcionais adicionam restrições ao seu código. Como veremos mais à frente, isso significa que não existem variáveis nessas linguagens, por exemplo, logo toda variável é constante e pode ser representada como um tipo. O principal motivo disso é a garantia da imutabilidade do código:
Toda função é pura, ou seja: * Toda função é determinística, logo, uma entrada sempre gerará a mesma saída * Não existem efeitos colaterais como variáveis ou recursos externos à função * Não existe nenhum estado oculto que possa afetar uma função * Não existe nenhuma forma de uma função ler ou escrever dados fora do seu escopo
Por exemplo, toda função matemática é pura. Porém, funções como open
e print
em Python não são puras.
Embora possa parecer que as restrições de linguagens puramente funcionais sirvam apenas para atrapalhar o desenvolvimento, o uso dessas restrições gera benefícios no código final que podem ser importantes para escrever um código de mais qualidade, que garante propriedades importantes.
Garantir que todas as funções de um código são imutáveis significa que um código não possui efeitos colaterais, ou seja, uma função não pode afetar o comportamento da outra se elas não interagem entre si. Esse comportamento é importante, por exemplo, em cenários de concorrência, onde um recurso está sendo dividido entre múltiplos processos. O compartilhamento desse recurso faz com que um processo possa afetar o resultado de outro, gerando efeitos colaterais indesejados à lógica de uma função que frequentemente são impossíveis de lidar.
Processos baseados apenas em funções puras tem garantia de que podem ser paralelizados efetivamente. É por isso que projetos que visam aumentar a eficiência de sistemas frequentemente empregam o uso de programação funcional em funcionalidades críticas em relação à necessidade de paralelização ou concorrência, como por exemplo:
Além disso, por usar muito o conceito de recursão, funciona muito bem com estruturas de dados que usam avaliação preguiçosa para carregar dados para memória maiores que a capacidade disponível em um computador ao realizar computações carregando apenas os elementos necessários a cada chamada de função.
functional
do framework PyTorch.Na teoria da computabilidade (que define quais funções matemáticas podem ser computadas ou não), na década de 1930, o matemático Alonzo Church propôs o cálculo lambda, um sistema que mostra, de forma resumida, que:
Tudo que é computável pode ser definido usando apenas funções.
Posteriormente, seu orientando de doutorado, Alan Turing, propôs as Máquinas de Turing, um sistema que mostra, de forma resumida, que:
Tudo que é computável pode ser definido usando apenas autômatos.
Em 1938, os dois sistemas foram definidos no Teorema de Alonzo-Church, que, de forma resumida, diz que:
Toda função computável pode ser definida por uma Máquina de Turing e vice versa.
Embora explicar o significado desses conceitos esteja além do escopo, todo algoritmo que pode ser executado em um computador pode ser definido através de uma Máquina de Turing. Portanto, pode ser descrito usando apenas funções. É esse sistema que baseia a programação funcional, diferentemente de outros paradigmas.
O cálculo lambda define que todas as abstrações necessárias para descrever um algoritmo podem ser representadas usando apenas funções. Logo, inicialmente só existem funções. Isso inclui que temos que aprender a representar os termos mais básicos de uma linguagem de programação usando funções.
Antes de definir abstrações usando funções, perceba que se não existem valores, apenas funções, os parâmetros de uma função têm que ser funções também. Isso dá origem à primeira propriedade do paradigma funcional: não há diferença entre funções e valores conceitualmente.
Não falamos sobre a linguagem Python antes, mas essa é uma característica funcional que a linguagem já herda. Apesar da diferença de sintaxe, em Python, atribuir uma função a uma variável ou definir uma nova função nomeada são coisas equivalentes. É como se o nome da função fosse o nome da variável, e o valor armazenado na variável fosse a lógica descrita na função.
Portanto, é possível usar uma função \(F\) como parâmetro de outra função \(G\), e ainda usar a lógica de \(F\) na lógica de \(G\). Por exemplo:
def function(parameter):
return f"{parameter} {parameter}"
def other_function(function):
return function("parameter")
other_function(function)
'parameter parameter'
Note que other_function
espera uma função como parâmetro, e ainda chama o parâmetro como uma função na sua lógica. other_function
é conhecido como uma função de alta ordem por esse comportamento. Você também pode tratar ambas as funções como se fossem variáveis se necessário:
<function __main__.other_function(function)>
Portanto, se acostume com funções que usam outras funções como parâmetros.
Como serão usadas muitas funções, o cálculo lambda geralmente usa uma sintaxe alternativa para funções. Por exemplo:
\[ f(x) = x \iff \lambda x.x \]
Você talvez já tenha visto uma sintaxe parecida com essa em Python, que se inspira nessa sintaxe:
No caso do cálculo lambda, como não existem variáveis, funções são separadas por parênteses para tornar a sintaxe mais legível. Essa função pode não parecer fazer muito sentido agora, mas veja que os parênteses têm o mesmo significado nas duas sintaxes, mas são bem menos necessários no cálculo lambda:
\[ f(x) = x(x(x)) \iff \lambda x.x(x ~ x) \]
functools.partial
Para lidar com funções com diversos parâmetros, funções de alta ordem são usadas para retornar uma nova função que espera um parâmetro a menos até que todos os parâmetros estejam definidos:
\[ f(x,y) = x(y) \iff \lambda x.(\lambda y.xy) \]
Essa abordagem é chamada de currying (em homenagem ao matemático Haskell Curry Brooks), e tem uma utilidade em linguagens funcionais: é possível criar uma função parametrizável, mas criar uma segunda função que vincula alguns valores a uma função. Esse padrão é muito parecido com a definição de uma classe, onde de forma inversa, em um método, uma função é vinculada a alguns valores.
Como Python não é uma linguagem puramente funcional (ou seja, que é baseada puramente em cálculo lambda), não é possível usar currying diretamente, porém, é possível realizar isso usando a biblioteca nativa functools
, que permite usar diversas técnicas de programação funcional na linguagem:
a, b, c, d!!!
e, f, g, h!!!
# Equivalente a essa classe (de forma muito mais simples)
class Printer:
def __init__(self, sep, end):
self.sep = sep
self.end = end
def print(self, *args):
print(*args, sep=self.sep, end=self.end)
printer = Printer(sep=", ", end="!!!\n")
printer.print("a", "b", "c", "d")
printer.print("e", "f", "g", "h")
a, b, c, d!!!
e, f, g, h!!!
É possível representar todos os tipos de dados e estruturas básicas de um programa usando apenas funções também. Aqui vai um breve resumo de como construir as estruturas mais comuns em linguagens de programação:
Ao ver algoritmos pela ótica da programação funcional, é possível criar novas abstrações para simplificar algoritmos. Embora essas abstrações sejam baseadas apenas em funções, elas ainda são úteis além das linguagens puramente funcionais, e recomendo que considere usar algumas delas no seu dia-a-dia em Python, por exemplo.
Essa forma de representação de dados usa a noção de que todo tipo de dados pode ser representado como um conjunto de constantes que pertencem àquele tipo para criar novos tipos, evitando confusões envolvedo tipos e tornando o código mais legível e simples.
Através de type hints, disponíveis desde o Python 3.5, Python implementa tipos de dados algébricos à sua maneira, que ilustram esse conceito aqui.
Em tipos de dados algébricos, todos os dados são compostos por:
from typing import Literal, Union
type_3 = Literal["Value_1", "Value_2", "Nothing"]
# o conjunto dos inteiros é um tipo soma
type_4 = int
# o conjunto das strings também
type_5 = str
# e o dos booleanos
type_6 = bool
# Antes da versão 3.10
type_7 = Union[int, str, type_2]
# A partir da versão 3.10
type_8 = int | str | type_2
Note que valores que podem ser None
, por definição, sempre são tipos soma:
Tipos produto (product types), ou a união de um arranjo de \(n\) tipos soma ou constantes:
E tipos recursivos, onde a definição de um tipo se auto-referencia:
Note que toda estrutura de árvore ou grafo, como XML, JSON e YAML, é recursiva:
match ... case
Tipos de dados algébricos podem ser usados juntamente com condicionais para criar lógicas de execução de funções similares à fluxogramas ou a grafos acíclicos direcionados (DAGs) usando pattern matching, que usa a estrutura de valores de um tipo algébrico para determinar o que será executado ou não.
Em Python, desde a versão 3.10, isso é implementado usando match ... case
. O resultado pode ficar mais simples que encadear diversos if ... else
e é mais potente que o switch ... case
de outras linguagens que não usam pattern matching:
def unpack(value: str | int | bool | float | list[str] | tuple[str, str]) -> str:
match value:
case [item, *_]:
return item
case (item, *_):
return item
# Funciona apenas com TypedDict
case {"key": item, "otherKey": _}:
return item
case str() | int() | bool() as item:
return item
case _:
print(f"Invalid: {type(value)}")
unpack("value")
'value'
A função map
permite aplicar uma função sobre uma estrutura de dados iterável e gerar outra estrutura de dados iterável com o resultado dessas funções.
Além da organização gerada pelo uso desse método, se a função usada for pura, é possível executar cada chamada de função paralelamente com segurança.
from typing import TypeVar, Callable, Iterable
T = TypeVar("T")
U = TypeVar("U")
def eager_map(function: Callable[[T], U], items: list[T]) -> list[U]:
match items:
case []:
return []
case [item, *items]:
return [function(item), *eager_map(function, items)]
eager_map(function=lambda x: x + 2, items=[1, 2, 3])
[3, 4, 5]
functools.reduce
Folds permitem combinar elementos de uma estrutura de dados iterável usando uma função acumuladora de diferentes formas em um único elemento.
Mesmo sem estado, como certas funções podem gerar resultados diferentes a partir de diferentes ordens de combinação, para controlar esse comportamento, linguagens puramente funcionais como Haskell oferecem diferentes folds, como foldl
e foldr
, que são equivalentes a:
A combinação de funções de transformação e combinação de elementos de estruturas iteráveis de forma eficiente é uma peça fundamental para aplicações que requerem o uso intenso de dados, como é o caso de ferramentas de big data.
É a partir dessa ideia que surgiu o MapReduce, um motor de analytics usado para realizar consultas em fontes de dados imensas de forma escalável muito popular e que é baseado nesse paradigma, e posteriormente, outros projetos baseadas no mesmo paradigma, como o Apache Spark.
Existem diversos outros conceitos de programação funcional que baseiam outros conceitos importantes de linguagens programação modernas baseados em conceitos mais abstratos envolvendo programação funcional, como functores, monoides e mônadas. Além da avaliação preguiçosa, mencionada anteriormente, podemos citar:
No caso de Python, o módulo functools
mencionado anteriormente tem diversas funcionalidades úteis que aproveitam programação funcional para criar melhores funcionalidades multiparadigma, como:
lru_cache
cached_property
wraps
O módulo operator
permite tratar operações comuns como funções, permitindo usá-las com funções de alta ordem.
Finalmente, o módulo itertools
oferece muitas funções de alta ordem e receitas na sua documentação que permitem trabalhar com estruturas iteráveis de forma preguiçosa, combinando os três elementos.
O objetivo desse tutorial não é convencer ninguém a abandonar linguagens não-puramente funcionais, já que por definição, características muito importantes de um algoritmo se tornam impossíveis. Porém, conhecer outro paradigma ajuda a melhorar seu código no seu paradigma favorito, ajudando a evitar os viéses causados por conhecer apenas uma forma de fazer as coisas.
Como toda linguagem possui funções, considere usar algumas dessas técnicas quando estiver programando, com o objetivo de criar um código mais simples e organizado misturando diferentes paradigmas.