항목 12 : 객체의 모든 부분을 빠짐없이 복사하자
객체의 안쪽 부분만 캡슐화한 객체 지향 시스템 중 설계가 잘 된 것들을 보면, 객체를 복사하는 함수가 딱 둘만 있는 것을 알 수 있습니다. 복사 생성자와 복사 대입 연산자라고, 성격에 따라 이름도 적절히 지어져 있습니다. 이 둘을 통틀어 객체 복사 함수라고 부릅니다. 객체 복사 함수는 컴파일러가 필요에 따라 만들어내기도 합니다. 그리고 컴파일러가 생성한 복사 함수는 비록 저절로 만들어졌지만 동작은 기본적인 요구에 아주 충실합니다. 복사되는 객체가 갖고 있는 데이터를 빠짐없이 복사한다라는 것입니다.
객체 복사 함수를 여러분인 선언한다는 것은, 컴파일러가 만든 녀석의 기본 동작에 뭔가 마음에 안 드는 것이 있다는 이야기입니다. 이에 대해 컴파일러도 썩 반기는 분위기는 아니라는 듯, 꽤나 까칠한 자세로 여러분을 골탕 먹이려고 합니다. 어떻게 하는고 하니, 여러분이 구현한 복사 함수가 거의 확실히 틀렸을 경우에도 입을 다물어 버립니다.
고객을 나타내는 클래스가 하나 있다고 가정합시다. 이 클래스의 복사 함수는 개발자가 직접 구현했고, 복사 함수(들)를 호출할 때마다 로그를 남기도록 작성되었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
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) // rhs의 데이터를 복사합니다.
{
logCall("Customer copy constructor");
}
Customer& Customer::operator=(const Customer& rhs)
{
logCall("Customer copy assignment operator");
name = rhs.name; // rhs의 데이터를 복사합니다.
return *this;
}
Colored by Color Scripter
|
문제될 것이 하나도 없어 보입니다. 실제로 그렇고요. 그런데 데이터 멤버 하나를 Customer에 추가하면서 행복에 금이 가기 시작합니다.
1
2
3
4
5
6
7
8
9
10
|
class Date { ... }; // 날짜 정보를
class Customer {
public :
...
private :
std::string name;
Date lastTransaction;
};
Colored by Color Scripter
|
이렇게 되면, 복사 함수의 동작은 완전 복사가 아니라 부분 복사가 됩니다. 고객의 name은 복사하지만, lastTransaction은 복사하지 않습니다.
결국 우리가 할 일은 한 가지 입니다. 클래스에 데이터 멤버를 추가했으면, 추가한 데이터 멤버를 처리하도록 복사 함수를 다시 작성할 수밖에 없습니다.
이 문제가 가장 사악하게 프로그래머를 괴롭히는 경우가 하나 있는데, 바로 클래스 상속입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
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;
}
Colored by Color Scripter
|
PriorityCustomer 클래스의 복사 함수는 언뜻 보기엔 PriorityCustomer의 모든것을 복사하고 있는 것처럼 보이지만, Customer로부터 상속한 데이터 멤버들의 사본도 엄연히 PriorityCustomer 클래스에 들어 있는데, 이들은 복사가 안 되고 있습니다!
PrioirtyCustomer의 복사 생성자에는 기본 클래스 생성자에 넘길 인자들도 명시되어 있지 않아서 PriorityCustomer 객체의 Customer 부분은 인자 없이 실행되는 Customer 생성자, 즉 기본 생성자에 의해 초기화됩니다. 이 생성자는 당연히 name 및 lastTransaction에 대해 '기본적인' 초기화를 해 줄 것입니다.
PrioirtyCustomer의 복사 대입 연산자의 경우에는 사정이 다소 다릅니다. 복사 대입 연산자는 기본 클래스의 데이터 멤버를 건드릴 시도도 하지 않기 때문에, 기본 클래스의 데이터 멤버는 변경되지 않고 그대로 있게 됩니다.
파생 클래스에 대한 복사 함수를 스스로 만든다고 결심했다면 기본 클래스 부분을 복사에서 빠뜨리지 않고록 각별히 주의 해야합니다. 물론 기본 클래스 부분을 private 멤버일 가능성이 아주 높기 때문에, 이들을 직접 건드리긴 어렵습니다.
그 대신, 파생 클래스의 복사 함수 안에서 기본 클래스의 복사 함수를 호출하도록 만들면 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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;
}
Colored by Color Scripter
|
이번 항목의 제목으로 나온 "모든 부분을 복사하자"라는 말의 말귀를 이제 알 수 있을 것입니다.
객체의 복사 함수를 작성할 때는 다음의 두 가지를 꼭 확인하라는 것입니다.
1. 해당 클래스의 데이터 멤버를 모두 복사
2. 이 클래스가 상속한 기본 클래스의 복사 함수를 호출
사실, 클래스의 양대 복사 함수(복사 생성자와 복사 대입 연산자)는 본문이 비슷하게 나오는 경우가 자주 있어서, 한쪽에서 다른 쪽을 호출하게 만들어서 코드 판박이를 피하려고 하는 사람들이 있을 수 있습니다.
하지만 복사 대입 연산자에서 복사 생성자를 호출하는 것부터 말이 안 되는 발상입니다. 이미 존재하는 객체를 '생성'하려고 하는 것이니까요.
그렇다면 복사 생성자에서 복사 대입 연산자를 호출하는 것은 어떨까요?
이 또한 말이 안됩니다. 생성자의 역할은 새로 만들어진 객체를 초기화하는 것이지마느 대입 연산자의 역할은 '이미' 초기화가 끝난 갹체에게 값을 주는 것입니다. 초기화된 객체에만 적용된다는 이야기죠 그런데 생성 중인 객체에다가 대입이라니, 초기화된 객체에 대해서만 의미를 갖는 동작을 '아직 초기화도 안 된' 객체에 대해 한다는 것입니다.
대신에 이런 방법은 생각해 볼 수 있습니다. 복사 생성자와 복사 대입 연산자의 코드 본문이 비슷하게 나온다는 느낌이 들면, 양쪽에서 겹치는 부분을 별도의 멤버 함수에 분리해 놓은 후에 이 함수를 호출하게 만드는 것입니다. 대개 이런 용도의 함수는 private 멤버로 두는 경우가 많고, 이름이 init~하는 이름을 가집니다. 안전할 뿐만 아니라 검증된 방법이므로, 복사 생성자와 복사 대입 연산자에 나타나는 코드 중복을 제거하는 방법으로 사용해 보시기 바랍니다.
꼭 잊지 말아야 할 것!
1. 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다.
2. 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 마세요. 그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결합시다.
'언어 > C++' 카테고리의 다른 글
[Effective C++] 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자 (0) | 2020.01.14 |
---|---|
[Effective C++] 자원 관리에는 객체가 그만! (0) | 2020.01.13 |
[Effective C++] operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자 (0) | 2020.01.11 |
[Effective C++] 대입 연산자는 *this의 참조자를 반환하게 하자 (0) | 2020.01.10 |
[Effective C++] 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자 (0) | 2020.01.09 |