C++ 가변 인자 템플릿 | Variadic Templates와 Fold Expression
이 글의 핵심
C++ 가변 인자 템플릿에 대한 실전 가이드입니다. Variadic Templates와 Fold Expression 등을 예제와 함께 상세히 설명합니다.
들어가며: 인자 개수가 고정되어 있어서 불편했다
”로그 함수에 인자를 몇 개까지 넣을 수 있나요?”
로그 함수를 만들 때 인자 개수를 미리 정해야 했습니다.
목차
- 가변 인자 템플릿이란
- 재귀 전개 패턴
- Fold Expression (C++17)
- 완전한 가변 인자 템플릿 예제
- 실전 예제
- std::tuple 이해하기
- 일반적인 에러와 해결법
- 성능 비교와 최적화
- 프로덕션 패턴
1. 가변 인자 템플릿이란
일반 템플릿은 타입 개수가 고정되어 있습니다. template <typename T>는 한 개, template <typename T, typename U>는 두 개죠. 가변 인자 템플릿은 typename... Args처럼 0개 이상의 타입을 한꺼번에 받습니다. 그래서 인자 개수를 미리 정하지 않고, 호출할 때 넘기는 인자에 맞춰서 컴파일 시점에 코드가 생성됩니다. C 스타일의 va_list와 달리 타입 안전하고, 인자마다 다른 타입을 섞어 쓸 수 있습니다.
파라미터 팩 전개 흐름
flowchart TD
subgraph input["입력: func(1, 2.5, \"hello\")"]
I1[int]
I2[double]
I3[const char*]
end
subgraph pack["파라미터 팩"]
P1["Args = int, double, const char*"]
P2["args = 1, 2.5, \"hello\""]
end
subgraph compile["컴파일 시점"]
C1["템플릿 인스턴스화"]
C2["funcint, double, const char* 생성"]
end
input --> pack
pack --> compile
기본 문법
// ... : 파라미터 팩 (parameter pack)
template <typename... Args>
void func(Args... args) {
// args는 0개 이상의 인자
}
int main() {
func(); // Args = {}, args = {}
func(1); // Args = {int}, args = {1}
func(1, 2.5); // Args = {int, double}, args = {1, 2.5}
func(1, "hello", 3.14); // Args = {int, const char*, double}
}
위 코드 설명: typename... Args는 0개 이상의 타입을 한 묶음으로 받는 파라미터 팩이고, Args... args는 그에 대응하는 함수 인자 팩입니다. func()는 인자 없음, func(1)은 int 하나, func(1, 2.5)는 int와 double처럼 호출 시점에 개수와 타입이 결정됩니다.
Args를 파라미터 팩(parameter pack)이라고 부릅니다. 타입 파라미터 팩 Args와 함수 매개변수 팩 args는 한 쌍으로, 호출 시 func(1, 2.5)면 Args는 int, double, args는 1, 2.5처럼 대응됩니다. 인자가 0개일 때도 처리할 수 있어서, “인자 없이 호출 가능한 함수”를 하나의 템플릿으로 표현할 수 있습니다.
va_list와의 차이
C 스타일 va_list는 타입 정보를 잃어버려 런타임에 잘못된 타입으로 접근하면 정의되지 않은 동작(UB)이 발생합니다. 가변 인자 템플릿은 컴파일 시점에 타입이 결정되므로 타입 안전합니다.
// C 스타일: 타입 안전하지 않음
#include <cstdarg>
#include <cstdio>
void unsafe_print(const char* fmt, ...) {
va_list args;
va_start(args, fmt);
int i = va_arg(args, int); // 실제로 double이면 UB!
va_end(args);
printf("%d\n", i);
}
// C++ 가변 인자 템플릿: 타입 안전
template <typename... Args>
void safe_print(Args... args) {
(std::cout << ... << args) << "\n"; // 각 타입에 맞게 처리
}
파라미터 팩 크기
template <typename... Args>
void printCount(Args... args) {
std::cout << "Argument count: " << sizeof...(Args) << "\n";
std::cout << "Argument count: " << sizeof...(args) << "\n"; // 동일
}
int main() {
printCount(); // 0
printCount(1); // 1
printCount(1, 2, 3); // 3
}
위 코드 설명: sizeof...(Args)와 sizeof...(args)는 각각 타입 팩과 인자 팩에 들어 있는 개수를 컴파일 시점에 반환합니다. 두 값은 항상 같고, 인자 개수에 따라 다른 동작을 하거나 로그를 남길 때 유용합니다.
2. 재귀 전개 패턴
파라미터 팩의 각 인자에 뭔가를 “하나씩” 하려면, C++11에서는 재귀를 써야 합니다. “첫 번째 인자 처리 → 나머지로 재귀”를 반복하다가, 인자가 없을 때 종료하는 비void 함수를 두면 됩니다. C++17에서는 fold expression으로 이 재귀를 한 줄로 대체할 수 있지만, 재귀 패턴을 이해해 두면 복잡한 로직(인자마다 다른 처리, 조건 분기 등)을 짤 때 도움이 됩니다.
재귀 전개 시퀀스
flowchart LR
subgraph step1["1단계"]
A1["print(1,2,3,hello,4.5)"]
A2["→ 1 출력"]
end
subgraph step2["2단계"]
B1["print(2,3,hello,4.5)"]
B2["→ 2 출력"]
end
subgraph step3["3단계"]
C1["print(3,hello,4.5)"]
C2["→ 3 출력"]
end
subgraph step4["종료"]
D1["print()"]
D2["→ 줄바꿈"]
end
A1 --> A2 --> B1 --> B2 --> C1 --> C2 --> D1 --> D2
기본 재귀 패턴
// 종료 조건: 인자가 없을 때
void print() {
std::cout << "\n";
}
// 재귀: 첫 인자 출력 후 나머지 재귀
template <typename T, typename... Args>
void print(T first, Args... rest) {
std::cout << first << " ";
print(rest...); // 나머지 인자로 재귀
}
int main() {
print(1, 2, 3, "hello", 4.5);
// 1 2 3 hello 4.5
}
위 코드 설명: 인자가 없을 때는 비템플릿 print()가 호출되어 줄바꿈만 합니다. 인자가 있으면 첫 번째(first)를 출력한 뒤 print(rest...)로 나머지 인자만 넘겨 재귀 호출합니다. 한 번 호출할 때마다 첫 인자 하나만 처리하고 팩이 줄어들어 결국 print()에서 끝납니다.
동작 과정:
print(1, 2, 3, "hello", 4.5)
→ 출력: 1, 호출: print(2, 3, "hello", 4.5)
→ 출력: 2, 호출: print(3, "hello", 4.5)
→ 출력: 3, 호출: print("hello", 4.5)
→ 출력: hello, 호출: print(4.5)
→ 출력: 4.5, 호출: print()
→ 출력: \n
재귀의 “종료 조건”은 인자가 하나도 없을 때 호출되는 비템플릿 버전입니다. print(1, 2, 3)은 결국 print(1, 2, 3) → print(2, 3) → print(3) → print() 순으로 호출되며, 매 단계에서 첫 인자만 출력하고 나머지를 다음 호출로 넘깁니다.
인덱스 접근 패턴
// 첫 번째 인자만 가져오기
template <typename T, typename... Args>
T getFirst(T first, Args... rest) {
return first;
}
int main() {
auto value = getFirst(10, 20, 30);
std::cout << value << "\n"; // 10
}
위 코드 설명: getFirst는 첫 번째 인자만 T first로 받고 나머지는 Args… rest로 받습니다. first를 그대로 반환하므로 getFirst(10, 20, 30)은 10을 돌려줍니다. 가변 인자에서 “첫 번째만” 쓰고 나머지는 무시하는 패턴입니다.
모든 인자 합산
// 종료 조건
int sum() {
return 0;
}
// 재귀
template <typename T, typename... Args>
T sum(T first, Args... rest) {
return first + sum(rest...);
}
int main() {
std::cout << sum(1, 2, 3, 4, 5) << "\n"; // 15
}
위 코드 설명: sum()(인자 없음)은 0을 반환하는 종료 조건입니다. sum(first, rest…)는 first와 sum(rest…)의 결과를 더해 반환하므로, sum(1,2,3,4,5)는 1+sum(2,3,4,5) → … → 1+2+3+4+5+0 = 15가 됩니다. 재귀적으로 팩을 하나씩 줄여가며 합산하는 전형적인 패턴입니다.
3. Fold Expression (C++17)
C++17의 fold expression은 파라미터 팩 전체에 이항 연산자를 “접는” 문법입니다. (args + ...)는 “모든 인자를 +로 이어 붙인다”는 뜻이라, 재귀 함수 없이 한 줄로 합계나 출력을 쓸 수 있습니다. 단, 연산자 종류와 괄호 위치에 따라 왼쪽부터 접을지 오른쪽부터 접을지가 달라지므로, 문서를 보면서 한두 번 직접 써 보는 것이 좋습니다.
Fold 종류 비교
flowchart TB
subgraph unary["Unary Fold"]
U1["(pack op ...) Right"]
U2["(... op pack) Left"]
end
subgraph binary["Binary Fold"]
B1["(pack op ... op init)"]
B2["(init op ... op pack)"]
end
subgraph example["예시"]
E1["(args + ...) → 1+(2+(3+4))"]
E2["(... + args) → ((1+2)+3)+4"]
end
unary --> example
binary --> example
기본 Fold Expression
C++17부터는 재귀 없이 fold expression으로 간단히 작성할 수 있습니다.
// 재귀 방식 (C++11)
template <typename... Args>
auto sum_old(Args... args) {
return (args + ...); // ❌ C++11에서는 안 됨
}
// Fold expression (C++17)
template <typename... Args>
auto sum(Args... args) {
return (args + ...); // ✅ 간단!
}
int main() {
std::cout << sum(1, 2, 3, 4, 5) << "\n"; // 15
}
위 코드 설명: (args + ...)는 C++17 unary right fold로, 모든 인자를 오른쪽부터 +로 묶습니다. (1 + (2 + (3 + (4 + 5))))와 같은 형태가 되어 합계가 나옵니다. 재귀 함수 없이 한 줄로 파라미터 팩 전체에 연산을 적용할 수 있습니다.
Fold 연산자 종류
Unary Right Fold: (pack op ...)
template <typename... Args>
void print(Args... args) {
(std::cout << ... << args) << "\n";
// ((std::cout << arg1) << arg2) << arg3 ...
}
int main() {
print(1, 2, 3); // 123
}
위 코드 설명: (std::cout << ... << args)는 cout에 args를 순서대로 이어 붙이는 right fold입니다. (std::cout << 1 << 2 << 3)처럼 전개되어 123이 출력됩니다. 출력 연산자를 fold로 쓸 때 자주 쓰는 형태입니다.
Unary Left Fold: (... op pack)
template <typename... Args>
auto sum(Args... args) {
return (... + args);
// (((arg1 + arg2) + arg3) + ...)
}
위 코드 설명: (... + args)는 left fold로, 왼쪽부터 더해 나갑니다. (((arg1 + arg2) + arg3) + …) 형태로 전개되어 sum(1,2,3,4,5)는 15가 됩니다. right fold (args + …)와 계산 순서만 다르고, 덧셈처럼 결합 법칙이 있으면 결과는 같습니다.
Binary Right Fold: (pack op ... op init)
template <typename... Args>
void printWithSpace(Args... args) {
((std::cout << args << " "), ...) << "\n";
}
int main() {
printWithSpace(1, 2, 3); // 1 2 3
}
위 코드 설명: ((std::cout << args << " "), ...)는 comma 연산자와 fold를 써서 각 인자를 출력한 뒤 공백을 붙입니다. (expr1, expr2, expr3)처럼 순서대로 실행되고 마지막 결과만 반환되며, 여기서는 각 인자마다 cout 출력이 수행됩니다.
논리 연산 Fold
template <typename... Args>
bool all(Args... args) {
return (... && args); // 모두 true?
}
template <typename... Args>
bool any(Args... args) {
return (... || args); // 하나라도 true?
}
int main() {
std::cout << all(true, true, true) << "\n"; // 1
std::cout << all(true, false, true) << "\n"; // 0
std::cout << any(false, false, true) << "\n"; // 1
}
위 코드 설명: (... && args)는 모든 인자가 true일 때만 true인 left fold이고, (... || args)는 인자 중 하나라도 true면 true인 left fold입니다. short-circuit으로 앞에서 결과가 정해지면 나머지는 평가하지 않아, all/any 같은 논리 조건을 한 줄로 쓸 수 있습니다.
Fold Expression 추가 예제
문자열 연결
template <typename... Args>
std::string concat(Args&&... args) {
std::ostringstream oss;
(oss << ... << std::forward<Args>(args));
return oss.str();
}
// concat("Hello, ", "World", "! ", 42) → "Hello, World! 42"
최대값 (comma fold)
template <typename T, typename... Args>
T maxOf(T first, Args... rest) {
T result = first;
((result = (result < rest ? rest : result)), ...);
return result;
}
int main() {
std::cout << maxOf(3, 1, 4, 1, 5) << "\n"; // 5
}
4. 완전한 가변 인자 템플릿 예제
아래 예제들은 복사해 붙여넣어 바로 컴파일·실행할 수 있는 완전한 코드입니다. g++ -std=c++17 -o out source.cpp && ./out로 실행하세요.
예제 A: 로깅 유틸리티 (완전판)
#include <iostream>
#include <chrono>
#include <iomanip>
template <typename... Args>
void log(Args&&... args) {
auto now = std::chrono::system_clock::now();
auto time = std::chrono::system_clock::to_time_t(now);
std::cout << "[" << std::put_time(std::localtime(&time), "%H:%M:%S") << "] ";
(std::cout << ... << std::forward<Args>(args)) << "\n";
}
int main() {
log("Server", " started on port ", 8080);
log("User count: ", 42, ", memory: ", 128.5, " MB");
return 0;
}
예제 B: 가변 인자 min (fold)
#include <iostream>
template <typename T, typename... Args>
T minOf(T first, Args... rest) {
T result = first;
((result = (result < rest ? result : rest)), ...);
return result;
}
int main() {
std::cout << minOf(3, 1, 4, 1, 5) << "\n"; // 1
return 0;
}
예제 C: 가변 인자 apply (함수에 팩 전달)
#include <iostream>
template <typename Func, typename... Args>
decltype(auto) apply(Func&& func, Args&&... args) {
return std::forward<Func>(func)(std::forward<Args>(args)...);
}
int add(int a, int b) { return a + b; }
double mul(double a, double b) { return a * b; }
int main() {
std::cout << apply(add, 3, 5) << "\n"; // 8
std::cout << apply(mul, 2.5, 4.0) << "\n"; // 10
return 0;
}
예제 D: 타입 리스트 검사
#include <type_traits>
template <typename... Args>
constexpr bool allPointers() {
return (... && std::is_pointer_v<std::decay_t<Args>>);
}
static_assert(allPointers<int*, double*>());
static_assert(!allPointers<int*, int>());
5. 실전 예제
가변 인자 템플릿은 로깅, 측정 래퍼, 여러 컨테이너에 동시에 넣기, 타입 리스트 검사 등에 쓰입니다. 아래 예제들은 “인자 개수가 가변”인 상황을 타입 안전하게 다루는 패턴을 보여 줍니다. 실무에서는 std::format(C++20), 로깅 라이브러리, 테스트 프레임워크에서 비슷한 패턴을 자주 볼 수 있습니다.
예제 1: 타입 안전한 printf
void print() {
std::cout << "\n";
}
template <typename T, typename... Args>
void print(T first, Args... rest) {
std::cout << first;
if constexpr (sizeof...(rest) > 0) {
std::cout << " ";
print(rest...);
} else {
std::cout << "\n";
}
}
int main() {
print("User:", "Alice", "Age:", 25, "Score:", 95.5);
// User: Alice Age: 25 Score: 95.5
}
위 코드 설명: 첫 인자만 출력한 뒤, sizeof...(rest) > 0이면 공백을 넣고 rest로 재귀하고, 아니면 줄바꿈으로 끝냅니다. if constexpr로 rest가 없을 때는 print(rest…) 호출을 컴파일에서 제외해, 종료용 비템플릿 print() 없이도 동작하게 할 수 있습니다.
예제 2: 함수 호출 래퍼
template <typename Func, typename... Args>
auto measure(Func func, Args... args) {
auto start = std::chrono::high_resolution_clock::now();
auto result = func(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() << " us\n";
return result;
}
int add(int a, int b) {
return a + b;
}
int main() {
auto result = measure(add, 3, 5);
std::cout << "Result: " << result << "\n";
}
위 코드 설명: measure(Func func, Args… args)는 임의의 함수와 그 인자들을 받아, 호출 전후로 시간을 재고 func(args…)를 실행한 뒤 걸린 시간을 출력하고 반환값을 그대로 돌려줍니다. func와 args가 가변이라 어떤 함수든 인자 개수에 상관없이 실행 시간을 측정할 수 있습니다.
예제 3: 여러 컨테이너에 동시 삽입
template <typename T, typename... Containers>
void insertAll(T value, Containers&... containers) {
(containers.push_back(value), ...); // fold expression
}
int main() {
std::vector<int> vec1, vec2, vec3;
insertAll(10, vec1, vec2, vec3);
insertAll(20, vec1, vec2, vec3);
std::cout << vec1.size() << "\n"; // 2
std::cout << vec2.size() << "\n"; // 2
std::cout << vec3.size() << "\n"; // 2
}
위 코드 설명: insertAll(T value, Containers&… containers)는 value 하나와 여러 컨테이너 참조를 받습니다. (containers.push_back(value), ...) fold로 각 컨테이너에 같은 값을 한 번씩 push_back합니다. vec1, vec2, vec3 모두에 10과 20이 들어가서 size가 2가 됩니다.
예제 4: 타입 체크
template <typename T, typename... Args>
constexpr bool areAllSame() {
return (std::is_same_v<T, Args> && ...);
}
int main() {
std::cout << areAllSame<int, int, int>() << "\n"; // 1
std::cout << areAllSame<int, int, double>() << "\n"; // 0
}
위 코드 설명: (std::is_same_v<T, Args> && ...)는 T가 모든 Args와 같은 타입인지 컴파일 시점에 검사하는 fold입니다. areAllSame<int, int, int>는 true, areAllSame<int, int, double>은 false가 됩니다. 타입 리스트 전체에 조건을 걸 때 유용합니다.
예제 5: emplace 스타일 팩토리
template <typename T, typename... Args>
T* create(Args&&... args) {
return new T(std::forward<Args>(args)...);
}
struct Widget {
Widget(int a, double b, const char* c) {
std::cout << "Widget(" << a << ", " << b << ", " << c << ")\n";
}
};
int main() {
auto* w = create<Widget>(42, 3.14, "hello");
delete w;
}
위 코드 설명: std::forward<Args>(args)...로 각 인자를 완벽 전달(perfect forwarding)합니다. create<Widget>(42, 3.14, “hello”)는 Widget 생성자에 인자를 그대로 넘겨 객체를 만듭니다. std::make_unique, std::vector::emplace_back 등이 이 패턴을 사용합니다.
6. std::tuple 이해하기
tuple은 “고정된 개수의 값”을 서로 다른 타입으로 묶어 두는 타입입니다. 함수가 여러 값을 반환할 때 구조체를 만들기 부담스러우면 std::tuple을 쓰거나, C++17의 structured binding으로 auto [a, b, c] = f();처럼 받을 수 있습니다. std::tuple 자체가 가변 인자 템플릿으로 구현되어 있어서, “N개의 타입을 담는 타입”을 하나의 템플릿으로 표현하는 좋은 예시가 됩니다.
tuple 기본 사용
#include <tuple>
int main() {
std::tuple<int, std::string, double> person(25, "Alice", 95.5);
std::cout << std::get<0>(person) << "\n"; // 25
std::cout << std::get<1>(person) << "\n"; // Alice
std::cout << std::get<2>(person) << "\n"; // 95.5
}
위 코드 설명: std::tuple<int, std::string, double>은 서로 다른 타입의 값들을 한 객체에 담습니다. std::get<0>, get<1>, get<2>로 인덱스에 해당하는 요소에 접근하고, tuple 자체가 가변 인자 템플릿으로 구현된 타입입니다.
tuple 만들기
template <typename... Args>
auto makeTuple(Args... args) {
return std::tuple<Args...>(args...);
}
int main() {
auto t = makeTuple(1, "hello", 3.14);
// std::tuple<int, const char*, double>
}
위 코드 설명: makeTuple(Args… args)는 인자 타입 그대로 std::tuple<Args…>를 만들고, (args…)로 생성자에 인자를 넘깁니다. makeTuple(1, “hello”, 3.14)는 tuple<int, const char*, double>을 반환합니다. 가변 인자로 “넘긴 인자들로 tuple 만들기”를 한 번에 표현하는 패턴입니다.
tuple 언패킹 (C++17)
int main() {
std::tuple<int, std::string, double> person(25, "Alice", 95.5);
auto [age, name, score] = person; // structured binding
std::cout << "Age: " << age << "\n";
std::cout << "Name: " << name << "\n";
std::cout << "Score: " << score << "\n";
}
위 코드 설명: C++17 structured binding으로 auto [age, name, score] = person처럼 tuple 요소를 각각 변수로 받을 수 있습니다. tuple의 요소 개수와 타입 순서에 맞게 선언하면, get<0> 등을 쓰지 않고도 이름으로 접근할 수 있습니다.
간단한 tuple 구현
// 기본 케이스: 빈 tuple
template <typename... Args>
class Tuple {};
// 재귀 케이스: 첫 타입 + 나머지
template <typename T, typename... Rest>
class Tuple<T, Rest...> : private Tuple<Rest...> {
T value;
public:
Tuple(T v, Rest... rest)
: Tuple<Rest...>(rest...), value(v) {}
T& get() { return value; }
const T& get() const { return value; }
};
int main() {
Tuple<int, double, std::string> t(42, 3.14, "hello");
std::cout << t.get() << "\n"; // 42 (첫 번째 값)
}
위 코드 설명: Tuple<T, Rest…>는 첫 타입 T의 value 하나를 갖고, 나머지 Rest…는 기반 클래스 Tuple<Rest…>에 넘겨 재귀적으로 상속합니다. 생성자에서 value(v)와 Tuple<Rest…>(rest…)로 초기화하고, get()은 현재 계층의 value만 반환합니다. std::tuple의 단순화된 구현 구조를 보여줍니다.
7. 일반적인 에러와 해결법
문제 1: “pack expansion” 관련 컴파일 에러
증상: error: parameter pack 'Args' must be expanded with '...'
원인: 파라미터 팩을 단독으로 사용하려고 할 때 발생합니다. 파라미터 팩은 반드시 ...로 전개해서 사용해야 합니다.
// ❌ 잘못된 코드
template <typename... Args>
void wrong(Args... args) {
Args first; // 에러: Args는 팩, 단독 사용 불가
}
// ✅ 올바른 코드
template <typename T, typename... Rest>
void correct(T first, Rest... rest) {
T value = first; // 첫 타입만 사용
}
문제 2: 빈 팩에서 fold expression 사용
증상: error: fold of empty expansion over operator '+'
원인: 인자 없이 호출하면 (args + ...)가 빈 팩에 적용되어 에러가 납니다.
// ❌ 잘못된 코드
template <typename... Args>
auto sum(Args... args) {
return (args + ...); // sum() 호출 시 에러!
}
// ✅ 올바른 코드: 초기값 제공
template <typename... Args>
auto sum(Args... args) {
return (0 + ... + args); // binary fold, 빈 팩이면 0 반환
}
// 또는 if constexpr로 분기
template <typename... Args>
auto sum(Args... args) {
if constexpr (sizeof...(Args) == 0) {
return 0;
} else {
return (args + ...);
}
}
문제 3: 출력 시 구분자 누락
증상: print(1, 2, 3) 출력이 123으로 붙어 나옴
원인: (std::cout << ... << args)는 인자 사이에 아무것도 넣지 않습니다.
// ❌ 구분자 없음
(std::cout << ... << args); // 123
// ✅ comma 연산자로 구분자 추가
((std::cout << args << " "), ...); // 1 2 3
문제 4: 재귀 종료 조건 누락
증상: error: no matching function for call to 'print()'
원인: 재귀 전개 시 인자가 0개가 되면 print()가 호출되는데, 이 비템플릿 오버로드가 없으면 에러입니다.
// ❌ 종료 조건 없음
template <typename T, typename... Args>
void print(T first, Args... rest) {
std::cout << first << " ";
print(rest...); // print() 호출 시 에러!
}
// ✅ 종료 조건 추가
void print() { std::cout << "\n"; }
template <typename T, typename... Args>
void print(T first, Args... rest) {
std::cout << first << " ";
print(rest...);
}
문제 5: perfect forwarding 누락
증상: rvalue 참조가 복사로 전달됨
원인: Args... args로 받으면 값 복사가 됩니다. 전달 참조와 std::forward를 써야 합니다.
// ❌ 값으로 받음 (복사 발생)
template <typename... Args>
void wrapper(Args... args) {
target(args...); // lvalue로 전달됨
}
// ✅ perfect forwarding
template <typename... Args>
void wrapper(Args&&... args) {
target(std::forward<Args>(args)...);
}
문제 6: 파라미터 팩을 표현식에 잘못 전개
증상: error: expansion pattern 'args' contains no parameter packs
원인: ... 위치가 잘못되었거나, 팩이 아닌 이름을 전개하려 함.
// ❌ 잘못된 전개
template <typename... Args>
void wrong(Args... args) {
std::cout << args; // args만 쓰고 ... 없음 → 에러
}
// ✅ 올바른 전개
template <typename... Args>
void correct(Args... args) {
(std::cout << ... << args); // fold로 전개
}
문제 7: 재귀 깊이 한계 (컴파일러별)
증상: error: template instantiation depth exceeds maximum of 256 (GCC 기본값)
원인: 인자가 너무 많으면 재귀 전개 깊이가 컴파일러 한계를 넘김.
// 256개 이상 인자 시 GCC에서 에러 가능
print(1, 2, 3, /* ... 250개 더 ... */);
// 해결: -ftemplate-depth=512 등으로 늘리거나, fold expression 사용
template <typename... Args>
void print(Args... args) {
(std::cout << ... << args) << "\n"; // 재귀 없음 → 깊이 문제 없음
}
8. 성능 비교와 최적화
재귀 vs Fold Expression
| 방식 | 컴파일 시간 | 코드 크기 | 재귀 깊이 한계 | 런타임 성능 |
|---|---|---|---|---|
| 재귀 전개 | 인자 수에 비례해 증가 | 인자 수만큼 함수 인스턴스 | 256 (GCC 기본) | 인라인되면 동일 |
| Fold (C++17) | 상대적으로 적음 | 단일 인스턴스 | 영향 없음 | 동일 |
요약: Fold expression이 컴파일 시간과 코드 크기 측면에서 유리합니다. 런타임 성능은 둘 다 인라인되면 거의 동일합니다.
va_list vs 가변 인자 템플릿
| 항목 | va_list (C 스타일) | 가변 인자 템플릿 |
|---|---|---|
| 타입 안전성 | 없음 (UB 위험) | 컴파일 시점 검사 |
| 인자 타입 | 동일 타입 가정 | 서로 다른 타입 혼합 가능 |
| 컴파일 시점 최적화 | 제한적 | 인라인·전개로 최적화 |
| 런타임 오버헤드 | va_start/va_arg 호출 | 없음 (인라인) |
벤치마크 요약: va_list는 va_arg 오버헤드로 약간 느릴 수 있으나, 가변 인자 템플릿(재귀·fold)은 인라인 후 거의 동일합니다. 차이는 컴파일 시간과 바이너리 크기에서 두드러집니다.
인스턴스화 최소화
// ❌ 매 호출마다 새 타입 조합 → 인스턴스 폭발
template <typename... Args>
void log(Args... args) {
(std::cout << ... << args) << "\n";
}
// log(1,2), log(1,2,3), log(1,2,3,4) → 각각 다른 인스턴스
// ✅ 공통 타입으로 수렴 (가능할 때)
template <typename... Args>
void log(const Args&... args) {
(std::cout << ... << args) << "\n";
}
// const 참조로 받으면 일부 인스턴스 공유 가능
constexpr 활용
타입 검사나 컴파일 시점 계산은 constexpr로 하면 런타임 비용이 없습니다.
template <typename T, typename... Args>
constexpr bool areAllSame() {
return (std::is_same_v<T, Args> && ...);
}
// 컴파일 시점에 검사, 런타임 오버헤드 없음
static_assert(areAllSame<int, int, int>());
9. 프로덕션 패턴
실무에서 가변 인자 템플릿을 활용하는 대표적인 패턴입니다.
패턴 1: 로깅 레벨 + 가변 인자
#include <iostream>
enum class LogLevel { Debug, Info, Warn, Error };
template <typename... Args>
void log(LogLevel level, Args&&... args) {
const char* prefix[] = {"[DEBUG] ", "[INFO] ", "[WARN] ", "[ERROR] "};
std::cout << prefix[static_cast<int>(level)];
(std::cout << ... << std::forward<Args>(args)) << "\n";
}
// log(LogLevel::Info, "User ", 42, " connected");
패턴 2: 팩토리 + perfect forwarding
#include <memory>
template <typename T, typename... Args>
std::unique_ptr<T> makeUnique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
struct Widget {
Widget(int a, const std::string& b) {}
};
int main() {
auto w = makeUnique<Widget>(42, "hello");
}
패턴 3: 조건부 컴파일 (SFINAE + 가변 인자)
#include <type_traits>
#include <iostream>
template <typename T, typename... Args>
std::enable_if_t<std::is_constructible_v<T, Args...>, T>
createOrNull(Args&&... args) {
return T(std::forward<Args>(args)...);
}
struct OnlyInt { OnlyInt(int) {} };
int main() {
auto o = createOrNull<OnlyInt>(42); // OK
}
패턴 4: 가변 인자 join (구분자 연결)
#include <sstream>
template <typename... Args>
std::string join(const char* sep, Args&&... args) {
std::ostringstream oss;
std::size_t n = 0;
((oss << (n++ ? sep : "") << std::forward<Args>(args)), ...);
return oss.str();
}
// join(", ", 1, "two", 3.0) → "1, two, 3"
프로덕션 체크리스트
| 항목 | 검토 |
|---|---|
| C++17 이상 사용 시 fold expression 우선 | (args + ...) 등 |
빈 팩 호출 시 binary fold 또는 if constexpr | (0 + ... + args) |
move-only 타입 전달 시 std::forward | Args&&... |
| 로깅/포맷 시 구분자 | ((cout << args << " "), ...) |
| 인스턴스 폭발 방지 | const Args&... (가능할 때) |
| 재귀 깊이 한계 (256) | fold로 대체 가능 |
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 범위 기반 for문과 구조화된 바인딩 | 모던 C++ 반복문
- C++ std::function | 콜백·전략 패턴과 함수 객체
- C++ optional·variant·any | “nullptr 체크 지겹다” C++17 타입 안전 처리
이 글에서 다루는 키워드 (관련 검색어)
C++ 가변 인자 템플릿, variadic template, parameter pack, fold expression, 가변 인자 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| 기본 문법 | template <typename... Args> |
| 파라미터 팩 | Args... (타입), args... (값) |
| 크기 | sizeof...(Args) |
| 재귀 전개 | 종료 조건 + 재귀 호출 |
| Fold (C++17) | (args + ...), (... && args), ((cout << args << " "), ...) |
| 실전 용도 | printf, 래퍼, 튜플, 다중 삽입, 로깅, 팩토리 |
| 프로덕션 | perfect forwarding, 빈 팩 처리, 인스턴스 최소화 |
구현 체크리스트
가변 인자 템플릿을 적용할 때 다음을 확인하세요:
- C++17 이상 사용 시 fold expression 우선 검토
- 빈 팩 호출 시 binary fold 또는
if constexpr로 처리 - 재귀 패턴 사용 시 반드시 종료 조건(비템플릿 오버로드) 정의
- perfect forwarding이 필요하면
Args&&...와std::forward사용 - 출력/포맷 시 구분자는 comma fold
((cout << args << " "), ...)활용 - 타입 검사는
constexpr로 컴파일 시점에 수행
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 로깅, 포맷 출력, 테스트 프레임워크(여러 인자 검증), emplace 생성자, tuple/optional 같은 타입 구현, 여러 컨테이너에 동시 삽입 등 “인자 개수가 가변”인 상황에서 사용합니다. C++17 이상이면 fold expression을 우선 고려하세요.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference - Parameter pack과 Fold expression 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
Q. C++11만 쓸 수 있는 환경에서는?
A. Fold expression 대신 재귀 전개 패턴을 사용합니다. 종료 조건과 재귀 케이스를 각각 정의하고, if constexpr(C++17) 대신 sizeof...(rest) == 0 체크나 SFINAE로 분기할 수 있습니다.
관련 글
- C++ 템플릿 입문 | template
와 템플릿 컴파일 에러 해결법 - C++ 클래스 템플릿 | 제네릭 컨테이너와 부분 특수화
- C++ 템플릿 특수화 완벽 가이드 | 완전·부분 특수화, 문제 시나리오, 프로덕션 패턴
- C++ Fold Expression 완벽 가이드 | 단항·이항·쉼표 fold·커스텀 연산자 실전
- C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지