이 가이드라인은 California Institute of Technology의 강의 자료인 C++ Operator Overloading Guidelines를 번역한 글로 C++에서 연산자를 오버로딩 할 때 유의해야 하는 부분을 잘 설명하고 있다.


C++ 연산자 오버로딩 가이드라인

사용자 정의 클래스를 사용할 때 연산자에 특별한 의미를 부여할 수 있다는 점은 C++의 멋진 기능 중 하나입니다. 이 기능을 연산자 오버로딩(operator overloading) 이라고 합니다. C++의 연산자 오버로딩은 클래스에 특별 멤버 함수를 다음과 같은 명명 규칙에 따라서 작성해 구현할 수 있습니다. + 연산자를 클래스에 오버로드 하는 것으로 예를 들면 operator+라는 이름의 멤버 함수를 클래스에 작성해서 제공할 수 있습니다.

다음은 사용자 정의 클래스에 일반적으로 오버로드하는 연산자 목록입니다.

  • = (할당 연산자, assignment operator)
  • + - * (이진 산술 연산자, binary arithmetic operators)
  • += -= *= (복합 할당 연산자, compound assignment operators)
  • == != (비교 연산자, comparison operators)

이 연산자를 구현하는데 있어 몇 가지 지침이 있습니다. 이 지침을 따르는 것은 매우 중요하며 각 지침은 꼭 버릇으로 만들기 바랍니다.

할당 연산자 =

할당 연산자는 다음과 같은 시그니처(signature)를 사용합니다.

class MyClass {
  public:
  ...
  MyClass & operator=(const MyClass &rhs);
  ...
}

MyClass a, b;
...
b = a;   // b.operator=(a); 와 동일함

= 연산자가 우측에 할당한 내용을 상수 참조로 받는 점을 확인할 수 있습니다. 이렇게 하는 이유는 명확한데 할당 연산자 왼쪽에 있는 내용을 바꾸고 싶은 것이지 할당 오른쪽은 변경을 원하지 않기 때문입니다.

또한 할당 연산자도 참조로 반환하는 점을 확인할 수 있습니다. 이 방식으로 **연산자 연결(operator chaining)**이 가능합니다. 원시 형식(primitive types)이 다음처럼 동작하는 것을 봤을 겁니다.

int a, b, c, d, e;

a = b = c = d = e = 42;

컴파일러는 이 코드를 다음처럼 해석합니다.

a = (b = (c = (d = (e = 42))));

다시 말하면 할당은 **우측 연관(right-associative)**입니다. 마지막 할당 연산이 먼저 평가되며 연쇄적인 할당에 따라 좌측으로 퍼져가게 됩니다. 특히,

  • e = 42는 42를 e에 할당하고 그 결과로 e를 반환합니다.
  • e의 값이 d에 할당되며 그 결과로 d를 반환합니다.
  • d의 값이 c에 할당되며 그 결과로 c를 반환합니다.
  • 기타 등등…

이제 연산자 연결을 지원하기 위해서 할당 연산자는 반드시 어떤 값을 반환해야 합니다. 반환되어야 하는 값은 할당의 좌측 을 참조합니다.

반환 참조에는 상수로 선언되지 않았음 을 확인할 수 있습니다. 이 점은 좀 혼란스러울 수 있는데 다음과 같은 기괴한 코드를 작성할 수 있기 때문입니다.

MyClass a, b, c;
...
(a = b) = c;  // 이건 뭐죠??

이건 코드를 처음 봤다면 아마 이런 상황을 방지하기 위해 operator=를 상수 참조로 반환하고 싶을지도 모릅니다. 하지만 이런 구문(statements)은 원시 형식과 함께 동작할겁니다. 그리고 더 나쁜 점은 어떤 도구는 이런 동작 방식에 의존하고 있다는 점입니다. 그러므로 operator=상수가 아닌 참조로 반환하는 것이 중요합니다. 경험적으로 “상수에 괜찮다면 사용자 정의 자료 형식에도 괜찮다”고 말할 수 있습니다.

그래서 가상의 MyClass 할당 연산자를 다음처럼 작성할 수 있습니다.

// 우측 할당에서 상수 참조를 받습니다.
// 좌측에 상수가 아닌 참조를 반환합니다.
MyClass& MyClass::operator=(const MyClass &rhs) {
    ...  // 할당 연산을 수행합니다!

    return *this;  // 자기 자신을 참조를 반환합니다.
}

기억하세요. this는 이 객체에 대한 포인터며 멤버 함수로 호출되는 대상입니다. a = ba.operator=(b)처럼 취급되는 방식을 보면 반환 객체 자체가 호출되는 함수라는 점이 더 와닿을 것입니다. 객체 a는 좌측에 해당합니다.

하지만 멤버 함수는 객체에 대한 포인터를 반환하는 것이 아니라 객체에 대한 참조를 반환해야 합니다. 그래서 *this를 반환했는데 이 코드는 무엇을 가리키고 있는지를 반환했지 포인터 자체를 반환한 것이 아닙니다. (C++에서는 인스턴스는 참조로 전환되고 또 그 역으로도 성립하는데 거의 자동으로 처리됩니다. 그래서 *this는 인스턴스지만 C++는 암시적으로 인스턴스에 대한 참조로 변환합니다.)

