[C++] 10. 연산자 오버로딩 #1

kkado·2023년 10월 15일
0

열혈 C++

목록 보기
10/16
post-thumbnail

💬 윤성우 님의 <열혈 C++ 프로그래밍> 책을 혼자 공부하며 배운 내용을 정리합니다. 글의 모든 내용은 책에서 발췌하였습니다.


연산자 오버로딩의 이해

오버로딩이란 같은 이름의 함수를 인자의 개수나 인자의 자료형을 달리하여 다양화시킬 수 있는 개념이다.

C++에서는 함수뿐만 아니라 연산자도 오버로딩이 가능하다.

아래 예제는 이제 쉽게 이해 가능하다.

class Point
{
private:
    int xpos, ypos;
public:
    Point(int x=0, int y=0) : xpos(x), ypos(y)
    {}

    void showPosition()
    {
        cout << "[" << xpos << ", " << ypos << "]\n";
    }

    Point operator+(const Point &ref)
    {
        return Point(xpos + ref.xpos, ypos + ref.ypos);
    }
};

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

    pos1.showPosition();
    pos2.showPosition();
    pos3.showPosition();
}

operator+ 라는 이름의 함수는 두 Point 객체의 좌표값을 더한 좌표를 가지는 Point 객체를 반환한다.

그럼 이제, main 함수를 이렇게 바꿔보자.

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

    pos1.showPosition();
    pos2.showPosition();
    pos3.showPosition();
}

pos3을 선언하는 부분이 조금 바뀌었는데 출력 결과를 확인해 보면 직전 코드와 똑같이 동작함을 알 수 있다. 여기서 한 가지의 추론이 가능하다.

operator+ 라는 이름의 함수를 오버로딩하여 재정의하면 함수를 마치 '+' 연산자처럼 쓸 수 있나? pos1+pos2가 어떤 규칙에 의해 pos1.operator+(pos2)와 같은 의미로 동작하는걸까.

그리고 이는 사실이다.

operator 키워드와 어떤 연산자를 붙여서 함수 이름을 정의하면, 함수의 이름을 이용한 함수의 호출뿐만 아니라 그 연산자를 이용한 함수의 호출도 허용한다.


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

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

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

앞에서 본 예제는 멤버함수를 이용해 오버로딩한 예시가 되겠다.
그런데 연산자는 전역함수를 이용해서도 오버로딩이 가능하다. 전역함수를 이용해서 '+' 연산자를 오버로딩하면 pos1+pos2는 다음과 같이 해석이 된다.

operator+(pos1, pos2);

멤버함수로 오버로딩할 경우에는 pos1.operator+(pos2) 와 같이 해석하고,
전역함수로 오버로딩할 경우에는 operator+(pos1, pos2)와 같이 해석한다.

참고로 동일한 자료형에 대해 같은 연산자를 전역함수와 멤버함수로 모두 오버로딩할 경우 멤버함수 기반으로 오버로딩된 함수가 우선시된다. 그러나 가급적 이러한 상황은 만들지 않는 것이 좋다.

전역에서 오버로딩을 할 경우에는 객체의 private 멤버에 접근할 수 있도록 객체 내에서 오버로딩할 함수에 friend 선언을 해 주어야 한다.


아래의 연산자들은 모두 멤버 함수 기반으로만 오버로딩이 가능하다.

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

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

본래의 의도를 벗어난 형태의 연산자 오버로딩은 좋지 않다.
기존의 연산자와 완전히 다른 생뚱맞은 함수를 오버로딩하면 프로그램을 이해하기 어렵게 만든다. 연산자의 본래 의도를 가급적 충실히 반영하여 오버로딩해야 혼란을 최소화할 수 있다.

연산자의 우선순위와 결합성은 바뀌지 않는다.
연산자 오버로딩을 통해서는 연산의 기능을 바꾸는 것이지 연산자가 지니고 있던 우선순위나 결합성은 그대로 따른다.

매개변수의 디폴트 값 설정이 불가능하다.
피연산자의 자료형에 따라 연산자를 오버로딩한 함수의 호출이 결정된다. 즉 인자가 무조건 존재한다. 따라서 디폴트 값 설정이라는 것은 존재하지 않는다.

연산자의 순수 기능까지 빼앗을 수 없다.
다음과 같은 코드는 정의 불가능하다.

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

int형 데이터의 + 연산은 이미 정해져 있다. 이것을 변경하는 것은 허용되지 않는다.


단항 연산자의 오버로딩

a++ 와 같은 단항 연산자의 경우, 연산자 오버로딩 함수의 인자로 전달할 것이 없다.

따라서 멤버함수 오버로딩의 경우 인자가 없이 a.operator++(); 로 호출하거나, 전역함수 오버로딩의 경우 operator++(a); 처럼 하나의 인자만 전달하여 사용한다.

증감 연산자의 후위/전위 구분

알다시피 ++aa++는 그 의미가 다르다.
그렇다면 a.operator++(); 와 같이 호출한 연산자는 후위증가일까, 전위증가일까?

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

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

