본문 바로가기

study/C++

[C++][Effective C++] 18~25. 설계 및 선언

728x90

[Effective C++(3판)]에 대한 내용을 공부하면서 정리한 내용이다.

 

이번에 요약하는 내용은 18~25 항목으로 아래와 같다.

  • 18. 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자
  • 19. 클래스 설계는 타입 설계와 똑같이 취급하자
  • 20. '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다
  • 21. 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자
  • 22. 데이터 멤버가 선언될 곳은 private 영역임을 명심하자
  • 23. 멤버 함수보다 비멤버 비프렌드 함수와 더 가까워지자
  • 24. 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자
  • 25. 예외를 던지지 않는 swap에 대한 지원도 생각해 보자

요약

18. 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자

인터페이스를 사용하는 사용자는 인터페이스를 똑바로 쓰고 싶어 할 것이다. 따라서 행여 잘못 사용할 경우에는 인터페이스가 최소한의 항의의 몸부림이라도 보여주는 것은 의무다. 어떤 인터페이스를 통한 결과 코드가 생각과 달리 동작한다면, 그 코드는 애초에 컴파일되지 않아야 맞다.

 

'제대로 쓰기엔 쉽고 엉터리로 쓰기엔 어려운' 인터페이스를 개발하려면 사용자가 저지를 만한 실수의 종류를 미리 알고 있어야 한다.

 

날짜를 나타내는 클래스의 생성자를 설계하고 있다고 가정해보자. 아래의 경우는 사용자의 잘못된 입력에 대한 보완이 되어 있지 않다. (물론 생성자 안에서 점검할 수는 있겠지만)

class CDate {
public:
	CDate(int month, int day, int year);
};

int main(void) {
	CDate d(30, 3, 1995); // 인자 순서를 잘못 적음
	CDate d(3, 39, 1995); // 오타로 30이 아닌 39가 입력 됨
}

 

위의 생성자를 보다 보강하기 위해, 새로운 타입을 지정하는 것이다. 새로운 타입으로 인터페이스의 조건을 좀 더 명확히 하면 실수를 보다 방지할 수 있다.

class CDay {
public:
	explicit CDay(int d) : m_val(d) {};

private:
	int m_val;
};

class CMonth {
public:
	explicit CMonth(int m) : m_val(m) {};

private:
	int m_val;
};

class CYear {
public:
	explicit CYear(int y) : m_val(y) {};

private:
	int m_val;
};

class CDate {
public:
	CDate(const CMonth &m, const CDay &d, const CYear &y);
};

int main(void) {
	CDate(30, 3, 1995); // Error. 타입이 틀림 (explicit은 암시적 형변환 금함)
	CDate d(CDay(30), CMonth(3), CYear(1995)); // Error. 타입이 틀림 (Month, Date, Year)
	CDate d(CMonth(3), CDay(30), CYear(1995)); // OK
}

 

 

이렇게 적절한 타입을 제대로 준비했다면, 타입의 값에 제약을 가해도 괜찮은 경우가 있다. 예를 들어 월은 12가 최대이기 때문에 이 사실을 제약으로 사용할 수 있다. 한 가지 방법은 enum인데, 타입 안전성을 신뢰할 수 없다. 오히려 유효한 CMonth의 집합을 미리 정의해도 괜찮다.

class CMonth {
public:
	static CMonth Jan() { return CMonth(1); }
	static CMonth Feb() { return CMonth(2); }
	//...
	static CMonth Dec() { return CMonth(12); }

private:
	explicit CMonth(int m) : m_val(m) {}

private:
	int m_val;
};

 

'그렇게 하지 않을 번듯한 이유가 없다면 사용자 정의 타입은 기본 제공 타입(int)처럼 동작하게 만들어라.' 즉, 사용자가 예상되는 동작들로 만들어놓고 일관성 갖는 인터페이스를 제공할 수 있도록 하는 것이다.

 

19. 클래스 설계는 타입 설계와 똑같이 취급하자

C++도 모든 객체 지향 프로그래밍 언어와 마찬가지로 새로운 클래스를 정의하는 것은 새로운 타입을 정의하는 것과 같다. 함수와 연산자를 오버 로드하고, 메모리 할당 및 해제를 제어하며, 객체 초기화 및 종료 처리를 정의하는 작업을 해야 한다.

