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

在尝到传引用的甜头后,你可能从此一发不可收拾。但是你总有一次会犯下致命错误:开始传递一些references指向实际不存在的对象。

现在假设有一个表达有理数(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成员

  1. 首先,获取私有成员的渠道大部分是函数,所以客户访问成员不需要考虑究竟是否要加小括号,因为全是函数,他们照做就是。

  2. 其次,你可以通过函数精确控制各种访问权限:

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

上一篇:laravel 常用知识总结


下一篇:HTML基础总结