관리 메뉴

Kim's Programming

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

Programming/Cplusplus

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

Programmer. 2015. 9. 20. 05:18

연산자 함수


기본형의 연산자


연산자를 오버로딩할 수 있다는 것은 C++의 언어의 큰 특징이며 클래스가 타입임을 보여주는 단적인 예라고 할 수 있습니다. 조금 어렵기는 하지만 문법이 체계적이어서 이해하고 나면 언어의 질서를 느낄 수 있으며 오히려 재밌기도 합니다. C/C++ 언어가 제공하는 기본형의 연산문을 한번 상펴보겠습니다. 대표적으로 덧셈 연산문을 보면 다음과 같은 구문이 가능합니다.

1
2
3
4
5
int i1 = , i2 = 2;
double d1 = 3.3, d2 = 4.;
 
int i = i1 + i2;//정수 덧셈
double d = d1 + d2;//실수 덧셈
cs

하나는 정수끼리 더해 정수형 변수에 대입하고 하나는 실수끼리 더해 실수형 변수에 대입하는데 둘다 잘 동작합니다. 연산 결과 i는 3이 되고 d는 7.7이 될 것입니다. 덧셈 연산자인 +는 피연산자의 타입이 달라도 문제없이 정확하게 연산을 해 냅니다. 너무 상식적이어서 당연한 것처럼 생각되겠지만 이 연산이 성립하는 이유도 알고보면 나름대로 복잡합니다. 정수형과 실수형은 길이도 다르고 비트 구조도 상이해서 각 타입을 더하는 알고리즘이 분명히 다르겠지만 똑같은 연산자로 두 타입의 덧셈이 가능한 것입니다.


이렇게 되는 이유는 덧셈 연산자가 피연산자의 타입에 따라 오버로딩되어 있기 때문입니다. 즉, 정수 덧셈을 하는 코드와 실수 덧셈을 하는 코드가 각각 따로 작성되어 있으며 컴파일러는 덧셈 연산자 양변에 있는 피연산자의 타입을 점검한 후 둘 다 정수일 경우 정수끼리 더하는 코드를 호출하고 둘 다 실수일 경우 실수끼리 더하는 코드를 호출합니다. 정수의 경우 부호가 같으면 절대값을 더하고 부호가 다르면 절대값끼리 빼고 부호는 큰 쪽을 따를 것이며 실수의 경우 지수를 일치시킨 후 덧셈을 할 것입니다. 인수의 타입이 다르면 같은 이름으로 함수를 중복 정의할 수 있는 것처럼 연산자도 피연산자의 타입에 따라 중복 정의 할 수 있습니다. + 기호를 덧셈을 하는 함수의 이름이라고 했을 때 이 함수의 원형은 아마도 다음과 같이 오버로딩되어 있을 것입니다.

1
2
int +(intint);
double +(doubledouble);
cs

위 쪽 함수는 정수 끼리 더한 후 정수를 리턴하고 아래쪽 함수는 실수끼리 더한 후 실수를 리턴합니다. i1 + d1같이 정수와 실수를 섞어서 더할 경우는 컴파일러의 형변환 기능에 의해 i1이 실수로 상승변환된 후 실수끼리 덧셈을 하게 될 것입니다. 또 포인터와 정수의 덧셈도 산술적인 덧셈과 다르게 정의되어 있는데 이 연산도 일종의 오버로딩된 예라고 할 수 있습니다. 이에 비해 char * + (char *, char *)따위의 원형은 정의되어 있지 않으므로 문자열이나 포인터끼리는 더할 수 없습니다. 마찬가지로 포인터에 실수를 더할 수도 없는데 이런 동작을 처리할 수 있는 연산자가 존재하지 않기 떄문입니다.


기본형에 대해 연산자가 중복 정의되어 있는 것은 정말 다행스러운 일입니다. 피연산자의 타입에 따라 사용해야 하는 연산자가 달라진다면 얼마나 피곤할까요?  피연산자의 타입이 달라도 +라는 똑같은 모양의 연산자로 일관되게 덧셈 연산을 할 수 있는 것은 다형성의 예입니다. 정수든 실수든 더하고 싶으면 +연산자를 쓰기만 하면 됩니다. 그러나 연산자의 이런 중복 정의는 어디까지나 컴파일러가 기본적으로 제공하는 타입에 대해서만 적용되며 사용자가 직접 정의하는 타입인 클래스에 대해서는 이런 규칙이 적용되지 않습니다. 다음 서스는 복소수를 표현하는 Complex 클래스의 객체끼리 +연산자로 더합니다.

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
#include<iostream>
using namespace std;
 
