The Complete Guide to return x; - Implicit Move

2022. 3. 13. 08:41C++

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

 

이전 포스트에서 return x; 명령어가 실행될 때 발생하는 컴파일러 최적화인 Copy Elision 및 NRVO에 대해 알아보았다.

이 포스트에서는 C++의 버전이 올라감에 따라 NRVO에 생긴 문제를 알아보고, 이 문제를 해결하기 위해 변경된 표준에 대해 알아볼 것이다.

 

C++11

C++11에 Move Semantics이 도입된 후로 NRVO에 문제가 생겼다.

 

다음 코드를 보자.

unique_ptr<T> f() 
{
    unique_ptr<T> x = ~~~;
    return x;
}

f 함수는 로컬 변수 x의 할당을 제어할 수 있고, return expression은 변수 x의 이름인 id-expression 이며, 이 expression의 타입은 반환 타입과 같다. 따라서 NRVO가 발생할 수 있는 조건에 부합한다.

 

하지만, unique_ptr은 move-only 타입이므로 lvalue인 x를 통해 생성될 수 없으며, return x를 하면 unique_ptr<T>의 Copy Constructor가 선택이 되어 컴파일 에러가 날 것이다. 따라서 컴파일 에러를 피하기 위해서는 std::move(x)를 반환해서 rvalue로 만들어주어야 한다.

 

다시 말하지만, NRVO는 return x; 와 같은 간단한 id-expression에 대해서만 작동한다.

그러므로 f 함수는 unique_ptr 오브젝트를 스택 프레임에 생성한 후, Move Constructor 호출을 통해 반환을 하게 된다.

 

C++11에서는 Implicit Move를 도입하여 이 문제를 해결하였다.

... overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue. If overload resolution fails, or if the type of the first parameter of the selected constructor is not an rvalue reference to the object’s type (possibly cv-qualified), overload resolution is performed again, considering the object as an lvalue ... — N3337, [class.copy]/32

컴파일러가 return x를 마주쳤을 때, x가 lvalue 일지라도 x를 rvalue로 취급하여 Move Constructor를 선택하기 위한 overload resolution을 수행하며, overload resolution이 실패하거나 선택된 생성자의 첫 번째 파라미터의 타입이 반환되는 오브젝트 타입의 rvalue reference가 아니면, 기존과 같이 lvalue로서 Copy Constructor를 선택하기 위한 overload resolution을 수행한다는 것이다.

 

이 내용을 다음 코드에 적용해보자.

struct auto_ptr 
{
    auto_ptr(auto_ptr&); // only from non-const lvalues
};

auto_ptr f() 
{ 
    auto_ptr x; 
    return x; 
}

컴파일러는 return x를 마주쳤을 때 x를 먼저 rvalue로 취급하여 적절한 생성자가 있는지 찾는다. 하지만 rvalue를 받을 수 있는 생성자가 없으므로 lvalue로 취급하여 auto_ptr(auto_ptr&) 생성자를 선택한다. 따라서 컴파일 에러가 발생하지 않는다.

 

다음 코드에도 적용해보자.

struct AutoSharePtr 
{
    AutoSharePtr(AutoSharePtr&); // pre-’11 “fast pilfer”
    AutoSharePtr(const AutoSharePtr&); // or copy, if we must
};

AutoSharePtr f() 
{ 
    AutoSharePtr x; 
    return x; 
}

Move Semantics이 없던 C++98/03에서는 위와 같이 AutoSharePtr&을 파라미터로 받는 생성자를 통해 Move Semantics을 흉내냈었는데, implicit move가 단순히 rvalue를 받을 수 있는 파라미터를 가진 생성자를 호출하면 const AutoSharePtr&을 파라미터로 받는 생성자가 호출이 되며, 따라서 Move Semantics을 흉내낼 수 없다. 이 문제는 다음 강조된 문장으로 해결하였다.

... overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue. If overload resolution fails, or if the type of the first parameter of the selected constructor is not an rvalue reference to the object’s type (possibly cv-qualified), overload resolution is performed again, considering the object as an lvalue ... — N3337, [class.copy]/32

반환되는 오브젝트의 타입이 T라고 했을 때, overload resolution을 통해 선택된 생성자의 첫 번째 파라미터가 T&& 또는 const T&&가 아니면 반환 오브젝트를 lvalue로 취급하여 overload resolution을 수행하므로 위 코드에서 정상적으로 AutoSharePtr(AutoSharePtr&) 생성자가 호출된다.

 

CWG1579 "Return by converting move constructor"

위에서 설명한 것과 같이 Implicit Move는 NRVO의 문제점을 해결하기 위해 도입 되었지만, CWG1579는 Copy Elision에 상관없이 다음 경우에도 Implicit Move가 적용될 수 있도록 규칙을 확장하였다.

  • 인자를 반환하는 경우
  • 반환 타입과 반환되는 오브젝트의 타입이 다른 경우

 

위 규칙이 적용된 예제를 보자.

unique_ptr<Base> g3(unique_ptr<Base> x) 
{
    return x; // OK, implicit move!
}

unique_ptr<Base> h2() 
{
    unique_ptr<Derived> x = ~~~;
    return x; // OK, implicit move!
}

g3 함수는 인자를 반환하므로 NRVO의 발동 조건에 해당되지 않지만, CWG1579로 인해 Implict Move가 적용된다. h2 함수는 반환되는 오브젝트의 타입과 반환 타입이 다르므로 NRVO 발동 조건에 해당되지 않지만, CWG1579로 인해 Implicit Move가 적용된다.

 

Implicit Move가 적용이 안되는 경우

CWG1579에서의 적용 범위 확장에도 불구하고 다음과 같은 경우에는 Implicit Move가 적용되지 않는다.

 

  • 반환 타입과 반환 오브젝트의 타입이 상속 관계에 있는 경우 
unique_ptr<Base> h2() 
{
    unique_ptr<Derived> x = ~~~;
    return x; // OK, implicit move!
}

위에서 봤던 h2 함수의 return x가 수행하는 overload resolution은 unique_ptr&&를 선택하므로 Implicit Move에 의해 Move가 발생한다. (unique_ptr은 template constructor를 가지고 있음)

 

Base h3() 
{
    Derived x = ~~~;
    return x; // Ugh, copy!
}
... overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue. If overload resolution fails, or if the type of the first parameter of the selected constructor is not an rvalue reference to the object’s type (possibly cv-qualified), overload resolution is performed again, considering the object as an lvalue ... — N3337, [class.copy]/32

하지만, h3 함수는 x를 rvalue로 취급하여 Derived를 rvalue로 받을 수 있는 생성자인 Base(Base&&)를 선택하는데, Implicit Move 발동 조건에 의해 Derived&&를 파라미터로 받는 생성자가 선택되지 않아 Implicit Move에 실패하며, 따라서 x를 다시 lvalue로 취급하여 Base(const Base&) 생성자가 선택되게 된다.

 

  • 선택된 생성자의 파라미터 타입이 반환되는 오브젝트의 타입과 정확히 일치 할 경우
struct Source { S(); Source(Source&&); Source(const Source&); };
struct Sink { Sink(Source); Sink(unique_ptr<int>); };
Sink f() { Source x; return x; } // C++17 calls Source(const Source&),
                                 // then Sink(Source)
Sink g() { unique_ptr<int> p; return p; } // C++17: ill-formed
... overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue. If overload resolution fails, or if the type of the first parameter of the selected constructor is not an rvalue reference to the object’s type (possibly cv-qualified), overload resolution is performed again, considering the object as an lvalue ... — N3337, [class.copy]/32

