[Advanced C++] 59. Shallow & Deep Copy, 연산자 오버로딩과 함수 템플릿

dev.kelvin·2025년 6월 4일
1

Advanced C++

목록 보기
59/74
post-thumbnail

1. Shallow Copy & Deep Copy

얕은 복사, 깊은 복사

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 클래스 객체가 깊은 복사를 수행함)


2. 연산자 오버로딩과 함수 템플릿

연산자 오버로딩과 함수 템플릿

함수 템플릿을 정의하고 사용할 때 연산자 오버로딩이 구현되어 있지 않으면 실패하는 경우가 종종 있다

    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++의 함수 템플릿과 연산자 오버로딩의 조합으로 함수 자체는 수정하지 않고 각각의 타입에 맞게 함수를 사용할 수 있다

profile
GameDeveloper🎮 Dev C++, DataStructure, Algorithm, UE5, Assembly🛠, Git/Perforce🌏

0개의 댓글