본문 바로가기

진리는어디에/C++

[C++] default move constructor

들어가며

[이전 포스트]에서는 move에 대한 기본 개념 필요 이유, 사용 방법에 대해 살펴 보았다. 이번 포스트는 앞의 내용에 이어 복사 생성자와 move 생성자의 자동 생성 규칙에 대해 살펴 보도록 한다.

먼저 아래의 다소 복잡해 보이는 코드를 먼저 살펴 보자.

class String
{
public:
    String() = default;
    String(const String&) { std::cout << "copy constructor" << std::endl; }
    String(String&&) noexcept { std::cout << "move constructor" << std::endl; }
    String& operator = (const String&) { std::cout << "copy assignment" << std::endl; return *this; }
    String& operator = (String&&) noexcept { std::cout << "move assignment" << std::endl; return *this; }
};

class Object
{
    String name;
public:
    Object() = default;
    Object(const Object& obj) : name(obj.name) {}
    Object& operator = (const Object& obj) { name = obj.name; return *this; }

    Object(Object&& obj) noexcept : name(std::move(obj.name)) {}
    Object& operator = (Object&& obj) noexcept { name = std::move(obj.name); return *this; }
};

int main()
{
    Object obj1;

    Object obj2 = obj1; // copy
    obj2 = obj1;

    Object obj3 = std::move(obj1); // move
    obj3 = std::move(obj1); 

    return 0;
}

23라인의 메인 함수 부터 살펴 보면 String 객체를 가지고 있는 Object 클래스의 obj1 객체를 생성하고 그것을 obj2에는 복사 생성과 복사 대입을, obj3에는 move 생성과 move 대입을 진행하고 있다.

String 클래스에는 각 생성자와 대입 연산자의 호출을 확인하기 위해 간단한 출력문이 구현 되어 있으며, 위 프로그램을 실행하면 다음과 같이 출력 된다.

copy constructor
copy assignment
move constructor
move assignment

우리의 예상 처럼 복사생성자와 복사 대입 연산자, move 생성자와 move 대입 연산자를 잘 호출해 주고 있다.

하지만 만일 우리가 Object 클래스에 아무런 생성자와 대입 연산자를 만들어 주지 않는다면 컴파일러는 무슨 일을할까? 기존 C++ 컴파일러가 그랬던것 처럼 기본 코드들을 생해 줄까? 아니면 또 다른 규칙이 있는 것일까? 다음 섹션 부터 무슨 일이 일어나는지 같이 살펴 보도록 하자.

사용자가 복사 계열과 move 계열 함수를 모두 제공하지 않을 때

class Object
{
    String name;
public:
    Object() = default;
    // Object(const Object& obj) : name(obj.name) {}
    // Object& operator = (const Object& obj) { name = obj.name; return *this; }

    // Object(Object&& obj) noexcept : name(std::move(obj.name)) {}
    // Object& operator = (Object&& obj) noexcept { name = std::move(obj.name); return *this; }
};

C++은 사용자가 복사 생성자와 대입 연산자를 직접 제공하지 않으면 컴파일러가 기본 복사 생성자와 대입 연산자를 제공한다.

move 계열 역시 마찮가지다. 사용자가 제공하지 않는 경우 컴파일러가 모든 디폴트 버전을 제공한다. 심지어 컴파일러가 제공하는 move 계열 함수의 모든 멤버는 자동적으로 move 함수를 호출 한다. 즉, 컴파일러가 자동으로 제공하는 버전이 우리가 주석 처리한 부분과 동일하다는 말이다.

위와 같이 Object의 내부 구현들을 주석 처리한 후 위 프로그램을 실행시키면 이전 사용자가 생성자와 대입 연산자를 제공한 버전과 동일한 결과를 얻는 것을 확인할 수 있다.

  사용자 제공 컴파일러
복사 생성자 X 디폴트 버전 제공
복사 대입 연산자 X 디폴트 버전 제공
move 생성자 X 디폴트 버전 제공
move 대입 연산자 X 디폴트 버전 제공

사용자가 복사 계열 함수만 제공할 때

class Object
{
    String name;
public:
    Object() = default;
    
    Object(const Object& obj) : name(obj.name) {}
    Object& operator = (const Object& obj) { name = obj.name; return *this; }

    // Object(Object&& obj) noexcept : name(std::move(obj.name)) {}
    // Object& operator = (Object&& obj) noexcept { name = std::move(obj.name); return *this; }
};

사용자가 복사 계열 생성자 또는 대입 연산자를 명시적으로 작성하는 경우, 컴파일러는 사용자가 멤버들을 복사하는 방법을 특수하게 처리하는 것으로 생각 하게 된다. 그리하여 기본 생성되는 코드로는 사용자가 정의한 멤버들을 처리할 수 없다고 판단하여 기본 move 생성자와 대입 연산자를 생성해 주지 않는다.

