본문 바로가기

study/C++

[C++][Effective C++] 41~48. 템플릿과 일반화 프로그래밍

728x90

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

 

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

  • 41. 템플릿 프로그램이의 천릿길도 암시적 인터페이스와 컴파일 타임 다형성부터
  • 42. typename의 두 가지 의미를 제대로 파악하자
  • 43. 템플릿으로 만들어진 기본 클래스 안의 이름에 접근하는 방법을 알아 두자
  • 44. 매개변수에 독립적인 코드는 템플릿으로부터 분리시키자
  • 45. "호환되는 모든 타입"을 받아들이는 데는 멤버 함수 템플릿이 직방!
  • 46. 타입 변환이 바람직할 경우에는 비멤버 함수를 클래스 템플릿 안에 정의해 두자
  • 47. 타입에 대한 정보가 필요하다면 특성정보 클래스를 사용하자
  • 48. 템플릿 메타프로그래밍, 하지 않겠는가?

요약

41. 템플릿 프로그램이의 천릿길도 암시적 인터페이스와 컴파일 타임 다형성부터

객체 지향 프로그래밍은 명시적 인터페이스(explicit itnerface)런타임 다형성(runtime polymorphism)이 매우 중요하다. 아래 코드를 보자.

class CWidget {
public:
	CWidget();
	virtual ~CWidget();
	virtual std::size_t size() const;
	virtual void normalize();
	void swap(CWidget &other);
	//...
};

void doProcessing(CWidget &widget) {
	if ( 10 < widget.size() && someNastyWidget != widget ) {
		CWidget temp(widget);
		temp.normalize();
		temp.swap(widget);
	}
}

 

위의 코드에서 doProcessing() 함수 안의 widget 변수에 대해 말해보자.

  • widgetCWidget 타입으로 선언되어 있어, CWidget 인터페이스를 지원해야 한다. 이 인터페이스를 소스코드에서 찾으면, 이것이 어떤 형태인지 확인할 수 있다. 이렇게 소스코드에 명시적으로 드러나는 인터페이스를 명시적 인터페이스라고 한다.
  • CWidget의 멤버 함수 중 몇 개는 가상 함수다. 가상 함수에 대한 호출은 widget의 동적 타입을 기반으로 런타임 다형성에 의해 이루어진다.

 

템플릿과 일반화 프로그래밍은 뭔가 다르다. 명시적 인터페이스와 런타임 다형성은 그대로 존재하지만, 중요도가 사뭇 떨어진다. 오히려 암시적 인터페이스(implicit interface)컴파일 타임 다형성(compile-time polymorphism)이 중요하다. doProcessing 함수를 함수 템플릿으로 바꿔보면 알 수 있다.

template<typename T>
void doProcessing(T &widget) {
	if ( 10 < widget.size() && someNastyWidget != widget ) {
		T temp(widget);
		temp.normalize();
		temp.swap(widget);
	}
}

 

위의 코드에서 다시 widget 변수에 대해 말해보자.

  • widget이 지원해야 하는 인터페이스는 템플릿 안에서 widget에 대해 실행되는 연산이 결정한다. 지금은 size / normalize / swap 멤버 함수를 지원해야 하는 widget의 타입(T)이다. Ttemp를 만들어야 하기 때문에, 복사 생성자도 지원해야 하며, someNastyWidget과 비교하기 위해 부등 비교 연산도 지원해야 한다. 따라서, 이 템플릿이 제대로 컴파일되기 위해서는 몇 개의 표현식이 유효해야 하며, 이 표현식들이 T가 지원해야 하는 암시적 인터페이스다.
  • widget이 수반되는 함수 호출이 일어날 때, 해당 호출을 성공시키기 위해 템플릿의 인스턴스화가 일어난다. 이 시기가 컴파일 도중이다. 인스턴스화를 진행하는 함수 템플릿에 어떤 템플릿 매개변수가 들어가느냐에 따라 호출되는 함수가 달라지기 때문에, 이를 컴파일 타임 다형성이라 한다.

 

런타임 다형성과 컴파일 타임 다형성의 차이는, 오버로드된 함수 중 호출할 것을 골라내는 과정과 가상 함수의 동적 바인딩의 차이점과 비슷하다.

 

