C++编程规范 类的设计与继承

第32条 弄清所要编写的是那种类
了解自我:有很多种不同的类。弄清楚要编写的是哪一种。
详细:
1、值类模仿的是内置类型,应该:有一个公用析构函数,复制构造函数和带有值语义的赋值。没有虚拟函数(包括析构函数)。是用作具体类,而不是基类(见 C35)。总是在栈中实例化,或者作为另一个类直接包含的成员实例化。
2、基类是类层次结构的构成要素,应该:有一个公用而且虚拟,或者保护而且非虚拟的析构函数(见 C50),和一个非公用复制构造函数和赋值操作符(见 C53)。通过虚拟函数建立接口。总是动态地在堆中实例化为具体派生类对象,并通过一个(智能)指针来使用。
3、traits 类是携带有关类型信息的模板,应该:只包含 typedef 和静态函数。没有可修改的状态或者虚拟函数。通常不实例化(其构造一般是被禁止的)。
4、策略类(通常是模板)是可插拔行为的片段,应该:可能有也可能没有状态或者虚拟函数。通常不独立实例化,只作为基类或者成员。
5、异常类提供了不寻常的值与引用语义的混合,通过值抛出,但通过引用捕获,应该:有一个公用析构函数和不会失败(no-fail)的构造函数(特别是一个不会失败的复制构造函数,从异常的复制构造函数抛出将使程序中止)。有虚拟函数,经常实现克隆(见 C54)和访问(visitation)。从 std::exception 虚拟派生更好。
6、附属类一般支持某些具体的惯用法(如 RAII,见 C13)。(见 C53)

第33条 用小类代替巨类
分而治之:小类更易于编写,更易于保证正确、测试和使用。小类更有可能适用于各种不同情况。应该用这种小类体现简单概念,不要用大杂烩式的类,它们要实现的概念既多又复杂(见 C5, C6)。
详细:
1、设计易于组合的更小的、尽量小的类,才是实践中更为成功的方法,这对任何规模的系统都适用。
2、小的类只体现了一个概念,粒度层次恰到好处(见 C5, C11)。
3、小的类更易于理解,被人使用和重用的可能性也越大。
4、小的类更易于部署。
5、巨类会削弱封装性。巨类不能适应需求的变化。巨类更难保证正确和错误安全(见 C5, C44)。

第34条 用组合代替继承
避免继承带来的负重:继承是 C++ 中第二紧密的耦合关系,仅次于友元关系。紧密的耦合是一种不良现象,应该尽量避免。因此,应该用组合代替继承,除非知道后者确实对设计有好处。
详细:
1、尽量减少耦合:如果一种关系不只有一种表示方式,那么应该用可行的最弱关系。
2、如果用组合就能表示类的关系,那么应该优先使用。
3、所谓“组合”就是指在一个类型中嵌入另一个类型的成员变量。用这种方式能够保存和使用对象,还能控制耦合强度。
4、与继承相比,组合有如下重要优点:在不影响调用代码的情况下具有最大的灵活性(见 C43, C37);更好的编译时隔离,更短的编译时间(见 C43);减少奇异现象(见 C58);更广的适用性(见 C35);更健壮、更安全;复杂性和脆弱性降低。
5、继承能够提供大量的功能,包括可替换性和/或改写虚拟函数(见 C36, C39)的能力。但是不要为不需要的东西付出代价;除非需要继承的功能,否则不要忍受其弊端。
6、使用公用继承模仿可替换性(见 C37)。
7、需要非公用继承的情况:需要改写虚拟函数;需要访问保护成员;需要在基类之前构造已使用过的对象,或者在基类之后销毁此对象;需要操心虚拟基类;能够确定空基类优化能带来好处,包括这种情况下优化的确很重要,以及这种情况下目标编译器确实能实施这种优化(见 C8);需要控制多态,相当于说,如果需要可替换性关系,但是关系应该只对某些代码可见(通过友元)。

