본문 바로가기

C++

[C/C++ 공부] 깊은 복사와 얕은 복사 차이? (feat.복사 생성자)

객체를 생성하고 초기화시킬 때 멤버 변수를 어떻게 초기화하느냐에 따라 깊은 복사가  될 수 있고, 얕은 복사가 될 수 있습니다.

 

 

*디폴트 생성자가 아닌 일반 생성자생성자의 인수를 전달 받아 멤버 변수를 초기화시킵니다.

복사 생성자는 별도 생성자를 선언하지 않는다면, 암시적 생성자로 인수로 제공된 객체의 멤버 변수를 복사하여 새롭게 생성하는 객체의 멤버 변수를 생성합니다.

 

디폴트 생성자란?

명시적인 초기화 값을 제공하지 않았을 때 객체를 생성하는 데 사용하는 생성자. 사용자가 어떠한 생성자도 정의하지 않을 경우에만 컴파일러가 디폴트 생성자를 제공

 

 

▶ 복사 생성자

 

밑에 보이는 프로그램은 아마 오류가 날 것입니다.

왜 그럴까요?

 

 

#include <iostream>
#include <string>
#include <cstring>

class MyString {
private:
	char* data;
	int length;

public:
	MyString(const char* n);
	~MyString();

	const char* GetString() const;
	int GetLength() const;
};

MyString::MyString(const char* n) {
	if (nullptr == n) {
		data = nullptr;
		length = 0;
		return;
	}
	int mLength = strlen(n);
	if (0 == mLength) {
		data = nullptr;
		length = 0;
		return;
	}

	char* temp = new char[mLength + 1];
	std::strcpy(temp, n);
	data = temp;
	length = mLength;

	printf("MyString::MyString(const char '%s')\n", n);
}

MyString::~MyString() {
	printf("MyString::MyString('%s') 소멸자 호출\n", data);
	if (nullptr != data) {
		delete[] data;

		data = nullptr;
		length = 0;
	}
}

const char* MyString::GetString() const {
	return data;
}

int MyString::GetLength() const {
	return length;
}

void display(MyString other) {
	std::cout << other.GetString() << std::endl;
}

int main() {
	MyString s1("Hello World");
	display(s1);
	MyString obj = s1;
}

 

 

위의 클래스 내 data 변수는 문자열이 저장된 힙(heap) 메모리를 가리키는 포인터입니다. 

display() 함수를 호출하기 위해 s1 객체를 복사하여 display() 함수의 other 객체를 만들어 함수의 인수로 전달합니다.

 

 

void display(MyString other) {
   std::cout << other.GetString() << std::endl;
}

int main() {
    MyString s1("Hello World");
    display(s1);
    MyString obj = s1;	//이 문장 또한 얕은 복사
    return 0;
}

 

 

이 과정에서 디폴트 복사 생성자는 char타입의 문자열을 가리키는 주소를 보관하는 data 변수와 length 변수의 데이터를 복사하여 새로운 other.data와 other.length 멤버 변수를 만듭니다.

 

