read

스물세번째 열혈강의


impossible

이전 포스팅에 이어서 연산자 오버로딩이 불가능한 연산자를 알아보자.

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

이 연산자의 오버로딩이 제한되는 이유는 C++ 문법규칙 보존을 위해서다. 이 연산자들이 오버로딩이 되버리면 C++ 문법규칙에 어긋나는 문장이 가능해지고 혼란스러워 지기 때문이다. 그리고 위 연산자들을 오버로딩할 상황이 거의 없기 때문에 아마 쓸 일도 거의 없을 것이다.

그럼 멤버함수 기반으로만 오버로딩이 가능한 연산자 도 알아보자.

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

이는 객체를 대상으로 진행해야 의미가 통하기 때문에, 멤버함수 기반으로만 가능하다.


주의사항


아직 + 연산자에 대해서만 오버로딩을 했지만, 이제 다양한 연산자들을 오버로딩 해볼 것인데 여기서 주의할 점이 있다.

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

연산자 오버로딩은 잘못 적용하면 매우 복잡하고 이해하기 어렵게 된다. 한가지 예시를 보자.

pos1 + pos2

이 문장은 일반적으로 pos1과 pos2의 멤버 별로 덧셈하고 그 결과를 객체로 반환이라고 생각한다. 그런데 만약 하나의 값이 증가가 된다던지, 연산의 결과가 pos2의 값을 바뀐다던지 하면 코드 이해도는 급격히 떨어질 수 밖에 없다.

  1. 연산자의 우선순위와 결합성은 바뀌지 않는다.

연산자의 연산 기능은 오버로딩이 되더라도, 연산자가 지니는 우선순위와 결합성은 그대로 따르게 되어 있다. 만약 이 것이 바뀌게 되면 위와 같이 엄청난 혼란을 줄 수 있다.

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

피연산자의 자료형에 따라 연산자를 오버로딩 한 함수의 호출이 결정되는데, 매개변수의 디폴트 값을 설정한다 함은 말이 되지 않는다. 만약 된다면 호출관계가 정확하게 알 수가 없다.

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

예시를 보자.

int operator+(const int num1, const int num2){
	return num1 * num2;
}

아마 이상한 점은 바로 보일 것이다.

이미 +연산은 자료형에 따라 정해져 있다. 그러므로 위와 같은 함수의 정의는 허용이 되지 않는다.

여기까지 봤을 때, 연산자 오버로딩이란 말의 이유가 머릿속에 있어야 한다.

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

함수가 오버로딩 되면 전달되는 인자에 따라 호출되는 함수가 달라진다. 이와 같이 위 두 문장에서 연산자가 오버로딩이 되면 피연산자에 따라 연산 방식이 달라진다. 따라서 연산자 오버로딩이라 한다.


단항 연산자 오버로딩


단항 연산자와 이항 연산자의 가장 큰 차이점은 피연산자의 갯수이다.

따라서 이에 따라 연산자 오버로딩의 차이점은 파라미터의 갯수에서 발견된다.

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

  • 1 증가 연산자
    • ++
  • 1 감소 연산자
    • --

여기에 이전에 예제로 했던 Point 클래스에 오버로딩이 되어 있다고 생각해보자.

++pos

그럼 이 문장은 어떻게 해석을 할 수 있을까?

호출되는 함수의 이름은 ++연산자와 키워드 operator를 연결해 완성되므로 operator++ 이다.

그럼 함수의 이름을 알게 되었으니, 멤버함수의 형태로 오버로딩 된 경우의 해석방법을 고민해보자. 멤버함수이므로 pos의 멤버함수가 호출되는 형태가 된다. 따라서,

pos.operator++()

와 같은 해석이 나온다.

전달할 인자는 단항 연산자이므로 없을 것이다. 그럼 전역함수의 형태에서 오버로딩 된 경우를 생각해보자.

operator++(pos)

전역함수의 형태는 operator++ 가 전역함수의 이름이 되므로 다음과 같이 된다.

전역함수는 피연산자가 모두 인자로 전달되기 때문이다. 그럼 에제를 보도록 하자.

#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++(){
        xpos+=1;
        ypos+=1;
        return * this;
    }
    friend Point& operator--(Point &ref);
};

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

int main() {

    Point pos(1, 2);
    ++pos;
    pos.ShowPosition();
    --pos;
    pos.ShowPosition();

    ++(++pos);
    pos.ShowPosition();
    --(--pos);
    pos.ShowPosition();
    return 0;
}

위 예제의 멤버함수를 보자. 객체 스스로를 반환하는데 반환형이 참조형이다. 따라서 위 함수의 호출 결과로 객체 자기자신을 참조할 수 있는 참조 값이 반환된다. 물론 반환형으로 Point 형이 선언되면 객체 자신의 복사본을 만들어서 반환한다.

그럼 ++(++pos)와 같은 문장은 어떻게 해석되는지 보자.

++(pos.operator++())

실행의 결과로 1씩 증가함은 보이고, 참조 값이 반환되므로,

++(pos의 참조 값)

이 된다.

그리고 이어서, (pos의 참조 값).operator++()

가 되는데, pos의 참조 값을 대상으로 하는 연산은 pos 객체를 대상으로 하는 연산이기 때문에 결과적으로 위 문장이 실행되면서 다시 1씩 증가하게 된다.

따라서 참조값으로 반환하는 이유는 일반적인 ++ 연산자와 마찬가지로 위와 같은 연산이 가능하게 하기 위함이다.

그럼 --(--pos)를 한번 보자.

이는

