본문 바로가기

Rust Programming

[Rust] 5. Ticket v2: Fallibility, Unwrap, Error enums, Error trait

이전 exercise의 Ticket::new function을 보자.

impl Ticket {
    pub fn new(title: String, description: String, status: Status) -> Ticket {
        if title.is_empty() {
            panic!("Title cannot be empty");
        }
        if title.len() > 50 {
            panic!("Title cannot be longer than 50 bytes");
        }
        if description.is_empty() {
            panic!("Description cannot be empty");
        }
        if description.len() > 500 {
            panic!("Description cannot be longer than 500 bytes");
        }

        Ticket {
            title,
            description,
            status,
        }
    }
}

 
check하는 logic중 하나라도 실패하면, function은 panic을 일으킨다.
하지만 앞에서도 그랬듯이 이건 별로다.
이건 caller에게 error를 handle할 기회조차 주지 않는것이다.
 
이제 Rust의 error handling의 primary mechanism인 Result type에 대해 설명할 때다.
 


 
Result type은 standard library에 정의된 enum이다.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

 
위와 같이 두 개의 variants를 갖고있다.

  • Ok(T): 성공적인 작업을 의미하며, 작업의 출력물인 T를 갖고있다.
  • Err(E): 실패한 작업을 의미하며, 일어난 error인 E를 갖고 있다.

Ok와 Err는 모두 generic이다.
그래서 성공과 error cases에 대해 당신의 types를 지정할 수 있다.
 


 
Rust에서 Recoverable errors는 values로 표현된다.
Recoverable errors는 type의 instance이며,
다른 value처럼 전달되고 조작된다.
이것이 다른 languages와의 중요한 차이점이다.
Python과 C#과 같은 language는 오류를 알리기 위해 exceptions를 사용한다.
 
Exceptions는 추론하기 어려울 수 있는 별도의 control flow path를 생성한다.
function의 signature만 보면 exception이 발생할 수 있는지 여부를 알 수 없다.
또한 function의 signature만 보면 어떤 exception types가 발생하는지도 알 수 없다.
function의 documentation을 읽거나 implementation에서 찾아내거나 해야한다.
 
Exception handling logic은 locality가 매우 낮다.
exception을 발생시키는 code는 exception을 포착하는 code에서
멀리 있으며, 둘 사이에는 직접적인 link가 없다.
 


 
Rust는 Result를 사용하여 function signature에 fallibility를 encode하도록 한다.
만약 function이 fail할 수 있다면(그리고 당신이 caller가 error를 handling하기를 원한다면),
Result를 return해야한다.

// Just by looking at the signature, you know that this function can fail.
// You can also inspect `ParseIntError` to see what kind of failures to expect.
fn parse_int(s: &str) -> Result<i32, ParseIntError> {
    // ...
}

 
Result가 fallibility를 explicitly하게 보여준다는 것은 매우 큰 장점이다.
 
하지만 panics가 존재한다는 것을 기억해라.
panics는 다른 languages의 exceptions처럼, type system에 의해 track되지 않는다.
그러나 panics는 unrecoverable errors에 대한 것이므로 드물게 사용해야한다.
 


06_fallibility exercise는 분명 맞는데... 왜 틀리지 하는 그런 코드를 썼다.
그런데 Err variant를 return할 때, return을 명시적으로 해줘야한다는 것을 solution을 통해 알았다.

 
조금 더 궁금해서 왜 이렇게 했는지 Chatgpt한테 물어봤다.
function의 목적은 특정한 type을 return하는 것인데
특정한 type을 return할 때는 return keyword를 생략해도 되지만,
특별한 case인 None이나 Err같은 경우는 return keyword를 써서
가독성을 높였다고 한다.
 
음... 읽어보니까 맞는 말이긴 하다.
 


 
Ticket::new는 이제 invalid inputs에 대해 panic을 내지 않고 Result를 return한다.
이게 caller에게 무슨 의미일까?
 


 
exceptions와 달리, Rust의 Result는 call 시점에 errors를 handle하도록 한다.
만약 당신이 Result를 return하는 function을 호출한다면,
Rust는 error case를 implicitly하게 무시하도록 하지않을 것이다.

fn parse_int(s: &str) -> Result<i32, ParseIntError> {
    // ...
}

// This won't compile: we're not handling the error case.
// We must either use `match` or one of the combinators provided by `Result`
// to "unwrap" the success value or handle the error.
let number = parse_int("42") + 2;

 


 
당신이 Result를 return하는 함수를 호출한다면, 두가지 key options가 있다.

  • operation이 실패하면 Panic을 일으킨다.
  • 이는 unwrap과 expect methods중 하나를 사용해 가능하다.
// Panics if `parse_int` returns an `Err`.
let number = parse_int("42").unwrap();
// `expect` lets you specify a custom panic message.
let number = parse_int("42").expect("Failed to parse integer");

 

  • match expression을 사용해 error case를 explicitly하게 다루고 Result를 destruct한다.
