Pattern Memento C# | Паттерн Хранитель C#

В этой статье будет рассмотрен паттерн проектирования хранитель C# — Memento C#, для чего он нужен и какие проблемы он решает, где можно применять данный шаблон и когда это будет излишним.

Паттерн проектирования — это продуманный способ построения исходного кода программы для решения часто возникающих в повседневном программировании проблем проектирования. Иными словами, это уже придуманное решения, для типичной задачи. При этом паттерн не готовое решение, а просто алгоритм действий, который должен привести к желаемому результату. Давайте рассмотрим один из наиболее часто используемых поведенческих паттернов — Хранитель (Memento).

Подпишись на группу Вконтакте и Телеграм-канал. Там еще больше полезного контента для программистов.
А на YouTube-канале ты найдешь обучающие видео по программированию. Подписывайся!

Как я уже писал ранее, существует три вида паттернов проектирования:

  • Порождающие паттерны позволяют возможность выполнять инициализацию объектов наиболее удобным и оптимальным способом.
  • Структурные паттерны описывают взаимоотношения между различными классами или объектами, позволяя им совместно реализовывать поставленную задачу.
  • Поведенческие паттерны позволяют грамотно организовать связь между сущностями для оптимизации и упрощения их взаимодействия.

Хранитель (Memento) — это поведенческий паттерн, который позволяет сохранить состояние состояние экземпляра объекта не раскрывая его полную внутреннюю структуру. То есть, данный паттерн позволяет сделать снимок объекта с возможностью восстановления состояния объекта из этого снимка, при этом не нарушая принцип инкапсуляции.

Давайте рассмотрим диаграмму паттерна Хранитель

Хранитель (Memento)
Хранитель (Memento)
  • Originator — основной объект. Именно его состояние будет сохраняться и восстанавливаться
  • Memento — снимок состояния основного объекта. Хранит в тебе все важные данные
  • Carataker — хранилище снимков, позволяет сохранять и восстанавливать любое из хранимых состояний.

Давайте рассмотрим основную логику работы паттерна Хранитель. Если у нас есть объект, который с которым ведется основное взаимодействие и нам нужно сохранить его состояние, но при этом мы не хотим делать его полную копию, так как в некоторых случаях это может раскрыть его внутреннюю структуру или предоставить доступ к свойствам и методам нежелательным пользователям, то мы можем создать уменьшенную копию объекта только с теми данными, которые хотим сохранить. Это позволит не только защитится от нежелательного доступа и соблюсти инкапсуляцию, но и также экономит ресурсы при хранении больших объемов данных. Первое, что приходит в голову при рассмотрении данного паттерна, это конечно же сохранение в компьютерных играх. Вспомним другую статью, где я уже использовал близкую предметную область. Это была статья Паттерн проектирования Абстрактная фабрика (Abstract Factory) на C#. В ней рассматривались космические корабли, позволяющие соревноваться в скорости и в битве. Переработаем данную предметную область и реализуем сохранение состояние космического корабля перед боем с возможностью загрузиться.

Реализация

Для начала реализуем класс космического корабля. В нем мы реализуем необходимые свойства и методы для работы корабля, а так же методы сохранения состояния и восстановления состояния из снимка. Обратите внимание, что мы не раскрываем внутреннюю структуру класса, так как именно внутри класса определяем логику сохранения и загрузки. Поэтому я специально добавил свойство RandomValue, которое не попадает в снимок, чтобы показать что класс сам контролирует какие свойства являются для него важными. Так же взаимодействие с классом MementoSpaceship осуществляется с через набор параметров-свойств, а не целым объектом класса Spaceship. Если бы в MementoSpaceship передавался экземпляр класса Spaceship, то было бы чрезмерное раскрытие внутренней структуры класса, снимок получил бы доступ ко всем свойствам и даже методам класса, и мог бы внести какие-либо изменения. В данном случае же мы от этого застрахованы. Это позволяет лучше придерживаться принципа инкапсуляции.

using System;

namespace Memento
{
    /// <summary>
	/// Космический корабль.
	/// </summary>
	public class Spaceship
    {
        /// <summary>
        /// Название космического корабля.
        /// </summary>
        public string Name { get; private set; }

        /// <summary>
        /// Запас здоровья космического корабля.
        /// </summary>
        public int Health { get; private set; }

