---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# Programmation fonctionnelle

```{code-cell} python
:tags: [hide-input]

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
```

## 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.

```{prf:definition} Fonction pure
:label: definition-18-01
Une **fonction pure** est une fonction qui satisfait deux propriétés :
1. **Déterminisme** : pour les mêmes arguments, elle retourne toujours le même résultat.
2. **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.

```{code-cell} python
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}
```

### `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.

```{code-cell} python
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']
```

### `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.

```{code-cell} python
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())
```

### `cached_property`

`functools.cached_property` transforme une méthode en propriété calculée une seule fois et mise en cache dans l'instance.

```{code-cell} python
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
```

### `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.

```{code-cell} python
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")
```

## `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.

```{code-cell} python
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]
```

```{prf:remark}
:label: remark-18-01
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.

```{code-cell} python
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)]
```

## 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.

```{code-cell} python
# 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
```

```{prf:example} Pipeline fonctionnel sur des données
:label: example-18-01
Voici un exemple complet de traitement de données dans un style purement fonctionnel, sans mutation :

```python
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
```

```{prf:remark}
:label: remark-18-02
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.
```

```{code-cell} python
:tags: [hide-input]

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# --- Style impératif ---
ax = axes[0]
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.axis('off')
ax.set_title("Style impératif", fontsize=13, fontweight='bold', color='#c0392b')

etapes_imp = [
    ("données = [...]", "#e74c3c"),
    ("résultat = []", "#e74c3c"),
    ("for x in données:", "#e67e22"),
    ("    total = x * prix", "#e67e22"),
    ("    if total > 50:", "#e67e22"),
    ("        résultat.append(x)", "#e74c3c"),
    ("résultat.sort(...)", "#e74c3c"),
    ("ca = sum(résultat)", "#c0392b"),
]
for i, (texte, couleur) in enumerate(etapes_imp):
    y = 9.0 - i * 1.05
    rect = patches.FancyBboxPatch((0.3, y - 0.35), 9.4, 0.75,
        boxstyle="round,pad=0.1", facecolor=couleur, alpha=0.15,
        edgecolor=couleur, linewidth=1.5)
    ax.add_patch(rect)
    ax.text(0.7, y + 0.02, texte, fontsize=9, va='center',
            fontfamily='monospace', color='#2c3e50')

# --- Style fonctionnel ---
ax = axes[1]
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.axis('off')
ax.set_title("Style fonctionnel", fontsize=13, fontweight='bold', color='#27ae60')

etapes_fn = [
    ("map(ajouter_total, données)", "#27ae60"),
    ("↓", "#95a5a6"),
    ("filter(rentable, ...)", "#2980b9"),
    ("↓", "#95a5a6"),
    ("sorted(..., key=total)", "#8e44ad"),
    ("↓", "#95a5a6"),
    ("reduce(additionner, ...)", "#16a085"),
    ("→ chiffre d'affaires", "#2c3e50"),
]
for i, (texte, couleur) in enumerate(etapes_fn):
    y = 9.0 - i * 1.05
    if texte == "↓":
        ax.text(5.0, y + 0.02, texte, ha='center', fontsize=14,
                color='#7f8c8d', fontweight='bold')
    else:
        rect = patches.FancyBboxPatch((0.3, y - 0.35), 9.4, 0.75,
            boxstyle="round,pad=0.1", facecolor=couleur, alpha=0.15,
            edgecolor=couleur, linewidth=1.5)
        ax.add_patch(rect)
        ax.text(0.7, y + 0.02, texte, fontsize=9, va='center',
                fontfamily='monospace', color='#2c3e50')

fig.suptitle(
    "Impératif vs fonctionnel : traitement d'un pipeline de données",
    fontsize=14, fontweight='bold', y=1.02
)
plt.tight_layout()
plt.show()
```

## 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.
- **`functools`** offre `reduce` pour les accumulations, `partial` pour l'application partielle, `lru_cache`/`cache` pour la mémoïsation, `cached_property` pour les propriétés calculées une fois, et `singledispatch` pour la surcharge par type.
- **`itertools`** fournit des itérateurs paresseux de haute performance — `accumulate`, `takewhile`, `dropwhile`, `filterfalse`, `starmap`, `chain` — qui se composent sans jamais tout charger en mémoire.
- **`operator`** remplace 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.
