Featured image of post 第8回-統計システムを追加しよう(OCP実践) - Perlでローグライク通知システムを作ろう

第8回-統計システムを追加しよう(OCP実践) - Perlでローグライク通知システムを作ろう

敵撃破数やダメージ統計を記録するStatisticsObserverを追加。既存コードを変更せずに拡張する開放閉鎖原則(OCP)を体感します。

@nqounetです。

「Perlでローグライク通知システムを作ろう」シリーズの第8回です。前回は、型チェックでバグを防ぐ方法を学びました。今回は、新しい「統計システム」を既存コードを変更せずに追加することで、開放閉鎖原則(OCP)を体感します。

前回の振り返り

前回は、does制約を使って間違ったオブジェクトの登録を防ぐ方法を学びました。

開放閉鎖原則(OCP)とは

開放閉鎖原則(Open/Closed Principle) は、SOLID原則の1つです。

ソフトウェアの実体(クラス、モジュール、関数など)は、拡張に対して開かれ、修正に対して閉じられているべきである

つまり、

  • 拡張に対して開いている: 新しい機能を追加できる
  • 修正に対して閉じている: 既存のコードを変更する必要がない

今回は、この原則を実践してみましょう。

統計システムを追加したくなった

ダンジョン探索の統計を記録したくなりました。

  • 倒した敵の数
  • 取得したアイテムの数
  • レベルアップした回数

これを実現するために、StatisticsObserverを作成します。

StatisticsObserverを作成する

新しいObserverを作成するだけで、既存コードは一切変更しません。

 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
# 統計を記録するObserver
package StatisticsObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';

    # 各イベント種類のカウント
    has 'stats' => (
        is      => 'ro',
        default => sub {{
            enemy_defeated => 0,
            item_acquired  => 0,
            level_up       => 0,
        }},
    );

    sub update ($self, $event) {
        my $type = $event->type;

        if (exists $self->stats->{$type}) {
            $self->stats->{$type}++;
        }
    }

    # 統計を表示
    sub show_stats ($self) {
        say "=== 探索統計 ===";
        say "敵撃破数: " . $self->stats->{enemy_defeated};
        say "アイテム取得数: " . $self->stats->{item_acquired};
        say "レベルアップ回数: " . $self->stats->{level_up};
    }
}

完全なコード例

既存のコードにStatisticsObserverを追加した完全な例を見てみましょう。

  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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo(cpanmでインストール)

use v5.36;

package GameEvent {
    use Moo;
    use v5.36;

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

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

    has 'data' => (
        is      => 'ro',
        default => sub { {} },
    );
}

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

    requires 'update';
}

package LogObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';

    sub update ($self, $event) {
        say "[LOG] " . $event->message;
    }
}

package AchievementObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';

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

    has 'defeated_count' => (
        is      => 'rw',
        default => 0,
    );

    sub update ($self, $event) {
        my $type = $event->type;

        if ($type eq 'enemy_defeated') {
            my $count = $self->defeated_count + 1;
            $self->defeated_count($count);

            if ($count == 1) {
                $self->_unlock('はじめての勝利');
            }
            if ($count == 5) {
                $self->_unlock('ハンター見習い');
            }
        }
    }

    sub _unlock ($self, $name) {
        push @{$self->unlocked}, $name;
        say "[ACHIEVEMENT] 実績解除: $name";
    }
}

package SoundObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';

    has 'sound_map' => (
        is      => 'ro',
        default => sub {{
            enemy_defeated => 'victory.wav',
            item_acquired  => 'pickup.wav',
            level_up       => 'levelup.wav',
        }},
    );

    sub update ($self, $event) {
        my $type = $event->type;
        my $sound = $self->sound_map->{$type};

        if ($sound) {
            say "[SOUND] $sound を再生";
        }
    }
}

# 新しいObserver!統計を記録
package StatisticsObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';

    has 'stats' => (
        is      => 'ro',
        default => sub {{
            enemy_defeated => 0,
            item_acquired  => 0,
            level_up       => 0,
        }},
    );

    sub update ($self, $event) {
        my $type = $event->type;

        if (exists $self->stats->{$type}) {
            $self->stats->{$type}++;
        }
    }

    sub show_stats ($self) {
        say "";
        say "=== 探索統計 ===";
        say "敵撃破数: " . $self->stats->{enemy_defeated};
        say "アイテム取得数: " . $self->stats->{item_acquired};
        say "レベルアップ回数: " . $self->stats->{level_up};
    }
}

