[rust] 9. 에러 처리

About_work·2024년 7월 10일
0

rust

목록 보기
10/16
  • 러스트에서는 코드가 컴파일 되기 전에, 에러의 가능성을 인지하고 조치를 취해야 합니다.
  • 러스트는 에러를 2가지 범주로 묶음
    • 복구 가능한 (recoverable) 에러
      • 예: 파일을 찾을 수 없음
      • 대부분의 경우 그저 사용자에게 문제를 보고하고 명령을 재시도하도록 하길 원함
      • Result<T, E> 타입 사용
    • 복구 불가능한 (unrecoverable) 에러
      • 예: 배열 끝을 넘어선 위치에 접근하는 경우
      • 언제나 버그 증상이 나타나는 에러이며, 따라서 프로그램을 즉시 멈추기를 원합니다.
      • 프로그램을 종료하는 panic! 매크로 사용
  • 대부분의 언어는 예외 처리 (exception) 와 같은 메커니즘을 이용하여, 이 두 종류의 에러를 구분하지 않고 같은 방식으로 처리
    • 러스트에는 예외 처리 기능이 없습니다.

1. panic!으로 복구 불가능한 에러 처리하기

  • 패닉을 일으키는 두 가지 방법
    • (배열 끝부분을 넘어선 접근과 같이) 코드가 패닉을 일으킬 동작을 하는 것
    • panic! 매크로를 명시적으로 호출하는 것
  • 두 경우 모두 프로그램에 패닉을 일으킵니다.
  • 기본적으로 이러한 패닉은
    • 실패 메시지를 출력하고,
    • 되감고 (unwind),
      • 패닉을 발생시킨 각 함수로부터, 스택을 거꾸로 훑어가면서 데이터를 청소한다는 뜻
    • 스택을 청소하고,
    • 종료

1.1. panic!에 대응하여 스택을 되감거나 그만두기

  • 하지만 이 되감기와 청소 작업은 간단한 작업이 아닙니다.
  • 그래서 러스트에서는 프로그램이 데이터 정리 작업 없이 즉각 종료되는 대안인 그만두기 (aborting) 를 선택할 수도 있습니다.
  • 프로그램이 사용하고 있던 메모리는 운영체제가 청소해 주어야 합니다.
  • 프로젝트 내에서 결과 바이너리를 가능한 한 작게 만들고 싶다면, 아래와 같이 되감기를 그만두기로 바꿀 수 있습니다.
    • Cargo.toml 내에서 적합한 [profile] 섹션에
      • panic = 'abort'를 추가
[profile.release]
panic = 'abort'
  • 패닉이 발생했을 때 그 패닉의 근원을 쉽게 추적하기 위해
    • 환경 변수(RUST_BACKTRACE=1)를 통하여 러스트가 호출 스택을 보여주도록 할 수 있음
  • 문제를 일으킨 코드 조각을 발견하기 위해서, panic! 호출이 발생한 함수에 대한 백트레이스 (backtrace) 를 사용할 수 있습니다.
fn main() {
    panic!("crash and burn");
}
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

1.2. panic! 백트레이스 이용하기

  • 백트레이스 (backtrace) 란 어떤 지점에 도달하기까지 호출한 모든 함수의 목록
  • 백트레이스를 읽는 요령은, 위에서부터 시작하여 여러분이 작성한 파일이 보일 때까지 읽는 것입니다.
  • 여러분의 파일이 나타난 줄보다 위에 있는 줄은 여러분의 코드가 호출한 코드이고,
    • 아래의 코드는 여러분의 코드를 호출한 코드입니다.
  • 이러한 정보로 백트레이스를 얻기 위해서는 디버그 심볼이 활성화되어 있어야 합니다.
    • 디버그 심볼은 여기서처럼 여러분이 cargo build나 cargo run을 --release 플래그 없이 실행했을 때 기본적으로 활성화

2. Result로 복구 가능한 에러 처리하기

  • 어떤 파일을 열려고 했는데 해당 파일이 존재하지 않아서 실패했다면, 프로세스를 종료해 버리는 대신 파일을 생성하는 것을 원할지도 모르죠.
  • Result 열거형
