Featured image of post 第5回-共通の約束を決める - ロールの活用 - Mooで作る簡易テキストエディタ

第5回-共通の約束を決める - ロールの活用 - Mooで作る簡易テキストエディタ

InsertCommandとDeleteCommandに共通のインターフェースを保証するため、Moo::Roleでrequiresを使ったCommand::Roleを作成します。コンパイル時に不足メソッドを検出できる安全な設計を学びます。

@nqounetです。

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

前回の振り返り

前回は、InsertCommandDeleteCommandにそれぞれundoメソッドを追加し、履歴を使った複数回Undoを実現しました。

これで、操作を実行するexecuteメソッドと、それを元に戻すundoメソッドの対が揃いました。履歴配列から操作を取り出してundoを呼ぶことで、何回でもUndoできるようになりました。

1
2
3
# 履歴から最後の操作を取り出してundo
my $cmd = pop @history;
$cmd->undo;

しかし、ここで1つ不安が残ります。

問題:メソッドの実装を保証できない

現在、InsertCommandDeleteCommandはたまたま同じメソッド(executeundo)を持っています。しかし、これは「約束」ではありません。

たとえば、新しいコマンドクラスReplaceCommandを追加するとします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package ReplaceCommand {
    use Moo;

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

    sub execute ($self) {
        # 置換処理...
    }

    # undoメソッドを実装し忘れた!
};

undoメソッドを実装し忘れても、Perlはエラーを出しません。問題に気づくのは、実際にUndoしようとしたときです。

1
2
3
4
5
6
7
my $cmd = ReplaceCommand->new(...);
$cmd->execute;
push @history, $cmd;

# 後で...
my $undo_cmd = pop @history;
$undo_cmd->undo;  # ここでエラー!「Can't locate object method "undo"」

このエラーは実行時に発生します。テストで見逃すと、本番環境で問題が起きる可能性があります。

「すべてのコマンドはexecuteundoを持つべき」という約束を、コードで保証したいのです。

解決策:Moo::Roleでインターフェースを定義する

この問題を解決するのがMoo::Rolerequires機能です。

	classDiagram
    class Command_Role {
        <<Moo::Role>>
        +execute()*
        +undo()*
    }
    class InsertCommand {
        -editor
        -position
        -string
        +execute()
        +undo()
    }
    class DeleteCommand {
        -editor
        -position
        -length
        +execute()
        +undo()
    }
    
    Command_Role <|.. InsertCommand : with
    Command_Role <|.. DeleteCommand : with
    
    note for Command_Role "requires で
必須メソッドを宣言"

Moo::Roleについては、前シリーズで詳しく解説しました。

前シリーズでは、ロールを使って属性やメソッドを提供する例を紹介しました。今回は、ロールを使ってメソッドの実装を要求する使い方を学びます。

Command::Roleを作成する

では、コマンドクラス用のロールCommand::Roleを作成しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Perl v5.36 以降
# 外部依存: Moo

package Command::Role {
    use Moo::Role;
    use v5.36;

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

requires 'execute'requires 'undo'が重要です。

これは「このロールを適用するクラスは、executeundoメソッドを実装しなければならない」という約束を宣言しています。

requiresで指定されたメソッドを実装していないクラスにロールを適用しようとすると、コンパイル時にエラーになります。

各コマンドにwithで適用する

InsertCommandDeleteCommandCommand::Roleを適用してみましょう。

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

package Command::Role {
    use Moo::Role;
    use v5.36;

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

package InsertCommand {
    use Moo;
    use v5.36;
    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;
    use v5.36;
    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);
    }
};

各クラスにwith 'Command::Role'を追加するだけです。

InsertCommandDeleteCommandも、executeundoの両方を実装しているので、問題なくロールが適用されます。

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

use v5.36;

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

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

package BrokenCommand {
    use Moo;
    with 'Command::Role';  # ロールを適用

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

    sub execute ($self) {
        # 何かの処理...
    }

    # undoメソッドがない!
};

このコードを実行しようとすると、以下のエラーが発生します。

1
Can't apply Command::Role to BrokenCommand - missing undo at ...

コンパイル時にエラーが発生しています。実行される前に問題を検出できるのです。

これは非常に重要な利点です。

  • 新しいコマンドクラスを追加するとき、undoを忘れるとすぐにエラーになる
  • テストを実行する前に問題を発見できる
  • 「すべてのコマンドはexecuteundoを持つ」という設計意図がコードで表現される

ロールを適用する利点

Command::Roleを適用することで、以下の利点が得られます。

  1. コンパイル時チェック — 必須メソッドの実装漏れを早期に検出できる
  2. 設計意図の明示 — 「コマンドは何をすべきか」がコードで表現される
  3. 型の代わり — Perlには静的型がないが、ロールで「このオブジェクトは〜できる」を表現できる
  4. ドキュメント効果 — 新しい開発者がコマンドクラスを追加するとき、何を実装すべきかが明確

今回作成した完成コード

以下が今回作成した完成コードです。Command::Roleを定義し、InsertCommandDeleteCommandに適用しています。

  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
#!/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);
    }
};

# メイン処理
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;
}

実行結果は前回と同じです。

1
2
3
操作1後: Hello
操作2後: Hello World
操作3後: Hello
1
2
3
4
--- Undo開始 ---
Undo後: Hello World
Undo後: Hello
Undo後: 

違いは見た目ではわかりませんが、設計の安全性が向上しています。

まとめ

  • Moo::Rolerequiresで必須メソッドを宣言できる
  • with 'RoleName'でロールを適用すると、requiresのメソッドがなければコンパイル時エラーになる
  • これにより「すべてのコマンドはexecuteundoを持つ」という約束をコードで保証できる
  • 新しいコマンドクラスを追加するときも、必須メソッドの実装漏れを早期に検出できる

次回予告

今回、Command::Roleを導入してコマンドクラスのインターフェースを保証しました。

しかし、現在のコードでは履歴管理がメイン処理に直接書かれています。@history配列へのpushpopがメイン処理に散らばっていて、コードが複雑になっています。

1
2
push @history, $cmd;  # メイン処理に散らばっている
my $cmd = pop @history;

次回は、履歴管理を専門に行うHistoryクラスを作成し、Undo操作をカプセル化します。

お楽しみに。

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