Option, Result, ?

undefcat·2023년 10월 8일
2

rust

목록 보기
6/6
post-thumbnail

Option

요즘 언어들은 null을 안전하게 처리하는 방식을 많이 도입하고 있습니다. Java의 경우 8버전부터 Optional 타입을 도입하였고, Kotlin의 경우 애초에 타입 시스템에 nullable 타입이 포함되어 있습니다.

그 외에도 Null safty operator들도 많이들 추가되고 있는 추세인데요. ?? 혹은 ?. 와 같은 연산자들을 여러 언어들에서 많이 확인할 수 있습니다.

러스트는 좀 더 극단적으로 null을 처리했는데요. 바로 null 값 자체를 없애버린 방식을 취했습니다. 대신 Java의 Optional과 비슷한 방식으로 Option 타입을 사용하도록 합니다.

Optionprelude에 포함된 Enum 타입인데요. 다음과 같이 정의되어 있습니다.

pub enum Option<T> {
    None,
    Some(T),
}

Option 뿐만 아니라 NoneSome 역시 prelude에 포함되어 있습니다. 따라서 별다른 use 없이 바로 사용할 수 있습니다.

사용법은 간단합니다. 만약 어떤 값이 존재하지 않을 수 있는 상황이라면, Option 값을 사용하면 됩니다. 가장 흔한 예는 아직 초기화되지 않은 필드의 경우일 것입니다.

예를 들어, 일반적인 언어에서는 객체의 필드가 나중에 초기화되는 경우가 심심찮게 있습니다.

class User {
	private String name;
    
    public void setName(String name) {
    	this.name = name;
    }
    
    public String getName() {
        return this.name;
    }
}

// main
User user = new User();
System.out.println(user.getName()); // null

위의 자바코드는 정상적으로 컴파일이 되고, 만약 setName()으로name 필드값을 설정하지 않은 상태에서 getName()을 호출하면 null 값이 리턴될 것입니다.

러스트의 경우, 애초에 컴파일이 되지 않습니다.

struct User {
	name: String,
}

impl User {
	fn set_name(&mut self, name: String) {
		self.name = name;
	}

	fn get_name(&self) -> String {
		self.name.clone()
	}
}

// main
// missing field `name` in initializer of `User` [E0063] missing `name`
let user = User {};
println!("{}", user.get_name());

즉, 애초에 초기화되지 않은 필드로 User를 생성할 방법이 없습니다. 이렇게 초기화되지 않은 필드는 Option 타입으로 선언해줘야 합니다.

struct User {  
    name: Option<String>,  
}  
  
impl User {  
    fn set_name(&mut self, name: String) {  
        self.name = Some(name);  
    }  
    fn get_name(&self) -> Option<String> {  
        self.name.clone()  
    }
}

// main
let user = User {  
	name: None, // or Some("name".to_string())  
};
println!("{}", user.name); // None

러스트는 이렇게 값이 있을수도, 없을수도 있는 경우엔 Option을 사용하여 반드시 이에 대한 처리를 강제합니다. 예를 들어, 위의 자바코드의 경우 name의 값을 String으로 단정하고 관련 메서드를 호출하면 NPE 에러를 만나게 될 것입니다.

String name = user.getName();
System.out.println(name.getName().toUpperCase()); // NPE!

하지만 러스트의 경우, 애초에 null 개념이 없어서 Option으로 선언할 수밖에 없고, 이 Option을 처리해야만 코드를 작성할 수 있습니다.

let maybe_name = user.get_name();
match maybe_name {
	Some(name) => {
		println!("{}", name.to_uppercase());
	}
	None => {
		// name이 없는 경우 코드
	}
}

물론 자바 역시 Optional 타입을 이용하면 안전하게 처리할 수 있지만, 언어 자체가 이를 강제하는 구조는 아닙니다. 하지만 러스트는 위에서도 언급했듯, null이라는 값 자체가 없기 때문에 러스트에서는 어떤 변수에는 항상 값이 존재해야만 합니다. 그렇지 않으면 컴파일러가 에러를 냅니다.

Result

러스트에는 null 뿐만 아니라, 일반적인 예외처리 방식인 try-catch와 같은 개념이 존재하지 않습니다. 대신, 러스트는 Option과 비슷한 Result enum이 존재하며 마치 C언어나 Golang과 같이 에러를 바로바로 처리하도록 합니다.

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

ResultOkErr로 구성되어 있으며, 직관적으로 이해가 가능합니다. Option과 비슷하게 성공/실패로 나뉘는 경우 Result 타입의 값을 리턴하며, 이를 반드시 해결해야만 값을 사용할 수 있습니다.

let result = try_processing();

let success = match result {
	Ok(v) => {
		println!("{}", v);

		// Ok인 경우 `v`값을 `success`에 할당한다.
		v
	}

	Err(e) => {
		// 에러인 경우 에러를 보고하고 함수를 종료한다.
		eprintln!("{}", e);
		return;
	}
}

// 이후 `success` 값을 이용한 코드

