C++에서 클래스에 포인터 멤버 변수가 없다면, 컴파일러가 자동으로 생성해 주는 기본 복사 생성자와 대입 연산자만으로도 충분합니다. 하지만 클래스가 포인터를 멤버로 가지고 있다면 이야기가 달라집니다. 기본 대입 연산자는 얕은 복사(shallow copy)를 수행하기 때문에, 여러 객체가 같은 메모리 주소를 공유하게 되어 예기치 않은 동작이나 문제를 일으킬 수 있습니다. 이럴 때는 사용자 정의 대입 연산자를 직접 작성해야 합니다.
문제 상황: 기본 대입 연산자의 얕은 복사
아래 예제는 포인터 멤버를 가진 클래스이지만, 사용자 정의 대입 연산자와 복사 생성자가 없는 경우입니다.
#include<iostream>
using namespace std;
class MyClass { // 사용자 정의 대입 연산자 및 복사 생성자 없음
int *ptr;
public:
MyClass (int x = 0) {
ptr = new int(x);
}
void setValue (int x) {
*ptr = x;
}
void print() {
cout << *ptr << endl;
}
};
main() {
MyClass ob1(50);
MyClass ob2;
ob2 = ob1;
ob1.setValue(100);
ob2.print();
}출력 결과
100
main() 함수에서 ob1의 값을 setValue() 메서드로 100으로 변경했을 뿐인데, ob2의 출력 결과까지 100으로 바뀌었습니다. 그 이유는 컴파일러가 자동으로 만든 기본 대입 연산자가 RHS(오른쪽 피연산자)의 ptr 값을 LHS(왼쪽 피연산자)에 그대로 복사하기 때문입니다. 결과적으로 두 객체의 포인터가 동일한 메모리 위치를 가리키게 되고, 한쪽의 변경이 다른 쪽에도 영향을 미치는 문제가 발생합니다.
해결 방법
이 문제를 해결하는 방법은 크게 두 가지입니다.
- 복사 제한: private 접근 지정자로 더미(dummy) 대입 연산자를 선언하여 객체 복사 자체를 금지합니다.
- 깊은 복사(deep copy): 사용자 정의 대입 연산자를 작성하여 포인터가 가리키는 실제 값을 새로운 메모리에 복사합니다.
아래는 깊은 복사를 수행하도록 대입 연산자를 직접 구현한 예제입니다.
#include<iostream>
using namespace std;
class MyClass { // 사용자 정의 대입 연산자 추가
int *ptr;
public:
MyClass (int x = 0) {
ptr = new int(x);
}
void setValue (int x) {
*ptr = x;
}
void print() {
cout << *ptr << endl;
}
MyClass &operator=(const MyClass &ob2){
// 자기 자신에 대한 대입(self assignment) 검사
if(this != &ob2)
*ptr = *(ob2.ptr);
return *this;
}
};
main() {
MyClass ob1(50);
MyClass ob2;
ob2 = ob1;
ob1.setValue(100);
ob2.print();
}출력 결과
50
이번에는 대입 연산자가 깊은 복사를 수행하여 각 객체가 서로 독립적인 포인터를 가지게 됩니다. 따라서 ob1의 값이 변경되어도 ob2에는 영향을 주지 않으며, 원래 값인 50이 그대로 출력됩니다.
주의할 점: 자기 대입(self assignment) 검사
대입 연산자를 작성할 때 반드시 this != &ob2 조건으로 자기 자신에 대한 대입인지 확인해야 합니다. 이 검사가 없으면 obj = obj; 와 같은 코드에서 현재 객체의 값이 의도치 않게 변경되거나 손상될 수 있습니다.
정리
클래스가 동적 메모리를 가리키는 포인터 멤버를 포함하고 있다면, 컴파일러가 제공하는 기본 대입 연산자는 얕은 복사로 인해 심각한 버그를 유발할 수 있습니다. 이 경우 깊은 복사를 수행하는 사용자 정의 대입 연산자를 작성하거나, 아예 복사를 금지하는 것이 안전합니다. 또한 소멸자와 복사 생성자도 함께 정의하는 것이 좋습니다. 이는 흔히 '삼의 법칙(Rule of Three)'이라고 불리는 C++의 중요한 원칙입니다.