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

Jangmanbo·2023년 5월 30일
0

Effective C++

목록 보기
25/26

※ 특수화, 부분 특수화에 대한 이해가 선행되어야 하는 항목이다.
1. 함수 템플릿과 클래스 템플릿
2. C++ 템플릿 특수화/부분 특수화
3. C++ 함수 부분 특수화



swap

  • 두 객체의 값을 맞바꿈
  • 자기대입 현상(항목 11)의 가능성에 대처하기 위한 대표적인 메커니즘
namespace std {
	template<typename T>
    void swap(T& a, T& b)
    {
    	T temp(a);	// a->temp 복사
        a = b;		// b->a 복사
        b = temp;	// temp->b 복사
    }
}

다음은 표준 라이브러리제서 제공하는 swap 알고리즘이다.
우리가 알고있는 swap과 다르지 않은 것을 알 수 있다.

복사 생성자와 복사 대입 연산자를 통해 복사가 가능한 타입이기만 하면 어떤 타입의 객체든 맞바꾸기 동작을 수행한다.

문제점

swap 호출 1번에 복사가 3번이나 일어난다.
타입에 따라서는 이런 복사 과정이 필요 없는 경우도 있는데, 대표적으로 포인터가 있다.

PIMPL (pointer to implementation)

  • 포인터를 활용해서 선언부에서 데이터 구현 세부를 감추는 프로그래밍 방법
  • pImpl에 대한 항목이 아니라 예시 설명을 위함이니 자세한 내용은 생략
// Widget의 실제 데이터를 나타내는 클래스
class WidgetImpl {
public:
	...
private:
	int a, b, c;
    vector<double> v;
    ...	// 복사 비용이 높은 수많은 데이터들...
};

// pImpl 관용구를 사용한 클래스
class Widget {
public:
	Widget(const Widget& rhs);
    
    // Widget을 복사하려면 WidgetImpl 객체를 복사해야 한다.
    Widget& opertaor=(const Widget& rhs)
    {
    	...
        *pImpl = *(rhs.pImpl);
        ...
    }
    
    ...
private:
	WidgetImpl *pImpl;	// Widget의 실제 데이터를 가진 객체에 대한 포인터
};

Widget객체를 우리가 직접 맞바꾼다면 pImpl포인터만 바꾸기만 하면 된다. 그러나 이를 표준 알고리즘인 swap에게 맡긴다면 Widget 객체를 3번 복사(+WidgetImpl도 3번 복사)할 것이다.



해결법

method 1 : 특수화

표준 swap이 Widget객체를 swap할 때는 일반적인 방법이 아니라 내부의 pImpl 포인터만 맞바꾸도록 만들자!

namespace std {
	template<>
    void swap<Widget>(Widget& a, Widget& b)	// T가 Widget일 경우에 대해 std::swap 특수화
    {
    	swap(a.pImpl, b.pImpl);	// pImpl 포인터만 맞바꾸기
    }
}

template<> : 이 함수가 std::swap의 완전 템플릿 특수화(total template specializaation) 함수라는 것을 컴파일러에게 알림
swap<Widget> : TWidget일 경우에 대한 특수화임을 알림

참고

  • 일반적으로 std 네임스페이스의 구성요소는 함부로 변경 불가
  • but 프로그래머가 직접 만든 타입(ex. Widget)에 대해 표준 템플릿(ex.swap)을 완전 특수화하는 것은 허용

그러나 위의 코드는 컴파일이 불가능하다. pImpl 포인터가 private 멤버이기 때문이다.
특수화 함수를 프렌드로 선언해도 되지만, 표준 템플릿의 규칙과 어긋나므로 일관성을 해치게 된다.

method 2 : 멤버함수에게 맡기자!

Widget 안에 실제 맞바꾸기를 수행하는 public 멤버 함수 swap를 만들고 std::swap의 특수화 함수가 그 멤버 함수를 호출한다.

class Widget {
public:
	...
    void swap(Widget& other)
    {
    	using std::swap;	// 이후 설명
        swap(pImpl, other.pImpl);	// 일반적인 표준 swap 사용하여 pImpl 포인터 맞바꾸기
    }
    ...
};

namespace std {
	template<>
    void swap<Widget>(Widget& a, Widget& b)	// T가 Widget일 경우에 대해 std::swap 특수화
    {
    	a.swap(b);	// Widget의 swap 함수 호출
    }
}

이렇게 하면 컴파일도 성공적이고, 기존 STL 컨테이너와의 일관성도 유지할 수 있다.



if : Widget, WidgetImpl가 클래스 템플릿이라면?

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

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

이전에 설명한 바와 동일하게 구현하면 되지 않을까 라고 생각할 것이다.
이전처럼 swap 멤버 함수를 Widget에 넣는 것은 어렵지 않지만, swap 함수의 특수화가 불가능하다.

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

이렇게 Widget<T>에 대해 특수화하려는 코드는 C++에서 불가능하다.
이는 즉 함수 템플릿(std::swap)을 부분적으로 특수화해 달라고 컴파일러에게 요청한 것인데,
C++은 클래스 템플릿에 대한 부분 특수화는 허용하지만, 함수 템플릿에 대한 부분 특수화는 허용하지 않기 때문이다.

