目录&索引
- 条款01:视 c++ 为一个语言联邦。
- c++高效编程守则视状况而变化,取决于你使用c++的哪个部分。
- 条款02:尽量以const,enum,inline 替换 #define。
- 对于单纯常量,最好以cosnt对象或enum替换#defines;
- 对于形似函数的宏(macros),最好改用inline函数替换#define。
- 条款03:尽可能使用const。
- 将某些东西声明为const可帮助编译器侦测出错误用法。const可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
- 编译器强制实施bitwise constness,但你编写程序时应该使用“概念上的常量性”(conceptual constness)。
- 当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本可避免代码重复。
- 条款04:确定对象被使用前已先被初始化。
- 为内置型对象进行手工初始化,因为c++不保证初始化它们。
- 构造函数最好使用成员初值列(初始化列表),而不要在构造函数本体内使用赋值操作。初值列列出的成员变量,其排列次序应该和它们在class中的声明次序相同。
- 为免除“跨编译单元之初始化次序”问题,请以local static对象替换non-local static对象。
- 条款05:了解c++默默编写并调用哪些函数。
- 编译器可以暗自为class创建默认构造函数、拷贝构造函数、赋值操作符以及析构函数。
- 条款06:若不想使用编译器自动生成的函数,就该明确拒绝。
- 为驳回编译器自动提供的机能,可将相应的成员函数声明为private并且不予实现。使用像Uncopyable这样的base class也是一种做法。
- 附:在c++11以后,可以用delete修饰函数,这是一种更好的方法。
- 条款07:为多态基类声明virtual析构函数。
- polymorphic(带多态性质的)base classes应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数
- classes的设计目的如果不是作为base classes使用,或不是为了具备多态性,就不应该声明virtual析构函数。
- 纯虚的析构函数必须给出定义。
- 条款08:别让异常逃离析构函数。
- 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。
- 条款09:绝不在构造和析构过程中调用virtual函数。
- 在构造和析构期间不要调用virtual函数,因为这类调用从不下降至派生类(比起当前执行构造函数和析构函数的那层)。
- 条款10:*令 operator= 返回一个reference to this。
- 令 operator= 返回一个reference to *this。
- 条款11:在 operator= 中处理“自我赋值”。
- 确保当对象自我赋值时 operator= 有良好行为。其中技术包括比较“来源对象”和“”目标对象“的地址、精心周到的语句顺序、以及copy-and-swap。
- 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。
- 条款12:复制对象时勿忘其每一个部分。
- copying函数应确保复制”对象内的所有成员变量“及”所有base class 成分“;
- 不要尝试以某个copying函数实现另一个copying函数。应将共同技能放进第三个函数中,并由两个copying函数共同调用。
- 条款13:以对象管理资源。
- 为防止资源泄露,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源。
- 两个常被使用的RAII classes 分别是tr1::shared_ptr和auto_ptr。前者通常是较佳选择 ,因为其copy行为比较直观。若选择auto_ptr,复制动作会使被复制物指向null。
- 条款14:在资源管理类中小心copying行为。
- 复制RAII对象必须一并复制它所管理的资源,所以资源的copying行为决定RAII对象的copying行为。
- 普遍而常见的RAII class copying 行为是:抑制copying、施行引用计数法。不过其他行为也都可能被实现。
- 条款15:在资源管理类中提供对原始资源的访问。
- APIs往往要求访问原始资源,所以每一个RAII class 应该提供一个“取得其所管理之资源”的办法。
- 对原始资源的访问可能经由显式转换(比如一个get成员函数)或隐式转换(重载operator A())。一般而言显式转换比较安全,但隐式转换对客户比较方便。
- 条款16:成对使用new和delete时要采取相同形式。
- 如果你在new表达式中使用[],必须在相应的delete表达式中也使用[]。如果你在new表达式中不使用[],一定不要在相应的delete表达式中使用[]。
- 条款17:以独立语句将newed对象置入智能指针。
- 以独立语句将newed对象存储于(置入)智能指针内。如果不这样做,一旦异常被抛出(在资源对象产生到置入资源管理对象之间,产生异常的话),有可能导致难以察觉的资源泄漏。
- 条款18:让接口容易被正确使用,不易被误用。
- 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
- “促使正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
- “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
- tr1::shared_ptr支持定制型删除器。这可防范DLL问题,可被用来自动解除互斥锁(见条款14)等等。
- 条款19:设计class犹如设计type。
- class的设计就是type的设计。在定义一个新type之前,请确定你已经考虑过本条款覆盖的所有讨论主题。
- 条款20:宁以pass-by-reference-to-const替换pass-by-value。
- 尽量以 pass-by-reference-to-const 替换pass-by-value。前者通常比较高效,并可避免切割问题。
- 以上规则并不适用于内置类型、STL的迭代器和函数对象。对它们而言,pass-by-value 往往比较适当。
- 条款21:必须返回对象时,别妄想返回其reference。
- 绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象。或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。条款4已经为“在单线程环境中合理返回reference 指向一个local static对象”提供了一份设计实例。
- 条款22:将成员变量声明为private。
- 切记将成员变量声明为private。这可赋予用户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
- protected并不比public更具封装性。
- 条款23:宁以non-member&non-friend替换member函数。
- 宁可拿non-member non-friend函数替换member函数。这样做可以增加封装性、包裹弹性和机能扩充性。
- 附:大量的便利函数可以使用相同的命名空间而放在不同的头文件里,这样用户可以根据自己对功能的需求使用头文件,减少编译相依性。其中该有个核心的头文件,提供核心便利函数以及类的实现。
- 条款24:若所有参数皆需类型转换,请为此采用non-member函数。
- 如果你需要为某个函数的所有参数(包括this指针所指的哪个隐喻参数)进行类型转换,那么这个函数必须是个non-member。
- 附:因为隐式转换需要匹配参数列,对于成员函数,第一个参数是*this,使得可能不能匹配转换。
- 条款25:考虑写出一个不抛异常的swap函数。
- 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
- 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于classes(而非templates),也请全特化std::swap。
- 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“命名空间资格修饰”。
- 为“用户定义类型”进行std templates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。
- 条款26:尽可能延后变量定义式的出现时间。
- 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
- 条款27:尽量少做转型动作。
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需要将转型放进他们自己的代码内。
- 宁可使用c++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。
- 条款28:避免返回handles指向对象内部成分。
- 避免返回handles(包括reference、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”的可能性降至最低。
- 条款29:为“异常安全”而努力是值得的。
- 异常安全函数即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
- “强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备实现意义。
- 函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。
- 条款30:透彻了解inlining的里里外外。
- 将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
- 不要只因为function templates出现在头文件,就将它们声明为inline。
- 条款31:将文件间的编译依存关系降至最低。
- 支持“编译依存性最小化”的一般构想是:相依于声明式,而不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
- 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及templates都适用。
- 条款32:确定你的public继承塑膜出is-a关系。
- “public 继承”意味着 is-a。适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个base class对象。
- 条款33:避免遮掩继承而来的名称。
- derived classes内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。
- 为了让被遮掩的名称再见天日,可使用using声明式或转交函数。
- 条款34:区分接口继承和实现继承。
- 接口继承和实现继承不同。在public继承之下,derived classes总是继承base class的接口。
- pure virtual函数只具体指定接口继承。(附:有强烈的特异性,必须重新实现。并且纯虚函数的定义实现可以用来充当缺省版本)
- 简朴的(非纯)impure virtual函数具体指定接口继承及缺省实现继承。(附:根据自己的特殊情况看需不需要覆写,如果没必要可以用基类的版本,但这个缺省的功能可能会产生一些危险。)
- non-virtual函数具体指定接口继承以及强制性实现继承。(附:强烈的共性,派生类只管继承不覆写)
- 条款35:考虑virtual函数以外的其他选择。
- virtual函数的替代方案包括NVI手法及Strategy设计模式的多种形式。NVI手法自身是一个特殊形式的Template Method设计模式。
- 将机能从成员函数移到class外部函数,带来的一个缺点是,非成员函数无法访问class的non-public成员。
- tr1::function对象的行为就像一般函数指针。这样的对象可接纳“与给定之目标签名式兼容”的所有可调用物。
- 条款36:绝不重新定义继承而来的non-virtual函数。
- 绝不重新定义继承而来的non-virtual函数。
- 条款37:绝不重新定义继承而来的缺省参数值。
- 绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而virtual函数——你唯一应该覆写的东西——却是动态绑定。
- 附:注意仅仅是参数值,而非整个函数。函数是可以重新定义的,并且当使用了一个基类指针时,可以视为带了默认参数。
- 条款38:通过复合塑模出has-a或“根据某物实现出”。
- 复合(composition)的意义和public继承完全不同。
- 在应用域(application domain),复合意味has-a;在实现域(implementation domain),复合意味is-implemented-in-terms-of(根据某物实现出)。
- 条款39:明智而审慎地使用private继承。
- private继承意味着is-implemented-in-terms-of(根据某物实现出)。它通常比复合(composition)的级别低。但是当derived class需要访问protected base class的成员,或需要重新定义继承而来的virtual函数时,使用private是合理的。
- 和复合(composition)不同,private继承可以造成empty base最优化。这对致力于“对象占用空间最小化”的程序库开发者而言,可能很重要。
- 条款40:明智而审慎地使用多继承。
- 多重继承比单一继承复杂。它可能导致新的歧义性、以及对virtual继承的需要。
- virtual继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果virtual base classes不带任何数据,将是最具实用价值的情况。
- 多重继承的确有正当用途。其中一个情节涉及“public继承某个Interface class”和“private继承某个协助实现的class”的两相组合。
- 条款41:了解隐式接口和编译期多态。
- classes和templates都支持接口和多态。
- 对classes而言接口是显式的,以函数签名为中心。多态则是通过virtual函数发生于运行期。
- 对template参数而言,接口是隐式的,奠基于有效表达式。多态则是通过template具现化和函数重载解析发生于编译期。
- 条款42:了解typename的双重意义。
- 声明template参数时,前缀关键字class和typename可互换。
- 请使用关键字typename标识嵌套从属类型名称;但不得在base class lists(基类列)或member initialization list(成员初值列)内以它作为base class修饰符。
- 条款43:学习处理模板化基类内的名称。
- 可在 derived class template 内通过 “this->” 指涉 base class template 内的成员名称,或藉由一个明白写出的 “base class 资格修饰符” 完成。
- 附:以及using声明
- 条款44:将与参数无关的代码抽离templates。
- Templates 生成多个 classes 和多个 functions,所以任何 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:需要类型转换时请为模板定义非成员函数。
- 当我们编写一个 class template,而它所提供之“与此 template 相关的”函数支持“所有参数隐式类型转换”时,请将那些函数定义为 “class template 内部的 friend 函数”。
- 条款47:请使用traits classes 表现类型信息。
- Traits classes使得“类型相关信息”在编译期可用。它们以templates和“templates特化”完成实现。
- 整合重载技术后,traits classes有可能在编译期对类型执行if…else测试。
- 条款48:认识template元编程。
- Template metaprogramming(TMP,模板元编程)可将工作由运行期移到编译期,因而得以实现早期错误侦测和更高的执行效率。
- TMP可被用来生成“基于政策选择组合”(based on combinations of policy choices)的客户定制代码,也可用来避免生成对某些特殊类型并不适合的代码。
- 条款49:了解new-handler的行为。
- set_new_handle 允许用户指定一个函数,在内存分配无法获得满足时被调用
- nothrow new 是一个颇为局限的工具,因为它只适用于内存分配;后继的构造函数调用还是有可能抛出异常
- 条款50:了解new和delete的合理替换时机。
- 有许多理由需要写个自定义的 new 和 delete,包括改善效能、对 heap 运用错误进行调试、收集 heap 使用信息。
- 条款51:编写new和delete时需固守常规。
- operator new 应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用 new-handler。它也应该有能力处理 0 bytes 申请。class 专属版本的还应该处理“比正确大小更大的(错误)申请”
- operator delete 应该在收到 null 指针时不做任何事。class专属版本则还应该处理“比正确大小更大的(错误)申请”
- 条款52:写了placement new也要写placement delete。
- 当你写一个 placement operator new,请确定也写出了对应的 placement operator delete。如果没有这样做,就可能造成隐蔽的内存泄漏。
- 当你声明 placement new 和 placement delete ,请确定不要无意识(非故意)地遮掩了它们的正常版本。
- 条款53:不要轻忽编译器的警告。
- 严肃对待编译器发出的警告信息。努力在你的编译器最高警告级别下争取”无任何警告“。
- 不要过度依赖编译器的报警能力,因为不同编译器对待事情的态度并不相同。一段有警告的代码,移植到另一个编译器上,可能没有任何警告。
- 条款54:让自己熟悉包括TR1在内的标准程序库。
- C++标准程序库的主要机能由STL、iostreams、locales组成。并包含C99标准程序库。
- TR1添加了智能指针、一般化函数指针、hash-based容器、正则表达式以及另外10个组件的支持。
- TR1自身只是一份规范,为获得TR1提供的好处,你需要一份实物。一个好的实物来源是Boost。
- 条款55:让自己熟悉Boost。
- Boost 是一个社群,也是一个网站。致力于免费,源码开放,同僚复审的 C++ 程序库开发。 Boost 在 C++ 标准化过程中扮演深具影响力的角色。
- Boost 提供许多 TR1 组件实现品,以及其他许多程序库。
条款01
视 c++ 为一个语言联邦。
c++ 已经是个多重范型编程语言,是个同时支持过程形式、面向对象形式、函数形式、泛型形式、元编程形式的语言。要如何理解这样一个语言呢?
最简单的方法是将c++视为一个由相关语言组成的联邦而非单一语言。在其某个次语言种,各种守则与通例都倾向简单、直观易懂、并且容易记住。然而当你从一个次语言移往另一个次语言,守则可能改变。为了理解c++,你必须认识其主要的次语言,总共只有四个:
- C
- Object-Oriented C++
- Template C++
- STL
比如,
- 对于内置类型(C-like)而言,pass-by-value通常比pass-by-reference更高效。
- 在Object-Oriented C++,由于用户自定义构造函数和析构函数的存在,pass-by-reference-to-const往往更好,Template C++也是如此。
- 然而一旦跨入STL,迭代器和函数对象都是在C指针之上塑造出来的,pass-by-value守则再次适用。
记住:
c++高效编程守则视状况而变化,取决于你使用c++的哪个部分。
条款02
尽量以const,enum,inline 替换 #define。
这个条款事实上为“宁可以编译器替换预处理器”,因为或许 #define 不被视为语言的一部分。
当你作出:#define myconst 1.6
时,记号名称myconst也许从未被编译器看见,也许在编译器开始处理源码之前它就被处理器移走了。于是记号名称myconst有可能没进入记号表内。于是当你运用此常量但获得一个编译错误信息时,可能会带来困惑,因为这个错误信息也许会提到1.6而非myconst(在程序里使用myconst,报错为1.6,因为预处理器会将myconst替换为1.6)。如果mycosnt被定义在一个非你所写的头文件内,你肯定对1.6以及它来自于何处毫无概念,于是你将因为追踪它而浪费时间。这个问题也可能出现在记号式调试器,原因仍是:你所使用的名称可能并未进入记号表。
解决之道是使用const:const double myconst = 1.6;
作为一个语言常量,myconst肯定会被编译器看到,当然就会进入记号表内。
以常量替换#define时,有两种特殊情况:
定义常量指针:由于常量定义式通常放在头文件内,因此有必要将指针(而不只是指针所指之物)声明为const。例如若要在头文件内定义一个常量的char*-base字符串,必须写const两次:
const char* const authorName = "Scott Meyers";
- 第一个const说明指向常量字符串,第二个const说明指针本身也是常量(不可改)
- string对象通常更合宜,定义成这样往往更好:
const std::string authorName("Scott Meyers");
class专属常量:#define 并不重视作用域,不能提供任何封装性。为了将常量的作用域限制于class内,必须让它称为class的一个成员。而为确保此常量至多只有一份实体(不允许不同实例使用不同的值初始化该const变量),必须让它称为一个static成员:
class GP{private: static const int num = 5;};
然而这是num的声明式而非定义式。通常c++要求对使用的任何东西提供一个定义式,但如果它是个class专属常量又是static且为整数类型,则需特殊处理。只要不取它们的地址,可以声明并使用而无须提供定义式。但如果取某个class的专属常量的地址,或编译器坚持要看到定义式,则必须提供定义:const int GP::num;
不必提供数值,因为声明式已经提供初值。- 因此个人感觉更建议的方式是,在类中声明(在头文件中)但不给予初值,在类外定义时(在实现文件种)再给予初值(这样不会忘记定义式),就不会出错。实际上,旧式编译器不支持在声明时给static成员赋初值。
针对第二点,如果在class编译期间需要一个class常量值,比如类中还定义了一个数组
int scores[num];
,这时如果不允许num在声明时赋值,就可以采用”the enum hack”补偿做法,理论基础是:一个属于枚举类型的数值可权充int被使用。1
2
3
4
5
6class GP
{
private:
enum{num = 5};//令num成为5的一个记号名称
int scores[num];
}- enum hack的行为某方面说比较像#define 而不像const,有时候这正是想要的。例如取const的地址是合法的,而取enum的地址和#define的地址是不合法的。如果不想让别人获得一个指针或引用指向你的某个整数常量,enum可以帮助你实现这个约束。
- 此外优秀的编译器不会为”整数型const对象“设定另外的存储空间(除非创建指针或引用指向该对象),但不够优秀的编译器可能会创建对象。enum和#define就绝不会导致非必要的内存分配。
另一个常见的#define误用情况是以它实现宏(macros)。宏看起来像函数,但不会招致函数调用带来的额外开销,下面这个宏夹带着宏实参,调用函数f:
1 |
无论何时当你写出这种宏,必须记住为宏中的所有实参加上小括号,否则某些人在表达式中调用这个宏时可能会遭遇麻烦。
1 | 加括号是为了处理表达式参数(即宏的参数可能是个算法表达式)时不出错,因为宏替换就是文本替换,所以如果有以下情况: |
纵使加上小括号,也会出现不可思议的事情:
1 | int a = 5, b = 0; |
在这里,调用f之前,a的递增次数竟然取决于它和谁比较。因为宏本质是替换,++a把(a)替换了,就导致比较时累加一次,如果++a更大,则传入f的参数是++a,又累加一次。
幸运的是,只要写出template inline函数,就可以获得宏带来的效率以及一般函数的所有可预料行为和类型安全性:
1 | template<typename T> |
有了const、enum、inline,我们对预处理器(特别是#define)的需求降低了,但并非完全消除。#include仍然是必需品,而#ifdef/#ifndef也继续扮演控制编译的重要角色。
记住:
- 对于单纯常量,最好以cosnt对象或enum替换#defines;
- 对于形似函数的宏(macros),最好改用inline函数替换#define。
条款03
尽可能使用const。
const允许你指定一个语义约束,而编译器会强制实施这项约束。它允许你高速编译器和其他程序员某值应该保持不变。只要某值保持不变是事实,你就该说出来,因为这可以获得编译器的帮助。
如果const出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量;如果出现在两边,表示二者都是常量。
1 | char greeting[] = "hello"; |
如果被指物(数据)是常量,const可以在类型之前也可以在类型之后:
1 | void f1(const Widget *pw); |
在STL内,有const_iterator和iterator两种迭代器:
- const iterator iter;则iter作用像一个T* const,表示iter本身不可改,所指之物可改
- const_iterator citer;则citer作用像一个const T*,表示所指之物不可改,本身可改
const最具威力的用法是面对函数声明时的应用,可以和函数返回值、参数、函数自身(作为成员函数)产生关联。
返回值:如const int f1,使得函数返回后的对象为右值,不可赋值,避免拿返回值再做赋值(f1() = 5这样的事情)。const可以预防这些没有意义的赋值动作。
参数:即表示传入参数不可改动,除非有需要改动,否则将它们声明为const。这样可以避免“想要输入‘==’却输入成‘=’”的错误。
自身(const成员函数):是为了确认该成员函数可以作用于const对象身上。
- 1.它们使得class接口比较容易理解,能够得知哪个函数可以改动对象内容而哪个不行。
- 2.它们使“操作const对象(常对象)”成为可能,常对象只能调用const成员。
- 3.两个成员函数如果只是常量性不同(函数后有无const),也可以被重载。常对象调用const成员函数,而非常对象调用non-const成员函数。
- 附:真实程序中常对象大多用于以passed by pointer-to-const或passed by reference-to-const形式函数传参,如
void print(const A& x);
一般都用于读取值。
对于const成员函数,有两个流行的概念:bitwise constness(又称physical constness)和logcial constness。
- bitwise constness认为成员函数不更改对象内的任何一个bit,它正是c++对常量性的定义,因此const成员函数不可以改变对象内任何non-static成员变量。
- 然而,如果一个类成员为non-const的指针(而非其所指之物),那么一个const成员函数可以仅仅返回该指针而不作任何改变(使得编译器通过)。这也就是说,可以通过常对象调用该const成员函数(返回non-const成员),然后在外部获取这个指针,再修改指针所指之物是合法的。
- 这其中没有任何错误:创建一个常对象并设某值,而且只对它调用const成员函数。但终究改变了它的值。
- 这种情况导出logical constness:一个const成员函数可以修改它所处理的对象内的某些bits,但只有在客户端侦测不出的情况下才得如此。
可以用mutable关键字释放掉non-static成员变量的bitwise constness约束。
当const成员函数和non-const成员函数有实质等价的实现时,都去实现这两个函数就会使得代码重复、膨胀、编译时间、维护等问题。此时真正应该作的是实现const成员函数并让non-const成员函数调用它。比如:
1 | class text |
这份代码有两个转型动作:
- 要让func调用func const,而不是调用自己(会造成无穷尽的递归)。因此必须明确指出是func const,所以这里将*this从原始类型text&转型为const text&,再调用func函数,则此时(是常对象)调用的是func const。
- 将non-const对象转为const对象强迫进行了一次安全转型(是安全的),所以需要使用static_cast。
- 使用const_cast将const func从返回值移除const。
const版本调用non-const版本并不是该作的事,因为const成员函数承诺绝不改变对象的逻辑状态,而non-const成员函数却没有这般承诺。这就是为什么这里能用static_cast作用于*this:这里并不存在const相关危险。
记住:
- 将某些东西声明为const可帮助编译器侦测出错误用法。const可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
- 编译器强制实施bitwise constness,但你编写程序时应该使用“概念上的常量性”(conceptual constness)。
- 当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本可避免代码重复。
条款04
确定对象被使用前已先被初始化。
读取未初始化我值,有时可能让程序终止运行,更可能的情况是读入一些“半随机”bits。最佳的处理办法就是:永远在使用对象之前先将它初始化。对于无任何成员的内置类型,必须手工完成。而内置类型以外的任何其他东西,都交给构造函数来初始化,其规则是:确保每一个构造函数都将对象的每一个成员初始化。
这个规则很简单,但重要的是别混淆了赋值和初始化。如果在类中先声明了成员,然后在构造函数的函数体赋值,则本质上这些成员变量执行的是声明-赋值,先调用默认的构造函数,然后执行赋值构造函数(两步,效率低)。更好的写法是用初始化列表替换赋值动作,对大多数类型而言,这样效率更高(一步,直接调用构造函数),有时甚至高效得多。对于内置类型对象,初始化和赋值成本相同,但为了一致性最好也通过初始化列表来初始化。
1 | A::A(name):Name(name),Addr(){} |
规定总是在初始化列表中列出所有成员变量。那些无需初值的变量使用一个()即可,以免还得记住哪些成员变量不需要初值。
另外,如果成员变量是const或reference的,它们就必须使用初始化列表。因而很多时候最简单的做法就是总使用初始化列表。
在初始化列表中,成员的初始化次序与初始化列表中的次序无关,真的的次序是成员变量在类中被声明的次序。这导致后声明的变量可以使用先声明的变量来初始化,反之不行。因此,也最好按声明的次序在初始化列表里初始化。
初始化列表除了初始化成员变量,还初始化继承的基类,一般基类总是在最前面。现在,还剩下non-local static对象需要讨论。
所谓static对象,其寿命从被构造出来直到程序结束为止,因此stack和heap-base对象都被排除。这种对象包括global对象、定义于namespace作用域内的对象、在class内、在函数内、以及在file作用域内被声明为static的对象。函数内的static对象称为local static对象(因为它们对函数而言是local的),其他对象称为non-local static对象。程序结束时static对象自动销毁,也即它们的析构函数会在main()结束时被自动调用。
所谓编译单元,是指产出单一目标文件的那些源码,基本上它是单一源码文件加上其所含入的头文件。
我们关心的问题涉及至少两个源码文件。如果某编译单元内的某个non-local static对象的初始化动作使用了另一个编译单元内的某个non-local static对象,它所用到的这个对象可能尚未被初始化。
下面是一个实例:
1 | //文件系统,应是全局的 |
现在,除非tfs在tempDir之前被初始化,否则tempDir的构造函数会用到尚未初始化的tfs。但tfs和tempDir是不同的人在不同的时间于不同的源码文件建立起来的,它们是定义于不同编译单元内的non-local static对象,如何能确定tfs先初始化呢?
c++对此是非常困难,根本无解的。
一个小小的设计便可以完全消除这个问题,唯一需要做的是将每个non-local static对象搬到自己的专属函数内(该对象在此函数内被声明为static)。这些函数返回一个reference指向它所包含的对象。然后用户调用这些函数,而不直接指涉这些对象。换句话说,non-local static对象被local static对象替换了。这便是Singleton模式的一个常见实现手法(《Design Patterns》)
这个手法的基础在于:c++保证函数内的local static会在“该函数被调用期间”以及“首次遇上该对象定义式”时被初始化。所以如果你以“函数调用”(返回reference指向local static对象)替换“直接访问non-local static对象”,就获得了保证:保证你所获得的哪个reference将指向一个历经初始化的对象。更棒的是,如果你从未调用non-local static对象的仿真函数,就不会引发构造和析构成本(直接访问需要先创建对象而不管之后会不会用到,这是有成本的)。
1 | class FileSystem{};//同前 |
它们使用函数返回的“指向static对象”的引用,而不再使用static对象自身。
这种结构下的reference-returning函数往往十分单纯:第一行定义并初始化一个local static对象,第二行返回它。
在多线程系统中仍带有不确定性(不论是local或者non-local)。处理的做法是:在程序的单线程启动阶段手工调用所有reference-returning函数,这可消除与初始化有关的race conditions。
记住:
- 为内置型对象进行手工初始化,因为c++不保证初始化它们。
- 构造函数最好使用成员初值列(初始化列表),而不要在构造函数本体内使用赋值操作。初值列列出的成员变量,其排列次序应该和它们在class中的声明次序相同。
- 为免除“跨编译单元之初始化次序”问题,请以local static对象替换non-local static对象。
条款05
了解c++默默编写并调用哪些函数。
如果没有声明拷贝构造、赋值构造、析构函数,编译器就会为它自动声明编译器版本的函数。此外如果没有声明任何构造函数,编译器也会自动声明一个默认构造函数。这些函数都是public且inline的。唯有当这些函数被需要(调用),它们才会被编译器创建出来。
「具体参考语法记录博客」
记住:
编译器可以暗自为class创建默认构造函数、拷贝构造函数、赋值操作符以及析构函数。
条款06
若不想使用编译器自动生成的函数,就该明确拒绝。
所有编译器产出的函数都是public。为阻止这些函数被创建出来,你得自行声明它们,但这里并没有什么需求使你必须将它们声明为public。因此你可以将构造函数、析构函数声明为private。藉由明确声明一个成员函数,你阻止了编译器暗自创建其专属版本;而令这些函数为private,使得你成功阻止人们调用它。
而成员函数和friend函数还是可以调用private函数,因此更进一步的做法是,在private声明而不去定义,这样即使调用,也会得到错误。
一般的做法是,将阻止构造的动作设计在基类,然后继承:
1 | class Uncopyable |
这种情况下,anyclass的拷贝构造和赋值构造都不会自动生成,因为基类已经声明了。
记住:
为驳回编译器自动提供的机能,可将相应的成员函数声明为private并且不予实现。使用像Uncopyable这样的base class也是一种做法。
附:在c++11以后,可以用delete修饰函数,这是一种更好的方法。
条款07
为多态基类声明virtual析构函数。
当一个基类指针指向一个派生类对象,而基类实现中析构函数是non-virtual的,则此时若进行销毁(delete),实际上只会调用基类的析构函数而不会调用派生类的析构函数(因为编译器发现这是基类指针),导致内存释放不完全,因为派生类对象的成员仍然存活,只有基类成员被消除。
消除这个问题的做法是:给基类一个virtual析构函数。这明确告诉编译器这个类是多态的,要销毁对象时要在运行期根据所指对象来调用析构函数(通过虚表)。
- 任何class只要带有virtual函数,都几乎确定应该也有一个virtual析构函数;
- 如果class不含virtual函数,通常表明它并不意图被用作一个基类。
- 当class不企图被当作基类时,往往没有virtual函数,因为一旦有virtual函数,就需要一个虚函数指针指向虚表,这是额外的开销。
- 抽象类总是希望当作基类,因此往往析构函数写为(纯虚函数):
virtual ~A() = 0;
除此之外,必须为纯虚的析构函数提供一份定义:A::A(){}
。因为在析构时,编译器往往从最深层的析构函数开始调用,逐步调用每一个基类的析构函数,如果该析构函数没有定义,就会出错。
不是所有的基类都是为了多态用途。
记住:
- polymorphic(带多态性质的)base classes应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数
- classes的设计目的如果不是作为base classes使用,或不是为了具备多态性,就不应该声明virtual析构函数。
- 纯虚的析构函数必须给出定义。
条款08
别让异常逃离析构函数。
不要在析构函数中抛出异常,原因是C++异常机制不能同时处理两个或两个以上的异常。多个异常同时存在的情况下,程序若不结束,会导致不明确行为。如下代码:
1 | class Widget{ |
函数dosomething运行结束后,最为栈对象的vector v将被销毁,它同时也有责任销毁其内含的所有Widgets。假设v内含十个Widgets,而在析构第一个元素期间,有个异常被抛出。其他九个widgets还是应该被销毁(否则他们保存的任何资源都会发生泄漏),因此v应该调用它们各个析构函数。但假设在那些调用期间,第二个widget析构函数又抛出异常,C++无法同时处理两个或多个异常,多个异常同时存在的情况下,程序若不结束,会导致不明确行为。
如果析构函数必须执行一个动作,而该动作可能会在失败时抛出异常,该怎么办?举个例子,假设你使用一个class负责数据库连接:
1 | class DBConnection { |
为确保客户不忘记在DBConnection对象身上调用close(),一个合理的想法是创建一个用来管理DBConection资源的class,并在其析构函数中调用close。这就是著名的以对象管理资源。
1 | class DBConn { //这个class用来管理DBConnection对象 |
调用close成功,一切都美好。但如果该调用导致异常,DBConn析构函数会传播该异常,也就是允许它离开这个析构函数。那会造成问题,解决办法如下:
1 | //方法一:结束程序 |
如果程序遭遇一个“于析构期间发生的错误”后无法继续执行,“强制结束程序”是个合理选项,毕竟它可以阻止异常从析构函数传播出去(那会导致不明确的行为)。也就是说调用abort可以抢先制“不明确行为”于死地。
1 | //方法二:吞下异常 |
一般而言,将异常吞掉是个坏主意,因为它压制了“某些动作失败”的重要信息。然而有时候吞下异常也比负担“草率结束程序”或“不明确行为带来的风险”好。为了让这成为一个可行方案,程序必须能够继续可靠的执行。
1 | //方法三:重新设计DBConn接口,使其客户有机会对可能出现的异常作出反应 |
我们可以给DBConn添加一个close函数,赋予客户一个机会可以处理“因该操作而发生的异常”。把调用close的责任从DBConn析构函数手上移到DBConn客户手中,你也许会认为它违反了“让接口容易被正确使用”的忠告。
实际上这污名并不成立。如果某个操作可能在失败的时候抛出异常,而又存在某种需要必须处理该异常,那么这个异常必须来自析构函数以外的某个函数。因为析构函数吐出异常就是危险,总会带来“过早结束程序”或“发生不明确行为”的风险。
由客户自己调用close并不会对他们带来负担,而是给他们一个处理错误的机会。如果他们不认为这个机会有用(或许他们坚信不会有错误发生),可能忽略它,依赖DBConn析构函数去调用close。
记住:
- 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。
条款09
绝不在构造和析构过程中调用virtual函数
之所以不要在构造函数和析构函数起点调用virtual函数,是因为这种调用并不会带来预期的结果。
举个例子,假设有这样一个class继承体系,用来模拟股市的买进、卖出的订单等。在这样的过程中,一定要经过审计,因此每当创建一个交易对象时,在审计日志(audit log)中也需要创建一笔适当的记录:
1 | class Transaction { //所有交易的base class |
当我们执行如下语句时:
1 | BuyTransaction b; |
无疑会有一个BuyTransaction构造函数被调用,但是,首先Transaction构造函数一定会更早的被调用。因为derived class对象内的base class成分会在derived class自身成分被构造之前先完成构造。
然而,Transaction构造函数中的virtual函数logTransaction却会引发问题。因为此时所调用的logTransaction是Transaction内的版本,而不是BuyTransaction内的版本——即使目前即将创建的对象是BuyTransaction。也就是说:base class构造期间,virtual函数绝不会下降到derived classes阶层。换种非正式的说法:在base class构造期间,virtual函数不是virtual函数。
- 这是因为在base class构造函数执行时derived class的成员变量尚未初始化。如果此期间调用的virtual函数下降至derived class阶层,要知道derived class的函数几乎必然取用local成员变量,而那些成员变量尚未初始化
- 更根本的原因在于:在derived class对象的base class构造期间,对象的类型是base class而不是derived class。不止virtual函数会被编译器解析至(resolve to)base class,若使用运行期间类型信息,也会把对象视为base class。
在上面的例子中,当Transaction构造函数正在执行起来,打算初始化“BuyTransaction对象内的base class成分”时,该对象的类型是Transaction。而这个对象内的“BuyTransaction专属成分”尚未被初始化。因此在面对它们时,最安全的做法就是视它们不存在。对象在derived class构造函数开始执行之前不会成为一个derived class对象。
相同的道理同样适用于析构函数。一旦derived class析构函数开始执行,对象内的derived class成员变量便呈现出未定义值。因此,C++将其视为仿佛不存在。进入base class析构函数后对象就变成了一个base class对象。
因此,在上面的例子中,Transaction构造函数直接调用一个virtual函数,这很明显就违反了该条款的内容。这就导致了一个问题:因为logTransaction函数在Transaction内是一个pure virtual(纯虚函数)。除非它被定义了,否则程序无法连接,因为连接器找不到必要的Transaction::logTransaction实现代码。
但是,侦测“构造函数或析构函数运行期间是否调用virtual函数”并不简单。一般来说,如果Transaction有多个构造函数,每个都需要执行某些相同的工作,那么避免代码重复的一个优秀做法就是将共同的初始化代码(包括对logTransaction的调用)都放到一个初始化函数如init内:
1 | class Transaction { |
上面的这段代码,和早期的版本是一样的,但是却有更深层次的危害,因为这样并不会引起编译器和连接器的报错。此时,由于logTransaction是Transaction的一个pure virtual函数,当pure virtual函数被调用是,大多执行系统会终止程序。然而,如果logTransaction是个正常的virtual(即impure)函数,并在Transaction内带有一份代码,该版本就会被调用,程序也会继续往下进行,只会造成创建一个derived class对象时会调用错误版本的logTransaction(而不报任何信息)。
唯一能避免这一问题的解决办法为:保证构造函数和析构函数都没有(在对象被创建和销毁期间)调用virtual函数,而他们调用的所有函数也都服从同一约束。
但是,又如何确保每一次都有Transaction继承体系上的对象被创建,就会有适当版本的logTransaction被调用?一种办法是在class Transaction内将logTransaction函数改为non-virtual,然后要求derived class构造函数传递必要的信息给Transaction构造函数,而后那个构造函数边可以安全的调用non-virtual logTransaction。
1 | class Transaction { |
换句话说,由于无法使用virtual函数从base class向下调用,在构造期间,可以由“令derived classes将必要的构造信息向上传递至base class构造函数”替换并加以弥补。
记住:
在构造和析构期间不要调用virtual函数,因为这类调用从不下降至派生类(比起当前执行构造函数和析构函数的那层)。
条款10
令 operator= 返回一个reference to *this。
- 赋值采用
右结合律
。 - 趣的一点,是你可以把它写出
连锁赋值
的形式。
1 | int x, y, z; |
为了实现这种连锁赋值,赋值操作符必须返回一个reference指向操作符的左侧实参
。这也是实现class重载赋值操作符应该遵循的协议。这种协议不仅适用于标准赋值,也适用于所有赋值相关的运算
(+=,-=,*=,/=,<<=,…)。
1 | class Widget{ |
当然这只是协议,并无强制性,不遵循,代码一样可以通过编译。然而这份协议被所有内置类型和标准库提供的类型,如string,vector,complex,trl1::shared_ptr或即将提供的类型共同遵守
,除非你有一个标新立异的好理由,不然还是遵守吧。
记住:
令赋值操作符返回一个reference to *this。
条款11
在 operator= 中处理“自我赋值”。
“自我赋值”发生在对象被赋值给自己时:
1 | class Widget { ... }; |
虽然这种做法看起来比较傻,但是这种操作却是合法的,所以绝不要认定客户不会这么做。
此外,自我赋值并不是总是可以一眼分辨出来,例如:
1 | a[i] = a[j]; //潜在的自我赋值 |
如果i和j具有相同的值时,这就是一个自我赋值。再比如:
1 | *px = *py; //潜在的自我赋值 |
如果指针px和py恰巧指向同一个东西,这也是一个自我赋值。
这些并不明显的复制行为,是“别名(aliasing)”所带来的结果。所谓“别名”:就是有一个以上的方法指称(指涉)某对象。
一般而言,如果某段代码操作pointers或references,而它们被从来“指向多个相同类型的对象”,就需要去考虑这些对象是否为同一个对象。实际上,两个对象只要来自同一个继承体系,它们甚至不需要声明为相同类型就可能会造成“别名”,因为一个base class的reference或者pointer可以指向一个derived class对象:
1 | class Base { ... }; |
在这里,假如说我们尝试自行管理资源(即打算写一个用于资源管理的class,就需要这样做),就可能会掉进“在停止使用资源之前意外释放了它”的陷阱。举个例子,假如建立一个class用来保存一个指针指向一块动态分配的位图(bitmap):
1 | class Bitmap { ... }; |
下面是operator= 的实现代码,看起来虽然合理,但是在进行自我赋值时并不安全:
1 | Widget& Widget::operator=(const Widget& rhs) //不安全的operator= 的实现版本 |
之所以会出现自我赋值的问题,是因为operator= 函数内的*this(赋值的目的端)和rhs有可能是同一个对象。如果它们是同一个对象,那么delete对象就不只是销毁当前对象的bitmap,它也同时销毁了rhs的bitmap。在函数末尾,Widget发现自己持有一个指针指向一个已被删除的对象。
想要阻止这样的错误,传统的做法是在operator= 最前面进行一个“证同测试(identity test)”,以此达到自我赋值的检验目的:
1 | Widget& Widget::operator=(const Widget& rhs) |
这种解决办法是行得通的。在第一个版本的operator= 中,不仅不具备“自我赋值安全性”,也不具备“异常安全性”,然而,这个新版本的operator=,仍然存在异常方面的问题:如果“new BItmap”导致了异常(比如因为分配时内存不足或者因为Bitmap的copy构造函数抛出异常),Widget最终会持有一个指针指向一个被删除的Bitmap。(如果具备异常安全,在new发生之前不delete原来的对象,以防new异常后不能恢复。)
这样的指针是有害的:既不能安全的删除它,也不能安全的读取它。唯一能对它们做的安全的事情就是付出很多调试的功夫,去找到错误的起源。
当我们让operator= 具备“异常安全性”时,往往会自动获得“自我赋值安全性”的特性。因此,很多时候,并不专门去解决“自我赋值”的问题,而是将注意力放在“异常安全性(exception safety)”之上。例如对于下面,只需要注意在赋值pb所指的东西之前不要删除pb即可:
1 | Widget& Widget::operator=(const Widget& rhs) |
于是,如果“new Bitmap”抛出异常,pb(及其栖身的那个Widget)也会保持原状。即使没有证同测试,这段代码也是能够处理自我赋值问题,因为我们对原bitmap做了一份复件、删除原bitmap、然后指向新制造的那个复件。
当然,如果我们想要提高效率,也可以将证同测试重新放到函数的起始处。然而这样做之前先问问自己,你估计“自我赋值”的发生频率有多高?因为这项测试也需要成本。它会使代码变大一些(包括原始码和目标码)并导入一个新的控制流分支,而两者都会降低执行速度。
一个替代方案是,使用copy and swap技术。我在惯用法中更详细地说明了这项技术,事实上,内容基本包含了这一章节的内容。下面是简单的介绍。
1 | class Widget { |
另一种更为高效简洁的写法为:
1 | //这个写法更快的原因是参数通过值传递,抵消了创建temp的过程。 |
这种方法之所以可以,是因为:
- 某class的copy assignment操作符可能被声明为“以by value方法接受实参”
- 以by value方法传递东西会形成一份复件。
这种方法牺牲了代码的清晰性,但是却将“copy动作”从函数本体内移至“函数参数构造阶段”,使得编译器生成了更有效的代码。
记住:
- 确保当对象自我赋值时 operator= 有良好行为。其中技术包括比较“来源对象”和“”目标对象“的地址、精心周到的语句顺序、以及copy-and-swap。
- 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。
条款12
复制对象时勿忘其每一个部分。
设计良好的面向对象的系统,会将对象的内部封装起来,只留下两个函数来负责对象的拷贝(复制):
- copy构造函数
- copy assignment操作符
我们将它们一起成为copying函数。
假如我们要声明自己的copying函数,即告诉编译器自己不会去使用缺省实现的某些行为,那么此时编译器会在代码几乎必然出错的情况下,却不去告诉你。
举个例子,考虑一个class,用来表示顾客,其中人为地书写copying函数(而非由编译器去创建),使得外界对它们的调用都会记录下来(logged):
1 | void logCall(const std::string& funcName); //制作一个log entry |
虽然上面的代码看起来并没有什么问题,但是当另一个变量加入到其中时:
1 | class Date { ... }; //日期 |
此时,既有的copying函数执行的是局部拷贝(partial copy):它们只复制了顾客的name,而没有复制新添加的lastTransaction。
这明显是个错误,但是编译器却并不会报错(即使在最高级别的警告中)。因此,如果我们为class添加一个新的成员变量时,就必须同时修改copying函数。(同时也需要修改class的所有构造函数以及任何非标准形式的operator=)。
另外,一旦发生继承,则会造成一个更严重的危机:
1 | class PriorityCustomer: public Customer { //定义Derived class |
上面的代码中,PriorityCustomer的copying函数看起来好像是复制了PriorityCustomer内的每一样东西,但是实际上,它们只是复制了PriorityCustomer声明的成员变量,但是每个PriorityCustomer还内含了它所继承的Customer成员变量复件,而那些成员变量并没有被复制。即:Derived class没有连同base class中的字段一起复制。
PriorityCustomer的copy构造函数并没有指定实参传给其base class构造函数。(也即说它在它的成员初值列(member initialization list)中没有提到Customer)。
因此,PriorityCustomer对象的Customer成分会被不带有Customer构造函数(即default构造函数)初始化。default构造函数将会针对name和lastTransaction执行缺省的初始化动作。对于PriorityCustomer,它不曾企图修改其base class的成员变量,因此那些成员变量保持不变。
任何时候只要你为派生类实现copying函数,必须很小心地也复制基类成分,那些成分往往是private,所以无法直接访问,应该让派生类的copying函数调用相应的基类函数:
1 | PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs) |
当你编写一个copying函数时,请确保:
- 复制所有local成员变量;
- 调用所有基类内的适当的copying函数。
copying函数往往有近似相同的实现,但需要记住的是:令某个copying函数调用另一个copying函数无法达到目标,应把相同的实现放入第三个函数中由两个copying函数调用(通常命名为init,且为private)。
原因是,拷贝构造函数是针对未初始化的对象的操作,而赋值操作符只能施行于已初始化对象身上。当对象已初始化时,使用赋值操作符调用”只能作用于未初始化对象“的拷贝构造函数是没有意义的;同样,对象未初始化时,使用拷贝构造函数调用”只能施行于已初始化对象“的赋值操作符也是没有意义的;乃至于根本没有相关语法。
记住:
- copying函数应确保复制”对象内的所有成员变量“及”所有base class 成分“;
- 不要尝试以某个copying函数实现另一个copying函数。应将共同技能放进第三个函数中,并由两个copying函数共同调用。
条款13
以对象管理资源
假设我们使用一个用来塑模投资行为(例如股票、债券等)的程序库,各种各样的投资类型继承自root class Investment。进一步假设这个库使用了通过一个 factory 函数为我们提供特定 Investment 对象的方法:
1 | class Investment { ... }; // “投资类型”继承体系中的root class |
以下几种情形会造成 f 可能无法删除它得自 createInvestment 的投资对象:
- “…” 部分的某处有一个提前出现的 return 语句,控制流就无法到达 delete 语句;
- 对 createInvestment 的使用和删除在一个循环里,而这个循环以一个 continue 或 goto 语句提前退出;
- “…” 中的一些语句可能抛出一个异常,控制流不会再到达那个 delete。
单纯依赖“f总是会执行其delete语句”是行不通的,因为代码可能会在时间渐渐过去后被其他人修改、维护。
为了确保 createInvestment 返回的资源总能被释放,我们需要将资源放入对象中,当控制流离开f,这个对象的析构函数会自动释放那些资源。将资源放到对象内部,我们可以依赖 C++ 的“析构函数自动调用机制”确保资源被释放。
许多资源都是动态分配到堆上的,并在单一区块或函数内使用,且应该在控制流离开那个块或函数的时候释放。标准库的 auto_ptr 正是为这种情形而设计的。auto_ptr 是一个类似指针的对象(智能指针),它的析构函数自动对其所指对象调用 delete。下面就是如何使用 auto_ptr 来预防 f 的潜在的资源泄漏:
1 | void f() |
这个简单的例子示范了“以对象管理资源”的两个关键想法:
- 获得资源后应该立即放进管理对象内。
- 管理对象使用它们的析构函数确保资源被释放。
auto_ptr 和 tr1::shared_ptr 都在它们的析构函数中使用 delete,而不是 delete []。这就意味着将 auto_ptr 或 tr1::shared_ptr 用于动态分配的数组是个馊主意。C++ 中没有可用于动态分配数组的类似 auto_ptr 或 tr1::shared_ptr 这样的东西,甚至在 TR1 中也没有。那是因为 vector 和 string 几乎总是能代替动态分配数组。
如果你手动释放资源(例如,使用 delete,而不使用资源管理类),你就是在自找麻烦。像 auto_ptr 和 tr1::shared_ptr 这样的预制的资源管理类通常会使本条款的建议变得容易,但有时你所使用的资源是目前这些预制的类无法妥善管理的,你就需要精心打造自己的资源管理类。
记住:
- 为防止资源泄露,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源。
- 两个常被使用的RAII classes 分别是tr1::shared_ptr和auto_ptr。前者通常是较佳选择 ,因为其copy行为比较直观。若选择auto_ptr,复制动作会使被复制物指向null。
条款14
在资源管理类中小心copying行为。
如果一个RAII对象被复制,会发生什么?
在上一个条款之中,提到了“资源获得的时机就是初始化时机”(Resource Acquisition Is Initialization(RAII)),并且说明了auto_ptr和tr1::shared_ptr如何在heap-based的资源上作用的。但是,并不是所有的资源都是“heap-based”的,对于这种资源,上述的这两种指类指针对象就不再适合作为资源管理者了。于是,我们需要建立自己的资源管理类,
举个例子,加入使用C API函数处理类型为Mutex的互斥器对象(mutex objects),共有lock和unlock两个函数可以使用:
1 | class Lock { |
在使用Lock时,符合RAII的标准:
1 | Mutex m; //定义所需要的互斥器 |
这样的操作的是没有问题,但是如果此时Lock对象被复制:
1 | Lock ml1(&m); //锁定m |
这个时候,对于这种情况,一般有两种选择:
- 禁止复制。因为许多时候,允许RAII被复制并不合理。如果复制动作对于RAII对象不够合理,就应该禁止。实现这种禁止,即用到了条款6中所说的办法:将copying操作声明为private。
- 对底层资源使用“引用计数法”。有的时候,我们希望保持着资源,直到它的最后一个使用者(某个对象)被销毁。在这种情况下,复制RAII对象时,应该将资源的“被引用数”进行递增。tr1::shared_ptr就是如此。
通常只要内含一个tr1::shared_ptr成员变量,RAII classes就可以实现reference-counting copying行为(使用默认的拷贝构造函数即可,因为这时会对成员变量调用其拷贝构造)。假如前面的Lock想要使用reference counting,它可以直接去改变mutexPtr的类型即可:将其从Mutex*改为tr1::shared_ptr< Mutex >。
但是,需要注意的是,tr1::shared_ptr的默认行为为:当引用次数为0时,删除其所指物。删除操作并不是我们想要的,当我们使用一个Mutex,我们要做的释放动作是解除锁定而并非删除。
因此,tr1::shared_ptr是允许定义所谓的“删除器(deleter)”的,这是一个函数或者函数对象(function object),当引用的次数为0时被调用(这个功能并不存在与auto_ptr中,它总是将其指针删除)。这个删除器对于tr1::shared_ptr是第二个参数:
1 | class Lock { |
在条款5中说过,无论是编译器自动生成的、还是用户自定义的,class 析构函数都会自动调用其non-static成员变量(在这里是mutexPtr)的析构函数,使计数器减1。而mutexPtr的析构函数会在互斥器的引用计数为0时自动调用tr1::shared_ptr的删除器(在这里是unlock)。也就是说,之所以没有声明析构函数,完全只是依赖了编译器生成的缺省行为。
总之,对RAII对象的复制基本就两种情况:
- 复制底部资源。可以是多个对象指向一份资源(引用计数);也可以深度拷贝,对这个资源多生成一份副本(从内存来看是不同的)。
- 转移底部资源的拥有权。确保永远只有一个RAII对象指向一个原始资源。
记住:
- 复制RAII对象必须一并复制它所管理的资源,所以资源的copying行为决定RAII对象的copying行为。
- 普遍而常见的RAII class copying 行为是:抑制copying、施行引用计数法。不过其他行为也都可能被实现。
条款15
在资源管理类中提供对原始资源的访问。
所谓资源管理类(resource-managing classes),可以有效的帮助我们去预防资源泄漏。但是,虽然在理想的情况下,我们希望所有的资源与对象之间的互动都依赖于这样的资源管理类,仍有许多的API会直接去涉及原始资源(raw resource)。
举个例子,在前面的条款13中,我们可以得知:使用智能指针auto_ptr或者tr1::shared_ptr来保存factory函数例如createInvestment的调用结果:
1 | std::tr1::shared_ptr<Investment> pInv(createInvestment());//pInv本身是shared_ptr,内部的成员指针才是Investment的 |
如果我们希望用某个函数来处理Investment对象:
1 | int dayHeld(const Investment* pi); //返回投资的天数 |
此时,如果我们想要这样去调用:
1 | int days = daysHeld(pInv); //错误!! |
这样做是错误的,因为daysHeld需要的是Investment的指针,而并非此时传递给它的类型为tr1::shared_ptr的对象。
因此,此时需要一个函数,可以将RAII class对象转换为其所内含的原始资源(即tr1::shared_ptr -> Investment*)
有两种方法可以实现这样的功能:
显式转换:tr1::shared_ptr和auto_ptr都提供了一个get成员函数,用来执行显式转换。也就是说,可以返回智能指针内部的原始资源(的复件):
1
int days = daysHeld(pInv.get()); //成功的将pInv内的原始指针传给了daysHeld
隐式转换:就像几乎所有的智能指针一样,这些对类指针对象都重载了指针取值(pointer dereferencing)操作符(operator ->和operator *),它们允许隐式转换至底部的原始指针:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Investment { //investment继承体系的根类
public:
bool isTaxFree () const;
...
};
Investment* createInvestment(); //factory函数
std::tr1::shared_ptr<Investment> pi1(createInvestment()); //令tr1::shared_ptr管理一笔资源
bool taxable1 = !(pi1->isTaxFree()); //经由operator->访问资源
...
std::auto_ptr<Investment> pi2(createInvestment()); //令auto_ptr管理一笔资源
bool taxable2 = !((*pi2).isTaxFree()); //经由operator*访问资源
...因为有时需要必须取得RAII内部的原始资源,一般的做法是提供一个隐式转换函数(operator A(),A的目标类型)。举个例子,对于用于字体的RAII class(对于C API而言,字体是一种原始数据结构、即原始资源)
1
2
3
4
5
6
7
8
9
10
11FontHandle getFont(); //这是一个C API
void releaseFont(FontHandle fh); //来自同一组的C API
class Font { //RAII class
public:
explicit Font(FontHandle fh) : f(fh) //获得资源,采用pass-by-value的方法
{ }
~Font() { releaseFont(f); } //释放资源
private:
FontHandle f; //原始(raw)字体资源
};假设有大量与字体相关的C API,它们处理的都是FontHandle,那么“将Font对象转换为FontHandle”将会是一件非常繁琐的事情。因此,Font class可以提供一个显式的转换函数,就像上面的get一样:
1
2
3
4
5
6
7
8
9
10
11
12class Font {
public:
...
FontHandle get() const { return f; } //显式转换函数
...
};
//然而,每次用户想要使用API时,都必须要调用get:
void changeFontSize(FontHandle f, int newFontSize); //C API
Font f(getFont());
int newFontSize;
...
changeFontSize(f.get(), newFontSize); //明确地将Font转换为FontHandle另外一种办法则是令Font提供隐式转换函数,转换的类型FontHandle:
1
2
3
4
5
6
7
8
9
10
11
12class Font {
public:
...
operator FontHandle() const //隐式转换函数
{ return f; }
...
};
//这样,在调用C API时,就非常的方便了:
Font f(getFont());
int newFontSize;
...
changeFontSize(f, newFontSize); //将Font隐式地转换为FontHandle但是,这种隐式转换却会增加错误的发生机会。例如,当我们需要使用Font时,却会意外的创建一个FontHandle:
1
2
3Font f1(getFont());
...
FontHandle f2 = f1; //注意!这里的本意是拷贝一个Font对象,但是因为隐式转换的原因(f2前面的FontHandle),将f1给隐式转换为了FontHandle,然后才执行了复制操作在上面的程序中,FontHandle由Font对象f1进行管理,但是这个FontHandle也可以直接通过f2进行取得。这样就会引发问题,例如当f1被销毁时,字体会被释放,而f2因此会成为“虚吊的”(dangle)。
综上所说,是否应该提供一个显式转换函数(例如get成员函数)将RAII class转换为其底部资源,还是提供隐式转换,取决于其执行的具体功能,具体说来:
- 让接口容易被正确使用,不易被误用。因此,通常显式转换函数如get就是比较好的方法,因为显式转换将非故意的类型转换的发生的可能性最小化了。
- 然而有时候隐式类型转换所带来的自然用法也能取得方便。
最后,值得说明的是,从RAII class 获取原始资源并没有什么矛盾(你可能认为这样的类封装是失败的)。关于RAII class,它们并不是为了封装,而是为了确保:资源释放,这一行为,一定会发生。
记住:
- APIs往往要求访问原始资源,所以每一个RAII class 应该提供一个“取得其所管理之资源”的办法。
- 对原始资源的访问可能经由显式转换(比如一个get成员函数)或隐式转换(重载operator A())。一般而言显式转换比较安全,但隐式转换对客户比较方便。
条款16
成对使用new和delete时要采取相同形式。
这章内容比较简单,就是“单一对象”和“对象数组”的问题。
- 当你使用delete[]时,实际上是告诉编译器我要delete的对象是一个数组,让它得知内存中存在一个“数组大小记录”,这样才能释放完全空间。
- 如果对象不是数组但依然用了delete[],就会导致编译器在解释内存空间时误认为某一项是“数组大小”,然后错误地释放空间。
- 最后,不要对数组形式做typedef动作,因为这会导致你new时不容易发现到底有没有[],从而导致delete出错。
记住:
如果你在new表达式中使用[],必须在相应的delete表达式中也使用[]。如果你在new表达式中不使用[],一定不要在相应的delete表达式中使用[]。
条款17
以独立语句将newed对象置入智能指针。
首先,假设我们有个函数用来显示处理函数的优先权,另一个函数用来在某动态分配所得的Widget上进行某些带有优先权的处理:
1 | int priority(); |
根据“以对象管理资源”的条款,processWidget使用智能指针来对动态分配得到的Widget进行管理(即tr1::shared_ptr)
当我们试图调用processWidget:
1 | processWidget(new Widget, priority());//这种情况会调用拷贝构造函数 |
需要注意的是,此时是不能通过编译的。tr1::shared_ptr构造函数需要一个原始指针(raw pointer),但是这个构造函数是一个explicit构造函数,无法进行隐式转换。因此,需要将“newWidget”的原始指针转换为processWidget所需要的tr1::shared_ptr,因此需要这样写:
1 | processWidget(std::tr1::shared_ptr<Widget> (new Widget), priority ());//先使用构造函数,再拷贝构造 |
但是,虽然此时我们使用了“对象管理式资源”,仍然会产生资源的泄漏!
在编译器产生一个processWidget调用代码之前,首先需要确定把即将被传递的各个实参是什么。在上面的代码中,第二个实参较为简单,只是一个对priority函数的调用;而第一个实参则较为复杂,对于这个实参std::tr1::shared_ptr< Widget > (new Widget),它有两部分组成:
- 执行“new Widget”表达式
- 调用tr1::shared_ptr构造函数
因此,在调用processWidget之前,编译器需要创建代码,做以下三件事情:
- 调用priority
- 执行“new Widget”
- 调用tr1::shared_ptr构造函数
对于这三件事情,对于C++而言,“new Widget”的执行次序一定是在“tr1::shared_ptr构造函数被调用”之前,因为这个表达式的结果还需要被传递作为tr1::shared_ptr构造函数的一个实参。但是对于priority的调用而言,则是可以在第一、第二或者第三执行。假如选择在第二位执行,可以得到这样的执行序列:
- 1.执行“new Widget”
- 2.调用priority
- 3.调用tr1::shared_ptr构造函数
因此,对于上面这个执行次序,如果priority的调用产生异常,此时“new Widget”返回的指针将会遗失,因为它尚未被置入tr1::shared_ptr内,而这个智能指针的存在就是为了防止资源泄漏的。因此,在对processWidget的调用过程中是会引发资源泄漏,因为在“资源被创建(经由“new Widget”)”和“资源被替换为资源管理对象”这两个时间点之间可能会被发生干扰。
为了避免这样的问题,解决办法为分离语句。即分别写出:
1.创建WIdget
2.将它置入一个智能指针之中,然后再将这个智能指针传给processWidget:
1
2
3std::tr1::shared_ptr<Widget> pw(new Widget); //在单独的语句内以智能指针存储newed所得对象
process(pw priority()); //这个调用动作就不会造成泄漏
之所以可以解决这个问题,是因为编译器对于“跨越语句的各项操作”没有重新排列的自由(只有在语句内它才拥有这个自由)。在上述修改后的代码中,“new Widget”表达式以及“对tr1::shared_ptr构造函数的调用”这两个动作,与“对priority的调用”是分隔开的,在不同的语句之中,因此编译器就不能再它们之间任意选择执行次序。
记住:
以独立语句将newed对象存储于(置入)智能指针内。如果不这样做,一旦异常被抛出(在资源对象产生到置入资源管理对象之间,产生异常的话),有可能导致难以察觉的资源泄漏。
条款18
让接口容易被正确使用,不易被误用。
想要开发出一个“容易被正确使用,不易被误用”的接口,首先就需要明白使用这样的接口可能会产生怎样的错误?举个例子,假设我们为一个用来表示日期的class设计构造函数:
1 | class Date { |
虽然这样定义的构造函数乍一看没什么问题,但其实在调用时,却可能会产生以下两个错误:
1 | //错误的传递参数次序: |
想要解决这样的问题,类型系统(type system)是一个关键办法。我们使用外覆类型(wrapper type)来区别这样的天数、月份和年份,然后在Date构造函数中使用这些类型:
1 | struct Day { |
当我们确定好类型的定义,限制它们的取值也是非常重要。比如,月份的取值只能是1-12。方法之一,就是利用enum表现月份。不过enum并不具备类型安全性。因此,比较安全的解法是预先定义所有有效的Months:
1 | class Month { |
- 预防错误的另一种方法,限制类型内什么事情可以做,什么不可以做。常见的限制是加上const。在前面的条款3中,就对const的作用进行了说明。
- “让types容易被正确使用,不容易被误用”,进一步表现为:除非有更好的理由,否则应该尽量让我们定义的types的行为与内置的types一致。一旦有怀疑,就拿int类型当范本。比如a*b这个表达式不允许赋值。
- 避免无端的与内置类型不兼容,真正的理由是为了提供行为一致的接口。STL容器的接口就比较一致,这就使得它们非常容易被使用。例如,每个STL容器都有size函的成员函数,它会告诉调用者目前容器内的对象的个数。
如果一个接口要求使用者必须记住某些事情,就会有着“不正确使用”的倾向,因为使用者很有可能忘记做这件事。举个例子,在条款13中所说的factory函数,它会返回一个指针指向Investment继承体系内的一个动态分配的对象:
1 | Investment* createInvestment(); |
为了避免资源的泄漏,createInvestment返回的指针最终必须被删除,但是在这个过程中,至少可能会出现两个错误机会:(1)没有删除指针。(2)删除同一个指针超过一次。
在条款13中提供的解决办法是将createInvestment的返回值存储在一个智能指针内,于是delete的责任就赋予了智能指针。不过,使用者如果忘记使用智能指针,则会出现问题。因此,较为理想的接口就是令factory函数返回一个智能指针:
1 | //这实际上就是强迫使用者将返回值存储在一个tr1::shared_ptr内, |
假设class的设计,希望“从createInvestment取得Investment*指针”,将该指针传递给一个名为getRidOfInvestment的函数,而不是直接delete。那么这样设计的一个接口(上面那个)却会产生新的错误:企图使用错误的资源析构机制——即使用delete替换getRidOfInvestment。
对于这个问题,一个解决办法则是:返回一个“将getRidOfInvestment绑定为删除器(deleter)”的tr1::shared_ptr。
在前面的条款中有讲到过,tr1::shared_ptr提供的构造函数有两个实参:
- (1)被管理的指针
- (2)引用次数变成0时被调用的“删除器”
由此可得,我们可以创建一个null tr1::shared_ptr并以getRidOfInvestment作为其删除器:
1 | //试图创建一个null shared_ptr并携带一个自定的编译器 |
上面的代码是不能通过编译的,因为tr1::shared_ptr构造函数的第一个参数必须是个指针,而0不是指针。因此,转型(cast)可以解决这样的问题:
1 | //正确地创建一个null shared_ptr并携带一个自定的编译器 |
因此,如果我们想要实现createInvestment使它返回一个tr1::shared_ptr并夹带getRidOfInvestment函数作为删除器,代码如下:
1 | std::tr1::shared_ptr<Investment> createInvestment() |
当然,如果被管理的原始指针可以在建立智能指针之前先确定下来,那么将原始指针传给构造函数会比先初始化为null再赋值更好。
tr1::shared_ptr的一个优秀的性质是:它会自动的使用它的“每个指针专属的删除器”这样的性质消除了一个潜在的可能错误:“cross-DLL problem”。
这个问题发生于“对象在动态链接库(DLL)中被创建,但却在另一个DLL内被delete销毁”。在一些平台上,这一类“跨DLL的new/delete成对运用”会导致运行期间错误。
然而tr1::shared_ptr就没有这个问题,因为它默认的删除器是来自“tr1::shared_ptr诞生所在的那个那个DLL”的delete。举个例子来说,如果Stock派生自Investment,而createInvestment的实现如下:
1 | std::tr1::shared_ptr<Investment> createInvestment() |
返回的那个tr1::shared_ptr可被传递给任何其他的DLLs,无需在意“cross-DLL problem”。这个指向Stock的tr1::shared_ptr会追踪记录“当Stock的引用次数变成0时该调用的那个DLL’s delete”。
记住:
- 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
- “促使正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
- “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
- tr1::shared_ptr支持定制型删除器。这可防范DLL问题,可被用来自动解除互斥锁(见条款14)等等。
条款19
设计class犹如设计type。
当我们定义一个新的class时,就相当于定义了一个新的type。
如何设计一个高效的classes?主要面对以下的问题:
- 新type的对象应该如何被创建和销毁?这会影响到class的构造函数和构造函数以及内存分配函数和释放函数(operator new, operator new[], operator deleter和operator deleter[])。
- 对象的初始化和对象的赋值应该有什么差别?这个问题的答案,决定了构造函数和赋值(assignment)操作符的行为,以及它们之间的差异。需要注意的是,“初始化”和“赋值”是不同的,因为他们应用于不同的函数调用。(详见条款4)
- 新type的对象如果被passed by value(以值传递),会怎样?需要记住的是,copy构造函数用来定义一个type的pass-by-value应该如何去实现。
- 什么是type的“合法值”?对class的成员变量来说,通常某些只有数值集是有效的。那些数值集决定了class需要维护的约束条件(invariants),因此,也就决定了成员函数(特别是构造函数、赋值操作符以及所谓的“setter”函数)必须要进行错误检查工作。
- 创建的新type需要配合某一个继承图系(inheritance graph)么?如果继承来自某些既有的classes,那么设计的新classes就受到了束缚,特别是受到“它们的函数是virtual或者non-virtual”的影响。如果我们定义的class允许其他class去继承,这样会影响我们所声明的函数——尤其是析构函数——是否为virtual(详见条款7)。
- 创建的新type需要怎样的转换?如果我们希望允许类型T1可以被隐式地转换为类型T2,就必须在class T1中写一个类型转换函数(operator T2)或者在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。如果我们只允许explicit构造函数存在,就必须写出专门负责执行转换操作的函数。
- 什么样的操作符和函数对新创建的type而言是合理的?针对这个问题,需要决定class声明哪些函数,在这些函数中,哪些应该是member 函数,哪些则不是。
- 什么样的标准函数应该被驳回?这些函数是必须声明为private的函数(详见条款7)。
- 谁来取用新的type成员?这个问题可以帮助我们决定哪些成员应该是public、protected和private;同时也可以帮我们决定哪一个class/function应该是friends,以及将它们嵌套到另一个之内是否合理。
- 什么是新type的“未声明接口”(undeclared interface)?它会对效率、异常安全性以及资源运用(例如多任务锁定和动态内存)提供何种保证?你在这些方面提供的保证将为你的class实现代码加上相应的约束条件。
- 创建的新的type有多么一般化?如果说我们所需要的不是一个type,而是需要一整个types家族,那么我们就需要定义一个新的class template。
- 真的需要定义一个新的type吗?如果只是定义新的derived class以便既有的class添加功能,那么单纯的定义一个或多个non-member函数或者template,或许更能达到目标。
记住:
class的设计就是type的设计。在定义一个新type之前,请确定你已经考虑过本条款覆盖的所有讨论主题。
条款20
宁以pass-by-reference-to-const替换pass-by-value。
在一般的情况下,默认情况中C++会以by value的方式传递对象自(或来自)函数。除非我们去特别指定,否则函数参数都是以实际实参的复件为初值,而调用端获得的也是函数返回值的一个复件。这些复件是由对象的copy构造函数生成的,这可能会造成pass-by-value称为较为费事儿的操作。
对于下面这个例子:
1 | class Person { |
这时,我们调用函数validateStudent,该函数需要一个Student实参(by value)并返回它是否是有效的:
1 | bool validateStudent(Student s); //函数以by value的形式接受学生 |
当我们执行上面的代码时:首先,Student的copy构造函数会被调用,以white为蓝本将s进行初始化。同时,当validateStudent返回s时会被销毁。因此,对于此函数而言,参数的传递成本是:
- 一次Student copy构造函数调用
- 一次Student析构函数调用
但是!这还不算完!Student对象内有两个string对象,因此每次构造一个Student对象也就构造了两个string对象。此外,Student对象继承自Person对象,因此,每次每次构造Student对象是也必须构造一个Person对象。一个Person对象又有两个string对象,因此,每一次Person的构造动作也要承担两个string对构造动作。
于是,最终结果是:以by value的形式传递一个Student对象会调用一次Student对象会导致调用一次Student copy构造函数、一次Person copy构造函数、四次string copy构造函数。因此,当函数内的Student复件被销毁时,每一个构造函数调用动作都会对应一个析构函数调用动作。以by-value方式传递一个Student对象,
总体成本是:六次构造函数 和 六次析构函数。
这是一个非常大的代价了。想要回避这样的大代价的一个办法就是pass by reference-to-const:
1 | bool validateStudent(const Student& s); |
这样的传递方法,效率要高的多:没有构造函数或者析构函数被调用,因为没有任何新对象被创建。
在上面修改后的代码,参数const是非常重要的。因为在原先的validateStudent中参数是以by-value的形式进行传递的,因此变相的就告诉我们这个传递的参数是收到保护的,函数内绝不会对传入的Student进行任何的更改,能更改也只是对Student的复件进行修改。
于是,Student以by reference方式的传递,将它声明为const是必要的,使得确保传递的Student不至于被修改。
以by reference方式传递参数,也可以避免slicing(对象切割)问题。
当一个derived class对象以by value方式进行传递,并被视为一个base class对象,base class的copy构造函数会被调用,然而:“造成此对象的行为像一个derived class对象”的那些特质化的特征全部被切割掉了,仅仅留下了一个base class对象。造成这种情况的原因是,这个对象正是base class构造函数进行建立的,然而这种现象是我们一定不希望看到的。
举个例子,假设我们定义一组class,用来实现一个图形窗口系统:
1 | class Window { |
对于所有的Window对象,都有一个名称,我们可以通过name函数获取。所有的窗口显示,我们也可以通过display函数来进行实现。
其中,display函数是一个virtual函数,这就意味着base class Window对象的显示方式和WindowWithScrollBars对象的显示方式是不同的。
而当我们希望写一个函数去打印窗口的名称,然后显示该窗口,下面的写法是错误的:
1 | void printNameAndDisply(Window w) //不正确,参数可能会被切割 |
当我们调用上述的参数并向其传递一个WindowWithScrollBars对象时:
1 | WindowWithScrollBars wwsb; |
此时,参数w会被构造称为一个Window对象,因为它是pass by value的。于是,使得wwsb“之所以是一个WindowWithScrollBars对象”的所有特征都会被切割掉,简而言之:在printNameAndDisplay函数内不管是传递过来的对象时什么类型,参数w就像是一个Window对象。
因此,在printNameAndDisplay函数内调用display调用的总是Window::display,而绝不会是WindowWithScrollBars::display。
解决切割(slicing)问题的办法,就是以by reference-to-const方式传递w:
1 | void printNameAndDisplay(const Window& w) |
此时,传进来的窗口的是什么类型,w就表现出那种类型。
当我们观察C++编译器底层时,可以看到reference往往是以指针来实现的,因此:pass by reference通常意味传递的是指针。
- 根据这点,如果我们需要传递的对象属于内置类型(例如int),pass by value往往比pass by reference的效率更高。对于这一点,也同样适用于STL的迭代器和函数对象,因为习惯上,它们都被设计为passed by value。
- 因为内置类型都相当的小,所以可能有人就会认为,所有小型types都可以使用pass-by-value,甚至当它们是用户自定义的class也一样,这个结论是错误的!因为对象小不代表copy构造函数的代价就不高。有许多对象——包括大多数STL容器——内含的东西只比一个指针多一些而已,但是复制这种对象却需要承担“赋值这些指针所指的每一样东西”。因此,代价也是非常昂贵的。
- 即使小型对象拥有并不昂贵的copy构造函数,在效率上也可能有差距。某些编译器在对待“内置类型”和“用户自定义类型”的态度上截然不同,即使两者拥有相同的底层表述(underlying representation)。
- “小型的用户自定义类型不一定通过pass-by-value”的另一个理由是:作为一个用户自定义类型,其大小容易变化。一个type目前虽然比较小,将来却可能会变得比较大,因为其内部实现可能会变化。
总而言之,其他小型的type包括自定义的class,对象本身小不代表copy代价不高;就算代价不高,编译器的不同对待也会导致效率也比较低;并且这些自定义类型大小是容易变化的。
一般而言,我们可以认为:
- pass-by-value代价不高的唯一对象就是内置类型和STL的迭代器和函数对象。
以至于所其他其他的任何东西,都应当尽量以pass-by-reference-to-const替换pass-by-value。
记住:
- 尽量以 pass-by-reference-to-const 替换pass-by-value。前者通常比较高效,并可避免切割问题。
- 以上规则并不适用于内置类型、STL的迭代器和函数对象。对它们而言,pass-by-value 往往比较适当。
条款21
必须返回对象时,别妄想返回其reference。
在上一章中我们了解到,pass-by-value有很多效率方面的问题,因此pass-by-reference可能是一种比较好的方法。
但是!盲目的用reference可能会造成这样的错误:开始传递一些references指向其实并不存在的对象!
举个例子,对于一个用以表现有理数(rational number)的class,内含一个函数用来计算一个有理数的乘积:
1 | class Rational { |
在上面的代码中,这个版本的operator 是以by value的方法返回其计算结果——一个rational对象。对于这样的返回方法,代价如何?
假如说我们进行修改,使用reference进行传递,就不需要付出代价了。
但是:所谓的reference,只是一个名称,代表着某个既有对象。即,它一定是某物的另一个名称。
就像上面的operator* ,如果他返回一个reference,那么后者一定指向某个既有的Rational对象,内含两个Rational对象的乘积。因此,我们不能期望这样一个内含乘积的Rational对象在调用operator* 之前就存在。也就是说:
1 | Rational a(1, 2); //a = 1/2 |
期望“原本就存在一个值为3/10的Rational对象”并不合理。如果operator* 要返回一个reference指向这个数值,它就必须自己创建这个Rational对象!
一般来说,函数创建新对象有两种方法:
- 在stack空间创建
- 在heap空间创建
如果我们定义一个local变量,就是在stack空间创建对象。根据这个策略,尝试写一下operator*:
1 | const Rational& operator* ( const Rational& lhs, |
对于上面这个方法,是一个比较糟糕的办法!因为我们的目标是避免使用构造函数,而result却必须用构造函数的方法来进行构造。更严重的是,这个函数返回一个reference指向result,但是result是一个local对象,而local对象在函数退出之前就被销毁了。因此,此时operator* 所指向的Rational,是一个已经被销毁的Rational!于是,此时将会陷入“无定义行为”的困境。
简单总结一句话:任何函数如果返回一个reference指向某个local对象,都会产生必然的错误!
因此,我们考虑在heap内构造一个对象,并返回reference指向它。
Heap-based对象是由new创建的,因此我们需要写一个heap-based operator*,形式如下:
1 | const Rational& operator* ( const Rational& lhs, |
在上面的代码中,我们依然需要付出一个“构造函数调用”的代价,因为分配获得的内存将以一个适当的构造函数完成初始化动作。
然而,此时还有一个更为严重问题:谁应该为被new出来的对象实施delete?
即使我们十分谨慎,还是会在合情合理的使用下,造成内存泄漏:
1 | Rational w, x,y,z; |
在上面的代码中,同一个语句调用了两次operator*,因此使用了两次new,因此也就需要两次delete。但是,并没有合理的办法让operator*的使用者进行哪些delete调用,因为没有合理的办法让他们取得operator* 返回的references背后隐藏的那个指针。
这势必会造成内存泄漏!
无论是on-the-stack,还是on-the-heap的做法,都因为对operator* 返回的结果调用构造函数而产生了问题,而最开始的目标就是避免如此的构造函调用动作!于是,另一个想法则是基于:
- 让operator* 返回的reference指向一个被定义于函数内部的static Rational对象。
1 | const Rational& operator* ( const Rational& lhs, |
上面的代码之所以糟糕,是因为如果对于以下的使用代码:
1 | bool operator==(const Rational& lhs, |
出现的问题是:无论a, b, c, d的值是多少,表达式((a * b) == (c * d))总是被判定为True!
让我们将上述的if判断语句写成等价的函数形式:
1 | if (operator==(operator*(a, b), operator*(c, d)) |
上面这句代码,可以看到在operator==被调用之前,已经有两个operator* 被调用了,并且每一个都返回reference指向operator* 内部定义的static Rational对象。因此,operator==被要求将“operator* 内的static Rational对象值” 拿来和“operator* 内的static Rational对象值” 进行比较,这自然就是相等了。
值得注意的是,两次的operator*的调用确实是改变了static Rational对象值,但是由于他们都是返回reference,因此调用段看到的永远是static Rational对象的“现值”。
另外,如果创建一个static array保存这些static对象呢?一方面这个数组的大小很难选择(太小不够用,太大降低效率),一方面每个对象都会在函数第一次调用时构造完成,调用n个构造函数和最后有n个析构函数。接着,为了把结果值写入array,又要调用赋值操作,很多时候赋值操作相当于一个拷贝构造和一个析构函数,情况就更恶劣了。
于是,一个“必须返回新对象”的函数的正确写法是:直接让这个函数返回一个新对象。
1 | incline const Rational operator* ( const Rational& lhs, const Raitonal& rhs) |
当然了,operator*返回值的构造成本和析构成本是必须支出的,但是这只是获得正确行为的小小代价。
总之:
- 当我们必须在“返回一个reference和返回一个object”之间进行选择时,选择行为正确的那一个。
记住:
绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象。或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。条款4已经为“在单线程环境中合理返回reference 指向一个local static对象”提供了一份设计实例。
条款22
将成员变量声明为private。
总结下来主要有三点理由:
- 语法一致性:如果public的都是函数,用户在调用成员时都加上小括号就行了,不必再考虑这是变量还是函数(要不要加括号);
- 精确控制:将变量放private,可以继而通过函数实现精准地控制,如这些变量读写访问权限;
- 封装:public意味着不封装,不封装意味着不可改变。如果将成员变量隐藏,那么暴露出来的函数即使内部实现改变(适应不同情况,可能用不同成员变量)也不会对客户造成多大的困扰,最多只需要重新编译。这样的封装,使得class的约束条件容易得到维护,保留了日后变更实现的权力。
- 改变public事物总是收到束缚的,因为会破坏许多客户码(它们用暴露出来的成员实现其他功能等等)。
- protected虽然不能被外部对象访问,但可以被派生类本身访问,因此也是不封装的,改变它会使得派生类代码被破坏。
- 也就是说:private提供封装,其他不提供封装。
记住:
- 切记将成员变量声明为private。这可赋予用户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
- protected并不比public更具封装性。
条款23
宁以non-member&non-friend替换member函数。
假设有一个class用来表示网页浏览器。这样的class可能提供的众多函数中:
1 | class WebBrowser { |
因此,用户可能会觉得使用一个操作来执行这些任务,因此WebBrowser也提供这样一个member函数:
1 | class WebBrowser { |
同时,这个机能也可以通过一个non-memebr函数调用适当的member函数而提供:
1 | void clearBrowser (WebBrowser& wb) |
那么,对于上面两个办法,哪一种较好呢?
首先,对于面向对象守则的要求,数据及操作数据的那些函数应该被捆绑在一起。这就意味着使用member函数可能是一个比较好的选择。然而事实上并非如此。
- 面向对象守则要求数据应该尽可能地被封装,而与直观相反的是,member函数clearEverything所带来的封装性比non-member函数clearBrowser要低。
- 提供non-member函数可允许对WebBrowser相关机能有较大的包裹弹性(packaging flexibility),而那最终导致较低的编译相依度,增加WebBrowser的可延伸性。
- 因此,许多方面non-member做法比member做法好。
从封装开始讨论。
如果某些东西被封装,它就不再可见。越多的东西被封装,就越少的人可以看见。而越少的人可以看到它,我们就有越大的弹性去改变它,因为我们的改变仅仅直接影响看到改变的那些人事物。
- 因此,越多的东西被封装,我们改变这些东西的能力就越大。简而言之,封装使得我们能够改变事物而只影响有限的用户。
越少的代码可以看到数据(即访问到它),越多的数据就可以封装,而我们就越能自由地改变对象数据,例如改变成员变量的数量、类型等。
- 简而言之,越多的函数可以访问这个数据,它的封装性就越低。
在上一个条款22中可以得知,成员变量应该是private,因为如果它们不是private,就会有无限量的函数可以访问到它们,它们也就毫无封装性。
- 能够访问private成员变量的函数只有class的member函数加上friend函数。
对于member函数,它可以访问class内的:private数据、private函数、enums、typedefs等等。而对于non-member函数,它无法访问上述的任何一个。如果我们需要在member函数和non-member函数中选择,而且两者提供相同的机能,则:
- 导致较大封装性的是non-member non-friend函数,因为它并不会增加“能够访问class内的private”的函数数量。
因此,这也就解释了为什么clearBrowser(non-member non-friend函数)比clearEverything(member函数)更受欢迎:它导致WebBrowser class有有较大的封装性。
- 这个论述仅适用于non-member non-friend函数。friends函数对class private成员的访问权利和member函数相同。因此,从封装的角度来看,这里的选择关键并不在于member和non-member函数之间,而在于member和non-member&non-friend函数之间。当然,封装并非我们的唯一考量。当我们考虑隐式类型转换时(条款24),就应该在member和non-member函数间进行选择了。
- 只因为在意封装性而让函数“变成class的non-member”,并不意味着它“不可以是另一个class的member。”例如,我们可以令clearBrowser称为某个工具类(utility class)的一个static member函数,只要它不是WebBrowser的一部分(或者称为其friend),就不会影响WebBrowser的private成员封装性。
在C++中,比较自然地做法是:让clearBrowser成为一个non-member函数,并且位于WebBrowser所在的同一个namespace(命名空间)中。
1 | namespace WebBrowserStuff { |
这不仅仅是看起来自然而已,namespace和class不同,前者可以跨越多个源码文件而后者不可以(namespace提供功能上的切割)。这一点很重要,因为clearBrowser这个函数是个“提供便利的函数”,如果它既不是member或friend。它就没有对WebBrowser的特殊访问权利,也就不能提供其他机能。没有这个函数,用户可以通过自行调用三个函数来清除网页。
这样,一个像WebBrowser这样的class可能会拥有大量的便利函数,某些与书签有关,某些与打印有关,某些则与cookie管理有关。而大多数情况下用户只对其中某一个或某几个感兴趣。
因此,分离它们的最直接的办法就是讲某一个相关函数声明在一个头文件内,将另一个相关函数声明在另一个头文件中且使用相同的命名空间:
1 | //头文件webbrowser.h,这个头文件针对class WebBrowser自身以及WebBrowser核心机能 |
这正是C++标准程序库的组织方式。标准程序库并不是拥有单一、整体、庞大的< C++StandardLibrary >头文件并在其中内含std命名空间内的每一样东西,而是有数十个头文件(< vector >, < algorithm >, < memory> 等等),每个头文件声明std的某些机能。如果一个用户只想使用vector的相关机能,他并不需要去#include <memory>
于是,这就允许用户只对他们所用的那一小部分系统形成编译相依。
然而:这种切割并不使用于class成员函数,因为一个class必须整体定义,不能被分割为片段,因此这种时候该使用namespace。
简而言之,将所有便利函数放在多个头文件内但是隶属于同一个命名空间,意味用户可以轻松扩展这一组便利函数。他们需要做的仅仅是添加更多non-member non-friend函数到这个命名空间中。
- 这一点是class无法提供的另一个性质,因为class定义式对于用户而言是不可扩展的。尽管用户可以派生出新的classes,但是derived classes无法访问base class中被封装(即private)成员,于是此时的“扩展机能”拥有的只是次级身份。
- 此外,并非所有的class都被设计作为base classes。
记住:
- 宁可拿non-member non-friend函数替换member函数。这样做可以增加封装性、包裹弹性和机能扩充性。
- 附:大量的便利函数可以使用相同的命名空间而放在不同的头文件里,这样用户可以根据自己对功能的需求使用头文件,减少编译相依性。其中该有个核心的头文件,提供核心便利函数以及类的实现。
条款24
若所有参数皆需类型转换,请为此采用non-member函数。
令class支持隐式类型转换通常是一个糟糕的选择。但是,这条规则也有例外,最常见的就是建立数值类型时。假设对于一个class用来表示有理数,因此允许整数“隐式转换”为有理数看起来其实是挺合理的:
1 | class Rational { |
接着,对于算数运算的实现,到底应该使用member函数、non-member函数,还是non-member friend函数来实现?首先,对于operator* 的实现,虽然在条款23中指出,将函数放进相关class内又是会与面向对象守则发生矛盾,但先暂时不考虑,先看一下将operator* 写成Rational 成员函数的写法:
1 | class Rational { |
这种设计,可以以很方便的方式实现相乘:
1 | Rational oneEight(1, 8); |
暂时看上去,是可行的。但是如果我们此时用两个不同类型的数据进行相乘——比如,一个Rational和int相乘,就可能会出现问题:
1 | result = oneHalf * 2; //成功! |
为什么会出现这样的错误??当我们以对应的函数形式重写上述两行代码:
1 | result = oneHalf.operator*(2); //成功! |
因此,错误就显而易见了:
- oneHalf是一个内含operator* 函数的class的对象,因此没有问题。
- 整数2并没有对应的class,也就没有operator* 成员函数。
此时,编译器也会尝试在命名空间内或在global作用域内调用以下形式的non-member operator*:
1 | result = operator*(2, oneHalf); //错误! |
在这里,上面第一次有参数2,之所以成功,是因为这里发生了所谓的隐式类型转化(implicit type conversion)。
编译器知道此时确实是传递了一个int,而函数需要的却是Rational;但编译器同时也知道,只要它调用Rational构造函数并赋予传递来的int,就可以构造出适当的Rational出来。换句话说,此时的调用动作在有点像以下的形式:
1 | const Rational temp(2); //根据2建立一个暂时性的Rational对象,2作为分子的参数,分母使用默认值 |
这也只是因为涉及到了non-explicit构造函数,编译器才会这样去实现。如果 Rational的构造函数是explicit,下面两条语句都是错误的:
1 | result = oneHalf * 2; //错误!无法将2转换位Rational |
此时我们可以看到,这就很难让Rational class支持混合式算数运算了。
只有当参数被列于参数列(parameter list)内,这个参数才是隐式类型转换的合格参与者。
1
2
3C++的参数列表有函数的参数列表、宏定义的参数列表、模板的类型参数列表等。
参数列表又可以分为形式参数列表和实际参数列表。
例如: 在定义函数时函数头部所列的就是形式参数列表,在调用函数时所列的就是实际参数列表。
这也就解释了为何第一次可以通过编译,而第二次不可以:因为第一次的调用伴随着一个放在参数列内的参数,第二次则没有(operator*函数没有两个放在参数列参数的版本)。实际上,是由于member函数自动使用*this占用第一个参数。
因此,最终的解决方案就出现了:
- 让operator* 成为一个non-member函数,并允许编译器在每一个实参身上执行隐式类型转换:
1 | class Rational { //并不包含operator*的定义 |
此时,问题得到了顺利的解决,不过仍然有一点需要考虑:
- operator* 是否应该是Rational class的一个friend函数呢?
就这个例子而言,答案是否定的!因为operator* 完全可以由Rational的public接口完成任务,上面的代码也是这样去实现的。(public的构造函数和分子分母的访问函数)于是,这也又引发了一个重要的结论:
- member函数的反面是non-member函数,而不是friend函数。
有很多程序员会有这样的误解,如果一个“与某class相关”的函数不应该成一个member,那么它就一定要称为friend,这是一个错误的理解。无论何时如果可以避免使用friend函数,就应该去避免。虽然friend有其正当性,但下面的结论依然成立:
- 不能只因为函数不该成为member,就自动让它成为friend。可以用class中public的成员函数作为接口,供non-member函数访问class相关的内容。
记住:
如果你需要为某个函数的所有参数(包括this指针所指的哪个隐喻参数)进行类型转换,那么这个函数必须是个non-member。
附:因为隐式转换需要匹配参数列,对于成员函数,第一个参数是*this,使得可能不能匹配转换。
条款25
考虑写出一个不抛异常的swap函数。
swap是一个有趣的函数。
原本它只是STL的一部分,而后则成为异常安全性编程(exception-safe programming)的主体,以及后来用于处理自我赋值可能性(条款11)的一个常见机制。因此,swap的实现是非常重要的。
所谓的swap(置换)两对象的值,指的就是将两对象的值彼此赋予对方。在默认的情况之下,swap动作可以由STL提供的swap算法来实现:
1 | namsespace std { |
只要类型T支持copying(通过copy构造函数和copy assignment操作符来完成),缺省的swap实现代码就会自动置换类型为T的对象,我们并不需要额外的工作。这种缺省的实现比较简单,涉及到了三个对象的复制:
- a复制到temp
- b复制到a
- temp复制到b
但是对于某些类型而言,这些复制动作并没有必要!
其中最主要的即“以指针指向一个对象,内含真正数据”的类型。这种类型最常见的表现形式就是“pimpl手法”(pointer to implementation)如果以这种手法设计Widget class:
1 | class WidgetImpl { |
一旦我们需要置换两个Widget对象的值,我们唯一需要做的就是置换其pImpl指针而已。
但是,缺省的swap并不知道这一点!它不仅会复制三个Widgets,还会复制三个WidgetImpl对象!效率一下子就变得很低了。
我们希望告诉std::swap,当Widgets被置换时,真正应该做的就是置换内部的pImpl指针。而实现这一想法的做法是
- 将std::swap针对Widget特化。
下面的代码是思路的实现,但是无法通过编译:
1 | namsespace std { //这是std::swap针对“T是Widget”的特化版本,并不能通过编译 |
tempate<>:表示它是std::swap的一个全特化(total template specialization)版本;函数名之后的:表示这一特化版本会针对“T是Widget”而设计的;换句话说,一般性的swap template施加于Widget身上便会启用这个版本。
通常而言,我们不能改变std命名空间内的任何东西,但是可以为标准templates(如swap)制造(全)特化版本,使得它专属于我们自己定义的class(例如Widget)。上面的代码也正是这样去实现的。
而之所以上面的代码无法通过编译,是因为:
- 它企图访问a和b内的pImpl指针,但这个指针是private。
因此,一个解决办法则是:
- 令Widget声明一个名为swap的public成员函数,来去做真正的置换工作,然后将std::swap特化,令它调用该成员函数:
1 | class Widget { //与前面相同,唯一的差别就是增加swap函数 |
在上面的代码中,不仅能够通过编译,而且还与STL容器有一致性:
- 所有STL容器也都提供有public swap成员函数和std::swap特化版本(用以调用前者)
然而,假设Widget和WidgetImpl都是class template而并非class,也许可以尝试将WidgetImpl内的数据类型加以参数化:
1 | template<typename T> |
在Widget内放一个swap成员函数就像前面一样简单,但是在特化std::swap时却会遇到问题!
1 | namespace std { |
虽然这样看起来是合理的,然而却并不合法。
当我们企图偏特化(partially specialize)一个function template(std::swap),而C++只允许对class template偏特化,在function template身上偏特化是不可以的,只能全特化。
当我们打算偏特化一个function template时,一般的做法是简单地为它添加一个重载模板:
1 | namespace std { |
般而言,我们是可以重载function template的,但是std是一个特殊的命名空间,因此管理规则也比较特殊:
- 使用者可以全特化std内的template,但是不可以添加新的template(或者class、function以及其他东西)
因此,所谓的“禁止”,其实添加新东西到std里是可以编译的,但是这样的行为是没有明确定义的。如果我们希望程序有可预期的行为,就不要添加任何新东西到std之中。
我们不要添加任何新东西到std内。但我们还是需要一个办法,以提供高效的template特定版本的swap。解决办法是:
- 依然是声明一个non-member swap,让它调用member swap,但不再将那个non-member swap声明为std::swap的特别版本或重载版本。(不需要为std的swap来全特化了,只做一个非成员函数的swap来调用)
因此,为了简化起见,假设Widget的所有相关机能被置于命名空间WidgetStuff,于是:
1 | namespace WidgetStuff { |
于是,当我们置换两个Widget对象,因而调用swap,C++的名称查找法则(name lookup rules;更具体的说是所谓argument-dependent lookup或Kobeig lookup法则)将会找到WidgetStuff内的Widget专属版本。
也可以不使用额外的命名空间,但何必在gloabal命名空间内塞满各式各样的class、template、function等等呢?
然而,虽然上面的做法对于class和class template都行得通,但我们还是应该为class(非template的)特化std::swap。
所以,如果我们想让“class 专属版”的swap在尽可能多的语境下被调用,我们就应该同时在该class所在命名空间内写一个non-member版本以及一个std::swap特化版本。也即为class写三个swap:成员函数版,非成员函数版和std全特化版本。后两个版本调用第一个版本。
上面所说的swap,一直是从我们自身角度去考虑。如果我们从用户的角度来看,对swap进行定义也非常有必要。假设正在写一个function template,其内需要置换两个对象值:
1 | template<typename T> |
此时,调用了swap,但是调用的是哪一个一般化版本?
- std既有的一般化版本?
- 某个可能存在的特化版本?
- 存在的T专属版本而且可能存在与某个命名空间内(非std内)
我们希望的是调用T专属版本,并在该版本不存在的情况下,再去调用std内的一般化版本:
1 | template<typename T> |
一旦编译器看到了对swap的调用,它们便查找适当的swap并加以调用。C++的名称查找发着会确保将找到global作用域或者T所在的命名空间内的任何T专属的swap。
- 如果T是Widget并位于命名空间WidgetStuff内(或者在global空间,如果你是在那实现的话),编译器就会使用“实参取决的查找规则”(argument-dependent lookup)找出WidgetStuff内的swap。
- 如果没有T专属的swap存在,编译器就会使用std内的swap——由using std::swap这条语句,使得这个选择被展现。
- 当然,如果已经针对T将std::swap进行了全特化,这个全特化版本也直接会被优先使用(优先于泛型版本的std::swap)。
因此,令适当的swap被调用是比较容易的。但需要小心的是:不要添加额外的修饰符,这样会影响C++挑选适当的函数:
1 | std::swap(obj1, obj2); //错误的swap调用方式 |
上面这个举动,会迫使编译器只认std内的swap,因而不再可能调用一个定义于其他地方的适当T专属版本。
到目前为止,这三部分已经讨论了:
- default swap
- member swap
- non-member swap
- std::swap 特化版本
- swap的调用
因此,做一个总结:
- 首先:如果swap的缺省实现(std版本)对我们的class或class template提供可接受的效率,那么我们并不需要做其他的事情。
- 其次:如果swap的缺省版本效率不足(基本上就是因为class或者class template使用了某种pimpl手法),则三部曲:
- 1.提供一个public swap成员函数,让它高效地置换对应类型的两个对象值。这个函数绝不能抛出异常(其他函数是调用它)。
- 2.在我们的class或template所在的命名空间内(最好是用特殊空间,但在global空间也不会编译错误)提供一个non-member swap,并令它调用上述swap成员函数。
- 3.如果我们正在编写一个class(而非class template),就需要为我们的class特化std::swap,并令它调用我们的swap成员函数。否则不需要这一步。
- 最后,如果我们调用swap(在最高的层次调用),请确定包含一个using声明式,以便让std::swap在我们的函数内部可以曝光可见,然后不加任何namespace修饰符,直接去调用swap。
还有一点:
- 成员版本的swap绝对不可以抛出异常!
原因在于,swap的一个最好的应用就是为了帮助class(和class template)提供强烈的异常安全性(exception-safety)保障。当然,这一约束只施行于成员版!不可实施于非成员版(也不必,因为非成员版就是调用成员版),因为swap缺省版本是以copy构造函数和copy assignment操作符为基础的,在一般情况下是允许抛出异常的。
因此,当我们写一个自定义版本的swap时,往往需要提供以下两点:
- 高效置换对象值的办法
- 不抛出异常
一般而言,上面这两个特性是连在一起的,因为高效率的swap几乎总是基于对内置类型的操作(例如pimpl首发的底层指针),而内置类型上的操作绝不会抛出异常。
记住:
- 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
- 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于classes(而非templates),也请全特化std::swap。
- 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“命名空间资格修饰”。
- 为“用户定义类型”进行std templates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。
条款26
尽可能延后变量定义式的出现时间。
两个理由:
- 定义好的变量可能没有使用,这样就多了一次构造和析构。这个没有使用可能是由于提前返回、抛出异常造成的。所以,尽可能延后变量的定义直到要使用为止;
- 有时变量先定义了但还不知道初值,而是等后面再赋值。这对于大多数类(可以说除了内置类型以外的其他类)来说是低效的,因为定义再赋值使用一次(默认)构造和一次赋值,效率往往比一次(带参)构造(直接在构造时指定初值)来得差。所以,尽可能延后变量定义直到确定初值为止。
关于第二点,假如在一个循环中使用一个变量,到底应不应该在循环体中定义呢?
- 在循环体外定义:1个构造函数+1个析构函数+n个赋值
- 在循环体内定义:n个构造+n个析构
如果一个赋值的成本低于一个构造+一个析构,那么往往选择第一种做法,尤其当n很大的时候。否则第二种做法好。此外做法1造成变量的作用域比做法2要大,有时对程序的可理解性和易维护性造成冲突。因此除非:
- 赋值比构造+析构成本低;
- 正在处理代码中效率高度敏感的部分。
否则应该使用做法2。
记住:
尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
条款27
尽量少做转型动作。
C++的设计目标之一是,保证“类型错误”绝对不可能发生。理论上如果你的程序很“干净地”通过编译,就表示它并不企图在任何对象身上执行任何不安全、无意义、愚蠢荒谬的操作。这是一个极具价值的保证,可别草率的放弃它。
不幸的是,转型破坏了类型系统。那可能导致任何种类的麻烦,有的容易辨识,有些非常隐晦。C、java、c#语言中可能转型是必要的、无法避免的,相比于C++也比较不那么危险。但是C++中,应该尽量少的做转型,C++中使用转型比较危险,应该尽量将转型动作使用不转型的手法给化解掉。
有三种转型的语法:
1 | //形式一:C语言风格的转型语法: |
1 | //形式三:C++风格的转型语法 |
注意:形式一、二并无差别,统称旧式转型,形式三称为新式转型。
新式转型的优点:
- 在代码新式转型容易被识别出来(无论是人工识别还是使用工具如grep),因而简化“找出类型系统在哪个点被破坏”的过程(简化找错的过程)。
- 各种转型动作的目标越窄化,编译器越能判断出出错的运用。例如:如果你打算将常量性去掉,除非使用新式转型的const_cast否则无法通过编译。
旧式转型的唯一适用场景( 对于作者本人来说的唯一):
唯一使用旧式转型的时机是,当调用一个explicit构造函数将一个对象传递给一个函数时。例如:
1 | class Widget{ |
从某个角度来说,蓄意的“对象生成”动作不怎么像“转型”,因此没使用新式转型。但是,其他情况下(或者所有情况下),即使觉得旧式转型合理,也最好使用新式转型。
容易产生的误解:请不要认为转型什么都没做,其实就是告诉编译器把某种类型视为另一种类型。实际上,任何一种转型动作往往真的令编译器额外地编译出运行期间执行的代码。例如将int转型为double就会发生这种情况,因为在大部分的计算器体系结构中,int的底层表述不同于double的底层表述。
转型动作导致编译器在执行期间编译出不同的码的另外一个例子:单一的对象可能拥有一个以上的地址(例如:”以base*指向它”时的地址和”以Derived*指向它”时的地址不同,因为这时会有一个偏移量在运行期间施加在Derived*身上,用以取得正确的base*的指针值。偏移量是因为:类成员本身的位置是确定的,但是对于派生类、基类来说,指向的起始位置不一样)。实际上一旦使用多重继承,这事几乎一直发生。即使在单一继承中也可能发生。
- 有了偏移量这个经验后,我们也不能做出“对象在C++中如何布局”的假设。因为对象的布局方式和它们的地址计算发式随着编译器的不同而不同,这就以为着写出”根据对象如何布局”而写出的转型代码在某一平台上行得通,在其它平台上则不一定。很多程序员历经千辛万苦才学到这堂课。
转型动作容易写出似是而非的代码:很多框架都需要在派生类的virtual函数中第一个动作就调用基类的版本的函数:
1
2
3
4
5
6
7
8
9
10
11
12
13class Window{
public:
virtual void onResize(){...}
...
};
class SpecialWindow:public Window{
public:
virtual void onResize(){
static_cast<Window>(*this).onResize();//调用基类的实现代码
... //这里进行SpecialWindow的专属行为.
}
...
};上述代码看着似乎合情合理,但是实际却是错误的。错在转型语句。为什么错呢?
首先它确实执行了多态,调用的函数版本是正确的,但是由于做了转型,它并没有真正作用在派生类对象身上,而是作用在了派生类对象的基类部分的副本身上,改动的是由于转型产生的那份基类副本(因为先产生副本,然后调用副本的函数)。如果后面的代码是对派生类更改的话,导致的最终结果就是:当前对象的基类部分没有被改动,但是派生类部分却被真实地改动了。
解决的方法是拿掉转型,直接调用:
1
2
3
4void SpecialWindow::onResize(){
Window::onResize(); //此时才是真正的调用基类部分的onResize实现.
... //同上
}
关于dynamic_cast,首先要有一个认识,就是dynamic_cast的实现版本执行速度相当的慢。尤其是在深度继承和多重继承中,速度更慢。
- 何时需要dynamic_cast:通常当你想在一个你认定为derived class对象上执行derived class操作函数时,但是你的手上只有一个指向base 的指针或引用时,你会想到使用dynamic_cast进行转型
有两个一般性做法可以避免使用dynamic_cast(并非总是可以,但许多情况下可行):
- 方法一:如果有多个对象,使用容器(如vector),并在其中存储直接指向derived class对象的指针(通常是智能指针),这样就避免了上述需求。
- 方法二:在base class内提供virtual函数做你想对各个派生类想做的事情。这样可以使得你通过base class 接口处理“所有可能之各种派生类”。因为virtual函数使得编译器不识别指针类型来选择函数,而是看运行期实际的对象类型来选择函数。
一连串dynamic_cast的代码又大又慢,而且基础不稳,因为每次继承体系一有改变,所有这种代码必须再次进行检查看看是否需要修改。例如假如新的派生类,就要加新的分支。这样的代码应该使用“基于virtual函数调用”的东西取而代之。
最后,完全不用转型是不切实际的。但是我们应该尽量避免转型。就像面对众多蹊跷可疑的构造函数一样,我们应该尽可能隔离转型动作,通常是把它隐藏在某个函数内,函数的接口会保护调用者不受函数内部任何肮脏龌龊的动作的影响。
记住:
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需要将转型放进他们自己的代码内。
- 宁可使用c++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。
条款28
避免返回handles指向对象内部成分。
reference、指针、迭代器系统都是所谓的handles(号码牌,用来获得某个对象)。函数返回一个handle而被用户得知,随之而来的便是“减低对象封装性”的风险。它也可能产生虽调用const成员函数却造成对象状态被更改的风险。
1 | class Point{ |
上述代码便是自相矛盾的一个例子。point 类是一个代表点的类,RectData代表一个矩形的结构,Rectangle类则代表一个矩形,该类能够返回表示矩阵的左上和右下的两个点。
由于这两个函数为const的,因此所要表达的意思就是,返回矩阵的两个点,但是不能修改他们。但是又由于返回的是点的reference形式,因此通过reference,实际是可以改变返回的点的数据的(可以作为左值修改)。因此,造成了自相矛盾。问题的原因就是,函数返回了handle。
这进而说明:
- 成员变量的封装性最多等于“返回其reference”的函数的访问级别。即使数据本身被声明为private的,但是如果返回他们的reference的函数是public的,那么数据的访问权限就编程public了。
- 如果const成员函数传出一个reference(返回外部对象的引用),后者所指数据又不在自身对象内,那么这个函数的调用者可以修改此数据。(这是 bitwise constness 带来的后果。)
- 如果返回的是对象自身的数据,const的限制会强制使得数据成员类型为const(这样使得数据不能改变),这样就不能返回该数据的引用,因为类型不匹配了。
上述代码的改进版本:在返回handles 的成员函数前加const。这便解决了自相矛盾问题:
1 | class Rectangle{ |
上述代码在其他场景下可能存在的问题:虚吊问题。所谓虚吊问题,就是指针指向了一个不复存在的对象。最常见的问题来源就是函数返回值,例如,某个函数返回GUI对象的外框,是一个矩形形式:
1 | class GUIObject{}; |
boundingBox 函数传入一个GUI对象,它返回一个GUI的外框,即是一个矩形,然后获取这个矩形的右下方的点,并使用一个指针指向它。而函数的返回值是一个临时的对象(by value),即这个矩形是一个临时的对象,当这个语句执行结束后,矩形对象被销毁,因此其内部的点也被销毁,而此时pUpperLeft指向了一个被销毁的点。就形成了所谓的虚吊。
这就是为什么函数如果返回一个handle代表对象内部成分总是危险的原因。不论这个handle是个指针或迭代器或引用,也不论这个handle是否为const(指返回值),也不论返回handle的成员函数是否为const。这里的唯一关键是,有个handle被传出去了,一旦如此你就是暴露在“handle比其所指对象更长寿”的风险下。
这并不意味着绝对不可以让成员函数返回handle,有时候必须返回handle,例如vectoer的operator[],operator=返回容器内部的数据,然而着只是少数的例外。一般来讲,返回整个对象的引用来提高效率(不破坏封装,且虚吊是容易控制的,你清除你的类什么时候销毁),而非对象内部成分的引用。
记住:
避免返回handles(包括reference、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”的可能性降至最低。
条款29
为“异常安全”而努力是值得的。
当异常被抛出时,”异常安全”函数有两个条件:
- 不泄露任何资源:从堆中申请的资源应该确保被释放
- 不允许数据败坏:函数不能对数据修改到一半而抛出异常以致数据被破坏。
解决”不泄露任何资源”很容易,只要使用资源管理类(如shared_ptr,见条款13)即可,”不允许数据败坏”(如delete掉数据,但程序异常没有在new回来,或是数据记录先修改了,但是由于异常,实际上并不需要修改)是主要考虑的问题。在解决问题前,需要知道三个层次的保证。
异常安全函数提供以下三种层次的保证:
- 基本保证:如果异常被抛出,程序内任何事物仍然保持在有效状态下,没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态(例如所有class约束条件都继续得到满足)。然而程序的现实状态很难预料,可处于任何合法状态:客户必须确认对象的状态。
- 强烈保证:如果异常被抛出,程序状态不改变。”如果函数成功,就是完全成功;如果函数失败,程序会回复到’调用函数之前’的状态”。
- 不抛掷(nothrow)保证:程序绝不抛出异常且总是能够完成承诺的内容。
以上三种层次的保证逐渐增强。
一般来说,实现不抛掷保证不太现实:
- 如果程序要保证nothrow,那么就要保证它所调用的所有函数也nothrow;
- 任何使用动态内存的代码(如STL容器)都有可能抛出无法找到足够内存而产生的ban_alloc异常。因此,提供异常安全保证通常从基本保证和强烈保证中选择。
要实现异常安全的两个条件,一般有以下策略:
- 资源管理对象的使用以确保不泄露堆中资源;
- 对函数语句顺序的细致规划以阻止数据的败坏。一般来讲:不要为了表示某件事将要发生而改变对象状态,除非真的发生了(例如对计数累加在对象生成之后,而非生成之前)。
- 使用”copy and swap”策略:为打算修改的对象做出一份副本,然后在副本上进行修改,若函数抛出异常,只有副本的数据发生败坏;若修改成功执行,调用swap函数(保证不抛异常)进行置换。这是解决数据败坏的有效途径。
copy-and-swap有以下限制:
- 使用”copy and swap”策略构造临时对象,因此要付出额外的资源和效率负担。
- 要使用swap函数,必须保证swap函数不抛出任何异常(见条款25)。
- 使用”copy and swap”策略不保证整个函数有强烈的异常安全性,如果函数内调用其它函数,会产生”连带影响”。比如调用两个函数,都保证强烈的异常安全,第一个成功,第二个异常。整个函数还是由于第一个函数调用成功而改变了,虽然是异常安全的,但不是强烈的。
异常安全符合短板原理:一个软件系统内只要有一个函数不符合异常安全性,整个软件系统就不具备异常安全性,没有所谓的局部安全性。
记住:
- 异常安全函数即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
- “强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备实现意义。
- 函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。
条款30
透彻了解inlining的里里外外。
inline函数是特殊的函数,它有宏的优点,却克服了宏的缺点(条款2)。inline函数可以免除函数调用所招致的额外开销,但你实际获得的好处可能比你想象的还多,编译器会对inline函数本体执行语境相关最优化。
inline 函数背后的机制是:将对此函数的每一个调用都用函数本体替换之。这样无疑会增加产出码的大小。在内存比较小的机器上,不宜过多使用inline函数。即使使用虚拟内存,也会导致额外的换页行为(paging),降低指令高速缓存装置的击中率(instruction cache hit rate),以及伴随而来的效率损失。如果inline函数本体很小,编译器对函数本体产出的码可能比函数调用所产出的码更小;如果这样,那么将函数inlining确实会减小目标码和提高高速指令高速缓存装置的击中率。
在函数前面加上inline关键字不是强制这个函数变为inline函数,这只是向编译器提一个申请。这个申请有时是隐喻的,例如将函数定义在class内。如果把friend函数定义在class内,那么它们也将隐喻声明为inline。
1 | //SetNumber,GetNumber 函数为隐式 inline,Try 函数为显式 inline。 |
明确声明为inline函数的的做法是在其定义式前加上关键字inline。例如标准的max template(来自)是这样的:
1 | template<typename T> |
inline函数和templates通常都被定义于头文件。这不是巧合。
- inline函数通常一定被置于头文件内,大多数建置环境(build environment)在编译过程中进行inlining,将一个“函数调用”替换为“被调用函数的本体”,在替换时需要知道这个函数长什么样子(因此必须要提前定义,所以放头文件里)。有的环境可以在运行期间完成inlining,但一般是例外。Inlining在大多数C++中是编译期行为。
- Templates通常也放置在头文件,因为它一旦被使用,编译器为了将它具体化,也需要知道它长什么样子(因此必须要提前定义,所以放头文件里)。有些编译环境可以在链接期间才执行template具体化,但是编译期间完成的更常见。
实际上templates和inlining无关,对于template,如果inline则所有的template的具体实现的函数都会被inline。如果没有理由要求都inline,则应该避免将这个template inline,否则会招来代码膨胀的成本。
大部分编译器拒绝太过复杂的inlining函数(例如有循环或递归)。virtual函数也不能是inline函数,因为virtual函数是直到运行时才确定调用哪个函数,而inline是执行前将调用动作替换为函数本体。
所以表面上是inline函数,实际上未必是,很大程度上取决于编译器。大多数编译器提供了一个诊断级别:如果无法将你要求的函数inline化,会给你一个警告信息。
编译器将inline函数调用替换为inline函数本体的同时,还是可能会为该函数生成一个函数本体。如果程序要取某个inline函数的地址,编译器通常必须为此函数生成赢outlined函数本体。如果inline函数本体不存在,自然就不会有这个函数的地址。但是,通过函数指针调用inline函数,这时inline函数一般不会被inlined。
1 | inline void f(){……}//假设编译器会inline函数f |
就算你未使用函数指针,程序有时也会使用。例如,编译器会生成构造函数和析构函数的outline副本,这样就可以获得这些函数的指针,在array内部元素的构造和析构过程中使用。
实际上,把构造函数和析构函数做为inline函数未必合适,表面上看并不可以看出原因。考虑下面Derived class构造函数:
1 | class Base{ |
class Derived的构造函数不含任何代码,应该是inlining函数的绝佳候选。但实际上未必。C++对于“对象被创建和销毁时都发了什么事”做了各种保证。例如,当你创建一个对象,base class和derived class的每一个成员变量都会被自动构造;当你销毁一个对象,会有反向的析构过程。
这是正常运行时的情况,但是如果对象在构造期间有异常被抛出,那么该对象已经构造好的那一部分应该自动销毁。当然,这是编译器负责的事情,但是编译器是怎么实现的呢?那就是编译器在你的程序中插入了某些代码,通常就在构造函数和析构函数内。我们可以想象一下,那个空的构造函数到底应该是怎么样的:
1 | //编译后实际上可能的(或大致上的)构造函数 |
上面代码并不是编译器生成的,编译器生成的应该会更加精致复杂,处理异常也没这么简单。但这也足够反应空白的Derived构造函数提供的行为。不论编译器怎么优化,Derived构造函数都会调用base class的构造函数和其成员变量的构造函数,而那些调用(它们自身也可能被inlined)会影响编译器是否对此空白函数inlining。这个道理同样适用于Base构造函数,也同样适用于析构函数。
上面的原因叙述的是:也许一个空的构造函数、析构函数并不含什么代码、未做什么事情,但实际上编译后会有许多代码。然而还有另一个原因,如果其他函数经常调用这个类,那么那个函数每构建一个对象,就inline一份构造函数。例如:如果string构造函数恰巧被inlined了,那么Derived的构造函数也将获得五份“string构造函数代码”副本,这可能是巨大的代码膨胀。
因此,必须慎重考虑构造函数、析构函数是否该被inline。
程序员还要考虑将函数声明为inline带了的其他影响:inline函数无法随着程序库的升级而升级。例如,fun是个inline函数,客户将fun编进其程序中,一旦程序库设计者升级程序库,所有用到函数fun的客户端程序多必须重新编译。但是如果fun是non-inline的,客户端只需重新连接即可;如果是动态链接库,客户端甚至感觉不到程序库升级。还有一个影响就是调试。大部分调试器无法调试inline函数,因为你不能再一个不存在的函数内设立断点。
这就使得我们在使用inline时更加慎重,一开始先不要将任何函数声明为inline,后面再手工优化代码。有一个80-20经验法则:平均而言,一个程序往往将80%的执行时间花费在20%的代码上。因此,作为一个开发者,你的目标是找出这可以有效增进程序整体效率的20%代码,用inline或其他方法将它瘦身。但除非选对目标,否则一切都是虚功。
记住:
- 将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
- 不要只因为function templates出现在头文件,就将它们声明为inline。
条款31
将文件间的编译依存关系降至最低。
假如你修改了C++ class实现文件,修改的仅仅是实现,而没有修改接口,而且只修改private部分。此时,重新构建这个程序时,会发现整个文件、以及用到该class 的文件都被会被重新编译和连接,这不是我们想要看到的。
问题出在C++没有把关于接口与实现相分离这件事做好。C++ 的class 的定义式中不仅定义了接口,还定义了实现细目(成员变量)。例如:
1 | class Person{ |
当编译器没有取得实现代码所需要的class string,Date和Address的定义式时,它无法通过编译。
它所需要的这样的定义式往往由#include <>提供(里面有class string,Date和Address的实现代码)。例如本例中需要:
1 |
如果这些头文件中(或头文件所依赖的头文件)的一个的实现被改变了,那么每一个含入或用到Person class的文件都得重新编译。这样的连串编译依存关系会对许多项目造成难以形容的灾难。
C++ 为什么坚持将实现细目置于class定义式中而不如下述这样做,以实现接口与实现分离呢:
1 | namespace std { class string;} // 前置声明(不正确) |
上述设想不成立,原因有两条:
第一:string并不是一个class,他是一个typedef(定义为basic_string)。因此上述针对string做的声明并不正确;正确的声明比较复杂,因为涉及额外的template。退一步讲,你本来就不应该尝试手工声明标准库程序的一部分,你应该仅仅使用适当的#include完成目的。其实标准头文件这也不是编译的瓶颈,也有解决的方法。例如:你可以值改变你的接口设计,避免使用标准头文件的非法的#include。
第二:问题的关键是:编译器必须在编译期间知道对象的大小。例如:下述程序中,当编译器看到x时,由于知道它是int类型的,也就知道需要为它分配多大的空间。但是当编译器看到自定义的类Person对象p时,编译器必须看到Person的类定义才能知道为p对象分配多大的内存。如果class中没有实现细目,也就是连一个成员变量都没有,那么编译器就无法确定为其分配多大内存(接口即函数是不占内存的)。
1
2
3
4
5
6int main()
{
int x;
Person p(params);
...
}
使用指针,可以解决这个问题,因为一个指针的大小是确定的,如果在Person类使用指针指向成员对象,内存就可以确定下来了。
方法一Handle classes:基本的思想就是:将对象的实现细目隐藏到一个指针(通常是一个智能指针)背后。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class PersonImpl;
class Date;
class Address;
class Person{
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name()const;
std::string birthDate() const;
std::string address()const;
...
private:
std::tr1::shared_ptr<PersonImpl> pImpl; // 指向实现物的指针
};上述程序中,将原本的Person 类写成两个部分,接口那部分是主要的部分,其中含了一个智能指针,指向实现细目。而实现细目另外定义了一个类:PersonImpl。这种设计手法被称为:pimpl idiom。
注意:pimpl 指的是 pointer to implementation。这种class内的指针往往被称为:pImpl指针。上述class的写法 往往被称为handle class。
这样做使得接口与实现分离。即:Person的客户与 [Date、Address、以及Person的实现细目]就分离了。这带来的好处是:
- 这些class的修改,都不需要Person客户进行重新编译(它们不会导致Person改变,指针大小是确定的)。
- 而且由于客户无法看到实现细目,也就不能写出由这些实现细目所决定的代码。
这个分离的关键在于以“声明的依存性”替换“定义的依存性”,这正是编译器依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。
其他情况也来自于这个设计策略:
- 如果用 object reference 或 object pointer 可以完成任务,就不要用 objects。可以只靠声明式定义出指向该类型的 pointer 和 reference;但如果定义某类型的 objects,就需要用到该类型的定义式(要分配内存)。
- 如果能够,尽量以 class 声明式替换 class 定义式。当你声明一个函数而它用到某个 class 时,你并不需要该 class 的定义式,纵使函数以 by value 方式传递该类型的参数(或返回值)亦然。
- 真正的定义式在函数调用前曝光即可。因为用户不太可能会用到所有的函数,客户只需要引入需要的函数的class定义式的头文件即可。
- 此外这里没有谈到函数的定义,因为一般头文件进行函数的声明,而定义在cpp文件。声明是在编译时处理的而定义在链接时处理,这里讨论的问题不涉及链接。
- 补充:正常情况下,应该是:函数声明在.h中,函数定义在.cpp中。 原因: (1)如果你对这个函数体进行了修改,那么只会重新编译相应的.cpp文件,在进行大工程编译时,会大大缩短编译时间。 (2)即便是对整个工程进行重新编译,在进行代码扩充阶段时,定义在.h中的情况,要比定义在.cpp的情况浪费更多的内存和编译时间。 2. 纯粹从理论角度来说,函数可以定义在.h中,但一定要加上#ifdef – #define – #endif对代码块进行包裹,避免出现重复定义的链接错误。
- 为声明式和定义式提供不同的头文件。两个头文件应该保持一致性,其中一个头文件发生改变,另一个就得也改变。一个内含了class 接口的定义,另一个仅仅内含声明。比如客户要使用Date,那么程序库作者应该提供声明的头文件和定义的头文件,客户引入声明的头文件来声明函数而不需要自己前置声明Date类。头文件名称如
<datefwd.h>
,这个命名方式取法C++标准程序库头文件。- 因而,这个条款也使用于template,因为template虽然一般定义式也写在头文件,但有的定义式也在非头文件内,这样就可以把只含声明式的头文件提供给template。
方法二:Interface classes手法,基本思想是:令Person class 成为一种特殊的abstract base class (抽象基类),称为interface class。
这样的类通常:没有成员变量,也没有构造函数,只有一个virtual 的析构函数以及一组pure virtual 用来描述接口。对于 Interface class 的客户,必须以接口的指针或者引用来编写应用程序。因为不可能针对内含 pure-virtual 的函数的 abstract class 具现出实例。就像 Handle class的客户那样,除非 Interface class 的接口被修改,否则客户不需要重新编译。
由于这样的类往往没有构造函数,因此通过工厂函数或者virtual构造函数创建,他们返回指针,指向动态分配对象所得的对象,这样的对象支持interface class的接口,这样的函数在interface class往往被声明为 static,例如
1 | class Person{ |
客户使用他们像这样:
1 | std::string name; |
当然支持 interface class 接口的那个具象类(concrete classes)必须被定义出来,而真正的构造函数必须被调用。假设有个 derived class RealPerson,提供继承而来的 virtual 函数的实现:
1 | class RealPerson : public Person{ |
有了 RealPerson 之后,写出 Person::create 就真的一点也不稀奇了:
1 | std::tr1::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address& addr) |
一个更现实的 Person::create 实现代码会创建不同类型的 derived class 对象,取决于诸如额外参数值、独自文件或数据库的数据、环境变量等等。
RealPerson 示范实现了 Interface class 的两个最常见机制之一:从 interface class 继承接口规格,然后实现出接口所覆盖的函数。
handle classes 和 interface classes 解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性。但也会带来开销:
handle classe:
成员函数必须通过 implementation pointer 取得对象数据。那会为每一次访问增加一层间接性。每个对象消耗的内存必须增加一个 implementation pointer 的大小。 implementation pointer 必须初始化指向一个动态分配的 implementation object,所以还得蒙受因动态内存分配儿带来的额外开销。
Interface classe:
由于每个函数都是 virtual,必须为每次函数调用付出一个间接跳跃。此外 Interface class 派生的对象必须内含一个 vptr(virtual table pointer)。
在程序开发过程中使用 handle class 和 interface class 以求实现码有所改变时对其客户带来最小冲击。
而当他们导致速度和/或大小差异过于重大以至于 class 之间的耦合相形之下不成为关键时,就以具象类(concrete class)替换 handle class 和 interface class。
记住:
- 支持“编译依存性最小化”的一般构想是:相依于声明式,而不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
- 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及templates都适用。
条款32
确定你的public继承塑膜出is-a关系。
以C++进行面向对象编程,最重要的一个规则是:public inheritance意味着”is-a”的关系。
如果令 class D(”Derived”)以 public 形式继承 class B(”Base”),便是告诉C++编译器说,每一个类型为D的对象同时也是一个类型为B的对象。意思是B比D表现出更一般化的概念,而D比B表现出更特殊化的概念。
以一个具体例子来说明:
1 | class Person {……}; |
由生活经验可以知道,每个学生都是人,但是并非每个人都是学生。这就是这个继承体系的主张。可以预期,对人成立的每件事,对学生也都成立。但是对学生成立的事对人未必成立。在C++中,任何函数如果期望接受类型为Person对象的实参(或pointer to person,或reference to person),也都可以接受一个类型为Student对象的实参(或pointer to student,或reference to student)。
1 | void eat(const Person& p); |
当然,上面正确的前提是以public继承,如果以private继承,意义将完全不同(条款39),protected继承也是一样。
有时public和is-a之间的关系会误导我们。例如,企鹅(penguin)是一种鸟,这是事实;鸟可以飞,这也是事实。如果以C++描述这层关系:
1 | class Bird{ |
但是我们知道,企鹅不会飞,这个是事实。这个问题的原因是语言(英语)不严谨。当我们说鸟会飞时,我们表达的意思是一般的鸟都会飞,并不是表达所有的鸟都会飞。我们还应该承认一个事实:有些鸟不会飞。这样可以塑造一下继承关系:
1 | class Bird{ |
这样的设计能更好的反映我们真正要表达的意思。但是这时,我们仍未完全处理好这些鸟事。例如,如果你的系统不会区分鸟会不会飞,你关心的是鸟啄和鸟翅,这样的话,原先的“双class继承体系”更适合你的系统。并不存在完美设计,具体问题要具体讨论。
还有一个方法来处理“所有鸟都会飞,企鹅是鸟,但企鹅不会飞”这个问题,我们可以在企鹅类重新定义fly函数,让它在产生一个运行期错误:
1 | void error(const std::string& msg);//输出错误 |
这里前面的解决方法不同,这里不说企鹅不会飞,当你说企鹅会飞时,会告诉你这是一个错误。但是这种解决方法之间有什么差异?从错误被侦测出来的时间来看:
- 第一种解决方法“企鹅不会飞”这个限制条件在编译期强加事实;
- 第二个解决方法,“企鹅会飞是错误”是在运行期检测出来的。
- 第一种解决方法更好,条款18说过,好的接口可以防止无效的代码通过编译,相比之下,我们应该选择在编译期来找出这个问题。
在考虑一个例子,基础几何我们都学过,那么正方形和矩形的关系有多么复杂呢?先看下面这个例子:class Square应该以public形式基础class Rectangle吗?我们都知道正方形是特殊的矩形,如果以public继承
1 | class Rectangle{ |
上面的assert结果肯定为真,因为makeBigger只是改变了r的宽度,高度并未改变。
1 | class Square:public Rectangle{……}; |
那么现在肯定是有问题了。因为第一个assert时,长和宽多相等;之后增加了宽度,长度不变;到了第二个assert时,长和宽还相等。
前面说过,以public继承,能够施行于base class对象身上的每件事,都可以施行于derived对象身上。在正方形和矩形的例子(还有一个类似的是条款38的sets和lists),这个结论行不通,所以——用public继承塑模它们之间的关系不正确。我们应该记住:代码通过编译不表示就可以正确运行。
is-a只是存在class继承关系中的一种,还有两个继承关系式has-a(有一个)和is-implemented-in-terms-of(根据某物实现出)。这些关系将在条款38和条款39讨论。在设计类时,应该了解这些classes之间的相互关系和相互差异,在去塑模类之间的关系。
记住:
“public 继承”意味着 is-a。适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个base class对象。
条款33
避免遮掩继承而来的名称。
这里说的名称,是和继承以及作用域有关。先看一个和作用域有关的例子:
1 | int x;//global |
这个cin是给local变量x赋值,而不是global变量x,因为内层作用域名称会遮掩外围作用域名称。当编译器在someFunc作用域内遇到名称x时,它在local作用域内查找是否有这个变量定义,如果找不到就再去找其他作用域。这个例子中的变量x类型不同,local的是double类型,而global的是int类型;但是这个并不要紧,C++的名称遮掩规则(name-hiding rules)所做的唯一事情就是:遮掩名称,至于类型并不重要。
现在来看一下继承。当一个derived class成员函数内指涉(refer to) base class内的某物(成员函数、成员变量、typedef等)时,编译器可以找到所指涉的东西,因为derived class继承了声明在base class内的所有东西。derived class的作用域被嵌套在base class作用域内。
1 | class Base{ |
这个例子中既有public,又有private。成员函数有pure virtual、impure virtual和non-virtual,这是为了强调我们讨论的是名称,和其他无关。这个例子是单一继承,了解单一继承很容易推断多重继承。假设在derived class的mf4内调用mf2
1 | void Derived::mf4() |
当编译器看到mf2时,要知道它指涉(refer to)什么东西。首先在local作用域内(即mf4覆盖的作用域)查找有没有名称为mf2的东西;如果找不到,再查找外围作用域(class Derived覆盖的作用域);如果还没找到,再往外围找(base class覆盖作用域),在这里找到了。如果base内还是没找到,之后继续在base那个namespace作用域内找,最后往global作用域找。
下面把这个例子变得稍微复杂一点,重载mf1和mf3,且添加一个新版mf3到Derived中。
1 | class Base{ |
因为以作用域为基础的“名称遮掩规则”,base class内所有名称为mf1和mf3的函数都被derived class内的mf1和mf3函数遮掩掉了。从名称查找观点来看,Base::mf1和Base::mf3都不再被Derived继承。
1 | Derived d; |
条款32中说过public继承是”is-a”关系,如果使用public继承而又不继承那些重载函数,就是违反了”is-a”关系。要想上面的函数调用都正确,可是使用using声明
1 | class Base{ |
这样,下面的调用都不会出错了。
1 | Derived d; |
如果你继承base class,且加上重载函数;你又希望重新定义或覆写其中一部分,那么要把被遮掩的每个名称引入一个using声明。
public继承暗示base和derived class之间是一种”is-a”关系,这也是上述using声明放在derived class的public作用域内的原因:base class内的public名称在publicly derived class内也应该是public。
如果想要private继承Base,而Derived唯一想继承的是时Base内mf1无参数的那个版本,using声明在这派不上用场,因为using声明会使继承而来的某个名称所有函数在derived class都可以见。这样的实现需要一个不同的技术,一个简单的转交函数(forwarding function):
1 | class Base{ |
上面所述都是不含templates。当继承结合templates时,又会面临“继承名称被遮掩”,关于以“角括号定界”的东西,在条款43讨论。
记住:
- derived classes内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。
- 为了让被遮掩的名称再见天日,可使用using声明式或转交函数。
条款34
区分接口继承和实现继承。
public继承的概念,由2部分构成:函数接口(function Interface)继承和函数实现(function implementation)继承。我们在设计class时:
- 有时希望derived class只继承函数的接口(即函数声明);
- 有时候希望derived class继承函数接口和实现,但又覆写它们所继承的实现;
- 又有时候希望derived class同时继承函数的接口和实现,但不覆写任何东西。
为了更好理解上述差异,用一个绘图程序来说明:
1 | class Shape{ |
Shape中有pure virtual函数,所以它是个抽象类,不能创建Shape对象,但Shape强烈影响了所有以public继承它的derivedclass,因为成员函数的接口总会被继承。条款32所说,public继承意味着is-a。
Shape class有三个函数。draw
是pure virtual函数;error
是impure pure函数;objectID
是non-virtual函数。
pure virtual函数有两个特点:它们必须被继承了它们的具体class重新声明,而且在抽象class中通常没有定义。这也就是说明:
- 声明一个pure virtual函数的目的是为了让derived class只继承函数接口。
但是我们可以为pure virtual函数提供定义,即为Share::draw
提供一份实现,C++不会发出怨言,但是调用这个函数的唯一途径是调用时指明其class名称:
1 | Shape* ps=new Shape; |
impure virtual函数和pure virtual函数有所不同,derived classes继承其函数接口,但impure virtual函数会提供一份实现代码,derived class可能覆写(override)它。
- 声明简朴的(非纯)impure virtual函数的目的,是让derived classes继承该函数的接口和缺省实现。
考虑Shape::error这个例子,error接口表示,每个class都必须支持一个遇上错误时可调用的函数,但每个class可以自由处理错误。如果某个class不想针对错误做出特殊行为,可以退回到Shape class提供的缺省错误处理行为。也就是说Shape::error的声明式告诉derived class设计者:你必须支持一个error函数,但如果你不想自己写,可以使用Shape class提供的缺省版本。
如果允许impure virtual函数同时指定函数声明和函数缺省行为,有可能造成危险。考虑一个具体例子,一个XYZ航空公司设计飞机继承体系,该公司有A型和B型两种飞机,都以相同方式飞行,可以考虑这样设计继承体系:
1 | class Airport{ ……}; |
因为不同型飞机不需要不同的fly实现,Airplane::fly被声明为virtual;为了避免在ModelA和ModelB重新撰写相同代码,缺省的飞行行为有Airplane::fly提供。
上面这种设计方式是典型的面向对象设计。两个classes共享的性质放到base class中,然后被这两个class继承。这样可以突出共同性质,避免代码重复。
但是如果XYZ要购买一种新型飞机C,C和A、B飞行方式不同。XYZ公司程序员给C型飞机添加了一个class,但是没有重新定义fly函数,然后又写了如下代码
1 | Airport PDX();//某个机场 |
这会造成大灾难,因为程序员试图以ModelA或ModelB的方式来飞ModelC。问题不在于Airplane::fly有缺省行为,在于ModelC在未搞清楚的情况下就使用了这个缺省行为。幸运的是可以做到:提供缺省实现给derived classes,除非derived classes真的要用。这个做法是切断virtual函数接口和其缺省实现之间的连接。
1 | class Airplane{ |
这里将Airplane::fly改为pure virtual函数,只提供接口。但是缺省的行为在Airplane::defaultFly函数中出现。如果要使用其缺省行为,可以在fly函数调用defaultFly函数。
1 | // 也就是告诉派生类必须实现fly,但你可以用我提供的默认飞行模式 |
上面设计中,Airplane::defaultFly是个non-virtual,derived classes不用重新定义(条款36)。如果Airplane::defaultFly是virtual函数,就会出现循环问题:万一derived classes忘记重新定义defaultFly函数会怎样?
有的人返回以不同的函数分别将提供接口和缺省实现,这样会因为过度雷同的函数名称引起class命名空间污染问题;但是他们同意接口和缺省实现应该分开。我们可以利用“pure virtual函数必须在derived classes中重新声明,但它们可以拥有自己的实现”这个特点
1 | // 依旧是转调用默认版本,不同的是,默认版本由纯虚函数的实现版本提供,这样不会显得命名空间臃肿 |
这个实现和上一个不同之处在于,用pure virtual函数Airplane::fly替换了独立函数Airplane::defaultFly。现在的fly被分割为两个基本要素:其声明部分表现为接口(derived classes必须使用),定义部分表现为缺省行为(derived classes明确提出申请才可以用)。
最后来看看Shape的non-virtual函数objectID;Shape::objectID是个non-virtual函数,这意味着它不打算在derived class中有不同行为。
- 声明non-virtual函数的目的是为了令derived classes继承函数 的接口及一份强制性实现。
可以把Shape::objectID看做“每个Shape对象都有一个用来产生识别码的函数,这个识别码采用相同计算方法。non-virtual函数代表的意义是不变性(invariant)凌驾特异性(specialization),所以不应该在derived classes中被重新定义。
pure virtual函数对应只继承接口;simple(impure) virtual函数对应继承接口和一份缺省实现;non-virtual函数对应继承接口和一份强制实现。在设计classes时,要分清这些区别和联系,否则容易犯两个错误:
- 第一个错误是将所有函数声明为non-virtual。这会使derived classes没有空间进行特化工作;non-virtual析构函数会有问题(条款7)。如果关心virtual函数的成本问题,可以参考条款30的80-20法则。典型的程序有80%时间在执行20%代码,函数中有80%的virtual函数不一定会给程序带了多大效率损失,将心力花在那些20%代码上才是关键。
- 第二个错误是将所有成员函数声明为virtual。有时候这样是正确的,例如条款31的Interface classes。但如果有些函数在derived classes中确实不应该被重新定义,那么就应该将这些函数声明为non-virtual。
记住:
- 接口继承和实现继承不同。在public继承之下,derived classes总是继承base class的接口。
- pure virtual函数只具体指定接口继承。(附:有强烈的特异性,必须重新实现。并且纯虚函数的定义实现可以用来充当缺省版本)
- 简朴的(非纯)impure virtual函数具体指定接口继承及缺省实现继承。(附:根据自己的特殊情况看需不需要覆写,如果没必要可以用基类的版本,但这个缺省的功能可能会产生一些危险。)
- non-virtual函数具体指定接口继承以及强制性实现继承。(附:强烈的共性,派生类只管继承不覆写)
条款35
考虑virtual函数以外的其他选择。
假设你在制作一款游戏,游戏内的人物都有自己的生命值,而不同的人物会有不同的方式来计算它们健康指数,所以,需要将成员函数healthValue()
声明为虚函数是非常正确的:
1 | class GameCharacter { |
healthValue()
并未被声明为纯虚函数,这表示有一个计算健康指数的默认算法。但是,这样做并不是最完美,它也有缺陷,有没有替代方式呢?
方法1:藉由Non-Virtual Interface手法实现Template Method模式
此手法主张的做法:
- 将healthValue()函数声明为public,并且改为non-virtual函数
- 再设计一个private virtual函数,将healthValue()原本的功能移至该函数中,然后在healthValue()函数中调用该函数
1 | class GameCharacter { |
NVI手法特点:
- 令客户通过public non-virtual成员函数间接调用private virtual函数,称为non-virtual interface(NVI)手法。它是所谓Template Method设计模式的一个独特表现形式。我们把这个non-virtual 函数称为virtual函数的外覆器
- NVI手法的优点:我们可以在non-virtual函数中做一些其他事情。例如:
- 事前工作:可以进行锁定互斥器、制造运转日志记录项、验证class约束条件、验证函数先决条件等等
- 事后工作:可以进行互斥器解锁、验证函数的事后条件、再次验证class约束条件等等
这些优点是在客户端直接调用virtual函数的情况中做不到的
private的虚函数并不会改变多态性,只是改变了访问权限而已。NVI手法可以在派生类中重新定义private virtual函数:
- 重新定义virtual函数:表示某些事“如何”被完成
- 调用virtual函数:表示它何时被完成
- 这两件事情互不干扰。因此NVI手法允许在派生类中重新定义virtual函数,从而赋予它们“如何实现机能”的控制能力
1 | class GameCharacter { |
关于virtual函数的访问级别:
- 在NVI手法下其实virtual函数不一定得是private的
- 在有些情况下,要求派生类在virtual函数中调用基类的virtual函数(参阅条款27),那么当virtual函数在基类中为private之后,派生类就不可以访问了。因此,在这种情况下,virtual函数可以设置为protected。有时甚至一定得是public。
- 在NVI手法下,virtual函数尽量不要设置为public,因为设置为public之后,就与NVI手法的初衷相反了,失去了封装性
方法2:藉由Function Pointers实现Strategy模式
上面介绍的NVI方法虽然可以避免客户端直接调用virtual函数,但是在non-virtual函数中还是调用了virtual函数,这种方法还是没有免去定义virtual函数的情况。现在我们进行另一种设计,要求每个人物的构造函数接受一个指针,指向一个健康计算函数,我们可以调用该函数进行实际计算。代码如下:
1 | class GameCharacter; |
这样做的优点是:同一个人物类型的不同实例之间可以有不同的健康计算函数。例如:
1 | class GameCharacter { //同上 }; |
同时,已定义的对象,在运行期间可以更改健康指数计算函数。例如,可以在类中再添加一个成员函数,用来更改当前计算健康指数的函数指针。
这种方法的争议是:
- 当全局函数可以根据class的public接口来取得信息并且加以计算,那么这种方法是没有问题的。但是如果计算需要访问到class的non-public信息,那么全局函数就不可以使用了。
- 解决上面的问题,唯一方法就是:弱化class的封装。例如将这个全局函数定义为class的friend,或者为其某一部分提供public访问函数
- 因此,这些争议对于“以函数指针替换virtual函数”其是否利大于弊?取决于你的是继续需求
方法3:藉由tr1::function完成Strategy模式
使用全局函数替换成员函数,这种成员函数太过死板,因为“健康指数计算”不必非得是个函数,还可以是其他类型的东西(例如函数模板、函数对象、成员函数、仿函数等),只要其能计算“健康指数”即可,我们可以使用C++标准库中的function模板来取代全局函数。
1 | class GameCharacter; |
现在我们可以不单单调用全局函数来计算“人物的健康指数”,还可以设计很多种方式来计算
1 | class GameCharacter { //同上}; |
优点是:
- 支持隐式转换,参数可被隐式转换为const GameCharacter&,返回类型可以被隐式转换为int;
- 支持了任何兼容的的可调用物(兼容就是指能隐式转换参数、返回值的)
- 能用tr1::bind进行参数扩展
方法4:古典的Strategy模式
在古典的Strategy设计模式中,会将用来计算健康的函数设计为一个继承体系,并且有virtual函数,这些函数用来计算健康
- GameCharacter是一个继承体系的根类,其派生类有EvilBadGuy、EyeCandyCharacter
- HealthCalcFunc是一个继承体系的根类,其派生类有SlowHealthLoser、FastHealthLoser
- 每一个GameCharacter对象都内含一个指针,指向于一个来自HealthCalcFunc继承体系中的对象
1 | class GameCharacter; |
这个模式也具有弹性,例如为HealthCalcFunc类添加派生类,那么就可以纳入新的计算方法
四种方法总结:
- 使用non-virtual interface(NVI)手法,那是Template Method设计模式的一种特殊形式。它以public non-virtual成员函数包裹较低访问性(private或protected)的virtual函数
- 将virtual函数替换为“函数指针成员变量”,这是Strategy设计模式的一种分解表现形式
- 以tr1::function成员变量替换virtual函数,因而允许使用任何可调用物搭配一个兼容于需求的签名式。这也是Strategy设计模式的某种形式
- 将继承体系内的virtual函数替换为另一个继承体系内的virtual函数。这是Strategy设计模式的传统实现手法
记住:
- virtual函数的替代方案包括NVI手法及Strategy设计模式的多种形式。NVI手法自身是一个特殊形式的Template Method设计模式。
- 将机能从成员函数移到class外部函数,带来的一个缺点是,非成员函数无法访问class的non-public成员。
- tr1::function对象的行为就像一般函数指针。这样的对象可接纳“与给定之目标签名式兼容”的所有可调用物。
条款36
绝不重新定义继承而来的non-virtual函数。
假设 class D系由 class B以 public 形式派生出来,class B定义有一个 public 成员函数mf。由于mf的参数和返回值都不重要,所以假设两者皆为 void。
1 | class B { |
虽然我们对于B、D和mf一无所知,但面对一个类型为D的对象 x:
1 | D x; // x是一个类型为D的对象 |
如果以下行为:
1 | B *pB = &x; // 获得一个指针指向x |
异于以下行为:
1 | D *pD = &x; |
两者都通过对象 x 调用成员函数 mf,由于两者所调用的函数都相同,所以行为应该相同,是吗?是的,一般如此。
更明确地说,如果mf是个non-virtual 函数而D定义有自己的mf,那就不是如此:
1 | class D : public B { |
造成此行为的原因是:non-virtual 函数如 B::mf 和 D::mf 都是静态绑定(staticlly bound,详见条款37)。这意思是说,由于pB被声明为一个pointer-to-B,通过pB调用的non-virtual 函数永远是B所定义的版本,即使pB指向一个类型为”D派生的class”的对象。
但另一方面,virtual 函数却是动态绑定(dynamically bound,详见条款37),所以它们不受这个问题困扰。如果mf是个 virtual 函数,不论是通过pB或pD调用mf,都会导致调用D::mf,因为pB和pD真正指的都是一个类型为D的对象。
如果正在编写 class D并重新定义继承自 class B的non-virtual 函数mf,D对象很可能展现出不一致的行为。更明确地说,当mf被调用,任何一个D对象都可能展现出B或D的行为:决定因素不在对象自身,而在于”指向该对象的指针”当初的声明类型。
但那只是实务面上的讨论,真正的理论层面的理由,接下来讨论:
条款32已经说过,所谓 public 继承意味is-a的关系。条款34则描述为什么在 class 内声明一个non-virtual 函数会为该 class 建立起一个不变性,凌驾其特异性。如果将这两个观点施行于两个 class B和D以及non-virtual 成员函数B::mf身上,那么:
- 适用于B对象的每一件事,也适用于D对象,因为每个D对象都是一个B对象
- B的derived class 一定会继承mf的接口和实现,因为mf是B的一个non-virtual函数
现在,如果D重新定义mf,设计便出现矛盾。既然D以 public 形式继承B,并且mf是B的一个non-virtual 函数,那么D的mf行为和B的mf行为必须是一致的。但D又重新定义mf,这就发生了矛盾。
因此,任何情况下都不应该重新定义一个继承而来的non-virtual 函数。
同时这个条款也解释了为什么多态性base class 内的析构函数应该是 virtual。如果违反了这个准则,在base class 内声明一个non-virtual 析构函数,那么在derived class 就不能重新定义一个派生类的non-virtual 析构函数。但即使没有重新定义non-virtual 析构函数,编译器也会为derived class 定义一个默认的析构函数,仍然发生矛盾。
记住:
绝不重新定义继承而来的non-virtual函数。
条款37
绝不重新定义继承而来的缺省参数值。
我们在条款36刚刚说过继承non-virtual函数是错误的。所以本条款更确切的说是:绝不重新定义继承而来的带有缺省参数值的virtual函数。理由很明确:virtual是动态绑定,而缺省参数是静态绑定。动态绑定又叫后期绑定,静态绑定又叫前期绑定。
先说一下静态类型和动态类型的概念:对象的所谓静态类型就是它在程序中被声明时所采用的类型 ,对象的动态类型指的是目前所指对象的类型,也就是说动态类型可以表现出一个对象将会有什么行为。
下面是一个继承体系:
1 | class Shape { |
现在我们定义下面的代码,它们都被声明为pinter-to-Shpae类型,因此它们不论它们指向什么,静态类型都是Shape*:
动态类型是指该该对象将会有什么行为。例如:
1 | Shape* ps; //静态类型为Shape* |
1 | Shape* ps; |
根据语法我们知道,对于virtual函数的调用,是根据其动态类型决定的。例如:
1 | Shape* ps; |
虽然对于virtual函数的调用时动态绑定的,但是对于virtual函数的缺省参数值却是静态绑定的
见下面的代码:
- 我们知道virtual函数是动态绑定的,pr的动态类型为Rectangle,所以调用的是Rectangle::draw()
- 但是virtual函数的缺省参数值是静态绑定的,在上面类的定义中Rectangle的draw()函数也有默认参数,但是由于pr指针的静态类型是Shape,因此pr的draw()函数的缺省参数值就是Shape::draw()函数中的参数值,为Shape::Red。
- 因此这个调用是派生类和基类各出一份力,基类提供默认参数,派生类提供动作。这个情况也适用于pc,注意Circle的实现并不是默认参数版本,但也因此可以认为有默认参数。
1 | Shape* pr = new Rectangle; |
以上事实不只局限于“ps,pc和pr都是指针”的情况:即使把指针换成references问题仍然存在。重点在于draw是个virtual函数,而它有个缺省参数值在derived class中被重新定义了。
为什么要设计这种行为的原因在于运行效率。如果缺省参数值也是动态绑定,编译器就必须有某种办法在运行期为virtual函数决定适当的参数缺省值,这比目前实行的“在编译期决定”的机制更慢而且更复杂。
这一切都很好,但如果你试着遵守这条规则,并且同时提供缺省参数值给base和derived classes的用户,又会发生什么事呢?
1 | class Shape { |
这导致了代码重复。更糟的是,代码重复又带着相依性(with dependencies):如果Shape内的缺省参数值改变了,所有“重复给定缺省参数值”的那些derived classes也必须改变,否则它们最终会导致“重复定义一个继承而来的缺省参数值”。
当你想令virtual函数表现出你所想要的行为但却遭遇麻烦,聪明的做法是考虑替代设计。
条款35列了不是virtual函数的替代设计,其中之一是NVI(non-virtual interface)手法:令base class内的一个public non-virtual函数调用private virtual函数,后者可被derived classes重新定义。这里我们可以让non-virtual函数指定缺省参数,而private virtual函数负责真正的工作:
1 | class Shape { |
由于non-virtual函数应该绝对不被derived classes覆写(见条款36),这个设计很清楚地使得draw函数的color缺省参数值总为Red。
记住:
绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而virtual函数——你唯一应该覆写的东西——却是动态绑定。
附:注意仅仅是参数值,而非整个函数。函数是可以重新定义的,并且当使用了一个基类指针时,可以视为带了默认参数。
条款38
通过复合塑模出has-a或“根据某物实现出”。
复合(composition)是类型之间的一种关系,一个类型的对象包含其他类型对象便是这种关系:
1 | class Address{ …… }; |
Person对象中包含string,Address,PhoneNumber对象,这就是复合。还有几个同义词:layering(分层),containment(内含),aggregation(聚合),embedding(内嵌)。
条款 32中提到,public是is-a关系,复合是has-a(有一个)或is-implemented-in-terms-of(根据某物实现出)。在程序中,大概可以分为两个领域(domains)。程序中对象相当于你所塑造现实世界中某物,例如地址、电话号码,这样的对象属于应用域(application domain)。还有一些是实现细节上的人工复制品,例如缓冲区(buffers)、互斥器(mutexes)、查找树(search tree)等,这些是实现域(implementation domain)。
当复合发生在应用域对象之间时,表现出has-a关系;发生在实现域表现出is-implemented-in-terms-of关系。
区分is-a和is-implemented-in-terms-of比较麻烦。通过一个例子来说明,假设你需要一个template,用来构造一组classes来表示不重复对象组成的sets。首先我们想到用标准程序库提供的set template。
标准程序库的set由平衡查找树(balance search tree)实现,每个元素使用了三个指针的额外开销。这样可以使查找、插入、移除等操作时间复杂度为O(logN)(对数时间,logarithmic-time)。如果速度比空间重要,这样做合理,但是如果空间比速度重要,那么标准库提供的set将不满足我们需求.
set实现方法很多,可以在底层使用linked lists来实现,标准库中有list template,于是我们复用它。
1 | template<typename T> |
上面看起来很美好,其实是错误的。条款 32曾说过,public继承是is-a关系,即set是一种list并不对。例如set不能包含重复元素,但是list可以。
因为这两个classes之间并非is-a关系,所以public继承并不适用。正确的做法是,set对象可以根据一个list对象来实现出来:
1 | template<calss T> |
只要熟悉list,便很快可以实现上面几个接口函数。
记住:
- 复合(composition)的意义和public继承完全不同。
- 在应用域(application domain),复合意味has-a;在实现域(implementation domain),复合意味is-implemented-in-terms-of(根据某物实现出)。
条款39
明智而审慎地使用private继承。
public继承是is-a关系,条款32曾讲过并给出例子,如果把那个例子用private继承会怎样?
1 | class Person{……}; |
上面的eat(s)
会出错,因为private继承不是is-a关系。如果继承关系是private,那么编译器不会自动将一个derived class对象转换为base class对象;此外继承base的所有成员,在derived class中都是private。
private继承意味implemented-in-terms-of(根据某物实现出)。如果class D以private形式继承class B,我们的用意是采用class B内已经具备的某些特性。private继承纯粹只是一种实现技术(这也是为什么derived class中,base class成员都是private的:因为它们都只是实现枝节而已)。private继承意味只有实现部分被继承,接口部分应略去。D以private形式继承B,意思是D对象是根据B对象实现而得。
private继承意味is-implemented-terms-of(根据某物实现出),和条款38的复合意义相同。那么如何在两者之间取舍?答案是尽可能的复合,必要时才使用private继承。
现在有个Widget class,我们想记录每个成员函数调用次数,在运行期间周期性审查这份信息。为了完成这项工作,需要用到定时器:
1 | class Timer{ |
每次滴答调用某个virtual函数,我们可以重新定义那个virtual函数,来取出Widget当时状态。为了重新定义Timer内的virtual函数,Widget必须继承Timer。因为Widget不是Timer,因此不适用public继承。还有一个观点支持不适用public,Widget对象调用onTick有点奇怪,会违反条款18:让接口容易被正确使用,不容易被误用。
1 | class Widget: private Timer{ |
这个设计也可以通过复合实现:
1 | class Widget{ |
这个设计稍微复杂一点,涉及到了public继承和复合,以及导入一个新class。我们有理由来选择这个复合版本,而不是private继承版本。
- Widget可能会有派生类,但是我们可能会想阻止在派生类中重新定义onTick。如果是使用private继承,上面的想法就不能实现,因为derived classes可以重新定义virtual函数(条款35)。如果采用复用方案,Widget的derived classes将无法采用WidgetTimer(private的),自然也就无法继承或重新定义它的virtual函数了。
- 采用复合方案,还可以降低编译依存性。如果Widget继承Timer,当Widget编译时Timer的定义必须可见,所以Widget所在的定义文件必须包含Timer的定义文件。复合方案可以将WidgetTimer移出Widget所在的文件,而让Widge只含有一个指针即可并声明WidgetTimer即可。
那么何时选择private继承呢?
private继承主要用于“当一个意欲成为derived class者想访问一个意欲成为base class者的protected成分,或为了重新定义一个或多个virtual函数”。这时候,两个classes之间关系是is-implemented-in-terms-of,而不是is-a。有一种激进情况涉及空间最优化,会促使你选择private继承,而不是继承加复合。
这个情况只适用于你所处理的class不带任何数据。它不包含non-static变量、virtual函数,没有继承virtual base class。这样的empty classes对象没使用任何空间,因为它没有任何数据对象要存储。但是因为技术原因,C++对象都必须有非零大小:
1 | class Empty{}; |
sizeof(HoldsAnInt)>sizeof(int)。大多数编译器中,sizeof(Empty)为1,通常C++官方勒令安插一个char到对象内,但class大小还有字节对其需求(比如对齐)。
“独立(非附属)”对象大小一定不为零,这个约束不适用于derived class对象内的base成分,因为它们不独立,如果继承Empty,而不是复合:
1 | class HoldsAnInt: private Empty{ |
这时,几乎可以确定sizeof(HoldsAnInt)==sizeof(int)。这是所谓的EBO(empty base optimization;空白基类最优化)。如果客户非常在意空间,那么使用EBO。EBO一般只在单一继承下才行,统治C++对象布局的那些规则通常表示EBO无法被施行余“拥有多个base”的derived classes身上。
那么这样的empty class有什么用呢?
- empty class并不是真的empty。它们内往往含有typedef、enum、static或弄-virtual函数。SLT有许多技术用途的empty classes,其中内含有的成员(通常是typedefs),包括base classes unary_function和binary_function,这些是“用户自定义之函数对象”,通常会继承的classes。
前面提到,只要可以尽可能选择复合,但这也不是全部。当面对并不存在is-a关系的两个classes,其中一个需要访问另一个的protected成员,或需要重新定义其一个或多个virtual函数,private继承可能成为正统设计策略。在考虑了其他方案后,仍然认为private继承是“表现两个classes之间的关系”的最佳办法,那就使用它。
记住:
- private继承意味着is-implemented-in-terms-of(根据某物实现出)。它通常比复合(composition)的级别低。但是当derived class需要访问protected base class的成员,或需要重新定义继承而来的virtual函数时,使用private是合理的。
- 和复合(composition)不同,private继承可以造成empty base最优化。这对致力于“对象占用空间最小化”的程序库开发者而言,可能很重要。
条款40
明智而审慎地使用多继承。
多重继承的意思是继承一个以上的base classes,但这些base classes并不常在继承体系中又有更高级的base classes。多重继承并不是继承有多个层级的意思。
使用多重继承 ,程序有可能从一个以上的基类继承相同名称(如函数,typedef等),那会导致较多的歧义。如:
1 | class BorrowableItem { //图书馆允许你借某些东西 |
C++解析重载函数调用的规则:在看到是否有函数可调用之前,C++首先确认这个函数对此调用是否是最佳匹配。找出最佳匹配才去检验可取用性。上述例子中的两个 checkOut 有相同的匹配程度(因此才造成歧义),没有所谓的最佳匹配。因此ElectronicGadget::checkOut 的可访问性也就从未被编译器审查。
为了解决歧义,必须指明你要调用哪一个 基类 内的函数:
1 | mp.BorrowableItem::checkOut(); //OK |
如果继承一个以上的基类,且基类继承更高级的基类,就可以会导致菱形继承。
1 | class File{}; |
上述继承体系中,File与IOFile之间有一条以上的相通路线。于是IOFile继承File成员时,需要面对的问题:是打算让base class内的成员变量经由每一条路径被复制(成员变量重复啦),还是说IOFile从InFile和OutFile继承的成员变量(其继承来自File)不该重复?
两个阵营,而C++在此表示中立(都可以)。于是引出虚基类的概念,即防止同一基类成员因不同相通路线而被复制多次。当然,虚继承是要付出相应代码代价。
具体做法如下,
1 | class File {}; |
从正确行为的观点看,public继承 应该总是 virtual。但是正确性并不是唯一观点,为避免继承来的成员变量重复,编译器必须提供一些成本:
- 使用 virtual 继承的那些类所产生的对象往往比使用 non-virtual 继承的兄弟们体积大
- 访问 virtual base class 的成员变量时,比访问 non-virtual base class 的成员变量速度慢
- 支配“virtual base class 初始化 ”的规则比 non-virtual base情况复杂且不直观
对virtual base classes的忠告:
- 非必要不使用virtual bases。平常请使用non-virtual继承。
- 如果必须使用virtual base classes,尽可能避免在其中放置数据。这样就不需担心classes身上的初始化(和赋值)所带来的诡异事情。
以下举例实现一个public和private并存的多重继承,public继承是is-a关系,而private继承是is implement in terms of关系,具体举例见下代码,
1 | class DatabaseID{}; |
于是下面所要给出的CPerson和PersonInfo的关系是,PersonInfo刚好有若干函数可帮助CPerson比较容易实现出来,而IPerson则提供给CPerson接口,运用多重继承
1 | class Cperson :public IPerson, private PersonInfo { |
这种方法就是:将“public继承自某接口”和“private继承自某实现”结合在一起。
最后,如果有一个单一继承的设计,而它几乎等价于一个多重继承的设计方案,那么单一继承设计方案几乎一定比较受欢迎。如果你唯一能够提出的设计方案涉及多重继承,你应该更努力想一想——几乎可以说一定会有某些方案让单一继承行得通。然而多重继承有时候的确是完成任务之最简结、最易维护、最合理的做法,果真如此就别害怕使用它。
记住:
- 多重继承比单一继承复杂。它可能导致新的歧义性、以及对virtual继承的需要。
- virtual继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果virtual base classes不带任何数据,将是最具实用价值的情况。
- 多重继承的确有正当用途。其中一个情节涉及“public继承某个Interface class”和“private继承某个协助实现的class”的两相组合。
条款41
了解隐式接口和编译期多态。
面向对象编程总是以显式接口和运行期多态来解决问题。例如:
1 | class Widget{ |
- 所谓的显式接口:由于w的类型被声明为Widget,因此w需要Widget接口,并且我们可以在源码中找到这个接口,看到源码的样子,所以称为是显式接口。
- 所谓的运行期多态:由于Widget的某些函数是虚函数,因此w的某些函数在运行期间才可以根据w的类型动态调用相关版本的函数,这就是所谓的运行期多态。
在泛型编程中,显式接口与运行期多态仍有使用,但是其主要应用的是隐式接口和编译期多态。
例如将刚才的函数改为函数模板(现在,在实例化前无法去找相应的源码中的接口):
1 | template<typename T> |
这个时候w发生了什么样的改变呢?
- 隐式接口:w所需要支持的接口需要当函数模板具现化时执行于w身上的操作决定(执行了什么操作,说明w一定需要支持这些接口),例子中w使用了size、normalize、swap函数、copy构造函数、不等比较。并且if语句中还有一个长表达式。这所有的函数与长表达式便是T必须支持的一组隐式接口(其实就是w需要被约束的东西)。(w.size() > 10 && w != someNastyWidget)
- 编译期多态:使用到w的任何函数调用,都可能会造成模板具现化,这样的函数具现化发生在编译期,而且不同的模板参数导致不同的模板函数,这就是所谓的编译期多态。
通常显式接口是由函数的签名式(函数名称、参数类型、返回类型)构成。但是隐式接口不是基于签名式的,而是由有效表达式组成。
1 | w.size()>10&&w!=someNastyWidget//这就是所谓的隐式接口,是一组有效表达式。 |
w的隐式接口似乎有下述的约束:
- 提供size()函数,返回整数值
- 支持!= 操作符重载,用来比较两个T对象
但是由于操作符重载的关系,隐式接口实际上不需要满足这两个约束。原因如下:
- w可能继承自base class的size 函数,因此不需要有size函数
- 并且size函数也没必要返回一个整数,只要它能够返回一个类型为X的对象,并且X和10 能够调用> 符号函数即可。
- ‘>’不需要非得是对象X的成员函数(可以是全局的一个函数。)
- 再退一步,并且符号函数>也并不是非得取得一个X对象和一个10才可以,它也可以取得类型Y的参数,只要存在一个隐式转换能够将类型X的对象转换为类型Y的对象。
同样,T不必支持operator!=,因为operator!=也可以接受类型为X和Y的对象,只要T可以被转换为X,someNastyWidget的类型可以被转换成Y就行。
总之,隐式接口就是一组表达式,不管中间过程怎么样,只要最终的结果是一个满足类似于上述if语句中的表达式应该有的结果就行,比如if的条件表达式应该是bool类型的,只要括号里的表达式最终的结果是bool类型即可。表达式中间的接口可能并不需要w去支持。这些就是所谓的隐式接口。
记住:
- classes和templates都支持接口和多态。
- 对classes而言接口是显式的,以函数签名为中心。多态则是通过virtual函数发生于运行期。
- 对template参数而言,接口是隐式的,奠基于有效表达式。多态则是通过template具现化和函数重载解析发生于编译期。
条款42
了解typename的双重意义。
在模板的的声明中,class与typename是没有什么区别的:
1 | template <typename T> T func1(const T&); |
但是在模板的定义中typename有时候却会派上用场。为了说明问题,我们先了解一下:模板中依赖于模板参数的名称称为从属名称(dependent name), 当一个从属名称嵌套在一个类里面时,称为嵌套从属名称(nested dependent name)。 其实C::const_iterator
还是一个嵌套从属类型名称(nested dependent type name)。
有了这两个基本概念之后我们就可以看一下例子:假设我们要打印一个容器(里面为)中的第二个元素,那么函数应该是这样:
1 | template<typename C> |
在代码中特别强调两个local变量和itemvalue。iter的类型是C::const_iterator,实际是什么必须取决于template参数C。
print2nd内的另一个local变量value,其类型是int。int是一个并不依赖任何template参数的名称。这样的名称是非从属名称。
嵌套从属名称有可能导致解析困难。举个例子,假设我们令print2nd更愚蠢些,这样起头:
1 | template<typename C> |
看起来好像我们声明x为一个local变量,它是个指针,指向一个C::const_iterator。但它之所以被那么认为,只因为我们“已经知道”C::const_iterator是个类型。如果C有个static成员变量而碰巧被命名为const_iterator,或如果x碰巧是个global变量名称呢?那样的话上述代码就不再是声明一个local变量,而是一个相乘动作:C::const_iterator乘以x。当然,这听起来有点疯狂,但却是可能的,而撰写C++解析器的人必须操心所有可能的输入,甚至是这么疯狂的输入。
在我们知道C是什么之前,没有任何办法可以知道C::const_iterator是否为一个类型。而当编译器开始解析template print2nd时,尚未确知C是什么东西。C++有个规则可以解析此一歧义状态:如果解析器在template中遭遇一个嵌套从属名称,它便假设这个名称不是个类型,除非你告诉它是。所以缺省情况下嵌套从属名称不是类型。此规则有个例外,稍后会提到。
现在再次看看print2nd起始处:
1 | template<typename C> |
现在应该很清楚为什么这不是有效的C++代码了吧。iter声明式只有在C::const_iterator是个类型时才合理,但我们并没有告诉C++说它是,于是C++假设它不是。若要矫正这个形势,我们必须告诉C++说C::const_iterator是个类型。只要紧临它之前放置关键字typename即可:
1 | template<typename C> // 这是合法的C++代码 |
一般性规则很简单:任何时候当你想要在template中指涉一个嵌套从属类型名称,就必须在紧临它的前一个位置放上关键字typename。
typename只被用来验明嵌套从属类型名称:其他名称不该有它存在。例如下面这个function template,接受一个容器和一个“指向该容器”的迭代器:
1 | template<typename C> // 允许使用“typename”(或“class”) |
上述的C并不是嵌套从属类型名称(它并非嵌套于任何“取决于template参数”的东西内),所以声明container时并不需要以typename为前导,但C::iterator是个嵌套从属类型名称,所以必须以typename为前导。
“typename必须作为嵌套从属类型名称的前缀词”这一规则的例外是,typename不可以出现在base classes list内的嵌套从属类型名称之前,也不可在member initialization list(成员初始列)中作为base class修饰符。例如:
1 | template<typename T> |
记住:
- 声明template参数时,前缀关键字class和typename可互换。
- 请使用关键字typename标识嵌套从属类型名称;但不得在base class lists(基类列)或member initialization list(成员初值列)内以它作为base class修饰符。
条款43
学习处理模板化基类内的名称。
我们需要一个程序,传送信息到不同的公司去。信息要不译成密码,要不就是未加工的文字。如果编译期间我们有足够信息来决定哪一个信息传至那一家公司,就可以采用基于 template 的解法:
1 | class CompanyA{ |
这个做法行的通。假设我们有时候想要在每次发送出信息的时候志记(log)某些信息。 derived class 可以轻易加上这样的行为,那似乎是个合情理的解法:
1 | template<typename Company> |
sendClearMsg 避免遮掩 “继承而得的名称”(条款 33),避免重新定义一个继承而得的 non-virtual 函数(条款 36)。但上述代码无法通过编译,编译器看不到 sendClear。
问题在于,编译器遇到 class template LoggingMsgSender 定义式时,并不知道它继承什么样的 class。因为 MsgSender<Company> 中的 Company 是个 template 参数,不到后来(当 LoggingMsgSender 被具现化)无法确切知道它是什么。而如果不知道 Company 是什么,就无法知道 class MsgSender<Company> 看起来是个什么样 —— 更明确的说是没办法知道它是否有个 sendClear 函数。
为了让问题具体化,假设有个 class CompanyZ 只是用加密通信:
1 | class CompanyZ { |
一般性的 MsgSender template 对 CompanyZ 并不合适,因为那个 template 提供了一个 sendClear 函数(其中针对其类型参数 Company 调用了 sendCleartext 函数),而这对 CompanyZ 对象并不合理。与纠正这个问题,我们可以针对 CompanyZ 产生一个 MsgSender 特化版;
1 | template<> //一个全特化的 |
注意 class 定义式最前头 “template<>” 语法象征这既不是 template 也不是标准 class,而是个特化版的 MsgSender template,在 template 实参是 CompanyZ 时被使用。这事模板全特化(total template specialization):template MsgSender 针对类型 CompanyZ 特化了,而且其特化是全面性的,也就是说一旦类型参数被定为 CompanyZ,再没有其他 template 参数可供变化。
1 | template<typename Company> |
那就是为什么 C++ 拒绝这个调用的原因:它知道 base class template 可能被特化,而那个特化版本可能不提供和一般属性 template 相同的接口。因此它往往拒绝在 templatized base class(模板化基类,MsgSender<Company>)内寻找继承而来的名称(本例的 SendClear)。从 Object Oriented C++ 跨进 Template C++ 继承就不想以前那般畅通无阻了。
我们必须令 C++ “进入 templatized base classes 观察”。有三个办法:
第一个办法是 base class 函数调用动作之前加上 “this->”:
1
2
3
4
5
6
7
8
9
10template<typename Company>
class LoggingMsgSender: public MsgSender<Company>{
public:
void sendClearMsg(const MsgInfo& info)
{
将传送前信息写至log;
this->sendClear(info); //成立,假设sendClear将被继承
将传送后信息写至log;
}
};第二个办法是使用 using 声明式:
1
2
3
4
5
6
7
8
9
10
11template<typename Company>
class LoggingMsgSender: public MsgSender<Company>{
public:
using MsgSender<Company>::sendClear; // 告诉编译器,请他假设 sendClear 位于 base class 内
void sendClearMsg(const MsgInfo& info)
{
将传送前信息写至log;
sendClear(info); //成立,假设sendClear将被继承
将传送后信息写至log;
}
};第三个做法是,明白指出被调用的函数位于 base class 内:
1
2
3
4
5
6
7
8
9template<typename Company>
class LoggingMsgSender: public MsgSender<Company>{
public:
void sendClearMsg(const MsgInfo& info)
{
将传送前信息写至log;
MsgSender<Company>::sendClear(info); //成立,假设sendClear将被继承
}
};但这往往不是令人满意的一个解法,因为如果被调用的是 virtual 函数,上述的明确资格修饰 MsgSender
:: 会关闭 virtual 绑定行为。
从名称可视点的角度出发,上述每个解法做的事情都相同:对编译器承诺 “base class template 的任何特化版本都将支持其一般化版本所提供的接口”。这样一个承诺是编译器在解析(parse)像 LoggingMsgSender 这样的 derived class template 时需要的。但如果这个承诺最终未被实践出来,往后的编译器最终还是会给事实一个公道。例如,如果稍后的源码内含这个:
1 | LoggingMsgSender<CompanyZ> zMsgSender; |
因为在那个点上,编译器知道 base class 是个 template 特化版本 MsgSender<CompanyZ>,而它们知道那个 class 不提供 sendClear 函数,而这个函数却是 sendClearMsg 尝试调用的函数。
根本而言,面对 “指涉 base class members” 之无效的 references,编译器的诊断时间可能发生在早期(当解析 derived class template 的定义式时),也可能发生在晚期(当那些 templates 被特定之 template 实参具现化时)。C++ 的政策是宁愿早诊断。这就是为什么 “当 base classes 从 templates 中被具现化时” 它假设它对那 base classes 的内容毫无所悉的缘故。
记住:
可在 derived class template 内通过 “this->” 指涉 base class template 内的成员名称,或藉由一个明白写出的 “base class 资格修饰符” 完成。
附:以及using声明
条款44
将与参数无关的代码抽离templates。
templates 是节省时间和避免代码重复的奇方妙法。你不再需要键入 20 个类似的 classes 并且每一个都带有 20 个 成员函数,你只需要键入一个 class template,留给编译器去具现化那 20 个你需要的相关 classes 即可,而且对于 20 个函数中未被调用的,编译器不会自动生成。
但是,这也很容易使得代码膨胀(code bloat),templates 产出码带着重复,或者几乎重复的代码,数据,或者两者。你可以通过:共性与变形分析(commonality and variability analysis)来避免代码膨胀。
这个概念其实你早在使用,即使你从未写过一个 templates。当你编写某个函数时,你明白其中某些部分的实现码和另一个函数的实现码实质相同,你会很单纯的重复它们吗?当然不,你会抽出这两个函数相同的部分,放进第三个函数中,然后令原先两个函数调用这个新函数。也就是说:你分析了两个函数的共性和变形,把公共的部分搬到一个新的函数中去,变化的部分保留在原来的函数不动。对于 class 也是这个道理,如果你明白某些 class 和另一个 class 具有相同的部分,你也会把共性搬到一个新的 class。
templates 的优化思路也是如此,以相同的方式避免重复,但其中有个窍门。在 non-template 代码中,重复很明确。然而在 template 代码中,重复是隐晦的,毕竟只存在一份 template 代码,所以你必须自己去感受 template 具现化时可能发生的重复。
一种情况是 template class 成员依赖 template 参数值
,举个例子:
1 | template<typename T,size_t N> |
上述代码用于正方形矩阵求逆矩阵,其 template 接受一个类型参数 T 作为元素类型外,还接受一个类型为 size_t 的参数作为矩阵大小,这是非类型参数(non-type parameter)。这种参数不常见,但它们完全合法,而且相当自然。
在 TryWithMatrix 函数中,我们分别对 5*5 大小和 10*10 大小的矩阵求逆,但除了常量 5 和 10,其他函数的操作部分完全相同,但因为 template 参数不同的,编译器仍然会会具现化两份函数,这是 template 引起代码膨胀的典型例子。
下面是对 SquareMatrix 的一次修改:
1 | template<typename T> |
就如你所看到的,带参数的 Invert 位于 base class SquareMatrixBase 中。和 SquareMatrix 一样,它也是个 template,不同的是它只对矩阵元素类型参数化,不对矩阵尺寸参数化,因此对于某给定的元素对象,所有的矩阵会共享唯一一个 SquareMatrixBase class,因此它们也因此共享这唯一一个 class 内的 Invert,从而避免了代码不必要的重复。如果元素类型与逆矩阵计算无关的话,甚至可以不对矩阵元素参数化,从而使得所有元素类型共享唯一一份 Invert 函数代码。
- SquareMatrixBase::invert只是企图成为”避免derived classes代码重复”的一种方法,所以它以protected替换public。
- 这些函数使用this->记号,因为若不这样做,便如条款43所说,模板化基类内的函数名称会被derived classes掩盖。(实际上感觉using已经完成了)
- SquareMatrix和SquareMatrixBase之间的继承关系是private。这反应一个事实:这里的base class只是为了帮助derived classes实现,不是为了表现SquareMatrix和SquareMatrixBase之间的is-a关系。
SquareMatrixBase::invert
如何知道该操作什么数据?虽然它从参数中知道矩阵尺寸,但它如何知道哪个特定矩阵的数据在哪里呢。
一个可能的做法是为SquareMatrixBase::invert添加另一个参数,也许是个指针,指向一块用来放置矩阵数据的内存起始点。那行得通,但十之八九invert不是唯一一个可写为”形式与尺寸无关并可移至SquareMatrixBase内”的”SquareMatrix函数。如果有若干这样的函数,我们唯一要做的就是找出保存矩阵元素值的那块内存。我们可以对所有这样的函数添加一个额外参数,却得一次又一次地告诉SquareMatrixBase相同的信息,这样做不是很好。
可以令SquareMatrixBase贮存一个指针,指向矩阵数值所在的内存。而只要它存储了那些东西,也就可能存储矩阵的尺寸。
1 | template<typename T> |
这允许derived class决定内存的分配方式,某些实现版本也许会决定将矩阵数据存储在SquareMatrix对象内部:
1 | template<typename T, std::size_t n> |
这种类型的对象就不需要动态分配内存,但对象自身可能非常大。另一种做法就是把每一个矩阵的数据放进heap(也就是通过new来分配内存)
1 | template<typename T, std::size_t n> |
但注意,之前直接使用模板参数的 Invert 函数,有可能产出比上面共享版本更好的代码,因为模板尺寸是一个编译器常量,因此可以像常量那样被直接生成到指令中成为立即操作数,达到更优化。
从另外一个角度来看,拥有共享的 Invert 函数,可减少执行文件的大小,降低了所需的内存,也提高了高速缓存命中率。这些都可能使得程序执行更快速。
哪一个影响占主要地位?需要进行实际的平台测试和观察面对代表性数据的行为。
类型参数(type parameters)也会导致代码膨胀。例如在许多平台上 int 和 long 二进制表示完全相同,所以像 vector<int> 和 vector<long> 有着相同的代码实现。某些链接器(linkers)会合并完全相同的函数实现码,但有些不会,后者意味着某些 templates 将具现化为 int 和 long 两个版本,从而造成代码膨胀。类似,在大多数平台上,所有指针类型都具有相同的二进制表示,因此凡是 template 拥有指针的,往往应该对每一个函数使用唯一一份底层实现。这很具代表性质,如果你实现某些成员函数而它们操作强型指针(strongly typed pointer)T*,你应该令它们调用另一个操作无类型的指针(untyped pointers,即 void*)的函数,由后者完成实际的工作。
记住:
- Templates 生成多个 classes 和多个 functions,所以任何 template 代码都不该与某个造成膨胀的 template 参数产生相依关系。
- 因非类型模板参数(non-type template parameters)而造成的代码膨胀,往往可以消除,做法是以函数参数或 class 成员变量替换 template 参数。
- 因类型参数(type parameters)而造成的代码膨胀,往往可以降低,做法是让带有完全相同二进制表述(binary representations)的具现类型(instantiation types)共享实现码。
条款45
运用成员函数模板接受所有兼容类型。
从例子入手,所谓智能指针(smart pointer),是行为像指针的对象,并提供指针没有的机能:自动管理资源。但原始指针(raw pointer)做的很好的一件事是:支持隐式转换(implicit conversions)。比如 derived class 指针可以隐式转换为 base class 指针,指向 non-const 的指针可以转换为 指向 const 的指针…下面是可能发生于三层继承体系的一些转换:
1 | class Top { ... }; |
但如果想在用户自定的智能指针中模拟上述转换,稍稍有点麻烦。我们希望以下代码通过编译:
1 | template<typename T> |
由于同一template的不同实例化之间没有直接联系,也就是说对于自定义的智能指针(假设名为SmartPtr),如果不额外采取手段支持基层层次中派生类指针向基类指针的转换,那么SmartPtr<Base>和SmartPtr<Derived>将会被编译器认为毫无关联,也就不存在SmartPtr<Derived>向SmartPtr<Base>的隐式转换。
在上述智能指针实例中,每一个语句创建了一个新式智能指针对象,所以现在我们应该关注如何编写智能指针的构造函数,使其行为能够满足我们转型需要。一个很关键的观察结果是:我们永远无法写出我们需要的所有构造函数。在上述继承体系中,我们根据一个SmartPtr<Middle>或一个SmartPtr<Bottom>构造出一个SmartPtr<Top>,但如果这个继承体系未来有所扩充,SmartPtr<Top>对象又必须能够根据其他智能指针构造自己。假设日后添加了:
1 | class BelowBottom: public Bottom { ... }; |
我们因此必须令SmartPtr<BelowBottom>对象得以生成SmartPtr<Top>对象,但我们当然不希望一再修改SmartPtr template以满足此类需求。
就原理而言,此例中我们需要的构造函数数量没有止尽,因为一个template可被无限量具现化,已致生成无限量函数。因此,似乎我们需要的不是为SmartPtr写一个构造函数,而是为它写一个构造模板。这样的模板是所谓member function templates,其作用是为class生成函数:
1 | template<typename T> |
以上代码的意思是,对任何类型T和任何类型U,这里可以根据SmartPtr<U>生成一个SmartPtr<T>——因为SmartPtr<T>有个构造函数接受一个SmartPtr<U>参数。这一类构造函数根据对象U创建对象T,而U和T的类型是同一个template的不同具现体,有时我们称之为泛化copy构造函数。
上面的泛化copy构造函数并未被声明为explicit。那是蓄意的,因为原始指针类型之间的转换(例如从derived class指针转为base class指针)是隐式转换,无需明白写出转型动作(cast),所以让智能指针效仿这种行径也属合理。在模板化构造函数中略去explicit就是为了这个目的。
完成声明之后,这个为SmartPtr而写的“泛化copy构造函数”提供的东西比我们需要的更多。是的,我们希望根据一个SmartPtr<Bottom>创建一个SmartPtr<Top>,却不希望根据一个SmartPtr<Top>创建一个SmartPtr<Bottom>(根据基类创建派生类),因为那对public继承而言(见条款32)是矛盾的。我们也不希望根据一个SmartPtr<double>创建一个SmartPtr<int>,因为现实中并没有“将int* 转换为double*”的对应隐式转换行为。是的,我们必须从某方面对这一member template所创建的成员函数群进行筛除。
假设SmartPtr遵循auto_ptr和tr1::shared_ptr所提供的榜样,也提供一个get成员函数,返回智能指针对象(见条款15)所持有的那个原始指针的副本,那么我们可以在“构造模板”实现代码中约束转换行为,使它符合我们的期望:
1 | template<typename T> |
使用成员初值列来初始化SmartPtr<T>之内类型为T*的成员变量,并以类型为U*的指针(由SmartPtr<U>持有)作为初值。这个行为只有当“存在某个隐式转换可将一个U*指针转为一个T*指针”时才能通过编译,而那正是我们想要的。最终效益时SmartPtr<T>现在有了一个泛化copy构造函数,这个构造函数只在其所获得的实参隶属适当(兼容)类型时才通过编译。
member function templates的效用不限于构造函数,它们常扮演的另一个角色是支持赋值操作。例如TR1的shared_ptr(见条款13)支持所有“来自兼容之内置指针、tr1::shared_ptrs、auto_ptrs和tr1::weak_ptrs(见条款54)”的构造行为,以及所有来自上述各物(tr1::weak_ptrs除外)的赋值操作。下面是TR1规范中关于tr1::shared_ptr的一份摘录。
1 | template<class T> |
上述所有构造函数都是explicit,唯有“泛化copy构造函数”除外。那意味从某个shared_ptr类型隐式转换至另一个shared_ptr类型是被允许的,但从某个内置指针或从其他智能指针类型进行隐式转换则不被认可(如果是显示转换和cast强制转型动作倒是可以)。另一个趣味点是传递给tr1::shared_ptr构造函数和assignment操作符的auto_ptrs并未被声明为const,与之形成对比的则是tr1::shared_ptrs和tr1::weak_ptrs都以const传递。这是因为条款13说过,当你复制一个auto_ptrs,它们其实被改动了。
member templates并不改变语言规则,而语言规则说,如果程序需要一个copy构造函数,你却没有声明它,编译器会为你暗自生成一个。在class内声明泛化copy构造函数并不会阻止编译器生成它们自己的copy构造函数,所以如果你想要控制copy构造函数的方方面面,你必须同时声明泛化copy构造函数和“正常的”copy构造函数。相同规则也适用于赋值操作。下面是tr1::shared_ptr的一份定义摘要,例证上述所言:
1 | template<class T> |
记住:
- 请使用member function templates(成员函数模板)生成“可接受所有兼容类型”的函数。
- 如果你声明member templates用于“泛化copy构造”或“泛化assignment操作”,你还是需要声明正常的copy构造函数和copy assignment操作符。
条款46
需要类型转换时请为模板定义非成员函数。
条款24讨论过为什么唯有non-member函数才有能力“在所有实参身上实施隐式类型转换”,该条款并以Rational class的operator*函数为例。
1 | template<typename T> |
像条款24一样,我们希望支持混合式算数运算,所以我们希望以下代码顺利通过编译。我们也预期它会,因为它正是条款24所列的同一份代码,唯一不同的是Rational和operator*如今都成了templates:
1 | Rational<int> oneHalf(1, 2); |
上述失败给我们的启示是,模板化的Rational内的某些东西似乎和其non-template版本不同。事实的确如此。在条款24内,编译器知道我们尝试调用什么函数(就是接受两个Rationals参数的那个operator*),但这里编译器不知道我们想要调用哪个函数。取而代之的是,它们试图想出什么函数被名为operator*的template具现化出来。它们知道它们应该可以具现化某个“名为operator*并接受两个Rational<T>参数”的函数,但为完成这一具现化行动,必须先算出T是什么。问题是它们没这个能耐。
为了推导T,它们看了看operator*调用动作中的实参类型。本例中那些类型分别是Rational<int>(oneHalf的类型)和int(2的类型)。每个参数分开考虑。
以oneHalf进行推导,过程并不困难。operator*的第一个参数被声明为Rational<T>,而传递给operator*的第一实参(oneHalf)的类型是Rational<int>,所以T一定是int。其他参数的推导则没有这么顺利。operator*的第二参数被声明为Rational<T>,但传递给operator*的第二实参(2)类型是int。
编译器如何根据这个推算出?你或许会期盼编译器使用Rational<int>的non-explicit构造函数将2转换为Rational<int>,进而将T推导为int,但它们不那么做,因为在template实参推导过程中从不将隐式类型转换函数纳入考虑(因为这样的转换在函数调用过程中出现,但在调用之前,函数首先得存在)。
只要利用一个事实,我们就可以缓和编译器在template实参推导方面受到的挑战:template class内的friend声明式可以指涉某个特定函数。那意味Rational<T>可以声明operator*是它的一个friend函数。Class templates并不依赖template实参推导(后者只施行于function templates身上),所以编译器总是能够在class Rational<T>具现化时得知T。因此,令Rational<T> class声明适当的operator*为其friend函数,可简化整个问题:
1 | template<typename T> |
现在对operator的混合式调用可以通过编译了,因为当对象 oneHalf被声明为一个Rational<int>, class Rational<int>于是被具现化出来,而作为过程的一部分,friend函数operator*(接受Rational<int>参数)也就被自动声明出来。后者身为一个函数而非函数模板,因此编译器可在调用它时使用隐式转换函数(例如Rational的non-explicit构造函数),而这便是混合式调用之所以成果的原因。
但是,此情境下的“成功”是个有趣的字眼,因为虽然这段代码通过编译,却无法连接。稍后我马上回来处理这个问题,首先我要谈谈在Rational内声明operator*的语法。
在一个class template内,template名称可被用来作为“template和其参数”的简略表达方式,所以在Rational<T>内我们可以只写Rational而不必写Rational<T>。本例中这只节省我们少打几个字,但若出现许多参数,或参数名称很长,这可以节省我们的时间,也可以让代码比较干净。我谈这个是因为,本例中的operator*被声明为接受并返回Rationals(而非Rational<T>)。如果它被声明如下,一样有效:
1 | template<typename T> |
然而使用简略表达式比较轻松也比较普遍。
现在回头想想我们的问题。混合式代码通过了编译,因为编译器知道我们要调用哪个函数,但哪个函数只被声明于Rational内,并没有被定义出来。我们意图令此class外部的operator* template提供定义式,但是行不通——如果我们自己声明了一个函数,就有责任定义那个函数。既然我们没有提供定义式,连接器当然找不到它!
或许最简单的可行办法就是将operator*函数本体合并至其声明式内:
1 | template<typename T> |
这便如同我们所期望地正常运作了起来:对operator*的混合式调用现在可以编译并执行。
这项技术的趣味点是,虽然我们使用 friend 关键字,却和其传统用途:访问 class 的 non-public 成分不同。我们是为了让类型转换发生在所有可能的实参上,我们需要一个 non-member 函数;而为了使这个函数自动具现化,我们需要将它声明在 class 内部,而在 class 内部声明 non-member 函数的唯一有效方法就是:令它成为一个 friend。
一如条款30所说,定义于 class 内的函数都将暗自 inline
,所以一个更好的做法是:令该 friend 函数调用另一个辅助函数(减少代码膨胀):
1 | template <typename T> |
记住:
当我们编写一个 class template,而它所提供之“与此 template 相关的”函数支持“所有参数隐式类型转换”时,请将那些函数定义为 “class template 内部的 friend 函数”。
条款47
请使用traits classes 表现类型信息。
STL主要由容器、迭代器和算法的templates构成,也包含若干工具性templates。当中有一个advance用来将迭代器移动某个给定距离:
1 | template<typename IterT, typename DistT> |
表面上看,仅仅是iterate+=d的动作,可是迭代器有5种。仅仅有random access(随机訪问)迭代器才支持+=操作。其它类型没这么大威力。仅仅有重复++和–才行。这里也回想一下这5种迭代器。
- input迭代器。它是read only,仅仅能读取它指向的对象,且仅仅能读取一次。它仅仅能向前移动。一次一步。它模仿指向输入文件的阅读指针(read pointer);C++程序中的istream_iterators就是这类的代表。
- output迭代器,和input迭代器相反。它是write only。它也是仅仅能向前移动,一次一步。且仅仅能涂写一次它指向的对象。它模仿指向输出文件的涂写指针(write pointer);ostream_iterators是这一类代表。
- forward迭代器。这个迭代器派生自input迭代器,所以有input迭代器的全部功能。而且他能够读写指向的对象一次以上。
- bidirectional迭代器继承自forward迭代器,它的功能还包含向后移动。STL中的list、set、multiset、map、和multimap迭代器就是这一类迭代器。
- random access迭代器继承自bidirectional迭代器。它厉害的地方在于能够向前或向后跳跃随意距离,这点相似原始指针,内置指针就能够当做random access迭代器使用。vector、deque和string的迭代器就是这类。
这5中分类。C++标准程序库提供专属卷标结构(tag struct)加以确认:
1 | struct input_iterator_tag {}; |
在了解了迭代器类型后,我们该去实现advance函数了。实现要高效。对于random access迭代器来说,前进d距离要一步完毕。而其它类型则须要重复前进或后退
1 | template<typename Iter, typename DistT> |
在上面实现中要推断iter是否为random access迭代器。即要知道IterT类型是否为random access类型。这就须要traits,它同意我们在编译期间获取某些类型信息。traits是一种技术,是C++程序猿共同遵守的协议。
这个技术要求之中的一个就是,它对内置类型和自己定义类型表现的一样好。如果接受的指针是const char*,advance也必须能够工作。traits必须能够施行于内置类型。意味着“类型内的嵌套信息”这种东西出局了,因为我们无法将信息嵌套于原始指针内。
所以类型的traits信息必须位于类型自身之外。标准技术是把它放进一个template及其一个或多个特化版本号中。这种templates在STL中有若干个,迭代器的为iterator_traits:
1 | template<typename IterT>//用来处理迭代器分类 |
尽管iterator_traits是个struct,往往称作traits classes。其运作方式是,针对每个类型IterT,在struct iterator_traits内声明某个typedef命名为iterator_category,用来确认IterT的迭代器分类。iterator_traits以两个部分实现上述所言。
它要求用户自己定义的迭代器嵌套一个typedef,名为iterator_category。用来确认是哪个卷标结构(tag struct),比如deque和list
1 | template<typename T> |
这样对用户自己定义类型行得通,可是对指针行不通,指针也是迭代器。可是指针不能嵌套typedef。以下就是iterator_traits的第2部分了。专门用来支持指针。为了支持指针迭代器。iterator_traits特别针对类型提供一个偏特化版本号(partial template specialization)。
1 | template<typename IterT> |
如今能够知道实现一个traits class步骤了
- 确认若干我们希望将来可取得的类型相关信息。对于迭代器来说,就是能够取得其分类。
- 为该信息选择一个名称。对于迭代器是iterator_category。
- 提供一个template和一组特化版本号。内含你希望支持的类型和相关信息。
现在能够实现一下advance了:
1 | template<typename IterT, typename DistT> |
尽管逻辑是正确,但并不是是我们想要的。抛开编译问题(条款48),另一个更根本的问题:IterT类型在编译期间获知。所以iterator_traits::iterator_category在编译期间确定。
可是if语句却是在执行期间核定。能够在编译期间完毕的事情推到执行期间,这不仅浪费时间,还造成执行文件膨胀。要在编译期间确定。能够使用重载。重载是在编译期间确定的,编译器会找到最匹配的函数来调用:
1 | template<typename IterT, typename DisT> |
由于forward_iterator_tag继承自input_iterator_tag,所以input_iterator_tag版本号的函数能够处理forward迭代器。这是由于public继承是is-a关系。实际上,random和bidirectional也是(is-a)input迭代器,但编译器会选择匹配程度最高的。
总结一下怎样使用traits class
- 建立一组重载函数或函数模板(比如doAdvance)。彼此间差异仅仅在于各自的traits參数。每个函数实现与之接受的traits信息像匹配。
- 建立一个控制函数或函数模板(比如advance),调用上面的函数并传递traits class信息。
traits 广泛应用于标准库,包括上述iterator_traits,除了iterator_category,iterator_traits还供应四分迭代器相关信息(value_type指明迭代器所指对象类型,difference_type指明迭代器距离类型,pointer指明对象的原生指针类型,reference指明对象的引用类型。此外还有char_traits用于保存字符类型的相关信息,numeric_limits用于保存数值类型相关信息等等。
TR1导入许多新的traits classes用以提供类型信息,包括is_fundamental<T>(判断T是否为内置类型),is_array<T>(判断T是否为数组类型),is_base_of<T1,T2>(判断T1,T2是否相同,抑或T1是T2的base classes).总计TR1一共为C++添加了50个以上的trait classes.
记住:
- Traits classes使得“类型相关信息”在编译期可用。它们以templates和“templates特化”完成实现。
- 整合重载技术后,traits classes有可能在编译期对类型执行if…else测试。
条款48
认识template元编程。
Template metaprogramming(TMP,模板元编程)是编写template-based C++程序,编译的过程。template metaprogramming是用C++写的模板程序,编译器编译出具体化的过程。也就是说,TMP程序执行后,从templates具体化出来C++源码,不再是模板了。
TMP有两个作用,一是它让某些事更容易。例如编写STL容器,使用模板,可是存放任何类型元素。二是将执行在运行期的某些工作转移到了编译期。还有一个结果是使用TMP的C++程序可能在其他方面更高效:较小的可执行文件、较短的运行期、较少的内存需求。但是将运行期的工作转移到了编译期,编译期可能变长了。
再看一下条款47中的advance伪码:
1 | template<typename Iter, typename DistT> |
可以使用typeid让判断iter类型的伪码运行:
1 | template<typename Iter, typename DistT> |
typeid-based解法效率比traits解法低,因为在此方案中,1.类型测试发生在运行期而不是编译期,2.运行期类型测试代码在(或被连接于)可执行文件中。这个例子可以说明TMP比正常的C++程序更高效,因为traits解法就是TMP。
一些东西在TMP比在正常的C++更容易,advance提供一个好例子。advance的typeid-based实现方式可能导致编译期问题
1 | std::list<int>::iterator iter; |
在+=这个操作符上是错误调用。因为list::iterator不支持+=,它是bidirectional迭代器。我们知道不会执行+=那一行,因为typeid那一行总是不相等;但是编译期要确保所有源码都有效,即使是不会执行的代码。traits-based TMP解法针对不同类型执行不同代码,不会出现上述问题。
TMP已被证明是个图灵完全机器,也就是说它的威力足以计算任何事物。可以使用TMP声明变量、执行循环、编写调用函数……。有时候这会和正常C++对应物看起来很是不同,例如条款 47展示的TMP if-else是由templas和其特化具体表现出来。不过那是汇编语言级的TMP。针对TMP设计的程序库(例如Boost’s MPL,条款55)提供更高级的语法。
为了再次认识下事物在TMP中如何运作,来看下循环。TMP没有真正循环,循环由递归(recursion)完成。TMP递归甚至不是正常的递归,因为TMP递归不涉及递归函数调用,而是涉及递归模板化(recursive template instantiation)。
TMP的起手程序是在编译期计算阶乘。TMP的阶乘运输示范如何通过递归模板具体化实现循环,以及如何在TMP中创建和使用变量
1 | template<unsigned n> |
有了这个template metaprogram,只要指涉Factorial::value就可以得到n阶乘值。循环发生在template具体化Factorial内部指涉另一个template具体化Factorial之时。特殊情况的template特化版本Factorial<0>是递归的结束。
每个Factorial template具体化都是一个struct,每个struct都声明一个名字为value的TMP变量,用来保存当前计算所获得的阶乘值。TMP以递归模板具体化取代循环,每个具体化有自己一份value,每个value有其循环内适当值。
用Factorial示范TMP就像用hello world示范编程语言一样。为了领悟TMP之所以值得学习,就要先对它能够达成什么目标有一个比较好的理解。下面举三个例子:
确保量度单位正确。使用TMP就可以确保在编译期所有量度单位的组合都正确。
优化矩阵运算。条款 21曾经提到过某些函数包括operator * 必须返回新对象,在条款44中有一个SquareMatrix。如果这样使用
1
2
3
4typedef SquareMatrix<double,1000> BigMatrix;
BigMatrix m1,m2,m3,m4,m5;
……
BigMatrix result=m1 * m2 * m3 * m4 * m5;上面乘法会产生四个临时性矩阵,乘法还可能产生了4个作用在矩阵元素身上的循环。如果使用高级、与TMP相关的template(即expression templates),就有可能消除那些临时对象并合并循环。所以TMP使用较少内存,执行速度也有提升。
可以生成客户定制之设计模式(custom design pattern)实现品。使用policy-based design之TMP-based技术,有可能产生一些templates用来表述独立的设计项(所谓policies),然后可以任意结合它们,导致模式实现品带着客户定制的行为。
TMP目前还不完全成熟,语法不直观,支持的工具还不充分。但TMP对难以或甚至不可能于运行期实现出来的行为表现能力很吸引人。虽然TMP不会成为主流,但是会成为一些程序员(特别是程序库的开发人员)的主要粮食。
记住:
- Template metaprogramming(TMP,模板元编程)可将工作由运行期移到编译期,因而得以实现早期错误侦测和更高的执行效率。
- TMP可被用来生成“基于政策选择组合”(based on combinations of policy choices)的客户定制代码,也可用来避免生成对某些特殊类型并不适合的代码。
条款49
了解new-handler的行为。
在operator new抛出异常以前,会先调用一个客户指定的错误处理函数:new-handler。(这其实并非全部事实,operator new 真正做的事更复杂,见条款51)。为了指定这个“用以处理内存不足”的函数,客户必须调用 set_new_handler,那是声明于 <new> 的标准程序库函数:
1 | namespace std{ |
set_new_handler 是“获得一个 new-handler 并返回一个 new-handler ” 的函数,后面的 throw() 是一份异常明细,表示该函数不抛出任何异常。
set_new_handler 的参数是个指针,指向 operator new 无法分配足够内存时该被调用的函数;其返回值也是个指针,指向set_new_handler被调用前正在执行的那个 new-handler 函数。可以这样使用set_new_handler :
1 | void outOfMem() //operator new 无法分配足够内存时该被调用的函数 |
如果operator new无法为100000000个整数分配足够空间,outOfMem会被调用,于是程序在发出一个信息之后夭折(abort)。
当operator new无法满足内存申请时,它会不断调用 new-handler 函数,直到找到足够内存。反复调用的代码在条款51讨论。这里先说一下,设计良好的new-handler必须做好以下事情:
- 让更多内存可被使用:这样可以造成operator new内的下一次内存分配动作可能成功。一个做法是,程序一开始就分配一大块内存,当 new-handler 第一次被调用时将它释放。
- 安装另一个new-handler:当前的 new-handler 无法取得更多可用内存时,或许它知道另外哪个new-handler有此能力。如果真这样,可用使用 set_new_handler 来替换有能力的那个。
- 卸除 new-handler:即将null指针传给 set_new_handler,一旦没有安装任何 new-handler,operator new 在内存分配不成功时便抛出异常。
- 抛出 bad_alloc(或派生自bad_alloc)的异常:这样的异常不会被 operator new 捕捉,因此不会被传播到内存索求处。
- 不返回:通常 abort 或 exit 。
有时候,我们希望处理内存分配失败的情况和类相关。例如:
1 | class X{ |
C++并不支持类专属的 new-handler,但是我们自己可以实现这种行为。令每一个类提供自己的 set_new_handler 和 operator new即可。其中 set_new_handler 使客户得以指定类专属的 new-handler,operator new则确保在分配类对象内存的过程中以类专属的 new-handler替换 global new-handler。
假设打算处理 Widget 类 内存分配失败的情况。首先要有一个”当 operator new 无法为Widget 分配足够内存时”调用的函数,所以你需要声明一个类型为 new_handler 的 static 成员,用以指向 Widget 的 new-handler,看起来像这样:
1 | class Widget{ |
Widget 的 operator new 做以下事情:
- 调用标准 set_new_handler,告知 Widget 的错误处理函数。这会将 Widget 的 new-handler 安装为 global new-handler。
- 调用 global operator new 分配内存。如果失败,global operator new 会调用 Widget 的 new-handler,因为那个函数才刚被安装为 global new-handler。如果 global operator new 最终无法分配足够内存,会抛出一个 bad_alloc 异常。这时 Widget 的operator new 必须恢复原本的 global new-handler,之后再传播该异常。为确保原本的 new-handler 总是能够被重新安装回去,使用资源管理对象防止资源泄漏(见条款13)。
- 如果 global operator new 分配内存成功,Widget 的 operator new 会返回一个指针,指向分配的内存。Widget 析构函数会管理 global new-handler,它会自动将 Widget’s operator new 被调用前的那个 global new-handler 恢复回来。
- 附:这里无论分配内存成功或失败,都要把旧的的new-handler用std::set_new_handler恢复(实际上安装也是使用set_new_handler,最终使用的都是std的,自定义的版本只是负责安装和回收),这是因为这个new只是针对Widget的,这件事情发生完,Widget安装的new-handler就应该换回去。
下面以C++代码再阐述一次,将从资源管理类开始,那里只有基础性RAII操作,再构造过程中获得一笔资源,并在析构中释还(见条款13):
1 | class NewHandlerHolder{ |
这使得Widget
类的 operator new 函数的实现变得简单:
1 | void* Widget::operator new(std::size_t size) throw(std::bad_alloc) { |
Widget客户应该类似这样使用其new-handling:
1 | void outOfMem(); //函数声明,此函数在 Widget 对象分配失败时被调用 |
实现这个方案的代码并不因 class 的不同而不同,因此在其它地方也复用这个代码是个合理的构想。一个简单的方式是建立起一个“mixin” 风格的基类,这种基类用来允许派生类继承单一特定能力——在本例中是“设定类专属的 new-handler 能力”。然后将这个基类转换为模板,如此一来每个派生类将获得实体互异的 class data 复件。
这个基类让其派生类继承它获取 set_new_handler和 operator new函数,而模板部分确保每一个派生类获得一个实体互异的currentHandler 成员变量。实现代码和前一个版本的近似,唯一真正意义上不同的是,它现在可被任何有所需要的类使用:
1 | template<typename T> |
有了这个 类模板,为 Widget
添加 set_new_handler
支持能力就容易了:只要令 Widget
继承自 NewHandlerSupport<Widget>
就好,像下面这样:
1 | class Widget:public NewHandlerSupport<Widget>{ |
在 NewHandlerSupport
模板中,从未使用到 类型T,这是为什么呢?
实际上 T 的确不需被使用。我们只希望继承 NewHandlerSupport 的每一个 类 拥有自己的 NewHandlerSupport 复件(其 static 成员变量 currentHandler ),类型参数 T 是用来区分不同的 派生类,模板机制会自动为每一个 T 生成一份 currentHandler 成员变量。
虽然通过继承 NewHandlerSupport
,使得“为任何类添加一个它们专属的new-handler”成为一件很容易的事,但 “mixin” 风格的继承肯定导致 多重继承 的争议,要注意条款40所提到的内容。
C++中新一代的 operator new 分配失败抛出异常 bad_alloc,但是旧标准是返回 null 指针,为了兼容以前使用旧标准的C++程序,C++委员会提供了另一种符合旧标准形式的 operator new , 这个形式被称为 “nothrow” 形式:
1 | class Widget{ ... }; |
nothrow new 对 异常的强制保证性(见条款29)并不高。表达式 new (std::nothrow)Widget 会发生两件事:
第一,分配内存给 Widget 对象,如果失败返回 null ;第二,如果成功,调用 Widget 的构造函数,在这个构造函数中可能又 new 一些内存,但没人可以强迫它再次使用 nothrow new。因此,虽然 new (std::nothrow)Widget 调用的 operator new 函数并不抛出异常,但 Widget 的构造函数可能会抛出异常 。
结论是:使用 nothrow new 只能保证 operator new 不抛出异常,不能保证像new (std::nothrow)Widget这样的表达式不抛出异常。所以,并没有使用 nothrow 的需要。
无论使用正常(会抛出异常)的 new,或是不抛出异常的 nothrow new ,重要的是需要了解 new-handler 的行为,因为两种形式都使用到 new-handler。
记住:
- set_new_handle 允许用户指定一个函数,在内存分配无法获得满足时被调用
- nothrow new 是一个颇为局限的工具,因为它只适用于内存分配;后继的构造函数调用还是有可能抛出异常
条款50
了解new和delete的合理替换时机。
有人会想要替换掉编译器提供的 operator new 或 operator delete,有三个理由:
- 用来检测运用上的错误:如果将“new 所得内存” delete 掉却失败,会导致内存泄漏。如果在“new 所得内存”身上多次 delete 会导致不确定行为。使用编译器提供的operator new和operator delete不能检测上述行为。如果 operator new 持有一串动态分配所得地址,而 operator delete 将地址从中移走,这样容易检测出上述错误用法。此外各式各样的编程错误可能导致数据 “overruns”(写入点在分配区块尾端之后)或 “underruns”(写入点在分配区块起点之前)。这时可以自己定义operator new 便可超额分配内存,在额外空间(位于用户所得区块之前或之后)写上特定 byte patterns(即签名,signature),自己定义 operator delete 检 测签名是否更改,若被更改了表示在分配区的某个生命时间点发生了 overrun 或 underrun,这时 operator delete 可以记录(log)那个事实以及那个签名被更改的指针。
- 为了强化效能:我们所用的编译器中自带的operator new和operator delete主要是用于一般的目的能够为各种类型的程序所接受,而不考虑特定的程序类型。它们必须处理一系列需求,必须接纳各种分配形态,必须要考虑破碎问题等等。因此编译器所带的operator new和operator delete采取中庸之道也是没办法的事情。它们的工作对每个人都是适度地好,但不对特定任何人有最佳表现。通常可以发现,定制版之operator new和operator delete性能胜过缺省版本。所谓的’胜过’,就是它们比较快,有时甚至快很多,而且它们需要内存比较少,最高可省50%。所以说对某些运用程序而言,将缺省new和delete替换为定制版本,是获得重大效能提升的办法之一。
- 为收集使用上的统计数据:在自定义 operator new 和 operator delete 之前,应该首先了解软件如何使用动态内存。分配区块如何分布?寿命分布如何?它们是先进先出(FIFO)还是后进先出(LIFO)顺序或随机顺序来分配和归还?软件在不同执行阶段有不同的分配归还形态吗?任何时刻使用的最大动态分配量是多少?自定义的 operator new 和 operator delete 可以轻松收集到这些信息。
写个定制的operator new和operator delete并不难。例如,写个global operator new,用于检测在分配区块的后面或前面写入数据。下面是个初步版本,有小错误,后面在完善。
1 | static const int signature = 0xDEADBEEF; |
暂且忽略上述代码中没有条款51所说的所有 operator new 都应该内含一个循环,反复调用某个 new-handling 函数。来说一下另外一个主题:对齐。
许多计算机体系结构要求特定的类型必须放在特定的内存地址上。例如它可能要求指针的地址必须是4的倍数(four-byte aligned)或double 的地址是8的倍数(eight-byte aligned)。如果没有这些约束条件可能会导致运行期硬件异常。有些体系结构要求没这么严格,而是宣称如果满足对齐条件,便提供更佳的效率。
C++要求所有 operator new 返回的指针都有适当的对齐(取决于数据类型)。malloc 就是在这样的要求下工作,所以令 operator new返回一个得自 malloc 的指针是安全的。但是上面 operator new 返回一个得自 malloc 且偏移一个 int 大小的指针,没人能保证它的安全。如果用户调用 operator new 企图获取足够给一个 double 所用的内存,而我们在一部 “int 为4位 且 double 必须为 8 位 对齐”的机器中跑程序,我们可能会获得没有对齐的指针,那可能造成程序崩溃或执行速度慢。
像对齐这类技术细节,可以区分内存管理器的质量。写一个能够运行的内存管理器并不难,难的是让它总是能够高效优良的运作。一般来说,若非必要,不要去写内存管理器。
很多时候也是非必要的。有些编译器已经在它们的内存管理函数中切换至调试状态(enable debugging)和志记状态(logging)。许多平台上有商业产品可以代替编译器自带的内存管理器,可以用它们来提高机能和改善效率,你唯一需要做的就是 重新链接。
另外一个选择是开源领域中的内存管理器。它们对许多平台都可以用。Boost程序库(见条款 55)的Pool就是这样的一个分配器,它对常见的分配“大量小型对象”很有帮助。一些小型开源内存分配器大多都不完整,缺少移植、线程安全、对齐等考虑。
本条款的主题是,了解何时可在“全局性的”或“class专属的”基础上合理替换默认的 new 和 delete:
- 为了检测运用错误(如前所述)
- 为了手机动态分配内存的使用统计信息(如前所述)
- 为了增加分配和归还的速度。使用定制的针对特定类型对象的分配器,可以提高效率。类专属分配器是“区块尺寸固定”的分配器实例,例如 Boost 提供的 Pool 程序库便是。如果在单线程程序中,你的编译器所带的内存管理具备线程安全,你可以写个不具备线程安全的分配器而大幅度改善速度。
- 为了降低默认内存管理器带来的空间额外开销。泛用型内存分配器往往(虽然并非总是)不只比定制型慢,还使用更多内存,因为它们常常在每一个分配区块上招引某些额外开销。针对小型对象开放的分配器(例如 Boost 库的Pool)本质上消除了这样的额外开销。
- 为了弥补默认分配器中的非最佳对齐。X86体系结构上的 double 访问最快–如果它们是8-byte对齐。但是编译器自带的 operator new 并不保证对动态分配而得的 double 采取8-byte对齐。这种情况下,将默认的 operator new 替换位一个 8-byte 对齐的版本,可使程序效率提升。
- 为了将相关对象成簇集中。如果特定的某个数据结构往往被一起使用,我们希望在处理这些数据时将“内存页错误”(page faults)的频率降至最低,那么为此数据结构创建另一个 heap 就有意义,这样就可以将它们成簇集中到尽可能少的内存页上。new 和 delete 的 “placement版本”(见条款52)有可能完成这一的集簇行为。
- 为了获得非传统的行为。有时候我们需要 operator new 和 delete 做编译器附带版没做的某些事情。例如,在归还内存时将其数据覆盖为0,以此增加应用程序的数据安全。
记住:
有许多理由需要写个自定义的 new 和 delete,包括改善效能、对 heap 运用错误进行调试、收集 heap 使用信息。
条款51
编写new和delete时需固守常规。
从operator new开始:
- 实现一致性 operator new 必须返回正确的值
- 内存不足时必须调用 new-handling 函数(见条款49)
- 必须有对付零内存需求的准备
- 避免不慎掩盖正常形式的 new
这比较偏近 class 接口的要求而非实现要求。正常形式的 new 描述于条款 52。
operator new 的返回值十分单纯。如果申请内存成功,就返回指向那块内存的指针,失败则遵循条款 49描述的规则,并抛出 bad_alloc 异常。
然而也不是非常单纯。因为 operator new 实际上不止一次尝试分配内存,并在每次失败后都调用 new-handling 函数。这里假设 new-handling 函数能做某些动作将一些内存释放出来。只有当指向 new-handling 函数的指针为 null,operator new 才会抛出异常。但C++规定,即使客户要求0 byte,operator new 也要返回一个合法指针。下面是个non-member operator new的伪代码:
1 | void* operator new(std::size_t size) throw(std::bad_alloc) { |
对于 0 byte 的内存申请视为 1 byte 的内存申请,做法简单、合法、可行。其中将 new-handling 函数指针设为 null 而后又立刻恢复原样,是因为我们没有任何办法可以直接取得 new-handling 函数指针,所以利用 set_new_handler 函数的返回值是前一个 new-handling 函数指针的特性。这种方法在单线程环境下很有效,但在多线程环境下,还需要某种锁机制,以便处理 new-handling 函数背后的(global)数据结构。
条款49提到 operator new 内含一个无穷循环,而上述代码中的第 6 行(while(true))就是那个无穷循环。退出此循环唯一办法是:内存分配成功或 new-handling 函数做了一件描述于条款49的事:让更多内存可用、安装另一个 new-handler、卸载 new-handler、抛出 bad_alloc异常(或其派生类),或承认失败直接 return。
上面的 operator new 成员函数可能会被derived classes继承。注意分配内存大小size,它是函数接收的实参。条款50提到,定制内存分配器往往是为了特定的 class 对象,以此来优化,而不是为了该 class 的任何派生类。也就是说,针对 class X 而设计的 operator new ,其行为只为大小刚好为 sizeof(X) 的对象而设计。然而一旦被继承,有可能基类的 operator new 被调用用于分配派生类对象:
1 | class Base{ |
如果基类专属的 operator new 并非被设计用来应对上述情况(实际上往往如此),处理这种情况的方法是:将“内存申请量错误”的调用行为改为标准 operator new,就像这样:
1 | void* Base::operator new(std::size_t size) throw(std::bad_alloc) { |
不需要再检验size是否为0,C++裁定所有非附属(独立式)对象必须有非零大小(见条款39)。因此sizeof(Base)无论如何不能为零。
如果你打算控制 class 专属的 “arrays 内存分配行为”,那么你需要实现 operator new[](这个函数通常被称为 “array new”)。编写operator new[] 时,唯一要做的事就是分配一块未加工的内存,因为你无法对 array 之内迄今尚未存在的元素对象做任何事。甚至我们无法知道这个 array 含有多少个元素对象。首先你不知道每个对象多大,毕竟 基类 的 operator new[] 有可能经由继承被调用,将内存分配给 “元素为 派生类 对象” 的 array使用。
因此,你不能在 Base::operator new[] 中假设 array 的每个元素对象大小是 sizeof(Base),这样就是说你不能假设 array 元素个数是(bytes申请数 / sizeof(Base))。此外,传递给 operator new[] 的 size_t 参数,其值有可能比“将被填以对象”的内存更大,因为条款 16提过,动态分配的 arrays 可能包含额外空间用来存放元素个数。
上面就是自定义 operator new 需要遵守的规矩。operator delete 情况更简单,你需要记住的唯一事情就是
- C++保证删除 null 指针永远安全
下面是 non-member operator delete的伪代码:
1 | void operator delete(void* rawMemory) throw(){ |
这个函数的 member 版本也很简单,只需多加一个动作——检查删除数量。万一你的 class 专属的 operator new 将大小有误的分配行为转交 ::operator new
执行,你也必须将大小有误的删除行为转交 operator delete
执行:
1 | class Base{ |
如果即将被删除的对象派生自某个基类 ,而后者没有虚析构函数,那么 C++ 传给 operator delete 的 size_t 数值可能不正确。也就是说,如果基类遗漏虚析构函数,operator delete 可能无法正常运作。
记住:
- operator new 应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用 new-handler。它也应该有能力处理 0 bytes 申请。class 专属版本的还应该处理“比正确大小更大的(错误)申请”
- operator delete 应该在收到 null 指针时不做任何事。class专属版本则还应该处理“比正确大小更大的(错误)申请”
条款52
写了placement new也要写placement delete。
placement new 和 placement delete 在C++中并不常见,如果你不熟悉它们,也不用担心。当你写一个 new 表达式时:
1 | Widget* pw = new Widget; |
共有两个函数被调用:一个是以分配内存的 operator new,一个是 Widget
的默认构造函数。
假如第一个函数调用成功,但第二个函数却抛出异常,这时需要释放第一步分配得到的内存,否则就造成了内存泄漏。这个时候,用户没有能力去归还内存,因为如果 Widget构造函数抛出异常,那么 pw 尚未被赋值,用户手中的指针还没有指向开辟的内存。因此释放第一步分配得到的内存的任务落到了C++运行期系统身上。
运行期系统会调用第一个函数 operator new 所对应的 operator delete 版本,前提当然是它必须知道哪一个 operator delete(因为可能有多个)版本。如果目前面对的是拥有正常签名式的 new 和 delete,这并不是问题,正常的 operator new 和对应于正常的 operator delete:
1 | void *operator new(std::size_t) throw(std::bad_alloc); //正常形式 |
如果使用正常的 operator new 和 operator delete,运行期系统可以找到如何释放 new 开辟内存的 delete 函数。但是如果使用非正常形式的 operator new,究竟使用哪个 delete 的问题就出现了。
举个例子,假设编写一个 class 专属的 operator new,要求接受一个 ostream,用来记录(logged)相关分配信息,同时又写了一个正常形式的 class 专属 operator delete:
1 | class Widget{ |
这个设计有问题,但我们探讨原因之前,需要先绕道,简单讨一些术语。
如果 operator new 接受的参数除了必有的 size_t 之外还有其他,这便是 placement new。因此,上述的 operator new 是个 placement 版本。众多 placement new 版本中,有一个特别有用的是 “接受一个指针指向对象该被构造之处”,vector使用它在未使用的内存上创建对象,那样的 operator new 形式如下:
1 | void* operator new(std::size_t, void* pMemory) throw(); //placement new |
placement new 有多重定义,一是带任意额外参数的new ,二是只有一个额外参数 void*。当人们谈到 placement new ,大多数是指后者。
现在让我们回到 Widget
的声明式,也就是之前我说设计有问题的那个。这里的难点是,那个类将引起微妙的内存泄漏。看下面的例子,它在动态创建一个 Widget
时将相关的分配信息记录(logs)于 cerr:
1 | Widget* pw = new (std:cerr) Widget;//调用operator new并传递cerr作为ostream实参, |
如果内存分配成功,而 Widget 构造函数抛出异常,运行期系统要释放 operator new 开辟的内存。但运行期系统不知道真正被调用的 operator new 如何运作,因此它无法释放内存。所以上述做法行不通。取而代之的是,运行期系统寻找参数个数和类型都与 operator new 相同的 operator delete,如果找到,那就是它的调用对象。上述代码中调用的 operator new 对应的 operator delete为:
1 | void operator delete(void*, std::ostream&) throw(); |
类似于 new 的 placement 版本,operator delete 如果接收额外参数,便称为 placement delete。上面 Widget 没有 placement 版本的operator delete,所以运行期系统不知道如何释放 operator new 开辟的内存,于是什么都不做。所以,如果 Widget 构造函数抛出异常,不会有任何的 operator delete 被调用。
为了解决上述问题,Widget
有必要声明一个 placement delete,对应那个有记录功能(logging)的 placement new:
1 | class Widget{ |
这样改变之后,如果以下语句导致Widget
构造函数抛出异常,就不会造成内存泄漏了:
1 | Widget* pw = new (std:cerr) Widget; //这次内存不在泄漏 |
如果 Widget
构造函数抛出异常,就会调用对应版本的placement delete;如果没有异常,就会调用正常形式的 operator delete,如下:
1 | delete pw;//客户调用 |
需要注意的是:placement delete 只有在 placement new 调用构造函数抛出异常时才会被调用。对着一个指针(例如上述的pw)施行 delete 绝不会导致调用 placement delete。
这意味着:如果要对所有与 placement new 相关的内存泄漏宣战,我们必须同时提供一个正常的 operator delete (用于构造期间无任何异常被抛出)和一个 placement 版本(用于构造期间有异常被抛出), placement 版本的额外参数必须和 operator new 一样。
需要注意的是,因为成员函数的名称会掩盖其外围作用域中相同名称的函数(见条款33),所以要小心避免 class 专属的 new 掩盖用户希望调用的 new。例如,你有一个 Base class,其中声明唯一一个 placement,用户会发现他们无法使用正常形式的 new:
1 | class Base{ |
同样道理,派生类 的 operator new 会掩盖继承而来的 operator new 和 global 版本的 new:
1 | class Derived: public Base{ //继承自先前的Base |
条款33更详细讨论了这种名称遮掩问题。对于撰写内存分配函数,你需要记住的是,默认情况下C++在 global 作用域内提供以下形式的operator new:
1 | void* operator new(std::size_t) throw(std::bad_alloc); //normal new |
如果你在 class 内声明任何形式的 operator new ,它都遮掩上述这些标准形式。除非你想要阻止 class 的用户使用这些形式,否则请确保它们在你所生成的任何自定义 operator new 之外还可用。对于每一个可用的 operator new,也要确保提供了对应形式的 operator delete。如果你希望这些函数有着平常的行为,只要令你的 class 专属版本调用 global 版本即可。
完成上面所说的一个简单的做法是,建立一个基类,内含所有正常形式的new和delete
1 | class StadardNewDeleteForms{ |
如果想以自定义方式扩充标准形式,可以使用继承机制和using声明式(见条款33)取得标准形式:
1 | class Widget: public StandardNewDeleteForms{ //继承标准形式 |
记住:
- 当你写一个 placement operator new,请确定也写出了对应的 placement operator delete。如果没有这样做,就可能造成隐蔽的内存泄漏。
- 当你声明 placement new 和 placement delete ,请确定不要无意识(非故意)地遮掩了它们的正常版本。
条款53
不要轻忽编译器的警告。
许多程序员习惯性的忽略编译器的警告。他们认为,如果问题很严重,那么编译器应该给一个错误而不是警告。这种想法在C++是不可取的,以一个例子来说明:
1 | class B{ |
这里希望 D::f()
重新定义 虚函数 B::f()
,但其中有个错误,B::f()
是个 const成员函数,而 D 不是。编译器不会报错,可能会给如下警告:
1 | warning: D::f() hides virtual B::f() |
经验不足的程序员认为这是 “D::f 遮掩了 B::f” ,这正是他们想要的。但这是错的,编译器是在告诉你声明于 B 中 的 f 并未在 D 中被重新声明,而是被整个遮掩了(条款33解释为什么会这样)。如果忽略这个警告,几乎肯定导致错误的程序行为,然后为了找出这个编译器已经告诉你的错误而进行许多调试。
1 | c++多态规定,基类和派生类中同名虚函数的函数名、返回值类型、函数参数个数及参数类型等必须完全相同。 |
一旦从编译器的警告信息中获得经验,你将学会了解不同的警告信息意味什么,那往往和它们“看起来“的意义并十分不同。一般认为,写出一个在最高警告级别下也没有任何警告信息的程序是最理想的,然而你一旦对警告信息有了深刻理解,可以选择忽略某些警告信息。但是一定记住在忽略这个警告之前,一定要了解它的真正意义。
警告信息和编译器相依,不同的编译器有不同的警告标准。所以,草率编程然后倚赖编译器为你指出错误的行为并不可取。例如上面代码中的函数遮掩在另一个编译器中编译,可能没有任何警告。
记住:
- 严肃对待编译器发出的警告信息。努力在你的编译器最高警告级别下争取”无任何警告“。
- 不要过度依赖编译器的报警能力,因为不同编译器对待事情的态度并不相同。一段有警告的代码,移植到另一个编译器上,可能没有任何警告。
条款54
让自己熟悉包括TR1在内的标准程序库。
C++98列入的C++标准 程序库有哪些主要成分:
- STL(Standard Template Library):覆盖容器、迭代器、算法、函数对象、各种容器适配器和函数对象适配器。
- Iostreams:覆盖用户自定缓冲功能、国际化I/O,以及预先定义好的对象cin,cout,cerr和clog。
- 国际化支持:包括多区域能力,像wchar_t和wstring等类型都对促进unicode有所帮助。
- 数值处理:包括复数模板和纯数值数组。
- 异常阶层体系:包括base class exception及其derived classes logic_error和runtime_error,以及更深继承的各个classes。
- C89标准程序库:1989 C标准程序库内的每个东西也都被覆盖于C++内。
TR1详细叙述了14个新组件,统统都放在std命名空间内,更正确地说是在其嵌套命名空间tr1内。因此TR1组件shared_ptr的全名是std::tr1::shared_ptr。本书通常在讨论标准程序库组件时略而不写std::,但我总是会在TR1组件之前加上tr1::。
本书展示以下TR1组件实例:
智能指针tr1::shared_ptr和tr1::weak_ptr。前者的作用有如内置指针,但会记录有多少个tr1::shared_ptrs共同指向同一个对象。所谓的reference counting(引用计数)。一旦最后一个这样的指针被销毁,这个对象被自动删除。但是如果两个或多个这样的指针形成环,这会造成每个对象的引用次数都超过0——即使这个环形的指所有指针都已被销毁。tr1::weak_ptr的设计使其表现像是“非环形tr1::shared_ptr-based数据结构”中的环形感生指针(cycle-including pointers)。tr1::weak_ptr并不参与引用计数的计算;当最后一个指向某对象的tr1::shared_ptr被销毁,纵使还有个tr1::weak_ptrs继续指向同一对象,该对象仍旧会被删除。这种情况下的tr1::weak_ptr会被自动标示无效。
tr1::function:此物得以表示任何callable entity(可调用物,也就是任何函数或函数对象),只要其签名符合目标。假设我们想注册一个callback函数,该函数接受一个int并返回一个string,我们可以这么写:
1
void registerCallback(string func(int)); //参数类型是函数,该函数接受一个int并返回一个string
其中参数名称func可有可无,所以上述的registerCallback也可以这样声明:
1
void registerCallback(string (int));
tr1::function使上述的RegisterCallback有可能更富弹性地接受可调用物,只要这个可调用物接受一个int或任何可转换为int的东西,并返回一个string或任何可被转换为string的东西。tr1::function是个template,以其目标函数的签名为参数:
1
void registerCallback(tr1::function<string (int)>func);
tr1::bind:它能够做STL绑定器bindlst和bind2nd所作的每一件事,而又更多。
其他TR1组件划分为两组。第一组提供彼此不相干的独立机能:
- Hash tables:用来实现sets,multisets,maps和multi-maps。每个新容器的接口都以前任(TR1之前的)对应容器塑膜而成。
- 正则表达式:包括以正则表达式为基础的字符串查找和替换,或是从某个匹配字符串到另一个匹配字符串的逐一迭代等等。
- Tuples:这是标准程序库中的pair_template的新一代制品。pair只能持有两个对象,tr1::tuple可持有任意个数的对象。
- tr1::array:本质上是个“STL化”数组,即一个支持成员函数如begin和end的数组,不过tr1::array的大小固定,并不适用动态内存。
- tr1::mem_fn:这是个语句构造上与成员函数指针一致的东西。tr1::mem_fn纳入并扩充了C++98的men_fun和mem_fun_ref的能力。
- tr1::reference_wrapper:一个“让references的行为更像对象”的设施。它可以造成容器“犹如持有references”。而容器实际上只能持有对象或指针。
- 随机数生成工具:它大大超越了rand,那是C++继承自C标准程序库的一个函数。
- 数学特殊函数:包括Lagnuerre多项式、Bessel函数、完全椭圆积分,以及更多数学函数。
- C99兼容扩充:这是一大堆函数和模板,用来将许多新的C99程序库特性带进C++。
第二组TR1组件由更精巧的template编程技术(包括template metaprogramming)构成:
- Type traits:一组traits class,用以提供类型的编译期信息。(见条款47)
- tr1::result_of:这是个template,用来推导函数调用的返回类型。当我们编写template时,能够“指涉函数调用动作所返回的对象的类型”往往很重要,但是该类型有可能以复杂的方式取决于函数的参数类型。tr1::result_of使得“指涉函数返回类型”变得十分容易。
记住:
- C++标准程序库的主要机能由STL、iostreams、locales组成。并包含C99标准程序库。
- TR1添加了智能指针、一般化函数指针、hash-based容器、正则表达式以及另外10个组件的支持。
- TR1自身只是一份规范,为获得TR1提供的好处,你需要一份实物。一个好的实物来源是Boost。
条款55
让自己熟悉Boost。
你正在寻找一个高质量,源码开放,平台独立,编译器独立的程序库吗?看看 Boost 吧。有兴趣加入一个由雄心勃勃,充满才干的 C++ 开发人员组成的社群,致力发展当前最高技术水平的程序库吗?看看 Boost 吧。想要一瞥未来 C++ 的面目吗?看看 Boost 吧。
Boost网址:Boost C++ Libraries
Boost有两点被认为是其它组织所不能比拟的:
- Boost 委员会和 C++ 标准委员会成员有很密切的关系,并对其有着深刻的影响能力。Boost 由标准委员会创立,因此两者成员有着很大重叠。Boost 有个目标:作为一个可被加入到标准 C++ 的功能测试场。这层关系造就的结果是:以 TR1 为例,进入标准 C++ 的 14个新程序库中,超过三分之二奠基于 Boost 的工作成果。
- Boost 接纳新程序库的过程也很有意思。它以公开进行的同僚复审为基础。如果你有意贡献一个程序库给 Boost,首先要对 Boost 开发者邮递开发作品,在评价这个程序库的重要性之后,启动初步审查程序。
- 当你最终正式提交时,你要满足一些最低条件。例如它必须通过至少两个编译器,以展示至此微不足道的可移植性,你必须证明你的程序库是在一个可接受的授权许可下是可用的,例如必须免费商业化和非商业化用途…
- 进入复审阶段,会有志愿者查看你的程序库和各种素材,例如源码,设计文档,使用说明等,并考虑以下问题:
- 这份设计和实现有多么好?
- 这些代码可跨编译器和操作系统吗?
- 这个程序库有可能被它所设定的用户使用吗?
- 这些对于阻挡低劣的程序库很有帮助,并且
启发程序库作者认真考虑一个工业强度,跨平台的程序库设计,实现和文档工程
。
Boost 程序库涉及的领域很多:
字符串和文本处理:包括类型安全(type-safe) 的形如printf的格式化库,正则表达式(TR1中相似的功能的基础,看条款54),tokenizing 和 parsing 。
容器:包括STL风格接口的大小固定的数组(fixed-size arrays),可变大小bitsets,和多维数组(multidimensional arrays.)。
函数对象(Function objects)和高阶编程(higher-order programming):包括几个TR1中作为功能性基础使用的库。一个有趣的库是 Lambda, 它使得凭空创建一个函数对象(function objects)如此容易,你甚至不需要知道到你做了什么:
1
2
3
4
5
6
7using namespace boost::lambda; //使 boost::lambda可见
std::vector<int> v;
...
std::for_each(v.begin(), v.end(), //遍历v中的元素x,
std::cout << _1 * 2 + 10 << "n"); //输出 x*2+10;
// "_1"是Lambda
//为当前元素设置的置位符泛型编程(Generic programming):包括一个traits类的扩展集(看条款 47关于traits 的资料)。
模板元编程(Template metaprogramming TMP 看条款 48):包括一个Boost MPL 这样的编译期断言库(compile-time assertions)。在MPL极好事情之一是支持STL风格的形如类型(types)的编译期实体的数据结构。
1
2
3
4
5
6
7
8
9//创建一个形如list(list-like)的编译期容器,容器包括3种数据类型(float, double 和 long double),并命名为"floats"。
typedef boost::mpl::list<float, double, long double> floats;
//创建一个新的编译期的由"floats"及在其前端插入的"int"所组成类型的list;并命名新的容器为"types"。
typedef boost::mpl::push_front<floats, int>::type types;
/*
这样的“类型容器”(经常被称为typelists,虽然他们也基于mpl::list和mpl::vector创建)
打开了通向强大且重要的TMP应用的广阔天地。
*/数学和数值(Math and numerics):包括有理数库(rational numbers);octonions和四元数(quaternions);最大公约数(greatest common divisor)和最小公倍数(common multiple computations);随机数(另一个影响TR1相关功能的库)。
正确性和测试(Correctness and testing):包括形式化隐式模板接口(formalizing implicit template interfaces (阅读条款 41)) 方便测试优先(test-first) 编程。
数据结构(Data structures):包括类型安全的unions库(例:存储变量的”any”类型)和导致相应TR1功能的tuple库。
交互语言支持(Inter-language support):包括充许在C++和Python之间进行无缝协作的库。
内存:包括高性能的固定大小分配的Pool库;多样的智能指针,包括(但不限于)在TR1中的智能指针。非TR1智能指针是scoped array, 为动态分配数组的auto_ptr风格的智能指针; 条款44展示一个使用的例子。
请记住,这只是一份抽样,并不是一份详尽清单。
Boost 提供的程序库可做的事情有很多,但它并未覆盖编程的所有领域,不过纵使你没能找到刚好符合需求的作品,也一定会在其中发现一些有趣的东西
。
记住:
- Boost 是一个社群,也是一个网站。致力于免费,源码开放,同僚复审的 C++ 程序库开发。 Boost 在 C++ 标准化过程中扮演深具影响力的角色。
- Boost 提供许多 TR1 组件实现品,以及其他许多程序库。