관리 메뉴

Kim's Programming

C++ - 연산자 오버로딩(2/3) 본문

Programming/Cplusplus

C++ - 연산자 오버로딩(2/3)

Programmer. 2015. 9. 20. 19:44

::리턴 타입


연산의 결과로 어떤 타입을 리턴할 것인가는 연산자별로 다릅니다. 정수끼리 더하면 정수가 되고 실수끼리 곱하면 실수가 되는 것처럼 객체에 대한 연산 결과는 보통 객체와 같은 타입이 되지만 반드시 그런 것은 아닙니다. 논리 연산자의 경우는 BOOL(또는 bool)형이나 int형이 리턴될 수도 있고 첨자 연산자 [ ]의 경우처럼 특수한 연산자는 멤버 중의 하나를 리턴하는 경우도 있습니다. 앞에 있던 Time클래스의 + Complex 클래스의 +는 둘 다 크래스형의 객체를 리턴했는데 그래야 연산결과를 제 3의 객체에 대입할 수 있습니다. 만약 +연산자가 덧셈만하고 결과를 리턴하지 않는다면 A=B+C같은 대입은 불가능할 것이며 A=B+C+D같은 계산도 할 수 없을 것입니다. 임의의 타입 T에 대한 덧셈 결과는 역시 T형이 되는게 올바릅니다.


연산자 함수가 객체를 리턴할 때 레퍼런스로 리턴할 것인가, 값을 리턴할 것인가는 연산자에 따라 다릅니다. operator +의 경우 임시 객체로 연산 결과를 리턴하기 때문에 레퍼런스형은 안됩니다. 임시 객체는 함수 호출이 종료되면 사라지며 함수 리턴 직후에 다른 객체로 대입할 수 있는 값을 넘겨야 합니다. 임시 객체에 대한 레퍼런스도 물론 곧바로 대입한다면 별 문제는 없습니다. Complex의 +연산자를 레퍼런스를 리턴하도록 수정해보겠습니다.

1
2
Complex &operator +(const Complex &T) const
..
cs

이렇게 수정한 후 컴파일하면 결고가 발생은 하지만 C3=C1+C2; 연산문이 정상적으로 실행됩니다. 왜냐하면 +연산자 다음 연산이 대입 연산이고 대입 연산은 함수 호출이 아닌 멤버별 복사 코드의 실행이기 떄문에 스택에 있는 임시 변수가 대입되는 시점까지 값을 계속 유지하기 때문입니다. 그러나 메인함수에 다음 소스를 작성하면 제대로 동작하지 않음을 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
void main()
{
    Complex C1(1.12.2);
    Complex C2(3.34.4);
    Complex C3(5.56.6);
    
    Complex C4;
    C4 = C1 + C2 + C3;
    C1.OutComplex();
    C2.OutComplex();
    C3.OutComplex();
    C4.OutComplex();
}
cs

연산 순위에 따라 C1+C2가 먼저 호출되고 이 연산의 결과 지역변수 R의 레퍼런스가 리턴되며 다음으로 연산결과 R+C3가 호출되는데 이 시점에서 스택에 있는 호출 객체인 R이 깨지기 떄문입니다. 그러므로 C4는 제대로 된 값을 대입 받을 수 없습니다. 연쇄적인 연산이 아닌 C3 = C1 +C2 같은 대입문도 = 연산자가 별도의 함수로 오버로딩된 경우 마찬가지 현상이 일어납니다. 바로 직전의 함수가 만든 지역변수는 다음 함수가 호출되면 완전히 사라집니다. 스택은 매 함수 호출마다 새로 재구성되는 임시 기억 장소이기 때문입니다. 반면 값으로 리턴할 경우는 아무런 문제가 없습니다. 값은 리턴될 때 새로 만들어지는 사본이기 때문에 다른 함수 호출에 대해 침범당하지 않기 떄문입니다. 그래서 Complex의 +연산자는 Complex의 레퍼런스가 아닌 Complex의 값을 리턴하는 것이 정확합니다.


::리턴 타입의 상수성


리턴 타입의 상수성도 경우에 따라 다른데 객체 타입을 리턴하는 함수는 보통 숭수 객체를 리턴해야 합니다. Time이나 Complex는 연산을 위해 임시 객체를 생성하고 연산 결과인 임시 객체를 리턴합니다. 이 임시 객체는 값을 리턴하기 위해 잠시 생성되는 것이므로 상수성을 가지는 것이 옳습니다. 이해가 안되면 정수 연산을 예로 드러보면됩니다.

1
2
int i = 3, j = 4, k;
= i + j;
cs

이 연산에서 i+j의 결과로 리턴되는 값은 7이라는 정수상수이지 정수형 변수가 아닙니다. 즉 우변값이어야지 좌변값이어서는 안 됩니다. 만약 i+j가 값을 변경할 수 있는 정수형 변수를 리턴한다면 i + j = 5;라는 연산식도 허용되어야 할 것입니다. Complex의 경우 C1 + C2는 덧셈을 한 복소수 객체일 뿐 여기에 어떤 변경을 가할 수 없어야 하며 만약 이를 허용하면 잠시 후면 사라질 임시 객체를 변경하는 쓸데없는 짓을 하게 됩니다. 이 함수의 원형을 보면 const가 3 번 사용 되는데 각각의 의미는 다릅니다.

1
2
3
4
const Complex operator +(const Complex &T) const
1번 째 const : 리턴되는 값도 읽기 전용이다.
2번 째 const : 피연산자, 즉 우변이 상수라는 뜻
3번 째 const: 호출 객체, 즉 좌변이 상수라는 뜻
cs

읽기 전용 피연산자를 받고 객체를 변경하지 않으며 리턴되는 객체도 읽기만 할 수 있습니다. 덧셈 연산은 모든 대상을 상수로만 취급합니다.


::생성자의 활용 


Complex 클래스는 실수부 R과 허수부 I를 전달받는 생성자가 정의되어 있으므로 이를 활용하여 생성자로부터 임시 객체를 쉽게 만들 수 있습니다. operator + 연산자의 본체를 다음과 같이 수정해 보겠습니다.

1
2
3
4
5
6
const Complex operator +(const Complex &T) const
    {
        Complex R(real + T.real, image + T.image);
        return R;
    }
};
cs

임시 객체 R을 만들 때 생성자로 실수부와 허수부의 연산식을 넘기면 됩니다. 생성자의 인수로 전달되기 전에 대응되는 멤버끼리 연산이 수행되고 그 결과가 새로 생성되는 객체의 멤버로 대입됩니다. 또는 아예 임시 객체를 만들지 않고 생성자가 리턴하는 이름없는 임시 객체를 곧바로 리턴할 수도 있습니다.

1
2
3
4
const Complex operator +(const Complex &T) const
{
    return Complex(real + T.real, image + T.image);;
}
cs

이 코드는 앞서 만든 코드보다 훨씬 더 짧고 간략해 보일 뿐만 아니라 컴파일러의 리턴값 최적화(RVO : Return Value Optimizatio)기능의 도움도 받을 수 있어 훨씬 더 유리합니다. 제대로 만든 컴파일러는 호출원의 대입되는 좌변에 대해 곧바로 생성자를 호출하며 불필요한 임시 객체를 만들지 않음으로써 훨씬 더작고 빠른 코드를 생성합니다. 임시 객체를 명시적으로 선언하든 아니면 생성자가 리턴하는 임시 객체를 리턴하든 어쨋든 리턴되는 결과는 임시적인 객체이므로 함수 호출이 완료되면 사라집니다. 그래서 호출원에서는 C3 = C1 + C2; 처럼 리턴되는 임시 객체를 곧바로 다른 객체에 대입해야 합니다. 만약 C1+C2; 연산문으로 더하기만 하고 대입을 받지 않으면 리턴되는 임시 객체는 버려집니다. 이 점도 정수형의 연산과 동일합니다.


::본체


연산자 함수의 본체에는 연산자에 요구되는 논리적인 연산 코드를 작성합니다. 실제 연산 코드는 클래스 마다, 연산자마다 천차만별로 달라질 것입니다. 복소수 연산의 경우 실수부와 허수부를 따로 연산하며 시간은 시분초 요소끼리 연산하되 자리 올림이나 내림을 처리해야 합니다. 문자열 끼리 더할 때는 버퍼를 재할당 하여 연결해야 할 것이며 행렬의 경우 수학적 정의에 따라 행렬 연산을 해야 할 것입니다.


이처럼 클래스가 표현하는 대상에 따라 연산하는 방법이 고유하고 특수하기 때문에 클래스를 만든 사람이 연산방법 자체를 정의할 수 있어야 하며 이런 정의를 가능하도록 하는 C++의 문법적인 장치가 바로 연산자 오버로딩인 것입니다. 모든 클래스에 대해, 모든 연산자에 대해 절대적으로 적용되는 법칙 같은 건 없으며 클래스별로 연산자별로 규칙이 달라집니다.


전역 연산자 함수


전역 연산자 함수


연산자를 오버로딩하는 방법에는 멤버 함수로 만드는 방법과 전역함수로 만드는 방법 두 가지가 있습니다. 멤버 연산자 함수로 만드는 방법에 대해서는 앞에서 보았으니 전역 함수로 만드는 방법에 대해 알아보겠습니다. 전역 연산자 함수는 클래스 외부에 존재하되 인수로 클래스의 객체를 받아들입니다.  클래스의 객체가 인수가 된다는 것은 곧 피연산자 중의 하나가 객체가 된다는 뜻이므로 클래스 외부에 전역 함수로도 클래스의 고유한 연산 방법을 정의할 수 있습니다. 함수란 인수로 전달된 대상을 액세스할수 있으므로 당연한 이야기 입니다. 다음 소스는 앞에서 만들었던 Time 클래스의 + 연산자를 전역함수로 새로 작성해 본 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include<iostream>
using namespace std;
class Time
{
    friend const Time operator+(const Time &T1, const Time &T2);
private:
    int hour, min, sec;
public:
    Time(){}
    Time(int h, int m, int s)
    {
        hour = h;
        min = m;
        sec = s;
    }
    void OutTime()
    {
        cout << hour << ":" << min << ":" << sec << endl;
    }
};
 
const Time operator +(const Time &T1, const Time &T2)
{
    Time R;
 
    R.sec = T1.sec + T2.sec;
    R.min = T1.min + T2.min;
    R.hour = T1.hour + T2.hour;
 
    R.min += R.sec / 60;
    R.sec %= 60;
    R.hour += R.min / 60;
    R.min %= 60;
    return R;
}
 
void main()
{
    Time A(111);
    Time B(222);
    Time C;
 
    A.OutTime();
    B.OutTime();
    C = A + B;
    C.OutTime();
}
cs

operator + 라는 이름의 전역 함수가 정의되어 있으며 이 함수는 Time형의 레퍼런스 T1, T2를 인수로 전달받아 임시 객체 R에 두 객체의 합을 더해 리턴합니다. 시간끼리의 합을 구하는 논리는 멤버 연산자 함수의 경우와 완전히 동일하되  전역 함수라는 점만 다를 뿐입니다. 실행 결과는 멤버 연산자 함수의 경우와 완전히 동일합니다.


Time 클래스에는 시간끼리 더하는 멤버 연산자 함수가 정의되어 있지 않지만 main 함수는 operator + 전역 함수의 도움으로 시간 객체끼리 덧셈을 훌륭하게 수행하고 있습니다. Time 클래스는 연산자 함수를 멤버로 정의하지 않는 대신 operator + 전역함수를 friend로 지정하여 자신의 모든 멤버를 자유롭게 엑세스할 수 있도록 허락합니다. 만약 Time 클래스 선언부의 선두에 있는 friend 선언을 생략해 버리면 수 많은 에러 메세지가 출력될 것입니다. Time의 주요 멤버인 hour, min, sec은 모두 프라이빗 엑세스 속성을 가지고 있으므로 클래스 외부의 전역 함수에서 이 멤버를 참조할 수 없습니다. 전역 operator + 함수는 시각 객체끼리 덧셈을 하기위해 이 멤버들을 자유롭게 읽을 수 있어야 하는데 이럴 때 사용하는 것이 바로 프렌드 선언입니다.


C=A+B; 연산문은 C=operator +(A,B);의 함수 호출문 형식으로 바꿀 수 있습니다. 만약 이 함수의 동작이나 호출 방법이 잘 이해되지 않는다며 operator + 함수의 이름을 AddTime이라는 좀 더 친숙한 이름으로 잠시 바꿔 보겠습니다. 함수의 본체는 수정이 필요없습니다.

1
2
3
4
const Time AddTime(const Time &T1, const Time &T2)
{
    ...
}
cs

그리고 main 함수에 있는 C = A + B; 호출문을 C = AddTime(A,B);로 바꿔 보면 똑같이 동작할 것입니다. AddTime은 Time형의 객체를 인수로 취할 뿐이지 단순한 함수에 불과하며 이 함수의 이름만 C++이 정의하는 연산자 함수의 이름 규칙대로 바꾸면 바로 저역 operator + 연산자 함수가 되는 것입니다. 결국 전역 연산자 함수란 이름이 조금 특이할 뿐이지 일반적인 함수로 이해하면 쉽습니다.


객체를 위한 연산자를 오버로딩하는 두 가지 방법, 즉 멤버로 만드는 방법과 전역으로 만드는 방법을 모두 보았습니다. 두 함수는 클래스의 내부에 있는가 아니면 외부에  있되 프렌드로 지정되어 있는가만 다를 뿐이며 연산을 하는 논리나 호출하는 방법은 동일합니다. 두 형식의 연산자 함수의 차이점은 바로 함수의 원형에 있습니다.