f 함수와 g 함수는 x와 p를 rvalue로 취급하여 overload resolution을 시도하지만, Sink 구조체에서 Sink(Source&&), Sink(unique_ptr<int>&&) 생성자를 찾지 못하며, 다시 lvalue로 취급하여 각각 Sink(Source), Sink(unique_ptr<int>) 생성자가 선택된다. Source 구조체는 Source(const Source&) 생성자가 호출되며, unique_ptr<int>는 Copy가 불가능하므로 컴파일에 실패한다.

 

  • overload resolution이 생성자가 아닌 다른 함수를 선택해 성공한 경우

 

struct To {};
struct From { operator To() &&; operator To() const&; };
To f() { From x; return x; } // C++17 calls From::operator To() const&
... overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue. If overload resolution fails, or if the type of the first parameter of the selected constructor is not an rvalue reference to the object’s type (possibly cv-qualified), overload resolution is performed again, considering the object as an lvalue ... — N3337, [class.copy]/32

f 함수는 x를 rvalue로 취급하여 overload resolution을 시도하는데, To 구조체에서 To(From&&) 생성자를 찾을 수 없으므로 x를 다시 lvalue로 취급하여 overload resolution을 시도하며, From 구조체의 operator To() const&가 선택된다.

 

C++20

P1155 "More implicit move"

P1155는 Implicit Move의 적용 조건을 다음과 같이 수정하였다.

... overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue. If overload resolution fails, or if the type of the first parameter of the selected constructor is not an rvalue reference to the object’s type (possibly cv-qualified), overload resolution is performed again, considering the object as an lvalue ...

overload resolution이 성공했을 때 선택된 생성자의 첫 번째 파라미터 타입이 반환되는 오브젝트의 rvalue reference여야 한다는 문구가 삭제되었다. 이 말은, 이제 overload resolution만 성공한다면 Implicit Move가 성공한다는 것이다. 

 

바로 위에서 본 Implicit Move가 적용이 안되는 경우를 살펴보자.

 

Base f() { Derived x; return x; }

이전에는 위에 삭제된 문구로 인해 Base(Derived&&)가 존재하지 않으면 Implicit Move가 실패 했었다. 하지만, 이제 Base(Base&&)가 선택되며 Implicit Move가 성공한다.

 

struct Source { S(); Source(Source&&); Source(const Source&); };
struct Sink { Sink(Source); };
Sink f() { Source x; return x; }

역시 이전에 삭제된 문구로 인해 overload resolution에서 Sink(Source&&) 생성자를 찾지못해 Implicit Move가 실패 했었다. 하지만, 이제 Source(Source&&) 생성자가 선택되며 Implicit Move가 성공한다.

 

struct To {};
struct From { operator To() &&; operator To() const&; };
To f() { From x; return x; }

역시 이전에 삭제된 문구로 인해 overload resolution에서 생성자가 아닌 변환 연산자가 선택되어 Implicit Move가 실패 했었다. 이제 operator To() && 변환 연산자가 선택되며 Implicit Move가 성공한다.

 

Implicit Move는 단순히 return 말고도 co_return, throw에도 적용이 될 수 있는데, P1155는 다음 문구를 삭제하여 throw의 operand가 함수 파라미터인 경우에도 Implicit Move가 적용될 수 있게 했다.

if the operand of a throw-expression is the name of a non-volatile automatic object (other than a function or catch-clause parameter) whose scope does not extend beyond the end of the innermost enclosing try-block (if there is one),

overload resolution to select the constructor for the copy is first performed as if...
void f(T x)
{
    throw x; // implicit move
}

 

P0527 "Implicitly move from rvalue references in return statements"

이전까지는 Implicit Move의 대상이 될 수 있는 오브젝트에 대한 용어가 없었는데, P0527에서 그 용어를 정의했다.

An implicitly movable entity is a variable of automatic storage duration that is either a non-volatile object or an rvalue reference to a non-volatile object type. In the following copy-initialization contexts, a move operation is first considered before attempting a copy operation:

- If the expression in a return or co_return statement is a (possibly parenthesized) id-expression that names an object with automatic storage duration implicitly movable entity declared in the body or parameter-declaration-clause of the innermost enclosing function or lambda-expression, or

