Skip to content

Instantly share code, notes, and snippets.

@metal3d
Last active July 4, 2024 05:58
Show Gist options
  • Save metal3d/e0989042e69191ac03bb to your computer and use it in GitHub Desktop.
Save metal3d/e0989042e69191ac03bb to your computer and use it in GitHub Desktop.
Python DI

Python et l'injection de dépendance

Ah, ça... quand on parle d'injection et de dépendance, on s'attend tout de suite à se prendre une flopée de visiteurs en manque de substance plus ou moins illicites. En réalité, on va parler "pattern". Et surtout, je vais lancer un vieux pavé dans la marre en disant qu'en Python c'est pas super utile, ou du moins que le langage permet déjà de le gérer sans se faire mal.

Piqure de rappel

Conversation un matin au travail:

  • Stagiaire C'est quoi "injection de dépendance ?"
  • Gaston C'est simple, c'est un framework qui te permet d'injecter des dépendances dans une classe sans avoir avoir de couplage fort. Tu vas utiliser une configuration (yaml, ini, json...) qui va te permettre de définir les classes à utiliser dans celle qui hoste ta partie métier...
  • Moi Gaston...
  • Gaston Oui ?
  • Moi Ta geueule s'il te plait...

On arrête les conneries, l'injection de dépendance c'est pas un framework, c'est pas de la configuration, c'est pas un truc si compliqué. Oui, il existe des frameworks pour la gérer, on peut utiliser des fichiers de conf, oui, oui, et oui... mais l'injection de dépendance c'est ni plus ni moins qu'un pattern; point barre !

L'injection de dépendance ou DI (pour Dependency Injection) ça existe depuis des lustres et je suis même sûr que vous en avez fait sans le savoir. Le fait est que nous somme dans une apogée, celle de AngularJS, et que celui-ci s'appuie sur l'injection de dépendance à mort. Du coup ça devient une question de dignité d'en utiliser partout, pour tout et n'importe quoi.

Symfony2 (PHP) utilise l'injection de dépendance "à la Java", j'entends par là: vive de la conf XML... C'est pas que j'aime pas, mais j'aime pas. Enfin bref, ça a le mérite de marcher.

Le souci c'est qu'à force de voir des frameworks utiliser à foison des systèmes d'injection très serrés, on en oubli ce que c'est vraiment, à la base.

Et là où je vais certainement lancer un troll à deux francs, ou me prendre des insultes, des critiques dont je me fous royalement: en Python, pas besoin de framework pour gérer l'injection de dépendance. Oui, y'en a, ça existe et c'est tant mieux. Mais personnellement en Python (ou en Go tiens) je ne me sers que de mes doigts (les deux en même temps) pour gérer la DI.

Non mais d'abord c'est quoi la DI ?

Quand j'ai cherché à avoir une explication claire de ce qu'est une "injection de dépendance", j'ai eut une flopée de réponses proches ce celles de Gaston. Rien de clair.

Je me suis dit "je vais trouver une explciation simple à donner". La voici.

Vous êtes d'accord avec moi sur le fait qu'un livre doit être "lisible" par un humain en général.

En admettant qu'un humain est un objet de type "Lecteur", nous pourrions faire:

class Lecteur:
	def __init__(self, titre):
		self.livre = Livre(titre)
		self.lit_le_bouquin()

tom = Lecteur("Martine va en teuf")

Sauf que voilà, un lecteur peut lire autre chose qu'un livre. Il existe les journeaux, les sites internets, le manuel d'une boite de préservatifs... bref on est capable de lire autre chose.

Dans notre cas, le Lecteur est défini en mode "couplage fort".

L'injection de dépendance est le fait de refaire notre classe Lecteur pour qu'il soit capable de "lire" tout ce qui est "lisible". En théorie on devrait utiliser au moins un principe d'interface.

Mais l'idée est de se retrouver avec ce genre de chose:

class Lecteur:
	def __init__(self, lisible):
		self.livre = lisible
		self.lit()
		
	def lit(self):
		#...

tom = Lecteur(Livre("Martine va en teuf"))
jean = Lecteur(Site("Sam et Max"))

Ou encore, avec un principe d'injection par "setter":

