Post

[Effective C++] 4. 설계 및 선언 [4/4]

[Effective C++] 4. 설계 및 선언 [4/4]

이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.


항목 24 : 타입 변환이 모든 매개변수에 적용되어야 한다면 비멤버 함수를 선언하자.


이 책의 처음에서 “클래스에서 암시적 타입 변환을 지원하는 것은 일반적으로 좋지 않은 생각이다”라고 언급했었다. 물론 이 규칙에도 예외가 있는데, 가장 흔한 예외 중 하나가 숫자 타입을 만들 때이다. 예를 들어 유리수를 나타내는 클래스를 만들고 있다면, 정수에서 유리수로의 암시적 변환은 허용하자고 판단하더라도 크게 어이없거나 하진 않을 것이다. C++가 기본으로 제공하는 int -> double 변환과 별반 다르지 않다. 지금부터 볼 코드는 이런 결정에 따라 만들기 시작한 Rational 클래스이다.

1
2
3
4
5
6
7
8
9
10
class Rational
{
public:
    Rational(int numerator = 0, int denominator = 1);
    
    int numerator() const;
    int denominator() const;
private:
...
};

유리수를 나타내는 클래스이니만큼 덧셈이나 곱셈 등의 수치 연산은 기본으로 지원하고 싶을 것이다. 그런데 이들을 어떤 식으로 지원해야 할지에 대해선 아직도 머리 속이 혼란스럽다. 멤버 함수가 나을까, 비멤버 함수가 나을까, 아니면 비멤버 프렌드 함수가 좋을까? 답답하니까 객체 지향으로 해보는 것도 나쁘지 않을 수도 있다. 이를테면 유리수의 곱셈은 Rational 클래스 자체와 관련이 있으니까, operator는 Rational 클래스 안에 구현해 넣는 게 자연스러울 것 같다. 직감적인 예상과 달리, 어떤 클래스와 관련된 함수를 그 클래스의 멤버로 두는 것은 실질적인 객체 지향 원칙에 반하는 것과 같다고 항목 23에서 말했지만, 일단 그 이야기는 제쳐주고 operator를 Rational의 멤버 함수로 만드는 데만 집중해 보자.

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

이렇게 설계해 두면 유리수 곱셈을 아주 쉽게 할 수 있게 된다.

1
2
3
4
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEighth;
result = result * oneEighth;

그러나 아직도 성에 안차는 부분이 있다. 우리는 혼합형 수치 연산도 가능했으면 좋겠다고 생각한다. 바꿔 말해서 Rational을 int 같은 것과 곱하고 싶다는 것이다. 어쨌든 두 숫자를 함께 곱한다는 것만큼 자연스러운 것은 없지 않나. 어쩌다 다른 종류의 숫자일지라도 말이다.

하지만 우리가 원했던 그 혼합형 수치 연산을 해 보려고 들었더니, 이게 반쪽짜리 연산이라는 사실을 곧 알게된다.

1
2
result = oneHalf * 2; // 가능
result = 2 * oneHalf; // 불가능

곱셈은 기본적으로 교환법칙이 성립해야 한다.. 이 문제의 원인은 위의 두 예제를 함수 형태로 바꾸어 써 보면 바로 드러난다.

1
2
result = oneHalf.operator*(2); // 가능
result = 2.operator*(oneHalf); // 불가능

첫 번째 줄에서 oneHalf 객체는 operator* 함수를 멤버로 갖고 있는 클래스의 인스턴스이므로, 컴파일러는 이 함수를 호출한다. 하지만 두 번째 줄에서 정수 2에는 클래스 같은 것이 연관되어 있지 않기 때문에, operator* 멤버 함수도 있을 리가 없다. 컴파일러는 아래처럼 호출할 수 있는 비멤버 버전의 operator*도 찾아ㅗㅂㄴ다.

1
result = operator*(2, oneHalf); // 불가능

그러나 이 예제에서는 int와 Rational을 취하는 비멤버 버전의 operator*가 없으므로 탐색은 실패하고 컴파일 에러가 나게 된다.

