IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)
Voir le flux RSS

Réac' programming

Misères de la conception objet, un cas pratique

Note : 5 votes pour une moyenne de 4,20.
par , 01/03/2015 à 14h27 (1771 Affichages)
Un des charmes de la programmation objet, c’est l’impression de « programmer le réel ». Finis les entiers, les tableaux, les fonctions, les fichiers. En objet, on manipule les concepts métiers, décrits par des classes, agissant au travers de fonctions, composés par assemblage d’autres concepts, ou par spécialisation de catégories générales. On programme un jeu d’aventure ? Nos programmes auront des classes portes, des classes monstres, des classes épées et des classes personnages. On fait de la gestion de personnel ? Il y aura des employés, des managers, des bureaux et des sociétés.

Et comme l’approche objet est très répandue, cette vision de l’informatique comme imitation du monde se retrouve un peu partout. Elle est dans les formations et les manuels, remplis d’animaux qui courent, d’oiseaux qui volent, de voitures qui roulent, et qui ont un moteur, et qui sont des véhicules. Elle est aussi, dans les systèmes de modélisation ou dans les formats de données comme XML ou JSON.

Cette approche est très séduisante. D’abord, parce qu’elle paraît simplifier les spécifications : il suffit de faire décrire à l’utilisateur son métier, pour en tirer une architecture. Ensuite, parce qu’elle semble certaine d’aboutir : on voit mal comment un programme qui décrit correctement le réel pourrait ne pas répondre aux besoins d’utilisateurs réels. Enfin, parce que dans certains cas complexes, elle fonctionne remarquablement bien. C’est évidemment l’approche idéale pour des programmes de simulation ou de suivi, mais elle est également très adaptée quand le « réel » qu’on prétend simuler est lui-même une construction informatique, par exemple un système de fenêtrage.

Ce n’est pourtant pas une évidence. Un avion ne ressemble pas à un oiseau, un bateau n’a pas grand-chose de commun avec un canard, et pourtant l’un vole, et l’autre navigue. Et de fait, l’imitation du réel, en informatique, est souvent au cœur de mauvaises conceptions, de programmes inefficaces, voire d’architectures très éloignés de la conception objet. Je vais essayer de l’illustrer sur un exemple simple (et très classique).

Essayons de concevoir un programme qui compare deux mains de poker. La réaction naturelle, en conception objet, consiste à se dire qu’on a des cartes, définies par leur couleur et leur rang, qui forment un premier concept, quelque chose comme (en C++, mais ce serait presque pareil dans d’autres langages)

Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
enum Couleur {coeur,carreau,trefle,pique} ;
enum Rang {deux,trois, quatre, cinq, six, sept, huit, neuf, dix, valet, dame, roi, as} ;
 
// struct car assemblage de données publiques sans méthodes
struct Carte { 
	Couleur couleur ;
	Rang rang ; 
}

Une main, que nous allons appeler Jeu (vu que main veut dire autre chose en C++) est une collection de cinq cartes, qu’on va agrémenter d’un opérateur de comparaison.

Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
enum QuiGagne {moi, personne, lui} ;
 
class Jeu {
	Carte cartes[5]; // ou un vector si vous voulez…
	QuiGagne Compare(const Jeu &j) const;
}

Ceci nous donne un programme qui ressemble à

Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
Jeu j1,j2 ;
// chargement des données à comparer
return j1.Compare(j2) ;

On constate ici la puissance de l’imitation. On a à peine commencé, mais on a déjà un squelette de programme, très simple, qui pourrait être réutilisé pour n’importe quel jeu de cartes. On sent que l’architecte, le responsable méthodes et le chef de projet vont être fiers de nous. Il ne reste qu’à écrire le comparateur.

Là encore, l’application naïve des règles du jeu donne une solution rapide. On a neuf cas, quinte flush, carré, full, couleur, suite, brelan, deux paires, paire, une carte. Pour chacun, on peut ajouter à notre classe une fonction de test estXXX() et une fonction de départage compareXXX(Jeu&), appelée si les deux mains sont XXX. On va donc avoir une série de neuf fonctions membres :

Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
bool EstQuinteFlush() ;
bool EstCarre() ;
bool EstFlush() ;


Neuf fonctions servant à départager les ex-aequos

Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
QuiGagne CompareQuintesFlush(Jeu&j) ; 
QuiGagne CompareCarres(Jeu &j) ;

