Notice
Recent Posts
Recent Comments
Link
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

로또

25. 예외를 던지지 않는 swap에 대한 지원도 생각해보자. 본문

책/Effective C++

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

아롱로또 2023. 11. 23. 16:06

 

다루는 내용

표준 swap, 멤버 swap, 비멤버 swap, 특수화한 std::swap 에 대해서 다루어보고, C++ 이름 탐색 규칙을 배워 원하는 swap 함수가 선택될 수 있도록 해보자.

표준 라이브러리 swap

swap은 두 객체의 값을 맞바꾸는 동작으로, 기본적으로 표준 라이브러리에서 제공하는 swap 알고리즘을 사용한다. 해당 알고리즘이 구현된 모습을 보면 우리가 알고 있는 swap과 큰 다를 바 없다.

namespace std{
    template<typename T>
    void swap(T& a, T& b){
        T temp(a);
        a = b;
        b = temp;
    }
}

복사 생성자와 복사 대입 연산자만 올바르게 지원하는 클래스라면, 표준 라이브러리에서 제공하는 swap 함수가 정상적으로 작동할 것이다.

 

하지만 위 코드에서 볼 수 있듯, swap 함수를 한 번 호출했을 뿐인데, 복사가 3번이나 발생한다.

type에 따라서는 복사가 아예 필요없는 경우도 있고, swap 중 복사 연산에 의해 손해를 보는 type들도 존재할 것이다.

 

Pointer to Implementation (pimpl)

복사 연산에 의해 손해를 보는 type 중 하나는 다른 type의 실제 데이터를 가리키는 포인터가 주성분인 type일 것이다.

이러한 type을 설계에 적용한 기법이 pimpl (pointer to implementaion) 이다.

다음은 pimpl 설계를 이용하여 만든 Widget 클래스의 예시이다.

class WidgetImpl{
private:
   int a, b, c; // 아무튼 많은 데이터들
   std::vector<double> v;
};

class Widget{
public:
   ...
   Widget& operator=(const Widget& rhs){
      *pimpl = *(rhs.pimpl); // 깊은 복사
   }
private:
   WidgetImpl* pimpl; // 실제 데이터를 갖고 있는 객체에 대한 포인터
};

Widget의 복사 대입 연산자를 보자. 많은 클래스에서 일반적으로 구현하듯이, 포인터에 대해서 깊은 복사를 하고 있다.

 

이러한 복사는, 똑같은 값을 가지는 객체를 생성할 때는 일반적으로 옳은 행동이다.

하지만 그렇다고 해서 swap에서도 무조건적으로 이렇게 사용해야 할까? 우리가 Widget 클래스를 사용하여 swap 함수를 호출할 때, swap 함수에서는 복사 생성자나 복사 대입 연산자를 실행하기 마련이다.

pimpl 기법이 적용된 클래스에 대해서는 각각의 객체에서 pimpl이 가리키는 값만 swap해주면 되는데, 굳이 WidgetImpl를 복사하여 임시 객체를 생성해주어야 할까?

슬프게도 이 방식이 표준 라이브러리에서 제공하는 swap의 방식이다.

 

완전 템플릿 특수화(Total Template Specialization)

Widget 클래스를 이용해 swap 사용 시 발생하는 오버헤드를 제거하기 위해, std::swap에게 "Widget 객체를 사용할 때는 일반적인 방법 대신 내부의 pimpl 포인터만 맞바꾸라." 라는 정보를 알려주자.

namespace std
{
   template<>
   void swap<Widget>(Widget& a, Widget& b){
      swap(a.pimpl, b.pimpl); // a와 b를 바꾸기 위해, pimpl 정보만 맞바꾼다
   }
} // namespace std

먼저 위 코드는 변수 pimpl이 private 멤버임으로 불가능함을 알자.

함수 시작 부분의 template<>은, 해당 함수가 std::swap의 완전 템플릿 특수화 함수라는 것을 알려 주는 부분이다.

그리고 함수 이름 뒤의 <Widget>은 T가 Widget일 경우에 대한 특수화라는 사실을 알려 주는 것이다.

즉, swap 함수에 Widget을 사용할 경우, 해당 함수가 사용되어야 한다는 의미이다.

 

일반적으로 namespace std의 구성요소는 함부로 변경할 수 없지만, Widget과 같이 우리가 직접 만든 Type에 대해 swap같은 표준 템플릿을 완전 특수화하는 것은 허용된다.

 

pimpl만을 바꾸게 하는 방법을 알았으니, 다음 문제인 private 멤버 변수에 접근하는 문제를 해결해보자.

Widget 클래스의 public 멤버 함수로 swap을 정의하고 swap의 특수화 함수에게 해당 멤버 함수를 호출하도록 변경하자.

class Widget{
public:
   void swap(Widget& other){ // Widge
      using std::swap; // 이후 설명
      swap(pimpl, other.pimpl); // std::swap(WidgetImpl*, WidgetImpl*) 함수 호출.
   }
private:
   WidgetImpl* pimpl;
};

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

위처럼 Widget 클래스에서 swap 함수를 멤버 함수로 새로 정의하고, std::swap에서 완전 템플릿 특수화를 통해 Widget 클래스의 swap 함수를 호출하게 함으로 private 멤버에 대한 접근 문제도 해결할 수 있다.

 

Widget과 WidgetImpl이 클래스가 아니라, 클래스 템플릿이라면?

어려워보이는 말이지만, 단순히 다음과 같은 형태일 때를 이야기하는 것이다.

template<typename T>
class WidgetImpl{...}

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

다음 코드처럼 단순히 해결 가능하면 좋겠지만, C++은 적법하지 않은 코드로 판단한다.

우리는 함수 템플릿을 부분적으로 특수화해 달라고 컴파일러에게 요청하였지만, C++은 클래스 템플릿에 대해서는 부분 특수화를 허용하지만 함수 템플릿에 대해서는 허용하지 않도록 정해져있기 때문이다.

namespace std
{
   template<typename T> // 불가!
   void swap<Widget<T> >(Widget<T>& a, Widget<T>& b){
      a.swap(b);
   }
}

 

함수 템플릿을 부분적으로 특수화하고 싶을 때, 흔히 다음처럼 오버로드 버전을 추가하곤 한다.

namespace std
{
   template<typename T> // swap<Widget<T> > -> swap
   void swap(Widget<T>& a, Widget<T>& b){
      a.swap(b);
   }
}

일반적으로 함수 템플릿의 오버로딩은 별 문제가 없지만, std는 특별한 namespace이기 때문에, std namespace에 대한 규칙도 특별하다.

std 내의 템플릿에 대한 완전 특수화는 가능하지만 std에 새로운 템플릿을 추가하는 것은 그렇지 않다는 것이다. std에 들어가는 구성요소의 결정은 전적으로 C++ 표준화 위원회에 달려있어 금지하고 있기 때문이다. 

이러한 방법은 컴파일도 되고 실행도 가능하지만 실행되는 결과가 미정의 사항이므로, std에는 아무것도 추가하지 말자.

 

C++ 이름 탐색 규칙

기껏 해결 방법을 알았다 싶었는데, std에 아무것도 추가하지 말라고 책에서 말하고 있다.

그럼 어떻게 하는가? 방법은 간단하다고 한다.

멤버 swap을 호출하는 비멤버 swap을 선언해 놓되, 해당 비멤버 함수를 std::swap의 특수화 버전이나 오버로딩 버전으로 선언하지 않으면 된다.

namespace WidgetStuff{ // Widget 관련 namespace 
   template<typename T>
   class Widget{
   public:
      void swap(Widget<T>& rhs){
         using std::swap; 
         swap(pimpl, rhs.pimpl); // std::swap 호출
      }
   private:
      WidgetImpl* pimpl;
   };
   // 비멤버 함수 WidgetStuff::swap.
   void swap(Widget<T>& a, Widget<T>& b){
      a.swap(b); // 멤버 함수 swap 호출
   }
}

이제, 어떤 코드가 두 Widget 객체에 대해 swap을 호출하면 컴파일러는 C++의 이름 탐색 규칙에 의해 WidgetStuff namespace 안에서 Widget 특수화 버전을 찾아낸다.

 

