카테고리:

형식 영역 - Type Deduction

C++ 98부터 존재하던 형식 영역은 템플릿에 관한 내용만 존재하였지만, C++11에서 auto와 decltype이 추가되고 C++14에서는 이들이 확장되었기 때문에 형식 영역에 적용 범위도 증가하였습니다.

형식 영역은 함수 템플릿, auto, decltype 표현식 등에 항상 발생하며, C++14의 경우 decltype(auto) 구성체(둘 이상의 언어 요소로 이루어진 코드 조각)이 쓰이는 곳에서도 발생합니다.

형식 영역을 확실히 이해하지 못한다면 Modern C++에서의 효과적인 프로그래밍이 어렵기 때문에 이 책은 작동 방식을 이해하고 컴파일러가 이를 명시적으로 표시하게 하는 방법을 설명합니다.

항목1: 템플릿 연역 형식의 숙지

템플릿 연역 형식 내부 구조는 사용자가 알 필요 없어도 손 쉬운 사용이 가능하도록 구현되어있습니다.

그리고, C++14부터 제공되는 auto는 템플릿에 대한 형식 연역을 기반으로 작동하기 때문에 템플릿이 올바르게 작동하는 환경에서는 auto도 동일하게 문제 없이 작동합니다.

하지만 auto의 형식 연역 규칙은 템플릿보다 덜 직관적인 면모가 있기 때문에 템플릿 형식 연역을 잘 이해하고 auto를 올바르게 사용하는 것이 중요합니다.

템플릿의 선언과 호출은 다음과 같습니다

template <typename T>
void f(ParamType param);

f(expr);

ParamType을 T로 지정해두지 않는 이유는 다음과 같습니다.

template<typename T>
void f(const T& param);

int x = 0;

f(x);

이 경우 ParamType은 const T&로 연역되며, T는 int로 연역됩니다.

하지만 항상 이렇게 연역되지 않고 다른 경우도 존재하며, 이는 ParamType의 형태에 따라 세 가지로 분류됩니다.

  1. ParamType이 포인터 또는 참조 형식이지만 보편 참조(universal reference)가 아닌 경우.

  2. ParamType이 보편 참조인 경우.

  3. ParamType이 포인터도 아니고 참조도 아닌 경우.

보편 참조 (universal reference)?
universal reference는 매개변수 선언은 T&&의 형태로 오른값 참조와 같은 형태이지만, 왼값 인수가 전달된다면 오른값 인수와 다른 방식으로 작동합니다.

ParamType이 포인터 또는 참조 형식이지만 보편 참조가 아닌 경우

가장 간단한 경우이며, 형식 연역은 다음과 같이 진행됩니다.

  1. expr이 참조 형식이면 참조 부분을 무시합니다.

  2. expr의 형식을 ParamType에 대해 패턴 부합(pattern-matching) 방식으로 대응하여 T의 형식을 결정합니다.

예시)

template <typename T>
void f(T& param);

int x = 27;         // x는 int
const int cx = x;   // cx는 const int
const int& rx = x;  // rx는 const int인 x에 대한 참조

f(x);               // T는 int, ParamType은 int&
f(cx);              // T는 const int, ParamType은 const int&
f(rx);              // T는 const int, ParamType은 const int&

const 값이 배정되는 경우, const-ness를 보장하기 위해 const는 유지되며 rx와 cx는 T에 const가 포함됩니다.
그리고 reference-ness는 무시되기 때문에 rx의 경우에 T에 &가 없이 const int로 연역되었음을 알 수 있습니다.

Pointer와 Reference는 동일하게 적용됩니다.

ParamType이 universal reference인 경우

이 경우에는 왼값 인수와 오른값 인수에 대해 다른 방식으로 행동하게 됩니다.

expr이 오른값인 경우에는 1번과 동일하게 적용되며, 왼값인 경우에는 T와 ParamType 둘다 왼값 참조로 연역되게 됩니다.

