11 분 소요

모던 C++

복사 대입 연산자

복사 대입 연산자는 개체의 내용을 복제하는 역할을 합니다. 기본 형태는 다음과 같습니다.

항목 내용
T& operator =(const T& other) 복사 대입 연산자

인자로 전달되는 other는 수정할 수 없도록 const를 사용하며, 복사 부하가 없도록 참조자(&)를 사용합니다. 리턴값은 a = b = c;의 표현이 가능하도록 T&const T&를 리턴하며, a = b = c;의 표현을 사용하지 않을 것이라면 void를 리턴해도 됩니다.(T를 리턴하는 것은 불필요하게 복사 연산을 하기 때문에 사용하지 않습니다.)

암시적 복사 대입 연산자

복사 생성자와 마찬가지로, 복사 대입 연산자를 정의하지 않으면, 컴파일러가 암시적으로 복사 대입 연산자를 정의해 줍니다. 기본 동작은 복사 생성자와 유사하게 멤버별 복사 대입입니다.

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
class T {
    int m_X;
    int m_Y;
public:
    T(int x, int y) : 
        m_X(x), 
        m_Y(y) {} 
    // 암시적 복사 생성자의 기본 동작은 멤버별 복사 생성자 호출입니다.    
    // T(const T& other) :
    //     m_X(other.m_X),
    //     m_Y(other.m_Y) {}

    // 암시적 복사 대입 연산자의 기본 동작은 멤버별 복사 대입입니다.    
    // T& operator =(const T& other) {
    //     m_X = other.m_X;
    //     m_Y = other.m_Y;
    // }

    int GetX() const {return m_X;}
    int GetY() const {return m_Y;}
};
T t1(10, 20);
T t2(1, 2); 
t2 = t1; // (O) 암시적 복사 대입 연산자 호출

EXPECT_TRUE(t2.GetX() == 10 && t2.GetY() == 20);

swap을 이용한 예외 보증 복사 대입 연산자

예외가 발생하면, 스택 풀기에 언급된 것처럼 예외가 발생하기 전의 상태를 복원해야 합니다.(이를 예외 보증이라 합니다.)

암시적 복사 대입 연산자는 각 멤버 변수별로 복사 대입을 하는데요, 그러다 보니 중간에 예외가 발생했을 경우, 이전에 이미 수정한 개체를 복원할 수 없어 예외 보증이 안됩니다.

1
2
3
4
T& operator =(const T& other) {
    m_X = other.m_X;
    m_Y = other.m_Y; // 여기서 예외가 발생했다면 m_X를 되돌려야 합니다.
}

이러한 문제를 해결하기 위해,

  1. 임시 개체를 만든 뒤,
  2. swap()을 이용해 this임시 개체를 바꿔치기하여,

예외를 보증하는 복사 대입 연산자를 구현할 수 있습니다.

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
class T {
    int m_X;
    int m_Y;
public:
    T(int x, int y) : 
        m_X(x), 
        m_Y(y) {} 
    // 암시적 복사 생성자의 기본 동작은 멤버별 복사 생성자 호출입니다.    
    // T(const T& other) :
    //     m_X(other.m_X),
    //     m_Y(other.m_Y) {}
    
    T& operator =(const T& other) {

        // other를 복제한 임시 개체를 만듭니다.
        T temp(other); // (O) 생성시 예외가 발생하더라도 this는 그대로 입니다.

        // this의 내용과 임시 개체의 내용을 바꿔치기 합니다.
        // this는 이제 other를 복제한 값을 가집니다.
        Swap(temp); 

        return *this;
        
    } // temp는 지역 변수여서 자동으로 소멸됩니다.

    // 멤버 변수들의 값을 바꿔치기 합니다.
    void Swap(T& other) {
        // (△) 비권장. int 형이라 복사 부하가 크지는 않습니다만, 
        // 조금 큰 개체라면 복사 부하가 있고 예외를 발생할 수 있습니다.
        std::swap(this->m_X, other.m_X); 
        std::swap(this->m_Y, other.m_Y);
    }

