dev/C++

Effective Modern C++ #29 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라.

dev_dev 2025. 6. 28. 10:01

29. 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라.

move semantics (이동 의미론)는 C++11 의 가장 주요한 기능이다.

컴파일러는 비싼 복사 연산을 비교적 저렴한 이동 연산으로 대체할 수 있다. (가능하면 그렇게 해야한다)

또한 C++98 코드 기반을 C++11 준수하는 컴파일러와 표준 라이브러리로 컴파일하면, 소프트웨어가 자동으로 더 빠르게 실행된다. (컨테이너 등 내부적 로직에서 이동 연산이 사용되므로 자동 개선)

 

개발자들은 이동 연산에 열광하나, 과장된 부분도 분명 존재한다.

이번 시간에는 이동 의미론에 대해 근거 있는 기대를 가질 수 있게 자세히 알아보자.

 

move semantics 를 지원하지 않는 형식도 다수 존재한다.

  1. 특수 멤버 함수 자동 생성 조건에 맞지 않아서, 이동 연산이 자동으로 작성되지 않은 케이스
    • ex) 생성자 명시적으로 지정했으나, 이동 연산은 명시적으로 작성되지 않음
  2. 이동 연산 삭제 등으로 이동 연산 비활성화 되어 있는 케이스
    • ex) `=delete; 처리 되어 있음

move semantics 를 지원하나, 성능 효율이 크지 않은 경우도 존재한다.

 

저렴한 이동 방법이 없는 컨테이너도 있다.

또한 컨테이너의 요소들이 저렴한 이동 연산의 요구 사항을 만족시키지 못하는 경우도 있다.

 

예시로 C++11 에서 도입된 std::array 는 내장 배열에 STL 인터페이스를 씌운 것이다.

다른 표준 컨테이너처럼 자신의 내용을 힙에 저장하지 않는다.

 

다른 표준 컨테이너는 본인 자료 멤버에 힙 메모리를 가리키는 포인터만 할당한다.

이 포인터의 존재로 상수 시간에 컨테이너 전체 내용을 이동시킬 수 있다.

원본 포인터를 이동시킬 포인터로 복사하고, 원본은 null 로 설정하면 되기 때문이다.

 

std::vector 의 예시)

std::vector<Widget> vw1;
auto vw2 = std::move(vw1); // O(1)

vw2 에는 std::vector 의 요소들이 아닌, vw1 포인터가 복사된다.

 

std::array 객체에서는 이러한 포인터가 없으며, std::array 의 내용은 객체 자체에 저장된다.

std::array 의 예시)

std::array<Widget, 10000> aw1;
auto aw2 = std::move(aw1); // O(N)

 

aw1 의 각 원소는 aw2 로 이동된다.

Widget이 이동이 복사보다 빠른 형식일 때만, 이 이동 연산이 복사 연산보다 빠를 것이다.

하지만 std::array 에서는 이동, 복사 모두 O(N) 의 동일한 시간 복잡도를 가진다.

따라서 ‘컨테이너를 이동하는 것이 포인터를 복사하는 것만큼 저렴하다’ 는 항상 성립하지 않는다.

 

std::string 및 Small String Optimization

std::string은 이동에 O(1), 복사에 O(N) 의 시간 복잡도를 가진다.

따라서 이동 연산이 더 빠를 것으로 예상되나, 이 또한 항상 성립하지는 않는다.

문자열 구현에서는 작은 문자열 최적화(Small String Optimization, SSO)를 사용하는 것이 많기 때문이다.

 

작은 문자열은 std::string 객체 내 버퍼 (스택)에 저장하고, 힙에 할당하지 않는다.

작은 문자열 이동 연산은 복사 연산보다 빠르지 않다.

이동이 빠른 이유는 포인터 하나만 복사하면 되기 때문인데, 작은 문자열 이동은 포인터를 사용하지 않기 때문이다.

SSO 는 많은 프로그램에서 짧은 문자열이 자주 쓰이기 때문에 생긴 기법이다.

 

마지막으로 move semantics 를 사용할 수 없는 경우도 존재한다.

형식 상 이동 연산을 지원하며, 이동 연산이 더 빠르지만 실제로는 복사 연산이 일어난다.

이는 이동 연산 코드에 예외 안전성을 보장하기 위한 noexcept qualifier 가 없기 때문이다.

std::vector 와 같은 stl의 일부 컨테이너 연산은 강한 예외 안전성을 보장하며, 이동 연산이 예외를 던지지 않음이 확실할 때만 복사 연산을 이동 연산으로 대체한다.

 

move semantics 의미가 없는 경우

  1. 이동 연산 없음
  2. 이동 연산 빠르지 않음
  3. 이동 사용 불가능 (noexcept 누락)
  4. 원본 객체가 l-value임

위와 같이 move semantics 의미가 없는 경우에는 복사 연산이 동작한다.

따라서 일반적인 케이스에서 (모든 형식을 알 수 없으므로) 이동 의미론이 없던 C++98 시절처럼 객체의 복사 비용을 가정하고 코드를 작성하는 것이 좋다.

이 때 이동 연산은 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정한다.

하지만 코드가 사용하는 형식, 특징(이동 연산 저렴한지) 등을 알 수 있다면, 이동 연산을 적절한 문맥에서 사용해서 기존의 복사 연산을 더 저렴하게 대체할 것으로 믿어도 안전하다.