__cloneと深いコピーでオブジェクト共有を断つ

cloneはオブジェクトを複製しますが、既定では浅いコピーで、内部のオブジェクトはコピー元と共有されます。__cloneを定義して内部も複製すると、深いコピーに近づけられます。

cloneは浅いコピーしかしない

clone はオブジェクトを複製しますが、既定では浅いコピー(shallow copy)です。プロパティにオブジェクトが入っている場合、そのオブジェクトは複製されず、コピー元とコピー先で同じインスタンスを共有してしまいます。片方をいじったつもりがもう片方も変わっていた、という事故はだいたいこれですね。

class Order {
    public function __construct(public Address $shipTo) {}
}

$a = new Order(new Address('東京'));
$b = clone $a;

$b->shipTo->city = '大阪';
echo $a->shipTo->city; // 大阪 !! 共有されている

スカラー値やその複製は独立しますが、オブジェクトや(参照を含む場合の)配列は共有され得る、と覚えておくと安全です。

__cloneで内部オブジェクトも複製する

この共有を断ちたいなら、__clone() マジックメソッドを定義します。clone 実行時に自動で呼ばれるので、この中で内部のオブジェクトプロパティを改めて clone し直すと、深いコピーに近づけられます。ネストしたオブジェクト側にも __clone() があれば、そこで再帰的に複製されますね。

class Order {
    public function __construct(public Address $shipTo) {}

    public function __clone(): void
    {
        // 内部オブジェクトも複製して共有を切る
        $this->shipTo = clone $this->shipTo;
    }
}

$a = new Order(new Address('東京'));
$b = clone $a;
$b->shipTo->city = '大阪';
echo $a->shipTo->city; // 東京 独立した

コレクションや多段ネストの注意

プロパティがオブジェクトの配列だと、配列自体はコピーされても中の各オブジェクトは共有されたままです。__clone() の中でループを回して一つずつ複製する必要があります。多段にネストしている場合、手書きの clone は漏れが出やすいので、状態が単純なら unserialize(serialize($obj)) で丸ごと深いコピーする手もあります。ただしこちらはリソースやクロージャを含むと使えないので、そこは注意ですね。

class Cart {
    /** @var Item[] */
    public array $items = [];

    public function __clone(): void
    {
        // 配列の中身も一つずつ複製する
        $this->items = array_map(fn($item) => clone $item, $this->items);
    }
}

まとめ

clone は浅いコピーなので、オブジェクトを持つプロパティはコピー元と共有され、思わぬ副作用の温床になります。共有を断ちたいなら __clone() で内部オブジェクトを明示的に複製し、オブジェクトの配列は一つずつコピーする。状態が単純なら serialize 経由で深いコピーする選択肢もあります。「コピーしたのに繋がっている」という不思議なバグに出会ったら、まず clone の深さを疑うのが早いと思います。

よくある質問

Q. cloneすればオブジェクトは完全に独立しますか。
A. いいえ、既定は浅いコピーです。プロパティに入ったオブジェクトはコピー元と共有されます。

Q. 内部オブジェクトも複製するには。
A. __clone() を定義し、その中で内部のオブジェクトプロパティを改めて clone します。

Q. オブジェクトの配列を持つ場合は。
A. 配列自体はコピーされても中身は共有されるので、__clone() 内でループや array_map で一つずつ複製します。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です