티스토리 뷰

※ 주의 사항 ※

  • 이 글의 목적은 '지식의 전달'이 아닌 '학습의 기록'입니다.
  • 따라서 제가 이해하는 그대로의 내용이 포함됩니다.
  • 따라서 이 글은 사실과는 다른 내용이 포함될 수 있습니다.

 

지금까지는 다음과 같은 방식으로 변수와 참조자를 선언 및 초기화해 왔습니다.

int num = 20l
int &ref = num;

 

그런데 C++에서는 다음의 방식으로도 선언 및 초기화가 가능합니다.

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

 

이 방법은 멤버 이니셜 라이저를 사용해 멤버 변수를 초기화하는 것과 형태가 같습니다. 위 두 가지 초기화 방식은 결과적으로 동일하며, C++에서는 두 가지 방법 모두 지원하고 있습니다.

 

객체의 생성에 관해서도 이는 동일하게 작용합니다. 예를 들기 위해 아래와 같이 간단한 클래스를 정의해보겠습니다.

class Simple
{
private:
	int num1;
	int num2;

public:
	Simple(int n1, int n2) : num1(n1), num2(n2) {}
	void ShowSimpleData()
	{
		cout << num1 << endl;
		cout << num2 << endl;
	}
};

 

이어서 다음의 코드를 보겠습니다.

int main(void)
{
	Simple sim1(15, 20);
	Simple sim2 = sim1;
	sim2.ShowSimpleData();
	return 0;
}

 

위 코드를 보면서 객체 sim2가 어떤 과정을 거쳐 생성이 되는 것인지 스스로 생각해 봅니다.

 

sim2 객체가 생성되는 과정은 이렇게 될 것으로 보입니다.

  • Simple 클래스를 틀로 하여 sim2 객체를 우선 생성한다.
  • 그리고 sim1의 멤버 변수의 데이터를 sim2의 멤버 변수로 복사한다.

 

그런데 실제로도 위와 같은 과정을 거칩니다. 그리고 변수와 참조자 등이 그러했 듯 객체 또한 다음과 같은 선언과 초기화가 가능합니다.

Simple sim2(sim1);

 

그런데 앞서 배운 내용에 따르면, 객체를 생성할 때는 반드시 생성자가 호출되어야 합니다. 그리고 생성자가 선언되어 있지 않다면 디폴트 생성자를 자동으로 삽입하여 이를 호출한다는 것을 배웠습니다.

 

그런데 이상합니다. 위 sim2 객체를 선언하고 초기화하는 코드를 보면 인자로 객체 sim1을 주고 있습니다. 그런데 디폴트 생성자는 매개변수가 없습니다. 따라서 sim2 객체를 생성할 때는 디폴트 생성자가 호출되지 못합니다. 그렇다고 따로 정의해 놓은 생성자도 없습니다. 객체 sim1을 인자로 주면서 sim2를 생성하면 다음과 같은 형식의 생성자가 필요합니다.

Simple (const Simple &copy) : num1(copy.num1), num2(copy.num2) {}

 

실제로 앞의 예처럼 sim2 객체를 생성할 때는 위와 같은 생성자가 자동으로 삽입되고 호출됩니다. 이러한 생성자를 '복사 생성자'라고 합니다.

 

즉, 객체를 생성하는데 클래스에 생성자가 하나도 정의되어 있지 않다면, 컴파일 과정에서 '디폴트 생성자' 하나와 '디폴트 복사 생성자' 하나, 2개의 생성자가 자동으로 삽입됩니다.

class Simple
{
private:
	int num1;
	int num2;

public:
	Simple() {}                                                         //디폴트 생성자 삽입
	Simple(const Simple &copy) : num1(copy.num1), num2(copy.num2) {}    //디폴트 복사 생성자 삽입
	~Simple() {}                                                        //디폴트 소멸자 삽입
}

 

복사 생성자를 따로 정의하지 않아도 디폴트 복사 생성자가 자동으로 삽입되기 때문에, 굳이 복사 생성자를 직접 정의할 필요는 없다고 생각할 수 있습니다. 실제로 많은 경우에 있어서 복사 생성자를 직접 정의하지 않아도 상관이 없지만, 반드시 복사 생성자를 정의해야 하는 경우도 있습니다. 이와 관련해서는 나중에 언급합니다.

 

explicit 키워드

방금까지, 다음의 문장은

Simple sim2 = sim1;

 

아래와 같은 형태로 묵시적 변환이 일어남을 배웠습니다.

Simple sim2(sim1);

 

그래서 '='을 이용한 대입 연산으로 클래스를 생성해도 오류를 발생시키지 않았습니다.

 

그런데 대입 연산을 이용한 객체의 생성을 방지하고 싶다면 키워드 explicit을 사용합니다. 이 키워드는 다음의 문장을

Simple sim2 = sim1;

 

아래와 같은 형태로 묵시적 변환하는 것을 허용하지 않습니다.

