이동 대입 연산자

이동 대입 연산자 (Move assignment operator)

= 연산자로 객체의 내용을 다른 객체로 이동시키고 싶을 때가 있어요. 이때 작동하는 건 이동 대입 연산자(move assignment operator)예요. 박스가 복사가 아니라 "이동"된다는 게 무슨 뜻인지, 그리고 왜 필요할까요?

출처: cppreference

본문

이동 대입 연산자는 이름이 operator=인, 템플릿이 아닌 비정적 멤버 함수로, 같은 클래스 타입의 인자로 호출할 수 있고 인자의 내용을 복사하면서 인자를 변경할 수도 있어요(수정할 수 있음).

문법

공식적인 이동 대입 연산자 문법은 함수 선언 문서를 참고하세요. 아래 문법 목록은 모든 유효한 이동 대입 연산자 문법의 일부만 보여줘요.

return-type operator=( parameter-list );                        (1)
return-type operator=( parameter-list ) function-body           (2)
return-type operator=( parameter-list-no-default ) = default;   (3)
return-type operator=( parameter-list ) = delete;               (4)
return-type class-name :: operator=( parameter-list ) function-body           (5)
return-type class-name :: operator=( parameter-list-no-default ) = default;   (6)
  • class-name — 이동 대입 연산자를 선언하는 클래스. 아래 설명에서 클래스 타입은 T로 표기할게요.
  • parameter-list — 정확히 하나의 매개변수만 있는 목록. 타입은 T&&, const T&&, volatile T&&, const volatile T&& 중 하나예요.
  • parameter-list-no-default — 정확히 하나의 매개변수만 있고 T&&, const T&&, volatile T&&, const volatile T&& 중 하나의 타입이며 기본 인자가 없는 목록.
  • function-body — 이동 대입 연산자의 함수 본문.
  • return-type — 어떤 타입이든 괜찮지만, 스칼라 타입과 일관되도록 T&가 선호돼요.

설명

먼저 올바른 예시와 잘못된 예시를 비교해 볼게요.

struct X
{
    X& operator=(X&& other);    // 이동 대입 연산자
//  X operator=(const X other); // 오류: 잘못된 매개변수 타입
};

union Y
{
    // 이동 대입 연산자는 위 목록에 없는 문법도 가질 수 있다.
    // 일반 함수 선언 문법을 따르고 위 제약을 어기지만 않으면 된다.
    auto operator=(Y&& other) -> Y&;       // OK: trailing return type
    Y& operator=(this Y&& self, Y& other); // OK: explicit object parameter
//  Y& operator=(Y&&, int num = 1);        // 오류: 다른 비객체 매개변수 있음
};

이동 대입 연산자는 오버로드 해석이 그것을 선택할 때마다 호출돼요. 예를 들어 객체가 대입 표현식의 왼쪽에 있고, 오른쪽이 같은 타입 또는 암시적 변환 가능한 타입의 rvalue(오른값)일 때죠.

이동 대입 연산자는 보통 인자가 가진 자원(동적으로 할당된 객체에 대한 포인터, 파일 디스크립터, TCP 소켓, 스레드 핸들 등)을 복사본을 만드는 대신 전달(transfer)하고, 인자를 유효하지만 값이 불특정한 상태로 남겨요. 이동 대입은 인자의 수명을 바꾸지 않기 때문에, 나중에 인자에 대해 소멸자가 호출되는 게 일반적이에요. 예를 들어 std::string이나 std::vector에서 이동 대입하면 인자가 비어 있는 상태로 남을 수 있어요. 이동 대입은 일반 대입보다 더 적게, 덜 제한적으로 정의돼요. 일반 대입은 완료 시 데이터의 사본 두 개를 남겨야 하는 반면, 이동 대입은 사본 하나만 남기면 돼요.

암시적으로 선언된 이동 대입 연산자

