1. 指针与引用

在这样情况下,使用指针:

  1. 可能存在不指向任何对象的可能
  2. 需要在不同时刻指向不同的对象。

在这样情况下,使用引用:

  1. 上述的其他情况
  2. 重载操作符时,返回值用引用

2.c++风格类型转换

static_cast ,很多类型转换的工作

const_cast, 去掉const 或 volatileness 属性

dynamic_cast, 安全的沿着类的继承关系向下进行类型转换

reinterpret_cast, 指针之间转换(通常不可移植)

3.多态与数组不应该一起使用

如果创建了派生类数组,将其传给基类数组形参,虽然不会报错,但内存分布有很大的问题,派生类一般大于基类,而基类数组间隔小于派生类数组间隔,会导致错误。有很大的潜在问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;
class Base{
public:
int a = 6;
};
class Der: public Base{
public:
int b = 9;
};
void func(const Base bs[10]){
for(int i = 0; i < 10; i++){
cout << bs[i].a;
};
};
int main(void){
Der ds[10];
func(ds);
return 0;
};
(yolo) tangjie.zhang@moveai415:effective_cpp$ ./b
6969696969

4.避免无用的缺省函数

有些类不能有缺省构造函数(比如不能输入没有姓名的地址簿)对于没有缺省构造函数,有三种情况会遇到错误:

  1. 建立数组时。不过也有三种方法回避这个限制

    1. 对于非堆数组,可以提供额外参数,但这不可用于堆数组。

      int ID1, ID2; Base b[] = {Base(ID1), Base(ID2)};

    2. 使用指针数组代替对象数组,即创建数组,数组存放的是对象的指针,对象的构造以及析构都需要手动完成。

    3. 之所以会出错是因为:创建数组分为两步(从栈,从堆new都是),1、分配内存,2、调用默认构造函数,关键在于第二步,所以可以将这两步分开处理。先分配(通过malloc,operator new,**allocator**),再构造(placement new 或者 construct)。它的缺点与第二点类似,需要手动处理内存分配,对象构建,对象析构,内存释放。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      void *rawMemory = 
      operator new[](10*sizeof(EquipmentPiece));

      // make bestPieces point to it so it can be treated as an
      // EquipmentPiece array
      EquipmentPiece *bestPieces =
      static_cast<EquipmentPiece*>(rawMemory);

      // construct the EquipmentPiece objects in the memory
      // 使用"placement new" (参见条款 M8)
      for (int i = 0; i < 10; ++i)
      new (&bestPieces[i]) EquipmentPiece( ID Number )
      删除:
      for (int i = 9; i >= 0; --i)
      bestPieces[i].~EquipmentPiece();
      // deallocate the raw memory

      operator delete[](rawMemory)
  2. 无法很多在基于模板的容器内使用

​ 像vector等优秀的容器没有对默认构造函数的要求了。

提不提供缺省构造函数是程序员的一个选择,提供一个无意义的,会增加其它部分的处理工作,但会简化构造。本书作者认为不应该强求所有类都有默认构造函数。

5.谨慎使用类型转换函数

c++和c都支持隐式转换,这是语言特性,无法控制。如果是自己的类,则有更多的控制能力。

有两个函数允许编译器进行这些转换:单参数构造函数(只要第二个参数开始有默认值就行了)和隐式类型转换运算符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;
class Rational{
public:
Rational(int num = 0,
int den = 1):num(num) , den(den){};

void print(){
cout << "num: " << num << endl;
cout << "den: " << den << endl;
};
int num;
int den;
};
int main(void){
Rational c;
c = 5;
c.print();
return 0;
};
ttj@ttj:~/cpp$ ./b
num: 5
den: 1
1
2
3
4
5
6
7
8
9
class Rational { 
public:
...
operator double() const; // 转换 Rational 类成
}; // double 类型
在下面这种情况下,这个函数会被自动调用:
Rational r(1, 2); // r 的值是 1/2
double d = 0.5 * r; // 转换 r 到 double,
// 然后做乘法

为什么不需要使用转换函数:这些函数会被隐式调用执行,难以判断。

解决方法:

  • 用成员函数替代类型转换运算符(比如string类的 c_str )
  • 为构造函数添加explicit关键字,或者使用代理类如下,c++不允许连续进行两次隐式类型转换。
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
#include <iostream>
using namespace std;

class Array {
public:
class ArraySize { // 这个类是新的
public:
ArraySize(int numElements): theSize(numElements) {}
int size() const { return theSize; }
private:
int theSize;
};
Array(int lowBound, int highBound);
Array(ArraySize size); // 注意新的声明
};
int main(){
Array a = Array::ArraySize(0); // 可以
Array b = 0; // 不行

return 0;
}
ttj@ttj:~/cpp$ g++ -o b 4.1.2.cpp
4.1.2.cpp: In function ‘int main()’:
4.1.2.cpp:18:15: error: conversion from ‘int’ to non-scalar type ‘Array’ requested
18 | Array b = 0;
|

