본문으로 건너뛰기 C++ 가변 인자 템플릿 | '가변 템플릿' 완벽 가이드

C++ 가변 인자 템플릿 | '가변 템플릿' 완벽 가이드

C++ 가변 인자 템플릿 | '가변 템플릿' 완벽 가이드

이 글의 핵심

가변 인자 템플릿의 팩 확장·sizeof...·C++17 fold·완벽한 전달까지, 컴파일러가 패턴을 전개하는 방식과 프로덕션 패턴을 정리합니다.

가변 인자 템플릿이란?

가변 인자 템플릿 (Variadic Templates) 은 C++11에서 도입된 기능으로, 임의 개수의 템플릿 인자를 받을 수 있습니다. 타입 안전하면서도 유연한 함수와 클래스를 작성할 수 있습니다.

왜 필요한가?:

  • 타입 안전: C 스타일 가변 인자(...)보다 안전
  • 유연성: 임의 개수의 인자 처리
  • 일반성: 모든 타입에 대해 동작
  • 컴파일 타임: 런타임 오버헤드 없음

print 함수의 구현 예제입니다.

// ❌ C 스타일: 타입 불안전
void print(int count, ...) {
    va_list args;
    va_start(args, count);
    // 타입을 알 수 없음
    va_end(args);
}

// ✅ 가변 인자 템플릿: 타입 안전
template<typename...Args>
void print(Args...args) {
    (std::cout << ... << args) << '\n';
}

기본 문법

print 함수의 구현 예제입니다.

// 가변 인자 템플릿 함수
// 실행 예제
template<typename...Args>
void print(Args...args) {
    (cout << ... << args) << endl;  // C++17 fold expression
}

int main() {
    print(1, 2, 3);              // 123
    print("Hello", " ", "World"); // Hello World
}

용어

  • typename…Args: 템플릿 파라미터 팩 (Template Parameter Pack)
  • Args…args: 함수 파라미터 팩 (Function Parameter Pack)
  • args…: 팩 확장 (Pack Expansion)

func 함수의 구현 예제입니다.

// 실행 예제
template<typename...Args>  // 템플릿 파라미터 팩
void func(Args...args) {   // 함수 파라미터 팩
    process(args...);       // 팩 확장
}

재귀 템플릿

print 함수의 구현 예제입니다.

// 종료 조건
void print() {
    cout << endl;
}

// 재귀 케이스
template<typename T, typename...Args>
void print(T first, Args...rest) {
    cout << first << " ";
    print(rest...);  // 재귀 호출
}

int main() {
    print(1, 2, 3, 4, 5);  // 1 2 3 4 5
}

파라미터 팩 확장 패턴(내부 동작)

팩 확장(pack expansion) 은 컴파일러가 패턴 E... 를 만나면, 팩에 들어 있는 각 요소에 대해 동일한 문법 구조를 복제한다는 뜻입니다. 여기서 중요한 것은 “어디에 ... 가 붙었는가”인데, 그 위치에 따라 생성되는 토큰 열이 달라집니다.

패턴이 붙는 위치별 의미

  • f(args...): 한 번의 호출에 인자 목록을 펼칩니다. f(a1, a2, a3) 와 같습니다. 각 인자마다 별도로 f 를 부르고 싶다면 쉼표 fold (f(args), ...) 를 씁니다.
  • std::forward<Args>(args)...: (std::forward<Arg1>(arg1), std::forward<Arg2>(arg2), ...) 처럼 각 쌍이 대응하도록 전개됩니다. 인자 전달에서 가장 자주 쓰는 형태입니다.
  • 템플릿 인자 리스트: Class<Ts...>Class<int, double> 처럼 그대로 전달되고, Class<Ts>... 는 “Ts 의 각 타입에 대해 Class<T> 인스턴스”를 의미하는 패턴 전개가 됩니다(가변 템플릿 템플릿 등).

중첩·혼합 패턴

((args + 1), ...) 는 쉼표 연산자와 fold 를 결합한 예이며, (std::invoke(f, args), ...) 는 각 인자에 단항 f 를 적용할 때 씁니다. Mixin 다중 상속에서는 template<typename... Ts> struct Derived : Bases<Ts>... 처럼 타입 팩마다 대응하는 베이스 클래스를 펼쳐 기능을 합성하는 방식이 자주 사용됩니다.

