46
22

More than 1 year has passed since last update.

【PHP8.1】PHPで列挙型ENUMが使えるようになる

Last updated at Posted at 2020-12-07

PHPの開発者のひとり、Larry GarfieldがMLに列挙型のRFCを投稿しました。

最近Ilija ToviloとふたりでPHPに代数的データ型のサポートを追加する作業を行ってるよ。
このプロジェクトは何段階かあるんだけど、まずは第一段階として列挙型を公開レビューするよ。

MLとプルリクで幾つかの突っ込みと修正が入っています。
しかし全体としては多くが賛成となっており、致命的な問題でも見つからないかぎり導入される可能性は高いでしょう。

ということで以下はPHP RFC: Enumerationsの日本語訳です。

PHP RFC: Enumerations

Introduction

このRFCは、PHPに列挙型を導入するものです。
このRFCの範囲は列挙型のみに限定されています。
すなわちプリミティブ型の糖衣構文などはなく、列挙型に関連する事項以外の追加情報は含まないようにしています。

列挙型の機能により、データモデリング、カスタム型の定義、モナドスタイルのサポートなどが大幅に拡充されるでしょう。
列挙型は、「無効な状態は存在できないようにする」というモデリングを可能とし、不要なテスト工数を減らし、より堅牢なコードを記述できるようになります。

多くの言語は、複数種類の列挙型をサポートしています。
複数言語を調査した結果によると、一般的に3つのグループに分類できることがわかりました。
すなわちファンシーな定数、ファンシーなオブジェクト、そして完全な代数的データ型です。

このRFCは、完全な代数的データ型を導入するという大きな取り組みの一部です。
従ってこのRFCでは、今後のRFCで完全な代数的データ型に拡張できるように、"ファンシーなオブジェクト"タイプのENUMを実装します。
これはSwift、Rust、Kotlinなどの考え方の影響を受けていますが、それらを直接的にモデル化したわけではありません。

列挙型の値のもっとも一般的なケースは真偽型で、取ることのできる値はtruefalseのみです。
このRFCでは、開発者が独自に任意の列挙型を定義することが可能にしています。

Proposal

列挙型は、クラスとオブジェクトを下敷きに構築されています。
すなわち、特に断りのない限り「この場合の列挙型の動作はどうなるの?」は、「オブジェクトのインスタンスと同じ」となります。
たとえばオブジェクト型チェックに成功し、大文字小文字を区別しません(オートロード時の区別がファイルシステム・クラスローダ依存なのも同じ)。

Unit enumerations

このRFCでは、enumという新しい言語機能が導入されます。
Enumはクラスに似ており、クラス・インターフェイス・トレイトと同じ名前空間に存在します。
また同じようにオートロードも可能です。
列挙型は、限られた数の有効な値を持ちます。

enum Suit {
  case Hearts;
  case Diamonds;
  case Clubs;
  case Spades;
}

この構文では、4つの値を持つ新たな列挙型Suitを作成しました。
値とはすなわちSuit::HeartsSuit::DiamondsSuit::ClubsSuit::Spadesです。
これら4値を変数などに代入することができます。
型宣言に列挙型を指定した場合は、その型の値のみを渡すことができます。

$val = Suit::Diamonds;

function pick_a_card(Suit $suit) { ... }

pick_a_card($val);        // OK
pick_a_card(Suit::Clubs); // OK
pick_a_card('Spades');    // TypeError

列挙型は0個以上のcaseを持つことができ、上限はありません。
caseが0個の列挙型は構文的には有効ですが、実質的にあまり意味がありません。

caseは具体的なスカラー値に紐付けられているわけではありません。
それぞれのcaseは紐付けられているシングルトンと等しくなります。

$a = Suit::Spades;
$b = Suit::Spades;

$a === $b; // true

$a instanceof Suit;         // true
$a instanceof Suit::Spades; // true

このタイプの列挙型(caseのみでできていて、関連データなどは付けられない)はユニット列挙型として知られています。

これはEnumインターフェイスを実装するオブジェクトとして実装されており、また内部インターフェイスUnitEnumも実装しています。
他のオブジェクトと列挙型を区別する方法のひとつです。

Suit::Hearts instanceof Enum; // true
Suit::Hearts instanceof UnitEnum; // true

Enumerated Case Methods

列挙型もクラスなので、メソッドを定義することができます。

またインターフェイスをimplementsすることもできますが、その場合は全てのcaseに対して実装しなければなりません。
個々のcaseにインターフェイスをimplementsすることはできません。

