文章目录
前言
软件设计就是 “令软件做出你希望它做的事情” 的步骤和做法,允许开发特殊接口,这些接口最终必须转换为C++声明式。
本章以最重要、最适合任何接口设计的一个准则作为开端:“让接口容易被正确使用,不容易被误用”。这个准则设立了一个舞台,让其他更专精的准则针对更大范围的设计,包括:正确性、高效性、封装性、维护性、延展性,以及协议一致性。
以下条例强调的是某些最重要的考虑,对某些频繁出现的错误提出警告,为class
、function
和 template
设计者经常遇到的问题找到解决方案。
基础
explicit
构造函数函数:C++提供了关键字explicit
,可以阻止不应该允许的经过转换构造函数进行的隐式转换的发生,声明为 explicit
的构造函数不能在隐式转换中使用。(简单说:explicit
构造函数是用来防止隐式转换的。)
条款18:让接口容易被正确使用,不易被误用
C++的每一种接口都是客户(使用接口的人)与代码交互的方式,如果使用接口的方式不正确也应该在设计接口的时候考虑到。如果这个接口能够接受客户任何输入并且编译通过,那么这个接口的设计也要能够接受这些输入,并且输出客户预想(设计者告知客户)的结果。
基于以上情况,设计者应该在开发接口时就要设想,客户使用接口会出现什么样的情况,甚至是错误情况。比如说设计一个表现日期的class
设计构造函数:
class Date
{
public:
Date(int month, int day, int year);
....
};
这个接口这样实现看起来没有什么问题。
但是在客户用的时候可能会出现两种错误情况:
- 错误的次序传递参数:
Date d(30, 3, 1995); //正确应该是 "3, 30", 而不是 "30, 3"
- 传递一个无效的日期或者月份:
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)); // 类型正确
使用Day
、Month
、Year
成为成熟的 classes
并封装他们的数据,比上面简单的用 structs
更好,但是 structs
也可以作为优化的接口案例告诉我们:明智而审慎地导入新类型对预防“接口被误用”有神奇疗效。
现在已经有了正确的类型限制,对于上面这个日期接口案例来说,还需要限制这个类型内部数据的值,比如说:一年只有12个月,2月只有28天,所以 month
和 day
都应该反映这些事实。可以利用两个操作来预防客户错误:
- 第一个办法:使用
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
对象的初始化次序!
-
第二个办法:限制类型内什么事可以做,什么事不能做,常见的限制是加上
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_ptr
或 tr1::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
派生自Investment
而createInvestment
的实现:std::tr1::shared_ptr<Investment> createInvestment() { return std::tr1::shared_ptr<Investment> (new Stock); }
返回的那个
tr1::shared_ptr
可被传递给其他任何 DLLs ,无需在意 “cross-DLL problem”。 这个指向Stock
的tr1::shared_ptrs
会追踪记录 “当Stock
的引用次数变成0时该调用的那个 DLL’sdelete
”
本条款不是针对说 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
对象,也不要返回pointer
或reference
指向一个local static
对象(有可能需要很多个这种对象)。
条款 4 已经为 “在单线程环境种合理返回
reference
指向一个local static
对象” 提供一份设计实例。
学到了什么
该创建新对象的时候就要创建新对象,该使用构造函数的时候就要使用构造!本条例说到的 “反面教材” 好几条都踩过坑了,要记住这个条例!
条款22:将成员变量声明为 private
这样做的原因有两个:
- public 没有封装性
- protected 也没有封装性
…(先略)
请记住:
- 切记将成员变量声明为
private
,这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class
作者以充分的实现弹性。 - protected 并不比 public 更具备封装性
学到了什么
class
的访问权限只有封装(private
)和不封装(其他)之分,作为一个类,还是要封装起来的好。