개발하는 리프터 꽃게맨입니다.

[C++] 클래스 생성자 (feat. 복사 생성자, 얕은 복사 & 깊은 복사) 본문

언어/C, C++

[C++] 클래스 생성자 (feat. 복사 생성자, 얕은 복사 & 깊은 복사)

파워꽃게맨 2023. 12. 27. 23:42

1. 생성자란?

생성자(constructor)란 클래스에서 해당 타입의 객체를 초기화하는 방법을 정의하는 특별한 멤버 함수를 의미합니다.

생성자에서는 객체가 선언되었을 때 멤버 변수를 초기화하거나, 동적 할당을 하는 등 초기에 해줘야 할 행동들을 명시합니다.

 

기본적인 형태는 다음과 같습니다.

/* 학생의 이름, 나이, 키를 저장하는 클래스 */
class Student
{
//참고: class의 기본적인 접근 제어자는 'private' 입니다.
//		생성자는 무조건적으로 public으로 지정한 뒤 정의하도록 합니다.
public:
	Student() {}


private:
	string	_name;
	int	_age;
	float	_height;
};

 

위 형태처럼 인자를 아무것도 넣지 않은 상태의 생성자를 '기본 생성자'라고 합니다.

class Student
{
public:
	Student(string n, int a, float h)
		: _name(n)
		, _age(a)
		, _height(h)
	{
		
	}


private:
	string	_name;
	int		_age;
	float	_height;
};

int main()
{
	Student st("CrabMan", 20, 181.5);

}

이런 식으로 '인자'를 이용해서 생성자를 정의할 수도 있습니다.

 

생성자는 기본적으로 클래스와 이름이 같고, 반환값이 없습니다.

또한 생성자는 하나의 클래스에 여럿 존재할 수 있습니다. (오버로딩)

다른 멤버 함수와 달리 생성자는 const로 선언할 수 없습니다.

 

2. 생성자 초기화 (초기화와 대입의 구분)

이제 생성자를 이용해서 '멤버 변수'들을 초기화해보도록 하겠습니다.

 

class Student
{
public:
	Student() {
		_name = "No Name";
		_age = 0;
		_height = 0.0f;
	}


private:
	string	_name;
	int		_age;
	float	_height;
};

 

대표적인 생성자의 잘못된 예입니다.

생성자 블록 안에 초기화 하고자하는 값을 '대입'하고 있는데, 이는 대입이지 초기화가 아닙니다.

그렇기 때문에 위 방식대로하면 '초기화'를 무조건 해야 하는 const, & 변수의 경우 컴파일 에러가 발생합니다.

 

class Student
{
public:
	Student() {
		_name = "No Name";
		_age = 0;
		_height = 0.0f;
		_sex = 'M'; //const 자료형에는 대입을 할 수 없다.
		_realname = _name; //참조 또한 초기화가 필요하다.
	}


private:
	string	_name;
	int		_age;
	float	_height;
	const int _sex; //성별을 나타내는 const 데이터
	string& _realname; //실명을 나타내는 레퍼런스 데이터
};

 

그렇기 때문에 : (콜론)을 이용해서 초기화해주어야 합니다.

 

class Student
{
public:
	Student()
		: _name("No Name.")
		, _age(0)
		, _height(0)
		, _sex (0)
		, _realname(_name)
	{
		
	}


private:
	string	_name;
	int		_age;
	float	_height;
	const int _sex; //성별을 나타내는 const 데이터
	string& _realname; //실명을 나타내는 레퍼런스 데이터
};

사용법 자체가 조금 어색할 수도 있지만, '대입'과 '초기화'는 확실히 구분되어야 하기 때문에

이런 식으로 초기화를 해줘야만 합니다.

 

3. 기본 생성자

class Student
{
public:
	Student() {} //생략해도 개체 생성에 문제가 되지 않음


private:
	string	_name;
	int	_age;
	float	_height;
};

int main()
{
	Student st2;
}

아무런 인자를 받지않는 생성자를 '기본 생성자'라고 하며,

생성자를 명시적으로 정의하지 않았을 경우에는

컴파일러가 자동적으로 '기본 생성자'를 만들어줍니다.

 

그렇기에 위 코드에서 '기본 생성자'를 지워버려도 아무런 문제가 발생하지 않습니다.

class Student
{
public:
	Student(string n, int a, float h)
		: _name(n)
		, _age(a)
		, _height(h)
	{
		
	}


private:
	string	_name;
	int		_age;
	float	_height;
};

int main()
{
	Student st1("CrabMan", 20, 181.5);
	Student st2; //오류가 납니다.
}

 

그러나 위 코드에선 오류가 납니다.

왤까요?

 

컴파일러가 만들어주는 '기본 생성자'는 

클래스에 생성자가 아예 없는 경우에만 만들어집니다.

 