사실 개인적으로 try-catch 방식은 다음과 같은 이유로 좋아하는 편은 아닙니다.

  1. 코드의 들여쓰기로 인해 가독성이 떨어진다.
  2. try문에서 정확히 어떤 시점에 예외가 발생하는지 확인하기가 힘들다.
    • 그렇다고 예외가 발생하는 모든 지점마다 try-catch를 쓰는 순간 가독성은 더 떨어진다.
  3. 보통 try-catch는 스코프를 만들어내기 때문에, try-catch문 이후에 무언가 값을 사용해야 한다면 변수를 밖에서 선언하고 내부에서 초기화하는 코드를 작성하게 되는데, 이 역시 가독성이 떨어진다.

결국 try-catch는 어쨌든 가독성이 떨어지기 때문에 좋아하는 편은 아닙니다. 러스트의 접근방법은 에러처리를 무조건 강제하며, 제어흐름 구조가 변경되지 않기 때문에 위에서 아래로 그냥 코드를 쭉 읽어나갈 수 있습니다.

하지만 단점 역시 존재하는데, 에러가 발생하는 지점과 이를 해소하는 지점이 동일하지 않은 경우 코드를 매우 반복적으로 작성해야된다는 점입니다.

let result1 = try_processing1();
let v1 = match result1 {
	Ok(v) => v,
	Err(e) => return Err(e),
};

let result2 = try_processing2(v1);
let v2 = match result2 {
	Ok(v) => v,
	Err(e) => return Err(e),
};

let result3 = try_processing3(v2);
let v3 = match result3 {
	Ok(v) => v,
	Err(e) => return Err(e),
};

// ...

Ok(v)

이는 비단 러스트의 문제만이 아니라, Golang 역시 마찬가지입니다. Golang의 경우 리턴을 여러개할 수 있으며(일종의 튜플구조), 이를 이용해 에러를 처리합니다.

v1, err := tryProcessing1()
if err != nil {
	return "", err
}

v2, err := tryProcessing2(v1)
if err != nil {
	return "", err
}

v3, err := tryProcessing3(v2)
if err != nil {
	return "", err
}

// ...

return v, nil

일반적인 예외처리 구조를 가진 언어는 try_processing류의 함수들이 에러상황의 경우 예외를 던져버리기 때문에, 해당 함수에서 에러를 해소할 필요가 없다면 훨씬 더 간단하게 코드를 작성할 수 있습니다.

try {
	var v1 = tryProcessing();
	var v2 = tryProcessing(v1)
	var v3 = tryProcessing(v2);

	// ...
	return v;
} catch (Exception e) {
	// error handling
	// ...
	throw e;
}

러스트 역시 이런 문제점을 잘 알고 있으며, 이에 대한 해결책을 갖고 있습니다.

? 연산자

러스트 문법에는 ? 연산자가 존재합니다. 이 연산자는 Option, Result와 함께 사용할 수 있는데요. OptionResult의 네거티브한 경우 이를 전파하는 역할을 합니다. 즉, Option::NoneResult::Err를 코드레벨에서 처리하지 않고 바로 종료할 수 있는 단축표현을 제공합니다.

fn search_value() -> Option<String> {  
    // finding...
	if found {
		None
	} else {
		Some(value)  
	}
}  

fn find_value() -> Option<String> {
	// `?` 연산자를 통해
	// Option에서 Some 값 추출을 시도한다.
    let result: String = search_value()?;  
  
    Some(result)  
}

// main
match find_value() {
	Some(v) => {
		// ...
	}

	None => {
		// ...
	}
}

위의 예제 코드를 보면 알 수 있듯이, ? 연산자를 Option 타입의 값에 사용했으며, 만약 Some이면 바로 래핑된 값을 획득하고 None이면 바로 리턴됩니다. 이는 Result 역시 마찬가지며, 따라서 이전에 봤던 에제 코드는 ?와 함께 사용하면 다음과 같이 변경될 수 있습니다.

let v1 = try_processing1()?;
let v2 = try_processing2(v1)?;
let v3 = try_processing3(v2)?;
// ...

Ok(v)

코드가 매우 간결해졌습니다. 아쉽게도 Golang에서는 이런식의 문법이 없기 때문에 여전히 에러를 각각 처리해야만 합니다.

물론 ? 연산자를 사용하는 경우, 당연하지만 모든 타입이 같아야 합니다. 즉, Option<T>의 경우 ? 연산자가 사용되는 부분은 모두 Option<T> 타입이어야만 하며, Result<T, E>의 경우 모두 Result<T, E> 타입이어야만 합니다.

특히 Result의 경우 까다로운점은 바로 에러 타입입니다. 에러의 경우 보통 크레이트마다 각 고유의 에러타입들이 존재하기 때문에 이를 하나의 타입으로 고정한다는 것은 사실상 불가능에 가깝기 때문입니다.

하지만 이 역시 우회할 수 있는 방법들이 있습니다.

Box<dyn std::error::Error>

