본문 바로가기

연산자 오버로딩 기초

사용자 정의 타입에 산술·입출력·대입·증감 연산의 의미를 부여하고 일관된 연산자 인터페이스를 설계합니다.

int는 a + b로 더하고 std::cout << a로 출력할 수 있지만, 직접 만든 Point 클래스는 처음에는 그럴 수 없습니다. 컴파일러는 두 Point를 더하는 방법을 모르기 때문입니다. 연산자 오버로딩(operator overloading)은 클래스에 맞는 연산자의 동작을 함수로 정의해, p1.add(p2) 대신 p1 + p2처럼 쓸 수 있게 합니다.

이 글은 연산자를 멤버 함수와 비멤버 함수로 정의하는 방법, 출력과 입력 연산자, +=와 +를 일관되게 만드는 방법, C++20의 비교 연산자, 전위와 후위 증감 연산자, 그리고 오버로딩할 때 지켜야 할 제한을 다룹니다.

C++ · OPERATOR RESOLUTION

피연산자로 후보를 찾고 호출 가능성과 변환을 비교한다

본문의 +와 스트림 <<는 멤버·비멤버 후보에서 오버로드를 결정합니다. 반환·예외 계약은 구현과 사용을 설명하며, noexcept만 다르게 해서 별도 오버로드를 만들 수는 없습니다.

이항 연산자의 표현식에서 후보 선택까지원desktop3노드2간선의표현식→후보집합→호출가능후보와변환비교를보존한다.없거나모호하면오류이며모든표현식이항상함수를선택하는뜻이아니다.본문Point의멤버+/스트림비멤버<<범위이며비교연산의재작성후보는별도규칙이다.
표현식Point + Point피연산자 타입·cv·참조
연결 관계표현식 · Point + Point · 피연산자 타입·cv·참조 → 후보 찾기 · 멤버 · 비멤버 · ADL · 해당 내장 후보
후보 찾기멤버 · 비멤버 · ADL해당 내장 후보
연결 관계후보 찾기 · 멤버 · 비멤버 · ADL · 해당 내장 후보 → 호출 가능 후보 · 변환·바인딩 비교 · 유일한 최선 선택
호출 가능 후보변환·바인딩 비교유일한 최선 선택

없거나 모호하면 컴파일 오류이 그림은 이항 + / << 범위

이항 연산자 후보 탐색과 오버로드 결정피연산자 타입에서 후보를 구성하고 호출 가능한 후보의 변환과 바인딩을 비교한다. 함수가 없거나 최선 후보가 모호하면 컴파일 오류다.① 표현식Point + Point피연산자 타입·cv·참조② 후보 찾기멤버 · 비멤버 · ADL해당 내장 후보도 검토③ 호출 가능 후보변환·바인딩을 비교유일한 최선 함수 선택

① 피연산자

타입, const 여부와 값 범주를 확인합니다.

② 후보 집합

멤버와 일반 이름 탐색·ADL의 비멤버 후보, 해당 내장 후보를 찾습니다.

③ 오버로드 결정

인수 변환과 참조 바인딩 등으로 호출 가능한 후보를 비교해 유일한 최선을 선택합니다. 없거나 모호하면 오류입니다.

Point의 멤버 +

본문 Point::operator+는 왼쪽 Point 객체에 오른쪽 Point를 인수로 전달하는 형태입니다.

p1.operator+(p2)

const 멤버 여부와 매개변수 타입이 호출 가능성에 영향을 줍니다.

스트림의 비멤버 <<

왼쪽 std::ostream을 수정하고 스트림 참조를 반환하는 비멤버 함수 형태입니다.

operator<<(os, point)

friend 지정은 Point의 private 접근 권한을 줍니다. 함수가 Point의 멤버가 되지는 않습니다.

이 그림은 본문에 등장하는 이항 +와 <<를 설명합니다. 비교 연산에는 C++20 이후 재작성 후보 등 추가 규칙이 있으며, 각 연산자의 후보 규칙을 따릅니다.

위 그림처럼 p1 + p2를 만나면 컴파일러는 피연산자의 타입으로 쓸 수 있는 operator+ 후보를 모읍니다. 후보에는 클래스의 멤버 함수, 보이는 비멤버 함수, 그리고 인자의 타입이 속한 이름 공간에서 찾는 함수가 들어가며, 마지막 규칙을 인자 의존 조회(ADL, argument-dependent lookup)라고 합니다. 그다음 함수 오버로딩과 같은 규칙으로 가장 잘 맞는 하나를 고르고, 없거나 모호하면 컴파일 오류가 납니다.

연산자를 함수로 정의하기

연산자는 operator 키워드 뒤에 기호를 붙인 이름의 함수로 정의합니다. 멤버 함수로 정의하면 왼쪽 피연산자가 대상 객체가 되고, 오른쪽 피연산자 하나를 매개변수로 받습니다.

