《Effective C++:改善程序与设计的55个具体做法》阅读笔记 5——实现

条款26:尽可能延后变量定义式的出现时间

  • 尽可能延后变量定义式的出现时间,因为有些变量定义了,可能未被使用,如“异常抛出,导致很多代码没有运行,这就有可能导致定义的变量未被使用”。定义的变量未使用,但是你仍然需要构造和析构,这就进行了无用功。

  • 由于std::string encrypted(password);优于std::string encrypted; encrypted=password;,所以不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。

  • 使用在循环中的变量应该定义在循环外还是循环内:除非(1) 你知道赋值成本比“构造+析构”成本低,(2) 且你正在处理代码中对于效率高度敏感,否则你应该将变量定义在循环内。

条款27:尽量少做转型动作

类型转换,请参考:C++类型转换:static_cast、reinterpret_cast、dynamic_cast、const_cast

下面介绍转型可能发生的错误。

可能发生错误的情况一:C++单一对象可能拥有一个以上的地址:
C++单一对象可能拥有一个以上的地址,这是由编译器决定的,在单继承和多继承中都有可能发生,如在g++中如下程序(参考:链接):

class B1
{
	int i;
};
 
class B2
{
	int i;
};
 
class D : public B1, public B2
{
	int i;
};
 
int
main()
{
	D aD;
 
	D* pD = &aD; 
	B2* pB2 = &aD; 
 
	cout << pD << endl; 
	cout << pB2 << endl; 
 
	return 0;
}

很多人以为同一个对象不可能有一个以上的地址,但是现实情况是“C++单一对象可能拥有一个以上的地址”,这告诉我们:在写程序的时候不要假定数据在内存中的存储方式,有可能你换了一个系统或编译器以后,存储方式就改变了。例如,将对象地址转型为char*指针,然后使用char*指针对对象进行操作,这种操作的行为是未定义的,在不同系统或编译器中的行为都有可能是不同的。

可能发生错误的情况二:子类成员函数中调用基类成员函数:
在子类Derived的onResize()函数中,首先调用父类Base的onResize():

  • 错误方法:static cast<Base>(*this).onResize(),这种方法是错误的,因为static cast<Base>(*this)创建了一个本对象的副本,所以对onResize()是在副本进行调用的,而不是在本对象上进行调用的。所以如果期望调用onResize()对本对象的数据成员进行修改就会失败,因为实际修改的是本对象的副本,并没有对本对象进行修改。
  • 正确方法:Base::onResize()

避免使用dynamic_cast的方法:
dynamic_cast主要用于保证安全的向下转型(所谓“安全的向下转型”即只有当Base class的指针确实指向Derived class对象时才能将其转为Derived class的指针),但是dynamic_cast会让程序运行变慢,特别是当dynamic_cast处理多继承类的是否速度会更慢。下面几种方法用于避免使用dynamic_cast:

  • 不使用基类指针实现多态,直接使用相应的子类指针
  • 基类中提供什么也不做的virtual函数,用于说明子类想做的事

最后作者提到了:

  • 尽量少做转型动作
  • 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。

条款28:避免返回handles指向对象内部成分

成员函数返回private或protect类型的数据成员的引用,这使得本来不可以直接修改的private数据成员,可以被直接修改。

  • 返回指针、迭代器也会出现和引用一样的情况。指针、迭代器和引用都是handles (号码牌,用来取得某个对象),返回一个“代表对象内部数据”的handle,随之而来的便是“降低对象封装性”的风险。
  • 上面说的是返回数据成员,当然成员函数返回其他成员函数的指针的情况,一样也需要注意。

上面提到了本来不可以在类外被修改的数据成员,变成了可以被修改的。这时我们可以在返回值上面加上一个const,代表返回值不可以被修改。但是这还是会出现悬空handle的问题:某一类外函数返回临时对象,临时对象中的成员函数返回类中的数据成员的handle,当临时对象被销毁,就会出现悬空handle。

