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

😢 이 페이지는 다음 주소로 변경될 예정입니다.

이 가이드라인은 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.


더 읽을 거리

C# 으로 배우는 적응형 코드

😢 이 페이지는 다음 주소로 변경될 예정입니다.

요즘 사무실에서 비는 시간이 좀 많이 있어서 책을 가져다두고 읽었다. 가볍게 읽으려고 읽었던 책을 가져가야지 했는데 지금 회사에서는 C#을 전혀 쓰지 않고 있으니 리마인드도 할 겸 읽게 되었다. 베타리딩을 포함해서 3번째 읽는데 그래도 또 배우는 게 많은 건 전에 열심히 안 읽어서 그런 걸까. 이번에는 읽고 기억하고 싶은 키워드라도 적어놔야지 싶어 표시해둔 부분을 여기에 옮겼다.

이 책은 어떤 방식으로 구조를 짜야 좋은 적응력을 가진 코드를 작성할 수 있는지 설명한다. 크게 세 부분으로 볼 수 있는데 가장 먼저 애자일 방법론과 적응형 코드를 작성하기 위한 배경적인 지식을 쌓는다. 그리고 SOLID 패턴에 대해 여러 예시를 들어 설명한 후, 마지막으로 실무에서 적용하는 예시로 마무리한다.

1부에서는 어떤 방식으로 작성하면 코드 의존성이 강해지는지, 어떤 계층적인 접근을 해야 유연한 구조를 구성할 수 있는지 풀어간다. 이런 흐름에서 왜 인터페이스를 도입해야 하고 디자인패턴이 어떻게 코드에 유연함을 더하는지 확인한다. 이런 구조의 코드를 어떻게 테스트하고 리팩토링하는 과정을 통해 개선하는 부분까지 다뤘다.

C#을 기준으로 설명하고 있어서 어셈블리를 어떻게 구성해야 한다거나 nuget을 구성하는 방법이라든가 하는 부분은 좀 거리감 있게 느낄 수도 있는데 요즘은 대다수 언어가 어떤 방식으로든 이런 부분을 지원하고 있으니 그렇게 맥락이 멀게 느껴지지 않았다.

의존성 관리(p. 66)에서 어떤 게 코드 스멜인지 알려주고 그 대안을 잘 설명하고 있다. C#에 매우 한정된 부분이긴 하지만 CLR의 어셈블리 해석 과정도 흥미로웠다.

여기서는 계층화를 점진적으로 진행하는 과정(p. 96)이 특히 좋았다. 인류 발달 과정 설명하듯 하나씩 짚어가며 어떤 이유에서 분리했는지 설명하고 있어서 최종 단계만 보면 막막할 수 있는 계층을 이해하는데 도움이 되었다.

2부에서는 SOLID 패턴을 하나씩 실질적인 예시와 함께 설명했다. 리스코프 치환 원칙을 설명하는 부분(p. 253~)이 재미있었다. 계약 규칙에서는 사전 조건과 사후 조건을 작성하는 방법과 불변성을 어떻게 유지해야 하는지 설명했다. System.Diagnostics.Contracts를 사용해서 각 조건을 기술하는 방식도 참 깔끔하다. 그리고 가변성 규칙에서 공변성과 반공변성을 짚고 넘어갔는데 제네릭이 어떤 식으로 동작하는지 이해하는데 많이 도움 됐다.

인터페이스 분리도 유익했는데 질의/명령을 인터페이스로 분리하는 부분도 좋았다. 의존성 주입은 이미 부지런히 쓰고 있었지만, 생명주기(p. 346)를 설명하는 부분은 다소 모호하게 생각했던 IDispose를 다시 살펴볼 수 있었고 이 인터페이스를 어떤 방식으로 생명주기 관리에 적용하는지 배울 수 있었다. 이 부분은 실무에서 좀 더 많은 사례를 접해보고 싶다.

예전에도 좋아서 추천 많이 했는데 또 읽어도 좋아서 추천하고 싶다. C#도 부지런히 해서 실무에서 다시 쓸 기회가 왔으면 좋겠다.

C# 초보가 C# 패키지를 만드는 방법 발표 후기

😢 이 페이지는 다음 주소로 변경될 예정입니다.

지난 21일 Weird Developer Melbourne 밋업이 있었다. 3회차인 이번 밋업은 라이트닝 토크 형식으로 진행되었고 그 중 한 꼭지를 맡아 C# 초보가 C# 패키지를 만드는 방법 주제로 발표를 했다.

C# 스터디에 참여한 이후에 윈도 환경에서 작업할 일이 있으면 C#으로 코드를 작성해서 사용하기 시작했다. 하지만 업무에서 사용하는 기능은 한정적인데다 의도적으로 관심을 갖고 꾸준히 해야 실력이 느는데 코드는 커져가고, 배운 밑천은 짧고, 유연하고도 강력한 코드를 만들고 싶다는 생각을 계속 하고 있었지만 실천에 옮기질 못하고 있었다.

얼마 전 저스틴님과 함께 바베큐를 하면서 이 얘기를 했었는데 “고민하지 않고 뭐든 만드는 것이 더 중요하다”는 조언을 해주셨다. 말씀을 듣고 그냥 하면 되는걸 또 너무 망설이기만 했구나 생각이 들어서 실천에 옮겼다. 특별하게 기술적으로 뛰어난 라이브러리를 만들거나 한 것은 아니지만 생각만 하고 앉아있다가 행동으로 옮기는 일을 시작한 계기와 경험이 좋아서 발표로 준비하게 되었다.

발표 자료는 다음과 같다.

발표는 다음 같은 내용이 포함되었다.

  • MonoDevelop에서 간단한 예제 코드 시연
  • 라이브러리 작성하면서 배운 것
  • Github
  • Nuget 패키지
  • AppVeyor 설정

라이트닝 토크라서 이 주제가 괜찮지 않을까 생각했지만 다른 분들은 더 심도있는 주제를 많이 다뤄서 쉬어가는 코너 정도 느낌이 되었던 것 같다. 시간을 짧게 한다고 좀 더 설명할 부분을 그냥 넘어가거나 보여줄 페이지를 다 보여주지 못했던 점도 아쉽다.

발표 이후로도 계속 시간을 내서 라이브러리도 다듬고 C# 공부도 부지런히 해야겠다는 생각을 했다. (아직도 갈 길이 멀다!) 학습에서 유익했던 자료와 보고 있는/볼 예정인 자료를 참고로 남긴다.

In Weird Developer Melbourne! Thanks @justinchronicles

A photo posted by 용균 (@edwardykim) on