11.1 레슨에서 '함수 오버로딩'에 대해 배웠던 것 기억하시나요? 함수 오버로딩은 이름은 같지만 전달받는 재료(매개변수)가 다른 여러 개의 함수를 만들 수 있게 해주는 편리한 기능입니다. 덕분에 다루는 데이터의 종류가 달라도 매번 새로운 함수 이름을 지어낼 필요 없이 같은 이름의 함수를 사용할 수 있었죠.
C++에서는 +나 - 같은 '연산자(operator)'들도 사실은 내부적으로 함수처럼 작동합니다. 따라서 이 연산자 함수들에 함수 오버로딩 기능을 적용하면, 우리가 직접 만든 클래스 같은 새로운 데이터 타입에 맞춰 연산자의 작동 방식을 새롭게 정의할 수 있습니다. 이렇게 기존 연산자에 나만의 새로운 능력을 부여하는 것을 바로 '연산자 오버로딩'이라고 부릅니다.
이번 장에서는 이 연산자 오버로딩과 관련된 다양한 주제들을 하나씩 살펴보겠습니다.
다음 예시를 한번 볼까요?
int x { 2 };
int y { 3 };
std::cout << x + y << '\n';
컴파일러(코드를 컴퓨터 언어로 번역해 주는 프로그램)는 숫자(정수)들을 더할 수 있는 더하기 연산자(+)를 기본적으로 내장하고 있습니다. 이 연산자 함수는 x와 y라는 정수를 받아서 두 값을 더한 뒤, 그 결과인 정수를 돌려줍니다. 코드를 읽으실 때 x + y라는 수식을 보시면, 머릿속으로는 이것이 operator+(x, y)라는 함수를 호출하는 것과 똑같다고 생각하시면 이해하기 쉽습니다. (여기서 함수 이름이 operator+인 것이죠!)
자, 이제 아주 비슷한 다음 코드를 볼까요?
double z { 2.0 };
double w { 3.0 };
std::cout << w + z << '\n';
컴파일러는 소수점 숫자(double)를 계산할 수 있는 더하기 연산자(+) 기능도 이미 가지고 있습니다. w + z라는 코드는 operator+(w, z)라는 함수 호출로 바뀝니다. 이때 컴파일러는 똑똑하게도 '함수 오버로딩' 기능을 사용해서, 정수용 더하기 함수가 아니라 소수점용 더하기 함수를 불러와야 한다는 것을 알아냅니다.
그렇다면 우리가 직접 만든 클래스(객체) 두 개를 더하려고 하면 어떤 일이 일어날까요?
Mystring string1 { "Hello, " };
Mystring string2 { "World!" };
std::cout << string1 + string2 << '\n';
이 경우 어떤 결과가 나올까요? 직관적으로는 "Hello, World!"라는 문장이 화면에 예쁘게 출력될 것이라고 기대하게 됩니다. 하지만 Mystring은 우리가 새롭게 만들어낸 타입이기 때문에, 컴파일러는 Mystring끼리 더할 때 쓸 수 있는 + 연산자가 무엇인지 전혀 알지 못합니다. 그래서 이 코드를 실행하면 에러가 발생하게 되죠. 우리가 원하는 대로 코드가 작동하게 만들려면, 컴파일러에게 Mystring 두 개를 더할 때 + 연산자가 어떻게 행동해야 하는지 알려주는 '오버로딩된 함수'를 우리가 직접 만들어 주어야 합니다. 다음 레슨에서 그 방법을 자세히 알아보겠습니다.
연산자가 포함된 코드를 해석할 때, 컴파일러는 다음과 같은 규칙을 따릅니다.
int나 double 같은 C++ 기본 데이터 타입이라면, 컴파일러는 원래 가지고 있는 내장 기능을 사용합니다. 만약 내장된 기능이 없다면 에러를 발생시킵니다.enum 타입)이라면, 컴파일러는 '함수 오버로딩 해결 알고리즘'을 사용해서 딱 맞는 오버로딩된 연산자가 있는지 찾아봅니다. 이 과정에서 연산자의 모양에 맞추기 위해 값의 타입을 컴파일러가 알아서(암시적으로) 변환할 수도 있습니다. 만약 내장된 연산자에 맞추기 위해 우리가 만든 타입을 기본 타입으로 자동 변환하는 과정이 일어날 수도 있습니다(이 부분은 나중에 다루겠습니다). 만약 딱 맞는 것을 찾지 못하거나, 어떤 것을 써야 할지 너무 애매하다면 컴파일러는 에러를 뿜어냅니다.첫째, C++에 있는 거의 모든 연산자를 마음대로 오버로딩할 수 있습니다. 하지만 예외가 몇 가지 있습니다. 조건부 연산자(?:), 크기 확인(sizeof), 범위 지정(::), 멤버 선택(.), 포인터 멤버 선택(.*), 타입 확인(typeid), 그리고 캐스팅(형변환) 연산자들은 오버로딩을 할 수 없습니다.
둘째, 이미 존재하는 연산자만 오버로딩할 수 있습니다. 새로운 기호를 만들어내거나 기존 기호의 이름을 바꿀 수는 없습니다. 예를 들어, 거듭제곱 계산을 하겠다고 **라는 완전히 새로운 연산자를 창조해 낼 수는 없습니다.
셋째, 오버로딩하려는 연산자의 양쪽 값 중 최소한 하나는 사용자가 직접 만든 타입이어야 합니다. 즉, operator+(int, Mystring)처럼 정수와 우리가 만든 문자열 클래스를 더하는 기능은 만들 수 있지만, operator+(int, double)처럼 C++ 기본 타입들끼리의 계산법을 마음대로 뜯어고칠 수는 없습니다.
참고로 C++ 표준 라이브러리(예: std::string)도 사용자가 만든 타입으로 간주됩니다. 그래서 이론적으로는 operator+(double, std::string) 같은 오버로딩도 가능은 합니다. 하지만 훗날 C++ 언어가 업데이트되면서 이런 연산자를 공식적으로 지원하게 되면 우리 프로그램이 망가질 수 있기 때문에, 이렇게 하는 것은 좋은 생각이 아닙니다.
권장 사항
오버로딩된 연산자는 최소한 하나 이상의 프로그램 정의 타입(우리가 직접 만든 타입)과 함께 사용되어야 합니다. 그래야 미래에 C++ 표준이 업데이트되더라도 프로그램에 문제가 생기지 않습니다.
넷째, 연산자가 원래 가지고 있는 피연산자(계산에 필요한 값)의 개수를 바꿀 수 없습니다.
(예를 들어, 값 두 개가 필요한 + 연산자를 값 하나만 받도록 바꿀 수 없습니다.)
마지막으로 아주 중요한 점입니다. 모든 연산자는 원래 가지고 있던 계산 우선순위와 결합 방향을 그대로 유지하며, 이는 절대 바꿀 수 없습니다.
초보 프로그래머들은 종종 비트 기호인 ^를 '거듭제곱' 연산자로 오버로딩하려고 시도합니다. 하지만 C++에서 ^ 연산자는 +나 - 같은 기본 사칙연산보다 계산 우선순위가 훨씬 낮기 때문에 문제가 발생합니다.
일반적인 수학에서는 거듭제곱을 사칙연산보다 먼저 계산하므로, 4 + 3 ^ 2는 4 + (3의 2제곱)이 되어 4 + 9 => 13이 됩니다.
하지만 C++에서는 + 연산자가 ^ 연산자보다 먼저 계산됩니다! 따라서 C++에서 4 + 3 ^ 2라고 쓰면 (4 + 3)이 먼저 계산되어 7 ^ 2가 되고, 결과는 어이없게도 49가 되어버립니다.
이걸 제대로 작동하게 하려면 매번 4 + (3 ^ 2)처럼 명시적으로 괄호를 쳐야 하는데, 이는 직관적이지도 않고 실수하기도 딱 좋습니다.
이러한 우선순위 문제 때문에, 연산자는 원래 의도된 쓰임새와 가장 비슷한 방식으로만 오버로딩하는 것이 좋습니다.
권장 사항
연산자를 오버로딩할 때는, 해당 연산자의 원래 의도나 기능과 최대한 가깝게 만드는 것이 제일 좋습니다.
게다가 연산자들은 이름이 기호로만 되어 있어서 무슨 역할을 하는지 한눈에 알기 어려울 때가 많습니다. 예를 들어, 문자열 클래스에서 + 연산자를 '두 문자열 하나로 합치기'로 쓰는 것은 아주 자연스럽습니다. 하지만 - 연산자는 어떨까요? 문자열에서 - 연산이 무슨 의미일지 딱 떠오르지 않습니다. 이럴 때는 코드를 읽는 사람이 헷갈리기 쉽습니다.
권장 사항
오버로딩하려는 연산자의 의미가 직관적이고 명확하지 않다면, 무리해서 기호를 쓰지 말고 그냥 이해하기 쉬운 단어로 된 일반 함수를 새로 만드세요.
마지막으로, 오버로딩된 연산자는 원래 연산자와 일관된 방식으로 값을 돌려주어야(반환해야) 합니다. +나 -처럼 원래의 값을 바꾸지 않는 연산자들은 계산된 '새로운 결과값' 자체를 반환해야 합니다. 반면에 원래의 값을 변경하는 연산자(예: ++ 증가 연산자나 대입 연산자)는 변경된 대상(왼쪽 값)을 '참조(reference)' 형태로 반환하는 것이 일반적입니다.
권장 사항
원본 값을 변경하지 않는 연산자(예: 사칙연산)는 일반적으로 결과를 값(value)으로 반환해야 합니다.
왼쪽 원본 값을 변경하는 연산자(예: 전위 증감 연산자, 대입 연산자)는 일반적으로 왼쪽 원본 값을 참조(reference)로 반환해야 합니다.
이런 몇 가지 규칙들만 잘 지킨다면, 여러분이 만든 클래스에 정말 다양하고 유용한 기능들을 마음껏 추가할 수 있습니다! 문자열 클래스에 +를 써서 글자들을 덧붙이거나, 분수 클래스 두 개를 더할 수도 있죠. << 연산자를 오버로딩하면 우리가 만든 객체의 정보를 화면이나 파일에 아주 쉽게 출력할 수도 있습니다. == 연산자로 두 객체가 똑같은지 비교할 수도 있고요. 이처럼 연산자 오버로딩은 우리가 만든 클래스를 마치 C++의 기본 기능처럼 자연스럽고 편리하게 쓸 수 있게 해주기 때문에, C++에서 가장 강력하고 유용한 기능 중 하나로 꼽힙니다.
이어지는 다음 레슨들에서는 다양한 종류의 연산자들을 실제로 어떻게 오버로딩하는지 조금 더 깊이 파헤쳐 보겠습니다.
C++에서 가장 자주 쓰이는 연산자 중 하나가 바로 산술 연산자입니다. 더하기(+), 빼기(-), 곱하기(*), 나누기(/)가 여기에 속하죠. 이 산술 연산자들은 모두 이항 연산자(binary operator)라는 공통점이 있습니다. 이항 연산자란 연산자를 기준으로 양쪽에 하나씩, 총 두 개의 값(피연산자)이 필요하다는 뜻이에요. 이 네 가지 연산자는 모두 완전히 똑같은 방식으로 오버로딩(재정의)할 수 있답니다.
연산자를 오버로딩하는 방법에는 크게 세 가지가 있습니다. '멤버 함수'를 쓰는 방법, '프렌드(friend) 함수'를 쓰는 방법, 그리고 '일반 함수'를 쓰는 방법이죠.
이번 레슨에서는 프렌드 함수를 사용하는 방법을 먼저 배워볼게요.
(이항 연산자에서는 이 방법이 제일 직관적이고 이해하기 쉽거든요!)
다음 레슨에서는 일반 함수를 쓰는 법을 알아보고, 이번 챕터 후반부에서는 멤버 함수를 쓰는 법을 다룰 예정입니다. 물론 각각의 방법을 언제 써야 하는지도 나중에 자세히 정리해 드릴게요.
먼저 아래의 클래스를 한번 살펴볼까요?
class Cents
{
private:
int m_cents {};
public:
Cents(int cents) : m_cents{ cents } { }
int getCents() const { return m_cents; }
};
다음 예제는 두 개의 Cents 객체를 서로 더하기 위해 더하기(+) 연산자를 어떻게 오버로딩하는지 보여줍니다.
#include <iostream>
class Cents
{
private:
int m_cents {};
public:
Cents(int cents) : m_cents{ cents } { }
// 프렌드 함수를 사용하여 Cents + Cents 더하기
friend Cents operator+(const Cents& c1, const Cents& c2);
int getCents() const { return m_cents; }
};
// 참고: 이 함수는 멤버 함수가 아닙니다!
Cents operator+(const Cents& c1, const Cents& c2)
{
// Cents 생성자와 기본 연산자 +(int, int)를 사용합니다
// 프렌드 함수이기 때문에 m_cents에 직접 접근할 수 있습니다
return c1.m_cents + c2.m_cents;
}
int main()
{
Cents cents1{ 6 };
Cents cents2{ 8 };
Cents centsSum{ cents1 + cents2 };
std::cout << "I have " << centsSum.getCents() << " cents.\n";
return 0;
}
이 코드를 실행하면 다음과 같은 결과가 나옵니다.
I have 14 cents.
더하기(+) 연산자를 오버로딩하는 건 생각보다 아주 간단해요!
operator+라는 이름의 함수를 만들고, 우리가 더하고 싶은 두 개의 값을 매개변수로 넣어준 뒤, 적절한 반환 타입(결과값의 형태)을 정해서 함수 내용을 작성하기만 하면 끝이랍니다.
우리가 만든 Cents 객체의 경우, operator+() 함수를 만드는 과정은 정말 쉽습니다.
첫째, 매개변수 타입입니다. 이번에 만들 operator+는 두 개의 Cents 객체를 더할 것이기 때문에, 함수도 두 개의 Cents 타입 객체를 받아야 합니다.
둘째, 반환 타입입니다. operator+의 계산 결과 역시 새로운 Cents 객체가 되어야 하므로, 반환 타입도 Cents로 정해줍니다.
마지막으로 함수 구현입니다. 두 개의 Cents 객체를 더한다는 건, 결국 각 객체 안에 들어있는 m_cents라는 숫자(멤버 변수)끼리 더한다는 뜻이에요. 우리가 만든 오버로딩 함수는 클래스의 '프렌드(friend)'로 등록되어 있기 때문에, 객체 안의 숨겨진(private) m_cents 변수에 직접 접근할 수 있습니다. 그리고 m_cents는 단순한 정수형(int)이기 때문에, C++이 기본적으로 제공하는 덧셈 기능을 써서 그냥 + 기호로 더해주면 됩니다.
빼기(-) 연산자를 오버로딩하는 것도 똑같이 쉽습니다!
#include <iostream>
class Cents
{
private:
int m_cents {};
public:
explicit Cents(int cents) : m_cents{ cents } { }
// 프렌드 함수를 사용하여 Cents + Cents 더하기
friend Cents operator+(const Cents& c1, const Cents& c2);
// 프렌드 함수를 사용하여 Cents - Cents 빼기
friend Cents operator-(const Cents& c1, const Cents& c2);
int getCents() const { return m_cents; }
};
// 참고: 이 함수는 멤버 함수가 아닙니다!
Cents operator+(const Cents& c1, const Cents& c2)
{
// Cents 생성자와 기본 연산자 +(int, int)를 사용합니다
// 프렌드 함수이기 때문에 m_cents에 직접 접근할 수 있습니다
return Cents { c1.m_cents + c2.m_cents };
}
// 참고: 이 함수는 멤버 함수가 아닙니다!
Cents operator-(const Cents& c1, const Cents& c2)
{
// Cents 생성자와 기본 연산자 -(int, int)를 사용합니다
// 프렌드 함수이기 때문에 m_cents에 직접 접근할 수 있습니다
return Cents { c1.m_cents - c2.m_cents };
}
int main()
{
Cents cents1{ 6 };
Cents cents2{ 2 };
Cents centsSum{ cents1 - cents2 };
std::cout << "I have " << centsSum.getCents() << " cents.\n";
return 0;
}
곱하기(*)나 나누기(/) 연산자 역시, 각각 operator*와 operator/라는 이름으로 함수를 정의해주기만 하면 똑같이 쉽게 오버로딩할 수 있습니다.
프렌드 함수는 클래스의 멤버 함수가 아니지만, 원한다면 클래스 코드 블록 안쪽에 직접 정의(작성)할 수도 있습니다.
#include <iostream>
class Cents
{
private:
int m_cents {};
public:
explicit Cents(int cents) : m_cents{ cents } { }
// 프렌드 함수를 사용하여 Cents + Cents 더하기
// 정의가 클래스 내부에 있더라도, 이 함수는 클래스의 멤버로 간주되지 않습니다.
friend Cents operator+(const Cents& c1, const Cents& c2)
{
// Cents 생성자와 기본 연산자 +(int, int)를 사용합니다
// 프렌드 함수이기 때문에 m_cents에 직접 접근할 수 있습니다
return Cents { c1.m_cents + c2.m_cents };
}
int getCents() const { return m_cents; }
};
int main()
{
Cents cents1{ 6 };
Cents cents2{ 8 };
Cents centsSum{ cents1 + cents2 };
std::cout << "I have " << centsSum.getCents() << " cents.\n";
return 0;
}
구현 내용이 아주 짧고 단순한 오버로딩 연산자라면 이렇게 클래스 안에 바로 작성하는 것도 괜찮은 방법입니다.
연산자를 만들다 보면, 서로 다른 타입의 값을 더하거나 빼고 싶을 때가 자주 생깁니다.
예를 들어, Cents(4)라는 객체에 숫자 6(정수형)을 더해서 Cents(10)이라는 결과를 만들고 싶은 경우처럼 말이죠.
C++이 x + y라는 식을 계산할 때, 앞의 x는 첫 번째 매개변수가 되고 뒤의 y는 두 번째 매개변수가 됩니다. 만약 x와 y가 같은 타입이라면 x + y를 하든 y + x를 하든 상관이 없습니다. 둘 다 똑같은 버전의 operator+ 함수를 불러오니까요. 하지만 서로 다른 타입이라면 이야기가 달라집니다. x + y를 할 때와 y + x를 할 때 호출되는 함수가 서로 다릅니다.
예를 들어, Cents(4) + 6은 operator+(Cents, int) 함수를 호출하지만, 반대로 6 + Cents(4)는 operator+(int, Cents) 함수를 호출합니다. 결과적으로 서로 다른 타입의 값을 더하는 이항 연산자를 만들고 싶다면, 순서가 바뀌는 두 가지 경우를 위해 함수를 2개 작성해야 합니다. 아래 예제를 확인해 보세요!
#include <iostream>
class Cents
{
private:
int m_cents {};
public:
explicit Cents(int cents) : m_cents{ cents } { }
// 프렌드 함수를 사용하여 Cents + int 더하기
friend Cents operator+(const Cents& c1, int value);
// 프렌드 함수를 사용하여 int + Cents 더하기
friend Cents operator+(int value, const Cents& c1);
int getCents() const { return m_cents; }
};
// 참고: 이 함수는 멤버 함수가 아닙니다!
Cents operator+(const Cents& c1, int value)
{
// Cents 생성자와 기본 연산자 +(int, int)를 사용합니다
// 프렌드 함수이기 때문에 m_cents에 직접 접근할 수 있습니다
return Cents { c1.m_cents + value };
}
// 참고: 이 함수는 멤버 함수가 아닙니다!
Cents operator+(int value, const Cents& c1)
{
// Cents 생성자와 기본 연산자 +(int, int)를 사용합니다
// 프렌드 함수이기 때문에 m_cents에 직접 접근할 수 있습니다
return Cents { c1.m_cents + value };
}
int main()
{
Cents c1{ Cents{ 4 } + 6 };
Cents c2{ 6 + Cents{ 4 } };
std::cout << "I have " << c1.getCents() << " cents.\n";
std::cout << "I have " << c2.getCents() << " cents.\n";
return 0;
}
두 오버로딩 함수가 똑같은 내용으로 작성된 것을 확인하셨나요?
사실 똑같은 덧셈을 하는 건데, 단지 받는 매개변수의 순서만 다를 뿐이기 때문입니다.
다른 예제를 하나 더 살펴봅시다.
#include <iostream>
class MinMax
{
private:
int m_min {}; // 지금까지 확인된 최솟값
int m_max {}; // 지금까지 확인된 최댓값
public:
MinMax(int min, int max)
: m_min { min }, m_max { max }
{ }
int getMin() const { return m_min; }
int getMax() const { return m_max; }
friend MinMax operator+(const MinMax& m1, const MinMax& m2);
friend MinMax operator+(const MinMax& m, int value);
friend MinMax operator+(int value, const MinMax& m);
};
MinMax operator+(const MinMax& m1, const MinMax& m2)
{
// m1과 m2 중 더 작은 값 가져오기
int min{ m1.m_min < m2.m_min ? m1.m_min : m2.m_min };
// m1과 m2 중 더 큰 값 가져오기
int max{ m1.m_max > m2.m_max ? m1.m_max : m2.m_max };
return MinMax { min, max };
}
MinMax operator+(const MinMax& m, int value)
{
// m과 value 중 더 작은 값 가져오기
int min{ m.m_min < value ? m.m_min : value };
// m과 value 중 더 큰 값 가져오기
int max{ m.m_max > value ? m.m_max : value };
return MinMax { min, max };
}
MinMax operator+(int value, const MinMax& m)
{
// operator+(MinMax, int)를 호출합니다
return m + value;
}
int main()
{
MinMax m1{ 10, 15 };
MinMax m2{ 8, 11 };
MinMax m3{ 3, 12 };
MinMax mFinal{ m1 + m2 + 5 + 8 + m3 + 16 };
std::cout << "Result: (" << mFinal.getMin() << ", " <<
mFinal.getMax() << ")\n";
return 0;
}
MinMax 클래스는 지금까지 입력된 값들 중에서 최솟값과 최댓값을 기억하는 역할을 합니다. 여기서는 + 연산자를 무려 3번이나 오버로딩했네요! 이렇게 하면 두 개의 MinMax 객체끼리 더할 수도 있고, MinMax 객체에 일반 정수(int)를 더할 수도 있게 됩니다.
이 예제를 실행하면 다음과 같은 결과가 나옵니다.
Result: (3, 16)
이 결과는 우리가 mFinal을 만들기 위해 더했던 값들 중에서 가장 작은 값(3)과 가장 큰 값(16)이라는 것을 알 수 있죠!
자, 그럼 MinMax mFinal { m1 + m2 + 5 + 8 + m3 + 16 }라는 코드가 어떻게 계산되는지 조금만 더 자세히 알아볼까요? + 연산자는 왼쪽에서 오른쪽으로 차례대로 계산된다는 사실을 기억해 주세요.
m1 + m2가 계산됩니다. 이는 operator+(m1, m2)를 호출하게 되어, MinMax(8, 15)라는 결과값을 만듭니다.MinMax(8, 15) + 5가 계산됩니다. 이는 operator+(MinMax(8, 15), 5)를 호출하여 MinMax(5, 15)가 됩니다.MinMax(5, 15) + 8을 하면, 똑같은 방식으로 MinMax(5, 15)가 반환됩니다.MinMax(5, 15) + m3을 더하면 MinMax(3, 15)가 됩니다.MinMax(3, 15) + 16을 계산해서 최종적으로 MinMax(3, 16)이 완성됩니다.이 최종 결과값이 바로 mFinal 객체를 처음 만들(초기화) 때 사용되는 거예요.
다시 말해, 이 코드는 MinMax mFinal = (((((m1 + m2) + 5) + 8) + m3) + 16) 와 같이 하나씩 순차적으로 계산됩니다. 매번 덧셈을 할 때마다 MinMax 객체가 반환되고, 이 객체가 다음 연산자의 왼쪽 피연산자 역할을 하면서 사슬처럼 이어지는 방식입니다.
위 예제 코드 중에서 operator+(int, MinMax) 함수를 어떻게 만들었는지 다시 한번 주목해 보세요. 안에서 직접 계산하지 않고, 이미 만들어둔 operator+(MinMax, int)를 불러와서 사용했습니다. (어차피 순서만 다르고 결과는 똑같으니까요!)
이렇게 하면 operator+(int, MinMax)의 내용을 단 한 줄로 줄일 수 있습니다. 중복되는 똑같은 코드를 여러 번 작성할 필요가 없으니 코드 관리가 훨씬 쉬워지고, 함수를 읽고 이해하기도 편해집니다.
이처럼 이미 만들어진 다른 오버로딩 연산자를 호출해서 새로운 연산자를 정의하는 경우가 많습니다. 코드를 더 깔끔하고 단순하게 만들 수 있다면 이 방법을 적극적으로 사용하는 것이 좋습니다. (물론 계산 내용이 단 한 줄짜리처럼 너무 간단하다면, 굳이 다른 함수를 부르지 않고 직접 작성해도 큰 상관은 없습니다.)