《Effective C++》第三版 第四章 设计与声明

19 篇文章 0 订阅
4 篇文章 0 订阅

前言

软件设计就是 “令软件做出你希望它做的事情” 的步骤和做法,允许开发特殊接口,这些接口最终必须转换为C++声明式。

本章以最重要、最适合任何接口设计的一个准则作为开端:“让接口容易被正确使用,不容易被误用”。这个准则设立了一个舞台,让其他更专精的准则针对更大范围的设计,包括:正确性、高效性、封装性、维护性、延展性,以及协议一致性。

以下条例强调的是某些最重要的考虑,对某些频繁出现的错误提出警告,为classfunctiontemplate 设计者经常遇到的问题找到解决方案。

基础

explicit 构造函数函数:C++提供了关键字explicit,可以阻止不应该允许的经过转换构造函数进行的隐式转换的发生,声明为 explicit 的构造函数不能在隐式转换中使用。(简单说:explicit 构造函数是用来防止隐式转换的。)

条款18:让接口容易被正确使用,不易被误用

C++的每一种接口都是客户(使用接口的人)与代码交互的方式,如果使用接口的方式不正确也应该在设计接口的时候考虑到。如果这个接口能够接受客户任何输入并且编译通过,那么这个接口的设计也要能够接受这些输入,并且输出客户预想(设计者告知客户)的结果。

基于以上情况,设计者应该在开发接口时就要设想,客户使用接口会出现什么样的情况,甚至是错误情况。比如说设计一个表现日期的class设计构造函数:

	class Date
	{
	public:
		Date(int month, int day, int year);
		....
	};

这个接口这样实现看起来没有什么问题。

但是在客户用的时候可能会出现两种错误情况:

  1. 错误的次序传递参数:
    Date d(30, 3, 1995);	//正确应该是 "3, 30", 而不是 "30, 3"
    
  2. 传递一个无效的日期或者月份:
    Date d(2, 30, 1995);	//正确应该是 "3, 30",而不是 "2, 30", 二月没有30天
    

这种案例可能很蠢,但是平时开发也是常见的,2 就在 3 的旁边,30 和 3 也可以混淆。

许多客户端错误可以因为导入新类型而获得预防

因为在编写导入错误的类型时,你的软件可能会提示错误!或者编译失败!
比如说,我们可以导入简单的外覆类型(wrapper types)来区别天数、月份和年份,然后于 Date 构造函数中使用这些类型:

	struct Day
	{
	explicit Day(int d):val(d){}
	int val;
	}
	
	struct Month
	{
	explicit Day(int m):val(m){}
	int val;
	}
	
	struct Year
	{
	explicit Day(int y):val(y){}
	int val;
	}
	
	class Date
	{
	public:
		Date(const Month& m, const Day& d, const Year& y);
		....
	};
	
	Date(30, 3, 1995);						// 错误!参数类型不正确
	Date(Day(30), Month(3), Year(1995));	// 错误!参数类型不正确
	Date(Month(3), Day(3), Year(1995));		// 类型正确

使用DayMonthYear 成为成熟的 classes 并封装他们的数据,比上面简单的用 structs 更好,但是 structs 也可以作为优化的接口案例告诉我们:明智而审慎地导入新类型对预防“接口被误用”有神奇疗效。

现在已经有了正确的类型限制,对于上面这个日期接口案例来说,还需要限制这个类型内部数据的值,比如说:一年只有12个月,2月只有28天,所以 monthday 都应该反映这些事实。可以利用两个操作来预防客户错误:

  1. 第一个办法:使用 enum 表现月份,但 enum 不具备类型安全性,例如 enums 可被拿来当一个ints 使用,比较安全的解决方法是预先定义所有有效的Months:
    class Month
    {
    public:
    	static Month Jan() {return Month(1);}		//函数,返回有效月份
    	static Month Feb() {return Month(2);}		//这些是函数而非duixiang
    	...
    	static Month Dec() {return Month(12)}		
    	...											//其他成员函数
    private:
    	explicit Month(int m);						//阻止生成新的月份
    	....										//月份的专属数据
    	
    };
    Date d(Month::Mar(), Day(30), Year(1995));
    

以函数替换对象,详见条例4 :non-local static 对象的初始化次序!

  1. 第二个办法:限制类型内什么事可以做,什么事不能做,常见的限制是加上 const

    例如条款3 说明为什么 “以 const 修饰 operator* 的返回类型” 可阻止客户因 “用户自定义类型” 而犯错:

    if (a * b = c) ...	// 本来是要做一个比较
    

另一个一般性准则:“让 types 容易被正确使用,不容易被误用” 的表现形式:

“除非有好的理由,否则尽量令你的 types 的行为与内置 types 一致”

例如说:如果 a 和 b 都是 ints, 那么对于 a*b 赋值不合法,所以除非你有更好的原因,否则应该让你的 types 也有相同的形式制约。

避免无端地与内置类型不兼容,真正的理由就是为了提供行为一致的接口!“一致性” 更能让 “接口容易被正确使用”,“不一致性” 更会加剧接口的恶化!

STL的接口十分一致性,这使他们非常容易被使用,比如 STL 的每个容器都有size 成员函数,这个函数会告诉调用者当前容器有多少对象。而 Java 语言中,它允许你对数组使用 length property ,对 Strings 使用 length method,而对 List 使用 size method; .NET也是一样混乱。其 Arrays 有个property 名为 Length, 其ArrayLists 有个 property 名为 Count。这种不一致性会让开发人员(客户)用起来混乱,或者心理和精神摩擦和争执,没有一个 IDE 可以完全抹除…

任何接口要求客户要记得做某些事,就是有“不正确”的倾向,因为客户可能会忘记那件事

例如条款 13 导入一个 factory 函数,它返回一个指针指向 Investment 继承体系内的一个动态分配对象:

Investment* createInvestment();		//来自条款13;为求简化暂略参数

为避免资源泄露,createInvestment 返回的指针最终必须被删除,但那至少给了客户端两种错误的机会:没有删除指针,或删除同一个指针超过一次!

条款13 表明客户如何将 createInvestment 的返回值存储于一个智能指针比如:auto_ptrtr1::shared_ptr 内,因而将 delete 的任务交给智能指针,但是万一客户忘记使用智能指针怎么办? 所以设计者最好能遵循最佳接口的设计原则,可以令 factory 函数返回一个智能指针:

	std::tr1::shared_ptr<Investment> createInvestment();

返回 tr1::shared_ptr 可以阻止客户可能犯下的资源泄露错误!(这实质上强迫客户将返回值存在一个 tr1::shared_ptr 内,指针没有使用时,客户不会因为忘记删除 Investment 对象)

如条款14 所言, tr1::shared_ptr 允许当智能指针被建立起来指定一个资源释放函数(删除器 “deleter”),绑定于智能指针身上(auto_ptr 就没有这种能耐)。
假设 class 设计者希望那些 “从 createInvestmen 取得 Investment* 指针” 的客户应该将指针传递给一个名为 getRidOfInvestment 的函数,而不是直接对指针使用 delete 。如果这样设计的话,可能又会给客户一个错误的机会:企图使用错误的资源析构方式(可能会拿 delete 替换 getRidOfInvestment)。

createInvestment 的设计者可以针对此问题先发制人:返回一个 “将 getRidOfInvestment 绑定为删除器(deleter)” 的 tr1::shared_ptr

这样的话, tr1::shared_ptr 提供的某个构造函数接受两个实参:一个是被管理的指针,另一个是当指针引用次数变成0时,将被调用的 “删除器”。 我们可以创建一个 null tr1::shared_ptr 并以 getRidOfInvestment 作为删除器,像这样:

	std::tr1::shared_ptr<Investment>			//企图创建一个 null shared_ptr
	pInv(0, getRidOfInvestment);				//并携带一个自定的删除器
												//无法通过编译!!! 这不是有效的 C++

tr1::shared_ptr 构造函数的第一个参数必须是个指针,C++ 中的 null 不是有效指针,是空指针, null指针是定义在标准库中的值为0的常量,在这里null作为参数无效,因为 tr1::shared_ptr 需要一个不折不扣的指针强转(cast)可以解决这个问题:

	std::tr1::shared_ptr<Investment>			//建立一个 null shared_ptr 
		pIv(static_cast<Investment*> (0)),		//并以 getRidOfInvestment 为删除器
			getRidOfInvestment);				//条款 27 提到 static_cast

因此,如果我们要实现 createInvestment 使他返回一个 tr1::shared_ptr 并夹带 getRidOfInvestment 函数作为删除器,代码看起来像这样:

	std::tr1::shared_ptr<Investment> createInvestment()
	{
		std::tr1::shared_ptr<Investment> retVal(static_cast<Investment *>(0)), getRidOfInvestment);
		retVal = ...; 	// 令 retVal 指向正确对象
		return retVal;
	}

如果被 pInv 管理的原始指针(raw pointer)可以在建立 pInv 之前先确定下来,那么 “将原始指针传给 pInv 构造函数” 会比 “先将pInv 初始化为 null 在对它做一次赋值操作” 更佳,原因参考条例 26。

