본문 바로가기

프로그래밍 언어들/C++

CHAPTER10 - 연산자 오버로딩 1

C++에서는 함수뿐만 아니라 '연산자도 오버로딩이 가능'하다. 연산자의 오버로딩이 조금 생소하게 느껴질 수 있으나

기본 개념이 매우 단순하니 어렵지 않을 것이다. 참고로 연산자 오버로딩은 C++을 이해하는데 매우 중요한 요소이다.


1. 연산자 오버로딩의 이해와 유형

함수가 오버로딩 되면, 그 수만큼 다양한 기능을 제공하게 된다. 마찬가지로 연산자의 오버로딩을 통해서,

기존에 존재하던 연산자의 기본 기능 이외에 다른 기능을 추가할 수 있다.


1.1 operator+라는 이름의 함수

다음 예제를 이해한다면, 연산자 오버로딩은 쉽게 정복이 가능하다.

class Point
{
private:
    int xpos;
    int ypos;
public:
    Point(const int &x, const int &y) : xpos(x), ypos(y) {}
    void showposition(void) 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;
}
3, 4
10, 20
13, 24


함수의 이름이 조금 독특하다고 생각해도 좋다. 이름의 형태가 조금 어색하지만 이는 함수의 이름이다.

그럼 이어서 위에서 정의한 클래스르 대상으로 main 함수만 변경해서 다시 한 번 실행해 보겠다.


class Point
{
   // 위의 내용과 같다.
};

int main(void)
{
    Point pos1(3, 4);
    Point pos2(10, 20);
    Point pos3 = pos1+pos2;

    pos1.showposition();
    pos2.showposition();
    pos3.showposition();

    return 0;
}
3, 4
10, 20
13, 24


바뀐 부분을 보면, pos1과 pos2를 더한 결과를 pos3에 저장하라는 의미로 해석할 것이다.

> Point pos3 = pos1 + pos2;

그렇다면 이러한 문장이 컴파일 가능할까? 실행결과에서 보이듯이 컴파일이 가능할 뿐만 아니라,

우리의 기대대로 pos3 객체에는 pos1과 pos2의 연산결과가 저장되었다.

혹시 지금 설명만으로 다음과 같은 사실을 발견했다면,

"아마도 pos1 + pos2는 pos1.operator+(pos2)의 다른 표현이다."

그리고 다음과 같은 생각을 했다면,

"pos1 + pos2가 pos1.operator+(pos2)의 다른 표현이 되기 위해서 약속된 변환의 규칙이 있을 것 같다."

이미 연산자 오버로딩을 50% 정도 이해한 셈이나 다름 없다.


Point pos3 = pos1+pos2;
Point pos3 = pos1.operator+(pos2);


즉, '처음 문장이 두 번째 문장으로 해석되어서 컴파일' 된다. 참고로 이는 멤버함수에 의한 연산자 오버로딩의 한 예이다.

※ 연산자 오버로딩 함수도 const로 선언이 가능하다. 그 이유는 덧셈연산이라는 것이 피연산자의 값을 변경하는 것이 아니고,

새로운 결과를 만들어내는 것이기 때문이다.


1.2 연산자를 오버로딩하는 두 가지 방법

연산자를 오버로딩하는 방법에는 다음 두 가지가 있다.

- 멤버함수에 의한 연산자 오버로딩

- 전역함수에 의한 연산자 오버로딩

앞서 보인 예제는 '+ 연산자'를 멤버함수를 이용해서 오버로딩 하였다. 그런데 + 연산자는 전역함수를 이용해서도

오버로딩이 가능하다.

참고로, 동일한 자료형을 대상으로 + 연산자를 전역함수 기반으로, 그리고 멤버함수 기반으로 동시에 오버로딩할 경우,

멤버함수 기반으로 오버로딩된 함수가 우선시 되어 호출된다. 단, 일부 오래된 컴파일러는 이러한 상황에서

컴파일 에러를 발생시키기도 하니, 이러한 상황은 가급적 만들지 않는게 좋다.

