Devin.KR

타입 시스템 심화 - 엄격 모드와 유니언 타입

개발자KR 조회 0

이 장에서 배우는 것

기본서에서 함수 매개변수에 타입을 붙이는 방법은 이미 다루었다. 이 장은 그 타입 선언이 실제로 언제, 어느 파일의 호출에 대해 검사되는지에서 시작한다. 이어서 둘 이상의 타입을 한 자리에 쓰는 표기와 반환 타입에만 쓰는 특수 타입을 정리하고, 타입 오류 메시지를 읽는 순서와 코드를 실행하지 않고 오류를 찾는 정적 분석 도구의 쓰임까지 살핀다. 예제는 동네 학원의 강좌와 수강료를 다루는 작은 코드이며, 이후 장에서 같은 도메인을 계속 키운다.

  • declare(strict_types=1) 이 어떤 호출에 적용되고 어떤 호출에는 적용되지 않는지 설명한다.
  • 유니언 타입, 교차 타입, nullable 타입을 구분해 쓰고 각각의 통과 조건을 말한다.
  • never, mixed, static 반환 타입의 의미와 알맞은 사용처를 가린다.
  • TypeError 메시지를 구성 요소별로 읽고 원인이 되는 호출을 찾는다.
  • 정적 분석 도구가 실행 전에 무엇을 잡아 주는지, 문법 검사와 어떻게 다른지 안다.

문제 상황

수강료를 다루는 함수가 있다고 하자. 신청 폼에서는 수강료가 "25000" 이라는 문자열로 들어오고, 데이터베이스에서는 정수로 나오며, 엑셀에서 옮긴 값은 25000.0 같은 실수다. 함수가 int $won 으로 선언되어 있어도 PHP 는 기본 설정에서 이 값들을 조용히 정수로 바꿔 준다. 문자열 "25000" 은 통과하고, 일부 상황에서는 "25000원" 같은 값도 앞부분만 읽혀 경고와 함께 넘어가던 시절의 습관이 코드에 남아 있기도 하다.

이런 변환은 편하지만 오류의 위치를 흐린다. 수강료 필드에 날짜 문자열이 들어갔을 때 계산 함수가 아니라 한참 뒤의 합계 화면에서 이상한 숫자로 드러난다. 그래서 새 코드는 파일 첫머리에 엄격 모드를 선언하는 편이 안전하다. 그런데 한 가지 오해가 흔하다. 파일 하나에 선언했다고 해서 프로젝트 전체의 호출이 엄격해지지는 않는다. 오래된 파일 하나가 선언 없이 같은 함수를 호출하면 그 호출은 여전히 느슨하게 변환된다. 먼저 이 범위를 정확히 잡아야 한다.

strict_types 가 적용되는 범위

선언의 위치와 효력

declare(strict_types=1); 은 파일의 첫 번째 문장이어야 한다. 여는 태그와 주석은 그 앞에 와도 되지만 다른 코드가 앞서면 컴파일 오류가 난다. 효력은 그 파일 하나에만 미친다. require 로 불러온 파일이나 불러온 쪽 파일로 번지지 않는다.

핵심은 판정 기준이 함수를 정의한 파일이 아니라 호출문이 있는 파일이라는 점이다. 엄격 파일에서 호출하면 인자는 선언된 타입과 정확히 맞아야 하고, 선언 없는 파일에서 같은 함수를 호출하면 같은 인자가 변환되어 들어간다. 호출하는 쪽이 책임을 지는 구조이므로, 라이브러리 작성자가 엄격 모드를 선언했다고 해서 사용자의 느슨한 호출까지 막을 수는 없다.

같은 함수를 호출해도 호출한 파일의 strict_types 선언 여부에 따라 문자열 인자가 거부되거나 변환된다.

반대로 반환 값의 검사는 함수를 정의한 파일의 모드를 따른다. 선언 없는 파일에서 function seats(): int { return "12"; } 로 정의하면 반환 시점에 정수 12 로 변환되고, 엄격 파일에서 이 함수를 호출해도 정수를 받는다. 값이 오가는 방향에 따라 심판이 달라진다고 기억하면 된다. 인자는 호출한 파일이, 반환은 정의한 파일이 판정한다.

엄격 모드에서 허용되는 변환

엄격 모드라고 해서 변환이 전혀 없는 것은 아니다. 정수를 float 매개변수에 넘기는 것은 허용된다. 값의 손실이 없는 확장이기 때문이다. 그 외에는 문자열, 정수, 실수, 불리언 사이의 변환이 모두 TypeError 로 바뀐다. 내장 함수도 예외가 아니다. 엄격 파일에서 strlen(2024) 를 호출하면 정수를 문자열로 바꿔 주지 않고 오류를 던진다.