좋은 타입이란 문법(syntax)이 자연스럽고, 의미 구조(semantics)가 직관적이며, 효율적인 구현이 한 가지 이상 가능하다. 충분한 고민이 없다면 세 가지 중 어느 것도 달성하기 어려울 수 있다.

 

아래 질문들의 대답에 따라 설계를 제한하는 것들이 생기게 된다.

  1. 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가?
    • 생성자와 소멸자에 대한 설계가 바뀔 수 있다.
    • 메모리 할당 함수(new, new [], delete, delete [])를 직접 작성할 경우에는 이들 함수에 대한 설계에도 영향을 미친다.
  2. 객체 초기화는 객체 대입과 어떻게 달라야 하는가?
    • 생성자와 대입 연산자의 동작의 차이를 결정짓는 요소다.
  3. 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가?
    • 값에 의한 전달을 구현하는 쪽이 복사 생성자다.
  4. 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가?
    • 클래스의 불변속성(invariant)은 클래스 차원에서 지켜주어야 한다.
    • 불변속성에 따라 멤버 함수 안에서 에러 점검 루틴이 결정된다. 특히 쓰기 작업을 하는 함수에서 많이 다룬다.
  5. 기존의 클래스 상속 계통망(inheritance graph)에 맞출 것인가?
    • 기존 클래스를 상속하게 되면 이들 클래스에 의해 제약을 받게 된다.
    • 특히 멤버 함수가 가상인가 비가상인가의 여부가 큰 요인이다.
  6. 어떤 종류의 타입 변환을 허용할 것인가?
    • 암시적/명시적 변환을 허용할 지에 따라서 구현해야 할 변환 연산자를 확인해야 한다.
  7. 어떤 연산자와 함수를 두어야 의미가 있을까?
    • 어떤 것을 멤버 함수로 적당할 것이고, 몇몇은 그렇지 않을 것이다.
  8. 표준 함수들 중 어떤 것을 허용하지 말 것인가?
    • private으로 선언해야 할 함수에 해당한다.
  9. 새로운 타입의 멤버에 대한 접근 권한을 어느 쪽에 줄 것인가?
    • public/protected/private 영역 어느 곳에 멤버를 둘 것인가를 결정해야 한다.
    • 프렌드로 만들어야 할 클래스 및 함수를 결정해야 한다.
    • 어떤 클래스를 중첩시켜도 되는 지를 결정해야 한다.
  10. '선언되지 않은 인터페이스'로 무엇을 둘 것인가?
    • 어떤 부분을 보장할 수 있는 부분으로, 수행 성능 및 예외 안전성 그리고 자원 사용(잠금 및 메모리 등) 부분이 있다.
  11. 새로 만드는 타입이 얼마나 일반적인가?
    • 타입 하나를 정의하는 것보다는 동일 계열의 타입 군(family of types) 전체를 정의하려는 것일 수도 있다.
    • 이때는 새로운 클래스가 아니라 새로운 클래스 템플릿을 정의해야 한다.
  12. 정말로 꼭 필요한 타입인가?
    • 기존 클래스의 몇 개가 아쉬워 파생 클래스를 새로 만들고 있다면 차라리 간단히 비멤버 함수라든지 템플릿을 몇 개 더 정의하는 게 낫다.

 

20. '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다

C++에서는 기본적으로 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때 '값에 의한 전달(pass-by-value)'방식을 사용한다. 다른 방식을 지정하지 않는 한, 실제 인자의 '사본'을 통해 매개변수가 초기화되며, 함수 호출한 쪽은 함수가 반환한 값의 '사본'을 돌려받는다. 이런 사본을 만드는 과정은 복사 생성자를 통해 이루어진다. 이 사본 과정 때문에 고비용 연산이 된다.

사본을 복사하게 되면 복사 생성자 호출, 소멸자 호출 두 번이 일어난다. 멤버 객체가 더 있다면 추가로 비용이 발생하게 된다.

 

생성자와 소멸자를 몇 단계씩 거치지 않을 수 있는 방법은 상수 객체에 대한 참조자(reference-to-const)로 전달하는 것이다. 이를 이용하면 훨씬 효율적인 코드를 작성할 수 있다.

 