Simple sim2(sim1);

 

따라서 객체를 생성하기 위해서는 다음과 같은 선언을 사용할 수밖에 없습니다.

Simple sim2(sim1);     //가능한 선언
Simple sim2 = sim1;    //불가능한 선언

 

이 키워드의 사용법은 다음과 같습니다.

class Simple
{
private:
	int num1;
	int num2;

public:
	explicit Simple(const Simple &copy) : num1(copy.num1), num2(copy.num2) {}
}

 

explicit 키워드는 복사 생성자뿐만 아니라 매개 변수를 하나 이상 가지는 생성자에 대해서도 사용이 가능합니다. 이 키워드를 사용하지 않으면 다음과 같은 두 가지 객체 생성이 가능하지만

Simple sim1(15, 20);       //가능한 객체 선언
Simple sim1 = {15, 20};    //가능한 객체 선언

 

explicit 키워드를 사용하게 되면 대입 연산을 통한 객체 생성은 불가능해집니다.

Simple sim1(15, 20);       //가능한 객체 선언
Simple sim1 = {15, 20};    //불가능한 객체 선언

 

얕은 복사

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

#include <iostream>
#include <cstring>

using namespace std;

class Person
{
private:
	char* name;
	int age;

public:
	Person(const char* name, int age)
		: age(age)
	{
		this->name = new char[strlen(name) + 1];
		strcpy(this->name, name);
	}

	void ShowPersonInfo() const
	{
		cout << "이름 : " << name << endl;
		cout << "나이 : " << age << endl;
	}

	~Person()
	{
		delete[] name;
		cout << "called destructor!" << endl;
	}
};

int main(void)
{
	Person man1("KOEY", 29);
	Person man2(man1);

	man1.ShowPersonInfo();
	man2.ShowPersonInfo();

	return 0;
}

 

해당 예제는 컴파일은 정상적으로 이뤄집니다. 하지만 프로그램을 실행하게 되면 다음의 실행결과를 보이고 오류를 일으킵니다.

/*
실행결과

이름 : KOEY
나이 : 29
이름 : KOEY
나이 : 29
called destructor!

*/

 

왜 해당 예제를 실행했을 때 오류가 발생했는지 추측해 봅니다.

 

실행 결과를 보면 "called destructor!" 문구가 한 번만 출력되었습니다. main 함수에서 선언된 객체가 두 개였기 때문에 이 문구도 두 번 출력되었어야 합니다.

 

man2 객체를 생성할 때 man1 객체를 복사하여 생성합니다. 이때 man1의 name에는 "KOEY"가 저장되어 있고, age에는 29가 저장되어 있어 man2의 name에는 man1의 name에 저장된 "KOEY"가 복사되고 age에는 29가 복사되었다고 생각할 수 있습니다. 그러나 이는 틀렸습니다.

 

man1의 name은 동적 할당한 char 배열 공간의 시작 주소를 저장하고 있습니다. 그리고 해당 공간에는 "KOEY"가 저장되어 있습니다. 따라서 man2를 생성하면서 man1의 멤버를 복사하게 되면 age는 똑같이 29로 복사되더라도 man2의 name 에는 man1의 name이 저장하고 있는 주소가 복사됩니다. 따라서 같은 메모리 공간을 두 개의 객체의 멤버 변수가 공유하게 되는 것입니다.

 

문제는 각각의 객체의 소멸자에서 delete 연산을 할 때 발생합니다. man1 객체의 소멸자가 호출되면서, name에 저장된 주소를 통해서 동적 할당된 공간을 반환시킵니다. 따라서 "KOEY"를 저장하고 있던 메모리 공간이 사라지게 되었습니다.

그리고 man2의 소멸자가 호출되면서 마찬가지로 name에 저장된 주소를 통해서 동적 할당된 공간을 반환시킵니다. 그런데 해당 공간은 man1의 소멸자가 호출되면서 반환시켜버렸기 때문에 man2의 소멸자는 delete 연산을 수행할 수 없고, 오류를 발생하게 됩니다.

 

깊은 복사

위와 같은 오류를 방지하기 위한 방법으로 복사 생성자를 직접 정의할 수 있습니다. 위의 오류는 man1 객체를 복사하여 man2 객체를 생성할 때, man2의 name이 참조하는 메모리 공간이 man1의 name과는 따로 존재하지 않아 발생했습니다.

 

즉, man1 객체를 복사하여 man2 객체를 생성할 때, man2의 name이 man1의 name과는 다른 메모리 공간을 참조하면서 해당 메모리 공간에는 같은 데이터가 들어있도록 하면 오류를 발생시키지 않을 수 있습니다. 이러한 형태의 복사를 '깊은 복사'라고 합니다. 멤버뿐만 아니라, 포인터로 참조하는 대상까지 깊게 복사한다는 뜻으로 정해진 이름입니다.

 

그럼 앞서 오류를 일으킨 예제에서, '깊은 복사'를 위한 복사 생성자를 정의해 보겠습니다.

