PROJET AUTOBLOG


Sam et Max

source: Sam et Max

⇐ retour index

Usages et dangers du null object pattern en Python

mardi 4 décembre 2012 à 16:46

Le motif de conception de l’objet nul ou “Null object pattern” dans la langue de Justin Bieber, est une technique qui consiste à créer un objet qui ne fait rien. Et quand on lui demande de faire quelque chose, il se renvoie lui-même.

Voici à quoi ça ressemble en Python:

class Null(object):
 
    _instances = {}
 
    def __new__(cls, *args, **kwargs):
        """
            On s'assure que Null est un singleton, c'est à dire qu'on ne peut
            pas créer deux fois Null. Ainsi, id(Null()) == id(Null()).
 
            Je devrais peut être faire un article sur le Singleton.
        """
        if cls not in cls._instances:
            cls._instances[cls] = super(Null, cls).__new__(cls, *args, **kwargs)
        return cls._instances[cls]
 
 
    def __init__(self, *args, **kwargs):
        """
            Null accepte n'importe quel argument
        """
        pass
 
    def __repr__(self):
        """
            On lui met quand même quelque représentation pour le debug
        """
        return "<Null>"
 
    def __str__(self):
        """
            Null se cast sous forme de chaîne vide. Comme ça vous pouvez
            faire open('fichier').write(Null()) et ça ne fait rien.
        """
        return ""
 
 
    def __eq__(self, other):
        """
            Null est égal à lui même, ou à None car on peut les utiliser dans
            des endroits similaires. On peut toujours les distinguer avec "is".
        """
        return id(self) == id(other) or other is None
 
    # Comme None, Null() est faux dans un contexte booléen
    __nonzero__ = __bool__ = lambda self: False
 
    # Et là, on fait le gros travail d'annihilation: toutes les méthodes
    # et opérations du Null() renvoient Null(). Et comme Null() accepte tout
    # et ne fait rien, toute opération marche toujours, ne fait rien, et
    # renvoie une valeur qui assure que les suivantes feront pareil.
    nullify = lambda self, *x, **kwargs: self
 
    __call__ = nullify
    __getattr__ = __setattr__ = __delattr__ = nullify
    __cmp__ = __ne__ = __lt__ = __gt__ = __le__ = __ge__ = nullify
    __pos__ = __neg__ = __abs__ = __invert__ = nullify
    __add__ = __sub__ = __mul__ = __mod__ = __pow__ = nullify
    __floordiv__ = __div__ = __truediv__ = __divmod__ = nullify
    __lshift__ = __rshift__ = __and__ = __or__ = __xor__ = nullify
    __radd__ = __rsub__ = __rmul__ = __rmod__ = __rpow__ = nullify
    __rfloordiv__ = __rdiv__ = __rtruediv__ = __rdivmod__ = nullify
    __rlshift__ = __rrshift__ = __rand__ = __ror__ = __rxor__ = nullify
    __iadd__ = __isub__ = __imul__ = __imod__ = __ipow__ = nullify
    __ifloordiv__ = __idiv__ = __itruediv__ = __idivmod__ = nullify
    __ilshift__ = __irshift__ = __iand__ = __ior__ = __ixor__ = nullify
    __getitem__ = __setitem__ = __delitem__ = nullify
    __getslice__ = __setslice__ = __delslice__ = nullify
    __reversed__ = nullify
    __contains__ = __missing__ = nullify
    __enter__ = __exit__ = nullify

Certaines méthodes ne peuvent pas retourner Null() car elle sont tenues
par l’implémentation en C de retourner un certain type. C’est le cas
des méthode de conversion, d’itération, d’approximation ou certaines
renvoyant des metadata comme: __int__ , __iter__, __round__ ou __len__.

Donc il y aura toujours des cas extrêmes où Null ne marchera pas, mais croyez moi, avec le code ci-dessus, on a déjà couvert un paquet de trucs.

Comment on s’en sert

C’est la beauté de l’objet Null, il n’y a rien à faire d’autre que l’instancier. Après il se débrouille tout seul… pour ne rien faire !

Null() accepte tout à la création, et renvoie toujours le même objet :

>>> n = Null()
>>> n
<Null>
>>> id(n) == id(Null('value')) == id(Null('value', param='value'))
True

On peut lui demander tout ce qu’on veut, il retourne Null() :

