2017년 2월 21일 화요일

Template Type Deduction (템플릿 타입 추론)

 수많은 C++관련 도서가 있고 그 수 만큼 많은 저자들이 있지만 그 중에 내가 인상깊게 본 도서들을 꼽자면 Effective 시리즈를 꼽을 수 있겠다. 전부 다 본건 아니지만 그래도 Effective 시리즈 작가인 Scott Meyers의 책을 볼 기회가 생긴다면 기꺼이 시간을 내서 볼 용의가 있다.

 최근에 보게된 Effective Modern C++도 상당히 인상적이다. C++11부터 크게 발전해가는 C++을 제대로 사용해 보려고 여기 저기 인터넷을 뒤져가며 새로운 문법을 익히다보니 머리속이 복잡해지는 걸 느끼고 있던 참에 여러가지 복잡하게 얽힌 생각들을 대부분 깔끔하게 정리할 수 있도록 도와준 책이었다.

 그래서 정리된 것들을 내 머리속에 오래 유지할 수 있도록 몇 번에 걸쳐 포스팅을 해볼까 한다. 그 첫 번째로 C++이 수행하는 타입 추론(Type Deduction)에 관해서 정리해보자.

 C++11이 나오기 전에는 템플릿(Template)말고는 타입 추론이 수행되는 경우가 없었지만 C++11에 auto와 decltype이 추가되면서 두 가지가 더 늘었다. 조금 정확히 얘기하자면 타입 추론은 C++ 컴파일러가 개발자가 지정하지 않은 타입을 주변의 다른 코드를 이용해서 결정하는 작업이다. 따라서 컴파일러가 개발자의 의도대로 움직여주지 않으면 엉뚱한 결과를 초래하는 경우도 종종 발생한다. 사실 컴파일러가 오동작하는 건 아니고 개발자가 컴파일러의 규칙을 제대로 알지 못하기 때문에 생기는 문제이며 그 때문에 그 규칙을 여기에 정리하려고 하는 것이다.

 우선 이번 글에서는 템플릿의 타입 추론에 대해서 얘기해 보고자한다.

 일반적인 함수 템플릿은 아래와 같은 형식으로 선언될 것이다.
template<typename T>
void f(ParamType param);
 여기서 ParamType은 'const T&'같은 T를 이용하는 타입이 될 것이다.

 그리고 이 함수를 호출할 때는 아래와 같은 형태로 호출될 것이다.
f(expr);
 이 때 컴파일러는 expr의 타입에 기초하여 ParamType과 T의 타입추론을 수행한다.

ParamType이 다음의 3가지 경우에 대해서 다르게 동작하므로 개발자는 이 규칙을 정확히 이해하고 있어야 타입 추론이 엉뚱하게 되는 경우를 피할 수 있을 것이다.
  1. ParamType이 Universal 참조가 아닌 참조형(Reference)인 경우 (T&)
  2. ParamType이 Universal 참조형인 경우 (T&&)
  3. ParamType이 참조형이 아닌 경우 (T)
차례대로 하나 씩 살펴보자.

1. ParamType이 Universal 참조가 아닌 참조형(T& 또는 const T&)인 경우
 이 경우가 가장 간단한 경우로 책에서는
  1) expr의 타입에서 참조를 제거(있다면)하고
  2) 그 타입을 ParamType과 패턴 매칭시켜서 T의 타입을 결정(추론)
하면 된다.

 패턴 매칭이라는 용어를 썼지만 패턴 매칭이 어떻게 이루어지는 지에 대한 설명이 없다. 내가 아는 일반적인 패턴 매칭으로는 사실 아래 예제의 모든 경우를 만족시키지 않는다.  주석을 보면 무엇이 매칭되는 대상들인지 적어놓았으므로 자세히 보기 바란다.

template<typename T>
void f(T& param); // 여기서 param은 참조형

int x = 27;        // x의 타입은 int
const int cx = x;  // cx의 타입은 const int
const int& rx = x; // rx의 타입은 const int&

f(x);  // int와 T& 매칭,       T는 int,       param은 int&
f(cx); // const int와 T& 매칭, T는 const int, param은 const int&
f(rx); // const int와 T& 매칭, T는 const int, param은 const int&
template<typename T>
void f(const T& param);

int x = 27;        // x의 타입은 int
const int cx = x;  // cx의 타입은 const int
const int& rx = x; // rx의 타입은 const int&

f(x);  // int와 const T& 매칭,       T는 int, param은 const int&
f(cx); // const int와 const T& 매칭, T는 int, param은 const int&
f(rx); // const int와 const T& 매칭, T는 int, param은 const int&
 여기서 패턴 매칭이라 함은 참조 제거된 expr의 타입을 ParamType과 매칭 시키는 데 적절히 매칭된 부분을 제외하고 남아있는 타입을 T에 매칭하는 방식인가 보다.  두 번째 예제의 마지막 함수 호출 f(rx)를 예로 들면 아래와 같이 const는 매칭되므로 제거하고 남은 건 int이므로 T는 int가 된다. 즉 매칭된 건 제거하고 남은 것을 T에 매칭하면 되는 것이다.

참조 제거된 expr : const int
    ParamType : const T   &

 패턴 매칭이라는 용어때문에 좀 찜찜한 느낌을 지울 수 없다. 저자가 매칭 규칙을 정확히 설명을 해주었다면 좋았을 것 같은데 그렇지 않다. 누구나 알 수 있을 것이라 생각했는지 따로 설명을 하지 않고 있다.

 그리고 책에서는 포인터형에 대해서도 같은 방식으로 타입 추론이 일어난다고 되어있지만 추후에 인터넷에 공개된 도서의 정오표를 통해서 포인터형은 사실 뒤에 나올 3번의 경우에 속한다고 수정하였다. 내가 보기에도 맞는 말이다. 따라서 여기서는 언급하지 않도록 하겠다.

