Chapter 8. Tweaks
41. Consider pass by value for copyable parameters that are cheap to move and always copied
효율성 좋은 코드를 만들기 위해서는 왼값 인수는 복사하되, 오른값 인수는 인동시키는 것이 바람직합니다. 이를 그냥 코드로 짜게 되면은 두개의 함수가 필요하며 이를 유지보수 및 object file 코드의 비대화도 걱정이 됩니다. 따라서 보편 참조를 활용한 템플릿을 사용하게 되면 이를 해결할 수 있습니다. 하지만 다루어야할 소스 코드는 줄어들지만, 앞선 항목들에서 본 것처럼 많은 문제들을 야기합니다.
class Widget {
public:
void addName(std::string newName)
{ names.push_back(std::move(newName)); }
...
};
위 코드는 어떠한 문제도 발생하지 않지만 newName이 복사 생성되기에 이동 생성되는 경우에 비해 성능이 떨어집니다.실제로 C++98에서는 복사만이 지원하여 비용이 컸지만 C++11 로 오면서 왼값일 때에만 복사 생성되고, 오른값일 때에는 이동 생성됩니다. 매개변수 전달 방법을 3가지로 나누어 성능을 비교해보자.
// Overloading
class Widget {
public:
void addName(const std::string& newName)
{ names.push_back(newName); }
void addName(std::string&& newName)
{ names.push_back(std::move(newName); }
...
private:
std::vector<std::string> names;
};
// 보편 참조
class Widget {
public:
template<typename T>
void addName(T&& newName)
{ names.push_back(std::forward<T>(newName)); }
...
};
// 값 전달
class Widget {
public:
void addName(std::string newName)
{ names.push_back(std::move(newName)); }
...
};
[Overloading]
왼값과 오른값 모두 참조로 전달되므로 인수로 전달되는 비용은 없습니다. 함수 내에서 왼값의 경우에는 복사 1회가 수행되고 오른값의 경우에는 이동 1회 수행됩니다.
[보편 참조]
오버로딩과 연산 시점과 비용이 똑같습니다. 함수로 전달하는 비용이 없고 본문에서 복사와 이동이 일어납니다. 만일 std::string 이 아닌 타입의 경우에는 std::string 의 복사와 이동이 0회 이상 일어날 수 있습니다. 그러나 여기서는 std::string 의 경우에만 생각합니다.
[값 전달]
앞서 설명한대로 왼값과 오른값으로 나누어 생각해야 합니다. 왼값의 경우에는 인수로 전달하는데 복사 1회가 일어나고, 오른값의 경우에는 이동 1회가 발생합니다. 함수 본문에서는 두 가지 경우 모두 이동 1회가 발생합니다.
성능의 비교로 알아보았듯이 값 전달이 장점만 있는 것은 아닙니다. 성능 뿐만 아니라 이동 전용 형식의 경우에는 우리가 값 전달을 고려할 필요가 없습니다. 오른값으로 매개변수를 받도록 만들었다면 인수를 전달하는데 드는 비용이 없기 때문입니다. 또한 만일 addName 함수 내에 조건문에 따라 삽입을 하지 않는다면 쓸데없는 복사나 이동 연산을 하게 되는 것입니다.
다시 "이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라"라는 문장을 해석해보겠습니다.
- 값 전달을 사용하라가 아니라 고려하라 입니다.
- 복사 가능 매개변수에 대해서만 값 전달을 고려해야 합니다. 이동만 가능한 경우에 값 전달을 하게 되면 이동 2회의 비용으로 보편 참조의 경우의 이동 1회보다 비용이 비싸집니다.
- 이동이 저렴한 경우에 대해서만 고려해야 합니다.
- 값 전달은 항상 복사되는 매개변수에 대해서만 고려해야 합니다.
복사에는 생성을 위한 복사와 배정을 위한 복사가 존재합니다. '=' 연산을 사용하는 경우를 배정문이라고 배정을 통해 복사하는 함수의 경우에는 좀 더 복잡해집니다.
class Password {
public:
explicit Password(std::string pwd)
: text(std::move(pwd)) {}
void changeTo(std::string newPwd)
{ text = std::move(newPwd): }
...
private:
std::string text;
};
위 코드는 패스워드를 담고있는 객체입니다. 생성자에서는 우리가 앞서 본 그대로입니다. 배정문에서는 인수로 전달되는 것은 왼값이나 오른값에 따라 다른 것은 분명합니다. 문제는 배정문을 수행할 때 기존에 저장되어 있던 메모리를 해제하고 새로운 객체를 이동합니다. 또한 만일 기존에 저장된 메모리가 새로 배정되는 메모리보다 크다면 굳이 메모리를 해제할 필요가 없습니다. 만약 우리가 값 전달이 아닌 중복적재 방식을 이용했다면 할당과 해제를 생략할 수 있습니다.
class Password {
public:
...
void changeTo(const std::string& newPwd)
{
text = newPwd;
}
...
private:
std::string text;
};
이 이외에도 값 전달은 slice off 문제가 발생할 수 있습니다. 다형성과 관련된 문제로 포인터 타입의 경우에는 이런 문제가 발생하지 않습니다. 값 전달을 사용하기에는 위험한 점이 너무나도 많기 때문에 주의해서 사용해야 합니다.
이것만은 잊지 말자!
- 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달이 참조 전달만큼이나 효율적이고, 구현하기가 더 쉽고, 산출되는 목적 코드의 크기도 더 작다.
- 좌측값 인수의 경우 값 전달(즉, 복사 생성) 다음의 이동 배정은 참조 전달 다음의 복사 배정보다 훨씬 비쌀 가능성이 있다.
- 값 전달에서는 잘림 문제가 발생할 수 있으므로, 일반적으로 기반 클래스 매개변수 형식에 대해서는 값 전달이 적합하지 않다.
42. Consider emplacement instead of insertion
std::vector<std::string> vs;
vs.push_back("xyzzy"); // vs.push_back(std::string("xyzzy"));
vs.emplace_back("xyzzy");
위 코드에서 함수로 전달되는 타입은 std::string 이 아닌 문자열 리터럴입니다. 이는 vector의 타입과는 맞지 않지만 std::string으로의 암시적 변환이 가능합니다. 따라서, 컴파일러는 형식 불일치를 인지하고 문자열 리터럴의 형식을 std::string으로 변환하여 push_back을 수행합니다. 이는 임시 std::string 객체가 생성하고 그 후에 벡터에 집어넣을 때 객체를 한번 더 생성하게 되며 임시 객체에 대해서는 소멸자가 호출되는데 이는 상당히 비효율적입니다.
이에 따라 등장한 것이 emplace_back으로 삽입할 객체가 아닌 삽입할 객체의 생성자를 위한 인수를 받습니다. 임시 std::string 객체를 생성하지 않고 함수 안에 들어가서 std::string 객체를 생성 후에 집어넣게 됩니다. 심지어 문자열 리터럴이 아닌 경우에도 push_back 과 똑같은 역할을 하며, 때로는 더 효율적으로 동작하기도 합니다. 하지만 이는 항상 emplace_back이 좋다는 것을 의미하지는 않습니다. 삽입 함수가 더 빠르게 실행되는 상황도 존재합니다. 아래의 세 조건이 모두 성립한다면 거의 항상 생성 삽입의 성능이 삽입의 성능보다 좋습니다.
[컨테이너에 배정이 아닌 생성되는 경우]
컨테이너에 원소를 추가할 때 어떠한 방식으로 추가하느냐의 차이이다. 객체 생성을 통해 추가할 수도 있고, 이동 배정을 통해 추가될 수 있다. 이동 배정을 위해서는 항상 원본 객체가 필요하다. 그래서 임시 객체가 생성되어 생성 삽입의 장점이 사라지는 것이다. 노드 기반 컨테이너들은 거의 항상 삽입을 통해 추가한다. 그 외에 컨테이너는 3가지 밖에 존재하지 않는다. (std::vector, std::deque, std::string) 이 컨테이너들의 emplace_back 의 경우에는 생성을 사용한다.
[인수의 형식과 템플릿 형식이 다른 경우]
앞서 보았던 내용이다. 만일 인수의 형식과 템플릿 형식이 같다면 임시 객체의 생성과 소멸이 일어나지 않기 때문에 생성 삽입이 빠를 이유가 없다.
[기존 값과의 중복을 허용하는 경우]
std::map, std::set 과 같이 중복을 허용하지 않은 경우에는 적용되지 않는 것을 의미하며, 이들은 생성 삽입의 경우에 노드를 생성해서 중복을 체크합니다. 만약 중복이라면 노드를 파괴시키는데 이는 생성과 파괴 비용을 발생시킨다. 이는 삽입 함수보다 생성 삽입에서 더 자주 발생한다.
이번에는 생성 삽입의 문제점을 알아보겠습니다. 커스텀 삭제자를 통해서 해제되어야 하는 객체 원소를 삽입한다고 가정한 경우make_shared 함수를 사용할 수 없기에 push_back을 이용하여 넣는 경우는 다음과 같습니다.
std::list<std::shared_ptr<widget>> ptrs;
void killWidget(Widget* pWidget);
// 첫번째 방식
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));
// 두번째 방식
ptrs.push_back({ new Widget, killWidget });
첫번째와 두번째 방식 모두 임시 객체가 생성되며, 만약 메모리 부족으로 예외가 발생한 경우에는 임시 객체가 소멸되어도 메모리 누수를 발생 시키지 않습니다. 하지만 emplace_back의 경우는 다르게 작동합니다.
ptrs.emplace_back(new Widget, killWidget);
new Widget 은 오른값이기 때문에 생성된 포인터가 emplace_back 안으로 완벽 전달됩니다. 이제 메모리가 부족하여 예외가 발생한 경우에는 new Widget에 대한 메모리를 해제 해주어야 하는데 이에 대한 포인터가 사라지며 메모리 누구사 생깁니다. 위와 같은 문제를 해결하기 위해서는 외부에서 객체를 생성해서 함수로 전달하는 방법으로 해결할 수 있습니다.
std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.push_back(std::move(spw));
ptrs.emplace_back(std::move(spw));
이 외의 다른 문제로는 정규표현식을 사용하는 경우가 있습니다.
std::vector<std::regex> regexes;
regexes.emplace_back(nullptr); // 정상 동작
regexes.push_back(nullptr); // 컴파일 에러
정규표현식들을 저장하는 컨테이너를 생성하였는데 이는 emplace_back과 push_back은 서로 다른 양상을 보입니다. 정규표현식의 생성자를 먼저 알아보아야 합니다. 정규표현식의 생성자 중에서 const char* 를 받는 생성자가 explicit 으로 선언되어 있습니다. 따라서 다음과 같은 코드는 정상 동작합니다.
std::regex r = nullptr; // 컴파일 에러
regexes.push_back(nullptr); // 컴파일 에러
std::regex r(nullptr); // 컴파일 성공
생성 삽입의 경우에는 첫번째 코드를 수행하는 것과 마찬가지이기 때문에 컴파일 에러가 발생하는 것이고 삽입 생성의 경우에는 마지막 라인을 수행하는 것이여서 컴파일에 성공합니다.
이것만은 잊지 말자!
- 이론적으로, 생성 삽입 함수들은 종종 해당 삽입 버전보다 더 효율적이어야 하며, 덜 효율적인 경우는 절대로 없어야 한다.
- 실질적으로, 만일 (1) 추가하는 값이 컨테이너로 배정되는 것이 아니라 생성되고, (2) 인수 형식(들)이 컨테이너가 담는 형식과 다르고, (3) 그 값이 중복된 값이어도 컨테이너가 거부하지 않는다면, 생성 삽입 함수가 삽입 함수보다 빠를 가능성이 아주 크다.
- 생성 삽입 함수는 삽입 함수라면 거부당했을 형식 변환들을 수행할 수 있다.