Le functools.singledispatch de Python a été introduit dans le PEP 443 et publié dans Python 3.4 pour apporter des capacités de fonction générique au langage. Inspiré par des fonctionnalités similaires dans Clojure et Julia, il permet aux développeurs d'écrire un seul nom de fonction qui se comporte différemment selon le type de son premier argument. Cela répond au schéma de longue date consistant à utiliser des chaînes isinstance() ou des tables de dispatch manuelles, qui encombrent le code et violent le principe d'ouverture/fermeture.
Sans un mécanisme de dispatch standardisé, les développeurs doivent implémenter des vérifications de type ad-hoc au sein des fonctions pour gérer différents types de données. Cela mène à un code étroitement couplé où l'ajout du support pour un nouveau type nécessite de modifier le code source de la fonction d'origine, rompant l'extensibilité. De plus, les sous-classes virtuelles et les classes de base abstraites posent des défis pour les tables de dispatch statiques car elles nécessitent un parcours MRO (Order de Résolution de Méthode) à l'exécution pour déterminer la meilleure implémentation correspondante.
L'implémentation utilise un dictionnaire interne _registry mappant les objets de type à leurs fonctions de gestion correspondantes. Lorsque la fonction générique est invoquée, elle extrait le type du premier argument et effectue une recherche. Si le type exact n'est pas trouvé, elle parcourt le MRO du type pour trouver la classe parente enregistrée la plus proche. La méthode register() agit comme une fabrique de décorateurs qui peuple ce registre. Pour les sous-classes virtuelles (celles enregistrées avec register() sur des classes de base abstraites), le dispatcher vérifie isinstance() contre les types abstraits enregistrés si aucun type concret ne correspond, permettant un dispatch polymorphe sans héritage.
from functools import singledispatch from abc import ABC class Shape(ABC): pass class Circle(Shape): def __init__(self, radius): self.radius = radius @singledispatch def area(obj): raise NotImplementedError("Type non supporté") @area.register(Circle) def _(obj): return 3.14 * obj.radius ** 2 # Support des sous-classes virtuelles @area.register(Shape) def _(obj): return "Aire de forme abstraite"
Considérez un pipeline de traitement des données qui ingère des fichiers de plusieurs sources — JSON, XML, et CSV — chacun nécessitant une logique de parsing différente mais produisant une représentation interne standardisée. L'implémentation initiale utilisait une fonction monolithique parse_data(data, file_type) avec un grand bloc if/elif/else vérifiant isinstance ou des identifiants de chaîne. Cela est devenu non maintenable à mesure que de nouveaux formats étaient ajoutés, nécessitant des modifications à la fonction principale et créant des risques de régression.
Une solution alternative était le modèle Visitor, qui sépare les algorithmes de parsing des structures de données. Bien que cela impose le principe d'ouverture/fermeture, cela nécessite de créer une hiérarchie parallèle de classes de visiteur et de méthodes d'acceptation, introduisant un encombrement significatif pour un simple dispatch basé sur le type. Le modèle semble également peu naturel lorsque les structures de données sont des chaînes simples ou des octets plutôt que des objets complexes.
Une autre approche envisagée était un dictionnaire de dispatch manuel mappant les identifiants de type à des fonctions de gestion. Cela découple l'enregistrement de l'implémentation mais manque d'intégration avec le système de type de Python. Il ne peut pas gérer automatiquement les hiérarchies d’héritage ou les classes de base abstraites, obligeant les développeurs à résoudre manuellement le meilleur gestionnaire en parcourant le MRO à chaque point d'appel, ce qui est source d'erreurs et répétitif.
L'équipe a choisi functools.singledispatch car il fournit un support de premier ordre pour le dispatch basé sur le type avec une résolution automatique du MRO et une syntaxe de registre propre basée sur les décorateurs. Cela permet aux bibliothèques tierces d'étendre le support de parsing pour de nouveaux formats sans modifier le code de la bibliothèque principale. Le résultat a été une réduction de 40 % du nombre de lignes de code pour le module de parsing et l'élimination des conflits de fusion lors de l'ajout de nouveaux gestionnaires de formats, chaque format vivant désormais dans son propre bloc d'enregistrement indépendant.
Comment singledispatch résout-il la bonne implémentation lorsque le type d'argument exact n'est pas enregistré, et quel rôle joue l'Ordre de Résolution de Méthode (MRO) ?
Lorsque la fonction générique reçoit un argument dont le type n'est pas explicitement dans le registre, le dispatcher inspecte la hiérarchie de classe de l'argument en utilisant type(obj).__mro__. Il parcourt le tuple MRO — qui liste la classe de l'objet suivie de ses parents dans l'ordre de linéarisation — et retourne la première fonction enregistrée associée à un type dans cette séquence. Cela garantit qu'un gestionnaire enregistré pour une classe parente gérera correctement les instances de ses sous-classes, maintenant la conformité avec le Principe de Substitution de Liskov. Si aucune correspondance n'est trouvée après avoir parcouru l'ensemble du MRO, le dispatcher revient à la fonction originale enregistrée avec @singledispatch, qui lève généralement NotImplementedError.
Pouvez-vous enregistrer une fonction existante (pas un décorateur) ou un lambda avec singledispatch, et quelle est la syntaxe pour désenregistrer un type ?
Oui, vous pouvez enregistrer des fonctions existantes en utilisant la forme fonctionnelle : generic_func.register(target_type, existing_function). Cela est utile lorsque vous souhaitez dispatcher vers une fonction définie ailleurs ou vers un lambda : process.register(int, lambda x: x * 2). Pour désenregistrer un type, vous assignez None à ce type dans le registre : process.registry[int] = None. Cela supprime le gestionnaire spécifique, entraînant les dispatches futurs pour ce type à revenir à la recherche du MRO ou à l'implémentation par défaut. Les candidats manquent souvent cela car la syntaxe de décorateur est mise en avant dans la documentation, tandis que l'API impérative est moins mise en avant.
En quoi functools.singledispatchmethod diffère-t-il de singledispatch lorsqu'il est utilisé dans une classe, et pourquoi une implémentation séparée est-elle nécessaire ?
singledispatchmethod est nécessaire pour les méthodes car singledispatch fonctionne sur le premier argument de la fonction, qui pour les méthodes est self. Si vous appliquiez singledispatch directement à une méthode, cela dispatcherait en fonction du type de l'instance plutôt que du type des arguments suivants. singledispatchmethod utilise le protocole descripteur pour séparer la logique de dispatch du processus de liaison : il lie self d'abord, puis applique le dispatch par type aux arguments restants. Cela garantit que le type de self n'interfère pas avec la cible de dispatch prévue, permettant aux méthodes d'être sobres sur la base du type de leur premier argument non self, similaire à la façon dont C++ ou Java gèrent la surcharge de méthodes.