연산자 오버로딩, 복사 생성자
연산자 오버로딩(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) 패턴은 강건한 대입 연산자 구현 방법입니다.
다음으로 원하시면 이 내용을 파일에 실제 코드 블록으로 추가(테스트 가능한 예제 파일 생성)하거나, “이동 생성자와 이동 대입”을 더 깊게 설명해 드리겠습니다.