중요한 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 이 같은 메모리(힙)를 가리키게 된다.
✅ 얕은 복사로 생기는 문제 시나리오
- a1과 a2는 서로 같은 메모리를 가리키게 됨.
- 프로그램 종료 시 a1과 a2 모두 소멸자에서 delete[] ptr; 호출
- 결국 같은 메모리 블록을 두 번 해제하게 됨 → 이중 해제 (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. 자기 자신인지 확인
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
수준 높은 강의에 감사드립니다.
'C++ > C++ : Study' 카테고리의 다른 글
8. 연산자 오버로딩 (10) - 첨자 연산자 오버로딩 (1) | 2025.06.22 |
---|---|
8. 연산자 오버로딩 (9) - 첨자 연산자 오버로딩 (2) | 2025.06.22 |
8. 연산자 오버로딩 (7) - 대입 연산자 , 얕은 복사 (0) | 2025.06.20 |
8. 연산자 오버로딩 (6) - 스트림 삽입 및 추출 연산자 오버로딩 (1) | 2025.06.18 |
8. 연산자 오버로딩 (5) - Ostream 객체 (0) | 2025.06.16 |