class Point
{
private:
    int xpos;
    int ypos;
public:
    Point(const int &x, const int &y) : xpos(x), ypos(y) {}
    void showposition(void) const
    {
        cout << xpos << ", " << ypos << endl;
    }
    friend Point operator+(const Point&, const Point&);
};

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;
}
3, 4
10, 20
13, 24


위의 예제는 전역함수 기반의 연산자 오버로딩에 대한 일반적인 모델을 보여주고 있다. 특히 'friend' 선언이

적절이 사용된 예를 보이고 있다.

이렇듯 Point 클래스에 삽입된 friend 선언으로 인해서, 이 클래스는 + 연산에 대해서 오버로딩이 되어 있다는 정보를

쉽게 확인할 수 있다. 사실 객체지향에는 '전역(global)'에 대한 개념이 존재하지 않는다.

다만 C++은 C 스타일의 코드구현이 가능한 언어이기 때문에 전역에 대한 개념이 존재한다.

따라서 특별한 경우가 아니면, 멤버함수를 기반으로 연산자를 오버로딩 하는게 낫다.

1.3 오버로딩이 불가능한 연산자의 종류

C++의 모든 연산자들이 오버로딩의 대상이 되는 것은 아니다. 다음과 같이 오버로딩이 불가능한 연산자들도 있다.

. 멤버 접근 연산자
.* 멤버 포인터 연산자
:: 범위 지정 연산자
?: 조건 연산자(3항 연산자)
sizeof 바이트 단위 크기 계산
typeid RTTI 관련 연산자
static_cast 형변환 연산자
dynamic_cast 형변환 연산자
const_cast 형변환 연산자
reinterpret_cast 형변환 연산자


이들 연산자에 대해서 오버로딩을 제한하는 이유는  C++의 문법규칙을 보존하기 위해서다. 만약 위의

연산자들까지 오버로딩을 허용해 버린다면, C++의 문법규칙에 어긋나는 문장의 구성이 가능해지고, 혼란스러운 언어가 된다.

다음으로, 멤버함수 기반으로만 오버로딩이 가능한 연산자를 소개하겠다.

= 대입 연산자
() 함수 호출 연산자
[] 배열 접근 연산자(인덱스 연산자)
-> 멤버 접근을 위한 포인터 연산자


이들은 객체를 대상으로 진행해야 의미가 통하는 연산자들이기 때문에, 멤버함수 기반으로만 연산자 오버로딩을 허용한다.


1.4 연산자 오버로딩의 주의사항

- 본래의 의도를 벗어난 형태의 연산자 오버로딩은 좋지 않다.

- 연산자 우선순위와 결합성을 바뀌지 않는다.

- 매개변수의 디폴트 값 설정이 불가능하다.

- 연산자의 순수 기능까지 빼앗을 수는 없다.


1.5 연산자 오버로딩이라 이름이 붙은 이유

int num = 3 + 4;
Point pos3 = pos1 + pos2;


함수가 오버로딩되면 전달되는 인자에 따라서 호출되는 함수가 달라진다. 이와 유사하게, 위의 두 문장에서 보이듯이

연산자가 오버로딩되면, 피연산자의 종류에 따라서 연산의 방식이 달라진다. 그래서 연산자 오버로딩이라 불린다.


2. 단항 연산자의 오버로딩

피연산자가 두 개인 이항 연산자와 피연산자가 한 개인 단항 연산자의 가장 큰 차이점은 '피연산자의 개수'이다.

그리고 이에 따른 연산자 오버로딩의 차이점은 매개변수의 개수에서 발견된다.

대표적인 단항 연산자로는 다음 두 가지가 있다.

- 1 증가 연산자 ++

- 1 감소 연산자 --


이에 ++ 연산자가 오버로딩 되어있다고 가정해보자. 이러한 경우 다음의 형태로 문장 구성이 가능하다.

=> ++pos; // pos는 객체