interface Colorful {
  public function color(): string;
}

enum Suit implements Colorful {
  case Hearts {
    public function color(): string {
      return "Red";
    }
  }

  case Diamonds {
    public function color(): string {
      return "Red";
    }
  }

  case Clubs {
    public function color(): string {
      return "Black";
    }
  }

  case Spades {
    public function color(): string {
      return "Black";
    }
  }

  public function shape(): string {
    return "Rectangle";
  }
}

function paint(Colorful $c) { ... }

paint(Suit::Clubs);  // Works

この例では、4つのcaseは全てSuiteから継承したメソッドcolorを持っています。
case内のメソッドは他のメソッドと同じように動作します。

またcase内では$thisが定義されていて、Caseインスタンスを参照することができます。

上記の場合、RedBlackの値を持つSuitColor列挙型を定義して、それを返す方がよりよいモデリングになるでしょう。
ただそうすると例が複雑になってしまうため避けました。

上記の列挙型は、下記のように書き替えることもできます。

interface Colorful {
  public function color(): string;
}

abstract class Suit implements UnitEnum, Colorful {
  public function shape(): string {
    return "Rectangle";
  }

  public function cases(): array {
    // See below.
  }
}

class Hearts extends Suit {
  public function color(): string {
    return "Red";
  }
}

class Diamonds extends Suit {
  public function color(): string {
    return "Red";
  }
}

class Clubs extends Suit {
  public function color(): string {
    return "Black";
  }
}

class Spades extends Suit {
  public function color(): string {
    return "Black";
  }
}

列挙型に静的メソッドを持たせ、各caseは静的メソッドを持たないことができます。

列挙型に静的メソッドを持たせる理由は、主に代替コンストラクタのためです。
たとえば以下のような記述が可能です。

enum Size {
  case Small;
  case Medium;
  case Large;

  public static function fromLength(int $cm) {
    return match(true) {
      $cm < 50 => static::Small,
      $cm < 100 => static::Medium,
      default => static::Large,
    };
  }
}

Comparison to objects

列挙型は内部的にはクラスを使って実装されていて、セマンティクスの多くを共有しています。
ただし幾つかのオブジェクトスタイルの機能は禁止されています。
これらの機能は列挙型においては意味がないか、不明瞭であるか、あるいは議論の余地がある(今後追加される可能性がある)ものです。

  • コンストラクタ・デストラクタ:状態を持たせなければ不要です。
  • 継承:列挙型は設計上クローズドであり、継承は列挙型を壊します。
  • 定数:メソッドを使います。
  • プロパティ:状態を持たせません。
  • 動的プロパティ:良い設計ではありません。
  • マジックメソッド:別途記載する一部を除き使用できません。
  • シリアライズ:シリアライズはできません。

これらの機能が必要であれば、既存のクラスの方が優れた選択肢になります。

以下のオブジェクトの機能は利用可能です。

  • メソッドのpublic / protected / privateアクセス修飾子
  • マジックメソッド__get__call__invoke
  • 定数__CLASS____FUNCTIONS__

列挙型へのマジック定数::classはオブジェクトと全く同じ、名前空間を含む型名で評価されます。

caseへのマジック定数::classは、::のついた値で評価されます。
たとえばFoo\Bar\Baz\Suit::Spadesです。

静的メソッドはcaseには使えず、列挙型自体には使用可能です。

また、各casenewで直接インスタンス化することはできず、newInstanceWithoutConstructorなどのリフレクションでインスタンス化することもできません。

Scalar Enums

デフォルトでは、列挙されたcaseに等しいスカラ値は存在しません。
単なるシングルトンオブジェクトです。
しかし列挙型の値を他のデータベースなどにストアする可能性は高いので、シリアライズ可能なスカラ値が存在すると便利です。

以下の構文で、列挙型にスカラ値を定義することができます。

enum Suit: string {
  case Hearts = 'H';
  case Diamonds = 'D';
  case Clubs = 'C';
  case Spades = 'S';
}

スカラ値の型はintもしくはstringであり、ひとつの列挙型は単一の型のみをサポートします。
つまりint|stringのユニオン型はサポートされません。
列挙型がスカラ値を持つ場合は、全てのcaseは一意でなければならず、同じ値を持つことはできません。
自動インクリメントなどの自動生成スカラ値は存在しません。