여기서 사용된 int는 전위 후위를 구분하기 위한 용도이지 실제로 int형 인자를 전달해 주어야 하는 것은 아니다.


이제 실제로 ++ 연산자와 -- 연산자를 전위/후위 모두 오버로딩하여 사용해보도록 합시다.

class Point
{
private:
    int xpos, ypos;
public:
    Point(int x=0, int y=0) : xpos(x), ypos(y)
    {}

    void showPosition()
    {
        cout << "[" << xpos << ", " << ypos << "]\n";
    }
    
    Point& operator++() // 전위 증가
    {
        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;
}

증가 연산자는 멤버함수로, 감소 연산자는 전역함수로 오버로딩하였다.

int main()
{
    Point pos(3, 4);
    Point cpy1 = pos--;
    cpy1.showPosition();
    pos.showPosition();

    Point cpy2 = ++pos;
    cpy2.showPosition();
    pos.showPosition();
}

cpy1에는 (3, 4)가 대입되고 그 이후 pos의 좌표가 (2, 3)으로 바뀐다.

cpy2에는 대입하기 전에 pos의 좌표가 (2, 3)에서 1씩 다시 증가한 (3, 4)가 되고 이 값이 대입된다.

[3, 4]
[2, 3]
[3, 4]
[3, 4]

여기서 주목해야 할 것은 후위 연산자 오버로딩 함수가 반환형이 const로 선언된 것이다.

retobj가 const 로 선언되어 있기 때문인가? 아니다. retobj가 반환되면서 새로운 객체가 반환되므로 retobj의 const 유무는 상관이 없다.

잠깐 다른 길로 새자면 반환형으로 선언된 const는 이런 의미를 지닌다.

operator-- 함수의 반환으로 인해 생성되는 임시 객체를 const 로 선언함

const 객체라는 것은, 해당 객체에 저장된 값의 변경을 허용하지 않는다는 뜻이다. 따라서 const 함수에 대해 배울 때 다뤘듯이 const 객체를 대상으로는 const 함수만 호출이 가능하다.

그리고 이러한 const 객체를 대상으로 참조자를 선언할 때에는 참조자 역시 const로 선언해야 한다. 그래야 참조자를 통한 객체 값의 변경을 허용하지 않을 수 있기 때문이다.

다시 돌아와서, 후위 연산자 오버로딩 함수를 살펴보면 상수 객체를 반환하고 있음을 알 수 있다. 따라서 다음과 같은 구성은 불가능하다.

int main()
{
	Point pos(3, 4);
    (pos++)++;
}

pos+++ 연산을 통해 반환되는 것은 상수 객체이니, 1차적 실행결과는 (Point형 const 임시객체)++; 이다.

그리고 이 문장은 다음과 같이 해석된다.

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

그런데 const 객체에 대해서는 일반 함수인 operator++() 을 통해서 호출할 수 없다. 따라서 컴파일 에러를 발생시킨다.

실제로 정수형 변수에 대해서 (n++)++; 하면 같은 에러가 발생한다.
이것을 허용하지 않는 C++의 원리를 그대로 반영하기 위함이다.


교환법칙 문제의 해결

기본적으로 연산에 사용되는 두 피연산자의 자료형은 일치해야 하지만 연산자 오버로딩을 이용하면 이러한 연산 규칙에 예외를 두어 다음과 같은 구현이 가능하다.

class Point
{
private:
    int xpos, ypos;
public:
    Point(int x=0, int y=0) : xpos(x), ypos(y)
    {}

    Point operator*(int times)
    {
        return Point(xpos * times, ypos * times);
    }
};

int main()
{
    Point pos(3, 4);
    Point mul = pos * 3;
    mul.showPosition();
}
// 실행 결과
[9, 12]

Point형과 int형끼리도 곱셈 연산이 가능하도록 오버로딩했다.

그러나 다음과 같이 작성하는 것은 불가능하다.

Point mul = 3 * pos;

멤버함수의 형태로 오버로딩 됐기 때문에 멤버함수가 정의된 클래스의 객체가 왼쪽에 와야 하기 때문이다. 그러나 교환법칙에 따르면 곱셈의 순서는 상관이 없어야 하기 때문에, 이러한 연산도 정상 작동한다면 좋을 것이다.

이러한 연산이 가능하려면 전역함수의 형태로 오버로딩 하는 수밖에 없다.
이를 위해서 operator* 함수를 전역에서 다음과 같이 정의한다.

Point operator* (int times, Point& ref)
{
	return Point(ref.xpos * times, ref.ypos * times)
}

또는 다음과 같이 단순히 순서를 바꾸는 형태로 오버로딩해도 된다.

Point operator* (int times, Point& ref)
{
	return ref*times;
}

위처럼 전역함수를 기반으로 오버로딩을 해야하는 경우도 있으니 전역함수 기반의 오버로딩에도 익숙해질 필요가 있다.


profile
울면안돼 쫄면안돼 냉면됩니다

0개의 댓글