참조에 의한 전달을 할 때의 다른 장점 중 하나는 복사 손실 문제(slicing problem)이 없어진다는 점이다. 복사 손실 문제는 드물게 파생 클래스 객체가 기본 클래스 객체로 전달되는 과정에서 파생 클래스 부분이 잘려 전달되지 않는 것이다.

값에 의한 복사가 이루어진다면 파생 클래스를 넘겨주었음에도 기본 클래스로 받아 처리하면서 다른 부분이 잘린 채로 처리하게 된다. 하지만, 상수 객체 참조자를 통해 넘겨주게 되면 함수 안에서 넘겨받은 객체가 잘리지 않는다는 장점이 있다.

 

다만, 기본 타입으로 전달할 때는 '값에 의한 전달'이 효율적인 경우가 많다. 반복자와 함수 객체도 마찬가지다. 반복자와 함수 객체는 예전부터 값으로 전달되도록 설계되어왔기 때문이다.

 

21. 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자

이는 실제로 존재하지 않는 객체의 참조자를 넘기게 되는 문제가 생긴다. 참조자는 그냥 "이름"이다. 존재하는 객체에 붙는 또 다른 이름이라는 것이다. 

 

호출된 함수에서 스택에 객체를 만들었다고 해보자. 함수가 종료되면 이 객체는 해제된다. 그런데 함수에서 스택의 객체에 대한 참조자를 반환한다면, 존재하지 않는 객체의 참조자를 반환하는 것이다.

 

그렇다면, 함수 안에서 동적(new)으로 생성한 객체(포인터)에 대한 참조자(포인터 객체의 참조자)를 반환하는 것은 어떨까? 이것 또한 문제가 생긴다. 동적으로 생성한 객체에 대한 자원을 누가 해제(delete)해주는 지다. 아래의 예를 보도록 하자. operator* 호출이 두 번 되었기 때문에 delete 도 두 번 되어야 한다. 그런데 사용자 쪽에서는 이를 할 수 없다. 아래의 글을 보도록 하자.

class CRational {
public:
	const CRational &operator*(const CRational &lhs, const CRational &rhs) {
		CRational *result = new CRational(lhs.n * rhs.n, lhs.d * rhs.d);
		return *result;
	}

private:
	int n, d;
};

int main(void) {
	CRational w, x, y, z;
	w = x * y * z;
}

 

그렇다면, static으로 미리 선언해두었던 멤버는 어떨까? 스택의 지역 객체나 힙의 동적 객체가 아니기 때문에 함수에서 클래스의 생성자가 호출되지 않는다. 그러니까 문제없지 않을까?

이는 static(정적) 객체가 항상 그렇듯이 스레드 안전성에 대한 문제가 있다. 또한, 아래와 같은 문제도 있을 수 있다. 아래의 조건 문에서는 항상 true 값이 나오게 된다.

class CRational {
public:
	int n, d;
};

const CRational &operator*(const CRational &lhs, const CRational &rhs) {
	static CRational result;

	result.n = lhs.n * rhs.n;
	result.d = lhs.d * rhs.d;

	return result;
}

bool operator==(const CRational &lhs, const CRational &rhs) {
	//...
}

int main(void) {
	CRational a, b, c, d;
	//..

	// static 변수에 저장하고 있기 때문에 어떤 일이 일어날까?
	if ( (a * b) == (c * d) ) {
		//...
	}
}

 

이제는 정적 배열까지 가야 할까? 이는 또 다른 문제를 뱉을 뿐이다.

 

새로운 객체를 반환해야 하는 함수는 정도(正道)가 있다. 바로 '새로운 객체를 반환하게 하는 것'이다. 즉, 아래처럼 만들면 된다.

inline const CRational operator*(const CRational &lhs, const CRational &rhs) {
	return CRational(lhs.n * rhs.n, lhs.d * rhs.d);
}

 

