관리 메뉴

기억을 위한 기록들

[CPP-effective] 4-8 예외를 던지지 않는 swap에 대한 지원도 생각해보자. 본문

C & CPP/Effective C++

[CPP-effective] 4-8 예외를 던지지 않는 swap에 대한 지원도 생각해보자.

에드윈H 2021. 4. 20. 14:54

두 객체의 값을 맞바꿔주는 각자의 값을 상대방에 주는 동작이다.

 

표준 라이브러리의 swap도 아래와 같이 생겼다.

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

 

코드를 보면 알겠찌만 복사만 제대로 지원하는 타입이기만 하면 어떤 타입의 객체든 swap이 가능하다.

하지만, 호출 한번에 복사가 3번씩 일어난다 a에서 temp로 b에서 a로 temp에서 b로.

 

복사를 하게 되면 손해를 보는 타입들 중 으뜸은 아마도 다른 타입의 실제 데이터를 가리키는 포인터가 주성분인 타입일 것이다.

 

이런 개념을 많이 쓰고 있는 기법이 바로 pimpl 관용구(pointer to implementation)이다.

pimpl 설계를 차용하여 만든 Widget 클래스 예를 보자.

 

class WidgetImpl {
public:
	//..
private:
	int a, b, c; //데이터가 많아질수록 복사 비용이 높아진다
	vector<double> v;
};
class Widget { //pimpl 관용구 사용한 클래스
public:
	//..
	Widget& operator=(const Widget& rhs) //widget을 복사하기 위해
	{ 
		//..
		*pImpl = *(rhs.pImpl);          //자신의 WidgetImpl 객체를 복사한다
	}
private:
	WidgetImpl* pImpl;  //Widget의 실제 데이터를 가진 객체에 대한 포인터
};

 

이렇게 만든 Widget객체를 직접 맞바꾼다면 pImpl 포인터만 살짝 바꿔주면 된다. 그런데 표준 swap 알고리즘은 알턱이 없다. Widget 객체 세 개를 복사하고, 거기에 WidgetImpl 객체 세 개도 복사해서 6번의 복사를 하게 된다..!

 

std::swap에 알려주는 것이다. Widget객체를 맞바꿀 때는 일반적인 방법말고 내부의 pImpl 포인터만 바꾸라고 알려주면 된다. 이 방법은 Widget에 대해 특수화(specialize) 하는 것인데, 아이디어만 간단히 확인해보면,

namespace std
{
	template<>
	void swap<Widget>(Widget& a, Widget& b) //아직 컴파일은 안된다.
	{
		swap(a.pImpl, b.pImpl); //각자의 포이터만 바꿔준다.
	}
}

 

우선 함수 시작부분의 template <>을 보면, 이 함수가 std::swap의 완전 템플릿 특수화(total template specialization) 함수라는 것을 컴파일러에게 알려주는 것이다. 그리고 함수 이름 뒤에 <Widget>은 T가 Widget일 경우에 대한 특수화라는 것을 알려주는 것이다.

 

타입에 무관한 swap 템플릿이 Widget에 적용될 때는 위의 함수를 사용하라는 것이다.

 

일반적으로 std네임스페이스의 구성요소는 함부로 변경하거나 할 수 없지만, 프로그래머가 직접 만든 타입(Widget 등)에 대해 표준 템플릿(swap 같은)을 완전 특수화 하는것이 허용된다. 지금처럼 말이다.

 

하지만, 위에 코드에서 아직 컴파일이 안된다고 적어놨다. 문법이 틀린건 아니고, a와 b에 있는 pImpl 포인터가 private멤버이기 때문이다.

 

특수화 함수를 프렌드로 선언할 수도 있지만, 이렇게 하면 표준 템플릿들에 쓰인 규칙과 어긋나게 된다.

그래서 swap이라는 public 멤버 함수를 선언하고 그 함수가 실제 맞바꾸기를 수행하도록 만든 후에, std::swap의 특수화 함수에게 그 멤버 함수를 호출하는 일을 맡긴다.

 

class Widget {
public:
	//..
	void swap(Widget& other) 
	{
		using std::swap;   //

		swap(pImpl, other.pImpl); //Widget을 바꾸기 위해 pImpl 각 pImpl 포인터를 바꿔준다.
	}
	//..
private:
	WidgetImpl* pImpl;
};

 

 

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

 

컴파일도 되고 기존 STL 컨테이너와도 일관성이 유지하게 된다.

 

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

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

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

swap 멤버 함수를 사용하는건 어렵지 않지만, std::swap을 특수화하는 데서 문제가 발생한다.

 

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

 

지금 함수템플릿(std::swap)을 부분적으로 특수화해 달라고 컴파일러에게 요청을 했으나,

C++는 클래스 템플릿에 대해서는 부분 특수화를 허용하지만, 함수 템플릿에 대해서는 허용하지 않도록 정해져 있다. 그러니 컴파일도 안된다.(되는 컴파일러도 있긴 하다..?)

 

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

namespace std
{
	template<typename T>   
	void swap(Widget<T>& a, Widget<T>& b)  //함수 이름 뒤에 <Widget>이 없다.
	{                                      //하지만 해당 코드도 유효하지 않는다.
		a.swap(b);
	}
}

 

 

