【Effect C++ 笔记】(四)设计与声明

【四】设计与声明

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

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

Make interfaces easy to use correctly and hard to use incorrectly.

"让接口容易被正确使用,不易被误用",这也是面向对象设计中的重要概念,好的接口在工程实践中尤其重要。 在使用优秀的第三方组件时,常常能够切身感受到好的接口原来可以这么方便,甚至不需记住它的名字和参数就能正确地调用。 反观自己写的API,常常会有人三番五次地问这个参数怎么设置,真是失败。人非圣贤孰能无过,只能在这种痛苦的驱动下努力的重构和学习!

虽然我已经脱离了很久的Windows开发,但想起来.NET API良好的设计,还是会五体投地。

言归正传。

在C++中,可以说到处都是接口,接口定义了客户如何与你的代码进行交互。如果用户误用了你的接口,你至少也要承担一部分的责任。 理想情况的接口是这样的: 如果用户误用了接口,代码不会正常编译;如果代码通过了编译,那么你的接口就要完成客户想要的操作。

正确地构造一个Date

来个通俗的例子:Date对象的构造函数需要传入月、日、年。但客户在调用时常常传错顺序,这时可以将参数封装为对象来提供类型检查:

cpp 复制代码
class Date{
public:
Date(const Month& m, const Day& d, const Year& y);
};

Date d(Day(30), Month(3), Year(1995));    // 编译错:类型不兼容!
Date d(Month(3), Day(30), Year(1995));    // OK

即使这样,用户的Month构造函数仍然会传入一个不合理的参数(例如32),或者搞不清楚下标从0还是1开始。 解决方案是预定义所有可用的Month:

cpp 复制代码
class Month{
public:
static Month Jan(){ return Month(1); }
static Month Feb(){ return Month(2); }
};
Date d(Month::Jan(), Day(30), Year(1995));

从上述Date的例子中可以看到,可以将运行时的数据转换为编译期的名称,可以将错误检查提前到编译期。以此解决参数顺序和范围的误用。

限制类型的操作

另外一个例子来自Item 3:尽量使用常量,乘法运算符返回值设为const,以防止误用赋值:

cpp 复制代码
if(a * b = c) ... // 用户的意图本来是判等

提供一致的接口

  • DLL :

除此之外,提供一致的接口也很重要。例如STL容器封装了互不兼容的基本数据类型,为STL算法提供了非常一致的接口。

比如STL提供了size属性来标识容器的大小,容器可以是数组、链表、字符串、字典、集合。.NET中所有这些大小都叫Count属性。 采用哪种命名并不重要,重要的是提供一致的接口。不仅便于应用中使用,也便于库的扩展。

好的接口不会要求用户去记住某些事情。比如Investment* createInvestment()要求客户记住及时去销毁, 那么客户很可能忘记了去deletedelete了多次。解决方案便是返回一个智能指针而不是原始资源,参见:Item 13:使用对象来管理资源 尤其是当销毁操作不是简单的delete时,客户还需要记住如何去销毁它。而我们返回智能指针时就能指定deleter来自定义销毁动作:

cpp 复制代码
shared_ptr<Investment> createInvestment(){
    // 销毁一项投资时,需要做一些取消投资的业务,而不是简单地`delete`
    return std::tr1::shared_ptr<Investment>(new Stock, getRidOfInvestment);    // tr1::shared_ptr  可以cross-DLL-problem
}

shared_ptr带来的好处还不仅仅是移除了客户的责任,同时还解决了跨DLL动态内存管理的问题。 在DLLnew的对象,如果在另一个DLLdelete往往会发生运行时错误,但shared_ptr进行资源销毁时, 总会调用创建智能指针的那个DLL中的delete,这意味着shared_ptr可以随意地在DLL间传递而不需担心跨DLL的问题。

  • 好的接口容易被正确使用,不易被误用。
  • 促进正确使用的方法包括接口的一致性,以及与内置类型的行为兼容。
  • 可以为内置类型提供一致的接口来方便正确的使用。
  • 识别误用的手段包括: 创建新的类型、限制类型的操作、限制对象的值、移除客户的资源管理责任。
  • shared_ptr 支持定制型删除器, 这可以预防DLL问题, 课内用来自动解除互斥锁 。

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

treat class design as type design