スカラ列挙型の値をスカラコンテキストで使用する場合、自動的にスカラ値にダウンキャストされます。
たとえばprintの引数に渡すとスカラ値として評価されます。

print Suit::Clubs; // "C"

print "I hope I draw a " . Suit::Spades; // "I hope I draw a S".

スカラ列挙型は内部インターフェイスUnitEnumにくわえてScalarEnumインターフェイスも実装されており、ScalarEnumインターフェイスにはfrom()メソッドが定義されています。
このメソッドはスカラ値から対応するcaseを返します。
一致するcaseが存在しない場合はValueErrorをthrowします。

$record = get_stuff_from_database($id);
print $record['suit'];// "H"

$suit = Suit::from($record['suit']);
$suit === Suit::Hearts; // true

スカラ列挙型も、ユニット列挙型同様メソッドを持つことができます。

enum Suit: string {
  case Hearts = 'H';
  case Diamonds = 'D';
  case Clubs = 'C';
  case Spades = 'S' {
    public function color(): string { return 'Black'; }
  }

  public function color(): string
  {
    // ...
  }
}

Value listing

UnitEnumインターフェイスには静的メソッドcases()が実装されています。
このメソッドは、定義された全てのcaseを順に返します。

Suit::cases();
// [Suit::Hearts, Suit::Diamonds, Suit::Clubs, Suit:Spades]

スカラ列挙型でない場合は0から順にインデックスがつけられます。
スカラ列挙型の場合は、対応するスカラ値がキーになります。

またScalarEnumインターフェイスには、スカラ値を返すvalue()メソッドが実装されています。

'D' == Suit::Diamonds->value(); // true

Attributes

列挙型と各caseには、他の言語要素と同じくアトリビュートを適用可能です。
Attributeクラスにふたつのターゲット定数が追加されます。
TARGET_ENUMは列挙型自体を対象とし、TARGET_CASEcaseを対象とします。

言語が定義するアトリビュートはありません。
ユーザ定義のアトリビュートでは何でもできます。

Match expressions

列挙値に応じてロジックを分岐させる方法として、match式が便利な文法を提供します。

$val = Suit::Diamonds;

$str = match ($val) {
    Suit::Spades => "The swords of a soldier",
    Suit::Clubs => "Weapons of war",
    Suit::Diamonds => "Money for this art",
    default => "The shape of my heart",
}

match式の自然な使い方であり、matchの構文に手を加える必要はありません。

Reflection

列挙型のリフレクションは、ReflectionClassをextendsしたReflectionEnumクラスを使用します。
無関係なメソッド、getProperty()などは常に空の値を返します。
また、以下のメソッドが追加されます。

  • hasCase(string $name): bool そのcaseを持っていればtrueを返す。$r->hasCase('Hearts')はtrue。
  • getCases(): array ReflectionCaseの配列を返す。
  • getCase(string $name): ReflectionCase その名前のReflectionCaseを返す。
  • hasType(): bool スカラ列挙型であればtrueを返す。
  • getType(): ReflectionType スカラ列挙型であればその型を、そうでなければ空のReflectionTypeを返す。

ReflectionCaseは、列挙型の中の個々のcaseを表します。
これもReflectionClassをextendsしており、以下のメソッドが追加されます。

  • getEnum(): ReflectionEnum caseの入っている列挙型のReflectionEnumを返す。
  • getScalar(): ?int|string スカラ列挙型であればその値を、そうでなければnullを返す。
  • getInstance(): Enum 該当の列挙型インスタンスを返す。

Examples

以下に、列挙型の例をいくつか表示します。

Basic limited values

enum SortOrder {
  case ASC;
  case DESC;
}

function query($fields, $filter, SortOrder $order) { ... }

query関数は、引数$orderSortOrder::ASCもしくはSortOrder::DESCのいずれかであることを保証できるようになりました。
それ以外の値を指定するとTypeErrorが発生するため、これ以上のチェックやテストは必要ありません。

Advanced Exclusive values

enum UserStatus: string {
  case Pending = 'pending' {
    public function label(): string { 
      return 'Pending';
    }
  }
  case Active = 'active' {
    public function label(): string { 
      return 'Active';
    }
  }
  case Suspended = 'suspended' {
    public function label(): string { 
      return 'Suspended';
    }
  }
  case CanceledByUser = 'canceled' {
    public function label(): string { 
      return 'Canceled by user';
    }
  }
}

ユーザのステータスはUserStatus::PendingUserStatus::ActiveUserStatus::SuspendedUserStatus::CanceledByUserのいずれかであり、他の値を取ることはできません。

