Rust의 orphan rule이 크레이트 조합성과 상호운용성에 미치는 영향, 그리고 이를 완화하기 위한 크레이트 수준 where-절 아이디어를 살펴본다.
Rust는 메서드를 추가한 이후로 거의 계속 코히어런스와 씨름해 왔습니다. 현재 규칙인 “orphan rule”은 안전하지만 지나치게 엄격합니다. 대략적으로 말하면, 이 규칙은 foreign trait(즉, 의존성 중 하나가 정의한 trait)은 local type(즉, 여러분이 정의한 type)에 대해서만 구현할 수 있다고 말합니다. 이 규칙의 목표는 crates.io 생태계를 키우는 데 있었습니다. 서로 호환되지 않는 impl을 정의해서 함께 사용할 수 없게 될 걱정 없이, 임의의 두 crate를 가져와 같이 사용할 수 있도록 보장하고 싶었던 것입니다. 그 점에서 이 규칙은 잘 작동해 왔지만, 시간이 지나면서 이것이 생태계에서 crate의 성공적인 조합을 돕는 것에 반해 의도치 않게 위축 효과를 낼 수도 있다는 점을 보게 되었습니다. 이런 이유로 저는 결국 orphan rule을 완화해야 할 것이라고 믿게 되었습니다. 이 글의 목적은 우리가 그렇게 할 수 있는 방법들을 예비적으로 탐색해 보는 것입니다.
orphan rule이 어떻게 crates.io의 crate들을 조합할 수 있게 보장하는지 궁금할 수도 있습니다. 예를 들어 widget이라는 crate가 Widget이라는 struct를 정의한다고 해 봅시다:
// crate widget
#[derive(PartialEq, Eq)]
pub struct Widget {
pub name: String,
pub code: u32,
}
보시다시피 이 crate는 Eq는 derive했지만 Hash는 derive하지 않았습니다. 이제 저는 widget에 의존하는 widget-factory라는 다른 crate를 작성하고 있습니다. 저는 widget을 hashset에 저장하고 싶지만, Hash를 구현하지 않았기 때문에 그럴 수 없습니다! 오늘날 Widget이 Hash를 구현하게 만들고 싶다면, 유일한 방법은 widget에 PR을 열고 새 릴리스를 기다리는 것뿐입니다.1 하지만 orphan rule이 없다면, 우리는 그냥 직접 Hash를 정의할 수 있을 것입니다:
// Crate widget-factory
impl Hash for Widget {
fn hash(&self) {
// PSA: Don’t really define your hash functions like this omg.
self.name.hash() ^ self.code.hash()
}
}
이제 HashSet<Widget>을 사용하는 WidgetFactory를 정의할 수 있습니다…
pub struct WidgetFactory {
produced: HashSet<Widget>,
}
impl WidgetFactory {
fn take_produced(&mut self) -> HashSet<Widget> {
self.produced.take()
}
}
좋습니다, 여기까지는 괜찮습니다. 하지만 누군가가 widget-delivery crate를 정의해서 그들도 HashSet<Widget>을 사용하고 싶어 한다면 무슨 일이 일어날까요? 그들도 Widget에 대해 Hash를 정의할 것이지만, 물론 그것을 다르게 정의할 수도 있습니다. 어쩌면 아주 형편없이 정의할 수도 있겠죠:
// Crate widget-factory
impl Hash for Widget {
fn hash(&self) {
// PSA: You REALLY shouldn’t define your hash functions this way omg
0
}
}
이제 문제가 생기는 시점은 제가 widget-delivery와 widget-factory 둘 다에 의존하는 widget-app crate를 개발하려고 할 때입니다. 이제 Widget에 대한 Hash impl이 두 개 생겼는데, 그렇다면 컴파일러는 어느 것을 사용해야 할까요?
여기에는 여러 답을 할 수 있겠지만, 대부분은 좋지 않습니다:
이론적으로는 각 crate가 자기 impl을 사용하게 할 수 있습니다. 하지만 사용자가 한 crate에서 HashSet<Widget>을 가져와 다른 crate로 넘기려 한다면 잘 작동하지 않을 것입니다.
컴파일러가 두 impl 중 하나를 임의로 고를 수도 있겠지만, 어느 쪽을 써야 하는지 어떻게 알 수 있을까요? 이 경우 그중 하나는 성능이 매우 나쁠 것이지만, 어떤 코드는 자신이 지정한 정확한 hash 알고리즘을 기대하도록 설계되었을 가능성도 있습니다.
사용자가 원하는 impl을 우리에게 말해 주게 할 수도 있습니다. 이것은 어쩌면 더 나을 수 있지만, 동시에 widget-delivery crate는 자신이 사용하는 impl이 나중에 어떤 다른 crate에 의해 다른 impl로 바뀔 수도 있다는 점에 대비해야 한다는 뜻이기도 합니다. 그러면 마지막 순간을 제외하고는 hash 함수를 인라인하거나 다른 최적화를 수행하는 것이 불가능해집니다.
이런 선택지들을 마주한 끝에, 우리는 그냥 orphan impl 자체를 전부 금지하기로 했습니다. 너무 번거로우니까요!
orphan rule은 두 crate를 함께 링크할 수 있게 보장하는 데는 잘 작동하지만, 아이러니하게도 실제 상호운용성 을 훨씬 더 어렵게 만들 수도 있습니다. async runtime 상황을 생각해 봅시다. 지금은 여러 async runtime이 있지만, 어떤 runtime과도 동작하는 코드를 작성할 편리한 방법이 없습니다. 그 결과, async 라이브러리를 작성하는 사람들은 흔히 하나의 특정한 runtime에 직접 맞춰 코드를 작성하게 됩니다. 최종 결과는 서로 다른 runtime을 기준으로 작성된 라이브러리들을 함께 조합할 수 없거나, 적어도 그렇게 하면 예상 밖의 실패가 발생할 수 있다는 것입니다.
더 나은 상호운용성을 허용하는 trait들을 구현할 수 있다면 좋을 것입니다. 하지만 그 trait들이 정확히 어떤 모양이어야 하는지는 아직 잘 모릅니다. (trait 안의 async fn 지원도 아직 없지만, 곧 올 예정입니다!) 그래서 그런 trait들을 crates.io 생태계에 도입해 거기서 조금 반복 개선해 나갈 수 있다면 좋겠습니다. 실제로 이것이 futures crate의 원래 비전이기도 했습니다! 하지만 실제로 그렇게 하려면, trait를 정의하는 바로 그 crate가 모든 runtime에 대한 구현도 함께 정의해야 합니다. 문제는 runtime 쪽은 아직 불안정한 futures crate에 의존하고 싶어 하지 않을 것이고, futures crate도 모든 runtime에 의존하고 싶어 하지 않는다는 점입니다. 그래서 우리는 다소 막혀 있는 상태입니다. 물론 futures crate가 어떤 특정 runtime에 의존하게 되면, 그 runtime은 나중에 futures를 의존성으로 추가할 수 없게 됩니다. 그렇게 하면 순환 의존성이 생기기 때문입니다.
결국 저는 orphan rule을 해제해야 할 것이라고 생각합니다. 그리고 겹치는 impl을 포함하고 있어서 함께 링크할 수 없는 crate를 만드는 것이 가능할 수도 있다는 점을 받아들여야 합니다. 하지만 그와 동시에, 조합이 매끄럽게 작동하도록 보장할 수 있는 도구를 사람들에게 제공할 수는 있습니다.
적어도 다음 두 경우를 구분할 수 있으면 좋겠습니다:
아이디어는 대부분의 crate는 구체적인 impl을 실제로 제공하지 않고도, 단지 impl이 필요하다고 선언만 할 수 있게 하자는 것입니다. 이런 crate는 여러 개를 문제 없이 함께 조합할 수 있습니다. (associated type에 대해 서로 모순되는 조건을 걸지만 않는다면 말입니다.)
그리고 별도로, foreign type에 대해 foreign trait의 impl을 실제로 제공하는 crate를 둘 수 있습니다. 이런 impl은 가능한 한 많이 격리될 수 있습니다. 실제 impl을 제공하는 책임은 최종 바이너리만 지게 되기를 기대할 수 있습니다.
생각해 보면, “나는 impl이 필요하다”를 표현하는 일은 우리가 항상 하고 있습니다. 다만 보통은 generic type을 이용합니다. 예를 들어 제가 다음과 같은 함수를 쓸 때…
fn clone_list<T: Clone>(v: &[T]) {
…
}
저는 “type T가 필요하고 그것이 Clone을 구현해야 한다”라고 말하고 있지만, 그 type이 정확히 무엇인지는 구체적으로 지정하지 않고 있습니다.
사실 where-절을 이용해서 non-generic type에 대한 사항을 지정하는 것도 가능합니다…
fn example()
where
u32: Copy,
{
{
…하지만 오늘날 컴파일러는 이런 것을 다루는 방식이 다소 일관되지 않습니다. 계획은 사용자가 쓴 내용을 우리가 “신뢰하는” 모델로 옮겨 가는 것입니다. 예를 들어 사용자가 where String: Copy라고 썼다면, 함수는 Copy impl을 찾을 수 없더라도 String type을 마치 Copy인 것처럼 취급하게 됩니다. 물론 그런 함수는 실제로는 절대 호출될 수 없겠지만, 그렇다고 정의하지 못할 이유는 없습니다2.
where-절을 crate 범위에 둘 수 있다면 어떨까요? 그렇게 하면 실제 impl을 제공하지 않고도, 존재해야 하는 impl을 표현하는 데 사용할 수 있습니다. 예를 들어 앞서의 예에 나온 widget-factory crate는 lib.rs에 다음과 같은 줄을 추가할 수 있을 것입니다:
// Crate widget-factory
where Widget: Hash;
그 결과 사람들은 (a) Widget에 대한 Hash impl을 제공하거나, 또는 (b) 자기 쪽에서도 같은 where-절을 반복해서 의존하는 crate 쪽으로 그 요구를 전파하지 않는 한, 그 crate를 사용할 수 없게 됩니다. (다른 모든 where-절과 마찬가지입니다.)
의도는 후자, 즉 의존성을 루트 crate까지 전파하는 것입니다. 그러면 루트 crate는 직접 impl을 제공하거나, 그렇게 하는 다른 crate를 링크할 수 있습니다.
이 아이디어의 다음 부분은 crate가 foreign impl에 대해 foreign trait를 구현할 수 있도록 허용하는 것입니다. 저는 orphan 검사를 기본적으로 거부하는 lint로 바꾸고 싶습니다. lint 메시지는 이런 impl이 linker 오류를 일으킬 수 있기 때문에 허용되지 않는다고 설명하되, crate는 impl에 #[allow(orphan_impls)]를 표시해서 그 경고를 무시할 수 있게 하는 것입니다. 가장 좋은 관행은 orphan impl을 다른 사람들이 사용할 수 있도록 별도의 crate에 넣는 것이 될 것입니다.
Josh Triplett는 또 다른 흥미로운 아이디어를 제시했는데, 중복 impl을 허용할 수 있다는 것입니다. 흔한 예시는 impl이 derive를 통해 정의되는 경우일 것입니다. (다만 그러려면 local이 아닌 struct 정의에 대해서도 somehow derive할 수 있도록 derive를 확장해야 하겠습니다.)
실제 impl을 제공하지 않더라도, 서로 모순되는 where-절을 포함하고 있다면 함께 링크할 수 없는 두 crate를 만드는 것은 가능합니다. 예를 들어 아마 widget-factory는 Widget을 문자열에 대한 iterator로 정의할 수 있을 것입니다…
// Widget-factory
where Widget: Iterator<Item = String>;
…한편 widget-lib는 Widget이 UUID에 대한 iterator이기를 원할 수 있습니다:
// Widget-lib
where Widget: Iterator<Item = UUID>;
결국 이 where-절들 중 많아야 하나만 만족될 수 있고 둘 다를 동시에 만족시킬 수는 없으므로, 두 crate는 상호운용되지 않을 것입니다. 이것은 피할 수 없고, 괜찮아 보입니다.
한동안 논의되어 온 또 다른 아이디어는 target architecture 전반의 이식성을 trait와 어떤 종류의 Platform type을 통해 표현하는 것입니다. 예를 들어 where Platform: NativeSimd라고 말하는 코드를 상상해 볼 수 있습니다. 이는 “이 코드는 native SIMD 지원이 필요하다”는 뜻입니다. 또는 where Platform: Windows라고 해서 “이 코드는 다양한 windows API를 지원해야 한다”는 뜻으로 사용할 수도 있겠습니다. 이것은 그저 아이디어의 “핵” 정도에 불과하고, 실제 trait 계층이 어떤 모습이어야 할지는 전혀 모르겠습니다. 하지만 꽤 매력적으로 보이고 crate 수준 where-절이라는 생각과도 잘 들어맞는 듯합니다. 본질적으로 이 아이디어는 crate가 “자신이 사용되는 환경에 제약을 거는 것”을 명시적인 방식으로 허용하자는 것입니다.
사실 crate 수준 where-절이라는 생각은 모듈 수준 generic을 갖는 것의 일종의 특수한 경우이며, 저는 그것을 매우 원합니다. 아이디어는 모듈도 (type, 함수 등처럼) generic parameter와 where-절을 선언할 수 있게 하자는 것입니다.3 이렇게 선언된 것들은 이름을 붙일 수 있고 모듈 내부의 모든 코드에서 사용할 수 있으며, 모듈 바깥에서 어떤 항목을 참조할 때는 그 값도 함께 지정해야 합니다. 이는 trait 수준 generic이 trait 안의 메서드들에 의해 “상속”되는 방식과 매우 비슷합니다.
저는 오래전부터 이것을 원해 왔습니다. 왜냐하면 제가 자주 다루는 모듈들 중에는 모든 코드가 어떤 종류의 “context parameter”에 대해 parameterized된 경우가 많기 때문입니다. 컴파일러에서는 그것이 lifetime ’tcx이고, 아주 흔하게는 어떤 generic type입니다. (예를 들어 salsa의 Interner 같은 것입니다.)
이 글에서 저는 몇 가지를 논의했습니다:
전반적으로 저는 이 방향에 꽤 기대가 됩니다. trait 시스템을 일반화하고 더 균일하게 만드는 쪽으로 생각하면, 점점 더 많은 것들이 가능해지고 있다는 느낌이 듭니다. 제 생각에 이 모든 것은 a-mir-formality에서 trait 시스템을 더 정밀하게 정의하려는 작업과, 그것이 어떻게 동작하는지에 대한 전문성을 가진 팀을 구축하는 작업( types team RFC 참조)에 기반하고 있습니다. 하지만 그것들에 대해서는 앞으로의 글에서 더 쓰겠습니다! =)