멤버함수의 형태로 오버로딩된 경우의 해석방법을 고민해보자. 그런데 멤버함수의 형태라면

pos의 멤버함수가 호출되는 형태이니, 다음과같이 해석되어야 한다.

=> pos.operator++();


인자가 없는 이유는 단항 연산자를 오버로딩 했기 때문이다. 그럼 이번에는 전역함수의 형태로 오버로딩 된 경우의

해석 방법을 고민해보자. 그런데 전역함수의 형태라면 'operator++가 전역함수의 이름'이 되므로 다음과 같다.

=> operator++(pos);

++pos;
// pos.operator++(); if it's declared as Point's member
// operator++(pos); if it's declared as the global function


또한, '참조 형태'를 이용하여 다음과 같이 동작이 가능하다.

class Point
{
private:
    int xpos;
    int ypos;
public:
    Point(const int &x, const int &y) : xpos(x), ypos(y) {}
    void showposition(void) const
    {
        cout << xpos << ", " << ypos << endl;
    }
    Point& operator++(void)
    {
        xpos    += 1;
        ypos    += 1;

        return *this;
    }

    friend Point& operator--(Point&);
};

Point& operator--(Point& ref)
{
    ref.xpos -= 1;
    ref.ypos -= 1;

    return ref;
}

int main(void)
{
    Point pos(10, 10);

    ++(++pos);
    --(--pos);

    return 0;
}


'++(++pos);'는 먼저 소괄호 부분이 다음의 형태로 해석이 되어서 실행이 된다.

=> ++(pos.operator++());
실행의 결과로 pos의 참조 값이 반환되므로, 다음의 형태가 된다.

=> ++(pos의 참조 값);
그리고 이 문장은 다음과 같이 어어서 해석이 된다.

=> (pos의 참조 값).operator++();


'--(--pos);'는 전역함수로 기반으로 오버로딩 되었다. 따라서 순서는 다음과 같다.

=> --(operator--(pos));

=> --(pos의 참조 값);

=> operator--(pos의 참조 값);


2.1 전위증가와 후위증가의 구분

오버로딩 한 연산자는 전위연산에 해당할까, 후위연산에 해당할까? 오버로딩 한 내용만 봐도,

오버로딩 한 연산자를 호출하는 형태만 봐도, 전위증가 및 감소 연산에 해당한다는 사실을 알 수 있다.

그렇다면 후위증가 및 감소 연산에 대한 연산자 오버로딩은 어떻게 해야 할까?

C++에서는 전위 및 후위 연산에 대한 해석 방식에 대해 다음의 규칙을 정해놓고 있다.

++pos -> pos.operator++();
pos++ -> pos.operator++(int);

--pos -> pos.operator--();
pos-- -> pos.operator--(int);


키워드 'int'를 이용해서 후위연산에 대한 함수를 전위연산에 대한 함수와 구분하고 있다. 키워드는 단지

후위연산을 구분하기 위한 목적으로 선택된 것일 뿐, int형 데이터를 인자로 전달하라는 뜻과 아무 상관이 없다.


다음 예제는 전위 및 후위 연산의 연산자 오버로딩의 예제이다.

class Point
{
private:
    int xpos;
    int ypos;
public:
    Point(const int &x, const int &y) : xpos(x), ypos(y) {}
    void showposition(void) const
    {
        cout << xpos << ", " << ypos << endl;
    }
    Point& operator++(void)
    {
        xpos    += 1;
        ypos    += 1;

        return *this;
    }
    const Point& operator++(int)
    {
        const Point retobj(xpos, ypos);

        xpos    += 1;
        ypos    += 1;

        return retobj;
    }
    friend Point& operator--(Point &ref);
    friend const Point& operator--(Point &ref, int);

};

Point& operator--(Point &ref)
{
    ref.xpos    -= 1;
    ref.ypos    -= 1;

    return ref;
}
const Point& operator--(Point& ref, int)
{
    const Point retobj(ref.xpos, ref.ypos);

    ref.xpos    -= 1;
    ref.ypos    -= 1;

    return retobj;
}