在面向对象语言中,开发者的大部分时间都用在了增强你的类型系统。这意味着你不仅是类的设计者,更是类型设计者。 重载函数和运算符、控制内存分配和释放、定义初始化和销毁操作......良好的类型有着自然的语法、直观的语义,以及高效的实现。 你在定义类时需要像一个语言设计者一样地小心才行!

类的设计就是类型设计,当你定义一个类之前,需要面对这些问题:

  • 这个新的类型如何创建和销毁?new还是new []
  • 初始化和赋值之间又怎样的区别?它们确实是不同的函数调用,参见:Item 4:确保变量的初始化
  • 如果该类型的对象被传值而不是传引用,意味着怎样的语义?记住:传值时调用的是拷贝构造函数!
  • 该类型合理的取值范围是?在你的成员函数、赋值和构造函数中需要做相应的范围检查!
  • 你的新类型能融合到继承图中吗?如果你继承自已有的类,你的类将被它们限制(尤其是虚函数限定);如果你希望其他类来继承该类型,那么你的方法是否需要声明为virtual?尤其是析构函数。
  • 你的新类型允许怎样的类型转换?你可能需要将构造函数声明为explicit来避免隐式类型转换。参见:Item 15:资源管理类需要提供对原始资源的访问
  • 哪些运算符对你的新类型是有意义的?
  • 那些编译器生成的默认方法需要被禁止?参见:Item 6:禁用那些不需要的缺省方法
  • 谁可以访问你的成员方法?私有、保护、共有成员限定符;友元类、友元函数。
  • 你想提供哪些潜在的接口?它们往往关乎异常安全、效率、资源使用等,这些潜在的接口将会影响你的实现。
  • 你的类型有多么通用?如果它是非常通用的类型,你可以考虑通过模板把它定义成一系列的类。
  • 你真的需要这个新的类型吗?如果你为了扩展一个类而继承了它,那么定义一个非成员函数或者模板能否更好地解决问题?

条款20 : 传递常量引用比传值更好

Item 20: Prefer pass-by-reference-to-const to pass-by-value

C++函数的参数和返回值默认采用传值的方式,这一特性是继承自 C 语言的。如果不特殊指定, 函数参数将会初始化为实参的拷贝,调用者得到的也是返回值的一个副本。 这些拷贝是通过调用对象的拷贝构造函数完成的,正是这一方法的调用使得拷贝的代价可能会很高。

通常来讲,传递常量引用比传值更好,同时避免了截断问题。但是内置类型和 STL 迭代器,还是传值更加合适。

来个例子

一个典型的类的层级可能是这样的:

cpp 复制代码
class Person {
string name, address;
};
class Student: public Person {
string schoolName, schoolAddress;
};

假如有这样一处函数调用:

cpp 复制代码
bool validateStudent(Student s);           // function taking a Student by value  传值调用 

Student plato;                             // Plato studied under Socrates
bool platoIsOK = validateStudent(plato);   // call the function

在调用 validateStudent() 时进行了6 个函数调用(Student*1 、 Person * 1 string * 4):

  1. Person 的拷贝构造函数,为什么 Student 的拷贝构造一定要调用 Person 的拷贝构造请参见:Item 12:完整地拷贝对象
  2. Student 的拷贝构造函数
  3. name, address, schoolName, schoolAddress 的拷贝构造函数

解决办法便是传递常量引用:

cpp 复制代码
bool validateStudent(const Student& s);

首先以引用的方式传递,不会构造新的对象,避免了上述例子中 6 个构造函数的调用。 同时 const 也是必须的:传值的方式保证了该函数调用不会改变原来的 Student, 而传递引用后为了达到同样的效果,需要使用 const 声明来声明这一点,让编译器去进行检查!

截断问题

将传值改为传引用还可以有效地避免 截断问题:由于类型限制,子类对象被传递时只有父类部分被传入函数。

比如一个 Window 父类派生了子类 WindowWithScrollBars

cpp 复制代码
class Window {
public:
...
std::string name() const;           // return name of window
virtual void display() const;       // draw window and contents
};

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

有一个访问 Window 接口的函数,通过传值的方式来获取 Window 的实例:

cpp 复制代码
// incorrect! parameter may be sliced!
void printNameAndDisplay(Window w){     
    std::cout << w.name();
    w.display();
}

WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);

当调用 printNameAndDisplay 时参数类型从 WindowWithScrollBars 被隐式转换为 Window。 该转换过程通过调用 Window 的拷贝构造函数来进行。 导致的结果便是函数中的 w 事实上是一个 Window 对象, 并不会调用多态子类 WindowWithScrollBarsdisplay()

