一、尽可能延后变量定义式的出现时间
我们通过一个实例来进行说明,考虑一个对密码进行编码解码的函数
1
2
3
4
5
6
7
8
9std::string encode(const std::string& rawpassword){
using namespace std;
string encoded_password;
if(rawpassword.length() < min_limit){
throe logic_error("Password is too short.");
}
...
return encoded_password;
}若是
rawpassword
的长度较小因而使得控制流提前抛出异常,那对于encoded_password
构造函数的调用就是一种浪费,所以,我们可以写:1
2
3
4
5
6
7
8
9
10
11
12std::string encode(const std::string& rawpassword){
using namespace std;
if(rawpassword.length() < min_limit){
throe logic_error("Password is too short.");
}
string encoded_password;
...
encoded_password = rawpassword;
encrypted(encoded_password);
...
return encoded_password;
}更受欢迎的做法是以
password
为初值,跳过default
构造和赋值:1
2
3
4
5
6
7
8
9
10
11std::string encode(const std::string& rawpassword){
using namespace std;
if(rawpassword.length() < min_limit){
throe logic_error("Password is too short.");
}
...
string encoded_password(rawpassword);
encrypted(encoded_password);
...
return encoded_password;
}所以,我们在定义变量时,不但应延后定义变量直到不得不使用它为止,甚至应当尝试延后定义直到能够给它初值实参为止。
对于一个循环结构内变量的初始化,我们应当评估两种方式:
1
2
3
4
5
6
7
8
9
10
11//plan A
for(int i = 0; i < n; ++i){
Widget w;
....
}
//plan B
Widget w;
for(int i = 0; i < n; ++i){
w = ...;
}来看看A和B的消耗:
A :n组构造 + n组析构
B :一组构造 + 一组析构 + n组赋值
由于B的做法使得
w
的作用域更大,这导致了B的可维护性和易理解性不强,故而除非(1)你需要使某段代码的效率极高,(2)B的消耗远小于A,否则,应当常用A使得程序更为清晰
二、尽量少做转型
这里先来补充一下Cpp的转型。
Cpp有三种转型风格:
C风格的转型:
1
(T)expression; //将expression转型为T
函数风格的转型:
1
T(expression); //将expression转型为T
C++提供的四种新式转型
上述两种转型方式虽然合法,且在C编程中常用,但新式转型往往更为受欢迎,因为它们很容易被程序员和编译器识别,在带project中十分有用
四种新式转型如下:
1
2
3
4const_cast<T>(expression); //常量性转除,从常量 ---> 非常量
dynamic_cast<T>(expression); //安全地向下转型,用于类继承层次间的指针或引用转换
reinterpret_cast<T>(expression); //强制按该类型的二进制规则翻译过来,逐个bit去解释它
static_cast<T>(expression); //通用的普通转型
使用static_cast
可以抵消explicit
:
1 | class A{ |
在子类中使用父类函数 / 成员的正确方式是:
1 | class parent{ |
对于dynamic_cast
,尽量以其他方式替代它,因为dynamic_cast
要满足动态性,需要更多的时间开销,常用的替代方法有:
- 直接使用子类的指针(引用),不适用多态
- 将父类考虑得面面俱到,一些不适用的方法只写声明不写定义
三、避免返回handles指向对象内部
成员变量的封装性最多等于“返回其reference”的函数的访问级别。
返回一个代表对象内部数据的
handle
在一些情况下很方便,但这降低了对象的封装性。返回refenrence可能会造成指针 / 引用 虚吊,经典情况如STL迭代器失效,这是由于handle比它所指对象更长寿造成的,应尽力避免这种情况。
四、为“异常安全”而努力
这里来补充一下C++中的异常
异常是指程序的控制流不能按预期工作且出错,这时程序会因异常而终止,C++使用
try
,catch
以及throw
来应对这一情况:1
2
3
4
5
6
7
8
9try{
.... // 本该运行的代码
}
catch(execption){
.... // 捕捉到异常execption后的应对策略
}
catch(exception){
throw exception; //捕捉到一个无法处理的异常,抛出给上级函数
}其中
throw
关键字也可定义一个函数应当抛出的异常类型:1
2
3
4T func (T param) throw (); //其中为空,表示不跑出异常
T func (T param) noexcept; //C++11不抛出异常版本
T func (T param) throw (int); //表示只抛出int型异常
T func (T param) throw (int, string); //表示抛出int和string型的异常C++常见的异常类型如下:
异常 描述 std::bad_alloc
内存分配失败 std::bad_cast
dynamic_cast
未通过运行时检查std::bad_typeid
该异常由typeid抛出 std::bad_exception
如果函数的异常列表里声明了 bad_exception 异常,当函数内部抛出了异常列表中没有的异常时,都会被替换为 bad_exception 类型。 std::logic_error
逻辑错误 std::runtime_error
运行时错误 注:
typeid
的作用与decltype
相似,都可以得到一个变量或者表达式的类型,不同的是,typeid
方法得到的类型不能用于定义变量,可以用来进行类型的比较。异常安全性
当因异常被抛出而导致程序暂时终止时,带有异常安全性的函数应当:
- 不泄露任何资源
- 不允许数据遭到破坏
对于第一条,我们遵循RAII的原则,以对象管理数据,使得系统清理栈上资源而调用栈上对象的析构函数时,正好释放资源。
而对于第二条不许数据遭到破坏,异常安全函数提供以下三个保证之一:
- 基本承诺,异常被抛出时,程序的任何事物都是一个合法状态
- 强烈保证,如果异常被抛出,程序内的任何事物保证被复位
- 不抛掷保证,一旦有异常被抛出,绝对是很严重的错误,T func (T param) throw ();
对于异常安全性,有一个小tip可以导致强烈保证,它就是——copy and swap,我们先为要修改的对象提供一个副本,再在副本上做一切必要的修改,最终通过一个不抛出异常的
swap
函数对副本和本体进行置换,这样做,即使副本处产生异常,也不影响到本体,从而使异常安全性得到强烈保证。下面举个异常安全相关的例子:
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
39
40
41
42
43
44
45
46
47
48
49class Menu{
public:
void changebackground(std::istream& imgsrc);
private:
Mutex mutex;
Image* bgimage;
int imagechanges;
}
//不提供异常安全保证版本,当new出现错误,毫无安全可言
void Menu::changebackground(std::istream& imgsrc)
{
lock(&mutex);
delete bgimage;
++imagechanges;
bgimage = new Image(imgsrc);
unlock(&mutex);
}
//基本保证版本吗,new出现错误时,输入流已经被读走,所以是基本保证
class Menu{
private:
std::shared_ptr<Image> bgimage;
}
void Menu::changebackground(std::istream& imgsrc)
{
Lock ml(&mutex); //RAII保证锁被释放
bgimage.reset(new Image(imgsrc));
++imagechanges;
}
//强烈保证版本,copy & swap
typedef struct PMImpl{
std::shared_ptr<Image> bgimage;
int imagechanges;
}PMImpl;
class Menu{
private:
Mutex mutex;
std::shared_ptr<PMImpl> pImpl;
}
void Menu::changebackground(std::istream& imgsrc)
{
using std::swap;
Lock ml(&mutex);
std::shared_ptr<PMImpl> pnew(new PMImpl(*pImpl)); //deep-copy
pnew->bgImage.reset(new Image(imgsrc));
++pnew->imagechanges;
swap(pImpl, pnew); //none-exception swap
}最后,不必一定给予强烈保证:
1
2
3
4
5void func(){
f1();
f2();
...
}根据木桶原理,若func调用的函数无强烈保证,则很难让他成为一个有强烈保证的函数,而即使所有被调用的函数都有强烈保证,但
f2()
出现异常后复位,f1()
没有出现异常,这依旧不是强烈保证,所以不必强求。
五、了解inline的里里外外
- inline的思想是将函数直接插入到程序中,使得一些本体执行比进行函数调用开销小的函数更有效率地运行(继承了宏的思想)
- 因为inline会将函数代码插入程序,所以可能会导致代码膨胀,使编译器产出更多的机器码,从而导致cache命中率下降导致效率降低
- 一个最佳方法时遵循 2 - 8定律,先使用不带inline版本的函数 ——> 找出决定程序性能的20% ——> 用各种奇技淫巧优化它
六、将文件编译依赖关系降到最低
- 支持“编译依存性最小化”的一般构想是,使得调用程序依赖于类型的“声明式”,而非“定义式”
定义式的class
实现如下所示:
1 | class Person{ |
直接将定义式放入头文件person.h
,然后需要这个类时include
这个头文件显然是很navie
的想法,搭嘎,这么做的代价又是什么呢?
代价就是——我们之后对该class进行版本迭代更新时,所有include
它的文件都需要重新编译!
对于这一问题,最佳解决策略是将定义式隐藏在背后,有两种方法可以达到这个目的,一是PIMPL
大法,即将Person分割成两部分,一个负责提供接口(声明式),一个负责实现(定义式),这样做可以使文件编译依赖关系降到最低:
1 |
|
这样做,重构PersonImpl时就不需要重新编译程序文件了,只需要重新编译PersonImpl所在的 .h
文件。
另一个解决问题的方法是,编写一个抽象基类来描述规范,使用抽象基类指针来完成操作(多态),这种方法的经典实践如工厂模式:
1 | //产品的基类 |
Pimpl大法会使得为每次访问增加间接性,且为每个对象增加了一个指针的大小,而
interface class
则为每次函数调用多付出了一个间接跳跃的成本(因为它的成员函数都是虚函数),但并不意味着不去使用它们,要大胆去用程序库头文件应当以”完全且仅有声明式“的形式呈现给用户,这对是否涉及
template
都适用