C++ - 얕은 복사 vs 깊은 복사
프로그래밍을 하다 보면, 데이터를 다른 객체에 복사해야 하는 상황이 자주 발생한다.
이 때, 복사를 어떻게 하느냐에 따라 프로그램의 안정성도 달라지고 성능도 달라진다.
복사는 얕은 복사와 깊은 복사로 나누어진다.
각각 알아보도록 하자.
얕은 복사
먼저, 얕은 복사에 대해 알아보자. 뭐를 복사하길래 얕다는 것일까?
얕은 복사는 가지고 있는 데이터를 그대로 복사하게 된다.
인스턴스 A에 int Hp = 100, int Mp = 50 으로 저장되어 있다고 한다면
인스턴스 A의 데이터를 인스턴스 B에 복사하게 되면 오른쪽과 같이 인스턴스 B의 값이 인스턴스 A와 동일해진다.
class ShallowCopy
{
public:
int Hp = 0;
int Mp = 0;
};
int main()
{
ShallowCopy A = {100, 50};
ShallowCopy B = {0, 0};
B = A;
std::cout << B.Hp << " " << B.Mp;
return 0;
}
위으 코드를 보면, 인스턴스 A와 B를 생성해준 뒤, B = A 로 대입연산을 해주고 있다.
이 때, 복사에 대해 연산자를 따로 정의하지 않았다면, B에는 A의 값이 그대로 복사될 것이다.
우리가 의도한대로 데이터가 잘 복사되었다.
하지만, 데이터가 이렇게 잘 복사되어서는 안되는 상황이 있다.
복사인데, 잘 복사되면 안된다니 무슨 소리일까?
아래 그림을 보자.
만약, 클래스에서 Level을 포인터 변수로 선언하고, 동적 할당을 하도록 설계하였다면?
인스턴스 A에서는 자체적으로 할당한 메머리의 주소값을 Level에 저장하고 있을 것이다. (16을 주소값이라고 가정하자.)
그런데, B = A 연산을 진행하게 되면, 인스턴스 B에서도 16이라는 동일한 주소값을 가리키게 된다.
이 때, B에서 만약 Level의 값을 바꿔버린다면?
A에서도 참조하는 값이 바뀌기 때문에 A에도 영향을 미치게 된다.
심지어 B에서 해당 메모리를 해제해버린다면?
A에선 아무것도 모르고 메모리를 참조하려다가 잘못된 메모리를 참조하여 크래시가 발생할 수도 있다.
이처럼 하나의 포인터를 여러 객체가 소유하고 있다가, 한 객체가 메모리를 해제해버려서 나머지 객체들이 잘못된 메모리 공간을 참조하게 되는 경우를 댕글링 포인터라고 한다.
얕은 복사는 이런 문제가 생긴다. 애초부터 함께 공유하도록 설계한 메모리 영역이라면 문제가 없겠지만, 각 인스턴스가 다르게 사용해야 하는 경우라면 여러가지 문제가 발생할 수 밖에 없다.
#include <iostream>
class ShallowCopy
{
public:
int Hp = 0;
int Mp = 0;
int* Level = nullptr;
ShallowCopy()
{
Level = new int();
}
};
int main()
{
ShallowCopy A;
A.Hp = 100;
A.Mp = 50;
*A.Level = 5;
ShallowCopy B;
B = A;
std::cout << A.Hp << " " << A.Mp << " " << *A.Level << " " << A.Level << std::endl;
std::cout << B.Hp << " " << B.Mp << " " << *B.Level << " " << B.Level << std::endl;
return 0;
}
위와 같이 얕은 복사를 하는 코드를 작성하고 실행하게 되면, 아래와 같이 값이 동일하게 나온다.
가리키는 주소값까지 완전히 동일한 것을 볼 수 있다. 얕은 복사는 이처럼 변수에 저장되어 있는 값을 그대로 복사하기 때문에, 포인터형을 복사할 때 문제가 발생할 수 있다.
이를 해결하기 위한 방법이 깊은 복사이다.
깊은 복사
깊은 복사는 위에서 말했던, 댕글링 포인터의 문제를 해결하기 위해 만들어졌다.
결론부터 말하자면, 깊은 복사는 별도의 메모리 영역을 동적으로 할당한 뒤에 저장되어 있는 값을 복사하는 형식이다.
아래 그림을 보자.
왼쪽처럼, A의 Level과 B의 Level이 같은 주소를 참조하고 있다면, 얕은 복사가 된 상태이다.
반면, 깊은 복사의 경우 오른쪽처럼 별도의 메모리 영역을 참조하게 된다.
깊은 복사는 주소값만을 가져오는 것이 아니라, 별도로 메모리 공간을 동적으로 할당한 뒤에 내부의 값을 복사하는 것이다.
그렇다면, 깊은 복사는 어떻게 해야할까?
복사 생성자, 복사 대입 연산자를 정의해야 한다.
복사 생성자란 아래와 같이 객체를 생성할 때 값을 복사하여 초기화 하는 것을 말한다.
class DeepCopy
{
public:
int Hp = 0;
int Mp = 0;
int* Level = nullptr;
DeepCopy()
{
Level = new int();
}
}
int main()
{
DeepCopy A;
A.Hp = 100;
A.Mp = 50;
*A.Level = 5;
DeepCopy B(A);
return 0;
}
위의 코드를 보면, B를 생성할 때 인자에 A를 대입하였다.
이렇게, 객체를 직접 넣었을 때 호출되는 생성자를 복사 생성자라고 한다.
만약 위의 코드처럼 복사 생성자를 직접 정의하지 않은 상태로 복사 생성자를 호출하게 되면 디폴트 복사 생성자가 호출된다. 디폴트 복사 생성자는 얕은 복사를 실행하기 때문에, 깊은 복사를 하고 싶다면 반드시 복사 생성자를 직접 정의해주어야 한다.
복사 생성자를 정의해보자.
DeepCopy(const DeepCopy& _Other)
{
if(Level == nullptr)
{
Level = new int();
}
Hp = _Other.Hp;
Mp = _Other.Mp;
*Level = *_Other.Level;
}
복사 생성자는 인자로 const (본인의 자료형)& 형태로 받아야 한다.
위에서는 DeepCopy 클래스이기 때문에 const DeepCopy& 타입으로 인자를 설정하였다.
생성자 내부에선 Level을 동적할당하였고, 값들을 복사해주고 있다.
이렇게 생성자를 정의한 뒤에 아래 코드를 실행하게 되면?
int main()
{
DeepCopy A;
A.Hp = 100;
A.Mp = 50;
*A.Level = 5;
DeepCopy B(A);
std::cout << A.Hp << " " << A.Mp << " " << *A.Level << " " << A.Level << std::endl;
std::cout << B.Hp << " " << B.Mp << " " << *B.Level << " " << B.Level << std::endl;
}
아래와 같이, 내부의 값은 동일하지만 Level은 서로 다른 주소를 가리키게 된다.
서로 다른 주소를 가리키기 때문에, 어느 한 쪽에서 메모리를 해제하더라도 다른 쪽에는 전혀 영향이 가지 않는다.
이처럼, 안정적으로 복사를 설계하는 것을 깊은 복사라고 한다.
깊은 복사는 생성자 뿐만이 아니라, 복사 대입 연산자에서도 사용할 수 있다.
복사 대입 연산이란, B = A; 처럼 등호를 이용하여 복사를 하는 경우이다.
이 때에도 복사 대입 연산자가 별도로 정의되어 있지 않다면, 디폴트 복사 대입 연산자를 호출하여 얕은 복사를 진행하게 된다. 그러므로 깊은 복사를 사용하고 싶다면 반드시 정의해주어야 한다.
복사 대입 연산자는 아래와 같이 연산자 오버로딩을 이용하여 정의할 수 있다.
DeepCopy& operator= (const DeepCopy& _Other)
{
Hp = _Other.Hp;
Mp = _Other.Mp;
*Level = *_Other.Level;
return *this;
}
복사 대입 연산자 오버로딩에서 왜 void가 아니라, 본인의 참조형을 반환하는지 궁금한 사람도 있을텐데
A = B = C; 와 같은 체이닝을 가능하게 하기 위해서라고 한다.
이 상태로, 아래의 코드를 실행한다면?
int main()
{
DeepCopy A;
A.Hp = 100;
A.Mp = 50;
*A.Level = 5;
DeepCopy B;
B = A;
std::cout << A.Hp << " " << A.Mp << " " << *A.Level << " " << A.Level << std::endl;
std::cout << B.Hp << " " << B.Mp << " " << *B.Level << " " << B.Level << std::endl;
}
복사 생성자와 동일한 결과를 볼 수 있다.
아래는 코드 전문이다.
#include <iostream>
class ShallowCopy
{
public:
int Hp = 0;
int Mp = 0;
int* Level = nullptr;
ShallowCopy()
{
Level = new int();
}
};
class DeepCopy
{
public:
int Hp = 0;
int Mp = 0;
int* Level = nullptr;
DeepCopy(const DeepCopy& _Other)
{
if(Level == nullptr)
{
Level = new int();
}
Hp = _Other.Hp;
Mp = _Other.Mp;
*Level = *_Other.Level;
}
DeepCopy()
{
Level = new int();
}
DeepCopy& operator= (const DeepCopy& _Other)
{
Hp = _Other.Hp;
Mp = _Other.Mp;
*Level = *_Other.Level;
return *this;
}
};
int main()
{
{
ShallowCopy A;
A.Hp = 100;
A.Mp = 50;
*A.Level = 5;
ShallowCopy B;
B = A;
std::cout << A.Hp << " " << A.Mp << " " << *A.Level << " " << A.Level << std::endl;
std::cout << B.Hp << " " << B.Mp << " " << *B.Level << " " << B.Level << std::endl;
}
{
DeepCopy A;
A.Hp = 100;
A.Mp = 50;
*A.Level = 5;
DeepCopy B;
B = A;
std::cout << A.Hp << " " << A.Mp << " " << *A.Level << " " << A.Level << std::endl;
std::cout << B.Hp << " " << B.Mp << " " << *B.Level << " " << B.Level << std::endl;
}
return 0;
}