Programação Funcional em Python

Por que usar “apenas” funções

Autor

Enzo Shiraishi

Data de Publicação

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.

Definição

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:

  • Linguagens como Java e SmallTalk seguem o paradigma de programação orientada a objetos
  • Linguagens como C e Ada seguem o paradigma de programação estruturada
  • Linguagens como Fortran e Go seguem o paradigma de programação procedural
  • Linguagens como LISP, Haskell e OCaml seguem o paradigma de programação funcional

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.

Porque?

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.

  • Sistemas de gestão de compartilhamento de mensagens e telecomunicações feitos na linguagem Erlang, usados no WhatsApp, Facebook Messenger e em jogos multiplayer como League of Legends.
  • O modelo de identificação de posts a serem marcados como spam da rede social Facebook, feita em Haskell
  • Robôs de trading programados em OCaml para baixíssima latência por empresas de trading como Jane Street e Six Sigma.
  • Frameworks de processamento de dados como Apache Spark e Polars, que se tornam muito mais escaláveis em relação ao crescimento do volume de dados usado por serem baseados em conceitos como avaliação preguiçosa e funções puras em diversos componentes nas suas implementações.
  • O módulo functional do framework PyTorch.
  • O framework Jax, que empresas como Google e Cohere usam para treinar LLMs como o Gemini, Gemma e Aya de forma mais escalável e que já chega a ser 30% mais rápido que o PyTorch com muito menos otimizações de algoritmo.

Um pouco de história

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.

Literalmente apenas funções

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.

Funções de alta ordem

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:

yet_another_function = other_function
yet_another_function
<function __main__.other_function(function)>

Portanto, se acostume com funções que usam outras funções como parâmetros.

Sintaxe

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:

other_function(lambda parameter: f"{parameter} {parameter}")
'parameter parameter'

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) \]

Currying e 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:

print("a", "b", "c", "d", sep=", ", end="!!!\n")
print("e", "f", "g", "h", sep=", ", end="!!!\n")
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!!!
from functools import partial

# Note que partial é uma função de alta ordem, porque espera outra função
# como parâmetro
partial_print = partial(print, sep=", ", end="!!!\n")

partial_print("a", "b", "c", "d")
partial_print("e", "f", "g", "h")
a, b, c, d!!!
e, f, g, h!!!

Estruturas básicas de programação

É 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:

  • Booleanos:
    • \(\text{True} = \lambda x.(\lambda y.x)\)
    • \(\text{False} = \lambda x.(\lambda y.y)\)
  • Condicionais:
    • \(\text{if} = \lambda x.(\lambda y.(\lambda z.z ~ x ~ y))\)
    • \(\text{not} = \lambda x.(\text{if} ~ z ~ \text{False} ~ \text{True})\)
    • \(\text{and} = \lambda x.(\lambda y. ~ \text{if} ~ x ~ y ~ \text{False})\)
    • \(\text{or} = \lambda x .(\lambda y. ~ \text{if} ~ x ~ \text{True} ~ y)\)
  • Listas, tuplas e dicionários: A partir de uma tupla de dois elementos é possível criar uma tupla de tuplas com 3, e assim por diante. Do ponto de vista do compilador, é possível criar diferentes abstrações para listas, tuplas e dicionários usando a mesma estrutura com diferentes formas de acessar cada uma.
    • \(\text{tuple} = \lambda x .(\lambda y .(\lambda z . (\text{if} ~ z ~ x ~ y)))\)
    • \(\text{first} = \lambda x . x ~ \text{True}\)
    • \(\text{second} = \lambda x . x ~ \text{False}\)
  • Inteiros (Números de Church):
    • \(0 = \lambda x .(\lambda y . x)\)
    • \(1 = \lambda x .(\lambda y . x ~ y)\)
    • \(2 = \lambda x .(\lambda y . x (x ~ y))\)
    • \(+ = \lambda x .(\lambda y . x (x (x ~ y)))\)
  • Laços de repetição: Como é impossível representar um laço sem estados, o Combinador Y é usado para realizar a recursão de uma função \(y\) em cálculo lambda.
    • \(\text{Y} = \lambda x . (\lambda y . y (x ~ x)) (\lambda y . y (x ~ x))\)

Novas abstrações

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.

Tipos de dados algébricos

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:

  • Tipos constantes, como uma string específica:
type_1 = "Value_1"

# O tipo de None possui apenas o valor
# None, logo é constante
type_2 = type(None)
  • Tipos soma (sum types), ou a união de \(n\) tipos constantes:
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:

from typing import Optional

# Antes da versão 3.10
type_9 = Optional[type_2]

# A partir da versão 3.10
type_9 = type_2 | None

Tipos produto (product types), ou a união de um arranjo de \(n\) tipos soma ou constantes:

# Outras abstrações onde a ordem importa podem ser usadas
# como um tipo produto também em Python

from typing import TypedDict


class Type_10(TypedDict):
    value_1: str
    value_2: int
    value_3: type_3

E tipos recursivos, onde a definição de um tipo se auto-referencia:

# A partir da versão 3.12 é possível usar tipos recursivos
# literalmente. Antes disso, como os tipos servem apenas
# para legibilidade, basta usar uma string


class Type_11(TypedDict):
    value_1: str
    value_2: "type_7"

Note que toda estrutura de árvore ou grafo, como XML, JSON e YAML, é recursiva:

JsonType = None | int | str | bool | list["JsonType"] | dict[str, "JsonType"]

Pattern matching e 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'
unpack(True)
True
unpack(["value"])
'value'
unpack({"key": "value", "otherKey": "otherValue"})
'value'
unpack(None)
Invalid: <class 'NoneType'>

Map

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.

Implementação oficial (lazy)
list(map(lambda x: x + 2, [1, 2, 3]))
[3, 4, 5]
Implementação com avaliação ansiosa (eager)
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]
Implementação com avaliação preguiçosa (lazy)
def recursive_lazy_map(function: Callable[[T], U], items: Iterable[T]) -> Iterable[U]:
    match items:
        case [item]:
            yield function(item)
        case [item, *items]:
            yield function(item)
            yield from eager_map(function, items)


list(recursive_lazy_map(function=lambda x: x + 2, items=[1, 2, 3]))
[3, 4, 5]

Folds e 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:

from functools import reduce

reduce(lambda x, y: x - y, [3, 4, 5])
-6
reduce(lambda x, y: x - y, reversed([3, 4, 5]))
-2

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.

Outros conceitos

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:

  • Macros
  • O Design Pattern decorator
  • Closures
  • Métodos de binding em contextos
  • Binding de variáveis e funções parciais
  • Domain Specific Languages
  • List, Dict e Set Comprehensions

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.

Conclusão

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.