큰 객체를 값으로 반환하는 함수를 보면 움찔하는 사람이 아직 많다. 그래서 반환값 대신 출력 매개변수를 받는 코드가 오래된 코드베이스에 잔뜩 남아 있다.

이걸 안심시키는 오래된 설명이 “RVO(반환값 최적화)가 있으니 괜찮다”는 것이다. 틀린 말은 아닌데, 이 설명은 C++17 이전에 만들어졌다. C++17에서 이 이야기는 하나가 아니라 성질이 다른 세 가지로 갈라졌다. 하나는 표준이 보장하고, 하나는 여전히 컴파일러 재량이고, 하나는 애초에 생략이 아니다.

셋을 구별하지 못하면 “컴파일러가 알아서 해주겠지”라고 믿은 자리에서 4KB짜리 memcpy가 조용히 돌아간다. g++ 13.2로 하나씩 확인한 기록이다.

먼저 실측

복사·이동 생성자가 호출될 때마다 찍는 타입을 만들어 세 가지 반환 형태를 돌렸다.

struct Big {
  Big()          { std::puts("  생성자"); }
  Big(const Big&){ std::puts("  복사 생성자"); }
  Big(Big&&)     { std::puts("  이동 생성자"); }
};
 
Big MakePrvalue() { return Big(); }              // 이름 없는 임시를 반환
Big MakeNamed()   { Big local; return local; }   // 이름 있는 지역 변수를 반환
Big MakeTwoVars(bool flag) {                     // 분기마다 다른 변수를 반환
  Big a, b;
  if (flag) return a;
  return b;
}

-O2로 컴파일한 결과다. 오른쪽 열은 -fno-elide-constructors를 준 것 — 생략을 끄라고 컴파일러에게 명시적으로 요구한 빌드다.

반환 형태C++17 기본C++17 -fno-elide-constructorsC++14 -fno-elide-constructors
MakePrvalue()생성자 1생성자 1생성자 1 + 이동 2
MakeNamed()생성자 1생성자 1 + 이동 1생성자 1 + 이동 2
MakeTwoVars()생성자 2 + 이동 1생성자 2 + 이동 1생성자 2 + 이동 2

가운데 열이 이 글의 전부다. 생략을 꺼달라고 했는데 첫 줄은 꺼지지 않았고, 둘째 줄은 꺼졌다. 둘 다 “RVO”라고 뭉뚱그려 부르던 것인데 성질이 다르다.

1등급 — 끌 수 없는 것

MakePrvalue()가 C++17에서 -fno-elide-constructors를 무시하는 이유는, 그게 최적화가 아니기 때문이다.

C++17은 prvalue(이름 없는 임시 표현식)의 의미 자체를 바꿨다. prvalue는 이제 **객체가 아니라 “객체를 초기화하는 방법”**이고, 최종 목적지가 정해질 때까지 구체화되지 않는다. return Big();에서 만들어졌다가 사라지는 임시 객체란 애초에 존재하지 않는다. cppreference가 짚는 대로, 표준은 이걸 복사 생략이라고 부르지도 않는다 — 생략할 것이 없기 때문이다.

최적화가 아니라 언어 규칙이므로 증명도 확실하게 된다. 복사 생성자와 이동 생성자를 둘 다 지워버린 타입을 값으로 반환해보면 된다.

struct Immovable {
  Immovable() = default;
  Immovable(const Immovable&) = delete;
  Immovable(Immovable&&)      = delete;
  int v = 42;
};
 
Immovable Make() { return Immovable(); }
 
int main() {
  Immovable x = Make();
  std::printf("%d\n", x.v);
}
$ g++ -std=c++17 immovable.cpp -o i17.exe && ./i17.exe
42

$ g++ -std=c++14 immovable.cpp
error: use of deleted function 'Immovable::Immovable(Immovable&&)'
   10 | Immovable Make() { return Immovable(); }

C++14에서는 생략이 실제로 일어나든 말든 이동 생성자가 접근 가능해야 했다. 생략은 어디까지나 “해도 된다”는 허가였으므로, 안 할 경우를 대비해 문법 검사는 통과해야 했다. C++17에서는 그 검사 자체가 사라졌다.

실용적으로 이게 뜻하는 바는 복사도 이동도 못 하는 타입에 팩토리 함수를 만들 수 있다는 것이다. 뮤텍스를 품은 타입, std::atomic을 멤버로 가진 타입처럼 이동이 원천적으로 막힌 것들이 여기 해당한다. C++14에서는 이런 타입을 만들려면 호출자가 직접 생성하거나 unique_ptr로 감싸야 했다.

2등급 — 여전히 컴파일러 재량인 것

MakeNamed()처럼 이름 있는 지역 변수를 반환하는 경우가 NRVO다. 이건 C++17에서도 여전히 “해도 된다”에 머물러 있다. 표준이 요구하지 않으므로 -fno-elide-constructors 한 줄에 꺼진다.

실무에서는 g++·clang·MSVC 모두 -O0에서도 NRVO를 하므로 대개 걱정할 일이 없다. 문제는 조건이 붙는다는 것이고, 그 조건을 벗어나면 조용히 폴백한다.

// NRVO 됨 — 변수 하나를 여러 return 문에서 반환
Big OneVarTwoReturns(bool flag) {
  Big local;
  if (flag) return local;
  return local;
}
 
// NRVO 안 됨 — 분기마다 다른 변수
Big TwoVars(bool flag) {
  Big a, b;
  if (flag) return a;
  return b;
}
[1] OneVarTwoReturns   생성자
[2] TwoVars            생성자, 생성자, 이동 생성자

