LoginSignup
143
79

More than 1 year has passed since last update.

【PHP8.0】PHPでunion型が使えるようになる

Last updated at Posted at 2019-11-04

Union Types 2.0というRFCが投票中です。
提案者はまたまたのNikita。
2019/10/25開始、2019/11/08終了で、受理には2/3+1の賛成が必要です。
2019/11/04時点で賛成55反対5であり、ほぼ導入確定です。

PHPのunion型って何かというと、TypeScriptのunion型とだいたい同じです。
int|string $aと書いたら$aint型もしくはstring型ですよ、ということです。

ちなみに別途RFCをGitHubで管理しようという実験が進行中で、このRFCの詳細はGitHub上に存在します
このRFCはまだNikitaの個人GitHub上にしかないのですが、本決まりになったらPHP公式に移動になると思います。
まあGitHubのほうが管理とか更新とか楽ですからね。
ただGitHubはURLがすぐ404になるのだけはどうにかしてほしい。

Union Types 2.0

Introduction

union型は、単一の型ではなく、複数の異なる型の値を受け入れます。
PHPは既に、2種類の特別なunion型をサポートしています。

・null許容型。?Tで使える。
・array型もしくはTraversable型。iterableで使える。

しかし任意のunion型はサポートされていません。
現在はかわりにphpdoc注釈を書くくらいしかできません。

class Number {
    /**
     * @var int|float $number
     */
    private $number;

    /**
     * @param int|float $number
     */
    public function setNumber($number) {
        $this->number = $number;
    }

    /**
     * @return int|float
     */
    public function getNumber() {
        return $this->number;
    }
}

Statisticsセクションで、オープンソースでunion型がどれだけ普及しているかを示しています。

言語でunion型をサポートすることにより、より多くの型情報をphpdocに頼ることなく関数シグナチャに移動することができ、多数の利点が得られます。

・型は実際に強制されるため、ミスを早期に発見できる。
・エッジケースを見逃したり、仕様変更の際にドキュメントを更新し忘れたりする可能性が減る。
・継承時にもリスコフの置換原則を適用できる。
・リフレクションから利用できる。
・phpdocより分量が減らせる。

union型は、ジェネリクスと並んで型システムに残っている最大の穴です。

Proposal

union型を構文T1|T2|...で表し、型を書くことができる全ての位置でunion型を使用可能とする。

class Number {
    private int|float $number;

    public function setNumber(int|float $number): void {
        $this->number = $number;
    }

    public function getNumber(): int|float {
        return $this->number;
    }
}

Supported Types

union型は、現在PHPでサポートされている全ての型をサポートしますが、一部の型については注意が必要です。

void型

void型は、union型の一部となることは決してできません。
T|voidのような型は、戻り値を含む全ての位置で不正です。

void型は関数に戻り値がないことを表します。
あらゆる非void型と互換がありません。

かわりにnull許容型?Tがあり、これはT型もしくはnullを返すことができます。

null許容型

T1|T2|nullとしてnullを許容するunion型を定義することができます。
既存の?Tは、T|nullの省略形と見做されます。

このRFCの草稿では、null許容型の文法が2種類になることを防ぐため、?(T1|T2)という文法を提唱していました。
しかしこの構文は都合が悪く、さらにphpdocで確立されているT1|T2|null構文とも異なっています。
議論の結果は、T1|T2|nullの文法が圧倒的に優勢でした。
?TT|nullの省略形として今後も有効な構文で、推奨も非推奨もされず、当分は廃止される予定もありません。

null型は、union型の一部としてのみ有効な型で、単独で使うことはできません。

union型と?T表記を混ぜて使用することはできません。
?T1|T2T1|?T2?(T1|T2)は全て不正な文法で、この場合はT1|T2|nullを使う必要があります。

false疑似型

現在では、エラーや不正が起きた際の関数の戻り値はnullにすることが推奨されていますが、歴史的理由から多くの内部関数はfalseを返してきます。
Statisticsセクションで示すように、union型を返す内部関数は大部分がfalseを含んでいます。

一般的な例としてはint|falseを返すstrposなどです。
これを正しく表記するとint|boolですが、これは関数がtrueを返すこともあるという誤った印象を与えます。

