Dot.Blog

C#, XAML, WinUI, WPF, Android, MAUI, IoT, IA, ChatGPT, Prompt Engineering

Programmer “pro” : Implémenter un Undo / Redo

[new:30/10/2014]Programmer “pro” cela implique beaucoup de choses dont le fait d’offrir des fonctionnalités absentes des logiciels amateurs comme la fonction Undo / Redo. C’est que ce n’est pas si simple à coder et à planifier. Regardons quelques approches sont les plus pertinentes…

Le droit à l’erreur

UndoL’une des (nombreuses) clés d’un bon Design et d’une UX réussis est la bonne gestion du “droit à l’erreur”. Les petits logiciels dans lesquels on ne peut pas revenir en arrière, annuler la dernière action, sont sources de frustrations. Et la frustration ce n’est pas bon pour l’adoption. On en a encore un exemple récent avec le fullscreen et le menu à tuile de Windows 8 qui finalement sont rétrogradés à une place moins centrale dans Windows 9 non pas parce que c’est la volonté de Microsoft mais bien parce que l’interface Modern UI a créée des frustrations ayant entrainé le rejet.

Il y a milles et une choses qui font un bon Design, une UX agréable et j’en ai souvent parlé dans des articles totalement tournés vers ces sujets. Mais la possibilité de se tromper et de revenir en arrière est certainement dans le Top 3 s’il n’est pas la 1ere à devoir être offerte à l’utilisateur.

D’où l’importance d’implémenter un Undo / Redo dans ses logiciels.

L’aspect purement intellectuel ne sera pas débattu plus longtemps  mais la démarche qui prévaut à l’implémentation d’une fonction de gestion du droit à l’erreur a une justification profonde ancrée au cœur de la conception d’une UX de qualité “pro”.

Quels types de logiciels concernés ?

Tous ! La réponse est simple.

Dans la pratique cela est moins évident. Dans Word par exemple l’utilisateur frappe du texte, applique des styles. La programmation même d’un logiciel de la qualité de Word est très complexe mais les actions de l’utilisateurs sont très simples. Et elles s’enchainent d’une façon qu’on peut les représenter comme un long ruban. Il est facile intellectuellement au moins d’aller d’avant en arrière sur un ruban. C’est le principe de fonctionnement de la Machine de Turing notamment.

Dans un logiciel de type Photoshop finalement c’est le même ruban qu’on retrouve : une suite de commandes clavier ou souris. Toutefois Photoshop n’est pas Illustrator et déjà des difficultés apparaissent. Dans un logiciel vectoriel le dessin est virtuel, il n’est qu’une somme de segments, de courbes, de changements simples de propriétés (épaisseur, couleur…). Il semble assez simple là aussi, comme dans Word, de considérer ces commandes comme un long ruban sur lequel on peut se déplacer pour annuler des actions. Sous Photoshop qui travaille sur des bitmaps de taille parfois très grande mémoriser les commandes n’est pas suffisant car on ne sait pas comment retrouver le dessin dans sa forme précédente juste en annulant la commande elle-même, à moins de tout mémoriser depuis le lancement de l’application et de rejouer exactement toutes les commandes jusqu’à celle qui précède la commande annulée, ce qui n’est pas raisonnable. Il est donc nécessaire dans ce type d’applications de faire plus que de stocker le ruban des commandes, il faut aussi sauvegarder l’état du logiciel. Quand faut-il garder automatiquement une copie de la bitmap en cours de travail ? A chaque mouvement de souris, à chaque clic pixel par pixel ? Il va falloir des Go d’espace disque et beaucoup d’écritures (lentes) sur disque pour un tel mécanisme… Ce qui va compliqué sérieusement le travail des développeurs pour que l’ensemble reste fluide !

On commence à le voir à ce simple exemple, si tous les logiciels peuvent potentiellement supporter un Undo / Redo dans la pratique les choses peuvent devenir vite très compliquées.

