공부 일지/CPP 공부 일지

C++ | 생성자(Constructor)와 소멸자(Destructor)

K◀EY 2021. 8. 2. 00:12

주의 사항!

  • 이 글은 제가 직접 공부하는 중에 작성되고 있습니다.
  • 따라서 제가 이해하는 그대로의 내용이 포함됩니다.
  • 따라서 이 글은 사실과는 다른 내용이 포함될 수 있습니다.

 

생성자

지금까지는 객체를 생성하고, private으로 선언한 멤버 변수를 초기화하기 위해 InitMembers 함수를 만들어 사용했습니다. 하지만 이런 과정은 조금 귀찮고 불편합니다. 그런데 '생성자'라는 것을 이용하면 객체도 생성과 동시에 초기화가 가능합니다.

 

생성자의 이해를 위해서 간단한 클래스 하나를 정의했습니다.

class SimpleClass
{
private:
	int num;

public:
	SimpleClass(int n)    //생성자
	{
		num = n;
	}

	int GetNum() const
	{
		return num;
	}
};

 

위에 정의된 클래스에는 눈에 띄는 부분이 있습니다.

SimpleClass(int n)
{
	num = n;
}

 

public으로 선언되었으며, 함수명이 클래스명과 같습니다. 그리고 반환형이 없고, 실제로 아무 값도 반환하지 않습니다.

이러한 유형의 함수를 가리켜 '생성자'라고 합니다.

 

생성자는 개체 생성 시 딱 한 번 호출됩니다. 그리고 생성자가 있는 객체를 생성할 때는 생성자의 매개변수에 들어갈 값을 다음과 같이 줘야 합니다.

SimpleClass sc(20);                        //생성자의 매개변수로 정수 20을 줌
SimpleClass *ptr = new SimpleClass(20);    //객체를 동적할당할 때 역시 매개변수를 줌

 

생성자의 오버로딩과 디폴트 값 설정

생성자도 함수의 일종이므로 '오버로딩''디폴트 값 설정'이 가능합니다. 다음 예제를 통해 확인해 보겠습니다.

#include <iostream>

using std::cout;
using std::cin;
using std::endl;

class SimpleClass
{
private:
	int num1;
	int num2;

public:
	SimpleClass()
	{
		num1 = 0;
		num2 = 0;
	}

	SimpleClass(int n)
	{
		num1 = n;
		num2 = 0;
	}

	
	SimpleClass(int n1, int n2)
	{
		num1 = n1;
		num2 = n2;
	}
	
	/*
	SimpleClass(int n1 = 0, int n2 = 0)
	{
		num1 = n1;
		num2 = n2;
	}
	*/

	void ShowData() const
	{
		cout << num1 << ' ' << num2 << ' ' << endl;
	}
};

int main(void)
{
	SimpleClass sc1;
	sc1.ShowData();

	SimpleClass sc2(100);
	sc2.ShowData();

	SimpleClass sc3(100, 200);
	sc3.ShowData();

	return 0;
}

/*
실행결과

0 0
100 0
100 200

*/

 

위 예제를 통해 생성자를 여럿 선언하여 오버로딩을 할 수 있음을 알게 되었습니다. 그리고 다른 SimpleClass 생성자를 주석 처리하고, 현재 주석 처리된 생성자만 주석을 해제하면 디폴트 값 설정도 가능함을 확인할 수 있습니다.

 

객체 생성 시 주의 사항

그리고 main 함수를 보면 sc1객체를 생성할 때 인자로 아무것도 주지 않은 것을 확인할 수 있습니다. 그런데 인자를 아무것도 주지 않는다면 다음과 같이 객체를 선언할 수도 있을 것입니다.

SimpleClass sc1();

 

하지만 이러한 객체 선언은 불가능합니다. 다음 예제를 통해 그 이유를 생각해보겠습니다.

#include <iostream>

using std::cout;
using std::cin;
using std::endl;

class SimpleClass
{
private:
	int num1;
	int num2;

public:
	SimpleClass(int n1 = 0, int n2 = 0)
	{
		num1 = n1;
		num2 = n2;
	}
	
	void ShowData() const
	{
		cout << num1 << ' ' << num2 << endl;
	}
};

