PHP 언어 레퍼런스 — Enumerations

PHP 언어 레퍼런스 — Enumerations (열거형)

원문: https://www.php.net/manual/en/language.enumerations.php PHP 8.1.0 이상

Enumerations(열거형) 개요

Enumerations, 줄여서 "Enum"은 개발자가 값을 딱 정해진 몇 개 중 하나로 제한한 사용자 정의 타입을 만들 수 있게 해줘요. 도메인 모델을 정의할 때 특히 유용한데, "유효하지 않은 상태를 아예 표현할 수 없게" 만들어 주거든요.

Enum은 여러 언어에 각기 다른 모습으로 등장해요. PHP에서 Enum은 객체의 한 특별한 종류예요. Enum 자신은 클래스이고, 그 가능한 케이스들은 모두 그 클래스의 단일 인스턴스(singleton) 객체예요. 즉 Enum 케이스는 유효한 객체이면서, 타입 검사 등 객체가 쓰일 수 있는 어디든 사용할 수 있어요.

열거형의 가장 대표적인 예는 내장된 boolean 타입이에요. truefalse를 값으로 갖는 열거형이죠. Enum을 쓰면 개발자가 자신만의, 얼마든지 강건한 열거형을 정의할 수 있어요.

기본 열거형

Enum은 클래스와 비슷하고, 클래스·인터페이스·트레이트와 같은 네임스페이스를 공유해요. 같은 방식으로 자동 로드(autoload)도 되어요. Enum은 고정되고 제한된 개수의 값만 가질 수 있는 새로운 타입을 정의해요.

<?php

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

이 선언은 Suit라는 새 열거형 타입을 만드는데, 이 타입은 Suit::Hearts, Suit::Diamonds, Suit::Clubs, Suit::Spades 네 값만 가질 수 있어요. 변수에는 그 값들 중 하나만 할당할 수 있고, 함수도 열거형을 타입으로 검사받을 수 있어요. 이 경우 그 타입의 값만 전달할 수 있죠.

<?php

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

function pick_a_card(Suit $suit)
{
    var_dump($suit);
}

$val = Suit::Diamonds;

// OK
pick_a_card($val);

// OK
pick_a_card(Suit::Clubs);

// TypeError: pick_a_card(): Argument #1 ($suit) must be of type Suit, string given
pick_a_card('Spades');

Enumeration은 case 정의를 0개 이상 가질 수 있고, 상한은 없어요. 케이스가 0개인 enum도 문법적으로는 유효하지만, 실용적으로는 쓸모가 없어요.

Enumeration 케이스에는 PHP의 다른 라벨과 동일한 문법 규칙이 적용돼요. 자세한 건 Constants를 참고해요.

기본적으로 케이스는 스칼라 값에 자동으로 연결(back)되지 않아요. 즉 Suit::Hearts"0"과 같지 않아요. 대신 각 케이스는 그 이름을 가진 싱글턴 객체로 뒷받침돼요. 그 말은 곧:

<?php

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

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

if ($a === $b) {
    print "Suits match using ===\n";
}

if ($a instanceof Suit) {
    print "Suits match using instanceof\n";
}

if ($a !== 'Spades') {
    print "Suit does not match the string\n";
}

또한 enum 값끼리는 <> 비교가 의미가 없기 때문에 그런 비교는 절대 성립하지 않아요. enum 값끼리 그런 비교를 하면 항상 **false**를 돌려줘요.

이렇게 연관된 데이터가 없는 케이스를 "Pure Case"라고 불러요. Pure Case만 들어 있는 Enum을 Pure Enum이라고 해요.

모든 Pure Case는 자기 enum 타입의 인스턴스로 구현돼요. enum 타입은 내부적으로 클래스로 표현돼요.

모든 Case는 name이라는 읽기 전용 프로퍼티를 가지는데, 이건 케이스 이름 그 자체를 대소문자 그대로 담고 있어요.

<?php

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

print Suit::Spades->name;
// prints "Spades"

이름을 동적으로 얻어온 경우 defined()constant() 함수로 enum 케이스의 존재를 확인하거나 값을 읽을 수도 있어요. 다만 대부분의 쓰임은 Backed enum으로 해결되기 때문에 이 방법은 권장하지 않아요.

Backed(스칼라 연결) 열거형

기본적으로 열거형 케이스는 스칼라 대응값이 없어요. 그저 싱글턴 객체일 뿐이죠. 그런데 열거형 케이스를 데이터베이스나 비슷한 저장소에 왔다 갔다(round-trip) 해야 하는 경우가 많아요. 그래서 내장된 스칼라 값(그래서 손쉽게 직렬화되는 값)을 처음부터 정의해 두는 게 유용해요.

