icon

안동민 개발노트

9장 : 객체 지향 프로그래밍 기초

연산자 오버로딩 기초


C++ 객체 지향 프로그래밍의 또 다른 강력한 기능인 연산자 오버로딩(Operator Overloading)에 대해 알아보겠습니다.

연산자 오버로딩은 사용자 정의 타입(클래스 객체)에도 +, -, *, /와 같은 기존 연산자를 사용할 수 있도록 확장하는 기능입니다.

이를 통해 코드의 가독성을 높이고, 객체 간의 연산을 더욱 직관적으로 만들 수 있습니다.


연산자 오버로딩이란?

일반적으로 C++의 연산자(예: +, -, *, /, ==, [], <<, >> 등)는 기본 데이터 타입(int, double 등)에 대해서만 미리 정의된 동작을 수행합니다.

예를 들어 두 개의 int 값을 더하는 a + b는 잘 작동합니다.

하지만 우리가 정의한 PointVector와 같은 클래스 객체를 + 연산자로 직접 더하려고 하면 어떻게 될까요?

Point 클래스 + 연산?
Point p1(1, 2);
Point p2(3, 4);
// Point p3 = p1 + p2; // 컴파일 오류! Point 타입에 대한 + 연산자가 정의되어 있지 않음

이런 연산은 컴파일 오류를 발생시킵니다.

왜냐하면 컴파일러는 Point 객체에 대해 + 연산자를 어떻게 처리해야 할지 모르기 때문입니다.

연산자 오버로딩은 바로 이 문제를 해결합니다.

클래스에 대해 특정 연산자가 사용될 때, 해당 연산자가 어떤 작업을 수행할지 우리가 직접 정의하는 것입니다.

이를 통해 사용자 정의 타입도 마치 기본 타입처럼 연산자를 사용하여 처리할 수 있게 됩니다.

장점
  • 가독성 향상: p1.add(p2) 대신 p1 + p2처럼 직관적인 코드를 작성할 수 있습니다.
  • 직관적인 표현: 수학적 또는 논리적인 개념을 코드에 더 자연스럽게 반영할 수 있습니다.

연산자 오버로딩의 기본 형식

연산자 오버로딩은 operator 키워드를 사용하여 멤버 함수 또는 비멤버 함수(전역 함수)로 정의할 수 있습니다.

연산자 오버로딩 형식
// 멤버 함수로 오버로딩
반환타입 operator연산자(매개변수1, 매개변수2, ...) {
    // 연산자 동작 정의
}

// 비멤버 함수 (전역 함수)로 오버로딩
반환타입 operator연산자(매개변수1, 매개변수2, ...) {
    // 연산자 동작 정의
}
  • operator 키워드 뒤에 오버로딩할 연산자 기호(예: +, -, ==, [] 등)를 붙입니다.
  • 매개변수의 개수와 타입은 오버로딩할 연산자의 종류(단항 연산자, 이항 연산자)에 따라 달라집니다.

이항 연산자 오버로딩 (+ 연산자 예시)

두 개의 피연산자를 가지는 이항 연산자(예: +, -, *, /, ==, !=)를 오버로딩하는 가장 흔한 방법은 다음과 같습니다.

  • 멤버 함수로 오버로딩
    • 좌측 피연산자가 호출되는 객체(암시적 this 포인터)가 됩니다.
    • 우측 피연산자 하나를 매개변수로 받습니다.
    • 반환 타입은 연산 결과의 타입입니다.
  • 비멤버 함수 (전역 함수)로 오버로딩
    • 두 피연산자를 모두 매개변수로 받습니다.
    • 클래스의 private 멤버에 접근해야 하는 경우, friend 키워드를 사용하여 프렌드 함수(Friend Function)로 선언해야 합니다. (friend 문법 자체는 ‘프렌드 함수와 프렌드 클래스’에서 독립적으로 다룹니다.)
예시 1: Point 클래스에 + 연산자 오버로딩
Point.h
#pragma once
#include <iostream>

class Point {
private:
    int x;
    int y;

public:
    Point(int _x = 0, int _y = 0) : x(_x), y(_y) {} // 기본값 인자를 가진 생성자

    // Getter 함수 (const 멤버 함수)
    int getX() const { return x; }
    int getY() const { return y; }

    // 1. 멤버 함수로 + 연산자 오버로딩
    // Point 객체를 더하면 새로운 Point 객체를 반환
    Point operator+(const Point& other) const; // const는 현재 객체의 상태를 변경하지 않음을 의미

    // 2. << 연산자 오버로딩을 위한 friend 선언 (뒤에서 다시 다룸)
    friend std::ostream& operator<<(std::ostream& os, const Point& p);
};
Point.cpp
#include "Point.h"

// 1. 멤버 함수로 + 연산자 오버로딩 정의
// 현재 Point 객체(this)와 other Point 객체를 더하여 새로운 Point 객체를 반환
Point Point::operator+(const Point& other) const {
    // 새로운 Point 객체를 생성하여 x, y 좌표를 더한 값으로 초기화
    return Point(this->x + other.x, this->y + other.y);
}

