Rust api 디자인

Rust api 디자인

날짜
생성자
ShalomShalom
카테고리
rust
작성일
2023년 05월 15일
태그
rust
이전 블로그

Designing interfaces

읽어볼 것들

Unsurprising

The principle of least surprise

인터페이스는 최대한 사용자가 예측 가능해야하고, 예상대로 동작해야한다.

Naming practices

이름에 iter가 들어간다면 &self, into_iter가 들어간다면 self 이름에 Error가 들어간다면 std::error::Error를 구현하고 Result에 사용된다.

Common traits for types

사용자는 모든 인터페이스가 잘 동작할 것이라고 기대한다. ex) 어떤 타입이든 {:?} 로 출력 컴파일러는 Coherence rule에 따라 사용자가 foreign traits의 구현을 추가하는 것을 허용하지 않는다.
  • Debug trait 거의 모든 대부분의 타입이 구현해야 한다. #[derive(Debug)]가 가장 많이 쓰이는 방법
  • Send and Sync trait 만약 타입이 Sync, Send 두가지 트레잇을 구현하지 않는다면, 반드시 합당한 이유가 있어야하며, 이것은 문서화 되어 있어야 한다. Send가 없다면 Mutex와 사용할 수 없고, Sync가 없다면 Arc나 static 타입으로 사용할 수 없다. thread pool에서 동작하는 async world에서 두 트레잇을 구현하지 않는다면 당신에게 큰 좌절감을 안겨줄 수 있다.
  • Clone and Default trait nearly universal trait 구현하기 쉽고, 없다면 사용자가 놀랄만한 trait 구현하지 않는다면 반드시 문서화한다.
  • Comparison traits (PartialEq, PartialOrd, Hash, Eq and Ord)
    • PartialEq 사용자가 갈망하는 trait, 타입을 비교(== or assert_eq!)할 때 필요하다.
    • PartialOrd and Hash 특수한 경우에 사용된다. ex) 타입이 hashmap과 같은 컬렉션의 key로 사용
    • Serialize and Deserialize serde는 외부 라이브러리 이지만 대부분의 라이브러리들이 serde를 구현하고 있으니 걱정말고 쓰자
  • Copy trait Copy는 보편적으로 구현되어 있을 것이라고 기대하는 trait이 아니다. 사용자는 보통 사본을 만들 때 clone을 호출한다.
"Copy changes the semantics of moving a value of the given type, which might surprise the user" Copy 타입의 제한 때문에 Copy 구현을 제외해야 하는 상황이 쉽게 벌어질 수 있다. ex) String을 멤버로 가져야 하는 경우 Copy를 사용할 수 없다. 기존 구현에서 Copy를 제외해야 하는 경우 발생

Ergonomic Trait Implementations

Rust는 자동으로 reference type의 traits을 구현해주지 않는다. Trait을 구현한(Bar: Trait) Bar의 레퍼런스인 &Bar는 fn foo<T: Trait>(t: T)에 사용될 수 없다. Trait은 &mut self, self를 인자로 갖는 메서드를 가질수도 있기 때문.
따라서 사용자는 일반적으로 새로운 trait을 정의할 때, trait의 &T 타입에 대해 where &T: Trait, &mut T where T: Trait, Box<T> where T: Trait 구현(blanket implementation)을 갖는 형태로 사용하는 것을 원한다. Trait이 가진 메소드의 형태에 따라서 이 중 일부의 구현만을 가질 수도 있다. 다수의 스탠다드 라이브러리가 이와 같은 형태로 구현되어있다.
  • blanket implementation:
impl<T> ToString for T where T: Display + ?Sized, { ... }
Iterator가 또다른 예이다. 순회하고 싶은 타입 &MyType과 &mut MyType 모두 IntoIterator을 구현한다면, 사용자는 원하는 형태로(&, &mut) 사용할 수 있을 것이다.

Wrapper Types

