Aller au contenu

Les indices de type (Type Hints)

Introduction : Pourquoi les Type Hints ?

Dans nos exemples précédents avec @property, nous avons utilisé beaucoup de vérifications isinstance() pour valider les types. Par exemple :

@nom.setter
def nom(self, valeur):
    if not isinstance(valeur, str):  # Vérification manuelle du type
        raise TypeError("Le nom doit être une chaîne de caractères")
    # ...

Ce code a plusieurs problèmes :

  1. Redondance : Nous devons écrire la même vérification à plusieurs endroits
  2. Maintenabilité : Si nous changeons le type attendu, nous devons mettre à jour toutes les vérifications
  3. Documentation : La signature de la méthode ne montre pas clairement quels types sont attendus
  4. Outils : Les IDE et outils d’analyse statique (comme mypy) ne peuvent pas aider

Les Type Hints (indices de type) résolvent ces problèmes en :

  • Documentant les types attendus
  • Permettant une vérification automatique
  • Améliorant l’expérience développeur avec l’autocomplétion et la détection d’erreurs

Principes de base des Type Hints

1. Syntaxe de base

Les type hints sont ajoutés après le deux-points dans les définitions de fonctions/méthodes :

def ma_fonction(param: type) -> retour:
# implémentation

Pour les attributs de classe, on utilise des commentaires ou des annotations :

class MaClasse:
    attribut: type  # Annotation pour les attributs

    def __init__(self):
        self.attribut = valeur  # Pas de vérification automatique ici

2. Types de base

Python propose plusieurs types intégrés pour les hints :

def exemple(
        entier: int,
        flottant: float,
        chaine: str,
        booleen: bool,
        liste: list[int],  # Liste d'entiers
        tuple: tuple[str, int],  # Tuple avec une chaîne et un entier
        ensemble: set[float],
        dictionnaire: dict[str, int]  # Clés chaînes, valeurs entiers
) -> None:
    pass

3. Types personnalisés

Pour les classes que vous définissez :

class Personne:
    def __init__(self, nom: str, age: int):
        self.nom = nom
        self.age = age


def saluer(personne: Personne) -> str:
    return f"Bonjour {personne.nom}"

4. Types optionnels et unions

Pour indiquer qu’une valeur peut être de plusieurs types :

from typing import Optional, Union


def exemple(
        valeur: Optional[int] = None,  # Peut être int ou None
        union: Union[str, float]  # Peut être str ou float
) -> None:
    pass

En Python 3.10+, on peut utiliser | à la place de Union :

def exemple(
        valeur: int | None = None,
        union: str | float
) -> None:
    pass

Exemple motivant : Classe Personne avec Type Hints

Reprenons notre classe Personne et voyons comment les type hints améliorent la situation :

Version sans type hints (avec isinstance)

class Personne:
    def __init__(self, nom: str, age: int):
        if not isinstance(nom, str):
            raise TypeError("Le nom doit être une chaîne de caractères")
        if not isinstance(age, int) or age < 0:
            raise ValueError("L'âge doit être un entier positif")

        self._nom = nom.strip()
        self._age = age

    @property
    def nom(self) -> str:
        return self._nom

    @nom.setter
    def nom(self, valeur: str) -> None:
        if not isinstance(valeur, str):
            raise TypeError("Le nom doit être une chaîne de caractères")
        valeur = valeur.strip()
        if len(valeur) == 0:
            raise ValueError("Le nom ne peut pas être vide après suppression des espaces")
        self._nom = valeur

    @property
    def age(self) -> int:
        return self._age

    @age.setter
    def age(self, valeur: int) -> None:
        if not isinstance(valeur, int) or valeur < 0:
            raise ValueError("L'âge doit être un entier positif")
        self._age = valeur

Version avec type hints (et vérification automatique)

from typing import final


class Personne:
    def __init__(self, nom: str, age: int):
        # Les vérifications de type sont maintenant implicites grâce aux hints
        self.nom = nom  # Utilise le setter pour valider
        self.age = age

    @property
    def nom(self) -> str:
        """Retourne le nom de la personne."""
        return self._nom

    @nom.setter
    def nom(self, valeur: str) -> None:
        """Définie le nom avec validation."""
        valeur = valeur.strip()
        if len(valeur) == 0:
            raise ValueError("Le nom ne peut pas être vide après suppression des espaces")
        self._nom = valeur

    @property
    def age(self) -> int:
        """Retourne l'âge de la personne."""
        return self._age

    @age.setter
    def age(self, valeur: int) -> None:
        """Définie l'âge avec validation."""
        if valeur < 0:
            raise ValueError("L'âge doit être positif")
        self._age = valeur