D’où la nécessite de choisir une approche bien ficelée et d’avoir des applications bien planifiées où on sait où on va depuis le départ ce qui permet de prévoir une architecture qui autorisera l’implémentation d’un Undo / Redo. C’est pour cela que cette fonction pourtant basique est principalement l’apanage des logiciels “pro”, ceux pour lesquels il existe un plan, des diagrammes UML, des uses cases, des plans précis des classes autant que des fonctionnalités offertes et de la façon dont elles seront présentées à l’utilisateur.

Dans un logiciel qui part d’une idée qu’on fait évoluer en fonction des demandes ou de ses désirs, il n’existe généralement pas une structure suffisamment claire et réfléchie pour permettre l’implémentation d’un Undo / Redo.

J’irais même jusqu’à dire que lorsque je fais un audit c’est l’une des questions que je me pose : ce logiciel peut-il supporter l’ajout d’un Undo / Redo tel qu’il est programmé ? D’une part la fonction n’est jamais ou presque implémentée et c’est donc une bonne question à se poser pour améliorer l’application, mais d’autre part en termes uniquement de qualité de codage pouvoir répondre un “oui” franc est la preuve d’une excellente architecture et dans le sens contraire toute réponse mitigée ou carrément négative oblige à constater que le code est mal fichu. L’effort à produire pour implémenter un Undo / Redo est donc inversement proportionnel à la qualité du code. Plus c’est dur à faire, plus le soft est mal écrit, moins le code est correctement structuré.

Amusez-vous à faire de simples Review de code, du vôtre, celui de vos collègues, en vous posant cette unique question… Vous verrez c’est édifiant.

Pour l’instant nous considèrerons que le code est bien écrit ou bien qu’il sera écrit en pensant à l’implémentation du Undo / Redo. C’est un cas idéal mais cela ne devrait pas l’être dès lors qu’il s’agit d’une application professionnelle.

Les différentes stratégies

Dans le cadre idyllique que je viens d’indiquer il reste à savoir comment implémenter un Undo / Redo.

Et pour ce faire on peut imaginer plusieurs approches, chacune ayant ses avantages et ses défauts.

Car avant de se lancer sur son clavier il faut penser !

Undo et Redo

Avant tout il faut comprendre que l’implémentation d’un Undo / Redo réclame deux listes : celle des actions passées pour autoriser le Undo et celle des opérations annulées par Undo qui pourront être réappliquer dans le Redo. Les deux listes peuvent être vues comme des vases communicants, quand l’une est vidée d’une action cette dernière est placée dans l’autre. Un Undo peut faire l’objet d’un Redo qui lui-même peut être à nouveau annulé etc…

Ces deux listes sont particulières et utilisent des piles LIFO, last in first out. .NET offre la classe Stack<T> qu’on va pouvoir utiliser pour pousser une action (push) ou prendre celle au somment de la pile (pop).

Cette contrainte est purement logique, c’est une conséquence incontournable de ce que signifie la fonction Undo / Redo et elle sera donc à la base du fonctionnement de toutes les stratégies qu’on pourra inventer.

Dans la pratique ce système à deux piles connaitra des variantes sur la nature de ce qui est mémorisé mais on n’échappera pas à cette architecture. Au mieux, mais au pire pour l’utilisateur, pourra-t-on limiter la fonction à un simple Undo sans Redo ce qui n’utilisera qu’une seule pile. Mais quitte à implémenter la fonction autant le faire jusqu’au bout…

Approche basique

La plus simple des approches consiste à se dire qu’un logiciel n’est qu’un automate à n états qui sont forcément représentés par une successions de valeurs, celles des propriétés des objets qui composent le code en fonctionnement.

Dans un telle approche on se dit qu’il “suffit” de mémoriser l’état du logiciel à chaque changement de propriétés. On va ainsi créer un objet qui contiendra toutes les propriétés susceptibles de changer et qui ont un impact sur ce qu’il est possible d’annuler. On créera aussi une liste pour contenir des instances de ces objets qui représenteront chacune un changement précis.

Par exemple si les états annulables d’un logiciel peuvent concerner sa hauteur et sa largeur de fenêtre, on créera un objet qui contiendra ces deux propriétés.