Enumeration에 스칼라 대응값을 정의하는 문법은 이렇게 생겼어요.

<?php

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

스칼라 대응값을 가진 케이스를 Backed Case라고 불러요. 더 단순한 값으로 "뒷받침(back)"되기 때문이에요. 모든 케이스가 Backed Case인 Enum을 "Backed Enum"이라고 부르고, Backed Enum은 Backed Case만, Pure Enum은 Pure Case만 가질 수 있어요.

Backed Enum은 intstring 타입으로 뒷받침될 수 있고, 한 enum은 한 번에 한 타입만 지원해요. 즉 int|string 같은 유니언은 안 돼요. 열거형이 스칼라 대응값을 가진다고 표시되면, 모든 케이스는 스칼라 대응값을 명시적으로, 서로 다르게 정의해야 해요. 자동 생성되는 스칼라 대응값(예: 순차 정수)은 없어요. Backed case는 서로 달라야 해요. 두 backed enum 케이스가 같은 스칼라 대응값을 가질 수 없어요. 다만 상수가 케이스를 가리켜 사실상 별칭(alias)을 만들 수는 있어요. Enumeration constants를 참고해요.

대응값은 상수 스칼라 표현식이 될 수 있어요. PHP 8.2.0 이전에는 대응값이 리터럴이나 리터럴 표현식이어야 했어요. 즉 상수와 상수 표현식은 지원되지 않았어요. 다시 말해 1 + 1은 허용됐지만 1 + SOME_CONST는 허용되지 않았어요.

Backed Case에는 value라는 추가적인 읽기 전용 프로퍼티가 있어요. 정의에 적은 값 그 자체를 담고 있어요.

<?php

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

print Suit::Clubs->value;
// Prints "C"

value 프로퍼티를 읽기 전용으로 강제하기 위해, 변수를 그 값의 참조로 지정할 수는 없어요. 즉 다음 코드는 에러를 던져요.

<?php

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

$suit = Suit::Clubs;
$ref = &$suit->value;
// Fatal Error: Cannot indirectly modify readonly property Suit::$value

Backed enum은 내부 BackedEnum 인터페이스를 구현하는데, 이 인터페이스가 추가 메서드 두 개를 노출해요.

  • from(int|string): self는 스칼라를 받아 그에 해당하는 Enum Case를 돌려줘요. 찾지 못하면 ValueError를 던져요. 입력 스칼라가 신뢰할 만하고, enum 값이 없으면 애플리케이션을 멈추는 에러로 봐야 하는 경우에 주로 유용해요.
  • tryFrom(int|string): ?self는 스칼라를 받아 그에 해당하는 Enum Case를 돌려줘요. 찾지 못하면 **null**을 돌려주죠. 입력 스칼라를 신뢰할 수 없고 호출자가 직접 에러 처리나 기본값 로직을 만들고 싶을 때 주로 유용해요.

from()tryFrom() 메서드는 표준 weak/strong 타이핑 규칙을 따라요. 약한 타이핑 모드에서는 정수나 문자열을 넘겨도 괜찮고, 시스템이 값을 적절히 변환(coerce)해요. float를 넘겨도 변환되어 동작해요. 강한 타이핑 모드에서는 string-backed enum의 from()에 정수를 넘기면(또는 그 반대) TypeError가 나고, float는 어떤 경우든 TypeError가 나요. 그 외의 모든 파라미터 타입은 두 모드에서 모두 TypeError를 던져요.

<?php

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

function get_stuff_from_database($id) {
    return [
        'suit' => 'S',
    ];
}

$record = get_stuff_from_database(42);
print $record['suit'] . "\n";

$suit = Suit::tryFrom('A') ?? Suit::Spades;
// Invalid data returns null, so Suit::Spades is used instead.
print $suit->value . "\n";

$suit =  Suit::from('X');
// Invalid data throws a ValueError: "X" is not a valid backing scalar value for enum Suit
print $suit->value . "\n";

Backed Enum에 from()이나 tryFrom() 메서드를 직접 정의하면 치명적 에러(fatal error)가 나요.

Enumeration 메서드

Enum(순수하든 Backed든)은 메서드를 가질 수 있고, 인터페이스를 구현할 수도 있어요. Enum이 인터페이스를 구현하면, 그 인터페이스에 대한 타입 검사는 그 Enum의 모든 케이스를 통과시켜요.

<?php

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

