WHITEPLUS TechBlog

株式会社ホワイトプラスのエンジニアによる開発ブログです。

PHP8.1のEnumと独自実装のEnumを比較して移行できるか検討しました

はじめに

こんにちは、ホワイトプラスでテックリードをしている仲見川です。

PHPにも8.1でついにEnumが導入されました。 ホワイトプラスではこれまでTraitを用いて独自にEnumを実装してきたのですが、今後PHP8.1のEnumに置き換えるために違いを検証しました。

背景

標準機能に則ることで新たにジョインしたメンバーもすんなりキャッチアップ出来たり、今までよりも厳密な処理を書く事ができるのではないかと思い今回詳しく比較検証を行ってみようと思います。

Enumとは

列挙型は、クラスやクラス定数に対して、さらに制限を加えたものです。 ある型に対して、取り得る値の限られた集合を定義する方法を提供します。

PHPマニュアルより)

Enumは乱暴に言うと特定の値のみ持ちうるstringのタイプエイリアスで、TypeScriptのUnion型のようなものです。 例えば以下のコードの会員種別は「premium」と「free」という値のみが取り得る値です。定義されていない場合「otokuisama」を持つMemberType型と言うような使い方は出来ません。

PHP8.1のEnum実装例

<?php
enum MemberType: string
{
    case PREMIUM = 'premium';
    case FREE = 'free';
}

// value無しでnameのみも可能です
enum MemberType
{
    case PREMIUM;
    case FREE;
}

独自実装

元々PHPにはEnumが有りませんでした、しかし表現として前述の会員種別のように取り得る値が限られた値を表現したい機会があり。 Enumを表現するTraitを作成し、実装クラスにてTraitをuseすることでEnumの表現を行っていました。

少々ボイラープレートがある事と、IDEや静的解析の為にdocコメントを書く必要があります。

<?php
/**
 * @method static self premium()
 * @method static self free()
 */
class MemberTypeByTrait
{
    use EnumTrait;

    private const  PREMIUM = 'premium';
    private const  FREE = 'free';
}

Enum移行の検証

Enumに移行するにあたり既存Traitで行っている事を同様に行えるかどうか、例えば適切な値以外の生成を制限できる事やIDEでの書き味などを比較してみます。

それぞれのEnumを実装してPHPUnitのテストコードで確認していきます。

<?php
/**
 * PHP8.1のEnum実装
 */
enum OrderStatus: string
{
    case PENDING = 'pending';
    case COMPLETED = 'completed';
    case CANCELLED = 'cancelled';
}

/**
 * 独自のTriatを用いたEnum実装
 * @method static self pending()
 * @method static self completed()
 * @method static self cancelled()
 */
class OrderStatusByTrait
{
    use EnumTrait;

    private const  PENDING = 'pending';
    private const  COMPLETED = 'completed';
    private const  CANCELLED = 'cancelled';
}

生成方法の違い

<?php
    /**
     * @test
     */
    public function php8_1_生成(): void
    {
        $orderStatus = OrderStatus::PENDING;
        $this->assertEquals("pending", $orderStatus->value, "PHP 8.1 Enum Generate by case");

        $orderStatus = OrderStatus::from("pending");
        $this->assertEquals("pending", $orderStatus->value, "PHP 8.1 Enum can Generate by value");

        $this->expectException(ValueError::class);
        $this->expectExceptionMessage("\"PENDING\" is not a valid backing value for enum App\Lib\EnumTest\OrderStatus");
        $orderStatus = OrderStatus::from("PENDING");
    }

    /**
     * @test
     */
    public function EnumTrait_生成(): void
    {
        $orderStatus = OrderStatusByTrait::pending();
        $this->assertEquals("pending", $orderStatus->value(), "Trait Enum Generate by static method");

        $orderStatus = OrderStatusByTrait::of("pending");
        $this->assertEquals("pending", $orderStatus->value(), "Trait Enum Generate by value");

        $this->expectException(Exception::class);
        $this->expectExceptionMessage("未定義の値です。(値='PENDING')");
        $orderStatus = OrderStatusByTrait::of("PENDING");
    }

違う点としてはPHP8.1のEnumは定数としてアクセスして生成可能なこと、存在しない値で生成を行おうとした際にErrorがthrowされることです。

Exceptionではないため既存ロジックでExceptionをcatchするようにしていたコードでは捕捉できずエラー発生時に今までと処理が異なる可能性があり、フォームやDBといった文字列からの復元時に注意が必要なことが分かりました。

