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

# Fonctions

```{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)
```

## Définir une fonction

Les fonctions sont le premier mécanisme d'**abstraction** offert par Python. Elles permettent de donner un nom à un bloc de code réutilisable, de l'isoler du reste du programme, et de définir clairement son interface : ce qu'elle reçoit en entrée et ce qu'elle produit en sortie. Une bonne fonction fait **une seule chose**, la fait bien, et son nom dit exactement ce qu'elle fait.

En Python, on définit une fonction avec le mot-clé `def`, suivi du nom, de la liste des paramètres entre parenthèses, et d'un deux-points. Le corps de la fonction est indenté. Le mot-clé `return` permet de retourner une valeur.

```{code-cell} python
def aire_rectangle(largeur, hauteur):
    """Calcule l'aire d'un rectangle.

    Args:
        largeur: La largeur du rectangle (nombre positif).
        hauteur: La hauteur du rectangle (nombre positif).

    Returns:
        L'aire du rectangle (largeur × hauteur).
    """
    return largeur * hauteur

print(aire_rectangle(5, 3))
print(aire_rectangle(10, 0.5))
```

### Fonctions sans valeur de retour

Une fonction qui ne contient pas d'instruction `return`, ou dont le `return` est sans expression, retourne implicitement `None`. Ce comportement est parfaitement valide pour les fonctions dites "procédurales", qui agissent par effets de bord (affichage, modification d'un objet, écriture dans un fichier).

```{code-cell} python
def saluer(nom):
    """Affiche un message de bienvenue."""
    print(f"Bonjour, {nom} !")
    # Pas de return : retourne None implicitement

résultat = saluer("Alice")
print(résultat)   # None
```

### Docstrings

La **docstring** est une chaîne de caractères placée immédiatement après la ligne `def`. Elle documente la fonction et est accessible via `help()` et `fonction.__doc__`. La convention PEP 257 recommande d'utiliser des guillemets triples `"""`. Un style populaire est le format **Google** (illustré ci-dessus) ou le format **NumPy** (préféré pour les projets scientifiques).

```{code-cell} python
help(aire_rectangle)
```

## Paramètres et arguments

Python offre un système de paramètres très riche qui couvre la grande majorité des cas d'usage.

### Paramètres positionnels et par mot-clé

```{code-cell} python
def créer_profil(nom, age, ville="Paris"):
    return f"{nom}, {age} ans, habite à {ville}"

# Appel positionnel
print(créer_profil("Alice", 30))

# Appel par mot-clé (keyword argument)
print(créer_profil(age=25, nom="Bob"))

# Mélange des deux (positionnels d'abord)
print(créer_profil("Charlie", 35, ville="Lyon"))
```

### Valeurs par défaut : attention aux défauts mutables

```{prf:remark}
:label: remark-04-01
Le piège le plus classique en Python concerne les **valeurs par défaut mutables**. Les valeurs par défaut ne sont évaluées qu'**une seule fois**, au moment de la définition de la fonction, pas à chaque appel. Utiliser une liste, un dictionnaire ou tout autre objet mutable comme valeur par défaut est une erreur classique qui conduit à un état partagé et des comportements inattendus.
```

```{code-cell} python
# MAUVAIS : liste mutable comme valeur par défaut
def ajouter_élément_mauvais(élément, liste=[]):
    liste.append(élément)
    return liste

print(ajouter_élément_mauvais(1))  # [1]
print(ajouter_élément_mauvais(2))  # [1, 2] — surprise !
print(ajouter_élément_mauvais(3))  # [1, 2, 3] — la liste persiste !
```

```{code-cell} python
# BON : utiliser None comme sentinelle
def ajouter_élément(élément, liste=None):
    if liste is None:
        liste = []
    liste.append(élément)
    return liste

print(ajouter_élément(1))  # [1]
print(ajouter_élément(2))  # [2]
print(ajouter_élément(3))  # [3]
```

### `*args` et `**kwargs`

```{code-cell} python
# *args : nombre variable d'arguments positionnels -> tuple
def somme(*args):
    """Somme d'un nombre arbitraire de valeurs."""
    return sum(args)

print(somme(1, 2, 3))
print(somme(10, 20, 30, 40, 50))

# **kwargs : nombre variable d'arguments par mot-clé -> dict
def décrire(**kwargs):
    """Affiche des paires clé=valeur."""
    for clé, valeur in kwargs.items():
        print(f"  {clé}: {valeur}")

décrire(nom="Alice", age=30, langage="Python")
```

```{code-cell} python
# Combinaison des deux
def journal(niveau, *messages, séparateur="\n", **métadonnées):
    print(f"[{niveau.upper()}]")
    print(séparateur.join(messages))
    for k, v in métadonnées.items():
        print(f"  {k}={v}")

journal("info", "Démarrage", "Connexion établie",
        séparateur=" | ", service="api", version="1.0")
```

