Featured image of post 第7回-does制約で型チェックしよう - Mooを使って自動販売機シミュレーターを作ってみよう

第7回-does制約で型チェックしよう - Mooを使って自動販売機シミュレーターを作ってみよう

間違ったオブジェクトが状態として設定されるバグを防ぎたい。does制約を使った型チェックで実行時エラーを未然に防ぐ方法を解説。

@nqounetです。

前回は、状態クラスがContextへの参照を受け取り、自ら状態遷移を行う仕組みを実装しました。

今回は、型チェックを追加して、間違ったオブジェクトが状態として設定されるバグを防ぎます。

現在の問題点

現在のVendingMachineクラスでは、state属性にどんなオブジェクトでも設定できてしまいます。

1
2
3
my $vm = VendingMachine->new;
$vm->set_state("これは文字列です");  # エラーにならない!
$vm->insert_coin;  # ここでエラーになる

このコードはset_stateの時点ではエラーにならず、insert_coinを呼び出したときに初めてエラーになります。

問題が発覚するタイミングが遅いため、デバッグが難しくなります。

isaで解決する

Mooでは、属性の検証手段として、isaを指定することができます。

指定できるのは、コードリファレンスで、仕組みとしては単純です。属性の値として指定されようとしている値が@_で渡されるので、その値が正しいかどうかを検証することができます。

Type::Tiny(実質的にはTypes::Standard)を使用する場合は、型を指定するように書けるのですが、Mooの本来のisaの仕組みは関数が実行されるだけです。

ですが、その仕組みを使うと、属性の型だけではなく、様々なバリデーションを行うことができます。

余談ですが、Mooseにはdoes制約があり、これを使うと、「このロールを持つオブジェクトのみ受け付ける」という条件を設定できます。

1
2
3
4
5
6
7
8
9
    has state => (
        is      => 'rw',
        default => sub { WaitingState->new },
        isa    => sub {
            my $value = shift;
            die "state must do VendingMachineState role"
                unless $value->does('VendingMachineState');
        },
    );

$obj->state($state) のように実行した場合、isaのコードリファレンスには$stateが第1引数として渡されます。

上記の例では、isaの中で$state$valueに代入して、メソッドのdoesを使用して、ロールを持っているかどうかを判定しています。

ここでは、実質的に正しいロール以外は例外を出せば良いという考え方で、$value->does()を直接呼んでいます。ですが、厳格に書く場合は、リファレンスかどうか、doesが実行できるか、といった検証のための準備も必要です。

型エラーのデモ

isaを追加したコードで、間違ったオブジェクトを設定しようとするとどうなるか見てみましょう。

 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
#!/usr/bin/env perl
use v5.36;
use Moo;

package VendingMachineState {
    use Moo::Role;

    requires 'insert_coin';
    requires 'select_product';
    requires 'dispense';
}

package WaitingState {
    use Moo;
    use v5.36;

    with 'VendingMachineState';

    sub insert_coin ($self, $context) {
        say "コインを受け付けました";
        $context->set_state(CoinInsertedState->new);
    }

    sub select_product ($self, $context) {
        say "先にコインを入れてください";
    }

    sub dispense ($self, $context) {
        say "払い出す商品がありません";
    }
}

# 他の状態クラスは省略

package VendingMachine {
    use Moo;
    use v5.36;

    has state => (
        is      => 'rw',
        default => sub { WaitingState->new },
        isa    => sub { # 型チェック追加
            my $value = shift;
            die "state must do VendingMachineState role"
                unless $value->does('VendingMachineState');
        },
    );

    has stock => (
        is      => 'rw',
        default => sub { 5 },
    );

    sub set_state ($self, $new_state) {
        $self->state($new_state);
    }
}

# 正しい使い方
my $vm = VendingMachine->new;
say "正常: 初期状態は " . ref($vm->state);

# 間違った使い方(エラーになる)
eval {
    $vm->set_state("文字列を渡してみる");
};
if ($@) {
    say "エラー発生: 文字列は状態として設定できません";
}

実行結果

1
2
正常: 初期状態は WaitingState
エラー発生: 文字列は状態として設定できません