int main(void)
{
	SimpleClass sc1();
	SimpleClass mysc = sc1();

	mysc.ShowData();
	
	return 0;
}

SimpleClass sc1(void)
{
	SimpleClass sc2(20, 30);

	return sc2;
}

/*
실행결과

20 30

*/

 

위 예제에서 'sc1객체를 생성하는 것 같은' 코드를 살펴보겠습니다.

SimpleClass sc1();

 

그리고 main 함수 아래에 정의되어 있는 sc1함수를 살펴보겠습니다.

SimpleClass sc1(void)
{
	SimpleClass sc2(20, 30);

	return sc2;
}

 

위 함수는 함수명이 sc1이고, 매개변수로 받는 값은 없으며, 반환형은 SimpleClass 클래스입니다. 이를 통해 'sc1 객체를 생성하는 것 같았던' 코드는 사실 함수를 선언하는 것이었음을 알 수 있습니다.  따라서 객체를 생성할 때 인자를 아무것도 주지 않는다면 소괄호를 사용하지 말아야 합니다. 그렇지 않으면 컴파일러는 이게 함수 선언문인지, 객체를 생성하는 것인지 알 수 없게 됩니다.

 

저는 여기서 한 가지 궁금증이 생겼습니다. 그럼 다음과 같이 인자를 주는 객체 생성은 함수 선언과 혼동할 여지가 없는 것일까?

SimpleClass sc2(20, 30);

 

생각해보고 내린 답은 '위 코드는 명백한 객체 생성임을 컴파일러가 알 수 있다'입니다. 이유는 다음과 같습니다. 만약 위 코드가 정수형 매개변수 두 개를 받는 함수의 선언문이었다면 코드는 다음과 같았어야 합니다.

SimpleClass sc2(int val1, int val2);

 

생성자의 선언과 정의 분리

생성자를 사용하는 여러 가지 방법들을 살펴보겠습니다. 첫 번째 방법으로는 지금까지 설명한 것과 같은 방법입니다. 클래스를 선언할 때 클래스 명과 같은 이름을 가진, 반환형이 없는 함수를 정의해주는 것입니다.

 

하지만 프로그래밍을 하게 되면 분할 컴파일을 위해 코드를 여러 개의 헤더 파일과 소스 파일로 분할하게 됩니다. 클래스를 선언하는 코드는 헤더 파일로 들어가며, 클래스를 정의하는 코드는 소스 파일로 들어갑니다. 이때 생성자를 사용하는 방법을 다음의 예를 통해 알아보겠습니다. 점의 x좌표와 y좌표를 저장하고, 출력하는 클래스입니다.

//point.h 헤더파일로 저장
#ifndef POINT_H
#define POINT_H

class Point
{
private:
	int x;
	int y;

public:
	Point(int xpos = 0, int ypos = 0);
	void ShowPointInfo(void);
};

#endif
//point.cpp 소스파일로 저장
#include "point.h"
#include <iostream>

using std::cout;
using std::cin;
using std::endl;

Point::Point(int xpos = 0, int ypos = 0)
{
	x = xpos;
	y = ypos;
}

void Point::ShowPointInfo(void)
{
	cout << x << ' ' << y << endl;
}

 

생성자를 정의하는 것은 다른 멤버 함수를 정의하는 것과 방법이 같습니다.

 

멤버 이니셜 라이저

다음으로 어느 한 클래스1의 멤버로 다른 클래스가 있고, 그 다른 클래스2가 생성자를 가지고 있을 때, 클래스1을 생성할 때 클래스2의 생성자를 이용해 클래스2도 생성하는 방법입니다. 말로 하면 어렵습니다. 다음 두 개의 점을 이용해 선을 표현하는 클래스를 이용한 예를 살펴보겠습니다.

//point.h 헤더파일로 저장
#ifndef POINT_H
#define POINT_H

class Point
{
private:
	int x;
	int y;

public:
	Point(int xpos, int ypos);
	void ShowPointInfo(void);
};

#endif
//point.cpp 소스파일로 저장
#include "point.h"
#include <iostream>

using std::cout;
using std::cin;
using std::endl;

Point::Point(int xpos = 0, int ypos = 0)
{
	x = xpos;
	y = ypos;
}