class Complex
{
private:
    double real;
    double image;
public:
    Complex(){}
    Complex(double R, double I) :real(R), image(I){}
    ~Complex(){}
    void OutComplex() const
    {
        printf(" %.2f + %.2fi\n", real, image);
    }
};
 
void main()
{
    Complex C1(1.13.1);
    Complex C2(3.22.8);
    C1.OutComplex();
    C2.OutComplex();
 
    Complex C3;
    C3 = C1 + C2;//에러
    C3.OutComplex();
}
cs

이 상태로 컴파일해 보면 C3 = C1 + C2; 연산문에서 "Complex 클래스는 + 연산을 정의하지 않았다"는  에러가 발생합니다. C++은 언어 차원에서 복소수를 지원하지 않기 떄문에 Complex가 어떤 타입인지 알지 못하며 따라서 두 객체를 어떻게 더해야 하는지도 모르는 것입니다. 복소수끼리 더하는 방법을 모르니 +연산을 처리할 수가 없습니다. 사용자 정의 타입인 클래스의 객체끼리 더하는 방법은 클래스별로 고유하기 떄문에 클래스를 만든 사람이 덧셈 연산을 직접 정의할 필요가 있습니다.


C3 = C1 + C2; 연산문이 제대로 컴파일되려면 복소수에 대한 덧셈 연산자를 중복 정의해야합니다. 고등수학을 배웠다면 복소수 끼리 더할때 실수부 허수부를 각각 더해야 하는 것을 알지만 컴파일러는 이 방식을 모르기 떄문에 못 더하는 것입니다. 따라서 개발자가 컴파일러에게 복소수끼리 더하는 방법을 알려 줘야 하는데 이것을 연산자 오버로딩이라고 합니다. 새로 만들어지는 + 연산자는 아마도 다음과 같은 원형을 가질 것입니다.

1
Complex +(Complex, Complex);
cs

두 개의 Complex 객체를 인수로 취하고 그 합을 구해 Complex형으로 리턴합니다. 정수끼리 더할 때나 실수끼리 더할 때 사용하는 똑같은 + 연산자로 복소수끼리도 덧셈을 할 수 있도록 중복 정의하는 것이 바로 연산자 오버로딩입니다. 고정된 타입만 제공되는 C에서는 이런 기능이 그다지 필요하지 않았었습니다. 그러나 C++은 사용자가 타입을 정의할 수 있게 되었고 사용자가 만든 타입도 기본 타입과 같은 자격을 주기위해 연산 방법을 정의할 필요가 생겼습니다. 그래야 사용자가 정의한 타입이 컴파일러가 제공하는 기본 타입과 대등한 자격을 가지며 일관된 방법으로 사용할 수 있기 떄문입니다. 클래스가 완전한 타입이 되려면 int가 할 수 있는 모든 일을 할 수 있어야 합니다. 이 절의 주제가 바로 객체의 연산 방법을 정의하는 것이며 더 직관적으로 얘기 하자면 임의의 객체에 대해 A=B+C;가  가능하도록 하는 것입니다. 물론 + 뿐만 아니라 *나 ==, %등 대부분의 연산자도 오버로딩할 수 있습니다. 개념은 무척이나 간단하지만 복잡한 규칙이 존재하며 또한 많은 함정들이 도사리고 있습니다.


연산자 함수


포인터끼리 더하는 것이 의미가 없는 것처럼 하루 중의 한 시점을 가리키는 시각을 더하는 것은 사실 별 의미가 없습니다. 아침 9:00이고 점심이 12:30일 때 이 둘을 더한 21:30은 어떤 의미도 부여할 수 없습니다. 그러나 경과 시간끼리 더하는 것은 분명히 의미가 있는데 밥먹는데 40분, 커피 마시는데 25분이 걸린다면 이 둘을 더한 1:5는 밥먹고 커피를 마시는데 필요한 시간이라고 할 수 있습니다. 시간을 표현하는 Time이라는 클래스를 정의했다면 시간끼리 더할 수 있는 방법도 제공할 필요가 있는데 시간이란 과연 어떻게 더할 수 있을까요? 시간이라는 타입은 시, 분, 초의 세 가지 요소로 구성되어 적어도 int나 double 같은 기본 타이보다는 훨씬 더 복잡한 처리가 필요합니다. 초는 초 끼리 더하고 분은 분 끼리 더해야 하며 시는 시끼리 각각 더하되 각 자리에서 60이 넘는 결과가 나오면 자리 올림 처리를 해야합니다. 예를 들어 1:26:42초 라는 시간과 2:38:55라는 시간을 더하면 3:64:97가 되는 것이 아니라 4:5:37가 되어야 합니다.