そのため、このRFCにはfalseのみを表すfalse疑似型が含まれています。
trueのみを表すtrue疑似型は、それが必要となる歴史的理由が存在しないため、このRFCには含まれません。

false疑似型は、union型の一部としてのみ有効で、単独やnull許容型として使うことはできません。
falsefalse|null?falseは全て無効な構文です。

Duplicate and redundant types

クラスのロードを行わずに検出できる冗長な記述は、コンパイルエラーになります。
これは以下のような例を含みます。

・同じ型の重複。int|string|INTは不可。
boolにはfalseを追加できない。
objectには個別のクラス型を追加できない。
iterableにはarrayTraversableを追加できない。

これは、型が最小であることを保証はしません。
たとえばクラスAとBがクラスエイリアスや継承関係にある場合、A|Bは有効です。

function foo(): int|INT {} // ×
function foo(): bool|false {} // ×

use A as B;
function foo(): A|B {} // × 構文解析時点でわかる

class_alias('X', 'Y');
function foo(): X|Y {} // 許可 実行時までわからない

Type grammar

特殊なvoid型を除くと、型の構文は以下のようになります。

type: simple_type
    | "?" simple_type
    | union_type
    ;

union_type: simple_type "|" simple_type
          | union_type "|" simple_type
          ;

simple_type: "false"          # union型でのみ有効
           | "null"           # union型でのみ有効
           | "bool"
           | "int"
           | "float"
           | "string"
           | "array"
           | "object"
           | "iterable"
           | "callable"       # プロパティ型指定では無効
           | "self"
           | "parent"
           | namespaced_name
           ;

Variance

union型は、既存の型ルールに従います。
・戻り値は共変。
・パラメータ型は反変。
・プロパティ型は不変。

唯一の変更点は、union型が派生型と相互作用する点であり、そのため3つの追加ルールがあります。

・全てのU_iV_jのサブタイプであった場合、U_1|...|U_nV_1|...|V_mのサブタイプである。
iterablearray|Traversableと同じと見做す。
・false疑似型はboolのサブタイプと見做す。

以下において、許可されているものと許可されないものの例を幾つか示します。

Property types

プロパティの型は不変です。
すなわち、継承しても型は同じである必要があります。
ただし、この"同じ"は意味が同じということを表します。
これまでもクラスエイリアスで同じクラスを表す別名を付けることができました。

union型はこの"同じ"の範囲を広げ、たとえばint|stringstring|intは同じとして扱います。

class A {}
class B extends A {}

class Test {
    public A|B $prop;
}
class Test2 extends Test {
    public A $prop;
}

この例では、親クラスのA|B型と子クラスのA型は明らかに異なる型であるにもかかわらず、これは正当な文法です。
内部的には、以下のようにこの結果に到達します。
まず、それはAのサブタイプであるため、AA|Bのサブタイプです。1
次にAAのサブタイプであり、BAのサブタイプであるため、A|BAのサブタイプです。

Adding and removing union types

戻り値からunion型の一部を削除し、パラメータに一部の型を追加することは正しい文法です。

class Test {
    public function param1(int $param) {}
    public function param2(int|float $param) {}

    public function return1(): int|float {}
    public function return2(): int {}
}

class Test2 extends Test {
    public function param1(int|float $param) {} // OK: パラメータの型追加は許可
    public function param2(int $param) {}       // NG: パラメータの型削除は禁止

    public function return1(): int {}           // OK: 返り値の型削除は許可
    public function return2(): int|float {}     // NG: 返り値の型追加は禁止
}

Variance of individual union members

同様に、戻り値の型を狭めたり、パラメータの型を広げることは許可されます。

class A {}
class B extends A {}

class Test {
    public function param1(B|string $param) {}
    public function param2(A|string $param) {}

    public function return1(): A|string {}
    public function return2(): B|string {}
}

class Test2 extends Test {
    public function param1(A|string $param) {} // OK: BをAに広げた
    public function param2(B|string $param) {} // NG: AをBに狭めた

    public function return1(): B|string {}     // OK: AをBに狭めた
    public function return2(): A|string {}     // NG: BをAに広げた
}

もちろん同じことを複数のunion型に同時に行ったり、型の追加削除と型の拡縮を組み合わせることもできます。

Coercive typing mode