enum Suit implements Colorful
{
    case Hearts;
    case Diamonds;
    case Clubs;
    case Spades;

    // Fulfills the interface contract.
    public function color(): string
    {
        return match ($this) {
            Suit::Hearts, Suit::Diamonds => 'Red',
            Suit::Clubs, Suit::Spades => 'Black',
        };
    }

    // Not part of an interface; that's fine.
    public function shape(): string
    {
        return "Rectangle";
    }
}

function paint(Colorful $c)
{
   print $c->color() . "\n";
}

paint(Suit::Clubs);  // Works

print Suit::Diamonds->shape(); // prints "Rectangle"

이 예시에서 Suit의 네 인스턴스 모두 color()shape() 두 메서드를 가져요. 호출 코드와 타입 검사가 보기에, 이들은 다른 객체 인스턴스와 완전히 똑같이 동작해요.

Backed Enum에서는 인터페이스 선언이 뒷받침 타입 선언 다음에 와요.

<?php

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

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

    // Fulfills the interface contract.
    public function color(): string
    {
        return match ($this) {
            Suit::Hearts, Suit::Diamonds => 'Red',
            Suit::Clubs, Suit::Spades => 'Black',
        };
    }
}

메서드 안에서 $this 변수가 정의되어 있고, 이건 Case 인스턴스를 가리켜요.

메서드는 얼마든지 복잡해질 수 있지만, 실제로는 보통 정적 값을 돌려주거나 $this에 대해 match를 써서 케이스마다 다른 결과를 내는 경우가 많아요.

참고로 이 경우엔 Red와 Black을 값으로 갖는 SuitColor Enum 타입을 따로 정의하고 그걸 돌려주는 편이 데이터 모델링 관점에서 더 나아요. 다만 그렇게 하면 이 예시가 복잡해지니까 여기선 그렇게 하지 않았어요.

위 계층 구조는 논리적으로 다음 클래스 구조와 비슷해요. 다만 이게 실제로 실행되는 코드는 아니에요.

<?php

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

final class Suit implements UnitEnum, Colorful
{
    public const Hearts = new self('Hearts');
    public const Diamonds = new self('Diamonds');
    public const Clubs = new self('Clubs');
    public const Spades = new self('Spades');

    private function __construct(public readonly string $name) {}

    public function color(): string
    {
        return match ($this) {
            Suit::Hearts, Suit::Diamonds => 'Red',
            Suit::Clubs, Suit::Spades => 'Black',
        };
    }

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

    public static function cases(): array
    {
        // Illegal method, because manually defining a cases() method on an Enum is disallowed.
        // See also "Value listing" section.
    }
}

메서드는 public, private, protected가 될 수 있어요. 다만 상속이 허용되지 않으므로 실제로는 private와 protected가 동일해요.

Enumeration 정적 메서드

Enumeration은 정적 메서드도 가질 수 있어요. enum 자체의 정적 메서드는 주로 대체 생성자(alternative constructor)로 쓰여요. 예를 들면:

<?php

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

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

var_dump(Size::fromLength(50));

정적 메서드는 public, private, protected가 될 수 있어요. 상속이 없으므로 실제로는 private와 protected가 동일해요.

Enumeration 상수

Enumeration은 상수를 포함할 수 있어요. 상수는 public, private, protected가 될 수 있고, 역시 상속이 없어서 private와 protected는 실질적으로 같아요.

enum 상수는 enum 케이스를 가리킬 수 있어요.

<?php

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

    public const Huge = self::Large;
}

var_dump(Size::Huge);

Traits(트레이트)

Enumeration은 트레이트를 활용할 수 있고, 클래스에서처럼 동작해요. 한 가지 조건이 있는데, enum에서 use하는 트레이트는 프로퍼티를 가져선 안 돼요. 메서드, 정적 메서드, 상수만 포함할 수 있어요. 프로퍼티가 있는 트레이트를 쓰면 치명적 에러가 나요.

<?php

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

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

enum Suit implements Colorful
{
    use Rectangle;

    case Hearts;
    case Diamonds;
    case Clubs;
    case Spades;

    public function color(): string
    {
        return match ($this) {
            Suit::Hearts, Suit::Diamonds => 'Red',
            Suit::Clubs, Suit::Spades => 'Black',
        };
    }
}

$suit = Suit::Spades;
var_dump($suit->color());
var_dump($suit->shape());

상수 표현식 안의 Enum 값