이 상황은 이질적인 상황으로 판별되는데, 그 이유는 템플릿 형식 연역에서 T가 참조 형식으로 되는 것은 이 경우가 유일하며, ParamType은 T&&로 오른값 참조 형태를 갖지만 연역된 형식은 왼값 참조가 되기 때문입니다.

예시)

template <typename T>
void f(T&& param);

int x = 27;         // x는 int
const int cx = x;   // cx는 const int
const int& rx = x;  // rx는 const int인 x에 대한 참조

f(x);               // x는 왼값, T는 int&, ParamType는 int&
f(cx);              // cx는 왼값, T는 const int&, ParamType는 const int&
f(rx);              // rx는 왼값, T는 const int&, ParamType는 const int&
f(27);              // 27은 오른값, T는 int, ParamType는 int&&

ParamType가 포인터도 아니고 참조도 아닌 경우

포인터도 아니고 참조도 아닌 경우는 값 전달의 경우이기 때문에 새로운 객체이며, 다음과 같은 규칙이 적용됩니다.

  1. expr의 형식이 참조라면, 참조 부분을 무시합니다.

  2. expr의 참조성을 무시한 후, expr이 const라면 const도 무시합니다. volatile이라면 그 역시 무시합니다.

주의점
주의 해야할 점은 포인터 변수를 전달할 때 발생합니다.

아래와 같이 const char* const ptr 변수는 ptr의 주솟값을 변경할 수 없고, 주솟값이 가리키는 문자열도 변경할 수 없는 변수입니다.

하지만 값 전달로 들어가며 포인터에 대한 const는 무시되기 때문에, 형식 연역은 const char*가 되어 변경할 수 있는 포인터 변수로 들어가게 됩니다.

예시)

template <typename T>
void f(T param);

int x = 27;         // x는 int
const int cx = x;   // cx는 const int
const int& rx = x;  // rx는 const int인 x에 대한 참조

f(x);               // T와 ParamType은 int
f(cx);              // T와 ParamType은 int
f(rx);              // T와 ParamType은 int

//주의점
const char* const ptr = "Fun with Pointers";

배열 인수 (Array Parameter)

배열 인수는 포인터 형태로 사용할 수 있는 형식의 틈새로 인해 발생할 수 있는 문제가 존재합니다.

배열을 이름으로 사용하면 포인터 변수로 사용하게 되는데, 이는 배열이 배열의 첫 원소를 가리키는 포인터로 붕괴(decay)한다고 말합니다.

이 형식은 아래와 같은 코드를 문제 없이 컴파일하게 됩니다.

const char name[] = "J. P. Briggs"; // const char[13] 형태
const char * ptrToName = name; // 배열이 포인터로 붕괴하게 된다

코드는 일반적인 상황에서는 문제없이 잘 작동합니다. 하지만 위 변수를 매개변수로 전달하는 경우를 고려해야합니다.

아래의 두 함수는 같은 매개변수를 갖습니다.

void f(int pararm[]) == void f(int* param)

그렇기에 위의 const char name 변수를 넣는다면 T는 배열이 아닌 const char*로 연역됩니다.

이를 배열 자체 형식으로 연역하기 위해 사용할 수 있는 교묘한 요령이 있습니다. 바로 매개변수를 참조로 받는 것입니다.

template <typename T>
void f(T& param);

매개변수를 참조로 받는다면 T의 형식은 배열 그 자체가 되며, 예시로 name을 넣는다면 T는 const char[13], ParamType은 const char&[13]이 되게 됩니다.

이를 통해 다음과 같이 컴파일 시간에 배열에 담긴 원소 갯수를 연역하는 템플릿을 만들 수 있습니다.

template <typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept // constexpr은 함수 호출의 결과를 컴파일 때 사용할 수 있게 합니다.
{
  return N;
}

함수 인수 (Function Parameter)

배열이 아닌 함수도 인수로 넘길 때 포인터 붕괴가 발생할 수 있습니다.

void someFunc(int, double);

template<typename T>
void f1(T param);

template<typename T>
void f2(T& param);

