Option 과 Result - 없음과 실패를 타입으로
이 장에서 배우는 것
앞 장에서 구조체와 열거형으로 데이터의 모양을 정하고 match 로 모든 경우를 나누어 다루는 법을 배웠다. 이 장은 그 열거형 중 표준 라이브러리가 제공하는 두 가지, 곧 값이 없을 수 있는 경우를 나타내는 Option 과 실패할 수 있는 경우를 나타내는 Result 를 다룬다. 다른 언어에서 null, 예외, 오류 코드로 처리하던 일을 Rust 는 타입 하나로 표현한다. 그래서 "빠뜨린 처리"가 실행 중이 아니라 컴파일 중에 드러난다.
예제는 가계부 도구의 입력 해석 부분이다. 텍스트 한 줄을 기록 한 건으로 바꾸고, 잘못된 줄은 이유와 함께 알려 주는 코드를 만든다.
Option<T>로 "값이 없을 수 있음"을 표현하고,map,and_then,filter,unwrap_or_else,ok_or같은 조합기(combinator)로 꺼내지 않고 가공한다.Result<T, E>와?연산자로 실패를 호출한 쪽에 넘긴다.unwrap과expect를 써도 되는 곳과 피해야 하는 곳을 구분한다.- 사용자 정의 오류 enum 을 만들고,
Display와From을 붙여?와 연결한다.
문제 상황
가계부 도구가 텍스트 파일에서 기록을 읽는다고 하자. 한 줄이 한 건이고, 형식은 "종류 분류 금액"이다.
수입 월급 3000000
지출 식비 12000
지출 취미
지출 식비 만원
저축 적금 100000
지출 식비 -300
사람이 손으로 쓴 파일에는 이런 줄이 섞이기 마련이다. 세 번째 줄은 금액이 빠졌고, 네 번째 줄은 금액이 숫자가 아니며, 다섯 번째 줄은 도구가 모르는 종류이고, 마지막 줄은 금액이 음수다. 줄마다 실패하는 방식이 다르다.
다른 언어에서는 이런 상황을 보통 세 가지 방식으로 처리한다. 값이 없으면 null 을 돌려주거나, 예외를 던지거나, -1 같은 특별한 값을 오류 표시로 쓴다. 세 방식 모두 공통된 약점이 있다. 함수 시그니처만 봐서는 실패할 수 있는지 알 수 없고, 처리를 빠뜨려도 컴파일러가 말해 주지 않는다. 빠뜨린 처리는 어느 날 실제 입력이 들어왔을 때 프로그램이 멈추는 방식으로 드러난다.
Rust 는 "없음"과 "실패"를 반환 타입에 적어 넣는다. 반환 타입이 Option<i64> 이면 호출자는 값이 없는 경우를 반드시 마주하고, Result<Entry, LedgerError> 이면 실패 이유까지 함께 받는다. 처리하지 않고는 안쪽 값에 손댈 수 없으므로 컴파일러가 처리를 강제한다.
Option: 없을 수 있는 값을 다루기
Option<T> 는 표준 라이브러리에 정의된 열거형으로, 갈래가 둘이다. 값이 있으면 Some(값), 없으면 None 이다. 앞 장의 열거형과 다를 바 없으므로 match 로 그대로 다룰 수 있다. 이름을 따로 가져오지 않아도 Some 과 None 은 바로 쓸 수 있다.
분류별 예산을 찾는 함수를 보자. 예산이 등록되지 않은 분류도 있으므로 반환 타입은 Option<i64> 가 된다.
fn find_budget(budgets: &[(&str, i64)], category: &str) -> Option<i64> {
for (name, limit) in budgets {
if *name == category {
return Some(*limit);
}
}
None
}
이 함수의 반환값은 i64 가 아니므로 바로 계산에 쓸 수 없다. 값이 있는 경우와 없는 경우를 모두 처리해야만 안쪽 숫자에 닿는다. null 을 돌려받고 검사를 잊는 실수가 타입 단계에서 막힌다.
match 대신 조합기를 쓰는 이유
모든 Option 을 match 로 풀면 코드가 길어진다. "값이 있으면 이렇게 바꾸고, 없으면 그대로 없음"이라는 흐름이 워낙 흔해서, 표준 라이브러리는 이를 한 줄로 쓰는 메서드를 제공한다. 이런 메서드를 이어 붙여 쓰는 방식을 조합기라고 부른다. 자주 쓰는 것만 표로 모았다.
| 메서드 | 하는 일 | Some 일 때 | None 일 때 |
|---|---|---|---|
map(f) | 안쪽 값을 바꾼다 | Some(f(값)) | None |
and_then(f) | Option 을 돌려주는 f 를 잇는다 | f(값) 의 결과 | None |
filter(f) | 조건을 통과한 값만 남긴다 | 조건이 참이면 Some, 거짓이면 None | None |
unwrap_or_else(f) | 없을 때 기본값을 만든다 | 안쪽 값 | f() 의 결과 |
ok_or(e) | Result 로 바꾼다 | Ok(값) | Err(e) |
map 과 and_then 의 차이가 처음에는 헷갈린다. 넘겨 주는 클로저(closure, 이름 없는 작은 함수)가 그냥 값을 돌려주면 map, 다시 Option 을 돌려주면 and_then 이다. and_then 이 없으면 Option<Option<i64>> 처럼 겹겹이 쌓인 타입이 나온다.
let limit = find_budget(&budgets, "식비"); // Some(40000)
let doubled = limit.map(|v| v * 2); // Some(80000)
let large = limit.filter(|v| *v > 50000); // None
사용률을 구하는 함수에서는 and_then 이 자연스럽다. 예산이 없거나 예산이 0이면 비율을 구할 수 없으므로 두 경우 모두 None 이 된다.
fn percent_used(budgets: &[(&str, i64)], category: &str, spent: i64) -> Option<i64> {
find_budget(budgets, category).and_then(|limit| {
if limit == 0 { None } else { Some(spent * 100 / limit) }
})
}
기본값을 줄 때는 unwrap_or 대신 unwrap_or_else 를 쓰는 습관이 좋다. 앞의 것은 인자를 항상 미리 계산하고, 뒤의 것은 None 일 때만 클로저를 실행한다. 문자열을 새로 만드는 기본값이라면 이 차이가 실제 비용이 된다.
Option 에도 ? 를 쓸 수 있다
함수의 반환 타입이 Option 이면 ? 연산자를 쓸 수 있다. Some 이면 안쪽 값을 꺼내 계속 진행하고, None 이면 그 자리에서 함수를 끝내며 None 을 돌려준다. 이 장의 완성 코드에서는 잔액을 더하다가 정수가 넘치는 경우를 이렇게 처리한다. checked_add 는 넘치면 None 을 돌려주는 덧셈이다.
Result 와 ? 연산자: 실패를 이유와 함께 넘기기
Result<T, E> 도 갈래가 둘인 열거형이다. 성공이면 Ok(값), 실패이면 Err(이유) 를 담는다. Option 이 "왜 없는지"를 말하지 않는 데 비해, Result 는 실패한 이유를 타입 E 로 함께 실어 나른다. 문자열을 정수로 바꾸는 parse 가 대표적이다. "12000" 은 Ok(12000), "만원" 은 Err(ParseIntError) 를 돌려준다.
또 하나 알아 둘 점이 있다. Result 는 "사용하지 않으면 경고"가 붙는 타입이다. 돌려받은 Result 를 변수에 담지도 검사하지도 않으면 컴파일러가 경고한다. 실패를 조용히 흘려보내는 실수를 도구가 잡아 준다.
? 는 "실패면 여기서 돌려보낸다"
단계마다 실패할 수 있는 코드를 match 로 풀면 들여쓰기가 깊어진다. ? 는 이 패턴을 한 글자로 줄인다. Result 뒤에 ? 를 붙이면 Ok 일 때는 안쪽 값을 꺼내 식의 값으로 쓰고, Err 일 때는 그 오류를 함수의 반환값으로 삼아 즉시 함수를 끝낸다. 따라서 ? 는 함수의 반환 타입이 Result (또는 Option) 일 때만 쓸 수 있다.
그림은 이 장의 parse_entry 가 한 줄을 읽는 흐름이다. 위에서 아래로 검사를 통과하면 Ok(Entry) 에 도달하고, 어느 단계든 실패하면 오른쪽으로 빠져나가 Err 가 된다. 각 단계가 자기 실패에 해당하는 오류 종류를 하나씩 갖는다는 점에 주목하자.
한 가지 더, parts.next() 는 Option 을 돌려주는데 parse_entry 는 Result 를 돌려준다. 둘을 잇는 것이 ok_or 다. "없음"에 이유를 붙여 Err 로 바꾼 뒤 ? 를 붙인다.
let category = parts.next().ok_or(LedgerError::MissingField("분류"))?;
이 한 줄은 "다음 조각이 있으면 category 에 담고, 없으면 MissingField 오류로 함수를 끝낸다"라고 읽는다.
unwrap 을 피할 곳과 오류 enum 설계
unwrap 과 expect 는 실패를 패닉으로 바꾼다
unwrap 은 Some/Ok 이면 안쪽 값을 꺼내고, None/Err 이면 프로그램을 중단시킨다. 이 중단을 패닉(panic)이라고 한다. 처리를 프로그래머가 아닌 실행 환경에 떠넘기는 셈이어서, 입력이 예상과 달라지는 순간 도구 전체가 멈춘다. 사용자가 손으로 고친 가계부 파일 한 줄 때문에 도구가 죽는 상황을 떠올려 보자.
| 방법 | 실패했을 때 | 쓸 만한 곳 | 주의 |
|---|---|---|---|
unwrap() | 패닉 | 테스트, 짧은 실험 코드 | 이유를 남기지 않는다 |
expect("이유") | 메시지를 붙여 패닉 | 실패가 프로그램 버그일 때 | 사용자 입력에 쓰지 않는다 |
unwrap_or_else(f) | 기본값으로 계속 | 기본값이 실제로 타당할 때 | 오류를 삼킬 수 있다 |
? | 호출자에게 넘김 | 대부분의 라이브러리형 함수 | 반환 타입이 맞아야 한다 |
match | 직접 분기 | 오류를 그 자리에서 처리할 때 | 코드가 길어진다 |
unwrap 이 정당한 곳은 실패가 불가능함을 프로그래머가 증명할 수 있을 때다. 예를 들어 소스에 직접 쓴 상수 문자열을 파싱하는 경우다. 그런 곳에서도 unwrap 보다 expect("상수 문자열은 항상 숫자다") 처럼 근거를 적어 두는 편이 낫다. 나중에 실패했을 때 어떤 전제가 깨졌는지 메시지가 알려 준다. 반대로 파일, 명령줄 인자, 사용자 입력처럼 바깥에서 들어오는 값에는 쓰지 않는다.
사용자 정의 오류 enum
parse 가 돌려주는 ParseIntError 는 "숫자가 아니다"만 알려 준다. 가계부 도구에는 필드 누락, 모르는 종류, 양수가 아닌 금액처럼 다른 실패도 있다. 이 모두를 하나의 반환 타입에 담으려면 실패의 종류를 갈래로 나눈 열거형이 알맞다. 갈래마다 필요한 정보를 다르게 실을 수 있고, 호출자는 match 로 종류별 대응을 고른다. 앞 장에서 배운 "데이터 모양 정하기"를 오류에 적용한 것이다.
#[derive(Debug)]
enum LedgerError {
MissingField(&'static str),
UnknownKind(String),
BadAmount(ParseIntError),
NotPositive,
}
&'static str 은 프로그램 전체 기간 동안 유효한 문자열 조각이라는 뜻이다. 소스에 직접 적은 "금액" 같은 글자가 여기에 해당한다. 참조가 얼마나 사는지를 표시하는 규칙은 수명을 다루는 장에서 자세히 배우므로, 지금은 "리터럴만 넣을 수 있는 문자열 칸"으로 이해하면 충분하다.
오류 타입에는 보통 두 가지를 더 붙인다. 하나는 사람이 읽을 메시지를 만드는 Display 구현이고, 다른 하나는 다른 오류 타입에서 자동 변환하는 From 구현이다. 트레이트(trait)라는 이름의 공통 동작 약속은 다음 장들에서 본격적으로 다루므로, 여기서는 형식을 그대로 따라 쓴다.
impl From<ParseIntError> for LedgerError {
fn from(err: ParseIntError) -> Self {
LedgerError::BadAmount(err)
}
}
이 구현이 있으면 ? 가 오류를 넘기기 직전에 From 으로 변환해 준다. 그래서 반환 타입이 Result<Entry, LedgerError> 인 함수 안에서 parse()? 를 쓰면 ParseIntError 가 자동으로 LedgerError::BadAmount 로 바뀐다. 변환을 직접 쓰고 싶다면 parse().map_err(LedgerError::BadAmount)? 로 같은 일을 할 수 있다. map_err 는 Err 안쪽 값만 바꾸는 메서드다.
완성 코드
cargo new ledger_errors 로 프로젝트를 만들고 src/main.rs 를 아래 내용으로 바꾼다. 외부 크레이트는 쓰지 않는다.
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum Kind {
Income,
Expense,
}
struct Entry {
kind: Kind,
category: String,
amount: i64,
}
#[derive(Debug)]
enum LedgerError {
MissingField(&'static str),
UnknownKind(String),
BadAmount(ParseIntError),
NotPositive,
}
impl fmt::Display for LedgerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LedgerError::MissingField(name) => write!(f, "필드가 없다: {name}"),
LedgerError::UnknownKind(kind) => write!(f, "알 수 없는 종류: {kind}"),
LedgerError::BadAmount(err) => write!(f, "금액을 읽을 수 없다: {err}"),
LedgerError::NotPositive => write!(f, "금액은 0보다 커야 한다"),
}
}
}
impl From<ParseIntError> for LedgerError {
fn from(err: ParseIntError) -> Self {
LedgerError::BadAmount(err)
}
}
fn parse_entry(line: &str) -> Result<Entry, LedgerError> {
let mut parts = line.split_whitespace();
let kind_text = parts.next().ok_or(LedgerError::MissingField("종류"))?;
let category = parts.next().ok_or(LedgerError::MissingField("분류"))?;
let amount_text = parts.next().ok_or(LedgerError::MissingField("금액"))?;
let kind = match kind_text {
"수입" => Kind::Income,
"지출" => Kind::Expense,
other => return Err(LedgerError::UnknownKind(other.to_string())),
};
let amount: i64 = amount_text.parse()?;
if amount <= 0 {
return Err(LedgerError::NotPositive);
}
Ok(Entry {
kind,
category: category.to_string(),
amount,
})
}
fn parse_all(lines: &[&str]) -> Result<Vec<Entry>, LedgerError> {
let mut entries = Vec::new();
for line in lines {
entries.push(parse_entry(line)?);
}
Ok(entries)
}
fn balance(entries: &[Entry]) -> Option<i64> {
let mut sum: i64 = 0;
for entry in entries {
let signed = match entry.kind {
Kind::Income => entry.amount,
Kind::Expense => -entry.amount,
};
sum = sum.checked_add(signed)?;
}
Some(sum)
}
fn spent_in(entries: &[Entry], category: &str) -> i64 {
let mut sum = 0;
for entry in entries {
if matches!(entry.kind, Kind::Expense) && entry.category == category {
sum += entry.amount;
}
}
sum
}
fn find_budget(budgets: &[(&str, i64)], category: &str) -> Option<i64> {
for (name, limit) in budgets {
if *name == category {
return Some(*limit);
}
}
None
}
fn percent_used(budgets: &[(&str, i64)], category: &str, spent: i64) -> Option<i64> {
find_budget(budgets, category).and_then(|limit| {
if limit == 0 { None } else { Some(spent * 100 / limit) }
})
}
fn budget_line(budgets: &[(&str, i64)], category: &str, spent: i64) -> String {
let usage = percent_used(budgets, category, spent)
.map(|p| format!("예산의 {p}% 사용"))
.unwrap_or_else(|| String::from("예산 없음"));
format!("{category}: {spent}원, {usage}")
}
fn main() {
let lines = [
"수입 월급 3000000",
"지출 식비 12000",
"지출 교통 1450",
"지출 식비 8500",
"지출 취미",
"지출 식비 만원",
"저축 적금 100000",
"지출 식비 -300",
];
let mut entries = Vec::new();
let mut failures = 0;
for (index, line) in lines.iter().enumerate() {
match parse_entry(line) {
Ok(entry) => entries.push(entry),
Err(err) => {
failures += 1;
println!("줄 {}: {err}", index + 1);
}
}
}
println!("기록 {}건, 오류 {failures}건", entries.len());
match balance(&entries) {
Some(value) => println!("잔액: {value}원"),
None => println!("잔액을 계산할 수 없다"),
}
let last = entries.last().map(|e| e.category.as_str()).unwrap_or("없음");
println!("마지막 기록 분류: {last}");
let budgets: [(&str, i64); 2] = [("식비", 40000), ("교통", 0)];
for category in ["식비", "교통", "취미"] {
println!("{}", budget_line(&budgets, category, spent_in(&entries, category)));
}
match parse_all(&lines[..2]) {
Ok(list) => println!("앞 두 줄 읽기 성공: {}건", list.len()),
Err(err) => println!("앞 두 줄 읽기 실패: {err}"),
}
match parse_all(&lines) {
Ok(list) => println!("전체 읽기 성공: {}건", list.len()),
Err(err) => println!("전체 읽기 실패: {err}"),
}
let huge = vec![
Entry { kind: Kind::Income, category: String::from("가상"), amount: i64::MAX },
Entry { kind: Kind::Income, category: String::from("가상"), amount: 1 },
];
match balance(&huge) {
Some(value) => println!("잔액: {value}원"),
None => println!("합산이 넘쳐 잔액을 계산할 수 없다"),
}
}
줄별 해설
타입 정의와 오류 변환
Kind와Entry는 앞 장에서 배운 열거형과 구조체다. 기록 한 건이 종류, 분류, 금액을 갖는다.LedgerError는 이 프로그램에서 일어나는 실패를 갈래로 나눈 열거형이다. 갈래마다 담는 정보가 다르다. 필드 이름, 잘못된 종류 글자, 원래 파싱 오류, 그리고 아무것도 없는 경우다.impl fmt::Display는 오류를{err}로 출력할 수 있게 한다.match self가 모든 갈래를 다루므로 갈래를 추가하면 이 부분에서 컴파일러가 빠뜨린 처리를 알려 준다.impl From<ParseIntError>는 파싱 오류를BadAmount로 감싸는 규칙이다.?가 이 규칙을 찾아 쓴다.
parse_entry 와 parse_all
split_whitespace는 공백 기준으로 조각을 하나씩 내놓는 값을 만든다.next()를 부르면 다음 조각이Some으로, 조각이 바닥나면None으로 나온다.- 세 줄의
ok_or(...)?는 조각이 없을 때 각각 종류, 분류, 금액이 빠졌다는 오류로 함수를 끝낸다. match kind_text의 마지막 갈래other는 앞의 두 글자가 아닌 모든 값을 받는다. 그 갈래에서는return Err(...)로 곧바로 빠져나간다. 나머지 갈래는Kind값을 만든다.amount_text.parse()?는 변수 타입i64를 보고 정수로 읽는다. 실패하면ParseIntError가From을 거쳐BadAmount가 되어 반환된다.- 0 이하 금액은
NotPositive로 거른다. 통과하면Ok(Entry { .. })를 돌려준다. 필드 이름과 변수 이름이 같은kind,amount는 줄여 쓸 수 있다. parse_all은 첫 실패에서 멈추는 방식이다.parse_entry(line)?가Err를 만나면 반복문 중간에서 함수가 끝난다.line은&&str이지만 컴파일러가&str로 알아서 맞춰 준다.
잔액, 집계, 예산
balance는Option을 돌려준다.checked_add(signed)?는 정수가 넘치면None으로 함수를 끝낸다. 넘쳐서 이상한 값이 조용히 나오는 일이 없다.spent_in은matches!매크로로 지출 기록인지 확인하고, 분류가 같으면 금액을 더한다.find_budget은 예산 목록을 훑어 찾으면Some, 못 찾으면None을 돌려준다.*name == category에서*는 이중 참조를 한 겹 벗겨&str끼리 비교하게 한다.percent_used는and_then으로 예산이 있고 0이 아닌 경우에만 비율을 만든다.budget_line은map으로 문장을 만들고unwrap_or_else로 없을 때의 문장을 채운다.{p}%에서%는 서식 문자열 안에서 그냥 글자다.
main
lines.iter().enumerate()는 번호와 줄을 함께 내놓는다. 번호는 0부터 시작하므로 사람에게 보여 줄 때index + 1을 쓴다.- 기록별
match는 성공이면entries에 쌓고, 실패면 오류를 출력하고 계속 진행한다. 잘못된 줄이 있어도 나머지 줄은 읽는다. entries.last()는 벡터가 비어 있으면None이다.map으로 분류만 꺼내고unwrap_or("없음")으로 기본값을 준다. 여기에unwrap이 없다는 점에 주목하자.- 예산 반복문은 세 분류를 차례로 출력한다. 취미는 예산 목록에 없고, 교통은 예산이 0이라 둘 다 "예산 없음"이 된다.
parse_all은 앞 두 줄에서는 성공하고, 전체에서는 다섯 번째 줄에서 처음 실패한다.- 마지막 블록은
i64::MAX에 1을 더해balance가None을 돌려주는 경우를 일부러 만든다.
실행 결과
프로젝트 폴더에서 실행한다. -q 는 빌드 메시지를 숨겨 프로그램 출력만 보이게 한다.
$ cargo run -q
줄 5: 필드가 없다: 금액
줄 6: 금액을 읽을 수 없다: invalid digit found in string
줄 7: 알 수 없는 종류: 저축
줄 8: 금액은 0보다 커야 한다
기록 4건, 오류 4건
잔액: 2978050원
마지막 기록 분류: 식비
식비: 20500원, 예산의 51% 사용
교통: 1450원, 예산 없음
취미: 0원, 예산 없음
앞 두 줄 읽기 성공: 2건
전체 읽기 실패: 필드가 없다: 금액
합산이 넘쳐 잔액을 계산할 수 없다
식비 사용률은 20500 × 100 ÷ 40000 = 51.25 에서 정수 나눗셈이 소수부를 버려 51 이 된다. 잔액은 3000000 에서 지출 12000, 1450, 8500 을 뺀 2978050 이다. "invalid digit found in string" 은 표준 라이브러리가 붙이는 영어 메시지이며, 우리 문장 뒤에 그대로 이어 붙인다.
실무에서 자주 틀리는 것
사용자 입력에 unwrap 을 쓴다
틀린 코드는 컴파일은 되지만 입력이 나쁘면 프로그램이 멈춘다.
let amount: i64 = amount_text.parse().unwrap();
"만원" 이 들어오면 다음과 같은 메시지와 함께 패닉한다. 어느 줄이 문제인지는 알 수 없다.
thread 'main' panicked at src/main.rs:..:
called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }
고친 코드는 오류를 호출자에게 넘긴다. 바로 처리할 수 있는 자리라면 match 로 분기한다.
let amount: i64 = amount_text.parse()?;
Result 를 돌려주지 않는 함수에서 ? 를 쓴다
? 는 실패를 함수 반환값으로 내보내는 문법이므로, 내보낼 곳이 없는 함수에서는 쓸 수 없다.
fn main() {
let entry = parse_entry("지출 식비 12000")?;
println!("{}", entry.amount);
}
컴파일러는 error[E0277] 로 "? 연산자는 Result 나 Option 등을 돌려주는 함수에서만 쓸 수 있다"는 요지로 알려 준다. main 의 반환 타입이 () 라서 생기는 문제다. 고치려면 main 이 Result 를 돌려주게 하면 된다. 이때 오류 타입은 Debug 를 구현해야 하고, Err 로 끝나면 프로그램은 오류를 표준 오류 출력에 적고 실패 종료 코드로 끝난다.
fn main() -> Result<(), LedgerError> {
let entry = parse_entry("지출 식비 12000")?;
println!("{}", entry.amount);
Ok(())
}
기본값으로 오류를 덮는다
파싱이 실패했을 때 0 으로 바꾸면 코드가 짧아지지만, 틀린 입력이 정상 기록처럼 흘러 들어간다.
let amount: i64 = amount_text.parse().unwrap_or(0);
"만원" 이 조용히 0원이 되고, 합계는 틀린 채로 아무 경고도 없다. 기본값이 정말 의미 있는 경우(예: 설정 파일에 값이 없으면 기본 한도를 쓴다)가 아니라면 오류를 넘긴다.
let amount: i64 = amount_text.parse()?;
비싼 기본값을 unwrap_or 로 미리 만든다
unwrap_or 의 인자는 값이 있든 없든 항상 계산된다.
fn default_category() -> String {
println!("기본 분류를 만든다");
String::from("기타")
}
let name = maybe.unwrap_or(default_category()); // maybe 가 Some 이어도 출력된다
여기서 maybe 는 Option<String> 이라고 하자. 값이 있는데도 문자열이 만들어졌다가 버려진다. 없을 때만 만들려면 함수를 넘긴다.
let name = maybe.unwrap_or_else(default_category); // None 일 때만 실행된다
한눈에 보기
| 개념 | 형태 | 쓰임 | 이 장의 예 |
|---|---|---|---|
| Option | Some(v) / None | 값이 없을 수 있음 | find_budget |
| Result | Ok(v) / Err(e) | 실패할 수 있음 | parse_entry |
| 조합기 | map, and_then, filter | 꺼내지 않고 가공 | percent_used |
| 변환 | ok_or, map_err | Option 을 Result 로, 오류 타입 바꾸기 | ok_or(MissingField) |
? | 식 뒤에 붙인다 | 실패를 즉시 반환 | parse_all, balance |
| 오류 enum | Display + From | 실패 종류를 갈래로 표현 | LedgerError |
다음 장에서는 Vec, String, HashMap 을 소유권과 함께 다룬다. 이 장에서 쓴 entries.last(), get 같은 메서드가 Option 을 돌려주는 이유가 그때 더 분명해진다.
연습 문제
- 한 줄에서 두 번째 단어(분류)를 꺼내는
fn category_of(line: &str) -> Option<String>을 조합기로 작성하시오. 힌트:split_whitespace()가 돌려주는 값에nth(1)을 부르면 0부터 세어 두 번째 조각이Option으로 나온다. LedgerError에TooLarge(i64)갈래를 추가하고, 금액이 10억을 넘으면 이 오류를 돌려주도록parse_entry를 고치시오. 추가한 뒤 컴파일러가 무엇을 알려 주는지도 적으시오.- 짝수일 때만 절반을 돌려주는
fn half(n: i64) -> Option<i64>가 있다. 이 함수를 두 번 적용해 "4로 나누어떨어질 때만 4분의 1"을 돌려주는fn quarter를match없이 쓰시오. fn require_category(name: Option<&str>) -> Result<&str, LedgerError>를 작성하시오. 이름이 없으면MissingField("분류")를 돌려준다.
정답과 해설
1번.
fn category_of(line: &str) -> Option<String> {
line.split_whitespace().nth(1).map(String::from)
}
조각이 두 개 미만이면 nth(1) 이 None 이고 map 은 그대로 None 을 돌려준다. 있으면 &str 을 String 으로 바꿔 Some 에 담는다. 반환값이 조각 자체가 아니라 새 String 이므로 입력 문자열의 수명에 묶이지 않는다.
2번. 열거형에 TooLarge(i64) 를 추가하고 NotPositive 검사 뒤에 다음을 넣는다.
if amount > 1_000_000_000 {
return Err(LedgerError::TooLarge(amount));
}
갈래를 추가하면 Display 구현의 match self 에서 error[E0004] 가 난다. 새 갈래를 처리하지 않았다는 요지의 비완전 패턴 오류다. 다음 갈래를 추가하면 해결된다.
LedgerError::TooLarge(value) => write!(f, "금액이 너무 크다: {value}"),
이렇게 갈래 추가가 처리 누락을 컴파일 오류로 알려 주는 점이 열거형으로 오류를 표현하는 장점이다.
3번.
fn quarter(n: i64) -> Option<i64> {
half(n).and_then(half)
}
첫 half 가 None 이면 두 번째는 실행되지 않고, Some 이면 그 값에 다시 half 를 적용한다. half 가 Option 을 돌려주므로 map 이 아니라 and_then 이어야 한다. map 을 쓰면 Option<Option<i64>> 가 된다.
4번.
fn require_category(name: Option<&str>) -> Result<&str, LedgerError> {
name.ok_or(LedgerError::MissingField("분류"))
}
ok_or 는 Some(v) 를 Ok(v) 로, None 을 인자로 준 오류의 Err 로 바꾼다. 오류 값을 만드는 데 비용이 든다면 ok_or_else 로 클로저를 넘겨 없을 때만 만들게 한다.