set_stateの時点でエラーが発生し、問題を早期に発見できます。

isaのメリット

isaを使うことで、以下のメリットが得られます。

  • 間違ったオブジェクトが設定された時点でエラーになる
  • エラーメッセージが明確で、デバッグしやすい
  • コードを読む人に「どんなオブジェクトが期待されているか」が伝わる
  • ドキュメントとしての役割も果たす

完成コード

isaを追加した完成コードです。

  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
#!/usr/bin/env perl
use v5.36;
use Moo;

package VendingMachineState {
    use Moo::Role;

    requires 'insert_coin';
    requires 'select_product';
    requires 'dispense';
}

package WaitingState {
    use Moo;
    use v5.36;

    with 'VendingMachineState';

    sub insert_coin ($self, $context) {
        say "コインを受け付けました";
        $context->set_state(CoinInsertedState->new);
    }

    sub select_product ($self, $context) {
        say "先にコインを入れてください";
    }

    sub dispense ($self, $context) {
        say "払い出す商品がありません";
    }
}

package CoinInsertedState {
    use Moo;
    use v5.36;

    with 'VendingMachineState';

    sub insert_coin ($self, $context) {
        say "すでにコインが入っています";
    }

    sub select_product ($self, $context) {
        if ($context->stock > 0) {
            say "商品を選択しました。払い出しを開始します";
            $context->set_state(DispensingState->new);
        }
        else {
            say "申し訳ありません。売り切れです";
            say "コインを返却します";
            $context->set_state(WaitingState->new);
        }
    }

    sub dispense ($self, $context) {
        say "先に商品を選択してください";
    }
}

package DispensingState {
    use Moo;
    use v5.36;

    with 'VendingMachineState';

    sub insert_coin ($self, $context) {
        say "払い出し中です。お待ちください";
    }

    sub select_product ($self, $context) {
        say "払い出し中です。お待ちください";
    }

    sub dispense ($self, $context) {
        say "商品を払い出しました";
        $context->stock($context->stock - 1);
        say "残り在庫: " . $context->stock . "個";
        $context->set_state(WaitingState->new);
    }
}

package VendingMachine {
    use Moo;
    use v5.36;

    has state => (
        is      => 'rw',
        default => sub { WaitingState->new },
        isa    => sub { # 型チェック追加
            my $value = shift;
            die "state must do VendingMachineState role"
                unless $value->does('VendingMachineState');
        },
    );

    has stock => (
        is      => 'rw',
        default => sub { 5 },
    );

    sub set_state ($self, $new_state) {
        $self->state($new_state);
    }

    sub insert_coin ($self) {
        $self->state->insert_coin($self);
    }

    sub select_product ($self) {
        $self->state->select_product($self);
    }

    sub dispense ($self) {
        $self->state->dispense($self);
    }

    sub current_state_name ($self) {
        return ref($self->state);
    }
}

# 動作確認
say "=== 型チェックのデモ ===";
say "";

my $vm = VendingMachine->new;
say "正常な状態オブジェクト: " . $vm->current_state_name;
say "";

say "[操作] コインを投入";
$vm->insert_coin;
say "現在の状態: " . $vm->current_state_name;
say "";

# 型エラーのテスト
say "=== 型エラーのテスト ===";
eval {
    $vm->set_state("無効な値");
};
if ($@) {
    say "エラー: 不正な値は状態として設定できません";
}

今回のポイント

isaを追加することで、型安全性が向上しました。

  • VendingMachineStateロールを持たないオブジェクトは拒否される
  • 間違ったオブジェクトが設定された時点でエラーになる
  • デバッグがしやすくなる

まとめ

  • isaを使って、state属性に設定できるオブジェクトを制限しました
  • VendingMachineStateロールを持つオブジェクトのみ受け付けます
  • 間違ったオブジェクトを設定しようとすると、その時点でエラーになります
  • 型チェックにより、バグの早期発見が可能になりました

次回「第8回-売り切れ状態を追加しよう(OCP実践)」では、新しい状態を追加して、既存コードを変更せずに機能拡張できることを確認します。お楽しみに!

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