C++/C++ : Study

8. 연산자 오버로딩 (8) - 대입 연산자 , 깊은 복사

더블유제이플로어 2025. 6. 21. 21:38

중요한 Part이다.

Assignment Operator Overloading

◆  대입 연산자 오버로딩, 깊은 복사 

   ●  C++는 대입 연산자를 자동 생성해 준다. 
   ●  자동 생성된 대입 연산자는 얕은 복사를 수행한다
   ●  포인터 타입의 멤버 변수가 존재하면, 깊은 복사를 통한 연산자 직접 정의 필요하다.

   ●  자동 생성된 대입 연산자는 얕은 복사를 수행한다
   ●  "복사 생성자" 내용 복습
        ☞     복사 생성자는 자동 생성된다.
        ☞      자동 생성된 복사 생성자는 얕은 복사 수행한다.
        ☞      포인터 타입의 멤버 변수가 존재하는 경우, 깊은 복사의 직접 정의가 필요하다.


📘 복사 생성자와 대입 연산자: 구조와 개념의 유사성

복사 생성자와 대입 연산자는 구조적으로 매우 유사하며,
컴파일러가 기본적으로 자동 생성해주고 얕은 복사(shallow copy) 를 수행한다는 공통점이 있다.

✅ 자동 생성과 얕은 복사

클래스에 복사 생성자나 대입 연산자를 명시적으로 정의하지 않아도,
컴파일러는 기본 구현을 자동으로 생성해준다.
이때 수행되는 복사는 멤버 변수 값을 그대로 복사하는 얕은 복사 방식이다.

✅ 대입 연산자 예시

Point& operator=(const Point& rhs) {
    if (this == &rhs) return *this;  // 자기 자신에 대한 대입 방지
    x = rhs.x;
    y = rhs.y;
    return *this;
}

위와 같은 대입 연산자 함수는 직접 정의하지 않아도 컴파일러가 생성해준다.
실제로 이 코드를 생략해도 컴파일 오류는 발생하지 않으며,
p3 = p1; 이후 p3.PrintPosition(); 으로 확인해보면 p1의 값이 제대로 복사되어 있음을 알 수 있다.

❓ 그런데 왜 직접 정의해야 할까?

단순한 정수형 멤버들만 있는 경우에는 문제가 없지만,
클래스 내부에 포인터 타입 멤버 변수가 존재할 경우에는 얕은 복사 방식이 문제를 일으킬 수 있다.

class Point {
private:
    int* data;  // 힙 메모리 할당
};
  • 이 경우 복사 생성자나 대입 연산자가 자동 생성되면,
    포인터 주소만 복사되기 때문에 두 객체가 동일한 메모리를 공유하게 돼.
  • 하나가 소멸되면 다른 하나는 댕글링 포인터가 되고,
    double delete, 예기치 않은 변경 등이 발생할 수 있어.

그래서 반드시 깊은 복사를 위해 오버로딩이 필요하다.

얕은 복사란, 포인터 변수의 주소값만 복사하여
복사된 두 객체가 동일한 힙 메모리를 공유하게 되는 방식이다.
이로 인해 다음과 같은 심각한 문제가 발생할 수 있다:

  • 이중 해제(double delete)
  • 댕글링 포인터(dangling pointer)
  • 의도치 않은 값 공유 및 변경

이러한 경우에는 깊은 복사(deep copy) 를 수행해야 하며,
이를 위해 복사 생성자와 대입 연산자를 직접 오버로딩하여 구현해야 한다.

✅ 복사 생성자와 대입 연산자의 공통점 요약

항목  복사 생성자  대입 연산자 
자동 생성 여부 O O
기본 복사 방식 얕은 복사 얕은 복사
필요성 포인터 멤버 있을 경우 직접 구현 필요 포인터 멤버 있을 경우 직접 구현 필요
역할 객체 생성 시 복사 기존 객체에 값 덮어쓰기
자기 대입 검사 필요 없음 this == &rhs 검사 필요

따라서 두 함수는 구조도 비슷하고, 구현이 필요한 상황도 거의 동일하다.
동적 메모리나 자원 관리가 필요한 클래스에서는
반드시 복사 생성자와 대입 연산자를 모두 명확하게 정의해주는 것이 좋다.


   ●  예제를 위해 임시 변경된 클래스

class Array {
private:
	int* ptr;
	int size;
public:
	Array(int val, int size)
		:size{ size }
	{
		ptr = new int[size];
		for (int i = 0; i < size; i++)
			ptr[i] = val + i;
	}
	int GetSize() const
	{
		return size;
	}
	int GetValue(int index) const
	{
		if (index < size && index >= 0)
			return ptr[index];
	}
    ...
	~Array()
	{
		delete[] ptr;
	}
};

📘 클래스 멤버로 포인터를 사용하는 이유와 구조

  • class Array 내부에 int* ptr;라는 포인터 멤버가 있다.
  • 이 말은 Array 객체가 어떤 정수 하나가 아니라, 정수가 저장된 메모리 공간의 주소만 가지고 있다는 뜻이다.
  • 일반적으로 이 포인터는 힙(Heap) 에 동적으로 할당된 메모리를 가리킨다.
  • 아래 그림처럼 포인터(ptr)는 주소만 저장하고 있고, 실제 값(10)은 0x1000 같은 힙 메모리 주소에 있다.

🧠 왜 힙 메모리를 사용하는가?

