KoreanFoodie's Study

C++ 기초 개념 17-5 : std::optional, variant, tuple 본문

Tutorials/C++ : Beginner

C++ 기초 개념 17-5 : std::optional, variant, tuple

GoldGiver 2022. 5. 27. 12:22

모두의 코드를 참고하여 핵심 내용을 간추리고 있습니다. 자세한 내용은 모두의 코드의 씹어먹는 C++ 강좌를 참고해 주세요!

std::optional (C++17 이상 - <optional>)

예를 들어서 어떠한 map 에서 주어진 키에 대응하는 값이 있는지 확인하는 함수를 만들고 싶다고 해보자.

std::optional 을 사용하면 이를 깔끔하게 구현할 수 있다!

#include <iostream>
#include <map>
#include <string>
#include <utility>

std::optional<std::string> GetValueFromMap(const std::map<int, std::string>& m,
                                           int key) {
  auto itr = m.find(key);
  if (itr != m.end()) {
    return itr->second;
  }

  // nullopt 는 <optional> 에 정의된 객체로 비어있는 optional 을 의미한다.
  return std::nullopt;
}

int main() {
  std::map<int, std::string> data = {{1, "hi"}, {2, "hello"}, {3, "hiroo"}};
  std::cout << "맵에서 2 에 대응되는 값은? " << GetValueFromMap(data, 2).value()
            << std::endl;
  std::cout << "맵에 4 는 존재하나요 " << std::boolalpha
            << GetValueFromMap(data, 4).has_value() << std::endl;
}

// 실행결과
// 맵에서 2 에 대응되는 값은? hello
// 맵에 4 는 존재하나요 false

먼저 std::optional 의 정의부터 살펴보자.

std::optional<std::string>

위와 같이 템플릿 인자로 optional 이 보관하고자 하는 객체의 타입을 쓰면 된다. 해당 optional 객체는 std::string 을 보관하던지, 아니면 안하던지 둘 중 하나의 상태만을 가지게 된다.

그리고 GetValueFromMap 함수 안에서 키에 대응하는 값이 존재한다면 그냥 해당 값을 리턴한다. std::optional 에는 보관하고자 하는 타입을 받는 생성자가 정의되어 있기 때문에 위와 같이 그냥 리턴하더라도 optional 객체로 알아서 만들어져서 리턴된다.

이 때 optional 의 가장 큰 장점은, 객체를 보관하는 과정에서 동적 할당이 발생하지 않는다는 것이다. 따라서 불필요한 오버헤드가 없습니다. 쉽게 생각해서, optional 자체에 객체가 포함되어 있다고 보면 된다.

// nullopt 는 <optional> 에 정의된 객체로 비어있는 optional 을 의미한다.
return std::nullopt;

만약에 아무런 객체도 가지고 있지 않은 빈 optional 객체를 리턴하고 싶다면, 그냥 nullopt 객체를 리턴하면 된다. std::nullopt 는 미리 정의되어 있는 빈 optional 객체를 나타낸다.

GetValueFromMap(data, 2).value()

만일 optional 객체가 가지고 있는 객체를 접근하고 싶다면 value() 함수를 호출하면 된다. 주의해야할 점은 만일 optional 이 가지고 있는 객체가 없다면 std::bad_optional_access 예외를 던지게 된다. 따라서 반드시 optional 가 들고 있는 객체에 접근하기 전에 실제로 값을 가지고 있는지 확인해야 하는데, 이는

GetValueFromMap(data, 4).has_value()

처럼 has_value 함수를 사용하면 된다.

한 가지 유용한 팁으로 optional 객체 자체에 bool 로 변환하는 캐스팅 연산자가 포함되어 있으므로 아래 두 문장은 동일한 의미를 가진다.

// 동일한 의미
if (GetValueFromMap(data, 4))
if (GetValueFromMap(data, 4).has_value())

마찬가지로 value( ) 함수 대신 * (역참조) 를 사용해도 된다.

// 동일한 의미
GetValueFromMap(data, 2).value()
*GetValueFromMap(data, 2)

 

std::optional<T>  std::pair<bool, T> 와 가장 큰 차이점은 바로 pair 와는 달리 무 것도 들고 있지 않는 상태에서 디폴트 객체를 가질 필요가 없다 라는 점이다.

#include <iostream>
#include <utility>

class A {
 public:
  A() { std::cout << "디폴트 생성" << std::endl; }

  A(const A& a) { std::cout << "복사 생성" << std::endl; }
};

int main() {
  A a;

  std::cout << "Optional 객체 만듦 ---- " << std::endl;
  std::optional<A> maybe_a;

  std::cout << "maybe_a 는 A 객체를 포함하고 있지 않기 때문에 디폴트 생성할 "
               "필요가 없다."
            << std::endl;
  maybe_a = a;
}