Ensuite viennent deux options. La simple consiste à systématiquement sauvegarder les deux propriétés et à les rétablir en cas de Undo. Cela n’est pas forcément toujours optimal et on peut ajouter une troisième propriété à la classe de sauvegarde : une énumération qui indiquera quelle action a été réalisée ce qui permettra de ne sauvegarder et restaurer que la ou les propriétés concernées par l’action.

Je ne vous monterai pas de code exemple pour cette approche basique. Elle est tellement simple qu’elle ne demande même pas de comprendre les design patterns ni d’être un crac du code.

Forcément si un développeur de base peut y penser et l’implémenter c’est que ce n’est certainement pas la façon la plus subtile de gérer le problème… Et en effet les défauts de cette approche sont nombreux.

Le plus grave et le plus rédhibitoire c’est que la maintenabilité sera très faible. Tout ajout d’une propriété quelque part imposera de modifier la classe de sauvegarde, d’ajouter du code pour mémoriser cette propriété et la rétablir par exemple. Le moindre oubli et patatras ! c’est toute l’application qui plantera en cas de Undo ou de Redo… Et cela peut prendre du temps avant qu’un utilisateur ne tombe sur le cas. Ici c’est le débogue qui sera rendu difficile. L’autre problème que pose cette approche c’est qu’elle est justement tellement pauvre conceptuellement qu’elle n’est pas du tout optimisée. Ainsi c’est une instance d’un objet de sauvegarde qui sera créé pour chaque action, peut-être des dizaines, voire de centaines de propriétés alors même qu’une poignée seulement ne sera utile à une action donnée. C’est un tel gâchis de ressources qu’un tel code ne peut mériter d’être qualifié de “professionnel”.

Il y a d’autres déconvenues à attendre de cette approche mais ces deux problèmes étant tellement énormes qu’il vaut mieux ne pas aller plus loin. C’est une mauvaise idée. Il fallait s’y attendre un peu, “approche basique” c’était dans le titre du paragraphe…

Le pattern Memento

L’approche basique pour brouillon qu’elle soit n’est pas forcément une mauvaise idée dès lors qu’on y met un peu plus de rigueur… La première chose à faire est de limiter la sauvegarde à un seul type d’objet, une surface de dessin, un texte. Vous allez me dire “un” logiciel ce n’est jamais qu’un objet application … Oui et non. Non parce que le tour de passe-passe est un peu gros, l’objet application ne fait pas grand chose d’autres que d’appeler la première fenêtre en général, il fournit éventuellement des services mais il ne mémorise pas quoi que ce soit qui intéresse l’utilisateur. Et puis on se voit mal dupliquer complètement toute une application en mémoire à chaque petit changement ! Et d’un autre côté on peut dire “oui” car une application n’est qu’un objet qui agrège d’autres objets et de ce point de vue il pourrait être sauvegardé.

Mais ce n’est pas la direction privilégiée par l’esprit du design pattern Memento. Ce dernier se limite à un objet qui peut être complexe mais un seul objet, généralement principal, comme peut l’être la surface de dessin dans Photoshop ou Illustrator ou un texte sous Word. Dans un logiciel d’entreprise on pourra concevoir qu’une fiche client ou une commande, une facture, sont des objets qui peuvent être amenés durant leurs modifications à supporter le Undo / Redo.

Le Gang Of Four

Le design pattern Memento fait partie des 23 patterns parus dans le livre “Design Patterns: Elements of Reusable Object-Oriented Software” en 1994 sous la plume de quatre auteurs, d’où le nom de “gang des quatre” : Erich Gamma, Richard Helm, Ralph Johnson et John Vlissides.

Ce livre fut l’un des premiers sur les design pattern en informatique et l’un de ceux qui fait référence comme un bible depuis 20 ans. Les design patterns y sont classés en trois grandes catégories, Creational (tout ce qui concerne la création d’objets), Structural et Behavioral (comportemental). Le Memento fait partie de cette dernière catégorie.

Petit rappel les “design pattern” sont des “patrons de conception” et n’ont rien à voir avec le Design comme parfois certains le croient. Le Design peut lui aussi avec ces design patterns, ce sont alors des design design pattern Sourire