- if the operand of a throw-expression is the name of a non-volatile automatic object (other than a catch-clause parameter) a (possibly parenthesized) id-expression that names an implicitly movable entity whose scope does not extend beyond ...

 

implicitly movable entity의 정의에서, 다음 강조된 문장에 주목하자.

An implicitly movable entity is ... or an rvalue reference to a non-volatile object type.

이 말은, 이제 단순히 오브젝트 타입이 아닌 rvalue reference 타입의 변수를 반환해도 Implicit Move가 발동 된다는 것 이다.

 

C++에서 변수의 이름은 lvalue로 취급된다는 것을 생각하며 다음 코드를 보자.

std::string setName(std::string&& rr)
{
    name_ = rr; // 1
    id_ = rr;   // 2
    return rr;  // 3
}

 

  1. rr이 참조하는 오브젝트를 name_ 변수에 Copy한다.
    (rr의 타입은 rvalue reference지만 value category는 lvalue임을 주의하자.)
  2. 위와 같이 id_ 변수에 복사한다. 위에서 Move가 아닌 Copy가 발생했으므로 문제가 없다.
  3. C++17까지는 rr이 lvalue이고 Implicit Move의 대상이 아니여서 Copy가 발생했었다.
    하지만, 1, 2번과는 다르게 return이 발생하는 순간 rr이 쓰이지 않을 것 이라는 것을 알고있다.
    그래서 C++20부터는 implicit movable entity 정의에 의해 Implicit Move가 발생하도록 변경되었다.

하지만 C++20에도 여전히 문제가 있다.

template<class T>
decltype(auto) f(T t) 
{
    return t.foo();
}

template<class T>
decltype(auto) g(T t) 
{
    decltype(auto) x = t.foo();
    return x;
}

struct C { C&& foo(); };

 

위 코드에서 f<C> 템플릿 함수는 다음과 같이 정의된다.

C&& f(C t)
{
    return t.foo();
}

f<C> 함수는 t.foo 함수가 반환하는 C&&를 그대로 반환한다.

아무 문제없이 컴파일 된다.

 

g<C> 템플릿 함수는 다음과 같이 정의된다.

C&& g(C t)
{
    C&& x = t.foo();
    return x;
}

g<C> 함수는 t.foo 함수가 반환하는 C&&를 C&& 타입 변수 x에 초기화했다.

이 함수는 컴파일 되지 않는다.

 

위에서 변수의 이름은 lvalue로 취급된다고 말했다.
x의 타입과 반환 타입은 같지만, return x에서 x는 lvalue로 평가되며 rvalue reference에 lvalue는 바인딩될 수 없으므로 컴파일 에러가 발생한다. C++20에서도 말이다.

 

어라? x는 implicitly movable entity이므로 rvalue로 취급되어서 반환이 가능해야 하는 것이 아닌가?

 

이게 불가능한 이유는 implicitly movable entity 용어에 대한 정의 다음 문구를 보면 알 수 있다.

In the following copy-initialization contexts, a move operation is first considered before attempting a copy operation:

- If the operand of a return or co_return statement is ...
- if the operand of a throw-expression is ...

overload resolution to select the constructor for the copy or the return_value overload to call is first performed as if the expression or operand were an rvalue...

copy-initialization context라는 말이 나와있다.

즉, Imilicit Move는 반환 타입이 reference가 아닌, 오브젝트 타입이어야 동작한다는 것이다.

 

MoveOnly one(MoveOnly&& rr)
{
    return rr; // 1
}

MoveOnly&& two(MoveOnly&& rr)
{
    return rr; // 2
}
  1. C++20에 도입된 implicitly movable entity의 정의에 의해 Implict Move가 실행된다.
  2. 반환 타입이 오브젝트 타입이 아니므로 Implicit Move가 불가능하며,
    lvalue를 rvalue reference에 바인딩할 수 없어 컴파일 에러가 뜬다.