point_operators.cpp
#include <iostream>

class Point {
private:
    int x;
    int y;

public:
    Point(int x0 = 0, int y0 = 0) : x(x0), y(y0) {}

    int getX() const { return x; }
    int getY() const { return y; }

    Point operator+(const Point& other) const {
        return Point(x + other.x, y + other.y);
    }

    Point operator-() const {
        return Point(-x, -y);
    }
};

std::ostream& operator<<(std::ostream& os, const Point& p) {
    return os << '(' << p.getX() << ", " << p.getY() << ')';
}

int main() {
    Point p1(1, 2);
    Point p2(3, 4);
    Point p3 = p1 + p2;
    Point p4 = p3 + Point(10, 20);

    std::cout << p3 << ' ' << p4 << ' ' << -p1 << ' ' << p1 << '\n';
}
(4, 6) (14, 26) (-1, -2) (1, 2)

p1 + p2는 p1.operator+(p2)를 호출하는 것과 같고, 두 좌표를 더한 새 Point를 돌려줍니다. 매개변수 없는 operator-는 단항 부정 연산자이며, -p1은 새 값을 만들 뿐 p1은 (1, 2) 그대로입니다. 두 연산자 모두 대상 객체를 바꾸지 않으므로 멤버 함수와 멤버 변수에서 본 const 멤버 함수로 선언했습니다.

멤버로 정의할지 비멤버로 정의할지

출력 연산자 <<의 왼쪽 피연산자는 std::ostream입니다. 멤버 함수로 정의하면 왼쪽 피연산자가 그 클래스의 객체여야 하는데, 표준 라이브러리의 std::ostream에 멤버를 추가할 수는 없으므로 <<와 >>는 비멤버 함수로 정의합니다. 위 예제의 operator<<는 공개된 getX, getY만 써서 구현했고, private 멤버에 직접 접근해야 한다면 다음 글의 friend로 지정합니다.

operator<<가 스트림을 참조로 돌려주는 것은 std::cout << p3 << ' ' << p4처럼 출력을 이어 붙이기 위해서입니다. (std::cout << p3)의 결과가 다시 std::cout이어야 그 뒤에 << ' '를 이어 쓸 수 있습니다.

왼쪽과 오른쪽을 같은 조건으로 다뤄야 하는 연산자도 비멤버가 알맞습니다. 위 Point의 생성자는 기본 매개변수 덕분에 int 하나로도 호출할 수 있어, int를 Point로 바꾸는 암시적 변환에 쓰입니다. 그래서 p1 + 5는 5가 Point(5, 0)으로 바뀌어 컴파일되지만, 멤버 함수는 왼쪽 피연산자를 변환하지 않으므로 5 + p1은 컴파일되지 않습니다. operator+를 비멤버로 정의하면 두 피연산자 모두 같은 변환을 받아 둘 다 컴파일됩니다.

입력 연산자

입력 연산자 >>도 비멤버로 정의하며, 스트림에서 값을 읽어 객체에 넣고 스트림을 돌려줍니다. 객체를 바꾸므로 객체는 const가 아닌 참조로 받습니다.

point_input.cpp
std::istream& operator>>(std::istream& is, Point& p) {
    int x;
    int y;
    if (is >> x >> y) {
        p = Point(x, y);
    }
    return is;
}

두 값을 모두 읽은 경우에만 p를 바꾸므로, 읽다가 실패하면 p는 원래 값을 유지하고 실패 상태는 스트림에 남습니다. 호출한 쪽은 if (std::cin >> p)처럼 텍스트 파일 읽기에서 본 방식으로 성공을 확인할 수 있습니다.

+=와 +를 일관되게

+와 +=처럼 관련된 연산자는 같은 결과를 내야 합니다. 흔히 쓰는 방법은 실제 계산을 +=에 두고, +는 복사본에 +=를 적용해 만드는 것입니다.

C++ · OPERATOR CONTRACT

연산의 의미를 먼저 정하고 일관된 반환·접근 계약을 고른다

수정 여부, 대칭성, private 접근 필요성을 따져 구현을 정합니다. 아래 반환형과 멤버 배치는 본문 예제의 설계 관례이며 모든 연산자에 강제되는 규칙이 아닙니다.

왼쪽 값을 바꿈 · 복합 대입

본문 Vec2의 +=는 자기 상태를 갱신하고 같은 객체의 참조를 돌려줍니다.

Vec2& operator+=(const Vec2& rhs);

T& 반환은 연쇄 사용에 익숙한 의미를 줍니다. 다른 반환형이 문법적으로 항상 금지되는 것은 아닙니다.

새 값을 만듦 · 이항 +