第35条 避免从并非要设计成基类的类中继承
有些人并不想生孩子:本意是要独立使用的类所遵守的设计蓝图与基类不同(见 C32)。将独立类用作基类是一种严重的设计错误,应该避免。要添加行为,应该添加非成员函数而不是成员函数(见 C44)。要添加状态,应该使用组合而不是继承(见 C34)。要避免从具体的基类中继承。
详细:
1、基类和具体类有很大的不同,而且从具体类中继承将面临大量问题,而且很少编译器能够发出警告或者报错。
2、应该通过新的非成员函数来添加功能(见 C44)。将这些函数与要扩展的类型放在同一个名字空间中(见 C57)。或者使用组合添加状态,而避免继承。
3、从带有公用非虚拟析构函数的类继承,将面临删除风险,会产生没有警告的错误、内存泄漏、堆损坏和移植性恶梦。

第36条 优先提供抽象接口
偏爱抽象艺术吧:抽象接口有助于我们集中精力保证抽象的正确性,不至于受到实现或者状态管理细节的干扰。优先采用实现了(建模抽象概念的)抽象接口的设计层次结构。
详细:
1、应该定义和继承抽象接口。抽象接口是完全由(纯)虚拟函数构成的抽象类,没有状态(成员数据),通常也没有成员函数实现。
2、应该遵守依赖倒置原理(Dependency Inversion Principle, DIP)。高层模块不应该依赖于低层模块。相反,两者都应该依赖抽象。抽象不应该依赖于细节。相反,细节应该依赖抽象。
3、策略应该上推,而实现应该下放。
4、DIP 有三个基本的设计优点:更强的健壮性;更大的灵活性;更好的模块性。
5、二次机会定律(Law of Second Changes):需要保证正确的最重要的东西是接口。其他所有东西以后都可以修改。如果接口弄错了,可能再也不允许修改了。
6、空基类优化是一个纯粹为了优化而使用继承(最好是非公用的)的实例(另见 C8)。
7、基于策略的设计使用了静态多态,抽象接口仍然存在,只不过是隐式的,并没有通过纯虚拟函数显式地声明而已。

第37条 公用继承即可替换性。继承,不是为了重用,而是为了被重用
知其然:公用继承能够使基类的指针或者引用实际指向某个派生类的对象,既不会破坏代码的正确性,也不需要改变已有代码。
还要知其所以然:不要通过公用继承(基类中的已有)代码,公用继承是为了被(已经多态地使用了基对象的已有代码)重用的。
详细:
1、按照 Liskov 替换原则(Liskov Substitution Principle),公用继承所建模的必须总是“是一个(is-a)”[即更精确的“其行为像一个(works-like-a)”]关系:所有基类约定必须满足这一点,因此如果要成功地满足基类的约定,所有虚拟成员函数的改写版本就必须不多于其基类版本,其承诺也必须不少于其基类版本。
2、对类 Square(正方形) 和 Rectangle(矩形)来说,正方形虽然从数学上说,“是一个”矩形,但是在行为上一个 Square 并不是一个 Rectangle,一个 Rectangle 也不是一个 Square。因此,我们不使用“是一个”,而喜欢说“其行为像一个”(或者,如果愿意的话,说“可以用作一个”也行),这样可以避免描述易于误解。
3、策略类和混入类(mixins)通过公用继承添加行为,但这并不是误用公用继承来建模“用……来实现”关系。

第38条 实施安全的改写
负责任地进行改写:改写一个虚拟函数时,应该保持可替换性;说得更具体一些,就是要保持基类中函数的前后条件。不要改变虚拟函数的默认参数。应该显式地将改写函数重新声明为 virtual。谨防在虚拟类中隐藏重载函数。
详细:
1、在基类保证了操作的前后条件后,任何派生类都必须遵守这些保证。改写函数可以要求更少而提供更多,但绝不能要求更多而承诺更少,因为这将违反已向调用代码保证过的约定。
2、在改写的时候,永远不要修改默认参数。
3、在改写的时候,应该添加冗余的 virtual,如果基类的重载函数应该可见,那就写一条 using 声明语句(using Base::Foo;),在派生类中重新声明。