# Utilisation avec vérification statique
p = Personne(nom="Alice", age=30)
print(p.nom)  # Alice

# Ces lignes seraient détectées par mypy comme des erreurs de type
# p = Personne(nom=123, age="trente")  # Erreur: argument 1 doit être str, reçu int
# p.age = "quarante"  # Erreur: ne peut pas assigner str à int

Avantages de cette approche :

  1. Documentation claire : La signature montre immédiatement quels types sont attendus
  2. Vérification automatique : mypy peut détecter les erreurs avant l’exécution
  3. Moins de code dupliqué : Plus besoin de vérifier isinstance pour chaque paramètre
  4. Meilleure expérience développeur :
    • Autocomplétion dans les IDE
    • Détection des erreurs en temps réel
    • Meilleure compréhension du code

Exemple complet avec Type Hints et vérification

Pour une solution complète, nous pouvons combiner type hints avec une vérification réelle à l’exécution :

from typing import Any, get_type_hints


class Personne:
    def __init__(self, nom: str, age: int):
        self._valider_types(nom=nom, age=age)
        self.nom = nom
        self.age = age

    @property
    def nom(self) -> str:
        return self._nom

    @nom.setter
    def nom(self, valeur: str) -> None:
        self._valider_type("nom", valeur)
        valeur = valeur.strip()
        if len(valeur) == 0:
            raise ValueError("Le nom ne peut pas être vide après suppression des espaces")
        self._nom = valeur

    @property
    def age(self) -> int:
        return self._age

    @age.setter
    def age(self, valeur: int) -> None:
        self._valider_type("age", valeur)
        if valeur < 0:
            raise ValueError("L'âge doit être positif")
        self._age = valeur

    def _valider_types(self, **kwargs: Any) -> None:
        """Valide les types des arguments en utilisant les type hints."""
        hints = get_type_hints(self.__init__)
        for param, value in kwargs.items():
            if param in hints:
                expected_type = hints[param]
                if not isinstance(value, expected_type):
                    raise TypeError(
                        f"Argument '{param}' doit être {expected_type}, "
                        f"reçu {type(value)}"
                    )

    def _valider_type(self, attr: str, value: Any) -> None:
        """Valide le type d'un attribut en utilisant les type hints."""
        hints = get_type_hints(self)
        if attr in hints:
            expected_type = hints[attr]
            if not isinstance(value, expected_type):
                raise TypeError(
                    f"Attribut '{attr}' doit être {expected_type}, "
                    f"reçu {type(value)}"
                )


# Utilisation
try:
    p = Personne(nom="Bob", age=25)
    print(p.nom)  # Bob

    # Ces lignes lèveront des exceptions à l'exécution
    p.age = "trente"  # TypeError: Attribut 'age' doit être <class 'int'>
except (TypeError, ValueError) as e:
    print(f"Erreur: {e}")

Exemple avec la classe Point

Voici comment nous pourrions réécrire la classe Point avec des type hints :

from typing import Union


class Point:
    def __init__(self, x: float = 0.0, y: float = 0.0):
        self.x = x
        self.y = y

    @property
    def x(self) -> float:
        """Retourne la coordonnée x du point."""
        return self._x

    @x.setter
    def x(self, valeur: float) -> None:
        """Définie la coordonnée x avec validation."""
        if valeur < 0:
            raise ValueError("La coordonnée x doit être positive ou nulle")
        self._x = float(valeur)

    @property
    def y(self) -> float:
        """Retourne la coordonnée y du point."""
        return self._y

    @y.setter
    def y(self, valeur: float) -> None:
        """Définie la coordonnée y avec validation."""
        if valeur < 0:
            raise ValueError("La coordonnée y doit être positive ou nulle")
        self._y = float(valeur)

    def distance(self, autre_point: 'Point') -> float:
        """Calcule la distance euclidienne entre deux points.

        Args:
            autre_point: Un autre objet Point

        Returns:
            La distance entre les deux points
        """
        return ((self.x - autre_point.x) ** 2 + (self.y - autre_point.y) ** 2) ** 0.5

    def deplacer(self, dx: float, dy: float) -> None:
        """Déplace le point des coordonnées (dx, dy)."""
        nouvelle_x = self.x + dx
        nouvelle_y = self.y + dy

        if nouvelle_x < 0 or nouvelle_y < 0:
            raise ValueError("Le déplacement résultant en des coordonnées négatives")

        self.x = nouvelle_x
        self.y = nouvelle_y


