C++/C++ : Study

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

더블유제이플로어 2025. 6. 20. 01:28

중요한 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= 가 호출된다.

이 두 가지는 반드시 구분해야 하며, =가 있다고 해서 모두 대입 연산자 호출이 되는 것은 아니다.

✅ 대입 연산자는 기본적으로 자동 생성된다

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가지 이유 때문이다:

  1. 연속 대입을 가능하게 하기 위해
    → a = b = c; 형태를 지원하려면 각 연산이 자기 자신을 반환해야 함
  2. 불필요한 복사 비용을 줄이기 위해
    → 반환형이 값일 경우 객체 복사가 일어남
  3. 원본 객체 자체를 수정한 결과를 다시 사용할 수 있기 때문에
    → 반환된 객체로 다시 멤버 호출, 연산 가능 ((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의 대입 연산자는 다음을 수행한다:
    1. name의 깊은 복사 (delete → new → strcpy)
    2. 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 문제가 발생한다.

✅ 해결 방법 요약

이 문제를 해결하려면 다음 두 가지를 반드시 구현해야 해:

  1. 복사 생성자
  2. 대입 연산자

그리고 소멸자도 함께 구현하면 이걸 Rule of Three라고 한다.

 

https://youtu.be/WIdO-LEGXGE?si=DPKfMTfUYXyZFdtb