Garantir un code propre : un regard sur Python, paramétré
Publié: 2022-03-11Dans cet article, je vais parler de ce que je considère comme la technique ou le modèle le plus important pour produire du code Pythonic propre, à savoir le paramétrage. Ce poste est pour vous si :
- Vous êtes relativement novice dans le domaine des modèles de conception et peut-être un peu déconcerté par les longues listes de noms de modèles et de diagrammes de classes. La bonne nouvelle est qu'il n'y a vraiment qu'un seul modèle de conception que vous devez absolument connaître pour Python. Mieux encore, vous le connaissez probablement déjà, mais peut-être pas toutes les façons dont il peut être appliqué.
- Vous êtes venu à Python à partir d'un autre langage OOP tel que Java ou C # et souhaitez savoir comment traduire vos connaissances des modèles de conception de ce langage en Python. En Python et dans d'autres langages à typage dynamique, de nombreux modèles courants dans les langages POO à typage statique sont "invisibles ou plus simples", comme l'a dit l'auteur Peter Norvig.
Dans cet article, nous explorerons l'application de la « paramétrisation » et comment elle peut être liée aux modèles de conception courants connus sous le nom d' injection de dépendance , de stratégie , de méthode de modèle , d'usine abstraite , de méthode d'usine et de décorateur . En Python, beaucoup d'entre eux s'avèrent simples ou sont rendus inutiles par le fait que les paramètres en Python peuvent être des objets ou des classes appelables.
La paramétrisation est le processus consistant à prendre des valeurs ou des objets définis dans une fonction ou une méthode, et à en faire des paramètres de cette fonction ou méthode, afin de généraliser le code. Ce processus est également connu sous le nom de refactorisation du « paramètre d'extraction ». D'une certaine manière, cet article porte sur les modèles de conception et le refactoring.
Le cas le plus simple de Python paramétré
Pour la plupart de nos exemples, nous utiliserons le module tortue de la bibliothèque standard d'instructions pour faire quelques graphiques.
Voici un code qui va dessiner un carré 100x100 en utilisant turtle
:
from turtle import Turtle turtle = Turtle() for i in range(0, 4): turtle.forward(100) turtle.left(90)
Supposons que nous voulions maintenant dessiner un carré de taille différente. Un programmeur très débutant à ce stade serait tenté de copier-coller ce bloc et de le modifier. De toute évidence, une bien meilleure méthode serait d'abord d'extraire le code de dessin du carré dans une fonction, puis de faire de la taille du carré un paramètre de cette fonction :
def draw_square(size): for i in range(0, 4): turtle.forward(size) turtle.left(90) draw_square(100)
Nous pouvons donc maintenant dessiner des carrés de n'importe quelle taille en utilisant draw_square
. C'est tout ce qu'il y a dans la technique essentielle du paramétrage, et nous venons de voir le premier usage principal : éliminer la programmation par copier-coller.
Un problème immédiat avec le code ci-dessus est que draw_square
dépend d'une variable globale. Cela a beaucoup de mauvaises conséquences, et il existe deux façons simples d'y remédier. Le premier serait que draw_square
crée l'instance Turtle
elle-même (dont je parlerai plus tard). Cela n'est peut-être pas souhaitable si nous voulons utiliser une seule Turtle
pour tout notre dessin. Donc pour l'instant, nous allons simplement utiliser à nouveau la paramétrisation pour faire de turtle
un paramètre de draw_square
:
from turtle import Turtle def draw_square(turtle, size): for i in range(0, 4): turtle.forward(size) turtle.left(90) turtle = Turtle() draw_square(turtle, 100)
Cela a un nom fantaisiste : l'injection de dépendance. Cela signifie simplement que si une fonction a besoin d'un type d'objet pour faire son travail, comme draw_square
a besoin d'un Turtle
, l'appelant est responsable de transmettre cet objet en tant que paramètre. Non, vraiment, si jamais vous étiez curieux à propos de l'injection de dépendance Python, c'est ça.
Jusqu'à présent, nous avons traité deux utilisations très basiques. L'observation clé pour le reste de cet article est que, en Python, il existe un large éventail de choses qui peuvent devenir des paramètres - plus que dans certains autres langages - et cela en fait une technique très puissante.
Tout ce qui est un objet
En Python, vous pouvez utiliser cette technique pour paramétrer tout ce qui est un objet, et en Python, la plupart des choses que vous rencontrez sont, en fait, des objets. Ceci comprend:
- Instances de types intégrés, comme la chaîne
"I'm a string"
et l'entier42
ou un dictionnaire - Instances d'autres types et classes, par exemple, un objet
datetime.datetime
- Fonctions et méthodes
- Types intégrés et classes personnalisées
Les deux derniers sont ceux qui sont les plus surprenants, surtout si vous venez d'autres langues, et ils ont besoin de plus de discussion.
Fonctions comme paramètres
L'instruction de fonction en Python fait deux choses :
- Il crée un objet fonction.
- Il crée un nom dans la portée locale qui pointe vers cet objet.
Nous pouvons jouer avec ces objets dans un REPL :
> >> def foo(): ... return "Hello from foo" > >> > >> foo() 'Hello from foo' > >> print(foo) <function foo at 0x7fc233d706a8> > >> type(foo) <class 'function'> > >> foo.name 'foo'
Et comme tous les objets, nous pouvons assigner des fonctions à d'autres variables :
> >> bar = foo > >> bar() 'Hello from foo'
Notez que bar
est un autre nom pour le même objet, il a donc la même propriété interne __name__
qu'avant :
> >> bar.name 'foo' > >> bar <function foo at 0x7fc233d706a8>
Mais le point crucial est que, comme les fonctions ne sont que des objets, partout où vous voyez une fonction utilisée, il peut s'agir d'un paramètre.
Donc, supposons que nous étendions notre fonction de dessin de carré ci-dessus, et maintenant parfois, lorsque nous dessinons des carrés, nous voulons faire une pause à chaque coin - un appel à time.sleep()
.
Mais supposons que parfois nous ne voulions pas nous arrêter. Le moyen le plus simple d'y parvenir serait d'ajouter un paramètre de pause
, peut-être avec une valeur par défaut de zéro afin que nous ne fassions pas de pause par défaut.
Cependant, nous découvrons plus tard que parfois nous voulons réellement faire quelque chose de complètement différent dans les virages. Peut-être voulons-nous dessiner une autre forme à chaque coin, changer la couleur du stylo, etc. Nous pourrions être tentés d'ajouter beaucoup plus de paramètres, un pour chaque chose que nous devons faire. Cependant, une solution beaucoup plus agréable serait de permettre à n'importe quelle fonction d'être transmise comme action à entreprendre. Par défaut, nous allons créer une fonction qui ne fait rien. Nous ferons également en sorte que cette fonction accepte les paramètres locaux de la turtle
et de la size
, au cas où ils seraient nécessaires :
def do_nothing(turtle, size): pass def draw_square(turtle, size, at_corner=do_nothing): for i in range(0, 4): turtle.forward(size) at_corner(turtle, size) turtle.left(90) def pause(turtle, size): time.sleep(5) turtle = Turtle() draw_square(turtle, 100, at_corner=pause)
Ou, nous pourrions faire quelque chose d'un peu plus cool comme dessiner récursivement des carrés plus petits à chaque coin :
def smaller_square(turtle, size): if size < 10: return draw_square(turtle, size / 2, at_corner=smaller_square) draw_square(turtle, 128, at_corner=smaller_square)
Il y a, bien sûr, des variantes de cela. Dans de nombreux exemples, la valeur de retour de la fonction serait utilisée. Ici, nous avons un style de programmation plus impératif, et la fonction est appelée uniquement pour ses effets secondaires.
Dans d'autres langues…
Avoir des fonctions de première classe en Python rend cela très facile. Dans les langages qui en manquent, ou dans certains langages à typage statique qui nécessitent des signatures de type pour les paramètres, cela peut être plus difficile. Comment ferions-nous cela si nous n'avions pas de fonctions de première classe ?
Une solution serait de transformer draw_square
en une classe, SquareDrawer
:
class SquareDrawer: def __init__(self, size): self.size = size def draw(self, t): for i in range(0, 4): t.forward(self.size) self.at_corner(t, size) t.left(90) def at_corner(self, t, size): pass
Nous pouvons maintenant sous-classer SquareDrawer
et ajouter une méthode at_corner
qui fait ce dont nous avons besoin. Ce modèle python est connu sous le nom de modèle de méthode modèle - une classe de base définit la forme de l'ensemble de l'opération ou de l'algorithme et les variantes de l'opération sont placées dans des méthodes qui doivent être implémentées par des sous-classes.
Bien que cela puisse parfois être utile en Python, extraire le code de variante dans une fonction qui est simplement passée en paramètre sera souvent beaucoup plus simple.
Une deuxième façon d'aborder ce problème dans les langages sans fonctions de première classe consiste à envelopper nos fonctions sous forme de méthodes à l'intérieur de classes, comme ceci :
class DoNothing: def run(self, turtle, size): pass def draw_square(turtle, size, at_corner=DoNothing()): for i in range(0, 4): turtle.forward(size) at_corner.run(turtle, size) t.left(90) class Pauser: def run(self, turtle, size): time.sleep(5) draw_square(turtle, 100, at_corner=Pauser())
C'est ce qu'on appelle le modèle de stratégie. Encore une fois, c'est certainement un modèle valide à utiliser en Python, surtout si la classe de stratégie contient en fait un ensemble de fonctions liées, plutôt qu'une seule. Cependant, souvent, tout ce dont nous avons vraiment besoin est une fonction et nous pouvons arrêter d'écrire des classes.
Autres exigibles
Dans les exemples ci-dessus, j'ai parlé de passer des fonctions à d'autres fonctions en tant que paramètres. Cependant, tout ce que j'ai écrit était, en fait, vrai de tout objet appelable. Les fonctions sont l'exemple le plus simple, mais on peut aussi considérer les méthodes.
Supposons que nous ayons une liste foo
:
foo = [1, 2, 3]
foo
a maintenant tout un tas de méthodes qui lui sont attachées, telles que .append()
et .count()
. Ces "méthodes liées" peuvent être transmises et utilisées comme des fonctions :
> >> appendtofoo = foo.append > >> appendtofoo(4) > >> foo [1, 2, 3, 4]
En plus de ces méthodes d'instance, il existe d'autres types d'objets appelables : les staticmethods
et classmethods
, les instances de classes qui implémentent __call__
et les classes/types eux-mêmes.

