C++23의 명시적 객체 매개변수(‘deducing this’) 기능을 소개하고, 설계 개요와 다양한 활용 사례(코드 중복 제거, 람다에서의 완벽 전달과 재귀, 값으로 this 전달, CRTP 대체, SFINAE 이슈 해결)를 예제로 설명합니다. MSVC는 Visual Studio 2022 17.2부터 지원합니다.
Deducing this (P0847)은 비정적 멤버 함수를 지정하는 새로운 방법을 제공하는 C++23 기능입니다. 보통 객체의 멤버 함수를 호출할 때, 매개변수 목록에 보이지 않더라도 객체가 멤버 함수로 암시적으로 전달됩니다. P0847은 이 매개변수를 명시적으로 만들 수 있게 해 주며, 이름과 const/참조 한정자를 부여할 수 있습니다. 예를 들어:
struct implicit_style {
void do_something(); // 객체는 암시적으로 전달됨
};
struct explicit_style {
void do_something(this explicit_style& self); // 객체를 명시적으로 전달함
};
명시적 객체 매개변수는 타입 지정자 앞에 키워드 this를 둠으로써 구분되며, 함수의 첫 번째 매개변수에만 사용할 수 있습니다.
이 기능을 허용하는 이유가 즉각적으로 명확하지 않을 수 있지만, 이로부터 사실상 마법처럼 많은 추가 기능들이 따라옵니다. 여기에는 코드의 사중 중복 제거, 재귀 람다, this를 값으로 전달, 그리고 기반 클래스가 파생 클래스를 템플릿 인자로 받지 않아도 되는 CRTP의 한 변형이 포함됩니다.
이 글에서는 먼저 설계를 개괄적으로 살펴본 다음, 여러분의 코드에서 이 기능을 사용할 수 있는 다양한 경우들을 살펴보겠습니다.
이후 본문에서는 이 기능을 “명시적 객체 매개변수(explicit object parameters)”라고 부르겠습니다. “deducing this”보다 기능 이름으로 더 적합하기 때문입니다. 명시적 객체 매개변수는 Visual Studio 2022 버전 17.2의 MSVC에서 지원됩니다. 이 글과 잘 어울리는 자료로 Ben Deane의 CppCon 발표 Deducing this Patterns이 있습니다.
이 기능을 제안한 문서는 Gašper Ažman, Ben Deane, Barry Revzin, 그리고 저가 작성했으며, 많은 전문가들의 경험에 의해 이끌어졌습니다. Barry와 저는 각각 std::optional을 구현하면서 동일한 문제를 겪은 뒤 이 문서를 작성하기 시작했습니다. 우리는 optional의 value 함수를 작성하면서, 좋은 라이브러리 개발자로서 가능한 많은 사용 사례에서 사용성과 성능을 확보하려 노력했습니다. 그래서, 호출된 객체가 const이면 const 참조를 반환하고, 호출된 객체가 우측값이면 우측값을 반환하는 등의 동작을 원했습니다. 결과는 대략 다음과 같았습니다:
template <typename T>
class optional {
// const가 아닌 좌측값(lvalue) 버전의 value
constexpr T& value() & {
if (has_value()) {
return this->m_value;
}
throw bad_optional_access();
}
// const 좌측값(lvalue) 버전의 value
constexpr T const& value() const& {
if (has_value()) {
return this->m_value;
}
throw bad_optional_access();
}
// const가 아닌 우측값(rvalue) 버전… 벌써 지루하지 않으신가요?
constexpr T&& value() && {
if (has_value()) {
return std::move(this->m_value);
}
throw bad_optional_access();
}
// 이쯤이면 분명 지루하실 겁니다
constexpr T const&& value() const&& {
if (has_value()) {
return std::move(this->m_value);
}
throw bad_optional_access();
}
// ...
};
(만약 member_function_name() & 구문에 익숙하지 않다면, 이것은 “참조 한정자(ref-qualifiers)”라고 하며 자세한 내용은 Andrzej Krzemieński의 블로그에서 확인할 수 있습니다. rvalue 참조(T&&)에 익숙하지 않다면 이 Stack Overflow 질문에서 이동语义에 대해 읽어보세요.)
서로 거의 동일한 구현의 함수 네 개가 있지만, 오직 const 여부와 저장된 값을 복사 대신 이동하는지 여부로만 구분되는 점에 주목하세요.
Barry와 저는 다른 함수를 작성하면서도 같은 일을 반복해야 했습니다. 그리고 또 반복하고, 또 반복하며 코드가 중복되고, 실수가 발생하고, 미래의 우리에게 유지보수 악몽을 남기게 되었죠. “만약,” 우리는 생각했습니다, “그냥 이렇게 쓸 수 있다면 어떨까?”
template <typename T>
struct optional {
// 모든 경우에 동작하는 value의 단일 버전
template <class Self>
constexpr auto&& value(this Self&& self) {
if (self.has_value()) {
return std::forward<Self>(self).m_value;
}
throw bad_optional_access();
}
(std::forward에 익숙하지 않다면, Eli Bendersky의 블로그에서 완벽 전달(perfect forwarding)에 대해 읽어보세요.)
이 코드는 위의 네 오버로드와 동일한 일을 하지만 단 하나의 함수로 처리합니다. const optional&, const optional&&, optional&, optional&&에 대해 각각 다른 버전의 value를 쓰는 대신, 함수 템플릿을 하나만 작성하고, 호출 대상 객체의 const/volatile/참조(cvref) 한정자를 추론하여 사용합니다. 타입 내 거의 모든 함수에 이 변경을 적용하면 코드 양을 크게 줄일 수 있습니다.
그래서 우리는 결국 표준화된 버전을 작성했고, 곧 Gašper와 Ben도 정확히 같은 기능을 위한 또 다른 문서를 작업 중이라는 것을 알게 되어 힘을 합쳤고, 몇 년이 흐른 지금 여기에 이르게 되었습니다.
우리가 따른 핵심 설계 원칙은 “기대한 대로 동작해야 한다”였습니다. 이를 위해 표준의 변경 지점을 가능한 한 최소화했습니다. 특히, 오버로드 결정 규칙이나 템플릿 추론 규칙은 건드리지 않았고, 이름 탐색(name resolution)도 아주 조금만 변경했습니다(보너스 느낌으로요).
예를 들어, 다음과 같은 타입이 있다고 합시다:
struct cat {
template <class Self>
void lick_paw(this Self&& self);
};
템플릿 매개변수 Self는 여러분이 이미 익숙한 템플릿 추론 규칙과 동일한 방식으로 추론됩니다. 추가적인 마법은 없습니다. Self와 self라는 이름을 꼭 써야 하는 것은 아니지만, 이들이 가장 명확하다고 생각하고, 여러 다른 프로그래밍 언어의 관례와도 맞습니다.
cat marshmallow;
marshmallow.lick_paw(); // Self = cat&
const cat marshmallow_but_stubborn;
marshmallow_but_stubborn.lick_paw(); // Self = const cat&
std::move(marshmallow).lick_paw(); // Self = cat
std::move(marshmallow_but_stubborn).lick_paw(); // Self = const cat
이와 같은 멤버 함수의 본문 안에서는 this를 명시적으로든 암시적으로든 참조할 수 없다는 것이 이름 탐색의 한 가지 변화입니다.
struct cat {
std::string name;
void print_name(this const cat& self) {
std::cout << name; // 잘못됨
std::cout << this->name; // 이것도 잘못됨
std::cout << self.name; // 올바름
}
};
이제부터는 이 기능의 다양한 활용법(적어도 지금까지 제가 알고 있는 것들!)을 살펴보겠습니다. 많은 예시는 제안서에서 직접 가져왔습니다.
이미 optional 같은 타입에 이 기능을 적용하여 동일한 함수의 네 가지 오버로드를 작성할 필요가 없음을 보았습니다.
또한 이는 rvalue 멤버 함수를 다루는 초기 구현 및 유지보수 부담을 줄여줍니다. 종종 개발자들은 많은 경우에서 rvalue 전용으로 함수를 두 개 더 작성하고 싶지는 않기 때문에, 멤버 함수에 대해 const 및 비-const 오버로드만 작성합니다. this의 한정자를 추론할 수 있게 되면 rvalue 버전도 자동으로 따라옵니다. 불필요한 복사를 피하여 런타임 성능을 얻고 싶다면, 적절한 위치에서 std::forward만 사용하면 됩니다:
class cat {
toy held_toy_;
public:
// 명시적 객체 매개변수 도입 전
toy& get_held_toy() { return held_toy_; }
const toy& get_held_toy() const { return held_toy_; }
// 도입 후
template <class Self>
auto&& get_held_toy(this Self&& self) {
return self.held_toy_;
}
// 도입 후 + forwarding
template <class Self>
auto&& get_held_toy(this Self&& self) {
return std::forward<Self>(self).held_toy_;
}
};
물론 이렇게 단순한 getter에서 이 변경이 가치가 있을지는 여러분의 구체적 사용 사례에 달려 있습니다. 하지만 더 복잡한 함수이거나, 복사를 피하고 싶은 큰 객체를 다루는 경우에는 명시적 객체 매개변수가 이런 처리를 훨씬 쉽게 만들어 줍니다.
Curiously Recurring Template Pattern(CRTP)은 가상 함수의 런타임 비용 없이 공통 기능 조각을 타입에 확장해 주는 컴파일 타임 다형성 기법입니다. 때로는 이를 믹스인(mixins)이라고도 합니다(이것이 CRTP의 전부는 아니지만 가장 흔한 용도입니다). 예를 들어, 접미 증감(postfix increment)을 접두 증감(prefix increment)으로 정의해 주는 add_postfix_increment 타입을 작성해 믹스인할 수 있습니다:
template <typename Derived>
struct add_postfix_increment {
Derived operator++(int) {
auto& self = static_cast<Derived&>(*this);
Derived tmp(self);
++self;
return tmp;
}
};
struct some_type : add_postfix_increment<some_type> {
// 접두 증감: 접미 증감이 이를 바탕으로 구현됨
some_type& operator++();
};
기반 클래스를 그 파생 클래스로 템플릿화하고 함수 내부에서 this를 static_cast하는 것은 다소 난해할 수 있으며, 다중 단계의 CRTP가 되면 문제가 더 커집니다. 명시적 객체 매개변수에서는 템플릿 추론 규칙을 변경하지 않았기 때문에, 명시적 객체 매개변수의 타입을 “파생 타입으로” 추론할 수 있습니다. 좀 더 구체적으로:
struct base {
template <class Self>
void f(this Self&& self);
};
struct derived : base {};
int main() {
derived my_derived;
my_derived.f();
}
my_derived.f() 호출에서 f 내부의 Self 타입은 base&가 아니라 derived&입니다.
이는 위의 CRTP 예제를 다음과 같이 정의할 수 있음을 의미합니다:
struct add_postfix_increment {
template <typename Self>
auto operator++(this Self&& self, int) {
auto tmp = self;
++self;
return tmp;
}
};
struct some_type : add_postfix_increment {
// 접두 증감: 접미 증감이 이를 바탕으로 구현됨
some_type& operator++();
};
이제 add_postfix_increment는 템플릿이 아니라는 점에 주목하세요. 대신, 커스터마이즈 지점을 접미 operator++로 옮겼습니다. 즉, 어디에도 some_type을 템플릿 인자로 전달할 필요가 없습니다. 모든 것이 “그냥 동작”합니다.
클로저에서 캡처한 값을 복사하여 꺼내는 것은 간단합니다. 객체를 그냥 평소처럼 전달하면 됩니다. 캡처한 값을 이동시켜 꺼내는 것도 간단합니다. 그저 std::move를 호출하면 됩니다. 문제는, 클로저가 좌측값인지 우측값인지에 따라 캡처 값을 완벽 전달해야 할 때 발생합니다.
제가 P2445에서 가져온 한 사용 사례는 “재시도(retry)”와 “시도 또는 실패(try-or-fail)” 문맥 모두에서 사용할 수 있는 람다입니다:
auto callback = [m=get_message(), &scheduler]() -> bool {
return scheduler.submit(m);
};
callback(); // retry(callback)
std::move(callback)(); // try-or-fail(우측값)
여기서 질문은: 클로저의 값 범주에 맞춰 m을 어떻게 전달(forward)하느냐입니다. 명시적 객체 매개변수가 답을 줍니다. 람다는 주어진 시그니처의 operator() 멤버 함수를 가진 클래스를 생성하므로, 지금까지 설명한 모든 기계 장치가 람다에도 그대로 적용됩니다.
auto closure = [](this auto&& self) {
// 람다 내부에서 self를 사용할 수 있음
};
즉, 람다 내부에서 클로저의 값 범주에 따라 완벽 전달을 구현할 수 있습니다. P2445는 어떤 식(expression)을 다른 식의 값 범주에 맞춰 전달하는 std::forward_like 도우미를 제공합니다:
auto callback = [m=get_message(), &scheduler](this auto &&self) -> bool {
return scheduler.submit(std::forward_like<decltype(self)>(m));
};
이제 원래 사용 사례가 동작하며, 클로저를 어떻게 사용하느냐에 따라 캡처한 객체가 복사되거나 이동됩니다.
이제 람다의 매개변수 목록에서 클로저 객체에 이름을 붙일 수 있게 되었으므로, 재귀 람다를 만들 수 있습니다! 앞의 예처럼:
auto closure = [](this auto&& self) {
self(); // 자기 자신을 계속 호출하여 스택이 넘칠 때까지
};
물론 스택을 넘치게 하는 것만이 유용한 사용법은 아닙니다. 예를 들어, 추가 타입이나 함수를 정의하지 않고 재귀적 자료구조를 방문할 수 있다는 점을 생각해 보세요. 다음과 같은 이진 트리 정의가 있다고 합시다:
struct Leaf { };
struct Node;
using Tree = std::variant<Leaf, Node*>;
struct Node {
Tree left;
Tree right;
};
잎(leaf)의 개수는 다음과 같이 셀 수 있습니다:
int num_leaves(Tree const& tree) {
return std::visit(overload( // 아래 참조
[](Leaf const&) { return 1; },
[](this auto const& self, Node* n) -> int {
return std::visit(self, n->left) + std::visit(self, n->right);
}
), tree);
}
여기서 overload는 여러 람다로 오버로드 집합을 만드는 도구이며, variant 방문에 일반적으로 사용됩니다. 예를 들어 cppreference를 참고하세요.
이 코드는 재귀를 통해 트리의 잎 개수를 셉니다. 호출 그래프의 각 함수 호출에서 현재 노드가 Leaf면 1을 반환합니다. 그렇지 않으면, 오버로드된 클로저가 self를 통해 자신을 호출하여 재귀하고, 왼쪽과 오른쪽 서브트리의 잎 개수를 더합니다.
this를 값으로 전달이제 명시적으로 객체 매개변수의 한정자를 지정할 수 있으므로, 참조가 아니라 값으로 받을 수도 있습니다. 작은 객체의 경우 런타임 성능이 더 좋아질 수 있습니다. 코드 생성에 어떻게 영향을 주는지 익숙하지 않다면, 다음 예를 보세요.
일반적인 암시적 객체 매개변수를 사용하는 다음 코드가 있다고 합시다:
struct just_a_little_guy {
int how_smol;
int uwu();
};
int main() {
just_a_little_guy tiny_tim{42};
return tiny_tim.uwu();
}
MSVC는 다음 어셈블리를 생성합니다:
sub rsp, 40
lea rcx, QWORD PTR tiny_tim$[rsp]
mov DWORD PTR tiny_tim$[rsp], 42
call int just_a_little_guy::uwu(void)
add rsp, 40
ret 0
줄마다 설명해 보겠습니다.
sub rsp, 40은 스택에 40바이트를 할당합니다. 이는 tiny_tim의 int 멤버를 위한 4바이트, uwu가 사용할 섀도 스페이스(shadow space) 32바이트, 그리고 4바이트 패딩입니다.lea 명령은 tiny_tim 변수의 주소를 uwu가 암시적 객체 매개변수를 기대하는 레지스터인 rcx에 적재합니다(사용되는 호출 규약 때문).mov는 42를 tiny_tim의 int 멤버에 저장합니다.uwu 함수를 호출합니다.만약 대신 uwu가 객체 매개변수를 값으로 받도록 다음과 같이 지정하면 어떻게 될까요?
struct just_a_little_guy {
int how_smol;
int uwu(this just_a_little_guy);
};
이 경우 생성되는 코드는 다음과 같습니다:
mov ecx, 42
jmp static int just_a_little_guy::uwu(this just_a_little_guy)
우리는 관련 레지스터에 42만 옮기고 uwu 함수로 점프(jmp)합니다. 참조로 전달하지 않기 때문에 스택에 아무 것도 할당할 필요가 없습니다. 스택을 할당하지 않으므로 함수 끝에서 할당을 해제할 필요도 없습니다. 해제할 필요가 없으므로 call로 갔다가 돌아오는 대신 곧바로 uwu로 점프하면 됩니다.
이런 종류의 최적화는 “사소한 손실의 누적”로 인해 작은 성능 손실이 수없이 반복되어, 원인을 찾기 어려운 느린 런타임으로 이어지는 상황을 방지해 줍니다.
이 문제는 다소 특수해 보이지만 실제 코드에서 발생합니다(제가 확장 구현한 std::optional에서 정확히 이 문제로 인한 프로덕션 버그 리포트를 받은 적이 있습니다). 저장된 값이 있을 때만 주어진 함수를 호출하는 optional의 transform 멤버 함수가 있다고 할 때, 문제는 다음과 같습니다:
struct oh_no {
void non_const();
};
tl::optional<oh_no> o;
o.transform([](auto&& x) { x.non_const(); }); // 컴파일 실패
MSVC가 내는 오류는 다음과 같습니다:
error C2662: ‘void oh_no::non_const(void)’: cannot convert ‘this’ pointer from ‘const oh_no’ to ‘oh_no &’
즉, non_const에 암시적 객체 매개변수로 const oh_no를 전달하려 했으나 실패했다는 뜻입니다. 하지만 그 const oh_no는 어디서 온 걸까요? 답은 optional의 구현 내부에 있습니다. 의도적으로 단순화한 버전은 다음과 같습니다:
template <class T>
struct optional {
T t;
template <class F>
auto transform(F&& f) -> std::invoke_result_t<F&&, T&>;
template <class F>
auto transform(F&& f) const -> std::invoke_result_t<F&&, const T&&>;
};
여기서 std::invoke_result_t는 transform을 SFINAE 친화적으로 만들기 위해 있습니다. 이는 대략, transform 호출이 컴파일될지 여부를 확인하고, 그렇지 않다면 전체 컴파일을 중단하지 않고 다른 일을 할 수 있게 한다는 뜻입니다. 하지만 여기에는 언어 차원의 빈틈이 조금 있습니다.
transform에 대해 오버로드 결정을 수행할 때, 컴파일러는 인자의 타입에 따라 어느 오버로드가 가장 적합한지 알아내야 합니다. 이를 위해 const 및 비-const 오버로드의 선언을 모두 인스턴스화해야 합니다. 만약 transform에 전달한 호출 가능 객체가 스스로 SFINAE 친화적이지 않고, const로 한정된 암시적 객체에 대해 유효하지 않다면(이 예제가 그렇습니다) const 멤버 함수 선언을 인스턴스화하는 것 자체가 하드 컴파일 오류가 됩니다. 아야.
명시적 객체 매개변수는 이 문제를 해결할 수 있게 해 줍니다. 왜냐하면 cvref 한정자는 여러분이 멤버 함수를 호출하는 식(expression)에서 “추론”되기 때문입니다. 즉, const optional에서 그 함수를 호출하지 않는 한, 컴파일러는 해당 선언을 인스턴스화하려 들지 않습니다. P1450의 std::copy_cvref_t를 사용하면:
template <class T>
struct optional {
T t;
template <class Self, class F>
auto transform(this Self&& self, F&& f)
-> std::invoke_result_t<F&&, std::copy_cvref_t<Self, T>>;
};
이렇게 하면 위의 예제가 컴파일되며, 동시에 transform을 계속 SFINAE 친화적으로 유지할 수 있습니다.
명시적 객체 매개변수의 동작과 유용성이 잘 전달되었기를 바랍니다. 이 기능은 Visual Studio 버전 17.2에서 사용해 볼 수 있습니다. 기능에 대한 질문, 의견, 이슈가 있다면 아래에 댓글을 남겨주시거나, visualcpp@microsoft.com으로 이메일을 보내주시거나, 트위터 @VisualC로 연락해 주세요.
Category

C++ Developer Advocate
Sy Brand는 Microsoft의 C++ Developer Advocate입니다. 임베디드 가속기를 위한 컴파일러와 디버거를 배경으로 하며, 제네릭 라이브러리 설계, 메타프로그래밍, 함수형 스타일 C++, 미정의 동작(undefined behaviour), 그리고 커뮤니티를 더 포용적이고 환영받는 곳으로 만드는 일에도 관심이 있습니다.