[Effective C++]예외를 던지지 않는 swap에 대한 지원도 생각해 보자

설계 및 선언, 여덟 번째 이야기

Posted by SungBeom on December 13, 2019 · 18 mins read

표준 swap

swap은 상당히 재미있는 함수입니다. swap은 초창기부터 STL에 포함된 이래로 예외 안전성 프로그래밍에 없어선 안 될 감초 역할로서, 자기대입 현상의 가능성에 대처하기 위한 대표적인 메커니즘으로서 널리 사랑받아 왔습니다. 이렇게 쓸모가 많다보니 swap을 어떻게 제대로 구현하느냐가 굉장히 중요해졌지요.

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

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

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

멤버 swap

복사하면 손해를 보는 타입들 중 으뜸을 꼽는다면 아마도 다른 타입의 실제 데이터를 가리키는 포인터가 주성분인 타입일 것입니다. 이러한 개념을 설계의 미학으로 끌어올려 많이들 쓰고 있는 기법이 바로 'pimpl 관용구(idiom)'('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
class WidgetImpl {
public:
    ...
private:
    int a, b, c;
    std::vector<double> v;                // 복사 비용이 높음
    ...
};
 
class Widget {                            // pimpl 관용구를 사용한 클래스
public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs)  // Widget을 복사하기 위해, 자신의
    {                                     // WidgetImpl 객체를 복사합니다.
        ...
        *pImpl = *(rhs.pImpl);
        ...
    }
    ...
private:
WidgetImpl *pImpl;                        // Widget의 실제 데이터를 가진
};                                        // 객체에 대한 포인터
cs

이렇게 만들어진 Widget 객체를 우리가 직접 맞바꾼다면, pImpl 포인터만 살짝 바꾸는 것 말고는 실제로 한 일이 없습니다. 하지만 이런 사정을 표준 swap은 모를 것이고, 언제나처럼 Widget 객체 세 개를 복사하고, 그것도 모자라 WidgetImpl 객체 세 개도 복사할 것입니다. 그래서 std::swap에다가 Widget 객체를 맞바꿀 때는 일반적인 방법이 아닌 내부의 pImpl 포인터만 맞바꾸라고 알려줍니다. 이는 Widget에 대해 특수화(specialize)하는 것입니다.

특수화한 std::swap

1
2
3
4
5
6
7
8
namespace std {
    template<>                    // 이 코드는 T가 Widget일 경우에
    void swap<Widget>(Widget& a,  // 대해 std::swap 을 특수화한 것입니다.
        Widget& b)                // 아직 컴파일되지는 않습니다.
    {
        swap(a.pImpl, b.pImpl);   // Widget을 'swap'하기 위해,
    }                             // 각자의 pImpl 포인터만 맞바꿉니다.
}
cs

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

하지만 위 코드는 아직 컴파일 되지 않습니다. 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);  // Widget을 맞바꾸기 위해, 각 Widget의
    }                              // pImp 포인터를 맞바꿉니다.
    ...
};
 
namespace std {
    template<>                     // std::swap 템플릿의
    void swap<Widget>(Widget& a,   // 특수화 함수를 살짝 고친 결과
        Widget& b)
    {
        a.swap(b);                 // Widget을 맞바꾸기 위해,
    }                              // swap 멤버 함수를 호출합니다.
}
 
cs

컴파일될 뿐만 아니라, 기존의 STL 컨테이너와 일관성도 유지되는 코드가 되었습니다. public 멤버 함수 버전의 swap과 이 멤버를 호출하는 std::swap의 특수화 함수 모두 지원하고 있고요. 그런데 Widget과 WidgetImpl이 클래스가 아니라 클래스 템플릿으로 만들어져 있어서, WidgetImpl에 저장된 데이터의 타입을 매개변수로 바꿀 수 있다면 어떻게 될까요?

비멤버 swap

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

swap 멤버 함수를 Widget에(필요하면 WidgetImpl에도) 넣는 건 별로 어렵지 않지만, std::swap을 특수화하는 데서 좌절하게 됩니다. 사실 작성하려고 했던 직관적인 코드는 이렇습니다.

1
2
3
4
5
6
namespace std {
    template<typename T>
    void swap<Widget<T>>(Widget<T>& a,
        Widget<T>& b)
    { a.swap(b); }
}
cs

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

함수 템플릿을 '부분적으로 특수화'하고 싶을 때 흔히 취하는 방법은 그냥 오버로드 버전을 하나 추가하는 것입니다.

1
2
3
4
5
6
namespace std {
    template<typename T>
    void swap(Widget<T>& a,  // std::swap을 오버로드한 함수인데,
        Widget<T>& b)        // 이 코드는 유효하지 않습니다.
    { a.swap(b); }
}
cs

