这一章主要讲如何设计和声明C++的接口,一个最重要的准则就是:接口应该易于使用而难于误用。这就为一系列更具体的准则奠定了基础,包括正确性、效率、封装、可维护性、可扩展性和与约定的一致性。
ITEM 18: MAKE INTERFACES EASY TO USE CORRECTLY AND HARD TO USE INCORRECTLY
C++中到处都能看到接口,函数、类、模板等等,每个接口都是客户端与你的代码交互的方式。一个正常人都是想写出正确的代码的,也就是说如果他错误的用了你的接口,那么你也应该有一部分责任,因为不够好用。理想情况下,如果接口用的方式不对,就无法编译,如果用的对,就能编译通过。所以要设计良好的接口就必须考虑其他人用的时候可能会犯的错误。
比如一个日期类
class Date {
public:
Date(int month, int day, int year);
...
};
其他人用的时候就可能会把顺序填错,这样日期就会不对甚至无效,改进方法是使用自己定义的类型来作为参数:
struct Day { struct Month { struct Year {
explicit Day(int d) explicit Month(int m) explicit Year(int y)
:val(d) {} :val(m) {} :val(y){}
int val; int val; int val;
}; }; };
class Date {
public:
Date(const Month& m, const Day& d, const Year& y);
...
};
Date d(30, 3, 1995); // error! wrong types
Date d(Day(30), Month(3), Year(1995)); // error! wrong types
Date d(Month(3), Day(30), Year(1995)); // okay, types are correct
这样做其实还不够,因为我们知道月份其实只有12个,应该对月份的输入进行限制。可以用枚举,但是不够安全,因为枚举可以强转成整形,最好是自己定义合法的月份:
class Month {
public:
static Month Jan() { return Month(1); } // functions returning all valid
static Month Feb() { return Month(2); } // Month values; see below for
... // why these are functions, not
static Month Dec() { return Month(12); } // objects
... // other member functions
private:
explicit Month(int m); // prevent creation of new
// Month values
... // month-specific data
};
Date d(Month::Mar(), Day(30), Year(1995));
其实天也可能出问题,不过判断起来比较复杂,因为要结合年份,这里就略过了
另一个减少错误的方法是之前第一章也讲过的,如果不需要改变某些变量的值,就加上const
。我们应该尽量让我们的类表现得像内置类型一样,对某些行为不确定时就参考int
的行为,因为大家都知道int
怎么用。保持一致性一个很重要的原因就是让接口统一,接口一致就好用,不一致就难用。比如STL的容器类接口就是一致的,它们都有size
函数表示内部包含了多少个元素,这样用起来就很方便,不需要去记忆哪个类用哪个接口
只要依赖调用接口的客户端去做什么事,就增加了出错的可能性,因为调用的人可能会忘记这件事。比如上一讲说的,用智能指针去管理新生成的Investment
对象,就是客户端自己去处理,如果忘了用智能指针,就会导致内存泄露,所以这件事应当放到接口中去,直接返回一个智能指针
std::tr1::shared_ptr<Investment> createInvestment();
这样就保证客户端使用了智能指针去管理对象,从而消除了内存泄露的可能性
然后讲了一下上一章也提到的,我们可以自己定制shared_ptr
的deleter
,让它在析构函数中进行相应的操作。shared_ptr
还有一个好处是避免了跨DLL的问题,也就是说它的构造函数和析构函数处于同一个DLL
总结:
1. 好的接口应该易于使用,难于误用
2. 接口的一致性以及与内置类型行为的一致性会让代码更好用
3. 创建新的类型、限制对类型的操作、限制对象的值、消除客户端对资源管理的责任可以减少错误的发生
4. shared_ptr
支持自定义deleter,也能防止跨DLL问题,同时还具有线程安全性
ITEM 19: TREAT CLASS DESIGN AS TYPE DESIGN
在C++中,定义一个类其实相当于定义了一种新的类型,我们应该处理所有跟类型有关的事,比如重载函数和运算符,分配释放内存,初始化等等,所以设计一个好的类是很困难的,因为涉及一个好的类型就很困难。好的类型有自然的形式,直观的语义,以及高效的实现,因此我们应该仔细的考虑对类的实现。总的来说我们需要回答以下这些问题:
- 这个类型的对象如何创建和销毁:这件事将影响我们的构造函数和析构函数,以及对内存的分配和释放
- 初始化和赋值如何区分:这件事决定了我们的拷贝构造函数和赋值语句的区分,不能混为一谈
- 值传递时会发生什么:拷贝构造函数的实现决定了对这个类的对象进行值传递时会发生什么
- 这个类型的合法值有哪些限制:通常来说类的成员变量的值只有某些组合时才有效,这决定了在类中我们需要进行的错误判断,特别是构造函数、赋值以及setter函数等
- 这个类是否有继承体系:如果继承了其他的类,那么这个类的很多东西就已经有了限制,比如某些函数是否为虚。如果这个类可能被继承,那么应该决定这个类定义的函数是否为虚,尤其是析构函数
- 这个类允许怎样的类型转换:类型安全是很重要的,如果你希望这个类能被某个其他的类转化而来,就应该写一个类型转换函数(
operator T()
)或者一个以这个类为参数且没有explicit
关键字的构造函数。反之如果不希望进行隐式转换,就不要写类型转换函数并且如果有以这个类为参数的构造函数就必须加上explicit
关键字 - 这个类支持哪些运算符和函数:这决定了你要声明和实现的运算符以及函数
- 哪些标准的函数应该被禁止:参考之前说的防止拷贝函数的方法,将它们声明为
private
- 这个类的访问权限是怎样的:这决定了成员变量应该是
public
,protected
还是private
,也决定了哪些变量和函数需要声明为友元 - 这个类的“未声明接口”是什么:它在性能、异常安全性和资源使用(比如锁和动态内存)方面提供了什么样的保证,这会对我们的实现施加约束
- 这个类有多通用:也许有时候我们不是想定义一个新的类型,而是想定义一族新的类型,这时候应该用类模板
- 这个新的类是否有必要:如果只是为了实现某个新功能而继承某个类,其实完全可以直接在原来的类中添加成员函数
总结:
像定义类型一样定义类,定义时考虑以上这些问题
ITEM 20: PREFER PASS-BY-REFERENCE-TO-CONST
TO PASS-BY-VALUE
这一项其实第一章也讲过,尽量用const&
,不过这里讲的更细更具体,而且也讲了内置类型与对象传递时的区别(想到我之前因为看了第一章,就把所有的参数都加上const&
就很惭愧,而且公司的cpp guideline上也写了内置类型应该使用值传递),那么这里再复习一下。
默认情况下,C++的函数参数的对象都是使用值传递的,这一点继承了C语言的特性,除非特殊声明,那么函数都会通过拷贝构造函数来创建一份参数的拷贝,然后进行函数体内的操作,这就使得值传递会有很多不必要的开销。假设现在有如下的类:
class Person {
public:
Person(); // parameters omitted for simplicity
virtual ~Person(); // see Item 7 for why this is virtual
...
private:
std::string name;
std::string address;
};
class Student: public Person {
public:
Student(); // parameters again omitted
~Student();
...
private:
std::string schoolName;
std::string schoolAddress;
};
再考虑以下几行代码:
bool validateStudent(Student s); // function taking a Student
// by value
Student plato; // Plato studied under Socrates
bool platoIsOK = validateStudent(plato); // call the function
当我们调用validateStudent
时,会发生什么?我们将plato
作为参数传递进了这个函数,所以我们要调用Student
类的拷贝构造函数来创建一份它的拷贝,当函数结束时我们要调用Student
类的析构函数,因此这次值传递需要调用一次构造一次析构。而Student
类中还有两个string
对象,所以调用构造函数的时候还要调用两次string
类的构造函数。同时Student
类继承了Person
类,所以调用Student
的构造函数时还要调用Person
类的构造函数,而它也包含两个string
对象,因此又要调用两次string
的构造函数,这样一来我们一共调用了6次构造函数,而每个构造函数也都要对应一个析构函数,真的很浪费
而只要我们加上const&
事情就简单了,没有构造函数和析构函数会被调用,因为没有新的对象被创建
bool validateStudent(const Student& s);
这里加上const
的好处是,表示这个函数不会更改传递进来的对象。使用值传递时,因为函数拿到的是拷贝,所以对拷贝的任何操作都不会影响原来的对象。而使用引用时,对参数的任何操作都会反应到原来的对象上,所以加上const
让任何看到这个函数的人都能放心的知道,这个函数不会更改原来的值
使用引用还有一个好处就是,可以防止“截断”的问题,这一点之前培训时老师也讲过,如果在父类的拷贝构造函数传入子类对象,那么子类那些自己的成员和特性就会消失,因为创建的对象是父类对象。因此如果使用值传递的方式定义一个函数参数,然后又传入子类对象的话,结果可能就不是我们想要的。假设现在有以下的类:
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;
};
然后我们写了以下代码来显式这个带滚动条的窗口:
void printNameAndDisplay(Window w) // incorrect! parameter
{ // may be sliced!
std::cout << w.name();
w.display();
}
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
我们的本意应该是根据传入不同的Window
类,调用相应的虚函数display
来进行不同的显式,但是这里函数参数使用的是值传递的方式,因此每次调用都只会调用父类的display
,没有多态的效果。如果改成传递常引用问题就会解决:
void printNameAndDisplay(const Window& w) // fine, parameter won't
{ // be sliced
std::cout << w.name();
w.display();
}
C++的引用其实也是靠指针实现的,只是引用更加安全,所以传递引用实际上也是传递指针。老师也说过,所有用引用的地方都可以用指针来代替,反之则不行。于是,对于内置类型来说,直接传递值会更高效(一定要记得)。对于STL迭代器来说也是如此,因为它们设计的时候就是希望用值传递的方式的,参考第一章第一条,C++在不同的场景会有不同的最佳实践
有的人可能会直接概括为,小的对象就可以使用值传递,这是错误的。STL容器类对象也很小,但是对它们进行拷贝时要把里面所有的元素拷贝一次,这是很大的开销。即使拷贝的开销不大,也不应该使用值传递。有些编译器不会把一个只包含一个double
类型变量的对象放到寄存器中,但如果是一个double
变量它就可以,所以这种情况下如果使用引用传递的方式就没有问题,它会放一个指针在寄存器
还有一个原因就是现在看起来很小的类可能随着时间会越来越大,于是拷贝的开销也就越来越高,所以既然可以不拷贝,干嘛还要去增加无谓的开销呢?总之,除了内置类型、STL迭代器和函数对象,其他的传递参数最好都使用常引用的传递方式
总结:
1. 传递常引用而不是值,因为它更高效
2. 对于内置类型、STL迭代器和函数对象使用值传递
ITEM 21: DON’T TRY TO RETURN A REFERENCE WHEN YOU MUST RETURN AN OBJECT
这一项应该是我看到现在看的最开心的一项,因为真的太好笑了😂大意很直白,返回对象而不是返回引用。很多人在看了上一项item之后就会变身const&
的狂教徒,然后把代码里的值传递赶尽杀绝(嗯,说的就是我了,我连int
和enum
都加上了常引用_(:з)∠)_)。于是他们就会开始犯一个错误:给不存在的对象传递引用
考虑如下的有理数类:
class Rational {
public:
Rational(int numerator = 0, // see Item 24 for why this
int denominator = 1); // ctor isn't declared explicit
...
private:
int n, d; // numerator and denominator
friend
const Rational // see Item 3 for why the
operator*(const Rational& lhs, // return type is const
const Rational& rhs);
};
这里的operator*
返回的是值而不是引用,那么问题来了:应该返回引用吗?我们应该记住,引用只是一个别名,每当我们看到一个引用,我们都应该反应一下,这个引用引用的是什么?它必须引用了某样东西才能是引用。如果这里operator*
返回的是引用,那就意味着它必须引用了已经乘完得到的这个对象。很显然,在我们还没乘的时候这个对象应该是不存在的,所以要引用的话就必须由operator*
自己去创建一个对象,于是这就开始了我们罪恶的bad code之路。。
创建一个对象的方法有两种:在栈上或者在堆上。通过声明局部变量我们可以在栈上创建一个对象,Round1:
const Rational& operator*(const Rational& lhs, // warning! bad code!
const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
且不说我们是为了避免调用构造函数和析构函数才返回引用,结果在函数中又调用了构造函数和析构函数,简直跟原地tp一样憨憨,这段代码有很严重的问题。函数中的result
是局部变量,当函数返回后它就已经不存在了,也就是说这个函数返回的引用引用了一个之前曾经是Rational
的对象,必定会出错,一个返回指向局部变量的指针的函数也是同理
既然在栈上不行,那我们在堆上创建一个对象,Round2:
const Rational& operator*(const Rational& lhs, // warning! more bad
const Rational& rhs) // code!
{
Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result;
}
首先,我们还是要调用构造函数,更重要的问题是:谁来帮我们delete
这个对象呢?考虑以下代码:
Rational w, x, y, z;
w = x * y * z; // same as operator*(operator*(x, y), z)
这行语句调用了两个operator*
,也就是会调用两次new
,那我们就应该调用两次delete
来释放分配的内存。但是我们无能为力,因为这两个指针已经消失在了内存的茫茫大海中,再也找不到了,你家内存必泄露.jpg
聪明的你注意到,前面没成功都是因为每次都要调用构造函数和析构函数,但我们本来是想避免调用它们的。所以你灵机一动,想到了下面这段用静态变量返回引用的方法,Round3:
const Rational& operator*(const Rational& lhs, // warning! yet more
const Rational& rhs) // bad code!
{
static Rational result; // static object to which a
// reference will be returned
result = ...; // multiply lhs by rhs and put the
// product inside result
return result;
}
由于静态变量引起的线程安全的问题我们就先不谈了,它还有更严重的错误,考虑以下正常代码:
bool operator==(const Rational& lhs, // an operator==
const Rational& rhs); // for Rationals
Rational a, b, c, d;
...
if ((a * b) == (c * d)) {
do whatever's appropriate when the products are equal;
} else {
do whatever's appropriate when they're not;
}
在这段代码中,无论a b c d
的值是什么,((a*b) == (c*d))
永远会返回true
,原因就不用我多说了,因为永远会拿同一个静态变量进行比较。
说了这么多,可能已经能让你明白返回引用简直就是浪费时间。可能有的人还是不死心:一个静态变量不行,多弄几个的话说不定行呢。。。😂作者表示心很累,然后是这么解释的:首先你需要选择一个n作为这个数组的大小,选的太小马上就用完了,选的太大太影响性能,因为第一次进行这个操作时要把所有的静态对象创建一次。最后,当你要把自己的值放进这个数组时,也要进行赋值操作,跟构造函数加析构函数的开销差不多,而你最初的目的明明是避免构造和析构。所以,面对现实吧:返回引用是没有出路的(用vector
也好不到哪去)
该返回对象时就老老实实的返回对象吧,其实你担心的那一点点性能问题可能根本就不会发生,因为C++的编译器会帮助我们优化生成的代码,可能根本就不会有这一次构造和析构的开销。总之当决定返回值时,我们首先应该保证的是行为正确,性能问题就让编译器帮我们处理吧
总结:
不要返回局部对象的引用或指针,不要返回堆对象的引用或指针,不要返回可能存在多个的局部静态对象的引用或指针
ITEM 22: DECLARE DATA MEMBERS PRIVATE
这一项主要就是讲将数据成员声明为private
,这一点也是在日常代码中习以为常但并没有理解特别深刻的。作者在这里详细的说明了将数据成员声明为public
或是protected
所带来的问题
首先是为了格式的一致性,如果数据成员是私有的,那么访问它们就都需要通过成员函数,使用接口的人就不需要记忆到底要不要加括号了。其次,如果把数据成员声明为公有的,那么其他地方对这个数据都有读写的权限,而通过成员函数去访问数据成员,你可以对数据成员有更好的权限管理,例如只读、读写、甚至只写:
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; // no access to this int
int readOnly; // read-only access to this int
int readWrite; // read-write access to this int
int writeOnly; // write-only access to this int
};
最重要的一点是为了封装性,通过接口去访问数据成员,以后可能改变其中的实现而不需要更改调用的代码。假设有以下的类:
class SpeedDataCollection {
...
public:
void addValue(int speed); // add a new data value
double averageSoFar() const; // return average speed
...
};
关于averageSoFar
的实现,一种是用一个数据成员来表示它,然后调用函数的时候直接返回它的值。另一种就是每次调用的时候再去计算然后返回。这就是一个典型的时间与空间的权衡问题,对于第一种方法需要给数据成员分配空间,但是计算的较快,第二种方法计算的比较慢,但是每个对象占用空间会更少。我们可以根据使用的环境决定使用哪种方式,但关键都是使用成员函数去实现,也就是封装。这样做的话以后随时都可以更改其中的实现,而客户端只需要重新编译,不用修改调用方式
隐藏数据成员还提供了更多的灵活性,比如更容易通知其他对象该数据成员的变化,验证类的不变性,进行线程的同步化等等。隐藏数据成员保证了数据成员永远由成员函数来管理,也保留了修改实现的自由。如果直接暴露出数据成员,很快就会发现对这些成员的修改受到了限制,因为太多的代码会受到影响。公有意味着未封装,也就意味着难以修改,特别是对于那些广泛使用的类来说。这里用“牵一发而动全身”来形容比较合适,可能改一个实现就要改所有调用的地方
对于protected
成员其实也是一样的道理,我们知道对于封装性来说,当它修改时引起其他地方的改动越大则说明封装性越差,越小则封装性越好。对于一个public
成员,如果将它从类中删除,受到影响的地方是所有用到这个成员的代码,这是难以计算的。对于一个protected
成员,如果将它从类中删除,受到影响的地方是所有用到这个成员的子类,这也是难以计算的。所以protected
并没有比public
更好的封装性。从封装的角度来说,只有私有和非私有两种权限
总结:
1. 将数据成员声明为私有,可以增加一致性,提供更好的访问控制,验证不变性以及更改实现的灵活性
2. protected
并没有比public
封装性更好
ITEM 23: PREFER NON-MEMBER NON-FRIEND FUNCTIONS TO MEMBER FUNCTIONS
这一项是以前没有接触过的,看完之后也算是有些感悟,明白工作中有些代码为什么是这么设计的了。首先考虑一个浏览器类:
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
};
很多时候用户可能想一次性执行所有clear的操作,方法之一就是加一个成员函数:
class WebBrowser {
public:
...
void clearEverything(); // calls clearCache, clearHistory,
// and removeCookies
...
};
还有一种方法是使用非成员函数:
void clearBrowser(WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
从面向对象的角度来说,对象应该尽可能被封装,而第一种相对于第二种,暴露了更多的细节,减少了封装性。而且使用非成员函数可以更灵活的组装成员函数,也能减少编译时的依赖。因此使用非成员函数是更好的选择
首先从封装性来说,上一条也提到过,越少接触,封装性越高,改起来的灵活性就越高,因为改动影响的地方越少。结合这一点来看,成员函数能访问私有成员,而非成员非友元函数无法访问私有成员,具有更强的封装性。这里有两点还需要注意的地方:
- 这个结论只对非成员非友元有效,对于友元来说并没有减少封装性,因为它们还是能访问私有的成员变量
- 出于封装的考虑选择非成员函数并不意味着这个函数不能是其他类的成员函数,我们可以把这个函数声明为一个其他实用类的静态方法,只要它不是
WebBrowser
类的函数,就不会破坏封装性
在C++中一个更自然的用法是通过命名空间来组合两者:(这也是公司的commonHead的用法)
namespace WebBrowserStuff {
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
...
}
这样做的好处是,命名空间可以跨源文件起作用,这很重要,因为像clearBrowser
这种方法很常用,将它声明为非成员非友元函数可以保证它跟其他客户端代码一样,没有特殊的访问权限。即使没有这个函数,客户端也可以自己去调用那三个成员函数
一个类可能有多个实用的方法,比如跟书签有关的,跟cookie有关的,于是可以通过不同的头文件,但是相同的命名空间来组合它们。这样的话处理书签的代码不需要编译cookie相关的代码,减少依赖和编译时间
// header "webbrowser.h" — header for class WebBrowser itself
// as well as "core" WebBrowser-related functionality
namespace WebBrowserStuff {
class WebBrowser { ... };
... // "core" related functionality, e.g.
// non-member functions almost
// all clients need
}
// header "webbrowserbookmarks.h"
namespace WebBrowserStuff {
... // bookmark-related convenience
} // functions
// header "webbrowsercookies.h"
namespace WebBrowserStuff {
... // cookie-related convenience
} // functions
...
C++的标准库也是这样组织的,例如<vector>
<memory>
<algorithm>
等等,它们都是std
命名空间中的,但是使用的时候可以根据需要决定要include哪些头文件。如果在类中这就无法实现,因为成员函数必须全部定义在类中,不能被分隔开。这样也提供了更好的扩展性,如果还需要加什么实用的函数,只需要再创建一个头文件,并且在相同的命名空间中加上就行。看到这里总算明白,平常那些什么CallUtils,VisualUtils,还有commonHead::viewmodels之类的是为了什么,又是什么原理了
总结:
比起成员函数,优先非成员非友元函数,这样做提供了更好的封装性、灵活性以及可拓展性
ITEM 24: DECLARE NON-MEMBER FUNCTIONS WHEN TYPE CONVERSIONS SHOULD APPLY TO ALL PARAMETERS
这一条之前上课的时候也看过了,不过当时老师主要是为了讲友元的用法。这里主要就是为了支持所有参数的类型转换,看个例子就明白了,假设有个有理数类:
class Rational {
public:
Rational(int numerator = 0, // ctor is deliberately not explicit;
int denominator = 1); // allows implicit int-to-Rational
// conversions
int numerator() const; // accessors for numerator and
int denominator() const; // denominator — see Item 22
private:
...
};
当我们重载它的算数运算符,比如*时,如果定义在类中让它以成员函数存在:
class Rational {
public:
...
const Rational operator*(const Rational& rhs) const;
};
那么对于对象间的运算是没有问题的
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEighth; // fine
result = result * oneEighth; // fine
可是如果涉及到混合类型的运算就会出现一些问题(这个例子也跟课上的差不多)
result = oneHalf * 2; // fine
result = 2 * oneHalf; // error!
直观上来说,乘法应该是符合交换律的,否则不合理,那么这里出现了什么问题呢?我们只需要把*号还原成成员函数的形式就很清楚了:
result = oneHalf.operator*(2); // fine
result = 2.operator*(oneHalf); // error!
第一条语句中,oneHalf
对象可以调用这个成员函数,但是第二条语句中,2并没有这个函数,于是编译失败。而在第一条语句中,我们能编译通过是因为存在隐式类型转换。编译器知道它应该传递一个Rational
类的对象,虽然这里传递的是一个int
型,但是Rational
类有一个以int
型为参数的构造函数,所以它帮我们直接调用了这个构造函数来生成一个临时对象并传递给成员函数,第一条语句才能成功编译。如果我们给int
型的构造函数加上explicit
关键字那么这两条语句都会编译失败
那么怎样才能让我们的运算既支持混合类型又支持交换律呢?仔细分析一下上面两条语句的不同就会发现,第一条能成功是因为我们把2作为参数进行了类型转换,而第二条中2放在前面了,没法作为参数所以失败了。所以要实现我们的目标,就应该把operator*
声明为非成员函数:
class Rational {
... // contains no operator*
};
const Rational operator*(const Rational& lhs, // now a non-member
const Rational& rhs) // function
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2; // fine
result = 2 * oneFourth; // hooray, it works!
这里注意一下并不一定要把这个函数声明为Rational
类的友元,因为它的公有方法已经足够我们达到目的,就没有必要声明友元,带来不必要的麻烦
总结:
如果你希望一个函数支持对所有参数的类型转换,就将它声明为非成员函数
ITEM 25: CONSIDER SUPPORT FOR A NON-THROWING SWAP
这一条主要在讲关于swap
的一些细节,说实话理解的不是特别透彻,尝试性的记录一下。swap
是标准库中引入的,作用就是交换两个变量的值。它的实现其实也很直白:
namespace std {
template<typename T> // typical implementation of std::swap;
void swap(T& a, T& b) // swaps a's and b's values
{
T temp(a);
a = b;
b = temp;
}
}
只要传入的对象支持拷贝操作,那么它就能work。但是可以看到它的实现进行了三次对象的拷贝,而有些时候这些拷贝是不必要的,比如对象包含的是指针,那么此时只需要进行浅拷贝,更改指针指向的值即可
class WidgetImpl { // class for Widget data;
public: // details are unimportant
...
private:
int a, b, c; // possibly lots of data —
std::vector<double> v; // expensive to copy!
...
};
class Widget { // class using the pimpl idiom
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) // to copy a Widget, copy its
{ // WidgetImpl object. For
... // details on implementing
*pImpl = *(rhs.pImpl); // operator= in general,
... // see Items 10, 11, and 12.
}
...
private:
WidgetImpl *pImpl; // ptr to object with this
}; // Widget's data
对于这种类的对象,我们就希望能进行浅拷贝,避免不必要的开销,所以我们要用到模板的方法
namespace std {
template<> // this is a specialized version
void swap<Widget>(Widget& a, // of std::swap for when T is
Widget& b) // Widget; this won't compile
{
swap(a.pImpl, b.pImpl); // to swap Widgets, just swap
} // their pImpl pointers
}
这里template<>
表示这是一个类的特制版swap
,而函数名后面的<Widget>
表示了它所服务的类。换句话说,当这个类调用这个方法时应该用如下的实现。当然这个函数暂时编译还不能通过,因为Impl
是私有成员,无法直接被访问,我们可以在Widget
类中添加一个公有的成员函数来实现指针的交换,并在std
中调用它
class Widget { // same as above, except for the
public: // addition of the swap mem func
...
void swap(Widget& other)
{
using std::swap; // the need for this declaration
// is explained later in this Item
swap(pImpl, other.pImpl); // to swap Widgets, swap their
} // pImpl pointers
...
};
namespace std {
template<> // revised specialization of
void swap<Widget>(Widget& a, // std::swap
Widget& b)
{
a.swap(b); // to swap Widgets, call their
} // swap member function
}
当Widget
是一个模板类的时候事情就会更复杂,作者先举了两个不正确的例子
namespace std {
template<typename T>
void swap<Widget<T> >(Widget<T>& a, // error! illegal code!
Widget<T>& b)
{ a.swap(b); }
}
这段代码不正确的原因是C++不允许部分特化,这里指的就是T
是泛化但Widget
又不是。如果想实现这样的效果可以直接重载这个函数
namespace std {
template<typename T> // an overloading of std::swap
void swap(Widget<T>& a, // (note the lack of "<...>" after
Widget<T>& b) // "swap"), but see below for
{ a.swap(b); } // why this isn't valid code
}
但是因为std
是个特殊的命名空间,不允许我们自己加新的模板进去,所以这里会造成无法预期的行为。那么最终想实现高效又泛化的swap
函数,我们就需要避免这两点。所以我们声明一个非成员函数,然后不去重载std::swap
,放在别的命名空间
namespace WidgetStuff {
... // templatized WidgetImpl, etc.
template<typename T> // as before, including the swap
class Widget { ... }; // member function
...
template<typename T> // non-member swap function;
void swap(Widget<T>& a, // not part of the std namespace
Widget<T>& b)
{
a.swap(b);
}
}
C++的编译器具有argument-dependent-lookup的特性,也称为Koenig lookup,可以通过实参的类型去查找函数的定义。所以如果编译器遇到以两个Widget
对象为参数的swap
函数就能正确的调用上面的特定实现。虽然这么做很好,但是我们还是需要一个std
空间中的特定实现,这是因为客户端可能没有成功调用命名空间的版本。我们希望的顺序应该是,先尝试特定的版本,再尝试标准版本,下面这段代码可以满足我们的需求
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap; // make std::swap available in this function
...
swap(obj1, obj2); // call the best swap for objects of type T
...
}
编译器会首先尝试在全局作用域以及T
所在的命名空间查找特制版本的swap
函数,如果没有找到就使用std::swap
,因为我们写了using std::swap
,而且在std
中查找时也会优先使用特制版,最后才会使用标准版。但是如果错误的指定了命名空间就会错误调用:
std::swap(obj1, obj2); // the wrong way to call swap
这行代码会强制使用std
中的版本,而忽略了更适合的特制版,所以为了这种错误的考虑,我们也应该在std
命名空间中加上我们的特制实现
总结一下,如果默认的swap
函数已经满足了需求,那就什么都不用做。如果不够高效,那就需要自己实现特定版本:
- 加一个公有的成员函数提供高效版本的
swap
- 在你的类或模板相同的命名空间中声明一个非成员函数,调用
swap
方法 - 如果写的是类,在
std
中加上特制版的实现,也调用swap
方法
最后,记得加上using std::swap
,然后不要加命名空间进行调用
总结:
1. 当默认的swap
不够高效时自己实现swap
方法,不要抛出异常
2. 如果提供了成员swap
,也提供非成员的swap
去调用它,如果是类就需要在std
中加上特定实现
3. 使用一个using
语句,然后不要指明命名空间
4. 在std
中完全给一个类定制化一个函数是可以的,但是不能加新的模板或类