위와 같이 작성할 경우 반환 값을 생성(생성자 호출)하고, 소멸(소멸자 호출)시키는 비용이 들지 않은가? 맞다. 하지만, 여기에 들어가는 비용은 올바른 동작에 지불되는 작은 비용이다. 다만, 몇몇 조건하에서는 최적화 메커니즘에 의해 반환 값에 대한 생성과 소멸 동작이 안전하게 제거될 수 있다. 반환 값 최적화RVO(Return Value Optimization)이라 부른다. 즉, 위의 과정은 의도했던 대로 비용을 낭비하지 않고 동작할 것이다.

 

22. 데이터 멤버가 선언될 곳은 private 영역임을 명심하자

데이터 멤버가 public으로 되면 왜 안될까? 우선 문법적 일관성 때문이다. 데이터 멤버가 public이 아니라면, 사용자 쪽에서 어떤 객체를 접근할 수 있는 유일한 수단은 멤버 함수일 것이다. 그렇다면 사용자에서는 그 함수들을 쓰기만 하면 된다. 개발자 측면에서도 함수로 데이터 멤버에 대한 접근성을 보다 정교하게 제어할 수 있다.

또 다른 이유로는 캡슐화(encapsulation)가 있다. 함수를 통해서만 데이터 멤버에 접근할 수 있도록 해야, 데이터 멤버를 나중에 유연하게 교체할 수 있다. 무슨 기능을 만들던 내부적으로 방법(코드)을 바꾸더라도 사용자에게 영향이 없다는 것이다. 이렇게 구현상의 융통성을 누리게 되면 다양한 일들을 처리할 수 있다. 클래스의 불변 속성 및 사전 / 사후 조건 검증, 동기화 등의 일을 말이다. 

C++ 세상에서 public이란 '캡슐화되지 않았다'는 뜻이며, 실질적으로 '캡슐화되지 않았다'라는 말은 '바꿀 수 없다'라는 것과 동일하다. 따라서 내부 구현을 유연하고 개선할 수 있게 만들기 위해서는 캡슐화가 반드시 필요하다.

 

protected 데이터 멤버는 어떨까? public 보다 캡슐화가 되어 있지만 결과적으로 동일하다. 어떤 것이 바뀌면 깨질 가능성을 가진 코드가 늘어난다는 것은 캡슐화의 정드는 그에 반비례해서 작아진다는 것을 의미한다.

어떤 public 데이터 멤버가 있고, 이를 제거한다고 가정해보자. 이 멤버와 연관된 모든 코드가 망가질 것이며, 이것을 사용하는 사용자 코드는 모두 망가질 것이다. 이번엔 protected 데이터 멤버라고 생각해보자. 이번에는 파생 클래스 전부가 망가질 것이다. 조금 가렸을 뿐 영향도는 마찬가지로 크다는 것이다.

 

따라서 데이터 멤버는 private이냐 private이 아니냐 정도로 구분하자.

 

23. 멤버 함수보다 비멤버 비프렌드 함수와 더 가까워지자

웹브라우저를 나타내는 클래스가 하나 있다고 가정하자. 웹브라우저 클래스라면 이런저런 함수를 통해 제공하는 여러 기능이 있을 것이다. 캐시를 비우는 함수, 방문 기록을 지우는 함수, 쿠키를 전부 제거하는 함수도 그중에 있을 것이다. 하지만 사용자 중에는 이 세 동작을 한 번에 하고 싶은 사람도 있을 것이다. 따라서, 세 함수를 모아서 불러주는 함수도 준비할 수도 있다.

class CWebBrowser() {
public:
	void clearChache();		// 캐시 비우기
	void clearHistory();	// 접속 기록 지우기
	void removeCookies();	// 쿠키 제거하기

	void clearEverything(); // clearChache, clearHistory, removeCookies 호출
};

 

물론 위의 clearEverything() 함수는 비멤버 함수로도 제공할 수 있다. 웹브라우저 객체의 각각의 멤버 함수를 호출해주면 된다.

void clearBrowser(CWebBrowser &web) {
	web.clearCache();
	web.clearHistory();
	web.removeCookies();
}

 

멤버 버전의 clearEverything()이 더 나을까? 비멤버 버전의 clearBrowser()가 더 나을까?

객체 지향 법칙에 따르면 데이터와 데이터를 기반으로 동작하는 함수는 한 데 묶여 있어야 한다. 따라서, 멤버 버전이 더 낫다고 생각할 수 있으나 이는 틀렸다.

