Effectivate C++

Effectivate C++

习惯C++

01

视C++为一个语言联邦

次语言:

  • C语言:以C语言为基础。
  • Object-Oriented C++:面向对象程序设计
  • Template C++:泛型编程
  • STL:程序库

02

尽量以const, enum, inline替代 #define

1
2
3
#define ASPECT_RATIO 1.653 //避免这种写法

const double AspectRatio = 1.653 //替换
  • 对于单纯常量,最好以const对象或enums替换#defines
  • 对于形似函数的宏(macros),最好改用inline函数替换#defines

03

尽可能使用const

04

确定对象在被使用前已先被初始化

C++资源管理

堆(heap)

在内存管理的语境下,堆——动态分配内存的区域,有别于数据结构的堆

此内存被分配后需要手动释放,否则造成内存泄漏。

自由存储区——free store

  • 特指用new和delete来分配和释放内存区域
  • new和delete的底层通常使用malloc和free实现

malloc和free的操作区域是heap

栈(stack)

在内存管理的语境下,栈——函数调用过程中产生的本地变量和调用数据的区域,和数据结构的栈高度类似,满足FIFO。

RALL

Resource Acquisition Is Initialization——C++是主流编程语言中唯一依赖RALL做资源管理,依托栈和析构函数

在包括 x86 在内的大部分计算机体系架构中,栈的增长方向是低地址,因而上方意味着低地址。

重新认识C++

程序的生命周期

编码(Coding)→预处理(Pre-processing)→编译(Compiling)→运行(Running)

编码

OpenResty® C 代码风格指南

最基本的要求是遵循语言规范和设计文档,再者还有代码规范、注释规范、设计模式、编程惯用法等

代码风格

  • 空格和空行
    • 留白的艺术——像‘写诗’一样去写代码
  • 起个好名字
    • 变量、函数名和名字空间用 snake_case,全局变量加“g_”前缀;
    • 自定义类名用 CamelCase,成员函数用 snake_case,成员变量加“m_”前缀;
    • 宏和常量应当全大写,单词之间用下划线连接;
    • 尽量不要用下划线作为变量的前缀或者后缀(比如 _local、name_),很难识别;
  • 注释

预处理

C/C++程序独有。预处理器(Pro-processer)起作用。“预处理”的目的是文字替换,即各种预处理指令,比如 #include、#define、#if 等,实现“预处理编程”。

预处理阶段编程的操作目标是“源码”,用各种指令控制预处理器,把源码
改造成另一种形式,就像是捏橡皮泥一样。

  • 预处理指令都以符号“#”开头
  • 单独的一个“#”也是一个预处理指令,叫“空指令”
  • “#include”,它的作用是“包含文件”,可以包含任意的文件
  • 使用“#if 1”“#if 0”来显式启用或者禁用大段代码,比“/* …*/”的注释方式安全

编译

编译和链接。经过编译器和链接器的“锤炼”,生成可在计算机上运行的二进制机器码。编译的过程中,编译器还会根据 C++ 语言规则检查程序的语法、语义是否正确,发现错误就会产生“编译失败”。这是最基本的 C++“静态检查”。

属性

C++11,标准委员会认识到了“编译指令”的好处,把“民间”用法升级为“官方版本”,起名叫“属性”。可以理解为给变量、函数、类等“贴”上一个编译阶段的“标签”,方便编译器识别处理。

“属性”没有新增关键字,而是用两对方括号形式“[[…]]”,方括号的中间就是属性标签。

1
2
3
4
5
[[noreturn]] 	// 属性标签
int func(bool flag) // 函数绝不会返回任何值
{
throw_std::runtime_error("XXX");
}

C++11 里只定义了两个属性:“noreturn”和“carries_dependency”,它们
基本上没什么大用处。C++14 增加了一个比较实用的属性“deprecated”,用来标记不推
荐使用的变量、函数或者类,也就是被“废弃”。

比如,原来写了一个函数 old_func(),后来觉得不够好,就另外重写了一个完全不同的新函数。但是,那个老函数已经发布出去被不少人用了,立即删除不太可能,该怎么办呢?
这个时候,可以让“属性”发挥威力了。你可以给函数加上一个“deprecated”的编译期标签,再加上一些说明文字:

1
2
[[deprecated("deadline:2020-12-31")]]
int old_func();

于是,任何用到这个函数的程序都会在编译时看到这个标签,报出一条警告:

1
warning: ‘int old_func()’ is deprecated: deadline:2020-12-31 [-Wdeprecated-decl

程序能够正常编译,但这种强制的警告形式会“提醒”用户旧接口已经被废弃了,应该尽快迁移到新接口。这种形式比毫无约束力的文档或者注释要好得多。

静态断言(static_assert)

static_assert 运行在编译阶段,只能看到编译时的常数和类型,看不到运行时的变量、指针、内存数据等,是“静态”的。

编程范式

编程范式(Paradigm)。“编程范式”是一种“方法论”,就是指导你编写代码的一些思路、规则、习惯、定式和常用语。

运行

CPU 利用率通常是评价程序运行的好坏最直观、最容易获取的指标,优化它是提升系统性能最快速的手段。

系统级工具

四个“高性价比”的工具:top、pstack、strace 和 perf。它们用起来很简单,而且实用性很强,可以观测到程序的很多外部参数和内部函数调用,由内而外、由表及里地分析程序性能。

某个进程 CPU 使用率太高,怀疑有问题,那我就要深入进程内部,看看到底是哪些操作消耗了 CPU。这时,我们可以选用两个工具:pstack 和 strace。

pstack 可以打印出进程的调用栈信息,有点像是给正在运行的进程拍了个快照,你能看到某个时刻的进程里调用的函数和关系,对进程的运行有个初步的印象。

pstack 显示的只是进程的一个“静态截面”,信息量还是有点少,而 strace 可以显示出进程的正在运行的系统调用,实时查看进程与系统内核交换了哪些信息。

perf 可以说是 pstack 和 strace 的“高级版”,它按照固定的频率去“采样”,相当于连续执行多次的 pstack,然后再统计函数的调用次数,算出百分比。只要采样的频率足够大,把这些“瞬时截面”组合在一起,就可以得到进程运行时的可信数据,比较全面地描述出 CPU 使用情况。

常用的 perf 命令是“perf top -K -p xxx”,按 CPU 使用率排序,只看用户空间的调用。

源码级工具

推荐一个专业的源码级性能分析工具:Google Performance Tools,简称为 gperftools。它是一个 C++ 工具集,包含了几个专门的性能分析工具(还有一个高效的内存分配器 tcmalloc),分析效果直观、友好、易理解,被广泛地应用于很多系统,经过了充分的实际验证。

编译运行后会得到一个“case1.perf”的文件,里面就是 gperftools 的分析数据,但它是
二进制的,不能直接查看,如果想要获得可读的信息,需要另外一个工具脚本 pprof。但是,pprof 脚本并不含在 apt-get 的安装包里,所以,你还要从GitHub上下载源码,然后用“–text”选项,就可以输出文本形式的分析报告。pprof 也能输出图形化的分析报告,支持有向图和火焰图,需要你提前安装 Graphviz和 FlameGraph。

怎样写出“好”的类

设计思想

抽象(Abstraction)和封装(Encapsulation)

实现原则

  • 在设计类的时候尽量少用继承和虚函数。
  • 使用特殊标识符“final”可以禁止类被继承,简化类的层次关系。

编码准则

在必须使用继承的场合,建议你只使用 public 继承,避免使用 virtual、protected

常用技巧

  • “委托构造”(delegating constructor)

    • ```c++
      class DemoDelegating final
      {
      private:
      int a; // 成员变量
      public:
      DemoDelegating(int x) : a(x) // 基本的构造函数
      {}
      DemoDelegating() : // 无参数的构造函数
      DemoDelegating(0) // 给出默认值,委托给第一个构造函数
      {}
      DemoDelegating(const string& s) : // 字符串参数构造函数
      DemoDelegating(stoi(s)) // 转换成整数,再委托给第一个构造函数
      {}
      };
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16

      - “成员变量初始化”(In-class member initializer)

      - ```c++
      class DemoInit final
      {
      private:
      int a = 0;
      string s = "
      vector<int> v{1, 2, 3};
      public:
      DemoInit() = default;
      ~DemoInit() = default;
      public:
      DemoInit(int x) : a(x) {}
      };
  • “类型别名”(Type Alias)

    • ```c++
      using uint_t = unsigned int; // using别名
      typedef unsigned int uint_t; // 等价的typedef
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18




      # 语言特性

      ## 自动类型推导

      关键字 *auto*

      - auto 总是推导出“值类型”,绝不会是“引用”;
      - auto 可以附加上 const、volatile、*、& 这样的类型修饰符,得到新的类型。

      ```c++
      auto x = 0L; // 自动推导为long
      auto y = &x; // 自动推导为long*
      auto z {&x}; // 自动推导为long*
      auto err; // 错误,没有赋值表达式,不知道是什么类型

C++ 的“自动类型推导”还有另外一个关键字:decltype

1
2
3
4
5
6
7
int x = 0;	// 整型变量
decltype(x) x1; // 推导为int,x1是int
decltype(x)& x2 = x; // 推导为int,x2是int&,引用必须赋值
decltype(x)* x3; // 推导为int,x3是int*
decltype(&x) x4; // 推导为int*,x4是int*
decltype(&x)* x5; // 推导为int*,x5是int**
decltype(x2) x6 = x2; // 推导为int&,x6是int&,引用必须赋值

decltype 不仅能够推导出值类型,还能够推导出引用类型,也就是表达式的“原始类
型”。

C++14 增加了一个“decltype(auto)”的形式,既可以精确推导类型,又能像
auto 一样方便使用。

1
2
3
4
int x = 0;	// 整型变量
decltype(auto) x1 = (x); // 推导为int&,因为(expr)是引用类型
decltype(auto) x2 = &x; // 推导为int*
decltype(auto) x3 = x1; // 推导为int&

auto 还有一个“最佳实践”,就是“range-based for”,不需要关心容器元素类型、迭
代器返回值和首末位置,就能非常轻松地完成遍历操作。不过,为了保证效率,最好使
用“const auto&”或者“auto&”。

1
2
3
4
5
6
7
8
9
vector<int> v = {2,3,5,7,11}; 	// vector顺序容器

for(const auto& i : v) { // 常引用方式访问元素,避免拷贝代价
cout << i << ","; // 常引用不会改变元素的值
}
for(auto& i : v) { // 引用方式访问元素
i++; // 可以改变元素的值
cout << i << ",";
}

常量/变量

const/volatile/mutable

  • const
1
2
3
4
5
6
// 需要加上volatile修饰,运行时才能看到效果
const volatile int MAX_LEN = 1024;
auto ptr = (int*)(&MAX_LEN);
*ptr = 2048;
cout << MAX
LEN << endl; // 输出2048

我从来不用“* const”的形式,也建议你最好不要用,而且这种形式在实际开发时也确实没有多大作用(除非你想“炫技”)。

image-20221217135753980
  • mutable

用来修饰成员变量,允许 const 成员函数修改,mutable 变量的变化不影响对象的常量性,但要小心不要误用损坏对象。你今后再写类的时候,就要认真想一想,哪些操作改变了内部状态,哪些操作没改变内部状态,对于只读的函数,就要加上 const 修饰。写错了也不用怕,编译器会帮你检查出来。

  • volatile

它表示变量可能会被“不被察觉”地修改,禁止编译器优化,影响性能,应当少用。

尽可能多用 const,让代码更安全

image-20221217140232505

智能指针

常用的有两种智能指针,分别是 unique_ptr 和 shared_ptr

  • unique_ptr

unique_ptr 是最简单、最容易使用的一个智能指针,在声明的时候必须用模板参数指定类型:

1
2
3
4
5
6
unique_ptr<int> ptr1(new int(10));	// int智能指针
assert(*ptr1 = 10); // 可以使用*取内容
assert(ptr1 != nullptr); // 可以判断是否为空指针
unique_ptr<string> ptr2(new string("hello")); // string智能指针
assert(*ptr2 == "hello"); // 可以使用*取内容
assert(ptr2->size() == 5); // 可以使用->调用成员函数

unique_ptr 虽然名字叫指针,用起来也很像,但它实际上并不是指针,而是一个对象。所以,不要企图对它调用 delete,它会自动管理初始化时的指针,在离开作用域时析构释放内存。

它也没有定义加减运算,不能随意移动指针地址,完全避免了指针越界等危险。

未初始化的 unique_ptr 表示空指针,这样就相当于直接操作了空指针,运行时就会产生致命的错误。
为了避免这种低级错误,可以调用工厂函数 make_unique(),强制创建智能指针的时候必须初始化。同时还可以利用自动类型推导 auto,少写一些代码:

1
2
3
4
auto ptr3 = make_unique<int>(42);	// 工厂函数创建智能指针
assert(ptr3 && *ptr3 == 42);
auto ptr4 = make_unique<string>("god of war"); // 工厂函数创建智能指针
assert(!ptr4->empty());

尽量不要对 unique_ptr 执行赋值操作

  • shared_ptr
1
2
3
4
5
6
7
8
shared_ptr<int> ptr1(new int(10)); 	// int智能指针
assert(*ptr1 = 10);// 可以使用*取内容
shared_ptr<string> ptr2(new string("hello")); // string智能指针
assert(*ptr2 == "hello");// 可以使用*取内容
auto ptr3 = make_shared<int>(42); // 工厂函数创建智能指针
assert(ptr3 && *ptr3 == 42);// 可以判断是否为空指针
auto ptr4 = make_shared<string>("zelda"); // 工厂函数创建智能指针
assert(!ptr4->empty()); // 可以使用->调用成员函数

它的所有权是可以被安全共享的,也就是说支持拷贝赋值。

Exception

  • 异常的处理流程是完全独立的,throw 抛出异常后就可以不用管了,错误处理代码都集中在专门的 catch 块里。这样就彻底分离了业务逻辑与错误逻辑,看起来更清楚。
  • 异常是绝对不能被忽略的,必须被处理。如果你有意或者无意不写 catch 捕获异常,那么它会一直向上传播出去,直至找到一个能够处理的 catch 块。如果实在没有,那就会导致程序立即停止运行,明白地提示你发生了错误,而不会“坚持带病工作”。
  • 异常可以用在错误码无法使用的场合,这也算是 C++ 的“私人原因”。因为它比 C 语言多了构造 / 析构函数、操作符重载等新特性,有的函数根本就没有返回值,或者返回值无法表示错误,而全局的 errno 实在是“太不优雅”了,与 C++ 的理念不符,所以也必须使用异常来报告错误。

函数式编程 lambda

1
2
3
4
5
auto func = [](int x)	// 定义一个lambda表达式
{
cout << x*x << endl; // lambda表达式的具体内容
};
func(3);// 调用lambda表达式

lambda 表达式除了可以像普通函数那样被调用,还有一个普通函数所不具备的特殊本领,就是可以“捕获”外部变量,在内部的代码里直接操作。

1
2
3
4
5
6
int n = 10;	// 一个外部变量
auto func = [=](int x) // lambda表达式,用“=”值捕获
{
cout << x*n << endl; // 直接操作外部变量
};
func(3); // 调用lambda表达式

C++ 没有为 lambda 表达式引入新的关键字,并没有“lambda”这样的词汇,而是用了一个特殊的形式“[]”,术语叫“lambda 引出符”(lambda introducer)。在 lambda 引出符后面,就可以像普通函数那样,用圆括号声明入口参数,用花括号定义函数体。

标准库

字符串

  1. 字面量后缀

    1
    2
    using namespace std::literals::string_literals; 	//必须打开名字空间
    auto str = "std string"s; // 后缀s,表示是标准字符串,直接类型推导
  2. 原始字符串

    1
    auto str = R"(nier:automata)"; 	// 原始字符串:nier:automata
  3. 字符串转换函数

    1
    2
    3
    assert(stoi("42") == 42);	// 字符串转整数
    assert(stol("253") == 253L); // 字符串转长整数
    assert(stod("2.0") == 2.0); // 字符串转浮点数
  4. 字符串视图类

正则表达式

C++ 正则表达式主要有两个类。

  • regex:表示一个正则表达式,是 basic_regex 的特化形式;
  • smatch:表示正则表达式的匹配结果,是 match_results 的特化形式。

C++ 正则匹配有三个算法,注意它们都是“只读”的,不会变动原字符串。

  • regex_match():完全匹配一个字符串;
  • regex_search():在字符串里查找一个正则匹配;
  • regex_replace():正则查找再做替换。

容器

容器,就是能够“容纳”“存放”元素的一些数据结构。

容器里存储的是元素的拷贝、副本,而不是引用。

  • 顺序容器
    • 即数据结构里的线性表,一共有 5 种:array、vector、deque、list、forward_list
    • 连续存储的数组:array、vector 和 deque。
      • array 和 vector 直接对应 C 的内置数组,内存布局与 C 完全兼容,所以是开销最低、速度最快的容器。区别在于,array 是静态数组,而 vector 是动态数组。
      • deque 也是动态数组,它可以在两端高效地插入删除元素,这也是它的名字double-end queue 的来历,而 vector 则只能用 push_back 在末端追加元素。
    • 指针结构的链表:list 和 forward_list
      • list 是双向链表,而 forward_list是单向链表。
  • 有序容器
    • C++ 的有序容器使用的是树结构,通常是红黑树——有着最好查找性能的二叉树。
    • 标准库里一共有四种有序容器:set/multiset 和 map/multimap。set 是集合,map 是关联数组(在其他语言里也叫“字典”)。
    • 有 multi 前缀的容器表示可以容纳重复的 key,内部结构与无前缀的相同,所以也可以认为只有两种有序容器。
    • 集合关系就用 set,关联数组就用 map。
  • 无序容器
    • unordered_set/unordered_multiset、unordered_map/unordered_multimap。
    • 内部数据结构不是红黑树,而是散列表(也叫哈希表,hash table)
    • 如果只想要单纯的集合、字典,没有排序需求,就应该用无序容器,没有比较排序的成本,它的速度就会非常快。

算法

迭代器

1
2
3
4
5
6
7
8
9
10
array<int, 5> arr = {0,1,2,3,4}; 	// array静态数组容器
auto b = begin(arr); // 全局函数获取迭代器,首端
auto e = end(arr); // 全局函数获取迭代器,末端
assert(distance(b, e) == 5); // 迭代器的距离
auto p = next(b); // 获取“下一个”位置
assert(distance(b, p) == 1); // 迭代器的距离
assert(distance(p, b) == -1); // 反向计算迭代器的距离
advance(p, 2); // 迭代器前进两个位置,指向元素'3'
assert(*p == 3);
assert(p == prev(e, 2)); // 是末端迭代器的前两个位置

for_each()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vector<int> v = {3,5,1,7,10}; 	// vector容器
for(const auto& x : v) { // range for循环
cout << x << ",";
}
auto print = [](const auto& x) // 定义一个lambda表达式
{
cout << x << ",";
};
for_each(cbegin(v), cend(v), print); // for_each算法

for_each( // for_each算法,内部定义lambda表达式
cbegin(v), cend(v), // 获取常量迭代器
[](const auto& x) // 匿名lambda表达式
{
cout << x << ",";
}
);

排序

  • 常见问题对应的算法:
    • 要求排序后仍然保持元素的相对顺序,应该用 stable_sort,它是稳定的;
    • 选出前几名(TopN),应该用 partial_sort;
    • 选出前几名,但不要求再排出名次(BestN),应该用 nth_element;
    • 中位数(Median)、百分位数(Percentile),还是用 nth_element;
    • 按照某种规则把元素划分成两组,用 partition;
    • 第一名和最后一名,用 minmax_element。

image-20221218173338732

查找

并发

“读而不写”就不会有数据竞争。

  • 多线程是并发最常用的实现方式,好处是任务并行、避免阻塞,坏处是开发难度高,有数据竞争、死锁等很多“坑”;
  • call_once() 实现了仅调用一次的功能,避免多线程初始化时的冲突;
  • thread_local 实现了线程局部存储,让每个线程都独立访问数据,互不干扰;
  • atomic 实现了原子化变量,可以用作线程安全的计数器,也可以实现无锁数据结构;
  • async() 启动一个异步任务,相当于开了一个线程,但内部通常会有优化,比直接使用线程更好。

技能进阶

序列化/反序列化

JSON

JSON 是一种轻量级的数据交换格式,采用纯文本表示,所以是“human readable”,阅
读和修改都很方便。

JSON for Modern C++ 可能不是最小最快的 JSON 解析工具,但功能足够完善,而且使
用方便,仅需要包含一个头文件“json.hpp”,没有外部依赖,也不需要额外的安装、编
译、链接工作,适合快速上手开发。

1
2
git clone git@github.com:nlohmann/json.git # git clone
wget https://github.com/nlohmann/json/releases/download/v3.7.3/json.hpp

MessagePack

它也是一种轻量级的数据交换格式,与 JSON 的不同之处在于它不是纯文本,而是二进制。由于二进制这个特点,MessagePack 也得到了广泛的应用,著名的有 Redis、Pinterest。

1
2
git clone git@github.com:msgpack/msgpack-c.git
g++ msgpack.cpp -std=c++14 -I../common/include -o a.out

ProtoBuffer

通常简称为 PB,由 Google 出品。PB 也是一种二进制的数据格式,但毕竟是工业级产品,所以没有 JSON 和 MessagePack那么“轻”,相关的东西比较多,要安装一个预处理器和开发库,编译时还要链接动态库(-lprotobuf):

1
2
3
4
apt-get install protobuf-compiler
apt-get install libprotobuf-dev

g++ protobuf.cpp -std=c++14 -lprotobuf -o a.out

PB 的另一个特点是数据有“模式”(schema),必须要先写一个 IDL(Interface Description Language)文件,定义好数据结构,只有预先定义了的数据结构,才能被序列化和反序列化。

网络通信

libcurl:高可移植、功能丰富的通信库

cpr:更现代、更易用的通信库

cpr 是对 libcurl 的一个 C++11 封装,使用了很多现代 C++ 的高级特性,对外的接口模仿了 Python 的 requests 库,非常简单易用。

1
2
3
git clone git@github.com:whoshuu/cpr.git
cmake . -DUSE_SYSTEM_CURL=ON -DBUILD_CPR
make && make install

ZMQ:高效、快速、多功能的通信库

作为消息队列,ZMQ 的另一大特点是零配置零维护零成本,不需要搭建额外的代理服务器,只要安装了开发库就能够直接使用,相当于把消息队列功能直接嵌入到你的应用程序里:

1
apt-get install libzmq3-dev

ZMQ 是一个高级的网络通信库,支持多种通信模式,可以把消息队列功能直接嵌入应用程序,搭建出高效、灵活、免管理的分布式系统。

脚本语言

Python

pybind11 完全基于现代 C++ 开发(C++11 以上),所以没有兼容旧系统的负担。它使用了大量的现代 C++ 特性,不仅代码干净整齐,运行效率也更高。

pybind11 支持 Python2.7、Python3 和 PyPy,这里我用的是 Python3:

1
2
3
apt-get install python3-dev
apt-get install python3-pip
pip3 install pybind11

Lua

第二个脚本语言是小巧高效的 Lua,号称是“最快的脚本语言”。

1
2
git clone git@github.com:openresty/luajit2.git
make && make install

总结

设计模式

常用有 5 个原则,也就是常说的“SOLID”。

  1. SRP,单一职责(Single ResponsibilityPrinciple);
  2. OCP,开闭(Open Closed Principle);
  3. LSP,里氏替换(Liskov Substitution Principle);
  4. ISP,接口隔离(Interface-Segregation Principle);
  5. DIP,依赖反转,有的时候也叫依赖倒置(Dependency Inversion Principle)。