자, 그럼 위에서 제대로 성공한 함수의 호출문을 다시 들여다보자. 두 번째 매개변수가 정수 2인데, Rational::operator*의 선언문을 보면 인자로 Rational 객체를 받도록 되어 있다. 이게 대체 무슨 조화일까? 2가 어디에선 먹히고 어디에선 안 먹히는 이유가 무엇일까?

조화는 바로 암시적 타입 변환에 있다. 컴파일러는 우리가 이 함수에 int를 넘겼으며 함수 쪽에선 Rational을 요구한다는 사실을 알고 있으나, 이 int를 Rational 클래스의 생성자에 주어 호출하면 Rational로 둔갑시킬 수 있다는 사실도 알고 있다. 그래서 컴파일러는 자기가 알고 있는 대로 한 것이다. 다시 말해, 마치 아래와 같이 작성된 코드인 것처럼 처리한 것이다.

1
2
const Rational temp(2);
result = oneHalf * temp;

물론 컴파일러가 이렇게 동작한 것은 explicit(명시 호출)로 선언되지 않은 생성자가 있기 때문이다. Rational 생성자가 만약 명시호출 생성자였으면 다음의 코드 중 어느 쪽도 컴파일되지 않는다.

1
2
result = oneHalf * 2;
result = 2 * oneHalf;

이렇게 하면 혼합형 수치 연산에 대한 지원은 수포로 돌아가지만, 최소한 두 문장의 동작은 일관되게 유지될 것이다.

하지만 여기서 끝이 아니다. 동작도 일관되게 유지하고 혼합형 수치 연산도 제대로 지원하자는 것이 목적이다. 다시 말해 앞에서 본 두 개의 문장이 전부 컴파일되는 설계를 해야 한다. 힘들겠지만 다시 그 문장으로 돌아와서, Rational 생성자가 명시호출이 아닐 때도 어째서 첫 번째 문장은 컴파일되는데 두 번째 문장은 안 되는지 고민해 보도록 하자.

1
2
result = oneHalf * 2; // 컴파일 된다. 비명시호출 생성자와 함께
result = 2 * oneHalf; // 컴파일 안 된다. 비명시호출 생성자와 함께 했는데도

이로써 알 수 있는 사실은, 암시적 타입 변환에 대해 매개변수가 먹혀들려면 매개변수 리스트에 들어 있어야만 한다는 것이다. 그러니까 호출되는 멤버 함수를 갖고 있는(쉽게 말해 this가 가리키는) 객체에 해당하는 암시적 매개변수에는 암시적 변환이 먹히지 않는다. 첫 번째 문장이 컴파일되고 두 번째 문장이 컴파일되지 않는 이유도 바로 이것이다. 전자의 경우에는 매개변수 리스트에 있는 매개변수가 쓰이고 있지만, 후자의 경우에는 그렇지 않다.

하지만 우리는 여전히 혼합형 수치 연산을 지원하고 싶다. 그러기 위해서는 바로 operator*를 비멤버 함수로 만들어서, 컴파일러 쪽에서 모든 인자에 대해 암시적 타입 변환을 수행하도록 내버려 두는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Rational
{
... // operator*가 없다.
};

const Rational operator*(const Rational& lhs, const Rational& rhs) // 비멤버 함수
{
    return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}

Rational oneFourth(1, 4);
Rational result;

result = oneFourth * 2; // 이건 원래 됐었다.
result = 2 * oneFourth; // 이젠 이것도 된다.

확실히 해피엔딩이긴 하지만, 걱정거리 하나가 있다. 정말로 operator* 함수는 Rational 클래스의 프렌드 함수로 두어도 될까?

지금의 예제에서는 ‘아니오’라고 답해야 옳다. operator*는 완전히 Rational의 public 인터페이스만을 써서 구현할 수 있기 때문이다. 그리고 그렇게 하는 방법들 중 하나를 위의 코드에서 본 것이다. 그러고 보니 여기서 한 가지 중요한 결론을 뽑을 수 있게 되었다. “멤버 함수의 반대는 프렌드 함수가 아니라 비멤버 함수이다”라는 것이다. 어떤 클래스와 연관 관계를 맺어 놓고는 싶은데 멤버 함수이면 안 되는(모든 인자에 대해 타입 변환이 필요하다든가 하는 이유로) 함수에 대해, 이런 것들은 프렌드로 만들어 버리면 다 해결된다고 가정하는 안타까운 C++ 개발자들이 있다. 이번 항목에서 본 예제는 그런 생각이 잘못됐음을 입증하는 예제로도 큰 의미를 갖고 있다. 프렌드 함수는 피할 수 있으면 피하도록 하자. 사실 프렌드들 떄문에 고마워할 일보다 골치 썩을 일이 많아지는 것은 C++나 우리나 마찬가지다. 물론 어떤 상황에서는 프렌드 함수를 꼭 써야할 필요도 있지만, 그러나 ‘멤버 함수이면 안되니까’가 반드시 ‘프렌드 함수이어야 해’를 뜻하진 않는다는 사실은 불변이다.

이번 항목에서 우리는 진실과 ‘진실일 수밖에 없는’ 진실을 보았다. 이것이 전부는 아니다. 객체 지향 C++에서 템플릿 C++로 눈을 돌려 Rational을 클래스가 아닌 클래스 템플릿으로 만들다 보면, 고민해야 할 문제 점이 지금 것과 다르고 이것을 해결하는 방법도 또 다르며, 설계 관련 사항들도 완전히 다르다.

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


항목 25 : 예외를 던지지 않는 swap에 대한 지원도 생각해 보자


swap은 상당히 재미있는 함수이다. swap은 초창기부터 STL에 포함된 이래로 예외 안전성 프로그래밍(항목 29 참조)에 없어선 안 될 감초 역할로서, 자가대입 현상(항목 11 참조)의 가능성에 대처하기 위한 대표적인 매커니즘으로 널리 사랑받아 왔다. 이렇게 쓸모가 많다보니 swap을 어떻게 제대로 구현하느냐가 굉장히 중요해졌다. 하지만 예쁘면 얼굴 값을 한다고, 이 함수에 관련된 특이한 말썽거리들도 많은 것이 사실이다. 대체 어떤 말썽거리가 불쑥 튀어나오며, 또 어떻게 대처해야 쓸 만한 swap을 만들 수 있는가를 파헤쳐 보는 것이 이번 항목의 주제이다.

두 객체의 값을 ‘맞바꾸기(swap)’한다는 것은 각자의 값을 상대방에게 주는 동작이다. 기본적으로는 이 맞바꾸기 동작을 위해 표준 라이브러리에서 제공하는 swap 알고리즘을 쓰는데, 이 알고리즘이 구현된 모습을 보면 우리가 알고 있는 그 swap과 하나도 다르지 않다는 것을 알 수 있다.

1
2
3
4
5
6
7
8
9
10
namespace std
{
    template <typename T>
    void swap(T& a, T& b)
    {
        T temp = a;
        a = b;
        b = temp;
    }
}

표준에서 기본적으로 제공하는 swap(이하 표준 swap)은 구현 코드를 보면 알겠지만 복사만 제대로 지원하는(복사 생성자 및 복사 대입 연산자를 통해) 타입이기만 하면 어떤 타입의 객체이든 맞바꾸기 동작을 수행해 준다. swap을 위해 특별히 추가 코드를 마련하거나 할 필요가 없다.

하지만, 표준 swap의 동작을 보고 짜릿한 감동을 느끼거나 소름이 돋는 사람은 없을 것이다. 한 번 호출에 복사가 세 번 일어나기 때문이다. a에서 temp로, b에서 a로, temp에서 b로 말이다. 타입에 따라서는 이런 사본이 정말 필요 없는 경우도 있는데, 이런 타입을 표준 swap에 넘겨 호출하는 것은 불필요할 것이다.

복사하면 손해를 보는 타입들 중 1등을 뽑자면 아마도 다른 타입의 실제 데이터를 가리키는 포인터가 주성분인 타입일 것이다. 이러한 개념을 설계의 미학으로 끌어올려 많이들 쓰고 있는 기법이 바로 pimpl(pointer to implementation)이다. 이해를 돕는 의미에서 pimpl 설계를 차용하여 Widget 클래스를 만든 예를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class WidgetImpl
{
public:
    ...
private:
    // 많은 데이터가 있을 것이고, 복사 비용도 높을 것이다.
    int a, b, c;
    std::vector<double> v;
    ...
};