원형 중 가장 다른 부분은 인수의 개수입니다. 멤버 연산자 함수의 경우는 원래의 피연산자보다 인수의 개수가 항상 하나 더 적은데 +는 이항 연산자이므로 두 개의 피연산자를 취하지만 멤버 연산자 함수의 인수는 하나만 있으면 됩니다. 이 함수를 호출하는 객체인 *this가 암시적인 좌변이 되며 나머지 우변이 될 대상만 인수로 전달받습니다. 나 자신(this)과 연산될 대상이 누구인가만 알면 되는 것입니다. 만약 ++ 단항 연산자를 멤버 연산자 함수로 오버로딩한다면 호출하는 객체 자체가 피연산자가 되므로 인수는 필요없을 것입니다. 이에 비해 전역 연산자 함수는 원래의 피연산자와 같은 수의 인수를 가집니다. + 연산자가 이항 연산자이므로 operator + 전역 연산자 함수는 두 개의 인수를 취하고 ++ 연산자는 단항 연산자이므로 operator ++ 전역 연산자는 함수는 증가시킬 대상 하나만 인수로 전달받으면 됩니다. 암시적으로 전달되는 this가 없으므로 좌우변 모두 인수로 전달받아야합니다.


그렇다면 연산자 오버로딩이 필요할 때 두 가지 형식중 어떤 함수를 쓰는것이 좋을까요? 두 형식의 연산자 함수는 정의하는 위치만 다를 뿐 큰 차이점은 없으므로 대개의 경우 둘 중 어떤 형식을 쓰더라고 큰 상관은 없습니다. 클래스의 객체를 다루는 연산이라면 가급적이면 클래스에 소속되는 것이 캡슐화의 원칙에 부합되므로 멤버 연산자 함수로 만드는 것이 더 깔끔합니다. 다만 불가피 하게 전역으로 만들어야 하는 경우도 있고 =,(),[],->연산자 들은 반드시 멤버 연산자 함수로만 만들어야 합니다. 결국 두 형식 모두 필요합니다. 그럴 필요는 없지만 만약 똑같은 연산자 함수를 멤버로도 정의하고 전역으로도 정의한다면 어떻게 될까요? 이경우는 정의 자체는 가능하지만 호출할 때 모호하다는 에러메세지가 출력되므로 양쪽 형식의 연산자를 모두 정의해서는 안 되며 그럴 필요도 없습니다. 컴파일러는 모호한 걸 싫어합니다.


참고로 전역 연산자 함수를 사용하면 열거형에 대한 연산도 정의할 수 있습니다. 열거형도 하나의 타입이며 오버로딩의 재료로 사용할 수 있으므로 열거형을 피연산자로 가지는 연산자도 중복 정의 가능합니다. 단, 열거형은 멤버함수를 가지지 못하므로 전역 연산자 함수로만 정의할 수 있습니다.  

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<iostream>
using namespace std;
 
enum origin{ EAST, WEST, SOUTH, NORTH };
origin operator++(origin &Ori)
{
    if (Ori == NORTH)
        Ori = EAST;
    else
        Ori = origin(Ori + 1);
    return Ori;
}
 
void main()
{
    origin mark = WEST;
    for (int i = 0; i < 7; i++)
        cout << ++mark;
}
cs

소스의 ++ 연산자는 origin 형 열거 변수를 다음으로 증가시키되 마지막 열거값 다음을 선두의 열거값과 연결하여 순환하도록 합니다. 이 연산자가 정의되어 있지 않으면 열거형에 대해서는 ++연산자를 적용할 수 없습니다.


객체와 기본형의 연산


연산자를 오버로딩하면 연산문으로 객체끼리 연산할 수 있는 것과 마찬가지로 객체를 정수나 실수형 같은 기본형이나 다른 객체와도 연산할 수 있습니다. 복소수에 실수를 더하거나 뺄 수 있고 시간에 정수형의 초를 연산할 수 있습니다. 사실 클래스가 타입이므로 굳이 객체와 기본형을 구분할 필요가 없으며 논리적으로 의미만 있다면 오버로딩하기에 따라서 임의 타입의 객체끼리 연산 가능합니다. 다음으로 나오는 소스는 시간 객체에 정수형으로 된 초를 더합니다. 연산자 함수는 멤버로 되어 있든 전역으로 되어 있든 어쨋든 함수이므로 취할 수 있는 인수의 타입에 근본적인 제약이 없으며 원하는 타입의 인수를 취하기만 하면 피 연산자를 받아들일 수 있습니다. 물론 시간과 정수처럼 연산이 실질적인 의미가 있어야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include<iostream>
using namespace std;
 
class Time
{
private:
    int hour, min, sec;
public:
    Time(){};
    ~Time(){};
    Time(int h, int m, int s)
    {
        hour = h;
        min = m;
        sec = s;
    }
    void OutTime()
    {
        cout << hour << ":" << min << ":" << sec << endl;
    }
    const Time operator+(int s) const
    {
        Time R = *this;
 
        R.sec += s;
        R.min += R.sec / 60;
        R.sec %= 60;
        R.hour += R.min / 60;
        R.min %= 60;
        return R;
    }
};
void main()
{
    Time A(123);
 
    A.OutTime();
    A = A + 5;
    A.OutTime();
}
cs

operator + 멤버 연산자 함수가 int 형의 s를 인수로 받아들여 이 값을 임시 객체 R의 sec에 더한 후 자리 올림 처리하고 R을 리턴했습니다. 객체끼리 더할 떄는 시분초를 모두 더하지만 분, 시도 영향을 받을 수 있으므로 자리올림 처리는 생략할 수 없습니다. 이 예제는 아주 정상적으로 잘 동작합니다. 결과는 다음과 같습니다.


1:2:3에 5초를 더하면 1:2:8가 됩니다. A=A+5; 연산문이 시간 객체와 정수와의 덧셈을 훌륭하게 연산한 것입니다. 그렇다면 A=5+A;의 경우는 어떨까요? 덧셈은 교환법칙이 성립하는 연산이므로 A+5가  가능하다면 당연히 5+A도 가능해야 합니다. main의 A=A+5; 연산문을 A=5+A;로 바꿔놓고 컴파일해보겠습니다. Time 형을 인수로 취하는 +연산자는 정의되어 있지 않다는 에러가 발생할 것입니다. 컴파일러는 5+A라는 연산문을 만났을 때 다음 두 함수 중 하나를 찾습니다.

1
2
const Time int::operator +(Time);
const Time operator +(int, Time);
cs

위쪽 함수는 멤버 연산자 함수인데 좌변인데 좌변이 5라는 int형 상수이므로 int 클래스에 정의된 멤버 함수이며 TIme형 객체를 인수로 취합니다. 이런 함수는 int형에 정의되어 있지 않으며 직접 만드는 것도 불가능합니다. int형을 시스템 내장 타입이기 떄문에 사용자가 이 클래스를 마음대로 확장할 수 없습니다. 아래쪽 함수는 전역 연산자 함수인데 int와 Time형 객체를 인수로 취합니다. 이 함수도 아직 만들어져 있지는 않지만 원한다면 직접 만들 수는 있습니다. 문제를 해결하기 위해 다음 함수를 추가해 보겠습니다.

1
2
3
4
5
6
7
8
9
10
const Time operator +(int s, const Time &T)
{
    Time R = T;
    R.sec = T.sec + s;
    R.min += R.sec / 60;
    R.sec %= 60;
    R.hour += R.min / 60;
    R.min %= 60;
    return R;
}
cs

정수형 변수 s와 Time형 객체 T를 인수로 전달받아 두 객체를 더합니다. 이 함수가 정의되어 있으면 컴파일러는 5+A 연산문을 처리할 수 있지만 컴파일해 보면 더 많은 에러가 발생할 것입니다. 왜냐하면 전역 함수에서 Time 클래스의 프라이빗 멤버를 엑세스 하고 있기 떄문입니다. 이 상황을 해결하는 여러가지 방법들을 생각해 볼 수 있습니다.

    1. 전역 연산자 함수를 아예 삭제해 버리고 클래스 설명서나 소스상의 주석에 A+5형태로만 호출할 수 있으며 5+A따위로 호출하지 말라고 분명히 써놓습니다. 만약 5+A같은 건방진 연산문을 쓸 경우 에러를 잔뜩 토해버리겠다고 협박할 수도 있습니다. 어쨋든 객체와 정수의 덧셈 방법은 제공하는 셈이지만 사용자들은 A+5가 되면 5+A도 당연히 될 것이라고 생각하기 떄문에 일반적인 기대에 부응하지 못하는 방법입니다.

    2. 전역 연산자 함수가 Time의 멤버를 자유롭게 읽을 수 있도록 프라이빗 멤버를 모두 공개합니다. 이렇게 되면 일단 잘 동작하기는 하겠지만 연산자를 오버로딩하기 위해 정보 은폐를 포기하는 꼴이 되므로 OOP의 설계 원칙에 한참 어긋나게 되고 좋은 방법아닙니다.

    3. 프라이빗 멤버는 비공개로 계속 유지하되 이 멤버들을 읽고 쓰는 공개 함수를 모두 작성하고 전역 연산자 함수는 이 액세스 함수들을 통해 멤버를 액세스 하도록 합니다. 엑세스 할 필요가 있는 멤버에 대해 일일이 Get, Set 함수를 만들어야 하므로 무척 번거롭습니다.

