dev/C++

Effective Modern C++ #1 템플릿 형식 연역 규칙을 숙지해라.

dev_dev 2025. 6. 4. 07:48

형식연역 (Deducing Types)

형식 연역 (template)은 타입에 대한 수정이 소스코드의 전반적 수정으로 전파되는 것을 막아준다.

하지만 동시에, 개발자가 의도한 타입인지 예측하는 것은 어려워졌다.

1. 템플릿 형식 연역 규칙을 숙지해라.

auto 가 연역하는 방식과, C++98 에도 존재했던 템플릿이 연역하는 방식은 일치한다.

따라서 template 연역 규칙을 숙지한다면, auto 연역 규칙에도 동일하게 적용할 수 있다.

template <typename T>
void fn(ParamType a)

fn(expr);

위 예시에서`ParamType` 과 `T` 는 다를 수도 있다.

const, &, && 등 수식어가 ParamType 에 붙을 수 있기 때문이다.

 

템플릿 형식 연역 규칙은, ParamType 종류에 따라 3가지 케이스로 나뉜다.

  1. ParamType 이 포인터나 참조이나, 보편 참조가 아니다.
  2. ParamType 이 보편참조이다.
  3. ParamType 이 포인터나 참조가 아니다.

 

1. ParamType 이 포인터나 참조이나, 보편 참조가 아니다

template <typename T>
void fn(T & param)

int i = 10;
const int ci = i;
const int & ri = i;

fn(i);	// T = int, param = int &
fn(ci); // T = const int, param = const int &

fn(ri); // T = const int, param = const int &

expr 에 들어오는 변수의 참조성은 무시된다.

 

2. ParamType 이 보편참조이다.

template <typename T>
void fn(T && param)

int i = 10;
const int ci = i;
const int & ri = i;

fn(i);	// T = int &, param = int &
fn(ci); // T = const int &, param = const int &

fn(ri); // T = const int &, param = const int &

fn(10); // T = int, param = int &&

expr 값이 l-value 인지 r-value 인지에 따라 연역 규칙이 상이하다.

  • l-value 가 전달되는 경우, l-value reference 형태로 연역된다.
    • ex) T = int &, ParamType = int &
  • r-value 가 전달되는 경우, r-value reference 형태로 연역된다
    • ex) T = int, ParamType = int &&

3. ParamType 이 포인터나 참조가 아니다.

template <typename T>
void fn(T param)

int i = 10;
const int ci = i;
const int &ri = i;

fn(i);	// T = int, param = int
fn(ci); // T = int, param = int

fn(ri); // T = int, param = int

값이 복사되는 케이스이므로, expr 에서의 참조, const는 무시된다. (volatile도 무시)

 


배열, 함수와 같은 특이한 인자의 케이스)

 

배열의 경우 첫번째 원소는 주소를 나타낸다.

expr 에 배열이 들어가면, template 연역 규칙에 따라 포인터로 변경된다.

함수도 마찬가지로, 함수 포인터로 변경된다.

 

 

3. decltype 작동 방식을 숙지하라.

decltype (declared type) 은 객체의 주어진 이름, 표현식 형태 등을 알려준다.

const int i = 5; // decltype(i) = const int

bool f(const Widget & w) // decltype(f) = bool(const Widget &), decltype(w) = const Widget &

 

또한 형식이 T 이고, 이름이 아닌 l-value 표현식에 대해서는 &T 형식으로 반환한다.

 

하지만 예상하지 못한 동작을 하는 케이스가 존재한다.

 

예를 들어, vector, deque 등 컨테이너의 operator[] 반환 형식은 컨테이너마다 다를 수 있다.

따라서 decltype을 사용하여, 이런 반환 형식을 일반화하는 예시를 확인해보자.

#include <iostream>
#include <vector>

template<typename Container, typename Index>
auto ret(Container & c, Index i) -> decltype(c[i]) {
    return c[i];
}

int main() {
    std::vector<int> v(5);
    ret(v, 2) = 5;
    std::cout << v[2] << std::endl;
}

함수의 반환 타입은 auto 이나, C++11 에서는 trailing return type 구문 (후행 반환 형식 / -> 뒤에 반환 형식 표현) 을 사용하지 않으면 컴파일 에러가 발생한다.

trailing return type 구문은 기존 return 구문과 다르게, 함수 인자로 들어오는 값을 사용하여 반환 형식을 지정할 수 있는 장점이 있다.

 

C++ 14 이상부터는 `-> decltype(c[i])` 와 같은 trailing return type 없이 `auto` 와 `return c[i];` 만으로 컴파일러가 반환 형식을 예측하게 할 수 있다.

따라서 auto 는 컴파일러가 '반환 형식을 알아서 예측' 하라는 역할만 하게 된다.

여기서 예상하지 못한 동작이 발생한다.

 

일단 위 코드에서 `-> decltype(c[i])` 를 제거한다.

컨테이너가 함수 인자로 전달되며, template 연역 과정에서 참조성은 사라지고 r-value 로 반환된다. (1. 템플릿 형식 연역 규칙을 숙지해라 참고)

따라서 `ret(v, 2) = 5;` 는 r-value 를 r-value 에 할당하게 되므로 컴파일 에러가 발생한다.

 

해결 방법은 `auto` 대신 `decltype(auto)` 를 사용하는 것이다.

  • auto :  컴파일러가 '반환 형식을 알아서 예측' 하라
  • decltype : 반환 형식을 추론할 때에는 decltype 규칙을 적용해라

이를 사용하면, l-value인 `v[2]` 를 그대로 반환할 수 있다.

 

함수에 l-value 가 아닌 r-value 를 인자로 넘기고 싶은 경우, `std::forward` 를 활용하고, 인자를 보편 참조 형태 (&&)로 변경하면 된다.

이렇게 코드를 수정하는 경우 l-value, r-value 에 모두 대응 가능하다.

// C++14 이하
template<typename Container, typename Index>
auto ret(Container && c, Index i) -> decltype(std::forward<Container>(c)[i]) {
    return std::forward<Container>(c)[i];
    
// C++14 이상
template<typename Container, typename Index>
decltype(auto) ret(Container && c, Index i) {
    return std::forward<Container>(c)[i];

 

하지만 decltype(auto) 마저도 예상하지 못한 동작을 하는 케이스가 있다.

예를 들어, l-value 를 () 괄호로 감싸는 경우 decltype 이 추론하는 형식이 달라진다.

int x = 0;

decltype(x); // int
decltype((x)); // int &

 

아래 예시는 () 와 같은 작성 습관에 따라 decltype(auto) 의 반환 추론이 달라져, 내부 변수 참조를 전달하는 문제 있는 코드이다.

#include <iostream>
#include <vector>

decltype(auto) ret() {
    int a = 5;
    return (a); // & int
}

int main() {
    std::cout << ret() << std::endl;
}