러스트는 상속을 지원하지 않지만, Deref trait과 AsRef가 상속과 비슷한 기능을 제공할 수 있다. 이 트레잇들은 T: Deref<Target = U>를 만족한다면, 타입T에 대해서 타입U의 메서드를 직접 호출해 사용할 수 있게 해준다. 이것은 사용자가 마법을 사용하는 것처럼 느끼게 만들어 준다.
Arc와 유사한 transparent Wrapper type을 만들고 싶다면, Deref trait을 구현해볼 좋은 기회다. 사용자는 inner type의 메서드를 . operator를 사용하여 호출할 수 있다. 만약 innter type을 접근하는 로직이 복잡하거나 느리지 않다면 AsRef trait을 함께 구현하는 것도 생각해보자. AsRef는 &WrapperType을 &InnerType처럼 사용할 수 있게 만들어준다. 그리고 대부분의 wrapper type은 사용자가 쉽게 wrapping을 추가하거나 제거하도록 From<InnerType>과 Into<InnerType>의 구현을 제공한다.
Borrow trait은 Deref와 AsRef trait과 유사해 보이나 특수한(좁은) 케이스에서 사용될 목적으로 만들어졌다.

Flexible

우리가 코드를 작성하는 것은 이를 사용하는 사용자와 명시적/묵시적인 계약을 맺게되는 것과 같다. 계약은 requirements와 promises로 이루어지는데, requirements는 코드를 사용할 때 필요한 제약 사항을 뜻하며, promises는 해당 코드가 사용될 때 보장하는 것을 뜻한다.
좋은 인터페이스를 만드는 방법은 불필요한 requirements를 만들지 않고, 지킬 수 있는 promises들만을 제공하는 것이다. requirements를 추가하거나 promises를 줄이는 것은 보통 major 버전 변경, 즉 하위 호환성을 깨뜨리는 변경이며, 제약을 줄이거나 추가적인 약속을 제공하는 것은 그 반대이다.
restriction: trait bound, argument types promises: trait implementation, return type
fn frobnicate1(s: String) -> String fn frobnicate2(s: &str) -> Cow<'_, str> fn frobnicate3(s: impl AsRef<str>) -> impl AsRef<str>
위 3개의 함수는 String을 인자로 받고, String을 리턴하지만 세부적으로 살펴보면 다른 계약 내용을 가지고 있다.
frobnicate1 -> String의 소유권을 전달 받고, 다시 리턴. 추후 하위 호환성을 유지하면서 소유권을 갖지 않는 형태로 변경할 수 없다. frobnicate2 -> 조금 완화된 계약, String의 레퍼런스를 전달, 리턴은 소유권을 가진 String 또는 러퍼런스가 될 수 있다(cow) frobnicate3 -> 훨씬 완화된 계약, String의 레퍼런스를 전달, 리턴도 레퍼런스
정답은 없다. 상황과 의도에 맞게 restriction과 promise를 설정해서 사용하는 것이 필요하다.

Generic Arguments

Interface의 requirement 중 하나는 type 함수가 구체적인 타입 Foo를 받는 것보다 제너릭을 사용할 수 있다면 쓰는 것이 좋다. &str -> impl
AsRef<str> 은 relaxing의 예이다. requirement를 relaxing할 수 있는 한가지 방법은 모든 인자를 제너릭으로 바꾸고, 컴파일러가 만들어내는 에러를 수정하면서 trait bound를 추가하는 것이다. 하지만 이것은 극단적일 수 있다. 더 좋은 방법은 사용자가 여러 타입으로 사용할만한 인자들을 제너릭으로 바꾸는 것이다. 만약 제너릭을 사용해 증가될 바이너리 사이즈가 걱정된다면 dynamic dispatch를 사용할 수 있다. impl AsRef<str> 대신 &dyn AsRef<str> 하지만 dynamic dispatch를 사용하기 전에 interface 사용자를 고려해야한다. 퍼포먼스에 민감한 인터페이스라면 dynamic dispatch는 부적합할 수 있다. 그리고 단순한 trait bound를 가진다면(T: AsRef<Str> or impl AsRef<str>) 문제가 되지 않지만, 복잡한 bound는 dynamic dispatch vtable을 생성하지 못한다. (&dyn Hash + Eq) 추가로 제너릭을 사용하더라도 사용자는 인자로 trait object를 넘겨 dynamic dispatch를 사용할 수 있으나, 반대는 불가능하다.
구체적 타입을 제너릭으로 바꾸고 싶은 욕망이 생길 것이다. 하지만 한가지 주의할 점은 제너릭으로 바꾸는 것이 항상 하위 호환성을 유지하는 것을 보장하지 않는다는 점이다. 예) fn foo(v: &Vec<usize>) -> foo(v: impl AsRef<[usize]>) &Vec<usize>가 AsRef<[usize]>를 구현하고 있지만 타입추론 문제가 발생한다. 사용자가 foo(&iter.collect())처럼 사용한다면, 변경 전에는 Vec<usize>로 타입을 추론할 수 있지만, AsRef<[usize]>은 타입을 결정할 수 없다.

