C++ Type deduction

C++ 에서의 형식 연역
Effective Modern C++ Chapter 1.


Type deduction in template

1
2
3
4
template <typename T>
void f(ParamType param);

f(expr);

위 코드는 컴파일 단계에서 expr로 부터 TParamType에 대한 형식 연역이 이루어진다. 이때 T의 형식 연역은 exprParamType에 영향을 받는다.



ParamType이 l-value ref 인 경우

1
2
3
4
5
6
7
8
9
10
template <typename T>
void f(T& param);

int x = 27;
const int cx = x;
const int& rx = x;

f(x); // call void foo<int>(int&)
f(cx); // call void foo<const int>(const int&)
f(rx); // call void foo<const int>(const int&)

ParamType이 참조형식이므로 T의 형식 연역 과정에서 expr의 참조는 무시된다.
만약 exprconst성질을 갖는다면 T역시 const의 성질을 갖는다. 따라서 ParamTypeT&인 함수에 const객체를 전달해도 안전하다(객체의 상수성이 보장됨).


1
2
3
4
5
6
7
8
9
10
template <typename T>
void f(const T& param);

int x = 27;
const int cx = x;
const int& rx = x;

f(x); // call void foo<int>(const int&)
f(cx); // call void foo<int>(const int&)
f(rx); // call void foo<int>(const int&)

ParamTypeconst로 선언되었다면 참조와 마찬가지로 T의 형식 연역 과정에서 exprconst가 무시된다.



ParamType이 r-value ref인 경우

ParamTypeT에 대한 r-value reference(T&&)지만, 형태만 그럴뿐 expr에 따라 r-value reference와 다르게 동작할 수도 있다.

1
2
3
4
5
6
7
8
9
10
template <typename T>
void f(T&& param);

int x = 27;
const int cx = x;
const int& rx = x;

f(x); // call void f<int&>(int&)
f(cx); // call void f<const int&>(const int&)
f(rx); // call void f<const int&>(const int&)

expr이 l-value라면 TParamType이 모두 l-value reference로 연역된다. 템플릿 형식 연역에서 유일하게 T가 참조로 연역된다.


1
2
3
...

f(27); // call void f<int>(int&&)

expr이 r-value라면 첫 번째 경우의 규칙대로 T에 대한 형식 연역이 이루어진다.



ParamType이 참조가 아닌 경우

1
2
3
4
5
6
7
8
9
10
template <typename T>
void f(T param);

int x = 27;
const int cx = x;
const int& rx = x;

f(x); // call void f<int>(int)
f(cx); // call void f<int>(int)
f(rx); // call void f<int>(int)

paramexpr의 복사본이 전달(pass-by-value)되기 때문에 exprparam은 서로 독립적인 객체이다. expr이 수정될 수 없는 const라도 그 복사본인 param은 상관없다. 따라서 T의 형식 연역 과정에서 expr참조, const, volatile 은 무시된다.


1
2
3
const char* const ptr = "Fun with pointers";

f(ptr); // call void f<const char*>(const char*)

위 코드에서 expr수정할 수 없는 문자(const char)열 을 가리키는 수정할 수 없는 포인터 (* const)이다. 당연하게도 T의 형식 연역 과정에서 ptr이 가리키는 대상의 상수성은 변함 없지만 ptr자체의 상수성은 무시된다.



배열 인수

배열은 포인터와 다르지만 포인터로 decay 될 수 있다. 이 과정에서 배열 길이에 대한 정보를 잃는다.

1
2
const char name[] = "J. P. briggs"; // name의 형식은 const char[13]
const char* ptrToName = name; // 배열이 포인터로 decay된다.

C++에는 배열 형식의 매개변수 라는 것은 없다. void foo(int param[]);같은 선언은 void foo(int* param);의 또다른 표현방식일 뿐 매개변수가 배열이라는것을 보장하지 않는다. 템플릿 형식 연역 과정에서도 T는 포인터로 연역된다.
함수의 매개변수를 배열로 선언할 수는 없지만, 배열에대한 참조로 선언할 수는 있다.

