Blog // Exirel.me

Le jeu de la vie

Par Florian Strzelecki - 12:22 - 05.01.2014

Tags : Python, Programmation, Bonne pratique, Technique, OOP, FP

J'aime la programmation orientée objets (OOP), tout autant que la programmation fonctionnelle (FP), chacune pour des raisons différentes. Je suis toujours un peu triste lorsqu'un développeur critique l'un en disant que l'autre est mieux parce que [inséré ici un argument basé sur une différence de fonctionnement]. L'un n'est pas mieux que l'autre, car les deux impliquent des orientations et des compromis différents.

Dans son article "Start Writing More Classes", par Armin Ronacher, j'aime beaucoup cette note de fin d'article :

Something else I want to mention: what's written above will most likely result in some sort of warmed up discussion in regards to object oriented programming versus something else. Or inheritance versus strategies. Or virtual methods versus method passing. Or whatever else hackernews finds worthy of a discussion this time around.

All of that is entirely irrelevant to the point I'm making which is that monolithic pieces of code are a bad idea.

Pourquoi ? Parce que la plupart du temps, les comparaisons que je peux lire se base sur les erreurs et toutes ces choses parfaitement stupides que les développeurs peuvent faire - et si vous faites ou avez fait de la maintenance sur de vieilles applications, vous avez déjà forcément rencontré ce genres d'abominations. Pour Armin, ce sont les fonctions monolithiques, pour d'autres ce sera autre chose.

TL; DR: Trouver une solution n'est déjà pas facile, faire en sorte qu'elle soit à la fois simple et élégante l'est donc encore moins. Le bon sens n'étant pas la norme, aucun paradigme ne vous mettra à l’abri des singes de l'Enfer qui codent avec les pieds et font absolument n'importe quoi avec des concepts qu'ils ne maîtrisent pas totalement.

Par contre, il est bel et bien nécessaire d'avoir du recul sur les outils que nous utilisons, et de comprendre pourquoi, quand, et comment nous devrions les utiliser.

Pour le moment tout va bien

Dans son article Objets ou fonctions, @magopian se pose une question intéressante : est-ce qu'on s'est trompés ?

Je ne le pense pas.