# Utilisation avec vérification statique
p1: Point = Point(3.0, 4.0)
p2: Point = Point()

print(p1.distance(p2))  # 5.0

# Ces lignes seraient détectées par mypy comme des erreurs de type
# p3: Point = Point("trois", "quatre")  # Erreur: argument doit être float
# p1.deplacer("cinq", 6)  # Erreur: arguments doivent être float

Bonnes pratiques avec les Type Hints

1. Utilisez des docstrings complètes

Les type hints ne remplacent pas les docstrings :

def calculer_moyenne(notes: list[float]) -> float:
    """Calcule la moyenne d'une liste de notes.

    Args:
        notes: Liste de notes numériques (doivent être entre 0 et 20)

    Returns:
        La moyenne des notes

    Raises:
        ValueError: Si une note est en dehors de l'intervalle [0, 20]
    """
    if any(n < 0 or n > 20 for n in notes):
        raise ValueError("Toutes les notes doivent être entre 0 et 20")
    return sum(notes) / len(notes)

2. Utilisez des types spécifiques plutôt que Any

Évitez d’utiliser typing.Any sauf lorsque vraiment nécessaire :

# Mauvais : trop vague
def traiter_donnees(donnees: Any) -> None:
    pass


# Bon : plus précis
def traiter_donnees(donnees: list[dict[str, Union[int, str]]]) -> None:
    pass

3. Utilisez des alias de type pour les structures complexes

Pour améliorer la lisibilité :

from typing import TypeAlias

Note: TypeAlias = float  # Une note est un nombre à virgule flottante
EtudiantID: TypeAlias = str  # Un identifiant d'étudiant est une chaîne


class Etudiant:
    def __init__(self, id: EtudiantID, notes: list[Note]):
        self.id = id
        self.notes = notes

4. Utilisez des protocoles pour le duck typing

Pour les objets qui doivent avoir certains attributs/méthodes :

from typing import Protocol


class Drawable(Protocol):
    def draw(self) -> None: ...


def afficher(obj: Drawable) -> None:
    obj.draw()


# Peut être utilisé avec n'importe quel objet ayant une méthode draw()
class Cercle:
    def draw(self) -> None:
        print("Dessin d'un cercle")


afficher(Cercle())  # Fonctionne

5. Utilisez des annotations pour les attributs de classe

Pour documenter les attributs de classe :

class Configuration:
    debug: bool = False
    timeout: float = 30.0
    max_connexions: int = 100

    def __init__(self, n_connections: int):
        if 0 < n_connections <= Configuration.max_connexions:
            self.n_connexions = n_connections
        else:
            self.n_connexions = Configuration.max_connexions

Outils pour les Type Hints

1. mypy - Vérificateur de type statique

Installez avec :

uv add mypy

Utilisation :

uv run mypy mon_fichier.py

Exemple de sortie :

mon_fichier.py:5: error: Argument 1 to "Personne" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 2 source files)

2. pyright - Alternative à mypy

Installez avec :

npm install -g pyright

Utilisation :

pyright mon_fichier.py

3. IDE Support

La plupart des IDE modernes supportent les type hints :

  • VS Code : Avec l’extension Python
  • PyCharm : Support intégré
  • Sublime Text : Avec des plugins comme LSP-python

4. pytype - Vérificateur de Google

Installez avec :

uv add pytype

Utilisation :

uv run pytype mon_fichier.py

Exercice pratique avec Type Hints

