Aller au contenu

Design pattern Memento

Sauvegarder puis restaurer l’état d’un objet

Introduction

Le design pattern Memento est un pattern de conception comportemental dont l’objectif est de sauvegarder l’état d’un objet afin de pouvoir le restaurer ultérieurement, sans exposer les détails de son implémentation interne. Il est particulièrement utile lorsqu’une application doit permettre de revenir à un état précédent. C’est notamment le cas des fonctionnalités d’annulation, présentes dans les éditeurs de texte, les outils graphiques ou certains assistants de configuration.

Dans cet article, après vous avoir présenté le fonctionnement du design pattern Memento, nous verrons comment l’implémenter en C#, en prenant comme exemple un système de sauvegarde et de restauration des différentes versions d’un document.

Problématique

Dans notre application, nous développons un éditeur permettant de modifier un document. Un document possède un titre et un contenu :

public class Document
{
    public string Title { get; set; }

    public string Content { get; set; }

    public Document(string title, string content)
    {
        Title = title;
        Content = content;
    }
}

L'utilisateur peut modifier plusieurs fois le contenu du document :

var document = new Document("Design Pattern Memento", "Première version du document.");

document.Content = "Deuxième version du document.";

document.Content = "Troisième version du document.";

Nous souhaitons ajouter une fonctionnalité permettant à l'utilisateur d'annuler ses modifications et de revenir à un état précédent du document.

Conception

Voici le diagramme de classes que nous implémenterons pour ce design pattern : Design Pattern Memento - Conception

Le design pattern Memento est composé de trois éléments :

  • L'originator, l'objet dont nous souhaitons sauvegarder et restaurer l'état. Il s'agit de la classe Document.
  • Le memento, l'objet contenant une représentation d'un état sauvegardé de l'Originator. Il s'agit de la classe DocumentMemento.
  • Le caretaker, l'objet chargé de conserver les différents Mementos sans avoir à connaître leur contenu. Il s'agit de la classe DocumentHistory.

Une responsabilité importante est attribuée à la classe Document : elle seule sait quelles informations doivent être sauvegardées et la manière dont elles doivent être restaurées. Quant à la classe DocumentHistory, elle conserve les états qui lui sont fournis.

Implémentation en C#

Implémentation du Memento

Implémentons la classe DocumentMemento permettant de représenter un état du document :

public class DocumentMemento
{
    public string Title { get; }
    public string Content { get; }

    public DocumentMemento(string title, string content)
    {
        Title = title;
        Content = content;
    }
}

Les propriétés sont uniquement accessibles en lecture, afin qu'un état sauvegardé ne puisse être modifié après sa création.

Sauvegarder l'état du document

La classe Document doit maintenant être capable de créer un Memento représentant son état courant. Ajoutons une méthode nommée CreateMemento :

public class Document
{
    public string Title { get; set; }
    public string Content { get; set; }

    public Document(string title, string content)
    {
        Title = title;
        Content = content;
    }

    public DocumentMemento CreateMemento()
    {
        return new DocumentMemento(Title, Content);
    }
}

Le document décide lui-même les informations à sauvegarder. Le code client n'a plus besoin de construire manuellement une représentation de son état. Nous pouvons donc écrire :

DocumentMemento memento = document.CreateMemento();

Restaurer l'état du document

La classe Document doit également être capable de restaurer un état précédemment sauvegardé. Ajoutons une méthode nommée Restore :

public class Document
{
    public string Title { get; set; }

    public string Content { get; set; }

    public Document(string title, string content)
    {
        Title = title;
        Content = content;
    }

    public DocumentMemento CreateMemento()
    {
        return new DocumentMemento(Title, Content);
    }

    public void Restore(DocumentMemento memento)
    {
        Title = memento.Title;
        Content = memento.Content;
    }
}

La responsabilité de la restauration appartient donc également au document. Ainsi, si les caractéristiques de la classe Document évoluent, des modifications seront nécessaires dans les classes Document et DocumentMemento.

Conserver l'historique

Nous devons maintenant conserver plusieurs états successifs de notre document. Pour ce faire, implémentons la classe DocumentHistory :

public class DocumentHistory
{
    private readonly Stack<DocumentMemento> _history;

    public DocumentHistory()
    {
        _history = new();
    }

    public void Save(DocumentMemento memento)
    {
        _history.Push(memento);
    }

    public DocumentMemento Restore()
    {
        return _history.Pop();
    }
}

Cette classe joue le rôle de Caretaker. Elle utilise la classe DocumentMemento, mais elle n'a pas besoin de connaître les informations qu'elle contient, ni la manière dont elles sont utilisées pour restaurer le document ; elle est uniquement responsable de leur conservation.

L'utilisation d'une pile avec la classe Stack\<T> est particulièrement adaptée à une fonctionnalité d'annulation puisque le dernier état sauvegardé est le premier état qui doit être restauré.

Utilisation du pattern Memento

Nous pouvons maintenant utiliser les différentes classes composant le design pattern. Avant cela, créons un document ainsi que son historique :

var document = new Document("Design Pattern Memento", "Première version du document.");

var history = new DocumentHistory();

Avant de modifier le document, nous sauvegardons son état :

history.Save(document.CreateMemento());

document.Content = "Deuxième version du document.";

Nous pouvons effectuer une nouvelle modification en sauvegardant à nouveau l'état courant :

history.Save(document.CreateMemento());

document.Content = "Troisième version du document.";

Le document contient le contenu suivant :

Troisième version du document.