class Lecteur:
	def __init__(self):
		pass
		
	def inject_lisible(self, lisible):
		self.lisible = lisible
		
	def lit(self):
		#...

Et donc de passer la dépendance:

tom = Lecteur()
tom.inject_lisible(Livre("Martine va en teuf")
tom.lit()

jean = Lecteur()
jean.inject_lisible(Site("Sam et Max"))
jean.lit()

C'est vraiment tout bête hein...

Mais c'est la base ! L'injection de dépendance veut juste dire "on injecte ce dont à besoin une classe".

La dépendance forte, c'est mal

Bon on y va cool, on parle d'injection et de dépendance, on va parler de drogue. Un junkie consomme de la drogue.

class Junkie:
	def __init__(self):
		self.drogue = Drogue()

Vous suivez ? on a donc une classe Drogue quelque part et le Junkie va l'instancier (la consommer). Et c'est là que les ennuis commencent...

La Drogue, on ne le répétera jamais assez, c'est pas bien. Soyons prudent, on va pas prendre de la drogue mais plutôt un truc soft (aspirine en poudre, sel, farine, tisane ou autre)

Là, si je veux éviter de prendre de la vraie drogue, je vais devoir modifier la classe Junkie. C'est un couplage fort et c'est pas bien. La classe Junkie va donc être retravaillée pour qu'on lui "injecte la drogue" et non pas que le Junkie soit dépendant tout de suite (décidément l'illustration est bien choisie).

Avant de commencer la descente

Pour le reste des exemples, j'estime qu'on a des classes pour chaque drogue:

class Heroine:
	pass
class Canabis:
	pass
class Ganja:
	pass
class MariJeanne:
	pass
class Tisane:
	pass
# etc...

Ne vous étonnez pas si le code ne fonctionne pas chez vous de manière direct, je ne vais que présenter le concept... à vous de bosser un peu quand même !

On fait une DI légère

Premier type de DI, l'injection dans le constructeur. On va passer à notre constructeur les objets ou les type à instancier.

Premier exemple:

class Junkie:
	def __init__(self, drogue):
		self.drogue = drogue

Sauf que voilà, on va forcément devoir "instancier" les objets pour les passer au constructeur:

BobMarley = Junkie(Drogue())

Mais c'est déjà pas mal, on peut créer plusieurs drogues et/ou simuler une drogue:

fausse_drogue = Tisane()
BobMarley = Junkie(fausse_drogue)

On a donc fait sauter le couplage, et clairement vous avez bien injecté la dépendance. C'est pas encore de la vraie DI bien propre et configurable, mais on s'en approche.

Petite parenthèse, normalement on devrait utiliser des "intefaces", mais Python connait pas ça... En Go c'est justement avec des interfaces que j'arrive à limiter la dépendance forte. Mais passons..

On peut faire mieux, on peut passer par une factory... ou pas ! Je m'explique... parce que là je vous perds hein.

Factory standard

Une factory c'est une usine à objet (traduction quasi littérale du mot "factory" je le concède). On l'appelle et ça génère un objet. C'est super utile quand on veut configurer la génération d'objets en fonction d'arguments, d'un état, de la conf ou encore la couleur de votre caleçon.

En clair:

def drogue_factory(name):
	if on veut faire une blague:
		return Tisane()
	if name == "Ganja":
		return MarieJeanne()
	# etc...

Du coup, on pourrait changer notre implémentation de Junkie de la sorte:

class Junkie:
	def init(self, nom_de_la_drogue,
			usine_de_drogue=drogue_factory):
		
		self.drogue = usine_de_drogue(nom_de_la_drogue)

Et donc à tout moment je peux changer la factory. Car là je passe bien une fonction qui génère des objets. La classe n'instancie pas directement ses dépendances, c'est quelque chose d'autre qui s'en occupe. On vient de faire de la DI, si si !

Et comme j'ai une factory par défaut... on pourra se passer de l'injecter par la suite. Mais c'est pas fini ! Python est une mine d'or pour se bricoler des trucs aussi marrant à construire qu'à utiliser.

Python factory mumuse

