Menu

Zero raises와 check: 예외 없는 명시적 실패

Zero 함수는 raises로 실패 모드를 선언하고 호출자는 check로 그것을 인지합니다. 시스템이 어떻게 동작하는지, 왜 조용한 throw가 없는지, 그리고 World 능력과 어떻게 맞물리는지 살펴봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 — 편집하고 실행하면 결과를 바로 볼 수 있습니다.

출발점

Zero에서 실패는 별도의 평행 제어 흐름이 아닙니다. 일반 제어 흐름의 일부로, 함수 시그니처에 선언되고 모든 호출 지점에서 인정됩니다. 두 조각이 이걸 가능하게 합니다.

  • raises — 함수 시그니처. "이 함수는 실패할 수 있다."
  • check — 호출 지점. "이게 실패하면 같은 에러로 내 함수를 실패시켜라."

이 조합만으로 대부분의 언어에서 try/catch나 Result 타입에 손이 가는 일들을 표현할 수 있습니다.

실패 가능한 함수 선언하기

반환 타입 뒤에 raises를 붙입니다.

fun validate(ok: Bool) -> i32 raises { InvalidInput } {
    if ok == false {
        raise InvalidInput
    }
    return 42
}

시그니처를 읽는 두 가지 방법:

  • "validatei32를 반환하거나 InvalidInput을 일으킨다."
  • "가능한 결과 집합은 { i32, InvalidInput }이다."

둘 다 맞습니다. 컴파일러는 두 가능성을 모두 추적하며, 호출자가 각각에 대해 명시적인 무언가를 하도록 요구합니다.

단독 raises 절도 쓸 수 있어요.

pub fun main(world: World) -> Void raises {
    check world.out.write("hello\n")
}

에러 목록 없는 raises는 "이 함수는 어떤 에러로든 실패할 수 있다"는 뜻입니다. main에서는 이게 관습적인 형태입니다 — 무언가 잘못되면 프로그램은 0이 아닌 상태로 종료될 수 있고, 런타임이 에러를 노출시키는 일을 처리해 줍니다.

호출 스택 깊은 곳의 함수에서는 명시적 형태 raises { ErrorA, ErrorB }를 선호하세요. 각 단계에서 실패 모드가 문서화되도록요.

에러 발생시키기

실패 가능한 함수 안에서 raise는 주어진 에러로 함수를 종료합니다.

fun validate(ok: Bool) -> i32 raises { InvalidInput } {
    if ok == false {
        raise InvalidInput
    }
    return 42
}

raise InvalidInput은 그 줄에서 InvalidInput 에러를 만들어 냅니다. 함수는 그 지점 이후로 계속되지 않아요. 제어가 호출자에게 돌아가고, 호출자는 i32 대신 에러를 봅니다. raises 절은 이 함수가 일으킬 수 있는 유일한 에러 타입을 나열합니다. 목록에 없는 것을 일으키면 컴파일 에러입니다.

check로 에러 전파하기

실패 가능한 함수를 호출하는 쪽은 실패 가능성을 인정해야 합니다. 가장 흔한 인정은 check입니다.

fun run() -> Void raises { InvalidInput } {
    check validate(true)
}

check validate(true)는 두 가지를 합니다.

  1. validate(true)를 호출합니다.
  2. validate가 에러를 일으켰다면 위로 전파합니다 — run자기 호출자에게 같은 에러를 일으킵니다.

전파가 허용되려면 run도 자기 raises 절에 InvalidInput(또는 호환되는 것)을 일으킬 수 있다고 선언해야 해요. 컴파일러가 이를 확인합니다. run의 시그니처가 raises { OtherError }였다면, InvalidInput이 집합에 없어서 전파는 컴파일에 실패합니다.

언어 공식 샘플에서 가져온 완전한 작동 예제 — Run을 눌러 전파가 성공하는 걸 확인해 보세요.

에러 타입은 함수 시그니처와 함께 호출 스택 끝까지 따라 올라갑니다. main의 단독 raisesrun이 일으킬 수 있는 어떤 것이든 받아 주므로, 전파가 안전하게 안착합니다.

왜 try/catch가 아닐까?

raises/check 뒤의 설계 원칙은 호출 지점에서 실패가 결코 보이지 않을 수 없다는 것입니다. try/catch 언어에서는 예외가 자기 관여 가능성을 모르는 함수를 조용히 통과할 수 있습니다 — 그 함수는 순수해 보이지만, 본문 어딘가 깊은 호출이 throw해서 그것을 풀어나가게 되죠.

throw하는 코드 작성자에게는 편리합니다. 다른 모두에게는 비용입니다.

  • 시그니처로는 함수가 실패 경로에 참여하는지 알 수 없습니다(에이전트도 마찬가지).
  • 리팩토링이 긴장됩니다. 호출을 함수 사이로 옮기는 것만으로 어떤 예외가 도달 가능한지가 바뀔 수 있어요.
  • 복구 코드가 무엇을 할지 아는 자리에서 멀리 떨어져 살게 됩니다.

Zero는 비용을 앞에서 치릅니다. 모든 실패 가능한 함수에 어노테이션, 모든 실패 가능한 호출에 check. 그 대신 "함수가 참여하는 실패 모드가 시그니처에서 보인다"는 속성을 얻습니다. 사람과 에이전트 모두 의지할 수 있는 속성이에요.

왜 그냥 Result<T, E>가 아닐까?