호출 상황별로 타입 검사를 정하는 파일과 방식
상황기준이 되는 파일방식이 장 예제에서
사용자 함수에 인자 전달호출문이 있는 파일선언 있으면 엄격main.php 의 formatFee("25000")
같은 함수를 선언 없는 파일에서 호출호출문이 있는 파일느슨한 변환legacy.php 의 legacyFee()
내장 함수에 인자 전달호출문이 있는 파일선언 있으면 엄격strlen($year)
반환 값 검사함수를 정의한 파일정의 파일의 모드를 따름legacyCount() 가 "7" 을 7 로 반환

합성 타입과 특수 타입

유니언, nullable, 교차

유니언 타입(union type)은 세로선으로 이어 쓴다. int|float|string 이면 세 타입 가운데 하나에 해당하는 값을 받는다. 엄격 모드에서도 유니언에 포함된 타입이면 그대로 통과하고, 포함되지 않은 타입은 거부된다. 유니언을 받은 함수는 본문에서 실제 타입을 좁혀 처리해야 한다. 예제의 normalizeFee() 가 문자열이면 숫자인지 확인하고, 실수면 반올림하고, 정수면 그대로 돌려주는 방식이다.

?Course 처럼 물음표를 붙인 nullable 타입은 Course|null 과 같은 뜻이다. 조회 함수가 결과 없음을 표현할 때 쓴다. 물음표를 붙이면 호출하는 쪽이 null 처리를 해야 한다는 사실이 시그니처에 드러난다. 예외를 던질지 null 을 돌려줄지는 호출 맥락에 따라 정하며, 이 책은 두 방식을 함께 쓴다.

교차 타입(intersection type)은 앰퍼샌드로 잇는다. HasTitle&HasCapacity 는 두 인터페이스를 모두 구현한 객체만 받는다. 유니언이 허용 범위를 넓히는 표기라면 교차는 요구 조건을 늘리는 표기다. 클래스 하나를 지정하기엔 너무 좁고 타입을 안 쓰기엔 너무 넓을 때, 함수가 실제로 호출하는 메서드만 인터페이스로 요구하는 용도에 어울린다. 교차 타입은 클래스와 인터페이스끼리만 조합할 수 있고, int&string 처럼 원시 타입에는 쓸 수 없다.

유니언은 하나만 맞으면 통과하고 교차는 전부 맞아야 통과하며 nullable 은 해당 타입 또는 null 이다.

오류 메시지에서 유니언 멤버의 순서는 선언한 순서와 다를 수 있다. PHP 는 타입을 내부 규칙에 따라 정렬해 보여 주므로 int|float|string 으로 쓴 타입이 string|int|float 로 출력된다. 메시지를 읽을 때 순서가 아니라 구성원의 집합으로 본다.

never, mixed, static

never 는 반환 타입으로만 쓰며, 함수가 호출한 쪽으로 돌아오지 않는다는 뜻이다. 예외를 던지거나 프로세스를 끝내는 함수에 붙인다. 이 타입이 있으면 $x ?? fail('메시지') 처럼 실패 경로를 식 안에 넣어도 읽는 사람과 분석 도구가 그 뒤로 값이 이어지지 않음을 알 수 있다. 본문이 끝까지 실행되어 그냥 반환되면 TypeError 가 난다.

mixed 는 모든 값을 받는다는 선언이다. null 도 포함한다. 타입을 쓰지 않은 것과 의미는 거의 같지만, 일부러 열어 두었다는 의사가 드러난다는 점이 다르다. 로깅이나 디버그 출력처럼 값의 종류에 관여하지 않는 함수에 알맞고, 도메인 함수에서 타입 고민을 피하려고 쓰는 것은 권하지 않는다.

static 반환 타입은 메서드가 호출된 객체의 실제 클래스를 돌려준다는 약속이다. 부모 클래스에 withCapacity(): static 을 정의하면, 자식 클래스 객체에서 호출했을 때 자식 타입 객체를 받는다. self 로 선언하면 부모 타입만 보장되므로 자식 전용 메서드를 이어서 부를 수 없다. 값을 바꾼 복사본을 돌려주는 메서드에 쓰기 좋다.