// 2. << 연산자 오버로딩 정의 (friend 함수이므로 클래스 외부에서 정의)
// Point::operator<< 가 아님!
std::ostream& operator<<(std::ostream& os, const Point& p) {
    os << "(" << p.x << ", " << p.y << ")"; // private 멤버 x, y에 직접 접근 가능 (friend라서)
    return os;
}
main.cpp
#include "Point.h" // Point 클래스 헤더 포함

int main() {
    Point p1(1, 2);
    Point p2(3, 4);

    // + 연산자 오버로딩 사용
    Point p3 = p1 + p2; // p1.operator+(p2) 와 동일

    std::cout << "p1: " << p1 << std::endl; // 출력: p1: (1, 2)
    std::cout << "p2: " << p2 << std::endl; // 출력: p2: (3, 4)
    std::cout << "p1 + p2 = p3: " << p3 << std::endl; // 출력: p1 + p2 = p3: (4, 6)

    Point p4 = p3 + Point(10, 20); // 임시 객체와도 연산 가능
    std::cout << "p3 + (10,20) = p4: " << p4 << std::endl; // 출력: p3 + (10,20) = p4: (14, 26)

    return 0;
}

단항 연산자 오버로딩 (- (부정) 연산자 예시)

하나의 피연산자를 가지는 단항 연산자(예: +, -, !, ++, --)는 멤버 함수로 오버로딩할 때 매개변수가 필요 없습니다.

예시 2: Point 클래스에 단항 - (부정) 연산자 오버로딩
Point.h
// ... (기존 Point 클래스 내용) ...
class Point {
    // ... (기존 멤버 변수 및 생성자, getter) ...
public:
    // 멤버 함수로 + 연산자 오버로딩
    Point operator+(const Point& other) const;

    // 단항 - 연산자 오버로딩 (멤버 함수)
    Point operator-() const; // 매개변수 없음
    
    friend std::ostream& operator<<(std::ostream& os, const Point& p);
};
Point.cpp
#include "Point.h"

// 단항 - 연산자 오버로딩 정의
// 현재 Point 객체의 각 좌표를 음수로 바꾸어 새로운 Point 객체를 반환
Point Point::operator-() const {
    return Point(-this->x, -this->y);
}

// ... (기존 + 연산자 및 << 연산자 정의) ...
main.cpp
#include "Point.h"

int main() {
    Point p1(1, 2);
    std::cout << "p1: " << p1 << std::endl; // 출력: p1: (1, 2)

    Point p_neg = -p1; // 단항 - 연산자 오버로딩 사용 (p1.operator-() 와 동일)
    std::cout << "-p1: " << p_neg << std::endl; // 출력: -p1: (-1, -2)

    return 0;
}

입출력 연산자 오버로딩 (<<, >> 연산자)

스트림 입출력 연산자 (<< 출력, >> 입력)는 특별한 경우입니다.

이 연산자들은 항상 비멤버 함수(전역 함수)로 오버로딩되어야 합니다.

그 이유는 좌측 피연산자가 std::ostream (for <<) 또는 std::istream (for >>) 객체이기 때문입니다.

멤버 함수로 오버로딩하면 좌측 피연산자가 항상 클래스 객체 자신(this)이 되어야 하므로 std::cout << myObject와 같은 문법을 지원할 수 없습니다.

<< 연산자 오버로딩은 일반적으로 friend 함수로 선언하여 클래스의 private 멤버에 접근할 수 있도록 합니다.

입출력 연산자 오버로딩 형식
// << (출력) 연산자 오버로딩
std::ostream& operator<<(std::ostream& os, const 클래스이름& 객체) {
    // os에 객체의 내용을 출력
    return os; // 연속적인 출력을 위해 os 참조자를 반환
}

// >> (입력) 연산자 오버로딩
std::istream& operator>>(std::istream& is, 클래스이름& 객체) {
    // is에서 데이터를 읽어 객체에 저장
    return is; // 연속적인 입력을 위해 is 참조자를 반환
}

Point 클래스의 Point.hPoint.cpp에 이미 << 연산자 오버로딩이 구현되어 있습니다.


연산자 오버로딩 시 주의사항

  • 모든 연산자를 오버로딩할 수 있는 것은 아닙니다. (. 멤버 접근, :: 스코프 결정, sizeof, ?: 삼항 연산자 등은 오버로딩 불가)
  • 연산자의 피연산자 개수(arity)를 변경할 수 없습니다. 예를 들어, 이항 연산자를 단항 연산자로 오버로딩할 수 없습니다.
  • 연산자의 우선순위나 결합 법칙을 변경할 수 없습니다.
  • 의미를 명확히 유지: 연산자를 오버로딩할 때는 원래 연산자가 가지는 일반적인 의미를 벗어나지 않도록 주의해야 합니다. 예를 들어, + 연산자가 두 수를 더하는 의미가 아니라, 두 객체를 전혀 관계없는 방식으로 빼기를 수행하도록 오버로딩하면 코드의 가독성과 이해도를 해칠 수 있습니다.
  • 일관성 유지: 연관된 연산자들(예: ++=, ==!=)은 일관성 있게 동작하도록 구현해야 합니다.
  • 성능 고려: 불필요한 객체 복사를 피하기 위해 인자를 const & (상수 참조)로 받고, 불필요한 오버로딩은 피하는 것이 좋습니다.

