std::vector<bool> features(const Widget& w) { ... }
...
auto highPriority = features(w)[5]; // std::vector<bool>::operator[](5)
/* 둘은 같은 코드일까? */
bool highPriority = feature(w)[5];

정답부터 얘기하면 둘의 타입은 다르다.

vector<bool>은 유일하게 템플릿 특수화에 의해 만들어지는 컨테이너이다. 또한 컨테이너의 요소를 바이트가 아닌 비트로 관리하고 있다.

그런데 C++에서 비트에 대한 참조는 금지되어있다는 사실을 안다면 vector<bool>::operator[]는 bool& 타입을 반환하지 못한다는 사실 역시 알 수 있다.

대신 마치 bool&처럼 작동하는 객체를 돌려주는 우회법을 사용한다.

 

vector<bool>::operator[]는 bool&이 아닌 vector<bool>::reference 타입을 반환해준다. 그리고 해당 타입은 실제 bool 타입에 대한 암시적 변환이 가능하게 해준다.

 

auto highPriority1 = features(w)[5]; // highPriority1 = std::vector<bool>::reference
bool highPriority2 = feature(w)[5]; // std::vector<bool>::reference의 bool로 암시적 변환

auto highPriority의 타입은 std::vector<bool>::reference가 되고 bool highPriority의 타입은 std::vector<bool>::reference의 암시적 변환 허용에 의해 정상적인 값이 대입된다. 물론 반환받은 값은 실제 vector의 요소가 아닌 임시로 생성된 bool 객체이다.

 

std::vector<bool>::reference는 스마트 포인터와 같이 프록시 패턴이 적용된 프록시 클래스의 일종이다.

operator[]가 마치 비트에 대한 참조를 돌려주는 듯한 환상을 제공해주는 것이다. 그런데 스마트 포인터와는 조금 다르게 캡슐화가 더 잘 되어있을 뿐이다.

 

 

Matrix sum = m1 + m2 + m3 + m4;

프록시 클래스는 표현식 템플릿 기법을 사용하는 라이브러리에서도 흔히 사용된다.

Matrix::operator+의 결과로 Matrix를 반환하는게 아니라 Sum<Matrix, Matrix>같은 프록시 클래스의 객체를 돌려주도록 하면 계산이 조금 더 효율적이다.

그런데 이걸 auto로 선언한다면 Sum<Sum<Sum<Matrix, Matrix>, Matrix>, Matrix> 같은 타입이 되기 때문에 사용자에게 보여줄 필요가 없다.

그래서 대체로 위와 같은 캡슐화가 잘된(보이지 않는) 프록시 클래스는 auto와 그다지 잘 맞지 않는다.

 

그러면 대체 어떻게하면 보이지 않는 프록시 클래스를 사용하고 있는지 파악할 수 있을까?

답은 의외로 간단하다. 타입 추론을 우리가 의도하는 타입으로 강제하는 것이다.

 

auto highPriority = static_cast<bool>(features(w)[5]);

auto sum = static_cast<Matrix>(m1 + m2 + m3 + m4);

auto ep = static_cast<float>(calcEpsilon()); // 본래 반환값은 double

일부러 타입을 변환한다는 의도도 명확히 드러낼 수 있다.

 

◾ "보이지 않는" 프록시 타입 때문에 auto가 초기화 표현식의 타입을 "잘못" 추론 할 수 있다.

◾ 타입 명시 초기치 관용구는 auto가 원하는 타입을 추론하도록 강제한다.

int x; // 초기화가 되지 않았다

typename std::iterator_traits<It>::value_type currValue = *b; // 코드가 너무 길다

/* closure type */ func = [](){ ...; }; // 명시적 타입 지정 불가

auto를 사용하지 않으면 위와같은 문제를 마주칠 수 있다.

 

auto x; // 컴파일 오류
auto currValue = *b; // 코드가 간결해졌다
auto func = [](){ ...; }; // 클로저의 타입 추론이 가능해졌다

그런데 auto를 사용하는것 만으로 위의 문제가 모조리 해결이 된다. auto는 초기값에 의해 타입 추론이 이루어지기 때문에 초기화가 반드시 필요하다.

 

여기서 한가지, 클로저의 타입은 auto가 아니라 std::function으로도 가능한게 아닌가?

물론 가능하다.

 

