std::optional: 언제, 어떻게, 그리고 왜

원문은 std::optional: How, when, and why by Casey Carter이며 아래는 이를 요약 정리한 글이다.

때때로 값이 있어야 할 때

객체를 선택적으로 받거나 반환하는 함수를 작성할 때 값이 없음(absence)을 표현하는 전통적인 방법은 특정 값을 감시값(sentinel)으로 선택하는 것이다.

하지만 감시 값이 사용하는 값 범위에 속하거나 선택할 감시 값이 마땅치 않을 때는 사용할 수 없다. 이때는 부울 값을 사용해 선택적인 매개변수의 값이 유효한지를 나타내는 것이 전형적인 해결 방법이다.

하지만 maybe_take_an_int(42)처럼 부울 값을 제대로 전달하지 않으면 원하는 결과를 얻지 못 할 수 있다. pair는 이런 문제가 없으나 부울 값을 확인하지 않거나 int에 쓰레기 값을 전달할 수 있다. 또한 std::make_pair(42, true)std::make_pair(whatever, false)는 쓰기 불편하다.

아직 값이 없어야 할 때

객체를 선택적으로 담는 클래스에서는 해당 멤버 객체를 생성자에서 초기화할 수 없으며 요청할 때 처리해야 한다. 또한 해당 멤버 객체는 초기화했을 때만 소멸해야 한다. 이는 그 멤버 객체를 저장할 메모리를 직접 할당하고 bool로 초기화 상태를 추적하며 끔찍한 위치 지정 new 기법으로 해결할 수 있다.

주석의 ‘많은 코드’ 부분에는 원본과 대상 객체가, 초기화한 T를 담고 있는지 여부에 따라 올바로 처리할 수 있도록 작성한 복사/이동 생성자/대입 연산자가 있다. 이는 끔찍하게 지저분하고 깨지기 쉬우며, 작은 실수 하나로도 미정의 행위로 떨어질 수 있는 절벽 가장자리를 걷는 것과 같다.

위 문제는 선택적인 값을 동적으로 할당하고 포인터로 전달해 해결할 수 있다. 널 포인터로 값이 없음을 나타내고 *로 값에 접근한다. std::unique_ptr<int>(42)return 42에 비해 조금 보기 안 좋을 뿐 unique_ptr은 할당한 객체를 자동으로 해제한다. 물론 단순히 정수를 반환하는 것에 비해 더 많은 비용을 물어야 한다.

optional이 필수

C++17에서는 std::optional로 이 문제를 해결한다. optional<T>T 객체를 내부에 저장해 동적 할당은 사용하지 않는다. 사실 C++ 표준에서도 명시적으로 이를 금하고 있다.

선택적으로 T를 전달해야 하는 함수는 다음처럼 선언할 수 있다.

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로 변환을 통해 명시적으로 확인할 수 있다.

optional에 값이 있으면 * 연산자로 얻을 수 있다(값이 없을 때 사용하는 것은 미정의 행위이다).

검사하지 않고 value 멤버 함수로 바로 값을 얻을 수도 있는데 값이 없으면 bad_optional_access 예외가 발생한다.

value_or 멤버 함수는 optional 객체의 값이 없을 때 예외가 아니라 지정한 대체 값(fallback value)을 반환한다.

이를 통해 얻을 수 있는 이점은 다음과 같다.

  • ‘기본 값 반환’ 방법과 달리 값이 있을 때와 없을 때를 쉽게 구별할 수 있다.
  • 예외 처리를 사용하지 않고 값이 없음을 알릴 수 있다. 예외가 빈번하게 발생하면 처리 비용이 너무 크다.
  • 반환한 반복자와 비교할 ‘끝(end)’ 반복자를 드러내지 않아도 되므로 호출하는 쪽에 구현 상세를 노출하지 않을 수 있다.

지연 초기화 문제는 간단히 해결할 수 있다. optional<T> 멤버를 추가하면 된다. 표준 라이브러리 구현자는 위치 지정 new를 책임지고 올바로 처리해야 한다. std::optional은 모든 특별한 사례도 처리할 수 있도록 복사/이동 생성자/대입 연산자를 준비해 두고 있다.

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);보다 의도를 더 명확히 표현한다.

You may also like...