어떤 함수의 리턴값이 존재하거나 존재하지 않는 경우 리턴타입으로 Option<T>
를 쓴다. Option<T>
는 Null Pointer Dereference(널 포인터 역참조) 처럼 Null로 리턴되는 값을 Null이 아닌 값처럼 사용할때 발생할 수 있는 문제점을 사전에(컴파일 타임에) 방지하고자 만들어진 열거형 타입이다.
그런데 사실 Option<T>
를 사용하지 않아도 문제는 없다. 다만 함수의 안전성을 보장하지 못할 뿐이다. Rust는 안전성을 최우선으로 하는 언어이다. 사후 발생할 소지가 있는 대부분의 오류를 컴파일 타임에 잡는것으로 안전성을 보장한다. 그래서 코드 작성에 제약이 많고 번거롭기도 하고 복잡하다. 그것을 코드 작성자가 비용으로 지불하게 했다. 하지만 해당 코드의 장기간 유지보수 비용을 고려하면, 번거롭더라도 코드작성시 시간을 투자하는게 결과적으로 가장 저렴하게 비용을 지불하는것과 다름없긴 하다. 결론적으로 함수를 작성할때 리턴타입을 Option<T>
나 Result<T, E>
으로 하면 안전성이 증가한다고 볼 수 있다(컴파일이 실패할 가능성도 증가하지만 런타임에 오류가 발생할 가능성이 줄어든다).
Option<T>
fn devide_by_2(val: i32) -> Option<i32> {
if val == 0 {
None
} else {
Some(val / 2)
}
}
fn main() {
let x = 10;
let y = devide_by_2(0);
let z = x + y
println!("{}", z);
}
어떤 함수작성시 리턴타입이 Option<i32>
일때, 리턴 값이 None
이 아니라면, i32
가 아니라 Some(i32)
로 리턴한다. 따라서 Some(i32)인 경우는 해당 리턴값을 처리하는 Caller쪽에서 i32
로 한꺼풀 벗겨(un-wrapping) 줘야한다. 위의 예시의 경우 x는 i32
타입인데 y는 Option<i32>
타입이다. Rust는 타입 추론(Type Inference)에 제약이 많은 언어이기 때문에, x + y
는 타입이 맞지않아 컴파일 에러가 발생한다. 이렇게 Option<T>
나 Result<T, E>
를 un-wrapping하는 방법은 다음과 같이 4가지가 있다.
match
에서 Some(n)이나 Ok(n)인경우 n값을 뽑아내 직접 처리를 할수있다. {} 블록으로 추가적인 동작을 수행하는것도 가능하다. match 사용시 모든 분기를 체크하지 않으면 컴파일 에러가 발생한다. 따라서 컴파일러가 코너케이스를 예방할 수 있도록 모든 분기 처리를 돕는다.
let z = x + match y {
Some(num) => num,
None => 0,
};
또는
let mut z = -1;
match y {
Some(num) => z = x + num,
None => println!("divided by zero")
}
let z = x + y.unwrap();
이 경우 y가 None일 때 패닉이 발생한다. None일때 패닉을 발생시키지 않고 다른값을 출력하고 싶으면 unwrap_or()
를 사용한다. 아래의 경우는 devide_by_2가 None일때 -1을 리턴한다.
/* unwrap_or() for no panic */
let y = devide_by_2(0).unwrap_or(-1);
println!("{}", y);
unwrap_or_else()
를 사용하면 Closure 익명함수를 인자로 전달할수 있어, 더 다양한 동작을 수행할수 있다. 아래의 경우는 my_num 이 짝수면 0을 리턴.
/* unwrap_or_else() for no panic and more complicated calculate with passing closure */
let my_num = 100;
let y = devide_by_2(0).unwrap_or_else(|| {
if my_num % 2 == 0 {
0
} else {
-1
}
});
println!("{}", y);
let z = x + y.expect("devided by zero");
이 경우 y가 None일 때 패닉이 발생하고 지정한 메시지가 출력된다.
?
사용caller에서 호출한 함수에서 발생한 예외를 caller에서 그대로 반환하는 방법이다. 단 이방법은 devide_by_2를 호출하는 함수가 devide_by_2와 동일한 리턴타입일때 가능하다. 이 경우는 sum()의 리턴 타입이 Option 이어야 한다. 다시말하면 ?
는 예외발생시 호출 함수 리턴타입과 동일한 타입을 전달한다. 가령 아래의 경우 device_by_2(val2)가 None일때 sum()도 None이 리턴된다.
fn sum(val1: i32, val2: i32) -> Option<i32> {
let z = val1 + devide_by_2(val2)?;
Some(z)
}
fn main() {
println!("{}", sum(20, 30).unwrap());
}
가령 main에서 sum(20, 0)
을 호출하는 경우 device_by_2(0)에서 None
을 리턴시킬텐데 , 런타임 에러발생시 로그는 device_by_2(0)가 아니라 sum(20, 0)에서 None
이 발생했다고 알린다.
fn main() {
println!("{}", sum(20, 0).unwrap());
}
에러 로그
$ ./main
thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', main.rs:17:20
...
Result<T, E>
타입의 경우는 E
즉 Err 의 타입만 동일하면 된다.
만약 Result<(), Error>
처럼 성공할때 반환값()
이 없는 경우는 Err()
만 체크하면 된다. 그럴 때는 if let
을 사용한다. 이런경우 un-warpping할 값이 없기 때문에, unwrap
이나 unwrap_or_else
를 사용할 필요가 없다. 아래의 예시를 보면 run() 함수는 실패시 에러타입만 반환한다. caller또한 에러만 체크하면 되기 때문에 if let을 사용해 에러처리를 한다.
fn main() {
// --snip--
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
23/08/15 추가
:
Option의 개념은 원래 함수형 프로그래밍(FP)에서 넘어온 것으로, 위에(이전에) 기술한 코드의 안전성을 보장해주는것 이상의 역할을 한다는것을 알게 되었다(가령, 함수들을 Composition할때 예외(즉 side effect)를 핸들링해주는 역할, side effect 가 존재하는 함수를 순수함수 처럼 다룰수 있게 해주는 등). Rust는 멀티 패러다임 언어로써, FP는 그 중 가장 큰 축중 하나이다. Rust를 배울때 익숙치 않던 많은 개념들이 FP에 존재하는 것이었다. Rust를 공부하거나 작성할때, FP개념을 사용할 수 있다면 Rust를 더욱 강력하고 효과적으로 작성 수 있게 될것같다. FP는 현재 공부중이므로 추후 더 자세한 내용을 정리해보고자 한다.
Option
is maybe there, maybe not, Result
is it can work or not.Option
is for no result or result, Result
is for error or normal result.
매우 도움이 되는 내용이네요. 감사합니다