Le Memento – Intention

Le but de Memento est de capturer et d’externaliser les états internes d’un objet de telle façon à pouvoir restaurer de cet objet à tout moment et ce sans violer les principes d’encapsulation à la base même de la programmation objet ou orientée objet.

Le Memento – Champ d’application

On utiliser le design pattern Memento :

  • Quand  un instantané de l’état d’un objet ou d’une partie de ce dernier doit être sauvegardé en vue d’être restauré plus tard
  • Qu’une Interface permet d’obtenir l’état de l’objet qui sinon obligerait à dévoiler les détails de son implémentation ce qui violerait l’encapsulation de cet objet.

Le Memento – Structure

Le diagramme UML suivant précise la structure du pattern :

image

Les participants sont :

Le Memento qui stocke les états de l’objet Originator et qui expose 2 interfaces, la première étant large et permet à l’Originator de stocker son état, la seconde est plus étroite, ne donne pas accès aux détails, et permet uniquement au Caretaker de manipuler une liste de Memento.

L’Originator est l’objet concerné par la sauvegarde. C’est lui qui créé un Memento pour y stocker son état et qui utilise des Memento pour restaurer son état tel qu’il était à un moment précis donné.

Le Caretaker est l’objet responsable de la conservation des Memento et de leur gestion. Il n’agit jamais ni n’interagit avec le contenu du Memento qui pour cette raison doit être de son point de vue une boite noire.

le Memento – Collaboration

Les trois participants au Memento collaborent en suivant les indications du diagramme UML suivant :

image

Ce diagramme est suffisamment simple pour ne pas s’y attarder.

Le Memento – Conséquences

Comme tous les design patterns le Memento est accompagné de sa liste de conséquences. Cette liste est aussi importante que le reste si ce n’est plus car grâce à elle on réutilise l’expérience des concepteurs du pattern et on peut l’utiliser en toute connaissance de cause, ce qui est aléatoire avec des solutions même brillantes inventées par un développeur en cours de développement d’une application. La supériorité des design pattern apparait ainsi clairement sur les solutions “maison” ne bénéficiant pas d’un tel recul.

Le Memento permet ainsi :

De préserver les limites de l’encapsulation d’un objet tout en permettant de manipuler ces états internes.

  • De simplifier l’Originator qui sinon devrait implémenter un code peut-être complexe ou brouillon pour obtenir le même résultat. Le Memento et le Caretaker décharge l’Originator d’une partie du travail ce qui participe à la création de classes centrées sur le travail principal.
  • Le Memento peut être une solution gourmande en ressources. Dans certains cas le pattern peut ne pas être adapté donc. On préfèrera alors une approche incrémentale.
  • Le Caretaker n’a pas la main sur les Memento et ne sait pas même combien il devra en gérer dans le temps ce qui peut finir par faire une quantité importe nécessitant des implémentations particulières.

L’implémentation

Il existe de nombreuses façons pratiques d’implémenter le design pattern Memento mais vu la clarté de sa définition il est difficile de s’écarter du droit chemin !

Sachant que le Memento dépend directement de l’objet à sauvegarder il pose des problèmes spécifiques à chaque situation. J’ai soulevé plus haut dans cet article le problème de sauvegarde d’un gros bitmap par exemple. Le Memento peut devenir une classe plus complexe qu’elle n’en a l’air si elle doit compresser les données, utiliser un cache disque, etc.

Je vous laisse réfléchir à la meilleure façon d’implémenter ce pattern assez simple dans vos applications cela peut d’ailleurs faire l’occasion d’un débat dans les commentaires.

Une meilleure solution ?

Comme nous venons de le voir le Memento est une solution propre et simple un poil plus sophistiqué que la solution basique malgré tout. il n’en reste pas moins vrai que ces deux solutions consomment des ressources. Le stockage de l’état visuel d’un bitmap à chaque opération peut devenir très lourd par exemple. Et des exemples de ce type peuvent être trouvés dans de nombreux autres domaines où l’état de l’objet à sauvegardé dépend de données nombreuses ou volumineuses.

