Rust에 새로운 자동 트레이트를 도입할 때, 제네릭 트레이트 메서드가 얽힌 하위 호환성 문제를 짚고, 제공/필수 메서드 각각에 대해 에디션별 이중 버전 생성과 트레이트 분할을 통한 완화 전략을 제안한다.
Rust에 새로운 자동 트레이트를 도입하는 아이디어에 대한 제 생각을 정리하면서, Ariel Ben‑Yehuda와 나눈 대화에서 나온 몇 가지 메모를 공유하고자 합니다.
맥락을 위해 아래 두 글을 먼저 읽어보세요:
이번 논의의 목표는, 현재 모든 타입이 특정 동작을 가진다고 가정하는 기존 코드를 깨뜨리지 않으면서, Rust에 새로운 자동 트레이트를 도입하는 것입니다. 두 번째 글에서, 제네릭 트레이트 메서드에 의존하는 구 코드와 신 세계의 코드를 호환시키는 일이 얼마나 어려운지 보여주는 꽤 성가신 예시를 보였습니다.
제가 받은 가장 흔한 반응은, 효과적으로 크레이트 foo를 2021에서 2024로 전환하는 과정에 “깨지는 변경”이 있었고, 따라서 오류의 원인은 bar 크레이트에 있다는 것이었습니다. Ralf Jung의 댓글이 이 입장을 잘 요약합니다:
저는 이것을 방화벽이 아니라 디슈가링(desugaring)이라고 생각합니다. 일반적으로 에디션을 이렇게 접근하죠: 이전 에디션의 모든 코드는 암묵적으로 현재 에디션으로 디슈가링됩니다(혹은, 대안적이고 아마 더 현실적으로는, 모두가 “에디션 간 Rust”로 함께 디슈가링됩니다).
2021 에디션에서 다음과 같이 썼다면:
pub struct Bar; impl foo::Foo for Bar { fn foo<T>(input: T) { std::mem::forget(input); } }이것은 다음으로 디슈가링됩니다
pub struct Bar; impl foo::Foo for Bar { fn foo<T: Leak>(input: T) { // 디슈가링에 의해 `Leak` 경계가 추가됨 std::mem::forget(input); } }그리고 분명히, 이 경우 트레이트의 경계보다 구현 쪽 경계가 더 제한적이므로 오류가 발생합니다.
그는 이 해석이 함의하는 바를 예리하게 지적합니다:
그 결과로, 함수의 제네릭들에 Leak 경계가 없는 2024 에디션의 어떤 트레이트도 2021 에디션 코드에서는 구현할 수 없다는 문제가 생깁니다(해당 함수가 기본 구현을 가진다면, 최소한 그 기본을 사용할 수는 있겠지만요). 이는 꽤 안 좋아 보입니다. 예를 들어, core가 2024로 옮겨질 때, Iterator::chain/zip/…에 명시적으로 Leak 경계를 추가하지 않으면, 이 메서드들을 덮어쓰는 기존 구현들을 깨뜨리게 될 가능성이 높습니다. 영 썩 좋아 보이지 않네요…
다시 말해, 이 문제는 첫 글에서의 ?Trait 접근법에서 나타난 문제와 유사해 보입니다. 다만 지금은 연관 타입이 아니라 제네릭 메서드의 문제로: 기존 코드와의 하위 호환을 위해 안정 API들이 실제로 필요 이상으로 더 제한적이어야 한다는 점입니다.
저는 이 해석이 옳다고 봅니다. 여기에는 분명 “쉬운 답”이 있습니다. 하지만 만족스럽지는 않습니다. ?Trait의 하위 호환성 문제와 마찬가지로, 이는 이런 식의 새로운 자동 트레이트를 추가하는 것이 정말 좋은 생각인지 의문을 갖게 합니다. 언어가 하위 호환성 때문에 특정 인터페이스에 임의의 제약을 남기는 좋지 않은 상태에 머물게 되기 때문입니다.
다행히도, std 안에서 이런 인터페이스는 몇 개 안 됩니다(다만 serde처럼 근본적으로 가치 있는 다른 크레이트들에는 더 많습니다):
Iterator: 제네릭 결합자(combinator) 메서드가 매우 많음FromIterator와 Extend: 각각 임의의 이터레이터에 대해 제네릭인 메서드를 하나씩 가짐이 두 예시는 서로 다릅니다. 첫 번째의 경우 결합자 메서드에는 제공된 기본 구현이 있고, 사용자가 실제로 이를 오버라이드하도록 의도되지 않았습니다. 두 번째의 경우, 해당 트레이트를 구현할 때 그 메서드들이 필요합니다(물론 두 경우 모두 인자를 잊어버리는 코드는 병적인 코드겠지만요). 에디션 업그레이드 시 새로운 자동 트레이트를 요구하지 않도록, 이들 메서드의 제네릭 경계를 하위 호환적으로 완화하는 해결책을 논의하려 합니다.
이 두 아이디어 모두 다음의 중요한 사실에 의존합니다: T: Leak인 타입은 임의의 T를 기대하는 인터페이스에도 전달할 수 있습니다. 따라서, Leak 경계가 있는(구 에디션이기 때문에) 인터페이스는 경계가 없는 인터페이스로 포워딩할 수 있습니다.
Iterator::map 등)제공된 메서드에 부착할 수 있는 속성을 도입해, 컴파일러가 동일한 기본 정의를 공유하는 두 가지 버전의 메서드를 생성하도록 합니다. 하나에는 모든 타입 파라미터에 Leak 경계를 추가하고, 다른 하나에는 추가하지 않습니다. 구 에디션에서는 전자를 호출하고, 신 에디션에서는 후자를 호출합니다. 구 에디션의 사용자가 이 메서드를 오버라이드하면, 구 에디션용 메서드만 오버라이드하게 됩니다.
가능하다면, 언어가 신 에디션에서 구 에디션 버전의 메서드를 호출하는 문법을 지원할 수도 있습니다. 이는 가장 엄격한 의미의 하위 호환성을 위해 필요합니다. 즉, 이 메서드 호출이 오버라이드된 버전으로 디스패치되는 것이 중요하다면 말이죠. 하지만 이 메서드들은 본래 사용자가 오버라이드하도록 의도되지 않았으므로, 프로젝트는 이를 에디션에서 허용 가능한 동작 변화로 판단할 수도 있습니다.
FromIterator::from_iter 등)제네릭 필수 메서드를 가진 트레이트의 경우, 트레이트에 속성을 적용해 사실상 두 가지 버전의 트레이트를 만듭니다. 하나는 모든 메서드의 제네릭에 경계를 추가하고, 다른 하나는 추가하지 않습니다. 두 번째 버전을 구현하는 모든 타입은 첫 번째 버전도 구현하지만, 그 역은 성립하지 않습니다. 구 에디션의 사용자가 트레이트를 구현하면, 첫 번째 버전을 구현합니다. 그들에게는 두 번째 버전은 사실상 존재하지 않습니다.
신 에디션의 사용자가 어떤 타입이 그 트레이트를 구현한다고 증명하려 할 때, 그 구현이 구 에디션에서 온 것이라면 첫 번째 버전의 트레이트만 사용할 수 있고, 두 번째 버전은 사용할 수 없습니다. 만약 두 번째 버전이 필요하다면(예: !Leak 타입을 그 인터페이스에 전달하려고 하기 때문에), 컴파일러 오류가 발생합니다.
그 트레이트를 구현하는 타입이 제네릭이고, 사용자가 두 번째 버전의 트레이트만을 지원하도록 자신을 제한할 필요가 있다면, 경계에 이를 나타내는 추가 문법이 필요합니다.
이 규칙하에서, 이전 글의 예시는 다음과 같이 바뀝니다:
크레이트 foo에서, Foo 트레이트는 이 호환성 레이어를 사용한다는 것을 나타내는 속성(편의상 #[leak_compatible]라고 부르겠습니다)을 얻게 됩니다:
#[leak_compatible]
trait Foo {
fn foo<T>(_: T);
}
크레이트 baz에서는, !Leak 타입 Baz에 대해 T::Foo를 호출하기 때문에, 경계 T: Foo에 속성을 추가해야 합니다:
struct Baz;
impl !Leak for Baz { }
fn baz<T: #[no_leak] Foo>() {
T::foo(Baz);
}
이제, quux가 bar::Bar로 baz::baz를 호출하려고 하면 컴파일러 오류가 발생합니다.
이 아이디어는 언어 팀이 이미 작업 중인 “RTN”과 어느 정도 유사성이 있습니다. RTN은 트레이트 메서드의 익명 반환 타입에 경계를 추가할 수 있게 해 주지만, 여기서는 반환 타입이 아니라 메서드의 제네릭 파라미터에 추가 경계가 필요하다는 점이 다릅니다.
항상 더 많은 구멍이 이 “방화벽” 제안에서 발견될 가능성은 있습니다. 불투명한 문법 없이 !Leak 타입을 받아들이도록 하위 호환적으로 업그레이드할 수 없는 더 많은 인터페이스들이 나타날 수도 있죠. 호환성 레이어의 복잡성이 커질수록, 과연 그 비용을 치를 가치가 있는지 의문이 들기 시작합니다.
이 연재는 Rust 프로젝트에 무언가를 하자고 제안하기 위한 글이 아니었습니다. 저는 Rust의 미래 에디션에서 선형 타입을 지원하는 아이디어에 대해 복잡하고 상충하는 감정을 가지고 있습니다. 이 글의 목적은 언어의 규칙에 그렇게 근본적인 변화를 가하는 데 따르는 어려움들을 좀 더 진지하게 탐구하는 데 있었습니다. 이전의 논평들은 이런 변화를 만드는 아이디어를 너무 가볍게 여겼고, 무엇이 요구되는지에 대한 이해를 충분히 진전시키지 못했다고 느꼈습니다.