Effective C++

2018-05-16
16 min read

Effective C++ / More Effective C++ / Effective STL / Effective Modern C++

trivial tips

技术

graph TD
	B[use const]
	C[virtual dtor]
  1. 尽可能使用 const 关键字(3)

    1. 注意 bitwise const 和 logical const 的区别,前者是编译器对 const 的实现方法,后者是对 const 的用法

    2. 虽然不应该使用类型转换但为了避免重复可以在 non-const 函数种调用 const 函数不要在 const 函数中调用 non-const 函数,这样违反了语义

      return const_cast<char&>( // 非安全转型,去除返回值中的 const 修饰
          static_cast<const T&>(*this).ret_char_ref(); // 安全转型,给当前对象加上 const 修饰
      );
      
  2. 多态基类的析构函数加上 virtual,注意这里的两个关键词:多态&基类 (7)

    1. 多态场景下,没有 virtual 析构的类不应该被继承,不然会因局部析构的问题造成内存泄漏等异常。C++ 中 的 string 和一些容器,比如 vector/map/set 等都没有 virtual 析构,所以要避免继承这些类,尽量使用 final 关键字避免这些类的继承。非多态用途的类,比如 Uncopyable,可以被继承
    2. 不要给不需要的类加上 virtual,避免不必要的性能损失
    3. 这一条主要担心的是运行时未完整析构多态对象
  3. 不要在构造与析构函数中调用 virtual 函数(9)

  4. inline(30)

    1. 类声明(头文件)中定义的函数默认 inline
    2. 有被取地址操作的 inline 函数会被剥夺 inline 属性;很多调试场景,inline 默认是禁止的;构造与析构函数的 inline 是非常差的设计
  5. 临时对象

    1. C++ 中的临时对象有多种,比如类型转化过程生成的临时变量(比如函数调用时 const 参数的默认转换)、函数返回时生成的临时对象

设计

  1. 令 operator = 返回 reference to *this,a = b = c (10)

  2. 使用证同测试在 operator = 中处理自我赋值;更好的办法是使用异常安全代码:使用 swap。没有处理自我赋值的拷贝赋值可能造成资源意外释放等问题(11)

    A& operator=(const A& rhs) {
    	A tmp(rhs);
    	swap(*this, tmp);
    	return *this;
    }
    
  3. 使用独立的语句将资源置入自能指针中,避免调用异常时造成资源泄漏(17)

    //A的创建/fun的执行/智能指针的创建,随编译器实现而不同,一旦出现异常很可能出现资源泄漏
    process(std::shared_ptr<A>(new A()), fun()); // 错误用法
    // 下面的写法比较合适
    auto sptr = std::shared_ptr<A>(new A());
    process(sptr, fun());
    
  4. 适当的引入新类型可以减少不必要的错误,比如引入 Day/Month/Year 类,可以减少参数填些的错误(18)

  5. 封装意味着减少信息的泄漏,非 private 的成员变量与更多的成员函数都会泄漏更多的信息(22、23)

    1. 将所有成员变量设置为 private 虽然在某些场景下增加了代码量但提高了代码的维护提供了弹性(22)
    2. 可能的话,使用 non-member/non-friend 函数代替 member 函数(23)
      1. 面向对象提倡封装,non-member/non-friend 可以提供比 member 更高的封装性能。封装程度越高意味着对象向外提供的信息越少,从实现上看就是对成员变量的访问(成员函数)更少
      2. 非必要的 helper 函数可以放到其他编译单元中以减少代码之间的依赖
  6. 使用 non-member 函数实现所有参数的自动类型转换,例如 class A 与常量之间的乘法(24)

  7. 尽量少做转型(27)

    1. 默认转型会造成编译器额外的工作,比如继承关系中同一个对象可能有不同指针指向不同位置表示不同对象
  8. 保证异常安全:不泄漏资源 & 不破坏数据,常常可以使用 copy-and-swap 方式实现异常安全(29)

    1. 不泄漏资源:RAII,例如 lock_guard/智能指针;特别注意new/delete之间的代码
    2. 不破坏数据:比如异常安全的 swap 会先构造一个临时变量而不是直接赋值,避免赋值失败对原始数据的破坏。这里的原则是:all or nothing,修改原始数据前准备好一切,直接 swap 就可以修改原始变量
  9. 减少编译依赖,最好实现头文件中“只有声明式”(31)

    1. C++ 编译依赖的一个原因:编译时编译器需要知道对象的大小以分配内存。Java 等语言不存在此类问题,因为 Java 中所有对象都在堆中,栈中分配一个指针大小的内存空间即可
    2. 手段(依赖声明式而非定义式)
      1. 尽量使用前置声明,会因为 string 是 typedef 所以 string 前置声明比较复杂,不过标准库依赖对编译性能的影响不大
      2. 优先使用指针与引用:类内能使用饮用或者指针,就不要使用对象
      3. Pimpl