같은 것을 choiceokerr 변종을 가진 Result<T, E> 타입 — 으로 표현할 _수_도 있습니다. Zero는 그 패턴도 제공해요. 실패를 _데이터_로 검사하고, 저장하고, 전달하고 싶을 때 유용한 도구입니다.

raises/check가 추가하는 것은 흔한 경우를 위한 문법 차원의 관례입니다. "이게 실패하면 같은 방식으로 내 함수도 실패시켜라." 그게 없다면 거의 모든 호출이 에러를 호출자의 Result에 다시 포장하는 match로 감싸지게 될 거예요. check는 그것을 위한 단축이며, 컴파일러가 전파가 타입에 맞는지 보장합니다.

그러니까:

  • Result<T, E> (choice) — 실패를 값으로 검사하거나 운반하고 싶을 때.
  • raises + check — 실패를 호출 스택 위로 그저 전파하고 싶을 때.

둘 다 제공됩니다. 서로 다른 인체 공학적 요구를 커버해요.

여러 에러 타입

함수는 한 가지 이상의 에러를 일으킬 수 있습니다.

fun parse(input: String) -> i32 raises { Empty, Malformed } {
    if std.mem.len(input) == 0 {
        raise Empty
    }
    // ... 파싱 로직 ...
    raise Malformed
}

호출자가 할 수 있는 일:

  • 자기 시그니처가 EmptyMalformed를 모두(또는 그것을 포함하는 더 큰 집합을) 나열한다면 check parse(input)으로 전파.
  • match나 언어가 제공하는 try 스타일 구조로 하나 또는 둘을 명시적으로 처리.

세밀한 처리(특정 에러 타입에 매치하면서 다른 것은 전파) 문법은 1.0 이전 Zero에서 움직일 수 있는 표면 중 하나입니다. 계약 — 함수가 일으킬 수 있는 모든 에러 타입이 시그니처에 있다 — 이 안정된 부분이에요.

멘탈 모델

Zero의 실패 시스템은 모든 명령형 언어가 가진 것의 엄격-정직 버전입니다.

개념Try/CatchZero
함수를 실패 가능으로 표시없음 (암묵적)raises { ... }
에러 발생throw eraise E
호출자에게 전파보이지 않게 거품처럼 올라옴check call(...)
지역에서 처리try { ... } catch(e) { ... }반환된 Result에 대한 match나 타입 처리 형태

행동 차이는 작습니다. 어노테이션 차이는 큽니다 — 의도된 거예요. 효과는 명시적. 실패도 효과입니다.

스타일 노트

  • check를 마음껏 쓰세요. 이 층위에서 특별히 할 일이 없다면 그게 올바른 기본값입니다.
  • 내부 도우미에서 단독 raises는 피하세요. 에러 집합이 좁을수록 시그니처가 유용해집니다.
  • 실패 가능한 연산을 그것에 필요한 능력과 함께 두세요. stdout에 쓰는 World 사용 함수는 대개 raises도 필요합니다 — 쓰기가 실패할 수 있으니까요.

다음 글: JSON 진단

raises/check는 Zero의 컴파일러 진단과 나란히 동작합니다. 적절한 에러를 선언하지 않은 함수에 check를 적었다면, 컴파일러는 무엇이 잘못됐는지 구조화된 형태로 정확히 알려줘요. 다음 문서에서는 JSON 진단 — 에이전트가 코드를 수정하기 위해 읽는 기계가 읽을 수 있는 피드 — 를 다룹니다.

자주 묻는 질문

Zero에서 raises는 무슨 뜻인가요?

함수 시그니처의 raises는 그 함수가 실패할 수 있음을 선언합니다. 단독 raises는 어떤 에러 타입이든 허용하고, raises { InvalidInput }처럼 구체적인 형태는 나열된 에러 타입으로만 실패하도록 제한합니다. 호출자는 실패 가능성을 인정해야 해요 — check로 처리하거나 다른 명시적 처리 형태를 사용합니다.

check 연산자는 무엇을 하나요?

check exprexpr을 평가하고, 에러가 나오면 그것을 현재 함수의 호출자에게 전파합니다. '이걸 실행해라, 실패하면 같은 에러로 내 호출자도 실패시켜라'라고 생각하면 됩니다. 호출자의 raises 절이 그 전파를 허용하려면, 호출자도 호환되는 에러를 일으킬 수 있다고 선언되어 있어야 해요.

Zero에서 에러는 어떻게 발생시키나요?

그 에러를 포함하는 raises 절이 있는 함수 안에서 raise ErrorName을 사용합니다. 예: if ok == false { raise InvalidInput }. 그 지점에서 함수가 종료되고, 에러가 함수의 결과가 되며, 호출자는 그것을 check할 수 있습니다.

Zero는 왜 try/catch를 쓰지 않나요?

try/catch는 예외가 자기 존재를 모르는 함수를 조용히 통과하게 둡니다. Zero의 설계는 모든 함수의 시그니처가 자신이 참여하는 실패 모드를 인정해야 한다는 것이에요. 숨은 제어 흐름이 없습니다. 함수가 실패할 수 있으면 시그니처가 그렇게 말하고, 호출자는 check로 명시적으로 인지해야 해요.

Zero에서 함수가 여러 에러 타입을 일으킬 수 있나요?

네 — raises { ... } 절에 쉼표로 나열합니다(또는 에러 집합에 대한 현재 Zero 문법을 따르세요). 가능한 에러 집합은 매개변수, 반환 타입과 마찬가지로 함수 계약의 일부입니다. 호출자는 어떤 에러가 발생했는지 패턴 매칭할 수도 있고, check로 그냥 전파할 수도 있어요.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기