41. 템플릿 프로그래밍의 천릿길도 암시적 인터페이스와 컴파일 타임 다형성부터
객체 지향 프로그래밍의 핵심은 명시적 인터페이스와 런타임 다형성이다.
하지만 템플릿과 일반화 프로그래밍에서는 암시적 인터페이스와 컴파일 타임 다형성이 핵심이다.
명시적 인터페이스는 대개 함수 시그니처로 이루어진다. 함수의 이름, 매개변수 타입, 반환 타입 등등이다.
반면 암시적 인터페이스는 유효 표현식으로 이루어진다.
template<typename T>
void doProcessing(T& w) {
if (w.size() > 10 && w != someNastyWidget) ..
}
이런 경우 타입 T에서 제공될 암시적 인터페이스에는 size와 operator!=를 지원해야 하는 제약이 걸린다.
size 멤버 함수를 지원해야 하는 것은 맞지만 반드시 수치 타입을 반환할 필요까지는 없다. 그저 어떤 X 타입의 객체와 int가 함께 호출될 수 있는 operator>가 성립될 수 있도록 X타입의 객체만 반환하면 된다.
operator!=의 경우 타입의 암시적 변환이 가능하다면 유효 호출로 간주된다.
표현식에 걸리는 제약은 복잡해 보이지만 일반적으로는 평이한 편이다.
예를 들어 위와 같은 조건식인 경우에 size, operator>, operator&&, operator!= 함수에 대한 제약을 일일이 집어내려면 난감하지만, 결과적으로 if문의 조건식 부분은 boolean 표현식이어야 하기 때문에 표현식에서 쓰이는 것들이 정확히 어떤 값을 내놓던지 간에 bool과 호환되면 된다.
이 제약이 암시적 인터페이스의 일부이다.
if (w.size() > 10 && w != someNastyWidget) ...
// w.size()는 반드시 int size(void)일 필요는 없다
// X size(void)가 되더라도 X객체가 operator>를 지원하기만 하면 된다
결과적으로 조건식이 성립하기만 하면 명시적 인터페이스가 아니라도 괜찮은 것이다.
템플릿 안에서 어떤 객체를 쓰려고 할 때, 암시적 인터페이스를 그 객체가 지원하지 않으면 컴파일 오류가 발생한다.
◾ 클래스 및 템플릿은 모두 인터페이스와 다형성을 지원합니다.
◾ 클래스의 경우, 인터페이스는 명시적이며 함수의 시그니처를 중심으로 구성되어 있습니다.
◾ 템플릿 매개변수의 경우, 인터페이스는 암시적이며 유효 표현식에 기반을 두어 구성됩니다. 다형성은 컴파일 중에 템플릿 인스턴스화와 함수 오버로딩 모호성 해결을 통해 나타납니다.
42. typename의 두 가지 의미를 제대로 파악하자
템플릿의 타입 매개변수를 선언할 때 class와 typename은 뜻이 완전히 똑같다.
하지만 typename에는 한가지 의미가 더 있다. (추가: 모던 C++에서는 차이가 아예 없는것 같다)
template<typename T>
void print(const T& container) {
if (container.size() >= 2) {
T::const_iterator iter(container.begin());
// 만약 T::const_iterator가 타입이 아니라 그냥 정적 멤버라면?
++iter;
int value = *iter;
std::cout << value;
}
}
위와 같이 const_iterator가 T에 의존하는 중첩 의존 타입 이름(종속적 형식 이름)이라면 모호성이 발생할 가능성이 생긴다.
T::const_iterator는 반드시 타입이어야만 하는데 전역 변수이거나 정적 멤버일 가능성이 있기 때문이다.
typename T::const_iterator iter(container.begin());
typename 키워드를 붙여줌으로써 반드시 타입이라는 것을 명시한다.
단, 중첩 의존 타입 이름 식별 이외에는 붙여선 안된다.
template<typename T> // typename 쓸 수 있음
void f(const T& container, // typename 쓰면 안됨
typename T::iterator iter); // typename 꼭 써야함
template<typename T>
class Derived: public Base<T>::Nested { // 상속받는 기본 클래스는 typename 쓰면 안됨
public:
explicit Derived(int x) : Base<T>::Nested(x) { // 초기화 리스트의 기본 클래스는 typename 쓰면 안됨
typename Base<T>::Nested temp; // 중첩 의존 타입 이름이기 때문에 typename 반드시 필요
}
};
예외적으로 기본 클래스의 리스트에 있거나 멤버 초기화 리스트의 기본 클래스 식별자로 존재하는 경우는 붙여줘서는 안된다.
template<typename IterT>
void workWithIterator(IterT iter)
{
typedef typename std::iterator_traits<IterT>::value_type value_type; // 타입 재정의
value_type temp(*iter);
}
여담으로 타입이 너무 길면 타입 재정의로 줄여서 사용할 수 있다. 멤버 이름과 똑같이 짓는것이 관례이다.
◾ 템플릿 매개변수를 선언할 때, class 및 typename은 서로 바꾸어 써도 무방합니다.
◾ 중첩 의존 타입 이름을 식별하는 용도에는 반드시 typename을 사용합니다. 단, 중첩 의존 이름이 기본 클래스 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로 있는 경우에는 예외입니다.
43. 템플릿으로 만들어진 기본 클래스 안의 이름에 접근하는 방법을 알아 두자
template<typename T>
class MsgSender {
public:
void sendClear(const MsgInfo& info) { ... }
void sendSecret(const MsgInfo& info) { ... }
};
template<typename T>
class LoggingMsgSender: public MsgSender<T> {
public:
void sendClearMsg(const MsgInfo& info)
{
sendClear(info); // 컴파일 에러
}
};
템플릿으로 만들어진 기본 클래스의 멤버 함수를 기존과 같이 호출하면 컴파일러는 클래스가 어디서 파생된 것인지 모르기 때문에 에러를 출력하게 된다.
템플릿 매개변수가 무엇이 될지 모르는 상황이므로 sendClear가 존재하는지 알 수 없기 때문이다.
예를 들어서 템플릿 특수화가 적용된 클래스에 sendClear 함수가 존재하지 않을수도 있다.
기본 클래스 템플릿은 언제라도 특수화될 수 있고, 특수화 버전에서 인터페이스가 항상 동일하리라는 보장이 없다.
이 문제를 해결하기 위한 세 가지 방법이 존재한다.
this->sendClear(info);
첫 번째는 this->를 붙여서 해당 함수가 반드시 상속된다고 가정시킨다.
using MsgSender<T>::sendClear;
두 번째는 클래스 내부에서 using 선언을 통해 파생 클래스의 유효 범위로 끌고온다.
마찬가지로 반드시 상속된다고 가정한다.
MsgSender<T>::sendClear(info);
세 번째는 기본 클래스의 함수라는 것을 명시적으로 지정한다.
다만 이 방법은 호출되는 함수가 가상 함수인 경우, 가상 함수 바인딩이 무시되기 때문에 좋은 해결 방법이 아니다.
세가지 방법 모두 인터페이스를 그대로 제공할 것이라고 컴파일러에게 약속하는 것이다.
그리고 그 약속이 지켜지지 않으면 컴파일 에러가 발생한다.
◾ 파생 클래스 템플릿에서 기본 클래스 템플릿의 이름을 참조할 때는, "this->"를 접두사로 붙이거나 기본 클래스 한정문을 명시적으로 써 주는 것으로 해결합시다.
44. 매개변수에 독립적인 코드는 템플릿으로부터 분리시키자
템플릿이 아닌 코드에서는 코드 중복이 명시적이기 때문에 중복되는 부분을 눈으로 찾기 쉽다. 중복되는 부분을 찾았다면 별도로 만들어서 호출하거나 상속받으면 될 것이다.
하지만 템플릿은 코드 중복이 암시적이기 때문에 실제로 인스턴스화 될 때 발생할 수 있는 코드 중복을 예측으로 알아내야 한다.
template<typename T, std::size_t n>
class SquareMatrix {
public:
void invert;
};
SquareMatrix<double, 5> sm1;
sm1.invert(); // SquareMatrix<double, 5>::invert 호출
SquareMatrix<double, 10> sm2;
sm2.invert(); // SquareMatrix<double, 10>::invert 호출
위의 코드같은 경우 템플릿 매개변수만 다를뿐 동작은 완전히 동일한 함수가 2개가 생성된다.
코드 비대화를 일으키는 일반적인 원인 중 하나이다.
떠올릴 수 있는 단순한 해결 방법으로는 size_t를 매개변수로 받는 별도의 함수를 만들어서 호출하는 방법이 있겠다.
template<typename T>
class SquareMatrixBase {
protected:
void invert(std::size_t matrixSize);
};
template<typename T, std::size_t n>
class SuqareMatrix: private SquareMatrixBase {
private:
using SquareMatrixBase<T>::invert;
public:
void invert() { this->invert(n); } // 이미 using 선언이 되어있기 때문에 this->를 안붙여도 됨
};
두 객체가 기본 클래스의 invert를 호출하는것에 더불어 인라인화 되기 때문에 코드 비대화를 크게 줄일 수 있다.
다만 기본 클래스에서 처리할 데이터는 파생 클래스에서밖에 모르기 때문에 기본 클래스로 넘겨줄 방법을 찾아야 한다.
template<typename T>
class SquareMatrixBase {
public:
void invert(T* data, std::size_t n);
void size(T* data, std::size_t n);
... // 계속 늘어난다면?
};
단순히 기본 클래스의 invert의 매개변수로 포인터를 추가하는 것은 함수가 추가될때마다 계속 매개변수를 추가로 달아줘야 하기 때문에 좋은 해결책은 아니다.
이 방법을 제외하고 기본 클래스의 멤버에서 파생 클래스가 가지고 있는 데이터의 포인터를 가지고 있으면 된다.
기본 클래스에서 데이터의 포인터를 가지게 됨으로써 파생 클래스의 멤버 함수들 대다수가 기본 클래스 버전을 호출하는 단순한 인라인 함수가 될 수 있어서 코드 비대화의 측면에서 매우 좋은 성과를 거둘 수 있다.
실행 코드가 작아지기 때문에 캐시 참조 지역성도 향상될 수 있다.
◾ 템플릿을 사용하면 비슷비슷한 클래스와 함수가 여러 벌 만들어집니다. 따라서 템플릿 매개변수에 종속되지 않은 템플릿 코드는 비대화의 원인이 됩니다.
◾ 비타입 템플릿 매개변수로 생기는 코드 비대화의 경우, 템플릿 매개변수를 함수 매개변수 혹은 클래스 데이터 멤버로 대체함으로써 비대화를 종종 없앨 수 있습니다.
◾ 타입 매개변수로 생기는 코드 비대화의 경우, 동일한 이진 표현구조를 가지고 인스턴스화되는 타입들이 한 가지 함수 구현을 공유하게 만듦으로써 비대화를 감소시킬 수 있습니다.
45. "호환되는 모든 타입"을 받아들이는 데는 멤버 함수 템플릿이 직방!
같은 템플릿으로 만들어진 인스턴스들끼리는 아무런 관계도 성립하지 않는다.
템플릿 매개변수의 클래스들이 상속관계라고 하더라도 별개의 클래스로 본다.
template<typename T>
class SmartPtr {};
// Top<-Middle<-Bottom 상속 관계
SmartPtr<Top> pt1 = SmartPtr<Middle>(new Middle); // 성립x
SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom); // 성립x
이 코드가 성립되게 하려면 모든 생성자를 일일이 만들어 두어야 한다. 물론 현실적으로 말도 안되는 얘기이다.
template<typename T>
class SmartPtr {
public:
template<typename U>
SmartPtr(const SmartPtr<U>& other); // 일반화 복사 생성자
};
생성자 함수 대신 생성자 템플릿으로 만들어주면 호환되는 생성자를 일일이 만들지 않아도 인스턴스화 된다.
말로 풀어내면 SmartPtr<T> 객체가 SmartPtr<U>로부터 생성될 수 있다는 이야기이다.
다만 상속 관계가 역행할 가능성이 존재하기 때문에 타입 변환 제약을 줘야 한다.
template<typename T>
class SmartPtr {
public:
template<typename U>
SmartPtr(const SmartPtr<U>& other) : heldPtr(other.get()) { ... }
// T*타입에서 U*타입 포인터로 변환
T* get() const { return heldPtr; }
private:
T* heldPtr;
};
T* 타입을 U* 타입으로 암시적으로 변환하기 때문에 T와 U간의 호환성이 반드시 지켜져야만 한다.
멤버 함수 템플릿은 생성자뿐만 아니라 대입연산에도 많이 사용된다.
여기서 한가지, T타입과 U타입이 동일하게 들어오는 경우에도 일반화 복사 생성자는 복사 생성자를 인스턴스화 한다.
보통의 복사 생성자까지 필요하다면 직접 선언해야 한다.
template<class T>
class shared_ptr {
public:
shared_ptr(const shared_ptr& r); // 복사 생성자
template<class Y>
shared_ptr(const shared_ptr<Y>& r); // 일반화 복사 생성자
...
};
◾ 호환되는 모든 타입을 받아들이는 멤버 함수를 만들려면 멤버 함수 템플릿을 사용합시다.
◾ 일반화된 복사 생성 연산과 일반화된 대입 연산을 위해 멤버 템플릿을 선언했다 하더라도, 보통의 복사 생성자와 복사 대입 연산자는 여전히 직접 선언해야 합니다.
46. 타입 변환이 바람직할 경우에는 비멤버 함수를 클래스 템플릿 안에 정의해 두자
template<typename T>
class Rational {
public:
Rational(const T& numerator=0, const T& denominator=1);
};
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; // 에러
생성자에 explicit가 붙지 않아서 암시적 변환이 가능하다 하더라도 함수 템플릿 추론 과정은 암시적 타입 변환이 고려되지 않기 때문에 소용이 없다.
다만 클래스 템플릿에서는 템플릿 인자 추론의 영향을 받지 않기 때문에 friend 키워드를 붙여서 operator*를 클래스 내부로 끌고온다.
template<typename T>
class Rational {
public:
...
friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};
이제 T의 정확한 정보는 Rational<T>가 인스턴스화 될 때 바로 알 수 있고 동시에 operator*도 인스턴스화 된다.
다만 위처럼 선언만 하면 컴파일은 통과하지만 정의부가 없기 때문에 링크 에러가 발생한다.
내부에서 정의까지 해주면 빌드가 정상적으로 통과된다.
Rational<int> oneHalf(1, 2); // Rational<int> operator*도 같이 인스턴스화
Rational<int> result = oneHalf * 2; // 상수 2는 Rational 생성자의 암시적 변환에 의해 객체화 된다
모든 타입을 지원하기 위해서는 비멤버 함수여야 하고, 함수가 자동으로 인스턴스화 되기 위해서는 클래스 내부에 선언되어야만 한다. 이 두가지가 성립되기 위해 자연스럽게 나온것이 프렌드 함수이다.
마지막으로, 클래스 내부에 정의된 함수는 암시적으로 인라인 함수로 선언된다. 그렇기 때문에 꽤 복잡한 내용이 함수의 내용으로 들어가 있다면 다른 도우미 함수만 호출하는것이 효율적이다.
template<typename T> class Rational; // 전방 선언
template<typename T>
const Rational<T> doMultiply(const Rational<T>& lhs, const Rational<T>& rhs) { ... }
template<typename T>
class Rational {
public:
friend Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs)
{
return doMultiply(lhs, rhs);
}
};
doMultiply 함수 템플릿은 맨 처음 문제가 발생했던것과 똑같이 oneHalf * 2 같은 혼합형 연산을 지원하지 못하지만 지원할 필요가 전혀 없다.
애초에 Rational<T>의 operator*만 doMultiply를 호출할 것이고 항상 Rational<T> 타입으로 변환되어서 전달되기 때문이다.
◾ 모든 매개변수에 대해 암시적 타입 변환을 지원하는 템플릿과 관계가 있는 함수를 제공하는 클래스 템플릿을 만들려고 한다면, 이런 함수는 클래스 템플릿 안에 프렌드 함수로써 정의합니다.
47. 타입에 대한 정보가 필요하다면 특성정보 클래스를 사용하자
STL의 utility에는 반복자를 지정된 거리만큼 이동시키는 advance라는 함수가 있다.
단순히 += 연산으로 처리할 수 있을것 같지만 이게 가능한 반복자는 임의 접근 반복자 밖에 없다. 나머지는 ++, -- 연산만 지원하기 때문에 증감연산을 d만큼 적용하는 것으로 구현해야 한다.
C++ 표준 라이브러리에는 다섯 개의 반복자 범주 각각을 식별하는 데 사용되는 태그 구조체가 정의되어 있다.
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{};
당연히 모든 반복자가 임의 접근 반복자로 구현되어 있지 않다.
하지만 advance 함수는 모든 반복자에 대해 호환성을 제공해야 하기 때문에 반복자의 타입을 알아야 할 필요가 있다.
단순히 모든 접근 반복자에 대해서 증감 연산을 d번 반복하면 되지만 임의 접근 반복자에 대해 성능이 전혀 보장되지 않기 때문에 호환성은 보장될지언정 성능이 보장되지 않는다.
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; }
}
}
조건문을 구현하는 것이 최종 목표가 될 것이다.
조건문을 만족시키려면 어떤 타입인지를 알아야 한다는 것인데, 이는 특성정보를 통해 알아낼 수 있다.
특성정보는 컴파일 도중에 어떤 타입인지 알아낼 수 있는 객체를 지칭하는 개념이다. (RTTI)
특성정보는 정의된 문법이 아니라 관례적인 구현 기법이다.
반복자의 경우는 이미 iterator_traits라는 이름으로 준비되어 있다.
iterator_traits<IterT> 안에는 IterT 타입 각각에 대해 iterator_category라는 재정의 타입이 선언되어있다.
set, list 등 각기 다른 컨테이너들도 마찬가지로 iterator_category를 가지고 있으며 대신 태그가 다르다.
특성정보 클래스의 설계 및 구현 방법은 크게 3가지이다.
◾ 다른 사람이 사용하도록 열어 주고 싶은 타입 관련 정보를 확인한다.
◾ 그 정보를 식별하기 위한 이름을 선택한다. (ex: iterator_category)
◾ 지원하고자 하는 타입 관련 정보를 담은 템플릿 및 그 템플릿의 특수화 버전을 제공한다. (ex: iterator_traits)
마지막으로, IterT의 타입은 컴파일 타임에 파악되지만 if문은 런타임 도중에 평가되기 때문에 시점이 어긋난다.
template<typename IterT, typename DistT> // 컴파일 타임에 파악
void advance(IterT& iter, DistT d)
{
if (typeif(typename std::iterator_traits<IterT>::iterator_category) // 런타임에 평가
== typeid(std::random_access_iterator_tag))
}
함수 오버로딩을 통해서 둘을 분리시킨다.
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::std::bidirectional_iterator_tag) // 양방향 접근 반복자
{
...
}
...
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
doAdvance(iter, d, std::iterator_traits<IterT>::iterator_category);
// 3번째 인자로 태그를 넘겨준다
}
advance 함수에는 더 이상 조건문이 없기 때문에 문제가 생기지 않는다.
정리
◾ "작업자" 역할을 맡을 함수 혹은 함수 템플릿(ex: doAdvance)을 특성정보 매개변수를 다르게 하여 오버로딩한다.
◾ 작업자를 호출하는 "주작업자" 역할을 맡을 함수 혹은 함수 템플릿(ex: advance)을 만든다. 이 때, 특성정보 클래스에서 제공되는 정보를 넘겨서 작업자를 호출하도록 구현한다.
◾ 특성정보 클래스는 컴파일 도중에 사용할 수 있는 타입 관련 정보를 만들어냅니다. 또한 특성정보 클래스는 템플릿 및 템플릿 특수 버전을 사용하여 구현합니다.
◾ 함수 오버로딩 기법과 결합하여 특성정보 클래스를 사용하면, 컴파일 타임에 결정되는 타입별 if...else 점검문을 구사할 수 있습니다.
48. 템플릿 메타프로그래밍, 하지 않겠는가?
템플릿 메타프로그래밍(TMP)은 컴파일 도중에 실행되는 템플릿 기반의 프로그램을 작성하는 일을 말한다.
TMP를 사용하면 다른 방법으로는 까다롭거나 불가능한 일을 매우 쉽게 할 수 있고, 작업을 런타임 영역에서 컴파일 타임 영역으로 전환시킬 수 있다.
런타임 도중에 찾을 수 있는 에러들이 컴파일 타임으로 끌려오는 것이다.
부수적으로 컴파일 타임에 모두 동작하기 때문에 실행 시간이나 코드의 크기, 메모리를 적게 사용한다. 딱 하나, 컴파일 타임이 길어진다.
TMP는 그 자체가 튜링 완전성을 갖고 있다. 변수 선언, 루프 실행, 함수 작성 및 호출 등 모두 가능하다.
대신 구문요소가 보통의 C++과는 많이 다르다.
예를 들어 팩토리얼을 계산하는 방식이 있다.
// 재귀식 템플릿 인스턴스화. 컴파일 타임에 계산된다
template <int N>
struct Factorial {
static const int result = N * Factorial<N - 1>::result;
};
template <>
struct Factorial<1> {
static const int result = 1;
};
// 일반적인 재귀. 런타임에 계산된다
int factorial(int n) {
if (n == 1) return 1;
return n * factorial(n - 1);
}
형태가 많이 다르다.
C++에서의 TMP가 효과적인 사용처는 세 곳 정도이다.
◾ 치수 단위의 정확성 확인
모든 치수 단위의 조합이 제대로 됐는지를 컴파일 타임에 확인할 수 있기 때문에 정확성을 보장할 수 있다.
◾ 행렬 연산의 최적화
표현식 템플릿을 사용하면 행렬 연산에 사용되는 임시 객체를 없앰과 동시에 루프도 합쳐버릴 수 있다.
◾ 맞춤식 디자인 패턴 구현의 생성
◾ 템플릿 메타프로그래밍은 기존 작업은 런타임에서 컴파일 타임으로 전환하는 효과를 냅니다. 따라서 TMP를 쓰면 선행 에러 탐지와 높은 런타임 효율을 손에 거머쥘 수 있습니다.
◾ TMP는 정책 선택의 조합에 기반하여 사용자 정의 코드를 생성하는 데 쓸 수 있으며, 또한 특정 타입에 대해 부적절한 코드가 만들어지는 것을 막는 데도 쓸 수 있습니다.
'도서 > Effective C++' 카테고리의 다른 글
9. 그 밖의 이야기들 (0) | 2022.12.07 |
---|---|
8. new와 delete를 내 맘대로 (0) | 2022.12.07 |
6. 상속, 그리고 객체 지향 설계 (0) | 2022.12.04 |
5. 구현 (0) | 2022.12.03 |
4. 설계 및 선언 (0) | 2022.12.01 |