条款29:为“ 异常安全”而努力是值得的

当异常被抛出时,带有异常安全性的函数会:

  • 不泄漏任何资源。可以使用资源管理对象管理资源,防止资源泄漏。
  • 不允许数据败坏。

异常安全函数有下面三种:

  • 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。
  • 强烈保证:如果异常被抛出,程序状态不改变。
  • 不抛出异常:承诺这个代码在任何情况下都不会抛出异常。函数不抛出异常的方法是在函数声明的后面加上throw(),具体如下:
    C++函数后面加关键字throw(something)限制,是对这个函数的异常安全作出限制;这是一种异常规范,只会出现在声明函数时,表示这个函数可能抛出任何类型的异常。
void fun() throw();                 //表示fun函数不允许抛出任何异常,即fun函数是异常安全的。
void fun() throw(...);              //表示fun函数可以抛出任何形式的异常。
void fun() throw(exceptionType);    // 表示fun函数只能抛出exceptionType类型的异常。

保证异常安全的两个注意点:

  • 先删除了一个旧的imgSrc对象,但是在建立新的imgSrc对象之前抛出了异常。这导致没有建立新的imgSrc,且旧的imgSrc对象还被删除了。
    解决方法:bgImage.reset(new Image(imgSrc));使用new成功生成对象以后,bgImage中旧的imgSrc对象才会被删除。
  • 不要为了表示某件事情发生而改变对象状态,除非那件事情真的发生了。比如记录对象更改的次数,那就等到对象真正改变了,次数才加一。

强烈保证有一种实现方法,那就是copy and swap。原则就是:为你打算修改的对象(原件)做出一份副本,然后在那副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)。(条款25:考虑写出一个不抛异常的swap函数)。
这种强异常安全保障,也会在下面的情况下遇到麻烦:

void someFunc()
{
    f1();
    f2();
}

f1()和f2()都是强异常安全的,但万一f1()没有抛异常,但f2()抛了异常呢?是的,数据会回到f2()执行之前的状态,但程序员可能想要的是数据回复到f1()执行之前。

当“强烈保证"不切实际时,你就必须提供“基本保证”。

条款30:透彻了解inlining的里里外外(书中的内容还没看,已经有点累)

inline的相关知识,请参考:C++ inline
C++ inline的补充:

  • 对于虚函数的inline,编译器也会将之忽略掉,因为内联(代码展开)发生在编译期,而虚函数意味着具体调用哪个函数是在运行期间决定的,所以编译器忽略掉对虚函数的inline。
  • 对于函数指针,当一个函数指针指向一个inline函数的时候,通过函数指针的调用一般不会被编译器处理成内联。
  • friend函数也可被定义于class内,定义在class中的friend函数也被隐喻声明为inline。

其他:

  • 构造函数和析构函数不要弄成inline函数,因为在调用构造函数和析构函数是内部做了很多事情,这些事情很难一一掌握。
  • 内联函数不能设置断点

条款31:将文件间的编译依存关系降至最低

31.1 不在头文件中包含其他头文件,而在源文件中包含其他头文件

本文参考:链接1

// ComplexClass.h
#include “SimpleClass1.h”

class ComplexClass
{
    SimpleClass1 xx;
};
  • SimpleClass1.h发生了变化,比如添加了一个新的成员变量,那么SimpleClass1.cpp会重编译。
    ComplexClass.cpp也会重新编译:因为ComplexClass.h里面包含了SimpleClass1.h,并使用了SimpleClass1作为数据成员,所以ComplexClass.cpp会重新编译。而且所有使用ComplexClass类的对象的文件,也都会重新编译。
  • SimpleClass1.cpp中成员函数的实现发生了变化,ComplexClass.cpp不会重新编译。因为编译器重新编译的条件是发现一个变量的类型或者大小跟之前的不一样了,但现在SimpleClass1的接口并没有任务变化,只是改变了实现的细节,所以编译器不会重编。

上述代码编译依存关系太高,修改上述代码:

// ComplexClass.h

class SimpleClass1;

class ComplexClass:
{
    SimpleClass1* xx;
};
  • 将上述代码的SimpleClass1* xx;修改为SimpleClass1 xx;,此时编译就会出错。因为编译器需要知道ComplexClass成员变量SimpleClass1对象的大小,但由于未包含头文件SimpleClass1.h,所以编译器不知道SimpleClass1对象的大小。上述代码中xx为指针类型,编译器视所有指针为一个字长(在32位机器上是4字节),所以上述代码不需要包含头文件SimpleClass1.h,也可以编译通过。
  • 我们会在ComplexClass.cpp文件中使用SimpleClass1对象的方法,所以需要在ComplexClass.cpp中包含头文件SimpleClass1.h。

在ComplexClass.h不导入SimpleClass1.h(只使用class SimpleClass1;做前向声明),而在ComplexClass.cpp中导入SimpleClass1.h的好处:
SimpleClass1.h发生了变化,SimpleClass1自身一定会重编,ComplexClass.cpp因为包含了SimpleClass1.h,所以需要重编,但换来的好处就是所有用到ComplexClass的其他地方,它们所在的文件不用重编了!因为ComplexClass的头文件未包含SimpleClass1.h,所以没有变化,接口没有改变!

总结:

  • 对于C++类而言,如果它的头文件变了,那么所有这个类的对象所在的文件都要重编
  • 实现文件(cpp文件)变了,而头文件没有变(对外的接口不变),那么所有这个类的对象所在的文件都不会因之而重编。

因此,避免大量依赖性编译的解决方案就是:在头文件中用class声明外来类,用指针或引用代替变量的声明;在cpp文件中包含外来类的头文件。

pimpl(pointer to implementation)

参考:链接2
使用pimpl改变31.1中代码,如下:

// ComplexClass.h

class ComplexClassImp;

class ComplexClass:
{
    ComplexClassImp *MemberImp;
};
// ComplexClassImp.h
class SimpleClass1;

class ComplexClassImp:
{
    SimpleClass1* xx;  // 使用std::shared_ptr来管理指针更加合理,这里这么写只是为了方便
};

这里ComplexClass类通过ComplexClassImp类的指针实现,这样做的好处:
对于31.1中ComplexClass.h,如果ComplexClass.h的内容发生改变(如添加成员变量),那么使用ComplexClass.h的文件都会重新编译。
但是如果具体实现交给了ComplexClassImp.h,此时如果ComplexClassImp.h的内容发生改变(如添加成员变量),ComplexClass.h的内容并不会发生改变。也就说使用ComplexClass.h的文件并不会重新编译。ComplexClassImp只面向于软件开发者而不是使用者。

【注】ComplexClass里面的方法与ComplexClassImp的方法是一模一样的。

总结: 我觉得pimpl的作用就是——增删成员变量的时候,客户使用的头文件(如ComplexClass.h)没有改变。
由于ComplexClass里面的方法与ComplexClassImp的方法是一模一样的,所以如果ComplexClassImp.h中添加方法,那么ComplexClass.h也必须添加方法。所以我觉得pimpl并没有隐藏“增删成员函数”,只是隐藏了“增删成员变量”。当然对于成员函数的实现细节的隐藏来说,本节的代码和31.1的代码都是有提供的。

Interface Classes

请直接参考:链接3,写得非常好。总结就是:

  • Person中定义了各种纯虚成员函数,RealPerson继承并实现Person中的纯虚成员函数。子类RealPerson中定义成员变量,父类Person不定义成员变量。
  • 静态函数static Person* CreatePerson()被RealPerson和Person共有,所以此静态函数被RealPerson类实现,用于创建RealPerson对象。

这样如果客户要使用RealPerson,只需要导入Person.h。RealPerson中内容的改变,不会影响到接口Person.h。

posted @ 2022-10-30 22:02  好人~  阅读(37)  评论(0编辑  收藏  举报