SamJ's blog

EffectiveC艹笔记(六)

Word count: 2.6kReading time: 10 min
2019/12/15 Share

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

只要定义一个变量,并且其类型有构造和析构函数,在定义之后不管是否使用这个变量,都要付出构造和析构这个变量的成本。所以要尽量避免这样的情况。

有下面的例子:

1
2
3
4
5
6
7
8
9
10
11
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;
}

如果函数抛出了异常,对象encrypted 就没有被使用,但是仍付出了encrypted的构造和析构成本。

最好的做法应该是这样的:

1
2
3
4
5
6
7
8
std::string encryptPassword(const std::string& password)
{
... //检查长度的语句
std::string encrypted(password);

encrypt(encrypted);
return encrypted;
}

所以尽可能延后变量定义式,延后到可以有将对象初始化的初值时才初始化变量。

对于循环,有两种情况。

1
2
3
4
5
6
7
8
9
10
11
12
//A
Widget w;
for(int i = 0; i < n; ++i)
{
w = ...;
}
//B
for(int i = 0; i < n; ++i)
{
Widget w;
...
}

A方法,1个构造函数,1个析构函数,n个赋值操作,但扩大了w的作用域

B方法,n个构造函数,n个析构函数

当满足(1)赋值成本比“构造+析构”低,(2)正在处理代码中对效率要求较高的部分,时使用A

其他情况都使用做法B。

  • 尽可能延后变量定义式的出现,可以提高代码可读性并改善程序效率。

条款二十七:尽量少做类型转换

在C++中最好使用C++提供的转型风格。

  • const_cast<T>() 用来将对象的常量性移除,将常量对象转换为非常量。
  • dynamic_cast<T>()用来执行“安全向下转型”,用来决定某个对象是否归属继承体系中的某个类型。
  • reinterpret_cast<T>()其动作和结果与编译器相关,代表其不可移植。可以将一个int的指针转换成一个int
  • static_cast<T>()用来强制隐式转换(类似于C语言中的强制转换),可以将非const对象转为const对象,int转为double等等。

类型转换会使编译器产生相关的代码,这些代码与对象模型有关。

1
2
3
4
class Base {...};
class Derived: public Base{...};
Derived d;
Base *pb = &d;

这里我们将一个基类的指针指向了一个派生类对象,表示可能有一个以上的地址指向这个对象。这时会根据编译器产生差异,“由于知道对象如何布局”设计的转型会带来不兼容的麻烦。

在高效的程序代码中,要想办法避免使用dynamic_cast。因为其实现效率通常很低。

通常避免使用dynamic_cast的做法是通过基类的接口处理所有子类的接口,就是在基类内提供虚函数,这些虚函数包括所有派生类会做的事。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Window{
public:
virtual void blink(){ }//什么也不做
};

class SpecialWindow: public Window{
public:
virtual void blink() {...} //实现blink的真正功能
}

typedef std::vector<std::shared_ptr<Window>> VPW;
VPW winPtrs; //内含可能指向各种Window派生类的指针。
for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
(*iter)->blink();
}

特别是需要一连串的dynamic_cast时,一定要用上面的方法取而代之。

  • 尽量避免类型转换,特别是很影响效率的dynamic_cast
  • 如果类型转换无可避免,那么将其隐藏于某个函数背后,客户可以调用这个函数而不用自己写转型的代码。
  • C++中最好要使用C++风格的类型转换。

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

首先要明确两个概念:

handles:可以是对象的引用、指针或者迭代器。是用来取得某个对象的。

对象内部成分:是指对象的成员变量,同时,对象的不被公开使用的成员函数也是对象内部成分。

这一条款告诫我们不应该让普通的用户可以调用的成员函数返回一个指针指向访问级别较低的对象内部成员。

第一个危害:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Point{
public:
Point(int x, int y);
...
void setX(int newVal);
void setY(int newVal);
...
};

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

这样的设计会导致用户可以通过这样的语句:rec.upperLeft().setX(50)来改变对象的私有成员。破坏了封装性同时使这个const成员函数的实现与其声明不符。

我们应该把上面的函数写成这样:

1
2
3
4
5
6
class Rectangle{
public:
...
const Point& upperLeft() const { return pData->ulhc;}
const Point& lowerRight() const { return pData->lrhc;}
};

这样虽然保证了const函数的正确性,但还是是返回对象内部的handles了,这可能会导致dangling handles。即指针所指的东西存在了一会就消失了。

1
2
3
4
5
6
7
class GUIObject{..};
const Rectangle
boundingBox{const GUIObject& obj}; //by value方式返回一个矩形
//如果客户这样操作:
GUIObject* pgo;
..
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());