Et un opérateur de comparaison qui ressemble à :

Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
QuiGagne Compare(Jeu &j) {
	// quintes flush
	bool qf1=EstQuinteFlush() ;
	bool qf2=j.EstQuinteFlush() ;
	if(qf1 && !qf2) return moi ;
	if(!qf1 && qf2) return lui ;
	if(qf1 && qf2) return CompareQuintesFlush(j)
	// carres
	bool c1=EstCarre();
	bool c2=j.EstCarrre();
	if(c1 && !c2) return moi ;
	if(!c1 && c2) return lui ;
	if(c1 && c2) return CompareCarres(j)
// et ainsi de suite en descendant
}

Mission accomplie, hein ? il n’y a plus qu’à écrire 18 fonctions de test et d’ex-aequo, et à assembler tout cela en une grande fonction de comparaison, avec ou sans boucle, qui traite les cas successivement. Ca ne va pas être très beau, ni très élégant, mais si on a le temps, on refactorisera. Et tout va bien se passer, vu qu’on a modélisé au plus près.

En fait, c’est généralement là que les problèmes commencent. D’abord, on vient de choisir une solution qui impose d’écrire pas mal de code (18 fonctions), sans grande possibilité de mise en commun. Ceci veut dire plus de tests unitaires, plus de débogage, plus de risques d’erreur. Ensuite, parier sur une refactorisation « après », c’est être presque certain qu’elle n’aura jamais lieu : l’écriture, les tests et le débogage prendront plus de temps que prévu, et une fois qu’on aura quelque chose qui marche, la refactorisation sera presque toujours reportée à des lendemains qui chantent. En fait, la refactorisation, c’est souvent une vue de l’esprit : soit elle a lieu quand on développe, et ce n’en est pas une, soit c’est quelque chose qu'on imagine pour se donner bonne conscience.

Mais le principal problème est théorique : notre élégante méthode de conception objet, censée nous produire de beaux programmes maintenables, réutilisables, flexibles, agiles, produit, sur ce cas simple, un affreux code procédural, avec une grosse fonction moche et un tas de sous fonctions utilisées une seule fois, qui sera probablement très lent. Dans une situation réelle, avec de vrais difficultés, et de vrais enjeux, est-ce bien raisonnable ?

L’approche objet est-elle en cause ? Je ne crois pas. A mon avis, ce qui pêche ici, c’est la conception imitative. La question posée était l’évaluation d’une main de poker, mais on a préféré modéliser le jeu, et en particulier son ‘apparence physique’ (les cartes et les mains), parce que c’était plus facile.

Oublions les cartes, et revenons à la question posée. Comment effectuer la comparaison de deux mains ?

La fonction Compare() nous donne une partie de la solution. Comme on l’a vu, on a neuf « cas » (appelons les figures), ordonnés de la quinte flush à la carte unique, qu’on va examiner successivement. Pour chacune, on appliquera la règle de décision suivante (A et B étant les joueurs)

Si A l’a et pas B, A gagne
Si B l’a et pas A, B gagne
Si A et B l’ont tous les deux : une fonction de départage est appliquée
Sinon, on passe au cas suivant.

Un cas général qui se décline en neuf cas particuliers ? Vous avez demandé l’héritage ? (ou la spécialisation de templates, ici, c’est à peu près la même chose)

Abandonnons les classes Carte et Jeu, et repartons d’une nouvelle « classe de base », la classe abstraite « figure », ready to derive or to templatise.

Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
class figure {
	bool EstUn(Jeu &j) ;  
	int ExAequo(Jeu &j1Jeu &j2) ;
        int Compare(Jeu &j1,Jeu &j2)  {
		bool b1=EstUn(j1) ;
		bool b2=EstUn(j2) ;
		if(b1 && !b2) return 1;
		if(b2 && !b1) return 2
		if(b1 && b2) return ExAequo(j1,j2) ;
		return -1;
	}
}

On pourrait en dériver neuf sous classes, une par figure, chacune avec ses fonctions EstUn() et ExAequo(), mais qui partageront toutes la même fonction Compare(). Et le programme principal instanciera successivement les sous-classes, appellera Compare(), renverra le résultat (A, B, égalité) si la figure apparaît, ou passera à la figure suivante sinon.

Notez bien qu’il ne s’agit pas « juste d’une refactorisation » du code précédent. Dans ce nouveau modèle, les jeux ne sont plus que des structures de données publiques (des struct), et la vraie classe de base, ce sont les figures, qui ne sont pas le concept métier qui vient le plus naturellement quand on pense au poker.

