
얕은 복사, 깊은 복사
C++ 컴파일러가 제공하는 암시적 복사 생성자와 암시적 복사 대입 연산자는 멤버별 복사 방식을 사용한다, 이러한 복사를 얕은 복사 (shallow copy)라고 한다
얕은 복사란 클래스의 각 멤버 변수를 그대로 복사하는 방식이다, 클래스가 동적 할당된 메모리를 다루지 않는경우 문제 없이 잘 동작한다
얕은 복사의 가장 큰 문제는 클래스가 동적 할당된 메모리를 다룰때이다, 포인터를 얕은 복사하게 되면 포인터 가 가리키는 주소값만 복사될 뿐 해당 주소에 있는 데이터 자체를 위한 새로운 메모리 할당이나 그 내용을 복사하지 않기 때문이다
다음과 같은 클래스가 있다고 가정해보자
class Foo
{
public:
Foo(int inValue)
{
valuePtr = new int(inValue);
}
~Foo()
{
delete valuePtr;
}
private:
int* valuePtr{};
};
이 클래스를 암시적 복사 생성자, 암시적 복사 대입 연산자를 통해 복사하게 되면 어떻게 될까?
Foo f1{ 100 };
Foo f2{ f1 }; //암시적 복사 생성자
따로 복사 생성자나 복사 대입 연산자를 정의하지 않았기 때문에 다음과 같은 복사 생성자가 호출될 것이다
Foo(const Foo& InFoo)
{
Foo.valuePtr = InFoo.valuePtr;
}
이렇게 얕은 복사가 되면 위에 정리했듯 포인터 주소가 복사되게 되고 결국 f1과 f2의 valuePtr은 같은 메모리 주소를 가리키게 된다
이런 상황은 여러 치명적인 문제를 발생시킬 수 있다
f1과 f2의 소멸자에서 가지고 있던 동적 할당된 메모리 변수를 delete하게 된다면 두번 delete가 되어 크래시가 발생할 수 있다 (같은 주소를 가리키기 때문에 같은 동적할당된 메모리 변수가 delete 되는것임)
또한 동일한 메모리 주소를 가리키고 있기때문에 하나를 수정하면 나머지 복사된 객체의 멤버 데이터도 전부 다 변경되게 된다
포인터 멤버를 얕은 복사하는건 거의 항상 문제를 유발한다
이러한 얕은 복사 문제를 해결할 수 있는 방법은 깊은 복사(deep copy)를 하는것이다
깊은 복사는 복사본을 위한 메모리를 새로 할당하고 값을 그 메모리에 복사하는 방식이다, 이렇게 되면 서로 다른 메모리 주소를 가리키게 되면서 값은 동일한 결과를 얻을 수 있다
직접 복사 생성자와 복사 대입 연산자를 정의하여 깊은 복사를 구현해보자
class Foo
{
public:
Foo(int inValue)
{
valuePtr = new int(inValue);
}
~Foo()
{
delete valuePtr;
}
Foo(const Foo& InFoo)
{
deepCopy(InFoo);
}
//깊은복사 함수
void deepCopy(const Foo& InFoo)
{
//기존의 동적 할당 변수를 할당 해제하고 nullptr로 만든다 (메모리 누수 방지)
delete valuePtr;
valuePtr = nullptr;
if (InFoo.valuePtr)
{
valuePtr = new int(*InFoo.valuePtr); //인자로 들어온 객체의 값을 복사하고 동일한 크기만큼 동적할당하여 변수에 주소를 넣어준다
}
else
{
valuePtr = nullptr;
}
}
private:
int* valuePtr{};
};
int main()
{
Foo f1{ 100 };
Foo f2{ f1 };
return 0;
}
f1과 f2는 각각 별개의 동적할당된 메모리 주소를 가리키게 되기 때문에 double free와 같은 문제가 발생하지 않는다
복사 대입 연산자를 정의하여 깊은 복사를 구현하려면 다음과 같이 처리한다
Foo& operator=(const Foo& InFoo) //체이닝을 위한 참조 타입 반환
{
if (this != &InFoo) //자기 자신 할당 체크
{
deepCopy(InFoo);
}
return *this;
}
일반적으로 클래스 멤버에 포인터 변수가 있다면 깊은 복사로 복사를 수행하는걸 강력히 권장한다
C++STL에 있는 여러 클래스들은 이러한 메모리 관리를 알아서 처리해준다 (std::string, std::vector 등)
std::string s1{ "Kelvin" };
std::string s2{ s1 };
s2에 s1을 복사했지만 둘은 완전히 독립적인 존재이다 (std::string 클래스 객체가 깊은 복사를 수행함)
연산자 오버로딩과 함수 템플릿
함수 템플릿을 정의하고 사용할 때 연산자 오버로딩이 구현되어 있지 않으면 실패하는 경우가 종종 있다
class Foo
{
public:
Foo(int inValue) : value{ inValue }
{
}
private:
int value{};
};
template <typename T>
const T& GetMax(const T& a, const T& b)
{
return (a < b) ? b : a;
}
int main()
{
Foo f1{ 100 };
Foo f2{ 200 };
Foo maxFoo{ GetMax(f1, f2) };
return 0;
}
T& GetMax()에서 각각의 인자는 Foo 클래스 타입으로 들어오게 되고 Foo 클래스에는 operator< 오버로딩이 없기 때문에 컴파일 에러가 발생한다
위 코드를 실행시키려면 Foo클래스에 operator< 오버로딩이 필요하다
class Foo
{
public:
Foo(int inValue) : value{ inValue }
{
}
friend bool operator<(const Foo& other1, const Foo& other2)
{
return other1.value < other2.value;
}
private:
int value{};
};
다른 예시도 정리해보자
template<typename T>
T GetAverage(const T* arr, int size)
{
T sum{};
for (int i = 0; i < size; ++i)
{
sum += arr[i];
}
return sum / size;
}
int main()
{
int intArr[]{ 1, 2, 3, 4, 5 };
std::cout << GetAverage(intArr, 5);
return 0;
}
위 코드는 built-in 타입이 int를 사용했기 때문에 문제 없이 함수 템플릿 호출이 가능하다, 하지만 사용자 정의 클래스 타입이면 어떨까?
바로 컴파일 에러가 발생한다
첫번째로 operator<< 오버로딩이 되어있지 않기 때문에 에러가 발생하고 그 다음은 평균을 구하는 함수 템플릿에서의 operator+=과 operator/ 오버로딩이 되어있지 않기 때문에 에러가 발생한다
class Foo
{
public:
Foo(int inValue) : value{ inValue }
{
}
friend std::ostream& operator<<(std::ostream& out, const Foo& other1)
{
return out << other1.value;
}
Foo& operator+=(const Foo& other)
{
value += other.value;
return *this;
}
Foo operator/(int divisor) const
{
assert(divisor != 0 && "Division by zero is not allowed.");
return Foo{ value / divisor };
}
private:
int value{};
};
template<typename T>
T GetAverage(const T* arr, int size)
{
T sum{ 0 };
for (int i = 0; i < size; ++i)
{
sum += arr[i];
}
return sum / size;
}
int main()
{
Foo intArr[]{ Foo{ 1 }, Foo{ 2 }, Foo{ 3 }, Foo{ 4 }, Foo{ 5 } };
std::cout << GetAverage(intArr, 5);
return 0;
}
함수 템플릿에서 필요한 연산자들을 오버로딩 하면서 해당 클래스 타입 객체로 함수 템플릿을 호출하여 사용할 수 있게 되었다
C++의 함수 템플릿과 연산자 오버로딩의 조합으로 함수 자체는 수정하지 않고 각각의 타입에 맞게 함수를 사용할 수 있다