일반적으로 함수 템플릿의 오버로딩은 해도 별 문제가 없지만, std는 조금 특별한 네임스페이스이기 때문에 이 네임스페이스에 대한 규칙도 다소 특별합니다. 요컨대, std 내의 템플릿에 대한 완전 특수화는 괜찮지만, std에 새로운 템플릿을 추가하는 것은 괜찮지 않습니다(혹은 클래스이든 함수이든 어떤 것도 안 됩니다). std의 영역을 침범하더라도 일단 컴파일까지는 거의 다 되고 실행도 되지만, 실행되는 결과가 미정의 사항이기에, std에 아무것도 추가하지 않는 것이 좋습니다.

'템플릿 전용 버전'의 swap을 사용하기 위해선 멤버 swap을 호출하는 비멤버 swap을 선언해 놓되, 이 비멤버 함수를 std::swap의 특수화 버전이나 오버로딩 버전으로 선언하지만 않으면 됩니다. 예를 들어, 이번 항목에 나온 Widget 관련 기능이 전부 WidgetStuff 네임스페이스에 들어 있다고 가정하면 다음과 같이 만들면 됩니다.

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

이제는 어떤 코드가 두 Widget 객체에 대해 swap을 호출하더라도, 컴파일러는 C++의 이름 탐색 규칙(인자 기반 탐색(argument-dependent lookup) 또는 쾨니그 탐색(Koenig lookup))에 의해 WidgetStuff 네임스페이스 안에서 Widget 특수화 버전을 찾아냅니다.
여기서 C++의 이름 탐색 규칙은 어떤 함수에 어떤 타입의 인자가 있으면, 그 함수의 이름을 찾기 위해 해당 타입의 인자가 위치한 네임스페이스 내부의 이름을 탐색해 들어간다는 간단한 규칙입니다.

이 방법은 템플릿뿐만 아니라 클래스에 대해서도 잘 통하므로, 클래스에 대해 std::swap을 특수화해야 할 이유가 생긴다면 이 방법을 쓰는 것이 좋습니다. 여러분이 만든 '클래스 타입 전용의 swap'이 되도록 만은 곳에서 호출되도록 만들고 싶으시면(그리고 그런 swap을 갖고 있다면), 그 클래스와 동일한 네임스페이스 안에 비멤버 버전의 swap을 만들어 넣고, 그와 동시에 std::swap의 특수화 버전도 준비해 두어야 하겠습니다.

swap 호출 시의 상황

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

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

이 부분에서 과연 어떤 swap을 호출해야 할까요? 가능성은 세 가지입니다. std에 있는 일반형 버전(이것은 확실히 있습니다), std의 일반형을 특수화한 버전(있을 수도, 없을 수도 있습니다), T 타입 전용의 버전(있거나 없거나 할 수 있으며, 어떤 네임스페이스 안에 있거나 없거나 할 수도 있습니다(하지만 확실히 std 안에는 없어야 하겠지요)). 여러분은 타입 T 전용 버전이 있으면 그것이 호출되도록 하고, T 타입 전용 버전이 없으면 std의 일반형 버전이 호출되도록 만들고 싶습니다.

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

컴파일러가 위의 swap 호출문을 만났을 때 하는 일은 현재의 상황에 딱 맞는 swap을 찾는 것입니다. C++의 이름 탐색 규칙을 따라, 우선 전역 유효범위 혹은 타입 T와 동일한 네임스페이스 안에 T 전용의 swap이 있는지를 찾습니다. T 전용 swap이 없으면 컴파일러는 그 다음 순서를 밟는데, 이 함수가 std::swap을 볼 수 있게 해 주는 using 선언(using declaration)이 함수 앞부분에 떡 하니 있기 때문에 std의 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을 진짜 쓸모 있게 응용하는 방법들 중에 클래스(및 클래스 템플릿)가 강력한 예외 안전성 보장(strong exception-safety guarantee)을 제공하도록 도움을 주는 방법이 있기 때문입니다. 그런데 이 기법은 멤버 버전 swap이 예외를 던지지 않아야 한다는 가정을 깔고 있습니다. 하필 멤버 버전만 이렇습니다! 비멤버 버전의 경우, 표준 swap은 복사 생성과 복사 대입에 기반하고 있는데 일반적으로 복사 생성 및 복사 대입 함수는 예외 발생이 허용되기 때문에 이런 제약을 받지 않습니다. 따라서 swap을 직접 만들면 예외를 던지지 않는 방법도 함께 준비해야 합니다. 다행히 대단히 좋은 swap 함수는 거의 항상 기본제공 타입(pimpl 관용구 기반의 설계에서 쓰이는 포인터처럼)을 사용한 연산으로 만들어지는데, 기본제공 타입을 사용한 연산은 절대로 예외를 던지지 않습니다.


정리

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