합성 타입과 특수 타입의 표기, 통과 조건, 쓰임
표기통과 조건알맞은 용도주의
int|string나열한 타입 중 하나입력 형태가 둘 이상인 경계 함수본문에서 타입을 좁혀 처리해야 한다
?CourseCourse 또는 null조회 결과가 없을 수 있는 반환호출 쪽의 null 확인이 필요하다
A&B두 타입을 모두 만족필요한 메서드만 요구하는 매개변수원시 타입은 조합할 수 없다
never반환하지 않음예외를 던지는 도우미 함수정상 종료하면 TypeError 다
static호출된 객체의 클래스복사본을 돌려주는 메서드반환 타입으로만 쓴다

타입 오류 읽기와 정적 분석

TypeError 메시지의 구조

타입 오류 메시지는 일정한 틀을 갖는다. 예를 들어 formatFee(): Argument #1 ($won) must be of type int, string given, called in … on line 40 은 앞에서부터 읽으면 된다. 어느 함수인지, 몇 번째 인자이며 이름이 무엇인지, 기대한 타입과 실제로 받은 타입이 무엇인지, 마지막으로 호출한 위치가 어디인지가 차례로 나온다. 원인 대부분은 호출한 위치를 열어 보면 바로 보인다. 함수 본문이 아니라 호출문이 틀린 경우가 많기 때문이다.

반환 타입 위반은 Return value must be of type int, string returned 로 나오며, 이때는 함수 본문의 return 이 원인이다. 예제 프로그램은 출력이 매번 같아야 해서 호출 위치를 뜻하는 , called in 이후를 잘라서 보여 준다. 경로와 줄 번호는 실행 환경마다 다르기 때문이다.

정적 분석 도구 개요

정적 분석(static analysis)은 코드를 실행하지 않고 읽어서 타입 불일치와 도달할 수 없는 분기, 정의되지 않은 메서드 호출 같은 문제를 찾는 방식이다. PHP 에서는 PHPStan 과 Psalm 이 널리 쓰인다. 이 책은 외부 패키지 없이 진행하므로 설치와 설정은 다루지 않지만, 개념은 알아 둘 만하다.

PHP 에 내장된 php -l 은 문법 검사만 한다. formatFee("25000") 이 엄격 파일에 있어도 문법은 올바르므로 통과한다. 이 호출이 오류가 되는 것은 실행하는 순간이다. 정적 분석 도구는 선언된 타입과 인자의 타입을 대조해 실행 전에 이 줄을 지적한다. 이때 배열 안의 요소 타입처럼 언어 문법으로 쓸 수 없는 정보는 @param array<int, Course> $courses 같은 문서 주석으로 알려 준다. 도구는 이 주석을 읽을 뿐 실행 중에는 검사하지 않는다.

정리하면 세 겹의 방어가 있다. 문법 검사는 코드가 파싱되는지 보고, 정적 분석은 실행 전에 타입 모순을 찾고, 엄격 모드의 TypeError 는 실행 중 경계에서 마지막으로 막는다. 서로 대체하지 않고 겹쳐서 쓴다.

완성 코드

세 파일을 같은 폴더에 두고 php main.php 로 실행한다. lib.php 와 main.php 는 엄격 모드를 선언하고, legacy.php 는 일부러 선언하지 않는다.

lib.php

<?php
declare(strict_types=1);

function formatFee(int $won): string
{
    return number_format($won) . '원';
}

legacy.php

<?php
// 이 파일에는 declare(strict_types=1) 이 없다. 일부러 느슨하게 둔다.

function legacyFee(): string
{
    return formatFee("25000");
}

function legacyCount(): int
{
    return "7";
}

main.php

<?php
declare(strict_types=1);

require __DIR__ . '/lib.php';
require __DIR__ . '/legacy.php';

interface HasTitle
{
    public function title(): string;
}

interface HasCapacity
{
    public function capacity(): int;
}

class Course implements HasTitle, HasCapacity
{
    public function __construct(
        private readonly string $title,
        private readonly int $capacity,
        private readonly int $fee,
    ) {
    }

    public function title(): string
    {
        return $this->title;
    }

    public function capacity(): int
    {
        return $this->capacity;
    }

    public function withCapacity(int $capacity): static
    {
        return new static($this->title, $capacity, $this->fee);
    }
}

class NightCourse extends Course
{
}

final class Seminar implements HasTitle
{
    public function title(): string
    {
        return '무료 특강';
    }
}

function brokenCount(): int
{
    return "3";
}