    int GetX() const {return m_X;}
    int GetY() const {return m_Y;}
};
T t1(10, 20);
T t2(1, 2); 
t2 = t1; // (O) swap 버전 복사 대입 연산자 호출

EXPECT_TRUE(t2.GetX() == 10 && t2.GetY() == 20);

swap의 복사 부하

swap을 이용한 예외 보증 복사 대입 연산자예외 보증이 되어 좋습니다만, 심각한 복사 부하가 있습니다.

다음 코드를 테스트 해보면,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class T {
public:
    T() {}
    T(const T& other) {
        std::cout << "T::T(const T& other)" << std::endl;   
    }
    T& operator =(const T& other) {
        std::cout << "T::operator =()" << std::endl;
        return *this; 
    } 
};
T t1;
T t2;

t1 = t2; // 복사 대입 1회
std::swap(t1, t2); // 복사 생성 1회 복사 대입 2회
  1. 멤버 변수복사 대입 방식을 사용하면, 복사 대입 연산이 1회 일어나지만,
  2. swap()을 이용하면, 복사 생성 1회와 복사 대입 연산 2회가 발생하는 걸 알 수 있습니다.

보통 swap()은 다음과 같이 임시 개체를 만들고, 각각 값을 복사 대입하기 때문에 복사 대입 연산에서 복사 부하가 있을 수 밖에 없습니다. 또한 복사 대입 과정에서 또다른 예외가 발생할 수도 있죠.

1
2
3
4
5
swap(T& left, T& right) {
    T temp(right); // 복사 생성 1회, 멤버별 복사 대입 연산과 거의 동등한 부하
    right = left;  // 복사 대입 연산 1회 - swap에 따른 추가 복사 부하
    left = temp; // 복사 대입 연산 1회 - swap에 따른 추가 복사 부하
}

임시 개체를 만들고 버리는 복사 생성은 미세한 차이는 있겠으나 멤버별 복사 대입 연산과 동등한 부하라고 볼 수 있습니다. 하지만, swap() 과정에서 발생하는 복사 대입swap() 때문에 추가된 부하입니다. 무려 2번이나 되죠.

따라서, nothrow swap 기법을 이용하여 swap()은 복사 부하도 없고, 예외도 발생시키기 않도록 최적화해야 합니다.

nothrow swap - 포인터 멤버 변수를 이용한 swap 최적화

개체가 int형과 같은 기본 자료형 멤버 변수를 1~2개 사용하고 있다면, 복사 대입 연산 부하도 적고, 예외 발생 확률도 낮습니다. 그냥 swap으로 복사 대입 연산자를 구현하더라도 예외 발생이 없는 nothrow swap으로 취급해도 무방합니다.

그러나, 아주 많은 기본 자료형을 사용하거나 동적으로 할당하는 거대한 데이터를 가지고 있다면, 복사 대입시 복사 부하도 크고, 예외 발생 확률도 높습니다. swap()으로 구현해서 예외 보증은 되지만, 복사 부하나 예외 발생 확률이 높다면 안되겠죠.

이런 문제를 해결하기 위해 비교적 복사 부하가 적고, 예외 발생 확률도 낮은 포인터 멤버 변수를 활용하여 복사 대입 연산자를 swap으로 구현할 수 있습니다.(포인터 복사는 8byte끼리의 복사이므로 복사 부하가 적고, 예외 발생 확률도 낮습니다.)