클래스 타입에 대해 사용자 정의 이동 대입 연산자가 제공되지 않고, 다음이 모두 참이면:

  • 사용자 선언 복사 생성자가 없고,
  • 사용자 선언 이동 생성자가 없고,
  • 사용자 선언 복사 대입 연산자가 없고,
  • 사용자 선언 소멸자가 없다면,

컴파일러는 시그니처 T& T::operator=(T&&)를 가진 이동 대입 연산자를 그 클래스의 인라인 public 멤버로 선언해요.

클래스는 여러 이동 대입 연산자를 가질 수 있어요. 예를 들어 T& T::operator=(const T&&)T& T::operator=(T&&) 둘 다요. 사용자 정의 이동 대입 연산자가 있더라도, default 키워드로 암시적으로 선언된 이동 대입 연산자의 생성을 강제할 수 있어요.

암시적으로 선언된 이동 대입 연산자는 동적 예외 사양(C++17 이전까지) / noexcept 사양(C++17부터)에서 설명하는 예외 사양을 가져요.

어떤 클래스에든 항상 어떤 대입 연산자(이동 또는 복사)가 선언되기 때문에, 기본 클래스의 대입 연산자는 항상 숨겨져요. using 선언으로 기본 클래스의 대입 연산자를 가져오는데, 그 인자 타입이 파생 클래스의 암시적 대입 연산자의 인자 타입과 같을 수 있다면, 그 using 선언도 암시적 선언에 의해 숨겨져요.

암시적으로 정의된 이동 대입 연산자

암시적으로 선언된 이동 대입 연산자가 삭제되지도 않고 자명(trivial)하지도 않으면, odr-use되거나 상수 평가에 필요할 때(C++14부터) 컴파일러가 그것을 정의해요(즉 함수 본문을 생성하고 컴파일).

공용체 타입의 경우, 암시적으로 정의된 이동 대입 연산자는 객체 표현(object representation)을 복사해요(std::memmove와 같이).

공용체가 아닌 클래스 타입의 경우, 이동 대입 연산자는 객체의 직접 기본 클래스와 직접 비정적 멤버에 대해, 선언 순서대로 전체 멤버별 이동 대입(full member-wise move assignment)을 수행해요. 스칼라에는 내장 대입을, 배열에는 멤버별 이동 대입을, 클래스 타입에는 이동 대입 연산자(비가상적으로 호출)를 사용해요.

클래스 T에 대한 암시적으로 정의된 이동 대입 연산자는, T가 리터럴 타입이고 각 직접 기본 클래스 하위객체를 이동하기 위해 선택된 대입 연산자가 constexpr 함수이며, T의 각 클래스 타입(또는 그 배열) 비정적 데이터 멤버를 이동하기 위해 선택된 대입 연산자가 constexpr 함수라면 constexpr이에요. (C++14~C++23) 클래스 T에 대한 암시적으로 정의된 이동 대입 연산자는 constexpr이에요. (C++23부터)

복사 대입과 마찬가지로, 상속 격자(inheritance lattice)에서 둘 이상의 경로로 접근할 수 있는 가상 기본 클래스 하위객체가 암시적으로 정의된 이동 대입 연산자에 의해 두 번 이상 대입되는지 여부는 불특정(unspecified)이에요:

struct V
{
    V& operator=(V&& other)
    {
        // 이건 한 번 또는 두 번 호출될 수 있다
        // 두 번 호출된다면 'other'는 방금 이동된 V 하위객체다
        return *this;
    }
};

struct A : virtual V {}; // operator=는 V::operator=를 호출
struct B : virtual V {}; // operator=는 V::operator=를 호출
struct C : B, A {};      // operator=는 B::operator=, 그다음 A::operator= 호출
                         // 하지만 V::operator=는 한 번만 호출할 수도 있다

int main()
{
    C c1, c2;
    c2 = std::move(c1);
}

삭제된 이동 대입 연산자