第39条 考虑将虚拟函数声明为非公用的,将公用函数声明为非虚拟的
在基类中进行修改代价高昂(尤其是库中和框架中的基类):请将公用函数设为非虚拟的。应该将虚拟函数设为私有的,或者如果派生类需要调用基类版本,则设为保护的。(请注意,此建议不适用于析构函数;见 C50)
详细:
1、将公用函数设为非虚拟的,将虚拟函数设为私有的(或者设为保护的,如果派生类需要调用基类的话)。这就是所谓的非虚拟接口(Nonvirtual Interface, NVI)模式。
2、公用虚拟函数本质上有两种不同而且互相竞争的职责,针对的是两种不同而且互相竞争的目标:它指定了接口;它指定了实现细节。
3、通过将公用函数和虚拟函数分离,可以获得如下明显的好处:每个接口都能自然成形;基类拥有控制权;基类能够健壮地适应变化。
4、NVI 对析构函数不适用,因为它们的执行顺序很特殊(见 C50)。
5、NVI 不直接支持调用者的协变返回类型。如果需要协变量对调用代码可见,而又不使用 dynamic_cast 向下强制(另见 C93),那么将虚拟函数设为公用的会更容易。

第40条 要避免提供隐式转换
并非所有的变化都是进步:隐式转换所带来的影响经常是弊大于利。在为自定义类型提供隐式转换之前,请三思而行,应该依赖的是显式转换(explicit 构造函数和命名转换函数)。
详细:
1、隐式转换有两个主要的问题:它们会在最意料不到的地方抛出异常;它们并不总是能与语言的其他元素有效地配合。
2、默认时,为单参数构造函数加上 explicit(另见 C54)。
3、使用提供转换的命名函数代替转换操作符。
4、std::string 通过两个命名函数 c_str() 和 data() 提供 const char * 的转换;并对所有的比较操作符(如 ==, !=, <)都进行了重载,以避免创建隐藏的临时变量;经过这样的处理后,才提供了一个参数类型为 const char * 的隐式构造函数。

第41条 将数据成员设为私有的,无行为的聚集(C 语言形式的 struct)除外
它们不关调用者的事:将数据成员设为私有的。简单得 C 语言形式的 struct 类型只是将一组值聚集在了一起,并不封装或者提供行为,只有在这种 struct 类型中才可以将所有数据成员都设成公用的。要避免将公用数据和非公用数据混合在一起,因为这几乎总是设计混乱的标志。
详细:
1、拥有公用数据或者保护数据的类的部分状态的变化可能是无法控制的、无法预测的、与其他状态异步发生的。这意味着抽象将与使用抽象的所有代码组成的无限集合共同承担维持一个或者更多不变式的职责,这是一种显而易见、根本性的、不可原谅的缺陷。应该断然拒绝这种设计。
2、请考虑使用 Pimpl 惯用法来隐藏类的私有成员(见 C43)。
3、获取函数和设置函数提供了最小的抽象和健壮的版本管理机制。
4、主要由 Get/Set 函数组成的类可能是一种设计不良的表现,这时需要考虑是否应该用 struct 代替。

第42条 不要公开内部数据
不要过于自动自发:避免返回类所管理的内部数据的句柄,这样类的客户就不会不受控制地修改对象自己拥有的状态。
详细:
1、避免下面的做法:

class Socket
{
	int handle_;	// 可能是一个操作系统的资源句柄
public:
	// ... 打开 handle_ 的构造函数,关闭 handle_ 的析构函数,等等 ...
	int GetHandle() const { return handle_; }	// 避免这样做
};
2、类似的:
class String
{
	char *buffer_;
public:
	char * GetBuffer() const { return buffer_; }	// 糟糕:应该返回 const char *
};
3、有时由于兼容性的原因,例如 std::string 通过成员函数 c_str() 和 data() 提供了访问其内部句柄的方式,应该小心并尽可能少地使用,而且,必须在文档中仔细记载什么情况下句柄仍然有效。

