끝나지 않는 프로그래밍 일기


1. 생성자(Constructor)


오늘은 객체 생성/소멸시에 호출되는 생성자와 소멸자에 대해 알아보도록 하겠습니다. 우리는 바로 전 강좌에서, private로 지정된 필드(=멤버 변수)를 초기화 시키기 위하여 SetInfo 함수를 따로 만들어 초기화 시켜주었습니다. 그런데, 이것보다 더 편하게 객체 생성과 동시에 초기화 시켜주는 녀석이 있습니다. 그 녀석이 바로 생성자라는 녀석입니다. 아래는 생성자의 형식입니다.

class 클래스명 {
public:
   클래스명(매개변수..)
   {
       // ...
    }
    // ..
}

위의 형식을 보시면, 생성자를 정의할때 생성자의 이름이 클래스의 이름과 같습니다. 생성자도 함수와 같이 매개변수를 가질 수 있습니다. 그리고 반환형이 없습니다. 한번 SetInfo 함수 대신 생성자를 이용해서 예제를 작성해보도록 합시다.

#include <iostream>
 
using namespace std;
 
class student {
private:
    char * name;
    int age;
    char * hobby;
public:
	student(char * _name, int _age, char * _hobby);
    void ShowInfo();
    void Study();
    void Sleep();
};

student::student(char * _name, int _age, char * _hobby)
{
    name = _name;
    age = _age;
    hobby = _hobby;
}

void student::ShowInfo()
{
    cout << "이름: " << name << ", 나이: " << age << ", 취미: " << hobby << endl;
}
 
void student::Study()
{
    cout << "공부!" << endl;
}
 
void student::Sleep()
{
    cout << "잠!" << endl;
}
 
int main()
{
    student stu("김철수", 16, "컴퓨터 게임");
 
    stu.ShowInfo();
 
    //while(true) {
    //    stu.Study();
    //    stu.Sleep();
    //}
 
    return 0;
}

결과:

이름: 김철수, 나이: 16, 취미: 컴퓨터 게임

계속하려면 아무 키나 누르십시오 . . .


코드의 11행을 보시면 생성자가 보이시죠? 17~22행을 보시면 생성자가 정의되어 있는데, 하는 일은 SetInfo 함수와 같습니다. 41행을 보시면 객체 생성시 호출되는 생성자에게 인자를 넘겨 초기화를 시킵니다. SetInfo를 이용한 방법보다 간편하죠? 45~48행은 주석처리 해버렸습니다. 주석은 푸셔도 상관 없습니다.


생성자의 또다른 특징에는 생성자도 함수 중 하나니 함수 오버로딩이 가능하다는 점입니다. 아래와 같이 말이죠.

#include <iostream>
 
using namespace std;
 
class ExConstructor
{
public:
	ExConstructor()
	{
		cout << "ExConstructor() called!" << endl;
	}

	ExConstructor(int a)
	{
		cout << "ExConstructor(int a) called!" << endl;
	}

	ExConstructor(int a, int b)
	{
		cout << "ExConstructor(int a, int b) called!" << endl;
	}
};

int main()
{
	ExConstructor ec1;
	ExConstructor ec2(10);
	ExConstructor ec3(20, 10);
 
    return 0;
}

결과:

ExConstructor() called!

ExConstructor(int a) called!

ExConstructor(int a, int b) called!

계속하려면 아무 키나 누르십시오 . . .


각각 8, 13, 18행을 보시면 생성자가 오버로딩 된것을 확인하실 수 있습니다. 26~28행에서 넘겨주는 인자의 형식, 수에 따라 그에 맞는 생성자가 호출됩니다.


또 하나는, 위에서 말한대로 생성자가 객체 생성시 호출되는 함수라고 했는데 우리가 생성자를 구현하지 않으면 어떻게 될까요? 우리가 클래스 내에 생성자를 구현하지 않으면 C++ 컴파일러에서 그 클래스 내에 디폴트 생성자라는 것을 알아서 넣어줍니다. 아래와 같이 인자를 받지도 않고, 아무런 일도 하지않는 생성자를요.

클래스명() { }

이렇게, 객체가 만들어질때는 반드시 생성자 호출을 합니다. 이번에는, 복사 생성자에 대해 알아보도록 합시다.


2. 복사 생성자(Copy Constructor)


