[Effective Modern C++] 항목 3: decltype의 작동 방식을 숙지하라
decltype은 주어진 식의 구체적인 타입 그대로 전달하는(추출하는) 키워드입니다. 대부분은 예측한 형식 추출되지만 가끔씩 그렇지 않은 경우가 있습니다. decltype 과 auto에 관한 설명을 참고해주세요.
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; // decltype(Point::x)는 int int y; // 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&
C++11, 후행 반환 형식(trailing return type)
decltype은 함수의 반환 형식(auto)이 그 함수의 매개변수 형식에(Container& c) 의존하는 템플릿을 선언할때 주로 사용합니다.
대체로, 형식 T의 객체를 담은 operator[] 연산은 T&를 돌려줍니다. 그러나 std::vector<bool>에 대한 operator[]는 bool&가 아니라 완전히 새로운 객체를 돌려줍니다. 그 이후는 추후 항목 6에서 설명합니다.
decltype을 이용하면 이런 함수의 반환 형식을 손쉽게 표현할 수 있습니다.
다음은 다듬어야할 부분이 있지만 decltype을 이용해 반환 형식을 표현한 예입니다.
// C++11 예1 // 작동하지만 좀 더 다듬을 필요가 있습니다. template<typename Container, typename Index> auto authAndAccess(Container& c, Index i) -> decltype(c[i]) { authenticateUser(); return c[i]; }
함수 이름 앞에 auto를 지정한 것은 형식 연역과는 관계 없고 C++의 후행 반환 형식(trailing return type) 구문입니다. 이러한 후행 반환 형식 구문에는 반환 형식을 매개변수들을 이용해서 지정할 수 있다는 장점이 있습니다. 예를 들어 아래 코드와 같이 decltype없이 auto로 반환 형식을 지정하면 C++11에서는 컴파일 오류가 발생합니다. c와 i는 반환 형식으로 사용할 수 없습니다.(둘다 아직 선언되지 않았으므로)
// C++11 예2 template<typename Container, typename Index> auto authAndAccess(Container& c, Index i) // auto 사용 불가, 컴파일 오류 발생 { authenticateUser(); return c[i]; }
C++14, 반환 형식 연역 범위 확장
C++11에서는 람다 함수가 한 문장으로 이루어져 있다면 그 반환 형식의 연역을 허용하며, C++14에서는 허용 범위를 더욱 확장해서 모든 람다와 모든 함수의 반환 형식 연역을 허용합니다. 따라서, authAndAccess 함수의 경우 C++14에서는 후행 반환 형식을 생략하고 그냥 함수 이름 앞에 auto만 남겨 두어도 됩니다. 즉, C++14에서는 C++11 예2는 정상동작합니다.
// C++14 예1 template<typename Container, typename Index> auto authAndAccess(Container& c, Index i) { authenticateUser(); return c[i]; // 반환 형식은 c[i]로 부터 연역됩니다. } // c[i]는 int&를 돌려주며, 반환 형식은 auto 형식 연역으로 int를 반환
항목 2에서 설명했듯이, 함수의 반환 형식에 auto가 지정되어 있으면 컴파일러는 템플릿 형식 연역을 적용합니다. 지금은 이것이 문제가 됩니다. 규칙대로라면 T객체를 담은 operator[] 연산은 T&를 돌려줍니다. 항목 1에서 설명했듯이 템플릿 형식 연역 과정에서 초기화 표ㅗ현식의 참조성이 무시됩니다.
std:deque<int> d; ... authAndAccess(d,5) = 10; // d[5]를 돌려주고, 10을 d[5]에 대입합니다. // 이 코드는 컴파일 되지 않습니다.
여기서 d[5]는 int&를 돌려주나, autoAndAccess에 대한 auto 반환 형식 연역과정에서 참조가 제거되기 때문에 결국 반환 형식은 int가 됩니다. 함수의 반환값으로서의 int는 rvalue이며, 결과적으로 rvalue에 10을 대입하려 해서 컴파일 오류가 발생합니다.
C++14, decltype(auto) 추가
C++수호자들은 C++14부터 decltype(auto)를 사용할 수 있도록하여 우리가 원하는 대로 동작 할 수 있도록 하였습니다.
// C++14 예2 template<typename Container, typename Index> decltype(auto) authAndAccess(Container& c, Index i) { authenticateUser(); return c[i]; }
이제 autoAndAccess의 반환 형식은 실제의 c[i]의 반환 형식과 일치합니다. 😄
decltype(auto)를 함수 반환 형식에만 사용할 수 있는 것은 아닙니다. 변수를 선언할 때에도, 초기화 표현식에 decltype 형식 연역 규칙들을 적용하고 싶을 경우라면 이 지정자가 유용합니다.
Widget w; const Widget& cw = w; auto myWidget1 = cw; // auto 형식 연역 : myWidget1의 형식은 Widget decltype(auto) myWidget2 = cw; // decltype 형식 연역 : myWidget2의 형식은 const Widget&
Universal Reference 사용으로 개선
autoAndAccess함수의 C++14 버전 선언이며 좀더 다듬을 필요가 있습니다.
template<typename Container, typename Index> decltype(auto) authAndAccess(Container& c, Index i);
Container& c는 비const 객체에 대한 lvalue 참조로서 함수에 전달됩니다. 이는 함수가 돌려준 컨테이너 요소를 수정할 수 있게하기 위함입니다. 문제는, 이 때문에 rvalue 컨테이너는 전달 할 수 없습니다. rvalue를 lvalue 참조에 묶을 수 없습니다.
솔직히 autoAndAccess에 rvalue 컨테이너를 넘기는 것은 극단적인 경우입니다. 임시 객체로서 오른값 컨테이너는 일반적으로 autoAndAccess호출을 담은 문장의 끝에서 파괴되며, 따라서 생성하는 문장의 끝에서 지칭 대상을 잃어버립니다.
그렇긴 하지만, autoAndAccess에 임시 객체를 넘겨줄 수 있게 만드는 일은 여전히 합당합니다. 다음의 예처럼 그냥 임시 컨테이너의 한 요소의 복사본을 만들고 싶을 수도 있기 때문입니다.
std::deque<std::string> makeStringDeque(); // 팩토리 함수 // makeStringDeque가 돌려준 deque의 다섯 번째 원소의 복사본을 생성합니다. auto s = authAndAccess(makeStringDeque(), 5);
이런 용법을 지원하려면 autoAndAccess가 lvalue 뿐만아니라 rvalue도 받아들이도록 선언을 고쳐야 합니다. lvalue 받는 함수와 rvalue를 받는 함수 각각을 만드는 편법이 있지만 이는 우리가 원하는 방식이 아닙니다. 이를 할 수 있는 것이 바로 Universal Reference입니다.
template<typename Container, typename Index> decltype(auto) authAndAccess(Container&& c, Index i); // Container&& : Universal Reference
std::forward 사용으로 개선
지금 시점에서 이 템플릿이 다루는 컨테이너의 형식과 그 컨테이너의 한 요소에 점근하는데 쓰이는 색인 객체의 형식은 알 수 없습니다. 미지의 형식의 객체에 대한 값 전달 방식을 적용하면 불필요한 복사 때문에 성능이 저하되거나, 객체가 잘려서(항목 41참고) 프로그램의 동작에 문제가 생기거나, 또는 동료의 비웃음을 살 위험이 있습니다. 이러한 위험을 피하기 위해 항목 25의 조언에 따라 Universal reference에 std::forward를 적용하도록 합니다.
// C++14 최종 버젼 template<typename Container, typename Index> decltype(auto) authAndAccess(Container&& c, Index i) { authenticateUser(); return std::forward<Container>(c)[i]; }
C++14 컴파일러를 사용할 수 없는 경우 C++11에서의 구현은 다음과 같습니다.
// C++11 최종 버젼 template<typename Container, typename Index> auto authAndAccess(Container&& c, Index i) -> decltype(std::forward<Container>(c)[i]) { authenticateUser(); return std::forward<Container>(c)[i]; }
decltype 주의 깊게 사용하자. 특히, decltype(auto)
decltype이 아주 가끔 예상 밖의 결과를 제공합니다. 여기서는 중요한 한 가지 예외 상황을 예를 들어 설명하겠습니다. decltype에 변수의 이름을 적용하면 그 이름의 선언 형식이 추출됩니다.
int x = 0; decltype(x) y; // int 입니다.
decltype에 lvalue 표현식을 적용하면 lvalue 참조가 추출됩니다.
int x = 1; decltype((x)) d; // (a)는 lvalue 표현식이며 int&로 추론하여 추출
decltype(x)는 int, decltype((x))는 int&를 추출합니다. 이는 C++11에서 드물게 만나는 신기한 현상 정도입니다. 그렇지만 C++14에서는 decltype(auto)를 지원하기에 return문 작성 습관의 사소한 차이 때문에 함수의 반환 형식 연역 결과가 달라지는 사태가 벌어질 수 있습니다.
decltype(auto) f1() { int x = 0; retrun x; // decltype(x)는 int이므로 f1은 int를 반환 } decltype(auto) f2() { int x = 0; return (x); // decltype((x))는 int&이므로 f2는 int&를 반환 }
f2가 f1과는 다른 형식을 반환할 뿐만 아니라, 자신의 지역 변수에 대한 참조를 돌려줍니다. 😓
이 예에서 주는 교훈은, decltype(auto)는 아주 조심해서 사용해야 한다는 것입니다.
decltype은 그 명칭에 걸맞게 행동합니다. 즉, 주어진 이름의 선언된 형식(declared type)을 돌려줍니다.
기억해 둘 사항들
- decltype은 항상 변수나 표현식의 형식을 아무 수정 없이 보고한다.
- decltype은 형식이 T이고 이름이 아닌 왼값 표현식에 대해서 항상 T& 형식을 보고한다.
- C++14는 decltype(auto)를 지원한다. decltype(auto)는 auto처럼 초기치로 부터 형식을 연역하지만, 그 형식 연역 과정에서 decltype의 규칙들을 적용한다.