Object Safety

Object safety는 public interface의 일부이다. 새로운 trait을 정의할 때 object safe한 trait으로 만든다면, 사용자는 'dyn Trait'을 사용하여 하나의 trait object로 여러 타입을 사용할 수 있으며, object safe하지 않다면, 'dyn Trait'을 사용하지 못하는 형태로 제공할 수 있다.
가능하다면 Object safe한 trait을 제공하는 것이 좋다.
만약 trait의 메서드에 generic을 꼭 사용해야 한다면, trait 자체에 제너릭을 사용하는 것을 고려하거나, where Self: Sized trait bound를 추가하는 것을 고려하라. 정답은 없다. 사용자가 어떤 형태로 많이 사용할 것인지를 고려해서 선택하라. 만약 사용자가 trait의 다양한 인스턴스들을 사용한다면 object safe한 trait으로 작성할 것을 고려하고, 그렇지 않다면 safe하지 않은 형태로 제공할 수 있다.
예) FromIterator는 메서드가 self를 인자로 갖지 않기 때문에 trait object를 생성할 수 없어 dynamic dispatch가 유용하지 않다. std::io::Seek은 trait object로는 seek만 가능하고, read, write가 불가능해서 유용하지 않다.

Borrowed vs. Owned

대부분의 function, trait, type을 정의할 때 데이터를 소유하거나 레퍼런스를 가질 것인지를 나타내야 한다. 'self'를 인자로 받거나 데이터를 다른 thread로 이동하는 메서드와 같이 데이터의 소유권을 가져야 한다면 caller가 데이터의 소유권을 제공하도록 만들어야한다.
당신의 코드가 데이터를 소유하지 않는다면, reference를 사용해야한다. i32, bool, f64와 같은 Copy trait을 구현하는 작은 크기의 타입은 예외다. 하지만 명심할 것은 모든 Copy type이 작은 크기를 갖는 것은 아니다. ( [u8; 8192] 또한 Copy 타입이다 )
하지만 현업에서 reference나 owned data 중 어떤 것이 필요한지 알 수 없는 경우도 있다. 이때는 Cow가 유용하게 사용될 수 있다.
그 외에도 reference의 lifetime을 인터페이스에 기술하는 것이 복잡하고 매우 고통스러울 수 있다. data의 크기가 작다면 clone을 활용해 데이터의 소유권을 갖는 것이 나을 수 있다.

Fallible and Blocking Destructors

I/O와 관련된 type은 drop되었을 때 cleanup을 수행해야 할 경우가 종종있다. (flushing writes to disk, close file...) 하지만 value가 drop되면, 사용자에게 에러를 전달할 수 있는 방법이 panic 뿐이다. Asynchronous code에서도 마찬가지로 pending job이 끝나지 않는 상태로 drop된다면 다음 작업을 수행할 수 없게 된다. 이럴때 explicit destructor를 제공하여 사용자가 cleanup 작업을 명시적으로 수행할 수 있다. (self의 소유권을 가지고, 에러를 반환하는 형태가 된다.)
explicit destructor의 trade-off
  • type이 Drop을 구현하는 경우, type의 어떤 필드도 destructor에서 move out 할 수 없다
  • drop은 '&mut self'를 인자로 갖기 때문에 drop의 구현은 explicit destructor를 직접 호출할 수 없다.