Pour revenir à l'état précédent :

document.Restore(history.Restore());

Le contenu est alors :

Deuxième version du document.

En restaurant de nouveau l'état précédent :

document.Restore(history.Restore());

Le contenu du document est :

Première version du document.

Nous avons ainsi implémenté un mécanisme simple d'annulation sans que la classe DocumentHistory ait besoin de connaître la structure de l’état sauvegardé.

Revenir à l'état précédent d'un document

Nous pouvons améliorer légèrement notre implémentation en proposant directement une méthode Undo dans la classe chargée de gérer l'historique :

public class DocumentHistory
{
    private readonly Stack<DocumentMemento> _history;

    public DocumentHistory()
    {
        _history = new();
    }

    public void Save(DocumentMemento memento)
    {
        _history.Push(memento);
    }

    public void Undo(Document document)
    {
        if (_history.Count == 0)
            return;

        DocumentMemento memento = _history.Pop();
        document.Restore(memento);
    }
}

Le code client devient alors plus simple :

var document = new Document("Design Pattern Memento", "Première version du document.");

var history = new DocumentHistory();

history.Save(document.CreateMemento());

document.Content = "Deuxième version du document.";

history.Save(document.CreateMemento());

document.Content = "Troisième version du document.";

history.Undo(document);

Console.WriteLine(document.Content);

Le document contient le contenu suivant :

Deuxième version du document.

Un nouvel appel à la méthode Undo :

history.Undo(document);

permet de revenir au premier état du document.

Encapsulation de l'état

L'un des objectifs du pattern Memento est de sauvegarder l'état d'un objet sans exposer inutilement sa représentation interne. Dans notre exemple volontairement simplifié, la classe DocumentMemento expose ses propriétés publiquement en lecture :

public string Title { get; }

public string Content { get; }

N'importe quel composant possédant une référence vers une instance de la classe DocumentMemento peut donc consulter ces informations. Dans une implémentation plus stricte, il peut être intéressant de limiter l'accès au contenu du Memento. Le Caretaker doit idéalement considérer le Memento comme un objet opaque en le conservant et le restituant sans avoir besoin d'interpréter son contenu. L'idée essentielle reste que la logique permettant de capturer et restaurer l'état appartient à l'Originator, et non au composant chargé de conserver l'historique.

Sauvegarde superficielle ou profonde

Nous devons apporter une attention particulière lorsque l'état sauvegardé contient des objets mutables. Par exemple, déclarons que notre document possède une collection :

public class Document
{
    public string Title { get; set; }

    public string Content { get; set; }

    public List<string> Tags { get; set; }

    public Document(string title, string content)
    {
        Title = title;
        Content = content;
        Tags = [];
    }

    public DocumentMemento CreateMemento()
    {
        return new DocumentMemento(Title, Content);
    }

    public void Restore(DocumentMemento memento)
    {
        Title = memento.Title;
        Content = memento.Content;
    }
}

Si dans notre Memento, nous conservons une référence vers cette liste :

return new DocumentMemento(Title, Content, Tags);

le document et le Memento peuvent partager les mêmes tags. Une modification ultérieure de cette liste de tags modifierait alors indirectement l'état que nous pensions avoir sauvegardé. Il peut donc être nécessaire d'effectuer une copie :

return new DocumentMemento(Title, Content, [.. Tags]);

Le choix entre copie superficielle et copie profonde dépend de la structure de l'objet et des données constituant son état. Un Memento doit représenter l'état de l'objet au moment où la sauvegarde a été réalisée, et non une vue susceptible d'évoluer avec l'objet d'origine.

Avantages

Le design pattern Memento permet de sauvegarder puis de restaurer l'état d'un objet sans confier cette responsabilité au code qui gère l'historique des modifications. Il permet notamment de préserver l'encapsulation de l'objet : l'Originator reste responsable de la création et de la restauration de ses états.

Le design pattern Memento facilite également l'implémentation de mécanismes tels que l'annulation de modifications, la restauration d'une configuration ou la création de points de sauvegarde.

Enfin, le Caretaker reste relativement indépendant de la structure de l'objet sauvegardé puisqu'il manipule principalement des Mementos.

Quand utiliser le design pattern Memento ?

Le pattern Memento est particulièrement adapté lorsqu'une application doit conserver différents états successifs d'un objet et permettre de restaurer l'un de ces états. En revanche, si l'état à sauvegarder est très volumineux ou si les modifications sont extrêmement fréquentes, conserver une copie complète à chaque changement peut être trop coûteux. D'autres stratégies, comme la conservation uniquement des différences entre les états, peuvent alors être plus appropriées.

Les design patterns Memento et Command

Les design patterns Memento et Command peuvent tous les deux intervenir dans l'implémentation d'une fonctionnalité d'annulation, mais leur approche est différente.

Avec le design pattern Memento, nous sauvegardons l'état d'un objet avant une modification afin de pouvoir le restaurer.

Avec le design pattern Command, une action est représentée sous la forme d'un objet. Une commande peut éventuellement proposer une opération inverse permettant d'annuler les effets de son exécution.

Ainsi, le design pattern Memento répond principalement à la question « Dans quel état était mon objet avant cette modification ? », alors que le design pattern Command répond davantage à la question « Quelle opération a été exécutée et comment pouvons-nous l'annuler ? ». Les deux design patterns peuvent également être utilisés ensemble : une commande peut créer un Memento avant son exécution et l'utiliser pour restaurer l'état de l'objet lors d'une annulation.