이상의 세 가지 방법은 일단 문제를 해결하기는 하겠지만 모두 다 제대로 된 방법이라 할 수 없습니다. 이런 애매한거 보다는 안전한 방법이 있는데 이것이 friend 함수 입니다. 전역 operator +연산자를 Time 클래스의 프렌드 함수로 지정해 보겠습니다.
1
2
3
class Time
{
    friend const Time operator +(int s, const Time &T);
cs
이 선언을 추가하면 제대로 컴파일도 되고 A+5나 5+A도 됩니다. 양쪽의 요구를 처리하는 함수가 모두 작성되었기 때문에 이상없이 작동은 하지만 거의 똑같은 함수가 두 번 반복된다는 것이 조금은 문제입니다. 두 함수는 인수의 순서만 다르지 거의 같은데 다음과 같이 수정해보겠습니다.
1
2
3
4
const Time operator +(int s, const Time &T)
{
    return T + s;
}
cs
인수로 전달받은 s, T의 순서를 바꿔 T+s 연산문을 리턴하면 멤버 연산자 함수가 이 연산을 대신 처리할 것입니다. 이 경우 전역 연산자 함수는 인수의 순서를 바꿔 멤버 연산자 함수를 호출하는 중계 역할만 하며 정상적으로 실행되게 됩니다. 이렇게 되면 전역 연산자 함수가 Time의 멤버를 직접 엑세스 하지 않으므로 이 함수에 대한 프렌드 지정은 생략할 수 있습니다. 하지만 이 함수는 여전히 Time과 관련된 함수이므로 프렌드 지정을 유지하는 것도 별 문제는 없습니다. 전역 연산자 함수가 중계를 하는 방법 대신 멤버 연산자 함수가 중계를 할 수도 있습니다. 전역 연산자 함수의 본체를 그대로 유지한 채로 멤버 연산자 함수만 다음과 같이 수정해 보겠습니다.
1
2
3
4
5
6
7
8
Class Time
{
    const Time operator+(int s) const
    {
            ..
            return s+*this;
    }
};
cs

이번에는 멤버 연산자 함수가 중계를 하는 셈인데 이렇게 해도 역시 잘 동작할 것입니다. 실제 연산을 하는 코드는 한쪽에만 있으면 되고 불필요하게 중복시키지 않는 것이 관리하기에 유리합니다. 요약하자면 타입이 다른 객체끼리 연산할 때는 교환 법칙이 성립할 수 있도록 전역 연산자 함수를 제공해야 하며 이 함수가 객체 내부의 멤버를 읽을 수 있도록 프렌드 선언을 적절히 활용해야합니다.


오버로딩 규칙


여기까지 객체의 연산을 위해 연산자를 어버로딩하는 두 가지 방법에 대해서 보았습니다. 연산자 오버로딩은 좋은 기능이지만 많은 규ㅣㄱ과 제약이 존재합니다. 이 규칙들은 연산자 오버로딩에 따른 부작용 해소하고 안전하게 연산자를 사용할 수 있도록 마련된 것들입니다. 내용이 좀 많기는 하지만 상식 범위를 크게 벗어나는 것은 없어서 쉽게 익숙해 질 것입니다.


    1. 연산자 오버로딩은 이미 존재하는 연산자의 기능을 조금 바꾸는 것이지 아예 새로운 연산자를 만드는 것은 아닙니다. 원래 C++ 언어가 제공하는 기존 연산자만 오버로딩의 대상이며 C++이 제공하지 안흔 연산자를 임의로 만들 수는 없습니다. 예를 들어 C++은 누승 연산자를 제공하지 않는데(대신 pow라는 표준함수가 있습니다.) 이런 목적으로 **라는 완전히 새로운 연산자를 정의하고 싶으며 C++이 이를 허용한다고 해 보겠습니다. 그렇다면 컴파일러는 c=a**b라는 연산식이 가능해지는데 컴파일러가 이 식을 해석할때 두 가지 모호한 상황이 발생합니다. 우선 이 연산식을 a를 b만큼 누승하라는 것인지 아니면 a와 포인터b가 가리키는 곳의 내용을 읽어 곱하라는 것인지를 판별할 수 없습니다. a**b는 a 누승 b로 볼 수도 있고  a*(*b)로 볼 수 도 있어 구문 분석 단계에서 모호함이 발생합니다. 그리고 새로 만들어진 연산자의 우선 순위와 결합 순서를 어떻게 정할 것인지도 문제가 됩니다. c=a**b라는 식이 있을 때 누승이 먼저인지 덧셈이 먼저인지를 컴파일러가 임의로 결정할 수 없습니다. 만약 이것을 정 가능하게 하자면 사용자가 새로 만든 연산자의 우선순위를 지정할 수 있는 문법을 만들어야 하는데 연산자 하나를 쓰기 위해 이런 복잡한 지정까지 해야 한다면 차라리 안쓰는것이 낫습니다.

      C++이 사용하지 않는 문자인 $, @같은 기호를 새로운 연산자로 정의할 수 있다면 나름대로 편리하겠지만 오버로딩이란 이미 존재하는 것을 중복 정의하는 것이지 없는 걸 아예 새로 만드는 것이 아니므로 이 경우도 부적당합니다. 사용자가 임의로 연산자를 만들 수 있도록 하는 것은 이론상 분명히 가능하지만 이런 기능을 지원하기 위해 대폭적인 문법의 확장이 필요하며 예기치 못한 부작용이 생길 수 있습니다. 득보다 실이 더 많기 떄문에 C++은 아예 새로운 연산자를 만드는 문법은 제공하지 않습니다.

