Skip to content

trait와 generic은 처음 보면 "타입을 일반화하는 문법"처럼 보인다. 하지만 실전에서 더 중요한 건 어떤 기능을 요구하는지, 어떤 capability를 계약으로 고정하는지다.

문제 제기

같은 함수를 여러 타입에 적용하고 싶을 때 Python은 duck typing, Go는 interface로 접근한다. Rust는 여기에 더해 "이 타입이 정확히 어떤 기능을 제공해야 하는가"를 trait bound로 훨씬 명시적으로 표현한다.

왜 필요한가

generic 자체보다 중요한 것은 bound다. T가 있다는 사실보다 T: Summary + Debug가 어떤 capability를 요구하는지가 API 의미를 만든다.

Python · Go · Rust 비교

py
class Summary(Protocol):
    def summary(self) -> str: ...
go
type Summary interface {
	Summary() string
}
rs
pub trait Summary {
    fn summary(&self) -> String;
}

Rust의 trait는 Go interface처럼 capability를 묘사하면서도, generic bound와 조합되어 호출 시점 계약을 더 구체적으로 드러낸다.

Trait를 contract로 읽기

Custom trait

도메인 의미를 가진 capability는 직접 trait로 이름 붙일 수 있다.

rs
pub trait Summary {
    fn summary(&self) -> String;
}
rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
    pub title: String,
    pub body: String,
}

impl Summary for Note {
    fn summary(&self) -> String {
        format!("{}: {}", self.title, self.body)
    }
}

Trait bound

generic 함수는 "아무 타입이나 받는다"가 아니라 "이 기능들을 제공하는 타입만 받는다"에 가깝다.

rs
pub fn render_summary<T>(value: &T) -> String
where
    T: Summary + Debug,
{
    format!("{value:?} => {}", value.summary())
}

Ergonomic standard traits

AsRef<str>는 소유권을 빼앗지 않고 문자열 같은 입력을 유연하게 받게 해준다. Display는 사용자 친화적 출력 계약을 만든다.

rs
pub fn parse_label<T>(input: T) -> String
where
    T: AsRef<str>,
{
    input.as_ref().trim().to_lowercase()
}
rs
pub fn display_badge<T>(value: T) -> String
where
    T: Display,
{
    format!("[{value}]")
}

Error as contract

Display는 사람에게 보여줄 메시지, Error::source()는 cause chain, From은 lower-level failure를 higher-level contract로 올리는 bridge다. 이 세 가지를 같이 보면 custom error가 단순한 enum이 아니라 API surface라는 점이 보인다.

rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CatalogError {
    Parse {
        input: String,
        source: NoteParseError,
    },
    NoteNotFound {
        title: String,
    },
}

impl CatalogError {
    pub fn parse(input: impl Into<String>, source: NoteParseError) -> Self {
        Self::Parse {
            input: input.into(),
            source,
        }
    }
}

impl From<(String, NoteParseError)> for CatalogError {
    fn from((input, source): (String, NoteParseError)) -> Self {
        Self::Parse { input, source }
    }
}

impl Display for CatalogError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Parse { input, source } => {
                write!(f, "parse error in `{input}`: {source}")
            }
            Self::NoteNotFound { title } => write!(f, "note not found: {title}"),
        }
    }
}

impl Error for CatalogError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Parse { source, .. } => Some(source),
            Self::NoteNotFound { .. } => None,
        }
    }
}

Runnable example

rs
fn main() {
    let note = Note {
        title: "Trait".to_string(),
        body: "describes capabilities".to_string(),
    };

    let rendered = render_summary(&note);
    let label = parse_label("  Deep Dive  ");
    let badge = display_badge(label);

    println!("{rendered} / {badge}");
}

이 예제는 Summary, Debug, AsRef<str>, Display가 각각 어떤 역할을 맡는지 한 흐름으로 보여 준다.

Compiler clinic

trait와 generic의 에러는 대개 "타입이 틀렸다"보다 "이 함수가 요구한 capability를 그 타입이 제공하지 않는다"는 뜻이다. 다음 배치의 error handling 챕터에서는 Result와 custom error를 trait 계약 관점에서 더 깊게 다룬다.

흔한 오해

generic을 쓰면 더 추상적이고 어려워진다고 느끼기 쉽다. 하지만 trait bound를 잘 붙이면 오히려 어떤 기능이 필요한지 더 명확해진다.

언제 쓰는가 / 피해야 하는가

  • custom trait: 도메인 capability를 이름 붙이고 싶을 때
  • Debug: 내부 상태를 개발자 관점으로 보여주고 싶을 때
  • Display: 사용자 친화적 문자열 표현이 필요할 때
  • AsRef: owned/borrowed 입력을 유연하게 받고 싶을 때
  • Error: 실패를 메시지와 cause chain으로 나누어 보고 싶을 때
  • 아직 도메인 의미가 없는데 generic부터 도입하는 습관은 피하는 편이 낫다

실무 판단 기준

  • concrete type 하나로 충분한 단계라면 generic을 서두르지 않는다. 추상화는 실제 variation이 드러날 때 올리는 편이 낫다.
  • trait bound는 최대한 capability 중심으로 좁게 잡고, 구현 세부사항을 새는 bound는 경계한다.
  • public API에서는 caller ergonomics와 semver 비용을 같이 본다. bound 하나 추가도 공개 계약 변경일 수 있다.
  • Display, Error, From 구현은 편의 기능이 아니라 디버깅과 에러 전파 경험을 결정하는 계약이라는 점을 잊지 않는다.
  • Error::source()가 비어 있으면 실패가 잘린다. 어디까지 caller에게 남길지 의도적으로 결정해야 한다.

Takeaway

  • trait는 inheritance 대체재보다 capability contract에 가깝다.
  • generic 함수의 핵심은 T가 아니라 bound다.
  • Option, Result, custom error도 결국 어떤 계약을 표면에 드러낼지의 문제다.
  • 그 연결은 Option, Result, and Question Mark에서 바로 이어진다.

Rust를 오래 기억하기 위한 why-first handbook