이제 할당 연산자에 가장 중요한 점이 하나 더 있습니다. 자기 할당(self-assignment)를 꼭 확인해야 합니다!

클래스가 자체적으로 메모리 할당을 하는 경우라면 특히 중요합니다. 할당 연산자가 일련의 연산을 수행할 때 일반적으로 다음처럼 동작합니다.

MyClass& MyClass::operator=(const MyClass &rhs) {
    // 1. MyClass가 내부적으로 갖고 있는 모든 메모리를 할당 해제합니다.
    // 2. rhs의 내용을 보관하기 위해 메모리에 할당합니다.
    // 3. rhs로부터 값을 인스턴스에 복사합니다.
    // 4. *this을 반환합니다.
}

이제 이렇게 작성하면 다음과 같은 일이 일어납니다.

MyClass mc;
...
mc = mc;     // 짜잔!

이 코드를 보면 프로그램에 대혼란을 불러온다는걸 볼 수 있을겁니다. mc는 좌측에도 있고 또한 우측에도 있기 때문에 가장 먼저 일어나는 일은 mc가 내부적으로 들고 있는 모든 메모리를 해제합니다. 하지만 여기서 값이 복사될 위치이기도 합니다. 우측도 mc가 있기 때문이죠! 이제 나머지 할당 연산자 내부를 완전히 다 엉망으로 만들고 맙니다.

이런 문제를 손쉽게 피하려면 자기 할당을 확인합니다. “두 인스턴스는 같나요?”라는 질문에 답하는 방법은 많지만 우리 용도를 생각해보면 객체 주소가 동일한지 확인하는 정도면 지금 용도에 맞습니다. 만약 주소가 동일하면 할당을 하지 않습니다. 주소가 다르면 할당을 수행합니다.

이제 올바르고 안전한 버전의 MyClass 할당 연산자를 생각하면 다음과 같습니다.

MyClass& MyClass::operator=(const MyClass &rhs) {
    // 자기 할당을 확인합니다.
    if (this == &rhs)      // 동일 객체?
        return *this;        // 맞네요. 그럼 할당을 건너뛰고 *this를 반환합니다.

    ... // 할당 해제, 새 공간을 할당하고 값을 복사합니다...

    return *this;
}

또는 간단하게 다음처럼 할 수 있습니다.

MyClass& MyClass::operator=(const MyClass &rhs) {

    // `rhs`가 `this`와 다를 때만 할당을 합니다.
    if (this != &rhs) {
        ... // 할당 해제, 새 공간을 할당하고 값을 복사합니다...
    }

    return *this;
}

위 비교에서 this는 호출되는 객체 포인터고 &rhs는 인자로 전달된 객체를 가리키는 포인터라는 점을 기억한다면 위 코드와 같은 검사로 자기 할당의 위험성을 회피할 수 있다는 점을 확인할 수 있을겁니다.

결론을 정리하면 할당 연산자를 위한 가이드라인은 다음과 같습니다.

  1. 인자는 상수 참조로 받습니다. (할당 우측)
  2. 좌측에 참조를 반환해서 안전하고 적절한 연산자 연결을 지원합니다. (*this를 반환하는 방법으로)
  3. 포인터를 비교해서 자기 할당을 확인합니다. (this&rhs)

복합 할당 연산자 += -= *=

산술 연산자에 대해 특별히 언급할 부분이 있는데 뒤에서 자세히 다루겠습니다. 이 연산자는 비구조(destructive) 연산자라는 점이 중요합니다. 바로 할당의 좌측 값이 갱신되거나 대체되기 때문입니다. 다음 예시를 확인합니다.

MyClass a, b;
...
a += b;    // a.operator+=(b) 와 동일함

이 경우에는 += 연산에 의해 값이 수정되었습니다.

어떻게 이 값이 변경 되었는가는 그렇게 중요하지 않습니다. 명백하게 MyClass가 이 연산자의 의미가 어떤 것인지 나타내고 있기 때문입니다.

이런 연산자의 멤버 함수 시그니처는 다음과 같아야 합니다.

MyClass & MyClass::operator+=(const MyClass &rhs) {
    ...
}

rhs가 상수 참조인 이유는 이미 앞에서 다뤘습니다. 그리고 이런 연산자의 구현 또한 직관적입니다.

하지만 연산자가 MyClass 참조를 반환하는 것을 볼 수 있으며 상수가 아닌 형태로 반환하고 있습니다. 이 말은 다음과 같은 코드도 가능하다는 의미입니다.

MyClass mc;
...
(mc += 5) += 3;

누가 이런 코드를 작성하는지 저에게 물어보지 않기 바랍니다. 하지만 다른 일반적인 할당 연산자와 같이 이런 원시 데이터 형식에서는 허용되야 하는 방식입니다. 사용자 정의 데이터 형식도 원시 데이터 형식에서 이런 연산자와 함께 동작하는 방식과 맥락이 맞는 동작을 제공해야 모든 코드가 기대한 대로 동작할 것입니다.

