DJの小站

记录生活,发现自己

0%

Effective C++ 关于实现

一、尽可能延后变量定义式的出现时间

  1. 我们通过一个实例来进行说明,考虑一个对密码进行编码解码的函数

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

    所以,我们在定义变量时,不但应延后定义变量直到不得不使用它为止,甚至应当尝试延后定义直到能够给它初值实参为止。

  2. 对于一个循环结构内变量的初始化,我们应当评估两种方式:

    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有三种转型风格:

  1. C风格的转型:

    1
    (T)expression;			//将expression转型为T
  2. 函数风格的转型:

    1
    T(expression);			//将expression转型为T
  3. C++提供的四种新式转型

    上述两种转型方式虽然合法,且在C编程中常用,但新式转型往往更为受欢迎,因为它们很容易被程序员和编译器识别,在带project中十分有用

    四种新式转型如下:

    1
    2
    3
    4
    const_cast<T>(expression);			//常量性转除,从常量 ---> 非常量
    dynamic_cast<T>(expression); //安全地向下转型,用于类继承层次间的指针或引用转换
    reinterpret_cast<T>(expression); //强制按该类型的二进制规则翻译过来,逐个bit去解释它
    static_cast<T>(expression); //通用的普通转型

使用static_cast可以抵消explicit

1
2
3
4
5
6
7
8
9
10
class A{
private:
int num;
public:
explicit A(int a):num(a){};
}
void func(A a);
func(15); //error,explicit阻止这一行为
func(A(15)); //ok,既可以解释成调用构造,又可以解释成调用函数风格强转
func(static_cast<A>(15)); //ok,static可以抵消explicit

在子类中使用父类函数 / 成员的正确方式是:

1
2
3
4
5
6
7
8
9
10
11
12
class parent{
public:
void dosomething(){...}
}
class child : public parent{
public:
void func(){
parent::dosomething(); //ok,正确的调用方式
static_cast<parent>(*this).dosomething(); //error,这里实际上是调用static_cast生成的临时对象
//若该函数与外界无交互,不影响,反之影响挺大,故不该这样写
}
}

对于dynamic_cast,尽量以其他方式替代它,因为dynamic_cast要满足动态性,需要更多的时间开销,常用的替代方法有:

  • 直接使用子类的指针(引用),不适用多态
  • 将父类考虑得面面俱到,一些不适用的方法只写声明不写定义

三、避免返回handles指向对象内部

  1. 成员变量的封装性最多等于“返回其reference”的函数的访问级别。

  2. 返回一个代表对象内部数据的handle在一些情况下很方便,但这降低了对象的封装性。

  3. 返回refenrence可能会造成指针 / 引用 虚吊,经典情况如STL迭代器失效,这是由于handle比它所指对象更长寿造成的,应尽力避免这种情况。

四、为“异常安全”而努力

  1. 这里来补充一下C++中的异常

    异常是指程序的控制流不能按预期工作且出错,这时程序会因异常而终止,C++使用trycatch以及throw来应对这一情况:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    try{
    .... // 本该运行的代码
    }
    catch(execption){
    .... // 捕捉到异常execption后的应对策略
    }
    catch(exception){
    throw exception; //捕捉到一个无法处理的异常,抛出给上级函数
    }

    其中throw关键字也可定义一个函数应当抛出的异常类型:

    1
    2
    3
    4
    T 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_castdynamic_cast未通过运行时检查
    std::bad_typeid该异常由typeid抛出
    std::bad_exception如果函数的异常列表里声明了 bad_exception 异常,当函数内部抛出了异常列表中没有的异常时,都会被替换为 bad_exception 类型。
    std::logic_error逻辑错误
    std::runtime_error运行时错误

    注:typeid的作用与decltype相似,都可以得到一个变量或者表达式的类型,不同的是,typeid方法得到的类型不能用于定义变量,可以用来进行类型的比较。

  2. 异常安全性

    当因异常被抛出而导致程序暂时终止时,带有异常安全性的函数应当:

    1. 不泄露任何资源
    2. 不允许数据遭到破坏

    对于第一条,我们遵循RAII的原则,以对象管理数据,使得系统清理栈上资源而调用栈上对象的析构函数时,正好释放资源。

    而对于第二条不许数据遭到破坏,异常安全函数提供以下三个保证之一:

    1. 基本承诺,异常被抛出时,程序的任何事物都是一个合法状态
    2. 强烈保证,如果异常被抛出,程序内的任何事物保证被复位
    3. 不抛掷保证,一旦有异常被抛出,绝对是很严重的错误,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
    49
    class 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
    5
    void func(){
    f1();
    f2();
    ...
    }

    根据木桶原理,若func调用的函数无强烈保证,则很难让他成为一个有强烈保证的函数,而即使所有被调用的函数都有强烈保证,但f2()出现异常后复位,f1()没有出现异常,这依旧不是强烈保证,所以不必强求。