auto func = [](const std::unique_ptr<Widget>& p1, const std::unique_ptr<Widget>& p2) { return *p1 < *p2; };
/* 심지어 이건 C++14부터 매개변수마저 auto를 쓸수 있다 */
/* auto func = [](const auto& p1, const auto& p2) { return *p1 < *p2; }; */

std::function<bool(const std::unique_ptr<Widget>&, const std::unique_ptr<Widget>&)> func =
    [](const std::unique_ptr<Widget>& p1, const std::unique_ptr<Widget>& p2) { return *p1 < *p2; };
    /* 현기증이 난다 */

function의 템플릿 매개변수에 일일이 다 써넣을 자신이 있다면 말이다.

또한 auto로 선언된 클로저를 담는 변수는 해당 클로저에 딱 맞는 메모리 크기만 사용하지만, std::function은 최소 크기가 고정되어있다. 심지어 클로저의 크기가 커서 다 못담는다면 힙 메모리를 할당해서 클로저를 저장하게 된다.

게다가 인라이닝도 제한되고 구현의 세부사항 때문에 auto타입의 클로저보다 느리다.

 

auto는 초기화 누락 방지, 코드 간결화, 클로저 타입 추론뿐만 아니라 타입 단축과 관련된 문제도 피할 수 있다.

 

std::vector<int> v;
...
unsigned int sz = v.size();

문제없어 보이지만 사실 size의 공식적인 반환 타입은 std::vector<int>::size_type이다.

size_type이 size_t로 되어있고, size_t는 unsigned long long으로 재정의 되어있을 뿐이다. (64비트 VS2022 기준)

물론 unsigned long long은 unsigned int로 암시적 변환이 허용되기 때문에 실행 자체는 문제가 없을 뿐이다.

애초에 이런 문제는 auto 선언이면 만사해결이다.

 

std::unordered_map<std::string, int> m;
...
for (const std::pair<std::string, int>& p : m) {
    &p; // 임시 객체의 요소를 가리키는 포인터
}

이 코드는 어떨까. 문제가 없을까?

당연히 문제가 있다. 저장된 키값은 const이기 때문에 std::string이 아니라 const std::string이어야 한다.

분명히 pair를 참조자로 선언했음에도 const 객체를 비const 객체로 어떻게든 변환시키기 위해서 임시 객체를 생성하게 되고, 그 객체를 참조하게 된다. 원래 의도했던 참조자의 의미가 없어지는 순간이다. 생성된 임시 객체는 루프 끝에서 파괴되기 때문에 루프만큼 복사-파괴가 일어나는 문제가 발생한다.

 

std::unordered_map<std::string, int> m;
...
for (const auto& p : m) {
    &p; // m의 요소를 가리키는 포인터
}

이 역시 auto 선언이면 골치 아플일이 없다.

 

auto가 마치 만병통치약인 것처럼 썼지만 결국 초기값의 타입으로부터 추론되는 것이기 때문에 초기값의 타입이 기대하지 않은 타입이거나 바람직하지 않은 타입일수도 있다. 그런 부분은 주의해서 사용하고 마지막으로 언급할 내용은 코드 가독성에 대한 얘기이다.

 

auto는 필수가 아닌 선택이다. 위에서 언급한 문제점들에 해당하더라도 명시적 선언이 더 깔끔하거나 유지보수에 용이하다면 명시적 선언을 사용하면 된다. 그래도 대체적으로는 auto 선언이 좋을것이다.

 

◾ auto 변수는 반드시 초기화해야 하며, 이식성 또는 효율성 문제를 유발할 수 있는 타입 불일치가 발생하는 경우가 거의 없으며, 대체로 변수의 타입을 명시적으로 지정할 때보다 타자량도 더 적다.

◾ auto로 타입을 지정한 변수는 항목 2와 항목 6에서 설명한 문제점들을 겪을 수 있다.

IDE 편집기

객체 위에 커서를 올리면 타입을 표시해 주는 경우가 많다.

이게 가능하려면 코드가 어느 정도는 컴파일이 가능한 상태여야 한다. 그리고 간단한 타입의 경우라면 쓸만하겠지만 좀 더 복잡한 타입이 관여하게 되면 그다지 도움이 되지 않을 수 있다.

 

컴파일러의 진단 메시지

원하는 타입으로 문제를 발생시켜서 오류 메시지를 확인한다.

 

template<typename T>
class TD; // 선언만 한다
...
TD<decltype(x)> xType; // x = int
TD<decltype(y)> yType; // y = const int*

