본문 바로가기
Programming/C++

복사 생성자(Copy Constructor)

by OKOK 2017. 7. 31.

복사 생성자가 생소하긴 해도 생성자의 한 형태일 뿐입니다. 우리는 지금까지 다음과 같은 방식으로 변수와 참조자를 선언 및 오기화해 왔습니다. int num=20; int &ref=num; 하지만 C++에서 다음의 방식으로 선언 및 초기화가 가능합니다. int num(20); int &ref(num); 위의 두 가지 초기화 방식은 결과적으로 동일합니다. C++ 의 모든 객체는 생성자의 호출을 동반한다고 했는데, sim2의 생성자 호출에 대해서는 언급한 바가 없습니다. 과연 sim2는 어떠한 과정을 거쳐서 생성되는 것일까요?


SoSimple sim2(sim1); 이 문장에 담겨있는 내용을 정리하면 다음과 같습니다. SoSimple형 객체를 생성합니다. 객체의 이름은 sim2로 정합니다. sim1을 인자로 받을 수 있는 생성자의 호출을 통해서 객체생성을 완료합니다. 


#include <iostream>

using namespace std;


class SoSimple

{

private:

int num1;

int num2;

public:

SoSimple(int n1, int n2)

: num1(n1), num2(n2)

{


}

SoSimple(SoSimple &copy)

:num1(copy.num1), num2(copy.num2)

{

cout << "Called SoSimple(SoSimple &copy)" << endl;

}


void SHowSimpleData()

{

cout << num1 << endl;

cout << num2 << endl;

}

};


int main(void)