cpp 复制代码
// fine, parameter won't be sliced
void printNameAndDisplay(const Window& w){ 
    std::cout << w.name();
    w.display();
}

这就很好嘛,如果你曾深入过编译器你会发现引用是通过指针来实现的。

特殊情况

  • "小型的用户自定义类型也没必要用pass-by-value"

一般情况下相比于传递值,传递常量引用是更好的选择。但也有例外情况,比如 内置类型STL 迭代器和函数对象。

内置类型传值更好是因为它们小,而一个引用通常需要 32 位或者 64 位的空间。可能你会认为小的对象也应当首选传值, 但 对象小并不意味着拷贝构造的代价不高!比如 STL 容器通常很小,只包含一些动态内存的指针。然而它的拷贝构造函数中, 必然会分配并拷贝那些动态内存的部分。

即使拷贝构造函数代价很小,传值的方式仍然有性能问题。有些编译器会区别对待内置类型和用户定义类型, 即使它们有相同的底层表示。比如有些编译器虽然会把 double 放入寄存器,但是拒绝将只含一个 double 的对象放入寄存器。

一个只含 double 的对象大小为 8,它和一个 double 具有相同的大小和底层表示。关于对象大小的计算,请参考:Item 7:将多态基类的析构函数声明为虚函数

从面向对象设计方面来讲,即使对象现在很小,但它作为用户定义类型是有可能变大的(如果你更改了内部实现)。 从长远来讲的性能考虑,也应当采取传引用的方式来设计使用它的函数。

STL 迭代器和函数对象也应当被传值,这是因为它们在 STL 中确实是被这样设计的,同时它们的拷贝构造函数代价并不高。

条款21 : 需要返回对象时,不要返回引用

Item 21: Don't try to return a reference when you must return an object

Item 20 中提到,多数情况下传引用比传值更好。追求这一点是好的,但千万别返回空的引用或指针。 一个典型的场景如下:

cpp 复制代码
class Rational{
    int n, d; // n : 分子   d : 分母 
public:
    Raitonal(int numerator=0, int denominator=1);
};

// 返回值为什么是const请参考Item 3
friend const Rational operator*(const Rational& lhs, const Rational& rhs);

Rational a, b;
Rational a(1, 2); // 1/ 2
Rational b(3, 5);  // 3/ 5
Rational c = a*b;   //理论来说是 3 / 10 

C11 move 语义为这种情况提供了更好的支持 .

注意operator*返回的是Rational实例,a*b时便会调用operator*( ), 返回值被拷贝后用来初始化c。这个过程涉及到多个构造和析构过程:

  1. 函数调用结束前,返回值被拷贝,调用拷贝构造函数
  2. 函数调用结束后,返回值被析构
  3. c 被初始化,调用拷贝构造函数
  4. c 被初始化后,返回值的副本被析构

我们能否通过传递引用的方式来避免这些函数调用?这要求在函数中创建那个要被返回给调用者的对象,而函数只有两种办法来创建对象:在栈空间中创建、或者在堆中创建。在栈空间中创建显然是错误的:

cpp 复制代码
const Rational& operator*(const Rational& lhs, const Rational& rhs){
    Rational result(lhs.n*rhs.n, lhs.d*rhs.d); // 糟糕的写法 : on-the-stack
    return result;
}

客户得到的result永远是空。因为引用只是一个名称,当函数调用结束后
result即被销毁。 它返回的是一个ex-result的引用。那么在堆中创建会是怎样的结果?

cpp 复制代码
const Rational& operator*(const Rational& lhs, const Rational& rhs){
    Rational *result  = new Rational(lhs.n*rhs.n, lhs.d*rhs.d);  //更糟糕的写法 : on-the=heap
    return *result;
}

问题又来了,既然是new的对象,那么谁来delete呢?比如下面的客户代码:

cpp 复制代码
Rational w, x, y, z;
w = x*y*z;

上面这样合理的代码都会导致内存泄露,那么operator*的实现显然不够合理。此时你可能想到用静态变量来存储返回值,也可以避免返回值被再次构造。但静态变量首先便面临着线程安全问题,除此之外当客户需要不止一个的返回值同时存在时也会产生问题:

cpp 复制代码
const Rational& operator*(const Rational& lhs, const Rational& rhs){
    static Rational result ;
    result = lhs * rhs;  // 普通人写法    但是这样会调用构造和析构 
    return *result;
}

if((a*b) == (c*d)){
    // ...  调用3次析构 + 构造   (传递的都是 "现值")
}

如果operator*的返回值是静态变量,那么上述条件判断恒成立,因为等号两边是同一个对象嘛。如果你又想到用一个静态变量的数组来存储返回值,那么我便无力吐槽了。。。

挣扎了这许多,我们还是返回一个对象吧:

cpp 复制代码
inline const Rational operator*(const Rational& lhs, const Rational& rhs){
    return Rational(lhs.n*rhs.n, lhs.d*rhs.d);
}

事实上拷贝构造返回值带来的代价没那么高,C++标准允许编译器做出一些客户不可察觉(without changing observable behavior)的优化。在很多情况下,返回值并未被析构和拷贝构造。

永远不要返回局部对象的引用或指针或堆空间的指针,如果客户需要多个返回对象时也不能是局部静态对象的指针或引用。 Item 4:确保变量的初始化 指出,对于单例模式,返回局部静态对象的引用也是合理的。

总结: 根据工作需要,挑选返回 reference or object , 目的就是让编译器厂商为尽可能降低成本 !!

条款22 : 数据成员应声明为私有

Item 22: Declare data members private

数据成员声明为私有可以提供一致的接口语法,提供细粒度的访问控制,易于维护类的不变式,同时可以让作者的实现更加灵活。而且我们会看到,protected并不比public更加利于封装。

语法一致性

你肯定也遇到过这种困惑,是否应该加括号呢?

obj.length  // 还是 obj.length()?
obj.size    // 还是 obj.size()?

总是难以记住如何获取该属性,是调用一个getter?还是直接取值?如果我们把所有数据都声明为私有,那么在调用语法上,统一用括号就好了。

访问控制

为数据成员提供gettersetter可以实现对数据更加精细的访问控制,比如实现一个只读的属性:

cpp 复制代码
class readOnly{
int data;
public:
int get() const { return data; }
}

事实上,在C#中提供了访问器(又称属性)的概念, 每个数据成员都可以定义一套访问器(包括settergetter),使用访问器不需要使用括号:

cpp 复制代码
public class readWrite{
private string _Name;
public string Name{
    set { this._Name = value; }
get { return this._Name; }
}
}
ReadWrite rw;
// 将会调用set方法
rw.Name = "alice";

可维护性

封装所有的数据可以方便以后类的维护,比如你可以随意更改内部实现,而不会影响到既有的客户。例如一个SpeedDataCollection需要给出一个平均值:

cpp 复制代码
class SpeedDataCollection{
public:
    void add(int speed);
    double average() const;
};

average()可以有两种实现方式:①维护一个当前平均值的属性,每当add时调整该属性;②每次调用average()时重新计算。两种实现方式之间的选择事实上是CPU和内存的权衡,如果在一个内存很有限的系统中可能你需要选择后者,但如果你需要频繁地调用average()而且一点内存不是问题,那么就可以选择前者。

你的实现方式的变化不会影响到你的客户。但如果avarage()不是方法而是一个共有数据成员。 那么对于你的每次实现方式变化,客户就必须重新实现、重新编译、重新调试和测试它们的代码了。

来看看 protected

既然共有数据成员会破坏封装,它的改动会影响客户代码。那么protected呢?

面向对象的精髓在于封装,可以粗略地认为一个数据成员的封装性反比于它的改动会破坏的代码数量。比如上述的average如果是一个public成员,它的改动会影响到所有曾使用它的客户代码,它们的数量是大到不可知的(unknowably large amount)。如果是protected成员,客户虽然不能直接使用它,但可以定义大量的类来继承自它,所以它的改动最终影响的代码数量也是 unknowably large
protectedpublic的封装性是一样的!如果你曾写了共有或保护的数据成员,你都不能随意地改动它们而不影响客户代码!

  • 切记将成员变量声明为private, 这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证, 给class实现提供弹性。、
  • protested 并不比 public、 更具有封装性

条款 23 : 非成员非友元函数好于成员函数

Item 23: Prefer non-member non-friend functions to member functions

csharp 复制代码
class WebBrowser{
    public:
    void clearCache();
    void clearCookies();
    void clearHistory();
};

此时你要实现一个clearEverything()有两种方式:

