Effective C++笔记④

设计与声明

最重要、适合任何接口设计的一个准则作为开端:“让接口容易被正确使用,不容易被误用”。

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

要开发一个接口,必须考虑可能会产生的错误。假设你为一个用来表现日期的class设计构造函数:

class Date{
public:
    Date(int month, int day, int year);
    ...
};

上述代码很容易犯下至少两个错误:

第一,他们也许会以错误的次序传递参数:

Date d(30, 3, 1995);    //正确应该是“3,30”,而不是“30,3”

第二,他们可能传递一个无效的月份或天数:

Date d(2, 30, 1995x);   //应该是“3,30”,而不是“2,30”

许多客户端错误可以因为导入新类型而获得预防。真的,在防范“不值得拥有的代码”上,类型系统是你的主要同盟国。既然这样,就让我们导入简单的外履类型来区别天数、月份和年份,然后于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);                            //错误,不正确的类型
Date d(Day(30), Month(3), Year(1995));        //错误,不正确的类型
Date d(Month(3),Day(30), Year(1995));         //OK,类型正确

令Day、Month和Year成为成熟且经充分锻炼的classes并封装其内数据,比简单使用上述的struct好(见条款22)。

一旦正确的类型就定位,限制其值有时候是通情达理的。例如一年只有12个有效月份,所以Month应该反映这一事实。办法之一就是利用enum表现月份。但enums不具备我们希望拥有的类型安全性。例如enums可被拿来当一个inits使用(见条款2)。比较安全的解法是预先定义所有有效的Months:

class Month{
public:
    static Month Jan(){ return Month(1); }    //函数,返回有效月份
    static Month Feb(){ return Month(2); }    //这都是函数而非对象
    ...
    static Month Dec(){ return Month(3); }
    ...

private:
    explicit Month(int m);                    //阻止生成新的月份
    ...                                       //这是月份的专属数据
};

Date d(Month::Mar(), Day(30), Year(1995));

预防客户错误的另一个办法是,限制类型内什么事可做,什么事不能做。常见的限制是加上const。例如条款3曾经说明为什么“以const修饰operator*的返回类型”可组织客户因“用户自定义类型”而犯错:

if (a * b = c) ...  //原意其实是要做一次比较操作!

下面是另一个一般性准则“让type容易被正确使用,不容易被误用”的表现形式:"除非有好理由,否则应该尽量令你的types的行为与内置types一致"。

tr1::shared_ptr有一个特别好的性质是:它会自动使用它的"每个指针专属的删除器",因而消除另一个潜在的客户错误;所谓的"cross-DLL problem"。这个问题发生于"对象在动态连接程序库(DLL)中被new创建,却在另一个DLL内被delete销毁"。

例如,如果Stock派生自Investment而createInvestment实现如下:

std::tr1::shared_ptr<Investment> createInvestment()
{
    return std::tr1::shared_ptr<Investment> (new Stock);
}

返回的那个tr1::shared_ptr可被传递给任何其他DLLs,无需在意"cross-DLL problem"。这个指向Stock的tr1::shared_ptr会跟踪记录"当Stock的引用次数变为0时该调用的那个DLL's delete"。

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

C++就像在其他OOP语言一样,当你定义一个新class,也就定义了一个type。重载函数和操作符、控制内存的分配和归还、定义对象的初始化和终结等全都在你手上。因此你应该带着和"语言设计者当初设计语言内置类型时"一样的谨慎来研讨class的设计。

那么,如何设计高效的classes呢?首先你必须了解你面对的问题。几乎每一个class都要面对以下的问题:

  • 新type的对象应该如何被创建和销毁?这会影响到你的class的构造函数和析构函数以及内存分配函数和释放函数额设计。
  • 对象的初始化和对象的赋值该有什么样的差别?这个答案决定你的构造函数和赋值操作符的行为,以及其间的差异。很重要的是别混淆了“初始化”和“赋值”,以为它们对应于不同的函数调用。
  • 新type的对象如果被passed by value(以值传递),意味着什么?记住,copy构造函数用来定义一个type的pass-by-value该如何实现。
  • 什么是新type的“合法值”?对class的成员变量而言,通常只有某些数值集是有效的。那些数值集决定了你的class必须维护的约束条件,也就决定了你的成员函数(特别是构造函数、赋值操作符和所谓的“setter”函数)必须进行的错误检查工作。
  • 你的新type需要配合某个继承图系吗?如果你继承自某些既有的classes,你就受到哪些classes的设计的束缚,特别是受到“它们的函数是virtual或non-virtual的影响(见条款34和条款36)”。
  • 你的新type需要什么样的转换?(见条款15,隐式和显示转换示例)。
  • 什么样的操作符和函数对此新type而言是合理的?(见条款23、25、46)。
  • 什么样的标准函数应该返回?哪些正式你必须声明为private者(见条款6)。
  • 谁该取用新type的成员?

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

缺省情况下C++以by value方式(一个继承自C的方式)传递对象至(或来自)函数。除非另外指定,否则函数参数都是以实际实参的复件(副本)为初值,而调用端所获得的亦是函数返回值的一个复件。这些复件(副本)系由对象的copy构造函数产出,这可能使得pass-by-value称为昂贵的(费时的)操作。考虑以下class继承体系:

class Person{
public:
    Person();                //为求简化,省略参数
    virtual ~Person();       //条款7告诉你为什么是virtual
    ...

private:
    std::string name;
    std::string address;
};

class Student:public Person{
public:
    Student();
    ~Student();
    ...
private:
    std::string schoolName;
    std::string schoolAddress;
};

现在考虑以下代码,其中调用函数validateStudent,后者需要一个Student实参(by value)并返回它是否有效:

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

当上述函数被调用时,发生什么事?

无疑地Student的copy构造函数会被调用,以plato为蓝本将s初始化。同样明显地,当validateStudent返回s会被销毁。因此,对此函数而言,参数的传递成本是“一次Student copy构造函数调用,加上一次Student析构函数调用”。

但仍未结束,因此Student对象内有两个string对象,所以每次构造一个Student对象也就构造了两个string对象。此外,因为其继承自Person对象,所以每次构造Student对象也构造出一个Person对象。一个Person对象里又有两个string对象,因此每一个Person构造又需承担两个string构造动作。最终结果,导致多次的调用构造函数创建对象。

这是正确的并且值得拥有的行为,因为能够正确的调用构造函数创建和调用析构函数。但如果有方法能够回避所有的构造和析构动作就太好了。事实上,是有的,就是pass by reference-to-const:

bool validateStudent(const Student& s);

这种传递方式的效率高得多:没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。修订后的这个参数声明中的const是重要的。原先以by value传递,因此调用者知道他们受到保护,函数内绝不会对传入的Student作任何改变;函数仅能对其复件(副本)做修改。而现在使用by reference传递,将它声明为const是必要的,因为不确定调用者是否会对其进行任何的修改。

以引用方式传递参数可以避免对象切割(slicing)问题。当一个派生类对象以值传递方式传递并被视为一个基类对象,基类的构造函数就会被调用,而“造成此对象的行为像个派生类对象”的那些特化性质全被切割掉了,仅仅剩下一个基类对象。例如你在一组classes上工作,用来实现一个图形窗口系统:

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

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

所有Window对象都带有一个名称,你可以通过name函数得到它。所有窗口都可显示,可以通过display函数完成它。display是个virtual函数,意味着简易朴素的基类Window对象的现实方式和华丽高贵的WindowWithScrollBars对象的显示方式不同(见条款34和条款36)。

现在假设你希望写个函数打印窗口名称,然后显示该窗口。下面是错误示范:

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

当调用上述函数并交给它一个WindowWithScrollBars对象,会发生什么事呢?

WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);

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

解决方法如下:

void printNameAndDisplay(const Window& w)
{
    std::cout << w.name();
    w.display();
}

现在,传进来的串口是什么类型,w就表现出那种类型。

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

一旦程序员领悟了pass-by-value(传值)的效率牵连层面(条款20),往往变成十字军战士,一心一意根除pass-by-value带来的种种邪恶。并且,他们一定会犯下一个致命错误:开始传递一些references指向其实并不存在的对象。

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

class Rational{
public:
    //条款24说明为什么使用这个构造函数,不声明为explicit
    Rational(int numerator = 0, int denominator = 1);
    ...
private:
    int n,d;        //分子(numerator)和分母(denominator)

    //条款3说明为什么返回类型是const
    friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};

这个版本的operator*系以by value方式返回其计算结果(一个对象)。如果你完全不担心该对象的构造和析构成本,你其实是明显逃避了你的专业责任。若非必要,没有人会想要为这样的对象付出太多的代价。

如果可以改而传递reference,就不需付出代价。但是要记住,reference只是个名称,代表某个既有对象。如上述的operator*,如果它返回一个reference,后者一定指向某个既有的Rational对象,内含两个Rational对象的乘积。

我们当然不可能期望这样一个(内含乘积的)Rational对象在调用operator*之前就存在。也就是说,如果你有:

Rational a(1, 2);    //a = 1/2
Rational b(3, 5);    //b = 3/5
Rational c = a * b;  //c应该是3/10

期望“原本就存在一个其值为3/10的Rational对象”并不合理。如果operator*要返回一个reference指向如此数值,它必须自己创建那个Rational对象。

函数创建对象的途径有二:

  • stack空间;
  • heap空间;

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

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

你可以拒绝这样的做法,因为你的目标是要避免调用构造函数,而result必须像任何对象一样地由构造函数构造出来。更严重的是:这个函数返回一个reference指向result,但result是个local对象,而local对象在函数退出前被销毁了。因此,这个版本的operator*并未返回reference指向某个Rational,它返回的reference指向一个“从前的”Rational。

于是,让我们考虑在heap内构造一个对象,并返回reference指向它。Heap-based由new创建,所以你得写一个heap-based operator*,如下:

const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);    //更糟的写法!
    return *result;
}

上述代码,你还是必须付出一个“构造函数调用”代价,因为分配所得的内存将以一个适当的构造函数完成初始化动作。但此外你现在又有了一个新的问题,谁该对着被你new出来的对象实施delete?

以下代码存在内存泄露的风险:

Rational w,x,y,z;
w = x * y * z;        //与operator*(operator*(x, y), z))相同

这里,同一个语句内调用了两次operator*,因而两次使用new,也就需要两次delete。但却没有合理的办法让operator*使用者进行那些delete调用,因为没有合理的办法让它们取得operator*返回的references背后隐藏的那个指针。这绝对导致了内存泄露

或许此时的你心里出现了以下的实现代码,此法奠基于“让operator*返回的reference指向一个被定义于函数内部额static Rational对象”:

const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    static Rational result;    //static对象,此函数将返回其reference
    result = ...;              //将lhs乘以rhs,并将结果置于result内
    return result;
}

就像所有用上static对象的设计一样,这一个也立刻造成我们对多线程安全性的疑虑。不过那还只是它显而易见的弱点。如果想看看更深层的瑕疵,考虑下面这些完全合理的客户代码:

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

以上条件判断语句中的条件恒成立,无论a、b、c、d的数值是什么!

一旦将代码写成等价的函数形式:

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

注意,在operator==调用前,已有两个operator*调用式起作用,每一个都返回reference指向operator*内部定义的static Rational对象。因此,条件判断中即为对象值与对象值之间的比较,如果比较结果不等,那才奇怪!(他们改变了各自的Ratonal对象的值是不错,但由于均返回的是引用,则读取到的永远都是static Rational对象的现值。)

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

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

当然,这需要承担operator*返回值的构造成本和析构成本,然而长远来看那只是为了获得正确行为而付出的一个小小代价。但万一账单很恐怖,承受不起,别忘了还有C++允许编译器实现者实施最优化,用以改善产出码的效率却不改变其可观察行为。

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

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

以下这个事实:使用函数可以让你对成员变量的处理有更精确的控制。如果你令成员变量为public,每个人都可以读写它,但如果你以函数取得或设定其值,你就可以实现出“不准访问”、“只读访问”以及“读写访问”。甚至可以实现“唯写访问”:

class AccessLevels{
public:
    ...
    int getReadOnly() const { return readOnly; }
    void setReadWrite(int value) { readWrite = value; }
    int getReadWrite() const { return readWrite; }
    void setWriteOnly(int value) { writeOnly = value; }

private:
    int noAccess;
    int readOnly;
    int readWrite;
    int writeOnly;
};