void Point::ShowPointInfo(void)
{
	cout << x << ' ' << y << endl;
}
//line.h 헤더파일로 저장
#ifndef LINE_H
#define LINE_H
#include "point.h"

class Line
{
private:
	Point point1;
	Point point2;

public:
	Line(int xpos1, int ypos1, int xpos2, int ypos2);
	void ShowLineInfo(void);
};

#endif
//line.cpp 소스파일로 저장
#include "line.h"
#include <iostream>

using std::cout;
using std::cin;
using std::endl;

Line::Line(int xpos1 = 1, int ypos1 = 1, int xpos2 = 10, int ypos2 = 10)
	:point1(xpos1, ypos1), point2(xpos2, ypos2)
{
	//empty
}

void Line::ShowLineInfo(void)
{
	cout << "Point1의 좌표 : ";
	point1.ShowPointInfo();
	cout << "Point2의 좌표 : ";
	point2.ShowPointInfo();
}
//main.cpp 소스파일로 저장
#include "line.h"

int main(void)
{
	Line line1(1, 2, 4, 9);
	line1.ShowLineInfo();
}

/*
실행결과

Point1의 좌표 : 1 2
Point2의 좌표 : 4 9

*/

 

Line 클래스는 멤버로 Point 클래스를 두 개 가지고 있습니다. 그리고 Line 클래스는 생성자를 가지고 있습니다. 따라서 main 함수에서 line1 객체를 생성할 때 1, 2, 4, 9를 인자로 주어 생성자를 호출했습니다. 이 값은 각각 point1의 x좌표, y좌표, point2의 x좌표, y좌표에 해당됩니다. 따라서 이 x와 y 좌표를 매개변수로 받아서 point1, 2 객체를 초기화하게 됩니다.

 

어떤 방식으로 생성자를 이용해 point1과 point2 객체를 초기화하는지 Line 생성자를 살펴보겠습니다. 조금 특이하게 코드가 작성되어 있음을 한눈에 알 수 있습니다. 생성자의 몸체는 비어있으며 몸체와 머리 사이에 다음의 코드가 작성되어 있습니다.

:point1(xpos1, ypos1), point2(xpos2, ypos2)

 

이를 '멤버 이니셜 라이저'라고 부릅니다. 이것이 의미하는 바는 다음과 같습니다.

  • "객체 point1 생성과정에서 xpos1과 ypos1을 인자로 전달받는 생성자를 호출하라."
  • "객체 point2 생성과정에서 xpos2과 ypos2를 인자로 전달받는 생성자를 호출하라."

 

이렇게 멤버 이니셜 라이저는 멤버 변수로 선언된 객체의 생성자 호출에 활용됩니다.

 

멤버 이니셜 라이저를 사용하면 객체가 아닌 멤버 변수의 초기화에도 사용할 수 있습니다. 다음 예를 살펴보겠습니다.

class SoSimple
{
private:
	int num1;
	int num2;

public:
	SoSimple(int n1, int n2) :num1(n1), num2(n2)
	{
    
	}
};

 

num1과 num2는 객체가 아님에도 멤버 이니셜 라이저를 사용해 위와 같이 초기화할 수 있습니다.

 

이로써 멤버 변수를 초기화하는 두 가지 방법을 알게 되었습니다. 첫째는 생성자나 함수의 몸체에서 num1 = n1; 과 같이 값을 설정하는 방법과, 둘째는 멤버 이니셜 라이저를 이용한 방법입니다.

 

둘 중에 어떤 방법이 더 좋으냐 따지자면 첫째 방법이 좋을 것 같지만, 멤버 이니셜 라이저를 사용한 두 번째 방법이 더 효율적이고, 많은 프로그래머가 선호한다고 합니다.

 

멤버 이니셜 라이저를 통해 멤버 변수를 초기화하는 것이 함수나 생성자의 몸체에서 초기화하는 것보다 효율적인 이유를 다음의 예를 들어 설명하겠습니다.

//멤버 이니셜라이저 : num1(n1)을 사용하는 경우
int num1 = n1;

//함수나 생성자 몸체에서 num = n1;을 사용하는 경우
int num1;
num1 = n1;

 

