条款 41 - 条款 52 | 《Effictive C++》笔记
Effictive C++ 条款 41 - 条款 52。
条款 41:了解隐式接口和编译期多态
- 类和模板都支持接口和多态
- 对类言接口是显式的,是通过函数签名实现的:函数签名描述了函数的参数和返回值;多态则是通过虚函数发生于运行期
- 对模板而言接口是隐式的,是通过有效表达式实现的:使用模板的表达式规定了该模板具有什么成员函数(比如表达式 T.ShowName() 则表明了模板 T 具有名为 ShowName 的成员函数);多态则是通过模板具体化和函数重载解析发生于编译期
条款 42:了解 typename 的双重意义
- 声明模板参数时,关键字 class 和 typename 意义完全相同
- 在模板函数或者模板类中指涉一个嵌套从属类型名称时,就必须在其之前使用 typename 进行声明
1 | template <typename C> // 使用 typename 和 class 都行 |
这是因为编译器不知道 iterator 是个类型还是个变量,比如表达式 C::iterator* x
,如果 C::iterator 是 C 命名空间下的变量 iterator 时,* 就是一个乘号而非指针符号了。
- 上述规则的例外是 typename 不可以出现在 base classes list 内的嵌套从属类型名称之前,也不可在成员初值列中作为基类的修饰符。例如:
1 | template <typename T> |
条款 43:学习处理模板化基类内的名称
模板化的类作为基类时,有哪些要注意的地方。以一个例子说明,假设现在编写一个发送信息到不同公司的程序,信息要么译成密码,要么就是原始文字,在编译期间来决定哪一家公司发送至哪一家公司,采用模板方法:
1 | class CompanyA { |
上述代码编译不能通过,因为编译器看不到 sendClear 函数。因为当编译器遇到类模板 LoggingMsgSender 定义式时,不知道它继承什么样的类,因为 MsgSender 中的 Company 是个参数,在 LoggingMsgSender 被具体化之前,无法确切知道它是什么,自然而然就不知道类 MsgSender 是什么,也就不知道它是否有个 sendClear 函数。
为了让问题更具体化,假设现在有个 class CompanyZ 坚持使用加密通讯:
1 | class CompanyZ { |
CompanyZ 没有 sendClear 函数,一般性的 MsgSender 模板对 CompanyZ 并不合适,这时我们可以针对 CompanyZ 产生一个 MsgSender 特化版:
1 | template<> |
开头的 template<> 表示是特化版的 MsgSender 模板,在模板实参是 CompanyZ 时被使用。这就是模板全特化。接着回到刚才那个不能编译的问题上,如果 Company = CompanyZ,那么 sendClear 函数就不存在。编译器拒绝调用这个函数是因为它知道模板基类可能被特化,而那个特化版本可能不提供和一般性模板相同的接口。所以编译器拒绝在模板化基类中寻找继承而来的名称。
对此有三种解决方法:
- 在基类函数调用动作之前加上
this->
:
1 | template <typename Company> |
- 使用 using 声明式:
1 | template <typename Company> |
- 明白指出被调用的函数位于基类内:
1 | template <typename Company> |
第三种做法使用了明确资格修饰符(explicit qualification),这将会关闭 virtual 绑定行为。
这三种做法原理相同:对编译器承诺模板基类的任何特化版本都将支持其一般(泛化)版本所提供的接口。这样的承诺是编译器在解析派生类模板时需要的。但如果这个承诺稍后没有兑现,即使用 CompanyZ 作为 LoggingMsgSender 的模板参数,编译器还是会编译失败的。
条款 44:将与参数无关的代码抽离 templates
在许多平台上 int 和 long 有相同的二进制表述,所以像 vector
条款 45:运用成员函数模板接受所有兼容类型
模板具体化后的类不会因为具体化类型而存在派生关系。来看一个关于指针的例子,真实指针支持隐式转换,派生类指针可以隐式转换为基类指针,指向 non-const 对象的指针可以转换为指向 const 对象的指针,等等。例如:
1 | class Top {}; |
如果使用模板定义智能指针,上面的转换就有点麻烦了。SmartPtr
所以 SmartPtr 需要的不是一个构造函数,而是一个构造模板。这样的模板就是所谓的 member function template,其作用是为类生成函数:
1 | template <typename T> |
以上代码意思是,对任何类型 T 和任何类型 U,可以根据 SmartPrt 生成一个 SmartPtr
上述代码并不完整,我们希望根据一个 SmartPtr
1 | template <typaname T> |
在上述代码中,存在一个将将 U* 转换为 T* 的隐式转换,这约束了转换行为。
在类内声明泛化拷贝构造函数并不阻止编译器生成它们自己的拷贝构造函数(non-template)。如果想要控制拷贝构造函数的方方面面,就要声明正常的拷贝构造函数。相同的规则也适用于赋值操作。
条款 46:需要类型转换时请为模板定义非成员函数
条款 24 提到过为什么 non-member 函数才有能力“在所有实参身上实施隐式类型转换”,本条款是条款 24 的扩展,将 Rational 变成模板类:
1 | template <typename T> |
非模板的例子可以通过编译,但是模板化的例子就不行。在条款 24 的例子中,编译器知道我们尝试调用什么函数(就是接受两个 Rational 参数那个operator*),但是这里编译器不知道。
本例中类型参数分别是 Rational
模板函数在调用的时候(具体化后)会出现隐式转换(运行期),但是在推导模板参数的时候不会发生隐式转换(编译期)。
模板类内的友元函数可以解决这个问题:
1 | template<typename T> |
这时候对 operator* 的混合调用可以通过编译了。oneHalf 被声明时,Rational
虽然通过编译,但是这段代码却无法链接,因为我们只在类内声明了这个函数,却在类外进行定义,链接器找不到的对应的实现。一个最简单的办法就是将该函数的定义合并到其声明内。
这个技术虽然使用了友元,却与传统的友元用途“访问类的 non-public 成员”不同。为了让类型转换可能发生与所有实参身上,我们需要一个 non-member 函数(条款 24);为了让这个函数被自动具体化,我们需要将其声明在类内;而在类内声明 non-member 函数的唯一办法就是让它成为一个友元。
条款 47:请使用 traits classes 表现类型信息
Traits 并不是关键字或者一个预先定义好的构件,这是一种技巧,也是 C++ 程序员共同遵守的协议。这个技术的要求之一是:对内置的类型和用户自定义的类型的表现必须一样好。这种技巧可以允许我们在编译期间取得某些类型信息。习惯上 traits 总是被实现为 struct,所以它们往往被称为 traits classes。
假如我们要自己实现 STL 容器中的 advance 函数,对于不同的容器类型,需要不同的实现,比如 list 的迭代器只能步进,而 vector 则可以任意访问,所以需要有一个类来表明迭代器的分类。迭代器 traits classes 在这里起到的作用就是萃取出迭代器的内容类型。
这就要求在容器的定义中必须有关于迭代器类型的声明。例如 vector 的迭代器可以随机访问,其 class 看起来是这样的:
1 | template < ... > |
list 的迭代器可以双向步进,所以它应该是这样的:
1 | template < ... > |
所以迭代器 traits classes iterator_traits 的实现就可以是这样的:
1 | template <typename IterT> |
但是这对指针(也是一种迭代器)行不通,因为指针内部不可能有 typedef 的定义,所以需要有一个 iterator_traits 的偏特化模板专门针对指针:
1 | template <typename IterT> |
现在的 iterator_traits 可以用于 advance 的实现了:
1 | template <typename IterT, typename DistT> |
但是这里有一个问题,IterT 类型在编译期获知,所以 std::iterator_traits
1 | // 原函数 |
条款 48:认识 template 元编程
模板元编程有三个好处:
- 它让某些事情更容易,如果没有它,有些事情将是困难的,甚至不可能的。
- 由于模板元编程执行于编译期,因此可将工作从运行期转移到编译期。这导致的一个结果是:某些错误原本通常在运行期才能侦测到,现在可在编译期找出来。
- 另一个结果是,使用模板元编程的程序可能在每一方面都更高效:较小的可执行文件、较短的运行期、较少的内存需求。然而将工作从运行期移转至编译期的另一个结果是编译时间变长了。
条款 49:了解 new-handler 的行为
什么是 new-handler
当 operator new 执行失败时(如没有足够的内存),它会抛出异常(在某些旧编译器上会返回空指针)。而在抛出异常前,它会先调用一个客户指定的错误处理函数,一个所谓的 new-handler,这个函数可以由 set_new_handler 函数指定。
1 | namespace std { |
new_handler 是个函数指针,该函数没有参数也不返回任何东西。set_new_handler 是获得一个 new_handler 并返回一个 new_handler 的函数,返回的 new_handler 是指向 set_new_handler 被调用前正在执行的那个 new-handler 函数。后面的 throw() 是一份异常明细,表示该函数不抛出异常。
当 operator new 无法满足内存申请时,它会不断调用 new-handler,直到找到足够内存。设计良好的 new-handler 必须做好以下事情:
- 让更多内存可被使用。这样可以造成 operator new 内的下一次内存分配动作可能成功。一个做法是,程序一开始就分配一大块内存,当 new-handler 第一次被调用时将它们给程序使用
- 安装另一个 new-handler:当前的 new-handler 无法取得更多内存时,安装另一个 new-handler 以替换自己
- 卸载 new-handler:即将 null 指针传给 set_new_handler,一旦没有安装任何 new-handler,operator new 在内存分配不成功时便抛出异常
- 抛出 bad_alloc(或派生自 bad_alloc)的异常:这样的异常不会被 operator new 捕捉,因此会在调用 operator new 之处被捕捉
- 不返回:通常 abort 或 exit
如何为类定制专属 new-handler
C++ 并不支持类专属的 new-handler,但是我们自己可以实现这种行为。令每一个类提供自己的 set_new_handler 和 operator new 即可。现在打算处理 Widget 类内存分配失败的情况,首先要有一个当 operator new 无法为 Widget 分配足够内存时的调用函数,即 new_handler 函数。
Widget 的客户应该这样设置其 new-handler:
1 | void outOfMem(); |
Widget 的 set_new_handler 函数是的:
1 | class Widget { |
Widget 的 operator new 会做以下事情:
- 调用标准 set_new_handler,告知 Widget 错误处理函数。这会将 Widget 的 new-handler 安装为 Widget 的 new-handler
- 调用 global operator new,如果失败,global operator new 会调用 Widget 的 new-handler,因为在上一步中 Widget 的 new-handler 被安装为 global new-handler。如果 global operator new 最终无法分配足够内存,会抛出一个 bad_alloc 异常。这时 Widget 的 operator new 要恢复原本的 global new-handler,之后再传播异常
- 如果 global operator new 调用成功,Widget 的 operator new 会返回一个指针,指向分配的内存。Widget 析构函数会管理 global new-handler,它会将 Widget 的 operator new 被调用前的那个 global new-handler 恢复回来
代码如下:
1 | class NewHandlerHolder { |
- 类的专属 set_new_handler 用以保存旧的 new-hanlder 以供恢复和新的 new-handler 以供在 new 的时候进行设置
- 类在 new 时调用标准 set_new_handler 将 new-hanlder 设置为类专属 new-handler,使用标准 operator new 分配内存,然后将 new-hanlder 恢复成旧的
如何使用 CRTP 为类定制专属 new-handler
实现这个方案的 class 代码基本相同,用个基类加以复用是个好的方法。可以用个 template 基类,如此以来每个派生类将获得实体互异的 class data 复件。这个基类让其派生类继承它的 set_new_handler 和 operator new,template 部分确保每一个派生类获得一个实体互异的 currentHandler 成员变量。
1 | template <typename T> |
在 template 基类中,从未使用类型 T。因为 currentHandler 是 static 类型,使用模板是为了保证每个 class 都有自己的 currentHandler。
最后
Nothrow new 对异常的强制保证性并不高。new(std::nothrow) Widget 发生两件事,第一分配内存给 Widget 对象,如果失败返回 null 指针。第二,如果成功,调用Widget 的构造函数,但是在这个构造函数做什么,nothrow new 并不知情。如果在构造函数使用 operator new 分配内存,那么还是有可能抛出异常并传播。使用 nothrow new 只能保证 operator new 不抛出异常,不能保证像 new(std::nothrow) Widget 这样的表达式不抛出异常。所以,并没有运用 nothrow 的需要。
条款 50:了解 new 和 delete 的合理替换时机
替换 new 和 delete 的原因:
- 检测运用上的错误:在分配内存的前后两端放置签名以检测是否会错误写入内存
- 强化效能:标准的 operator new 是为了所有场景设计的,在某些特定场景上的性能可能达不到最优,针对自己的使用场景进行定制可以获得最优的性能
- 收集使用上的统计数据
- 为了增加分配和归还的速度(内存池):使用定制的针对特定类型对象的分配器,可以提高效率,例如 Boost 库的 Pool
- 为了降低缺省内存管理器带来的空间额外开销:泛用型分配器往往(虽然并非总是)不只比定制型慢,还使用更多空间,因为它们常常在每一个分配区块上招引某些额外开销。针对小型对象开放的分配器,例如 Boost 库的 Pool,本质上消除了这样的额外开销
- 为了弥补缺省分配器的非最佳对齐(suboptimal alignment):x86 体系结构上的 double 在 8-byte 对齐的情况下访问速度最快,但是编译器自带的 operator new 并不保证分配 double 是 8-byte 对齐
- 为了将相关对象成簇集中:如果特定的某个数据结构往往被一起使用,我们希望在处理这些数据时将缺页中断的频率降至最低,那么为此数据结构创建另一个 heap 就有意义,这样就可以将它们成簇集中到尽可能少的内存页上
- 为了获得非传统的行为:有时候我们需要做 operator new 和 operator delete 没做的事,例如,归还内存时将其数据覆盖为 0,以此增加应用程序的数据安全
条款 51:编写 new 和 delete 时需固守常规
operator new
下面是个 non-member operator new 的伪代码:
1 | void* operator new(std::size_t size) throw(std::bad_alloc) { |
上面包含一个死循环,退出此循环唯一办法就是内存被成功分配或 new-handler 函数做了一件描述于条款 49 的事情:
- 让更多内存可用
- 安装另一个 new-handler
- 卸除 new-handler
- 抛出 bad_alloc 异常(或其派生物)
- 或是承认失败而直接 return
上面的 operator new 成员函数可能会被 derived classes 继承,定制内存分配器往往是为了特定的类,以此来优化,并不是针对该类的派生类,所以应该像这样:
1 | void* Base::operator new(std::size_t size) throw(std::bad_alloc) { |
operator delete
operator delete 情况就简单很多,但是要记住,C++ 保证删除空指针永远安全。这个函数的 member 版本也很简单,只需多加一个检查删除数量:
1 | void Base::operator delete(void rawMemory, std::size_t size) throw() { |
总结
编写 operator new 和 operator delete 的
- operator new
- 死循环
- 针对派生类的行为
- operator delete
- 删除空指针安全
- 针对派生类的行为
条款 52:写了 placement new 也要写 placement delete
Operator new 会做两件事情:分配内存和构造对象。如果内存已经提前分配好了,直接在分配好的内存地址上构造对象是比较合理的需求。
如果 operator new 接受的参数除了一定会有的那个 size_t 之外还有其他,这便是所谓的 placement new。众多 placement new 版本中特别有用的一个是“接受一个时针指向对象该被构造之处”,其形式如下:
1 | void* operator new(std::size_t, void* pMemory) throw(); |
这个版本的 operator new 已被纳入 C++ 标准程序库,#include
在使用 operator new 创建对象时,有两个函数被调用,第一个函数就是 operator new,用以分配内存,第二个是类的构造函数。如果第一个函数调用成功,但是第二个函数调用失败,这时需要释放第一步分配的内存,否则就造成了内存泄露。这个时候,如果使用的是 placement new 且没有对应的 placement delete,那么之前分配的内存就无法释放,造成内存泄漏。
在默认情况下,C++ 在 global 作用域内提供以下形式的 operator new:
1 | void* operator(std::size_t) throw(std::bad_alloc); |
在类内声明任何形式的 operator new 都会隐藏上面这些标准形式,为避免这种隐藏,一个简单的做法是建立一个基类,内含所有正常形式的 operator new 和 delete,形式如下:
1 | class StadardNewDeleteForms{ |
如果想以自定义方式扩充标准形式,可以使用继承机制和 using 声明:
1 | class Widget : public StandardNewDeleteForms { |
条款 41 - 条款 52 | 《Effictive C++》笔记