이런 작업은 매우 직관적입니다. 단지 복합 할당 연산자 구현을 작성하고 *this를 마지막으로 반환하도록 작성합니다. 다른 일반적인 할당 연산자처럼 말이죠. 그러면 다음과 같은 코드를 작성하게 될 것입니다.

MyClass & MyClass::operator+=(const MyClass &rhs) {
    ...   // 복합 할당 작업을 처리합니다.

    return *this;
}

마지막으로 일반적으로 복합 할당 연산자더라도 자기 할당 문제에 유의해야 합니다. 운좋게도 C++ 트랙 연구실에서 이 걱정을 할 필요는 없겠지만 클래스를 직접 작성하고 사용할 때마다 이 문제에 대해 항상 생각하고 있어야 합니다.

이진 산술 연산자 + - *

이진 산술 연산자는 연산의 양쪽을 모두 수정하지 않으며 두 인자로 만든 새로운 값을 반환한다는 점에서 흥미롭습니다. 아마 이 작업은 조금 추가적인 작업을 필요로 해서 짜증날 수 있겠지만 여기에 비밀이 있습니다.

복합 할당 연산자를 활용해서 이진 산술 연산자를 정의하세요.

방금 제가 당신이 숙제에 쓸 수많은 시간을 절약해줬습니다.

이미 += 연산자를 구현했고 이제 + 연산자를 구현하려고 합니다. 함수 시그니처는 다음과 같습니다.

// 인스턴스의 값을 다른 곳에 추가하고 결과와 함께 새 인스턴스를 반환합니다.
const MyClass MyClass::operator+(const MyClass &other) const {
    MyClass result = *this;     // 자신의 사본을 만듭니다. `MyClass result(*this);`와 같습니다.
    result += other;            // 다른 곳에서 사본에 더하기 위해 +=를 사용합니다.
    return result;              // 모두 끝났습니다!
}

간단하네요!

사실 명시적으로 모든 과정을 다 설명했지만 원한다면 이 모든 코드를 다음처럼 한 줄 구문으로 작성할 수 있습니다.

// 인스턴스의 값을 다른 곳에 추가하고 결과와 함께 새 인스턴스를 반환합니다.
const MyClass MyClass::operator+(const MyClass &other) const {
    return MyClass(*this) += other;
}

이 코드는 *this의 사본으로 이름 없는 MyClass 인스턴스를 생성합니다. 그리고 += 연산자는 이 임시 값에서 호출하고 반환합니다.

마지막 구문이 아직 이해가 되지 않는다면 앞서 단계를 풀어서 설명한 코드를 사용하기 바랍니다. 하지만 정확히 무슨 과정이 이뤄지는지 이해된다면 짧은 코드를 사용하세요.

+ 연산자가 상수 인스턴스를 반환한 것이지 상수 참조를 반환하지 않았다는 점 을 알 수 있을 겁니다. 상수 참조를 반환하지 않는 것으로 다음과 같은 이상한 구문을 작성하는 일을 막습니다.

MyClass a, b, c;
...
(a + b) = c;   // 엥...?

이 구문은 기본적으로 아무 일이 일어나지 않습니다. 하지만 + 연산자가 상수가 아닌 값을 반환하는 경우에는 컴파일이 됩니다! 반환 값을 상수 인스턴스로 한다면 이런 광기는 더이상 컴파일 되지 않을 것입니다.

이진 산술 연산자를 위한 지침을 정리하면 다음과 같습니다.

  1. 복합 할당 연산자를 처음부터 구현합니다. 그리고 이진 산순 연산자를 복합 할당 연산자를 사용해서 이진 산술 연살자를 정의합니다.
  2. 허용되지 말아야 할 쓸모 없고 혼란스러운 할당 연산을 방지하기 위해서 상수 인스턴스를 반환합니다.

비교 연산자 ==, !=

비교 연산자는 매우 간단합니다. 다음 함수 시그니처를 사용해서 ==를 먼저 정의합니다.

bool MyClass::operator==(const MyClass &other) const {
    ...  // 값을 비교하고 bool 형식으로 결과를 반환합니다.
}

구현 내부는 매우 명확하고 직관적입니다. bool 반환 값도 아주 분명합니다.

여기서 중요한 점은 != 연산자도 == 연산자를 사용해서 간편하게 정의할 수 있습니다. 다음과 같이 작성하세요.

bool MyClass::operator!=(const MyClass &other) const {
    return !(*this == other);
}

== 연산자를 구현하려고 고생해서 만든 코드를 그대로 재사용하는 방법입니다. 또한 ==!= 구현이 서로를 구현하고 있기 때문에 이 연산자 사이에서 불일치 문제가 발견될 가능성이 매우 낮습니다.


Updated Oct 23, 2007

Copyright (c) 2005-2007, California Institute of Technology.


더 읽을 거리

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.