Classes en tant que paramètres
En Python, les classes sont "de première classe" - ce sont des objets d'exécution tout comme les dicts, les chaînes, etc. Cela peut sembler encore plus étrange que les fonctions étant des objets, mais heureusement, il est en fait plus facile de démontrer ce fait que pour les fonctions.
L'instruction de classe que vous connaissez est une bonne façon de créer des classes, mais ce n'est pas la seule façon — nous pouvons aussi utiliser la version à trois arguments de type. Les deux instructions suivantes font exactement la même chose :
class Foo: pass Foo = type('Foo', (), {})
Dans la deuxième version, notez les deux choses que nous venons de faire (qui sont faites plus facilement en utilisant l'instruction de classe) :
- À droite du signe égal, nous avons créé une nouvelle classe, avec un nom interne
Foo
. C'est le nom que vous récupérerez si vous faitesFoo.__name__
. - Avec l'affectation, nous avons ensuite créé un nom dans la portée actuelle, Foo, qui fait référence à cet objet de classe que nous venons de créer.
Nous avons fait les mêmes observations pour ce que fait l'instruction de fonction.
L'idée clé ici est que les classes sont des objets auxquels on peut attribuer des noms (c'est-à-dire qui peuvent être placés dans une variable). Partout où vous voyez une classe en cours d'utilisation, vous ne voyez en fait qu'une variable en cours d'utilisation. Et si c'est une variable, ça peut être un paramètre.
Nous pouvons décomposer cela en un certain nombre d'utilisations :
Les classes comme usines
Une classe est un objet appelable qui crée une instance de lui-même :
> >> class Foo: ... pass > >> Foo() <__main__.Foo at 0x7f73e0c96780>
Et en tant qu'objet, il peut être affecté à d'autres variables :
> >> myclass = Foo > >> myclass() <__main__.Foo at 0x7f73e0ca93c8>
Pour en revenir à notre exemple de tortue ci-dessus, un problème avec l'utilisation de tortues pour dessiner est que la position et l'orientation du dessin dépendent de la position et de l'orientation actuelles de la tortue, et cela peut également la laisser dans un état différent qui pourrait être inutile pour l'appelant. Pour résoudre ce problème, notre fonction draw_square
pourrait créer sa propre tortue, la déplacer à la position souhaitée, puis dessiner un carré :
def draw_square(x, y, size): turtle = Turtle() turtle.penup() # Don't draw while moving to the start position turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)
Cependant, nous avons maintenant un problème de personnalisation. Supposons que l'appelant veuille définir certains attributs de la tortue ou utiliser un autre type de tortue qui a la même interface mais qui a un comportement spécial ?
Nous pourrions résoudre ce problème avec l'injection de dépendances, comme nous l'avions fait auparavant - l'appelant serait responsable de la configuration de l'objet Turtle
. Mais que se passe-t-il si notre fonction a parfois besoin de créer de nombreuses tortues à des fins de dessin différentes, ou si elle veut peut-être lancer quatre threads, chacun avec sa propre tortue pour dessiner un côté du carré ? La réponse est simplement de faire de la classe Turtle un paramètre de la fonction. Nous pouvons utiliser un argument de mot-clé avec une valeur par défaut, pour simplifier les choses pour les appelants qui s'en moquent :
def draw_square(x, y, size, make_turtle=Turtle): turtle = make_turtle() turtle.penup() turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)
Pour l'utiliser, nous pourrions écrire une fonction make_turtle
qui crée une tortue et la modifie. Supposons que nous voulions masquer la tortue lors du dessin de carrés :
def make_hidden_turtle(): turtle = Turtle() turtle.hideturtle() return turtle draw_square(5, 10, 20, make_turtle=make_hidden_turtle)
Ou nous pourrions sous-classer Turtle
pour intégrer ce comportement et passer la sous-classe en paramètre :
class HiddenTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hideturtle() draw_square(5, 10, 20, make_turtle=HiddenTurtle)
Dans d'autres langues…
Plusieurs autres langages POO, comme Java et C #, manquent de classes de première classe. Pour instancier une classe, vous devez utiliser le new
mot clé suivi d'un nom de classe réel.
Cette limitation est à l'origine de modèles tels que la fabrique abstraite (qui nécessite la création d'un ensemble de classes dont le seul travail consiste à instancier d'autres classes) et le modèle Factory Method. Comme vous pouvez le voir, en Python, il s'agit simplement d'extraire la classe en tant que paramètre car une classe est sa propre usine.
Classes comme classes de base
Supposons que nous nous retrouvions à créer des sous-classes pour ajouter la même fonctionnalité à différentes classes. Par exemple, nous voulons une sous-classe Turtle
qui écrira dans un journal lors de sa création :
import logging logger = logging.getLogger() class LoggingTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Turtle got created")
Mais ensuite, on se retrouve à faire exactement la même chose avec une autre classe :
class LoggingHippo(Hippo): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Hippo got created")
Les seules choses qui varient entre ces deux sont :
- La classe de base
- Le nom de la sous-classe, mais nous ne nous en soucions pas vraiment et nous pourrions le générer automatiquement à partir de l'attribut
__name__
de la classe de base. - Le nom utilisé dans l'appel de
debug
, mais encore une fois, nous pourrions le générer à partir du nom de la classe de base.
Face à deux bouts de code très similaires avec une seule variante, que faire ? Tout comme dans notre tout premier exemple, nous créons une fonction et extrayons la partie variante en tant que paramètre :
def make_logging_class(cls): class LoggingThing(cls): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("{0} got created".format(cls.__name__)) LoggingThing.__name__ = "Logging{0}".format(cls.__name__) return LoggingThing LoggingTurtle = make_logging_class(Turtle) LoggingHippo = make_logging_class(Hippo)
Ici, nous avons une démonstration de classes de première classe :
- Nous avons passé une classe dans une fonction, en donnant au paramètre un nom conventionnel
cls
pour éviter le conflit avec le mot-cléclass
(vous verrez égalementclass_
etklass
utilisés à cet effet). - À l'intérieur de la fonction, nous avons créé une classe. Notez que chaque appel à cette fonction crée une nouvelle classe.
- Nous avons renvoyé cette classe comme valeur de retour de la fonction.
Nous définissons également LoggingThing.__name__
qui est entièrement facultatif mais peut aider au débogage.
Une autre application de cette technique est lorsque nous avons tout un tas de fonctionnalités que nous voulons parfois ajouter à une classe, et nous pouvons vouloir ajouter diverses combinaisons de ces fonctionnalités. Créer manuellement toutes les différentes combinaisons dont nous avons besoin pourrait devenir très difficile à manier.
Dans les langages où les classes sont créées au moment de la compilation plutôt qu'au moment de l'exécution, cela n'est pas possible. Au lieu de cela, vous devez utiliser le modèle de décorateur. Ce modèle peut parfois être utile en Python, mais la plupart du temps, vous pouvez simplement utiliser la technique ci-dessus.
Normalement, j'évite en fait de créer beaucoup de sous-classes pour la personnalisation. Habituellement, il existe des méthodes plus simples et plus Pythonic qui n'impliquent pas du tout de classes. Mais cette technique est disponible si vous en avez besoin. Voir aussi le traitement complet par Brandon Rhodes du modèle de décorateur en Python.
Classes comme exceptions
Un autre endroit où vous voyez des classes utilisées est dans la clause except
d'une instruction try/except/finally. Pas de surprises pour deviner que nous pouvons également paramétrer ces classes.
Par exemple, le code suivant implémente une stratégie très générique consistant à tenter une action susceptible d'échouer et à réessayer avec une interruption exponentielle jusqu'à ce qu'un nombre maximal de tentatives soit atteint :
import time def retry_with_backoff(action, exceptions_to_catch, max_attempts=10, attempts_so_far=0): try: return action() except exceptions_to_catch: attempts_so_far += 1 if attempts_so_far >= max_attempts: raise else: time.sleep(attempts_so_far ** 2) return retry_with_backoff(action, exceptions_to_catch, attempts_so_far=attempts_so_far, max_attempts=max_attempts)
Nous avons extrait à la fois l'action à entreprendre et les exceptions à intercepter en tant que paramètres. Le paramètre exceptions_to_catch
peut être soit une classe unique, telle que IOError
ou httplib.client.HTTPConnectionError
, soit un tuple de ces classes. (Nous voulons éviter les clauses "bare except" ou même except Exception
car cela est connu pour cacher d'autres erreurs de programmation).
Avertissements et conclusion
Le paramétrage est une technique puissante pour réutiliser le code et réduire la duplication de code. Ce n'est pas sans quelques inconvénients. Dans la poursuite de la réutilisation du code, plusieurs problèmes surgissent souvent :
- Code trop générique ou abstrait qui devient très difficile à comprendre.
- Code avec une prolifération de paramètres qui obscurcit la vue d'ensemble ou introduit des bugs car, en réalité, seules certaines combinaisons de paramètres sont correctement testées.
- Couplage inutile de différentes parties de la base de code car leur « code commun » a été regroupé en un seul endroit. Parfois, le code à deux endroits n'est similaire qu'accidentellement, et les deux endroits doivent être indépendants l'un de l'autre car ils peuvent avoir besoin de changer indépendamment.
Parfois, un peu de code "dupliqué" est bien meilleur que ces problèmes, alors utilisez cette technique avec précaution.
Dans cet article, nous avons couvert les modèles de conception connus sous le nom d' injection de dépendance , de stratégie , de méthode de modèle , d'usine abstraite , de méthode d'usine et de décorateur . En Python, beaucoup d'entre eux se révèlent être une simple application de paramétrage ou sont définitivement rendus inutiles par le fait que les paramètres en Python peuvent être des objets ou des classes appelables. Espérons que cela aide à alléger la charge conceptuelle des « choses que vous êtes censé connaître en tant que véritable développeur Python » et vous permet d'écrire du code Pythonic concis !
Lecture complémentaire :
- Modèles de conception Python : pour un code élégant et à la mode
- Patterns Python : pour les modèles de conception Python
- Journalisation Python : un didacticiel approfondi