이름 탐색 규칙은 어떤 함수에 어떤 타입의 인자가 있으면 그 함수의 이름을 찾기 위해 해당 타입의 인자가 위치한 네임스페이스 내부의 이름을 탐색해 들어간다는 간단한 규칙이다. 인자 기반 탐색(argument dependent lookup) 혹은 쾨니그 탐색(koenig lookup)이란 이름으로 알려져 있다. 

template<typename T>
void DoSomething(T& obj1, T& obj2){
   ...
   swap(obj1, obj2);
   ...
}

위 코드에서, swap은 어떤 swap을 호출해야 할까? 가능성은 세 가지이다.

  1. namespace std에 있는 일반형 버전(확실히 존재한다.)
  2. namespace std의 일반형을 특수화한 버전(있을 수도, 없을 수도)
  3. type T 전용 버전(있을 수도, 없을 수도, 어떤 namespace 안에 있을 수도, 단, namespace std는 제외한다)

우리는 type T 전용 버전이 존재하면 이를 호출하고, 그렇지 않다면 std의 일반형 버전이 호출되도록 하고 싶다.

이를 위해서는 다음과 같이 작성하면 된다.

template<typename T>
void DoSomething(T& obj1, T& obj2){
   ...
   using std::swap; // std::swap을 해당 함수 안으로 끌어올 수 있도록 한다.
   swap(obj1, obj2);
   ...
}

컴파일러가 위의 swap 함수를 만났을 때 하는 일은, 현재의 상황에 딱 맞는 swap 함수를 찾는 것이다.

C++의 이름 탐색 규칙을 따라, 우선 전역 유효범위 혹은 type T와 동일한 namespace 안에 type T 전용 swap이 존재하는 지 찾는다.

T type 전용 swap이 없다면 컴파일러는 그 다음 순서를 밟는데, 위 함수가 std::swap을 볼 수 있게 해주는 using 선언이 함수 앞부분에 존재하므로 std의 swap을 쓰게끔 결정할 수도 있다.

하지만 이런 상황에도 컴파일러는 std::swap의 type T 전용 버전을 일반형 템플릿보다 더 우선적으로 선택하도록 정해져 있으므로, type T에 대한 특수화 버전이 준비되어 있다면 결국 그 특수화 버전이 사용된다.

 

정리

  1. 표준에서 제공하는 swap이 우리의 클래스 및 클래스 템플릿에 대해 납득할만한 효율을 보인다면 표준 swap을 사용하자.
  2. 그렇지 않다면(위처럼, 클래스가 pimpl 관용구와 비슷하게 만든 경우) 다음 규칙을 따르자.
    1. swap 함수를 클래스의 public 멤버 함수로 선언하자. (단, 예외를 던져서는 안된다. 항목 29에서 설명할 것.)
    2. 클래스 혹은 클래스 템플릿이 들어있는 namespace와 동일한 namespace에 비멤버 함수 swap을 만든다. 그리고 1번에서 만든 swap 멤버 함수를 이 비멤버 함수가 호출하도록 만들자.
    3. 클래스 템플릿이 아니라, 새로운 클래스를 만들고 있다면 해당 클래스에 대한 std::swap의 특수화 버전을 준비해두자. 그리고 이 특수화 버전에서도 swap 멤버 함수를 호출하도록 만들자.
  3. 사용자 입장에서 swap을 호출할 때, swap을 호출하는 함수가 std::swap을 볼 수 있도록 using 선언을 반드시 포함시키자. 그 다음에 swap을 호출하되, namespace 한정자(std::swap)를 붙이지 않는다.

 

swap이 예외를 던져서는 안되는 이유

항목 29. "예외 안전성이 확보되는 그 날 위해 싸우고 또 싸우자!"에서 자세히 설명한다.

대략적인 이유는, swap을 응용하는 방법 중 클래스 및 클래스 템플릿이 강력한 예외 안전성 보장을 제공하도록 도움을 주는 방법이 있다고 한다. 이 방법을 이용하기 위해서는 멤버 버전 swap이 예외를 던져선 안된다고 한다.

 

이것만은 잊지 말자!

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