컴퓨터공학/Effective C++

11. operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자

Pyxis 2025. 2. 13. 23:59

[ Effective C++ ]

자기대입(self assignment)이란, 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다.

class Widget { ... };
Widget w;
...
w = w; // 자기에 대한 대입

 

이 코드는 의외로 legal한 코드이다. 더 큰 문제는, 이 대입이란 연산은 그렇게 눈에 잘 띄지도 않는다는 것이다.

예를 들면,

a[i] = a[j]; // [1] 자기대입 가능성 존재
// ---------------- //
*px = *py; // [2] 자기대입 가능성 존재

 

[1]은 인덱스인 i 와 j 가 같을 경우, [2]는 포인터가 가리키는 주소가 같을 경우 자기대입이 발생할 수 있다.

 

객체 포인터에 주소를 담는 것은 반드시 원본 객체의 타입에 담아야 하는 것이 아니다.

파생 클래스의 주소를 기반 클래스의 포인터에 저장하는 것은 얼마든지 가능하고, 자주 발생하기 때문이다.

class Base { ... };
class Derived : public Base { ... };
void doSomething(const Base& rb, Derived* pd);
// -> rb와 *pd는 충분히 같은 객체일 수 있다.

 

항목 13, 14의 조언을 따른다면 자원 관리 용도로 항상 객체를 만들어야 하고, 이 객체들이 복사되기 위해 복사 대입 연산자를 코딩하게 될텐데, 여기서 조심해야 하는 점이 있다.

이 복사 대입 연산자는 자기대입에 대해 안전하게 동작해야 하는데, 자원을 사용하기 전에 해제될 수도 있다. 예를 들어보자.

class Bitmap { ... };
class Widget
{
  ...
private:
    Bitmap* pb; // 힙에 할당한 객체를 가리키는 포인터
};

/* ... */
Widget&
Widget::operator=(const Widget& rhs) // 안전하지 않게 구현된 operator=
{
    delete pb;                       // 현재의 비트맵 사용 중지
    pb = new Bitmap(*rhs.pb);        // 이제 rhs의 비트맵 사용
    
    return *this;
}


위 예제의 문제점은, rhs와 *this가 같은 객체일 가능성이 있다는 것이다.

그럴 경우, delete pb 를 하는 순간 *this와 rhs 객체 전부 적용되어 버린다.

 

이런 에러에 대한 전통적인 대책은 operator= 의 시작 부분에 일치성 검사(identity test)를 통해 자기대입을 점검하는 것이다.

Widget& Widget::operator=(const Widget& rhs)
{
    if (this == &rhs) return *this; // 자기대입 검사
    
    delete pb;
    pb = new Bitmap(*rhs.pb);
    
    return *this;
}

 

또한, 만약 new 연산자에서 예외가 터진다면 (동적할당 메모리 부족 또는 Bitmap 복사 생성자내의 예외 발생) Widget 객체는 삭제된 Bitmap을 가리키는 포인터를 쥐게 된다.

 

다행히, operator=를 예외에 안전하게 구현한다면 보통은 자기대입에도 안전한 코드가 나온다.

pb를 삭제하기 전에 복사해두고 삭제하는 것이다.

Widget& Widget::operator=(const Widget& rhs)
{
    Bitmap *pOrig = pb;       // 원래의 pb를 어딘가에 저장
    pb = new Bitmap(*rhs.pb); // 다음, pb가 *pb의 사본을 가리키게 만든다.
    delete pOring;            // 원래의 pb 삭제
    
    return *this;
}

 

위 예제는 예외에 안전한데, new Bitmap에서 예외가 발생하더라도 pb는 변경되지 않은 상태가 유지되기 때문이다.

게다가 일치성 검사가 없어도 자기대입 현상을 완벽히 처리하고 있는데, 원본을 복사해놓고 사본에 저장해둔 뒤, 원본을 해제하는 순서로 실행되기 때문이다.

 

자기대입이 발생하는 빈도가 적은 것에 비해, 일치성 테스트의 비용은 무시할만한 것이 아니다.

if 문을 통해 분기가 생기는 것이므로, 코드의 증가와 함께 CPU instruction pretech, cache, pipelining(아마도 분기 예측) 등의 효과도 떨어질 수 있다.

 

[ copy and swap ]

예외 안전성과 자기대입 안정성을 둘 다 챙길 수 있는 operator=의 구현법이 한 가지 더 존재한다.

항목29의 예외 안전성에서 깊게 다루는 방법인데, operator= 작성에서 자주 쓰이기 때문에 미리 알아보자.

class Widget
{
    ...
        void swap(Widget& rhs) // *this의 데이터 및 rhs의 데이터를 맞바꿈
    ...
}

Widget& Widget::operator=(const Wdiget& rhs)
{
    Widget temp(rhs); // rhs의 사본을 하나 생성
    swap(temp);       // *this의 데이터를 사본과 맞바꿈(swap)
    return *this;
}

 

이 방법은 C++의 2가지 특징을 이용해서 다르게 구현가능한데, 

1) 클래스의 복사 대입 연산자는 인자를 값으로 받아오는 것이 가능하다는 점

2) 값에 의한 전달을 수행하면 전달된 대상의 사본이 생긴다는 점

을 이용한 것이다.

Wdiget& Widget::operator=(Widget rhs) // rhs는 넘어온 원래 객체의 사본
{                                     // 값에 의한 전달
    swap(rhs);                        // *this 데이터를 사본과 맞바꿈(swap)
    
    return *this;
}

 

위 코드는 대신 의도를 전달하는 명확성을 희생해야 하는데, 대신 객체를 복사하는 코드가 함수 본문이 아니라 매개변수의 생성자로 옮겨졌기 때문에, 컴파일러가 최적화할 수 있는 여지가 생기는 이점이 있다.

 

 

[ 정리 ]

- operator=를 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만들자. 원본 객체와 복사 대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복 사후 맞바꾸기 기법을 써도 된다.

- 두 개 이상의 객체에 대해 동작하는  함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해 보자.