Featured image of post 第6回-履歴を管理しよう - Historyクラス - Mooで作る簡易テキストエディタ

第6回-履歴を管理しよう - Historyクラス - Mooで作る簡易テキストエディタ

操作履歴を管理するHistoryクラスを作成します。undo_stackとredo_stackでスタック管理を行い、execute_commandとundoメソッドを実装。履歴管理のカプセル化を学びます。

@nqounetです。

シリーズ「Mooで作る簡易テキストエディタ」の第6回です。

前回の振り返り

前回は、Moo::Rolerequires機能を使って、Command::Roleを作成しました。

これにより、すべてのコマンドクラスがexecuteundoメソッドを持つことをコンパイル時に保証できるようになりました。新しいコマンドクラスを追加する際に、必須メソッドの実装漏れを早期に検出できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package Command::Role {
    use Moo::Role;

    requires 'execute';
    requires 'undo';
};

package InsertCommand {
    use Moo;
    with 'Command::Role';  # executeとundoがないとエラー

    # ...
};

しかし、前回のコードを見返すと、気になる点があります。

問題:履歴管理がメイン処理に散らばっている

前回の完成コードでは、履歴管理がメイン処理に直接書かれています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# メイン処理
my $editor = Editor->new;
my @history;  # ここで履歴配列を管理

# 操作1: 'Hello'を挿入
my $cmd1 = InsertCommand->new(
    editor   => $editor,
    position => 0,
    string   => 'Hello',
);
$cmd1->execute;
push @history, $cmd1;  # 履歴に追加

# ...(他の操作も同様)

# Undo
while (my $cmd = pop @history) {  # 履歴から取り出し
    $cmd->undo;
}

このコードには、いくつかの問題があります。

  1. 履歴操作が分散している — push @history, $cmdpop @historyがメイン処理のあちこちに散らばっている
  2. Redo機能を追加しにくい — Undoした操作をRedoするには、別のスタックが必要。このままでは実装が複雑になる
  3. 再利用が難しい — 履歴管理のロジックを別のアプリケーションで使いたくても、切り出しにくい

履歴管理を専門に行うクラスがあれば、これらの問題を解決できます。

解決策:Historyクラスを作成する

履歴管理を担当するHistoryクラスを作成しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Perl v5.36 以降
# 外部依存: Moo

package History {
    use Moo;
    use v5.36;

    has undo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    has redo_stack => (
        is      => 'ro',
        default => sub { [] },
    );
};

Historyクラスには2つの属性があります。

  • undo_stack — 実行したコマンドを積み上げるスタック(Undo用)
  • redo_stack — Undoしたコマンドを積み上げるスタック(Redo用)

どちらも配列リファレンスで、スタック(後入れ先出し:LIFO)として使います。

なぜ2つのスタックが必要なのでしょうか?

Undo/Redo機能を実現するには、以下の動作が必要です。

  • Undo — 最後に実行した操作を取り消す
  • Redo — Undoした操作をやり直す

Undoした操作を覚えておかないと、Redoできません。そのため、redo_stackが必要になります。

	flowchart LR
    subgraph before["Undo前"]
        direction TB
        subgraph undo_stack_before["undo_stack"]
            US1["cmd1"]
            US2["cmd2"]
            US3["cmd3 ←top"]
        end
        subgraph redo_stack_before["redo_stack"]
            RS0["(空)"]
        end
    end
    
    subgraph after["Undo後"]
        direction TB
        subgraph undo_stack_after["undo_stack"]
            USA1["cmd1"]
            USA2["cmd2 ←top"]
        end
        subgraph redo_stack_after["redo_stack"]
            RSA1["cmd3 ←top"]
        end
    end
    
    before -->|"cmd3.undo()"| after
    
    style undo_stack_before fill:#e1f5fe
    style undo_stack_after fill:#e1f5fe
    style redo_stack_before fill:#fff3e0
    style redo_stack_after fill:#fff3e0
1
2
3
execute_command → undo_stackにpush
undo           → undo_stackからpop → redo_stackにpush
redo           → redo_stackからpop → undo_stackにpush

今回はundo_stackredo_stackの準備、そしてexecute_commandundoメソッドを実装します。redoメソッドは次回詳しく扱います。

execute_commandメソッドを実装する

まず、コマンドを実行して履歴に追加するexecute_commandメソッドを実装しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Perl v5.36 以降
# 外部依存: Moo

package History {
    use Moo;
    use v5.36;

    has undo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    has redo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    sub execute_command ($self, $command) {
        $command->execute;
        push $self->undo_stack->@*, $command;

        # 新しい操作を実行したら、redo_stackをクリア
        $self->redo_stack->@* = ();
    }
};

execute_commandメソッドは、以下の処理を行います。

  1. コマンドのexecuteメソッドを呼び出す
  2. 実行したコマンドをundo_stackpushする
  3. redo_stackをクリアする(重要)

なぜredo_stackをクリアするのでしょうか?

これは、エディタの一般的な動作に合わせています。多くのエディタでは、Undoした後に新しい操作を行うと、Redo履歴がクリアされます。

たとえば、以下のシナリオを考えてみましょう。

	flowchart TD
    subgraph Step1["1. 「A」を入力"]
        US1_1["undo: [A]"]
        RS1_1["redo: []"]
    end
    
    subgraph Step2["2. 「B」を入力"]
        US2_1["undo: [A, B]"]
        RS2_1["redo: []"]
    end
    
    subgraph Step3["3. Undo(Bを取消)"]
        US3_1["undo: [A]"]
        RS3_1["redo: [B]"]
    end
    
    subgraph Step4["4. 「C」を入力 ← redo_stackクリア"]
        US4_1["undo: [A, C]"]
        RS4_1["redo: [] ⚠️"]
    end
    
    Step1 --> Step2 --> Step3 --> Step4
  1. 「A」を入力 → undo_stack: [A]
  2. 「B」を入力 → undo_stack: [A, B]
  3. Undo(「B」を取り消し)→ undo_stack: [A], redo_stack: [B]
  4. 「C」を入力 → undo_stack: [A, C], redo_stack: [](クリア)

ステップ4で新しい操作「C」を実行したので、「B」のRedo履歴は意味がなくなります。そのため、redo_stackをクリアしています。

undoメソッドを実装する

次に、最後の操作を取り消すundoメソッドを実装しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Perl v5.36 以降
# 外部依存: Moo

package History {
    use Moo;
    use v5.36;

    has undo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    has redo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    sub execute_command ($self, $command) {
        $command->execute;
        push $self->undo_stack->@*, $command;
        $self->redo_stack->@* = ();
    }

    sub undo ($self) {
        return unless $self->undo_stack->@*;  # スタックが空なら何もしない

        my $command = pop $self->undo_stack->@*;
        $command->undo;
        push $self->redo_stack->@*, $command;
    }
};

undoメソッドは、以下の処理を行います。

  1. undo_stackが空なら、何もせずにreturnする
  2. undo_stackから最後のコマンドをpopする
  3. コマンドのundoメソッドを呼び出す
  4. Undoしたコマンドをredo_stackpushする(Redo用に保存)

最初のreturn unless ...はガード節です。Undoする操作がない場合にエラーにならないよう、早期リターンしています。

Historyクラスを使ってみる

では、Historyクラスを使って、履歴管理をカプセル化してみましょう。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
# Perl v5.36 以降
# 外部依存: Moo

use v5.36;

package Editor {
    use Moo;

    has text => (
        is      => 'rw',
        default => '',
    );
};

package Command::Role {
    use Moo::Role;

    requires 'execute';
    requires 'undo';
};

package InsertCommand {
    use Moo;
    with 'Command::Role';

    has editor   => (is => 'ro', required => 1);
    has position => (is => 'ro', required => 1);
    has string   => (is => 'ro', required => 1);

    sub execute ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $string   = $self->string;

        my $current  = $editor->text;
        my $new_text = substr($current, 0, $position) 
                     . $string 
                     . substr($current, $position);
        $editor->text($new_text);
    }

    sub undo ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $length   = length($self->string);

        my $current  = $editor->text;
        my $new_text = substr($current, 0, $position) 
                     . substr($current, $position + $length);
        $editor->text($new_text);
    }
};

package History {
    use Moo;

    has undo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    has redo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    sub execute_command ($self, $command) {
        $command->execute;
        push $self->undo_stack->@*, $command;
        $self->redo_stack->@* = ();
    }

    sub undo ($self) {
        return unless $self->undo_stack->@*;

        my $command = pop $self->undo_stack->@*;
        $command->undo;
        push $self->redo_stack->@*, $command;
    }
};

# メイン処理
my $editor  = Editor->new;
my $history = History->new;

# 操作1: 'Hello'を挿入
my $cmd1 = InsertCommand->new(
    editor   => $editor,
    position => 0,
    string   => 'Hello',
);
$history->execute_command($cmd1);
say "操作1後: " . $editor->text;

# 操作2: ' World'を挿入
my $cmd2 = InsertCommand->new(
    editor   => $editor,
    position => 5,
    string   => ' World',
);
$history->execute_command($cmd2);
say "操作2後: " . $editor->text;

say "";
say "--- Undo開始 ---";

# Undo
$history->undo;
say "Undo1後: " . $editor->text;

$history->undo;
say "Undo2後: " . $editor->text;

実行結果は以下のようになります。

1
2
3
4
5
6
操作1後: Hello
操作2後: Hello World

--- Undo開始 ---
Undo1後: Hello
Undo2後: 

前回と同じ結果ですが、メイン処理がシンプルになりました。

変更前(前回のコード)

1
2
3
4
5
6
7
my @history;  # 配列を直接管理

$cmd1->execute;
push @history, $cmd1;  # 手動でpush

my $cmd = pop @history;  # 手動でpop
$cmd->undo;

変更後(今回のコード)

1
2
3
4
5
my $history = History->new;  # Historyオブジェクトに任せる

$history->execute_command($cmd1);  # 実行と履歴追加を一括

$history->undo;  # Undo操作もメソッド呼び出しだけ

履歴管理のロジックがHistoryクラスにカプセル化されたため、メイン処理は「何をするか」に集中できるようになりました。

Historyクラスを導入する利点

Historyクラスを導入することで、以下の利点が得られます。

  1. 履歴操作の集中管理 — push/popのロジックがHistoryクラス内に閉じ込められる
  2. Redo機能の準備 — redo_stackが用意され、次回のRedo実装がスムーズに行える
  3. 再利用性の向上 — Historyクラスを別のアプリケーションでも使い回せる
  4. テストしやすさ — 履歴管理のテストをHistoryクラス単体で行える

また、execute_commandメソッドでredo_stackをクリアする処理も、適切な場所(Historyクラス内)で行われています。この処理をメイン処理に書いていたら、忘れてバグになる可能性がありました。

今回作成した完成コード

以下が今回作成した完成コードです。Historyクラスを定義し、execute_commandundoメソッドで履歴管理を行っています。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
#!/usr/bin/env perl
# Perl v5.36 以降
# 外部依存: Moo

use v5.36;

package Editor {
    use Moo;

    has text => (
        is      => 'rw',
        default => '',
    );
};

package Command::Role {
    use Moo::Role;

    requires 'execute';
    requires 'undo';
};

