はじめに
こんにちは!今回はCommandパターンについて学習したことをまとめたいと思います。
Commandパターンは名前の通り、命令に関係します。
短くにいうと命令それぞれをオブジェクトで表現するパターンです。
役割としては
- Command 命令のインターフェースを定義
- ConcreteCommand 具体的な命令を実装
- Receiver 命令を受信するもの
- Client 命令を作成し、Reciverに命令を送るもの
- Invoker 命令の実行を起動するもの
が挙げられます。
今回はCommandパターンと関係するものでよく一緒に使われるMementoパターンと組み合わせます。
分かりやすいように単純な、テキストボックスにタイトルと文章を入力し、Undo/Redoすることのできるアプリを設計、実装してみます。
クラス図はこちらになります。
プロパティの値を変更するCommand(ユーザが行った操作、Undoで行った操作)を入れるStackを二つ用意します。
ユーザがテキストボックスの値を変更する操作を行ったとき、その操作(命令)がStackに積まれていきます。その後Undoを行うとStackに積まれた一番上のCommandを取り出し、実行、そうすることで一つ操作を戻すことが可能になります。ここで取り出した命令はそのまま破棄するわけではなく、もう一方のRedo用のStackに積まれます。Redoを行うと先ほど積まれた命令が取り出され実行されます。そうすることでRedoを実現できるのです。
上で挙げた役割だと
Command → ICommand
ConcreteCommand → PropertyChangeCommand
Receiver → ChangeHistory
Client → ChangeHistory
Invoker → MainWindowに配置されたUndo/Redoボタン
が割当たります。
以下実装です。
<Window x:Class="Command.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Command"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid>
<StackPanel>
<TextBlock Text="タイトル" Margin="0,0,0,5"/>
<TextBox x:Name="m_TitleTextBox" Text="{Binding Path = Title}" Height="20" Margin="0,0,0,10"/>
<TextBlock Text="内容" Margin="0,0,0,5"/>
<TextBox x:Name="m_TextTextBox" Text="{Binding Path = Text}" Height="20" Margin="0,0,0,10"/>
<Button Content="Redo" Click="RedoButton_Click" Width="80" HorizontalAlignment="Left"/>
<Button Content="Undo" Click="UndoButton_Click" Width="80" HorizontalAlignment="Left"/>
</StackPanel>
</Grid>
</Window>
using System.Windows;
namespace Command
{
/// <summary>
/// MainWindow.xamlの相互作用ロジック
/// </summary>
public partial class MainWindow : Window
{
#region 内部フィールド
/// <summary>
/// 文章
/// </summary>
private Description m_Description =new Description();
/// <summary>
/// 変更履歴
/// </summary>
private ChangeHistory m_ChangeHistory = new ChangeHistory();
#endregion
#region コンストラクタ
/// <summary>
/// コンストラクタ
/// </summary>
public MainWindow()
{
InitializeComponent();
DataContext = m_Description;
// Descriptionの変更をChangeHistoryに通知する
m_Description.PropertyChanged += m_ChangeHistory.AddChangeHistory;
}
#endregion
#region 内部メソッド
/// <summary>
/// Redoボタンが押された時の処理
/// </summary>
private void RedoButton_Click(object sender, RoutedEventArgs e)
{
m_ChangeHistory.Redo();
}
/// <summary>
/// Undoボタンが押された時の処理
/// </summary>
private void UndoButton_Click(object sender, RoutedEventArgs e)
{
m_ChangeHistory.Undo();
}
#endregion
}
}
using System.ComponentModel;
namespace Command
{
/// <summary>
/// 変更履歴
/// </summary>
/// <remarks>
/// PropertyChangeCommand(Memento)のCareTakerの役割を果たす
/// </remarks>
public class ChangeHistory
{
#region 公開フィールド
/// <summary>
/// 変更履歴
/// </summary>
public Stack<ICommand> changeHistory = new Stack<ICommand>();
/// <summary>
/// Undo履歴
/// </summary>
public Stack<ICommand> undoHistory = new Stack<ICommand>();
#endregion
#region 内部フィールド
/// <summary>
/// 今Undo/Redo中かどうか
/// </summary>
/// <remarks>
/// Undo/Redo中は操作履歴を追加した変更を加えない
/// </remarks>
private bool m_IsUndoRedoing = false;
#endregion
#region 公開メソッド
/// <summary>
/// Undoを行う
/// </summary>
public void Undo()
{
// フラグの上げ下げはtry-finallyで行う
m_IsUndoRedoing = true;
try
{
// changehistoryが空なら何もしない
if (changeHistory.Count == 0)
{
return;
}
var command = changeHistory.Pop();
command.Undo();
undoHistory.Push(command);
}
// 例外が発生した場合もちゃんとUndo/Redo中フラグを解除できるようにfinallyで解除する
finally
{
m_IsUndoRedoing = false;
}
}
/// <summary>
/// Redoを行う
/// </summary>
public void Redo()
{
m_IsUndoRedoing = true;
try
{
//undohistoryが空なら何もしない
if (undoHistory.Count == 0)
{
return;
}
var command = undoHistory.Pop();
command.Redo();
changeHistory.Push(command);
}
finally
{
m_IsUndoRedoing = false;
}
}
/// <summary>
/// ユーザが行った操作履歴を追加する
/// </summary>
public void AddChangeHistory(object sender, PropertyChangedEventArgs args)
{
if (m_IsUndoRedoing)
{
return;
}
undoHistory.Clear();
if (args is PropertEventArgWithValue argsWithValue)
{
changeHistory.Push(new PropertyChangeCommand((Description)sender, argsWithValue.PropertyName,
(string)argsWithValue.OldValue, (string)argsWithValue.NewValue));
}
}
#endregion
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Command
{
/// <summary>
/// コマンドのインターフェース
/// </summary>
public interface ICommand
{
#region 公開メソッド
/// <summary>
/// Undoを行う
/// </summary>
public void Undo();
/// <summary>
/// Redoを行う
/// </summary>
public void Redo();
#endregion
}
}
namespace Command
{
/// <summary>
/// PropertyChangedイベントが発生した時につくられるコマンド
/// 変更前の値と変更後の値を保持する
/// </summary>
/// <remarks>
/// ConcreteCommandとConcreteMementoの役割を兼ねる
/// </remarks>
public class PropertyChangeCommand : ICommand
{
/// <summary>
/// 変更した対象のプロパティの名前
/// </summary>
private readonly string m_PropertyName;
/// <summary>
/// 値を変更したモデル
/// </summary>
private readonly Description m_TargetDescription;
/// <summary>
/// 変更前の値
/// </summary>
/// <remarks>
/// 初期化以外で値を変更しないためreadonly
/// </remarks>
private readonly string m_OldValue;
/// <summary>
/// 変更後の値
/// </summary>
private readonly string m_NewValue;
/// <summary>
/// コンストラクタ
/// </summary>
public PropertyChangeCommand(Description targetDescription, string propertyName, string oldValue, string newValue)
{
m_TargetDescription = targetDescription;
m_PropertyName = propertyName;
m_OldValue = oldValue;
m_NewValue = newValue;
}
/// <summary>
/// 元に戻す 変更前の値を設定する
/// </summary>
public void Undo()
{
typeof(Description).GetProperty(m_PropertyName).SetValue(m_TargetDescription,m_OldValue);
}
/// <summary>
/// やり直し 変更後の値を設定する
/// </summary>
public void Redo()
{
typeof(Description).GetProperty(m_PropertyName).SetValue(m_TargetDescription,m_NewValue);
}
}
}
using System.ComponentModel;
namespace Command
{
/// <summary>
/// 変更前、変更後を保持するPropertyChangedイベントの引数
/// </summary>
public class PropertEventArgWithValue : PropertyChangedEventArgs
{
#region プロパティ
/// <summary>
/// 変更前の値
/// </summary>
public object OldValue { get; }
/// <summary>
/// 変更後の値
/// </summary>
public object NewValue { get; }
#endregion
#region 公開メソッド
/// <summary>
/// プロパティの変更を通知する
/// </summary>
public PropertEventArgWithValue(string propertyName, object oldValue, object newValue) : base(propertyName)
{
OldValue = oldValue;
NewValue = newValue;
}
#endregion
}
}
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Command
{
/// <summary>
/// 文章を表すクラス
/// </summary>
public class Description : INotifyPropertyChanged
{
#region フィールド
/// <summary>
/// タイトル
/// </summary>
private string m_Title;
/// <summary>
/// テキスト
/// </summary>
private string m_Text;
#endregion
#region プロパティ
/// <summary>
/// タイトル
/// </summary>
public string Title
{
get => m_Title;
set
{
var before = m_Title;
m_Title = value;
OnPropertyChanged(nameof(Title), before, value);
}
}
/// <summary>
/// テキスト
/// </summary>
public string Text
{
get => m_Text;
set
{
var before = m_Text;
m_Text = value;
OnPropertyChanged(nameof(Text), before, value);
}
}
#endregion
#region イベント
/// <summary>
/// プロパティが変更された時に発生するイベント
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
#endregion
#region 内部メソッド
/// <summary>
/// プロパティが変更された時のイベントハンドラ
/// </summary>
private void OnPropertyChanged(string propertyName, object? before, object? after)
{
PropertyChanged?.Invoke(this, new PropertEventArgWithValue(propertyName, before, after));
}
#endregion
}
}
今回のように編集したオブジェクトの状態を元に戻す処理は単にMementoだけでも実装できるのですが、Commandを用いることでメモリ効率の向上につながります。
Mementoはインスタンス自体を保存しますが、編集対象のオブジェクト自体ではなくCommandのインスタンスを残すことで差分の方だけを残すためです。
今回の編集対象オブジェクトは単純なものでしたのであまり影響はないと思いますが、複雑なオブジェクトの状態の丸ごと保存となるとると多くのメモリを使ってしまいますので注意が必要ですね。
Commandパターンのメリット
- 呼び出し側は命令を実行するだけで中身を知らずとも行いたい処理を行えるので、命令の内容に修正があった際も呼び出し側を修正する必要がなく影響範囲が少なくなる
- Mementoと組み合わせることで簡単にUndo/Redo操作を実現できる
おわりに
Undo/Redo操作は大半のアプリで見かけるものなので自分で実装して動いたときは少しうれしかったです。今後もオブジェクト指向、C#の学習を進めていき、また今回の学びを生かして実装も行っていきます!
お読みいただきありがとうございました!