[Modern C++] 항목 42: 삽입 대신 생성 삽입을 고려하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다.
e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 8. 다듬기
👉🏻 항목 42: 삽입 대신 생성 삽입을 고려하라
🔍 push_back의 숨은 비용
std::string컨테이너에 삽입 함수(insert,push_front,push_back,std::forward_list의 경우insert_after)로 새 요소를 추가한다고 하자.- 논리적으로는 삽입 함수에 넘기는 요소의 타입이
std::string이어야 한다. 그런데 다음 코드를 보자.
std::vector<std::string> vs; // std::string 컨테이너
vs.push_back("xyzzy"); // 문자열 리터럴을 추가한다
- 문자열 리터럴은
std::string객체가 아니므로,push_back에 넘기는 인수는 컨테이너 요소의 타입과 다르다. std::vector의push_back은 lvalue와 rvalue에 대해 오버로딩되어 있다.
template <class T, // C++11 표준
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")); // 임시 std::string 객체를
// 생성해서 push_back에 전달
- 실행 시점에 일어나는 일은 다음과 같다.
- 문자열 리터럴
"xyzzy"로부터 임시std::string객체(temp)가 생성된다. 이것이 첫 번째std::string생성이며,temp는 임시 객체이므로 rvalue이다. temp가push_back의 rvalue 오버로딩으로 전달되어 rvalue 참조 매개변수x에 묶인다. 그다음std::vector를 위한 메모리 안에서x의 복사본이 생성되는데, 이것이 두 번째std::string생성이며std::vector안에 실제로 새 객체가 만들어진다. (x는 rvalue 참조라서 복사되기 전에 rvalue로 캐스팅되므로 이동 생성자가 쓰인다. 항목 25 참고)push_back이 반환된 즉시temp가 파괴되어std::string소멸자가 실행된다.
- 문자열 리터럴
- 만일 단계 2에서
std::vector안에std::string객체를 생성하는 코드에 문자열 리터럴을 직접 전달할 수 있다면temp의 생성과 파괴를 피할 수 있다.
✅ 해결책: emplace_back
- 그 방법은
push_back을 호출하지 않는 것이다. 지금 필요한 것은emplace_back이다. emplace_back은 주어진 인수를 이용해서std::vector안에서 직접std::string을 생성한다. 그 과정에 임시 객체는 관여하지 않는다.
vs.emplace_back("xyzzy"); // "xyzzy"를 이용해서 std::string을
// vs 안에 직접 생성한다
emplace_back은 완벽 전달을 이용하므로, 완벽 전달의 한계(항목 30)에 부딪히지 않는 한 임의의 타입, 임의의 개수의 인수 조합을 전달할 수 있다.
vs.emplace_back(50, 'x'); // 'x' 문자 50개로 이루어진
// std::string을 삽입
🔍 생성 삽입 함수의 종류
push_back을 지원하는 모든 표준 컨테이너는emplace_back을 지원한다.push_front를 지원하는 모든 표준 컨테이너는emplace_front를 지원한다.insert를 지원하는 모든 표준 컨테이너(std::forward_list와std::array를 제외한 모든 표준 컨테이너)는emplace를 지원한다.- 연관 컨테이너들은
insert에 대응되는emplace_hint(힌트 반복자를 받는다)를 제공한다. std::forward_list에는insert_after에 대응되는emplace_after가 있다.- 생성 삽입 함수가 삽입 함수보다 성능이 뛰어난 비결은 인터페이스가 더 유연하다는 것이다.
- 삽입 함수는 삽입할 객체를 받지만, 생성 삽입 함수는 삽입할 객체의 생성자를 위한 인수들을 받는다.
- 덕분에 삽입 함수에서는 꼭 필요할 수 있는 임시 객체의 생성과 파괴를 피한다.
- 컨테이너에 담긴 요소의 타입과 동일한 타입의 인수를 생성 삽입 함수에 전달할 수도 있다(그 경우 복사 생성 또는 이동 생성을 수행한다). 그 경우 삽입과 생성 삽입은 본질적으로 같은 일을 한다.
std::string queenOfDisco("Donna Summer");
vs.push_back(queenOfDisco); // vs의 끝에서 queenOfDisco를
// 복사 생성한다
vs.emplace_back(queenOfDisco); // 마찬가지
- 따라서 생성 삽입 함수는 삽입 함수가 하는 모든 일을 할 수 있고, 더 효율적으로 수행하는 경우가 있으며, 이론적으로는 덜 효율적인 경우가 없다.
- 그러나 실제로는 차이가 있다. 표준 라이브러리의 현재 구현에서는 삽입 함수가 더 빠르게 실행되는 상황도 존재하며, 그런 상황을 특징짓기는 쉽지 않다.
- 전달된 인수들의 타입, 삽입 대상 컨테이너의 종류, 삽입 위치, 컨테이너가 담는 타입의 생성자의 예외 안전성, 값의 중복이 금지된 컨테이너(
std::set,std::map,std::unordered_set,std::unordered_map)의 경우 값이 이미 컨테이너에 있는지의 여부 등 다양한 요인이 영향을 미친다.
- 전달된 인수들의 타입, 삽입 대상 컨테이너의 종류, 삽입 위치, 컨테이너가 담는 타입의 생성자의 예외 안전성, 값의 중복이 금지된 컨테이너(
- 따라서 통상적인 성능 조율 조언이 적용된다. 즉, 어느 쪽이 더 빠른지 직접 측정해 보라.
🔍 생성 삽입이 바람직한 세 조건
다음 세 조건이 모두 성립한다면 거의 항상 생성 삽입의 성능이 삽입의 성능을 능가한다.
① 추가할 값이 컨테이너에 대입되는 것이 아니라 컨테이너 안에서 생성된다.
- 도입부 예제(
"xyzzy"를vs의 끝에 추가)가 이런 경우다. 값이 아직 그 어떤 객체도 존재하지 않는 곳에 추가되므로 새 값은std::vector안에 생성되어야 한다. - 새
std::string을 이미 다른 객체가 차지하고 있는 장소에 배치하도록 코드를 바꾸면 상황이 달라진다.
std::vector<std::string> vs; // 이전과 동일
… // 요소들을 vs에 추가한다
vs.emplace(vs.begin(), "xyzzy"); // "xyzzy"를 vs의
// 시작에 추가한다
- 이런 코드에서
vs[0]이 차지하던 메모리에std::string을 생성하는 표준 라이브러리 구현은 드물다. 대신 구현들은 그 값을 해당 장소로 이동 대입한다. - 이동 대입을 수행하려면 이동의 원본이 필요하므로 이동 원본이 될 임시 객체를 생성해야 한다.
- 삽입에 비한 생성 삽입의 주된 장점은 임시 객체를 생성, 파괴하는 일이 없다는 것이므로, 이처럼 값을 대입을 통해서 컨테이너 안에 넣을 때에는 생성 삽입의 장점이 사라지는 경향이 생긴다.
- 안타깝게도 한 값을 컨테이너에 추가할 때 생성을 사용하는지 대입을 사용하는지는 일반적으로 구현자가 결정한다. 그러나 이 경우에도 휴리스틱이 도움이 된다.
- 노드 기반 컨테이너들은 거의 항상 생성을 통해서 새 값을 추가하며, 표준 컨테이너들은 대부분 노드 기반이다.
- 노드 기반이 아닌 표준 컨테이너는
std::vector와std::deque,std::string뿐이다. (std::array도 아니지만, 삽입과 생성 삽입 둘 다 지원하지 않으므로 논의와 무관하다.) - 노드 기반이 아닌 컨테이너에서는
emplace_back이 항상 대입 대신 생성을 이용해서 새 값을 컨테이너에 넣는다고 간주해도 무방하다.std::deque의 경우에는emplace_front역시 마찬가지이다.
② 추가할 인수 타입(들)이 컨테이너가 담는 타입과 다르다.
- 삽입에 비한 생성 삽입의 장점은 유연한 인터페이스 덕분에 전달된 인수 타입들이 컨테이너가 담는 타입과 달라도 임시 객체의 생성과 파괴가 일어나지 않는다는 것이다.
컨테이너<T>에T타입의 객체를 추가할 때에는 삽입 인터페이스에서도 임시 객체를 생성할 필요가 없기 때문에 생성 삽입이 삽입보다 빠를 이유가 없다.
③ 컨테이너가 기존 값과의 중복 때문에 새 값을 거부할 우려가 별로 없다.
- 이는 컨테이너가 중복을 허용하거나, 추가할 값들이 대부분 고유한 경우에 해당한다.
- 중복 제한이 있는 경우 일반적으로 생성 삽입 구현은 새 값으로 노드를 생성해서 그것을 기존 컨테이너 노드들과 비교한다.
- 추가할 값이 컨테이너에 없으면 그 노드를 컨테이너에 연결(link)한다.
- 값이 이미 있으면 생성 삽입이 취소되고 그 노드가 파괴된다. 따라서 생성과 파괴 비용이 낭비된다.
- 이런 노드들은 삽입 함수보다 생성 삽입 함수에서 더 자주 생성된다.
이번 항목에서 지금까지 나온 호출 예들은 이 세 조건을 모두 만족하며, 해당 push_back 호출보다 더 빠르게 실행된다.
vs.emplace_back("xyzzy"); // 새 값을 컨테이너의 끝에 생성한다;
// 인수의 타입이 컨테이너 요소의 타입과
// 다르다; 컨테이너는 중복된 요소를
// 거부하지 않는다
vs.emplace_back(50, 'x'); // 마찬가지
⚠️ 고려사항 ①: 자원 관리와 예외 안전성
std::shared_ptr<Widget>들을 담는 컨테이너가 있다고 하자.
std::list<std::shared_ptr<Widget>> ptrs;
- 커스텀 삭제자(항목 19)를 통해서 해제되어야 하는
std::shared_ptr객체 하나를 이 컨테이너에 추가한다고 하자. - 항목 21에서 설명했듯이
std::shared_ptr를 생성할 때는 가능하면std::make_shared를 사용하는 것이 좋지만, 커스텀 삭제자를 지정해야 하는 경우처럼 그럴 수 없는 경우도 있다. 이 경우 반드시new로 얻은 raw 포인터로 그것을 관리할std::shared_ptr객체를 생성해야 한다.
void killWidget(Widget* pWidget); // 커스텀 삭제자로 사용할 함수
삽입 함수를 이용하는 코드는 다음과 같다.
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));
또는 이런 방식일 수도 있다. 의미는 동일하다.
ptrs.push_back({ new Widget, killWidget });
- 어떤 방식이든
push_back호출 전에 임시std::shared_ptr객체가 생성된다.push_back의 매개변수는std::shared_ptr에 대한 참조이므로 그 매개변수가 참조할 객체가 반드시 있어야 한다. - 임시
std::shared_ptr객체의 생성 비용은 일반적으로emplace_back으로 피하고자 하는 것이지만, 이 경우 임시 객체에는 그 생성 비용 이상의 가치가 있다. 다음과 같은 일련의 사건들이 일어난다고 생각해 보자.- 두 호출 모두
"new Widget"으로 만들어진 raw 포인터를 담는 임시std::shared_ptr<Widget>객체가 생성된다. 이 객체를temp라고 부르기로 하자. push_back은 그temp를 참조로 받는다.temp의 복사본을 담을 목록 노드를 할당하는 도중에 메모리 부족(out-of-memory) 예외가 발생한다.- 예외가
push_back바깥으로 전파되면서temp가 파괴된다.temp는 자신이 관리하는Widget을 참조하는 마지막std::shared_ptr객체였으므로,temp가 파괴되면서 해당Widget도 자동으로 해제된다(killWidget호출에 의해).
- 두 호출 모두
- 예외가 발생해도 어떠한 누수도 일어나지 않는다.
"new Widget"으로 생성된Widget은 그것을 관리하기 위해 생성된std::shared_ptr객체(temp)의 소멸자에서 해제된다. 행복한 결말이다.
이번에는 push_back 대신 emplace_back을 호출하면 어떤 일이 생기는지 생각해 보자.
ptrs.emplace_back(new Widget, killWidget);
"new Widget"으로 만들어진 raw 포인터가emplace_back으로 완벽 전달된다.emplace_back은 새 값을 담을 목록 노드를 할당한다. 그런데 할당이 실패하고 메모리 부족 예외가 발생한다.- 예외가
emplace_back밖으로 전파되면서, 힙에 있는Widget객체에 도달하는 유일한 수단인 raw 포인터가 사라진다. 결국 그Widget이(그리고 그 객체가 소유한 모든 자원이) 샌다(자원 누수).
- 이 시나리오의 결말은 행복하지 않다. 그리고 그 원인이
std::shared_ptr는 아니다. 커스텀 삭제자를 가진std::unique_ptr를 사용한다고 해도 같은 종류의 문제가 발생할 수 있다. - 근본적으로
std::shared_ptr나std::unique_ptr같은 자원 관리 클래스의 유효성은 자원(new로 얻은 raw 포인터 등)이 그것을 관리하는 객체(자원 관리 객체)의 생성자에 즉시 전달된다는 조건에 의존한다.std::make_shared나std::make_unique같은 함수가 중요한 것은 이들이 그런 ‘생성 즉시 전달’을 보장해 주기 때문이다. - 자원 관리 객체들을 담은 컨테이너의 삽입 함수를 호출할 때, 일반적으로 삽입 함수의 매개변수 타입들은 자원의 획득(
new의 적용)과 그 자원을 관리하는 객체의 생성 사이에 아무것도 끼어들지 않음을 보장한다. - 그러나 생성 삽입 함수에서는 자원 관리 객체의 생성이 컨테이너의 메모리 안에서 그것을 생성할 수 있는 시점까지 지연된다(완벽 전달에 의해). 그 시점까지의 ‘위험 구간’ 도중에 예외가 발생하면 자원 누수가 일어날 수 있다.
- 따라서 자원 관리 객체들의 컨테이너를 다루면서 효율성을 위해 삽입 대신 생성 삽입을 선택할 때에는, 그 때문에 예외 안전성이 줄어들지는 않는지 반드시 점검해야 한다.
올바른 방법
- 사실
"new Widget"같은 표현식을emplace_back이나push_back에(그리고 대부분의 함수에) 넘겨주는 것은 바람직하지 않다(항목 21). - 예외에 안전하지 않은 위험 구간을 제거하려면,
"new Widget"으로 생긴 포인터를 받아서 자원 관리 객체를 생성하는 작업을 개별적인 문장에서 수행하고, 그 자원 관리 객체를 rvalue로서 전달해야 한다.
std::shared_ptr<Widget> spw(new Widget, // Widget 객체와 그것을
killWidget); // 관리하는 spw 객체를 생성
ptrs.push_back(std::move(spw)); // spw를 rvalue로 전달
emplace_back 버전도 비슷하다.
std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.emplace_back(std::move(spw));
- 어떤 방식이든 이 접근방식에는
spw의 생성과 파괴 비용이 발생한다. 생성 삽입을 사용하는 동기가 컨테이너가 담는 타입(개념적으로는spw)의 임시 객체를 피하는 것이지만, 자원 관리 객체를 컨테이너에 추가한다면(그리고 자원 획득과 자원 관리 객체로의 변환 시점 사이에 아무것도 끼어들지 못하게 하라는 조언을 따른다면) 생성 삽입이 삽입 함수보다 더 나은 성능을 보일 가능성은 별로 없다.
⚠️ 고려사항 ②: explicit 생성자와의 상호작용
C++11에 정규 표현식이 추가된 점을 축하하는 의미에서, 정규표현식(정규식)들을 담은 컨테이너를 만들어보자.
std::vector<std::regex> regexes;
실수로 다음과 같이 무의미해 보이는 코드를 작성했다고 하자.
regexes.emplace_back(nullptr); // 정규식들의 컨테이너에
// nullptr를 추가한다고?
- 이 코드를 입력하는 동안 실수를 깨닫지 못했으며, 컴파일러도 불평 없이 받아들였기 때문에 나중에 디버깅하는 데 상당한 시간을 허비하게 된다.
- 포인터는 정규표현식이 아니므로 다음과 같은 일을 시도하면 컴파일러가 코드를 거부한다.
std::regex r = nullptr; // 오류! 컴파일 안 됨
- 흥미롭게도
emplace_back은 되지만 다음처럼push_back을 호출하면 컴파일러가 컴파일을 거부한다.
regexes.push_back(nullptr); // 오류! 컴파일 안 됨
- 이 이상한 현상은 C 스타일 문자열로부터
std::regex객체를 구축할 수 있다는 사실에서 비롯된 것이다. 이 덕분에 다음과 같은 유용한 코드가 적법해진다.
std::regex upperCaseWord("[A-Z]+");
- 실행 시점에서 C 스타일 문자열로부터
std::regex를 생성하려면 비교적 큰 비용이 필요할 수 있으므로, 의도하지 않게 그런 비용이 발생할 가능성을 줄이기 위해const char*포인터를 받는std::regex생성자는explicit으로 선언되어 있다. 다음 두 줄이 컴파일되지 않는 것은 바로 그 때문이다.
std::regex r = nullptr; // 오류! 컴파일 안 됨
regexes.push_back(nullptr); // 오류! 컴파일 안 됨
- 두 경우 모두 포인터에서
std::regex로의 암묵적 변환을 요청하지만, 생성자가explicit이라서 컴파일러는 그러한 변환을 거부한다. - 그러나
emplace_back호출 시 지정한nullptr는std::regex객체로 변환할 무엇이 아니라std::regex객체의 생성자에 전달할 인수이다. 따라서 컴파일러는 이를 암묵적 변환 요청으로 간주하지 않고, 대신 애초에 다음과 같은 코드를 작성한 것처럼 취급한다.
std::regex r(nullptr); // 컴파일됨
- 이 코드가 컴파일되긴 하지만 미정의 행동을 포함하고 있다.
const char*포인터를 받는std::regex생성자에는 그 포인터가 널이 아니어야 한다는 요구조건이 있으며,nullptr는 그러한 요구조건을 만족하지 못한다.
복사 초기화 vs 직접 초기화
- 다음처럼 아주 비슷한 두 초기화 구문이 다른 결과를 낸다는 점에 주목하자.
std::regex r1 = nullptr; // 오류! 컴파일 안 됨
std::regex r2(nullptr); // 컴파일됨
- 표준의 공식 어법으로 말하자면,
r1을 초기화하는 데 쓰인 구문(등호가 있는 것)은 소위 복사 초기화(copy initialization)이다. 반면r2를 초기화하는 데 쓰인 구문(괄호가 있는 것; 사실 중괄호를 사용해도 된다)은 소위 직접 초기화(direct initialization)이다. explicit생성자에서는 복사 초기화를 사용할 수 없지만 직접 초기화는 사용할 수 있다. 이 때문에r1의 초기화 문장은 컴파일되지 않지만r2의 초기화 문장은 컴파일된다.- 이제 다시
push_back과emplace_back으로, 좀 더 일반화하자면 삽입 함수와 생성 삽입 함수의 차이로 돌아가자.- 생성 삽입 함수는 직접 초기화를 사용한다. 따라서
explicit생성자를 지원한다. - 삽입 함수는 복사 초기화를 사용한다. 따라서
explicit생성자를 지원하지 않는다.
- 생성 삽입 함수는 직접 초기화를 사용한다. 따라서
regexes.emplace_back(nullptr); // 컴파일됨; 직접 초기화에서는
// 포인터를 받는, explicit으로
// 선언된 std::regex 생성자를
// 사용할 수 있다
regexes.push_back(nullptr); // 오류! 복사 초기화에서는
// 그런 생성자를 사용할 수 없다
- 이 논의의 교훈은, 생성 삽입 함수를 사용할 때에는 제대로 된 인수를 넘겨주는 데 특별히 신경을 써야 한다는 것이다. 컴파일러는 독자의 코드를 어떻게든 유효하게 만들기 위해 심지어
explicit생성자들까지도 고려한다는 점을 기억하자.
🧐 정리
- 이론적으로, 생성 삽입 함수들은 종종 해당 삽입 버전보다 더 효율적이어야 하며, 덜 효율적인 경우는 절대로 없어야 한다.
- 실질적으로, 만일 (1) 추가하는 값이 컨테이너로 대입되는 것이 아니라 생성되고, (2) 인수 타입(들)이 컨테이너가 담는 타입과 다르고, (3) 그 값이 중복된 값이어도 컨테이너가 거부하지 않는다면, 생성 삽입 함수가 삽입 함수보다 빠를 가능성이 아주 크다.
- 생성 삽입 함수는 삽입 함수라면 거부당했을 타입 변환들을 수행할 수 있다(
explicit생성자 포함). - 자원 관리 객체(
std::shared_ptr등)를 담는 컨테이너에서는 생성 삽입으로 인해 예외 안전성이 줄어들 수 있으므로, 자원 관리 객체를 별도 문장에서 생성한 뒤 rvalue로 전달하라.
댓글남기기