✅ 스택 메모리와는 다르게:

  • 스택(stack) 메모리는 함수 호출이 끝나면 사라지기 때문에,
    함수 바깥에서도 유지되는 데이터가 필요할 때는 쓸 수 없다.
  • 힙(heap) 메모리는 new, malloc 등을 통해 동적으로 할당되며,
    프로그래머가 직접 delete, free로 해제할 때까지 유지된다.

✅ 예제에서 힙을 쓰는 이유

class Array {
private:
    int* ptr;  // 동적 할당한 배열의 시작 주소를 저장할 포인터
};
  • 여기서 ptr은 단순 정수 하나가 아니라,
    동적으로 할당한 배열이나 값이 저장된 위치를 가리키는 포인터다.
  • Array 객체 하나로 여러 개의 정수를 저장하거나,
    크기가 런타임에 결정되는 데이터를 관리하려면 이렇게 포인터로 설계해야 한다.

🧱 그림 구조 정리

Array 객체       힙 메모리
------------     ---------------------
| *ptr    | ---> | 10 (value)        |
------------     | (0x1000 주소)     |
                ---------------------
  • ptr에는 0x1000이라는 주소값이 들어있고,
  • 그 주소는 힙에 있는 정수 값 10을 가리킨다.

✨ 결론 요약

  • int* ptr;은 값을 직접 담는 게 아니라 값이 위치한 주소를 담는 변수이다.
  • 클래스 내부에 이런 포인터를 두는 이유는 보통 동적 메모리를 통해 데이터를 유연하게 관리하기 위해서이다.
  • 실습 예제에서 자주 사용하는 이유는,
    깊은 복사 / 얕은 복사 개념을 실험하거나,
    객체가 메모리를 직접 관리하는 경우를 체험하기 위해서이다.

📌 분야별 힙 메모리 사용 여부 정리

✅ 1. 머신비전 (C++ 기반, OpenCV, 산업용 프레임워크 등)

  • 이미지 데이터 처리 시 cv::Mat 등은 대부분 힙 메모리 사용
    • cv::Mat 객체는 내부적으로 동적 할당된 데이터 버퍼를 가진다.
    • 고해상도 이미지, ROI, 필터 결과 등은 모두 힙에 저장된다.
  • 다양한 구조체나 버퍼(uchar*, float*, 객체 리스트 등)를
    동적 할당하여 비동기 처리하거나 버퍼 크기를 런타임에 결정하기 위해 사용됨
  • 결론: 거의 항상 힙 메모리 사용

✅ 2. AI 비전 (딥러닝, 추론 엔진, GPU 연동)

  • AI 비전은 대량의 텐서 데이터, 이미지 버퍼, 결과 맵 등을 다루기 때문에
    거의 전적으로 힙 메모리에 의존
  • PyTorch, TensorRT, OpenVINO, ONNX Runtime 등은 내부적으로 메모리 풀과 힙 기반 텐서 관리 수행
  • 추론 엔진은 입력/출력 텐서를 GPU/CPU의 힙에 맞게 자동 관리함

📌 특징:

  • C++로 inference engine 다룰 때는 new, malloc, cudaMalloc 등이 일상적이다.

⛔ 3. PLC (Programmable Logic Controller)

  • 전통적인 PLC는 고정된 메모리 맵스택 기반 실행 구조를 가진다.
  • C언어나 구조화된 텍스트(ST) 기반 언어에서 힙 메모리 사용은 거의 없다.
  • 실시간성, 안정성 때문에 정적 메모리 구조를 선호한다.

📌 단, PLC+PC 연동 시스템에서
PC 측의 비전 소프트웨어는 힙 메모리를 적극적으로 활용한다.
(즉, PLC 자체는 힙을 안 쓰지만, 연동되는 비전 소프트웨어는 사용함)

✅ 결론 요약

분야 힙 메모리 사용 여부 비고
머신비전 자주 사용 ✅ 이미지, 필터, 동적 버퍼 등
AI 비전 매우 자주 사용 ✅ 딥러닝 추론, 텐서 처리
PLC 자체 거의 사용 안 함 ❌ 실시간 제어 특성상 정적 메모리 사용
PLC + Vision 사용 ✅ 비전 소프트웨어 쪽에서 힙 사용

📘 힙 메모리와 배열 클래스 구현 (Array 클래스 예제 분석)

이 예제는 Array 클래스가 힙 메모리에 정수 배열을 할당하고,
요소 접근 및 해제까지 책임지는 구조를 보여준다.

✅ 생성자: 힙 메모리 동적 할당 및 초기화

Array(int val, int size)
	: size{ size }
{
	ptr = new int[size];             // 힙에 size 크기만큼 공간 확보
	for (int i = 0; i < size; i++)
		ptr[i] = val + i;           // 순차적으로 값 초기화
}
  • ptr은 int* 타입 포인터로, 힙 메모리에 동적 할당된 정수 배열을 가리킨다.
  • new int[size]를 통해 메모리를 확보한 뒤,
  • 각 인덱스에 val + i 값을 저장한다.

즉, ptr은 힙 주소값을 저장하고, 실질적인 데이터는 힙 메모리에 존재한다.

✅ GetSize 함수

int GetSize() const {
	return size;
}
  • size는 private 멤버이므로 외부에서 접근할 수 없고,
  • GetSize() 함수를 통해 현재 배열 크기를 확인할 수 있다.

✅ GetValue 함수