// 실행 결과
// 디폴트 생성
// Optional 객체 만듦 ---- 
// maybe_a 는 A 객체를 포함하고 있지 않기 때문에 디폴트 생성할 필요가 없다.
// 복사 생성

이와 같이 std::optional 을 이용해서 어떠한 객체를 보관하거나 말거나 라는 의미를 쉽게 전달할 수 있다.

 

레퍼런스를 가지는 std::optional

std::optional 의 한 가지 단점으로는 일반적인 방법으로는 레퍼런스를 포함할 수 없다는 점이다. 예를 들어서 아래와 같이 레퍼런스에 대한 optional 객체를 정의하고 한다면

#include <iostream>
#include <map>
#include <string>
#include <utility>

class A {
 public:
  A() { std::cout << "디폴트 생성" << std::endl; }

  A(const A& a) { std::cout << "복사 생성" << std::endl; }
};

int main() {
  A a;

  std::optional<A&> maybe_a = a;
}

컴파일 에러가 발생한다. std::reference_wrapper 를 사용하면 레퍼런스처럼 동작하는 wrapper 객체를 정의할 수 있다.

#include <functional>
#include <iostream>
#include <optional>
#include <utility>

class A {
 public:
  int data;
};

int main() {
  A a;
  a.data = 5;

  // maybe_a 는 a 의 복사복이 아닌 a 객체 자체의 레퍼런스를 보관하게 된다.
  std::optional<std::reference_wrapper<A>> maybe_a = std::ref(a);

  maybe_a->get().data = 3;

  // 실제로 a 객체의 data 가 바뀐 것을 알 수 있다.
  std::cout << "a.data : " << a.data << std::endl;
}

 

 

std::variant (C++17 이상 - <variant>)

std::variant  one-of 를 구현한 클래스라고 보면 된다. 즉, 컴파일 타임에 정해진 여러가지 타입들 중에 한 가지 타입의 객체를 보관할 수 있는 클래스이다.

물론 공용체(union) 을 이용해서 해결할 수 도 있겠지만, 공용체가 현재 어떤 타입의 객체를 보관하고 있는지 알 수 없기 때문에 실제로 사용하기에는 매우 위험하다.

// v 는 이제 int
std::variant<int, std::string, double> v = 1;

// v 는 이제 std::string
v = "abc";

// v는 이제 double
v = 3.14;

먼저 variant 를 정의할 때 포함하고자 하는 타입들을 명시해줘야 한다. 위의 경우 정의한 variant  int, std::string, double 이 셋 중 하나의 타입을 가질 수 있다.

variant 의 가장 큰 특징으로는 반드시 값을 들고 있어야 한다는 점이다. 만약에 그냥

std::variant<int, std::string, double> v;

을 정의한다면 v 에는 첫 번째 타입 인자 (int) 의 디폴트 생성자가 호출되게 된다. 즉 위 경우 v 에는 0 이 들어가는데, 비어 있는 variant 는 불가능한 상태라고 보면 된다.

variant  optional 과 비슷하게 객체의 대입 시에 어떠한 동적 할당도 발생하지 않는다. 따라서 굉장히 작은 오버헤드로 객체들을 보관할 수 있다. 다만 variant 객체 자체의 크기는 나열된 가능한 타입들 중 가장 큰 타입의 크기를 따라간다.

variant  이러이러한 타입들 중 하나(one-of) 를 표현하기에 매우 적합한 도구이다. 예를 들어서 어떤 데이터 베이스에 검색을 해서 결과를 돌려주는 함수를 생각해보자. 이 결과는 조건에 따라 클래스 A 객체나 클래스 B 객체가 될 수 있다.

class A {};
class B {};

/* ?? */ GetDataFromDB(bool is_a) {
  if (is_a) {
    return A();
  }
  return B();
}

 

한 가지 방법이라면 C++ 의 다형성(polymorphism)을 이용하는 것이다. 이를 위해서는 A  B 클래스의 공통 부모가 정의되어 있어야 한다.

class Data {};
class A : public Data {};
class B : public Data {};

std::unique_ptr<Data> GetDataFromDB(bool is_a) {
  if (is_a) {
    return std::make_unique<A>();
  }
  return std::make_unique<B>();
}

따라서 위와 같이 A 혹은 B 객체를 리턴할 수 있다. 그리고 해당 함수를 호출하는 곳에서 리턴하는 Data 의 실제 객체가 무엇인지 간단하게 알아낼 수도 있다.

하지만 위 문제는 리턴하고자 하는 클래스들의 부모 클래스가 공통으로 정의되어 있어야 하고, std::string 이나 int 와 같은 표준 클래스의 객체들에는 적용할 수 없다는 문제가 있다. 하지만 std::variant 를 이용하면 매우 간단하게 해결할 수 있다.