사람은 시간이라는 포맷에 아주 익숙하고 일상생활에서 늘상 사용하므로 쉽게 연산할 수 있지만 컴퓨터는 이런 복잡한 타입의 연산 방법을 모릅니다. Time 클래스에 대해 덧셈을 하는 멤버 함수를 정의해 보겠습니다. 다음 소스의 addTime 멤버 함수는 또 다른 Time 객체 T를 인수로 전달받아 자기 자신과 더한 결과를 리턴합니다. 시간끼리 덧셈 연산을 하므로 이 동작을 잘 설명할 수 있는 AddTime이라는 이름을 주었습니다.

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
#include<iostream>
using namespace std;
 
class Time
{
private:
    int hour, min, sec;
public:
    Time(){}
    Time(int h, int m, int s)
    {
        hour = h;
        min = m;
        sec = s;
    }
    void OutTime()
    {
        printf("%d:%d:%d\n", hour, min, sec);
    }
    const Time AddTime(const Time &T) const
    {
        Time R;
        R.sec = sec + T.sec;
        R.min = min + T.min;
        R.hour = hour + T.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.AddTime(B);
    C.OutTime();
}
cs

더하는 방법은 비교적 간단한데 임시 객체 R을 선언한 후 자기 자신과 T의 시, 분, 초를 각각 더해 R의 대응되는 멤버에 대입하고 자리 올림을 합니다. 자리 올림을 나누기 연산자와 나머지 연산자를 적절히 활용하면 간단하게 처리할 수 있습니다. main 함수의 테스트 코드는 A와 B를 더해 C에 대입한 후 C를 출력해 봅니다.  결과는 다음과 같이 나옵니다.


아주 간단한 연산을 해 보았는데 1:36:42와 5:42:29처럼 조금 복잡해 보이는 시간끼리 더해도 자리올림까지 고려하여 7:19:11라는 정확한 연산을 합니다. Time 클래스가 시간 포맷에 대해 캡슐화를 잘 하고 있으며 AddTime이라는 이름의 멤버 함수를 정의함으로써 시간 객체끼리 더하는 방법을 컴파일러에게 알려 주었으므로 main에서는 AddTime 함수를 호출하여 A와 B를 더하기만 하면 됩니다. 동작상의 문제는 전혀 없지만 연산을 위해 함수를 호출하는 방식이 연산자를 쓰는 방법에 비해 직관적이지 못하며 기본형의 연산문과 모양이 다르다는 것도 불만입니다. 그래서 이런 동작을 하는 연산자 함수를 정의할 수 있습니다.


사실 연산자는 모양이 좀 특이한 함수라고 볼 수 있는데 인수를 취한다는 것과 연산 결과를 리턴한다는 점에서 함수와 공통적입니다. 연산자 함수의 이름은 키워드 operator 다음에 연산자 기호를 써서 작성하는데 연산자 기호를 명칭으로 쓸 수 없으므로 operator라는 키워드를 앞에 두는 것입니다. 덧셈 연산자 함수의 이름은 operator +가 되는데 중간의 공백은 무시되므로 operator+라고 붙여 써도 상관없습니다. 함수명은 명칭이므로 영문자, 숫자, _만 쓸 수 있찌만 연산자 함수의 이름은 예외적으로 기호를 사용할 수 있습니다. 연산자 자체가 기호로 되어 있으므로 여기에는 예외를 적용할 수밖에 없습니다. 위 예제에서 AddTime이라는 함수의 이름을 operator +로 바꿔 보겠습니다.

1
2
3
4
5
6
7
clas Time
{
    const Time operator +(const Time &T) const
    {
        ...
    }
};
cs

리턴값, 인수, 본체는 그대로 두고 AddTime이라는 이름만 operator +로 바꾼 것뿐입니다. 이렇게 연산자 함수를 정의하면 이 클래스 타입의 객체를 좌변으로 가지는 +연산자를 쓸 수 있습니다. main 함수의 AddTime 호출문도 다음과 같이 수정합니다.

1
2
3
4
5
6
7
8
9
10
11
void main()
{
    Time A(111);
    Time B(222);
    Time C;
 
    A.OutTime();
    B.OutTime();
    C = A + B;
    C.OutTime();
}
cs

C=A.AddTime(B)가 C=A+B로 바뀌었는데 함수의 본체 코드가 똑같으므로 동작도 완전히 동일합니다. AddTime이라는 함수의 이름이 operator +로 바뀌었고 함수를 호출하는 방법이 연산문으로 바뀌었을뿐입니다. C=A+B를 다음과 같이 작성해도 똑같이 동작합니다.

1
= A.operator +(B);        //C=A+B; 와 같다.
cs

C=A+B 연산문이고 C=A.operator +(B)는 함수 호출문의 형태를 띠고 있을 뿐 실행되는 코드는 둘 다 동일합니다. 표현만 다른 같은 구문입니다. A+B 연삼누에 중단점을 설정하고 디버거로 실행하여 함수안쪽을 파고 들어가 보면 이 연산문에 의해 operator + 함수가 호출된다는 것을 확인할 수 있습니다. 그렇다면 AddTime 일반 함수와 operator +연산자 함수는 과연 어떤 차이점이 있는지 비교해 보겠습니다.


첫 번째로 연산자 형태의 호출 방식이 길이가 짧아 타이핑하기 편하며 오타가 발생할 가능성도 극히 낮습니다. 몇 자 되지는 않지만 자주 사용하는 연산이라면 이 차이도 결코 무시할 수 없습니다. 두 번째로 연산자 함수는 호출 형식이 연산문 형태로 작성되기 때문에 훨씬 더 직관적이고 기본형의 연산 방법과 일치하므로 사용하기 쉽니다. A + B라는 표현식 자체가 A와 B를 더한다는 것을 잘 표현합니다. 물론 Add라는 영어 단더가 뭔가를 더한다는 의미하기는 하지만 +연산자보다 쉽지는 않습니다. Add는 영어지만 +는 모두 나 아는 기호입니다. 세 번째로 연산자는 함수와는 달리 우선 순위와 결합 방향의 개념이 있어 괄호를 쓰지 않아도 연산순서가 자동으로 적용되어 편리합니다. 어떤 객체 A와 B의 곱과 C와 D의 곱을 더해 E에 대입한다고 해보겠습니다.

1
2
일반   함수 : E = (A.Multi(B)).Add(C.Multi(D));
연산자 함수 : E = * B + C * D;
cs

어느쪽이 더 보기도 읽기도 좋은지는 물을 필요도 없습니다. 일반 함수는 호출 순서를 괄호로 분명히 명시해야 하므로 식을 작성하는 프로그래머들도 골치 아프고 이 식을 읽는 사람은 더 골치아픕니다. 자연어로 표현하면 "A와 B를 곱하고 C와 D를 곱하고 두 곱셈 결과를 더해 E에 대입한다"가 되어 훨씬 더 복잡해집니다. 이런 복잡한 동작을 E=A*B+C*D로 간략하게 표기할 수 있으므로 한마디로 가독성의 차이가 엄청나며 이 차이로 인해 유지 보수 비용의 규모가 달라집니다. 그래서 C++은 문법이 복잡해지는 대가를 치루더라도 객체에 대한 연산자 오버로딩을 지원하는 것이며 우리는 이것을 애써 배우고 적극적으로 활용해야합니다. 물론 이런 연산자 함수를 일일이 정의한다는 것은 상당히 번거로운 일이며 또 정확하게 작성하기 위해 알아야 할 것도 많습니다. 하지만 OOP의 철학은 소수의 객체 작성자에게 편리함을 주는 것보다 무수히 많은 사용자들을 편하게 하는 쪽에 치중되어 있음을 생각해 본다면 연산자 오버로딩은 진정으로 사용자를 위한 기능임이 분명합니다.


연산자 함수의 형식


클래스의 연산자 함수를 정의하는 방법은 두 가지가 있습니다.

