std::any: 언제, 어떻게, 그리고 왜
원문은 std::any: How, when, and why by Casey Carter이며 아래는 이를 요약 정리한 글이다.
임의의 사용자 데이터 저장
달력을 구성 요소로 만들어 다른 개발자가 사용할 라이브러리로 배포한다고 하자. 이 달력으로 가급적 많은 문제를 해결할 수 있도록 연, 월, 주, 일을 사용자가 지정한 임의의 데이터와 함께 사용할 수 있게 하려면 어떻게 설계 해야 좋을까.
C 개발자는 각 데이터 구조에 void*
를 추가할 듯하다.
1 2 3 4 5 6 7 8 9 |
struct day { // ... 여러 내용 ... void* user_data; }; struct month { std::vector<day> days; void* user_data; }; |
하지만 이 방법엔 분명한 단점이 몇 가지 있다.
실제 가리키는 객체가 Foo
인지 여부에 관계 없이 항상 void*
를 Foo*
로 변환할 수 있다. 연관된 데이터의 타입 정보가 없으므로 저장한 데이터를 원래 타입으로 접근하는 기본적인 타입 안전성조차 보장할 수 없다.
1 2 3 4 |
some_day.user_data = new std::string{"Hello, World!"}; // … 이런저런 내용 이후 Foo* some_foo = static_cast<Foo*>(some_day.user_data); some_foo->frobnicate(); // 꽝! |
void*
는 스마트 포인터처럼 수명 관리를 하지 않으므로 사용자가 직접 연관 데이터의 수명을 관리해야 한다. 실수는 곧 메모리 누수로 이어진다.
1 2 3 4 |
delete some_day.user_data; some_day.user_data = nullptr; some_month.days.clear(); // 이런: days 중에 널이 아닌 // user_data가 없기를 |
void*
로 가리키는 객체는 타입을 알 수 없으므로 라이브러리에서 복사할 수 없다. 사용자가 직접 해야 하며 앞의 예와 마찬가지로 실수는 곧 허상 포인터(dangling pointer), 이중 해제(double free) 또는 누수로 이어진다.
1 2 3 4 5 6 7 |
some_month.days[0] = some_month.days[1]; if (some_month.days[1].user_data) { // user_data에 string을 저장하고 days 사이에 // 공유하고 싶진 않다. 직접 복사한다: std::string const& src = *some_month.days[1].user_data; some_month.days[0].user_data = new std::string(src); } |
C++ 표준 라이브러리의 shared_ptr
을 사용해 void*
를 shared_ptr<void>
로 변경하면 수명 관리 문제를 해결할 수 있다.
1 2 3 4 5 6 7 8 9 |
struct day { // ... 여러 내용 ... std::shared_ptr<void> user_data; }; struct month { std::vector<day> days; std::shared_ptr<void> user_data; }; |
사용자는 shared_ptr<Foo>
를 생성할 수 있고 달력에 shared_ptr<void>
로 변환해 저장한 후에도 삭제자(deleter)는 잘 처리한다.
1 2 3 4 |
some_day.user_data = std::make_shared<std::string>("Hello, world!"); // ... 이런저런 내용 이후 ... some_day = some_other_day; // some_day.user_data로 가리키는 // 객체를 자동으로 해제한다 |
사용자가 값이 아닌 객체를 나타내는 shared_ptr<void>
의 복사본을 담고 있는 days
/weeks
등을 사용하더라도 복사 문제를 해결할 수 있다. 하지만 shared_ptr<void>
역시 void*
와 마찬가지로 연관 데이터의 타입을 추적하지 않으므로 타입 안전성 문제는 해결할 수 없다.
그저 그런 해결책은 도움이 되지 않는다
std::any
는 똑똑한 void*
/shared_ptr<void>
이며 복사 가능한 타입이라면 어떤 값이든 초기화할 수 있다.
1 2 3 |
std::any a0; std::any a1 = 42; std::any a2 = month{"October"}; |
shared_ptr
처럼 any
는 자신이 소멸할 때 담고 있는 값을 적절히 소멸하는 방법을 알고 있다. shared_ptr
과 달리 any
객체를 복사할 때 담고 있는 값을 복사하는 방법도 알고 있다.
1 2 3 |
std::any a3 = a0; // 앞의 코드 조각에서 생성한 빈 any를 복사한다 std::any a4 = a1; // int를 담고 있는 any를 복사한다 a4 = a0; // 복사 대입을 할 수 있고 이전 값을 적절히 소멸한다 |
shared_ptr
과 달리 any
는 담고 있는 타입을 알고 있다.
1 2 3 4 |
assert(!a0.has_value()); // a0은 여전히 빈 객체 assert(a1.type() == typeid(int)); assert(a2.type() == typeid(month)); assert(a4.type() == typeid(void)); // type()은 빈 객체에 대해 typeid(void)를 반환한다 |
따라서 any_cast
로 참조를 얻는 예처럼, 담고 있는 타입에 접근할 때 올바른 타입으로 접근할 수 있다.
1 2 3 4 5 |
assert(std::any_cast<int&>(a1) == 42); // 성공 std::string str = std::any_cast<std::string&>(a1); // a1은 string이 아닌 int를 담고 있으므로 // bad_any_cast 예외를 일으킨다 assert(std::any_cast<month&>(a2).days.size() == 0); std::any_cast<month&>(a2).days.push_back(some_day); |
any
가 담고 있는 타입이 확실하지 않고 예외를 피하고 싶으면 포인터를 반환하는 any_cast
메서드로 타입 조회 후 접근할 수 있다.
1 2 3 4 5 6 7 |
if (auto ptr = std::any_cast<int>(&a1)) { assert(*ptr == 42); // a1은 int를 담고 있으므로 실행할 수 있다 } if (auto ptr = std::any_cast<std::string>(&a1)) { assert(false); // 절대 실행하지 말 것: a1은 string을 담고 있지 // 않으므로 any_cast는 nullptr을 반환한다 } |
C++ 표준에서는 예외를 던지지 않는 이동 생성자로 작은 객체를 any 객체의 저장소에 바로 생성해 동적 할당 비용을 피하도록 구현할 것을 장려한다. 하지만 이는 최선의 노력일 뿐 어느 크기 이하는 할당하지 않는다는 그런 보장은 없다. Visual C++ 구현에서는 포인터 크기로 소수라면 할당하지 않는 더 큰 any
를 사용하지만 libc++와 libstdc++에서는 포인터 크기로 둘 이상에 해당하는 객체에 대해 할당한다(참조: https://godbolt.org/z/RQd_w5).
어휘 타입을 선택하는 방법 (또는 ‘저장할 타입을 알고 있다면 어떻게?’)
저장할 타입이 반드시 복사 가능해야 한다는 것 말고도 저장할 타입을 안다면 std::any
가 적절하지 않을 수 있다. 유연함을 위해 성능 저하를 감수해야 하기 때문이다. 정확한 타입 T를 안다면 std::optional
을 사용하는 게 좋다. 저장할 타입이 콜백 같이 항상 시그너처가 특정한 함수 객체라면 std::function
을 선택한다. 컴파일 시점에 정해진 여러 타입 집합을 저장해야 한다면 std::variant
가 좋은 선택이다.
결론
임의의 타입 객체를 저장해야 할 때면 도구 상자에서 std::any
를 꺼내자. 저장할 타입에 관해 안다면 더 적절한 도구가 있다는 것도 기억하자.