반환 경로가 이름 하나로 수렴하면 컴파일러가 그 이름을 호출자의 저장 공간에 직접 배치할 수 있다. 이름이 둘이면 어느 쪽을 호출자 자리에 놓을지 컴파일 시점에 정할 수 없으니, 둘 다 지역에 만들어놓고 마지막에 하나를 옮긴다.

3등급 — 생략이 아니라 이동

TwoVars()의 마지막 “이동 생성자” 한 줄이 세 번째 등급이다. 생략에 실패한 자리에서 컴파일러는 반환문의 지역 변수를 좌측값이 아니라 우측값으로 취급해 이동 생성자를 부른다. 암시적 이동이다.

생략이 원천적으로 불가능한 자리에서도 이게 작동한다.

struct Derived : Base {};
 
Base SliceReturn() { Derived d; return d; }   // 지역 변수 타입 != 반환 타입
Base ParamReturn(Base b) { return b; }        // 값 매개변수를 반환

둘 다 호출자의 저장 공간에 직접 만들 수 없다 — 앞은 타입이 다르고, 뒤는 매개변수라 이미 다른 자리에 만들어져 있다. 그래도 결과는 복사가 아니라 이동이다.

[A] SliceReturn   Base 이동
[B] ParamReturn   Base 이동

-std=c++14, -std=c++17, -std=c++20 모두 같았다. 이 규칙은 결함 보고로 소급 적용돼서 g++ 13.2는 예전 표준 모드에서도 이미 이렇게 동작한다.

”이동이니까 싸다”가 틀리는 자리

세 등급을 굳이 구별해야 하는 이유가 여기 있다. 폴백이 이동이라는 말은 안심되게 들리지만, 이동이 싼 건 힙 버퍼를 가진 타입에서만 그렇다. 데이터를 자기 안에 담는 타입은 이동이 곧 복사다.

4KB 배열을 멤버로 가진 타입으로 확인했다.

struct Buffer { std::array<char, 4096> data{}; };

-O2 -S로 뽑은 어셈블리다. NRVO가 된 쪽:

_Z9MakeNamedv:
        pushq   %rdi
        leaq    8(%rcx), %rdi     ; %rcx = 호출자가 넘긴 반환 슬롯 주소
        movq    $0, (%rcx)
        rep stosq                 ; 호출자 메모리를 직접 0으로 채운다
        movb    $120, (%rdx)
        ret

스택을 전혀 잡지 않는다. 4KB 버퍼는 처음부터 호출자의 것이고 함수는 거기에 바로 쓴다.

분기마다 다른 변수를 반환하는 쪽:

_Z11MakeTwoVarsb:
        movl    $8200, %eax
        call    ___chkstk_ms
        subq    %rax, %rsp        ; 스택 8200바이트 — 4KB 버퍼 두 개
        ...
        rep movsq                 ; 4096바이트를 호출자 슬롯으로 복사
        ret

스택 8200바이트에 4KB memcpy. 로그에는 “이동 생성자” 한 줄로 찍히지만 실제로 하는 일은 전체 복사다. return a;return b;로 나눠 쓴 대가가 이것이다. 두 줄을 이름 하나로 합치면 그대로 사라진다.

스스로 깨는 경우

가장 흔한 자해는 반환문에 std::move를 붙이는 것이다.

std::string Pessimizing() {
  return std::move(std::string("hi"));  // 1등급을 깬다
}
 
std::string Redundant() {
  std::string local = "hi";
  return std::move(local);              // 2등급을 깬다
}

std::move는 표현식을 우측값으로 캐스팅한다. prvalue를 우측값 참조로 바꾸면 참조할 객체가 있어야 하니 임시가 구체화되고, 의무적 생략의 전제가 무너진다. 이름 있는 지역 변수도 마찬가지로 NRVO 조건을 벗어난다. 도우려던 std::move가 정확히 반대로 작동한다.

다행히 컴파일러가 잡아준다.

$ g++ -std=c++17 -Wall -c redundant.cpp
warning: moving a temporary object prevents copy elision [-Wpessimizing-move]
    5 |   return std::move(std::string("hi"));
note: remove 'std::move' call
warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
   10 |   return std::move(local);
note: remove 'std::move' call

-Wpessimizing-move-Wall에 포함되지만 -Wall 없이는 안 나온다. 옵션 없이 컴파일하면 경고 0건이고, -Wall을 주면 2건이다. -O2만 켜고 경고를 끈 빌드에서는 이 실수가 끝까지 조용하다.

정리

등급언제보장실패하면
구체화 지연return Big(); — prvalue표준 보장. 끌 수 없고, 복사·이동이 delete여도 된다실패하지 않는다
NRVOBig b; ... return b;컴파일러 재량암시적 이동
암시적 이동타입이 다르거나 매개변수를 반환복사

실천으로 옮기면 세 줄이다.

  • 값으로 반환하는 걸 두려워하지 않는다. 출력 매개변수는 대개 얻는 것 없이 읽기만 어렵게 만든다.
  • 반환 경로를 이름 하나로 모은다. Big a, b; 뒤에 분기마다 다른 걸 반환하는 대신 변수 하나를 쓰면 NRVO가 살아난다. 자기 안에 데이터를 담는 큰 타입일수록 차이가 크다.
  • 반환문에 std::move를 쓰지 않는다. -Wall을 켜두면 실수해도 컴파일러가 짚어준다.

참고