        /// <summary>
        /// Случайное значение.
        /// </summary>
        public int RandomValue { get; private set; }


        /// <summary>
        /// Создать экземпляр космического корабля.
        /// </summary>
        /// <param name="name"> Название корабля. </param>
        /// <param name="health"> Уровень здоровья корабля. </param>
        public Spaceship(string name, int health = 100)
        {
            // Проверяем входные данные на корректность.
            if(string.IsNullOrEmpty(name))
            {
                throw new ArgumentNullException(nameof(name));
            }

            if (health <= 0)
            {
                throw new ArgumentException("Здоровье космического корабля не может быть меньше либо равно нулю.", nameof(health));
            }

            // Устанавливаем свойства.
            Name = name;
            Health = health;
            var rnd = new Random();
            RandomValue = rnd.Next();
        }

        /// <summary>
        /// Выстрелить.
        /// </summary>
        /// <returns> Нанесенный урон. </returns>
        public int Shoot()
        {
            // Задаем минимальный и максимальный возможный урон.
            const int MinDamage = 0;
            const int MaxDamage = 10;

            // Вычисляем значение нанесенного урона.
            var rnd = new Random();
            var damage = rnd.Next(MinDamage, MaxDamage);
            return damage;
        }

        /// <summary>
        /// Получить урон.
        /// </summary>
        /// <param name="damage"> Величина полученного урона. </param>
        public void TakeDamage(int damage)
        {
            // Проверяем входные данные на корректность.
            if(damage < 0)
            {
                throw new ArgumentException("Нанесенный урон не может быть меньше нуля", nameof(damage));
            }

            // Изменяем значение здоровья.
            Health -= damage;
        }

        /// <summary>
        /// Сохранить состояние космического корабля.
        /// </summary>
        /// <returns> Состояние корабля. </returns>
        public MementoSpaceship Save()
        {
            // Формируем снимок для сохранения состояние. 
            var memento = new MementoSpaceship(Name, Health);
            return memento;
        }

        /// <summary>
        /// Восстановить состояние космического корабля из снимка.
        /// </summary>
        /// <param name="memento"> Снимок состояния космического корабля. </param>
        public void Restore(MementoSpaceship memento)
        {
            // Проверяем входные данные на корректность.
            if(memento == null)
            {
                throw new ArgumentNullException(nameof(memento));
            }

            if(string.IsNullOrEmpty(memento.SpaceshipName))
            {
                throw new ArgumentNullException(nameof(memento.SpaceshipName));
            }

            if(memento.Health <= 0)
            {
                throw new ArgumentException("Здоровье космического корабля не может быть меньше либо равно нулю.", nameof(memento.Health));
            }

            // Восстанавливаем состояние из снимка.
            Name = memento.SpaceshipName;
            Health = memento.Health;
        }

        /// <summary>
        /// Приведение объекта к строке.
        /// </summary>
        /// <returns> Название космического корабля. </returns>
        public override string ToString()
        {
            return Name;
        }
    }
}

Так же реализуем класс контроллер для выполнения сражений между кораблями.

using System;

namespace Memento
{
    /// <summary>
	/// Класс-контроллер отвечающий за проведение боев между космическими кораблями.
	/// </summary>
	public class Battle
    {
        /// <summary>
        /// Первый космический корабль.
        /// </summary>
        private Spaceship _ship1;

        /// <summary>
        /// Второй космический корабль.
        /// </summary>
        private Spaceship _ship2;

        /// <summary>
        /// Создать экземпляр битвы.
        /// </summary>
        /// <param name="ship1"> Первый космический корабль. </param>
        /// <param name="ship2"> Второй космический корабль. </param>
        public Battle(Spaceship ship1, Spaceship ship2)
        {
            _ship1 = ship1 ??
                throw new ArgumentNullException("Первый космический корабль не может быть пустым.", nameof(ship1));

            _ship2 = ship2 ??
                throw new ArgumentNullException("Второй космический корабль не может быть пустым.", nameof(ship2));
        }