클래스의 정의가 존재하지 않기 때문에 컴파일 오류가 발생하고 친절하게 타입을 알려준다.

 

실행시점 출력

타입 정보를 출력해서 표시하는 방법은 런타임에만 사용할 수 있지만 출력의 서식을 완벽하게 제어할 수 있다는 장점이 있다.

 

std::cout << typeid(x).name() << '\n'; // int
std::cout << typeid(y).name() << '\n'; // const int*

위의 경우만 봤을때는 완벽한 방법으로 보인다. 객체에 typeid를 적용하면 새로운 객체(std::type_info)가 생성되는 것인데, name이 항상 의미있는 결과를 반환해준다는 보장은 없다. 아래의 예를 보자.

 

template<typename T> // Case1. T = const Widget*
void f(const T& param) // Case1. param = const Widget* const&
{
    typeid(T).name(); // Case3. const Widget*
    typeid(param).name(); // Case3. const Widget*. param의 const와 참조성이 떨어져나갔다
}
...
std::vector<Widget> createVec(); // 팩토리 함수
const auto vw = createVec();

if (!vw.empty()) f(&vw[0]); // &vw[0] = const Widget*

템플릿 추론 규칙에 의하면 T는 const Widget*, param은 const Widget* const& 타입이어야 한다.

그런데 type_info::name은 둘다 const Widget*으로 나오게 된다. 왜냐면 type_info::name은 값의 의한 전달이 이루어진것처럼 취급해야 하기 때문이다. 그래서 포인터의 참조성과 상수성이 모두 사라진 const Widget*으로 바뀌어버린다.

실제로 f로 전달된 타입의 추론은 틀리지 않는다.

 

#include <boost/type_index.hpp>

template<typename T>
void f(const T& param)
{
    using boost::typeindex::type_id_with_cvr; // cvr: const, volatile, reference
    
    type_id_with_cvr<T>().pretty_name();
    type_id_with_cvr<decltype(param)>().pretty_name();
}

typeid대신 부스트 라이브러리를 사용하면 전달된 인수의 참조 한정사들을 그대로 보존하기 때문에 정확한 결과를 알려준다.

그래도 항목 1~3의 규칙들을 모두 숙지해서 파악하는것만큼 좋은게 없다.

 

◾ 컴파일러가 추론하는 타입을 IDE 편집기나 컴파일러 오류 메시지, Boost TypeIndex 라이브러리를 이용해서 파악할 수 있는 경우가 많다.

◾ 일부 도구의 결과는 유용하지도 않고 정확하지도 않을 수 있으므로, C++의 타입 추론 규칙들을 제대로 이해하는 것은 여전히 필요한 일이다.

템플릿과 auto의 타입 추론에서 일어나는 일과 달리 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
std::vector<int> v; // decltype(v) = vector<int>
if(v[0] == 0) ... // decltype(v[0]) = int&

전부 다 직관적이고 당연한 타입이다.

 

decltype은 함수의 반환 타입이 매개변수 타입들에 의존하는 함수 템플릿을 선언할 때 주로 사용된다.

이게 무슨 소리인고 하니, 예를 들어서 컨테이너와 인덱스를 매개변수로 받아서 사용자 인증 후에 컨테이너의 operator[]를 호출해서 특정 요소를 반환해주는 함수 템플릿을 작성한다고 해보자.

 

template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i) -> decltype(c[i])
{
    authenticateUser();
    return c[i];
}

대체로 컨테이너들의 operator[]는 T&를 반환하지만 vector<bool>에 대해서만은 예외이다. (Effective STL 항목 18 참고)

아무튼 컨테이너에 따라 반환 타입이 다를수도 있다는 점이다.

 

위와 같이 후행 반환 타입 구문이 쓰인 함수에서 auto는 반환값의 타입 추론과 아무런 연관이 없다. 그저 반환 타입을 매개변수 목록 다음에 선언하겠다는 점을 나타낼 뿐이다. 단, C++14는 후행 반환 타입을 생략하더라도 타입 추론이 된다.

그런데 함수의 반환 타입이 auto라면(C++14부터 가능) 템플릿 타입 추론 규칙을 따른다는것을 항목 2 마지막에 언급한것을 기억하는가?

 

template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i)
{
    authenticateUser();
    return c[i];
}

위에서 언급한대로 컨테이너의 operator[]는 대체로 T& 타입을 반환해준다.

그런데 템플릿 타입 추론 규칙이 적용되면 참조성이 무시되므로 반환 타입은 T가 되어버린다.

 