f1(someFunc);   // param이 함수 포인터로 연역, void (*)(int, double)
f2(someFunc);   // param이 함수 참조로 연역, void (&)(int, double)

항목2: auto의 형식 연역 규칙 숙지

auto의 형식 연역 규칙은 한 가지를 제외하면 템플릿과 동일합니다.

대괄호 {}를 사용하는 경우에 발생하며, T는 대괄호를 사용하여 초기화를 진행한다면 대괄호 내부에 있는 변수의 타입으로 연역됩니다.

하지만 auto의 경우 std::initializer_list의 형태로 연역하게 됩니다.

auto x1 = 27;   // int로 연역
auto x2 = {27}; // std::initializer_list<int>로 연역

이러한 이유 때문에 대괄호를 사용하여 템플릿과 auto문을 사용하는 경우에는 다음과 같은 상황에서 오류가 발생할 수 있기 때문에 항상 주의하여야 합니다.

initializer_list를 연역할 수 없어서 오류가 발생하는 경우

template<typename T>
void f(T param);

f({11,23,9});       // T에 대한 형식 연역이 불가능하기 때문에 컴파일 오류가 발생

//void f(std::initializer_list<T> param)으로 선언하여 오류를 해결할 수 있다.

반환값 또는 람다식의 매개변수로 사용되는 auto는 템플릿과 동일한 연역 방식을 사용하기 때문에 오류가 발생

auto createInitList()
{
  return {1,2,3};
}

auto resetV = [&v](const auto& newValue) {v = newValue;};

resetV({1,2,3});

항목3: decltype의 작동 방식 숙지ㅇ

decltype은 주어진 이름 또는 표현식의 type을 return해줍니다.

대부분의 경우에는 프로그래머의 예측대로 동작하지만, 아주 가끔 뜻 밖의 결과를 보이기 때문에 작동 방식을 숙지한다면 이러한 상황에 직면한 경우 원인을 파악할 수 있습니다.

아래는 후행 반환 형식(trailing return type)을 사용하여 decltype을 통해 return type을 지정하는 과정입니다.

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

만일 decltype없이 auto로만 return type을 연역하여 반환한다면, c[i]가 int라고 가정할 때 c[i]에 대한 참조값 int&가 아닌 int가 반환되어 생각대로 작동하지 않을 것입니다.

//decltype이 없는 경우
std::deque<int> d;
authAndAccess(d, 5) = 10; // 오른값에 오른값을 넣게 되어 컴파일 오류가 발생합니다.

위에서 나온 decltype의 예시는 올바르게 작동하지만 보기에 난잡하며, 왼값 참조로만 받는다는 단점이 있습니다.

함수에는 오른값 참조를 통해 임시 객체를 받는 등의 기능이 필요할 수 있으며, 이러한 조건들을 적용하여 C++14에서는 좀 더 정련된 방식을 사용할 수 있습니다.

보편 참조와 std::forward를 활용

template<typename Container, typename Index>
decltype(auto)
authAndAccess(Container&& c, Index i)
{
  authenticateUser();
  return std::forward<Container>(c)[i];
}

decltype이 오작동 하는 경우

decltype이 아주 가끔 오작동을 발생 시키는 경우는 괄호를 사용하는 경우입니다.

decltype(x)는 int가 나오며, decltype((x))는 int&가 나오게 됩니다.

이는 (x)도 하나의 표현식으로 간주하기 때문에 발생하는 차이입니다.

일반적으로는 큰 문제가 없어보이지만, C++14에서는 decltype()을 통해 return type을 지정할 수 있기 때문에 다음과 같은 상황을 주의하여야 합니다.

decltype(auto) f1()
{
  int x = 0;
  return x;
}

decltype(auto) f2()
{
  int x = 0;
  return (x);
}

f1은 int타입의 x를 반환하지만, f2는 int&타입의 x를 반환하게 되는데, 이는 함수의 지역 변수이기 때문에 오류가 발생할 수 있습니다.

아주 극단의 케이스를 제외하고는 decltype은 강력한 무기이기 때문에 잘 파악하고 사용하는 것이 중요합니다.