Le pattern Dispatcher en Python

Ou comment avoir la flemme avec élégance

Le Dispatcher est un design pattern que j'aime beaucoup utiliser en Python car il permet de concevoir un programme de façon événementielle, c'est-à-dire comme une collection de callbacks qui sont utilisés en réaction à des évènements.

Il est très utile lorsque l'on veut donner à l'utilisateur de sa classe un moyen générique d'en personnaliser le comportement. On peut penser, par exemple, à un bot dont on voudrait qu'il réagisse à des commandes simples sur un canal de chat, ou un framework web, dans lequel on associe des actions à des URL, ou même un Visitor, dont on se sert pour traverser de grandes arborescences d'objets pour traiter ces objets de façon adéquate selon leur type, comme les arbres syntaxiques (AST) dans un compilateur.

En fait, ces dernières années, j'ai tellement passé de temps à réimplémenter ce pattern chaque fois que j'en ai eu besoin, que j'ai fini par le raffiner au point de chercher une façon de l'ajouter à n'importe quelle classe juste en héritant du mixin-qui-va-bien. Et c'est cette implémentation que je vais détailler au travers de ce tutoriel.

Un exemple pour commencer : un chatter bot

Imaginons que nous voulions programmer un bot pour un système de chat quelconque. Celui-ci pourrait réagir facilement à des commandes que l'on lui enverrait depuis un client de chat à la façon des bots IRC. Par exemple, si on lui envoie la commande !ping, le bot répondra pong, et si on lui envoie la commande !date, il nous retournera la date et l'heure.

C'est le genre de programme qu'un pattern Dispatcher rend tout à fait élégant à programmer : typiquement, dans ce genre de programme, on veut séparer le code des différentes commandes de celui qui est responsable d'administrer le bot et sa communication avec le réseau, par exemple.

Voici comment on pourrait réaliser cette partie de notre bot :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from contextlib import suppress

class ChatBot:
    _callbacks = {}

    def react(self, message):
        """
        Process a message.
        Commands are assumed to follow this syntax:

            !<command> [<args>...]

        When a command is identified, find the corresponding callback and
        let it handle the command.

        """
        if not message.startswith('!'):
            return

        cmd, *args = message.split()
        cmd = cmd.lstrip('!')
        with suppress(KeyError):
            self._callbacks[cmd](self, *args)

    @classmethod
    def register_cmd(cls, callback):
        """
        Decorator to register a new callback.

        A callback should have the same name as its corresponding command.
        It takes an instance of the calling ChatBot object as its first
        argument and may accept an arbitrary number of positional arguments.

        """
        cls._callbacks[callback.__name__] = callback
        return callback

Notre ChatBot consiste en fait uniquement en ces deux méthodes :

  • Le décorateur @ChatBot.register_cmd va servir à définir de nouveaux callbacks et les enregistrer auprès de la classe ChatBot, dans l'attribut de classe _callbacks,
  • La méthode bot.react(message) identifie les messages qui contiennent des commandes, et exécute les commandes lorsqu'il trouve le callback correspondant.

Les commandes, quant à elles, sont définies hors de la classe. Comme ceci:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@ChatBot.register_cmd
def ping(bot, *args):
    """
    Usage: !ping

    Answer "pong!".
    """
    print("pong!")

@ChatBot.register_cmd
def list_cmds(bot, *args):
    """
    Usage: !list_cmds

    List all available commands.
    """
    print("Available commands: ")
    print(', '.join(sorted(bot._callbacks.keys())))


@ChatBot.register_cmd
def help(bot, cmd=None, *args):
    """
    Usage: !help [command]

    Show help about commands.
    """
    if not cmd:
        list_cmds(bot)
        print("\nType '!help <command>' to get help about specific commands")
        return
    cmd = bot._callbacks.get(cmd, None)
    if cmd is None:
        return
    doc = cmd.__doc__
    if not doc:
        return
    print('\n'.join(line.strip() for line in doc.strip().split('\n')))

Comme vous le voyez, il suffit de créer une fonction qui prend une instance de la classe ChatBot en premier argument pour implémenter une commande, et de lui appliquer le décorateur @ChatBot.register_cmd pour l'ajouter au robot. Notez que j'utilise ici une petite astuce qui consiste à se servir des docstrings de ces callbacks pour générer les messages d'aide du robot. :)

Ce qu'il est important de noter ici, c'est que les développeurs n'ont plus du tout besoin de se soucier du fonctionnement interne du bot lorsque leur but est simplement de lui ajouter de nouveaux comportements. Ils peuvent se concentrer uniquement sur l'écriture du callback et de sa doc.

