Chapter 5. Rvalue References, Move Semantics, and Perfect Forwarding
23. Understand std::move and std::forward
C++11 에 등장한 이동 의미론(move semantic)을 수행하는 데에 있어 이를 수행하는 방법으로 크게 두가지, std::move 와 std::forward 함수가 존재합니다. std::move가 모든 것을 이동하지는 않고, std::forward가 모든 것을 전달하지는 않는다는 관점으로 접근하면 이해하기 쉽습니다.
[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);
}
함수의 반환 형식에 있는 &&를 통해, std::move는 오른값 참조를 돌려줍니다. 만약 T가 왼값인 경우 T&&도 왼값으로 캐스팅되기에, 이를 막기 위해 형식 특질을 이용하여 고정시킵니다. 결론적으로 std::move 함수가 오른값으로의 캐스팅만을 수행하는데, std::move 함수를 사용하더라도 이동이 되지 않는 경우가 있습니다.
class Annotation {
public:
explicit Annotation(const std::string text)
: value(std::move(text))
{ ... }
...
private:
std::string value;
};
위의 코드에서 생성자를 효율적으로 구성하기 위해 매개변수 text를 value로 이동시키려고 했지만 실제로는 복사가 수행됩니다. 이는 전체의 과정에서 const가 유지되는데 const의 여부가 이동에 영향을 미치기 때문입니다.
class string {
public:
...
string(const string& rhs);
string(string&& rhs);
...
};
std::move(text)의 결과는 const std::string 형식의 오른값이며, 이 값은 const 타입이기에 이동 생성자에 전달이 불가능하지만, 복사 생성자에는 전달이 가능합니다. const 에 대한 왼값 참조에다 const 오른값을 묶는 것이 허용되기 때문입니다. 따라서 복사생성자가 호출되게 됩니다. 여기서 얻게되는 교훈은 이동을 지원할 객체는 const 로 선언하면 안된다는 점과 std::move는 실제로 이동하지 않을 뿐더러 그저 오른값을 내뱉는다는 사실입니다.
[std::forward]
std::forward와 std::move와 비슷하게 수행되는데, std::forward는 조건부 캐스팅을 한다는 점이 다릅니다. 가장 흔한 시나리오는 보편 참조 매개변수를 받아 이를 전달하는 경우입니다.
void process(const Widget& lvalArg);
void process(Widget&& rvalArg);
template<typename T>
void logAndProcess(T&& param)
{
auto now = std::chrono::system_clock::now();
makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}
Widget w;
logAndProcess(w);
logAndProcess(std::move(w));
logAndProcess는 왼값과 오른값에 대해서 중복적재가 되어있고, 이를 시험해보기 위해 logAndProcess 함수에 전달하는 인자를 좌측값과 우측값으로 보내는 경우로 나누어보았습니다.
- w 왼값 -> param 왼값 참조 -> T 왼값 참조 -> std::forward<Widget &> -> process(const Widget&)
- w 오른값 -> param 오른값 참조 -> T 오른값 참조 -> std::forward<Widget &&> -> process(Widget&&)
이처럼 보편참조를 사용하는 경우에는 std::forward를 사용해야 안전하며 우리가 받은 값의 형태를 그대로 전달 해줄 수 있습니다. 인수가 왼값과 오른값 둘 중 어느 쪽으로 초기화 되었는지에 대한 정보는 템플릿 매개변수 T에 부호화되어 있습니다.
std::forward로 모든 것을 할 수 있지만, std::move 역할을 대신하려면 상당히 피곤해집니다. 따라서 상황에 따라 둘다 맞춰쓰는 것이 좋습니다.
결론적으로, std::move와 std::forward는 그냥 캐스팅을 수행하는 함수입니다. std::move는 주어진 인수를 무조건 우측값으로 캐스팅하고, std::forward는 특정 조건이 만족될 때에만 그런 캐스팅을 수행합니다.
이것만은 잊지 말자!
- std::move는 오른값으로의 무조건 캐스팅을 수행한다. std::move 자체는 아무것도 이동하지 않는다.
- std::forward는 주어진 인수가 오른값에 묶인 경우에만 그것을 오른값으로 캐스팅한다.
- std::move와 std::forward 둘 다, 실행시점에서는 아무 일도 하지 않는다.
24. Distinguish universal references from rvalue references
T&&에는 오른값 참조와 보편적 참조, 두 가지 의미가 존재합니다. 오른값 참조는 오른값에만 묶이며 이동의 원본이 될 수 있는 객체를 지정합니다.
보편적 참조는 우측값 참조 또는 좌측값 참조 중 하나로, 우측값에 묶을 수도 있고 좌측값에 묶을 수도 있습니다. 더 나아가서, 이런 참조는 const 객체에 묶을 수도 있고 비const 객체에 묶을 수도 있으며, 마찬가지로 volatile 객체에 묶을 수도 있고 비volatile 객체에 묶을 수도 있습니다. 왼값과 오른값을 둘 다 적절히 전달하기 위해서 보편 참조에는 거의 항상 std::forward를 적용해야 합니다.
template<typename T>
void f(T&& param);
auto&& var2 = var1;
위와 같이 보편 참조로서 수행되려면 반드시 형식 연역이 관여해야 하며, 딱 "T&&"의 형태이어야만 합니다. 마지막으로, 템플릿 안에서는 형식 연역이 반드시 일어나야만 합니다. 이에 해당하는 예시로 std::vector의 다음과 같은 push_back 멤버 함수를 사용하겠습니다.
template<typename T>
void f(std::vector<T>&& param);
template<typename T>
void f(const T&& param);
template<class T, class Allocator = allocator<T>>
class vector {
public:
void push_back(T&& x);
...
};
먼저 위의 경우는 T&& 형태가 아니라 std::vector<T>에 대한 오른값 참조가 됩니다. 두번째의 경우는 const 한정사를 통해서 수정불가능해지기 때문에 이동이 지원되지 않기에, 보편 참조로서 작동하지 않습니다. 마지막의 경우는 push_back의 매개변수는 보편 참조가 요구하는 형태이지만, 이 경우에는 vector 클래스가 이전에 인스턴스화되어 push_back의 형식을 결정하기 때문에 형식 연역이 전혀 일어나지 않는다. 반면, std::vector의 멤버 함수들 중 이와 비슷한 emplace_back 멤버 함수는 실제로 형식 연역을 사용합니다.
template<class T, class Allocator = allocator<T>>
class vector {
public:
template<class... Args>
void emplace_back(Args&&... args);
...
};
이 경우에는 형식 매개변수 T와 인자 Args가 독립적이고, Args가 보편 참조의 형태를 띄고 있으며, T에서 vector 클래스가 인스턴스화 되지만, emplace_back은 Args에서 형식 연역이되고 있기에 Args는 보편 참조입니다.
앞서 말했듯 auto 변수 역시 보편 참조가 될 수 있습니다. 특히, C++14에서 람다 표현식에 auto&&를 사용가능해지면서 범위가 더 넓어졌습니다.
auto timeFuncInvocation =
[](auto&& func, auto&&... params)
{
std::foward<decltype(func)>(func)(
std::forward<decltype(params)>(params)...
);
};
이것만은 잊지 말자!
- 함수 템플릿 매개변수의 형식이 T&& 형태이고 T가 연역된다면, 또는 객체를 auto&&로 선언한다면, 그 매개변수나 객체는 보편 참조이다.
- 형식 선언의 헝태가 정확히 형식&&가 아니면, 또는 형식 연역이 일어나지 않으면, 형식&&은 오른값 참조를 뜻한다.
- 오른값으로 초기화되는 보편 참조는 오른값 참조에 해당한다. 왼값으로 초기화되는 보편 참조는 왼값 참조에 해당한다.
25. Use std::move on rvalue references, std::forward on universal references
오른값 참조는 이동할 수 있는 객체에만 묶이기에 어떤 매개변수가 오른값 참조라면, 객체를 이동할 수 있다는 의미입니다. 객체를 이동하기 위해 오른값 참조로 캐스팅 해주는 함수가 std::move 함수입니다. 반면에 보편 참조는 오른값과 왼값에 모두 대응해야하기에, 오른값일때만 오른값으로 캐스팅해주어야하는데 이 역할을 하는 함수가 std::forward입니다.
이 둘을 서로 바꿔서 사용하는 것은 상당히 좋지 않습니다. 먼저 보편 참조에 std::move를 사용하는 경우를 보겠습니다.
class Widget {
public:
template<typename T>
void setName(T&& newName) // 보편 참조
{ name = std::move(newName); } // 보편 참조에 move 함수를 사용
...
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};
std::string getWidgetName(); // 팩터리 함수
Widget w;
auto n = getWidgetName();
w.setName(n);
...
이 코드는 지역변수 n을 setName 함수에 전달해주었다. 작성자는 n 을 읽기 전용으로 취급하여, std::move를 통해 이동을 수행하였습니다. 하지만 setName 함수가 끝난 이후에는 n 은 미지정 값이 되기에 이후에 n이 더 사용된다면 문제가 발생할 수 있습니다.
이때 setName의 매개변수를 보편 참조로 선언한 것이 문제이고 이를 아래와 같이 overloadding을 한다면 문제가 사라질 것이라고 믿을 수도 있습니다.
class Widget {
public:
void setName(const std::string& newName)
{ name = snewName; }
void setName(std::string&& newNmae)
{ name = std::move(newName); }
...
};
위 코드가 위의 문제는 사라지지만 3가지 단점이 발생합니다. 첫째는 유지보수해야 할 소스 코드의 양이 늘어나며, 둘째는 효율성이 떨어질 수 있습니다. 만약 문자열 리터럴을 파라미터로 전달하는 경우에는 setName에 전달하기 위하여 임시 std::string 객체를 생성해야 하고, 이는 또 std::move를 통해 이동을 하면서 비용이 비싸집니다. 결국 불필요한 객체 생성과 소멸이 일어납니다. 만약 보편 참조를 사용했더라면 문자열 리터럴이 name 에 대한 할당 연산자의 인수로 쓰였을 것입니다.
마지막으로 세번째 문제는 코드양이 기하급수적으로 증가한다는 것이다. 왼값과 오른값를 구별해야하며, 여러 개를 받는 경우의 수도 상정해야하기 때문에 전부 만들어내는 것은 힘듭니다. 따라서, 이런 함수들은 내부적으로 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));
}
만약 함수가 결과를 값으로 돌려주고 그 값이 오른값 참조이거나 보편 참조에 묶인 객체라면 반환문에 std::move나 std::forward를 사용하는 것이 바람직할 것입니다. 만약 그냥 결과값을 반환한다면 값을 복사해서 반환하지만, std::move를 이용한다면 이동을 시켜 좀 더 효율적으로 만들 수 있을 것 입니다. 이 같은 접근방식을 모든 곳에서 사용할 수 있을 것이라 생각하지만 이는 틀렸습니다.
Widget makeWidget()
{
Widget w;
...
return std::move(w);
}
언뜻 보기에는 복사를 이동으로 바꾸었으니 성능이 향상될 것이라고 기대하지만, 위 코드와 같이 지역 변수를 반환하는 함수를 위하여 RVO(return value optimization)를 통해 반환 타입과 같은 같은 타입을 가지는 지역변수를 사용하는 경우, 반환값을 위해 마련한 메모리 안에 객체를 생성하여 w의 복사를 제거할 수 있도록 컴파일러가 최적화시켜줍니다.
그런데 만약 std::move 를 사용하게 되면 반환하는 객체의 타입이 Widget 이 아닌 std::move(w)가 되어버리기에, 복사 제거의 조건을 만족하지 않게 되어 최적화가 일어나지 않아 성능 하락으로 이어집니다. 만약 복사 제거가 일어나지 않아도 이는 좋지 않은 방법인데, 반환되는 객체는 반드시 오른값으로 취급되어야만 합니다. 이는 컴파일러가 암묵적으로 오른값으로 취급하기 때문에, std::move를 적용한다고 해서 컴파일러에게 도움이 되지는 않습니다.
이것만은 잊지 말자!
- 우측값 참조나 보편 참조가 마지막으로 쓰이는 지점에서, 우측값 참조에는 std::move를, 보편 참조에는 std::forward를 적용하라.
- 결과를 값 전달 방식으로 돌려주는 함수가 우측값 참조나 보편 참조를 돌려줄 때에도 각각 std::move나 std::forward를 적용하라.
- 반환값 최적화의 대상이 될 수 있는 지역 객체에는 절대로 std::move나 std::forward를 적용하지 말아야 한다.
26. Avoid overloading on universal references
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);
logAndAdd(std::string("Persephone"));
logAndAdd("Patty Dog");
보편 참조를 받게 하고, 이를 std::forward를 통해 emplace에 전달하면, 효율적으로 코드가 돌아가도록 할 수 있습니다. 만약 이 상태에서 logAndAdd를 다음과 같이 overloadding한다고 생각해봅시다.
void logAndAdd(int idx)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}
이 함수는 통상적인 문제에 대해서는 적절하게 동작하지만 int 가 아닌 short 를 전달하는 경우 문제가 발생합니다. 우리가 기대한 것은 short가 int로 암시적 변환이 일어난 후에 logAndAdd(int idx)가 수행되기를 바라지만 실제로는 short에 대한 템플릿이 객체화되면서 short을 이용하여 string 객체를 생성하는데에 실패하면서 에러를 발생시킵니다. 위와 같은 문제를 피하는 방법 중 하나는 완벽 전달 생성자를 작성하는 것입니다.
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;
};
위와 같은 상황에서도 int 이외의 정수 형식을 넘겨주면 보편 참조를 받는 생성자가 호출되고 컴파일에 실패하는데 이전보다 문제가 심각해졌습니다. 그 이유는 자동으로 복사 생성자와 이동 생성자가 생성되기 때문입니다.
class Person {
public:
template<typename T>
explicit Person(T&& n)
: name(std::forward<T>(n)) {}
explicit Person(int idx);
Person(const Person& rhs);
Person(Person&& rhs);
...
};
Person p("Nancy");
auto cloneOfP(P); // 컴파일 에러
위의 코드에서는 복사 생성자가 호출되는 것이 아닌 Person을 매개변수로 받는 완벽 전달 생성자를 호출하여 ㅐPerson로부터 string 객체를 생성하는 방법 없어 에러가 발생시킵니다. 이는 컴파일러의 추론 따라가보면 알 수 있는데, cloneofP는 const 가 아닌 왼값으로 초기화됩니다. 따라서 const를 받는 복사 생성자보다 비const Person 형식으로 템플릿화된 생성자로 추론하게 됩니다. 이를 해결 하기 위해서는 p 객체를 const 객체로 만들어 주면 복사 생성자를 호출하게 될 것 입니다.
하지만 이는 모든 문제를 해결하기에는 부족합니다. 템플릿화된 생성자 역시 복사 생성자와 동일한 서명으로 객체화될 수 있기 때문입니다. 심지어 상속이 관여한다면 이는 더 복잡해집니다.
class SpecialPerson: public person {
public:
SpecialPerson(const SpecialPerson& rhs)
: Person(rhs)
{ ... }
SpecialPeron(SpecialPerson&& rhs)
: Person(std::move(rhs))
{ ... ]
};
복사생성자와 이동생성자 모두 person 클래스의 완벽 전달 생성자를 호출하는데, 이는 SpecialPerson 형식의 객체가 전달되기 때문에 완벽 전달 생성자가 정확히 부합하게 되기 때문입니다. 이에 대한 해결법은 다음 항목에서 소개하도록 하겠습니다.
이것만은 잊지 말자!
- 보편 참조에 대한 중복적재는 거의 항상 보편 참조 중복적재 버전이 예상보다 자주 호출되는 상황으로 이어진다.
- 완벽 전달 생성자들은 특히나 문제가 많다. 그런 생성자는 대체로 비const 좌측값에 대한 복사 생성자보다 더 나은 부합이며, 기반 클래스 복사 및 이동 생성자들에 대한 파생 클래스의 호출들을 가로챌 수 있기 때문이다.
27. Familiarize yourself with alternatives to overloading on universal references
이번 항목에서는 보편 참조에 대한 overloading이 아닌 기법 또는 보편 참조에 대한 중복적재가 부합할 수 있는 인수들의 형식을 제한함으로써 보편 참조가 지닌 문제점을 해결하고자 합니다.
[중복적재를 포기]
각 중복적재 버전들에 각자 다른 이름을 붙이면 보편 참조에 대한 중복적재의 단점을 피할 수 있지만, 통하지 않는 경우도 존재하고, 오히려 단점만 더 부각됩니다.
[const T& 매개변수 사용]
C++98로 돌아가서 보편 참조 매개변수 대신 const에 대한 좌측값 참조 매개변수를 사용하는 방법인데 이는 딱히 효율적이지 않다.
[값 전달 방식의 매개변수 사용]
종종 복잡도를 높이지 않고 성능을 높일 수 있는 한 가지 접근 방식은, 참조 전달 매개변수 대신 값 전달 매개변수를 사용하는 방법입니다. 이에 대한 자세한 논의는 항목 41에서 논합니다.
[꼬리표 배분(tag dispatch) 사용]
중복 적재된 함수의 호출에 대해 컴파일러는 그 호출에 쓰인 인수들과 선택 가능한 overloading 버전들의 모든 가능한 조합을 고려해서, 가장 잘 부합하는 것을 선택합니다. 이때, 일반적으로 보편 참조 매개변수는 전달된 인수에 대해 정확히 부합하지만, 매개변수 목록에 보편 참조 매개변수뿐만 아니라 보편 참조가 아닌 매개변수들도 포함되어 있으면, 보편 참조가 아닌 매개변수들에 대한 충분히 나쁜 부합이 보편 참조 매개변수가 있는 overloading을 제치고 선택될 가능성이 있습니다. 이에 착안하여 꼬리표 배분 접근 방식이 탄생하였습니다.
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>());
}
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));
}
위의 코드는 꼬리표 배분 접근 방식을 통해 리팩토링한 코드인데, 들어오는 매개변수에 대해 모두 형식 특질을 통해 모든 한정사를 제거 하고 원본 타입으로 만듭니다. 그 이후에, std::is_integral을 통해 정수인지 아닌지를 확인하여 std::false_type과 std::true_type으로 구별하여, 이에 따라 별도의 함수가 돌아가도록 구현을 합니다. std::is_intergral의 경우는 템플릿 특수화를 통해 만들어지며, 위와 같은 과정은 컴파일 시점의 현상인 overloading 해소 과정에서 logAndAddImpl을 적절하게 선택할 수 있도록 합니다.
[보편 참조를 받는 템플릿을 제한]
꼬리표 배분은 완벽 전달 생성자와 복사 생성자의 문제를 해결하는데에 있어서 해결책이 되기 어렵습니다. 이는 보통 컴파일러가 작성한 함수들이 꼬리표 배분 설계를 항상 우회하지 않기 때문입니다. 이처럼 보편 참조를 받는 중복적재 버전이 독자가 원하는 것보다는 탐욕스럽지만 단일한 배분 함수로 작용할 정도로 탐욕스럽지는 않은 경우에는 꼬리표 배분 설계가 적합하지 않습니다. 그보다는, 보편 참조 매개변수를 포함하는 함수 템플릿이 중복적재 해소의 후보가 되는 조건들을 적절히 제한할 수 있는 또 다른 기법이 필요한데 바로 std::enable_if입니다.
std::enable_if를 이용하면 컴파일러가 마치 특정 템플릿이 존재하지 않는 것처럼 행동하게 만들 수 있습니다. 그런 템플릿을 비활성화된 템플릿(disabled template)이라고 부릅니다. 기본적으로 모든 템플릿은 활성화된 상태이나, std::enable_if를 사용하는 템플릿은 오직 그 std::enable_if에 지정된 조건이 만족될 때에만 활성화 됩니다. 이는 SFINAE와도 깊은 관련이 있습니다. 그래서 Person과 관련된 객체 및 정수형 인자를 제대로 처리하기 위해, 먼저 const성, volatile성 및 참조 여부를 제거 하기 위한 std::decay와, 상속 계열 여부를 확인하는 is_base_of에 정수형인지를 파악하기 위한 is_integral을 통해서 다음과 같은 코드를 짤 수 있습니다.
class Person {
public:
template<
typename T,
typename = typename std::enable_if_T<
!std::is_base_of<Person,std::decay_t<T>>::value
&&
std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n);
explicit Person(int idx);
...
};
이것만은 잊지 말자!
- 보편 참조와 중복적재의 조합에 대한 대안으로는 구별되는 함수 이름 사용, 매개변수를 const에 대한 좌측값 참조로 전달, 매개변수를 값으로 전달, 꼬리표 배분 사용 등이 있다.
- std::enable_if를 이용해서 템플릿의 인스턴스화를 제한함으로써 보편 참조와 중복적재를 함께 사용할 수 있다. std::enable_if는 컴파일러가 보편 참조 중복적재를 사용하는 조건을 프로그래머가 직접 제어하는 용도로 쓰인다.
- 보편 참조 매개변수는 효율성 면에서 장점인 경우가 많지만, 대체로 사용성 면에서는 단점이 된다.
28. Understand reference collapsing
인수가 템플릿에 전달되었을 때 템플릿 매개변수에 대해 연역된 형식에는 그 인수가 왼값인지 아니면 오른값인지에 대한 정보가 부호화되어 있습니다. 그러나 오직 인수가 보편 참조 매개변수를 초기화하는데 쓰일 때에만 그런 일이 일어납니다.
template<typename T>
void func(T&& param);
이러한 부호화의 메커니즘은 왼값 인수가 전달되면 T는 왼값 참조로 연역되고 오른값이 전달되면 T는 비참조 형식으로 연역됩니다. 이는 std::forward의 작동에 깔린 바탕 매커니즘이기도 합니다. 우리가 코드 상으로 참조에 대한 참조를 사용하게 된다면 컴파일러 에러가 뜨게 되지만, 특정 문맥에서는 컴파일러가 참조에 대한 참조를 산출하는 것이 허용되는데 템플릿 인스턴스화가 그런 문맥 중 하나입니다. 그런 경우에는 참조 축약(reference collapsing)이 적용됩니다. 참조에 대한 참조에 대한 규칙은 단 하나로 축약될 수 있습니다.
만일 두 참조 중 하나라도 좌측값 참조이면 결과는 좌측값 참조이다. 그렇지 않으면(즉, 둘 다 우측값 참조이면) 결과는 우측값 참조이다.
이에 대한 예시는 std::forward에서 인수가 왼값으로 들어오는 경우와, 오른값으로 들어온 경우에 대해 값에 맞게 전달하는 과정을 생각하면 정리됩니다.
template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{
return static_cast<T&&>(param);
}
즉 보편 참조라는 사실상 오른쪽 참조이지만, 참조 축약 문맥의 존재에 따라 이를 편의상 보편적 참조라고 부르고 있는 것입니다.
이와 같은 참조 축약이 일어나는 문맥은 4가지가 있는데, 템플릿 객체화, auto 형식 연역, typedef의 지정 및 사용 그리고 decltype 사용이 있습니다. 따라서 typedef의 경우 우리가 지정하고자 했던 타입과 실제 저장되는 타입이 달라질 수도 있습니다.
이것만은 잊지 말자!
- 참조 축약은 템플릿 인스턴스화, auto 형식 연역, typedef와 별칭 선언의 지정 및 사용, decltype의 지정 및 사용이라는 4가지 문맥에서 일어난다.
- 컴파일러가 참조 축약 문맥에서 참조에 대한 참조를 만들어 내면, 그 결과는 하나의 참조가 된다. 원래의 두 참조 중 하나라도 좌측값 참조이면 결과는 좌측값 참조이고, 그렇지 않으면 우측값 참조이다.
- 형식 연역이 좌측값과 우측값을 구분하는 문맥과 참조 축약이 일어나는 문맥에서 보편 참조는 우측값 참조이다.
29. Assume that move operations are not present, not cheap, and not used
C++11에 추가된 주요 기능으로 이동 의미론이 꼽히는데, 이에 따른 성능 향상이 크게 주목 받습니다. 이동 연산이 항상 복사 연산보다 빠를 것이라고 기대하지만 사실은 그렇지 않을 수 있습니다. 먼저 명시적으로 이동 연산을 지원하지 않는다면 복사와 다를 것이 없고, 지원한다고 하더라도 성능상의 이득이 생각만큼 크지 않을 수 있습니다.
std::array는 std::vector와 다르게 포인터가 아닌 내장 배열에 인터페이스를 씌워놓은 형태이므로, 이동을 하게 되면 내장 배열에 있는 자료들을 하나하나 이동시켜야 합니다. 그렇기 때문에 이동 또한 선형 시간이 필요하며, 수행 시간이 복사와 크게 차이가 나지 않습니다.
이외에도 std::string의 경우에도 작은 문자열 최적화가 일어나 내장 배열로서 존재하는 경우는 std::array와 같이 복사와 이동이 선형 시간으로 필요하여 큰 차이가 나지 않습니다.
따라서 다음과 같은 경우 이동 의미론이 도움이 되지 않습니다
- 객체 자체의 이동 연산 부재한 경우
- 이동이 복사보다 더 우월하지 않은 경우
- 이동 연산이 예외를 방출하면 안되는 문맥에서 noexcept가 아니라 사용 불가능한 경우
- 원본 객체가 왼값인 경우
이것만은 잊지 말자!
- 이동 연산들이 존재하지 않고, 저렴하지 않고, 적용되지 않을 것이라고 가정하라.
- 형식들과 이동 의미론 지원 여부를 미리 알 수 있는 경우에는 그런 가정을 둘 필요가 없다.
30. Familiarize yourself with perfect forwarding failure cases.
완벽 전달은 단순히 객체들을 전달하는 것만이 아니라, 그 객체들의 주요 특징, 즉 그 형식, 좌측값 또는 우측값 여부, const나 volatile 여부까지도 전달하는 것을 말합니다. 이를 위해서는 앞에서 언급했듯이 참조 매개변수가 필요하며, 전달된 인수의 좌측값/우측값에 대한 정보를 부호화하는 것은 보편 참조 매개변수가 유일하기 때문에 보편 참조 매개변수가 필요합니다. f라는 함수에 인수들을 전달하는 함수를 작성하였을 때, 함수 f에 직접 전달하는 것과 전달 함수 fwd를 통해 전달하는 것과 다른 일이 일어나면 완벽 전달을 실패한 것일 것입니다.
template<typename... Ts>
void fwd(Ts&&... params)
{
f(std::forward<Ts>(params)...);
}
[중괄호 초기치]
f의 선언이 다음과 같은 경우, 중괄호 초기치를 f에 넘겨주는 코드는 정상 작동하지만 fwd에 넘겨주는 코드는 컴파일되지 않습니다.
void f(const std::vector<int>& v);
f({1, 2, 3}); // OK: "{1, 2, 3}"은 암묵적으로 std::vector<int>로 변환된다.
fwd({1, 2, 3}); // Compile Error
f에 직접 넣어준 경우는 std::initializer_list으로부터 std::vector<int> 객체를 생성하여 에러가 발생하지 않지만 fwd를 통해 한번 전달해야하는 경우에는 컴파일 에러가 발생합니다. 이는 컴파일러는 fwd로 전달된 인수들의 형식을 연역하고, 연역된 형식들을 f의 매개변수 선언들과 비교합니다. 이 때 아래 조건이 하나라도 만족하면 컴파일 에러가 발생합니다.
- fwd 의 매개변수들 중 하나 이상에 대해 컴파일러가 형식을 연역하지 못하는 경우
- fwd 의 매개변수들 중 하나 이상에 대해 컴파일러가 형식을 잘못 연역하는 경우
fwd({1, 2, 3})호출에서의 문제는 std::initializer_list가 될 수 없는 형태로 선언된 함수 템플릿 매개변수에 중괄호 초기치를 전달하여 문제가 발생하는 것입니다. 이를 '비연역 문맥(non-deduced context)' 라고 부릅니다. 이를 해결하기 위해서는 std::initializer_list 객체로 간주시키기 위하여 이를 지역 변수에 저장한 후 넘겨주면 됩니다.
auto il = {1, 2, 3}; // std::initializer_list<int>로 연역됨.
fwd(il);
[널 포인터를 뜻하는 0또는 NULL ]
0이나 NULL을 널 포인터로서 템플릿에 넘겨주려 하면 컴파일러가 그것을 포인터 형식이 아니라 정수 형식으로 연역하기 때문에 문제가 생깁니다. 결과적으로 0과 NULL은 널 포인터로서 완벽하게 전달되지 못합니다.
[선언만 된 정수 static const 및 constexpr 자료 멤버 ]
정수 static const 자료 멤버와 static constexpr 자료 멤버는 클래스 안에서 정의할 필요 없이 선언만 하면 됩니다. 이는 컴파일러가 'const 전파(const propagation)' 를 적용해서, 메모리를 따로 마련할 필요가 없어지기 때문입니다.
class Widget {
public:
static constexpr std::size_t MinVals = 28;
...
};
...
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals);
컴파일러는 MinVals의 정의가 없어도 Minvals가 언급된 곳에 28이라는 값을 배치함으로써 누락된 정의를 처리합니다. 그런데 만일 MinVals 의 포인터가 필요해지면 이 코드는 실패하게 된다. 완벽 전달 역시 문제가 발생합니다.
fwd의 매개변수는 보편 참조이며, 컴파일러가 산출한 코드에서 참조는 포인터처럼 취급되기에 본질적으로 같은 것입니다. 따라서 static const 또는 constexpr 자료 멤버의 정의를 제공하면 이에 대한 주소값을 가지게 되어 문제가 사라집니다.
constexpr std::size_t Widget::MinVals;
[Overloading된 함수 이름과 템플릿 이름 ]
함수를 인자로 받는 f 함수와 f에 인자를 전달하는 fwd 함수가 있을 때 overloading된 함수를 부르는 코드를 생각해보겠습니다.
int processVal(int value);
int processVal(int value, int priority);
void f(int pf(int));
f(processVal);
fwd(processVal); // error
f에 전달하는 경우에는 processVal 이름을 가진 함수 중에서 첫번째 processVal와 인자 형태가 같으므로 선택됩니다. 하지만 전달자 함수를 통한 경우는 어떤 것을 선택해야 하는지에 대한 정보가 없기에 선택을 할 수가 없게 됩니다. 이는 함수 템플릿을 사용하려고 해도 문제가 동일합니다. processVal 자체에는 타입이 존재하지 않기 때문에 형식 연역도 이뤄질 수 없습니다.
template<typename T>
T workOnVal(T param)
{ ... }
fwd(workOnVal); // 에러 발생
fwd와 같은 완벽 전달 함수가 제대로 된 함수 이름 및 템플릿 이름을 받아들이게 하기 위해서는 명시적으로 형태를 지정해주면 됩니다.
using ProcessFuncType =
int (*)(int);
ProcessFuncType processValPtr = processVal;
fwd(processValPtr);
fwd(static_cast<ProcessFuncType>(workOnVal));
[비트필드 ]
마지막으로 완벽 전달이 실패하는 경우는 비트필드를 사용할 때입니다.
struct IPv4Header {
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;
...
};
void f(std::size_t sz);
IPv4Header h;
...
f(h.totalLength); // OK
fwd(h.totalLength); // error
std::size_t를 사용하는 함수 fwd에 totalLength를 전달하고 하면 에러가 발생합니다. 이는 fwd 의 매개변수는 참조인데 반해 totalLength 는 비 const 비트필드이기 때문입니다. C++ 표준에서는 "비 const 참조는 절대로 비트필드에 묶이지 않아야 한다"는 조건이 있습니다. 그 이유는 비트필드들은 워드의 일부분으로 존재할 수 있는데, 임의의 비트들을 가르키는 포인터를 생성하는 방법이 없기 때문입니다. 다행히 우회하는 방법이 존재하여, 그 방법은 복사본을 만들어 전달하는 방법입니다. 복사본에는 포인터를 생성할 수 있기 때문에 문제는 발생하지 않습니다.
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length);
이것만은 잊지 말자!
- 완벽 전달은 템플릿 형식 연역이 실패하거나 틀린 형식을 연역했을 때 실패한다.
- 인수가 중괄호 초기치이거나 0또는 NULL로 표현된 널 포인터, 선언만 된 정수 static const 및 constexpr 자료 멤버, 템플릿 및 중복적재된 함수 이름, 비트필드이면 완벽 전달이 실패한다.