tr1::shared_ptr 有一个特别好的性质:它会自动使用它的 “每个指针专属的删除器”,因而消除客户潜在的错误:所谓的 “cross-DLL problem” 这问题发生在 “对象在动态连接程序库(DLL)中被创建,却在另一个DLL内被 delete 销毁” 。在许多平台上,这一类 “跨DLL之 new/delete 成对运用” 会导致运行期错误。 而对于 tr1::shared_ptr 来说没有这个问题,因为它缺省的删除器是来自 “tr1::shared_ptr 诞生所在的那个 DLL” 的 delete

比如说:Stock 派生自 InvestmentcreateInvestment 的实现:

std::tr1::shared_ptr<Investment> createInvestment()
{
	return std::tr1::shared_ptr<Investment> (new Stock);
}

返回的那个 tr1::shared_ptr 可被传递给其他任何 DLLs ,无需在意 “cross-DLL problem”。 这个指向 Stocktr1::shared_ptrs 会追踪记录 “当 Stock 的引用次数变成0时该调用的那个 DLL’s delete

本条款不是针对说 tr1::shared_ptr 而是为了 “让接口容易被正确使用, 不容易被误用” 而设,这样设计可以消除某些客户错误,可以考虑这样做。

虽然有时候智能指针的大小和开销都比较大且慢,而且使用辅助动态内存,但是在许多应用程序中这些额外的执行成本并不显著,然而它的 “降低客户错误” (容错)的成效却是每个人都看得到的。

请记住
  • 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
  • “促进正确使用” 的办法包括接口的一致性,以及与内置类型的行为兼容。
  • “阻止误用” 的办法包括建立新类型、限制类型上的操作、束缚对象值,以及消除客户的资源管理责任。
  • tr1::shared_ptr 支持定制型删除器(custom deleter)。这可防范 DLL 问题,可被用来自动解除互斥锁等等。
学到了啥

不管做哪种语言开发,只要在写代码,都要设计接口,而设计接口时考虑本条例是非常有必要的,在设计传入的参数时,做类型限定能够帮助我们筛选很多错误情况;一致性原则能够让其他使用者更快速理解、方便调用我们的接口;尽可能消除别人(客户)使用接口的错误也是接口设计者的责任。

条款19:设计 class 犹如设计 type

C++ 就像在其他 OOP(面向对象编程)语言一样,当你定义了一个新 class,也就定义了一个新type。作为 C++ 程序员,不只是 class 设计者,也是 type 设计者,重载(overloading)函数和操作符、控制内存的分配和归还、定义对象的初始化和终结… 全都在你的手上,因此你应该带着和 “语言设计者当初设计语言内置类型时” 一样的谨慎来研讨 class 的设计。

设计优秀的 classes 是一项艰巨的工作,因为设计好的 type 是一项艰巨的工作!好的 type 有自然的语法,直观的语义,以及一或多个高效实现品。在 C++ 中,一个不良规划下的 class 定义估计无法达到上述的任何一个目标,甚至 class 成员函数的高效率都有可能受到它们“如何被声明”的影响。

几乎设计每一个 class 都可能遇到以下问题,而你的答案往往导致你的设计规范:

新 type 的对象应该如何被创建和销毁?

这会影响到你的 class 构造函数和析构函数,以及内存分配函数和释放函数(operator new, operator new[], operate delete 和 operate[]——第八章)的设计(如果你写这些函数的话)。

对象的初始化和对象的赋值该有什么样的差别?

这个答案决定了你的构造函数和赋值操作符的行为,和它们的差异,别混淆了“初始化” 和 “赋值”,这两个操作对应不同的函数调用(在C++中,当一个新对象被创建时,会有初始化操作;而赋值是修改一个已经存在的对象的值)C++ 初始化与赋值

新 type 对象如果被 passed by value(以值传递),意味着什么?

copy 构造函数(拷贝构造函数)用来定义一个 type 的 pass-by-value 该如何实现。

什么是新 type 的 “合法值”?

对于 class 的成员变量而言,通常只有某些数值集是有效的,那些数值集决定了你的class 必须维护的约束条件,也就决定了你的成员变量必须进行的错误检查操作,(赋值、构造函数和所谓 “setter” 函数(set/get)),这些操作也影响着异常、以及函数异常明细列。

你的新 type 需要配合某个继承图系吗?(体系)

