이 글에서는 C++에서 복사 생성자(copy constructor)를 어떻게 구현하고 활용하는지 자세히 알아보겠습니다. 본격적인 구현 방법에 앞서, 복사 생성자가 정확히 무엇인지부터 짚고 넘어가겠습니다.
복사 생성자란?
복사 생성자(copy constructor)는 같은 클래스의 기존 객체를 이용해 새로운 객체를 초기화하면서 생성하는 특수한 생성자입니다. 복사 생성자는 주로 다음과 같은 상황에서 호출됩니다.
- 같은 타입의 다른 객체로부터 새 객체를 초기화할 때
- 함수의 인자로 객체를 전달하기 위해 복사할 때
- 함수의 반환값으로 객체를 전달하기 위해 복사할 때
클래스 안에 복사 생성자가 따로 정의되어 있지 않으면, 컴파일러가 기본 복사 생성자를 자동으로 만들어 줍니다. 하지만 클래스에 포인터 변수가 있거나 동적 메모리 할당이 이루어진다면 이야기가 달라집니다. 기본 복사 생성자는 단순히 포인터의 '주소'만 복사하는 얕은 복사(shallow copy)를 수행하기 때문에, 두 객체가 같은 메모리를 공유하게 되어 이중 해제(double free)나 댕글링 포인터 같은 심각한 오류를 일으킬 수 있습니다. 따라서 이런 경우에는 반드시 깊은 복사(deep copy)를 수행하는 복사 생성자를 직접 작성해야 합니다.
가장 일반적인 복사 생성자의 형태는 다음과 같습니다.
classname(const classname &obj) {
// 생성자 본문
}
여기서 obj는 새 객체를 초기화하는 데 사용되는 원본 객체에 대한 참조(reference)입니다. 매개변수를 참조 타입으로 선언하는 이유는, 값으로 전달할 경우 복사 과정에서 다시 복사 생성자가 호출되어 무한 재귀에 빠질 수 있기 때문입니다. 또한 const 키워드를 붙이면 원본 객체가 실수로 변경되지 않도록 보장할 수 있습니다.
예제 1: 함수 인자로 객체를 전달할 때
아래 예제는 객체를 함수의 인자로 값(value) 형태로 전달하는 상황으로, 이때 복사 생성자가 호출됩니다.
#include <iostream>
using namespace std;
class Line {
public:
int getLength( void );
Line( int len ); // 일반 생성자
Line( const Line &obj ); // 복사 생성자
~Line(); // 소멸자
private:
int *ptr;
};
// 생성자를 포함한 멤버 함수 정의
Line::Line(int len) {
cout << "Normal constructor allocating ptr" << endl;
// 포인터를 위한 메모리 할당
ptr = new int;
*ptr = len;
}
Line::Line(const Line &obj) {
cout << "Copy constructor allocating ptr." << endl;
ptr = new int;
*ptr = *obj.ptr; // 값 복사
}
Line::~Line(void) {
cout << "Freeing memory!" << endl;
delete ptr;
}
int Line::getLength( void ) {
return *ptr;
}
void display(Line obj) {
cout << "Length of line : " << obj.getLength() << endl;
}
// 프로그램의 main 함수
int main() {
Line line(10);
display(line);
return 0;
}
실행 결과
Normal constructor allocating ptr Copy constructor allocating ptr. Length of line : 10 Freeing memory! Freeing memory!
실행 순서를 살펴보면 다음과 같습니다. 먼저 Line line(10)이 일반 생성자를 호출하고, display(line)에서 객체가 값으로 전달되면서 복사 생성자가 한 번 실행됩니다. 마지막에는 원본 객체와 복사된 객체 각각에 대해 소멸자가 호출되어 메모리가 두 번 해제됩니다. 중요한 점은 복사 생성자 내부에서 new로 새로운 메모리를 할당했기 때문에, 두 객체의 ptr이 서로 다른 메모리 공간을 가리킨다는 것입니다.
예제 2: 기존 객체로 새 객체를 생성할 때
이번에는 같은 예제에서 main 함수만 약간 바꿔, 이미 존재하는 객체를 이용해 같은 타입의 새 객체를 생성해 보겠습니다.
#include <iostream>
using namespace std;
class Line {
public:
int getLength( void );
Line( int len ); // 일반 생성자
Line( const Line &obj ); // 복사 생성자
~Line(); // 소멸자
private:
int *ptr;
};
// 생성자를 포함한 멤버 함수 정의
Line::Line(int len) {
cout << "Normal constructor allocating ptr" << endl;
// 포인터를 위한 메모리 할당
ptr = new int;
*ptr = len;
}
Line::Line(const Line &obj) {
cout << "Copy constructor allocating ptr." << endl;
ptr = new int;
*ptr = *obj.ptr; // 값 복사
}
Line::~Line(void) {
cout << "Freeing memory!" << endl;
delete ptr;
}
int Line::getLength( void ) {
return *ptr;
}
void display(Line obj) {
cout << "Length of line : " << obj.getLength() << endl;
}
// 프로그램의 main 함수
int main() {
Line line1(10);
Line line2 = line1; // 이 역시 복사 생성자를 호출합니다
display(line1);
display(line2);
return 0;
}
실행 결과
Normal constructor allocating ptr Copy constructor allocating ptr. Copy constructor allocating ptr. Length of line : 10 Freeing memory! Copy constructor allocating ptr. Length of line : 10 Freeing memory! Freeing memory! Freeing memory!
출력 결과를 보면 복사 생성자가 총 세 번 호출된 것을 확인할 수 있습니다.
Line line2 = line1;— 기존 객체로 새 객체를 초기화하면서 첫 번째 복사 생성자가 호출됩니다.display(line1)— 객체가 값으로 전달되면서 두 번째 복사 생성자가 호출됩니다.display(line2)— 마찬가지로 값 전달 시점에 세 번째 복사 생성자가 호출됩니다.
프로그램이 종료될 때는 생성된 네 개의 객체(원본 1개 + 복사본 3개) 각각에 대해 소멸자가 호출되므로 "Freeing memory!"가 네 번 출력됩니다.
정리
복사 생성자는 같은 클래스의 객체로부터 새 객체를 만들 때, 객체가 함수에 값으로 전달될 때, 그리고 함수에서 객체가 값으로 반환될 때 호출됩니다. 포인터 멤버나 동적 메모리 할당이 있는 클래스라면 컴파일러가 제공하는 기본 복사 생성자 대신, 메모리를 새로 할당하고 값을 복사하는 깊은 복사 방식의 복사 생성자를 직접 구현해야 안전하게 객체를 관리할 수 있습니다.