#include <iostream>
#include <memory>
#include <variant>

class A {
 public:
  void a() { std::cout << "I am A" << std::endl; }
};

class B {
 public:
  void b() { std::cout << "I am B" << std::endl; }
};

std::variant<A, B> GetDataFromDB(bool is_a) {
  if (is_a) {
    return A();
  }
  return B();
}

int main() {
  auto v = GetDataFromDB(true);

  std::cout << v.index() << std::endl;
  std::get<A>(v).a();  // 혹은 std::get<0>(v).a()
}

// 실행 결과
// 0
// I am A

variant 역시 optional 과 마찬가지로 각각의 타입의 객체를 받는 생성자가 정의되어 있기 때문에 그냥 A 를 리턴하면 A 를 가지는 variant 가, B 를 리턴하면 B 를 가지는 variant 가 생성된다.

 
std::cout << v.index() << std::endl;
std::get<A>(v).a();  // 혹은 std::get<0>(v).a()

먼저 현재 variant 에 몇 번째 타입이 들어있는지 알고 싶다면 index() 함수를 사용하면 된다. 위의 경우 A 타입의 객체가 들어 있는데 A  variant 에서 첫 번째 타입이므로 0 을 리턴하게 된다.

그 다음으로 실제로 원하는 값을 뽑아내고 싶다면 외부에 정의되어 있는 함수인 std::get<T> 를 이용하면 된다. 이 때 이 T 자리에 뽑아내고자 하는 타입을 써주던지, 아니면 해당 타입의 index 를 넣어주면 된다.

따라서 A 를 뽑고 싶다면 std::get<A>(v)  std::get<0>(v) 를 하면 되고, B 를 뽑고 싶다면 std::get<B>(v)  std::get<1>(v) 를 하면 된다.

여기서 한 가지 알 수 있는 점은 varinat 가 보관하는 객체들은 타입으로 구분된다는 점이다. 따라서 variant 를 정의할 때 같은 타입을 여러 번 써주면 안된다. 예를 들어서

std::variant<std::string, std::string> v;

는 컴파일 시 오류가 발생한다.

 

std::monostate

만약에 굳이 variant  아무 것도 들고 있지 않은 상태를 표현하고자 싶다면 해당 타입으로 std::monostate 를 사용하면 된다. 이를 통해서 마치 std::optional 과 같은 효과를 낼 수 있다.

std::variant<std::monostate, A, B> v;

위와 같이 variant 를 정의한다면 v 에는 아무것도 안들어 있거나 A 혹은 B 가 들어가 있을 수 있다. 또한 variant 안에 정의된 타입들 중에 디폴트 생성자가 있는 타입이 하나도 없는 경우 역시 std::monostate 를 활용하면 된다. 예를 들어서

class A {
 public:
  A(int i) {}
};

class B {
 public:
  B(int i) {}
};

위와 같이 디폴트 생성자가 없는 클래스가 있다고 하였을 때,

 
std::variant<A, B> v;

를 덩그러니 정의하게 되면 컴파일 오류가 발생하게 된다. 왜냐하면 앞서 이야기 하였듯이 variant 는 반드시 객체를 들고 있어야 하는데, 이를 지정하지 않을 경우 자동으로 첫 번째 타입의 디폴트 생성된 객체를 갖고 있으려고 하기 때문이다. 하지만 위의 경우 A 의 디폴트 생성자가 없기 때문에 컴파일 오류가 발생한다.

이 경우 그냥 첫 번째 타입으로 std::monostate 를 지정해주면 깔끔하게 해결된다.

 
std::variant<std::monostate, A, B> v;

와 같이 하게 되면 디폴트로 std::monostate 가 v 에 들어가게 되어서 문제가 생기지 않는다.

 

 

std::tuple (C++ 11 이상 - <tuple>)

마지막으로 여러 서로 다른 타입들의 묶음을 간단하게 다룰 수 있도록 제공하는 std::tuple 에 대해 알아보자. C++ 에서 같은 타입 객체들을 여러개 다루기 위해서는 std::vector 나 배열을 사용하였다.

struct Collection {
  int a;
  std::string s;
  double d;
};

위와 같이 간단히 구조체를 정의할 수도 있다. 하지만 매번 이렇게 의미 없는 구조체를 생성하게 된다면 코드를 읽는 수고로움이 배가된다. (파이썬과 같은 언어에서는 (1, 'abc', 3.14) 처럼 간단히 tuple 을 생성할 수 있다)

다행이 C++ 11 부터 std::tuple 라이브러리가 추가되어 간단히 서로 다른 타입들의 집합을 생성할 수 있게 되었다!

