C++ 고급 문법 (11) — 연산자 오버로딩(Operator Overloading)

벡터 덧셈을 add(a, b) 대신 a + b로 쓰고, 사용자 타입을 std::set에 넣으려면 연산자 오버로딩(operator overloading)이 필요하다. 연산자는 이름이 operator+인 함수일 뿐이지만, 멤버로 둘지 비멤버로 둘지, 무엇을 반환할지에 따라 대칭성·성능·의미가 달라진다. 이 글에서는 g++ 13.3으로 산술 연산자, C++20 <=>, 첨자·호출 연산자, 증감 연산자를 구현하고, 오버로드하면 동작이 바뀌는 연산자를 확인한다. 스트림 출력 <<는 cout 편, 변환 연산자는 변환 편, 대입 연산자는 객체 복사 편에서 다룬다.

이 시리즈의 다른 글

산술 연산자

#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 하나로 비교 연산자 여섯 개를 얻는다.
  • 후위 증감은 복사본을 만들고, 오버로드한 &&/||는 단락 평가를 잃는다.

참고

답글 남기기