입력 객체를 보존하고 별도 결과 값을 반환합니다. += 구현을 재사용하면 두 연산의 의미가 맞습니다.

Vec2 operator+(Vec2 lhs,
               const Vec2& rhs);

lhs += rhs; return lhs; 처럼 복사본을 수정합니다. 반환한 지역 객체를 참조로 돌려주면 안 됩니다.

대칭 변환 · 비멤버 후보

양쪽 피연산자 변환을 고려할 때 비멤버를 검토합니다. 공개 API만으로 구현되면 friend가 필요하지 않습니다.

private/protected 접근이 꼭 필요할 때 해당 함수를 friend로 지정합니다. operator= 등 멤버 제약이 있는 연산자는 별도 규칙을 따릅니다.

const·반환·예외 계약 · 실제 의미

읽기 연산은 const 가능성을, 변경 연산은 후상태를, noexcept는 실제 예외 가능성을 확인합니다.

Counter& operator++();
Counter operator++(int);

본문 전위 ++는 갱신된 자기 참조, 후위 ++는 갱신 전 값 복사본을 반환합니다. int는 후위를 구별하는 매개변수입니다.

오버로딩은 기존 연산자의 의미를 사용자 타입에 구현합니다. 연산자의 우선순위·결합 방향·피연산자 수를 새로 바꾸지는 않습니다.

vec2.cpp
#include <iostream>

class Vec2 {
private:
    int x;
    int y;

public:
    Vec2(int x0 = 0, int y0 = 0) : x(x0), y(y0) {}

    Vec2& operator+=(const Vec2& rhs) {
        x += rhs.x;
        y += rhs.y;
        return *this;
    }

    bool operator==(const Vec2&) const = default;

    int getX() const { return x; }
    int getY() const { return y; }
};

Vec2 operator+(Vec2 lhs, const Vec2& rhs) {
    lhs += rhs;
    return lhs;
}

std::ostream& operator<<(std::ostream& os, const Vec2& v) {
    return os << '(' << v.getX() << ", " << v.getY() << ')';
}

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

    a += b;
    std::cout << a << ' ' << std::boolalpha << (a == c) << ' ' << (a != b) << '\n';
}
(1, 2) (3, 4) (4, 6)
(4, 6) true true

그림처럼 +=는 왼쪽 객체를 바꾸고 그 객체의 참조를 돌려주며, +는 입력을 그대로 두고 새 값을 돌려줍니다. 비멤버 operator+는 왼쪽 피연산자를 값으로 받아 그 복사본에 +=를 적용하므로, 계산이 += 한 곳에만 있어 두 연산이 항상 같은 결과를 냅니다. 함수 안의 지역 객체를 참조로 돌려주면 허상 참조가 되므로 +는 값을 돌려줍니다.

bool operator==(const Vec2&) const = default;는 C++20의 기본 비교로, 멤버를 차례로 비교하는 ==를 컴파일러가 만들어 줍니다. C++20부터는 ==만 정의하면 a != b를 !(a == b)로 바꾸어 쓰므로 !=를 따로 정의하지 않아도 됩니다. 크기 비교까지 필요하면 auto operator<=>(const Vec2&) const = default;로 <, <=, >, >=를 한꺼번에 얻을 수 있습니다.

전위와 후위 증감 연산자

++c와 c++는 같은 기호를 쓰므로 매개변수로 구별합니다. 전위는 operator++()이고, 후위는 쓰이지 않는 int 매개변수를 받는 operator++(int)입니다.

counter_increment.cpp
#include <iostream>

class Counter {
private:
    int value;

public:
    Counter(int v = 0) : value(v) {}

    Counter& operator++() {
        ++value;
        return *this;
    }

    Counter operator++(int) {
        Counter old(*this);
        ++value;
        return old;
    }

    int get() const { return value; }
};

int main() {
    Counter c(10);
    Counter a = ++c;
    Counter b = c++;
    std::cout << a.get() << ' ' << b.get() << ' ' << c.get() << '\n';
}
11 11 12

전위 ++c는 값을 늘린 뒤 자기 자신을 돌려주므로 a는 11입니다. 후위 c++는 늘리기 전의 복사본을 만들어 두었다가 돌려주므로 b는 11이고, c는 12가 됩니다. 이처럼 후위 연산자는 복사본을 하나 더 만들기 때문에, 결과를 쓰지 않는다면 전위 연산자를 쓰는 편이 낫습니다.

대입 연산자

operator=는 반드시 멤버 함수로 정의해야 하며, 정의하지 않으면 컴파일러가 멤버를 차례로 대입하는 것을 만들어 줍니다. 생성자와 소멸자에서 본 것처럼 원시 포인터로 자원을 직접 소유하는 클래스는 이 기본 대입이 포인터만 복사하므로, 복사 대입과 이동 대입을 직접 정의하거나 막아야 합니다. 이때 a = a; 같은 자기 대입도 안전해야 하며, 자세한 구현은 Rule of Three, Five, Zero 실전에서 다룹니다.