package InsertCommand {
    use Moo;
    with 'Command::Role';

    has editor => (
        is       => 'ro',
        required => 1,
    );

    has position => (
        is       => 'ro',
        required => 1,
    );

    has string => (
        is       => 'ro',
        required => 1,
    );

    sub execute ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $string   = $self->string;

        my $current  = $editor->text;
        my $new_text = substr($current, 0, $position) 
                     . $string 
                     . substr($current, $position);
        $editor->text($new_text);
    }

    sub undo ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $length   = length($self->string);

        my $current  = $editor->text;
        my $new_text = substr($current, 0, $position) 
                     . substr($current, $position + $length);
        $editor->text($new_text);
    }
};

package DeleteCommand {
    use Moo;
    with 'Command::Role';

    has editor => (
        is       => 'ro',
        required => 1,
    );

    has position => (
        is       => 'ro',
        required => 1,
    );

    has length => (
        is       => 'ro',
        required => 1,
    );

    has _deleted_string => (
        is      => 'rw',
        default => '',
    );

    sub execute ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $length   = $self->length;

        my $current = $editor->text;
        my $deleted = substr($current, $position, $length);
        $self->_deleted_string($deleted);

        my $new_text = substr($current, 0, $position) 
                     . substr($current, $position + $length);
        $editor->text($new_text);
    }

    sub undo ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $deleted  = $self->_deleted_string;

        my $current  = $editor->text;
        my $new_text = substr($current, 0, $position) 
                     . $deleted 
                     . substr($current, $position);
        $editor->text($new_text);
    }
};

package History {
    use Moo;

    has undo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    has redo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    sub execute_command ($self, $command) {
        $command->execute;
        push $self->undo_stack->@*, $command;
        $self->redo_stack->@* = ();
    }

    sub undo ($self) {
        return unless $self->undo_stack->@*;

        my $command = pop $self->undo_stack->@*;
        $command->undo;
        push $self->redo_stack->@*, $command;
    }
};

# メイン処理
my $editor  = Editor->new;
my $history = History->new;

# 操作1: 'Hello'を挿入
my $cmd1 = InsertCommand->new(
    editor   => $editor,
    position => 0,
    string   => 'Hello',
);
$history->execute_command($cmd1);
say "操作1後: " . $editor->text;

# 操作2: ' World'を挿入
my $cmd2 = InsertCommand->new(
    editor   => $editor,
    position => 5,
    string   => ' World',
);
$history->execute_command($cmd2);
say "操作2後: " . $editor->text;

# 操作3: ' World'を削除
my $cmd3 = DeleteCommand->new(
    editor   => $editor,
    position => 5,
    length   => 6,
);
$history->execute_command($cmd3);
say "操作3後: " . $editor->text;

say "";
say "--- Undo開始 ---";

# Undo
$history->undo;
say "Undo1後: " . $editor->text;

$history->undo;
say "Undo2後: " . $editor->text;

$history->undo;
say "Undo3後: " . $editor->text;

実行結果は以下のようになります。

1
2
3
4
5
6
7
8
操作1後: Hello
操作2後: Hello World
操作3後: Hello

--- Undo開始 ---
Undo1後: Hello World
Undo2後: Hello
Undo3後: 

まとめ

  • 履歴管理をメイン処理から分離し、Historyクラスにカプセル化した
  • undo_stackは実行したコマンドを積み上げ、Undo時に取り出す
  • redo_stackはUndoしたコマンドを積み上げ、Redo用に保存する
  • execute_commandメソッドは、コマンドを実行し、undo_stackに追加し、redo_stackをクリアする
  • undoメソッドは、undo_stackからコマンドを取り出し、undoを呼び、redo_stackに保存する

次回予告

今回、Historyクラスを作成して履歴管理をカプセル化しました。redo_stackも用意しましたが、まだredoメソッドは実装していません。

1
2
3
4
# 今回作成したもの
$history->execute_command($cmd);  # ✅ 実装済み
$history->undo;                   # ✅ 実装済み
# $history->redo;                 # ❌ 未実装

次回は、Undoした操作をやり直すRedo機能を実装します。undo_stackredo_stackの間でコマンドがどのように移動するかを理解し、Undo/Redoの連続操作を可能にします。

お楽しみに。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。