C++/C++ 기초

[C++] 복사 생성자 (Copy constructor) - 얕은 복사 vs 깊은 복사

Song 컴퓨터공학 2023. 7. 9. 21:16

복사(Copy) 란 원본과 동일한 별개의 사본을 만드는 것으로 정의되는데, 크게 2가지로 나뉩니다. 복사의 2가지 차이를 먼저 알아야 복사 생성자를 이해할 수 있기 때문에 이 분류에 대해 먼저 다뤄보겠습니다.

 

 얕은 복사 vs 깊은 복사 (Shallow copy vs Deep copy)

 

얕은 복사와 깊은 복사의 차이점은 객체의 멤버 변수에 동적 메모리가 할당된 경우 나타나게 됩니다. 

 

얕은 복사(Shallow copy)

  • 객체 복사 시, 객체의 멤버를 1:1로 복사
  • 객체의 멤버 변수에 동적 메모리가 할당된 경우 → 사본과 원본이 동일한 동적 메모리 공유

깊은 복사(Deep copy)

  • 객체 복사 시, 객체의 멤버를 1:1로 복사
  • 객체의 멤버 변수에 동적 메모리가 할당된 경우 → 사본은 원본이 가진 메모리 크기만큼 별도로 동적 할당한 이후 원본의 동적 메모리에 있는 내용을 사본에 복사 (=완전한 형태의 복사. 사본과 원본 메모리 공유 X)

얕은 복사를 하는 경우 하나의 동적 메모리 공간에 대해 2개 객체가 서로 그 동적 메모리 공간이 자신의 멤버라고 인식되는 문제가 생깁니다. 이 문제가 생기면 만약 A라는 객체를 복사해서 B를 만든 이후 B의 동적 메모리 공간의 값을 바꾸면 사실 하나의 공간을 공유하고 있기 때문에 A의 동적 메모리 공간 값까지 바뀐다는 것이죠. 

 

그런데 여기서 문제점은, 객체에 대입 연산자를 사용하는 객체 치환에 대해 배웠고, 객체 치환은 R value 의 객체를 복사하여 L value 에 대입한다고 배웠습니다. 그런데 이 때의 복사는 기본적으로 "얕은 복사" 라는 것이 문제입니다.

// C++ 에서의 얕은 복사와 깊은 복사
#include <iostream>
using namespace std;

int main() {
	int* a = new int(3);
	int* b = new int(5);
	cout << "a의 주소(복사전) : " << a << endl;
	cout << "b의 주소(복사전) : " << b << endl;

	a = b; // 얕은 복사(참조 복사) = b의 주소를 a에 복사 즉 a는 b의 주소 200를 가리킨다.
	// *a = *b;  // 깊은 복사(값 복사)
	cout << "a의 주소(복사후) : " << a << endl;
	cout << "b의 주소(복사후) : " << b << endl;

	cout << "a의 값 : " << *a << endl;
	cout << "b의 값 : " << *b << endl;

	delete a;
	delete b;
}

위 코드를 수행하면 이처럼 에러가 발생합니다. 이 에러가 발생하는 이유는 빨간 박스를 확인해보면 a 포인터와 b 포인터가 같은 동적 메모리 공간을 가리키고 있는데, delete 를 2번 수행했기 때문입니다. 이미 반환한 메모리를 또 반환하려고 하면 오류가 발생한다는 내용은 new 와 delete 연산자를 다룰 때 배운적이 있습니다. 얕은 복사 줄을 주석처리하고 주석 처리 해놓은 깊은 복사를 주석을 지우고 실행하면

a 포인터와 b 포인터는 서로 다른 메모리 공간을 가리키고 있으며 따라서 오류가 발생하지 않고 정상적으로 동작합니다.

 

객체의 호출에 대해서는 왜 얕은 복사로 이루어지는지, 얕은 복사로 이루어진다면 어떤 과정을 통해 무엇을 바꿔서 깊은 복사가 일어나도록 해야하는지가 오늘 포스팅의 핵심 내용입니다.

 

 

 

 

[C++] 동적 메모리 할당 : new / delete 연산자

동적 메모리 할당(Dynamic Memory Allocation) 및 해제 동적 메모리 할당을 알아보기에 앞서 정적 메모리 할당의 한계점을 하나 짚고 넘어가겠습니다. 앞선 포스팅... blog.naver.com C 언어에서는 malloc() 이

songsite123.tistory.com

[ delete 2번 실행해서 발생하는 오류에 대한 내용은 위 포스팅에 설명되어 있습니다]

 

 

 

 

 복사 생성자 (Copy constructor)

 

C++에서 객체를 생성하려면 생성자가 실행되어야 합니다. 그런데 Call-by-value 로 객체를 호출하는 경우 생성자가 호출되지 않는 비대칭 구조를 지난 번 포스팅에서 배웠습니다.

 

[C++] 참조 매개 변수 / 참조 리턴 (Reference parameter / Reference return) + Call-by-reference