int GetValue(int index) const {
	if (index < size && index >= 0)
		return ptr[index];
	else
		std::cout << "Out of range!!" << std::endl;
}
  • 배열 요소에 접근하기 위한 함수이다.
  • index가 유효한 범위에 있는지를 먼저 검사한 뒤 접근한다.
  • 잘못된 인덱스에 접근하려 할 경우 경고 메시지를 출력한다.

❓ index가 범위를 벗어났을 때 무슨 문제라고 부를까?

이것은 "Out of Bounds Access" (경계 밖 접근) 또는
좀 더 구체적으로는 "Buffer Overflow" (버퍼 오버플로우) 문제가 발생할 수 있는 상황이다.

  • C++은 배열 인덱스를 넘겨도 기본적으로 런타임 에러를 발생시키지 않기 때문에,
  • 명시적으로 index 범위를 검사해주는 코드가 중요하다.

📌 이 범위를 검사하지 않으면,
프로그램이 예기치 않게 동작하거나 보안 취약점의 원인이 될 수 있다.

✅ 소멸자: 메모리 누수 방지

~Array() {
	delete[] ptr;
}
  • 동적으로 할당된 힙 메모리를 해제하는 역할을 한다.
  • new[]로 할당한 메모리는 반드시 delete[]로 해제해야 한다.
  • 그렇지 않으면 메모리 누수(memory leak) 가 발생한다.

✅ 요약 정리

함수  역할 
생성자 힙에 메모리 할당 및 값 초기화
GetSize() 배열 크기 반환 (size 확인용)
GetValue(i) 배열 값 읽기, 범위 검사 포함
소멸자 ptr에 할당된 메모리 해제
예외 상황 인덱스가 유효 범위를 벗어나면 "Out of range" 경고

📘 사용자 정의 배열 클래스와 깊은 복사 문제

Array a1{5, 10};   // 5부터 시작하는 10개의 정수 배열 생성
std::cout << a1[3] << std::endl;  // ❌ 사용 불가

❓ 왜 a1[3]이 불가능한가?

  • Array는 배열이 아니라 배열처럼 동작하도록 만든 사용자 정의 클래스일 뿐이다.
  • C++에서 객체에 [] 연산을 사용하려면 첨자 연산자(operator[]) 오버로딩을 해야 한다.

👉 정확히 말하면:

  • Array는 클래스 이름
  • a1은 Array 클래스의 인스턴스(즉, 객체)

그래서 a1[3]을 사용하려면 아래처럼 연산자를 오버로딩해야 한다:

int& operator[](int index) {
    return ptr[index];
}

📘 얕은 복사로 인한 문제

Array a1{5, 10};
Array a2{3, 5};

a2 = a1;  // 자동 생성된 대입 연산자 호출 (얕은 복사)
  • 여기서 a2 = a1; 은 사용자 정의 연산자가 없기 때문에 컴파일러가 자동 생성한 얕은 복사 대입 연산자가 호출된다.
  • 얕은 복사는 ptr 포인터의 주소값만 복사하므로,
    a1.ptr 과 a2.ptr 이 같은 메모리(힙)를 가리키게 된다.

✅ 얕은 복사로 생기는 문제 시나리오

  1. a1과 a2는 서로 같은 메모리를 가리키게 됨.
  2. 프로그램 종료 시 a1과 a2 모두 소멸자에서 delete[] ptr; 호출
  3. 결국 같은 메모리 블록을 두 번 해제하게 됨 → 이중 해제 (double free)

❓ Exception Thrown 이란?

"Exception Thrown" 은 프로그램 실행 중에 예외(Exception) 가 발생했다는 알림이다.

📌 이 경우 발생한 예외는 일반적으로 다음 중 하나이다:

  • Heap corruption detected
  • Access violation
  • Debug assertion failed: Invalid address specified to RtlFreeHeap

이러한 예외는 대부분 아래 상황에서 발생한다:

  • 이미 해제된 힙 메모리에 다시 접근할 때
  • 메모리를 두 번 해제하려고 할 때 (이중 해제)
  • 포인터가 잘못된 주소를 가리킬 때

즉, 이 메시지가 떴다는 건 얕은 복사로 인해 같은 메모리를 delete[] 두 번 했다는 증거이다.

✅ 결론 및 조치

  • 컴파일러가 생성한 기본 대입 연산자는 얕은 복사만 수행하므로, 포인터 멤버가 있을 경우 반드시 사용자 정의 대입 연산자를 작성해야 한다.
  • 깊은 복사로 수정하려면 다음을 구현해야 한다:
Array& operator=(const Array& rhs) {
    if (this == &rhs) return *this;

    delete[] ptr;

    size = rhs.size;
    ptr = new int[size];
    for (int i = 0; i < size; ++i)
        ptr[i] = rhs.ptr[i];

    return *this;
}

이렇게 하면 a2 = a1에서 a1과 같은 값을 갖는 독립된 배열이 a2 안에 복사되므로,
이중 해제 없이 안정적으로 작동하게 된다.


중요한 Part이다.

   ●  대입 연산자의 구현

	Array& operator=(const Array& rhs)
	{
		if (this == &rhs)
			return *this;
		delete[] ptr;

		ptr = new int[rhs.size];

		size = rhs.size;
		for (int i = 0; i < size; i++)
		{
			ptr[i] = rhs.ptr[i];
		}
		return *this;
	}
* Self-assignment checking 이 필요하다.
(Q. 하지 않았을때, a=a를 작성하면 어떻게 될까?)
* 데이터를 배열로 가지고 있을 경우에는 해제(delete[]) 후 재 할당이 필요하다
* 데이터의 길이가 변할 수 있기 때문이다.

📘 얕은 복사로 인한 이중 해제와 메모리 누수

✅ 상황 설명: 얕은 복사 기본 동작

다음은 기본 대입 연산자 오버로딩의 형태이다:

Point& operator=(const Point& rhs) {
    if (this == &rhs) return *this;
    x = rhs.x;
    y = rhs.y;
    return *this;
}

이 코드는 멤버 변수의 값을 단순히 복사하는 구조로,
컴파일러가 자동 생성한 기본 대입 연산자의 동작과 동일하다.

✅ 예제 구조 (Array 클래스의 얕은 복사 상황)

Array a1{5, 10};   // 0x1000: [5,6,7,...,14]
Array a2{3, 5};    // 0x2000: [3,4,5,6,7]

a2 = a1;  // 얕은 복사 발생!

📌 복사 후 상태

객체  size  ptr 주소  가리키는 힙 메모리
a1 10 0x1000 [5,6,7,...,14]
a2 10 0x1000 [5,6,7,...,14]
  • a2.ptr = a1.ptr; 이 되어 동일한 힙 메모리를 가리키게 됨
  • a2.size = a1.size; → size = 10 으로 덮어쓰기 됨
  • a2가 원래 가지고 있던 0x2000 메모리는 해제되지 않음 → 메모리 누수 발생
  • a1과 a2가 동일한 메모리를 가리키므로 소멸자가 두 번 delete[] ptr; 시도이중 해제

🧨 중단점 & 디버깅 시 관찰되는 현상

  • std::cout << a2.GetValue(0) 같은 코드를 실행하거나 중단점을 걸면
  • a2.ptr == a1.ptr 인 것을 확인할 수 있음
  • 힙 메모리 해제 순서상 a1이 먼저 delete[] 실행 후,
    나중에 a2도 같은 주소를 해제하려 하기 때문에
    런타임에서 Exception Thrown 오류가 발생한다.

❗ 이 문제의 핵심

  • 얕은 복사는 포인터 멤버를 그대로 복사하기 때문에 위험하다
  • 따라서 포인터를 멤버로 가지는 클래스에서는
    반드시 깊은 복사가 가능한 사용자 정의 대입 연산자를 만들어야 한다

✅ 해결 방법: 깊은 복사 구현

Array& operator=(const Array& rhs) {
    if (this == &rhs) return *this;

    delete[] ptr;

    size = rhs.size;
    ptr = new int[size];
    for (int i = 0; i < size; ++i)
        ptr[i] = rhs.ptr[i];

    return *this;
}

✅ 결론

  • 얕은 복사: 주소값만 복사 → 힙 메모리 공유 → 이중 해제 + 메모리 누수
  • 깊은 복사: 새 메모리를 생성하고 값을 복사 → 독립된 안전한 메모리 구조

이 상황은 C++ 객체 설계에서 매우 중요한 개념이고,
특히 포인터 멤버가 있을 때 꼭 기억해야 할 부분이다.


📘 대입 연산자 오버로딩과 깊은 복사의 전체 흐름

Array 클래스처럼 포인터 멤버를 가지는 경우,
단순한 얕은 복사로는 문제가 생기기 때문에
직접 대입 연산자 오버로딩을 통해 깊은 복사를 구현해야 한다.

✅ 대입 연산자 오버로딩 코드

Array& operator=(const Array& rhs) {
    if (this == &rhs)
        return *this;  // 자기 자신 대입 방지

    delete[] ptr;  // 기존 메모리 해제

    ptr = new int[rhs.size];  // 새로운 메모리 할당
    size = rhs.size;

    for (int i = 0; i < size; i++) {
        ptr[i] = rhs.ptr[i];  // 깊은 복사
    }

    return *this;  // 자기 자신 참조 반환
}

 

🧠 전체 흐름 설명

  1. 자기 자신인지 확인
  2. 기존 메모리 해제
  3. 새 메모리 할당 + 복사
  4. 참조 반환

1. 자기 자신인지 확인

if (this == &rhs) return *this;
 
  • 만약 a1 = a1; 같이 자기 자신에게 대입하는 경우라면,
  • 불필요한 해제와 복사가 일어나지 않도록 바로 리턴한다.
  • 이 코드가 없으면 delete[] ptr;로 인해 데이터를 날려버리고
    복사하려는 대상이 사라지는 심각한 문제가 발생한다.

2. 기존 메모리 해제

delete[] ptr;
  • a2 = a1; 수행 시, a2가 원래 가리키고 있던 메모리(예: 0x2000)를 해제해야 한다.
  • 그렇지 않으면 메모리 누수(memory leak) 발생.

3. 새 메모리 할당 + 복사

ptr = new int[rhs.size];
size = rhs.size;
for (int i = 0; i < size; i++) {
    ptr[i] = rhs.ptr[i];
}
  • 새롭게 메모리 공간을 확보 (예: 0x3000)
  • rhs.ptr로부터 값을 하나씩 복사 (깊은 복사)

4. 참조 반환

return *this;
  • 이렇게 해야 a = b = c; 같은 연속 대입(chain assignment) 이 가능하다.
  • 반환형이 Array가 아니라 Array& 여야 복사 비용도 줄이고, 원본 객체로 접근할 수 있다.

🧠 메모리 시각화 흐름

[기존 상태]
a1: size = 3, ptr → 0x1000 → [5 6 7]
a2: size = 3, ptr → 0x2000 → [3 4 5]

[a2 = a1 실행]
1) delete[] 0x2000
2) new int[3] → 0x3000
3) 복사: 0x3000 ← [5 6 7]
4) a2.ptr → 0x3000

❓ 기존 메모리에 다시 복사하면 더 효율적이지 않아?

즉, delete[] ptr 없이 그냥 같은 공간에 덮어쓰는 게 낫지 않나?

✅ 답변:

상황에 따라 다르지만, 일반적으로는 delete 후 새로 할당하는 것이 안전하고 더 낫다.

🔹 이유 1: 크기가 다를 수 있다

  • a1 = a2; 를 수행할 때
    a1.size == a2.size 보장이 없다.
  • 기존 메모리에 덮어쓰기 하려면 항상 크기를 비교해야 하고,
    크기가 다르면 다시 delete 하고 new 해야 하므로,
    결국 조건문이 늘어나는 것보다 항상 재할당이 더 깔끔하다.

🔹 이유 2: 관리 일관성

  • delete → new → 복사 구조는 매우 명확하고 버그가 적음.
  • 포인터 관리가 복잡해질수록 실수로 인한 메모리 손상이 생기기 쉽다.

✅ 정리하면

방식  장점  단점 
항상 delete → new (현재 방식) 코드 간결, 안전 약간의 성능 낭비 가능
조건 검사 후 reuse 메모리 재사용 가능 (성능↑) 조건 분기, 관리 복잡, 버그↑
성능이 아주 민감한 시스템을 다루는게 아니라면
지금처럼 항상 안전하고 새로 할당하는 방식이 가장 좋고 추천한다.

ChatGPT 로 만든 예제

📄 예제: Point_asMember_assignment_deep.cpp

🎯 설명

Point 클래스가 멤버로 포인터를 갖고 있으며 깊은 복사를 위한 대입 연산자 오버로딩 구현

#include <iostream>
#include <cstring>

class Point {
private:
    int* x;
    int* y;

public:
    Point(int xVal = 0, int yVal = 0) {
        x = new int(xVal);
        y = new int(yVal);
    }

    ~Point() {
        delete x;
        delete y;
    }

    Point(const Point& rhs) {
        x = new int(*rhs.x);
        y = new int(*rhs.y);
    }

    Point& operator=(const Point& rhs) {
        if (this == &rhs) return *this;

        delete x;
        delete y;
        x = new int(*rhs.x);
        y = new int(*rhs.y);
        return *this;
    }

    void Show() const {
        std::cout << "[" << *x << ", " << *y << "]\n";
    }
};

int main() {
    Point p1(3, 4);
    Point p2;
    p2 = p1;
    p2.Show();
}

📄 예제: Array_asMember_assignment_deep.cpp

🎯 설명

Array 클래스가 멤버로 포인터를 갖고 있으며 깊은 복사를 위한 대입 연산자 오버로딩 구현

#include <iostream>

class Array {
private:
    int* ptr;
    int size;

public:
    Array(int val = 0, int sz = 5) : size(sz) {
        ptr = new int[size];
        for (int i = 0; i < size; i++)
            ptr[i] = val + i;
    }

    ~Array() {
        delete[] ptr;
    }

    Array(const Array& rhs) {
        size = rhs.size;
        ptr = new int[size];
        for (int i = 0; i < size; i++)
            ptr[i] = rhs.ptr[i];
    }

    Array& operator=(const Array& rhs) {
        if (this == &rhs) return *this;

        delete[] ptr;
        size = rhs.size;
        ptr = new int[size];
        for (int i = 0; i < size; i++)
            ptr[i] = rhs.ptr[i];

        return *this;
    }

    void Show() const {
        for (int i = 0; i < size; i++)
            std::cout << ptr[i] << " ";
        std::cout << "\n";
    }
};

int main() {
    Array a1(10, 3);
    Array a2;
    a2 = a1;
    a2.Show();
}

Copy/Move Constructor& Overloaded Operator=

◆  (참고) 상속에서의 대입 연산자 오버로딩

class Base {
	int value;
public:
	Base& operator=(const Base& rhs) {
		if (this != &rhs)
		{
			value = rhs.value;
		}
		return *this;
	}
};
class Derived : public Base {
	int double_value;
public:
	Derived& operator=(const Derived& rhs)
	{
		if (this != &rhs)
		{
			Base::operator=(rhs);
			double_value = rhs.double_value;
		}
		return *this;
	}
};
* 기본 클래스에 대한 대입 연산자를 호출하고 난 뒤, 유도 클래스의 속성에 대한 대입 연산을 처리하도록 구현
* 만일 기본 클래스 연산자를 호출하지 않는다면, value 값에 대한 대입 연산은 수행되지 않는다.

📘 상속에서의 대입 연산자 오버로딩 정리

✅ 1. 기본 클래스(Base)의 대입 연산자

class Base {
    int value;

public:
    Base& operator=(const Base& rhs) {
        if (this != &rhs) {
            value = rhs.value;
        }
        return *this;
    }
};
  • Base 클래스는 value 멤버 변수 하나를 갖고 있고,
  • 자신의 값만 복사하도록 operator=를 직접 정의하고 있다.
  • 자기 자신 대입 방지도 포함되어 있다.

✅ 2. 파생 클래스(Derived)의 대입 연산자

class Derived : public Base {
    int double_value;

public:
    Derived& operator=(const Derived& rhs) {
        if (this != &rhs) {
            Base::operator=(rhs);               // ⬅️ Base 부분 복사
            double_value = rhs.double_value;    // ⬅️ Derived 부분 복사
        }
        return *this;
    }
};
  • Derived 클래스는 Base를 상속받고, 추가 멤버로 double_value를 가지고 있다.
  • 여기서 중요한 점은 기본 클래스의 대입 연산자를 명시적으로 호출하고 있다는 점이다.

❗왜 Base::operator=(rhs) 를 호출해야 하나?

기본 클래스의 멤버(value)도 함께 복사되길 원한다면,
Base의 대입 연산자를 직접 호출해줘야 한다.

그렇지 않으면 아래와 같은 문제가 발생한다:

호출 방식  결과 
Base::operator=(rhs); 포함 value와 double_value 모두 복사됨 ✅
포함하지 않음 double_value만 복사됨 ❌ → value는 그대로

✅ 핵심 정리

  • 파생 클래스에서 operator=를 오버로딩할 경우,
    반드시 기본 클래스의 대입 연산자도 호출해줘야 한다.
  • 그렇지 않으면 기본 클래스의 멤버 변수는 복사되지 않음.
  • 이는 복사 생성자, 이동 생성자에서도 마찬가지로 적용됨.

🔑 실전 팁

상황  해야 할 일
상속 구조에서 복사 연산자 구현 시 Base::operator=(rhs)를 명시적으로 호출할 것
값이 단순 타입(int, double 등)일 경우 직접 대입하거나, 기본 연산자 호출로 해결 가능

📘 예제로 배우는 상속에서의 대입 연산자 오버로딩

🎯 개념 목표

  • 파생 클래스가 기본 클래스의 멤버도 올바르게 복사하려면
    기본 클래스의 대입 연산자를 명시적으로 호출해야 한다는 것

✅ 예제: Point와 ColorPoint

#include <iostream>
#include <cstring>

class Point {
private:
    int x;
    int y;

public:
    Point(int x = 0, int y = 0) : x(x), y(y) {}

    Point& operator=(const Point& rhs) {
        if (this != &rhs) {
            x = rhs.x;
            y = rhs.y;
        }
        return *this;
    }

    void Show() const {
        std::cout << "[" << x << ", " << y << "]";
    }
};
class ColorPoint : public Point {
private:
    char color[20];

public:
    ColorPoint(int x, int y, const char* c) : Point(x, y) {
        strcpy_s(color, c);
    }

    ColorPoint& operator=(const ColorPoint& rhs) {
        if (this != &rhs) {
            Point::operator=(rhs);  // ⬅️ 기본 클래스 복사 명시
            strcpy_s(color, rhs.color);
        }
        return *this;
    }

    void Show() const {
        Point::Show();
        std::cout << ", Color: " << color << std::endl;
    }
};

✅ main 함수 테스트

int main() {
    ColorPoint cp1(10, 20, "Red");
    ColorPoint cp2(0, 0, "Blue");

    cp2 = cp1;

    cp2.Show();  // 결과: [10, 20], Color: Red
}

🧠 핵심 설명

  • cp1과 cp2는 ColorPoint 객체이지만,
  • 그 내부에는 Point 부분도 존재한다.
    대입 시 Point의 x, y 값도 복사되길 원하면:
Point::operator=(rhs);

이 구문을 명시적으로 써야 한다.

❗ 왜 꼭 명시해야 하나?

C++은 파생 클래스의 대입 연산자에서 기본 클래스의 대입 연산자를 자동 호출하지 않기 때문이다.
자동으로 호출되는 건 생성자에서만이다.

✅ 실무/코테 팁 요약

상황  필요한 처리 
파생 클래스의 대입 연산자 구현 시 Base::operator=(rhs); 명시적으로 호출할 것
기본 클래스에 포인터 멤버가 있다면 Base의 깊은 복사 구현 필요, 파생 클래스에서도 호출 필수
생성자에서는? 자동 호출되므로 별도로 호출하지 않아도 됨

🎯 코테에서 출제 가능한 유형 3가지

✅ 1. 얕은 복사 vs 깊은 복사 구분 문제

❓다음 코드의 결과는?

class A {
private:
    int* data;
public:
    A(int val) { data = new int(val); }
    ~A() { delete data; }
    void Show() { std::cout << *data << std::endl; }
};

int main() {
    A a1(5);
    A a2 = a1;
    a2.Show();
}

📌 보기:

A. 5 출력됨
B. 컴파일 에러
C. 런타임 에러 (이중 해제)
D. 무한 루프 발생

정답: C

기본 복사 생성자로 얕은 복사가 수행되어 두 객체가 같은 data를 가리킴 → 소멸자에서 이중 해제 발생

✅ 2. 대입 연산자 직접 구현 문제

❓포인터 멤버를 가진 클래스에서 안전하게 동작하도록
대입 연산자 오버로딩을 완성하시오:

class Buffer {
private:
    char* data;
public:
    Buffer(const char* str);
    ~Buffer();
    // TODO: operator= 완성
};

✅ 답안 요건:

  • 자기자신 체크
  • 기존 메모리 해제
  • 깊은 복사
  • 참조 반환
