C++ 연산자 오버로딩: 멤버·비멤버 선택, 산술·비교·입출력 연산자 구현
이 글의 핵심
연산자 오버로딩은 특별한 이름을 가진 함수 호출일 뿐입니다. 복소수·분수·행렬·문자열 예제로 멤버와 비멤버 중 무엇을 고를지, <<에 friend가 필요한 이유, 자기 대입과 const 누락처럼 실제로 자주 터지는 함정을 정리합니다.
기본 산술 연산자
operator+는 실제로는 그냥 Complex::operator+라는 이름의 평범한 멤버 함수이며, c1 + c2라는 표현식을 만나면 컴파일러가 이를 c1.operator+(c2)로 재해석해 호출하는 문법적 설탕(syntactic sugar)일 뿐입니다 — 연산자 오버로딩이 마법처럼 느껴지지만 실제로는 “특별한 이름 규칙을 가진 함수 호출”에 지나지 않는다는 것을 이해하면 이후의 모든 규칙(멤버 vs 비멤버, const 필요성 등)이 훨씬 자연스럽게 이해됩니다. const가 붙은 이유도 명확합니다 — c1 + c2는 c1과 c2의 값을 바탕으로 새로운 Complex 객체를 만들 뿐, 원본 두 객체 중 어느 것도 변경하지 않아야 하며, const 멤버 함수로 선언하면 컴파일러가 이 계약(원본을 건드리지 않음)을 강제해 줍니다.
class Complex {
private:
double real, imag;
public:
Complex(double r = 0, double i = 0) : real(r), imag(i) {}
// + 연산자
Complex operator+(const Complex& other) const {
return Complex(real + other.real, imag + other.imag);
}
// - 연산자
Complex operator-(const Complex& other) const {
return Complex(real - other.real, imag - other.imag);
}
void print() const {
cout << real << " + " << imag << "i" << endl;
}
};
int main() {
Complex c1(3, 4);
Complex c2(1, 2);
Complex c3 = c1 + c2; // operator+ 호출
c3.print(); // 4 + 6i
}
멤버로 만들었을 때 생기는 비대칭 문제
위 Complex는 생성자에 기본 인자가 있어서 double에서 Complex로 암시적 변환이 됩니다. 그래서 c1 + 2.0은 c1.operator+(Complex(2.0))으로 잘 컴파일됩니다. 그런데 순서만 바꾼 2.0 + c1은 no match for 'operator+' (operand types are 'double' and 'Complex') 에러가 납니다. 멤버 함수는 왼쪽 피연산자가 반드시 그 클래스의 객체여야 하고, 컴파일러는 멤버 함수를 찾기 위해 왼쪽 피연산자에 암시적 변환을 적용하지 않기 때문입니다. 수학적으로 교환법칙이 성립하는 연산인데 코드에서는 한쪽 방향만 되는 셈이라, 라이브러리를 쓰는 사람 입장에서는 꽤 당황스러운 동작입니다.
해결책은 대칭이어야 하는 이항 연산자를 비멤버로 옮기는 것입니다. 흔한 패턴은 상태를 바꾸는 +=를 멤버로 두고, +는 그것을 이용하는 비멤버로 만드는 방식입니다.
class Complex {
double real, imag;
public:
Complex(double r = 0, double i = 0) : real(r), imag(i) {}
Complex& operator+=(const Complex& other) {
real += other.real;
imag += other.imag;
return *this;
}
};
// 비멤버: 양쪽 피연산자 모두 암시적 변환 대상이 됨
Complex operator+(Complex lhs, const Complex& rhs) {
lhs += rhs; // 값으로 받은 lhs를 수정해서 그대로 반환
return lhs;
}
lhs를 값으로 받는 이유는 어차피 결과를 담을 복사본이 필요하기 때문입니다. 호출하는 쪽이 임시 객체를 넘기면 복사 대신 이동이 일어나고, private 멤버를 건드리지 않으므로 friend도 필요 없습니다. +=와 +의 동작이 서로 어긋날 일도 없어집니다. 반대로 =, [], (), ->는 언어 규칙상 반드시 멤버로 정의해야 하고, +=처럼 왼쪽 객체를 바꾸는 복합 대입도 멤버로 두는 것이 자연스럽습니다.
비교 연산자
operator!=를 !(*this == other)로 정의한 것은 우연이 아니라 흔한 관용구입니다 — “같지 않다”는 논리적으로 “같다의 부정”이므로, ==의 실제 비교 로직을 두 번 작성하는 대신 한 번만 작성하고 !=는 그 결과를 뒤집기만 하면 됩니다. 이렇게 하면 나중에 Point의 동등성 기준이 바뀌어도(예: 부동소수점 좌표에 허용 오차를 추가하는 등) ==만 수정하면 !=도 자동으로 올바르게 따라오므로, 두 연산자의 정의가 서로 어긋날 위험이 원천적으로 사라집니다. operator<가 x를 먼저 비교하고 같을 때만 y를 비교하는 것은 “사전식 순서(lexicographic ordering)“라 불리는 표준적인 다중 필드 비교 패턴이며, 이렇게 <를 정의해 두면 std::sort나 std::set처럼 순서가 필요한 표준 라이브러리 도구에 Point를 바로 사용할 수 있게 됩니다.
class Point {
public:
int x, y;
Point(int x, int y) : x(x), y(y) {}
bool operator==(const Point& other) const {
return x == other.x && y == other.y;
}
bool operator!=(const Point& other) const {
return !(*this == other);
}
bool operator<(const Point& other) const {
if (x != other.x) return x < other.x;
return y < other.y;
}
};
int main() {
Point p1(1, 2);
Point p2(1, 2);
Point p3(2, 3);
cout << (p1 == p2) << endl; // 1 (true)
cout << (p1 < p3) << endl; // 1 (true)
}
C++20을 쓸 수 있다면 이 세 함수를 거의 다 지울 수 있습니다. bool operator==(const Point&) const = default;를 선언하면 멤버별 비교가 자동으로 만들어지고, !=는 컴파일러가 ==를 뒤집어 알아서 사용합니다. auto operator<=>(const Point&) const = default;까지 선언하면 선언 순서대로 사전식 비교하는 <, <=, >, >=가 모두 생깁니다. 직접 작성한 비교 연산자들은 <만 정의하고 >를 잊는 식으로 구멍이 생기기 쉬운데, 기본 생성을 쓰면 그런 누락이 원천적으로 사라집니다. 다만 부동소수점 멤버에 허용 오차를 두는 것처럼 멤버별 비교가 원하는 의미가 아니라면 여전히 직접 작성해야 합니다.
operator<를 직접 작성할 때는 엄격한 약순서(strict weak ordering) 를 지켜야 한다는 점도 중요합니다. x만 비교하고 y를 무시하면 서로 다른 두 점이 std::set에서 “같은 원소”로 취급되어 한쪽이 조용히 삽입되지 않습니다. <=처럼 같은 값에 대해 true를 반환하는 비교를 std::sort에 넘기면 정의되지 않은 동작이 되어, 구현에 따라 배열 범위를 벗어나 읽다가 크래시가 나기도 합니다. 여러 필드를 비교할 때는 std::tie(x, y) < std::tie(other.x, other.y)처럼 튜플 비교에 맡기면 순서 실수를 줄일 수 있습니다.
입출력 연산자 (friend)
<<가 Vector2D의 비멤버 함수로 정의된 이유는 멤버 함수로 만들 수 없다는 문법적 제약 때문입니다 — 멤버 함수로 정의하면 os << v가 아니라 v << os처럼 왼쪽 피연산자(ostream)가 항상 그 함수를 소유한 클래스의 인스턴스여야 하는데, ostream은 우리가 수정할 수 없는 표준 라이브러리 클래스이므로 Vector2D에 operator<< 멤버를 아무리 추가해도 os << v 형태의 호출과는 맞지 않습니다. friend가 필요한 이유는 이 비멤버 함수가 Vector2D의 private 멤버(x, y)에 직접 접근해야 하기 때문입니다 — friend로 선언하면 클래스 바깥에 있는 이 함수에게 마치 멤버 함수인 것처럼 private 접근 권한을 예외적으로 부여합니다. operator<<와 operator>>가 각각 ostream&/istream&을 반환하는 것도 중요한데, 이 반환값 덕분에 cout << a << b << c처럼 여러 출력을 연쇄적으로 이어 쓸 수 있습니다.
class Vector2D {
private:
double x, y;
public:
Vector2D(double x = 0, double y = 0) : x(x), y(y) {}
// << 연산자 (friend 필요)
friend ostream& operator<<(ostream& os, const Vector2D& v) {
os << "(" << v.x << ", " << v.y << ")";
return os;
}
// >> 연산자
friend istream& operator>>(istream& is, Vector2D& v) {
is >> v.x >> v.y;
return is;
}
};
int main() {
Vector2D v(3.5, 4.2);
cout << v << endl; // (3.5, 4.2)
Vector2D v2;
cin >> v2; // 입력: 1 2
cout << v2 << endl; // (1, 2)
}
operator>>에서 흔히 빠뜨리는 부분은 입력 실패 처리입니다. 사용자가 숫자 대신 문자를 입력하면 is >> v.x가 실패하면서 스트림에 failbit가 켜지고, v.x에는 0이 들어가거나(C++11 이후) 원래 값이 남습니다. 위 코드는 스트림을 그대로 반환하므로 호출한 쪽에서 if (cin >> v2)로 성공 여부를 검사할 수 있는데, 이 검사를 하지 않으면 반쯤 채워진 객체를 정상 값처럼 쓰게 됩니다. 입력값에 대한 검증(예: 음수 금지)이 필요한 타입이라면 임시 변수로 먼저 읽고, 검증에 성공했을 때만 객체에 반영하며, 실패하면 is.setstate(std::ios::failbit)로 실패를 알리는 것이 표준 라이브러리 타입들과 같은 관례입니다.
한편 operator<< 안에서 endl을 쓰지 않은 것도 의도된 선택입니다. 출력 연산자는 “값을 표현하는 문자열”만 내보내고, 줄바꿈 여부는 호출하는 쪽이 정하게 두어야 cout << "v = " << v << ", w = " << w처럼 한 줄 안에 섞어 쓸 수 있습니다.
실전 예시
예시 1: 분수 클래스
이 클래스가 잘 보여주는 것은 연산자 오버로딩이 “수학적으로 이미 정의된 연산의 실제 계산 로직을 코드로 옮기는” 작업이라는 점입니다 — operator+가 a/b + c/d = (ad + cb)/(bd)라는 분수 덧셈 공식을 그대로 구현하고 있을 뿐, 특별한 마법은 없습니다. 생성자에서 매번 simplify()를 호출해 최대공약수로 약분해 두는 것도 실용적인 설계 선택입니다 — 그 결과 Fraction은 항상 기약분수 상태로 유지되므로, operator==가 분자·분모를 그대로 비교하는 것만으로 올바르게 동작합니다(약분하지 않았다면 1/2와 2/4가 값은 같지만 비교에서 다르다고 나오는 버그가 생겼을 것입니다). 이는 클래스 불변식(항상 기약분수 상태)을 생성자에서 강제해 두면 그 이후의 모든 연산이 그 가정에 안전하게 의존할 수 있다는 좋은 예입니다.
#include <iostream>
#include <numeric>
#include <stdexcept>
using namespace std;
class Fraction {
private:
int numerator; // 분자
int denominator; // 분모
void simplify() {
int gcd = std::gcd(numerator, denominator);
numerator /= gcd;
denominator /= gcd;
if (denominator < 0) {
numerator = -numerator;
denominator = -denominator;
}
}
public:
Fraction(int n = 0, int d = 1) : numerator(n), denominator(d) {
if (d == 0) throw invalid_argument("분모는 0이 될 수 없습니다");
simplify();
}
Fraction operator+(const Fraction& other) const {
return Fraction(
numerator * other.denominator + other.numerator * denominator,
denominator * other.denominator
);
}
Fraction operator-(const Fraction& other) const {
return Fraction(
numerator * other.denominator - other.numerator * denominator,
denominator * other.denominator
);
}
Fraction operator*(const Fraction& other) const {
return Fraction(
numerator * other.numerator,
denominator * other.denominator
);
}
Fraction operator/(const Fraction& other) const {
return Fraction(
numerator * other.denominator,
denominator * other.numerator
);
}
bool operator==(const Fraction& other) const {
return numerator == other.numerator && denominator == other.denominator;
}
friend ostream& operator<<(ostream& os, const Fraction& f) {
if (f.denominator == 1) {
os << f.numerator;
} else {
os << f.numerator << "/" << f.denominator;
}
return os;
}
};
int main() {
Fraction f1(1, 2); // 1/2
Fraction f2(1, 3); // 1/3
cout << f1 << " + " << f2 << " = " << (f1 + f2) << endl; // 5/6
cout << f1 << " * " << f2 << " = " << (f1 * f2) << endl; // 1/6
return 0;
}
설명: 연산자 오버로딩으로 분수 연산을 직관적으로 표현할 수 있습니다.
operator/는 따로 0 검사를 하지 않지만, f1 / Fraction(0)을 계산하면 분모 자리에 denominator * 0이 들어간 Fraction을 만들게 되고 생성자의 검사가 invalid_argument를 던집니다. 불변식을 생성자 한 곳에서 강제해 두면 모든 연산자가 그 덕을 보는 예입니다. 반면 이 구현이 막지 못하는 문제도 있습니다. 덧셈에서 분모끼리 곱하는 denominator * other.denominator는 int 범위를 쉽게 넘는데, 부호 있는 정수의 오버플로는 정의되지 않은 동작이라 결과가 조용히 틀립니다. 분모가 수만 단위인 분수 몇 개만 더해도 도달하는 범위이므로, 실용적인 분수 타입이라면 long long을 쓰거나 곱하기 전에 최대공약수로 먼저 나누는 방식을 씁니다.
예시 2: 행렬 클래스
operator()를 원소 접근에 쓰는 것은 [](단일 인자만 받을 수 있음)로는 2차원 인덱싱(행, 열)을 자연스럽게 표현할 수 없기 때문입니다 — m1(0, 0), m1(0, 1)처럼 함수 호출 문법을 빌려 쓰면 두 개의 인자를 그대로 받을 수 있어, 수학 표기(M[i][j]보다는 M(i,j)에 가까운)와도 자연스럽게 맞아떨어집니다. operator*가 행렬 곱셈의 정의(cols != other.rows일 때 곱셈 자체가 수학적으로 불가능하다는 조건)를 명시적으로 검사하고 예외를 던지는 것도 눈여겨볼 만합니다 — 오버로딩된 연산자라 해도 내장 타입의 연산과 달리 도메인 규칙(여기서는 선형대수의 규칙)을 실제로 검증해야 하며, 이 검증을 생략하면 크기가 안 맞는 행렬끼리 곱셈을 시도했을 때 정의되지 않은 동작(버퍼 오버런)으로 이어질 수 있습니다.
#include <iostream>
#include <vector>
using namespace std;
class Matrix {
private:
vector<vector<int>> data;
int rows, cols;
public:
Matrix(int r, int c) : rows(r), cols(c) {
data.resize(r, vector<int>(c, 0));
}
int& operator()(int r, int c) {
return data[r][c];
}
Matrix operator+(const Matrix& other) const {
if (rows != other.rows || cols != other.cols) {
throw invalid_argument("행렬 크기가 다릅니다");
}
Matrix result(rows, cols);
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
result.data[i][j] = data[i][j] + other.data[i][j];
}
}
return result;
}
Matrix operator*(const Matrix& other) const {
if (cols != other.rows) {
throw invalid_argument("행렬 곱셈 불가");
}
Matrix result(rows, other.cols);
for (int i = 0; i < rows; i++) {
for (int j = 0; j < other.cols; j++) {
for (int k = 0; k < cols; k++) {
result.data[i][j] += data[i][k] * other.data[k][j];
}
}
}
return result;
}
friend ostream& operator<<(ostream& os, const Matrix& m) {
for (int i = 0; i < m.rows; i++) {
for (int j = 0; j < m.cols; j++) {
os << m.data[i][j] << " ";
}
os << endl;
}
return os;
}
};
int main() {
Matrix m1(2, 2);
m1(0, 0) = 1; m1(0, 1) = 2;
m1(1, 0) = 3; m1(1, 1) = 4;
Matrix m2(2, 2);
m2(0, 0) = 5; m2(0, 1) = 6;
m2(1, 0) = 7; m2(1, 1) = 8;
cout << "m1 + m2:" << endl << (m1 + m2) << endl;
cout << "m1 * m2:" << endl << (m1 * m2) << endl;
return 0;
}
설명: () 연산자로 행렬 원소 접근을 직관적으로 만들고, +와 * 연산자로 행렬 연산을 구현합니다.
이 Matrix에는 실제로 쓰다 보면 곧 부딪히는 빈틈이 하나 있습니다. operator()가 non-const 버전뿐이라 const Matrix&로 받은 함수 안에서는 m(0, 0)을 읽을 수 없습니다. 클래스 내부의 operator<<가 m.data[i][j]에 직접 접근하는 것도 그 때문입니다. 보통은 int operator()(int r, int c) const { return data[r][c]; }를 하나 더 선언해 const 객체에서는 읽기 전용 접근을 허용합니다. 또 operator()는 범위 검사를 하지 않으므로 m1(2, 0)은 정의되지 않은 동작입니다. 디버그 빌드에서만 assert로 검사하거나 data.at(r).at(c)를 쓰는 식으로 안전성과 속도를 저울질합니다.
참고로 C++23부터는 operator[]가 여러 인자를 받을 수 있어 m[0, 1] 형태도 가능해졌지만, 컴파일러 지원 범위를 고려하면 아직은 operator()가 더 이식성 있는 선택입니다.
예시 3: 스마트 문자열 클래스
이 클래스는 연산자 오버로딩이 종종 Rule of Five(소멸자·복사 생성자·복사 대입·이동 생성자·이동 대입)와 함께 다뤄져야 하는 이유를 보여줍니다 — data가 힙에 할당된 원시 포인터인 이상, operator=를 직접 정의하지 않으면 컴파일러가 생성하는 기본 대입은 포인터만 얕게 복사해 두 객체가 같은 메모리를 가리키다 이중 해제(double free)로 이어집니다. operator+가 문자열 연결의 결과를 담을 임시 버퍼(temp)를 직접 관리하는 것도 주목할 만합니다 — 두 문자열의 길이를 합친 만큼 새로 할당하고, strcat으로 이어붙인 뒤, 그 임시 버퍼로 String result를 만들고 나서 임시 버퍼 자체는 즉시 해제하는 흐름이며, 이는 std::string이 내부적으로 하는 일을 최소한의 형태로 직접 구현해 본 것입니다.
#include <iostream>
#include <cstring>
using namespace std;
class String {
private:
char* data;
size_t length;
public:
String(const char* str = "") {
length = strlen(str);
data = new char[length + 1];
strcpy(data, str);
}
~String() {
delete[] data;
}
// 복사 생성자
String(const String& other) {
length = other.length;
data = new char[length + 1];
strcpy(data, other.data);
}
// 대입 연산자
String& operator=(const String& other) {
if (this != &other) {
delete[] data;
length = other.length;
data = new char[length + 1];
strcpy(data, other.data);
}
return *this;
}
// + 연산자 (문자열 연결)
String operator+(const String& other) const {
char* temp = new char[length + other.length + 1];
strcpy(temp, data);
strcat(temp, other.data);
String result(temp);
delete[] temp;
return result;
}
// [] 연산자
char& operator[](size_t index) {
return data[index];
}
// == 연산자
bool operator==(const String& other) const {
return strcmp(data, other.data) == 0;
}
friend ostream& operator<<(ostream& os, const String& str) {
os << str.data;
return os;
}
};
int main() {
String s1("Hello");
String s2(" World");
String s3 = s1 + s2;
cout << s3 << endl; // Hello World
cout << s3[0] << endl; // H
return 0;
}
설명: 문자열 클래스에 연산자 오버로딩을 적용하여 사용성을 높입니다.
본문에서 Rule of Five를 언급했지만 이 코드에는 소멸자·복사 생성자·복사 대입 세 가지만 있습니다(Rule of Three). 이동 생성자와 이동 대입이 없으면 String s3 = s1 + s2;처럼 임시 객체에서 생성할 때도 복사 경로를 타는데, 이 경우는 C++17의 복사 생략 덕분에 괜찮지만 vector<String>이 재할당될 때는 모든 원소가 새로 할당·복사됩니다. String(String&& other) noexcept : data(other.data), length(other.length) { other.data = nullptr; other.length = 0; }처럼 포인터만 가져오는 이동 생성자를 추가하고 noexcept를 붙여야 vector가 재할당 시 이동을 사용합니다. 이때 소멸자의 delete[] nullptr는 아무 일도 하지 않으므로 안전합니다.
operator+에서 temp를 만들었다가 다시 String result(temp)로 복사하는 것도 할당을 두 번 하는 셈입니다. 학습용으로는 흐름이 잘 보이지만, 실제로는 std::string을 멤버로 두는 편이 거의 항상 낫습니다. 직접 구현하는 의미는 연산자 오버로딩과 자원 관리가 어떻게 맞물리는지를 이해하는 데 있습니다.
자주 발생하는 문제
문제 1: 대입 연산자에서 자기 대입 체크 누락
증상: 자기 자신을 대입하면 크래시
원인: delete 후 복사 시도
해결법:
이 버그는 s = s처럼 명시적으로 자기 자신을 대입하는 경우뿐 아니라, 참조나 포인터를 통해 간접적으로 같은 객체를 가리키는 경우(*p1 = *p2인데 p1 == p2)에도 똑같이 발생합니다. this != &other 검사 없이 delete[] data를 먼저 실행하면, other가 사실 *this와 같은 객체이므로 other.data도 이미 해제된 포인터가 되어 그 뒤의 strcpy(data, other.data)가 이미 해제된 메모리를 읽으려 시도합니다 — 이 검사 하나가 자기 대입이라는 드문 경로에서만 발생하는 use-after-free 버그를 막아줍니다.
// ❌ 위험한 코드
String& operator=(const String& other) {
delete[] data; // 자기 자신이면 문제!
data = new char[other.length + 1];
strcpy(data, other.data);
return *this;
}
// ✅ 자기 대입 체크
String& operator=(const String& other) {
if (this != &other) { // 자기 대입 체크
delete[] data;
data = new char[other.length + 1];
strcpy(data, other.data);
}
return *this;
}
자기 대입 검사로 크래시는 막았지만, 이 코드에는 여전히 예외 안전성 문제가 남아 있습니다. delete[] data 다음 줄의 new가 std::bad_alloc을 던지면 data는 이미 해제된 주소를 가리킨 채로 남고, 나중에 소멸자가 그 주소를 다시 해제합니다. 이 두 문제를 한 번에 해결하는 관용구가 copy-and-swap입니다.
String& operator=(String other) { // 값으로 받음: 복사는 여기서 일어남
std::swap(data, other.data);
std::swap(length, other.length);
return *this; // other가 소멸하며 옛 데이터를 해제
}
복사가 함수에 들어오기 전에 끝나므로 할당이 실패해도 *this는 전혀 바뀌지 않고, 자기 대입도 “복사본과 교환”일 뿐이라 별도 검사가 필요 없습니다. 이동 생성자가 있으면 s = String("tmp")처럼 임시 객체를 대입할 때 매개변수가 이동으로 만들어지므로, 복사 대입과 이동 대입을 이 함수 하나로 처리할 수 있다는 것도 장점입니다. 대신 자기 대입일 때도 복사 비용을 한 번 치른다는 점은 트레이드오프입니다.
문제 2: const 정확성
증상: const 객체에서 연산자 호출 불가
원인: const 키워드 누락
해결법:
const 키워드가 없는 멤버 함수는 암묵적으로 “이 객체를 수정할 수도 있다”고 컴파일러에 선언하는 것과 같으므로, const Complex& c처럼 상수 참조로 전달된 객체에 대해서는 그 함수를 호출할 후보에서 아예 제외됩니다. 이는 Complex가 실제로 값을 바꾸지 않는 연산자(+, -, == 등)일수록 더 자주 마주치는 문제입니다 — 이런 연산자는 논리적으로 원본을 변경할 이유가 없는데도, const를 빠뜨리면 상수 컨텍스트(다른 const 멤버 함수 안, const 매개변수로 받은 객체 등)에서 호출이 막혀버립니다. 원칙은 단순합니다 — 멤버 함수가 객체의 상태를 실제로 바꾸지 않는다면 항상 const를 붙여, 그 함수가 const/non-const 객체 양쪽 모두에서 호출 가능하도록 열어두는 것입니다.
// ❌ const 객체에서 호출 불가
Complex operator+(const Complex& other) {
return Complex(real + other.real, imag + other.imag);
}
// ✅ const 멤버 함수
Complex operator+(const Complex& other) const {
return Complex(real + other.real, imag + other.imag);
}
문제 3: 연산자 우선순위 무시
증상: 예상과 다른 결과
원인: 연산자 우선순위 오해
해결법:
연산자를 오버로딩해도 그 연산자의 우선순위·결합 방향·피연산자 개수는 언어가 이미 정한 규칙을 그대로 따르며, 이는 C++가 의도적으로 막아 둔 부분입니다 — operator+를 오버로딩했다고 해서 +가 *보다 먼저 계산되도록 우선순위를 바꿀 수는 없습니다. 그래서 a + b * c는 사용자 정의 타입에 대해서도 항상 a + (b * c)로 해석되며, 만약 오버로딩된 연산자들 사이의 실제 연산 순서가 헷갈릴 여지가 있다면 괄호로 명시하는 것이 유일하고 확실한 해법입니다.
// 연산자 우선순위는 변경 불가
// 괄호로 명확하게 표현
Complex result = (a + b) * c; // 명확
우선순위 문제가 실제로 가장 자주 드러나는 곳은 <<입니다. <<는 원래 비트 시프트 연산자라서 비교 연산자나 비트 연산자보다 우선순위가 높습니다. 그래서 cout << a == b;는 (cout << a) == b로 해석되어 이해하기 어려운 컴파일 에러가 나고, cout << flags & MASK;도 마찬가지입니다. 출력 문장 안에서 비교나 비트 연산을 할 때는 cout << (a == b)처럼 반드시 괄호로 감싸야 합니다.
같은 맥락에서 &&, ||, ,는 오버로딩하지 않는 것이 좋습니다. 내장 &&와 ||는 왼쪽 결과에 따라 오른쪽을 평가하지 않는 단락 평가(short-circuit)를 하지만, 오버로딩된 버전은 평범한 함수 호출이라 양쪽 피연산자가 항상 먼저 평가됩니다. ptr && ptr->valid() 같은 관용구가 사용자 정의 타입에서 갑자기 널 포인터 역참조로 바뀌는 것이 대표적인 사고입니다.
FAQ
Q1: 모든 연산자를 오버로딩할 수 있나요?
A: 아니요, 일부는 불가능합니다.
- 오버로딩 불가:
::,.,.*,?:,sizeof - 오버로딩 가능:
+,-,*,/,==,<<,[]등
Q2: friend는 왜 필요한가요?
A: <<, >> 같은 연산자는 왼쪽 피연산자가 ostream/istream이므로 멤버 함수로 만들 수 없습니다.
Q3: 연산자 오버로딩은 언제 사용하나요?
A:
- 수학적 객체 (벡터, 행렬, 복소수)
- 컨테이너 (배열, 리스트)
- 스마트 포인터
Q4: 성능에 영향이 있나요?
A: 인라인화되면 오버헤드가 거의 없습니다. 컴파일러가 최적화합니다.
Q5: ++a vs a++는 어떻게 구현하나요?
A: 전위(++a)와 후위(a++)는 매개변수 목록의 미묘한 차이(더미 int 하나의 유무)로만 구분되는데, 이 더미 매개변수는 실제로 어떤 값을 전달받기 위한 것이 아니라 컴파일러에게 “이것이 후위 버전”이라는 것을 알려주는 순전히 문법적인 표식입니다. 두 버전의 의미 차이도 이름 그대로입니다 — 전위는 값을 증가시킨 뒤 그 결과(*this)에 대한 참조를 반환하지만, 후위는 증가시키기 전의 원래 값을 복사해 두었다가(temp) 실제 증가는 전위 버전에 위임한 뒤 그 복사본을 값으로 반환합니다. 이 때문에 후위 버전은 항상 복사 비용이 하나 더 들어, 그 반환값을 실제로 쓰지 않는다면(단순히 a++;처럼 값을 버리는 경우) 전위 ++a가 미세하게 더 효율적입니다 — 반복문의 증감식(for (...; ...; ++i))에 전위를 쓰는 관례가 여기서 나옵니다.
// 전위 증가
T& operator++() {
// 증가
return *this;
}
// 후위 증가 (int는 더미 매개변수)
T operator++(int) {
T temp = *this;
++(*this);
return temp;
}
Q6: 연산자 오버로딩 남용은?
A: 직관적이지 않은 연산자 오버로딩은 피하세요. 예: +를 파일 삭제에 사용하는 것은 나쁜 예입니다.