如果你继承自某些 classes,你就会受到那些 classes 的设计束缚,特别是受到 “他们的函数是 virtual 或 non-virtual” 的影响(条款 34 和 条款36)。如果你允许其他 classes 继承你的 class ,那会影响你所声明的函数——尤其是析构函数——是否为 virtual
virtual 函数用于多态,当某个父类中有 virtual 修饰的函数时,父类的析构函数必须使用 virtual 修饰,否则在释放子类时,只调用了父类的析构函数,没有调用子类的析构函数,导致只释放了对象的父类部分,而子类部分没有释放)

你的新 type 需要什么样的转换?

你的新类型存在于很多类型之中,所以要考虑彼此之间是否需要有转换!如果你希望允许类型 T1 能被隐式转换为类型 T2,就必须在 class T1 内写一个类型转换函数(operator T2)或在 class T2 内写一个 non-explicit-one-argument(可被单一实参调用)的构造函数。

如果你只允许 explicit 构造函数的存在,就得写出专门负责执行转换的函数,且不得为类型转换操作符(type conversion operators)或 non-explicit-one-argument 构造函数。

什么样的操作符和函数对此新 type 而言是合理的?

看你在你新的 class 中声明的哪些函数,其中某些函数应该是成员函数,有些函数则不是。

什么样的标准函数应该驳回?

那些函数就应该被声明为 private(条款 6)

谁该取用新 type 的成员?

这个问题可以帮助你决定哪个成员是 public、哪个成员为 protected,哪个为 private ,它也能帮助你决定哪一个 classes 或、和 functions 应该是 friends ,以及将他们嵌套于另一个之内是否合理?

什么是新 type 的 “未声明接口”(undeclared interface)?

它对效率、异常安全性(条款 29),以及资源运用(比如说多任务锁定和动态内存)提供何种保证?你在这些方面提供的保证将为你的 class 实现代码加上相应的约束条件。

你的新 type 有多么一般化?

或许你其实并定义一个新 type,而是定义一整个 types 家族,果真如此你就不该定义一个新的 class,而是应该定义一个新的 class template

你真的需要一个新 type 吗?

如果只是定义新的 derived class 以便为既有的 class 添加机能,那么说不单纯定义一或多个 non-member 函数或 templates,更能够达到目标。

以上这些问题不容易回答,所以定义出高效的 classes 是一种挑战。然而如果能够设计出至少像 C++ 内置类型一样好的用户自定义(user-defined)classes,一切汗水都值得!

请记住
  • Class 的设计就是 type 的设计。在定义一个新 type 之前,请确认你已经思考过本条例所覆盖的所有问题讨论。
学到了什么

做开发新功能时,我也经常想,我要不要写一个新的 class ? 这个 class 里面要做什么事?存什么数据结构?又或者是,我的代码该放在哪里(塞到哪个类里)?为啥好几个文件删了又写,写了又删?…

条款20:宁以 pass-by-reference-to-const 替换 pass-by-value

…(先略)

请记住:

  • 尽量以 pass-by-reference-to-const 替换 pass-by-value,前者通常比较高效,并可以避免切割问题。
  • 以上规则并不适用于内置类型,以及 STL 的迭代器和函数对象,对它们而言,pass-by-value 往往比较恰当。
学到了什么

原来在 pass-by-value 内部还有这么多 “隐藏操作”!但其实对于常见的 lua 和 python 语言来说(python 没太深接触过,没有了解)某种类型就是 pass-by-value 或者 pass-by-reference ,对于lua来说:table类型赋值为引用传递,其它类型赋值为值传递(好像是这个语言规定的),像这种语言我们好像没有办法使用这个条款,但是对于 C\C++\C# 等等这些语言来说,条款还是值得我们遵循的。

待研究的相关语言(关于本条例):
C#中的值传递和引用传递是什么?
这一次,彻底解决Java的值传递和引用传递

条款21:必须返回对象时,别妄想返回其 reference

…(先略)

请记住:

  • 绝不要返回 pointer 或者 reference 指向一个 local stack 对象,或返回 reference 指向一个 heap-allocated 对象,也不要返回 pointerreference 指向一个 local static 对象(有可能需要很多个这种对象)。

条款 4 已经为 “在单线程环境种合理返回 reference 指向一个 local static 对象” 提供一份设计实例。

学到了什么

该创建新对象的时候就要创建新对象,该使用构造函数的时候就要使用构造!本条例说到的 “反面教材” 好几条都踩过坑了,要记住这个条例!

条款22:将成员变量声明为 private

这样做的原因有两个:

  1. public 没有封装性
  2. protected 也没有封装性

…(先略)

请记住:

  • 切记将成员变量声明为 private,这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供 class 作者以充分的实现弹性。
  • protected 并不比 public 更具备封装性
学到了什么

class 的访问权限只有封装(private)和不封装(其他)之分,作为一个类,还是要封装起来的好。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值