Rust의 coherence와 orphan rule이 생태계 발전을 어떻게 저해하는지, 그리고 named impl과 incoherent trait를 통해 이를 어떻게 재구상할 수 있는지를 탐구하는 글.
2026년 3월 23일
이 블로그 글을 작성하는 과정에는 LLM이 사용되지 않았습니다.
Rust 생태계에는 그것이 발전하는 방식에 관한 근본적인 문제가 있습니다.
serde 같은 기반 크레이트는 Serialize 같은 기반 트레이트를 정의하고, 그러면 생태계의 모든 크레이트는 자기 타입에 대해 Serialize 트레이트를 구현해야 합니다. 어떤 크레이트가 자기 타입에 대해 serde의 트레이트를 구현하지 않으면, 다운스트림 크레이트는 다른 크레이트의 타입에 대해 serde의 트레이트를 구현할 수 없기 때문에 그 타입들은 serde와 함께 사용할 수 없습니다.
더 나쁜 점은, 누군가 serde의 대안을 공개한다면(예를 들어 nextserde), serde 지원을 추가한 모든 크레이트는 nextserde 지원도 추가해야 한다는 것입니다. 존재하는 모든 새로운 직렬화 라이브러리에 대한 지원을 추가하는 것은 비현실적이며 크레이트 작성자에게 많은 작업입니다.
이 크레이트들의 사용자 입장에서는 새로운 직렬화 라이브러리를 사용하고 싶다면 이 모든 크레이트를 포크해서 nextserde 지원을 패치해야 합니다. 이는 serde 같은 기반 크레이트의 대안이 만들어지고 생태계 전체로 퍼져 나가기를 훨씬 더 어렵게 만듭니다.
더 나은 대안이 존재하는지와 무관하게 단지 그것들을 대체하기가 인위적으로 어렵다는 이유만으로 “먼저 도착한” 오래된 크레이트가 생태계에 계속 남아 있을 강한 유인이 생깁니다.
이것은 어떤 라이브러리나 Rust 코드를 작성하는 사람들의 잘못이 아닙니다. 오히려 이 문제는 coherence와 orphan rule을 통해 언어 자체가 생태계에 강제하는 것입니다.
Rust 생태계에 coherence가 어떻게 해를 끼치는지에 대한 Niko의 설명도 참고하세요: Coherence and crate-level where clauses - nikomatsakis.
Coherence는 어떤 Trait가 하나의 타입과 그 트레이트의 주어진 제네릭 인자 집합에 대해 최대 한 번만 구현되도록 확인합니다:
trait Trait {}
trait Thingies {}
trait OtherThingies {}
impl<T: Thingies> Trait for T {}
impl<T: OtherThingies> Trait for T {}
error[E0119]: conflicting implementations of trait `Trait`
--> src/lib.rs:7:1
|
6 | impl<T: Thingies> Trait for T {}
| ----------------------------- first implementation here
7 | impl<T: OtherThingies> Trait for T {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation
For more information about this error, try `rustc --explain E0119`.
error: could not compile `playground` (lib) due to 1 previous error
Orphan rule은 coherence를 구현하는 데 도움이 되는 검사입니다. 이것은 현재 크레이트에서 트레이트나 self 타입 중 하나라도 정의되어 있을 때만 트레이트 구현을 작성할 수 있도록 강제합니다(실제로는 이것보다 조금 더 복잡하지만 이 블로그 글에서는 크게 중요하지 않습니다).
// crate a
pub trait Trait {}
pub struct Foo;
// crate b
use a::*;
impl Trait for Foo {}
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
--> src/lib.rs:8:1
|
8 | impl Trait for Foo {}
| ^^^^^^^^^^^^^^^---
| |
| `a::Foo` is not defined in the current crate
|
= note: impl doesn't have any local type before any uncovered type parameters
= note: for more information see https://doc.rust-lang.org/reference/items/implementations.html#orphan-rules
= note: define and implement a trait or new type instead
겹치는 impl이 전혀 없더라도 이 코드는 여전히 orphan rule 때문에 거부됩니다.
다음도 참고하세요: Trait implementation coherence - Rust Reference.
// crate a
#[derive(PartialEq, Eq)]
pub struct MyData(u8);
// crate b
impl Hash for MyData {
fn hash(&self) {
self.0.hash();
}
}
pub fn make_hashset() -> HashSet<MyData> {
// Uses the `Hash` impl defined in this crate to insert
[MyData(1), MyData(12)].into()
}
// crate c
impl Hash for MyData {
fn hash(&self) {
// You probably don't want this to be your hash function...
0.hash();
}
}
pub fn check_hashset(set: HashSet<MyData>) {
// Uses the `Hash` impl defined in this crate to lookup
assert!(set.contains(MyData(1)));
assert!(set.contains(MyData(12)))
}
// crate d
c::check_hashset(b::make_hashset());
이 예제에서는 crate b에서 구성된 HashSet을 crate c의 함수에 전달하는데, 여기서 crate b가 HashSet을 구성할 때 사용한 Hash impl은 crate c가 HashSet에 항목이 있는지 확인할 때 사용하는 Hash impl과 다릅니다.
서로 다른 Hash impl 때문에 check_hashset은 집합에 어떤 값도 존재한다고 알려지지 않는 완전히 말이 안 되는 결과를 내게 됩니다.
다음도 참고하세요: Coherence and crate-level where clauses - nikomatsakis의 “So wait, how does the orphan rule protect composition”.
현재 coherence는 사실 타입 시스템이 sound 하도록 만드는 데 중요합니다:
trait Trait {
type Assoc;
}
// crate a
impl Trait for () {
type Assoc = *const u8;
}
pub fn make_assoc() -> <() as Trait>::Assoc {
// `<() as Trait>::Assoc` is implemented as being `*const u8`
0x0 as *const u8
}
// crate b
impl Trait for () {
type Assoc = Box<u8>;
}
fn drop_assoc(a: <() as Trait>::Assoc) {
// `<() as Trait>::Assoc` is implemented as being `Box<u8>`
let a: Box<u8> = a;
// free'ing an allocation here
drop(a);
}
// crate c
// create a `*const u8` and then implicitly transmute it to a `Box<u8>`
b::drop_assoc(a::make_assoc())
여기에는 연관 타입 Assoc에 대해 서로 다른 값을 지정하는 두 개의 겹치는 트레이트 impl이 있습니다.
사용자가 타입 <()>::Assoc의 값을 만들 때 컴파일러는 이것이 raw pointer라고 생각하고, 나중에 사용자가 타입 <()>::Assoc의 값을 읽을 때 컴파일러는 이것이 Box라고 생각한다면, 안전한 코드에서 *const u8을 Box<u8>로 transmute한 셈이 됩니다.
Coherence는 soundness 에 필요하지만, orphan rule은 (대체로) 그렇지 않습니다. Orphan rule에는 두 가지 주요 이유가 있습니다.
첫째, orphan rule은 Rust 생태계의 모든 크레이트가 서로 조합될 수 있게 합니다. 링크 시점에 겹치는 impl이 없는지만 검사한다고 해도 여전히 sound 하겠지만, 그 경우 서로 호환되지 않는 크레이트가 존재할 수 있습니다:
// crate a
pub trait GetU32 { fn get(self) -> u32 }
// crate b
impl GetU32 for u32 {
fn get(self) -> u32 {
self
}
}
// crate c
impl GetU32 for u32 {
fn get(self) -> u32 {
self
}
}
// crate d
extern crate b;
extern crate c;
// Uh oh... there are two impls of `GetU32` for `u32`.
// Coherence violation -> error
이 예제에서 b와 c는 모두 a에 의존하고 있으며, crate a의 작성자가 그렇게 하는 것을 잊어버렸기 때문에 u32에 대한 GetU32를 각자 직접 구현해야 했습니다. 그리고 나서 crate d가 두 크레이트를 모두 사용하려고 하지만 이제는 겹치는 트레이트 impl 때문에 사용할 수 없습니다.
둘째, orphan rule은 분리 컴파일/동적 링크 상황에서도 coherence를 유지할 수 있게 해 줍니다.
Rust 라이브러리는 동적 라이브러리로 컴파일된 뒤, 그것이 Rust 크레이트였다는 사실을 모른 채 동적으로 링크될 수 있습니다. 우리는 이 라이브러리가 링크 대상 프로젝트의 impl과 겹치는 impl을 가지고 있지 않다는 것을 알아야 합니다.
// crate a
pub trait GetU32 { fn get(self) -> u32 }
// crate b
impl GetU32 for u32 {
fn get(self) -> u32 {
self
}
}
// crate c
impl GetU32 for u32 {
fn get(self) -> u32 {
self
}
}
fn main() { ... }
이 예제에서는 다시 crate b와 c가 있지만, crate b가 동적 라이브러리로 컴파일되어 crate c에 동적으로 링크된 상황을 상상해 보세요.
crate c를 컴파일할 때 컴파일러는 crate b의 내용을 모릅니다. 왜냐하면 그것은 그냥 동적 라이브러리이기 때문입니다. 그럼에도 불구하고 겹치는 impl이 존재하여 unsoundness로 이어질 수 있으므로 컴파일이 성공해서는 안 됩니다.
Orphan rule은 crate c가 어떤 impl을 작성할 수 있었는지 추론할 수 있게 해 주며, 다른 크레이트가 작성할 수 있는 impl을 crate c가 작성할 수 없는 impl로만 제한할 수 있게 합니다.
그래서 orphan rule은 Rust 생태계 전체에 엄청나게 가치가 있지만, 엄밀히 말해 필수는 아니며 주로 coherence를 강제하는 수단입니다.
Coherence와 orphan rule에 어떤 방식으로든 관여하여 그것들을 덜 제한적으로 만들려는 언어 제안은 많이 있습니다.
바이너리 크레이트를 컴파일할 때 orphan rule을 제거합니다. 바이너리가 아닌 크레이트는 여전히 orphan rule을 따릅니다.
바이너리 크레이트의 다운스트림에는 다른 크레이트가 없으므로 이것으로 생태계 조합 문제를 일으킬 수는 없습니다. 다르게 말하면, 바이너리 크레이트와 가상의 업스트림 크레이트가 겹치는 impl을 가질 때 발생하는 파손을 목격할 다운스트림 크레이트가 없습니다.
여전히 업스트림 크레이트가 존재하여 바이너리에 동적으로 링크됨으로써 검사할 수 없는 겹치는 impl이 생길 수는 있습니다:
// crate a
pub trait Trait {}
// crate b
struct Local;
impl a::Trait for Local {}
// crate c
impl<T> a::Trait for T {}
fn main() { ... }
이 예제에서 crate b를 crate c에 동적으로 링크하면, 바이너리 크레이트의 blanket impl이 다른 impl과 겹친다는 사실을 알 방법이 없습니다(그리고 이것은 unsound합니다).
이 접근에는 다른 문제들도 있습니다:
Orphan rule을 완전히 제거하고, 최종 바이너리를 컴파일/링크할 때까지 coherence 검사를 미룹니다.
이것에는 몇 가지 문제가 있습니다:
크레이트는 coherence 목적상 하나의 크레이트 집합의 일부로 서로를 지정할 수 있어야 합니다. 예를 들어 core, alloc, std 크레이트는 서로의 크레이트에 있는 타입에 대해 서로의 크레이트에 있는 트레이트를 구현할 수 있어야 합니다.
이것은 종종 cargo workspace 전체를 coherence상 하나로 간주하자는 맥락에서 구체적으로 이야기되며, 각 크레이트를 개별적으로 보는 대신 그렇게 하자는 것입니다.
Cargo workspace를 coherence 관점에서 단일 단위로 간주하면 컴파일러가 workspace 안의 모든 크레이트를 볼 수 있으므로 동적 링크 문제를 피할 수 있습니다.
이것은 coherence/orphan rule을 우회하는 데 매우 의미 있게 도움이 되지만, 몇 가지 문제가 있습니다:
호환되지 않는 의존성을 허용하여 생태계 조합 문제를 도입합니다. * 구체적으로, workspace 안 크레이트들의 서로 다른 버전에 의존하는 경우 더는 함께 동작하지 않을 수 있습니다.
생태계 진화 문제를 해결하지 못합니다
다음도 참고하세요: Vague proposal: Extending coherence with workspaces - Nikomatsakis
RFC1032-Rebalancing-Coherence는 타입과 트레이트에 적용되었을 때 coherence/orphan rule에서 그것들이 취급되는 방식을 바꾸는 #[fundamental] 속성을 도입합니다. RFC에서 인용하면 다음과 같습니다:
#[fundamental]타입Foo는Foo에 대한 blanket impl을 추가하는 것이 큰 변경인 타입입니다. 설명한 대로&와&mut은 fundamental입니다.#[fundamental]트레이트Foo는 기존 타입에 대해Foo의 impl을 추가하는 것이 큰 변경인 트레이트입니다.
이것은 coherence/orphan rule에 약간의 추가 유연성을 허용합니다(구체적인 사용 사례는 RFC를 보세요). 여기에는 두 가지 주요 문제가 있습니다:
두 impl이 “같은” impl이라면 겹치는 것을 허용합니다:
// crate a
pub trait Trait {
type Assoc;
}
// crate b
impl Trait for () {
type Assoc = ();
}
// crate c
// legal even though it overlaps with crate `b`'s impl
// as the implementation is the exact same
impl Trait for () {
type Assoc = ();
}
이것에는 몇 가지 문제가 있습니다:
기술적인 수준에서 다른 문제들도 있습니다. 예를 들어 SemVer 문제라든지, 구현이 “같다”는 것을 대체 어떻게 정의할 것인지 같은 문제들입니다. 이 블로그 글에서는 coherence가 생태계에 미치는 영향과 우리가 그것에 대해 무엇을 할 수 있는지라는 큰 그림에 더 관심이 있으므로, 저는 이것들이 중요하지 않다고 생각합니다.
RFC1268-allow-overlapping-impls-on-marker-traits는 연관 항목이 없는 트레이트에 대해 겹치는 impl을 허용하자고 제안합니다. RFC 이후 구현 과정에서 이것은 #[marker] 속성으로 표시된 트레이트에 대해서만 겹치는 impl을 허용하는 방식으로 바뀌었습니다.
이 기능은 완전히 sound하지만 생태계 진화 문제를 해결하지는 못합니다.
RFC1210-impl-specialization은 겹치는 구현을 허용하되, impl 중 하나가 다른 impl이 적용되는 경우의 부분집합에만 적용되도록 하자는 제안입니다. 다시 말해, 한 impl이 더 일반적인 impl의 특수한 경우인 상황을 허용하자는 것입니다.
Specialization은 언어의 표현력을 높이는 동시에 coherence를 우회하는 도구 역할도 하는 기능입니다. Specialization의 설계 및 soundness 문제는 무시하더라도(이미 다른 곳에서 충분히 논의되었습니다), 이 기능은 몇몇 제한적인 경우에는 coherence를 우회하는 데 도움이 됩니다.
안타깝게도 생태계 진화 문제는 해결하지 못합니다.
Reflection and Comptime Project Goal은 라이브러리가 일부 사용 사례(예를 들어 임의 타입의 직렬화)에서 트레이트를 아예 피할 수 있을 정도로 강력한 reflection을 언어에 도입하자고 제안합니다.
이것은 탐구해 볼 가치가 있는 유망한 방향처럼 보이며, coherence 문제를 해결하는지와 무관하게 언어에 있으면 가치가 있을 것 같습니다. 어떤 경우에는 이것이 coherence를 상당한 정도로 우회할 수 있게 해 줄 것이라고 상상할 수 있습니다.
하지만 언어의 아주 핵심적인 부분을 고치는 대신, 그 핵심 부분을 피하기 위한 새로운 언어 기능을 도입해서 생태계 진화 문제를 “해결”하는 것은 제게는 어딘가 잘못된 느낌이 듭니다. 사람들은 coherence가 강제하는 제약 때문에 트레이트를 피하는 것이 아니라 트레이트를 사용하도록 하고 싶어야 합니다.
이 모든 제안이 coherence/orphan rule을 의미 있게 약화시키기는 하지만, 생태계 진화 문제는 여전히 해결되지 않은 채로 남아 있습니다.
그렇다면 coherence를 아예 제거할 수 있다면 어떨까요? 그러면 생태계 진화 문제는 확실히 해결될 것입니다.
먼저 특정 impl을 가리키는 방법, 즉 impl에 이름을 붙이는 방법을 도입해 봅시다:
trait Trait<T> {}
impl Name<T> = Trait<T> for T { }
이 문법은 const item을 닮아 있으며, 트레이트는 일종의 타입이고 impl은 이 타입의 일종의 값이라는 근본 개념을 표현합니다.
트레이트의 “값”을 가진다는 것이 정확히 무엇을 의미하는지는 조금 복잡하고, 나중 글에서 더 깊이 다룰 생각입니다. 하지만 대체로 trait는 일종의 Vtable을 정의하고 impl은 그 종류의 VTable을 생성한다고 생각할 수 있습니다.
트레이트의 값이 무엇을 의미하는지에 대한 더 많은 생각은 Elaborating Rust Traits to Dictionary-Passing Style - Nadrieril도 참고하세요.
다음으로 어떤 트레이트 바운드를 만족시키기 위해 어떤 트레이트 impl이 사용되는지를 지정하는 문법을 도입해 봅시다:
fn function<T: Trait + OtherTrait>(x: T) -> T
where
(): Five,
{
...
}
impl TraitImpl<T> = Trait for T { ... }
impl OtherTraitImpl<T> = OtherTrait for T { ... }
impl ImplFive = Five for () { ... }
let result =
function::<T + TraitImpl<T> + OtherTraitImpl<T>>(...)
where
ImplFive,;
대체로 함수 정의 위치에서 쓰는 문법을 그대로 따르되, 트레이트 바운드를 쓰는 대신 트레이트 impl의 경로를 적습니다.
마지막으로 트레이트 바운드에 이름을 붙이는 방법을 도입해 봅시다:
fn function<T>(x: T) -> T
where
impl SizedImpl: Sized for T,
impl TraitImpl: Trait for T,
impl OtherTraitImpl: OtherTrait for T,
impl FiveImpl: Five for (),
{
other_function::<T + SizedImpl>();
...
}
컴파일러가 트레이트 바운드의 증명을 요구하는 다른 위치들도 있는데, 그런 곳들에서도 어떤 impl을 지정할 수 있는 문법이 필요할 것입니다(예를 들어 <T as Trait>::Assoc에서 T: Trait가 어떻게 증명되는지 같은 부분입니다).
저는 이런 모든 위치를 하나하나 다루지는 않을 것입니다. 이 글의 목적은 언어 변경에 대한 완전한 제안을 만드는 것이 아니라, 더 큰 그림을 논의하는 데 있기 때문입니다.
이 모든 문법을 도입한다고 해서 그 자체로 큰 이득이 생기지는 않습니다. Coherence가 존재하는 한, 트레이트 솔버는 거의 항상 아무 도움 없이도 어떤 트레이트 impl이 쓰이는지 스스로 알아낼 수 있기 때문입니다.
그렇다고 해도, 어떤 트레이트 바운드가 어떻게 증명되는지를 명시적으로 주석할 수 있으면 도움이 될 수 있는 몇몇 특수한 경우는 있습니다:
이 모든 새로운 문법과, 어떤 impl이 트레이트 바운드를 만족시키는 데 사용되는지에 대해 추론할 수 있는 능력을 추가하는 핵심 목적은 겹치는 트레이트 impl을 허용하기 위해서입니다. 이것이 없으면 겹치는 트레이트 구현은 그다지 유용하지 않습니다:
impl Clone for MyType { fn clone(&self) -> Self { loop {} } }
impl Clone for MyType { fn clone(&self) -> Self { MyType(self.0) } }
fn takes_cloneable<T: Clone>(_: T) {}
fn main() {
// what impl is used? the compiler cant figure it out so error...
takes_cloneable(MyType(1));
}
하지만 named trait impl과 트레이트 바운드 매개변수가 있다면:
impl Impl1 = Clone for MyType { ... }
impl Impl2 = Clone for MyType { ... }
fn takes_cloneable<T: Clone>(_: T) {}
fn main() {
takes_cloneable::<_ + Impl1>(MyType(1));
takes_cloneable::<_ + Impl2>(MyType(2));
}
겹치는 impl을 임의로 허용할 수는 없습니다. unsafe 코드가 올바르기 위해서 오직 하나의 impl만 존재한다는 사실을 coherence가 보장해 주기를 기대하는 트레이트가 있을 수 있기 때문입니다. 그래서 대신 incoherent trait를 도입하여, 어떤 트레이트가 coherence와 orphan rule에서 완전히 빠져나갈 수 있게 할 수 있습니다:
// crate a
pub incoherent trait Serialize {
fn serialize(&self) -> String;
}
// crate b
pub struct Matrix(...)
// crate c
impl CSerialize = a::Serialize for b::Matrix { ... }
// crate d
impl DSerialize = a::Serialize for b::Matrix { ... }
Coherence를 제거하고 트레이트 바운드 매개변수를 도입했을 때 흥미로운 결과 중 하나는, impl에 트레이트 바운드를 거는 것과 struct에 트레이트 바운드를 거는 것 사이에 의미 있는 차이가 생긴다는 점입니다:
incoherent trait Name {
const NAME: &'static str;
}
impl DummyName<T> = Name for T {
const NAME: &'static str = "dummy";
}
impl RealName<T> = Name for T {
const NAME: &'static str = core::any::type_name::<T>();
}
#[derive(Copy, Clone)]
struct Foo<T>(T);
impl MyImpl<T: Name> = Foo<T> {
pub fn do_stuff(self) {
println!("{}", <T as Name>::NAME);
}
}
fn main() {
let foo = Foo(1);
// prints "dummy"
MyImpl<_ + DummyName<_>>::do_stuff(foo);
// prints "i32"
MyImpl<_ + RealName<_>>::do_stuff(foo);
}
이 예제에서 타입 Foo는 Name 트레이트에 대해 아무것도 모르고, do_stuff를 정의하는 MyImpl만 그것을 압니다. 함수를 호출할 때마다 T: Name 매개변수에 서로 다른 impl을 제공할 수 있습니다.
반면 Foo를 struct Foo<T: Name>로 정의하면:
#[derive(Copy, Clone)]
struct Foo<T: Name>(T);
impl MyImpl<T: Name> = Foo<T> {
...
}
fn main() {
let foo = Foo::<_ + DummyName<_>>(1)
// prints "dummy"
MyImpl<_ + DummyName<_>>::do_stuff(foo);
// errors as `foo` has type `Foo<u8 + DummyName<u8>>` but
// the impl requires the self type to be `Foo<u8 + RealName<u8>>`
MyImpl<_ + RealName<_>>::do_stuff(foo);
}
타입 정의에 있는 트레이트 바운드는 타입의 일부입니다. Foo<u8 + Impl1>은 Foo<u8 + Impl2>와 다른 타입입니다. Foo 값을 다룰 때는 그것이 비일관적 트레이트이더라도 어디서나 동일한 Name impl이 사용된다고 가정할 수 있습니다.
다음도 참고하세요: 곁가지: ADT에 대한 Maybe Bounds
앞에서 coherence가 왜 애초에 존재하는지 다뤘고, 거기에는 두 가지 주요 이유가 있었습니다:
트레이트 바운드를, 어떤 impl이 사용되었는지에 해당하는 인자를 갖는 매개변수로 desugar하는 우리의 방식에서는 이 둘 모두 문제가 되지 않습니다.
블로그 글 Coherence and crate-level where clauses - nikomatsakis에서 Niko는 HashMap<K, V>가 K 값을 해시하는 일관된 방법을 사용해야 하고(마찬가지로 K 값을 동등 비교하는 일관된 방법도 필요하다는 점에서) 왜 이것이 필요한지를 설명합니다.
우리의 가상 설계에서 이것을 유지하는 방법은 두 가지입니다.
한 가지 선택지는 Hash, Eq, PartialEq를 coherent trait로 유지하는 것입니다(즉, incoherent trait로 정의하지 않는 것입니다). 그러면 HashMap이 동작하는 현재 상태가 유지됩니다.
다른 선택지는 Hash/PartialEq/Eq를 비일관적 트레이트로 만들되, 바운드를 HashMap의 정의로 옮기는 것입니다: struct HashMap<K: Hash + Eq, V> { ... }. 이렇게 하면 일단 HashMap 값이 만들어지고 나면, 어떤 impl이 사용되는지가 HashMap 타입 자체의 일부가 되기 때문에, 그 값에 대해서는 항상 같은 Hash/Eq impl이 사용됩니다.
위 선택지들을 조금 변형한 것으로, 곁가지: ADT에 대한 Maybe Bounds에서 설명된 maybe 바운드를 사용해 HashMap을 struct HashMap<K: maybe Hash + maybe Eq>처럼 정의할 수도 있습니다. 이는 항상 Hash/Eq impl을 요구하는 것보다 더 유연할 것입니다.
이 마지막 해결책, 즉 트레이트 바운드를 HashMap의 타입 정의로 옮기는 방식은 분명 파괴적 변경이 될 것이고 복잡하고 긴 마이그레이션 전략이 필요하겠지만, 이 블로그 글은 실제로 이를 실무에서 어떻게 달성할 수 있을지의 작은 세부사항보다 큰 그림을 생각하는 데 더 초점을 맞추고 있습니다.
앞서 우리는 서로 다른 크레이트가 연관 타입을 서로 다른 타입이라고 간주하는 일을 막기 위해 coherence가 필요하다고 이야기했습니다:
trait Trait {
type Assoc;
}
// crate a
impl Trait for () {
type Assoc = *const u8;
}
pub fn make_assoc() -> <() as Trait>::Assoc {
// `<() as Trait>::Assoc` is implemented as being `*const u8`
0x0 as *const u8
}
// crate b
impl Trait for () {
type Assoc = Box<u8>;
}
fn drop_assoc(a: <() as Trait>::Assoc) {
// `<() as Trait>::Assoc` is implemented as being `Box<u8>`
let a: Box<u8> = a;
// free'ing an allocation here
drop(a);
}
// crate c
// create a `*const u8` and then implicitly transmute it to a `Box<u8>`
b::drop_assoc(a::make_assoc())
새로운 desugaring을 사용하면 이것을 어떤 impl이 사용되는지를 좀 더 명시적으로 이야기하도록 다시 쓸 수 있고, 그러면 이 문제가 어떻게 해결되는지 볼 수 있습니다:
incoherent trait Trait {
type Assoc;
}
// crate a
impl ATrait = Trait for () {
type Assoc = *const u8;
}
pub fn make_assoc() -> ATrait::Assoc {
0x0 as *const u8
}
// crate b
impl BTrait = Trait for () {
type Assoc = Box<u8>;
}
fn drop_assoc(a: BTrait::Assoc) {
let a: Box<u8> = a;
drop(a);
}
// crate c
let a_assoc: a::ATrait::Assoc = a::make_assoc()
// error: expected `b::BTrait::Assoc` but found `a::ATrait::Assoc`
b::drop_assoc(a_assoc)
struct HashMap<K: Hash>에서 K: Hash에 사용되는 impl이 HashMap 타입의 일부인 것과 비슷하게, (): Trait에 사용되는 impl도 연관 타입의 일부가 됩니다. 이로 인해 컴파일러는 둘 다 <() as Trait>::Assoc이더라도 a::make_assoc의 반환 타입과 b::drop_assoc의 인자가 서로 다른 타입이라는 것을 알아낼 수 있습니다.
Impl을 명시적으로 전달되는 값으로 보는 이 모델은 제게 정말 흥미진진합니다. 이 방식에는 정말 많은 이점이 있기 때문입니다:
컴파일러가 이런 방식으로 트레이트를 다루도록 바꾸는 일은 몇 년에 걸친 적극적인 작업이 필요한 종류의 일입니다(다만 그것은 이미 진행 중입니다: Dictionary Passing Style Experiment - 2026 Rust Project Goal).
그리고 그 일이 끝난 뒤에도 incoherent 트레이트에 이를 실제로 활용하기 위해 해야 할 언어 설계 작업이 여전히 남아 있습니다:
incoherent 트레이트에서 사용성이 크게 나빠질 것입니다.저는 오랫동안 coherence에 대한 블로그 글을 쓰고 싶었습니다. coherence가 왜 존재하는지 제대로 이해하지 못한 채 coherence에 대해 불평하는 사람들을 자주 보기도 하고, coherence/orphan rule을 충분히 일반적이지 않거나 soundness가 부족한 방식으로 완화하자고 제안하는 사람들도 보았기 때문입니다.
Coherence가 Rust에 얼마나 가치 있었는지는 아무리 강조해도 지나치지 않지만, 생태계 진화 문제 역시 그에 못지않게 중요합니다. Rust가 coherence를 가진 것이 실수였다고 생각하지는 않지만, coherence가 Rust에 준 이점을 희생하지 않으면서 어떻게 비일관적인 Rust를 향해 나아갈 수 있을지를 진지하게 고민해야 한다고는 생각합니다.
이 블로그 글에서 사람들이 가져가 주었으면 하는 것이 있다면(분명 지나치게 작지 못한 글이지만), 그것은 아마 coherence를 우회하는 것이 아니라 coherence를 제거함으로써 우리의 문제를 해결할 수 있는 세계가 있을지도 모른다는 점입니다.
읽어주셔서 감사합니다 :)
이 글을 읽는 데 필수적이지는 않지만, 그래도 어딘가에는 들어갈 자격이 있다고 느껴졌던 무작위 생각/정보 모음입니다.
trait Trait {
type Assoc;
}
impl BlanketTrait<T> = Trait for T {
type Assoc = T;
}
fn foo<T>(x: T)
where
impl TraitBound: Trait for T,
{
// Does checking `T: Trait` use `TraitBound` or `BlanketTrait`?
let a: <T as Trait>::Assoc = T;
}
위 예제에서는 컴파일러가 T가 Trait를 구현한다는 것을 알아내는 두 가지 방법이 있습니다. 하나는 BlanketTrait라는 이름을 붙인 blanket impl을 통해서이고, 다른 하나는 TraitBound라는 이름을 붙인 트레이트 바운드 매개변수를 통해서입니다.
컴파일러가 어느 쪽을 선택하느냐에 따라 컴파일은 성공할 수도 실패할 수도 있습니다:
TraitBound를 선택하면, TraitBound는 Assoc의 값을 지정하지 않으므로(예를 들어 T: Trait<Assoc = T>처럼 하지 않으므로) <T as Trait>::Assoc가 T와 같다는 것을 알 수 없습니다.BlanketTrait를 선택하면, 그 impl이 Assoc의 값을 지정하기 때문에 <T as Trait>::Assoc가 T와 같다는 것을 알 수 있습니다.컴파일러가 현재 이 두 선택지 사이에서 어떻게 고르는지에 대한 일반 개념은 candidate preference 또는 impl shadowing이라고 불립니다: Tracking issue for where-bounds shadowing trait implementations".
Named trait impl이 있다면 BlanketTrait를 사용해야 한다는 것을 명시적으로 지정할 수 있을 것이고, 마찬가지로 TraitBound를 사용해야 한다는 것도 명시적으로 지정할 수 있을 것입니다.
Inherent impl에도 이름 붙이기를 지원한다면, inherent 연관 항목에 접근하기 위한 완전 수식 문법도 지원할 수 있을 것입니다:
struct Foo;
impl Inherent = Foo {
fn assoc(&self) {
dbg!("inherent");
}
}
trait Trait {
fn assoc(&self);
}
impl Trait for Foo {
fn assoc(&self) {
dbg!("trait");
}
}
fn main() {
<Foo as Inherent>::assoc(Foo);
// or maybe
Inherent::assoc(Foo);
}
이것은 아마 꽤 틈새적인 이점이겠지만, 그래도 어떤 경우에는 독자에게 트레이트 메서드가 아니라 inherent 메서드를 호출하려는 의도를 명시적으로 전달할 수 있다는 점에서 유용할 수 있습니다.
글 On always-applicable trait impls - lcnr에서는 maybe 바운드라는 아이디어가 도입되었습니다. 우리의 모델에서는 이것이 where impl Name: Option<Trait for T>를 지원하는 것과 같습니다:
struct Foo<T>(T)
where
impl Name: Option<Trait for T>;
fn foo<T>(arg: T)
where
impl Name: Trait for T
{
let mut foo = Foo(arg) where None;
let foo2 = Foo(arg) where Some(Name);
// error as `Foo<T> where None` and `Foo<T> where Name`
// are different types
foo = foo2;
}
이것은 어떤 impl이 반드시 사용 가능할 것을 요구하지는 않지만, 그 impl이 사용 가능하다면 그 타입에 대해서는 항상 같은 impl이 사용되도록 하는 타입을 정의할 수 있게 해 줍니다.