스토리텔링 개발자

[Effective Modern C++] 42. 삽입(insert) 대신 생성 삽입(emplace) 고려하기 본문

Effective C++/Effective Modern C++

[Effective Modern C++] 42. 삽입(insert) 대신 생성 삽입(emplace) 고려하기

김디트 2025. 4. 30. 11:06
728x90

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

 

 

 

std::vector<std::string>에 요소 삽입
  • 삽입 함수로 넣는 요소의 타입이 늘 std::string인 것은 아니다.
std::vector<std::string> vs;

vs.push_back("xyzzy"); // 문자열 리터널을 삽입한다.
  • std::vector의 push_back은 lValue와 rValue에 대해 오버로딩 되어 있다.
template<class T, class Allocator = allocator<T>>
class vector
{
public:
    ...
    void push_back(const T& x); // lValue 버전
    void push_back(T&& x); // rValue 버전
    ...
};
  • 컴파일러는 const char[6]과 std::string이 불일치함을 인식한다.
    • 이를 해소하기 위해 컴파일러는 문자열 리터럴로 임시 std::string 객체를 생성한다.
    • 그리고 그 임시 객체를 push_back에 전달한다.
  • 즉, 아래와 같은 코드로 치환된다.
vs.push_back(std::string("xyzzy"));
  • 잘 동작하지만, 생성자가 두 번, 소멸자가 한 번 호출된다는 점이 불만이다.
  • 동작
    1. xyzzy로부터 rValue인 임시 std::string 객체(줄여서 temp) 생성(생성자 호출 1회)
    2. temp가 push_back의 rValue 버전에 전달되어 x에 바인딩된다.
    3. std::vector를 위한 메모리 안에서 x의 복사본이 생성된다.(생성자 호출 1회)
    4. push_back의 호출이 종료되며 temp가 파괴된다.(소멸자 호출 1회)
  • 세 번째 단계(std::vector가 복사본을 생성)에 문자열 리터럴을 직접 전달할 수 있다면 temp의 불필요한 생성 / 호출을 제거할 수 있지 않을까?
    • push_back 대신 emplace_back을 사용하면 해결된다.

 

 

 

생성 삽입(emplace_back) 사용하기
vs.emplace_back("xyzzy"); // 문자열 리터럴을 직접 사용하여 vs 안에서 바로 생성한다.
  • emplace_back은 완벽 전달을 이용한다.
    • 완벽 전달의 한계들에 부딪히지 않는 한,(항목 30 참조)
    • 임의의 타입, 임의의 개수의 인수 조합을 emplace_back에 전달할 수 있다.
vs.emplace_back(50, 'x'); // 'x' 문자 50개로 이루어진 std::string 삽입
  • push_back을 지원하는 모든 표준 컨테이너는 emplace_back을 지원한다.
  • push_front를 지원하는 모든 표준 컨테이너는 emplace_front를 지원한다.
  • insert를 지원하는 모든 표준 컨테이너는 emplace를 지원한다.

 

 

 

생성 삽입 함수의 특징
  • 생성 삽입(emplacement) 함수들이 성능이 뛰어난 이유는 인터페이스가 더 유연하기 때문이다.
    • 삽입 함수들은 삽입할 객체를 받는다.
    • 하지만 샙성 삽입 함수들은 삽입할 객체의 생성자를 위한 인수들을 직접 받는다.
  • 물론 삽입 함수처럼 컨테이너에 지정된 타입과 동일한 타입의 인수를 넘길수도 있다.
    • 복사 생성자, 이동 생성자의 인수로 동작하기 때문이다.
    • 즉, 생성 삽입 함수들은 삽입 함수가 하는 일을 그대로 대체할 수 있다.
std::string queenOfDisco("Donna summer");

// 둘 다 복사 생성한다.
vs.push_back(queenOfDisco);
vs.emplace_back(queenOfDisco);

 

  • 그렇다면 항상 생성 삽입을 사용해도 괜찮을까?
    • 그렇진 않다.
    • 표준 라이브러리의 구현 상 삽입 함수가 더 빠르게 실행되는 상황도 존재한다.
    • 하지만 그런 상황들을 특정짓기는 쉽지 않다.
    • 다행히 생성 삽입이 더 바람직할 가능성이 큰 상황에 대한 휴리스틱 추론법은 존재한다.

 

 

 