enum Result<T, E> {
    Ok(T),
    Err(E),
}
  • T와 E는 제네릭 타입 매개변수
    • T는 성공한 경우에 Ok 배리언트 안에 반환될 값의 타입을 나타냄
    • E는 실패한 경우에 Err 배리언트 안에 반환될 에러의 타입을 나타냄
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}
  • File::open의 반환 타입은 Result<T, E>
    • 제네릭 매개변수 T는 File::open의 구현부에 성공 값인 파일 핸들 std::fs::File로 채워져 있습니다.
    • 에러 값에 사용된 E의 타입은 std::io::Error입니다.
  • File::open이 greeting_file_result 변수의 값이
    • 성공한 경우: 파일 핸들을 가지고 있는 Ok 인스턴스
    • 실패한 경우: 발생한 에러의 종류에 관한 더 자세한 정보가 담긴 Err 인스턴스
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}
  • Option 열거형과 같이 Result 열거형과 배리언트들은
    • 프렐루드로부터 가져와진다는 점을 주의하세요.
    • 따라서 match 갈래의 Ok와 Err 앞에 Result::라고 지정하지 않아도 됩니다.

2.1. 서로 다른 에러에 대해 매칭하기

  • 파일이 없어서 File::open이 실패했다면, 새로운 파일을 만들어서 핸들을 반환하겠습니다.
  • File::open이 반환하는 Err 배리언트 값의 타입은 io::Error인데, 이는 표준 라이브러리에서 제공하는 구조체
    • 이 구조체가 제공하는 kind 메서드를 호출하여 io::ErrorKind값을 얻을 수 있음
    • 표준 라이브러리가 제공하는 io::ErrorKind는
      • io 연산으로부터 발생할 수 있는 다양한 종류의 에러를 나타내는 배리언트가 있는 열거형

2.2. Result<T, E>와 match 사용에 대한 대안

  • match가 정말 많군요! match 표현식은 매우 유용하지만 굉장히 원시적이기도 합니다.
  • 13장에서는 클로저에 대해서 배워볼 텐데,
    • Result<T, E> 타입에는 클로저를 사용하는 여러 메서드가 있습니다.
      • unwrap_or_else 메서드와 클로저를 사용했습니다:

2.3. 에러 발생 시 패닉을 위한 숏컷: unwrap과 expect

  • match의 사용은 충분히 잘 동작하지만, 살짝 장황하기도 하고 의도를 항상 잘 전달하는 것도 아닙니다.
  • Result<T, E> 타입은 다양한 특정 작업을 수행하기 위해 정의된 수많은 도우미 메서드 가지고 있음
  • unwrap 메서드는 예제 9-4에서 작성한 match 구문과 비슷한 구현을 한 숏컷 메서드
    • 만일 Result 값이 Ok 배리언트: unwrap은 Ok 내의 값을 반환
    • 만일 Result가 Err 배리언트: unwrap은 panic! 매크로를 호출
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}
  • 이와 비슷한 expect는 panic! 에러 메시지도 선택할 수 있도록 해 줍니다.
  • unwrap 대신 expect를 이용하고 좋은 에러 메시지를 제공하면
    • 여러분의 의도를 전달하면서 패닉의 근원을 추적하는 걸 쉽게 해줍니다.
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}
  • 프로덕션급 품질의 코드에서 대부분의 러스타시안은 unwrap보다 expect를 선택하여
    • 해당 연산이 항시 성공한다고 기대하는 이유에 대한 더 많은 맥락을 제공합니다.