인덱스와 짝을 맞추기

서로 다른 팩을 동시에 전개할 때는 길이가 같아야 합니다. sizeof...(args) 로 길이를 맞추거나, std::index_sequence / std::make_index_sequence 로 정수 팩과 타입 팩을 동일한 길이로 묶는 패턴이 흔합니다.

#include <tuple>
#include <utility>

template<typename F, typename Tuple, std::size_t... I>
decltype(auto) apply_impl(F&& f, Tuple&& t, std::index_sequence<I...>) {
    return std::forward<F>(f)(std::get<I>(std::forward<Tuple>(t))...);
}

template<typename F, typename Tuple>
decltype(auto) apply(F&& f, Tuple&& t) {
    constexpr std::size_t n = std::tuple_size_v<std::remove_reference_t<Tuple>>;
    return apply_impl(std::forward<F>(f), std::forward<Tuple>(t),
                      std::make_index_sequence<n>{});
}

std::get<I>(t)...값 팩 I 와 튜플 요소 접근을 한 줄에 맞추는 대표적인 “인덱스 시퀀스 + 팩 확장” 패턴입니다.

sizeof… 연산자

sizeof...런타임이 아니라 컴파일 타임 상수로, 이름 붙은 파라미터 팩의 요소 개수를 실행하여줍니다. 함수 템플릿에서는 sizeof...(args), 클래스 템플릿에서는 sizeof...(Ts) 처럼 값 팩·타입 팩 모두에 사용할 수 있습니다.

타입 팩과 값 팩

template<typename... Ts>
struct count_types {
    static constexpr std::size_t value = sizeof...(Ts);
};

template<typename... Args>
void g(Args... args) {
    static_assert(sizeof...(Args) == sizeof...(args));
}

Argsargs 는 서로 다른 네임스페이스의 팩이지만, 일반적인 함수 선언에서는 개수가 항상 같습니다. 부분 특수화추가 템플릿 인자를 끼워 넣는 메타함수에서는 sizeof...(Ts) 만으로 “타입 리스트 길이”를 얻는 경우가 많습니다.

활용: if constexpr·static_assert 와 결합

template<typename...Args>
void printCount(Args...args) {
    if constexpr (sizeof...(args) == 0) {
        std::cout << "인자 없음\n";
    } else {
        std::cout << "인자 개수: " << sizeof...(args) << '\n';
    }
}

printCount 함수의 구현 예제입니다.

template<typename...Args>
void printCount(Args...args) {
    std::cout << "인자 개수: " << sizeof...(args) << '\n';
}

int main() {
    printCount(1, 2, 3);            // 3
    printCount("a", "b", "c", "d"); // 4
}

Fold Expression (C++17) — 문법과 의미

Fold 는 팩을 하나의 표현식으로 접는 문법입니다. 단항/이항, 왼쪽/오른쪽 조합에 따라 결합 순서가 달라지으며, 빈 팩일 때 규칙이 엄격합니다.

단항 fold

  • 왼쪽 단항 (... op pack): ((a op b) op c)
  • 오른쪽 단항 (pack op ...): a op (b op (c op ...))

이항 fold (빈 팩 허용에 유리)

  • 왼쪽 이항 (init op ... op pack): 초기값 init 이 왼에 있어 빈 팩이면 init 이 됩니다.
  • 오른쪽 이항 (pack op ... op init): 초기값이 오른쪽.

논리 연산의 단축 평가

&&|| 를 쓰는 fold 는 일반 연산처럼 단축 평가가 적용됩니다. 예를 들어 (p && ...) 형태는 첫 false 에서 이후를 평가하지 않을 수 있습니다.

template<typename... Args>
bool all_positive(Args... args) {
    return ((args > 0) && ...);  // 모두 참일 때만 true
}

잘못 쓰기 쉬운 예: min 과 비교 연산자

