C++ 编程技巧笔记(持续更新)

目录

前言:C++是博大精深的语言,特性复杂得跟北京二环一样,继承乱得跟乱伦似的。

不过它仍然是必须用在游戏开发上的编程语言,这篇文章用于挑选出一些个人觉得重要的条款/经验/技巧进行记录总结。
文章最后列出一些我看过的C++书籍/博客等,方便参考。

其实以前也写过相同的笔记博文,现在用markdown”重置“一下。

类/对象

多态基类的析构函数应总是 public virtual,否则应为 protected

当要释放多态基类指针指向的对象时,为了按正确顺序析构,必须得借助 virtual 从而先执行析构派生类再析构基类。

当基类没有多态性质时,可将基类析构函数声明 protected ,并且也无需耗费使用 virtual

(C++11) 禁用某个成员函数时,使用 = delete

禁用函数用 =delete 而不用 private: 的原因有4个:

  • private 函数仍需要写定义(即使那是空的实现)
  • 派生类潜在覆盖禁用函数名的可能性
  • =delete 语法比 private: 语法更直观体现函数被禁用的特点
  • 在编写非类函数的时候,无法提供 private 属性

一般 = delete 的类函数应为 public,因为编译器先检测可访问性再检验禁用性

编译器会隐式生成默认构造、析构、复制构造、复制赋值、(C++11)移动构造、(C++11)移动赋值的 inline 函数

当你在代码中用到以上函数时且没有声明该函数时,就会默认生成相应的函数;特殊的,当你声明了构造函数(无论有无参数),都不会隐式生成默认构造函数。

不过隐式生成的函数比自己手写的函数(即使行为一样)效率要高,因为经过了编译器特殊优化。

(c++11)当你需要显式禁用生成以上某个函数时,可在函数声明尾部加上 = delete ,例如:

Type(const Type& t) = delete;

(c++11)当你需要显式默认生成以上某个函数时,可在函数声明尾部加上 = default ,例如:

Type(Tpye && t) = default;

不要在析构函数抛出异常,也尽量避免在构造函数抛出异常

析构函数若抛出异常,可能会使析构函数过早结束,从而可能导致一些资源未能正确释放。

构造函数若抛出异常,则无法调用析构函数,这可能导致异常发生前部分资源成功分配,却没能执行析构函数的正确释放行为。

(C++11) 尽量使用 {} 而不是 () 去承担构造对象的职责

主要是为了可读性。既然 C++11 引入初始化列表 {} 的语法,那么为何不好好利用起来?尽量让 {} 承担构造对象的职责,让 () 承担函数相关的职责。

class Test;
...					// 一堆眼花缭乱的代码,看到你头皮发麻身心疲惫后
Test a = Test(1);	// 第一印象可能觉得是构造对象,也可能是在调用函数
Test b{1};			// 看见{}就第一印象就是在构建对象

模板

不要偏特化模板函数,而是选择重载函数

编译器匹配函数时优先选择 非模板函数(重载函数),再选择 模板函数,最后再选择 偏特化模板函数

当匹配到某个模板函数时,就不会再匹配选择其他模板函数,即使另一个模板函数旗下有更适合的偏特化函数;所以这很可能导致编译器没有选择你想要的偏特化模板函数。

(C++11) 不要重载万能引用的函数,否则使用其它替代方案

万能引用的函数是C++中最贪婪的函数,容易让需要隐式转换的实参匹配到不希望的转发引用函数。(例如下面)

template<class T>
  void f(T&& value);

void f(int a);

//当使用f(long类型的参数)或者f(short类型的参数),则不会匹配int版本而是匹配到转发引用的版本

替代方案:

  1. 舍弃重载。换个函数名或者改成传递 const T& 形参
  2. 使用更复杂的标签分派或模板限制(不推荐)

函数

(C++11) lambda 表达式一般是函数对象;特殊地,在无捕获时是函数指针

编译器编译 lambda 表达式时实际上都会对每个表达式生成一种函数对象类型,然后构造出函数对象出来。

