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
と書いたら$a
はint型
もしくは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
の文法が圧倒的に優勢でした。
?T
はT|null
の省略形として今後も有効な構文で、推奨も非推奨もされず、当分は廃止される予定もありません。
null型は、union型の一部としてのみ有効な型で、単独で使うことはできません。
union型と?T
表記を混ぜて使用することはできません。
?T1|T2
、T1|?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許容型として使うことはできません。
false
、false|null
、?false
は全て無効な構文です。
Duplicate and redundant types
クラスのロードを行わずに検出できる冗長な記述は、コンパイルエラーになります。
これは以下のような例を含みます。
・同じ型の重複。int|string|INT
は不可。
・bool
にはfalse
を追加できない。
・object
には個別のクラス型を追加できない。
・iterable
にはarray
とTraversable
を追加できない。
これは、型が最小であることを保証はしません。
たとえばクラス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_i
がV_j
のサブタイプであった場合、U_1|...|U_n
はV_1|...|V_m
のサブタイプである。
・iterable
はarray|Traversable
と同じと見做す。
・false疑似型はbool
のサブタイプと見做す。
以下において、許可されているものと許可されないものの例を幾つか示します。
Property types
プロパティの型は不変です。
すなわち、継承しても型は同じである必要があります。
ただし、この"同じ"は意味が同じということを表します。
これまでもクラスエイリアスで同じクラスを表す別名を付けることができました。
union型はこの"同じ"の範囲を広げ、たとえばint|string
とstring|int
は同じとして扱います。
class A {}
class B extends A {}
class Test {
public A|B $prop;
}
class Test2 extends Test {
public A $prop;
}
この例では、親クラスのA|B
型と子クラスのA
型は明らかに異なる型であるにもかかわらず、これは正当な文法です。
内部的には、以下のようにこの結果に到達します。
まず、それはA
のサブタイプであるため、A
はA|B
のサブタイプです。1
次にA
はA
のサブタイプであり、B
はA
のサブタイプであるため、A|B
はA
のサブタイプです。
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|string
にfalse
を渡すと、0
と""
の両方が暗黙の型変換の候補になります。
従って、引数に正しくないスカラー値が渡ってきた場合、以下の優先順位で変換することにします。
1.int
2.float
3.string
4.bool
PHPの既存の型変換機構で型変換が可能である場合、その型が選ばれます。
例外として、値が文字列であり、union型がint|float
である場合、優先される型は引数の数値文字列の中身によって決まります。
すなわち、"42"はint型となり、"42.0"はfloat型となります。
上記のリストに含まれない型には自動型変換されません。
特にnull
やfalse
への自動型変換は起きないことに注意しましょう。
// 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|string
とstring|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許容型?T
、T|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|Countable
はTraversable
とCountable
のうち少なくともどちらかである必要がありますが、Traversable&Countable
はTraversable
でありなおかつ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型をうまく使う方法、正直あまり思いつきません。
誰かがきっといいサンプルを考えてくれるはず。
-
it
がどれにかかってるのかわからなかった。というかここの文の意味がわからん。 ↩