これはBEAR.Sunday Advent Calendar 2024の17日目の記事です。
はじめに
2024年に追加されたPHP8.4のLazy Objects機能は、コア言語レベルでゴースト(Ghost)やプロキシ(Proxy)オブジェクトによる遅延初期化を可能にする新たな仕組みです。これにより、SymfonyやDoctrine ORMのような大規模フレームワークで膨大なオブジェクト生成が最適化されることが期待されています。
この新機能は、「いつ、どこで、どのようにオブジェクトを初期化するか」という問題に対し、言語コアが直接アプローチするものです。
マーティン・ファウラーの4つの遅延ロード戦略
マーティン・ファウラーは『Patterns of Enterprise Application Architecture』において、以下の4つの遅延ロード戦略を定義しています。
1. Lazy Initialization(単純な遅延初期化)
プロパティにアクセスする際、まだ未初期化であれば、その時点で必要なデータをロードします:
class UserProfile {
private ?Image $avatar = null;
public function getAvatar(): Image {
if ($this->avatar === null) {
$this->avatar = new Image('default.jpg');
}
return $this->avatar;
}
}
2. Value Holder(値ホルダ)
実オブジェクトへの参照を保持するホルダオブジェクトを用い、アクセスされたときに初期化します:
class ValueHolder {
private mixed $value = null;
private bool $loaded = false;
private $loader;
public function __construct(callable $loader) {
$this->loader = $loader;
}
public function getValue(): mixed {
if (!$this->loaded) {
$this->value = ($this->loader)();
$this->loaded = true;
}
return $this->value;
}
}
3. Virtual Proxy(仮想プロキシ)
実オブジェクトと同じインターフェースを持つ代理オブジェクトを用意し、プロキシ越しのアクセス時に初期化します:
class DocumentProxy implements DocumentInterface {
private ?Document $realDocument = null;
private string $documentId;
public function __construct(string $documentId) {
$this->documentId = $documentId;
}
public function getContent(): string {
if ($this->realDocument === null) {
$this->realDocument = DocumentLoader::load($this->documentId);
}
return $this->realDocument->getContent();
}
}
4. Ghost Object(ゴーストオブジェクト)
初期化前は「空っぽ」の実体を持つオブジェクトで、プロパティアクセスがあった時点で実体化します:
class GhostUser {
private string $id;
private ?string $name = null;
private ?string $email = null;
public function __construct(string $id) {
$this->id = $id;
}
public function getName(): string {
$this->loadIfNeeded();
return $this->name;
}
private function loadIfNeeded(): void {
if ($this->name === null) {
$data = UserLoader::loadUserData($this->id);
$this->name = $data['name'];
$this->email = $data['email'];
}
}
}
Virtual ProxyやGhost Objectsはクラス本体を大幅に変更せずに外部から遅延性を付与できる手法です。従来はユーザーランドでマジックメソッドやコード生成を多用する必要がありましたが、PHP8.4ではこれを言語レベルで解決できるようになりました。
遅延束縛
PHPのDIコンテナの多くが遅延束縛をサポートしています。これはさきほどのLazy Objectsと違って、束縛の時に遅延させるだけでコンストラクタなどでインジェクトされた時にはもう生成されています。
DIコンテナにおける遅延束縛
一般的なDIコンテナでは、以下のような方法で遅延束縛を実現します:
// 即時束縛
$container->bind(UserRepository::class, DatabaseUserRepository::class);
// 遅延束縛
$container->bind(UserRepositoryInterface::class, function() {
return new DatabaseUserRepository(
new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass')
);
});
遅延束縛では、実際にそのインスタンスが必要になるまでオブジェクトの生成を遅らせます。
このように束縛に無名関数を使うとシリアライズができません。リクエストの度に束縛を繰り返すことになりパフォーマンスとしても不利です。この問題に対処するためにRay.Diでは専用のProviderクラスを使います。
Ray.DiのProviderInterface
による実装
Ray.DiではProviderInterface
を使用することで、より明示的な形で遅延束縛を実現できます。また、Providerにも依存注入ができ、InjectionPoint
を用いて注入先の情報を参照できる特徴があります:
class UserRepositoryProvider implements ProviderInterface
{
public function __construct(
private PDOProvider $pdoProvider,
private InjectionPoint $ip
){}
public function get(): UserRepository
{
// 注入先のクラス情報を取得
$targetClass = $this->ip->getClass();
return new DatabaseUserRepository(
$this->pdoProvider->get()
);
}
}
// バインディング
$module->bind(UserRepositoryInterface::class)->toProvider(UserRepositoryProvider::class);
この方式には以下のような特徴があります:
-
明示的な契約:
ProviderInterface
の実装により、依存関係の提供方法が明確になります - テスト容易性: Providerをモック化することで、テストが容易になります
- 関心の分離: オブジェクトの生成ロジックをProviderクラスに分離できます
- Provider自身への依存注入: Providerにも他のProviderや依存を注入できます
-
注入先情報の参照:
InjectionPoint
により、注入先のコンテキストに応じた生成が可能です
また、Ray.Diでは#[Set]
アトリビュートを使用することで、ユーザーコードレベルで遅延生成を制御できます:
class UserService
{
/**
* @param ProviderInteterface<UserRepository> $userRepoProvider
*/
public function __construct(
// インジェクトされるのは最小限のファクトリー
#[Set] private ProviderInteterface $userRepoProvider
) {}
public function findUser(string $id): User
{
// 実際に必要になった時点でProviderからインスタンスを取得
return $this->userRepoProvider->get()->findById($id);
}
}
明示的生成と暗黙的生成の比較
Ray.DIのプロバイダ束縛による明示的生成
class UserService
{
/**
* @param ProviderInteterface<UserRepository> $userRepoProvider
*/
public function __construct(
// 明示的に遅延生成することを宣言
#[Set] private ProviderInteterface $userRepoProvider
) {}
public function findUser(string $id): User
{
// 明示的なget()呼び出しで生成
$repo = $this->userRepoProvider->get();
return $repo->findById($id);
}
}
Ray.DIのプロバイダ束縛による遅延生成の特徴:
第一の特徴は明示性です。アトリビュートによる遅延生成の宣言と、get()メソッドによる明示的な生成タイミングの制御により、コードを読むだけで遅延生成の意図が明確になります。
次に、型安全性が挙げられます。phpdocsのジェネリクス(ProviderInteterface)によってget()
で得られる型が保証され、IDEによる補完やリファクタリングのサポートも受けられます。
Providerクラスの作成は不要です。ボイラープレートコード削減にもなります。
PHP8.4 Lazy Objectsによる暗黙的生成
class UserService
{
public function __construct(
private UserRepository $userRepo
) {}
public function findUser(string $id): User
{
// 暗黙的に初期化される
return $this->userRepo->findById($id);
}
}
Lazy Objectsの最大の特徴は、その透過性にあります。通常のオブジェクトと同じように扱えるため、特別な記法や追加のコードは不要です。フレームワークやランタイムが最適化を担当するため、開発者は実装の詳細を意識する必要がありません。
ただし、初期化のタイミングが暗黙的で、コードからは遅延生成されることが分かりにくいという課題もあります。
3つの遅延アプローチの比較
遅延束縛、プロバイダ束縛、Lazy Objectsは、それぞれ異なるレベルで遅延初期化を実現します。
遅延束縛はDIコンテナレベルでの遅延で、インジェクト時にはすでにオブジェクトが生成されています。一方、プロバイダ束縛とLazy Objectsはインジェクト後も生成を遅らせることができ、より深いレベルでの最適化が可能です。
Ray.DIのプロバイダ束縛はProviderInterface
を通じて明示的なAPIを提供します。get()
メソッドを呼び出すまでオブジェクトは生成されず、その時点で初めて生成が行われます。このアプローチは、生成のタイミングを完全に制御できる一方で、get()
の呼び出しを意識する必要があります。ただし、これは制約というよりも設計の意図を明確にする利点として捉えることができます。
PHP8.4のLazy Objectsはより透過的なアプローチを取ります。インジェクトされたオブジェクトは通常のオブジェクトと同じように扱えますが、内部的にはプロキシとして機能し、実際の操作が必要になるまで生成を遅らせます。これにより、開発者は遅延生成を意識することなくパフォーマンスの最適化を得られます。
結論
遅延というキーワードでそれぞれを見てきましたが、それぞれが異なる目的を持ったものです。Lazy Objectsはエンティティの遅延ロードに特に有効です。関連エンティティの大量ロードを避けたい場合や、透過的な最適化が必要な場面で力を発揮します。
一方、遅延束縛はDIコンテナレベルでの最適化手法です。処理の開始時に全てのオブジェクトを生成するのではなく、必要になった時点で初めて生成することで、アプリケーションの起動を高速化します。ただし、一度インジェクトされたオブジェクトは即座に生成されるため、その後の遅延制御はできません。
Ray.DIのプロバイダ束縛は、ユーザーコードレベルで遅延生成を制御できる唯一の手法です。これは単なるパフォーマンス最適化ではなく、オブジェクト生成のタイミングを設計の一部として扱えるようにする仕組みです。開発者はget()
メソッドを通じて、明示的に生成タイミングを制御できます。
追記
ソフトウェア開発には興味深いパラドックスが存在します。私たちは問題を解決しようとして新しい抽象化層を導入し、その抽象化層自体が新たな複雑性を生み、それがまた新しい問題を産む...。
Object-Relational Mapping is the Vietnam of Computer Science (ORMはコンピュータサイエンスのベトナム戦争だ)というTed Neward氏の有名な記事があります。
当初は「単純な」マッピング問題として始まったものが、次第により深い複雑さを招き、常に戦力投入が必要とされる泥沼状態になっていく1 - LazyObjectsが行う遅延生成はこの最新戦力投入といえるかもしれません。アプリケーションのデータモデルが膨れ上がり、パフォーマンス要求を満たすには単純なオブジェクト生成戦略では非効率が目立ちます。その非効率を解消するために、コード生成やプロキシ、ゴーストオブジェクトなどが投入され、ついにPHPコアレベルでサポートすることでフレームワークレベルの複雑性を軽減しようとしています。
PHP8.4でLazy Objectsがコア機能として導入されることは、PHPエコシステムにとって大きな転換点です。これまでユーザーランドで実装されてきた様々な最適化手法が、言語レベルで統合・標準化される流れは、フレームワーク間の共通基盤になりますが、この進化は「抽象化レイヤーを言語コアに取り込む」という、新たなパラドックスを生み出す可能性もあります。これまでは「フレームワークの責任」だった領域が「言語の責任」へとシフトしているということです。
PHP8.4のLazy Objectsによって、遅延ロードは言語レベルで透過的に行えるようになりました。そういった言語の進化を祝福しながら、複雑性そのものを問い直すことも必要かもしれません。
-
2006年の記事であり、当時指摘された問題の多くは現在では解消・軽減されていることには留意しながら、本記事の趣旨として紹介しました。 ↩