Complétez le code des classes suivantes, et testez-les. Utilisez mypy pour vérifier que votre implémentation respecte les types déclarés.

  1. Classe CompteBancaire :

    class CompteBancaire:
        def __init__(self, titulaire: str, solde: float = 0.0):
            # Implémentez avec type hints et validation
            pass
    
        @property
        def solde(self) -> float:
            pass
    
        def deposer(self, montant: float) -> None:
            pass
    
        def retirer(self, montant: float) -> None:
            pass
    

  2. Classe Etudiant :

    class Etudiant:
        def __init__(self, nom: str, notes: list[int]):
            # Implémentez avec type hints et validation
            pass
    
        @property
        def moyenne(self) -> float:
            """Retourne la moyenne des notes."""
            pass
    

  3. Fonction de tri personnalisée :

    from typing import Callable, TypeVar
    
    T = TypeVar("T")
    
    def tri_personnalise(
        liste: list[T],
        cle: Callable[[T], float]
    ) -> list[T]:
        """Trie une liste en utilisant une clé de comparaison."""
        pass
    
    Note : T = TypeVar("T") est une déclaration de type générique. Plus de détails ici.


Annotations de Type vs Attributs : Ne confondez pas Déclaration et Création

Une confusion fréquente en Python consiste à croire qu’une annotation de type (type hint) crée un attribut. Il est crucial de distinguer l’ annotation (qui décrit la forme) de l’ assignation (qui crée la donnée).

1. L’annotation seule : Une déclaration (Type Hinting)

Lorsque vous écrivez une variable suivie de son type, sans le signe =, vous ne créez pas d’attribut en mémoire. Vous faites une déclaration.

class Personne:
    nom: str  # Ceci est une ANNOTATION
  • Ce que cela fait : Cela informe les outils de développement (IDE comme VS Code, Pycharm) et les analyseurs statiques (comme mypy) que si l’attribut nom est utilisé, il devrait être une chaîne de caractères (str).
  • Ce que cela NE fait PAS : Cela ne crée pas la variable. Si vous essayez d’accéder à Personne.nom ou ma_personne.nom immédiatement, Python lèvera une AttributeError.
  • En mémoire : L’information est stockée dans le dictionnaire spécial __annotations__ de la classe, mais l’objet n’existe pas encore.

2. L’annotation avec assignation : L’attribut de classe

Si vous ajoutez une valeur après l’annotation, vous créez un véritable attribut stocké au niveau de la classe.

class Personne:
    nom: str = "Inconnu"  # Annotation + Assignation
  • Ce que cela fait : Vous créez un attribut de classe. Toutes les instances de Personne partageront, par défaut, cette même valeur.
  • Usage : On l’utilise pour définir des constantes ou des valeurs par défaut communes à tous les objets de la classe.

3. L’assignation via self : L’attribut d’instance

C’est la manière standard de définir les propriétés d’un objet dans un constructeur (__init__).

class Personne:
    def __init__(self, nom_initial: str):
        self.nom = nom_initial  # Assignation sur l'instance
  • Ce que cela fait : L’attribut est créé dans l’espace mémoire de l’ objet spécifique (l’instance). Chaque personne a son propre nom, indépendant des autres.
  • Usage : C’est la règle d’or pour stocker l’ état d’un objet (ses données propres).

Synthèse comparative

Le tableau suivant récapitule les trois cas de figure rencontrés en programmation orientée objet :

Syntaxe Terme technique Ce qui est créé en mémoire Portée (Scope) Usage principal
nom: str Annotation seule Rien (juste une note) Nulle (statique) Déclarer une exigence de type pour l’analyse.
nom = "Valeur" Attribut de classe Une variable partagée Classe Constantes ou valeurs par défaut communes.
self.nom = "Valeur" Attribut d’instance Une variable propre à l’objet Instance État unique de l’objet (nom, âge, etc.).

Astuce de débogage

Si votre programme crash avec une AttributeError: 'Personne' object has no attribute 'nom', c’est généralement parce que vous avez seulement annoté le type (nom: str) sans jamais l’ assigner réellement dans un __init__ ou ailleurs.

Conclusion

Les type hints sont un outil puissant qui améliore :

  1. La lisibilité : La signature des fonctions est plus claire
  2. La maintenabilité : Moins de vérifications manuelles de types
  3. La sécurité : Détection d’erreurs avant l’exécution
  4. L’expérience développeur : Meilleure intégration avec les IDE

En combinant type hints et propriétés, vous obtenez un code :

  • Bien documenté
  • Facile à maintenir
  • Sûr grâce aux validations
  • Agréable à utiliser grâce à une API propre

Les type hints ne remplacent pas complètement les vérifications à l’exécution (qui restent nécessaires pour la sécurité), mais ils forment un excellent complément qui améliore significativement la qualité du code.