Je pense qu'un tas de développeurs (et moi inclus) se trompent au quotidien lorsqu'il s'agit de trouver une bonne solution à un problème. Je pense aussi qu'un tas de mauvaises pratiques sont bien trop répandues un peu partout dans les projets informatiques, et que la plupart des design-patterns ne devraient jamais sortir des livres de programmation pour Java (ça, c'était le point troll gratuit) (encore que, hein...) (c'est bon j'ai commencé à écrire cet article un Vendredi, ça explique un tas de choses).

Singleton

Par exemple, le pattern "Singleton", qui consiste à faire des appels à MyClass.getInstance() est une aberration en Python. Oui, le langage permet de faire ça en modifiant le comportant de __new__ et/ou de __init__, mais il permet de faire beaucoup plus simple, et beaucoup plus élégant :

# myclass.py
class _RandomGenerator(object):
    def rand(self):
        return 43  # random number given by my personal D100

# Use only this 
random_singleton = _RandomGenerator()

# other.py
from myclass import random_singleton
random_singleton.rand()

Avec cela, je suis loin des solutions proposées dans Python and the Singleton Pattern, ou bien de Is there a simple, elegant way to define Singletons in Python?, ou encore de ma préférée à base de metaclass (heureusement que quelqu'un se pose quelques questions sur l'intérêt de tout ça quand même).

Le problème ici ce n'est pas l'idée même d'avoir "une seule instance d'une classe", le problème, c'est de le faire sans réfléchir en calquant une solution prisée dans d'autres langages.

Certes non, ce que je propose ne permet pas de s'assurer qu'il n'existe qu'une seule instance de la classe, mais il assure au développeur qu'en utilisant random_singleton il n'aura qu'une seule instance pour tout son projet - bien entendu, on peut se demander si c'est une bonne chose. Une autre façon de faire aurait sans doute été de déclarer une fonction qui retourne toujours la même instance. Enfin, des solutions élégantes il y en a.

En l’occurrence, ma solution permet d'écrire beaucoup plus facilement des tests unitaires sur la classe _RandomGenerator, et elle permet à quelqu'un d'autre d'étendre cette classe, de créer sa propre instance, etc.

Il y a beaucoup à dire sur le principe de Singleton (en bien et en mal), mais laissons ça de côté pour le moment, ce n'était qu'un exemple : lorsqu'un développeur n'a aucun recul, il risque de faire exactement ce qu'il ne faut pas faire.

Le bon sens n'est pas la norme

Quelle que soit la méthode mise en place, il est illusoire de vouloir protéger à tout prix son code de la stupidité de quelqu'un d'autre - ou pire, de sa propre stupidité. D'ailleurs, si ce n'était pas le cas, nous n'aurions pas besoin de bonnes pratiques : tests unitaires, documentations, revues de codes, gestion des exceptions, etc. tout ce que vous pouvez retrouver d'ailleurs dans le Zen of Python.

Nous discutions sur twitter à la suite du billet de Mathieu, et @n1k0 m'a répondu avec un exemple, mais il ne me convient pas du tout, le trouvant stupide. L'exemple, pas Nicolas, c'est un homme très intelligent, et vous devriez lire son article : Functional JavaScript for crawling the Web, il pourrait vous inspirer.

J'explique : rendre mutable un type de base comme les entiers numériques, c'est clairement faire une énorme connerie. Cela ne prouve pas que la fonctionnalité de mutabilité est une mauvaise fonctionnalité. Cela prouve seulement qu'il ne faut pas faire n'importe quoi avec. Si vous voulez utiliser 1, 2, 3 ou 5, vous ne voulez certainement pas que ces éléments soient mutables, et pour d'excellentes raisons. Par contre, cet exemple montre bien qu'il faut faire attention à la mutabilité des objets, et que tout bon développeur consciencieux doit toujours peser le pour et le contre avant de programmer une méthode qui change les propriétés d'un objet.

Alors, je sais, je le dis tout le temps : le bon sens n'est pas la norme. Si c'était le cas, il n'y aurait pas de débat sur les objets, sur les états mutables dans la programmation objet. Ce serait bénin, tout le monde ferait attention, et nous serions très content de bénéficier des avantages de ce paradigme.

Sauf qu'évidement, je m'occupe un peu trop souvent de vieux bouts de code mal foutus manifestement codés avec les pieds par des singes de l'Enfer dont le seul but dans leur misérable existence est de laisser trainer les pires boules puantes de tout l'Univers dans le code que je dois maintenir aujourd'hui. Ma seule consolation est de savoir, par expérience, que ce n'est pas spécifique à la programmation objet - avec un autre paradigme la nature de ces problèmes serait sans doute différente, mais certainement pas son origine (les singes de l'Enfer, vous dis-je) !

Le jeu de la vie

Mais revenons au Jeu de la Vie, de John Horton Conway, qui était l'exemple proposé par Mathieu dans le billet que je cite au tout début. J'aime beaucoup les règles très simple de ce "jeu", il permet de faire un peu d'algorithmique avec un résultat visible rapidement. Et puis il y a des noms très cools aux formes possibles, que vous pouvez retrouver sur Wikipédia (j'hésite entre le Jardin d'Eden et le Mathusalem pour mon préféré).

Mathieu, donc, conclus de son expérience cette phrase un peu triste :

L'impression que ça m'a laissé est qu'avoir utilisé des objets nous mettait une contrainte supplémentaire, un frein dont on aurait pu se passer.

Je trouve ça dommage. J'ai réfléchi à l'implémentation du Jeu de la Vie, sans me baser sur ce qu'il proposait, et tout est disponible sur un petit Gist: Life's Game. Vous y trouverez trois fichiers : les tests (à lancer avec py.test), le code d'une classe World, et un module orienté programmation fonctionnelle.

La première version n'avait pas le module worldfp, car je n'avais besoin que d'une classe, mais le code dans les fonctions n'a pas vraiment changé (tout au plus ais-je mis en forme deux ou trois petites choses). Je reparlerai de ce module dans la suite.

Lors de ma réflexion, les objets m'ont semblé naturels, et je ne suis pas posé beaucoup de questions sur la mutabilité : j'ai donné à mes objets World les attributs nécessaires à représenter leur état, puis les méthodes permettant d'interroger et d'utiliser cet état : position, ou bien calculate. Ces méthodes ne modifient pas les propriétés de l'objet, et je n'avais donc pas besoin de m'inquiéter de la mutabilité.

Ensuite seulement, je me suis attaché à lui fournir des méthodes de contrôle exploitant sa mutabilité : de la sorte, je savais exactement ce que je faisais, pourquoi je le faisais, et tout était bien séparé et rangé, à la fois dans ma tête, et dans mon code. Je n'ai pas ressenti de frein inhérent à la méthode choisie : mis à part ma méconnaissance de l'algorithme, je n'ai en réalité eu aucun problème.

Les cellules n'ont aucune classe

Petit détail, je n'ai pas choisi de créer une classe "Cell". Pourquoi ? Parce que je sais que c'est une erreur. Sans doute cela vient-il de mon intérêt pour les jeux vidéos, et des discussions que j'ai pu avoir avec un ami bien plus compétents en la matière (normal, c'est son boulot). Je sais qu'un monde, donc, dont chaque case n'a qu'un état booléen n'a pas besoin de représenter chacune d'entre elle par un objet avec une classe et des méthodes.

Toujours est-il que je savais que je n'en avais pas besoin. La seule chose qui change, c'est le monde. Ce monde a un état, qui est sa taille et son damier (le "board"). Mieux encore, j'ai fait en sorte que seule les cellules vivantes soient stockées dans mon objet, comportement que j'ai ajouté par la suite (et les tests unitaires m'ont permis de vérifier que je n'avais rien cassé entre temps).

Comme Mathieu, je trouve disproportionné d'utiliser trop de classes différentes pour représenter les données du problème, et cela pourrait justifier sa remarque :

[...] j'ai été confronté à des visions très différentes de mes collègues de pair-programming, par exemple sur le découpage des objets, ou sur leur responsabilité

Oui, trouver une solution entraîne des débats, des difficultés. Oui, cela veut dire faire des choix, et ce n'est pas simple. Mais je ne vois pas ce que l'objet vient faire là dedans. Comme je l'ai dit, je pense que les principaux problèmes avec la programmation orientée objet, ce sont les mauvaises pratiques et les mauvaises habitudes prises depuis trop longtemps et avec un manque de recul manifeste.

Et comme le dit Eevee :

Stop writing stupid classes.

Tout cela donc m'amène à penser que là où nous nous trompons, c'est lorsque nous oublions de remettre en question des automatismes, comme "un concept == une classe".

Mutabilité

Toujours dans les discussions qui suivirent le billet de Mathieu, il y a ces remarques de @clementd, notamment celle ci :

L'OOP masque la complexité en l'éclatant en plein d'agents mais ne la supprime pas.

Ce avec quoi je suis d'accord (enfin, je joue un peu sur les mots) : c'est même tout l'intérêt d'un bon code orienté objet.

Il masque la complexité dont je n'ai pas besoin de m'inquiéter au quotidien, en me rendant les choses simples par une interface claire, pratique et élégante - et documentée de préférence. Dans mon exemple de code par exemple, la classe World masque la complexité du calcul du nouvel état par ses méthodes calculate_diff, update et enfin mutate.

Comme je l'ai dit plus haut, j'ai fait le choix de laisser des méthodes qui permettent de modifier l'état de l'objet : je profite de la mutabilité de mon objet pour "masquer" la complexité des opérations que je vais devoir effectuer à un moment où à un autre. Néanmoins, c'est un choix assumé et très clair. Le nom même des deux fonctions update et mutate laisse à penser qu'elles ne laissent pas intact leur objet.

Pour autant, toutes les méthodes n'utilisent pas la mutabilité de l'objet : calculate_diff, dump, get_around, position, etc. Elles sont d'ailleurs séparés par un commentaire Immutable, et utilisent toutes... le module worldfp !

Dans tous les cas, j'ai bien conscience des choix que je fais : je donne la responsabilité de son changement à mon objet. Il me garantit, en échange, la cohérence et la stabilité de son comportement.

Complexité

Dans l'exemple de Mathieu, il expose un fonctionnement que je n'aime pas du tout : chaque cellule calcule son prochain état, et le stocke dans un attribut. Il profite bien de la mutabilité des objets, mais, en l'occurrence, je pense qu'il se trompe dans son implémentation, et que la plupart de ses problèmes viennent de là : il a fait quelque chose d'inutilement complexe et trompeur, où il mélange à la fois contrôle et mutabilité de l'état. De fait, son implémentation "masque" des changements d'états.

Dans Out of the tar pit les auteurs exposent ce qu'ils appellent la complexité "essentielle", inhérente au problème, et la complexité "accidentelle" :

Pour mieux comprendre tout cela, vous devriez vraiment lire Out of the tar pit, de Ben Moseley et Peter Marks.

Essentielle ou accidentelle

Ici, nous avons une complexité essentielle : c'est l'état originel du monde (sa taille et son damier). Nous avons ensuite deux complexité accidentelles utiles : l'état actuel du monde d'une part, et - évidement - le contrôle des données de l'autre. Enfin, je pense que dans le code de Mathieu il y a une complexité accidentelle inutile, c'est l'état suivant de chaque cellule, dont nous n'avons besoin qu'au moment de créer une nouvelle génération du monde.

L'état actuel du monde est accidentel, car il peut être recalculé à partir de l'état d'origine du monde et du nombre de générations souhaitées. Pour des raisons pratiques, son état "actuel" est cependant stocké et disponible "à plat" à la demande : accidentel mais très utile. Il est donc bien séparé dans l'attribut board, et il n'y a rien d'autre avec lui.

Le contrôle sur les données quant à lui (comme la position) est tout aussi accidentel : dans mon implémentation, le damier ne contient que les cellules vivantes, c'est à dire dont l'état est True. Cela implique du contrôle, ce qui n'est absolument pas essentiel, mais bien utile : vous pouvez regarder les méthodes position ou bien is_valid par exemple. Ces deux méthodes effectuent des vérifications et retourne True, False, ou None, mais aucune ne modifie l'état de l'objet.

Utile ou inutile

Dans mon code, certaines méthodes pourraient être retirées car potentiellement inutile, mais elles sont là car elles sont utiles dans la logique de programmation objet ; il s'agit de set_alive, set_dead, mutate, update et reset (bien que pour set_alive et set_dead je pourrais les retirer complètement).

Je pense que c'est de ces fonctions que vient la plus grande part de complexité liée à la mutabilité des objets, et qui s'exprime donc ici. C'est aussi une complexité qui vient avec ses avantages, comme la possibilité d'écrire sa propre implémentation qui hérite du comportement de ma classe World. C'est ce que Armin Ronacher dit lorsqu'il parle de ne pas masquer l'API bas niveau dans une seule fonction monolithique :

In fact the Jinja2 environment is full of methods that are just waiting to be overridden. [...]. This is useful! Yes. At the end of the day Flask's render_template is all you're going to use in 99% of all cases, but that 1% of the other cases should not require you to rewrite all of the library.

En utilisant des classes et des objets, il est possible de créer sa propre implémentation rapidement, et de façon élégante, en respectant simplement les interfaces. C'est un compromis, et en tant que tel, il doit être compris et mesuré.

Il critique, par ailleurs, lorsqu'un code ne fournis pas ces outils nécessaires à la modularité, et il rappelle au passage que cela a assez peu à voir avec le paradigme objet ou fonctionnel.

Contrôle et séparation

Dans la suite de Out of the tar pit, les auteurs classent ce qu'il convient de faire en fonction des complexités rencontrées :

Dans mon implémentation, j'estime avoir effectué au moins deux séparations : la première est de ne pas avoir donner au monde la possibilité de se modifier sans qu'on ne le lui demande explicitement, avec un appel à des méthodes très spécifiques (je parle de mutate et de update). Si quelqu'un veut utiliser le monde pour calculer 50 générations puis n'afficher qu'ensuite l'état du monde, il peut, mais le monde ne le gère pas de lui-même (et ne trompe pas le développeur en faisant des trucs dans son dos).

Tactique d'évitement

La deuxième séparation que j'estime avoir effectuée, c'est le calcul du prochain état, et l'application de ce prochain état : la méthode mutate n'est qu'un raccourci pour écrire ceci :

world.update(world.calculate_diff())

Qui peut s'écrire ainsi :

new_diff_state = world.calculate_diff()
world.update(new_diff_state)

C'est plus simple à modifier, à tester, et à moduler. Si quelqu'un ne veut modifier que la méthode de calcul du différentiel, ou son application, il peut comme il le souhaite. Mais à aucun moment je ne stocke d'état intermédiaire, par essence accidentel, mutable, et parfaitement inutile.

J'ai donc "évité" cette complexité, contrairement à Mathieu qui, selon moi, s'est trompé en rendant les cellules responsables à la fois d'un état (essentiel), de son contrôle (accidentel), et a ajouté une partie accidentelle inutile : son "prochain état", qui n'est valide que lors d'un traitement et n'a donc pas lieu d'être en dehors de ce traitement.

Bon, mais je peux me tromper, alors je prends toutes les remarques via Twitter, billet de blog et email.

Encore quelques détails

Comme j'aime beaucoup le paradigme fonctionnel, j'ai ajouté au Gist un fichier worldfp.py, que j'utilise même dans mon code "objet", et qui est une implémentation de l'algorithme du jeu de la vie. Certains détails méritent des explications.

dict & namedtuple

Tout d'abord, le namedtuple que j'ai créé et qui s'appelle World. Pour des raisons pratiques (et par flemme), j'ai préféré créer un type d'objet non-mutable qui ne contient que les trois même attributs que ma classe World : la taille du damier, et l'état du damier.

Du coup, au lieu de fournir à chaque fonction board, size_x, size_y, je fournis simplement un objet world. Ce namedtuple expose les mêmes attributs que ma classe World - je sais, le hasard fait si bien les choses.

J'insiste lourdement sur l'aspect "non-mutable" de l'objet en lui-même, mais je tiens à vous prévenir tout de suite : non, il n'est pas possible de garantir son aspect non-mutable, car j'utilise un dictionnaire pour représenter son damier, et que ce type d'objet est mutable. Cette propriété de mutabilité de l'objet dict est d'ailleurs exploitée par Mathieu dans son exemple :

new_world[x][y] = evolve_cell(new_world[x][y], num_alive_neighbours(world, x, y))

Ce n'est néanmoins pas un soucis, car il construit un dictionnaire qu'il retourne, mais il ne stocke rien une fois sorti du traitement, il n'y a donc pas d'état à gérer entre deux appels. De la même façon, je fais en sorte dans mes fonctions de ne jamais intervenir sur le dictionnaire de l'objet world fourni en paramètre.

world & self

Autre détail, mais vous aurez sans doute remarqué deux choses :

Là, c'est moi qui joue avec le principe de duck typing. On aime ou on aime pas, moi je trouve ça pratique, et cela me permet de démontrer ce qui m'intéresse : il est possible de faire un mélange d'orienté objet et de programmation fonctionnelle, tant que chaque composant est bien séparé du reste, et que les responsabilités sont bien claires.

Alors, oui, moi je trouve ça simple comme séparation, je trouve ça simple de manipuler des objets, et je trouve ça simple d'adopter un style plus FP lorsque c'est utile, pratique, voire nécessaire. Comme je le disais à @clementd dans un petit tweet de réponse :

En même temps, je peux t'avouer un truc : je n'ai jamais ressenti la complexité exprimée par mes pairs.

Peut-être ai-je cette chance de ne pas avoir de problèmes avec les principes de base de la programmation objet et de la programmation fonctionnelle - et je parle des bases, pas du reste, où je suis loin d'être un génie dans un quelconque domaine.

Toujours est-il que je trouve injuste de comparer l'un et l'autre de ces paradigmes, en se basant sur de mauvaises expériences personnelles qui s'expliquent par d'autres raisons.

La bonne nouvelle, c'est qu'au vu des très nombreuses erreurs que je commets tous les jours en écrivant du code, je peux affirmer qu'il s'agit là d'un apprentissage tout à fait naturel, et qu'il vaut mieux remettre en question de temps en temps à la fois ce que l'on sait, et ce que l'on croit savoir. Si vous ne l'avez pas déjà fait, vous devriez donner sa change à la programmation fonctionnelle, elle a beaucoup à apprendre à des développeurs qui, comme moi, n'ont été éduqués que dans la programmation orientée objet.

Les liens

Pour finir, voici quelques liens que je vous invite à découvrir ou à redécouvrir (certains sont déjà glissés dans ce billet) :