交差型とDNF型で「両方を実装していること」を型で要求する

交差型は Traversable&Countable のように書き、並べたインターフェースやクラスをすべて満たすオブジェクトだけを受け取る型宣言です。PHP 8.1で追加されました。

ユニオン型が「AかBのどちらか」を書く道具だとすれば、こちらはその逆で「AでもありBでもある」を書く道具です。地味な機能なのですが、実際に書き始めてみると「あ、これ今までメソッドの先頭で instanceof を並べてやっていたやつだ」と気づくことが多い。私はテストコードから使い始めました。

なぜ「両方を実装していること」が書けなかったのか

クラスの継承は単一ですが、インターフェースはいくつでも実装できます。ところが受け取る側の型宣言では、8.0のユニオン型が入ったあとも「複数の型を同時に要求する」ことはできませんでした。結果として、こういうコードが量産されます。

function summarize(Traversable $items): string
{
    if (!$items instanceof Countable) {
        throw new InvalidArgumentException('Countable も実装してください');
    }

    return count($items) . '件';
}

型で言えないことを実行時チェックと例外で埋め合わせている状態ですね。呼び出し側から見ると、シグネチャを読んだだけでは Countable も必要だと分かりません。IDEも静的解析も助けてくれない。

交差型はどう書く?

型名を & でつなぐだけです。パラメータ、戻り値、プロパティのいずれにも書けます。

function summarize(Traversable&Countable $items): string
{
    return count($items) . '件';
}

final class ItemList implements IteratorAggregate, Countable
{
    public function __construct(private readonly array $items) {}

    public function getIterator(): ArrayIterator
    {
        return new ArrayIterator($this->items);
    }

    public function count(): int
    {
        return count($this->items);
    }
}

echo summarize(new ItemList(['a', 'b', 'c'])); // 3件

チェックのコードが丸ごと消えて、代わりに要求がシグネチャに現れました。満たさない値を渡すと TypeError になります。

ちなみに Traversable はユーザーコードから直接 implements できない内部インターフェースですが、型宣言に書く分には問題ありません。IteratorIteratorAggregate を実装していれば自動的に満たされます。

書ける型はかなり狭い

交差型のメンバーになれるのはクラス名とインターフェース名だけです。ここは制限が厳しいので、最初にまとめて把握しておいたほうが早いと思います。

class Sample
{
    // Fatal error: Type string cannot be part of an intersection type
    public function a(string&int $v) {}

    // iterable も不可(実体が array|Traversable のユニオン型なので)
    public function b(iterable&Countable $v) {}

    // self / static / parent も不可
    public function c(self&Countable $v) {}

    // Fatal error: Duplicate type Traversable is redundant
    public function d(Traversable&Countable&Traversable $v) {}
}

arrayvoidmixedcallablenevernull あたりも全部だめです。要するに「オブジェクトの型」だけを組み合わせる機能だと思っておけばいい。

重複の検出はコンパイル時に、名前解決した文字列の比較だけで行われます。class_alias() で別名を付けた実質同じクラスを並べても、PHPは気づきません。継承関係も解決されないので、Iterator&Traversable のような冗長な指定もそのまま通ります。この手の指摘は静的解析ツールの仕事ですね。

もうひとつ、クラス名同士を & でつなぐのは構文としては通りますが、PHPに多重継承がない以上、片方がもう片方を継承していない限りその型を満たすオブジェクトは存在しません。書けてしまうけれど誰も通れない型になります。

PHP 8.1では null と組み合わせられない

8.1の実装は「純粋な交差型(pure intersection types)」と呼ばれていて、ユニオン型との混在が一切できません。nullable も例外ではないので、8.1では次の2つが両方とも通りません。

// PHP 8.1
function f(?Traversable&Countable $v) {}         // Parse error

function g(Traversable&Countable $v = null) {}   // Fatal error:
// Cannot use null as default value for parameter $v of type Traversable&Countable

デフォルト値 null による暗黙のnullableも封じられています。ここでつまずいて「使えないな」と引き返した人は結構いるんじゃないかと思います。実際、私も最初はそうでした。

PHP 8.2のDNF型で混ぜられるようになった

PHP 8.2でDNF型(Disjunctive Normal Form Types)が入り、交差型とユニオン型を組み合わせられるようになりました。名前は仰々しいですが、要は「ANDのかたまりをORでつなぐ形に限って許す」というだけの話です。

// PHP 8.2 以降
function find(int $id): (Traversable&Countable)|null
{
    return null;
}

交差のかたまりには括弧が必須です。単独の型には括弧は要りません。そしてDNF形になっていない書き方はパースエラーになります。

OK:  A|B|C
OK:  A|B|(C&D)
OK:  (A&B&C)|null

NG:  A&(B|D)          → (A&B)|(A&D) と書き直す
NG:  A|(B&(D|W))      → A|(B&D)|(B&W) と書き直す

注意したいのは、8.1で使えなかった ? の短縮記法がDNF型でも使えないことです。nullableにしたいなら (A&B)|null と明示的に書きます。?(A&B) は書けません。デフォルト値に null を置く暗黙のnullableも、8.2以降で交差型に対しては同じく通りません。

冗長さのチェックも入ります。こちらはクラスをロードせずに判定できる範囲だけですが、うっかりミスは拾ってくれます。

interface A {}
interface B {}

function f(): (A&B)|(B&A) {}   // Fatal error: 同じ組み合わせの重複