일반적으로 함수 템플릿의 오버 로딩을 해도 별 문제는 없지만, std는 조금 특별한 네임스페이스이기에 규칙도 특별하다.

std 내의 템플릿에 대한 완전 특수화는 OK지만, std에 새로운 템플릿을 추가하는 것은 Not OK이다. (클래스든 함수든 안된다.)

 

std에 들어가는 구성요소는 전적으로 C++ 표준화 위원회에 달려있기 때문이다.

사실  std에 아무것도 추가 안 하는 게 맞다.

 

그럼 어떻게 해야 할까?? swap 호출해서 우리만의 효율 좋은 '템플릿 전용 버전'을  쓸 수 있으면 좋겠다는 것이다.

 

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

 

Widget예를 들어 추가로 WidgetStuff 네임스페이스에 들어 있다고 가정하면,

 

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 특수화 버전을 찾아낸다.

*(이름 탐색 규칙은 인자 기반 탐색(argument-dependent lookup) or 쾨니그 탐색(Koenig lookup)이란 이름으로 알려져 있다.)

 

어떤 상황을 놓고 예를 보면

template<typename T>
void DoSomething(T& a, T&b)
{
	//..
	swap(a, b);
	//..
}

여기서는 어떤 swap을 호출할까?? 가능성은 3가지로

1. std에 있는 일반형 버전 (무조건 있다.)

2. std의 일반형을 특수화 한 버전 (있을 수도 없을 수도)

3. T 타입 전용의 버전 (있을 수도 없을 수도)

 

만약에 T전용 버전이 있으면 그것이 호출되고, T타입 전용 버전이 없으면 std의 일반형 버전을 호출되도록 만들고 싶다면??

template<typename T>
void DoSomething(T& a, T&b)
{
        using std::swap; //std::swap을 이 함수로 끌어올 수 있도록 만드는 문장이다.
	//..
	swap(a, b);  //T타입 전용의 swap 호출
	//..
}

 

컴파일러는 위의 swap 호출 문을 만났을 때 하는 일은 현재의 상황에 딱 맞는 swap을 찾는 것이다.

C++의 이름 탐색 규칙을 따라

 

1. 우선 전역 유효 범위 혹은 타입 T와 동일한 네임스페이스 안에 T 전용의 swap이 있는지를 찾는다.

 

2. 1번의 T전용 swap이 없으면, std::swap을 볼 수 있게 해주는 using 선언(using declaration)이 함수 앞부분에 떡 하나 있기에 std의 swap을 쓰게 결정할 수도 있다. 

 

그래도 컴파일러는 std::swap의 T전용 버전을 일반형 템플릿보다 더 우선적으로 선택하도록 정해져 있기에, T에 대한 std::swap의 특수화 버전이 있다면 그 특수화 버전을 쓰게 된다.

 

주의할 점은 호출 문에 한정자를 잘못 붙이는 것인데, C++가 호출될 함수를 결정하는 메커니즘에 영향이 가기 때문이다.

std::swap(a,b); //잘못 된 방법이다.

 std의 swap으로 구속시키기에 더 나은 방법의 T전용 버전이 있더라도 무시해버리게 된다.

 

 

정리해보면

 

1. 표준에서 제공하는 swap이 클래스 및 클래스 템플릿에 대해 납득할 만한 효율을 보이면, 그냥 사용하자.

 

2. 그런데 표준 swap의 효율이 기대한 만큼 충분하지 않다면 다음과 같이 하자

1. 만든 타입으로 만들어진 객체의 맞바꾸는 swap 함수를 만들고 public 멤버로 두자

2. 만든 클래스 혹은 템플릿이 들어 있는 네임스페이스와 같은 네임스페이스에 비멤버 swap를 만들자. 그리고 1번에서 만든 swap멤버 함수를 이 비멤버 함수가 호출하도록 만들자

3. 새로운 클래스(클래스 템플릿 X)를 만들고 있다면, 그 클래스에 대한 std::swap의 특수화 버전을 준비해 두자. 그리고 이 특수화 버전에서도 swap멤버 함수를 호출하도록 만들자.

3. 사용자 입장에서 swap을 호출할 때, swap을 호출하는 함수가 std::swap을 볼 수 도록 using 선언을 반드시 포함시키자.

그다음 swap을 호출하되, 네임스페이스 한정자를 붙이지 말자.

 

그리고 진짜 마지막으로 멤버 버전의 swap 함수에서 예외를 던지지 않도록 만들자. 그 이유는 swap을 진짜 쓸모 있게 응용하는 방법들 중에 클래스가 강력한 예외 안전성 보장(strong exception-safety guarantee)을 제공하도록 도움을 주는 방법이 있기 때문이다.

 

비멤버 버전은 표준 swap은 복사 생성과 복사 대입에 기반하는데, 일반적으로 복사 생성 및 복사 대입 함수는 예외 발생이 허용되기에 이런 제약을 받지 않는다. swap을 직접 만들어 볼 거면 예외를 던지지 않는 방법도 함께 준비하는 센스가 필요하다. 효율이 대단히 좋은 swap함수는 거의 항상 기본 제공 타입(pimpl 관용구 기반의 설계에서 쓰이는 포인터처럼)을 사용한 연산으로 만들어지기 때문이다. 그리고 기본제공 타입을 사용한 연산은 절대로 예외를 던지지 않는다.

 

휴 길었다.

 

정리

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