멤버 이니셜 라이저를 통해 초기화되는 멤버 변수는 선언과 동시에 초기화가 이뤄지는 것과 같은 유형의 바이너리 코드를 구성합니다. 반면, 함수나 생성자 몸체에서 초기화하는 경우 num1 변수를 우선 선언하고, 이후 초기화하는 것과 같은 바이너리 코드를 구성합니다.

 

그리고 멤버 이니셜 라이저는 const 멤버 변수를 다룰 때에 특히나 유용합니다. 왜냐하면 const 멤버 변수는 선언과 동시에 초기화가 이루어져야 하기 때문입니다. 멤버 이니셜 라이저는 선언과 동시에 초기화가 이뤄지는 것과 같기 때문에 const 멤버 변수를 초기화할 때 유용합니다.

 

const 멤버 변수와 마찬가지로 '참조자'도 선언과 동시에 초기화가 이루어져야 합니다. 아래는 참조자 예시입니다.

int num1;
num1 = 100;
int &ref = num1;

 

따라서 멤버 이니셜 라이저를 사용하면 참조자도 멤버 변수로 선언될 수 있습니다.

 

디폴트 생성자

객체를 생성하면 반드시 하나의 생성자가 호출됩니다. 그런데 생성자가 없는 클래스를 이용해 객체를 생성하는 경우는 어떻게 될까요?

 

우리가 생성자를 만들지 않으면 컴파일 과정에서 '디폴트 생성자'가 자동으로 삽입됩니다. 디폴트 생성자는 '객체를 생성하면 반드시 하나의 생성자가 호출된다.'는 형식을 맞추기 위해 있을 뿐. 실제로는 아무런 일도 하지 않습니다.

 

이는 객체를 동적으로 할당할 때도 마찬가지입니다.

Point * ptr = new Point;

 

위와 같이 new 키워드를 사용해 객체를 동적으로 할당할 때에도 생성자는 호출됩니다. 만들어 둔 생성자가 없으면 디폴트 생성자가 호출됩니다.

 

그런데 C언어에서 배운 malloc함수를 이용하면 생성자는 호출되지 않습니다.

Point * ptr = (Point *)malloc(sizeof(Point));

 

위 코드에서 malloc 함수는 Point객체의 크기만큼의 메모리 공간을 할당하고 그 시작 주소를 반환할 뿐, 생성자를 호출하는 연산은 수행하지 않습니다. 이러한 이유로 객체를 동적 할당하려는 경우에는 반드시 new 키워드를 사용해야 합니다.

 

private 생성자 

앞서 설명한 생성자들은 모두 public으로 선언되었습니다. 그도 그럴 것이 객체의 생성은 클래스의 외부에서 진행되기 때문에, 클래스 외부에서 호출하게 될 생성자는 public으로 선언되어야 합니다.

 

그렇다면 클래스 안에서 객체를 생성할 경우에는 생성자를 private으로 선언해도 괜찮은 걸까요? 그렇습니다. 클래스 내부에서만 객체의 생성을 허용하려는 목적으로 생성자를 private으로 선언하기도 합니다.

 

다음의 예제를 통해 생성자의 publicd과 private 선언에 따른 차이점을 확인해 보겠습니다.

#include <iostream>

using std::cout;
using std::cin;
using std::endl;

class AAA
{
private:
	int num;
	AAA(int n) : num(n) {}

public:
	AAA() : num(0) {}
	AAA& CreateInitObj(int n) const
	{
		AAA* ptr = new AAA(n);
		return *ptr;
	}
	void ShowNum() const
	{
		cout << num << endl;
	}
};

int main(void)
{
	AAA base;
	base.ShowNum();

	AAA& obj1 = base.CreateInitObj(3);
	obj1.ShowNum();

	AAA& obj2 = base.CreateInitObj(12);
	obj2.ShowNum();

	delete& obj1;
	delete& obj2;

	return 0;
}

/*
실행결과

0
3
12

*/

 

AAA 클래스는 생성자를 두 개 가지고 있습니다. 하나는 private으로 선언되었으며 매개변수를 하나 갖습니다. 나머지 하나는 public으로 선언되었으며 매개변수를 받지 않습니다. 그리고 객체를 동적 할당으로 생성하고 이를 참조형으로 반환하는, public으로 선언된 함수가 있습니다.

 