    2. 이미 존재하는 연산자 중에도 오버로딩의 대상이 아닌 것들이 있습니다. 다음 연산자들은 기능을 변경할 수 없습니다. 즉, 오버로딩의 대상이 아닙니다.

      1
      2
      3
      4
      .
      .*
      static_cast
      reinterpret_cast;
      cs
      1
      2
      3
      4
      ::
      sizeof
      dynamic_cast
      new
      cs
      1
      2
      3
      4
      ?:
      typeid
      const_cast
      delete
      cs

      이 연산자들은 C++의 클래스와 관련된 중요한 동작을 하기 떄문에 클래스를 기반으로 하는 연산자 오버로딩의 재료로 쓰기에는 무리가 있습니다. 클래스의 멤버를 지정하는  . 연산자의 동작을 바꿔 버리면 어떤 혼란이 올지 가히 상상이 갈 것입니다. 삼항 조건 연산자는 피연산자가 셋이나 되기 때문에 오버로딩을 하더라도 다른 연산자에 비해 더 복잡한 규칙이 필요할 것이므로 아예 오버로딩 못하도록 되어 있습니다. 이런 몇 가지 특수한 연산자만 빼고 나머지 42개나 되는 연산자들은 모두 오버로딩 할 수 있으므로 연산자가 부족한 상황은 발생하지 않습니다.

      오버로딩이 가능한 연산자 중에도 가급적 오버로딩을 삼가해야 하는 것도 있습니다. 언어가 제공하는 연산자는 우선 순위 규칙이 명확하게 정의되어 있어 좌우의 피연산자 중 어떤 것이 먼저 평가될지 예측 가능합니다. 그러나 함수로 오버로딩되면 인수의 평가 순서가 정의되지 않으므로 원치 않는 부작용이 발생할 수 있습니다. 예를 들어 콤마 연산자의 경우 좌에서 우로 순서대로 평가하지만 함수로 오버로딩되면 이런 우선 순위가 더 이상 적용되지 않습니다. 함수의 인수는 보통 우에서 좌로 평가되어 연산자의 평가 순서와는 반대로 되어 있는데 순서가 의미가 있는 때는 문제가 될 수도 있습니다. &&, || 논리 연산자의 경우 쇼트 서키트 기능이 동작하도록 설계되어 있지만 오버로딩되면 쇼트 서킷은 더 이상 동작하지 않습니다. 문법적으로는 허용한다 하더라도 그 효과를 예측하기 어려우므로 가급적이며 이 연산자들은 오버로딩하지 말아야 합니다. 사실 이 연산자들이 오버로딩되어야 하는 경우도 거의 없는 편입니다.

    3.  기존 연산자의 기능을 바꾸더라도 연산자의 본래 형태는 변경할 수 없습니다. 여기서 본래 형태라는 하는 것은 피연자의 개수와 우선 순위를 말합니다. + 연산자는 원래 피연사자를 두 개 취하는 이항 연산자이므로 오버로딩된 후에도 이항 연산자여야 하며 반드시 피연산자 두개를 가져야 합니다. operator + 가 멤버연산자 함수일 때는 하나의 인수를 가져야 하며 전역 연산자 함수일 때는 두 개의 인수가 필요합니다.

      1
      2
      3
      4
      5
      6
      const Time Time::operator +(Time &T)                //가능
      const Time Time::operator +(int i)                    //가능
      const Time operator +(Time &T,int i)                //가능
      const Time Time::operator +(Time &T1, Time &T2)    //불가능
      const Time Time::operator +(Time &T)                //불가능
      const Time Time::operator +(void)                    //불가능
      cs

      우선 순위나 결합 순서도 변경할 수 없습니다. + 연산자의 기능을 바꾸어 곱셈이나 누승을 하도록 오버로딩했다 하더라도 이 연산자의 우선 순위는 원래의 + 연산자의 것과 동일합니다. 컴파일러는 연산자가 논리적으로 어떤 연산을 하는지 다른 연산과의 관계가 어떠한지까지는 판단할 수 없습니다. 따라서 완전히 새로운 연산을 정의하고자 할 때 적당한 우선 순위를 가지는 연산자를 골라 오버로딩 해야합니다. 다른 언어는 누승 연상을 위해 ^ 기호를 사용합니다. 그래서 ^연산자를 누승 연산자로 재정의한다면 베이직 언어처럼 편리하게 누승 연산을 할 수 있을 것입니다. 그러나 C++에서 원래의 ^ 연산자는 곱셉보다 우선 순위가 늦고 심지어 덧셈이나 뺄셈보다 우선 순위가 늦어 모양이 직관적이더라도 누승연산자로 재정의하기에는 어울리지 않습니다. 2^3+4는 12여야 상식적이지만 +연산자가 순위가 높아서 128이 되어 버립니다.

