Featured image of post 第4回-浅いコピーの罠!装備を持ったモンスターで問題発生 - mass-producing-monsters

第4回-浅いコピーの罠!装備を持ったモンスターで問題発生 - mass-producing-monsters

装備(武器オブジェクト)を持つドラゴンをclone()したら、武器を変更すると元のドラゴンにも影響が!Perlで陥りがちな「浅いコピー(Shallow Copy)」の罠を解説します。

@nqounetです。

「PerlとMooでモンスター軍団を量産してみよう」シリーズの第4回です。

前回の振り返り

前回は、clone()で作ったオブジェクトの属性を変更して、色違いモンスターを量産する方法を学びました。

今回は、もう少し複雑なモンスターを扱います。装備(武器オブジェクト)を持つモンスターです。ここで思わぬ問題が発覚します。

シリーズ全体の目次は以下をご覧ください。

装備を持つドラゴンを作る

より強力なモンスター「ドラゴン」を作りましょう。ドラゴンは武器を装備しています。

まず、武器を表すWeaponクラスを作ります。

 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
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo, MooX::Clone(cpanmでインストール)

use v5.36;

# 武器クラス
package Weapon {
    use Moo;

    has name   => (is => 'ro', required => 1);
    has power  => (is => 'rw', required => 1);

    sub show ($self) {
        say "武器: " . $self->name . " (威力:" . $self->power . ")";
    }
}

# モンスタークラス(武器を装備可能)
package Monster {
    use Moo;
    use MooX::Clone;

    has name    => (is => 'ro', required => 1);
    has hp      => (is => 'rw', required => 1);
    has attack  => (is => 'rw', required => 1);
    has defense => (is => 'rw', required => 1);
    has weapon  => (is => 'rw');  # Weaponオブジェクトを持つ

    sub show_status ($self) {
        say "【" . $self->name . "】HP:" . $self->hp
            . " 攻撃:" . $self->attack
            . " 防御:" . $self->defense;
        if ($self->weapon) {
            $self->weapon->show;
        }
    }

    sub total_attack ($self) {
        my $base = $self->attack;
        my $weapon_power = $self->weapon ? $self->weapon->power : 0;
        return $base + $weapon_power;
    }
}

# 炎の剣を作成
my $fire_sword = Weapon->new(name => '炎の剣', power => 10);

# 炎の剣を装備したドラゴンを作成
my $dragon = Monster->new(
    name    => 'ドラゴン',
    hp      => 100,
    attack  => 20,
    defense => 15,
    weapon  => $fire_sword,
);

$dragon->show_status;
say "総攻撃力: " . $dragon->total_attack;

ドラゴンが武器を持っていますね。ここまでは問題ありません。

ドラゴンを複製してみる

このドラゴンをclone()で複製して、それぞれに別の武器を持たせたいとしましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# ドラゴンを複製
my $dragon2 = $dragon->clone;

# dragon2の武器を「氷の剣」に変えたい!
$dragon2->weapon->name('氷の剣');  # ...あれ、nameはroだからエラー
# 威力だけ変えてみる
$dragon2->weapon->power(20);

say "=== ドラゴン1 ===";
$dragon->show_status;
say "=== ドラゴン2 ===";
$dragon2->show_status;

出力結果:

1
2
3
4
5
6
=== ドラゴン1 ===
【ドラゴン】HP:100 攻撃:20 防御:15
武器: 炎の剣 (威力:20)    ← あれ?威力が20に変わってる!
=== ドラゴン2 ===
【ドラゴン】HP:100 攻撃:20 防御:15
武器: 炎の剣 (威力:20)

おかしい!$dragon2の武器の威力を変えただけなのに、元の$dragonの武器の威力も変わってしまいました。

これが「浅いコピー」の罠

この問題は「浅いコピー(Shallow Copy)」と呼ばれる現象が原因です。

clone()がコピーするのは属性値そのものです。武器属性にはWeaponオブジェクトへのリファレンス(参照)が入っています。浅いコピーでは、このリファレンスがそのままコピーされます。

つまり、$dragon$dragon2は同じ武器オブジェクトを参照しているのです。