また、実際にはあまりこういう書き方はしないと思いますが、PHP8.1のEnumは定義されていない値で生成しようとするとPHPStormではエラー表示がされました。

Enum同士の比較

<?php
    /**
     * @test
     */
    public function PHP8_1_比較(): void
    {
        $this->assertTrue(OrderStatus::PENDING === OrderStatus::PENDING, "PHP 8.1 Enum Equal Object");
        $this->assertTrue(OrderStatus::PENDING->name === OrderStatus::PENDING->name, "PHP 8.1 Enum Equal name");
        $this->assertTrue(OrderStatus::PENDING->value === OrderStatus::PENDING->value, "PHP 8.1 Enum Equal value");
    }

    /**
     * @test
     */
    public function EnumTrait_比較(): void
    {
        // この振る舞いがPHP8.1のenumと違う(Traitの方はtrueが返りません)
        $this->assertFalse(OrderStatusByTrait::pending() === OrderStatusByTrait::pending(), "Trait Enum Equal Object");
        // nameは用意していない
        // $this->assertTrue(OrderStatusByTrait::pending()->name() === OrderStatusByTrait::pending()->name(), "Trait Enum Equal name");
        $this->assertTrue(OrderStatusByTrait::pending()->value() === OrderStatusByTrait::pending()->value(), "Trait Enum Equal value");
    }

PHP8.1のEnumはClassではなく、厳密比較でも直接比較を行う事が出来ます。一方で独自実装はClassなのでインスタンス同士の比較はFalseとなり、正しく比較するためにはvalueで比較する必要があります。

これは独自実装で注意が必要なポイントだったため、PHP8.1のEnumを使う事でわかりやすくなります。

valueを更新出来てしまわないか

<?php
    /**
     * @test
     */
    public function PHP8_1_代入_valueへ代入可能か(): void
    {
        // valueへ代入可能?
        // -> 出来ない(エラー)
        $this->expectException(Error::class);
        $this->expectExceptionMessageMatches("/Cannot modify readonly property/");
        $orderStatus = OrderStatus::PENDING;
        $orderStatus->value = "hoge";
    }
    /**
     * @test
     */
    public function PHP8_1_代入_定義されている値なら変更可能か(): void
    {
        // 定義されている値なら変更可能?
        // -> 出来ない(エラー)
        $this->expectException(Error::class);
        $this->expectExceptionMessageMatches("/Cannot modify readonly property/");
        $orderStatus = OrderStatus::PENDING;
        $orderStatus->value =  OrderStatus::CANCELLED->value;
    }

    /**
     * @test
     */
    public function EnumTrait_代入_valueへ代入可能か(): void
    {
        // valueへ代入可能?
        // -> 出来ない(例外)
        $this->expectException(Exception::class);
        $this->expectExceptionMessageMatches("/全てのセッターは禁止されています/");
        $orderStatus = OrderStatusByTrait::pending();
        $orderStatus->value = "hoge";
    }

    /**
     * @test
     */
    public function EnumTrait_代入_定義されている値なら変更可能か(): void
    {
        // 定義されている値なら変更可能?
        // -> 出来ない(例外)
        $this->expectException(Exception::class);
        $this->expectExceptionMessageMatches("/全てのセッターは禁止されています/");
        $orderStatus = OrderStatusByTrait::pending();
        $orderStatus->value = OrderStatusByTrait::cancelled()->value();
    }

これはTraitがPHP8よりも前につくられた為にreadonly propertyではないからという違いになります。

readonly propertyへの代入を行おうとするとPHPStorm、PHPStanではエラーがでるため独自実装の実行時例外と比べて安全に書けるようになります。

メソッド追加ができるか

<?php
    // PHP8.1のEnumにこのメソッドを追加
    public function isCompleteOrCancelled(): bool
    {
        return  $this === self::COMPLETED || $this === self::CANCELLED;
    }

    // 独自実装のEnumにこのメソッドを追加
    public function isCompleteOrCancelled(): bool
    {
        return $this->value() === self::completed()->value() || $this->value() === self::cancelled()->value();
    }


    /**
     * @test
     */
    public function PHP8_1_追加メソッド(): void
    {
        // メソッド生やす
        $orderStatus = OrderStatus::CANCELLED;
        $this->assertTrue($orderStatus->isCompleteOrCancelled(), "PHP 8.1 Enum can add method");
    }

    /**
     * @test
     */
    public function EnumTrait_追加メソッド(): void
    {
        // メソッド生やす
        $orderStatus = OrderStatusByTrait::cancelled();
        $this->assertTrue($orderStatus->isCompleteOrCancelled(), "Trait Enum can add method");
    }

