Rust 컴파일러는 고아 규칙(일관성 시스템의 핵심 구성 요소)을 적용하여 각 트레이트-타입 쌍이 전체 종속성 그래프에서 최대 하나의 구현만 갖도록 보장합니다. 이 규칙은 impl 블록이 유효하려면 구현되는 트레이트 또는 구현을 받는 타입 중 하나가 현재 크레이트 내에 정의되어 있어야 한다고 규정합니다. 즉, "로컬" 크레이트라고 합니다. 트레이트와 타입 모두 외부(외부)인 구현을 금지함으로써, Rust는 두 개의 독립적인 크레이트가 동일한 대상을 위한 충돌하는 구현을 도입할 수 있는 시나리오를 방지합니다. 이는 다운스트림 프로젝트에서 정의되지 않은 동작이나 해결할 수 없는 모호성을 초래할 수 있습니다. "로컬 타입" 예외는 개발자가 로컬 타입에 대해 외부 트레이트를 구현하거나 외부 타입에 대해 로컬 트레이트를 구현할 수 있게 하여, 명확한 단형화와 런타임 디스패치 테이블 없이 제로 비용 추상화를 보장합니다.
우리 팀은 serde 프레임워크를 사용하여 스키마 정의를 JSON으로 직렬화해야 하는 고성능 GraphQL 서버 라이브러리를 개발하고 있었습니다. 우리는 로컬 Schema 구조체에 대해 serde의 Serialize 트레이트를 구현해야 했고, 이는 타입이 로컬이므로 간단했습니다. 하지만 우리는 또한 외부 graphql_parser 크레이트의 Document 타입에 대해 표준 Display 트레이트를 통해 로깅 시스템에 통합하기 위한 사용자 지정 형식화가 필요했습니다. 이것은 Document와 Display 모두 외부이기 때문에 설계 긴장을 유발했고, 상위 크레이트가 자신의 Display 구현을 추가할 경우 미래의 파손 가능성에 대한 우려가 있었습니다.
우리가 고려한 첫 번째 해결책은 Newtype 패턴으로, graphql_parser::Document를 튜플 구조체 struct DocWrapper(graphql_parser::Document)로 래핑하고 DocWrapper에 대해 Display를 구현하는 것이었습니다.
이 접근 방식은 DocWrapper가 로컬 타입이므로 고아 규칙을 완벽하게 준수하며, Rust는 런타임 오버헤드 없이 newtype에 대해 제로 비용 추상화를 보장합니다. 이를 통해 API에 대한 완전한 제어를 유지하고 향후 상위 충돌을 방지할 수 있습니다. 그러나 이로 인해 변환을 위한 상당한 보일러플레이트가 발생하고, 사용자들이 인스턴스를 수동으로 래핑하거나 제공된 From 구현에 의존해야 하므로, 구현 세부 사항이 노출되는 래퍼 타입으로 공개 API가 복잡해질 수 있습니다.
두 번째 해결책은 우리 크레이트 내에서 로컬로 정의된 확장 트레이트 GraphQLDisplay를 만들어 외부 Document 타입에 대해 직접 구현하는 것이었습니다.
이것은 트레이트 자체가 로컬이기 때문에 고아 규칙에 따라 합법적이며, 래퍼 타입의 수고를 피하면서 메서드 체이닝 구문을 가능하게 합니다. 주요 단점은 이 솔루션이 Rust의 표준 형식 매크로인 format! 또는 println!와 통합되지 않는다는 점입니다. 이러한 매크로는 특별히 Display 트레이트가 필요하기 때문에, 사용자는 우리의 사용자 지정 트레이트를 가져오고 특정 메서드를 호출해야 하므로, 표준 Rust 관례와 일관성이 없는 단절된 경험을 초래합니다.
결국 우리는 Document 타입에 대해 Newtype 패턴을 선택했습니다. 장기적인 안정성과 표준 라이브러리 통합이 단기적인 사용 편의성 비용보다 더 중요했기 때문입니다. DocWrapper를 사용함으로써 표준 형식화 도구를 사용하여 사용자 정의 매크로나 트레이트 가져오지 않고도 오류 로깅을 수행할 수 있었습니다. Schema 타입에 대해서는 단순히 Serialize를 파생했습니다. 두 타입과 파생 매크로 모두 로컬이었기 때문입니다. 그 결과는 컴파일 타임에 모든 트레이트 해석이 명확하고, 모호성 해소 오버헤드 때문에 컴파일 속도가 빠르며, graphql_parser가 자신의 Display 구현을 도입할 경우 다이아몬드 의존성 문제의 위험이 사라진 일관된 미래 지향적인 API였습니다.
고아 규칙이 Vec<T>와 같은 제네릭 타입으로 어떻게 확장되며, 외부 트레이트를 Vec<LocalType>에 대해 구현하는 것이 허용되는 반면 Vec<ForeignType>에 대해서는 금지되는 이유는 무엇인가요?
고아 규칙은 "로컬 타입 커버리지"라는 개념을 기반으로 제네릭 타입에 적용됩니다. 이는 제네릭 구조 내의 최소한 하나의 타입 매개변수가 현재 크레이트에 로컬이어야 한다는 것을 요구합니다. 따라서 impl ForeignTrait for Vec<LocalType>는 유효합니다. 왜냐하면 LocalType이 구현을 로컬 크레이트에 연결함으로써, 다른 크레이트가 해당 특정 구체적인 타입에 대해 충돌하는 구현을 작성할 수 없도록 보장하기 때문입니다. 반면에 impl ForeignTrait for Vec<ForeignType>는 규칙을 위반합니다. 이 경우 트레이트와 모든 타입 인자가 외부이기 때문에, ForeignType을 정의하는 크레이트가 나중에 Vec<ForeignType>에 대해 동일한 트레이트를 구현할 위험이 있습니다. 후보자들은 종종 이 커버리지가 중첩 제네릭에 재귀적으로 적용된다는 점을 놓치지만, 그 제네릭 컨테이너 자체가 로컬로 정의되지 않는 한 그 자체로 확장되지 않는다는 점도 간과합니다.
업스트림 크레이트에 의해 제공되는 블랭킷 구현(impl<T> Trait for T where T: ToString)이 후속 크레이트가 특정 타입에 대해 해당 트레이트를 구현하는 것을 방해하는 이유는 무엇인가요?
블랭킷 구현은 특정 트레이트 경계를 만족하는 모든 타입에 대해 기본 동작을 제공합니다. Rust의 일관성 규칙은 이미 존재하는 블랭킷 구현과 겹치는 구체적인 구현을 금지합니다. 만약 업스트림 크레이트가 impl<T> Serialize for T where T: ToString을 제공하면, 후속 크레이트는 ToString을 구현하는 모든 타입에 대해 Serialize를 구현할 수 없습니다. 로컬 타입이라 하더라도 마찬가지입니다. 이는 컴파일러가 블랭킷 구현과 구체적인 구현이 상호 배타적이라고 보장할 수 없기 때문입니다. 이는 고아 규칙과 다릅니다. 고아 규칙은 누가 구현을 작성할 수 있는지를 다루고, 겹침 규칙은 두 개의 유효한 구현이 동일한 네임스페이스 내에서 공존할 수 있는지를 다룹니다. 후보자들은 종종 이러한 개념을 혼동하여 고아 규칙에 따라 구문상 유효한 구체적인 구현을 작성하려고 하지만, 업스트림 블랭킷 구현과 겹침으로 인해 거절당합니다.
기본 트레이트인 Fn, FnMut, FnOnce가 고아 규칙과 관련하여 특별한 대우를 받는 이유는 무엇이며, 이는 클로저가 이러한 트레이트를 구현할 수 있도록 하는 이유는 무엇인가요?
Fn 계열의 트레이트는 "기본"으로 지정되어 있어, 이러한 트레이트의 구현이 트레이트의 제네릭 매개변수에 로컬 타입을 포함할 때 외부 타입에 대해 구현할 수 있도록 고아 규칙이 완화됩니다. 이 "역류" 규칙은 구현이 허용되는지를 판단할 때 일관성 측면에서 트레이트를 로컬로 간주하여 처리합니다. 예를 들어, 여러분의 크레이트 내에서 정의된 클로저는 고유하고 이름이 없는 타입이므로 귀하의 크레이트에 로컬입니다. 그리고 이 클로저에 대해 FnOnce를 구현하는 것은 허용됩니다, 비록 FnOnce는 표준 라이브러리에 정의되어 있고 클로저의 타입은 불투명하더라도 말입니다. 후보자들은 종종 이 메커니즘을 놓치기 때문에 Rust가 클로저를 처리하는 방식의 구현 세부 사항이지만, 이를 이해하면 클로저가 로컬 환경을 캡처하고 외부 트레이트를 구현할 수 있는 이유가 명확해지며, 이는 newtype 래퍼나 일관성 오류를 유발하지 않습니다.