特殊地,lambda 表达式在无任何捕获时,会被编译成函数,其表达式值为该函数指针(毕竟函数比函数对象更效率);因此在一些老旧的C/C++ API只接受函数指针而不接受 std::function 的时候,可以使用无捕获的 lambda 表达式。

(C++11) 尽可能使用 lambda 表达式代替 std::bind

直接举例说明,假设有如下 Func 函数:

void Func(int a, float b);

现在我们让 Func 绑定上 2.0f 作为参数 b,转化一个 void(int a) 的函数对象。

std::function<void(int)> f;
float b = 2.0f;

//std::bind写法
f = std::bind(Func, std::placeholders::_1, b);
f(100);

//lambda表达式写法
f = [b](int a) {Func(a, b); };
f(100);

可以看到使用 std::bind 会十分不美观不直观,还得注意占位符位置顺序。

而使用 lambda 表达式可以让代码变得十分简洁优雅。

(C++11) 使用 lambda 表达式时,避免默认捕获模式

按引用默认捕获容易造成引用空悬,而显示的引用捕获更能容易提醒我们捕获的是哪个变量的引用,从而更容易理清该引用的生命周期。

按值默认捕获容易让人误解 lambda 式是自洽的(即不依赖外部)。下面是一个典型例子:

void test() {
   static int a = 0;
   auto func = [=]() {
   return a + 2;
   };
   a++;
   int result = func();
}

由于默认捕获,你以为 a 是以按值拷贝过去,所以期待 result 总会会是 2。但是实际上你是调用了同一个作用域的静态变量,没有拷贝的行为。

所以,无论是按值还是引用,都尽量指定变量,而不是用默认捕获。

内存相关

检查 new 是否失败通常是无意义的

new 几乎总是成功的,现代大部分操作系统采取进程的惰式内存分配(即请求内存时不会立即分配内存,当使用时才分配)。

所以当使用 new 时,通常不会立即分配内存,从而无法真正检测到是否内存将会耗尽。

尽量避免多次 new 同一种轻量级类型,而是先 new 一个大区域再分配多次

每次 new 的时候,实际上还会额外分配出一个存放内存信息的区域,而多次分配内存给轻量级类型时,会造成臃肿的内存信息;而且在删除这些区域时,很容易造成很多块内存碎片,导致内存利用率不高。

所以应当使用内存池的方式,先 new 一大块区域,再从区域分配内存给轻量级类型。

STL 容器

(C++11) 使用 emplace/emplace_back/emplace_front 而非 insert/push_back/push_front

emplace / emplace_back / emplace_front 最大的作用是避免产生不必要的临时变量,因为它可以直接在容器相应的位置根据参数来原地构造变量。

而 insert / push_back / push_front 操作是会先通过参数构造一个临时变量,然后将临时变量移动到容器相应的位置。

(C++11) 优先使用 const_iterator 而非 iterator

只要变量是只读的,那么就尽量使用 const 修饰,既能提高代码可读性,又能避免程序员写代码时错误的修改了变量。而对于 STL 迭代器,假如使用 const 修饰迭代器,那也只是迭代器对象本身不会被修改,其内部指针指向的元素仍然可能被程序员修改,因此才在 C++11 推出了 const_iterator

容器对应的接口一般有 cbegin()cend() 等,可以通过这些接口获取const_iterator 迭代器对象:

// example
std::vector<int> v{1,2,3,4,5};
// itr 完整类型是 std::vector<int>::const_iterator,为了简化代码使用 auto
for (auto itr = v.cbegin();itr != v.cend(); ++itr)
{
	std::cout << *itr;
}

在遍历容器时删除迭代器需谨慎

顺序式容器删除迭代器会破坏本身和后面的迭代器,节点式容器删除迭代器会破坏本身,导致循环遍历崩溃(循环遍历依赖于容器原有的迭代器)。

两个值得借鉴的正确做法:

auto it = vec.begin();
while (it != vec.end()){
    if (...){
        // 顺序式容器的erase()会返回紧随被删除元素的下一个元素的有效迭代器
        it = vec.erase(it);
    }
    else{
        it++;
    }
}
auto it = list.begin();
while (it != list.end()){
    if (...) {
        t.erase(it++);
    }
    else {
        it++;
    }
}