Qu'a-t-on gagné? On a amélioré la conception, et on a probablement un programme un peu plus élégant. Mais c'est à peu près tout. Il nous reste toujours 18 fonctions à écrire (les mêmes qu'avant, en fait), et autant de risques d’erreur. Pour aller plus loin, poursuivons la réflexion et voyons dans quelle mesure on pourrait mutualiser les calculs de ces 18 fonctions.

Au poker, la plupart des figures (6 sur 9) sont déterminées par la longueur des séries de cartes de même rang. Imaginons que les cartes nous arrivent triées, par longueur de série, puis par hauteur. On aurait ainsi, (2 10) (2 8) (1 R) (une paire de dix, une paire de 8, un roi), ou (3 8) (2 10) (full aux huit par les dix). C’est une représentation assez naturelle. En fait, c’est exactement comme cela qu’un joueur décrira sa main. On retrouve l'imitation du réel, mais sur un plan moins naïf que tout à l'heure: au lieu de tenter de copier les cartes à jouer, on s'inspire de la façon dont un joueur range ses cartes.

Si les mains sont représentées de cette façon, on constate (essayez !) que tous les tests d’ex aequo deviennent identiques : on parcourt les paires (longueur/hauteur) dans l’ordre, si A est plus haut que B, A gagne, si B est plus haut de A B gagne, sinon, on passe à la paire suivante (et si on est au bout, il y a égalité).

Ainsi, en modifiant la représentation des cartes dans les mains (ce que certains qualifieraient d’optimisation prématurée… ben tiens !), nous venons d’unifier la fonction ExAequo() sur toutes les figures, et donc de diviser par 2 le nombre de fonctions à écrire. Notre classe figure mutualise désormais deux fonctions sur trois. Cela semble justifier notre nouvelle architecture.

Mais on peut faire mieux, en poussant le raisonnement. Les lecteurs les plus attentifs auront peut-être remarqué qu’avec cette représentation, en dehors des suites et des couleurs, toutes les comparaisons de figures s’unifient… (et six tests sur neuf deviennent inutiles): il suffit de comparer d’abord les longueurs des séries, puis les hauteurs en cas d’égalité.

Par exemple un carré de 8 et un 10 (4,8) (1,10), est supérieur à un full aux rois par les dames (3,R) (2,D) (car 4>3). Mais un carré de 9 et une dame (4,9) (1,D) sera supérieur (car 4=4 1=1 mais 9>8).

Imaginons un poker simplifié, dans lequel il n’y aurait ni suite ni couleur. On n’a plus besoin des classes figures, car toute l’évaluation à deux niveaux tient en une fonction (qu’on peut, si on le souhaite, rendre membre de la classe Jeu).

Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef Jeu  vector<pair<int,int> > ;
 
int Compare(Jeu &j1,Jeu &j2) 
{
int sz1=j1.size() ;
int sz2=j2.cartes.size() ;
int sz=min(sz1,sz2) ;
for(int i=0 ;i<sz ;i++) {
    if(j1[i].first>j2[i].first) return 1;
    if(j1[i].first<j2[i].first) return 2;
}
for(int i=0 ;i<sz ;i++) {
    if(j1[i].second>j2[i].second) return 1;
    if(j1[i].second<j2[i].second) return 2;
}
return 0;
}

Tout ça pour ça ? Heureusement qu’il y a les suites et les couleurs, hein ? Parce que sinon, on aurait l’air malins, avec notre approche objet qu’on remplace par une fonction d’une dizaine de lignes, qu’on peut rendre membre ou pas. Pfff, tu parles d’un exemple bidon !

Comment faire ? Abandonner une si élégante simplification pour trois misérables cas particuliers semble une capitulation en rase campagne. C’est le moment de nous souvenir de nos cours de programmation objet, et du principe selon lequel on doit cacher ces cas particuliers via l’encapsulation des données…

Reformulons tout cela, donc, en revenant à la classe Jeu qu’on avait abandonnée lors de l’itération précédente. Certains disent que pour être informaticien, il faut être paresseux, je crois qu’ils se trompent. Pour être informaticien, il ne faut avoir aucune fierté, et ne pas hésiter à aller chercher dans la poubelle le modèle qu’on vient de déclarer nul et non avenu (ce qui est évidemment incompatible avec la paresse).

On dirait donc qu’on aurait une classe Jeu, qui contiendrait une représentation interne d’une main de poker, constituée d’un vecteur de paires longueur/hauteur. Cette représentation serait bien évidemment privée, et serait calculée, lors de la création d’un Jeu, par une fonction dédiée appelée Trie().

Code C++ : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
class Jeu {
private :
	vector<pair<int,int> > _cartes ;
public :
	Trie() ;
	Compare(Jeu &j) ;
}

Si on jouait au poker simplifié, on conserverait la fonction Compare définie ci-dessus, et la fonction Trie() consisterait à

1- Trier les cartes par rang
2- Générer les paires « longueur/rang »
3- Trier les paires

Avec le poker réel, il va falloir tenir compte de la quinte flush (supérieure au carré), de la couleur et de la suite (entre le full et le brelan). Il va falloir tenir compte des couleurs, et notre bel algorithme de comparaison s’effondre. Trop pas de chance, hein ?

Oui, sauf que… la représentation de notre main, en une série de paires longueur/rang est maintenant privée, on pourrait la faire évoluer, en modifiant Trie(), de façon à réintégrer les trois cas particuliers. Commençons par la quinte flush.

Dans notre représentation, une quinte flush au roi s’écrit (1,R), (1,D), (1,V) (1,10), (1,9). Et il nous faut ajouter un booléen, indiquant la couleur. Convenons qu’on l’écrit désormais : (5,R), (1,D), (1,V) (1,10), (1,9), ou simplement (5,R). Une petite modification est nécessaire dans Trie(), mais Compare() est inchangé, puisqu'il considère désormais la quinte flush comme cinq cartes égales, et donc mieux qu'un carré. Ah mais c’est un hack ? Oui oui, mais sur des données privées, avec un commentaire explicatif dans le code, et c’est exactement à cela que sert l’encapsulation…

Et pour les quintes et les flush ? Comme elles sont entre un full (3,X)(2,Y) et un brelan (3,X) (1,Y) (1,Z) (1,T), l'astuce précédente ne marchera pas, et il va falloir tordre un peu plus le bras à la représentation privée. A mon avis, on a deux options : soit admettre des longueurs non entières , et on écrira alors (3,X) (1.6,Y) … pour un full et (3,X) (1.3,Y) pour une quinte. Soit multiplier les longueurs par trois, partout, pour pouvoir utiliser les « barreaux intermédiaires ». Comme pour la quinte flush, il suffira pour cela de bricoler la fonction Trie() (et de documenter le bricolage), et de garder la comparaison comme avant.

La boucle est maintenant bouclée. Nous sommes revenus à nos classes Jeu, auxquelles nous avons ajouté une représentation interne un peu éloignée du monde réel, mais adaptée aux calculs, et qui unifie la fonction de comparaison. Vue de loin, notre architecture ressemble au réel : on a des cartes qu’on trie, puis qu’on compare. Vu de près, c’est plus compliqué (comme on dit sur les réseaux sociaux). Mais on arrive à un programme assez minimaliste : la fonction Compare fait une dizaine de lignes, la fonction Trie() guère plus.

Ah mais c’est dégoûtant ? Pas vraiment. Ca coute quelques lignes de codes, et autant de commentaires, dans une seule fonction Trie(). Ah mais c’est pas objet ? Mais si, justement. Tout le principe de l’encapsulation, c’est de pouvoir être libre d’adapter la représentation interne des données (un des pionners de l’algorithmique, Aho, je crois, expliquait que l’informatique n’est qu’une question de représentation des données). Et rien, dans le paradigme objet, n'impose à nos classes de ressembler au monde réel.

Ce qui était, d’ailleurs, l’objet de ce premier billet…

Envoyer le billet « Misères de la conception objet, un cas pratique » dans le blog Viadeo Envoyer le billet « Misères de la conception objet, un cas pratique » dans le blog Twitter Envoyer le billet « Misères de la conception objet, un cas pratique » dans le blog Google Envoyer le billet « Misères de la conception objet, un cas pratique » dans le blog Facebook Envoyer le billet « Misères de la conception objet, un cas pratique » dans le blog Digg Envoyer le billet « Misères de la conception objet, un cas pratique » dans le blog Delicious Envoyer le billet « Misères de la conception objet, un cas pratique » dans le blog MySpace Envoyer le billet « Misères de la conception objet, un cas pratique » dans le blog Yahoo

Mis à jour 02/03/2015 à 19h19 par fcharton2

Tags: conception, objet
Catégories
Programmation

Commentaires

  1. Avatar de Pierre Louis Chevalier
    • |
    • permalink
    Merci pour ce billet très intéréssant
  2. Avatar de Spike_Spiegel
    • |
    • permalink
    Trés trés interressant
  3. Avatar de kolodz
    • |
    • permalink
    Tu t'es allié avec bouye pour le sujet des cartes ? Il est quand votre jeu de pocker ?

    Cordialement,
    Patrick Kolodziejczyk.

    Note : Pense à utiliser le = nom du langage quand tu utilise la balise code. Les blogs n'ont pas de coloration syntaxique par défaut. On ne sait pas d'avance si tu parle de Java ou de C# ou du Cobol !
  4. Avatar de fcharton2
    • |
    • permalink
    Citation Envoyé par kolodz
    Note : Pense à utiliser le = nom du langage quand tu utilise la balise code. Les blogs n'ont pas de coloration syntaxique par défaut. On ne sait pas d'avance si tu parle de Java ou de C# ou du Cobol !
    Fait, merci du tuyau (sur le fil politique, j'emploie assez peu la balise CODE...)
  5. Avatar de lanje
    • |
    • permalink
    Merci pour ce billet, très utile
  6. Avatar de stendhal666
    • |
    • permalink
    Un billet agréable à lire mais qui peine un peu à convaincre... Je trouve que la démonstration est bien faite mais que la conclusion qu'il faut en tirer est une autre!
    Si je suis bien le raisonnement:
    1. la modélisation naïve du réel conduit à l'échec (contrairement à ce que les exemples sempiternels de formes et de voitures dans les livres d'initiation laissent croire);
    2. la modélisation est en fait un travail itératif;
    3. le travail de modélisation doit donc être éclairé par le souci d'utiliser des algorithmes plus généraux et plus puissants;
    Conclusion: ce qui justifie la POO est qu'elle permet de faire ce travail à l'abri grâce à l'encapsulation des données

    En fait, si on examine le cas pratique, on se rend compte que le résultat final est la déconstruction du problème en un petit nombre de fonctions primitives et orthogonales, donc le résultat d'une analyse fonctionnelle du problème, pas d'une analyse objet! Un plus pour les langages fonctionnels, un moins pour les langages OO. Quant à l'encapsulation, elle n'est d'une part pas nécessaire à la programmation objet (cf Python ou CLOS) et possible par module dans des langages non-objets, comme Haskell par exemple.
  7. Avatar de GeoffreyOnRails
    • |
    • permalink
    Billet très intéressant et très bien écrit, mais je reste également non convaincu.
    Je qualifierai complètement ceci d’optimisation prématurée, car je pense que comparer deux mains de poker ne consomme quasiment rien en ressources, même pour une grande plate-forme de poker, et de toute façon on pourrait facilement scaler à l'horizontale. Mais je comprends qu'il s'agit plus d'un exemple que d'un cas réel, donc je ne m'attacherai pas trop sur cet aspect là, même si pour moi il est valable dans 99% des cas.

    En fait ce que tu proposes, c'est de lier le format des données aux traitements qui seront fait dessus, pour simplifier les traitements. C'est ce qui se faisait au début de l'informatique, et je pense qu'on en est revenu pour de bonnes raisons,

    En dehors d'un véritable besoin de performance, voici les limitations de cette solution :
    - Il est difficile à comprendre. Même avec des commentaires, un modèle compliqué abouti à un code complexe à appréhender, ce qui abouti à des bugs. Je suis un nouveau programmeur sur ton projet, et je me sers de ta classe pour afficher la main, si j'utilise _cartes, je ne me rendrai compte du problème que lorsque j'aurais une suite ou une couleur. C'est fourbe. Et en cas de bug à trouver, c'est très délicat de s'assurer que le problème viens (ou non) d'ici.
    - Il ne représente pas le réel, et cela à des conséquences sur la ré utilisabilité. Je risque d'avoir du mal à réutiliser cet algorithme sur d'autres variantes de poker par exemple. Imagine que je rajoute un joker. Imagine que je joue au Omaha, où ma main à 7 cartes? Comment vas-tu tordre ton modèle? Quels tests proposeras-tu me convaincre qu'il ne présente pas de bugs?


    Certes, 18 fonctions de comparaison, c'est peut-être pas le plus efficace. Mais je suis sûr que le code est correct au premier coup d’œil, que mon nouveau stagiaire va parfaitement comprendre comment traduire la demande de l'expert en code sans tout casser, et qu'il pourra s'en resservir pour le traitement suivant à faire, et ça pour moi, ça n'a pas de prix, et c'est ce qui fait la valeur de l'objet.

    Au plaisir de te lire.