2015. 2. 2.

[C++]복사 생성자(Copy Constructor), 깊은 복사(Deep Copy)와 얕은 복사(Shallow Copy)

 생성자에 대한 지식을 바탕으로 이번엔 복사 생성자에 대해 알아보고자 한다. 복사 생성자는 우리가 그냥 지나치면서 호출되는 경우가 많다. 복사 생성자가 언제 호출되고, 어떻게 적용되는지에 대한 이해하면 프로그램의 성능을 향상시킬 수 있다. 무엇보다 복사 생성자에 대한 이해는 깊은 복사(deep copy)와 얕은 복사(shallow copy)를 이해하는 기초이다.


복사 생성자(copy constructor)

 복사 생성자를 설명하기에 앞서서 우리가 자주 하는 변수 초기화에 대해서 알아보자. 변수 초기화에서 생성자에 대한 이해를 돕는 힌트를 얻을 수 있다.
 1번 방법은 우리가 일반적으로 선언 및 초기화하는 방법이다. C++에서는 2번 방법으로 초기화할 수도 있다. 전에 설명한 생성자를 활용한 초기화 방법과 닮아있다는 생각이 들 것이다. 정확하게 생성자를 통한 방법인지는 나도 잘 모르겠다. 여튼 생성자를 통한 초기화를 할 때 인자를 전달하는 것과 형태적으로 유사하다는 점에 주목하자. 그리고 우리는 이미 이러한 형태를 멤버 이니셜라이저를 사용하면서 접해본 경험도 있다. Point(const int &x1, const int &y1) : x(x1), y(y1) {}에서 x(x1)은 x를 x1으로 초기화하라는 의미였다. 이제 객체를 대상으로 살펴보자.
 sim1 객체를 생성하면서 멤버 이니셜라이저로 초기화하고, 이를 sim2에 대입하고 있다. Simple sim2 = sim1; 이라는 문장이 내부적으로 어떻게 동작하는지 살펴보자. 먼저 sim2 객체를 생성한 다음 멤버 변수를 복사하면 이 과정은 완료된다. 결국 대입하는 과정을 새롭게 표현해보면 다음과 같다.
 실제로 객체를 복사하면서 생성할 때는 Simpe sim2 = sim1;이 Simple sim2(sim1)로 묵시적으로 변환되어서 객체가 생성된다고 한다. 그런데 이상한 점은 우리는 Simple 클래스 생성자에 Simple 객체를 인자로 받는 생성자를 선언해두지 않았다. 이것은 어떻게 된 일인가? 디폴트 생성자처럼 이런 경우를 위한 생성자가 준비되어 있다. 위의 코드처럼 객체 복사가 필요한 경우, 복사 생성자를 따로 정의하지 않았을 때는 디폴트 복사 생성자가 코드에 자동으로 삽입된다. 그럼 자동으로 삽입되는 복사 생성자를 간단하게 구현해보자.
 간단하게 구현할 수 있다. 멤버 이니셜라이저를 통해서 멤버 변수를 초기화하면 끝이다. 그런데 디폴트 복사 생성자가 자동으로 삽입이 되는데, 이렇게 복사 생성자를 일부러 만들어 줄 필요가 있을까? 분명히 우리가 따로 복사 생성자를 만들어 줄 필요가 있다. 이는 깊은 복사와 얕은 복사 문제와 관련있다.


디폴트 복사 생성자의 문제점

 자동으로 만들어 주는 디폴트 복사 생성자가 그 어떤 경우에도 문제없이 동작하면 좋겠지만 현실은 그렇지 않다. 디폴트 복사 생성자가 문제를 일으키기도 한다. 그럼 앞에서 알아본 복사 생성자에 대한 지식을 바탕으로 그 문제를 일으키는 상황에 대해서 알아보자.
 p1 객체를 생성한 후, 복사 생성자를 통해 p2 객체를 생성한다. 복사 생성자를 선언하지 않았으므로 디폴트 복사 생성자가 삽입되어서 호출된다. 디폴트 복사 생성자는 앞에서 본 것처럼 멤버 변수를 값 복사하는 방식으로 이루어 진다. 위의 코드는 실행하는 과정에는 문제없이 작동한다. 하지만 프로그램을 종료할 때 문제가 발생한다. 어떤 문제가 발생할 지 예상할 수 있겠는가?

 의심이 되는 부분을 먼저 살펴보자. 생성자 부분에서 문자 배열이 동적 할당되어 있다. 소멸자를 설명하면서 생성자에서 동적 할당한 메모리는 소멸자에서 메모리 해제를 하지 않으면 메모리 누수가 발생한다고 하였다. 그렇다면 소멸자를 살펴볼 필요가 있다. 소멸자에서 문자 배열에 대한 메모리 해제가 이뤄지고 있다. 그렇다면 메모리 해제 문제도 아니다.

 문제는 복사 생성 과정에서 발생한다.


