연산자 오버로딩, 복사 생성자

연산자 오버로딩(operator overloading)은 사용자 정의 타입에 대해 C++ 연산자(+, -, [], « 등)를 직관적으로 동작하도록 만드는 방법입니다. 초보자도 이해하기 쉽도록 원리와 예제를 중심으로 친절하게 정리했습니다.

핵심 아이디어

  • 연산자를 함수처럼 구현합니다. 예: a + b → a.operator+(b) 또는 operator+(a, b)
  • 멤버 함수로 구현하면 왼쪽 피연산자가 해당 클래스의 객체여야 합니다.
  • 비멤버(또는 friend)로 구현하면 좌우 대칭성을 유지하기 쉽습니다.
  • 연산자의 기본 의미를 바꾸지 않는 것이 좋습니다(직관성 유지).

규칙 간단 정리

  • 일부 연산자(., .* , ::, ?: 등)는 오버로딩할 수 없습니다.
  • 연산자 우선순위나 결합법(associativity)은 변경할 수 없습니다.
  • 가능한 경우 const와 &를 사용해 불필요한 복사를 피하세요.
  • 대칭 연산(예: 숫자 + 객체)은 비멤버 함수를 고려하세요.

예제 1 — 간단한 Point: +, +=

#include <iostream>

struct Point {
    double x, y;

    // += 는 멤버로 구현하는 것이 자연스럽다.
    Point& operator+=(const Point& other) {
        x += other.x;
        y += other.y;
        return *this;
    }

    // +는 +=를 재사용하여 구현
    friend Point operator+(Point lhs, const Point& rhs) {
        lhs += rhs; // 복사를 이용해 구현
        return lhs;
    }
};

int main() {
    Point a{1, 2}, b{3, 4};
    Point c = a + b; // operator+
    a += b;          // operator+=
    std::cout << c.x << ", " << c.y << '\n';
}

설명: operator+는 보통 값 복사(lhs) + operator+= 재사용 패턴으로 구현하면 예외 안전성과 코드 재사용 측면에서 좋습니다.


예제 2 — Complex: 비멤버 operator+와 operator«

#include <iostream>

class Complex {
    double re, im;
public:
    Complex(double r=0, double i=0) : re(r), im(i) {}
    // 멤버로 더하기 가능하지만 비멤버로 구현하면 자동 변환에 유리
    friend Complex operator+(const Complex& a, const Complex& b) {
        return Complex(a.re + b.re, a.im + b.im);
    }
    friend std::ostream& operator<<(std::ostream& os, const Complex& c) {
        return os << '(' << c.re << ',' << c.im << ')';
    }
};

int main() {
    Complex a(1,2), b(3,4);
    std::cout << (a + b) << '\n';
}

설명: 출력용 operator«는 반드시 비멤버로 작성하세요(ostream을 왼쪽 피연산자로 가져야 하기 때문입니다).


예제 3 — 동등 비교와 const

struct Vec2 {
    double x, y;
    bool operator==(const Vec2& other) const {
        return x == other.x && y == other.y;
    }
    bool operator!=(const Vec2& other) const {
        return !(*this == other);
    }
};

설명: operator==를 먼저 정의하고 operator!=는 이를 재사용하는 패턴을 권장합니다.


예제 4 — 전위/후위 ++ 구분

struct Counter {
    int v = 0;
    // 전위 ++
    Counter& operator++() { ++v; return *this; }
    // 후위 ++ (int 더미 인자)
    Counter operator++(int) { Counter tmp = *this; ++v; return tmp; }
};

설명: 후위 연산자는 int가 더미 인자를 받아 멤버 시그니처로 구분됩니다. 후위는 보통 값 복사를 수반하므로 비용을 고려하세요.


예제 5 — 인덱스 연산자 []

#include <vector>

class IntList {
    std::vector<int> data;
public:
    int& operator[](std::size_t i) { return data[i]; }           // 수정 가능
    int operator[](std::size_t i) const { return data[i]; }     // 읽기 전용
};