1
2
3
$dragon  ──── weapon属性 ──┬──→ Weapon (炎の剣)
$dragon2 ──── weapon属性 ──┘

片方から武器を変更すると、もう片方にも影響してしまいます。

問題を可視化する

この問題をより明確に見てみましょう。

 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
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo, MooX::Clone(cpanmでインストール)

use v5.36;

package Weapon {
    use Moo;
    has name  => (is => 'ro', required => 1);
    has power => (is => 'rw', required => 1);
}

package Monster {
    use Moo;
    use MooX::Clone;

    has name   => (is => 'ro', required => 1);
    has hp     => (is => 'rw', required => 1);
    has attack => (is => 'rw');
    has weapon => (is => 'rw');

    sub show_status ($self) {
        say "【" . $self->name . "】HP:" . $self->hp;
        if ($self->weapon) {
            say "  武器: " . $self->weapon->name
                . " (威力:" . $self->weapon->power . ")";
        }
    }
}

# 武器を作成
my $sword = Weapon->new(name => '炎の剣', power => 10);

# ドラゴンを作成
my $dragon1 = Monster->new(
    name   => 'ドラゴン1',
    hp     => 100,
    attack => 20,
    weapon => $sword,
);

# 複製
my $dragon2 = $dragon1->clone;

say "=== 変更前 ===";
$dragon1->show_status;
$dragon2->show_status;

# dragon2の武器の威力を変更
$dragon2->weapon->power(50);

say "\n=== dragon2の武器を変更した後 ===";
$dragon1->show_status;  # dragon1にも影響!
$dragon2->show_status;

# 同じオブジェクトかどうか確認
say "\n=== 同じ武器オブジェクトを参照している? ===";
say "dragon1の武器: " . $dragon1->weapon;
say "dragon2の武器: " . $dragon2->weapon;
say "同じオブジェクト: " . ($dragon1->weapon == $dragon2->weapon ? 'はい' : 'いいえ');

出力結果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
=== 変更前 ===
【ドラゴン1】HP:100
  武器: 炎の剣 (威力:10)
【ドラゴン1】HP:100
  武器: 炎の剣 (威力:10)

=== dragon2の武器を変更した後 ===
【ドラゴン1】HP:100
  武器: 炎の剣 (威力:50)    ← 変更していないのに50に!
【ドラゴン1】HP:100
  武器: 炎の剣 (威力:50)

=== 同じ武器オブジェクトを参照している? ===
dragon1の武器: Weapon=HASH(0x...)
dragon2の武器: Weapon=HASH(0x...)    ← 同じアドレス
同じオブジェクト: はい

同じオブジェクトを参照していることが確認できました。これが浅いコピーの問題です。

なぜこうなるのか

MooX::Cloneが行うclone()は、以下のようなイメージです。

1
2
3
4
5
6
7
8
9
# MooX::Cloneが内部でやっていること(イメージ)
sub clone ($self) {
    return Monster->new(
        name   => $self->name,     # 値をコピー(OK)
        hp     => $self->hp,       # 値をコピー(OK)
        attack => $self->attack,   # 値をコピー(OK)
        weapon => $self->weapon,   # リファレンスをコピー(問題!)
    );
}

weapon属性に入っているのはWeaponオブジェクトへのリファレンスです。リファレンスをコピーしても、参照先のオブジェクトは同じままです。

文字列や数値はそのまま値がコピーされるので問題ありませんが、オブジェクトや配列リファレンスは「参照がコピーされる」ため、元のデータを共有してしまうのです。

まとめ

  • clone()はデフォルトでは「浅いコピー」を行う
  • 浅いコピーでは、ネストしたオブジェクト(リファレンス)は共有されたまま
  • 片方のオブジェクトからネストしたオブジェクトを変更すると、もう片方にも影響する
  • これは意図しない副作用を生み、バグの原因になる

次回予告

次回は、この問題を解決する「深いコピー(Deep Copy)」を学びます。Perl標準モジュールのStorable::dclone()を使って、ネストしたオブジェクトも含めて完全に独立したコピーを作る方法を紹介します。お楽しみに。

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