객체 지향 법칙은 할 수 있는 만큼 데이터를 캡슐화하라고 주장하고 있다. 멤버 버전인 clearEverything()을 보면 비멤버 버전의 clearBrowser() 보다 캡슐화 정도가 형편없다. 오히려 비멤버 함수를 사용했을 때 CWebBrowser 관련 기능을 구성하는 데 있어서 더 나은 패키징 유연성(packaging flexibility)을 제공한다. 이로 인해 추가적으로 컴파일 의존도도 낮추고 WebBrowser의 확장성도 높일 수 있다.

그래서 비멤버 버전이 더 낫다는 것이다.

 

캡슐화를 먼저 얘기해보자. 캡슐화가 늘어나면 그만큼 밖에서 볼 수 있는 것들이 줄어든다. 밖에서 볼 수 있는 것들이 줄어들면, 변경에 대한 영향을 받는 범위가 적어진다. 따라서, 유연성은 커질 수 있게 된다. 이로 인해서 캡슐화에 가치를 두는 것이다.

어떤 객체의 데이터를 직접 접근할 수 있는 코드가 적을수록 그 데이터는 많이 캡슐화된 것이고, 그 객체가 가진 데이터의 특징을 바꿀 자유도가 높아진다는 말이다. 

따라서, 데이터 멤버는 private 멤버여야 한다는 것이다. private이 아닌 데이터 멤버는 접근 가능한 함수를 수없이 많이 만들 수 있다. 반면, private 멤버라면 해당 데이터 멤버에 접근 가능한 함수는 개수가 가능하다. 그 클래스의 멤버 함수와 프렌드 함수가 전부이기 때문이다.

다시, 이 캡슐화의 개념을 가지고 멤버 함수냐 비멤버 함수냐를 살펴보도록 하자. 캡슐화 정도가 높은 쪽은 어떤 것인가? 단연 후자이다. 전자의 경우에는 데이터에 접근 가능한 함수가 하나 더 늘어난 것이다. 비멤버 비프렌드 함수는 private 멤버 부분을 점근 할 수 있는 함수의 개수에 추가되지 않는다. 즉, 캡슐화의 개념에서는 비멤버 비프렌드 함수가 더 적절한 조치라는 것이다.

 

여기서 주의할 점이 있다.

  1. 해당 내용은 비멤버 비프렌드 함수에만 적용된다.
    • 프렌드 함수는 동일하게 private 멤버에 대한 접근 권한이 있기 때문에 캡슐화가 약해진다.
    • 즉, (멤버 vs 비멤버)가 아닌, (멤버 vs 비멤버 비프렌드)가 되어야 한다. 
  2. "함수는 어떤 클래스의 비멤버가 되어야 한다"라는 캡슐화에 근거한 주장이 "그 함수는 다른 클래스의 멤버가 될 수 없다"라는 의미가 아니다.
    • 모든 함수를 클래스 안에 넣지 않아도 큰일이 나지 않는다.
    • 즉, clearBrowser() 함수를 어딘가에 유틸성의 정적 멤버 함수로 만들어도 된다는 것이다.
    • 어쨌든 이 함수가 CWebBrowser 클래스의 멤버 혹은 프렌드가 아니면 된다는 점이다. private 멤버의 캡슐화에 영향을 주지 않는 것이 중요하다.

 

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

앞선 내용 중에 '클래스에서 암시적 타입 변환을 지원하는 것은 일반적으로 못된 생각이다'라는 것이 있다. 이 규칙에도 예외가 있는데, 흔한 예외 중 하나는 숫자 타입이다. 유리수 클래스가 있다고 한다면, 정수에서 유리수로의 암시적 변환은 허용하자고 판단하더라도 이상하거나 틀리지 않다.

 

유리수(Rational) 클래스가 있다고 해보자. 유리수에 대한 사칙 연산을 제공하고 싶을 것이다. 이때 이들은 멤버 함수가 나을까? 비멤버 함수가 좋을까? 비멤버 비프렌드 함수가 좋을까?

 

우선, 항목 23의 내용은 잠깐 잊고 당연히 멤버 함수가 낫다고 생각해보자. 아래와 같이 구현한다면, 유리수 곱셈을 쉽게 할 수 있다.