display() 함수의 작업이 완료되면, 함수의 인수로 사용한 MyString 클래스의 other 객체를 소멸하게 됩니다.( other객체 또한 함수 내의 지역 변수이므로

그래서 MyString의 소멸자가 호출되어 문자열의 저장소를 삭제합니다.

 

프로그램이 종료되면서 other 객체의 원본인 s1 또한 소멸되는데, 이때 위에서 other 객체에 의해 삭제된 문자열의 저장소를 다시 삭제하는 작업에 의하여 영역 침범이라는 에러가 발생하게 됩니다.

 

 


 

 

▶ 얕은 복사(Shallow Copy)

 

 

이와 같이 변수가 가리키는 실제 데이터가 아닌 단지 메모리 주소만을 복사하여 변수의 데이터로 만드는 방법을 얕은 복사라고 합니다.

 

앞서 살펴봤던 프로그램에서의 얕은 복사를 그림으로 나타내면 아래와 같습니다.

 

 

얕은 복사

 

 

그렇다면 display() 함수의 인수를 참조로 수정하면 영역 침범 에러가 해결될까?

 

display() 함수를 아래와 같이 바꿔봅시다.

인수를 참조로 사용한다면, 참조 역시 가상 변수이기 때문에 일종의 포인터처럼 작동합니다.

인수가 객체가 아니기 때문에 함수가 끝날 때 소멸자가 호출되지 않습니다.

 

 

void display(const MyString& other) {
   std::cout << other.GetString() << std::endl;
}

 

 

질문에 대한 답은

O

입니다. 단, display() 함수 내에서'는' 오류가 발생하지 않지만, 전체 코드에서 보이는 그 뒤에 MyString obj = s1; 이 문장이 오류를 초래합니다.

 

 

MyString obj = s1;

 

이 문장은 디폴트 복사생성자를 통해 얕은 복사가 이루어집니다.  

프로그램이 종료되기 전에 obj 객체의 소멸자가 먼저 호출이 되는데, 이때 data의 저장소를 삭제합니다. 바로 다음으로 s1 객체의 소멸자가 호출되는데, 이미 앞서서obj 객체 소멸 시에 data의 저장소를 삭제했음에도 다시 삭제하려는 시도로 인해 오류가 발생합니다.

 

 

int main() {
    MyString s1("Hello World");
    display(s1);
    MyString obj = s1;	//이 문장 또한 얕은 복사
    return 0;
}

 

 

여기까지 보면 눈치 채셨겠지만, 얕은 복사에 따른 문제가 발생하는 원인은 바로 클래스 내 멤버 포인터의 존재입니다.

위 코드에서는 char* data가 그 원인입니다.

 

이 문제를 해결하는 방안은 바로 '깊은 복사'입니다.

 

 


 

 

깊은 복사(Deep Copy)

 

 

깊은 복사란?

변수가 관리하는 리소스 자체를 복사(새롭게 메모리를 할당)하여 새롭게 멤버 변수에 입력시키는 것입니다. 얕은 복사에 비해 작업 시간과 리소스의 소모가 따릅니다.

 

 

앞에서 보았던 것과 같이 그림으로 먼저 깊은 복사를 살펴봅시다.

 

 

깊은 복사

 

 

그림을 보면 각 객체마다 메모리 할당이 이루어져서 각자 다른 곳을 가리키고 있는 것이 보입니다.

 

 

앞서 다뤘던 MyString 클래스 프로그램을 복사 생성자도 추가하여 깊은 복사를 알아보겠습니다.

 

 

#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>

class MyString {
public:
	MyString(const char* n);
	MyString(const MyString& other);
	~MyString();

	void SetString(const char* n);
	const char* GetString() const;
	int GetLength() const;

private:
	void Release();
	char* data;
	int length;
};

MyString::MyString(const char* n) : data(nullptr), length(0) {
	SetString(n);
	printf("MyString::MyString(const char '%s')\n", n);
}

//복사 생성자
MyString::MyString(const MyString& other) : data(nullptr), length(0) {
	SetString(other.GetString());
	printf("MyString::MyString(const MyString& '%s')\n", data);
}

//소멸자
MyString::~MyString() {
	printf("MyString::MySTring('%s') 소멸자 호출\n", data);
	Release();	//할당받은 메모리 삭제
}

void MyString::SetString(const char* n) {
	//인수의 문자열이 NULL인지 확인한다.
	if (nullptr == n) {
		std::cout << "nullptr" << std::endl;
		return;
	}

	int mLength = strlen(n);
	if (0 == mLength) {
		std::cout << "" << std::endl;
		return;
	}

	data = new char[mLength + 1];
	std::strcpy(data, n);
	length = mLength;
}

void MyString::Release() {
	if (data != nullptr) {
		delete[] data;
		data = nullptr;
		length = 0;
	}
}

const char* MyString::GetString() const { return data; }
int MyString::GetLength() const { return length; }
void display(MyString other) { std::cout << other.GetString() << std::endl; }


int main() {
	MyString s1("Hello World"), s2 = "복사 생성자";
	display(s1); 	//복사 생성자 호출
	s2 = s1;		//복사 대입 연산자 호출
	display(s2);
	return 0;
}

 

 

MyString 클래스에 아래와 같은 복사 생성자가 추가되었습니다.

SetString() 함수를 통해 char* data에 대한 메모리를 새로 할당받는 것을 알 수 있습니다.

 

 

MyString::MyString(const MyString& other) : data(nullptr), length(0) {
	SetString(other.GetString());
	printf("MyString::MyString(const MyString& '%s')\n", data);
}

 

 

복사 대입 연산자?

 

 

복사 생성자를 작성함으로써 display() 함수에서 발생하는 오류를 해결할 수 있었습니다. 하지만 아직도 이 프로그램은 오류가 날 것입니다. 이유가 뭘까요?

 

 

<실행 결과>
MyString::MyString(const char 'Hello World')
MyString::MyString(const char '복사 생성자')
MyString::MyString(const MyString& 'Hello World')
Hello World
MyString::MyString('Hello World') 소멸자 호출
MyString::MyString(const MyString& 'Hello World')
Hello World
MyString::MyString('Hello World') 소멸자 호출
MyString::MyString('Hello World') 소멸자 호출
MyString::MyString('&*#$@') 소멸자 호출                 //오류 발생

 

 

 

바로 " s2 = s1 " 이 문장 하나 때문입니다. 이제까지 복사 생성자 관련 문제를 해결했지 복사 대입 연산자 문제는 해결하지 않았습니다.

 

 

int main() {
	MyString s1("Hello World"), s2 = "복사 생성자";
	display(s1); 	//복사 생성자 호출
	s2 = s1;		//복사 대입 연산자 호출
	display(s2);
	return 0;
}

 

 

MyString 클래스 안에서 복사 대입 연산자를 따로 정의해주지 않아서 s2 = s1 이 문장을 실행할 때 디폴트로 연산 작업을 수행하다보니 앞서 봤던 얕은 복사의 원리로 s1의 data와 s2의 data가 의도치 않게 같은 주소를 가리키게 된 겁니다.

이로 인해 s2를 소멸시킨 후, s1을 소멸할 때 오류가 납니다.

 

 

 

복사 대입 연산자 오버로딩

 

 

위 문제를 해결하기 위해 복사 대입 *연산자 오버로딩을 만들어 주어야 합니다.

 

연산자 오버로딩이란?

쉽게 말하자면, 존재하고 있는 연산자를 개발자가 원하는 내용을 실행할 수 있도록 만들어주는 것입니다. 자세한 내용은 나중에 다루겠습니다.

 

 

아래와 같이 복사 대입 연산자 오버로딩을 위한 코드를 추가한다면, 오류가 나지 않습니다.

 

 

MyString& MyString::operator= (const MyString& other) {
	if (this != &other) {
		delete[] data;
		SetString(other.GetString());
	}

	printf("MyString& MyString::operator = (MyString& '%s')\n", this->data);
	return *this;
}

 

<실행 결과>
MyString::MyString(const char 'Hello World')
MyString::MyString(const char '복사 생성자')
MyString::MyString(const MyString& 'Hello World')
Hello World
MyString::MyString('Hello World') 소멸자 호출
MyString& MyString::operator = (MyString& 'Hello World')
MyString::MyString(const MyString& 'Hello World')
Hello World
MyString::MyString('Hello World') 소멸자 호출
MyString::MyString('Hello World') 소멸자 호출
MyString::MyString('&*#$@') 소멸자 호출

 

 

 

오류나 오타 지적해주시면 감사하겠습니다. :)