{

SoSimple sim1(15, 30);

cout << "Before" << endl;

SoSimple sim2 = sim1;

cout << "After" << endl;

sim2.SHowSimpleData();

return 0;


SoSimple 객체를 인자로 받는 생성자가 추가되었습니다. 이니셜라이저를 이용해서 멤버 대 멤버의 복사를 진행하고, 생성자의 호출을 확인하기 위한 문장이 삽입되었습니다. 객체생성 과정에서 정의된 생성자가 호출됩니다. 생성자가 호출되는 시점이 다른 일반 생성자와 차이가 있기 때문에 붙은 이름이다. 즉, 복사 생성자를 정확히 이해하기 위해서는 복사 생성자의 호출시점을 확실히 이해해야 합니다. 멤버 대 멤버의 복사에 사용되는 원본을 변경시키는 것은 복사의 개념을 무너뜨리는 행위가 되ㄴ, 키워드 const 를 삽입해서 이러한 실수를 막아 놓는 것이 좋습니다. 


디폴트 복사 생성자

복사 생성자를 정의하지 않으면, 멤버 대 멤버의 복사를 진행하는 디폴트 복사 생성자가 자동으로 삽입됩니다. 


변환에 의한 초기화 키워드 explicit로 막을 수 있습니다.

복사 생성자가 묵시적으로 호출된 것으로 볼 수 있습니다. 따라서 위와 같은 유형의 변환이 마음에 들지 않는다면, 복사 생성자의 묵시적 호출을 허용하지 않으면 됩니다. 이러한 목적으로 사용되는 키워드는 explicit 입니다. 묵시적 변환이 좋은 것만은 아닙니다. 자료형이든, 문장이든 자동으로 변환되는 것이 늘 바가운 것만은 아닙니다. 묵시적 변환이 많이 발생하는 코드일수록 코드의 결과를 예측하기가 어려워지기 때문이빈다. 따라서 키워드 explicit는 코드의 명확함을 더하기 위해서 자주 사용되는 키워드 중 하나입니다. 


복사 생성자이 매개변수 선언에 const는 필수가 아닙니다. 그러나 참조형의 선언을 의미하는 &는 반드시 삽입해야 합니다. &선언이 없다면 복사 생성자의 호출은 무한루프에 빠져버립니다. 



깊은 복사와 얕은 복사

디폴트 복사 생성자는 멤버 대 멤버의 복사를 진행합니다. 이러한 방식의 복사를 가리켜 얕은 복사라 하는데, 이는 멤버변수가 힙의 메모리 공간을 참조하는 경우가 문제가 됩니다. 


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

#pragma warning(disable:4996)

#include <iostream>

#include <cstring>

using namespace std;


class Person

{

private:

char * name;

int age;

public:

Person(char * myname, int myage)

{

int len = strlen(myname) + 1;

name = new char[len];

strcpy(name, myname);

age = myage;

}


void ShoPersonInfo() const

{

cout << "name : " << name << endl;

cout << "age: " << age << endl;

}

~Person()

{

delete[]name;

cout << "called destructor" << endl;

}

};


int main(void)

{

Person man1("Lee dong woo", 29);

Person man2 = man1;

man1.ShoPersonInfo();

man2.ShoPersonInfo();

return 0;


생성자에서 new를 이용한 동적 할당을, 소멸자에서 delete 를 이용한 메모리이 해제를 진행함에 주목합니다. 별도의 복사 생정자가 정의되지 않았으므로, 디폴트 복사 생성자에 의한 멤버 대 멤버의 복사가 진행됩니다. 디폴트 복사 생성자는 멤버 대 멤버의 단순 복사를 진행하기 때문에 복사의 결과로 하나의 문자열을 두 개의 객체가 동시에 차모하는 꼴을 만들어버립니다. 이미 지워진문자열을 대상으로 delete 연산을 하기 때문에 문제가 됩니다. 따라서 복사 생성자를 정의할 때에는 이러한 문제가 발생하지 않도록 신경을 써야 합니다. 


깊은 복사를 위한 복사 생성자의 정의

그림에서 보인 문제의 해결 방법으로 여러 가지를 생각해 볼 수 있습니다. 복사 생성자를 정의함으로써 문제를 해결해보고자 합니다. 객체 별로 각각 문자열을 참조하기 때문에, 언급한 객체 소멸과정에서의 문제는 발생하지 않습니다. 이러한 형태의 복사를 가리켜 깊은 복사라고 합니다. 멤버뿐만 아니라, 포이터 참조하는 대상까지 깊게 복사한다는 뜻으로 정해진 이름입니다. 멤버변수 age의 멤버 대 멤버 복사, 메모리 공간 할당후 문자열 복사, 그리고 할당된 메모리의 주소 값을 멤버 name에 저장합니다. 



복사 생성자의 호출 시점

복사 생성자가 호출되는 시점은 크게 세가지로 구분할 수 있습니다. case 1: 기존에 생성된 객체를 이용해서 새로운 객체를 초기화하는 경우, case 2 : Call-by-value 방식의 함수호출 과정에서 객체를 인자로 전달하는 경우, case 3: 객체를 반환하되, 참조형으로 반환하지 않는 경우. 객체를 새로 생성해야 한다. 단, 생성과 동시에 동일한 자료형이 객체로 초기화해야 합니다. 


메모리 공간의 할당과 초기화가 동시에 일어나는 상황

복사 생성자의 호출시기를 논하기에 앞서, 먼저 메모리 공간이 할당과 동시에 초기화되는 상황을 나열해보겠습니다. 함수가 호출되는 순간에 매개변수 n이 할당과 동시에 변수 num에 저장되어 있는 값으로 초기화됩니다. 이렇듯 매개변수도 함수가 호출되는 순간에 할당되므로, 이 상황도 메모리 공간의 할당과 초기화가 동시에 일어나는 상황입니다. 


반환도니느 값을 별도의 변수에 저장하는 것과 별개로, 값을 반환하면 반환된 값은 별도의 메모리 공간이 할당되어서 저장이 됩니다. 위의 코드에서 보이고 있는 다음 문장을 보면 쉽게 이해할 수 있습니다. 반환되는 값을 메모리 공간의 어딘가에 저장해 놓지 않았다면, cout에 의한 출력이 가능하겠는가? 값이 출력되기 위해서는 그 값을 참조할 수 있어야 하고, 참조가 가능 하려면 메모리 공간의 어딘가에 저장되어야 합니다. 함수가 값을 반환하면, 별도의 메모리 공간이 할당되고, 이 공간에 반호나 값이 저장됩니다. 반환 값으로 초기화됩니다. return 문이 실행되는 순간, SoSimple 객체를 위한 메모리 공간이 할당되고, 이 공간에 할당된 객체는 반환되는 객체 ob의 내용으로 초기화 됩니다. 


할당 이후, 복사 생성자를 통한 초기화

복사 생성자의 호출 디폴트 복사 생성자는 멤버 대 멤버가 복사되도록 정의가 되니, 

#include <iostream>

using namespace std;


class SoSimple

{

private:

int num;

public:

SoSimple(int n) : num(n)

{ }

SoSimple(const SoSimple& copy) : num(copy.num)

{

cout << "Called SoSimple(const SoSimple& copy)" << endl;

}

void ShowData()

{

cout << "num: " << num << endl;

}

};


void SimpleFuncObj(SoSimple ob)

{

ob.ShowData();

}


int main(void)

{

SoSimple obj(7);

cout << "before" << endl;

SimpleFuncObj(obj);

cout << "after" << endl;

return 0;


복사 생성자의 호출시기를 알기 위해서 복사 생성자를 별도로 정의하였습니다. 디폴트 복사 생성자와의 유일한 차이점은 13행의 문자열 출력에 있습니다. 함수 SimpluFuncObj를 호출하면서 객체 obj를 인자로 전달하고 있습니다. 따라서 21행에 선언된 매개변수 ob의 복사 생성자가 호출되면서, 인자로 obj가 전달됩니다. 


실행결과를 통해서 함수에 인자를 전달하는 과정에서 복사 생성자가 호출됨을 확인하였습니다. 그리고 이로 인해서 멤버변수 num에 저장된 값이 복사 됨을 확인하였습니다. 초기화의 대상은 obj 객체가 아닌, ob 객체입니다. 그리고 ob 객체는 obj 객체로 초기화됩니다. 따라서 ob 객체의 복사 생성자가 호출되면서, obj 객체가 인자로 전달되어야 합니다. 


#include <iostream>

using namespace std;


class SoSimple

{

private:

int num;

public:

SoSimple(int n) : num(n)

{ }

SoSimple(const SoSimple& copy) : num(copy.num)

{

cout << "Called SoSimple(const SoSimple& copy)" << endl;

}

SoSimple& AddNum(int n)

{

num += n;

return *this;

}

void ShowData()

{

cout << "num: " << num << endl;

}

};


SoSimple SimpleFuncObj(SoSimple ob)

{

cout << "return before" << endl;

return ob;

}


int main(void)

{

SoSimple obj(7);

SimpleFuncObj(obj).AddNum(30).ShowData();

obj.ShowData();

return 0;


참조형을 반환하는 함수를 이해해야 합니다. 이 문장을 실행하는 객체 자신을 반환하고 있습니다. 그런데 반환형이 참조형이니, 참조 값이 반환됩니다. 매개변수 선언을 보면, 인자의 전달과정에서 복사 생성자가 호출됨을 알 수 있습니다. ob 객체를 반환하고 있습니다. 반환형이 참조형이 아닙니다. 따라서 ob 객체의 복사본이 만들어지면서 반환이 진행됩니다. SimpleFuncObj 함수가 반환한 객체를 대상으로 AddNum 함수를 호출하고 있습니다. 이어서 AddNum 함수가 반환하는 참조 값을 대상으로 ShowData 함수를 호출하고 있습니다. 객체를 반환하게 되면, 임시객체라는 것이 생성되고, 이 객체의 복사 생성자가 호출되면서 retrun 문에 명시된 객체가 인자로 전달됩니다. 즉, 최정적으로 반환되는 객체는 새롭게 생성되는 임시객체입니다. 함수호출이 완료되고 나면, 지역적으로 선언된 객체 ob는 소멸되고, obj 객체와 임시객체만 남습니다. 


반환할 때 만들어진 객체는 언제 사라지나요?

#include <iostream>

using namespace std;


class Temporary

{

private:

int num;

public:

Temporary(int n) :num(n)

{

cout << "create obj: " << num << endl;

}

~Temporary()

{

cout << "destroy obj: " << num << endl;

}

void ShowTempInfo()

{

cout << "My num is" << num << endl;

}

};


int main(void)

{

Temporary(100);

cout << "after" << endl << endl;


Temporary(200).ShowTempInfo();

cout << "after" << endl << endl;


const Temporary &ref = Temporary(300);

cout << "end of main" << endl << endl;

return 0;


임시객체를 직접 생성하는 방법입니다. 이 문장에 의해서 100으로 초기화된 Temporary 임시객체가 생성됩니다. 임시객체를 생성하고, 이어서 이 객체를 대상으로 ShowTempInfo 함수를 호출하고 있습니다. 객체가 생성 및 반환되면, 생성 및 반환된 위치에 객체를 참조할 수 있는 참조 값이 반환되기 때문에 이러한 문장을 구성할 수 있습니다. 임시객체를 생성했습니다. 앞에서 생성한 다른 두 객체와 달리 여기서는 참조자 ref 로 임시객체를 참조하고 있습니다.


클래스 외부에서 객체의 멤버함수를 호출하기 위해 필요한 것은 3가지 중 하나 입니다. 객체에 붙여진 이름, 객체의 참조 값(객체 참조에 사용된느 정보), 그리고 객체의 주소 값입니다. 그런데 임시객체가 생성된 위치에는 임시객체의 참조 값이 반환됩니다. 즉, 위 문장의 경우 먼저 임시객체가 생성되면서 다음의 형태가 됩니다. 그래서, 이어서 멤버함수의 호출이 가능한 것입니다. 참조 값이 반환되기 때문에 다음과 같은 문장의 구성도 가능합니다. 임시객체 생성시 반환되는 참조 값이 참조자 ref 에 전달되어, ref가 임시객체를 참조하게 됩니다. 실제로는 임시객체가 통쨰로 반환되어서가 아니라, 임시객체는 메모리에 저장되고, 그 객체의 참조 값이 반환되었기 때문입니다. 


반환을 위해서 임시객체가 생성은 되지만, 이 객체는 메모리 공간에 존재하고, 이 객체의 참조 값이 반환되어서 AddNum 함수의 호출이 진행된 것입니다. 임시객체는 다음 행으로 넘어가면 바로 소멸되어 버립니다. 참조자에 참조되는 임시객체는 바로 소멸되지 않습니다. 따라서 접근이 불가능하게 된 임시객체는 바로 소멸을 시켜버립니다. 


#include <iostream>


using namespace std;


class SoSimple

{

private:

int num;

public:

SoSimple(int n) : num(n)

{

cout << "New Object: " << this << endl;

}

SoSimple(const SoSimple& copy) : num(copy.num)

{

cout << "New Copy obj: " << this << endl;

}


~SoSimple()

{

cout << "Destory obj: " << this << endl;

}

};


SoSimple SimpleFuncObj(SoSimple ob)

{

cout << "Parm ADR: " << &ob << endl;

return ob;

}


int main(void)

{

SoSimple obj(7);

SimpleFuncObj(obj);


cout << endl;

SoSimple tempRef = SimpleFuncObj(obj);

cout << "Return Obj" << &tempRef << endl;

return 0;


추가로 객체를 생성하지 않고, 반회되는 임시객체에 tempRef 라는 이름을 할당하고 있음을 보여줍니다. 객체의 생성 수를 하나 줄여서 효율성을 높이기 위해서.