strict_typesが有効でない場合、スカラー型宣言は暗黙の型変換の対象となります。
これは一部のunion型において、変更先の型を一意に決められないため問題となります。
たとえばint|stringfalseを渡すと、0""の両方が暗黙の型変換の候補になります。

従って、引数に正しくないスカラー値が渡ってきた場合、以下の優先順位で変換することにします。

1.int
2.float
3.string
4.bool

PHPの既存の型変換機構で型変換が可能である場合、その型が選ばれます。

例外として、値が文字列であり、union型がint|floatである場合、優先される型は引数の数値文字列の中身によって決まります。
すなわち、"42"はint型となり、"42.0"はfloat型となります。

上記のリストに含まれない型には自動型変換されません。
特にnullfalseへの自動型変換は起きないことに注意しましょう。

// int|string
42    --> 42          // 正しい型
"42"  --> "42"        // 正しい型
new ObjectWithToString --> "__toString()の結果" // objectはint型にならない
42.0  --> 42          // floatはint型になる
42.1  --> 42          // floatはint型になる
1e100 --> "1.0E+100"  // floatの上限を超えたらstring型になる
INF   --> "INF"       // floatの上限を超えたらstring型になる
true  --> 1           // boolはint型になる
[]    --> TypeError   // 配列はint|string型にならない

// int|float|bool
"45"    --> 45        // 整数っぽいのでint型になる
"45.0"  --> 45.0      // 小数っぽいのでfloat型になる
"45X"   --> 45 + Notice: Non well formed numeric string // 有効部分は整数っぽいのでint型になり、E_NOTICEが出る
""      --> false     // 数値形式文字列でないのでbool型になる
"X"     --> true      // 数値形式文字列でないのでbool型になる
[]      --> TypeError // 配列はint|float|bool型にならない

Alternatives

自動型変換については、別案が2種類ありました。

ひとつめはunion型は常に厳密な型指定とすることで、複雑な強制型変換を完全に排除することです。
これは2つの欠点があります。
まず、strictでないときに型をfloatからfloat|intにすると、直感に反して有効な入力が減ります。
第二に、float型float|int型のサブタイプと言えなくなるため、union型のモデルが崩壊します。

二つ目が、変換の優先順位を型の順番にすることです。
これは即ちint|stringstring|intが異なる型になることを意味します。
この変換は直感的ではなく、継承関係に非常に不明瞭な影響を及ぼします。

Property types and references

union型プロパティへの参照は、プロパティ型指定RFCに書かれた挙動に従います。
プロパティ型指定とunion型の組み合わせによる影響は、当時から考慮されていました

class Test {
    public int|string $x;
    public float|string $y;
}
$test = new Test;
$r = "foobar";
$test->x =& $r;
$test->y =& $r;

// $rと$test->xと$test->yは同じもので、型は{ mixed, int|string, float|string }の論理積になる

$r = 42; // TypeError

複数のリファレンスが紐付けられている場合、型の強制変換が行われた後の最終的な型は全ての型と互換する必要があります。
上記例の場合、$test->xはint型の42になり、$test->yはfloat型の42.0になります。
これは同じ型ではないため、TypeErrorが投げられます。

この場合は共通の型であるstring型にキャストすることでエラーは出なくなりますが、自動型変換による優先順位と異なるため、型がどうなるかわからないという欠点があります。

Reflection

union型をサポートするReflectionUnionTypeクラスが追加されます。

class ReflectionUnionType extends ReflectionType {
    /** @return ReflectionType[] */
    public function getTypes();

    /* Inherited from ReflectionType */
    /** @return bool */
    public function allowsNull();

    /* Inherited from ReflectionType */
    /** @return string */
    public function __toString();
}

getTypes()メソッドは、ReflectionTypeクラスの配列を返します。
この型は、元の型宣言と順番が異なる可能性があり、また等価である別の型になる可能性があります。

たとえばint|string型が["string", "int"]の順で要素を返す場合があります。
またiterable|array|string型は["iterable", "string"]になるかもしれないし["Traversable", "array", "string"]になるかもしれません。
Reflection APIが保証するのは、論理的に同じものであるということです。

allowsNull()メソッドは、union型の要素にnull型が含まれるか否かを返します。

__toString()メソッドは、型宣言を有効なコード表現として返します。
元の型宣言と必ずしも同じではありません。

