프로그래밍/C\C++

[Effective C++ 정리 #11] operator= 시 자기 자신 대입 유의!

허구의 2025. 6. 21. 07:39
728x90

이 글은 『Effective C++』를 읽고 개인적으로 공부한 내용을 정리한 기록입니다.

저는 컴퓨터공학을 전공하지 않았으며, 프로그래밍을 공부하는 과정에서의 이해와 생각을 정리하기 위해 글을 작성하고 있습니다.

따라서 내용 중 일부에 오류나 부정확한 설명이 있을 수 있으며, 피드백은 언제든지 환영합니다. 확인 후 수정하도록 하겠습니다.

전문적인 해설이 아닌 개인적 시선에서의 정리임을 참고하고 읽어주시면 감사하겠습니다.

[Effective C++ 정리 #11] 자기 자신에게 대입해도 안전한 코드 만들기: operator=의 함정과 해법

C++에서 operator=를 오버로딩할 때 반드시 고려해야 할 상황이 있습니다. 바로 객체가 자기 자신에게 대입되는 경우(self-assignment)입니다.

 

처음 보면 w = w; 처럼 이상하게 느껴질 수 있지만, 코드가 복잡해지면 이런 일이 의외로 자주 발생합니다.

Widget w;
w = w;          // 직접적인 self-assignment

a[i] = a[j];    // i == j라면 self-assignment
*px = *py;      // px와 py가 같은 주소를 가리키면 self-assignment

 

이처럼 self-assignment는 의도하지 않아도 aliasing에 의해 발생할 수 있기 때문에 대입 연산자 구현 시 반드시 **안전한 처리가 필요**합니다.


Self-assignment이 위험한 이유

다음은 대입 연산자 구현이 잘못된 예입니다:

class Bitmap { ... };

class Widget {
private:
    Bitmap* pb;

public:
    Widget& operator=(const Widget& rhs) {
        delete pb;
        pb = new Bitmap(*rhs.pb);
        return *this;
    }
};

 

처음 보면 괜찮아 보이지만, rhs와 *this가 같은 객체를 가리킬 경우 문제가 발생합니다.

delete pb;로 삭제한 메모리는 rhs.pb와 동일한 객체이므로, 복사하려는 대상이 이미 메모리에서 사라진 셈입니다.

즉, 복사할 데이터를 삭제한 뒤 복사하려는 구조가 되어, 런타임 에러나 정의되지 않은 동작을 유발하게 됩니다.


해결 방법 1: 자기 자신인지 확인

가장 직관적인 해결책은 포인터 주소 비교를 통해 self-assignment인지 확인하는 것입니다:

Widget& Widget::operator=(const Widget& rhs) {
    if (this == &rhs) return *this;  // 자기 자신이면 아무 작업도 하지 않음

    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
}

 

이렇게 하면 불필요한 복사나 해제가 방지되어 안전한 대입 연산이 됩니다.

하지만 이 방법에도 예외 안전성(exception safety)이라는 새로운 이슈가 남습니다.

예외 안전성 문제

위 코드에서 new Bitmap(*rhs.pb)에서 예외가 발생한다면 어떻게 될까요? delete pb;까지는 실행됐고, 이후 pb에는 쓰레기 값이 남게 됩니다.

즉, 예외 발생 시 객체가 중간 상태에 빠지게 됩니다. 이는 매우 위험한 상황입니다.


해결 방법 2: 안전한 순서로 처리

문제를 해결하려면 복사 먼저 하고, 그 다음 원본을 삭제하는 순서로 바꾸면 됩니다:

Widget& Widget::operator=(const Widget& rhs) {
    Bitmap* pOrig = pb;
    pb = new Bitmap(*rhs.pb);  // 복사 먼저
    delete pOrig;              // 기존 메모리 해제
    return *this;
}

 

이렇게 하면 복사 성공 후에만 원본을 삭제하므로 예외가 발생해도 기존 상태는 유지됩니다. 즉, 예외 안전성과 self-assignment 안전성을 모두 만족하는 구조가 됩니다.


해결 방법 3: Copy-and-Swap 기법

가장 널리 쓰이는 근본적인 해결책은 복사-스왑(copy-and-swap) idiom입니다.

class Widget {
public:
    void swap(Widget& rhs);  // 멤버 전부 교환

    Widget& operator=(const Widget& rhs) {
        Widget temp(rhs);  // 복사 생성자 사용
        swap(temp);        // 데이터 교환
        return *this;
    }
};

 

이 방법은 다음과 같은 장점이 있습니다:

  • 자기 대입에 자동으로 안전: 자기 자신을 복사한 뒤 스왑하면 문제가 없음
  • 예외 안전성 확보: 복사 생성자만 성공하면 스왑은 절대 예외 발생 안함
  • 코드 간결성: 한 가지 방식으로 모든 경우를 처리

다만 swap을 정의해야 하고, 복사 생성자가 구현되어 있어야 한다는 점에서 약간의 코드 분리가 필요합니다.

또 다른 변형은 다음과 같이 아예 복사본을 매개변수로 받는 방식도 있습니다:

Widget& Widget::operator=(Widget rhs) {
    swap(rhs);
    return *this;
}

 

이 방식은 매개변수 복사에서 이미 복사 생성자를 통해 예외 안전성이 확보되므로, swap()만 제대로 작동하면 안정적인 대입 연산이 됩니다.

 

일반적인 self-assignment는 자주 일어나지 않기 때문에 항상 if (this == &rhs)를 검사하는 것이 효율적이지 않을 수도 있습니다.

반면 copy-and-swap은 예외 안전성과 self-assignment 모두에 강하지만 처음 보는 사람에게는 이해가 어려울 수 있습니다.

즉, 안전성, 명확성, 성능 사이의 균형을 고려해 선택하는 것이 좋습니다.


핵심 요약

  • 대입 연산자에서 자기 자신에게 대입되는 경우를 반드시 고려하라.
  • 대표적인 방법은 주소 비교, 안전한 순서의 자원 처리, copy-and-swap이다.

감사합니다.

728x90