클래스 T의 암시적으로 선언되거나 default된 이동 대입 연산자는 다음 조건 중 하나라도 만족하면 삭제된(deleted) 것으로 정의돼요:

  • T가 const-qualified 비클래스 타입(또는 그 다차원 배열)의 비정적 데이터 멤버를 가질 때.
  • T가 참조 타입의 비정적 데이터 멤버를 가질 때.
  • T가 클래스 타입 M(또는 그 다차원 배열)의 잠재적으로 구성된 하위객체를 가져서, M의 이동 대입 연산자를 찾기 위한 오버로드 해석이
    • 유용한 후보(candidate)를 만들지 못하거나, 하위객체가 variant 멤버인 경우 자명하지 않은 함수를 선택할 때.

삭제된 암시적으로 선언된 이동 대입 연산자는 오버로드 해석에서 무시돼요.

자명한 이동 대입 연산자 (Trivial move assignment operator)

클래스 T의 이동 대입 연산자는 다음이 모두 참이면 자명(trivial)해요:

  • 사용자가 제공한 것이 아니고(즉 암시적으로 정의되거나 default된 것),
  • T에 가상 멤버 함수가 없고,
  • T에 가상 기본 클래스가 없고,
  • T의 각 직접 기본 클래스에 대해 선택된 이동 대입 연산자가 자명하고,
  • T의 각 클래스 타입(또는 클래스 타입 배열) 비정적 멤버에 대해 선택된 이동 대입 연산자가 자명할 때.

자명한 이동 대입 연산자는 자명한 복사 대입 연산자와 같은 동작을 수행해요. 즉 std::memmove처럼 객체 표현의 복사본을 만드는 것이에요. C 언어와 호환되는 모든 데이터 타입은 자명하게 이동 대입 가능(trivially move-assignable)해요.

적격 이동 대입 연산자 (Eligible move assignment operator)

이동 대입 연산자가 삭제되지 않으면 적격(eligible)이에요. (C++20 이전까지) 이동 대입 연산자는 다음 조건이 모두 만족되면 적격이에요: 삭제되지 않았고, 연관 제약(있다면)이 만족되고, 연관 제약이 만족되는 다른 이동 대입 연산자 중 더 제한적(더 constrained)인 것이 없을 때. (C++20부터)

적격 이동 대입 연산자의 자명성(triviality)은 클래스가 trivially copyable 타입인지 결정해요.

참고 사항 (Notes)

복사 및 이동 대입 연산자가 모두 제공되면, 오버로드 해석은 인자가 rvalue(이름 없는 임시 같은 prvalue, 또는 std::move의 결과 같은 xvalue)일 때 이동 대입을 선택하고, 인자가 lvalue(이름 있는 객체 또는 lvalue 참조를 반환하는 함수/연산자)일 때 복사 대입을 선택해요. 복사 대입만 제공되면 모든 인자 범주가 그것을 선택해요(인자를 값이나 const 참조로 받는 한, rvalue는 const 참조에 바인딩될 수 있으므로). 덕분에 이동이 불가능할 때 복사 대입이 이동 대입의 대체(fallback)가 돼요.

상속 격자에서 둘 이상 경로로 접근할 수 있는 가상 기본 클래스 하위객체가 암시적으로 정의된 이동 대입 연산자에 의해 두 번 이상 대입되는지 여부는 불특정이에요(복사 대입에도 동일하게 적용돼요).

사용자 정의 이동 대입 연산자의 기대 동작에 대한 자세한 내용은 대입 연산자 오버로딩 문서를 참고하세요.

예제

복사와 이동 대입이 상황에 따라 어떻게 선택되는지, 그리고 암시적 이동 대입이 언제 만들어지는지 살펴볼게요.

#include <iostream>
#include <string>
#include <utility>

struct A
{
    std::string s;

    A() : s("test") {}

    A(const A& o) : s(o.s) { std::cout << "move failed!\n"; }

    A(A&& o) : s(std::move(o.s)) {}