容器的 at() 会检查边界,[] 则不检查边界

STL小细节。

另外 std::vector<bool>std::bitset 的 [] 提供的是值拷贝,而不是引用。

永远记住,更低的时间复杂度并不一定意味着更高的效率

STL容器,特别是 std::set, std::map,有着很多 O(logN) 的操作速度,但并不意味着是最佳选择,因为这种复杂度表示往往隐藏了常数很大的事实。

例如说,集合的主流实现是基于红黑树,基于节点存储的,而每次插入/删除节点都意味着调用一次系统分配内存/释放内存函数。这相比 std::vector 等矢量容器所有操作仅一次系统分配内存(理想情况来说),实际上就慢了不少。此外,矢量容器对CPU缓存更加友好,遍历该种容器容易命中缓存,而节点式容器则相对容易命中失败。

综上,如果要选择一个最适合的容器,要记住时间复杂度是一个主要衡量因素但不是唯一因素。

STL 容器需要深度优化时,使用自定义内存分配器

每个 STL 容器都会要求提供一个 Allocator 类型作为该容器的节点分配器,不提供时使用 STL 默认的缺省分配器。

template<typename T, class Allocator = allocator<T>>
class list {...};

默认缺省分配器的行为往往是简单粗暴的 new delete,这可能带来一些效率问题和内存碎片问题。
而通过自己定制分配器,我们可以把 STL 容器的内存分配达到如下策略:

类型 策略描述
固定大小的缓冲池 所有内存分配都是一样大小,减少每次分配内存浪费。
共享内存 分配使用共享内存。
多个堆 分配使用不同的堆,试分配大小和类型而定。
单线程的 分配和释放均不保证线程安全。
垃圾回收 调用释放的时候并不立即释放,调用垃圾回收函数时才释放。
基于栈的策略 所有内存都是在栈上,适用于短生命期的容器对象。
静态内存 分配的内存位于程序的静态内存区里。
从不删除 调用释放的时候不释放内存,程序结束时才回收内存。
一次性删除 调用释放的时候不释放内存,通过定制函数来释放内存。
边界对齐策略 为了满足某些条件,内存边界总是对齐分配。例如在SSE中使用指令对齐内存的时候。
调试 分配记录、检查内存泄漏、检查内存覆盖情况、峰值分配大小等等。

STL 算法

std::sort 数量少时用插入排序,数量多时先用快速排序再用堆排序

image-20221201133138751

重载 std::sort/std::set/std::multiset 等的 < 比较操作符时,若两者相等则必须返还失败

实际上 STL 要求重载 STL 的 < 比较操作符必须遵循严格弱序。如果不是严格弱序,可能会导致:

  • std::sort 在快排的 __unguarded_partition_pivot() 过程中的 while(Cmp(*first, pivot))++first;没有检查边界条件(为了节省性能),导致了 first 指针越界,程序 crash。
  • std::set / std::map / std::multiset / std::multimap 判断相等时使用了 !Cmp(a,b) && !Cmp(b,a) ,查找相同的元素会导致永远失败的情况。
  • ...

优化与效率

尽可能使用 ++i 而不是 i++

在使用迭代器或其他自定义类型时,i++ 往往还得创建一个额外的副本来用于返还值,而 ++i 则直接返还它本身。

这个是老生常谈的 C++ 经典问题。不过对于 int/unsigned 等内置类型,++ii++ 在效率上没有区别。

不必考虑什么时候用 inline,而是将其视为一种代码注释

  • 现代编译器已经十分智能,很多时候该写成 inline 的函数编译器会自动帮你 inline,不该 inline 的时候即使你显式写了 inline 编译器也有可能认为不该inline;也就是说显式的写出 inline 只是给编译器一个建议,它不一定会采纳(实际上基本不听你的,该干啥干啥,编译器绝大部分时候都比你聪明)
  • 而且 inline 实现往往写在头文件,开发前期频繁的更改也会导致包含该头文件的编译单元必须得重新编译

因此在开发时不用考虑 inline 优化,而是编写那些基础而短小的函数时才考虑显式写出 force inline,不过编译器基本上也会我行我素,所以这时候的 inline 也只不过是一种代码提示。