러스트에서는 에러 타입을 std::error::Error 트레이트를 기반으로 작성하는 것이 관례입니다. 따라서, 일반적으로 리턴되는 에러들이 모두 std::error::Error 트레이트 타입이라고 가정하면 이를 쉽게 해결 할 수 있습니다.

하지만 러스트에서 그냥 std::error::Error 타입을 리턴한다고 할 수 없는 것이 문제입니다. 하나하나 차근차근 따라가보겠습니다.

// Trait objects must include the `dyn` keyword [E0782]
fn try_processing() -> Result<String, std::error::Error> {
	// ...
}

컴파일 오류 메세지를 읽어보면, 트레이트 객체는 반드시 dyn 키워드를 포함하라고 합니다. std::error::Error는 트레이트이기 때문에 이를 구현하는 오브젝트를 지칭하려면 dyn 키워드가 필요한 것 같습니다.

키워드를 추가합니다.

// the size for values of type `(dyn std::error::Error + 'static)`
// cannot be known at compilation time [E0277
// doesn't have a size known at compile-time
fn try_processing() -> Result<String, dyn std::error::Error> {
	// ...
}

이번엔 또 다른 오류를 내뱉습니다. 컴파일 타임에 크기를 알 수 없기 때문에 dyn std::error::Error 타입을 리턴할 수 없다고 합니다.

러스트의 모든 함수 리턴값은 스택 사이즈를 컴파일 타임에 알 수 있어야 합니다(사실 다른 언어들 역시 모두 그렇습니다). 생각해보면 트레이트 오브젝트란 특정 트레이트를 구현하고 있는 오브젝트를 뜻하는데, 이 오브젝트는 너무나도 다양할 수 있기 때문에 컴파일 시점에 이 크기를 정확히 알 방법이 없습니다.

이를 해결하려면 dyn std::error::Error값이 스택이 아닌 힙에 할당되어 있어야만 합니다. 그리고 우리는 이 힙을 가리키는 일종의 포인터를 갖고 있으면 됩니다. 이 포인터 값 자체는 스택에 할당되어 있을테니까요.

러스트의 모든 값은 스택에 할당되는 것으로 취급됩니다. 특정 값을 힙에 할당하려면 러스트에서 제공하는 스마트 포인터를 사용해야만 합니다.

가장 기본적인 스마트 포인터는 Box<T> 타입으로, 이는 T를 힙에 할당하고 이를 가리키는 포인터 타입입니다. 즉, Box<T>에서 Box 자체는 당연하지만 스택에 할당되며, Box가 내부적으로 T를 힙에 할당한 뒤 이를 가리키고 있는 것입니다.

이렇게하면 러스트는 컴파일 타임에 Box의 크기를 알 수 있게 됩니다. 따라서 다음과 같이 코드를 수정하면 컴파일이 됩니다.

fn try_processing() -> Result<String, Box<dyn std::error::Error>> {  
    // ...  
}

재귀 타입

비슷한 이유로 러스트에서는 재귀타입을 그냥 선언할 수 없습니다. 예를 들어 일반적인 LinkedList 타입을 자바와 러스트에서 정의해봅시다.

class LinkedList<T> {
	private T value;
	private LinkedList<T> next;

	// ...
}
struct LinkedList<T> {
	value: T,
	next: Option<LinkedList<T>>,
}

자바에서는 컴파일이 가능하지만, 러스트에서는 불가능합니다. 러스트에서는 다음과 같은 오류가 발생합니다.

// recursive type `LinkedList` has infinite size [E0072]
struct LinkedList<T> {
	value: T,
	next: Option<LinkedList<T>>,
}

자바에서 컴파일이 가능한 이유는 자바의 경우 모든 객체들은 힙에 할당됨을 가정하고 있기 때문에, 이 값을 가리키는 필드나 변수들은 모두 일종의 포인터처럼 동작하기 때문입니다.

하지만 러스트는 스택에 할당됨을 가정하기 때문에 재귀적인 타입 선언의 경우 무한한 크기를 갖게 됩니다. 생각해보면 이는 매우 당연한 얘기이긴 합니다. 마찬가지의 이유로 Golang 역시 재귀적인 타입 선언은 불가능합니다.

type Environment struct {
    parent Environment // ❌
    symbol string
    value RCFAEValue
}

type Environment struct {
    parent *Environment // ✅
    symbol string
    value RCFAEValue
}

생각해보면 매우 당연한 동작이라고 볼 수 있습니다. 러스트에서는 스마트 포인터를 이용해 이를 달성할 수 있습니다.

struct LinkedList<T> {
	value: T,
	next: Option<Box<LinkedList<T>>>,
}

정리

  • 러스트는 null이 없기 때문에 아주 확실한 null-safety 언어라고 볼 수 있다.
  • 러스트는 Option을 통해 값의 유무를 나타내고, Result를 통해 성공/실패를 나타낸다.
  • ? 연산자를 통해 Option::NoneResult::Err를 간단하게 전파할 수 있다.
  • 러스트에서 모든 값은 스택에 할당됨을 가정하며, 힙에 할당하려면 스마트 포인터를 사용해야만 한다.
profile
undefined cat

0개의 댓글