trade off를 우회하는 방법
  • Option을 사용하여 NewType wrapper으로 type을 감싼다. Option::take를 양쪽 destructor에서 모두 사용 가능, inner type이 존재하는 경우에만 explicit destructor를 호출가능, inner type은 drop을 구현하지 않기 때문에 모든 필드의 소유권을 가져올 수 있음 하지만 top-level의 모든 메서드는 Inner type에 접근하기 위해서 Option을 거쳐서 사용해야 하는 불편함이 있다. 이 방법의 단점은 모든 필드를 Option으로 사용하여 코드가 장황해질 수 있다.
  • 두번째 방법은 각 필드를 takeable하게 만드는 것이다. Option을 take하여 None으로 만드는 것 처럼, 다른 타입도 마찬가지 방법을 사용할 수 있다. Vec나 HashMap은 std::mem::take를 사용하여 교체할 수 있다. (타입이 empty 값을 지원하는 경우)
  • 세번째 방법은 데이터를 ManuallyDrop 타입으로 감싸는 것이다. ManuallyDrop은 inner type을 dereference하여 unwrap이 불필요하고, ManuallyDrop::take를 drop내에서 사용하여 소유권을 destruction time에 가져올 수 있다. 단점은 ManaullyDrop::take가 unsafe라는 것. 이미 take를 수행한 값을 다시 take했을 때 에러를 탐지해줄 수 있는 안전장치가 없다. take를 여러번 수행하는 것은 undefined behaviour로 주의가 필요하다.
코드가 간단하고, unsafe를 사용하는데 자신감 있다면 ManuallyDrop은 훌륭한 선택이 될 수 있다.

Obvious

일부 사용자는 당신이 만든 interface의 구현에 매우 익숙한 반면, 룰과 제약사항을 이해하지 못한 사용자도 있을 수 있다. 그들은 foo 다음에 bar를 호출하면 안된다는 사실을 모르거나, 달이 47도에 위치하고 아무도 18초간 재채기를 하지 않았을때에만 safe한 unsafe baz 메서드가 있다는 사실을 모를 수 있다.
사용자가 이해하기 쉽고, 잘못 사용하기 어려운 인터페이스를 만드는 것은 매우 중요하다. Documentation과 type signature 두가지를 통해 사용자에게 명확한 인터페이스를 제공할 수 있다.

Documentation

투명한 interface를 만드는 첫번째 단계는 좋은 documentation을 작성하는 것이다.
  • 예측할 수 없는 예외적인 사항을 문서화하라 Panic이 대표적인 예, 패닉이 발생할 수 있는 상황을 문서화하라 Error도 마찬가지. unsafe 함수라면 어떤 조건에서 safe한지 기술하라
  • crate와 module level에서 end-to-end usage examples 를 제공하라 어떤 type이나 메서드의 예를 제공하는 것보다, module 또는 crate가 제공하는 타입/메서드들이 어떻게 함께 맞물려 돌아가는지를 사용자가 알 수 있게 하는 것이 중요하다.
  • Documentation을 조직하라 모든 type, trait, function들을 하나의 top-level 모듈에 위치시키면 사용자가 어디서부터 파악해야할지 알기 어렵다. 관련있는 항목들끼리 모듈 그룹으로 묶고, 관려 있는 각 아이템은 링크를 추가하자
    • interface 중에서 public으로 사용하길 의도하지 않았지만, legacy와 호환을 위해 남겨둔 public interface는 #[doc(hidden)]으로 문서에 노출하지 않을 수 있다.
  • 가능하다면 양질의 Documentation을 제공하라 concepts, data structures, algorithms 또는 다른 측면 등을 설명하기 위해 외부 리소스(RFCs, blog posts, and white papers)의 링크를 추가 configuration을 위해 #[doc(cfg(...))], 검색을 쉽게하기 위해 #[doc(alias = "...")]

Type System Guidance