6.自增自减操作符前后缀的区别

为了区分,后缀有一个0作为int参数。

后缀通过前缀实现,

1
2
3
4
5
6
7
8
9
10
11
12
UPInt& UPInt::operator++() 
{
*this += 1; // 增加
return *this; // 取回值
}
// postfix form: fetch and increment
const UPInt UPInt::operator++(int)
{
UPInt oldValue = *this; // 取回值
++(*this); // 增加
return oldValue; // 返回被取回的值
}

7.不要重载&&、 ||、 与 ,

c++与c一致,使用布尔表达式短路求值法。而一旦重载,则你以函数调用法替代了短路求值法: 左右两个表达式都会以未知顺序计算出,然后传递给运算符函数,这是无法变更的。

1
2
3
4
5
6
if (expression1 && expression2) ... 
对于编译器来说,等同于下面代码之一:
if (expression1.operator&&(expression2)) ...
// when operator&& is a member function
if (operator&&(expression1, expression2)) ...
// when operator&& is a global function

8.不同含义的new和delete

new操作符(new operator)和new操作(operator new)是有区别的,

如下auto str = new string("hello") 中的new是new操作符,是语言内置的,他的功能由两部分:

  • 分配足够的内存
  • 调用构造函数初始化内存中的对象

我们能改变的是如何为对象分配内存,这个功能由new操作完成,void * operator new(size_t size);

重载即可,可以增加额外的参数,但第一个参数必须是size_t类型的。(参见 Effective C++ 条款 8 至条款 10。)

如上所述,可以重载以掌握内存分配功能,那么如何掌握初始化对象功能呢:通过placement new实现。

new (buffer) Widget(widgetSize),这是new操作符的一种用法,它会调用如下的operator new(placement new) :void * operator new(size_t, void *location

operator delete 与 delete 操作符的关系与 operator new 与 new 操作符的关系一样

delete操作符会先调用析构函数,然后释放内存。

new,delete与operator new,operator delete应该匹配,不能出现placement new + delete的情况(毕竟你不知道内存是如何分配的,如果是个共享内存呢)

9.使用析构函数防止资源泄漏

。。。略

10.在构造函数中防止资源泄漏

构造函数中产生异常,对象没有被完全构建,已经申请的指针数据不会被自动释放(即使离开作用域),也不能手动释放,因为根本得不到对象地址。

一种做法是在构造函数中捕获所有异常,释放掉所有资源,在让异常重新传递。如下

1
2
3
4
5
6
7
8
9
10
11
12
13
try {                            // 这 try block 是新加入的 
if (imageFileName != "") {
theImage = new Image(imageFileName);
}
if (audioClipFileName != "") {
theAudioClip = new AudioClip(audioClipFileName);
}
}
catch (...) { // 捕获所有异常
delete theImage; // 完成必要的清除代码
delete theAudioClip;
throw; // 继续传递异常
}

类似的做法虽然可以达到要求,但一种更好的做法是将指针交由类管理。纯指针必须经历new 和 delete两个过程,将其作为类的成员变量或者单独使用时,如果因为遗忘或者异常很可能会导致内存泄漏,即使通过try catch 弥补,效果也差强人意。更好的做法时当指针一出现时就交由类(智能指针)管理,一旦对象创建成功,不管该对象是单独使用还是作为父对象的成员变量,都能够自动完成指针释放。(现在纯指针过时了,任何时候都应该记用智能指针替代,我说的)

11.异常不应该传出析构函数(effective c++ 也讲过一样的)

有两种情况会调用析构函数,一种是通过正常情况删除一个对象,宁一种是异常传递的堆栈辗转开解(stack-unwinding)过程中,由异常处理系统删除一个对象。但析构函数不知道异常是不是处于激活状态,所以按照最差情况(异常已激活考虑),他将不能抛出异常(程序本来处于异常中,如果抛出了,会直接终止)

禁止异常传递到析构函数外有两个原因,第一能够在异常转递的堆栈辗转开解(stack-unwinding)的过程中,防止 terminate 被调用。第二它能帮助确保析构函数总能完成我们希望它做的所有事情。

12.理解“抛出一个异常”与“传递一个参数”或“调用一个虚函数”间的差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget { ... };                 //一个类,具体是什么类 
// 在这里并不重要
void f1(Widget w); // 一些函数,其参数分别为
void f2(Widget& w); // Widget, Widget&,或

void f3(const Widget& w); // Widget* 类型

void f4(Widget *pw);

void f5(const Widget *pw);
catch (Widget w) ... //一些 catch 子句,用来
catch (Widget& w) ... //捕获异常,异常的类型为
catch (const Widget& w) ... // Widget, Widget&, 或
catch (Widget *pw) ... // Widget*
catch (const Widget *pw) ...

如上,catch的参数看起来和函数的参数一致,没啥区别,实际上他们有些异同点:

相同点:

  1. 你传递函数参数与异常的途径可以是传值、传递引用或传递指针,这是相同的。

不同点:

  1. 你调用函数时,程序的控制权最终还会返回到函数的调用处,但是当你抛出一个异常时,控制权永远不会回到抛出异常的地方
  2. 当抛出异常时仍将复制出抛出的对象(如上的w),即使是通过引用传递(因为离开作用域,原对象可能被释放),这导致一个问题:抛出异常运行速度比参数传递要慢

​ 当异常对象被拷贝时,拷贝操作是由对象的拷贝构造函数完成的。该拷贝构造函数是对象的静态类型(static type)所对应类的拷贝构造函数,而不是对象的动态类型(dynamic type)对应类的拷贝构造函数。(条款25说明可以根据动态类型拷贝)

在catch中抛出异常时,也有差异:第一个没有拷贝,直接传引用,第二个再次拷贝,传值。(这样会影响效率,而且再次拷贝也会按照静态类型的构造函数生成对象。)一般使用throw。

1
2
3
4
5
6
7
8
9
10
catch (Widget& w)                 // 捕获 Widget 异常 
{
... // 处理异常
throw; // 重新抛出异常,让它
} // 继续传递
catch (Widget& w) // 捕获 Widget 异常
{
... // 处理异常
throw w; // 传递被捕获异常的拷贝
}
  1. 一个被异常抛出的对象,可以传递给非const引用。函数显然不能这么做。
  2. 抛引用,拷贝一次;抛值,拷贝两次(一是拷贝到临时对象中,二是拷贝到参数中);抛指针,拷贝两次(注意,指针应该执行全局内存)。
  3. 函数调用者或抛出异常者与被调用者或异常捕获者之间的类型匹配的过程不同。抛出异常者不会进行类型隐式转换。两种转换除外:catch基类的子句可以catch派生类。类型指针到无类型指针的转换catch (const void*)会捕获任意指针类型异常
  4. catch 子句匹配顺序总是取决于它们在程序中出现的先后顺序

13.通过引用捕获异常

通过指针捕获,会遇到一些问题:空悬指针,应不应该删除指针。

通过值捕获,可以避免上面的问题,但会有新的问题:对象拷贝两次,对象被切割(派生类->基类)

通过引用传递,能解决上面的所有问题。这是被推荐的

14.谨慎使用异常规格(c++11弃用,c++17废除)

unexpected --> terminate --> abort

当没有合适的catch处理异常时,unexpected被调用,如果没有其他的操作,会调用terminate,terminate的默认行为是调用abort,abort会直接结束程序,不会像exit一样执行清理操作,关闭文件,释放内存等。

违反异常规格的异常发生时,由于没有适当的 catch 子句来处理这个异常,程序会调unexpected,然后是 terminate,最终调用 abort。这将导致程序立即停止运行,

15.了解异常处理的系统开销

  1. 建立数据结构来跟踪对象是否被完全构造。

  2. try 块,每个try块预计增加5%-10%代码尺寸,每个异常规格花掉与 try 块一样多的系统开销。

  3. 抛出异常的开销,开销是正常函数的指数级,如果你用异常表示一个比较普遍的状况,那你必须重新予以考虑。

16.牢记80-20准则

即20%的代码使用了80%的资源,包括时间,内存,磁盘等。

80-20 准则既简化了你的工作又使你的工作变得复杂。

简化:大多数时间你能够编写性能一般的代码,因为 80%的时间里这些代码的效率不会影响到整个系统的性能

复杂:如果你的软件出现了性能问题,需要找到那一小块代码位置,提高他们的性能。如何找,两个方法:

  • 大多数人用的方法,猜,感觉。作者认为程序性能特征往往不能靠直觉确定,猜出的概率与随机数差不多。
  • 正确的方法,用 profiler 程序识别出令人讨厌的程序的 20%部分。profiler可以找出语句或者函数被调用了多少次,这可以帮我们推断其他不能直接计算的软件行为。用户不关心函数调用多少,他们只厌恶等待。用尽可能多,有代表性的的数据profile 你的软件。

17.lazy evaluation 懒惰计算法

lazy evaluation应用广泛,如下:

  1. 引用计数。比如复制对象后,s2不代表新对象,而是s1的引用,遇到写数据时才复制。

    1
    2
    String s1 = "Hello";  
    String s2 = s1;
  2. 区别读取还是写入,通常没法直接判断一个操作是读还是写,通过使用 lazy evaluation 和条款 M30 中讲述的 proxy class,我们可以推迟做出是读操作还是写操作的决定,直到我们能判断出正确的答案。

    1
    2
    cout << s[3];                         // 调用 operator[] 读取 s[3] 
    s[3] = 'x'; // 写
  3. 懒惰提取,如果一个类中的若干个数据成员需要从数据库初始化。为避免耗时,初始化时可直接赋零,真正使用时才从数据库读取数据。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    LargeObject::LargeObject(ObjectID id) 
    : oid(id), field1Value(0), field2Value(0), field3Value(0), ...
    {}
    const string& LargeObject::field1() const
    {
    if (field1Value == 0) {
    从数据库中为 filed 1 读取数据,使
    field1Value 指向这个值;
    }
    return *field1Value;
    }
  4. 懒惰表达式计算

    直到用时才计算,话说这不是三元表达式相关的工作吗

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

c+++特别适合用户实现 lazy evaluation,因为它对封装的支持使得能在类里加入lazy evaluation,而根本不用让类的使用者知道。

profiler 调查(参见条款 M16)显示出类实现有一个性能瓶颈,就可以用使用 lazy evaluation 的类实现来替代它

18.分期还期望的计算

上个条款介绍了lazy evaluation,这有种不同的态度也可以提高软件性能:over-eager evaluation。让程序做的事情比被要求的还要多,

例如:min等函数需要频繁调用时,实时跟踪当前的最小最大值,

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

两种应用方法:cache,precatch。空间换时间

priflier的使用:gprof

编译选项增加-pg,运行程序后,出现gmon.out文件。按照如下步骤:

1.编写程序如下

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
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <cstdlib>
int a(void)
{
int i=0,g=0;
while(i++<100000)
{
g+=i;
}
return g;
}

int b(void)
{
int i=0,g=0;
while(i++<400000)
{
g +=i;
}
return g;
}
int main(int argc, char** argv)
{
int iterations;
if(argc != 2)
{
printf("Usage %s <No of Iterations>\n", argv[0]);
exit(-1);
}
else
iterations = atoi(argv[1]);
printf("No of iterations = %d\n", iterations);
while(iterations--)
{
a();
b();
}
}

这里main调用了两个函数a, b,b函数的执行时间应该是a函数的4倍。

2.然后进行编译,注意需要加上 -pg参数,g++ -pg -o b example.cpp。然后运行,./b 50000。之后可以在当前文件夹下看到自动生成的gmon.out文件。(如果需要输出结果中带上源码,需要增加 -g 选项.)

3.然后进行分析,gprof提供的分析包括两个输出模式部分,flat profile模式和call graph模式。

  • Flat profile: 显示每个函数花费的时间,以及函数调用次数。
  • Call graph:显示函数调用链

使用时,一般输入如下命令:gprof b gmon.out 参数, 其中参数包括如下几个:

  • -b: 输出摘要报告,不再输出统计图表中每个字段的详细描c-p 得到每个函数占用的执行时间
  • -q 得到函数的时间消耗列表
  • -A 得到一个带注释的“源代码清单”,指出每个函数的执行次数,需要-g选项编译配合。

flat profile模式:

1
2
3
4
5
6
7
ttj@ttj:~/cpp$ gprof b gmon.out -p -b
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls us/call us/call name
81.27 42.49 42.49 50000 849.75 849.75 b()
20.25 53.08 10.59 50000 211.78 211.78 a()

不加-b可以看到每个字段的详细解释,目前不清楚的是为什么cumulative时间,b为什么比a少

程序的累积执行时间只是包括gprof能够监控到的函数。工作在内核态的函数和没有加-pg编译的第三方库函数是无法被gprof能够监控到的。

call graph模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ttj@ttj:~/cpp$ gprof b gmon.out -q -b
Call graph
granularity: each sample hit covers 2 byte(s) for 0.02% of 53.08 seconds
index % time self children called name
<spontaneous>
[1] 100.0 0.00 53.08 main [1]
42.49 0.00 50000/50000 b() [2]
10.59 0.00 50000/50000 a() [3]
-----------------------------------------------
42.49 0.00 50000/50000 main [1]
[2] 80.0 42.49 0.00 50000 b() [2]
-----------------------------------------------
10.59 0.00 50000/50000 main [1]
[3] 20.0 10.59 0.00 50000 a() [3]
-----------------------------------------------
Index by function name
[3] a() [2] b()

这个表格的每行代表了一个函数,每行内左边有序号的行代表当前行,它上面的行代表调用该函数的函数,它下面的行代表它调用的函数。

gprof只能查看用户态函数,适合于查找用户级程序的瓶颈,无法得到程序在内核态函数的运行时间。

如要监控第三方库函数执行时间,第三方库也必须是添加 –pg 选项编译。

gprof的特点是它只能分析应用程序在运行过程中所消耗掉的CPU时间,只有当应用程序的函数消耗CPU的时候,gprof才能够获取函数的性能数据。如果应用程序在运行过程中暂时挂起,并在系统内核唤醒应用程序后进一步执行,那么在应用程序中间暂停的时间性能数据是无法统计的;而且在应用程序等待I/O操作返回的时间,性能数据也是无法统计的。

参考文献:Linux性能优化gprof使用 - youxin - 博客园 (cnblogs.com)性能分析工具gprof介绍(转载) - 安大叔 - 博客园 (cnblogs.com)

19.理解临时对象的来源

临时对象用户是看不见的,它们不出现在你的源代码中。建立一个没有命名的非堆(non-heap)对象会产生临时对象。这种对象在两种条件下产生:为了使函数成功调用而进行隐式类型转换函数返回对象时。(在异常捕获时,也会通过复制构造函数建立临时对象,再将该对象以值、引用、指针传给catch)

原文说要避免临时对象,因为他们的创建和销毁需要付出开销。(个人感觉这开销是必须的呀,不改变接口的前提下,始终都需要传入一个该类型的对象的)。见20,21都可以消除部分临时对象。

20.协助完成返回值优化

返回 constructor ,而不是直接返回对象。可以降低开销。如下:

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

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

1
2
3
4
5
6
const UPInt operator+(const UPInt& lhs,      // add UPInt 
const UPInt& rhs); // and UPInt
const UPInt operator+(const UPInt& lhs, // add UPInt
int rhs); // and int
const UPInt operator+(int lhs, // add int and
const UPInt& rhs); // UPInt

虽然可行,但记住80-20原则,

22.考虑用运算符的赋值形式(op=)取代其单独形式(op)

23.考虑变更程序库

类型安全(Type Safety)是编程语言中的一个重要概念,它指的是程序在编译或运行时能够防止不恰当的类型操作,从而避免类型错误。类型安全主要包含以下几个方面:

  1. 静态类型检查:在编译时检查变量和表达式的类型,确保类型匹配。如果发现类型不匹配,编译器会报错,阻止程序运行。
  2. 动态类型检查:在运行时检查变量和表达式的类型,如果发现类型不匹配,程序会抛出异常。

参考16-18,如果通过profiler发现有性能瓶颈,尝试更换程序库,比如iostream与printf,

24.理解虚拟函数、多继承、虚基类和RTTI所需的代价

语言设计者是一类人,编译器实现者是另一类人,他们按照第一类人的要求去实现,有时实现的好,有时实现的坏,甚至有时没能实现。

  1. 虚拟函数,要求:执行的代码与调用的动态类型一致。实现:vtbl和vptr。一个对象只要有虚函数,就有vtbl。

****太难了,暂停,看后面的

技巧

25:将构造函数和非成员函数虚拟化

虚拟构造函数,和含义有点区别,并不是在定义构造函数时在前面加上virtual。而是有点类似于函数工厂的一个函数。

考虑一下 readComponent 所做的工作。它根据所读取的数据建立了一个新对象,或是
TextBlock 或是 Graphic。因为它能建立新对象,它的行为与构造函数相似,而且因为它能

建立不同类型的对象,我们称它为虚拟构造函数。虚拟构造函数是指能够根据输入给它的数
据的不同而建立不同类型的对象。

还有一类是虚拟拷贝构造函数,其实就是调用了类中的拷贝构造函数构造了对象,再返回。如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class NLComponent { 
public:
// declaration of virtual copy constructor
virtual NLComponent * clone() const = 0;

};
class TextBlock: public NLComponent {
public:
virtual TextBlock * clone() const // virtual copy
{ return new TextBlock(*this); } // constructor
};
class Graphic: public NLComponent {
public:
virtual Graphic * clone() const // virtual copy
{ return new Graphic(*this); } // constructor
};

虚拟构造函数的返回值比较宽松,因为按道理,派生类继承了基类的构造函数,那么函数的返回值应该与基类一致(指向基类的指针或引用),但返回值其实可以指向派生类的指针或引用。

类似的,虚拟非成员函数并不是说某个非成员函数是virtual的。而是说这个非成员函数调用了类中的虚拟函数。

比如想要通过 <<打印一个类string(像这样,string a, cout << a),如何实现呢,

  • 如果是成员函数,分为两类,将该函数定义到

    • ostream内,不太可能,尽量不要修改这些基本的类

    • string内,可以实现,但一旦作为成员函数,函数的第一个参数默认是this,就只能这么调用,a << cout,不符合一般习惯。

  • 如果不是成员函数,可以声明如下的函数,ostream& operator<<(ostream& s, string& c)可以实现对对象的打印,也能与一般的习惯相符。如果更进一步,想要在这个函数变成虚拟函数(第二个参数,传入string的派生类时,也会调用对应的函数)。由于第二个参数传入的是一个类的引用,它可以对类的函数或者类的派生类的函数进行访问,所以在类中实现一个虚拟函数print,供函数调用即可。

    1
    2
    3
    ostream& operator<<(ostream& s, string& c) { 
    return c.print(s);
    }

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

如何限制->每个类要被实例化,需要先调用构造函数->把构造函数delete或者声明为private就可以了。

假设使用private,这么做了后,将不能创建类的任何实例,如何创建实例呢->使用friend函数创建实例。如:

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

这样有一个缺陷,函数thePrinter被放到了全局命名空间,而不是Printer类中。

  • 一种改进办法是将thePrinter设置为Printer的static函数。
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;
}
  • 另一种改进方式是新建一个命名空间,将thePrinter函数与Printer类放进去。

thePrinter有两个值得注意的细节:

  • 唯一的静态Printer对象是放到函数类,而不是类内,这是因为:

    • 类的静态成员变量在程序启动时初始化,程序结束时释放。函数中的静态局部对象在第一次调用函数时初始化,程序结束时释放。建立 C++一个理论支柱是你不需为你不用的东西而付出,在函数里,把类似于 Printer 这样的对象定义为静态成员就是坚持这样的理论。
    • 还有另一个原因,虽然说类的静态成员变量在程序启动时初始化,但如果有多个object文件呢,他们的初始化顺序不一定,可能会导致一些麻烦,见effective c++ item47
  • (有点微妙,可看原文118)thePrinter不能被声明为内联,

    带有内部链接的函数可能在程序内被复制(也就是说程序的目标(object)代码可能包含一个以上的内部链接函数的代码

可能导致静态对象的拷贝超过一个,所以不要建立包含局部静态数据的非成员函数

from gpt

对于现代C++编译器,这段旧的建议已经不再适用。现代编译器已经能够很好地处理内部链接的函数和局部静态对象,不会出现静态对象被复制或多次初始化的情况。因此,在现代C++中,使用包含局部静态对象的非成员函数是安全的,不会导致多个静态对象拷贝的发生。

前文所说的做法都是保存一个类的静态对象,使用时调用函数返回就行了,这里有一种新的做法,

计算类的实例化数目,超过某个值就报错,否则就创建。这很直观,也容易拓展(类的数量可变)如下:

1
2
3
4
5
6
7
8
9
10
11
12
size_t Printer::numObjects = 0;  
Printer::Printer() {
if (numObjects >= 1) {
throw TooManyObjects();
}
继续运行正常的构造函数;
++numObjects;
}
Printer::~Printer() {
进行正常的析构函数处理;
--numObjects;
}

tips.concrete 类是具体类,和抽象类是反义词

这么做也有一些问题,比如

  • 如果有个派生类ColorPrinter继承自Printer,那么创建派生类时,由于会调用基类的构造函数,也会导致计数器(numObjects)的增加。一种解决方法时,别从具体类派生出类,而是只从虚类派生。
  • 如果Printer是某个类的子类,当他的父类被重复实例化化时,Printer的构造函数会报错。

问题是 Printer 对象能存在于三种不同的环境中:只有它们本身;作为其它派类的基类;被嵌入在更大的对象里。存在这些不同环境极大地混淆了跟踪“存在对象的数目”的含义,因为你心目中的“对象的存在” 的含义与编译器不一致。

通常我们只对对象单独存在的情况感兴趣,我们希望限制该类作为基类,作为子类的情况。使用本节第一种方法就好限制,因为构造函数是private,所以该类不能作为基类也不能作为子类。可以将这个技术应用到其他地方。比如:

有一个类,含有非虚析构函数(见effective c++ 14,基类的析构函数应该是虚拟的,不然没法通过指针或者引用释放派生类的部分),那么应该禁止该类产生派生类,但是我们并不限制该类单独产生多少对象。就可以使用private构造函数来实现。还可以优化的一点是makeFAS返回的应该是智能指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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函数封装对单个函数的访问,使得我们在每次运行程序时只能使用一个对象,甚至不能销毁后建立一个新的对象。如何改变这一点?将计数的代码与private构造代码结合在一起就行了。它既保留了不能作为基类,子类的特性,又保留了可以限制对象数量的特性。如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Printer { 
public:
class TooManyObjects{};
static Printer * makePrinter();
...
private:
static size_t numObjects;
Printer();
Printer(const Printer& rhs); //我们不定义这个函数
}; //因为不允许 //进行拷贝
// (参见 Effective C++条款27)
// Obligatory definition of class static
size_t Printer::numObjects = 0;
Printer::Printer()
{
if (numObjects >= 1) {
throw TooManyObjects();
}
继续运行正常的构造函数;
++numObjects;
}
Printer * Printer::makePrinter()
{ return new Printer; }
一个具有对象计数功能的基类

上述的方法十分好用,但使用有点麻烦,我们就想可不可以将实例计数封装一下,之后如果想要创建类,继承一下封装的类就行了。。。

有一些要点,这个用于实例计数的基类应该拥有计数的所有变量,函数。同时也要保证每个进行实例计数的都拥有相互隔离的计数器。**—->通过模板实现**(太巧妙了,我什么时候才能想到啊。除此以外,其他方法都是不行的,非静态成员变量肯定不行,他没法对类的数量计数;静态成员变量也是不行的,因为派生的类访问的都是基类的静态变量,不能实现相互隔离,虽然说可以在派生的类中定义同名变量覆盖掉基类的变量,但这太麻烦了,每定义一个类都需要覆盖一次)

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
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;
}

Counted类中有一个protected,其中包含了该类的构造/析构函数,这使得我们不能单独构造该类,不能将该类作为一个子类,但可以将其作为一个基类。继承时,这么做就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Printer: private Counted<Printer> { 
public:
// 伪构造函数
static Printer * makePrinter();
static Printer * makePrinter(const Printer& rhs);
~Printer();
...
using Counted<Printer>::objectCount; // 参见下面解释
using Counted<Printer>::TooManyObjects; // 参见下面解释
private:
Printer();
Printer(const Printer& rhs);
bbs.theithome.com
};

没人关心基类的具体细节,所有使用了private继承,

Counted 所做的大部分工作对于派生类是隐藏的,但派生类可能想知道有多少Printer对象存在,所以可以使用using 将objectCount函数恢复public访问权。(至于为什么将TooManyObjects声明为public还不太清楚,虽然原文解释了,128)

后还有一点需要注意,可以定义 Counted 内的静态成员,也可以不定义,每定义一个派生类时再定义(如果程序员忘了定义,也会有报错)

27.禁止在堆中分配对象

要求在堆中分配对象

如何限制仅在堆中分配对象。非堆对象在定义时自动构造,在生命周期结束时调用析构函数。所以只要禁止这两个函数之一,构建非堆对象就会报错了->无法构造了。

一般做法是让析构函数变成private,让构造函数变成public,可以额外提供一个伪析构函数供在堆的对象调用。如下:(不能使用delete a,这会调用operater delete函数以及析构函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>                                                             using namespace std;
class A{
public:
A();
destroy();
private:
~A();
};
int main(void){
A();
auto a = new A();
a->destroy(); //不能使用delete a,这会调用operater delete函数以及析构函数
return 0;
}
te.cpp: In function ‘int main()’:
te.cpp:10:7: error: ‘A::~A()’ is private within this context
10 | A();
| ^
te.cpp:7:5: note: declared private here
7 | ~A();
| ^

将析构函数声明为private,不仅限制了对象本身的构造,也限制其作为基类以及类中的子类。

上面的问题可以解决:比如把析构函数声明为protected,如果某个类包含本类,则改为指向其的指针。(friend类,可以解决吧,应该)

判断一个对象是否在堆中
可以分析下在堆,在非堆的初始化顺序
  • 在堆中,调用operator new -> 调用构造函数
  • 在非堆,系统完成内存分配 -> 调用构造函数

区别在于,在堆中需要多执行一个opearor new 函数,可以在这执行一些判定。

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
28
29
#include <iostream>
using namespace std;
class heapcon{
public:
class HeapConstraintViolation{};
heapcon();
static void * operator new(size_t size);
static bool onHeap;

};
bool heapcon::onHeap = false;
void* heapcon::operator new(size_t size){
onHeap = true;
return ::operator new(size);
};
heapcon::heapcon(){
if(!onHeap){
cerr << "cannot constuct in !heap";
throw HeapConstraintViolation();
}
cerr << "construct in heap;";
onHeap = false;
};
int main(void){
// auto h = new heapcon();
// heapcon s{};
heapcon* temp = new heapcon(*new heapcon());
return 0;
};

但有个缺陷是无法使用 new[],除非还需要定义一个operator new[],但是operator new[]只调用一次,但是,构造函数会调用n次(n为数组大小),所以可以考虑将onheap改为一个计数的。(但如果有多个数组同时定义,安全性能保证吗,不知道不知道,或者直接将new [] 禁了,反正也不常用/dog)

原书作者也认为上述做法不太靠谱。

所以又有一种新的判断方式,内存,系统中,栈向下,堆向上生长,要判断一个地址是栈还是堆可以用该地址与临时定义的变量地址(位于栈顶)比较。

1
2
3
4
5
6
bool onHeap(const void *address) 
{
char onTheStack; // 局部栈变量
return address < &onTheStack;

}

但这只能区分栈与非栈。一个对象可以定义在三个地方,栈,堆,静态对象的地址。现在没法区分堆和静态对象。

前两种方式都不靠谱,作者又这么分析

之所以需要知道一个地址是位于堆还是非堆,主要是想要安全的delete 该对象。不过 安全的delete该对象与该对象地址位于堆不是等价的。(比如,父类在堆上定义,它的子类地址也在堆上,但这不意味着可以直接delete 该子类。)

还有一种方式是建立一个地址集合,operator new时往其中加元素,operator delete时从中移除元素。但这有几个缺陷:

  • 如果定义为全局的operator会污染全局空间
  • 效率降低
  • 如何判断一个地址是否在地址集合内,这中间有些缺陷。
禁止堆对象

建立对象分为三类:对象直接实例化,对象作为基类初始化,对象被嵌入到其他对象类初始化。

  • 对象直接实例化:对象在堆中被初始化,必须调用new,这些操作符内嵌于语言,无法修改,但可以利用他们都需要调用operator new这个特点,将operator new设置为private。
  • 对象作为基类初始化:这么做有个缺陷,一个类的operator new 被定义为private,那么它的派生类也不能在堆上创建(除非派生类重写operator new)
  • 对象被嵌入到其他对象类初始化:如果一个类的operator new 为private,它可以作为另一个类的成员变量,不会造成什么影响。

28.smart 指针

相比于内建指针(dumb pointer),智能指针有很多优势:

  • 构造与析构,这两个函数可以完全程序员控制,可以用于防止资源泄漏。
  • 拷贝与赋值,拷贝构造函数、移动构造函数也由程序员控制,可以实现不同形式的复制,如潜复制、深复制。
  • Dereferencing,解引用操作符也由程序员控制,可以给解引用符定义返回不同的内容。

涉及到智能指针构造、赋值、析构

构造,赋值,存在以下几种处理方式

  • 只拷贝原始指针,这会导致多个指针指向内存中的同一个地址,会导致段内存被delete多次,

  • 调用new,建立新对象,但这遗漏了派生类这种情况,会导致派生类被截断。

  • 所有权转移,等号右边的指针传给左边,右边的指针改为0。这也有个缺陷,每个指针对象只能赋值给其他对象一次,用完指针就没了。(但也有个trick,可以用const unique& 来访问指针,不会产生赋值)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void printTreeNode(ostream& s, auto_ptr<TreeNode> p) 
    { s << *p; }
    int main()
    bbs.theithome.com
    {
    auto_ptr<TreeNode> ptn(new TreeNode);
    ...
    printTreeNode(cout, ptn); //通过传值方式传递 auto_ptr
    ...
    }

实现解引用时,如下,返回值为引用类型而不是对象。最关键的原因是防止派生类被切割。(这么说,涉及到指针,引用时,不到最后使用时,不该将其转化为类类型。

1
2
3
4
5
6
template<class T> 
T& SmartPtr<T>::operator*() const
{
perform "smart pointer" processing;
return *pointee;
}
进阶内容,如何判断智能指针是否为NULL

要想行为与内置指针一致,一种很自然的想法是为智能指针添加一个转换函数。

1
operator void*();

这么做就能达到与内置指针同样的性质。但这样还是有点瑕疵,比如遇到如下情况:

1
2
3
4
SmartPtr<Apple> pa; 
SmartPtr<Orange> po;
...
if (pa == po) ..

两者都会被转换成void*指针进行比较,(见条款5,这可能导致很多问题。有些会转换成bool值,比如c++)

有另一种方法是重载 operator!,但这只对该操作符有效。

进阶内容,如何将智能指针变为内置指针

结论,不要提供这样的隐式类型转换,

c++已经支持智能指针从派生类到基类的转换(unique_ptr由于资源问题暂不支持,也许可以试试move?)

29.引用计数

动机1:资源管理,垃圾回收

动机2:节省内存,多个对象可能对应内存中的同一个区域

如何实现:

要计数,需要一个位置保存计数的值,这个值可以和引用的对象一起保存。所以我们将创建一个类来保存引用计数及其跟踪的值。我们叫这个类StringValue,并将其定义在对象内,这阻止了其他人的访问。

1
2
3
4
5
6
7
8
class String { 
public:
... // the usual String member functions go here
private:
struct StringValue { ... }; // holds a reference count
// and a string value
StringValue *value; // value of this String
};

。。。。。。。。没看完

30.代理类

通过实现多维数组,需要让类可以执行这种操作:A[][],由于只能重载一个[],可以考虑执行后返回一个一维数组,再次调用重载[]函数。这其中的一维数组就可以作为代理类。

上章讲过,因为没法区分operator[]是读还是写,只能假设所有调用都是写操作(导致一旦调用该函数,就将shared置为false),这其实是不合理的,可能会浪费资源。现在考虑能不能将判断读还是写的时机推出到operatro[]之后,这就会利用到代理类。