メソッドの追加については(比較の手間はありますが)問題無く同じように出来ました。PHP8.1のEnumにメソッドについての制約があった場合少々困るかもと思いましたが問題ありません。

全要素の取得ができるか

<?php
    /**
     * @test
     */
    public function PHP8_1_全要素取得(): void
    {
        // 全項目取得
        $orderStatuses = OrderStatus::cases();
        $this->assertCount(3, $orderStatuses, "PHP 8.1 Enum cases count");
        $this->assertEquals("pending", $orderStatuses[0]->value, "PHP 8.1 Enum cases value");
        $this->assertEquals("completed", $orderStatuses[1]->value, "PHP 8.1 Enum cases value");
        $this->assertEquals("cancelled", $orderStatuses[2]->value, "PHP 8.1 Enum cases value");
    }

    /**
     * @test
     */
    public function EnumTrait_全要素取得(): void
    {
        // 全項目取得
        $orderStatuses = OrderStatusByTrait::allCases();
        $this->assertCount(3, $orderStatuses, "Trait Enum allCases count");
        $this->assertEquals("pending", $orderStatuses[0]->value(), "Trait Enum allCases value");
        $this->assertEquals("completed", $orderStatuses[1]->value(), "Trait Enum allCases value");
        $this->assertEquals("cancelled", $orderStatuses[2]->value(), "Trait Enum allCases value");
    }

全項目の取得はメソッド名は異なりますが配列で取得出来、同等の使い方ができる事が分かりました。

Switch文が書けるか

<?php
    /**
     * @test
     */
    public function PHP8_1_switch(): void
    {
        $orderStatus = OrderStatus::PENDING;
        switch ($orderStatus) {
            case OrderStatus::PENDING:
                $this->assertTrue(true, "PHP 8.1 Enum switch");
                return;
        }

        $this->assertTrue(false, "ここには来ないはず");
    }


    /**
     * @test
     */
    public function EnumTrait_switch(): void
    {
        // switchは緩やかな比較なのでオブジェクト比較でもマッチする
        // https://www.php.net/manual/ja/control-structures.switch.php
        $orderStatus = OrderStatusByTrait::pending();
        switch ($orderStatus) {
            case OrderStatusByTrait::pending():
                $this->assertTrue(true, "Trait Enum switch");
                return;
        }

        $this->assertTrue(false, "ここには来ないはず");
    }

    /**
     * @test
     */
    public function EnumTrait_switch_value(): void
    {
        $orderStatus = OrderStatusByTrait::pending();
        switch ($orderStatus->value()) {
            case OrderStatusByTrait::pending()->value():
                $this->assertTrue(true, "Trait Enum switch");
                return;
        }
        $this->assertTrue(false, "ここには来ないはず");
    }

Switch文も問題なく可能でした、ただし独自実装が緩やかな比較が行われるとvalueの持ち方によっては意図しない結果になる可能性があるためPHP8.1のEnumの方が安心できそうです。(そもそも今後は緩やかな比較を行うswitch文よりもmatch式を使用した方がよさそうですが)

match式での使用

PHP8.1のEnumをmatch式に使用した場合不足しているcaseがあるとPHPStorm、PHPStanでは警告を出してくれます。

独自実装では警告が出ないためうっかり漏れが発生しないようユニットテストでカバーしていました。

結論

PHP8.1のEnumは独自実装のものと比較してExceptionではなくErrorがthrowされることに注意が必要ですが、 機能的にも問題無く、言語機能として組み込まれているためPHPStormなどのIDEやPHPStanの静的解析による支援が得られる事から新規実装ではPHP8.1のEnumを使用して、既存実装も適宜リファクタリングを行い独自実装は廃止して行く方針としようと思います。

おわりに

PHP8.0、8.1ではPHPの言語機能も強化されより安全かつ生産性高く書きやすいようになりました。ホワイトプラスでは今後生産性の観点でも言語のアップデートを実施して行きたいと考えています。

ホワイトプラスでは、ビジョンバリュー、そしてホワイトプラスの開発文化に共感していただけるエンジニアを募集しています!

ネットクリーニングの「リネット」など「生活領域×テクノロジー」で事業を展開している会社です。どんな会社か気になった方はオウンドメディア「ホワプラSTYLE」をぜひご覧ください。

オンラインでカジュアル面談もできますので、今回の記事の内容に興味を持っていただけたら、ぜひお気軽にお問い合わせください。

open.talentio.com