Type System은 interface를 obvious, self-documenting and misuse-resistant 하게 만든다.
  • Semantic typing: Type 이름이 값에 의미를 부여
  • Zero sized type technique
    • struct Grounded; struct Launched; struct Rocket<Stage = Grounded> { stage: std::marker::PhantomData<Stage>, } impl Default for Rocket<Grounded> {} impl Rocket<Grounded> { pub fn launch(self) -> Rocket<Launched> {} } impl Rocket<Launched> { pub fn accelerate(&mut self) {} pub fn decelerate(&mut self) {} } impl<Stage> Rocket<Stage> { pub fn color(&self) -> Color {} pub fn weight(&self) --> Kilograms {} } fn main() { let rocket = Rocket::default(); // Rocket<Grounded> rocket.accelerate(); // error! let rocket = rocket.launch(); // Rocket<Launched> rocket.accelerate(); }
      각 stage를 나타내기 위해 unit type Grounded, Launched를 정의 stage는 메타정보만 제공하고 컴파일타임에 제거하기 위해 PhantomData를 사용 Rocket은 Grounded stage로 생성 -> launch -> accelerate/decelerate color(), weight()는 모든 stage에서 사용 가능
      사용자가 메서드를 잘못된 stage에서 호출할 가능성을 차단해버렸다.
      이 개념은 다양하게 확장가능 예) pointer와 bool 두개의 인자를 받는 함수가 bool 값이 true인 경우에만 pointer를 처리하는 경우 -> enum을 사용하여 두개의 인자를 하나로 만든다.
  • #[must_use] annotation: 작지만 강력한 기능 type, trait, funtions에 사용할 수 있으며, 사용자가 이를 처리하지 않으면 컴파일러 워닝을 발생 예) Result 남용하지 않도록 주의, 사용자가 실수하기 쉬운 곳에 사용할 것

Constrained

하위호환성을 깨뜨리는 Interface 변경이 필요한 경우가 종종 발생하는데, 잦은 변경은 사용자를 분노하게 만들 수 있다. 어떻게 하면 좋을까?

Type Modifications

