Programmation fonctionnelle#
Paradigme fonctionnel en Python#
La programmation fonctionnelle est un paradigme dans lequel les programmes sont construits principalement par la composition de fonctions pures — des fonctions dont le résultat dépend uniquement de leurs entrées et qui ne modifient aucun état externe. Ce style, incarné par des langages comme Haskell, Erlang ou Clojure, repose sur trois piliers : la pureté des fonctions, l”immuabilité des données et l”absence d’effets de bord.
Python n’est pas un langage fonctionnel pur : il est résolument multi-paradigme. On peut y mélanger librement le style impératif, orienté objet et fonctionnel au sein d’un même programme. Cette flexibilité est une force : on adopte le style fonctionnel là où il apporte de la lisibilité et de la fiabilité — notamment dans le traitement de données, les pipelines de transformations, les callbacks et les utilitaires génériques — sans s’y contraindre partout.
Guido van Rossum lui-même a intégré des constructions fonctionnelles dans Python (les compréhensions de listes, les générateurs, map, filter, reduce, les expressions lambda) tout en décourageant leur abus au profit de la lisibilité. La bibliothèque standard propose trois modules clés qui organisent l’outillage fonctionnel : functools, itertools et operator. Ce chapitre les explore en détail.
Définition 34 (Fonction pure)
Une fonction pure est une fonction qui satisfait deux propriétés :
Déterminisme : pour les mêmes arguments, elle retourne toujours le même résultat.
Absence d’effets de bord : elle ne modifie aucune variable externe, ne fait pas d’I/O, ne lève pas d’exception conditionnelle à un état global.
Les fonctions pures sont faciles à tester (pas de mock nécessaire), à raisonner (le résultat ne dépend que des entrées) et à mémoïser (on peut mettre le résultat en cache).
functools#
Le module functools regroupe des outils de haut niveau pour travailler avec des fonctions comme des objets de première classe.
reduce#
functools.reduce(func, iterable, initializer) applique une fonction binaire cumulativement à un itérable pour le réduire à une seule valeur. C’est l’équivalent du fold des langages fonctionnels.
from functools import reduce
import operator
# Produit de tous les éléments d'une liste
nombres = [1, 2, 3, 4, 5]
produit = reduce(operator.mul, nombres, 1)
print(produit) # 120
# Construction d'un dictionnaire par fusion
dicts = [{"a": 1}, {"b": 2}, {"c": 3}]
fusionne = reduce(lambda acc, d: {**acc, **d}, dicts, {})
print(fusionne) # {'a': 1, 'b': 2, 'c': 3}
120
{'a': 1, 'b': 2, 'c': 3}
partial#
functools.partial crée une nouvelle fonction en fixant partiellement des arguments d’une fonction existante. C’est l”application partielle, qui permet de spécialiser une fonction générale.
from functools import partial
def puissance(base, exposant):
return base ** exposant
carre = partial(puissance, exposant=2)
cube = partial(puissance, exposant=3)
print(carre(5)) # 25
print(cube(3)) # 27
# Très utile avec sorted, map, etc.
from functools import partial
ajouter_prefixe = partial("{}{}".format, ">>> ")
mots = ["alpha", "beta", "gamma"]
print(list(map(ajouter_prefixe, mots)))
# ['>>> alpha', '>>> beta', '>>> gamma']
25
27
['>>> alpha', '>>> beta', '>>> gamma']
lru_cache et cache#
functools.lru_cache(maxsize=128) est un décorateur qui mémoïse les résultats d’une fonction pour les maxsize derniers appels distincts (Least Recently Used). functools.cache (Python 3.9+) est équivalent à lru_cache(maxsize=None) — pas de limite de taille.
from functools import lru_cache, cache
import time
@cache
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
debut = time.perf_counter()
print(fibonacci(50)) # 12586269025
duree = time.perf_counter() - debut
print(f"Calculé en {duree*1000:.3f} ms")
# Infos sur le cache
print(fibonacci.cache_info())
12586269025
Calculé en 0.165 ms
CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)
cached_property#
functools.cached_property transforme une méthode en propriété calculée une seule fois et mise en cache dans l’instance.
from functools import cached_property
class CercleGeometrique:
def __init__(self, rayon: float):
self.rayon = rayon
@cached_property
def aire(self) -> float:
import math
print(" (calcul de l'aire...)")
return math.pi * self.rayon ** 2
c = CercleGeometrique(5.0)
print(c.aire) # (calcul de l'aire...) 78.539...
print(c.aire) # Pas de nouveau calcul — valeur en cache
(calcul de l'aire...)
78.53981633974483
78.53981633974483
singledispatch#
functools.singledispatch implémente la dispatch simple (sélection de l’implémentation selon le type du premier argument), à la façon d’une surcharge de fonctions.
from functools import singledispatch
@singledispatch
def afficher(valeur):
print(f"Valeur générique : {valeur!r}")
@afficher.register(int)
def _(valeur: int):
print(f"Entier : {valeur:,}")
@afficher.register(list)
def _(valeur: list):
print(f"Liste de {len(valeur)} éléments : {valeur}")
afficher(42)
afficher([1, 2, 3])
afficher("bonjour")
Entier : 42
Liste de 3 éléments : [1, 2, 3]
Valeur générique : 'bonjour'
itertools#
Le module itertools fournit des itérateurs de haute performance, inspirés des langages fonctionnels comme APL, Haskell et SML. Tous ses outils travaillent en flux : ils ne matérialisent jamais l’intégralité des données en mémoire.
import itertools
# accumulate : sommes cumulées (ou autre opération)
cumulees = list(itertools.accumulate([1, 2, 3, 4, 5]))
print("accumulate:", cumulees) # [1, 3, 6, 10, 15]
produits_cumules = list(itertools.accumulate(
[1, 2, 3, 4, 5], lambda a, b: a * b
))
print("produits cumulés:", produits_cumules) # [1, 2, 6, 24, 120]
# takewhile / dropwhile : prendre / ignorer selon un prédicat
nombres = [2, 4, 6, 7, 8, 10]
print("takewhile pair:", list(itertools.takewhile(lambda x: x % 2 == 0, nombres)))
# [2, 4, 6]
print("dropwhile pair:", list(itertools.dropwhile(lambda x: x % 2 == 0, nombres)))
# [7, 8, 10]
# filterfalse : inverse de filter
print("impairs:", list(itertools.filterfalse(lambda x: x % 2 == 0, range(10))))
# [1, 3, 5, 7, 9]
# starmap : map avec déballage de tuples
paires = [(2, 3), (4, 2), (5, 1)]
print("starmap puissance:", list(itertools.starmap(pow, paires)))
# [8, 16, 5]
# chain : concaténer plusieurs itérables
print("chain:", list(itertools.chain("ABC", [1, 2], (True,))))
# ['A', 'B', 'C', 1, 2, True]
# islice : découper un itérateur
infini = itertools.count(10, 2) # 10, 12, 14, ...
print("islice:", list(itertools.islice(infini, 5)))
# [10, 12, 14, 16, 18]
accumulate: [1, 3, 6, 10, 15]
produits cumulés: [1, 2, 6, 24, 120]
takewhile pair: [2, 4, 6]
dropwhile pair: [7, 8, 10]
impairs: [1, 3, 5, 7, 9]
starmap puissance: [8, 16, 5]
chain: ['A', 'B', 'C', 1, 2, True]
islice: [10, 12, 14, 16, 18]
Remarque 33
La documentation officielle de itertools inclut une section « Recettes » qui propose des combinaisons prêtes à l’emploi : pairwise, batched, flatten, grouper, sliding_window, etc. Depuis Python 3.10, itertools.pairwise et depuis Python 3.12, itertools.batched sont intégrés directement au module. Ces recettes illustrent parfaitement la puissance de la composition d’itérateurs simples.
operator#
Le module operator expose les opérateurs Python standard sous forme de fonctions, ce qui permet de les passer à des fonctions d’ordre supérieur sans recourir à des lambdas verbeux.
import operator
# Opérations arithmétiques
print(operator.add(3, 4)) # 7
print(operator.mul(3, 4)) # 12
print(operator.floordiv(10, 3)) # 3
# itemgetter : accès à des clés/indices multiples
donnees = [
{"nom": "Alice", "age": 30, "score": 95},
{"nom": "Bob", "age": 25, "score": 87},
{"nom": "Clara", "age": 28, "score": 92},
]
par_score = sorted(donnees, key=operator.itemgetter("score"), reverse=True)
for d in par_score:
print(f" {d['nom']}: {d['score']}")
# attrgetter : accès à des attributs (avec chaînage possible)
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
points = [Point(3, 1), Point(1, 4), Point(2, 2)]
par_x = sorted(points, key=operator.attrgetter("x"))
print(par_x) # [Point(x=1, y=4), Point(x=2, y=2), Point(x=3, y=1)]
7
12
3
Alice: 95
Clara: 92
Bob: 87
[Point(x=1, y=4), Point(x=2, y=2), Point(x=3, y=1)]
Fonctions d’ordre supérieur#
Une fonction d’ordre supérieur est une fonction qui prend d’autres fonctions en argument ou en retourne une. Python en possède plusieurs natives.
# map : appliquer une fonction à chaque élément
carres = list(map(lambda x: x**2, range(1, 6)))
print("map:", carres) # [1, 4, 9, 16, 25]
# filter : garder les éléments satisfaisant un prédicat
pairs = list(filter(lambda x: x % 2 == 0, range(10)))
print("filter:", pairs) # [0, 2, 4, 6, 8]
# sorted avec key : tri par critère personnalisé
mots = ["banane", "Pomme", "cerise", "ananas"]
par_longueur = sorted(mots, key=len)
print("par longueur:", par_longueur)
insensible = sorted(mots, key=str.lower)
print("insensible à la casse:", insensible)
# Composition de fonctions
def composer(*fonctions):
"""Compose f ∘ g ∘ h : composer(f, g, h)(x) = f(g(h(x)))"""
def compose2(f, g):
return lambda x: f(g(x))
return reduce(compose2, fonctions)
double = lambda x: x * 2
ajouter1 = lambda x: x + 1
carre = lambda x: x ** 2
pipeline = composer(double, ajouter1, carre) # double(ajouter1(carre(x)))
print(pipeline(3)) # double(ajouter1(9)) = double(10) = 20
map: [1, 4, 9, 16, 25]
filter: [0, 2, 4, 6, 8]
par longueur: ['Pomme', 'banane', 'cerise', 'ananas']
insensible à la casse: ['ananas', 'banane', 'cerise', 'Pomme']
20
Exemple 9 (Pipeline fonctionnel sur des données)
Voici un exemple complet de traitement de données dans un style purement fonctionnel, sans mutation :
from functools import reduce
import operator
ventes = [
{"produit": "Pomme", "quantite": 150, "prix_unitaire": 0.50},
{"produit": "Banane", "quantite": 80, "prix_unitaire": 0.30},
{"produit": "Cerise", "quantite": 200, "prix_unitaire": 1.20},
{"produit": "Raisin", "quantite": 60, "prix_unitaire": 2.50},
]
# Pipeline fonctionnel : calculer, filtrer, trier, totaliser
total_par_produit = map(
lambda v: {**v, "total": v["quantite"] * v["prix_unitaire"]},
ventes
)
produits_rentables = filter(lambda v: v["total"] > 50, total_par_produit)
classes = sorted(produits_rentables, key=operator.itemgetter("total"), reverse=True)
chiffre_affaires = reduce(lambda acc, v: acc + v["total"], classes, 0.0)
## Immuabilité et structures de données immuables
Le style fonctionnel favorise les structures de données **immuables** : une fois créées, elles ne changent plus. On crée de nouvelles valeurs plutôt que de modifier les existantes.
```{code-cell} python
# tuple : immuable, hashable, utilisable comme clé de dictionnaire
coordonnees = (48.8566, 2.3522) # Paris — ne peut pas être modifié
# frozenset : ensemble immuable et hashable
voyelles = frozenset("aeiouy")
consonnes = frozenset("bcdfghjklmnpqrstvwxz")
print(voyelles & consonnes) # frozenset() — intersection vide
# types.MappingProxyType : vue en lecture seule sur un dictionnaire
from types import MappingProxyType
_CONSTANTES_INTERNES = {"version": "3.12", "auteur": "Guido"}
CONSTANTES = MappingProxyType(_CONSTANTES_INTERNES)
print(CONSTANTES["version"]) # 3.12
try:
CONSTANTES["version"] = "4.0" # Interdit
except TypeError as e:
print(e) # 'mappingproxy' object does not support item assignment
Remarque 34
L’immuabilité n’est pas seulement une contrainte esthétique. Elle apporte des garanties concrètes : un objet immuable peut être partagé entre plusieurs threads sans risque de race condition, peut être utilisé comme clé de dictionnaire ou dans un frozenset, et facilite le raisonnement sur le code (pas de surprise liée à une mutation distante). En Python, la convention est de préférer les tuples aux listes et les frozensets aux sets dès lors qu’on n’a pas besoin de mutation.
Résumé#
Dans ce chapitre, nous avons exploré la dimension fonctionnelle de Python :
Le paradigme fonctionnel repose sur les fonctions pures, l’immuabilité et l’absence d’effets de bord. Python l’intègre naturellement dans un style multi-paradigme.
functoolsoffrereducepour les accumulations,partialpour l’application partielle,lru_cache/cachepour la mémoïsation,cached_propertypour les propriétés calculées une fois, etsingledispatchpour la surcharge par type.itertoolsfournit des itérateurs paresseux de haute performance —accumulate,takewhile,dropwhile,filterfalse,starmap,chain— qui se composent sans jamais tout charger en mémoire.operatorremplace les lambdas triviaux par des fonctions nommées (operator.add,itemgetter,attrgetter), rendant le code plus lisible et légèrement plus rapide.Les fonctions d’ordre supérieur natives (
map,filter,sorted) et la composition de fonctions permettent de construire des pipelines de transformation déclaratifs.Les structures immuables (
tuple,frozenset,MappingProxyType) facilitent le raisonnement et la sécurité en environnement concurrent.
Dans le chapitre suivant, nous entrons dans la programmation asynchrone : coroutines, asyncio, boucle d’événements et I/O non bloquants.