중요한 Part이다.★★★★★
Assignment Operator Overloading
◆ 대입 연산자 오버로딩
● C++는 대입 연산자를 자동 생성해 준다.
● 대입 연산과 복사 생성의 구분
● 자동 생성된 대입 연산자는 얕은 복사를 수행
Point p1{ 10,20 };
Point p2{ 30,40 };
Point p3 = p1; // ** 대입이 아닌 복사 생성
p3.ShowPosition();
Point p4;
p4 = p1; // * 대입 연산
p4.ShowPosition();
📘 대입 연산자 오버로딩 (Assignment Operator Overloading)
C++에서 대입 연산자 = 또한 오버로딩이 가능한 이항 연산자이다.
하지만 이 연산자는 객체 복사와 밀접하게 연관되어 있기 때문에
복사 생성자와 함께 반드시 개념적으로 구분해서 이해해야 한다.
📘 코드 보기
- Point p3 = p1;
- 이 코드는 대입 연산이 아니라 복사 생성자 호출이다.
- 객체 p3가 생성되면서 p1의 값을 복사하여 초기화된다.
- p4 = p1;
- 이 경우는 이미 생성된 객체 p4에 p1을 대입하므로
대입 연산자 operator= 가 호출된다.
- 이 경우는 이미 생성된 객체 p4에 p1을 대입하므로
이 두 가지는 반드시 구분해야 하며, =가 있다고 해서 모두 대입 연산자 호출이 되는 것은 아니다.
✅ 대입 연산자는 기본적으로 자동 생성된다
C++에서는 클래스에 대입 연산자를 명시적으로 정의하지 않아도
컴파일러가 얕은 복사를 수행하는 대입 연산자를 자동으로 생성해준다.
즉, 아래 코드를 작성하지 않아도:
Point& operator=(const Point& other);
컴파일러는 기본적인 구조 복사를 수행하는 operator=를 자동으로 제공한다.
❗ 얕은 복사의 한계
자동 생성되는 대입 연산자는 얕은 복사(shallow copy)를 수행한다.
얕은 복사는 멤버 데이터를 그대로 복사하는 방식이기 때문에
포인터나 동적 메모리를 사용하는 클래스의 경우 심각한 문제가 발생할 수 있다.
❓ 얕은 복사로 인한 문제는 무엇인가?
얕은 복사는 포인터 멤버를 단순히 주소값만 복사하기 때문에,
두 객체가 동일한 메모리 공간을 공유하게 된다.
예를 들어 다음과 같은 클래스가 있을 때:
class Sample {
int* data;
};
얕은 복사를 하면 두 객체의 data 포인터가 같은 힙 메모리를 가리키게 되며,
한 객체가 메모리를 해제하면 다른 객체의 포인터는 댕글링 포인터(dangling pointer)가 된다.
이로 인해 다음과 같은 문제들이 발생한다:
- 이중 해제(double free)
- 예기치 않은 메모리 손상
- 프로그램의 비정상 종료
이러한 이유로 사용자 정의 대입 연산자를 직접 오버로딩하여 깊은 복사(deep copy)로 구현하는 것이 필요할 수 있다.
✅ 결론
- Point p3 = p1; 은 복사 생성자
- p4 = p1; 은 대입 연산자
- 둘 다 명시적으로 정의하지 않아도 C++에서 자동으로 생성하지만,
포인터나 동적 리소스를 관리하는 경우에는 반드시 직접 정의해야 한다.
◆ 대입 연산자 오버로딩, 선언
● 기본 패턴
Type& operator=(const Type& rhs);
● 참조형으로 반환하여 추가적인 복사 연산을 하지 않도록 한다
Q. 참조형으로 반환하지 않으면 어떤 일이 벌어질까?
● 사용 예
Point& operator=(const Point& rhs)
p4 = p1; // p4.operator=(p1);
✅ 주요 구성 요소 설명
- const Type& rhs: 대입할 대상 객체를 상수 참조자로 전달한다.
- 이유: 복사 대상인 rhs 객체를 변경할 필요가 없기 때문
- 복사 비용을 줄이기 위해 값 전달이 아닌 참조자를 사용한다.
- Type& (반환형): 자신의 참조자 *this를 반환한다.
- 이유: 연속된 대입 연산을 가능하게 하기 위해서
- 예: a = b = c; 는 (a = (b = c));처럼 오른쪽부터 수행된다.
- 이때 각 대입 연산이 자기 자신을 참조자로 반환해야 다음 대입이 가능하다.
✅ 예시 코드
class Point {
private:
int x, y;
public:
Point(int xpos = 0, int ypos = 0) : x(xpos), y(ypos) {}
Point& operator=(const Point& rhs) {
if (this == &rhs) return *this; // 자기 자신 체크
x = rhs.x;
y = rhs.y;
return *this;
}
};
이렇게 하면 p1 = p2 = p3; 와 같은 코드가 자연스럽게 동작한다.
❓ 왜 대입 연산자는 참조형으로 반환해야 하는가?
대입 연산자의 반환형이 참조형(Type&)이 아닌 경우, 다음 문제가 발생한다:
1. 연속 대입이 불가능해짐
예시: 반환형이 void일 경우
a = b = c; // 오류 발생
- b = c 가 먼저 수행되는데 반환값이 없으므로,
- 그 결과를 a =... 에 사용할 수 없다.
- 따라서 연속 대입(chain assignment) 이 불가능하다.
2. 복사 비용 증가
예시: 반환형이 Type일 경우
Type operator=(const Type& rhs); // 값 반환
Type a, b, c;
a = b = c;
- b = c의 결과로 복사본이 생성되고 반환된다.
- a = 복사본;에서 또 한 번 복사가 발생한다.
- 즉, 불필요한 객체 복사가 일어나며 성능 저하가 생긴다.
3. 원본 객체를 수정해야 할 경우 참조가 유리함
- 반환형이 Type&이면 반환값에 대해 연속적인 작업이 가능하다:
(a = b).SetX(10); // a.x = 10 이 된다
- 이처럼 대입 후 바로 추가적인 멤버 접근이나 연산이 필요한 경우 유용하다.
정리하면,
대입 연산자는 반드시 자기 자신을 참조(*this)로 반환해야
✔️ 연속 대입 가능,
✔️ 불필요한 복사 방지,
✔️ 직관적인 멤버 체이닝 이 가능해진다.
📘 대입 연산자 오버로딩: 기본 원리와 구현
C++에서는 객체 간 대입을 위한 연산자인 = 연산자도
연산자 오버로딩이 가능한 이항 연산자이다.
✅ 기본 구조와 자동 생성
다음 코드를 보자:
class Point {
private:
int xPos;
int yPos;
public:
Point(int x, int y) : xPos{x}, yPos{y} {}
Point() : Point{0, 0} {}
};
int main() {
Point p1{10, 20};
Point p2{30, 40};
Point p3;
p3 = p1; // p3.operator=(p1)
}
위 코드에서 p3 = p1;은 내부적으로 다음과 같이 해석된다:
p3.operator=(p1); // 멤버 함수로 해석됨
혹은, 만약 전역 함수로 정의했다면 다음처럼도 해석될 수 있다:
operator=(p3, p1); // 전역 함수 호출
하지만 operator=가 직접 정의되어 있지 않아도
컴파일 시 오류가 발생하지 않는다.
그 이유는 C++ 컴파일러가 기본 대입 연산자를 자동으로 생성해 주기 때문이다.
✅ 자동 생성되는 대입 연산자
- 생성 조건: 사용자 정의 operator=가 없는 경우
- 수행 방식: 멤버 변수들을 단순 복사 (얕은 복사)
- 포인터나 리소스가 없을 경우, 대부분 문제없이 작동
✅ 사용자 정의 대입 연산자 직접 구현
다음은 Point 클래스에 대입 연산자를 명시적으로 정의한 예시이다:
class Point {
private:
int xPos;
int yPos;
public:
Point(int x , int y)
:xPos{x}, yPos{y}{ }
Point()
:Point{ 0,0 }{ }
Point& operator=(const Point& rhs){}
};
Point& operator=(const Point& rhs) {}와 같은 대입 연산자 함수가 정의되어 있으면,
p3 = p1; 구문은 내부적으로 p3.operator=(p1);로 해석되어 호출된다.
Point 클래스에 operator= 함수가 정의되어 있고,
이 함수는 const Point& 형식의 참조자를 인자로 받기 때문에
p1을 넘겨주는 데 아무런 문제가 없다.
✅ 사용자 정의 대입 연산자 직접 구현
대입 연산자가 자동 생성되더라도,
동적 메모리, 포인터, 자원 관리가 있는 클래스라면 직접 구현하는 것이 안전하다.
다음은 Point 클래스에 대입 연산자를 명시적으로 정의한 예시이다:
class Point {
private:
int xPos;
int yPos;
public:
Point(int x, int y) : xPos{x}, yPos{y} {}
Point() : Point{0, 0} {}
Point& operator=(const Point& rhs) {
if (this == &rhs) return *this; // 자기 대입 방지
xPos = rhs.xPos;
yPos = rhs.yPos;
return *this;
}
};
✅ 주요 포인트 정리
요소 | 설명 |
const Point& | 대입 대상 객체를 변경하지 않기 위해 상수 참조로 받음 |
Point& 반환 | 연속 대입을 가능하게 하기 위해 자기 참조를 반환 |
this == &rhs | 자기 자신에 대한 대입을 방지하는 보호 조건 |
멤버 복사 | 직접 멤버들을 복사하는 깊은 복사 방식의 기초 |
✅ 결과
Point p1{10, 20};
Point p3;
p3 = p1; // 명시적 대입 연산자 호출
이 코드는 p3.operator=(p1);으로 변환되어 실행되며,
Point 클래스에 operator=가 명시적으로 구현되어 있기 때문에
컴파일러가 자동 생성한 대입 연산자가 아닌 사용자 정의 연산자 함수가 호출된다.
● 대입 연산자의 구현
Point& operator=(const Point& rhs)
{
if(this==&rhs) // 왼쪽 객체의 주소(this)가 오른쪽 객체의 주소와 같은 경우 (p1 = p1)
return *this; // 왼쪽 객체의 주소(this)를 역참조하여 반환
xPos = rhs.xPos; // 그렇지 않으면 인자로 넘어온 객체(=우변)의 멤버변수 값을 복사해
yPos = rhs.yPos; // 왼쪽 객체의 멤버 변수에 대입
return *this;
}
Q. 어떤 경우에 필요할까? (Hint : 멤버 변수가 포인터라면 어떤 문제가 발생할 수 있을까?)
📘 대입 연산자 오버로딩: this 포인터와 return *this 의미
다음과 같은 대입 연산자 오버로딩 함수가 있다고 하자:
Point& operator=(const Point& rhs)
{
if (this == &rhs) // 자기 자신에 대한 대입 방지
return *this;
xPos = rhs.xPos;
yPos = rhs.yPos;
return *this;
}
✅ 함수 호출 예시
Point p1{10, 20};
Point p3;
p3 = p1;
이 코드는 내부적으로 p3.operator=(p1);로 해석된다.
따라서 this는 p3 객체의 주소이고, rhs는 p1 객체의 참조자다.
✅ this와 자기 대입 검사
if (this == &rhs)
return *this;
이 코드는 p3 = p3; 와 같이 자기 자신에게 대입하는 경우를 방지하기 위해 존재한다.
- this: 현재 멤버 함수를 호출한 객체의 주소 (p3)
- &rhs: 인자로 전달된 객체의 주소 (p1)
이 두 주소가 같다는 것은 같은 객체라는 뜻이다.
이 경우에는 멤버 변수의 값을 다시 복사할 필요가 없으므로,
그냥 자기 자신을 반환하여 대입 연산을 건너뛰게 한다.
✅ return *this 의 의미
return *this;
- this는 현재 객체의 주소이다.
- *this는 해당 객체의 자체를 참조 형식으로 반환하는 것이다.
이렇게 하면 대입된 객체 자체가 반환되므로 다음과 같은 연속 대입이 가능하다:
p1 = p2 = p3;
이 코드는 (p2 = p3)의 결과인 p2를 다시 p1 = ...에 넘기게 된다.
✅ 예시 확인
Point p1{10, 20};
Point p3;
p3.PrintPosition(); // [0, 0]
p3 = p1;
p3.PrintPosition(); // [10, 20]
- 대입 연산자를 호출하여 p1의 좌표가 p3로 복사되었음을 확인할 수 있다.
✅ PrintPosition 함수와 출력 연산자 비교
void PrintPosition() {
std::cout << xPos << ", " << yPos << std::endl;
}
이와 별도로 << 연산자 오버로딩을 정의할 수도 있다:
friend std::ostream& operator<<(std::ostream& os, const Point& rhs) {
os << "[" << rhs.xPos << ", " << rhs.yPos << "]";
return os;
}
이렇게 하면 std::cout << p1; 형식으로 더 간결하게 출력할 수 있다.
❓ 스트림 연산자 오버로딩으로 쓰면 이 코드가 맞지?
friend ostream& operator<<(ostream& os, const Point& rhs)
{
os << "[" << rhs.xPos << "," << rhs.yPos << "]";
return os;
}
✔️ 맞다.
- ostream 객체에 Point 객체의 정보를 출력하기 위한 연산자 오버로딩이다.
- 두 번째 인자는 읽기 전용이므로 const Point&로 받는다.
- 반환형은 ostream& 이어야 cout << p1 << p2;처럼 체인 출력이 가능하다.
❓ 반환형이 Point& 인 이유는 ① ② ③ 때문이지?
✔️ 정확하다. 다음 3가지 이유 때문이다:
- 연속 대입을 가능하게 하기 위해
→ a = b = c; 형태를 지원하려면 각 연산이 자기 자신을 반환해야 함 - 불필요한 복사 비용을 줄이기 위해
→ 반환형이 값일 경우 객체 복사가 일어남 - 원본 객체 자체를 수정한 결과를 다시 사용할 수 있기 때문에
→ 반환된 객체로 다시 멤버 호출, 연산 가능 ((a = b).SetX(10); 등)
정리하자면,
- this는 현재 객체의 주소이며,
- return *this;는 자기 자신을 참조로 반환하여
체인 대입, 성능 향상, 유연한 연산을 모두 가능하게 해준다.
❓ 어떤 경우에 대입 연산자 오버로딩이 꼭 필요할까?
(Hint: 멤버 변수가 포인터라면 어떤 문제가 발생할 수 있을까?)
대입 연산자 오버로딩이 반드시 필요한 대표적인 경우는
클래스 내부에 포인터 멤버(또는 동적 자원)가 포함되어 있을 때이다.
✅ 문제 상황: 얕은 복사로 인한 공유와 충돌
C++은 기본적으로 얕은 복사(shallow copy) 를 수행한다.
얕은 복사는 멤버 변수의 값을 단순히 복사하는 방식이다.
이때 포인터 변수는 주소값만 복사되므로,
복사된 두 객체가 같은 힙 메모리를 가리키게 된다.
예시:
class Sample {
private:
int* data;
public:
Sample(int value) {
data = new int(value);
}
~Sample() {
delete data;
}
};
이 경우 a와 b는 data라는 동일한 힙 메모리를 공유하게 되므로
a나 b 중 하나가 소멸되면 다른 하나는 삭제된 메모리를 참조하게 되어
다음과 같은 문제를 유발한다:
- 이중 해제(double delete)
- 댕글링 포인터(dangling pointer)
- 예기치 않은 데이터 변경
✅ 해결책: 깊은 복사를 수행하는 사용자 정의 대입 연산자
이런 문제를 방지하려면,
사용자가 직접 깊은 복사(deep copy) 방식으로 대입 연산자를 오버로딩해야 한다:
Sample& operator=(const Sample& rhs) {
if (this == &rhs) return *this;
delete data; // 기존 자원 정리
data = new int(*rhs.data); // 깊은 복사
return *this;
}
✅ 결론
따라서 대입 연산자 오버로딩은
다음과 같은 경우에 직접 구현해야 한다:
- 포인터를 멤버로 가지는 클래스
- 파일 핸들, 네트워크 소켓 등 시스템 자원을 다루는 클래스
- 동적 메모리 또는 참조 카운트가 중요한 클래스
이러한 경우, 기본 대입 연산자는 오류를 유발할 수 있으므로
복사 생성자 + 대입 연산자 + 소멸자를 함께 구현하는 것이 필수다.
이것을 Rule of Three 라고 부른다.
ChatGPT 가 만들어준 예제 코드
📄 예제: Point_asMember_assignment.cpp
🎯 주제 요약
- 클래스가 다른 클래스 타입을 멤버로 포함하고 있을 때
- 멤버 클래스에 사용자 정의 대입 연산자가 있을 경우
- 이 대입 연산자가 적절히 호출되는지, 얕은 복사의 문제가 없는지 확인 필요
🔸 예제 코드
#include <iostream>
#include <cstring>
class Point {
private:
int x, y;
public:
Point(int xpos = 0, int ypos = 0) : x(xpos), y(ypos) {}
void ShowPosition() const {
std::cout << "[" << x << ", " << y << "]" << std::endl;
}
Point& operator=(const Point& rhs) {
if (this == &rhs) return *this;
x = rhs.x;
y = rhs.y;
return *this;
}
};
class NameCard {
private:
char* name;
Point pos;
public:
NameCard(const char* _name, int x, int y)
: pos{x, y}
{
name = new char[strlen(_name) + 1];
strcpy(name, _name);
}
~NameCard() {
delete[] name;
}
NameCard& operator=(const NameCard& rhs) {
if (this == &rhs) return *this;
delete[] name;
name = new char[strlen(rhs.name) + 1];
strcpy(name, rhs.name);
pos = rhs.pos; // Point 클래스의 대입 연산자 호출
return *this;
}
void ShowCard() const {
std::cout << "Name: " << name << ", Position: ";
pos.ShowPosition();
}
};
int main() {
NameCard nc1("Alice", 10, 20);
NameCard nc2("Bob", 30, 40);
nc2 = nc1;
nc1.ShowCard(); // Name: Alice, Position: [10, 20]
nc2.ShowCard(); // Name: Alice, Position: [10, 20]
}
✅ 코드 설명
- Point 클래스는 좌표를 나타내는 단순 클래스이며,
직접 대입 연산자를 정의해 자기 대입 방지 + 값 복사를 구현했다. - NameCard 클래스는 문자열 포인터 name과
Point 객체 pos를 멤버로 가지고 있다. - NameCard의 대입 연산자는 다음을 수행한다:
- name의 깊은 복사 (delete → new → strcpy)
- pos = rhs.pos; 를 통해 Point 객체의 대입 연산자 호출
💡 핵심 포인트
- 멤버로 들어간 클래스(Point)에 사용자 정의 대입 연산자가 있다면,
이를 명시적으로 호출하거나,
C++이 자동 호출하도록 작성해야 한다. - Point가 포인터 멤버를 가진 클래스였다면,
깊은 복사를 위해 반드시 사용자 정의 대입 연산자가 필요했을 것이다. - 이 예제는 클래스 안에 또 다른 클래스가 있을 경우에도
깊은 복사를 고려해야 함을 보여준다.
지금 배운 내용을 바탕으로 **"얕은 복사의 문제를 직접 확인할 수 있는 예제"**를 만들어줄게.
아래 예제는 다음을 중점으로 보여줄 거야:
- 포인터 멤버를 가진 클래스
- 복사 생성자/대입 연산자 없이 사용할 경우 발생하는 얕은 복사 문제
- 두 객체가 같은 메모리를 공유해서 벌어지는 충돌
📄 예제: ShallowCopyProblem.cpp
#include <iostream>
#include <cstring>
class Person {
private:
char* name;
public:
Person(const char* pname) {
name = new char[strlen(pname) + 1];
strcpy(name, pname);
}
~Person() {
std::cout << "소멸자 호출: " << name << std::endl;
delete[] name;
}
void ShowName() const {
std::cout << "이름: " << name << std::endl;
}
};
int main() {
Person p1("Alice");
Person p2 = p1; // 복사 생성자 없음 → 얕은 복사
p1.ShowName(); // Alice
p2.ShowName(); // Alice
// main 함수 끝나면서 소멸자 호출 → 문제 발생!
// 두 객체 모두 같은 name 메모리를 지우려 함 → double free 오류
}
❗ 출력 및 오류 설명
이름: Alice
이름: Alice
소멸자 호출: Alice
소멸자 호출: Alice
*** 오류 발생 ***
free(): double free detected in tcache 2
- Person p2 = p1; 에서 복사 생성자가 정의되지 않았기 때문에
컴파일러가 얕은 복사를 수행한다. - 이로 인해 p1.name과 p2.name은 동일한 힙 메모리 주소를 공유하게 된다.
- 프로그램 종료 시 두 객체 모두 동일한 메모리를 삭제하려고 해서
double free 문제가 발생한다.
✅ 해결 방법 요약
이 문제를 해결하려면 다음 두 가지를 반드시 구현해야 해:
- 복사 생성자
- 대입 연산자
그리고 소멸자도 함께 구현하면 이걸 Rule of Three라고 한다.
https://youtu.be/WIdO-LEGXGE?si=DPKfMTfUYXyZFdtb
'C++ > C++ : Study' 카테고리의 다른 글
8. 연산자 오버로딩 (9) - 첨자 연산자 오버로딩 (2) | 2025.06.22 |
---|---|
8. 연산자 오버로딩 (8) - 대입 연산자 , 깊은 복사 (3) | 2025.06.21 |
8. 연산자 오버로딩 (6) - 스트림 삽입 및 추출 연산자 오버로딩 (1) | 2025.06.18 |
8. 연산자 오버로딩 (5) - Ostream 객체 (0) | 2025.06.16 |
8. 연산자 오버로딩 (4) - 전역 함수로의 구현 (3) | 2025.06.15 |