처음 main 함수에서 base 객체를 생성할 때는 인자로 아무것도 주지 않았습니다. 따라서 public으로 선언된, 매개변수를 가지지 않은 생성자를 호출하고 num는 0이 됩니다.

 

이후에는 객체를 동적 할당으로 생성하는 함수를 호출하여 obj1과 obj2 객체를 생성합니다. 이때 동적 할당하면서 매개변수로 각각 3과 12를 주었습니다. public으로 선언된 생성자는 매개변수를 갖지 않기 때문에 이 생성자는 호출할 수 없습니다. private으로 선언된 생성자가 매개변수를 하나 갖기 때문에 이 생성자를 호출합니다. private으로 선언되었지만 클래스 내에서 객체를 동적 할당하기 때문에 이를 호출할 수 있습니다.

 

소멸자

객체 생성 시 반드시 호출되는 것이 생성자라면, 객체 소멸 시 반드시 호출되는 것은 '소멸자'입니다.

 

소멸자는 다음과 같은 특징들을 갖습니다.

  • 소멸자는 클래스의 이름 앞에 '~'가 붙은 형태의 이름을 갖습니다.
  • 반환형이 선언되어 있지 않으며, 실제로 반환하지 않습니다.
  • 매개변수는 void형으로 선언되어야 하기 때문에 오버로딩도, 디폴트 값 설정도 불가능합니다.

 

이 소멸자는 객체 소멸 과정에서 자동으로 호출됩니다. 그리고 프로그래머가 직접 소멸자를 정의하지 않으면, 디폴트 생성자와 마찬가지로 아무런 기능을 하지 않는 디폴트 소멸자가 자동으로 삽입됩니다.

 

즉, 다음의 클래스 정의는,

class AAA
{
	//empty
}

 

다음의 클래스 정의와 100% 동일합니다.

class AAA
{
public:
	AAA() {}
	~AAA() {}
}

 

이러한 소멸자는 대개 생성자에서 할당한 리소스의 소멸에 사용됩니다. 예를 들어서 생성자 내에서 new 키워드를 이용해 할당해 놓은 공간이 있다면, delete 키워드를 사용해 이 메모리 공간을 소멸해야 합니다.

 

이와 관련한 예제를 살펴보겠습니다.

#include <iostream>
#include <cstring>

using std::cout;
using std::cin;
using std::endl;

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

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

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

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

int main(void)
{
	Person man1("KOEY", 26);
	Person man2("홍길동", 54);

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

	return 0;
}

/*
실행결과

이름 : KOEY
나이 : 26
이름 : 홍길동
나이 : 54
called destructor.
called destructor.

*/

 

위 예제를 보면 Person 클래스의 생성자에서 name을 저장할 메모리 공간을 동적으로 할당합니다. 동적으로 할당한 메모리 공간은 직접 회수해주어야 하기 때문에 Person 객체가 소멸될 때 이 공간을 회수하기 위해 소멸자에서 delete를 사용했습니다.

 

여기서 저는 한 가지 궁금증이 생겼습니다. 생성자가 아닌 멤버 함수를 통해서 동적 할당으로 변수들을 생성하는 경우, 이런 경우에도 소멸자를 통해서 동적 할당된 메모리 공간을 회수할 수 있을까요?

 

저는 회수가 가능하기는 하나 제대로 사용될 수 없다고 생각합니다. 이유는 다음과 같습니다.

 

생성자를 통해 동적 할당한 메모리 공간은 쉽게 예측이 가능하고, 이를 통해 해당 메모리 공간을 다시 회수하는 데에 어려움이 없습니다. 하지만 생성자가 아닌 다른 멤버 함수를 호출하여 동적 할당하는 경우, 해당 함수가 얼마나 호출되어 얼마나 많은 메모리 공간을 할당하게 될지, 해당 메모리 공간의 주소는 어떻게 되는지 등등을 프로그래밍 단계에서 예측할 수가 없습니다.

 

만약 예측이 가능하다면 이 또한 소멸자에서 회수하는 것이 가능하겠지만, 예측이 불가하기 때문에 소멸자에서 회수하지 않고, main함수에서(main함수에서 동적 할당했다면) 직접 회수해주어야 합니다.