참조 매개 변수와 참조 리턴을 이해하기 위해서는 프로그래밍 언어의 인자 전달 방식(argument passing) 에 대해 복습해야합니다. https://blog.naver.com/songsite123/222938354059 두 변수의 값 서로 바꾸기(Call-by

songsite123.tistory.com

 

그리고 사실 이런 비대칭 구조로 나타나는 이유는 실제로 생성자가 실행되지 않는 것이 아니라 "복사 생성자" 라는 것이 실행되기 때문입니다. 복사생성자를 선언하지 않으면 디폴트 복사 생성자(Default copy constructor)가 이 작업을 수행합니다. 그리고 이 디폴트 복사 생성자는 얕은 복사를 실행하도록 만들어져 있기 때문에 지금까지의 문제가 발생합니다. 멤버에 포인터 변수가 없는 경우는 문제가 없지만 포인터 변수가 있는 경우 충돌이 발생하는 것이죠. 따로 우리가 복사 생성자를 선언하지 않으면 디폴트 복사 생성자는 원본 객체의 모든 멤버를 일대일로 사본(this)에 복사하도록 구성됩니다. 마치 아래와 같은 Book class가 있다면 디폴트 복사 생성자는 아래처럼 구성됩니다.

class Book{
	double price;
    int pages;
    char *title;
    char *author;
public:
	Book();
    ~Book();
}

// 컴파일러가 생성하는 Book 클래스의 디폴트 복사 생성자
Book(const Book& book){
	this->price = book.price;
    this->pages = book.pages;
    this->title = book.title
    this->author = book.author;
}

 

복사생성자를 따로 선언해주고 싶으면 위처럼 매개변수를 오직 하나만 사용하며, 자기 클래스에 대한 참조로 선언됩니다.

또한 복사 생성자는 클래스에 오직 한 개만 선언할 수 있습니다. 예제 코드를 확인해보며 마무리 하도록 하겠습니다.

 

#include <iostream>
#include <cstring>
using namespace std;
class Person {
	char* name;
	int id;
public:
	Person(int id, const char* name);
	Person(const Person& p);
	~Person();
	void changeName(const char* name);
	void show() { cout << id << ',' << name << endl; }
};

/*
// 디폴트 복사 생성자
Person::Person(Person& p)
{
	this->id = p.id;
	this->name = p.name;
}
*/

// 깊은 복사를 수행하는 복사 생성자
Person::Person(const Person& p) {
	this->id = p.id;
	int len = strlen(p.name);
	this->name = new char[len + 1];
	strcpy(this->name, p.name);
}


Person::Person(int id, const char* name) {
	this->id = id;
	int len = (int)strlen(name);
	this->name = new char[len + 1];
	strcpy(this->name, name);
}
Person::~Person() {
	if (name)
		delete[] name;
}

void Person::changeName(const char* name) {
	if (strlen(name) > strlen(this->name))
		return;
	strcpy(this->name, name);
}

int main()
{
	Person father(1, "Kitae");
	Person daughter(father);   // daughter 객체 복사 생성. 복사 생성자 호출

	cout << "daughter 객체 생성 직후 ----" << endl;
	father.show();
	daughter.show();

	daughter.changeName("Grace");
	cout << "daughter 이름을 Grace 로 변경한 후 ----" << endl;
	father.show();
	daughter.show();

	return 0;
}

중간에 주석으로 처리한 부분은 따로 생성하지 않아도 자동으로 수행되는 디폴트 복사 생성자입니다. name 이라는 포인터가 새로 할당되지 않기 때문에 충돌이 발생하는데, 이 부분을 새로 strlen(p.name) 을 통해 길이를 저장해 널문자를 포함해 길이 + 1 만큼 새로 할당 받고, strcpy 로 복사를 하여 깊은 복사를 수행합니다. 이 때 주의할 점이 strcpy 가 보안성이 안 좋은 함수라서 strcpy_s(this->name , strlen(s) + 1, p.name) 으로 바꿔서 수행해야 돌아갈 수도 있습니다. 혹은 설정에서 언어모드 설정이나 SDL 검사 같은 항목을 조정하면 실행이 됩니다.

 

결국 핵심은 이 부분입니다.

// 디폴트 복사 생성자
Person::Person(Person& p)
{
	this->id = p.id;
	this->name = p.name;
}

// 깊은 복사를 수행하는 복사 생성자
Person::Person(const Person& p) {
	this->id = p.id;
	int len = strlen(p.name);
	this->name = new char[len + 1];
	strcpy(this->name, p.name);
}

이 둘의 차이를 알고, 언제 깊은 복사 생성자를 선언해야하는지 잘 파악해서 멤버 변수에 포인터가 있거나 동적 할당을 받는 경우 깊은 복사 생성자를 잘 선언하고 사용해야 오류가 없는 프로그램을 작성할 수 있습니다.

 

참고 문헌 : 명품 C++ programming