Pilot chapter
Trait와 Generic: capability contract로 읽기
trait, impl, generic bound를 재사용 문법이 아니라 API 계약으로 이해하도록 돕는다.
- ownership
- borrowing
- 기본 함수 시그니처 읽기
trait와 generic은 "코드를 재사용하는 문법"이 아니라 capability를 계약으로 고정하는 도구다. 이 파트에서는 어떤 trait가 Rust 생태계의 기본 어휘인지와, Result/?가 제어 흐름을 어떻게 단순화하는지 확장할 예정이다.
Debug, Display, From, AsRef는 어떤 API ergonomics를 만들어 주는가Option, Result, ?는 어떤 실패와 부재를 표현하는가Option, Result, ?, custom error를 섞을 때 호출자 계약이 어떻게 달라지는지 설계할 수 있다.AsRef, Into, From, Display, Error 중 어떤 trait가 호출자 경험을 가장 정직하게 만드는가Pilot chapter
trait, impl, generic bound를 재사용 문법이 아니라 API 계약으로 이해하도록 돕는다.
Planned
파일럿 템플릿이 안정화되면 이 주제로 확장한다.
PlannedPlanned
파일럿 템플릿이 안정화되면 이 주제로 확장한다.
PlannedPlanned
파일럿 템플릿이 안정화되면 이 주제로 확장한다.
PlannedPlanned
파일럿 템플릿이 안정화되면 이 주제로 확장한다.
PlannedPlanned
파일럿 템플릿이 안정화되면 이 주제로 확장한다.
Plannedtrait가 capability의 계약이라면, Option과 Result는 제어 흐름의 계약이다. Rust는 "없음"과 "실패"를 같은 값으로 뭉개지 않고 타입으로 분리한다. 그래서 호출자는 None과 Err를 보고 다른 대응을 할 수 있고, 구현자는 ?를 써서 실패를 위로 자연스럽게 전달할 수 있다.
실무에서 "값이 없을 수 있음"과 "복구 가능한 실패"는 전혀 다른 의미다. Python에서는 None과 exception이 섞여 보일 수 있고, Go에서는 value, ok와 value, err 패턴이 같은 함수 안에서 공존한다. Rust는 여기에 이름을 붙여 Option과 Result로 분리한다.
Option은 "없음"을, Result는 "실패"를 표현한다. ?는 이 둘을 호출자에게 넘기는 문법이 아니라, 함수의 에러 계약을 끝까지 지켜주는 전달 장치다.
class NoteParseError(Exception):
pass
class CatalogError(Exception):
pass
def find_note(notes: list[dict[str, str]], title: str) -> dict[str, str] | None:
return next((note for note in notes if note["title"] == title), None)
def parse_note_line(line: str) -> dict[str, str]:
if "|" not in line:
raise NoteParseError("missing separator")
title, body = [part.strip() for part in line.split("|", 1)]
if not title:
raise NoteParseError("empty title")
if not body:
raise NoteParseError("empty body")
return {"title": title, "body": body}
def preview_note_line(notes: list[dict[str, str]], line: str) -> str:
try:
parsed = parse_note_line(line)
except NoteParseError as exc:
raise CatalogError(f"parse error in `{line}`") from exc
note = find_note(notes, parsed["title"])
if note is None:
raise CatalogError(f"note not found: {parsed['title']}")
return f"{note!r}"type Note struct {
Title string
Body string
}
type NoteParseError struct {
Reason string
}
func (e NoteParseError) Error() string {
return e.Reason
}
type CatalogError struct {
Reason string
Err error
}
func (e CatalogError) Error() string {
return e.Reason
}
func (e CatalogError) Unwrap() error {
return e.Err
}
func FindNote(notes []Note, title string) (Note, bool) {
for _, note := range notes {
if note.Title == title {
return note, true
}
}
return Note{}, false
}
func ParseNoteLine(line string) (Note, error) {
if !strings.Contains(line, "|") {
return Note{}, NoteParseError{Reason: "missing separator"}
}
parts := strings.SplitN(line, "|", 2)
title := strings.TrimSpace(parts[0])
body := strings.TrimSpace(parts[1])
if title == "" {
return Note{}, NoteParseError{Reason: "empty title"}
}
if body == "" {
return Note{}, NoteParseError{Reason: "empty body"}
}
return Note{Title: title, Body: body}, nil
}
func PreviewNoteLine(notes []Note, line string) (string, error) {
parsed, err := ParseNoteLine(line)
if err != nil {
return "", CatalogError{
Reason: fmt.Sprintf("parse error in `%s`", line),
Err: err,
}
}
note, ok := FindNote(notes, parsed.Title)
if !ok {
return "", CatalogError{
Reason: fmt.Sprintf("note not found: %s", parsed.Title),
}
}
return fmt.Sprintf("%+v", note), nil
}pub fn find_note<'a>(notes: &'a [Note], title: &str) -> Option<&'a Note> {
notes.iter().find(|note| note.title == title)
}Python은 None과 exception으로, Go는 ok와 error로 같은 문제를 다룬다. Rust는 Option과 Result로 그 차이를 타입으로 분명히 남긴다.
먼저 absence와 parse failure를 분리하는 핵심 함수들을 본다.
pub fn find_note<'a>(notes: &'a [Note], title: &str) -> Option<&'a Note> {
notes.iter().find(|note| note.title == title)
}pub fn parse_note_line(line: &str) -> Result<Note, NoteParseError> {
let (title, body) = line
.split_once('|')
.ok_or(NoteParseError::MissingSeparator)?;
let title = title.trim();
if title.is_empty() {
return Err(NoteParseError::EmptyTitle);
}
let body = body.trim();
if body.is_empty() {
return Err(NoteParseError::EmptyBody);
}
Ok(Note {
title: title.to_string(),
body: body.to_string(),
})
}둘을 합쳐서 ?로 위임하는 상위 함수는 에러 계약을 더 분명하게 만든다.
pub fn preview_note_line(notes: &[Note], line: &str) -> Result<String, CatalogError> {
let parsed = parse_catalog_note(line)?;
let note = find_note(notes, &parsed.title).ok_or_else(|| CatalogError::NoteNotFound {
title: parsed.title.clone(),
})?;
Ok(render_summary(note))
}문서 전체에서 실제로 실행할 수 있는 예제는 다음 binary다.
fn main() {
let notes = vec![
Note {
title: "Trait".to_string(),
body: "contracts over inheritance".to_string(),
},
Note {
title: "Result".to_string(),
body: "recoverable failures".to_string(),
},
];
let parsed = parse_note_line("Result | recoverable failures").expect("valid input");
let parsed_with_context =
parse_catalog_note("Trait | contract driven design").expect("valid catalog input");
let found = find_note(¬es, &parsed.title).expect("note must exist");
let preview = preview_note_line(¬es, "Trait | contracts over inheritance")
.expect("preview should succeed");
let layered_error = parse_catalog_note("broken input").expect_err("invalid input");
println!("{parsed:?} / {parsed_with_context:?} / {found:?} / {preview}");
if let Some(source) = layered_error.source() {
println!("cause: {source}");
}
}NoteParseError는 "입력 문자열이 왜 깨졌는가"를 설명하는 low-level error다. CatalogError는 여기에 도메인 경계를 더한 error다. 둘을 분리하면 parser는 재사용하기 쉬워지고, caller는 도메인 수준에서 실패를 다룰 수 있다.
핵심은 두 가지다.
Display는 호출자에게 보여 줄 문장이다.source()는 디버깅과 logging을 위한 cause chain이다.이 구조는 ?와 map_err를 같이 쓰면 가장 자연스럽다. ?는 이미 있는 error type을 위로 전달하고, map_err는 context가 필요한 boundary에서 new shape를 만든다.
#[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,
}
}
}pub fn parse_catalog_note(line: &str) -> Result<Note, CatalogError> {
parse_note_line(line).map_err(|error| (line.to_string(), error).into())
}pub fn preview_note_line(notes: &[Note], line: &str) -> Result<String, CatalogError> {
let parsed = parse_catalog_note(line)?;
let note = find_note(notes, &parsed.title).ok_or_else(|| CatalogError::NoteNotFound {
title: parsed.title.clone(),
})?;
Ok(render_summary(note))
}From은 단순한 편의 문법이 아니라, "이 lower-level failure를 이 higher-level failure로 올려도 된다"는 계약이다. 이 책의 예제에서는 tuple conversion을 써서, input context와 parser failure를 함께 domain error로 올린다.
pub fn parse_catalog_note(line: &str) -> Result<Note, CatalogError> {
parse_note_line(line).map_err(|error| (line.to_string(), error).into())
}이 패턴을 남발하면 error type이 과도하게 커진다. 하지만 boundary에서 context가 진짜 필요할 때는, 이 정도의 명시성이 오히려 reviewer에게 더 정직하다.
?는 아무 Result에나 붙는 만능 문법이 아니다. 바깥 함수의 반환 타입이 Result 여야 하고, 안쪽 error를 바깥 error로 바꿀 수 있어야 한다. 이 연결은 보통 From을 통해 이뤄진다.
흔한 오해
Option과 Result를 둘 다 unwrap으로 풀어버리면 타입이 의도한 정보를 다시 잃는다. 그런 경우에는 Rust를 쓴 보람이 사라진다.
Option: 값이 없을 수 있는 상황이 자연스러울 때Result: 실패 이유를 호출자에게 보여줘야 할 때?: 에러 전달이 함수의 기본 제어 흐름일 때panic/unwrap: 불가능한 상태를 즉시 깨뜨리는 목적이 아닐 때는 피하는 편이 낫다map_err: context를 붙일 수 있는 boundary에서만 쓰고, 내부 로직 전반에 뿌리지는 않는다Option은 부재, Result는 실패다.?는 에러를 숨기는 게 아니라 계약을 위로 전달하는 문법이다.From, Display, Error는 여기서 실제 에러 계약으로 이어진다.