function normalizeFee(int|float|string $raw): int
{
    if (is_string($raw)) {
        if (!ctype_digit($raw)) {
            throw new InvalidArgumentException("수강료 형식 오류: {$raw}");
        }
        return (int) $raw;
    }
    if (is_float($raw)) {
        return (int) round($raw);
    }
    return $raw;
}

function fail(string $message): never
{
    throw new DomainException($message);
}

/**
 * @param array<int, Course> $courses
 */
function findCourse(array $courses, int $id): ?Course
{
    return $courses[$id] ?? null;
}

/**
 * @param array<int, Course> $courses
 */
function requireCourse(array $courses, int $id): Course
{
    return findCourse($courses, $id) ?? fail("강좌 {$id}번이 없다");
}

function summary(HasTitle&HasCapacity $item): string
{
    return sprintf('%s (정원 %d명)', $item->title(), $item->capacity());
}

function describeValue(mixed $value): string
{
    return get_debug_type($value);
}

function attempt(string $label, Closure $action): void
{
    try {
        $result = $action(); // 먼저 계산해야 예외가 났을 때 라벨이 두 번 찍히지 않는다
        echo $label, ' => ', $result, PHP_EOL;
    } catch (TypeError $e) {
        echo $label, ' => TypeError: ', explode(', called in', $e->getMessage())[0], PHP_EOL;
    } catch (InvalidArgumentException | DomainException $e) {
        echo $label, ' => ', get_class($e), ': ', $e->getMessage(), PHP_EOL;
    }
}

$courses = [
    1 => new Course('파이썬 기초반', 12, 30000),
    2 => new NightCourse('야간 알고리즘', 8, 45000),
];
$year = 2024;

echo '== strict_types 의 범위 ==', PHP_EOL;
attempt('엄격 호출 int', fn() => formatFee(25000));
attempt('엄격 호출 string', fn() => formatFee("25000"));
attempt('느슨한 파일 경유', fn() => legacyFee());
attempt('내장 함수', fn() => (string) strlen($year));
attempt('반환 값 검사', fn() => (string) brokenCount());
echo '느슨한 파일의 반환 타입: ', get_debug_type(legacyCount()), PHP_EOL;

echo '== 유니언 타입 ==', PHP_EOL;
attempt('string 입력', fn() => formatFee(normalizeFee("30000")));
attempt('float 입력', fn() => formatFee(normalizeFee(19999.6)));
attempt('int 입력', fn() => formatFee(normalizeFee(45000)));
attempt('잘못된 문자열', fn() => formatFee(normalizeFee("3만원")));
attempt('null 입력', fn() => formatFee(normalizeFee(null)));

echo '== nullable · never ==', PHP_EOL;
attempt('존재하는 강좌', fn() => requireCourse($courses, 1)->title());
attempt('없는 강좌(?->)', fn() => findCourse($courses, 3)?->title() ?? '(없음)');
attempt('없는 강좌(never)', fn() => requireCourse($courses, 3)->title());

echo '== 교차 · static · mixed ==', PHP_EOL;
attempt('교차 성공', fn() => summary($courses[1]));
attempt('교차 실패', fn() => summary(new Seminar()));
attempt('static 반환', fn() => get_debug_type($courses[2]->withCapacity(10)));
$samples = [1, 1.5, 'a', null, [], true, $courses[1]];
echo 'mixed 검사: ', implode(', ', array_map(describeValue(...), $samples)), PHP_EOL;

줄별 해설

lib.php 는 int $won 을 받아 천 단위 구분 쉼표를 붙인 문자열을 돌려준다. 이 파일의 선언은 이 파일 안의 호출과 반환 검사에만 영향을 준다. 이 함수를 누가 부르느냐가 이 장의 실험 대상이다.

legacy.php 에는 선언이 없다. legacyFee() 는 문자열 "25000" 을 formatFee 에 넘기는데, 호출문이 선언 없는 파일에 있으므로 정수 25000 으로 변환되어 통과한다. legacyCount() 는 int 반환을 선언하고 문자열 "7" 을 반환한다. 정의한 파일이 느슨하므로 반환 시점에 정수 7 로 바뀐다.

main.php 앞부분에서는 선언을 첫 문장에 두고 두 파일을 불러온다. 인터페이스 HasTitle 과 HasCapacity 는 교차 타입 실험에 쓸 작은 계약이다. Course 는 두 인터페이스를 구현하고 생성자 승격으로 속성을 선언한다. withCapacity() 는 new static 으로 호출된 클래스의 새 객체를 만들고 반환 타입도 static 이다. NightCourse 는 아무것도 추가하지 않는 자식이며 static 의 효과를 보여 주려고 둔다. Seminar 는 HasTitle 만 구현해 교차 타입을 통과하지 못하는 예가 된다.

brokenCount() 는 int 반환을 선언하고 문자열을 반환한다. 엄격 파일에서 정의했으므로 변환 없이 TypeError 가 된다. legacyCount() 와 대비되는 사례다.

normalizeFee() 는 유니언 인자를 받아 문자열일 때 ctype_digit 로 숫자만 있는지 확인한다. 통과하면 정수로 바꾸고, 아니면 InvalidArgumentException 을 던진다. 실수는 반올림해 정수로, 정수는 그대로 반환한다. null 은 유니언에 없으므로 함수 본문에 들어오기 전에 거부된다.

fail() 은 never 를 선언하고 도메인 예외를 던진다. requireCourse() 는 findCourse() 결과가 null 이면 ?? 오른쪽의 fail() 을 실행한다. 이 함수의 반환 타입이 Course 로 좁혀지는 이유가 이 구조에 있다. findCourse() 의 문서 주석은 정적 분석 도구가 배열 요소 타입을 알 수 있게 하는 정보다.

summary() 의 매개변수는 교차 타입이다. 같은 내용을 두 인터페이스 메서드만 쓰므로 Course 클래스에 묶이지 않는다. describeValue() 는 mixed 를 받아 get_debug_type() 이 돌려주는 타입 이름 문자열을 반환한다.

attempt() 는 클로저를 실행하며 TypeError 의 메시지를 출력한다. explode(', called in', …)[0] 은 호출 위치를 잘라 내 출력을 결정적으로 만든다. 도메인 예외는 클래스 이름과 메시지를 함께 보여 준다. TypeError 는 Error 계열이라 뒤의 catch 와 겹치지 않는다. 본문 호출은 위에서부터 범위 실험, 유니언, nullable 과 never, 교차와 static 과 mixed 순서이며, 마지막 줄은 describeValue(...) 를 첫 클래스 호출 가능 객체 문법으로 array_map 에 넘긴다.

실행 결과

$ php main.php
== strict_types 의 범위 ==
엄격 호출 int => 25,000원
엄격 호출 string => TypeError: formatFee(): Argument #1 ($won) must be of type int, string given
느슨한 파일 경유 => 25,000원
내장 함수 => TypeError: strlen(): Argument #1 ($string) must be of type string, int given
반환 값 검사 => TypeError: brokenCount(): Return value must be of type int, string returned
느슨한 파일의 반환 타입: int
== 유니언 타입 ==
string 입력 => 30,000원
float 입력 => 20,000원
int 입력 => 45,000원
잘못된 문자열 => InvalidArgumentException: 수강료 형식 오류: 3만원
null 입력 => TypeError: normalizeFee(): Argument #1 ($raw) must be of type string|int|float, null given
== nullable · never ==
존재하는 강좌 => 파이썬 기초반
없는 강좌(?->) => (없음)
없는 강좌(never) => DomainException: 강좌 3번이 없다
== 교차 · static · mixed ==
교차 성공 => 파이썬 기초반 (정원 12명)
교차 실패 => TypeError: summary(): Argument #1 ($item) must be of type HasTitle&HasCapacity, Seminar given
static 반환 => NightCourse
mixed 검사: int, float, string, null, array, bool, Course
$ php -l main.php
No syntax errors detected in main.php

같은 formatFee 가 엄격 호출에서는 거부되고 느슨한 파일을 거치면 통과한 점, 그리고 php -l 이 타입 오류를 전혀 잡지 못한 점을 눈여겨본다.

실무에서 자주 틀리는 것

선언을 첫 문장 뒤에 둔다

다른 코드가 앞서면 컴파일 단계에서 치명 오류가 나고 파일이 아예 실행되지 않는다.

<?php
require __DIR__ . '/lib.php';
declare(strict_types=1);   // 오류: 첫 번째 문장이어야 한다
<?php
declare(strict_types=1);

require __DIR__ . '/lib.php';

기본값 null 로 암묵적 nullable 을 만든다

PHP 8.4 부터 타입을 쓰고 기본값만 null 로 둔 매개변수는 사용 중단 경고를 낸다. 경고 없이 컴파일하려면 nullable 임을 타입에 적는다.

function findByName(string $name, Course $parent = null): ?Course
{
    return null;
}
function findByName(string $name, ?Course $parent = null): ?Course
{
    return null;
}

nullable 반환값을 확인하지 않고 사용한다

