Scott Meyers의 Effective Modern C++은 모던 C++, 즉 C++11 이후에 등장한 C++의 새로운 기능들을 잘 사용하는 팁들을 담은 책이다. 책에 나온 팁들을 글로 정리해본다. 참고로 한글판에 나오는 번역어들은 좀 더 널리 사용되는 용어들로 바꾸어 사용하였다.
Chapter 1. 타입 추론
01. 템플릿 타입 추론 규칙을 숙지하라
이 항목에서는 함수는 템플릿, 즉 제너릭으로 정의된 함수가 파라미터의 타입을 어떻게 추론하는지를 자세히 살펴본다. 이것이 중요한 이유는 모던 C++의 핵심 기능 중 하나인, 다름아닌 auto
가 템플릿과 같은 방식으로 타입을 추론하기 때문이다.
다음과 같이 정의된 템플릿이 있다고 하자.
template<typename T>
void f(ParamType param);
f(expr); // 특정 타입을 가지는 expr이라는 변수를 대입해 호출
여기서 ParamType
의 자리에는 T
자신이 들어갈 수도 있지만, const T
나 T&
, const T&
, 심지어는 보편참조(universal reference, a.k.a. forwarding reference) T&&
가 들어갈 수도 있을 것이다. 모던 C++에서는 이렇듯 T
에서 파생되어 나오는 다양한 타입들이 ParamType
의 자리에 들어갔을 때 어떻게 타입을 추론하는지 규칙이 정해져 있는데, 세 가지 경우가 있다.
ParamType
이 보편참조 이외의 참조 타입이거나 포인터 타입일 경우
ParamType
이 보편참조 T&&
일 경우
ParamType
이 참조도 포인터도 아닐 경우
각각의 경우를 살펴보자.
-
ParamType
이 보편참조 이외의 참조 타입이거나 포인터 타입일 경우
- 함수에 들어온 표현식
expr
에서 참조 부분(&
)은 무시된다.
- 그 다음
expr
의 타입을 ParamType
에 대해 pattern-matching 방식으로 대응시켜서 T의 타입을 알아낸다
예시는 다음과 같다.
int x1 = 3;
const int x2 = 5;
const int& x3 = x2;
template<typename T>
void f(const T& param); // ParamType 자리에 const T&
f(x1); // T는 int로 매칭되고, param은 const int & 타입이 된다.
f(x2); // T는 int로 매칭되고, param은 const int & 타입이 된다.
f(x3); // T는 int로 매칭되고, param은 const int & 타입이 된다.
template<typename T>
void g(T& param); // ParamType 자리에 T&
g(x1); // T는 int로 매칭되고, param은 int & 타입이 된다.
g(x2); // T는 const int로 매칭되고, param은 const int & 타입이 된다.
g(x3); // T는 const int로 매칭되고, param은 const int & 타입이 된다.
-
ParamType
이 보편참조 T&&
인 경우
expr
이 좌측값(lvalue)인 경우 T
와 param
모두 좌측값 참조자로 타입이 추론된다. 이는 특히 T
자체가 좌측값 참조자가 되는 유일한 상황인데, 보편참조가 우측값 참조자와 똑같이 생겼다는 점을 고려하면 이는 특이하다.
expr
이 우측값(rvalue)인 경우 경우 1에 대한 규칙들이 비슷하게 적용된다.
예시는 다음과 같다.
int x1 = 3;
const int x2 = 5;
const int& x3 = x2;
template<typename T>
void f(T&& param); // ParamType 자리에 T&&
f(x1); // x1이 좌측값이니 T는 int&로 매칭되고, param도 int& 타입이 된다.
f(x2); // x2가 좌측값이니 T는 const int&로 매칭되고, param은 const int& 타입이 된다.
f(x3); // x3가 좌측값이니 T는 const int&로 매칭되고, param은 const int & 타입이 된다.
f(42); // 27이 우측값이니 T는 int로 매칭되고, param은 int&& 타입이 된다.
-
ParamType
이 참조도 포인터도 아닌 경우
- 파라미터가 함수에 값으로 전달(pass-by-value)되는 상황이라고 할 수 있다. 이 경우는 참조(
&
)와 const
성이 모두 무시된다.
int x1 = 3;
const int x2 = 5;
const int& x3 = x2;
template<typename T>
void f(T param); // ParamType 자리에 T
f(x1); // T는 int로 매칭되고, param도 int 타입이 된다.
f(x2); // T는 int로 매칭되고, param도 int 타입이 된다. (const가 무시)
f(x3); // T는 int로 매칭되고, param도 int 타입이 된다. (const와 &가 모두 무시)
배열 타입의 템플릿 추론
한편 템플릿의 타입 추론 과정에서, 배열 타입은 포인터 타입으로 자동 붕괴한다. 배열 타입의 매개변수라는 것은 없기 때문이다. 예를 들어 다음과 같다.
void f(int arr[]); // void f(int *arr)과 동일하다.
따라서 템플릿 함수에서도 배열이 매개변수로 들어왔을 때는 포인터로 추론될 것이다. 그러나 책에서 제안하는 한 가지 교묘한 요령이 있는데, 바로 매개변수 타입을 배열을 가리키는 참조자로 선언하는 것이다.
const char string[] ="Effective Modern C++";
template<typename T>
void f(T& param);
f(name); // 앞서 말한 1번 케이스에 해당. T는 const char[21]으로,
// param의 타입은 const char(&)[21]으로 추론된다
이렇게 배열의 참조 타입을 매칭하는 것을 이용해, 다음과 같이 해당 배열의 길이를 리턴하는 함수를 만들 수도 있다.
template <typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept {
return N;
}
02. auto
의 타입 추론 규칙을 숙지하라
앞 절에서 언급했듯이, auto
는 템플릿의 타입 추론 규칙을 거의 똑같이 사용한다. 다음 코드를 보자.
const auto& x1 = 42; // 경우 1(보편참조가 아닌 참조 or 포인터)에 의해 auto는 int로,
// x1의 타입은 const int&로 추론
auto&& x2 = x1; // 경우 2(보편참조)에 해당. x1은 왼값 const int&이므로,
// x2의 타입은 const int&로 추론
auto&& x3 = 42; // 경우 2이나 오른값. 따라서 우측값 참조자 int&&로 추론
auto x4 = 42; // 경우 3(참조도 포인터도 아닌 경우)에 의해 x1은 int로 추론
유일한 차이점은 중괄호 초기화(또는 균일 초기화)에서 발생한다. auto
는 중괄호 초기화 구문을 std::initializer_list
로 간주하지만, 템플릿은 그 대신 타입추론을 거부하고 컴파일에 실패한다는 점이다.
auto arr = {1, 2, 3, 4}; // arr의 타입은 std::initializer_list<int>로 추론됨
template<typename T>
void f(T param);
f({1, 2, 3, 4}); // std::initializer_list<int>로 추론되지 않고 타입 추론에 실패함
03. decltype
의 작동 방식을 숙지하라
decltype
(declared type)은 주어진 이름이나 표현식의 형식을 알려주는 키워드이다. 대부분의 상황에서는 그 작동방식을 몰라도 될만큼 직관적으로 동작한다. 하지만 decltype
을 취했을 때 뜻밖의 결과가 나오는 경우도 있다. 이 항목에서는 그러한 경우 decltype
의 행동 방식을 살펴본다.
선언된 타입을 알려주는 decltype
우선 decltype
의 기본 사용법을 알아보자. 다음과 같은 대부분의 사용례에서는 decltype
이 예상대로 동작한다. 즉, decltype
을 이름에 대해 사용할 때에는 선언된 타입을 그대로 알려준다.
const int i = 0; // decltype(i)는 const int
bool f(const Widget& w); // decltype(f)는 bool(const Widget&)
struct Point{ // decltype(Point::x)는 int
int x, y;
};
Widget w; // decltype(w)는 Widget
한편, decltype
은 이러한 경우 외에도 제너릭 라이브러리를 작성할 때, 함수의 반환 타입이 매개변수들에 의존하도록 하기 위해 많이 사용된다. 예를 들어 다음과 같이 함수의 반환 타입을 매개변수들을 사용해 쉽게 표현할 수 있는데, 이를 후행 반환 타입(trailing return type)이라고 한다.
// Container c에서 i번째 원소를 접근한다
template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i) -> decltype(c[i]) {
authenticateUser();
return c[i];
}
특히 C++14에서는 이러한 후행 반환 타입까지도 생략해 다음과 같이 쓸 수 있다.
template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i) {
authenticateUser();
return c[i];
}
그러나 이 경우 문제가 생긴다. 다음의 코드를 보자.
std::deque<int> d;
authAndAccess(d, 5) = 10; // 컴파일 에러
위와 같은 경우, std::deque<int>
에 정의된 operator []
는 결과로 int&
를 반환한다. 따라서 대입 연산이 가능해야 할 것처럼 보이지만, auto
가 타입 추론을 하는 과정에서 항목 01에서 살펴본 바와 같이 참조자가 무시되기 때문에 이 함수는 int
를 반환하게 되고, 대입 연산이 불가능해지는 것이다. 이를 해결하기 위해서는 auto
대신 decltype(auto)
를 사용하면 된다.
decltype(auto)
: decltype
의 규칙을 사용해 타입을 추론하자
template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i) {
authenticateUser();
return c[i];
}
함수 반환 타입을 decltype(auto)
로 지정하면 auto
처럼 타입을 추론해야 한다는 것을 컴파일러에게 알리게 되지만, 이 과정에서 auto
의 규칙들이 아닌 decltype
의 규칙을 이용하라고 알려준다. 따라서 함수에 등장하는 return문들의 타입을 auto
가 아닌 decltype
을 통해 확인하고, 이들이 모두 일치하는 경우에 함수의 반환 타입을 이들과 같도록 결정하는 것이다. 즉 decltype(auto)
는auto
와 비슷하지만, auto
의 비직관적인 규칙들을 모두 배제하고 해당 이름의 선언된 타입을 그대로 알려준다고 생각하면 된다!
Widget w;
const Widget &cw = w; // cw의 타입은 const Widget&
auto myWidget1 = cw; // myWidget1의 타입은 Widget (&와 const를 제거)
decltype(auto) myWidget2 = cw; // myWidget2의 타입은 cw 그대로 const Widget&
위와 같이 함수의 반환 타입을 결정할 때뿐만 아니라 변수를 선언할 때에도 decltype(auto)
를 이용할 수 있다.
decltype
의 특이한 행동
decltype
을 이름에 대해 사용할 때에는 항상 그 선언된 타입을 그대로 알려준다. 그러나 표현식에 대해서 사용할 경우에는 그렇지 않을 수 있다. 이름이 아닌, 타입 T인 좌측값 표현식에 대해서 decltype
을 사용하면 T&
가 결과로 나오는 것이다. 예를 들어 int x = 0;
으로 선언되었을 때 decltype(x)
는 int
를 보고하지만, decltype((x))
는 int&
를 보고하게 된다. 따라서 decltype
을 사용할 때에는 이러한 측면에 유의하여야 한다.
04. 추론된 타입을 파악하는 방법을 알아두라
이 항목은 직접적으로 코딩을 하는 방법이라기보다는, 디버깅을 하기 위한 방법에 가깝다. 위에서 살펴봤다시피 auto
나 decltype
이 결정해주는 타입 추론은 비직관적일 때도 있으니, 이들을 확인할 수 있는 방법들을 알아놓으라는 것이다. 크게 네 가지가 나온다.
template<typename T> class TD;
와 같이 더미 클래스를 하나 만들어놓고, 타입을 확인하고 싶을 때는
TD<decltype(x)> xType;
TD<decltype(y)> yType;
와 같이 코드를 작성한다. 그러면 TD가 제대로 정의되어 있지 않으므로
error: aggregate ‘TD<int> xType’ has incomplete type and cannot be defined
error: aggregate ‘TD<const int *> yType’ has incomplete type and cannot be defined
와 같이 에러 메시지가 변수 x
, y
의 타입을 알려주게 된다.
#include <boost/type_index.hpp>
template<typename T>
void printType(const T& param){
using std::cout;
using boost::typeindex::type_id_with_cvr;
cout << "T = "
<< type_id_with_cvr<T>().pretty_name() << "\n";
cout << "param = "
<< type_id_with_cvr<decltype(param)>().pretty_name() << "\n";
}
위의 함수를 사용하면 아래와 같이 T
에 매칭된 타입과 param
으로 전달되는 타입을 정확히 출력해준다.
std::vector<Widget> createVec();
const auto vw = createVec();
printType(&vw[0]); // 위에서 정의한 printType을 호출
// 결과
T = Widget const*
param = class Widget const * const &
Chapter 2. auto
05. 명시적 타입 선언보다는 auto
를 선호하라
auto
는 개념적으로는 매우 간단하고 편리한 기능이지만, 앞 장에서 살펴봤듯이 생각보다는 미묘한 구석이 있다. 그럼에도 저자는 명시적으로 타입을 선언하지 말고, 최대한 auto
를 써서 코드를 작성하라고 권고하고 있다.
auto
사용시 반드시 초기화를 해야 하기 때문에, 초기화를 까먹지 않게 된다.
- 함수 포인터의 복잡한 타입을 대체할 수 있다.
std::function
을 쓸 수도 있지만, 이는 추가적인 자원을 차지하게 된다.
- 다른 실행 환경으로의 이식성이 더 좋고, 리팩터링도 더 쉬워진다. 예를 들어 함수의 반환 타입을
int
에서 long
으로 바꾸고 싶을 때, auto
를 사용하지 않았다면 해당 함수의 반환값을 활용하는 변수들의 타입을 전부 long
으로 바꿔줘야 한다.
06. auto
가 원치 않은 타입으로 추론될 때에는 명시적 타입의 초기치를 사용하라
여러 라이브러리들을 사용해 개발을 하다보면, 대리자 클래스(proxy class) 등이 방해가 되는 경우가 많다. 대표적인 예시로 std::vector<bool>
이 있다.
std::vector<bool> w = {true, false, true, true};
auto bit = w[3];
직관적으로 생각하면 위의 코드에서 bit
는 (당연히) bool&
의 타입을 가져야 할 것이다. 그러나 실제로는 그렇지 않고 std::vector<bool>::reference
타입을 가진다. std::vector
는 효율성을 위해 bool
일 때에는 특별한 구현을 하기 때문이다. 이와 같이 다른 어떤 타입의 행동을 흉내내는 것이 목적인 클래스를 대리자 클래스라고 한다. 이렇듯 대리자 클래스가 있는 경우에는 auto
를 사용한 타입 추론에 방해가 된다.
하지만 저자는 이와 같은 경우에도 auto
를 버리지 말라고 말고 static_cast
를 이용하는 해결책을 제시한다. 위와 같은 경우에는 다음과 같이 쓸 수 있다.
auto bit = static_cast<bool>(w[3]);
이렇게 하면 w[3]
의 타입이 bool
로 강제로 캐스팅되면서, auto
는 의도한대로 bool
타입을 가질 수 있게 된다. 이와 같은 패턴을 타입을 명시적으로 지정한 초기치 관용구(explicitly typed initializer idiom)라고 한다.
Chapter 3. 모던 C++에 적응하기
07. 객체 생성 시 괄호(()
)와 중괄호({}
)를 구분하라
C++11부터는 객체의 생성을 위한 구문이 매우 다양해졌다. 새로운 객체를 생성할 때, 괄호, 등호, 그리고 중괄호가 모두 가능하다.
int x(0);
int y = 0;
int z{ 0 };
int w = { 0 }; // int w{ 0 };과 똑같이 처리됨
균일 초기화의 장점
그런데 이렇게 초기화를 위한 문법이 다양해질수록 오히려 혼란이 가중되기 쉽다. 예시로 다음 상황을 보자
Widget w1;
Widget w2 = w1; // 대입이 아닌 복사 생성자가 호출
w1 = w2; // 대입이 호출
Widget w2 = w1;
는 등호만 보고 대입(assignment)이 일어난다고 생각하기 쉽지만, 실제로는 복사 생성자(copy constructor)가 호출되어 Widget w2(w1)
과 같은 효과를 가져온다. 이 때문에 중괄호를 사용한 균일 초기화(Uniform initialization)만을 사용하여 코드를 작성해야 한다고 주장하는 사람들이 많다. 균일 초기화는 어느 상황에서나 사용할 수 있고, 모든 것을 표현할 수 있다.
std::vector<int> v{ 1, 3, 5 };
int x{ 0 };
std::atomic<int> ai{ 0 };
이렇게 모든 경우에서 중괄호만으로 균일하게 초기화 구문을 작성할 수 있다는 점은 균일 초기화의 큰 장점이다. 균일 초기화는 축소 변환(narrowing conversion)을 방지해준다. 예를 들어 다음 코드를 보자.
double x, y, z;
int sum1 = x + y + z; // x + y + z가 int의 표현범위로 잘려나감
int sum2(x + y + z); // 마찬가지
int sum3 { x + y + z }; // double들의 합을 int로 나타내지 못할 수 있음을 감지하고 에러 발생
균일 초기화를 사용하지 않은 sum1
과 sum2
에서는 x+y+z
가 int
의 표현가능 범위에 맞게 잘려나가는 축소 변환이 이루어졌다. 프로그래머가 이것을 의도한 경우라면 상관이 없겠지만, 이는 예상하지 못한 결과를 야기할 수도 있다. 반면 균일 초기화를 사용한 경우 컴파일러는 int
타입의 sum1
이 덧셈 결과를 표현하지 못함을 감지하고 코드를 거부한다.
마지막으로, 균일 초기화는 가장 성가신 파싱(Most Vexing Parse)에서 자유롭다. 이는 C++의 "함수로 해석이 가능한 것들은 반드시 함수로 해석한다"는 규칙 때문에 나타나는 번거로움을 의미한다. 예를 들어
Widget w(); // C++은 이것을 함수 선언으로 해석한다
와 같은 코드를 보면 C++은 Widget
타입의 w
를 파라미터 없는 생성자로 초기화한다고 생각하지 않고, "입력이 없고 출력이 Widget
타입인, w
라는 이름의 함수를 선언한다"고 해석해버린다. 반면 균일 초기화를 사용하면 다음과 같이 이러한 문제를 피해갈 수 있다.
Widget w {}; // 함수 선언으로 해석될 여지가 없다
균일 초기화 사용 시 유의할 점
그러나 이렇게 편리한 균일 초기화도 문제점이 있는데, 종종 예상하지 못한 결과를 낳는다는 점이다. 항목 02에서 살펴봤듯, 균일 초기화를 사용하면 타입은 std::initializer_list
로 추론된다. 여기서 문제점은 생성자 오버로딩을 해소할 때, 컴파일러는 std::initializer_list
타입을 받는 생성자를 매우 강하게 선호한다는 점이다. 예시를 보자.
class Widget{
public:
Widget(int i, bool b);
Widget(int i, double b);
}
Widget w1(10, true); // 첫 번째 생성자 호출
Widget w2(10, 3.5); // 두 번째 생성자 호출
Widget w1{ 10, true }; // 첫 번째 생성자 호출
Widget w2{ 10, 3.5 }; // 두 번째 생성자 호출
이렇게 std::initializer_list
형식의 매개변수를 가진 생성자가 없는, 일반적인 상황에서 이는 전혀 문제가 되지 않는다. 그러나 그러한 생성자를 하나 추가해버리면 이야기가 달라진다.
class Widget{
public:
Widget(int i, bool b);
Widget(int i, double b);
Widget(std::initializer_list<long double> il); // 새로운 생성자 추가
}
Widget w1(10, true); // 첫 번째 생성자 호출
Widget w2(10, 3.5); // 두 번째 생성자 호출
Widget w1{ 10, true }; // 세 번째 생성자가 호출됨!!
Widget w2{ 10, 3.5 }; // 세 번째 생성자가 호출됨!!
이렇게 컴파일러는 중괄호 초기화 구문을 std::initializer_list
타입의 매개변수로 해석할 여지가 조금이라도 있다면 무조건 std::initializer_list
타입의 생성자를 호출해버린다. 컴파일러의 std::initializer_list
선호가 어느 정도로 강하냐면, 다음 예시에는 아예 컴파일을 거부해버린다.
class Widget{
public:
Widget(int i, bool b);
Widget(int i, double b);
Widget(std::initializer_list<bool> il); // std::initializer_list의 요소를 bool 타입으로 바꿈
}
Widget w1{ 10, true }; // 그냥 첫 번째/두 번째 생성자를 사용하면 될텐데도
Widget w2{ 10, 3.5 }; // 포기하지 않고 세 번째 생성자를 쓰려 하다 축소 변환 때문에 컴파일 에러 발생
이는 일상적인 사용과는 관련이 없는 edge case로 보일 수도 있겠지만, 여기에 직접 영향을 받는 예시 중 하나가 다름아닌 std::vector
이기 때문에 알아둘 필요가 있다.
std::vector<int> v1(10, 20); // 비std::initializer_list 생성자 사용,
// 20이 10개 들어있는 vector 생성
std::vector<int> v2{ 10, 20 }; // std::initializer_list 생성자 사용,
// 20과 10의 두 원소를 가진 vector 생성
즉, std::initializer_list
생성자가 하나라도 있으면 나머지 생성자들은 아예 경쟁 상대에서 제외될 정도로 std::initializer_list
생성자는 강력하게 선호된다. 따라서 애초에 클래스를 작성할 때 이러한 메서드는 꼭 필요할 때만 사용해야 한다.
균일 초기화와 기존의 괄호 초기화는 모두 일장일단이 있으므로 세심한 선택을 필요로 하며, 커뮤니티의 공감대가 형성되어 있지 않다. 저자는 둘 중 하나를 선택해 일관되게 사용하는 것을 추천한다.
08. 0과 NULL
보다 nullptr
을 선호하라
null 포인터를 표현하기 위해 기존 C/C++ 개발자들은 0이나 NULL
을 사용해왔다. 그러나 이들은 기본적으로는 포인터 타입이 아니라는 데서 문제가 있다. 따라서 컴파일러가 오버로딩된 함수들 중 어떤 것을 호출할지 정할 때, 0이나 NULL
을 함수에 전달하면 이는 포인터 타입으로 해석되지 않을 수 있다.
void f(int);
void f(bool);
void f(void*);
f(0); // 반드시 f(int)를 호출
f(NULL); // 컴파일 에러가 나거나 f(int)를 호출
따라서 애초에 정수 타입과 포인터 타입 간의 오버로딩을 피하는 것이 가장 좋지만, 이러한 문제를 해결하기 위해 C++11부터는 nullptr
의 개념이 추가되었다. 이는 모든 포인터 타입으로 캐스팅될 수 있는 std::nullptr_t
이라는 타입을 가진다. 따라서 위와 같은 코드에서,
f(nullptr); // f(void*)를 호출
을 사용했더라면 더 좋았을 것이다. 특히 템플릿을 사용할 때 nullptr
의 장점이 돋보이게 된다. 또 nullptr
은 코드의 명확성도 높여준다.
auto result = f(...);
if (result == 0) {...} // 0을 사용한 경우
if (result == nullptr) {...} // nullptr을 사용한 경우
둘째 줄과 셋째 줄 중 어느 것이 result
가 포인터 타입임을 더 잘 드러내는지는 명백하다.
09. typedef
보다 별칭 선언을 선호하라
특히 템플릿을 많이 사용하는 경우, C++의 타입 이름은 복잡해지기 쉽다. 이런 경우를 위해 C++에서는 typedef
키워드를 제공한다.
typedef std::unique_ptr<std::unordered_map<std::string, std::string>> UPtrMapSS;
그런데 모던 C++에서는 typedef
이외에도 별칭 선언(alias declaration)이라는 방법을 사용할 수도 있다. 이 경우 using
키워드를 사용하면 된다.
using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>;
별칭 선언이 typedef
에 비해 가지는 장점은 크게 두 가지이다. 먼저, 함수 포인터 타입을 더 직관적으로 다룰 수 있다 int
와 const std::string
타입의 두 매개변수를 받아 아무것도 돌려주지 않는 함수에 대한 포인터 타입을 나타낸다고 하자. 이를 typedef
와 using
에서는 각각 다음과 같이 나타내야 한다.
typedef void (*FP)(int cosnt std::string&); // FP의 위치가 매우 비직관적이다
using FP = void (*)(int, const std::string);
한눈에 보아도 using
을 사용한 구문이 훨씬 간편하고 이해하기 쉬운 것을 알 수 있다. 하지만 이보다 훨씬 강력한 장점이 있는데, 바로 별칭 선언은 템플릿화를 할 수 있다는 점이다. std::list<T, MyAlloc<T> >
를 임의의 타입 T
에 대해
별명을 짓고 싶다고 하자. typedef
를 이용하면 다음과 같이 수행해야 한다.
template<typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T> > type;
}
MyAllocList<Widget>::type lw; // MyAllocList<T>::type가 std::list<T, MyAlloc<T> >와 같다
반면 using
을 이용해 별칭 선언을 해주면 번거롭게 ::type
을 붙일 필요가 없어진다.
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T> >;
MyAllocList<Widget>lw; // MyAllocList<T>::type가 std::list<T, MyAlloc<T> >와 같다
한편, C++11에서는 그럼에도 불구하고 typedef
를 이용한 것과 같은 방식으로 정의된 템플릿 타입들이 존재한다. 이들은 C++14에서 별칭 선언으로 다시 정의되어 표준에 포함되었다. 그 덕분에 번거롭게 ::type
을 붙일 필요가 없어졌다.
// C++11
std::remove_const<T>::type // const T를 T로 변환
std::remove_reference<T>::type // T&나 T&&를 T로 변환
std::add_lvalue_reference<T>::type // T를 T&로 변환
// C++14
std::remove_const_t<T> // const T를 T로 변환
std::remove_reference_t<T> // T&나 T&&를 T로 변환
std::add_lvalue_reference_t<T> // T를 T&로 변환
10. 범위 없는 enum
보다 범위 있는 enum
을 선호하라
기존 C++에서 사용되던 enum
은 모던 C++에서는 범위 없는(unscoped) enum
이라고 불린다. enum
이 유효한 스코프 내에서는 enum
안에서 정의된 열거자의 이름들도 전부 유효하기 때문이다. 이는 불편한 점이 많다.
enum Color { black, white, red };
auto white = red; // 이미 Color에서 red를 정의했으니 오류가 발생
따라서 모던 C++에서는 범위 있는(scoped) enum
이 도입되었다. 이는 기존과 달리 enum
이 아닌 enum class
로 선언되며, 이 때문에 enum
클래스라고 불리기도 한다. 실제로도 클래스와 비슷하게 행동하는 부분이 있다.
enum class Color { black, white, red };
auto white = red; // OK
Color c1 = Color::white; // OK
auto c2 = Color::red; // OK
이뿐만 아니라 암묵적으로 정수 타입으로 변환되는 enum
의 열거자들과는 달리, 범위 있는 enum
은 다른 타입으로 변환되지 않는다. 따라서 다음과 같이 비정상적인 사용을 막을 수 있다.
enum Color = { black, white, red }; // 범위 없는 enum으로 선언
Color c = Color::red;
if (c < 14.5) {...} // c가 자동으로 정수 2로 변환되어 비교. 비정상적인 사용
enum class Color = { black, white, red }; // 범위 있는 enum으로 선언
Color c = Color::red;
if (c < 14.5) {...} // 타입이 서로 다르니 비교 불가. 컴파일 에러
if (dynamic_cast<int>(c) < 14.5) {...} // 꼭 비교를 하고 싶다면 이렇게 작성
마지막으로, 범위 있는 enum
의 경우 전방 선언(forward declaration)이 가능하다. 즉, 열거자들을 지정하지 않고 enum
의 이름만 미리 선언할 수 있다.
enum Color; // 오류
enum class Color; // 가능!
이 덕분에 enum
의 선언은 헤더 파일에서 하고, 그 정의는 다른 파일에 두는 식으로 하여 컴파일 시 의존 관계의 복잡성을 줄일 수 있다.
범위 없는 enum
이 유용한 경우
이렇게 보면 범위 없는 enum
을 사용해야 할 일은 아예 없는 것처럼 보인다. 그럼에도 범위 없는 enum
이 유용한 경우가 있는데, 바로 std::tuple
의 필드들을 지칭할 때이다.
using UserInfo = std::tuple<std::string, std::string, std::size_t>; // 사용자 이름, 이메일 주소, 레이팅을 담은 tuple
UserInfo uInfo;
auto val = std::get<1>(uInfo); // UserInfo의 필드 1이 이메일 주소라는 사실을 기억해야 이해 가능
enum UserInfoFields { uiName, uiEmail, uiReputation }; // 각 필드의 인덱스를 enum으로 저장
auto val = std::get<uiEmail>(uInfo); // userInfo의 이메일 정보를 가져왔음을 바로 알 수 있다
// 반면 범위 있는 enum을 사용하는 경우 다음과 같이 코드가 복잡해진다.
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);
11. 정의되지 않은 private
함수보다 삭제된 함수를 선호하라
코드를 다른 개발자에게 배포할 때, 코드에 포함된 특정 함수의 사용을 막아야 하는 경우가 종종 있다. 특히 C++이 자동으로 생성하는 클래스의 멤버 함수에서 이런 일이 생기는데, C++이 자동으로 만들어주는 복사 생성자나 대입 연산자를 제거해야 할 경우 등이 있다.
예를 들기 위해서 iostream
에 있는 basic_ios
라는 클래스 템플릿을 가져오자. 이는 모든 입출력 스트림(istream
, ostream
등등)의 조상 역할을 하는 클래스이다. 그런데, 이러한 스트림 객체는 복사하지 않는 것이 좋기 때문에 복사 생성자와 대입 연산자를 삭제하고 싶다고 하자. C++98 표준에서는 이 문제를 이렇게 해결한다:
// C++98에서 함수의 사용을 막는 방법
template <class charT, class traits = char_traits<charT> >
class basic_ios: public ios_base{
public:
...
private:
basic_ios(const basic_ios&); // 복사 생성자
basic_ios& operator = (const basic_ios&); // 대입 연산자
}
이렇게 복사 연산자와 대입연산자를 private
으로 선언하고 정의는 하지 않아 외부에서 호출하지 못하게 하는 것이다. 그런데 C++11에서는 더 나은 방법이 선언되는데, 바로 =delete
를 사용해 해당 함수를 아예 삭제하는 것이다.
template <class charT, class traits = char_traits<charT> >
class basic_ios: public ios_base{
public:
...
basic_ios(const basic_ios&) = delete; // 복사 생성자
basic_ios& operator = (const basic_ios&) = delete; // 대입 연산자
}
앞선 C++98의 관례에서 private
으로 선언된 함수의 잘못된 사용은 링킹 단계에 가서야 발견되는 반면, delete
를 사용하면 컴파일 자체가 실패하므로 더 효율적이다. 한편, 이렇게 delete
를 사용해 삭제될 함수는 public
으로 선언하는 것이 관례인데 이는 디버깅의 용이성 때문이다. 많은 컴파일러들이 함수의 사용 가능 여부를 체크할 때 private/public
여부를 먼저 따진 후 삭제되었는지를 따지기 때문에, 이렇게 선언하는 경우에 더 도움되는 에러 메시지들을 얻을 수 있다.
delete
의 다양한 용례
클래스에서만 사용가능한 private
과 달리, delete
는 어떤 함수도 삭제할 수 있다. 이것이 유용하게 사용되는 첫 번째 예시는 자동으로 만들어지는 함수의 오버로딩 버전들을 삭제하는 것이다. 예시로 어떤 숫자가 행운의 숫자인지 알려주는 bool isLucky(int number)
가 있다고 하자. 작성자는 숫자만 받을 것을 의도했다고 하더라도, C++은 bool
이나 char
, double
등의 자료형도 isLucky
에 들어올 수 있도록 자동으로 캐스팅해버린다. isLucky('a')
, isLucky(3.5)
, isLucky(false)
등도 컴파일에 성공하는 것이다.
이런 경우, isLucky
가 bool
, char
, double
등을 받지 못하게 오버로딩된 버전들을 삭제해버릴 수 있다.
bool isLucky(char) = delete;
bool isLucky(bool) = delete;
bool isLucky(double) = delete;
또 delete
는 템플릿의 특정 인스턴스들을 삭제할 때도 사용이 가능하다. 예를 들어 임의의 포인터를 인수로 받을 수 있는 다음 템플릿 함수가 있다고 하자.
template<typename T>
void processPointer(T * ptr);
그런데, ptr
의 타입으로 void*
(임의의 포인터를 의미)나 char*
(C스타일 문자열을 의미)와 같은 특별한 포인터가 들어와서는 곤란하다. 따라서 이러한 용례로 사용되지 않도록 삭제할 수 있다.
template<>
void processPointer<void>(void*) = delete;
template<>
void processPointer<char>(char*) = delete;
위와 같이 void*
와 char*
을 입력으로 받는 버전들을 삭제해버리면 원하는 타입의 포인터들만 템플릿 함수에 들어오도록 강제할 수 있다.
12. 오버라이딩 함수들을 override
로 선언하라
C++에서 객체 지향 프로그래밍은 클래스와 상속, 그리고 가상 함수(virtual function)가 주도한다. 클래스의 상속에서 가장 기본적인 개념 중 하나는 자식 클래스가 부모 클래스의 가상 함수를 오버라이딩(override)하는 것이다. 그런데 가상 함수의 오버라이딩은 잘못 작성하기가 매우 쉽다. 다음 조건을 모두 만족해야 오버라이딩이 일어나기 때문이다.
- 부모 클래스의 함수가 반드시 가상함수여야 한다.
- 부모 클래스와 자식 클래스의 함수명이 동일해야 한다. (소멸자는 예외)
- 부모 클래스와 자식 클래스 함수의
const
성과 매개변수 타입들, 반환 타입, 예외 명세(exception specification)이 모두 일치해야 한다.
- (C++11에서 추가) 참조 한정사(reference qualifier)가 동일해야 한다.
- 이는 멤버 함수를 좌측값 또는 우측값에만 사용할 수 있게 제한하는 기능으로, 다음과 같이 사용된다.
class Widget{
public:
void doWork() &; // *this가 좌측값일 때만 사용 가능
void doWork() &&; // *this가 우측값일 때만 사용 가능
}
Widget w;
Widget makeWidget(); // 새 Widget 객체를 만들어주는 함수. 우측값을 반환
w.doWork(); // 좌측값용 doWork()가 호출
makeWidget().doWork() // 우측값용 doWork()가 호출
이렇게 많은 조건들 중 하나라도 잘못된다면, 오버라이딩은 일어나지 않는다. 문제는 이렇게 프로그래머의 실수로 오버라이딩 조건이 충족되지 않아도 컴파일러는 아무 문제도 일으키지 않는다는 것이다. 예를 들어서 다음 코드는 그 오버라이딩이 단 한번도 일어나지 않는데도 불구하고 컴파일러는 아무런 에러를 발생시키지 않고, 심지어는 경고조차 해주지 않을 수도 있다.
class Base{
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
}
class Derived: public Base{
public:
virtual void mf1(); // const성이 다름
virtual void mf2(unsigned int x); // 매개변수 타입이 int와 unsigned int로 불일치
virtual void mf3() &&; // 참조한정사가 좌측값과 우측값으로 다름
void mf4() const; // 가상 함수가 아님
}
override
키워드를 사용하면 이와 같이 오버라이딩이 일어나지 않는 경우 컴파일 에러를 발생시킨다. 따라서 프로그래머는 자신이 오버라이딩을 잘못 작성했다는 것을 바로 알 수 있다. 사용법도 간단한데, 위의 Derived
클래스의 각 함수들에 override
를 붙여 다음과 같이 선언하도록 바꾸면 된다.
class Derived: public Base{
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override;
void mf4() const override;
}
위의 코드는 컴파일 에러를 발생시키며, 오버라이딩이 전혀 의도대로 되지 않고 있다는 것을 경고해줄 것이다.
참조 한정사(reference qualifier)
앞서 잠깐 언급했던 참조 한정사에 대해 좀 더 자세히 알아보자. 일반 함수가 좌측값 또는 우측값만 인수로 받고 싶다면, 다음과 같이 작성하면 된다.
void doSomething(Widget &w); // 좌측값만 받음
void doSomething(Widget &&w); // 우측값만 받음
클래스 멤버 함수의 참조 한정사는 이러한 제한을 *this
에도 적용할 수 있게 해줄 뿐이다. 마치 *this
가 바뀌지 않음을 알려주기 위한 const
가 멤버 함수에서는 함수 선언 뒤에 붙는 것과도 같다.
class Widget {
public:
void doSomething() &; // Widget 객체가 좌측값일 때만 가능
void doSomething() &&; // Widget 객체가 우측값일 때만 가능
void changeNothing() const; // Widget 객체가 바뀌지 않음을 알려주는 후행 const 키워드와 유사
}
참조 한정사를 사용해 유용한 상황 중 하나를 제시해보자.
class Widget{
public:
using DataType = std::vector<double>;
DataType& data() { return values; }
private:
DataType values;
}
Widget makeWidget(){
... // 새 Widget 객체를 만들어주는 함수. 우측값을 반환
}
makeWidget().data(); // makeWidget()이 반환한 우측값에서 values가 복사생성되어 비효율적
위의 코드를 보면, makeWidget()
이 반환하는 우측값으로부터 std::vector<double>
타입의 values
가 복사생성된다. data()
가 좌측값 참조를 반환하도록 선언되어 있기 때문에 makeWidget()
이 만든 객체에서 values
를 좌측값으로 바꿔주려면 복사 연산이 반드시 필요한 것이다.
이러한 문제를 해결하려면 다음과 같이 우측값 참조한정사를 이용해 data()
를 하나 더 정의해주면 된다.
DataType&& data() && { return std::move(values); }
13. iterator
보다 const_iterator
를 선호하라
const_iterator
는 const
값을 가리키는 포인터의 STL 버전이라고 할 수 있다. 따라서 이터레이터가 가리키는 것을 수정할 필요가 없을 때는 const_iterator
를 쓰는 것이 좋다. 이는 C++98에서도 존재하는 개념이긴 했으나, 실용적으로는 잘 사용되지 않았다. 다음 코드를 보자. 벡터 values
에서 1993이라는 값을 찾아 거기에 1998을 삽입하는 일을 const_iterator
로 수행하는 코드이다.
typedef std::vector<int>::iterator IterT;
typedef std::vector<int>::const_iterator ConstIterT;
std::vector<int> values;
ConstIterT ci =
std::find(static_cast<ConstIterT>(values.begin()), // const_iterator로 캐스팅 필요
static_cast<ConstIterT>(values.end()), // 마찬가지로 캐스팅 필요
1983);
values.insert(static_cast<IterT>(ci), 1998); // 다시 일반 iterator로 캐스팅 필요
- 먼저,
values.begin()
, values.end()
와 같이 쉽게 얻을 수 있는 일반 iterator
와는 달리, const_iterator
를 사용하려면 static_cast<ConstIterT>(values.begin())
와 같이 일일이 캐스팅을 해주어야 했다.
- 이렇게 공들여서
const_iterator
를 얻었다고 해도, 이터러블에서 새 원소를 삽입할/삭제할 위치를 지정해줄 때는 사용할 수 없었다. 따라서 다시 static_cast<IterT>(constIteratorObject)
와 같이 일반 iterator
로 바꿔주어야 했다.
하지만 C++11에서는 이러한 번거로움이 모두 없어졌다. 이제 다음과 같이 const_iterator
를 쉽게 생성하고 사용할 수 있게 되었다.
std::vector<int> values;
auto it = std::find(values.cbegin(), values.cend(), 1983); // cbegin, cend로 const_iterator 생성
values.insert(it, 1998); // const_iterator 객체를 삽입에도 사용 가능
따라서 모던 C++을 사용할 때에는 가능한 한 const_iterator
를 쓰는 것이 좋다.
14. 예외를 방출하지 않을 함수는 noexcept
로 선언하라
C++98에서는 예외 명세(exception specification)라는 기능이 제공되었다. 프로그래머는 throw
구문을 사용하여 함수가 방출할 예외들의 타입을 명시해줄 수 있었다. 그러나 함수의 구현이 바뀌면 예외도 바뀔 수 있고, 함수의 사용자가 이러한 예외 타입에 의존하는 경우 불편함이 있을 수 있다는 지적이 생겼다. 이에 따라 C++11에서는 함수가 예외를 방출하는지/방출하지 않는지만을 이분법적으로 구분하기로 했다. 바로 noexcept
키워드를 이용해서이다. 이는 클라이언트에 노출되는 함수의 인터페이스의 일부이다.
예외의 방출(emit)은 예외가 함수 바깥으로 전파되는 것을 의미하며, 예외의 발생 또는 던지기(throw)와는 다른 개념이다. 예외가 발생하였지만 함수 바깥으로 전파되지는 않을 수도 있다.
int f(int x) throw(); // f가 예외를 방출하지 않음. C++98 스타일
int f(int x) noexcept; // f가 예외를 방출하지 않음. C++11 스타일
기존 C++98에서는 throw()
를 이용해 예외 방출이 없음을 명시해주었다. throw()
로 예외 명세를 한 함수는 예외 명세가 위반되면 호출 스택에서 f
가 호출된 지점까지를 비우고 몇 가지 예외처리를 한 후 프로그램이 종료된다. 반면 noexcept
는 스택을 비워야 한다는 것이 규정되어 있지 않다. 따라서 컴파일러가 최적화할 수 있는 여지가 더 많아진다.
특히 “이동 연산이 안전함이 확실하면 이동하고, 아니면 복사한다”는 전략으로 작동하는 STL의 많은 함수들에서는 noexcept
가 최적화의 여지를 더 많이 줄 수 있다. 예외가 발생하지 않는다는 여지가 없는데도 이동 연산을 써버리면, 예외가 발생했을 때 원래의 상태로 복구할 수 없기 떄문이다.
그럼에도 불구하고 noexcept
로 선언하기 위해 함수의 구현을 억지로 바꾸는 바람직하지 않다. 따라서 대부분의 함수는 예외에 중립적(exception-neutral)이다. 즉 자기 자신은 예외를 던지지 않지만, 예외를 던지는 다른 함수들을 호출할 수는 있다.
15. 가능하면 항상 constexpr
을 사용하라
constexpr
은 C++11에서 가장 헷갈리는 개념 중 하나이다. 개념적으로 constexpr
은 어떠한 값이 상수일 뿐만 아니라, 컴파일 시점에서 알려진다는 것을 의미한다. 그런데 이는 객체에 적용될 때는 항상 맞는 말이지만, 함수에 constexpr
이 적용될 때에는 그렇지 않을 수 있다. 여기에 대해 좀 더 알아보자.
constexpr
객체
constexpr
이 객체에 적용된 경우, 객체는 실제로 const
이며 컴파일 시점에서 알려진다. 따라서 읽기전용 메모리에 배치되거나, 배열의 크기나 정수 템플릿 인수 등 정수 상수 표현식이 요구되는 상황에서 사용할 수 있다.
int sz;
constexpr auto arraySize1 = sz; // sz의 값이 컴파일 시점에 알려지지 않으니, 오류
std::array<int, sz> data1; // sz이 정수 템플릿 인수에 들어감. 같은 이유로 오류
constexpr auto arraySize2 = 10; // 10은 컴파일시점 상수가 맞으니 OK
std::array<int, arraySize2> data2; // OK
반면 그냥 const
는 반드시 컴파일 시점에서 알려진 값일 필요는 없다. 따라서 모든 constexpr
은 const
이나, 역은 성립하지 않는다고 말할 수 있다.
constexpr
함수
반면 함수를 constexpr
으로 선언하는 것도 가능하다. constexpr
함수들은 컴파일시점 상수가 인수로 들어온 경우, 컴파일시점 상수를 반환한다. 즉, 함수의 결과가 컴파일 도중에 계산되는 것이다. 반면 그렇지 않은(실행시점에야 알려지는) 값이 인수로 들어오면 보통의 함수처럼 행동한다.
constexpr
함수는 반드시 입력/반환 타입이 모두 리터럴 타입, 즉 컴파일 시 값을 결정할 수 있는 타입을 사용해야만 한다. C++11에서는 void
를 제외한 모든 타입이 여기에 해당하고, 생성자 및 적절한 멤버 함수들이 constexpr
인 커스텀 타입도 리터럴 타입이 될 수 있다.
class Point{
public:
constexpr Point(double xVal = 0, double yVal = 0) noexcept
: x(xVal), y(yVal) {}
constexpr double xValue() const noexcept { return x; }
constexpr double yValue() const noexcept { return y; }
void setX(double newX) noexcept { x = newX; }
void setY(double newY) noexcept { y = newY; }
private:
double x, y;
}
위의 클래스에서 생성자는 constexpr
으로 선언될 수 있다. 주어진 인수들이 전부 컴파일시에 알려진다면 Point
객체의 자료값들도 컴파일시에 결정되기 때문이다. Getter 메서드인 xValue
, yValue
또한 Point
가 컴파일 시에 알려지면 그 반환값을 컴파일시에 결정할 수 있으니, constexpr
로 선언하는 것이 가능하다.
반면 setX
와 setY
는 C++11에서는 constexpr
로 선언할 수 없다. 여기에는 두 가지 이유가 있다.
- 이 두 함수는 객체 그 자체를 수정하는데, C++11에서
constexpr
함수는 암묵적으로 const
로 선언된다. ,
- 반환 형식인
void
는 리터럴 형식이 아니기 때문이다.
C++14에서는 이 두 제약조건이 모두 사라졌기 때문에, 두 setter 함수도 constexpr
로 작성할 수 있다.
constexpr void setX(double newX) noexcept { x = newX; }
constexpr void setY(double newY) noexcept { y = newY; }
16. const
멤버 함수를 스레드에 안전하게 작성하라
다항식을 저장할 수 있는 클래스와 그 내부함수로 다항식의 근들을 계산하는 함수를 만들었다고 가정하자. 근을 계산하는 함수는 다항식 자체를 바꾸지는 않으므로 const
로 선언하는게 적절하다.
class Polynomial{
public:
using RootsType = std::vector<double>;
RootsType roots() const;
...
}
그런데 다항식 계산의 비용이 클 수 있으니 캐싱을 적용한다고 가정하자. 그러면 다음과 같이 될 것이다.
class Polynomial{
public:
using RootsType = std::vector<double>;
RootsType roots() const{
if(!rootsAreValid){
... // 캐시가 유효하지 않으면 다시 계산
rootsAreValid = true;
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false }; // mutable 적용시, const 함수도
mutable RootsType rootVals{}; // 이들 변수를 변경할 수 있음
}
그런데, 만약 두 스레드가 같은 Polynomial
에 대해서 roots()
를 동시에 호출한다면 동기화 없이 같은 메모리에 접근하는 레이스 컨디션이 발생할 수 있다. 이는 위험한 상황으로, 정의되지 않은 행동을 유발할 수 있다. 이 책에서는 이를 해결하는 방법으로 두 가지를 제시한다.
뮤텍스
뮤텍스는 가장 보편적인 동기화 방법이다. C++에서 이는 std::mutex
로 사용 가능하다.
class Polynomial{
public:
using RootsType = std::vector<double>;
RootsType roots() const{
std::lock_guard<std::mutex> g(m); // 뮤텍스를 잠근다
if(!rootsAreValid){
...
rootsAreValid = true;
}
return rootVals;
} // 뮤텍스를 푼다
private:
mutable std::mutex m; // 뮤텍스 변수 정의
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
}
이렇게 하면 roots()
실행을 위해 뮤텍스 m
을 획득해야 하기 때문에 두 스레드에서 동시에 실행되는 것을 막을 수 있다. 이때 std::mutex
는 복사나 이동이 불가능하기 때문에, Polynomial
자체도 복사나 이동이 불가능해짐에 유의해야 한다.
std::atomic
카운터
뮤텍스 사용이 너무 과하고 계산 자원을 많이 소모한다고 생각되는 경우가 있다. 예를 들어서, 아래 예시와 같이 멤버 함수가 호출되는 횟수를 세고 싶다면 std::atomic
을 이용해 멤버 함수의 호출 횟수를 셀 수 있다.
class Point{
public:
...
doubleDistanceFromOrigin() const noexcept {
++callCount; // 원자적(atomic) 연산
return std::hypot(x, y); // sqrt(x^2+y^2)을 계산하는 함수
}
private:
mutable std::atomic<unsigned> callCount{ 0 };
double x, y;
}
이 경우 callCount
의 증가 연산은 원자적(atomic)으로, 즉 하나의 기계어 명령으로 취급되어 이루어지기 때문에, 중간에 다른 스레드가 끼어드는 일 없이 안전하게 값을 증가시킬 수 있다.
이렇듯,
- 동기화가 필요한 변수나 메모리 위치 하나에 대해서는
std::atomic
을 사용하고,
- 둘 이상의 변수나 메모리 위치를 하나의 단위로서 조작해야 할 때는 뮤텍스를 사용하는 것이 좋다.
스레드에 자유로운 상황은 점점 드물어지고 있고, 앞으로는 특히 아주 희귀해질 것으로 전망되므로 const
멤버 함수는 항상 동시적인 상황에 놓일 수 있다고 가정하고 스레드에 안전하게 만드는 것이 좋다.
17. 특수 멤버 함수들의 자동 작성 조건을 숙지하라
특수 멤버 함수(special member function)란 C++이 자동으로 작성하는 멤버 함수들을 말한다. C++98에서 특수 멤버 함수들로는 다음이 있었다.
- 기본 생성자
- 소멸자
- 복사 생성자(copy constructor)
- 복사 대입 연산자(copy assignment operator)
이들은 모두 디폴트로 public
이고, inline
이며, virtual
으로 생성된다는 (단, 부모 클래스의 소멸자가 virtual
이면 자식 클래스의 소멸자도 virtual
) 공통점이 있다. C++11에는 여기에 더해 두 개의 특수 멤버 함수가 추가되었다:
- 이동 생성자(move constructor)
- 이동 대입 연산자(move assignment operator)
이를 코드로 표현하면 다음과 같다.
class Widget{
public:
Widget(Widget&& rhs); // 이동 생성자
Widget& operator=(Widget&& rhs); // 이동 대입 연산자
}
즉, 이 두 멤버 함수는 각각 복사 생성자와 복사 대입 연산자의 인수를 좌측값 참조자에서 우측값 참조자로 바꾸고, 복사 대신 이동(즉 std::move
)을 수행하는 것과 같다고 할 수 있다.
이들 특수 멤버 함수들이 어떤 경우에 자동으로 작성되는지를 잘 숙지하고 있어야 클래스가 관리하는 자원을 알맞게 다루도록 특수 멤버 함수들을 구성할 수 있다. C++98에서는 Rule of Three라는 것이 있어서, 복사 생성자와 복사 대입 연산자, 소멸자 중 하나라도 선언하면 나머지 둘도 선언해야 한다는 법칙이 있었다. 모던 C++에서도 비슷한 규칙이 적용되며, 이동 연산자들의 경우 둘 중 하나라도 선언되었거나 복사 연산자가 선언되었으면 자동작성되지 않는다. 여기에는 이유가 있는데, 이동 연산자와 복사 연산자 중 하나라도 선언되었다는 말은 일반적인 복사 연산이 이 클래스의 데이터를 다루는 데 적합하지 않다는 말이고, 그러면 이동 연산도 암묵적 작성이 적합하지 않을 것이기 때문이다.
이러한 논리를 기반으로 정리해보면 다음과 같다.
- 기본 생성자: C++98과 같다. 사용자가 선언하지 않으면 자동으로 작성된다
- 소멸자: C++98과 기본적으로 같으나,
noexcept
로 작성된다. C++98에서와 같이, 부모 클래스가 있고 부모 클래스의 소멸자가 virtual
일 때만 virtual
로 작성된다.
- 복사 생성자: 사용자가 선언하지 않을 때만 작성된다. 또, 클래스에 이동 연산이 하나라도 정의되어 있는 경우 비활성화된다. 자동생성된 경우, 비
static
데이터 멤버들을 멤버별로 복사생성하는 식으로 동작한다.
- 복사 대입 연산자: 마찬가지로 사용자가 선언하지 않았을 때만 작성되고, 클래스에 이동 연산이 하나라도 정의되어 있는 경우 비활성화된다. 자동생성된 경우, 비
static
데이터 멤버들을 멤버별로 복사 대입하는 식으로 동작한다.
- 이동 생성자와 이동 대입 연산자: 사용자가 복사 연산들과 이동 연산들, 그리고 소멸자를 전부 선언하지 않았을 때만 자동으로 작성된다. 비
static
데이터 멤버들을 멤버별 이동시키는 식으로 동작한다.
Chapter 4. 스마트 포인터
C/C++의 일반 포인터(raw pointer)는 분명 강력한 수단이지만, 다음과 같은 단점을 가지고 있다.
- 선언만 봐서는 객체를 가리키는지 배열을 가리키는지 알 수가 없다.
- 선언만 봐서는 포인터 사용 후에 피지칭 객체를 직접 파괴해야 하는지(i. e. 포인터가 피지칭 객체를 소유하고 있는지) 알 수 없다
- 직접 파괴해야 한다면 어떻게 파괴해야 하는지 정보를 얻을 수 없다. e. g.
delete
사용, 전용 파괴 함수에 넘겨주기 등등
delete
를 사용해야 한다면 delete
를 사용해야 하는지 delete[]
를 사용해야 하는지 알 수 없다.
- 코드의 모든 경로에서 파괴가 정확히 한 번만 일어남을 보장할 수 없다.
- 포인터가 피지칭 객체를 잃었는지 알아낼 수 있는 방법이 없다.
이렇게 (일반) 포인터는 강력한 수단이지만, 프로그래머가 실수할 수 있는 여지를 너무 많이 가지고 있다. 스마트 포인터는 이를 해결할 수 있는 방법이다. 일반 포인터가 할 수 있는 거의 모든 일을 할 수 있으면서도 적절한 시간에 파괴되도록 하여 오류의 여지가 훨씬 적기 때문이다.
C++11의 스마트 포인터는 std::auto_ptr
, std::unique_ptr
, std::shared_ptr
, std::weak_ptr
로 총 네 개가 있다. 이중 std::auto_ptr
은 비권장(deprecated)으로 분류된 클래스로, std::unique_ptr
의 기능을 이동 의미론 없이 구현하려는 시도였으나 현재는 std::unique_ptr
에 의해 대체되었다. 이 장의 항목들에서는 나머지 세 개의 스마트 포인터를 효율적으로 사용하는 방법을 다룰 것이다.
18. 소유권 독점 자원의 관리에는 std::unique_ptr
를 사용하라
유니크 포인터 std::unique_ptr
은 일반 포인터와 거의 같은 크기를 가지며, 역참조(dereference, *
) 등을 일반 포인터와 거의 똑같이 시행할 수 있다. 그 중심에는 독점적 소유권(exclusive ownership)이라는 철학이 있다. 유니크 포인터가 자신이 가리키는 객체를 “소유”하여, 다른 포인터는 이를 가리킬 수 없는 것이다. 이를 실현하기 위해서는 유니크 포인터 객체는 복사가 불가능해야 한다. 복사할 수 있다면 두 유니크 포인터 객체가 같은 자원을 가리키게 되어버리기 때문이다. 이는 복사 생성자를 =delete
로 처리함으로써 가능하다(항목 11 참고). 따라서 일반 포인터를 유니크 포인터에 대입하거나, 유니크 포인터를 다른 유니크 포인터에 대입하는 등의 표현식은 허용되지 않는다.
int a = 42;
int *b = &a;
auto p1 = std::unique_ptr<int>(33);
std::unique_ptr<int> p2 = b; // 일반 포인터를 std::unique_ptr로 대입. 컴파일 에러 발생
std::unique_ptr<int> p3 = p1; // std::unique_ptr을 std::unique_ptr에 대입. 컴파일 에러 발생
예를 들어서 Stock
, Bond
, RealEstate
의 세 자식 클래스를 가지는 Investment
클래스가 있다고 하자. Investment
객체를 만드는 팩토리 함수 makeInvestment
를 선언해야 한다고 가정하자. 이렇게 팩토리 함수를 만드는 상황은 유니크 포인터가 딱 알맞은 경우이다. 다음과 같이 선언하면 된다.
template<typename... Ts>
std::unique_ptr<Investment>
makeInvestment(Ts&&... params);
{
auto pInvestment = makeInvestment(parameters); // std::unique_ptr<Investment> 타입
} // 범위를 벗어나면 *pInvestment가 파괴된다
이렇게 pInvestment
를 사용하면 자원의 관리를 호출자가 알아서 책임지고 범위에서 벗어날 때 파괴해준다. 이는 기본적으로는 delete
를 이용해 일어나지만, std::unique_ptr
객체 생성시에 파괴자를 따로 지정해줄 수도 있다. 다음 예시를 보자. delInvmt
라는 커스텀 파괴자를 적용해주었다.
auto delInvmt = [](int* pInv){ // 커스텀 파괴자 (람다 표현식)
cout << "Investment object deleted" << endl;
delete pInvmt;
};
template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)> makeInvestment(Ts&&... params){
// std::unique_ptr의 두 번째 인수로 커스텀 파괴자를 넣어주면 된다
auto pInv = std::unique_ptr<Investment, decltype(delInvmt)>(nullptr, delInvmt); // 우선 nullptr로 설정하고 나중에 바꿔주기
if (/* Stock 객체를 생성해야 할 경우*/)
pInv.reset(new Stock(std::forward(params)...)); // reset을 사용해 새로운 Stock 객체의 소유권을 부여
else (/*Bond 객체를 생성해야 할 경우*/)
pInv.reset(new Bond(std::forward(params)...));
return pInv;
}
이렇게 커스텀 파괴자를 사용해주면 유니크 포인터 객체는 메모리를 두 배로 차지하게 되는데, 파괴자 함수를 가리키는 포인터를 따로 저장해주어야 하기 때문이다.
유니크 포인터를 사용하면 여러 장점이 있다. pInv
가 지칭하는 객체의 타입이 실제로는 자식 클래스인 Stock
이나 Bond
, RealEstate
이더라도 알아서 자식 클래스의 파괴자가 호출된다. (다만, 이를 가능하게 하려면 Investment
의 파괴자 ~Investment
가 virtual
로 선언되어 있어야 한다) 이 함수를 사용할 클라이언트는 Investment
라는 자원의 관리를 전혀 신경쓰지 않아도 되니 일반 포인터를 사용하는 것보다 훨씬 편리하다.
한편, 유니크 포인터는 다음 장에서 소개할 공유 포인터로 매우 간편하게 변환된다.
std::shared_ptr<Investment> sp = makeInvestment(params);
팩토리 함수가 만든 포인터를 좀 더 유연하게 사용하고자 하는 경우 공유 포인터를 유용하게 사용할 수 있다. 여기에 대해서는 다음 항목에서 설명한다.
19. 소유권 공유 자원의 관리에는 std::shared_ptr
를 사용하라
공유 포인터 std::shared_ptr
은 개발자에게 메모리 관리를 일임하는 기존 C++의 방식과 쓰레기 수집기(garbage collector)의 장점을 합친 기능이라고 할 수 있다. 수동적인 메모리 관리는 실수의 여지가 있고, 쓰레기 수집은 언제 일어날지 비결정적이라는 단점을 가지지만, 공유 포인터는 공유된 소유권(shared ownership) 의미론을 통해서 이 두 단점을 모두 해결한다. 여러 개의 공유 포인터가 특정 객체를 가리킬 때, 해당 객체를 가리키는 마지막 공유 포인터가 파괴되는 순간 이 객체도 함께 파괴되는 것이다.
이러한 의미론은 참조 횟수(reference count, RC)를 통해 구현된다. 어떤 객체를 가리키고 있는 공유 포인터들이 몇 개가 있는지를 세어 0이 되는 순간 그 객체를 파괴하면 되는 것이다. std::shared_ptr
의 생성자는 RC를 증가시키고, 파괴자는 감소시키는 식으로 RC를 관리한다. 따라서 RC를 담을 메모리 공간이 따로 동적 할당되어 유지된다. 한편, 이렇게 RC를 증가/감소시키는 연산은 스레드에 안전하도록 반드시 원자적(atomic) 연산으로 구현되어야 한다. 그러지 않으면 동시에 두 스레드가 RC를 감소시키는 경우 문제가 될 수 있기 때문이다.
std::shared_ptr<Widget> pw(new Widget()); // 동적으로 RC를 저장할 메모리를 할당 후 1을 저장
auto pw2 = pw; // RC가 1 증가
std::cout << pw.use_count() << std::endl; // RC의 값을 알 수 있음. 2가 출력
유니크 포인터와 마찬가지로, 공유 포인터에도 커스텀 파괴자를 지정해줄 수 있다. 반면 차이점은 커스텀 파괴자가 std::shared_ptr
에서는 타입의 일부가 아니라는 점이다.
// customDel이 unique_ptr 타입의 일부
std::unique_ptr<Widget, decltype(customDel)> upw(new Widget, customDel);
// customDel이 unique_ptr 타입의 일부가 아님!
std::shared_ptr<Widget> spw(new Widget, customDel);
// 다른 파괴자를 가진 shared_ptr를 하나 더 정의
std::shared_ptr<Widget> spw2(new Widget, anotherDel);
// spw와 spw2는 서로 다른 파괴자를 가지고 있지만 같은 타입이므로 같은 컨테이너에 넣을 수 있다.
std::vector<std::shared_ptr<Widget> > vpw{spw, spw2};
제어 블록
앞서 항목 18에서 커스텀 파괴자가 적용된 경우 유니크 포인터는 메모리를 일반 포인터의 두 배로 차지한다고 언급했었다. 반면, 공유 포인터는 항상 일반 포인터의 두 배 메모리를 차지한다. 먼저 피지칭 객체를 가리키는 포인터를 저장하고, 제어 블록이라 불리는 데이터구조로의 포인터를 또 저장하기 때문이다. 그림으로 표현하자면 다음과 같다.
std::shared_ptr<T>
+-----------------------------+ +------------+
| Pointer to T object | -----> | T object |
+-----------------------------+ +------------+
| Pointer to control block | \
+-----------------------------+ \ Control Block
\ +------------------------+
-->| Reference count |
+------------------------+
| Weak count |
+------------------------+
| Other data |
| e.g. custom destructor |
+------------------------+
이 제어블록에는 참조 횟수(reference count)뿐만 아니라 후술할 약한 참조 횟수(Weak count) 등을 저장한다. 또, 커스텀 파괴자에 대한 정보 등의 기타 정보도 이 제어블록에 저장된다. std::shared_ptr
객체가 생성될 때 이러한 제어 블록이 메모리에 할당되어 유지되는 것이다. 따라서 일반 포인터 타입의 변수로부터 공유 포인터를 생성하는 것은 피해야 한다. 다음과 같이 작성할 경우 같은 포인터에 대한 제어 블록이 두 개 생겨 각각 따로 유지되기 때문이다.
auto pw = new Widget; // 일반 포인터
std::shared_ptr<Widget> spw1(pw, customDel); // *pw에 대한 제어 블록 생성
std::shared_ptr<Widget> spw2(pw, customDel); // *pw에 대한 제어 블록이 하나 더 생성!
new
를 사용해 Widget
객체를 직접 만들지 말고 std::make_shared
를 사용하면 이러한 상황을 피할 수 있다. 만일 new
를 꼭 사용하고 싶으면
std::shared_ptr<Widget> spw1(new Widget());
과 같이 new
의 결과를 std::shared_ptr
의 생성자에 바로 전달해야 한다.
20. std::shared_ptr
와 비슷하지만 객체를 잃을 수도 있어야 한다면 std::weak_ptr
을 사용하라
약한 포인터 std::weak_ptr
는 유니크 포인터(std::unique_ptr
)나 공유 포인터(std::shared_ptr
)와는 달리 독립적으로 사용될 수 없다. 대신 약한 포인터는 공유 포인터와 함께 사용하여 공유 포인터를 보강하는 역할을 한다.
먼저 약한 포인터를 어떻게 생성하는지부터 보자. 약한 포인터는 공유 포인터를 통해서만 생성할 수 있다. 그런데, 약한 포인터는 공유 포인터와는 달리 참조 횟수(RC)에 영향을 주지 않는다. 다음 코드를 보자.
auto spw = std::make_shared<Widget>(); // 공유 포인터 객체 생성. RC=1
std::weak_ptr<Widget> wpw(spw); // 약한 포인터 생성, 여전히 RC=1
spw = nullptr; // RC가 0이 되어 객체 파괴. wpw는 피지칭 객체를 잃어버림
std::cout << wpw.expired() << std::endl; // wpw는 만료됨(expired)
이처럼 약한 포인터는 RC에 미치는 영향이 없기 때문에, 만료라는 상황이 가능해지게 된다. 약한 포인터 자신에 아무런 변화가 없더라도, 같은 객체를 참조하고 있던 마지막 공유 포인터가 nullptr
로 바뀌는 순간 해당 객체를 잃게 되기 때문이다.
그렇다면 (1) 약한 포인터가 만료되었는지를 확인한 후 (2) 해당 약한 포인터를 역참조(dereferencing)하는 용법을 생각할 수 있을 것이다. 하지만 약한 포인터는 역참조(dereferencing, *
)가 불가능하기 때문에 이러한 방식으로는 사용이 불가능하다. 약한 포인터를 제대로 사용하는 방법은 약한 포인터 객체를 이용해 공유 포인터 객체를 생성한 후 사용하는 것이다. 여기에는 두 가지 방법이 있는데, (1) lock()
을 사용하는 방법과 (2) 약한 포인터 객체를 인수로 받는 생성자를 사용하는 방법이다. 코드를 보자.
std::shared_ptr<Widget> spw1 = wpw.lock(); // (1) lock()을 사용해 공유포인터 객체 얻음
// 만약 만료 상태였다면 nullptr이 반환됨
std::shared_ptr<Widget> spw2(wpw); // (2) wpw를 공유 포인터 생성자에 인수로 넘겨줌
그렇다면 약한 포인터를 왜 사용하는 것일까? 약한 포인터가 필요한 경우들을 알아보자
약한 포인터의 용도 1: 캐싱
약한 포인터가 유용하게 쓰이는 첫 번째 사례로 다음과 같은 팩토리함수 loadWidget
을 가정하자. Widget
의 id를 인자로 받아서, (항목 18의 조언에 따라) Widget을 가리키는 유니크 포인터를 반환하는 함수이다.
std::unique_ptr<const Widget> loadWidget(WidgetID id)
그런데 이 loadWidget
을 한번 호출하는데 너무 많은 비용이 든다고 하자. 그렇다면 loadWidget
의 결과를 캐싱해두는 새로운 함수 fastLoadWidget
을 만들 수 있을 것이다. 그런데, loadWidget
의 모든 호출 결과를 다 저장해놓는다면 너무 많은 용량이 들 것이니 더 이상 사용되지 않는 Widget
은 삭제될 수도 있어야 한다.
이런 경우, 캐시가 Widget
의 유니크 포인터를 저장해두는 것은 바람직하지 않다. fastLoadWidget
의 호출자에게 Widget
의 소유권이 전달되어야만 하니 이를 캐시에도 저장한다면 이 Widget
객체에 대한 소유권을 두 개의 변수가 가지게 되는 것이기 때문이다.
공유 포인터를 사용하는 것 또한 바람직하지 않다. 이 경우, 캐시가 모든 Widget
에 대한 공유 포인터를 가지고 있을 것이므로 Widget
객체의 RC는 항상 1 이상으로 유지될 것이고, 한 번이라도 생성된 객체는 절대 소멸되지 않을 것이기 때문이다. 이럴 때 사용할 수 있는 것이 바로 약한 포인터이다.
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id){
// 캐시는 Widget에 대한 약한 포인터를 저장해둔다
static std::unordered_map<WidgetID, std::weak_ptr<const Widget> > cache;
auto objPtr = cache[id].lock(); // lock()을 이용해 공유 포인터 객체를 얻는다
if (!objPtr){ // 캐시에 없는 경우
objPtr = loadWidget(id); // loadWidget을 직접 호출하고
cache[id] = objPtr; // 캐시에 저장한다
}
return objPtr;
}
코드를 이렇게 작성하는 경우, 프로그램에서 더 이상 사용되지 않는(=파괴된) Widget
객체들에 대한 std::weak_ptr
은 만료되고, 그렇지 않은 경우에만 캐시에 저장된 약한 포인터들이 사용될 것이다.
약한 포인터의 용도 2: 관찰자(Observer) 디자인 패턴
관찰자(Observer) 디자인 패턴은 관찰자(observer)와 관찰 대상(subject)으로 구성된다. 이 때 관찰 대상들은 자신의 관찰자들을 가리키는 포인터의 배열을 가지고 있어야 한다. 자신의 상태가 변할 경우, 관찰자들에게 이를 통지해주어야 하기 때문이다. 그런데 이 때 파괴된 관찰자에게 상태변화를 통지하는 일은 없어야 한다.
이러한 경우 약한 포인터가 유용하게 사용될 수 있다. 약한 포인터를 사용하는 경우, 관찰자의 수명을 직접 관리하지 않고도 이미 파괴된 관찰자에게 통지가 일어나는 일은 방지할 수 있다. 관찰자를 가리키는 약한 포인터가 만료되었는지를 미리 체크해주기만 하면 되기 때문이다.
약한 포인터의 용도 3: 공유 포인터의 순환고리 방지
약한 포인터가 유용하게 사용되는 마지막 경우는 공유 포인터의 순환고리를 방지하는 것이다. 이중 연결리스트(doubly linked list)와 같은 자료구조를 구현하는 경우 A가 B를 가리키는 포인터를 가지고, B는 다시 A를 가리키는 포인터를 가지는 경우가 종종 발생한다. 그런데, 공유 포인터만을 가지고 이를 구현하는 경우 객체 A와 B 모두가 파괴되지 못한다. 둘이 서로를 가리키고 있으니 RC가 영원히 1 이상으로 유지되는 것이다.
둘 중 한 방향의 포인터는 약한 포인터로 구현해준다면 이러한 문제를 해결할 수 있다. 예를 들어서, A->B 방향의 포인터는 공유 포인터로 유지하되 B->A 방향은 약한 포인터를 사용한다고 하자. 이 경우 객체 A가 파괴된다면 B는 자신이 가진 약한 포인터의 만료 여부를 판단하여, A가 파괴되었음을 인지할 수 있게 된다. 또, B는 A의 RC에 영향을 끼치지 않으므로, A를 가리키는 다른 객체들이 모두 없어지면 A는 자연스럽게 파괴될 수 있게 되는 것이다.
21. new
를 직접 사용하는 것보다 std::make_unique
와 std::make_shared
를 선호하라
std::make_unique
는 C++14부터, std::make_shared
는 C++11부터 표준 라이브러리에 포함된 함수이다. 이들 함수가 하는 일은 매우 단순하다.
- 임의의 개수와 타입을 가진 인수들을 받아서,
- 이들을 생성자로 완벽 전달해 객체를 동적으로 생성한 후,
- 생성된 객체를 가리키는 스마트 포인터를 돌려주는 것이다.
예를 들어서, 다음의 코드는 두 줄씩 똑같은 역할을 한다.
auto upw1(std::make_unique<Widget>()); // make 함수를 사용
std::unique_ptr<Widget> upw2(new Widget); // make 함수를 사용하지 않음
auto spw1(std::make_shared<Widget>()); // make 함수를 사용
std::shared_ptr<Widget> spw2(new Widget); // make 함수를 사용하지 않음
만약 Widget
의 생성자에 전달해주고 싶은 인자가 있다면 make
함수의 괄호 안에 전달하면 된다.
이렇게 make 함수를 사용하면 여러가지 이점이 있다. 먼저, 가장 단순하게는 객체의 타입이름(여기서는 Widget
)을 한번씩만 입력해줘도 된다. 프로그래밍에서 중복은 항상 지양해야 할 점이기 때문이다.
두 번째 이점은 예외 안전성에 있다. std::make_shared
를 사용하지 않고 다음과 같이 기존 방식대로 공유포인터를 만든다고 하자.
std::shared_ptr<Widget> spw1(new Widget);
위 경우, 컴파일러는 이를 다음과 같이 실행되도록 바꾼다.
new Widget
이 실행되어 Widget
객체가 하나 만들어진다.
- 이를 인자로 받아
std::shared_ptr
객체가 생성된다.
그런데, 컴파일러의 최적화로 이 두 과정 사이에 다른 코드가 끼어들게 될 수도 있다. 최악의 경우에, 이 코드가 예외를 일으킨다면 위의 1번 코드만 실행되고 2번 코드는 실행되지 못하게 된다. 즉, 생성된 Widget
객체는 수명 관리를 받지 못하게 되고, 이는 메모리 누수로 이어진다. 반면 make
함수를 사용하여
std::make_shared<Widget>();
라고 하는 경우 위의 1번과 2번 과정 사이에 다른 코드가 끼어들지 못하므로, 위와 같은 일은 염려할 필요가 없다. 또, new
를 직접 사용하는 경우에 메모리 할당이 두 번(Widget
객체를 위해 한 번, 항목 19에서 설명한 제어 블록에 대해 한 번) 일어나는 것에 비해 위의 경우에는 메모리 할당이 한 번만 일어나므로 더 효율적이다.
make
함수의 한계
한편, make_shared
와 make_unique
를 써서는 할 수 없는 일들도 존재한다. 이 항목의 제목이 사용하라가 아니라 선호하라인 이유가 이것이다.
먼저 make
함수로는 커스텀 파괴자를 지정해줄 수 없다. 또, 항목 07에서 설명한 균일(중괄호) 초기화를 사용할 수 없다. 항목 07에서 살펴봤듯이, 아래의 두 초기화 구문은 서로 다른 결과를 만들어낸다.
std::vector<int> v1(10, 20); // 20이 10개 들어있는 벡터
std::vector<int> v2{10, 20}; // 두 원소 10과 20을 가지는 벡터
그런데
auto upv = std::make_unique<std::vector<int> >(10, 20);
를 실행하면 균일 초기화가 아닌 괄호 초기화가 일어나고, 20이 10개 들어있는 벡터가 생성된다. 중괄호 초기화처럼 해석하도록 하고 싶으면 따로
auto initList = {10, 20}; // std::initializer_list 객체 생성
auto upv = std::make_unique<std::vector<int> >(initList);
// 두 원소 10과 20을 가지는 벡터를 가리키는 유니크 포인터가 만들어짐
와 같이 std::initialier_list
객체를 따로 만들어 전달해주어야 한다.
22. Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일에서 정의하라
Pimpl 관용구란
Pimpl 관용구("pointer to implementation" idiom)은 구현 클래스/구조체를 따로 만들어 자료 멤버들을 옮기고, 대신 구현 클래스를 가리키는 포인터를 갖도록 하는 것이다. 예를 들어서 다음과 같은 클래스가 있다고 하자.
// widget.h
class Widget{
public:
Widget();
...
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget
클래스를 사용하고자 하는 사람은 widget.h
헤더파일을 include
할 것이고, 그러면 다시 widget.h
가 include
하는 <string>
, <vector>
, gadget.h
를 include
하는 효과가 나게 된다. 이는 컴파일 시간을 증가시키고, 이러한 헤더들에 대한 불필요한 의존성을 심어준다.
이제 Pimpl 관용구를 적용해보면, 이 클래스를 다음과 같이 바꿀 수 있다.
// widget.h
class Widget{
public:
Widget();
~Widget();
...
private:
struct Impl; // 선언만 하고 정의는 하지 않음; "불완전 타입"
Impl *pImpl;
};
// widget.cpp
struct Widget::Impl { // 위에서 선언한 Impl 구조체의 정의
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget(): pImpl(new Impl) { } // Widget 생성시 Impl 객체 생성
Widget::~Widget() { // Widget 파괴시 Impl 객체 파괴
delete pImpl;
}
위는 C++98을 기준으로 Pimpl 관용구를 적용해본 것이다. 이제 Widget
클래스의 선언은 <string>
, <vector>
, gadget.h
등과 무관하므로 widget.h
에서는 이들 헤더를 include
해줄 필요가 없다. 이러한 include
문을 widget.cpp
로 옮겨줘도 되는 것이다.
모던 C++에서 Pimpl 관용구의 사용
한편, 위의 코드는 new
와 delete
문, 일반 포인터를 사용하는 등에서 모던 C++의 정신에 맞지 않다. 대신 std::unique_ptr
을 사용하여 다음과 같이 바꿔주자.
// widget.h
class Widget{
public:
Widget();
... // 소멸자 선언이 불필요
private:
struct Impl;
std::unique_ptr<Impl> pImpl; // 유니크 포인터로 바꿔줌
};
// widget.cpp
...
Widget::Widget(): pImpl(std::make_unique<Impl>()) {}
유니크 포인터를 사용해주었기 때문에 Impl
객체는 수명이 자동으로 관리되며, 소멸자를 따로 정의해줄 필요가 없어졌다. 그런데, 이를 다음과 같이 클라이언트가 사용하려 하는 순간 컴파일이 되지 않는다.
#include "widget.h"
Widget w;
/opt/homebrew/Cellar/gcc/14.1.0_2/include/c++/14/bits/unique_ptr.h:91:23: error: invalid application of 'sizeof' to incomplete type 'Widget::Impl'
91 | static_assert(sizeof(_Tp)>0,
| ^~~~~~~~~~~
이는 w
가 파괴되는 시점에서 컴파일러가 생성하는 코드 때문에 생기는 문제이다. Widget
의 소멸자를 직접 정의해주지 않았으니 컴파일러가 이를 대신 작성하는데, Widget
의 (자동작성된) 소멸자는 다시 pImpl
의 소멸자를 호출한다. 그런데 대부분의 컴파일러 구현에서 pImpl
의 소멸자는 자신이 가진 Impl
객체에 대한 포인터에 delete
를 적용시키기 전에, 포인터가 불완전 타입을 가리키지는 않는지 점검한다.
이 문제를 해결해주기 위해서는 컴파일러가 std::unique_ptr<Widget::Impl>
을 파괴하는 코드를 생성하기 전에 Widget::Impl
을 완전한 타입으로 만들어주어야 한다. 컴파일러는 타입의 정의를 보아야만 해당 타입을 완전한 타입으로 간주하므로, 우리는 컴파일러가 ~Widget
의 본문을 보기 전에 (widget.cpp
에 있는) Widget::Impl
의 정의를 먼저 보게 해야 한다. 그러려면 다음과 같이 코드를 바꾸면 된다.
// widget.h
class Widget{
public:
Widget();
~Widget(); // 소멸자 ~Widget을 명시적으로 선언하되 정의는 하지 않는다.
...
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
}
// widget.cpp
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget(): pImpl(std::make_unique<Impl>()) { }
Widget::~Widget() {} // ~Widget을 정의한다
// Widget::~Widget() = default;로 써주어도 같은 효과
이렇게 하면 소멸자 ~Widget
을 확인하기 위해 컴파일러가 widget.cpp
를 방문하게 되고, 그 과정에서 Widget::Impl
의 정의를 보게 되어 Impl
은 완전한 타입으로 다뤄지게 되는 것이다.
한편, 항목 17에서 다루었듯, 소멸자를 선언하면 컴파일러는 이동 연산을 자동으로 작성해주지 않는다. 따라서 이동 연산도 명시적으로 선언해주어야 한다. 이 또한 마찬가지로, 선언은 명시적으로 해주고 정의는 widget.cpp
로 옮기면 된다.
// widget.h
class Widget{
public:
Widget();
~Widget();
Widget(Widget&& rhs); // 이동 생성자
Widget& operator=(Widget&& rhs); // 이동 대입 연산자
...
};
// widget.cpp
...
Widget::Widget(Widget&& rhs) = default; // 여기에서
Widget& Widget::operator =(Widget&& rhs) = default; // 정의
Widget
에 대한 복사 연산 또한 우리가 직접 선언해주어야 한다. 복사 연산의 경우 정의까지도 직접 구현해주어야 하는데, std::unique_ptr
처럼 이동 전용 타입이 있는 클래스의 경우 컴파일러가 복사 연산을 자동으로 정의해주지 못하기 때문이다. 헤더 파일 widget.h
에서는 이전까지와 마찬가지로
Widget(const Widget& rhs); // 복사 생성자
Widget& operator=(const Widget& rhs); // 복사 대입 연산자
처럼 선언만 해주고, 구현 파일 widget.cpp
에서 제대로 구현을 해주면 된다.
// widget.cpp
Widget::Widget(const Widget& rhs): pImpl(nullptr){
if(rhs.pImpl) // rhs.pImpl이 nullptr가 아니면
pImpl = std::make_unique<Impl>(*rhs.pImpl); // *rhs.pImpl을 복사생성한 것을
// 가리키는 유니크포인터로 설정
}
Widget& Widget::operator=(const Widget& rhs){
if(!rhs.pImpl) // rhs.pImpl이 널이면 자신의
pImpl.reset(); // pImpl도 널로 설정.
else if (!pImpl) // pImpl이 널이 아니면 rhs에서
pImpl = std::make_unique<Impl>(*rhs.pImpl); // 복사생성해 새 유니크 포인터로 지시
else // pImpl이 null이면 *rhs.pImpl을 복사대입
*pImpl = *rhs.pImpl;
return *this;
}
위에서 다룬 내용을 한 마디로 요약하자면, 이 항목의 제목과 같다. Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일(즉 위의 widget.cpp
)에서 정의하라. 한편, Impl
포인터를 유니크 포인터가 아닌 공유 포인터로 구현하면 어떻게 될지 의문이 생길 수 있을 것이다. 이 글에서는 생략하지만, 결론만 말하자면 이 경우 Widget
의 소멸자를 선언할 필요가 없으며, 이 절의 조언들은 모두 적용되지 않는다.
Chapter 5. 오른값 참조, 이동 의미론, 완벽 전달
이번 장을 시작하기 전에, 매개변수가 항상 왼값이라는 사실을 다시 한번 되짚고 넘어가자. 예를 들어서,
와 같은 함수가 있을 때, 매개변수 w
는 여전히 왼값이다. 다만 그 타입이 Widget
에 대한 오른값 참조일 뿐이다.
23. std::move
와 std::forward
를 숙지하라
std::move
와 std::forward
를 설명하려면 이들이 하는 것을 다루기보다 이들이 하지 않는 것을 다루는 것이 편하다.
std::move
는 아무것도 이동시키지 않는다.
std::forward
가 모든 것을 전달시키지는 않는다
- 실행 시점에서는, 둘 다 아무것도 하지 않는다.
std::move
와 std::forward
는 캐스팅을 수행하는 함수, 정확히는 함수 템플릿이다. 이 때 std::move
는 인수를 무조건 오른값으로 캐스팅하는 반면, std::forward
는 특정 조건이 만족될 때만 오른값으로 캐스팅한다.
오른값으로의 무조건 캐스팅을 수행하는 std::move
std::move
가 하는 일의 이해를 돕기 위해, C++14에서 std::move
를 구현한 예를 보자.
template<typename T>
decltype(auto) move(T&& param){
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}
decltype(auto)
에 대해서는 항목 03을 참고하자.
코드를 한줄 한줄 뜯어보면, std::move
는
T&&
타입의 인수 param
을 받아서
remove_reference<T>::type
을 이용해 T
에서 참조(&
)를 떼고
- 여기에 다시 오른값 참조
&&
를 붙인 타입으로 param
을 캐스팅
하는 역할을 한다는 것을 알 수 있다. 즉, std::move
는 이름과는 다르게 이동을 수행하지 않는다. 대신, 컴파일러에게 어떤 객체가 이동에 적합하다는 것을 말해주는 역할을 한다. 오른값은 곧 이동의 후보이기 때문이다.
std::move
는 const
객체에 사용하지 말아야 한다
한편, 이동을 시킬 수 없는 오른값도 있다. 바로 const
객체이다. 이동을 시키면 원래의 객체가 가진 자원을 빼앗아가야 하는데, 원래의 객체가 const
로 선언되어 있으면 당연히 이것이 불가능하다. 그런데 이에 유의하지 않고 코드를 작성하면 원하지 않은 결과가 아무 경고 없이 발생할 수 있다. 다음과 같은 예시를 보자.
class Annotation{
public:
explicit Annotation(const std::string text): value(std::move(text)) {
...
}
private:
std::string value;
};
위의 Annotation
클래스에서, 생성자는 const std::string
타입의 인자 text
를 받아, 이를 std::move
를 사용해 오른값으로 캐스팅한다. 이렇게 캐스팅된 것을 이용해 std::string
타입의 멤버 변수 value
를 이동 생성하려 시도하고 있다. 그런데 위 코드의 결과로 실제로는 이동 생성이 아니라 복사 생성이 일어난다.
그 이유는 std::string
생성자의 오버로딩 후보로 무엇이 있는지를 살펴보면 알 수 있다. std::string
의 생성자는 다음과 같이 복사 생성자와 이동 생성자가 선언되어 있다.
class string{
public:
...
string(const string& rhs);
string(string&& rhs);
};
Annotation
의 생성자에서, std::string value
는 이 두 오버로딩 후보 중 하나를 이용해 생성되어야 한다. 그런데 인자로 주어지는 std::move(text)
는 const string&&
타입으로, string&&
타입에 바인딩될 수 없다. 앞서 설명했듯 const
타입으로부터는 이동이 불가능하기 때문이다. 그 대신 const string&&
타입은 const string&
으로 변환되어 복사 생성자가 대신 호출된다. 이 과정에서 어떤 경고나 에러도 발생하지 않는다. 따라서 const
객체에 std::move
를 사용하지 않도록 주의해주어야 한다.
위의 예시에서 알 수 있는 점은, 설령 std::move
를 사용하여 이동을 시키고 싶다는 의도를 컴파일러에게 전달해준다고 해도 std::move
는 이동시킬 수 있는 자격을 보장해주지 않는다는 것이다. 즉, std::move
는 아무것도 이동시키지 않으며, 이동 자격을 갖추게 됨도 보장해주지 않는다. 다만, 그 결과가 오른값이라는 점만을 보장해준다.
오른값으로의 조건부 캐스팅을 수행하는 std::forward
std::forward
함수의 동작을 이해하기 위해, 함수의 가장 흔한 용법을 먼저 알아보자. 바로 보편참조 매개변수를 받아서 다른 함수에 전달하는 것이다.
void process(const Widget& lvalArg); // 왼값들을 처리
void process(Widget&& rvalArg); // 오른값들을 처리
template<typename T>
void logAndProcess(T&& param){ // param은 보편참조
// 로그를 처리하는 부분 (생략)
process(std::forward<T>(param));
}
위에서 logAndProcess
는 보편참조 매개변수 param
을 받아 이를 process
에 전달하는 역할을 한다. 그런데 process
는 왼값과 오른값에 대해 오버로딩이 되어 있으니, logAndProcess
에 왼값을 전달하면 왼값 버전의 process
를, 오른값을 전달하면 오른값 버전의 process
를 호출하도록 하고 싶을 것이다.
그런데, 챕터를 시작하면서 다시 한번 언급했듯이 함수 내부에서 함수의 매개변수는 항상 왼값이므로 param
은 왼값으로 취급된다. 이런 상황에서 logAndProcess
함수에 전달된 변수가 왼값인지, 오른값인지를 어떻게 구별할까? std::forward
는 구별할 수 있다. std::forward
는 가 오른값으로 초기화된 경우에만 이를 오른값으로 캐스팅하기 때문이다.
따라서 위의 코드를 실행하면,logAndProcess
에 오른값을 전달한 경우에만 오른값 버전의 process
가 실행되고, 그렇지 않은 경우에는 왼값 버전의 process
가 실행된다. std::forward
라는 이름은 객체를 원래의 성질을 유지한 채 다른 함수에 그냥 넘겨준다(forward)는 데에서 온 것이다.
24. 보편참조와 오른값 참조를 구별하라
코드에 T&&
가 나온다면, 이것이 오른값 참조라고 생각하기 쉽다. 하지만 T&&
에는 오른값 참조라는 의미 외에도 보편참조(universal reference)라는 의미가 있다.
void f(Widget&& param); // 오른값 참조
Widget&& var1 = Widget(); // 오른값 참조
auto&& var2 = var1; // 보편참조
template<typename T>
void f(std::vector<T>&& param); // 오른값 참조
template<typename T>
void f(T&& param); // 보편참조
보편참조는 소스 코드에서는 오른값 참조처럼 보이지만 실제로는 오른값 참조와는 다른, 매우 유연한 형태의 참조자이다. 이는 왼값에도, 오른값에도 바인딩할 수 있으며 const
성이나 volatile
성에도 상관 없이 거의 모든 것에 바인딩시킬 수 있다. 보편참조라는 이름이 붙은 이유이다.
&&
가 등장하는 경우 중 딱 두 가지만이 보편참조에 해당한다. 각각을 살펴보자.
1. 함수 템플릿 매개변수 T&&
보편참조의 첫 번째 경우는 함수 템플릿 매개변수로 T&&
와 같은 형태가 등장할 때이다. 위의 예제 코드에서
template<typename T>
void f(T&& param); // 보편참조
와 같은 경우가 여기에 해당한다. 이때, 참조 선언은 정확하게 T&&
와 같아야 한다. 예를 들어서,
template<typename T>
void f(std::vector<T>&& param); // 오른값 참조! 보편참조가 되려면 정확하게 T&&여야 함.
와 같은 경우는 보편참조가 아니다. 심지어는 T&&
에 const
하나만 붙여도 참조 선언이 보편참조가 아닌 오른값 참조로 변해버리게 된다. 그런데 T&&
의 형태이더라도 반드시 보편참조가 아닐 수도 있다. T&&
의 형태이지만 타입 추론이 일어나지 않을 수도 있기 때문이다. 예로 다음의 코드를 보자.
template<class T, class Allocator = allocator<T>>
class vector{
public:
void push_back(T&& x);
...
};
위 코드에서 push_back
에 등장하는 T
는 vector
클래스가 인스턴스화됨과 동시에 하나로 고정되어 정해진다. 즉, T
는 어떤 값이 push_back
함수에 전달될 때 추론되는 것이 아니다. 이러한 경우에는 T&&
의 형태임에도 불구하고 타입 추론이 일어나지 않기 때문에 보편참조가 아닌 오른값 참조가 된다.
2. auto
보편참조 auto&&
보편참조가 등장하는 다른 하나의 경우는 auto
선언이다.
과 같은 경우가 여기에 해당한다. 1번의 경우와는 타입 추론이 일어난다는 공통점이 있다. 즉, 타입 추론이 일어나지 않는 경우는 모두 보편참조가 아닌 오른값 참조라고 간주해도 된다.
보편참조가 오른값 참조를 나타내는지 왼값 참조를 나타내는지는 보편참조에 전달되는 값(초기치)이 결정한다. 전달되는 값이 왼값이면 왼값 참조, 오른값이면 오른값 참조가 되는 것이다.
template<typename T>
void f(std::vector<T>&& param);
Widget w;
f(w); // param의 타입은 Widget& (왼값 참조)
f(std::move(w)); // param의 타입은 Widget&& (오른값 참조)
사실 이 항목은 전체가 추상화된 설명으로, 실제로는 참조 축약(reference collapsing)이라는 기작에 의해 위와 같은 현상이 일어나는 것이다. 여기에 대해서는 항목 28에서 다룬다.
25. 오른값 참조에는 std::move
를, 보편참조에는 std::forward
를 사용하라
오른값 참조는 이동가능한 객체에만 바인딩된다. 그런데 이렇게 이동가능한 객체를 다른 함수에 넘겨주되, 객체의 오른값 성질을 활용할 수 있도록 넘겨주어야 하는 경우도 있다. 이것이 사실상 std::move
의 존재이유이다.
class Widget{
public:
Widget(Widget&& rhs): // rhs의 타입은 Widget&&이지만, rhs는 함수 안에서는 왼값이다.
: name(std::move(rhs.name)), // rhs가 가리키는 객체의 오른값 성질을 활용할 수 있도록
p(std::move(rhs.p)) { // std::move를 이용해 오른값으로 캐스팅해준다
...
}
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
}
반면 보편참조가 바인딩되는 대상은 이동이 가능할 수도, 아닐 수도 있다. 따라서 보편참조는 오른값이 들어오는 경우에만 오른값으로 캐스팅되어야 하는데, 이는 바로 std::forward
가 하는 일이다. 보편참조(universal reference)를 다른 말로 전달 참조(forwarding reference)라고 부르는 이유도 보편참조를 일반적으로 std::forward
하여 사용하기 때문이다.
class Widget{
public:
template<typename T>
void setName(T&& newName){ // newName은 보편참조
name = std::forward<T>(newName); // 오른값이 들어오면 이동대입, 왼값이면 복사대입
}
}
정리하자면, 오른값 참조는 std::move
와 함께, 보편참조는 std::forward
와 함께 사용하여야 한다. 오른값 참조를 std::forward
와 함께 사용하는 것은 가능하나 불필요하게 코드가 복잡해지며, 보편참조를 std::move
로 사용하는 경우 의도치 않은 이동이 발생할 수 있으니 피해야 한다.
std::move
나 std::forward
는 마지막 한 번에만 적용해줘야 한다
한편, 오른값 참조나 보편참조에 바인딩된 객체가 한 함수에서 여러 번 사용되는 경우도 있을 수 있다. 이러한 경우에는 std::move
나 std::forward
는 해당 참조를 마지막으로 사용할 때만 적용해줘야 한다. 그러지 않으면 해당 변수를 사용하는 도중에 값이 이동하여 그 자리를 미정의 값이 채우게 될 수도 있다. 예를 들어 다음의 코드를 보자.
template<typename T>
void setSignText(T&& text){
sign.setText(text);
auto now = std::chrono::system_clock::now();
signHistory.add(now, std::forward<T>(text)); // text 마지막 사용때만 forward 적용
}
Return-by-value 함수가 오른값/보편참조에 바인딩된 객체를 반환한다면 std::move
/std::forward
를 적용하자
다음과 같은 행렬 덧셈 함수를 보자. lhs
에는 오른값이 들어온다는 사실이 보장되어 있는 경우에 사용할 수 있는 함수이다.
Matrix operator+(Matrix&& lhs, const Matrix& rhs){
lhs += rhs;
return std::move(lhs); // lhs에 std::move 적용
}
위와 같은 경우에는 return
값이 반환하는 lhs
가 오른값 참조에 바인딩되어 있기 때문에, std::move
로 반환값을 한번 감싸주어 오른값으로 캐스팅해주면 복사 대신 이동을 하게 되고, 더 효율적인 코드가 만들어진다. 만약에 Matrix
가 이동을 지원하지 않는 클래스라고 해도 문제가 생기지는 않는다. 반환된 오른값이 복사 생성사에 의해 이동 대신 복사될 뿐이기 때문이다.
오른값 참조 대신 보편참조가 사용될 때도 마찬가지이다.
template<typename T>
Fraction reduceAndCopy(T&& frac){
frac.reduce();
return std::forward<T>(frac); // frac에 std::forward 적용
}
위 코드에서 std::forward
가 없다면 frac
은 무조건 reduceAndCopy
의 반환값으로 복사되어야 했을 것이다. std::forward
로 처리해줌으로써, 적어도 frac
이 오른값일 때에는 복사 연산을 피할 수 있게 되었다.
그런데 이러한 테크닉을 과도하게 적용해서, 함수가 지역변수를 반환할 때에도 이러한 최적화를 적용하려 시도하는 경우가 있다. 예를 들어서,
// makeWidget의 '복사' 버전 (실제로 복사는 일어나지 않음)
Widget makeWidget(){
Widget w;
...
return w; // std::move(w);는 불필요
}
와 같은 코드에서 return w;
를 return std::move(w);
로 바꾸는 것을 시도할 수 있다. 위와 같은 경우는 복사가 일어나지 않도록 컴파일러가 이미 최적화되어 있기 때문이다. 위의 코드처럼 지역변수를 반환값으로 사용하는 경우, 컴파일러는 w
를 애초에 함수 반환을 위한 메모리 주소에 할당해놓는다. 이를 반환값 최적화(return value optimization, RVO)라고 한다. 반환값 최적화가 적용되는 조건은 다음과 같다.
- 그 지역 객체의 타입이 함수의 반환 타입과 같아야 하고
- 그 지역 객체 자체가 바로 함수의 반환값이어야 한다.
위의 코드에서 나오는 w
는 둘 모두를 만족하므로 반환값 최적화가 적용되어 w
의 복사를 피할 수 있게 된다. 반면 사용자가 과도하게 최적화를 시도해 코드를
// makeWidget의 '이동' 버전
Widget makeWidget(){
Widget w;
...
return std::move(w);
}
와 같이 바꾸었다면, 위의 2번 조건을 만족하지 못하게 된다. 지역 객체 w
를 돌려주는게 아니라 std::move(w)
의 결과를 돌려주는 것이 되기 때문이다. 즉 컴파일러가 할 수 있는 최적화를 오히려 방해하게 된다.
혹자는 컴파일러가 최적화를 수행하지 않을 수도 있기 때문에, 그런 가능성에 대비해 '이동' 버전의 코드를 작성해야 한다고 주장할 수도 있을 것이다. 그러나 C++ 표준을 따르는 컴파일러는 반환값 최적화가 가능한 경우라면 반드시 그 지역객체를 오른값으로 취급하도록 되어 있다. 즉, 따로 써주지 않아도 코드가 위의 '이동' 버전으로 바뀌어 처리되는 것이다. 따라서 반환값 최적화의 대상이 될 수 있는 지역 객체를 반환할 때에는 std::move
나 std::forward
를 적용하지 말아야 한다.
26. 보편참조에 대한 오버로딩을 피하라
보편참조가 도움을 줄 수 있는 예시를 들기 위해, 다음과 같은 함수를 보자.
std::multiset<std::string> names; // 전역 변수
void logAndAdd(const std::string& name){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd"); // 시간을 로그에 기록
names.emplace(name); // 이름을 전역 자료구조에 추가
}
위의 코드는 잘 작동하지만, 비효율적이다. name
에 왼값이 들어가는 경우 어차피 복사를 해야 하니 상관이 없으나 오른값이 들어가는 경우 이동을 할 수 있는데도 복사가 일어나게 된다. 오른값 참조를 받는 함수를 따로 만들어 오버로딩 할 수도 있겠지만, 보편참조를 이용해서 한번에 해결해주자.
template<typename T>
void logAndAdd(T&& name){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd"); // 시간을 로그에 기록
names.emplace(std::forward<T>(name)); // 이름을 전역 자료구조에 추가
}
std::string petName("Darla");
logAndAdd(petName); // T가 std::string&로 추론, 복사 발생
logAndAdd(std::string("Darla")); // T가 std::string&&로 추론, 이동 발생
여기까지는 아무런 문제가 없다. 그런데, logAndAdd
의 다른 오버로딩 버전을 만들고 싶다고 하자. 이번에는 이름을 logAndAdd
에 직접 넣어주는게 아니라, 이름의 인덱스에 해당하는 정수를 logAndAdd
에 전달하면 색인에서 이름을 찾아 names
에 추가해주도록 구현해주고 싶다고 하자.
void logAndAdd(int idx){ // int 타입을 받는 오버로딩 버전
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}
logAndAdd(std::string("Darla")); // 보편참조 버전 호출
logAndAdd(42); // int 버전 호출
int
타입을 넣었을 때는 위 코드가 잘 작동한다. 문제는 int
가 아닌 다른 정수 타입이 전달되었을 때 발생한다.
short nameIdx = 42;
logAndAdd(nameIdx); // 오류!
nameIdx
는 그 자체로는 int
가 아니며, int
로 승격(promotion)되어야 logAndAdd(int idx)
에 들어갈 수 있게 된다. 그런데 오버로딩 해소 규칙에 의해, 정확한 매칭이 승격을 통한 매칭보다 우선시되므로 위의 경우에는 int
버전이 아닌 보편참조 버전의 logAndAdd
가 호출된다. 결국 names.emplace
의 호출에서 short
를 인수로 받는 오버로딩 함수가 없으니 에러가 발생한다.
책의 표현을 빌리자면, 보편참조를 받는 템플릿 함수는 C++에서 가장 욕심이 많은 함수이다. 거의 모든 타입의 인수와 정확히 매칭되기 때문이다. 이는 프로그래머의 예상보다 훨씬 많은 타입의 인수들을 빨아들이는 결과를 낳게 된다. 따라서 보편참조와 오버로딩을 결합하는 것은 피해야 한다.
달 생성자의 오버로딩
완벽전달 생성자, 즉 클래스의 생성자를 보편참조를 이용하여 선언한 경우는 더 나쁜 결과가 초래된 수 있다. 다음과 같은 클래스가 있다고 하자.
class Person{
public:
template<typename T>
explicit Person(T&& n): name(std::forward<T>(n)) {} // 완벽전달 생성자
explicit Person(int idx): name(nameFromIdx(idx)) {}
private:
std::string name;
}
위 클래스 Person
의 경우 생성자가 보편참조 버전(즉 완벽전달 생성자)과 int
타입을 받는 버전의 두 가지가 오버로딩되어 존재한다. 그런데, 문제는 C++ 컴파일러가 복사생성자와 이동생성자를 자동으로 작성한다는 점이다. 이는 심지어 템플릿화된 생성자가 복사 생성자나 이동 생성자에 해당하는 모습으로 인스턴스화될 수 있는 경우에도 마찬가지이다. 따라서, 실제 Person
클래스에는 위에서 선언해준 두 생성자 외에도 컴파일러가 자동작성한
Person(const Person& rhs); // 복사 생성자
Person(Person&& rhs); // 이동 생성자
의 두 생성자가 공존하게 된다. 이는 다음과 같은 코드에서 문제를 일으킨다.
Person p("Nancy");
auto cloneOfP(p); // 복사 생성자가 아닌 완벽전달 생성자가 호출
p
를 복사해 cloneOfP
를 만드려고 했는데, 컴파일 에러가 발생하게 되는 것이다. 그 이유는 이러하다. p
의 타입은 const Person
이 아니라 Person
이므로, (컴파일러가 작성한) 복사 생성자 Person(const Person& rhs);
와 타입이 정확하게 부합하지 않는다. 따라서 복사 생성자가 아닌, "T
가 Person
으로 추론된 완벽전달 생성자"에 매칭되어 완벽전달 생성자가 호출되고, std::string
타입인 멤버 변수 name
은 Person
타입으로 복사생성될 수 없으니 컴파일 에러가 나는 것이다.
한편, 위 코드의 첫 줄을 const Person p("Nancy");
로 바꾸면 정상적으로 복사 생성자가 호출된다. 오버로딩 해소 규칙 중에는 템플릿 함수와 비템플릿 함수 중 비템플릿 함수를 우선시한다는 규칙도 포함되어 있기 때문이다.
클래스에 상속이 개입될 때는 이러한 문제가 더욱 심각해진다. Person
을 상속받는 SpecialPerson
클래스가 있다고 하자. 복사, 이동생성자를 다음과 같이 통상적인 방식으로 구현하였다.
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) : Person(rhs)
{ ... } // 복사생성자
SpecialPerson(SpecialPerson&& rhs) : Person(std::move(rhs))
{ ... } // 이동생성자
};
위의 코드에서 SpecialPerson
의 복사, 이동생성자들은 Person
의 복사, 이동생성자가 아닌 완벽전달 생성자를 호출하게 된다. 결과적으로 SpecialPerson
타입을 받는 std::string
의 생성자는 없으니 컴파일 에러가 발생한다.
요약하자면, 보편 참조를 받는 함수가 있다면 그 함수에 대한 오버로딩은 피하는 것이 좋다. 그렇다면 보편 참조로 대부분의 인수 타입들을 처리하고, 나머지는 특별한 방법으로 처리하고 싶은 경우에는 어떻게 해야 할까? 이에 대해서는 바로 다음 항목에서 다룬다.
27. 보편참조에 대한 오버로딩 대신 사용할 수 있는 기법들을 알아두라
오버로딩 포기하기
애초에 오버로딩을 포기하고, 보편참조를 인수로 가지는 함수와 같은 역할을 하더라도 함수의 이름을 다르게 짓는 것이 가장 간단한 해결방법이다. 하지만 생성자의 경우에는 이름이 고정되어 있기 때문에 오버로딩이 불가피하다.
const T&
매개변수 사용하기
함수가 보편참조 대신에 const T&
를 받도록 하고 오버로딩을 하는 것 또한 방법이 될 수 있다. 다만 이 경우, 함수 내부에서 인수를 수정해야 하는 경우 인수가 오른값이었다고 하더라도 복사 연산이 필요하기 때문에 비효율성이 발생한다(반면 보편참조였더라면 std::forward
를 통해 복사 없이 이동시킬 수 있다). 효율성을 조금 타협하더라도 예상치 못한 상황을 피할 수 있게 된다는 데에 의의가 있다.
값 전달(call-by-value) 방식의 매개변수 사용하기
뒤의 항목 41에 나오겠지만, 애초에 복사될 것이 확실한 객체는 값으로 전달하는 것을 고려하는 것이 좋다. 예를 들어 다음 코드를 보자.
class Person {
public:
explicit Person(std::string n) // T&& 생성자 대신 사용
: name(std::move(n)) {}
explicit Person(int idx) : name(nameFromIdx(idx)) {}
private:
std::string name;
}
코드를 위와 같이 작성하는 경우,
- 생성자에 오른값이 전달되는 경우
n
으로 이동대입된 후 std::move(n)
의 결과(n
자신)가 name
으로 이동한다. 즉 이동이 두 번 일어난다.
- 반면 완벽전달 생성자의 경우 이동이 한 번만 일어난다
- 생성자에 왼값이 전달되는 경우,
n
으로 복사가 먼저 일어난 후 std::move
에 의해 오른값으로 캐스팅된 후, name
으로 이동이 일어난다. 즉 복사 한 번과 이동 한 번이 일어난다.
- 반면 완벽전달 생성자의 경우
n
은 std::string&
형을 갖게 되고 이것이 완벽전달되어 name
변수에 직접 전달되니 복사만 한 번 일어난다.
즉 완벽전달 생성자가 더 효율적이기는 하나 혼동의 여지를 막을 수 있다.
꼬리표 배분
꼬리표 배분(tag dispatch)라는 테크닉을 사용하는 방법도 있다. 꼬리표 배분이란 std::remove_reference<T>::type
(C++14에서는 std::remove_reference_t<T>
)을 이용해 참조 한정사를 모두 떼고 오버로딩 함수에 true
, false
를 받는 두 번째 매개변수를 만드는 것이다.
앞선 항목 26의 예시를 다시 가져오자. logAndAdd
라는 이름에 (1) 보편참조를 인수로 받는 버전과 (2) int
형의 index
를 인수로 받는 버전을 오버로딩하고싶다고 할 때, 다음과 같이 꼬리표 배분을 적용할 수 있다.
// logAndAddImpl: logAndAdd에서 실제로 작업을 실행하는 부분
template<typename T>
void logAndAddImpl(T&& name, std::false_type){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
void logAndAddImpl(int idx, std::true_type){
logAndAdd(nameFromIdx(idx));
}
void logAndAdd(T&& name){
// T에서 참조한정사를 다 뗀게 정수형인지를 판별해 logAndAddImpl에 인자로 넘겨줌
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference_t<T>>()
);
}
이 때 컴파일 타임 오버로딩 버전들이 선택될 수 있도록 하기 위해서, std::true_type
과 std::false_type
을 이용하였다.
std::enable_if
를 이용한 템플릿의 인스턴스화 제한
std::enable_if
를 이용하면 템플릿이 특정 조건을 만족하는 타입에 대해서만 인스턴스화되도록 할 수 있다. 코드는 다음과 같다.
class Person {
public:
template <typename T,
typename = typename std::enable_if<조건>::type>
explicit Person(T&& n);
...
};
따라서, 완벽전달 생성자에 넘겨진 타입이 Person
인 경우에는 템플릿을 비활성화시키면 문제가 해결된다. 우선은 다음과 같이 코드를 작성하면 된다.
class Person {
public:
template <
typename T,
typename = typename std::enable_if<
!is_same<Person,
typename std::decay<T>::type
>::value
>::type
>
explicit Person(T&& n);
...
};
이 때 std::decay
는 const
, volatile
및 참조 한정사를 모두 제거하는 역할을 한다. std::is_same
을 이용해 한정사를 모두 제거한 T
가 Person
과 동일한지 비교해주는 것이다. 그런데, 아직은 문제가 있는데 바로 Person
을 상속한 클래스가 존재하는 경우이다. 상속을 고려한 최종적인 코드는 다음과 같다.
class Person {
public:
template <
typename T,
typename = typename std::enable_if<
!std::is_base_of<Person,
typename std::decay<T>::type
>::value
>::type
>
explicit Person(T&& n);
...
};
is_same
대신에 is_base_of
를 사용함으로써 T
(에 참조 한정사를 제거한 것)이 Person
의 자식 클래스일 가능성 또한 고려해주었다.
28. 참조 축약을 숙지하라
다음과 같이 보편참조를 인수로 받는 함수가 있다고 하자
template<typename T>
void func(T&& param);
param
에 왼값이 들어가면 T
는 왼값 참조로 추론되지만, 오른값이 전달되면 비참조 타입으로 추론된다. 그런데, C++에서 참조에 대한 참조는 위법이다. param
에 Widget
의 왼값이 들어간다고 가정시 템플릿은
void func(Widget& && param);
으로 인스턴스화되어야 한다. 어떻게 된 일일까? 이는 참조 축약(reference collapsing)이 있기 때문에 가능하다. 참조에 대한 참조는 원래는 위법이지만, 특별한 몇몇 상황에서는 참조에 대한 참조를 산출하는 것이 가능하고, 이 때는 참조 축약의 규칙이 적용된다. 그 규칙은 바로 다음과 같다.
두 참조 중 하나라도 왼값 참조이면 결과는 왼값 참조이다. 둘 다 오른값 참조이면 결과는 오른값 참조이다.
템플릿 인스턴스화는 참조 축약이 적용되는 "특별한 몇몇 상황" 중 하나이다. 따라서 위의 예시에서 Widget& &&
는 Widget&
로 축약되고, 평범한 왼값 참조가 얻어지는 것이다.
std::forward
가 작동할 수 있는 것도 바로 이 참조 축약 때문이다. std::forward
의 (C++14 버전) 간단한 구현을 보면 다음과 같다.
template<typename T>
T&& forward(remove_reference_t<T>& param){
return static_cast<T&&>(param);
}
f
라는 템플릿 함수가
template<typename T>
void f(T&& fParam){
...
someFunc(std::forward<T>(fParam));
}
와 같은 식으로 std::forward
를 사용하고 있다고 하자.
fParam
이 Widget
타입의 왼값이라면 T
는 Widget&
로 추론된다. 그러면 std::forward
의 T=Widget&
인 인스턴스가 만들어지게 된다. 즉, 반환 형식은 Widget& &&
로, 참조 축약에 의해서 Widget&
타입의 반환값을 갖게 된다. 즉, 결과적으로 std::forward
는 이 경우 아무 효과도 없고 왼값을 넘겨주면 왼값 참조가 반환되는 것이다.
fParam
이 Widget
타입의 오른값이라면 T
는 그냥 Widget
으로 추론된다. 즉, std::forward
는 T
가 Widget
인 버전으로 인스턴스화되고, 이는 다음과 같은 모습이다.
Widget&& forward(Widget& param)
{ return static_cast<Widget&&>(param); }
즉 fParam
이 오른값이라면 오른값이 someFunc
에 전달된다.
템플릿 인스턴스화 이외에도 참조 축약이 적용되는 문맥은 세 가지가 있다.
1. auto
변수에 대한 타입 추론
위 예시에서 Widget& && w1 = w;
와 같이 참조 축약의 문맥이 나타난다(항목 02 참고). 따라서 w1
은 Widget&
타입이 된다. 반면,
auto&& w2 = widgetFactory(); // Widget 객체를 반환하는 함수
와 같이 오른값으로 초기화가 일어나는 경우에는 auto
가 Widget
으로 추론된다. 따라서 w2
의 타입은 Widget&&
가 되는 것이다.
2. typedef
와 별칭 선언
typedef
가 지정 또는 평가되는 도중에 참조에 대한 참조가 발생한다면 참조 축약이 개입하게 된다. 예시를 보자.
template<typename T>
class Widget{
public:
typedef T&& RvalueRefToT;
...
}
위 상황에서, Widget<int&>
와 같이 왼값 참조 형식으로 인스턴스화를 한다면, T=int&
이므로 RvalueRefToT
는 int& &&
타입이 된다. 이 경우 참조 축약이 발생하여 int&
로 바뀌게 된다.
3. decltype
사용
마지막으로, decltype
이 사용될 때도 참조 축약이 일어난다. 컴파일러는 decltype
이 등장하는 형식을 분석하는 도중에 참조가 발생하면 참조 축약이 등장해 이를 제거한다.
29. 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라
이동 의미론은 C++11에서 등장한 가장 중요한 기능이라고 할 수 있다. 그 덕분에 비싼 복사 연산 대신에, (상대적으로) 저렴한 이동 연산을 사용할 수 있기 때문이다. 그러나 이 항목의 조언은 이동 의미론에 대해 너무 환상을 가지지는 말라는 것이다.
- 먼저, 표준 라이브러리와 달리 사용자가 다루는 라이브러리들은 타입이 이동 의미론의 장점을 살리도록 구현되지 않았을 수 있다. 물론 컴파일러가 이동 연산들을 자동작성했을 수도 있지만, 항목 17에서 다룬 것처럼 복사, 이동 연산, 소멸자 중 하나라도 있으면 자동작성은 일어나지 않는다.
- 타입이 이동을 지원하는 경우에도, 이동이 복사에 비해 가져다주는 이득이 생각보다 크지 않을 수 있다. 예시로
std::array
가 있다. 다른 표준 컨테이너들은 일반적으로 자료를 힙에 저장하고, 컨테이너 객체에는 자료를 가리키는 포인터만을 저장한다. 따라서 이동 연산은 포인터를 복사하는 것만큼이나 저렴해진다. 반면 std::array
의 경우, 데이터를 실제 std::array
객체 안에 저장하기 때문에 이러한 효과를 볼 수 없다. std::string
의 경우 데이터를 평소에는 힙에 저장하지만, 작은 문자열 최적화를 적용해 문자열의 길이가 짧을 때는 std::string
객체 안의 버퍼에 저장하기도 한다. 이러한 경우에도 이동 연산이 특별히 저렴하지 않게 된다.
- 타입이 이동을 지원하고 이동 연산이 빠른 경우라고 할지라도, 겉으로 보기에 이동이 일어나야 할 상황에서 복사가 일어나기도 한다. 표준 라이브러리의 컨테이너들이 대표적인데, 이들 중 일부는 이동이 예외를 던지지 않음이 확실한 경우(
noexcept
에 대한 항목 14를 보자)에만 복사를 이동으로 대신한다.
따라서 C++11을 이용한 코드를 작성할 때, 이 항목의 제목과 같이 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하고 코드를 작성하는 것이 좋다.
30. 완벽 전달이 실패하는 경우들을 잘 알아두라
(작성중)