package GameEventEmitter {
    use Moo;
    use v5.36;

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

    sub attach ($self, $observer) {
        unless ($observer->does('GameEventObserver')) {
            die "Error: ObserverはGameEventObserverを実装している必要があります";
        }
        push @{$self->observers}, $observer;
    }

    sub detach ($self, $observer) {
        @{$self->observers} = grep { $_ != $observer } @{$self->observers};
    }

    sub notify ($self, $event) {
        for my $observer (@{$self->observers}) {
            $observer->update($event);
        }
    }
}

package main {
    use v5.36;

    my $emitter = GameEventEmitter->new();

    my $log_observer = LogObserver->new();
    my $achievement_observer = AchievementObserver->new();
    my $sound_observer = SoundObserver->new();
    my $statistics_observer = StatisticsObserver->new();  # 新規追加!

    # 既存コードは変更なし
    $emitter->attach($log_observer);
    $emitter->attach($achievement_observer);
    $emitter->attach($sound_observer);
    $emitter->attach($statistics_observer);  # 追加するだけ!

    say "=== ダンジョン探索開始 ===";
    say "";

    my @events = (
        GameEvent->new(type => 'enemy_defeated', message => 'スライムを倒した!'),
        GameEvent->new(type => 'item_acquired', message => '薬草を手に入れた!'),
        GameEvent->new(type => 'enemy_defeated', message => 'ゴブリンを倒した!'),
        GameEvent->new(type => 'enemy_defeated', message => 'オークを倒した!'),
        GameEvent->new(type => 'level_up', message => 'レベルが2になった!'),
        GameEvent->new(type => 'item_acquired', message => '宝箱を手に入れた!'),
        GameEvent->new(type => 'enemy_defeated', message => 'トロルを倒した!'),
        GameEvent->new(type => 'enemy_defeated', message => 'ドラゴンを倒した!'),
    );

    for my $event (@events) {
        $emitter->notify($event);
        say "";
    }

    say "=== ダンジョン探索終了 ===";

    # 統計を表示
    $statistics_observer->show_stats();

    say "";
    say "=== 解除した実績 ===";
    say "- $_" for @{$achievement_observer->unlocked};
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
=== ダンジョン探索開始 ===

[LOG] スライムを倒した!
[ACHIEVEMENT] 実績解除: はじめての勝利
[SOUND] victory.wav を再生

[LOG] 薬草を手に入れた!
[SOUND] pickup.wav を再生

[LOG] ゴブリンを倒した!
[SOUND] victory.wav を再生

...(中略)...

=== ダンジョン探索終了 ===

=== 探索統計 ===
敵撃破数: 5
アイテム取得数: 2
レベルアップ回数: 1

=== 解除した実績 ===
- はじめての勝利
- ハンター見習い

何が素晴らしいのか

StatisticsObserverを追加するとき、以下のクラスは一切変更していません。

  • GameEvent
  • GameEventObserver
  • LogObserver
  • AchievementObserver
  • SoundObserver
  • GameEventEmitter

変更したのは以下だけです。

  1. StatisticsObserverクラスを新規作成
  2. mainattachを呼び出す行を追加

これが「拡張に対して開かれ、修正に対して閉じられている」状態です。

OCPのメリット

メリット説明
既存機能への影響なし新機能追加で既存コードのバグリスクなし
テストの安定性既存テストが失敗するリスクが低い
並行開発が容易チームメンバーが独立して新しいObserverを開発可能
ロールバックが簡単新しいObserverを削除するだけで元に戻せる

今回のまとめ

今回は、開放閉鎖原則(OCP)を実践しました。

  • 新しいStatisticsObserverを追加
  • 既存コードは一切変更なし
  • with 'GameEventObserver'attachだけで統合

次回予告

次回は「完成!ローグライク通知システム」です。全機能を統合して対話的なCLIデモを作成します。

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