2.2 반환형에서의 const 선언과 const 객체

위 예제에서 다음 두 함수를 조금 더 관찰해보자.

const Point& operator++(int);
const Point& operator--(Point& ref, int);


후위 연산자를 오버로딩한 함수들인데, 반환형이 'const'로 선언되었다. 이유가 무엇인지 알겠는가?

"반환의 대상이 되는 retobj 객체가 const로 선언되어 있어서 그런것 아닌가?"

"아니다." retobj 객체가 반환되면, 반환의 과정에서 새로운 객체가 생성되기 때문에, retobj 객체의 const 선언 유무는

retobj 객체의 반환에 아무런 영향을 미치지 않는다.

이것이 의미하는 바는 다음과 같다.

"함수의 반환으로 인해서 생성되는 임시객체를 const 객체로 생성하겠다!"

즉, 반환되는 임시객체를 상수화해서 값의 변경을 허용하지 않겠다는 뜻이다.

또한, const 객체를 대상으로는 'const로 선언된 함수'만 호출이 가능하다. 이는 const 함수의 특성과 같다.

"const 함수 내에서는 const 함수의 호출만 허용한다."

그리고 이러한 const 객체를 대상으로 참조자를 선언할 때에는 참조자도 'const'로 선언해야 한다.

    const Point pos1(10, 10);
    const Point &pos2 = pos1;


즉, 앞의 두 함수에서 생성되는 임시객체는 const 선언으로 인해서 값을 변경을 허용하지 않는 상수형 객체가 된다.

따라서 이 객체를 대상으로는 const로 선언되지 않은 멤버함수의 호출이 불가능하다.

때문에 다음의 문장 구성은 불가능하다.

int main(void)
{
    Point pos(3, 5);
    (pos++)++; // compile error
    (pos--)--; // compile errer

    return 0;
}


컴파일 에러를 발생시키는 두 문장의 일차적인 실행결과는 다음과 같다.

=> (Point형 const 임시객체)++;

=> (Point형 const 임시객체)--;
그리고 이 두 문장은 다음과 같이 해석된다.

=> (Point형 const 임시객체).operator++();

=> operator--(Point형 const 임시객체);


여기서 'operator++' 멤버함수는 const로 선언된 함수가 아니기 때문에 호출이 불가능해서 컴파일 에러가 발생한다.

그리고 'operator--' 전역함수의 경우는 매개변수로 참조자가 선언되었는데, 이 참조자가 const로 선언되지 않았기 때문

컴파일 에러가 발생하는 것이다.

이는 다음을 허용하지 않는 C++의 연산 특성을 그대로 반영하기 위함이다.

int main(void)
{
    int num = 100;

    (num++)++;
    (num--)--;
    return 0;
}


3. 교환법칙 문제의 해결

교환법칙이란 'A+B의 결과는 B+A의 결과와 같음'을 뜻한다. 대표적으로 교환법칙이 성립하는 연산으로는

곱셈연산과 덧셈연산이 있다.


3.1 자료형이 다른 두 피연산자를 대상으로 하는 연산

연산자 오버로딩을 통해 서로 다른 자료형의 두 데이터간의 연산을 가능하게 할 수 있다.

기본적으로 연산에 사용되는 두 피연산자의 자료형은 일치해야 한다. 일치하지 않으면, 형 변환의 규칙에 따라서

변환이 진행된 다음에 연산이 이뤄져야 한다. 그러나 연산자 오버로딩을 이용하면, 이러한 연산 규칙에 예외를 둘 수 있다.

class Point
{
private:
    int xpos;
    int ypos;
public:
    Point(const int &x, const int &y) : xpos(x), ypos(y) {}
    void showposition(void) const
    {
        cout << xpos << ", " << ypos << endl;
    }
    Point operator*(int times)
    {
        Point pos(xpos*times, ypos*times);
        return pos;
    }
};