Vérifions son fonctionnement dans la console :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> bot = ChatBot()
>>> bot.react("Hello !")
>>> bot.react("!help")
Available commands:
help, list_cmds, ping

Type '!help <command>' to get help about specific commands
>>> bot.react("!help ping")
Usage: !ping

Answer "pong!".
>>> bot.react("!ping")
pong!

En somme, le fait que cette classe ChatBot soit conçue comme un dispatcher permet de découpler complètement l'implémentation des commandes auxquelles il doit réagir de tout le reste de la classe, ce qui le rend facile à maintenir et à étendre.

Un dispatcher générique

Bon, c'est bien joli de créer un dispatcher en deux méthodes, mais il faut quand même avouer que ces méthodes ne sont pas évidentes à se rappeler. Ce serait plutôt cool qu'on puisse créer un dispatcher juste en héritant de la classe-qui-va-bien, pour ne pas risquer de se tromper en le codant. D'ailleurs, le top, ce serait qu'il existe un mixin, qui puisse apporter la totalité de cette fonctionnalité à n'importe quelle classe.

Et ce n'est pas trivial, figurez-vous !

Anatomie d'un dispatcher

Avant d'aller plus loin, il faut caractériser ce qui définit un dispatcher. Concrètement, on a besoin d'au moins trois éléments :

  • Une structure (un mapping, comme un dictionnaire) dans laquelle on référence les callbacks.
  • Une méthode de classe permettant d'enregistrer un nouveau callback, et de préférence, qui se décline en deux versions : une version explicite et une version décorateur. Cette méthode s'appellerait register.
  • Une méthode permettant de récupérer un callback au moyen de sa clé. Cette méthode (d'instance) s'appellerait dispatch.

Cassons-nous les dents sur une première implémentation…

On pourrait partir bille en tête en définissant notre mixin de façon directe, comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class WrongDispatcher:
    __callbacks__ = {}

    @classmethod
    def set_callback(cls, key, cbk):
        cls.__callbacks__[key] = cbk
        return cbk

    @classmethod
    def register(cls, key):
        def wrapper(cbk):
            return cls.set_callback(key, cbk)
        return wrapper

    @property
    def dispatcher(self):
        return self.__callbacks__

    def dispatch(self, key, default=None):
        return self.__callbacks__.get(key, default)

Après tout, cette classe implémente exactement l'anatomie du Dispatcher telle que nous l'avons décrite à l'instant. Après un rapide test, on pourrait même croire que ça marche :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> class A(WrongDispatcher):
...     pass
...
>>> @A.register('foo')
... def foo(): pass
...
>>> a = A()
>>> a.dispatcher
{'foo': <function foo at 0x7f637e4b1950>}
>>> a.dispatch('foo')
<function foo at 0x7f637e4b1950>

Mais regardez ce qu'il se passe lorsque l'on crée un second dispatcher :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> class B(WrongDispatcher):
...     pass
...
>>> @B.register('bar')
... def bar(): pass
...
>>> b = B()
>>> b.dispatch('bar')
<function bar at 0x7f637e4b19d8>
>>> b.dispatch('foo')
<function foo at 0x7f637e4b1950>   # <---- WTF?!

Que vient faire dans B le callback foo de A ?!

En fait, le problème, c'est que ces deux classes ont rempli le même dictionnaire de callbacks, hérité de leur classe mère commune :

1
2
3
>>> WrongDispatcher.__callbacks__
{'bar': <function bar at 0x7f637e4b19d8>,
 'foo': <function foo at 0x7f637e4b1950>}

Ça, c'est embêtant : les dispatchers se marchent sur les pompes !

Pour éviter cela, il faut que les classes filles aient leur propre dictionnaire de callbacks à elles. Regardez :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> class C(WrongDispatcher):
...     __callbacks__ = {}   # on crée un nouveau dictionnaire
...                          # de callbacks
...
>>> @C.register('baz')
... def baz(): pass
...
>>> C.__callbacks__
{'baz': <function baz at 0x7f637e4b1a60>}
>>> WrongDispatcher.__callbacks__
{'bar': <function bar at 0x7f637e4b19d8>,
 'foo': <function foo at 0x7f637e4b1950>}

Cette fois, le callback que l'on a enregistré dans C n'a contaminé personne, parce qu'on a eu la présence d'esprit de créer un nouveau dictionnaire __callbacks__ dans la déclaration de la classe C.

C'est vraiment pas pratique ! On ne va quand même pas demander à l'utilisateur de rajouter manuellement cet attribut chaque fois qu'il hérite de notre mixin ou d'une de ses filles…

Eh bien j'ai une bonne et une mauvaise nouvelle :

  • La bonne nouvelle, c'est qu'il existe une solution propre à ce problème.
  • La mauvaise nouvelle, c'est que cette solution consiste à implémenter la création de l'attribut de classe __callbacks__ dans le constructeur d'une métaclasse

Cassons-nous la tête sur la seconde implémentation…

En voilà un mot qui fait peur : métaclasse !

Je vous rassure tout de suite, je ne vais pas me lancer dans un cours théorique sur les métaclasses. D'abord parce que je trouve ça plutôt ennuyeux, et ensuite, parce que, comme probablement 99% des gens (qui n'oseront jamais l'avouer publiquement) : je n'ai jamais vraiment pigé aucun cours sur les métaclasses. Il a fallu que j'expérimente tout seul pour ça.

Par contre, je vais essayer de vous montrer simplement ce que c'est, et pourquoi on a besoin d'en créer une ici.

Bon, prenons notre problème à nous : nous avons besoin que lorsque l'on définit une classe particulière (un dispatcher), un nouvel attribut soit créé et ajouté automatiquement à cette classe, attribut que j'ai appelé __callbacks__ un peu plus haut.

En fait, ça ressemble beaucoup à ce qui se passe lorsque l'on définit n'importe quelle classe. Par exemple si je définis une classe Foo comme ceci :

1
2
3
class Foo:
    def some_method(self):
        pass

Et que j'affiche tous ses attributs dans la console…

1
2
3
4
5
6
>>> dir(Foo)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__',
'__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__',
'__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__',
'__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__',
'__subclasshook__', '__weakref__', 'some_method']

