객체의 안쪽 부분을 캡슐화한 객체 지향 시스템 중 설계가 잘 된 것들을 보면, 객체를 복사하는 함수가 딱 둘만 있는 것을 알 수 있습니다. 복사 생성자와 복사 대입 연산자로, 이 둘을 통틀어 객체 복사 함수(copying function)라고 부릅니다. 객체 복사 함수는 컴파일러가 필요에 따라 만들어내기도 하고, 비록 저절로 만들어졌지만 복사되는 객체가 갖고 있는 데이터를 빠짐없이 복사합니다.
고객(customer)을 나타내는 클래스가 하나 있다고 가정합시다. 이 클래스의 복사 함수는 개발자가 직접 구현했고, 복사 함수(들)를 호출할 때마다 로그를 남기도록 작성되었습니다.
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 | 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 assignment operator"); name = rhs.name; // rhs의 데이터를 복사합니다. return *this; } | cs |
현재로선 문제될 것이 없지만, 데이터 멤버 하나를 Customer에 추가하면서 문제가 생깁니다.
1 2 3 4 5 6 7 8 9 10 | class Date { ... }; // 날짜 정보를 위한 클래스 class Customer { public: ... // 이전과 동일 private: std::string name; Date lastTransaction; } | cs |
이렇게 되고 나면, 복사 함수의 동작은 완전 복사가 아니라 부분 복사(partial copy)가 됩니다. 고객의 name은 복사하지만, lastTransaction은 복사하지 않습니다. 여기서 주의해야 할 점이 나오는데, 이런 상황에 대해 알려주는 컴파일러가 거의 없습니다. 결국 클래스에 데이터 멤버를 추가했으면, 추가한 데이터 멤버를 처리하도록 복사 함수를 다시 작성할 수밖에 없는 거죠(그뿐 아니라 생성자도 전부 갱신해야 할 것이고 비표준형 operator= 함수도 전부 바꿔 줘야 합니다).
부분 복사 문제는 특히 클래스 상속의 경우, 찾기 어려워집니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 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; } | cs |
PriorityCustomer 클래스의 복사 함수는 언뜻 보기엔 PriorityCustomer의 모든 것을 복사하고 있는 것처럼 보이지만, 문제가 있습니다. PriorityCustomer에 선언된 데이터 멤버를 모두 복사하고 있는 것은 사실이지만, Customer로부터 상속한 데이터 멤버들의 사본도 엄연히 PriorityCustomer 클래스에 들어 있는데, 이들은 복사가 안 되고 있습니다! PriorityCustomer 객체의 Customer 부분은 인자 없이 실행되는 Customer 생성자, 즉 기본 생성자에 의해 초기화됩니다(기본 생성자가 없으면 코드 컴파일도 안 됩니다). 하지만 PriorityCustomer의 복사 대입 연산자의 경우에는 기본 클래스의 데이터 멤버를 건드릴 시도도 하지 않기 때문에, 기본 클래스의 데이터 멤버는 변경되지 않고 그대로 있게 됩니다.
파생 클래스에 대한 복사 함수를 여러분 스스로 만든다고 결심했다면 기본 클래스 부분을 복사에서 빠뜨리지 않도록 주의하셔야 하겠습니다. 물론 기본 클래스 부분은 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; } | cs |
객체의 복사 함수를 작성할 때는 다음의 두 가지는 꼭 확인하라는 것입니다. 해당 클래스의 데이터 멤버를 모두 복사하고, 이 클래스가 상속한 기본 클래스의 복사 함수도 꼬박꼬박 호출해 주도록 합시다.
클래스의 양대 복사 함수(복사 생성자와 복사 대입 연산자)는 본문이 비슷하게 나오는 경우가 자주 있어서, 한쪽에서 다른 쪽을 호출하게 만들어서 코드 중복을 피하면 좋겠다고 생각할 수 있습니다. 하지만 이는 불가능합니다. 복사 대입 연산자에서 복사 생성자를 호출하는 것부터 말이 안 되는 발상입니다. 이미 만들어져 버젓이 존재하는 객체를 '생성'하고 있으니까요. 복사 생성자에서 복사 대입 연산자를 호출하는 것 또한 마찬가지입니다. 생성자의 역할은 새로 만들어진 객체를 초기화하는 것이지만, 대입 연산의 역할은 '이미' 초기화가 끝난 객체에게 값을 주는 것입니다. 그런데 생성 중인 객체에다가 대입이라니, 초기화된 객체에 대해서만 의미를 갖는 동작을 '아직 초기화도 안 된' 객체에 대해 한다는 것입니다. 따라서 둘 다 좋은 방법이 아닙니다.
대신 이런 방법은 생각해 볼 수 있습니다. 어쩌다 보니 복사 생성자와 복사 대입 연산자의 코드 본문이 비슷하게 나온다는 느낌이 들면, 양쪽에서 겹치는 부분을 별도의 멤버 함수에 분리해 놓은 후에 이 함수를 호출하게 만드는 것입니다. 대개 이런 용도의 함수는 private 멤버로 두는 경우가 많고, 이름이 init 어쩌고 하는 이름을 가집니다. 안전할 뿐만 아니라 검증된 방법이므로, 복사 생성자와 대입 연산자에 나타나는 코드 중복을 제거하는 방법으로 사용해 보시기 바랍니다.
객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다.
클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 마세요.
그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결합니다.