前回は、Builderパターンにfluent interfaceを取り入れると、Builderを使う側のコード(クライアントコード)の可読性が高まることを説明した。
前回: PHP: Builderパターンの実装手順 #2【Fluent Interface実装】 - Qiita
今回は、それを更に発展させた形としてイミュータブル(immutable)なBuilderを実装する手順を説明する。なお、本稿で実装したコードの完全版はGitHubで公開している。
イミュータブルとは?
イミュータブルとは日本語では不変と訳される。不変とは読んで字のごとく、変わらないことである。オブジェクトにおいては、オブジェクトが生成されてから消滅されるまで、そのプロパティが一切変更されない性質1を持つオブジェクトはイミュータブルなオブジェクトということになる。PHPではDateTimeImmutable
やException
は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は再利用しやすい。
関連
- PHP: Builderパターンの実装手順 #1【基礎実装】
- PHP: Builderパターンの実装手順 #2【Fluent Interface実装】
- PHP: Builderパターンの実装手順 #3【Immutable Builder実装】
この投稿
- PHP: Builderパターンの実装手順 #4【Builder経由での生成を強制する】
-
完全にプロパティの変更がないものに限らず、少なくともクライアントコードからはイミュータブルに見えればイミュータブルな性質を持つオブジェクトとみなせる。 ↩