On s'aperçoit que cette classe Foo a quand même un paquet d'attributs cachés ! Par exemple, l'attribut __dict__ contient une référence sur l'unique méthode de notre classe :

1
2
>>> Foo.__dict__['some_method']
<function Foo.some_method at 0x7f637e4b1b70>

Cet attribut __dict__, il a bien fallu qu'il soit créé et initialisé quelque part, lorsque Python a fini de parser la classe Foo, il a construit un objet Foo avec des attributs spéciaux.

Vu qu'on peut manipuler Foo dans la console, on peut lui demander son type :

1
2
>>> type(Foo)
<class 'type'>

Bon, accrochez-vous parce que c'est là que ça devient rigolo. Le type de Foo est type. Ça veut donc dire que type est une… classe, et que lorsqu'on l'instancie, on obtient des classes, comme Foo.

Une classe que l'on instancie pour obtenir une classe, on appelle ça une métaclasse. Et la métaclasse principale de Python, est type. En somme, pour résumer : c'est dans le constructeur de type que la classe Foo a gagné ses attributs, comme Foo.__dict__.

Nous, notre but, c'est d'ajouter un nouveau dictionnaire __callbacks__ en attribut de la classe. Un attribut du même genre que __dict__. Il est donc naturel que nous rajoutions cet attribut dans le constructeur de la métaclasse de notre dispatcher. Il suffit donc de surcharger le constructeur de la (méta)classe type, pour y ajouter notre tambouille. On en profitera d'ailleurs pour déplacer toutes nos @classmethod dans la définition de la métaclasse.

Allons, courage. C'est pas si terrible que ça. J'ai même commenté le code en français, pour une fois :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class SingleDispatcherMeta(type):
    def __new__(mcs, name, bases, attrs):
        # Juste avant que la classe ne soit définie par Python.

        # on lui ajoute un dictionnaire de callbacks
        callbacks = dict()
        attrs['__callbacks__'] = callbacks

        # et une property "dispatcher" qui retourne ce dictionnaire.
        attrs['dispatcher'] = property(lambda obj: callbacks)

        return super().__new__(mcs, name, bases, attrs)

    # Enregistre un nouveau callback auprès du dispatcher
    def set_callback(cls, key, callback):
        cls.__callbacks__[key] = callback
        return callback

    # Pareil, mais avec un decorator @Class.register(key)
    def register(cls, key):
        def wrapper(callback):
            return cls.set_callback(key, callback)
        return wrapper


# Le mixin en lui-même.
class SingleDispatcher(metaclass=SingleDispatcherMeta):
    # Retourne le callback associé à la clé key, s'il existe.
    def dispatch(self, key, default=None):
        return self.dispatcher.get(key, default)

Constatons que cela fonctionne :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
>>> class A(SingleDispatcher):
...     pass
...
>>> class B(SingleDispatcher):
...     pass
...
>>> @A.register('foo')
... def foo(): pass
...
>>> @B.register('bar')
... def bar(): pass
...
>>> a = A()
>>> b = B()
>>> a.dispatcher
{'foo': <function foo at 0x7f4b26875ae8>}
>>> b.dispatcher
{'bar': <function bar at 0x7f4b26875b70>}