#include <iostream>
#include <cstring>

using namespace std;

class Person
{
private:
	char* name;
	int age;

public:
	Person(const char* name, int age)
		: age(age)
	{
		this->name = new char[strlen(name) + 1];
		strcpy(this->name, name);
	}

	Person(const Person& copy)
		: age(copy.age)
	{
		name = new char[strlen(copy.name) + 1];
		strcpy(name, copy.name);
	}

	void ShowPersonInfo() const
	{
		cout << "이름 : " << name << endl;
		cout << "나이 : " << age << endl;
	}

	~Person()
	{
		delete[] name;
		cout << "called destructor!" << endl;
	}
};

int main(void)
{
	Person man1("KOEY", 29);
	Person man2(man1);

	man1.ShowPersonInfo();
	man2.ShowPersonInfo();

	return 0;
}

/*
실행결과

이름 : KOEY
나이 : 29
이름 : KOEY
나이 : 29
called destructor!
called destructor!

*/

 

복사 생성자를 위와 같이 직접 정의해준 뒤에는 called destuructor! 문구도 두 번 출력되었으며 오류를 일으키지 않았습니다.

 

복사 생성자의 호출 시점

복사 생성자의 호출 횟수는 프로그램의 성능과도 관계가 있기 때문에 호출 시기를 이해하는 것은 매우 중요합니다.

 

우선 다음의 경우에 복사 생성자가 호출된다는 사실을 이제 알고 있을 것입니다.

Person man1("KOEY", 29);
Person man2(man1);

 

하지만 이것이 전부는 아닙니다. 이를 포함해 복사 생성자가 호출되는 시점은 크게 세 가지로 구분할 수 있습니다.

  • 기존에 생성된 객체를 이용해서 새로운 객체를 초기화하는 경우(앞서 보인 경우)
  • Call-By-Value 방식의 함수 호출 과정에서 객체를 인자로 전달하는 경우
  • 객체를 반환하되, 참조형으로 반환하지 않는 경우

 

위 세 가지 경우는 모두 동일한 공통점을 지니고 있습니다. 바로 '객체를 새로 생성해야 한다'는 것입니다.

 

먼저, 메모리 공간이 할당됨과 동시에 초기화되는 경우를 알아보겠습니다.

int num1 = 19;
int num2 = num1;

 

위 경우는 쉽게 이해할 수 있을 것입니다. int형 메모리 공간을 할당하고 이 공간의 이름을 num2라고 하며, 해당 공간의 데이터를 num1의 것을 복사하여 초기화합니다.

 

다음 경우입니다.

void SimpleFunc(int n);

int main(void)
{
	int num = 10;
	SimpleFunc(num);

	return 0;
}

void SimpleFunc(int n)
{
	......
}

 

위의 경우에는 SimpleFunc 함수의 매개변수에서 메모리 공간 할당과 초기화가 이뤄집니다. 함수를 호출하면서 num를 인자로 주었습니다. 그리고 호출된 함수 내에서는 int형 메모리 공간을 할당하고, 이 공간의 이름을 n으로 부르며, 해당 공간의 데이터를 num의 것을 복사하여 초기화합니다.

 

다음 경우입니다.

int SimpleFunc(int n)
{
	......

	return n;
}

int main(void)
{
	int num = 10;
	cout << SimpleFunc(num) << endl;
	return 0;
}

이번에는 SimpleFunc 함수가 호출되고 n을 반환합니다. 이때에도 역시 메모리 공간의 할당과 초기화가 이뤄집니다. 왜냐하면 함수가 n을 반환하지만 이 변수 n은 함수 내에서만 사용이 되는 변수이며, 함수의 호출이 종료되면 해당 메모리 공간은 반환되어 사라집니다. 따라서 이 변수가 정상적으로 반환되어 함수 밖에서도 이용되기 위해서는 함수 밖에 메모리 공간을 할당하고 이를 초기화해야 합니다.

 

함수 밖에 int형 메모리 공간을 할당하고, 이 공간의 이름을 무엇으로 부를지는 모릅니다. 그리고 해당 공간의 데이터를 n의 것을 복사하여 초기화합니다. 해당 메모리 공간의 이름과 주소를 알 수가 없기 때문에 이 변수를 다른 변수에 대입해주지 않으면 일회성으로만 사용할 수 있습니다. 하지만 다른 변수에 대입하지 않았다고 해서 메모리 공간을 가지지 않는 것은 아님을 주의해야 합니다. 물론 다른 변수에 대입되지 않은 반환값은 프로그램이 다음 코드 줄을 읽으면서 소멸됩니다.

 

변수에 대해서 위 세 가지 예를 들었지만 이는 변수가 아닌 객체에 대해서도 동일합니다. 따라서 위 세 가지 경우에 대해서 객체의 복사 생성자가 호출됩니다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/12   »
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 26 27 28
29 30 31
글 보관함