    A& operator=(const A& other)
    {
         s = other.s;
         std::cout << "copy assigned\n";
         return *this;
    }

    A& operator=(A&& other)
    {
         s = std::move(other.s);
         std::cout << "move assigned\n";
         return *this;
    }
};

A f(A a) { return a; }

struct B : A
{
    std::string s2;
    int n;
    // 암시적 이동 대입 연산자 B& B::operator=(B&&)
    // A의 이동 대입 연산자 호출
    // s2의 이동 대입 연산자 호출
    // 그리고 n의 비트별 복사 수행
};

struct C : B
{
    ~C() {} // 소멸자가 암시적 이동 대입을 막는다
};

struct D : B
{
    D() {}
    ~D() {} // 소멸자가 암시적 이동 대입을 막는다
    D& operator=(D&&) = default; // 어쨌든 이동 대입을 강제한다
};

int main()
{
    A a1, a2;
    std::cout << "Trying to move-assign A from rvalue temporary\n";
    a1 = f(A()); // rvalue 임시에서 이동 대입
    std::cout << "Trying to move-assign A from xvalue\n";
    a2 = std::move(a1); // xvalue에서 이동 대입

    std::cout << "\nTrying to move-assign B\n";
    B b1, b2;
    std::cout << "Before move, b1.s = \"" << b1.s << "\"\n";
    b2 = std::move(b1); // 암시적 이동 대입 호출
    std::cout << "After move, b1.s = \"" << b1.s << "\"\n";

    std::cout << "\nTrying to move-assign C\n";
    C c1, c2;
    c2 = std::move(c1); // 복사 대입 연산자 호출

    std::cout << "\nTrying to move-assign D\n";
    D d1, d2;
    d2 = std::move(d1);
}

출력:

Trying to move-assign A from rvalue temporary
move assigned
Trying to move-assign A from xvalue
move assigned

Trying to move-assign B
Before move, b1.s = "test"
move assigned
After move, b1.s = ""

Trying to move-assign C
copy assigned

Trying to move-assign D
move assigned

C는 소멸자를 선언해서 암시적 이동 대입이 막혔기 때문에, c2 = std::move(c1)이 복사 대입으로 대체된 걸 볼 수 있어요. 반면 D= default로 강제해서 이동 대입을 쓸 수 있어요.

결함 보고 (Defect reports)

DR 적용 대상 발표 당시 동작 올바른 동작
CWG 1353 C++11 defaulted 이동 대입 연산자가 삭제된 것으로 정의되는 조건이 다차원 배열 타입을 고려하지 않음 이 타입들도 고려함
CWG 1402 C++11 자명하지 않은 복사 대입 연산자를 호출할 defaulted 이동 대입 연산자는 삭제됨; 삭제된 defaulted 이동 대입 연산자는 오버로드 해석에 계속 참여함 그런 복사 대입 연산자 호출 허용; 오버로드 해석에서 무시됨
CWG 1806 C++11 가상 기본 클래스를 포함한 defaulted 이동 대입 연산자에 대한 사양이 없었음 추가됨
CWG 2094 C++11 volatile 하위객체가 defaulted 이동 대입 연산자를 자명하지 않게 만들었음 (CWG 이슈 496) 자명성에 영향 없음
CWG 2180 C++11 클래스 T가 추상적이고 이동 대입 불가능한 직접 가상 기본 클래스를 가질 때 defaulted 이동 대입 연산자가 삭제된 것으로 정의되지 않았음 이 경우 삭제된 것으로 정의
CWG 2595 C++20 더 제한적이지만 연관 제약을 만족하지 않는 다른 이동 대입 연산자가 있으면 이동 대입 연산자가 적격이 아니었음 이 경우 적격일 수 있음
CWG 2690 C++11 공용체 타입에 대한 암시적으로 정의된 이동 대입 연산자가 객체 표현을 복사하지 않았음 객체 표현을 복사함

더 알아보기