Ici, on se rend compte que les callbacks de A et de B sont bien enregistrés dans des dictionnaires bien distincts. Mission accomplie. :)

Retour sur notre exemple

Voici à quoi ressemble la classe ChatBot de notre exemple en utilisant cette métaclasse. Notez que l'on réimplémente quand même le décorateur @ChatBot.register_cmd, puisque celui-ci utilise automatiquement le nom de la fonction comme clé pour identifier les callbacks.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class ChatBot(SingleDispatcher):
    @classmethod
    def register_cmd(cls, callback):
        return cls.set_callback(callback.__name__, callback)

    def react(self, message):
        if not message.startswith('!'):
            return
        cmd, *args = message.split()
        cmd = cmd.lstrip('!')

        handler = self.dispatch(cmd, lambda *_: None)
        handler(self, *args)

# ... l'enregistrement des callbacks se fait de la même manière ...

Plutôt cool, non ?

Un dispatcher générique... et extensible !

Bien, nous avons un pattern que nous pouvons utiliser en héritant d'une classe spéciale et nous pouvons même hériter d'un SingleDispatcher sans risquer de contaminer son dictionnaire de callbacks. C'est pas mal, mais cette solution n'est qu'à moitié satisfaisante.

En fait, lorsque l'on hérite d'une classe, intuitivement, on s'attend à hériter de tous ses comportements, et justement en Python, le principal intérêt de l'héritage, c'est de ne pas avoir besoin de les recoder. Sauf que ce n'est pas le cas lorsque l'on hérite d'un SingleDispatcher : on gagne ses méthodes et son fonctionnement général, mais on n'a plus accès à ses callbacks.

Si on reprend l'exemple de notre ChatBot, ça veut dire que si un développeur décide d'hériter de ChatBot, il se retrouve obligé de réimplémenter et réenregistrer la commande help qui gère la doc, par exemple, alors qu'on peut tout à fait se dire que ce callback sera utile à tous les bots qui seront implémentés de la sorte…

Bref, le but, c'est que lorsque l'on hérite d'un tel dispatcher, on puisse également hériter de tous ses callbacks.

En somme, si A est un dispatcher et que B hérite de A :

  • B doit répondre par défaut à tous les signaux de A, avec le même comportement par défaut,
  • Les nouveaux callbacks ajoutés à A doivent être accessibles à B,
  • Les nouveaux callbacks ajoutés à B ne doivent concerner que B et ses classes filles.

En fait, la bibliothèque standard fournit une structure, relativement méconnue, qui répond parfaitement à ce problème. J'ai nommé collections.ChainMap. Voyons comment ça marche.

Imaginons que nous ayons un dictionnaire de callbacks pour A. Au lieu que ce soit un simple dictionnaire, ce sera un ChainMap (par défaut, les deux se comportent exactement pareil) :

1
2
3
4
5
>>> A = ChainMap()
>>> A['spam'] = 'a_spam'
>>> A['eggs'] = 'a_eggs'
>>> A
ChainMap({'eggs': 'a_eggs', 'spam': 'a_spam'})

Maintenant, si nous voulons créer le dictionnaire de callbacks pour B, il suffit d'ajouter les dictionnaires mappés par A dans le ChainMap de B :

1
2
3
4
>>> B = ChainMap()
>>> B.maps.extend(A.maps)
>>> B
ChainMap({}, {'eggs': 'a_eggs', 'spam': 'a_spam'})

Ajoutons à B un nouveau callback (bacon) et surchargeons son callback spam :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> B['bacon'] = 'b_bacon'
>>> B['spam'] = 'b_spam'
>>> B
ChainMap({'spam': 'b_spam', 'bacon': 'b_bacon'},
         {'eggs': 'a_eggs', 'spam': 'a_spam'})
>>> for key, callback in B.items():
...     print("{}: {}".format(key, callback))
...
eggs: a_eggs
spam: b_spam
bacon: b_bacon

Vérifions que les callbacks de A n'ont pas bougé :

1
2
3
4
5
>>> for key, callback in A.items():
...     print("{}: {}".format(key, callback))
...
eggs: a_eggs
spam: a_spam

Maintenant vérifions que les nouveaux callbacks ajoutés à A deviennent automatiquement accessibles à B :

1
2
3
4
5
6
7
8
>>> A['homard'] = 'a_homard'
>>> for key, callback in B.items():
...     print("{}: {}".format(key, callback))
...
eggs: a_eggs
homard: a_homard
spam: b_spam
bacon: b_bacon

