[Effective Modern C++] 5. 명시적 타입 선언보다는 auto를 선호하자
아래처럼 auto는 반드시 초기화가 필요하다.
int x1; // 문맥에 따라서는 초기화되지 않을 수 있음
auto x2; // 오류! 초기치가 꼭 필요함
auto x3 = 0; // 양호함: x3의 값이 잘 정의됨
여기서 반복자의 역참조를 통해 초기화되는 지역 변수 하나를 간단하게 선언한 아래의 코드를 보자.
// b에서 e까지의 구간에 있는 모든 요소에 대해 dwim("do what I mean") 알고리즘을 수행한다.
template <typename It>
void dwim(It b, It e)
{
for (; b != e; ++b)
{
typename std::iterator_traits<It>::value_type
currValue = *b;
...
}
}
// auto를 사용하면 훨씬 간단하게(적은 타이핑으로) 선언할 수 있다.
template <typename It>
void dwim(It b, It e)
{
for (; b != e; ++b)
{
auto currValue = *b;
...
}
}
그리고 auto는 타입 추론을 사용하므로(항목 2 참고), 예전에는 컴파일러만 알던 타입을 지정할 수 있다. 또한, C++14에서는 람다 표현식의 매개변수에도 auto를 적용할 수 있어 더욱 편리해졌다.
// std::unique_ptr들이 가리키는 Widget 객체들을 비교하는 함수
auto derefUPLess = [](const std::unique_ptr<Widget>& p1, const std::unique_ptr<Widget>& p2) { return *p1 < *p2; }
// C++14 버전: 그 어떤 것이든 포인터처럼 작동하는 것들이 가리키는 값들을 비교하는 함수
auto derefUPLess = [](const auto& p1, const auto& p2) { return *p1 < *p2; }
하지만, 클로저를 담는 변수를 선언할 때 굳이 auto를 사용하지 않고 std::function 객체를 사용하면 되지 않을까?라고 생각할 수 있지만 std::function을 사용하게 되면 원하는 결과와 다른 결과가 나올 수도 있다.
std::function은 C++ 표준 라이브러리의 한 템플릿으로, 함수 포인터 개념을 일반화한 것이다. 결과적으로 std::function은 함수뿐만 아니라, 호출 가능한 객체면 그 어떤 것이라도 가리킬 수 있는데, 이것이 지칭할 타입을 반드시 지정해야 한다.
아래의 예시를 봐보자.
// C++ 버전 std::unique_ptr<Widget> 비교 함수의 시그니처
bool(const std::unique_ptr<Widget>&, const std::unique_ptr<Widget>&)
// 그러면 다음과 같은 코드가 필요하다.
std::function<bool(const std::unique_ptr<Widget>&, const std::unique_ptr<Widget>&)> func;
// 람다 표현식이 산출하는 클로저는 호출 가능 객체이므로, std::function 객체에 저장할 수 있다.
// 따라서 C++11 버전의 derefUPLess를 다음처럼 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; }
auto를 사용하는 것보다 훨씬 길고 매개변수 타입들을 반복해서 지정해야 하는 것보다 더 중요한 차이는, auto로 선언된 클로저를 담는 변수는 그 클로저와 같은 타입이며 따라서 그 클로저에 요구되는 만큼의 메모리만 사용하지만, std::function으로 선언된 변수의 타입은 std::function템플릿의 한 인스턴스이며 그 크기는 임의의 주어진 시그니처에 대해 고정되어 있다는 점이다. 만약 이 크기가 요구된 클로저보다 작은 경우 std::function은 힙 메모리를 할당해서 클로저를 저장한다.
결과적으로, std::function객체는 auto로 선언된 객체보다 메모리를 더 많이 소비한다.
그리고 인라인화를 제한하고 간접 함수 호출을 산출하는 구현 세부사항 때문에, std::function 객체를 통해서 클로저를 호출하는 것은 거의 항상 auto로 선언된 객체를 통해 호출하는 것보다 느리다.
std::bind 호출의 결과를 담기 위한 auto와 std::function의 경쟁 역시 auto가 승리한다.
그리고 auto를 사용하는 또 다른 장점은, 타입 단축(type shortcut)이라고 부르는 것과 관련된 문제를 피할 수 있다는 것이다.
아래의 예시를 봐보자.
std::vector<int> v;
...
unsigned sz = v.size();
// v.size()의 공식적인 반환 타입은 std::vector<int>::size_type이다.
// 이 타입은 부호 없는 정수타입으로 지정되므로, 많은 프로그래머는 그냥 unsigned로 충분하다고 생각하고 위와 같은 코드를 작성한다.
// 그런데, 32비트 Windows에서 unsigned와 std::vector<int>::size_type은 같은 크기이지만, 64비트 Windows에서는 unsigned는 32비트인 반면 std::vector<int>::size_type은 64비트이다.
// 이는 32비트 Windows 환경에서는 잘 작동하는 코드가 64비트 Windows 환경에서는 잘 작동하지 않을 수도 있음을 의미하지만, auto를 사용하면 이런 문제를 피할 수 있다.
auto sz = v.size(); // sz의 타입은 std::vector<int>::size_type
// 예시 2
std::unordered_map<std::string, int> m;
...
for (const std::pair<std::string, int>& p : m)
{
... // p로 뭔가를 수행
}
// std::unordered_map(해시 테이블)의 키 부분이 const라는 점을 기억한다면 무엇이 문제인지 감이 올 것이다.
// 해시 테이블에 담긴 std::pair의 타입은 std::pair<std::string, int>가 아니라 std::pair<const std::string, int>이다.
// 그런데 이는 루프 위의 변수 p에 대해 선언된 타입과는 다르다.
// 그래서 컴파일러는 std::pair<const std::string, int> 객체들을 std::pair<std::string, int> 객체로 변환하려 든다.
// 루프의 각 반복에서, 컴파일러는 p를 묶고자 하는 타입의 임시 객체를 생성하고, m의 각 객체를 복사하고, 참조 p를 그 임시 객체에 묶음으로써 그러한 변환을 실제로 수행한다.
// 임시 객체가 생성되고 복사가 일어나므로 효율이 떨어지는 것은 물론이고, p의 주소를 구하면 임시 객체의 주소를 얻을 수 있을 뿐이다.
// 반면 다음과 같이 auto를 사용하면,
for (const auto& p : m)
{
... // 이전과 동일
}
// 더 쉽고 효율적이며, p의 주소를 구하면 실제로 m 안의 한 요소를 가리키는 포인터를 얻게 된다.
auto 타입들은 초기화 표현식의 타입이 변하면 자동으로 변한다. 이는 리펙터링이 어느 정도 수월해질 수 있다는 것을 의미한다. 하지만, 명시적 타입 선언을 사용하는 것이 더 깔끔하거나 유지 보수하기 쉬운, 또는 다른 어떤 방식으로 더 나은 코드로 이어진다는 결론을 내렸다면 명시적 타입 선언을 사용하면 된다.
결론적으로, auto는 필수가 아니라 선택이다.
람다 표현식 vs 클로저
스콧 마이어(Scott Meyers)는 "람다와 클로저의 차이는 클래스와 클래스 인스턴스 사이의 차이점과 동일하다"라는 좋은 예를 들었다. 클래스는 오직 소스 코드에만 존재하며 런타임에 존재하지 않는다. 클래스 인스턴스만이 런타임에 존재한다. 즉, C++에서는 람다 표현식이 인스턴스화 된 것을 클로저라 한다.
요약
- auto 변수는 반드시 초기화해야 하며, 이식성 또는 효율성 문제를 유발할 수 있는 타입 불일치가 발생하는 경우가 거의 없으며, 대체로 변수의 타입을 명시적으로 지정할 때보다 타자량도 더 적다.
- auto로 타입을 지정한 변수는 항목 2와 항목 6에서 설명한 문제점들을 겪을 수 있다.
'Books > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] 7. 객체 생성 시 괄호(())와 중괄호({})를 구분하자 (0) | 2022.08.28 |
---|---|
[Effective Modern C++] 6. auto가 원치 않은 타입으로 추론 될 때에는 명시적 타입의 초기화를 생각하자 (0) | 2022.08.27 |
[Effective Modern C++] 4. 추론된 타입을 파악하는 방법을 알아두자 (0) | 2022.08.06 |
[Effective Modern C++] 3. decltype의 작동 방식을 숙지하자 (0) | 2022.08.06 |
[Effective Modern C++] 2. auto의 타입 추론 규칙을 숙지하자 (0) | 2022.08.06 |
댓글
이 글 공유하기
다른 글
-
[Effective Modern C++] 7. 객체 생성 시 괄호(())와 중괄호({})를 구분하자
[Effective Modern C++] 7. 객체 생성 시 괄호(())와 중괄호({})를 구분하자
2022.08.28 -
[Effective Modern C++] 6. auto가 원치 않은 타입으로 추론 될 때에는 명시적 타입의 초기화를 생각하자
[Effective Modern C++] 6. auto가 원치 않은 타입으로 추론 될 때에는 명시적 타입의 초기화를 생각하자
2022.08.27 -
[Effective Modern C++] 4. 추론된 타입을 파악하는 방법을 알아두자
[Effective Modern C++] 4. 추론된 타입을 파악하는 방법을 알아두자
2022.08.06 -
[Effective Modern C++] 3. decltype의 작동 방식을 숙지하자
[Effective Modern C++] 3. decltype의 작동 방식을 숙지하자
2022.08.06