-
[Effective Modern C++] insert와 emplaceC++/Effective Modern C++ 2023. 6. 13. 16:07
C++의 여러 컨테이너는 여러 종류의 삽입 함수를 지원합니다.
컨테이너의 종류에 따라 다르지만, 대부분 push혹은 insert라는 이름을 취하고 있습니다.
이러한 컨테이너에 자료를 삽입하는 경우를 생각해보도록 하겠습니다.
std::vector<std::string> vec; vec.push_back("asdf");
위 코드의 push_back부분을 자세히 들여다보도록 하겠습니다.
위 삽입 과정은 다음과 같이 진행됩니다.
- 삽입하는 인자 "asdf"로부터 임시 std::string 객체가 생성됩니다. 이 객체에는 이름이 없지만, 지금은 temp라고 명명하도록 하겠습니다.
- temp가 push_back에 전달 R-value reference로 전달됩니다. 전달된 R-value reference를 통해 std::string 객체가 생성됩니다. (이번에 생성되는 객체는 vector컨테이너에 들어갈 객체입니다.)
- push_back이 종료되면서, temp객체의 소멸자가 실행됩니다.
종합하자면, std::string의 생성자가 2회, 소멸자가 1회 생성됩니다.
하지만 위 과정을 조금만 자세히 살펴봐도, 아래와 같은 생각을 하는 것은 쉬울 것 입니다.
최초 생성된 임시 std::string객체를 vector에 넣으면 소멸자 1회로 끝나는 것이 아닌가?
이번 글은 위와 같은 의문의 해답을 제공하는 역할을 합니다.
이번 글에서는 컨테이너가 제공하는 emplace 함수에 대하여 알아보도록 하겠습니다.
1. emplace 란
더보기emplace함수는 생성 삽입 함수라 표현될 수 있습니다.
예를 들어 다음과 같은 코드에서
std::vector<std::string> vec; vec.emplace_back("asdf");
결과적으로 vector컨테이너에 "asdf"라는 std::string 객체가 추가되는 것은 push_back과 동일합니다.
하지만 결과까지 가는 과정이 다릅니다.
위 코드의 emplcae_back함수는 다음과 같은 과정을 거칩니다.
- 인자 "asdf"가 emplace_back함수로 전달됩니다.
- emplace_back함수에 전달된 "asdf"를 std::string의 생성자에 전달합니다.
- 생성된 std::string을 컨테이너에 삽입합니다.
push_back과 비교하기 위해 과정을 분할했습니다.
임시 객체가 생성되지 않으므로, std::string생성자 1회가 호출됩니다.
요약하면 다음과 같습니다.
- push_back과 같은 (insert, push_front 등)삽입 함수는 삽입할 객체를 인자로 받습니다.
- emplace함수는 삽입할 객체의 생성자에 필요한 인수를 인자로 받습니다.
위 emplace함수의 분석에도 언급했듯이, emplace함수는 임시 객체의 생성, 파괴를 피하는 것으로 성능 향상을 가져올 수 있습니다.
2. emplace를 지원하는 컨테이너
더보기emplace는 여러 표준 컨테이너에서 제공하는 기능입니다.
구체적으로 다음과 같습니다.
- push_back을 지원하는 모든 표준 컨테이너는 emplace_back을 지원합니다.
- push_front를 지원하는 모든 표준 컨테이너는 emplace_front를 지원합니다.
- insert를 지원하는 모든 표준 컨테이너는 emplace를 지원합니다.
위와 같이, 대부분의 표준 컨테이너에서 제공하는 삽입 함수는 그에 대응하는 생성 삽입 함수를 지원합니다.
3. emplace의 맹점 (자원 누수)
더보기위 문단을 통해 알 수 있는 내용으로는, emplace함수를 통해 삽입 함수에서 발생하는 임시 객체의 생성, 파괴 비용을 없앨 수 있다는 점 이었습니다.
이부분만 보면 생성 삽임 함수(emplace)는 삽입 함수(push, insert)보다 성능 측면에서 더 좋아보입니다.
하지만, emplace함수가 부정적으로 작동하는 상황도 있습니다.
다음 예제를 살펴보도록 하겠습니다.
std::vector<std::shared_ptr<MyClass>> vec; void killClass(MyClass* cls);
위와 같이 std::shared_ptr을 담는 컨테이너와, std::shared_ptr의 커스텀 삭제자가 있는 상황입니다.
위 상황에서 삽입 함수와 생성 삽입 함수는 각각 다음과 같이 사용될 수 있습니다.
// push vec.push_back(std::shared_ptr<MyClass>(new MyClass, killClass)); // or vec.push_back({ new MyClass, killClass }); // emplace vec.emplace_back(new MyClass, killClass);
중괄호 초기치를 이용한 표현은 생성자를 호출하는 것과 동일합니다.
위 두 경우를, 극단적인 예시를 통해 자세히 살펴보도록 하겠습니다.
우선, push_back입니다.
- new MyClass를 통해 임시 std::shraed_ptr객체가 생성됩니다. 이 객체를 temp라고 명명하도록 하겠습니다.
- push_back은 temp를 참조로 받고, temp의 복사본을 담을 노드를 할당합니다. 이 과정에서 메모리 부족(Out of memory)예외가 발생합니다.
- 예외가 push_back 바깥으로 전파되고, temp가 파괴됩니다. temp는 MyClass객체를 관리하는 마지막 std::shared_ptr객체였으므로, killClass를 호출하여 MyClass를 해제합니다.
메모리 부족 에러가 발생했으나, 커스텀 소멸자에 의해 객체가 정상적으로 해제됩니다.
다음은 emplace_back입니다.
- new MyClass를 통해 만들어진 Raw 포인터가 emplace_back으로 완벽 전달 됩니다. emplace_back은 컨테이너에 담기 위한 노드를 할당합니다. 이 때 메모리 부족 예외가 발생합니다.
- 예외가 emplace_back 바깥으로 전파됩니다. 이 때, 힙에 존재하는 MyClass객체에 도달할 수 있는 Raw 포인터가 소멸됩니다. MyClass는 해제되지 않았으므로, 자원 누수가 발생합니다.
emplace의 경우는 자원의 누수가 발생합니다.
이 경우, 코드 분석에서도 알 수 있듯이 std::shared_ptr에는 문제가 없습니다.
이러한 상황은 emplace의 기능적 특징이 좋지 않은 예외를 만나 발생한 결과입니다.
위 상황의 경우, 아래와 같이 해결할 수 있습니다.
// push std::shared_ptr<MyClass> sp(new MyClass, killClass); vec.push_back(std::move(sp)); // emplace std::shared_ptr<MyClass> sp(new MyClass, killClass); vec.emplace_back(std::move(sp));
두 접근 방식 모두 거의 유사하며, 비용적으로도 유사합니다.
모든 과정에서 sp의 생성과 파괴 비용이 발생합니다.
4. emplace의 맹점 (explicit)
더보기explicit생성자와의 관계에서도 살펴볼만 한 예시가 존재합니다.
다음과 같은 정규식 컨테이너를 생성하도록 하겠습니다.
std::vector<std::regex> vec;
그리고 이번에도, 극단적인 예시로, 다음과 같은 코드가 실행되었다고 가정하겠습니다.
vec.emplace_back(nullptr);
위 코드는 컴파일됩니다.
하지만, 다음과 같은 코드는 컴파일되지 않음에 주목해야 합니다.
std::regex r = nullptr; vec.push_back(nullptr);
두 코드 모두 컴파일되지 않습니다.
emplace_back은 되지만, push_back은 되지 않는 점에서 이 이유를 살펴볼 만 합니다.
std::regex의 생성자 중 const char*를 받는 생성자는 explicit으로 선언이 되어 있습니다.
위 코드가 컴파일되지 않는 이유는 생성자가 explicit으로 선언되어 있고, 위 코드들은 호출 과정에서 포인터->std::regex로의 형 변환을 요청하기 때문입니다.
하지만 emplace_back은 std::regex생성자에 직접 인수를 전달합니다.
const char*인자에 nullptr를 전달하는 것은 코드의 관점에서는 적법하므로, 컴파일 되는 것 입니다.
당연하게도, 다음과 같은 코드 또한 컴파일 됩니다.
std::regex r(nullptr);
물론, 위와 같은 nullptr을 통해 std::regex를 생성하는 것 자체가 미정의 행동을 유발하기 때문에, 적절한 행동이 되지는 않습니다.
이 문단에서 주목해야 할 점은 다음 두 코드입니다.
std::regex r1 = nullptr; // Can not compile std::regex r2(nullptr); // Can compile
표준의 어휘를 사용하면, 두 초기화는 복사 초기화와 직접 초기화로 나뉘게 됩니다.
- 복사 초기화 (Copy initialization) : 등호를 사용한 초기화 입니다.
- 직접 초기화 (Direct initialization) : 괄호(중괄호)를 사용한 초기화 입니다.
위 지식을 가지고, 문단의 주제인 explicit을 연결지어보도록 하겠습니다.
- explicit 생성자는 복사 초기화를 사용할 수 없지만, 직접 초기화는 사용할 수 있습니다.
위와 같은 이유때문에 r1, r2의 컴파일 여부가 갈리는 것 입니다.
또한, 이번 글의 주제와 연결지어, 다음과 같은 일반화가 가능합니다.
- 삽입 함수(push, insert... etc)는 복사 초기화(생성자)를 사용합니다. (explicit을 지원하지 않습니다.)
- 생성 삽임 함수(emplace)는 직접 초기화를 사용합니다. (explicit을 지원합니다.)
이론적으로, 생성 삽입 함수는 삽입 함수를 완전히 대체할 수 있으며, 성능이 떨어지는 경우는 없습니다.
하지만, 위와 같은 예시들 및 살펴보지 않은 예시들을 통해, 항상 그렇지는 않다는 것을 알 수 있었습니다.
이번 글을 통해 알 수 있는 점은, 생성 삽입은 성능 최적화 측면에서 유용할 수 있다는 점이며, 그것을 사용하기 위해서는 삽입되는 인수에 대한 철저한 점검이 필요할 것이라는 점 입니다.
감사합니다.
'C++ > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] 값 전달을 고려할만한 상황 (0) 2023.06.09 [Effective Modern C++] std::atomic VS volatile (0) 2023.05.12 [Effective Modern C++] 스레드 간 단발성 이벤트 통신 (0) 2023.05.04 [Effective Modern C++] std::future의 소멸자 (0) 2023.04.19 [Effective Modern C++] std::thread를 unjoinable하게 만들어야 하는 이유 (0) 2023.04.14