연산자 오버로딩 기초
사용자 정의 타입에 산술·입출력·대입·증감 연산의 의미를 부여하고 일관된 연산자 인터페이스를 설계합니다.
C++ 객체 지향 프로그래밍에서 연산자 의미를 타입에 맞게 확장하는 연산자 오버로딩(Operator Overloading)을 다룹니다.
연산자 오버로딩은 사용자 정의 타입(클래스 객체)에도 +, -, *, /와 같은 기존 연산자를 사용할 수 있도록 확장하는 기능입니다.
이를 통해 코드의 가독성을 높이고, 객체 간의 연산을 더욱 직관적으로 만들 수 있습니다.
C++ · OPERATOR RESOLUTION
피연산자로 후보를 찾고 호출 가능성과 변환을 비교한다
본문의 +와 스트림 <<는 멤버·비멤버 후보에서 오버로드를 결정합니다. 반환·예외 계약은 구현과 사용을 설명하며, noexcept만 다르게 해서 별도 오버로드를 만들 수는 없습니다.
없거나 모호하면 컴파일 오류이 그림은 이항 + / << 범위
① 피연산자
타입, const 여부와 값 범주를 확인합니다.
② 후보 집합
멤버와 일반 이름 탐색·ADL의 비멤버 후보, 해당 내장 후보를 찾습니다.
③ 오버로드 결정
인수 변환과 참조 바인딩 등으로 호출 가능한 후보를 비교해 유일한 최선을 선택합니다. 없거나 모호하면 오류입니다.
Point의 멤버 +
본문 Point::operator+는 왼쪽 Point 객체에 오른쪽 Point를 인수로 전달하는 형태입니다.
p1.operator+(p2)const 멤버 여부와 매개변수 타입이 호출 가능성에 영향을 줍니다.
스트림의 비멤버 <<
왼쪽 std::ostream을 수정하고 스트림 참조를 반환하는 비멤버 함수 형태입니다.
operator<<(os, point)friend 지정은 Point의 private 접근 권한을 줍니다. 함수가 Point의 멤버가 되지는 않습니다.
이 그림은 본문에 등장하는 이항 +와 <<를 설명합니다. 비교 연산에는 C++20 이후 재작성 후보 등 추가 규칙이 있으며, 각 연산자의 후보 규칙을 따릅니다.
그림의 ADL은 인자 타입과 관련된 이름 공간에서 비멤버 함수를 찾는 규칙입니다. 후보 중 호출 가능한 함수를 고르며, const·참조 형식은 이 판단에 관여합니다. noexcept는 선택한 함수의 예외 계약이지 독립적인 오버로딩 구분 수단은 아닙니다.
연산자 오버로딩이란?
일반적으로 C++의 연산자(예: +, -, *, /, ==, [], <<, >> 등)는 기본 데이터 타입(int, double 등)에 대해서만 미리 정의된 동작을 수행합니다.
예를 들어 두 개의 int 값을 더하는 a + b는 잘 작동합니다.
하지만 우리가 정의한 Point나 Vector와 같은 클래스 객체를 + 연산자로 직접 더하려고 하면 어떻게 될까요?
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문법 자체는 ‘프렌드 함수와 프렌드 클래스’에서 독립적으로 다룹니다.)
Point 클래스에 + 연산자 오버로딩
#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);
};#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;
}#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;
}단항 연산자 오버로딩 (- (부정) 연산자 예시)
단항 +, -, !와 전위 ++, --를 아래 예제처럼 멤버 함수로 오버로딩할 때는 매개변수를 적지 않습니다. 다만 후위 ++, --는 전위와 구별하는 int 매개변수 하나가 필요하며, 뒤의 전위/후위 증감 연산자 예제에서 두 형식을 비교합니다.
Point 클래스에 단항 - (부정) 연산자 오버로딩
아래 Point.h·Point.cpp는 앞의 완전한 파일에 추가할 선언과 정의만 표시한 발췌입니다. 앞의 Point 클래스를 다시 정의하지 않고 그 public 구간에 operator- 선언을 추가하며, 기존 Point.cpp에 정의를 추가합니다. 이번 main.cpp는 앞의 main을 교체합니다. g++ -std=c++20 -Wall -Wextra Point.cpp main.cpp -o point.exe처럼 두 소스를 함께 빌드하세요. 새 실행에서 −p1은 새 값을 만들고 p1은 (1,2)를 유지합니다.
// ... (기존 Point 클래스 내용) ...
class Point {
// ... (기존 멤버 변수 및 생성자, getter) ...
public:
// 멤버 함수로 + 연산자 오버로딩
Point operator+(const Point& other) const;
// 단항 - 연산자 오버로딩 (멤버 함수)
Point operator-() const; // 매개변수 없음
friend std::ostream& operator<<(std::ostream& os, const Point& p);
};#include "Point.h"
// 단항 - 연산자 오버로딩 정의
// 현재 Point 객체의 각 좌표를 음수로 바꾸어 새로운 Point 객체를 반환
Point Point::operator-() const {
return Point(-this->x, -this->y);
}
// ... (기존 + 연산자 및 << 연산자 정의) ...#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, >>의 경우 std::istream 객체이기 때문입니다.
멤버 함수로 오버로딩하면 좌측 피연산자가 항상 클래스 객체 자신(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.h와 Point.cpp에 이미 << 연산자 오버로딩이 구현되어 있습니다.
연산자 오버로딩 시 주의사항
- 모든 연산자를 오버로딩할 수 있는 것은 아닙니다. (
.멤버 접근,::스코프 결정,sizeof,?:삼항 연산자 등은 오버로딩 불가) - 연산자의 피연산자 개수(arity)를 변경할 수 없습니다. 예를 들어, 이항 연산자를 단항 연산자로 오버로딩할 수 없습니다.
- 연산자의 우선순위나 결합 법칙을 변경할 수 없습니다.
- 의미를 명확히 유지: 연산자를 오버로딩할 때는 원래 연산자가 가지는 일반적인 의미를 벗어나지 않도록 주의해야 합니다. 예를 들어,
+연산자가 두 수를 더하는 의미가 아니라, 두 객체에 대해 전혀 관계없는 방식으로 빼기를 수행하도록 오버로딩하면 코드의 가독성과 이해도를 해칠 수 있습니다. - 일관성 유지: 연관된 연산자들(예:
+와+=,==와!=)은 일관성 있게 동작하도록 구현해야 합니다. - 성능 고려: 불필요한 객체 복사를 피하기 위해 인자를
const &(상수 참조)로 받고, 불필요한 오버로딩은 피하는 것이 좋습니다.
복합 대입 연산자와 이항 연산자의 일관성 (+=와 +)
아래는 연산의 변경 여부·대칭성·접근 권한에 따른 설계 관례입니다. 모든 변경 연산이 언어 규칙상 멤버나 참조 반환이어야 한다는 뜻은 아닙니다.
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는 후위를 구별하는 매개변수입니다.
오버로딩은 기존 연산자의 의미를 사용자 타입에 구현합니다. 연산자의 우선순위·결합 방향·피연산자 수를 새로 바꾸지는 않습니다.
실전에서는 +를 직접 구현하기보다, +=를 먼저 구현하고 +는 이를 재사용하도록 만드는 패턴이 많이 사용됩니다.
이 패턴의 장점은 다음과 같습니다.
- 로직 중복 감소
+와+=의 동작 일관성 유지- 유지보수 편의성 향상
#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";
}const와 noexcept 설계 기준
연산자 오버로딩에서는 시그니처 설계가 품질을 좌우합니다.
- 읽기 전용 연산 (
+,-,==,<등)은 보통const멤버 함수로 선언합니다. - 자원을 이동만 하는 연산은 가능하면
noexcept를 붙여 컨테이너 최적화에 유리하게 만듭니다. - 반환 타입은 관례를 따르는 것이 좋습니다.
+=,=,++(전위):T&+,-,++(후위):T(값 반환)
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;
};연산자 오버로딩은 문법 자체보다 일관된 의미와 예측 가능한 동작이 더 중요합니다.
결과 객체와 변경된 입력을 혼자 추적하기
Vec2 원 main에서 c=a+b를 계산한 직후 a·b·c는 무엇인가요? 이어 a+=b가 끝나면 무엇이 달라지나요? 새 독립 실행으로 Counter를 5에서 시작하여 Counter a = c++; Counter b = ++c; 순서로 바꾸고 a·b·c의 get 결과를 예측하세요. 각 문장은 별도로 실행하며 하나의 표현식 안에서 증감을 섞지 않습니다.
각 operator 본문에서 찾는 풀이
Vec2의 +는 temp를 복사한 뒤 temp에만 +=를 호출하므로 첫 시점은 a(1,2), b(3,4), c(4,6)입니다. 다음 a+=b 뒤에는 a(4,6), b(3,4), c(4,6)이며 c는 같은 값이어도 별도 객체입니다. Counter의 후위는 예전 값 5의 사본을 a에 주고 c를 6으로 만들며, 전위는 c를 7로 만든 뒤 그 값을 b에 복사합니다. 결과는 a의 값 5·b의 값 7·c의 값 7입니다. 이 결과는 C++가 모든 사용자 연산자를 대신 구현한 것이 아니라 본문의 operator 구현을 따른 것입니다.
심화 연결: 다형성과 템플릿 분기
연산자 오버로딩 시그니처를 설계할 때는 단순 문법뿐 아니라 다형성 계층에서의 값/참조 전달 정책도 함께 고려해야 합니다.
- 다형성이 필요한 기반 타입을 값으로 전달하면 객체 슬라이싱이 발생할 수 있습니다.
- 따라서 연산자 매개변수는
const T&형태를 기본 선택으로 두고, 정말 필요한 경우에만 값 전달을 사용합니다.
이 부분은 10장의 ‘다형성과 가상 함수’에서 다루는 객체 슬라이싱 내용과 직접 연결됩니다.
또한 템플릿 기반 유틸리티 연산에서는 if constexpr를 활용한 컴파일 타임 분기 설계가 유용합니다.
관련 내용은 14장의 ‘C++ 20 기능 소개’에서 확인할 수 있습니다.
연산자 오버로딩은 짧은 문법 뒤에 의미, 비용, 예외 가능성을 숨길 수 있으므로 기존 연산자의 기대와 맞는지 검토해야 합니다.
연산자 오버로딩은 operator 함수, const, noexcept, 복합 대입의 의미 일관성을 함께 정해야 합니다.