std::string 객체들을 담는 컨테이너에 삽입 함수를 이용해서 새 요소들을 추가한다고 하면 당연히 논리적으로 삽입 함수에 넘겨주는 인수의 타입은 std::string이어야 할 것이다.
그렇기는 한데 항상 참은 아닐 수도 있다.
std::vector<std::string> vs;
vs.push_back("xyzzy"); // 문자열 리터럴
문자열 리터럴은 std::string 객체가 아니라서 컨테이너의 타입과 다르다. 그런데 에러가 발생하지 않고 동작하는 이유가 무엇일까?
template<class T, class Allocator = allocator<T>>
class vector {
public:
...
void push_back(const T& x);
void push_back(T&& x);
...
};
push_back은 왼값과 오른값에 대해 오버로딩 되어있다.
문자열 리터럴이 인수로 전달되면 std::string != const char[] 이기 때문에 타입 불일치를 해소하기 위해 컴파일러는 문자열 리터럴로부터 임시 std::string 객체를 생성하고 push_back에 전달하는 코드를 산출한다.
vs.push_back("xyzzy");
vs.push_back(std::string("xyzzy"));
위의 코드를 마치 아래 코드처럼 취급하게 된다.
문제없이 작동하지만 성능을 고려한다면 그다지 좋지는 않다. 왜냐면 std::string의 생성자가 두번 호출되기 때문이다.
문자열 리터럴이 인수로 전달될 때, 임시 std::string 객체가 생성되면서 생성자가 호출된다.
임시 객체가 push_back의 오른값 매개변수 버전으로 전달되고 x의 참조에 묶이게 되는데, 이 때 이동 생성자에 의해 x의 복사본이 생성되며 생성자가 호출된다.
이후 push_back의 호출이 끝나는 즉시 임시 객체도 사라지며 소멸자가 호출된다.
총 2번의 생성과 한번의 소멸이 이루어진다.
만약 문자열 리터럴을 통해 임시 객체를 생성하지 않고 직접 전달할 수 있으면 임시 객체의 생성과 파괴 비용이 발생하지 않을 것이다.
이를 위해서는 push_back이 아닌 emplace_back을 호출하면 된다.
emplace_back은 전달된 인수를 이용해서 직접 객체를 생성하고 그 과정에 임시 객체가 관여하지 않게 된다.
완벽 전달을 이용하기 때문에 완벽 전달이 실패하는 경우들을 제외하고는 항상 효율적으로 동작한다.
push_back, push_front를 지원하는 표준 컨테이너는 emplace_back, emplace_front 역시 같이 지원한다.
그 외 삽입 함수들 역시 대응되는 생성 삽입 함수인 emplace를 제공한다.
생성 삽입 함수들은 삽입 함수들이 하는 모든 일을 할 수 있으며 이론적으로 적어도 삽입 함수들보다 덜 효율적으로 수행하는 경우는 없다.
항상 그런것은 아니지만 그렇지 않은 경우들을 특정짓는것은 쉽지 않고 대신 생성 삽입이 바람직할 가능성이 큰 상황들을 식별할 수 있는 발견법은 있다.
◾ 추가할 값이 컨테이너에 대입되는 것이 아니라 컨테이너 안에서 생성된다.
◾ 추가할 인수 타입들이 컨테이너가 담는 타입과 다르다.
◾ 컨테이너가 기존 값과의 중복 때문에 새 값을 거부할 우려가 별로 없다.
위의 세 가지 조건을 모두 만족하는 경우 거의 항상 생성 삽입이 삽입보다 성능이 좋다.
추가로 생성 삽입 함수를 사용할 때 고려하면 좋은 사항들도 알아보자.
자원 관리
자원 관리와 관련된 경우 emplace의 호출은 오히려 독이될수도 있다.
std::shared_ptr를 생성할 때, 커스텀 삭제자를 지정하는 경우에는 make_shared를 통해서 생성할 수 없다. 이 때는 std::shared_ptr의 생성자를 직접 호출하게 되는데, 이를 push_back을 통해 삽입하게 되면 임시객체로 인수를 전달하는것이 보통의 경우일 것이다.
임시 객체의 생성과 삭제 비용을 피하기 위해 emplace를 사용하는 것인데, 이런 경우에는 임시 객체의 생성 비용이 매우 가치가 있다.
만약 임시 std::shared_ptr 객체를 담을 복사본을 할당하는 도중 메모리 부족 예외가 발생한다면 예외가 push_back 밖으로 전파되며 임시 std::shared_ptr 객체가 파괴된다. 그와 함께 임시 std::shared_ptr 객체를 만들 때 동적 할당된 자원 역시 임시 std::shared_ptr 객체의 소멸자가 호출되며 같이 해제된다.
그러나 emplace_back을 호출하면 동적할당으로 만들어진 원시 포인터가 완벽 전달되고 할당이 실패하여 메모리 부족 예외가 발생하면 예외가 emplace_back 바깥으로 전파되며 원시 포인터가 사라지게 되어 메모리 누수가 발생한다.
자원 관리 객체들을 담은 컨테이너의 삽입 함수를 호출하는 경우, 일반적으로 삽입 함수의 매개변수 타입들은 자원의 획득과 그 자원을 관리하는 객체의 생성 사이에 아무것도 끼어들지 않음을 보장한다.
그에 반해 생성 삽입 함수는 완벽 전달에 의해 컨테이너의 메모리 안에서 생성할 수 있는 시점까지 지연된다.
그 사이에 예외가 발생하면 위와 같은 문제가 발생하는 것이다.
std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.push_back(std::move(spw)); // 삽입
ptrs.emplace_back(std::move(spw)); // 생성 삽입
이번 예제에 한해서는 new 연산자를 통해 생성된 임시 객체를 직접 push_back이나 emplace_back에 전달하기보다 별도로 생성하여 오른값으로 전달해 주는것이 더 바람직하다.
explicit 생성자들과의 상호작용
C++11에 추가된 정규표현식(regex)은 nullptr을 받을 수 없다.
std::regex r = nullptr;
regexes.push_back(nullptr);
이것들은 컴파일이 안되는데,
std::vector<std::regex> regexes;
regexes.emplace_back(nulltpr);
이 코드는 컴파일이 된다.
std::regex는 암시적 변환에 의한 복사 연산이 일어나면 비용이 커질 우려가 있기 때문에 생성자가 explicit로 선언되어있다.
explicit 생성자는 복사 초기화를 사용할 수 없지만 직접 초기화는 사용할 수 있는 특징이 있다.
대입이나 push_back은 암시적 변환이 일어나며 복사 초기화를 시도하여 컴파일 에러가 발생하지만, emplace_back은 std::regex 객체로 변환될 어떤 객체가 아니라 std::regex의 생성자에 전달될 인수에 불과하기 때문에 컴파일이 가능한 것이다.
근데 컴파일만 가능할 뿐이지 미정의 동작이 일어날것은 뻔하다.
좀 더 일반화하자면 생성 삽입 함수는 직접 초기화를 사용하여 explicit 생성자를 지원하고 삽입 함수는 복사 생성자를 사용하여 explicit 생성자를 지원하지 않는다.
◾ 이론적으로, 생성 삽입 함수들은 종종 해당 삽입 버전보다 더 효율적이어야 하며, 덜 효율적인 경우는 절대로 없어야 한다.
◾ 실질적으로, 만일 (1)추가하는 값이 컨테이너로 대입되는 것이 아니라 생성되고, (2)인수 타입들이 컨테이너가 담는 타입과 다르고, (3)그 값이 중복된 값이어도 컨테이너가 거부하지 않는다면, 생성 삽입 함수가 삽입 함수보다 빠를 가능성이 아주 크다.
◾ 생성 삽입 함수는 삽입 함수라면 거부당했을 타입 변환들을 수행할 수 있다.
'도서 > Effective Modern C++' 카테고리의 다른 글
[8장] 항목 41 : 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라 (0) | 2023.01.31 |
---|---|
[7장] 항목 40 : 동시성에는 std::atomic을 사용하고, volatile은 특별한 메모리에 사용하라 (0) | 2023.01.31 |
[7장] 항목 39 : 단발성 사건 통신에는 void future 객체를 고려하라 (0) | 2023.01.31 |
[7장] 항목 38 : 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라 (0) | 2023.01.30 |
[7장] 항목 37 : std::thread들을 모든 경로에서 합류 불가능하게 만들어라 (0) | 2023.01.30 |