[Effective Modern C++] 항목 5, 6: auto의 장점, 주의할 점
Modern C++에서 auto는 편리하면서 중요하게 알아둬야 할 feature이다.
auto 사용에 있어서 장점을 먼저 살펴보자.
auto의 장점
우선 간단한 장점 하나는, 타입 추론을 해야 하기 때문에 uninitialized 될 위험을 없앤다는 것이다.(안 그러면 컴파일러 오류를 발생시키므로!)
int x;
auto x; // error!
다음의 장점은, closure 등에 대해서 매우 간단하게 표현을 가능하게 해준다는 것이다. 예를 들어서 다음과 같은 lambda 표현식을 정의할 수 있다. (lambda 식 안의 parameter를 auto로 선언 가능한 건 C++14부터 가능하다.)
auto derefLess = [](const auto& p1, const auto& p2)
{ return *p1 < *p2; }; // C++14
p1, p2에 대해서 dereference를 한 뒤 대소를 비교하는 간단한 함수인데, 이것을 auto를 쓰지 않고 쓴다면..
std::function<bool(const std::unique_ptr<Widget>&, const std::unique_ptr<Widget>&)>
derefUPLess = [](const std::unique_ptr<Widget>& p1, const std::unique_ptr<Widget>& p2)
{ return *p1 < *p2; };
이와 같이 무시무시한 lambda 표현식이 탄생한다.
그래도 쓸 순 있는게 어디야?라고 생각할 수 있지만, 뒤의 std::function을 이용한 식은 심지어 auto로 표현한 식보다 메모리도 더 많이 잡아먹을 수 있고, 속도도 느릴 수 있다고 한다. 왜냐하면 auto를 이용하여 표현하면 딱 필요한 만큼만 메모리를 잡아먹는데 비해, std::function은 함수의 signature와 관계없이 정해진 메모리를 잡아먹고, 이는 비효율성을 야기시킬 수 있다는 얘기이기 때문이다. 또한 여러 내부 구현 등에 의해 std::function을 사용하면 거의 auto 표현식보다 느리다고 한다.
간단하고, 메모리도 효율적이고, 속도도 빠르니 auto를 안 쓸 이유가 없다!
또 다른 장점은, 타입을 정확하게 추론하기 때문에 예기치 못한 에러를 미연에 방지할 수 있다. 예를 들어서,
std::vector<int> v;
unsigned sz1 = v.size();
auto sz2 = v.size(); // std::vector<int>::size_type
위 코드를 보자. 보통 std::vector<int>의 size 함수의 return type이 std::vector<int>::size_type이란걸 신경 쓰면서 코딩을 하진 않는다. 실제로 보통의 경우 unsigned로 받아도 큰 문제는 없을 수 있지만, 64비트 windows 운영체제에서 unsigned는 4byte인데 반해 std::vector<int>::size_type은 8byte이다. 이는 언젠가 큰 버그를 야기시킬 수 있는데, 분명 잡아내기 쉽지 않을 것이다!
std::unordered_map<std::string, int> m;
for (const std::pair<std::string, int>& p : m)
{
… // do something with p
}
또, 위 코드를 보자. 언뜻 보기에 문제없는 코드 같지만, 위 코드는 비효율적으로 돌아갈 수 있다. 왜냐하면 unordered_map의 key 부분은 const이기 때문에, for 문을 돌 때 std::pair<const std::string, int>로 해야 맞기 때문이다. 물론 저렇게 해도 돌아갈 테지만, temporary object를 생성하고 파괴되는 비효율적인 동작을 하게 된다. unordered_map의 key가 const임을 놓치면 비효율적인 코드를 쓸 수 있는 것이다. 따라서 다음과 같이 쓰자.
for (const auto& p : m)
{
… // as before
}
이렇게 했을 때의 또다른 장점은, p의 주소가 실제 m의 element의 주소를 나타낸다는 것이다. 만약 std::pair<std::string, int>로 받았으면 temporary object의 주소를 가리켰을 것이고, 그것은 파괴될 객체의 주소이기 때문에 undefined behavior를 야기시킬 수 있을 것이다.
여기까지 auto의 장점을 알아봤다. 하지만 auto를 사용할 때 주의해야할 점을 알아두는 게 좋다.
auto 사용 시 주의할 점: invisible proxy class
auto가 타입을 너무 정확하게 추론해서 오히려 주의해야 한다!
std::vector<bool> features(const Widget& w);
Widget w;
…
bool highPriority = features(w)[5]; // is w high priority?
…
processWidget(w, highPriority);
위와 같은 코드를 보자. features는 Widget을 받아서 Widget의 특성을 std::vector<bool>로 return 하는 함수이다. return값의 index 5가 Widget의 priority를 나타낸다고 생각하자.
그런데 위 코드에서 highPriority를 auto로 받으면 어떻게 될까?
auto highPriority = features(w)[5];
processWidget(w, highPriority) // undefined behavior!!!
놀랍게도 processWidget은 undefined behavior를 나타내게 된다. highPriority가 더 이상 priority를 나타내지 않을 수 있기 때문이다!
이는 std::vector<bool>의 operator[]가 vector 원소의 reference를 return하지 않기 때문이다. std::vector<bool>의 operator[]는 std::vector<bool>::reference를 반환한다. (이런 이상한 동작은 std::vector에서는 <bool>에 대해서만 그렇게 동작한다. 다른 모든 타입에 대해서는 그대로 reference를 반환한다. 이유는 원래 bool은 1byte를 잡아먹는데, vector가 bool에 대해서 효율적으로 동작하게 하기 위해 1bit씩 저장하게 만들었기 때문이다!)
하지만 일반 사용자 입장에서 이것을 알아둘 필요는 없었다. 왜냐하면 bool로 받았다면 implicit conversion이 일어났을 것이고, 우리는 그냥 그대로 쓰면 된다. 그리고 이것이 라이브러리 제작자가 의도한 바일 것이다.
하지만 우리의 auto는 너무 똑똑한 나머지, features(w)[5]의 타입을 std::vector<bool>::reference로 그대로 추론을 하고 만다. 그렇다면 highPriority가 갖는 값은 무엇이 될까? std::vector<bool>::reference의 구현 방식에 따라 달라질 수 있는데, 예를 들어 reference bit의 pointer를 가질 수 있다. 그런데 features(w)는 rvalue이므로 바로 파괴될 것이고, 따라서 파괴된 객체의 pointer를 가리키므로, dangling pointer가 돼버릴 수 있다!
auto 하나 썼다고 dangling pointer 문제를 맞닦뜨리게 될 줄은 몰랐을 것이다..
이러한 일이 발생한 이유는 std::vector<bool>::reference가 proxy class이기 때문이다. proxy class에 대해서는 나중에 다시 알아보도록 하자. unique_ptr이나 shared_ptr도 proxy class의 일종이라고 한다.
문제는 std::vector<bool>::reference가 invisible proxy, 즉 사용자가 모르는 걸 전제로 만들어진 proxy class라는 것이다. 이 class는 애초에 conversion이 일어날 것이라고 가정하고 설계된 class이기 때문에, lifetime이 한 줄보다 길어지면 undefined behavior를 나타낼 수 있다. 그런데 auto가 그 타입을 정확하게 추론해 버려서 문제가 되는 것이다.
Matrix sum = m1 + m2 + m3 + m4;
위와 같은 코드에서도 비슷한 문제가 발생할 수 있다. Matrix의 + operator가 Matrix를 반환하는게 아니라 Sum<Matrix, Matrix> 와 같은 proxy class를 반환하면 더 효율적일 수 있는데, 저걸 Matrix가 아니라 auto로 받아버리면 비슷한 문제가 생길 수 있다.
기억해야 할 것은 invisible proxy class는 auto와 상성이 안 좋다는 것이다!
아무튼 저자는 이것을 해결하기 위해 explicitly typed initializer idiom을 사용하라고 한다. auto를 쓰되, static_cast와 같이 type을 explicit하게 명시하라는 것이다.
auto highPriority = static_cast<bool>(features(w)[5]);
auto sum = static_cast<Matrix>(m1 + m2 + m3 + m4);
이렇게 명시적인 type casting을 나타냄으로써 위의 invisible proxy class의 문제를 해결할 수 있다!
요약
- auto는 편리하고, 타입을 정확하게 추론하며, 효율적이며 빠르게 동작하게 도와줄 수 있는 등 여러 장점을 가지고 있다.
- invisible proxy일 때는 explicit하게 casting해서 써야 한다.