>>> n() == n('value') == n('value', param='value') == n
True
>>> n.attr1
<Null>
>>> n.attr1.attr2
<Null>
>>> n.method1()
<Null>
>>> n.method1().method2()
<Null>
>>> n.method('value')
<Null>
>>> n.method(param='value')
<Null>
>>> n.method('value', param='value')
<Null>
>>> n.attr1.method1()
<Null>
>>> n.method1().attr1
<Null>

On peut le modifier, ça ne change rien :

>>> n.attr1 = 'value'
>>> n.attr1.attr2 = 'value'
>>> del n.attr1
>>> del n.attr1.attr2.attr3

Les opérations sur lui le laissent de glace:

>>> str(n) == ''
True
>>> n + 1 / 7 % 3
<Null>
>>> n[1] == n[:4] == n
True
>>> 'test' in n
False
>>> n['test']
<Null>
>>> Null() >> 1
<Null>
>>> Null() == None
True

Chouetttteeeeeee ! Mais ça sert à quoi ?

Et bien à simplifier les vérifications, et supprimer pas mal de if. Quand vous avez un état inconnu dans votre programme, au lieu de renvoyer None, ou faire des tests dans tous les sens pour éviter de planter, vous pouvez juste renvoyer Null(). Il va continuer sa petite vie dans votre programme, et accepter tout ce qu’on lui demande, sans rien faire crasher.

Attention cependant, Null() accepte tout silencieusement, et donc votre programme ne plantera jamais là où Null() est utilisé. En clair, si vous merdez et que Null() se retrouve là où il ne doit pas, il va se multiplier et remplir tout votre programme et le transformer en une soupe d’appels à Null() qui ne foutent rien, mais le font bien, et en silence.

Un design pattern puissant, mais dangereux.

L’édit obligatoire

Suite aux objections tout à fait valides de Moumoutte, je rajoute un exemple d’usage.

Il faut bien comprendre en effet que Null() n’est pas un passe droit pour faire n’importe quoi avec la gestion des erreurs, et n’est en aucun cas quelque chose à utiliser massivement.

Il est particulièrement utile quand les tests à faire sont juste trop nombreux. Par exemple, dans des secteurs comme la banque ou la biologie, on traite des données en masse, et hétérogènes, mais on fait des opérations entre les différents types.

Certains types sont compatibles, d’autres pas. Si on a des milliers de types, et des milliers d’opérations différentes, cela fait des milliers de milliers de combinaisons possibles à évaluer. Dans ce cas, Null() permet de s’affranchir de tous ces tests et simplifier énormément l’algo.

Voici un exemple simple de ce que ça donne à petit échelle (multipliez ça par mille types et opérations pour avoir un ordre de grandeur d’un cas réel):

# Chaque type peut faire des opérations uniquement sur d'autres types
 
class TypeA(object):
    def operation(self, other):
        if isinstance(other, TypeB):
            return other
        return Null()
 
 
class TypeB(object):
    def operation(self, other):
        if isinstance(other, TypeC):
            return other
        return Null()
 
 
class TypeC(object):
    def operation(self, other):
        if isinstance(other, TypeD):
            return other
        return Null()
 
 
class TypeD(object):
    def operation(self, other):
        if isinstance(other, TypeE):
            return other
        return Null()
 
 
class TypeE(object):
    def operation(self, other):
        if isinstance(other, TypeA):
            return other
        return Null()
 
 
# Votre pool de données vous arrive en masse et complètement mélangé.
# Vous n'avez AUCUN contrôle là dessus.
pool = (TypeA, TypeB, TypeC, TypeD, TypeE)
 
data1 = (random.choice(pool)() for x in xrange(1000))
data2 = (random.choice(pool)() for x in xrange(1000))
data3 = (random.choice(pool)() for x in xrange(1000))
data4 = (random.choice(pool)() for x in xrange(1000))
 
# Avec le Null() object pattern, vous ne vous posez pas la question de quel type
# va avec quoi. Vous faites l'opération comme si tout était homogène. Tout
# va se trier automatiquement.
 
res1 = (z.operation(y) for z, y in zip(data1, data2))
res2 = (z.operation(y) for z, y in zip(res1, data3))
res3 = (z.operation(y) for z, y in zip(res2, data4))
 
# Et à la fin, il est très facile de récupérer uniquement les données
# significatives.
 
print list(x.__class__.__name__ for x in res3 if x)
 
# Ceci va printer quelque chose comme:
# ['TypeD', 'TypeA', 'TypeE', 'TypeE', 'TypeC', 'TypeE', 'TypeE', 'TypeA']

On a ainsi réduit un énorme jeu de données hétérogènes en un petit jeu de données significatif, avec très très peu d’effort et un algo simple et lisible.