15 minute read

해당 글은 C++의 생성자(Constructor), 대입연산자 overloading(operator=())에 대한 이해를 하고 있음을 전제로 합니다.

TL;DR

  1. std::move()는 rvalue로 casting 역할만 한다.
  2. 객체 소유권 이전에 관한 문제는 이동 생성자 및 대입연산자에서 수행하지 std::move()와는 연관이 없다.
  3. 이동 생성자 및 대입연산자 에서는 소유권을 넘길 객체가 가진 포인터를 null로 바꿔주는 명시적인 처리를 해줘야 한다.
  4. 그 이유는 소유권이 넘겨진 객체가 delete 될 때 해당 객체가 소유한 포인터가 가리키는 데이터가 삭제될 수 있기 때문이다.
  5. 그런 이유로 소멸자에 대해서도 추가적인 처리가 필요하다. (nullptr 아닐 때만 delete)

Detail

1. 소유권 이동에 대한 필요성

제일 들기 쉬운 예는 사이즈가 매우 큰 데이터에 대한 swap 연산이다. element 수가 1억개인 class 에 대해서 swap하는 연산을 수행하는 아래의 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#include <iostream>
#include <chrono>
#include <cstring>

// 1. std::move()는 rvalue로 casting 역할만 한다.
// 2. 객체 소유권 이전에 관한 문제는 이동 생성자 및 대입연산자에서 수행하지 std::move()와는 연관이 없다.
// 3. 이동 생성자 및 대입연산자 에서는 소유권을 넘길 객체가 가진 포인터를 null로 바꿔주는 명시적인 처리를 해줘야 한다.
// 3-1. 그 이유는 소유권이 넘겨진 객체가 delete 될 때 해당 객체가 소유한 포인터가 가리키는 데이터가 삭제될 수 있기 때문이다.
// 3-2. 그런 이유로 소멸자에 대해서도 추가적인 처리가 필요하다. (nullptr 아닐 때만 delete)

class LargeData {
private:
    int* data;
    size_t size;

public:
    LargeData(size_t s) : size(s) {
        data = new int[size];
        std::fill(data, data+size, 1);
    }

    // 복사 생성자
    LargeData(const LargeData& other) : size(other.size) {
        data = new int[size];
        std::memcpy(data, other.data, size * sizeof(int));
    }

    // 이동 생성자
    LargeData(LargeData&& other) noexcept : data(other.data), size(other.size) {
        // 3. 이동 생성자 및 대입연산자 에서는 소유권을 넘길 객체가 가진 포인터를 null로 바꿔주는 명시적인 처리를 해줘야 한다.
        other.data = nullptr;
        other.size = 0;
    }

    // 복사 대입 연산자
    LargeData& operator=(const LargeData& other) {
        if (this != &other) {
            delete[] data;
            size = other.size;
            data = new int[size];
            std::memcpy(data, other.data, size * sizeof(int));
        }
        return *this;
    }

    // 이동 대입 연산자
    // 2. 객체 소유권 이전에 관한 문제는 이동 생성자 및 대입연산자에서 수행하지 std::move()와는 연관이 없다.
    LargeData& operator=(LargeData&& other) noexcept {
        if (this != &other) {
            delete[] data;
            data = other.data;
            size = other.size;
            // 3. 이동 생성자 및 대입연산자 에서는 소유권을 넘길 객체가 가진 포인터를 null로 바꿔주는 명시적인 처리를 해줘야 한다.
            other.data = nullptr;
            other.size = 0;
        }
        return *this;
    }

    ~LargeData() {
        // 3-2. 그런 이유로 소멸자에 대해서도 추가적인 처리가 필요하다. (nullptr 아닐 때만 delete)
        if (data != nullptr) {
            delete[] data;
            std::cout << "Destroying LargeData with " << size << " elements.\n";
        } else {
            std::cout << "Destroying empty LargeData.\n";
        }
    }

    size_t getSize() const { return size; }
};

void swap_largedata_copy(LargeData& LD1, LargeData& LD2)
{
    LargeData tmp(LD1);
    LD1 = LD2;
    LD2 = tmp;
}

void swap_largedata_move(LargeData& LD1, LargeData& LD2)
{
    LargeData tmp(std::move(LD1)); // 1. std::move()는 rvalue로 casting 역할만 한다.
    LD1 = std::move(LD2);
    LD2 = std::move(tmp);
}

int main() {
    LargeData original1(100000000);
    LargeData original2(100000000);

    // measure copy
    auto start = std::chrono::high_resolution_clock::now();
    swap_largedata_copy(original1, original2);
    auto end = std::chrono::high_resolution_clock::now();
    long long copyTime = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    

    // measure move
    start = std::chrono::high_resolution_clock::now();
    swap_largedata_move(original1, original2);
    end = std::chrono::high_resolution_clock::now();
    long long moveTime = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();

    std::cout << "Copy swap time: " << copyTime << "ms\n";
    std::cout << "Move swap time: " << moveTime << "ms\n";

    return 0;
}

결과는 다음과 같다. move 의 경우 swap 함수 내에서 소유권에 의한 포인터 이동만 하면 되므로 실행시간이 거의 0에 수렴하지만, copy의 경우 매우 오랜 시간이 소요됨을 확인할 수 있다.

1
2
3
4
5
6
Destroying LargeData with 100000000 elements.
Destroying empty LargeData.
Copy swap time: 490ms
Move swap time: 0ms 
Destroying LargeData with 100000000 elements.
Destroying LargeData with 100000000 elements.

2. 소유권 이동 시 주의 사항

TL;DR 에서도 얘기 했듯이 소유권 이동은 생성자나 대입연산자 안에서 실질적으로 이루어지는 것이지, std::move()에 의해서 일어나는 것이 아니다. std::move()는 그저 lvalue를 rvalue로 typecasting 시키는 것이다. 위 코드에서 소유권 이동은 이동생성자와 이동 대입연산자 override 에서 이루어진다.

1
2
3
4
5
6
// 이동 생성자
    LargeData(LargeData&& other) noexcept : data(other.data), size(other.size) {
        // 3. 이동 생성자 및 대입연산자 에서는 소유권을 넘길 객체가 가진 포인터를 null로 바꿔주는 명시적인 처리를 해줘야 한다.
        other.data = nullptr;
        other.size = 0;
    }
1
2
3
4
5
6
7
8
9
10
11
LargeData& operator=(LargeData&& other) noexcept {
        if (this != &other) {
            delete[] data;
            data = other.data;
            size = other.size;
            // 3. 이동 생성자 및 대입연산자 에서는 소유권을 넘길 객체가 가진 포인터를 null로 바꿔주는 명시적인 처리를 해줘야 한다.
            other.data = nullptr;
            other.size = 0;
        }
        return *this;
    }

그렇다면 왜 이동 생성자나 대입 연산자에서 포인터 값을 null로 처리하는가?

1. 데이터의 소유권이 없어진 변수 들을 다시 사용하지 않도록 명시적으로 표시하는 역할

위 코드의 swap_largedata_move 함수를 보면

1
2
3
4
5
6
void swap_largedata_move(LargeData& LD1, LargeData& LD2)
{
    LargeData tmp(std::move(LD1)); // 1. std::move()는 rvalue로 casting 역할만 한다.
    LD1 = std::move(LD2);
    LD2 = std::move(tmp);
}

LargeData tmp(std::move(LD1)); 를 하는 순간 LD1 변수가 멤버로 가지고 있는 pointer 자료형 data는 nullptr가 된다. LD1이 소유한 데이터를 tmp가 모두 가졌으므로, data가 가리키는 주소는 tmp만 소유하고 있다. 중단점을 걸고 확인하면 LD1.data의 주소가 0x0이 됨을 확인할 수 있다. (nullptr가 되도록 이동생성자에서 코드를 짰으니까 당연)

지금은 swap_largedata_move()라는 간단한 함수 내에서 작업이 이뤄져서 굳이 nullptr로 만들어줘야 하나 하는 생각이 들 수 있지만, 코드가 길어져서 개발자가 데이터에 대한 소유권을 뺐긴 LD1을 다시 참조하려고 할 때 compile error를 띄워줄 것이다.

2. 소멸자에서 데이터의 소유권이 상실된 변수에 대해서 delete 연산 방지

더 중요한 건 소멸자의 행동을 다르게 하기 위해서이다. swap_largedata_move()가 종료되고나면 지역변수인 tmp는 소멸자에 의해 사라지게 된다. 소멸자는 아래와 같이 정의되어있다.

1
2
3
4
5
6
7
8
~LargeData() {
    if (data != nullptr) {
        delete[] data;
        std::cout << "Destroying LargeData with " << size << " elements.\n";
    } else {
        std::cout << "Destroying empty LargeData.\n";
    }
}

소멸되는 변수인 tmp는 LD2 = std::move(tmp); 에 의해 사용자가 작성한 이동 대입연산자에 의해 이미 data에 대한 주소가 nullptr(0x0)으로 바뀐 상태다. 따라서 tmp가 소멸되더라도 tmp의 멤버인 data는 delete 되지 않는다. 만약에 이동 생성자나 이동 대입연산자에서 nullptr 처리를 하지 않았다면 delete 에 의해 가리키고 있는 데이터가 지워지게 된다. 자신이 소멸하면서 소유권을 뺏긴 데이터를 지우는 건 의도하지 않은 동작이기 때문에 소멸자에는 반드시 저런 예외처리가 들어가야 한다.

이런 사실을 고려하지 않고 같은 의도로, 그러나 이동 생성자 및 대입연산자, 그리고 소멸자를 잘 못 짠 아래 코드를 돌려보면 확실하게 그 차이를 알 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <iostream>
#include <chrono>
#include <cstring>

class LargeData {
private:
    int* data;
    size_t size;

public:
    LargeData(size_t s) : size(s) {
        data = new int[size];
        std::fill(data, data+size, 1);
    }

    // 복사 생성자
    LargeData(const LargeData& other) : size(other.size) {
        data = new int[size];
        std::memcpy(data, other.data, size * sizeof(int));
    }

    // 이동 생성자
    LargeData(LargeData&& other) noexcept : data(other.data), size(other.size) {
        // 3. 소유권 이전 후 원본 객체의 데이터를 비움
        other.data = nullptr;
        other.size = 0;
    }

    // 복사 대입 연산자
    LargeData& operator=(const LargeData& other) {
        if (this != &other) {
            delete[] data;
            size = other.size;
            data = new int[size];
            std::memcpy(data, other.data, size * sizeof(int));
        }
        return *this;
    }

    // 이동 대입 연산자 (nullptr 처리를 하지 않은 wrong way)
    LargeData& operator=(LargeData&& other) noexcept {
        if (this != &other) {
            delete[] data;
            data = other.data;
            size = other.size;
        }
        return *this;
    }

    ~LargeData() {
        // 3-2. 소멸자에서 추가적인 처리하지 않음 (wrong way)
        delete[] data;
    }

    size_t getSize() const { return size; }
};

void swap_largedata_copy(LargeData& LD1, LargeData& LD2)
{
    LargeData tmp(LD1);
    LD1 = LD2;
    LD2 = tmp;
}

void swap_largedata_move(LargeData& LD1, LargeData& LD2)
{
    LargeData tmp(std::move(LD1));
    LD1 = std::move(LD2);
    LD2 = std::move(tmp);
}

int main() {
    LargeData original1(100000000);
    LargeData original2(100000000);

    // measure copy
    auto start = std::chrono::high_resolution_clock::now();
    swap_largedata_copy(original1, original2);
    auto end = std::chrono::high_resolution_clock::now();
    long long copyTime = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    

    // measure move
    start = std::chrono::high_resolution_clock::now();
    swap_largedata_move(original1, original2);
    end = std::chrono::high_resolution_clock::now();
    long long moveTime = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();

    std::cout << "Copy swap time: " << copyTime << "ms\n";
    std::cout << "Move swap time: " << moveTime << "ms\n";

    return 0;
}

위 코드를 돌려보면 소멸자에서 segmentation fault가 뜨는데, 그 전에 swap_largedata_move(original1, original2); 이후 중단점을 걸고 original2.data 를 보면 아무런 값도 들어있지 않은 것을 확인할 수 있다. 왜냐하면 tmp가 소멸되면서 자신이 가리키는 데이터도 지워버렸기 때문이다!!

Conclusion

std::move()는 해당 변수에 대한 소유권이 다른 곳으로 이동할 것이라는 indicator 역할만 수행한다. 실제 소유권 이동은 이동 생성자 및 대입연산자에서 이루어질 뿐임을 명심하고 소유권을 뺏긴 데이터에 대한 소멸자를 신경쓰도록 주의하자.

Leave a comment