생성 삽입의 성능이 나은 상황
  • 추가할 값이 컨테이너에 배정되는 것이 아니라 컨테이너 안에서 생성된다.
    • 도입부의 예제("xyzzy"를 std::vector<std::string> vs에 삽입)가 바로 이 경우이다.
    • 하지만 컨테이너 안에서 생성되는지 아닌지는 상황과 컴파일러에 따라 다를 수 있다.
    • // 아래의 경우는 대부분 안에서 string을 만들지 않고, 임시 객체를 만들어 이동 할당한다.
      // 즉, 생성 삽입의 장점이 사라진다.
      std::vector<std::string> vs;
      ...
      vs.emplace(vs.begin(), "xyzzy"); // vs의 첫 번째 위치에 삽입
    • 하지만 노드 기반이 아닌 컨테이너에서는 emplace_back이 항상 할당 대신 생성을 이용해서 새 값을 컨테이너에 넣는다고 간주해도 무방하다.
  • 추가할 인수 타입들이 컨테이너가 담는 타입과 다르다.
    • 컨테이너<T>에 T 타입 객체를 직접 추가할 때는 생성 삽입이 삽입보다 빠를 이유가 없다.
  • 컨테이너가 기존 값과의 중복 때문에 새 값을 거부할 우려가 별로 없다.
    • 중복 제한이 있는 경우 일반적으로 생성 삽입 구현은 새 값으로 노드를 생성해서 그것을 기존 컨테이너 노드들과 비교한다.
    • 그리고 추가할 값이 없는 경우에 그 노드를 컨테이너에 연결한다.
    • 하지만 값이 있이므녀 생성 삽입은 취소되고 임시 노드는 파괴된다.
      • 즉 생성, 파괴 비용이 낭비된다.

 

 

 

생성 삽입 시 추가로 고려하면 좋은 사항
  • 사항 1. 커스텀 삭제자를 사용해야 하는 경우엔 삽입 함수를 사용하자.
void killWidget(Widget* pWidget); // 커스텀 삭제자

std::list<std::shared_ptr<Widget>> ptrs;

// 삽입 함수를 사용한 경우
ptrs.push_back(std::sahred_ptr<Widget>(new Widget, killWidget));
// ptrs.push_back({ new Widget, killWidget }); 위와 동일한 코드

// 1. new Widget으로 임시 객체(temp) 할당
// 2. push_back이 해당 temp를 참조로 받음.
// 3. temp를 담을 노드를 할당하는 도중 메모리 부족(out-of-memory) 예외 발생
// 4. 예외가 push_back 바깥으로 전파되며 temp 파괴
// 5. temp는 커스텀 삭제자를 통해 적절히 해제된다.
// 즉, 누수가 없이 완벽하게 해결된다.

// 생성 삽입 함수를 사용한 경우
ptrs.emplace_back(new Widget, killWidget);

// 1. "new Widget"으로 만들어진 raw 포인터가 emplace_back으로 완벽 전달
// 2. emplace_back은 새 값을 담을 노드 할당하는 도중 메모리 부족 예외 발생
// 3. 예외가 emplace_back 바깥으로 전파
// 4. raw 포인터가 유실되면서 메모리 누수 발생!!
  • 사실 new Widget 같은 표현식을 직접 넘겨주는 건 바람직하지 않다.(항목 21 참조)
  • 아래와 같이 작성되어야 한다.
std::shared_ptr<Widget> spw(new Widget, killWidget);

// 삽입 함수
ptrs.push_back(std::move(spw));

// 생성 삽입 함수
ptrs.emplace_back(std::move(spw));

// 이 경우 어떤 방식이든 spw의 생성, 파괴 비용이 발생한다.
  • 항목 2. 생성 삽입 함수는 직접 초기화를 사용하므로 explicit 생성자를 지원한다.
std::vector<std::regex> regexes;

regexes.emplace_back(nullptr); // ? 하지만 컴파일 성공
regexes.push_back(nullptr); // 컴파일 에러!!

// std::regex는 C 스타일 문자열(char*)로부터 생성될 수 있다.
// 하지만 explicit으로 되어 있으므로 암시적 변환은 불가능하다.

std::regex r1 = nullptr; // 컴파일 에러!
// push_back은 위처럼 복사 생성자를 사용한다.

// 그럼에도 직접 초기화는 허용한다.

std::regex r2(nullptr); // 컴파일 되지만 미정의 동작!
// emplace_back은 위처럼 직접 초기화를 사용한다.
728x90
Comments