软件设计是“让软件做你想让它做的事情”的步骤和做法。从一般性的构想开始,逐渐清晰,构造细节,最终设计出良好的接口(interface)。这些接口而后变为C++的声明。
下面讲的是关于接口设计和声明的做法。设计接口一个很重要的准则是:让接口容易被正确使用,不容易被误用。这是一个大的准则,细化之后包括很多内容:正确性、高效性、封装性、维护性、延展性以及协议的一致性。
接口是客户和你的代码交换的唯一手段。如果客户正确使用你开发的接口,那自然很好;但是如果你的接口被误用,你也要负一部分责任。理想上,如果客户使用了某个接口却没有获得他想要的行为,那么不应该编译通过;如果编译通过了,那么客户就应该得到他想要的行为。
欲开发一个“容易被正确使用,不容易被误用”的接口,首先要考虑客户可能会出现什么样的错误。假如设计一个class来表示日期,那么它的构造函数应该怎么设计?
class Date{
public:
void Date(int month, int day, int year);
……
};
Date d(30, 3, 1995);//把月和日弄反了
还有
Date d(2, 30, 1995);//2月没有30号
上面例子看起来很愚蠢,但是还是可能会出现的。
像这样客户可能错误输入的错误可以通过引入新的数据类型来预防。
struct Day{
explict Day(int d):val(d){};
int val;
}
struct Month{
explicit Month(int m):val(m){}
int val;
};
struct Year{
explicit Year(int y):val(y){}
int val;
};
在重写Date的构造函数
class Date{
public:
void Date(const Month& m, const Day& d, const Year& y);
……
};
这样在使用起来,如果年月日的顺序不对,编译器会报错。但是这样还是无法避免客户端使用时的第二个错误。
在参数类型定位会后,就可以限制其值了。一年只有12个月份,所以Month应该反映这样的事实。办法之一就是利用enum表现月份,但是enum不具有类型安全,因为enum本质是一个int类型。比较安全的一个办法是预先定义所有的月份。
class Month{
public:
static Month Jan(){ return Month(1);}
static Month Feb(){ return Month(2);}
……
private:
explicit Month(int m);//只能在类内部使用,防止生成其他月份。
……
};
以函数代替对象。
预防客户端错误的另一个办法是,限制类型内什么事可以做,什么事不可以做。经常见到的是加上限制const。例如在条款3中,用const修饰operator*的返回值,这样就可以阻止客户因“用户自定义类型而犯错”
if(a*b=c)//这里其实打算做比较,而不是赋值
下面是另一个设计原则:让types容易被正确使用,不容易被误用。
除非你有好的理由,否则应该尽量让你的types的行为于内置types保持一致。例如,客户已经知道像int这样的type有些什么样的行为,所以你应该努力让你的types在合情合理的前提下也有相同的表现。
避免于内置类型发送冲突,这样做的目的是提供行为一致性的接口。“一致性”最容易导致接口被正确使用。STL容器的接口十分一致(虽然不是完美地一致),这样使得它们非常容易被使用。例如,每个STL容易都有一个名为size的成员函数,来表示当前容器内有多少对象。与此对戏的是Java,它允许你针对数组使用length property,对Strings使用length method,而对Lists使用size method;.NET也有一样混乱,其Arrays有property名字为Length,但ArrayLists有个property名字为Count。
任何接口,如果要求客户必须记得做某些事,那么这个接口就有着不正确使用的倾向,因为客户很可能忘记做那件事。例如在条款13有一个工厂函数factory,它返回一个指针,指向Investment继承体系内的一个动态分配对象:
Investment* CreateInvestment();
为了避免资源泄露,CreateInvestment返回的指针必须被删除,这样客户就有了两个犯错误的机会:没有删除指针,或者删除了不止一次。
条款13最终使用智能指针,来确保资源的释放。但是如果客户忘记使用智能指针怎么办?许多时候,较好的接口设计原则是先发制人,令factory函数返回一个智能指针
shared_ptr CreateInvestment();
这样强迫客户将返回值存储在shared_ptr内,几乎消弭了忘记删除底部Investment对象的可能性。
shared_ptr的接口设计可以阻止一大群客户犯下资源泄露的错误。正如条款14所言,shared_ptr允许当智能指针被创建时就制定一个资源释放函数(所谓删除器,默认为delete)绑定到智能指针身上。
假设class的设计者期许从Createinvestment取得Investment*指针的客户,最终把指针传递给名为getRidOfInvestment的函数,而不是删除。那么这个接口有了一种新的被误用的错误:企图使用析构地址替换getRidOfInvestment)。这时,CreateInvestment的设计者可以先发制人,返回一个将getRidOfInvestment绑定为删除器的shared_ptr指针。
这样shared_ptr的构造函数接受两个实参:一个被管理的指针,另一个为次数变为0时的“删除器”。这启发我们创建一个NULL shared_ptr,并将getRidOfInvestment作为其删除器。
shared_prt createInvestment()
{
shared_prt retVal(static_cast(0),
getRidOfInvestment);
retVal=……;//令retVal指向正确对象
return retVal;
}
shared_ptr一个特别好的性质是:它会自动使用它的“每个指针专属的删除器”,因而消除另一个潜在客户的错误:Corss-DLL Problem。这个问题发生于:对象在一个动态链接库DLL中被new创建,却在另一个DLL内被delete销毁。在许多平台上,这一类跨DLL之new/delete成对使用会导致运行期错误。shared_ptr没有这个问题,因为它的删除器来自其所诞生的那个DLL的delete。
本条款特别针对shared_ptr,目的是让接口容易被正确使用,不容易被误用。shared_ptr带来如此多好处,那么使用它的成本低多少?最常见的shared_ptr实现品来自Boost(条款55),源代码可以参考这里。其大小是原始指针的两倍(一个原始指针,一个计数器),以动态分配内存作为记录和删除器专用,以virtual形式调用删除器,并在多线程程序修改引用次数时受到线程同步(thread synchronization)的额外开销(可以关闭支持多线程)。总之它比原始指针大且慢,并且使用辅助内存。但是这些成本并不显著,带来的好处非常有效。
总结:
1、好的接口容易被正确使用,不容易被误用。
2、”促进正确使用“的办法包括接口一致性,以及于内置类型兼容。
3、”阻止误用“方法包括建立新类型、限制类型上的操作、束缚对象值,以及消除客户的资源管理责任。
4、shared_ptr支持特定的删除器。可以防范cross-DLL problem。
在面向对象的语言中,定义一个新class时,也就定义了一个新type。在开发C++时,许多时间都是在扩张类型系统,这意味着程序员只是class的设计者,还是type的设计者。重载函数和操作符、内存分配于释放、对象的创建和销毁……这些全都控制在程序员手上。因此应该带着和”语言设计者当初设计语言内置类型时“一样的谨慎来研讨class的设计。
在设计新的class时,应该回答一下问题。
新type的对象应该如何被创建和销毁?
这会影响到class的构造函数和析构函数以及内存的分配和释放。
对象初始化和对象赋值该有什么样的区别?
这个是你构造函数和赋值操作符的行为以及它们的差异。不要混淆初始化和赋值,它们对应不同的函数(见条款4)。
新type的对象如果被pass by value,意味着什么?
这决定于你的copy构造函数。
什么是新type的合法值?
对于class的成员变量而言,通常只有某些数据集是有效的。这些数据集决定了你的class要维护的约束条件,也决定了你的某些成员函数进行的错误检查的工作,它也影响函数抛出异常的明细。
新type需要配合某个继承图系(inheritance graph)吗?
如果你继承自某些既有的classes,特别是收到那些classes的virtual或non-virtual的影响(条款34和条款36)。如果你允许其他classes继承你的class,那会影响你所声明的函数,尤其是析构函数(是否为virtual)。
新type需要什么样的转换?
你的type生存在其他type之间,彼此之间需要转换行为吗?如果允许类型T1转换为类型T2,那么就必须在class T1内写一个类型转换函数operator T2,或在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。如果只允许explicit构造函数存在,必须写出专门负责执行转换的函数,且不得为类型转换操作符(type-conversion operators)或non-explicit-one-argument构造函数。条款15有隐式和显示转换的范例。
什么样的操作符和函数对此新type而言是合理的?
这取决于你为class声明哪些函数。其中一些是member函数,一些不是。
什么样的函数应该被驳回?
即哪些函数应该声明为private。
谁该取用新type的成员?
这个问题帮助你决定哪个成员是public,哪个是private。也帮你解决哪一个class或fuction应该是friends,以及将它们嵌套于另一个是否合理。
什么是新type的“未声明接口”(undeclared interface)?(不懂???)
它对效率、异常安全性(条款29)以及资源运用(例如多任务和动态内存)提供何种保证?你在这些方面提供的保证将为你的class实现代码上加上响应约束条件。
你的新type有多么一般化?
也许你并非定义一个新type,而是定义一整个types家族。如果是这样就不应该定义一个新class,而是应该定义一个新的class template。
你真的需要一个新type吗?
如果只是定义新的derived class以便为既有的class添加机能,那么说不定单纯定义一个或多个non-member function或templates,更能达到目标。
上述问题不容易回答,定义高效的class是挑战。如果能够设计出像C++内置类型一样友好的自定义class,再多努力也值得。