C++26의 std::indirect가 힙에 할당된 객체에 값 의미론, 깊은 복사, 상수 전파를 제공하는 방식을 살펴봅니다.
C++26은 Coe, Peacock, Parent가 작성한 P3019R14에서 도입된 <memory>의 새로운 어휘 타입 두 가지를 추가합니다. 초록에서 발췌하면 다음과 같습니다.
클래스 템플릿
indirect는 동적으로 할당된 객체에 값과 같은 의미론을 부여합니다.indirect는 클래스T의 객체를 보유할 수 있습니다.indirect를 복사하면 객체T도 복사됩니다.indirect<T>가 상수 접근 경로를 통해 접근되면, 상수성은 소유한 객체로 전파됩니다.클래스 템플릿
polymorphic는 동적으로 할당된 객체에 값과 같은 의미론을 부여합니다.polymorphic<T>는T에서 공개적으로 파생된 클래스의 객체를 보유할 수 있습니다.polymorphic<T>를 복사하면 파생 타입의 객체도 복사됩니다.polymorphic<T>가 상수 접근 경로를 통해 접근되면, 상수성은 소유한 객체로 전파됩니다.
보시다시피 이 두 타입은 취지가 매우 비슷합니다. 이들은 하나의 문서로 합쳐지기 전에는 각각 indirect를 위한 P1950과 polymorphic를 위한 P0201이라는 별도의 제안이었습니다. 마찬가지로 처음에는 두 가지를 한 글에서 다룰 계획이었지만, 글이 충분히 길어져 나누기로 했습니다. 이 글에서는 std::indirect를 다루고, 다음 글에서는 std::polymorphic를 다룹니다.
unique_ptr의 문제점std::unique_ptr를 값 타입 클래스의 멤버로 사용할 때는 두 가지 근본적인 문제가 있습니다.
첫째, 상수성 전파가 깨집니다. unique_ptr::operator*() const는 상수가 아닌 T&를 반환합니다. const 객체가 간접적으로 저장한 멤버를 변경할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// https://godbolt.org/z/P7zdodhsd
struct Settings {
int volume = 50;
bool muted = false;
};
class Player {
std::unique_ptr<Settings> settings_;
public:
Player() : settings_(std::make_unique<Settings>()) {}
void mute() const {
settings_->muted = true; // compiles — mutates through const!
}
};
const Player p;
p.mute(); // const-correctness is broken
둘째, 복사 연산이 삭제됩니다. Car가 복사 가능해야 한다면, 다섯 개의 특수 멤버 함수를 모두 직접 작성해야 합니다. 이는 모든 C++ 개발자가 너무나 잘 아는 지루한 Rule of Five 보일러플레이트입니다.
std::indirect — 힙에 할당된 객체를 위한 값 의미론std::indirect<T>는 소유권 이전이 아니라 복합 클래스 멤버를 위해 설계되었다면 std::unique_ptr<T>가 되었을 모습입니다. 힙에 할당된 T를 소유하며, 깊은 복사, 상수성 전파, 값 기반 비교, 해싱 등 값 타입에 기대하는 모든 기능을 제공합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// https://godbolt.org/z/ePxb8E9Ko
struct Settings {
int volume = 50;
bool muted = false;
bool operator==(const Settings&) const = default;
auto operator<=>(const Settings&) const = default;
};
class Player {
std::indirect<Settings> settings_;
public:
Player() : settings_(std::in_place) {}
void mute() { settings_->muted = true; }
void set_volume(int v) { settings_->volume = v; }
int volume() const { return settings_->volume; }
bool is_muted() const { return settings_->muted; }
const Settings& settings() const { return *settings_; }
// ALL special member functions are compiler-generated.
// Copying deep-copies the Settings. Moving transfers it.
};
이제 mute()와 set_volume()은 상수가 아닙니다. 그래야 마땅합니다. 이를 const로 만들려고 하면 컴파일러가 막습니다. indirect::operator->() const는 const Settings*를 반환하므로, const 메서드 안의 settings_->muted = true는 컴파일 오류입니다.
1
2
error: assignment of member 'Settings::muted' in read-only object
settings_->muted = true; // this wouldn't compile!
unique_ptr 버전에 있던 정확히 그 버그는 구조적으로 발생할 수 없습니다.
이제 indirect가 제공하는 다른 기능도 살펴보겠습니다.
unique_ptr와 달리 indirect::operator*() const는 const T&를 반환합니다. const Player가 있을 때 settings_->는 const Settings&를 제공하므로, 어떤 멤버라도 변경하려 하면 컴파일 오류가 발생합니다. 이는 멤버 하위 객체가 동작하는 방식이며, indirect는 이를 힙까지 확장할 뿐입니다.
indirect<T>를 복사하면 소유한 T가 복사됩니다. 보일러플레이트를 한 줄도 작성하지 않고 클래스를 복사 가능하게 만들 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
// https://godbolt.org/z/z6aEE4oP4
Player a;
a.set_volume(80);
a.mute();
Player b = a; // deep copies the Settings
b.set_volume(30);
assert(a.volume() == 80); // a is unchanged
assert(b.volume() == 30); // b has its own copy
unique_ptr를 사용했다면 직접 작성한 복사 생성자가 필요했을 것입니다.
T가 ==와 <=>를 지원한다면 indirect<T>도 지원합니다. 포인터가 아니라 소유한 객체를 비교합니다.
1
2
3
4
5
6
7
8
// https://godbolt.org/z/znMWdPvjr
Player a;
Player b;
assert(a.settings() == b.settings()); // true — both have volume=50, muted=false
a.set_volume(80);
assert(a.settings() != b.settings()); // true — different volume now
indirect는 설계상 널 또는 비어 있는 상태가 없습니다. 기본 operator bool()도, has_value()도 없습니다. indirect는 항상 객체를 소유합니다. 이동된 뒤의 경우만 예외입니다. 이때 valueless_after_move()는 true를 반환하며, 객체에 접근하는 것은 정의되지 않은 동작입니다.
널 허용 간접 참조가 필요하다면 std::optional<std::indirect<T>>를 사용하세요.
std::indirect는 구조적인 이유로 힙 할당이 필요하지만 클래스가 값처럼 동작하기를 원할 때 알맞은 도구입니다.
indirect<Impl>는 일반적인 unique_ptr<Impl>를 대체합니다. 더 이상 직접 작성한 복사/이동/소멸자가 필요 없습니다. Marius Bancila는 이에 관한 상세한 안내를 제공합니다.struct Node { int value; std::indirect<Node> next; };가 그대로 작동합니다.sizeof(YourClass)를 줄이기 위해 큰 멤버를 힙으로 옮기는 경우입니다.std::indirect는 C++11이 이동 의미론과 스마트 포인터를 도입한 이래 존재해 온 공백을 메웁니다. unique_ptr는 소유권 문제를 해결했지만, 간접적으로 저장한 객체를 위한 _값 의미론_은 해결하지 못했습니다. indirect를 사용하면 PIMPL 구현은 보일러플레이트를 잃고, 복합 클래스는 올바른 상수성 전파를 얻으며, 깊은 복사와 비교, 해싱이 모두 특수 멤버 함수를 하나도 작성하지 않고 작동합니다.
다음 글에서는 같은 아이디어를 클래스 계층 구조로 확장하는 형제 타입 std::polymorphic를 살펴보겠습니다. 이 타입은 값 의미론을 갖고 clone() 메서드가 필요 없는 다형성 컨테이너를 제공합니다.
이 글이 마음에 드셨다면 다음을 부탁드립니다.