Usages et dangers du null object pattern en Python
mardi 4 décembre 2012 à 16:46Le 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.