템플릿 매개변수 추론 규칙을 이해하고 완벽 전달(perfect forwarding)로 제네릭 코드에서 발생하는 숨은 복사를 제거하는 방법을 설명한다.
URL: https://0xghost.dev/blog/template-parameter-deduction/
Title: Template Parameter Deduction: Eliminating Hidden Copies in Generic Code
1/8/2026 59 min
이전 글인 std::move에 대한 글에서 우리는 값 범주(value category), 이동 시맨틱(move semantics), 그리고 std::move가 소유권 이전을 가능하게 하는 단순한 캐스트(cast)일 뿐이라는 점을 살펴봤습니다. std::move는 실제로 아무것도 “이동”시키지 않고, 단지 컴파일러가 객체를 바라보는 방식을 바꿔서 lvalue를 이동 가능한 xvalue로 변환해 준다는 것도 배웠죠.
하지만 std::move를 이해하는 건 전투의 절반에 불과했습니다. 서로 다른 타입의 객체를 대상으로 제네릭 라이브러리를 만들기 시작하면, 템플릿 코드 안에 숨어 있는 성능 문제들을 마주치게 됩니다. 이동 시맨틱을 이해하고 있음에도, 템플릿 매개변수 추론이 어떻게 동작하는지를 이해하지 못하면 여전히 불필요한 복사가 곳곳에서 발생할 수 있습니다.
서로 다른 데이터 타입을 감싸는 래퍼(wrapper) 객체를 만드는 라이브러리를 만든다고 상상해 봅시다. std::make_unique나 std::make_shared처럼 동작하지만, 커스텀 컨테이너 타입용이라고 생각하면 됩니다. 코드는 잘 컴파일되고 테스트도 통과했습니다. 그런데 뭔가 이상했습니다. 벤치마크를 보니 큰 객체를 감싸는 래퍼를 생성하는 데 걸리는 시간이 거의 두 배였습니다. 초당 수천 개의 객체를 만든다면, “두 배”는 무시할 수 있는 수준이 아니죠.
범인은? 숨어 있는 복사. 여기저기서.
우리는 어떤 데이터 타입이든 감쌀 수 있는 제네릭 팩토리 함수를 원했습니다. 아래처럼 깔끔한 형태로요.
cpptemplate<typename T> Wrapper<T> createWrapper(const T& value) { return Wrapper<T>(value); }
합리적으로 보이죠? 값을 넘기면 그 값을 감싼 래퍼를 돌려준다. 그런데 여기서 흥미로운 일이 생겼습니다. 프로파일링해 보니 이상한 점이 보였어요.
cpp// 임시 객체로 래퍼 생성 auto w1 = createWrapper(std::vector<int>{1, 2, 3, 4, 5});
이 한 줄이 std::vector의 **복사 생성자(copy constructor)**를 호출하고 있었습니다. 임시 객체(즉 rvalue)를 넘겼으니 이동(move)할 수 있어야 하는데 말이죠. 작은 벡터라면 큰 문제가 아닙니다. 하지만 수천 개 엔트리가 있는 맵(map)이나 몇 MB짜리 데이터를 가진 벡터 같은 큰 구조를 래핑한다면? 성능을 박살내는 주범이 됩니다.
이전 글에서 값 범주(value category)를 자세히 다뤘습니다. lvalue, rvalue, 그리고 std::move가 만드는 특별한 xvalue 범주 말이죠. 아직 읽지 않았다면 먼저 그 글부터 보길 권합니다. 여기서는 그 개념 위에서 바로 이어갈 예정입니다.
핵심 포인트만 빠르게 복습해 봅시다.
Lvalue는 정체성(identity)과 메모리상의 지속적인 위치를 가집니다. 주소를 취할 수 있죠.
cppint x = 42; // x는 lvalue int* ptr = &x; // 주소를 취할 수 있음
Rvalue는 지속적인 정체성이 없는 임시 객체입니다. 리터럴, 임시 객체, 혹은 표현식 결과 등이 해당합니다.
cppint y = x + 5; // x + 5는 rvalue(임시 값) auto v = std::vector{1, 2, 3}; // 임시 벡터는 rvalue
Xvalue(“만료(expiring) 값”)는 std::move가 만들어내는 것으로, 여전히 정체성은 있지만 “곧 소멸할 예정이니 훔쳐도 안전하다”라고 표시된 객체입니다.
앞서 보았듯 이 구분은 이동 시맨틱을 가능하게 합니다. 하지만 우리가 다루지 않았던 질문이 하나 있습니다. 템플릿은 이 값 범주들과 어떻게 상호작용할까요? 템플릿 함수를 작성했을 때 컴파일러는 언제 복사하고 언제 이동할지 어떻게 결정할까요? 바로 여기서 템플릿 추론(template deduction)이 등장하는데, 놀랄 만큼 미묘합니다.
제가 처음 시도했던 것들을 보여드리겠습니다. 아마 대부분의 C++ 프로그래머가 가장 먼저 떠올릴 법한 것들이죠.
cpptemplate<typename T> Wrapper<T> createWrapper(T value) { return Wrapper<T>(std::move(value)); }
“완벽해!”라고 생각했습니다. “이제 값을 래퍼로 이동시킬 수 있겠군.”
하지만 문제가 있었습니다. 값으로 받는(pass-by-value) 순간, 컴파일러는 인자로부터 매개변수 value를 만들기 위해 항상 복사를 수행합니다. 임시 객체를 넘겨도 우선 value로 복사(혹은 이동)된 다음, 다시 Wrapper로 이동됩니다.
즉 한 복사를 다른 복사로 바꾼 꼴이죠. lvalue(이름 있는 변수)를 넘겼다면 괜찮습니다. 호출자는 원본 객체가 필요하니까요. 하지만 rvalue(임시 객체)라면, 현대 C++(C++17+)에서는 보통 매개변수로 이동되고, 다시 Wrapper로 이동됩니다. 결국 복사+이동 대신 이동+이동이 됩니다. 최선은 아니지만, 그래도 복사보단 낫죠.
이전 글에서 기억하세요: 이동은 보통 싸고(포인터 스왑), 복사는 비쌉니다(메모리 할당 + 데이터 복사). 가능한 경우 임시 객체에서는 이동을 하고 싶습니다.
좋아요, const 참조를 쓰면 복사를 피할 수 있죠.
cpptemplate<typename T> Wrapper<T> createWrapper(const T& value) { return Wrapper<T>(value); }
함수를 호출할 때의 복사가 사라졌으니 좋습니다. 하지만 이제 다른 문제가 생깁니다. 여기서 std::move(value)를 하더라도 의미 있는 일이 일어나지 않습니다.
이유는 이렇습니다. 이전 글에서 배웠듯 std::move는 rvalue 참조로의 캐스트일 뿐입니다. “이걸 이동 가능하게 취급하라”는 의미죠. 그런데 value의 타입은 const T&입니다. 이를 캐스트하면 const T&&가 됩니다.
그리고 이전 글에서의 결정적 통찰: const 객체에서는 이동할 수 없습니다. 이동은 소스 객체를 수정하는 것을 전제로 합니다(내부 포인터를 null로 만든다든지). 그런데 const T&&는 T&&를 받는 이동 생성자에 바인딩될 수 없습니다. const가 막아버리거든요.
결국 Wrapper 생성자는 const rvalue 참조를 받고 “이건 이동 못하겠네, 복사할게”라고 판단합니다. 원점으로 돌아왔습니다. 이것이 std::move 글에서 다룬 “실수 2”와 같은 상황입니다. const에서 이동하려고 하면 조용히 복사로 폴백됩니다.
막혔습니다. 우리가 원하는 것은:
바로 여기서 템플릿 매개변수 추론과 **완벽 전달(perfect forwarding)**이 구해줍니다.
템플릿 함수를 작성할 때, 우리는 겉보기보다 더 많은 것을 컴파일러에게 말하고 있습니다. 아래의 일반적인 형태를 보죠.
cpptemplate<typename T> void someFunction(ParamType param);
someFunction(expr)를 호출하면 컴파일러는 두 가지 타입을 알아내야 합니다.
T는 무엇인가?ParamType은 무엇인가?컴파일러는 ParamType이 어떻게 선언되었는지에 따라 세 가지 서로 다른 추론 규칙 집합을 적용합니다. 이 규칙들을 이해하는 것이 템플릿 추론을 마스터하는 핵심입니다.
간단한 경우부터 시작해 봅시다.
cpptemplate<typename T> void func(T& param); int x = 42; const int cx = x; const int& rx = x; func(x); func(cx); func(rx);
추론 규칙: 인자의 참조성(reference-ness)은 무시하지만 const성(const-ness)은 유지한다.
func(x)에서는 T가 int로 추론되고, param은 int&.func(cx)에서는 T가 const int로 추론되고, param은 const int&.func(rx)에서는 rx의 참조는 무시되고 T는 const int, param은 const int&.왜 중요할까요? const 객체를 참조 매개변수로 넘기면 const성이 T에 포함되어 전파되기 때문입니다. 템플릿 로직에서 const 여부가 의미가 있을 수 있죠.
하지만 주의: 이 함수에는 rvalue를 넘길 수 없습니다. func(42)는 컴파일 에러입니다. rvalue는 non-const lvalue 참조에 바인딩할 수 없으니까요. 이는 임시 객체를 실수로 수정하는 것을 막기 위한 설계입니다.
매개변수를 const T&로 바꾸면 rvalue도 받을 수 있습니다.
cpptemplate<typename T> void func(const T& param); func(42); // 이제 동작. T는 int, param은 const int&
하지만 앞에서 봤듯 이렇게 하면 모든 것이 const가 되어 이동이 막힙니다.
이제 반대 극단입니다.
cpptemplate<typename T> void func(T param); // 참조 아님 int x = 42; const int cx = x; const int& rx = x; func(x); func(cx); func(rx);
추론 규칙: 완전히 독립적인 복사본을 만든다고 보고, 참조성과 const성을 모두 제거한다.
func(x): T는 int, param은 int.func(cx): T는 int, param은 int(const 제거).func(rx): T는 int, param은 int(참조/const 모두 제거).이 과정을 **디케이(decay)**라고도 부릅니다. 복사본은 원본과 독립이므로, 원본이 const였는지 복사본이 알 필요가 없다는 점에서 합리적이죠.
또 하나 중요한 디케이 규칙: 배열은 포인터로 디케이됩니다. 배열을 값으로 넘기면 첫 원소에 대한 포인터로 변합니다.
cppconst char name[] = "Hello"; func(name); // T는 const char*, const char[6]이 아님
C/C++의 유산이며, sizeof가 매개변수에서는 배열 크기가 아니라 포인터 크기를 주는 이유이기도 합니다. 템플릿 메타프로그래밍에서 정확한 타입을 보존하려 한다면 함정이 될 수 있습니다.
여기가 게임 체인저이며, 우리의 문제를 해결한 핵심입니다. 동시에 가장 복잡하니 차근차근 봅시다.
우선 T&&의 의미부터. 보통 &&는 “rvalue 참조”입니다. 하지만 템플릿 문맥에서의 T&&는 특별한 의미를 가집니다. 전달 참조(forwarding reference)(Scott Meyers가 퍼뜨린 용어로 “유니버설 참조”)라 불리며, lvalue와 rvalue 둘 다에 바인딩될 수 있습니다.
핵심 규칙:
lvalue를 넘기면 T는 lvalue 참조로 추론된다. rvalue를 넘기면 T는 비참조 타입으로 추론된다.
예제로 보죠.
cpptemplate<typename T> void func(T&& param); int x = 42; const int cx = x; func(x); func(cx); func(42);
func(x): x는 lvalue → T는 int&로 추론 → param 타입은 int& &&func(cx): cx는 lvalue → T는 const int& → param 타입은 const int& &&func(42): 42는 rvalue → T는 int → param 타입은 int&&여기서 int& && 같은 “참조의 참조”는 원래 C++에서 허용되지 않습니다. 하지만 참조 붕괴(reference collapsing) 규칙이 적용됩니다.
T& & → T&T& && → T&T&& & → T&T&& && → T&&즉 연쇄 중 어디엔가 lvalue 참조가 있으면 결과는 lvalue 참조이고, 둘 다 rvalue 참조일 때만 rvalue 참조가 됩니다.
따라서:
func(x): T는 int&, param은 int& && → int&func(cx): T는 const int&, param은 const int& && → const int&func(42): T는 int, param은 int&&정말 멋집니다. 값 범주(lvalue vs rvalue)가 타입 T 자체에 인코딩되었습니다. 이제 이 정보를 활용할 수 있습니다.
좀 더 자세한 예시를 보죠.
cpptemplate<typename T> void process(T&& value) { std::cout << "Type T: " << typeid(T).name() << std::endl; std::cout << "Is lvalue ref: " << std::is_lvalue_reference_v<T> << std::endl; } int main() { int x = 5; process(x); // T = int&, is_lvalue_reference = true process(10); // T = int, is_lvalue_reference = false const int y = 5; process(y); // T = const int&, is_lvalue_reference = true }
WARNING
typeid(T).name()에 대한 주의typeid(T).name()은 참조 한정자(&, &&)나 const/volatile 한정자를 보존하지 않습니다. 따라서 T가 int&나 const int&로 추론되더라도, 컴파일러에 따라 typeid(T).name()은 int와 같은 이름을 출력할 수 있습니다.
이 예제에서 T가 어떻게 추론되는지 확인하는 올바른 방법은 typeid가 아니라 std::is_lvalue_reference_v<T>, std::is_const_v<std::remove_reference_t<T>> 같은 타입 트레이트(type traits)입니다.
유니버설 참조와 참조 붕괴를 이해했으니, 이제 팩토리 함수를 올바르게 작성할 수 있습니다. 값 범주와 std::move에서 배운 것들이 여기서 합쳐집니다.
cpptemplate<typename T> Wrapper<T> createWrapper(T&& value) { return Wrapper<T>(std::forward<T>(value)); }
이제 어떤 일이 일어나는지 따라가 봅시다.
lvalue로 호출할 때:
cppstd::vector<int> data = {1, 2, 3, 4, 5}; auto w = createWrapper(data);
T는 std::vector<int>&로 추론value 타입은 std::vector<int>&std::forward<std::vector<int>&>(value)는 std::vector<int>&로 캐스트rvalue로 호출할 때:
cppauto w = createWrapper(std::vector<int>{1, 2, 3, 4, 5});
T는 std::vector<int>로 추론value 타입은 std::vector<int>&&std::forward<std::vector<int>>(value)는 std::vector<int>&&로 캐스트완벽합니다. 인자로 무엇을 넘기느냐에 따라 함수가 자동으로 올바른 동작을 합니다.
그렇다면 std::forward는 실제로 무엇을 할까요? std::move처럼 생각보다 단순합니다. 이것도 캐스트일 뿐입니다. 표준 라이브러리의 실제 구현(여기서는 libstdc++)을 봅시다.
cpp/** * @brief lvalue를 전달한다. * @return 매개변수를 지정된 타입으로 캐스트한 결과. * * 이 함수는 "완벽 전달"을 구현하는 데 사용된다. */ template<typename _Tp> constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept { return static_cast<_Tp&&>(__t); } /** * @brief rvalue를 전달한다. * @return 매개변수를 지정된 타입으로 캐스트한 결과. * * 이 함수는 "완벽 전달"을 구현하는 데 사용된다. */ template<typename _Tp> constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept { static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument" " substituting _Tp is an lvalue reference type"); return static_cast<_Tp&&>(__t); }
밑줄과 엄격한 타입이 겁나 보일 수도 있지만, 이 코드가 전달을 안전하게 처리하는 방식이 그대로 드러납니다.
오버로드가 두 개 있고, 각각 다른 상황을 처리합니다.
cpptemplate<typename _Tp> constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept { return static_cast<_Tp&&>(__t); }
완벽 전달에서 보통 호출되는 버전입니다(이름 있는 변수를 std::forward로 넘길 때). remove_reference로 _Tp의 참조를 제거한 타입에 대한 lvalue 참조를 받고, 다시 _Tp&&로 캐스트합니다.
_Tp가 int&처럼 lvalue 참조로 추론되면 _Tp&&는 붕괴되어 int&. 결과: lvalue._Tp가 int처럼 비참조 타입이면 _Tp&&는 int&&. 결과: rvalue.cpptemplate<typename _Tp> constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
이미 rvalue인 것을 전달하되, 그 상태를 유지해야 할 때를 다룹니다.
여기서 static_assert를 주목하세요.
cppstatic_assert(!std::is_lvalue_reference<_Tp>::value, ...);
이건 컴파일 타임 안전장치입니다. rvalue를 lvalue 참조 타입으로 전달하려는 시도를 금지합니다. 임시 객체를 마치 지속적인 정체성이 있는 것처럼 다루어 나중에 참조하게 되는 위험을 막아주죠.
“마법”의 핵심은 여전히 참조 붕괴
안전장치와 오버로드가 있더라도 핵심 메커니즘은 static_cast<_Tp&&>(__t)입니다.
constexpr: 전부 컴파일 타임에서 처리되고, 런타임 오버헤드가 없습니다.noexcept: 참조 캐스트는 예외를 던지지 않으니 안전합니다.간결하고 우아하며, 런타임 비용이 사실상 0입니다.
이제 std::move 글과 연결해봅시다. std::move는 대략 이렇게 생겼죠.
cpptemplate<typename T> typename std::remove_reference<T>::type&& move(T&& param) noexcept { return static_cast<typename std::remove_reference<T>::type&&>(param); }
둘 다 캐스트지만 목적이 다릅니다.
std::move**는 **무조건적(unconditional)**입니다. 무엇을 넘기든 항상 rvalue 참조(xvalue)를 만듭니다. “이 객체는 이제 끝났다.”std::forward**는 **조건적(conditional)**입니다. 들어온 값 범주를 그대로 유지합니다. “들어온 방식 그대로 전달하라.”왜 std::move를 쓰면 안 될까요? std::move는 항상 rvalue 참조로 캐스트합니다.
createWrapper에서 std::move를 쓰면 lvalue도 이동시켜 버립니다. 호출자는 여전히 원본 객체를 필요로 하는데 말이죠. 이전 글에서 봤듯, 이동된 객체를 다시 사용하면 “유효하지만 상태는 불명(valid but unspecified)”인 상태가 됩니다. 파괴하거나 재할당하는 것은 안전하지만, 읽는 것은 안전하지 않습니다.
경험 법칙: 객체를 더 이상 쓰지 않을 때는
std::move. 템플릿 함수에서는 호출자의 의도를 보존하기 위해std::forward.
이론은 좋지만 실제로 복사가 사라졌는지 증명해야 했습니다. 그래서 Compiler Explorer(Godbolt)를 열었습니다.
생성자 호출을 로그로 남기는 간단한 클래스를 만들죠.
cpp#include <iostream> #include <vector> #include <utility> struct Heavy { std::vector<int> data; Heavy(std::vector<int> d) : data(std::move(d)) { std::cout << "Constructed with data size: " << data.size() << "\n"; } Heavy(const Heavy& other) : data(other.data) { std::cout << "Copied (size: " << data.size() << ")\n"; } Heavy(Heavy&& other) noexcept : data(std::move(other.data)) { std::cout << "Moved (size: " << data.size() << ")\n"; } };
cpptemplate<typename T> void processNaive(const T& value) { Heavy h(value); } int main() { std::cout << "=== Naive (const T&) ===" << std::endl; processNaive(Heavy{std::vector<int>{1,2,3}}); }
출력:
=== Naive (const T&) ===
Constructed with data size: 3
Copied (size: 3)
임시 객체를 넘겼는데도 복사 생성자가 호출됐습니다. const 참조가 rvalue에 바인딩되긴 하지만, 결국 Heavy h(value)에서 복사해야 했습니다.
cpptemplate<typename T> void processOptimized(T&& value) { Heavy h(std::forward<T>(value)); } int main() { std::cout << "=== Optimized (T&& + forward) ===" << std::endl; processOptimized(Heavy{std::vector<int>{1,2,3}}); }
출력:
=== Optimized (T&& + forward) ===
Constructed with data size: 3
Moved (size: 3)
이동 생성자가 호출됩니다. 복사는 없습니다. 포인터만 옮깁니다.
lvalue 동작을 깨지 않는지 확인해 봅시다.
cppint main() { std::cout << "=== Testing with lvalue ===" << std::endl; Heavy h1{std::vector<int>{1,2,3}}; processOptimized(h1); std::cout << "h1 still valid, size: " << h1.data.size() << std::endl; }
출력:
=== Testing with lvalue ===
Constructed with data size: 3
Copied (size: 3)
h1 still valid, size: 3
완벽합니다. lvalue는 복사되고(그래야 함), 원본은 그대로 유지됩니다.
이제 기본을 이해했으니, 자주 만나는 함정들을 공유해 보겠습니다.
처음엔 누구나 여기서 걸립니다.
cpptemplate<typename T> void wrapper(T&& param) { // param은 rvalue 참조죠? // 틀렸습니다! param은 이름이 있으므로 lvalue입니다. someFunction(param); // lvalue로 전달됨 someFunction(std::forward<T>(param)); // 원래 값 범주로 전달 }
param이 T&&로 선언되어도, 이름을 갖는 순간 표현식으로서는 lvalue입니다. 그래서 std::forward가 필요합니다. 원래 값 범주를 복원하기 위해서죠.
cpptemplate<typename T> void bad(T&& param) { foo(std::forward<T>(param)); bar(std::forward<T>(param)); // 위험! T가 비참조라면, // foo가 param에서 이미 이동했을 수 있음 }
첫 번째 std::forward에서 T가 비참조 타입(즉 rvalue가 들어옴)으로 추론되면, foo는 rvalue 참조를 받아 param에서 이동할 가능성이 큽니다. 그 다음 두 번째 호출은 이동된 객체를 사용하게 됩니다. 대신 이렇게 하세요.
cpptemplate<typename T> void good(T&& param) { foo(param); // lvalue로 전달(필요하면 복사) bar(std::forward<T>(param)); // 마지막 사용에서만 이동 가능 }
값 전달 시 배열이 포인터로 디케이된다는 걸 기억하세요.
cpptemplate<typename T> void print(T param) { std::cout << sizeof(param) << std::endl; } int arr[10]; print(arr); // 40이 아니라 8(포인터 크기)을 출력
배열 타입을 보존하고 싶다면 참조를 사용하세요.
cpptemplate<typename T, size_t N> void print(T (¶m)[N]) { std::cout << "Array of " << N << " elements" << std::endl; } int arr[10]; print(arr); // "Array of 10 elements"
값으로 받으면 const는 제거됩니다.
cpptemplate<typename T> void process(T param) { // const 객체를 넘겨도 T에는 const가 포함되지 않음 } const int x = 42; process(x); // T는 const int가 아니라 int
대개 원하는 동작(복사본은 독립)입니다. 다만 이 사실을 알고 있어야 합니다.
템플릿 추론을 이해하는 것은 학술적 잡학이 아닙니다. C++에서 제로 오버헤드 추상화를 쓰는 기반입니다. 이 규칙들을 알기 전까지 저는 “컴파일되고 올바르게 실행되지만, 곳곳에서 숨은 복사를 만드는” 템플릿들을 작성하고 있었습니다.
세 가지 추론 케이스(참조/포인터, 값, 전달 참조)는 각각 다른 목적이 있습니다.
T& 또는 const T&: 원본 객체를 참조하고 복사를 피해야 하지만 이동 지원은 필요 없을 때T(값 전달): 독립 복사본이 필요하고 원본의 const성은 중요하지 않거나, 복사가 저렴할 때T&&(전달 참조) + std::forward: 값 범주를 보존하며 인자를 완벽 전달해야 할 때. lvalue/rvalue 모두에서 효율적으로 동작해야 하는 제네릭 코드의 정답단일 인자에서의 완벽 전달은 시작일 뿐입니다. 진짜 힘은 가변 인자 템플릿(variadic templates)과 결합될 때 나옵니다. 이 조합은 std::make_unique, std::make_shared, 컨테이너의 emplacement 메서드 같은 표준 라이브러리 기능의 기반입니다.
가변 인자 템플릿이 없었다면, 제네릭 팩토리 함수는 인자 개수별로 여러 오버로드를 작성해야 했을 겁니다. C++11 이전에는 1개, 2개, 3개… 대개 10개 정도까지 임의의 한계를 두고 오버로드를 제공했죠. 코드 중복, 유지보수 지옥, 인위적 제한의 원인이었습니다.
가변 인자 템플릿은 완벽 전달 시맨틱을 유지하면서 이 문제를 우아하게 해결합니다. 컴파일러가 필요한 인자 개수에 맞는 특수화를 단 하나의 템플릿 정의로부터 생성해낼 수 있습니다.
파라미터 팩(parameter pack)은 0개 이상의 템플릿 인자를 받는 템플릿 매개변수입니다. 문법에서 줄임표(...)는 세 가지 서로 다른 문맥에서 쓰이며 각각 의미가 다릅니다.
cpptemplate<typename... Args> // 템플릿 파라미터 팩 선언 void function(Args&&... args) { // 팩을 함수 매개변수로 확장 // args... 는 표현식에서 팩 확장 // pack expansion }
세 가지 사용을 분해하면:
typename... Args는 Args가 임의 개수의 타입에 매칭될 수 있는 템플릿 파라미터 팩임을 선언Args&&...는 전달 참조들의 콤마 구분 목록으로 확장args...는 표현식에서 팩을 확장(곧 예시로)컴파일러가 가변 템플릿 호출을 보면 팩의 각 요소를 독립적으로 추론하며, 앞서 다룬 전달 참조 규칙을 각 인자마다 적용합니다.
std::make_unique가 어떻게 동작하는지 생각해 봅시다. 이 함수는 임의 개수의 인자를 받아 객체를 생성할 때 완벽 전달합니다.
cpptemplate<typename T, typename... Args> std::unique_ptr<T> make_unique(Args&&... args) { return std::unique_ptr<T>(new T(std::forward<Args>(args)...)); }
예시로 추적해보죠.
auto ptr = make_unique<std::string>("Hello", 5);
컴파일러는 다음과 같이 추론합니다.
T는 std::string(명시적으로 지정)Args는 {const char(&)[6], int}(두 타입의 팩)args의 타입은 {const char(&)[6], int&&}std::forward<Args>(args)... 확장은 다음이 됩니다.std::forward<const char(&)[6]>(args_0), std::forward<int>(args_1)
std::string 생성자 string(const char*, size_t)로 전달됩니다.핵심: 팩의 각 요소는 값 범주를 독립적으로 유지합니다. 어떤 인자는 lvalue로, 다른 인자는 rvalue로 전달될 수 있죠. 완벽 전달은 팩 전체에 대해 원소별(element-by-element)로 동작합니다.
그래서 std::forward<Args>(args)... 패턴에서 Args 팩을 사용하는 것입니다. 각 std::forward 호출이 해당 인자에 맞는 타입을 받아, 그 인자의 값 범주를 보존합니다.
파라미터 팩은 함수 호출뿐 아니라 여러 문맥에서 확장할 수 있습니다. 각 확장은 패턴을 팩의 각 요소에 반복 적용하여 콤마로 구분된 목록을 만듭니다.
cpptemplate<typename... Args> void print_sizes() { // 폴드 표현식(C++17): (cout << sizeof(T1)), (cout << sizeof(T2)), ...로 확장 ((std::cout << sizeof(Args) << " "), ...); std::cout << '\n'; } print_sizes<int, double, char>(); // 출력: 4 8 1
이 폴드 표현식은 좌→우로 평가되며 각 타입의 크기를 출력합니다. 바깥 괄호는 폴드 표현식 문법상 필요합니다.
팩 확장과 완벽 전달을 결합하면 각 인자를 처리하면서도 값 범주를 보존할 수 있습니다.
cpptemplate<typename... Args> void log_and_forward(Args&&... args) { // 각 인자를 처리(소비하지 않음) ((std::cout << "Arg: " << args << '\n'), ...); // 이후 모두를 다른 함수로 전달 actual_function(std::forward<Args>(args)...); }
핵심 포인트: 한 함수 안에서 파라미터 팩을 여러 번 확장할 수 있고, 확장마다 다른 패턴을 사용할 수 있습니다. 다만 인자를 forward해서(잠재적으로 move하여) 소비했다면, 단일 인자 전달과 마찬가지로 다시 사용하면 안 됩니다.
sizeof... 연산자(일반 sizeof와 다름)는 파라미터 팩 요소 개수를 반환합니다.
cpptemplate<typename... Args> void count_args(Args&&... args) { std::cout << "Received " << sizeof...(Args) << " arguments\n"; } count_args(1, "hello", 3.14); // 출력: Received 3 arguments
컴파일 타임 어설션에도 유용합니다.
cpptemplate<typename... Args> void at_least_two(Args&&... args) { static_assert(sizeof...(Args) >= 2, "Function requires at least 2 arguments"); }
sizeof...는 타입 팩(Args)과 함수 매개변수 팩(args) 모두에 대해 동작하며, 같은 개수를 반환합니다.
개념을 모두 합치면, 가변 완벽 전달을 보여주는 실용적인 팩토리 패턴은 다음과 같습니다.
cpptemplate<typename T> class Factory { public: template<typename... Args> static T create(Args&&... args) { return T(std::forward<Args>(args)...); } template<typename Container, typename... Args> static void emplace_into(Container& container, Args&&... args) { container.emplace_back(std::forward<Args>(args)...); } }; struct Widget { std::string name; int value; double weight; Widget(std::string n, int v, double w) : name(std::move(n)), value(v), weight(w) {} }; int main() { Widget w = Factory<Widget>::create("Sensor", 42, 3.14); std::vector<Widget> widgets; Factory<Widget>::emplace_into(widgets, "Widget1", 1, 1.0); Factory<Widget>::emplace_into(widgets, "Widget2", 2, 2.0); }
emplace_into는 컨테이너 연산에서 완벽 전달이 왜 중요한지 보여줍니다. 완벽 전달이 없다면:
Widget 생성완벽 전달과 emplacement를 쓰면 Widget이 벡터가 할당한 메모리 안에 직접 생성됩니다. 임시도 없고 이동도 없습니다. 이것이 “emplacement”이며 현대 C++의 중요한 성능 개선 중 하나입니다.
C++17의 폴드 표현식은 흔한 팩 연산을 간결하게 해줍니다. 이전에는 재귀 템플릿 인스턴스화나 헬퍼 함수가 필요했지만, 이제 컴파일러가 직접 처리합니다.
cpp// 폴드 표현식으로 모든 인자 합산 template<typename... Args> auto sum(Args... args) { return (args + ...); // 단항 우측 폴드 } sum(1, 2, 3, 4, 5); // 15
(args + ...)는 (arg1 + (arg2 + (arg3 + (arg4 + arg5))))로 확장됩니다. 폴드에는 네 가지가 있습니다.
(args op ...) → (arg1 op (... op (argN-1 op argN)))(... op args) → (((arg1 op arg2) op ...) op argN)(args op ... op init) → (arg1 op (... op (argN op init)))(init op ... op args) → ((init op arg1) op ...) op argN콤마 연산자로 각 인자에 대해 함수를 호출하는 실용 예시:
cpptemplate<typename... Args> void print_all(Args&&... args) { ((std::cout << args << ' '), ...); std::cout << '\n'; } print_all(1, "hello", 3.14); // 출력: 1 hello 3.14
이는 (std::cout << arg1 << ' '), (std::cout << arg2 << ' '), (std::cout << arg3 << ' ')로 확장됩니다. 콤마 연산자는 각 표현식을 순서대로 평가하고 마지막 결과만 남깁니다.
C++20 컨셉(concepts)은 가변 템플릿에 제약을 걸어, 완벽 전달을 유지하면서도 타입 안전성을 보장할 수 있게 해줍니다.
cpptemplate<typename... Args> requires (std::is_arithmetic_v<std::decay_t<Args>> && ...) void process_numbers(Args&&... args) { ((std::cout << args << '\n'), ...); } process_numbers(1, 2.5, 3); // OK // process_numbers(1, "text", 3); // 에러
제약식 (std::is_arithmetic_v<std::decay_t<Args>> && ...) 자체가 폴드 표현식으로, 팩의 모든 타입이 조건을 만족해야 합니다. 전달 참조는 참조 타입으로 추론될 수 있으니, 기반 타입을 검사하려면 std::decay_t가 필요합니다.
완벽 전달 + 컨셉은 제로 오버헤드 추상화와 컴파일 타임 타입 안전성을 동시에 제공하는 강력한 조합입니다.
지금까지의 추론 메커니즘은 타입을 템플릿 인자로 전달하는 방식이었습니다. 하지만 더 높은 수준의 추상화도 있습니다. 템플릿 자체를 템플릿 인자로 전달하는 것입니다. 이를 “템플릿 템플릿 매개변수(template template parameter)”라고 하며, 어떤 컨테이너 타입에서도 동작하는 제네릭 어댑터를 만들 수 있습니다.
스택(stack) 자료구조를 만든다고 해봅시다. 스택은 내부 저장용 컨테이너가 필요하지만, 그 컨테이너가 vector든 deque든 list든 스택 인터페이스에 영향이 없어야 합니다. 이상적으로는 컨테이너 타입에 대해 제네릭해야 합니다.
순진한 접근은 이런 식입니다.
cpptemplate<typename Container> class Stack { Container data_; // Container는 어떤 element 타입을 담아야 하지? };
하지만 Container는 완성된 타입입니다. std::vector를 쓰려면 Stack<std::vector<int>>처럼 element 타입을 매번 하드코딩해야 하죠. 우리가 원하는 건 “임의의 컨테이너 템플릿을 받아, 내가 필요한 element 타입으로 인스턴스화하겠다”입니다.
템플릿 템플릿 매개변수는 std::vector<int> 같은 타입이 아니라 std::vector 같은 템플릿을 받습니다.
cpp// 일반 템플릿 매개변수: 완성된 타입을 받음 template<typename T> class SimpleContainer { T value; }; // 템플릿 템플릿 매개변수: 템플릿을 받음 template<template<typename> class Container> class Wrapper { Container<int> int_container; Container<std::string> string_container; }; // 사용 Wrapper<std::vector> w; // 컴파일 에러! (아래 설명)
문제는 표준 컨테이너 대부분이 template<typename> class Container 형태와 맞지 않는다는 것입니다. 예를 들어 std::vector는 사실 두 개의 템플릿 매개변수를 가집니다.
cpptemplate<typename T, typename Allocator = std::allocator<T>> class vector;
두 번째 인자가 기본값이 있어도, 템플릿 시그니처 자체는 두 인자를 요구합니다. 그래서 template<typename> class Container는 std::vector를 받을 수 없습니다.
C++11부터는 템플릿 템플릿 매개변수에서도 파라미터 팩을 사용할 수 있어, (기본 인자 포함) 임의 개수 매개변수를 가진 템플릿을 매칭할 수 있습니다.
cpptemplate<template<typename...> class Container> class Wrapper { Container<int> int_container; Container<std::string> string_container; }; Wrapper<std::vector> w; // 이제 동작
typename...은 기본값이 있는 매개변수까지 포함해, 어떤 개수의 매개변수를 가진 템플릿과도 매칭되게 합니다. 표준 컨테이너와 함께 쓰려면 필수입니다.
컨셉까지 결합해 컨테이너에 구애받지 않는 스택을 만들 수 있습니다.
cpptemplate<template<typename...> class Container> class Stack { private: Container<int> data_; public: void push(int value) { static_assert( requires { data_.push_back(value); }, "Container must support push_back" ); data_.push_back(value); } int pop() { int value = data_.back(); data_.pop_back(); return value; } bool empty() const { return data_.empty(); } }; Stack<std::vector> vec_stack; Stack<std::deque> deque_stack; Stack<std::list> list_stack;
각 인스턴스는 서로 다른 내부 컨테이너를 가지지만 스택 인터페이스는 동일합니다. 이것이 템플릿 템플릿 매개변수의 힘입니다.
NOTE
표준 라이브러리의 std::stack은 다른 접근을 씁니다. 완성된 컨테이너 타입을 일반 템플릿 매개변수로 받고 기본값을 제공합니다: template<typename T, typename Container = std::deque<T>> class stack. 그래서 std::stack<int, std::vector<int>>처럼 씁니다. 템플릿 템플릿 매개변수는 어떤 제네릭 프로그래밍 시나리오에서 더 유연한 대안 설계를 제공합니다.
템플릿 템플릿 매개변수는 완벽 전달과 결합하면 더 강력합니다. 파라미터 팩으로 임의의 컨테이너를 만드는 팩토리는 이런 형태가 됩니다.
cpptemplate<template<typename...> class Container, typename... Args> auto make_container(Args&&... args) -> Container<std::common_type_t<Args...>> { return Container<std::common_type_t<Args...>>{std::forward<Args>(args)...}; } auto vec = make_container<std::vector>(1, 2, 3, 4, 5); // std::vector<int> auto lst = make_container<std::list>(1.0, 2.5, 3.7); // std::list<double>
작동을 분해하면:
Container는 std::vector나 std::list로 명시됨Args는 함수 인자로부터 추론됨std::common_type_t<Args...>가 모든 인자의 공통 타입(원소 타입)을 계산이 패턴은 컨테이너 종류에 상관없이 동일하게 동작하는 빌더/팩토리 함수를 만들 때 유용합니다.
각 레벨에서 무엇이 일어나는지 이해하는 것이 중요합니다.
일반 템플릿 매개변수(typename T):
cpptemplate<typename T> void func(T value); func<std::vector<int>>(vec); // T는 완성 타입 std::vector<int>
템플릿 템플릿 매개변수(template<typename...> class Container):
cpptemplate<template<typename...> class Container> void func(); func<std::vector>(); // Container는 std::vector 템플릿 자체 // Container<int>, Container<double> 등으로 인스턴스화 가능
템플릿 템플릿 매개변수는 같은 코드로 Container<int>, Container<double> 등 다양한 인스턴스화를 내부에서 사용할 수 있게 해줍니다.
C++17 이전에는 클래스 템플릿을 인스턴스화할 때, 생성자 인자에서 충분히 유추할 수 있더라도 모든 템플릿 인자를 명시해야 했습니다. 그래서 코드가 장황해지고 make_ 헬퍼 함수가 늘어났죠. CTAD는 이 의식을 제거하여, 컴파일러가 생성자 인자에서 직접 템플릿 인자를 추론하게 합니다.
C++17 이전에는 중복 타입 정보를 써야 했습니다.
cpp// CTAD 이전(C++14 이하) std::pair<int, std::string> p1(42, "hello"); auto p2 = std::make_pair(42, "hello");
함수 템플릿은 항상 인자에서 타입을 추론했는데,
cpptemplate<typename T> void func(T value); func(42); // T는 자동으로 int로 추론
왜 클래스 템플릿은 못했을까요? 클래스 템플릿은 함수 호출처럼 단일 사용 지점이 아니라 생성자들이 있고, 생성자마다 다른 추론 규칙이 필요할 수 있기 때문입니다.
C++17은 **추론 가이드(deduction guides)**를 표준화하여 이를 해결했습니다. 즉 “이 생성자 인자들로부터 이렇게 템플릿 인자를 추론하라”는 명시 규칙입니다.
CTAD에서는 유추 가능한 경우 템플릿 인자 명시를 생략할 수 있습니다.
cpp// C++17 이후 std::pair p(42, "hello"); std::vector v{1, 2, 3}; std::optional opt{42}; std::mutex mtx; std::lock_guard guard{mtx};
각 예시는 클래스 생성자에서 암시적으로 생성된 추론 가이드 덕분에 동작합니다.
클래스 템플릿의 각 생성자에 대해 컴파일러는 대응하는 추론 가이드를 암시적으로 생성합니다.
cpptemplate<typename T> class MyClass { public: MyClass(T value) : data_(value) {} private: T data_; }; // 컴파일러가 암시적으로 생성: // template<typename T> // MyClass(T) -> MyClass<T>;
이 가이드는 “T 타입 인자 하나로 MyClass를 생성하면 템플릿 인자는 T로 추론하라”라는 의미입니다.
cppMyClass obj(42); // MyClass<int> MyClass obj2("hi"); // MyClass<const char*>
추론은 함수 템플릿 추론 규칙과 동일하며, 참조 붕괴/전달 참조도 포함됩니다.
암시적 가이드가 부적절한 경우가 있어, 명시적 추론 가이드로 보완하거나 덮어쓸 수 있습니다. 포인터에서 추론하는 경우가 대표적입니다.
cpptemplate<typename T> class Container { T* data_; size_t size_; public: Container(T* data, size_t size) : data_(data), size_(size) {} }; // 포인터에서 한 단계 포인터를 제거하는 추론 가이드 template<typename T> Container(T*, size_t) -> Container<T>; int* ptr = new int[10]; Container c(ptr, 10); // Container<int>
이 패턴은 포인터로 리소스를 관리하는 컨테이너/래퍼에서 흔합니다.
CTAD를 완벽 전달과 결합할 때는, 전달 참조가 참조 타입으로 추론될 수 있으므로 추론 가이드에 주의해야 합니다.
cpptemplate<typename T> class Wrapper { T value_; public: template<typename U> Wrapper(U&& value) : value_(std::forward<U>(value)) {} }; int x = 5; Wrapper w1(42); Wrapper w2(std::string("hi")); Wrapper w3(x); // Wrapper<int>가 되어야지 Wrapper<int&>면 안 됨!
가이드가 없으면 w3가 Wrapper<int&>로 추론될 수 있습니다. 해결은 std::decay_t를 사용하는 추론 가이드입니다.
cpptemplate<typename U> Wrapper(U&&) -> Wrapper<std::decay_t<U>>; int x = 5; Wrapper w1(42); // Wrapper<int> Wrapper w2(std::string("hi")); // Wrapper<std::string> Wrapper w3(x); // Wrapper<int>
std::decay_t<U>는:
를 적용해, 개발자가 기대하는 “값 타입을 저장하는 래퍼” 의미에 맞춰줍니다.
cpptemplate<typename T> class Array { public: Array(T* ptr) : data(ptr) {} private: T* data; }; int arr[10]; Array a(arr); // Array<int>
배열 타입을 보존해야 한다면 참조 기반 생성자를 사용하세요.
cpptemplate<typename T, size_t N> class Array { public: Array(T (&arr)[N]); }; int arr[10]; Array a(arr); // Array<int, 10>
cppstd::vector v1{1, 2, 3}; const std::vector v2{1, 2, 3}; auto v3 = v1; // std::vector<int> auto v4 = v2; // std::vector<int>
const는 변수 v2에 붙는 것이지 std::vector<int> 타입의 일부가 아닙니다. 복사하면 새 벡터는 기본적으로 non-const입니다.
cppstd::vector v1{1, 2, 3}; std::vector v2(10, 5); auto v3 = std::vector{1, 2, 3}; auto v4 = std::vector(10, 5);
중괄호 {}는 가능한 경우 initializer_list 생성자를 선택하고, 괄호 ()는 일반 생성자를 선택합니다. CTAD는 이 차이를 존중합니다.
원치 않는 추론은 아예 컴파일 에러로 만들 수 있습니다.
cpptemplate<typename T> class StrictWrapper { public: StrictWrapper(T value) : value_(value) {} private: T value_; }; template<typename T> StrictWrapper(T*) -> StrictWrapper<T> = delete; int x = 5; StrictWrapper w1(x); // OK // StrictWrapper w2(&x); // 에러
값 의미를 의도했는데 포인터가 실수로 래핑되는 위험한 패턴을 막을 때 유용합니다.
CTAD가 생겼으니 std::make_unique, std::make_shared 같은 함수는 필요 없을까요? 그렇지 않습니다. 서로 용도가 다릅니다.
CTAD를 쓸 때:
make_ 함수를 쓸 때:
std::make_shared는 컨트롤 블록과 객체를 함께 할당)예시:
cpp// CTAD로는 여전히 장황 auto ptr = std::unique_ptr<int>(new int(42)); // 더 안전하고 깔끔 auto ptr2 = std::make_unique<int>(42); template<typename T, typename... Args> auto create(Args&&... args) { return std::make_unique<T>(std::forward<Args>(args)...); }
템플릿 추론 에러는 악명 높게도 컴파일러 메시지가 난해합니다. 한 번의 잘못된 추론이 연쇄적인 에러를 유발해, 핵심 원인이 수백 줄 에러 속에 묻히죠. 이 에러들에서 중요한 정보를 뽑아내는 방법을 아는 것은 생산적인 C++ 개발에 필수입니다.
추론이 실패하면 컴파일러는 단순히 “타입이 틀렸음”이라고 말하지 않습니다. 대신:
를 모두 출력합니다. 그래서 실제 문제는 메시지 중간에 파묻히곤 합니다.
가장 강력하면서도 단순한 방법: 타입을 드러내는 컴파일 에러를 유도합니다. 정의하지 않은 템플릿을 선언만 해두고 인스턴스화시키는 방식입니다.
cpptemplate<typename T> struct TD; template<typename T> void func(T&& param) { TD<T> t_type; TD<decltype(param)> param_type; } int x = 5; func(x); // 에러 메시지에 TD<int &> 같은 형태로 타입이 그대로 출력됨
원하는 어떤 타입 표현식에도 쓸 수 있습니다. 예를 들어 TD<std::common_type_t<int, double>>는 double임을 알려줄 겁니다.
코드를 실행해야 한다면 타입 트레이트와 static_assert로 컴파일 타임 검증을 할 수 있습니다.
cpptemplate<typename T> void func(T&& param) { if constexpr (std::is_lvalue_reference_v<T>) { std::cout << "T is an lvalue reference\n"; } else if constexpr (std::is_rvalue_reference_v<T>) { std::cout << "T is an rvalue reference\n"; } else { std::cout << "T is a non-reference type\n"; } }
더 엄격하게:
cpptemplate<typename T> void strict_rvalue_only(T&& param) { static_assert(!std::is_lvalue_reference_v<T>, "This function requires an rvalue argument"); }
대부분의 컴파일러는 타입 정보를 드러내는 내장 매크로를 제공합니다.
cpp#include <iostream> template<typename T> void reveal_type(T&& param) { std::cout << __PRETTY_FUNCTION__ << "\n"; // GCC/Clang // MSVC: __FUNCSIG__ } int x = 5; reveal_type(x); reveal_type(5); reveal_type(std::move(x));
출력에 T = int& 같은 형태로 정확한 추론 결과가 나타납니다.
Compiler Explorer(Godbolt)는 템플릿 인스턴스화를 이해하는 데 매우 유용합니다.
를 볼 수 있습니다.
완벽 전달이 실제로 복사를 피하는지 검증할 때도 좋습니다. 어셈블리에 이동 생성자 호출이 보이는지, 아니면 직접 생성되는지 확인할 수 있습니다.
기본 설정으로는 에러 메시지 폭주를 막기 위해 백트레이스를 제한합니다. 복잡한 에러에서는 전체 체인을 봐야 합니다.
bash# GCC g++ -ftemplate-backtrace-limit=0 file.cpp # Clang clang++ -ftemplate-backtrace-limit=0 file.cpp # MSVC cl /diagnostics:caret file.cpp
긴 메시지지만, 예상치 못한 타입이 처음 등장하는 지점이 어디인지 드러내 줍니다.
템플릿 추론 에러를 마주하면:
TD<T> 삽입핵심 통찰: 템플릿 추론은 결정론적입니다. 랜덤처럼 보인다면, 대개 컴파일러가 아니라 규칙 이해의 문제입니다.
규칙을 이해한 뒤에도, 반복적으로 개발자를 넘어뜨리는 패턴들이 있습니다. 이런 안티패턴은 컴파일은 되지만 특정 조건에서만 버그가 드러나거나, 프로덕션에서만 나타나는 미묘한 문제를 일으킵니다. 미리 알아두면 디버깅 시간을 크게 줄일 수 있습니다.
완벽 전달에서 가장 흔한 실수입니다.
cpptemplate<typename T> void problematic(T&& param) { T local = param; other_function(std::forward<T>(local)); // 버그: 이름 있는 변수를 forward }
왜 실패하나: local은 타입이 T일지라도, 이름이 있으므로 표현식으로는 lvalue입니다. 전달해도 원래 rvalue였던 것을 복원하지 못합니다.
개념적 오류: 변수의 타입과 표현식의 값 범주를 동일시한 것. int&& 타입의 이름 있는 변수도 표현식으로는 lvalue입니다.
수정 버전:
cpptemplate<typename T> void corrected(T&& param) { T local = std::forward<T>(param); other_function(std::move(local)); }
혹은 local이 필요 없다면:
cpptemplate<typename T> void corrected(T&& param) { other_function(std::forward<T>(param)); }
클래스에 전달 참조를 그대로 저장하려 한다면 거의 항상 오해입니다.
cpptemplate<typename T> class Dangerous { T&& member_; // 위험! public: Dangerous(T&& value) : member_(std::forward<T>(value)) {} void use() { process(std::forward<T>(member_)); } };
왜 실패하나: T가 lvalue 참조로 추론되면 member_는 lvalue 참조가 되는데, 참조 대상이 Dangerous보다 먼저 파괴될 수 있어 댕글링 참조가 됩니다. T가 비참조로 추론되면 member_는 이미 소비된 임시를 가리키는 rvalue 참조가 될 수 있습니다. 둘 다 UB로 이어질 수 있는데 컴파일은 됩니다.
올바른 버전(값으로 저장):
cpptemplate<typename T> class Safe { std::decay_t<T> member_; public: Safe(T&& value) : member_(std::forward<T>(value)) {} void use() { process(member_); } };
소유권을 가져야 저장이 안전합니다.
루프에서 전달하면 반복 도중 요소를 move해버릴 수 있습니다.
cpptemplate<typename Container> void consume_all(Container&& container) { for (auto&& elem : container) { process(std::forward<decltype(elem)>(elem)); } }
왜 실패하나: move iterator, std::views::move, transform view, 프록시 이터레이터 등에서 역참조가 xvalue/prvalue를 낼 수 있습니다. 그러면 elem이 rvalue 참조로 추론되어 std::forward가 요소를 이동시킵니다. 이후 반복은 이동된 요소를 보게 됩니다.
일반 STL 컨테이너는 보통 역참조가 lvalue를 반환하므로, 이 문제는 주로 ranges/view에서 발생합니다.
수정 접근(의도 명확화):
cpptemplate<typename Container> void process_without_consuming(Container&& container) { for (auto&& elem : container) { process(elem); // 절대 move하지 않음 } } template<typename Container> void consume_elements(Container&& container) { for (auto&& elem : container) { process(std::move(elem)); } }
“혹시 몰라서” auto&&를 남발하면 혼란을 부릅니다.
cppvoid overly_generic() { auto&& x = get_value(); auto&& y = compute(); auto&& z = x + y; process(x, y, z); }
문제점: auto&&는 전달 참조이므로, 전달할 의도가 있는 것처럼 보입니다. 하지만 그렇지 않다면 오해를 낳습니다. 또한 어떤 것에든 바인딩되므로 타입이 덜 명확해집니다.
더 나은 접근(의도 명시):
cppvoid explicit_intent() { auto x = get_value(); const auto& y = compute(); auto z = x + y; process(x, y, z); }
auto&&는 정말로 값 범주를 보존해야 하는 제네릭 문맥에서만 사용하세요.
cpptemplate<typename Func> auto measure_time(Func&& func) { auto&& result = func(); return std::forward<decltype(result)>(result); }
cpptemplate<typename T> void pointless(const T&& param) { consume(std::move(param)); }
왜 실패하나: const 객체에서 이동할 수 없습니다. std::move(param)은 const rvalue 참조가 되어 이동 생성자 대신 복사 생성자에 바인딩됩니다. 컴파일은 되지만 항상 복사합니다.
올바른 버전:
cpptemplate<typename T> void correct(T&& param) { consume(std::forward<T>(param)); }
공통점은 타입 시스템과 협력하지 않고 억지로 행동을 강제하려는 시도입니다.
const T&&)를 추가하지 말 것auto&&로 모든 것을 제네릭하게 만들려 하지 말 것전달 참조는 전달을 위한 것입니다. 저장은 소유권을 요구합니다. 로컬 변수는 명시적 move가 필요합니다. 타입이 의도를 표현할 때 코드가 올바른 경우가 많습니다.
템플릿 추론은 함수 매개변수에서 끝나지 않습니다. 현대 C++은 반환 타입도 추론할 수 있으며, 이는 완벽 전달과 미묘하게 상호작용합니다. 이 상호작용을 이해하는 것은 표현식의 정확한 타입과 값 범주를 보존하는 래퍼 함수를 만드는 데 필수입니다.
C++11은 반환 타입이 매개변수를 참조할 수 있도록 후행 반환 타입(trailing return type) 문법을 도입했습니다.
cpptemplate<typename T, typename U> auto add(T t, U u) -> decltype(t + u) { return t + u; }
C++14는 이를 auto 반환 타입 추론으로 단순화했습니다.
cpptemplate<typename T, typename U> auto add(T t, U u) { return t + u; }
하지만 중요한 차이가 있습니다. auto 추론은 값 전달 템플릿 추론 규칙을 따라 참조를 제거합니다.
반환값까지 보존해야 하는 전달 래퍼를 보죠.
cpptemplate<typename Func, typename Arg> auto call_and_return(Func&& func, Arg&& arg) { return std::forward<Func>(func)(std::forward<Arg>(arg)); } int x = 5; int& modify_x() { return x; } call_and_return(modify_x, 0); // int&가 아니라 int를 반환!
문제는 auto가 참조를 제거한다는 점입니다. 그래서 래퍼가 반환값을 완벽하게 전달하지 못합니다.
C++14의 decltype(auto)는 decltype 규칙으로 반환 타입을 추론하여 참조와 cv 한정자를 보존합니다.
cpptemplate<typename Func, typename Arg> decltype(auto) perfect_call(Func&& func, Arg&& arg) { return std::forward<Func>(func)(std::forward<Arg>(arg)); } int x = 5; int& modify_x() { return x; } decltype(auto) result = perfect_call(modify_x, 0); // result는 int& result = 10; // x 수정
auto는 참조/cv를 제거합니다.
auto a = func();
func()가 int&, const int&, int&&를 반환해도 a는 int가 됩니다.
**decltype(auto)**는 정확히 보존합니다.
decltype(auto) a = func();
func()가 반환한 타입 그대로(int&, const int&, int&&)를 유지합니다.
모든 타입 정보를 보존하는 래퍼의 정석 패턴은 다음과 같습니다.
cpptemplate<typename Func, typename... Args> decltype(auto) invoke_and_log(Func&& func, Args&&... args) { std::cout << "Calling function with " << sizeof...(Args) << " arguments\n"; return std::forward<Func>(func)(std::forward<Args>(args)...); }
복잡한 표현식에서는 후행 반환 타입이 의도를 더 명확히 할 수 있습니다.
cpptemplate<typename Container> auto get_first_element(Container& container) -> decltype(container[0]) { return container[0]; }
이는 container[0]이 만드는 타입(대개 참조)을 그대로 반환한다는 의도를 명확히 합니다.
cpptemplate<typename Func, typename... Args> decltype(auto) measure_execution_time(Func&& func, Args&&... args) { auto start = std::chrono::high_resolution_clock::now(); decltype(auto) result = std::forward<Func>(func)(std::forward<Args>(args)...); auto end = std::chrono::high_resolution_clock::now(); auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start); std::cout << "Execution time: " << duration.count() << "μs\n"; return result; } int& get_global() { static int x = 5; return x; } decltype(auto) ref = measure_execution_time(get_global); ref = 42;
측정 래퍼이면서도 인자와 반환 타입을 완벽히 보존합니다. 반환 타입 전달은 런타임 오버헤드를 추가하지 않습니다.
모든 추론 메커니즘을 살펴봤으니, 이제 “언제 무엇을 써야 하나?”로 돌아옵니다. 흔한 시나리오에 대한 결정 패턴은 다음과 같습니다.
목표: 다른 타입을 감싸며 값 시맨틱을 보존하고 행동을 추가.
cpptemplate<typename T> class LoggedValue { T value_; public: template<typename U> LoggedValue(U&& value) : value_(std::forward<U>(value)) { std::cout << "Created with value\n"; } T& get() { return value_; } const T& get() const { return value_; } };
포인트:
std::decay_t)목표: 함수 호출을 감싸면서 인자와 반환 특성을 보존.
cpptemplate<typename Func, typename... Args> decltype(auto) logged_call(Func&& func, Args&&... args) { std::cout << "Calling function\n"; return std::forward<Func>(func)(std::forward<Args>(args)...); }
포인트:
decltype(auto)목표: 값 범주에 따라 다른 동작 수행.
cpptemplate<typename T> void conditional_process(T&& value) { if constexpr (std::is_lvalue_reference_v<T>) { observer_add_ref(value); } else { observer_take_ownership(std::move(value)); } }
포인트:
if constexpr로 분기std::is_lvalue_reference_v<T>를 검사( decltype(param)이 아님 )std::move로 의도를 명시템플릿 함수/클래스를 설계할 때 다음 프로세스를 따르세요.
인자를 받아야 하나?
├─ 예
│ ├─ 저장할 것인가?
│ │ ├─ 예 → 저장에는 std::decay_t<T>
│ │ └─ 아니오
│ │ ├─ 전달(포워딩)해야 하나?
│ │ │ ├─ 예 → T&& + std::forward<T>
│ │ │ └─ 아니오 → const T&(읽기 전용) 또는 T(복사/이동)
│ │
│ └─ 반환값도 전달할 것인가?
│ ├─ 예 → decltype(auto)
│ └─ 아니오 → auto 또는 명시 반환 타입
└─ 아니오 → 일반 비템플릿 코드
제로 오버헤드 추상화를 위한 규칙:
std::forward는 캐스트일 뿐이다(0비용, 어떤 생성자를 호출할지 지시)std::decay_t도 컴파일 타임이다(무료)std::move로 방해하지 말 것)성능이 중요할 때 이 메커니즘들은 추상화 비용 없이 동작합니다. 제네릭 코드가 손으로 쓴 특수화 코드와 동일하게 실행될 수 있다는 점은 흔치 않은 장점입니다.
Next steps: 아직 읽지 않았다면, 이동 시맨틱의 기반을 이해하기 위해
std::move딥 다이브를 읽어보세요. 이 글과 함께 읽으면 효율적인 제로 오버헤드 제네릭 C++ 코드를 작성하는 데 필요한 지식을 갖추게 됩니다.Coming next: 다음 글에서는 수동 객체 수명 관리와 타입 안전 합(sum) 타입 구현을 다룹니다. placement new, 명시적 파괴, 태그드 유니온(discriminated unions), 그리고
std::optional이나Result<T,E>같은 타입을 구현하는 데 필요한 저수준 메커니즘을 살펴볼 예정입니다.
더 깊게 파고들고 싶다면, 제가 특히 도움을 많이 받은 자료들입니다.
C++, 시스템 프로그래밍, 성능 최적화 관련 새 블로그 글 알림을 구독하세요.
무료 • 스팸 없음 • 언제든 구독 해지 가능