面向对象

  1. 面向行为继承。public 继承意味着 is-a,这里的 is 是面向行为(函数)的。企鹅是鸟但不能飞,所以企鹅不应该继承自鸟(32)
  2. 区分接口继承与实现即成,impure virtual fun(34)
  3. 绝不重写继承而来的非虚函数(36)
  4. 不修改继承而来的虚函数默认参数(37)
  5. 虚函数的替代与优化方法(35)
    1. NVI(non-virtual interface),类似装饰模式。使用非虚函数作为接口,在非虚函数中调用虚函数。NVI 的好处是可以在虚函数前后执行一些准备与清理任务而又有虚函数的灵活性
    2. 使用 functor/bind 实现策略模式,比继承实现的策略模式更灵活

new&delete

  1. 可以给 new 提供一个函数,申请内存失败时调用

异常安全

  1. 构造函数抛出异常后 C++ 不会调用对象的析构函数,因为此时对象没有构建完全
  2. C++11 以前,使用异常对比不使用异常的二进制文件其大小与执行时间有 5%~10% 的损耗;一旦抛出异常,整个异常流可能比正常流慢三个数量级,这只是抛出异常时的情况

STL

下面条目参考自《Effective STL》

graph TB
	A[STL]
	B[speed]
	C[empty<br/>size]
	D[prefer region f]
	E[design]
	F[alloc<br/>rbind]
	G[string]
	H[1-7]
	I[no heap]
	J[equivalence<br/>equality]
	K[ret false if eq]
	L[vec/bs<br/>map]
	M[istreambuf_it]
	N[partation<br/>nth_element<br/>partail_sort<br/>sort<br/>stable]
	O[functor]
	P[pass by val]
	Q[pure]
	R[inline]
	S[utils]
	
	
	
	A-->B
	B-->D
	A-->E
	E-->F
	G-->H
	G-->I
	E-->J
	J-->K
	B-->L
	D-->N
	E-->O
	O-->P
	O-->Q
	O-->R
	B-->S
	S-->M
	S-->C
	S-->G
	
	style K fill:#9F9,stroke:#333,stroke-width:1px
	style L fill:#F99,stroke:#333,stroke-width:1px
	style Q fill:#99F,stroke:#333,stroke-width:1px
  1. 不要试图编写独立于容器类型的代码(item2)
  2. 调用 empty而不是检查 size() 是否为 0 (item4)
    1. empty 的效率 保证不低于 size,list 的不同实现 empty 是线性时间复杂度但 size 不一定(因为 splice)
  3. 区间成员函数优先于与之对应的单元素成员函数 (item5)。至少前者可以预分配空间
  4. 很多标准容器没用使用内存分配器(item10)
    1. 比如基于节点形式的关联型容器,默认内存分配的空间了些是 T,但容器所需要的是 Node<T> ,这也是 rebind 存在的价值
  5. string 有多种不同的实现(item15)
    1. 比如引用计数,小字符串优化等等,常见的实现方法中 sizeof(string) 是 sizeof(char) 的 1~7 倍*
    2. 有些 string 设计,其对象内部有额外的 15 字节用于小字符串的保存,此时 str 没有堆数据
  6. 使用“swap技巧”除去多余的容量(item17)。shrink 不一定有效,std::vector<int>(vec).swap(vec)
  7. 理解相等(equality)和等价(equivalence)的区别(item19)
  8. 总是让比较函数在等值情况下返回 false(item21)
  9. 考虑用排序的 vector / binary_search替代关联容器(item23)
    1. 因为内存使用方式的区别,vec 可以减少页错误造成的性能损耗
  10. 当效率至关重要时,请在map::operator[]map::insert之间谨慎做出选择(item24)
  11. 对于逐个字符的输入请考虑使用 istreambuf_iterator/ostreambuf_iterator (item29)
  12. partition/stable_partition / nth_element / partial_sort / sort/stable_sort (item31)
  13. remove/erase(item32)
    1. remove 无法真实删除元素是因为只有容器对象才可以删除节点,remove 的参数是迭代器而非容器对象
  14. 使用 accumulate 或者for_each进行区间统计(item37)
  15. 遵循按值传递的原则来设计 functor(item38)
  16. 确保判别式是“纯函数”(item39)
    1. 如果删除的过程中状态会发生变化,那么可以先搜集将被删除的数据,然后调用标准库中的方法
  17. 容器的成员函数优先于同名的算法(item44)
  18. 考虑使用函数对象而不是函数作为 STL 算法的参数(item46)。functor 会做 inline 优化,效率提升比较明显