Le souci maintenant (non c'est pas un vrai souci) c'est que la factory crée des objets à la volée. Il faut constamment donner le nom de la drogue pour en avoir une instance. Sauf qu'on peut générer une factory pour une drogue spécifique, et ce en utilisant la capacité de Python (ou autre langage fonctionnel) à pouvoir retourner une fonction.

def drogue_factory(name):
	def generate():
		if name == "Aspirine":
			obj = Asprine()
		elif name == "Ganja":
			obj = MarieJeanne()
		return obj
	# on retourne le générateur
	return generate

Qu'on se comprenne, je vais pouvoir faire ça:

Ganja = drogue_factory("Ganja")
joint1 = Ganja()
joint2 = Ganja()

Et donc, on peut changer notre classe Junkie pour utiliser ce type de factory:

class Junkie:
	def init(self,
		usine_de_drogue=drogue_factory("coco")):
		
		self.drogue = usine_de_drogue()

# exemple:
Bob = Junkie(drogue=drogue_factory("Ganja"))

Je ne dis pas que c'est ce que vous allez faire, je vous donne cet exemple pour que vous compreniez la suite. L'idée, ici, c'est qu'on peut générer des drogues à injecter au Junkie de manière procédurale. La suite est plus claire, on se détend.

Python permet de faire plus simple. Beaaaauuucoup plus simple. Car il a un avantage, il permet de passer "un type", donc une classe non instanciée. Du coup, la factory... on peut s'en passer !

Python et les joies de l'instanciation

En python on a un avantage certain, qui à la base est critiqué par pas mal de gens qui viennent de Java, de Php et autre: on pas de mot clef new.

Du coup, on instancie un objet en appelant la classe comme une fonction. Ça parait idiot comme ça, mais ça permet de faire des roulettes de ninja (special dedicace to Greg) qui vont nous ouvrir les portes du possible.

Voilà ma nouvelle classe, elle prend en argument un type ou une fonction (argument "drogue")!

class Junkie:
	def __init__(self, drogue=Drogue):
		# remarquez l'argument, pas de parenthèses, donc
		# on défini que drogue correspond à Drogue
		self.drogue = drogue() 

Donc de base, je peux créer un Junkie de cette manière:

cocoman = Junkie()

Et si j'ai un fumeur de Ganja à créer, je passe un autre type de drogue:

# remarquez que je n'instancie pas de la mariejeane
# je passe la classe !
BobMarley = Junkie(drogue=MarieJeane)

Je vais insister mais... soyons clairs ! Dans le constructeur, on appelle self.drogue = drogue() ce qui induit que je fais un appel:

  • soit à un constructeur
  • soit à une fonction

Si jamais je passe une fonction au paramètre "drogue", elle sera appelée. Vous me voyez venir ?

Passons une factory à la place:

# Appel standard avec un type
# On passe la classe à Junkie
BobMarley = Junkie(drogue=MarieJeanne)
# dans le constructeur, la classe MarieJeanne
# sera instancié via drogue()

# une factory pour une drogue spéciale
def usine_a_placebo():
	placebo = Drogue()
	# je vire l'effet de la drogue
	# car c'est un placébo
	placebo.effet = None
	return placebo

tricheur = Junkie(drogue=usine_a_placebo)
# et oui... quand le constructeur va appeler
# drogue(), il n'instancie pas un objet, mais
# appelle l'usine... qui retourne une instance
# ho bha ça ressemble à une factory dis donc !

Sans changer l'implémentation de Junkie j'ai bien changé le comportement de mon code. Parce que Python permet de faire ce genre de chose.

Et voilà... Python me permet de faire de la DI programmatiquement sans trop d'effort. Vous comprennez ce que je veux dire maintenant par "pas besoin de framework de DI en Python en règle générale".

Et le bouquet final

Alors vous vous dites "l'est sympa l'auteur de l'article là, mais Symfony il me permet de configurer l'injection en dehors du code".

C'est pas faux. Alors revoyons notre code. Imaginons un fichier de settings:

USE_DROGUE=Ganja

Dans notre code:

import settings

Bob = Junkie(drogue=settings.USE_DROGUE)

C'était compliqué hein...

OK... j'ai compris... on va plus loin. Gérons une jolie factory:

import inspect

def drogue_factory(drogue):
	if inspect.isclass(drogue) or \
		inspect.isfunction(drogue):
		# c'est une classe ou une fonction,
		# on retourne le résultat
		return drogue()
		
	if type(drogue) in (str, unicode):
		# une chaine de caractères
		if drogue == u"Heroïne":
			return Heroine(dose="10ml")
		if drogue == "Ganja":
			return Mariejeane(collage="double",
							 longueur="1 bon mètre")
		if drogue == "Placebo":
			return Tisane(temps_infusion="5minutes")

Ce qui fait que ce code marche:

# settings.py
USE_DROGUE="Placebo"

Notre classe:

class Junkie:
	def __init__(self, 
		drogue=settings.USE_DROGUE,
		factory=drogue_factory):
		
		self.drogue = factory(genre)

Et par conséquent:

tricheur = Junkie()
# donc... dans la classe factory(genre) equivaut à  
# appeler drogue_factory(settings.USE_DROGUE)
# et donc tricheur.drogue est une Tisane

bob = Junkie(drogue=MarieJeanne)
# donc... dans la classe drogue() va appeler
# drogue_factory(MarieJeanne).
# Le test "inspect.isclass" est vrai, on a
# une instance de MarieJeanne

kurt = Junkie(drogue=u"Heroïne")
# là on passe un unicode, pareil ça marche...

# une factory spéciale
def preparation_spacecake(drogue):
	# on prépare le gateau, on le retourne
	gateau = SpaceCake()

    # inject la drogue dans les ingrédients
	gateau.ingredients = [
		drogue(), "farine", "sucre", "levure"
	]
    gateau.cuit_au_four()
	gateau.poids = "800g"
	return gateau
	
tom = Junkie(factory=preparation_spacecake,
				drogue=Canabis)
# du moment que la factory respecte le prototype
# demandé, ça fonctionne. Junkie utilise alors
# preparation_spacecake(Canabis).
# Dans la fonction de
# préparation de gateau, "drogue" 
# est de type Canabis, on l'insert dans les ingrédients
# et on retourne le gateau en tant que drogue 

L'injection est donc bien définie dans un fichier de settings et on peut en plus modifier le comportement. Que ce soit en donnant une autre factory, en donnant un type spécifique de drogue ou en modifiant le fichier de settings. La classe Junkie n'est plus dépendante de l'instanciation, mais elle dépend de ce que vous lui injectez.

@metal3d
Copy link
Author

metal3d commented Feb 4, 2020

Super article autant technique que drôle !

😄

@gabibouty
Copy link

Salut et sympa l'article, mais je comprends pas trop pourquoi tu oppose l'injection de dépendance en Python, à l'injection en Java par exemple. Alors attention, je suis étudiant, donc je peux dire des conneries... En effet, le typage dure de Java rend l'injection un peu plus lourde à écrire (que ce soit de l'injection par mutateur, constructeur, factory, ou que sais-je...), mais le principe reste le même, on découple au max, et on injecte les dépendances le plus tard possible (à l'exécution en fait). Pour le côté fonctionnel, c'est sur que là aussi avec Java par exemple, c'est bien plus compliqué (et encore que...). Bref dans tout les cas c'est cool de remettre une couche sur les DI, merci à toi (d'autant qu'en effet, en Python, certaines choses deviennent vraiment easy !). Au passage, l'article de Martin Fowler en français qui décrit aussi ce patron (car oui! c'est un patron !).

@loicteillard
Copy link

loicteillard commented Mar 29, 2022

Très sympa à lire, et toujours pertinent en 2022 ! 😉

Je rajoute juste à propos de cette remarque :
Petite parenthèse, normalement on devrait utiliser des "intefaces", mais Python connait pas ça...

Maintenant on peut simuler 1 interface en Python (bien qu'on peut s'en passer on est d'accord c'est juste si on veut) :

from __future__ import annotations
from abc import ABC, abstractmethod

class Virus(ABC):  # Interface
    @abstractmethod
    def mutate(self):
        pass

    @abstractmethod
    def spread(self):
        pass

class CoronaVirus(Virus):
    def mutate(self):
        print("Mutating the corona virus...")

    def spread(self):
        print("Spreading the corona virus...")

si la méthode spread dans CoronaVirus n'est pas présente ça soulèvera une erreur.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment