第四章、设计与声明

条款18、让接口容易被正确使用,不易被误用

假设设计一个用来表现日期的class设计构造函数,需要设计成以下:

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构造函数中使用这些类型。
令Day、Month和Year成为成熟且经充分锻炼的classes并封装其内数据,比简单使用上述struct好。
利用enum表现月份,但enum不具备我们希望拥有的类型安全性,例如enum可以被拿来当一个int使用,比较安全的解法是预先定义所有有效的Month:

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));

预防客户错误的另外一个办法是,限制类型内什么事情可以做,什么事情不能做。

任何接口如果要求客户必须记得做某件事情,就是有着“不正确使用”的倾向,因为客户可能会忘记做那件事。例如在heap申请一个对象指针,客户有可能忘记delete对象,释放其内存。

shared_ptr允许当智能指针被建立起来时指定一个资源释放函数(所谓删除器,“deleter”)绑定于智能指针身上(auto_ptr没有这种性能)。

shared_ptr提供的某个构造函数接受两个实参:一个是被管理的指针,另一个是引用次数变为0时将被调用的“删除器”。
因此,我们创建一个null shared_ptr并以getRidOfInvestment作为删除器,如下。

shared_ptr<Investment> pInv(0,getRidOfInvestment);
//企图创建一个null shared_ptr
//并携带一个自定的删除器。此式无法通过编译。

但是0是int类型,不是指针,可以被转换为指针。因此,转型可以解决这个问题:

    shared_ptr<Investment> pInv(static_cast<Investment*>(0),getRidOfInvestment);
  
   // 创建一个null shared_ptr 并以  getRidOfInvestment为删除器

因此,如果我们要实现createInvestment使它返回一个shared_ptr并夹带getRidOfInvestment函数作为删除器,因此代码如下:

   shared_ptr<Investment> createInvestment();
   {
       shared_ptr<Investment> retVal(static_cast<Investment*>(0),getRidOfInvestment);
       retVal = ...;//令retVal指向正确对象
       return retVal;
   }

shared_ptr有个性质:它会自动使用它的“每个指针专属的删除器”,因而消除另一个潜在的客户错误:所谓的“cross-DLL problem”。这个问题发生于“对象在动态链接库(DLL)中被new创建,却在另一个DLL内被delete销毁”。许多平台上,这一类“跨DLL之new/delete成对运用”会导致运行期错误。shared_ptr没有这个问题,因为它缺省的删除器是来自“shared_ptr诞生所在的那个DLL”的delete。例如下面的例子:
Stock派生自Investment,

   shared_ptr<Investment> createInvestment();
   {
       return shared_ptr<Investment>(new Stock);

   }

返回的那个shared_ptr可以被传递到任何其他DLL,无需在意“cross-DLL problem”。这个指向Stock的shared_ptr会追踪记录“当Stock的引用次数变成0时该调用的那个DLL的delete”。

请记住:
1、好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
2、”促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
3、“阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
4、shared_ptr支持定制型删除器。这可防范DLL问题,可被用来自动解除互斥锁等等。

条款19:设计class犹如设计type

当你定义一个新的class,也就定义了一个新的type。
每一个class的设计,你都要面对以下问题:
1、新type的对象应该如何被创建和销毁?
2、对象的初始化和对象的赋值该有什么样的差别?
3、新type的对象如果被passed by value(以值传递),意味着什么?
4、什么是新type的“合法值”?
5、新的type需要配合某个继承图系吗?
6、新的type需要什么样的转换?
7、什么样的操作符和函数对此新type而言是合理的?
8、什么样的标准函数应该驳回?
9、谁该取用新type的成员?
10、什么是新type的“未声明接口”?
11、新type有多么一般化?
12、是否有必要设计一个新type?

请记住:
class的设计就是type的设计。在定义一个新的type之前,请确定你已经考虑过本条款覆盖的所有讨论主题。

条款20:宁以pass-by-reference-to-const替换pass-by-value

假设你有by value传递参数的validateStudent函数调用如下:

bool validateStudent(Student s);//函数以by value方式接受学生
Student plato;                  //柏拉图,苏格拉底的学生
bool platoIsOK = validateStudent(plato);//调用函数

对此函数而言,参数的传递成本是“一次Student copy构造函数调用,加上一次Student析构函数调用”。

因此修改成如下方式,pass by reference-to-const的传递参数方式:

bool validateStudent(const Student& s);

这种传递方式效率高很多:没有任何构造函数或析构函数被调用,因为没有任何新的对象被创建。

假设你在一组classes上工作,用来实现一个图形窗口系统:

#include <iostream>
#include<string>
using namespace std;

class Window{
public:
	...
    string name() const;//返回窗口名称
    virtual void display() const;//显示窗口和其内容
};

class WindowWithScrollBars:public Window{
public:
    virtual void display() const;
    ...
};


void printNameAndDisplay(Window w){
   cout<<w.name();
   w.display();
}

int main()
{
    WindowWithScrollBars wwsb;
    printNameAndDisplay(wwsb);
    
   
    return 0;
}

display是个virtual函数,这意味着简易朴素的base class Window对象的显示方式和华丽高贵的的WindowWithScrollBars 对象的显示方式不同。
当以下两行代码被执行之后,发生什么事情呢?

WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);

参数w会被构造成为一个Window对象;它是passed by value。而造成wwsb“之所以是个WindowWithScrollBars对象”的所有特化信息都会被切除。在printNameAndDisplay函数内无论传递过来的对象原本是什么类型,参数w就像一个Window对象(因为其类型是Window)。因此在printNameAndDisplay内调用display的调用的总是Window::display,绝不会WindowWithScrollBars::display。

解决切割问题的方法,就是by reference-to-const 的方式传递w;

void printNameAndDisplay(const Window &w){//很好,参数不会被切割
   cout<<w.name();
   w.display();
}

现在,传进来的窗口是什么类型,w就表现出那种类型。
如果了解C++编译器的底层,reference往往以指针实现出来,因此,pass by reference通常意味真正传递的是指针。因此如果数据类型属于内置类型(例如int),pass by value往往比pass by reference的效率高些。用户自定义类型往往选择by reference方式,减少copy 构造函数的执行,提高效率。一般而言,"pass-by-value"主要使用在内置类型和STL的迭代器和函数对象。至于其他类型,尽量选择pass-by-reference-to-const替换pass-by-value。

请记住:
1、尽量以pass-by-reference-to-const替换pass-by-value。前者通常比较高效,并可避免切割问题。
2、以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对它们而言,pass-by-value往往比较适合。

条款21:必须返回对象时,别妄想返回其reference

在pass-by-reference的纯度中,程序员往往犯下一个错误:开始传递一些reference指向其实并不存在的对象。

下面是一个用以表现有理数的class,内含一个函数用来计算两个有理数的乘积:

class Rational{
public:
    Rational(int numerator=0,int denominator=1);//条款24说明为什么这个构造函数不声明为explicit
    
private:
    int n,d;//  numerator 分子; denominator:分母
    
    //条款3说明为什么返回类型是const
    friend const Rational operator*(const Rational& lhs,const Rational& rhs);    
};

这个版本的operator*以by value方式返回其计算结果(一个对象),该对象的构造函数和析构函数会消耗一定的内存资源。因此,可以考虑operator *返回一个reference,后者一定指向某个既有的Rational对象,内含两个Rational对象的乘积。

在stack空间或heap空间创建之。如果定义一个local变量,就是在stack空间创建对象。根据这个策略写下operator*如下:

const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
     Rational result(lhs.n*rhs.n,lhs.d*lhs.d);//警告,糟糕的代码
     return result;
}

这个函数有个问题:函数返回一个reference指向result,但result是个local对象,而local对象在函数退出前被销毁了。如果对这个函数的返回值进行运用,会出现“无定义行为”的报错。任何函数如果返回一个reference指向某个local对象,或者函数返回指针指向一个local对象,都是出现这种“无定义行为”。

如果在heap内构造对象,并返回reference指向它。如下:

const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
     Rational * result=new Rational(lhs.n*rhs.n,lhs.d*lhs.d);//警告,糟糕的代码
     return *result;
}

这里“构造函数调用”的消耗,还有一个问题:谁负责把new出来的对象实施delete?因此存在内存泄漏的风险。

因此在heap和stack空间创建对象的方法都不可以通过。因此,让operator* 返回的reference指向一个被定义于函数内部的static Rational对象。代码如下

const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
    static Rational result;//警告,又是一堆烂代码。static对象,此函数将返回其reference。
    
     result=...;//将lhs乘以rhs,并将结果置于result内。
     return result;
}

bool operator==(const Rational& lhs,const Rational& rhs);//一个针对Rational而写的operator==
Rational a,b,c,d;
...
if((a*b)==(c*d)){
    //乘积相等,执行相应动作
}else {
    //乘积不等,执行相应动作
}

表达式(ab)==(cd)总是被核算为true,这是为什么呢?
一旦将代码重新写为等价的函数形式,很容易就可以了解出了什么意外:

if(operator==(operator*(a,b),operator*(c,d)))

注意,在operator == 被调用前,已经有两个operator* 调用式起作用,每一个都返回reference指向operator* 内部定义的static Rational对象。因此operator == 被要求将“operator* 内的static Rational对象值”拿来和“operator* 内的static Rational对象值”比较,如果比较结果不相等,这才奇怪。(两次operator* 调用的确各自改变了static Rational对象值,但由于它们返回的都是reference,因此调用端看到的永远是static Rational对象的“现值”。)

一个“必须返回新对象”的函数的正确写法是:就让那个函数返回一个新对象,对Rational的operator*而言意味着以下写法(或其他本质上等价的代码):

inline const Rational operator*(const Rational& lhs,const Rational& rhs)
{
    return Rational(lhs.n*rhs.n,lhs.d*lhs.d);
}

当然,这种情况要承受operator* 返回值的构造成本和析构成本,然而长远来看那只是为了获得正确行为而付出的一个小小的代价。

当你必须在“返回一个reference和返回一个object”之间抉择时,你的工作就是挑出行为正确的那个。

请记住:
1、绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。条款4已经为“在单线程环境中合理返回reference指向一个local static对象”提供了一份设计实例。

条款22:将成员变量声明为private

如果成员变量表示public,客户唯一能够访问对象的办法就是通过成员函数。如果public接口内的每样东西都是函数,客户就不需要在打算访问class成员时迷惑地试着记住是否该使用小括号。

假设你正在写一个自动测速程序,当汽车通过,其速度便被计算并填入一个速度收集器内:

class SpeedDataCollection{
    ...
public:
    void addValue(int speed);//添加一笔新数据
    double averageSoFar() const;//返回平均速度
    ...
};

成员函数averageSoFar有两种做法:1、记录至今以来所有速度的平均值,当averageSoFar被调用,只需返回那个成员变量就好;2、令
averageSoFar每次被调用时重新计算平均值,此函数有权力调取收集器内的每一笔速度值。

相对而言,方法1会使每一个SpeedDataCollection对象变大,但是averageSoFar函数比较高效;方法2的averageSoFar执行比较慢,但每一个SpeedDataCollection对象比较小。

到底哪一种比较好?在一部内存吃紧的机器上,或是在一个并不常常需要平均值的应用程序中,方法2或许比较好。但在一个频繁需要平均值的应用程序中,如果反应速度非常重要,内存不是重点,这时候方法1或许比较好。

“成员变量的封装性与“成员变量的内容改变时所破坏的代码数量”成反比。

protected成员变量就像public成员变量一样缺乏封装性,因为在这两种情况下,如果成员变量被改变,都会有不可预知的大量代码受到破坏。一旦你将一个成员变量声明为public或protected而客户开始使用它,就很难改变那个成员变量所涉及的一切。太多代码需要重写、重新测试、重新编写文档、重新编译。从封装的角度观之,其实只有两种访问权限:private(提供封装)和其他(不提供封装)。

请记住:
1、切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。

2、protected并不比public更具封装性。

条款23:宁以non-member、non-friend替换member函数

假设有个class用来表示网页浏览器。这样的class可能提供的众多函数中,有一些用来清除下载元素高速缓存区、清除访问过的URLs的历史记录、以及移除系统中的所有cookies:

class WebBrowser{
public:
    //...
    void clearCache();
    void clearHistory();
    void removeCookies();
    //...
    void clearEverything();//调用 clearCache ,clearHistory和removeCookies
};

当然,clearEverything函数的功能也可以由一个non-member函数调用适当的member函数而提供出来:

void clearBrowser(WebBrowser& wb){
    wb.clearCache();
    wb.clearHistory();
    wb.removeCookies();
}

那么,哪一个比较好呢?是member函数clearEverything还是non-member函数的clearBrowser呢?

member函数clearEverything带来的封装性比non-member函数的clearBrowser低。此外,提供non-member函数可允许对WebBrowser相关性能有较大的包裹弹性,而那最终导致较低的编译相依度,增加WebBrowser的可延伸性。

愈多东西被封装,我们改变那些东西的能力也就愈大。这就是我们首先推崇封装的原因:它使我们能够改变事物而只影响有限客户。

愈多函数可访问它,数据的封装性就愈低。

导致较大封装性的是non-member non-friend函数,因为它并不增加“能够访问class内之private成分”的函数数量。这就解释了为什么clearBrowser(一个non-member non-friend函数)比clearEverything(一个member函数)更受欢迎的原因:它导致WebBrowser class有较大的封装性。

在C++,比较自然的做法是让clearBrowser成为一个non-member函数并且位于WebBrowser所在的同一个namespace(命名空间)内:

namespace WebBrowserStuff {
    class WebBrowser{//...};
    void  clearBrowser(WebBrowser &wb);
    //...
}

要知道,namespace和class不同,前者可以跨越多个源码文件而后者不能。

一个像WebBrowser这样的class可能拥有大量便利函数,某些与书签有关,某些与打印有关,某些与cookies管理有关…通常大多数客户只对其中某些感兴趣。没道理一个只对书签相关便利函数感兴趣的客户却与…呃…例如一个cookies相关便利函数发生编译相依关系。分离它们的最直接做法就是将书签相关便利函数声明于一个头文件,将cookies相关便利函数声明于另一个头文件,再将打印相关便利函数声明于第三个头文件,依次类推:

//头文件"webbrowser.h"——这个头文件针对class WebBrowser自身及WebBrowser核心机能
namespace WebBrowserStuff {
 class WebBrowser{...};
 ...//核心机能,例如几乎所有客户都需要的non-member函数。
} 

//头文件"webbrowserbookmarks.h"
namespace WebBrowserStuff {
  
 ...//与书签相关的便利函数
}  

//头文件"webbrowsercookies.h"
namespace WebBrowserStuff {
  
 ...//与cookie相关的便利函数
}   
...

将所有便利函数放在多个头文件内但隶属同一个命名空间,意味着客户可以轻松扩展这一组便利函数。他们需要做的就是添加更多non-member non-friend函数到此命名空间内。

请记住:
1、宁可拿non-member non-friend函数替换member函数。这样做可以增加封装性、包裹弹性和机能扩充性。

条款24:若所有参数皆需类型转换,请为此采用non-member 函数

假设有一个Rational class:

class Rational{
public:
        Rational(int numerator=0,int denominator=1);//构造函数刻意不为explicit;
        //允许int-to-Rational隐式转换。
        int numerator() const;//分子  (numerator)和 分母(denominator)
        int denominator() const;//的访问函数
        
        const Rational operator* (const Rational& rhs) const;
private:
     ...   
};

这个设计是你能够将两个有理数以最轻松自在的方式相乘:

    Rational oneEighth(1,8);
    Rational oneHalf(1,2);
    Rational result = oneHalf* oneEighth;//很好
    result= result*oneEighth;//很好

当你尝试混合式计算,你会发现只有一半行得通:

    result = oneHalf*2;//如果是explict构造函数,无法将2转换为一个Rational;很好 允许int-to-Rational隐式转换
    result =2*oneHalf;//错误

下面是以函数形式重写上面两个式子,因此问题就一目了然:

    result = oneHalf.operator*(2);//很好
    result =2.operator*(oneHalf);//错误!

result = oneHalf*2;其原理等同于下面

    const Rational temp(2);
    result = oneHalf*temp;//等同于 oneHalf.operator*(temp);

是的,oneHalf是一个内含operator * 函数的class 的对象,所以编译器调用该函数。然而整数2并没有相应的class ,也就没有operator *成员函数。编译器也会尝试寻找可被以下这般调用的non-member operator * (也就是在命名空间内或在global作用域内):

result =operator*(2,oneHalf);//错误

但是本例不存在这样一个接受int 和Rational作为参数的non-member operator * ,因此查找失败。

当参数被列于参数列表内,这个参数才是隐式类型转换的合格参与者。

如果一定要支持混合式算术运算,让operator* 成为一个non-member 函数,俾允许编译器在每一个实参身上执行隐式类型转换:

class Rational{
public:
        Rational(int numerator=0,int denominator=1);//构造函数刻意不为explicit;
        //允许int-to-Rational隐式转换。
        int numerator() const;//分子  (numerator)和 分母(denominator)
        int denominator() const;//的访问函数
        
       //不包括operator *
private:
     ...   
};

 const Rational operator* (const Rational& lhs,const Rational& rhs) //现在成为一个non-member函数
 {
     return Rational(lhs.numerator()*rhs.numerator(),
                     lhs.denominator()*rhs.denominator());
 }
 
    Rational oneFourth(1,4);
    Rational result;
    result= oneFourth*2;//没问题
    result =2*oneFourth;//通过编译

operator*是否应该是Rational class 的一个friend 函数呢?
这是否定的,member函数的反面是non-member函数,不是friend函数。无论何时如果你可以避免friend函数就该避免,因为就像真实的世界一样,朋友带来的麻烦往往多过其价值。

请记住:
1、如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member。

条款25:考虑写出一个不抛异常的swap函数

典型的swap函数如下:

namespace {
    template<typename T>  //std::swap的典型实现
    void swap(T &a,T &b){//置换a和b的值
        T temp(a);
        a=b;
        b=temp;
    }
}

只要类型T支持copying,缺省的swap实现代码就会帮你置换类型为T的对象,你不需要为此另外在做任何工作。

“以指针指向一个对象,内含真正数据”的类型。这种设计的常见表现形式就是所谓的“pimpl(pointer to implementation (指向实现的指针)的缩写)手法”。如果以这种方式设计Widget class ,代码如下:

class WidgetImpl{  //针对Widget数据而设计的class
public:     //细节不重要
    ...
private:
    int a,b,c;//可能有许多数据
    std::vector<double> v;//意味复制时间很长。
    ...
};

class Widget{//这个class 使用pimpl手法
public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs)//复制Widget时,令它复制其WidgetImpl对象。
    {
        //...           //关于operator=的一般性实现细节,见条款10,11和12
        *pImpl=*(rhs.pImpl);
    }
private:
    WidgetImpl *pImpl;//指针,所指对象内含Widget数据。
    
};

一旦要置换两个Widget对象值,我们唯一需要做的就是置换其pImpl指针,但缺省的swap算法不知道这一点。它不只复制三个Widgets,还复制了三个WidgetImpl对象,非常缺乏效率。

我们的思路是,当Widget被置换时真正该做的是置换其内部的pImpl指针。实践方法是:将std::swap针对Widget特化。具体代码如下:

class Widget{//这个class 使用pimpl手法
public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs)//复制Widget时,令它复制其WidgetImpl对象。
    {
        //...           //关于operator=的一般性实现细节,见条款10,11和12
        *pImpl=*(rhs.pImpl);
    }
    
    void swap(Widget& other)
    {
        using std::swap;//这个声明之所以必要,稍后解释
        swap(pImpl,other.pImpl);//若要置换Widget 就置换其pImpl指针。
    }
    
private:
    WidgetImpl *pImpl;//指针,所指对象内含Widget数据。
    
};

namespace  std{
template<> void swap<Widget>(Widget &a,Widget &b)
{
    a.swap(b);//若要置换Widget,调用其swap成员函数
}
}

这种做法不仅能够通过编译,还与STL容器有一致性,因为所有STL容器也都提供public swap成员函数和std::swap特化版本(用以调用前者)。

我们声明一个non-member swap让它调用member swap,但不再将那个non-member swap声明为std::swap的特化版本或重载版本。假设Widget的所有相关机能都被置于命名空间WidgetStuff内,整个结果看起来便像这样:

namespace WidgetStuff {
    template<typename T> //模板化的WidgetImpl等等
    class WidgetImpl{...};
    
    template<typename T> //模板化的Widget,内含swap成员函数
    class Widget{...};
    //...

    template<typename T>//non-member swap函数
    void swap(Widget<T>& a,Widget<T>& b)//这里不属于std命名空间
    {
        a.swap(b);
    }
}

现在,任何地点的任何代码如果打算置换两个Widget对象,因而调用swap,C++的名称查找法则会找到WidgetStuff内的Widget专属版本。
如果你想让你的“class 专属版”swap在尽可能多的语境下被调用,你需要同时在该class所在命名空间内写一个non-member版本以及一个std::swap特化版本。

如果swap的缺省实现版的效率不足(那几乎总是意味着你的class或template使用了某种pimpl(指向实现的指针)手法),试着做以下事情:

1、提供一个public swap成员函数,让它高效地置换 你的类型的两个对象值。稍后我将解释,这个函数绝不该抛出异常。
2、在你的class或template所在命名空间内提供一个non-member swap,并令它调用上述swap成员函数。
3、如果你在编写一个class(而非class template),为你的class 特化std::swap。并令它调用你的swap成员函数。

成员函数版swap绝不可抛出异常。

请记住:
1、当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。

2、如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于class(而非template),也请特化std::swap。

3、调用swap时应针对std::swap使用using 声明式,然后调用swap并且不带任何“命名空间资格修饰”。

4、为“用户定义类型”进行std template全特化是好的,但千万不要尝试在std 内加入某些对std 而言全新的东西。

来源:C++ Effective
仅供学习,侵删

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值