这时pUpperLeft指向boundingBox返回的一个临时对象的ulhc,但是当这行语句执行完毕,临时对象会被销毁。此时pUpperLeft就指向了不存在的对象,导致了dangling handles。

  • 所以避免返回handles指向对象内部可以增加封装性,防止产生dangling handles。

条款二十九: 努力达到“异常安全”

异常安全性指的是当异常被抛出时,带有异常安全性的函数:不泄露任何资源,不允许数据败坏(指针不会指向无效的对象)。

看下面一段代码,有个class表现带背景图案的GUI菜单。这个class可以用于多线程环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PrettyMenu{
public:
...
void changeBackground(std::istream& imgSrc);//改变背景图像
...
private:
Mutex _mutex; //互斥锁
Image* _bgImage; //目前的背景图像
int _imgChanges; //背景图像被改变的次数
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
unlock(&mutex);
}

可以看到这段代码不满足异常安全的所有条件,当new Image(imgSrc)抛出异常时,互斥锁会被锁住,产生资源泄漏,同时bgImage会指向无效的对象,导致数据败坏。

首先对于资源泄漏问题,可以通过条款十四的方法,使用资源管理类解决。

对于数据败坏问题,我们首先要知道,异常安全函数会提供下面三个保证之一:

  1. 基本承诺:如果抛出异常,程序内所有事物都保持在有效状态(有效状态可能是任意一个有效状态),客户只有调用某些函数才能知道程序的当前状态。
  2. 强烈保证:如果异常抛出,程序状态不改变。
  3. 不抛掷保证:承诺绝不抛出异常,总是能够完成他们所承诺的功能。

不抛掷保证可以说是我们的追求,但任何使用动态内存的东西,如果无法找到内存,就会抛出一个bad_alloc异常。对于上面的changeBackgrount可以很容易就提供强烈保证:

1
2
3
4
5
6
7
8
9
10
11
12
class PrettyMenu{
...
std::shared_ptr<Image> bgImage;
...
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock ml(&_mutex);
bgImage.reset(new Image(imgSrc));
++imageChanges;
}

由于将Image放进了智能指针,不再需要手动调用deletedelete会自动在reset函数内调用。由于传入参数imgSrc可能会导致输入流的读取记号被移走(??),这样的话只能提供基本保证。

有一个一般化的策略可以很容易完成强烈保证,叫copy and swap:对任何你要修改的对象创建一个副本,在副本上进行修改,如果没有抛出异常,就将副本和原对象进行置换,如果抛出了异常,原对象仍保持原来的状态。

pimpl idiom,对PrettyMenu来说,是下面的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 ml(&_mutex);
std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc));
++pNew->imageChanges;
swap(pImpl, pNew);
}

“copy and swap” 是保证对象状态的一个很好地做法,但是如果在这个行为中调用的函数异常安全性比强烈保证低,就很难整体做到强烈保证。copy and swap 还会带来显而易见的效率问题。

在写代码的时候,一定要考虑异常安全性,并写出文档,写明保证哪一种异常安全性。

  • 异常安全函数即使发生异常也不会出现资源泄漏或数据结构的破坏。
  • 强烈保证往往能用copy and swap实现。
  • 函数提供的异常安全保证往往最高只等于其调用各个函数的异常安全保证的最弱者。

条款三十: 深入了解inline函数

inline函数是C++一个类似于宏的特性,动作像函数,比宏还要好用,可以免除函数调用成本。

但是,使用inline是有代价的,inline背后会将每一个函数的调用以函数本体替换。这样可能增加程序的体积大小。如果inline的本体很小,编译产生的码比函数调用的要小,这样才能达到提高效率的目的。

要注意inline只是对编译器的一个申请,可以明确提出,也可以隐式提出。

隐式提出是将函数定义放在class定义内。

对于构造函数进行inline并不是很好的选择,编译器对默认的构造函数进行处理的时候,会插入很多代码,若将其inline会产生大量代码。

所以对inline函数进行处理的时候,要对inline函数的使用进行严格的限制。记住80-20原则:一个程序的执行时间花费在20%的代码上。找到20%的代码进行优化,使用inline提高效率才是最重要的。

  • 将inline限制在小型,被频繁调用的函数上。
  • 不要只因为函数模板出现在头文件,就将其声明为inline。
CATALOG
  1. 1. 条款二十六:尽可能延后变量定义式的出现时间
  2. 2. 条款二十七:尽量少做类型转换
  3. 3. 条款二十八:避免返回handles指向对象内部成分
  4. 4. 条款二十九: 努力达到“异常安全”
  5. 5. 条款三十: 深入了解inline函数