尽量不使用 dynamic_cast 并且禁用 RTTI

依靠 dynamic_cast 的代码往往可以用多态虚函数解决,而且多态虚函数更加优雅。因此,尽可能避免编写 dynamic_cast

另外可以随之禁用与 dynamic_cast 相关的RTTI特性,禁用该特性可以提升程序效率(每个类的虚函数表少一些臃肿的RTTI信息,即type_info对象)。

(C++11/14) 只要函数潜在编译期可计算,返还值就使用 constexpr 而非 const

其实就是相当于部分模板元运算的美化语法 😃

constexpr 函数接受的输入全是编译期常量时,该函数就可以进行编译期运算,避免在运行期运算;但如果函数输入接受的是运行期变量,则会退化成运行期计算。

不过在使用时要注意:

  • 函数输入主要是指函数实参和引用的外部变量,这两者都应当保证是编译期常量。
  • 如果函数递归过多,编译器会拒绝编译期运算。
  • 函数返还值应当是非 void 类型,这样 constexpr 修饰才有效果。

此外,constexpr 也可以修饰变量,这意味着该变量将是一个编译期常量,并且其初始化只允许接受编译期表达式,并且之后一直不可修改。

对比一下 const 修饰的变量,这意味着该变量将是一个运行期常量,即其初始化可以接受运行期表达式,并且之后一直不可修改。

constexpr int b = 2;
constexpr int f(int n)
{
	int a = n + 1;
	a /= b;
	if (n <= 1)return a;
	return f(n - 1) + f(n - 2) + a;
}
int main()
{
	constexpr int v1 = f(10);	// 编译期计算,没有代价
	int n = 10;
	const int v2 = f(n);		// 运行期计算 
}

异常

(C++11) 若保证异常不会抛出,应使用 noexpect,否则不要声明异常规格。

如果没有任何声明异常规格,意思该函数是可以抛出任何异常。相比无声明异常规格的函数,noexpect 函数能得到编译器的优化(发生异常时不必解开栈),且能清晰表示自己的无异常保证。

头文件

做好头文件 include guard

使用宏 #pragma once 或者 #ifndef #define ... #endif ,确保本类只会声明一次,做好 include guard,避免重复定义。

头文件包含顺序

首先最好遵守的基本原则是:XXX.cpp 文件最好首先包含对应的头文件 XXX.h,这可以避免隐含依赖。

然后下面是两种主流的包含顺序,可根据自己需要选择(实际上随便一种都可以,问题不大,这里就当了解一下):

  • 《Google C++ Style Guide》推荐顺序,先包含 cpp 对应头文件,再从最一般到最特殊的头文件包含顺序:XXX.h、C标准库、C++标准库、第三方库头文件、你自己工程的头文件。因为这更加直观,增加可读性。
  • 《C++编程思想》推荐顺序,先包含 cpp 对应头文件,再从最特殊到最一般的头文件包含顺序:XXX.h、你自己工程的头文件、第三方库头文件、C++标准库、C标准库。这可以检测出你的库的头文件是不是包含了所有必需的头文件:如果某个你的库文件没有包含它必需的系统文件的话,那么这个顺序就会导致编译错误。

能使用前向声明(forward declarations)就使用,减少对头文件的依赖

一般而言,代码文件之间的耦合越小越好,如果可以使用前向声明来代替包含头文件,那就使用前向声明。包含头文件时尽量具体,如:不要包含一个大而全的头文件,而是包含其中具体需要用到的头文件。

杂项

(C++11) 使用 nullptr 而不是 NULL 或 0

NULL 是C语言遗留的东西,是将宏定义成0的,容易造成指针和整数的二义性。

nullptr 很好的避免了整数的性质。

(C++11) 使用 enum class 语法为枚举类型提供限定范围

C带来的 enum 语法是允许枚举类进行隐式转换的,潜在可能造成程序员不希望发生的转换。

而C++11的 enum class 会阻止隐式转换,需要程序员显示转换

enum class Color{Red,Blue,Green};
Color color = Color::Red;
int i = static<int>(color);

(C++11) auto 只能推导出类型型别,而 decltype 能够推导出声明型别

