@nqounet です。
シリーズ「Mooで作る簡易テキストエディタ」の第6回です。
前回の振り返り 前回は、Moo::Roleのrequires機能を使って、Command::Roleを作成しました。
これにより、すべてのコマンドクラスがexecuteとundoメソッドを持つことをコンパイル時に保証できるようになりました。新しいコマンドクラスを追加する際に、必須メソッドの実装漏れを早期に検出できます。
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 ;
}
このコードには、いくつかの問題があります。
履歴操作が分散している — push @history, $cmdやpop @historyがメイン処理のあちこちに散らばっている Redo機能を追加しにくい — Undoした操作をRedoするには、別のスタックが必要。このままでは実装が複雑になる 再利用が難しい — 履歴管理のロジックを別のアプリケーションで使いたくても、切り出しにくい 履歴管理を専門に行うクラスがあれば、これらの問題を解決できます。
解決策: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_stackとredo_stackの準備、そしてexecute_commandとundoメソッドを実装します。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メソッドは、以下の処理を行います。
コマンドのexecuteメソッドを呼び出す 実行したコマンドをundo_stackにpushする 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
「A」を入力 → undo_stack: [A] 「B」を入力 → undo_stack: [A, B] Undo(「B」を取り消し)→ undo_stack: [A], redo_stack: [B] 「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メソッドは、以下の処理を行います。
undo_stackが空なら、何もせずにreturnするundo_stackから最後のコマンドをpopするコマンドのundoメソッドを呼び出す Undoしたコマンドをredo_stackにpushする(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クラスを導入することで、以下の利点が得られます。
履歴操作の集中管理 — push/popのロジックがHistoryクラス内に閉じ込められる Redo機能の準備 — redo_stackが用意され、次回のRedo実装がスムーズに行える 再利用性の向上 — Historyクラスを別のアプリケーションでも使い回せる テストしやすさ — 履歴管理のテストをHistoryクラス単体で行える また、execute_commandメソッドでredo_stackをクリアする処理も、適切な場所(Historyクラス内)で行われています。この処理をメイン処理に書いていたら、忘れてバグになる可能性がありました。
今回作成した完成コード 以下が今回作成した完成コードです。Historyクラスを定義し、execute_commandと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
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_stackとredo_stackの間でコマンドがどのように移動するかを理解し、Undo/Redoの連続操作を可能にします。
お楽しみに。