第43条 明智地使用 Pimpl
抑制语言的分离欲望:C++ 将私有成员指定为不可访问的,但并没有指定为不可见的。虽然这样自有其好处,但是可以考虑通过 Pimpl 惯用法使私有成员真正不可见,从而实现编译器防火墙,并提高信息隐藏度(见 C11, C41)。
详细:
1、Pimpl 惯用法类似如下代码:
class Map
{
	class Impl;								// Impl 类是 Map 类的嵌套类
	std::unique_ptr<Impl> impl_;
	// 或者如下:
	//std::shared_ptr<Impl> impl_;			// 采用共享指针
	//std::unique_ptr<class MapImpl> impl_;	// MapImpl 类与 Map 类在同一名字空间中
	//std::shared_ptr<class MapImpl> impl_;
public:
	// ......
};
2、Pimpl 惯用法可以解决下面三个可访问性(是否能够调用或者使用某种东西)和可见性(是否能够看到它从而依赖它的定义)之间的差异引起的问题:
3、潜在更长的构建时间,因为需要处理不必要的类型定义。
4、会给试图调用函数的代码带来二义性和名字隐藏。
5、对错误处理和错误安全的影响。
6、只有在弄清楚了增加间接层次确实有好处之后,才能添加复杂性,Pimpl 也是一样的。(见 C6, C8)

第44条 优先编写非成员非友元函数
要避免交成员费:尽可能将函数指定为非成员非友元函数。
详细:
1、非成员非友元函数通过尽量减少依赖提高了封装性:函数体不能依赖于类的非公用成员(见 C11)。
2、它们还能够分离巨类,释放可分离的功能,进一步减少耦合(见 C33)。
3、它们能够提高通用性,因为在不知道一个操作是否为某个给定类型的成员的情况下,很难编写模板(见 C67)。
4、使用下面这个算法确定函数是否应该是成员和/或友元:
// 如果别无选择,就无需选择了;如果必需,就指定为成员:
if (函数是操作符 =, ->, [] 或者 () 之一)
{
	则函数必须是成员,将其指定为成员函数。
}
// 如果可能是非成员非友元函数,或者设为非成员友元函数有好处,那就照办:
else if ((a.函数需要与其左参数不同的类型(例如操作符 << 或者 >>)) || 
	(b.需要对其最左参数进行强制转换) || 
	(c.能够用类的公用接口单独实现))
{
	将其指定为非成员函数。
	if (a 和 b 需要)
	{
		可以将其指定为非成员友元函数。
	}
	if (需要虚拟行为)
	{
		添加虚拟成员函数以提供虚拟行为,并通过它实现非成员函数。
	}
}
else
{
	将其指定为成员函数。
}

第45条 总是一起提供 new 和 delete
它们是一揽子交易:每个类专门的重载 void * operator new(parms) 都必须与对应的重载 void operator delete(void *, parms) 相随相伴,其中 parms 是额外参数类型的一个列表(第一个总是 std::size_t)。数组形式的 new[] 和 delete[] 也同样如此。
详细:
1、这是因为编译器可能需要 operator delete 的重载,即使实际上从来也不会调用它。
2、当构造函数失败的情况下,没有对应的 operator delete 的重载,会导致内存泄漏。
3、operator new 的就地(in-place)形式:void * operator new(std::size_t, void *p) { return p; },并没有进行真正的分配,属于例外情况。

