copy_elision — 복사 생략

copy_elision — 복사 생략 (Copy elision)

특정 조건이 충족되면, 같은 타입(복 소 생략은 cv 한정을 무시)의 원본 객체에서 클래스 객체를 만드는 과정을 생략할 수 있어요. 선택된 생성자나 소멸자에 부수 효과(side effect)가 있더라도 가능해요. 이렇게 객체 생성을 생략하는 것을 **복사 생략(copy elision)**이라고 불러요.

복사 생략은 성능 최적화의 대표적인 기법이에요. 객체를 반환하거나 전달할 때 불필요한 복사·이동을 없애서, 중간 임시 객체를 만들지 않게 해줘요.

출처: cppreference

본문

특정 조건이 충족되면, 같은 타입(복사 생략은 cv 한정을 무시)의 원본 객체에서 클래스 객체를 만드는 과정을 생략할 수 있어요. 선택된 생성자와 소멸자에 부수 효과가 있더라도 마찬가지예요. 이 객체 생성 생략을 복사 생략이라고 불러요.

설명 (Explanation)

복사 생략은 다음 상황에서 허용돼요 (여러 상황이 결합되어 여러 번의 복사를 없앨 수도 있어요).

  • 반환 문(return statement) — 클래스 반환 타입의 함수에서, 피연산자가 자동 저장 기간(storage duration)을 가진 비휘발성 객체 obj의 이름일 때(함수 매개변수나 catch 처리자 매개변수는 제외), 결과 객체의 복사 초기화를 생략하고 obj를 함수 호출의 결과 객체에 직접 생성할 수 있어요. 이 변형을 **명명된 반환 값 최적화(NRVO)**라고 불러요.
  • 임시 객체 초기화 (C++17 이전) — 같은 타입의 클래스 객체 target이 참조에 묶이지 않은 임시 클래스 객체 obj로 초기화될 때, objtarget에 직접 생성해 초기화를 생략할 수 있어요. 이 변형을 **비명명된 반환 값 최적화(URVO)**라고 불러요. C++17부터 URVO는 필수라 더 이상 복사 생략의 한 형태로 간주되지 않아요.
  • throw 표현식 — 피연산자가 자동 저장 기간을 가진 비휘발성 객체 obj의 이름이고, 그 객체가 가장 안쪽으로 둘러싼 try 블록이 포함되지 않는 범위에 속할 때, 예외 객체의 복사 초기화를 생략하고 obj를 예외 객체에 직접 생성할 수 있어요.
  • catch 처리자(handler) — 처리자 매개변수가 예외 객체의 별칭으로 취급될 수 있다면, 처리자 인자의 복사 초기화를 생략할 수 있어요.
  • 코루틴(coroutine) — 코루틴 매개변수의 복사를 생략할 수 있어요.

복사 생략이 일어나면, 구현은 생략된 초기화의 원본과 대상을 같은 객체를 가리키는 서로 다른 두 방식으로 취급해요.

Prvalue 의미론 ("보장된 복사 생략")

C++17부터 prvalue는 필요해질 때까지 구체화(materialize)되지 않고, 최종 목적지의 저장 공간에 직접 생성돼요. 이는 때로 문법상으로는 복사/이동이 일어나는 것처럼 보여도(예: 복사 초기화) 실제로는 복사/이동이 수행되지 않는다는 뜻이에요. 즉 그 타입은 접근 가능한 복사/이동 생성자를 가질 필요조차 없어요. 예를 들어:

  • return 문에서 피연산자가 함수 반환 타입과 같은 클래스 타입의 prvalue일 때
  • 변수가 같은 클래스 타입의 prvalue로 초기화될 때
struct C { /* ... */ };
C f();

struct D;
D g();

struct D : C
{
    D() : C(f()) {}    // 기반 클래스 부분 객체를 초기화할 땐 생략 없음
    D(int) : D(g()) {} // 생략 없음. 초기화되는 D 객체가
                       // 다른 클래스의 기반 클래스 부분 객체일 수 있기 때문
};

이 규칙은 최적화를 지정하는 게 아니고, 표준에서도 "복사 생략"으로 공식 설명하지 않아요. C++17의 prvalue와 임시 객체에 대한 핵심 언어 명세는 이전 C++ 개정판과 근본적으로 달라요. 복사/이동할 임시 객체가 더 이상 존재하지 않기 때문이죠. "미구체화 값 전달(unmaterialized value passing)" 또는 "지연된 임시 구체화(deferred temporary materialization)"라고도 설명해요.

노트 (Notes)

복사 생략은 관찰 가능한 부수 효과를 바꿀 수 있는 유일하게 허용된 최적화 형태예요. 일부 컴파일러는 허용되는 모든 상황에서 복사 생략을 수행하지 않기 때문에(예: 디버그 모드), 복사/이동 생성자와 소멸자의 부수 효과에 의존하는 프로그램은 이식성이 없어요.

C++11부터, 컴파일러가 복사 생략을 할 수 없지만 그 조건은 충족되는 상황(또는 원본이 함수 매개변수라서 조건이 충족될 뻔한 상황)이라면, 원본이 lvalue로 지정돼도 이동 생성자를 사용하려고 시도해요.

상수 표현식(constant expression)과 상수 초기화에서는 복사 생략이 절대 수행되지 않아요.

예제 (Example)

#include <iostream>

struct Noisy
{
    Noisy() { std::cout << "constructed at " << this << '\n'; }
    Noisy(const Noisy&) { std::cout << "copy-constructed\n"; }
    Noisy(Noisy&&) { std::cout << "move-constructed\n"; }
    ~Noisy() { std::cout << "destructed at " << this << '\n'; }
};

Noisy f()
{
    Noisy v = Noisy(); // (C++17 이전) v 를 임시 객체로부터 복사 생략;
                       //               이동 생성자가 호출될 수 있음
                       // (C++17 이후) "guaranteed copy elision"
    return v; // v 에서 결과 객체로 복사 생략 ("NRVO");
              // 이동 생성자가 호출될 수 있음
}

void g(Noisy arg)
{
    std::cout << "&arg = " << &arg << '\n';
}

int main()
{
    Noisy v = f(); // (C++17 이전) f() 의 결과로부터 v 를 복사 생략
                   // (C++17 이후) "guaranteed copy elision"

    std::cout << "&v = " << &v << '\n';

    g(f()); // (C++17 이전) f() 의 결과로부터 arg 를 복사 생략
            // (C++17 이후) "guaranteed copy elision"
}

출력에서 varg가 각각 함수 내부의 객체와 같은 주소를 공유하는 걸 볼 수 있어요. 즉 추가 복사 없이 같은 객체를 재사용했어요.

constructed at 0x7fffd635fd4e
&v = 0x7fffd635fd4e
constructed at 0x7fffd635fd4f
&arg = 0x7fffd635fd4f
destructed at 0x7fffd635fd4f
destructed at 0x7fffd635fd4e

더 알아보기 (Learn more)

cppreference