後方互換性のため、null許容型?TT|nullのunion型は、ReflectionUnionTypeではなくReflectionNamedTypeを返します。

    // getTypes()や__toString()の結果は順番が異なることもある

    function test(): float|int {}
    $rt = (new ReflectionFunction('test'))->getReturnType();
    var_dump(get_class($rt));    // "ReflectionUnionType"
    var_dump($rt->allowsNull()); // false
    var_dump($rt->getTypes());   // [ReflectionType("int"), ReflectionType("float")]
    var_dump((string) $rt);      // "int|float"

    function test2(): float|int|null {}
    $rt = (new ReflectionFunction('test2'))->getReturnType();
    var_dump(get_class($rt));    // "ReflectionUnionType"
    var_dump($rt->allowsNull()); // true
    var_dump($rt->getTypes());   // [ReflectionType("int"), ReflectionType("float"), ReflectionType("null")]
    var_dump((string) $rt); // "int|float|null"

    function test3(): int|null {}
    $rt = (new ReflectionFunction('test3'))->getReturnType();
    var_dump(get_class($rt));    // "ReflectionNamedType"
    var_dump($rt->allowsNull()); // true
    var_dump($rt->getName());    // "int"
    var_dump((string) $rt);      // "?int"

Backwards Incompatible Changes

このRFCには、後方互換性のない変更はありません。
ただしReflectionTypeを利用しているコードは、union型に関する処理を追加する必要があります。

Future Scope

この項目は今後の展望であり、このRFCには含まれていません。

Intersection Types

交差型は論理的にunion型と似ています。
union型では少なくとも一つの型が満たされる必要がありますが、交差型では全ての型が満たされる必要があります。

たとえばTraversable|CountableTraversableCountableのうち少なくともどちらかである必要がありますが、Traversable&CountableTraversableでありなおかつCountableである必要があります。

Mixed Type

mixed型は、任意の値が受け入れ可能であることを表します。
型指定が無い場合と見た目の動きは同じですが、型指定が無いと、それが本当に自由であるのか、単に型指定を書き忘れただけなのかが区別できません。

Literal Types

このRFCで導入されたfalse疑似型は、TypeScriptでサポートされているリテラル型の特殊なケースです。
リテラル型は、列挙型のように一部特定の値のみを許可できる型です。

type ArrayFilterFlags = 0|ARRAY_FILTER_USE_KEY|ARRAY_FILTER_USE_BOTH;
array_filter(array $array, callable $callback, ArrayFilterFlags $flag): array;

列挙型ではなくリテラル型を使用する利点は、元の文法をそのまま維持できることです。
そのため、後方互換性を壊すことなく後付けすることができます。

Type Aliases

型が複雑になると、型宣言の再利用が必要になります。
その一般的な方法は2種類が考えられます。
ひとつめは次のようなエイリアスです。

use int|float as number;

function foo(number $x) {}

このnumber型はソースコード上でのみ現れる型で、コンパイル時に元のint|floatに解決されます。

もうひとつは型宣言を定義することです。

namespace Foo;
type number = int|float;

// \Foo\numberをどこからでも使える

Statistics

上位2000パッケージの@param@returnにおいて、野生のunion型がどれだけ使われているかを分析しました。

@paramのunion型:25k。一覧
@returnのunion型:14k。一覧

PHPの内部関数を調べたところ(調査が不完全なので最低2倍はあるはず)

・336関数がunion型を返す。
・そのうち213関数がfalse型を返す。

多くの内部関数が、戻り値の型を表すためにfalse疑似型が必要であることを示しています。

感想

元々型に厳密なTypeScriptがunion型を導入する理由はまあわかるんですよ。
しかしですね、元々フリーダム型だったPHPがわざわざ型宣言やらプロパティ型指定やらで狭めてきた上でのunion型って、なんというかこうマッチポンプ感とかそんな感じを感じざるをえない。
いやまあ違う話だってのは理屈ではわかるんですけど感覚的にね。

PHPでunion型をうまく使う方法、正直あまり思いつきません。
誰かがきっといいサンプルを考えてくれるはず。


  1. itがどれにかかってるのかわからなかった。というかここの文の意味がわからん。 

143
79
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
143
79