Objets proxy et pattern adapter en Python


En informatique, le vocabulaire c’est une bonne partie du travail. On a des tas de termes comme polymorphisme, récursivité, idempotent ou closure. Certains sont des termes mathématiques, d’autres sont des anglicismes, mais la majorité sont juste des mots compliqués pour décrire des choses simples.

Vous connaissez mon manque d’attrait pour ça, on va donc clarifier.

Pour comprendre cet article, il vous faut les pré-requis suivants :

  • Être à l’aise avec avec la programmation orientée objet : héritage, property…
  • Bien comprendre la notion de référence.
  • Avoir un peu de temps devant soi.

Et pour une fois, on va mettre un morceau qui colle bien au blog :

S’il vous plait, dessine moi un objet proxy

Un proxy, en anglais, c’est un mandataire, c’est à dire grosso merdo un intermédiaire. Un objet proxy est donc tout simplement un objet qui fait l’intermédiaire, généralement entre un objet et un autre.

Exemple, on a des enseignants qui vont avoir besoin d’une autorisation de sortie pour des enfants dans leur classe. Un enfant ne peut pas décider de cela, ses parents décident de ce genre de chose :

class Enseignant(object):
 
    # la demande d'autorisation attend un objet enfant en paramètre
    def demande_autorisation_de_sortie(self, enfant):
        return enfant.peut_sortir()
 
 
class Enfant(object):
 
    def __init__(self, age):
        self.age = age
 
    # mais un enfant ne peut pas donner cette information
    def peut_sortir(self):
        raise NotImplementedError('Un enfant ne peut decider de cela')
 
 
class Parent(object):
 
    # le parent va faire proxy sur l'objet enfant
    def __init__(self, enfant):
        self.enfant = enfant
 
    # et propose la même méthode que l'objet enfant afin de pouvoir
    # être passé en paramètre à sa place à la méthode
    # demande_autorisation_de_sortie()
    def peut_sortir(self):
        return self.enfant.age > 10

Ici, si la classe Enseignant appelle directement la méthode peut_sortir() de la classe Enfant, on obtient une erreur. :

>>> prof = Enseignant()
>>> enfant = Enfant(11)
>>> prof.demande_autorisation_de_sortie(enfant)
Traceback (most recent call last):
  File "<ipython-input-23-6d509f016e40>", line 1, in <module>
    prof.demande_autorisation_de_sortie(enfant)
  File "<ipython-input-18-e8d34178df4c>", line 4, in demande_autorisation_de_sortie
    return enfant.peut_sortir()
  File "<ipython-input-18-e8d34178df4c>", line 13, in peut_sortir
    raise NotImplementedError('Un enfant ne peut decider de cela')
NotImplementedError: Un enfant ne peut decider de cela

Nous avons créé un système où on est obligé de créer une instance de Parent, qui va se placer entre la classe Enseignant et la classe Enfant, et qui va retourner la valeur pour cette méthode :

>>> parent = Parent(enfant) # on passe l'enfant en paramètre au parent
>>> prof.demande_autorisation_de_sortie(parent) # et le parent remplace l'enfant
True

Ceci est un objet proxy, c’est à dire un objet qui se place entre deux objets, et fait l’intermédiaire. Pour que cela marche, il faut que l’objet proxy, ici l’instance de Parent, ait la même interface (les mêmes méthodes, avec les mêmes paramètres et noms) que l’objet derrière le proxy, ici l’instance de Enfant.

Dans notre cas, c’est académique, ça ne sert pas à grand chose. Mais il y a des tas de cas utiles pour les objets proxy.

Ainsi, on veut parfois créer des objets dit “lazy”, littéralement “paresseux”, c’est à dire pas évalués tout de suite. C’est ce que fait Django dans le cadre de certaines chaînes de caractères traduites. En effet, en Django, il existe une fonction lazy_gettex() que l’on peut appliquer sur les chaînes à traduire. Mais elle ne retourne pas une string, car on ne connait pas encore la langue dans laquelle il faut traduire la chaîne : la langue dépend de l’utilisateur qui viendra demander la page Web en question. lazy_gettex() retourne un objet proxy, et quand, à l’appel de la page Web, on tente de manipuler cet objet comme une string pour l’afficher, le proxy va chercher la traduction, et retourne la bonne chaîne. Pour que ça marche, il faut que l’interface de l’objet lazy ressemble trait pour trait à celui d’une chaîne.

Dans d’autres cas on veut mettre un proxy pour gérer des permissions, et selon le niveau de permission, l’objet proxy donne accès ou non à l’objet derrière lui.

Le design pattern décorateur, le fameux @truc en Python, n’est jamais qu’un objet fonction qui fait proxy vers une autre fonction qu’on a enrobé.

Mais la raison majeur pour laquelle on va utiliser un objet proxy est le design pattern “adapter”.

