More Effective C++ 阅读笔记 解释清晰

文章目录

3.1 Item M1: 指针与引用的区别

  1. 在任何情况下都不能使用指向空值的引用。一个引用必须总是指向某些对象,而指针可以置为空。

在C++里,引用应被初始化。可能有人会不知道引用怎么指向空值:
image.png
这是非常危险的未定义行为。
所以就不要做那些匪夷所思的操作,这样便可利用引用提高一些效率,如下点。

  1. 不存在指向空值的引用这个事实意味着使用引用的代码效率比使用指针的要高。因为在使用引用之前不需要测试它的合法性。
void printDouble(const double& rd)
{
    cout << rd; // 不需要测试rd,它
} 				// 肯定指向一个double值

相反,指针则应该总是被测试,防止其为空。

  1. 指针可以被重新赋值以指向另一个不同的对象。但是引用则总是指向在初始化时被指定的对象,以后不能改变。

  2. 在以下情况下你应该使用指针:

    1. 一是你考虑到存在不指向任何对象的可能(在这种情况下,你能够设置指针为空)
    2. 二是你需要能够在不同的时刻指向不同的对象(在这种情况下,你能改变指针的指向。

如果总是指向一个对象并且一旦指向一个对象后就不会改变指向,那么你应该使用引用。

  • 当你重载某个操作符时,你应该使用引用(重载操作符的返回值)
  • 毕竟你也不想这样用吧
vector<int> v(10); 	// 建立整形向量(vector),大小为10;
                    // 向量是一个在标准C库中的一个模板(见条款M35)
v[5] = 10; 			// 这个被赋值的目标对象就是操作符[]返回的值

//如果操作符[]返回一个指针,那么后一个语句就得这样写:
*v[5] = 10;

//这样会使得	v	看上去象是一个数组指针

3.2 Item M2:尽量使用C++风格的类型转换

  1. C 风格的类型转换能允许你在任何类型之间进行转换,这样在作者看来太过粗鲁不过如果要进行更精确的类型转换,这会是一个优点。(这里我的理解是C语言的类型转换是 bite-wise的)
  2. C 风格的类型转换在程序语句中难以识别
  3. C++通过引进四个新的类型转换操作符克服了C风格类型转换的缺点,这四个操作符是, static_cast, const_cast, dynamic_cast, 和reinterpret_cast
//这些操作符你只需要知道原来你习惯于这样写
(type) expression
//而现在你总应该这样写:
static_cast<type>(expression)
  1. static_cast在功能上基本上与C风格的类型转换一样强大,含义也一样。它也有功能上限制。不能用于结构体与原生类型的转换,也不能const和非const的转换
  2. const_cast用于类型转换掉表达式的const或volatileness属性
  3. dynamic_cast被用于安全地沿着类的继承关系向下进行类型转换。你能用dynamic_cast把指向基类的指针或引用转换成指向其派生类或其兄弟类的指针或引用,而且你能知道转换是否成功。失败的转换将返回空指针
  4. reinterpret_cast。使用这个操作符的类型转换,其的转换结果几乎都是执行期定义reinterpret_casts的最普通的用途就是在函数指针类型之间进行转换。且转换函数指针的代码是不可移植的,且很少用到,所以避免使用该操作

3.3 Item M3:不要对数组使用多态

  1. 编译器为了建立正确遍历数组的执行代码,它必须能够确定数组中对象的大小,这对编译器来说是很容易做到的。
class a{
    ...
}
class b:public a{
    ....
}

void console(const a array[] , int nums){
    for (int i = 0; i < nums; i + + ) {
        cout << array[i];
    }
}

b vec[10];
console(vec,10);


  • vec是一个指向数组起始地址的指针
  • 但是 vec 中各元素内存地址与数组的起始地址的间隔究竟有多大呢?
  • 这是编译器就决定的,如上例,当vec被传入console函数时,就说明数组中元素大小被编译器解释为 a 类型了,当你的派生类和基类大小不一的时候呢?没人知道会发生什么预料外的事。可当你在console函数中 free 这个数组呢?编译器只会 free b中属于a的部分,这会造成内存泄漏,是非常危险的。

3.4 Item M4:避免无用的缺省构造函数

  1. 无用的默认构造函数使生成一个类变得简单,但是因此你必须得为此付出代价,在对这种类进行操作前,你必须对其进行有效性检验,你并不知道你目前需要处理的这个对象内的那个数据是否存在。
class a{
public:
    int* wa
}

void dosomething{
    //你得先检查 a 中的 wa 是否合法
    do.......something.....
}

4.1 Item M5:谨慎定义类型转换函数

  1. 你能选择是否提供函数让编译器进行隐式类型转换
    • 有两种函数允许编译器进行这些的转换:单参数构造函数(single-argument constructors)和隐式类型转换运算符。
    • 该函数可以是只定义了一个参数,也可以是虽定义了多个参数但第一个参数以后的所有参数都有缺省值。
    • 隐式类型转换运算符只是一个样子奇怪的成员函数:operator 关键字,其后跟一个类型符号。
class Rational {//有理数
public:
    ...
    operator double() const; // 转换Rational类成double类型
}; 					

//在下面这种情况下,这个函数会被自动调用:
Rational r(1, 2); 	// r 的值是1/2
double d = 0.5 * r; // 转换 r 到double,
                    // 然后做乘法

作者真正想说的是:根本问题是当你在不需要使用转换函数时,这些的函数缺却会被调用运行。结果,这些不正确的程序会做出一些令人恼火的事情,而你又很难判断出原因。
解决方法是:用不使用语法关键字的等同的函数来替代转换运算符。例如为了把Rational对象转换为double,用asDouble函数代替operator double函数:

double asDouble() const; //转变 Rational 成double

显示调用进行类型转换,避免一些隐式造成的不清楚的问题,具体可以看书中例子

  1. explicit关键字。为了解决隐式类型转换而特别引入的这个特性,构造函数用explicit声明,如果这样做,编译器会拒绝为了隐式类型转换而调用构造函数。
  2. 除非你确实需要,不要定义类型转换函数

4.2 Item M6:自增、自减操作符前缀形式与后缀形式的区别

  1. C++规定后缀形式有一个int类型参数,当函数被调用时,编译器传递一个0做为int参数的值给该函数
  2. 操作符前缀与后缀形式返回值类型是不同的。前缀形式返回一个引用,后缀形式返回一个const类型。
    • 因此如果:i++++;是合法的,i 将仅仅增加了一次。因为后缀返回的并不是它本身引用,而是一个值,所以最好禁止这么做。

4.3 ItemM7:不要重载“&&”,“||”, 或“,”

  1. C++允许根据用户定义的类型,来定制&&和||操作符。但确实不要做这种反人类的东西
    • 因为与C一样,C++使用布尔表达式短路求值法(short-circuit evaluation)。这表示一旦确定了布尔表达式的真假值,即使还有部分表达式没有被测试,布尔表达式也停止运算。
a&&b
若 a 表达式错了就没必要去算 b 表达式了

若你重载了布尔运算符,就代表了你以以函数调用法替代了短路求值法
对于你来说代码是这样的:

if (expression1 && expression2) ...

对于编译器来说,等同于下面代码之一:

if (expression1.operator&&(expression2)) ...
    // when operator&& is a
    // member function
if (operator&&(expression1, expression2)) ...
    // when operator&& is a
    // global function

这好像没有什么不同,但是函数调用法与短路求值法是绝对不同的。首先当函数被调用时,需要运算其所有参数,所以调用函数functions operator&& operator||时,两个参数都需要计算,换言之,没有采用短路计算法。

  1. 逗号运算符也是,不要重载
    • 经常在for循环的更新部分(update part)里遇见它
for (int i = 0, j = 10;i < j; ++i, --j) // 啊! 逗号操作符!
{
    do something....
}

在 for 循环的最后一个部分里,i 被增加同时 j 被减少。在这里使用逗号很方便,因为在最后一个部分里只能使用一个表达式,分开表达式来改变 i 和 j 的值是不合法的。

4.4 Item M8:理解各种不同含义的new和delete

  1. 你使用的new是new操作符。这个操作符就象sizeof一样是语言内置的,你不能改变它的含义,它的功能总是一样的。它要完成的功能分成两部分。第一部分是分配足够的内存以便容纳所需类型的对象。第二部分是它调用构造函数初始化内存中的对象。new操作符总是做这两件事情,你不能以任何方式改变它的行为。
    • 你所能改变的是如何为对象分配内存,new操作符为分配内存所调用函数的名字是operator new。
    • 函数operator new 通常这样声明:
void * operator new(size_t size);
  1. placement new
    • 有时你有一些已经被分配但是尚未处理的(raw)内存,你需要在这些内存中构造一个对象。你可以使用一个特殊的operator new ,它被称为placement new。
  2. 为了避免内存泄漏,每个动态内存分配必须与一个等同相反的deallocation对应。
    1. operator delete与delete操作符的关系与operator new与new操作符的关系一样
  3. new和delete操作符是内置的,其行为不受你的控制,凡是它们调用的内存分配和释放函数则可以控制。

5.1 Item M9:使用析构函数防止资源泄漏

  • 资源应该被封装在一个对象里,遵循这个规则,你通常就能够避免在存在异常环境里发生资源泄漏,通过智能指针的方式。
  • C++确保删除空指针是安全的,所以析构函数在删除指针前不需要检测这些指针是否指向了某些对象。

5.2 Item M10:在构造函数中防止资源泄漏

  1. C++仅仅能删除被完全构造的对象(fully contructed objects), 只有一个对象的构造函数完全运行完毕,这个对象才被完全地构造。C++拒绝为没有完成构造操作的对象调用析构函数。
    • 原因是:在很多情况下这么做是没有意义的,甚至是有害的。如果为没有完成构造操作的对象调用析构函数,析构函数如何去做呢?
  2. 该条款主要是怕构造函数抛出异常使构造函数未完成,且未捕获 或 因为某些原因被捕获后未进行恰当处理,导致类中指针成员变量指向的本该在析构中释放的资源,未被释放。
    • 因为当对象在构造中抛出异常后C++不负责清除对象,所以你必须重新设计你的构造函数以让它们自己清除。经常用的方法是捕获所有的异常,然后执行一些清除代码,最后再重新抛出异常让它继续转递。
    • 更好的解决方法是采用条款M9的建议,把指针成员变量指向的对象做为一个资源,被一些局部对象管理,如auto_ptr。因为成员变量被auto_ptr包裹着,当对象被删除时它们能被自动地删除。

5.3 Item M11:禁止异常信息(exceptions)传递到析构函数外

  1. 在有两种情况下会调用析构函数。第一种是在正常情况下删除一个对象,例如对象超出了作用域或被显式地delete。第二种是异常传递的堆栈辗转开解(stack-unwinding)过程中,由异常处理系统删除一个对象。
  2. 如果在一个异常被激活的同时,析构函数也抛出异常,并导致程序控制权转移到析构函数外,C++将调用terminate函数。这个函数的作用正如其名字所表示的:它终止你程序的运行,而且是立即终止,甚至连局部对象都没有被释放,所以该条款的目的之一便是为了防止terminate的调用。
  3. 如果一个异常被析构函数抛出而没有在函数内部捕获住,那么析构函数就不会完全运行(它会停在抛出异常的那个地方上)。如果析构函数不完全运行,它就无法完成希望它做的所有事情。,所以该条款的目的之一便是为了析构能达到他的目的。

怎么做呢?就是catch下来,这样不会terminate,然后恰当的做!

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

image.png

  1. 你调用函数时,程序的控制权最终还会返回到函数的调用处,但是当你抛出一个异常时,控制权永远不会回到抛出异常的地方。
  2. **C++规范要求被做为异常抛出的对象必须被复制。**因为抛出异常的对象可能是个临时对象之类的,当你离开作用域到异常控制区的时候,其析构函数将被调用,如果不是拷贝传递的话,catch语句很可能得到的是已经析构的对象。
  3. 当我们这样声明一个catch子句时:
catch (Widget w) ... // 通过传值捕获

会建立两个被抛出对象的拷贝,一个是所有异常都必须建立的临时对象,第二个是把临时对象拷贝进w中(WQ加注,重要:是两个!)。

catch (Widget& w) ... // 通过引用捕获
catch (const Widget& w) ... //也通过引用捕获
//这便只会产生一个拷贝
  1. 异常处理采用的是最先适合法。如果一个处理派生类异常的catch子句位于处理基类异常的catch子句后面,编译器会发出警告。因为这样所有本来属于派生类异常处理函数的 job 都被处理基类异常做掉了。

5.5 Item M13:通过引用(reference)捕获异常

  1. 当你选择通过指针来抛出且捕获时,你并不知道你抛出的这个指针是否指向一个局部对象还是一个static对象,而且若采用建立一个堆对象抛出指针的方法,catch子句的作者又面临一个令人头疼的问题:他们是否应该删除他们接受的指针?所以不要那么麻烦,再者!通过指针捕获异常也不符合C++语言本身的规范。
  2. 通过值捕获异常(catch-by-value)可以解决上述的问题,例如异常对象删除的问题和使用标准异常类型的问题。但是当它们被抛出时系统将对异常对象拷贝两次(参见条款M12)。而且它会产生slicing problem,即派生类的异常对象被做为基类异常对象捕获时,那它的派生类行为就被切掉了(sliced off)。
  3. 如果你通过引用捕获异常(catch by reference),你就能避开上述所有问题,不会为是否删除异常对象而烦恼;能够避开slicing异常对象;能够捕获标准异常类型;减少异常对象需要被拷贝的数目。所以你还在等什么?通过引用捕获异常吧!

5.6 Item M14:审慎使用异常规格(exception specifications)

  1. 异常规格既可以做为一个指导性文档同时也是异常使用的强制约束机制
  2. 如果一个函数抛出一个不在异常规格范围里的异常,系统在运行时能够检测出这个错误,然后一个特殊函数std::unexpected将被自动地调用。std::unexpected缺省的行为是调用函数std::terminate,而std::terminate缺省的行为是调用函数abort。应避免调用std::unexpected
  3. 避免在带有类型参数的模板内使用异常规格。

image.png

  1. C++允许你用其它不同的异常类型替换std::unexpected异常,通过std::set_unexpected

例如你正在编写一个软件,精确地使用了异常规格,但是你必须从没有使用异常规格的程序库中调用函数,要防止抛出unexpected异常是不现实的,因为这需要改变程序库中的代码。

  1. 综上所述,异常规格是一个应被审慎使用的特性。在把它们加入到你的函数之前,应考虑它们所带来的行为是否就是你所希望的行为。

5.7 Item M15:了解异常处理的系统开销

  1. 为了在运行时处理异常,程序要记录大量的信息。无论执行到什么地方,程序都必须能够识别出如果在此处抛出异常的话,将要被释放哪一个对象;程序必须知道每一个入口点,以便从try块中退出;对于每一个try块,他们都必须跟踪与其相关的catch子句以及这些catch子句能够捕获的异常类型。虽然确保程序满足异常规格不需要运行时的比较(runtime comparisons),而且当异常被抛出时也不用额外的开销来释放相关的对象和匹配正确的catch字句。
  2. 在理论上,你不能对这些代价进行选择:异常是C++的一部分,C++编译器必须支持异常。也就是说,当你不用异常处理时你不能让编译器生产商消除这方面的开销,因为程序一般由多个独立生成的目标文件(object files)组成,只有一个目标文件不进行异常处理并不能代表其他目标文件不进行异常处理。不过这只是理论,实际上大部分支持异常的编译器生产商都允许你自由控制是否在生成的代码里包含进支持异常的内容
  3. 使用异常处理的第二个开销来自于try块,无论何时使用它,也就是当你想能够捕获异常时,那你都得为此付出代价,粗略地估计,如果你使用try块,代码的尺寸将增加5%-10%并且运行速度也同比例减慢。这还是假设程序没有抛出异常,我这里讨论的只是在程序里使用try块的开销。为了减少开销,你应该避免使用无用的try块。
  4. 编译器为异常规格生成的代码与它们为try块生成的代码一样多,所以一个异常规格一般花掉与try块一样多的系统开销。
  5. 与一个正常的函数返回相比,通过抛出异常从函数里返回可能会慢三个数量级。这个开销很大。但是仅仅当你抛出异常时才会有这个开销,一般不会发生。
  6. 尽管我们知道异常确实会带来开销,却很难预测出开销的准确数量。事实是大部分人包括编译器生产商在异常处理方面几乎没有什么经验。

6.1 ItemM16:牢记80-20准则(80-20 rule)

  1. 80-20准则说的是大约20%的代码使用了80%的程序资源;大约20%的代码耗用了大约80%的运行时间;大约20%的代码使用了80%的内存;大约20%的代码执行80%的磁盘访问;80%的维护投入于大约20%的代码上;通过无数台机器、操作系统和应用程序上的实验这条准则已经被再三地验证过。80-20准则不只是一条好记的惯用语,它更是一条有关系统性能的指导方针,它有着广泛的适用性和坚实的实验基础。
  2. 当想到80-20准则时,不要在具体数字上纠缠不清,一些人喜欢更严格的90-10准则,而且也有一些试验证据支持它。不管准确地数字是多少,基本的观点是一样的:软件整体的性能取决于代码组成中的一小部分。
  3. 性能提升不能依靠直觉,要根据具体程序,例如在程序里使用能够最小化计算量的奇特算法和数据结构,但是如果程序的性能限制主要在I/O上(I/O-bound)那么就丝毫起不到作用。
  4. 正确的方法是用profiler程序识别出令人讨厌的程序的20%部分。profiler告诉你每条语句执行了多少次或各函数被调用了多少次,这是一个作用有限的工具。不过,知道语句执行或函数调用的频繁程度,有时能帮助你洞察软件内部的行为。例如如果你建立了100个某种类型的对象,会发现你调用该类的构造函数有上千次,这个信息无疑是有价值的。

6.2 Item M17:考虑使用lazy evaluation(懒惰计算法)

  1. 当你使用了lazy evaluation后,采用此种方法的类将推迟计算工作直到系统需要这些计算的结果。如果不需要结果,将不用进行计算,软件的客户和你的父母一样,不会那么聪明。
  2. lazy evaluation广泛适用于各种应用领域,所以我将分四个部分讲述。
    1. 引用计数
      • string的复制只有在被修改时才会制作属于自己的拷贝,否则共享,如果我们很幸运,这2个字符串不会被修改,这种情况下我们永远也不会为赋给它独立的值耗费精力。
    2. 区别对待读取和写入
    3. Lazy Fetching(懒惰提取):要用的时候再去拿,不用时候就假装有这个值
    4. Lazy Expression Evaluation(懒惰表达式计算):比如一个很大的矩阵等于另外2个矩阵相加,我们可以先不计算,只是记着他是那2个矩阵相加,等用到的时候再做具体计算。

6.3 Item M18:分期摊还期望的计算

  1. 该条款的核心就是over-eager evaluation(过度热情计算法):在要求你做某些事情以前就完成它们
    1. 假设min,max和avg函数分别返回现在这个集合的最小值,最大值和平均值,有三种方法实现这三种函数。
      1. 使用eager evaluation(热情计算法),当min,max和avg函数被调用时,我们检测集合内所有的数值,然后返回一个合适的值。
      2. 使用lazy evaluation(懒惰计算法),只有确实需要函数的返回值时我们才要求函数返回能用来确定准确数值的数据结构。
      3. 使用 over-eager evaluation(过度热情计算法),我们随时跟踪目前集合的最小值,最大值和平均值,这样当min,max或avg被调用时,我们可以不用计算就立刻返回正确的数值。

6.4 Item M19:理解临时对象的来源

  1. 在C++中真正的临时对象是看不见的,它们不出现在你的源代码中。建立一个没有命名的非堆(non-heap)对象会产生临时对象。
  2. 这种未命名的对象通常在两种条件下产生:为了使函数成功调用而进行隐式类型转换和函数返回对象时。
  3. 临时对象是有开销的,所以你应该尽可能地去除它们,然而更重要的是训练自己寻找可能建立临时对象的地方。

6.5 Item M20:协助完成返回值优化

  1. 一个返回对象的函数很难有较高的效率,因为传值返回会导致调用对象内的构造和析构函数(参见条款M19),这种调用是不能避免的。
  2. 一些函数(operator*也在其中)必须要返回对象。这就是它们的运行方法。你所应该关心的是把你的努力引导到寻找减少返回对象的开销上来,而不是去消除对象本身(我们现在认识到这种寻求是无用的)。
  3. 编译器该做的,返回值优化(return value optimization)(WQ加注:在《深度探索C++物件模型》中有更多更详细的讲述,它叫之为named return value optimization。NRV优化

6.6 Item M21:通过重载避免隐式类型转换

class A{
public: 
    A();
    A(int value);
}
const A operator+(const A& lhs, const A& rhs);

A a1,a2,a3;

a3 = a1 + 10;
a3 = 10 + a2;

这些语句也能够成功运行。方法是通过建立临时对象把整形数10转换为UPInts(参见条款M19)。
让编译器完成这种类型转换是确实是很方便,但是建立临时对象进行类型转换工作是有开销的,而我们不想承担这种开销。
我们只是想让 A 类型 和int 类型相加,你可以用函数重载来消除类型转换,如下

const A operator+(const A& lhs, const A& rhs); 

const A operator+(const A& lhs, int rhs);

const A operator+(int lhs, const A& rhs); 

但是很有可能陷入危险之中

const A operator+(int lhs, int rhs); // 错误!

这将会改变int类型相加的含义
所以没有必要实现大量的重载函数,除非你有理由确信程序使用重载函数以后其整体效率会有显著的提高。

6.7 Item M22:考虑用运算符的赋值形式(op=)取代其单独形式(op)

  1. 就C++来说,operator+、operator=和operator+=之间没有任何关系,因此如果你想让这三个operator同时存在并具有你所期望的关系,就必须自己实现它们。
  2. operator的赋值形式(operator+=)比单独形式(operator+)效率更高。做为一个库程序设计者,应该两者都提供,做为一个应用程序的开发者,在优先考虑性能时你应该考虑考虑用operator赋值形式代替单独形式。

6.8 Item M23:考虑变更程序库

  1. 程序库的设计就是一个折衷的过程,因为没有一个库是完美的,鱼和熊掌不可兼得。不同的设计者给库的特征赋予了不同的优先级。他们从而在设计中牺牲了不同的东西。因此一般两个提供相同功能的程序库却有着完全不同的性能特征。
    1. 例如,考虑iostream和stdio程序库,对于C++程序员来说两者都是可以使用的。iostream程序库与C中的stdio相比有几个优点(参见Effective C++)。例如它是类型安全的(type-safe),它是可扩展的。然而在效率方面,iostream程序库总是不如stdio,因为stdio产生的执行文件与iostream产生的执行文件相比尺寸小而且执行速度快。
  2. 因为不同的程序库在效率、可扩展性、移植性、类型安全和其他一些领域上蕴含着不同的设计理念,通过变换使用给予性能更多考虑的程序库,你有时可以大幅度地提高软件的效率。

6.9 Item M24:理解虚拟函数、多继承、虚基类和RTTI所需的代价

  1. 当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致;指向对象的指针或引用的类型是不重要的。编译器如何能够高效地提供这种行为呢?大多数编译器是使用virtual tablevirtual table pointers。这两通常被分别地称为vtblvptr。一个vtbl通常是一个函数指针数组。(一些编译器使用链表来代替数组,但是基本方法是一样的)
    1. 这个论述引出了虚函数所需的第一个代价:你必须为每个包含虚函数的类的virtual talbe留出空间。
      1. 因为在程序里每个类只需要一个vtbl拷贝,所以编译器肯定会遇到一个棘手的问题:把它放在哪里。
        1. 必须采取一种不同的方法,编译器厂商为此分成两个阵营。对于提供集成开发环境(包含编译程序和连接程序)的厂商,一种干脆的方法是为每一个可能需要vtbl的object文件生成一个vtbl拷贝。连接程序然后去除重复的拷贝,在最后的可执行文件或程序库里就为每个vtbl保留一个实例。
        2. 更普通的设计方法是采用启发式算法来决定哪一个object文件应该包含类的vtbl。通常启发式算法是这样的:要在一个object文件中生成一个类的vtbl,要求该object文件包含该类的第一个非内联、非纯虚拟函数(non-inline non-pure virual function)定义(也就是类的实现体)。
    2. 第二个代价是每个声明了虚函数的对象都带有它,它是一个看不见的数据成员,指向对应类的virtual table。
      1. 如果对象很小,这是一个很大的代价。比如如果你的对象平均只有4比特的成员数据,那么额外的vptr会使成员数据大小增加一倍(假设vptr大小为4比特)。
    3. 调用虚函数所需的代价基本上与通过函数指针调用函数一样。虚函数本身通常不是性能的瓶颈。
    4. 虚函数是不能内联的。这是因为”内联”是指”在编译期间用被调用的函数体本身来代替函数调用的指令”,但是虚函数的”虚”是指”直到运行时才能知道要调用的是哪一个函数”。
  2. 多继承经常导致对虚基类的需求.没有虚基类,如果一个派生类有一个以上从基类的继承路径,基类的数据成员被复制到每一个继承类对象里,继承类与基类间的每条路径都有一个拷贝。
    1. 程序员一般不会希望发生这种复制,而把基类定义为虚基类则可以消除这种复制。然而虚基类本身会引起它们自己的代价,因为虚基类的实现经常使用指向虚基类的指针做为避免复制的手段,一个或者更多的指针被存储在对象里。
    2. image.png
    3. 这里A是一个虚基类,因为B和C虚拟继承了它。使用一些编译器(特别是比较老的编译器),D对象会产生这样布局:
    4. image.png
    5. 加上虚函数后
    6. image.png
    7. 还有一点奇怪的是虽然存在四个类,但是上述图表只有三个vptr。只要编译器喜欢,当然可以生成四个vptr,但是三个已经足够了(它发现B和D能够共享一个vptr),大多数编译器会利用这个机会来减少编译器生成的额外负担。
  3. 运行时类型识别(RTTI)能让我们在运行时找到对象和类的有关信息,所以肯定有某个地方存储了这些信息让我们查询。这些信息被存储在类型为type_info的对象里,你能通过使用typeid操作符访问一个类的type_info对象。
    1. 在每个类中仅仅需要一个RTTI的拷贝,但是必须有办法得到任何对象的类型信息。RTTI被设计为在类的vtbl基础上实现。
    2. 例如,vtbl数组的索引0处可以包含一个type_info对象的指针,这个对象属于该vtbl相对应的类。具体索引位置根据编译器有所不同,现在大部分是在-1的位置

7 技巧

这篇章许多条款或多或少都是C++11或更先进版本的思想,故若你了解过现代C++,可能有些思想你早就学习过了,所以这一篇章,记录可能没那么详细,因为或多或少有些条款在我心中已经是“本来就该这样了”。

7.1 Item M25:将构造函数和非成员函数虚拟化

  1. 虚拟构造函数是指能够根据输入给它的数据的不同而建立不同类型的对象。并不是带virtual那种,那种是不能的。对于这种概念的虚拟构造函数例子看书。
  2. 虚拟拷贝构造函数能返回一个指针,指向调用该函数的对象的新拷贝。因为这种行为特性,虚拟拷贝构造函数的名字一般都是copySelf,cloneSelf或者是象下面这样就叫做clone。

image.png
正如我们看到的,类的虚拟拷贝构造函数只是调用它们真正的拷贝构造函数。因此“拷贝”的含义与真正的拷贝构造函数相同。如果真正的拷贝构造函数只做了简单的拷贝,那么虚拟拷贝构造函数也做简单的拷贝。如果真正的拷贝构造函数做了全面的拷贝,那么虚拟拷贝构造函数也做全面的拷贝。如果真正的拷贝构造函数做一些奇特的事情,象引用计数或copy-on-write(参见条款M29),那么虚拟构造函数也这么做。
无论指针指向谁,都能进行正确的拷贝操作,虚拟构造函数能够为我们做到这点。

  1. 就像构造函数不能真的成为虚拟函数一样,非成员函数也不能成为真正的虚拟函数(参见Effective C++ 条款19)
    1. 但具有虚拟行为的非成员函数很简单。你编写一个虚拟函数来完成工作,然后再写一个非虚拟函数,它什么也不做只是调用这个虚拟函数。为了避免这个句法花招引起函数调用开销,你当然可以内联这个非虚拟函数(参见Effective C++ 条款33)

7.2 Item M26:限制某个类所能产生的对象数量

例如你在系统中只有一台打印机,所以你想用某种方式把打印机对象数目限定为一个。或者你仅仅取得 16 个可分发出去的文件描述符,所以应该确保文件描述符对象存在的数目不能超过 16 个。你如何能够做到这些呢?如何去限制对象的数量呢?

  1. Printer 类的构造函数是 private。这样能阻止建立对象。

  2. 全局函数 thePrinter 被声明为类的友元,让 thePrinter 避免私有构造函数引起的限制。

  3. 最后 thePrinter 包含一个静态 Printer 对象,这意味着只有一个对象被建立。 客户端代码无论何时要与系统的打印机进行交互访问,它都要使用 thePrinter 函数:

  4. 另一种方法是把 thePrinter 移出全局域,放入 namespace(命名空间)。把 Printer 类和thePrinter 函数放入一个命名空间,我们就不用担心别人也会使用 Printer 和 thePrinter名字;命名空间能够防止命名冲突。

    1. 有两个值得注意的地方:
    2. 第一,唯一的 Pritner 对象是位于函数里的静态成员而不是在类中的静态成员,这样做是非常重要的。在类中的静态对象实际上总是被构造(和释放),即使不使用该对象。与此相反,只有第一次执行函数时,才会建立函数中的静态对象,所以如果没有调用函数,就不会建立对象。
    3. 此外,与一个函数的静态成员相比,把 Printer 声明为类中的静态成员还有一个缺点,它的初始化时间不确定。我们能够准确地知道函数的静态成员什么时候被初始化:“在第一次执行定义静态成员的函数时”。
    4. 第二,是内联与函数内静态对象的关系,再看一下 thePrinter 的非成员函数
Printer& thePrinter() 
{ 
	static Printer p; 
	return p; 
}

这个函数最适合做为内联函数使用。然而它不能被声明为内联。为什么呢?请想一想,为什么你要把对象声明为静态呢?通常是因为你只想要该对象的一个拷贝。现在再考虑“内联”意味着什么呢?从概念上讲,它意味着编译器用函数体替代该对函数的每一个调用,不过非成员函数还不只这些。非成员函数还有其它的含义。它还意味着 internal linkage(内部链接)。
只需要记住一件事:带有内部链接的函数可能在程序内被复制这种复制也包括函数内的静态对象。(也就是说程序的目标(object)代码可能包含一个以上的内部链接函数的代码),这种复制也包括函数内的静态对象
这意味着:如果建立一个包含局部静态对象的非成员函数,你可能会使程序的静态对象的拷贝超过一个!所以不要建立包含局部静态数据的非成员函数。
本条款接下来还有许多内容,但对于我来说感觉有点些许冗余,故不与记录。

7.3 Item M27:要求或禁止在堆中产生对象

  1. 要求在堆中建立对象
    1. 先从必须在堆中建立对象开始说。为了执行这种限制,必须找到一种方法禁止以调用“new”以外的其它手段建立对象。这很容易做到。非堆对象(non-heap object)在定义它的地方被自动构造,在生存时间结束时自动被释放,所以只要禁止使用隐式的构造函数和析构函数,就可以实现这种限制。
  2. 判断一个对象是否在堆中
    1. 检测在 operator new(或 operator new[])里的 bit set 不是一个可靠的判断方法。我们需要更好的方法进行判断。
    2. 如果你实在非得必须判断一个地址是否在堆上,你必须使用完全不可移植的方法,其实现依赖于系统调用,只能这样做了。因此你最好重新设计你的软件,以便你可以不需要判断对象是否在堆中。
  3. 禁止堆对象
    1. 通常对象的建立这样三种情况:对象被直接实例化;对象做为派生类的基类被实例化;对象被嵌入到其它对象内。我们将按顺序地讨论它们
      1. 禁止用户直接实例化对象很简单,因为总是调用 new 来建立这种对象,你能够禁止用户调用 new。你不能影响 new 操作符的可用性,但是你能够利用 new 操作符总是调用 operator new 函数这点来达到目的。你可以自己声明这个函数,而且你可以把它声明为 private。
class UPNumber 
{ 
private: 
	static void *operator new(size_t size); 
	static void operator delete(void *ptr); 
	... 
};

7.4 ItemM28:灵巧(smart)指针

  1. 灵巧指针是一种外观和行为都被设计成与内建指针相类似的对象,不过它能提供更多的功能。
// 大多数灵巧指针模板
template<class T>
class SmartPtr {
public:
	SmartPtr(T* realPtr = 0); // 建立一个灵巧指针指向dumb pointer(内建指针)所指的对象,未初始化的指针,缺省值为0(null)
	SmartPtr(const SmartPtr& rhs); // 拷贝一个灵巧指针
	~SmartPtr(); // 释放灵巧指针
	// make an assignment to a smart ptr
	SmartPtr& operator=(const SmartPtr& rhs);
	T* operator->() const; // dereference一个灵巧指针以访问所指对象的成员
	T& operator*() const; // dereference灵巧指针
 
private:
	T* pointee; // 灵巧指针所指的对象
};
  1. 在C++11中auto_ptr已经被废弃,用unique_ptr替代。

7.5 Item M29:引用计数

  1. 引用计数是这样一个技巧,它允许多个有相同值的对象共享这个值的实现。这个技巧有两个常用动机。
    1. 第一个是简化跟踪堆中的对象的过程。一旦一个对象通过调用new被分配出来,最要紧的就是记录谁拥有这个对象,因为其所有者--并且只有其所有者--负责对这个对象调用delete。因此,引用计数是个简单的垃圾回收体系。
    2. 第二个动机是由于一个简单的常识。如果很多对象有相同的值,将这个值存储多次是很无聊的。更好的办法是让所有的对象共享这个值的实现。这么做不但节省内存,而且可以使得程序运行更快,因为不需要构造和析构这个值的拷贝。然后在必要时,比如写时拷贝。
  2. 实现引用计数不是没有代价的。每个被引用的值带一个引用计数,其大部分操作都需要以某种形式检查或操作引用计数。对象的值需要更多的内存,而我们在处理它们时需要执行更多的代码。引用计数是基于对象通常共享相同的值的假设的优化技巧。如果假设不成立的话,引用计数将比通常的方法使用更多的内存和执行更多的代码。另一方面,如果你的对象确实有具有相同值的趋势,那么引用计数将同时节省时间和空间。

7.6 ItemM30:代理类

  1. C++代理类是为了解决这样的问题: 容器通常只能包含一种类型的对象,所以很难在容器中存储对象本身。怎样设计一个c++容器,使它有能力包含类型不同而彼此相关的对象?
  2. C++代理类可以帮助我们实现想要得效果,例如:
    1.多维数组
    2.区分左值和右值(区分读和写)
    3.压制隐式转换
  3. 多维数组
    1. 想要实现一个二维数组类,重载[][]肯定是不行的 ,无法通过编译,这时就可以通过代理类来实现:
class MyProxy
{
public:
	MyProxy(char *s) :s(s) {}
	char operator[](int index)
	{
		return s[index];
	}
private:
	char *s;
};

class MyString
{
public:
	
	MyString(int row) :p(new char[row][100]) {}
	MyProxy operator[](int index)
	{
		return p[index];
	}
private:
	char(*p)[100];
};
  1. 同时,proxy类也有缺点。作为函数返回值,proxy对象是临时对象,它们必须被构造和析构。Proxy对象的存在增加了软件的复杂度。从一个处理实际对象的类改换到处理proxy对象的类经常改变了类的语义,因为proxy对象通常表现出的行为与实际对象有些微妙的区别。

7.7 Item M31:让函数根据一个以上的对象来决定怎么虚拟

假设要编写一个发生在太空的游戏,其中有飞船(spaceship),太空站(space station)和小行星(ssteroid),使它们继承自一个抽象基类GameObject:

class GameObject { ... };
class SpaceShip: public GameObject { ... };
class SpaceStation: public GameObject { ... };
class Asteroid: public GameObject { ... };

不同的对象相撞有不同的规则,处理碰撞的函数声明像这样:

void checkForCollision(GameObject& object1,GameObject& object2);

这就产生了一个问题,要处理 object1 和 object2 的碰撞,必须知道这两个引用的动态类型,但 C++ 支持的虚函数只支持 single-dispatch(单重分派),如果某个函数调用根据两个参数而虚化就称为双重分派,根据多个函数而虚化称为多重分派。C++ 不支持双重分派和多重分派,因此我们必须自己实现。有以下几种方法:

  • 虚函数 + RTTI(运行时期类型辨识)
  • 只使用虚函数
  • 模拟虚函数表

8.1 Item M32:在未来时态下开发程序

  1. 要用语言提供的特性来强迫程序符合设计,而不要指望使用者去遵守约定。比如禁止继承,禁止复制,要求类的实例只能创建在堆中等等。处理每个类的赋值和拷贝构造函数,如果这些函数是难以实现的,则声明它们为私有。
  2. 尽可能写可移植性的代码,只有在性能极其重要时不可移植的结构才是可取的。
  3. 多为未来的需求考虑,尽可能完善类的设计。

8.2 Item M33:将非尾端类设计为抽象类

只要不是最根本的实体类(不需要进一步被继承的类),都设计成抽象类。

8.3 Item M34:如何在同一程序中混合使用C++和C

  1. 名变换:就是C++编译器给程序的每个函数换一个独一无二的名字。在C中,这个过程是不需要的,因为没有函数重载,但几乎所有C++程序都有函数重名。要禁止名变换,使用C++的extern “C”。不要将extern “C”看作是声明这个函数是用C语言写的,应该看作是声明这个函数应该被当作好像C写的一样而进行调用。
  2. 静态初始化:在main执行前和执行后都有大量代码被执行。尤其是,静态的类对象和定义在全局的、命名空间中的或文件体中的类对象的构造函数通常在main被执行前就被调用这个过程称为静态初始化。同样,通过静态初始化产生的对象也要在静态析构过程中调用其析构函数,这个过程通常发生在main结束运行之后
  3. 动态内存分配:C++部分使用newdelete,C部分使用malloc(或其变形)和free
  4. 数据结构的兼容性:在C++和C之间这样相互传递数据结构是安全的----在C++和C下提供同样的定义来进行编译。在C++版本中增加非虚成员函数或许不影响兼容性,但几乎其它的改变都将影响兼容。
  5. 如果想在同一程序下混合C++与C编程,记住下面的指导原则:

(1)确保C++和C编译器产生兼容的obj文件;
(2)将在两种语言下都使用的函数声明为extern “C”;
(3)只要可能,用C++写main();
(4)总用delete释放new分配的内存;总用free释放malloc分配的内存;
(5).将在两种语言间传递的东西限制在用C编译的数据结构的范围内;这些结构的C++版本可以包含非虚成员函数。

8.4 Item M35:让自己习惯使用标准C++语言

没什么好说的,主要就是多学习标准库
在这里推荐一些c++学习资料
现代C++
C++求职

  • 0
    点赞
  • 2
    收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:大白 设计师:CSDN官方博客 返回首页
评论

打赏作者

晰烟

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值