Modern CPP

graph TB
	A[template & auto]
	B[T&]
	C[T&&]
	D[T]
	E[array & func ptr]
	F[initializer_list]
	G[pitfall]
	H[decltype/auto]
	J[move/forward vs std::ref/cref]
	K[rule of 5]
	L[引用折叠]
	M[move failed]
	
	A-->B
	A-->C
	A-->D
	A-->E
	A-->H
	H-->F
	H-->G
	C-->J
	J-->L
	L-->M

模板类型 & auto 推导

本节参考 EMC(Effective Modern CPP)第一/二章,具体细节可参考这里。如下所示,T 的类型推导不仅取决于 expr 的类型,也取决于 ParamType 的类型

template<typename T>
void f(ParamType param);

f(expr);                        //使用表达式调用 f

template<typename T>
void f(const T& param);         // ParamType 是 const T&

模板参数的推导一般分为三种情形:

  1. ParamType 是引用或者指针。在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略

    1. 如果expr的类型是一个引用,忽略引用部分
    2. 然后expr的类型与ParamType进行模式匹配来决定T
  2. 对于通用引用(T&&)的推导,左值实参会被特殊对待

    1. 如果expr是左值,TParamType都会被推导为左值引用,注意这里的都会
    2. 如果expr是右值,就使用正常的推导规则
  3. 对于传值类型推导,cv 修饰会被剔除。但引用场景下 cv 修饰符会被保留

    1. 和之前一样,如果expr的类型是一个引用,忽略这个引用部分
    2. 忽略 cv 属性

其他需要注意的地方:

  1. 将一个const对象传递给以T&类型为形参的模板安全的:对象的常量性const会被保留为T的一部分

数组与函数

数组与函数在 ParamType 不是引用的场景下自动退化为指针

引用场景下数组转化为数组类型(包括数组长度),函数会转化为函数的引用

auto

auto 的推导与模板类型推导类似,但有一个区别

auto类型推导假定花括号表示std::initializer_list而模板类型推导不会这样

C++14 以后 lambda 表达式支持返回值自动推导,这个推导使用的是模板规则而不是 auto 规则

当一个变量使用auto进行声明时,auto扮演了模板中T的角色,变量的类型说明符扮演了ParamType的角色。下面推导示例同时适用于上面模板参数的推导

// 通用引用的推导
auto x = 27;                    //情景三(x既不是指针也不是引用)
const auto cx = x;              //情景三(cx也一样)
const auto &rx=cx;              //情景一(rx是非通用引用)

auto&& uref1 = x;               //x 是 int 左值,所以 uref1 类型为 int&
auto&& uref2 = cx;              //cx 是 const int 左值,所以 uref2 类型为 const int&
auto&& uref3 = 27;              //27 是 int 右值,所以 uref3 类型为 int&&

