KoreanFoodie's Study

Effective Modern C++ | 항목 42 : 삽입 대신 생성 삽입을 고려하라 본문

Tutorials/C++ : Advanced

Effective Modern C++ | 항목 42 : 삽입 대신 생성 삽입을 고려하라

GoldGiver 2022. 10. 26. 10:09

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 42 : 삽입 대신 생성 삽입을 고려하라

핵심 :

1. 이론적으로, 생성 삽입 함수들은 종종 해당 삽입 버전보다 더 효율적이어야 하며, 덜 효율적인 경우는 절대로 없어야 한다.
2. 실질적으로, (1) 추가할 값이 컨테이너에 배정되는 것이 컨테이너에 배정되는 것이 아니라 컨테이너 안에서 생성되고, (2) 인수 형식(들)이 컨테이너가 담는 형식과 다르고, (3) 그 값이 중복된 값이어도 컨테이너가 거부하지 않는다면, 생성 삽입 함수가 삽입 함수보다 빠를 가능성이 아주 크다.
3. 생성 삽입 함수는 삽입 함수라면 거부당했을 형식 변환들을 수행할 수 있다.

 

std::string 객체들을 담는 컨테이너(예를 들어 벡터) 에 push_back 을 사용한다고 가정해 보자.

std::vector<std::string> vs;
vs.push_back("abc");

위의 코드는 문자열 리터럴로부터 std::string 임시 객체(temp 라고 부르자)를 생성해서 push_back 에 전달한다. 즉, 생성자를 2 번 호출한다(한 번은 임시 객체 temp 를 위한, 그리고 또 한 번은 std::vector 내에서 std::string 을 이동 생성). 그리고 push_back 이 반환된 즉시 temp 가 파괴되어 std::string 소멸자가 실행된다.

push_back 대신 emplace_back 을 사용하면, 임시 객체를 만들지 않고 완벽 전달을 통해 std::vector 안에서 직접 std::string 을 생성한다.

이러한 emplace 류 함수들을 생성 삽입(emplacement) 함수라고 한다(push_front 는 emplace_front 에 대응되는 등, 기존 삽입 함수들과 대응되는 함수들이 존재함). 삽입 함수들은 삽입할 객체를 받지만, 생성 삽입 함수는 삽입할 객체의 생성자를 위한 인수들을 받아 인터페이스가 더 유연하다.

삽입 함수가 임시 객체를 필요로 하지 않는 경우에는 생성 삽입 함수와 삽입 함수는 동일한 일을 한다.

std::vector<std::string> vs;

std::string test("abc");
vs.push_back(test); // vs 에서 복사 생성 1회
vs.emplace_back(test); // 마찬가지

 

물론 항상 생성 삽입 함수가 유리한 것은 아니다. 삽입 함수가 더 빠른 경우도 존재하기 때문이다. 일반적으로, 다음 세 조건이 모두 성립하면 거의 항상 생성 삽입의 성능이 삽입의 성능을 능가한다.

1. 추가할 값이 컨테이너에 배정되는 것이 컨테이너에 배정되는 것이 아니라 컨테이너 안에서 생성된다(첫 코드 블락의 "abc" 를 push_back 으로 전달하는 경우가 그 예시다).

하지만 다음과 같이 배정의 경우, 이동 배정 시 이동 원본이 될 임시 객체를 생성해야 하므로, 성능 상의 이점은 딱히 없다.

std::vector<std::string> vs;

vs.emplace(vs.begin(), "abc");

노드 기반 컨테이너들은 거의 항상 생성을 통해서 새 값을 추가하며, 표준 컨테이너들은 대부분 노드 기반이다. 노드 기반이 아닌 표준 컨테이너는 std::vector, std::deque, std::string 뿐이다. 그리고 노드 기반이 아닌 컨테이너에서는 emplace_back 이 항상 배정 대신 생성을 이용해서 새 값을 컨테이너에 넣는다고 간주해도 무방하다.

2. 추가할 인수 형식(들)이 컨테이너가 담는 형식과 다르다. 어떤 컨테이너<T> 에 T 형식의 객체를 추가할 때에는 생성 삽입이 삽입보다 빠를 이유가 없다. 그럴 때는 삽입 인터페이스에서도 임시 객체를 생성할 필요가 없기 때문이다.

