개요
C++ 프로그래밍에서 객체 복사는 매우 흔한 연산이지만, 올바르게 구현하지 않으면 심각한 버그의 원인이 될 수 있습니다. Effective C++의 12번째 항목은 이 중요한 주제를 다룹니다. 본 글에서는 객체의 모든 부분을 빠짐없이 복사해야 하는 이유와 그 방법에 대해 알아보겠습니다. 특히 복사 생성자와 복사 대입 연산자의 올바른 구현 방법, 그리고 상속 관계에서의 주의사항을 중점적으로 살펴볼 것입니다.
본문
객체 복사의 기본 원칙
C++에서 객체 복사는 주로 복사 생성자와 복사 대입 연산자를 통해 이루어집니다. 이 두 특수 멤버 함수를 구현할 때는 다음 원칙을 반드시 지켜야 합니다: 객체의 모든 데이터 멤버와 모든 기본 클래스 부분을 빠짐없이 복사해야 합니다.
이 원칙을 지키지 않으면 객체의 일부만 복사되는 '부분 복사' 문제가 발생할 수 있습니다. 이는 예기치 않은 프로그램 동작을 유발하며, 디버깅하기 매우 어려운 버그의 원인이 됩니다.
복사 함수의 올바른 구현
다음은 Customer 클래스의 복사 함수를 올바르게 구현한 예시입니다:
class Customer {
public:
Customer(const Customer& rhs)
: name(rhs.name) { // 이니셜라이저 리스트를 사용한 멤버 초기화
logCall("Customer copy constructor");
}
Customer& operator=(const Customer& rhs) {
logCall("Customer copy assignment operator");
name = rhs.name; // 모든 데이터 멤버를 빠짐없이 복사
return *this; // 자기 자신의 참조를 반환
}
private:
std::string name;
};
C++
복사
여기서 주목할 점은 다음과 같습니다:
1.
복사 생성자에서는 이니셜라이저 리스트를 사용하여 멤버를 초기화합니다.
2.
복사 대입 연산자에서는 모든 데이터 멤버를 명시적으로 복사합니다.
3.
복사 대입 연산자는 자기 자신의 참조를 반환합니다. 이는 a = b = c;와 같은 연쇄 대입을 가능하게 합니다.
상속 관계에서의 복사 연산
상속 관계에 있는 클래스의 경우, 파생 클래스의 복사 함수에서 기본 클래스의 복사 함수를 명시적으로 호출해야 합니다. 다음은 Customer를 상속받는 PriorityCustomer 클래스의 예시입니다:
class PriorityCustomer : public Customer {
public:
PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), // 기본 클래스의 복사 생성자 호출
priority(rhs.priority) {
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer& operator=(const PriorityCustomer& rhs) {
logCall("PriorityCustomer copy assignment operator");
Customer::operator=(rhs); // 기본 클래스의 복사 대입 연산자 호출
priority = rhs.priority;
return *this;
}
private:
int priority;
};
C++
복사
이 예시에서 중요한 점은 다음과 같습니다:
1.
복사 생성자의 이니셜라이저 리스트에서 기본 클래스의 복사 생성자를 명시적으로 호출합니다.
2.
복사 대입 연산자에서는 기본 클래스의 복사 대입 연산자를 먼저 호출한 후, 자신의 멤버를 복사합니다.
얕은 복사와 깊은 복사
객체가 동적으로 할당된 메모리를 가리키는 포인터를 멤버로 가지고 있다면, 단순히 포인터 값만 복사하는 얕은 복사(shallow copy)로는 충분하지 않을 수 있습니다. 이 경우 포인터가 가리키는 실제 데이터까지 복사하는 깊은 복사(deep copy)를 구현해야 합니다.
class DeepCopyExample {
public:
DeepCopyExample(const DeepCopyExample& rhs)
: data(new int(*rhs.data)) {} // 깊은 복사
DeepCopyExample& operator=(const DeepCopyExample& rhs) {
auto tmp = new int(*rhs.data); // 복사 후 대입(copy and swap) 관용구
std::swap(data, tmp);
delete tmp;
return *this;
}
~DeepCopyExample() { delete data; }
private:
int* data;
};
C++
복사
이 예시에서는 복사 시 새로운 메모리를 할당하고 데이터를 복사하여 깊은 복사를 구현합니다.
개인적 견해
객체의 완전한 복사는 C++뿐만 아니라 모든 객체 지향 언어에서 중요한 개념입니다. 특히 복잡한 데이터 구조나 상태를 가진 객체를 다룰 때, 불완전한 복사로 인한 버그는 추적하기 어렵고 심각한 문제를 야기할 수 있습니다. C++에서는 복사 생성자와 복사 대입 연산자를 통해 이를 더욱 세밀하게 제어할 수 있어, 개발자에게 큰 유연성을 제공합니다.
그러나 이러한 세밀한 제어 능력은 양날의 검이 될 수 있습니다. 복사 연산을 구현할 때 실수로 일부 멤버를 누락하거나, 자기 대입 문제를 고려하지 않는 등의 오류를 범하기 쉽습니다. 따라서 C++ 개발자는 이러한 복사 연산의 구현에 특별한 주의를 기울여야 하며, 가능한 경우 컴파일러가 제공하는 기본 구현을 활용하는 것도 좋은 방법일 수 있습니다.
결론
객체의 모든 부분을 빠짐없이 복사하는 것은 C++ 프로그래밍에서 핵심적인 원칙입니다. 복사 생성자와 복사 대입 연산자를 올바르게 구현함으로써 예측 가능하고 안정적인 프로그램을 작성할 수 있습니다. 이는 단순히 버그를 예방하는 것을 넘어, 코드의 의도를 명확히 표현하고 유지보수성을 높이는 중요한 실천 방법입니다.