function g(): (A&B)|A {}
// Fatal error: Type A&B is redundant as it is more restrictive than type A

あくまで「明らかに冗長なもの」だけです。(A&B)|C のように、Cが実際にはAとBを継承していて冗長であるケースは、クラスをロードしないと判定できないので素通りします。

継承したときの変性はどうなる?

ここは覚え方が単純で助かります。RFCの言い方をそのまま借りると、戻り値はANDを足せる、引数はORを足せる。要するに戻り値は狭める方向、引数は広げる方向、といういつものLSPです。

interface Repo
{
    public function all(): (Traversable&Countable)|array;

    public function save((Traversable&Countable)|array $items): void;
}

final class ArrayRepo implements Repo
{
    private array $rows = [];

    // 戻り値はORを減らして狭めた。covariant なのでOK
    public function all(): array
    {
        return $this->rows;
    }

    // 引数はORを足して広げた。contravariant なのでOK
    public function save((Traversable&Countable)|array|null $items): void
    {
        $this->rows = is_array($items) ? $items : [];
    }
}

逆をやると、たとえば戻り値を Traversable|array のように広げたり、引数から array を削ったりすると、コンパイル時に Declaration of ... must be compatible with ... で止まります。プロパティは従来どおり不変で、追加も削除もできません。ただし並び順を変えるだけなら許されます。A&BB&A は論理的に同じものとして扱われる、ということですね。

リフレクションで型を読むときの落とし穴

交差型を読むには PHP 8.1 で追加された ReflectionIntersectionType を使います。getTypes()ReflectionNamedType の配列を返しますが、順序は保証されていません。RFCにも「元の宣言と一致しない任意の順で返りうる」と明記されています。

function summarize(Traversable&Countable $items): string { return ''; }

$type = (new ReflectionFunction('summarize'))->getParameters()[0]->getType();

var_dump($type instanceof ReflectionIntersectionType); // bool(true)

foreach ($type->getTypes() as $t) {
    echo $t->getName(), PHP_EOL;   // Traversable と Countable。順序は不定
}

「1つめが本命の型だろう」と決め打ちして書きたくなるところですが、そこは踏んではいけません。順序に依存せず、集合として扱う必要があります。

厄介なのはDNF型が入った8.2以降です。ReflectionUnionType::getTypes() は以前「必ず ReflectionNamedType の配列を返す」と考えられていましたが、いまは ReflectionIntersectionType が混ざりえます。DIコンテナやシリアライザのように型を舐めて処理を分けるコードは、ここで壊れます。

function find(): (Traversable&Countable)|null { return null; }

$type = (new ReflectionFunction('find'))->getReturnType();

foreach ($type->getTypes() as $t) {
    // getName() があるとは限らない
    echo $t instanceof ReflectionIntersectionType ? 'intersection' : $t->getName(), PHP_EOL;
}

RFCでも後方互換の影響としてこの1点だけが挙げられています。自作のリフレクション処理を持っているなら、8.2に上げる前に見ておく価値はあると思います。

実務でどこまで使うか

個人的な線引きとしては、自分が定義したインターフェース同士なら、交差型を書くより専用のインターフェースを1本切ったほうが読みやすいと思っています。interface CountableCollection extends Traversable, Countable {} と書けば済む話を、呼び出し側全部に Traversable&Countable と書いて回るのは、いずれ組み合わせが増えたときにつらい。

交差型が効くのは、自分では変更できない型を組み合わせるときです。他社ライブラリやPSRのインターフェース、それからPHPUnitのモック。テストクラスのプロパティを private MockObject&PaymentGateway $gateway; と書けるようになったのは、地味ですがかなり効きました。実装の型とモックの型が両方立っていることが、宣言の1行で分かる。

PHPStanやPsalmではもっと前からdocblockで @param A&B と書けていたので、そちらに慣れている人には目新しさはないかもしれません。ただ、注釈ではなく言語のチェックとして効くようになったのは、やはり別物だという気がしています。

まとめ

交差型は8.1で入ったときnullすら混ぜられず、正直まだ使いどころが限られていました。8.2のDNF型で (A&B)|null が書けるようになって、ようやく実務で素直に使えるものになったと思います。書ける型はクラスとインターフェースだけ、括弧は必須、変性は戻り値がAND追加で引数がOR追加。この3つを覚えておけば、あとは書きながら怒られて身につく類の機能です。使いすぎると型宣言が呪文になるので、そこだけ気をつけながら。

よくある質問

Q. 交差型とユニオン型はどちらが厳しい型ですか?
A. 交差型です。ユニオン型は並べた型のどれか1つを満たせば通りますが、交差型は全部を満たす必要があります。メンバーを追加すると、ユニオン型は緩くなり交差型は厳しくなる、と覚えると混乱しにくいです。

Q. ?Foo&Bar と書けないのはなぜですか?
A. ? の短縮記法は交差型・DNF型のどちらでもサポートされていません。PHP 8.2以降で (Foo&Bar)|null と明示的に書く必要があります。PHP 8.1では、そもそもnullと組み合わせること自体ができません。

Q. 交差型を使ったコードを古いPHPで動かす方法はありますか?
A. 構文レベルの機能なのでポリフィルはできません。8.0以前を相手にするなら、両方を継承した中間インターフェースを定義してそれを型に使うか、docblockに @param A&B と書いて静的解析に任せる形になります。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です