class CRational {
public:
	CRational(int numerator = 0, int denominator = 1); // 생성자에 explit을 붙이지 않은 이유는, int에서 CRational로의 암시적 변환을 허용하기 위함

	// 멤버 함수로 연산자 선언
	const CRational operator*(const CRational &rhs) const;
};

int main(void) {
	CRational oneEighth(1, 8);
	CRational oneHalf(1, 2);

	CRational result = oneHalf * oneEighth;
	result = result * oneEighth;
}

 

위의 코드를 보면 성에 안 차는 부분이 있을 수 있다. 혼합형(mixed-mode) 수치 연산이 불가능하기 때문이다. 즉, CRational 객체와 int 객체와도 곱하고 싶은 것이다. 이 혼합형 수치 연산에서 위의 코드는 반쪽자리 연산을 제공한다.

result = oneHalf * 2; // OK.		oneHalf.operator*(2)
result = 2 * oneHalf; // Error.		2.operator*(oneHalf)

 

연산에 실패한 두 번째 예제를 보도록 하자. 정수 2에 대한 클래스 같은 것이 연관되어 있지 않아 operator* 연산자에 대한 멤버 함수도 없다. 멤버 함수가 없으니 비멤버 함수를 찾아보자. 당연히 비멤버 함수로 선언한 적도 없기 때문에 두 번째 예제는 실패한 것이다.

첫 번째 예제는 CRational의 암시적 변환이 가능한 생성자가 있는 덕분에 가능했다.

 

이번에는 operator* 연산자에 대해 비멤버 함수로 만들어서 연산자의 모든 인자에 대해 암시적 변환이 가능하게끔 해보자.

class CRational {
public:
	CRational(int numerator = 0, int denominator = 1); // 생성자에 explit을 붙이지 않은 이유는, int에서 CRational로의 암시적 변환을 허용하기 위함

	int numerator() const;
	int denominator() const;
};

const CRational operator*(const CRational &lhs, const CRational &rhs) {
	return CRational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}

int main(void) {
	CRational oneEighth(1, 8);
	CRational oneHalf(1, 2);

	CRational result;
	result = oneHalf * 2; // OK.
	result = 2 * oneHalf; // OK.
}

 

마지막으로, operator* 함수가 CRational 클래스의 프렌드 함수로 있어야 하는 지를 살펴보자. 지금의 예제에서는 아니다. operator* 함수를 오로지 public 인터페이스만으로 구현했기 때문이다. 프렌드 함수를 피할 수 있으면 피하도록 하자. 멤버 함수면 안된다는 의미가 프렌드 함수여야 함을 의미하는 것은 아니다.

 

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

swap은 초창기 STL에 포함된 이래로 예외 안전성 프로그래밍(항목 29에서 다룰 예정)에 없어선 안 될 감초 역할로서 자기 대입 현상(항목 11 참조)의 가능성에 대처하기 위한 대표적인 메커니즘이다. 이렇게 유용하다 보니 swap을 어떻게 제대로 구현할지가 굉장히 중요했다.

표준 라이브러리에서 제공하는 swap 알고리즘을 보도록 하자.

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

 

표준에서 제공하는 swap은 구현 코드를 봐도 알겠지만, 복사만 제대로 지원하는 타입이면 어떤 타입의 객체든 동작을 정상적으로 수행해준다. 하지만, 한 번 호출에 복사가 세 번 일어난다는 점은 반갑지 않다.

복사하면 손해를 보는 타입 중 으뜸은 다른 타입의 실제 데이터를 가리키는 포인터를 멤버로 가득 갖고 있는 타입일 것이다. 이런 개념을 설계의 미학으로 끌어올려 많이 사용하는 기법이 'pimpl(pointer to implementation) 관용구(idiom)'다. pimpl 설계를 차용한 아래의 클래스 예제를 보자.

// CWidget의 실제 데이터를 나타내는 클래스
class CWidgetImpl {
private:
	// 기타 등등 많은 복사 비용이 드는 멤버를 갖는다 해보자
	int a, b, c;
	std::vector<double> v;
}