class Widget
{
public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs)
    {
        // Widget을 복사하기 위해, 자신의 WidgetImpl 객체를 복사 
        ...
        *pImpl = *(rhs.pImpl);
        ...
    }
private:
    WidgetImpl* pImpl;

이렇게 만들어진 Widget 객체를 우리가 직접 맞바꾼다면, pImpl 포인터만 살짝 바꾸는 것 말고는 실제로 할 일이 없다. 하지만 이런 사정을 표준 swap 알고리즘이 알 턱이 없다. 언제나처럼 Widget 객체 세 개를 복사하고, 그것도 모자라 WidgetImpl 객체 세 개도 복사할 것이다. 아주 심하게 비효율적이다.

그래서 조금 손을 보고 싶다. std::swap에다가 뭔가를 알려 주는 것이다. Widget 객체를 맞바꿀 때는 일반적인 방법을 쓰지 말고 내부의 pImpl 포인터만 맞바꾸라고 말이다. C++로는 방금 말한 것을 그대로 할 수 있는 방법이 있다. std::swap을 Widget에 대해 특수화하는 것인데, 일단 기본 아이디어만 간단히 코드로 보자. 아직 컴파일은 안 된다.

1
2
3
4
5
6
7
8
9
namespace std
{
    // 이 코드는 T가 Widget일 경우에 대해 std::swap을 특수화한 것이다.
    template <>
    void swap<Widget>(Widget& a, Widget& b)
    {
        std::swap(a.pImpl, b.pImpl); // pImpl 포인터만 맞바꾼다.
    }
}

우선 함수 시작부분에 있는 template<>을 보자. 이 함수가 std::swap의 완전 템플릿 특수화(total template specialization) 함수라는 것을 컴파일러에게 알려 주는 부분이다. 그리고 함수 뒤에 있는 은 T가 Widget일 경우에 대한 특수화라는 사실을 알려 주는 부분이다. 다시 말해, 타입에 무관한 swap 템플릿이 Widget에 적용될 때는 위의 함수 구현을 사용해야 한다는 뜻이다. 일반적으로 std 네임스페이스의 구성요소는 함부로 변경하거나 할 수 없지만, 프로그래머가 직접 만든 타입(Widget 등)에 대해 표준 템플릿을 완전 특수화하는 것은 허용이 된다. 지금처럼 말이다.

그렇지만 미리 말했듯이 이 함수는 컴파일이 되지 않는다. 문법이 틀려서 그런 것은 아니고, a와 b에 들어 있는 pImpl 포인터에 접근하려고 하는데 이들 포인터가 private 멤버이기 때문이다. 특수화 함수를 프렌드로 선언할 수도 있었지만, 이렇게 하면 표준 템플릿들에 쓰인 규칙과 어긋나므로 좋은 모양은 아니다. 그래서 Widget 안에 swap이라는 public 멤버 함수를 선언하고 그 함수가 실제 맞바꾸기를 수행하도록 만든 후에, std::swap의 특수화 함수에 그 멤버 함수를 호출하는 일을 맡긴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Widget
{
public:
    ...
    void swap(Widget& other)
    {
        using std::swap;
        swap(pImpl, other.pImpl); // pImpl 포인터만 맞바꾼다.
    }
    ...
};

namespace std
{
    template <>
    void swap<Widget>(Widget& a, Widget& b)
    {
        a.swap(b); // Widget의 멤버 swap 함수를 호출한다.
    }
}

컴파일될 뿐만 아니라, 기존 STL 컨테이너와 일관성도 유지되는 착한 코드가 되었다. public 멤버 함수 버전의 swap과 이 멤버 함수를 호출하는 std::swap의 특수화 함수 모두 지원하고 있고 말이다.

그런데 이런 가정을 하나 더 해보자. Widget과 WidgetImpl이 클래스가 아니라 클래스 템플릿으로 만들어져 있어서, WidgetImpl에 저장된 데이터 타입을 매개변수로 바꿀 수 있다면 어떻게 될까?

1
2
3
4
5
template <typename T>
class WidgetImpl { ... };

template <typename T>
class Widget { ... };

