2017년 2월 27일 월요일

decltype에 대하여

 Effective Modern C++에는 decltype에 대한 설명이 잘 나와있다. 책을 읽기 전까지는 언제 이런 키워드가 사용되는지 궁금할 정도로 거의 쓰이지 않을 것 같았는데 이 책을 읽고나니 나름 쓰임새가 있다는 것을 알 수 있었고 그 동작에 대해서도 깊게 이해할 수 있었던 것 같다.

 decltype은 declared type의 줄임말이다. 즉 선언된 타입을 알려주는 키워드이다. 이것도 템플릿이나 auto 키워드 처럼 컴파일타임에 유효한 것이지 런타임에 동적으로 타입을 식별해서 알려주는 것은 아니니 decltype을 이용해서 런타임에 동적으로 뭔가 하려고 했다면 생각을 바꿔야할 것이다. 최근에 나오는 많은 프로그래밍 언어들이 런타임에 동적으로 타입을 체크할 수 있는 기능들을 대부분 제공하다보니 decltype에 대해서 오해할 소지가 있기에 컴파일 타임에 정적으로 타입이 결정되어 사용된다는 것을 확실히 하고 가는 것이 좋겠다.

 먼저 간단히 아래 코드의 주석을 참고하면 decltype이 어떻게 동작할 지 알 수 있을 것이다. 사실 별로 예상과 어긋나는 동작은 없다.
const int i = 0; // decltype(i)는 const int

bool f(const Widget& w); // decltype(w)는 const Widget&
                         // decltype(f)는 bool(const Widget&)
struct Point {
  int x, y;   // decltype(Point::x)는 int
};            // decltype(Point::y)는 int

Widget w;     // decltype(w)는 Widget

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

template<typename T> // std::vector의 단순화 버전
class vector {
public:
  ...
  T& operator[](std::size_t index);
  ...
};

vector<int> v; // decltype(v)는 vector<int>
...

if (v[0] == 0) ... // decltype(v[0])는 int&. 일반적으로 []연산자가 반환하는 타입은 참조형임
 이렇게 보니 decltype의 동작은 별로 예상과 다르지 않다. 게다가 별로 쓰일만한 곳도 없어 보인다. 일반적으로 컴파일러가 알아서 타입을 추론하도록 할 때는 auto 키워드가 쓰이기 때문에 decltype으로 타입을 알아내야 할 때가 있을까 싶기도 하다.

 책에서 말하는 decltype의 대표적인 쓰임새는 함수 템플릿의 반환값이 템플릿 인자에 의존하여 타입이 결정되지 않은 값을 반환해야할 때이다. 아래 예제를 보자. 이 예제는 일반적인 컨테이너의 []연산자와 같은 동작을 하는 함수를 구현한 것인데 값을 반환하기 전에 사용자 인증을 수행한다는 가정하에 구현된 함수이다.
template<typename Container, typename Index> // 완벽한 구현은 아니지만 뒤에 보완할 것임.
auto authAndAccess(Container& c, Index i)
  -> decltype(c[i])
{
  authenticateUser();
  return c[i];
}
 함수의 이름 앞에 auto가 지정되어 있다. 사실 이런 auto는 타입 추론과 아무 상관이 없으며 trailing return type 문법이 사용된다는 표시일 뿐이다. 이렇게 trailing return type 방식을 사용하면 함수의 인자들을 return 타입을 지정할 때에 이용할 수 있다는 장점이 있다. 위 예에서는 return 타입으로 decltype(c[i])를 사용함으로써 템플릿 인자 Container와 Index의 타입에 상관없이 return 타입을 지정할 수 있었다. 이런 식으로 decltype이 사용되는 것이다. 참고로 일반적인 컨테이너 c의 원소 타입이 T라면 함수의 return 타입은 T&이 된다.

 여기서 전에 올린 auto 타입 추론에서 설명했던 것처럼 C++14에서는 함수의 return 타입으로 auto를 지정하여 타입 추론이 되도록 할 수 있으므로 C++14에서는 아래와 같이 수정할 수 있다.
template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i)  // C++14. 사실 제대로된 구현은 아님.
{
  authenticateUser();
  return c[i]; // return 타입은 c[i]로부터 추론된다.
}
 이렇게 수정하면 C++14 컴파일러는 함수의 return문을 분석하여 타입 추론을 수행한다. auto 타입 추론에서 설명했듯이 이 경우 타입 추론은 템플릿 타입 추론과 같은 방식으로 이루어지며 따라서 c[i]의 referenceness가 제거되어 타입이 결정된다. 즉 c[i]의 타입은 T&지만 함수의 return 타입은 T로 추론되는 것이다. 일반적으로 []연산자는 원소의 참조형을 반환해야 하는데 위 구현은 원소의 복사본이 반환되게 된다.

 C++14에서는 이런 경우를 극복할 수 있도록 decltype(auto)를 사용하도록 하였다. 아래 예제를 보자.