        /// <summary>
        /// Бой между космическими кораблями.
        /// </summary>
        /// <returns> Победивший в бою корабль. null - если оба корабля погибли. </returns>
        public Spaceship Fight()
        {
            // Сражаемся насмерть, пока у одного из космических кораблей не закончится здоровье.
            while (_ship1.Health > 0 && _ship2.Health > 0)
            {
                _ship2.TakeDamage(_ship1.Shoot());
                _ship1.TakeDamage(_ship2.Shoot());
            }

            // Выявляем победителя 
            if (_ship1.Health > 0)
            {
                return _ship1;
            }

            if (_ship2.Health > 0)
            {
                return _ship2;
            }

            return null;
        }
    }
}

Теперь создадим класс хранитель. В нем нужно указать все поля, которые должны быть сохранены, и при необходимости добавить вспомогательные, как например дата создания снимка.

using System;

namespace Memento
{
    /// <summary>
    /// Снимок состояния космического корабля.
    /// </summary>
    public class MementoSpaceship : ICloneable
    {
        /// <summary>
        /// Дата и время создания снимка.
        /// </summary>
        public DateTime CreatedAt { get; private set; }

        /// <summary>
        /// Название космического корабля.
        /// </summary>
        public string SpaceshipName { get; private set; }

        /// <summary>
        /// Здоровье космического корабля.
        /// </summary>
        public int Health { get; private set; }

        /// <summary>
        /// Создать новый экземпляр снимка космического корабля.
        /// </summary>
        /// <param name="spaceshipName"> Название. </param>
        /// <param name="health"> Состояние здоровья. </param>
        public MementoSpaceship(string spaceshipName, int health)
        {
            // Проверяем входные параметры на корректность.
            if(string.IsNullOrEmpty(spaceshipName))
            {
                throw new ArgumentNullException(nameof(spaceshipName));
            }

            if(health <= 0)
            {
                throw new ArgumentException("Здоровье космического корабля не может быть меньше или равным нулю.", nameof(health));
            }

            // Устанавливаем значения.
            SpaceshipName = spaceshipName;
            Health = health;
            CreatedAt = DateTime.UtcNow;
        }

        /// <summary>
        /// Создать копию объекта.
        /// </summary>
        /// <returns> Копия объекта. </returns>
        public object Clone()
        {
            return MemberwiseClone();
        }

        /// <summary>
        /// Приведение объекта к строке.
        /// </summary>
        /// <returns> Дата и время создания снимка. </returns>
        public override string ToString()
        {
            return CreatedAt.ToString();
        }

        
    }
}

Ну и наконец добавим класс для хранения снимков. Он позволяет добавлять снимок в коллекцию всех состояний, а так же получать последний снимок. При необходимости можно реализовывать дополнительную логику работы, например получение конкретного снимка.

using System;
using System.Collections.Generic;
using System.Linq;

namespace Memento
{
    /// <summary>
    /// Хранилище состояний космического корабля.
    /// </summary>
    public class CaratakerSpaceship
    {
        /// <summary>
        /// Коллекция всех снимков состояния космического корабля.
        /// </summary>
        private List<MementoSpaceship> _mementos = new List<MementoSpaceship>();

        /// <summary>
        /// Сохранения состояний космического корабля.
        /// </summary>
        public IReadOnlyList<MementoSpaceship> Mementos => _mementos;

        /// <summary>
        /// Создать новый экземпляр хранилища состояний космического корабля.
        /// </summary>
        public CaratakerSpaceship() { }

        /// <summary>
        /// Сохранить снимок в хранилище.
        /// </summary>
        /// <param name="memento"> Снимок состояние космического корабля. </param>
        public void Save(MementoSpaceship memento)
        {
            // Проверяем входные данные на корректность.
            if(memento == null)
            {
                throw new ArgumentNullException(nameof(memento));
            }

            // Добавляем состояние в коллекцию.
            _mementos.Add(memento);
        }

        /// <summary>
        /// Получить последний снимок состояния космического корабля.
        /// </summary>
        /// <returns> Снимок состояния космического корабля. </returns>
        public MementoSpaceship Restore()
        {
            if(_mementos.Count == 0)
            {
                throw new IndexOutOfRangeException("Не возможно восстановить состояние из коллекции. Коллекция пуста.");
            }

            var result = _mementos.LastOrDefault() ??
                throw new IndexOutOfRangeException("Не возможно восстановить состояние из коллекции. Не удалось получить состояние.");

            return result.Clone() as MementoSpaceship;
        }
    }
}