五、了解inline的里里外外

  1. inline的思想是将函数直接插入到程序中,使得一些本体执行比进行函数调用开销小的函数更有效率地运行(继承了宏的思想)
  2. 因为inline会将函数代码插入程序,所以可能会导致代码膨胀,使编译器产出更多的机器码,从而导致cache命中率下降导致效率降低
  3. 一个最佳方法时遵循 2 - 8定律,先使用不带inline版本的函数 ——> 找出决定程序性能的20% ——> 用各种奇技淫巧优化它

六、将文件编译依赖关系降到最低

  1. 支持“编译依存性最小化”的一般构想是,使得调用程序依赖于类型的“声明式”,而非“定义式”

定义式的class实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
class Person{
private:
std::string name;
Date brithday;
Adress adress;
public:
Person(const std::string& name, const Date& brithday, const Adress& adress);
std::string getName() const;
Date getBrithday() const;
Adress getAdress() const;
}

直接将定义式放入头文件person.h,然后需要这个类时include这个头文件显然是很navie的想法,搭嘎,这么做的代价又是什么呢?

代价就是——我们之后对该class进行版本迭代更新时,所有include它的文件都需要重新编译!

对于这一问题,最佳解决策略是将定义式隐藏在背后,有两种方法可以达到这个目的,一是PIMPL大法,即将Person分割成两部分,一个负责提供接口(声明式),一个负责实现(定义式),这样做可以使文件编译依赖关系降到最低:

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
#include<personpimpl.h>
class PersonImpl{
private:
std::string name;
Date brithday;
Adress adress;
public:
Person(const std::string& name, const Date& brithday, const Adress& adress);
std::string getName() const;
Date getBrithday() const;
Adress getAdress() const;
}

#include<person.h>
class Person{
private:
std::shared_ptr<PersonImpl> Pimpl;
public:
Person(const std::string& name, const Date& brithday, const Adress& adress):
Pimpl(std::make_shared<PersonImpl>(const std::string& name, const Date& brithday, const Adress& adress))
{}
std::string getName() const{
return Pimpl -> getName();
}
...
}

这样做,重构PersonImpl时就不需要重新编译程序文件了,只需要重新编译PersonImpl所在的 .h文件。

另一个解决问题的方法是,编写一个抽象基类来描述规范,使用抽象基类指针来完成操作(多态),这种方法的经典实践如工厂模式:

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
//产品的基类
class Product{
public:
//基类中的纯虚函数
virtual int operation(int a, int b) = 0;
};
//产品的子类Add
class Product_Add : public Product{
public:
int operation(int a, int b){
return a + b;
}
};
//产品的子类Mul
class Product_Mul : public Product{
public:
int operation(int a, int b){
return a * b;
}
};
//工厂
class Factory{
public:
Product* Create(int i){
switch (i){
case 1:
return new Product_Add;
break;
case 2:
return new Product_Mul;
break;
default:
break;
}
}
};
  1. Pimpl大法会使得为每次访问增加间接性,且为每个对象增加了一个指针的大小,而interface class则为每次函数调用多付出了一个间接跳跃的成本(因为它的成员函数都是虚函数),但并不意味着不去使用它们,要大胆去用

  2. 程序库头文件应当以”完全且仅有声明式“的形式呈现给用户,这对是否涉及template都适用