  1. 클래스의 멤버 함수로 작성합니다.
  2. 전역 함수로 작성합니다.
우선 상대적으로 좀 더 간단한 멤버 연산자 함수를 작성하는 형식부터 알아보겠습니다. 전역 함수로 작성하는 방법에 대해서는 다음 절에서 상세하게 알아볼 것입니다. 멤버 연산자 함수의 기본 형식은 다음과 같습니다.
1
2
3
4
리턴타입 class::operator 연산자(인수 목록)
{
    함수 본체;
}
cs
일반적인 멤버 함수 선언문과 동일하되 함수 이름이 키워드 operator와 연산자로 구성되어 있다는 점만 다릅니다. 연산자 자리에는 +, -, *, /, <<, !=, >>등 대부분의 연산자 기호가 올 수 있습니다. 이 형식대로 앞 항에서 작성한 ComplexAdd 예제의 Complex 클래스에 덧셈 연산자를 추가해 보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include<iostream>
using namespace std;
class Complex
{
private:
    double real;
    double image;
public:
    Complex();
    Complex(double R, double I) :real(R), image(I){}
    void OutComplex()
    {
        printf("%.2f + %.2fi\n", real, image);
    }
    const Complex operator +(const Complex &T) const
    {
        Complex R;
        R.image = image + T.image;
        R.real = real + T.real;
        return R;
    }
};
cs

임시 객체 R을 선언하고 R에 덧셈 결과를 작성하되 허수부와 실수부를 각각 따로 더했습니다. 이 연산자가 정의되면 이제 Complex 객체에 대해 + 연산자로 간편하게 덧셈을 할 수 있으며 Complex가 기본형과 비숫한 자격을 가지게 됩니다. 실행결과는 다음과 같습니다.


C3 = C1 + C2 연산문에 의해 두 복소수가 제대로 더해졌습니다. 멤버 연산자 함수의 원형이 다소 복잡한데 이 원형을 간략하게 분석해 보면 다음과 같습니다.

1
2
3
4
5
6
const Complex operator +(const Complex &T) const
 
const Complex            //리턴 타입
operator +                //함수의 이름
const Complex &T        //인수 = 피연산자
const                        //상수 함수
cs


클래스 선언문 내부의 인라인 함수로 정의했기 떄문에 함수명 앞에 소속 클래스에 대한 표기(Complex::)는 빠져 있는데 외부에서 정의한다면 Complex::operator + 등으로 소속 클래스 이름도 밝혀야 합니다. 이 예를 통해 멤버 연산자 함수의 각 요소에 대해 상세하게 연구해 보겠습니다. 각각의 const 키워드가 가지는 의미, 레퍼런스를 넘기는 이유, 값을 리턴하는 이유 등이 나름대로 복작합니다.


::인수의 타입


연산자 함수의 인수란 피 연산자를 의미하는데 함수를 호출하는 자기 자신(this)과 함수로 전달되는 인수가 연산 대상입니다. 이항 연산자의 경우 멤버 연산자 함수를 호출하는 객체가 좌변이 되고 인수로 전달되는 대상이 우변이 됩니다.

1
 A + B = A.operator +(B)
cs

원칙적으로 연산자 함수의 인수는 임의의 타입을 모두 받아들일 수 있지만 논리적으로 객체와 연산 가능한 대상이어야 합니다. Complex 객체의 경우 다른 Complex 객체나 실수 또는 정수형이 피연산 대상이 될 수 있습니다. 복소수를  복소수와 덧셈하는 것은 논리적으로 합당하지만 복소수에 시간을 더하거나 Person, Position 따위의 전혀 관련없는 객체를 더하는 것은 별 의미가 없습니다. 자신과 같은 타입의 다른 객체인 경우가 가장 보편적이고 가끔 호환되는 타입과 연산하기도 합니다. 객체는 값으로 넘길 수도 있지만 아무래도 기본형보다는 덩치가 크기 때문에 값으로 넘기면 비효율적이므로 레퍼런스로 넘기는 것이 유리합니다. 인수 T 앞에 &기호를 빼고 값으로 넘겨도 동작에는 별 이상이 없지만 객체가 커지면 다소 느릴 것입니다. 포인터를 넘기는 것도 연산자 함수가 피연산자 대상을 읽을 수 있으므로 일단 가능은 합니다. 위 예제의 +연산자를 다음과 같이 Complex *를 받도록 수정해 보겠습니다.

1
2
3
4
5
6
7
8
const Complex operator +(const Complex *T) const
{
    Complex R;
    R.image = image + T->image;
    R.real = real + T->real;
    return R;
}
 
cs

포인터로 넘겨진 피연산자의 멤버를 참조하려면 . 연산자 대신 ->연산자를 사용하기만 하면 됩니다. 그러나 연산자 함수가 포인터를 받아들이면 이 함수를 호출할 때 피연산자의 주소를 넘겨야 하므로 호출부의 모양이 C3=C1.operator +(&C2);가 될 것이고 이를 연산식으로 표현하면 C3=C1+C2;가 되는데 이런 형식은 연산문의 일반적인 표기법에 어긋나며 전혀 직관적이지 못합니다. 정수형의 경우 i = j + &k;로 연산하지 않는 것과 마찬가지입니다.


연산자 오버로딩의 목적은 객체의 연산문을 기본형과 같은 방법으로 표현함으로 표현함으로써 사독성을 높이고 클래스의 직관적인 활용성을 향상시키는 것인데 이런 식으로 매번 &연산자를 사용해야한다면 차라리 AddComplex 따위의 일반 함수를 쓰는 편이 더 나을 것입니다. 연산자 함수로 피연산자를 넘기는 방법은 사실 세 가지 모두 가능합니다. 값으로 넘기는 방법은 객체가 커지면 효율이 좋지 못하다는 문제가 있고 포인터로 넘기는 방법은 효율은 좋지만 호출 구문이 요상해집니다. 레퍼런스로 넘기면 효율과 직관적인 표기라는 두 마리 토끼를 다 잡을 수 있습니다. C++이 레퍼런스 타입을 지원하는 주된 이유 중의 하나가 객체 연산식의 직관적인 표현을 위해서입니다.


::인수의 상수성


피연산자로 전달된 인수를 보통 읽기만 합니다. a+b, a*b, a>>b, a[b], a->b등 우리가 알고 있는 모든 이항 연산자를 관찰해 보면 인수로 전되는 우변의 값을 변경하는 경우는 전혀 없으며 단지 연산할 값을 얻기 위해 읽기만 합니다. 그래서 연산자 함수로 전달되는 인수는 읽기 전용의 const로 받는 것이 좋습니다. 연산자 함수로 객체의 레퍼런스를 전달할 때 이 함수가 객체의 상태를 함부로 변경하지 못하도록 하기 위해 const 지정자를 붙이는 것이 안전합니다. 만약 레퍼런스로 전달되는 T가 const가 아니라면 operator + 함수 내부에서 T.real=12.34;로  실인수를 마음대로 바꿔 버릴 수도 있습니다. 이항 연산자의 피연자는 연산의 재료일 뿐이지 연산 대상이 아니므로 이는 분명히 잘못된 연산입니다. 또한 다음과 같은 연산문도 불가능해집니다.

1
2
const Complex C2(1.02.0);
C3 = C1 + C2;
cs

상수 객체도 피 연산자로 사용할 수 있어야 하는데 인수가 상수가 아니라면 에러로 처리될 것입니다. 정수연산에서 a=b+3;이 허용되므로 복소수 연산에서도 상수 객체를 피연자로 쓸 수 있어야합니다. 물론 강제 사항은 아니므로 필요에 따라 인수의 상수성을 선택할 수 있겠지만 제대로 된 연산자라면 피연산자를 변경하지 말아야 합니다. 직관적인 연산식 표현을 위해 포인터는 안 된다고 했으므로 Complex 객체를 인수로 전달받는 operator + 의 경우 다음 4가지 형식의 인수를 받아들일 수 있습니다.

1
2
3
4
Complex
Complex &
const Complex
const Complex &
cs

이 중 4번 째 줄 형식이 가장 바람직합니다. 레퍼런스를 넘기므로 빠르고 const 지정을 했으므로 안전하기도 합니다. 객체의 크기가 아주 작아 굳이 레퍼런스를 쓸 필요가 없다면 첫 번째 줄 형식이 가장 간단합니다. 값으로 넘길 경우는 어차피 사본이 전달되므로 세 번째 줄 형식처럼 값에 대해 const 지정자를 붙이는 것은 사실 별 실용성이 없습니다.


::함수의 상수성


Complex의 operator + 연산자가 const 함수로 지정되어 있는데 멤버 연산자 함수가 호출 객체의 상태를 바꾸지 않을 경우는 원칙에 따라 const 함수로 지정하는 것이 좋습니다. 그래야 함수 내부에서 부주의하게 호출 객체를 변경하는 사고를 방지할 수 있습니다. 덧셈, 뺄셈, 곱셈 등의 통상적인 이항 연산자들은 객체의 값을 읽기만 할 뿐 객체를 변경하지 않습니다. 만약 연산자 함수가 상수성을 가지지 않으면 상우 객체에 대해서는 연산을 할 수 없을 것입니다. 다음 코드를 보겠습니다.

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

이 연산이 가능하기 위해서는 +연산자가 상수 i의 값을 바꾸지 않는다는 보장이 있어야 합니다. 반면 객체의 값을 직접 변경하는 연산자는 const로 지정해서는 안됩니다. 이런 연산자에는 대표적으로 대입 연산자가 있고 증감 연산자, 복합 대입 연산자도 const가 될 수 없는 연산자입니다. 같은 타입의 다른 객체를 대입받아 객체의 값을 변경하는 = 연산자가 const 라면 말이 안 됩니다.


::임시 객체의 사용


위 소스에서 operator +연산자 본체를 보면 Complex형의 임시 객체 R을 선언하고 호출 객체와 피연사자 T를 더한 결과를 R에 작성한 후 임시 객체 R을 리턴하고 있습니다. 이 연산에 사용된 임시 객체 R은 호출 객체와 피연산자의 값을 변경하지 않고 연산 결과를 잠시 저장하기 위한 용도로 사용되는 것입니다. 만약 임시 객체를 쓰지 않고 다음과 같이 함수를 작성했다고 해 보겠습니다.

1
2
3
4
5
6
const Complex operator +(const Complex &T) 
{
    image = image + T.image;
    real = real + T.real;
    reutnr *this;
}
cs

호출 객체인 this의 멤버를 직접 변경하고 *this 자체를 리턴했습니다. 이렇게 수정한 후 컴파일해 보면 별 이상없이 잘 동작하는 것처럼 보입니다. 그러나 테스트 코드의 끝에 C1.OutComplex();로 C1값을 확인 해 보면 원래 값인 1.1 + 2.2i를 그대로 가지고 있지 않으며 C3과 같은 값이 되어 있을 것입니다. + 연산자의 좌변 객체가 변경되어 버리므로 자세히 따져 보면 본래의 + 연산과는 다른 연산(+=)이 되어 버린다. 이 상황을 좀 더 이해하기 쉬운 정수형 연산을 예로 설명해 보겠습니다.

1
2
int a = 1, b = 2, c;
= a + b;
cs

이 코드의 결과 c에는 3이 대입될 것이고 a와 b는 원래의 값을 그대로 유지해야 하므로 a는 1, b는  2가 되는 것이 옳습니다. a가 b의 값을 더한 값으로 변경된 후 그 결과가 c에 대입되는 것이 아니라 두 피연산자의 값만 읽어 덧셈을 한 후 그 결과값을 c로 대입해야 합니다. 이때의 결과값을 잠시 가지기 위해 정수형 임시 변수가 필요합니다. 그래서 Complex의 operator +도 이 요구에 맞추기 위해 호출 객체를 것들이지 말아야 하며 따라서 이 함수는 const가 되어야 하는 것입니다. 그러다 보니 연산 결과를 저장할 임시 객체가 필요하며 이 함수는 임시 객체에 연산을 한 후 그 객체를 리턴하는 형식으로 작성해야 합니다. 호출 측에서는 연산 결과 리턴되는값을 같은 타입의 다른 객체에 즉시 대입해야 합니다. 대입되지 않으면 이 값은 버려집니다.



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

C++ - 연산자 오버로딩(3/3)  (0) 2015.09.21
C++ - 연산자 오버로딩(2/3)  (0) 2015.09.20
C++ - 캡슐화(3/3)  (0) 2015.09.19
C++ - 캡슐화(2/3)  (0) 2015.09.19
C++ - 캡슐화(1/3)  (0) 2015.09.19