class Buffer {
private:
    char* data;
public:
    Buffer(const char* str);
    ~Buffer();
    // TODO: operator= 완성
    Buffer& operator=(const Buffer& rhs)
    {
    	if(this == &rhs) return *this; // 자기 자신 대입 방지
        
        delete[] data; // 기존 메모리 해제
        
        data = new char[strlen(rhs.data) + 1];
        strcpy_s(data, strlen(rhs.data) + 1, rhs.data); // 깊은 복사
        
        return *this;
};

✅ 3. 상속 구조에서의 복사/할당 누락 찾기

❓다음 코드에서 복사가 누락된 멤버가 무엇인지 고르시오:

class Base {
protected:
    int* val;
public:
    Base(const Base& rhs);
    Base& operator=(const Base& rhs);
};

class Derived : public Base {
private:
    char* label;
public:
    Derived(const Derived& rhs);
    // 복사 생성자 작성
};

📌 보기:

A. val 복사 안됨
B. label 복사 안됨
C. 소멸자 없음
D. 모두 복사됨

정답: B

Base 생성자만 호출하고, 파생 클래스 멤버(label) 복사가 빠졌다면, 그게 문제.

❓  C++ 미니 퀴즈

1. 클래스에 포인터 멤버가 존재할 경우, 복사 생성자와 대입 연산자 오버로딩이 필요한 이유로 가장 적절한 것은?

📌 보기:

A. 포인터 변수는 무조건 오류를 발생시킴
B. 객체 크기를 줄이기 위해서
C. 얕은 복사가 포인터 주소만 복사하므로, 메모리 공유로 인한 오류 방지
D. 포인터는 자동으로 복사되지 않음

정답: C

2. 다음 중 연속 대입이 가능한 대입 연산자의 반환 타입은?

📌 보기:

A. void
B. int
C. 객체 자체
D. 객체 참조형

정답: D

3. 다음 중 상속 관계에서 파생 클래스의 대입 연산자 오버로딩 시 반드시 해야 할 일은?

📌 보기:

A. 파생 클래스 멤버만 복사
B. 기본 클래스의 대입 연산자 호출 생략
C. 기본 클래스의 대입 연산자를 명시적으로 호출
D. 복사 생성자 호출

정답: C

4. 다음 중 Rule of Three에 해당하지 않는 항목

📌 보기:

A. 복사 생성자
B. 소멸자
C. 이동 생성자
D. 대입 연산자

정답: C

백준/프로그래머스 스타일의 C++ 코딩 테스트 문제로 구성

✅ 문제 : 클래스 깊은 복사 연산자 구현

정수 배열을 내부적으로 관리하는 MyArray 클래스를 구현하시오.
이 클래스는 동적 메모리를 사용하며, 복사 생성자와 대입 연산자를 통해 깊은 복사가 수행되어야 합니다.

MyArray 클래스는 다음 기능을 제공해야 합니다:

1. 생성자: MyArray(int start, int size)
   - start 부터 시작하는 size개의 정수를 저장하는 배열을 힙에 동적 할당하여 초기화합니다.

2. 복사 생성자: MyArray(const MyArray& rhs)
   - 깊은 복사를 수행합니다.

3. 대입 연산자 오버로딩: MyArray& operator=(const MyArray& rhs)
   - 깊은 복사를 수행하며 자기 자신에 대한 대입은 무시합니다.

4. Print 함수: void Print() const
   - 배열의 모든 원소를 공백으로 구분하여 출력합니다. (마지막에 개행 포함)

제약:
- 배열 크기(size)는 1 이상 100 이하의 정수입니다.
- 표준 라이브러리 <iostream>과 <algorithm> 외의 STL 사용은 금지합니다.

입력: 없음 (main 함수에 하드코딩되어 있음)
출력:
10 11 12
10 11 12

#include <iostream>
using namespace std;

// 이 아래에 MyArray 클래스 정의를 작성하시오.




// 테스트용 main 함수 (수정하지 말 것)
int main() {
    MyArray arr1(10, 3); // 10부터 시작하는 3개짜리 배열
    MyArray arr2 = arr1; // 복사 생성자 호출
    arr1.Print();
    arr2.Print();
    return 0;
}

◆  (참고) 상속에서의 대입 연산자 오버로딩

   ●  유도 클래스에서 사용자가 이를 구현하지 않은 경우,
        ☞     컴파일러가 자동으로 생성하며, 기본 클래스를 위한 복사/이동 생성자를 호출
   ●  유도 클래스에서 사용자가 이를 구현한 경우,
        ☞    기본 클래스를 위한 복사/이동 생성자를 사용자가 반드시 호출해 주어야 한다.

   ●  따라서, 포인터형 멤버 변수를 가지고 있는 경우,
   기본 클래스의 복사/이동 생성자를 호출하는 방법에 대해 반드시 숙지해두어야함
        ☞    유도 클래스에 멤버 변수에 대한 깊은 복사 고려


📘 상속에서의 대입 연산자 오버로딩 – 핵심 요약

✅ 상황 1: 파생 클래스에서 대입 연산자를 구현하지 않은 경우

  • 컴파일러가 자동으로 생성한다.
  • 이때 기본 클래스(Base)의 복사/이동 생성자나 대입 연산자도 자동으로 호출된다.
  • 이 방식은 단순한 얕은 복사 구조에서는 문제없이 작동.

✅ 상황 2: 파생 클래스에서 대입 연산자를 직접 구현한 경우