swap 멤버 함수를 Widget에 넣는 정도는 앞선 경우처럼 별로 어렵지 않지만, std::swap을 특수화하는 데서 문제가 생긴다. 사실 우리가 작성하려고 했던 코드는 이런 것이었단 말이다.

1
2
3
4
5
6
7
8
namespace std
{
    template <typename T>
    void swap<Widget<T>>(Widget<T>& a, Widget<T>& b) // 에러. 적법하지 않은 코드
    {
        a.swap(b);
    }
}

우리 눈에는 괜찮아 보이지만, C++의 기준에는 적법하지 않다는 점이 걸림돌이 되고 말았다. 우리는 지금 함수 템플릿(std::swap)을 부분적으로 특수화해 달라고 컴파일러에게 요청한 것인데, C++는 클래스 템플릿에 대해서는 부분 특수화(partial specialization)을 허용하지만, 함수 템플릿에 대해서는 허용하지 않도록 정해져 있다. 그러니 컴파일이 안 되는 것은 당연하다.

함수 템플릿을 ‘부분적으로 특수화’하고 싶을 때 흔히 취하는 방법은 그냥 오버로드 버전을 하나 추가하는 것이다. 즉, 이렇게 하라는 거다.

1
2
3
4
5
6
7
8
namespace std
{
    template <typename T>
    void swap(Widget<T>& a, Widget<T>& b) // 오버로드 버전. 이 코드가 왜 유효하지 않은지에 대한 이야기가 이후에 이어진다.
    {
        a.swap(b);
    }
}

일반적으로 함수 템플릿의 오버로딩은 해도 별 문제가 없지만, std는 조금 특별한 네임스페이스이기 때문에 이 네임스페이스에 대한 규칙도 다소 특별하다. 요컨대, std 내의 템플릿에 대한 완전 특수화는 OK이지만, std에 새로운 템플릿을 추가하는 것은 OK가 아니다. (혹은 클래스이든 함수이든 어떤 것도 안 된다.) std에 들어가는 구성요소의 결정은 전적으로 C++ 표준화 위원회에 달려 있기 때문에, 무언가를 추가하는 것은 금지하고 있는 것이다. std의 영역을 침범하더라도 일반 컴파일까지는 거의 다 되고 실행도 된다. 그런데 실행하는 결과가 미정의 사항이라는 것이다. 우리 소프트웨어가 우리 생각대로 돌아가게 만들고 싶으면, std에 절대 아무것도 추가하지 말자.

그럼 뭘 어쩌라는 것인가? 어찌 됐든 우리가 swap을 호출해서 우리만의 효율 좋은 ‘템플릿 전용 버전’을 쓸 수 있었으면 좋겠단 말이다. 방법은 간단하다. 멤버 swap을 호출하는 비멤버 swap을 선언해 놓되, 이 비멤버 함수를 std::swap의 특수화 버전이나 오버로딩 버전으로 선언하지만 않으면 된다. 예를 들어, 이번 항목에 나온 Widget 관련 기능이 전부 WidgetStuff 네임스페이스에 들어 있다고 가정하면 다음과 같이 만들라는 이야기이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace WidgetStuff
{
    // 템플릿으로 만들어진 WidgetImpl 및 기타 등등 
    ...
    template <typename T> 
    class Widget { ... }; // 이전과 마찬가지로 swap이란 이름의 멤버 함수가 들어 있다.
    ...
    template <typename T>
    void swap(Widget<T>& a, Widget<T>& b) // 비멤버 swap 함수. 이번엔 std 네임스페이스의 일부가 아니다.
    {
        a.swap(b);
    }
}

이제는 어떤 코드가 두 Widget 객체에 대해 swap을 호출하더라도, 컴파일러는 C++의 이릍 탐색 규칙(인자 기반 탐색 혹은 쾨니그 탐색이란 이름으로 알려져 있다.)에 의해 WidgetStuff 네임스페이스 안에서 Widget의 특수화 버전을 찾아낸다. 이것이 바로 우리가 원하는 바였다.