위처럼, 사용자 지정 생성자를 하나 이상 만든 경우에는 '기본 생성자'를 사용할 수 없습니다.

class Student
{
public:
	Student(string n, int a, float h)
		: _name(n)
		, _age(a)
		, _height(h)
	{
	}

	Student() {};


private:
	string	_name;
	int		_age;
	float	_height;
};

int main()
{
	Student st1("CrabMan", 20, 181.5);
	Student st2; //오류가 나지 않습니다.
}

이런 식으로 '기본 생성자'를 사용하고 싶다면, 직접 만들어줘야 합니다.

(참고로 기본 생성자는 객체만 생성해줄뿐 아무런 동작도 하지 않으므로, 기본 생성자로 만들어진 객체의 멤버들은

모두 쓰레기 값을 가지고 있습니다.)

 

4. 생성자 오버로딩

생성자는 오버로딩이 가능합니다.

오버로딩은 하나의 함수가 다수의 기능을 구현하는 것을 뜻합니다.

(오버라이딩과는 다른 개념입니다.)

 

생성자 오버로딩을 하려면

생성자의 이름이 같고, 매개변수의 개수나 타입이 달라야 합니다.

(바로 위 예시 또한 생성자 오버로딩의 예입니다.)

 

그러면 상당히 다양한 생성자를 설계할 수 있습니다.

class Student
{
public:
	Student() //생성자 1번
	{
		cout << "기본 생성자 호출" << endl;
	};

	Student(string n) //생성자 2번
		: _name(n)
	{
		cout << "이름만 설정하는 생성자 호출" << endl;
	}

	Student(string n, int a, float h) //생성자 3번
		: _name(n)
		, _age(a)
		, _height(h)
	{
		cout << "모든 멤버 변수를 설정하는 생성자 호출" << endl;
	}

private:
	string	_name;
	int		_age;
	float	_height;
};

int main()
{
	Student st1; //생성자 1번 호출
	Student st2("Crab"); //생성자 2번 호출
	Student st3("Man", 20, 180.5); //생성자 3번 호출
}

Student 개체를 생성하는 모습을 보면

어떤 방식으로 호출하냐에 따라 다른 생성자가 호출되는 것을 확인할 수 있습니다.

 

5. 복사 생성자

class Point
{
public:
	Point()
		: _x(0)
		, _y(0)
	{}

	Point(int x, int y)
		: _x(x)
		, _y(y)
	{}

private:
	int _x;
	int _y;
};

int main()
{
	Point pt1(10, -10);
	Point pt2(pt1);
}

간단한 2차원 좌표값을 저장하는 클래스를 만들어봤습니다.

main 함수쪽을 보시면 

pt2 = pt1 처럼 Point 형으로 초기화를 시도하고 있습니다.

그러면 어떻게 동작할까요?

pt2._x = pt1._x;
pt2._y = pt1._y;

마치 이러한 코드가 실행되는 것처럼

 

pt2의 _x 에는 pt1._x의 값이 복사되고

pt2의 _y 에는 pt1._y의 값이 복사됩니다.

 

아주 똑똑하게도 컴파일러가 알아서 멤버 변수를 옮겨주는 것이지요.

위 코드처럼 동일한 클래스에 대해서 같은 클래스 객체로 초기화하는 생성자를

'복사 생성자'라고 합니다.

 

명시적으로 정의하면 다음과 같습니다.

	Point(const Point& other)
		: _x(other._x)
		, _y(other._y)
	{
	}

참고로, 위 코드처럼 인자에 대해서 '읽기' 행위만 할 것이고 '쓰기' 행위를 하지 않는다면

명시적으로 const를 붙여주는 것이 좋습니다.

 

6. 암시적인 복사 생성자

복사 생성지는 개발자가 명시적으로 작성하지 않아도 자동으로 컴파일러가 만들어줍니다.

class Point
{
public:
	Point()
		: _x(0)
		, _y(0)
	{}

	Point(int x, int y)
		: _x(x)
		, _y(y)
	{}

private:
	int _x;
	int _y;
};

int main()
{
	Point pt1(10, -10);
	Point pt2(pt1);
}

 

이 예시에서 '복사 생성자'를 정의하지 않았지만 제대로 동작하는 것을 볼 수 있습니다.

이것을 '암시적인 복사 생성자'라고 합니다.

 

그런데 다음 코드를 한 번 보겠습니다.

 

class Point
{
public:
	Point()
		: _x(0)
		, _y(0)
	{}

	Point(int x, int y)
		: _x(x)
		, _y(y)
	{}

	Point(const Point& other)
		: _x(other._x)
		, _y(other._y)
	{
	}

public:
	int _x;
	int _y;
};

class PointManager
{
public:
	PointManager()
		: _point(nullptr)
	{}

	~PointManager()
	{
		if (_point != nullptr)
		{
 			delete _point;
			_point = nullptr;
		}
	}

