Rust는 예외(exception)가 없습니다. 대신 오류를 두 종류로 나눕니다. 복구할 수 없는 버그는 panic!으로 프로그램을 멈추고, 실패할 수 있는 작업은 Result 값으로 돌려줘 호출한 쪽이 처리하게 합니다. 이 장의 뒷부분에서는 Rust에 내장된 테스트 도구를 다룹니다.
panic과 복구 가능한 오류
panic!은 "여기까지 오면 프로그램에 버그가 있다"는 뜻입니다. 배열 범위를 벗어나거나, None에 unwrap()을 호출해도 패닉이 납니다. 파일이 없거나 입력 형식이 틀린 것처럼 예상할 수 있는 실패에는 Result<T, E>를 씁니다.
use std::fs;
use std::io;
fn read_config(path: &str) -> Result<String, io::Error> {
let text = fs::read_to_string(path)?; // 실패하면 여기서 오류를 돌려줍니다
Ok(text.trim().to_string())
}
fn main() {
match read_config("config.txt") {
Ok(text) => println!("설정: {text}"),
Err(e) => eprintln!("설정을 읽지 못했습니다: {e}"),
}
}
unwrap()과 expect("이유")는 오류면 패닉을 일으킵니다. 예제나 테스트, "절대 실패하지 않는다"는 것이 확실한 곳에서만 쓰고, 그때도 이유를 적는 expect가 낫습니다.
? 연산자
?는 Result가 Ok면 안의 값을 꺼내고, Err면 그 오류를 즉시 함수 밖으로 돌려줍니다. 오류 타입이 다르면 From 변환을 거쳐 함수의 오류 타입으로 바꿔 줍니다. Option을 돌려주는 함수 안에서는 Option에도 쓸 수 있습니다.
use std::error::Error;
use std::num::ParseIntError;
fn sum_strs(a: &str, b: &str) -> Result<i32, ParseIntError> {
let x: i32 = a.trim().parse()?;
let y: i32 = b.trim().parse()?;
Ok(x + y)
}
fn first_char_upper(s: &str) -> Option<char> {
let c = s.chars().next()?; // 빈 문자열이면 None
Some(c.to_ascii_uppercase())
}
// main 도 Result 를 돌려줄 수 있습니다. Err 이면 오류를 출력하고 실패 코드로 끝납니다
fn main() -> Result<(), Box<dyn Error>> {
println!("{}", sum_strs("2", " 40 ")?); // 42
println!("{:?}", first_char_upper("rust")); // Some('R')
println!("{:?}", sum_strs("2", "x")); // Err(ParseIntError { .. })
Ok(())
}
Box<dyn Error>는 어떤 오류 타입이든 담을 수 있어 작은 프로그램의 main에 편리합니다.
직접 만든 오류 타입
라이브러리에서는 실패 원인을 열거형으로 정의하면 호출한 쪽이 match로 구분할 수 있습니다. Display와 std::error::Error를 구현하고, From을 구현해 두면 ?가 자동으로 변환합니다.
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Parse(ParseIntError),
Negative(i64),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Parse(e) => write!(f, "숫자가 아닙니다: {e}"),
AppError::Negative(n) => write!(f, "음수는 받을 수 없습니다: {n}"),
}
}
}
impl std::error::Error for AppError {}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
fn parse_positive(s: &str) -> Result<i64, AppError> {
let n: i64 = s.parse()?; // ParseIntError 가 AppError 로 바뀝니다
if n < 0 {
return Err(AppError::Negative(n));
}
Ok(n)
}
fn main() {
for input in ["42", "-7", "abc"] {
match parse_positive(input) {
Ok(n) => println!("성공: {n}"),
Err(e) => println!("실패: {e}"),
}
}
}
이 반복 코드를 줄여 주는 크레이트도 널리 쓰입니다. 라이브러리에서는 thiserror로 오류 열거형을 간단히 정의하고, 애플리케이션에서는 anyhow로 여러 오류를 하나로 모아 문맥을 덧붙이는 방식이 흔합니다.
단위 테스트
테스트는 #[test]를 붙인 함수입니다. 보통 같은 파일 아래쪽에 #[cfg(test)] 모듈을 두어 테스트할 때만 컴파일되게 합니다. assert!, assert_eq!, assert_ne!로 결과를 확인합니다.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("divide by zero");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
#[should_panic(expected = "divide by zero")]
fn panics_on_zero() {
divide(1, 0);
}
#[test]
fn parses_number() -> Result<(), std::num::ParseIntError> {
let n: i32 = "10".parse()?; // 테스트도 Result 를 돌려줄 수 있습니다
assert_eq!(n, 10);
Ok(())
}
}
cargo test와 문서 테스트
cargo test는 단위 테스트, tests/ 폴더의 통합 테스트, 문서 주석 속 예제(문서 테스트)를 모두 실행합니다.
cargo test # 전부 실행
cargo test adds # 이름에 adds 가 들어간 테스트만
cargo test -- --nocapture # 테스트 중 println! 출력 보기
cargo test -- --ignored # #[ignore] 를 붙인 느린 테스트 실행
cargo test --doc # 문서 테스트만
/// 문서 주석 안의 코드 블록은 문서가 되면서 동시에 테스트로 실행되므로, 예제가 실제 코드와 어긋나지 않습니다. 패키지 이름이 my-crate라면 코드에서는 my_crate로 씁니다.
/// 두 수를 더합니다.
///
/// ```
/// let sum = my_crate::add(2, 3);
/// assert_eq!(sum, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
정리
- 버그는
panic!, 예상할 수 있는 실패는 Result로 표현합니다. ?는 오류를 바로 돌려주며 From으로 오류 타입을 변환합니다.- 오류 열거형에
Display, Error, From을 구현하면 쓰기 좋은 오류 타입이 됩니다. #[test]와 cargo test로 단위 · 통합 · 문서 테스트를 한 번에 돌립니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.