int& value = 233;
auto a = value;//auto推导出是int类型
decltype(auto) b = value; //decltype(auto)是int&类型

也就是说 auto 推导出的类型会抛弃引用性质,而 decltype() 能够推导出完整的声明类型。

此外一提,auto 是声明类型的语法,而 decltype() 是一个表达式,类似于 sizeof() ,只不过表达式的值是类型

C++17 新特性

(C++17) 需要用到 union 时,可以考虑使用 std::variant

union 是从 C 继承来的特性,由于它的类型可以随意解释,使用就非常不安全。目前 C++17 已引入 <variant> 库,提供了 std::variant ,目的在于提供更为安全的 union。

  • 需要在编译期提前指定有哪几个可转换类型,这点和 union 是一样的。
  • 在接口上更清晰和更安全,通过运行时类型信息跟踪对象的最新类型,相比 union 更具有类型安全性。
  • 内存基于栈分配,占用大小固定,但是需要一些额外的内存来缓存运行时类型信息。
  • 可搭配 std::visit,自动选择 variant 变量的当前类型来做某些行为,而无需写一堆 if 判断 variant 变量内部当前是哪个类型然后做对应的行为。
// some example
std::variant<int, double> v1;
v1 = 1; // activate the "int" member
assert(v1.index() == 0);
assert(std::get<0>(v1) == 1);

v1 = 3.14; // activate the "double" member
assert(v1.index() == 1);
assert(std::get<1>(v1) == 3.14);
assert(std::get<double>(v1) == 3.14);

assert(std::holds_alternative<int>(v1) == false);
assert(std::holds_alternative<double>(v1) == true);

assert(std::get_if<int>(&v1) == nullptr);
assert(*std::get_if<double>(&v1) == 3.14);

(C++17) 需要用到任意可变的类型时,可以考虑使用 std::any

万能变量是指可以在运行期转换任意类型(可扩展,如 metadata)的变量,即它不希望提前指定可转换类型,而是运行时想转换成什么类型就转换成什么类型,其变量大小一般取决于转换过的类型中最大的那个。

注:万能变量的成员不可以是带构造函数/析构函数/自定义复制构造函数的 C++ 对象。

万能变量在传统的 C/C++ 实现中,往往采用动态分配内存,用 void* 变量和一个表示分配内存大小的整型变量,运行时需要转换成什么类型时便强转指针进行访问。目前 C++17 已引入 <any> 库,提供了std::any,目的在于提供更为安全的万能变量。

  • 相当于封装了基于void* 和整型变量的类型,提供了更加清晰和安全的访问接口
  • 无需指定可转换类型,在运行时可随时转换成任意类型的对象
  • 内存需要基于堆分配,可运行时扩充,效率低些

(C++17) 函数返回值要包含成功与否信息+返还值信息时,使用 std::optional

C++ 17 引进了 <optional> 库:

  • std::optional,实际上类似于 std::tuple<bool,ReturnType>,不过可以让函数返还更加优雅
std::optional<float> func(float x){
    if(x>0.f)
        return x;
    else
        return std::nullopt;
}

int main(){
    float x;
    auto res = func(1.0f);
    res.has_value();// 检查不为空
    res;			// 检查不为空
    res.value();	// 检查不为空,如果为空则抛出异常,否则返还正常函数值
    *res;			// 直接返还函数值,不检查空也不抛出异常;有一定风险,但只要保证在检查了不为空的分支下执行就可以用,效率好过value()
    res.value_or(x);	// 检查不为空,如果为空则返还缺省值x,否则返还正常函数值
    // 一个使用例子
    if(res){
        std::cout << *res;
    }
}

optional 的设计目的在于让它的行为看起来如同指针行为一样,例如 std::nullopt 就和 nullptr 非常相似。

参考

C++是非常非常复杂的语言,了解得越多就越发觉得自己的无知(例如C++ Boost)。但是在学习C++的中途也必须认识到,C++是一门工具,不要过多钻C++语言的牛角尖。

谨记:程序员是要成为工程师而不是语言学家。

posted @ 2019-09-28 01:14  KillerAery  阅读(3450)  评论(2编辑  收藏  举报