2.4. 에러 전파하기

  • 함수의 구현체에서 실패할 수도 있는 무언가를 호출할 때, 이 함수에서 에러를 처리하는 대신
    • 이 함수를 호출하는 코드 쪽으로 에러를 반환하여 그쪽에서 수행할 작업을 결정하도록 할 수 있음
  • 이를 에러 전파하기 (propagating) 라고 하며, 호출하는 코드 쪽에 더 많은 제어권을 주는 것인데,
    • 호출하는 코드 쪽에는 에러를 어떻게 처리해야 하는지 결정하는 정보와 로직이
    • 여러분의 코드 컨텍스트 내에서 활용할 수 있는 것보다 더 많이 있을 수도 있기 때문
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
  • 함수의 반환 타입인 Result<String, io::Error>부터 먼저 살펴봅시다.
  • 함수가 Result<T, E> 타입의 값을 반환하는데
    • 제네릭 매개변수 T는 구체 타입 (concrete type) 인 String으로 채워져 있고,
    • 제네릭 타입 E는 구체 타입인 io::Error로 채워져 있다는 뜻
  • 이 함수의 반환 타입으로 io::Error를 선택했는데, 그 이유는 아래 2가지가 모두 io::Error 타입의 에러 값을 반환하기 때문
    • 실패할 수 있는 연산 File::open 함수
    • read_to_string 메서드
  • 이 함수의 마지막 표현식이기 때문에 명시적으로 return이라고 적을 필요는 없음

2.5. 에러를 전파하기 위한 숏컷: ?

  • 러스트에서는 에러를 전파하는 패턴이 너무 흔하여, 이를 더 쉽게 해주는 물음표 연산자 ?를 제공
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
  • 만일 값이 Err라면, (return 키워드로 에러 값을 호출하는 코드에게 전파하는 것처럼) Err의 값이 반환될 것
  • ? 연산자를 사용할 때의 에러 값들은 from 함수를 거친다는 것
    • from 함수: 표준 라이브러리 내의 From 트레이트에 정의
    • 어떤 값의 타입을 다른 타입으로 변환하는 데에 사용
  • ? 연산자가 from 함수를 호출하면,
    • ? 연산자가 얻게 되는 에러를, ? 연산자가 사용된 현재 함수의 반환 타입에 정의된 에러 타입으로 변환
    • 이는 어떤 함수가 다양한 종류의 에러로 인해 실패할 수 있지만, 모든 에러를 하나의 에러 타입으로 반환할 때 유용
  • 심지어는 아래와 같이, ? 뒤에 바로 메서드 호출을 연결하는 식으로 이 코드를 더 줄일 수도 있습니다:
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
  • 파일에서 문자열을 읽는 코드는 굉장히 흔하게 사용되기 때문에,
    • 표준 라이브러리에서는 fs::read_to_string라는 편리한 함수를 제공
      • 파일을 열고, 새 String을 생성하고, 파일 내용을 읽고, 내용을 String에 집어넣고 반환
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}