다음 예제에서

  1. #1 : Big은 임의의 큰 데이터를 처리하는 클래스로 가정합니다.
  2. #2 : Big복사 생성자복사 대입 연산자에 메시지를 출력해서 복사 부하를 확인합니다.
  3. #3 : TBig포인터 멤버 변수로 관리합니다.
  4. #4 : 포인터 멤버 변수의 소유권 분쟁이 없도록 T복사 생성자에서 Big을 복제하고 소멸자에서 delete 합니다.
  5. #5 : 복사 대입 연산자를 만들고 Swap()으로 구현합니다.
  6. #6 : Swap()포인터 멤버 변수들끼리 바꿔치기합니다. 실제 Big을 복사하는 것이 아니라 포인터만 복사하여 복사 부하를 줄입니다.
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
class Big {
    int m_Val; // #1. 실제로는 복사 부하가 큰 데이터라고 생각해 주세요.
public:
    explicit Big(int val) : 
        m_Val(val) {}
    Big(const Big& other) : 
        m_Val(other.m_Val) {
        std::cout << "Big::Big(const Big& other)" << std::endl; // #2 
    }
    Big& operator =(const Big& other) {
        m_Val = other.m_Val;
        std::cout << "Big::operator =(const Big& other)" << std::endl;  // #2
        return *this;
    }    
    int GetVal() const {return m_Val;}
    void SetVal(int val) {m_Val = val;}
};
class T {
    Big* m_Big; // #3. 복사 부하가 큰 데이터는 포인터로 관리합니다.
public:
    explicit T(Big* big) : 
        m_Big(big) {} 
    // NULL 포인터가 아니라면 복제합니다.
    T(const T& other) :
        m_Big(other.m_Big != NULL ? new Big(*other.m_Big) : NULL) { // #4
    }
    // 힙 개체를 메모리에서 제거 합니다.
    ~T() {
        delete m_Big; // #4
    }
    
    T& operator =(const T& other) { // #5

        // other를 복제한 임시 개체를 만듭니다.
        T temp(other); // (O) 생성시 예외가 발생하더라도 this는 그대로 입니다.

        // this의 내용과 임시 개체의 내용을 바꿔치기 합니다.
        // this는 이제 other를 복제한 값을 가집니다.
        Swap(temp); 

        return *this;
        
    } // temp는 지역 변수여서 자동으로 소멸됩니다.

    // 멤버 변수들의 값을 바꿔치기 합니다.
    void Swap(T& other) { // #6
        // (O) 포인터 변수끼리의 복사/대입이라 복사 부하가 크지 않습니다.
        // 예외가 발생할 확률도 낮습니다.
        std::swap(this->m_Big, other.m_Big); 
    }

    const Big* GetBig() const {return m_Big;}
};
T t1(new Big(10));
T t2(new Big(1)); 
t2 = t1; // (O) swap 버전 복사 대입 연산자 호출

EXPECT_TRUE(t2.GetBig()->GetVal() == 10);

하기는 실행 결과 입니다.

복사 대입 연산시 임시 개체(temp)를 생성하느라 복사 생성자(T(const T& other))에서 Big 개체 1개를 복사 생성한 것(멤버별 복사 대입에서와 거의 동등한 부하입니다.) 외에는 다른 복사 부하가 없습니다.

1
Big::Big(const Big& other)

임시 개체(temp)에서 newBig개체들은 this에 포인터 복사되고, this가 관리하던 Big개체들은 임시 개체에 전달된 후 버려집니다. 따라서 Swap()으로 인한 복사는 포인터 복사(8byte 복사) 뿐이므로, 복사 부하는 거의 없다고 보셔도 무방합니다.

즉, 포인터 멤버 변수로 정의한 개체의 복사 대입 연산자swap()으로 구현하면,

  1. 예외에 안전하고,
  2. 복사 부하는 멤버 변수복사 대입과 거의 동등합니다.

복사 대입 연산자까지 지원하는 스마트 포인터

복사 대입 연산자 지원을 위해 복사 생성자만 지원하는 스마트 포인터swap을 이용한 복사 대입 연산자 지원 기능을 추가하면,

  1. 복사 생성시 스마트 포인터에서 복제를 해주고,
  2. 소멸시 스마트 포인터에서 delete 해주고,
  3. 복사 대입 연산시 스마트 포인터에서 swap()해 주므로,

