Programming Language/C++

[C++] 이동 시맨틱(Move Semantics): 이동 생성자, 이동 대입 연산자

lumana 2024. 6. 27. 16:39

이동 시맨틱(Move Semantics): 이동 생성자, 이동 대입 연산자

필독

아래에서 주의해서 봐야할 내용이 있습니다. C++17부터 Guarenteed Copy Elision(보장된 복사 생략)이라는 개념이 들어오면서 단순히 함수의 리턴값을 대입하는 경우 임시 객체를 만드는 과정을 생략합니다. 아래 예시에서도 단순히 함수의 리턴 값을 대입하고 있는데, 이 글은 C++11을 기준으로 작성된 내용이므로 실제 컴파일러에서 코드를 돌리는 것과 동일하게 작동하지 않습니다. 이동 시맨틱에 대해서 배운다는 목적으로 아래 내용을 참조하면 되겠습니다.


  • 깊은 복사의 문제점을 해결하기 위해 얕은 복사를 사용

    • 객체를 복사할 때 하나 하나 많은 양의 값을 복사를 해야함.

    • 깊은 복사가 필요 없는 경우도 존재함

// 고의적으로 얕은 복사를 하는 경우
// 깊은 복사 : 하나 하나 많은 양의 값을 다 복사를 해야함.
#include <iostream>
using namespace std;

class String {
   public:
    String() {
        cout << "String() : " << this << '\n';
        strData = NULL;
        len = 0;
    }  // 동적할당 하는바에 포인터에 널값을 대입
    String(const char *str) {
        cout << "String(const char *str) : " << this << '\n';
        // strData = str; --> 얕은 복사
        len = strlen(str);
        alloc(len);
        strcpy(strData, str);  // 깊은 복사
    }

    // 복사 !!!생성자!!!의 작동
    String(const String &rhs) {  // String s2(s1); 사용시 발생하는 일.
        cout << "String(String &rhs) : " << this << '\n';
        // strData = rhs.strData; // 얕은 복사로 인해 메모리 해제 2번
        len = rhs.len;
        alloc(len);                    // 깊은 복사
        strcpy(strData, rhs.strData);  // 깊은 복사
    }

    ~String() {
        cout << "~String() : " << this << endl;
        release();
        strData = NULL;  // delete하고 해제된 메모리에 대한 접근을 막기위한 관습적 방법.
    }

    String &operator=(const String &rhs) {  // 레퍼런스 변수를 사용하지 않으면 rhs(s1)이라는 복사 생성자가 작동해서 복잡해짐.
        cout << "String &operator=(const String &rhs) : " << this << endl;
        if (this != &rhs) {  // 자기 자신을 대입하는 경우를 막기.
            // 만약 strData에 어떤 값이 존재한다면, delete해줘야 함.
            release();
            len = rhs.len;
            alloc(len);
            strcpy(strData, rhs.strData);  // 깊은 복사
        }                                  // 깊은 복사
        return *this;                      // this : 함수가 속한 객체의 주솟값. *this --> 객체
    }

    char *GetStrData() const {
        return strData;
    }

    int GetLen() const {
        return len;
    }
    void SetStrData(const char *str) {
        cout << "void SetStrData(const char *str) : " << this << ", " << str << '\n';
        len = strlen(str);
        alloc(len);
        strcpy(strData, str);
    }

   private:
    void alloc(int len) {
        strData = new char[len + 1];
        cout << "strData allocated : " << (void *)strData << '\n';
    }
    void release() {
        delete[] strData;
        if (strData) cout << "strData released : " << (void *)strData << '\n';
    }

    char *strData;
    int len;
};

String getName() {
    cout << "===== 2 =====" << '\n';
    String res("Doodle");
        /* 코드 생략 */
    cout << "===== 3 =====" << '\n';
    return res;
}
int f() { return 5; }

