KoreanFoodie's Study
C++ 기초 개념 17-5 : std::optional, variant, tuple 본문
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 을 사용하면 훨씬 깔끔하게 나타낼 수 있다!
'Tutorials > C++ : Beginner' 카테고리의 다른 글
C++ 기초 개념 17-4 : C++ 파일 시스템(<filesystem>) 라이브러리 (0) | 2022.05.27 |
---|---|
C++ 기초 개념 17-3 : 난수 생성(<random>) 과 시간 관련 라이브러리(<chrono>) (0) | 2022.05.26 |
C++ 기초 개념 17-2 : C++ 정규 표현식 <regex> 사용하기 (0) | 2022.05.26 |
C++ 기초 개념 17-1 : type_traits 라이브러리, SFINAE, enable_if (0) | 2022.05.25 |
C++ 기초 개념 16-3 : decltype 과 std::declval (0) | 2022.05.24 |