실전 팁

  • 연산자 오버로딩은 문법을 더 깔끔하게 하지만, 남용하면 코드 가독성이 떨어질 수 있습니다.
  • 가능한 표준 타입과 유사한 의미로 사용하세요(예: + 는 덧셈 의미).
  • 성능 민감한 경우 const&, &&, noexcept, constexpr 사용을 고려하세요.
  • 대칭 연산(숫자 + 객체)을 지원하려면 비멤버 함수를 제공하거나 추가 오버로드를 작성하세요.

연산자 오버로딩은 올바르게 사용하면 API를 더 자연스럽게 만듭니다. 위 예제를 직접 컴파일해 보시고, 멤버 vs 비멤버, const 여부에 따른 동작 차이를 실험해 보길 권장합니다.

복사 생성자

복사 생성자(copy constructor)와 대입 연산자(assignment operator)는 C++에서 객체를 “복사”할 때 동작하는 특별한 함수들입니다. C에 익숙한 사람에게는 구조체를 그대로 대입하는 것과 비슷해 보이지만, 포인터나 동적 메모리를 멤버로 가진 경우 동작 방식에 따라 심각한 버그(이중 해제, 잘못된 메모리 접근)가 발생할 수 있습니다. 여기서는 개념(얕은 복사 vs 깊은 복사), 기본 동작, 직접 구현하는 방법, 그리고 안전한 설계 원칙까지 예제로 자세히 보여드립니다.

1) 기본 개념

  • 복사 생성자: T(const T& other) 형태의 생성자로, 객체를 생성하면서 다른 객체의 값을 복사합니다.
  • 복사 대입 연산자(복사 대입): T& operator=(const T& other) 형태로, 이미 생성된 객체에 다른 객체의 값을 대입할 때 호출됩니다.

예: T a = b; 는 복사 생성자(초기화) 호출, a = b; 는 복사 대입 호출입니다.

2) 얕은 복사(Shallow copy) vs 깊은 복사(Deep copy)

  • 얕은 복사: 멤버별로 바이트 단위 복사(기본 동작). 포인터 멤버는 포인터 값(주소)만 복사되어 두 객체가 같은 메모리를 가리키게 됩니다.
  • 깊은 복사: 포인터가 가리키는 실제 데이터를 새로 복사하여 독립적인 소유권을 갖도록 합니다.

문제 상황(얕은 복사의 위험)

#include <iostream>
using namespace std;

struct Shallow {
    int *p;
    Shallow(int v){ p = new int(v); }
    ~Shallow(){ delete p; }
};

int main(){
    Shallow a(10);
    Shallow b = a; // 기본 복사 생성자 -> p의 값(주소)만 복사
    // 이제 a.p와 b.p는 같은 주소를 가리킵니다
    // 프로그램이 끝나면 a의 소멸자가 p를 삭제하고, 그 다음 b의 소멸자가 같은 주소를 다시 delete 하게 되어 오류 발생
}

위 코드는 프로그램 종료 시 이중 해제(double free) 또는 런타임 오류를 초래합니다. 기본(암묵적) 복사 생성자는 멤버별 복사를 하므로 포인터의 ‘주소’만 복사합니다(얕은 복사).

깊은 복사로 해결하기

#include <iostream>
using namespace std;

class Deep {
    int *p;
public:
    Deep(int v){ p = new int(v); }
    ~Deep(){ delete p; }

    // 복사 생성자 (깊은 복사)
    Deep(const Deep& other) {
        p = new int(*other.p); // 다른 객체가 가진 값을 새 메모리에 복사
    }

    // 복사 대입 연산자 (깊은 복사)
    Deep& operator=(const Deep& other) {
        if (this == &other) return *this; // 자기 대입(self-assignment) 보호
        delete p;                // 기존 자원 해제
        p = new int(*other.p);   // 새 자원 할당 및 복사
        return *this;
    }
};

