관리 메뉴

KoreanFoodie's Study

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

Tutorials/C++ : Advanced

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

머니덕 2022. 9. 17. 19:54

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


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

핵심 :

1. 호환되는 모든 타입을 받아들이는 멤버 함수를 만들려면 멤버 함수 템플릿을 사용한다.
2. 일반화된 복사 생성 연산과 일반화된 대입 연산을 위해 멤버 템플릿을 선언했다 하더라도, 보통의 복사 생성자와 복사 대입 연산자는 여전히 직접 선언해야 한다.

 

포인터는 다음과 같은 암시적 변환을 지원한다.

class Base {};
class Derived: public Base {};

Base *pt1 = new Derived;

하지만 템플릿을 이용해서 만드는 사용자 정의 스마트 포인터는 위와 같은 암시적 변환을 지원하지 않는다. 

template<typename T>
class SmartPtr
{
public:
	explicit SmartPtr(T *realPtr);
}

// 컴파일러 입장에서 SmartPtr<Base> 와 SmartPtr<Derived> 는
// 완전히 별개의 클래스다! (float 과 Widget 관계 수준이다)
SmartPtr<Base> pt1 = SmartPtr<Derived>(new Derived);

 

위와 같이 우리가 원하는 복사 생성자를 만들고 싶으면, 다음과 같이 멤버 함수 템플릿(member function template, 멤버 템플릿) 을 사용해야 한다.

template<typename T>
class SmartPtr
{
public:
	template<typename U>
	SmartPtr(const SmartPtr<U>& other);
}

위와 같은 생성자를 가리켜 일반화 복사 생성자(generalized copy constructor) 라고 부른다.

하지만 위의 코드는 Derived 에서 Base 로의 업캐스팅이나, int* 에서 double* 로의 바람직하지 않은 변환까지도 가능하게 한다. 이를 막기 위해, SmartPtr 도 기존 스마트 포인터의 get 멤버 함수를 통해 객체에 담긴 기본제공 포인터의 사본을 반환한다고 가정하면, 암시적 변환이 허용하는 선에서의 캐스팅이 이루어질 수 있다.

template<typename T>
class SmartPtr
{
public:
	template<typename U>
	SmartPtr(const SmartPtr<U>& other)
	: heldPtr(other.get()) { ... }

	T* get() const { return heldPtr; }
private:
	T *heldPtr;
}

멤버 초기화 리스트를 사용해서 SmartPtr<T> 의 데이터 멤버인 T* 타입의 포인터를 SmartPtr<U> 에 들어 있는 U* 타입의 포인터로 초기화했다. 즉, U* 에서 T* 로 진행되는 암시적 변환이 가능할 때만 컴파일 에러가 나지 않는다.

 

멤버 함수 템플릿은 대입 연산에도 자주 쓰인다. shared_ptr 템플릿의 예시를 보자.

template<class T> class shared_ptr
{
public:
	// 호환되는 기본 제공 포인터 및 스마트 포인터로부터
	// 생성자 호출이 가능
	template<class Y>
	explicit shared_ptr(Y * p);
	template<class Y>
	explicit shared_ptr(shared_ptr<Y> const& r);
	template<class Y>
	explicit shared_ptr(weak_ptr<Y> const& r);
	template<class Y>
	explicit shared_ptr(auto_ptr<Y>& r);
	// 호환되는 shared_ptr 과 auto_ptr 로부터 대입 가능
	template<class Y>
	shared_ptr& operator=(shared_ptr<Y> const& r);
	template<class Y>
	shared_ptr& operator=(auto_ptr<Y>& r);
	...
};

auto_ptr 에서 const 가 붙지 않는 것은, auto_ptr 는 복사 연산으로 객체가 수정될 때, 복사된 쪽만 유효하게 남는다는 사실을 반영한 것이다(unique_ptr 에서는 아예 복사 생성/대입 연산자가 삭제되어 있음).

다만, 일반화 복사 생성자에서 T 와 U 가 같은 타입으로 들어오면, "보통의" 복사 생성자로 인스턴스화된다. 그럼 T 와 U 가 다르면 어떻게 될까?

컴파일러는 컴파일러가 나름의 복사 생성자를 자동으로 만든다. 따라서, 우리가 원하는 동작을 하도록 하는 복사 생성자를 제대로 구현해 주어야 한다! shared_ptr 의 예시를 보자.

template<class T> class shared_ptr
{
public:
	// 복사 생성자
	shared_ptr(shared_ptr const& r);
	// 일반화 복사 생성자
	template<class Y>
	explicit shared_ptr(shared_ptr<Y> const& r);

	// 복사 대입 연산자
	shared_ptr& operator=(shared_ptr const& r);
	// 일반화 복사 대입 연산자
	template<class Y>
	shared_ptr& operator=(shared_ptr<Y> const& r);
	...
};

 

 

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

핵심 :

모든 매개변수에 대해 암시적 타입 변환을 지원하는 템플릿과 관계가 있는 함수를 제공하는 클래스 템플릿을 만들려고 한다면, 이런 함수는 클래스 템플릿 안에 프렌드 함수로서 정의한다

 

다음과 같은 유리수 계산 클래스가 있다고 해 보자.

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)
{ ... }

...

// 문제 없음
Rational<int> oneHalf(1, 2);

// 컴파일이 안됨!
Rational<int> result = oneHalf * 2;

위 코드에서 result 가 동작하지 않는 이유는, 2 라는 int 타입 변수를 매개변수로 받는 과정(템플릿 인자 추론 - template argument deduction)에서는 암시적 타입 변환이 고려되지 않기 때문이다!

이런 타입 변환은 함수 호출이 진행될 때 쓰이는 것은 맞다. 하지만, 템플릿 인자 추론이 진행되는 동안에는 생성자 호출을 통한 암시적 타입 변환 자체가 고려되지 않는다.

그런데, 클래스 템플릿 안에 프렌드 함수를 넣어 두면 함수 템플릿으로서의 성격을 주지 않고 특정한 함수 하나를 나타낼 수 있다. 클래스 템플릿은 템플릿 인자 추론 과정에 좌우되지 않으므로(템플릿 인자 추론은 함수 템플릿에만 적용되는 과정임), T 의 정확한 정보는 Rational<T> 클래스가 인스턴스화 될 당시에 바로 알 수 있다.

template<typename T>
class Rational
{
public:
	friend 
	const Rational operator*(const Rational& lhs, const Rational& rhs)
	{
		return Rational(lhs.numerator() * rhs.numerator(),
			lhs.denominator() * rhs.denominator());
	}
};

이제 이 코드는 컴파일이 된다. oneHalf 객체가 Rational<int> 타입으로 선언되면 Rational<int> 클래스가 인스턴스로 만들어지고, 그 과정의 일부로 Rational<int> 타입의 매개변수를 받는 프렌드 함수인 operator* 도 자동으로 선언된다. 이전과 달리 지금은 함수가 선언된 것이므로(함수 템플릿이 아니라) 컴파일러는 이 호출문에 대해 암시적 변환 함수(Rational 의 비명시 호출 생성자) 를 적용할 수 있게 된다. 또한, Rational<T> 로 써도, Rational 로 써도 같은 의미이다. 다만 코드가 더 깔끔해졌을 뿐이다.

모든 인자에 대해 타입 변환이 가능하도록 만들기 위해 비멤버 함수가 필요하고, 호출 시의 상황에 맞는 함수를 자동으로 인스턴스화 하기 위해서는 그 비멤버 함수를 클래스 안에 선언해야 한다. 공교롭게도, 클래스 안에 비멤버 함수를 선언하는 유일한 방법이 '프렌드'이다(public 여부랑은 관계없음).

추가적으로, 클래스 안에 정의된 함수는 암시적으로 인라인된다. 물론, 다음과 같이 "도우미 함수" 가 호출되게 만듦으로써 인라인의 영향을 피할 수 있다.

template<typename T>
class Rational
{
public:
	friend 
	const Rational operator*(const Rational& lhs, const Rational& rhs)
	{
		// 프렌드 함수가 도우미 함수를 호출하게 만듦
		return doMultiply(lhs, rhs);
	}
};

// 도우미 함수 템플릿은 일반적으로 헤더 파일에 들어감
template<typename T>
const Rational<T> doMultiply(const Rational<T>& lhs, const Rational<T>& rhs)
{
	return Rational<T>(lhs.numerator() * rhs.numerator(),
	lhs.denominator() * rhs.denominator());
}

 

 

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

핵심 :

1. 특성정보 클래스는 컴파일 도중에 사용할 수 있는 타입 관련 정보를 만들어낸다. 또한 특성정보 클래스는 템플릿 및 템플릿 특수 버전을 사용하여 구현한다.
2. 함수 오버로딩 기법과 결합하여 특성정보 클래스를 사용하면, 컴파일 타임에 결정되는 타입별 if...else 점검문을 구사할 수 있다.

 

STL 에는 container, iterator, algorithm 말고도 utility 라고 불리는 템플릿도 몇 개 들어 있다. 이들 중 advance 라는, 지정된 반복자를 지정된 거리(distance) 만큼 이동시키는 녀석을 보자.

template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d);

사실 iterator 는 다섯 종류가 있다 : (contiguous iterator 는 일단 제외하자. C++20 부터의 기능이다)

  1. 입력 반복자(input iterator) : 전진만 가능, 한 번에 한 칸씩 이동, 가리키는 위치에서 읽기만 가능, 읽을 수 있는 횟수는 한 번
  2. 출력 반복자(output iterator) : 입력 반복자와 비슷하지만 출력용이라 쓰기만 가능
  3. 순방향 반복자(forward iterator) : 읽기 쓰기 여러 번 가능, 전진만 가능
  4. 양방향 반복자(bidirectional iterator) : 읽기 쓰기 여러 번 가능, 전진/후진 가능
  5. 임의 접근 반복자(random access iterator) : 반복자를 임의의 거리만큼 이동 가능. 제일 강력
struct input_iterator_tag { };
struct output_iterator_tag { };
struct forward_iterator_tag : public input_iterator_tag { };
struct bidirectional_iterator_tag : public forward_iterator_tag { };
struct random_access_iterator_tag : public bidirectional_iterator_tag { };
struct contiguous_iterator_tag : public random_access_iterator_tag { }; // (since C++20)

위의 다섯 반복자 범주 각각을 식별하는 데 쓰이는 "태그(tag) 구조체" 가 C++ 표준 라이브러리에 정의되어 있다.

 

이제 다시 advance 로 돌아와서, 구현을 해 보자.

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

위 부분의 코드가 제대로 동작하려면 iter 부분이 임의 접근 반복자인지 판단할 수 있어야 한다. 이때 사용하는 것이 바로 특성정보(traits) 이다. 특성정보란, 컴파일 도중에 어떤 주어진 타입의 정보를 얻을 수 있게 하는 객체를 지칭하는 개념이다. 

특성정보는 C++ 에 미리 정의된 문법구조가 아니며, 키워드도 아니다. 그냥 프로그래머들이 따르는 구현 기법이자 관례이다. 특성정보는 기본 제공 타입에 대해서 쓸 수 있어야 하는데, 이는 중첩된 정보 등으로는 구현이 안된다는 말과도 같다. 결국, 어떤 타입의 특성정보는 그 타입의 외부에 존재하는 것이어야 한다. 

// 반복자 타입에 대한 정보를 나타내는 템플릿
template<typename IterT>
struct iterator_traits;

보다시피, iterator_traits 는 구조체 템플릿이다. 관례에 따라, 특성정보는 항상 구조체로 구현하는 것으로 굳어져 있다. 또한 이런 구조체를 가리켜 '특성정보 클래스'라고 부른다.

이는 특성 정보는 두 부분으로 나뉘어져 있다. 먼저, 사용자 정의 반복자 타입으로 하여금 iterator_category 라는 typedef 타입이 선언될 것을 요구사항으로 둔다. 또한 이 iterator 클래스가 내부에 지닌 typedef 타입을 앵무새처럼 똑같이 재생한 것이 iterator_traits 이다.

// 매개변수는 편의상 생략...
template<...>
class deque
{
public:
  class iterator
  {
  public:
    typedef random_access_iterator_tag iterator_category;
  };
};

// 반복자 타입에 대한 정보를 나타내는 템플릿
template<typename IterT>
struct iterator_traits
{
  typedef typename IterT::iterator_category iterator_category;
};

// 포인터 타입의 반복자 지원
// 부분 템플릿 특수화(partial template specialization) 버전
template<typename IterT>
struct iterator_traits<IterT*>
{
  typedef random_access_iterator_tag iterator_category;
};

 

이제 advance 의 의사코드를 다듬어 보자.

template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
  if (typeid(typename std::iterator_traits<IterT>::iterator_category)
    == typeid(std::random_access_iterator_tag))
  {
    // 임의 접근 반복자일때의 동작 수행
  }
  ...
}

if ... else 문의 처리는 런타임에 수행하지만, 사실 우리는 위의 타입 체킹이 컴파일 타임에 이루어졌으면 한다(추가로, 이 코드는 컴파일 문제가 있는데, 이는 항목 48에서 다루겠다).

오버로딩을 사용하면 우리가 원하는 것을 얻을 수 있다.

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

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

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

이제 advance 를 수정하자. 오버로딩된 doAdvance 를 호출하기만 하면 된다.

template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
  // iter 의 반복자 타입에 맞는 doAdvance 의 오버로드 버전을 호출한다
  doAdvance
  (
    iter, d,
    typename std::iterator_traits<IterT>::iterator_category()
  );
}

정리하면, 특성정보 클래스 사용은 다음과 같은 2-step 으로 이루어져 있다.

  1. "작업자(worker)" 역할을 맡을 함수 혹은 함수 템플릿(e.g. doAdvance)을 특성정보 매개변수를 다르게 하여 오버로딩한다(오버로드 버전 구현).
  2. 작업자를 호출하는 "주작업자(master)" 역할을 맡을 함수 혹은 함수 템플릿(e.g. advance) 를 만들고, 특성정보 클래스에서 제공되는 정보를 넘겨서 작업자를 호출하도록 구현한다.

 

 

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

핵심 :

1. 템플릿 메타프로그래밍은 기존 작업을 런타임에서 컴파일 타임으로 전환하는 효과를 낸다. 따라서 TMP 를 쓰면 선행 에러 탐지와 높은 런타임 효율을 손에 거머쥘 수 있다.
2. TMP 는 정책 선택의 조합에 기반하여 사용자 정의 코드를 생성하는 데 쓸 수 있고, 특정 타입에 대해 부적절한 코드가 만들어지는 것을 막는 데도 쓸 수 있다.

 

템플릿 메타프로그래밍(template metaprogramming: TMP) 는 컴파일 도중에 실행되는 템플릿 기반의 프로그램을 작성하는 일을 말한다.

TMP 는 두 가지 장점이 있다. 첫째, 까다롭거나 불가능한 일을 매우 쉽게 할 수 있고, 둘째, 런타임 부하를 줄여준다.

 

이제 항목 47에서 컴파일 문제가 일어난 부분을 다시 한 번 보자.

 

 

void advance(std::list<int>::iterator& iter, int d)
{
  if (typeid(typename std::iterator_traits<std::list<int>::iterator>::iterator_category)
    == typeid(std::random_access_iterator_tag))
  {
    iter += d;
  }
  ...
}

여기서 list 는 양방향 반복자를 가지므로, iter += d; 부분 자체가 불가능해 컴파일이 되지 않는다!

 

TMP 는 그 자체가 튜링 완전성을 갖고 있어, 어떤 것이든 계산할 수 있다(변수 선언, 루프, 함수 작성 및 호출 등).

TMP 의 루프는 재귀함수 호출을 만들지 않고 재귀식 템플릿 인스턴스화(recursive template instantiation)를 통해 진행된다. 예시를 보자.

#include <iostream>

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

template<>
struct Factorial<0>
{
  enum { value = 1 };
};

int main()
{
  // 런타임 계산 없이 아래 값들을 출력
  std::cout << Factorial<5>::value << std::endl;
  std::cout << Factorial<10>::value << std::endl;
}

 

TMP 의 대표적인 쓰임처는 다음과 같다 :

  • 치수 단위(dimensional unit) 의 정확성 확인 : 컴파일 도중에 단위의 정합성이 체크된다
  • 행렬 연산의 최적화 : 덩치 큰 임시 객체를 없애고 루프를 병합한다
  • 맞춤식 디자인 패턴 구현의 생성 : 정책 기반 설계(policy-based design) 을 이용해 패턴을 조합. 생성식 프로그래밍(generative programming)의 기초가 이런 기술

TMP 의 더 자세한 예시는 C++ Beginner 카테고리의 9-3번 글과 9-4번 글을 참고하도록 하자!

 

0 Comments
댓글쓰기 폼