암시적 복사 생성자, 암시적 소멸자, 암시적 복사 대입 연산자와 호환되어 별도로 구현할 필요가 없어집니다. 따라서 다음처럼 복사 생성자, 소멸자, 복사 대입 연산자 정의 없이 간소하게 클래스를 작성할 수 있습니다.

1
2
3
4
5
6
7
class T {
    IntPtr m_Val;
public:
    explicit T(int* val) :
        m_Val(val) {}
    int GetVal() const {return *m_Val;}
};

다음은 전체 코드입니다.

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
// 복사 생성시 m_Ptr을 복제하고, 소멸시 delete 합니다.
// 복사 대입 연산은 임시 개체 생성 후 swap 합니다.
class IntPtr {
private:
    int* m_Ptr; // new로 생성된 개체입니다.
public: 
    explicit IntPtr(int* ptr) :
        m_Ptr(ptr) {}

    // (O) NULL 포인터가 아니라면 복제합니다.    
    IntPtr(const IntPtr& other) :
        m_Ptr(other.IsValid() ? new int(*other.m_Ptr) : NULL) {}

    // 힙 개체를 메모리에서 제거 합니다.
    ~IntPtr() {delete m_Ptr;}

    IntPtr& operator =(const IntPtr& other) {

        // other의 힙 개체를 복제한 임시 개체를 만듭니다.
        IntPtr temp(other); // (O) 생성시 예외가 발생하더라도 this는 그대로 입니다.

        // this의 내용과 임시 개체의 내용을 바꿔치기 합니다.
        // this는 이제 other의 힙 개체를 복제한 값을 가집니다.
        Swap(temp); // (O) 포인터 끼리의 값 변경이므로 복사 부하가 없고, 예외가 발생하지 않습니다.

        return *this;
        // temp는 지역 변수여서 자동으로 소멸됩니다.
        // 소멸되면서 this가 이전에 가졌던 힙 개체를 소멸합니다.
    }
    // 멤버 변수들의 값을 바꿔치기 합니다.
    void Swap(IntPtr& other) {
        std::swap(this->m_Ptr, other.m_Ptr); // (O) 포인터 끼리의 값 변경이므로 복사 부하도 없고, 예외가 발생하지 않습니다.   
    }

    // 포인터 연산자 호출시 m_Ptr에 접근할 수 있게 합니다.
    const int* operator ->() const {return m_Ptr;}
    int* operator ->() {return m_Ptr;}

    const int& operator *() const {return *m_Ptr;}
    int& operator *() {return *m_Ptr;}

    // 유효한지 검사합니다.
    bool IsValid() const {return m_Ptr != NULL ? true : false;}    
};

class T {
    // (O) IntPtr로 복사 생성과 복사 대입시 포인터의 복제본을 만들고, 소멸시 IntPtr에서 delete 합니다.
    // (O) 암시적 복사 생성자에서 정상 동작하므로, 명시적으로 복사 생성자를 구현할 필요가 없습니다.
    // (O) 포인터 멤버 변수가 1개 있고, 내부적으로 복사 대입 연산시 swap하므로 복사 대입 연산자를 구현할 필요가 없습니다.
    IntPtr m_Val;
public:
    // val : new 로 생성된 것을 전달하세요.
    explicit T(int* val) :
        m_Val(val) {}
    int GetVal() const {return *m_Val;}
};
// (O) 힙 개체를 복제하여 소유권 분쟁 없이 각자의 힙 개체를 delete 합니다.
{
    T t1(new int(10));
    T t2(t1); // 새로운 int형 개체를 만들고 10을 복제합니다.

    EXPECT_TRUE(t2.GetVal() == 10);
} 
// (O) 복사 대입 연산 시에도 소유권 분쟁 없이 각자의 힙 개체를 delete 합니다.
{
    T t1(new int(10));
    T t2(new int(20));
    t2 = t1; // (O) swap 버전 복사 대입 연산자 호출
    EXPECT_TRUE(t2.GetVal() == 10);
}