Je ne sais pas vous, mais personnellement, même après plusieurs années de carrière, je reste ébahi devant la capacité de la bibliothèque standard à résoudre notre problème avant même qu'il ne se pose…

Notre problème d'héritage est complètement résolu. On n'a plus qu'à implémenter la métaclasse, et on aura fini.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class DispatcherMeta(type):
    def __new__(mcs, name, bases, attrs):
        # Juste avant que la classe ne soit définie par Python

        # On construit le dictionnaire de callbacks en héritant de ceux des
        # classes mères
        callbacks = ChainMap()
        maps = callbacks.maps
        for base in bases:
            if isinstance(base, DispatcherMeta):
                maps.extend(base.__callbacks__.maps)

        # Comme avant, on ajoute le dictionnaire de callbacks et
        # la property "dispatcher" pour y accéder
        attrs['__callbacks__'] = callbacks
        attrs['dispatcher'] = property(lambda obj: callbacks)
        cls = super().__new__(mcs, name, bases, attrs)
        return cls

    def set_callback(cls, key, callback):
        cls.__callbacks__[key] = callback
        return callback

    def register(cls, key):
        def wrapper(callback):
            return cls.set_callback(key, callback)
        return wrapper


class Dispatcher(metaclass=DispatcherMeta):
    def dispatch(self, key, default=None):
        return self.dispatcher.get(key, default)

Vérifions que ce nouveau dispatcher se comporte comme prévu :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
>>> class A(Dispatcher): pass
...
>>> @A.register('spam')
... def spam(): pass
...
>>> @A.register('eggs')
... def eggs(): pass
...
>>> class B(A): pass
...
>>> b = B()
>>> @B.register('bacon')
... def bacon(): pass
...
>>> @B.register('spam')
... def spam_override(): pass
...
>>> @A.register('homard')
... def homard(): pass
...
>>> for key, val in b.dispatcher.items():
...     print(key, val)
...
eggs <function eggs at 0x7fe513411268>
spam <function spam_override at 0x7fe513411400>
bacon <function bacon at 0x7fe513411378>
homard <function homard at 0x7fe513411488>

Victoire !

Le Visitor en douze lignes

Vous connaissez le décorateur @functools.singledispatch ?

Celui-ci sert à surcharger le comportement d'une fonction, suivant le type du premier argument de cette fonction. Remarquez qu'à la différence du programme qu'on a pris en exemple jusqu'à maintenant, celui-ci dispatche en fonction d'un type de données et plus en fonction d'une chaîne de caractères, et c'est là que le Dispatcher devient vraiment marrant : on peut dispatcher sur n'importe quel objet hashable, comme les types de données, les tuples, les chaînes de caractères, les frozenset, les bytes

Par exemple, on peut implémenter le pattern Visitor de façon extrêmement concise grâce à notre Dispatcher :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from dispatcher_pattern import Dispatcher

class Visitor(Dispatcher):
    def visit(self, key, *args, **kwargs):
        handler = self.dispatch(type(key))
        if handler:
            return handler(self, key, *args, **kwargs)
        raise RuntimeError("Unknown key: {!r}".format(key))

    @classmethod
    def on(cls, type_):
        return cls.register(type_)

Servons-nous de ce visitor pour évaluer les arbres syntaxiques (AST) de simples expressions algébriques. Comme le suivant :

1
2
3
4
5
6
7
(40 + 2) * 3

       *
      / \
     +   3
    / \
  40   2

Le Visitor va évaluer cet arbre en le parcourant comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
      (*)        Le Visitor se trouve sur le noeud *,
      / \        Il sait que pour évaluer ce noeud, il faut qu'il évalue
     +   3       ses deux opérandes avant de retourner
    / \          leur produit. Il va donc s'exécuter récursivement sur les
  40   2         deux noeuds fils : + et 3


       *         Le Visitor descend sur le noeud +,
      / \        Comme pour le noeud *, il faut qu'il évalue d'abord les deux
    (+)  3       opérandes avant de retourner leur somme.
    / \
  40   2


       *         "40" est une valeur constante, le visitor retourne cette valeur
      / \        telle quelle.
     +   3
    / \         
 (40)  2


       *         Idem pour "2".
      / \
     +   3
    / \
  40  (2)


       *         De retour dans son évaluation du noeud +, il calcule la somme
      / \        de ses deux opérandes et la retourne.
    (42) 3


       *         Avant de retourner sur le noeud *, le visitor
      / \        est appelé récursivement par celui-ci sur le "3", sur lequel
     42 (3)      il n'a ici rien à faire.


     (126)       Le visitor retourne dans l'évaluation de la multiplaction, 
                 et renvoie le produit de 42 et 3.