int main(void)
{
    Point pos1(10, 10);
    Point pos2 = pos1 * 3;

    pos2.showposition();
    return 0;
}
30, 30


그러나 위 예제에서, 다음의 형태로 문장을 구성하면 연산이 불가능하다.

=> Point pos2 = 3 * pos1;
위의 문장은 '3.operator*(pos1)'로 해석이 되기 때문이다. 멤버함수의 형태로 오버로딩이 되면,

멤버함수가 정의된 클래스의 객체가 오버로딩 된 연산자의 왼편에 와야 하기 때문이다.


3.2 교환법칙의 성립을 위한 구현

그럼 이제 교환법칙이 성립하도록 예제를 확장해보자. 일단 다음의 곱셈연산이 가능 하려면,

=> pos2 = 3 * pos1;

전역함수의 형태로 곱셈 연산자를 오버로딩 하는 수 밖에 없다. 즉, 위의 문장이 다음과 같이 해석이 되도록

연산자를 오버로딩 해야 한다.

=> ops2 = operator*(3, pos1);

class Point
{
private:
    int xpos;
    int ypos;
public:
    Point(const int &x, const int &y) : xpos(x), ypos(y) {}
    void showposition(void) const
    {
        cout << xpos << ", " << ypos << endl;
    }
    Point operator*(int times) const
    {
        Point pos(xpos*times, ypos*times);
        return pos;
    }
    friend Point operator*(int, const Point&);
};
Point operator*(int times, const Point& ref)
{
    Point pos(ref.xpos * times, ref.ypos * times);
    // return ref * times;
    return pos;
}
int main(void)
{
    Point pos1(10, 10);
    Point pos2  = pos1 * 3;
    Point pos3  = 3 * pos1;

    pos2.showposition();
    pos3.showposition();
    return 0;
}
30, 30
30, 30


위 예제를 보면, 두 가지 방식으로 가능하다. 첫 번째는 값을 연산하는 함수를 정의하는 것이고,

두 번째로는 '3 * pos'를 'pos * 3'으로 바꾸는 형태로 오버로딩 하는 것이다.(주석 부분)


4. cout, cin 그리고 endl의 정체


4.1 cout과 endl 이해하기

namespace mystd
{
    using namespace std;

    class ostream
    {
    public:
        void operator<<(char *str)
        {
            printf("%s", str);
        }
        void operator<<(char ch)
        {
            printf("%c", ch);
        }
        void operator<<(int num)
        {
            printf("%d", num);
        }
        //........//
        void operator<<(ostream& (*fp)(ostream &ostm))
        {
            fp(*this);
        }
    };

    ostream& endl(ostream &ostm)
    {
        ostm<<'\n';
        fflush(stdout);
        return ostm;
    }

    ostream cout;
}
int main(void)
{
    using mystd::cout;
    using mystd::endl;

    cout<<"Simple string";
    cout<<endl;
    cout<<34;
    cout<<endl;

    return 0;
}


이미 한 번씩 언급했던 내용이므로, 스스로 이해할 수 있을 것이다.

즉, 메인 함수의 cout 호출 부분은 다음과 같이 해석된다.

    cout.operator<<("Simple String");
    cout.operator<<(endl);
    cout.operator<<(34);


그런데 위의 예제는 한가지 문제점을 지니고 있다. 다음 형태의 문장은 컴파일 오류를 발생시킨다.

=> cout<<123<<endl<<"cherr up"<<endl;
<< 연산자는 왼쪽에서 오른쪽으로 진행이 된다. 따라서 다음 문장은 다음의 순서로 연산이 이뤄진다.

=> ( ( ( ( cout<<123 ) << endl ) << "cherr up") << endl );

즉, 모든 << 연산의 결과로는 cout이 반환되어야 한다. 그래야 연이은 << 연산을 진핸할 수 있다.

ostream& operator<<(char *str)
{
    printf("%s", str);
    return *this;
}
// ......... //


출처 : 윤성우 열혈 C++ 프로그래밍