std::optional: 언제, 어떻게, 그리고 왜
원문은 std::optional: How, when, and why by Casey Carter이며 아래는 이를 요약 정리한 글이다.
때때로 값이 있어야 할 때
객체를 선택적으로 받거나 반환하는 함수를 작성할 때 값이 없음(absence)을 표현하는 전통적인 방법은 특정 값을 감시값(sentinel)으로 선택하는 것이다.
1 2 |
void maybe_take_an_int(int value = -1); // 인자 -1은 '값이 없음'을 뜻함 int maybe_return_an_int(); // 반환값 -1은 '값이 없음'을 뜻함 |
하지만 감시 값이 사용하는 값 범위에 속하거나 선택할 감시 값이 마땅치 않을 때는 사용할 수 없다. 이때는 부울 값을 사용해 선택적인 매개변수의 값이 유효한지를 나타내는 것이 전형적인 해결 방법이다.
1 2 3 |
void maybe_take_an_int(int value = -1, bool is_valid = false); void or_even_better(pair<int, bool> param = std::make_pair(-1, false)); pair<int, bool> maybe_return_an_int(); |
하지만 maybe_take_an_int(42)
처럼 부울 값을 제대로 전달하지 않으면 원하는 결과를 얻지 못 할 수 있다. pair
는 이런 문제가 없으나 부울 값을 확인하지 않거나 int
에 쓰레기 값을 전달할 수 있다. 또한 std::make_pair(42, true)
나 std::make_pair(whatever, false)
는 쓰기 불편하다.
아직 값이 없어야 할 때
객체를 선택적으로 담는 클래스에서는 해당 멤버 객체를 생성자에서 초기화할 수 없으며 요청할 때 처리해야 한다. 또한 해당 멤버 객체는 초기화했을 때만 소멸해야 한다. 이는 그 멤버 객체를 저장할 메모리를 직접 할당하고 bool
로 초기화 상태를 추적하며 끔찍한 위치 지정 new
기법으로 해결할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
using T = /* some object type */; struct S { bool is_initialized = false; alignas(T) unsigned char maybe_T[sizeof(T)]; void construct_the_T(int arg) { assert(!is_initialized); new (&maybe_T) T(arg); is_initialized = true; } T& get_the_T() { assert(is_initialized); return reinterpret_cast<T&>(maybe_T); } ~S() { if (is_initialized) { get_the_T().~T(); // destroy the T } } // ... 많은 코드 ... }; |
주석의 ‘많은 코드’ 부분에는 원본과 대상 객체가, 초기화한 T
를 담고 있는지 여부에 따라 올바로 처리할 수 있도록 작성한 복사/이동 생성자/대입 연산자가 있다. 이는 끔찍하게 지저분하고 깨지기 쉬우며, 작은 실수 하나로도 미정의 행위로 떨어질 수 있는 절벽 가장자리를 걷는 것과 같다.
위 문제는 선택적인 값을 동적으로 할당하고 포인터로 전달해 해결할 수 있다. 널 포인터로 값이 없음을 나타내고 *
로 값에 접근한다. std::unique_ptr<int>(42)
는 return 42
에 비해 조금 보기 안 좋을 뿐 unique_ptr
은 할당한 객체를 자동으로 해제한다. 물론 단순히 정수를 반환하는 것에 비해 더 많은 비용을 물어야 한다.
optional이 필수
C++17에서는 std::optional
로 이 문제를 해결한다. optional<T>
는 T
객체를 내부에 저장해 동적 할당은 사용하지 않는다. 사실 C++ 표준에서도 명시적으로 이를 금하고 있다.
선택적으로 T를 전달해야 하는 함수는 다음처럼 선언할 수 있다.
1 2 3 |
void maybe_take_an_int(optional<int> potential_value = nullopt); // 'potential_value = {}'와 같다 optional<int> maybe_return_an_int(); |
optional<T>
는 T
값으로 초기화할 수 있으므로 maybe_take_an_int
를 호출하는 쪽에서는 ‘값이 없음’을 표현하려고 명시적으로 -1
을 전달하는 게 아니면 고치지 않아도 된다. 마찬가지로 maybe_return_an_int
를 구현하는 쪽에서는 ‘값이 없음’을 표현하려고 -1
을 반환하는 곳만 nullopt
(또는 이와 같은 {}
)를 반환하도록 고치면 된다.
maybe_return_an_int
를 호출하는 쪽과 maybe_take_an_int
를 구현하는 쪽에서는 상당히 고쳐야 한다. optional
인스턴스에 값이 있는지는 has_value
멤버나 bool
로 변환을 통해 명시적으로 확인할 수 있다.
1 2 3 |
optional<int> o = maybe_return_an_int(); if (o.has_value()) { /* ... */ } if (o) { /* ... */ } // 'if'는 조건을 bool로 변환한다 |
optional
에 값이 있으면 *
연산자로 얻을 수 있다(값이 없을 때 사용하는 것은 미정의 행위이다).
1 |
if (o) { cout << "The value is: " << *o << '\n'; } |
검사하지 않고 value
멤버 함수로 바로 값을 얻을 수도 있는데 값이 없으면 bad_optional_access
예외가 발생한다.
1 |
cout << "The value is: " << o.value() << '\n'; |
value_or
멤버 함수는 optional
객체의 값이 없을 때 예외가 아니라 지정한 대체 값(fallback value)을 반환한다.
1 |
cout << "The value might be: " << o.value_or(42) << '\n'; |
이를 통해 얻을 수 있는 이점은 다음과 같다.
- ‘기본 값 반환’ 방법과 달리 값이 있을 때와 없을 때를 쉽게 구별할 수 있다.
- 예외 처리를 사용하지 않고 값이 없음을 알릴 수 있다. 예외가 빈번하게 발생하면 처리 비용이 너무 크다.
- 반환한 반복자와 비교할 ‘끝(end)’ 반복자를 드러내지 않아도 되므로 호출하는 쪽에 구현 상세를 노출하지 않을 수 있다.
지연 초기화 문제는 간단히 해결할 수 있다. optional<T>
멤버를 추가하면 된다. 표준 라이브러리 구현자는 위치 지정 new
를 책임지고 올바로 처리해야 한다. std::optional
은 모든 특별한 사례도 처리할 수 있도록 복사/이동 생성자/대입 연산자를 준비해 두고 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
using T = /* some object type */; struct S { optional<T> maybe_T; void construct_the_T(int arg) { // 반복해 초기화하는 것을 막지 않아도 된다. // optional의 emplace 멤버는 담고 있는 객체가 // 무엇이든 소멸하고 새로운 것을 담는다. maybe_T.emplace(arg); } T& get_the_T() { assert(maybe_T); return *maybe_T; // maybe_T를 초기화하지 않았을 때 예외 발생이 더 좋으면 // return maybe_T.value(); } // ... 오류가 생기기 쉽도록 손수 작성한 특별한 멤버 함수는 없다! ... }; |
optional
자체가 지연 초기화의 인스턴스이므로 지연 초기화 문제에 특히 적합하다. 포함한 T
는 생성자에서 초기화하거나 나중 또는 전혀 초기화하지 않을 수도 있다. optional
이 소멸할 때 포함한 T
는 반드시 소멸한다.
결론
‘값이 있거나 없거나’, ‘결과 값이 있을 수도 있거나’, 또는 ‘지연 초기화하는 객체’를 표현해야 하면 std::optional
을 선택하는 게 좋다. 이러한 사례에 어휘 타입(vocabulary type)을 사용하면 추상화 수준과 코드 이해도를 높일 수 있다. optional<T> f();
와 void g(optional<T>);
는 pair<T, bool> f();
나 void g(T t, bool is_valid);
보다 의도를 더 명확히 표현한다.