명시적 인터페이스는 대개 함수 시그니처(함수 이름, 매개변수 타입, 반환 타입, 함수의 상수성 등)로 이루어진다. 반면, 암시적 인터페이스는 함수 시그니처에 기반하지 않는다. 암시적 인터페이스는 유효 표현식(expression)을 기반으로 하고 있다. 템플릿 doProcessing의 조건문에 해당하는 것이 표현식이다.

	// 함수 이름이 size이며, 정수 계열의 값을 반환하는 함수를 지원해야 한다.
	// T 타입 객체 둘을 비교하는 operator!= 연산자 함수를 지원해야 한다.
	if ( 10 < widget.size() && someNastyWidget != widget ) {

주석과 같은 제약 사항이 T 타입에 대해 있을 것이다. 하지만 이는 실제로는 연산자 오버로딩 가능성이 있어, 어떤 것도 만족시킬 필요는 없다.

  • Tsize 멤버 함수를 지원해야 하는 것은 맞지만, 수치 타입을 반환하지 않아도 된다. 심지어, operator> 정의에 필요한 타입 반환도 필요 없다. 다만, operator> 연산자에서 int와 비교하기 위해 필요한 타입으로 암시적 형변환이 가능한 어떤 타입 X를 반환하면 된다.
  • 마찬가지로, Toperator!= 연산자를 지원하지 않아도 된다. 두 타입을 비교할 때 암시적 형변환으로 비교만 가능하면 된다.

 

복잡하지만, 간단히 생각해보면 표현식 자체만 유효하면 된다는 말이다. 암시적 인터페이스는 그저 유효 표현식의 집합인 것이다. 표현식만 일치하고, 입력 타입과 반환 타입이 정확할 필요는 없다. 암시적 형변환만 가능하면 된다.

 

42. typename의 두 가지 의미를 제대로 파악하자

템플릿 선언문의 타입 매개변수를 선언할 때 class와 typename은 동일한 의미를 갖는다. 그러나, typename을 쓰지 않으면 안 되는 경우가 분명히 있다. 

template<class T> class CWidget;
template<typename T> class CWidget;

 

먼저, 템플릿 안에서 참조할 수 있는 이름의 종류는 두 가지다.

 

함수 템플릿 하나를 가정해보자. STL과 호환되는 컨테이너를 받도록 만들어졌고, 담기는 객체는 int에 대입할 수 없다. 하는 일은 컨테이너의 두 번째 원소의 값을 출력하는 것이다. 컴파일도 안 된다.

template<typename C>
void print2nd(const C &container) {
	if ( 2 <= container.size() ) {
		// iter는 템플릿 매개변수 C에 따라 타입이 달라진다.
		C::const_iterator iter(container.begin());
		++iter; // 두 번째 원소

		// value는 템플릿 매개변수 C와 관련 없다.
		int value = *iter;
		std::count << value;
	}
}

 

위의 코드처럼 템플릿 매개변수에 종속된 이름을 의존 이름(dependent name)이라 한다. 의존 이름이 어떤 클래스 안에 중첩되어 있는 경우는 중첩 의존 이름(nested dependent name)이라 부른다. 따라서, iter의 타입은 중첩 의존 타입 이름(nested dependent type name)이다. 반대로 템플릿 매개변수에 종속되지 않는, 독립적인 이름을 비의존 이름(non-dependent name)이라고 한다.

 

코드 안에 중첩 의존 이름이 있으면 골치 아프다. 컴파일러가 구문 분석을 할 때 애로사항이 생긴다. 아래의 코드를 보면, const_iterator에 대한 포인터인 지역 변수 x를 선언하고 있는 것으로 보이지만, const_iterator가 타입이라는 사실을 알고 있을 때만이다. 만약, const_iterator라는 이름을 가진 정적 데이터 멤버가 C에 들어있다면, 아래의 코드는 두 수의 곱셈으로 볼 것이다. 이런 경우도 발생 가능하기 때문에 주의가 필요하다.

template<typename C>
void print2nd(const C &container) {
	C::const_iterator *x;
	//...
}

 

C는 컴파일러가 구문분석을 하는 순간에도 이게 잘못된 경우를 찾지 못한다. 이러한 모호성을 없애기 위해 C++에서는 어떤 규칙을 적용하고 있다. 구문 분석기는 템플릿 안에서 중첩 의존 이름을 만나면 개발자가 타입이라고 알려주지 않는 한 그 이름이 타입이 아니라고 가정하게 된다. 즉, 기본적으로 중첩 의존 이름은 타입이 아닌 것으로 해석한다.

 

따라서, 위의 코드들을 의도했던 대로 템플릿 "타입"을 지정해주려면 다음과 같이 "typename" 키워드를 추가해야 한다.

template<typename C>
void print2nd(const C &container) {
	if ( 2 <= container.size() ) {
		// typename 키워드 지정
		typename C::const_iterator iter(container.begin());
		// ...
	}
}

 

typename 키워드는 중첩 의존 이름만 식별할 때 써야 한다. 그 외의 멤버 이름 같은 것들은 typename 키워드를 쓰면 안 된다. 어떤 컨테이너와 그 컨테이너 내의 반복자를 한꺼번에 받아들이려면 다음과 같이 써야 한다.

template<typename C>
void print2nd(const C &container,				// typename 붙으면 안 된다.
			typename C::const_iterator iter)	// typename 붙어야 한다.

 

위의 코드에서 C는 중첩 의존 타입 이름이 아니기 때문에 typename을 쓰면 안 된다. 그러나, C::iterator는 중첩 의존 이름이기 때문에 typename 키워드가 붙어야 한다.

 

다만, 중첩 의존 타입 이름 앞에 typename을 붙이지 않는 예외가 있다. 중첩 의존 타입 이름이 기본 클래스의 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로서 있을 경우에는 붙이면 안 된다.

template<typename T>
class CDerived : public CBase<T>::Nested { // 상속되는 기본 클래스 리스트에는 typename 제외
public:
	explicit CDerived(int x) : CBase<T>::Nested(x) { // 멤버 초기화 리스트에 있는 기본 클래스 식별자는 typename 제외
		typename CBase<T>::Nested temp; // 이외에 기본적으로 typename 필요
	}
};

 

43. 템플릿으로 만들어진 기본 클래스 안의 이름에 접근하는 방법을 알아 두자

몇 개의 회사에 메시지를 전송할 수 있도록 해야 한다고 해보자. 암호화 메시지와 평문 메시지를 보낼 수 있다. 만약 어떤 메시지가 어떤 회사로 전송되어야 할지가 컴파일 때 결정할 수 있는 정보가 있다면 템플릿 방법을 적용할 수 있다.

class CCompanyA {
public:
	void sendCleartext(const std::string &sMsg);
	void sendEncrypted(const std::string &sMsg);
};

class CCompanyB {
public:
	void sendCleartext(const std::string &sMsg);
	void sendEncrypted(const std::string &sMsg);
};

// 메시지 정보 담는 클래스
class CMsgInfo {};

template<typename Company>
class CMsgSender {
public:
	void sendClear(const CMsgInfo &info) {
		std::string sMsg; // info로 msg 만들기

		Company company;
		company.sendCleartext(sMsg);
	}

	void sendSecret(const CMsgInfo &info) {
		// sendClear와 유사하며 sendEncrypted() 호출
	}
};

template<typename Company>
class CLoggingMsgSender : public CMsgSender<Company> {
public:
	void sendClearMsg(const CMsgInfo &info) {
		// 메시지 전송 전 로깅
		sendClear(info); // 컴파일 되지 않음
		// 메시지 전송 후 로깅
	}
};

 

그러나 위의 코드는 CLogginMsgSender 클래스에 의해 빌드되지 않는다. 템플릿 정의에 마주칠 때 컴파일러는 이 클래스가 어떤 클래스의 파생 클래스인지 찾지 못한다. CMsgSender<Company>로 지정을 했지만, 템플릿 매개변수인 CompanyCLoggingMsgSender 객체가 생성될 때 알 수 있다. 따라서, 컴파일러는 누구의 sendClear() 함수를 호출해야 할지를 모른다.

게다가, 기본 클래스 템플릿은 언제나 특수화될 수 있다. 그리고 그 특수화 템플릿에서 sendClaer() 함수를 지원하지 않을 수도 있다.

따라서, C++ 컴파일러는 템플릿인 기본 클래스 속에서 상속된 이름을 찾지 않는다.

 

위의 문제를 해결하기 위해, 템플릿인 기본 클래스 속에서 이름을 찾는 방법을 소개한다. 3가지 방법이 있다.

  1. 기본 클래스 함수에 대한 호출문 앞에 this-> 붙여 지정해준다.
    • template<typename Company>
      class CLoggingMsgSender : public CMsgSender<Company> {
      public:
      	void sendClearMsg(const CMsgInfo &info) {
      		this->sendClear(info);
      	}
      };
  2. using 선언을 이용해 기본 클래스의 이름을 파생 클래스의 유효 범위 내에 끌어온다.
    • template<typename Company>
      class CLoggingMsgSender : public CMsgSender<Company> {
      public:
      	using CMsgSender<Company>::sendClear;
      
      	void sendClearMsg(const CMsgInfo &info) {
      		sendClear(info);
      	}
      };
  3. 호출할 함수 앞에 기본 클래스를 명시적으로 지정한다. (가상 함수 바인딩 무시될 수 있어 비추천)
    • template<typename Company>
      class CLoggingMsgSender : public CMsgSender<Company> {
      public:
      	void sendClearMsg(const CMsgInfo &info) {
      		CMsgSender<Company>::sendClear(info);
      	}
      };

 

44. 매개변수에 독립적인 코드는 템플릿으로부터 분리시키자

템플릿은 코딩 시간을 줄여주고 코드 중복을 피할 수 있게 해 준다. 단, 아무 생각 없이 사용하면 코드 비대화(code bloat)가 발생할 수 있다. 똑같은 내용의 코드와 데이터가 여러 개로 중복되어 이진 파일에 존재한다는 것이다. 소스 코드는 단정하지만, 목적 코드부터 부풀게 된다.

 

코드 비대화를 막기 위한 방법으로, 공통성 및 가변성 분석(commonality and variability analysis)이 있다. 우리는 평소에 새로 만드는 함수가 기존 함수와 유사한 부분이 많이 있다면, 유사한 부분을 따로 함수로 빼내고 두 함수를 가볍게 만들 것이다. 이는 함수뿐만 아니라 클래스에 대해서도 마찬가지다. 두 클래스가 제각기 갖고 있는 다른 고유 부분(varying part)은 남아있고, 공통부분은 별도의 클래스로 만드는 것이다. 템플릿에서도 마찬가지의 방식을 적용하면 된다.

다만, 템플릿이 아닌 코드에서는 코드 중복이 명시적이다. 반면, 템플릿에서는 코드의 중복이 암시적(템플릿 코드 하나만 존재)이기 때문에, 템플릿이 인스턴스화 될 때 발생할 수 있는 중복을 알아채야 한다.

 

5x5 정방 행렬과 10x10 정방 행렬의 역행렬을 구하는 방법은, 크기를 빼고는 동일하게 구할 것이다. 그러다 보니 템플릿을 사용했음에도 하나는 5x5 행렬의 역행렬을 구하는 함수, 다른 하나는 10x10 행렬의 역행렬을 구하는 함수로 invert 함수의 사본이 중복 인스턴스화 될 것이다. 그리고, 이러한 것들이 코드의 비대화를 유발한다.

template<typename T, std::size_t n> // 비타입 매개변수 n도 받음 (정수 상수 또는 Bool 상수 타입만 가능)
class CSquareMatrix {
public:
	void invert(); // 주어진 정방행렬의 메모리에 역행렬을 덮어쓰는 함수
};

int main(void) {
	CSquareMatrix<double, 5> sm1;
	sm1.invert(); // CSquareMatrix<double, 5>::invert 호출
	
	CSquareMatrix<double, 10> sm2;
	sm2.invert(); // CSquareMatrix<double, 10>::invert 호출

	return 0;
}

 

위의 문제를 해결해보면, 아래와 같이 구성할 수 있다.

template<typename T> 
class CSquareMatrixBase { // 크기에 독립적인 정방행렬 클래스
protected: // 파생 클래스에서만 사용
	void invert(std::size_t matrixsize); // 주어진 크기의 행렬을 역행렬로 만드는 함수
};

template<typename T, std::size_t n>
class CSquareMatrix : private CSquareMatrixBase<T> { // is-a 관계가 아닌, is-implemented-in-terms-of 관계
public:
	void invert() { this->invert(n); } // 기존 클래스의 invert를 호출 및 인라인화(비용x)
};

 

아직 문제가 남아있다. CSquareMatrixBase::invert 함수는 자신이 상대할 데이터의 실질적인 메모리의 위치를 모른다. 주어진 크기의 행렬의 역행렬을 구하더라도 덮어쓸 메모리를 모르는 것이다. 이는 인자로 넘겨주면 쉽게 해결할 수 있겠지만, 유사하게 기존 행렬의 메모리를 덮어써주는 함수가 많이 필요하다면 함수마다 다 인자로 주는 것은 낭비다.

따라서, 다음과 같이 설계를 할 수 있다.

template<typename T> 
class CSquareMatrixBase {
protected:
	CSquareMatrixBase(std::size_t n, T *pMem) 
	: size(n), pData(pMem) {
	}

	void setDataPtr(T *ptr) { pData = ptr; } // 사용자에게 pData를 입력할 수 있는 여러 옵션을 제공

	void invert(std::size_t matrixsize);

private:
	std::size_t size; // 행렬 크기
	T *pData; // 헹렬의 메모리 위치
};

template<typename T, std::size_t n>
class CSquareMatrix : private CSquareMatrixBase<T> {
public:
	CSquareMatrix()
	: CSquareMatrixBase<T>(n, data) {
	}

private:
	T data[n * n];
};

 

이렇게, 파생 클래스에게 메모리 할당 방법의 결정 권한을 넘겨주게 되면, 파생 클래스를 만들면 동적 메모리 할당이 필요 없는 객체가 된다. 하지만, 스택을 사용하는 만큼 객체 자체의 크기가 좀 커질 수 있다. 이를 대체하기 위해 데이터를 힙에 둘 수도 있다.

template<typename T, std::size_t n>
class CSquareMatrix : private CSquareMatrixBase<T> {
public:
	CSquareMatrix()
	: CSquareMatrixBase<T>(n, 0), pData(new T[n * n]) { // 파생 클래스의 포인터에 동적 할당한 메모리의 위치를 저장
		this->setDataPtr(pData.get()); // 파생 클래스에서 동적 할당한 메모리의 위치를 기본 클래스의 위치로 올림
	}

private:
	std::unique_ptr<T[]> pData; // C++11에서 scoped_array는 unique_ptr<T[]>로 대체해서 사용
};

 

파생 클래스의 스택 부분이냐, 힙 부분에 두고 기본 클래스로 올리느냐의 차이는 코드 비대화의 측면에서 중요한 논점이다. CSquareMatrix에 속한 멤버 함수 상당수가 기본 클래스 버전을 호출하는 식으로, 단순 인라인 함수가 될 수 있다. 따라서 똑같은 타입의 데이터를 원소로 갖는 모든 정방 행렬들이 행렬 크기 상관없이 기본 클래스 버전의 사본 하나를 공유하는 점이 중요하다.

즉, 앞서 CSquareMatrix<double, 5>::invert()와 CSquareMatrix<double, 10>::invert()로 각각 사본이 따로 존재하는 것이 아니라, CSquareMatrixBase<double>::invert() 하나의 사본을 공유한다는 것이다. 

 

행렬 크기를 미리 저장한 별도의 버전이 만들어지는 invert는 그렇지 않은 것보다 더 좋은 코드를 만들 가능성이 높다. 상수 전파 등 최적화가 잘 되기 때문이다.
하지만, 행렬 크기를 함수 매개변수로 넘기거나, 객체에 저장된 형태로 다른 파생 클래스가 공유한다면, 실행 코드가 매우 작아져 프로그램 실행 속도도 그만큼 빨라지게 된다. 하지만, 자원관리(중복 발생, 해제 문제 등)와 같은 문제가 발생할 수 있다.

 

45. "호환되는 모든 타입"을 받아들이는 데는 멤버 함수 템플릿이 직방!

스마트 포인터는 그냥 포인터처럼 동작하면서 포인터가 주지 못한 기능들을 추가로 갖는 객체다. auto_ptr(unique_ptr) 및 shared_ptr이 그렇다. STL 컨테이너의 iterator도 마찬가지다. 포인터에 ++ 연산과 같은 것을 가능하게 해 준다.

그러나, 포인터에만 지원되고 스마트 포인터로는 대체할 수 없는 것이 "암시적 변환(implicit conversion)"이다. 파생 클래스의 포인터는 암시적으로 기본 클래스의 포인터로 변환된다. 또한, 비상수 객체의 포인터는 상수 객체의 포인터로 암시적 변환이 가능하다.

하지만, 이러한 암시적 형변환을 스마트 포인터로 흉내 내려면 무척 까다롭다.

class Top {};
class Middle : public Top{};
class Bottom : public Middle{};

void pointer() {
	Top *pt1 = new Middle; // Middle * -> Top *
	Top *pt2 = new Bottom; // Bottom * -> Top *
	const Top *pct2 = pt1; // Top * -> const Top *
}

template<typename T>
class SmartPtr {
public:
	explicit SmartPtr(T *realPtr); // 스마트 포인터는 보통 기본 제공 포인터로 초기화된다.
	// ...
};

void smartpointer() {
	// 아래 3개는 모두 컴파일 오류
	SmartPtr<Top> pt1 = SmartPtr<Middle>(new Middle); // SmartPtr<Middle> -> SmartPtr<Top>
	SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom); // SmartPtr<Bottom> -> SmartPtr<Top>
	SmartPtr<const Top> pct2 = pt1; // SmartPtr<Top> -> SmartPtr<const Top>
}

 

컴파일러 입장에서는 SmartPtr<Top>과 SmartPtr<Middle>은 그냥 별개의 클래스이고 상속 관계 등을 따지지 않는다. 이런 클래스 사이에서 변환을 하고 싶다면, 직접 변환하는 프로그램을 만들어야 한다.

SmartPtr 클래스에 생성자로 변환하는 과정을 둔다면 가능하겠지만, 다른 상속 관계의 클래스가 추가될 때마다 생성자를 추가로 만들어주는 것은 옳지 않은 방법이다.

 

이때 템플릿을 인스턴스화하면 문제를 해결할 수 있다. SmartPtr 클래스에 생성자 함수를 두는 것이 아닌, 생성자를 만드는 멤버 함수 템플릿(member function template)을 만들면 된다.

template<typename T>
class SmartPtr {
public:
	template<typename U>
	SmartPtr(const SmartPtr<U> &other); // 일반화된 복사 생성자를 만들기 위한 멤버 템플릿
	// ...
};

 

모든 T타입과 U타입에 대해서 SmartPtr<T> 객체가 SmartPtr<U>로부터 생성될 수 있게 되었다. 이런 꼴의 생성자(템플릿을 써서 인스턴스화 될 때 타입이 다른 객체를 만들어주는 생성자)를 일반화 복사 생성자(generalized copy constructor)라고 부른다.

 

위의 예제에서 일반화 복사 생성자는 explicit으로 선언되지 않았다. 기본 제공 포인터와 같이, 암시적 형변환 시 별도 캐스팅을 해주지 않기 위해서다.

 

그런데, 위의 예제는 문제가 하나 있다. 원하던 형변환 보다 더 큰 범위를 지원하기 때문이다. SmartPtr<Bottom>을 SmartPtr<Top>으로 형변환할 수 있음과 동시에, SmartPtr<Top>을 SmartPtr<Bottm>으로 형변환할 수 있기 때문이다. 이는 public 상속의 의미를 역행하는 과정이다.

이제, 원하는 타입 변환에 제약을 줄 수 있도록 해보자.

template<typename T>
class SmartPtr {
public:
	template<typename U>
	SmartPtr(const SmartPtr<U> &other)
		: heldPtr(other.get()) { // 다른 SmartPtr의 포인터로 현재 SmartPtr의 것을 변경
		//...
	}

	T *get() const { return heldPtr; }

private:
	T *heldPtr; // SmartPtr 내부의 기본 제공 포인터
};

 

이제 위의 예제 코드에서는 일반화 복사 생성자에 의해 SmartPtr<T>의 T * 타입 객체를 SmartPtr<U>의 U * 타입 객체로 초기화한다. 따라서, U *에서 T *로 암시적 변환이 가능할 때 컴파일 에러가 발생하지 않는다. 이로써 원하는 암시적 형변환을 반영할 수 있다.

 

멤버 함수 템플릿은 생성자뿐만 아니라 다양하게 활용될 수 있다. 그중 하나는 대입 연산이다. 예를 들어, shared_ptr 클래스 템플릿은 shared_ptr, auto_ptr, weak_ptr 객체로부터 생성자 호출이 가능하며, weak_ptr을 제외한 나머지를 대입 연산에 사용할 수 있다.

 

단, 일반화 복사 생성자와 일반화 복사 대입 연산자는 기본적인 복사 생성자와 복사 대입 연산자와는 다르다. 따라서, 일반화 복사 생성자/일반화 복사 대입 연산자만 구현해놓는다면, 동일한 타입의 객체로 복사 생성/복사 대입 연산을 수행할 때는 기본 복사 생성자/기본 복사 대입 연산자를 컴파일러가 자동으로 만든다. 즉, 서로 다른 타입일 때만 일반화 멤버 템플릿이 호출되며, 같은 타입일 때는 기본적인 멤버 함수가 호출되기 때문에 이를 고려해야 한다.

 

46. 타입 변환이 바람직할 경우에는 비멤버 함수를 클래스 템플릿 안에 정의해 두자

항목 24에서 모든 매개변수에 대해 암시적 타입 변환이 되기 위해서는 비멤버 함수를 사용하는 방법밖에 없다고 했었다. 이번 항목은 그때의 예제를 좀 더 확장해서, Rational 클래스와 operator *함수를 템플릿으로 만든다.

template<typename T>
class Rational {
public:
	Rational(const T &numerator = 0, const T &denominator = 1);

	const T numerator() const;
	const T denominator() const;
};

template<typename T>
const Rational<T> operator *(const Rational<T> &lhs, const Rational<T> &rhs) {
	// ..
}

void func() {
	Rational<int> oneHalf(1, 2);
	Rational<int> result = oneHalf * 2; // 혼합형 수치 연산에서 컴파일 에러 발생
}

 

항목 24에서 분명 혼합형 수치 연산을 가능하게 한 뒤 템플릿화한 것 밖에 없는데, 혼합형 수치 연산을 작성하게 되면 컴파일 에러가 발생한다. 이는 기존 항목 24에서의 예제는 호출하려는 함수(Rational 객체 두 개를 받는 operator * 함수)를 컴파일러가 확실히 알고 있지만, 지금의 경우에는 컴파일러가 아는 바가 없기 때문이다.

컴파일러는 operator *라는 이름의 템플릿으로부터 인스턴스화할 함수를 결정하고자 할 뿐이다. 그러나, Rational<T> 타입의 매개변수 두 개를 받아야 하는 것은 알지만, T가 무엇인지 알 방법이 없기 때문이다.

 

위의 예제에서 혼합형 수치 연산에서 호출하는 operator * 템플릿의 인자는 각각 Rational<int> 타입과 int 타입이다. 첫 번째 매개변수 lhs는 당연히 Rational<T> 타입으로 선언되었고 Rational<int> 타입을 입력하기 때문에 T가 int인 것을 알 수 있다.

두 번째 매개변수 rhs는 좀 더 어렵다. 마찬가지로 Rational<T> 타입으로 선언되어 있으나 int를 입력하고자 하기 때문에 컴파일러는 받아들이기 어려운 상황이다. 'Rational<T> 생성자가 explicit이 아니기 때문에 이를 활용해 암시적 형변환을 지원하면 어떻겠는가'라고 생각할 수 있지만, 컴파일러 입장에서는 그게 안 된다. C++ 컴파일러는 함수 템플릿에서 템플릿 인자 추론(template argument deduction) 과정에서 암시적 타입 변환을 고려하지 않기 때문이다.

이는 함수를 호출할 때 이미 어떤 타입인지 개발자는 알고 있어야 하는 것과 동일한 의미다. 필요한 함수 템플릿에 넣어 줄 매개변수 타입을 추론하는 것도 직접 해야 한다. 단, 템플릿 인자 추론은 지원하지 않아 암시적 타입 변환이 불가능한 상황에서 말이다.

 

이때 사용할 수 있는 방법 중 하나로, 클래스 템플릿 안에 프랜드 함수를 넣어두는 것이다. 함수 템플릿의 성격을 주지 않고, 특정한 함수를 나타낼 수 있게 된다. 클래스 템플릿은 함수 템플릿과 달리 템플릿 인자 추론 과정과 관련이 없기 때문이다. T의 정보는 Rational<T> 클래스가 인스턴스화 될 때 이미 정해지기 때문에, 컴파일러도 명확히 알 수 있어 클래스 템플릿은 인자 추론 과정과 관련이 없다.

template<typename T>
class Rational {
public:
	Rational(const T &numerator = 0, const T &denominator = 1);

	const T numerator() const;
	const T denominator() const;

	// 프랜드 함수 추가
	// 템플릿 내부에서는 템플릿의 매개변수(<T>)를 떼어도 자동으로 붙인 것(Rational<T>)과 동일하게 인식한다.
	friend const Rational operator *(const Rational &lhs, const Rational &rhs);
};

template<typename T>
const Rational<T> operator *(const Rational<T> &lhs, const Rational<T> &rhs) {
	// ..
}

void func() {
	Rational<int> oneHalf(1, 2);
	Rational<int> result = oneHalf * 2; // 혼합형 수치 연산에서 컴파일 성공. 단, 링크 실패
}

 

클래스 템플릿이 인스턴스화 되어 Rational<int> 타입의 클래스로 만들어지면, 프렌드 함수 operator *의 템플릿 타입도 T가 int로 고정된다. 따라서, 인스턴스화 되는 시점에서 함수 템플릿이 아닌 일반 함수가 선언된 것과 동일하게 된다. 일반 함수로 선언/정의되었기 때문에 암시적 형변환이 가능하게 된다.

 

위의 예제에서 Rational 템플릿 클래스 안의 프렌드 선언부는 템플릿이 인스턴스화 될 때 같이 매개변수가 결정된다고 했다. 즉, 선언 과정에 의해 Rational<int> 타입을 두 개 받는 것으로 선언된 것이다. 그런데 이는 클래스 외부의 템플릿 함수 정의부까지 변경되지는 않아, 선언과 정의 부분을 링크할 수 없다.

이를 해결하기 위해서 operator *함수의 본문을 선언부에서 호출(사용)하면 된다.

template<typename T>
const Rational<T> operator *(const Rational<T> &lhs, const Rational<T> &rhs) {
	// ..
}

template<typename T>
class Rational {
public:
	Rational(const T &numerator = 0, const T &denominator = 1);

	const T numerator() const;
	const T denominator() const;

	// 외부 정의 함수를 직접 호출
	friend const Rational operator *(const Rational &lhs, const Rational &rhs) {
		return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
	}
};

 

47. 타입에 대한 정보가 필요하다면 특성정보 클래스를 사용하자

STL에서 제공하는 것 중 유틸리티(utility)라고 불리는 템플릿이 몇 개 있다. 이들 중 하나가 advance라는 이름의 템플릿이다. advance는 지정된 반복자(iterator)를 지정된 거리(distance)만큼 이동시킨다.

간단히, iter += d만 하면 될 것 같지만, 사실 이렇게 구현할 수 없다. += 연산을 지원하는 반복자는 임의 접근 반복자 밖에 없기 때문이다. 임의 접근 반복자보다 기능적으로 떨어지는 다른 반복자는 ++ 또는 -- 연산을 d번 수행하는 것으로 advance를 구현할 수밖에 없다.

더보기

반복자의 종류 참고

- [C++] 반복자 (Iterator) (tistory.com)

 

struct input_iterator_tag {};

struct output_iterator_tag {};

struct forward_iterator_tag : public input_interator_tag {};

struct bidirectional_iterator_tag : public forward_iterator_tag {};

struct random_access_iterator_tag : public bidirectional_iterator_tag {};

 

이렇듯 반복자마다 종류가 다양하고 가능한 기능이 다른 시점부터, 구현 시 신경을 써야 한다. 한 가지 방법은 소위 최소 공통분모(lowest common denominator) 전략이 있다. 반복자를 주어진 횟수만큼 반복적으로 증가시키거나 감소시키는 루프를 돌리는 것이다. 하지만, 이는 선형 시간이 걸리며 상수 시간의 반복자 산술 연산을 사용할 수 있는 임의 접근 반복자에서는 손해다. 따라서, 다음과 같이 구현할 수 있다.

template<typename IterT, typename DistT>
void advance(IterT &iter, DistT &d) {
	if ( /* iter가 임의 접근 반복자 */ ) {
		iter += d;
	}
	else {
		if ( 0 <= d ) {
			while ( 0 < --d ) {
				++iter;
			}
		}
		else {
			while ( 0 > ++d ) {
				--iter;
			}
		}
	}
}

 

위의 if 조건 안과 같이, iter가 임의 접근 반복자인지 알기 위해서는 특성정보(traits)를 사용해야 한다. 특성정보는 컴파일 도중에 어떤 주어진 타입의 정보를 얻을 수 있게 하는 객체를 지칭하는 개념이다.

특성정보는 미리 정의된 문법이나 키워드가 아닌, 일종의 관례이다. 특성정보가 되려면 몇 가지 요구사항을 지켜야 하는데, 그중 하나가 기본 제공 타입과 사용자 정의 타입 모두 돌아가야 한다는 점이다. 즉, advance는 포인터 및 int를 받아서 호출될 때도 정상 동작해야 한다.

 

위의 말은 '특성정보는 기본 제공 타입에 대해서 쓸 수 있어야 한다'라는 말이며, 어떤 타입 내에 중첩된 정보 등으로는 구현이 안되며 보다 일반적이어야 한다. 따라서, 특성정보는 어떤 타입의 내부에 있으면 안 되고 외부에 있어야 한다. 특성정보를 다루는 표준적인 방법은 해당 특성정보를 템플릿 및 그 템플릿의 1개 이상의 특수화 버전에 넣는 것이다.

 

반복자의 경우, 표준 라이브러리 특성정보용 템플릿인 iterator_traits가 있다. 이는 구조체 템플릿이며, 관례상 특성정보는 항상 구조체로 구현한다. 그리고, 이와 같이 특성정보를 구현하는 데 사용한 구조체는 '특성정보 클래스'라고 부른다.

template<typename IterT>
struct iterator_traits;

 

iterator_traits 클래스가 동작하는 방법은, iterator_traits<IterT> 안의 IterT 타입 각각에 대해 iterator_category라는 이름의 typedef 타입이 선언되어 있다. 그리고, 이렇게 선언된 typedef 타입이 IterT의 반복자 범주를 가리키게 된다.

iterator_traits 클래스는 이 반복자 범주를 두 부분으로 나누어 구현한다. 첫 번째는 사용자 정의 반복자 타입에 대한 구현이다. 사용자 정의 반복자가 iterator_category라는 이름의 typedef 타입을 내부에 가질 것을 요구사항으로 두어야 한다. 이 typedef 타입은 해당 태그 구조체에 대응되어야 한다. 예로, deque의 반복자는 임의 접근 반복자이고, list는 양방향 반복자이기 때문에 다음과 같이 구성되어 있다.

template<typename T>
class deque {
public:
	class iterator {
	public:
		// iterator_category라는 이름의typedef 타입을 내부에 가져야 한다.
		typedef random_access_iterator_tag iterator_category;
	};
};

template<typename T>
class list {
public:
	class iterator {
	public:
		typedef bidirectional_iterator_tag iterator_category;
	};
};

 

마찬가지로 iterator_traits는 다음과 같이 작성될 수 있다.

// IterT 타입의 iterator_category는 IterT 그 자체다.
template<typename IterT>
struct iterator_traits {
	typedef typename IterT::iterator_category iterator_category;
};

 

이제 사용자 정의 타입에 대한 처리는 완료했다. 그러나, 반복자의 실제 타입이 포인터인 경우에는 전혀 돌아가지 않는다. 포인터 안에 typedef 타입이 중첩되는 것 자체가 말이 안 된다. 따라서, 두 번째는 반복자가 포인터인 경우에 대한 처리다.

포인터 타입 반복자 지원을 위해 iterator_traits는 포인터 타입에 대한 부분 템플릿 특수화(partial template specilization) 버전을 제공한다. 포인터의 동작 원리가 임의 접근 반복자와 동일하기 때문에, 특수화된 iterator_traits는 아래와 같이 임의 접근 반복자의 범주에 속하게 된다.

// 기본제공 포인터에 대한 부분 템플릿 특수화
template<typename IterT>
struct iterator_traits<IterT *> {
	// 임의 접근 반복자 태그를 typedef
	typedef random_access_iterator_tag iterator_category;
};

 

특성정보 클래스의 설계 및 구현 방법은 아래와 같다.

  • 다른 사람이 사용하도록 열어주고 싶은 타입 관련 정보를 확인한다. (반복자일 경우, 반복자 범주 등이 여기에 해당)
  • 해당 정보를 식별하기 위한 이름 선택한다. (iterator_category와 같은 것)
  • 지원하고자 하는 타입 관련 정보를 담은 템플릿과 그 템플릿의 특수화 버전을 제공한다.

 

이제, iterator_traits(std::iterartor_traits)를 다음과 같이 다듬을 수 있다.

template<typename IterT, typename DistT>
void advance(IterT &iter, DistT &d) {
	/* iter가 임의 접근 반복자 */
	if ( typeid(typename std::iterator_traits<IterT>:iterator_category) == typeid(std:random_access_iteratror_tag) ) {
		iter += d;
	}
	else {
		// ...
	}
}

 

위의 예제를 통해서 일차적으로 해결했다. 그러나, IterT는 컴파일 도중에 파악되기 때문에 iterator_traits<IterT>::iterator_category도 컴파일 도중에 파악할 수 있다. 하지만 if 문을 통해 런타임 때 점검을 하고 있는 형태다. 굳이 실행 도중에 하는 것은 시간 낭비이며, 코드의 크기도 비대해질 것이다.

 

주어진 타입에 대해서 컴파일 도중에 비교하기 위해서는 "오버로딩"을 사용하면 된다. 어떤 함수 f를 오버로딩하는데, 매개변수 리스트를 다르게 하는 것이다. 이렇게 f를 호출하게 되면 컴파일러는 인자를 보고 적절한 버전의 f를 찾아낼 것이다. 

template<typename IterT, typename DistT>
void advance(IterT &iter, DistT &d) {
	// 적절한 오버로드 버전을 호출
	doAdvance(iter, d, typename std::iterator_traits<IterT>::iterator_category());
}

/* iter가 임의 접근 반복자 */
template<typename IterT, typename DistT>
void doAdvance(IterT &iter, DistT &d,
			std::random_access_iterator_tag) {
	iter += d;
}

/* iter가 양방향 반복자 */
template<typename IterT, typename DistT>
void doAdvance(IterT &iter, DistT &d,
			std::bidirectional_iterator_tag) {
	if ( 0 <= d ) {
		while ( 0 < d-- ) {
			++iter;
		}
	}
	else {
		while ( 0 > d++ ) {
			--iter;
		}
	}
}

/* iter가 입력 반복자 */
template<typename IterT, typename DistT>
void doAdvance(IterT &iter, DistT &d,
	std::input_iterator_tag) {
	if ( 0 > d ) {
		throw std::out_of_range("Negative distance");
	}
	while ( 0 < d-- ) {
		++iter;
	}
}

 

특성정보 클래스에 대해 정리하자면 다음과 같다.

  • 작업자(worker) 역할을 맡을 함수 또는 함수 템플릿(doAdvance)을 특성정보 매개변수를 다르게 하여 오버로딩한다. 전달되는 특성정보에 맞춰 각 오버로드 버전을 적절히 구현한다.
  • 작업자를 호출하는 주작업자(master) 역할을 맡을 함수 또는 함수 템플릿(advance)을 만들 때, 특성정보 클래스에서 제공되는 정보를 넘겨서 작업자를 호출한다.

 

48. 템플릿 메타프로그래밍, 하지 않겠는가?

템플릿 메타프로그래밍(TMP, Template MetaProgramming)컴파일 도중 실행되는 템플릿 기반의 프로그램을 작성하는 일을 말한다. 템플릿 메타프로그램은 C++ 컴파일러가 실행시키는 C++ 프로그램이다. TMP 프로그램이 실행을 마친 후에는 그 결과로 나온 출력물(템플릿에서 인스턴스화 된 소스 코드)이 다시 일반 컴파일 과정을 거친다.

TMP에는 엄청난 강점이 두 개가 있다. 첫 번째로, TMP를 쓰면 다른 방법으로는 까다롭거나 불가능한 일을 쉽게 할 수 있다. 두 번째로, 템플릿 메타프로그램은 C++ 컴파일이 진행되는 동안에 실행되기 때문에, 기존 작업을 런타임 영역에서 컴파일 타임 영역으로 전환시킬 수 있다. 이로 인해 몇몇 에러들은 컴파일 도중에 찾을 수 있고, TMP를 써서 만든 C++ 프로그램이 효율적일 여지가 많다. 컴파일 타임에 동작을 이미 다 수행하기 때문에 실행 코드가 작아(TMP는 함수식 언어처럼 사용된다)지고, 실행 시간도 짧아지며, 메모리도 적게 먹는 것이다. (컴파일 시간은 꽤 길어질 수 있다.)

 

앞의 항목에서 다뤘던, STL의 advance와 유사한 코드를 다시 가져와 다루도록 한다.

template<typename IterT, typename DistT>
void advance(IterT &iter, DistT &d) {
	/* iter가 임의 접근 반복자 */
	if ( typeid(typename std::iterator_traits<IterT>:iterator_category) == typeid(std:random_access_iteratror_tag) ) {
		iter += d;
	}
	else {
		if ( 0 <= d ) {
			while ( 0 < --d ) {
				++iter;
			}
		}
		else {
			while ( 0 > ++d ) {
				--iter;
			}
		}
	}
}

 

위의 예제 코드는 typeid를 쓰고 있으며, 타입 정보를 꺼내는 작업을 런타임에 하는 것이다. 항목 47에서도 이를 오버로딩을 통해 변경했듯이, 여기서 typeid 연산자를 쓰는 방식은 특성정보(traits)를 쓰는 방법보다 효율성이 떨어진다.

  • 타입 점검 동작이 컴파일 시간이 아닌 런타임 시간에 수행
  • 런타임 타입 점검을 수행하는 코드는 어쩔 수 없이 실행 파일에 들어감

여기서 특성정보는 TMP가 된다. 특성정보를 썼기 때문에 런타임의 조건문 처리를 컴파일 타임에 수행할 수 있도록 대체한 것이기 때문이다.

 

typeid는 성능 외에도 컴파일 문제를 발생시킬 수 있는 부분이 있다. 아래와 같이 advance함수를 호출하는 경우다.

	std::list<int>::iterator iter;
	advance(iter, 10);

 

위와 같이 advance를 호출할 경우, 다음과 같은 advance 함수가 인스턴스화 될 것이다. 그리고, 컴파일 오류가 발생할 것이다. 물론, if 조건문 때문에 컴파일 오류 발생하는 부분까지 가지도 못한다.

void advance(std::list<int>::iterator &iter, int &d) {
	if ( typeid(std::iterator_traits<std::list<int>::iterator>::iterator_category) == typeid(std::random_access_iterator_tag) ) {
		iter += d; // ERROR (list<int>::iterator는 양방향 반복자이기 때문에 += 연산을 지원하지 못 함)
	}
	else {
		if ( 0 <= d ) {
			while ( 0 < --d ) {
				++iter;
			}
		}
		else {
			while ( 0 > ++d ) {
				--iter;
			}
		}
	}
}

 

만약 특성정보 기반의 TMP를 썼다면, 이런 문제가 없었을 것이다. 주어진 타입에 따른 코드가 별도의 함수로 분리되었고, 각각의 함수는 자신이 맡은 타입에 대한 연산만 수행하기 때문이다.

 

TMP는 그 자체로 튜링 완전성을 갖는 것으로 알려져 있다. 범용 프로그래밍 언어처럼 어떤 것이든 계산할 수 있다는 뜻이다. 변수 선언도 되고, 루프도 실행시킬 수 있으며, 함수를 작성하고 호출하는 것도 가능하다. 단, 이런 것들에 필요한 구문 요소가 보통은 다른 모습을 하고 있다.

항목 47에서 특성정보를 다룬 것도, if~else 문을 템플릿 및 템플릿 특수화 버전을 통해 구현할 수 있었다. 

 

이미 잘 만들어진 TMP용 라이브러리도 꽤 있다. (ex. 부스트의 MPL)

 

TMP 동작 원리 중 루프에 대해 확인해보자. TMP에는 반복(iteration) 의미의 진정한 루프는 없고, 재귀(recursion)를 사용해 루프의 효과를 나타낸다. 단, TMP에서는 재귀 함수 호출을 일반적으로 만들지 않고, 재귀식 템플릿 인스턴스화(recursive template instantiation)를 수행한다.

 

TMP에서의 "hello world"가 컴파일을 통해 계승(factorial)을 계산하는 템플릿이다. TMP 계승 계산에서 재귀식 템플릿 인스턴스화를 통한 루프 효과를 확인해보자.

template<unsigned n>
struct Factorial {
	enum {
		value = n * Factorial<n - 1>::value;
	};
};

// n == 0 일때 특수화
template<>
struct Factorial<0> {
	enum {
		value = 1
	};
};

 

이와 같이 Factorial<n>의 내부에서 또 다른 템플릿 인스턴스인 Factorial<n-1>을 참조하는 곳이 있기 때문에 루프가 돈다. 추가적으로 Factorial<0>에 대해서는 특수 조건이 붙어있다. enum 타입을 통해 value를 TMP 변수를 사용한 것은 나열자 둔갑술(enum hack)(항목 2 참조)을 쓴 것이다.

원래 기존의 루프를 사용할 경우, 루프가 돌 때마다 값이 갱신되었을 것이다. 하지만, TMP는 재귀식 템플릿 인스턴스화를 사용하기 때문에 꼬리에 꼬리를 무는 템플릿 인스턴스화 버전의 Factorial<>이 만들어진다. 그리고 각각의 인스턴스화 버전마다 자체적으로 value의 사본을 고유하게 갖게 된다. 이로 인해서 런타임 때 들어가는 시간을 줄일 수 있다.

int main() {
	std::cout << Factorial<5>::value; // 런타임 계산 없이 즉시 출력
	std::cout << Factorial<10>::value;  // 런타임 계산 없이 즉시 출력

	return 0;
}

 

C++ 프로그래밍에서 TMP가 효과적인 경우는 3가지가 있다.

  1. 치수 단위(dimensional unit)의 정확성 확인
    • TMP를 사용하면 프로그램 안에서 쓰이는 모든 치수 단위의 조합이 제대로 됐는지 컴파일 동안에 확인해볼 수 있다.
    • 속도를 구할 때 변수와 질량을 나타내는 변수가 들어가면 에러가 발생하고, 거리를 시간으로 나누는 것이 맞다.
  2. 행렬 연산의 최적화
    • 4개 행렬의 곱셈을 보통의 방법으로 수행하면, 각 곱셈 연산자마다 4개의 임시 행렬이 생겨야 한다. 게다가 행렬 원소끼리의 곱셈이 필요해 네 개의 루프가 순차적으로 만들어질 수밖에 없다.
    • 이와 같은 비싼 연산에 적용 가능하다.
    • TMP를 응용한 고급 프로그래밍 기술인 표현식 템플릿(expression template)을 사용하면 덩치 큰 임시 객체를 없애고 루프까지 합칠 수 있다.
  3. 맞춤식 디자인 패턴 구현의 생성
    • 다양한 디자인 패턴 중 TMP를 사용한 프로그래밍 기술인 정책 기반 설계(policy-based design)를 사용하면, 따로 마련된 설계상의 선택을 나타내는 템플릿을 만들게 된다.
    • 만들어진 정책 템플릿은 임의대로 조합해 사용자 취향에 맞는 동작을 갖는 패턴으로 구현된다.

 


이번 장은 유독 어려운 내용이 많았다. 지금도 간신히 이해한 내용들이 있는데, 바로 실사용할만한 것들은 그에 비해 앞의 장보다는 많이 없는 것 같다.​ 그래도 이런 스킬들은 코드가 훨씬 복잡하고 포괄적으로 대응할 때 유용할 것 같다.

나중에 문득 기억나 다시 찾아봤을 때 기억날 정도로는 익혀두어야겠다.

728x90