如此细微地划分访问控制颇有必要,因为许多成员变量应该被隐藏起来。每个成员变量都需要一个getter函数和setter函数毕竟罕见。同时,我们也可以通过封装,在通过函数访问成员变量时,可以在日后改以某个计算替换这个成员变量,而class客户一点也不会知道class的内部已经起了变化。

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

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

现在让我们考虑成员函数averageSoFar。做法之一就是在class内设计一个成员变量,记录至今以来所有速度的平均值。当averageSoFar,只需返回那个成员变量就好。另一个做法就是令averageSoFar每次被调用时重新计算平均值,此函数有权利调取收集器内的每一笔速度值。

上述第一种做法(随时保持平均值)会使得每一个SpeedDataCollection对象变大,因为必须用来存放目前平均值、累积总量、数据点数的每一个成员变量分配空间。然后averageSoFar却可因此而十分高效:它可以只是一个返回目前平均值的inline函数。相反地,“被询问才计算平均值”会使得averageSoFar执行较慢,因为每个SpeedDataCollection对象比较小。

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

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

class WebBrowser{
public:
    ...
    void clearCache();
    void clearHistory();
    void removeCookies();
    ...
};

许多用户会想一整个执行所有这些动作,因此WebBrowser也提供这样一个函数:

class WebBrowser{
public:
    ...
    void clearEverything();

    ...
};

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

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

那么,哪一个好呢?是调用clearEverything好,还是调用clearBrowser好呢?

面向对象守则要求,数据以及操作数据的那些函数应该被捆绑在一起,这意味着它建议member函数是最好的选择。不幸的是这个建议并不正确。这是基于面向对象真实意义的一个误解。面向对象守则要求尽可能被封装,然而与直观相反地,member函数clearEverything带来的封装性比non-member函数clearBrowser低。

让我们从封装开始讨论,一旦有些东西被封装起来,那么它就不再可见。愈多东西被封装,愈少人可以看到它,因此我们也就有愈大的弹性去变化它。而考虑到对象内的数据,愈少的代码可以看到数据,愈多的数据可以被封装,而我们也就愈加自由地改变对象数据,例如改变成员变量的数量、类型等等。

条款22曾说过,成员变量应该是private,而能够访问private成员变量的函数只有class的member函数加上friend函数而已。

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

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

然而这不只是为了看起来自然而已。要知道,namespace和classes不同,前者可跨越多个源码文件而后者不可以。这很重要,因为像clearBrowser这样的函数是个“提供便利的函数”,如果它既不是members也不是friends,就没有对WebBrowser的特殊访问权力,也就不能提供“WebBrowser客户无法以其他方式取得”的机能。举个例子,如果clearBrowser不存在,客户端就只好自行调用clearChche、clearHistory和removeCookies。

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

//头文件“webBrowser.h”针对WebBrowser本身及WebBrowser核心机能
namespace WebBrowserStuff{
    class WebBrowser { ... };
    ...                           //核心机能,例如几乎所有客户都需要的non-member函数
}

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

//头文件“webBrowsercookies.h”
namespace WebBrowserStuff{
    ...                           //与cookie相关的便利函数
}

注意,这正是C++标准程序库的组织方式。标准程序库并不是拥有单一、整体、庞大的<C++StandardLibrary>头文件并在其中内涵std命名空间内的每一样东西,而是有数十个头文件(<vector>、<algorithm>、<memory>等等),每个头文件声明std的某些机能。

将所有便利函数放在多个头文件内但隶属于同一个命名空间,意味着客户可以轻松扩展这一组便利函数。他们需要做的就是添加更多non-member、non-friend函数到此命名空间内。举个例子,如果某个WebBrowser客户决定写些与影像下载相关的便利函数,他只需要在WebBrowserStuff命名空间内建立一个头文件,内含那些函数的声明即可。新函数就像其他旧有的便利函数那样可用且整合为一体。

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

令classes支持隐式类型转换通常是个糟糕的主意z。当然这条规则有其例外,最常见的例外就是在建立数值类型时。假设你设计一个class用来表现有理数,允许整数“隐式转换”为有理数似乎颇为合理。假设你这样开始你的Rational class:

class Rational{
public:
    //构造函数刻意不为explicit,允许隐式转换
    Rational(int numerator = 0, int denominator = 1); 

    //分子和分母的转换函数   
    int numerator() const;    
    int denominator() const;

private:
    ...
};

你想支持算术运算诸如加法、乘法等等,但你不确定是否该由member函数、non-member函数,或可能的话由non-member friend函数来实现它们。你的直觉告诉你,当你犹豫就该保持面向对象精神。你知道有理数相乘和Rational class有关,因此很自然地似乎该在Rational class内为有理数实现operator*。条款23曾经反直觉地主张,将函数放进相关class内有时会与面向对象守则发生矛盾,但让我们先把那放在一旁,先研究一下将operator*写成Rational成员函数的写法:

class Rational{
public:
    ...
    const Rational operator*(const Rational& rhs) const;
};

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

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

但你仍希望支持混合式运算,也就是拿Rational和ints等相乘,如下:

result = oneHalf * 2;    //很好
result = 2 * oneHalf;    //错误

当重写上面两个式子,也就很好理解了:

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

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

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

再次看看先前成功的那个调用。注意其第二参数是整数2,但Rational::operator*需要的实参却是个Rational对象。这里发生了什么事呢?为什么2在这里能被接受,在另一个却不被接受呢?

因为这里发生了所谓的隐式转换。编译器知道你正在传递一个int,而函数是Rational;但它也知道只要调用Rational构造函数并赋予你所提供的int,就可以变出一个适当的Rational来。于是它就那样做了。换句话说此一调用动作在编译器眼中有点像这样:

const Rational temp(2);        //根据2建立一个临时的Rational对象
result = oneHalf * temp;       //等同于oneHalf.operator*(temp);

当然,只因为涉及non-explicit构造函数,编译器才会这样做。如果Rational构造函数是explicit,以下语句没有一个可通过编译:

result = oneHalf * 2;
result = 2 * oneHalf;

这就很难让Rational class支持混合式算术运算了。

然后你的目标不仅仅在于一致性,还要支持混合式运算,也就是希望有个设计能让以上语句通过编译。可行之道终于拨云见日:让operator*成为一个non-member函数,允许编译器在每一个实参身上执行隐式类型转换:

class Rational{
    ...                        //不包含operator*
};

const Rational operator*(const Rational& lhs, const Ratioanl& rhs)
{
    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函数?

就本例而言,答案是否定的,因为operator*可以完全藉由Rational的public接口完成任务,上面的代码已表明此种做法。这导出一个重要的观察:member函数的反面是non-member函数,不是friend函数。大多数C++程序员假设,如果一个“与某个class相关”的函数不该成为一个member(也许由于其所有实参都需要类型转换,例如先前的Rational的operator*函数),就该是friend。

【注】

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

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

swap原本是STL的一部分,而后成为异常安全编程(见条款29)的脊柱,以及用来处理自我赋值可能性(见条款11)的一个常见机制。由于swap如此有用,适当的实现很重要。

所谓swap(置换)两对象值,意思是将两对象的值彼此赋予对方。缺省情况下swap动作可由标准程序库提供的swap算法完成。其典型实现完全如你所预期:

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

只要类型T支持copying(通过copy构造函数和copy assignment操作符完成),缺省的swap实现代码就会帮你置换类型为T的对象,你需要为此另外再做任何工作。

这些复制动作无一必要:对它们而言swap缺省行为等于是把告诉铁路铺设在慢速小巷弄内。其中最主要的是“以指针指向一个对象,内含真正数据”那种类型。这种设计的常见表现形式是所谓的“pimpl手法”(见条款31)。如果以这种手法设计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对象
    {
        ...
        *pImpl = *(rhs.pImpl);                   //细节,见条款10、11和12
        ...
    }   
    ...
private:
    WidgetImpl* pImpl;                           //指针,所指对象内含Widget数据
}

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

我们希望能够告诉std::swap:当Widgets被置换时真正该做的是置换其内部的pImpl指针。确切时间这个思路的一个做法是:将std::swap针对Widget特化。下面是基本构想,但目前这个形式无法通过编译:

namespace std{
    template<>                                //这是std::swap针对
    void swap<Widget>(Widget& a, Widget& b)   //“T是Widget”的特化版本
    {                                         //目前还不能通过编译
        swap(a.pImpl, b.Impl);                //置换Widgets时只要置换它们的Impl指针就好
    }
}

这个函数一开始的“template<>”表示它是std::swap的一个全特化(total template specialization)版本,函数名称之后的“<Wiget>”表示这一特化版本系针对“T是Widget”而设计。换句话说当一般性的swap template施行于Widgets身上便会启用这个版本。

但是一如稍早所说,这个函数无法通过编译。因为它企图访问a和b内的Impl指针,而那确实private。我们可以将这个特化版本声明为friend,但和以往的规矩不太一样:我们令Widget声明一个名为swap的public成员函数做真正的置换工作,然后将std::swap特化,令它调用该成员函数:

class Widget{                        //与前同,唯一差别是增加swap函数
public:
    ...
    void swap(Widget& other)
    {
        using std::swap;             //这个声明之所以必要,稍后解释
        swap(pImpl, other.pImpl);    //若要置换Widgets就置换其pImpl指针
    }
    ...
};

namespace std{
    template<>                                //修订后的std::swap特化版本
    void swap<Widget>(Widget& a, Widget& b)
    {
        a.swap(b);                            //若要置换Widgets,调用其swap成员函数
    }
}

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

然而假设Widget和WidgetImpl都是class template而非classes,也许我们可以试试将WidgetImpl内的数据类型加以参数化:

template<typename T>
class WidgetImpl { ... };

template<typename T>
class Widget { ... };

在Widget内(以及WidgetImpl内,如果需要的话)放个swap成员函数就像以往一样简单,但我们却在特化std::swap时遇上乱流。我们想写成这样:

namespace std{
    template<typename T>
    void swap< Widget<T> >(Widget<T>& a, Widget<T>& b)    //错误!不合法!
    {
        a.swap(b);
    }
}

看起来合情合理,却不合法。是这样的,我们企图偏特化一个function template(std::swap),但C++只允许对class template偏特化,在function templates身上偏特化是行不通的。这段代码不该通过编译。

当你打算偏特化一个function template时,惯常做法是简单地为它添加一个重载版本,像这样:

namespace std{
    template<typename T>                     //std::swap的一个重载版本
    void swap(Widget<T>& a, Widget<T>& b)    //注意“swap”之后没有“<...>”
    {
        a.swap(b);                           //稍后会说明这也不合法
    }
}

一般而言,重载function templates没有问题,但std是个特殊的命名空间,其管理规则也比较特殊。客户可以去全特化std内的template,但不可以添加新的template(或classes或functions或其他任何东西)到std里头。

那该如何是好?毕竟我们总是需要一个办法让其他人调用swap时能够取得我们提供的较高效的template特定版本。答案很简单:

声明一个non-member swap,让它调用member swap,但不再将那个non-member swap声明为std::swap的特化版本或重载版本。

假设Widget的所有相关机能都被置于命名空间WidgetStuff内,整个结果看起来便像这样:

namespace WidgetStuff{
    ...                                        //模板化的WidgetImpl等等
    template<typename T>                       //同前,内含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专属版本。那正是我们所需要的。

目前为止所写的每一样东西都和swap编写者有关。换位思考,从客户观点看看事情也有必要。假设你正在写一个function template,其内需要置换两个对象值:

template<typename T>
void doSomething(T& obj1, T& obj2)
{
    ...
    swap(obj1,obj2);
    ...
}

应该调用哪个swap?是std既有的那个一般化版本?还是某个可能存在的特化版本?抑或是一个可能存在的T专属版本而且可能栖身于某个明明空间(当然不可以是std)内?你希望的应该是调用T专属版本,并在该版本不存在的情况下调用std内的一般化版本。下面是你希望发生的事:

template<typename T>
void doSomething(T& obj1, T& obj2)
{
    using std::swap;    //令std::swap在此函数内可用
    ...
    swap(obj1, obj2);   //为T型对象调用最佳swap版本
    ...
}
发布了90 篇原创文章 · 获赞 6 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_37160123/article/details/101646822