Теперь нам остается только реализовать взаимодействие с пользователем и поработать со снимками.

using System;

namespace Memento
{
	class Program
	{
		static void Main(string[] args)
		{
            Console.WriteLine("Добро пожаловать на 77-ые Космические игры! Представьтесь...");

            // Запрашиваем имя пользователя, пока он ни введет не пустую строку.
            var name = "";
            while (true)
            {
                name = Console.ReadLine();

                if (string.IsNullOrEmpty(name))
                {
                    Console.WriteLine("Введите Ваше имя!");
                }
                else
                {
                    break;
                }
            }

            // Создаем хранилище состояний.
            var carataker = new CaratakerSpaceship();

            // Счетчик побед. 
            var winCounter = 0;

            // Создаем космический корабль игрока.
            var playerShip = new Spaceship(name, 300);

            // Повторяем пока пользователь не выйдет.
            while (true)
            {
                // Создаем космический корабль противника.
                var computerShip = new Spaceship($"Гладиатор №{winCounter + 1}");

                // Представляем участников и создаем объект сражения с этими участниками.
                Console.WriteLine($"Первый претендент: {playerShip}. Уровень здоровья {playerShip.Health}.");
                Console.WriteLine($"Второй претендент: {computerShip}. Уровень здоровья {computerShip.Health}");

                var battle = new Battle(playerShip, computerShip);

                // Запрашиваем у пользователя продолжение.
                Console.WriteLine("Да начнется смертельная битва! Продолжить?(y/n)");
                var ansver = Console.ReadLine();

                if (ansver.ToLower() == "y")
                {
                    // Если пользователь согласился, то начинаем битву.
                    var battleWinner = battle.Fight();

                    if (battleWinner == playerShip)
                    {
                        // Если выиграл пользователь, то поздравляем его, сохраняем новый снимок и переходим к следующему туру.
                        Console.WriteLine($"Поздравляем! Вы выиграли в битве, {battleWinner}");

                        // Формируем снимок корабля и сохраняем снимок в хранилище снимков.
                        var saveMemento = playerShip.Save();
                        carataker.Save(saveMemento);

                        // Увеличиваем количество побед.
                        winCounter++;
                        continue;
                    }
                    else
                    {
                        // Если выиграл компьютер, то предлагаем загрузиться из последнего сохранения.
                        Console.WriteLine("Сожалеем, но вы погибли в сражении. Загрузить последнее сохранение?(y/n)");
                        var answer2 = Console.ReadLine();
                        if (ansver.ToLower() == "y")
                        {
                            // Если пользователь согласился, то загружаем игру и начинаем раунд заново.

                            // Загружаем состояние.
                            var loadMemento = carataker.Restore();
                            playerShip.Restore(loadMemento);

                            // Сообщаем пользователю.
                            Console.WriteLine("Игра загружена. Нажмите ввод, чтобы продолжить.");
                            Console.ReadLine();
                            continue;
                        }
                        else if(ansver.ToLower() == "n")
                        {
                            // Иначе если пользователь отказался загружаться, то сообщаем количество побед.
                            Console.WriteLine($"Вы доблестно победили в {winCounter} битвах и погибли с честью.");
                            Console.ReadLine();
                            break;
                        }
                    }
                }
                else if (ansver.ToLower() == "n")
                {
                    // Иначе если пользователь отказался сражаться, то сообщаем ему результат.
                    if (winCounter == 0)
                    {
                        Console.WriteLine("Вы с позором отказались от битвы, но остались целы и невредимы.");
                    }
                    else
                    {
                        Console.WriteLine($"Поздравляем! Вы с доблестью выиграли {winCounter} сражений и уходите победителем.");
                    }
                    break;
                }

                // Был введен некорректный ответ.
                Console.WriteLine("Некорректный ввод ответа. " +
                        "Если хотите продолжить, введите английский символ y, " +
                        "если хотите отказаться, введите английский символ n.");
            }

            // Игра завершена.
            Console.WriteLine("Игра завершена.");
            Console.ReadLine();
		}
	}
}

В итоге получаем следующий результат.

Исходный код доступен в репозитории github.

Кроме того, рекомендую прочитать статью C# Array | Работа с массивами в C#. А также подписывайтесь на группу ВКонтакте, Telegram и YouTube-канал. Там еще больше полезного и интересного для программистов.