이 글은 『Effective C++』를 읽고 개인적으로 공부한 내용을 정리한 기록입니다.
저는 컴퓨터공학을 전공하지 않았으며, 프로그래밍을 공부하는 과정에서의 이해와 생각을 정리하기 위해 글을 작성하고 있습니다.
따라서 내용 중 일부에 오류나 부정확한 설명이 있을 수 있으며, 피드백은 언제든지 환영합니다. 확인 후 수정하도록 하겠습니다.
전문적인 해설이 아닌 개인적 시선에서의 정리임을 참고하고 읽어주시면 감사하겠습니다.
[Effective C++ 정리 #12] 복사 생성자와 복사 대입 연산자는 객체의 모든 부분을 복사해야 한다
객체를 복사할 때 우리는 쉽게 "이 멤버만 복사하면 되겠지"라고 생각하기 쉽습니다. 그러나 잘못 구현된 복사 생성자나 대입 연산자는 부분 복사(partial copy)로 이어지고, 그 결과 프로그램이 의도하지 않은 잘못된 상태에 빠질 수 있습니다.
이번 item 12에서는 복사 생성자와 복사 대입 연산자를 작성할 때 반드시 지켜야 할 "객체의 모든 부분을 복사하라"는 핵심 원칙을 자세히 살펴봅니다.
컴파일러가 생성하는 복사 함수는 모든 것을 복사
C++에서 복사 생성자(copy constructor)와 복사 대입 연산자(copy assignment operator)는 객체를 복사할 때 호출됩니다.
명시적으로 작성하지 않으면 컴파일러가 자동으로 생성해 주는데, 이 자동 생성된 복사 함수들은 **객체의 모든 데이터 멤버와 기반 클래스 부분까지 빠짐없이 복사**합니다.
예를 들어 다음처럼 작성했다면:
class Customer {
public:
...
private:
std::string name;
};
컴파일러가 만들어주는 복사 생성자와 복사 대입 연산자는 name 멤버를 제대로 복사해줍니다.
문제는 **우리가 복사 함수를 직접 작성하기 시작할 때부터** 시작됩니다.
복사 함수를 직접 작성 시
아래처럼 로그를 남기기 위해 복사 생성자와 대입 연산자를 직접 작성했다고 가정해 봅시다.
void logCall(const std::string& funcName);
class Customer {
public:
Customer(const Customer& rhs);
Customer& operator=(const Customer& rhs);
private:
std::string name;
};
// 복사 생성자
Customer::Customer(const Customer& rhs)
: name(rhs.name)
{
logCall("Customer copy constructor");
}
// 복사 대입 연산자
Customer& Customer::operator=(const Customer& rhs)
{
logCall("Customer copy assignment operator");
name = rhs.name;
return *this;
}
현재까지는 문제가 없습니다. name이라는 유일한 데이터 멤버를 잘 복사하고 있습니다.
하지만 나중에 다음처럼 새로운 멤버가 추가되면 문제가 발생합니다.
class Date { ... };
class Customer {
public:
Customer(const Customer& rhs);
Customer& operator=(const Customer& rhs);
private:
std::string name;
Date lastTransaction;
};
이제 lastTransaction 멤버가 추가되었지만, 기존의 복사 생성자와 대입 연산자는 여전히 name만 복사합니다. 결국 **부분 복사만 이루어지고 있으며, 이로 인해 객체의 일관성이 깨집니다**.
문제는 이 상태에서도 컴파일러가 아무 경고도 주지 않는다는 것입니다. 복사 함수를 직접 작성했기 때문에, 이제 **복사의 책임은 전적으로 프로그래머에게 넘어온** 것이죠.
상속에서 발생하는 부분 복사 문제
상속 관계가 있다면 상황은 더욱 복잡해집니다.
다음은 Customer를 상속한 PriorityCustomer 클래스입니다:
class PriorityCustomer : public Customer {
public:
PriorityCustomer(const PriorityCustomer& rhs);
PriorityCustomer& operator=(const PriorityCustomer& rhs);
private:
int priority;
};
// 복사 생성자
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
// 복사 대입 연산자
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
priority = rhs.priority;
return *this;
}
표면적으로는 priority만 잘 복사하면 충분해 보이지만, 사실 상속받은 Customer의 데이터(name, lastTransaction)는 전혀 복사되지 않고 있습니다.
이런 상황이 발생하는 이유는 다음과 같습니다:
- 복사 생성자에서 기반 클래스의 복사 생성자를 호출하지 않으면, 기반 클래스는 default constructor로 초기화됨.
- 복사 대입 연산자에서 기반 클래스의 대입 연산자를 호출하지 않으면, 기반 클래스 멤버는 변하지 않음.
결국 복사된 PriorityCustomer 객체는 priority만 복사되었을 뿐, 상속된 Customer 부분은 초기화되지 않거나 이전 상태가 남아 있습니다.
상속된 클래스의 복사 함수는 반드시 기반 클래스 복사 함수를 호출해야 함
정상적으로 작성하려면 다음과 같이 해야 합니다:
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), // 기반 클래스 복사 생성자 호출
priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
Customer::operator=(rhs); // 기반 클래스 대입 연산자 호출
priority = rhs.priority;
return *this;
}
이렇게 해야 **객체의 모든 부분이 올바르게 복사**됩니다.
기반 클래스의 데이터 멤버는 보통 private이므로 직접 접근할 수 없고, 반드시 기반 클래스의 복사 함수를 호출해야 합니다.
복사 생성자와 대입 연산자의 코드 중복을 줄이고 싶을 때
복사 생성자와 복사 대입 연산자는 거의 비슷한 일을 합니다. 이걸 보고 둘 중 하나가 다른 하나를 호출하도록 구현하는 경우가 종종 시도됩니다.
하지만 이것은 절대 해서는 안 되는 설계입니다.
- 복사 생성자가 복사 대입 연산자를 호출 → 생성도 되기 전인데 대입을 하려는 모순
- 복사 대입 연산자가 복사 생성자를 호출 → 이미 생성된 객체인데 새로 생성하려는 모순
이런 시도는 객체 생명주기를 혼란스럽게 만들고, 경우에 따라 심각한 버그를 초래합니다.
대신 공통 코드를 별도의 private 멤버 함수로 분리하는 것이 좋은 방법입니다.
class Customer {
public:
Customer(const Customer& rhs) { init(rhs); }
Customer& operator=(const Customer& rhs) {
if (this != &rhs) init(rhs);
return *this;
}
private:
void init(const Customer& rhs) {
name = rhs.name;
lastTransaction = rhs.lastTransaction;
}
std::string name;
Date lastTransaction;
};
이렇게 하면 중복은 제거되면서도, 복사 생성자와 대입 연산자의 **객체 생명주기 차이점**은 유지됩니다.
핵심 요약
- 복사 생성자와 복사 대입 연산자는 객체의 모든 부분을 복사해야 한다.
- 복사 생성자와 대입 연산자가 서로 호출하도록 설계하지 마라.
- 공통 코드는 별도의 private 함수로 추출하여 중복을 제거하라.
감사합니다.
'프로그래밍 > C\C++' 카테고리의 다른 글
| [Effective C++ 정리 #14] 리소스 관리 클래스 복사 시 유의사항 (6) | 2025.06.24 |
|---|---|
| [Effective C++ 정리 #13] RAII란 무엇인가? RAII로 자원 관리 방법 (2) | 2025.06.23 |
| C/C++ 얕은 복사 및 깊은 복사 내용 정리 (2) | 2025.06.21 |
| [Effective C++ 정리 #11] operator= 시 자기 자신 대입 유의! (0) | 2025.06.21 |
| [Effective C++ 정리 #10] 대입 연산자의 return *this의 의미?! (4) | 2025.06.20 |