The Complete Guide to return x; - Copy Elision & NRVO

2022. 3. 12. 00:03C++

이 포스트는 [The Complete Guide to return x]를 기반으로 재구성 되었습니다.

 

이 포스트에서는 return x; 명령어가 실행될 때 발생할 수 있는 컴파일러 최적화인 Copy ElisionNRVO에 대해 알아 볼 것이다.

 

int f()
{
    int i = 42;
    return i;
}

void test()
{
    int j = f();
}

위 코드를 보면 test 함수에서 f 함수를 호출하여 반환 값을 통해 int j 변수를 초기화 하고 있다.

x86-64 호출규약(calling convention)에서는 함수 f에서 return i; 명령어가 실행되면 함수 f의 스택 프레임에 있는 변수 i의 값을 함수 test의 스택 프레임에 있는 변수 j로 전달하기 위해 eax 레지스터에 변수 i의 값을 복사하여 함수 간 반환 값을 주고 받게 된다.

 

 

그렇다면 클래스 타입은 어떻게 return이 될까?

struct S { int m; };

S f() 
{
    S i = S{42};
    return i;
}

void test() 
{
    S j = f();
}

역시 eax 레지스터를 통해 반환 값이 전달된다.

 

하지만, 클래스 타입의 크기가 eax 레지스터 보다 크면 어떻게 될까?

struct S { int m[3]; };

S f() 
{
    S i = S{{1, 3, 5}};
    return i;
}

void test() 
{
    S j = f();
}

 

 

 

구조체 S에 3개의 원소를 가진 int 배열을 만들고, 각 원소의 값을 {1, 3, 5}로 초기화한 후 return 하였다.

 

위와 같이 반환 될 오브젝트를 레지스터에 복사할 수 없는 경우, 다음과 같이 호출 및 반환이 이루어진다.

(a) test 함수는 rdi 레지스터에 "return slot" 이라 부르는 f 함수의 반환 값을 복사하기 위한 메모리의 주소를 저장하게 되며, f 함수는 이 주소에 접근하여 자신의 로컬 변수 i의 값을 복사한다. 

(b) test 함수는 return slot의 값들을 로컬 변수 j로 복사하여 초기화(copy-initialize)한다.

 

Copy Elision

 

(a) 과정은 뭔가 비효율적인 것 처럼 보인다.

애초에 test 함수는 return slot을 어디로 할 지 제어가 가능하므로 (c)와 같이 로컬 변수 j를 return slot으로 설정하면 f 함수는 로컬 변수 i를 test 함수의 로컬 변수 j로 직접 복사하게 되는 것이다.
(즉, return slot -> j로의 복사 한번이 사라진다.)

 

그런데 return slot에서 로컬 변수 j로의 복사를 생략해도 문제가 없는지 생각해 봐야 한다.

만약 복사 과정에서 사이드 이펙트가 발생한다면 생략했을때 프로그램이 의도치 않은 방향으로 동작할 수 있다.

 

다행히도 구조체 S는 유저가 정의한 Copy/Move Consturctor가 존재하지 않으므로 Copy/Move에 사이드 이펙트가 발생하지 않는다. 하지만, 유저가 정의한 클래스 타입에는 Copy/Move Constructor가 정의 돼 있을 확률도 낮지 않은데, 이러한 경우는 복사를 생략할 수 없는 것인가? 

 

C++98 표준에서는 Copy Constructor가 정의되어 사이드 이펙트가 존재한다고 해도 복사를 생략(Copy Elision)하는 것을 허용했다. 그렇기 때문에 Copy/Move Constructor는 사이드 이펙트가 생기지 않도록 구현해야 하며, 따라서 다음 코드도 컴파일러가 복사를 생략할 수 있다.

struct S { int m[3]; S(S&&); };

S f() 
{
    S i = S{{1, 3, 5}};
    return i;
}

void test() 
{
    S j = f();
}

 

C++17 이후로는 오브젝트를 초기화할 때 initializer expression의 타입이 변수의 타입과 같고 value category가 prvalue인 경우 Copy Elision이 의무적으로 발생하도록 보장된다. 위의 코드에서는 f() 함수 콜은 prvalue로 평가되므로 컴파일러 최적화 여부에 상관없이 Copy Elision이 발생하며, deferred temporary materialization에 의해 temporary가 생성되지 않는다.

(즉, 로컬 변수 j를 초기화할 때 temporary의 생성 없이 초기화가 가능하므로 굳이 temporary를 생성하지 않는다.)

 

Named Return Value Optimization (NRVO)

(c) 과정은 효율적인가?

f 함수는 로컬 변수 i의 할당을 제어할 수 있고, rdi 레지스터를 통해 return slot의 주소를 알 수 있다. 따라서 굳이 로컬 변수 i를 만들고 return slot에 복사할 필요 없이, return slot에 직접 로컬 변수 i에 할당될 값들을 초기화 시켜주면 된다.

 

이와 같이 이름 있는 오브젝트 x를 반환(return x;)할 때 발생하는 Copy Elision을 Named Return Value Optimization(NRVO)라고 부르며, C++98 표준은 특수한 상황에서 이러한 최적화를 허용하고 있다.

 

반면, C++17 이후로는 오브젝트를 초기화 할 때와 같이 return expression의 타입이 반환 타입과 같고 value category가 prvalue인 경우 역시 Copy Elision이 의무적으로 발생하도록 보장된다.

(예전에는 이 최적화를 Return Value Optimization이라고 불렀는데, 의무가 된 후로 최적화로 취급하지 않는다.)   

 

그럼 NRVO가 발생하기 위한 조건은 무엇인가?

  • return expression이 automatic storage duration을 가진 non-volatile 오브젝트의 이름이어야 하며, 타입이 반환 타입과 같아야한다. (즉, 변수 이름과 같은 id-expression이어야 한다.) 
  • 반환되는 변수의 할당을 반환하는 함수에서 제어할 수 있어야 하며, 따라서 함수 파라미터는 NRVO 대상이 아니다. (함수 파라미터는 호출하는 함수에서 제어한다.)
  • 호출한 함수에서 제공하는 return slot이 존재해야 한다.
    Trivial 타입 오브젝트는 레지스터 크기에 맞을 경우 레지스터로 반환될 수 있지만, Non-Trivial 타입은
    항상 return slot으로 반환된다.

 

NRVO가 발생하지 않는 상황을 예제를 통해 알아보자.

struct Trivial { int m; };
struct S { S(); ~S(); };
struct D : public S { int n; };

Trivial f() { Trivial x; return x; } // 레지스터로 반환되므로 return slot이 없음 

S x;
S g1() { return x; } // x의 할당을 함수 g1이 제어할 수 없음
S g2() { static S x; return x; } // 위와 같음
S g3(S x) { return x; } // 위와 같음 (파라미터는 호출하는 함수에서 할당)
S h() { D x; return x; } // return slot으로 들어가기에 x의 크기가 너무 큼 (타입 불일치)