Usine de classes Python

English   |   Source

Les usines de classes (class factory) sont une manière de faire du design pattern en Python, ou plus simplement de définir des classes génériques (ou abstraites). L'avantage de cette méthode est de décorréler les objets manipulés et leur interface, en offrant plus de possibilités et de flexibilité que le simple héritage.

En prime, une manière élégante de manipuler les usines de classe est d'utiliser des décorateurs ; c'est donc un bon moyen de mettre en pratique des concepts pythonesques pas toujours faciles à appréhender.

Le code source utilisé dans ce poste peut se retrouver sur GitHub.

Usine de classe

La première partie consiste en une définition d'une classe qui nous servira d'usine ; c'est à dire que c'est cette classe qui permettra de brancher la classe abstraite sur la bonne implémentation de classe :

# Factory class
class DataFactory(object):
    data_maker = {}

    # Decorator to register a new class
    @classmethod
    def register_data(cls, typ):
    def wrapper(maker):
            cls.data_maker[typ] = maker
            return cls
        return wrapper

    # Class maker for new classes with explicit error message if the class does not exist
    @classmethod
    def make_data(cls, typ, *args, **kwargs):
        try:
            return cls.data_maker[typ](*args, **kwargs)
        except KeyError:
            print("TypeError\n'" + typ + "' extension does not exist!")

On y trouve deux méthodes :

  • une méthode register_data — à noter : il s'agit d'un décorateur — qui permet d'enregistrer une nouvelle implémentation de classe ;
  • une méthode make_data qui va permettre de brancher la classe abstraite sur l'implémentation voulue. Ici, on utilise un critère (une extension de fichier, comme on le verra plus explicitement par la suite) pour faire ce choix.

Interface générique

La seconde étape consiste en définissant une classe générique qui servira d'interface utilisateur. Au final, c'est uniquement par cette classe que l'on passera pour adresser n'importe quelle autre implémentation :

# User Interface class: generic class the user will use
class UIClass(object):
    """Generic User class:
    Use this class to do stuff on dummy files.

    Usage:
      myobj = UIClass(fname)
      myobj.load()
    With:
      fname: dummy file name
    """
    def __init__(self, fname):
        self.fname = fname
    # List of data we will retrieve from ad hoc classes
        self._data = ['n', 'x', 'y', 'str']

    # Some method that is calling the method that is calling the class maker
    def load(self):
        ext = self.fname.split('.')[-1]
        self.ext = ext
        self._get_data(self.fname, ext)

    # Method that calls the class maker
    def _get_data(self, fname, ext):
        data_reader = DataFactory.make_data(ext)
        data_reader.load(fname=fname)
        # We transparently load attributes from the created class; we get only the ones defined in _d\
ata
        for attr in self._data:
            self.__setattr__(attr, data_reader.__getattribute__(attr))
        # Get extra methods if defined -- note that the same could be used to discover attributes
        # instead of explicitely defining them in __init__
        for attr in dir(data_reader):
            if callable(getattr(data_reader, attr)) and not attr in dir(self):
                self.__setattr__(attr, data_reader.__getattribute__(attr))

Ici, la méthode load() fait en fait appel à une méthode cachée _get_data() qui utilise elle l'usine de classe, en réalisant le branchement en fonction de l'extension.

La principale astuce ici est de récupérer les attributs et méthodes des classes sous-jacentes, et il existe deux possibilités, toutes deux exposées ici :

  • définir explicitement dans le __init__ les attributs ou méthodes que l'on souhaite récupérer (ce qui en général n'est pas si problématique si on développe soi-même les classes sous-jacentes, et donc qu'on en connaît le contenu) ;
  • découvrir, grace à getattr, les attributs ou méthodes. Cette approche à le mérite d'être à la fois extrêmement générique et de permettre d'avoir des interfaces différentes en fonction des classes sous-jacentes (ce qui est fait de manière très simple par la suite, mais qui peut se comprendre d'un point de vue pratique : imaginons que nous développons une classe pour manipuler des fichiers, on pourrait souhaiter afficher un fichier image avec un visionneur d'image, alors que faire de même avec un fichier binaire n'aurait aucun sens).

Classe générique

On va ensuite hériter d'une classe générique qui sert de caneva pour les autres implémentations :

# Generic class to build the ad hoc classes; we will inherit from this class
class DataReader(object):
    def __init__(self):
    self.n = 0

    # Hidden empty method that will be defined in the ad hoc class
    def _load(self, *args, **kwargs):
        pass

    # Method used to call the hidden method that will be defined in the ad hoc class
    def load(self, fname):
        self.str = "If defined, then it was loaded"
        self._load(fname)

Cette classe est globalement une coquille vide, mais elle contient l'essentiel.

Définition des classes ad hoc

Pour l'exemple, on va définir deux classes sous-jacentes (pour les extensions .a et .b) :

# Ad hoc class for extension 'a'
@DataFactory.register_data('a')
class ADataReader(DataReader):
    def __init__(self):
    DataReader.__init__(self)
    self.x = 42
    self.y = 42

    # Actual definition of the previously empty method to do stuff
    def _load(self, fname):
    print("fname: {}".format(fname))

# Ad hoc class for extension 'b'
@DataFactory.register_data('b')
class BDataReader(DataReader):
    def __init__(self):
    DataReader.__init__(self)
    self.x = 'test'
    self.y = [i for i in range(10)]

    # Actual definition of the previously empty method to do stuff
    def _load(self, fname):
    print("fname extension: {}".format(fname.split('.')[-1]))

    def do_smthg(self):
    print("Specific method only for BDataReader")

Plusieurs choses sont à noter :

  • l'usage du décorateur : on enregistre chaque nouvelle classe avec le décorateur register_data qui vient de l'usine de classe. C'est grâce à cet identifiant qu'on pourra ensuite brancher à la volée la classe utilisée en interface utilisateur sur la bonne implémentation ;
  • les attributs : qui peuvent être totalement différents, certains définis au niveau de la classe mère, d'autre au niveau des classes filles, avec des types différents ;
  • les méthodes : qui là aussi sont différentes, mais on remarquera que la classe BDataReader contient même une méthode qui n'existe pas dans les autres classes... et qui pourra néanmoins être utilisée car « découverte » au niveau de la classe d'interface.

Il ne reste plus qu'à tester...

Pour s'assurer qu'on obtient le comportement attendu :

obj_a = UIClass("file.a")
obj_a.load()

obj_b = UIClass("file.b")
obj_b.load()