[Effective C++]타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자

설계 및 선언, 일곱 번째 이야기

Posted by SungBeom on December 12, 2019 · 7 mins read

매개변수의 암시적 타입 변환

"클래스에서 암시적 타입 변환을 지원하는 것은 일반적으로 못된 생각이다"라는 규칙이 있습니다. 하지만 이 규칙에도 예외가 있는데, 가장 흔한 예외 중 하나가 숫자 타입을 만들 때입니다. 예를 들어 유리수를 나타내는 클래스를 만들고 있다면, 정수에서 유리수로의 암시적 변환은 허용하자고 판단하더라도 크게 이상하진 않을 것입니다. 지금부터 보실 코드는 이런 결정에 따라 만든 Rational 클래스입니다.

1
2
3
4
5
6
7
8
9
10
class Rational {
public:
    Rational(int numerator = 0,  // 생성자에 일부러 explicit을 붙이지 않았습니다.
        int denominator = 1);    // int에서 Rational로의 암시적 변환을 허용하기 위함입니다.
    int numerator() const;
    int denominator() const;
 
private:
    ...
};
cs

유리수를 나타내는 클래스이니만큼 덧셈이나 곱셈 등의 수치 연산은 기본으로 지원하고 싶습니다. operator*를 Rational의 멤버 함수로 만든다고 생각해 봅시다.

1
2
3
4
5
6
class Rational {
public:
    ...
 
const Rational operator*(const Rational& rhs) const;
};
cs

이렇게 설계해 두면 유리수 곱셈을 아주 쉽게 할 수 있습니다. 여기서 더 나아가 혼합형(mixed-mode) 수치 연산도 가능했으면 좋겠습니다. 바꿔 말해서 Rational을 int 같은 것과도 곱하고 싶다는 것입니다. 하지만 혼합형 수치 연산을 직접 해보면, 완전하지 않다는 것을 알게 될 것입니다.

1
2
3
4
5
6
Rational oneHalf(12);
Rational result;
 
result = oneHalf * 2;  // 가능, oneHalf.operator*(2);
result = 2 * oneHalf;  // 불가능, 2.operator*(oneHalf);
                       // operator*(2, oneHalf);
cs

첫 번째 줄에서 oneHalf 객체는 operator* 함수를 멤버로 갖고 있는 클래스의 인스턴스이므로, 컴파일러는 이 함수를 호출합니다. 하지만 두 번째 줄에서 정수 2에는 클래스 같은 것이 연관되어 있지 않기 때문에, operator* 멤버 함수도 있을 리가 없습니다. 컴파일러는 아래처럼 호출할 수 있는 비멤버 버전의 operator*(네임스페이스 혹은 전역 유효범위에 있는 operator*)도 찾아봅니다. 그러나 위 예제에서는 int와 Rational을 취하는 비멤버 버전의 operator*가 없으므로 탐색은 실패하고 컴파일 에러가 나게 됩니다.

그렇다면 두 번째 매개변수가 정수 2인 함수는 성공한 것일까요? 이유는 바로 암시적 타입 변환(implicit type conversion)에 있습니다. 컴파일러는 여러분이 이 함수에 int를 넘겼으며 함수 쪽에선 Rational을 요구한다는 사실을 알고 있기에, 이 int를 Rational 클래스의 생성자에 주어 호출하여 Rational로 둔갑시킵니다. 다시 말해, 마치 아래와 같이 작성된 코드인 것처럼 처리한 거죠.

1
2
const Rational temp(2);   // 2로부터 임시 Rational 객체를 생성합니다.
result = oneHalf * temp;  // oneHalf.operator*(temp);
cs

물론 컴파일러가 이렇게 동작한 것은 명시호출(explicit)로 선언되지 않은 생성자가 있기 때문입니다. Rational 생성자가 만약 명시호출 생성자였으면 컴파일되지 않았을 것입니다. 이로써 알 수 있는 사실은, 암시적 타입 변환에 대해 매개변수가 먹혀들려면 매개변수 리스트에 들어 있어야만 한다는 것입니다. 그러니까 호출되는 멤버 함수를 갖고 있는(쉽게 말해 this가 가리키는) 객체에 해당하는 암시적 매개변수에는 암시적 변환이 먹히지 않습니다. 따라서 앞의 예제도 첫 번째 문장은 컴파일되고 두 번째 문장이 컴파일되지 않는 것이죠.

해답은 비멤버 함수

혼합형 수치 지원을 하는 해답은 바로 operator*를 비멤버 함수로 만들어서, 컴파일러 쪽에서 모든 인자에 대해 암시적 타입 변환을 수행하도록 내버려 두는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Rational {
    ...  // operator*가 없습니다.
};
 
const Rational operator*(const Rational* lhs,
    const Rationa;& rhs)  // 이제는 비멤버 함수입니다.
{
    return Rational(lhs.numerator() * rhs.numerator(),
        lhs.denominator() * rhs.denominator());
}
 
Rational oneFourth(14);
Rational result;
 
result = oneFourth * 2;
result = 2 * oneFourth;
cs

해결은 했으나, 진정 operator* 함수는 Rational 클래스의 프렌드 함수로 두어도 될까요? 지금의 예제에서는 '아니오'라고 답해야 옳습니다. operator*는 완전히 Rational의 public 인터페이스만을 써서 구현할 수 있기 때문입니다. 여기서 한 가지 중요한 결론은 "멤버 함수의 반대는 프렌드 함수가 아니라 비멤버 함수이다"라는 것입니다. 어떤 클래스와 연관 관계를 맺어 놓고는 싶은데 멤버 함수이면 안 되는(모든 인자에 대해 타입 변환이 필요하다든가 하는 이유로) 함수에 대해, 이런 것들을 프렌드 함수로 만든다고 다 해결되진 않습니다.

Rational을 클래스가 아닌 템플릿으로 만들다 보면, 고민해야 할 문제점이 지금 것과 다르고 이것을 해결하는 방법도 또 다르며, 설계 관련 사항들도 완전히 다릅니다.


정리

어떤 함수에 들어가는 모든 매개변수(this 포인터가 가리키는 객체도 포함해서)에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 합니다.