### Paramètres positionnels-seulement et mot-clé-seulement

Python 3.8+ permet de préciser exactement comment les paramètres peuvent être passés :

```{code-cell} python
# Le / sépare les paramètres positionnels-seulement (à gauche)
# Le * sépare les paramètres mot-clé-seulement (à droite)
def f(pos1, pos2, /, normal, *, kwonly1, kwonly2):
    print(f"pos1={pos1}, pos2={pos2}, normal={normal}, "
          f"kwonly1={kwonly1}, kwonly2={kwonly2}")

f(1, 2, 3, kwonly1=4, kwonly2=5)         # OK
f(1, 2, normal=3, kwonly1=4, kwonly2=5)  # OK
# f(pos1=1, ...)  # Erreur : pos1 est positionnel-seulement
```

## Portée et espaces de noms

La **portée** (*scope*) d'une variable désigne la région du code dans laquelle elle est accessible. Python résout les noms de variables selon la règle **LEGB** : Local → Enclosing → Global → Built-in.

```{prf:definition} Règle LEGB
:label: definition-04-01
Lorsque Python rencontre un nom de variable, il le cherche successivement dans quatre portées, de la plus proche à la plus lointaine :

1. **Local** : l'espace de noms de la fonction courante (les variables définies dans la fonction).
2. **Enclosing** : les espaces de noms des fonctions englobantes (pour les fonctions imbriquées).
3. **Global** : l'espace de noms du module courant (les variables définies au niveau du module).
4. **Built-in** : les noms prédéfinis par Python (`print`, `len`, `range`, `type`…).

La première portée où le nom est trouvé gagne. Si le nom n'est trouvé dans aucune portée, Python lève une `NameError`.
```

```{code-cell} python
x = "global"      # Variable globale

def extérieure():
    x = "enclosing"   # Variable dans la portée englobante

    def intérieure():
        x = "local"       # Variable locale
        print(f"intérieure voit : {x}")

    intérieure()
    print(f"extérieure voit : {x}")

extérieure()
print(f"global voit : {x}")
```

### `global` et `nonlocal`

```{code-cell} python
compteur = 0

def incrémenter():
    global compteur    # Déclare que l'on modifie la variable globale
    compteur += 1

incrémenter()
incrémenter()
print(compteur)  # 2
```

```{code-cell} python
def fabrique_compteur():
    valeur = 0

    def incrémenter():
        nonlocal valeur    # Modifie la variable de la portée englobante
        valeur += 1
        return valeur

    return incrémenter

compteur = fabrique_compteur()
print(compteur())  # 1
print(compteur())  # 2
print(compteur())  # 3
```

```{prf:remark}
:label: remark-04-02
L'utilisation de `global` est généralement un signe de conception discutable : elle introduit un état partagé qui rend le code difficile à tester et à comprendre. Dans la plupart des cas, il est préférable de passer la valeur en paramètre et de la retourner. En revanche, `nonlocal` est souvent légitime dans les **fermetures** (*closures*) et les **décorateurs**, où l'on veut maintenir un état entre les appels d'une fonction retournée par une autre fonction.
```

## Fonctions comme objets

En Python, les fonctions sont des **objets de première classe** (*first-class objects*) : elles peuvent être stockées dans des variables, passées en argument à d'autres fonctions, retournées par des fonctions, et insérées dans des structures de données. Cette propriété est au cœur du style de programmation fonctionnel en Python.

```{code-cell} python
# Assigner une fonction à une variable
def carré(x):
    return x ** 2

ma_fonction = carré
print(ma_fonction(5))      # 25
print(type(ma_fonction))   # <class 'function'>

# Stocker des fonctions dans une liste
def cube(x):
    return x ** 3

def racine(x):
    return x ** 0.5

transformations = [carré, cube, racine]
for fn in transformations:
    print(f"{fn.__name__}(9) = {fn(9)}")
```

### Passage en argument et fonctions d'ordre supérieur

```{code-cell} python
# Passer une fonction en argument
def appliquer(fonction, valeurs):
    return [fonction(v) for v in valeurs]

données = [1, 4, 9, 16, 25]
print(appliquer(carré, données))
print(appliquer(racine, données))

# map et filter : fonctions d'ordre supérieur intégrées
print(list(map(carré, données)))
print(list(filter(lambda x: x > 5, données)))
```

### `lambda`

Les expressions **lambda** définissent des fonctions anonymes en une seule ligne. Elles sont utiles pour de petites fonctions passées en argument à d'autres fonctions.