2.6. ? 연산자가 사용될 수 있는 곳

  • ?는, ?이 사용된 값과 호환 가능한 반환 타입을 가진 함수에서만 사용될 수 있음
  • 이는 ? 연산자가 함수를 일찍 끝내면서 값을 반환하는 동작을 수행하도록 정의되어 있기 때문
  • 아래 코드는 컴파일 안됨.
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}
  • ? 연산자는 File::open에 의해 반환되는 Result 값을 따르지만,
    • main 함수는 반환 타입이 Result가 아니라 ()
  • ? 연산자는 Result, Option 혹은 FromResidual을 구현한 타입을 반환하는 함수에서만 사용될 수 있음
  • 이 에러를 고치기 위해서는 두 가지 선택지가 있습니다.
      1. ? 연산자가 사용되는 곳의 값과 호환되게 함수의 반환 타입을 수정하는 것
      • 이러한 수정을 막는 제약 사항이 없는 한에서 가능
      1. Result<T, E>를 적절한 식으로 처리하기 위해 아래 2가지 중 하나를 사용하는 것
      • match
      • Result<T, E>의 메서드
  • 에러 메시지는 또한 ?가 Option<T> 값에 대해서도 사용될 수 있음을 알려줌
    • Result에 ?를 사용할 때와 마찬가지로, 함수가 Option를 반환하는 경우에는 Option에서만 ?를 사용할 수 있음
    • None 값인 경우 그 함수의 해당 지점으로부터 None 값을 일찍 반환할 것
    • Some 값이라면 Some 안에 있는 값이 이 표현식의 결괏값이 되면서 함수가 계속됨
  • 주어진 텍스트에서 첫 번째 줄의 마지막 문자를 찾는 함수
  fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}
  • 만일 text가 빈 문자열이라면 next 호출은 None을 반환하는데,
  • 여기서 ?를 사용하여 last_char_of_first_line의 실행을 멈추고 None을 반환
  • 만약 text가 빈 문자열이 아니라면 next는
    • text의 첫 번째 줄의 문자열 슬라이스를 담고 있는 Some의 값을 반환합니다.
  • "\nhi"처럼 빈 줄로 시작하지만 다른 줄에는 문자가 담겨있는 경우처럼, 첫 번째 라인이 빈 문자열일 수 있으므로 반복자의 결과는 Option
  • 만약 첫 번째 라인에 마지막 문자가 있다면 Some 배리언트를 반환할 것
  • 가운데의 ? 연산자가 이러한 로직을 표현할 간단한 방식을 제공하여 이 함수를 한 줄로 작성할 수 있도록 해 줍니다.

  • Result를 반환하는 함수에서는 Result에서 ? 연산자를 사용할 수 있고,
  • Option을 반환하는 함수에서는 Option에 대해 ? 연산자를 사용할 수 있지만,
  • 이를 섞어서 사용할 수는 없음을 주목하세요.
  • ? 연산자는 자동으로 Result를 Option으로 변환하거나 혹은 그 반대를 할 수 없습니다;
  • 그러한 경우에는 Result의 ok 메서드 혹은 Option의 ok_or 메서드 같은 것을 통해 명시적으로 변환을 할 수 있음

  • 여태껏 다뤄본 main 함수는 모두 ()를 반환했습니다.
  • main 함수는 실행 프로그램의 시작점이자 종료점이기 때문에 특별하며, 프로그램이 기대한 대로 동작려면 반환 타입의 종류에 대한 제약사항이 있습니다.
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}
  • Box<dyn Error> 타입은 트레이트 객체 (trait object)

    • ‘어떠한 종류의 에러’를 의미
  • main 함수의 구현 내용이 std::io::Error 타입의 에러만 반환하겠지만, 이 함수 시그니처에 Box<dyn Error>라고 명시하면

    • 이후 main의 구현체에 다른 에러들을 반환하는 코드가 추가되더라도 계속 올바르게 작동할 것
  • main 함수가 Result<(), E>를 반환하게 되면,

    • 실행 파일은 main이 Ok(())를 반환할 경우 0 값으로 종료되고,
    • main이 Err 값을 반환할 경우 0이 아닌 값으로 종료
  • C로 작성된 실행파일은 종료될 때 정숫값을 반환합니다:

    • 성공적으로 종료된 프로그램은 정수 0을 반환하고,
    • 에러가 발생한 프로그램은 0이 아닌 어떤 정숫값을 반환
  • 러스트 또한 이러한 규칙과 호환될 목적으로 실행파일이 정숫값을 반환

  • main 함수가 std::process::Termination 트레이트를 구현한 타입을 반환할 수도 있는데,

    • 이는 ExitCode를 반환하는 report라는 함수를 가지고 있습니다.
  • 여러분이 만든 타입에 대해 Termination 트레이트를 구현하려면 표준 라이브러리 문서에서 더 많은 정보를 찾아보세요.


