【Effective C++】模板与泛型编程

文章目录

    • 一、了解隐式接口和编译期多态
      • 1、面向对象编程的世界
      • 2、隐式接口
      • 3、编译期多态
      • 4、请记住
    • 二、了解typename的双重意义
      • 1、嵌套从属名称
      • 2、请记住
    • 三、学习处理模板化基类内的名称
      • 1、模板全特化的概念
      • 2、请记住
    • 四、将参数无关的代码抽离template
      • 1、共性与变性分析(找出共同部分和变化部分,分别分析处理)
      • 2、请记住
    • 五、运用成员函数模板接受所有兼容类型
      • 1、成员函数模板
      • 2、请记住
    • 六、需要类型转换时请为模板定义非成员函数
      • 1、非成员函数模板
      • 2、请记住
    • 七、使用traits classes表现类型信息
      • 1、迭代器分类
      • 2、Traits
      • 3、请记住
    • 八、认识template元编程
      • 1、template元编程(TMP)的优缺点
      • 2、请记住

  C++ templates的最初发展动机很直接:让我们得以建立“类型安全”的容器如vector,list和map。然而当愈多人用上templates,他们发现templates有能力完成愈多可能的变化。容器当然很好,但泛型编程——写出的代码和其所处理的对象类型彼此独立——更好。

  C++ template机制自身是一部完整的图灵机:它可以被用来计算任何可计算的值。于是导出了模板元编程,创造出“在C++编译器内执行并于编译完成时停止执行”的程序。

一、了解隐式接口和编译期多态

1、面向对象编程的世界

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

void doProcessing(Widget& w)
{
    if (w.size() > 10 && w != someNastyWidget)
    {
        Widget temp(w);
        temp.normalize();
        temp.swap(w);
    }
}
  • 由于w的类型被声明为Widget,所以w必须支持Widget接口。我们可以在源码中找出这个接口,看看它是什么样子,所以我们称为一个显式接口,也就是它在源码中明确可见

  • 由于Widget的某些成员函数是virtual,w对那些函数的调用将表现出运行期多态,也就是说将于运行期根据w的动态类型(条款37)决定究竟调用哪一个函数。

2、隐式接口

  隐式接口是相对于函数签名所代码的显式接口而言的。当我们看到一个函数签名(即函数声明),比如说:

string GetNameByStudentID(int StudentID); 

我们就知道这个函数有一个整型的形参,返回值是string。

  但隐式接口是由有效表达式组成的,考虑一个模板函数,像下面这样:

template <class T>
void TemplateFunction(T& w)
{
  if(w.size() > 10) {}
}

T可以是int,可以double,也可以是自定义的类型。光看这个函数声明,我们不能确定T具体是什么,但我们知道,要想通过编译,T必须要支持size()这个函数。也就是说,T中一定要有这样的函数接口声明。

ReturnValue size(); 

当然返回值ReturnValue不一定是int了,只要它能支持operator > (ReturnValue, 10)这样的运算即可。这种由表达式推判出来的函数接口,称之为隐式接口。

  简言之,显式接口由函数签名式构成,隐式接口由有效的表达式组成。

3、编译期多态

  编译期多态,从字面上来看,它发生在编译阶段,实际上就是template 这个T的替换,它可以被特化为int,或者double,或者用户自定义类型,这一切在编译期就可以决定下来T到底是什么,编译器会自动生成相应的代码(把T换成具体的类型),这就是编译期多态做的事情了,它的本质可以理解成自动生成特化类型版本,T可以被替换成不同的类型,比如同时存在int版本的swap与double版本的swap,形成函数重载。

  简言之,运行时多态是决定“哪一个virtual函数应该被绑定”,而编译期多态决定“哪一个重载函数应该被调用”。

4、请记住

  • classes 和 templates 都支持接口和多态。

  • 对classes而言接口是显式的,以函数签名为中心。多态则是通过virtual函数发生于运行期。

  • 对template参数而言,接口是隐式的,奠基于有效表达式。多态则是通过template具现化和函数重载解析发生于编译期。


二、了解typename的双重意义

  声明模板:

template <class T> 
template <typename T> 

1、嵌套从属名称

template <typename C>
void print2nd (const C& container)
{
    if(container.size()>=2)
    {
        C::const_iterator iter(container.begin());
        ++iter;
        int value = *iter;
        cout << value;
    }
}

  iter的类型是C::const_iterator,实际上它是什么取决于template参数C.template内出现的名字如果依赖于某个template参数,则称为从属名称。如果从属名称在class内程嵌套状(::)则称之为嵌套从属名称。嵌套从属名称可能会导致解析(parsing)困难。

如果编译器在template中遭遇了一个嵌套从属名称,它便假设该名称并非类型,除非你主动告诉它这是类型。(这个情况有一个小小的例外)。想要程序正确运行,上文中的实例应该改为:

typename C::const_iterator iter;

  任何时候你想在template内指涉一个嵌套从属类型名称,就必须在它之前放上一个typename。另外,typename只被用来验明嵌套从属类型名称,其它名称没必要用它。

【使用typename的两个特例】:

// 继承
class A: public B::NestedClass{}; // 正确
class A: public typename B::NextedClass(){}; // 错误

// 构造函数
A(): B::NestedClass(){} // 正确
A(): typename B::NestedClass(){} // 错误

2、请记住

  • 声明template参数时,前缀关键字class 和 typename 可互换。

  • 请使用关键字 typename 标识嵌套从属类型名称;但不得在base classes list (基类列)或 member initialization list(成员初值列)内以它作为base class 修饰符。


三、学习处理模板化基类内的名称

1、模板全特化的概念

  背景是这样的,有两个不同的公司,然后想设计一个MessageSender,为这两个公司发送不同的消息,既支持明文发送SendClearText,也支持密文发送SendEncryptedText:

class CompanyA
{
public:
    void SendClearText(){}
    void SendEncryptedText(){}
};

class CompanyB
{
public:
    void SendClearText(){}
    void SendEncrypedText(){}
};

template <class T>
class MsgSender
{
public:
    void SendClearText(){}
};

template <class T>
class MsgSenderWithLog: public MsgSender<T>
{
public:
    void SendClearTextWithLog()
    {
        // Logs
        SendClearText(); // 有的编译器会编不过这段代码
    }
};

int main()
{
    MsgSenderWithLog<CompanyA> MsgSender;
    MsgSender.SendClearTextWithLog();
}

  CompanyA与CompanyB有各自的发送函数,然后有一个模板类MsgSender,这个模板待确定的参数是T,可以是CompanyA或CompanyB,这样就可以在定义MsgSender时,比如MsgSender或者MsgSender,指定到底调用的哪个公司的发送函数了,这是在编译期就可以确定下来的事情。

  在模板技术中存在全特化的概念,比如C公司,这个公司根本不想发送明文,也就是说它只有SendEncryptedText()接口,没有SendClearText()。为了使我们的静态多态仍然可用,我们这样定义只适用于C公司的MsgSender:

class CompanyC
{
public:
    void SendEncryptedText(){}
};
// 模板特例化(本质是模板的一个实例,专门为某一种类型设计的)
template <>
class MsgSender<CompanyC>
{
public:
    void SendEncryptedText(){}
};
int main()
{
	MsgSenderWithLog<CompanyC> MsgSenderC;
	MsgSenderC.SendClearTextWithLog(); //编译器无法通过编译
}

  此时,对于LoggingMsgSender,当base class 被指定为MsgSender 时,调用sendClearMsg仍不合法,因为CompanyZ并未提供sendClear函数!这就是为什么C++拒绝这个调用的原因:它知道base class templates有可能被特化,而那个特化版本可能不提供和一般性template相同的接口。因此它往往拒绝在templatized base classes(模板化基类)内寻找继承而来的名称。

【解决方法一】:

void SendClearTextWithLog()
{
    // Logs
    SendClearText(); // 有的编译器会编不过这段代码
}
// 改成
void SendClearTextWithLog()
{
    // Logs
    this->SendClearText(); // 这下能编译通过了
}

【解决方法二】:

  在子类中声明using MsgSender::SendClearText;编译器报error本质是不进行模板父类域的查找,所以这里using了父类的一个函数名,强制编译器对之进行查找。

【解决方法三】:

  将SendClearText()指明为MsgSender::SendClearText()

2、请记住

  • 在derived class template内通过“this->”指涉base class templates内的成员名称,或藉由一个明白写出的“base class资格修饰符”完成。

四、将参数无关的代码抽离template

1、共性与变性分析(找出共同部分和变化部分,分别分析处理)

  Templates是避免代码重复与节约时间的最优解。但有时,template可能会造成代码膨胀:二进制码带着大量重复(或几乎重复)的代码或者数据。其源码可能看起来优雅整洁,但实际上生成的object code完全不是一回事,我们需要对此进行优化。

  假定我们要为矩阵写一个类,这个矩阵的行列元素个数相等,是一个方阵,因而我们可以对之求逆运算。因为方阵的元素可以有多种类型,同时方阵的维数(方阵大小)也可以不同,像下面这样,我们使用了模板:

template <class T, size_t n>
class SquareMatrix
{
public:
    void Invert();
};

int main()
{
	SquareMatrix<double,5> sm1;
	sm1.invert();
	SquareMatrix<double,10> sm2;
	sm2.invert();
}

  以上操作直接导致具现化了两个invert,虽然它们并非完全相同,但是相当类似,唯一的区别就是一个作用于55矩阵一个作用于1010矩阵。

【解决方法】:

  为了防止编译器生成冗余的代码,我们可以将Invert抽离这里,变成一个以n为参数的函数,像下面这样:

// 把算法一致、只与维数n相关的算法函数都放在SquareMatrixBase中,
// 且将n作为它的Invert()函数的形参
class SquareMatrixBase
{
public:
    SquareMatrixBase(T* p) : DataPointer(p){}
    void Invert(size_t n){}
private:
    T* DataPointer;
};

template <class T, size_t n>
class SquareMatrix: private SquareMatrixBase<T>
{
public:
    SquareMatrix() : SquareMatrixBase(Data)
    {}
    void Invert()
    {
        SquareMatrixBase::Invert(n);
    }
private:
    T Data[n * n];
};

  这样在main函数中,针对类型都是int,但矩阵维数不同的情况,Invert中生成的冗余代码非常少(只有一句话),父类也因为模板参数与维数无法,所以也只生成一份。从这个角度来看,将与参数无关的代码抽离template起到了减少代码冗余度的优化作用。

2、请记住

  • template生成多个class与多个函数,所以任何template代码都不应该与某个造成膨胀的template参数相互依存。

  • 因为non-type template parameters造成的代码膨胀往往可以消除,做法是以函数参数或者class成员变量替换template参数。

  • 因type parameters造成的代码膨胀往往可以降低,做法是让带有相同二进制表述的具现类型共享实现码。


五、运用成员函数模板接受所有兼容类型

1、成员函数模板

  真实指针有一个很好用的功能:支持隐式转换。举例来说:

  • derived class指针可以隐式转为base class指针;

  • 指向non-const对象的指针可以指向const对象

// 原始指针
class Top { ... };
class Middle : public Top { ... };
class Bottom : public Middle { ... };
Top* pt1 = new Middle;
Top* pt2 = new Bottom;
const Top* pct2 = pt1;

// 智能指针
template<typename T>
class SmartPtr
{
    public:
        explicit SmartPtr(T* realPtr);
        ...
};
SmartPtr<Top> pt1 = SmartPtr<Middle>(new Middle);
SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom);
SmartPtr<const Top> pct2 = pt1;

  上面智能指针转换问题在于,对于同一个template的不同具现化之间并不存在什么与生俱来的固有关系。所以编译器视SmartPtrSmartPtr为完全不同的classe。

【解决方法】:

  我们关注的重点是应该如何编写智能指针的构造函数。经过分析不难得出结论:我们永远无法写出所有需要的构造函数。我们真正需要撰写的是一个构造模板。假设SmartPtr遵循shared_ptr的接口,存在一个get函数返回原始指针的副本,那我们则可以在构造模板中实现代码约束,具体如下:

template <typename T>
class SmartPtr{
public:
    template <typename U>
    SmartPtr(const SmartPtr<U> &other) : helder(other.get()) {....}
    T* get() const {return heldptr;}
private:
    T* heldPtr;
}

2、请记住

  • 请使用member function templates(成员函数模板)生成“可接受所有兼容类型”的函数。

  • 如果你声明member templates用于“泛化copy构造”或“泛化assignment操作”,你还是需要声明正常的copy构造函数和copy assignment操作符。


六、需要类型转换时请为模板定义非成员函数

  前面已经描述过为何仅有non-member函数才有能力对所有实参执行隐式转换。本节我们将类型转换拓展到template范围。

1、非成员函数模板

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;//error 编译失败

  在非模板情况下,编译器知道我们尝试去调用接受两个Rational参数的operator*,但在这里,编译器不知道我们想调用哪个函数。它在试图想出什么函数可以被名为operator*的template具现化出来。它知道它们应该可以具现出某个名为operator*且接受两个rational参数的函数,但为了完成这一具现化行为,必须先算出T是什么。编译器无法做到。

【模板实参推导】:

  operator*的第一参数被声明为rational,而传递给operator*的第一实参oneHalf是Rational,那可以肯定T是int.关键在于第二实参接受的应该是一个Rational,但实际传入的却是int。T的推导无法确定template实参推导过程中并不考虑隐式类型转换。这就是报错的关键。尽管隐式转换在函数调用过程中的确被使用,但在能调用一个函数之前,它必须已经存在(已被推导出并具现化)。

【解决方法】:

  template class内的friend声明式可以指涉某个特定函数,那意味着class Rational可以声明operator*是它的一个friend函数。class templates并不依赖于template实参推导(实参推导仅发生在function templates身上),所以编译器总是能在class rational具现化时得知T。因此,令Rational class 声明适当的operator*为其friend函数,可以简化整个问题:

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时,Rational则被具现化,而作为具现化的一部分,operator*也完成了自动声明,后者作为一个non-template function,编译器可以在调用时使用隐式转换。

2、请记住

  • 当我们编写一个class template,而它所提供之“与此template相关的”函数支持“所有参数之隐式类型转换”时,请将那些函数定义为“class template内部的friend函数”。

七、使用traits classes表现类型信息

1、迭代器分类

  • input(输入)与output(输出)迭代器:它们只能向前移动,一次一步,用户只能读取/涂写它们所指的东西,显然这是在模仿输入输出文件的读写指针。istream_iterator与ostream_iterator是这一类迭代器的代表。由于这两类都只能向前移动,并且最多读写一次,所以他们只适合“一次性操作算法”。

  • forward(前向)迭代器:这类迭代器能完成input与output迭代器所做的任何事,并且可以读或写其所指物一次以上。所以它们可执行多次性算法。STL并未提供SingleLinked List,但不难想象,这类容器的迭代器就是forward迭代器。

  • Bidirection(双向)迭代器:比上一类更加强大,支持双向移动。STL中list,map,set(以及它们的multi版本)的迭代器就属于这一分类。

  • random access(随机)迭代器:在Bidirection迭代器的基础上支持了算术操作,也就是说可以在常量时间内前后移动任意距离。这种操作类似于指针算术,而它也确实以内置指针为榜样。vector,deque,string的迭代器便是这种类型。

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 bidirection_iterator_tag {};

2、Traits

  Traits并非是c++的某个关键词或者一个事先定义好的构件;这是一种技术,也是c++程序员共同遵循的协议。其内置要求之一是:对于内置类型与用户自定义类型的表现必须一样好。举例而言,即如果上述advance接受的是一个指针与一个int参数,它必须仍然可以有效运作。所以traits的工作:允许你在编译期获取某些类型信息。

  iterator_traits以两个部分来实现上述要求。首先,它要求每个“用户自定义的迭代器类型”必须嵌套一个typedef,名为iterator_category,用以确认当前的卷标结构。举例而言,deque和list的迭代器可能如下:

template<...> 
class deque{
public:
    class iterator{
    public:
        typedef random_access_iterator_tag iterator_category;
    };
};

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

  至于iterators_traits,则是被动响应iterator class的嵌套式typedef:

template<typename IterT>
struct iterator_traits{
    typedef typename IterT::iterator_category iterator_category;
}

template<typename IterT>
struct iterator_traits<IterT *>{//偏特化
    typedef random_access_iterator_tag iterator_cagetory;
}

  这里都是用typeid(返回指针或者引用所指对象的类型)去进行类型判断的,它是在运行期才能执行,那么能不能放在编译期呢,当然可以,就是要用到函数的重载,像下面这样:

template<typename IterT,typename Dist>
void doadvance(IterT &iter,Dist d,std::random_access_iterator_tag){
    iter+=d;
}
template<typename IterT,typename Dist>
void doadvance(IterT &iter,Dist d,std::bidirectional_iterator_tag){
    if(d>=0) {
        while(d--) ++iter;
    }
    else{
        while(d++) --iter;
    }
}
template<typename IterT,typename Dist>
void doadvance(IterT &iter,Dist d,std::input_iterator_tag){
    if(d>=0) {
        throw std::out_of_range("Negative distance");
    }
    else{
        while(d--) ++iter;
    }
}
// advance的实现
template<typename IterT,typename Dist>
void advance(IterT &iter,Dist d){
    doadvance(iter,d,typename std::iterator_traits<IterT>::iterator_category());
}

【如何使用traits classes】:

1. 建立一组重载函数或函数模板(doAdvance),彼此间的差异只在于各自的traits参数。令每个函数实现码与其接受之traits信息相应和。

2. 建立一个控制函数或函数模板(advance),它调用上述那些doAdvance函数并传递traits class 所提供的信息。

3、请记住

  • traits class使得“类型相关信息”在编译期可用,它们以templates和偏特化完成实现。
  • 整合重载技术后我们可以在编译期执行if-else测试。

八、认识template元编程

1、template元编程(TMP)的优缺点

  作为模板部分的结束节,本条款谈到了模板元编程,元编程本质上就是将运行期的代价转移到编译期,它利用template编译生成C++源码,举下面阶乘例子:

template <int N>
struct Factorial
{
    enum
    {
        value = N * Factorial<N - 1>::value
    };
};

// 特化版本
template <>
struct Factorial<0>
{
    enum
    {
        value = 1
    };
};

int main()
{
	// 在编译期,Factorial<5>::value就被翻译成了5 * 4 * 3 * 2 * 1,
	// 在运行期直接执行乘法即可。
    cout << Factorial<5>::value << endl; // 输出120
}

【元编程的优点】:

  • 以编译耗时为代价换来卓越的运行期性能,因为对于产品级的程序而言,运行的时长远大于编译时长。

  • 将原来运行期才能发现的错误提前到了编译期,要知道,错误发现的越早,代价越小。

【元编程的缺点】:

  • 代码可读性差,写起来要运用递归的思维,非常困难。

  • 调试困难,元程序执行于编译期,不能debug,只能观察编译器输出的error来定位错误。

  • 编译时间长,运行期的代价转嫁到编译期。

  • 可移植性较差,老的编译器几乎不支持模板或者支持极为有限。

2、请记住

  • TMP可将工作由运行期转移到编译期,因而得以实现早期错误侦测或者更高的执行效率。

  • TMP可被用来生成“基于政策选择组合”的客户定制代码,也可以用来避免生成对某些特殊类型并不适合的代码。

你可能感兴趣的:(c++进阶)