C++_복사생성자

최강림·2022년 12월 18일
0

C++개념정리

목록 보기
6/9
post-thumbnail

📌 개요

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

저번주에 만들어둔 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";
	}
};

📌멤버변수 동적할당

먼저 복사생성자에 대해 설명하기 전, 멤버변수의 동적할당에 대해 이해해야합니다.
코드를 만들다보면 멤버변수를 포인터 변수로 두고 동적할당을 해줘야할 때가 많습니다.

string* farmerName; //이러한 포인터변수로 선언된 멤버변수는 (line 32)과 같이 초기화하여 동적할당해줍니다.

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();
r2.rabbit_inform();

만약 r2=r1을 한 후 위의 코드로 멤버변수의 값을 출력해보면 다음과 같아요.

🖥️복사 후 출력
name : R1
year : 3
farmerName : Kim
name : R1
year : 3
farmerName : Kim

그냥 보기에는 r1의 값이 r2에 잘 들어가고 출력된 것 같습니다.
하지만 위에서 말했듯이 복사 이후의 두 객체는 서로 독립적이어야 "복사"했다고 말할 수 있습니다.
만약 r2의 farmerName을 역참조하여 값을 수정한다면 어떤 결과가 나올까요?

*r2.farmerName = "Lee";
r1.rabbit_inform();
r2.rabbit_inform();

만약 "깊은복사생성자"를 따로 만들지 않은채 r2=r1을 할 시 컴파일러는 기본복사생성자를 생성후 호출합니다. 이때 위의 코드의 결과는 다음과 같습니다.

🖥️얕은복사 출력
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 : 00AF7550
r2 farmerName : 00AF7550

만약 얕은 복사를 한다면 두 포인터가 같은 heap메모리 주소를 가리키고있기 때문에 문제가 되는것입니다.
이해가 되셨나요?

📖깊은복사생성자 만들기

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

말로 보면 헷갈리시죠? 아래의 코드가 위를 적용한 코드입니다.

//깊은 복사 생성자
Rabbit(const Rabbit& r) : Rabbit(r.year,r.name,*r.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))한 후 다른 오버로딩 생성자를 호출하는 것을 볼 수 있습니다.

여기서 핵심은 다음의 2가지입니다.
1. Rabbit(int y, string n, string fn)생성자에서 fn의 값으로 r.farmerName, 즉 역참조의 값이 넘어간다.
2. new string{fn}을 통하여 새로운 힙메모리 영역을 할당 후 fn값으로 초기화해준다.

또 이해하며 주의할 점은 복사생성자도 결국은 생성자를 오버로딩한것이라는 것을 느껴야합니다. 생각보다 많은 학생이 복사생성자를 C++의 새로운 기능으로 분리하여 생각하는데(특히 코딩을 막 배우는 분들), 그렇지 않고 복사생성자도 결국 생성자를 오버로딩한것이라는 것을 명심하세요.

이렇게 깊은복사생성자를 만든 후에는 r1,r2 모두 독릭접인 개체로써 동작하게됩니다.

r1.rabbit_inform();
r2.rabbit_inform();

cout<<'\n';

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

cout<<'\n';

*r2.farmerName = "Lee";
r1.rabbit_inform();
r2.rabbit_inform();

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

r1 farmerName : 00AF7550
r2 farmerName : 00AF7310

name : R1
year : 3
farmerName : Kim
name : R1
year : 3
farmerName : Lee

이상으로 깊은복사에 대한 설명을 마칩니다.

📌코드전문

//안녕하세요! 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개의 댓글