관리 메뉴

KoreanFoodie's Study

Effective C++ 정리 7-1 : 템플릿과 일반화 프로그래밍 본문

Tutorials/C++ : Advanced

Effective C++ 정리 7-1 : 템플릿과 일반화 프로그래밍

머니덕 2022. 9. 17. 18:11
C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

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

핵심 :

1. 클래스 및 템플릿은 모두 인터페이스와 다형성을 지원한다.
2. 클래스의 경우, 인터페이스는 명시적이며 함수의 시그니처를 중심으로 구성되어있다. 다형성은 프로그램 실행 중에 가상 함수를 통해 나타난다.
3. 템플릿 매개변수의 경우, 인터페이스는 암시적이며 유효 표현식에 기반을 두어 구성된다. 다형성은 컴파일 중에 템플릿 인스턴스화와 함수 오버로딩 모호성 해결을 통해 나타난다.


객체 지향 프로그래밍의 세계를 회전시키는 축은 명시적 인터페이스(explicit interface)런타임 다형성(runtime polymorphism) 이다. 예시를 보자.

class Widget
{
public:
	virtual int normalize();
	void swap(Widdget& other);
};

void doProcessing(Widget& w)
{
	Widget temp(w);
	int a = temp.normalize();
	temp.swap(w);
}

doProcessing 함수를 보자. w 는 Widget 타입이므로, w 는 Widget 인터페이스를 지원해야 한다. 이 인터페이스는 소스 코드에서 어떤 형태인지 확인가능하므로, 이런 인터페이스를 명시적 인터페이스라고 한다.
Widget 의 멤버 함수 중 어떤 것은 가상 함수 이므로, 이 가상 함수에 대한 호출은 런타임 다형성에 의해 이루어진다(런타임에 w 의 동적 타입을 기반으로 결정됨).

템플릿은 암시적 인터페이스(implicit interface)컴파일 타임 다형성(compile-time polymorphism) 이 중요하다. doProcessing 함수를 템플릿으로 바꿔보자.

template<typename T>
void doProcessing(T& w)
{
	T temp(w);
	int a = temp.normalize();
	temp.swap(w);	
}

w 가 지원해야 하는 인터페이스는 w 에 대해 실행되는 연산이 결정한다. 즉 이 템플릿이 제대로 컴파일 되려면 T 는 복사 생성자, normalize, swap 을 지원해야 한다. 이 표현식들이 바로 T 가 지원해야 하는 암시적 인터페이스이다. 이때, 타입이 '정확히' 일치할 필요는 없다. 즉, normalize 함수가 int 가 아닌 float 을 반환하더라도, 암시적으로 int 로의 형 변환이 가능하므로 인스턴스화가 가능하다는 것이다.
위에서 함수들의 호출을 성공시키기 위해 템플릿의 인스턴스화가 일어나는데(컴파일 도중에 진행됨), 어떤 템플릿 매개변수가 들어가느냐에 따라 호출되는 함수가 달라지기 때문에, 이를 컴파일 타임 다형성이라고 한다.

항목 42 : typename 의 두 가지 의미를 제대로 파악하자

핵심 :

1. 템플릿 매개변수를 선언할 때, class 및 typename 은 서로 바꾸어 써도 무방하다.
2. 중첩 의존 타입 이름을 식별하는 용도에는 반드시 typename 을 사용한다. 단, 중첩 의존 이름이 기본 클래스 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로 있는 경우에는 예외이다.


질문 : 아래의 두 템플릿 선언문에 쓰인 class 와 typename 의 차이는 무엇일까?

template<typename T> class Widget;
template<class T> class Widget;

답변 : 차이가 없음
그렇다면 typename 과 class, 두 개의 키워드는 왜 생긴걸까? 다음 예시를 통해 typename 을 써야 하는 케이스를 알아보자.

template<typename C>
// 컨테이너의 두 번째 원소를 출력하는 함수
void print2nd(const C& conatiner)
{
	if (conatiner.size() >= 2)
	{
		C::const_iterator iter(conatiner.begin());

		++iter;
		int value = *iter;
		std::cout << value;
	}
}

위의 const_iterator 처럼, 템플릿 매개변수에 종속된 것을 의존 이름(dependent name) 이라고 한다. 의존 이름이 어떤 클래스 안에 중첩되어 있는 이런 경우의 이름을 중첩 의존 이름(nested dependent name) 이라고 부른다. 위의 코드에서 C::const_iterator 는 정확히 말하면 중첩 의존 타입 이름(nested dependent type name) 이다. 반면 value 는 int 타입으로, 비의존 이름(non-dependent name) 이다.
코드 안에 중첩 의존 이름이 있으면 구문 분석 과정에서 에러가 날 수 있다. 예를 들어 다음 코드를 보자.

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

언뜻 보면 C::const_iterator 타입 포인터 x 를 선언하는 것처럼 보이지만, 만약 C 클래스에 const_iterator 라는 변수가 있으면, 위 코드는 C::const_iterator 와 x 를 피연산자로 하는 곱셈 연산이 된다!
이런 황당한 경우를 해결하려면, C::const_iterator 앞에 typename 을 붙여 주어야 한다.

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

typename 키워드는 중첩 의존 이름만 식별하는 데 써야 한다. 즉, print2nd 의 매개변수에 오는 const C& container 앞에는 typaname 을 붙여주면 안된다.
예외가 하나 있는데, 중첩 의존 타입 이름이 기본 클래스의 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로서 있을 경우에는 typename 을 붙이면 안된다는 것이다.

template<typename T>
// 상속되는 기본 클래스 리스트 : typename 쓰면 안 됨
class Derived: public Base<T>::Nested
{
public:
	// 멤버 초기화 리스트에 있는 기본 클래스 식별자
	// : typename 쓰면 안 됨
	explicit Derived(int x)
	: Base<T>::Nested(x)
	{
		// 중첩 의존 타입 이름 : typename 필요
		typename Base<T>::Nested temp;
	}
};


마지막으로, 현업에서 볼 만한 대표적인 사례를 하나 보자. 반복자를 매개변수로 받아 반복자가 가리키는 객체의 사본을 temp 라는 이름의 지역 변수로 만든다고 가정하자.

template<typename IterT>
void workWithIteragot(IterT iter)
{
	typename std::iterator_traits<IterT>::value_type temp(*iter);
}

위의 typename std::iterator_traits<IterT>::value_type 은 풀어 설명하면 "IterT 타입의 객체로 가리키는 대상의 타입" 이라는 뜻이다. 즉, iter 의 타입이 vector<int>::iterator 라면, temp 는 int 타입이 될 것이다.
typename std::iterator_traits<IterT>::value_type 은 다음과 같이 간략화가 가능한데, typedef 이름을 만들 때는 그 멤버 이름과 똑같이 짓는 것이 관례이다.

template<typename IterT>
void workWithIteragot(IterT iter)
{
	typedef typename std::iterator_traits<IterT>::value_type value_type;
	value_type temp(*iter);
}

추가적으로, 컴파일러마다 typename 에 관한 규칙을 얼마나 강조하는지는 약간의 차이를 보인다!

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

핵심 :

파생 클래스 템플릿에서 기본 클래스 템플릿의 이름을 참조할 때는, "this->" 를 접두사로 붙이거나 기본 클래스 한정문을 명시적으로 써 주는 것으로 해결하자


템플릿 클래스 상속하는 다음 예제 코드를 보자.

class CompanyA
{
public:
	void sendCleartext(const std::string& msg);
	void sendEncrypted(const std::string& msg);
};

// CompanyB, CompanyC 클래스들 선언
...

// 메시지 생성에 사용되는 정보를 담기 위한 클래스
class MsgInfo { ... };

template<typename Company>
class MsgSender
{
public:
	void sendClear(const MsgInfo& info)
	{
		std::string msg;
		// info 로부터 msg 를 생성
		...

		Company c;
		c.sendCleartext(msg);
	}

	void sendSecret(const MsgInfo& info)
	{
		std::string msg;
		// info 로부터 msg 를 생성
		...

		Company c;
		c.sendEncrypted(msg);	 
	}
};

이제 메시지를 보낼 때마다 로그를 남기는 코드를 파생 클래스를 이용해 만들어 보자.

template<typename Company>
class LoggingMsgSender: public MsgSender<Company>
{
public:
	void sendClearMsg(const MsgInfo& info)
	{
		...
		// 기본 클래스의 함수 호출
		// 컴파일이 안됨!
		sendClear(info);
		...
	}
};

MsgSender 에는 sendClear 함수가 있는데 왜 컴파일이 안될까?
문제는 간단하다. 컴파일러가 LoggingMsgSender 클래스 템플릿의 정의와 마주칠 떄, 컴파일러는 대체 이 클래스가 어디서 파생된 것인지를 모른다. MsgSender<Company> 에서, Company 는 템플릿 매개변수이고, Company 가 정확이 무엇인지 모르는 상황에서는 MsgSender<Company> 클래스가 어떤 형태인지(sendClear 함수가 있는지 없는지) 알 수가 없다.

좀 더 구체적인 예시를 위해, 암호화된 통신만을 사용하는 CompanyZ 클래스가 있다고 하자.

class CompanyZ
{
public:
	void sendEncrypted(const std::string& msg);
};

// 완전 템플릿 특수화 (total template specialization)
template<>
class MsgSender<CompanyZ>
{
public:
	...
	void sendSecret(const MsgInfo& info) { ... }
};

만약 Company 에 CompanyZ 타입이 들어오면, LoggingMsgSender 에서는 sendClear(info) 함수가 존재할 수도 없다!
즉, 기본 클래스 템플릿은 언제라도 특수화될 수 있고, 이런 특수화 버전에서 제공하는 인터페이스가 원래의 일반형 템플릿과 같으리라는 없다. 그래서 C++ 컴파일러는 템플릿으로 만들어진 기본 클래스를 뒤져서 상속된 이름을 찾는 것을 거부한다.
C++ 의 "난 템플릿화된 기본 클래스는 멋대로 안 뒤질거야" 동작을 발현되지 않도록 하려면 다음과 같은 3가지 방법을 사용하면 된다.

첫째, 기본 클래스 하수에 대한 호출문 앞에 "this->" 를 붙인다

template<typename Company>
class LoggingMsgSender: public MsgSender<Company>
{
public:
	void sendClearMsg(const MsgInfo& info)
	{
		...
		this->sendClear(info);
		...
	}
};

둘째, using 선언을 사용한다

template<typename Company>
class LoggingMsgSender: public MsgSender<Company>
{
public:
	// 컴파일러에게 sendClear 함수가 기본 클래스에 있다고 가정하라고 알려준다
	using MsgSender<Company>::sendClear;
	void sendClearMsg(const MsgInfo& info)
	{
		...
		sendClear(info);
		...
	}
};

셋째, 호출할 함수가 기본 클래스의 함수라는 점을 명시적으로 지정한다

template<typename Company>
class LoggingMsgSender: public MsgSender<Company>
{
public:
	void sendClearMsg(const MsgInfo& info)
	{
		...
		// sendClear 함수가 상속되는 것으로 가정
		MsgSender<Company>::sendClear(info);
		...
	}
};

이 방식은 호출되는 함수가 가상 함수인 경우 가상 함수 바인딩이 무시될 수 있어 추천하지 않는다.

물론 다음과 같은 코드는 컴파일 되지 않는다.

LoggingMsgSender<CompanyZ> zMsgSender;
MsgInfo msgData;
...
// 에러! 컴파일 되지 않는다
zMsgSender.sendClearMsg(msgData);

이는 템플릿 특수화 버전을 명시했고, sendClear 함수는 MsgSender<CompanyZ> 클래스에 들어가 있지 않다는 것을 컴파일러가 알 수 있기 때문이다.
본질적인 논점은, 기본 클래스의 멤버에 대한 참조가 무효한지를 컴파일러가 진단하는 과정이 미리(파생 클래스 템플릿의 정의가 구문분석될 때) 들어간다는 것이다! (파생 클래스 템플릿이 특정한 템플릿 매개변수를 받아 인스턴스화될 때가 아님)

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

핵심 :

1. 템플릿을 사용하면 비슷비슷한 클래스와 함수가 여러 벌 만들어진다. 따라서 템플릿 매개변수에 종속되지 않은 템플릿 코드는 비대화의 원인이 된다.
2. 비타입 템플릿 매개변수로 생기는 코드 비대화의 경우, 템플릿 매개변수를 함수 매개변수 혹은 클래스 데이터 멤버로 대체함으로써 비대화 종종 없앨 수 있다.
3. 타입 매개변수로 생기는 코드 비대화의 경우, 동일한 이진 표현구조를 가지고 인스턴스화되는 타입들이 한 가지 함수 구현을 공유하게 만듦으로써 비대화를 감소시킬 수 있다.


템플릿 코드는 코드 중복을 회피할 수 있지만, 코드 비대화(code bloat) 문제를 초래할 수 있다. 다음 예시를 보자.

template<typename T, std::size_t n>
class SquareMatrix
{
public:
	...
	void invert();
};

...

SquareMatrix<double, 5> sm1;
// SquareMatrix<double, 5>::invert 호출
sm1.invert();

SquareMatrix<double, 10> sm2;
// SquareMatrix<double, 10>::invert 호출
sm2.invert();

"std::size_t n" 처럼 비타입 매개변수(non-type paramter) 인 n 을 받는 SquareMatrix 클래스를 정의했다.
sm1 과 sm2 는 타입이 다르므로, 완전히 같은 동작을 함에도 invert 함수를 중복해서 생성할 것이다.
이를 막기 위해, SquareMatrixBase 클래스를 만들어 볼 수 있다.

template<typename T>
class SquareMatrixBase
{
protected:
	// 주어진 크기의 행렬을 역행렬로 만든다
	void invert(std::size_t matrixSize);
}


template<typename T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T>
{
private:
	using SquareMatrixBase<T>::invert;
public:
	...
    // invert 기본 클래스 버전에 대해 인라인 호출을 수행
	void invert() { this->invert(n); }
};

이렇게 되면, 모든 정방행렬이 사용하는 기본 클래스 버전의 invert 함수는 오직 한 개의 사본이 된다.

행렬의 크기는 인자로 쉽게 전달할 수 있지만, 실질적인 메모리를 어떻게 참조할 수 있을까? 다음과 같이 구현해 보자.

template<typename T>
class SquareMatrixBase
{
protected:
	// 행렬 크기를 저장하고, 행렬 값에 대한 포인터 저장
	SquareMatrixBase(std::size_t n, T *pMem)
	: size(n), pData(pMem) {}
	void setDataPtr(T *ptr) { pData = ptr; }
	...

private:
	std::size_t size;
	T *pData;
};


template<typename T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T>
{
public:
	// 행렬 크기와 데이터 포인터를 기본 클래스로 올려보낸다
	SquareMatrix()
	:SquareMatrixBase<T>(n, data) {}
private:
	T data[n*n];
};

이렇게 파생 클래스를 만들면 동적 메모리 할당이 필요 없는 객체가 되지만, 객체 자체의 크기가 좀 커질 수 있다. 각 행렬의 데이터를 힙에 두는 방법도 있다.

template<typename T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T>
{
public:
	// 행렬 크기와 데이터 포인터를 기본 클래스로 올려보낸다
	SquareMatrix()
	:SquareMatrixBase<T>(n, 0), pData(new T[n*n])
	{
		this->setDataPtr(pData.get());
	}
private:
	// 항목 13 참고
	boost::scoped_array<T> pData;
};

boost::scoped_array 는 new [ ] 처럼 동적으로 할당되지만, 스마트 포인터처럼 영역에서 빠져나갈 때 자동으로 메모리가 해제되는 객체이다.
어쨌든, 위 두 가지 방식 전부 코드 비대화를 줄일 수 있다. 물론 위의 두 가지 방식보다는, 행렬 크기가 미리 녹아든 상태로 별도의 버전이 만들어지는 invert 가 더 좋은 코드를 생성할 수도 있다. 상수 전파(constant propagation) 등의 최적화가 먹혀들어가기 좋기 때문이다.
반면, 여러 행렬 크기에 대해 한 가지 버전의 invert 를 두도록 만들면(위의 두 가지 방식을 써서) 프로그램의 작업 세트(working set) 크기가 줄어들면서 명령어 캐시 내의 참조 지역성(locality of reference) 가 향상될 수도 있다. 물론 SquareMatrixBase 에 추가적인 포인터 하나가 더 커져서 객체의 크기가 늘어날 수도 있다. 결론은, 여러 가지 가능성과 문제를 잘 고려하면서 만들어야 한다는 것이다.
vector<int> 나 vector<long> 멤버 함수도, 사실 똑같이 생겼다. list<int*>, list<const int*>, list<SquareMatrix<long, 3>*> 은 이진 수준에서 보면 멤버 함수 집합을 달랑 한 벌만 써도 되어야 한다. 즉, void* 로 동작하는 버전을 호출하는 식으로 만든다는 것이다!

0 Comments
댓글쓰기 폼