int main(){
    Deep a(10);
    Deep b = a; // 안전한 깊은 복사
    Deep c(20);
    c = a;      // 복사 대입도 안전
}

이제 각 객체는 자신의 별도 메모리를 소유하므로 소멸자에서 안전하게 해제됩니다.

3) Rule of Three / Rule of Five

  • Rule of Three: 클래스가 소유하는 리소스(동적 메모리, 파일 등)가 있다면 세 가지를 모두 정의해야 합니다: 복사 생성자, 복사 대입 연산자, 소멸자.
  • Rule of Five (C++11 이후): 이동 생성자(move constructor)와 이동 대입 연산자(move assignment)도 추가로 고려해야 합니다. 이동은 소유권을 ‘옮기는’ 연산으로 깊은 복사보다 효율적입니다.

간단한 이동 예시(참고)

// Deep class에 이동 생성자/대입 추가 (간단 버전)
Deep(Deep&& other) noexcept : p(other.p) { other.p = nullptr; }
Deep& operator=(Deep&& other) noexcept {
    if (this != &other) {
        delete p;
        p = other.p;
        other.p = nullptr;
    }
    return *this;
}

이렇게 하면 임시 객체에서 리소스를 복사하는 대신 포인터만 옮겨와 성능을 개선할 수 있습니다.

4) 스마트 포인터로 더 안전하게

직접 메모리를 관리하는 대신 std::unique_ptr나 std::shared_ptr 같은 스마트 포인터를 사용하면 복사/대입 동작이 보다 명확해집니다.

예: std::unique_ptr는 복사가 금지되고 이동만 허용되어 소유권 규칙을 안전하게 강제합니다.

#include <memory>
struct S {
    std::unique_ptr<int> p;
    S(int v): p(std::make_unique<int>(v)){}
    // 복사 생성자는 삭제됨(암묵적으로) — std::unique_ptr는 복사 불가, 이동만 가능
};

5) 추가 팁과 권장 사항

  • 가능한 한 원시 포인터로 직접 자원 소유권을 관리하지 마세요. 표준 컨테이너(std::vector)나 스마트 포인터를 사용하세요.
  • 자기 대입(self-assignment) 체크를 잊지 마세요(특히 복사 대입 연산자 구현 시).
  • 예외 안전성을 고려하세요: 복사 대입에서 기존 자원을 바로 해제하면 새 할당이 실패할 때 객체가 손상될 수 있습니다. 안전한 패턴으로는 “복사-교환(copy-and-swap)” idiom이 있습니다.

copy-and-swap 예시 (강건한 복사 대입)

class Safe {
    int *p;
public:
    Safe(int v=0): p(new int(v)){}
    ~Safe(){ delete p; }
    Safe(const Safe& o): p(new int(*o.p)){}
    Safe& operator=(Safe o) { // 값으로 받음 -> 복사 생성자 사용
        std::swap(p, o.p);
        return *this;
    }
};

operator=을 값으로 받아(복사) 내부 자원을 스왑하면, 할당 실패에 안전하고 자기 대입에도 안전합니다.

6) 요약

  • 복사 생성자: 객체 생성 시 호출, 기본 구현은 멤버별 얕은 복사.
  • 복사 대입: 이미 존재하는 객체에 값을 복사, 기본 구현도 멤버별 얕은 복사.
  • 얕은 복사는 포인터/리소스 공유로 이중 해제 등 버그를 유발할 수 있으니, 리소스를 소유하는 클래스는 깊은 복사를 직접 구현하거나 스마트 포인터를 사용하세요.
  • 리소스 소유 클래스는 Rule of Three/Five를 따르세요.
  • 복사-교환(copy-and-swap) 패턴은 강건한 대입 연산자 구현 방법입니다.

다음으로 원하시면 이 내용을 파일에 실제 코드 블록으로 추가(테스트 가능한 예제 파일 생성)하거나, “이동 생성자와 이동 대입”을 더 깊게 설명해 드리겠습니다.

results matching ""

    No results matching ""