Featured image of post 第4回-元に戻せるようにする - undoメソッド - Mooで作る簡易テキストエディタ

第4回-元に戻せるようにする - undoメソッド - Mooで作る簡易テキストエディタ

各操作に「逆操作」を定義してUndo機能を実現します。InsertCommandにundoメソッドを追加し、挿入の逆である削除を実装。DeleteCommandクラスも作成します。

@nqounetです。

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

前回の振り返り

前回は、「何をしたか」を記録するために、操作そのものをオブジェクトにするアイデアを学びました。

InsertCommandクラスを作成し、executeメソッドで挿入操作を実行できるようにしました。そして、操作オブジェクトを配列(履歴)に保存することで、「何をしたか」を記録できるようになりました。

1
2
3
4
5
6
7
my $cmd = InsertCommand->new(
    editor   => $editor,
    position => 0,
    string   => 'Hello',
);
$cmd->execute;          # 挿入を実行
push @history, $cmd;    # 履歴に保存

しかし、まだUndo機能は実装されていません。履歴があっても、「元に戻す」操作ができないのです。

今回は、各操作クラスにundoメソッドを追加して、実際にUndoできるようにします。

executeとundoの対称性

Undoを実現するための考え方はシンプルです。

各操作には「逆の操作」があるのです。

  • 「挿入」の逆は「削除」
  • 「削除」の逆は「挿入」
	flowchart LR
    subgraph InsertCommand
        direction LR
        E1["execute()<br/>位置0に'A'を挿入"] 
        U1["undo()<br/>位置0から1文字削除"]
        E1 <-->|対称| U1
    end
    
    subgraph テキストの変化
        T1["text: ''"] -->|execute| T2["text: 'A'"]
        T2 -->|undo| T1
    end

たとえば、「位置0に’A’を挿入した」操作を元に戻すには、「位置0から1文字を削除する」という逆の操作を実行すればよいのです。

1
2
execute: 位置0に'A'を挿入  →  text: '' → 'A'
undo:    位置0から1文字削除 →  text: 'A' → ''

この「execute」と「undo」は対になっています。executeで行った操作を、undoで元に戻す。この対称的な構造が、Commandパターンの美しさです。

InsertCommandにundoメソッドを追加

では、InsertCommandクラスに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
# Perl v5.36 以降
# 外部依存: Moo

package InsertCommand {
    use Moo;
    use v5.36;

    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);
    }
};

undoメソッドでは、executeで挿入した文字列を削除しています。

  • 挿入した位置($position)から
  • 挿入した文字列の長さ($length)分だけ削除する

これで、executeで挿入した操作をundoで元に戻せるようになりました。

InsertCommandの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
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
# Perl v5.36 以降
# 外部依存: Moo

use v5.36;

package Editor {
    use Moo;

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

package InsertCommand {
    use Moo;

    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);
    }
};

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

my $cmd = InsertCommand->new(
    editor   => $editor,
    position => 0,
    string   => 'Hello',
);

$cmd->execute;
say "execute後: " . $editor->text;  # Hello

$cmd->undo;
say "undo後: " . $editor->text;     # (空文字列)

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

1
2
execute後: Hello
undo後: 

executeで挿入した「Hello」が、undoで削除されて空文字列に戻りました。

DeleteCommandクラスを作る

次に、削除操作を表すDeleteCommandクラスを作成しましょう。

削除操作の場合、undoでは「削除した文字列を元の位置に挿入」することで元に戻せます。

ただし、ここで1つ注意があります。削除した文字列を覚えておく必要があるのです。

そのため、executeメソッドの中で、削除する前に文字列を保存しておきます。

 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
# Perl v5.36 以降
# 外部依存: Moo

package DeleteCommand {
    use Moo;
    use v5.36;

    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);
    }
};

DeleteCommandクラスの特徴を見てみましょう。

  • position — 削除開始位置
  • length — 削除する文字数
  • _deleted_string — 削除した文字列を保存する内部属性(_で始まる)
  • execute — 文字列を削除する前に_deleted_stringに保存してから削除
  • undo — 保存しておいた文字列を元の位置に挿入

_deleted_string属性はis => 'rw'にしています。これは、executeメソッドの中で値を設定する必要があるためです。アンダースコア(_)で始めているのは、「外部から直接触らない内部属性」であることを示す慣習です。

DeleteCommandのundoを試す

DeleteCommandの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
# Perl v5.36 以降
# 外部依存: Moo

use v5.36;

package Editor {
    use Moo;

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

package DeleteCommand {
    use Moo;

    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);
    }
};

# メイン処理
my $editor = Editor->new;
$editor->text('Hello World');  # 初期テキストを設定

say "初期状態: " . $editor->text;  # Hello World

my $cmd = DeleteCommand->new(
    editor   => $editor,
    position => 5,
    length   => 6,
);