Implémentons les classes qui représenteront les noeuds de l'AST. Rien de difficile, ils doivent juste "décrire" ce qu'ils sont :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ASTNode:
    pass

class ConstVal(ASTNode):
    def __init__(self, value):
        self.value = value

class BinOp(ASTNode):
    def __init__(self, left, right):
        self.left = left
        self.right = right

class Add(BinOp):
    pass

class Mul(BinOp):
    pass

class Sub(BinOp):
    pass

class Div(BinOp):
    pass

Ici, nous avons défini un AST pouvant contenir :

  • des noeuds ConstVal : les feuilles de l'arbre,
  • les quatre opérations binaires : addition, multiplication, soustraction, division.

Pour évaluer un arbre composé de ces noeuds, on peut utiliser le visitor suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class EvalVisitor(Visitor):
    pass

@EvalVisitor.on(ConstVal)
def eval_cval(vtr, node):
    return node.value

@EvalVisitor.on(Add)
def eval_add(vtr, node):
    return vtr.visit(node.left) + vtr.visit(node.right)

@EvalVisitor.on(Mul)
def eval_mul(vtr, node):
    return vtr.visit(node.left) * vtr.visit(node.right)

@EvalVisitor.on(Sub)
def eval_sub(vtr, node):
    return vtr.visit(node.left) - vtr.visit(node.right)

@EvalVisitor.on(Div)
def eval_div(vtr, node):
    return vtr.visit(node.left) / vtr.visit(node.right)

Essayons :

1
2
3
4
>>> ast = Mul(Add(ConstVal(40), ConstVal(2)), ConstVal(3))  # (40 + 2) * 3
>>> vtor = EvalVisitor()
>>> vtor.visit(ast)
126

Aucun soucis. :)

Bonus : un Visitor qui comprend l'héritage

Un détail qui survient lorsque l'on utilise beaucoup ce genre de dispatchers sur des types de données, est que l'on peut vouloir exploiter le fait que certaines classes héritent d'autres pour définir des actions par défaut. Si vous regardez bien, dans notre ast, les quatre opérations dérivent toutes de la classe BinOp. Et si on créait un visitor dans lequel on pourrait spécifier un callback par défaut de toutes les classes filles d'un type donné ?

En fait, ce n'est pas très difficile : pour nous faciliter la tâche, toutes les classes en Python ont un attribut __mro__ (Method Resolution Order), qui sert précisément à nous dire dans quel ordre on doit résoudre les appels des méthodes héritées des classes mères. Il nous suffit de faire exactement la même chose que Python en interne, et boucler dessus !

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Visitor(Dispatcher):
    def visit(self, key, *args, **kwargs):
        cls = type(key)
        for base in cls.__mro__:
            handler = self.dispatch(base)
            if handler:
                return handler(self, key, *args, **kwargs)
        raise RuntimeError("Unknown key: {!r}".format(cls))

    @classmethod
    def on(cls, type_):
        return cls.register(type_)

Pour illustrer ce comportement, implémentons un visiteur qui transforme les expressions en leur représentation textuelle. Commençons par modifier légèrement les opérations binaires :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class BinOp(ASTNode):
    symbol = None
    def __init__(self, left, right):
        self.left = left
        self.right = right

class Add(BinOp):
    symbol = '+'

class Mul(BinOp):
    symbol = '*'

class Sub(BinOp):
    symbol = '-'

class Div(BinOp):
    symbol = '/'

Nous pouvons maintenant écrire un visiteur qui tire parti de l'arbre d'héritage de notre AST :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class PrintVisitor(Visitor):
    pass

@PrintVisitor.on(ConstVal)
def print_val(vtr, node):
    return str(node.value)

@PrintVisitor.on(BinOp)
def print_binop(vtr, node):
    if node.symbol is None:
        raise RuntimeError("Unknown BinOp: {!r}".format(node))
    return "({} {} {})".format(
        vtr.visit(node.left),
        node.symbol,
        vtr.visit(node.right)
    )

Et… c'est tout.

1
2
3
4
>>> ast = Mul(Add(ConstVal(40), ConstVal(2)), ConstVal(3))  # (40 + 2) * 3
>>> vtr = PrintVisitor()
>>> print(vtr.visit(ast))
((40 + 2) * 3)

En somme, grâce à notre Dispatcher, nous avons pu implémenter un Visitor qui :

  • Est héritable : si l'on hérite d'une implémentation de Visitor, on hérite également de tous ses callbacks, que l'on peut surcharger ou laisser comme callbacks par défaut.

  • Comprend la sémantique de l'héritage : si le Visitor ne trouve pas de callback pour un type donné, il essaye de se rattrapper en cherchant un callback pour tous les parents de ce type.

