Usages et dangers du null object pattern en Python


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.

17 thoughts on “Usages et dangers du null object pattern en Python

  • Jérôme Avoustin

    Ou comment créer un anti-pattern inutile qui masquera vos erreurs en prod et que vous mettrez 10j à en détecter l’origine.
    Un bon vieux tapis sous lequel on fout toute la poussière en espérant qu’elle ne ressorte pas de l’autre côté….

  • JeromeJ

    Ouah, ça peut sembler pratique mais c’est hyper dangereux comme pratique :o

    Laisser le programme faire ses conneries au lieu de catch les erreurs :p Mouais au moins ça doit permettre de gagner du temps de dev à ne pas devoir gérer les erreurs :)

    En plus de l’article sur les singleton (ou inclus avec), peut-être expliquer la complexité (selon moi) de la différence entre __new__ et __init__ et comment s’en servir ;)

    Cordialement.

  • Sam Post author

    Une dynamite aussi c’est dangereux. Et on l’utilise pas tout les jours. C’est pas pour ça qu’une fois par an, quelque part dans le monde, c’est pas utile et ça ne fait pas le travail qu’on pourrait faire moins bien autrement.

    Utilisez le bon outil pour le bon travail, je ne cesserai de le répéter.

  • anthony

    Je devrais peut être faire un article sur le Singleton

    Agreed!

    Je n’ai jamais fait de Singleton en Python de cette manière. Juste avec:

    truc.py

    class machin:
    ...

    inst = machin()

    Ensuite utilisé ailleurs via:

    truc.inst

    Pas bien ?

  • Sam Post author

    Ta technique est tout à fait valide.

    Le fait d’utiliser __new__ a surtout deux avantages:

    – on garde la syntaxe d’instanciation qui parait naturelle et s’intègre bien dans l’idée du duck typing
    – on peut déporter le code dans une classe à part et faire une metaclass qui transforme n’importe quelle classe en métaclasse

    Mais ta technique et simple et marche, ce qui sont deux qualités de très grande valeur.

  • Moumoutte

    Utilisez le bon outil pour le bon travail, je ne cesserai de le répéter.

    ouais sauf que là à mon sens, c’est vraiment pas le bon cas d’utilisation.

    Il existe une bibliothèque qui intègre déjà se pattern: mock. Sauf que ce n’est absolument pas pour “économiser des if”. C’est principalement pour créer des “bouchons” avec l’environnement extérieur (ou pas) et ISOLER le code pour faire des tests unitaires.

    Utiliser se pattern pour enfouir des erreurs, c’est absolument à proscrire.

  • Sam Post author

    C’est complètement différemment. Mock c’est pour les tests. Nul n’est pas là pour enfouir les erreurs, mais pour renforcer le duck typing.

    Comme try/except utilisé pour la gestion des flux n’est pas une aberration en Python.

    Il ne faut pas être dogmatique, ça élimine des opportunités de progression.

  • Moumoutte

    C’est complètement différemment

    Mock se comporte exactement comme tu le décris dans ton objet NULL. Là pour le coups, non ce n’est pas différent.

    mais pour renforcer le duck typing.

    L’intêret du duck typing c’est de se dire “Si il vole comme un oiseau, c’est que c’est peut-être un oiseau”, d’où :

    &gt;&gt; bird.fly()
    "I'm flying !"

    Donc, si l’objet ne vole pas , je veux qu’il se casse la gueule, pas lui greffer des prothèses pour qu’il vole 5m et qu’il se casse la gueule après pour faire encore plus de dégats.

    &gt;&gt; car.fly()
    AttributeError: 'Car' object has no attribute 'fly'

    Y’a que chez Harry Potter que les voitures ça vole, et même dans ce monde merveilleux, elle finit par se ramasser lamentablement dans un sol cogneur.
    Autant qu’elle ne commence même pas à voler, ça aurait éviter bien des problèmes.

    D’ailleurs, c’est souligné dans l’article de wikipedia sur le duck typing

    “If the object does not have the methods that are called then the function signals a run-time error”

    On s’attend bien à avoir une erreur au runtime, pas qu’elle soit masquée.

    (Cela dit, ce n’est pas parce que je ne suis pas d’accord sur ce point d’utilisation, que de manière général , je ne suis pas satisfait de votre boulot de blogeur python. J’en apprend régulièrement avec vous, continuez à écrire, c’est vraiment chouette de vous lire :))

  • Sam Post author

    Je comprends bien ce que tu veux dire. Le message que j’essaye de faire passer c’est juste que, utilisé correctement, l’usage de Null() n’est pas fait pour masquer des erreurs, mais juste pour faire “rien” dans les cas où il n’y a rien à faire. C’est bel et bien du duck typing: on a la signature appropriée, on peut tout faire, c’est juste qu’il n’y a aucun effet.

    Je crois que mon tort c’est de n’avoir pas posté un example d’utilisation concret.

  • Sam Post author

    Ok, j’ai fais un édit pour rajouter un exemple d’usage. Ça m’apprendra à faire ma feignasse en me disant “c’est bon, ça va passer” et en écoutant pas mes propres conseils.

  • anthony

    Y a bien des fois où on fait un:

    try:

    except:
    pass

    sans que ça pose problème non ? Alors pourquoi le null object serait problématique en lui même ?

  • Sam Post author

    Le potentiel de foirage et la difficulté de debuggage est plus importante avec le Null object car il retourne un Null object. On peut donc “contaminer” tout son jeux de données avec plein de Null objects si on ne fait pas attention.

  • Réchèr

    Je m’en servirais probablement jamais, mais je trouve ça rigolo.

    J’aime bien les différentes façons dont les langages de programmation expriment le vide. Ça amène parfois des concepts bizarres.
    *void rien = NULL;
    Tout ça …

  • JM

    Marrant, mais un peu dérangeant pour une méthode de masquage d’erreur. C’est bien d’avoir dit que c’est un outil dangereux.

    “objections tout à fait valide//s/”

  • GeoStoneMarten

    Hummm. En effet c’est dangereux mais pas besoin de levé une exception à chaque fois qu’un cas non souhaité est mis au placard. Le fait de rien renvoyé n’est pas problématique. Tu peux cependant ajouter un logger.debug dans le script si c’est nécessaire à certain endroit à la façon de JAVA.

    C’est comme pour les conditions! if elif else.
    j’aimerai bien savoir qui blinde son code et met dans le else les cas non étudié lors de la conception du programme.

    Genre exemple simple: J’ai une ligne vide dans un fichier csv si celle-ci est vide je renvoi pas une erreur je l’ignore mais je le spécifie dans la doc comme étant un cas volontairement ignoré.

    Programmer c’est aussi expliquer les limites d’un programme! Et donc la doc c’est important tous comme un bon modèle.

    Au moins on le sais si c’est Null ben c’est Null.

Comments are closed.

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