$cmd->execute;
say "execute後: " . $editor->text;  # Hello

$cmd->undo;
say "undo後: " . $editor->text;     # Hello World

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

1
2
3
初期状態: Hello World
execute後: Hello
undo後: Hello World

「 World」を削除した後、undoで元に戻すことができました。

履歴を使ったUndo

これでInsertCommandDeleteCommandの両方にundoメソッドが追加されました。

履歴配列から操作を取り出してundoを呼べば、複数回のUndoができるはずです。

	flowchart TD
    subgraph 履歴スタック
        direction TB
        H1["[0] cmd1: insert 'A'"]
        H2["[1] cmd2: insert 'B'"]
        H3["[2] cmd3: insert 'C'"]
    end
    
    subgraph Undo操作
        U1["pop → cmd3"] -->|"cmd3->undo()"| R1["text: 'ABC'→'AB'"]
        U2["pop → cmd2"] -->|"cmd2->undo()"| R2["text: 'AB'→'A'"]
        U3["pop → cmd1"] -->|"cmd1->undo()"| R3["text: 'A'→''"]
    end
    
    H3 --> U1
    H2 --> U2
    H1 --> U3
  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
# Perl v5.36 以降
# 外部依存: Moo

use v5.36;

package Editor {
    use Moo;

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

package InsertCommand {
    use Moo;

    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);
    }
};

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

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

# 操作2: 'B'を挿入
my $cmd2 = InsertCommand->new(
    editor   => $editor,
    position => 1,
    string   => 'B',
);
$cmd2->execute;
push @history, $cmd2;
say "操作2後: " . $editor->text;  # AB

# 操作3: 'C'を挿入
my $cmd3 = InsertCommand->new(
    editor   => $editor,
    position => 2,
    string   => 'C',
);
$cmd3->execute;
push @history, $cmd3;
say "操作3後: " . $editor->text;  # ABC

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

# Undo1: 履歴から最後の操作を取り出してundo
my $undo1 = pop @history;
$undo1->undo;
say "Undo1後: " . $editor->text;  # AB

# Undo2: 履歴から最後の操作を取り出してundo
my $undo2 = pop @history;
$undo2->undo;
say "Undo2後: " . $editor->text;  # A

# Undo3: 履歴から最後の操作を取り出してundo
my $undo3 = pop @history;
$undo3->undo;
say "Undo3後: " . $editor->text;  # (空文字列)

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

1
2
3
4
5
6
7
8
操作1後: A
操作2後: AB
操作3後: ABC

--- Undo開始 ---
Undo1後: AB
Undo2後: A
Undo3後: 

複数回のUndoが正しく動作しています。

履歴配列からpopで最後の操作を取り出し、そのundoメソッドを呼ぶことで、逆順に操作を元に戻しています。これはスタック(LIFO: Last In, First Out)の動作です。

何が実現できたのか

ここで、何が実現できたのかを整理しましょう。

第2回で抱えていた問題:

  • 直前の状態(previous_text)を1つだけ保存する方法では、複数回のUndoに対応できない

今回実現したこと:

  • 各操作クラス(InsertCommand, DeleteCommand)にundoメソッドを追加
  • undoメソッドはexecuteの逆操作を実行する
  • 履歴配列から操作を取り出してundoを呼ぶことで、何回でもUndoできる

操作をオブジェクトにして、executeundoのペアを持たせることで、複数回のUndo問題を解決できました。

今回作成した完成コード

以下が今回作成した完成コードです。InsertCommandDeleteCommandの両方に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
 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
#!/usr/bin/env perl
# Perl v5.36 以降
# 外部依存: Moo

use v5.36;

package Editor {
    use Moo;

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

package InsertCommand {
    use Moo;

    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;

    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);
    }
};

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

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

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

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

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

# Undo
while (my $cmd = pop @history) {
    $cmd->undo;
    say "Undo後: " . $editor->text;
}

まとめ

  • 各操作には「逆の操作」がある(挿入↔削除)
  • executeメソッドと対になるundoメソッドを追加する
  • InsertCommand.undoは挿入した文字列を削除する
  • DeleteCommand.undoは削除した文字列を元の位置に挿入する(削除前に保存が必要)
  • 履歴配列からpopしてundoを呼ぶことで、複数回のUndoが可能

次回予告

今回、InsertCommandDeleteCommandにそれぞれexecuteundoメソッドを追加しました。

しかし、ここで疑問が生じます。「すべてのコマンドクラスが同じメソッドを持つべき」という約束を、どう保証するのでしょうか?

もし、新しいコマンドクラスを追加する際にundoメソッドを実装し忘れたら、Undo機能が壊れてしまいます。

次回は、Moo::Roleを使って共通のインターフェースを定義し、すべてのコマンドクラスが必ずexecuteundoメソッドを持つことを保証します。

お楽しみに。

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