24
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

PHP: Builderパターンの実装手順 #3【Immutable Builder実装】

Last updated at Posted at 2019-02-21

前回は、Builderパターンにfluent interfaceを取り入れると、Builderを使う側のコード(クライアントコード)の可読性が高まることを説明した。

前回: PHP: Builderパターンの実装手順 #2【Fluent Interface実装】 - Qiita

今回は、それを更に発展させた形としてイミュータブル(immutable)なBuilderを実装する手順を説明する。なお、本稿で実装したコードの完全版はGitHubで公開している。

イミュータブルとは?

イミュータブルとは日本語では不変と訳される。不変とは読んで字のごとく、変わらないことである。オブジェクトにおいては、オブジェクトが生成されてから消滅されるまで、そのプロパティが一切変更されない性質1を持つオブジェクトはイミュータブルなオブジェクトということになる。PHPではDateTimeImmutableExceptionはimmutableなオブジェクトである。

PHPでオブジェクトをイミュータブルにする方法は?

セッターを持つオブジェクトをイミュータブルにする方法は、下記の投稿で詳しく説明した:

概要だけいうと、セッターは$thisから新しいオブジェクトを作り、新オブジェクトの値を変更し、それを返すようにする。そうすれば、イミュータブルなオブジェクトにすることができる:

final class Product
{
    private $name;

    public function setName(string $name): self
    {
        $new = clone $this;
        $new->name = $name;
        return $new;
    }
}

EmailBuilderをイミュータブルにする

イミュータブルじゃないBuilder実装の振り返り

前回までに作ったEmailBuilderをイミュータブル化するにあたって、前回の実装を振り返っておこう。前回は、setFromなどのセッターをfluent interfaceにした。そのため、各セッターは自インスタンス($this)を返すようにした。fluent interface化の変更影響はEmailBuilderだけでEmailクラスは第1回から変更されていない。

final class EmailBuilder
{
    /**
     * @var string[]
     */
    private $from = [];

    // ...他のプロパティ...

    public function setFrom(string ...$from): self
    {
        $this->from = $from;
        return $this;
    }
    
    // ...他のセッター...

    public function build(): Email
    {
        $this->assertAtLeastOneFromAddress();
        $this->assertAtLeastOneRecipientAddress();
        $this->assertSubjectIsProvided();
        $this->assertThatBodyIsSpecified();
        return new Email(
            $this->from,
            $this->to,
            $this->cc,
            $this->subject,
            $this->body
        );
    }

   // ...assert系メソッド...
}

イミュータブル化するにあたっての影響範囲

このEmailBuilderをイミュータブルにする際も、Emailクラスに手を加える必要はない。また、EmailBuilderのクライアントコードも変更する必要がない。つまり、テストコードEmailTestはイミュータブル化したEmailBuilderのテストに使うことができる。

イミュータブルにする方法

EmailBuilderはプロパティ(属性)が多いので、「PHP: イミュータブルなオブジェクトの実装方法(属性が多いとき)」で説明したアプローチを取る。

例えば、setFromメソッドは次のように実装を書き換える:

final class EmailBuilder
{
    /**
     * @var string[]
     */
    private $from = [];

    // ...他のプロパティ...

    public function setFrom(string ...$from): self
    {
        $new = clone $this; // コピーした新インスタンスを作る
        $new->from = $from; // 新インスタンスの from を変更する
        return $new; // 新インスタンスを返すようにする
    }

    // ...以下略...
}

前回でfluent interface化しておいたので、戻り値の型宣言selfはそのままでよい。もし、fluent interface化の手順を飛ばしている場合は、戻り値の型宣言を: voidから: selfに変更する必要がある。

他のセッターも同様に変更していく:

    public function setTo(string ...$to): self
    {
        $new = clone $this;
        $new->to = $to;
        return $new;
    }

    public function setCc(string ...$cc): self
    {
        $new = clone $this;
        $new->cc = $cc;
        return $new;
    }

    public function setSubject(string $subject): self
    {
        $new = clone $this;
        $new->subject = $subject;
        return $new;
    }

    public function setBody(string $body): self
    {
        $new = clone $this;
        $new->body = $body;
        return $new;
    }

以上でBuilderのイミュータブル化は完了となる。

イミュータブルさを確かめるテストを書く

Builderが本当にイミュータブルになったかを確かめるために、これまでに作ったEmailTestクラスにテストを追加する。イミュータブルさのテストのしかたはいろいろあると思うが、セッターを叩いて新しいインスタンスが帰ってきていることが確認できれば、イミュータブルさが満たされていると判断できるので、assertNotSameでテストしていく:

use PHPUnit\Framework\TestCase;

final class EmailTest extends TestCase
{
    // ...その他のテスト...

