このBuilderパターン実装シリーズではPHPでどのようにBuilderパターンを実装するのかを解説する。前回は、ImmutableなBuilderを実装する方法を解説した。
前回: PHP: Builderパターンの実装手順 #3【Immutable Builder実装】 - Qiita
今回は、生成対象のオブジェクトをBuilder経由でのみ生成できるようにする方法を紹介する。
本稿で紹介する方法は、PHPの言語仕様の限界にチャレンジする側面があるので、実装としてはトリッキーで必ずしも美しくないかもしれないが、オブジェクトをBuilder経由でのみ生成させたいときには「こういった方法もあるよ」ということを示したいと思う。
なお、本稿で実装したコードの完全版はGitHubにて公開している。
なぜBuilder経由でのみ生成できるようにしたいか?
前回までに作ってきたEmailクラスはnew Email
することでEmailBuilder
を使わなくても生成することが可能だった:
new Email(
['alice@example.com'],
['bob@example.com'],
[],
'Hello',
'Hello, there.'
);
このコードは動作するし、問題なさそうだ。では、なぜnew Email
をさせたくないのか? そのモチベーションはいくつか考えられる。
- コードの一貫性: すべての生成を
EmailBuilder
経由にすることで、実装のゆれをなくしたい。ここはnew
で、ここはビルダーで、となっていると読み手は混乱するし、ひとつのことを複数の方法でできるようになっているのは美しくない。 - バグりにくさ:
new Email
は引数が多く、注意して書かないとバグを生みやすい。そうならないよういするためにビルダーを作っている。 - ビルダーへの誘導:
EmailBuilder
という便利なクラスがあることに気づかなければ、new Email
を頑張って書いてしまうだろう。コード上でnew Email
できないようになっていれば、EmailBuilder
を自然と使ってくれるだろう。 - 保守性の観点:
EmailBuilder
を通じて生成されるよう一貫したコードになっていれば、EmailBuilder
をメンテするだけで生成の処理をアップデートしていける。もし、new
が併用されていたら、どちらもメンテしていかなければならない。
new Email
を封じる
ビルダー経由でのみEmail
オブジェクトを作れるようにするための第一歩は、new Email
を実行できないようにすることだ。そのためには、Email
クラスのコンストラクタをプライベートメソッドにする。public function __construct()
となっていた部分をprivate function __construct()
に変更しよう。そうすれば、Email
クラス以外からnew Email
は呼び出せなくなる:
final class Email
{
// ...略...
private function __construct(
array $from,
array $to,
array $cc,
string $subject,
string $body
) {
$this->from = $from;
$this->to = $to;
$this->cc = $cc;
$this->subject = $subject;
$this->body = $body;
}
// ...略...
}
EmailBuilder
だけはnew Email
できるようにする
このままだと、EmailBuilder
までもnew Email
できない。それだと、意味のないコードになってしまうので、EmailBuilder
だけは、new Email
できるようにする。
PHPの言語仕様上、そういったことをできるようにする都合のいい構文はない1ので、トリッキーな方法だがプライベートコンストラクタをClosure
を使って呼び出す小技を使うことにする。
まずは、Email
クラスにEmailBuilder
を返す静的メソッドを生やす:
final class Email
{
// ...略...
public static function builder(): EmailBuilder
{
return new EmailBuilder();
}
// ...略...
}
EmailBuilder
が間接的にnew Email
できるように、Email
クラスのコンストラクタをEmailBuilder
に渡すようにする:
public static function builder(): EmailBuilder
{
$constructor = function (
array $from,
array $to,
array $cc,
string $subject,
string $body
) {
return new Email($from, $to, $cc, $subject, $body);
};
return new EmailBuilder($constructor);
}
この変更はクライアントコードにも影響がある。これまでは、new EmailBuilder()
でビルダーを作っていたが、これからはEmail::builder()
でビルダーを作る必要がある。
// 旧コード
$email = (new EmailBuilder())
->setFrom('alice@example.com')
->setTo('bob@example.com')
->setCc('carol@example.com')
->setSubject('Hello')
->setBody('Hello, Bob.')
->build();
// 新コード
$email = Email::builder()
->setFrom('alice@example.com')
->setTo('bob@example.com')
->setCc('carol@example.com')
->setSubject('Hello')
->setBody('Hello, Bob.')
->build();
EmailBuilder
でEmail
のコンストラクタを受け取れるようにする
次に、EmailBuilder
を変更して、Email
のコンストラクタを受け取れるようにする。EmailBuilder
クラスにcallable
型の引数を受け取るコンストラクタ追加し、それを$emailConstructor
に代入するようにしよう:
final class EmailBuilder
{
/**
* @var callable
*/
private $emailConstructor;
// ...略...
public function __construct(callable $emailConstructor)
{
$this->emailConstructor = $emailConstructor;
}
// ...略...
}
new Email
は直接呼び出せなくなったので、EmailBuilder::build
は受け取ったコンストラクタを使うように変更する:
public function build(): Email
{
$this->assertAtLeastOneFromAddress();
$this->assertAtLeastOneRecipientAddress();
$this->assertSubjectIsProvided();
$this->assertThatBodyIsSpecified();
return ($this->emailConstructor)( // new Emailしていた箇所
$this->from,
$this->to,
$this->cc,
$this->subject,
$this->body
);
}
以上で、EmailBuilder
の対応は完了だ。
テストコードを変更する
前回までに作ったテストコードではnew EmailBuilder()
と書いていたが、今回の変更でEmail::builder()
に書き換える必要が出てきたのでその対応をする。対応箇所は数が少なくないのが、エディタの一括置換で直せば良さそうだ。
use PHPUnit\Framework\TestCase;
final class EmailTest extends TestCase
{
/**
* @test
*/
public function email_builder_usage(): void
{
$email = Email::builder() // これまでは (new EmailBuilder()) となっていた
->setFrom('alice@example.com')
->setTo('bob@example.com')
->setCc('carol@example.com')
->setSubject('Hello')
->setBody('Hello, Bob.')
->build();
// The Email object will be like the following:
self::assertSame(['alice@example.com'], $email->getFrom());
self::assertSame(['bob@example.com'], $email->getTo());
self::assertSame(['carol@example.com'], $email->getCc());
self::assertSame('Hello', $email->getSubject());
self::assertSame('Hello, Bob.', $email->getBody());
}
// ...略...
}
すべて変更したら、テストが通るかチェックしよう。
リファクタリングする
テストがすべて通ることを確認したら、テストを回しながらEmail::builder()
をリファクタリングしていこう。下記のコードはもっとシンプルにすることができるからだ:
public static function builder(): EmailBuilder
{
$constructor = function (
array $from,
array $to,
array $cc,
string $subject,
string $body
) {
return new Email($from, $to, $cc, $subject, $body);
};
return new EmailBuilder($constructor);
}
まず、一時変数を無くす。
public static function builder(): EmailBuilder
{
return new EmailBuilder(
function (
array $from,
array $to,
array $cc,
string $subject,
string $body
) {
return new Email($from, $to, $cc, $subject, $body);
}
);
}
次に、Email
はself
に置き換えられるのでそれも直す:
public static function builder(): EmailBuilder
{
return new EmailBuilder(
function (
array $from,
array $to,
array $cc,
string $subject,
string $body
) {
return new self($from, $to, $cc, $subject, $body);
}
);
}
new self
への引数はクロージャの引数をそのまま渡せばいいので、可変長引数を使うように変える:
public static function builder(): EmailBuilder
{
return new EmailBuilder(
function () {
return new self(...func_get_args());
}
);
}
これで最もシンプルな記述になった。
最後に、new EmailBuilder()
に渡す変数が何なのか読み手に伝わりやすくするために、クロージャーを作る処理を適切な名前をつけたプライベートメソッドに切り出す。ここでは切り出したメソッドはgetEmailConstructor
と名付ける。
final class Email
{
// ...略...
public static function builder(): EmailBuilder
{
return new EmailBuilder(self::getEmailConstructor());
}
// ...略...
private static function getEmailConstructor(): callable
{
return function () {
return new self(...func_get_args());
};
}
}
クロージャー構文を直接EmailBuilderのコンストラクタに書くよりも説明的になったのではないだろうか。
これで、リファクタリングは完了となる。
今回のパターンの問題点
今回紹介したパターンは、確かにビルダー経由での生成を強制することに成功したが、問題点がまったくないわけではない。
- まず、トリッキーである。クロージャを使ってprivateコンストラクタにアクセスしているので初見殺し感はいなめない。
- 次に、EmailとEmailBuilderが密結合になっている。そして循環依存になっている。本当ならEmailがEmailBuilderを知らないほうが設計としては優れている。
これらの問題があるが、PHPにおいてはこれはトレードオフだと思うので、問題点を理解した上で使う分には有りだと思う。
まとめ
- Builderパターンの実装において、生成対象のオブジェクトをBuilder経由でのみ生成できるようにする方法を紹介した。
- それに当たってのテストコードの変更方法を説明した。
- 紹介した方法にある問題点と、それにはトレードオフがあることを説明した。
関連
- PHP: Builderパターンの実装手順 #1【基礎実装】
- PHP: Builderパターンの実装手順 #2【Fluent Interface実装】
- PHP: Builderパターンの実装手順 #3【Immutable Builder実装】
-
PHP: Builderパターンの実装手順 #4【Builder経由での生成を強制する】
この投稿
-
他の言語だと、例えばJavaのinner classやScalaのスコープを指定したprivate修飾子があったりするが、PHPにはそれに該当する機能がない。 ↩