[Effective C++ 정리 #11] operator= 시 자기 자신 대입 유의!
이 글은 『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
이다.
감사합니다.