```{code-cell} python
# Forme : lambda paramètres: expression
doubler = lambda x: x * 2
print(doubler(7))

# Usage courant : clé de tri
élèves = [("Alice", 17), ("Bob", 19), ("Charlie", 15)]
triés_par_note = sorted(élèves, key=lambda e: e[1], reverse=True)
print(triés_par_note)

# Avec sorted et une clé composée
mots = ["banane", "pomme", "cerise", "abricot", "kiwi"]
triés = sorted(mots, key=lambda m: (len(m), m))
print(triés)
```

### Fermetures (*closures*)

```{code-cell} python
def multiplicateur(facteur):
    """Retourne une fonction qui multiplie par facteur."""
    def multiplier(x):
        return x * facteur   # facteur est capturé de la portée englobante
    return multiplier

doubler = multiplicateur(2)
tripler = multiplicateur(3)
print(doubler(10))   # 20
print(tripler(10))   # 30

# La fermeture "se souvient" de son environnement
print(doubler.__closure__[0].cell_contents)   # 2
```

## Fonctions récursives

La **récursion** est une technique où une fonction s'appelle elle-même pour résoudre un problème en le décomposant en sous-problèmes de même nature mais de taille réduite. Elle repose sur deux éléments essentiels : un **cas de base** (qui arrête la récursion) et un **cas récursif** (qui réduit le problème).

```{code-cell} python
def factorielle(n):
    """Calcule n! de façon récursive."""
    if n <= 1:        # Cas de base
        return 1
    return n * factorielle(n - 1)    # Cas récursif

print(factorielle(0))
print(factorielle(5))
print(factorielle(10))
```

```{code-cell} python
# Fibonacci récursif (naïf mais illustratif)
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print([fibonacci(i) for i in range(10)])

# Version mémoïsée (bien plus efficace)
from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci_rapide(n):
    if n <= 1:
        return n
    return fibonacci_rapide(n - 1) + fibonacci_rapide(n - 2)

print(fibonacci_rapide(50))
```

### Limite de récursion

```{code-cell} python
import sys

# La profondeur de récursion maximale par défaut
print(sys.getrecursionlimit())

# On peut l'augmenter, mais avec prudence
# sys.setrecursionlimit(5000)

# Dépasser la limite lève RecursionError
def infini(n):
    return infini(n + 1)

try:
    infini(0)
except RecursionError:
    print("RecursionError : limite de récursion atteinte !")
```

```{prf:remark}
:label: remark-04-03
Python n'optimise **pas** la récursion terminale (*tail call optimization*), contrairement à des langages comme Haskell ou Scheme. Une fonction récursive très profonde lèvera donc toujours une `RecursionError`, même si l'appel récursif est en dernière position. Pour les algorithmes nécessitant une grande profondeur, il est préférable de les réécrire de façon itérative, ou d'utiliser `functools.lru_cache` pour la mémoïsation quand le problème a une structure de sous-problèmes récurrents (programmation dynamique).
```

## Annotations de types

Python 3.5+ introduit les **annotations de types** (*type hints*) : une syntaxe optionnelle pour déclarer le type attendu des paramètres et la valeur de retour d'une fonction. Ces annotations ne sont **pas vérifiées à l'exécution** (Python reste dynamiquement typé) mais servent de documentation et sont exploitées par les outils d'analyse statique comme **mypy**, **pyright** et l'extension Pylance de VS Code.

```{code-cell} python
# Annotations de paramètres et de retour
def saluer(nom: str, fois: int = 1) -> str:
    return (f"Bonjour, {nom} ! " * fois).strip()

print(saluer("Alice"))
print(saluer("Bob", 3))

# Avec des types plus complexes
from typing import Optional, list as List

def trouver_premier(valeurs: list[int], cible: int) -> int | None:
    """Retourne l'indice de la première occurrence de cible, ou None."""
    for i, v in enumerate(valeurs):
        if v == cible:
            return i
    return None

print(trouver_premier([1, 5, 3, 5, 2], 5))   # 1
print(trouver_premier([1, 5, 3, 5, 2], 9))   # None
```

```{code-cell} python
# Types de retour avancés
from typing import Callable

def composer(f: Callable[[float], float],
             g: Callable[[float], float]) -> Callable[[float], float]:
    """Retourne la composition h = f ∘ g."""
    def h(x: float) -> float:
        return f(g(x))
    return h

import math
racine_de_absolu = composer(math.sqrt, abs)
print(racine_de_absolu(-16))    # 4.0
```