public type을 삭제하거나 변경하게 되면 거의 사용자의 코드를 망가뜨릴 확률이 높다. 이런 문제를 예방하기 위해 visibility modifier(pub(crate), pub(in path)를 최대한 사용하자. public type을 적게 만들 수록 추후에 발생할 breaking change를 줄일 수 있기 때문이다.
Depends on your types
// in your interface pub struct Uint; // in user code let u = lib::Uint
// in your interface pub struct Uint { pub field: bool }; // in user code fn is_true(u: lib::Uint) -> bool { matches!(u, Uint { field: true }) }
두가지 예시에서 Uint 타입에 private field를 추가한다면 기존 코드를 깨뜨리게 된다.
또 다른 예로, tuple struct를 이름을 갖는 필드를 추가한 일반 struct로 변경하는 경우가 있다.
exhaustive: (하나도 빠뜨리는 것 없이) 철저한[완전한]
#[non_exhaustive] attribute를 사용하자. 이것은 컴파일러가 implicit constructors( lib::Uint { field1: ture } )와 non-exhaustive pattern을 사용하지 못하게 만들고, 따라서 미래에 타입 변경으로 코드가 깨질 수 있는 문제를 사전에 방지할 수 있다.
#[non_exhaustive] pub struct Config { pub window_width: u16, pub window_height: u16, } #[non_exhaustive] pub enum Error { Message(String), Other, } pub enum Message { #[non_exhaustive] Send { from: u32, to: u32, contents: String }, #[non_exhaustive] Reaction(u32), #[non_exhaustive] Quit, } // Non-exhaustive structs can be constructed as normal within the defining crate. let config = Config { window_width: 640, window_height: 480 }; // Non-exhaustive structs can be matched on exhaustively within the defining crate. if let Config { window_width, window_height } = config { // ... } let error = Error::Other; let message = Message::Reaction(3); // Non-exhaustive enums can be matched on exhaustively within the defining crate. match error { Error::Message(ref s) => { }, Error::Other => { }, } match message { // Non-exhaustive variants can be matched on exhaustively within the defining crate. Message::Send { from, to, contents } => { }, Message::Reaction(id) => { }, Message::Quit => { }, }
Within the defining crate, non_exhaustive has no effect.
// `Config`, `Error`, and `Message` are types defined in an upstream crate that have been // annotated as `#[non_exhaustive]`. use upstream::{Config, Error, Message}; // Cannot construct an instance of `Config`, if new fields were added in // a new version of `upstream` then this would fail to compile, so it is // disallowed. let config = Config { window_width: 640, window_height: 480 }; // Can construct an instance of `Error`, new variants being introduced would // not result in this failing to compile. let error = Error::Message("foo".to_string()); // Cannot construct an instance of `Message::Send` or `Message::Reaction`, // if new fields were added in a new version of `upstream` then this would // fail to compile, so it is disallowed. let message = Message::Send { from: 0, to: 1, contents: "foo".to_string(), }; let message = Message::Reaction(0); // Cannot construct an instance of `Message::Quit`, if this were converted to // a tuple-variant `upstream` then this would fail to compile. let message = Message::Quit;
There are limitations when matching on non-exhaustive types outside of the defining crate:
// `Config`, `Error`, and `Message` are types defined in an upstream crate that have been // annotated as `#[non_exhaustive]`. use upstream::{Config, Error, Message}; // Cannot match on a non-exhaustive enum without including a wildcard arm. match error { Error::Message(ref s) => { }, Error::Other => { }, _ => {}, // would compile with: `_ => {},` } // Cannot match on a non-exhaustive struct without a wildcard. if let Ok(Config { window_width, window_height }) = config { // would compile with: `..` } match message { // Cannot match on a non-exhaustive struct enum variant without including a wildcard. Message::Send { from, to, contents } => { }, // Cannot match on a non-exhaustive tuple or unit enum variant. Message::Reaction(type) => { }, Message::Quit => { }, }
Non-exhaustive types are always considered inhabited in downstream crates.

Trait Implementation

In chapter 3, Rust's coherence rules disallow multiple implementations of a given trait for a given type
breaking changes
  • (generally) trait의 blanket 구현을 추가 (downstream code에서 구현이 추가되었을 수 있다.)
  • (must) existing type 에 foreign trait의 구현 추가
  • (must) foreign type 에 existing trait의 구현 추가
    • 위 두가지 케이스에서 breaking change인 이유는 foreign trait/type 의 구현이 충돌할 수 있기 때문이다.
  • trait 구현을 삭제
    • 새로운 타입에 대한 trait 구현을 추가하는 것은 문제가 되지 않는다. 구현이 충돌할 가능성이 없기 때문이다.
  • existing type 에 어떤 trait의 구현을 추가할때는 주의가 필요하다.
    • // crate1 1.0 pub struct Unit; pub trait Foo1 { fn foo(&self) } // note that Foo1 is not implemented for Unit // crate2; depends on crate1 1.0 use crate1::{Unit, Foo1}; trait Foo2 { fn foo(&self) } impl Foo2 for Unit { .. } fn main() { Unit.foo(); }
      Unit struct에 Foo1 trait의 구현을 추가하면 main()에서는 foo() 호출이 Foo1, Foo2 중 어느 trait의 구현을 가리키는지 알 수 없게된다.(breaking change) 이 조건은 기존 트레잇의 구현을 추가하는 것 뿐만 아니라 새로운 트레잇을 추가하는 경우에도 마찬가지로 적용된다. prelude module로 사용자가 wildcard import(*)를 사용하도록 interface를 제공하는 경우 특히 주의하자.
  • (most) existing trait을 변경
    • method signature를 변경
    • 새로운 method 추가(default 구현을 가진 method를 추가하는 것은 문제가 되지 않는다)
sealed traits breaking change를 피할 수 있는 도구(방법) sealed trait은 사용자가 사용만 가능하고 구현을 하지 못하는 trait의 형태를 갖는다. derived traits (blanket 구현을 제공하는 trait)에서 종종 사용된다.
pub trait CanUseCannotImplement: sealed::Sealed { .. } mod sealed { pub trait Sealed {} impl<T> Sealed for T where T: TraitBounds {} // explicitly allow } impl<T> CanUseCannotImplement for T where T: TraitBounds {}
private empty trait을 supertrait으로 정의하여 외부의 다른 crate에서 해당 trait을 구현하지 못하게 만든다. explicitly allowed types(T: TraitBounds)에 대해서만 CanUseCannotImplement trait의 구현이 가능하다. Sealed trait을 사용한다면 사용자가 구현을 시도하느라 시간을 낭비하지 않도록 반드시 문서화해준다.

Hidden Contracts

어떤 경우에는 내가 수정한 코드가 Interface의 다른 어딘가의 계약에 영향을 미치는 경우가 있다. ex) re-exports, auto-traits
Re-Exports
Auto traits Send, Sync, Unpin, Sized, UnwindSafe Auto traits은 숨겨진 약속(promise)를 인터페이스에 추가(제공)한다.

댓글

guest