케이스는 enum 자신의 상수로 표현되므로, 대부분의 상수 표현식에서 정적 값으로 쓰일 수 있어요. 프로퍼티 기본값, 정적 변수 기본값, 파라미터 기본값, 전역·클래스 상수 값이 그 대상이에요. 다른 enum 케이스의 값으로는 사용할 수 없지만, 일반 상수가 enum 케이스를 가리키는 것은 가능해요.

다만 enum에 대한 ArrayAccess 같은 묵시적 매직 메서드 호출은 정적·상수 정의에서 허용되지 않아요. 결과 값이 결정적(deterministic)이라고 보장할 수 없고, 메서드 호출에 부수 효과(side effect)가 없다고 보장할 수 없기 때문이에요. 함수 호출, 메서드 호출, 프로퍼티 접근은 여전히 상수 표현식에서 유효하지 않은 연산이에요.

<?php

// This is an entirely legal Enum definition.
enum Direction implements ArrayAccess
{
    case Up;
    case Down;

    public function offsetExists($offset): bool
    {
        return false;
    }

    public function offsetGet($offset): mixed
    {
        return null;
    }

    public function offsetSet($offset, $value): void
    {
        throw new Exception();
    }

    public function offsetUnset($offset): void
    {
        throw new Exception();
    }
}

class Foo
{
    // This is allowed.
    const DOWN = Direction::Down;

    // This is disallowed, as it may not be deterministic.
    const UP = Direction::Up['short'];
    // Fatal error: Cannot use [] on enums in constant expression
}

// This is entirely legal, because it's not a constant expression.
$x = Direction::Up['short'];
var_dump("\$x is " . var_export($x, true));

$foo = new Foo();

객체와의 차이점

Enum은 클래스와 객체 위에 만들어졌지만, 객체 관련 기능을 전부 지원하지는 않아요. 특히 enum 케이스는 상태(state)를 가질 수 없어요.

  • 생성자와 소멸자는 금지돼요.
  • 상속은 지원되지 않아요. Enum은 다른 것을 상속할 수도, 상속될 수도 없어요.
  • 정적 프로퍼티나 객체 프로퍼티는 허용되지 않아요.
  • 케이스는 싱글턴 인스턴스여야 하므로 enum 케이스를 복제(clone)할 수 없어요.
  • 아래 목록에 없는 매직 메서드는 금지돼요.
  • Enum은 사용되기 전에 항상 먼저 선언되어야 해요.

다음 객체 기능은 사용 가능하고, 다른 객체에서처럼 똑같이 동작해요.

  • public, private, protected 메서드.
  • public, private, protected 정적 메서드.
  • public, private, protected 상수.
  • Enum은 인터페이스를 몇 개든 구현할 수 있어요.
  • Enum과 케이스에는 속성(attribute)을 붙일 수 있어요. TARGET_CLASS 타깃 필터는 Enum 자신을 포함하고, TARGET_CLASS_CONST 타깃 필터는 Enum Case를 포함해요.
  • __call, __callStatic, __invoke 매직 메서드.
  • **__CLASS__**와 __FUNCTION__ 상수는 평소처럼 동작해요.

Enum 타입의 ::class 매직 상수는 객체에서와 똑같이 네임스페이스를 포함한 타입 이름으로 평가돼요. Case 인스턴스의 ::class 매직 상수도 Enum 타입으로 평가돼요. 인스턴스가 그 타입이니까요.

추가로, enum 케이스는 new로 직접 인스턴스화할 수 없고, 리플렉션의 ReflectionClass::newInstanceWithoutConstructor()로도 만들 수 없어요. 둘 다 에러가 나요.

<?php

$clovers = new Suit();
// Error: Cannot instantiate enum Suit

$horseshoes = (new ReflectionClass(Suit::class))->newInstanceWithoutConstructor()
// Error: Cannot instantiate enum Suit

값 목록 (Value listing)

Pure Enum과 Backed Enum 모두 UnitEnum이라는 내부 인터페이스를 구현해요. UnitEnumcases()라는 정적 메서드를 포함하는데, 이 메서드는 선언 순서대로 정의된 모든 Case를 담은 밀집 배열(packed array)을 돌려줘요.

<?php

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

var_dump(Suit::cases());

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

var_dump(SuitBacked::cases());

Enum에 cases() 메서드를 직접 정의하면 치명적 에러가 나요.

직렬화 (Serialization)

Enumeration은 객체와 다르게 직렬화돼요. 특히 "E"라는 새 직렬화 코드를 쓰는데, 이 코드가 enum 케이스의 이름을 지정해요. 역직렬화 루틴은 그 값을 보고 변수를 기존 싱글턴 값으로 설정할 수 있어요. 이렇게 하면 다음이 보장돼요.

