C++17 CTAD: 클래스 템플릿 인자 추론 원리와 추론 가이드가 필요한 경우
이 글의 핵심
C++17 CTAD는 생성자마다 만들어지는 암시적 추론 가이드로 클래스 템플릿 인자를 추론합니다. 반복자 쌍 생성자처럼 사용자 정의 추론 가이드가 꼭 필요한 경우와, std::vector{v}가 복사가 되는 이유, std::map을 중첩 중괄호로 추론할 수 없는 이유, explicit이 추론을 막지 못하는 이유를 GCC로 확인한 결과와 함께 정리합니다.
들어가며
C++17에서 도입된 CTAD(Class Template Argument Deduction)는 클래스 템플릿 인자를 자동으로 추론하여 코드를 간결하게 만들어줍니다.
CTAD 기본
C++17의 CTAD(Class Template Argument Deduction)는 함수 템플릿이 인자로 T를 추론하듯, 클래스 템플릿의 인자를 생성자 인자로부터 추론합니다.
// C++14: 타입 명시
std::pair<int, double> p(1, 3.14);
std::vector<int> vec = {1, 2, 3};
// C++17: 타입 추론
std::pair p(1, 3.14); // pair<int, double>
std::vector vec = {1, 2, 3}; // vector<int>
std::tuple t(1, 2.0, "Hi"); // tuple<int, double, const char*>
std::optional o(42); // optional<int>
std::array a = {1, 2, 3}; // array<int, 3>
동작 원리는 단순합니다. 컴파일러는 클래스 템플릿의 생성자마다 가상의 함수 템플릿(암시적 추론 가이드)을 하나씩 만들고, 평범한 함수 템플릿 오버로드 해석을 돌려 이긴 쪽의 반환 타입을 객체 타입으로 씁니다. template<class T> struct Box { Box(T); };라면 template<class T> Box(T) -> Box<T>;가 자동으로 있는 셈입니다. 그래서 생성자 인자에서 모든 템플릿 매개변수를 추론할 수 있으면 별도 작업 없이 CTAD가 되고, 그렇지 않을 때만 사용자 정의 추론 가이드가 필요합니다.
아래 예제의 동작과 에러 메시지는 모두 GCC 10.3(-std=c++20)으로 컴파일해 확인했습니다.
사용자 정의 추론 가이드가 필요한 경우
경우 1: 다른 타입으로 저장하고 싶을 때
template<typename T>
class Box {
T value;
public:
Box(T v) : value(v) {}
};
Box(const char*) -> Box<std::string>; // 문자열 리터럴은 std::string으로 저장
Box b("hi"); // Box<std::string>
Box n(42); // Box<int> (암시적 가이드)
가이드가 없으면 Box b("hi")는 Box<const char*>가 되어, 리터럴이 아닌 임시 문자열을 넘기는 순간 댕글링 포인터를 저장하게 됩니다. 추론 가이드는 생성자와 별개로 “이 인자 형태면 이 타입으로 만들어라”를 지정하는 선언이라, 생성자는 T를 받는 그대로 두고 추론 결과만 바꿀 수 있습니다.
경우 2: 반복자 쌍 생성자
template<typename T>
class Stack {
std::vector<T> data_;
public:
template<typename It>
Stack(It first, It last) : data_(first, last) {}
};
std::vector<int> v{1, 2, 3};
Stack s(v.begin(), v.end()); // ❌ error: class template argument deduction failed
생성자 매개변수에는 It만 있고 T는 어디에도 나오지 않으므로, 암시적 가이드로는 T를 알 수 없습니다. 표준 컨테이너가 쓰는 것과 같은 가이드를 추가합니다.
template<typename It>
Stack(It, It) -> Stack<typename std::iterator_traits<It>::value_type>;
Stack s(v.begin(), v.end()); // ✅ Stack<int>
std::vector v2(v.begin(), v.end());가 vector<int>로 추론되는 것도 표준 라이브러리에 똑같은 가이드가 들어 있기 때문입니다. 실무에서 사용자 정의 가이드가 가장 많이 필요한 경우가 이 반복자 생성자입니다.
자주 틀리는 부분
문제 1: 한 원소짜리 중괄호는 “복사”가 이긴다
std::vector vec{1, 2, 3};
std::vector vec2{vec}; // vector<int>, 크기 3 ← vector<vector<int>>가 아님
std::vector vec4{vec, vec}; // vector<vector<int>>, 크기 2
vec2{vec}는 vec을 원소로 하는 vector<vector<int>>가 될 것 같지만, 실제로는 vec의 복사본(vector<int>, 크기 3)입니다. CTAD에는 “같은 템플릿의 특수화 하나로 초기화하면 복사로 본다”는 복사 추론 후보가 있고, 인자가 하나일 때는 이것이 우선합니다. 원소가 둘 이상이면 비로소 initializer_list 쪽으로 추론됩니다. 이 글의 이전 버전도 vec2.size()를 1이라고 적었다가, 실제로 돌려 보고 3이 나오는 것을 확인해 고쳤습니다. 템플릿 코드에서 std::vector{x}로 “x 하나를 담은 벡터”를 만들려다 x가 이미 벡터인 경우에만 동작이 바뀌는 버그로 이어지므로, 그런 곳에서는 std::vector<T>{x}처럼 인자를 명시합니다. 사용자 클래스도 마찬가지로, Wrapper w3(w1);은 Wrapper<Wrapper<int>>가 아니라 Wrapper<int>의 복사입니다.
문제 2: 중첩 중괄호에서는 추론하지 못한다
std::map m = {{"a", 1}, {"b", 2}}; // ❌ class template argument deduction failed
std::map m2 = {std::pair{"a", 1}, std::pair{"b", 2}}; // 컴파일은 되지만 map<const char*, int>
std::map<std::string, int> m3 = {{"a", 1}, {"b", 2}}; // ✅ 의도한 타입
{"a", 1} 같은 안쪽 중괄호는 그 자체로 타입이 없어서 pair<Key, T>의 Key와 T를 추론할 근거가 되지 못합니다. std::pair{...}로 감싸면 컴파일은 되지만, 키가 std::string이 아니라 const char*로 추론되어 문자열 내용이 아니라 포인터 주소로 비교하는 map이 됩니다. 같은 문자열 리터럴이 우연히 같은 주소로 합쳐지는 환경에서는 테스트를 통과하다가, 런타임에 만든 문자열로 find하면 못 찾는 식으로 드러나는 고약한 버그입니다. 키 타입이 중요한 컨테이너는 CTAD 대신 타입을 명시하는 것이 원칙입니다. 같은 이유로 std::vector<std::vector<T>>를 받는 생성자에 {{1, 2}, {3, 4}}를 넘기는 코드도 추론에 실패합니다.
문제 3: 문자열 리터럴은 const char*
std::pair p("Hello", "World"); // pair<const char*, const char*>
std::string을 기대했다면 std::pair<std::string, std::string>으로 명시하거나, using namespace std::string_literals; 후 "Hello"s처럼 std::string 리터럴을 씁니다. 직접 만든 클래스라면 경우 1처럼 추론 가이드로 해결할 수 있습니다.
문제 4: explicit로는 CTAD를 막을 수 없다
template<typename T>
struct NoDeduction {
explicit NoDeduction(T) {}
};
NoDeduction n(42); // 컴파일됨: NoDeduction<int>
explicit은 NoDeduction n = 42; 같은 복사 초기화만 막을 뿐 추론 자체에는 영향이 없습니다. 사용자가 반드시 템플릿 인자를 적게 하려면(예: 단위 타입을 실수로 추론하지 않도록) 매개변수를 추론되지 않는 문맥으로 만듭니다.
template<typename T>
struct Blocked {
Blocked(std::type_identity_t<T>) {} // C++20 (C++17이면 직접 만든 type_identity)
};
Blocked b(1); // ❌ class template argument deduction failed
Blocked<int> b2(1); // ✅
문제 5: 추론할 수 없는 가이드는 조용히 무시된다
template<typename T>
TypedId(std::string) -> TypedId<T>; // T를 인자에서 알 수 없음
이런 가이드는 컴파일 에러 없이 선언되지만, T를 추론할 방법이 없으니 절대 선택되지 않습니다. 결과적으로 TypedId id("U001");은 여전히 실패하고, 사용자는 TypedId<User>처럼 명시해야 합니다. 태그 타입(User, Post)으로 구분하는 강타입 ID처럼 일부러 명시하게 만든 설계라면 가이드를 아예 쓰지 않는 것이 의도를 더 잘 드러냅니다.
문제 6: 일부만 명시하고 나머지를 추론할 수는 없다
std::pair<int> p(1, 2.0); // ❌ 템플릿 인자 부족 (나머지를 추론하지 않음)
std::pair p2(1, 2.0); // ✅ 전부 추론
std::pair<int, double> p3(1, 2); // ✅ 전부 명시
함수 템플릿은 f<int>(1, 2.0)처럼 앞쪽 인자만 명시하고 나머지를 추론할 수 있지만, CTAD는 전부 추론하거나 전부 명시하거나 둘 중 하나입니다. 템플릿 인자 목록을 하나라도 적으면 CTAD는 일어나지 않고 일반적인 템플릿 인자 규칙이 적용됩니다(기본 템플릿 인자가 있는 경우는 예외처럼 보이지만, 그것도 추론이 아니라 기본값 채우기입니다). 그래서 std::array<int> a = {1, 2, 3};처럼 원소 타입만 적고 크기를 추론하는 것도 불가능하며, 이 경우 C++20의 std::to_array<int>({1, 2, 3})를 쓰면 됩니다.
문제 7: CTAD가 동작하지 않는 자리
CTAD는 초기화식이 있는 변수 선언과 함수형 표기(Box(42)) 에서만 동작합니다. 초기화식이 없는 std::vector v;는 추론할 근거가 없어 에러이고, 클래스의 비정적 멤버 선언(struct S { std::vector v = {1, 2}; };)에서는 기본 멤버 초기화식이 있어도 CTAD를 쓸 수 없습니다. 함수 매개변수 타입에도 쓸 수 없는데, 이 자리에서 “타입을 추론하고 싶다”면 CTAD가 아니라 함수 템플릿이나 C++20 약식 템플릿(void f(std::vector<auto>)는 불가, void f(auto v)는 가능)을 써야 합니다.
C++20에서는 적용 범위가 두 방향으로 넓어졌습니다. 생성자가 없는 집합체(aggregate) 도 멤버 초기화로부터 추론할 수 있게 되어 template<class T> struct Point { T x, y; }; Point p{1, 2};가 Point<int>로 추론되고(GCC 10 이상, Clang 17 이상), 별칭 템플릿(template<class T> using Vec = std::vector<T>;)으로도 Vec v = {1, 2, 3};처럼 추론할 수 있습니다. 컴파일러마다 지원 시점이 달랐으므로, 여러 컴파일러를 지원해야 하는 라이브러리라면 이 두 기능에 기대기 전에 대상 컴파일러에서 먼저 확인하는 편이 좋습니다.
언제 CTAD를 쓰고 언제 피하나
| 상황 | 권장 |
|---|---|
지역 변수, 인자 타입이 명확함 (std::pair p(1, 2.0)) | CTAD 사용 |
std::lock_guard lock(m);, std::scoped_lock lock(a, b); | CTAD 사용 (가장 흔한 좋은 예) |
키·값 타입이 중요한 컨테이너 (map, set) | 타입 명시 |
| 원소 하나를 중괄호로 감싸는 제네릭 코드 | 타입 명시 (문제 1) |
| 문자열 리터럴이 들어가는 경우 | 가이드나 s 리터럴, 또는 명시 |
| 공개 API의 반환 타입·멤버 타입 | 명시 (읽는 사람이 추론 규칙을 몰라도 되게) |
CTAD 자체는 컴파일 시간에 끝나므로 런타임 비용이 없습니다. 판단 기준은 성능이 아니라 읽는 사람이 타입을 바로 알 수 있는가입니다.
lock_guard가 가장 좋은 예로 꼽히는 이유는 템플릿 인자가 정보를 전혀 더하지 않기 때문입니다. std::lock_guard<std::mutex> lock(m);의 <std::mutex>는 바로 옆의 m 타입을 반복할 뿐이고, 나중에 m을 std::shared_mutex로 바꾸면 이 줄까지 함께 고쳐야 합니다. 반대로 map의 키 타입은 문자열 비교 방식이라는 의미를 담고 있어서, 추론에 맡기면 문제 2처럼 의미가 조용히 바뀝니다. 이 구분이 CTAD를 쓸지 판단하는 가장 실용적인 기준입니다.
직접 만든 클래스가 CTAD로 쓰일 때 의도대로 동작하는지 확신이 없다면, Clang의 -Wctad-maybe-unsupported 경고를 켜 볼 만합니다. 사용자 정의 추론 가이드가 하나도 없는 클래스 템플릿에 CTAD를 쓰면 “이 타입이 CTAD를 고려해 설계되었는지 알 수 없다”고 알려 줍니다. 라이브러리 작성자는 CTAD를 지원할 의도가 있다면 template<class T> Box(T) -> Box<T>;처럼 암시적 가이드와 똑같은 가이드라도 명시적으로 적어 두어 의도를 표시하는 것이 관례입니다.
같이 보면 좋은 글
- C++ 템플릿 인자 추론 | template argument deduction 가이드
- C++17 if constexpr: 컴파일 타임 분기로 템플릿 단순화하기와 static_assert(false) 문제
- C++20 템플릿 람다: auto 람다와 차이, Concepts로 타입 제한하기
자주 묻는 질문 (FAQ)
Q. CTAD를 쓰면 코드가 느려지나요?
A. 아닙니다. 추론은 컴파일 중에 끝나고 결과 타입은 직접 적은 것과 똑같습니다. CTAD를 쓸지 말지는 성능이 아니라 읽는 사람이 타입을 바로 알 수 있는지(4절 표)로 정하면 됩니다.