// 数组与函数
const char name[] = "R. N. Briggs";
auto arr1 = name;               //arr1的类型是const char*
auto& arr2 = name;              //arr2的类型是const char (&)[13]

void someFunc(int, double);     //someFunc是一个函数,类型为void(int, double)
auto func1 = someFunc;          //func1的类型是void (*)(int, double)
auto& func2 = someFunc;         //func2的类型是void (&)(int, double)

pitfall of auto

  • 不可见的代理类可能会使auto从表达式中推导出“错误的”类型
  • 显式类型初始器惯用法强制auto推导出你想要的结果

下面以vector<bool> 为例来说明问题:

std::vector<bool> features(const Widget& w);

bool highPriority = features(w)[5];     // 显式的声明highPriority的类型,隐式类型转换
auto highPriority = features(w)[5];     // Pitfall,推导 highPriority的类型
auto highPriority = static_cast<bool>(features(w)[5]); // 显示类型转换

decltype && decltype(auto)

auto 使用了类似于模板参数推导的规则,很多时候会自动去除形参的引用部分,但 decltype 不会

  • decltype总是不加修改的产生变量或者表达式的类型
  • 对于T类型的不是单纯的变量名的左值表达式,decltype总是产出T的引用即T&
  • C++14 支持decltype(auto),就像auto一样,推导出类型,但是它使用decltype的规则进行推导

std::ref && std::cref

注意 std::ref/cref 的实现方法与 move/forward 是完全不同的,后者是类型转换而前者是封装。通过上面模板参数推导规则可以知,类型转换是无法实现 ref/cref 功能的

std::movestd::forward仅仅是执行转换(cast)的函数(事实上是函数模板),二者的实现差异还是很大的。具体实现见下文

Rule of 3/5

Rule of 3/5 (c++98:拷贝构造,拷贝赋值和析构) 也常常被称为 Big5(C++11 及以后),一般指 C++ 默认生成的 5 中成员函数:

Big5 的原则

一旦实现了 Big5 中任意一个,其余都应该显式实现。C++ 11 以后可以使用 =default 强制编译器帮我们生成

编译器自动实现 Big5 的前提是它能确定用户不会手动管理资源,而上面的任何一个自定义的成员都迫使编译器放弃这类假设,所以不会主动生成

实际上,就算编译器乐于为你的类生成拷贝和移动操作,生成的函数也如你所愿,你也应该手动声明它们然后加上= default。这看起来比较多余,但是它让你的意图更明确,也能帮助你避免一些微妙的bug。比如开始类中没有析构函数,有一天维护者新增了自定义析构,那么移动函数就会失效,系统性能会受到影响

class Base {
public:
    virtual ~Base() = default;              //使析构函数virtual
    
    Base(Base&&) = default;                 //支持移动
    Base& operator=(Base&&) = default;
    
    Base(const Base&) = default;            //支持拷贝
    Base& operator=(const Base&) = default;
    … 
};

C++11对于特殊成员函数处理的规则如下:

  • 默认构造函数:和C++98规则相同。仅当类不存在用户声明的构造函数时才自动生成
  • 析构函数:基本上和 C++98 相同;稍微不同的是现在析构默认noexcept(参见Item14)。和C++98一样,仅当基类析构为虚函数时该类析构才为虚函数
  • 拷贝构造函数:和 C++9 8运行时行为一样:逐成员拷贝non-static数据。仅当类没有用户定义的拷贝构造时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝赋值或者析构,该函数自动生成已被废弃
  • 拷贝赋值运算符:和 C++98 运行时行为一样:逐成员拷贝赋值 non-static 数据。仅当类没有用户定义的拷贝赋值时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝构造或者析构,该函数自动生成已被废弃
  • 移动构造函数移动赋值运算符:都对非static数据执行逐成员移动。仅当类没有用户定义的拷贝操作,移动操作或析构时才自动生成

Smart Pointer