match parse_int("42") {
    Ok(number) => println!("Parsed number: {}", number),
    Err(err) => eprintln!("Error: {}", err),
}

 


 
07_unwrap exercise는 match keyword를 사용하여 error control을 하는 것이었다.
꼭 하나가 걸려서 solution을 확인한다.
String이 ownership을 넘겨줘서 사라지는 문제때문에 ticket을 return을 못하는 경우가 생겼다.
그럴 때, clone함수를 이용하여 function을 실행시켜주면됐다.

 
이전에 것들도 복습해주니 참 좋긴하다...
물론 실제로 구현하려할 때는 어렵긴하다만..
 


 
strings를 match시키는 것은 이상적이지 않아서, 이전 exercise의 답은 이상하게 느껴질 수 있다.
같이 일하는 동료가 Ticket::new의 error message를 readability를 높이기 위해 재작업할 수 있으며,
그러면 당신이 짠 code는 안돌아간다.
 
당신은 이미 이것을 고치기 위한 machinery인 enums를 알고 있다.
 


 
발생한 특정한 error마다 다르게 caller가 행동하게 하고 싶다면,
당신은 서로 다른 error cases를 나타내는 enum을 사용할 수 있다.

// An error enum to represent the different error cases
// that may occur when parsing a `u32` from a string.
enum U32ParseError {
    NotANumber,
    TooLarge,
    Negative,
}

 
error enum을 사용함으로써, 당신은 type system에 서로 다른 error cases를 encode하는 것이고,
그것들은 fallible function의 signature의 일부가 된다.
이것은 caller가 error handling하는 것을 쉽게 해준다.
match expression을 사용해 서로 다른 error cases에 따라 행동하면 되기 때문이다.

match s.parse_u32() {
    Ok(n) => n,
    Err(U32ParseError::Negative) => 0,
    Err(U32ParseError::TooLarge) => u32::MAX,
    Err(U32ParseError::NotANumber) => {
        panic!("Not a number: {}", s);
    }
}

08_error_enums exercise는 match와 error enums를 활용해 잘 해결했다.
처음에만 다 어렵지, 어느정도 익숙해지면 쉽게 하는 것 같다.


 
이전 exercise에서 error message를 얻기 위해
TitleError variant를 destruct하고 panic! macro에 error message를 넘겼을 것이다.
이것이 error reporting의 한 예시다.
error type을 user, service operator, developer에게 보여줄 수 있는 표현으로 변환한 것이다.
 
Rust developer 각자가 각자의 error reporting을 만들어서 사용하는것은 실용적이지않다.
시간낭비고 프로젝트 전반에 걸쳐 잘 구성되지도 않는다.
이것이 Rust가 std::error:Error trait을 제공하는 이유다.
 


 
Result의 Err variant의 type에는 제한이 없다.
하지만 Error trait을 구현한 type을 사용하는 것이 좋다.
Error trait은 Rust의 error handling에서 중요한 부분이다.

// Slightly simplified definition of the `Error` trait
pub trait Error: Debug + Display {}

 
당신은 Sized trait에서의 :(colon) 문법을 배웠다.
그것은 supertraits를 나타낼 때 사용한다.
Error trait에는 Debug와 Display라는 두가지 supertraits가 있다.
만약 Error를 구현하길 원할 때, Debug와 Display도 모두 구현해야한다.
 


 
이전 exercise에서 우리는 이미 Debug trait을 봤다.
그것은 assert_eq!이 사용하는 trait이다.
assertion이 실패할 때 비교하는 변수의 값을 표시해준다.
 
기계적인 관점에서 볼때, Display와 Debug는 동일하다.
그들은 type이 string-like representation으로 어떻게 변환돼야하는지 encode한다.

// `Debug`
pub trait Debug {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}

// `Display`
pub trait Display {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}

 
차이점은 각각의 목적에 있다.
Display는 end-users(최종 사용자, 서비스 이용자)를 위한 representation을 반환하는 반면,
Debug는 developers나 service operators에게 적합한 low-level representation을 제공한다.
이것이 Debug가 #[derive(Debug)] 속성을 사용해 자동으로 구현할 수 있는 이유다.
반면, Display는 직접적인 구현이 필요하다.
 


09_error_trait exercise는 display를 직접 구현하고 error trait을 활용하는 것이었다.

impl std::error::Error for TicketNewError {}
이 코드 한줄 때매 시간을 많이 썼고
solution을 봤을 때, 당연한 것 같아 허망했다.
 
compiler는 계속 Error trait이 어딨냐고 꾸짖었지만 난 계속 derive 시도를 했다.
Display는 주어진 자료를 참고하여 잘 구현했고, easy_ticket function은 이전 exercise를 했으면,
그냥 쉽게 구현할 수 있었다.
 


 
슬 exercise들에 여러 개념들이 들어있기 때문에,
시간이 오래걸리고 못하는 경우가 있다.
하지만, solution을 보더라도 내 것으로 만드는 것이 중요하기에
시간이 전혀 아깝지 않다.
그리고 무엇보다 재밌다.