3. panic!이냐, panic!이 아니냐, 그것이 문제로다

  • 복구 가능한 방법이 있든 없든 간에 에러 상황에 대해 panic!을 호출할 수 있지만,

    • 그렇게 되면 호출하는 코드를 대신하여 현 상황은 복구 불가능한 것이라고 결정을 내리는 꼴
  • Result 값을 반환하는 선택을 한다면 호출하는 쪽에게 옵션을 제공하는 것

  • 호출하는 코드 쪽에서는

    • 상황에 적합한 방식으로 복구를 시도할 수도 있고,
    • 혹은 현재 상황의 Err은 복구 불가능하다고 결론을 내리고 panic!을 호출하여 복구 가능한 에러를 복구 불가능한 것으로 바꿔놓을 수도 있습니다.
  • 그러므로 실패할지도 모르는 함수를 정의할 때는 기본적으로 Result를 반환하는 것이 좋은 선택

  • 예제, 프로토타입, 테스트 같은 상황에서는 Result를 반환하는 대신 패닉을 일으키는 코드가 더 적절

    • 프로토타입: 최종 완성품의 주요 기능과 디자인을 시험해보기 위해 제작하는 초기 모델 또는 시제품
  • 사람으로서의 여러분이라면 실패할 리 없는 코드라는 것을 알 수 있지만, 컴파일러는 이유를 파악할 수 없는 경우에 대해서도 논의해 봅시다.

  • 그리고 라이브러리 코드에 패닉을 추가해야 할지 말지를 어떻게 결정할까에 대한 일반적인 가이드라인을 공부해보자.

3.1. 예제, 프로토타입 코드, 그리고 테스트

  • 어떤 개념을 묘사하기 위한 예제를 작성 중이라면, 견고한 에러 처리 코드를 포함시키는 것이 오히려 예제의 명확성을 떨어트릴 수도 있습니다.
  • 예제 코드 내에서는 panic!을 일으킬 수 있는 unwrap 같은 메서드의 호출이 애플리케이션의 에러 처리가 필요한 곳을 뜻하는 방식으로 해석될 수 있는데,
    • 이러한 에러 처리는 코드의 나머지 부분이 하는 일에 따라 달라질 수 있습니다.

  • 비슷한 상황으로 에러를 어떻게 처리할지 결정할 준비가 되기 전이라면, unwrap과 expect 메서드가 프로토타이핑할 때 매우 편리합니다.
  • 이 함수들은 코드를 더 견고하게 만들 준비가 되었을 때를 위해서 명확한 표시를 남겨 둡니다.

  • 만일 테스트 내에서 메서드 호출이 실패한다면, 해당 메서드가 테스트 중인 기능이 아니더라도 전체 테스트를 실패시키도록 함
  • panic!이 테스트의 실패를 표시하는 방식이므로, unwrap이나 expect의 호출이 정확히 그렇게 만들어줍니다.

3.2. 여러분이 컴파일러보다 더 많은 정보를 가지고 있을 때

  • Result가 Ok 값을 가지고 있을 거라 확신할만한 논리적 근거가 있지만, 컴파일러가 그 논리를 이해할 수 없는 경우라면,
    • unwrap 혹은 expect를 호출하는 것이 적절
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
  • 여기서는 하드코딩된 문자열을 파싱하여 IpAddr 인스턴스를 만드는 중
  • 127.0.0.1이 유효한 IP 주소라는 사실을 알 수 있으므로, 여기서는 expect의 사용이 허용됩니다.
  • 하지만 하드코딩된 유효한 문자열이라는 사실이 parse 메서드의 반환 타입을 변경해 주지는 않습니다:
  • 만일 IP 주소 문자열이 프로그램에 하드코딩된 것이 아니라 사용자로부터 입력되었다면, 그래서 실패할 가능성이 생겼다면, 더 견고한 방식으로 Result를 처리할 필요가 분명히 있습니다.