N’y aurait-il pas une autre approche possible qui atténuerait ce problème majeur ?

Ce n’est pas si évident que cela… Dans certains cas seul le Memento permettra de gérer un Undo / Redo.

Prenons le cas d’un bitmap qui peut être modifié par des filtres complexes, par exemple un floutage radial, cette opération n’est pas réversible. On ne recréée pas une image nette d’une image totalement floue cela serait de la magie. Dans un tel cas il n’y a pas d’autre solution que de stocker le bitmap à chaque opération ce qui peut imposer de manipuler une quantité gigantesque de RAM. Les applications de ce type limitent d’ailleurs le plus souvent le niveau de Undo, et pour cause ! Comme il n’y a pas de choix, le Memento va devenir complexe à implémenter, il faudra qu’il gère de la compression de données, qu’il sache aussi décompresser les données, voire qu’il soit capable de gérer une sauvegarde incrémentale.

Mais dans de nombreux autres cas il y a beaucoup plus efficace que Memento. C’est le design pattern Command.

Ici il s’agit de se souvenir des actions de l’utilisateur et non de leur effet. Il suffit pour faire un Undo de refaire les actions “à l’envers”. On le comprend donc aisément cela est impossible pour un filtre sur un bitmap mais est rudimentaire pour une calculatrice : inverser la commande “+10” est évident comme inverser “* 50”. Le niveau de Undo peut devenir gigantesque sans consommer trop de ressources. Mais cela suppose que chaque action soit réversible.

De plus si Memento se bornait à répondre exclusivement à la question du Undo / Redo, le pattern Command touche à l’organisation générale du logiciel en imposant un “circuit” bien précis pour toutes les commandes possibles. Il s’agit d’un niveau de contrainte fort qui est parfaitement gérable si le logiciel a été conçu dès le départ dans l’esprit de supporter Undo / Redo !

On en revient à ce que je disait en introduction, la qualité “pro” est la résultante non pas de recettes de cuisines mais d’une planification soignée ce qui réclame talent et expertise.

Cet article étant déjà bien long je laisse le traitement du pattern Command pour une prochaine fois car son importance fait qu’il le mérite. Je vous laisse réfléchir par exemple à MVVM qui utilise l’interface ICommand et comment on peut y insérer une logique de Undo / Redo !

Conclusion

Un logiciel professionnel ne se conçoit pas sans expérience ni sans connaissance des règles et de l’état de l’Art. Ajouté un Undo peut sembler trivial et on voit que ce ne l’est pas. Il faut du recul, la connaissance de patterns éprouvés et un bon niveau de codeur pour savoir prévoir, planifier et implémenter une telle fonctionnalité. Pour cela il faut sans cesse apprendre et remettre cent fois sur le métier notre code, nos idées.

Ce billet, au delà de son but pratique, l’implémentation de Undo / Redo, est surtout l’occasion de rappeler cette nécessité de viser la perfection par le travail, la remise en cause des acquis et la réflexion même sur des choses simples. Car rien n’est simple quand il s’agit de le bien faire.

Je vois encore trop souvent du code qui me déprime, franchement, ça me fait mal. Physiquement presque.

Un code bien écrit est un plaisir intellectuel. On est développeur aussi parce qu’on aime cette gymnastique intellectuelle, cette recherche perpétuelle de l’amélioration. Aucun de nous n’est parfait ni n’écrit de code qui le soit. Mais ceux qui ne s’interrogent pas sur leur niveau et qui ne remettent jamais en question leurs connaissances sont des dangers et quelque part la honte de notre métier. Je sais que ce n’est pas dans les lecteurs de Dot.Blog qu’il faut les chercher mais nous en connaissons tous…

Nous ne sommes pas parfait mais nous n’aspirons pas au repos et nous allons continuer à dégrossir la pierre brute en visant une perfection certes idyllique et à jamais inaccessible, mais en chemin nous serons devenus meilleurs… et c’est finalement ça le but. Parfois le voyage est plus beau que la destination…

Stay Tuned !

Faites des heureux, partagez l'article !
blog comments powered by Disqus