std::deque<int> d;
...
authAndAccess(d, 5) = 10; // 오른값 = 오른값

왼값에 오른값을 대입하는것을 기대했지만 오른값이 반환되기 때문에 컴파일이 되지 않는다.

그럼 어떻게 해야하는가? 후행 반환 타입을 꼭 써주어야 하나? 아니다. 방법이 있다.

 

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i)
{
    authenticateUser();
    return c[i];
}

신기한 문법같아 보이지만 하나씩 따져보면 어렵지 않다.

반환 타입이 auto라는것은 타입을 추론해야 한다는 것을 의미하고, 그것을 decltype으로 감쌌다는것은 타입 추론 과정에서 decltype 타입 추론 규칙들이 적용되어야 한다는 뜻이다. 단, C++14 이상에서 작동한다. C++11은 후행 반환 타입을 꼭 명시해야 한다.

c[i]의 타입은 T&이기 때문에 decltype 타입 추론 규칙에 따르면 T&가 되어서 후행 반환 타입을 명시하지 않아도 된다.

특히나 vector<bool>같이 operator[]의 반환 타입이 T&가 아닌 경우에도 같은 타입의 객체를 반환해준다.

 

decltype(auto)를 함수 반환 타입에만 사용할 수 있는것은 아니다.

 

Widget w;
const Widget& cw = w;
auto myWidget1 = cw; // Case3. myWidget1 = Widget
decltype(auto) myWidget2 = cw; // myWidget2 = const Widget&

myWidget1은 템플릿 타입 추론 Case3를 따라서 참조성과 const가 무시되어 값의 복사가 이루어졌다.

하지만 myWidget2는 decltype 타입 추론 규칙에 의해 cw의 타입으로 추론된다.

 

 

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i)

마무리하기 전에 함수의 매개변수를 살펴볼 필요가 있다. c의 타입은 비const 객체에 대한 왼값 참조이다.

그런데 만약 c의 인수로 오른값이 오게되면 문제가 발생할 수 있다.

그래서 매개변수가 왼값 뿐만 아니라 오른값도 받을 수 있게 선언을 수정해야 한다. 오버로딩을 사용해도 되지만 관리해야 하는 함수가 늘어나기 때문에 좋은 선택이 아니다.

 

해결 방법은 항목 1의 Case2에서 잠시 언급되었던 보편 참조이다.

 

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container&& c, Index i) // Case2
/* C++11, auto authAndAccess(Container&& c, Index i) -> decltype(std::forward<Container>(c)[i]) */
{
    authenticateUser();
    return std::forward<Container>(c)[i]; // 전달받은 c의 참조 타입을 그대로 반환
}

 

마지막으로 decltype이 제공하는 몇가지 특별한 경우가 있다. 그 중 한가지만 언급한다.

 

int x = 0;

decltype(x); // x = int
decltype((x)); // (x) = int&

 

x를 괄호로 한번 감쌌을 뿐인데 타입이 다르다. decltype을 이름에 적용하면 그 이름에 대해 선언된 타입이 산출되지만, 이름보다 복잡한 왼값 표현식에 대해서는 일반적으로 항상 왼값 참조를 보고한다.

(x)는 이름보다 복잡한 표현식이라고 판단하는 것이다.

 

decltype(auto) f1()
{
    ...
    return x; // int
}

decltype(auto) f2()
{
    ...
    return (x); // int&
}

C++11에서는 드물게 만나는 신기한 현상 정도로 치부할 수 있지만 C++14에서는 return문 작성 습관때문에 결과 자체가 달라질 수도 있다는 것을 주의해야 한다.

 

◾ decltype은 항상 변수나 표현식의 타입을 아무 수정 없이 보고한다.

◾ decltype은 타입이 T이고 이름이 아닌 왼값 표현식에 대해서는 항상 T& 타입을 보고한다.

◾ C++14는 decltype(auto)를 지원한다. decltype(auto)는 auto처럼 초기화 값으로 타입을 추론하지만, 그 타입 추론 과정에서 decltype의 규칙들을 적용한다.

한 가지 기이한 예외를 빼면 auto의 타입 추론 규칙은 항목 1의 규칙과 동일하다.

템플릿 타입 추론에서 expr의 표현식을 통해 T와 ParamType의 타입을 추론했었다.

auto를 이용해서 변수를 선언하면 auto는 T, 변수의 타입 지정자는 ParamType과 동일한 역할을 한다.

 