Ce n’est pas un chapeau, c’est un boa qui a avalé un éléphant

En informatique, on aime bien les traitements généralistes.

Par exemple, imaginez cet objet qui dit si on a le droit de se bourrer la gueule :

class Videur(object):
 
    def tu_peux_rentrer(self, personne):
        """
            Cette fonction n'est pas réaliste, puisque bien entendu une mineure
            à gros nichons rentrerait, et un arabe en short lacoste non,
            mais heureusement l'informatique est plus simple que notre société.
        """
        return personne.age >= 18
 
class Personne(object):
    def __init__(self, age):
        self.age = age

Et tant que vous avez la main sur le système, tout va bien dans le meilleur des mondes :

>>> import random
>>> clients = [Personne(random.randint(12, 65)) for x in xrange(random.randint(0, 100))]
>>> videur = Videur()
>>> for client in clients:
...     if videur.tu_peux_rentrer(client):
...         print("Glouglou")
...
Glouglou
Glouglou
Glouglou
Glouglou
Glouglou
...

Mais malheureusement la vie n’est pas toujours pleine de licornes et de bisounours, et votre collègue, lui, il a codé une lib en C qui a extrait les clients d’un XML, et quand vous vous mappez directement dessus avec ctype, vous obtenez des clients comme ça:

class Client(object):
    def __init__(self, majeur):
        self.majeur = majeur

Bon, vous vous dites que c’est pas grave. Après tout, c’est juste une condition à rajouter :

class Videur(object):
 
    def tu_peux_rentrer(self, personne):
 
        if hasattr(personne, 'age'):
            return personne.age >= 18
 
        return personne.majeur

Ouai mais votre commercial arrive avec un super contrat avec pillierdebar.com, et il faut s’interfacer avec leur API SOAP de 1997, dont la deserialisation vous retourne :

class Prospect(object):
    def __init__(self, datetime):
        self.date_de_naissance = datetime

Les connards. Ah les connards.

Mais bon, c’est pas grave, c’est juste une petite condition à rajouter.

import datetime
 
class Videur(object):
 
    def tu_peux_rentrer(self, personne):
 
        if hasattr(personne, 'age'):
            return personne.age >= 18
 
        if hasattr(personne, 'date_de_naissance'):
            # TODO: prendre en compte les années bisextiles
            return (datetime.datetime.now() - personne.date_de_naissance) / 365 > 18
 
        return personne.majeur

Bon, vous allez enfin pouvoir soufll…. Ahhhh ! Votre boss appelle, il a oublié de vous dire, le stagiaire travaille aussi sur une feature, et a fait son propre objet Prospect, c’est comme l’objet Client mais pas pareil. Non on ne peut pas changer. Oui il a fait de la merde mais si on change tout son code pête. Il se barre demain. Il n’a pas fait de test. Démerde toi.

class LeClient(object):
    def __init__(self, majeur_de_combien):
        self.__majeur_de_combien = majeur_de_combien
 
    def get_majeur_de_combien(self):
        return self.__majeur_de_combien

A ce stade là, le code est tellement moche de toute façon, vous vous en branlez complètement. Vous allez rentrer jouer Left4Dead avec un mod qui transforme les zombies pour qu’ils aient la tête de votre patron. Le reste n’est que détail d’implémentation.

class Videur(object):
 
    def tu_peux_rentrer(self, personne):
 
        if hasattr(personne, 'age'):
            return personne.age >= 18
 
        if hasattr(personne, 'date_de_naissance'):
            # TODO: prendre en compte les années bisextiles
            return (datetime.datetime.now() - personne.date_de_naissance) / 365 > 18
 
        if hasattr(personne, 'get_majeur_de_combien'):
            # retourne le nombre d'années après 18 ans ou -1. Don't ask...
            return personne.get_majeur_de_combien() > -1
 
        return personne.majeur

Quelque moi(s) plus tard, les specs changent. Il faut maintenant pouvoir vérifier la carte d’identité des clients. Ou la carte de membre. Et garder l’ancien système compatible bien entendu. Ah, et il y a aussi des controleurs, c’est comme des videurs, mais qui doivent vérifier si les personnes dans le bar sont majeurs, sauf qu’il n’acceptent pas la carte de membre ou la date de naissance donnée à l’oral.

class CarteDeMembre(object):
    pass
 
 
class CarteIdentité(object):
    def __init__(self, date_de_naissance):
        self.date_de_naissance = date_de_naissance
 
 
class Client(object):
    def __init__(self, majeur=None, carte_identite=None, carte_membre=None):
        self.majeur = majeur
        self.carte_identite = carte_identite
        self.carte_membre = None
 
 
class Videur(object):
 
    def tu_peux_rentrer(self, personne):
 
        if hasattr(personne, 'age') and personne.age is not None:
            return personne.age >= 18
 
        if hasattr(personne, 'carte_membre'):
            return True
 
        if hasattr(personne, 'carte_identite') and personne.carte_identite is not None:
            now = datetime.datetime.now()
            return (now - personne.carte_identite.date_de_naissance) / 365 > 18
 
        if hasattr(personne, 'date_de_naissance'):
            # TODO: prendre en compte les années bisextiles
            return (datetime.datetime.now() - personne.date_de_naissance) / 365 > 18
 
        if hasattr(personne, 'get_majeur_de_combien'):
            # retourne le nombre d'années après 18 ans ou -1. Don't ask...
            return personne.get_majeur_de_combien() > -1
 
        return personne.majeur
 
 
class Controleur(object):
 
    def est_autorise(self, personne):
 
        if hasattr(personne, 'age') and personne.age is not None:
            return personne.age >= 18
 
        if hasattr(personne, 'carte_identite') and personne.carte_identite is not None:
            now = datetime.datetime.now()
            return (now - personne.carte_identite.date_de_naissance) / 365 > 18
 
        if hasattr(personne, 'date_de_naissance'):
            # TODO: prendre en compte les années bisextiles
            return (datetime.datetime.now() - personne.date_de_naissance) / 365 > 18
 
        if hasattr(personne, 'get_majeur_de_combien'):
            # retourne le nombre d'années après 18 ans ou -1. Don't ask...
            return personne.get_majeur_de_combien() > -1
 
        return personne.majeur

Bon, là c’est la merde. Vous le sentez, demain il va y avoir un portail électronique qui check uniquement les cartes de membres. Quand il va falloir faire une modif quelque part, ça va être galère. La maintenance va être un enfer. Le debuggage, bien relou. Il faut trouver un moyen de faire plus propre.

C’est ici qu’entre en jeu le pattern “adapter”. Un adapter, c’est comme les adaptateurs pour prises, mais pour les objets. C’est un proxy qu’on met devant pour que la prise ressemble à une autre.

Dans le domaine du code, ça veut dire qu’on va enrober chaque objet dans un autre pour qu’ils aient tous la même interface. Le traitement du coup sera de nouveau très simple, et la complexité sera répartie dans les adapters.

 
# on met le code commun dans un parent abstrait, notamment le check pour
# savoir si un adapter convient a un objet donné
class Adapteur(object):
 
    cls = None
 
    def __init__(self, objet):
        self.objet = objet
 
    def peut_adapter(self, objet):
        return self.cls == type(objet)
 
 
# un adapter, c'est juste un objet proxy qui arrondit les angles
class PersonneAdapteur(Adapteur):
 
    cls = Personne
 
    # on va réduire la complexité en faisant en sorte que tous les checks
    # soient cachés derrière une simple property
    @property
    def majeur(self):
        return self.objet >= 18
 
 
class ProspectAdapteur(Adapteur):
 
    cls = Prospect
 
    @property
    def majeur(self):
        # TODO: prendre en compte les années bisextiles
        return (datetime.datetime.now() - self.objet.date_de_naissance) / 365 > 18
 
 
class LeClientAdapteur(Adapteur):
 
    cls = LeClient
 
    @property
    def majeur(self):
        # retourne le nombre d'années après 18 ans ou -1. Don't ask...
        return self.objet.get_majeur_de_combien() > -1
 
 
class ClientAdapteur(Adapteur):
 
    cls = Client
 
    def __init__(self, objet):
        self.objet = objet
 
    @property
    def majeur(self):
 
        if self.accepte_carte_membre and self.objet.carte_membre is not None:
            return True
 
        if self.objet.majeur:
            return True
 
        if self.object.carte_identite is not None:
            now = datetime.datetime.now()
            # TODO: prendre en compte les années bisextiles
            return (now - personne.carte_identite.date_de_naissance) / 365 > 18
 
        return False

Le contrôleur et le videur deviennent du coup beaucoup plus simples, maintenant que la logique est normalisée :

class VerificateurDeMajorite(object):
    def check_majorite(self, personne):
        for adapteur in self.adapteurs:
            if adapteur.peut_adapter(personne):
                return adapteur(personne).majeur
 
# du coup un vérificateur, c'est très déclaratif
class Videur(VerificateurDeMajorite):
 
    adapteurs = (
        PersonneAdapteur,
        ClientAdapteur,
        ProspectAdapteur,
        LeClientAdapteur,
    )
 
    def tu_peux_rentrer(self, personne):
        return self.check_majorite(personne)
 
 
class Controleur(VerificateurDeMajorite):
 
    adapteurs = (
        PersonneAdapteur,
        # ProspectAdapteur()
        LeClientAdapteur(),
    )
 
    def est_autorise(self, personne):
        return self.check_majorite(personne)