(args < ...) 형태는 “최솟값”이 아니라 비교 연산의 fold이며, 타입과 결합 법칙 때문에 기대와 다를 수 있습니다. 동질 타입의 최솟값에는 std::min 과 이니셜라이저 리스트가 안전합니다.

sum 함수의 구현 예제입니다.

// 단항 왼쪽 fold: (... op pack)
template<typename...Args>
auto sum(Args...args) {
    return (... + args);  // ((arg1 + arg2) + arg3) + ...
}

// 단항 오른쪽 fold: (pack op ...)
template<typename...Args>
auto sum2(Args...args) {
    return (args + ...);  // arg1 + (arg2 + (arg3 + ...))
}

// 이항 fold (빈 팩이면 0)
template<typename...Args>
auto sum_init(Args...args) {
    return (0 + ... + args);
}

// 이항 fold
template<typename...Args>
void printAll(Args...args) {
    (std::cout << ... << args) << '\n';
}

int main() {
    std::cout << sum(1, 2, 3, 4, 5) << '\n';  // 15
    printAll(1, " ", 2, " ", 3);
}

완벽한 전달(Perfect forwarding)과 가변 인자

가변 인자를 다른 함수로 그대로 넘길 때는 값 범주(value category)를 보존해야 합니다. 시그니처는 Args&&...args(전달 참조 팩)로 받으며, 전개 시에는 std::forward<Args>(args)... 를 씁니다.

std::forward 팩인가

args 의 각 요소는 원래 lvalue 일 수도 xvalue 일 수도 있습니다. std::forward<Args>(args) 는 템플릿 인자 Args 에 담긴 원래 값 범주에 맞춰 캐스팅하므로, emplace / construct 계열 API에서 필수입니다.

#include <memory>
#include <utility>