// pimpl 관용구를 사용한 클래스
class CWidget {
public:
	CWidget(const CWidget &rhs);

	// 복사 대입 연산자
	CWidget &operator=(const CWidget &rhs) {
		// ...
		
		// CWidget을 복사하기 위해, 자신의 CWidget Impl 객체를 복사한다.
		*pImpl = *(rhs.pImpl);
		
		// ...
	}

private:
	CWidgetImpl *pImpl;
};

 

이렇게 만들어진 CWidget 객체를 우리가 직접 맞바꾼다면, pImpl 포인터만 살짝 바꾸는 것 말고는 실제로 할 일이 없다. 하지만, 이는 swap 알고리즘이 알 방법이 없다. 언제나처럼 CWidget 객체 세 개를 복사하고 CWidgetImpl 객체까지 세 개 복사할 것이다. swap에 pImpl 포인터만 바꾸라고 어떻게 알려줄 수 있을까? C++에서는 swap을 CWidget에 대해 특수화(specialize)할 수 있다. 아래 코드를 통해 기본 아이디어만 확인해보자.

// 특수화를 적용한 경우
namespace std {
	template<>
	void swap<CWidget>(CWidget &a, CWidget &b) {
		swap(a.pImpl, b.pImpl);
	}
}

 

위의 코드를 부면 template<> 부분이 있다. 이 부분이 완전 템플릿 특수화(total template specialization) 함수라는 것을 컴파일러에게 알려주는 부분이다. 추가적으로 함수 이름 뒤에 있는 <CWidget>typename TCWidget일 경우에 대한 특수화라는 사실을 알려주는 것이다. 즉, 타입과 무관한 swap 템플릿을 CWidget 객체에 대해서는 위 함수로 적용되는 특수한 함수라는 것이다. (※ 일반적으로 std 네임스페이스의 구성요소는 함부로 변경할 수 없지만, 프로그래머가 직접 만든 타입에 대해 완전 특수화하는 것은 허용된다.)

다만, 위의 코드는 pImpl이라는 private 멤버에 접근하기 때문에 컴파일은 되지 않는다. 특수화 함수를 프렌드로 선언할 수도 있겠지만, 이럴 경우 표준 템플릿들에 쓰인 규칙과 어긋나기 때문에 좋은 방법이 아니다.

 

따라서, CWidget 안에 swap이라는 public 멤버 함수를 선언하고 실제 맞바꾸기를 수행하도록 해보자.

// pimpl 관용구를 사용한 클래스
class CWidget {
public:
	CWidget(const CWidget &rhs);
	
	// public 멤버 함수로 구현한 swap
	void swap(CWidget &other) {
		using std::swap;
		
		// CWidget을 swap하기 위해 각 객체의 pImpl 포인터를 바꾼다.
		swap(pImpl, other.pImpl);
	}

private:
	CWidgetImpl *pImpl;
};

// 특수화를 적용한 경우
namespace std {
	template<>
	void swap<CWidget>(CWidget &a, CWidget &b) {
		// CWidget을 swap하기 위해 멤버 swap 함수를 호출한다.
		a.swap(b);
	}
}

 

이제 만족할 수 있는 코드가 되었다. 컴파일도 되며, 기존의 STL 컨테이너와 일관성도 유지하며, 특수화 함수도 지원하고 있다.

 

가정을 하나 추가해보자. CWidget과 CWidgetImpl이 클래스가 아닌 클래스 템플릿이라면 어떨까? CWidgetImpl에 저장된 데이터의 타입을 매개변수로 바꿀 수 있다고 가정해보자.

swap 멤버 함수를 CWidget에 넣는 정도는 앞선 예제처럼 하면 별로 어렵진 않지만, std::swap을 특수화하는 것은 만만치 않다. 아래의 특수화는 잘못되었다.

template <typename T>
class CWidgetImpl {
private:
	// ...
};

template <typename T>
class CWidget {
public:
	void swap(CWidget &other);
};

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

 

위는 함수 템플릿(std::swap)을 부분 특수화(partial specialization) 한 것인데, C++는 클래스 템플릿에 대한 부분 특수화은 허용하지만, 함수 템플릿에 대해서는 허용하지 않는다. 따라서 위의 코드는 std::swap 부분이 함수 템플릿으로, 컴파일이 안 된다. (일부 컴파일러는 함수 템플릿의 부분 특수화도 받아들인다.)

따라서, 함수 템플릿을 부분 특수화하고 싶은 경우에는 흔히 오버로드한 버전을 하나 추가한다.

namespace std {
	template <typename T>
	void swap(CWidget<T> &a, CWidget<T> &b) { // 함수명 뒤에 <> 삭제로 특수화문 제거
		a.swap(b);
	}
}

 

위의 코드도 유효한 코드는 아니다. std는 특별한 네임스페이스이기 때문에 템플릿에 대한 완전 특수화는 허용되지만, 새로운 템플릿을 추가하는 것은 허용하지 않는다. std 영역을 침범해 컴파일과 실행을 하더라도, 결과는 미정의 상태인 것이다. 어떤 일이 발생할지 모르니 std에 무언가를 추가하면 안 되기 때문에 오버로딩한 함수도 추가해서는 안 된다.

템플릿 전용 버전의 효율 좋은 swap을 사용하기 위해서는 멤버 swap을 호출하는 비멤버 swap을 선언해놓자. 이 비멤버 함수를 std::swap의 특수화 버전이나 오버로딩 버전으로 선언하지만 않으면 된다

namespace CWidgetStuff {

template <typename T>
class CWidget {
public:
	void swap(CWidget &other);
};

// 비멤버 swap 함수
template <typename T>
void swap(CWidget<T> &a, CWidget<T> &b) { // 함수명 뒤에 <> 삭제로 특수화문 제거
	a.swap(b);
}

}

 

이제는 위에서 두 CWidget 객체에 대해 swap을 하더라도, 컴파일러는 C++의 이름 탐색 규칙에 의해 CWidget 특수화 버전의 swap을 찾아낸다.

std::swap을 특수화할 일이 많은데, 직접 만든 클래스 타입 전용의 swap이 되도록 많은 곳에서 호출되도록 하고 싶다면 그 클래스와 동일한 네임스페이스에 비멤버의 swap 함수를 만들어놓자. 동시에, std::swap의 특수화 버전도 준비해두도록 하자. 

 

그렇다면, 이제 사용자 측면에서 보자. 위에서 swapdmf 다양한 버전으로 구성해봤다.

  1. std의 일반 swap 버전
  2. std의 일반 swap을 특수화한 버전 (있을 수도, 없을 수도)
  3. T 타입 전용의 버전 (있을 수도, 없을 수도 - std가 아닌 어떤 네임스페이스에 있거나, 없거나)

 

그렇다면, 컴파일러가 swap 호출문을 만났을 때 무엇부터 찾을까?

  1. 전역 유효 범위 혹은 타입 T와 동일한 네임스페이스 안에서의 T 전용 swap
  2. std::swap의 특수화 버전 (using std::swap이 있을 경우)
  3. std::swap (using std::swap이 있을 경우)

 

정리해보면, 표준에서 제공하는 swap으로도 만족스럽다면 굳이 바꾸지 않아야 한다. 불만족스러운 부분이 있다면 템플릿이 pimpl 관용구와 유사하게 만들어졌을 경우고, 새롭게 swap을 정의해야 한다. 이때는 아래를 따라야 한다.

  1. 타입으로 만들어진 두 객체의 값을 빨리 바꾸는 함수를 swap이라는 이름으로 public 멤버 함수로 만들어라. (멤버 swap은 예외를 던져서는 안 된다.)
  2. 클래스 또는 템플릿이 들어있는 네임스페이스와 같은 네임스페이스에 비멤버 swap을 만든다.
    1. 1번의 swap 멤버 함수를 이 비멤버 함수에서 호출한다.
  3. 새로운 클래스(클래스 템플릿이 아님.)를 만든다면, 그 클래스에 대한 std::swap의 특수화 버전을 준비한다.
    1. 1번의 swap 멤버 함수를 이 특수화 함수에서 호출한다.

그리고, 사용하는 측면에서는 호출하는 swap 함수(지정자가 붙지 않은)가 std::swap도 바라볼 수 있도록 using 선언을 한다. 그리고, swap 함수를 호출한다.

 

728x90