EffectiveC++笔记 第4章
我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的“可能比较准确”的「翻译」。
Chapter4 设计与声明 Designs and Declarations
条款18: 让接口容易被正确使用,不易被误用
欲开发一个“容易被使用,不容易被误用”的接口,首先必须考虑客户可能做出什么样的错误。
假设我们要设计一个表示日期的class:
class Data{ public: Date(int month,int day,int year); ... };
事实上使用它的客户很容易犯错误:
以错误的次序传参:
Date d(30,3,1995); //喔哟! 应该是“3,30”而不是"30,3"
传递无效的参数:
Date d(2,30,1995); //2月有30号????
我们可以引入类型系统(type system)和外覆类型(wrapper types)
现以外覆类型来区别天数,月份,年份,然后再Date中使用:
struct Day{ struct Month{ explicit Day(int d) explicit Month(int m) :val(d) {} :val(m) {} int val; 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); //not ok Date d(Day(30),Month(3),Year(1995)); //not ok Date d(Month(3),Day(30),Year(1995)); //ok
其实你也可以用更成熟的class来封装外覆类型,但这里的struct已经很好了。
类型确定后,通常要对值进行限制,比如一年只有12个月。你可以用enum来表现月份。但是enum不具备类型安全性,比如enums可以被拿来当一个ints使用。
比较安全的解法是预先定义所有有效的Months:
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));
常见的预防客户出错的办法是限制类型内的权限。例如加上const。
另外,::应尽量让你的包装types与内置types行为一致::。客户已知道int这样的type会有什么行为,所以你应让你包装的types也有相同表现。<避免无端与内置类型不兼容>
记住,任何接口如果试图让客户「必须记得做某些事情」,就是有着「不正确使用」的倾向。
还记得条款13吗,tr1::shared_ptr接受了func()返回的指针,这将发挥智能指针的威力。
但是如果客户自己就忘记了要用到智能指针呢?较佳接口的设计原则是先发制人,也就是这样写func():
std::tr1::shared_ptr<xx> func();
这实质上强迫客户将返回值储存于一个tr1::shared_ptr内,让接口设计者得以阻止一大群客户犯下资源泄漏的错误。
还有一种特殊情况。假设作为class设计者的你想让那些“从func()取得xx*指针”的客户将该指针传递给一个名为getRidOfxx()的函数,并让它处理这个指针的「销毁」,而不是粗暴地直接对此指针使用delete。可能你这样设计有出于你的考虑,但是客户还是可能忘记并仍使用delete。所以func()的设计者可先发制人,不仅返回一个tr1::shared_ptr,并在它身上绑定删除器(deleter) getRidOfxx()。
事实上tr1::shared_ptr有一个重载的构造函数接受两实参:一个是被管理的指针,另一个是当引用次数变为0时被调用的“删除器”:
std::tr1::shared_ptr<xx> pInv(0,getRidOfxx); //企图创建一个null智能指针,但是无法通过编译。
这个构造函数坚持第一个参数必须是指针,而不是int型的值0———虽然它能被转换为指针。所以转型可解决:
std::tr1::shared_ptr<xx> pInv(static_cast<xx*>(0), getRidOfxx); //static_cast以后提到.
而作为func()的内部实现:
std::tr1::shared_ptr<xx> func() { std::tr1::shared_ptr<xx> retVal(static_cast<xx*>(0), getRidOfxx); retVal = ...; //令retVal指向正确对象 return retVal; }
当然,若将被pInv接管的原始指针已经在建立pInv之前确定了,那么直接传此指针给pInv构造函数是更佳选择。
条款19: 设计class犹如设计type
实际上,你定义一个新class时,可理解为你定一个了新type。
这意味着你不仅是class设计者,还是type设计者,重载(overloading)函数和操作符、控制内存的分配和归还、定义对象的初始化和终结……都在你手上
考虑以下问题,你的回答往往影响你的设计规范:
- 新class,或者说type该如何被创建和销毁?
这将影响你的构造、析构、内存分配与释放函数:
(operator new, operator new[], operator delete, operator delete[])
前提是你打算撰写它们
- 对象初始化和赋值该有怎样的差别?
决定了你的构造函数和赋值操作符的行为。别混淆“初始化”和“赋值”,它们对应不同的函数调用(条款4)。
- 新type对象若被passed by value(以值传递),意味着什么?
考虑copy构造函数用来定义一个type的pass-by-value该如何实现
- 什么是新type的“合法值”?
对于你设计的class成员变量,你必须考虑它们取值的范围以及规范(约束条件),这决定了你的成员函数必须进行的错误检查工作。它也影响函数抛出的异常。
- 你的type需要配合某个继承图系吗?
如果你的type继承自现有的classes,就会受到设计约束。特别是受到“它们的函数是virtual或non-virtual”的影响。若你允许其它classes继承你的class,这要考虑你的函数是否为virtual。
- 你的type需要什么样的转换?
如果你希望你的type T1能隐式转换为T2,就必须在class T1内写一个类型转换函数( operator T2 )或在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。若你只允许显式转换,就得写出专门负责执行转换的函数。
什么样的标准函数应驳回? 那些是你应声明为private的成员(条款6)
谁该取用新type的成员?
这将帮助你决定哪个成员为public、private、proteced。也帮你决定哪个class,functions应该是友元,以及它们的嵌套是否合理。
条款20: 宁以pass-by-reference-to-const替换pass-by-value
C++默认是以by value方式(继承自C)传递对象至函数(或来自函数)。这样一来,函数参数都是以实参的副本为初值,而调用端获得的亦是函数返回值的副本。这些副本是由对象的copy构造函数产出,会成为费时的操作。
考虑以下代码:
class Person{ public: Person(); virtual ~Person(); ... private: std::string name; std::string address; }; class Student: public Person{ public: Student(); ~Student(); ... private: std::string schoolName; std::string schoolAddress; };
假设我们有这样的代码:
bool validateStudent(Student s); //by value Student plato; bool platoIsOk = validateStudent(plato);
无疑,会以plato为蓝本初始化s,返回后s被销毁。
你会发现,在这里,以by value传递一个Student对象会导致调用一次Student copy构造函数、一次Person copy构造函数、四次string copy构造函数。
但如果以pass by reference-to-const的方式,效率会高得多:
bool validateStudent(const Student& s);
这时,没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。这个const修饰符很重要,它可以保证函数不会修改源头的Student。
另外,by reference方式可避免slicing(对象切割)。假设base class为A,derived class为B,有这种代码:
void func(A obj)..
而你传参的操作:
B tmp = new B(); func(tmp);
此时A的copy构造函数被调用,但是属于B的特质化成员会被无视掉,只剩A对象的框架。此时解决方案即为以by reference-to-const方式传递参数。
当应用于C++内置类型,如int之类,pass-by-value可能会更高效,这同样适用于STL迭代器和函数对象。
条款21: 必须返回对象时,别妄想返回其reference
在尝到传引用的甜头后,你可能从此一发不可收拾。但是你总有一次会犯下致命错误:开始传递一些referencce指向司机不存在的对象。
现在假设有一个表达有理数(Rational Number)的Class:
class Rational{ public: Rational(int numerator = 0, int denominator = 1); //分别表示分子和分母 ... private: int n,d; //分子和分母的内部储存 friend const Rational operator*(const Rational& lhs const Rational& rhs); //将*操作符的重载函数定义为友元 };
然后我们在主调函数中有下面的操作:
Rational a(1,2); //a = 1/2
Rational b(3,5); //b = 3/5
Rational c = a * b; //c算得3/10
第三条语句相当于 Rational c = operator*(a,b);
,这时函数会返回适当的「值」赋给c。
现在看第一个版本的*运算重载函数:
const Rational operator*(const Rational& lhs const Rational& rhs) { Rational result(las.n*rhs.n , lhs.d*rhs.d); return result; //返回一份copy }
经过之前学习,我们知道这样开销较大。
现在考虑考虑返回引用的版本,即将细节改成 return &result;
,并将返回类型改成const Rational&
这有严重问题,不用new来构造对象的话,对象只是一个local本地对象,它将在函数退出后被销毁。这会导致你得到的引用指针将会指向一个不明的「残骸」
看看另一种版本,由new构造的对象储存在heap堆上:
const Rational& operator*(const Rational& lhs const Rational& rhs) { Rational* result = new result(las.n*rhs.n , lhs.d*rhs.d); return* result; //返回指针,被&加工为产量指针 }
没有啥卵用,因为new的过程还是要构造对象。其实这个版本更糟,因为你需要考虑delete。
还有一种坑爹的版本,就是将函数内部的Rational对象声明为静态的,并返回它的引用。这里虽然解决了被销毁的问题,但是对于C++多线程它是不安全的。
假设我们已经写好了==重载函数,且完全正确:
bool operator==(const Rational& lhs, const Rational& rhs);
假设有下面的操作:
Rational a,b,c,d; ... if((a*b)==(c*d)){...} else ...
估计你也想到了,两个*运算都返回一个指向同一处static对象地址的引用,所以这个式子的比较结果永远为true。
抱歉,说了这么多,我们还是回到了起点————对于*运算的重载,我们几乎只能采用返回一个新对象的方法:
//第一个版本的精简 const Rational operator*(const Rational& lhs const Rational& rhs) { return Rational(las.n*rhs.n , lhs.d*rhs.d); //返回一份copy }
总结:
- 绝不要返回指向local stack对象的pointer或reference / 返回指向heap-allocated对象的reference / 返回指向local static对象的pointer或reference,而且可能同时需要多个这样的对象
条款22: 将成员变量声明为private
这个建议适用于protected成员
首先,获取私有成员的渠道大部分是函数,所以客户访问成员不需要考虑究竟是否要加小括号,因为全是函数,他们照做就是。
其次,你可以通过函数精确控制各种访问权限:
class AccessLevels{ private: int noAccess; //无任何访问动作 int readOnly; //read-only access int readWrite; //read-write access int writeOnly; //write-only public: ... int getReadOnly() const { return readOnly; } void setReadWrite(int v) { readWrite = v; } int getReadWrite() const { return readWrite; } void setWriteOnly(int v) { writeOnly = value; } };
一般来说,每个成员变量都需要getter和setter的情况实属罕见,所以这样的控制很有必要。
将成员变量隐藏在函数接口的背后,可以为“所有可能的实现”提供弹性。
现在我问你,protected成员的封装性是否高于public?答案是不尽如人意。
我们知道,public的访问一般要求客户自己写代码来实现,一旦public的成员函数被取消,所有使用它的客户代码都会被破坏。而protected被取消掉的话,它的所有dervied classes都会被破坏。因此protected和public一样缺乏封装性。
所以从封装的角度来看,其实只有两种访问权限:private(提供封装)和其它(不提供封装)。
条款23:宁以non-member、non-friend替换member函数
假设有一个Class代表网页浏览器。有几个成员函数,提供了清除缓存、清除历史记录、清除cookies:
class WebBrowser{ public: ... void clearCache(); void clearHistory(); void removeCookies(); void clearEverything(); //调用上述三个函数。 ... };
这些功能也可由一个non-member函数实现,只需传入一个WebBrowser对象引用就行:
void clearBrowser(WebBrowser& wb) { wb.clearCache(); wb.clearHistory(); wb.removeCookies(); }
那么,哪一个比较好呢?member函数clearEverything还是non-member clearBrowser?
根据面向对象守则,数据以及操作数据的函数应捆绑在一块儿,这意味member函数是更好的选择。然而这是一个误解。
和你直觉相反的是,non-member函数clearBrowser封装性实际上比member版本的clearEverything还要高。
通常来说,member函数不仅能访问Class里的private成员,还能取用enums、typedefs等。我们说高封装性是指应有尽可能少的代码能够直接「看到」私有成员变量,这时non-member函数的优越性就体现出来了,能完成同样的机能,但又和Class的私有成员保持了绝对的距离。
所以如果只考虑封装性的话,选择的关键在于member和non-member、non-friend之间。(friend的权限和member一样大)
我们甚至可以将函数clearBrowser作为某工具类的一个static member函数,给其它Class用时,再变成non-member。
在C++,你以后可能比较自然的做法是,将clearBrowser成为一个non-member函数并位于WebBrowser所在同一个namespace里:
namespace WebBrowserStuff{ class WebBrowser{...}; void clearBrowser(WebBrowser& wb); }
条款24: 若所有参数都需类型转换,请采用non-member函数
令class支持隐式转换通常会有风险。但常见的例外是建立「数值类型」。假设我们又设计一个有理数Class,允许整数“隐式转换”为有理数很合理。假设我们这样构造有理数Class:
class Rational{ public: Rational(int numerator = 0, int denominator = 1); //刻意不为explicit,允许int-to-Rational隐式转换 int numerator() const; //分子访问 int denominator() const; //分母访问 private: ... };
假设此时你想让Class支持算术运算,比如让它能作乘法运算。你不确定要用member、non-member还是non-member friend函数。你的直觉告诉你要用member版本的operator*重载:
class Rational{
public:
...
const Rational operator*(const Rational& rhs) const;
};
这个设计能让相乘很自然:
Rational oneEight(1,8);
Rational oneHalf(1,2);
Rational result = oneHalf * oneEight;
result = result * oneEight;
你不满足,你希望Rationals能和ints相乘:
result = oneHalf * 2; //Good
result = 2 * oneHalf; //Bad!
Wait,乘法应该满足交换律啊!
问题出在哪?我们翻译一下上述代码:
result = oneHalf.operator*(2); //Good result = 2.operator*(oneHalf); //Bad!
语句一中,将int型2传入操作符函数后,发生了隐式转换(原参数是一个Rational引用)。有点类似于:
const Rational temp(2); //编译器建立一个临时对象
result = oneHalf.operator*(temp); //传参
这里成功的原因是我们没有将构造函数声明为显式的,这为上面的操作提供了支持。
然而第二个语句呢?2作为一个int型,并没有class,更别说operator* 成员函数。编译器会试着在namespace或global域内寻找是否有一个non-member operator*。然而并没有。
当然,如果构造函数是explicit,没有一个语句会通过编译。
结论是,当参数位于参数列(parameter list)内,才有资格参与隐式转换。这就是为啥第一个语句能够通过编译。
但是我们想支持混合运算啊喂!!也就是能让Rational和其它类型数据相运算!!
现在考虑non-member operator* :
class Rational{
...
};
const Rational operator*(const Rational& lhs,const Rational& rhs)
{
return Rational(lhs.numerator()*rhs.numerator(),
lhs.denominator()*rhs.denominator());
} //变成non-member函数
执行:
Rational oneFourth(1,4); Rational result; result = oneFourth * 2; result = 2 * oneFourth; //全部通过编译,恭喜!!!!
这都很好。但要不要考虑将operator* 变为一个friend函数呢?答案是否定的。因为我们可以从上面的操作中看出,完全可以只靠Rational的public接口完成operator* 的任务。这导出一个重要的观察:
member函数的方面就是non-member,而不是friend。
无论何时,可以避免使用friend就避免。
必须告诉你的是,这些不是「真理」。因为从Object-Oriented C++跨入Template C++后,你会考虑将Rational设计为一个class template而非class,这将引入很多新考虑,以后会提到。
Remember :
- 若你要为某函数的所有参数(包括this隐指针所指参数)进行类型转换,这个函数必须设计为non-member
条款25: 考虑写出不抛异常的swap函数
swap原本是STL一部分,实现了两个数据对象的交换。后来成为异常安全性编程的脊柱(exception-safe programming)。
以下是swap在标准程序库中的典型实现:
namespace std{ template<typename T> void swap(T& a,T& b) { T temp(a); a = b; b = temp; } } //只要T类型支持copying(copy构造函数和=赋值符),以上代码即可帮你自动置换。
这种default swap实现很平庸,特别对于某些类型,它的效率会显得较低。
现在我们讨论这种类型,也就是“以指针指向一个对象,内含真正数据”的类型,也就是“pimp”手法(pointer to implementation)。
现在我们试着用pimp来设计一个Widget class:
class WidgetImpl{ //Widget类的数据实现 public: ... private: int a, b, c; std::vector<double> v; //可能会有很多数据,复制时间长 ... };
class Widget{ //使用pimp手法 public: Widget(const Widget& rhs); Widget& operator=(const Widget& rhs) //复制Widget时,令它复制其WidgetImpl对象 { ... *pImpl = *(rhs.pImpl); ... } ... private: WidgetImpl* pImpl;//指向对象内含Widget数据 };
如果我们交换两个Widget时,只希望置换其中的pImpl指针;然而默认的swap算法不知道。在swap的三条置换语句中,不只复制了三个Widget,还复制三个WidgetImpl对象。这很缺乏效率,一点不令人兴奋!
所以我们应告诉swap该怎么做:将 std::swap
针对Widget特化。下面进行基本构思,但是暂时通不过编译:
namespace std{ template<> void swap<Widget>(Widget& a,Widget& b) //"T为Widget"的特化版本 { swap(a.pImpl, b.pImpl); //仅置换Widgets内部指针 } }
First,此函数开头 template<>
表明它是std::swap的一个全特化(total template specialization)版本。 函数名后的 <Widget>
表明这一特化版本系针对”T是Widget”而设计。 所以当你将swap施行于Widget对象身上便会自动调用此版本。
我们通常不被允许改变std空间里的任何东西,但被允许为标准templates(比如此处的swap)制造特化版本。
之前说通不过编译的原因是,pImpl指针是私有的。可以考虑将此特化版本声明为friend;但和以往规矩不同,这次我们在Widget内部声明一个swap的公共函数进行真正的置换工作,再特化 std::swap
,令它调用该member function:
class Widget{ public: ... void swap(Widget& other) { using std::swap; swap(pImpl, other.pImpl); } ... };
namespace std{ template<> void swap<Widget>(Widget& a,Widget& b) { a.swap(b); } }
实际上,这也是类似STL容器的写法,它们都提供public swap成员函数和std::swap特化版本。
另一种情况:假设Widget和WidgetImpl都是class templates而非classes:
template<typename T> class WidgetImpl { ... }; template<typename T> class Widget { ... };
在Widget内写一个swap函数依旧简单,但是特化 std::swap
时会遇到麻烦:
namespace std{ template<typename T> void swap<Widget<T>>(Widget<T>& a, Widget<T>& b) //invalid! { a.swap(b); } }
这么写不合法的原因是,我们正企图偏特化(partially specialize)一个function template(std::swap)。然而C++仅允许对class templates偏特化。
所以惯常做法是手动添加一个重载的版本:
namespace std{ template<typename T> void swap(Widget<T>& a, Widget<T>& b)//注意"swap"后没有"<...>" { a.swap(b); } //其实这也不合法,稍后提出 }
在C++中,重载function templates没问题。然而std是特殊的命名空间,C++标准委员会禁止膨胀已经写好的东西,因为可能会发生不明行为。所以问题出在我们的重载版本正在做这样的事。
绕了一大圈,我们没有前功尽弃。要提供一个高效的template swap特定版本,可以声明一个non-member swap让它调用member swap,而不再特化 std::swap
或在std里重载它。
为了简化,将Widget相关机能一并置入命名空间WidgetStuff内:
namespace WidgetStuff{ ... //模版化的WidgetImpl等等 template<typename T> class Widget { ... }; //内含swap成员函数 ... template<typename T> void swap(Widget<T>& a, Widget<T>& b) { a.swap(b); } }
从现在开始,任何地点的代码若打算置换俩Widget对象而调用swap。C++的名称查找法则(name lookup rules)会找到WidgetStuff空间内的Widget专属豪华版本。
以上做法适用于classes和class templates。不幸的是,有一种情况使我们不得不为classes特化 std::swap
———只要你想让你的专属swap能在尽可能多语境被调用,你需要写一个该class命名空间内的non-member版本和一个 std::swap
特化版本。(稍后解释)
< 事实上你可以不采用namespace的方式,但global空间里漫天飞的东西真的好看吗?>
现在开始解释: 假设你在写一个function template,需置换两个对象值:
template<typename T> void doSomething(T& obj1, T& obj2) { ... swap(obj1,obj2); ... }
该调用哪种swap呢,也许有一种可能存在的T专属版本此时栖身于某namespace中?(当然不可以在std内) 所以你希望如果存在专属版本就调用它;不存在就用默认的 std::swap
吧:
template<typename T> void doSomething(T& obj1, T& obj2) { using std::swap; //令std::swap在此函数内可用 ... swap(obj1,obj2); //为T型调用最佳swap版本 ... }
之后C++会在global域和T所在namespace里搜索可能存在的T专属版swap,若没有则调用默认 std::swap
。
这里有一个小trick,如果你这么写: std::swap(obj1, obj2);
,语意会截然不同,这相当于强迫使用std内的swap ————— 你get到了吗,这就是我们要写特化std::swap
的动机!这使得类型专属的swap实现也能被这些蠢代码所用。
OVER
下一篇: web前端学习笔记