#include <iostream>
#include <string>
#include <tuple>

int main() {
  std::tuple<int, double, std::string> tp;
  tp = std::make_tuple(1, 3.14, "hi");

  std::cout << std::get<0>(tp) << ", " << std::get<1>(tp) << ", "
            << std::get<2>(tp) << std::endl;
}


// 실행 결과
// 1, 3.14, hi
 
std::tuple<int, double, std::string> tp;

tuple 을 정의하는 방법은 간단하다. tuple 이 보관하고자 하는 타입들을 쭈르륵 나열해주면 된다. 위 tp 의 경우 int, double, std::string 이 세 개 타입의 객체를 보관하는 컨테이너라 생각하면 된다.

참고로 variant 와는 다르게 tuple 에는 같은 타입들이 들어 있어도 전혀 문제가 될 것이 없다!

 
tp = std::make_tuple(1, 3.14, "hi");

tuple 객체를 생성하기 위해서는 make_tuple 함수를 사용하면 된다.

std::cout << std::get<0>(tp) << ", " << std::get<1>(tp) << ", "
          << std::get<2>(tp) << std::endl;

그리고 마지막으로 tuple 의 각각의 원소에 접근하기 위해서는 이전의 variant 처럼 std::get 을 이용하면 된다. 이 때 get 에 템플릿 인자로 몇 번째 원소에 접근할지 지정해주면 된다.

참고로 원하는 타입의 원소를 뽑아내고 싶다면 타입을 전달해도 되는데, 예를 들어서 std::get<std::string> 을 하게 되면 tuple 에 정의된 문자열 객체가 뽑혀져 나오게 됩니다. 다만, tuple  std::string 이 없거나, 2 개 이상 존재한다면 예외가 발생하게 된다.

 

Structured binding (C++ 17 이상)

C++ 17 에서는 structured binding 이라는 테크닉이 추가되어서 tuple 을 좀더 편리하게 다룰 수 있게 되었다. 예시 코드를 보자.

#include <iostream>
#include <string>
#include <tuple>

std::tuple<int, std::string, bool> GetStudent(int id) {
  if (id == 0) {
    return std::make_tuple(30, "철수", true);
  } else {
    return std::make_tuple(28, "영희", false);
  }
}

int main() {
  auto student = GetStudent(1);

  int age = std::get<0>(student);
  std::string name = std::get<1>(student);
  bool is_male = std::get<2>(student);

  std::cout << "이름 : " << name << std::endl;
  std::cout << "나이 : " << age << std::endl;
  std::cout << "남자 ? " << std::boolalpha << is_male << std::endl;
}

// 실행 결과
// 이름 : 영희
// 나이 : 28
// 남자 ? false

와 같이 잘 나옵니다. 이전까지, tuple 에서 각각의 원소들을 뽑아내기 위해서는 아래와 같이 해야 합니다.

int age = std::get<0>(student);
std::string name = std::get<1>(student);
bool is_male = std::get<2>(student);

하지만 C++ 17 부터는 structured binding 이라는 방식을 통해 아주 간단하게 표현 할 수 있다.

 

auto [age, name, is_male] = student;

마치 파이썬을 생각나게 하는 문법인데, structured binding 을 사용하기 위해선

auto /* & 혹은 && 도 가능 */ [/* tuple 안에 원소들을 받기 위한 객체*/] = tp;

와 같이 쓰면 된다. 자세한 내용은 여기 에서 볼 수 있다.

 

예를 들어서 만약에 tuple 안에 객체들을 복사하지 않고 그냥 레퍼런스만 취하고 싶다면

auto& [age, name, is_male] = student;

와 같이 하면 된다.

한 가지 중요한 점은 tuple  모든 원소들을 반드시 받아야 한다는 점이다. structured binding 을 사용해선 중간의 원소 하나만 빼고 받기와 같은 것은 할 수 없다!

그래도 structured binding 은 여러가지 쓰임새들이 매우 많다. 꼭 tuple 말고도, 데이터 멤버들이 정의되어 있는 구조체의 데이터 필드들을 받는데에도 사용할 수 있다. 예를 들어서

struct Data {
  int i;
  std::string s;
  bool b;
};

Data d;
auto [i, s, b] = d;

와 같이 각각의 데이터 필드를 받아낼 수 있다.

덕분에 pair 와 같은 클래스들 역시 structured binding 을 사용할 수 있다.

std::map<int, std::string> m = {{3, "hi"}, {5, "hello"}};
for (auto& [key, val] : m) {
  std::cout << "Key : " << key << " value : " << val << std::endl;
}

기존에는 iterator 로 받아서 first  second 로 키와 대응되는 값을 나타내야 했지만 strucuted binding 을 사용하면 훨씬 깔끔하게 나타낼 수 있다!

Comments