벡터 덧셈을 add(a, b) 대신 a + b로 쓰고, 사용자 타입을 std::set에 넣으려면 연산자 오버로딩(operator overloading)이 필요하다. 연산자는 이름이 operator+인 함수일 뿐이지만, 멤버로 둘지 비멤버로 둘지, 무엇을 반환할지에 따라 대칭성·성능·의미가 달라진다. 이 글에서는 g++ 13.3으로 산술 연산자, C++20 <=>, 첨자·호출 연산자, 증감 연산자를 구현하고, 오버로드하면 동작이 바뀌는 연산자를 확인한다. 스트림 출력 <<는 cout 편, 변환 연산자는 변환 편, 대입 연산자는 객체 복사 편에서 다룬다.
이 시리즈의 다른 글
- C++ 고급 문법 (9) — 객체 복사(Object Copy)
- C++ 고급 문법 (10) — 함수 포인터(Function Pointer)
- C++ 고급 문법 (12) — cout과 endl
- C++ 고급 문법 (13) — 스마트 포인터(Smart Pointer)
산술 연산자
#include <cstdio>
struct Vec2 {
double x = 0, y = 0;
Vec2& operator+=(const Vec2& o) { x += o.x; y += o.y; return *this; } // 복합 대입: 멤버
Vec2& operator*=(double k) { x *= k; y *= k; return *this; }
Vec2 operator-() const { return {-x, -y}; } // 단항
};
// 이항 연산자: 비멤버로 두고 복합 대입으로 구현
Vec2 operator+(Vec2 a, const Vec2& b) { return a += b; }
Vec2 operator*(Vec2 v, double k) { return v *= k; }
Vec2 operator*(double k, Vec2 v) { return v *= k; } // 2.0 * v 도 가능하게
int main()
{
Vec2 a{1, 2}, b{3, 4};
Vec2 c = a + b;
Vec2 d = 2.0 * a;
Vec2 e = -(a * 3);
std::printf("c=(%g,%g) d=(%g,%g) e=(%g,%g)\n", c.x, c.y, d.x, d.y, e.x, e.y);
}$ g++ -std=c++20 -Wall -o v vec.cpp
$ ./v
c=(4,6) d=(2,4) e=(-3,-6)
상태를 바꾸는 +=는 멤버로, 새 값을 만드는 +는 비멤버로 두고 +=를 재사용하는 것이 관례다. 이항 연산자를 멤버로만 정의하면 왼쪽 피연산자가 그 클래스일 때만 호출된다.
struct Vec2 {
double x = 0, y = 0;
Vec2 operator*(double k) const { return {x * k, y * k}; } // 멤버로만 정의
};
int main()
{
Vec2 a{1, 2};
Vec2 ok = a * 2.0;
Vec2 bad = 2.0 * a; // 왼쪽 피연산자가 double이라 멤버 후보가 없다
}$ g++ -std=c++20 -Wall -c member_only.cpp 2>&1 | grep error
member_only.cpp:10:20: error: no match for ‘operator*’ (operand types are ‘double’ and ‘Vec2’)
| 연산자 | 권장 위치 | 반환 |
|---|---|---|
=, [], (), -> | 멤버 (필수) | 참조 / 임의 |
+=, -=, *= 등 복합 대입 | 멤버 | T& |
+, -, *, ==, <=> 등 이항 | 비멤버 (또는 hidden friend) | 값 / bool / ordering |
전위 ++/-- | 멤버 | T& |
후위 ++/-- | 멤버, int 더미 매개변수 | T (이전 값) |
<<, >> (스트림) | 비멤버 | 스트림 참조 |
비교 연산자와 <=>
#include <compare>
#include <cstdio>
#include <set>
#include <string>
struct Version {
int major, minor, patch;
auto operator<=>(const Version&) const = default; // 6개 비교 연산자를 한 번에 (C++20)
};
struct Employee {
std::string name;
int id;
// id로만 비교하고 싶을 때는 직접 구현
std::strong_ordering operator<=>(const Employee& o) const { return id <=> o.id; }
bool operator==(const Employee& o) const { return id == o.id; } // <=>를 직접 쓰면 ==는 따로
};
int main()
{
Version a{1, 4, 2}, b{1, 10, 0};
std::printf("a<b=%d a==b=%d a>=b=%d\n", a < b, a == b, a >= b);
std::set<Version> vs{{2, 0, 0}, {1, 10, 0}, {1, 4, 2}};
for (auto& v : vs) std::printf("%d.%d.%d ", v.major, v.minor, v.patch);
std::printf("\n");
Employee x{"kim", 7}, y{"lee", 7};
std::printf("x==y=%d x<y=%d\n", x == y, x < y);
}$ g++ -std=c++20 -Wall -o sp spaceship.cpp
$ ./sp
a<b=1 a==b=0 a>=b=0
1.4.2 1.10.0 2.0.0
x==y=1 x<y=0
= default로 선언한 <=>는 멤버를 선언 순서대로 비교하고 ==도 함께 만든다. 직접 구현한 <=>는 ==를 만들어주지 않으므로 따로 정의한다(==는 더 빠른 구현이 가능해서다).
호출 연산자와 첨자 연산자
#include <cstdio>
#include <stdexcept>
#include <vector>
class Matrix {
std::vector<double> d_;
std::size_t cols_;
public:
Matrix(std::size_t r, std::size_t c) : d_(r * c), cols_(c) {}
double& operator()(std::size_t r, std::size_t c) { return d_[r * cols_ + c]; }
const double& operator()(std::size_t r, std::size_t c) const { return d_[r * cols_ + c]; }
#if __cpp_multidimensional_subscript
double& operator[](std::size_t r, std::size_t c) { return d_[r * cols_ + c]; } // C++23
#endif
};
int main()
{
Matrix m(2, 3);
m(1, 2) = 5.5;
const Matrix& cm = m;
std::printf("%.1f\n", cm(1, 2));
#if __cpp_multidimensional_subscript
m[0, 1] = 1.5;
std::printf("%.1f (C++23 m[r, c])\n", m[0, 1]);
#endif
}$ g++ -std=c++20 -Wall -o s20 subscript.cpp
$ ./s20
5.5
$ g++ -std=c++23 -Wall -o s23 subscript.cpp
$ ./s23
5.5
1.5 (C++23 m[r, c])
C++20까지 operator[]는 인수를 하나만 받아 2차원 접근에 operator()를 썼다. C++23부터는 다차원 첨자 m[r, c]가 허용되고 g++ 13은 -std=c++23에서 지원한다.
전위·후위 증감 연산자
#include <cstdio>
struct Iter {
int pos = 0;
static inline int copies = 0;
Iter() = default;
Iter(const Iter& o) : pos(o.pos) { ++copies; }
Iter& operator++() { ++pos; return *this; } // 전위: 참조 반환
Iter operator++(int) { Iter old = *this; ++pos; return old; } // 후위: int 더미 매개변수, 복사본 반환
};
int main()
{
Iter it;
Iter a = ++it; // 결과를 복사해 받는 것이므로 1회
Iter b = it++; // 함수 안에서 old 복사 1회 (반환은 생략)
std::printf("it=%d a=%d b=%d copies=%d\n", it.pos, a.pos, b.pos, Iter::copies);
Iter::copies = 0;
for (int i = 0; i < 1000; ++i) ++it;
std::printf("prefix x1000 copies=%d\n", Iter::copies);
Iter::copies = 0;
for (int i = 0; i < 1000; ++i) it++;
std::printf("postfix x1000 copies=%d\n", Iter::copies);
}$ g++ -std=c++20 -Wall -O2 -o id incdec.cpp
$ ./id
it=2 a=1 b=1 copies=2
prefix x1000 copies=0
postfix x1000 copies=1000
후위 연산자는 이전 값을 돌려주기 위해 매번 복사본을 만든다. 복사 생성자에 부수 효과가 있으면 -O2에서도 제거되지 않으므로, 반환값을 쓰지 않는 반복문에서는 전위 ++it를 쓴다.
오버로드하면 의미가 바뀌는 연산자
#include <cstdio>
struct Flag {
bool v;
friend Flag operator&&(Flag a, Flag b) { return {a.v && b.v}; } // 오버로드한 &&
};
Flag check(const char* name, bool v) { std::printf(" evaluated %s\n", name); return {v}; }
bool check_b(const char* name, bool v) { std::printf(" evaluated %s\n", name); return v; }
int main()
{
std::puts("built-in &&:");
bool r1 = check_b("lhs", false) && check_b("rhs", true); // rhs 생략
std::puts("overloaded &&:");
Flag r2 = check("lhs", false) && check("rhs", true); // 일반 함수 호출: 둘 다 평가
std::printf("r1=%d r2=%d\n", r1, r2.v);
}$ g++ -std=c++20 -Wall -o sc shortcircuit.cpp
$ ./sc
built-in &&:
evaluated lhs
overloaded &&:
evaluated lhs
evaluated rhs
r1=0 r2=0
오버로드한 &&, ||는 일반 함수 호출이라 단락 평가(short-circuit evaluation)가 사라진다. 쉼표 연산자와 단항 &도 기대 동작이 바뀌므로 오버로드하지 않는다.
struct S { int v; };
int operator.(S&, int); // 멤버 접근 연산자는 오버로드 불가
int operator+(int, int); // 내장 타입끼리의 연산자도 불가$ g++ -std=c++20 -Wall -c nooverload.cpp 2>&1 | grep error
nooverload.cpp:2:13: error: expected type-specifier before ‘.’ token
nooverload.cpp:3:5: error: ‘int operator+(int, int)’ must have an argument of class or enumerated type
::, ., .*, ?:, sizeof, typeid는 오버로드할 수 없고, 피연산자 중 하나는 반드시 클래스나 열거형이어야 한다.
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 이항 연산자를 멤버로만 정의 | 2.0 * v 같은 역순 호출 불가 | 비멤버 또는 hidden friend |
+와 +=를 따로 구현 | 두 구현이 어긋날 위험 | +를 +=로 구현 |
<=>를 직접 구현 | ==가 생기지 않음 | operator==를 함께 정의 |
&&, ||, , 오버로드 | 단락 평가·순서 보장이 바뀜 | 오버로드하지 않는다 |
| 후위 증감을 습관적으로 사용 | 불필요한 복사 | 결과를 안 쓰면 전위 사용 |
| 직관과 다른 의미 부여 | 읽는 사람이 동작을 예측할 수 없음 | 내장 타입과 같은 의미일 때만 오버로드 |
마무리
- 복합 대입은 멤버로, 이항 연산자는 비멤버로 두고 복합 대입을 재사용한다.
- C++20
<=> = default하나로 비교 연산자 여섯 개를 얻는다. - 후위 증감은 복사본을 만들고, 오버로드한
&&/||는 단락 평가를 잃는다.