참고: 함수 템플릿을 특수화하지 말아야 하는 이유



해결법

method 1 : 함수 템플릿을 부분 특수화하고 싶다면 오버로드 버전을 하나 추가하자!

namespace std {
	template<typename T>
    void swap(Widget<T>& a, Widget<T>& b)	// std::swap 오버로드 시도. 
    { a.swap(b); }
}

※ 위 코드는 유효하지 않다!!!

namespace std

  • 일반적인 함수 템플릿은 오버로딩이 가능하지만, std는 특별한 네임스페이스이다.
  • std 내의 템플릿에 대한 완전 특수화는 가능하지만, std에 새로운 템플릿을 추가하는 것은 금지하고 있다.
  • 따라서 std의 영역을 침범해도 일단 컴파일은 성공하지만, 실행 결과 미정의 사항이다.

method 2: 멤버 swap을 호출하는 비멤버 swap 선언

아무튼 우리의 목적은 일반적인 std::swap이 아니라 우리가 만든 효율적인 템플릿 전용 버전 swap을 쓰는 것이다.
따라서 std::swap의 특수화나 오버로딩 버전이 아닌 비멤버 함수 swap이 멤버 swap을 호출하면 된다!

namespace WidgetStuff {
	...
    template<typename T>
    class Widget { ... };
    ...
    
    // 비멤버 swap
    // std 네임스페이스와는 관련 X
    template<typename T>
    void swap(Widget<T>& a, Widget<T>& b)    
    {
    	a.swap(b); 	// 멤버 swap 호출
    }		
}

어떤 코드가 두 Widget 객체에 대해 swap을 호출했을 때, 컴파일러는 C++의 이름 탐색 규칙인 인자 기반 탐색/쾨티그 탐색에 의해 WidgetStuff 네임스페이스의 swap을 찾아낼 것이다.


어떤 swap이 호출될까?

template<typename T>
void doSomething(T& obj1, T& obj2)
{
	...
    swap(obj1, obj2);
    ...
}
  1. std에 있는 일반 버전: 무조건 있음
  2. std의 일반형을 특수화한 버전: 있을 수도, 없을 수도
  3. T타입 전용 버전: 있을 수도, 없을 수도(일단 std에는 없음)

만일 3번이 있으면 3번을, 없으면 1번 버전을 호출하고 싶다면 아래 코드처럼 구현하면 된다.

template<typename T>
void doSomething(T& obj1, T& obj2)
{
	using std::swap;	// std::swap을 이 함수 안으로 끌어올 수 있도록 함
	...
    swap(obj1, obj2);	// T 타입 전용 swap 호출
    ...
}

컴파일러 실행 순서

  1. 우선 C++의 이름 탐색 규칙에 따라 전혁 유효범위나 타입 T와 동일한 네임스페이스의 T 전용 swap을 찾음
    • ex. T가 WidgetStuff 네임스페이스의 Widget이라면 WidgetStuffswap을 찾음
  2. T 전용 swap이 없으면 using std::swap;으로 인해 std::swap을 사용하게 됨
    • 이때 std::swap의 T에 대한 특수화 버전이 있다면 특수화 버전 사용


주의점: 한정자를 잘못 붙이지 말자

std::swap(obj1, opbj2);		// 잘못된 방법

위의 코드는 std::swap 외에는 어떤 템플릿 특수화 버전들까지도 모두 사용하지 않도록 한다.
결국 딱 맞는 T 전용 버전이 다른 곳에 있을지도 모르는데 이를 완전히 무시하게 된다.

그래서 std::swap을 완전 특수화하는 것이 중요하다. 한정자를 잘못 붙여도 std 내의 T 전용 swap 함수를 사용할 수 있기 때문이다.



정리

만약 표준 swap이 비효율적이라면

  1. 두 객체의 값을 효율적으로 맞바꾸는 함수 swap을 public 멤버 함수로 선언 (단, 예외를 던지면 안됨)
  2. 해당 클래스나 템플릿이 들어있는 네임스페이스 안에 비멤버 swap 선언. 1번에서 만든 멤버 swap을 비멤버 swap이 호출
  3. 새로운 클래스(클래스 템플릿 X)를 만들고 있다면, 그 클래스에 대한 std::swap의 특수화 버전 선언. 2번에서 만든 멤버 swap을 이 특수화 버전이 호출

마지막으로, 사용자가 swap을 호출할 때 swap을 호출하는 함수가 std::swap도 사용할 수 있도록 using 선언을 반드시 포함+네임스페이스 한정자 붙이지 않기


부록

멤버 swap은 절대 예외를 던져서는 안된다.
swap을 응용하는 방법 중에 클래스(+클래스 템플릿)가 강력한 예외 안정성 보장을 제공하도록 도움을 주는 방법이 있기 때문이다....?

무슨 말인지 모르겠다. 나중에 정리,,

0개의 댓글