Autant de petites choses pour en faire plus en tapant le moins de code possible. ;)


Nous venons d'implémenter un design pattern parfait pour les flemmards !

En effet, en héritant (même partiellement) de notre Dispatcher, nous pouvons ajouter à n'importe quelle classe la possibilité :

  • d'enregistrer des callbacks via un simple décorateur,
  • callbacks qui peuvent réagir à n'importe quelle clé du moment que celle-ci est hashable (qu'elle peut être utilisée comme clé d'un dictionnaire),
  • d'implémenter la fonctionnalité métier d'une application en dehors du coeur de la classe,
  • de rendre cette fonctionnalité métier parfaitement extensible,
  • d'hériter proprement de cette classe, et de surcharger ses callbacks sans craindre les effets de bord,
  • d'implémenter de façon tout à fait pythonique un autre pattern, très utilisé dans les compilateurs : le Visitor.

Vous verrez, maintenant que vous savez qu'il existe, ce pattern va devenir un de vos meilleurs potes ! ;)

Je souhaite remercier tous les membres qui ont participé à la bêta de ce tutoriel. En particulier Gabbro, entwanne, artragis et yoch pour leurs remarques constructives.

18 commentaires

Et voilà le 8ème cours sur Python publié \o/ Ça commence à bien s'étoffer !

Contenu intéressant qui exploite des fonctionnalités assez poussées (voire mystiques) du langage.

Je pensais aussi en regardant la liste des cours Python, il y aurait peut-être un truc à faire au niveau des icônes. On risque vite d'avoir plusieurs tutoriaux avec le même logo Python.

Merci !

Ce qui me choque aussi en regardant la liste des tutos Python, c'est que le présent tuto est seulement ma première contribution à cette liste depuis la naissance de ZdS…

Fort heureusement, je devrais avoir plus de temps de cerveau disponible pour contribuer à partir de ce mois-ci : croisons les doigts pour que ses petits frères arrivent vite ! ;)

+0 -0

Pas de modestie mal placée. :D Tes articles sur les coroutine et la sortie de python 3.5, ton sujet sur le forum, ont largement participé à animer le communauté python de ZdS.


Excellent tuto, au passage, même si je l'ai déjà dit durant le béta.

+1 -0

Pas de modestie mal placée. :D Tes articles sur les coroutine et la sortie de python 3.5, ton sujet sur le forum, ont largement participé à animer le communauté python de ZdS.

Gabbro

Ah mais c'est pas spécialement de la modestie (c'est pas mon truc la modestie, au contraire, et je me soigne même pas !), c'est surtout qu'après avoir écrit des kilomètres de posts à propos du contenu Python sur le forum, j'aurais aimé en avoir déjà fait beaucoup plus (du contenu pérenne) en deux ans sur ZdS.

Je me faisais cette remarque dans le sens où il est grand temps que je réajuste mes priorités en fait. :)

+1 -0

Bonjour,

Merci pour ce très bon tutoriel. :D J'en apprend un peu plus sur les spécificités de python. D'ailleurs, est-ce qu'on retrouve le pattern dispatcher dans d'autres langages ? Quelles sont les alternatives aux décorateurs et aux mixins ?

Sinon j'ai buté sur un détail, ici:

1
handler = self.dispatch(cmd, lambda *_: None)

Le lambda *_: None permet de renvoyer une fonction qui ne fait rien par défaut et qui prend n'importe quels arguments, est-ce bien ça ? La notation *_ pour une variable m'intrigue également. Renseigne-t-elle sur son utilisation par rapport à un *args, ou est-ce pour rendre le code plus concis ?

Je comptais poser plus de questions mais j'ai compris en rédigeant le message. :p

Encore merci pour ce tutoriel.

Considérant le snippet suivant :

