Rust의 bottom 타입과 표준 라이브러리의 Infallible, 그리고 never 타입을 활용해 비종료 함수와 async 백그라운드 작업을 타입 안전하게 모델링하고, JoinSet 등에서 이질적인 반환 타입을 깔끔하게 통합하는 방법을 설명합니다.
Rust로 60초마다 어떤 작업을 수행해야 하는 프로그램을 작성한다고 해 보겠습니다. 간단히 이렇게 쓸 수 있습니다.
rustfn background_task() { loop { println!("Performing background task."); std::thread::sleep(std::time::Duration::from_secs(60)); } } fn main() { background_task(); }
Rust 컴파일러는 종료되지 않는 루프를 꽤 잘 알아차립니다. 예를 들어, loop 마지막에 다음 줄을 추가해 보면:
rustprintln!("Loop finished");
코드는 여전히 컴파일되지만 다음과 같은 경고가 발생합니다.
textwarning: unreachable statement --> src/main.rs:6:5 | 2 | / loop { 3 | | println!("Performing background task."); 4 | | std::thread::sleep(std::time::Duration::from_secs(60)); 5 | | } | |_____- any code following this expression is unreachable 6 | println!("Loop finished"); | ^^^^^^^^^^^^^^^^^^^^^^^^^ unreachable statement
이 무한 루프 감지는 도달 불가능 코드에 대한 경고만 생성하는 것이 아닙니다. 코드 안의 타입에도 영향을 줍니다. 현재 버전의 코드에서 loop는 단위 값 ()를 평가 결과로 갖고, 마찬가지로 background_task 함수도 unit을 반환합니다. 사실 이 부분은 꽤 쉽게 바꿀 수 있습니다.
rustfn background_task() -> u32 { loop { println!("Performing background task."); std::thread::sleep(std::time::Duration::from_secs(60)); } }
여기서 한 일은 background_task 함수에 반환 타입을 하나 추가한 것뿐인데, 여전히 타입 체크가 잘 됩니다! main 안의 호출부를 보면 말이 되긴 합니다. 문장 끝에 세미콜론을 붙이면 "반환값을 버린다"는 의미이므로, 우리가 u32를 반환한다고 주장하더라도 그 값은 그냥 버려지는 셈입니다.
그런데 어떻게 동일한 loop가 unit 값과 u32 값을 모두 평가 결과로 가질 수 있을까요?
이를 이해하기 위해, 잠깐 타입 이론(type theory) 산책을 해 보겠습니다. 먼저 _타입_과 _값_의 관계부터 짚고 가죠. 타입은 값의 집합을 정의합니다. 예를 들어, u32 타입은 0부터 2^32 - 1까지 모든 정수를 표현합니다. String 타입은 임의 길이의 유니코드 코드 포인트 시퀀스를 모두 나타냅니다. 등등.
타입 이론에는 바텀 타입(bottom type)이라는 개념이 있습니다. 위키백과 정의는 조금 난해합니다.
어떤 타입 시스템에서 bottom 타입은 모든 다른 타입의 서브타입인 타입이다
저는 bottom 타입을 이렇게 생각하는 편이 더 이해하기 쉽습니다. 어떠한 값도 갖지 않는 타입입니다. Rust에서 이런 타입을 직접 만들기는 아주 쉽습니다.
rustenum Bottom {}
이 enum에는 변형(variant)이 하나도 없기 때문에, 이 타입의 값을 만드는 것은 불가능합니다. 또, 앞에서 본 것처럼 무한 루프는 어떤 타입이든 반환할 수 있기 때문에, background_task 함수의 반환 타입을 Bottom으로 바꿀 수 있습니다.
rustfn background_task() -> Bottom { loop { println!("Performing background task."); std::thread::sleep(std::time::Duration::from_secs(60)); } }
논리적으로 보면, Bottom 값이 실제로 존재하게 만들 방법은 전혀 없습니다. 따라서, 타입이 Bottom인 값을 본다면, 그 코드에 도달하지 못한다는 것을 확신할 수 있습니다. 이를 약간 장난스럽게 헬퍼 메서드와 match로 강제해 볼 수 있습니다.
rustimpl Bottom { fn use_it(self) { match self {} } } fn main() { let bottom = background_task(); bottom.use_it(); }
아직까지는 크게 유용해 보이진 않습니다. 게다가 이 코드는 (일부만 발췌하자면) 달갑지 않은 경고들을 발생시킵니다.
textwarning: unreachable expression --> src/main.rs:18:5 | 17 | let bottom = background_task(); | ----------------- any code following this expression is unreachable 18 | bottom.use_it(); | ^^^^^^ unreachable expression | note: this expression has type `Bottom`, which is uninhabited
아직 실용적이지는 않아도, bottom 타입으로 "이 일은 절대 일어나지 않는다"를 표현할 수 있다는 것은 보여줍니다. 다른 사용 예를 하나 더 살펴보죠.
가끔은, 어떤 trait이 에러 타입을 정의하도록 요구하는 경우가 있습니다. 대표적인 예가 TryFrom입니다. MyU32라는 헬퍼 타입을 정의하고, u64에서 변환하는 TryFrom을 구현해 보겠습니다. (실제로는 기존의 u32에 대한 TryFrom 구현을 재사용하는 편이 좋습니다. 여기서는 좀 더 명시적으로 보이도록 직접 검사와 캐스팅을 하겠습니다.)
rust#[derive(Debug)] struct MyU32(u32); #[derive(Debug)] struct TooBigError; impl TryFrom<u64> for MyU32 { type Error = TooBigError; fn try_from(value: u64) -> Result<MyU32, TooBigError> { if value > (u32::MAX as u64) { Err(TooBigError) } else { Ok(MyU32(value as u32)) } } } fn main() { println!("{:?}", MyU32::try_from(42u64)); println!("{:?}", MyU32::try_from(u64::MAX)); }
이제 더 작은 정수 타입으로부터의 변환을 해 보죠. u16에서 u32로 옮기는 경우입니다. 이건 전사(total) 변환입니다. 가능한 모든 u16 입력 값이 어떤 u32 출력 값으로 매핑됩니다. 에러가 발생할 여지가 없습니다. 하지만 TryFrom의 구조상 여전히 에러 타입을 정의해야 합니다. 어떻게 할 수 있을까요? 한 가지 방법은 계속 TooBigError 타입을 사용하는 것입니다. 하지만 이름이 굉장히 헷갈립니다.
대신 에러 이름을 CannotError처럼 바꿀 수도 있습니다. 그러면 적어도 코드 모양은 그럴듯해집니다. 하지만 결과를 단순히 출력하는 것 외에 뭔가 더 하려고 하면, 코드에서 고통 포인트가 드러나기 시작합니다.
rust#[derive(Debug)] struct MyU32(u32); #[derive(Debug)] struct CannotError; impl TryFrom<u16> for MyU32 { type Error = CannotError; fn try_from(value: u16) -> Result<MyU32, CannotError> { Ok(MyU32(value as u32)) } } fn main() { match MyU32::try_from(u16::MAX) { Ok(value) => println!("{}", value.0), Err(_) => unreachable!(), } }
여기서 unreachable! 매크로 호출을 명시적으로 넣어야 했다는 점을 주목해 보세요. 저는 이걸 코드 냄새(code smell)라고 생각합니다. 가능하다면, 제 코드 분석이 틀렸을 수도 있는데 "이 코드는 절대 도달하지 않는다"는 주장(assertion)을 적지 않고 싶습니다. 대신, 이런 부분은 컴파일러가 보장해 주기를 선호합니다.
앞서의 bottom 타입 트릭이 여기에도 딱 들어맞습니다! 에러를 표현할 타입은 필요하지만, "에러는 전혀 발생하지 않는다"고 말하고 싶은 상황입니다. 해결책은? 에러 타입으로 bottom 타입을 사용하면 됩니다! CannotError 타입을 다음과 같이 바꾸면 됩니다.
rust#[derive(Debug)] enum CannotError {}
그리고 main 함수에서는 더 이상 에러 케이스를 고려할 필요가 없습니다. 반환 값이 항상 Ok가 될 것임을 알기 때문에, 반박 불가능한 패턴 매칭(irrefutable pattern match)을 사용할 수 있습니다.
rustfn main() { let Ok(value) = MyU32::try_from(u16::MAX); println!("{}", value.0); }
그리고 이런 사용 예는 너무 흔해서, 이미 표준 라이브러리에서 바로 제공됩니다. CannotError를 std::convert::Infallible로 바꾸면 완전히 동일한 동작을 얻을 수 있습니다.
이 예는 타입 이론이 큰 도움이 되는 상황이라고 생각합니다. 우리의 코드는 이제 다음과 같습니다.
try_from의 반환 타입 자체가 에러가 일어날 수 없다는 사실을 말해 줍니다.try_from 호출 지점들이 그 에러를 처리하도록 자동으로 업데이트되어야 하므로 컴파일 에러로 바로 드러납니다.여기까지를 요약하면: Rust에서 bottom 타입은 정의하기 쉽고, 표준 라이브러리의 Infallible은 아주 좋은 예입니다. bottom 타입은 "이 타입의 값은 절대 만들어지지 않는다"를 표현하는 도구이며, 이는 (함수가 결코 종료하지 않는) **비종료(non-termination)**나 (에러처럼) **실제로는 발생하지 않는 코드 분기(branch)**를 표현하는 데 사용할 수 있습니다. 이제 사용성(ergonomics) 이야기로 넘어가 보겠습니다.
background_task는 무엇을 반환해야 할까?bottom 타입에 대한 새로운 이해를 바탕으로, 처음 예제였던 비종료 함수로 돌아가 봅시다.
rustfn background_task() { loop { println!("Performing background task."); std::thread::sleep(std::time::Duration::from_secs(60)); } }
이 함수는 이상적으로 어떤 반환 타입을 가져야 할까요? 지금 구현(unit 반환)을 보면, 최소한 한 가지 단점은 분명합니다. 함수 시그니처만 봐서는 이 함수가 비종료라는 사실을 알 수 없습니다. 꼭 문제가 되는 건 아니지만, 타입 수준에서 얻을 수 있었던 힌트를 잃고 있는 셈입니다.
이 글의 실제 동기 사례는 약간 다른 상황이었습니다. tokio의 JoinSet 안에서 여러 백그라운드 작업을 동시에 실행하고, 이들의 타입을 하나로 통합해야 하는 상황이었죠. Tokio를 직접 예시로 쓰면 좋겠지만, 그러려면 async 코드를 끌어와야 하고, 아직 그럴 준비가 안 됐습니다.
그래서 대신 간단한 비-async JoinSet를 만들어 실험해 봤습니다. (곁다리 이야기: 이 코드를 프로덕션에서 쓰지는 마세요. 이 글을 위해 급히 만든 것이고, 미묘한 버그들이 있을 가능성이 큽니다.) 이를 준비해 둔 뒤, 새 main 모듈을 살펴보겠습니다.
rustmod join_set; fn background_task1() { loop { println!("Performing background task 1."); std::thread::sleep(std::time::Duration::from_secs(60)); } } fn background_task2() -> Result<(), String> { let mut counter = 0; loop { counter += 1; println!("Performing background task 2."); std::thread::sleep(std::time::Duration::from_secs(2)); if counter >= 3 { return Err("Background task 2 errored".to_owned()); } } } fn main() -> Result<(), String> { let mut set: join_set::JoinSet<Result<(), String>> = join_set::JoinSet::new(); set.spawn(background_task1); set.spawn(background_task2); while let Some(res) = set.join_next() { match res { Err(e) => return Err(format!("Background task panicked: {e:?}")), Ok(Err(e)) => return Err(format!("Background task errored out: {e}")), Ok(Ok(())) => (), } } Ok(()) }
이 코드는 컴파일되지 않습니다. background_task1은 unit을 반환하고, background_task2는 Result를 반환하는데, 이 둘을 동시에 실행하려 하다 보니 타입이 맞지 않는다는 컴파일러 에러가 발생합니다.
texterror[E0271]: expected `background_task2` to return `()`, but it returns `Result<(), String>`
이걸 해결하기 위해 시도해 볼 수 있는 방법들을 몇 가지 살펴보겠습니다.
background_task1이 Result를 반환하게 하기아마 구현상 가장 간단한 방법일 겁니다. background_task1 시그니처를 이렇게 바꿉니다.
rustfn background_task1() -> Result<(), String> {
장점 최소한의 손질로 잘 동작합니다.
단점 이 함수를 단독으로 사용하고 싶을 때, 갑자기 처리해야 할 가짜 에러 케이스가 생깁니다. 또, 이 함수를 Result가 아닌 다른 타입을 반환하는 함수들과 함께 JoinSet에 넣고 싶다면, 다시 비슷한 꼼수를 써야 합니다.
함수 자체를 바꾸지 않고, 호출부에서 타입을 바꿀 수도 있습니다.
rustset.spawn(|| { background_task1(); Ok(()) }); set.spawn(background_task2);
장점 각 함수의 시그니처는 여전히 사실 그대로를 말해 줍니다. 적어도, 비종료 함수에 unit 반환 타입을 주는 게 허용하는 한에서 말이죠.
단점 이런 보일러플레이트를 여기저기 반복해서 써야 합니다.
위에서 bottom 타입으로 Infallible을 사용했습니다. 여기서도 써 보죠! 최소한 background_task1은 이렇게 바꾸고 싶을 겁니다.
rustfn background_task1() -> std::convert::Infallible {
그리고 기왕 하는 김에 background_task2도 바꿔 봅시다.
rustfn background_task2() -> Result<std::convert::Infallible, String> {
이 반환 타입은 "성공적으로 끝나는 일은 불가능하지만, 에러로 끝나는 일은 가능하다"는 의미를 가집니다.
문제는, 이 코드 역시 컴파일되지 않는다는 점입니다. 대신 다음과 같은 에러가 발생합니다.
textexpected `background_task2` to return `Infallible`, but it returns `Result<Infallible, String>`
비록
Infallible은 값이 존재하지 않는(uninhabited) 타입이므로 bottom 타입 정의에 들어맞으며,Infallible을 특별히 취급해 그 값을 다른 타입과 통합(unify)하거나 자동 변환(coerce)하지는 않는다는 점이 문제입니다.이 문제를 수동으로 해결하려면, background_task1의 반환값에 대해 명시적으로 패턴 매칭을 할 수 있습니다.
rustset.spawn(|| { let infallible = background_task1(); match infallible {} });
여기에는 두 가지 주요 문제가 있습니다.
text26 | let infallible = background_task1(); | ------------------ any code following this expression is unreachable 27 | match infallible {} | ^^^^^^^^^^ unreachable expression
하지만 더 흥미로운 점이 있습니다. background_task2의 반환 타입도 Result<(), String> 대신 Result<Infallible, String>으로 바꾸었기 때문에, 컴파일러는 이제 백그라운드 작업들 가운데 어느 것도 성공적으로 종료될 수 없다는 사실을 알게 됩니다. 따라서 join_next가 값을 돌려줄 때마다, 그것은 스레드 패닉이거나 작업 에러입니다.
이 둘 모두 return으로 즉시 빠져나가므로, 컴파일러는 이 루프가 실제로는 반복되지 않는다는 사실을 정확히 짚어냅니다. 이제 while 루프를 더 단순하게 바꿀 수 있습니다.
rustlet res = set .join_next() .expect("Impossible: received a None from join_next, but there are threads"); match res { Err(e) => Err(format!("Background task panicked: {e:?}")), Ok(Err(e)) => Err(format!("Background task errored out: {e}")), // x의 타입은 Infallible이므로, 매치를 사용해 // 이 분기가 절대 실행되지 않음을 증명할 수 있습니다. // 이 경우 자체를 빼도 컴파일러가 알아서 파악해 줍니다! Ok(Ok(x)) => match x {}, }
이 코드는 더 명시적입니다. 우리는 단 하나의 스레드가 종료되기를 기다릴 뿐이며, 그 일이 일어나면 반드시 에러와 함께 프로세스를 종료합니다.
여기까지도 꽤 괜찮은 개선이지만, 여전히 사용성은 좋지 않습니다. 드디어 "진짜" 해결책을 소개할 시간입니다!
never 타입사실 Rust에는 내장 bottom 타입이 있습니다! 왜 이제서야 이야기하냐고요? 하나는 이 타입을 이해할 동기를 먼저 만들고 싶었기 때문이고, 또 다른 이유는... 조금만 더 읽어 보시면 압니다.
never 타입은 !로 표기합니다. 다른 반환 타입들과 마찬가지로, background_task1의 반환 타입으로 !를 지정해도 잘 동작합니다.
rustfn background_task1() -> ! { loop { println!("Performing background task 1."); std::thread::sleep(std::time::Duration::from_secs(60)); } }
불행히도, background_task2에는 같은 방식이 통하지 않습니다. 시그니처를 아래처럼 바꾸면:
rustfn background_task2() -> Result<!, String> {
컴파일 에러가 발생합니다.
texterror[E0658]: the `!` type is experimental ... = note: see issue #35121 <https://github.com/rust-lang/rust/issues/35121> for more information
우리가 딱 쓰고 싶었던 기능이지만 동작하지 않습니다! 안타깝네요.
하지만 괜찮습니다. 지금은 이 함수의 반환 타입을 다시 Result<(), String>으로 돌려놓고, background_task1의 반환 타입만 !로 유지하겠습니다. 그리고 에러 메시지를 조금 더 보기 좋게 하기 위해 background_task2를 background_task1보다 먼저 spawn해 봅시다.
rustset.spawn(background_task2); set.spawn(background_task1);
그 다음 에러 메시지는 Rust의 never 타입의 한계를 더 적나라하게 보여 줍니다.
textexpected `background_task1` to return `Result<(), String>`, but it returns `!`
Rust 컴파일러는 !를 모든 타입의 자동 서브타입으로 취급하는 진짜 bottom 타입으로 다루고 있지 않습니다. 대신, !를 Result<(), String>과는 구분되는 독립적인 타입으로 취급합니다. 우리가 원하는 것은 이 두 타입이 **통합(unify)**되거나, ! 반환값이 Result<(), String>으로 **자동 변환(coerce)**되는 것입니다.
다행히 이런 기능은 계획되어 있습니다. 향후 개선된 never 타입에 대한 자세한 내용은 아래에서 볼 수 있습니다.
never 타입은 이미 오늘날 컴파일러 내부에서 일부 사용되고 있습니다. 사실, 위에서 이미 한 번 짚고 지나갔던 사례가 있습니다. 바로 무한 loop입니다.
!입니다.!에 대한 특별한 변환(coercion)을 수행하고, 모든 타입 관계에서 !를 일반적인 1급(first-class) 서브타입으로 다루지는 않습니다.loop일 때, Rust가 그 표현식의 타입인 !을 함수 시그니처에 적힌 반환 타입으로 자동 변환해 줍니다.loop { .. }가 함수 반환 타입에 따라 () 또는 u32를 "반환하는 척" 할 수 있었던 것입니다. 루프의 타입이 !이고, Rust가 이 !을 함수 선언의 반환 타입으로 변환해 주었기 때문입니다.여기까지를 정리하면 다음과 같습니다.
이 글의 나머지 부분에서는, 오늘날의 stable Rust에서 쓸 수 있는, 그럭저럭 편리한 해결책을 도출해 보겠습니다. 이를 위해, 잠시 우회로를 돌아 Haskell의 Void와 absurd, 그리고 전칭 한정(universal quantification)에 대해 이야기하겠습니다.
Haskell의 표준 라이브러리(base)에는 Data.Void라는 모듈이 있고, 여기에서 값이 존재하지 않는 타입 Void와 두 개의 헬퍼 함수 absurd(곧 다룹니다)와 vacuous(직접 연습해 보세요)를 제공합니다.
haskellimport Data.Void (Void) import Control.Concurrent (threadDelay) backgroundTask1 :: IO Void backgroundTask1 = do putStrLn "Performing background task 1." threadDelay (1 * 1000 * 1000) -- 마이크로초 단위 backgroundTask1 -- Haskell에는 명시적 루프 대신 재귀를 사용 main :: IO () main = do -- 반환된 Void 값을 무시 _ <- backgroundTask1 pure ()
Void는 Rust의 Infallible과 비슷합니다. !처럼 언어에 내장된 마법 타입이 아니라, 표준 라이브러리에 정의된 평범한, 값이 존재하지 않는(uninhabited) 타입입니다. 위 예제에서는 Void 값을 그냥 무시했습니다. 하지만 더 나은 방법이 있습니다. 그 연산이 값을 반환한다는 사실이 얼마나 말이 안 되는지 아예 타입으로 표현할 수 있습니다!
haskellimport Data.Void (Void, absurd) import Control.Concurrent (threadDelay) backgroundTask1 :: IO Void backgroundTask1 = do putStrLn "Performing background task 1." threadDelay (1 * 1000 * 1000) -- 마이크로초 단위 backgroundTask1 -- 재귀 main :: IO () main = do v <- backgroundTask1 absurd v
absurd의 타입 시그니처와 구현은, 제 생각에는 꽤 흥미롭습니다.
haskellabsurd :: Void -> a absurd x = case x of {}
Haskell의 case 표현식은 Rust의 match와 같으므로, 구현이 앞서 Rust에서 본 "값이 없는 타입에 대한 패턴 매칭" 기법과 똑같다는 것을 알 수 있습니다.
하지만 첫 줄(타입 시그니처)을 잘 보세요. Haskell에 익숙하지 않은 분을 위해 설명하면, 이 뜻은 다음과 같습니다. "만약 나에게 Void 값을 준다면, 나는 네가 원하는 어떤 타입이든 돌려 줄 수 있다." 여기서 a 타입 변수는 아무 제약도 없기 때문에, 이 함수의 호출자가 타입 추론과 통합(unification)을 통해 어떤 타입이 되어야 할지를 결정합니다.
그리고 사실, 이 예제에서는 Void 타입도, absurd 함수도 전혀 필요 없습니다. Haskell에서는 훨씬 간단한 해결책이 있습니다.
haskellimport Control.Concurrent (threadDelay) backgroundTask1 :: IO a backgroundTask1 = do putStrLn "Performing background task 1." threadDelay (1 * 1000 * 1000) backgroundTask1 main :: IO () main = backgroundTask1
backgroundTask1에 IO a라는 더 일반적인 타입을 부여함으로써, Void와 absurd를 완전히 건너뛰었습니다. 이 함수는 절대 반환하지 않으므로, 이렇게 해도 안전합니다. a는 호출자가 필요로 하는 어떤 타입이든 될 수 있고, 여전히 타입 검사가 통과합니다.
굉장히 아름다운 패턴입니다. 그리고 Rust의 fully-featured never 타입이 완성되면, 비슷한 것을 할 수 있을 것입니다.
마지막으로 한 가지 더. "그렇다면 Void 타입을 왜 굳이 따로 두는가?"라는 의문이 들 수 있습니다. 많은 경우에 꼭 필요하지는 않습니다. 하지만 "아니, 여기서는 정말로 아무것도 나오지 않는다. 호출자가 어떻게 우겨도 안 된다"라고 명시적으로 말하고 싶은 상황이 있습니다. 예로는 제가 만든 스트리밍 라이브러리 conduit 패키지의 connect 함수를 들 수 있습니다. 데이터 파이프라인 구성 요소 중 하나가 값은 전혀 내보내지 않는다는 사실을 타입 수준에서 표현하기 위해 Void를 사용합니다.
타입 이론 용어로 말하면, absurd는 forall a. Void -> a라는 타입을 갖습니다. a에 대해 전칭 한정(universally quantified)되어 있다고 말하며, 이는 "호출자가 선택하는 어떤 결과 타입 a에 대해서도 동작한다"는 뜻입니다. 그러면 질문이 생깁니다. 같은 트릭을 Rust에서 쓸 수 있을까요?
T 반환하기다시 도면판으로 돌아가 봅시다. background_task1의 시그니처를 이렇게 바꿔 보겠습니다.
rustfn background_task1<T>() -> T {
여기까지 변경한 전체 코드가 있습니다. 이 코드는 잘 동작합니다! 좋습니다! 이제 우리가 굳이 Rust 팀이 만들고 있는 never 타입을 쓸 필요가 없어 보입니다. 제한 없는 타입 매개변수만 쓰면 되니까요.
이제 background_task2도 이 방식으로 업데이트해 봅시다.
rustfn background_task2<T>() -> Result<T, String> {
불행히도, 예상한 대로 되지 않았습니다.
texterror[E0282]: type annotations needed --> src/main.rs:26:15 | 26 | set.spawn(background_task2); | ^^^^^^^^^^^^^^^^ cannot infer type of the type parameter `T` declared on the function `background_task2`
문제는, Rust의 타입 추론을 도와줘야 한다는 점입니다. 이건 background_task2를 수정하지 않고도 바로 확인할 수 있습니다.
rustfn main() { background_task1(); }
그러면 다음과 같은 에러가 납니다.
text23 | background_task1(); | ^^^^^^^^^^^^^^^^ cannot infer type of the type parameter `T` declared on the function `background_task1` | help: consider specifying the generic argument | 23 | background_task1::<T>(); | +++++
에러 메시지가 제안하듯, turbofish 문법을 써서 T를 제한해 줄 수도 있습니다. 하지만 저는 이것이 Rust스러운 코드가 아니기도 하고, 사용성도 떨어진다고 생각합니다. Haskell의 방식을 그대로 가져오고 싶은 유혹은 크지만, 두 언어는 타입 수준 기능에서 꽤 다릅니다.
저는 제한 없는 타입 매개변수 접근법을 비해결책으로 간주하겠습니다. 이론상으로는 동작하지만, Rust의 타입 추론과 사용성을 거스르고, 모든 호출부에 잡음을 강요합니다. 결국 그 "쓸모없어 보이던" never 타입이 그렇게 쓸모없지 않았던 셈입니다.
absurd 매크로정리해 보면, Haskell에는 absurd :: Void -> a가 있고, Rust의 never 타입은 같은 표현력을 제공하고자 하지만 아직 완전하지 않습니다.
그렇다면 오늘날 우리는 무엇을 할 수 있을까요?
다행히, Haskell 버전과 정확히 같은 의미를 갖는 Rust판 absurd를 구현할 수 있습니다. 사실, 필요한 구성 요소들은 이미 다 살펴봤습니다.
Infallible 또는 직접 정의한 enum Never {})match이를 매크로로 구현해 보겠습니다.
rustmacro_rules! absurd { ($x:expr) => { match $x {} }; }
이 매크로는 Haskell에서의 뜻과 일치합니다. 값이 없는 타입의 값을 받으면, 우리는 원하는 어떤 타입이든 만들어 낼 수 있습니다. match에는 절이 하나도 없기 때문에, 해당 코드는 실제로 실행되지 않습니다.
그리고 작업을 spawn할 때 이 매크로를 적용해서 반환 타입을 일반화하고, 타입 추론이 나머지를 처리하게 만들 수 있습니다.
rustset.spawn(|| absurd!(background_task1())); set.spawn(background_task2);
이 방식은 Rust가 목표로 하는 완전한 ! 변환 이야기만큼 마법 같지는 않습니다. 결국 absurd! 매크로를 우리가 직접 호출해야 하니까요. 하지만, 타입이 맞지 않는 반환 타입들을 통합할 수 있는 명시적이고, 원칙에 맞는 방법을 제공합니다.
또한 이런 장점도 있습니다.
absurd!를 사용한 모든 곳을 업데이트하도록 강제합니다. (바로 우리가 원하는 타입 수준의 정직성입니다.)AbsurdFuture드디어 이 모든 논의를 촉발한 실제 PR로 돌아가 보겠습니다. 이 PR은 Kolme의 일부였습니다. 우리가 해결하려 했던 상황은 다음과 같습니다.
JoinSet에서 여러 백그라운드 작업을 실행해야 했습니다.Result를 반환하고, 다른 작업은 단순히 unit(나중에 Infallible로 변경)을 반환했습니다.rustset.spawn(async { processor.run().await; #[allow(unreachable_code)] Err(anyhow::anyhow!("Unexpected exit from processor")) });
이 코드는 정말 답답합니다. 우리도(코드 작성자) 알고, 컴파일러도 알듯이 processor.run().await는 절대 반환하지 않습니다. 하지만 코드를 그대로 두면 타입이 맞지 않다고 컴파일러가 불평합니다. 그리고 저 Err 줄만 별도로 넣어 두면, 도달 불가능 코드 경고가 나옵니다. 결국 불필요한 Err뿐 아니라, 불필요한 allow 주석까지 달아야 합니다. 썩 마음에 들지 않습니다.
처음에는 매크로 버전 absurd_future를 구현했습니다. 이 방식도 잘 동작했지만, Emanuel Borsboom이 한 단계 더 나은 접근을 제안했습니다. 바로 AbsurdFuture 헬퍼 타입과 absurd_future 함수의 조합입니다.
AbsurdFuture의 아이디어는 비종료 future를, 원하는 어떤 타입이든 결과로 내는 future로 바꾸는 것입니다. Kolme 코드가 어떻게 깔끔해졌는지는 이 커밋에서 볼 수 있습니다. 여기선 우리가 계속 사용해 온 백그라운드 작업 예제를 tokio 버전으로 옮겨서, 실제로 이 패턴이 어떻게 동작하는지 살펴보겠습니다.
rustuse std::{ convert::Infallible, marker::PhantomData, pin::Pin, task::{Context, Poll}, }; use tokio::task::JoinSet; use std::future::Future; /// 절대 반환하지 않는 future를, 원하는 타입을 결과로 내는 future로 바꿉니다. /// /// 논리적으로 완료되지 않는 async 작업이지만, 어떤 구체적인 출력 타입을 /// 요구하는 인터페이스를 만족시켜야 할 때 유용합니다. #[must_use = "futures do nothing unless polled"] pub struct AbsurdFuture<F, T> { inner: Pin<Box<F>>, _marker: PhantomData<fn() -> T>, } impl<F, T> AbsurdFuture<F, T> { pub fn new(inner: F) -> Self { Self { inner: Box::pin(inner), _marker: PhantomData, } } } impl<F, T> Future for AbsurdFuture<F, T> where F: Future<Output = Infallible>, { type Output = T; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { let inner = self.get_mut().inner.as_mut(); match Future::poll(inner, cx) { Poll::Pending => Poll::Pending, Poll::Ready(never) => match never {}, } } } pub fn absurd_future<F, T>(future: F) -> AbsurdFuture<F, T> where F: Future<Output = Infallible>, { AbsurdFuture::new(future) } async fn background_task1() -> Infallible { loop { println!("Performing background task 1."); tokio::time::sleep(std::time::Duration::from_secs(60)).await; } } async fn background_task2() -> Result<Infallible, String> { let mut counter = 0; loop { counter += 1; println!("Performing background task 2."); tokio::time::sleep(std::time::Duration::from_secs(2)).await; if counter >= 3 { return Err("Background task 2 errored".to_owned()); } } } #[tokio::main] async fn main() -> Result<(), String> { let mut set: JoinSet<Result<Infallible, String>> = JoinSet::new(); set.spawn(absurd_future(background_task1())); set.spawn(background_task2()); let res = set .join_next() .await .expect("Impossible: received a None from join_next, but there are threads"); match res { Err(e) => Err(format!("Background task panicked: {e:?}")), Ok(Err(e)) => Err(format!("Background task errored out: {e}")), // Ok(Ok(_)) 케이스는 타입상 불가능하므로 필요 없습니다. } }
저는 이 패턴이 꽤 마음에 듭니다. 표현력이 좋고, 사용성 부담이 최소화되어 있으며, 나중에 오류로 판명될 수 있는 unreachable!() 같은 느슨한 주장에 의존하지도 않습니다.
이번 주 초에는 타입 이론과 Rust의 불안정 기능을 파고들게 될 줄 몰랐습니다. 하지만 정말 잘한 선택이었다고 느낍니다! 현재 Rust의 never 타입 상황이 아주 좋다고 하기는 어렵지만, 앞으로 개선될 모습은 꽤 기대됩니다.
여러분 생각은 어떠신가요? 비종료에 대한 타입 수준의 증명에 충분한 가치가 있다고 보시나요? 아니면 타입 통합을 위해 더 단순한 접근 방식을 택하시겠습니까? 의견을 들려 주세요!
이메일 구독은 우리의 Atom 피드를 기반으로 하며 Blogtrottr에서 처리됩니다. 블로그 글이 올라왔을 때만 알림을 받게 되며, 언제든지 구독을 취소할 수 있습니다.
이 글이 마음에 들고, 차세대 소프트웨어 엔지니어링, 플랫폼 엔지니어링, 혹은 블록체인 & 스마트 컨트랙트와 관련해 도움이 필요하신가요? 문의해 주세요.