복사 생성자(Copy Constructor)는 자신과 같은 자료형의 객체를 인수로 전달하는 생성자입니다. 복사 생성자를 설명하기전, 변수와 참조자, 그리고 객체의 초기화를 먼저 다루도록 하겠습니다. 복사 생성자를 이해하시려면 참조자와 생성자를 이해하고 계셔야 합니다. 참조자 관련 게시글을 보시려면 아래 링크를 클릭하세요.

(참조자(Reference): http://blog.eairship.kr/170)


C와 C++ 스타일의 초기화 방식을 한번 비교해보도록 합시다.

int a(50); // C++ 스타일 초기화
int b = 40; // C 스타일 초기화

cout << "a: " << a << " b: " << b << endl;

위의 코드에서 a와 b를 출력해보면, a는 50, 예상하듯 b는 40을 출력합니다. C에서는 2행과 같이 초기화가 가능했으나, C++에서는 1행, 2행 방식 모두 초기화가 가능합니다. 그럼, 객체도 이렇게 초기화가 가능할까요? 아래의 코드를 한번 보도록 합시다.

#include <iostream>

using namespace std;

class MyClass
{
private:
	int num1;
	int num2;
public:
	MyClass(int a, int b)
	{
		num1 = a;
		num2 = b;
	}
	void ShowData()
	{
		cout << "num1: " << num1 << " num2: " << num2 << endl;
	}
};

int main()
{
	MyClass mc1(50, 40);
	MyClass mc2 = mc1;

	mc2.ShowData();
	return 0;
}

결과:

num1: 50 num2: 40

계속하려면 아무 키나 누르십시오 . . .


코드를 보시면 MyClass라는 클래스가 정의되었고, 그 안에는 num1과 num2란 멤버 변수와, 생성자, 그리고 num1과 num2의 값을 출력하는 ShowData라는 함수가 존재합니다. 24행을 보시면, mc1라는 객체가 만들어지면서 생성자에게 50과 40이란 값을 넘겨주고 mc1 객체 내의 num1과 num2는 각각 a와 b의 값으로 초기화 됩니다. 그런데 다음 25행에서 등장하는 구문을 한번 보도록 합시다. 변수처럼, mc2 객체에 mc1 객체를 대입시키고 있습니다. 그리고 27행을 보시면 ShowData 함수로 mc2 객체 내의 num1 변수와 num2변수의 값을 출력하고 있습니다. (실제로는 자동으로 MyClass mc2 = mc1;이 MyClass mc2(mc1);로 변환이 됩니다.)


결과를 보시면 mc1 객체 내의 num1과 num2 멤버 변수의 값과 같음을 알 수 있습니다. 마치, 멤버별 복사가 이루어진것처럼 말이죠. 생각해보면, MyClass 객체를 인수로 받는 생성자를 구현하지 않았음에도, 오류가 나지않고 정상적인 결과를 출력합니다. 이는, 우리가 따로 복사 생성자를 정의하지 않아도 디폴트 생성자처럼, 디폴트 복사 생성자가 컴파일러에 의해 중간 삽입됩니다. 아래와 같이 말이죠.

...
	MyClass(int a, int b)
	{
		num1 = a;
		num2 = b;
	}
	MyClass(const MyClass& mc) // 디폴트 복사 생성자의 형태
	{
		num1 = mc.num1;
		num2 = mc.num2;
	}
...

위와 같이 멤버별 복사가 이루어지는 방식을 가르켜 '얕은 복사(Shallow Copy)'라고 합니다. 그런데, 이 얕은 복사에 문제점이 존재합니다. 한번 같이 어떠한 문제점이 살펴보기전, 간단히 소멸자에 대해 알아두고 넘어갑시다.


3. 소멸자(Destructor)


생성자가 객체 생성시 호출되는 함수라면, 소멸자는 객체 소멸시 호출되는 함수입니다. 아래는 소멸자의 형식입니다. 주로 소멸자는 객체 소멸시 자동 호출되기에, 객체의 메모리 반환 즉 할당한 리소스의 해제를 위해 사용합니다.

class 클래스명 {
public:
   ~클래스명()
   {
       // ...
    }
    // ..
}

생성자와는 달리, 클래스 이름 앞에 ~가 붙은 형태를 가집니다. 그리고 매개변수도 가질 수 없습니다. (소멸자 역시 반환형이 존재하지 않습니다. 또한 소멸자를 정의하지 않으면 컴파일러에서 디폴트 소멸자를 넣어줍니다.) 아래 예제에서 각각 생성자와 소멸자가 호출되면 호출되었다고 출력하도록 했습니다.

#include <iostream>
 
using namespace std;
 
class ExConstructor
{
public:
	ExConstructor()
	{
		cout << "ExConstructor() called!" << endl;
	}

	~ExConstructor()
	{
		cout << "~ExConstructor() called!" << endl;
	}
};

int main()
{
    ExConstructor ec;
 
    return 0;
}

결과:

ExConstructor() called!

~ExConstructor() called!

계속하려면 아무 키나 누르십시오 . . .


13~16행을 보시면 소멸자가 정의되었습니다. 객체의 소멸은 소멸자를 호출하고 나서 메모리를 반환하는 순서로, 객체가 소멸됩니다. 이 소멸자는 메모리 반환시에 반환되지 않은 메모리 공간을 명시적으로 반환하기 위해 사용합니다.


4. 얕은 복사의 문제점, 그리고 깊은 복사(Deep Copy)


이어서, 디폴트 복사 생성자(얕은 복사 방식)의 문제점을 보도록 하겠습니다. 아래를 우선 보시죠.

#include <iostream>

using namespace std;

class MyClass
{
private:
	char *str;
public:
	MyClass(const char *aStr)
	{
		str = new char[strlen(aStr)+1];
		strcpy(str, aStr);
	}
	~MyClass() {
		delete []str;
		cout << "~MyClass() called!" << endl;
	}
	void ShowData()
	{
		cout << "str: " << str << endl;
	}
};

int main()
{
	MyClass mc1("MyClass!");
	MyClass mc2 = mc1;

	mc1.ShowData();
	mc2.ShowData();
	return 0;
}

결과:

str: MyClass!

str: MyClass!

~MyClass() called!


위 코드를 한번 컴파일해보면, "~MyClass() called!"가 단한번만 출력되고 오류가 발생합니다. 생각해보면, mc1 선언과 동시에 생성자 내에서 str를 메모리에 할당합니다. 그리고 mc2 선언시에 디폴트 복사 생성자가 호출되고, 메모리를 할당하지 않고 str의 포인터만 복사합니다. 그런 뒤에, mc2 객체가 먼저 소멸되고 mc2의 소멸자가 호출되고 str를 메모리 공간에서 해제시킵니다. 그리고 mc1 소멸자가 호출되어 str 포인터가 가르키고 있는 메모리 공간을 해제하려 하나, 이미 mc2의 소멸자에 의해 해제되었으므로 오류가 발생합니다.


이를 해결하기 위해서는, 포인터로 참조하는 대상까지 복사하는 "깊은 복사(Deep Copy)"가 필요합니다. 깊은 복사는 위의 MyClass 생성자 내의 코드를 똑같이 구현하시면 됩니다. 한번 볼까요?


#include <iostream>

using namespace std;

class MyClass
{
private:
	char *str;
public:
	MyClass(const char *aStr)
	{
		str = new char[strlen(aStr)+1];
		strcpy(str, aStr);
	}
	MyClass(const MyClass& mc)
	{
		str = new char[strlen(mc.str)+1];
		strcpy(str, mc.str);
	}
	~MyClass() {
		delete []str;
		cout << "~MyClass() called!" << endl;
	}
	void ShowData()
	{
		cout << "str: " << str << endl;
	}
};

int main()
{
	MyClass mc1("MyClass!");
	MyClass mc2 = mc1;

	mc1.ShowData();
	mc2.ShowData();
	return 0;
}

결과:

str: MyClass!

str: MyClass!

~MyClass() called!

~MyClass() called!

계속하려면 아무 키나 누르십시오 . . .


이번에는 오류가 뜨지 않고 정상적으로 출력됨을 확인하실 수 있습니다. 메모리 공간 할당 후 문자열을 복사합니다. 그 다음에는 할당된 메모리의 주소를 str에 저장합니다. 이렇게, 깊은 복사와 얕은 복사, 그리고 복사 생성자를 추가로 설명하다 보니 이해하기 어려운 부분이 많아졌고 설명이 부족한 부분도 몇몇 보이네요. 이해가 되지 않는 부분은 덧글로 달아주세요.


이번 강좌는 여기서 마치도록 하겠습니다. 수고하셨고, 다음 강좌에서는 Bool, Inline에 대해 알아보도록 하겠습니다.