第46条 如果提供类专门的 new,应该提供所有标准形式(普通、就地和不抛出)
不要隐藏好的 new :如果类定义了 operator new 重载,则应该提供 operator new 所有三种形式——普通(plain),就地(in-place)和不抛出(nothrow)的重载。不然,类的用户就无法看到和使用它们。
详细:
1、C++ 中,在某个作用域(比如一个类作用域)里定义了一个名字之后,就会隐藏所有外围作用域中(如,在基类或者外围名字空间)同样的名字,而且永远不会发生跨作用域的重载。类定义 operator new 重载,将隐藏内置全局的 operator new 。
2、内置全局版本:
// 内置全局的 operator new
static void * operator new(std::size_t);									// 普通 new
static void * operator new(std::size_t, const std::nothrow_t &) throw();	// 不抛出 new
static void * operator new(std::size_t, void *);							// 就地 new
// 对应的 operator delete
static void operator delete(void *);										// 普通 delete
static void operator delete(void *, const std::nothrow_t &) throw();		// 不抛出 delete
static void operator delete(void *, void *);								// 就地 delete
3、如果类的基类也定义了 operator new,那么要公开 operator new 所需做的就是:
using Base::operator new;
using Base::operator delete;
4、如果没有基类版本或者基类没有定义 operator new,就需要写一些短小的转送函数,转交全局版本执行。
5、类应该提供不抛出的 new 给客户代码使用,但是不建议客户代码使用该方式。
6、应该总是避免隐藏就地 new,因为它在 STL 容器中有广泛的使用。
7、上面的建议也适用于数组形式的 operator new[] 和 operator delete[]。

返回 目录

返回《C++ 编程规范及惯用法》

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
 C++领域20年集大成之作   两位世界专家联袂巨献   适合所有层次C++程序员   良好的编程规范可以改善代码质量,缩短上市时间,提升团队效率,简化维护工作。在本书中,两位全世界受尊敬的C++专家将全球C++社区的集体智慧和经验凝结成一整套编程规范。这些规范可以作为每一个开发团队制定实际开发规范的基础,更是每一位C++程序员应该遵循的行事准则。   本书涵盖了C++程序设计的每一个方面,包括设计和编码风格、函数、操作符、设计继承、构造与析构、赋值、名字空间、模块、模板、泛型、异常、STL容器和算法等。书中对每一条规范都给出了言简意赅的概述,并辅以实例说明;另外还给出了从型定义到错误处理等方面的大量C++实践,包括许多总结和标准化的技术。即使使用C++多年的程序员也会从中受益匪浅。   通过阅读本书,可以找到以下问题的答案。   哪些东西值得标准化?哪些东西不值得标准化?   使代码可扩展的方法是什么?   合理的错误处理策略有哪些要素?   如何(和为什么要)避免不必要的初始化、循环依赖和定义依赖?   何时应该(以及如何)同时使用静态和动态的多态性;   如何实践“安全的”改写?   何时该提供不会失败的交换?   为什么阻止异常跨越模块边界传播?如何阻止?   为什么不应该在头文件中写名字空间声明或指令?   为什么应该使用STL vector和string代替数组?   如何选择正确的STL搜索或排序算法?   为了保证代码的型安全,应该遵从哪些规则?
良好的编程规范可以改善软件质量,缩短上市时间,提升团队效率,简化维护工作。在本书中,两位全世界最受尊敬的C++专家将全球C++社区的集体智慧和经验凝结成一整套编程规范。这些规范可以作为每一个开发团队制定实际开发规范的基础,更是每一位C++程序员应该遵循的行事准则。, 本书涵盖了C++程序设计的每一个方面,包括设计和编码风格、函数、操作符、设计继承、构造与析构、赋值、名字空间、模块、模板、泛型、异常、STL容器和算法等。书中对每一条规范都给出了言简意赅的叙述,并辅以实例说明;另外还给出了从型定义到错误处理等方面的大量C++ 最佳实践,包括许多最新总结和标准化的技术,即使使用C++多年的程序员也会从中受益匪浅。, 通过阅读本书,可以找到以下问题的答案。,  哪些东西值得标准化?哪些东西不值得标准化?,  使代码可扩展的最佳方法是什么?,  合理的错误处理策略有哪些要素?,  如何(和为什么要)避免不必要的初始化、循环依赖和定义依赖?,  何时应该(以及如何)同时使用静态和动态的多态性?,  如何实践“安全的”改写?,  何时该提供不会失败的交换?,  为什么要阻止异常跨越模块边界传播?如何阻止?,  为什么不应该在头文件中写名字空间声明或指令?,  为什么应该使用STL vector和string代替数组?,  如何选择正确的STL搜索算法?,  为了保证代码的型安全,应该遵从哪些规则?

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值