이 간단한 방법은 클래스 템플릿뿐만 아니라 클래스에 대해서도 잘 통하므로, 언제든 이 방법을 써야 할 것 같다는 느낌이 들긴 하다. 하지만 반드시 클래스에 대해 std::swap을 특수화해야 할 이유가 생기기 때문에, 우리가 만든 클래스 전용 타입의 swap이 되도록 많은 곳에서 호출되도록 만들고 싶다면, 그 클래스와 동일한 네임스페이스 안에 비멤버 버전의 swap을 만들어 넣고, 그와 동시에 std::swap의 특수화 버전도 준비해 두어야 한다.

그런데, 위의 모든 사항들은 우리가 네임스페이스를 안 쓰고 있어도 여전히 유효하다(멤버 swap을 호출하는 비멤버 swap이 필요하다는 말이다). 하지만 전역 네임스페이스를 못 잡아먹어서 안달복달할 필요가 있을까? 클래스며, 템플릿이며, 함수며, 나열자 타입이며, 나열자 상수며, typedef 타입 등의 온갖 이름을 전역 네임스페이스에 들이대면서 말이다.

지금까지 함께 살펴본 내용은 전부 swap을 구현하는 사람들 쪽에 무게가 가 있엇지만, 이제는 고객의 눈으로 어떤 상황을 하나 놓고 이야기해 보자. 우리가 어떤 함수 템플릿을 만들고 있는데, 이 함수 템플릿은 실행 중에 swap을 써서 두 객체의 값을 맞바꾸어야 한다고 가정한다.

1
2
3
4
5
6
7
template <typename T>
void doSomething(T& a, T& b)
{
    ...
    swap(a, b);
    ...
}

이 부분에서 과연 어떤 swap을 호출해야 할까? 가능성은 세 가지이다.

  1. std에 있는 일반형 버전. 이건 확실히 있다.
  2. std의 일반형을 특수화한 버전. 있을 수도 있고, 없을 수도 있다.
  3. T 타입 전용의 버전. 있거나 없거나 할 수 있으며, 어떤 네임스페이스 안에 있거나 없거나 할 수도 있다.

우리는 타입 T 전용 버전이 있으면 그것이 호출되도록 하고, T 타입 전용 버전이 없으면 std의 일반형 버전이 호출되도록 만들고 싶다. 어떻게 이렇게 이끌어 낼 수 있을까? 아래의 코드가 정답이다.

1
2
3
4
5
6
7
8
template <typename T>
void doSomething(T& a, T& b)
{
    using std::swap; // std::swap을 이 함수 안으로 끌어올 수 있도록 만드는 문장
    ...
    swap(a, b); // T 타입 전용의 swap을 호출
    ...
}

컴파일러가 위의 swap 호출문을 만났을 때 하는 일은 현재의 상황에 딱 맞는 swap을 찾는 것이다. C++의 이름 탐색 규칙에 따라, 우선 전역 유효범위 혹은 타입 T와 동일한 네임스페이스 안에 T 전용의 swap이 있는지를 찾는다. T 전용 swap이 없으면 컴파일러는 그 다음 순서를 밟는데, 이 함수가 std::swap을 볼 수 있게 해 주는 using 선언이 함수 앞부분에 있으므로, std::swap을 쓰게끔 결정할 수 있다. 하지만 이런 상황이 되더라도 컴파일러는 std::swap의 T 전용 버전을 일반형 템플릿보다 더 우선적으로 선택하도록 정해져 있기 때문에, T에 대한 std::swap의 특수화 버전이 이미 준비되어 있으면 결국 그 특수화 버전이 쓰이게 된다.

원하는 swap이 호출되게 만드는 작업은 별로 어렵지 않다. 이거 딱 하나만 조심하면 된다. 호출문에 한정자를 잘못 붙이거나 하지는 말자. 한정자가 붙게 되면 C++가 호출될 함수를 결정하는 메커니즘에 바로 영향이 가기 때문이다. 예를 들어, 위의 swap 호출문을 아래와 같이 써 버리면,

1
std::swap(a, b); // swap을 호출하는 잘못된 방법

std의 swap 외의 다른 것은 거들떠보지도 말라고 컴파일러에게 말하는 꼴이 된다. 더 딱 맞을 수 있는 T 전용 버전이 다른 곳에 있을 가능성을 완전히 무시하는 것이다. 안타깝게도, 이런 식으로 쓰는 사람이 꽤 많다. 클래스에 대해 std::swap을 완전히 특수화 하는 게 중요한 이유가 바로 이것이다. 이렇게 해 두면 잘 못 한정화된 호출문으로도 타입 T 전용의 swap 함수를 끌어와 쓸 수 있기 때문이다.