1
2
3
4
5
6
template <typename T>
void f(T& param);

const char name[] = "J. P. briggs";

f(name); // call void f<const char[13]>(const char (&)[13])

위 코드처럼 ParamType이 참조이고 expr이 배열이라면, Tconst char [13]으로 연역되고 ParamType은 그 참조인 const char (&)[13]으로 연역된다.


배열 참조는 배열의 길이정보를 그대로 갖고있다. 이를 이용하면 배열의 길이를 연역하는 템플릿을 만들 수 있다.

1
2
3
4
5
6
7
8
template <typename T, std::size_t N>
constexpr std::size_t arrayLength(T (&)[N]) noexcept
{
return N;
}

int keyVals[] = {1, 3, 7, 9, 11, 22, 35};
std::array<int, arrayLength(keyVals)> mappedVals;

constexpr을 이용하면 함수의 결과를 컴파일 타임에 이용할 수 있다.



함수 인수

배열처럼 함수역시 포인터로 decay 된다.

1
2
3
4
5
6
7
8
9
10
template <typename T>
void f1(T param);

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

void func(int, double);

f1(func); // call f1<void (*)(int, double)>(void (*)(int, double))
f2(func); // call f2<void (int, double)>(void (&)(int, double))



Type deduction in auto

auto의 형식 연역은 템플릿에서의 형식 연역과 대부분 비슷하다. auto는 템플릿 형식 연역과정에서 T와 동일한 역할을 하며, 변수의 형식 지정자(type specifier)은 ParamType과 동일한 역할을 한다.

1
2
3
auto x = 27;          // x의 형식 지정자는 auto
const auto cx = x; // cx의 형식 지정자는 const auto
const auto& rx = x; // rx의 형식 지정자는 const auto&

형식 지정자가 ParamType과 동일한 역할을 하므로, 위 코드는 각각의 ParamType을 갖는 템플릿처럼 행동한다.

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
void func_for_x(T param);
func_for_x(27);

template <typename T>
void func_for_cx(const T param);
func_for_cx(x);

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

auto도 예외 하나를 제외하면 템플릿 형식 연역과 동일하다.



템플릿 형식 연역과 동일한 경우

템플릿 형식 연역을 세 가지 경우로 나눈것 처럼 auto의 형식 연역도 세 가지로 나뉜다.

  • 경우 1: 형식 지정자가 l-value ref인 경우
  • 경우 2: 형식 지정자가 r-value ref인 경우
  • 경우 3: 형식 지정자가 참조가 아닌경우

경우 1과 3

1
2
3
auto x = 27;        // 경우 3 auto는 int
const auto cx = x; // 경우 3 auto는 int
const auto& rx = x; // 경우 1 auto는 int

경우 2

1
2
3
4
...
auto&& rref1 = x; // x는 l-value, rref1의 형식은 int&
auto&& rref2 = cx; // cx는 l-value, rref2의 형식은 const int&
auto&& rref3 = 27; // 27은 r-value, rref3의 형식은 int&&

배열, 함수의 decay 역시 동일하게 동작한다.

1
2
3
4
5
6
7
8
9
const char name[] = "R. N. Briggs";

auto arr1 = name; // arr1의 형식은 const char*
auto& arr2 = name; // arr2의 형식은 const char (&)[13]

void foo(int, double);

auto func1 = foo; // func1의 형식은 void (*)(int, double)
auto& func2 = foo; // func2의 형식은 void (&)(int, double)



템플릿 형식 연역과 다른 경우

C++11에서는 네 가지 방법으로 27을 초기값으로 갖는 int형 변수를 선언할 수 있다.

1
2
3
4
int x1 = 27;
int x2(27);
int x3 = { 27 };
int x4{ 27 };

위 네 가지 구문의 결과는 모두 값이 27인 int형 변수가 생성되는 것으로 동일하다. 하지만 int대신 auto를 사용하면 그 결과가 다르다.