int main(void) {
    String a;
    cout << "===== 1 =====" << '\n';
    a = getName();
    /*
    operator=에 rvalue를 매개변수로 갖는 연산자 오버로딩을 하자.
    */
    // String &&r = getName();  // 임시객체 반환, &&r은 r-value 참조자이다. 
    cout << "===== 4 =====" << '\n';
}
// r-value(등호에 우변에만 올 수 있는 값)
/*
int a = 5;
a = a --> a는 l-value(오른쪽, 왼쪽 다 올 수 있음)
5 = a 이런건 안됨 --> 5는 r-value

int x = 2
x + 3 = 5; // x + 3 은 r-value --> 연산의 결과값은 r-value이다.
f() = 1; // 리턴값 자체는 임시객체인데 왜 1을 대입을 못하냐? 리턴값은 수정할 수 없는 그냥 r-value이다.

결국 임시객체는 r-value 이다
*/
  • 3과 4 사이에 복사 생성자가 호출됨

  • 어디서 복사 생성자가 호출될까?

    • 위 과정에서 getName함수는 res를 리턴하는데, res가 임시 객체에 복사되고 리턴하면서 res가 소멸되고, 메인 함수 내의 a로 임시 객체가 복사되고 임시 객체가 소멸된다.

    • a에 이름을 넣기 위해, res, 임시 객체를 만들어 깊은 복사를 2번이나 하고 2개의 변수가 생성되고 소멸된다.

  • res -> 임시객체로 -> a 과정에서 복사 생성자, 복사 대입 연산자가 사용되고 있는데, 이를 사용하지 않고 얕은 복사가 일어나도록 다른 종류의 생성자를 사용하고 대입과정에서 얕은 복사를 이용해보자

    • 이 과정에서 이동 시멘틱을 이용한다
  1. "임시 객체의 strdata"가 "res의 strdata" 가리키는 곳"을 가리키고,

  2. "a의 strdata"가 "임시 객체의 strdata가 가리키는 곳"을 가리킨다.

--> "a의 strdata"가 "res의 strdata"를 가리키게 된다

  • 즉, strData를 복사하지 않고, res의 strData가 임시객체의 strData로 이동하고, a의 strData로 이동하는 것처럼 구현할 수 있다는 것이다.

  • 어떻게 구현할 건가요?

    • 함수의 리턴에서 등장하는 임시객체는 r-value이다

    • r-value 참조자를 이용해 getName()에서 반환하는 임시객체를 참조할 수 있다.

      • 복사 생성자에서 l-value 참조자를 썼던 것처럼, r-value 참조자를 이용할 수 있다.

        • String &&r = getName(); 이런 식으로 처리해보자.

1. "임시 객체의 strdata"가 "res의 strdata" 가리키는 곳"을 가리키도록 해보자

  • 이 과정에 이동 생성자를 구현해서 사용하자

  • String(String &&rhs)를 사용하면 된다. // &&rhs는 r-value 참조자

    • 이동 생성자

    • 임시객체가 res로부터 복사를 받아야 함.

      • 임시객체(res)
    • res는 l-value이고 임시 객체는 r-value인데, 어떻게 r-value에 l-value 복사할 수 있는 거죠?

      • 반환되는 동안에는 res가 r-value로 취급되서 임시객체가 res로부터 복사를 받을 수 있다.

      • 만약 String(String &&rhs)를 선언하지 않으면 어떻게 작동하나요?

        • String(const String &rhs)가 호출된다.

        • l-value를 참조하고 있지만, const이므로 값을 변경할 수 없다. 즉 r-value 처럼 작동하기 때문에 const 형 레퍼런스 변수가 r-value를 받을 수 있는 것이다.

// 이동 생성자(깊은 복사), 임시객체(res)에 해당
String(String &&rhs) {  // 임시객체가 res로부터 복사를 받아야 함. res는 l-value이지만, res가 반환되는 동안에는 r-value로 취급됨.
        cout << "String(String&&) : " << this << '\n';
        len = rhs.len;
        strData = rhs.strData;  // 얕은 복사
        rhs.strData = NULL;     // res.strData와 메모리와 연결을 끊어주는 것
}
  • 위 1, 2번 중에서 1번 해결 완료!

2. "a의 strdata"가 "임시 객체의 strdata가 가리키는 곳"을 가리키도록 해보자

  • "a = 임시객체" 와 같은 동작이 실행될 것이고, 대입 연산자가 r-value를 참조하는 이동 대입 연산자를 구현해야 한다
// 이동 대입 연산자
String &operator=(String &&rhs) {  // a = 임시객체(r-value) 호출
        cout << "String &operator=(String &&) : " << this << '\n';
        len = rhs.len;
        strData = rhs.strData;
        rhs.strData = NULL; // 임시객체의 strData와 메모리 간의 연결 끊기
        return *this; 
}
  • String의 대입 연산자에서 자신의 객체를 반환하도록 구현한 이유(return *this)

    • String a = b = c;와 같은 문법을 위해
  • 2번 해결 완료!

참조) 두들낙서 C/C++ 강좌