	void AddPoint(Point pt)
	{
		Point* newpt = new Point(pt);
		_point = newpt;
	}

public:
	Point* _point;
};

int main() {
	PointManager* pt1 = new PointManager;
	pt1->AddPoint(Point(10, 10));
	
	PointManager* pt2 = new PointManager(*pt1);
	
	cout << pt2->_point->_x << endl;

	delete pt1;

	cout << pt2->_point->_x << endl;

	return 0;
}

PointManager 라 하여 Point 포인터를 멤버 변수로 관리하는 클래스입니다.

pt1은 AddPoint 함수를 통해서 Point 객체를 가지고 있고

pt2는 복사 생성자를 통해 생성되었습니다.

 

해당 PointManager 클래스는 생성자가 명시되어 있지 않기에, 암시적인 복사 생성자를 사용합니다.

 

그리고 pt1을 삭제할 것인데, pt1 삭제 전후로 pt2의 값을 출력할 것입니다.

 

이 코드의 출력 결과는 어떻게 될까요?

제대로 된 값이 나오지 않습니다.

 

분명 pt1을 삭제했고, pt2는 가만히 내버려두었는데 왜 pt2의 값이 이상하게 변했을까요?

그것은 '암시적인 복사 생성자'는 기본적으로 '얕은 복사'를 수행하기 때문입니다.

 

7. 얕은 복사 vs 깊은 복사

포인터에 대해서 숙련되어 있는 개발자라면, 왜 위 코드가 오류를 발생시키는지 이해할 수 있을 겁니다.

기본 복사 생성자는 복사하고자 하는 개체의 멤버 변수의 값을

하나하나 차례대로 가져와서 새로운 개체의 멤버 변수에 복사합니다.

 

PointManager의 _point는 힙 메모리의 주소를 값으로 가지고 있고, 이 값을 그대로 복사했으니

pt1과 pt2는 '같은 주소'를 가리키고 있던 것입니다.

 

그렇기에 pt1을 삭제해서 해당 주소의 할당을 해제해 버리면,

pt2의 _point가 가리키고 있던 값 또한 소멸되는 것이지요.

 

이것이 '얕은 복사'입니다.

단순하게 모든 값을 복사해오는 것.

 

얕은 복사의 문제점은

'포인터를 복사해오는 경우 같은 주소를 가리키도록 할 수 있다.'

였죠?

 

그러면, 복사 생성자에 새로운 메모리를 할당하여 값을 복사하는 문장을 추가하면

이런 문제를 해결할 수 있습니다.

	PointManager(const PointManager& other)
	{
		_point = new Point;
		memcpy(_point, other._point, sizeof(Point));
	}

 

이런 식으로 말이죠.

이것이 '깊은 복사' 입니다.

 

힙 메모리를 사용하는 경우

예상치못한 포인터가 같은 주소를 가리키지 않도록 방지하는 기법이죠. 

 

'깊은 복사 생성자'를 추가한 것을 제외하면 완전히 똑같은 코드이지만

원하는대로 출력됩니다.

 

8. 얕은 복사 vs 깊은 복사 (전체 코드)

#include <iostream>
using namespace std;

class Point
{
public:
	Point()
		: _x(0)
		, _y(0)
	{}

	Point(int x, int y)
		: _x(x)
		, _y(y)
	{}

	Point(const Point& other)
		: _x(other._x)
		, _y(other._y)
	{
	}

public:
	int _x;
	int _y;
};

class PointManager
{
public:
	PointManager()
		: _point(nullptr)
	{}

	~PointManager()
	{
		if (_point != nullptr)
		{
 			delete _point;
			_point = nullptr;
		}
	}

	PointManager(const PointManager& other)
	{
		_point = new Point;
		memcpy(_point, other._point, sizeof(Point));
	}

	void AddPoint(Point pt)
	{
		Point* newpt = new Point(pt);
		_point = newpt;
	}

public:
	Point* _point;
};

int main() {
	PointManager* pt1 = new PointManager;
	pt1->AddPoint(Point(10, 10));
	
	PointManager* pt2 = new PointManager(*pt1);
	
	cout << pt2->_point->_x << endl;

	delete pt1;

	cout << pt2->_point->_x << endl;

	return 0;
}

 

깊은 복사, 얕은 복사 예시를 억지로 만들려고 하다보니

제가 생각해도 코드가 조금 난해한 감이 있습니다.

양해부탁드립니다.

 

9. 마치며

가볍게 생성자, 초기화와 대입의 구분 정도만 다루고 싶었는데,

생성자를 다루자니 복사 생성자를 안다룰 순 없고,

복사 생성자를 다루자니 얕은 복사, 깊은 복사를 안다룰 순 없어서

생각보다 포스팅의 볼륨이 커졌습니다.

 

제가 생각해도 글이 조금 난해한 감이 있습니다만,

혹시나 읽으시고 이해한가는 부분 있다면 댓글남겨주세요.