지난 글에서 본 오버로딩으로 max를 만들면 int, double, std::string마다 본문이 똑같은 함수를 하나씩 복사해야 한다. 함수 템플릿(function template)은 타입을 매개변수로 받아 이 복사를 컴파일러에게 맡긴다. 대신 템플릿 인수 추론(template argument deduction) 규칙과 인스턴스화(instantiation) 시점을 모르면 예상과 다른 타입으로 추론되거나 링크 에러를 만난다. 이 글에서는 g++ 13.3(-std=c++20)으로 실제 컴파일한 예제로 추론 규칙, 인스턴스화, 특수화와 오버로드의 차이, concepts 제약, 가변 인자 템플릿을 정리한다.
이 시리즈의 다른 글
- C++ 고급 문법 (1) — 함수 오버로딩(Function Overloading)
- C++ 고급 문법 (3) — inline 함수(Inline Function)
- C++ 고급 문법 (4) — 함수 객체(Function Object)
기본 형태
#include <iostream>
#include <string>
template <typename T>
T max_of(T a, T b)
{
return a < b ? b : a;
}
int main()
{
std::cout << max_of(3, 7) << '\n'; // T = int
std::cout << max_of(2.5, 1.5) << '\n'; // T = double
std::cout << max_of(std::string("abc"), std::string("abd")) << '\n';
}$ g++ -std=c++20 -Wall -o basic basic.cpp
$ ./basic
7
2.5
abd
호출할 때 T를 적지 않아도 인수 타입에서 추론된다. 두 인수가 서로 다른 타입이면 T를 하나로 정할 수 없어 에러다.
template <typename T>
T max_of(T a, T b) { return a < b ? b : a; }
int main()
{
return max_of(1, 2.5); // T = int ? double ?
}$ g++ -std=c++20 -Wall -c deduce_fail.cpp
deduce_fail.cpp: In function ‘int main()’:
deduce_fail.cpp:6:18: error: no matching function for call to ‘max_of(int, double)’
6 | return max_of(1, 2.5); // T = int ? double ?
| ~~~~~~^~~~~~~~
deduce_fail.cpp:2:3: note: candidate: ‘template<class T> T max_of(T, T)’
2 | T max_of(T a, T b) { return a < b ? b : a; }
| ^~~~~~
deduce_fail.cpp:2:3: note: template argument deduction/substitution failed:
deduce_fail.cpp:6:18: note: deduced conflicting types for parameter ‘T’ (‘int’ and ‘double’)
6 | return max_of(1, 2.5); // T = int ? double ?
| ~~~~~~^~~~~~~~
템플릿 인수 추론에서는 int → double 같은 암시적 변환을 고려하지 않는다. 해결 방법은 세 가지다.
#include <iostream>
#include <type_traits>
template <typename T>
T max_of(T a, T b) { return a < b ? b : a; }
template <typename A, typename B>
auto max_mixed(A a, B b) -> std::common_type_t<A, B>
{
return a < b ? b : a;
}
int main()
{
std::cout << max_of<double>(1, 2.5) << '\n'; // 명시적 지정
std::cout << max_of(static_cast<double>(1), 2.5) << '\n';
std::cout << max_mixed(1, 2.5) << '\n'; // 두 타입 매개변수
}$ g++ -std=c++20 -Wall -o deduce_fix deduce_fix.cpp
$ ./deduce_fix
2.5
2.5
2.5
인수 추론 규칙
매개변수를 T, T&, T&& 중 무엇으로 선언했느냐에 따라 같은 인수에서 다른 T가 나온다.
#include <cstdio>
// g++의 __PRETTY_FUNCTION__은 추론된 T를 "[with T = ...]"로 보여준다
template <typename T> void by_value(T) { std::puts(__PRETTY_FUNCTION__); }
template <typename T> void by_ref(T&) { std::puts(__PRETTY_FUNCTION__); }
template <typename T> void by_fwd(T&&) { std::puts(__PRETTY_FUNCTION__); }
int main()
{
const int ci = 1;
int arr[4]{};
by_value(ci); by_ref(ci); by_fwd(ci);
by_value(arr); by_ref(arr);
by_value("hi"); by_ref("hi");
by_fwd(1);
}$ g++ -std=c++20 -Wall -o deduce_rules deduce_rules.cpp
$ ./deduce_rules
void by_value(T) [with T = int]
void by_ref(T&) [with T = const int]
void by_fwd(T&&) [with T = const int&]
void by_value(T) [with T = int*]
void by_ref(T&) [with T = int [4]]
void by_value(T) [with T = const char*]
void by_ref(T&) [with T = const char [3]]
void by_fwd(T&&) [with T = int]
| 매개변수 형태 | const | 배열 | 비고 |
|---|---|---|---|
T (값) | 버려짐 (const int → int) | 포인터로 붕괴(decay) | 인수를 복사하므로 원본 속성이 필요 없다 |
T& | 유지 (T = const int) | 배열 그대로 (int [4]) | rvalue는 받지 못한다 |
T&& (전달 참조, forwarding reference) | 유지 | 배열 그대로 | lvalue면 T가 참조 타입(const int&), rvalue면 비참조(int) |
인스턴스화
템플릿 자체는 코드가 아니다. 실제로 쓰인 타입마다 함수가 하나씩 생성된다.
template <typename T>
T twice(T v) { return v + v; }
template <typename T>
T unused(T v) { return v; } // 호출하지 않는다
int a = twice(1);
double b = twice(1.5);
long c = twice(2L);$ g++ -std=c++20 -Wall -c inst.cpp
$ nm -C inst.o | grep -E 'twice|unused'
0000000000000000 W double twice<double>(double)
0000000000000000 W int twice<int>(int)
0000000000000000 W long twice<long>(long)
호출하지 않은 unused는 심볼이 없다. 생성된 함수는 약한 심볼(W)이어서 여러 번역 단위(translation unit)에서 같은 인스턴스가 생겨도 링커가 하나로 합친다.
정의를 .cpp에 두면 생기는 링크 에러
template <typename T>
T twice(T v); // 선언만 헤더에#include "sep_util.h"
template <typename T>
T twice(T v) { return v + v; } // 정의는 .cpp에#include "sep_util.h"
#include <iostream>
int main()
{
std::cout << twice(21) << '\n';
}$ g++ -std=c++20 -Wall -o sep sep_main.cpp sep_util.cpp
/usr/bin/ld: /tmp/ccdy7ZL8.o: in function `main':
sep_main.cpp:(.text+0xe): undefined reference to `int twice<int>(int)'
collect2: error: ld returned 1 exit status
sep_util.cpp를 컴파일할 때는 twice<int>를 쓰는 곳이 없어 인스턴스가 생기지 않고, sep_main.cpp는 정의를 볼 수 없어 생성하지 못한다. 정의를 헤더로 옮기는 것이 일반적인 해법이고, 쓸 타입이 정해져 있으면 명시적 인스턴스화로도 해결된다.
#include "sep_util.h"
template <typename T>
T twice(T v) { return v + v; }
template int twice<int>(int); // 명시적 인스턴스화(explicit instantiation)$ g++ -std=c++20 -Wall -o sep sep_main.cpp sep_util_fix.cpp
$ ./sep
42
비타입 템플릿 매개변수
타입 대신 정수 같은 컴파일 타임 상수도 템플릿 인수가 될 수 있다(non-type template parameter). 배열 크기 추론에 자주 쓴다.
#include <array>
#include <cstddef>
#include <iostream>
// 비타입 템플릿 매개변수: 값이 템플릿 인수가 된다
template <std::size_t N>
constexpr std::size_t kib() { return N * 1024; }
// 배열 크기를 추론
template <typename T, std::size_t N>
constexpr std::size_t count_of(const T (&)[N]) { return N; }
template <typename T, std::size_t N>
T sum(const std::array<T, N>& a)
{
T s{};
for (auto v : a) s += v;
return s;
}
int main()
{
static_assert(kib<4>() == 4096); // 컴파일 타임에 계산
int raw[] = {1, 2, 3, 4, 5};
std::cout << count_of(raw) << '\n';
std::cout << sum(std::array{1.5, 2.5, 3.0}) << '\n';
}$ g++ -std=c++20 -Wall -o nontype nontype.cpp
$ ./nontype
5
7
특수화보다 오버로드
특정 타입만 다르게 처리하려고 명시적 특수화(explicit specialization)를 쓰면, 추론된 타입이 특수화 타입과 정확히 같을 때만 선택된다.
#include <cstring>
#include <iostream>
template <typename T>
bool equal(T a, T b) { std::cout << "[primary] "; return a == b; }
// const char*는 주소 비교가 되므로 문자열 비교로 바꾼다
template <>
bool equal<const char*>(const char* a, const char* b)
{
std::cout << "[specialization] ";
return std::strcmp(a, b) == 0;
}
int main()
{
char a[] = "abc", b[] = "abc";
const char* pa = a;
const char* pb = b;
std::cout << equal(pa, pb) << '\n'; // T = const char*
std::cout << equal(a, b) << '\n'; // T = char* -> 특수화가 아니다
}$ g++ -std=c++20 -Wall -o spec spec.cpp
$ ./spec
[specialization] 1
[primary] 0
char[]는 char*로 추론되어 특수화를 비켜가고, 주소 비교 결과 0이 조용히 나온다. 포인터 형태의 함수 템플릿 오버로드를 두면 부분 순서(partial ordering)에서 더 특수한 쪽으로 선택되어 두 경우를 모두 잡는다.
#include <concepts>
#include <cstring>
#include <iostream>
#include <type_traits>
template <typename T>
bool equal(T a, T b) { std::cout << "[primary] "; return a == b; }
// char 계열 포인터 전체를 받는 제약된 오버로드
template <typename T>
requires std::same_as<std::remove_cv_t<T>, char>
bool equal(T* a, T* b)
{
std::cout << "[c-string] ";
return std::strcmp(a, b) == 0;
}
int main()
{
char a[] = "abc", b[] = "abc";
const char* pa = a;
const char* pb = b;
int x = 1, y = 1;
std::cout << equal(pa, pb) << '\n';
std::cout << equal(a, b) << '\n';
std::cout << equal(&x, &y) << '\n'; // int*는 주소 비교 그대로
}$ g++ -std=c++20 -Wall -o spec2 spec2.cpp
$ ./spec2
[c-string] 1
[c-string] 1
[primary] 0
concepts로 제약하기
제약이 없는 템플릿은 맞지 않는 타입이 들어와도 본문을 인스턴스화한 뒤에야 에러를 낸다.
#include <vector>
template <typename T>
T average(const std::vector<T>& v)
{
T s{};
for (const auto& x : v) s += x;
return s / static_cast<T>(v.size());
}
struct Point { int x, y; };
int main()
{
std::vector<Point> pts{{1, 2}, {3, 4}};
average(pts);
}$ g++ -std=c++20 -Wall -c noconcept.cpp
noconcept.cpp: In instantiation of ‘T average(const std::vector<T>&) [with T = Point]’:
noconcept.cpp:16:12: required from here
noconcept.cpp:7:31: error: no match for ‘operator+=’ (operand types are ‘Point’ and ‘const Point’)
7 | for (const auto& x : v) s += x;
| ~~^~~~
noconcept.cpp:8:14: error: no match for ‘operator/’ (operand types are ‘Point’ and ‘Point’)
8 | return s / static_cast<T>(v.size());
| ~~^~~~~~~~~~~~~~~~~~~~~~~~~~
C++20 concept으로 요구 조건을 선언하면 에러가 템플릿 내부가 아니라 호출 지점에서 “제약을 만족하지 않는다”는 형태로 나온다.
#include <concepts>
#include <vector>
template <typename T>
concept Arithmetic = std::integral<T> || std::floating_point<T>;
template <Arithmetic T>
T average(const std::vector<T>& v)
{
T s{};
for (const auto& x : v) s += x;
return s / static_cast<T>(v.size());
}
struct Point { int x, y; };
int main()
{
std::vector<Point> pts{{1, 2}, {3, 4}};
average(pts);
}$ g++ -std=c++20 -Wall -c concept.cpp
concept.cpp: In function ‘int main()’:
concept.cpp:20:12: error: no matching function for call to ‘average(std::vector<Point>&)’
20 | average(pts);
| ~~~~~~~^~~~~
concept.cpp:8:3: note: candidate: ‘template<class T> requires Arithmetic<T> T average(const std::vector<T>&)’
8 | T average(const std::vector<T>& v)
| ^~~~~~~
concept.cpp:8:3: note: template argument deduction/substitution failed:
concept.cpp:8:3: note: constraints not satisfied
concept.cpp: In substitution of ‘template<class T> requires Arithmetic<T> T average(const std::vector<T>&) [with T = Point]’:
concept.cpp:20:12: required from here
concept.cpp:5:9: required for the satisfaction of ‘Arithmetic<T>’ [with T = Point]
concept.cpp:5:39: note: no operand of the disjunction is satisfied
5 | concept Arithmetic = std::integral<T> || std::floating_point<T>;
| ~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~
cc1plus: note: set ‘-fconcepts-diagnostics-depth=’ to at least 2 for more detail
가변 인자 템플릿
매개변수 팩(parameter pack)은 개수가 정해지지 않은 인수를 받는다. C++17 폴드 표현식으로 재귀 없이 펼칠 수 있고, C++20부터는 auto 매개변수로 템플릿 선언을 줄여 쓸 수 있다.
#include <iostream>
#include <string>
// C++17 폴드 표현식(fold expression)
template <typename... Ts>
auto sum(Ts... xs) { return (xs + ... + 0); }
template <typename... Ts>
void print_all(const Ts&... xs)
{
std::cout << sizeof...(Ts) << " args:";
((std::cout << ' ' << xs), ...);
std::cout << '\n';
}
// C++20 축약 함수 템플릿(abbreviated function template): auto 매개변수
auto mul(auto a, auto b) { return a * b; }
int main()
{
std::cout << sum(1, 2, 3, 4) << '\n';
std::cout << sum(1, 2.5) << '\n';
std::cout << sum() << '\n';
print_all(1, "two", 3.0, std::string("four"));
std::cout << mul(3, 1.5) << '\n';
}$ g++ -std=c++20 -Wall -o variadic variadic.cpp
$ ./variadic
10
3.5
0
4 args: 1 two 3 four
4.5
sum()처럼 인수가 없을 때를 대비해 폴드에 초기값 0을 넣었다.
주의사항
| 상황 | 증상 | 대응 |
|---|---|---|
| 인수 타입이 서로 다름 | deduced conflicting types | 명시적 템플릿 인수, 캐스트, 또는 타입 매개변수 분리 |
| 템플릿 정의를 .cpp에만 둠 | undefined reference 링크 에러 | 정의를 헤더에 두거나 명시적 인스턴스화 |
| 함수 템플릿 명시적 특수화 | 추론 타입이 조금만 달라도 기본 템플릿이 선택됨 | 비템플릿 또는 더 특수한 템플릿 오버로드로 대체 |
| 제약 없는 템플릿 | 템플릿 본문 깊숙한 곳에서 긴 에러 | concept/requires로 요구 조건 명시 |
| 타입마다 인스턴스 생성 | 바이너리 크기 증가(code bloat) | 타입 무관한 부분은 비템플릿 함수로 분리 |
T&&를 rvalue 참조로 착각 | lvalue도 받아 오버로드를 가로챔 | 전달 참조는 std::forward와 함께 쓰고 다른 오버로드와 섞을 때 주의 |
마무리
- 추론은 암시적 변환을 보지 않으며, 매개변수가 값/참조/전달 참조인지에 따라
const와 배열 처리가 달라진다. - 인스턴스는 사용된 타입에 대해서만, 정의가 보이는 번역 단위에서 생성된다.
- 함수 템플릿을 타입별로 다르게 처리할 때는 특수화보다 오버로드가 안전하다.
- C++20에서는 concept으로 요구 조건을 선언해 에러를 호출 지점으로 끌어온다.