《Effective C++》是一本轻薄短小的高密度的“专家经验积累”。本系列就是对Effective C++进行通读:
C++ 在接口之海漂浮。函数接口,类接口,模板接口……每个接口都是客户与你的代码进行交互的一种方法。假设你正在面对的是一些“讲道理”的人,这些客户尝试把工作做好,他们希望能够正确使用你的接口。在这种情况下,如果接口被误用,你至少负一部分的责任。理想情况下,如果使用一个接口没有做到客户希望做到的,代码应该不能通过编译;如果代码通过了编译,那么它就能做到客户想要的。
想要开发出一个容易被正确使用不容易被误用的接口,首先需要考虑客户可能出现的所有类型的错误。举个例子,假设你正在为一个表示日期的类设计一个构造函数:
class Date {
public:Date(int month, int day, int year);...
};
乍一看,这个接口可能看上去去合理的,但是客户很容易犯下至少两个错误:
Date d(30, 3, 1995);
Date d(2, 30, 1995);
(上一个例子看上去很蠢,但是不要忘了在键盘上,数字2和3是挨着的,将2错打成3这样的错误并不罕见。)
许多客户端错误可以因为通过引入新的类型获得预防,的确,类型系统(type system)是你阻止不合要求的代码编译通过的主要盟友。在这种情况下,我们可以引入简单的外覆类型来区分天,月和年,然后在Date构造函数中使用这些类型:
struct Day{explicit 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;};
class Date {
public:Date(const Month& m, const Day& d, const Year& y);
...
};
Date d(30, 3, 1995); // error! 错误类型
Date d(Day(30), Month(3), Year(1995)); // error! 错误类型
Date d(Month(3), Day(30), Year(1995)); // OK,正确类型
将Day,Month和Year数据封装在羽翼丰满的类中比上面简单的使用struct要更好(见条款22),但是使用struct就足以证明,明智而谨慎地引入新类型可以很好的阻止接口被误用的问题。
一旦正确的类型准备好了,就可以合理的约束这些类型的值。如,一年只有12个月份应该能够通过Month类型反映出来。方法之一是使用一个枚举类型来表示月份,但是枚举不是我们喜欢的类型安全的类型。例如,枚举可以像int一样使用(见条款2)。一个更加安全的解决方案是预先将所有有效的月份都定义出来。
class Month {
public:static Month Jan() { return Month(1); } // 函数,返回有效月份static Month Feb() { return Month(2); } ...static Month Dec() { return Month(12); }... // 其它成员函数
private:explicit Month(int m); // 阻止生成新的月份... // 月份专属数据
};
Date d(Month::Mar(), Day(30), Year(1995));
如果使用函数代替对象来表示指定月份值会让你觉的奇怪的话,可能是因为你忘记了非本地static对象的初始化次序有可能出现问题(见条款4)。
另外一种防止类似错误的方法是对类型能够做什么进行限制。进行限制的一般方法是添加const。举个例子,条款3解释了对于用户自定义的类型,把operator*的返回类型加上const能够防止下面错误的发生:
if (a * b = c) ... //原意是要做一次比较动作
事实上,这只是“使类型容易正确使用不容易被误用”的表现形式:除非有更好的理由,让你的自定义类型同内置类型的行为表现一致。客户已经知道像int一样的内置类型的行为是什么样子的,所以在任何合理的时候你应该努力使你的类型表现与其一致。举个例子,如果a和b是int类型,那么赋值给a*b是不合法的,所以除非有一个好的理由偏离这种行为,你应该使你的类型同样不合法。每当你不确定自定义类型的行为时,按照int来做就可以了。
避免自定义类型同内置类型无端不兼容的真正原因是:提供行为一致的接口。很少有其它特征比“一致性”更能使接口容易被使用了,也没有特征比“不一致性”更加导致接口容易被误用了。STL容器的接口基本上(虽然不是完全一致)是一致的,这使得它们使用起来相当容易。举个例子,每个STL容易有一个size成员函数,用来指出容器中的对象数量。与Java相比,arrays使用length属性(property)来表示对象数量,而String使用length方法(method)来表示,List使用size方法来表示;对于.NET来说,Array有一个Length属性,而ArrayList有一个Count属性。一些开发人员认为集成开发环境(IDE)使这种不一致性不再重要,但他们错了。不一致性会将精神摩擦强加到开发人员的工作中,没有任何IDE能够将其擦除。
一个要让客户记住做某事的接口比较容易被用错,因为客户有可能会忘记做。举个例子,条款13中引入一个工厂函数,在一个Investment继承体系中返回指向动态分配内存的指针:
Investment* createInvestment();
为了防止资源泄漏,createInvesment返回的指针最后必须被delete,但是这为至少两类客户错误的出现创造了机会:delete指针失败,多次delete同一个指针。
条款13展示了客户如何将createInvestment的返回值存入像auto_ptr或者tr1::shared_ptr一样的智能指针中,这样就将delete的责任交给智能指针。但是如果客户忘记使用智能指针该怎么办?在许多情况下,更好的接口是要先发制人,让工厂函数首先返回一个智能指针:
std::tr1::shared_ptr createInvestment();
这就强制客户将返回值保存在tr1::shared_ptr中,从而完全消除了忘记delete不再被使用的底层Investment对象的可能性。
事实上,对于一个接口设计者来说,返回tr1::shared_ptr能够避免许多其他的有关资源释放的客户错误,因为条款14中解释道,在创建智能指针时,tr1::shared_ptr允许将一个资源释放函数——释放器(deleter)——绑定到智能指针上。
假设客户从createInvestment得到一个Investment*指针,我们通过将这个指针传递给一个叫做getRidOfInvestment的函数来释放资源,而不是直接使用delete。这样的接口开启了另外一类客户错误的大门:客户可能会使用错误的资源析构机制(用delete而不是用提供的getRidOfInvestment接口)。createInvestment的实现者可以先发制人,返回一个tr1::shared_ptr,并将getRidOfInvestment绑定为删除器。
tr1::shared_ptr提供了一个有两个参数的构造函数:需要被管理的指针和当引用计数为0时需要被调用的删除器。这就提供了一个创建用getRidOfInvestment作为删除器的空tr1::shared_ptr的方法,如:
std::tr1::shared_ptr // 视图创建一个 null shared_ptr
pInv(0, getRidOfInvestment); // 并携带一个自定的删除器// 此式无法通过编译
上面不是有效的c++,tr1::shared_ptr构造函数的第一个参数必须为指针,但是0不是指针,是个int。虽然它可以转换成指针,但是在此例子中并不够好,因为tr1::shared_ptr坚持使用真实的指针。转型(cast)就能解决问题:
std::tr1::shared_ptr // 创建一个 null shared_ptr
pInv( static_cast(0), // 并以getRidOfInvestment作为删除器
getRidOfInvestment);
这意味着实现一个createInvestment的代码如下(返回值为绑定了getRidOfInvestment作为删除器的tr1::shared_ptr):
std::tr1::shared_ptr createInvestment() {std::tr1::shared_ptr retVal(static_cast(0),getRidOfInvestment);retVal = ...; // 令retVal指向正确对象
return retVal;
}
当然,如果在创建一个retVal之前就能够决定一个原始指针是不是由reVal来管理,将原始指针直接传递给retVal的构造函数比先将retVal初始化为null然后做一个赋值操作要好。为什么请看 条款26。
tr1::shared_ptr的一个特别好的性质是它会自动使用它的“每个指针专属的删除器”,因而消除另外一个客户错误——交叉(cross)-DLL错误。
当一个对象在一个DLL中使用new被创建,但是在另外一个DLL中被delete时这个问题就会出现。在许多平台中,这样的交叉-DLL new/delete对会导致运行时错误。使用tr1::shared_ptr可以避免这种错误,因为它使用的默认的删除器来自创建tr1::shared_ptr的DLL。这就意味着,例如,如果Stock是一个继承自Investment的类,createInvestment实现如下:
std::tr1::shared_ptr createInvestment() {return std::tr1::shared_ptr(new Stock);
}
的tr1::shared_ptr可以在DLL之间被传递而不用考虑cross-DLL问题。在Stock的引用计数为0的时候,指向Stock的tr1::shared_ptr指针会追踪哪个DLL的删除器被用来释放资源。
本条款不是关于tr1::shared_ptr的——它是关于“让接口容易被正确使用不容易被误用”这个议题的——但是使用tr1::shared_ptr是一个如此容易的消除客户错误的方法,所以值得将使用它的代价做一个概述。tr1::shared_ptr的最一般的实现来自Boost(条款55)。Boost中的shared_ptr占用内存是原生指针的两倍,为bookkeeping(引用计数)和deleter-specific(专属删除器) 数据分配动态内存,调用删除器的时候使用虚函数,当在一个应用中修改引用计数时,如果它认为自己是多线程的,会引发线程同步开销。(你可以通过定义一个预处理符号来disable多线程支持)一句话,它比原始指针占用内存多,比原始指针慢,并且使用了辅助的动态内存。但是在许多应用中,这些额外的运行时开销是不明显的,但是客户错误的消除对每个人来说都是显而易见的。
好的接口容易被正确使用,不容易被误用,你应该使所有的接口努力达成这些性质。
“促进接口正确使用”的方法包括接口的一致性,以及与内置类型的行为兼容。
“阻止误用”的方法包括,建立新的类型,限制类型上的操作,约束对象值,去除客户管理资源的责任。
tr1::shared_ptr支持个性化删除器。这避免了交叉-DLL问题,可以被用来自动unlock互斥器条款14等等。
期待大家和我交流,留言或者私信,一起学习,一起进步!