3. 컨테이너가 기존 값과의 중복 때문에 새 값을 거부할 우려가 별로 없다. 이는 컨테이너가 중복을 허용하거나, 또는 추가할 값들이 대부분 고유한 경우에 해당한다. 중복 제한이 있으면 생성 삽입 구현은 새 값으로 노드를 생성하고, 그것을 기존 컨테이너 노드들과 비교한다. 값이 이미 있으면 생성 삽입이 취소되고, 노드가 파괴되므로, 생성과 파괴 비용이 낭비된다. 이런 노드들은 삽입 함수보다 생성 삽입 함수에서 더 자주 생성된다.

 

추가로, 다음 두 가지 사항을 고려하자.

1. 자원 관리

std::shared_ptr<Widget> 에 포인터를 추가하는 예시를 보자.

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});


// 생성 삽입 버전
ptrs.emplace_back(new Widget, killWidget);

예를 들어, 삽입 버전에서, 1) std::shared_ptr<Widget> 생성(해당 객체를 temp 라고 하자), 2) push_back 이 temp 를 참조로 받음. temp 복사본을 담을 노드 할당 중 메모리 부족(out-of-memory) 예외 발생, 3) 예외가 push_back 바깥으로 전파되며 temp 파괴. 동시에 해당 Widget 도 자동으로 해제(killWidget 호출에 의해)... 의 순서로 동작이 이루어진다고 하자.

이 때, 메모리 누수는 발생하지 않는다. 그렇다면 emplace_back 버전은 어떨까?

emplace_back 버전이 만약 1) "new Widget" 으로 만들어진 생 포인터가 emplace_back 으로 완벽 전달. 새 목록 노드 생성 중 메모리 부족 예외가 발생하고 할당 실패, 2) 예외가 emplace_back 밖으로 전파되며 생 포인터가 사라짐... 의 순서로 동작한다면, 결국 그 Widget 의 자원이 누수된다.

즉, 이 경우에는 항목 21 에서 말했듯이, "new Widget" 을 바로 전달하지 말고, 별도의 문장으로 분리하는 것이 바람직할 것이다.

// 삽입 버전
std::shared_ptr<Widget> spw1(new Widget, killWidget);
ptrs.push_back(std::move(spw1));


// 생성 삽입 버전
std::shared_ptr<Widget> spw2(new Widget, killWidget);
ptrs.emplace_back(std::move(spw2));

위의 코드 둘 다, spw 의 새성과 파괴 비용이 발생하며, 성능상의 큰 차이는 별로 없다.

 

2. explicit 생성자들과의 상호작용 방식

정규 표현식을 이용한 예제를 보자.

std::vector<std::regex> regexes;

// 정상 작동
std::regex upperCaseWord("[A-Z]+");

// 오류! 컴파일 안됨!
std::regex r = nullptr;

// 오류! 컴파일 안됨!
regexes.push_back(nullptr);


// ??? : 컴파일 됨
regexes.emplace_back(nullptr);

위의 생성자와 push_back 이 실패하는 이유는, 생성자가 explicit 으로 되어 있어 컴파일러가 변환을 거부하기 때문이다(std::regex 는 생성자 인수로 const char* 을 받음).

반면, emplace_back 은 std::regex 객체로 변환할 무엇이 아니라 std::regex 객체의 생성자에 전달할 인수이므로, 애초에 다음 코드를 작성한 것처럼 취급한다.

// 컴파일 됨
std::regex r(nullptr);

그런데 위 문장은 왜 컴파일이 될까? 다음 두 문장을 비교해 보자.

// 복사 초기화 (copy initialization)
std::regex r1 = nullptr;

// 직접 초기화 (direct initialization)
std::regex r2(nullptr);

explicit 생성자에서는 복사 초기화를 사용할 수 없지만, 직접 초기화는 사용할 수 있다. 이 때문에 r1 은 컴파일되지 않고, r2 는 컴파일된다.

위에서 emplace_back 으로 nullptr 를 전달한 경우, 프로그램은 미정의 동작 무한열차를 타게 된다. 즉, 생성 삽입 함수를 사용할 때에는 제대로 된 인수를 넘겨주는 데 특별히 신경을 써야 한다는 사실을 알 수 있다!

Comments