Rust에서 with 절과 블록을 활용해 내장 효과, 제네릭 효과, 효과 다형성, 효과 대수, 총합성 가정을 일관되게 표현하는 효과 표기법을 제안한다.
내 나쁜 습관 중 하나는 어떤 주제에 대해 10,000자나 20,000자씩 써 놓고도 결국 그걸 공개하지 않는다는 점이다. 내가 쓰는 긴 글들 중 상당수는 편집의 연옥에 갇혀 세상 밖으로 나오지 못한다. 짧은 글을 편집하는 것은 쉽고 빠르지만, 긴 글은 그렇지 않다. 나는 한동안 효과 표기법에 대해 더 써 보고 싶었고, 사람들이 어느 정도는 괜찮다고 여길 만한 무언가를 이리저리 궁리해 왔다. 최소한 코드에서 이런 것이 나타나는 건 나쁘지 않겠다고 생각한다.
지난 글에서 나는 Rust가 오늘날 가진 2개의 내장 안정 효과에서 9개 이상의 내장 효과를 갖는 방향으로 성장했으면 좋겠다고 말했다. 그러려면 다루기 위한 어느 정도의 일관성이 필요하고, 동시에 효과들 사이의 상호작용도 모두 고려해야 한다. 이것이 가능하려면 대수와 형식적 추론 체계를 도입해야 한다. 그와 동시에 사용법은 익숙해야 하고, 하위 호환성을 깨뜨리지 않으면서 점진적으로 도입할 수 있어야 한다. 물론 이 모든 것은 아주 쉽고 단순하며, 사람들이 여기에 대해 아무 의견도 없을 리가 없다.
표기법을 이야기하기 전에, 효과가 무엇인지 다시 한번 빠르게 분명히 하고 싶다. 프로그래밍 언어에서 우리는 함수의 입력과 출력에 대한 정보를 제공하기 위해 타입을 사용한다. 효과는 함수 자체에 대한 정보를 제공하게 해 주며, 여기에는 “이 함수는 블로킹 없이 기다릴 수 있다”거나 “이 함수는 컴파일 중에 평가될 수 있다” 같은 내용이 포함된다.
Rust는 시스템 프로그래밍 언어이고, 그렇기 때문에 필요할 때 구조에 대해 세밀하고 저수준의 제어를 제공하는 것을 중요하게 여긴다 1. async fn은 “async 효과”를 가진 함수이지만, 이는 -> impl Future를 반환하는 함수와는 다르다. 둘 다 같은 구조로 디슈거링되지만, async fn은 더 고수준이며 예를 들어 단순히 future를 반환하는 함수가 할 수 없는 방식으로 borrow를 추론할 수 있다. 나는 이것을 Rust에서 참조와 포인터가 구조적으로는 비슷하지만 의미적으로는 다른 것에 비유해서 생각한다.
효과라는 용어는 관련 문헌을 읽지 않은 많은 사람을 혼란스럽게 하는 듯하다. 사람들은 보통 효과라고 하면 네트워크 접근이나 로깅 같은 것을 떠올리지만, 문헌에서는 힙 접근과 비결정성도 효과로 본다.
async와 try가 효과 타입의 예다.const fn은 힙, 정적 데이터, 비결정성, 호스트 API에 대한 접근을 제거한다. 이런 것들은 모두 컴파일러 수준의 추론이 필요하다.yield로 값을 가지고 중단하고 재개하는 대신, 대수적 효과 핸들러는 반드시 명시적으로 처리되어야 하는 타입 있는 자신만의 yield를 만들 수 있게 해 준다. 문헌에서는 반복, 비동기성, 실패 가능성을 보통 효과 핸들러로 인코딩한다. “사용자 정의 효과”는 대개 효과 핸들러 기능의 도입을 가리킨다. 이 글은 그 기능에 대한 글이 아니다.내가 효과에 관심을 두는 이유는 그것이 “비동기성”, “비종료”, “패닉 없음”, “결정성” 등의 개념을 추론할 수 있게 해 주는 우리가 알고 있는 가장 단순한 추상화이기 때문이다. 그리고 이것들을 개별 개념으로가 아니라, 실제로 쓰기 즐거운 일관된 프로그래밍 언어의 일부로서 다룰 수 있게 해 준다. 효과에 대한 추론이 그 자체로 단순한 것은 아니지만, 각각의 기능과 그 상호작용을 전부 독립적으로 추론하려는 것보다는 더 단순하다 2.
좋다, 용어 수업은 끝났으니 이제 본론으로 들어가자. 이 글의 설계는 세 개의 새 키워드를 기반으로 한다.
eff: 매개변수 위치에서 사용되면 “효과 제네릭”을 뜻한다. 독립된 항목으로 사용되면 “효과 항목”(effect alias, associated effect 등)을 뜻한다.with: 효과 제네릭 절에 사용되며, 타입 제네릭 절에 where 키워드를 쓰는 것과 비슷하지만 같지는 않다. 함수 내부에서는 블록과 클로저에 효과를 부여하는 데도 쓸 수 있다..do: 효과에 대해 완전히 제네릭한 함수들에 어떤 효과든 적용하는 데 사용된다.나중에 다른 키워드들도 언급하겠지만, 이 체계의 핵심은 이 세 가지를 중심으로 돌아간다.
우선 제네릭 효과나 효과 제네릭을 추론하지 않는 효과들을 위한 기본 표기법부터 시작해 보자. 의도는 이것을 with 블록을 중심으로 구성하는 것이다. 이는 효과가 있는 문맥을 정의하는 데 쓸 수 있는 새로운 표기법이다. 같은 표기법이 블록, 클로저, 함수, 메서드 모두에 적용된다. 이 표기법은 Python의 with 문에서 영감을 받았지만, 물론 완전히 같은 것은 아니다 3.
긴 함수 접두사 나열(예: pub const placing try gen fn foo() {})에 의존하는 대신, 이 표기법은 효과를 with 키워드로 구분된 별도 위치로 분리한다. 이는 단일 효과를 추론할 때는 조금 더 장황하지만, 두 개 이상의 효과를 다룰 때는 접두사 형태보다 훨씬 더 관리하기 쉽다.
마지막으로 이 표기법은 효과 별칭 을 위한 공간도 제공한다. 이름 붙은 내장 효과 집합이다. 타입 별칭이 반복적인 타입 시그니처를 추상화하게 해 주고, trait 별칭이 반복적인 trait 바운드를 추상화하게 해 주듯이, 효과 별칭은 반복적인 효과 시그니처를 추상화할 수 있게 해 준다.
// alias
eff Alias = async + emplace;
// functions
fn foo() -> i32 { .. } // empty set
fn foo() -> i32 with async { .. } // single
fn foo() -> i32 with async + emplace { .. } // multiple
fn foo() -> i32 with Alias { .. } // alias
// closures
let foo = || with async { .. }; // single
let foo = || with async + emplace { .. }; // multiple
let foo = || with Alias { .. }; // alias
fn foo(f: impl FnOnce() with async) { .. } // in arg position
// blocks
let x = with async { .. }; // single
let x = with async + emplace { .. }; // multiple
let x = with Alias { .. }; // alias
// trait impls
impl Foo with async for Bar { .. } // single
impl Foo with async + emplace for Bar { .. } // multiple
impl Foo with Alias for Bar { .. } // alias
// inherent impls
impl Foo with async { .. } // single
impl Foo with async + emplace { .. } // multiple
impl Foo with Alias { .. } // alias
// methods
impl Foo {
fn foo(&self) with async { .. } // single
fn foo(&self) with async + emplace { .. } // multiple
}
이 예제는 다중 효과 표기법을 보여 주기 위해 async 효과와 함께 가상의 효과 emplace를 사용한다. 실패 가능성과 반복을 위한 효과는 둘 다 제네릭 효과이며, const 효과는 긍정적 바운드의 집합이라기보다 부정적 바운드의 집합처럼 작동하기 때문이다. emplace는 placing functions를 표현하는 하나의 그럴듯한 방법이며, async와 같은 가산적 방식으로 작동한다.
내가 특히 마음에 드는 점은, 현재 우리가 놓치고 있는 블록, 클로저, 함수 사이의 일관성을 어느 정도 얻게 된다는 것이다. 무엇보다도 || async {}와 async || {}가 서로 다른 뜻을 가지는 다소 성가신 차이를 없애 준다.
// Using today's Rust
let x = async { .. }; // async block
let foo = async || { .. }; // async closure
let foo = || async { .. }; // closure returning a future (!)
async fn foo() -> i32 { .. } // async function
// Using `with`-clauses
let x = with async { .. }; // block
let foo = || with async { .. }; // closure
fn foo() -> i32 with async { .. } // function
나는 이것이 Scala의 = {} 본문 표기법이 지닌 우아함 일부도 담고 있다고 생각한다. 언어 전반에서 “블록”을 일관되게 자체 항목 타입으로 구분하고 이름 붙일 수 있게 해 주기 때문이다.
반복과 실패 가능성 같은 일부 효과는 스스로 제네릭 매개변수를 지닌다. 이는 Iterator와 Try trait이 연관 타입(Iterator의 Item, Try의 Output과 Residual)을 지니고 있어 서로 다른 타입을 반환할 수 있다는 점에서 드러난다. 그러나 우리가 앞으로 신경 써야 한다는 걸 이미 알고 있는 효과들도 있다. 예를 들어 next 인자를 가지는 이터레이터의 효과적 인코딩이 그렇다. 이는 이런 효과들을 위한 표기법이 입력 타입과 출력 타입을 모두 지정할 수 있어야 함을 뜻한다.
문헌에서 throw와 gen 같은 효과는 보통 “효과 핸들러”라는 기능을 사용해 인코딩된다. 앞의 용어 설명에서 말했듯, 나는 효과 핸들러를 대개 “타입이 있는 코루틴”으로 생각한다. Rust에는 일반적인 루틴(함수)을 위한 표기법이 이미 Fn() -> () 형태로 있다. 그렇다면 “타입이 있는 코루틴”을 표기하는 뻔한 방법은 Fn을 효과 이름으로 치환하고, () -> ()를 입력과 출력에 사용하는 것이다. 이를 Rust의 현재 내장 효과 집합(안정 및 불안정 모두)에 적용하면 다음과 같다.
fn foo() with async { .. } // asynchrony, no types
fn foo() with gen(i32) { .. } // iteration, input type only
fn foo() with gen(i32) -> u32 { .. } // iteration, input and return type (coroutine)
fn foo() with try(Option<!>) { .. } // fallibility, input type only
Flix의 효과 모델을 따르면, 개별 효과 종류는 하나의 집합 안에서 반복될 수 없다 4. 이를 적용하면 같은 집합 안에 반환 타입이 있는 gen과 없는 gen을 동시에 둘 수는 없다. 이 점을 염두에 두고, 이들을 하나의 시그니처에 모두 넣어 복잡한 조합이 어떻게 보일지 살펴보자.
fn get_foo(x: Bar) -> Foo // returns `Foo` by default
with async // is non-blocking
+ gen(i32) -> u32 // yields `i32`, resumes with `u32`
+ try(Option<!>) // may fail with `None`
{ .. }
여기서 주목할 점은 집합 안의 효과 순서가 중요하지 않다는 것이다. 언제나 같은 타입으로 디슈거링된다. 위 문장이 얼마나 복잡해 보이든, 내가 생각하기에 이에 대한 올바른 디슈거링은 다음과 같다 5. 그리고 부디 아무 준비 없는 Rust 사용자가 자신의 프로그램에서 이것과 직접 마주할 일은 없기를 바란다.
// 1. Returns `Foo` by default
// 2. Does not block between calls to `yield`
// 3. Does not block between the last `yield` and the final `return`
// 4. May fail early by returning `None`
// 5. May hold arbitrary internal references to `Bar`
// 6. Yields `i32`
// 7. Resumes with `u32`
fn get_foo(x: Bar) -> impl LendingReturningIterator<
Output<'a> = impl Future<Output = impl Try<Output = Foo, Residual = Option<!>>> + 'a where Self: 'a,
Item<'a> = impl Future<Output = i32>> + 'a where Self: 'a,
Resume = u32,
> { .. }
Syntactic musings on the fallibility effect에서 나는 try 효과를 Java와 Swift가 사용하는 이름인 throws로 부르면 더 잘 작동할지도 모른다고 주장했다. 또한 실패 가능성에는 Option<!> 대신 None처럼 패턴 같은 표기법을 활용하는 것이 좋을 수도 있다고 말했다. 대부분의 효과 지향 언어는 명사-동사 이름 짓기 방식을 사용하며, 효과의 이름은 동사(throw)에 접근을 제공하는 명사(Exception)다. 클로저 기반 표기법에서는 표기 안에 동사만 사용하는 것이 가장 자연스럽다. 따라서 throws는 throw가 된다.
fn foo() with await { .. } // asynchrony, no arguments
fn foo() with yield(i32) { .. } // iteration, input type only
fn foo() with yield(i32) -> u32 { .. } // iteration, input and return type (coroutine)
fn foo() with throw(None) { .. } // fallibility, input type only
이렇게 하면 미래 에디션에서 yields와 throws까지 추가로 예약할 필요가 없어지고, async 키워드의 예약도 해제할 수 있을 가능성이 생긴다. 얼핏 보면 조금 우스꽝스러워 보일 수도 있지만, 완전히 비합리적이지는 않다. 하나로 할 수 있는 일을 왜 키워드 두 개로 해야 할까? 점수판을 따지는 사람을 위해 말하자면, 이렇게 하면 Rust의 예약 키워드 총수는 하나 줄어든다. 새로 eff, with, throw를 예약하지만, yeet, async, try, gen의 예약은 해제할 수 있게 된다. 내게 묻는다면 나쁘지 않은 거래다.
함수 자체가 효과에 대해 제네릭해지도록 만드는 것은, 구체적인 효과만 추론하는 것보다 복잡도가 한 단계 올라간다. 하지만 타입 시스템을 더 복잡하게 만드는 대신, 언어의 표현력을 높여 Rust를 사용하는 전체 경험은 더 단순하게 만들어 줄 것이다. 더 이상 stdlib의 async 버전이나 실패 가능 버전 6이 따로 필요하지 않게 된다. 뿐만 아니라 “패닉이 없으면 패닉 없음”, “발산이 없으면 발산 없음” 같은 개념을 다룰 미래의 표현력도 제공할 수 있게 된다.
효과 제네릭은 제네릭 위치에서 사용할 수 있는 새로운 종류의 제네릭 eff를 도입함으로써 작동한다. 이것은 효과 타입 변수이며, 타입 변수와 매우 비슷하게 동작한다. 제약 없는 효과 집합을 나타내며, where 절을 사용해 제약할 수 있다. 제약 정의는 뒤에서 더 이야기하고, 우선 기본 표기법은 다음과 같다.
// alias
eff Alias = async + emplace;
// functions
fn foo<eff Ef>() -> i32 with Ef { .. } // generic on self
fn foo<eff Ef>(x: impl Foo with Ef) { .. } // generic on param
// closures
fn foo<eff Ef>(f: impl Fn() with Ef) { .. } // in bounds
// data types
struct Foo<eff Ef> { .. } // item-level definition
impl<eff Ef> Foo with Ef { .. } // impl-level generic
// traits
impl<eff Ef> Foo with Ef for Bar { .. } // impl-level generic
impl Foo for Bar { eff Ef = async; } // associated effect
impl<eff Ef> Foo for Bar { eff Ef = Ef; } // passing impl-level as associated
// methods
impl<eff Ef> Foo with Ef { // defining impl-level generic
fn foo(&self) with Ef { .. } // using item-level generic
fn foo<eff A>(&self) with A { .. } // method-level generic
}
논문 “Programming with effect exclusion”7에서 Flix 팀은 사람들이 효과를 통해 표현하고 싶어 할 수 있는 다양한 조건을 보여 준다.
Rust는 아직 바운드에서 배제와 상호 배제를 지원하지 않으므로, 효과에 대해서도 같은 조건 집합을 지원해야 하는지 여기서 주장하려는 것은 아니다. 하지만 내가 주장하고 싶은 것은, 고려할 가치가 있는 어떤 표기법이든 나중에 그것이 필요하다고 판단될 경우 이를 지원할 수 있도록 표기 공간을 남겨 두어야 한다는 점이다.
아래는 그 논문에 나온 예제를 문자 그대로 옮긴 것이다. 문자 그대로 옮긴 것이라는 점을 강조하고 싶다. Rust 코드를 이런 식으로 써야 한다고 주장하는 것이 아니라, 이 글에서 개괄하는 문법이 논문에서 제시된 전체 조건 범위를 수용하도록 조정될 수 있음을 보여 주기 위한 것이다.
// Empty set of effects: this function does not
// carry any additional effects beyond the default.
fn noop() { .. }
// Effect union: the returned closure `H` carries
// all effects included in closures `F` and `G`.
fn compose_right<F, G, H, A, B, C, eff Ef1, eff Ef2, eff Ef3>(f: F, g: G)
-> H
where
F: FnOnce(A) -> B with Ef1,
G: FnOnce(B) -> C with Ef2,
H: FnOnce(A) -> C with Ef3,
Ef3: Ef1 + Ef2,
{ .. }
// Effect exclusion / upper bound: the closure
// `F` has all effects of `Ef`, but will never
// include the `Block` effect. (example 2.6)
fn on_mouse_pressed<F, eff Ef>(listener: F)
where
F: FnOnce(MouseEvent) with Ef,
Ef: !Block
{ .. }
// Disjoint effects: the effects that are part of
// `F` cannot be the same effects that are present
// on `G`.
fn par<A, B, F, G, eff Ef1, eff Ef2>(x: A, f: F, g: G)
where
F: FnOnce(A) -> B with Ef1,
G: FnOnce(A) -> B with Ef2,
Ef2: !Ef1,
{ .. }
// Disjoint effects with a shared component: the
// effects that are part of `F` cannot be the
// same effects that are present on `G`, except
// for the `Network` effect which is present on both.
fn par<A, B, F, G, eff Ef1, eff Ef2>(f: F, g: G)
where
F: FnOnce(A) -> B with Ef1,
G: FnOnce(A) -> B with Ef2,
Ef1: Network,
Ef2: !Ef1 + Network,
{ .. }
// Mutual exclusion: at most one of `F` and `G`
// can have the `Throw` effect. (example 4.2)
fn hof<T, F, G, eff Ef1, eff Ef2>(f: F, g: G)
where
F: FnOnce(T) -> T with Ef2,
G: FnOnce(T) -> T with Ef1,
Ef1: !Ef2 & Throw,
Ef2: !Ef1 & Throw,
{ .. }
내게 이것은 Rust에 완전히 통합되는 표기법처럼 느껴진다. 언어를 확장하지만, 그 느낌 자체를 근본적으로 바꾸지는 않는다. 물론 where의 의미는 바뀌긴 한다. 이제 타입 변수뿐 아니라 효과 변수도 제약할 수 있게 되기 때문이다. 내게는 그것이 목적에 맞는 올바른 도구처럼 느껴지지만, 익숙해지는 데는 잠깐 시간이 걸리긴 했다.
지금까지는 항목 시그니처만 살펴봤고 함수 본문은 보지 않았다. 그러니 이제 그걸 보자. 함수 본문에서 효과를 다룰 때는 효과 연산을 세 가지 범주로 나눠 생각할 수 있다.
생성과 소비 연산은 반드시 해당 효과에 특화되어야 하므로, 이것들까지 일반화할 수는 없다. 예를 들어 async는 그 자체로 어떤 타입에 대해서도 제네릭하지 않고, 대부분의 await의 근원은 시스템 호출이나 다른 futures 수준 구현으로 거슬러 올라간다. 하지만 효과의 전파는 분명히 제네릭할 수 있으며, 나는 이를 위해 이미 예약된 .do 키워드를 사용하자고 제안한다.
fn foo<F, eff Ef>(f: F) -> i32
where
F: FnOnce() -> i32 with Ef,
with Ef {
f().do; // inserts `.await`, `?`, `for..in..yield` as needed
}
이 함수는 아무 제약 없는 효과 집합을 대상으로 작동하므로, 함수 본문에는 그 지점 이후 함수가 더 진행되지 않을 수 있다는 시각적 표시가 필요하다. ?는 오류를 반환할 수 있고, .await는 재개되지 않을 수 있으며(async cancellation), for..in..yield는 다시 반복되지 않을 수 있다(iterator cancellation) 등등. 앞으로 우리가 추가하고 싶어 할 다른 효과가 무엇일지는 알 수 없지만(예를 들어 효과 핸들러는 어떤 것이든 추가할 수 있을 것이다), 그렇다고 해서 사용자가 .do를 이해하는 방식이 바뀌진 않는다. “함수는 .do에서 중단되거나 심지어 종료될 수 있다.” 이것은 .await만큼이나 직관적이다.
예전에 내가 들었던 우려 중 하나는, 효과를 도저히 추상화할 수 없다는 것이었다. 효과 처리 코드가 사방에 존재하기 때문이라는 주장이다. 그리고 그 지적에는 일리가 있다. async 코드를 쓰기 매력적으로 만들어 주는 요소의 절반은 즉석에서 동시 실행을 도입할 수 있는 능력에 있다. 그걸 완전히 포기하는 것은 분명 아쉬운 일처럼 보인다.
나는 오래전부터 Rust가 동시성에 대해 고수준에서 추론할 수 있으면 좋겠다고 생각해 왔다. 이 점에서 Swift가 async let 기능으로 대체로 올바른 방향을 보여 줬다고 믿는다. Automatic interleaving of high-level concurrent operations에서 나는 .co_await를 사용해 그런 기능을 Rust에 맞게 적용할 수 있는 방법을 보여 주었는데, 할당이 전혀 필요 없고 10, 전부 join 연산 위에 구축되는 방식이었다.
그 글의 Swift 예제를 완전하고 정확하게 옮기는 작업은 아마 별도의 글 하나를 써야 할 주제일 것이다. 하지만 가능성을 보여 주기 위해 말하자면, .do가 .await를 추상화하는 키워드라면, .co_await를 추상화하는 키워드 .co도 상상할 수 있다.
fn make_dinner<eff Ef>() -> SomeResult<Meal> with Ef {
let veggies = chop_vegetables().co;
let tofu = marinate_tofu().co;
let oven = preheat_oven(350).co;
let dish = Dish(&[veggies, tofu]).co;
oven.cook(dish, Duration::from_mins(3 * 60)).do
}
나는 .co를 정신적으로는 Go의 go 키워드와 같은 방향을 향하고 있는 것으로 생각한다. 하지만 구조화되어 있고, 지연 실행되며, 모든 효과를 추상화하고, 스택리스 동시성을 사용한다는 점이 다르다. 다만 지금 당장 이 시스템을 추진하자는 제안은 아니다. 단지 우리가 정말 충분히 원한다면 그렇게 하는 것이 가능해 보인다는 뜻일 뿐이다.
총합 효과는 다른 모든 효과의 부재를 나타낸다. 함수가 “총합적”이라는 것은 그 함수의 전체 효과 집합이 비어 있다는 뜻이다. 이는 “순수하다”는 것보다도 더 강한 보장이다. 순수 함수는 여전히 무한 루프를 돌거나 패닉할 수 있기 때문이다. Koka의 주 저자에 따르면, 전형적인 Koka 프로그램은 대략 70%가 총합적이고, 15%가 순수하며, 나머지 15%만이 호스트 API(시스템 호출)에 접근할 필요가 있다.
Rust에서는 오늘날 총합성을 표현할 수 없다. core, alloc, std를 대상으로 하느냐, 혹은 const fn을 작성하느냐에 따라 프로그램의 함수들이 다룰 수 있다고 가정되는 효과가 달라진다. 당시 이를 설계할 때, 이 계층 구조가 효과와 이렇게 멋지게 대응된다는 점을 우리가 얼마나 의식했는지는 모르겠다.
기본 가정은 함수가 대상이 정의하는 모든 능력에 접근할 수 있다는 것이고, 예를 들어 const fn처럼 그런 능력을 옵트아웃 해서 능력을 바꿀 수 있다. 지금까지 이 글의 예제에서 const를 거의 언급하지 않은 이유도 여기에 있다. 지금까지 언급한 대부분의 다른 효과와는 거의 정반대의 극성을 가지기 때문이다. const의 능력을 옵트아웃이 아니라 옵트인으로 정의할 수 있다면 좋을 것이다.
그렇다면 Rust를 기본적으로 총합성을 가정하도록 바꾸고 싶다고 해 보자. 이를 단지 가능하게 하는 데서 그치지 않고, 어떻게 실용적이게 만들 수 있을까? 첫 번째 단계는 stdlib가 기존 동작으로 쉽게 다시 옵트인할 수 있도록 별칭을 만드는 것이다.
pub eff Pure = panic + diverge;
pub eff Core = Pure + static;
pub eff Alloc = Core + heap;
pub eff Std = Alloc + Host;
Alloc과 Host는 아마 원시 효과라기보다 효과 핸들러(혹은 더 세분화된 효과 핸들러 집합)여야 할 텐데, 이 글에서는 그것을 논의하지 않는다. 그러니 그건 어떻게든 정의할 수 있다고 가정하자. 이제 함수는 항상 총합적(효과가 없음)이라고 가정되므로, 위 별칭을 사용해 각 함수를 정밀하게 표기할 수 있다.
fn foo() {} // Has no effects (total)
fn foo() with Pure {} // Equivalent to writing `const fn` today
fn foo() with Core {} // Equivalent to targeting `core` today
fn foo() with Alloc {} // Equivalent to targeting `alloc` today
fn foo() with Std {} // Equivalent to targeting `std` today
Koka의 수치가 Rust에도 그대로 적용된다고 가정하면, 새 코드의 약 70%는 총합적이어서 쉽게 작성할 수 있을 것이다. 하지만 총합적이지 않은 나머지 30%에 대해서는 with Pure와 with Std를 여기저기 계속 쓰는 일이 금세 지겨워질 것이다. 기존 프로젝트라면 더더욱 그렇다. Niko Matsakis는 과거에 절 안의 반복을 어느 정도 줄이기 위해 “scoped generics”를 도입하는 아이디어를 언급한 적이 있다. 그가 이것에 대해 글로 남긴 적은 없는 것 같아서, 내가 이해한 바를 최대한 공유하며 효과에 적용해 보겠다.
어떤 스코프 안의 모든 항목마다 with 절을 쓰는 대신, 그 안에 포함된 모든 항목에 적용되는 모듈 수준의 표기를 도입할 수 있다고 상상해 보자. 특정 모듈의 모든 항목이 어떤 효과 집합에 접근할 수 있음을 표시하려면 다음과 같이 쓸 수 있을 것이다.
pub mod bar with Std { // Adds `with Std` to all functions in the module
pub fn foo() {} // `with Std` is implied
pub fn bar() {} // `with Std` is implied
}
이제는 여기저기 with 절을 복제하는 대신 모듈마다 하나만 두면 된다. 다만 이 표기법은 인라인 모듈에서만 잘 작동할 것이다. Rust에서 파일도 모듈로 간주되므로, 파일 자체가 자기 기술적이 되도록 “현재 이 파일(모듈)의 모든 항목은 이런 효과를 지녀야 한다”라고 말하는 표기법도 필요할 것이다. 내 생각에는 mod self 같은 것으로 충분히 가능할 것 같다.
mod self with Std; // Adds `with Std` to all functions in the file (module)
pub fn foo() {} // `with Std` is implied
pub fn bar() {} // `with Std` is implied
보통 프로젝트는 많은 파일로 이루어져 있으므로, 각 파일 맨 위에 같은 프라그마를 추가하는 것조차 여전히 꽤 반복적일 수 있다. 오늘날 std를 옵트아웃하고 core를 사용하려면 crate의 맨 위에 #![no_std]를 쓸 수 있다. 그리고 alloc 지원을 다시 옵트인하려면 extern crate alloc;을 이어서 쓸 수 있다.
만약 #![no_std]/extern crate alloc 대신 mod self와 비슷한 crate self;를 쓸 수 있다면 어떨까? 기본적으로 효과가 없다고 가정할 때, core, alloc, std의 능력을 다시 활성화하는 것은 각 crate 시작부에 한 번만 정의되는 같은 줄의 변형들로 모두 표현할 수 있을 것이다.
crate self with Alloc;
pub mod bar {
pub fn foo() {} // `with Alloc` is implied
}
최적의 컴파일러 성능을 보장하기 위해 crate self를 어디서 어떻게 사용할 수 있는지에 대해서는 아마 제한을 두어야 할 것이다. 하지만 이런 종류의 표기법은, 언젠가 기본값을 암묵적 Std에서 에디션 차원에서 총합성으로 바꾸고 싶어질 때 필요한 바로 그런 종류의 것일 것이다. crate마다 한 줄 추가해서 core, alloc, std의 기존 동작으로 다시 옵트인하는 정도는 그런 전환을 지원하기 위한 합리적인 비용처럼 보인다.
여기까지 읽고 머리가 조금 빙글빙글 돌고, 도대체 이 정도 복잡도와 기능이 정당화될 수 있겠느냐는 생각이 든다면, 그것은 지극히 정상적인 반응이다. 나도 이 글이 소화하기에 많은 내용이라는 걸 안다. 그리고 내가 내린 각 결정의 근거 대부분을 의도적으로 생략했다는 점도 알고 있다. 앞서 말했듯, 나는 그런 글을 써 보려고 했다. 그런데 이 글의 두 배 길이가 되었고, 실제로 출판 가능한 무언가로 어떻게 바꿔야 할지 도무지 감을 잡을 수 없었다.
나는 다시 한번 강조하고 싶다. 여기서 내가 개괄한 내용은 이대로 받아들여서 당장 착수하고 다른 건 전부 제쳐 두자는 뜻이 아니다. 아니다. 이 글의 목적은, 효과에 대한 추론이 타입 변수에 대한 추론보다 더 어렵지 않은 지점에 도달할 수 있는 그럴듯한 경로가 존재한다는 점을 보여 주는 데 있다.
효과에 with 키워드를 쓰는 걸 모든 사람이 좋아하진 않을 것이라고 생각한다. 나 역시 더 짧은 것이나, 가능하다면 시길 문자를 더 선호할 것이다. 하지만 함수에 do async라고 쓰는 것은 내게 우스워 보이고, 흔히 쓰이는 \ 시길을 재사용하는 것도 블록 위치에서는 보기 좋지 않다. 사람들이 직접 예제를 번역해 보며 내가 무슨 뜻인지 확인해 보길 권한다. 내게는 with 키워드가 가독성과 간결성 사이에서 가장 좋은 균형을 잡아 주는 것처럼 보인다. 물론 이 글에서 다루지 않은 다른 것들도 아주 많다. 이를테면 다음과 같은 것들이다.
하지만 그건 괜찮다고 생각한다. 미래의 글에서 더 논의할 거리는 언제나 있기 마련이다. 이 글을 통해 내가 적어도 Rust에서 효과를 표현하는 하나의 그럴듯한 표기법을 대략적으로라도 제시할 수 있었기를 바란다. 혹은 어떤 사람들이 내게 말해 준 표현을 빌리자면, “이건 내가 본 표기법 중 처음으로 적극적으로 싫지는 않았던 것이다.”
출판 전에 쓰고 있던 다른 글의 일부를 검토해 준 Oli Scherer에게 감사한다. 그 검토는 정말 큰 도움이 되었고, 실제로 내가 무엇을 중심으로 설명해야 할지 잠시 멈춰 다시 생각하게 만들었다. 여기서 Oli의 이름을 직접 언급하며 감사하고는 있지만, 이 글에 인용된 의견들은 그들의 것이 아님을 밝힌다.
async 같은 효과를 효과 우선 언어가 어떻게 다루는지에 관심 있는 분이라면, 예를 들어 다음을 볼 수 있다: D. Leijen, “Structured asynchrony with algebraic effects,” in Proceedings of the 2nd ACM SIGPLAN International Workshop on Type-Driven Development, Sep. 2017, pp. 16–29. doi: 10.1145/3122975.3122977.←
네, monad가 있다는 건 안다. 임의의 효과를 monad로 모델링하려면 free monad를 사용해야 한다. Rust의 효과가 매력적인 이유는 순서와 조합 문제를 해결해 주기 때문이다. 대안을 말하며 monad를 언급할 때 사람들이 보통 떠올리는 것은 대개 free monad가 아니다.←
약속하건대, 효과 핸들러와 함께 쓰는 with까지 고려하면 더 Python스럽게 보일 것이다. 하지만 이 글은 이미 너무 길고, 지금 그 주제를 정말 꺼낼 수가 없다 lol. 다소 멀게 느껴진다면 그냥 믿어 달라. 효과 핸들러가 들어오면 다시 더 가까워질 것이다.←
다만 아주 분명히 해 두자면, Flix는 아직 스스로 제네릭한 효과를 지원하지 않는다. 그러니 여기서 내가 말한 것은 완전히 정확하진 않고, 좀 더 정확히는 “현재 Flix는 반복된 효과를 지원하지 않는다”에 가깝다. 하지만 그들의 논문을 읽어 보면, 그 결정은 그들의 설계 핵심에 속하는 것 같고 효과 안의 제네릭을 지원한 뒤에도 여전히 사실일 것으로 보여서 이렇게 말했다.←
어떤 사람들은 올바른 디슈거링이 impl Future를 반환하고 yield하는 LendingIterator의 변형이 아니라, 대신 Poll<T>를 반환하고 yield하는 새 trait AsyncGenerator를 기반으로 해야 한다고 주장할 것임을 안다. 그런 설계는 여기서 내가 보여 주는 것보다 의미 있게 더 단순하지도 않다. 게다가 async cancellation의 전파를 지원하지 못하기 때문에 근본적으로 깨져 있다. 이에 대해 더 깊이 들어가는 것은 또 다른 주제다.←
Rust for Linux 프로젝트와 Wasmtime은 둘 다 라이브러리를 통해 실패 가능성을 다루는 자체 추상화를 가지고 있다. 둘 다 OOM 오류 처리를 중요하게 여기므로, 어떤 할당이든 실패할 수 있고, 따라서 할당하는 API를 호출할 수 있는 어떤 코드든 그 실패를 처리할 수 있어야 한다. 아직 “stdlib의 실패 가능 포크” 수준까지 이르진 않았지만, 그런 것이 존재한다면 적어도 Wasmtime은 분명 관심을 가질 것이다.←
M. Lutze, M. Madsen, P. Schuster, and J. I. Brachthäuser, “With or Without You: Programming with Effect Exclusion,” Proc. ACM Program. Lang., vol. 7, no. ICFP, pp. 448–475, Aug. 2023, doi: 10.1145/3607846.←
네이티브 async 지원이 있는 호스트 플랫폼에서는 “소비” 연산이 ABI 경계를 가로질러 존재할 수도 있다. 예를 들어 Rust에서 WASI 0.3을 대상으로 할 때는 async 함수를 직접 export할 수 있다. 이 경우 런타임이 프로그램의 일부가 아니라 운영체제의 일부라고 생각할 수 있으므로, 로컬 block_on 호출에서 종료되지 않는다.←
Python의 yield from 같은 것이 이터레이터에도 있으면 아주 좋을 것이다. 하지만 전형적인 Rust 스타일대로라면 아마 후위 연산자로 원할 가능성이 높다.←
The Waker Allocation problem은 실제 문제다. 하지만 할당하지 않는 선택을 하고 약간 더 나쁜 동시성을 얻을 수는 있다. 그래도 동시성이 전혀 없는 것보다는 낫다.←
모든 참고문헌 보기