얕은 복사(shallow copy)

 디폴트 복사 생성자를 한 번 직접 구현해보도록 하자. 참고로 디폴트 복사 생성자는 단순한 멤버 대 멤버 복사만 이뤄진다.
 디폴트 복사 생성자도 위와 같이 멤버 간 단순 복사만 이뤄진다. 이제 어떤 부분이 잘못되었는지 보이는가? 문제는 바로 문자 배열을 동적 할당한 포인터를 복사한다는 점이다. p1 객체의 문자 배열은 동적 할당되어 있다. 디폴트 복사 생성자를 통해 동적 할당된 공간을 복사하는 것이 아니라 동적 할당된 영역을 가리키는 포인터를 그대로 복사한 것이다. 결국 p1 객체의 문자 배열이 동적 할당된 메모리 영역은 하나이지만, p1의 문자 배열 포인터와 p2의 문자 배열 포인터 둘이 하나의 메모리 영역을 가리키고 있는 상태이다. 여기까지는 문제가 되지 않는다.

 객체를 소멸시킬 경우를 생각해보자. p1이 먼저 소멸되었다고 가정하자. p1이 소멸되면서 소멸자를 통해 문자 배열의 메모리 공간이 해제된다. 그리고 p1 객체는 소멸된다. 이제 p2 객체를 소멸시킬 차례이다. p2 객체의 소멸자가 호출되고, 문자 배열의 메모리를 해제하기 위한 delete 연산자가 실행된다. 이 때 메모리 영역을 가리키는 포인터가 가리키는 곳을 어디인가? p1 객체가 소멸되면서 이미 메모리 해제가 된 공간이 아닌가? 결국 p2 객체의 소멸자는 이미 해제된 메모리 공간에 대해서 다시 해제하는 꼴이 된다. 그래서 문제가 발생한 것이다.

 그림을 통해서 다시 살펴보자.

 위 그림에서 100을 저장하기 위한 메모리가 동적 할당되어 있고, 이를 가리키는 포인터 p를 멤버 변수로 가지는 객체 A가 있다고 가정하다. 그리고 A를 디폴트 복사 생성자를 통해 객체 B를 생성하였다. 디폴트 복사 생성자로 복사했기 때문에 포인터 p에 저장된 값이 객체 B의 포인터 q에 복사되었다. 100이라는 값을 저장하는 메모리가 할당되는 친절함이 디폴트 복사 생성자에는 없다. 결국 100이라는 값이 저장된 메모리 공간을 참조하고 있는 포인터는 p, q 두 개가 존재한다. 이제 객체 A를 소멸시키자. 다행히도 객체 A의 소멸자에는 100을 저장한 메모리 공간을 해제하는 코드가 삽입되어 있다. A를 소멸하면서 100을 저장한 메모리 공간도 깔끔하게 해제되었다. 이제 B를 소멸시키자. B 또한 A와 동일한 클래스로부터 생성한 객체이므로 소멸자에서 100이라는 메모리 공간을 해제하려고 시도할 것이다. 그 공간을 가리키는 q라는 포인터를 B가 가지고 있기 때문이다. 여기서 문제가 발생한다. 이미 A가 소멸하면서 해제된 메모리 공간에 대해 B도 메모리 해제를 시도한다. 에러가 발생하게 된다.

 이런 문제를 해결하기 위해서는 어떻게 해야할까? 동적 할당한 메모리 공간이 있는 경우 디폴트 복사 생성자로 객체를 복사하면 안 된다는 결론이 나온다. 제대로 복사하기 위해서는 동적 할당한 메모리 공간을 복사할 때, 동일한 크기로 메모리를 동적 할당한 후에 이 공간을 가리키는 포인터를 멤버 변수로 가지고 있어야 한다. 위 그림의 상황으로 설명하자면 객체 B도 100을 저장할 수 있는 메모리 공간을 독립적으로 할당해야 한다는 의미이다. 이를 위해서 디폴트 복사 생성자가 아닌 프로그래머가 직접 복사 생성자를 정의할 필요가 있다. 그리고 이런 복사 과정을 깊은 복사(deep copy)라고 부른다.


깊은 복사(deep copy)

 문제 상황에 대한 파악은 마친 상태이다. 이제 디폴트 복사 생성자를 대체할 복사 생성자를 만들어 보자.
 이 코드는 디폴트 복사 생성자의 문제점을 해결하기 위한 해결책을 적용한 복사생성자가 구현되어 있다. 단순히 포인터를 복사하는 것이 아니라 동적 할당된 공간이 있으면 이와 동일한 크기의 메모리 공간을 동적 할당한 후, 이 공간을 포인터 멤버 변수로 가리키고 있다. 결국 앞의 그림에서 객체 A와 B는 이름을 저장하는 문자 배열을 각각 가지고 있는 상태이다.


 위 그림은 깊은 복사로 이루어진 경우이다. 각각의 객체는 독립적으로 동적 할당이 이뤄진 상태이고, 각각의 동적 할당된 메모리 공간을 가리키는 포인터는 멤버 변수로 가지고 있다. 객체 A가 소멸되어도 객체 B의 자신만의 동적 할당된 공간이 존재하므로 소멸 과정에서도 문제가 발생하지 않는다.


정리해보자

 디폴트 복사 생성자는 복사 생성자가 정의되지 않은 경우 자동으로 코드에 삽입된다.
 디폴트 복사 생성자는 단순한 값 복사이므로 동적 할당된 메모리 공간을 처리하지 않는다.
 이는 메모리 해제에 문제가 발생할 수 있으므로 이를 해결하기 위해 깊은 복사가 이뤄지도록 복사 생성자를 정의해줘야 한다.

댓글 3개: