一、让自己习惯C++
1.1 条款01: 视C++为一个语言联邦
C++可认为是个多重范型语言( multilparadigm programing language),同时支持过程形式(procedural),面向对象形式(object-oriented),函数形式(functional),泛型形式(generic),元编程形式(metaprogramming)的语言,这些能力和弹性使C++成为一个无可匹敌的工具。
最简单的理解方式是将C++视为一个由相关语言组成的联邦语言而非单一语言,由四个次语言组成
- C语言。区块(blocking),语句(statements),预处理(preprocessor),内置数据类型(built-in data types),数组(array),指针(pointer)等来之于C
- Object-Oriented C++。也就是C with Classes 包括classed(包括构造函数和析构函数),封装(encapsulation),继承(interitance),多态(polymorphism),virtual函数(动态绑定)等
- Template C++,即C++泛型编程(generic programming)部分。Template相关考虑和设计已经弥漫整个C++,良好编程守则中“惟template适用”特殊条款并不罕见。
- STL,STL是个template程序库,它对容器(container),迭代器(iterators),算法(algorithm)以及函数对象(function objects)的规约有极佳的紧密配合与协调。
注意到这四个次语言,当从某个次语言切换到另一个,导致高效编程守则改变策略时,不必惊讶。例如对内置类型(满足C语言sub)而言,pass-by-value通常比pass-by-reference高效,但从C part of C++移向Object-Oriented C++,由于用户自定义构造函数,析构函数的存在,pass-by-reference-to-const往往更好。运用template C++也是如此,因为彼时甚至不知道处理对象的实际类型,自然需要pass-by-reference。然而对于STL,由于迭代器和函数对象都是基于指针塑造,因此对STL的迭代器和函数对象而言,pass-by-value依然适用。
因此,C++并不是一个带有一组守则的一体语言,而是从四个次语言组成,每个次语言都有自己的规约。
1.2 条款02:尽量以const, enum, inline替换#define
这个条款实际是,以编译器代替预处理器,当写下
#define ASPECT_RATIO 1.653
名称ASPECT_RATIO从未被编译器看见,编译器只获得被替换之后的1.653,但它认为1.653只是数值,而不是常量。也许编译器在处理源码之前它就被预处理器移走了,于是记号名称ASPECT_RATIO有可能没有进入记号表内(symbol table)。
于是用此常量获得一个编译错误信息时,这个错误信息也许会提到1.653,但绝不会提到ASPECT_RATIO。此时,若ASPECT_RATIO定义在头文件中,甚至头文件非你所写,你肯定对ASPECT_RATIO毫无概念,从来浪费时间追踪1.653。同时也会给调试带来难度,原因在于ASPECT_RATIO未出现在记号表中。
用一个常量代替上述的宏
const double AspectRatio = 1.653;
作为语言常量,AspectRatio肯定会被编译器看到,进入记号表。同时可以进行编译器优化,因为预处理器盲目将宏名称用1.653替换可能导致目标码出现多份1.653,而const常量只会生成一份内存。
以常量替换#define,两种特殊情况
- 定义常量指针,由于常量定义式往往放在头文件中,因此有必要将指针本身,和指向的object同时声明为const,即在头文件中写
const char* const authorName = "Scott Meyers"; // 用string对象比char* 更好
const std::String author("Scoot Meyers");
- class专属常量,为了将常量作用域scope限制在class内,必须成为class的成员;同时,为了确保常量与object无关,只有一份实体,必须成为一个static成员
class GamePlayer {
private:
static const int NumTurns = 5; // 声明常量
int scores[NumTurns]; // 使用常量
};
注意以上是NumTurn的声明式,声明的作用是指定变量的类型和名称,区分声明和定义可以让C++支持分开编译。 定义是为变量分配存储空间,并可能进行初始化。定义是一种声明,因为定义的同时必然会指定变量的类型和名称,然而声明却不是定义。C++中变量的定义必须有且仅有一次,而变量的声明可以多次。变量一般不能定义在头文件中,除了const变量。
一般的,在实现文件提供定义式,如下
const int GamePlayer::NumTurns; // 由于声明式已经赋初值,定义时不可以再赋
// 或者如下
class CostEstimate {
private:
static const double FudgeFactor; // static class常量声明
... // 位于头文件内
};
const double CostEstimate::FudgeFactor = 1.35; // static class常量定义,位于实现文件内
而且 ,我们无法利用#define创建一个class专属常量,因为#define并不重视作用域。一旦宏被定义,除非某处被#undef,它就对其后编译过程有效。因此#define不能提供任何封装性,不能作为class专属常量。当然const成员变量可以被封装。
除了const,还可以使用enum。enum的行为更像#define而不是const, 例如取const地址合法,但取enum地址不合法。一般用于某种类型具有多个常量的时候进行定义
class GamePlayer {
private:
enum { NumTurns = 5};
int score[NumTurns];
...
};
#define除了替换符号作用外,另一个作用是实现宏。宏看起来像函数 ,但不会招致函数调用带来的额外开销,例
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))
这种宏,必须为所有实参加上小括号,但纵使为所有实参加上小括号,同样会发生不可思议事情,相信学C语言时肯定被宏定义折磨过。
int a = 5, b = 0;
CALL_WITH_MAX(++a,b); // a输出7
CALL_WITH_MAX(++a,b+1); // a输出9
答案是完全不要写宏函数,而是用inline函数替代
template<typename T>
inline void callWithMax(const T& a, const T& b)
{
return a > b ? a:b;
}
此时inline callWithMax遵守作用域和访问规则,例如可以写一个class内的private inline函数。事实上,有了const, enum,和inline,宏在C++中只需要#ifdef/ #ifndef两种宏定义来防止重复编译。
- 对于单纯常量,最好以const对象或enum替换#define
- 对于宏函数,最好用inline函数替换#define
1.3 条款03:尽可能使用const
const的基础是语义是常量和只读。 当const修饰某变量时,该变量只能在内存中产生一份(常量),且不能修改(只读)。注意当const修饰类成员变量,表示object的常量。如果变成class常量,需要使用static const。
此外,const还能修饰指针,分别可以对指针本身或指针指向对象的修饰。const修饰函数本质上是修饰指针。
char greeting[] = "Hello";
char *p = greeting;
const char* p = greeting; // const data, non-const pointer
char* const p = greeting; // const pointer, non-const data
const char* const p = greeting; // const pointer, const data
const char *p; // 意义相同,const均在*左边,被指物为常量且不可修改
char const* p;
如果const出现在左边,表示被指物为常量,出现在右边,表示指针本身是常量。
对STL来说,container迭代器实际是以指针为根据塑造出来,并类似指针设置了*,++等操作符,在实际使用迭代器时,可以当作T*看待。
std::vector<int> vec; // 通过int具现vector模板,vector的迭代器实际就是指针,其他container可能是类似指针的类
const std::vector<int>::iterator iter = vec.begin(); // iter作用相当于T* const指针
*iter = 10;
++ iter; // 错误! iter 是const
std::vector<int>::const_iterator cIter = vec.begin(); // cIter作用像const T*
*cIter = 120; // 错误,cIter是const data
++ cIter; // 正确
令函数返回一个常量值,使返回后的值不能被修改。注意const修饰返回值,在调用函数中也必须用const进行接收。
class Rational { ... }
class Rational operator* (const Rational& lhs, const Rational& rhs);
// operator*,返回值为const,可以防止
Rational a,b,c;
(a*b) = c; // 设置const使这种操作不能通过编译 ,因为返回值(const+引用)不能改动
class TextBlock {
public:
...
const char& operator[] (std::size_t position) const // const function, const return value
{ return text[position]; }
char& operator[] (std::size_t position) // non-const 对象
{ return text[position]; }
private:
std::string text;
}
TextBlock tb("Hello");
std::cout<<tb[0]; //
const TextBlock ctb("World"); // 用const接收 用const修饰返回值
std::cout<< ctb[0];
tb[0] = 'x'; // ok
ctb[0] = 'x'; // 错误,ctb是const,只读
float Point3d::magnitude3d() const { ... }
// 以下是member function被内化为nonmember形式的转化步骤
// 1. 安插一个额外的参数到member fucntion中,使class object可以调用该函数。
Point3d
Point3d::magnitude( Point3d *const this )
// 若member function为const则变成如下,这也说明const修饰后函数不能改变member data
// Point3d *const this 指针指向地址不能变,但指向的对象Point3d可以变。
// const Point3d *const this 指针指向的地址,指向的对象都不能变。
同时注意char& 需要返回引用,而不是char。如果返回char,则tb[0]操作的实际是tb的副本,对实际的tb并没有操作。 时刻注意C++当前变量名是引用还是变量。
如上,const可以施加于成员函数。在深度探索C++对象模型中有讲,const修饰成员函数实际是修饰传入的this指针指向的对象。显然const修饰成员函数导致this对象被const,从而无法修改对象内任何non-static对象。
但注意对于object内部的指针对象,const成员函数保证不能修改指针,但指针指向的对象往往可以修改。
class CTextBlock {
public:
...
char& operator[] (std::size_t position) const
{ return pText[position]; }
private:
char* pText; }
};
// 尽管operator[]用const修饰,但pText指向对象仍然可以修改
const CTextBlock cctb("Hello");
char* pc = &cctb[0];
*pc = 'J'; // 仍然可以修改*pc,即pc指向对象。虽然operator[]设置为const
对类成员变量用mutable关键字修饰使其可以在const修饰的member function中修改。如:
class CTextBlock {
public:
...
std::size_t length() const;
private:
char* pText;
mutable std::size_t textLength; // 用mutable修饰后可以在const memberfuntion中修改
mutable bool lengthIsValid;
};
std::size_t CTextBlock::length() const
{
if (!lengthIsValid) {
textLength = std::strlen(pText);
lengthIsValid = true;
}
return true;
}
注意const函数不可调non-const函数,因为const承诺不改变对象的逻辑状态,当const函数调用非const函数时,承诺不改动的对象改动了。同时const与non-const可以互相转型。
class TextBlock {
public:
const char& operator[] (std::size_t position) const
{
return text[positon];
}
}
char& operator[] (std::size_t position)
{
return const_cast<char&>{ // 将返回值const去掉
static_cast<const TextBlock&>(*this)[position]); // 对*this加上const以调用operator[] const
}
};
如上,打算让non-const调用const,为避免递归调用自己,必须明确指出调用const operator[]。因此需要将*this从原始类型TextBlock& 转型为const TextBlock&,最后再使用const_cast移除const。
总结
- 声明为const可帮助编译器侦测出错误用法,const可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体
1.4 条款04:确定对象被使用之前以先被初始化
C++对象模型中有讲,C++构造对象的规则是满足编译器基本需要,包括合成default constructor。这往往导致C++对象不明确的初始化成员变量。一般的,正如条款一把C++分成四种语言的集合,当使用C part of C++时,初始化可能会招致运行期成本,就不保证初始化。而使用STL part of C++,例如vector,会保证初始化。
最佳处理方法是,永远在使用对象之前将它初始化。对于内置类型,例如int, 指针,必须手工完成初始化。
使用成员初始列对成员变量进行初始化
// 基于成员初始列初始化的版本
ABEntry:ABEntry (const std:string& name, const std::string& address, const std::list<PhoneNumber>& phones)
: theName(name), theAddress(address), thePhones(phones), numTimesConsulted(0)
{}
// 基于赋值的版本
ABEntry:ABEntry (const std:string& name, const std::string& address, const std::list<PhoneNumber>& phones)
{
theName = name;
theAddress = address;
thePhones = phones;
numTimesConsulted = 0;
}
注意区分赋值和初始化的区别,基于赋值的版本实际上是先调用default constructor赋初值之后 ,再利用参数赋予新值。此外还会产生临时对象,效率低。事实上,基于赋值版本先用了default构造函数,后用copy assigment, 成员初始列只用了copy构造函数。如果是const, reference,它们只有初值,不能被赋值。
注意总是要在成员初值列中列出所有成员变量,不要遗漏。当然存在很多成员变量,可以合理的采用赋值操作,并且将赋值操作移往某个函数(通常是private)供所有构造函数调用,但比起由赋值操作完成的“伪初始化”,通过成员初始列完成的“真正初始化”往往更加可取。
此外,成员初始列最好以声明次序为次序。
不同编译单元内定义non-local static对象的初始化次序
以上:将内置型成员变量明确初始化,确保构造函数用成员初值列初始化base classes和成员变量,这样只剩下唯一的事情需要操心。不同编译单元内定义non-local static对象的初始化次序。
static对象,其寿命从构造出来到程序结束,因此排除static和heap-based对象。这些对象包括global对象,定义于namespace作用域的对象,class内,函数内,以及作用域声明为static的对象。其中函数内的static对象称为local static对象,其他static对象称为non-local static对象。程序结束时static对象自动销毁,也就是说析构函数会在main()结束时自动调用。
编译单元,translation unit,指产出单一目标文件single object file的源码,基本上是单一源码文件加上含入的头文件。往往每一个cpp 文件就是一个编译单元。编译器不会去编译.h 或者.hpp文件
真正的问题是,若某一编译单元内某个non-local static对象的初始化动作,使用了另一编译单元的某个non-local static对象,它用到的这个对象可能没有初始化。
例如,假设一个FileSystem class,它需要产生一个特殊对象位于global或namspace作用域中,以便客户使用
class FileSystem {
public:
std::size_t numDisks() const; // 众多 成员函数之一
...
}
extern fileSystem tfs; // 预备给客户使用对象
显然客户使用tfs时,并不能保证tfs已经被初始化。甚至使用模板后不能寻找次序。解决方式是将non-local static对象搬到所属函数内,对象在函数内声明为static。这些函数返回一个reference指向所含的对象,用户调用这些函数。C++保证,函数内local static对象会在函数被调用期间,首次遇到对象定义式被初始化。因此调用这些函数就获得了初始化保证。
class FileSystem {...};
FileSystem& tfs()
{
static FileSystem fs;
return fs;
}
class Directory {...}
{
std::size_t disks = tfs().numDisks(); // 调用tfs()函数,获得指向对象的reference,而不是对象本身
}
以上reference-returning在单线程会有比较好的效果,但必须保证对象A先于对象B初始化,且A的初始化不受制于B的初始化。避免在对象初始化之前过早使用需要做到
- 手工初始化内置non-member对象。
- 使用成员初值列member initialization lists处理对象所有成分。尽量不在构造函数内部使用赋值assignment操作。
- 在初始化次序不确定下加强设计。在跨编译初始化次序问题,用local static对象替换non-local static对象。
二、构造/析构/赋值运算
2.1 条款05:了解C++默默编写并调用哪些函数
一般的,对于空类,C++编译器会声明编译器版本的一个copy构造函数,一个copy assignment操作符和一个析构函数。若没有声明构造函数,编译器也会声明一个default 构造函数。这些函数都是public且inline的。(探索C++对象模型说,应该是编译器按条件添加以上函数)
class Empty { };
// 等价下于写下如下代码
class Empty {
public:
Empty() { ... }
Empty(const Empty& rhs) { ... }
~Empty() { ... }
Empty& operator= (const Empty& rhs) { ... }
};
在只有当这些函数被需要,被调用时,他们才会被编译器创建出来。 例如
Empty e1; // default构造函数
Empty e2(e1); // copy构造函数
e2 = e1; // copy assignement操作符
- 编译器产生的析构函数是non-virtual的,除非这个class的base class自身声明有virtual析构函数
- copy构造函数和copy assignment操作符,编译器创建的版本只是单纯地将来源对象的每一个non-static成员变量拷贝到目标对象。
template <typename T>
class NamedObject {
public:
NamedObject (const char* name, const T& value);
NamedObject (const std::string& name, const T& value);
...
private:
std::string nameValue;
T objectValue;
};
以上NameObject声明了构造函数,编译器不再为其创建default构造函数。这很重要,因为如果设计一个class,构造函数要求实参,就无须担心编译器会添加一个无参构造函数(即default构造函数)而掩盖你的版本。
NamedObject没有声明copy构造函数,assignment操作符,如果它们被调用,编译器会为它创建那些函数。以拷贝构造函数为例
NamedObject<int> no1("Smallest Prime Number",2); // 构造函数
NamedObject<int> no2(no1); // 拷贝构造函数
注意拷贝构造函数即以no1.nameValue和no1.objectValue为初值设置no2.nameValue和no2.objectValue。两者之中,nameValue为string, 标准string有copy 构造函数。因此nameValue初值设置方法是调用string的拷贝函数。而objectValue被具现为int类型,为内置类型。因此no2.objectValue会以拷贝no1.objectValue内的每一个bits来完成初始化。
Copy assignment行为类似于copy构造函数。但注意在赋值时需要确保行为合法,编译器才会生成copy assignment。例如
template <typename T>
class NamedObject {
public:
NamedObject (const char* name, const T& value);
NamedObject (const std::string& name, const T& value);
...
private:
std::string& nameValue; // 如今是一个reference
const T objectValue; // 如今是一个const
};
std::string newDog("Persephone");
std::string oldDog("Satch");
NamedObject<int> p(newDog, 2);
NamedObject<int> s(oldDog, 36);
p = s; // 会报错
如上,由于nameValue是一个reference,objectValue是一个const,也就是说reference自身不可以改动,不可以赋值(只能有初值)。copy构造函数意义是设置初值,而copy assignment是赋值。
面对以上问题,C++的响应是拒绝编译赋值操作。还有情况是当base class的copy assignment设为private,编译器将拒绝为derived class生成copy assignment操作符。原因显然是编译器不能允许derived class调用base class的private成员。
2.2 条款06:若不想使用编译器自动生成的函数,应该明确拒绝
有时候,我们需要阻止cpoy构造和copy assignment,以确保对象的独一无二,不能创造对象的副本。
class HomeForSale { ... }
HomeForSale h1;
HomeForSale h2;
HomeForSale h3(h1); // 企图拷贝h1,不该通过编译
h1 = h2; // 企图拷贝h2, 也不该通过编译
问题在于如果不声明copy构造函数和copy assignment操作符,编译器可能产生一份。解决办法是将copy 构造函数和copy assignment声明为private。借助声明成员函数,阻止了编译器暗自创建专属版本,而令函数为private,得以阻止外界调用。
一般以上做法不绝对安全,因为member函数和friend函数还是可以调用。此时,可以将成员函数定义为private但不去实现它们。
class HomeForSale {
public:
...
private:
...
HomeForSale( const HomeForSale& ); // 只有声明,不用写参数名称,因为没想去实现使用
HomeForSale& operator=( const HomeForSale& );
};
有了以上定义,当企图拷贝HomeForSale对象,编译器会阻挠。如果在member或friend函数内那么做,轮到链接器发出抱怨(因为只有声明没有定义)。
将连接期错误移至编译期,可以使用继承结构实现。将copy构造函数设置为private并继承之: 继承结构中member和friend函数不能调用private成员
class Uncopyable {
protected: // 允许构造和析构
Uncopyable() {}
~Uncopyable() {}
}
private: // 阻止copy
Uncopyable( const Uncopyable&);
Uncopyable& operator= (const Uncopyable&);
};
class HomeForSale: private Uncopyable {
...// 不再声明copy构造函数和copy assignment操作符
};
基于以上继承结构,HomeForSale不可以调用copy构造和copy assignment。包括member成员和friend函数。
2.3 条款07:为多态基类声明virtual析构函数
C++多态指的是动态绑定,条件有二:
- 存在virtual 函数
- 返回base class指针,指向derived class对象
class TimeKeeper {
public:
TimeKeeper();
~TimeKeeper();
...
};
class AtomicClock: public TimeKeeper { ... }; // 原子钟
class WaterClock: public TimeKeeper { ... }; // 水钟
class WristWatch: public TimeKeeper { ... }; // 腕表
TimeKeeper* getTimeKeeper(); // 返回一个指针,指向TimeKeeper的派生类对象
在继承结构中,客户往往只需要关心TimeKeeper,不用关心具体的类型(子类)。可以设计工厂函数,返回一个指针,指向TimeKeeper派生类动态分配对象。
这时在delete中存在问题,即derived对象需要通过base指针删除。这种情况下,需要给base class设置virtual析构函数才能确保同时销毁base对象和derived对象。
注意,只有当C++试图多态时才有必要设置析构函数为virtual,倘若class不包含virtual函数,令其析构函数为virtual反而徒然增加对象体积。
同理,尽量不要继承一个non-virtual析构函数的class,可能出现问题
class SpecialString: public std::string {... }; // 馊注意,string析构函数为non-virtual
// 上述情况继承non-virtual 析构,下列代码可能会出现问题
SpecialString* pss = new SpecialString("Impending Doom");
std::string* ps;
ps = pss; // SpecialString* => std::string*
delete ps; // 事实上没有调用SpecialString析构函数
C++并未提供类似Java final 或C# sealed禁止派生的机制。同样分析使用与STL如vector等标准容器。但注意到base class的设计目的并不都是多态用途,例如标准string和STL。这种设计不考虑多态的继承也就不需要定义virtual析构函数。
综上:
- polymorhic多态性质的base class应该声明一个virtual析构函数。如果class含有virtual函数它就应该有一个virtual析构函数
- Class设计目的如果不是作为base class使用,或者不考虑多态性。就不该声明virtual析构函数
2.4 条款08:别让异常逃离析构函数
以上原因很简单,析构过程有异常抛出容易导致不明确行为,对象有可能没有析构成功。一般
例如数据库连接class
class DBConnection {
public:
...
static DBConnection create();
void close();
~DBConn()
{
db.close();
}
private:
DBConnection db;
};
为确保客户不忘记在DBConnection对象上调用close,一个合理的想法是创建一个用来管理DBConnection资源的class,并在其析构函数中调用close。
上述代码调用可能导致异常,DBConn析构函数就会传播该异常,这可能抛出意外。
重新设计DBConn接口,使其客户有机会对可能出现的问题作出反应。
class DBConn {
public:
void close()
{
db.close();
closed = true;
}
~DBConn()
{
if (!closed) {
try {
db.close();
}
catch (...) {
// 记录对close的调用失败
}
}
}
private:
DBConnection db;
bool closed;
}
以上将close调用从DBConn析构函数手上移到DBConn客户身上 (析构函数中是双保险调用),就算发生异常也不能从析构函数中抛出,因为析构函数发生异常总会带来风险。
有可能发生异常的函数,例如数据库连接,文件连接,网络连接等,不要放在析构函数中,而是提供一个普通函数执行该操作。
2.5 条款09:绝不在构造和析构过程中调用virtual函数
上述条款有讲到,当需要C++多态结构时,需要将析构函数设置为virtual。本条款则是,不应该在构造函数和析构函数中调用virtual函数。调用virtual函数的做法并非有误,而是并不会带来多态效果,与普通函数相比徒然增加复杂性。
当derived class的构造函数调用virtual时,实际上还是调用base class的内容不会产生多态。因为base class先于derived class构造完成,base class构造期间virtual函数绝不会下降到derived class层。或者说,base class构造期间,virtual函数不是真正意义的virtual函数。
同理对于析构函数,当到达base class时,derived class析构过程早已执行完毕,virtual函数实际等同于base class的普通函数。
在构造期间无法使用virtual函数从base class向下调用,但可以藉由derived class将必要构造信息向上传递至base class构造函数。
class Transaction {
public:
explicit Transaction(const std::string& logInfo);
void logTransaction(const std::string& logInfo) const; // 设置为non-virtual
};
Transaction::Transaction(const std::string& logoInfo)
{
logTransaction(logInfo);
}
class BuyTransaction: public Transaction {
public:
// 将log信息传递给base class构造函数
BuyTransaction( parameters) : Transaction(createLogString( parameters )) { ... }
private:
static std::string createLogString( parameters );
};
2.6 条款10: 令operator= 返回一个reference to *this
注意到这只是一个协议,并无强制性,不遵守代码一样可以通过编译。包括+=,=等相关运算都应该遵守返回*this的约定
class Widget {
public:
Widget& operator=(const Widget& rhs)
{
return *this;
}
Widget& operator+=(const Widget& rhs)
{
return *this;
}
};
2.7 条款11:在operator=中处理自我赋值
自我赋值发生在对象被赋值给自己时
class Widget { ... };
Widget w;
...
w = w; // 赋值给自己
a[i] = a[j]; //有可能自己赋值给自己
*px = *py; // 如果px,py指向一个东西也是自我赋值
事实上某段代码操作pointer或reference用来指向多个相同类型的对象,就需要,考虑这些对象是否为同一个。而如果两个对象来自同一继承体系,它们甚至不需声明为相同类型就可能造成别名。
Widget& Widget::operator=(const Widget& rhs)
{
delete pb; // 有可能把rhs也同时delete
pb = new Bitmap(*rhs.pb);
return *this;
}
有可能*this与rhs是同一个对象,那么delete pb同时吧rhs对象也delete了。
传统的解决办法
Widget& Widget::operator=(const Widget& rhs)
{
if (this == &rhs) return *this; // 如果是自我赋值,不做任何事
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
问题在于,倘若new Bitmap导致异常,依然存在指针pb指向一块被删除的Bitmap,这样的指针有害。
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap* pOrig = pb; // 记住原来的pb
pb = new Bitmap(*rhs.pb);
delete pOrign; // new成功新的Bitmap,再删去旧的
return *this;
}
// 使用copy and swap技术
Widget& Widget::operator= (const Widget& rhs)
{
Widget temp(rhs); // 为rhs制作一份副本
swap(temp); // 将*this数据和上述副本的数据进行交换
return *this;
}
现在,如果new Bitmap出现异常,pOrig仍然会维持原状。
- 确保当对象自我赋值时operator=有良好行为,其中技术包括对象地址,语句顺序,copy-and-swap
- 确保任何函数如果操作一个以上对象,而其中多个对象是同一个对象时,行为仍然正确。
2.8 条款12: 复制对象时勿忘其每一个成分
设计良好的面向对象系统会将对象的内部封装起来,只留两个函数负责对象拷贝,即copy构造函数和copy assignment。条款5观察到编译器在必要时候为class创建copying函数,并将被拷对象的所有成员变量做一份拷贝。
如果声明自己的拷贝函数,等同于告诉编译器并不需要缺省实现的行为,此时代码出错时编译器往往不会给予提示。往往这时,变量复制不全时编译器并不会提醒。
// 第一种情况,变量复制不全
class Date { ... };
class Customer {
public:
...
private:
std::string name;
Date lastTransaction;
};
Customer& Customer::operator=(const Customer& rhs)
{
name = rhs.name; // 没有复制Date lastTransaction
return *this;
}
注意以上复制对象成分不全,编译器不大可能提醒。但一旦发生继承,将引发危机
class PriorityCustomer : public Customer {
public:
PriorityCustomer(const PriorityCustomer& rhs);
PriorityCustomer& operator= (const PriorityCustomer& rhs);
private:
int priority;
};
PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
priority = rhs.priority;
return *this;
}
似乎PriorityCustomer的copying函数好像复制了PriorityCustomer的每一样东西,但注意它继承了Customer,但这些成员变量并没有被复制。实际上,PriorityCustomer对象的Customer成分会被不带实参的Customer default构造函数初始化。default构造函数对name和lastTransaction会执行缺省的初始化动作,并不会用rhs赋值。
因此当为derived class撰写copying函数时,需要很小心的复制其base class部分。那些成分往往是private,无法直接访问,应该让derived class的copying函数调用相应的base class函数
PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
Customer::operator=(rhs); // 对base class成分进行赋值
priority = rhs.priority;
return *this;
}
当编写一个copying函数时,确保
- 复制所有local成员变量
- 调用所有base class内的适当copying函数
三、资源管理
所谓资源,一旦用了它,将来必须还给系统。如果不这样,糟糕的事情就会发生。C++程序最常使用的资源就是动态分配内存(如不归还会导致内存泄漏),除内存外,其他常见的资源还包括文件描述器(file descriptors),互斥锁(mutex locks),数据库链接,图形界面的字型和笔刷,网络sockets。不论哪一种资源,重要的是,不再使用时必须归还给系统。
本部分基于对象的内存管理方法,建立在C++对构造函数,析构函数,copying函数的基础上。
3.1 条款13:以对象管理资源
假设使用一个投资行为(例如股票,债券)的程序库,各种投资继承自一个root class Investment
class Investment { ... };
// 通过工厂函数返回指针,指向Investment继承体系内的动态分配对象
Investment* createInvestment();
void f()
{
Investment* pInv = createInvestment();
delete pInv;
}
void f()
{
std::auto_ptr<Investment> pInv(createInvestment());
}
std:auto_ptr<Investment> pInv2(pInv1); // 现在pInv2指向对象,pInv1变成NULL
pInv1 = pInv2; // pInv1指向对象,pInv2变成NULL
std::auto_ptr<int> spi(new int[1024]);// 在array上使用智能指针,馊注意
使用delete手动析构存在问题,f有可能无法删除对象,且f并非总能执行到delete,而一旦失误影响是巨大的。为确保createInvestment返回的资源总是被释放,需要把资源放到对象内,当控制流离开f,该对象的析构函数会自动释放那些资源,无须调用delete。
标准库提供的auto_ptr正是一个类指针对象,也就是所谓“智能指针”,auto_ptr对象的析构函数自动对所指对象调用delete。
以对象管理资源的两个关键想法
- 获得资源后立刻放进管理对象内,以上createInvestment返回的资源被当作管理者auto_ptr的初值。或称之为“资源取得时机便是初始化时机”(Resource
Acquisition Is Initialization RAII) - 管理对象运用析构函数确保资源被释放。显然管理对象在栈上创建,控制流离开后自动调用析构函数。
由于auto_ptr被销毁时自动删除所指对象,因此注意一定别让多个auto_ptr指向同一对象。对象被删除一次以上程序将驶向“未定义行为”。
预防这个问题,auto_ptr具有性质:
若copy构造函数或copyassignment,原先的auto_ptr赋值NULL,被赋值的指向对象。由于以上特征,受auto_ptr管理的资源绝对没有一个以上的auto_ptr同时指向。这有时不能满足要求,例如STL容器需要正常的复制行为,这些容器容不得auto_ptr。
auto_ptr的替代方案是“引用计数型智慧指针”(reference counting smart pointer; RCSP)。RCSP也是一个智能指针,持续追踪有多少对象指向某资源并在无人指向它时自动删除该资源。实际上类似垃圾回收。shared_ptr实际上是这种,可以实现多个指针指向同一对象。
注意auto_ptr和share_ptr两者在析构函数内做delete而不是delete[],意味着在动态分配的array身上使用auto_ptr或share_ptr是个馊注意。
有时候这些预置class是无法妥善管理的,既然如此就要精巧制作资源管理类。综上
- 为防止资源泄漏,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源
- 两个常使用的RAII class是share_ptr和auto_ptr,前者通常为较佳选择,因为auto_ptr复制动作会令被复制物指向NULL。
3.2 条款14:在资源管理类中小心copying行为
条款13中“资源取得时机就是初始化时机”,描述了auto_ptr和share_ptr使用在heap-based资源上。但并非所有资源都是heap-based,有时候需要建立自己的资源管理类。
假设有类型为Mutex的互斥器对象mutex objects,共有lock和unloc两函数可用。确保不会忘记将被锁住的Mutex解锁,建立一个class管理锁。这样的class基本结构由RAII守则支配,也就是资源在构造期间获得,在析构期间释放。
class Lock {
public:
explicit Lock(Mutex* pm) : mutexPtr(pm){
lock(mutexPtr); // 显示转换,获得资源
}
~Lock() { unlock(mutexPtr); } // 释放资源
}
private:
Mutex* mutexPtr;
};
Mutex m; // 定义互斥器
{
Lock m1(&m); // 构造期间获取资源,锁定互斥器,符合RAII方式
} // 区块末尾,调用析构函数自动释放资源,解除互斥器锁定
Lock m2(m1); // 将m1复制到m2上,发生什么事??
但问题在于当一个RAII被复制,会发生什么。一般有两种可能
- 禁止复制。许多时候RAII被复制并不合理,例如锁一般不会有多个对象获取资源。比如auto_ptr
- 底层资源的引用计数法。即直到对象最后一个使用者之后再销毁,复制对象令对象内部引用计数器递增。例如share_ptr。
注意当复制资源管理对象时,应该同时复制其包裹的资源。也就是深拷贝。往往当包含一个指针和一个指向一块heap内存时,指针和所指内存都会被制作出一个复件,也就是深度复制行为。
3.3 条款15: 在资源管理类中提供对原始资源的访问
尽管资源管理类可以很好的管理资源,排除资源泄漏等问题。但有时API只涉及资源,需要资源管理类提供包裹资源的访问。
std::shared_ptr<Investment> pInv(createInvestment());
// 希望某个函数处理Investment对象
int dayHeld(const Investment* pi); // 返回投资天数
int days = daysHeld(pInv); //错误
int days = daysHeld(pInv.get()); // ok
以上调用通不过编译,因为daysHeld需要Investment * 指针,传入的却是shared_ptr这个对象。这时候需要一个函数可将RAII class对象(上述为shared_ptr)转换成其内之原始资源(上述为Investment * )。有两个做法,显式转换和隐式转换。
shared_ptr和auto_ptr都重载的指针取值操作符(operator ->和operator * ),他们允许隐式转换至原始指针。同时提供一个get成员函数,用来执行显式转换,也就是返回原始的内部指针。
如果是自定义资源管理类,可以这样写显示转换
class Font {
public:
explicit Font(FontHandle fh) :f(fh) {};
~Font() { relaseFont(f); } // 释放资源
FontHandle get() const { return f; } // 显式转换函数
private:
FontHandle f; // 原始资源
}
以上,资源管理类Font,可以通过调用get()来获取内部的原始资源。
3.4 条款 16: 成对使用new和delete要采取相同形式
简而言之:
如果在new中使用[],必须在delete表达式也使用[]。反之亦然。
delete []认定指针只想一个由对象组成的数组。注意尽量不要对数组形式使用typedef动作。
typedef std::string AddressLines[4]; // 进来不要这样做
std::string* pal = new AddressLines; // 等同于new string[4]
delete [] pal; // 需要匹配delete []
上述AddressLines可尽量定义为vector
3.5 条款17:以独立语句将new的对象置入智能指针
假设我们有个函数用来揭示处理程序的优先权,另一个函数用来进行处理
int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);
processWidget(new Widget, priority()); // 不能通过编译
processWidget(std::shared_ptr<Widget>(new Widget), priority()); //可能泄漏资源
上述第一个调用不能通过编译,shared_ptr构造函数需要一个原始指针(raw pointer),但构造函数一个explicit构造函数,无法进行隐式转换。
然而第二个虽然可以通过编译,但有可能泄漏资源。
std::shared_ptr(new Widget)由两部分组成
- 执行new Widget
- 调用std::shared_ptr构造函数
此外还会执行priority()。问题是,有可能执行new Widget成功,但std::shared_ptr构造失败,new Widget返回指针遗失,引发资源泄漏。
避免这种问题方法很简单,使用分离语句
std::shared_ptr<widget> pw(new Widget);
processWidget(pw, priority());
上述也说明,函数传递的实际参数不宜过于复杂。
四、设计与声明
本部分主要是C++接口的设计和声明。也就是“让接口容易被正确使用,不容易被误用”,接口分为class, function,和template。
4.1 条款18 让接口容易被正确使用,不易被误用
C++ function接口,class接口,template接口,每一种接口都是客户与代码互动的手段。理想上,如果客户企图使用某个接口却没有获得预期的行为,这个代码不该通过编译。欲开发容易被正确使用,不容易被误用的接口,首先必须考虑客户可能做出什么样的错误。例如 、
class Date {
public:
Date(int month, int day, int year);
};
应当考虑日期,月份的限制。比如Date(2,30,1995)不能通过编译。可以导入简单的外覆类型wrapper types来区别天数、月份和年数,然后于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(Month(3), Day(30), Year(1995)); // 类型正确
然而相比于struct,用class并封装其内数据要好。同时限制其值, 例如一年只有12个月份。办法之一是利用enum表现月份,但enum不具有封装性。比较有效的方法是预先定义所有有效的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));
"以函数替换对象表现特定月份”,原因是non-local static对象的初始化有可能出问题,阅读条款4恢复记忆。
任何接口如果要求客户必须做某些事,就有不正确使用的倾向,因为客户可能会忘记做那件事。例如条款13导入了一个factory函数,它返回一个指针指向Investment继承体系内的一个动态分配对象
Investment* createInvestment();
std::shared_ptr<Investment> createInvestment(); // 直接返回智能指针
std::shared_ptr<investment> pInv(static_cast<Investment*>(0),
getRidOfInvestment); // 初始化,将null转换成Investment类型的指针
为避免资源泄漏, createInvestment返回的指针被删除时,至少应开启两个客户错误可能:没有删除指针,或删除同一个指针超过一次。
同理,为了防止客户忘记使用智能指针,直接返回智能指针。shared_ptr的某个构造函数接受两个实参,一个是被管理的指针,另一个是引用次数为0时被调用的删除器。
Boost的shared_ptr是原始指针的两倍大,以virtual形式调用删除器,多线程修改引用次数。显然它比原始指针大而慢,但是降低用户错误方面的成效确实显著。
综上
- 促进正确使用的方法包括接口的一致性,与内置类型的行为兼容
- 阻止误用的方法包括建立新类型、限制类型上的操作、束缚对象值、以及消除客户的资源管理责任
4.2 条款19:设计class犹如设计type
C++就像其他OOP(面向对象编程)语言一样,当定义一个新class,也就定义了新的type.重载函数和操作符,控制内存的分配和归还,定义对象的初始化和终结…全都在你手上。设计高效的class首先面对的问题
- 新type对象如何被创建和销毁。这影响到class构造函数和析构函数以及内存分配函数和释放函数的设计(operator new,operator new[], operator delete, operator delete[] )
- 对象的初始化和对象赋值该有什么样的差别。这决定构造函数,copy构造函数和assignment赋值操作符的行为。注意不要混淆初始化和赋值
- type对象如果passed by value值传递,意味着什么。copy构造函数定义pass by value的实现
- 新type的合法值。对class成员变量而言,通常只有某些数值是有效的,这些约束条件也决定了成员函数(特别是构造函数,赋值操作符,setter函数)必须进行的错误检查工作。
- type是否需要某个继承图。如果集成某些既有的class,就会受到那些class设计的束缚,特别是virtual。
- type需要什么样的转换。如果希望T1之物被隐式转换为T2之物,就必须在class T1内写一个类型转换函数(operator T2)或在class T2内写一个non-explicit-one-argument可被单一实参调用的构造函数。如果存在explicit就必须专门执行转换的函数。
- 什么样的操作符和函数对type合理
- 谁该去用问type成员,从而设定public, protected, private成员。以及friend函数
- type有多么有一般化。定义type家族,如果需要参数一般化则需要定义class template
- 真的需要type吗?如果定义新的derived class以便为既有的class添加机能,那non-member或template更能达到目标。
以上问题不容易回答,因此定义高效的class是一种挑战。然而如果能设计出C++内置类型那样的用户自定义class,一切汗水便都值得。
请记住,class的设计就是type的设计,定义新type之前请确定以上所有问题。
4.3 条款20:尽量以pass-by-reference-to-const替换pass-by-value
默认情况下C++以by value的方式(一个继承自C的方式)传递对象至函数。默认的,函数参数都是以实际参数的副本作为初值,调用端所获得的亦是函数返回值的一个副本。这些副本由对象的copy构造函数生成,这可能使pass by value成为成本高的操作。
考虑例子
class Person {
public:
Person();
virtual ~Person(); // 多态结构 virtual析构
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);
// pass by reference-to-const
bool validateStudent(const Student& s);
事实上,Student中有两个string对象,Person也是。以by value形式传入Student s实际上调用一次Student copy函数,一次Person copy函数,四个string copy函数共六个。以上by value传值总体成本为六个copy构造函数,六个析构函数。
当使用pass by reference const,没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。by reference传递声明为const是很有必要的,不然可能会被修改对象。
以by reference方式传递参数也可以避免值传递可能带来的slicing对象切割问题。如下
class Window {
public:
std::string name() const; // 返回窗口名称
virtual void display() const; // 显示窗口,内容
};
class WindowWithScrollBars: public Window {
public:
virtual void display() const;
};
// 打印函数
void printNameAndDisplay(Window w) // 参数可能被切割
{
std::cout << w.name();
w.display();
}
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
传入的是子对象WindowWithScrollBars,但到函数中pass by value只会表现的是个Window对象,所有WindowWithScrollBars的特化信息将会被删除,derived class到达函数时向上转型为base class。
解决切割问题办法就是,通过传引用传入const Window& w。这时传入的是什么类型,w就表现出那种类型。
尽量以pass-by-reference-to-const替换pass-by-value。前者通常比较高效。但该规则不适用于内置类型,以及STL的迭代器和函数对象。习惯上STL的迭代器被设计为pass-by-value,这种规则也是条款1:规则之改变取决于使用哪一部分C++的体现。
4.4 条款21:必须返回对象时,别妄想返回其reference
在坚定追求pass-by-reference的纯度中,可能犯下致命错误:返回reference指向其实并不存在的对象。这意味着不是一切都可能返回reference。
简而言之,若函数返回指针或者引用,一定要确保返回之后指针指向的对象依然存在。
在stack中创建对象
const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d); // 糟糕的代码
return result;
}
上述代码中result是个local stack对象,而local对象在函数退出前就被销毁了。因此返回结果实际是“无定义行为”十分危险。
在heap中构造对象
const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result;
}
// 下列代码会导致内存泄漏
Rational w,x,y,z;
w = x * y * z; // 等价于operator*(operator(x,y),z)
以上问题是,谁该对new出的对象实现delete。事实上,以上代码很容易导致内存泄漏。例如连乘操作符new了两个对象,但无法对这两个对象使用delete。
显然以上不论on-the-stack还是on-the-heap,返回引用都很容易导致错误。实际上由于要建立对象,这时候返回引用效率不必直接返回对象好多少。
结论就是必须返回对象时,别妄想返回其reference。 当你必须在返回一个reference和返回一个object之间抉择时,你的工作就是挑出行为正确的那个。就让编译器厂商为尽可能降低成本奋力吧,你只需要挑选行为正确的即可。
4.5 条款22:将成员变量声明为private
- 语法一致性,如果成员变量不是public而是private或protected,客户唯一能够访问对象的办法就是通过成员函数,从而在语法上有一致性。
- 通过函数访问,可以实现“不准访问”,“只读访问”,“读写访问”等控制。细分划分控制可以避免对每个成员变量设置一个getter函数和setter函数。
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; // 惟写访问
};
- 封装。以函数访问成员变量,日后可以替换这个成员变量,而class客户不会知道class内部的变化。public意味着不封装,不封装意味着容易被破坏,代码依赖性强,不容易维护,更新。
- 对于一个public成员变量,如果做出更改甚至取消了它。所有使用它的客户码都会被破坏;对于一个protected成员变量,更改后所有derived class会被破坏;对private,没有客户码会被破坏。 显然使用private更容易维护,客户只管使用对象,而对内部结构改变没有必要知道。
4.6 条款23:尽量以non-member, non-friend替换member函数
一个class表示网页浏览器
class WebBrowser {
public:
void clearCache(); // 清除缓存区
void clearHistory(); // 清除历史
void removeCookies(); // 清除cookies
// 一次执行以上三个动作,使用member 函数
void clearEverything();
}
// 使用non-member函数调用member函数实现上述三个动作
void clearBrowser(WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
以上,使用member函数clearEverything和non-member函数clearBrowser哪一个比较好呢?
从封装讨论:如果被封装,就不再可见,愈多东西被封装,愈少人可以看到它,从而有愈大弹性去改变它。因此封装可以实现,改变事物而只影响有限客户。
能够访问private成员变量的函数只有class 的member函数加上friend函数。non-member函数和non-friend函数能导致较大封装性,因为它不能增加能够访问class内之private变量的函数数量。 因此应尽量以non-member函数代替member函数。
一般的,可以将以上clearBrowser函数成为某工具类untity class的一个static member函数,只要它不是WebBrowser的一部分或其friend,就不会影响WebBrower的private成员的封装性。 在C++中一个比较自然的做法是将两者放在同一命名空间中
namespace WebBrowserStuff {
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
}
使用namespace原因在于namespace可以跨越多个源码文件而class不能。使用namespace可以提升扩展性而又避免编译依赖,如下
// 头文件 webbrowser.h 这个头文件针对class WebBrowser自身
namespace WebBrowserStuff {
class WebBrowser { ... }; // 核心机能
}
// 头文件webbrowserbookmarks.h
namespace WebBrowserStuff {
... // 与书签相关的便利函数
}
// 头文件 webbrowsercookies.h
namespace WebBrowserStuff {
... // 与cookie相关的便利函数
}
以上,把核心WebBrowser, 书签相关便利函数, cookie相关便利函数放在三个头文件。需要哪一个就#include哪一个,降低多个文件的编译相依关系。
三个函数放在同一个namespace中,通过namespace 可以轻松扩展便利函数。
实际上以上正是C++标准程序库的组织方式,标准库有大量头文件例如 等,同时每个头文件声明了std命名空间相关机能。当用户使用List只需要include 从而只对使用的小部分系统形成了编译相依。而通过声明std可以轻松扩展标准库文件的机能。而class必须整体定义,不能切割为片片段段,因为class不能直接在文件外访问。
利用namespace 客户也可以轻松扩展这些功能,例如WebBrowser客户决定写影像下载相关便利函数,只需要在WebBrowserStuff命名空间内建立一个头文件,内含函数生命即可。class不能提供这个性质,因为class是不能被客户扩展的,派生出的drived class也是次级身份,无法访问base class被封装的成分。
4.7 条款24: 如果某个函数的参数可能出现类型转换,设置这个函数为non-member
令class支持隐式转换通常是一个糟糕的注意,但也有例外,常见的是数值类型。例如int隐式转换为double。所谓隐式转换,一般出现在赋值,拷贝,初始化中,即不给系统提示具体类型直接转换。
以操作符号operator为例:
class Rational {
public:
// 构造函数不为explicit,允许int-to-Rational隐式转换
Rational(int number = 0, int denominator = 1);
int numerator() const;
int denominator() const;
private:
...
};
设置一个operator* 写成Rational成员函数
class Rational {
public:
const Rational operator* (const Rational& rhs) const;
};
// 测试
Rational oneEighth(1,8);
Rational oneHalf(1,2);
Rational result = oneHalf * oneEighth // ok
result = oneHalf * 2; // 很好, 发生隐式转换,const Rational temp(2);
result = 2 * oneHalf; // 错误
上述混合运算只有一半行得通。原因在于不存在2.operator*(oneHalf)。而前者出现了隐式转换int -> Rational。如果设置构造函数为explicit,则都不可以通过编译。
解决上述问题办法,使用non-member函数解决可能存在的隐式转换问题。
class Rational {
public:
...
};
const Rational operator* (const Rational& lhs, const Rational& rhs)
{
return Rational (lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator() );
}
result = oneHalf * 2; //
result = 2 * oneHalf; // 可以通过编译
4.8 条款25:考虑写出一个不抛异常的swap函数
该条款主要讲如何写一个好的swap函数,所谓swap置换两个对象值。缺省情况下swap动作可由标准程序库提供的swap算法完成,如下。
namespace std {
template<typename T>
void swap(T& a, T& b) // 置换a和b的值
{
T temp(a);
a = b;
b = temp;
}
}
以上要求T支持copying(一个拷贝构造函数,两个copy assignment操作符)。但这种实现方法对某些类型而言浪费太多资源,特别是对pimpl手法。
所谓pimpl手法(pointer to implementation),表示指针指向一个对象,该对象含真正数据,外层如同一个壳。例如
class WidgetImpl {
public:
...
private:
int a, b, c;
std::vector<double> v;
};
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator= (const Widget& rhs)
{
*pImpl = *(rhs.pImpl);
}
private:
WidgetImpl* pImpl; // 指针,指向真正数据对象 WidgetImpl
};
以上,置换两个WidgetImpl对象其实内部只需要交换指针。但缺省的swap会复制WidgetImpl和Widget内部的所有对象,效率很低。这时一般我们需要新设计swap函数。
一般的,由于内部指针pImpl为private成员,只能被member和friend函数访问。所以我们令Widget声明一个swap的public成员函数做真正的置换工作,然后将std::swap全特化,令它调用swap成员函数。
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator= (const Widget& rhs)
{
*pImpl = *(rhs.pImpl);
}
private:
WidgetImpl* pImpl; // 指针,指向真正数据对象 WidgetImpl
};
namespace std {
template<> // std的swap函数特化
void swap<Widget>(Widget& a, Widget& b)
{ a.swap(b); }
}
namespace std {
template<typename T> // 错误, C++不支持函数对class template特化
void swap<Widget<T>>(Widget<T>& a, Widget<T>& b)
{ a.swap(b); }
}
以上,Widget和WidgetImpl均为class,只需要在std中特化swap函数即可。而当Widget和WidgetImpl为template时,就不能用特化了。因为C++不支持function对 template偏特化。解决办法是使用重载
class Widget {
public:
void swap(Widget& other)
{
using std::swap;
swap(pImpl, other.pImpl); // 使用缺省swap函数置换指针
}
};
namespace std {
template<typename T>
void swap (Widget<T>& a, Widget<T>& b)
{ a.swap(b); }
};
以上重载std命名空间的swap函数,当传入Widget时,会调用以上写的swap,其他调用默认缺省的swap函数。这里使用重载而不是模板特化,原因是C++只允许对class template偏特化,在function template上行不通。
一般而言,重载function template没有问题。但std内容完全由C++标准委员会决定,即不可以添加新的template, class ,function到std里头,但客户可以全特化std里面的template。
因此我们声明一个non-member swap让它调用member swap,但不再将non-member swap声明为std::swap的特化版或者重载版本。而是置于新的命名空间WidgetStuff内。
namespace WidgetStuff {
template<typename T> // 内含swap成员函数
class Widget { ... };
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{ a.swap(b);
}
};
// 调用
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap;
swap(obj1, obj2); // 会自动对T型对象调用最佳swap版本
}
虽然swap函数没有定义在std命名空间内,但是不妨碍C++名称查找规则。即参数为Widget&时会优先调用命名空间WidgetStuff的swap,而不是缺省swap。但注意swap前不应该添加任何修饰符,例如std::swap。这时候C++会自动挑选最合适的进行调用。
综上:
- 如果swap的缺省实现对你的class或class template提供可以接受的效率,直接使用缺省swap即可。
- 如果swap缺省版的效率不足(基本意味着class或template使用了某种pimpl手法)则:
- 提供一个public swap成员函数,高效置换对象,且该函数不能抛出异常
- 在class或template命名空间内提供一个non-member swap,并令它调用上述swap成员函数
- 对于class,直接对non-member swap进行全特化;对于template,对non-member swap进行重载,并放在其他命名空间
五、 实现
5.1 条款26:尽可能延后变量定义式的出现时间
只要定义了一个变量而其类型带有一个构造函数和析构函数,那么当程序控制流到达这个变量定义式时,就得承受构造成本;变量离开作用域时,就得承受析构成本。应该避免这种效率支出。
std::string encryptPassword(const std::string& password)
{
using namespace std;
string encrypted; // 定义早了
if (password.length() < MinimumPasswordLength){
throw logic_error("Password is too short");
}
...
return encrypted;
}
显然如果有logic_error异常抛出,encrypted未被使用就被析构付出成本。因此最好将encrypted定义延到异常处理之后。
void encrypt(std::string& s); // 对s进行加密
std::string encryptPassword(const std::string& password)
{
... // 如上
std::string encrypted(password); // 通过copy构造函数
encrypt(encrypted);
return encrypted;
}
尽可能延后,具体来说,尽可能延后到给初值实参为止。尽可能使用一个copy构造函数代替default构造函数+赋值操作符。
// default构造函数+赋值操作符。
std::string encrypted;
encrypted = password;
// 一个copy 构造函数,效率高
std::string encrypted(password);
5.2 条款27:尽量少做转型工作
C++作为强类型语言,规则的设计目标就是保证类型错误不能发生。但是转型cast破坏了类型系统,可能导致各种麻烦。C/Java/C#这些语言的转型比较必要且难以避免,也不太危险,但C++不同。 转型语法通常有三种不同的形式,如下
// C风格的转型操作
(T) expression // 将expression转型为T
// 函数风格的转型操作
T(expression)
// 以下为新式转型
const_cast<T>(expression)
dynamic_cast<T> (expression)
reinterpret_cast<T> (expression)
static_cast<T> (expression)
四种新式转型各有不同的目的 - const_cast将对象的常量性转除,即const转为non-const - dynamic_cast主要用来执行安全的向下转型,仅支持多态向下转型,这是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。 - reinterpret_cast执行低级转型,实际动作和结果可能取决于编译器。这种转型有巨大危险,只在低级代码中有所应用。 - static_cast用来强制隐式转换,例如将non-const转换成const对象,int转为double,但无法将const转为non-const。
class Widget {
public:
explicit Widget(int size);
...
};
void doSomeWork(const Widget& w);
// 函数风格,以一个int转为Widget类型
doSomeWork(Widget(15));
// C++风格的static_cast,将int转为Widget类型
doSomeWork(static_cast<Widget>(15));
事实上,任何一个类型转换,不管是通过转型操作进行的显式转换,或通过编译器完成的隐式转换,都会令编译器编译出一些运行期执行的码。 尽量少用转型操作,用其他方式来代替转型,尤其是向下转型dynamic_cast。 如果转型是必要的,将转型隐藏在某个函数背后。客户随后可以调用该函数,而不是将转型放进他们的代码内。
5.3 条款28:避免返回Handles指向对象内部成分
所谓Handles,用来取得某个对象,指针、Reference和迭代器都是Handles。返回一个对象内部成分的Handle,有降低对象封装性的风险,甚至可能造成恶意修改私有变量。
class Point { // 表示点的class
public:
Point(int x, int y);
void setX(int newVal);
int setY(int newVal);
};
strcut RectData {
Point ulhc; // 两个点可以表现一个矩形
Point lrhc;
};
class Rectangle {
public:
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
};
private:
std::shared_ptr<RectData> pData;
};
Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2);
rec.upperLeft().setX(50); // 修改了private成员
以上代码,虽然令组成矩形的两个点为private成员,但public成员函数返回了私有成员的reference。即reference指向private数据,调用者可以通过这些reference更改内部数据。 成员变量的封装性可能等于其reference函数的访问级别。本例虽然ulhc声明为private,但实际上是public,因为public函数传出了它们的reference。我们必须留心成员函数返回内部数据的handles。 一般的,我们在返回引用类型加上const、
class Rectangle {
public:
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
};
private:
std::shared_ptr<RectData> pData;
};
通过返回const& ,客户可以读取矩形的Point但不能进行修改。但这种情况仍然可能导致handles所指对象不存在,见条ji款21:必须返回对象时,别妄想返回其reference。 以上:避免返回handles(包括reference,指针,迭代器)指向对象内部。
5.4 条款29:为异常安全而努力是值得的
假设一个class用来表示GUI菜单,用于多线程环境,有一个互斥器(mutex)作为并发控制。
class PrettyMenu {
public:
void changeBackground(std::istream& imgSrc); // 改变背景图像
private:
Mutex mutex; // 互斥器
Image* bgImage; // 目前的背景图像
int imageChanges; // 背景图像被改变的次数
};
// changeBackground函数的一个可能实现
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex); // 取得互斥器
delete bgImage; // 去除旧背景
++imageChanges;
bgImage = new Image(imgSrc); //安装新背景
unlock(&mutex); // 释放互斥器
}
上述代码十分糟糕,异常安全有两个条件,该函数没有满足任何一个
- 不泄露任何资源。上述代码没有做到,一旦new Image导致异常,unlock不会调用,互斥器就被永远把持。
- 不允许数据破坏。若new Image抛出异常,bgImage就指向已被删除的对象,且imageChanges也被累加,这不合理。 解决资源泄露问题,条款13已经讨论对象管理资源,将对象建立在栈上则控制流离开代码段后会自动调用析构函数释放资源。
异常安全函数提供以下三个保证:
- 基本保证。如果异常被抛出,程序内的任何事物仍然保持在有效状态下,没有任何对象和数据结构会因此而败坏。然而程序的现实状态可能无法预料,例如上述代码异常抛出时,PrettyMenu对象可以继续保持原背景图像,或变成缺省背景图像。
- 强烈保证。若异常被抛出,程序状态不改变。即函数成功,就是完全成功;如果失败,程序回复到调用函数之前的状态。
- 不抛掷nothrow保证,承诺绝不抛出异常,总是能完成原先承诺的功能。作用于内置类型的所有操作都提供nothrow保证,这是异常安全码一个关键基础材料。但这个保证对大部分函数来说不现实。
异常安全必须提供上述三种保证之一,否则它就不具有异常安全性。显然nothrow保证很难,大部分函数抉择往往在基本保证和强烈保证之间。 对changeBackground而言,提供强烈保证不困难。首先将bgImage成员变量从类型为Image*的内置指针改为一个用于资源管理的智能指针。然后排列语句次数,使得更新图像之后再累加imageChanges
class PrettyMenu {
std::shared_ptr<Image> bgImage;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock m1(&mutex); // 用对象管理资源
bgImage.reset(new Image(imgSrc); // 智能指针维护
++imageChanges;
}
但以上不足的是参数imgSrc,若Image构造函数抛出异常,再解决这个问题之前changeBackground只能提供基本的异常安全保证。 copy and swap策略可以实现异常的强烈保证,原则:为你打算修改的对象做出一份副本,然后在副本上做一切必要之修改,若修改成功将副本和原对象在不抛出异常下进行swap(只需要令指针指向副本),若修改失败原对象仍然保持未改变状态。 对PrettyMenu而言,copy and swap典型写法如下
struct PMImpl {
std::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu {
private:
Mutex mutex;
std::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap;
Lock m1(&mutex);
std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl)); // 获得一个副本
pNew->bgImage.reset(new Image(imageSrc)); // 修改副本
++pNew->imageChanges;
swap(pImpl, pNew); // 置换数据
}
令PMImpl为struct因为其封装性已经由pImpl为private而得到了保证。 但由于连带影响,copy-and-swap不能保证整个函数具有强烈的安全性
void someFunc()
{
f1();
f2();
}
显然如果f2()执行了某操作发生异常,事实上很难恢复原状,因为存在f1()的执行。而且,如果f1()使某个数据库被改动了,因为数据库其他客户可能已经看到这笔数据,很难恢复原状。且copy-and-swap需要支出一个副本的时间和空间。当强烈保证不切实际时,你就必须提供异常基本保证。 注意:如果系统内由一个函数不具备异常安全性,整个系统就不具备异常安全性。一个软件系统要么具备异常安全性,要么就不具备。事实上许多老旧C++代码并不具备异常安全性,因此今天很多系统仍然不能说是异常安全的。
5.5 条款30:透彻了解inlining的里里外外
Inline函数,看起来像函数,动作像函数,比宏好得多 。调用它们又不需要蒙受函数调用所招致的额外开销。 而且,编译器最优化机制通常被设计用来浓缩那些“不含函数调用”的代码,对inline函数编译器能执行更好的语境相关最优化。 inline会增加目标码大小,程序体积太大,这是inline函数的注意缺点。 inline只是对编译器的一个申请,不是强制命令。可以隐喻指出,也可以明确指出。隐喻方式是将函数定义在class定义式内。
// 隐喻inline, class定义式函数
class Person {
public:
int age() const { return theAge; }
private:
int theAge;
}
// 显式inline
template<typename T>
inline const T& std::max(const T& a, const T& b)
{ return a < b ? b : a; }
没错,class定义式中的函数就是inline函数。另一组自然是关键字Inline明确指出。 Inline函数通常置于头文件内,因为大多数构建环境在编译过程中进行inline。Template通常也置于头文件中,因为它一旦被调用,编译器为了具现化,需要知道它长什么样子。但Template的具现化与inlining无关。 inline是个申请,大多数编译器拒绝将太过复杂(例如带有循环和递归)的函数inline,同时virtual函数也不会inline,因为virtual意味着运行期才确定调用哪个函数。inline是编译期行为。一般编译器拒绝Inline后给出一个警告信息。 倘若使用一个函数指针取某个inline函数的地址,inline函数不会被Inline。原因很简单,inline后的函数不独立存在,自然无法取地址
inline void f() { ... }
void (*pf)() = f; // 函数指针pf指向f
f(); //这个调用会被Inline,正常调用
pf(); // 这个调用不被inline,因为通过函数指针取地址达成
构造函数和析构函数不应该被设置为inline,因为编译器往往会在其内添加很多内容,事实上构造函数和析构函数是内容庞大的函数。 综上,应该将inline限制在小型,被频繁调用的函数身上,这可使日后的调试过程更容易,潜在的代码膨胀问题最小化,速度提升最大化。平均而言一个程序往往将80%的执行时间花费在20%的代码上面,作为一个软件开发者,目标是找出这20%代码,尽量能用inline将它们瘦身(循环递归等除外)。
5.6 条款31:将文件间的编译依存关系降至最低
C++并没有把“将接口从实现中分离”这件事做得很好,如下
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
以上class Person无法通过编译,因为编译器没有取得实现代码用到的class string,Date和Address的定义式。这些定义式常常由#include指示符提供,以下
#include <string>
#include "date.h"
#include "address.h"
然而这么以来,Person定义文件和含入文件之间形成了一种编译依存关系。如果头文件发生改变,那么每一个含入头文件的文件必须重新编译。这种编译依赖会造成难以形容的灾难。 解决办法是把Person分割成两个class,一个负责提供接口,一个负责实现接口。一般的,外界使用class时只需要inlcude接口。
#include <string>
#include <memory>
class PersonImpl; // Person实现类的前置声明
class Date; // Person接口用到的class的前置声明
class Address;
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::shared_ptr<PersonImpl> pImpl; // 指针,指向实现的对象
};
这里Person类只含有一个指针成员,指向其实现类。之所以使用指针因为C++必须知道Person的大小,而指针大小确定但对象大小不能在该文件中确定。这种设计称之为pimpl idiom(pointer to implementation)。 在这种设计下,Person的客户完全与Date Address Person的实现细目分离,Person实现类的任何修改都不需要Person客户端的重新编译。客户无法看到Person的具体实现,更不可能写出依赖具体实现的代码,真正的“接口与实现分离” 编译依存最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。策略要求如下:
- 如果使用object reference或object pointer可以完成任务,就不要使用object。可以只靠类型声明式定义出指向该类型的reference和pointer,但如果定义object就需要定义式。
- 如果能够,尽量以class声明式替换class定义式。当声明一个函数而用到某个class时,并不需要class的定义。
class Date; // class声明式
Date today(); // 只需要声明式即可
void clearAppointments(Date d); // 同样只需要声明式
声明today()函数和clearAppointments()函数无需定义Date,但一旦调用这些函数,调用之前Date定义式需要曝光。 1. 为声明式和定义式提供不同的头文件 声明式头文件和定义式头文件必须保持一致性,客户只需要#include一个声明文件而无需前置声明若干函数。
#include "datewfd.h" // 这个头文件内声明但未定义class Date
Date today();
void clearAppointments(Date d);
为了访问其他编译单元(如另一代码文件)中的变量或对象,对普通类型(包括基本数据类、结构和类),可以利用关键字extern,来使用这些变量或对象时;对模板类型,则必须在定义这些模板类对象和模板函数时,使用标准C++新增加的关键字export(导出/出口/输出)。
像Person这样使用pimpl idiom的class,称为Handle class。使用Person类需要将其所有函数行为转交给实现类PersonImpl。实现Person类时需要同时include接口类和实现接口类
#include "Person.h" // include Person class定义式
#include "PersonImpl.h" // 实现类
Person::Person(const std::string& name, const Date& birthday, const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr)) {}
std::string Person::name() const
{
return pImpl->name();
}
以上Person构造函数调用new PersonImpl构造,以及通过Person::name()实际调用 PersonImpl::name。
另一种制作Handle class的办法是,令Person成为一种特殊的abstract base class(抽象基类),称为interface class。这种class用来描述derived class的接口,因此通常不带成员变量,也没有构造函数,只有一个virtual析构函数以及一组pure virtual函数。
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
};
显然必须以Person的pointer或reference来撰写应用程序,因为不可能针对内涵pure virtual函数的Person class具现出实体。除了interface的接口被修改否则客户不需要重新编译。 interface class通常通过工厂函数,返回指针指向动态分配所得对象。这种函数往往被声明为static
// 实现类
class RealPerson: public Person {
public:
RealPerson(const std::string& name, const Date& birthday, const Address& addr)
theName(name),theBirthDate(birthday), theAddress(addr) {}
virtual ~Person() {};
virtual std::string name() const;
virtual std::string birthDate() const;
virtual std::string address() const;
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
// 工厂类
class Person {
public:
static std::shared_ptr<Person> create(const std::string& name,
const Date& birthday, const Address& addr)
{
return std::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}
};
以上,通过Handle class和Interface class接触了接口和实现的耦合关系。Handle class利用接口class含有指向实现class的指针接触接口和实现的耦合。Interface class利用纯虚函数接口,实现类只需要即成Interface class。 程序头文件应该以“完全且仅有声明式”的形式存在。
六、其他
右值引用是C++11增加的主要新特性之一了,比较绕不容易理解,这里整理一下。
6.1 左值和右值
维基百科的定义为,C++表达式的“值分类”(value categories)属性为左值或右值。左值是对应内存中有确定存储地址的对象的表达式的值,而右值是所有不是左值的表达式的值。因而,右值可以是字面量、临时对象等表达式。
能否被赋值不是区分C++左值与右值的依据,C++的const左值是不可赋值的;而作为临时对象的右值可能允许被赋值。左值与右值的根本区别是通过&运算符获得对应的内存地址。
C++的表达式不是左值(lvalue),就是右值(rvalue)。注意到C++中字面值和变量是最简单的表达式,其结果就是字面值和变量的值。
int i = 0, j = 0, k = 0; //初始化而非赋值
const int ci = i; //初始化而非赋值
1024 = k; //错误:字面值是右值
i + j = k; //错误:算术表达式是右值
ci = k; //错误:ci是常量左值,不可修改
一般而言,左值表达式表示一个对象的身份,右值表达式表示的是对象的值,不同的运算符对运算对象的要求各不相同,有的需要左值运算对象,有的需要右值运算对象;返回值也有差异,有的得到左值结果,有的得到右值结果。 左值不能绑定到要求转换的表达式、字面常量或者返回右值的表达式,但可以将右值引用绑定到这些表达式上。右值引用不能直接绑定到左值上。注意到右值虽然无法获取地址,但是右值引用是可以获取地址的,该地址表示临时对象的存储位置。
int i = 42; // 初始化,声明+定义+赋值
int &r = i; // 引用,左值
int &&rr = i; // 错误,不能将右值引用绑定到左值上
int &r2 = i * 42; // 错误,i*42是一个右值表达式,左值引用不能绑定右值
const int &r3 = i * 42; // 正确,可以将一个const引用绑定到一个右值上
int &&rr2 = i * 42; // 正确,绑定到右值表达式上
右值引用可以获取地址,左值引用虽然不能绑定右值,但能绑定右值引用
int &&r = 10;
int &l = r;
l = 11;
cout << r << endl; // 11
另一种理解左值右值的方法是通过寄存器和内存,比如常数5,在使用时不会在内存分配空间,而是直接放到寄存器中,所以它在C++中就是一个右值。而定义一个变量 a,它在内存中会分配空间,在C++中就是左值。至于a+5,因为a+5的结果存放在寄存器中,它并没有在内存中分配新空间,所以它是右值。
由于右值引用只能绑定到临时对象,而不是变量,可以得知 * 所引用的对象将要被销毁
虽然不能之将将右值引用绑定到左值上,但可以显式地将一个左值转换为对应的右值引用类型。
int &&rr3 = std::move(rr1);
move调用告诉编译器,我们有一个左值,但希望像右值一样使用它。这意味着承诺除了对rr1赋值或销毁之外,不再使用它。
6.2 移动构造函数
移动构造函数实现一个对象到另一个对象的元素移动,而非拷贝。
StrVec::StrVec(StrVec &&s) noexcept
: elements(s.elements), first_free(s.first_free)
{
s.elements = s.first_free = nullptr;
}
以上,移动构造函数不分配任何新内存,其作用实际上是实现指针的移动,即可以将一个对象中的指针成员转移给另一个对象。指针成员转移后,原对象中的指针成员一般要被设置为NULL,防止其再被使用。
移动赋值操作背后的思想是,“赋值”不一定要通过“拷贝”来做,还可以通过把源对象简单地“偷换”给目标对象来实现。例如对于表达式s1=s2,我们可以不从s2逐字拷贝,而是直接让s1“侵占”s2内部的数据存储,并以某种方式“删除”s1中原有的数据存储(或者干脆把它扔给s2,因为大多情况下s2随后就会被析构)。移动构造函数传入右值引用,也是为了实现数据的移动。因为左值只是别名,右值才是真实的值。
6.3 std::move
std::move就是一个类型转换器,将左值转换成右值。
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_case<typename remove_reference<T>::type&&>(t);
}
move的输入参数类型称为通用引用类型。所谓通用引用,就是auto或者模板修饰的引用,可以接受左值引用和右值引用。实际上auto就是模板中的T,它们是等价的。之所以能接受左值引用和右值引用,是通过模板推导实现的。
template<typename T>
void f(T&& param){
std::cout << "the value is "<< param << std::endl;
}
int main(int argc, char *argv[]){
int a = 123;
auto && b = 5; //通用引用,可以接收右值
int && c = a; //错误,右值引用,不能接收左值
auto && d = a; //通用引用,可以接收左值
const auto && e = a; //错误,加了const就不再是通用引用了
func(a); //通用引用,可以接收左值
func(10); //通用引用,可以接收右值
}
6.4 模板推导和move的实现
模板类型推导分成两大类:其中不是引用也不是指针类型的模板为一类; 引用和指针模板为另一类。
对于第一类其推导时根据的原则是,函数参数传值不影响原值,所以无论你实际传入的参数是普通变量、常量还是引用,它最终都退化为不带任何修修饰的原始类型。例如,const int &类型传进去后,退化为int型了。
第二类为模板类型为引用(包括左值引用和右值引用)或指针模板。这一类在类型推导时根据的原则是去除对等数量的引用符号,其它关键字照般。例如,func(x)中x的类型为 int&,它与T&放在一起可以知道T为int。另一个例子function(x),其中x为int&它与T&& 放在一起可知T为int&, 注意此时通过引用折叠,int&& &折叠成int&&因此T为int&
template <typename T>
void f(T param);
template <typename T>
void func(T& param);
template <typename T>
void function(T&& param);
6.5 通用引用
template<typename T>
void f(T&& param){
std::cout << "the value is "<< param << std::endl;
}
传入类型T&&,又因是模板可以进行类型推导,所以是通用引用,因此给它传左值和右值它都能正确接收。左值与通用引用放在一推导出来的T仍为左值,而右值与通用引用放在一起推导出来的T仍然为右值。 引用折叠 int& & 折叠为 int& int& && 折叠为 int& int&& & 折叠为 int& int&& && 折叠为 int &&
在C++11中,增加了一种成员称为类型成员。类型成员与静态成员一样,它们都属于类而不属于对象,访问它时也与访问静态成员一样用::访问。例如:
typename remove_reference<T>::type&&
typedef std::string::size_type type_s;
template <typename T>
typename T::value_type top(const T &c){} // 返回一个T::value_type类型
remove_reference类的实现,萃取类型从而不论传入左值引用、右值引用还是值,都会得到右值引用。remove_reference利用模板的自动推导获取到了实参去引用后的类型。
template <typename T>
struct remove_reference{
typedef T type; //定义T的类型别名为type
};
template <typename T>
struct remove_reference<T&> //左值引用
{
typedef T type;
}
template <typename T>
struct remove_reference<T&&> //右值引用
{
typedef T type;
}
6.6 完美转发
通过move函数,我们可以将一个通用引用无条件转化成右值引用,从而方便移动语义,防止拷贝,提高效率。而某些函数需要将其实参连同类型不变的转发给其他函数,而不再是无条件转化成右值,这时候就用到完美转发。
尽管通用引用可以保证传入左值推导出左值,传入右值推导出右值,但可能存在的临时变量恐使其不能奏效。
template<typename T>
void func(T& param) {
cout << "传入的是左值" << endl;
}
template<typename T>
void func(T&& param) {
cout << "传入的是右值" << endl;
}
template<typename T>
void warp(T&& param) {
func(param);
}
int main() {
int num = 2019;
warp(num);
warp(2019);
return 0;
}
// 输出
传入的是左值
传入的是左值
warp()函数本身的形参是一个通用引用,即可以接受左值又可以接受右值;第一个warp()函数调用实参是左值,所以warp()函数中调用func()中传入的参数也应该是左值;第二个warp()函数调用实参是右值,根据引用折叠规则,warp()函数接收的参数类型是右值引用,那么为什么却调用了调用func()的左值版本了呢?这是因为在warp()函数内部,左值引用类型变为了右值,参数有了左值名称,实际上通过变量名取得变量地址,传入的是左值引用。
保持函数调用过程中变量类型不变,这就是所谓的“完美转发”技术,在C++11中通过std::forward()函数来实现函数将实参类型不变的转发。
template<typename T>
void warp(T&& param) {
func(std::forward<T>(param));
}