3.3. 에러 처리를 위한 가이드라인

  • 코드가 결국 나쁜 상태에 처하게 될 가능성이 있을 때는 -> 코드에 panic!을 넣는 것이 바람직
  • 나쁜 상태란 어떤 가정, 보장, 계약, 혹은 불변성이 깨질 때
  • 유효하지 않은 값이나 모순되는 값, 혹은 찾을 수 없는 값이 코드에 전달되는 경우
    • 이 나쁜 상태란 것은 예기치 못한 무언가이며, 이는 사용자가 입력한 데이터가 잘못된 형식이라던가 하는 흔히 발생할 수 있는 것과는 반대되는 것
    • 그 시점 이후의 코드는 매번 해당 문제에 대한 검사를 하는 것이 아니라, 이 나쁜 상태에 있지 않아야만 할 필요가 있음
    • 여러분이 사용하고 있는 타입 내에 이 정보를 집어넣을만한 뾰족한 수가 없습니다.
  • 만일 어떤 사람이 여러분의 코드를 호출하고 타당하지 않은 값을 집어넣었다면,
    • 가능한 에러를 반환하여 라이브러리의 사용자들이 이러한 경우에 대해 어떤 동작을 원하는지 결정할 수 있도록 하는 것이 가장 좋습니다.
  • 그러나 계속 실행하는 것이 보안상 좋지 않거나 해를 끼치는 경우라면
    • panic!을 써서 여러분의 라이브러리를 사용하고 있는 사람에게 자신의 코드에 있는 버그를 알려줘서 개발 중에 이를 고칠 수 있게끔 하는 것이 최선책일 수도 있습니다.
  • 비슷한 식으로, 여러분의 제어권에서 벗어난 외부 코드를 호출하고 있고, 이것이 고칠 방법이 없는 유효하지 않은 상태를 반환한다면, panic!이 종종 적절합니다.

  • 하지만 실패가 충분히 예상되는 경우라면 panic!을 호출하는 것보다 Result를 반환하는 것이 여전히 더 적절
    • 이에 대한 예는 잘못된 데이터가 제공된 파서나, 속도 제한에 도달했음을 나타내는 상태를 반환하는 HTTP 요청 등
  • 이러한 경우, Result를 반환하면 호출자가 처리 방법을 결정해야 하는 실패 가능성이 예상된다는 것을 나타냄

  • 코드가 유효하지 않은 값에 대해 호출되면 사용자를 위험에 빠뜨릴 수 있는 연산을 수행할 때, 그 코드는 해당 값이 유효한지를 먼저 검사하고, 만일 그렇지 않다면 panic!을 호출해야 합니다.
  • 이는 주로 보안상의 이유입니다:
  • 유효하지 않은 데이터에 어떤 연산을 시도하는 것은 코드를 취약점에 노출시킬 수 있음
  • 범위를 벗어난 메모리 접근을 시도했을 경우 표준 라이브러리가 panic!을 호출하는 주된 이유
    • 현재 사용하는 데이터 구조가 소유하지 않은 메모리에 접근 시도하는 것은 흔한 보안 문제
  • 종종 함수에는 입력이 특정 요구사항을 만족시킬 경우에만 함수의 행동이 보장되는 계약이 있음
    • 이 계약을 위반했을 때는 패닉을 발생시키는 것이 이치에 맞는데,
    • 그 이유는 계약 위반이 항상 호출자 쪽의 버그임을 나타내고,
    • 이는 호출하는 코드가 명시적으로 처리해야 하는 종류의 버그가 아니기 때문
  • 사실 호출하는 쪽의 코드가 복구시킬 합리적인 방법은 존재하지 않고, 호출하는 프로그래머가 그 코드를 고칠 필요가 있습니다.
  • 함수에 대한 계약은, 특히 계약 위반이 패닉의 원인이 될 때는, 그 함수에 대한 API 문서에 설명되어야 합니다.

  • 하지만 모든 함수 내에서 수많은 에러 검사를 한다는 것은 장황하고 짜증나는 일일 것
  • 다행히도 러스트의 타입 시스템이 (그리고 컴파일러에 의한 타입 검사 기능이) 여러분을 위해 수많은 검사를 해줄 수 있습니다.
  • 함수에 특정한 타입의 매개변수가 있는 경우 컴파일러가 이미 유효한 값을 확인했으므로 코드 로직을 계속 진행할 수 있습니다.
  • 예를 들면, 만약 Option이 아닌 어떤 타입을 갖고 있다면, 여러분의 프로그램은 아무것도 아닌 것이 아닌 무언가를 갖고 있음을 예측합니다.
  • 그러면 코드는 Some과 None 배리언트에 대한 두 경우를 처리하지 않아도 됩니다:
    • 분명히 값을 가지고 있는 하나의 경우만 있을 것입니다.