    /**
     * @test
     * @testdox EmailBuilderはイミュータブルである
     */
    public function immutability(): void
    {
        $builder1 = new EmailBuilder();
        $builder2 = $builder1->setFrom('alice@example.com');
        $builder3 = $builder1->setFrom('alice@example.com');
        self::assertEquals($builder2, $builder3);  // 状態は同じだけど、
        self::assertNotSame($builder2, $builder3); // インスタンスは異なる。
        self::assertNotSame($builder1, $builder1->setTo('dummy@recipient'));
        self::assertNotSame($builder1, $builder1->setCc('dummy@recipient'));
        self::assertNotSame($builder1, $builder1->setSubject('dummy_subject'));
        self::assertNotSame($builder1, $builder1->setBody('dummy_body'));
    }

    // ...その他のテスト...
}

Builderをイミュータブルにしたことで発生するメリット

Builderをイミュータブルにすると何が嬉しいかというと、途中まで作っておいたビルダーを再利用できるというところだ。

たとえば、パスワードリセットメールのひな形を予め作っておける。

$emailTemplate = (new EmailBuilder())
    ->setFrom('webmaster@example.com')
    ->setSubject('Choose a new password')
    ->setBody(
        'Someone requested a new password for ... account.' .
        'Click here to reset your password: https://...' .
        "If you didn't make this request then you can safely ignore this email :)"
    );

これを再利用すると、必要な部分を埋めるだけでメールが作れるようになる。また、変えたいところだけ変えることもできる。

$emailForAlice = $emailTemplate
    ->setTo('alice@example.com') // 埋める必要がある部分
    ->setSubject('Please reset your password') // ひな形から変えたい部分
    ->build();

このようにセッターを実行しても、$emailTemplateはイミュータブルなので、ひな形が書き換わることがない。安心して再利用することができるわけだ。

こうした使い方はテストコードでドキュメント化しておくと良いだろう:

use PHPUnit\Framework\TestCase;

final class EmailTest extends TestCase
{
    /**
     * @test
     * @testdox EmailBuilderはイミュータブルなので再利用できる
     */
    public function builder_is_immutable_so_that_you_can_reuse_it(): void
    {
        // 1. Prepare preconfigured EmailBuilder:
        $emailTemplate = (new EmailBuilder())
            ->setFrom('webmaster@example.com')
            ->setSubject('Choose a new password')
            ->setBody(
                'Someone requested a new password for ... account.' .
                'Click here to reset your password: https://...' .
                "If you didn't make this request then you can safely ignore this email :)"
            );

        // 2. By reusing it you can build different emails:
        $aliceEml = $emailTemplate
            ->setTo('alice@example.com')
            ->setSubject('Please reset your password')
            ->build();
        $bobEml = $emailTemplate
            ->setTo('bob@example.com')
            ->build();

        // Built emails are actually different:
        self::assertSame('Please reset your password', $aliceEml->getSubject());
        self::assertSame('Choose a new password', $bobEml->getSubject());
        self::assertSame(['alice@example.com'], $aliceEml->getTo());
        self::assertSame(['bob@example.com'], $bobEml->getTo());
    }
}

セッターのリファクタリング

今回、セッターをいくつか変更したが、どのセッターも同じような内容の冗長的なコードになったので、リファクタリングしておくことにする。

どのメソッドもだいたい次のような構造として一般化できるので、

    public function set◯◯◯(◯◯◯): self
    {
        $new = clone $this;
        $new->◯◯◯ = $◯◯◯;
        return $new;
    }

この構造を抽出した別メソッドを定義し、copyメソッドと名付ける:

final class EmailBuilder
{
    // ...
    private function copy(string $key, $value): self
    {
        $new = clone $this;
        $new->{$key} = $value;
        return $new;
    }
    // ...

これを各セッターで使うようにする:

final class EmailBuilder
{
    // ...
    public function setFrom(string ...$from): self
    {
        return $this->copy('from', $from);
    }

    public function setTo(string ...$to): self
    {
        return $this->copy('to', $to);
    }

    public function setCc(string ...$cc): self
    {
        return $this->copy('cc', $cc);
    }

    public function setSubject(string $subject): self
    {
        return $this->copy('subject', $subject);
    }

    public function setBody(string $body): self
    {
        return $this->copy('body', $body);
    }
    // ...

あとはテストをで動くかを確認して、問題なければリファクタリングは完了となる。

まとめ

  • Builderパターンをイミュータブルする方法を紹介した。
  • イミュータブルなBuilderは再利用しやすい。

関連

  1. 完全にプロパティの変更がないものに限らず、少なくともクライアントコードからはイミュータブルに見えればイミュータブルな性質を持つオブジェクトとみなせる。

24
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?