graph TD
	A[smart ptr]
	B[shared_ptr]
	C[unique_ptr]
	D[weak_ptr]
	E[deleter & type]
	F[make_* is better]
	G[ctor/dtor & Pimpl]
	
	A-->B
	A-->C
	A-->D
	B-->E
	C-->E
	B-->F
	C-->F
	C-->G
	B-->G

unique_ptr 的空间占用与性能和裸指针相近,但有状态的删除器和函数指针会增加std::unique_ptr对象的大小。unique_ptr 常用在工厂方法与 Pimpl 手法中

unique_ptr 可以自动隐式的转化为 shared_ptr

template<typename Y, typename Deleter>
shared_ptr(std::unique_ptr<Y,Deleter>&& u);

shared_ptr 的实现如下图所示:

std::shared_ptr大小是原始指针的两倍

对于std::unique_ptr来说,删除器类型是智能指针类型的一部分。对于std::shared_ptr则不是,二者的声明如下所示:

// shared_ptr
template< class T > class shared_ptr;
// unique_ptr
template<class T, class Deleter = std::default_delete<T>> class unique_ptr;
template<class T,class Deleter> class unique_ptr<T[], Deleter>;

有不同删除器的 sharedptr 可以相互赋值,但 uniqueptr 不行

std::weak_ptr替代可能会悬空的std::shared_ptrstd::weak_ptr的潜在使用场景包括:缓存、观察者列表、打破std::shared_ptr环状结构

从效率角度来看,std::weak_ptrstd::shared_ptr基本相同。两者的大小是相同的,使用相同的控制块(参见Item19),构造、析构、赋值操作涉及引用计数的原子操作(weak count)

make 函数的优缺点

make 类函数优点:

  1. 减少内存泄露的风险,此类风险源自参数计算顺序的随机性

  2. 减少动态内存申请的次数与动态内存的占用

    sharedptr 有控制块,make 函数将控制块和具体对象分配到了连续的内存空间中,调用一次 new 即可;连续内存只需要系统记录一次,也减少的内存的占用。普通的 new 方法,需要创建控制块和对象,需要调用两次 new

  3. 减少代码量

缺点:

  1. 无法指定删除器
  2. 不能用于重载了 new/delete 的类

Pimpl & Smart Pointer

使用 uniqueptr 实现 Pimpl 手法是需要在 cpp 文件中实现对应的 Big5。为了效率,默认的 uniqueptr 的析构一般是 inline 的,编译时需要知道对象的细节

Pimpl 对象的默认拷贝构造、移动构造函数有抛异常的可能(需要先析构当前对象的资源)且这些函数默认 inline,调用这些函数的地方必然要析构本对象中 uniquptr,这就触发了上面需要对象细节的问题

解决办法也很简单,在头文件中声明这些函数,在 cpp 中实现(可以在 cpp 中使用 default)

sharedptr 不需要考虑上面的问题,sharedptr 的析构函数不是类型的一部分,析构时调用了其他编译单元中的函数,因此编译时不需要析构对象的细节

rvalue/move/forward

即使用了 move 也不一定意味着移动语义,比如下面的代码,move 降级为了 copy

explicit Annotation(const std::string text) : value(std::move(text)) { … }                   
  • std::move执行到右值的无条件的转换,但就自身而言,它不移动任何东西
  • std::forward只有当它的参数被绑定到一个右值时,才将参数转换为右值
  • std::movestd::forward在运行期什么也不做

move 的实现大致如下:

template<typename T>
typename remove_reference<T>::type&& move(T&& param) {
    using ReturnType = typename remove_reference<T>::type&&; // remove ref 以避免完美转发
    return static_cast<ReturnType>(param);
}

forward 的实现大致如下(参考):

template <class T>
inline T&& forward(typename std::remove_reference<T>::type& t) noexcept {
    return static_cast<T&&>(t);
}

template <class T>
inline T&& forward(typename std::remove_reference<T>::type&& t) noexcept {
    static_assert(!std::is_lvalue_reference<T>::value,
                  "Can not forward an rvalue as an lvalue.");
    return static_cast<T&&>(t);
}

通用引用&&右值引用