  • 사용자가 만든 Derived::operator=() 내에서는
    기본 클래스의 복사/이동 생성자를 자동 호출하지 않음!
  • 따라서 직접 Base::operator=(rhs); 명시적으로 호출해야 한다.
  • 이걸 누락하면 기본 클래스의 멤버는 복사되지 않음 ❗

❗ 포인터형 멤버가 있는 경우 주의할 점

상황  주의사항 
기본 클래스에 포인터 멤버 있음 깊은 복사를 구현한 operator= 를 명시적으로 호출해야 함
파생 클래스에 포인터 멤버 있음 그 역시 깊은 복사 구현 필요
두 클래스 모두 포인터 있음 Base → Derived 순서로 명시적으로 복사 연산자 호출 필요

✅ 실전 예시 (다시 복습)

class Base {
protected:
    int* data;
public:
    Base& operator=(const Base& rhs) {
        if (this != &rhs) {
            delete data;
            data = new int(*rhs.data);
        }
        return *this;
    }
};

class Derived : public Base {
private:
    char* label;
public:
    Derived& operator=(const Derived& rhs) {
        if (this != &rhs) {
            Base::operator=(rhs);  // ❗ 명시적으로 호출해야 함
            delete[] label;
            label = new char[strlen(rhs.label) + 1];
            strcpy_s(label, strlen(rhs.label) + 1, rhs.label);
        }
        return *this;
    }
};
 

🧠 핵심 암기 포인트

  • 파생 클래스에서 operator= 구현 시 기본 클래스의 것도 꼭 호출해야 한다
  • 복사 누락은 버그로 직결됨 (특히 포인터 멤버일 경우)
  • 깊은 복사가 필요한 구조에서는 항상 직접 구현 + 명시적 호출

❓실전 퀴즈

다음 중 파생 클래스에서 기본 클래스의 대입 연산자를 반드시 호출해야 하는 상황은?

A. 생성자에서 기본 클래스 멤버를 초기화할 때
B. 복사 생성자 없이 대입 연산자를 자동 생성할 때
C. 파생 클래스에서 operator=를 직접 정의할 때
D. 기본 클래스의 멤버가 모두 값 타입(int, double)일 때

👉 정답: C


Operator Overloading

◆  연산자 오버로딩 : 클래스에 대한 연산자의 적용 방식을 사용자가 직접 오버로딩하여 구현할 수 있다.

◆  멤버 한수인 연산자 오버로딩 : 클래스의 멤버함수로 operatorX()라는 이름을 갖는 함수를 구현하여 연산자를 오버로딩 할 수 있다. 이때 이항 연산자의 경우 우측 피연산자는 인자로 넘어온다.
◆  전역 함수인 연산자 오버로딩 : 멤버 함수로 구현 시 교환 법칙 문제가 발생할 수 있고, 이러한 경우 전역 함수로 오버로딩하며, 이때 friend 키워드를 사용하면 편리함
◆  스트림 삽입 및 추출 연산자 오버로딩 : << , >> 도 연산자이며, cout / cin 객체에 대해 오버라이딩 하면 된다. Chain insertion, extraction을 위해 참조자 반환 필요 
◆  대입 연산자 오버로딩 : 기본 대입 연산자는 얕은 복사를 수행하기 때문에, 깊은 보가사가 필요한 경우 대입 연산자 직접 오버로딩이 반드시 필요
◆  첨자 연상자 오버로딩


📘 대입 연산자 오버로딩이 중요한 이유

C++에서는 대입 연산자(=)도 오버로딩이 가능한 연산자 중 하나이며,
클래스가 포인터 멤버를 포함할 경우에는 반드시 직접 구현해야 한다.

✅ 기본 대입 연산자의 문제점

C++에서는 클래스에 대입 연산자를 작성하지 않아도,
컴파일러가 기본 대입 연산자(default assignment operator) 를 자동으로 생성해준다.

Point p1(3, 4);
Point p2;
p2 = p1;  // 자동 생성된 대입 연산자 호출 (얕은 복사)

기본 대입 연산자가 수행하는 작업은 아래와 같다:

Point& operator=(const Point& rhs) {
    x = rhs.x;
    y = rhs.y;
    return *this;
}

즉, 단순한 멤버 변수 값 복사(얕은 복사) 만 수행한다.

❗ 얕은 복사의 문제점

  • 멤버 변수에 포인터가 포함되어 있을 경우,
    얕은 복사를 하면 포인터 주소만 복사되므로
    여러 객체가 같은 힙 메모리를 공유하게 된다.
  • 이 경우, 한 객체가 메모리를 해제하면
    다른 객체는 해제된 주소를 참조하게 되어 프로그램 오류 발생 (이중 해제 등)

✅ 깊은 복사가 필요한 경우

포인터 멤버가 있는 클래스에서는
기본 대입 연산자 대신 직접 오버로딩된 대입 연산자를 구현해야 한다.

예:

Array& operator=(const Array& rhs) {
    if (this == &rhs) return *this;

    delete[] ptr;  // 기존 메모리 해제
    ptr = new int[rhs.size];
    size = rhs.size;

    for (int i = 0; i < size; i++)
        ptr[i] = rhs.ptr[i];  // 깊은 복사

    return *this;
}

이렇게 하면 두 객체가 서로 다른 메모리 공간에 동일한 데이터를 가지게 되어,
복사, 해제 모두 안정적으로 수행된다.

✅ 결론

  • 기본 대입 연산자는 얕은 복사를 수행하므로
    포인터 멤버를 가진 클래스에서는 위험할 수 있다.
  • 따라서 깊은 복사가 필요하면 반드시 대입 연산자를 직접 오버로딩해야 한다.
  • 이때 operator=는
    • 자기 자신 검사
    • 기존 메모리 해제
    • 새로운 메모리 할당 및 복사
    • 참조 반환 (return *this)
      를 반드시 포함해야 한다.

https://youtu.be/FIZtPkqQ1Ps?si=xWZJnzKrcB4leKDy

수준 높은 강의에 감사드립니다.