C++ 고급 문법 (2) — 함수 템플릿(Function Template)

지난 글에서 본 오버로딩으로 max를 만들면 int, double, std::string마다 본문이 똑같은 함수를 하나씩 복사해야 한다. 함수 템플릿(function template)은 타입을 매개변수로 받아 이 복사를 컴파일러에게 맡긴다. 대신 템플릿 인수 추론(template argument deduction) 규칙과 인스턴스화(instantiation) 시점을 모르면 예상과 다른 타입으로 추론되거나 링크 에러를 만난다. 이 글에서는 g++ 13.3(-std=c++20)으로 실제 컴파일한 예제로 추론 규칙, 인스턴스화, 특수화와 오버로드의 차이, concepts 제약, 가변 인자 템플릿을 정리한다.

이 시리즈의 다른 글

기본 형태

#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으로 요구 조건을 선언해 에러를 호출 지점으로 끌어온다.

참고

답글 남기기