복합 대입 연산자와 이항 연산자의 일관성 (+=+)

실전에서는 +를 직접 구현하기보다, +=를 먼저 구현하고 +는 이를 재사용하도록 만드는 패턴이 많이 사용됩니다.

이 패턴의 장점은 다음과 같습니다.

  • 로직 중복 감소
  • ++=의 동작 일관성 유지
  • 유지보수 편의성 향상
+= 기반으로 + 구현하기
#include <iostream>

class Vec2 {
private:
    int x;
    int y;

public:
    Vec2(int _x = 0, int _y = 0) : x(_x), y(_y) {}

    // 핵심 로직은 +=에 모은다
    Vec2& operator+=(const Vec2& rhs) {
        x += rhs.x;
        y += rhs.y;
        return *this; // 연쇄 대입 지원
    }

    // +는 복사본을 만든 뒤 +=를 호출
    Vec2 operator+(const Vec2& rhs) const {
        Vec2 temp(*this);
        temp += rhs;
        return temp;
    }

    friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
        os << "(" << v.x << ", " << v.y << ")";
        return os;
    }
};

int main() {
    Vec2 a(1, 2), b(3, 4);
    Vec2 c = a + b; // (4, 6)
    a += b;         // a도 (4, 6)

    std::cout << "c = " << c << "\n";
    std::cout << "a = " << a << "\n";
}

대입 연산자 오버로딩 (operator=)

operator=는 클래스가 자원을 직접 소유할 때 특히 중요합니다.

  • 복사 대입 연산자: 깊은 복사(Deep Copy) 정책이 필요할 수 있습니다.
  • 이동 대입 연산자: 불필요한 복사를 줄여 성능을 높일 수 있습니다.
복사/이동 대입 연산자 형식
class MyType {
public:
    MyType& operator=(const MyType& rhs);      // Copy Assignment
    MyType& operator=(MyType&& rhs) noexcept;  // Move Assignment
};

대입 연산자는 자기 자신에 대한 대입(self-assignment)을 안전하게 처리해야 하며, 이동 대입은 가능한 경우 noexcept를 붙이는 것이 권장됩니다.

복사/이동 생성자와 대입 연산자의 전체 설계는 ‘Rule of Three, Five, Zero 실전’에서 구체적으로 다룹니다.


전위/후위 증감 연산자 오버로딩 (++obj, obj++)

전위와 후위는 시그니처가 다릅니다.

  • 전위 ++obj: operator++() (매개변수 없음)
  • 후위 obj++: operator++(int) (구분용 더미 int)
전위/후위 ++ 오버로딩 예시
#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; // c=11, a=11
    Counter b = c++; // c=12, b=11

    std::cout << "a: " << a.get() << ", b: " << b.get() << ", c: " << c.get() << "\n";
}

constnoexcept 설계 기준

연산자 오버로딩에서는 시그니처 설계가 품질을 좌우합니다.

  • 읽기 전용 연산 (+, -, ==, < 등)은 보통 const 멤버 함수로 선언합니다.
  • 자원을 이동만 하는 연산은 가능하면 noexcept를 붙여 컨테이너 최적화에 유리하게 만듭니다.
  • 반환 타입은 관례를 따르는 것이 좋습니다.
    • +=, =, ++(전위): T&
    • +, -, ++(후위): T (값 반환)
const와 noexcept를 적용한 시그니처 예시
class Value {
public:
    Value operator+(const Value& rhs) const;
    Value& operator+=(const Value& rhs);
    bool operator==(const Value& rhs) const noexcept;
    Value& operator=(Value&& rhs) noexcept;
};

연산자 오버로딩은 문법 자체보다 일관된 의미와 예측 가능한 동작이 더 중요합니다.


심화 연결: 다형성과 템플릿 분기

연산자 오버로딩 시그니처를 설계할 때는 단순 문법뿐 아니라 다형성 계층에서의 값/참조 전달 정책도 함께 고려해야 합니다.

  • 다형성이 필요한 기반 타입을 값으로 전달하면 객체 슬라이싱이 발생할 수 있습니다.
  • 따라서 연산자 매개변수는 const T& 형태를 기본 선택으로 두고, 정말 필요한 경우에만 값 전달을 사용합니다.

이 부분은 10장의 ‘다형성과 가상 함수’에서 다루는 객체 슬라이싱 내용과 직접 연결됩니다.

또한 템플릿 기반 유틸리티 연산에서는 if constexpr를 활용한 컴파일 타임 분기 설계가 유용합니다. 관련 내용은 14장의 ‘C++ 20 기능 소개’에서 확인할 수 있습니다.

목차