Effective Modern C++ - 형식 영역
카테고리: Books
형식 영역 - 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의 형태에 따라 세 가지로 분류됩니다.
-
ParamType이 포인터 또는 참조 형식이지만 보편 참조(universal reference)가 아닌 경우.
-
ParamType이 보편 참조인 경우.
-
ParamType이 포인터도 아니고 참조도 아닌 경우.
보편 참조 (universal reference)?
universal reference는 매개변수 선언은 T&&의 형태로 오른값 참조와 같은 형태이지만, 왼값 인수가 전달된다면 오른값 인수와 다른 방식으로 작동합니다.
ParamType이 포인터 또는 참조 형식이지만 보편 참조가 아닌 경우
가장 간단한 경우이며, 형식 연역은 다음과 같이 진행됩니다.
-
expr이 참조 형식이면 참조 부분을 무시합니다.
-
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가 포인터도 아니고 참조도 아닌 경우
포인터도 아니고 참조도 아닌 경우는 값 전달의 경우이기 때문에 새로운 객체이며, 다음과 같은 규칙이 적용됩니다.
-
expr의 형식이 참조라면, 참조 부분을 무시합니다.
-
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은 강력한 무기이기 때문에 잘 파악하고 사용하는 것이 중요합니다.