    4. 아주 당연한 얘기가 되겠지만 한 클래스가 하나의 연산자를 여러 가지 피연산자 타입에 대해 오버로딩 할 수 있습니다. 오버로딩이란 인수의 개수나 타입이 다르면 항상 성립하므로 여러 개의  피연산자에 대한 연산자를 제공할 수 있다는 얘기입니다. Time 클래스에 다음 두 덧셈 연산자가 동시에 정의되어도 아무런 문제가 없습니다.

      1
      2
      const Time operator+(const Time &T) const {...}
      const Time operator +(int s) const {...}
      cs

      위쪽 함수는 시간에 시간을 더하는 것이고 아래쪽 함수는 시간에 정수를 더하는 것입니다. 호출부에서 피연산자 타입을 보고 어떤 덧셈을 원하는지 알 수 있으므로 모호하지 않으며 실용성도 있습니다. 원한다면 시간에 실수나 Date를 더하는 + 연산자도 얼마든지 중복 정의할 수 있습니다.

    5. 오버로딩된 연산자의 피연산자 중 적어도 하나는 사용자 정의형이어야 합니다. 연산자의 기능을 바꾸는 목적은 객체에 대한 고유한 연산 방법을 정의하기 위한 것이므로 반드시 객체와 관련있는 연산자만 중복 정의할 수 있습니다. C++이 기본적으로 제공하는 타입에 대해서는 연산자를 오버로딩할 수 없습니다. 만약 다음과 같은 연산자 함수를 만들 수 있다고 해보겠습니다.

      1
      2
      3
      4
      int operator +(int a, int b)
      {
          .....
      }
      cs

      이 함수는 정수형의 덧셈 연산을 완전히 새로 정의하는데 정수형의 덧셈은 언어의 가장 기본적인 동작이고 CPU의 원자적인 연산이기 떄문에 이 동작이 바뀌게 되면 파급효과가 너무 엄청날 것입니다. 그래서 컴파일러는 기본형에 대한 연산자 오버로딩은 거부하며 "최소한 하나의 피연산자는 클래스 타입이어야 한다"는 에러 메세지를 출력합니다. 사실 위에서 보인 두 개의 정수를 더하는 연산자는 시스템에 이미 존재합니다. 오버로딩이란 인수의 개수나 타입이 달라야 하므로 위 연산자는 오버로딩의 대상이 될 수도 없습니다. 오버로딩이란 이미 존재하는 함수의 기능을 바꾸는 것이 아니라 타입에 따라 다르게 동작하는 함수를 중복 정의하는 것이므로 기본 타입에 대한 여산자는 만들 수 없는 것입니다.

    6. 강제적인 규칙은 아닌지만 연산자의 논리적 의미는 가급적 유지하는 것이 바람직합니다. +연산자를 오버로딩한다면 어떤 클래스에 대해서라도 덧셈의 의미를 가지는 연산을 하는 것이 좋습니다. 그래야 연산자에 대한 사용자들의 기존 상식을 보호할 수 있습니다. + 연산자를 더하기와는 전혀 상관없는 다른 연산으로 오버로딩해 버리면 이 객체를 사용하는 사람은 혼란스러워할 것입니다. 그러나 이 규칙은 어디까지나 권장 사항일 뿐이므로 불가피할 경우는 지키지 않아도 상관 없습니다. 예를 들어 표준 입출력 스트림인 cout은 쉬프트 연산자인 <<를 모양이 마음에 든다는 이유로 출력 연산자로 재정의하는 만행을 저지르기도 합니다. 기본 문법에는 제공되지 않는 완전히 새로운 연산을 정의할 경우는 적당한 우선 순위를 가지고 모양이 좀 그럴싸해 보이는 연산자 하나를 선택할 수밖에 없을 것입니다.

연산자를 적절하게 오버로딩하면 복잡한 것을 간결하게 논리적으로 표기할 수 있고 어떤 동작을 한다는 것을 명확하게 표현할 수 있어 가독성에 유리합니다. 또한 이미 익숙해진 연산자를 직관적인 방법으로 활용할 수 있으므로 생산성 향상에도 큰 기여를 합니다. 그러나 연산자 오버로딩에는 여러 가지 부작용도 있음을 알아야 합니다. 모든 문법이 마찬가지겠지만 연산자 오버로딩은 꼭 필요할 때, 그리고 논리적으로 무리가 없을 때에 한해 규칙에 맞게 안전하게 사용해야 합니다.



'Programming > Cplusplus' 카테고리의 다른 글

C++ - 클래스 상속(1/3)  (0) 2015.09.22
C++ - 연산자 오버로딩(3/3)  (0) 2015.09.21
C++ - 연산자 오버로딩(1/3)  (1) 2015.09.20
C++ - 캡슐화(3/3)  (0) 2015.09.19
C++ - 캡슐화(2/3)  (0) 2015.09.19