이번 항목에서는 표준 swap, 멤버 swap, 비멤버 swap, 특수화한 std::swap, 그리고 swap 호출 시의 상황들에 대해 집중적으로 조명해 보았다. 이제 차근히 정리해 보는 시간을 가져 보자.

첫째, 표준에서 제공하는 swap이 우리의 클래스 및 클래스 템플릿에 대해 납득할 만한 효율을 보이면, 그냥 그대로 사용하자. 우리가 만든 타입으로 만든 객체에 대해 swap을 시도하는 사용자 코드는 표준 swap을 호출하게 될 것이다. 그리고 아무 문제도 없을 것이다.

둘째, 그런데 표준 swap의 효율이 기대한 만큼 충분하지 않다면(pimpl처럼), 다음과 같이 하자.

  1. 우리의 타입으로 만들어진 두 객체의 값을 맞바꾸는 함수를 swap이라는 이름으로 만들고, 이것을 public 멤버 함수로 두자. 단, 이 함수는 절대로 예욀르 던져선 안 된다(추후 설명).
  2. 우리의 클래스 혹은 템플릿이 들어 있는 네임스페이스와 같은 네임스페이스에 비멤버 swap을 만들어 넣는다. 그리고 1번에서 만든 swap 멤버 함수를 이 비멤버 함수가 호출하도록 만든다.
  3. 새로운 클래스(클래스 템플릿이 아니라)를 만들고 있다면, 그 클래스에 대한 std::swap의 특수화 버전을 준비해 둔다. 그리고 이 특수화 버전에서도 swap 멤버 함수를 호출하도록 만든다.

셋째, 사용자 입장에서 swap을 호출할 때, swap을 호출하는 함수가 std::swap을 볼 수 있도록 using 선언을 반드시 포함시킨다. 그 다음에 swap을 호출하되, 네임스페이스 한정자를 붙이지 않도록 하자.

마무리 짓지 않은 이야기가 이제 딱 하나 남았다. 지금 세 가지 사항을 정리하면서, 멤버 버전의 swap은 절대로 예외를 던지지 않도록 만들라고 했다. 그 이유는 swap을 진짜 쓸모 있게 응용하는 방법들 중에 클래스 및 클래스 템플릿이 강력한 예외 안전성 보장을 제공하도록 도움을 주는 방법이 있기 때문이다. 그 방법은 항목 29에서 볼 수 있다. 그런데 이 기법은 멤버 버전 swap이 예외를 던지지 않아야 한다는 가정을 깔고 있다. 하필 멤버 버전만 이렇다. 비멤버 버전의 경우, 표준 swap은 복사 생성과 복사 대입에 기반하고 있는데 일반적으로 복사 생성 및 복사 대입 함수는 예외가 허용되기 때문에 이런 제약을 받지 않는다. 따라서 swap을 직접 만들어 볼 사람들은 두 값을 빠르게 바꾸는 방법만 구현하고 끝내면 안 된다. 예외를 던지지 않는 방법도 함께 준비하는 센스가 필요하다. 다행히, 효율과 예외 금지 두 가지 특성은 함께 붙어 다니는 경우가 대부분이다. 효율이 대단히 좋은 swap 함수는 거의 항상 기본제공 타입을 사용한 연산으로 만들어지기 때문이다. 그리고 기본제공 타입을 사용한 연산은 절대로 예외를 던지지 않는다.

std::swap이 우리의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공하자. 이 멤버 swap은 예외를 던지지 않도록 만들자.
멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공하자. 클래스(템플릿이 아닌)에 대해서는, std::swap도 특수화해 두자.
사용자 입장에서 swap을 호출할 때는, std::swap에 대한 using 선언을 넣어 준후에 네임 스페이스 한정 없이 swap을 호출하자.
사용자 정의 타입에 대한 std 템플릿을 완전 특수화하는 것은 가능하다. 그러나 std에 어떤 것이라도 새로 추가하려고 하지 말자.


참고

This post is licensed under CC BY 4.0 by the author.