2. ParamType이 Universal 참조형(T&&)인 경우
 이 규칙은 C++11에 rvalue 참조형이 추가되면서 템플릿 타입 추론에도 영향을 미치게된 경우이다. 아래 예제와 같이 ParamType은 rvalue 참조형 같이 생겼지만 실제 전달되는 expr의 타입에 따라 다음과 같은 타입 추론이 수행된다.
  • expr의 타입이 lvalue이면 T와 ParamType은 lvalue 참조형으로 추론된다.
  • expr의 타입이 rvalue이면 1번과 같은 방식으로 추론된다.
template<typename T>
void f(T&& param); // 여기서 param은 Universal 참조형

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

f(x);  // x는 lvalue,  따라서 T는 int&,       param은 int&
f(cx); // cx는 lvalue, 따라서 T는 const int&, param은 const int&
f(rx); // cx는 lvalue, 따라서 T는 const int&, param은 const int&
f(27); // 27은 rvalue, 따라서 T는 int,        param은 int&&
 lvalue의 경우는 그냥 모두 lvalue 참조형으로 추론되므로 추가 설명이 필요 없을 것 같고 rvalue의 경우에는 1번 규칙처럼 상수 27의 타입 int와 T&&가 패턴매칭해서 T는 int가 되고 param은 int&&가 된 것이다.

3. ParamType이 참조형이 아닌 경우(T)
 이 경우에는 인자가 그냥 pass-by-value 방식으로 전달되는 것과 같다. 즉 param의 타입은 expr의 복사본의 타입이 되는 것이다. 다음 예제를 보도록 하자
template<typename T>
void f(T param); // 여기서 param은 비-참조형

int x = 27;        // x는 int
const int cx = x;  // cx는 const int
const int& rx = x; // rx는 const int&
const char* const ptr = "Fun with pointers"; // ptr은 const char* const

f(x);   // T와 param의 타입은 모두 int
f(cx);  // T와 param의 타입은 모두 int
f(rx);  // T와 param의 타입은 모두 int
f(ptr); // T와 param의 타입은 모두 const char*
 위 예제에서 "const int형인 cx를 넘겼는데 왜 T가 const int가 아니고 int일까?"라고 의문을 제기할 수 있다. 바로 전에 말한 것과 같이 ParamType이 참조형이 아닌 경우에는 값의 복사본이 전달되므로 지정된 expr의 타입이 참조형이던 const형이던 상관없이 그 복사본이 전달되고 그 복사본은 수정되지 못할 이유가 없으므로 원래 붙어있던 const성질은 사라지게 되는 것이다. 마찬가지로 volatile 같은 성질들도 모두 사라지게 된다.

 다만 ptr의 경우에 const가 그대로 있는 것이 이상해 보일 수 있지만 f(ptr)에서 복사되는 건 ptr그 자체이지 ptr이 가리키는 객체가 복사되는 게 아니기 때문에 그 객체의 const성질은 그대로 유지되어야 하고 ptr 자신의 const성질만 사라지는 게 맞다. 따라서 ptr이 가리키는 객체의 const인 왼쪽의 const는 그대로 남고 ptr자신의 const는 사라져야 하기 때문에 오른쪽의 const만 사라지게 되는 것이다.

 위의 3가지 경우 외에 생각해 보아야 할 예외 케이스가 2가지가 있다. 바로 함수 인자로 배열이 전달될 때와 함수가 전달되는 경우이다. 이런 경우를 실제 앞으로 만나게 될지는 모르겠지만 정리하는 김에 같이 해보도록 하겠다.

* 배열을 인자로 넘겼을 때
 배열을 템플릿 함수에 인자로 넘기면 타입 추론은 어떻게 될까? 아래 예제를 보자.
template<typename T>
void f(T param); // 여기서 param은 비-참조형

const char arr[] = "Array";

f(arr); // arr은 const char[6]이지만 T는 const char*
 ParamType이 비-참조형일 때는 const char[6]을 const char*로 바꿔서 타입 추론을 수행한다. 하지만 아래 예제의 경우는 좀 다르다.
template<typename T>
void f(T& param); // 여기서 param은 참조형

const char arr[] = "Array";

f(arr); // arr은 const char[6], T는 const char[6], param은 const char(&)[6]
 ParamType이 참조형일 때는 배열을 포인터로 바꾸지 않고 배열 그대로 받아들인다.

* 함수를 인자로 넘겼을 때
 크게 쓸 일은 없지만 함수를 템플릿 함수에 인자로 넘겼을 때도 배열과 비슷한 방식의 동작이 수행된다는 것도 알아두자.
void someFunc(int, double); // 함수의 타입은 void(int, double)

template<typename T>
void f1(T param); // 여기서 param은 비-참조형

template<typename T>
void f2(T& param); // 여기서 param은 참조형

f1(someFunc); // T는 void(*)(int, double, param은 void(*)(int, double)
f2(someFunc); // T는 void(int, double),   param은 void(&)(int, double)
 ParamType이 참조형일 때만 배열에서 처럼 함수타입이 유지되고 비-참조형인 경우에는 함수 포인터로 바꿔서 타입 추론을 수행한다.

댓글 1개: