C++_복사생성자

최강림·2022년 11월 29일
0

이번 주는 많은 학생들이 헷갈려하는 복사 생성자에 대해 설명드릴꺼예요.
사실 포인터에 대해 기반이 탄탄하다면 그렇게 어려운 내용은 아니지만,
아직 포인터에 대해 완전한 이해가 되어있지 않으면 많이들 헷갈려하더라고요.

이전 목차에서 만들어둔 Rabbit클래스를 이용해 설명해보겠습니다.
Rabbit클래스는 아래와 같습니다. 가볍게 눈으로 슥 읽어주세요.

📌Rabbit클래스

class Rabbit {
private:
	int year;
	string name; 
public:
	string* farmerName; 

	Rabbit(int y, string n, string fn) : year{ y }, name{ n }{
		farmerName = new string{ fn }; //동적할당 후 farmerName초기화
		cout << "Rabbit has year and name!\n";
	}
	//Rabbit(const Rabbit& r) : Rabbit(r.year,r.name,*r.farmerName) {} //깊은 복사 생성자, 풀으라고 할때 주석 풀으세요.
	Rabbit() : Rabbit(0, "None", "None") {}
	
	void rabbit_inform();

	~Rabbit() {
		delete farmerName; //farmerName 동적할당 해제
		cout << name << " is dead\n";
	}
};

void Rabbit::rabbit_inform() {
	cout << "name : " << name << '\n';
	cout << "year : " << year << '\n';
	cout << "farmerName : " << *farmerName << '\n';
}

📌멤버변수 동적할당

🖥️생성자를 이용한 동적할당

먼저 복사생성자에 대해 설명하기 전, 멤버변수의 동적할당에 대해 이해해야합니다.
Rabbit클래스의 일부인 string포인터 변수 farmerName을 봅시다.

//코드를 만들다보면 멤버변수를 포인터 변수로 두고 동적할당을 해줘야할 때가 많습니다.
//이러한 포인터변수로 선언된 멤버변수는 일반적으로 생성자를 이용하여 동적할당해줍니다.
private:
string* farmerName; 
public:
Rabbit(int y, string n, string fn) : year{ y }, name{ n }{
	farmerName = new string{ fn }; //동적할당 후 farmerName초기화
	cout << "Rabbit has year and name!\n";
}

메인함수에서 동적할당할 때와 크게 다르지 않아요.
메인함수에서 Rabbit객체가 생성되면?
생성자의 호출과 동시에 객체의 멤버변수 year, name이 초기화되고
farmerName을 동적할당과 동시에 초기화해줍니다.

🖥️소멸자를 이용한 할당해제

한가지 유의해야 할점은 이렇게 동적할당이 된 힙메모리값은
만약 객체가 어떤사유로든 할당해제가 된다면 같이 힙메모리도 해제되어야 합니다.
그렇지 않으면 메모리 누수가 생기거든요.
논리적으로 생각해도 객체가 없어졌는데, farmerName이 계속 할당되어있어야 할 이유도 없겠죠?
우리는 이것을 소멸자를 이용하여 구현합니다.

~Rabbit() {
	delete farmerName; //farmerName 동적할당 해제
	cout << name << " is dead\n";
}

동적할당이 필요한 경우 생성자에 동적할당을 하자마자 미리 소멸자에 할당해제 코드를 써주는 습관을 들여주세요.
멤버변수의 동적할당은 이정도면 충분하다고는 생각하는데, 처음에는 좀 헷갈릴 수 있습니다.

📌복사생성자

🖥️개요

객체는 다양한 상황에 복사가 됩니다.
가장 간단한 예시로는 아래가 있습니다.

Rabbit r1{ 3,"R1","Kim"};
Rabbit r2 = r1; //r1객체를 r2에 복사

이렇게 복사하면 컴파일할 때 임의의 복사생성자가 생성되어
객체 r1의 데이터(=멤버변수 값들)가 새로운 객체 r2에 복사가 되어요.
r2에 저장된 값들 을 봐볼까요?

🖥️ r2.rabbit_inform() 호출 결과 :
name : R1
year : 3
farmerName : Kim

🖥️깊은복사가 필요한 이유

r2 = r1은 r1의 값을 r2에 "복사"한다는 의미에 문제가 없어보입니다.
하지만 만약 Rabbit클래스에 포인터타입으로 동적할당하여 사용하는 멤버변수가 있다면 얘기가 달라집니다. (String *farmerName)

r2 = r1과 같은 복사대입은 말그대로 r1에 있는 멤버변수들의 "값 그대로"를 r2의 멤버변수들에 복사하여 넘겨줍니다. 이를 얕은복사라고 합니다.
이것이 왜 문제가 되는가 살펴볼까요?

우리가 흔히 "복사"한다는 행위는 어느 한 대상에게 다른 대상의 정보를 원본 그대로 옮기는 행위를 말합니다.
하지만 복사를 한 그 "이후"는 어떤가요?

위에서 선언한 객체 r2와 r1은 데이터값만 같을 뿐 엄현히 다른 객체로서,
서로 "독립적"이어야 합니다. 서로 영향을 끼치면 안돼요.

r1.rabbit_inform();
cout << '\n';
r2.rabbit_inform();
cout << "잘 복사된 것 같지만 아님!\n\n";

🖥️출력결과
name : R1
year : 3
farmerName : Kim

name : R1
year : 3
farmerName : Kim
잘 복사된 것 같지만 아님!

결과문을 보면 r2는 r1의 데이터 값이 잘 복사된 것 처럼 보입니다.
하지만 위에서 말했듯 복사된 객체는 반드시!! "독립적"이어야 합니다.
아래의 결과문을 봅시다.

*r2.farmerName = "Lee";
r1.rabbit_inform();
cout << '\n';
r2.rabbit_inform();

🖥️출력결과
name : R1
year : 3
farmerName : Lee

name : R1
year : 3
farmerName : Lee

r2의 farmerName을 바꿨을 뿐인데, r1의 farmerName도 바뀐게 보이시나요?
즉 이들은 현재 종속적인 관계라는 말입니다.

🖥️얕은복사의 문제점 파악

어째서 이런일이 일어난 것일까요?
그 비밀은 r2 = r1을 할 때 데이터가 "그대로 복사"된다는 점에 숨어있습니다.

먼저 동적할당에 대해 복습해봅시다.
"farmerName = new string;" 을 하면 farmerName에는 무슨 데이터값이 들어갈까요?
바로 heap메모리의 "주소값"이 들어가게 됩니다. 우리는 이렇게 할당된 heap메모리를 역참조하여 포인터를 사용합니다.
어떤 포인터든 할당된 주소값만 알면 그 메모리에 접근할 수 있어요.
포인터에 대해 좀 기억이 나시나요?

이 점이 이해가 되신다면 우리는 위 같은 복사의 문제점을 알 수 있습니다.
아래의 코드를 봅시다.

cout << "r1 farmerName : " << r1.farmerName << '\n';
cout << "r2 farmerName : " << r2.farmerName << '\n';

🖥️출력결과
r1 farmerName : 012C0BC8
r2 farmerName : 012C0BC8

얕은 복사에 의해 r2에는 r1.farmerName에 할당된 주소값이 그대로 복사가 되고 이는 역참조의 과정에서 r1과 r2의 farmerName의 관계가 종속적일 수 밖에 없는 이유입니다.

🖥️깊은복사

이러한 문제점을 해결하기 위해 우리는 클래스에 깊은 복사를 하는 "복사생성자"라는 것을 만들어줍니다.
깊은복사생성자는 다음의 알고리즘으로 코드를 짭니다.

  1. r2가 복사될 때 생성자를 호출해 r2.farmerName에 새로운 heap메모리를 할당함.

  2. r2.farmerName을 역참조하여 r1.farmerName의 역참조 값을 복사함.

말로 보면 헷갈리시죠?
아래의 복사생성자를 보며 차근차근 이해해봅시다.

Rabbit(int y, string n, string fn) : year{ y }, name{ n }{
	farmerName = new string{ fn }; //동적할당 후 farmerName초기화
	cout << "Rabbit has year and name!\n";
}
Rabbit(const Rabbit& r) : Rabbit(r.year,r.name,*r.farmerName) {}

복사생성자는 생성자를 오버로딩한 형태입니다.
아래의 "Rabbit&"을 인자로 받는 Rabbit생성자가 바로 복사생성자예요.
복사생성자를 만들지 않았다면, 컴파일러는 자체적으로 멤버변수들의 값을 그대로 복사해주는 기본복사생성자를 생성해주는데, 이것은 얕은복사를 기반으로 만들어진 생성자입니다.

따라서 우리는 멤버변수에 포인터변수가 있다면, 깊으복사생성자를 만들어줘야 합니다.

위 코드를 보시면 r2 = r1을 할 때 r2객체는 "Rabbit(const Rabbit& r)" 생성자를 호출합니다. (이 때 인자 r값은 r1입니다.) 코드에서 알 수 있듯이 이 복사생성자는 생성자를 호출해주며, r1의 farmerName 역참조값을 생성자의 인자로 넘겨주며, r2는 새로운 힙메모리 영역을 할당받고 초기화하게 됩니다.

그래도 이해가 안가신다면 코드를 써보고 디버그해보시면 이해가 더욱 잘됩니다.

잘 이해가 가셨나요? 안되시면 댓글남겨주세요. 이상으로 설명 마치겠습니다.

📌코드 전문

//안녕하세요! WHO 스터디부장 최강림입니다!
//저번주에 공지한대로 저도 이제 슬슬 시험공부를 해야하는 관계로 
//다음 개념정리는 중간끝나고 다시 제공해드릴꺼예요. 
//제 개념정리가 얼마나 도움이 될지는 알 수 없으나 하나라도 무언가 얻어가는게 있었으면 좋겠네요!
//서론이 길었네요. 5주차 개념정리 시작하겠습니다.

//I1 - 이번주차 소개
/**
이번 주는 많은 학생들이 헷갈려하는 복사 생성자에 대해 설명드릴꺼예요.
사실 포인터에 대해 기반이 탄탄하다면 그렇게 어려운 내용은 아니지만,
아직 포인터에 대해 완전한 이해가 되어있지 않으면 많이들 헷갈려하더라고요.

저번주에 만들어둔 Rabbit클래스를 이용해 설명해보겠습니다. 
복습겸 클래스를 눈으로 대충 살펴보면서 메인함수를 한번 봐보실까요? (line 74)
*/

#include <iostream>

using namespace std;
class Rabbit {
private: 
	int year;
	string name; //이번에는 정석대로 name을 private에 넣을께요
public: 
	//설명의 편의를 위해 public으로 놓겠습니다.
	//코드를 만들다보면 멤버변수를 포인터 변수로 두고 동적할당을 해줘야할 때가 많습니다.
	string* farmerName; //이러한 포인터변수로 선언된 멤버변수는 (line 32)과 같이 초기화하여 동적할당해줍니다.

	//생성자
	Rabbit(int y, string n, string fn) : year{ y }, name{ n }{
		/**
		메인함수에서 동적할당할 때와 크게 다르지 않아요.
		메인함수에서 Rabbit객체가 생성되면?
		생성자의 호출과 동시에 객체의 멤버변수 year, name이 초기화되고
		farmerName을 동적할당과 동시에 초기화됩니다.
		*/
		farmerName = new string{ fn }; //동적할당 후 farmerName초기화
		cout << "Rabbit has year and name!\n"; 

		/**
		한가지 유의해야 할점은 이렇게 동적할당이 된 힙메모리값은
		만약 객체가 어떤사유로든 할당해제가 된다면 같이 힙메모리도 해제되어야 합니다.
		그렇지 않으면 메모리 누수가 생기거든요.
		논리적으로 생각해도 객체가 없어졌는데, farmerName이 계속 할당되어있어야 할 이유도 없겠죠?
		우리는 이것을 소멸자를 이용하여 구현합니다. (line 57)
		*/
	}
	Rabbit(const Rabbit& r) : Rabbit(r.year,r.name,*r.farmerName) {} //깊은 복사 생성자, 풀으라고 할때 주석 풀으세요.
	/*복사생성자는 매개변수로 클래스타입의 참조자를 가지는 생성자입니다.
	디버그하여 그 과정을 잘 살펴본 후 복사생성자의 작동과정을 살펴보세요.
	이해하는데 중요한 핵심은 복사생성자도 결국은 생성자를 오버로딩한것이라는 것을 느껴야합니다.
	디버그한후 다시 (line 160)으로 가세요*/
	Rabbit() : Rabbit(0,"None","None") {}
	//생성자 위임을 이용해 기본생성자도 Rabbit() : Rabbit(0,"None") 으로 바꾸는게 좋겠죠?


	//메소드(=멤버함수)
	void rabbit_inform();

	~Rabbit() { 
		delete farmerName; //farmerName 동적할당 해제
		//동적할당이 필요한 경우 생성자에 동적할당을 하자마자 미리 소멸자에 할당해제 코드를 써주는 습관을 들여주세요.
		cout << name << " is dead\n"; 
		/**
		멤버변수의 동적할당은 이정도면 충분하다고는 생각하는데, 처음에는 좀 헷갈릴 수 있습니다.
		이해해보시고 도저히 안되겠다 싶으면 질문해주세요!
		메인함수의 (line 81)를 봅시다.
		*/
	} 
};

void Rabbit::rabbit_inform() { 
	cout << "name : " << name << '\n';
	cout << "year : " << year << '\n';
	cout << "farmerName : " << *farmerName << '\n';
}

int main() {
	//I4 - 멤버변수 동적할당
	/**
	먼저 복사생성자에 대해 설명하기 전, 멤버변수의 동적할당에 대해 이해해야합니다.
	(line 25)의 string포인터 변수 farmerName을 봅시다.
	*/

	cout << "**복사생성자 개요\n";
	//I3 - 복사생성자 개요
	/**
	객체는 다양한 상황에 복사가 됩니다.
	가장 간단한 예시로는 아래가 있습니다.
	*/
	Rabbit r1{ 3,"R1","Kim"};
	Rabbit r2 = r1; //r1객체를 r2에 복사
	/**
	이렇게 복사하면 컴파일할 때 임의의 복사생성자가 생성되어
	객체 r1의 데이터(=멤버변수 값들)가 새로운 객체 r2에 복사가 되어요.
	r2에 저장된 값들 을 봐볼까요?
	*/
	r2.rabbit_inform(); //Sarah is 3 years old

	//I5 - 깊은복사와 얕은복사
	/**
	r2 = r1은 r1의 값을 r2에 "복사"한다는 의미에 문제가 없어보입니다.
	하지만 만약 Rabbit클래스에 포인터타입으로 동적할당하여 사용하는 멤버변수가 있다면 얘기가 달라집니다. (String *farmerName)

	r2 = r1과 같은 복사대입은 말그대로 r1에 있는 멤버변수들의 값 그대로를 r2의 멤버변수들에 복사하여 넘겨줍니다.
	이것이 왜 문제가 되는가 살펴볼까요?

	우리가 흔히 "복사"한다는 행위는 어느 한 대상에게 다른 대상의 정보를 원본 그대로 옮기는 행위를 말합니다.
	하지만 복사를 한 그 "이후"는 어떤가요?

	위에서 선언한 객체 r2와 r1은 데이터값만 같을 뿐 엄현히 다른 객체로서,
	서로 "독립적"이어야 합니다. 서로 영향을 끼치면 안돼요.
	*/
	//아래의 코드를 봅시다.
	cout << "\n**얕은복사 설명\n";
	r1.rabbit_inform();
	r2.rabbit_inform();
	cout << "잘 복사된 것 같지만 아님!\n\n";
	//결과문을 보면 r2는 r1의 데이터 값이 잘 복사된 것 처럼 보입니다.
	//하지만 위에서 말했듯 복사된 객체는 반드시!! "독립적"이어야 합니다.
	// 아래의 결과문을 봅시다.
	*r2.farmerName = "Lee";
	r1.rabbit_inform();
	r2.rabbit_inform();
	//r2의 farmerName을 바꿨을 뿐인데, r1의 farmerName도 바뀐게 보이시나요? 즉 이들은 현재 종속적인 관계라는 말입니다.

	//I5 - 얕은복사에 대한 이해
	/**
	어째서 이런일이 일어난 것일까요?
	그 비밀은 r2 = r1을 할 때 데이터가 그대로 복사된다는 점에 숨어있습니다.

	먼저 동적할당에 대해 복습해봅시다.
	farmerName = new string; 을 하면 farmerName에는 무슨 데이터값이 들어갈까요?
	바로 heap메모리의 "주소값"이 들어가게 됩니다. 우리는 이렇게 할당된 heap메모리를 역참조하여 포인터를 사용합니다.
	어떤 포인터든 할당된 주소값만 알면 그 메모리에 접근할 수 있어요.
	포인터에 대해 좀 기억이 나시나요? 

	이 점이 이해가 되신다면 우리는 위 같은 복사의 문제점을 알 수 있습니다.
	아래의 코드를 봅시다.
	*/

	cout << "\n**얕은 복사의 문제점\n";
	cout << "r1 farmerName : " << r1.farmerName << '\n';
	cout << "r2 farmerName : " << r2.farmerName << '\n';
	//두 포인터가 같은 heap메모리 주소를 가리키고있기 때문에 문제가 되는것입니다.
	//이해가 되셨나요? 안되셨다면 팀원들과 논의해보거나 저에게 개인적인 질문해주세요.

	//I5 - 깊은복사에 대한 이해
	/**
	이러한 문제점을 해결하기 위해 우리는 클래스에 깊은 복사를 하는 "복사생성자"라는 것을 만들어줍니다.
	깊은복사생성자는 다음의 알고리즘으로 코드를 짭니다.
	1. r2가 복사될 때 복사생성자를 호출해 r2.farmerName에 새로운 heap메모리를 할당함.
	2. r2.farmerName을 역참조하여 r1.farmerName의 역참조 값을 복사함. 

	말로 보면 헷갈리시죠? (line 48)의 주석을 풀어본 후 브레이크를 걸고 디버그를 해보세요.
	결과값을 찬찬히 보시면 r1과 r2가 독릭접으로 사용이되고,
	각자의 farmerName에도 다른 메모리 주소값이 들어간 것을 확인할 수 있어요. 신기하죠?
	반드시 디버그 해서 어떻게 작동하는지 봐보세요.

	그렇다면 복사생성자란 무엇일까요? 어떻게 이용해야할까요?
	복사생성자란 매개변수로 Rabbit& , 즉 자신의 클래스를 참조자로 받는 생성자예요.
	말이 복사생성자지 또 하나의 생성자를 오버로딩한겁니다.

	r2 = r1을 할 때 생성자는 r2.Rabbit(r1)을 호출한다~ 라고 이해해주세요.
	우리가 복사생성자를 만들지 않으면 r2 = r1을 할때 기본복사생성자를 자동으로 생성하여 컴파일됩니다.
	하지만 이 기.복.생은 얕은 복사를 기반으로 하여 멤버변수의 값을 그대로 복사해주기만해요.
	때문에 우리는 포인터 변수를 멤버변수로 가질 때 깊은복사를 하는 복사생성자를 하나 반드시 만들어줘야합니다.
	*/

	/**
	지금까지 복사생성자에 대해 설명을 마치겠습니다.
	형기 교수님 pdf에는 다양한 키워드에 대한 설명도 포함되어있으나, 나중에 필요하면 따로 설명드리겠습니다.
	혹시 궁금하신분은 검색해보셔도 좋아요. this키워드는 개념탄탄에 있습니다.
	(const, static, friend, this)
	*/

	//이상으로 정리 마치겠습니다!

	cout << "\n**소멸자\n";
}
profile
개발잘하고싶다

0개의 댓글