1
2
3
4
5
6
        # on lui ajoute un dictionnaire de callbacks
        callbacks = dict()
        attrs['__callbacks__'] = callbacks

        # et une property "dispatcher" qui retourne ce dictionnaire.
        attrs['dispatcher'] = property(lambda obj: callback

Y a-t-il un intérêt à avoir un attribut __callbacks__ et un attribut dispatcher qui est en fait une propriété vers l'attribut __callbacks__ ? Pourquoi ne pas avoir simplement fait :

1
attrs['dispatcher'] = {}

L'attribut __callbacks__ est masqué et propre à toute la classe. Tu ne veux pas que l'utilisateur y touche sinon il va tout péter. En créant une property dont tu ne fournis que le getter, tu lui donnes un accès en lecture seule à cet attribut, donc tu garantis qu'il ne l'écrasera pas par mégarde.

De plus, ça renforce la sémantique qui veut que ce soit l'instance qui interagit avec le dispatcher et non la classe.

+0 -0

C'est en effet l'idée que je me suis faite en regardant ton code, mais je t'avoue que j'ai eu une léger doute sur la bonne façon de faire quand j'ai découvert ce paragraphe sur la page française de Python sur wikipédia :

L'encapsulation est une problématique de développement logiciel. Le slogan des développeurs Python est we're all consenting adults here (nous sommes entre adultes consentants). Ils estiment en effet qu'il suffit d'indiquer, par des conventions d'écriture, les parties publiques des interfaces et que c'est aux utilisateurs des objets de se conformer à ces conventions ou de prendre leurs responsabilités. L'usage est de préfixer par un underscore les membres privés. Le langage permet par ailleurs d'utiliser un double underscore pour éviter les collisions de noms, en préfixant automatiquement le nom de la donnée par celui de la classe où elle est définie.

L'utilisation de la fonction property() permet de définir des propriétés qui ont pour but d'intercepter, à l'aide de méthodes, les accès à une donnée membre. Cela rend inutile la définition systématique d'accesseurs et le masquage des données comme il est courant de le faire en C++ par exemple.

Merci pour ta réponse btw. ;)

+0 -0

En fait, l'accès aux membres "privés" avec la philosophie we're consenting adults présuppose que l'utilisateur sait ce qu'il fait. Dans ce cas précis, la classe est trop compliquée pour considérer raisonnablement que ça puisse être le cas.

Le but n'est pas tant d'interdire l'accès à l'utilisateur que d'éviter qu'il n'écrase un élément important par inadvertance.

À partir du moment où le dictionnaire __callbacks__ est nommé ainsi, tout bon analyseur statique se mettrait à hurler si l'utilisateur écrivait obj.__callbacks__. Cela dit, l'utilisateur peut vouloir toucher au mapping, par exemple pour savoir si une clé y est présente. C'est à ça que sert la property en lecture seule : pour les accès "légitimes".

Du reste, il n'y a strictement aucun cas d'utilisation de ce mixin où l'utilisateur pourrait vouloir écraser ce chainmap : c'est précisément parce que sa manipulation est délicate et la logique interne un peu complexe qu'il hérite du mixin plutôt que de coder le dispatcher lui-même.

+0 -0

D'ailleurs, est-ce qu'on retrouve le pattern dispatcher dans d'autres langages ? Quelles sont les alternatives aux décorateurs et aux mixins ?

Oramy

N'importe quel langage avec des tables de hachage et des références sur des fonctions peut implémenter un dispatcher. Ici les décorateurs et le fait que ce soit un mixin, c'est simplement du sucre syntaxique pour le rendre plus commode à utiliser. :)

+0 -0

Bonjour, et merci pour ce tuto, il m’a enormement servi ces dernieres annees !

Je vous propose une mise a jour avec l’usage de la methode 'init_subclass' qui permet de se passer de la creation de la meta class. Cette méthode viens de https://peps.python.org/pep-0487/

from collections import ChainMap

__all__ = ['Dispatcher', 'Visitor']
class Dispatcher():

    __callbacks__ = {}

    def __init_subclass__(cls, **kwargs) -> None:
        super().__init_subclass__(**kwargs)

        callbacks = ChainMap()
        for c in cls.mro():
            if issubclass(c, Dispatcher): # scope extention
                if hasattr(c, '__callbacks__'):
                    callbacks.maps.extend(getattr(c, '__callbacks__'))
        cls.__callbacks__ = callbacks
        
    @classmethod
    def set_callback(cls, key, cbk):
        cls.__callbacks__[key] = cbk
        return cbk

    @classmethod
    def register(cls, key):
        def wrapper(cbk):
            return cls.set_callback(key, cbk)
        return wrapper

    @property
    def dispatcher(self):
        return self.__callbacks__

    def dispatch(self, key, default=None):
        return self.__callbacks__.get(key, default)


class Visitor(Dispatcher):
    def visit(self, key, *args, **kwargs):
        cls = type(key)
        for base in cls.mro():
            handler = self.dispatch(base)
            if handler:
                return handler(self, key, *args, **kwargs)
        raise RuntimeError("Unknown key: {!r}".format(cls))
    

    @classmethod
    def on(cls, type_):
        return cls.register(type_)

C’est la "revanche" du WrongDispatcher ;)

+1 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte