C++(四) Effective-C++(下)

原图

模板与泛型编程

条款 41:了解隐式接口和编译期多态

面向对象编程世界总是以显式接口( explicit interfaces)和运行期多态( runtime polymorphism)解决问题。Templates 及泛型编程的世界,与面向对象有根本上的不同。在此世界中显式接口和运行期多态仍然存在,但重要性降低。反倒是隐式接口( implicit interfaces)和编译期多态( compile-time polymorphism)移到前头了。

1
2
3
4
5
6
7
8
9
template<typename T>
void doprocessing(T& w)
{
if (w.size() > 10 && w != somenastywidget) {
T temp (w);
temp.normalize ();
temp.swap(w);
}
}
  • w 必须支持哪一种接口,系由 template 中执行于 w 身上的操作来决定。本例看来 w 的类型好像必须支持 size, normalize 和 swap 成员函数、copy 构造函数(用以建立 temp)、不等比较( inequality comparison,用来比较 somenasty-Widget)。我们很快会看到这并非完全正确,但对目前而言足够真实。重要的是,这一组表达式(对此 template 而言必须有效编译)便是必须支持的一组隐式接口 (implicit interface)。
  • 凡涉及 w 的任何函数调用,例如 operator>和 operator!=,有可能造成 template 具现化( instantiated),使这些调用得以成功。这样的具现行为发生在编译期。“以不同的 template 参数具现化 function templates”会导致调用不同的函数,这便是所谓的编译期多态( compile-time polymorphism)。

请记住

  • classes 和 templates 都支持接口( interfaces)和多态( polymorphism)对 classes 而言接口是显式的( explicit),以函数签名为中心。多态则是通过 virtual 函数发生于运行期。
  • 对 template 参数而言,接口是隐式的( implicit),奠基于有效表达式。多态则是通过 template 具现化和函数重载解析( function overloading resolution)发生于编译期。

条款 42:了解 typename 的双重意义

  • 声明 template 参数时,前缀关键字 class 和 typename 可互换。
  • 请使用关键字 typename 标识嵌套从属类型名称;但不得在 base class lists(基类列)或 member initialization list(成员初值列)内以它作为 base class 修饰符。

条款 43:学习处理模板化基类内的名称

假设我们需要撰写一个程序,它能够传送信息到若干不同的公司去。信息要不译成密码,要不就是未经加工的文字。如果编译期间我们有足够信息来决定哪一个信息传至哪一家公司,就可以采用基于 template 的解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Class CompanyA
{
void sendcleartext(const std: string& msg);
void sendencrypted(const std: string& msg);
...
};
Class CompanyB
{
void sendcleartext(const std: string& msg);
void sendencrypted(const std: :string& msg);
...
};

... //针对其他公司设计的 classes

class Msginfo {...}; //这个 class 用来保存信息,以备将来产生信息

template<typename Company>
class MsgSender {
public:
... //构造函数、析构函数等等。
void sendclear(const Msginfo& info){
std::string msg;
//在这儿,根据 info 产生信息;
Company c;
c.sendcleartext(msg);
}
void sendSecret(const Msginfoinfo) //类似 sendclear,唯一不同是
{...} //这里调用 c.sendencrypted
};

这个做法行得通。但假设我们有时候想要在每次送出信息时志记(log)某些信息。 derived class 可轻易加上这样的生产力,那似乎是个合情合理的解法:

1
2
3
4
5
6
7
8
9
10
11
template<typename Company>
class Loggingmsgsender: public MsgSender<Company>
{
public:
... //构造函数、析构函数等等
void sendclearmsg(const Msginfo& info){
//将“传送前”的信息写至 log
sendclear(info); //调用 base class 函数;这段码无法通过编译。编译器会抱怨 sendclear 不存在
//将“传送后”的信息写至 log;
}
};

问题在于,当编译器遭遇 class template Loggingmsgsender 定义式时,并不知道它继承什么样的 class 当然它继承的是 MsgSender,但其中的 Company 是个 template 参数,不到后来(当 Loggingmsg Sender 被具现化)无法确切知道它是什么。而如果不知道 Company 是什么,就无法知道 class MsgSender看起来像什么一更明确地说是没办法知道它是否有个 sendclear 函数。

有三个办法,第一是在 base class 函数调用动作之前加上 "this->":

1
2
3
4
5
6
7
8
9
10
template<typename Company>
class Loggingmsgsender: public Msgsender<Company>
{
public:
void sendclearmsg(const Msginfo& info){
//将“传送前”的信息写至 log
this->sendclear(info); //成立假设 sendClean 被继承
//将“传送后”的信息写至 log;
}
};

第二是使用 using 声明式。如果你已读过条款 33,这个解法应该会令你感到熟悉。条款 33 描述 using 声明式如何将“被掩盖的 base class 2 名称”带入个 derived class 作用域内。我们可以这样写下 sendclearmsg:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename Company>
class Loggingmsgsender: public Msgsender<Company>
{
public:
using Msgsender<Company>::sendclear;//告诉编译器,请它假设
... // sendclear 位于 base class 内。
void sendclearmsg(const Msginfo& info){
//将“传送前”的信息写至 log
this->sendclear(info); //OK,假设 sendclear 将被继承下来。
//将“传送后”的信息写至 log;
}
};

第三个做法是,明白指出被调用的函数位于 base class 内:

1
2
3
4
5
6
7
8
9
10
template<typename Company>
class Loggingmsgsender: public Msgsender<Company>
{
public:
void sendclearmsg(const Msginfo& info){
//将“传送前”的信息写至 log
Msgsender<Company>::sendclear(info); //OK, sendclear
//将“传送后”的信息写至 log;
}
};

但这往往是最不让人满意的一个解法,因为如果被调用的是 virtual 函数,上述的明确资格修饰( explicit qualification)会关闭“ virtual 绑定行为”。

请记住

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

条款 44:将与参数无关的代码抽离 templates

  • Templates 生成多个 classes 和多个函数,所以任何 template 代码都不该与某个造成膨胀的 template 参数产生相依关系。
  • 因非类型模板参数(non 一 type template parameters)而造成的代码膨胀,往往可消除,做法是以函数参数或 class 成员变量替换 template 参数。
  • 因类型参数( type parameters)而造成的代码膨胀,往往可降低,做法是让带有完全相同二进制表述( binary representations)的具现类型( instantiation types)共享实现码。

条款 45:运用成员函数模板接受所有兼容类型

  • 请使用 member function templates(成员函数模板)生成“可接受所有兼容类型的函数。
  • 如果你声明 member templates 用于“泛化 copy 构造”或“泛化 assignment 操作”,你还是需要声明正常的 copy 构造函数和 copy assignment 操作符。

条款 46:需要类型转换时请为模板定义非成员函数

模板类中的模板函数不支持隐式类型转换,如果你在调用时传了一个其他类型的变量,编译器无法帮你做类型转换,从而报错。 解决方案是将该模板函数定义为模板类内的友元模板函数,从而支持了参数的隐式转换。

条款 47:请使用 traits classes 表现类型信息

对于模板函数,可能对于接收参数的不同类型,有不同的实现。此时,可以提供一个 traits class,其中包含了某一系列类型的类型信息(通常以枚举区分具体类型),然后,在该类中实现接收多种 traits 参数的重载工具函数,用来根据标识的不同类进行不同的具体函数操作。这使得该行为能在编译期就被区分。

条款 48:认识模板元编程(TMP)

所谓 template metaprogram(模板元程序)是以 C++写成、执行于 C++编译器内的程序。一旦 TMP 程序结束执行,其输出,也就是从 templates 具现出来的若干 C++源码,便会一如往常地被编译。

1
2
3
4
5
6
7
8
template<unsigned n>
struct Factorial {
enum { value = n * Factorial<n-1>::value };
};
template<>
struct Factorial<0> {
enum { value = 1 };
}

有了这个 template metaprogram(其实只是个单一的 template metafuncti acterial),只要你指涉 Factoria: value 就可以得到阶乘值。

循环发生在 template 具现体 Factorial内部指涉另一个 template 具现体 Factorial之时。和所有良好递归一样,我们需要一个特殊情况造成递归结束。这里的特殊情况是 template 特化体 Factorial<0>。

1
2
3
4
5
int main()
{
std: cout << Factorial<5>::value; //印出 120
std: cout << Factorial<10>::value; //印出 3628800
}

请记住

  • Template metaprogramming(TMP,模板元编程)可将工作由运行期移往编译期因而得以实现早期错误侦测和更高的执行效率
  • TMP 可被用来生成“基于政策选择组合”( based on combinations of policy choices)的客户定制代码,也可用来避免生成对某些特殊类型并不适合的代码

定制 new 和 delete

条款 49:了解 new-handler 的行为

  • set_new_handler 允许客户指定一个函数,在内存分配无法获得满足时被调用。
  • Nothrow new 是一个颇为局限的工具,因为它只适用于内存分配;后继的构造函数调用还是可能抛出异常。

条款 50:了解 new 和 delete 的合理替換时机

让我们暂时回到根本原理。首先,怎么会有人想要替换编译器提供的 operatornew 或 operator deletel 呢?下面是三个最常见的理由:

  • 用来检测运用上的错误。如果将“new 所得内存” delete 掉却不幸失败,会导致内存泄漏( memory leaks)。如果在“new 所得内存”身上多次 delete 则会导致不确定行为。如果 operator new 持有一串动态分配所得地址,而 operator delete 将地址从中移走,倒是很容易检测出上述错误用法。此外各式各样的编程错误可能导致数据" over runs"(写入点在分配区块尾端之后)或" under runs"(写入点在分配区块起点之前)。如果我们自行定义个 operator news,便可超额分配内存,以额外空间(位于客户所得区块之前或后)放置特定的 byte pattems(即签名, signatures)。 operator deletes 便得以检查上述签名是否原封不动,若否就表示在分配区的某个生命时间点发生了 over run 或 under run,这时候 operator delete 可以志记(log)那个事实以及那个惹是生非的指针。

  • 为了强化效能。编译器所带的 operator new 和 operator delete 主要用于一般目的。它们必须处理一系列需求,包括大块内存、小块内存、大小混合型内存。它们必须接纳各种分配形态,范围从程序存活期间的少量区块动态分配,到大数量短命对象的持续分配和归还。它们必须考虑破碎问题( fragmentation),这最终会导致程序无法满足大区块内存要求即使彼时有总量足够但分散为许多小区块的自由内存。

  • 为了收集使用上的统计数据。在一头栽进定制型 news 和定制型 deletes 之前,理当先收集你的软件如何使用其动态内存。分配区块的大小分布如何?寿命分布如何?它们倾向于以 FIFO(先进先出)次序或 LIFO(后进先出)次序或随机次序来分配和归还?它们的运用型态是否随时间改变,也就是说你的软件在不同的执行阶段有不同的分配归还形态吗?任何时刻所使用的最大动态分配量(高水位)是多少?自行定义 operator new 和 operator delete 使我们得以轻松收集到这些信息。

参考文献

《Effective C++》