PHPStanを使うとオブジェクトの未定義プロパティへのアクセスを警告できて便利だが、必ずしもそうしたくないことがある。
プロパティのオーバーロード
PHPはオーバーロードと呼ばれる機能でメソッドやプロパティの動的アクセスを提供できる。
オブジェクト指向用語の『オーバーロード』の一般的な用法とは異なるので注意。
ただしPHPでもこの機能を利用して挙動を再現することは… できなくはない。
プロパティに関しては、たとえば以下のように実装できる。
<?php
class Row
{
public function __construct(
private array $row
)
{
}
public function __get(string $name)
{
return $this->row[$name];
}
}
$row = new Row($stmt->fetch());
// $row = new Row(['id' => 42, 'name' => 'hoge', 'birthday' => '2112-09-03']);
var_dump([$row->id, $row->name, $row->birthday]);
// => array(3) {
// [0]=> int(42)
// [1]=> string(4) "hoge"
// [2]=> string(10) "2112-09-03"
// }
このコードの弱点は、実際にはアクセス可能であるに拘らずPHPStanは未定義アクセスだと警告することだ。
$this->row
のような配列ではなく、プロパティを定義せずに$this->id
などに未定義のまま代入することもできる。ただしこれはPHP 8.2で非推奨になり、将来PHP 9.0でエラーになることが決まっている。
クラス定義に#[AllowDynamicProperties]
アトリビュートを追加することでエラーは回避できるが、配列だろうと動的プロパティだろうと現在のところ静的解析の積極的な対象とは捉えられていないので、どちらでも大して変わらないと考えてよい。
対応策1: PHPDoc @property
もし一種類のデータ構造とクラス定義を一対一対応できるなら、PHPDocに @property
と書くことでPHPStanは定義済みのプロパティと同様に扱える。
/**
* @property int $id
* @property string $name
* @property string $birthday
*/
class Person
{
/**
* @param array{id: int, name: string, birthday: string} $row
*/
public function __construct(
private array $row,
)
{
}
public function __get(string $name): mixed
{
return $this->row[$name];
}
}
そもそもこれは現代ではあまり有効な手段ではない。プロパティの遅延フェッチのようなものを実装したいのでなければ、PHP 8以降ではこのように書いた方が簡単だからだ。
readonly class Person
{
public function __construct(
public int $id,
public string $name,
public string $birthday,
)
{
}
}
// $row = new Row($stmt->fetch());
$row = new Person(...['id' => 42, 'name' => 'hoge', 'birthday' => '2112-09-03']);
PHPDocのような胡乱なものは必要ではない。
ユニバーサルオブジェクトクレート
ここからが本題だ。そもそも今回提示したような『クラスで配列を内包して__get()
経由でプロパティとして返す』という実装と『一種類のデータ構造とクラス定義を一対一対応させる』というユースケースは合致していない。
このような実装パターンが用いられるのは以下のような場合だろう。
- データベースから動的にフェッチした行
- ヘッドレスCMSのユーザーカスタムプロパティ
- その他APIレスポンスの動的な要素
このようなデータは、典型的には生の配列として返すか、何らかのオブジェクトでラップしてプロパティアクセスを提供することになるだろう。
PsalmやPHPStanのような静的解析ツールは、このような未定義プロパティへのアクセスを前提としたクラスをユニバーサルオブジェクトクレート(universal object crates)と呼んでいる。PHP組み込みクラスのstdClass
やSimpleXMLElement
が典型例であり、PHPStanとPsalmの両方が初期設定でユニバーサルオブジェクトクレートとして認識している。
さて、特定のオブジェクトがユニバーサルオブジェクトクレートであるということは、PHPStanではプロジェクトの設定ファイルに以下のように記述する。
parameters:
universalObjectCratesClasses:
- Cake\Datasource\EntityInterface
- Contentful\Delivery\Resource\Entry
- Dibi\Row
- Ratchet\ConnectionInterface
- Phactory\Sql\Row
- SolrObject
Psalmであればこうなる。
<universalObjectCrates>
<class name="Cake\Datasource\EntityInterface" />
<class name="Contentful\Delivery\Resource\Entry" />
<class name="Dibi\Row" />
<class name="Ratchet\ConnectionInterface" />
<class name="Phactory\Sql\Row" />
<class name="SolrObject" />
</universalObjectCrates>
これで、これらのクラスに対してプロパティアクセスしても怒られることはなくなる。ignoreErrors
にいちいち追加するなどという、愚かしいことはしなくてもよい。世界は救われた。
ユニバーサルオブジェクトクレートは使うべきか?
あなたが単なるフレームワークやライブラリの一般ユーザーなら、話はここでおしまい。
ただ、あなたがこのようなクラスを設計し提供する立場なら、本当にそんなことをすべきかは一考の余地はあるだろう。
__get()
のようなマジックメソッド(オーバーロード)や動的プロパティのセットはPHPオブジェクトの動的な性質を高められるが、逆にいえば静的解析の放棄に他ならない。実装方法によっては、オプショナルなプロパティと単なるプロパティのタイプミスの区別はつけられないだろう。
個人的な意見では、データ構造に対応するクラスを用意できないような動的な要素やカスタムプロパティのようなものはオーバーロードプロパティ(__get()
)経由ではなく、メソッド経由でのアクセスを提供するか、あるいはArrayAccess::offsetGet()
経由でのアクセスを提供した方がましなのではなかろうか。
これは「自分たちがどうしたいか」「ユーザーや開発者にどうさせたいか」という設計の問題なので、それぞれの良し悪しのようなものは各自で考えてもらいたい。
あとがき
universalObjectCratesClasses
について、実はPHPStanの設定リファレンスにはちゃんと書いてあるのだが(それも割と上の方)、PHPStanを使っている各位はあまり説明書を読まずに使うタイプだということがわかる。
……という記事を一年くらい前にも書いたつもりだったが、特に宣伝とかしていなかったので、この三ヶ月くらい説明する度に初耳だと言われるので改めて書いた次第。