cpp 复制代码
class WebBrowser{
public:
    void clearEverything(){
        clearCache();
        clearCookies();
        clearHistory();
    }
}
// 或者使用非成员函数:
void clearEverything(WebBrowser& wb){
    wb.clearCache();
    wb.clearCookies();
    wb.clearHistory();
}

哪种更好呢?面向对象原则指出,数据和数据上的操作应当绑定在一起,那么前者更好。 这是对面向对象的误解,面向对象设计的精髓在于封装,数据应当被尽可能地封装。 相比于成员函数,非成员函数提供了更好的封装,包的灵活性(更少的编译依赖),以及功能扩展性。

封装性

封装就是对外界隐藏的意思。如果数据被越好地封装,那么越少的东西可以看到它,我们便有更大的灵活性去改变它。这是封装带来的最大的好处:给我们改变一个东西的灵活性,这样的改变只会影响到有限的客户。

作为粗粒度的估计,数据的封装性反比于可访问该数据的函数数量。这些函数包括成员函数、友元函数和友元类中的函数。 因此非成员非友元函数会比成员函数提供更好的封装, 我们应该选择clearEverything()的第二种实现。
Item22提到,如果一个数据成员不是私有的,那么将会有无限数量的函数可访问它。

这里有两点值得注意:

  1. 友元函数成员函数一样的,因为友元函数也可以访问私有数据成员,它和成员函数对封装具有相同的影响。
  2. 非成员函数并不意味着它不可以是其他类的成员函数。尤其是在像Java,C#之类的语言中,函数必须定义在类中。
  3. 静态成员函数也是不错的选择。因为静态函数不能访问对象成员,因此不会影响对象的封装。

扩展性

C++中,可以把这些非成员函数定义在相同的命名空间下。 但问题又来了:这些在命名空间下的函数并不在类中,它们会被传播到所有的源文件中。 而客户并不希望为了使用几个工具函数,就对这样一个庞大的命名空间产生编译依赖。 因此我们可以将不同类别的工具函数放在不同的头文件中,客户可以选择它想要的那部分功能:

cpp 复制代码
// file: webbrowser.h
namespace WebBrowserStuff{
    class WebBrowser{};
}

// file: webbrowser-bookmarks.h
namespace WebBrowserStuff{
    ...
        }

// file: webbrowser-cookies.h
namespace WebBrowserStuff{
    ...
        }

这也是C++标准库的组织方式,std命名空间下的所有东西都被分在了不同的头文件中:<vector>, <algorithem>, <memory>等。这样客户代码只对它引入的那部分功能产生编译依赖。 为了做到这一点,这些工具函数必须是非成员函数,因为类作为整体必须在一个文件中进行定义。

同一命名空间不同头文件的组织方式,也为客户扩展工具函数提供了可能。 客户可以在同一命名空间下定义他自己的工具函数, 这些函数便会和既有工具函数天然地集成在一起,如 用户使用vector 的话 不需要 include <list> <memory>。 这也是成员函数无法做到的一个特性,因为类的定义对客户扩展是关闭的。 即使是子类也不能访问封装的(私有)成员数据, 况且有些类不是用来做基类的(见Item 7:将多态基类的析构函数声明为虚函数)。

非成员非友元函数好于成员函数 : 这样可以增加封装性、包裹弹性和机能扩充性。

条款24 若所有参数都需要类型装换,请为此采用non-member函数

Item 24: Declare non-member functions when type conversions should apply to all parameters.

虽然Item 15:资源管理类需要提供对原始资源的访问中提到,最好不要提供隐式的类型转化。 但这条规则也存在特例,比如当我们需要创建数字类型的类时。正如double和int能够自由地隐式转换一样, 我们的数字类型也希望能够做到这样方便的接口。 当然这一节讨论的问题不是是否应当提供隐式转换,而是如果运算符的所有"元"都需要隐式转换时,请重载该运算符为友元函数。

通过运算符重载来扩展用户定义类型时,运算符函数可以重载为成员函数,也可以作为友元函数。 但如果作为了成员函数,this将被作为多元操作符的第一元,这意味着第一元不是重载函数的参数,它不会执行类型转换。 仍然拿有理数类作为例子,下面的Rational类中,将运算符重载为成员函数:

cpp 复制代码
class Rational{
public: 
Rational(int n = 0, int d = 1);
int numerator() const;
int denominator() const;
const Rational operator*(const Rational& rhs) const;
...

我们看下面的运算符调用能否成功:

cpp 复制代码
Rational oneHalf(1, 2);

Rational result = oneHalf * oneHalf;   // OK
result = oneHalf * 2;                  // OK
result = 2 * oneHalf;                  // Error

第一个运算符的调用的成功是很显然的。我们看第二个调用:

当编译器遇到运算符*时,它会首先尝试调用:

cpp 复制代码
result = oneHalf.operator*(2);

编译器发现该函数声明(它就是定义在Rational类中的方法)存在, 于是对参数2进行了隐式类型转换(long->Rational)。所以第二个调用相当于:

cpp 复制代码
Rational tmp(2);
result = oneHalf.operator*(tmp);

将Rational的构造函数声明为explicit可以避免上述隐式转换,这样第二个调用也会失败。

对于第三个调用,编译器仍然首先尝试调用:

cpp 复制代码
result = 2.operator*(oneHalf);

2属于基本数据类型,并没有成员函数operator*。于是编译器再尝试调用非成员函数的运算符:

cpp 复制代码
result = operator*(2, oneHalf);

再次失败,因为并不存在与operator*(long, Rational)类型兼容的函数声明,所以产生编译错误。 但如果我们提供这样一个非成员函数:

cpp 复制代码
const Rational operator*(const Rational& lhs, const Rational& rhs);

这时候第一个参数也可以进行隐式转换。第三个调用(result = 2 * oneHalf)便会成功,该表达式相当于:

cpp 复制代码
Rational tmp(2);
result = operator*(tmp, oneHalf);

只有当运算符的元出现在运算符函数的参数列表时,它才会被隐式类型转换。所以当我们需要运算符的所有"元"都可以被隐式转换时, 应当将运算符声明为非成员函数。 在JavaScript或者C#中,这个规则是不需要的,因为编译器/解释器在这里做了更多的工作。比如JavaScript中2.toFixed(3) 会被解释为Number(2).toFixed(3),该表达式的值为"2.000"。

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

Item 25: Consider support for a non-throwing swap.

swap 函数最初由 STL 引入,已经成为异常安全编程(见 Item 29)的关键函数, 同时也是解决自赋值问题(参见 Item 11:赋值运算符的自赋值问题)的通用机制。 std 中它的基本实现是很直观的:

cpp 复制代码
namespace std{
    template<typename T>
    void swap(T& a, T& b){
        T tmp(a);
        a = b;
        b = tmp;
    }
}

可以看到,上述 swap 是通过赋值和拷贝构造实现的。所以 std::swap 并未提供异常安全, 但由于 swap 操作的重要性,我们应当为自定义的类实现异常安全的 swap,这便是本节的重点所在。

类的 swap

先不提异常安全,有时 std::swap 并不高效(对自定义类型而言)。 比如采用 pimpl idiom(见 Item 31)设计的 类 中,实际上只需要交换对应的指针即可实现 swap :

cpp 复制代码
class WidgetImpl;
class Widget {           // pimpl idiom 的一个类
	WidgetImpl *pImpl;   // 指向Widget的实现(数据)        
public:
    Widget(const Widget& rhs);
}; 

namespace std {
    template<>                      // 模板参数为空,表明这是一个全特化
    void swap<Widget>(Widget& a, Widget& b){   
        swap(a.pImpl, b.pImpl);     // 只需交换它们实体类的指针 
    }
}

上述代码是不能编译的,因为 pImpl 是私有成员!所以,Widget 应当提供一个 swap 成员函数或友元函数。 惯例上会提供一个成员函数:

cpp 复制代码
class Widget {
public:       
    void swap(Widget& other){
        using std::swap;          // 为何要这样?请看下文
        swap(pImpl, other.pImpl);
    }
};

接着我们继续特化 std::swap,在这个通用的 swap 中调用那个成员函数:

cpp 复制代码
namespace std {
    template<>
    void swap<Widget>(Widget& a, Widget& b){
        a.swap(b);              // 调用成员函数
    }
}

到此为止,我们得到了完美的 swap 代码。上述实现与 STL 容器是一致的:提供公有 swap 成员函数, 并特化 std::swap 来调用那个成员函数。

类模板的 swap

当 Widget 是类模板时,情况会更加复杂。按照上面的 swap 实现方式,你可能会这样写:

cpp 复制代码
template<typename T>
class WidgetImpl { ... };

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

namespace std {
    template<typename T>
    // swap 后的尖括号表示这是一个特化,而非重载。
    // swap<> 中的类型列表为 template<> 中的类型列表的一个特例。
    void swap<Widget<T>>(Widget<T>& a, Widget<T>& b){
        a.swap(b); 
    }
}

悲剧的是上述代码不能通过编译。C++ 允许偏特化类模板,却不允许偏特化函数模板(虽然在有些编译器中可以编译)。 所以我们干脆不偏特化了,我们来重载 std::swap 函数模板:

cpp 复制代码
namespace std {
    template<typename T>
    // 注意 swap 后面没有尖括号,这是一个新的模板函数。
    // 由于当前命名空间已经有同名函数了,所以算函数重载。
    void swap(Widget<T>& a, Widget<T>& b){
        a.swap(b); 
    }
}

这里我们重载了 std::swap,相当于在 std 命名空间添加了一个函数模板。但这在 C++ 标准中是不允许的! C++ 标准中,客户只能特化 std 中的模板,但不允许在 std 命名空间中添加任何新的模板。 上述代码虽然在有些编译器中可以编译,但会引发未定义的行为,所以不要这么搞!

那怎么搞?办法也很简单,就是别在 std 下添加 swap 函数了,把 swap 定义在 Widget 所在的命名空间中:

cpp 复制代码
namespace WidgetStuff {
    template<typename T> 
    class Widget { ... };

