강께르의 개발일지

[C++] 복사 생성자 / 얕은 복사와 깊은 복사 본문

프로그래밍/C++

[C++] 복사 생성자 / 얕은 복사와 깊은 복사

강께르 2021. 6. 14. 10:34

1. C++에서 초기화 스타일과 복사 생성자

- C에서는 다음과 같은 방식을 주로 선호한다.

int num = 20;
int& ref = num;

- C++에서는 다음과 같은 방식도 허용한다.

int num(20);
int& ref(num);

- 이와 같은 방식이 허용될 때, 기본 자료형 말고 사용자가 정의한 클래스에도 가능하지 않을까? 예를 들어서 객체 생성 시 이미 생성된 같은 클래스의 객체를 대입한다는 것이다.

MyClass obj1();
MyClass obj2 = obj1;
MyClass obj3(obj1);		// 이런 유형이 가능하지 않을까?

- MyClass obj1(obj2)를 생성자 호출 관점으로 다시 보자.

 MyClass형 객체를 생성 - 그 객체의 이름은 obj1 - obj2를 인자로 받는 생성자 호출로 객체 생성 완료

- 다음과 같은 과정을 거칠 것이다. 그러면 이와 같은 과정에 필요한 생성자는 MyClass(MyClass& copy)가 될 것이다.

- 그리고 위의 초기화 스타일을 적용하여 MyClass obj1 = obj2;는 묵시적 변환을 해서 MyClass obj1(obj2)의 모양으로 객체 생성을 진행한다.

- 이것이 복사 생성자라고 불리며 이를 통해서 기대 되는 객체 내의 멤버 간의 복사를 얻을 수 있다.

- 만약 대입 연산자를 묵시적 변환을 통해 복사 생성자로 사용하고 싶지 않다면 복사 생성자를 정의할 때, 맨 앞에 explicit 키워드를 붙여주면 된다.

- 복사 생성자는 따로 정의하지 않으면, 컴파일러가 알아서 디폴트 복사 생성자를 삽입한다. 이 경우, 멤버 대 멤버 복사를 자동으로 지원한다. 그 코드를 묘사하자면 다음과 같다.

MyClass(const MyClass& copy) : this->val1(copy.val1), this->val2(copy.val2) { }

 

2. 얕은복사와 그 문제점

- 복사 생성자를 따로 정의하지 않으면 디폴트 복사 생성자가 자동으로 삽입된다는 것을 앞서 이야기했다. 그렇다면 프로그래머가 따로 복사 생성자를 만들 이유가 없지 않을까? 왜냐면 프로그래머가 복사 생성자에 기대하는 것은 객체의 모든 멤버 변수가 복사되어서 새로 만든 객체에 적용되는 것이기 때문에, 멤버 대 멤버 복사가 기본으로 갖고 있는 디폴트 복사 생성자만 있어도 충분하지 않냐는 것이다.

- 다음과 같은 상황을 예로 들어 보자.

class MyClass
{
public:
	MyClass();
	~MyClass();

private:
	int* val;
};

MyClass::MyClass() { val = new int[5]; }

MyClass::~MyClass() { delete[] val; }

int main(void)
{
	MyClass obj1;
	MyClass obj2(obj1);
}

- 이런 코드는 무리없이 돌아갈까? 겉으로 봐서는 문제 없어보인다. MyClass 클래스의 객체 obj1이 생성되어 그의 기본 생성자를 통해 obj1.val에는 동적할당으로 int형 공간 5개가 할당되었다.

- obj2도 문제 없이 생성된다. 디폴트 복사 생성자에 의해 this->val = copy.val;로 복사가 될 것이다. 주소값을 그대로 복사받아 obj2가 생성될 것이다.

- 그런데 이 이후가 문제다. 프로그램을 종료하기 전에 클래스에 정의된 소멸자에 의해 delete[] val;을 하려고 한다. 여기서 힙 공간에 대한 오류가 발생한다. 왜일까?

- 위의 그림은 위의 코드를 그림으로 나름 표현해봤다. obj2는 obj1의 val 값인 동적할당한 주소값을 복사해와서 저장했기에 위와 같은 그림이 될 것이다. 여기서 소멸자를 진행해보자. 소멸자는 생성자를 수행한 순서의 역순이다.

- obj2의 소멸자는 정상적으로 진행된다. 그에 따라 val이 가지고 있는 주소값에 의해 동적할당한 메모리를 해제한 것으로 보인다. 하지만 obj1도 같은 주소값을 가리키는 val를 가지고 있다. 그래서 obj1이 소멸자를 실행하면 그 주소는 이미 해제했기에 어떤 메모리 공간을 해제하는지 길을 잃어버린다.

- 그렇기에 포인터에 의한 동적할당과 같이 소멸자 호출 과정에서 문제가 있을 수있는, 위와 같은 멤버 대 멤버 복사 연산은 '얕은 복사'라고 칭한다. 그렇다면 이에 대한 해결 방법은 없는 것일까?

 

3. 깊은 복사

- 그에 대한 방안으로 '깊은 복사'가 있다. 복사를 진행할 때, 멤버 변수의 값만 그냥 대충 복사해오는 것이 아니라, 포인터로 참조하는 대상인 값까지 복사한다는 뜻이다.

- 아래의 코드를 예를 들어보자.

class MyClass
{
public:
	MyClass();
	MyClass(const MyClass&);
	~MyClass();
private:
	int* val;
};

MyClass::MyClass() { val = new int[5];}

MyClass::MyClass(const MyClass& copy)
{
	this->val = new int[5];
	for (int i = 0; i < 5; i++)
		this->val[i] = copy.val[i];
}

MyClass::~MyClass() { delete[] val; }

int main(void)
{
	MyClass obj1;
	MyClass obj2(obj1);
}

- 이 코드는 복사 생성자를 따로 정의한 것이다. 이 경우에는 오류가 발생하지 않을 것이다.

- 기존에 있던 디폴트 복사 생성자는 포인터의 주소값을 그대로 갖고 오기에 같은 메모리 공간을 공유하여 문제가 발생했지만, 이 경우엔 새로 생성되는 객체의 포인터 변수에 새로 동적 할당한 메모리 공간을 할당하고 반복문을 통해 매개변수로 받은 copy 객체의 val 값을 대입해주기에 결과적으로 다른 주소값을 가리켜 메모리 공간을 분리할 수 있다.