멤버 변수가 2개 이상인 경우 스마트 포인터와 복사 대입 연산자와의 호환성

복사 대입 연산자를 지원하는 스마트 포인터를 사용하더라도, 만약 멤버 변수가 2개 이상이라면, 암시적 복사 대입 연산자와 기본적인 호환은 되나, 기존에 수정된 멤버 변수를 되돌릴 수 없으므로 예외 보증은 지원하지 않습니다.

1
2
3
4
5
6
7
8
9
10
class T {
    IntPtr m_Val1;
    IntPtr m_Val2;
public:
    // 암시적 복사 대입 연산자의 기본 동작은 멤버별 복사 대입입니다.    
    T& operator =(const T& other) {
        m_X = other.m_X;
        m_Y = other.m_Y; // (△) 비권장. 여기서 예외가 발생했다면 m_X를 되돌려야 합니다.
    }    
};

따라서, 스마트 포인터를 사용했더라도, 멤버 변수가 2개 이상이라면, 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
class T {
    // (O) IntPtr로 복사 생성과 복사 대입시 포인터의 복제본을 만들고, 소멸시 IntPtr에서 delete 합니다.
    // (O) 암시적 복사 생성자에서 정상 동작하므로, 명시적으로 복사 생성자를 구현할 필요가 없습니다.
    // (O) 포인터 멤버 변수가 2개 있어, 예외에 안전하지 않으므로 swap으로 복사 대입 연산자를 구현합니다.
    IntPtr m_Val1;
    IntPtr m_Val2;
public:
    // val1, val2 : new 로 생성된 것을 전달하세요.
    T(int* val1, int* val2) :
        m_Val1(val1),
        m_Val2(val2) {}
    T& operator =(const T& other) {
        T temp(other); // (O) 생성시 예외가 발생하더라도 this는 그대로 입니다.
        Swap(temp); // (O) 포인터 끼리의 값 변경이므로 복사 부하가 없고, 예외가 발생하지 않습니다.
        return *this;
    } 
    void Swap(T& other) {
        m_Val1.Swap(other.m_Val1); // 포인터 끼리의 값 변경이므로 복사 부하도 없고, 예외가 발생하지 않습니다. 
        m_Val2.Swap(other.m_Val2);
    }

    int GetVal1() const {return *m_Val1;}
    int GetVal2() const {return *m_Val2;}
};
// (O) 힙 개체를 복제하여 소유권 분쟁 없이 각자의 힙 개체를 delete 합니다.
{
    T t1(new int(10), new int(20));
    T t2(t1); // 새로운 int형 개체를 만들고 10, 20을 복제합니다.

    EXPECT_TRUE(t2.GetVal1() == 10 && t2.GetVal2() == 20);
} 
// (O) 복사 대입 연산 시에도 소유권 분쟁 없이 각자의 힙 개체를 delete 합니다.
{
    T t1(new int(10), new int(20));
    T t2(new int(1), new int (2));
    t2 = t1; // (O) swap 버전 복사 대입 연산자 호출
    EXPECT_TRUE(t2.GetVal1() == 10 && t2.GetVal2() == 20);
}

혹은 멤버 변수를 무조건 1개로 유지하는 방법도 있습니다.(PImpl 이디엄 참고)

복사 대입 연산자 사용 제한

복사 생성자의 경우와 마찬가지로, 만약 복사 대입 연산자가 필요없다면, 암시적 복사 대입 연산자도 사용할 수 없도록 private 로 만드는게 좋습니다. 어짜피 사용하지 않을거라 내버려 뒀는데, 누군가가 유지보수 하면서 무심결에 복사 대입 연산자를 사용하게 된다면, 오동작을 할 수 있거든요. 의도하지 않았다면 동작하지 않게 해야 합니다.

(C++11~) default, delete가 추가되어 암시적으로 생성되는 멤버 함수의 사용 여부를 좀더 명시적으로 정의할 수 있습니다.

댓글남기기