    template<typename T> 
    void swap(Widget<T>& a, Widget<T>& b){
        a.swap(b);
    }
}

任何地方在两个 Widget 上调用 swap 时,C++根据其 argument-dependent lookup(又称 Koenig lookup) 会找到 WidgetStuff 命名空间下的具有 Widget 参数的 swap。

那么似乎 类的 swap 也只需要在同一命名空间下定义 swap 函数,而不必特化 std::swap。 但是!有人喜欢直接写 std::swap(w1, w2),特化 std::swap 可以让你的类更加健壮。

因为指定了调用 std::swap,argument-dependent lookup 便失效了,WidgetStuff::swap 不会得到调用。

说到这里,你可能会问如果我希望优先调用 WidgetStuff::swap,如果未定义则取调用 std::swap,那么应该如何写呢? 看代码:

cpp 复制代码
template<typename T>
void doSomething(T& obj1, T& obj2){
    using std::swap;           // 使得`std::swap`在该作用域内可见
    swap(obj1, obj2);          // 现在,编译器会帮你选最好的swap
}

此时,C++编译器还是会优先调用指定了 T 的 std::swap,其次是 obj1 的类型 T 所在命名空间下的对应 swap 函数, 最后才会匹配 std::swap 的默认实现。

最佳实践

如何实现 swap 呢?总结一下:

  1. 提供一个更加高效的,不抛异常的公有成员函数(比如 Widget::swap)。
  2. 在你类(或类模板)的同一命名空间下提供非成员函数 swap,调用你的成员函数。
  3. 如果你写的是类而不是类模板,也可以特化 std::swap,同样地在里面调用你的成员函数。
  4. 调用时,请首先用 using 使std::swap可见,然后直接调用 swap

参考

相关推荐
OTWOL4 分钟前
两道数组有关的OJ练习题
c语言·开发语言·数据结构·c++·算法
问道飞鱼8 分钟前
【前端知识】强大的js动画组件anime.js
开发语言·前端·javascript·anime.js
拓端研究室8 分钟前
R基于贝叶斯加法回归树BART、MCMC的DLNM分布滞后非线性模型分析母婴PM2.5暴露与出生体重数据及GAM模型对比、关键窗口识别
android·开发语言·kotlin
k09339 分钟前
vue中proxy代理配置(测试一)
前端·javascript·vue.js
Code成立9 分钟前
《Java核心技术I》Swing的网格包布局
java·开发语言·swing
傻小胖11 分钟前
React 脚手架使用指南
前端·react.js·前端框架
Auc2414 分钟前
使用scrapy框架爬取微博热搜榜
开发语言·python
Somnus陳15 分钟前
软考架构师笔记-计算机系统组成-1
笔记·系统架构
中草药z15 分钟前
【Spring】深入解析 Spring 原理:Bean 的多方面剖析(源码阅读)
java·数据库·spring boot·spring·bean·源码阅读
QQ同步助手21 分钟前
C++ 指针进阶:动态内存与复杂应用
开发语言·c++