《C++编程规范》五、类的设计与继承
- 第32条 弄清所要编写的是哪种类
- 第33条 用小类代替巨类
- 第34条 用组合代替继承
- 第35条 避免从并非要设计成基类的类中继承
- 第36条 优先提供抽象接口
- 第37条 公用继承即可替换性。继承,不是为了重用,而是为了被重用
- 第38条 实施安全的覆盖
- 第39条 考虑将虚拟函数声明为非公用的,将公用函数声明为非虚拟的
- 第40条 要避免提供隐式转换
- 第41条 将数据成员设为私有的,无行为的聚集(C语言形式的struct)除外
- 第42条 不要公开内部数据
- 第43条 明智地使用Pimpl
- 第44条 优先编写非成员非友元函数
- 第45条 总是一起提供new和delete
- 第46条 如果提供类专门的new,应该提供所有标准形式(普通、就地和不抛出)
第32条 弄清所要编写的是哪种类
了解自我:有很多种不同的类。弄清楚要编写的是哪一种。
不同种类的类适用于不同用途,因此遵循着不同的规则。
-
值类(如std::pair, std::vector)模仿的是内置类型。一个值类应该:
- 有一个公用析构函数、复制构造函数和带有值语义的赋值;
- 没有虚拟函数(包括析构函数);
- 是用作具体类,而不是基类(见第35条);
- 总是在栈中实例化,或者作为另一个类直接包含的成员实例化。
-
基类是类层次结构的构成要素。一个基类应该:
- 有一个公用而且虚拟,或者保护而且非虚拟的析构函数(见第 50条),和一个非公用复制构造函数和赋值操作符(见第53条);
- 通过虚拟函数建立接口;
- 总是动态地在堆中实例化为具体派生类对象,并通过一个(智能)指针来使用。
-
不严格地说来,traits类是携带有关类型信息的模板。一个traits类应该:
- 只包含typedef和静态函数,没有可修改的状态或者虚拟函数;
- 通常不实例化(其构造一般是被禁止的)。
-
策略类(通常是模板)是可插拔行为的片段。一个策略类应该:
- 可能有也可能没有状态或者虚拟函数;
- 通常不独立实例化,只作为基类或者成员。
-
异常类提供了不寻常的值与引用语义的混合:它们通过值抛出,但应该通过引用捕获(见第73条)。一个异常类应该:
- 有一个公用析构函数和不会失败(no-fail)的构造函数(特别是一个不会失败的复制构造函数,从异常的复制构造函数抛出将使程序中止);
- 有虚拟函数,经常实现克隆(见第54条)和访问(visitation);
- 从std::exception虚拟派生更好。
-
附属类一般支持某些具体的惯用法(如 RAII,见第 13条)。正确使用是很容易的,想误用反倒难了(具体例子见第53条)。
第33条 用小类代替巨类
分而治之:小类更易于编写,更易于保证正确、测试和使用。小类更有可能适用于各种不同情况。应该用这种小类体现简单概念,不要用大杂烩式的类,它们要实现的概念既多又复杂(见第5条和第6条)。
- 小的类只体现了一个概念,粒度层次恰到好处。而巨类则很可能体现了几个不同的概念,使用这样的类将增加其他人的脑力耗费(见第5条和第11条)。
- 小的类更易于理解,被人使用和重用的可能性也越大。
- 小的类更易于部署。而巨类必须经常以一个笨重而又不可分的单位来部署。例如,一个巨大的 Matrix(矩阵)类可能要实现并部署一些比较特殊的功能,比如计算矩阵的特征值——即使大多数类的使用者只需要简单的线性代数计算。更好的封装方式,应该是将各种功能域实现为小的Matrix类型的非成员函数。然后这些功能域能够与需要它们的调用者隔离开来,进行测试和部署(见第44条)。
第34条 用组合代替继承
避免继承带来的重负:继承是C++中第二紧密的耦合关系,仅次于友元关系。紧密的耦合是一种不良现象,应该尽量避免。因此,应该用组合代替继承,除非知道后者确实对设计有好处。
与继承相比,组合有如下重要优点。
-
在不影响调用代码的情况下具有更大的灵活性。私有数据成员是在我们控制之下的。在不破坏客户代码的前提下,可以将通过值保存切换为通过(智能)指针或者Pimpl(见第43条)保存;只需要修改类自身(使用了私有数据成员的)成员函数的实现代码即可。如果决定使用不同的功能,则改变成员类型或者保存成员的方式都很容易,同时又能保持类的公用接口不变。相反,如果一开始就使用公用继承关系,则使用者很可能已经开始依赖于这种继承关系了;因此,类必须遵守这种关系,以后再想改变其基类设计就难了(见第37条)。
-
更好的编译时隔离,更短的编译时间。通过指针(用智能指针最好)保存对象,而不是以直接成员或者基类的形式,能够减少头文件的依赖性,因为声明对象的指针不需要对象的完整的类定义。相反,继承则总是要求基类的完整定义可见。常用的一种技术是将所有私有成员聚合在一个不透明的指针后面,这个指针就称为Pimpl(见第43条)。
-
奇异现象减少。从一个类型继承,会导致名字查找涉及与该类型同一名字空间中定义的函数和函数模板。这是非常复杂的,很难进行调试(另见第58条)。
-
更广的适用性。有些类一开始并不是想设计成基类(另见第 35条)。但是,大多数类都能充当一个成员的角色。
-
更健壮、更安全。继承的较强耦合性使编写错误安全代码更加困难(见[Sutter02]§23)。
-
复杂性和脆弱性降低。继承会带来更多额外的复杂情况,比如名字隐藏,而以后基类的改变也会带来其他麻烦。
当然,这些并不是反对继承本身的论据。继承能够提供大量的功能,包括可替换性和改写虚拟函数(见第36条至第39条,以及下面的例外情况)的能力。但是不要为不需要的东西付出代价;除非需要继承的功能,否则不要忍受其弊端。
第35条 避免从并非要设计成基类的类中继承
有些人并不想生孩子:本意是要独立使用的类所遵守的设计蓝图与基类不同(见第32条)。将独立类用作基类是一种严重的设计错误,应该避免。要添加行为,应该添加非成员函数而不是成员函数(见第44条)。要添加状态,应该使用组合而不是继承(见第34条)。要避免从具体的基类中继承。
在不需要的情况下使用继承,实际上暴露出对面向对象的能力有一种错误的信任心理。在C++中,定义基类时需要做一些特定的工作(另见第32条、第50条和第54条),而定义独立类时则需要做另外一些大不相同甚至经常相反的工作。从独立类中继承将使代码面临大量问题,而且其中很少有编译器能够发出警告或者报错。
有些人不喜欢非成员函数,因为它的调用语法是Fun(str)而不是str.Fun(),但这只是一种语法习惯和熟悉与否的问题而已。(由此产生了一句名言,仍然来自传奇人物Alan Perlis:“太多的语法糖会导致分号癌。”)
示例
例1 用组合代替公用继承或者私有继承。如果的确需要这样一个localized_string:“与 string差不多,但是状态和函数比 string 多,而且要对现有的一些 string 函数进行微小的修改”,而许多函数的实现仍然保持不变,该怎么办呢?应该安全地用string的现有结构,通过包含而不是继承(这样能够防止切片和未定义的多态删除)来安全地实现它,同时添加一些通道函数(passthrough function)使未改变的函数可见:
class localized_string {
public:
// ……为需要保持不变的string成员函数
// 提供通道函数(比如,定义名为insert的函数来调用impl_.insert)……
void clear(); // 屏蔽/重新定义clear(
bool is_in_klingon()const; // 添加功能
private:
std::string impl_;
// ……添加更多状态……
};
必须承认的是,为需要保留的成员函数编写通道函数是很枯燥的,但是这种实现方式比使用公用或者非公用继承要好得多,也安全得多。
第36条 优先提供抽象接口
偏爱抽象艺术吧:抽象接口有助于我们集中精力保证抽象的正确性,不至于受到实现或者状态管理细节的干扰。优先采用实现了(建模抽象概念的)抽象接口的设计层次结构。
应该定义和继承抽象接口。抽象接口是完全由(纯)虚拟函数构成的抽象类,没有状态(成员数据),通常也没有成员函数实现。请注意,在抽象接口中避免使用状态能够简化整个层次结构的设计(具体示例请参阅[Meyers96])。
应该遵守依赖性倒置原理(Dependency Inversion Principle, DIP,见[Martin96a]和[Martin00])。DIP是如下表述的。
- 高层模块不应该依赖于低层模块。相反,两者都应该依赖抽象。
- 抽象不应该依赖于细节。相反,细节应该依赖抽象。
遵守DIP意味着层次结构应该以抽象类而不是具体类为根(见第35条)。抽象基类必须负责定义功能,而不是实现功能。换言之,策略应该上推,而实现应该下放。
DIP有三个基本的设计优点。
- 更强的健壮性。系统中较不稳定的部分(即实现)依赖于更稳定的部分(即抽象)。健壮的设计就是能将修改限于局部的设计。相反,在一个脆弱的系统中,很小的修改也会以种种糟糕的方式传播到系统意料不到的部分去。包含具体基类的设计正是如此。
- 更大的灵活性。基于抽象接口的设计通常更加灵活。如果能正确地建模抽象,那么就能很容易地针对新的需求设计新的实现。相反,依赖于许多具体细节的设计是很难改变的,因为新的需求将导致核心性的修改。
- 更好的模块性。依赖于抽象的设计,其模块性较好,因为它的依赖层次很简单:高度可变的部分依赖于稳定部分,而不是相反。相反,设计如果含有混合了实现细节的接口,就很可能会出现复杂的依赖网,这样一来,想要将这个设计作为一个单元插入到另一个系统中重新应用,就会变得很困难。
与此相关的二次机会定律(Law of Second Chances)是这样陈述的:“需要保证正确的最重要的东西是接口。其他所有东西以后都可以修改。如果接口弄错了,可能再也不允许修改了。”
第37条 公用继承即可替换性。继承,不是为了重用,而是为了被重用
知其然:公用继承能够使基类的指针或者引用实际指向某个派生类的对象,既不会破坏代码的正确性,也不需要改变已有代码。
还要知其所以然:不要通过公用继承重用(基类中的已有)代码,公用继承是为了被(已经多态地使用了基对象的已有代码)重用的。
按照Liskov替换原则(Liskov Substitution Principle;见[Liskov88]),公用继承所建模的必须总是“是一个(is-a)”[即更精确的“其行为像一个(works-like-a)”]关系:所有基类约定必须满足这一点,因此如果要成功地满足基类的约定,所有虚拟成员函数的改写版本就必须不多于其基类版本,其承诺也必须不少于其基类版本。使用指向Base的指针或者引用的代码必须能正确工作,即使指针或者引用实际上指向的是Derived。
继承的误用将破坏正确性。没有被正确实现的继承大多数都会因为无法遵守基类确定的显式或者隐式约定而迷乱。这种约定可能是很微妙的,如果无法在代码中直接表达,程序员就必须格外小心。(有些模式有助于在代码中声明更多意图,见第39条。)
为了提炼出一个能够被经常引用的例子,让我们考虑两个类Square和Rectangle,它们都有设置高度和宽度的虚拟函数。因此 Square 无法正确地从 Rectangle 中继承,因为代码要使用可修改的Rectangle,就必须假定SetWidth不能改变高度(无论Rectangle是否显式地说明了这一约定),而Square::SetWidth不能同时遵守此约定及其自身的正方形不变性。但是Rectangle也无法正确地从Square继承,因为Square的使用者可能会用到一些Rectangle不具备而Square独有的性质,比如,Square的面积是其宽度的平方。
当人们使用公用继承进行不相关的现实类比时,其实就已经误解了公用继承中“是一个”这种描述的意义:从数学上说,一个正方形的确“是一个”矩形,但是在行为上一个Square并不是一个 Rectangle。因此,我们不使用“是一个”,而喜欢说“其行为像一个”(或者,如果愿意的话,说“可以用作一个”也行),这样可以避免描述易于误解。
第38条 实施安全的覆盖
负责任地进行覆盖:覆盖一个虚拟函数时,应该保持可替换性;说得更具体一些,就是要保持基类中函数的前后置条件。不要改变虚拟函数的默认参数。应该显式地将覆盖函数重新声明为virtual。谨防在虚拟类中隐藏重载函数。
虽然派生类通常会增加更多状态(即数据成员),但它们所建模的是其基类的子集而非超集。在正确的继承关系中,派生类所建模的是更一般的基础概念的一个特例(见第37条)。
这对正确覆盖有直接的影响:因为包含关系隐含着可替换性——适用于整个集合的操作也应该适用于其任何子集。在基类保证了操作的前后条件后,任何派生类都必须遵守这些保证。覆盖函数可以要求更少而提供更多,但绝不能要求更多而承诺更少,因为这将违反已向调用代码保证过的约定。
在覆盖的时候,永远不要修改默认参数。它们不是函数签名的一部分,客户代码将因为不知情而将不同参数传递给函数,具体要传递哪一个参数,将取决于它们具有层次结构中哪个节点的访问权限。考虑以下代码:
class Base {
// ……
virtual void Foo( int x = 0 );
};
class Derived : public Base {
// ……
virtual void Foo(int x=1); // 糟糕的格式,会使人感到奇怪
};
Derived *pD = new Derived;
pD->Foo(); // 调用 pD->Foo(1)
pB->Foo(); // 调用 pB->Foo(0)
Base *pB = pD;
对于调用者而言,同一个对象的成员函数会不加提示地根据自己访问所使用的静态类型而接受不同参数,这可能是一件非常令人奇怪的事情。
在覆盖函数时,应该添加冗余的virtual。这能够更清楚地向阅读代码的人表达自己的意图。
谨防不小心在基类中隐藏了重载函数。例如:
class Base{// ……
virtual void Foo( int );
virtual void Foo( int, int );
void Foo( int, int, int );
};
class Derived : public Base { // ……
virtual void Foo(int); // 覆盖了 Base::Foo(int),但是隐藏了其他的重载函数
};
Derived d;
d.Foo(1); // 正确
d.Foo(1,2); // 错误(怎么回事?)
d.Foo(1,2,3); // 错误(怎么回事?)
如果基类的重载函数应该可见,那就写一条using声明语句,在派生类中重新声明:
class Derived : public Base { // ……
virtual void Foo(int); // 覆盖 Base::Foo(int)
using Base::Foo; // 将其他 Base::Foo 重载函数引入作用域
};
示例
例 Ostrich(鸵鸟)。如果基类Bird定义了虚拟函数Fly,而你又从Bird派生了一个新类Ostrich (一种著名的不会飞的鸟),怎样实现Ostrich::Fly呢?回答是:“要看具体情况。”因为飞行这个操作是Bird模型必不可少的部分,所以如果Bird::Fly能够保证成功(即提供不会失败的保证,见第71条),则Ostrich就不是该模型的合适实现。
第39条 考虑将虚拟函数声明为非公用的,将公用函数声明为非虚拟的
在基类中进行修改代价高昂(尤其是库中和框架中的基类):请将公用函数设为非虚拟的。应该将虚拟函数设为私有的,或者如果派生类需要调用基类版本,则设为保护的。(请注意,此建议不适用于析构函数;见第50条。)
在面向对象层次结构中进行修改尤其昂贵,所以应该实施完整的抽象:将公用函数设为非虚拟的,将虚拟函数设为私有的(或者设为保护的,如果派生类需要调用基类的话)。这就是所谓的非虚拟接口(NonVirtual Interface,NVI)模式。
通过将公用函数与虚拟函数分离,可以获得如下明显的好处。
- 每个接口都能自然成形。将公用函数与自定义接口分离后,每个接口都能很容易地获得符合其自然需求的形式,而不用寻找折中方案,以使它们看上去相同。两个接口经常需要不同数量的函数或不同的参数。例如,外部调用程序可能会调用一个公用 Process 函数执行一个逻辑上的工作单元,而自定义程序可能更愿意只覆盖处理工作的某些部分,用独立可覆盖的虚拟函数(如DoProcessPhase1、DoProcessPhase2)可以自然地建模,这样派生类就用不着覆盖所有的东西了。(值得提到的是,后一个例子与Template Method模式可以说是殊途同归。)
- 基类拥有控制权。基类现在完全控制着其接口和策略,可以实施接口的前后置条件(见第14条和第68条)、插入度量性代码(instrumentation),还可以在一个方便的可重用场所(非虚拟函数)中做任何类似的工作。因此,这种为了分离而进行的“预构”(prefactoring)能够促进良好的类设计。
- 基类能够健壮地适应变化。以后,我们可以随意改变构思、添加前后条件检查,或者将处理工作分成更多步骤,或者进行重构,或者用Pimpl惯用法(见第43条)实现更完整的接口与实现的分离,或者对基类的可自定义性进行其他修改,而不会影响到使用此类或者从此类继承的任何代码。请注意,若以NVI开始(即使公用函数只是虚拟函数的一行通道性代码),以后再添加检查或者度量就要容易得多,因为这样做不会破坏使用此类或者从此类继承的代码。但如果以公用虚拟函数开始,以后再分离就困难多了,因为这必然会破坏使用此类或者从此类继承的代码,具体情况还分别取决于是选择保持原函数为虚拟函数,还是公用函数。
第40条 要避免提供隐式转换
并非所有的变化都是进步:隐式转换所带来的影响经常是弊大于利。在为自定义类型提供隐式转换之前,请三思而行,应该依赖的是显式转换(explicit构造函数和命名转换函数)。
隐式转换有两个主要的问题。
- 它们会在最意料不到的地方抛出异常。
- 它们并不总是能与语言的其他元素有效地配合。
隐式转换构造函数(即能够用一个参数调用而且未声明为 explicit 的构造函数)与重载机制配合得很糟糕,而且会使不可见的临时对象到处出现。定义成operator T(其中T是一个类型)形式的成员函数的转换也好不到哪里去——它们与隐式构造函数的配合很糟,而且会允许各种荒谬的代码通过编译。令人尴尬的是,这种例子比比皆是(见本条参考文献)。我们只能提及其中的两个(见示例)。
在C++中,一个转换序列最多只能包含一个用户定义的转换。可是,如果这其中加入了内置转换,结果就会变得极为混乱。解决方案其实很简单。
默认时,为单参数构造函数加上explicit(另见第54条):
class Widget { // ……
explicit Widget( unsigned int widgetizationFactor );
explicit Widget( const char* name, const Widget* other = 0 );
};
使用提供转换的命名函数代替转换操作符:
class String { // ……
const char*as_char_pointer()const; // 遵循 c_str 的伟大传统 [3]
};
第41条 将数据成员设为私有的,无行为的聚集(C语言形式的struct)除外
它们不关调用者的事:将数据成员设为私有的。简单的C语言形式的struct类型只是将一组值聚集在了一起,并不封装或者提供行为,只有在这种struct类型中才可以将所有数据成员都设成公用的。要避免将公用数据和非公用数据混合在一起,因为这几乎总是设计混乱的标志。
信息隐藏是优秀软件工程的关键(见第11条)。应该将所有数据成员都设为私有的,不管是现在,还是可能发生变化的将来,私有数据都是类用来保持其不变式的最佳方式。
如果类要建模一个抽象,并因而必须维持不变式,那么使用公用数据就不好了。拥有公用数据意味着类的部分状态的变化可能是无法控制的、无法预测的、与其他状态异步发生的。这意味着抽象将与使用抽象的所有代码组成的无限集合共同承担维持一个或者更多不变式的职责,这是一种显而易见的、根本性的、不可原谅的缺陷。应该断然拒绝这种设计。
在同一个类中混合使用公用和非公用数据成员既容易含混不清,又存在前后矛盾。私有数据表明具有不变式而且希望保持这种不变性,而将其与公用数据混合则意味着无法明确地判定这个类到底是不是要成为抽象。
非私有数据成员甚至还不如简单的通道性的 get/set 函数,后者起码还能进行健壮的版本处理。
请考虑使用Pimpl惯用法来隐藏类的私有成员(见第43条)。
例外情况
get/set函数很有用,但是主要由get/set组成的类可能是一种设计不良的表现。遇到这种情况,应该决定一下,它是要提供抽象还是要成为一个struct。
值的聚集(也称“C语言形式的struct”)只是将一组数据简单地放在了一起,但是并没有实际添加什么有效的行为或者要建模什么抽象并实施不变式,它们并不是想提供抽象。它们的数据成员应该都是公用的,因为数据成员本身就是接口。例如,标准容器使用std::pair<T,U>将两个其他方面不相关的类型T和U的元素聚集在一起,而pair本身并不添加行为和不变式。
第42条 不要公开内部数据
不要过于自动自发:避免返回类所管理的内部数据的句柄,这样类的客户就不会不受控制地修改对象自己拥有的状态。
考虑下面的代码:
class Socket {
public:
// ……打开handle_的构造函数,关闭handle_的析构函数,等等
int GetHandle() const { return handle_; } // 避免这样做
private:
int handle_; // 可能是一个操作系统的资源句柄
};
数据隐藏是一种强大的抽象方式,也是强大的模块化机制(见第11条和第41条)。但是隐藏数据却又暴露句柄的做法是一种自欺欺人,就像你锁上了自己家的门,却把钥匙留在了锁里。原因如下。
- 客户现在有两种方式实现其功能。可以使用你的类提供的抽象(Socket),也可以直接操作你的类所依赖的实现(套接字的C语言形式的句柄)。在后一种情况下,对象并不知道自己所拥有的资源已经发生了显著变化。现在类无法可靠地增加或者改善功能(比如代理、日志、统计数据收集)了,因为客户可以避开这些改善的、受控的实现——以及任何它认为后加的不变式,这样本来正确的错误处理就几乎不可能起作用了(见第70条)。
- 类不能改变其抽象的底层实现,因为客户将依赖于此。如果以后升级 Socket,用不同的低级操作原语支持不同的协议,则获取了底层handle_且不能对其进行正确操作的调用代码就会不加警告地中断。
- 类无法实施其不变式,因为调用代码能够在类不知情的情况下改变状态。例如,有人会不通过某个Socket对象的成员函数,就关闭该对象正在使用的句柄,从而使该对象无效。
- 客户代码会存储类所返回的句柄,并且在类代码已经销毁句柄之后还试图使用它们。
第43条 明智地使用Pimpl
抑制语言的分离欲望:C++将私有成员指定为不可访问的,但并没有指定为不可见的。虽然这样自有其好处,但是可以考虑通过Pimpl惯用法使私有成员真正不可见,从而实现编译器防火墙,并提高信息隐藏度(见第11条和第41条)。
如果创建“编译器防火墙”将调用代码与类的私有部分完全隔离是明智的,就应该使用Pimpl惯用法:将私有部分隐藏在一个不透明的指针(即指向已经声明但是尚未定义的类的指针,最好是选择合适的智能指针)后面。例如:
class Map {
// ……
private:
struct Impl;
shared_ptr<Impl> pimpl_;
};
应该用Pimpl来存储所有的私有成员,包括成员数据和私有成员函数。这使我们能够随意改变类的私有实现细节,而不用重新编译调用代码——独立和自由正是这个惯用法的标记性特点。(见第41条。)
请注意:一定要如上所示使用两个声明来声明Pimpl。将两行合并成一条语句,在一句中前置声明类型和指针,即采用struct Impl* pimpl;这种形式的,也是合法的,但是意义就不同了,此时Impl处于外围名字空间中,而不是类中的嵌套类型。
使用Pimpl的理由至少有三个,而且它们都源自C++语言可访问性(是否能够调用或者使用某种东西)和可见性(是否能看到它从而依赖它的定义)之间的差异。另外,类的所有私有成员在成员函数和友元之外是不可访问的,但是对整个世界,所有看得到类定义的代码而言,都是可见的。
这种差异的第一个后果,就是潜在更长的构建时间,因为需要处理不必要的类型定义。对于通过值保存的私有数据成员以及通过值接受的或者用于可见函数实现的私有成员函数中的参数,必须定义它们的类型,即使在此编译单元中根本不需要。这会导致更长的构建时间。例如:
class C {
// ……
private:
AComplicatedType act_;
};
含有类C定义的头文件必须包含含有AComplicatedType定义的头文件,后者继而又要传递性地包含AComplicatedType可能需要的所有头文件,依此类推。如果头文件数量很大,编译时间将受到显著影响。
第二个后果是会给试图调用函数的代码带来二义性和名字隐藏。即使私有成员函数不能从类外及其友元调用,它们也会参加名字查找和重载解析,因此会使调用无效或者存在二义性。C++在可访问性检查之前执行名字查找,然后重载解析。这是可见性具有优先级的原因:
int Twice(int); //1
class Calc {
public:
string Twice(string); //2
private:
char*Twice(char*); //3
int Test() {
return Twice(21); // A: 错误,2 和 3 是无法独立存在的(1 可以独立存在,
} // 但是不能考虑,因为它是隐藏的)
};
Calc c;
c.Twice("Hello"); // B: 错误,3 不可访问(2 没有问题,但是
// 不能考虑,因为 3 是更好的匹配)
在A行,解决的办法是显式限定调用,即::Twice( 21 ),以强制名字查找来选择全局函数。在B行,解决的办法是添加一个显式强制转换,即c.Twice( string("Hello") ),以重载解析来选择需要的函数。这些调用问题中,有些是可以用Pimpl惯用法以外的办法解决的,例如,永远不写成员函数的私有重载,但是并非所有Pimpl能解决的问题都能有这样的替代方案。
第三个后果是对错误处理和错误安全的影响。考虑Tom Cargill的Widget示例:
class Widget {// ……
public:
Widget& operator=( const Widget& );
private:
T1 t1_;
T2 t2_;
};
简而言之,如果 T1 或者 T2 操作的是不可逆的方式失败(见第 71条),我们就无法编写operator=来提供强大的保证,以至所需的最少(基本)保证。好在以下的简单转换总是能够为防错赋值提供最基本的保证,而且只要所需的T1和T2操作(值得注意的有构造和析构)没有副作用,通常还能够提供较强的保证:通过指针而不是值来保存成员对象,将它们都放在一个Pimpl指针之后更好。
class Widget {// ……
public:
Widget& operator=( const Widget& );
private:
struct Impl;
shared_ptr<Impl> pimpl_;
};
Widget& Widget::operator=( const Widget& ) {
shared_ptr<Impl> temp( new Impl( /*...*/ ) );
// 改变 temp->t1_ 和 temp->t2_; 如果失败就抛出异常,否则这样提交:
pimpl_ = temp;
return *this;
}
第44条 优先编写非成员非友元函数
要避免交成员费:尽可能将函数指定为非成员非友元函数。
非成员非友元函数通过尽量减少依赖提高了封装性:函数体不能依赖于类的非公用(public)成员(见第11条)。它们还能够分离巨类,释放可分离的功能,进一步减少耦合(见第33条)。它们能够提高通用性,因为在不知道一个操作是否为某个给定类型的成员的情况下,很难编写模板(见第67条)。
用非成员非友元函数取代成员函数。这样做可以提高封装性,包装弹性,和机能扩充性。
第45条 总是一起提供new和delete
它们是一揽子交易:每个类专门的重载void* operator new(parms)都必须与对应的重载void operator delete(void*, parms)相随相伴,其中 parms 是额外参数类型的一个列表(第一个总是std::size_t)。数组形式的new[]和delete[]也同样如此。
第46条 如果提供类专门的new,应该提供所有标准形式(普通、就地和不抛出)
不要隐藏好的new [4] :如果类定义了operator new的重载,则应该提供operator new所有三种形式——普通(plain)、就地(in-place)和不抛出(nothrow) [5] 的重载。不然,类的用户就无法看到和使用它们。
很少需要提供自定义的new或者delete,但是如果确实需要,通常也不想隐藏内置的签名。
C++中,在某个作用域(比如一个类作用域)里定义了一个名字之后,就会隐藏所有外围作用域中(如,在基类或者外围名字空间)同样的名字,而且永远不会发生跨作用域的重载。当上述的名字是operator new时,需要特别小心,以免对类的客户产生不良影响。
假设我们定义了一个类专门的operator new:
class C {
// ……
static void* operator new(size_t, MemoryPool&); // 隐藏三种常规形式
};
然后,如果有人试图编写一个和普通旧式new C一样无趣的表达式,编译器会拒绝对其进行编译,其根据是无法找到普通旧式的 operator new。声明接受一个 MemoryPool 为参数的C::operator new重载,将隐藏所有其他重载,包括我们都熟知而且爱用的内置全局版本,也就是:
void*operator new(std::size_t); // 普通(plain)new
void*operator new(std::size_t,std::nothrow_t)throw(); // 不抛出(nothrow)new
void*operator new(std::size_t,void*); // 就地(in-place)new
或者,类也可能为以上三种operator new之一提供自己专用的版本。在此情况下,如果声明了其中之一,默认时类将屏蔽其他两个:
class C {
// ……
static void* operator new(size_t, void*); // 隐藏其他两种常规形式
};
应该让类C在作用域中显式地声明operator new的所有三种标准变体。通常,所有三种形式都有相同的可见性。(各个形式还可以将可见性设为private,比如要显式地禁用普通或者不抛出operator new,但是本条款的目的是提醒读者记住不要因为疏忽而隐藏它们。)
请注意,应该总是避免隐藏就地new,因为它在STL容器中有广泛的使用。
最后一个技巧是:在两种不同的环境下,公开已隐藏的operator new需要采用两种不同的方式。如果类的基类也定义了operator new,那么要公开operator new所需做的就是:
class C : public B { // ……
public:
using B::operator new;
};
否则,如果没有基类版本或者基类没有定义operator new,就需要写一些短小的转送函数(因为无法通过using从全局名字空间中导入名字):
class C { // ……
public:
static void* operator new(std::size_t s) {
return ::operator new(s);
}
static void* operator new(std::size_t s, std::nothrow_t nt) throw() {
return ::operator new(s, nt);
}
static void* operator new(std::size_t s, void* p) {
return ::operator new(s, p);
}
};
上面的建议也适用于数组形式的operator new[]和operator delete[]。
避免在客户代码中调用 new (nothrow)版本,但是仍然要为客户提供,以免客户一旦要用到时感到奇怪。