两者主要区别在于是否需要类型推导

  • 如果一个函数模板形参的类型为T&&,并且T需要被推导得知,或者如果一个对象被声明为auto&&,这个形参或者对象就是一个通用引用(universal references
  • 如果类型声明的形式不是标准的type&&,或者如果类型推导没有发生,那么type&&代表一个右值引用
  • 通用引用,如果它被右值初始化,就会对应地成为右值引用;如果它被左值初始化,就会成为左值引用

下面是通用引用与 forward 的示例:

void process(const Widget& lvalArg);        //处理左值
void process(Widget&& rvalArg);             //处理右值

template<typename T>                        //用以转发param到process的模板
void logAndProcess(T&& param)               //param 在函数内部是左值,但 T 不一定 
{
    auto now =                              //获取现在时间
        std::chrono::system_clock::now();
    
    makeLogEntry("Calling 'process'", now);
    process(std::forward<T>(param));
}

不要重载通用引用,因为通用引用几乎是万能的接口,很多调用都将调用通用引用接口(item 26)

避免通用引用的万能匹配(除精确匹配),有多种技巧,例如:

  1. 参数直接传值而非引用,调用相应接口时可以自行选择是否将对临时参数使用 move。函数内部直接使用 move

  2. tag dispatch,即模板的偏特例化

  3. std::enable_if && std::decay

    class Person {
    public:
        template<typename T,
                 typename = typename std::enable_if<condition>::type>   //译者注:本行高亮,condition为某其他特定条件
        explicit Person(T&& n);
        …
    };
    

移动语义也不一定高效

下面几种场景,移动不一定比拷贝高效

  • 没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作
  • 移动不会更快:要移动的对象提供的移动操作并不比复制速度更快(比如字符串的 SSO 优化
  • 移动不可用:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为noexcept

多线程

graph LR
	A[async is better]
	B[std::launch::async]
	
	A-->B

优先使用 std::async

优先考虑基于任务的编程而非基于线程的编程

优先使用 async 接口的原因如下:

  • std::thread API不能直接访问异步执行的结果,如果执行函数有异常抛出,代码会终止执行
  • 基于线程的编程方式需要手动的线程耗尽、资源超额、负责均衡、平台适配性管理
  • 通过带有默认启动策略的std::async进行基于任务的编程方式会解决大部分问题。async 默认并不一定会创建新的线程去执行任务,直接在当前线程执行任务也是有可能的,所以默认的 async 可能造成线程假死

当然直接使用 thread 也有其自身的优势,使用 async 需要注意:

  • std::async的默认启动策略是异步和同步执行兼有的
  • 这个灵活性导致访问thread_locals的不确定性,隐含了任务可能不会被执行的意思,会影响调用基于超时的wait的程序逻辑
  • 如果异步执行任务非常关键,则指定std::launch::async

Other New Features

graph TD
	A[花括号 & initializer_list]
	B[deleter for many things]
  1. 花括号与小括号之间的转换(空的花括号意味着没有实参,不是一个空的std::initializer_list

    在构造函数调用中,只要不包含std::initializer_list形参,那么花括号初始化和小括号初始化都会产生一样的结果

    只要某个使用括号表达式的调用能适用接受std::initializer_list的构造函数,编译器就会使用它

  2. 任何函数都能被删除(be deleted),包括非成员函数和模板实例

    // 模板特列化的删除,禁止某些类型的模板实例化
    template<>
    void processPointer<void>(void*) = delete;
    template<>
    void processPointer<char>(char*) = delete;
    
  3. 成员方法的引用限定符

    class Widget {
    public:
        using DataType = std::vector<double>;
        DataType& data() &              //对于左值Widgets,
        { return values; }              //返回左值
    
        DataType data() &&              //对于右值Widgets,
        { return std::move(values); }   //返回右值
    private:
        DataType values;
    };
    
  4. 尽量保证 const 成员函数的线程安全属性。使用std::atomic变量可能比互斥量提供更好的性能,但是它只适合操作单个变量或内存位置

  5. 尽量使用 emplace 方法

参考

  1. 《Effective C++》
  2. 《More Effective C++》
  3. 《Effective Modern C++》Github 中文翻译