3.4. 유효성을 위한 커스텀 타입 생성하기

  • 러스트의 타입 시스템을 사용해 유효한 값을 보장하는 아이디어에서 한 발 더 나가서, 유효성 검사를 위한 커스텀 타입을 생성하는 방법을 살펴봅시다.
  • 2장의 추리 게임을 상기해 보시면, 사용자에게 1부터 100 사이의 숫자를 추측하도록 요청했었죠.
  • 사용자의 추릿값을 비밀 번호와 비교하기 전에 추릿값이 양수인지만 확인했을 뿐, 해당 값이 유효한지는 확인하지 않았습니다.
  • 이 경우에는 결과가 그렇게 끔찍하지는 않았습니다:
    • ‘Too high’나 ‘Too low’라고 표시했던 출력이 여전히 정확했기 때문입니다.
  • 하지만 사용자가 올바른 추측을 할 수 있도록 안내하고,
    • 사용자가 범위를 벗어난 숫자를 입력했을 때사용자가 숫자가 아닌 문자 등을 입력했을 때 다른 동작을 하는 건 꽤 괜찮은 개선일 겁니다.
      • (1~100 사이의 값으로 추측해야한다는 가이드라인 주기)
  • 이를 위한 한 가지 방법은
    • u32 대신 i32로 추릿값을 파싱하여 음수가 입력될 가능성을 허용하고,
    • 그리고서 숫자가 범위 내에 있는지에 대한 검사를 아래와 같이 추가하는 것
    loop {
        // --생략--

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --생략--
    }
  • 루프의 다음 반복을 시작하고 다른 추릿값을 요청해 줍니다.
    • if 표현식 이후에는 guess가 1과 100 사이의 값임을 확인한 상태에서, guess와 비밀 숫자의 비교를 진행할 수 있습니다.
  • 하지만 이는 이상적인 해결책이 아닙니다.
    • 만약 프로그램이 오직 1과 100 사이의 값에서만 동작한다는 점이 굉장히 중요한 사항이고 많은 함수가 동일한 요구사항을 가지고 있다면,
      • 모든 함수 내에서 이런 검사를 하는 것은 지루한 일일 겁니다.
      • (게다가 성능에 영향을 줄지도 모릅니다.)
  • 그대신 새로운 타입을 만들어서, 그 타입의 인스턴스를 생성하는 함수에서 유효성을 확인하는 방식으로 유효성 확인을 모든 곳에서 반복하지 않게 할 수 있습니다.
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
  • new라는 이름의 연관 함수를 구현
  • 만일 value가 이 테스트에 통과하지 못하면 panic!을 호출하며,
    • 이는 이 코드를 호출하는 프로그래머에게 고쳐야 할 버그가 있음을 알려주는데,
    • 범위 밖의 value로 Guess를 생성하는 것은 Guess::new가 요구하는 계약을 위반하기 때문
  • Guess::new가 패닉을 일으킬 수 있는 조건은 공개 API 문서에서 다뤄져야 합니다.

  • 다음으로, self를 빌리고, 매개변수를 갖지 않으며, i32를 반환하는 value라는 이름의 메서드를 구현
    • 이러한 종류의 메서드를 종종 게터 (getter) 라고 부르는데,
      • 그 이유는 이런 함수의 목적이, 객체의 필드로부터 어떤 데이터를 가져와서 반환하는 것이기 때문
    • 이 공개 메서드가 필요한 이유는 Guess 구조체의 value 필드가 비공개이기 때문
    • value 필드는 비공개이기 때문에, Guess 구조체를 사용하는 코드는 value를 직접 설정할 수 없다는 것은 중요
  • 모듈 밖의 코드는 반드시 Guess::new 함수로 새로운 Guess의 인스턴스를 생성해야 하며,
    • 이를 통해 Guess가 Guess::new 함수의 조건에 의해 확인되지 않은 value를 가질 수 없음을 보장
profile
새로운 것이 들어오면 이미 있는 것과 충돌을 시도하라.

0개의 댓글