Table of Contents:

github: https://github.com/chronolaw/cpp_study

开篇词 | 把C++从“神坛”上拉下来,这次咱这么学

 有两种写程序的方式:一种是把代码写得非常复杂,以至于“看不出明显的错误”;另一种是把代码写得非常简单,以至于“明显看不出错误”。
 “把正确的代码改快速”,要比“把快速的代码改正确”,容易得太多。

课前准备 | 搭建实验环境

Linux 也是版本众多,最常见的是 RedHat 系的 CentOS 和 Debian 系的 Ubuntu。
这两个被很多企业广泛采用,但 CentOS 通常比较“稳定”,更新较慢,CentOS 7 的Gcc版本是 4.8,对 C++ 标准的支持很不完善,极大地限制了C++ 能力的发挥。
而Ubuntu 16.04的 GCC版本是 5.4,完美支持 C++11/14。
当然,你要是愿意安装更新的 18.04、20.04 也没有问题,它们里面的 GCC 版本更高,支持 C++17,只不过有点“功能过剩”。
Ubuntu的版本基本上是和年份一致的,如20版的是20年的。

01 | 重新认识C++:生命周期和编程范式

C++ 程序的生命周期

一个 C++ 程序从“诞生”到“消亡”,要经历这么几个阶
段:编码(Coding)、预处理(Pre-processing)、编译(Compiling)和运行(Running)。
“预处理”的目的是文字替换,用到的就是我们熟悉的各种预处理指令,比如 #include#define#if 等,实现“预处理编程”。
不过,你要注意的是,它们都以符号“#”开头,虽然是 C++ 程序的一部分,但严格来说不属于 C++ 语言的范畴,因为它走的是预处理器。

在编译的过程中,编译器还会根据 C++ 语言规则检查程序的语法、语义是否正确,发现错误就会产生“编译失败”。这就是最基本的 C++“静态检查”。
在处理源码时,由于编译器是依据 C++ 语法检查各种类型、函数的定义,所以,在这个阶段,我们就能够以编译器为目标进行编程,有意识地控制编译器的行为。这里有个新名词,叫“模板元编程”。不过,“模板元编程”比较复杂,不太好理解,属于比较高级的用法.

还有,别忘了软件工程里的“蝴蝶效应”“混沌理论”,大概意思是:一个 Bug 在越早的阶段发现并解决,它的价值就越高;一个 Bug 在越晚的阶段发现并解决,它的成本就越高。所以,依据这个生命周期模型,我们应该在“编码”“预处理”“编译”这前面三个阶段多下功夫,消灭 Bug,优化代码,尽量不要让 Bug 在“运行”阶段才暴露出来,也就是所谓的“把问题扼杀在萌芽期”。

C++ 语言的编程范式(Paradigm)

“编程范式”是一种“方法论”,就是指导你编写代码的一些思路、规则、习惯、定式和常用语
C++(11/14 以后)支持“面向过程”“面向对象”“泛型”“模板元”“函数式”这五种主要的编程范式

说得具体一点,就是要认识、理解这些范式的优势和劣势,在程序里适当混用,取长补短才是“王道”。
说到这儿,你肯定很关心,该选择哪种编程范式呢?
拿我自己来说,我的出发点是“尽量让周围的人都能看懂代码”,所以常用的范式是“过程+ 对象 + 泛型”,再加上少量的“函数式”,慎用“模板元”。

如果是开发直接面对用户的普通应用(Application),那么你可以再研究一下“泛型”和“函数式”,就基本可以解决 90% 的开发问题了;如果是开发面向程序员的库(Library),那么你就有必要深入了解“泛型”和“模板元”,优化库的接口和运行效率。

02 | 编码阶段能做什么:秀出好的code style

 空格与空行
不同逻辑段的代码用空格隔开,加上了适当的空格和空行后,代码就显得错落有致,舒缓得当
 起个好名字
一些已经在程序员之间形成了普遍共识的变量名,比如用于循环的i/j/k、用于计数的 count、表示指针的 p/ptr、表示缓冲区的 buf/buffer、表示变化量的delta、表示总和的 sum。
全局变量前缀:“g_”前缀(global)  成员变量加“m_”前缀(member)
变量 / 函数的名字长度与它的作用域成正比

* 代码风格检查工具
cpplint是Google开发的一个C++代码风格检查工具,用python编写的,如果是遵循google code style的,可以使用cpplint作为代码规范的一个检查工具。
空格、折行这类的可以用IDE带的格式化代码的工具。

03 | 预处理阶段能做什么:宏定义和条件编译

一般来说,预处理指令不应该受C++ 代码缩进层次的影响,不管是在函数、类里,还是在 if、for 等语句里,永远是顶格写。

#if __linux__ // 预处理检查宏是否存在
#    define HAS_LINUX 1 // 宏定义,有缩进
#endif // 预处理条件语句结束