시그니처가 ?Course 인데 바로 메서드를 부르면 결과가 없을 때 null 에 대한 메서드 호출 오류가 난다. 정적 분석 도구도 이 줄을 지적한다. 결과 없음을 어떻게 다룰지 정해 둔다.

echo findCourse($courses, 3)->title();
echo findCourse($courses, 3)?->title() ?? '(없음)';
// 또는 없는 경우를 오류로 보는 호출이면
echo requireCourse($courses, 3)->title();

타입 고민을 피하려고 mixed 를 쓴다

mixed 로 받으면 함수 안에서 모든 타입을 가정해야 하고, 잘못된 값이 경계에서 걸러지지 않는다. 입력이 실제로 몇 가지 형태뿐이라면 유니언으로 좁힌다.

function normalizeFee(mixed $raw): int
{
    return (int) $raw;   // "3만원" 이 3 이 되어 조용히 지나간다
}
function normalizeFee(int|float|string $raw): int
{
    // 본문에서 문자열을 검증하고 나머지를 처리한다 (완성 코드 참고)
}

한눈에 보기

이 장의 개념을 한 줄로 정리한 표
개념핵심예
strict_types파일 단위, 호출문이 있는 파일이 인자 검사를 정한다formatFee("25000") 은 엄격 파일에서 오류
반환 검사정의한 파일의 모드를 따른다legacyCount() 는 정수를 돌려준다
유니언나열한 타입 중 하나int|float|string
nullable타입 또는 null?Course
교차나열한 타입을 모두 만족HasTitle&HasCapacity
never반환하지 않는다fail()
static호출된 객체의 클래스withCapacity()
검사 도구문법, 정적 분석, 실행 중 오류가 서로 다른 단계다php -l, PHPStan, TypeError

연습 문제

  1. 엄격 파일 A 가 선언 없는 파일 B 의 function seats(): int { return "12"; } 를 호출한다. 반환 값의 타입은 무엇인가. 반대로 B 가 A 에 정의된 function reserve(int $n) 을 reserve("5") 로 호출하면 어떻게 되는가.
  2. 엄격 파일에 function discount(int|float $rate): float 이 있다. discount(1), discount(0.5), discount("0.5"), discount(null) 중 통과하는 호출과 TypeError 가 나는 호출을 가려라.
  3. function abort(string $m): never { echo $m; } 의 문제를 설명하고 두 가지 방법으로 고쳐라.
  4. 완성 코드의 Seminar 가 summary() 를 통과하도록 수정하라. 정원은 30명으로 한다.

정답과 해설

  1. 반환 값은 정수 12 다. 반환 검사는 함수를 정의한 B 의 모드를 따르고 B 는 느슨하므로 문자열이 변환된다. 두 번째 질문은 호출문이 B 에 있으므로 B 의 모드가 적용되어 "5" 가 정수 5 로 변환되고 통과한다. A 가 엄격 모드이든 아니든 결과는 같다.
  2. discount(1) 과 discount(0.5) 는 유니언에 포함된 타입이라 통과한다. discount("0.5") 는 문자열이 유니언에 없고 엄격 모드에서는 변환되지 않으므로 TypeError 다. discount(null) 도 TypeError 다. nullable 로 선언하지 않았기 때문이다.
  3. never 함수는 호출한 쪽으로 돌아오지 않아야 하는데 이 함수는 출력만 하고 끝까지 실행되어 TypeError 가 난다. 고치는 방법 하나는 예외를 던지는 것이다: function abort(string $m): never { throw new RuntimeException($m); }. 다른 방법은 exit($m); 로 프로세스를 끝내는 것이다. 메시지를 출력하고 계속 진행하고 싶다면 반환 타입을 void 로 바꿔야 하며, 그 경우 이름도 never 의미와 어울리게 고친다.
  4. Seminar 가 HasCapacity 도 구현하면 된다.
    final class Seminar implements HasTitle, HasCapacity
    {
        public function title(): string
        {
            return '무료 특강';
        }
    
        public function capacity(): int
        {
            return 30;
        }
    }
    이렇게 바꾸면 summary(new Seminar()) 가 무료 특강 (정원 30명) 을 돌려준다. 교차 타입은 클래스의 계통이 아니라 구현한 계약만 보므로 Course 를 상속할 필요가 없다.

다음 장에서는 수강 상태처럼 값이 몇 가지로 정해진 경우를 열거형과 값 객체로 표현해, 잘못된 값이 만들어지는 것 자체를 막는 방법을 다룬다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.