<?php

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

Suit::Hearts === unserialize(serialize(Suit::Hearts));

print serialize(Suit::Hearts);
// E:11:"Suit:Hearts";

역직렬화할 때 직렬화된 값에 맞는 enum과 케이스를 찾지 못하면 경고가 발생하고 **false**가 돌아와요.

unserialize()allowed_classes 옵션은 Enumeration에는 영향을 주지 않아요.

Pure Enum을 JSON으로 직렬화하면 에러가 나요. Backed Enum을 JSON으로 직렬화하면 적절한 타입의 스칼라 값만으로 표현돼요. 둘 다 JsonSerializable을 구현해서 동작을 바꿀 수 있어요.

print_r()의 경우, 헷갈림을 줄이기 위해 enum 케이스의 출력은 객체와 조금 달라요.

<?php

enum Foo
{
    case Bar;
}

enum Baz: int
{
    case Beep = 5;
}

print_r(Foo::Bar);
print_r(Baz::Beep);

/* Produces

Foo Enum (
    [name] => Bar
)
Baz Enum:int {
    [name] => Beep
    [value] => 5
}
*/

Enum이 확장 불가능한 이유

클래스는 그 메서드에 계약(contract)이 있어요.

<?php

class A {}
class B extends A {}

function foo(A $a) {}

function bar(B $b)
{
    foo($b);
}

이 코드는 타입 안전해요. B가 A의 계약을 따르고, 공변/반변(co/contra-variance) 덕분에 메서드에 대한 기대가 유지되기 때문이에요. 예외는 예외로 치고 말이죠.

Enum은 메서드가 아니라 케이스에 계약이 있어요.

<?php

enum ErrorCode
{
    case SOMETHING_BROKE;
}

function quux(ErrorCode $errorCode)
{
    // When written, this code appears to cover all cases
    match ($errorCode) {
        ErrorCode::SOMETHING_BROKE => true,
    };
}

quux 함수의 match 문은 정적 분석을 통해 ErrorCode의 모든 케이스를 다룬다고 판단할 수 있어요.

그런데 enum 확장이 허용된다고 상상해 보죠.

<?php

// Thought experiment code where enums are not final.
// Note, this won't actually work in PHP.
enum MoreErrorCode extends ErrorCode
{
    case PEBKAC;
}

function fot(MoreErrorCode $errorCode)
{
    quux($errorCode);
}

fot(MoreErrorCode::PEBKAC);

일반적인 상속 규칙 아래에서는 다른 클래스를 상속한 클래스가 타입 검사를 통과해요.

문제는 quux()의 match 문이 더 이상 모든 케이스를 다루지 않게 된다는 거예요. MoreErrorCode::PEBKAC를 모르기 때문에 match가 예외를 던지게 되죠.

그래서 enum은 final이고, 확장될 수 없어요.

예시

Example #1 기본적인 제한 값

<?php

enum SortOrder
{
    case Asc;
    case Desc;
}

function query($fields, $filter, SortOrder $order = SortOrder::Asc)
{
     /* ... */
}

이제 query() 함수는 $orderSortOrder::AscSortOrder::Desc 중 하나임이 보장된다는 안심을 하고 진행할 수 있어요. 다른 값이 들어오면 TypeError가 났을 테니, 추가적인 에러 검사나 테스트가 필요 없어요.

Example #2 고급 배타적 값

<?php

enum UserStatus: string
{
    case Pending = 'P';
    case Active = 'A';
    case Suspended = 'S';
    case CanceledByUser = 'C';

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

$status = UserStatus::Suspended;
var_dump($status->label());

이 예시에서 사용자 상태는 UserStatus::Pending, UserStatus::Active, UserStatus::Suspended, UserStatus::CanceledByUser 중 하나, 그리고 오직 이 중 하나뿐이에요. 함수는 UserStatus로 파라미터 타입을 지정하면 그 네 값만 받아들여요. 그걸로 끝이에요.

네 값 모두 label() 메서드를 가지는데, 이 메서드는 사람이 읽을 수 있는 문자열을 돌려줘요. 이 문자열은 스칼라 대응값인 "기계 이름(machine name)"과는 독립적이에요. 기계 이름 쪽은 예를 들어 DB 필드나 HTML select 박스에 쓰면 되고요.

<?php

enum UserStatus: string
{
    case Pending = 'P';
    case Active = 'A';
    case Suspended = 'S';
    case CanceledByUser = 'C';

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

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