template<typename T, typename... Args>
std::unique_ptr<T> make_unique_forwarded(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

invoke·가변 래퍼

#include <functional>

template<typename F, typename... Args>
decltype(auto) invoke_all(F&& f, Args&&... args) {
    return std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
}

컴파일러는 std::forward<Args>(args)...인자별로 짝이 맞는 일련의 forward 호출로 전개합니다. 한쪽만 args... 로 넘기면 전달 참조 의미가 깨질 수 있으므로, 래핑 함수에서는 항상 위 형태를 유지하는 것이 안전합니다.

실전 예시

예시 1: 제네릭 printf

#include <iostream>
#include <sstream>
using namespace std;

void printf_impl(ostringstream& oss, const char* format) {
    oss << format;
}

template<typename T, typename...Args>
void printf_impl(ostringstream& oss, const char* format, T value, Args...args) {
    while (*format) {
        if (*format == '%' && *(++format) != '%') {
            oss << value;
            printf_impl(oss, format, args...);
            return;
        }
        oss << *format++;
    }
}

template<typename...Args>
string sprintf(const char* format, Args...args) {
    ostringstream oss;
    printf_impl(oss, format, args...);
    return oss.str();
}

int main() {
    cout << sprintf("Hello % from %", "World", "C++") << endl;
    cout << sprintf("% + % = %", 1, 2, 3) << endl;
}

예시 2: 튜플 구현

template<typename...Types>
class Tuple;

template<>
class Tuple<> {};

template<typename Head, typename...Tail>
class Tuple<Head, Tail...> : private Tuple<Tail...> {
private:
    Head value;
    
public:
    Tuple(Head h, Tail...t) : Tuple<Tail...>(t...), value(h) {}
    
    Head& head() { return value; }
    Tuple<Tail...>& tail() { return *this; }
};

template<size_t Index, typename...Types>
struct TupleElement;

template<typename Head, typename...Tail>
struct TupleElement<0, Tuple<Head, Tail...>> {
    using type = Head;
    
    static Head& get(Tuple<Head, Tail...>& t) {
        return t.head();
    }
};

template<size_t Index, typename Head, typename...Tail>
struct TupleElement<Index, Tuple<Head, Tail...>> {
    using type = typename TupleElement<Index-1, Tuple<Tail...>>::type;
    
    static type& get(Tuple<Head, Tail...>& t) {
        return TupleElement<Index-1, Tuple<Tail...>>::get(t.tail());
    }
};

template<size_t Index, typename...Types>
auto& get(Tuple<Types...>& t) {
    return TupleElement<Index, Tuple<Types...>>::get(t);
}

int main() {
    Tuple<int, double, string> t(42, 3.14, "Hello");
    
    cout << get<0>(t) << endl;  // 42
    cout << get<1>(t) << endl;  // 3.14
    cout << get<2>(t) << endl;  // Hello
}

예시 3: 함수 체이닝

#include <iostream>

template<typename...Funcs>
class Pipeline;

template<typename Func>
class Pipeline<Func> {
private:
    Func func;
    
public:
    Pipeline(Func f) : func(f) {}
    
    template<typename T>
    auto operator()(T value) {
        return func(value);
    }
};

template<typename Func, typename...Rest>
class Pipeline<Func, Rest...> {
private:
    Func func;
    Pipeline<Rest...> rest;
    
public:
    Pipeline(Func f, Rest...r) : func(f), rest(r...) {}
    
    template<typename T>
    auto operator()(T value) {
        return rest(func(value));
    }
};

template<typename...Funcs>
auto makePipeline(Funcs...funcs) {
    return Pipeline<Funcs...>(funcs...);
}

int main() {
    auto pipeline = makePipeline(
        [](int x) { return x * 2; },
        [](int x) { return x + 10; },
        [](int x) { return x * x; }
    );

    std::cout << pipeline(5) << '\n';  // ((5*2)+10)^2 = 400
}

예시 4: 가변 인자 min/max

minFold 함수의 구현 예제입니다.

#include <algorithm>
#include <iostream>

template<typename T>
T min(T value) {
    return value;
}

template<typename T, typename...Args>
T min(T first, Args...rest) {
    T restMin = min(rest...);
    return first < restMin ? first : restMin;
}

// C++17: 동질 타입 최솟값 (initializer_list + min)
template<typename T, typename... Args>
T minFold(T first, Args... rest) {
    return (std::min)({first, rest...});
}

int main() {
    std::cout << min(5, 2, 8, 1, 9) << '\n';       // 1
    std::cout << minFold(5, 2, 8, 1, 9) << '\n';  // 1
}

팩 확장으로 순회하기

#include <iostream>
#include <vector>

// 각 인자에 함수 적용
template<typename Func, typename... Args>
void forEach(Func f, Args... args) {
    (f(args), ...);  // 쉼표 fold
}

// 각 인자를 벡터에 추가
template<typename... Args>
std::vector<int> makeVector(Args... args) {
    std::vector<int> result;
    (result.push_back(args), ...);
    return result;
}

int main() {
    forEach([](auto x) { std::cout << x << ' '; }, 1, 2, 3, 4, 5);
    std::cout << '\n';

    auto v = makeVector(10, 20, 30);
    for (int x : v) {
        std::cout << x << ' ';
    }
}

타입 추출

// 첫 번째 타입 추출
template<typename...Args>
struct FirstType;

template<typename First, typename...Rest>
struct FirstType<First, Rest...> {
    using type = First;
};

// N번째 타입 추출
template<size_t N, typename...Args>
struct NthType;

template<typename First, typename...Rest>
struct NthType<0, First, Rest...> {
    using type = First;
};

template<size_t N, typename First, typename...Rest>
struct NthType<N, First, Rest...> {
    using type = typename NthType<N-1, Rest...>::type;
};

int main() {
    using T1 = FirstType<int, double, string>::type;  // int
    using T2 = NthType<1, int, double, string>::type;  // double
}

자주 발생하는 문제

문제 1: 빈 팩 처리

sum 함수의 구현 예제입니다.

// ❌ 에러 (빈 팩)
template<typename...Args>
auto sum(Args...args) {
    return (... + args);  // 빈 팩이면 에러
}

// ✅ 초기값 제공
template<typename...Args>
auto sum(Args...args) {
    return (0 + ... + args);  // 이항 fold
}

문제 2: 재귀 종료 조건 누락

print 함수의 구현 예제입니다.

// ❌ 무한 재귀
template<typename T, typename...Args>
void print(T first, Args...rest) {
    cout << first << " ";
    print(rest...);  // 종료 조건 없음!
}

// ✅ 종료 조건 추가
void print() {}  // 종료 조건

template<typename T, typename...Args>
void print(T first, Args...rest) {
    cout << first << " ";
    print(rest...);
}

문제 3: 타입 추론 실패

func 함수의 구현 예제입니다.

// ❌ 타입 추론 실패
template<typename...Args>
void func(Args...args) {
    auto result = (args + ...);  // 타입 불명확
}

// ✅ 명시적 반환 타입
template<typename...Args>
auto func(Args...args) -> decltype((args + ...)) {
    return (args + ...);
}

성능 고려사항

// 재귀 템플릿 (컴파일 시간 증가)
template<typename T, typename...Args>
T sum(T first, Args...rest) {
    if constexpr (sizeof...(rest) == 0) {
        return first;
    } else {
        return first + sum(rest...);
    }
}

// Fold expression (더 빠름)
template<typename...Args>
auto sumFold(Args...args) {
    return (... + args);
}

프로덕션에서의 가변 인자 패턴

라이브러리·프레임워크 코드에서는 다음 패턴이 반복됩니다.

  • emplace 계열: container.emplace_back(std::forward<Args>(args)...) 처럼 저장소 안에 객체를 제자리 생성할 때 가변 전달이 표준입니다.
  • std::make_unique / std::make_shared: 구현은 내부적으로 new T(std::forward<Args>(args)...) 와 동일한 전개를 사용합니다.
  • std::tuple_cat / std::apply: 튜플을 합치거나 호출 가능 객체에 인자 팩을 펼칠 때, 인덱스 시퀀스 + get<I> 팩 확장이 핵심입니다.
  • 타입 특성 fold: (std::is_integral_v<Args> && ...)(... || std::is_same_v<Args, T>) 형태로 컴파일 타임 제약을 걸으며, 실패 시 static_assert 메시지로 원인을 좁힙니다.
  • 가변 템플릿 operator() 오버로드 집합: operator()(T)operator()(T, Args...) 를 함께 두어 “0개·1개·여러 개” 호출을 한 클래스에서 처리하는 패턴이 흔합니다(C++17 이후에는 if constexpr(sizeof...(args) == 0) 로 분기하기도 합니다).

컴파일 시간과 에러 메시지 길이는 인자 개수에 비례해 늘어날 수 있으므로, 공통 베이스 템플릿으로 분리하거나 개념(Concepts) 으로 조건을 먼저 걸어 인스턴스 수를 줄이는 것이 운영 환경에서 유리합니다.

실무 패턴

패턴 1: 로깅 시스템

enum class LogLevel { INFO, WARNING, ERROR };

template<typename...Args>
void log(LogLevel level, Args&&...args) {
    std::ostringstream oss;
    
    // 레벨 출력
    switch (level) {
        case LogLevel::INFO:    oss << "[INFO] "; break;
        case LogLevel::WARNING: oss << "[WARN] "; break;
        case LogLevel::ERROR:   oss << "[ERROR] "; break;
    }
    
    // 메시지 출력
    (oss << ... << std::forward<Args>(args));
    
    std::cout << oss.str() << '\n';
}

// 사용
log(LogLevel::INFO, "User ", 123, " logged in");
log(LogLevel::ERROR, "Failed to connect to ", "database");

패턴 2: 타입 안전 printf

template<typename...Args>
std::string format(const std::string& fmt, Args&&...args) {
    std::ostringstream oss;
    size_t argIndex = 0;
    
    for (size_t i = 0; i < fmt.size(); ++i) {
        if (fmt[i] == '{' && i + 1 < fmt.size() && fmt[i + 1] == '}') {
            // {} 발견
            ((argIndex++ == 0 ? (oss << args, true) : false) || ...);
            ++i;
        } else {
            oss << fmt[i];
        }
    }
    
    return oss.str();
}

// 사용
auto msg = format("User {} has {} points", "Alice", 100);
// "User Alice has 100 points"

패턴 3: 이벤트 발행

template<typename...Args>
class Event {
    std::vector<std::function<void(Args...)>> handlers_;
    
public:
    void subscribe(std::function<void(Args...)> handler) {
        handlers_.push_back(std::move(handler));
    }
    
    void emit(Args...args) {
        for (auto& handler : handlers_) {
            handler(args...);
        }
    }
};

// 사용
Event<int, std::string> userEvent;

userEvent.subscribe([](int id, const std::string& name) {
    std::cout << "User " << id << ": " << name << '\n';
});

userEvent.emit(123, "Alice");

FAQ

Q1: 가변 인자 템플릿은 언제 사용하나요?

A:

  • 인자 개수가 가변적인 함수: printf, make_tuple
  • 제네릭 라이브러리: 팩토리 함수, 래퍼
  • 메타프로그래밍: 타입 리스트 처리
template<typename...Args>
void print(Args...args) {
    (std::cout << ... << args) << '\n';
}

Q2: 재귀 vs fold expression?

A: C++17 이상이면 fold expression이 더 간결하고 빠릅니다.

sum 함수의 구현 예제입니다.

// 재귀 (C++11)
template<typename T>
T sum(T value) { return value; }

template<typename T, typename...Args>
T sum(T first, Args...rest) {
    return first + sum(rest...);
}

// Fold expression (C++17)
template<typename...Args>
auto sum(Args...args) {
    return (... + args);
}

Q3: 가변 인자 템플릿 vs 가변 인자 함수?

A:

  • 템플릿: 타입 안전, 컴파일 타임, 권장
  • 함수(...): 타입 불안전, 런타임, 비권장

print 함수의 구현 예제입니다.

// ❌ C 스타일: 타입 불안전
void print(int count, ...) {
    va_list args;
    // 타입을 알 수 없음
}

// ✅ 템플릿: 타입 안전
template<typename...Args>
void print(Args...args) {
    (std::cout << ... << args);
}

Q4: 성능 오버헤드는?

A: 없습니다. 컴파일 타임에 모두 처리되므로 런타임 오버헤드가 없습니다.

sum 함수의 구현 예제입니다.

template<typename...Args>
auto sum(Args...args) {
    return (... + args);
}

sum(1, 2, 3, 4, 5);
// 컴파일 타임에 1 + 2 + 3 + 4 + 5로 확장
// 런타임 오버헤드 없음

Q5: 디버깅은 어떻게 하나요?

A:

  • static_assert: 컴파일 타임 검증
  • 컴파일 에러: 에러 메시지를 주의 깊게 읽기
  • 단계적 테스트: 간단한 케이스부터 테스트

func 함수의 구현 예제입니다.

template<typename...Args>
void func(Args...args) {
    static_assert(sizeof...(args) > 0, "최소 1개 인자 필요");
    static_assert((std::is_integral_v<Args> && ...), "모두 정수 타입이어야 함");
}

Q6: sizeof... 연산자는 무엇인가요?

A: 파라미터 팩의 크기를 반환하는 연산자입니다. 컴파일 타임 상수입니다.

printCount 함수의 구현 예제입니다.

template<typename...Args>
void printCount(Args...args) {
    std::cout << "인자 개수: " << sizeof...(args) << '\n';
}

printCount(1, 2, 3);  // 3

Q7: 빈 팩은 어떻게 처리하나요?

A: 초기값을 제공하거나 if constexpr 로 확인합니다.

// 초기값 제공
template<typename...Args>
auto sum(Args...args) {
    return (0 + ... + args);  // 빈 팩이면 0
}

// if constexpr
template<typename...Args>
auto sum(Args...args) {
    if constexpr (sizeof...(args) == 0) {
        return 0;
    } else {
        return (... + args);
    }
}

Q8: 가변 인자 템플릿 학습 리소스는?

A:

관련 글: variadic-template-advanced, fold-expression, perfect-forwarding.

한 줄 요약: 가변 인자 템플릿은 임의 개수의 타입 안전한 인자를 받을 수 있는 C++11 기능입니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글


이 글에서 다루는 키워드 (관련 검색어)

C++, 가변인자, variadic, template, 템플릿 등으로 검색하시면 이 글이 도움이 됩니다.