들어가며
C++은 사용자 정의 타입의 연산을 정의하는 연산자의 동작을 재정의 할 수 있는 연산자 오버로딩(operator overloading)을 제공한다. 이번 포스트는 연산자 오버로딩에 대해 자세히 살펴 보도록한다.
연산자 자체에 대한 보다 자세한 사항은 [여기]를 살펴 보도록 한다.
연산자 오버로딩을 설명하기 위해 아래 Point 클래스를 살펴 보자. Point 클래스는 int x, y를 멤버로 가지고, display 함수가 호출되면 x와 y 값을 출력 해주는 간단한 클래스다.
class Point
{
int x;
int y;
public:
Point(int x, int y) : x(x), y(y) {}
void display() const
{
std::cout << "Point(" << x << ", " << y << ")" << std::endl;
}
};
Point 클래스의 인스턴스는 아래 처럼 간단히 선언할 수 있다.
Point p1(1, 1);
Point p2(2, 2);
위 코드는 아무런 문제 없이 컴파일 된다. 이제 p1과 p2의 값을 더한 p3라는 인스턴스를 만들어 보자.
Point p3 = p1 + p2;
p1과 p2를 더한다는 의미로 위와 같이 두 객체에 대해 '+' 연산자를 적용 했다. 당연하지만 위 코드는 컴파일 에러를 발생 시킨다.
C++ 컴파일러는 int와 같은 기본 타입의 + 연산은 어떻게 동작해야 하는지 알고 있지만, Point 클래스 처럼 사용자 정의 타입의 + 연산자는 어떻게 동작해야 하는지 정의 되어 있지 않기 때문에 컴파일 에러를 발생 시킨다.
C++은 이런 사용자 정의 타입의 연산자에 대해 컴파일러가 이해할 수 있도록 연산자를 재정의 할 수 있는 기능을 제공하고 있고 이것을 연산자 오버로딩이라고 한다.
연산자 오버로딩 구현
방금 C++은 클래스와 구조체 같은 사용자 정의 타입에 대해 연산자를 재정의 할 수 있다고 말했다. 재정의 방법은 연산자의 동작을 정의하는 함수를 클래스 멤버 함수 또는 전역 함수로 추가하면 된다.
이번 섹션에서는 연산자 오버로딩을 구현하는 두 방식의 구현 방법과 차이에 대해 살펴 보도록 하겠다.
전역 함수
아래와 같이 클래스 외부에 Point operator + (const Point& p1, const Point& p2) 라는 전역 연산자 오버로딩 함수를 작성했다.
class Point
{
int x;
int y;
public:
Point(int x, int y) : x(x), y(y) {}
void display() const
{
std::cout << "Point(" << x << ", " << y << ")" << std::endl;
}
};
Point operator+(const Point& p1, const Point& p2)
{
return Point(p1.x + p2.x, p1.y + p2.y);
}
Point operator + (const Point& p1, const Point& p2)는 Point 타입에 대한 + 연산이 호출 되는 경우, 이 함수를 적용해 달라고 컴파일러에게 알려주는 것이다. 이제 p1 + p2를 호출하게 되면 컴파일러가 똑똑하게 위의 operator + 함수를 호출 해줄 것이다...는 개뿔...위 코드는 여전히 컴파일 에러를 발생 시킨다.
Point 클래스의 x, y 멤버 변수는 private 접근 권한을 가지고 있기 때문에 전역 함수에서 x와 y에 접근할 수 없다는 에러를 발생 시킨다. 해결 방법으로는 :
- 두 변수를 public으로 접근 권한 변경
- public으로 선언된 get/set 멤버 함수를 추가
정도를 생각해 볼 수 있지만 모두 아름다운 형태는 아니다.
그래서 일반적으로 전역 함수를 이용해 오퍼레이터 오버로딩을 구현하면 private 멤버에도 접근할 수 있도록 friend 속성을 걸어 주는것이 관용적인 사용법이다.
class Point
{
int x;
int y;
public:
Point(int x, int y) : x(x), y(y) {}
void display() const
{
std::cout << "Point(" << x << ", " << y << ")" << std::endl;
}
friend Point operator+(const Point& p1, const Point& p2);
};
Point operator+(const Point& p1, const Point& p2)
{
return Point(p1.x + p2.x, p1.y + p2.y);
}
int main()
{
Point p1(1, 1);
Point p2(2, 2);
Point p3 = p1 + p2;
p3.display();
return 0;
}
멤버 함수
이번 섹션에서는 멤버 함수를 이용해 연산자 오버로딩을 구현해 보고 전역 함수와는 무슨 차이가 있는지 살펴 보도록 하겠다.
class Point
{
int x;
int y;
public:
Point(int x, int y) : x(x), y(y) {}
void display() const
{
std::cout << "Point(" << x << ", " << y << ")" << std::endl;
}
Point operator + (const Point& other) const
{
return Point(x + other.x, y + other.y);
}
};
위 코드에서 가장 먼저 눈에 띄는 것은 오퍼레이터 오버로딩 함수의 인자 갯수다. + 연산자는 이항 연산이기 때문에 전역 함수 버전에서는 두 개의 인자를 받았지만 멤버 함수는 하나의 인자만 받고 있다. 이는 전역 함수와 멤버 함수의 오버로딩 된 함수 호출 방식의 차이 때문이다.
아래는 사용자가 보기에는 동일한 코드지만 컴파일러는 다르게 분석 한다.
Point p3 = p1 + p2;
// 전역 함수인 경우 컴파일러가 이해하는 코드
Point p3 = operator + (p1, p2);
// 멤버 함수인 경우 컴파일러가 이해하는 코드
Point p3 = p1.operator + (p2);
멤버 함수로 오퍼레이터 오버로딩을 구현하는 경우, 자기 자신이 연산에 포함되므로 오퍼레이터 함수 인자는 연산에 필요한 항 갯수 보다 하나 더 적게 필요하다.
만일 이 둘을 동시에 만든다면 어떻게 될까? 일단 멤버 함수와 전역 함수 둘 다 구현한다고 해도 컴파일 오류 같은 현상은 발생하지 않는다. 다만 멤버 함수가 높은 우선 순위를 가지고 있기 때문에 컴파일러는 멤버 함수 버전의 오퍼레이터 오버로딩 함수를 먼저 호출한다.
단, operator + (p1, p2) 와 같이 전역 오퍼레이터 함수를 명시적으로 호출하면 우선 순위와 관계 없이 호출 가능하다. 반대로 p1.operator + (p2)와 같이 멤버 함수 버전 오퍼레이터 함수도 명시적으로 호출하는 것 또한 가능하다.
주의 사항
아래는 오퍼레이터 오버로딩 시 주의 사항들을 나열 했다.
- 오퍼레이터 오버로딩 함수의 모든 인자가 기본(primitive) 타입인 경우 오버로딩 불가능
- 아래 연산자들은 오버로딩 불가능 :
- ., *, ::, ?:, sizeof, typeid, static_cast, dynamic_cast, reinterpret_cast, const_cast
단 . 는 C++20 부터 오버로딩 가능
- ., *, ::, ?:, sizeof, typeid, static_cast, dynamic_cast, reinterpret_cast, const_cast
- 다음 연산자들은 멤버 함수로만 오버로딩 가능 : = () [] ->
- 새로운 연산자는 만들 수없다. 예를 들어 [)와 같이 C++문법에 정의되지 않은 연산자는 재정의 불가능
- 연산자 우선 순위를 변경할 수 없다. 사칙 연산에서 +, - 가 *, / 보다 높은 순서를 가질 수 없다.
- 연산자의 항 갯수를 변경할 수 없다. 이항 연산인 +를 단항 연산으로 변경할 수 없다.
- 연산자 오버로딩 함수는 디폴트 인자를 지정할 수 없다.
마치며
...