--(operator--(pos)) 와 같고, 위 전역으로 선언되어 있는 것을 볼 수 있다. 즉, 인자로 전달된 pos 객체를 참조자 ref로 받아서 이를 그대로 참조형으로 반환한다.

따라서 위 ++과 같은 진행이 이어진다.

그럼 한가지 의문이 드는 곳은 전위증가와 후위증가의 구분이다.

그럼 이 부분은 어떻게 할 수 있을까?

이는 따로 규칙이 정해져 있는데, 다음과 같다.

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

즉, 키워드 int 를 이용해서 구분하고 있다. 물론 여기서 사용된 int는 단순히 구분을 위한 목적이고 int형 데이터를 인자로 전달한다는 등 다른 의미는 없다.

그럼 예제로 보자.

#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++(){
        xpos += 1;
        ypos += 1;
        return * this;
    }

    const Point operator++(int){
        const Point retobj(xpos, ypos); // const Point retobj(* this)
        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); // const 객체
        ref.xpos -= 1;
        ref.ypos -= 1;
        return retobj;
    }

int main() {

    Point pos(3, 5);
    Point cpy;
    cpy = pos--;
    cpy.ShowPosition();
    pos.ShowPosition();

    cpy = pos++;
    cpy.ShowPosition();
    pos.ShowPosition();
    return 0;
}

반환형 const 선언, const 객체


그럼 함수들을 좀더 관찰해보자.

const Point operator++(int){
		const Point retobj(xpos, ypos); // 함수 내에서 retobj 변경을 막는다.
		xpos += 1;
		ypos += 1;
		return retobj;
}

const Point& operator--(Point &ref, int){
		const Point retobj(ref); // 함수 내에서 retobj 변경을 막는다.
		ref.xpos -= 1;
		ref.ypos -= 1;
		return retobj;
}

반환형에 const를 왜 선언했는지 이해가 가면 좋다.

반환이 대상이 되는 retobj 객체가 const로 선언이 되어 있어서 라고 생각한다면 그렇지 않다.

retobj 객체가 반환되면, 반환의 과정에서 새로운 객체가 생성되기 때문에(복사 생성자) retobj 객체의 반환에 있어서 const는 아무 영향을 주지 않는다.

operator-- 함수의 반환으로 인해 생성되는 임시객체를 const 객체로 생성하겠다.

는 의미가 맞는데, 이를 좀더 풀어서 설명하려고 한다. 먼저 const 객체 가 뭔지를 모르지만 다음과 같은 형태다.

const Point pos(3, 4)

이는 pos 객체를 상수화해서 pos 객체에 저장된 값의 변경을 허용하지 않겠다 는 뜻이다.

즉, const 객체를 대상으로는 값의 변경능력을 지니는, const로 선언되지 않은 함수의 호출을 허용하지 않겠다는 것 이다.

실제 값의 변경유무는 상관없이 말이다. 그리고 이러한 const 객체를 대상으로 참조자를 선언할 때는 참조자도 const로 되야 한다.

int main(){
	const Point pos(3, 4);
	const Point &ref = pos; //컴파일 ok!
}

다음과 같이 말이다. 그래야 참조자를 통한 pos 객체의 변경을 허용하지 않을 수 있기 때문 이다.

그럼 본론으로 돌아와서,

const Point operator++(int){
		const Point retobj(xpos, ypos); // 함수 내에서 retobj 변경을 막는다.
		xpos += 1;
		ypos += 1;
		return retobj;
}

const Point& operator--(Point &ref, int){
		const Point retobj(ref); // 함수 내에서 retobj 변경을 막는다.
		ref.xpos -= 1;
		ref.ypos -= 1;
		return retobj;
}

위의 두 함수는 객체를 반환한다. 그리고 이 과정에서 생성되는 임시객체는 반환형의 const 선언으로 인해 값의 변경을 허용하지 않는 const 객체(상수 객체)가 된다. 따라서, 이 객체를 대상으로는 const로 선언되지 않은 멤버함수의 호출이 불가능하다. 때문에 다음과 같은 형태는 허용되지 않는다.

int main(){
	Point pos(3, 5);
	(pos++)++; // 컴파일 err
	(pos--)--; // 컴파일 err
}

그럼 컴파일 에러가 나는 이유에 대해 정리를 해보자.

pos++, pos--연산에서 반환되는 것은 const 객체이므로

(Point형 const 임시객체)++, (Point형 const 임시객체)-- 가 된다.

이는

(Point형 const 임시객체).operator++(); // operator++(int)의 호출

operator--(Point형 const 임시객체); // operator–(Point &ref, int)의 호출

이 된다. 여기서 operator++ 멤버함수는 const로 선언된 함수가 아니므로 const 임시객체를 대상으로는 호출이 불가능 하므로, 컴파일 에러가 발생한다. operator– 전역함수의 경우는 매개변수로 참조자가 선언됬는데, 이 참조자가 const가 아니므로 에러가 난다.

그럼 결론을 내려보자. 후위증가, 후위감소 연산에서 오버로딩한 함수의 반환형을 const로 한 이유는 다음 두 문장에서 컴파일 에러를 일으키기 위함이다.

(pos++)++, (pos--)--

그리고,

int main(){
	int num = 100;
	++(++num);
	--(--num);
}

은 허용하지만 다음은 허용하지 않는 C++ 연산특성을 반영한 것이다.

int main(){
	int num = 100;
	(num++)++;
	(num--)--;
}

많은 생각이 필요한 부분이다. 다음 포스팅은 교환법칙부터 진행하겠다.

Blog Logo

구찌


Published

Image

구찌의 나도한번 해블로그

구찌의 개발 블로그 입니다.

Back to Overview