러스트에서는 코드가 컴파일 되기 전에, 에러의 가능성을 인지하고 조치를 취해야 합니다.
- 러스트는 에러를 2가지 범주로 묶음
- 복구 가능한 (recoverable) 에러
- 예: 파일을 찾을 수 없음
- 대부분의 경우 그저 사용자에게 문제를 보고하고 명령을 재시도하도록 하길 원함
Result<T, E> 타입
사용
- 복구 불가능한 (unrecoverable) 에러
- 예: 배열 끝을 넘어선 위치에 접근하는 경우
- 언제나 버그 증상이 나타나는 에러이며, 따라서 프로그램을 즉시 멈추기를 원합니다.
프로그램을 종료하는 panic! 매크로
사용
- 대부분의 언어는 예외 처리 (exception) 와 같은 메커니즘을 이용하여, 이 두 종류의 에러를 구분하지 않고 같은 방식으로 처리
1. panic!으로 복구 불가능한 에러 처리하기
패닉을 일으키는 두 가지 방법
- (배열 끝부분을 넘어선 접근과 같이)
코드가 패닉을 일으킬 동작을 하는 것
panic! 매크로를 명시적으로 호출하는 것
- 두 경우 모두 프로그램에 패닉을 일으킵니다.
- 기본적으로 이러한 패닉은
- 실패 메시지를 출력하고,
- 되감고 (unwind),
- 패닉을 발생시킨 각 함수로부터, 스택을 거꾸로 훑어가면서 데이터를 청소한다는 뜻
- 스택을 청소하고,
- 종료
1.1. panic!에 대응하여 스택을 되감거나 그만두기
- 하지만 이 되감기와 청소 작업은 간단한 작업이 아닙니다.
- 그래서 러스트에서는 프로그램이
데이터 정리 작업 없이
즉각 종료되는 대안인 그만두기 (aborting) 를 선택할 수도 있습니다.
- 프로그램이 사용하고 있던 메모리는 운영체제가 청소해 주어야 합니다.
- 프로젝트 내에서 결과 바이너리를 가능한 한 작게 만들고 싶다면, 아래와 같이 되감기를 그만두기로 바꿀 수 있습니다.
- Cargo.toml 내에서 적합한 [profile] 섹션에
[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을 구현한 타입
을 반환하는 함수에서만 사용될 수 있음
- 이 에러를 고치기 위해서는 두 가지 선택지가 있습니다.
- ? 연산자가 사용되는 곳의 값과 호환되게 함수의 반환 타입을 수정하는 것
- 이러한 수정을 막는 제약 사항이 없는 한에서 가능
- Result<T, E>를 적절한 식으로 처리하기 위해 아래 2가지 중 하나를 사용하는 것
- 에러 메시지는 또한 ?가
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를 가질 수 없음을 보장