欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

EffectiveC++笔记 第4章

程序员文章站 2022-04-14 23:05:14
我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的“可能比较准确”的「翻译」。 Chapter4 设计与声明 Designs and Declarations 条款18: 让接口容易被正确使用,不易被误用 欲开发一个“容易被使用,不容易被误用”的接口,首先必须考虑客户可 ......

我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的“可能比较准确”的「翻译」。

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)函数和操作符、控制内存的分配和归还、定义对象的初始化和终结……都在你手上

考虑以下问题,你的回答往往影响你的设计规范:

  1. 新class,或者说type该如何被创建和销毁?

这将影响你的构造、析构、内存分配与释放函数:
(operator new, operator new[], operator delete, operator delete[])

前提是你打算撰写它们

  1. 对象初始化和赋值该有怎样的差别?

决定了你的构造函数和赋值操作符的行为。别混淆“初始化”和“赋值”,它们对应不同的函数调用(条款4)。

  1. 新type对象若被passed by value(以值传递),意味着什么?

考虑copy构造函数用来定义一个type的pass-by-value该如何实现

  1. 什么是新type的“合法值”?

对于你设计的class成员变量,你必须考虑它们取值的范围以及规范(约束条件),这决定了你的成员函数必须进行的错误检查工作。它也影响函数抛出的异常。

  1. 你的type需要配合某个继承图系吗?

如果你的type继承自现有的classes,就会受到设计约束。特别是受到“它们的函数是virtual或non-virtual”的影响。若你允许其它classes继承你的class,这要考虑你的函数是否为virtual。

  1. 你的type需要什么样的转换?

如果你希望你的type T1能隐式转换为T2,就必须在class T1内写一个类型转换函数( operator T2 )或在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。若你只允许显式转换,就得写出专门负责执行转换的函数。

  1. 什么样的标准函数应驳回? 那些是你应声明为private的成员(条款6)

  2. 谁该取用新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成员

  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