auto x = 27; // 타입 지정자 = auto
const auto cx = x; // 타입 지정자 = const auto
const auto& rx = x; // 타입 지정자 = const auto&

x, cx, rx의 타입을 추론할 때, 컴파일러는 마치 선언마다 템플릿 함수 하나와 해당 초기화 표현식으로 해당 템플릿 함수를 호출하는 구문이 존재하는 것처럼 행동한다.

 

auto x = 27; // Case3. x = int
const auto cx = x; // Case3. x = const int
const auto& rx = x; // Case1. x = const int&

auto&& uref1 = x; // Case2. x = 왼값. uref1 = int&
auto&& uref2 = cx; // Case2. cx = 왼값. uref2 = const int&
auto&& uref3 = 27; // Case2. 27 = 오른값. uref3 = int&&

const char name[] = "R. N. Briggs"; // const char[13]
auto arr1 = name; // Case4. arr1 = const char*
auto& arr2 = name; // Case4. arr2 = const char(&)[13]

void someFunc(int, double);
auto func1 = someFunc; // Case5. func1 = void(*)(int, double)
auto& func2 = someFunc; // Case5. func2 = void(&)(int, double)

항목 1에서 템플릿 타입 추론을 ParamType의 특성에 따라 일반적인 경우 3가지 및 추가 2가지로 구분했었는데, auto도 동일하게 구분할 수 있다.

 

그렇다면 대체 한가지 기이한 예외가 무엇일까?

 

int x1 = 27;
int x2(27);
int x3 = {27}; // C++11
int x4{27}; // C++11

int를 초기화 하는 방법은 총 4가지가 있고 표현식이 다를지언정 모두 동일한 초기화 과정을 거치고 동일한 타입을 가지게 된다.

 

auto x1 = 27;
auto x2(27);
auto x3 = { 27 }; // C++11
auto x4{ 27 }; // C++11

그런데 int를 auto로 바꿨더니 결과가 다르게 나온다. 그 이유는 C++11부터 추가된 초기화리스트 때문이다.

초기화리스트 얘기가 나온김에 한가지 더 언급하자면 auto와 중괄호 초기화를 같이 사용하면 타입 추론이 두번 일어나게 된다.

 

auto x5 = { 1, 2, 3.0 }; // error. initializer_list<T>의 T를 추론할 수 없음

첫번째 타입 추론은 x5에 대한 타입 추론이다. 중괄호 초기화를 사용했으므로 initializer_list<T>로 추론이 되는데 이것 역시 템플릿이기 때문에 T에 대한 추론이 필요해진다.

두번째 타입 추론인 T는 중괄호 초기화의 구성 값 타입을 봐야하는데, 타입이 한 종류가 아니라서 추론이 불가능해진다.

그래서 오류가 발생한다.

 

/* auto */
auto x = { 11, 23, 9 }; // x = std::initializer_list<int>

/* template */
template<typename T>
void f(T param);

f({ 11, 23, 9}); // error

auto 타입 추론과 템플릿 타입 추론은 중괄호 초기화가 관여할 때에만 차이를 보인다.

auto는 중괄호 초기화가 initializer_list<T> 타입이라고 추론하지만 템플릿은 그렇지 않다.

 

template<typename T>
void f(std::initializer_list<T> param);

f({ 11, 23, 9}); // T = int, param = std::initializer_list<int>

param의 타입이 initializer_list라면 문제 없다.

 

 

auto createInitList() { return { 1, 2, 3}; } // error. 타입 추론 불가능

std::vector<int> v;
...
auto resetV = [&v](const auto& newValue) { v = newValue; };
resetV({ 1, 2, 3 }); // error. 타입 추론 불가능

추가로 C++14부터 함수의 반환 타입이나 람다의 매개변수에 auto를 사용할 수 있다. 하지만 타입 추론 방식이 auto가 아닌 템플릿 타입 추론 방식을 따르게 된다.

 

◾ auto 타입 추론은 대체로 템플릿 타입 추론과 같지만, auto 타입 추론은 중괄호 초기화가 std::initializer_list를 나타낸다고 가정하는 반면 템플릿 타입 추론은 그렇지 않다는 차이가 있다.

◾ 함수의 반환 타입이나 람다 매개변수에 쓰인 auto에 대해서는 auto 타입 추론이 아니라 템플릿 타입 추론이 적용된다.

+ Recent posts