연산자 오버로딩
연산자 오버로딩 (operator overloading)
C++ 언타입엔 +, ==, << 같은 연산자가 이미 정의돼 있지만, 사용자 정의 타입에 대해서는 그 동작을 우리가 직접 정할 수 있어요. 이걸 **연산자 오버로딩(operator overloading)**이라고 해요. 사용자 정의 타입의 피연산자를 위해 연산자 자체를 커스터마이즈하는 거죠.
출처: cppreference
본문
문법
_연산자 함수(operator function)_는 특별한 함수 이름을 가진 함수예요.
operator op |
(1) | |
operator newoperator new [] |
(2) | |
operator deleteoperator delete [] |
(3) | |
operator co_await |
(4) | (C++20부터) |
| op | – | 다음 연산자 중 하나: + - * / % ^ & ` |
- 오버로드된 문장 부호 연산자.
- 할당 함수(allocation function).
- 해제 함수(deallocation function).
co_await표현식에서 쓸 오버로드된co_await연산자.
문장 부호가 아닌 연산자들(new, delete, co_await)의 동작은 각자 해당 페이지에서 설명돼요. 별도로 명시하지 않는 한, 이 페이지의 나머지 설명은 이 함수들에는 적용되지 않아요.
동작(Explanation)
표현식에 연산자가 나타나고, 그 피연산자 중 하나가 클래스 타입이나 열거 타입이면, 다음 서명(signature)들과 일치하는 모든 함수 중에서 호출할 사용자 정의 함수를 고르기 위해 오버로드 해석을 해요.
| 표현식 | 멤버 함수로 | 비멤버 함수로 | 예시 |
|---|---|---|---|
| @a | (a).operator@ ( ) | operator@ (a) | !std::cin은 std::cin.operator!()를 호출 |
| a@b | (a).operator@ (b) | operator@ (a, b) | std::cout << 42는 std::cout.operator<<(42)를 호출 |
| a=b | (a).operator= (b) | 비멤버로는 불가 | std::string s;가 있다면 s = "abc";는 s.operator=("abc") 호출 |
| a(b...) | (a).operator()(b...) | 비멤버로는 불가 | std::random_device r;가 있다면 auto n = r();는 r.operator()() 호출 |
| a[b...] | (a).operator[](b...) | 비멤버로는 불가 | std::map<int, int> m;이 있다면 m[1] = 2;는 m.operator[](1) 호출 |
| a-> | (a).operator->( ) | 비멤버로는 불가 | std::unique_ptr<S> p;가 있다면 p->bar()는 p.operator->() 호출 |
| a@ | (a).operator@ (0) | operator@ (a, 0) | std::vector<int>::iterator i;가 있다면 i++는 i.operator++(0) 호출 |
이 표에서 @는 일치하는 모든 연산자의 자리 표시자예요. @a의 모든 전위 연산자, a@의 ->를 뺀 모든 후위 연산자, a@b의 =를 뺀 모든 중위 연산자를 의미해요.
게다가 비교 연산자 ==, !=, <, >, <=, >=, <=>의 경우, 오버로드 해석은 재작성 후보(rewritten candidates)operator==나 operator<=>도 고려해요. |
(C++20부터) |
오버로드된 연산자(내장 연산자는 제외)는 함수 표기법으로도 호출할 수 있어요.
std::string str = "Hello, ";
str.operator+=("world"); // str += "world"와 동일
operator<<(operator<<(std::cout, str), '\n'); // std::cout << str << '\n'과 동일
// (C++17부터) 순서 규칙만 빼면
| #### 정적 오버로드 연산자 멤버 함수인 오버로드 연산자는 static으로 선언할 수 있어요. 다만 이것은 operator()와 operator[]에만 허용돼요.그런 연산자는 함수 표기법으로 호출할 수 있어요. 하지만 표현식에 나타날 때는 여전히 클래스 타입 객체가 필요해요. <br>struct SwapThem<br>{<br> template<typename T><br> static void operator()(T& lhs, T& rhs) <br> {<br> std::ranges::swap(lhs, rhs);<br> }<br> <br> template<typename T><br> static void operator[](T& lhs, T& rhs)<br> {<br> std::ranges::swap(lhs, rhs);<br> } <br>};<br>inline constexpr SwapThem swap_them{};<br>void foo()<br>{<br> int a = 1, b = 2;<br> <br> swap_them(a, b); // OK<br> swap_them[a, b]; // OK<br> <br> SwapThem{}(a, b); // OK<br> SwapThem{}[a, b]; // OK<br> <br> SwapThem::operator()(a, b); // OK<br> SwapThem::operator[](a, b); // OK<br> <br> SwapThem(a, b); // error, 유효하지 않은 생성<br> SwapThem[a, b]; // error<br>}<br> |
(C++23부터) |
제약(Restrictions)
- 연산자 함수는 매개변수나 암시적 객체 매개변수 중 적어도 하나의 타입이 클래스, 클래스에 대한 참조, 열거 타입, 열거 타입에 대한 참조여야 해요.
::(스코프 해석),.(멤버 접근),.*(멤버 포인터를 통한 멤버 접근),?:(삼항 조건부)는 오버로드할 수 없어요.**,<>,&|같은 새 연산자를 만들 수는 없어요.- 연산자의 우선순위, 그룹화, 피연산자 개수를 바꿀 수는 없어요.
->연산자의 오버로드는 원시 포인터를 반환하거나,->가 또 오버로드된 객체를 (참조나 값으로) 반환해야 해요.&&와||의 오버로드는 단락 평가(short-circuit evaluation)를 잃어요.
- &&, ` |
표준 구현 형태(Canonical implementations)
위 제약 말고 언어는 오버로드 연산자가 뭘 하는지나 반환 타입에 다른 제약을 두지 않아요(반환 타입은 오버로드 해석에 참여하지 않아요). 하지만 일반적으로 오버로드 연산자는 내장 연산자와 최대한 비슷하게 동작할 것으로 기대돼요. operator+는 곱하는 대신 더해야 하고, operator=는 대입해야 하고요. 관련 연산자끼리도 비슷하게 동작해야 해요(operator+와 operator+=는 같은 더하기 같은 동작). 반환 타입은 연산자가 쓰일 표현식에 의해 제한돼요. 예를 들어 대입 연산자는 a = b = c = d처럼 쓸 수 있게 참조를 반환해요. 내장 연산자가 그걸 허용하기 때문이에요.
흔히 오버로드되는 연산자들은 다음과 같은 전형적인 표준 형태를 가져요.[^1]
대입 연산자
대입 연산자 operator=는 특별한 속성을 가져요. 자세한 내용은 복사 대입과 이동 대입을 참고해요.
표준 복사 대입 연산자는 자기 대입에 안전하고, 왼쪽 피연산자를 참조로 반환할 것이 기대돼요.
// copy assignment
T& operator=(const T& other)
{
// Guard self assignment
if (this == &other)
return *this;
// assume *this manages a reusable resource, such as a heap-allocated buffer mArray
if (size != other.size) // resource in *this cannot be reused
{
temp = new int[other.size]; // allocate resource, if throws, do nothing
delete[] mArray; // release resource in *this
mArray = temp;
size = other.size;
}
std::copy(other.mArray, other.mArray + other.size, mArray);
return *this;
}
표준 이동 대입은 이동된 원본 객체를 유효한 상태로 남기고(즉 클래스 불변식이 온전한 상태), 자기 대입 시엔 아무것도 하지 않거나 최소한 유효한 상태로 남기며, 비-const 왼쪽 피연산자를 참조로 반환하고 noexcept일 것이 기대돼요.<br>// move assignment<br>T& operator=(T&& other) noexcept<br>{<br> // Guard self assignment<br> if (this == &other)<br> return *this; // delete[]/size=0 would also be ok<br> <br> delete[] mArray; // release resource in *this<br> mArray = std::exchange(other.mArray, nullptr); // leave other in valid state<br> size = std::exchange(other.size, 0);<br> return *this;<br>}<br> |
(C++11부터) |
복사 대입이 자원 재사용의 이득을 볼 수 없는 상황(힙 할당 배열을 관리하지 않고, 그런 멤버를 가진 멤버도 없는 경우)에선, 인기 있는 간편 표기법 하나가 있어요. 바로 copy-and-swap 대입 연산자예요. 매개변수를 값으로 받아서(따라서 인자의 값 카테고리에 따라 복사·이동 대입을 겸함) 매개변수와 스왑하고, 소멸자가 정리하게 두는 방식이에요.
// copy assignment (copy-and-swap idiom)
T& T::operator=(T other) noexcept // call copy or move constructor to construct other
{
std::swap(size, other.size); // exchange resources between *this and other
std::swap(mArray, other.mArray);
return *this;
} // destructor of other is called to release the resources formerly managed by *this
이 형태는 자동으로 강한 예외 보장(strong exception guarantee)을 제공하지만, 자원 재사용은 못 해요.
스트림 추출·삽입
왼쪽 인자로 std::istream&이나 std::ostream&을 받는 operator>>와 operator<<의 오버로드를 삽입·추출 연산자라고 해요. 사용자 정의 타입을 오른쪽 인자(a @ b의 b)로 받기 때문에 비멤버로 구현해야 해요.
std::ostream& operator<<(std::ostream& os, const T& obj)
{
// write obj to stream
return os;
}
std::istream& operator>>(std::istream& is, T& obj)
{
// read obj from stream
if (/* T could not be constructed */)
is.setstate(std::ios::failbit);
return is;
}
이 연산자들은 때로 friend 함수로 구현되기도 해요.
함수 호출 연산자
사용자 정의 클래스가 함수 호출 연산자 operator()를 오버로드하면, 그 타입은 FunctionObject 타입이 돼요.
그런 타입의 객체는 함수 호출 표현식에서 쓸 수 있어요.
// An object of this type represents a linear function of one variable a * x + b.
struct Linear
{
double a, b;
double operator()(double x) const
{
return a * x + b;
}
};
int main()
{
Linear f{2, 1}; // Represents function 2x + 1.
Linear g{-1, 0}; // Represents function -x.
// f and g are objects that can be used like a function.
double f_0 = f(0);
double f_1 = f(1);
double g_0 = g(0);
}
많은 표준 라이브러리 알고리즘이 동작을 커스터마이즈하기 위해 FunctionObject를 받아요. operator()에 특히 유명한 표준 형태는 없지만, 사용 예를 보여 주면 이래요.
#include <algorithm>
#include <iostream>
#include <vector>
struct Sum
{
int sum = 0;
void operator()(int n) { sum += n; }
};
int main()
{
std::vector<int> v = {1, 2, 3, 4, 5};
Sum s = std::for_each(v.begin(), v.end(), Sum());
std::cout << "The sum is " << s.sum << '\n';
}
출력:
The sum is 15
증가·감소 연산자
후위 증가·감소 연산자가 표현식에 나타나면, 대응하는 사용자 정의 함수(operator++나 operator--)는 정수 인자 0과 함께 호출돼요. 보통 T operator++(int)나 T operator--(int)로 선언하는데, 이 인자는 무시돼요. 후위 증가·감소 연산자는 보통 전위 버전을 이용해 구현해요.
struct X
{
// prefix increment
X& operator++()
{
// actual increment takes place here
return *this; // return new value by reference
}
// postfix increment
X operator++(int)
{
X old = *this; // copy old value
operator++(); // prefix increment
return old; // return old value
}
// prefix decrement
X& operator--()
{
// actual decrement takes place here
return *this; // return new value by reference
}
// postfix decrement
X operator--(int)
{
X old = *this; // copy old value
operator--(); // prefix decrement
return old; // return old value
}
};
전위 증가·감소 연산자의 표준 구현은 참조를 반환하지만, 다른 연산자 오버로드처럼 반환 타입은 사용자가 정해요. 예를 들어 std::atomic의 이 연산자 오버로드는 값으로 반환해요.
이항 산술 연산자
이항 연산자는 보통 대칭을 유지하기 위해 비멤버로 구현돼요. (예를 들어 복소수와 정수를 더할 때, operator+가 복소수 타입의 멤버 함수면 complex + integer만 컴파일되고 integer + complex는 안 돼요.) 모든 이항 산술 연산자에는 대응하는 복합 대입 연산자가 있으므로, 이항 연산자의 표준 형태는 그 복합 대입으로 구현돼요.
class X
{
public:
X& operator+=(const X& rhs) // compound assignment (does not need to be a member,
{ // but often is, to modify the private members)
/* addition of rhs to *this takes place here */
return *this; // return the result by reference
}
// friends defined inside class body are inline and are hidden from non-ADL lookup
friend X operator+(X lhs, // passing lhs by value helps optimize chained a+b+c
const X& rhs) // otherwise, both parameters may be const references
{
lhs += rhs; // reuse compound assignment
return lhs; // return the result by value (uses move constructor)
}
};
비교 연산자
std::sort 같은 표준 라이브러리 알고리즘과 std::set 같은 컨테이너는 사용자 제공 타입에 operator<가 기본으로 정의돼 있고, **엄밀 약순서(strict weak ordering)**를 구현할 것을 기대해요. 구조체에 엄밀 약순서를 구현하는 관용적인 방법은 std::tie가 주는 사전순 비교를 쓰는 거예요.
struct Record
{
std::string name;
unsigned int floor;
double weight;
friend bool operator<(const Record& l, const Record& r)
{
return std::tie(l.name, l.floor, l.weight)
< std::tie(r.name, r.floor, r.weight); // keep the same order
}
};
보통 operator<를 제공하면 나머지 관계 연산자는 operator<로 구현해요.
inline bool operator< (const X& lhs, const X& rhs) { /* do actual comparison */ }
inline bool operator> (const X& lhs, const X& rhs) { return rhs < lhs; }
inline bool operator<=(const X& lhs, const X& rhs) { return !(lhs > rhs); }
inline bool operator>=(const X& lhs, const X& rhs) { return !(lhs < rhs); }
마찬가지로 부등 연산자는 보통 operator==로 구현해요.
inline bool operator==(const X& lhs, const X& rhs) { /* do actual comparison */ }
inline bool operator!=(const X& lhs, const X& rhs) { return !(lhs == rhs); }
삼중 비교(세 값 사이 비교, 예: std::memcmp 또는 std::string::compare)를 제공하면, 여섯 개의 이항 비교 연산자를 모두 그걸로 표현할 수 있어요.
inline bool operator==(const X& lhs, const X& rhs) { return cmp(lhs,rhs) == 0; }
inline bool operator!=(const X& lhs, const X& rhs) { return cmp(lhs,rhs) != 0; }
inline bool operator< (const X& lhs, const X& rhs) { return cmp(lhs,rhs) < 0; }
inline bool operator> (const X& lhs, const X& rhs) { return cmp(lhs,rhs) > 0; }
inline bool operator<=(const X& lhs, const X& rhs) { return cmp(lhs,rhs) <= 0; }
inline bool operator>=(const X& lhs, const X& rhs) { return cmp(lhs,rhs) >= 0; }
배열 첨자 연산자
읽기와 쓰기를 모두 허용하는 배열 같은 접근을 제공하는 사용자 정의 클래스는 보통 operator[]에 const와 비-const 두 오버로드를 정의해요.
struct T
{
value_t& operator[](std::size_t idx) { return mVector[idx]; }
const value_t& operator[](std::size_t idx) const { return mVector[idx]; }
};
대안으로, 명시적 객체 멤버 함수(explicit object member function)를 쓴 단일 멤버 함수 템플릿으로 표현할 수도 있어요.<br>struct T<br>{<br> decltype(auto) operator[](this auto& self, std::size_t idx) <br> { <br> return self.mVector[idx]; <br> }<br>};<br> |
(C++23부터) |
값 타입이 스칼라 타입임이 알려져 있으면 const 오버로드는 값으로 반환해야 해요.
컨테이너 요소에 직접 접근하는 게 바람직하지 않거나 불가능할 때, 또는 좌값 c[i] = v;와 우값 v = c[i];의 사용을 구분하고 싶을 때, operator[]는 프록시(proxy)를 반환할 수 있어요. std::bitset::operator[]를 참고해요.
operator[]는 첨자를 하나만 받을 수 있었어요. 3차원 배열 접근 a[i][j][k] = x;를 구현하려면 operator[]가 2차원 평면에 대한 참조를 반환하고, 그 타입이 다시 자체 operator[]로 1차원 행에 대한 참조를, 또 그 타입이 요소에 대한 참조를 반환하는 operator[]를 가져야 했죠. 이런 복잡함을 피하려고 일부 라이브러리는 대신 operator()를 오버로드해서 3차원 접근이 Fortran스러운 문법 a(i, j, k) = x;가 되게 해요. |
(C++23까지) |
operator[]는 이제 몇 개든 첨자를 받을 수 있어요. 예를 들어 T& operator[](std::size_t x, std::size_t y, std::size_t z);로 선언한 3차원 배열 클래스의 operator[]는 요소에 직접 접근할 수 있어요.<br>#include <array><br>#include <cassert><br>#include <iostream><br>template<typename T, std::size_t Z, std::size_t Y, std::size_t X><br>struct Array3d<br>{<br> std::array<T, X * Y * Z> m{};<br> constexpr T& operator[](std::size_t z, std::size_t y, std::size_t x) // C++23<br> {<br> assert(x < X and y < Y and z < Z);<br> return m[z * Y * X + y * X + x];<br> }<br>};<br>int main()<br>{<br> Array3d<int, 4, 3, 2> v;<br> v[3, 2, 1] = 42;<br> std::cout << "v[3, 2, 1] = " << v[3, 2, 1] << '\n';<br>}<br>출력: <br>v[3, 2, 1] = 42<br> |
(C++23부터) |
비트 산술 연산자
BitmaskType 요구 사항을 구현하는 사용자 정의 클래스와 열거 타입은 비트 산술 연산자 operator&, operator|, operator^, operator~, operator&=, operator|=, operator^=를 오버로드해야 하고, 시프트 연산자 operator<< operator>>, operator>>=, operator<<=는 선택적으로 오버로드할 수 있어요. 표준 구현은 보통 위에서 설명한 이항 산술 연산자의 패턴을 따라요.
논리 부정 연산자
operator!는 불리언 문맥에서 쓰려고 만든 사용자 정의 클래스들이 흔히 오버로드해요. 그런 클래스는 불리언 타입으로의 사용자 정의 변환 함수도 제공하고요(표준 라이브러리 예로 std::basic_ios 참고). 이때 operator!가 operator bool의 반대 값을 반환할 것이 기대돼요. |
(C++11까지) |
내장 연산자 !는 bool로의 문맥 변환(contextual conversion)을 수행하므로, 불리언 문맥에서 쓰려는 사용자 정의 클래스는 operator bool만 제공하고 operator!는 오버로드할 필요가 없어요. |
(C++11부터) |
드물게 오버로드되는 연산자
다음 연산자들은 드물게 오버로드돼요.
- 주소 연산자
operator&. 단항&가 불완전 타입의 좌값에 적용되고 완전 타입이 오버로드된operator&를 선언하면, 내장 의미로 적용될지 연산자 함수가 호출될지 불명확해요. 이 연산자가 오버로드될 수 있기 때문에, 제네릭 라이브러리는 사용자 정의 타입 객체의 주소를 얻을 때 std::addressof를 써요. 표준 오버로드operator&의 가장 잘 알려진 예는 Microsoft 클래스CComPtrBase예요. EDSL에서 이 연산자 사용 예는 boost.spirit에서 볼 수 있어요. - 불리언 논리 연산자
operator&&와operator||. 내장 버전과 달리 오버로드는 단락 평가를 구현할 수 없어요. 또 내장 버전과 달리 왼쪽 피연산자를 오른쪽보다 먼저 시퀀싱하지도 않아요. (C++17까지) 표준 라이브러리에서 이 연산자는 std::valarray만 오버로드해요. - 쉼표 연산자
operator,. 내장 버전과 달리 오버로드는 왼쪽 피연산자를 오른쪽보다 먼저 시퀀싱하지 않아요. (C++17까지) 이 연산자가 오버로드될 수 있으므로, 제네릭 라이브러리는 사용자 정의 타입의 표현식들을 순서대로 실행하려고a, b대신a, void(), b같은 식을 써요. boost 라이브러리는 boost.assign과 boost.spirit 등에서operator,를 써요. 데이터베이스 접근 라이브러리 SOCI도operator,를 오버로드해요. - 멤버 포인터를 통한 멤버 접근
operator->*. 이 연산자를 오버로드하는 데 특별한 단점은 없지만 실무에선 드물게 쓰여요. 스마트 포인터 인터페이스의 일부가 될 수 있다는 제안이 있었고, 실제로 boost.phoenix의 액터가 그 용도로 써요. cpp.react 같은 EDSL에서 더 흔해요.
주의할 점(Notes)
| 기능 테스트 매크로 | 값 | 표준 | 기능 |
|---|---|---|---|
__cpp_static_call_operator |
202207L |
(C++23) | static operator() |
__cpp_multidimensional_subscript |
202211L |
(C++23) | static operator[] |
예제
분수가 어떻게 이항 연산자와 삽입 연산자를 오버로드하는지 보여 주는 예시예요.
#include <iostream>
class Fraction
{
// or C++17's std::gcd
constexpr int gcd(int a, int b) { return b == 0 ? a : gcd(b, a % b); }
int n, d;
public:
constexpr Fraction(int n, int d = 1) : n(n / gcd(n, d)), d(d / gcd(n, d)) {}
constexpr int num() const { return n; }
constexpr int den() const { return d; }
constexpr Fraction& operator*=(const Fraction& rhs)
{
int new_n = n * rhs.n / gcd(n * rhs.n, d * rhs.d);
d = d * rhs.d / gcd(n * rhs.n, d * rhs.d);
n = new_n;
return *this;
}
};
std::ostream& operator<<(std::ostream& out, const Fraction& f)
{
return out << f.num() << '/' << f.den();
}
constexpr bool operator==(const Fraction& lhs, const Fraction& rhs)
{
return lhs.num() == rhs.num() && lhs.den() == rhs.den();
}
constexpr bool operator!=(const Fraction& lhs, const Fraction& rhs)
{
return !(lhs == rhs);
}
constexpr Fraction operator*(Fraction lhs, const Fraction& rhs)
{
return lhs *= rhs;
}
int main()
{
constexpr Fraction f1{3, 8}, f2{1, 2}, f3{10, 2};
std::cout << f1 << " * " << f2 << " = " << f1 * f2 << '\n'
<< f2 << " * " << f3 << " = " << f2 * f3 << '\n'
<< 2 << " * " << f1 << " = " << 2 * f1 << '\n';
static_assert(f3 == f2 * 10);
}
출력:
3/8 * 1/2 = 3/16
1/2 * 5/1 = 5/2
2 * 3/8 = 3/4
결함 보고(Defect reports)
이전에 발표된 C++ 표준들에 소급 적용된 동작 변경 결함 보고들이 있어요.
| DR | 적용 대상 | 발표 당시 동작 | 올바른 동작 |
|---|---|---|---|
| CWG 1481 | C++98 | 비멤버 전위 증가 연산자는 클래스 타입, 열거 타입, 또는 그런 타입에 대한 참조 타입의 매개변수만 가질 수 있었음 | 타입 요구 사항 없음 |
| CWG 2931 | C++23 | 명시적 객체 멤버 연산자 함수는 클래스 타입·열거 타입·그 참조 타입의 매개변수를 가질 수 있었음 | 금지됨 |