```{prf:definition} Annotations de types
:label: definition-04-02
Les **annotations de types** en Python sont des métadonnées attachées aux paramètres et à la valeur de retour d'une fonction. Elles sont stockées dans l'attribut `__annotations__` de la fonction et n'ont aucun effet sur l'exécution. Leur rôle est triple : **documentation** (elles explicitent le contrat de la fonction), **vérification statique** (des outils comme mypy peuvent détecter des incohérences de types sans exécuter le code), et **complétion intelligente** (l'IDE peut proposer des suggestions précises en se basant sur les types déclarés).
```

## Visualisation de la règle LEGB

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

fig, ax = plt.subplots(figsize=(11, 8))
ax.set_xlim(0, 11)
ax.set_ylim(0, 9)
ax.axis('off')
ax.set_title("Règle LEGB : résolution des noms de variables en Python",
             fontsize=14, fontweight='bold', pad=20)

palette = sns.color_palette("muted", 4)

# Les 4 portées imbriquées (de l'extérieure vers l'intérieure)
portées = [
    (0.2, 0.2, 10.6, 8.2, palette[3], "B — Built-in",
     "print, len, range, type, int, str, list…",
     "Portée la plus large (interpréteur)"),
    (0.7, 0.9, 9.6, 6.8, palette[2], "G — Global (module)",
     "Variables définies au niveau du module",
     "Visible dans tout le module"),
    (1.4, 1.7, 8.2, 5.3, palette[1], "E — Enclosing (fonction englobante)",
     "Variables de la fonction externe (pour les fermetures)",
     "Portée intermédiaire"),
    (2.3, 2.6, 6.4, 3.6, palette[0], "L — Local (fonction courante)",
     "Variables définies dans la fonction en cours d'exécution",
     "Portée la plus prioritaire"),
]

for (x, y, w, h, color, titre, exemple, note) in portées:
    box = patches.FancyBboxPatch((x, y), w, h,
        boxstyle="round,pad=0.15", linewidth=2.2,
        edgecolor=color, facecolor=color, alpha=0.15)
    ax.add_patch(box)
    brd = patches.FancyBboxPatch((x, y), w, h,
        boxstyle="round,pad=0.15", linewidth=2.2,
        edgecolor=color, facecolor='none')
    ax.add_patch(brd)
    ax.text(x + 0.25, y + h - 0.38, titre,
            ha='left', va='center',
            fontsize=11, fontweight='bold', color=color)
    ax.text(x + 0.25, y + h - 0.78, exemple,
            ha='left', va='center',
            fontsize=8, color='#444444', fontfamily='monospace')
    ax.text(x + 0.25, y + 0.28, note,
            ha='left', va='center',
            fontsize=7.5, color='#888888', style='italic')

# Flèche indiquant l'ordre de résolution
ax.annotate('', xy=(5.5, 3.4), xytext=(5.5, 7.8),
            arrowprops=dict(arrowstyle='->', color='#cc3333', lw=2.5,
                            linestyle='dashed'))
ax.text(6.6, 5.6, "Ordre de\nrecherche\n(L→E→G→B)",
        ha='center', va='center', fontsize=9, color='#cc3333',
        fontweight='bold')

plt.tight_layout()
plt.show()
```

## Résumé

Dans ce chapitre, nous avons exploré les fonctions Python sous toutes leurs facettes :

- Une fonction se définit avec `def`, peut retourner une valeur avec `return`, et retourne `None` implicitement si aucun `return` n'est présent. La **docstring** documente le contrat de la fonction.
- Les paramètres peuvent être **positionnels**, **par mot-clé**, avec des **valeurs par défaut**. Les valeurs par défaut mutables sont un piège classique : utiliser `None` comme sentinelle. `*args` capture des arguments positionnels supplémentaires dans un tuple ; `**kwargs` capture des arguments par mot-clé dans un dictionnaire. `/` et `*` permettent de contraindre le mode de passage des arguments.
- La règle **LEGB** (Local → Enclosing → Global → Built-in) gouverne la résolution des noms. `global` permet de modifier une variable globale ; `nonlocal` permet de modifier une variable d'une portée englobante.
- Les fonctions sont des **objets de première classe** : elles peuvent être passées en argument, retournées par d'autres fonctions et stockées dans des structures de données. Les **lambdas** permettent de définir de petites fonctions anonymes. Les **fermetures** capturent l'environnement dans lequel elles sont définies.
- La **récursion** décompose un problème en sous-problèmes de même nature ; elle nécessite impérativement un cas de base. Python limite la profondeur de récursion (1000 par défaut) et n'optimise pas la récursion terminale.
- Les **annotations de types** documentent le contrat d'une fonction et permettent la vérification statique, sans affecter l'exécution.

Dans le chapitre suivant, nous explorerons les **structures de données natives** de Python : listes, tuples, dictionnaires, ensembles et les collections avancées du module `collections`.