1
2
3
4
auto x1 = 27;
auto x2(27);
auto x3 = { 27 };
auto x4{ 27 };

x1, x2, x4는 모두 값이 27인 int형 변수로 동일하지만, x3는 값이 27인 원소를 하나 가지고있는 std::initilaize_list<int>형식의 변수가 된다. auto에 대한 형식 연역에는 'auto로 선언된 변수의 initializer가 ={}의 형태이면 std::initialize_list로 연역된다’라는 특수가 규칙이 존재한다. std::initilaize_list<T>는 템플릿이므로 이 과정에서 T에 대한 형식 연역이 발생한다. 따라서 모든 원소의 타입이 동일해야한다.

1
auto x5 = { 1, 2, 3.0 }; // error! std::initilaize_list<T>의 T를 연역할 수 없음

한가지 주의할 점은 중괄호를 이용한 std::initialize_list생성은 auto의 형식 연역에서만 발생한다는 점이다.

1
2
3
4
5
6
7
template <typename T>
void f(T param);

auto x = { 11, 23, 9 };
f(x); // call void<std::initialize_list<int>>(std::initialize_list<int>)

f({ 11, 23, 9 }); // error! T를 연역할 수 없음

C++14 에서는 함수의 반환 형식과 람다의 매개변수에 auto를 사용할 수 있다. 하지만 이 경우에는 auto 형식 연역 규칙이 아닌 템플릿 형식 연역 규칙이 적용된다.

1
2
3
4
auto createInitList()
{
return { 1, 2, 3 }; // error!
}
1
2
3
4
5
std::vector<int> v;
...

auto resetV = [&v](const auto& newValue) { v = newValue };
resetV({ 1, 2, 3 }); // error!

템플릿 형식 연역에서는 중괄호가 std::initialize_list로 연역되지 않으므로 위 코드는 컴파일 에러가 발생한다.



decltype

decltype은 템플릿이나 auto와 달리 주어진 이름이나 표현식의 구체적인 형식을 수정없이 그대로 말해준다.

1
2
3
4
5
6
7
8
9
const int i = 0;          // decltype(i) : const int

bool f(const Widget& w); // decltype(w) : const Widget&
// decltype(f) : bool(const Widget&)

Widget w; // decltype(w) : Widget

if(f(w)) // decltype(f(w)) : bool
...



decltype의 산출 방식

  • 인자가 식별자(이름)인 경우: 선언된 형식 그대로 산출
  • 인자가 표현식(expression)인 경우
    • 표현식이 pure r-value인 경우: 표현식 결과 형식 그대로(T) 산출
    • 표현식이 l-value인 경우: 표현식 결과 형식의 l-value reference(T&)로 산출
    • 표현식이 x-value인 경우: 표현식 결과 형식의 r-value reference(T&&)로 산출
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int i = 17;
const int ci = i;
const int& ri = i;

decltype(i) n; // decltype(i) : int
decltype(ci) n; // decltype(ci) : const int
decltype(ri) n; // decltype(ri) : const int&

decltype(i++) pr; // decltype(i++) : int
decltype(i + i) pr; // decltype(i + i) : int

decltype(++i) l; // decltype(++i) : int&
decltype((i)) l; // decltype((i)) : int&

decltype(std::move(i)) x; // decltype(std::move(i)) : int&&



decltype의 활용

C++11부터는 함수의 반환 형식을 연역할 수 있지만, 몇 가지 제약이 따른다.

1
2
3
4
5
template <typename Container, typename Index>
decltype(c[i]) Access(Container& c, Index i) // Compilation failed
{
return c[i];
}

위 코드처럼 decltype을 사용해서 함수의 반환 형식을 연역할 수 있다고 생각하기 쉽지만, decltype이 실행되는 위치에서는 아직 함수가 정의되지 않았기 때문에 ci를 이용할 수 없다.

함수 반환 형식 위치에 decltype을 사용하는 대신, auto->키워드를 사용하는 후행 반환 형식(trailing return type) 구문을 이용하면 함수의 반환 형식을 연역할 수 있다.