오버로딩의 제한과 주의점

모든 연산자를 오버로딩할 수 있는 것은 아닙니다. 멤버 접근 ., 범위 지정 ::, 조건 연산자 ?:, sizeof, 멤버 포인터 접근 .*는 오버로딩할 수 없습니다. 또 피연산자의 개수, 연산자 우선순위와 결합 방향은 바꿀 수 없으며, 적어도 하나의 피연산자는 클래스나 열거형이어야 해서 int끼리의 +를 바꿀 수는 없습니다.

&&와 ||도 오버로딩할 수는 있지만, 오버로딩한 버전은 두 피연산자를 모두 계산하는 일반 함수 호출이 되어 관계 연산자와 논리 연산자에서 본 단락 평가가 사라집니다. 그래서 이 둘과 쉼표 연산자는 오버로딩하지 않는 편이 좋습니다.

가장 중요한 것은 연산자가 원래 기대되는 뜻을 지키는 것입니다. +가 빼기를 하거나 ==가 값을 바꾼다면 읽는 사람이 연산자만 보고 동작을 짐작할 수 없습니다. 연산자는 의미가 자연스러운 경우에만 정의하고, 그렇지 않다면 이름 있는 멤버 함수를 씁니다.

정리

  • 연산자는 operator 뒤에 기호를 붙인 함수로 정의하며, 멤버 함수이면 왼쪽 피연산자가 대상 객체가 됩니다.
  • <<와 >>처럼 왼쪽 피연산자가 다른 타입인 연산자와, 양쪽에 같은 변환이 필요한 연산자는 비멤버로 정의합니다.
  • 출력과 입력 연산자는 스트림을 참조로 돌려주어 이어 쓸 수 있게 하고, 입력은 모두 읽은 경우에만 객체를 바꿉니다.
  • +=는 자기 참조를, +는 새 값을 돌려주며, +를 +=로 구현하면 두 연산이 일관됩니다.
  • C++20에서는 = default로 비교 연산자를 만들고, ==를 정의하면 !=가 함께 쓰입니다.
  • 후위 증감은 int 매개변수로 구별하고 이전 값의 복사본을 돌려주며, 연산자는 원래 뜻을 지키는 경우에만 정의합니다.

연습 문제

1. 결과 객체와 바뀐 입력

vec2.cpp에서 Vec2 c = a + b; 직후에 a, b, c는 각각 무엇인가요? 이어서 a += b;를 실행하면 무엇이 바뀌나요?

해설 보기

+는 a의 복사본에 +=를 적용하므로 a는 (1, 2), b는 (3, 4) 그대로이고 c만 (4, 6)입니다. a += b;는 a 자신을 바꾸므로 a가 (4, 6)이 되고, b와 c는 그대로입니다. a와 c는 값이 같아져 a == c가 참이지만 서로 다른 객체입니다.

2. 증감 순서 바꾸기

counter_increment.cpp에서 Counter c(5);로 시작해 Counter a = c++; 다음에 Counter b = ++c;를 실행하면, a, b, c의 값은 무엇인가요?

해설 보기

후위 c++는 늘리기 전의 값 5의 복사본을 a에 주고 c를 6으로 만듭니다. 전위 ++c는 c를 7로 만든 뒤 그 값을 b에 복사합니다. 결과는 a가 5, b가 7, c가 7입니다.

3. 왼쪽에 놓인 정수

point_operators.cpp에서 Point q = p1 + 5;와 Point r = 5 + p1;은 각각 컴파일되나요? 둘 다 컴파일되게 하려면 어떻게 고치나요?

해설 보기

p1 + 5는 p1.operator+(5)가 되고, 5가 생성자로 Point(5, 0)으로 바뀌어 컴파일되며 결과는 (6, 2)입니다. 5 + p1은 왼쪽이 int여서 멤버 operator+를 쓸 수 없으므로 컴파일 오류입니다. 멤버 operator+를 지우고 Point operator+(const Point& a, const Point& b) { return Point(a.getX() + b.getX(), a.getY() + b.getY()); }처럼 비멤버로 정의하면 두 피연산자 모두 변환되어 둘 다 컴파일됩니다. 멤버를 남겨 둔 채 비멤버를 더하면 두 후보가 똑같이 잘 맞아 p1 + p2도 모호하다는 컴파일 오류가 납니다.

다음 글 프렌드 함수와 프렌드 클래스에서는 클래스가 특정 함수나 클래스에 private 멤버의 접근을 허락하는 friend와, 그것을 쓸 때의 기준을 다룹니다.