Rust의 오류 처리(오류 가능성) 효과를 어떻게 표기하고 다룰지에 대한 문법적·의미적 설계 공간을 훑고, `throws`/`throw` 중심의 일관된 표기와 추상 `impl Try`의 가치를 논한다.
URL: https://blog.yoshuawuyts.com/syntactic-musings-on-the-fallibility-effect/
Rust가 nightly에 try-함수에서 새 오류를 반환하는 데 쓸 수 있는 불안정 키워드 yeet를 추가한 게 아마 3~4년 전쯤이었던 것 같다. 더 최근에는 nightly에 불안정 키워드 bikeshed가 추가되어 try-블록과 클로저가 자신이 어떤 종류의 오류를 다루는지 표현할 수 있게 되었다. 우리는 한동안 오류 처리 문법에 대한 결정을 미뤄왔고, 나는 그게 사실 꽤 합리적이라고 생각한다.
하지만 안정판으로 가고 싶다면, 언젠가는 문법을 결정해야 한다. 그래서 여기서의 전체 문법적 공간을 처음부터 끝까지 한 번 훑어보는 것도 나쁘지 않다고 생각한다. 그리고 불안정한 상태인 만큼 의미론(semantic) 공간도 함께 훑는 건 피할 수 없을 것 같다. 길게 말하면 이런 뜻이다. 이 글에서는 Rust의 “오류 가능성(fallibility) 효과(effect)”에 대한 내 의견을 공유한다. 주된 이유는 그냥 이런 걸 생각해보는 게 재밌기 때문이다.
Rust의 오류 처리 공간은 세 가지 범주로 나눌 수 있다. 첫 번째 범주는 효과 타입 표기(effect type notation)를 담는 위치들이다. 그 수는 세 가지다. 즉 언어의 항목(items) 중에서 다음과 같이 말하고 싶은 지점들이다. “여기 이건 오류 처리 컨텍스트에서 동작하고 있어”. 해당 항목들은 다음과 같다.
{})fn f() {})|| {})두 번째 범주는 효과 연산자(effect operators)다. 이는 오류 가능성 효과로 표시된 항목 내부에서 사용되며, 그 효과를 대상으로 연산할 수 있게 해준다. 지원해야 하는 연산은 세 가지다.
yield).await)block_on, 반복에서의 for..in)지금까지 본 두 범주는 Rust의 모든 제어 흐름 효과(control-flow effects)(async, 오류 가능성, 반복)에 공통적으로 적용된다. 하지만 세 번째 범주는 오류 가능성에만 고유한데, 바로 특수화(specialization)다. 오류 가능성은 두 모드로 동작할 수 있다.
-> impl Try로 구체화(reify)됨-> io::Result)으로 구체화됨오류 가능성은 마치 트렌치코트를 입은 두 개의 별도 효과처럼 생각할 수도 있다. 또는 “구체(concrete)”가 “추상(abstract)”의 특수화 버전이라고 볼 수도 있다. 하지만 오류 가능성의 두 맛(flavor)은 목적과 사용 방식이 매우 비슷하므로, 최종 사용자 관점에서는 둘이 서로 비슷하게 느껴져야 한다.
효과 타입은 함수, 클로저, 블록을 모두 커버해야 한다. 이들 모두에 대해 추상/구체 변형을 지원해야 한다. 달리 말하면, 필요할 때 명시적 타입을(선택적으로) 적을 수 있어야 한다.
함수부터 시작하자. Swift는 함수가 오류 가능함을 표시하기 위해 throws 키워드를 쓴다. Java, Kotlin, Scala도 비슷하다고 알고 있다. 이 키워드는 선택적으로 구체 타입을 담을 수 있는데, 나는 그게 완벽하다고 생각한다. Rust에 이를 적용하면 이렇게 될 것이다.
fn foo() {} // `-> ()` (기본)
fn foo() throws {} // `-> impl Try`
fn foo() throws Err(io::Error) {} // `-> io::Result<()>`
fn foo() throws None {} // `-> Option<()>`
fn foo() throws None -> i32 {} // `-> Option<i32>`
여기 표기법은 패턴 타입(pattern types)에서 아이디어를 빌린다. None과 Err는 Rust 프렐류드의 일부로 가져오고(import) 있으며, 함수 본문에서 None과 Err를 쓸 수 있는 것처럼 시그니처에서도 자유롭게 쓸 수 있어야 한다. 그리고 이들이 impl Try의 일부임을 알고 있으므로, 실제 타입이 무엇인지도 알 수 있다. 내가 가진 유일한 미해결 질문은, 이를 impl Try 트레이트 자체에서 어떻게 표현하느냐인데—Try 트레이트는 아직 불안정이니 나중에 정하면 된다.
위치 측면에서 이는 Swift가 하는 방식, 즉 인자 목록 뒤이면서 화살표(->) 앞에 효과를 나열하는 방식을 따른다. 내게는 이게 합리적으로 보인다. 효과는 함수 자체의 속성을 설명하기 때문이다. 그리고 throws는 함수의 출력에 대해 말하므로 인자 목록 _뒤_에 오는 게 자연스럽다. 또한 논리적 반환 타입과 혼동되지 않게 하려면 반환 타입 _앞_에 두는 게 맞다.
또 하나 주의할 점은, 함수 시그니처에서 효과 표기를 언제나 제거해도 여전히 유효한 함수 시그니처가 되어야 한다는 점이다. 이는 효과를 그룹으로 묶거나 집합/변수로 놓고 추론할 때 필요한 중요한 성질이다. 예를 들어: 효과 별칭(effect aliases), 연관 효과(associated effects), 효과 제네릭(effect generics) 같은 것들.
// 함수에 효과를 추가하거나 제거해도
// 나머지 함수 시그니처에는 영향이 없어야 한다:
async fn foo() -> i32; // async 효과
const fn foo() -> i32; // const 효과
fn foo() throws None -> i32; // 오류 가능성 효과
fn foo() -> i32; // 효과 없음(기준선)
// 그러면 효과에 대한 고차 추론(higher-order reasoning)을
// 가능하게 하는 문이 열린다. 재미로 Flix에서 영감받은 표기 사용:
effect ef = async + const + throws None;
fn foo() -> i32 \ ef;
표기법을 끝까지 밀어붙여 일반 목적의 효과 표기로 마이그레이션을 시작해야 한다는 주장도 있을 수 있다. 하지만 여기서는 의도적으로 범위를 줄여 오류 가능성 표기를 어떻게 할지에 대한 보다 국소적인 결정들을 주장하고 있다. 다만 사람들이 “한 번에 일반적/일관적으로 다 해야 한다”고 강하게 느낀다면, 더 일반적인 방향으로 가는 것에도 설득될 수 있다.
블록과 클로저는 쓰는 방식이 매우 비슷하다. 차이는 클로저는 인자 목록을 반드시 나열해야 하고, 반환 타입을 나열할 수도 있다는 점이다. 나는 전체적으로 블록 표기법이 좀 어색하다고 생각하며, 블록도 반환 타입을 나열할 수 있으면 좋겠다고 바란다. 내 의견으로는 Swift의 do 문이 이 부분을 더 잘 하고 있고, 블록에도 그런 게 있으면 좋겠다는 생각이 든다. 하지만 지금 Rust는 그렇지 않으니, 우선은 클로저와 블록을 다음과 같이 쓰는 것으로 시작하자.
// 추상 정의
throws {} // 블록 표기
throws || {} // 클로저 표기
throws || -> i32 {} // 명시적 반환 타입
async throws || {} // 효과 섞기
// 구체 정의
throws None {} // 블록 표기
throws None || {} // 클로저 표기
throws None || -> i32 {} // 명시적 반환 타입
async throws None || {} // 효과 섞기
개인적으로는 여기서 효과가 인자 목록 _앞_에 나열되는 게 마음에 들지 않는다. fn() {} 함수에서는 인자 목록 _뒤_에(그리고 화살표 앞에) 나열되는데, 클로저가 같은 문법 패턴을 따르지 않으면 이상하게 느껴진다. 다만 이를 더 일관되게 만드는 문제는 나중에 다뤄도 될 것 같다.
여기서 눈에 띄는 건 내가 try 키워드를 완전히 생략하기로 했다는 점이다. 내가 보기엔 여기 표기를 할 수 있는 방법이 세 가지 정도 있다.
try {} / try throws None {}try throws {} / try throws None {}throws {} / throws None {}나는 개인적으로 마지막 방식, 즉 try를 완전히 생략하는 것을 선호한다. try를 동사로서 좋아하긴 하지만, throws가 이미 전달하는 정보 외에 추가 정보가 없다. 그래서 Stroustrup의 규칙을 떠올리며 try는 덜어내기로 한다.
오류 가능성에 대해 지원해야 하는 연산은 세 가지다: 생성(create), 전파(propagate), 소비(consume). 이 중 뒤의 두 개는 이미 언어에 있으니, 그부터 시작하자. 코드에서 오류 가능성 효과를 “전달/전파”하려면 공식적으로는 “try 전파 표현식(try propagation expression)”이라고 불리는 것을 사용하면 된다. 더 흔히는 “물음표 연산자(question mark operator)”로 알려져 있다.
let x = foo()?; // `?`는 오류 가능성 효과를 전파한다
try 전파 표현식은 그 자체로 impl Try(추상 또는 구체)를 반환하는 함수에서만 사용할 수 있다. 만약 함수 자체가 오류 불가능(infallible)하거나, 다른 Try 타입을 가진다면, 우리는 오류 가능성 효과를 _소비_해야 한다. 이를 위해 match 키워드를 사용할 수 있다. 추상 impl Try 구현을 다루는 중이라면 먼저 구체 타입으로 캐스팅해야 한다. 하지만 그 외의 로직은 둘 다 동일하다.
// `match`는 구체 `Result` 타입을 소비한다
match foo() {
Ok(x) => { .. }
Err(err) => { .. }
}
// `match`는 추상 `impl Try` 타입을 소비한다
// NOTE: 이는 아마 `Try`의 콤비네이터(combinator)가 되어야 한다
let res = match foo().branch() {
Break(b) => FromResidual::from_residual(b),
Continue(c) => Try::from_output(c),
};
match res {
Ok(x) => { .. }
Err(err) => { .. }
}
마지막으로 빠져 있는 “생성(create)” 연산자는 새 impl Try 타입을 만들 수 있게 해준다. 오늘날 불안정에서는 이것이 yeet라고 불리는데, 이름이 웃기긴 하지만 아마 throw라고 불려야 한다고 생각한다. 함수 내부에서 쓰면 이런 모습이다.
throw None; // -> `return None`
throw Err("hello") // -> `return Err("hello".into())`
이로써 세 가지 제어 흐름 연산이 모두 갖춰졌다: ?, match, throw.
전반적으로 throw 용어를 좋아하지만, 조금 다듬을 여지는 있다고 생각한다. throw로 갈 거라면, 나는 정말로 그쪽으로 확 기울었으면 한다. 내게 try를 명사로 쓰는 건 매우 직관적이지 않다. 그래서 throw 연산을 기반으로 명사와 동사를 구성하는 편이 훨씬 낫다고 본다.
Try → Throwabletry/try..bikeshed → throwsyeet → throw?(try 전파 연산자) → ?(rethrow 연산자)내게 이런 수준의 규칙성은 대단히 매력적이다. 범용 match를 제외하면, 이 항목들은 모두 서로 명확히 연관돼 보인다. 그리고 그건 정말 가치 있다고 생각한다. 예전 글에서 반복(iteration) 효과에 대해서도 비슷한 주장을 한 적이 있다. 내가 마음대로 바꿀 수 있다면(그래야 한다고 말하는 건 아니지만) 이렇게 바꾸고 싶다.
IntoIterator → IterateFromIterator → Collect_Iterate_는 연산의 이름이고, 그 연산은 반복 가능한 _iterator_를 반환하며, 그 iterator는 다시 반복할 수 있다.
async 효과도 정규화가 좀 필요할 수 있다. 우리는 현재 “async”, “future”, “poll” 같은 용어를 섞어 쓰고 있는데, 이를 체화하는 데 시간이 걸린다. 여기서도 어느 정도 용어 정규화가 괜찮을 것 같다(나는 처방이 아니라 잡생각을 하는 중이다).
IntoFuture → WaitFuture → WaitableFuture::poll → Waitable::waitPoll → WaitableState다만 “throwable”과 “waitable”이 있다면 “iterable”도 원할 수 있겠다. 나는 “iterable”을 “iterator”보다 덜 좋아하지만, 개인적으로는 일관성을 더 좋아한다. 그래도 큰 그림에서 보면 이 구체적인 이름은 그리 중요하지 않다.
이 글 앞부분에서 깊게 다루진 않았지만, 나는 현재 우리가 함수가 추상 impl Try를 반환할지 구체 Try 타입을 반환할지를 선택할 수 있다는 점의 가치를 과소평가하고 있다고 생각한다. 내 2019 오류 처리 설문에서, 함수 본문에서 서로 다른 타입(heterogenous types)을 반환하도록 자동으로 가능하게 해주는 auto-enums 크레이트를 강조한 바 있다.
나는 이 기능 조합이 anyhow::Error 타입이 제공하는 가치의 80%를 언어 차원에서 제공하는 방식이 될 수 있다고 본다. 오류 타입을 Box<dyn Error + Send + Sync + 'static>로 캐스팅하는 대신, 오류를 타입별 variant를 가진 익명 enum Error {}로 캐스팅하는 것이다. 그러면 다음이 가능해진다.
이게 어떻게 전개될지 나는 이렇게 본다. 아래의 함수 bar는 throw i32 또는 throw &'static str를 할 수 있다. 컴파일러가 이를 인식하여 두 타입을 모두 담을 수 있는 익명 enum을 만들고, 그것을 오류 타입으로 반환하는 것이다.
fn foo(x: bool) throws { // `throws i32 | &'static str`
bar(x)?;
}
fn bar(x: bool) throws {
if x {
throw 12 // `: i32`
} else {
throw "oops" // `: &'static str`
}
}
이것이 동작하는 정확한 규칙은 다소 미묘할 것이다. 아마 오늘날처럼 ? 디슈거링에서 .into()로 오류를 강제(coerce)하는 걸 그대로 허용할 수는 없을 것이다. 하지만 단기적으로는, 미래를 위해 자리를 비워두는 정도의 기본 규칙으로도 충분할 수 있다고 본다. 그래서 그냥 fn() throws {} 형태의 함수는:
impl Try 타입을 반환해야 한다? 디슈거링 과정에서 타입을 강제하기 위해 .into를 호출하지 않아야 한다내 생각엔 대략 이 정도다. 다만 Scott(T-Lang)에게 내가 뭘 놓쳤는지, 왜 이게 충분하지 않을 수 있는지 꼭 듣고 싶다.
이로써 오류 가능성 효과 표기에 대한 완전한 설계가 끝났다. 모두가 나만큼 throw를 좋아하진 않을 거란 건 알지만, 내 바람은 최소한 논의가 어떤 규칙성으로 수렴하도록 유도하는 것이다. 예를 들어 yeet로 가기로 한다면, 올바른 용어는 yeets/Yeetable/“re-yeet”가 되어야 한다고 믿는다. 나는 개인적으로, 최종적으로 어떤 키워드를 쓰게 되는지보다 우리가 적용하는 시스템과 프레임워크를 더 중요하게 여긴다.
이 글을 쓰며 눈에 띈 점 하나는, 오류 가능성 효과에는 현재 IntoIterator/IntoFuture에 대응하는 도덕적 동등물(moral equivalent)이 없어 보인다는 점이다. ?를 사용할 때 적용되는 자동 Into 변환이 있긴 하다. 하지만 그건 더 범용적이어서, 별도 트레이트가 없을 때의 정확한 함의를 나는 좀 헤매고 있다.
어쨌든 여기까지다. 쓰면서 정말 즐거웠고, 흥미롭게 읽혔으면 한다. 12월 잘 보내시길!
모든 참고문헌 보기