template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i)  // C++14.
{
  authenticateUser();
  return c[i]; 
}
 decltype(auto)를 return타입으로 지정하면 반환되는 표현식을 그대로 decltype에 넘긴 것과 같은 타입으로 추론된다. 따라서 decltype(c[i])의 타입 그대로 참조형(T&)을 반환하게 되는 것이다. 이런 식의 타입 추론을 "decltype 타입 추론"이라 한다.

책에서는 따로 설명하지 않았지만 위 예제의 경우 항상 참조형을 반환해야 한다면 decltype(auto)대신 auto&를 지정해도 문제가 되지 않는다. 하지만 혹시 c[i]가 참조형이 아니라 비-참조형 값이라면 또 다시 문제가 발생하게 된다. 왜냐하면 Container의 []연산자가 특별히 비-참조형을 반환하도록 설계되어 있다면 반환되는 값은 c[i]의 복사본이 될 텐데 복사본의 참조형을 반환하는 건 위험한 일이기 때문이다. 역시 decltype(auto)가 참조형을 반환하던 비-참조형을 반환하던 상관없이 문제를 일으키지 않는 유일한 해결책이다.

decltype(auto)는 함수의 리턴 타입에서만 사용되는 것은 아니다. 아래 예제에서 처럼 일반 변수의 선언에서도 사용될 수 있다.
Widget w;
const Widget& cw = w;

auto myWidget1 = cw;           // auto 타입 추론:
                               // myWidget1은 Widget

decltype(auto) myWidget2 = cw; // decltype 타입 추론:
                               // myWidget2는 const Widget&
 참고로 책에서는 위의 authAndAccess() 예제가 rvalue에 대해서도 완전하게 동작하려면 아래 예와 같이 Universal 참조와 std::forward를 사용하여 구현해야 한다고 설명하지만 decltype(auto)에 추가적인 의미를 부여하는 것은 아니기 때문에 설명은 따로 하지 않겠다.
template<typename Container, typename Index> // final C++14 version
decltype(auto) authAndAccess(Container&& c, Index i)
{
  authenticateUser();
  return std::forward<Container>(c)[i];
}

 decltype(auto)에 대해 한 가지 더 주의해야 할 것이 있다. 대부분 decltype이 개발자의 예상대로 동작하지만 개발자의 예상을 종종 빗나가는 경우가 있다. 일반적인 변수의 이름에 decltype을 적용하면 실제 그 변수가 선언된 타입이 도출된다. 이 경우는 아무런 문제가 없다. 그러나 단순 이름이 아닌 "lvalue 표현식"을 decltype에 넘겼을 때 나오는 타입이 문제가 된다. 이 경우 lvalue 표현식이 평가되는 타입은 lvalue 참조형이다. 실제로 lvalue 표현식의 타입은 lvalue 참조형이 맞기 때문에 사실 잘못된 건 아니다. 하지만 아래 예를 보면 충분히 헷갈릴만 한 경우가 있음을 알 수 있다.
int x = 0;

decltype(auto) a = x;   // a는 int
decltype(auto) b = (x); // b은 int&. "(x)"는 lvalue 표현식이기 때문에

decltype(auto) f1()
{
  int x = 0;
  ...
  return x; // decltype(x)은 int, 따라서 f1은 int형 반환
}

decltype(auto) f2()
{
  int x = 0;
  ...
  return (x); // decltype((x))은 int&, 따라서 f2는 int& 반환
}
위 예제에서 보듯이 단순한 이름이 아닌 lvalue 표현식의 타입은 lvalue 참조형으로 평가되기 때문에 "(x)"가 lvalue 참조형으로 평가되는 것이다. 이것이 f1과 f2의 커다란 차이를 만들게 된다. 이미 짐작하고 있겠지만 f2는 지역 변수의 참조형을 반환하는 위험한 동작을 하는 잘못된 함수라는 것이다. 이 처럼 decltype(auto)를 사용할 때 주어지는 표현식은 주의깊게 작성해야 함을 기억하기 바란다.

 이렇게 C++11, C++14 버전에서 새롭게 추가된 타입 추론의 동작에 대해서 정리를 해보았다. 혹시 이전 내용이 궁금하다면 아래 링크를 참고하기 바란다.

  1. 템플릿 타입 추론
  2. auto 타입 추론

0 개의 댓글:

댓글 쓰기