1
2
3
4
5
template <typename Container, typename Index>
auto Access(Container& c, Index i) -> decltype(c[i])
{
return c[i];
}

여기서 auto->이후에 함수 반환 형식을 표기하겠다는 의미일 뿐, auto를 통해서 형식 연역이 일어나거나 하지 않는다.

C++14 부터는 후행 반환 형식의 ->부분을 생략해도 된다.

1
2
3
4
5
template <typename Container, typename Index>
auto Access(Container& c, Index i)
{
return c[i];
}

이 형태에서는 C++11에서와 달리 auto에서 c[i]로 부터 형식 연역이 이루어진다. Access함수의 auto는 템플릿 형식 연역과 동일한 방식을 사용한다. 중요한건 여기서 참조가 무시된다는 점이다.

1
2
3
std::vector<int> vec;
...
Access(vec, 3) = 10; // error!

Access의 반환 형식 연역 결과가 l-value이기 때문에 함수 설계 의도와 다르게 컴파일 에러가 발생한다.


이런 문제를 해결하려면 함수의 반환 형식이 c[i]의 반환 형식과 동일해야 한다. 명시적으로 후행 반환 형식 구문을 사용한다면 c[i]의 반환 형식을 수정없이 그대로 얻을 수 있다.

1
2
3
4
5
template <typename Container, typename Index>
auto Access(Container& c, Index i) -> decltype(c[i])
{
return c[i];
}

C++14에서는 decltype(auto)지정자를 이용해서 ->부분을 생략 가능하다.

1
2
3
4
5
template <typename Container, typename Index>
decltype(auto) Access(Container& c, Index i)
{
return c[i];
}

decltype(auto)지정자는 형식 연역을 의미하는 autodecltype의 인자로 사용함으로써, '형식 연역을 하되, 그 과정에서 decltype의 규칙을 적용한다.'라는 뜻을 나타낸다. decltype(auto)지정자는 함수의 반환타입 뿐만 아니라 일반 변수를 선언할 때에도, 초기화 표현식에 decltype규칙을 적용하고 싶다면 사용할 수 있다.

1
2
3
4
5
6
7
Widget w;

const Widget& cw = w;

auto myWidget1 = cw; // myWidget1 형식은 Widget

decltype(auto) myWidget2 = cw; // myWidget2 형식은 const Widget&

마지막으로 Access함수의 컨테이너 c는 non-const 객체에 대한 l-value reference이다. 이는 함수의 반환값을 사용자가 수정할 수 있도록 하기 위해서이다. 하지만 이때문에 함수에 r-value 컨테이너 객체를 전달할 수 없다. 이를 허용하려면 c를 const 객체에 대한 l-value reference로 선언하면 되지만, 그렇게하면 함수의 반환값을 사용자가 수정할 수 없다.

우리가 원하는 형태는 다음 두 가지를 만족하는 함수이다.

1
2
3
4
auto s = Access(std::vector<int>{ 1, 2, 3, 4, 5 }, 3); // 임시 벡터의 인덱스 3에 위치한 값을 복사

std::vector<int> vec{ 10, 20, 30, 40 };
Access(vec, 3) = 400; // vec의 인덱스 3에 위치한 값을 400으로 수정

각각의 타입을 받는 버전을 갖는 템플릿 함수를 2개 작성할 수도 있지만 r-value reference를 사용하면 위에서 본 것과 같이 한 번에 처리할 수 있다.

1
2
3
4
5
6
template <typename Container, typename Index>
auto Access(Container&& c, Index i)
-> decltype(std::forward<Container>(c)[i])
{
return std::forward<Container>(c)[i];
}

마찬가지로 C++14부터는 다음과 같이 간단히 표현할 수 있다.

1
2
3
4
5
template <typename Container, typename Index>
decltype(auto) Access(Container&& c, Index i)
{
return std::forward<Container>(c)[i];
}

References

Author

Joyus.Gim

Posted on

2022-06-09

Updated on

2022-07-19

Licensed under

Comments