Si il faut ajouter un autre format de client, il suffit de coder un adapteur. Si il faut ajouter une autre classe de vérification, il suffit de lui donner les bons adapteurs.

Et le traitement est aussi simple, car si vous avez une liste qui contient plein de clients différents :

>>> clients = [Client(majeur=True), Client(carte_de_membre=CarteDeMembre()), Client(carte_identite=CarteIdentité(datetime.datetime.now()), LeClient(-1), Personne(age=16)...]

Vous pouvez quand même vérifier tout ça sans y réfléchir:

>>> videur = Videur()
>>> for client in clients:
...     if videur.tu_peux_rentrer(client):
...         print("Glouglou")
...
Glouglou
Glouglou
...
 
>>> controlleur = Controleur()
>>> for client in clients:
...     if not controlleur.est_autorise(client):
...         print("Ahhhh !")
...
Ahhhh !
Ahhhh !
...

Dans un cas comme celui de notre exemple, l’adapter est un peu overkill, on s’en tirerait aussi bien avec une liste de fonctions, et le code serait plus léger et lisible.

Mais quand les objets et la logique deviennent très complexes, tout ramener à une interface commune permet de simplifier énormément le code.

14 thoughts on “Objets proxy et pattern adapter en Python

  • freakazoid

    pré-requis suivant -> pré-requis suivants
    les traitemenst généralistes. -> les traitements généralistes.
    Il faut trouver une moyen -> Il faut trouver un moyen
    Le controleur et le videur deviennt -> Le contrôleur et le videur deviennent

    merci pour l’acticle

  • Mojowork

    D’la balle je l.attendais celui là car je bosse pas mal sur Plone et c’en est pété de ses adapteurs.
    Le début de l’article est bien détaillé mais par contre le passage à la version “adapteurs ” va un peu vite je trouve.
    Mais Merci !!

  • Stéphane

    une mineur -> une mineure
    c’est comme objet Client -> c’est comme l’objet Client
    un objet proxy qui arrondie -> un objet proxy qui arrondit

  • Sam Post author

    Il faudrait intégrer un outil “suggérer une modification” directement dans le blog, avec possibilité de merger ^^

  • mek

    Merci Sam pour le tuto.
    Je pense avoir bien compris le “pattern adapter” mais le tuto me marche pas chez moi avec la declaration ci-dessous:

    adapteurs = (
    PersonneAdapteur(),
    ClientAdapteur(),
    ProspectAdapteur(),
    LeClientAdapteur(),
    )

    Traceback (most recent call last):
    File “/home/workspace/tutorial-python/main.py”, line 115, in
    class Videur(VerificateurDeMajorite):
    File “/home/workspace/tutorial-python/main.py”, line 118, in Videur
    PersonneAdapteur(),
    TypeError: __init__() takes exactly 2 arguments (1 given)

    Peux tu apporter plus de précisions?

  • Sam Post author

    Oui, j’ai mis des instances d’adapteurs au lieu des classes, c’est une erreur de ma part. Il faut faire :

    class Videur(VerificateurDeMajorite):
     
        adapteurs = (
            PersonneAdapteur,
            ClientAdapteur,
            ProspectAdapteur,
            LeClientAdapteur,
        )

    Et NON:

    class Videur(VerificateurDeMajorite):
     
        adapteurs = (
            PersonneAdapteur(),
            ClientAdapteur(),
            ProspectAdapteur(),
            LeClientAdapteur(),
        )

    C’est corrigé.

  • Sam Post author

    J’ai aussi viré l’adapteur configurable, car ça n’aurait pas marché comme ça. Si l’on souhaite faire l’adapteur configurable, il faut définir les paramètres via :

        adapteurs = (
            (PersonneAdapteur, [les paramètres]),
            (ClientAdapteur, [les paramètres]),
            (ProspectAdapteur, [les paramètres]),
            (LeClientAdapteur, [les paramètres]),
        )

    Et faire un truc comme ça à l’instanciation :

    class VerificateurDeMajorite(object):
        def check_majorite(self, personne):
            for adapteur, params in self.adapteurs:
                if adapteur.peut_adapter(personne):
                    return adapteur(personne, *params).majeur
  • vv

    juste une petite correction

    class Adapteur(object):

    cls = None

    def __init__(self, objet):
        self.objet = objet
    
    @classmethod
    def peut_adapter(cls, objet):
        return cls == type(objet)
    
  • Sam Post author

    Non cls n’est pas la classe Adapteur, mais celle que l’adapteur est capable d’enrober, définie dans l’attribut de class au dessus de la méthode init.

  • Nico

    J’avais rien compris quand je l’avais vu utilisé, du coup incapable d’en voir l’intérêt, et là j’ai tout compris. Merci! Ps: reste des parenthèses d’instanciation dans le dernier controleur

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.