これら4値はポリモーフィックなlabel()メソッドを持っており、これは人間に読める文字列を返します。
この値はデータベースやHTMLのselectボックスに入れる値とは独立して指定することができます。

foreach (UserStatus::cases() as $key => $val) {
  printf('<option value="%s">%s</option>\n', $key, $val->label());
}

label()メソッドは個別に実装せず、ひとつのメソッドとして列挙型クラスに実装することもできます。

enum UserStatus: string {
  case Pending = 'pending';
  case Active = 'active';
  case Suspended = 'suspended';
  case CanceledByUser = 'canceled';

  public function label(): string {
    return match($this) {
      UserStatus::Pending => 'Pending',
      UserStatus::Active => 'Active',
      UserStatus::Suspended => 'Suspended',
      UserStatus::CanceledByUser => 'Canceled by user',
    };
  }
}

どちらのアプローチが適切であるかは、何をするかの目的によって異なるものであり、開発者の裁量に委ねられます。

State machine

列挙型は、有限ステートマシンを簡単に表現できます。

enum OvenStatus {

  case Off {
    public function turnOn() { return OvenStatus::On; }
  }

  case On {
    public function turnOff() { return OvenStatus::Off; }
    public function idle() { return OvenStatus::Idle; }
  }

  case Idle {
    public function on() { return OvenStatus::On; }
  }
}

この例では、オーブンはオン、オフ、アイドルの3状態を持っています。
しかしオフからアイドル、アイドルからオフに移行することはできず、オフにするには必ずオンの状態を経由しなければなりません。
すなわち、オフから直接アイドルに移行するテストやコードを書いたりする必要がないということです。

もちろん、実際のケースではもっと追加の実装が必要になることも多いでしょうが。

New interfaces

これまで出てきたように、このRFCでは3つの内部インターフェイスが追加されます。
このインターフェイスは、ユーザの作成したコードが列挙型であるか、列挙型である場合はどのような種類であるかを判断するために利用可能です。
ユーザが直接implementsすることはできません。

interface Enum {}

interface UnitEnum extends Enum {
  public function cases(): array;
}

interface ScalarEnum extends Enum {
  public function value(): int|string;

  public static function from(int|string $scalar): static;
}

Backward Incompatible Changes

enumがキーワードになります。

グローバルスコープにインターフェイスEnumUnitEnumScalarEnumが追加されます。

Open questions

特定のcaseに対してタイプヒントは可能?たとえば

public function stuff(Suit::Heart|Suit:Diamond $card) { ... }

Future Scope

代数的データ型のRFCを参照ください。

Grouped syntax

単純なユニット列挙型であれば、まとめて定義を可能にする。

enum Suit {
  case Hearts, Diamonds, Clubs, Spades;
}

メソッドの定義されていない単純なユニット列挙型にのみ可能な文法です。
この列挙型がどのくらい使われるのか不明であり、構文も議論の余地があり、必要に応じて後から追加も可能な構文なので、現時点では対応していません。

Enums as array keys

列挙型のcaseはオブジェクトであるため、連想配列のキーとしては使用できません。
将来的には対応するかもしれません。

Serialization

Suit::Clubs === unserialize(serialize(Suit::Clubs))を安全にシリアライズする方法があれば、後から追加される可能性があります。
今のところは非対応です。

感想

最終的に代数的データ型の実装を目指す遠大な計画の一端です。

個人的にはconstでどうにもならない場面に遭遇したことがないので、PHPにおいて列挙型がどこまで有用なのかよくわかりません。
しかし、PHPで列挙型を作ろうという試みが昔から山ほど行われていることを鑑みると、けっこうな需要はありそうです。
特に型以上の引数値限定はこれまで基本的にはできませんでしたから、この点についてはかなり便利そうです。

このような追加機能については、往々にしてユーザによる勝手拡張の方が便利だったりすることが多いですが、列挙型の場合はextends enumを文法で禁止することで値を完全に保証したり、値にメソッドを生やすことができたりと、公式実装の方が便利っぽいですね。
しかし、いちいちcaseを入れないといけないのは面倒ですね。
他言語ではenum Suit{Hearts, Diamonds, Clubs, Spades}みたいに書けるものも多いのですが、PHPでは構文解析の都合上難しかったのかな?

ところでSplEnumってどうなったんだろう。
RFCでもMLでもプルリクでも誰一人話題に上げていない。

46
22
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
46
22