包含文件(#include

#include,它的作用是“包含文件”。注意,不是“包含头文件”,而是可以包含任意的文件。
除了最常用的包含头文件,你还可以利用“#include”的特点玩些“小花样”,编写一些代码片段,存进“*.inc”文件里,然后有选择地加载,用得好的话,可以实现“源码级别的抽象”
比如说,有一个用于数值计算的大数组,里面有成百上千个数,放在文件里占了很多地方,
特别“碍眼”:

static uint32_t calc_table[] = { // 非常大的一个数组,有几十行
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba,
0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3,
0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91,
...
};

可以用来代替

static uint32_t calc_table[] = {
# include "calc_values.inc" // 非常大的一个数组,细节被隐藏
};

条件编译

#ifdef __cplusplus // 定义了这个宏就是在用C++编译
    extern "C" { // 函数按照C的方式去处理
#endif
    void a_c_function(int a);
#ifdef __cplusplus // 检查是否是C++编译
    } // extern "C" 结束
#endif
#if __cplusplus >= 201402 // 检查C++标准的版本号
    cout << "c++14 or later" << endl; // 201402就是C++14
#elif __cplusplus >= 201103 // 检查C++标准的版本号
    cout << "c++11 or before" << endl; // 201103是C++11
#else // __cplusplus < 201103 // 199711是C++98
# error "c++ is too old" // 太低则预处理报错
#endif // __cplusplus >= 201402 // 预处理语句结束

04 | 编译阶段能做什么:属性和静态断言

和预处理阶段一样,在这里你也可以“面向编译器编程”,用一些指令或者关键字让编译器按照你的想法去做一些事情。

属性(attribute)

虽然编译器非常聪明,但因为 C++ 语言实在是太复杂了,偶尔它也会“自作聪明”或者“冒傻气”。如果有这么一个东西,让程序员来手动指示编译器这里该如何做、那里该如何做,就有可能会生成更高效的代码。
在 C++11 之前,标准里没有规定这样的东西,但 GCC、VC 等编译器发现这样做确实很有用,于是就实现出了自己“编译指令”,在 GCC 里是__ attribute __,在 VC 里是__declspec。不过因为它们不是标准,所以名字显得有点“怪异”。
到了 C++11,标准委员会终于认识到了“编译指令”的好处,于是就把“民间”用法升级为“官方版本”,起了个正式的名字叫“属性”。你可以把它理解为给变量、函数、类等“贴”上一个编译阶段的“标签”,方便编译器识别处理。
C++11:noreturn 、carries_dependency
C++14:deprecated
目前的 C++17 和 C++20 又增加了五六个新属性,比如 fallthrough、likely,但我觉得,标准委员会的态度还是太“保守”了,在实际的开发中,这些真的是不够用。

// 编译的时候用到这个函数的地方会报警告
[[deprecated("deadline:2020-12-31")]] // C++14 or later
int old_func();

好在“属性”也支持非标准扩展,允许以类似名字空间的方式使用编译器自己的一些“非官方”属性,比如,GCC 的属性都在“gnu::”里。下面我就列出几个比较有用的:
 deprecated:与 C++14 相同,但可以用在 C++11 里。
 unused:用于变量、类型、函数等,表示虽然暂时不用,但最好保留着,因为将来可能会用。
 constructor:函数会在 main() 函数之前执行,效果有点像是全局对象的构造函数。
 destructor:函数会在 main() 函数结束之后执行,有点像是全局对象的析构函数。
 always_inline:要求编译器强制内联函数,作用比 inline 关键字更强。
 hot:标记“热点”函数,要求编译器更积极地优化。

在没有这个属性的时候,如果有暂时用不到的变量,我们只能用“(void) var;”的方式假装
用一下,来“骗”过编译器,属于“不得已而为之”的做法。
那么现在,我们就可以用“unused”属性来清楚地告诉编译器:这个变量我暂时不用,请
不要过度紧张,不要发出警告来烦我:

[[gnu::unused]] // 声明下面的变量暂不使用,不是错误
int nouse;

静态断言(static_assert)

属性”像是给编译器的一个“提示”“告知”,无法进行计算,还算不上是编程,而“静态断言”,就有点编译阶段写程序的味道了。
assert 虽然是一个宏,但在预处理阶段不生效,而是在运行阶段才起作用,所以又叫“动态断言”。
static_assert 可以在编译阶段定义各种前置条件,充分利用 C++ 静态类型语言的优势,让编译器执行各种检查,避免把隐患带到运行阶段。在编译阶段计算常数和类型,如果断言失败就会导致编译错误。它也是迈向模板元编程的第一步。
static_assert 运行在编译阶段,只能看到编译时的常数类型,看不到运行时的变量、指针、内存数据等,所以不要简单地把 assert 的习惯搬过来用。

注意点:在用“静态断言”的时候,你就要在脑子里时刻“绷紧一根弦”,把自己代入编译器的角色,像编译器那样去思考,看看断言的表达式是不是能够在编译阶段算出结果。
比如:
比如说,这节课刚开始时的斐波拉契数列计算函数,可以用静态断言来保证模板参数必须大于等于零:

template<int N>
struct fib
{
    static_assert(N >= 0, "N >= 0");
    static const int value =
        fib<N - 1>::value + fib<N - 2>::value;
};

不过这句话说起来容易做起来难,计算数字还好说,在泛型编程的时候,怎么检查模板类型呢?比如说,断言是整数而不是浮点数、断言是指针而不是引用、断言类型可拷贝可移动……
这些检查条件表面上看好像是“不言自明”的,但要把它们用 C++ 语言给精确地表述出来,可就没那么简单了。所以,想要更好地发挥静态断言的威力,还要配合标准库里的type_traits,它提供了对应这些概念的各种编译期“函数”。

// 假设T是一个模板参数,即template<typename T>
static_assert(
    is_integral<T>::value, "int");
static_assert(
    is_pointer<T>::value, "ptr");
static_assert(
    is_default_constructible<T>::value, "constructible");

05 | 面向对象编程:怎样才能写出一个“好”的类?

面向对象编程的基本出发点是对现实世界的模拟,把问题中的实体抽象出来,封装为程序里的类和对象,
“面向对象编程”的关键点是“抽象”和“封装”,而“继承”“多态”并不是核心,只能算是附加品。
所以,我建议你在设计类的时候尽量少用继承和虚函数。
特别的,如果完全没有继承关系,就可以让对象不必承受“父辈的重担”(父类成员、虚表等额外开销),轻装前行,更小更快。没有隐含的重用代码也会降低耦合度,让类更独立,更容易理解。

如果非要用继承不可,那么我觉得一定要控制继承的层次,如果继承深度超过三层,就说明有点“过度设计”了。
在设计类接口的时候,我们也要让类尽量简单、“短小精悍”,只负责单一的功能。
如果很多功能混在了一起,出现了“万能类”“意大利面条类”(有时候也叫 God
Class),就要应用设计模式、重构等知识,把大类拆分成多个各负其责的小类。

我还看到过很多人有一种不好的习惯,就是喜欢在类内部定义一些嵌套类,美其名曰“高内聚”。但恰恰相反,这些内部类反而与上级类形成了强耦合关系,也是另一种形式的“万能类”。
其实,这本来是名字空间该做的事情,用类来实现就有点“越权”了。正确的做法应该是,定义一个新的名字空间,把内部类都“提”到外面,降低原来类的耦合度和复杂度。
编码准则:
1. 用final显式地禁用继承
2. 只使用 public 继承
3. = default  明确使用默认的    = delete 明确禁用某个函数
4. 用explicit防止意外的隐式类型转换

常用技巧

 委托构造(delegating constructor)。
在 C++11 里,你就可以使用“委托构造”的新特性,一个构造函数直接调用另一个构造函数,把构造工作“委托”出去,既简单又高效
 成员变量初始化(In-class member initializer)。
在 C++11 里,你可以在类里声明变量的同时给它赋值,实现初始化,这样不但简单清晰,也消除了隐患(列表初始化可能会被漏掉)
* 类型别名(Type Alias)
在写类的时候,我们经常会用到很多外部类型,比如标准库里的 string、vector,还有其他的第三方库和自定义类型。这些名字通常都很长(特别是带上名字空间、模板参数),书写起来很不方便,这个时候我们就可以在类里面用 using 给它们起别名,不仅简化了名字,同时还能增强可读性。

using uint_t = unsigned int; // using别名
typedef unsigned int uint_t; // 等价的typedef

06 | auto/decltype:为什么要有自动类型推导?

auto

因为 C++ 是一种静态强类型的语言,任何变量都要有一个确定的类型,否则就不能用。
在“自动类型推导”出现之前,我们写代码时只能“手动推导”,也就是说,在声明变量的时候,必须要明确地给出类型。
对于比如 int、double这类的变量类型还好说,但在泛型编程的时候,类型特别的长,这就迫使我们去和编译器“斗智斗勇”,只有写对了类型,编译器才会“放行”(编译通过)
除了简化代码,auto 还避免了对类型的硬编码,也就是说变量类型不是“写死”的,而是能够“自动”适应表达式的类型。比如,你把 map 改为 unordered_map,那么后面
的代码都不用动。这个效果和类型别名有点像,但你不需要写出 typedef 或者 using,全由 auto“代劳”
另外,“自动类型推导”,是编译阶段的特殊指令,指示编译器去计算类型。
注意点:
 auto 的“自动推导”能力只能用在“初始化”的场合。
 类里不能使用auto推导类型
 auto 总是推导出值类型,绝不会是“引用”;
 auto 可以附加上 const、volatile、*、& 这样的类型修饰符,得到新的类型。

范围for循环一般都用auto,为了保证效率,最好使用“const auto&”或者“auto&
在 C++14 里,auto 还新增了一个应用场合,就是能够推导函数返回值,这样在写复杂函数的时候,比如返回一个 pair、容器或者迭代器,就会很省事。

auto get_a_set() // auto作为函数返回值的占位符
{
    std::set<int> s = {1,2,3};
    return s;
}

decltype

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

int x = 0; // 整型变量
decltype(x) x1;

decltype有个缺点,就是写起来略麻烦,特别在用于初始化的时候,表达式要重复两次(左边的类型计算,右边的初始化),把简化代码的优势完全给抵消了。
所以,C++14 就又增加了一个“decltype(auto)”的形式,既可以精确推导类型,又能像auto 一样方便使用。

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

再来看 decltype 怎么用最合适。
它是 auto 的高级形式,更侧重于编译阶段的类型计算,所以常用在泛型编程里,获取各种类型,配合 typedef 或者 using 会更加方便。当你感觉“这里我需要一个特殊类型”的时候,选它就对了。
比如说,定义函数指针在 C++ 里一直是个比较头疼的问题,因为传统的写法实在是太怪异了。但现在就简单了,你只要手里有一个函数,就可以用 decltype 很容易得到指针类型。

// UNIX信号函数的原型,看着就让人晕,你能手写出函数指针吗?
void (*signal(int signo, void (*func)(int)))(int)
// 使用decltype可以轻松得到函数指针类型
using sig_func_ptr_t = decltype(&signal) ;

在定义类的时候,因为 auto 被禁用了,所以这也是 decltype 可以“显身手”的地方。它可以搭配别名任意定义类型,再应用到成员变量、成员函数上,变通地实现 auto 的功能。

class DemoClass final
{
    public:
        using set_type = std::set<int>; // 集合类型别名
    private:
        set_type m_set; // 使用别名定义成员变量

        // 使用decltype计算表达式的类型,定义别名
        using iter_type = decltype(m_set.begin());

        iter_type m_pos; // 类型别名定义成员变量 
};

07 | const/volatile/mutable:常量/变量究竟是怎么回事?

const 常量虽然不是“真正的常数”,但在大多数情况下,它都可以被认为是常数,在运行期间不会改变。编译器看到 const 定义,就会采取一些优化手段,比如把所有 const 常量出现的地方都替换成原始值。

关键字 mutable
效果是:允许 const 成员函数改写 mutable 成员变量

在我看来,,mutable 像是 C++ 给 const 对象打的一个“补丁”,让它部分可变。因为对象与普通的 int、double 不同,内部会有很多成员变量来表示状态,但因为“封装”特性,外界只能看到一部分状态,判断对象是否 const 应该由这些外部可观测的状态特征来决定。
比如说,对象内部用到了一个 mutex 来保证线程安全,或者有一个缓冲区来暂存数据,再或者有一个原子变量做引用计数……这些属于内部的私有实现细节,外面看不到,变与不变不会改变外界看到的常量性。这时,如果 const 成员函数不允许修改它们,就有点说不过去了。
所以,对于这些有特殊作用的成员变量,你可以给它加上 mutable 修饰,解除 const 的限制,让任何成员函数都可以操作它。

class DemoClass final
{
    private:
    mutable mutex_type m_mutex; // mutable成员变量
    public:
    void save_data() const // const成员函数
    {
        // do someting with m_mutex
    }
};

08 | smart_ptr:智能指针到底“智能”在哪里?

C++ 里也是有垃圾回收的,不过不是 Java、Go 那种严格意义上的垃圾回收,而是广义上的垃圾回收,这就是构造 / 析构函数和 RAII 惯用法(Resource Acquisition Is Initialization)。

我们可以应用代理模式,把裸指针包装起来,在构造函数里初始化,在析构函数里释放。这样当对象失效销毁时,C++ 就会自动调用析构函数,完成内存释放、资源回收等清理工作。
智能指针就是代替你来干这些“脏活累活”的。它完全实践了 RAII,包装了裸指针,而且因为重载了 * 和 -> 操作符,用起来和原始指针一模一样

unique_ptr

unique_ptr<string> ptr2(new string("hello")); // string智能指针
assert(*ptr2 == "hello"); // 可以使用*取内容
assert(ptr2->size() == 5); // 可以使用->调用成员函数

unique_ptr 虽然名字叫指针,用起来也很像,但它实际上并不是指针,而是一个对象。所以,不要企图对它调用 delete,它会自动管理初始化时的指针,在离开作用域时析构释放内存。
另外,它也没有定义加减运算,不能随意移动指针地址,这就完全避免了指针越界等危险操作

ptr1++; // 导致编译错误
ptr2 += 2; // 导致编译错误

易犯错误:不初始化,而是声明后直接使用

unique_ptr<int> ptr3; // 未初始化智能指针
*ptr3 = 42 ; // 错误!操作了空指针

为了避免这种低级错误,你可以调用工厂函数 make_unique(),强制创建智能指针的时候必须初始化。同时还可以利用自动类型推导的 auto,少写一些代码:

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

make_unique() 要求 C++14,你可以自己实现一个:

template<class T, class... Args> // 可变参数模板
std::unique_ptr<T> // 返回智能指针
my_make_unique(Args&&... args) // 可变参数模板的入口参数
{
    return std::unique_ptr<T>( // 构造智能指针
        new T(std::forward<Args>(args)...)); // 完美转发
}

unique_ptr不允许共享,任何时候只能有一个“人”持有它。故禁止了拷贝和赋值,所以,在向另一个 unique_ptr 赋值的时候,要特别留意,必std::move() 函数显式地声明所有权转移。
不能拷贝unique_ptr的规则有一个例外:我们可以拷贝或赋值一个将要被销毁的unique_ptr。上面的my_make_unique就是return了一个unique_ptr

shared_ptr

shared_ptr它的所有权是可以被安全共享的,也就是说支持拷贝赋值,允许被多个“人”同时持有,就像原始指针一样

auto ptr1 = make_shared<int>(42);  // 工厂函数创建智能指针
assert(ptr1 && ptr1.unique() );         // 此时智能指针有效且唯一
auto ptr2 = ptr1;                               // 直接拷贝赋值,不需要使用move()
assert(ptr1 && ptr2);                        // 此时两个智能指针均有效
assert(ptr1 == ptr2);                        // shared_ptr可以直接比较
// 两个智能指针均不唯一,且引用计数为2
assert(!ptr1.unique() && ptr1.use_count() == 2);
assert(!ptr2.unique() && ptr2.use_count() == 2);

shared_ptr 支持安全共享的秘密在于内部使用了“引用计数”,当引用计数减少到0才真正的释放。
因为 shared_ptr 具有完整的值语义(即可以拷贝赋值),所以,它可以在任何场合替代原始指针,而不用再担心资源回收的问题,比如用于容器存储指针、用于函数安全返回动态创建的对象,等等
注意点:
0. shared_ptr 有少量的管理成本,也会引发一些难以排查的错误,所以不要过度使用。 
1. 因为我们把指针交给了 shared_ptr 去自动管理,但在运行阶段,引用计数的变动是很复杂的,很难知道它真正释放资源的时机。你要特别小心对象的析构函数,不要有非常复杂、严重阻塞的操作。一旦 shared_ptr 在某个不确定时间点析构释放资源,就会阻塞整个进程或者线程,“整个世界都会静止不动”
2. shared_ptr 的引用计数也导致了一个新的问题,就是“循环引用”,这在把 shared_ptr作为类成员的时候最容易出现,典型的例子就是链表节点。

weak_ptr

weak_ptr 顾名思义,功能很“弱”。它专门为打破循环引用而设计,只观察指针,不会增加和减少引用计数,但在需要的时候,可以调用成员函数 lock(),升级到shared_ptr。
C++11标准虽然将 weak_ptr 定位为智能指针的一种,但该类型指针通常不单独使用,因为连、 ->都没重载(没有实际用处),只能和 shared_ptr 类型指针搭配使用。甚至于,我们可以将 weak_ptr 类型指针视为 shared_ptr 指针的一种辅助工具,借助 weak_ptr 类型指针, 我们可以获取 shared_ptr 指针的一些状态信息,比如有多少指向相同的 shared_ptr 指针、shared_ptr 指针指向的堆内存是否已经被释放等等。

weak_ptr<T> 模板类中没有重载 * 和 -> 运算符,这也就意味着,weak_ptr 类型指针只能访问所指的堆内存,而无法修改它。

成员方法:

operator=()   //重载 = 赋值运算符,是的 weak_ptr 指针可以直接被 weak_ptr 或者 shared_ptr 类型指针赋值。
swap(x)      // 其中 x 表示一个同类型的 weak_ptr 类型指针,该函数可以互换 2 个同类型 weak_ptr 指针的内容。
reset()    // 将当前 weak_ptr 指针置为空指针。
use_count()  //查看指向和当前 weak_ptr 指针相同的 shared_ptr 指针的数量。
expired()  // 判断当前 weak_ptr 指针为否过期(指针为空,或者指向的堆内存已经被释放)。
lock()     // 如果当前 weak_ptr 已经过期,则该函数会返回一个空的 shared_ptr 指针;反之,该函数返回一个和当前 weak_ptr 指向相同的 shared_ptr 指针。

循环引用
在把 shared_ptr作为类成员的时候最容易出现,典型的例子就是链表节点

class Node final
{
public:
    using this_type = Node;
    using shared_type = std::shared_ptr<this_type>;
public:
    shared_type next; // 使用智能指针来指向下一个节点
};

auto n1 = make_shared<Node>(); // 工厂函数创建智能指针
auto n2 = make_shared<Node>(); // 工厂函数创建智能指针

assert(n1.use_count() == 1); // 引用计数为1
assert(n2.use_count() == 1);
n1->next = n2; // 两个节点互指,形成了循环引用
n2->next = n1;
assert(n1.use_count() == 2); // 引用计数为2
assert(n2.use_count() == 2); // 无法减到0,无法销毁,导致内存泄漏
// 出作用域析构时n1减1,n2减1,但因彼此都相互引用,所有无法减到0

改正方式

class Node final
{
public:
    using this_type = Node;
    // 注意这里,别名改用weak_ptr
    using shared_type = std::weak_ptr<this_type>;
public:
    shared_type next; // 因为用了别名,所以代码不需要改动
};
auto n1 = make_shared<Node>(); // 工厂函数创建智能指针
auto n2 = make_shared<Node>(); // 工厂函数创建智能指针
n1->next = n2; // 两个节点互指,形成了循环引用
n2->next = n1;
assert(n1.use_count() == 1); // 因为使用了weak_ptr,引用计数为1
assert(n2.use_count() == 1); // 打破循环引用,不会导致内存泄漏
if (!n1->next.expired()) { // 检查指针是否有效
    auto ptr = n1->next.lock(); // lock()获取shared_ptr
    assert(ptr == n2);
}

09 | exception:怎样才能用好异常?

异常只是 C++ 为了处理错误而提出的一种解决方案,当然也不会是唯一的一种。
在 C++ 之前,处理异常的基本手段是错误码。函数执行后,需要检查返回值或者全局的 errno,看是否正常,如果出错了,就执行另外一段代码处理错误.

错误码缺点:
1. 这种做法很直观,但也有一个问题,那就是正常的业务逻辑代码与错误处理代码混在了一起,看起来很乱,你的思维要在两个本来不相关的流程里来回跳转。而且,有的时候,错误处理的逻辑要比正常业务逻辑复杂、麻烦得多,看了半天,你可能都会忘了它当初到底要干什么了,容易引起新的错误。
2. 错误码还有另一个更大的问题:它是可以被忽略的。也就是说,你完全可以不处理错误,“假装”程序运行正常,继续跑后面的代码,这就可能导致严重的安全隐患。

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

C++ 里对异常的定义非常宽松,任何类型都可以用 throw 抛出,也就是
说,你可以直接把错误码(int)、或者错误消息(char*string)抛出,catch 也能接住,然后处理。
但我建议你最好不要“图省事”,因为 C++ 已经为处理异常设计了一个配套的异常类型体系,定义在标准库的 <stdexcept> 头文件里。

异常也与上一讲的智能指针密切相关,如果你决定使用异常,为了确保出现异常的时候资源会正确释放,就必须禁用裸指针,改成智能指针,用 RAII 来管理内存。
由于异常出现和处理的时机都不好确定,当前的 C++ 也没有在语言层面提出更好的机制,所以,你还要在编码阶段写好文档和注释,说清楚哪些函数、什么情况下会抛出什么样的异常,应如何处理,加上一些“软约束”。
小结:
1. 异常是针对错误码的缺陷而设计的,它不能被忽略,而且可以“穿透”调用栈,逐层传播到其他地方去处理;
2. 使用 try-catch 机制处理异常,能够分离正常流程与错误处理流程,让代码更清晰; 
3. throw 可以抛出任何类型作为异常,但最好使用标准库里定义的 exception 类; 
4. 完全用或不用异常处理错误都不可取,而是应该合理分析,适度使用,降低异常的成本;
5. 关键字 noexcept 标记函数不抛出异常,可以让编译器做更好的优化。

10 | lambda:函数式编程带来了什么?

在 C/C++ 里,所有的函数都是全局的,没有生存周期的概念(static、名字空间的作用很弱,只是简单限制了应用范围,避免名字冲突)
而且函数也都是平级的,不能在函数里再定义函数,也就是不允许定义嵌套函数、函数套函数
所以,在面向过程编程范式里,函数变量虽然是程序里最关键的两个组成部分,但却因为没有值、没有作用域而不能一致地处理。函数只能是函数,变量只能是变量。
因为 lambda 表达式是一个变量,所以,我们就可以“按需分配”,随时随地在调用点“就地”定义函数,限制它的作用域和生命周期,实现函数的局部化。
而且,因为 lambda 表达式和变量一样是“一等公民”,用起来也就更灵活自由,能对它做各种运算,生成新的函数。这就像是数学里的复合函数那样,把多个简单功能的小
lambda 表达式组合,变成一个复杂的大 lambda 表达式。

C++ 里的 lambda 表达式除了可以像普通函数那样被调用,还有一个普通函数所不具备的特殊本领,就是可以“捕获”外部变量,在内部的代码里直接操作。
看到这里,如果你用过 JavaScript,那么一定会有种眼熟的感觉。没错,lambda 表达式就是在其他语言中大名鼎鼎的“闭包”(closure),这让它真正超越了函数和函数对象。不过在c++中闭包的作用不大,因为有比较强的面向对象编程。

在 lambda 表达式赋值的时需用auto关键字,在 C++ 里,每个 lambda 表达式都会有一个独特的类型,而这个类型只有编译器才知道,我们是无法直接写出来的,所以必须用 auto。
可以使用 auto 自动推导类型存储 lambda 表达式,但 C++ 鼓励尽量就地匿名使用,缩小作用域;

泛型的 lambda
在 C++14 里,lambda 表达式又多了一项新本领,可以实现“泛型化”,相当于简化了的模板函数,具体语法还是利用了“多才多艺”的 auto

auto f = [](const auto& x) // 参数使用auto声明,泛型化
{
    return x + x;
};
cout << f(3) << endl; // 参数类型是int
cout << f(0.618) << endl; // 参数类型是double
string str = "matrix";
cout << f(str) << endl; // 参数类型是string

注意点:
捕获引用时必须要注意外部变量的生命周期,防止变量失效

11 | 一枝独秀的字符串:C++也能处理文本?

只有语言、标准库“双剑合璧”,才能算是真正的 C++。
看一下官方发布的标准文档吧(C++14),全文有 1300 多页,而语言特性只有 400 出头,不足三分之一,其余的篇幅全是在讲标准库,可见它的份量有多重。

字符串是“文本”,里面的字符之间是强关系,顺序不能随便调换,否则就失去了意义,通常应该视为一个整体来处理。而容器是“集合”,里面的元素之间没有任何关系,可以随意增删改,对容器更多地是操作里面的单个元素。

但有的时候,我们也确实需要存储字符的容器,比如字节序列数据缓冲区,这该怎么办呢?
这个时候,我建议你最好改用 vector<char>,它的含义十分“纯粹”,只存储字符,没有 string 那些不必要的成本,用起来也就更灵活一些。

1. 字面量后缀

C++11 为方便使用字符串,新增了一个字面量的后缀“s”,明确地表示它是 string 字符串类型,而不是 C 字符串,这就可以利用 auto 来自动类型推导,而且在其他用到字符串的地方,也可以省去声明临时字符串变量的麻烦,效率也会更高

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

2. 原始字符串

如果使用原始字符串的话,就没有这样的烦恼了,它不会对字符串里的内容做任何转义,完全保持了“原始风貌”,即使里面有再多的特殊字符都不怕

auto str = R"(\r\n\t\")"; // 原始字符串:\r\n\t\"

3. 字符串转换函数

stoi()、stol()、stoll() 等把字符串转换成整数;
stof()、stod() 等把字符串转换成浮点数;
to_string() 把整数、浮点数转换成字符串

4. 字符串视图类

string_view是c++17标准库提供的一个类,它提供一个字符串的视图,即可以通过这个类以各种方法“观测”字符串,但不允许修改字符串。
它内部只保存一个指针和长度,无论是拷贝,还是修改,都非常廉价构造和求substr都是O(1)的复杂度
std的string的构造不可避免的会设计内存分配和拷贝。而string_view只是一个字符串的视图,构造函数可以避免拷贝,做到O(1)复杂度。
因为string_view并不拷贝内存,所以要特别注意它所指向的字符串的生命周期。string_view指向的字符串,不能再string_view死亡之前被回收。

5. 正则表达式

C++ 正则表达式主要有两个类。
regex:表示一个正则表达式,是 basic_regex 的特化形式;
smatch:表示正则表达式的匹配结果,是 match_results 的特化形式。
C++ 正则匹配有三个算法,注意它们都是“只读”的,不会变动原字符串。
regex_match():完全匹配一个字符串;
regex_search():在字符串里查找一个正则匹配;
regex_replace():正则查找再做替换。会返回修改后的新字符串

在写正则的时候,记得最好要用“原始字符串”,不然转义符绝对会把你折腾得够呛。

在使用 regex 的时候,还要注意正则表达式的成本。因为正则串只有在运行时才会处理,
检查语法、编译成正则对象的代价很高,所以尽量不要反复创建正则对象,能重用就重用。
在使用循环的时候更要特别注意,一定要把正则提到循环体外。

12 | 三分天下的容器:恰当选择,事半功倍

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

容器的通用特性

你必须要知道所有容器都具有的一个基本特性:它保存元素采用的是“值”(value)语义,也就是说,容器里存储的是元素的拷贝、副本,而不是引用。
从这个基本特性可以得出一个推论,容器操作元素的很大一块成本就是值的拷贝

解决办法:
1. 一个解决办法是,尽量为元素实现移动构造和移动赋值函数,在加入容器的时候使用
std::move() 来“转移”,减少元素复制的成本:

Point p; // 一个拷贝成本很高的对象
v.push_back(p); // 存储对象,拷贝构造,成本很高
v.push_back(std::move(p)); // 定义转移构造后就可以转移存储,降低成本

2. 你也可以使用 C++11 为容器新增加的 emplace 操作函数,它可以“就地”构造元素,免去了构造后再拷贝、转移的成本,不但高效,而且用起来也很方便

3. 当然,你可能还会想到在容器里存放元素的指针,来间接保存元素,但我不建议采用这种方案。
虽然指针的开销很低,但因为它是“间接”持有,就不能利用容器自动销毁元素的特性了,你必须要自己手动管理元素的生命周期,麻烦而且非常容易出错,有内存泄漏的隐患。如果真的有这种需求,可以考虑使用智能指针 unique_ptr/shared_ptr,让它们帮你自动管理元素。
一般情况下,shared_ptr 是一个更好的选择,它的共享语义与容器的值语义基本一致。使用 unique_ptr 就要当心,它不能被拷贝,只能被转移,用起来就比较“微妙”。

容器分类
常见的一种分类是依据元素的访问方式,分成顺序容器、有序容器和无序容器三大类别

顺序容器

连续存储的数组:
 array
array 是静态数组,大小在初始化的时候就固定
 vector
 deque
它可以在两端高效地插入删除元素
指针结构的链表:
 list
list 是双向链表,可以向前或者向后遍历,但查找效率比较低
* forward_list
forward_list,顾名思义,是单向链表,只能向前遍历,查找效率就更低了。

链表结构比起数组结构还有一个缺点,就是存储成本略高,因为必须要为每个元素附加一个或者两个的指针,指向链表的前后节点。
当 vector 的容量到达上限的时候(capacity),它会再分配一块两倍大小的新内存,然后把旧元素拷贝或者移动过去。这个操作的成本是非常大的,所以,你在使用 vector 的时候最好能够“预估”容量,使用 reserve 提前分配足够的空间,减少动态扩容的拷贝代价。
vector 的做法太“激进”,而 deque、list 的的扩容策略就“保守”多了,只会按照固定的“步长”(例如 N 个字节、一个节点)去增加容量。但在短时间内插入大量数据的时候就会频繁分配内存,效果反而不如 vector 一次分配来得好。

有序容器

顺序容器的特点是,元素的次序是由它插入的次序而决定的,访问元素也就按照最初插入的顺序。而有序容器则不同,它的元素在插入容器后就被按照某种规则自动排序,所以是“有序”的。
C++ 的有序容器使用的是树结构,通常是红黑树——有着最好查找性能的二叉树。
 set/multiset
 map/multimap

在定义容器的时候必须要指定 key 的比较函数,解决这个问题有两种办法: 一个是重载<,另一个是自定义模板参数。

无序容器

内部是散列表
unordered_set/unordered_multiset、unordered_map/unordered_multimap。

无序容器对 key的要求:
一是可以计算 hash值,二是能够执行相等比较操作。
第一个是因为散列表的要求,只有计算 hash 值才能放入散列表,
第二个则是因为 hash 值可能会冲突,所以当 hash 值相同时,就要比较真正的 key 值。

如果只想要单纯的集合、字典,没有排序需求,就应该用无序容器,没有比较排序的成本,它的速度就会非常快。 

我再教你一个使用这些容器的小技巧,就是多利用类型别名,而不要“写死”容器定义。因为容器的大部分接口是相同的,所以只要变动别名定义,就能够随意改换不同的容器,对于开发、测试都非常方便。

13 | 五花八门的算法:不要再手写for循环了

虽然算法是 STL(标准库前身)的三大要件之一(容器、算法、迭代器),也是 C++ 标准库里一个非常重要的部分,但它却没有像容器那样被大众广泛接受。

在 C++ 里,算法的地位非常高,甚至有一个专门的“算法库”。早期,它是泛型编程的示范和应用,而在 C++ 引入 lambda 表达式后,它又成了函数式编程的具体实践,所以,学习掌握算法能够很好地训练你的编程思维,帮你开辟出面向对象之外的新天地

C++ 里的算法,指的是工作在容器上的一些泛型函数,会对容器内的元素实施的各种操作。
不过要是“说白了”,算法其实并不神秘,因为所有的算法本质上都是 for 或者 while,通过循环遍历来逐个处理容器里的元素。

vector<int> v = {1,3,1,7,5}; 
auto n1 = std::count( 
    begin(v), end(v), 1 
);

int n2 = 0;
for(auto x : v) { 
    if (x == 1) {
        n2++;
    }
}
//配合lambda效果更好
auto n = std::count_if( 
    begin(v), end(v),
    [](auto x) { 
        return x > 2;
    }
);

这是追求更高层次上的抽象和封装,也是函数式编程的基本理念。

认识迭代器

虽然刚才我说算法操作容器,但实际上它看到的并不是容器,而是指向起始位置和结束位置的迭代器,算法只能通过迭代器去“间接”访问容器以及元素,算法的能力是由迭代器决定的。

这种间接的方式有什么好处呢?
这就是泛型编程的理念,与面向对象正好相反,分离了数据和操作。算法可以不关心容器的内部结构,以一致的方式去操作元素,适用范围更广,用起来也更灵活。

可以把迭代器简单地理解为另一种形式的智能指针,只是它强调的是对数据的访问,而不是生命周期管理

最有用的算法

手写循环的替代品 for_each

它能够促使我们更多地以“函数式编程”来思考,使用 lambda 来封装逻辑,得到更干净、更安全的代码。

排序

很多时候,这样做的成本比较高,比如 TopN、中位数、最大最小值等,我们只关心一部分数据,如果你用 sort(),就相当于“杀鸡用牛刀”,是一种浪费。

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

如果是 list 容器,应该调用成员函数 sort(),它对链表结构做了特别的优化。
有序容器set/map 本身就已经排好序了,直接对迭代器做运算就可以得到结果。
而对无序容器,则不要调用排序算法,原因你应该不难想到(散列表结构的特殊性质,导致迭代器不满足要求、元素无法交换位置)。

查找算法

 binary_search    它只返回一个 bool 值,告知元素是否存在
 lower_bound      返回第一个“大于或等于”值的迭代器,判断是否找到:一个是迭代器是否有效,另一个是迭代器的值是不是要找的值。
* upper_bound,  返回的是第一个“大于”值的迭代器
begin < x <= lower_bound < upper_bound < end

 find   find_if  查找算法,找到第一个出现的位置
 search

对于有序容器 set/map,就不需要调用这三个算法了,它们有等价的成员函数 find/lower_bound/upper_bound,效果是一样的。

因为标准算法的名字实在是太普通、太常见了,所以建议你一定要显式写出“std::”名字空间限定,这样看起来更加醒目,也避免了无意的名字冲突。

14 | 十面埋伏的并发:多线程真的很难吗?

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

多线程开发实践

仅调用一次

程序免不了要初始化数据,这在多线程里却是一个不大不小的麻烦。因为线程并发,如果没有某种同步手段来控制,会导致初始化函数多次运行。
C++ 提供了“仅调用一次”的功能,可以很轻松地解决这个问题

    static once_flag flag;

    auto f = []()
    {
        cout << "tid=" <<
            this_thread::get_id() << endl;


        std::call_once(flag,
            [](){
                cout << "only once" << endl;
            }
        );
    };

    thread t1(f);
    thread t2(f);

    t1.join();
    t2.join();

线程局部存储(thread local storage)

这个功能在 C++ 里由关键字 thread_local 实现,它是一个和 static、extern 同级的变量存储说明,有 thread_local 标记的变量在每个线程里都会有一个独立的副本,是“线程独占”的,所以就不会有竞争读写的问题。   
linux下的有__thread关键字

    thread_local int n = 0;

    auto f = [&](int x)
    {
        n += x;


        cout << n;    //在程序执行后,我们可以看到,两个线程分别输出了 10 和 20,互不干扰。
        cout << ", tid=" <<
            this_thread::get_id() << endl;
    };


    thread t1(f, 10);
    thread t2(f, 20);


    t1.join();
    t2.join();

和 call_once() 一样,thread_local 也很容易使用。但它的应用场合不是那么显而易见的,这要求你对线程的共享数据有清楚的认识,区分出独占的那部分,消除多线程对变量的并发访问

原子变量

那么,对于那些非独占、必须共享的数据,该怎么办呢?
对于小数据,应该采用“原子化”这个更好的方案。
所谓原子(atomic),在多线程领域里的意思就是不可分的。操作要么完成,要么未完成,不能被任何外部操作打断,总是有一个确定的、完整的状态。所以也就不会存在竞争读写的问题,不需要使用互斥量来同步,成本也就更低。
目前,C++ 只能让一些最基本的类型原子化,
和普通变量的区别是,原子变量禁用了拷贝构造函数,所以在初始化的时候不能用“=”的赋值形式,只能用圆括号或者花括号。
最基本的用法是把原子变量当作线程安全的全局计数器或者标志位,这也算是“初心”吧。
但它还有一个更重要的应用领域,就是实现高效的无锁数据结构(lock-free)。
但我强烈不建议你自己尝试去写无锁数据结构,因为无锁编程的难度比使用互斥量更高,可能会掉到各种难以察觉的“坑”(例如 ABA)里,最好还是用现成的库。
遗憾的是,标准库在这方面帮不了你,虽然网上可以找到不少开源的无锁数据结构,但经过实际检验的不多,我个人觉得你可以考虑 boost.lock_free。

线程

    static atomic_flag flag {false};
    static atomic_int n;


    auto f = [&]()
    {
        auto value = flag.test_and_set();

        if (value) {
            cout << "flag has been set." << endl;
        } else {
            cout << "set flag by " <<
                this_thread::get_id() << endl;
        }

        n += 100;

        this_thread::sleep_for(n.load() * 10ms);
        cout << n << endl;
    };

    thread t1(f);
    thread t2(f);

    t1.join();
    t2.join();

但还是基于那个原则,我建议你不要直接使用 thread 这个“原始”的线程概念(因为抽象层次太低了),最好把它隐藏到底层,因为看不到的线程才是好线程

具体的做法是调用函数 async(),它的含义是异步运行一个任务,隐含的动作是启动一个线程去执行,但不绝对保证立即启动(也可以在第一个参数传递 launch::async,要求立即启动线程)。
大多数 thread 能做的事情也可以用 async() 来实现,但不会看到明显的线程:

auto task = [](auto x) // 在线程里运行的lambda表达式
{
    this_thread::sleep_for( x * 1ms); // 线程睡眠
    cout << "sleep for " << x << endl;
    return x;
};
auto f = std::async(task, 10); // 启动一个异步任务
f.wait(); // 等待任务完成
assert(f.valid()); // 确实已经完成了任务
cout << f.get() << endl; // 获取任务的执行结果

其实,这还是函数式编程的思路,在更高的抽象级别上去看待问题,异步并发多个任务,让底层去自动管理线程,要比我们自己手动控制更好(比如内部使用线程池或者其他机制)。
async() 会返回一个 future 变量,可以认为是代表了执行结果的“期货”,如果任务有返回值,就可以用成员函数 get() 获取。
不过要特别注意,get() 只能调一次,再次获取结果会发生错误,抛出异常   std::future_error
另外,这里还有一个很隐蔽的“坑”,如果你不显式获取 async() 的返回值(即 future 对象),它就会同步阻塞直至任务完成(由于临时对象的析构函数),于是“async”就变成了“sync”。
所以,即使我们不关心返回值,也总要用 auto 来配合 async(),避免同步阻塞

C++20 正式加入了协程(关键字 co_wait/co_yield/co_return)。它是用户态的线程,没有系统级线程那么多的麻烦事,使
用它就可以写出开销更低、性能更高的并发程序。

15 | 序列化:简单通用的数据交换格式有哪些?

序列化,就是把内存里“活的对象”转换成静止的字节序列,便于存储和网络传输;而反序
列化则是反向操作,从静止的字节序列重新构建出内存里可用的对象
 JSON
JSON 格式注重的是方便易用,在性能上没有太大的优势,所以一般选择
JSON 来交换数据,通常都不会太在意性能(不然肯定会改换其他格式了),还是自己用着顺手最重要。
这里介绍的json类库为:JSON for Modern C++ 
 MessagePack
它也是一种轻量级的数据交换格式,与 JSON 的不同之处在于它不是纯文本,而是二进制。所以 MessagePack 就比 JSON 更小巧,处理起来更快,不过也就没有 JSON 那么直
观、易读、好修改了。
MessagePack 支持几乎所有的编程语言,你可以在官网上找到它的 C++ 实现。
我常用的是官方库 msgpack-c,

 JSON 是纯文本,容易阅读,方便编辑,适用性最广; 
 MessagePack 是二进制,小巧高效,在开源界接受程度比较高; 
* ProtoBuffer 是工业级的数据格式,注重安全和性能,多用在大公司的商业产品里。

问:为什么要有序列化和反序列化,直接 memcpy 内存数据行不行呢?
直接memcpy,同一种语言不同机器,或者不同语言可能存在兼容问题(变量内存存储布局、编码可能不同),而Json是一种标准,由Json库处理编码问题(比如大小端),且不同语言间统一

16 | 网络通信:我不想写原生Socket

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

libcurl 使用纯 C 语言开发,兼容性、可移植性非常好,基于 C 接口可以很容易写出各种语言的封装,所以 Python、PHP 等语言都有 libcurl 相关的库。
libcurl 的接口可以粗略地分成两大类:easy 系列和 multi 系列。其中,easy 系列是同步调用,比较简单;multi 系列是异步的多线程调用,比较复杂。通常情况下,我们用 easy系列就足够了。
使用 libcurl 发送HTTP请求的基本步骤有 4 个:
1. 使用 curl_easy_init() 创建一个句柄,类型是 CURL*。但我们完全没有必要关心句柄的
类型,直接用 auto 推导就行。
2. 使用 curl_easy_setopt() 设置请求的各种参数,比如请求方法、URL、header/body 数
据、超时、回调函数等。这是最关键的操作。
3. 使用 curl_easy_perform() 发送数据,返回的数据会由回调函数处理。 
4. 使用 curl_easy_cleanup() 清理句柄相关的资源,结束会话

因为 libcurl 是 C 语言实现的,所以回调函数必须是函数指针。不过,C++11 允许你写lambda 表达式,这利用了一个特别规定:无捕获的 lambda 表达式可以显式转换成一个函数指针

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

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

17 | 脚本语言:搭建高性能的混合系统

Python

Python 本身就有 C 接口,可以用 C 语言编写扩展模块,把一些低效耗时的功能改用 C 实现,有的时候,会把整体性能提升几倍甚至几十倍。
使用纯 C 语言写扩展模块非常麻烦,那么,能不能利用 C++ 的那些高级特性来简化这部分的工作呢?
pybind11 借鉴了“前辈”Boost.Python,能够在 C++ 和 Python 之间自由转换,任意翻译两者的语言要素,比如把 C++ 的 vector 转换为 Python 的列表,把 Python 的元组转换为 C++ 的 tuple,既可以在 C++ 里调用 Python 脚本,也可以在 Python 里调用 C++的函数、类。

lua

标准的 Lua使用解释器运行,速度虽然很快,但和 C/C++ 比起来还是有
差距的。所以,你还可以选择另一个兼容的项目:LuaJIT 
它使用了 JIT(Just in time)技术,能够把 Lua 代码即时编译成机器码,速度几乎可以媲美原生 C/C++ 代码。
不过,LuaJIT 也有一个问题,它是一个个人项目,更新比较慢,最新的 2.1.0-beta3 已经是三年前的事情了。所以,我推荐你改用它的一个非官方分支:OpenResty-LuaJIT 。它由 OpenResty 负责维护,非常活跃,修复了很多小错误。

使用 LuaBridge 可以导出 C++ 的函数、类,但直接用 LuaJIT 的 ffi 库更好;
使用 LuaBridge 也可以很容易地执行 Lua 脚本、调用 Lua 函数,让 Lua 跑在 C++里。

18 | 性能分析:找出程序的瓶颈

性能分析的关键就是测量,用数据说话。没有实际数据的支撑,优化根本无从谈起,即使做了,也只能是漫无目的的“不成熟优化”,即使成功了,也只是“瞎猫碰上死耗子”而已。
性能分析的范围非常广,可以从 CPU 利用率、内存占用率、网络吞吐量、系统延迟等许多维度来评估。

pstack

pstack 可以打印出进程的调用栈信息,有点像是给正在运行的进程拍了个快照,你能看到某个时刻的进程里调用的函数和关系,对进程的运行有个初步的印象。
不过,pstack 显示的只是进程的一个“静态截面”,信息量还是有点少,而 strace 可以显示出进程的正在运行的系统调用,实时查看进程与系统内核交换了哪些信息:

perf

perf 可以说是 pstack 和 strace 的“高级版”,它按照固定的频率去“采样”,相当于连续执行多次的 pstack,然后再统计函数的调用次数,算出百分比。只要采样的频率足够大,把这些“瞬时截面”组合在一起,就可以得到进程运行时的可信数据,比较全面地描述出 CPU 使用情况。
我常用的 perf 命令是“perf top -K -p xxx”,按 CPU 使用率排序,只看用户空间的调用,这样很容易就能找出最耗费 CPU 的函数。

源码级工具

top、pstack、strace 和 perf 属于非侵入式的分析工具,不需要修改源码,就可以在软件的外部观察、收集数据。它们虽然方便易用,但毕竟是“隔岸观火”,还是不能非常细致地分析软件,效果不是太理想。
所以,我们还需要有侵入式的分析工具,在源码里埋点,直接写特别的性能分析代码。这样针对性更强,能够有目的地对系统的某个模块做精细化分析,拿到更准确、更详细的数据。
其实,这种做法你并不陌生,比如计时器、计数器、关键节点打印日志,等等,只是通常并没有上升到性能分析的高度,手法比较“原始”。
在这里,我要推荐一个专业的源码级性能分析工具:Google Performance Tools,一般简称为 gperftools。它是一个 C++ 工具集,里面包含了几个专门的性能分析工具(还有一个高效的内存分配器 tcmalloc),分析效果直观、友好、易理解,被广泛地应用于很多系统,经过了充分的实际验证。
gperftools 的性能分析工具有 CPUProfiler 和 HeapProfiler 两种,用来分析 CPU 和内存。不过,如果你听从我的建议,总是使用智能指针、标准容器,不使用 new/delete,就完全可以不用关心 HeapProfiler。
CPUProfiler 的原理和 perf 差不多,也是按频率采样,默认是每秒 100 次(100Hz),也就是每 10 毫秒采样一次程序的函数调用情况。
它的用法也比较简单,只需要在源码里添加三个函数:
 ProfilerStart(),开始性能分析,把数据存入指定的文件里;
 ProfilerRegisterThread(),允许对线程做性能分析;
* ProfilerStop(),停止性能分析。

所以,你只要把想做性能分析的代码“夹”在这三个函数之间就行,运行起来后,gperftools 就会自动产生分析数据。

19 | 设计模式(上):C++与设计模式有啥关系?

设计模式系统地描述了一些软件开发中的常见问题、应用场景和对应的解决方案,给出了专家级别的设计思路和指导原则。
按照设计模式去创建面向对象的系统,就像是由专家来“手把手”教你,不能说绝对是“最优解”,但至少是“次优解”。
而且,在应用设计模式的过程中,你还可以从中亲身体会这些经过实际证明的成功经验,潜移默化地影响你自己思考问题的方式,从长远来看,学习和应用设计模式能够提高你的面向对象设计水平。

经典的《设计模式》一书里面介绍了 23 个模式,并依据设计目的把它们分成了三大类:1.创建型模式、2.结构型模式和3.行为模式。
这三类模式分别对应了开发面向对象系统的三个关键问题:如何创建对象、如何组合对象,以及如何处理对象之间的动态通信和职责分配。解决了这三大问题,软件系统的“架子”也就基本上搭出来了。

学习、理解设计原则,才能用好多范式的 C++

最常用有 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)

20 | 设计模式(下):C++是怎么应用设计模式的?

轻松话题(一) | 4本值得一读再读的经典好书

 设计模式:可复用面向对象软件的基础
 C++标准库(第2版)   译者: 侯捷
 C++ 语言的设计与演化
 C++ Primer

个人认为,C++ 最大的优点是与 C 兼容,最大的缺点也是与 C 兼容。
一方面,它是 C 之外唯一成熟可靠的系统级编程语言(目前 Rust 还没有达到可以和C++“叫板”的程度),大部分用 C 的地方都能用 C++ 替代,这就让它拥有了广阔的应用天地。而面向对象、泛型等编程范式,又比 C 能够更好地组织代码,提高抽象层次,管理复杂的软件项目。
但另一方面,为了保持与 C 兼容,C++ 的改革和发展也被“束缚了手脚”,做出任何新设计时,都要考虑是否会对 C 代码造成潜在的破坏。这就使得很多 C++ 新特性要么是“一拖再拖”,要么是“半成品”,要么是“古里古怪”,最后导致 C++ 变得有些不伦不类,丢掉了编程语言本应该的简洁、纯粹。