Xander's Wiki


  • Home

  • Tags

  • Categories

  • Archives

  • Search

26.限制某个类所能产生的对象数量

Posted on 2018-04-28 | In More Effective C++

前言

 
本节内容的极端情况即为禁止建立对象与单例模式。


禁止对象实例化

 
如果我们需要阻止建立某个类的对象,应该把它的构造函数设为private:

1
2
3
4
5
6
class CantBeInstantiated {
private:
CantBeInstantiated();
CantBeInstantiated(const CantBeInstantiated&);
...
};


单例模式

 
假设我们现在只允许生成一个打印机对象,我们应当把这个对象封装置入某个函数,允许所有人访问,但只有一个对象存在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class PrintJob;
class Printer {
public:
void submitJob(const PrintJob& job);
void reset();
void performSelfTest();
...
friend Printer& thePrinter();
private:
Printer();
Printer(const Printer& rhs);
...
};
Printer& thePrinter(){
static Printer p; // 单个打印机对象
return p;
}

这个设计有三部分:

  1. printer无法构造
  2. 全局函数thePrinter被设为friend,因此可以调用private构造函数。
  3. thePrinter包含一个静态Printer对象,这意味着只有一个对象被建立。我们只能通过thePrinter函数来使用这个对象。

静态函数

有人会认为thePrinter没必要污染全局命名空间,为此解决方法是把thePrinter声明为printer内部的静态函数,那顺便去除了friend属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Printer{
public:
static Printer& thePrinter();
...
private:
Printer();
Printer(const Printer& rhs);
...
};
Printer& Printer::thePrinter(){
static Printer p;
return p;
}

就是调用函数的时候麻烦了一些:
1
2
Printer::thePrinter().reset();
Printer::thePrinter().submitJob(buffer);

新建命名空间

另一种做法是将class与thePrinter移出全局域,放入专属namespace,防止命名冲突:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace PrintingStuff{
class Printer{
public:
void submitJob(const PrintJob& job);void reset();
void performSelfTest();
...
friend Printer& thePrinter();
private:
Printer();
Printer(const Printer& rhs);
...
};
Printer& thePrinter(){
static Printer p;
return p;
}

只不过这样一来调用每一次调用都必须提及命名空间:
1
2
PrintingStuff::thePrinter().reset();
PrintingStuff::thePrinter().submitJob(buffer);

又或者使用using:
1
2
3
using PrintingStuff::thePrinter; 
thePrinter().reset();
thePrinter().submitJob(buffer);


单例模式实现细节

thePrinter的实现有两个微妙之处:

  1. p是函数中的静态对象而非类中的静态对象
    如果是类中的静态对象,则该对象总是会被构造和析构。(即使你根本没有试图去使用)函数中的则不同,只有第一次调用函数时才会被构造。当然,我们也为此付出了代价:每一次都必须检查是否需要建立对象。
    如果对象是类中的静态成员,它的初始化时间难以确定,我们清楚的了解函数的静态对象初始化于函数的第一次调用,而类则说不准。
  2. 关注内联与函数内静态对象的关系
    我们看得出thePrinter的函数体非常简短,很适合内联,但我们不能内联它。因为对于非成员函数,内联会导致复制obj中的代码,简单来说,就连静态对象也被复制了。所以不要内联包含局部静态数据的非成员对象。

多例模式(限制数目)

 
一般来说,限制对象数目的核心思想在于:实时跟踪当前已生成的对象数目,如果超出则抛出异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Printer {
public:
class TooManyObjects{};//异常类
Printer();
~Printer();
...
private:
static size_t numObjects;
Printer(const Printer& rhs);//禁止拷贝
};
size_t Printer::numObjects = 0;
Printer::Printer(){
if (numObjects >= 1) {
throw TooManyObjects();
}
...//执行构造
++numObjects;
}
Printer::~Printer(){
...//执行析构;
--numObjects;
}

这种方法相当直观,就是设计起来存在些许小小的问题。


建立对象的环境

假设我们有一个彩色打印机,继承自printer:

1
2
3
class ColorPrinter: public Printer {...};
Printer p;
ColorPrinter cp;

上述定义产生了两个Printer对象,于是,在构造cp的基类部分时抛出了异常(More Effective C++ 33提出设计时避免非尾端类为具象类)。
如果有其他对象包含Printer对象,也会有同样的问题:
1
2
3
4
5
6
7
8
9
class CPFMachine {
private:
Printer p;//有打印能力
FaxMachine f;//有传真能力
CopyMachine c;//有复印能力
...
};
CPFMachine m1;//运行正常
CPFMachine m2;//抛出异常

这些问题的根源在于Pointer对象可以在3种不同状态下生存:

  1. 仅有自身
  2. 作为派生对象的base成分
  3. 内嵌于较大对象

这直接导致了我们心目中的“目标个数”与编译器看到的目标个数不一致。

private构造函数

通常情况下我们只对状态1感兴趣,那把构造函数设为private可以很有效地满足我们。带有private构造函数的类无法作为基类,也无法内嵌到其他对象中。
private构造函数是一种阻止产生派生类的好手段,具体来说,假如你有一个类FSA(finite state automata),我们允许它产生多个对象,但禁止从它派生出新类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FSA {
public:
static FSA * makeFSA();
static FSA * makeFSA(const FSA& rhs);
...
private:
FSA();
FSA(const FSA& rhs);
...
};
FSA* FSA::makeFSA(){
return new FSA();
}
FSA* FSA::makeFSA(const FSA& rhs){
return new FSA(rhs);
}

不同于thePrinter函数总是返回一个对象的引用(引用的对象是固定的),每个 makeFSA的伪构造函数则是返回一个指向对象的指针,也就是说允许建立的 FSA 对象数量没有限制。
为了防止资源泄漏,最佳方案自然是RAII:
1
2
auto_ptr<FSA> pfsa1(FSA::makeFSA());
auto_ptr<FSA> pfsa2(FSA::makeFSA(*pfsa1));


允许对象自由生灭

在之前的设计手法中,首次我们调用thePrinter时,对象被构造,但我们无法控制对象的销毁,也就是说无法达成这样的功能:

1
2
3
4
5
6
建立Printer对象 p1;
使用p1;
释放p1;
建立Printer对象 p2;
使用p2;
释放p2;

解决方法就是把对象计数和伪构造函数合并:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Printer {
public:
class TooManyObjects{};
static Printer * makePrinter();
~Printer();
void submitJob(const PrintJob& job);
void reset();
void performSelfTest();
...
private:
static size_t numObjects;
Printer();
Printer(const Printer& rhs);
};
size_t Printer::numObjects = 0;
Printer::Printer(){
if (numObjects >= 1) {
throw TooManyObjects();
}
...//执行构造
++numObjects;
}
Printer::~Printer(){
...//执行析构;
--numObjects;
}

除了用户必须调用伪构造函数之外,这一切使用起来就如同其他类:
1
2
3
4
5
6
7
Printer p1;//error 构造函数为private
Printer *p2 =Printer::makePrinter();
Printer p3 = *p2; //error 禁止拷贝
p2->performSelfTest();
p2->reset();
...
delete p2;//避免资源泄漏

如果我们需要制定最多生成N个,那只要在class中定义一个static const maxsize=N就ok:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Printer {
public:
class TooManyObjects{};
static Printer * makePrinter();
static Printer * makePrinter(const Printer& rhs);
...
private:
static size_t numObjects;//声明
static const size_t maxObjects = 10;//内置static类型可以直接在class中声明时定义
Printer();
Printer(const Printer& rhs);
};
size_t Printer::numObjects = 0;//在类外定义
const size_t Printer::maxObjects;//定义


建立具有计数功能的基类

如果我们需要大量这种限制产出对象数量的class,难道我们要一遍一遍地重复编写?当然不是。
理想做法是编写一个具有实例计数功能的基类,然后让产出受限的类继承该类。我们使用一种方法封装计数功能,这种做法不但封装维护实例计数器的函数,也封装实例计数器本身。
下面给出该类的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
template<class BeingCounted>
class Counted {
public:
class TooManyObjects{};//异常类
static int objectCount() {return numObjects;}
protected://仅作为基类使用
Counted();
Counted(const Counted& rhs);
~Counted() {--numObjects;}
private:
static int numObjects;
static const size_t maxObjects;
void init();//避免代码重复
};
template<class BeingCounted>
Counted<BeingCounted>::Counted(){
init();
}
template<class BeingCounted>
Counted<BeingCounted>::Counted(const Counted<BeingCounted>&){
init();
}
template<class BeingCounted>
void Counted<BeingCounted>::init(){
if (numObjects >= maxObjects) throw TooManyObjects();
++numObjects;
}

于是最终的Printer类如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Printer:private Counted<Printer>{
public:
static Printer * makePrinter();
static Printer * makePrinter(const Printer& rhs);
~Printer();
void submitJob(const PrintJob& job);
void reset();
void performSelfTest();
...
using Counted<Printer>::objectCount;
using Counted<Printer>::TooManyObjects;
private:
Printer();
Printer(const Printer& rhs);
};

用户有权得知当前的对象数目以及最大数目,private继承使得它们被隐藏,因此需要在public中使用using来获取他们。
最后需要注意的就是定义counted内部的静态成员,对于numobject,我们只需要在counted的实现中定义:
1
2
template<class BeingCounted>
int Counted<BeingCounted>::numObjects;//自动初始化为0

max的定义则略微麻烦,我们不初始化它,而是留待客户进行初始化。以Printer举例:
1
const size_t Counted<Printer>::maxObjects = 10;

如果客户没写,那么在连接时会报错。

25.虚拟工厂模式

Posted on 2018-04-28 | In More Effective C++

前言

 
工厂模式在C++中体现为构造函数虚拟化,即针对不同的已知对象,调用不同的构造函数,生成不同的新对象。


问题实例

 
假设现有一个程序负责生成新闻报道,每篇报道由文字或者图片组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class NLComponent {//abstract class
public:
...
};
class TextBlock:public NLComponent {
public:
...
};
class Graphic: public NLComponent {
public:
...
};
class NewsLetter {
public:
...
private:
list<NLComponent*> components;//以链表存储数据
};

classes之间的关系如下:
image_1cc4spmjh1ivl249m7r1o1jo3319.png-107.4kB
我们给NewsLetter class添加一个以istream作为参数的构造函数,其伪代码大致如下:
1
2
3
4
5
6
7
8
9
10
11
class NewsLetter {
public:
NewsLetter(istream& str);
...
};
NewsLetter::NewsLetter(istream& str){
while (str) {
...//从 str 读取下一个component对象;
...//将component对象加入链表
}
}

又或者,我们为此建立一个独立函数readComponent:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class NewsLetter {
public:
...
private:
//为建立下一个 NLComponent 对象从 str 读取数据,
//建立 component 并返回一个指针。
static NLComponent * readComponent(istream& str);
...
};
NewsLetter::NewsLetter(istream& str){
while (str) {
components.push_back(readComponent(str));
}
}

显然,readComponent根据str建立了一个新对象,其类型是text或者graphic.它的行为类似于构造函数,但又能构建不同的对象,因此被称为虚构造函数。其特性在于,根据不同的输入构造出不同的对象。(虚拟工厂模式)


虚拷贝构造函数

 
虚拷贝构造函数返回一个指针,指向调用该函数的对象的新拷贝,一般命名该函数为copyself或者clone,其大致实现细节如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class NLComponent {
public:
virtual NLComponent * clone() const = 0;
...
};
class TextBlock: public NLComponent {
public:
virtual TextBlock * clone() const{
return new TextBlock(*this);
}
...
};
class Graphic: public NLComponent {
public:
virtual Graphic * clone() const{
return new Graphic(*this);
}
...
};

可以看出,虚拷贝构造完全调用了拷贝构造,因此具备其拥有的一切特性,诸如引用计数之类。
值得注意的是,虚拷贝构造的一大特点:派生类重定义的虚函数不必与基类对应函数具备一致的返回类型,这也就是我们上例中返回Text*的原因。

这种虚拷贝构造函数让对象的正常拷贝构造变得很容易:

1
2
3
4
5
6
7
8
9
10
11
12
class NewsLetter {
public:
NewsLetter(const NewsLetter& rhs);
...
private:
list<NLComponent*> components;
};
NewsLetter::NewsLetter(const NewsLetter& rhs){
for (auto it =rhs.components.begin();it != rhs.components.end();++it) {
components.push_back((*it)->clone());
}
}

虚拷贝构造函数的最大特性在于,无论对象的真正类型是什么,它都能轻易地完成拷贝。


非成员函数虚拟化

 
我们可能希望存在这么一个非成员函数,能够根据参数的不同类型而执行不同的行为。
以上文中的NewsLetter为例,我希望为text或graphic实现一个输出操作符,但operator<<默认把ostream&作为它的lhs,那注定了它无法变成成员函数。
解决方案是建立一个虚成员函数print,在operator<<中调用print函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class NLComponent {
public:
virtual ostream& print(ostream& s) const = 0;
...
};
class TextBlock: public NLComponent {
public:
virtual ostream& print(ostream& s) const;
...
};
class Graphic: public NLComponent {
public:
virtual ostream& print(ostream& s) const;
...
};
inline
ostream& operator<<(ostream& s, const NLComponent& c){
return c.print(s);
}

显然,虚拟非成员函数的实现过程如下:

  1. 完成一个虚成员函数
  2. 在非成员函数中调用虚成员函数
  3. 为了避免额外的调用开销,inline该虚非成员函数

总结

 
以上均为以单一参数完成虚拟化的实例,根据多参数完成虚拟化详见More Effective C++ 31。

24.了解虚函数、多继承、虚基类、RTTI所带来的成本

Posted on 2018-04-27 | In More Effective C++

前言

 
C++编译器必须实现语言的每一个特性,其实现细节由编译器来决定,不同的编译器以不同的方式实现语言特性。在多数情况下,开发者无需理解这些隐藏于代码背后的操作。然而某些特性的实现对对象大小和其成员函数执行速度有很大的影响,所以对于这些特性有一个基本的了解,知道编译器可能执行的操作,就较为重要。


虚函数

 
当调用虚函数时,所执行的代码必须与调用函数的对象的动态类型一致;指向对象的指针或引用的静态类型并不重要。
编译器为了高效地提供这种行为,通常会使用virtual table与virtual table pointers.此二者通常又被称为vtbl与vptr.


vbtl

一个vbtl通常是一个函数指针数组(或链表)。只要某个类声明了虚函数或者继承了虚函数就会存在vtbl,vtbl中的元素是指向虚函数实现体的指针。

1
2
3
4
5
6
7
8
9
10
class C1 {
public:
C1();
virtual ~C1();
virtual void f1();
virtual int f2(char c) const;
virtual void f3(const string& s);
void f4() const;
...
};

C1的vtbl如下所示:image_1cc2pakm9rru1e2gtrp1p9016ob9.png-44.2kB
显然,非虚函数不会存在于vtbl中。

继承下的vbtl

现有C2继承自C1,并且重定义了部分虚函数:

1
2
3
4
5
6
7
class C2: public C1{
public:
C2();//非虚函数
virtual ~C2(); //重定义函数
virtual void f1();//重定义函数virtual void f5(char *str); //新的虚函数
...
};

C2的vtbl如下所示:image_1cc2po1k51uvcejs1585tht199lm.png-40.6kB
可以看出,C2的vbtl里面包括了没有被C2重定义的C1的虚函数的指针。

vbtl体现了虚函数所需的第一个代价:每一个包含虚函数的类都需要空间来容纳vtbl,其大小与虚函数的数量成正比。

vtbl的位置

因为每一个类只需要一个vbtl拷贝,那把它放在哪个obj里呢?编译器厂商有两种做法:

  1. 为所有可能需要vbtl的obj生成一个vbtl拷贝,连接程序然后删除多余的拷贝。最后的可执行文件或者程序库里只有一个vbtl实例。
  2. 采用启发式算法:只有obj包含该类的第一个非内联,非纯虚函数定义(也就是实现体)时才会生成vbtl.

vptr

单有vbtl并不能实现虚函数,还需要vptr.这是一个指向vbtl的指针,隐藏在对象中,其位置只有编译器知道。这是虚函数的第二个代价:对象内需要额外的空间开销来存储指针。对于较小的对象而言,这笔买卖很不划算。
假设我们有一些C1与C2的对象,对象、vbtl、vptr的关系大致如下图所示:
image_1cc2qaquhtk915b82dqco71mde13.png-106.9kB

1
2
3
void makeACall(C1 *pC1){
pC1->f1();
}

为了保证调用正确,编译器做出了如下操作:

  1. 通过对象的vptr找到vtbl。
  2. 找到vbtl里面的指向被调用函数的指针。
  3. 调用该函数。

假设存在索引i指向vbtl中的f1函数,那么上述代码的执行类似于:

1
(*pC1->vptr[i])(pC1);//pC1作为this指针作为形参

从上述过程可以看出调用虚函数并不是性能的瓶颈,因为虚函数调用所花费的成本相对于普通函数调用相差无几。在实际运行中虚函数成为性能瓶颈的原因是无法内联,原因很简单,inline是编译到此处时替换,而虚函数的确认需要到运行期,这就是虚函数的第三个不足:无法内联从而降低性能。


多继承与虚基类

 
一旦多继承被引入,在对象里为寻找vptr而进行的偏移量计算会变得更复杂。在单个对象中会存在多个vptr(每一个基类对应一个),此外,此外,针对base class而形成的vtbl也被生成。这直接导致了空间成本进一步扩大,并且运行期调用成本也有了轻微的增长。
多继承导致了对虚基类的需求。在不使用虚基类的情况下,如果一个派生类有一个以上从基类的继承路径,基类的数据成员将通过所有路径产生多个拷贝存在于派生类中。
但虚基类本身亦存在使用成本,因为虚基类的实现经常使用指向虚基类的指针做为避免复制的手段,从而导致一个或者更多的指针被存储在对象里。

钻石型继承体系(Effective C++ 41)

考虑如下的继承关系:
image_1cc2re6k51thn88j1av642r6351g.png-35.1kB
D对象的内部会呈现出这样的结构:
image_1cc2rods774enmju44g9s147g2d.png-79.7kB
显然,对象内部有一个多余的指针,这也就是上文所说的虚基类的一大弊端。
当我们再加入vptr的概念,D对象的内部结构大概是这样:
image_1cc2rrm7ap0hbc01a4819nj18pk3a.png-166.3kB
四个类只有3个vptr,这是因为编译器发现BD的vptr可以共享。


运行期类型识别(RTTI)

 
RTTI能够让我们在运行时找到对象和类的相关信息,这样信息被存储在类型为type_info的对象里,我们可以通过typeid操作符来访问一个类的type_info对象.

一个类仅仅只需要一个RTTI的拷贝,但是必须有办法得到任何对象的类型信息。从语言规范角度而言:如果一个类型至少有一个虚函数,那我们保证可以获得一个对象动态类型信息。这似乎类似于vbtl,实际上,RTTI就是基于vbtl实现的。
具体来说,vbtl的索引0处可以包含一个指针,指向type_info对象:
image_1cc2s78hs1cho1rhb13vft1npt83n.png-40.5kB
也就是说vbtl又多了一个需要被占用的空间。


总结

Feature Increases Size of Objects Increases Per-Class Data Reduces Inlining
Virtual Functions Yes Yes Yes
Multiple Inheritance Yes Yes No
Virtual Base Classes Often Sometimes No
RTTI No Yes No

要记住理解是为了更好的使用,而非因噎废食。

23.考虑变更程序库

Posted on 2018-04-27 | In More Effective C++

程序库的设计就是一个折衷的过程。理想的程序库应该是短小的、快速的、强大的、灵活的、可扩展的、直观的、普遍适用的、具有良好的支持、没有使用约束、没有错误的。当然,上述所描述的东西不存在。为尺寸和速度而进行优化的程序库一般不能被移植。具有大量功能的的程序库不会具有直观性。没有错误的程序库在使用范围上会有限制。没有十全十美。
不同的设计者给这些条件赋予了不同的优先级。他们从而在设计中牺牲了不同的东西。因此一般两个提供相同功能的程序库可能有着完全不同的性能特征。
因为不同的程序库在效率、可扩展性、移植性、类型安全和其他一些领域上蕴含着不同的设计理念,通过使用给予性能更多考虑的程序库,有时可以大幅度地提高软件的效率。

22.以op=代替op

Posted on 2018-04-27 | In More Effective C++

前言

 
对于c++而言,运算符operator+、operator=、operator+=之间并没有任何关联,对于其他运算也同理,如果你期望使用效果犹如内置类型,那你必须自己实现。


实现

 
为了确保operator+=与operator+之间存在正常的关系,建议根据operator+=实现出operator+:

1
2
3
4
5
6
7
8
9
class Rational {
public:
...
Rational& operator+=(const Rational& rhs);
Rational& operator-=(const Rational& rhs);
};
const Rational operator+(const Rational& lhs,const Rational& rhs){
return Rational(lhs) += rhs;//返回值优化
}

这种方法将赋值形式作为了成员函数,维护性得到了提高,同时避免了operator+成为了类的友元。(提高封装性)


效率

 
赋值的效率高于单独形式,原因在于赋值返回引用,无需构建析构临时对象。
但需要记住,提供赋值的同时必须提供标准形式,以方便用户能够作出权衡,因为标准形式的可读性高于赋值,在大多数情况下,可读性很重要。
作为一个库编写者,必须给客户提供这两种操作。但作为使用者,我们必须明白赋值的效率更高。

21.通过重载避免隐式转换

Posted on 2018-04-27 | In More Effective C++

问题实例

1
2
3
4
5
6
7
8
9
10
class UPInt {// unlimited precision integers
public:
UPInt();
UPInt(int value);
...
};
const UPInt operator+(const UPInt& lhs, const UPInt& rhs);
UPInt upi1, upi2;
...
UPInt upi3 = upi1 + upi2;

这段程序不足为奇,再接着看如下表达式:

1
2
upi3 = upi1 + 10;
upi3 = 10 + upi2;

这也能够运行,原因在于隐式转换建立临时对象把int转成了UPInt(More Effective C++ 5)。显然,这是需要一定的开销。


解决方案

 
其实我们并不是想要做类型转换,我们只不过是想让int能和upint相加而已,既然当前不具备可适配性,那我们完全可以通过重载函数适配所有可能出现的情况:

1
2
3
const UPInt operator+(const UPInt& lhs,const UPInt& rhs);
const UPInt operator+(const UPInt& lhs,int rhs);
const UPInt operator+(int lhs,const UPInt& rhs);

如此则不会产生任何由于隐式转换所带来的开销。
看起来我们忘记了做两个int相加返回UPInt的operator+重载,但是实际上我们无法这么做,因为c++规定每一个operator重载函数都必须有一个用户自定义类型作为参数,否则容易出现混乱的修改。


总结

 
利用重载避免生成临时对象的方法不仅仅只适用于操作符,而是适用于任何可能产生隐式转换之处。
不过,必须谨记80-20准则(More Effective C++ 16)。我们没有必要实现大量的重载函数,除非你有理由确信整体效率会有显著的提高。

20.返回值优化

Posted on 2018-04-27 | In More Effective C++

前言

 
一个返回对象的函数难以保持高效,因为构造与析构所带来的开销无法避免。


问题实例

 
以与Rational类相关的operator*函数为例:

1
2
3
4
5
6
7
8
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
...
int numerator() const;
int denominator() const;
};
const Rational operator*(const Rational& lhs,const Rational& rhs)

在Effective C++中已经多次以它为例,显然,任何以不返回对象为目的做出的修改都是徒劳的。


优化

 
必须返回对象并不意味着不可以优化,从效率的角度而言,我们更应该把注意力集中到临时对象所带来的开销上,而不是去消除对象。

返回构造函数表达式

1
2
3
4
const Rational operator*(const Rational& lhs,const Rational& rhs){
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}

显然,我们通过构造函数构造了一个临时的Rational对象,函数把它拷贝给了返回值。
这种不存在局部对象的方法依旧存在开销,比如说,函数体内临时对象的构造与释放依旧需要开销。但是,C++允许编译器优化不出现的临时对象(temporary objects out of existence)因此,在如下的语句中:

1
2
3
Rational a = 10;
Rational b(1, 2);
Rational c = a * b;

其实并没有出现临时对象,你仅仅付出了一个构造函数的成本,因为编译器直接在为c分配的内存中构造return表达式定义的对象。

如果还要更进一步的话,可以把operator*声明为inline,减少函数的调用开销,这已经达到了优化的最高水平:

1
2
3
4
inline const Rational operator*(const Rational& lhs,const Rational& rhs){
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}

19.了解临时对象的来源

Posted on 2018-04-27 | In More Effective C++

前言

 
程序员习惯于把短暂需要的变量称为临时变量:

1
2
3
4
5
template<class T>
void swap(T& object1, T& object2){
T temp = object1;//往往被称为临时对象
object1 = object2;object2 = temp;
}

对于c++而言,temp根本不是临时对象,它只是一个函数的局部对象。


临时对象

 
在c++中真正的临时对象是看不见的,它不出现在任何源码中。建立一个未命名的non-heap对象会产生临时对象,它一般只会出现在两种条件下:

  1. 为了使函数成功调用而进行隐式转换时
  2. 函数返回对象时

我们之所以需要关注临时对象,是因为构造和析构它们带来的成本会对程序的性能造成很大影响。


隐式转换所产生的临时对象

问题实例

以下是一个记录字符在字符串中出现次数的函数:

1
2
3
4
5
size_t countChar(const string& str, char ch);
char buffer[MAX_STRING_LEN];
char c;
cin >> c >> setw(MAX_STRING_LEN) >> buffer;//避免缓存溢出
cout << countChar(buffer, c) << endl;

显然,countChar需要的是一个string对象,但实际传入的是一个字符数组(C API 字符串),因此编译器不得不建立一个string的临时对象,以buffer作为参数来初始化它,str被绑定到了这个临时对象,当函数返回时,临时对象释放。

解决方案

这种类型转换在方便之余也会存在一些风险(More Effective C++ 5),此外,临时对象的构造与析构也是一笔很大的开销,修改方法无非两种:

  1. 重新设计程序,禁止发生类型转换,详见More Effective C++ 5。
  2. 通过修改程序保证不再需要类型转换,详见More Effective C++ 21。

仅仅在传值与传常量引用(reference-to-const)时会发生上述情况,因为c++禁止为非常量引用生成临时对象(如果允许存在非常量引用,在实际使用时无法改变真正传入的参数,一切操作均作用于临时对象)。


返回对象

 
返回对象会生成临时对象是很容易理解的,并且More Effective C++ 20给出了优化方案。


临时对象的发现

 
训练自己发现临时对象的能力是很有必要的。任何时候只要看到常量引用参数,就存在建立一个临时对象绑定于其上的可能性。任何时候只要见到一个函数返回对象,就会明白必然存在一个临时对象被构造和析构。


关于常量引用的补充

 
如果对一个常量进行引用,那么编译器首先建立一个临时变量,然后将该常量的值置入临时变量中,对该引用的操作就是对该临时变量的操作。对常量的引用可以用其它任何引用来初始化,但不能改变。
关于引用的初始化有两点值得注意:

  1. 当初始化值是一个左值(可以取得地址)时,没有任何问题。
  2. 当初始化值不是一个左值时,则只能对一个const T&(常量引用)赋值。

常量引用的初始化过程为:首先将右值隐式转换到类型T,然后将这个转换结果存放在一个临时对象里,最后用这个临时对象来初始化这个引用变量。

1
2
3
4
5
double& dr = 1;//error 需要左值
const double& cdr = 1; // ok
//实际过程等价于
double temp = double(1);
const double& cdr = temp;

18.Over-eager evaluation

Posted on 2018-04-26 | In More Effective C++

前言

 
在上一节中,我们通过Lazy evalution来提高效率。但本节不存在Lazy,我们试图去让程序做的事情比被要求的还要多,通过这种方式来提高软件的性能。其核心就是本节主题Over-eager evaluation:在要求你做某些事情以前就完成它们。


问题实例

 
下述这个模板类表示一个存有大量数字型数据的集合:

1
2
3
4
5
6
7
8
template<class NumericalType>
class DataCollection {
public:
NumericalType min() const;
NumericalType max() const;
NumericalType avg() const;
...
};

假设 min,max 和 avg 函数分别返回现在这个集合的最小值,最大值和平均值,有三种方法实现这些功能:

  • lazy evaluation
    确实需要返回值我们才去计算。
  • eager evaluation
    当函数被调用时,我们分析集合内所有数据,进行计算
  • over-eager evaluation
    随时跟踪目前集合的min,max,avg(每当集合发生变化即更新数据)因此函数调用时无需计算。

在频繁调用函数的情况下,我们把跟踪所花费的时间分摊于所有函数调用,那么实际花销反而比lazy与eager都要少。隐藏于其背后的思想是:如果一个计算需要频繁进行,那你应该设计某种数据结构进行高效处理,降低每一次计算的开销。


cache

 
over-eager evaluation最简单的实现是cache,缓存那些已经被计算出来并且以后可能仍然需要的值。
举例而言,办公室成员的所有信息都被放在数据库内,我们应该建立cache保存已经查到的信息,如此在以后需要时无需反复的查询数据库内部。
以下以办公人员隔间号作为信息举例:

1
2
3
4
5
6
7
8
9
10
11
12
int findCubicleNumber(const string& employeeName){
typedef map<string, int> CubicleMap;
static CubicleMap cubes;//cache
auto it = cubes.find(employeeName);
if (it == cubes.end()) {
int cubicle = //search in database
cubes[employeeName] = cubicle;
return cubicle;
}else {
return (*it).second;
}
}


prefetech

 
举例而言,每一次磁盘的读取并不是读你所需要的那一小块,而是直接读取一整块或者一个扇区。因为读取大块所需要的时间比读取好几个小块所需要的时间短,而且经验表明如果需要某一个地方的数据,那么也有可能会需要其周边的数据。(位置相关现象)正因为如此,系统设计者才有理由为指令与数据使用磁盘cache和内存cache,以及指令prefetch.
有可能你会说你对这种低级硬件处理没兴趣,那在数据结构中prefetch也有发挥作用。那就是vector的动态增长:一般而言,我们并不给vector分配恰好的cap,而是总是趋向于分配所需要的2倍大小,当vector饱和时,则再次分配当前cap两倍大小的cap.


总结

 
本节的核心思想在于:更快的速度需要更多的内存,也就是以空间换时间的策略。
当你需要支持某些操作但不需要立即得到结果时,使用lazy evaluation。
当你必须支持某些操作并且其结果总是被不止一次的重复需求时,记得caching与prefetching。

17.Lazy evaluation

Posted on 2018-04-26 | In More Effective C++

前言

 
从效率的角度而言,最好的计算就是不计算。如果必须执行计算,我们可以拖到非计算不可的时候再计算。这种操作广泛适用于各个领域。


引用计数

1
2
3
class String { ... };//自定义string类
String s1 = "Hello";
String s2 = s1;

一般来说,s2被s1初始化后,s1与s2都有了自己的值,为了完成这个拷贝初始化,我们需要使用new来分配内存,需要调用strcpy函数拷贝数据等等,付出了极大的成本。但实际上此时的s2根本不需要执行拷贝操作,因为s2没被有被使用。

从lazy evaluation的角度而言,我们根本无需拷贝,只需要让s1与s2共享一个值即可。通过做一些记录以便了解哪些对象在共享哪些值,就省略了new与copy的开销。
当且仅当某个string被修改时,我们才需要执行真正的拷贝操作。例如当s2需要被修改,此时我们应该赶紧拷贝s1赋予s2,然后修改s2。

引用计数的具体实现机制见More Effective C++ 29,其核心原理就是lazy evaluation:除非你确实需要,否则不去为任何东西制作拷贝,能共享就共享。


区分读写

 
仍以上文带有引用计数的string举例:

1
2
3
4
String s = "Homer's Iliad";
...
cout << s[3]; //读取s[3]
s[3] = 'x'; //写入s[3]

读取并不会破坏共享性,但写入则需要对string值建立一个新拷贝。如果我们能够区分读取还是写入,在operator[]中采取不同的操作,那么效率必然会大幅度提升。但事实上我们不可能判断出调用operator[]是执行了读取还是写入,但可以配合More Effective C++ 30中的proxy class来推迟决定,直到我们了解当前是读取还是写入。


Lazy Fetching

 
假设当前程序使用了一些包含许多字段的大型对象,它们的生存期超越了程序运行期,所以它们必须被封存于数据库中,每一个对象都有一个唯一的标识符,以便于从数据库中重新获得对象:

1
2
3
4
5
6
7
8
9
10
class LargeObject {
public:
LargeObject(ObjectID id);
const string& field1() const;
int field2() const;
double field3() const;
const string& field4() const;
const string& field5() const;
...
};

如果要从数据库中获取该对象,有常规方法如下:
1
2
3
4
void restoreAndProcessObject(ObjectID id){
LargeObject object(id);//构造对象
...
}

显然,由于对象实例太大,数据库以及网络的开销也将花费巨大,如果你仅仅只需要某一部分的数据:
1
2
3
4
5
6
void restoreAndProcessObject(ObjectID id){
LargeObject object(id);
if (object.field2() == 0) {
cout << "Object " << id << ": null field2.\n";
}
}

这里我们只需要获取field2的值,获取其他的都是浪费,因此我们决定,当对象被建立时,不从数据库读取所有数据。建立的对象只是一个空壳,只有在需要某个数据时,该数据才从数据库中被读取:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class LargeObject {
public:
LargeObject(ObjectID id);
const string& field1() const;
int field2() const;
double field3() const;
const string& field4() const;
...
private:
ObjectID oid;
mutable string *field1Value;
mutable int *field2Value;
mutable double *field3Value;
mutable string *field4Value;
...
};
LargeObject::LargeObject(ObjectID id)
:oid(id), field1Value(nullptr), field2Value(nullptr), field3Value(nullptr), ...
{}
const string& LargeObject::field1() const{
if (field1Value == null) {
...//从数据库中为filed 1读取数据,使field1Value 指向这个值;
}
return *field1Value;
}

可以看出,每一个成员在访问成员前检查对应的指针是否为空,如为空则进行读取操作。mutable的使用时因为我们可能会在一个const成员函数内修改数据。


Lazy Expression Evaulation

 
考虑如下的矩阵运算:

1
2
3
4
5
6
template<class T>
class Matrix { ... };
Matrix<int> m1(1000, 1000); // 一个 1000 * 1000 的矩阵
Matrix<int> m2(1000, 1000);
...
Matrix<int> m3 = m1 + m2; // m1+m2

显然,eager evaluation差不多会执行1000000次加法。这并不为我们的lazy精神所提倡。
lazy evaluation认为应该建立一个数据结构表示m3的值是m1与m2发生交互的结果,再用一个enum表示矩阵间执行加法操作。如果接下来又有m4=m3*m1,那么同样地,我们会记录m4是m3与m1发生交互的结果,用一个enum表示乘法。
看起来以上操作并无用处,因为很少有人会列出表达式但不要求计算。但是事实上在很多情况下我们只需要计算矩阵的某一个元素或者某一列,因此我们完全没有理由计算出全部,每一次计算都仅仅针对被需求了解的未知量,剩余的部分将保持未计算的状态,直到确实需要它们。


总结

 
如果确实所有的任务都必须完成,那么lazy本质上并没有降低工作量,甚至还增加了内存使用与维护成本。从本质来说,它在当前只做关键的,需要使用的计算,仅此而已。

<i class="fa fa-angle-left"></i>1…141516…27<i class="fa fa-angle-right"></i>

xander.liu

266 posts
11 categories
36 tags
RSS
GitHub E-Mail
© 2024 xander.liu
Powered by Hexo
|
Theme — NexT.Pisces v5.1.4