그렇다고 위 Object 객체를 move 함수를 통해 호출 했을대 컴파일 에러가 발생하는 것은 아니다. C++은 기존 move가 없던 시절의 코드와 호환성을 위해 move 계열 함수를 제공하지 않는 객체의 경우 복사 생성자와 복사 대입 연산자를 대신 호출 한다.

위 Object 클래스의 move 계열 함수들을 주석처리한 위 프로그램을 실행하면 아래와 같이 move가 호출 되는 부분 모두 복사 생성자와 대입 연산자가 호출된 것을 확인할 수 있다.

copy constructor
copy assignment
copy constructor
copy assignmen
  사용자 제공 컴파일러
복사 생성자 O  
복사 대입 연산자 X 디폴트 버전 제공
move 생성자 X 제공 안함
move 대입 연산자 X 제공 안함

위 내용 중에 웃긴 부분이 복사 생성자를 제공하면 move 계열 함수들은 멤버들이 특수한 처리가 필요하다고 생각하고 디폴트 버전을 제공하지 않는데 복사 대입 연산자의 경우는 디폴트 버전을 제공한다. 이상하긴 한데 선진국에서 고액 연봉 받는 사람들이 무슨 생각이 있어서 이렇게 해놨겠거니 생각하고 넘어 가기로 하자.

사용자가 move 계열 함수만 제공할 때

세 번째로 살펴 볼 내용은 사용자가 move 생성자 또는 대입 연산자만을 제공했을 때다.

class Object
{
    String name;
public:
    Object() = default;
    
    // Object(const Object& obj) : name(obj.name) {}
    // Object& operator = (const Object& obj) { name = obj.name; return *this; }

    Object(Object&& obj) noexcept : name(std::move(obj.name)) {}
    // Object& operator = (Object&& obj) noexcept { name = std::move(obj.name); return *this; }
};

이전 섹션에서 살펴 보았듯이 복사나 move에 관해 사용자 정의 코드를 제공한다는 것은 컴파일러가 제공해주는 디폴트 코드로는 처리할 수 없다는 의미라고 컴파일러는 받아 들인다고 했다. 그러므로 위와 같이 move 생성자 하나만 제공하는 경우 컴파일러는 그 어떤 디폴트 코드도 제공하지 않는다.

  사용자 제공 컴파일러
복사 생성자 X 삭제됨(=delete)
복사 대입 연산자 X 삭제됨(=delete)
move 생성자 O 제공 안함
move 대입 연산자 X 제공 안함
int main()
{
    Object obj1;

    Object obj2 = obj1;            // error
    obj2 = obj1;                   // error

    Object obj3 = std::move(obj1); // 사용자 제공함수 사용
    obj3 = std::move(obj1);        // error

    return 0;
}

이렇게 사용자가 특수한 목적으로 함수 하나만 제공 했지만, 그 외 나머지는 컴파일러에서 제공하는 기본 함수들이 충분한 경우도 있다. 이럴 경우에는 생성자와 대입 연산자에 default 처리를 해주면 간단하게 해결 된다.

class Object
{
    String name;
public:
    Object() = default;

    Object(const Object& obj) : name(obj.name) {}

    Object& operator = (const Object& obj) = default;
    Object(Object&& obj) noexcept = default;
    Object& operator = (Object&& obj) noexcept = default;
};

복사 대입 연산자의 경우 복사 생성자를 선언하면 자동으로 생성해주기 때문에 default 처리를 할 필요 없지 않냐고 물어 보시는 분들도 있을 수 있겠으나, 아래에서 move 생성자와 move 대입 연산자가 default이긴 해도 사용자가 제공한 함수이기 때문에 이전에 살펴 보았던 규칙에 따라 자동 생성 되지 않는다. 그래서 복사 대입 연산자의 경우에도 명시적으로 default 선언을 해주어야 한다.

마치며

이상으로 move 생성자의 기본 생성 규칙을 살펴 보았다. 정리하자면 복사 계열 사용자 함수가 제공되는 경우 move 계열 함수들이 자동 생성되지는 않으나 move 계열 함수가 구현되지 않은 객체에 대해 std::move함수를 호출하게 되면 복사 생성자와 대입 연산자가 대신 호출 된다.  

반면에 move 계열 사용자 함수가 제공 되면 복사 계열 함수들이 자동 생성되지 않으므로 복사 연산에서 오류가 발생한다. 이런 경우 직접 복사 계열 함수들을 제공하거나, 기본 생성 되는 함수들로 충분하다면 함수 선언과 함께 default 처리 해주면 된다.

부록 1. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!