[Modern C++] 항목 29: 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 5. rvalue 참조, 이동 개념, 완벽 전달
👉🏻 항목 29: 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라
🔍 도입
- 이동 개념은 C++11의 가장 주된 기능이다. “컨테이너를 이동하는 것이 포인터를 복사하는 것만큼 저렴하다”, “임시 객체의 복사를 피하는 코딩은 때 이른 최적화다” 같은 말을 들어 보았을 것이다.
- 이동 개념 덕분에 컴파일러는 비싼 복사 연산을 비교적 저렴한 이동 연산으로 대체할 수 있다. 그러나 이 항목의 목적은 이동 개념에 대해 근거 있는 기대를 갖게 하는 것이다.
- C++98 코드 기반을 C++11을 준수하는 컴파일러와 표준 라이브러리로 컴파일하면 소프트웨어가 저절로 더 빠르게 실행되기도 한다. 그러나 이 기능이 만능은 아니다.
🔍 이동 개념이 도움이 되지 않는 이유 ① — 이동을 지원하지 않는 타입
- C++11은 C++98 표준 라이브러리를 개정하면서, 이동이 복사보다 빠른 타입에는 이동 연산을 추가했다. 그러나 독자가 다루는 코드 기반은 C++11에 맞게 개정되지 않았을 가능성이 있다.
- 이동을 명시적으로 지원하지 않는 타입에 대해 C++11이 이동 연산을 자동으로 작성해 주긴 하지만, 다음 경우에는 자동 작성이 일어나지 않는다(항목 17 참고).
- 타입에 복사 연산, 이동 연산, 소멸자가 하나라도 선언되어 있는 경우
- 타입의 자료 멤버나 기반 클래스에 이동이 비활성화되어 있는 경우 (예: 이동 연산을
delete한 경우 - 항목 11 참고)
- 이동을 명시적으로 지원하지 않으며 컴파일러의 이동 연산 작성 대상도 아닌 타입은, C++98에 비해 C++11이 성능을 향상해 주리라고 기대할 이유가 없다.
🔍 이유 ② — 이동이 저렴하지 않은 타입 (std::array, std::string)
- 이동을 명시적으로 지원하는 타입에서도 성능상 이득이 생각만큼 크지 않을 수 있다. C++11 표준 라이브러리의 모든 컨테이너가 이동을 지원하지만, 모든 컨테이너의 이동이 저렴하다고 가정하는 것은 실수다.
std::vector — 이동이 상수 시간
- 대부분의 컨테이너는 내용을 힙에 저장하고, 컨테이너 객체 자체에는 힙 메모리를 가리키는 포인터만 담는다. 포인터만 복사하고 원본 포인터를 널로 설정하면 되므로 상수 시간 이동이 가능하다.
std::vector<Widget> vw1;
// vw1에 자료를 채운다
…
// vw1을 vw2로 이동한다. 이 이동은
// 상수 시간으로 실행된다. 오직 vw1과
// vw2의 포인터들만 수정된다
auto vw2 = std::move(vw1);
std::array — 이동이 선형 시간
std::array는 본질적으로 내장 배열에 STL 인터페이스를 씌운 것으로, 내용이 힙이 아니라 객체 자체에 직접 저장된다. 즉 포인터가 없다.
std::array<Widget, 10000> aw1;
// aw1에 자료를 채운다
…
// aw1을 aw2로 이동한다. 이 이동은
// 선형 시간으로 실행된다. aw1의
// 모든 원소가 aw2로 이동된다
auto aw2 = std::move(aw1);
aw1의 각 원소가aw2로 일일이 이동된다.Widget의 이동이 복사보다 빠르다면std::array의 이동도 복사보다 빠르지만, 이동과 복사 모두 계산 복잡도가 선형이다. “컨테이너를 이동하는 것은 포인터를 복사하는 것만큼 저렴하다”는 주장과는 거리가 멀다.
std::string — SSO 때문에 이동이 복사보다 빠르지 않을 수 있다
std::string은 상수 시간 이동과 선형 시간 복사를 제공하므로 이동이 복사보다 빠를 것 같지만 반드시 그렇지는 않다.- 많은 문자열 구현이 작은 문자열 최적화(SSO, small string optimization)를 사용한다. ‘작은’ 문자열(예: 용량 15자 이하)을
std::string객체 안의 버퍼에 저장하고 힙 할당 저장소는 사용하지 않는다. - 이런 구현에서 작은 문자열의 이동은 복사보다 빠르지 않다. 이동이 복사보다 빠른 것은 대체로 포인터 하나만 복사하면 되기 때문인데, SSO 상황에서는 그런 요령을 적용할 수 없다.
- SSO는 짧은 문자열이 통상적으로 쓰인다는 방대한 증거에서 비롯된 기법이다. 내용을 내부 버퍼에 저장하면 동적 메모리 할당이 필요 없어 대체로 효율성이 좋아지지만, 그 결과 이동이 복사보다 빠르지 않게 된다.
🔍 이유 ③ — 이동을 사용할 수 없는 경우 (noexcept)
- 항목 14에서 설명했듯이, 표준 라이브러리의 일부 컨테이너 연산은 강한 예외 안전성을 보장한다. C++98 코드가 C++11에서 컴파일되어도 망가지지 않게 하기 위해, 이동 연산이 예외를 던지지 않음이 확실한 경우에만 기반 복사 연산을 이동 연산으로 대체한다.
- 따라서 복사보다 효율적인 이동 연산을 제공하는 타입이고 코드의 특정 지점에서 이동이 적합해 보여도(예: 원본 객체가 rvalue), 해당 이동 연산이
noexcept로 선언되어 있지 않으면 컴파일러는 여전히 복사 연산을 호출할 수 있다. std::move대신std::move_if_noexcept를 사용하는 게 바람직한 경우도 있다(항목 14 참고).
🔍 이동 개념이 도움이 되지 않는 시나리오 정리
- 이동 연산이 없다: 이동할 객체가 이동 연산들을 제공하지 않는다. 이 경우 이동 요청은 복사 요청이 된다.
- 이동이 더 빠르지 않다: 이동할 객체의 이동 연산이 해당 복사 연산보다 빠르지 않다(예:
std::array, SSO를 쓰는std::string). - 이동을 사용할 수 없다: 이동이 일어나려면 이동 연산이 예외를 방출하지 않아야 하는 문맥에서, 해당 연산이
noexcept로 선언되어 있지 않다. - 원본 객체가 lvalue다: 아주 드문 경우(항목 25 참고)지만, 오직 rvalue만 이동 연산의 원본이 될 수 있는 경우도 있다.
🔍 그래서 어떻게 가정해야 하는가
- 이 항목의 조언은 이동 연산들이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라는 것이다.
- 일반적 코드(예: 템플릿)에서는 대체로 그런 가정이 사실이다. 코드에 쓰이는 모든 타입을 알 수 없기 때문이다. 그런 상황에서는 이동 개념이 존재하기 전인 C++98 시절처럼 객체의 복사를 보수적으로 다루어야 한다. 코드가 사용하는 타입의 특징이 비교적 자주 바뀌는 “안정적이지 않은” 코드에도 적용된다.
- 반대로, 코드가 사용하는 구체적인 타입들을 미리 알 수 있고 그 타입들의 특징(저렴한 이동 연산 지원 여부 등)이 바뀌지 않으리라 확신할 수 있는 경우에는 이 항목의 가정을 둘 필요가 없다. 사용하는 타입이 제공하는 이동 연산의 구체적인 사항을 찾아보고, 복사보다 저렴한 이동 연산을 제공하며 그 이동 연산이 실행될 문맥에서 객체를 사용한다면, 이동 개념 덕분에 복사 연산이 더 저렴한 이동 연산으로 대체될 것이라고 믿어도 안전하다.
🧐 정리
- 이동 연산들이 존재하지 않고, 저렴하지 않고, 적용되지 않을 것이라고 가정하라.
- 타입들과 이동 개념 지원 여부를 미리 알 수 있는 경우에는 그런 가정을 둘 필요가 없다.
댓글남기기