개요
연산자 오버로딩이란, 기존의 연산자의 오버로딩을 통하여, 기존에 존재하던 연산자의 기본 기능 이외에 다른 기능을 추가할 수 있게끔 하는 문법적 요소이다.
연산자 오버로딩에 사용되는 함수명은 ‘operation’ + ‘(연산자)’ 의 형태를 띈다.
연산자 오버로딩은 기본적으로 아래와 같이 사용된다.
#include <iostream>
using namespace std;
class Point
{
private :
int xpos, ypos;
public :
Point(int x = 0, int y = 0) : xpos(x), ypos(y) {}
void ShowPosition() const
{
cout<<'['<<xpos<<", "<<ypos<<']'<<endl;
}
Point operator+(const Point &ref)
{
Point pos(xpos + ref.xpos, ypos + ref.ypos);
return pos;
}
};
/*
int main(void)
{
Point pos1(3, 4);
Point pos2(10, 20);
//연산자 오버로딩을 사용하지 않으면 보통 이와 같이 코드를 작성한다.
Point pos3 = pos1.operator+(pos2);
pos1.ShowPosition();
pos2.ShowPosition();
pos3.ShowPosition();
return (0);
}
*/
int main(void)
{
Point pos1(3, 4);
Point pos2(10, 20);
//연산자 오버로딩을 사용하여 아래와 같이 코드를 축약할 수 있다.
Point pos3 = pos1 + pos2;
pos1.ShowPosition();
pos2.ShowPosition();
pos3.ShowPosition();
return (0);
}
다음은 위 예제에서 연산자 오버로딩이 적용된 문장이다.
pos1 + pos2;
이 문장은 아래와 같이 분해할 수 있다.
pos1
+
pos2
연산자 오버로딩은 위 문장의 각 구성요소를 다음과 같이 변환한다.
pos
.operator+
(pos2)
pos.operator+(pos2);
전역함수에 의한 연산자 오버로딩
연산자를 오버로딩 하는 방법에는 다음 두 가지가 있다.
- 멤버함수에 의한 연산자 오버로딩
- 전역함수에 의한 연산자 오버로딩
개요에서 소개한 방법은 멤버함수에 의한 연산자 오버로딩이다.
전역함수를 이용해서 오버로딩을 하면 pos1 + pos2는 다음과 같이 해석이 된다.
operator+(pos1, pos2);
아래 예제를 참조하자.
#include <iostream>
using namespace std;
class Point
{
private :
int xpos, ypos;
public :
Point(int x = 0, int y = 0) : xpos(x), ypos(y) {}
void ShowPosition() const
{
cout<<'['<<xpos<<", "<<ypos<<']'<<endl;
}
/*전역 함수에서 Point 객체의 멤버를 참조할 수 있도록 한다.*/
friend Point operator+(const Point &pos1, const Point &pos2);
};
//연산자 오버로딩 전역 함수 버전
Point operator+(const Point &pos1, const Point &pos2)
{
Point pos(pos1.xpos + pos2.xpos, pos1.ypos + pos2.ypos);
return pos;
}
int main(void)
{
Point pos1(3, 4);
Point pos2(10, 20);
Point pos3 = pos1 + pos2;
pos1.ShowPosition();
pos2.ShowPosition();
pos3.ShowPosition();
return (0);
}
위 예제와 같이, 연산 대상이 private 멤버 변수인 경우, friend 선언을 통하여 전역 변수에게 멤버의 참조를 허용하여야 한다.
전역 함수를 이용한 연산자 오버로딩은 후술할 교환 법칙이 성립하는 연산자 오버로딩의 구현에 유용하게 사용된다.
오버로딩이 불가능한 연산자의 종류
| . | 멤버 접근 연산자 |
|---|---|
| .* | 멤버 포인터 연산자 |
| :: | 범위 지정 연산자 |
| ?: | 조건 연산자(3항 연산자) |
| sizeof | 바이트 단위 크기 계산 |
| typeid | RTTI 관련 연산자 |
| static_cast | 형변환 연산자 |
| dynamic_cast | 형변환 연산자 |
| const_cast | 형변환 연산자 |
| reinterpret_cast | 형변환 연산자 |
이들 연산자에 대해서 오버로딩을 제한하는 이유는 C++의 문법 규칙을 보존하기 위해서다.
만약 이들 연산자들까지 오버로딩을 허용해 버린다면, C++ 문법 규칙에 어긋나는 문장의 구성이 가능해진다.
멤버함수 기반으로만 오버로딩이 가능한 연산자
| = | 대입 연산자 |
|---|---|
| () | 함수 호출 연산자 |
| [] | 배열 접근 연산자(인덱스 연산자) |
| → | 멤버 접근을 위한 포인터 연산자 |
이들은 객체를 대상으로 진행해야 의미가 통하는 연산자들이기 때문에, 멤버함수 기반으로만 연산자의 오버로딩을 허용한다.
연산자 오버로딩에 있어서의 주의사항
- 연산자의 본래 정의를 벗어난 형태의 오버로딩은 좋지 않다.
- 연산자의 우선 순위와 결합성은 바뀌지 않는다.
- 매개변수의 디폴트 값 설정이 불가능하다.
- 클래스가 아닌 일반 자료형 간의 연산을 재정의 할 수는 없다.
- 부연 : 예를 들어 ‘int 형 변수 간의 덧셈’과 같은 동작을 재정의할 수는 없다는 말이다.
단항 연산자의 오버로딩
피연산자가 두 개인 이항 연산자와 피연산자가 한 개인 단항 연산자의 가장 큰 차이점은 피연산자의 개수이다. 그리고 이에 따른 연산자 오버로딩의 차이점은 매개변수의 개수에서 발견된다.
대표적인 단항 연산자로는 다음의 두 가지가 있다.
++--
단항 연산자는 연산 결과가 피연산자 객체 스스로에 적용되기 때문에 함수에 인자를 전달할 필요가 없다.
따라서 멤버함수의 경우, 다음의 형태를 띈다.
//전위 연산을 담당하는 함수의 형태
Obj &operation++() { /* . . . */ }
위의 연산자들이 피연산자의 앞에 위치하면 전위 연산자, 뒤에 위치하면 후위 연산자가 된다.
둘을 구분하기 위하여, 후위 연산을 담당하는 함수에게만 키워드 int를 표시한다. int 키워드는 오로지 전위와 후위를 구분하기 위해 존재하며, int 형 인자를 전달하는 것이 아니다.
//후위 연산을 담당하는 함수의 형태
Obj &operation++( int ) { /* . . . */ } //키워드 int를 사용하여 전위와 후위를 구분한다.
마찬가지로 전역함수로 단항 연산을 구현할 경우, 이항 연산과 달리 피연산자를 하나만 전달하면 된다.
//전위 연산을 담당하는 함수의 형태
Obj &operation++( Obj &a) { /* . . . */ }
//후위 연산을 담당하는 함수의 형태
Obj &operation++( Obj &a, int ) { /* . . . */ } //키워드 int를 사용하여 전위와 후위를 구분한다.
연산자 오버로딩 - 참조자 반환을 통한 연속적인 연산의 구현
위 절의 예제에서 operation++()은 객체의 참조값을 반환한다. 사실 객체의 멤버 변수를 1 더하는 동작만을 정의한다면 다음과 같이 반환형을 void로 하여도 상관없다.
void operation++()
{
//var1과 var2는 객체의 멤버변수
var1 += 1;
var2 += 1;
}
그러나 이러한 형식으로 함수를 정의하면 다음과 같은 형태의 연산이 불가능하다.
++(++obj) //obj에 ++연산을 연속적으로 2번 적용하고자 함- operation++은 void를 반환하므로 위 연산은 다음과 동일하다.
++(void) //error
따라서 위와 같은 연산이 가능하게 하기 위해서는 반환형을 객체의 참조자로 하는 것이 좋다.
Obj &operation++()
{
var1 += 1;
var2 += 1;
return (*this); //객체 스스로의 참조값을 반환
}
++(++obj)를 연산하고자 함.operation++은 객체의 참조값을 반환하므로 위 연산은 다음과 동일하다.++(obj.operation++())++(obj 객체의 참조값)(obj 객체의 참조값).operation++()
연산자 오버로딩 - const 객체 반환을 통한 연속적인 연산의 제한
C++ 언어에서는 다음과 같은 연속적인 후위 연산을 금지하고 있다.
(obj--)--;
따라서 다음과 같이 operation--()의 반환형을 const 객체로 선언하면 연속적인 후위 연산을 제한할 수 있다.
//전위 연산을 담당하는 함수의 형태
const Obj operation++( Obj &a) { /* . . . */ }
//후위 연산을 담당하는 함수의 형태
const Obj operation++( Obj &a, int ) { /* . . . */ } //키워드 int를 사용하여 전위와 후위를 구분한다.
(obj--)--;를 연산하고자 함.operation--는 const 임시 객체를 반환하므로 위 연산은 다음과 동일하다.(obj.operation--())--(Obj 클래스의 const 임시 객체)--(Obj 클래스의 const 임시 객체).operation--() // error : const 임시 객체는 const 멤버 함수가 아닌 operation--()를 호출할 수 없다.
자료형이 다른 두 피연산자를 대상으로 하는 연산, 연산자의 교환법칙 구현하기
곱셈과 같은 연산은 다음과 같이 피연산자 2개의 자료형이 서로 다를 수 있다.
class Point
{
private :
int xpos, ypos;
public :
Point operation*(int n)
{
xpos *= n;
ypos *= n;
return (*this);
}
}
int main(void)
{
Point pos(1, 2);
Point cpy;
cpy = pos * 3;
cpy = pos * 3 * 2;
return (0);
}
위 예제에서 cpy = pos * 3;은 cpy = 3 * pos;으로도 표현할 수 있어야 한다.
하지만 cpy = 3 * pos;은 다음과 같이 해석될 수는 없기 때문에 연산자 오버로딩이 불가하다.
3.operator*(pos);
따라서 * 연산자의 교환법칙을 구현하려면 전역 함수를 사용하여 다음과 같이 정의해야 한다.
class Point
{
private :
int xpos, ypos;
public :
Point operation*(int n)
{
xpos *= n;
ypos *= n;
return (*this);
}
friend Point operator*(int n, Point &pos);
}
Point operator*(int n, Point &pos)
{
return ref * n;
}
int main(void)
{
Point pos(1, 2);
Point cpy;
cpy = 3 * pos; //operator*(3, pos);로 해석됨.
cpy = 3 * pos * 2; //operator*(3, pos).operator*(3);으로 해석됨.
return (0);
}
대입 연산자
대입 연산자는 복사 생성자와 매우 유사한 성격을 가지고 있다.
복사 생성자의 특성
- 정의하지 않으면 디폴트 복사 생성자가 삽입된다.
- 디폴트 복사 생성자는 멤버 대 멤버의 복사(얕은 복사)를 진행한다.
- 생성자 내에서 동적 할당을 한다면, 그리고 깊은 복사가 필요하다면 직접 정의해야 한다.
대입 연산자의 특성
- 정의하지 않으면 디폴트 대입 연산자가 삽입된다.
- 디폴트 대입 연산자는 멤버 대 멤버의 복사(얕은 복사)를 진행한다.
- 연산자 내에서 동적 할당을 한다면, 그리고 깊은 복사가 필요하다면 직접 정의해야 한다.
참고로, 디폴트 대입 연산자는 다음과 같은 형식을 하고 있다.
Obj &operator=(const Obj obj)
{
objVal1 = obj.objVal1;
objVal2 = obj.objVal2;
return *this;
}
그러나 대입 연산자와 복사 생성자는 호출시점에 차이를 갖는다.
- 복사 생성자 : 새로 생성하는 객체의 초기화에 기존에 생성된 객체가 사용.
- ex)
Point pos = pos1;
- ex)
- 대입 연산자 : 기존의 생성된 객체 사이에 대입 연산이 사용되었을 경우.
- ex)
pos2 = pos1;
- ex)
또한 대입 연산자와 복사 생성자는 상속 구조에서의 호출에서 다음의 차이점을 갖는다.
- 복사 생성자 : 자식 클래스에서 아무런 명시를 하지 않아도 부모 클래스의 생성자가 호출.
- 대입 연산자
- 디폴트 대입 연산자 호출의 경우 : 디폴트 대입 연산자가 부모 클래스의 대입 연산자까지 호출한다.
- 대입 연산자를 직접 정의하는 경우 : 자식 클래스의 대입 연산자에서 명시가 없으면, 부모 클래스의 대입 연산자가 호출되지 않는다. 다음의 형태를 사용하여 부모 클래스의 대입 연산자를 호출할 수 있다.
Parent::operator=(ref); //부모 클래스명::operator=(매개인자);
c++ 언어의 대입연산( =객체 간의 대입 연산 )은 c 언어의 구조체 간의 대입과 유사해 보이지만, 대입 연산자를 오버로딩하였다는 점에서 근본적으로 차이점이 있다.
객체 내에서 멤버를 동적 할당하는 경우, 디폴트 대입 연산자 사용 시 다음의 두 가지 문제가 발생한다.
.png)
- 기존에 가리키던 heap 영역의 공간에 대한 참조를 잃는다.
- 얕은 복사로 인해서, 객체 소멸 과정에서 지워진 문자열을 중복 소멸하는 문제가 발생한다.
따라서 다음의 원칙을 준수하여 직접 대입 연산자를 정의하여야 한다.
- 깊은 복사를 진행하도록 정의한다.
- 메모리 누수가 발생하지 않도록, 깊은 복사에 앞서 메모리 해제의 과정을 거친다.
배열의 인덱스 연산자 오버로딩
앞서 언급했듯이, [] 연산자는 멤버함수 기반으로만 오버로딩 하도록 제한되어 있다.
[] 연산자 오버로딩 함수는 아래와 같은 형식을 가지고 있다.
int operator[](int idx) { . . . . } //int로 선언된 반환형은 임의로 결정한 것이다. 이는 반환하는 값의 자료형에 따라 달라진다.
/*아래 문장은 다음과 같이 해석된다*/
arrObject[2]; //arrObject는 객체
//arrObject.operator[](2);
[] 연산자 오버로딩은 오류 발생, 잘못된 접근 등을 개발자가 사전에 제약할 수 있다는 장점이 있다. (ex. out of index).
new, delete 연산자 오버로딩
new 연산자가 하는 일은 다음과 같다.
- 메모리 공간의 할당
- 생성자의 호출
- 할당하고자 하는 자료형에 맞게 반환된 주소 값의 형 변환 → malloc과 달리 반환하는 주소값을 형변환할 필요가 없음.
이 세 가지 동작 중에서 개발자는 1번에 해당하는 메모리 공간의 할당만 오버로딩 할 수 있다. 나머지 두 가지 작업은 C++ 컴파일러에 의해서 진행이 되며, 오버로딩할 수 있는 대상도 아니다.
다음과 같은 형식으로 오버로딩하도록 사전에 약속되어 있다.
/*아래의 기본적인 동작에 사용자가 원하는 동작을 추가적으로 정의한다.*/
void *operator new (size_t size)
{
void *adr = new char[size];
return (adr);
}
delete 연산자가 호출되면 다음과 같이 동작한다.
- 다음의 문장으로 객체의 소멸을 명령하면
delete obj; - 컴파일러는 먼저 obj가 가리키는 객체의 소멸자를 호출한다.
- 그 다음 다음의 형태로 정의된 함수에 ptr에 저장된 주소 값을 전달한다.
void operator delete (void *adr) { . . . . }
따라서 delete 함수는 최소한 다음의 동작을 포함해야 하며, 그 의외에 필요한 추가적인 내용을 개발자가 추가한다.
void operator delete (void *adr)
{
delete []adr;
}
참고로, 사용하는 컴파일러에서 void 포인터 형 대상의 delete 연산을 허용하지 않는다면, 위의 delete 문을 다음과 같이 작성하면 된다. 즉, char 포인터 형으로 변환해서 delete 연산을 진행하면 된다.
delete []((char *)adr);
new 연산자는 멤버함수의 형태로 오버로딩되지만, 아래와 같이 객체가 생성되기도 전에 호출할 수 있다.
Obj *ptr = new Obj(3, 4);
이는 operator new, operator delete 함수는 static 함수이기 때문이다. 멤버함수의 형태로 함수를 작성해도 두 함수는 static 함수로 간주되도록 약속이 되어있다.
new[], delete[] 연산자의 오버로딩
new[], delete[] 함수는 배열 할당, 해제 시 호출되는 함수라는 점만 제외하고 new, delete 함수과 동일하다.
void *operator new[] (size_t size) { . . . . }
void operator delete[] (void *adr) { . . . . }
포인터 연산자 오버로딩
포인터를 기반으로 하는 연산자에는 다음이 있다.
| 연산자 | 기능 |
|---|---|
| → | 포인터가 가리키는 객체의 멤버에 접근 |
| * | 포인터가 가리키는 객체에 접근 |
그리고 이 두 연산자의 오버로딩은 아래의 예제와 같이 이루어진다.
#include <iostream>
using namespace std;
class Number
{
priate :
int num;
public :
Number(int n) : num(n) { }
void ShowData() {cout<<num<<endl;}
/*객체 자신의 주소 값을 반환하도록 ->연산자를 오버로딩 하고 있다. -> 연산자를 다른 형태로 오버로딩 하는 것도 가능하지만, 이 연산자의 오버로딩을 허용하는 이유는 주소 값의 반환이 목적이기 따문에 다른 형태로는 오버로딩하지 않는 것이 좋다.*/
Number *operator->()
{
return this;
}
/*이 함수는 객체 자신을 참조의 형태로 반환하도록 *연산자를 오버로딩하고 있다.*/
Number &operator*()
{
return *this;
}
};
int main(void)
{
Number num(20);
num.ShowData();
/*객체 num이 포인터 변수인 것처럼 연산문이 구성되었다. 이는 *,-> 연산자의 오버로딩 결과이다.*/
(*num) = 30;
num->ShowData();
(*num).ShowData();
return (0);
}
포인터 연산자 오버로딩을 통해, 기존에 포인터 연산자의 기능에 사용자가 정의한 기능을 추가한 포인터 연산자를 만들 수 있다. 이러한 포인터는 객체의 형태로 구현되며, **스마트 포인터(Smart Pointer)**라고 한다.
() 연산자의 오버로딩과 펑터(Functor)
함수의 호출에 사용하는 () 또한 연산자이다. 때문에 이 역시 오버로딩이 가능하다. () 연산자 오버로딩의 목적은 객체를 함수처럼 사용하는 것이다.
() 연산자 오버로딩은 아래의 예제와 같이 이루어진다.
class Functor
{
public :
//생략
void operator()(int a, int b) { /*정의된 동작*/ }
}
int main()
{
Functor obj;
obj(2, 4); //obj.operator()(2, 4);와 동일하게 동작함.
}
위와 같이 함수처럼 동작하는 클래스를 가리켜 펑터(functor), 또는 **함수 오브젝트(Function Object)**라고 한다.
펑터는 다음과 같이 응용할 수 있다.
- 부모 클래스에서 operator()()를 순수 가상함수로 선언한다.
- 자식 클래스에서 operator()()의 동작을 정의한다.
- 부모 클래스의 참조형으로 입력을 받는 함수가 있다고, 가정할 때, 다양한 기능을 구현한 자식 클래스들을 그 자리에 입력할 수 있으며, 어떤 자식을 입력하는 지에 따라 동작이 달라진다. 이것이 펑터의 이점이다.
임시객체로의 자동 형 변환
c++에서는 다음과 같은 코드의 실행이 가능한다.
int main(void)
{
Number num;
num = 30; //서로 다른 자료형 간의 대입이 가능하다.
num.ShowNumber();
return (0);
}
위 예제에서 num = 30;은 다음과 같이 변환되어 처리된다.
num = Number(30); //1단계. 임시 객체의 생성
num.operator=(Number(30)); //2단계. 임시 객체를 대상으로 하는 대입 연산자의 호출
c++에는 다음과 같은 문법적 기준이 존재한다.
A형 객체가 와야 할 위치에 B형 데이터(또는 객체)가 왔을 경우, B형 데이터를 인자로 전달받은 A형 클래스의 생성자 호출을 통해서 A형 임시객체를 생성한다.
형 변환 연산자의 오버로딩
형 변환 연산자는 객체가 다른 타입의 데이터와 연산이 일어난 때 호출되는 연산자이다.
아래의 예제를 확인하자.
int main(void)
{
Number num1(30);
Number num2 = num1 + 20; //서로 다른 자료형 간의 + 연산이 일어남.
num2.ShowNumber();
return 0;
}
위의 예제가 실행 가능하려면 아래의 두 가지 방법 중 하나를 사용해야 한다.
- int 타입 데이터와의 + 연산이 가능하도록 + 연산자를 오버로딩한다.
- Number 객체의 int 자료형으로의 형 변환을 구현한다.
c++ 언어에서는 2번 방법을 구현하기 위해 형 변환 연산자의 오버로딩을 사용한다.
아래와 같이 구현한다.
operator int () // 형 변환 연산자의 오버로딩.
{
return (num);
}
형 변환 연산자 함수는 아래와 같은 특징을 가진다.
- ‘operator + 형 변환하는 데이터 타입’ 형식의 함수명을 가진다.
- 정의된 데이터 타입으로 형 변환해야 하는 경우에 호출된다.
- 반환형을 명시하지 않는다. 하지만 return을 사용한 반환은 가능하다. 이 때 return으로 반환되는 값은 정의된 데이터 타입으로 형 변환된다.
참고자료