Effective C++ 3nd——实现
尽可能延后变量定义式的出现时间
只要你定义了一个变量而其类型带有构造函数或析构函数,那么当程序的控制流到达这个变量定义式时,你便得承受构造成本;当这个变量离开其作用域时,你便得承受析构成本。即使这个变量最终并未被使用,仍需耗费这些成本,所以你应该尽可能避免这种情形
本条款的意义就是:当我们要定义某个对象时,尽量靠近我们使用这个对象的操作之前定义(延后定义),因为这样可以避免一些无意义的构造函数或析构函数的调用。例如:
std::string encryptPassword(const std::string& password){
using namespace std;
string encrypted; // 定义变量
if( password.length() < MinimumPasswordLength ){
throw logic_error( "Password is too short" );
}
... // 使用我们定义的 encrypted 变量
return encrypted;
}
如果此时在 if 语句中抛出一个异常,则函数会被终止,但是仍然会付出这个对象构造函数和析构函数的成本。因此我们可以把变量的定义式放到我们将要使用它之前
std::string encryptPassword( ... ){
...
string encrypted;
... // 使用该变量
return encrypted;
}
这份代码比上一份代码好一些,但还是有改进的地方:在定义 encrypted 对象时,我们可以使用成员初始化列表来初始化该对象,避免无意义的 default 构造函数行为
string encrypted( password );
如果存在循环语句,则要分情况:
- 如果类的一个赋值成本低于一组构造成本 + 析构成本,则定义在循环体外会好一些
- 否则定义在循环体内比较好
// 定义在循环体外
Widget w;
for( int i=0; i<n; ++i ){
w = ...;
}
// 定义在循环体内
for( int i=0; i<n; ++i ){
Widget w(...);
}
请记住:
- 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率
尽量少做转型动作
为什么要这样主要有以下几点:
- 转型破坏了类型系统,影响了编译器对代码的类型检测
- 转型可能会发生一些意想不到的错误。比如:
- 在继承体系中,当我们用父类指针指向一个派生类时,父类指针所指的地址可能与该派生类地址并不相同。这种情况下会有个偏移量在运行期间被施行与派生类指针上,用以取得正确的基类指针值
- 如果我们想在派生类中调用父类的某个虚函数,而用转型动作(static_cast)将 *this 转为父类。此时我们并不是在当前对象身上调用父类的虚函数,而是在当前对象的父类成分的复本上调用父类的虚函数。这会使当前对象进入一种 “伤残” 状态:其父类成分的更改没有落实,而派生类落实了
- 使用 dynamic_cast 的效率较低
解决这些问题的办法就是尽量避免转型。如果无法避免,则将这些转型动作隐藏于某个函数背后
请记住:
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast 。如果有个设计需要做转型动作,试着发展无需转型的替代设计
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进它们自己的代码内
- 宁可使用 C++ style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职责
避免返回 handles 指向对象内部成分
class Point{
public:
Pint( int x, int y );
void SetX( int val );
void SetY( int val );
...
};
struct RectData{
public:
Point ulhc;
Point lrhc;
...
};
class Rectangle{
...
private:
std::tr1::shared_ptr< RectData > pData;
};
此时如果我们
class Rectangle{
public:
...
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
};
这样的设计可以通过编译,但却是矛盾的。这两个函数定义为 const 代表我们不想让客户修改 Rectangle 。另一方面两个函数返回引用指向私有数据,调用者于是可以通过这些引用更改内部数据,如:
Point coord1(0,0);
Point coord2(100,100);
// 定义为 const 表示 rec 不能被修改
const Rectangle rec(coord1, coord2);
// 但是却修改了 rec 的值
rec.upperLeft().SetX(50);
// 解决办法是:可以在上面定义的两个成员函数前面加上 const
这提醒我们:
- 成员变量的封装性最多只等于 “返回其引用” 的函数的访问级别
- 如果 const 成员函数传出一个引用,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据
上面我们所说的每件事都是由于 “成员函数返回引用” 。如果它们返回的是指针或迭代器,相同的情况还是发生,原因也相同。引用、指针和迭代器统统都是所谓的 handles,而返回一个 “代表对象内部数据” 的 handle ,随之而来的便是 “降低对象封装性” 的风险
其次,它可能还会导致 “空悬的 handles” ,这种 handles 所指东西不复存在。例如:
class GUIObject{ ... };
// 以 by-value 的形式返回一个矩形
const Rectangle boundingBox( const GUIObject& obj );
// 以下是客户可能的操作
GUIObject* pgo;
// 取得一个指针指向外框左上点
const Point* pUpperLeft = &( boundingBox( *pgo ).upperLeft());
这样就会造成 pUpperLeft 指针悬空,因为赋值号右边的是一个临时对象,当语句结束时,该临时对象就会被销毁,对象里面的 Point 成分也会被销毁。
请记住:
- 避免返回 handles 指向对象内部。遵守这个条款可增加封装性,帮助 const 成员函数的行为像个 const ,并将发生 “虚吊 handles ” 的可能性将至最低
为 “异常安全” 而努力是值得的
当异常被抛出时,带有异常安全函数会:
- 不泄露任何资源
- 不允许数据败坏:即指针悬空或者相关数据被不正确的改变之类的
我们可以遵循前面的条跨 “以对象管理资源” 来解决资源泄漏的问题。下面讨论如何解决数据败坏,先介绍相关术语:
异常安全函数提供以下三个保证之一:
- 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。也就是如果出现异常,则程序会恢复原状态,或者转到一个默认的状态
- 强烈保证:类似于数据库中的事务——函数要么完全成功,要么完全失败
- 不抛掷保证(nothrow):承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。比如作用域内置类型上的操作就不抛出异常
在编写异常安全性码的时候,最好让其提供 nothrow 保证,但对大部分函数而言,抉择往往落在基本保证和强烈保证之间
本书作者讲述了一种提供强烈安全保证的方法:可以把原来的对象复制一份,然后直接对对象的副本进行操作,如果所有操作均成功,则把副本与对象进行交换。如果中间有一个操作失败,也不会影响源对象。这种方法称为 “copy and swap” 。这里的 swap 函数可以遵循之前的条款,设计一个不抛出异常的 swap 函数。但是这种做法不完美的 地方在于:需要付出大量的时空代价
但是在编写提供强烈保证的函数时要注意函数内调用的函数是否也具备强烈保证,如果其调用的函数中某一个或多个函数不提供强烈保证,那么整个函数一定不提供强烈保证
若其调用的函数都提供强烈保证,那么函数也不一定提供强烈保证
上面第二种情况的原因在于 “连带影响” 。如果函数只操作局部性状态,便相对容易地提供强烈保证。但是当函数对 “非局部性数据” 有连带影响时,提供强烈保证就困难的许多
如果系统内有一个函数不具备异常安全性,整个系统就不具备异常安全性
请记住:
- 异常安全性函数即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型
- “强烈保证” 往往能够以 copy-and-swap 实现出来,但 “强烈保证” 并非对所有函数都可实现或具备现实意义
- 函数提供的 “异常安全保证” 通常最高只等于其所调用的各个函数的 “异常安全保证” 中的最弱者
透彻了解 inlining 的里里外外
本条款主要围绕两个点来阐述 inline 函数:
- inline 函数的优点以及缺点
- 哪些地方会默认 inline
- 编译器对 inline 函数的操作以及优化
inline 函数背后的整体观念是:将 “对此函数的每一个调用” 都以函数本体替换之。首先 inline 函数可以免除函数调用的成本,在预编译阶段会将代码内的 inline 函数展开,但是会增加你的目标码,如果使用不当,会导致代码膨胀,降低指令告诉缓存装置的命中率,进而损失效率。
但如果 inline 函数的本体很小,编译器针对 “函数本体” 所产出的码可能比针对 “函数调用” 所产出的码更小。那么此时就会导致较小的目标码和较高的指令缓存装置命中率。
inline 函数无法随着程序库的升级而升级。一旦程序设计者决定改变某个 inline 函数,那么所有用到此函数的程序都要重新编译。但如果是 non-inline 函数,则只需要重新连接就好
inline 函数很多时候无法调试,因为它并不存在函数实体
inline 只是对编译器的一个申请,即编译器可以选择接受 inline ,也可以拒绝它。这项申请可以隐喻提出,也可以明确提出。
在类中定义的成员函数就是隐喻 inline ,明确提出是在函数前面加上 inline 。
大部分 build environment 在编译过程进行 inlining ,某些可以在连接期 inlining ,少部分可以在运行期 inlining
- 一个表面上看似 inline 的函数是否真是 inline 取决于你的 build environment ,主要却决于你的编译器。大部分编译器拒绝将太过复杂(包含循环或递归)的函数 inline ,虚函数也不会 inline
- 有时候虽然编译器有意愿 inlining 某个函数,还是可能为该函数生成一个函数本体。例如当我们要使用函数指针调用某个 inline 函数时,编译器通常会为此函数生成一个 outlined 函数本体。即使你从未使用函数指针,也可能会发生这种情况,因为程序员并非唯一要求函数指针的人
- 编译器通常不对 “通过函数指针而进行的调用” 实施 inlining ,这意味着对 inline 函数的调用有可能被 inlined 也可能不会被 inlined ,取决于该调用的方式
请记住:
- 将大多数 inlining 限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化
将文件间的编译依存关系降至最低
略,后面搞懂了再加上