C++에서 오류 코드를 리턴하는 다양한 방법들
이전 글에서, C++에서 에러(std::exception
)를 던지는 방법에 대해서 알아보았고, 구글은 에러를 사용하지 않는다고 하였다. 그렇다면 에러를 던지지 않고, 오류 코드를 반환하는 방법은 무엇이 있을까?
당연하지만 고전적인 C언어 식의 방법, 즉 매크로로 오류 코드를 정의해놓고, 이를 반환하는 방법은 굳이 설명하진 않겠다. 이 방법은 C++에서도 사용할 수 있지만, C++에서는 더 좋은 방법들이 존재한다.
결론부터 말하면 이 글에서는 std::optional
, std::variant
, std::pair
, std::tuple
, absl::Status
, std::expected
에 대해서 살펴볼 것이다.
std::optional
공식 문서: std::optional
std::optional
은 C++11부터 도입된, 선택적인 자료형이다. 여기서 선택적이라는 말은, 값이 들어있을 수도 있고, 들어있지 않을 수도 있음을 의미한다. 값이 들어있지 않을 떄는 std::nullopt
로 표현된다. 만약 값이 들어있다면, std::optional::value()
함수로 값을 꺼내온다(이떄 만약 값이 들어있지 않다면, std::bad_optional_access
라는 exception을 던진다).
값이 들어있는지 여부를 확인할 때에는 has_value()
라는 함수로 체크해주면 된다. 그냥 예시를 직접 보자. 아래 에러를 던지는 코드의 형태를, std::optional
을 사용하여 바꿔보자.
#include <iostream>
int divide(int a, int b) {
if (b == 0) {
throw std::runtime_error("Cannot divide by zero");
}
return a / b;
}
int main() {
int a, b;
std::cin >> a >> b;
try {
std::cout << divide(a, b) << '\n';
} catch (const std::exception& e) {
std::cout << e.what() << '\n';
}
}
위 코드를 std::optional
을 사용해서 바꾸어보자.
#include <iostream>
#include <optional>
std::optional<int> divide(int a, int b) {
if (b == 0) {
return std::nullopt;
}
return a / b;
}
int main() {
int a, b;
std::cin >> a >> b;
auto result = divide(a, b);
if (result.has_value()) {
std::cout << result.value() << '\n';
} else {
std::cout << "Cannot divide by zero\n";
}
}
만약 divide
함수가 std::nullopt
를 반환했다면, 이것이 에러가 되는 거고, 정상적인 처리 과정을 통해 값을 반환했다면, std::optional::value
로 값을 꺼내옴을 알 수 있다.
여기서 더 나아가서, dart의 ?
관련 연산자처럼 사용하는 것도 가능하다. 무슨 말이냐면, 아래 dart 코드를 보자.
void printer(String? str) {
print(str ?? "null");
}
위 dart 코드에서 만약 str이 null이면, print
함수는 "null" 을 출력한다. 반대로 str이 값이 들어있다면, str 값을 출력할 것이다. 위 코드를 C++의 optional 스타일로 바꾸어보면 아래와 같다.
void printer(std::optional<std::string> str) {
std::cout << (str.value_or("null")) << '\n';
}
std::optional::value_or
함수는 값이 있다면 그 값을 쓰고, 없다면 지정한 대체 값을 반환하는 함수이다.
std::optional
은 도입된 이후로 꾸준히 사랑받고 있으며, 유용하고 많이 사용돼서 알아두는 게 좋다. 여러 연산자들을 지원하는데, 자세한 내용은 공식 문서를 참고하되, 몇 가지만 살펴보고 가자.
만약 value
로 매번 값을 접근하기 귀찮다면, operator*
연산자를 쓰거나 operator->
를 쓰면 된다.
std::optional<User> user = getCurrentUser();
if (user) { // user.has_value랑 같은 의미임
std::cout << user->name << '\n'; // 같은 결과
std::cout << *user.name << '\n'; // 같은 결과
}
std::variant
공식 문서: std::variant
std::variant
는 C++17부터 도입된, 여러 가지 타입을 동시에 저장할 수 있는 타입이다. 사실 원래는 Union(유니온)이라는 구조체를 더 안전하게 사용하기 위한 타입이다.
유니온이 뭔지부터 알아보자. 유니온은 사실 C언어에서부터 존재했는데, 여러 타입을 동시에 저장할 수 있는 구조체이다. 예를 들어, 아래와 같은 유니온을 정의할 수 있다.
union MyUnion {
int a;
double b;
char c;
};
위에서 MyUnion
은 int
, double
, char
메모리들이 한 공간을 공유한다. 그러면서 메모리 공간을 절약할 수 있다. 사용할 때는 아래와 같이 사용할 수 있다.
MyUnion u;
u.a = 1;
std::cout << u.a << '\n'; // 1
u.b = 3.14;
std::cout << u.b << '\n'; // 3.14
그런데 유니온 자체는 여러가지 구조적 문제점을 안고 있는데, 일단 만약에 double
형의 값을 지정해놓고서 int
형태로 읽는다면, 이는 undefined behavior, 즉 정의되지 않은 행동이라면서 예측할 수 없는 결과를 가져온다. 그래서 이 취약점을 해결하기 위해 std::variant
가 도입됐다.
#include <iostream>
#include <variant>
struct S {
int a;
double b;
char c;
};
union U {
int a;
double b;
char c;
};
int main() {
std::variant<int, double, char> v;
std::cout << sizeof(S) << '\n'; // 24
std::cout << sizeof(U) << '\n'; // 8
std::cout << sizeof(v) << '\n'; // 16
}
혹시 위의 코드를 실행했을 때 나오는 결과를 예측할 수 있겠는가?
24
8
16
왜 이런 결과가 나오는 지 잠깐 설명하고 가자면, 먼저 struct
와 union
은 크게 어려울 게 없다. struct
의 경우 메모리 공간을 공유하지 않고 따로 각각 할당된다. 이때 컴파일러 최적화를 위해서, 메모리 공간 상에 패딩이라는 영역을 주게 된다. 패딩을 주어서, 전체적인 크기는 구조체 중 가장 큰 메모리 공간을 차지하는 원소의 배수로 주어진다. 여기서는 double
이 8 바이트 크기로 가장 큰 영역을 차지하고 있다. 그래서 int
, char
은 각각 4바이트, 1바이트지만, 이 과정에서 그냥 8바이트로 패딩이 추가되면서 총 24바이트가 그 크기가 8byte의 배수로 정해진다.
그림 상으로 표현하면 아래와 같다.
그리고 union
은 메모리 공간을 공유하기 때문에, 가장 큰 메모리 공간을 차지하는 double
의 크기인 8바이트만 할당된다. 그래서 8바이트가 그 크기가 된다.
하지만 std::variant
는, 내부적으로 어떤 타입으로 할당되었는지를 알려주는 정보가 추가로 들어가있다. 그래서 8바이트의 메모리가 추가로 할당되어 있어서, union
보다 8바이트가 더 많은 16바이트가 할당된다.
이제 std::variant
를 사용해보자. union
대신 std::variant
를 사용하는 가장 큰 장점은, 할당된 타입이 아닌 다른 타입으로 값을 읽을 때 union
에서는 이를 undefined behavior로 정의했지만, std::variant
는 그대로 에러를 던진다는 점이다.
std::variant
는 std::get()
함수를 통해서 값을 읽어온다.
int main() {
std::variant<int, double, char> v;
v = 1; // v에 1 할당
std::cout << std::get<double>(v) << '\n'; // 에러 발생
}
여기서 int
를 v에 할당해놓고 double
형식으로 값을 읽어오려고 시도하고 있으므로, 에러가 발생한다. 즉 이 상황을 에러 핸들링을 통해 적절하게 처리할 수 있다는 건데, 이 에러 핸들링 자체가 싫다면(우리는 에러 핸들링 대신 사용할 대체 방법을 찾고 있으므로) std::get_if
함수를 사용하면 된다.
int main() {
std::variant<int, double, char> v;
v = 10;
if (auto value = std::get_if<double>(&v)) {
std::cout << *value << '\n';
}
else {
std::cout << "double 타입이 아닙니다.\n";
}
v = 3.14;
if (auto value = std::get_if<double>(&v)) {
std::cout << *value << '\n';
}
else {
std::cout << "double 타입이 아닙니다.\n";
}
}
결과:
double 타입이 아닙니다.
3.14
std::get_if
함수는 std::get
함수와 달리, 에러를 던지지 않고, 에러가 발생하면 nullptr
를 반환한다. 그래서 이를 활용해서 타입 체크를 할 수 있다.
std::variant
가 union
대비 더 좋은 점은, 호출자가 직접 constructor/destructor를 호출해주지 않아도 알아서 관리가 된다는 점이다.
std::variant<std::vector<int>, std::string> v;
v = std::string("hello");
std::cout << std::get<std::string>(sv) << std::endl;
// hello
v = std::vector<int>{1, 2, 3, 4, 5};
std::cout << std::get<std::vector<int>>(sv).size() << std::endl;
// 5
원래 만약 union
함수를 사용했다면 destructor/constructor를 직접 호출해야 한다.
단순히 이렇게 union
을 대체하는 역할 외에도, std::variant
는 에러 코드를 리턴하는 방식으로 사용 가능하다.
위에서 보여준 std::optional
의 예시를 다시 가져와보자.
std::optional<int> divide(int a, int b) {
if (b == 0) {
return std::nullopt;
}
return a / b;
}
위 함수는 문제는 없긴 한데, std::optional
의 경우 그 값이 유효한지 무효한지만 체크해주기 때문에, 더 많은 오류에 대한 정보를 담고 있지는 않는다. 이를 std::variant
로 바꾸어보면
std::variant<int, ErrorCode> divide(int a, int b) {
if (b == 0) {
return ErrorCode::kDivideByZero;
}
return a / b;
}
이렇게 바꿀 수 있다.
사용할 때는 이렇게 사용하자.
auto result = divide(10, 0);
if (auto value = std::get_if<ErrorCode>(&result)) {
std::cout << "에러 발생: " << *value << std::endl;
}
else {
std::cout << "결과: " << std::get<int>(result) << std::endl;
}
std::pair
공식 문서: std::pair
std::pair
는 두 개의 값을 묶어서 하나의 값으로 만들어주는 클래스이다. 사실 std::pair
는 은근히 많이 사용되는 타입이라 익숙할 것이다.
std::pair<int, double> p(1, 3.14);
std::cout << p.first << std::endl; // 1
std::cout << p.second << std::endl; // 3.14
const auto [x, y] = p;
std::cout << x << std::endl; // 1
std::cout << y << std::endl; // 3.14
참고로 위에 예시에서 볼 수 있듯이, std::pair
는 기본적으로 자동 언팩킹을 지원한다. auto
키워드와의 조합으로, 굳이 first
, second
로 값에 접근하는 것보다, 언팩킹을 통해서 값을 가져오는 것이 더 편리하고 바람직하다.
이를 이용해서 에러 코드를 리턴하는 함수를 만들어보자.
std::pair<int, ErrorCode> divide(int a, int b) {
if (b == 0) {
return {0, ErrorCode::kDivideByZero};
}
return {a / b, ErrorCode::kSuccess};
}
사용할 때는 이렇게 사용하자.
auto [result, error] = divide(10, 0);
if (error != ErrorCode::kSuccess) {
std::cout << "에러 발생: " << error << std::endl;
}
else {
std::cout << "결과: " << result << std::endl;
}
참고로 std::pair
는 다른 STL 컨테이너들, std::unordered_map
이나 std::map
등을 구성할 때 매우 유용하게 사용되니깐, 꼭 기억해두자.
std::tuple
공식 문서: std::tuple
std::tuple
은 사실 std::pair
와 거의 비슷한데, std::pair
는 값을 두 개만 들고 있는 한편 std::tuple
은 값을 여러 개 들고 있다는 점이 다르다. 튜플도 마찬가지로 자동 언팩킹을 지원한다.
std::tuple<int, double, std::string> t(1, 3.14, "hello");
const auto &[number, pi, message] = t;
std::cout << number << std::endl; // 1
std::cout << pi << std::endl; // 3.14
std::cout << message << std::endl; // hello
물론 자동 언팩킹을 사용하지 않고, std::get
함수를 사용하는 것도 가능하다. 다만 굳이 싶긴 하다. 가독성이 더 좋은 언팩킹이 있는데 싶다.
std::cout << std::get<0>(t) << std::endl; // 1
std::cout << std::get<1>(t) << std::endl; // 3.14
std::cout << std::get<2>(t) << std::endl; // hello
이를 이용해서 에러 코드를 리턴해보면
std::tuple<int, ErrorCode> divide(int a, int b) {
if (b == 0) {
return {0, ErrorCode::kDivideByZero};
}
return {a / b, ErrorCode::kSuccess};
}
auto [result, error] = divide(10, 0);
if (error != ErrorCode::kSuccess) {
std::cout << "에러 발생: " << error << std::endl;
}
else {
std::cout << "결과: " << result << std::endl;
}
다만 이럴 떄는 그냥 std::pair
를 사용하는 게 더 일반적이다. 보통 튜플은 함수가 여러 개의 값을 동시에 리턴하고 싶을떄 사용한다.
absl::Status
공식 문서: absl::Status
이전 글에서 구글은 absl::Status
, 혹은 absl::StatusOr
을 이용해서 에러 상태를 반환한다고 설명했다. 이에 대해서 더 자세히 알아보자.
absl::Status
는 absl
에서 정의하고 있는 에러 상태를 반환하는 함수이다.
absl::Status MyFunction(absl::string_view filename, ...) {
...
// encounter error
if (error condition) {
return absl::InvalidArgumentError("bad mode");
}
// else, return OK
return absl::OkStatus();
}
만약에 정상적인 상태인 경우, absl::OkStatus
를 반환한다.
그리고 중요한 것은, absl::Status
는 그 자체로 [[nodiscard]]
로 마크되어 있어서, 프로그래머는 이 에러를 반드시 읽어야 한다. 읽지 않으면 컴파일조차 되지 않는다. 즉 프로그래머가 에러를 그냥 무시하고 넘기는 것 자체를 허용하지 않는다.
에러를 체크할 때는 이렇게 하면 된다.
const auto status = MyFunction(...);
// Don't do this:
//
// if (my_status.code() == absl::StatusCode::kOk) { ... }
//
// Use the Status.ok() helper function:
if (!status.ok()) {
// handle error
}
공식 문서에서도 나와있지만, my_status.code() == absl::StatusCode::kOk
이런 식으로 비교할 필요 없이, ok()
함수로 직접 비교하라고 한다.
혹시나 에러 코드 확인하는 것을 무시하고 싶다면, IgnoreError()
함수를 사용하자. 이렇게 되면 [[nodiscard]]
속성이 무시될 것이다.
// Don't let caching errors fail the response.
StoreInCache(request, response).IgnoreError();
다만 에러 코드를 무시하는 것은 신중히 고려하자. exception을 사용하지 않는 구글의 코딩 스타일 가이드 상, 특별한 이유 없이 에러를 무시하는 것은 자칫 큰 문제를 일으킬 수 있다.
상태 혹은 값 반환하기
한편 absl::StatusOr<T>
는 에러 코드 혹은 T
타입의 값을 반환하는 함수에서 사용된다. 예를 들어서 absl::StatusOr<int>
는 int
타입의 값을 반환하거나, 에러 코드를 반환한다.
StatusOr<Foo> result = Calculation();
if (result.ok()) {
result->DoSomethingCool();
} else {
LOG(ERROR) << result.status();
}
StatusOr<std::unique_ptr<Foo>> result = FooFactory::MakeNewFoo(arg);
if (!result.ok()) {
LOG(ERROR) << result.status();
} else if (*result == nullptr) {
LOG(ERROR) << "Unexpected null pointer";
} else {
(*result)->DoSomethingCool();
}
참고로 absl::Status
에 관한 모든 예제들은 공식 문서에서 직접 긁어온 것들이다. 그러니 자세한 사용법을 더 알고 싶다면, 꼭 공식 문서를 참고하자.
그리고 하나 주의할 것이, std
가 붙지 않고 absl
이 붙었다. 이 말은 cpp에서 기본적으로 지원해주는게 아니라, 직접 absl
라이브러리를 설치해야 한다는 의미다.
참고로 absl
은 구글에서 자체 개발한 cpp 라이브러리인데, boost
라이브러리만큼이나 다양하고 큰 자체적인 생태계를 가지고 있다. Status
외에도, absl::string_view
나 absl::flat_hash_map
등의 자체적인 구조체를 갖고 있기도 하다. 특히 이러한 해시맵에 관련돼서는, CPP의 std::unordered_map
은 선형 탐색에 굉장히 취약한 모습을 보이는 등 여러 성능적인 문제점을 갖고 있는데, absl 라이브러리의 여러 컨테이너 옵션들은 STL의 단점이나 한계들을 보완해준다.
그 외에도 다양한 유틸성 기능들을 지원해주기도 하니, 궁금하다면 absl의 공식 문서를 참고하자.
std::exception
공식 문서: std::exception
마지막으로, C++23에 도입된 std::exception
에 대해서 이야기해보자. 결론부터 말하면, 굳이 사용할 필요는 없다. absl::StatusOr<T>
와 비슷한데, 문제는 C++23에 도입됐다는 이야기는 아직 충분히 검증되지 않았다는 이야기다. 사실 새 기능이 도입되면 무작정 이를 사용하기 보다는, 2-3년 정도는 지켜보다가 충분히 생태계가 구축되고 사용하는 사람들이 많아지면 그때부터 도입을 검토해보는 것을 추천한다.
template< class T, class E >
class expected;
위와 같이 정의되어 있고, 각각 T는 값, E는 에러 타입이다.
std::expected<int, ErrorCode> divide(int a, int b) {
if (b == 0) {
return std::unexpected(ErrorCode::kDivideByZero);
}
return a / b;
}
에러 체크할 때는 이렇게 체크하면 된다.
auto result = divide(10, 0);
if (result.has_value()) {
std::cout << *result << '\n';
}
else {
std::cout << "Error: " << result.error() << '\n';
}
그 외에도 value_or()
함수도 지원한다. 자세한 사항은 공식 문서를 꼭 참고하자.
마치며
C++에서 사용 가능한, std::exception
외에 다양한 옵션들을 알아보았다. 언제나 그렇지만, 새로운 것을 무작정 도입하기 보다는, 기존 개발 환경과 스타일, 팀의 정책 등을 검토하고서 도입할 만한 장점이 있을 때 도입하는 게 좋다. 그럼에도 단순히 익셉션을 던지는 것 외에 다양한 옵션들이 있다는 것은 알아둘 필요가 있다.
그리고 항상 이런 글을 쓸 때마다 붙이진 않았지만 강조하고 싶은 것은, 모든 기능을 다 알 필요가 없다. 단지 이러한 옵션이 있다는 것을 기억해두었다가, 나중에 필요할 때 검색해서 사용할 수 있어야 한다. 그리고 그렇기 때문에 언제든지 필요할 때마다 공식 문서를 검색해서 참고할 수 있는 능력 역시 중요하고, 또한 2023년 현재는 ChatGPT를 활용하는 것도 중요한 능력이 될 것이다.
참고 자료
- std::optional, cppreference
- C++17 Optional 선택적 변수, 코드없는 프로그래밍
- C++ Weekly - Ep 87 - std::optional, C++ Weekly With Jason Turner
- std::variant, cppreference
- C++ 17 std::variant, 코드없는 프로그래밍
- std::pair, cppreference
- std::tuple, cppreference
- absl::Status, abseil
- std::expected, cppreference
- google c++ style guide - Exceptions, google
- 씹어먹는 C++ - <22. 구글에서는 C++ 을 어떻게 쓰는가?>, 모두의 코드