mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-12-28 19:16:02 +08:00
mod
This commit is contained in:
267
极客时间专栏/罗剑锋的C++实战笔记/语言特性/06 | auto|decltype:为什么要有自动类型推导?.md
Normal file
267
极客时间专栏/罗剑锋的C++实战笔记/语言特性/06 | auto|decltype:为什么要有自动类型推导?.md
Normal file
@@ -0,0 +1,267 @@
|
||||
<audio id="audio" title="06 | auto/decltype:为什么要有自动类型推导?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d0/33/d0dec00dd6b1003f2238d36f60b20e33.mp3"></audio>
|
||||
|
||||
你好,我是Chrono。
|
||||
|
||||
前两周我们从宏观的层面上重新认识了C++,从今天开始,我们将进入一个新的“语言特性”单元,“下沉”到微观的层面去观察C++,一起去见一些老朋友、新面孔,比如const、exception、lambda。
|
||||
|
||||
这次要说的,就是C++11里引入的一个很重要的语言特性:自动类型推导。
|
||||
|
||||
## 自动类型推导
|
||||
|
||||
如果你有过一些C++的编程经验,了解过C++11,那就一定听说过“**自动类型推导**”(auto type deduction)。
|
||||
|
||||
它其实是一个非常“老”的特性,C++之父Bjarne Stroustrup(B·S )早在C++诞生之初就设计并实现了它,但因为与早期C语言的语义有冲突,所以被“雪藏”了近三十年。直到C99消除了兼容性问题,C++11才让它再度登场亮相。
|
||||
|
||||
那为什么要重新引入这个“老特性”呢?为什么非要有“自动类型推导”呢?
|
||||
|
||||
我觉得,你可以先从字面上去理解,把这个词分解成三个部分:“自动”“类型”和“推导”。
|
||||
|
||||
- “自动”就是让计算机去做,而不是人去做,相对的是“手动”。
|
||||
- “类型”指的是操作目标,出来的是编译阶段的类型,而不是数值。
|
||||
- “推导”就是演算、运算,把隐含的值给算出来。
|
||||
|
||||
好,我们来看一看“自动类型推导”之外的其他几种排列组合,通过对比的方式来帮你理解它。
|
||||
|
||||
像计算“a = 1 + 1”,你可以在写代码的时候直接填上2,这就是“手动数值推导”。你也可以“偷懒”,只写上表达式,让电脑在运行时自己算,这就是“自动数值推导”。
|
||||
|
||||
“数值推导”对于人和计算机来说都不算什么难事,所以手动和自动的区别不大,只有快慢的差异。但“类型推导”就不同了。
|
||||
|
||||
因为C++是一种静态强类型的语言,任何变量都要有一个确定的类型,否则就不能用。在“自动类型推导”出现之前,我们写代码时只能“手动推导”,也就是说,在声明变量的时候,必须要明确地给出类型。
|
||||
|
||||
这在变量类型简单的时候还好说,比如int、double,但在泛型编程的时候,麻烦就来了。因为泛型编程里会有很多模板参数,有的类型还有内部子类型,一下子就把C++原本简洁的类型体系给搞复杂了,这就迫使我们去和编译器“斗智斗勇”,只有写对了类型,编译器才会“放行”(编译通过)。
|
||||
|
||||
```
|
||||
int i = 0; // 整数变量,类型很容易知道
|
||||
double x = 1.0; // 浮点数变量,类型很容易知道
|
||||
|
||||
std::string str = "hello"; // 字符串变量,有了名字空间,麻烦了一点
|
||||
|
||||
std::map<int, std::string> m = // 关联数组,名字空间加模板参数,很麻烦
|
||||
{{1,"a"}, {2,"b"}}; // 使用初始化列表的形式
|
||||
|
||||
std::map<int, std::string>::const_iterator // 内部子类型,超级麻烦
|
||||
iter = m.begin();
|
||||
|
||||
??? = bind1st(std::less<int>(), 2); // 根本写不出来
|
||||
|
||||
```
|
||||
|
||||
虽然你可以用typedef或者using来简化类型名,部分减轻打字的负担,但关键的“手动推导”问题还是没有得到解决,还是要去翻看类型定义,找到正确的声明。这时,C++的静态强类型的优势反而成为了劣势,阻碍了程序员的工作,降低了开发效率。
|
||||
|
||||
其实编译器是知道(而且也必须知道)这些类型的,但它却没有办法直接告诉你,这就很尴尬了。一边是急切地想知道答案,而另一边却只给判个对错,至于怎么错了、什么是正确答案,“打死了也不说”。
|
||||
|
||||
但有了“自动类型推导”,问题就迎刃而解了。这就像是在编译器紧闭的大门上开了道小口子,你跟它说一声,它就递过来张小纸条,具体是什么不重要,重要的是里面存了我们想要的类型。
|
||||
|
||||
这个“小口子”就是关键字**auto**,在代码里的作用像是个“占位符”(placeholder)。写上它,你就可以让编译器去自动“填上”正确的类型,既省力又省心。
|
||||
|
||||
```
|
||||
auto i = 0; // 自动推导为int类型
|
||||
auto x = 1.0; // 自动推导为double类型
|
||||
|
||||
auto str = "hello"; // 自动推导为const char [6]类型
|
||||
|
||||
std::map<int, std::string> m = {{1,"a"}, {2,"b"}}; // 自动推导不出来
|
||||
|
||||
auto iter = m.begin(); // 自动推导为map内部的迭代器类型
|
||||
|
||||
auto f = bind1st(std::less<int>(), 2); // 自动推导出类型,具体是啥不知道
|
||||
|
||||
```
|
||||
|
||||
不过需要注意的是,因为C++太复杂,“自动类型推导”有时候可能失效,给不出你想要的结果。比如,在上面的这段代码里,就把字符串的类型推导成了“const char [6]”而不是“std::string”。而有的时候,编译器也理解不了代码的意思,推导不出恰当的类型,还得你自己“亲力亲为”。
|
||||
|
||||
在这个示例里,你还可以直观感觉到auto让代码干净整齐了很多,不用去写那些复杂的模板参数了。但如果你把“自动类型推导”理解为仅仅是简化代码、少打几个字,那就实在是浪费了C++标准委员会的一番苦心。
|
||||
|
||||
**除了简化代码,auto还避免了对类型的“硬编码”**,也就是说变量类型不是“写死”的,而是能够“自动”适应表达式的类型。比如,你把map改为unordered_map,那么后面的代码都不用动。这个效果和类型别名([第5讲](https://time.geekbang.org/column/article/235301))有点像,但你不需要写出typedef或者using,全由auto“代劳”。
|
||||
|
||||
另外,你还应该认识到,“自动类型推导”实际上和“attribute”一样([第4讲](https://time.geekbang.org/column/article/235295)),是编译阶段的特殊指令,指示编译器去计算类型。所以,它在泛型编程和模板元编程里还有更多的用处,后面我会陆续讲到。
|
||||
|
||||
## 认识auto
|
||||
|
||||
刚才说了,auto有时候会不如你设想的那样工作,因此在使用的时候,有一些需要特别注意的地方,下面我就给你捋一捋。
|
||||
|
||||
首先,你要知道,auto的“自动推导”能力只能用在“**初始化**”的场合。
|
||||
|
||||
具体来说,就是**赋值初始化**或者**花括号初始化**(初始化列表、Initializer list),变量右边必须要有一个表达式(简单、复杂都可以)。这样你才能在左边放上auto,编译器才能找到表达式,帮你自动计算类型。
|
||||
|
||||
如果不是初始化的形式,只是“纯”变量声明,那就无法使用auto。因为这个时候没有表达式可以让auto去推导。
|
||||
|
||||
```
|
||||
auto x = 0L; // 自动推导为long
|
||||
auto y = &x; // 自动推导为long*
|
||||
auto z {&x}; // 自动推导为long*
|
||||
|
||||
auto err; // 错误,没有赋值表达式,不知道是什么类型
|
||||
|
||||
```
|
||||
|
||||
这里还有一个特殊情况,在类成员变量初始化的时候([第5讲](https://time.geekbang.org/column/article/235301)),目前的C++标准不允许使用auto推导类型(但我个人觉得其实没有必要,也许以后会放开吧)。所以,在类里你还是要老老实实地去“手动推导类型”。
|
||||
|
||||
```
|
||||
class X final
|
||||
{
|
||||
auto a = 10; // 错误,类里不能使用auto推导类型
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
知道了应用场合,你还需要了解auto的推导规则,保证它能够按照你的意思去工作。虽然标准里规定得很复杂、很细致,但我总结出了两条简单的规则,基本上够用了:
|
||||
|
||||
- **auto总是推导出“值类型”,绝不会是“引用”;**
|
||||
- **auto可以附加上const、volatile、*、&这样的类型修饰符,得到新的类型。**
|
||||
|
||||
下面我举几个例子,你一看就能明白:
|
||||
|
||||
```
|
||||
auto x = 10L; // auto推导为long,x是long
|
||||
|
||||
auto& x1 = x; // auto推导为long,x1是long&
|
||||
auto* x2 = &x; // auto推导为long,x2是long*
|
||||
const auto& x3 = x; // auto推导为long,x3是const long&
|
||||
auto x4 = &x3; // auto推导为const long*,x4是const long*
|
||||
|
||||
```
|
||||
|
||||
## 认识decltype
|
||||
|
||||
前面我都在说auto,其实,C++的“自动类型推导”还有另外一个关键字:**decltype**。
|
||||
|
||||
刚才你也看到了,auto只能用于“初始化”,而这种“**向编译器索取类型**”的能力非常有价值,把它限制在这么小的场合,实在是有点“屈才”了。
|
||||
|
||||
“自动类型推导”要求必须从表达式推导,那在没有表达式的时候,该怎么办呢?
|
||||
|
||||
其实解决思路也很简单,就是“自己动手,丰衣足食”,自己带上表达式,这样就走到哪里都不怕了。
|
||||
|
||||
decltype的形式很像函数,后面的圆括号里就是可用于计算类型的表达式(和sizeof有点类似),其他方面就和auto一样了,也能加上const、*、&来修饰。
|
||||
|
||||
但因为它已经自带表达式,所以不需要变量后面再有表达式,也就是说可以直接声明变量。
|
||||
|
||||
```
|
||||
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和auto比较一下,简单来看,好像就是把表达式改到了左边而已,但实际上,在推导规则上,它们有一点细微且重要的区别:
|
||||
|
||||
**decltype不仅能够推导出值类型,还能够推导出引用类型,也就是表达式的“原始类型”**。
|
||||
|
||||
在示例代码中,我们可以看到,除了加上*和&修饰,decltype还可以直接从一个引用类型的变量推导出引用类型,而auto就会把引用去掉,推导出值类型。
|
||||
|
||||
所以,你完全可以把decltype看成是一个真正的类型名,用在变量声明、函数参数/返回值、模板参数等任何类型能出现的地方,只不过这个类型是在编译阶段通过表达式“计算”得到的。
|
||||
|
||||
如果不信的话,你可以用using类型别名来试一试。
|
||||
|
||||
```
|
||||
using int_ptr = decltype(&x); // int *
|
||||
using int_ref = decltype(x)&; // int &
|
||||
|
||||
```
|
||||
|
||||
既然decltype类型推导更精确,那是不是可以替代auto了呢?
|
||||
|
||||
实际上,它也有个缺点,就是写起来略麻烦,特别在用于初始化的时候,表达式要重复两次(左边的类型计算,右边的初始化),把简化代码的优势完全给抵消了。
|
||||
|
||||
所以,C++14就又增加了一个“**decltype(auto)**”的形式,既可以精确推导类型,又能像auto一样方便使用。
|
||||
|
||||
```
|
||||
int x = 0; // 整型变量
|
||||
|
||||
decltype(auto) x1 = (x); // 推导为int&,因为(expr)是引用类型
|
||||
decltype(auto) x2 = &x; // 推导为int*
|
||||
decltype(auto) x3 = x1; // 推导为int&
|
||||
|
||||
```
|
||||
|
||||
## 使用auto/decltype
|
||||
|
||||
现在,我已经讲完了“自动类型推导”的两个关键字:auto和decltype,那么,该怎么用好它们呢?
|
||||
|
||||
我觉得,因为auto写法简单,推导规则也比较好理解,所以,**在变量声明时应该尽量多用auto**。前面已经举了不少例子,这里就不再重复了。
|
||||
|
||||
auto还有一个“最佳实践”,就是“**range-based for**”,不需要关心容器元素类型、迭代器返回值和首末位置,就能非常轻松地完成遍历操作。不过,为了保证效率,最好使用“const auto&”或者“auto&”。
|
||||
|
||||
```
|
||||
vector<int> v = {2,3,5,7,11}; // vector顺序容器
|
||||
|
||||
for(const auto& i : v) { // 常引用方式访问元素,避免拷贝代价
|
||||
cout << i << ","; // 常引用不会改变元素的值
|
||||
}
|
||||
|
||||
for(auto& i : v) { // 引用方式访问元素
|
||||
i++; // 可以改变元素的值
|
||||
cout << i << ",";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在C++14里,auto还新增了一个应用场合,就是能够推导函数返回值,这样在写复杂函数的时候,比如返回一个pair、容器或者迭代器,就会很省事。
|
||||
|
||||
```
|
||||
auto get_a_set() // auto作为函数返回值的占位符
|
||||
{
|
||||
std::set<int> s = {1,2,3};
|
||||
return s;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
再来看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; // 类型别名定义成员变量
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
好了,今天我介绍了C++里的“自动类型推导”,简单小结一下今天的内容。
|
||||
|
||||
1. “自动类型推导”是给编译器下的指令,让编译器去计算表达式的类型,然后返回给程序员。
|
||||
1. auto用于初始化时的类型推导,总是“值类型”,也可以加上修饰符产生新类型。它的规则比较好理解,用法也简单,应该积极使用。
|
||||
1. decltype使用类似函数调用的形式计算表达式的类型,能够用在任意场合,因为它就是一个编译阶段的类型。
|
||||
1. decltype能够推导出表达式的精确类型,但写起来比较麻烦,在初始化时可以采用decltype(auto)的简化形式。
|
||||
1. 因为auto和decltype不是“硬编码”的类型,所以用好它们可以让代码更清晰,减少后期维护的成本。
|
||||
|
||||
## 课下作业
|
||||
|
||||
最后是课下作业时间,给你留两个思考题:
|
||||
|
||||
1. auto和decltype虽然很方便,但用多了也确实会“隐藏”真正的类型,增加阅读时的理解难度,你觉得这算是缺点吗?是否有办法克服或者缓解?
|
||||
1. 说一下你对auto和decltype的认识。你认为,两者有哪些区别呢?(推导规则、应用场合等)
|
||||
|
||||
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/14/6ec0c53ee9917795c0e2a494cfe70014.png" alt="">
|
||||
@@ -0,0 +1,213 @@
|
||||
<audio id="audio" title="07 | const/volatile/mutable:常量/变量究竟是怎么回事?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/13/2a/130847d342028d4dcd2277aede0cf42a.mp3"></audio>
|
||||
|
||||
你好,我是Chrono。
|
||||
|
||||
上节课我讲了自动类型推导,提到auto推导出的类型可以附加const、volatile修饰(通常合称为“cv修饰符”)。别看就这么两个关键字,里面的“门道”其实挺多的,用好了可以让你的代码更安全、运行得更快。今天我就来说说它们俩,以及比较少见的另一个关键字mutable。
|
||||
|
||||
## const与volatile
|
||||
|
||||
先来看**const**吧,你一定对它很熟悉了。正如它的字面含义,表示“常量”。最简单的用法就是,**定义程序用到的数字、字符串常量,代替宏定义**。
|
||||
|
||||
```
|
||||
const int MAX_LEN = 1024;
|
||||
const std::string NAME = "metroid";
|
||||
|
||||
```
|
||||
|
||||
但如果我们从C++程序的生命周期角度来看的话,就会发现,它和宏定义还是有本质区别的:**const定义的常量在预处理阶段并不存在,而是直到运行阶段才会出现**。
|
||||
|
||||
所以,准确地说,它实际上是运行时的“变量”,只不过不允许修改,是“只读”的(read only),叫“只读变量”更合适。
|
||||
|
||||
既然它是“变量”,那么,使用指针获取地址,再“强制”写入也是可以的。但这种做法破坏了“常量性”,绝对不提倡。这里,我只是给你做一个示范性质的实验,还要用到另外一个关键字volatile。
|
||||
|
||||
```
|
||||
// 需要加上volatile修饰,运行时才能看到效果
|
||||
const volatile int MAX_LEN = 1024;
|
||||
|
||||
auto ptr = (int*)(&MAX_LEN);
|
||||
*ptr = 2048;
|
||||
cout << MAX_LEN << endl; // 输出2048
|
||||
|
||||
```
|
||||
|
||||
可以看到,这段代码最开始定义的常数是1024,但是输出的却是2048。
|
||||
|
||||
你可能注意到了,const后面多出了一个volatile的修饰,它是这段代码的关键。如果没有这个volatile,那么,即使用指针得到了常量的地址,并且尝试进行了各种修改,但输出的仍然会是常数1024。
|
||||
|
||||
这是为什么呢?
|
||||
|
||||
因为“真正的常数”对于计算机来说有特殊意义,它是绝对不变的,所以编译器就要想各种办法去优化。
|
||||
|
||||
const常量虽然不是“真正的常数”,但在大多数情况下,它都可以被认为是常数,在运行期间不会改变。编译器看到const定义,就会采取一些优化手段,比如把所有const常量出现的地方都替换成原始值。
|
||||
|
||||
所以,对于没有volatile修饰的const常量来说,虽然你用指针改了常量的值,但这个值在运行阶段根本没有用到,因为它在编译阶段就被优化掉了。
|
||||
|
||||
现在就来看看**volatile**的作用。
|
||||
|
||||
它的含义是“不稳定的”“易变的”,在C++里,表示变量的值可能会以“难以察觉”的方式被修改(比如操作系统信号、外界其他的代码),所以要禁止编译器做任何形式的优化,每次使用的时候都必须“老老实实”地去取值。
|
||||
|
||||
现在,再去看刚才的那段示例代码,你就应该明白了。MAX_LEN虽然是个“只读变量”,但加上了volatile修饰,就表示它不稳定,可能会悄悄地改变。编译器在生成二进制机器码的时候,不会再去做那些可能有副作用的优化,而是用最“保守”的方式去使用MAX_LEN。
|
||||
|
||||
也就是说,编译器不会再把MAX_LEN替换为1024,而是去内存里取值(而它已经通过指针被强制修改了)。所以,这段代码最后输出的是2048,而不是最初的1024。
|
||||
|
||||
看到这里,你是不是也被const和volatile这两个关键字的表面意思迷惑了呢?我的建议是,你最好把const理解成read only(虽然是“只读”,但在运行阶段没有什么是不可以改变的,也可以强制写入),把变量标记成const可以让编译器做更好的优化。
|
||||
|
||||
而volatile会禁止编译器做优化,所以除非必要,应当少用volatile,这也是你几乎很少在代码里见到它的原因,我也建议你最好不要用(除非你真的知道变量会如何被“悄悄地”改变)。
|
||||
|
||||
## 基本的const用法
|
||||
|
||||
作为一个类型修饰符,const的用途非常多,除了我刚才提到的修饰变量外,下面我再带你看看它的常量引用、常量指针等其他用法。而volatile因为比较“危险”,我就不再多说了。
|
||||
|
||||
在C++里,除了最基本的值类型,还有引用类型和指针类型,它们加上const就成了**常量引用**和**常量指针**:
|
||||
|
||||
```
|
||||
int x = 100;
|
||||
|
||||
const int& rx = x;
|
||||
const int* px = &x;
|
||||
|
||||
```
|
||||
|
||||
const &被称为**万能引用**,也就是说,它可以引用任何类型,即不管是值、指针、左引用还是右引用,它都能“照单全收”。
|
||||
|
||||
而且,它还会给变量附加上const特性,这样“变量”就成了“常量”,只能读、禁止写。编译器会帮你检查出所有对它的写操作,发出警告,在编译阶段防止有意或者无意的修改。这样一来,const常量用起来就非常安全了。
|
||||
|
||||
因此,**在设计函数的时候,我建议你尽可能地使用它作为入口参数,一来保证效率,二来保证安全**。
|
||||
|
||||
const用于指针的情况会略微复杂一点。常见的用法是,const放在声明的最左边,表示指向常量的指针。这个其实很好理解,指针指向的是一个“只读变量”,不允许修改:
|
||||
|
||||
```
|
||||
string name = "uncharted";
|
||||
const string* ps1 = &name; // 指向常量
|
||||
*ps1 = "spiderman"; // 错误,不允许修改
|
||||
|
||||
```
|
||||
|
||||
另外一种比较“恶心”的用法是,const在“*”的右边,表示指针不能被修改,而指向的变量可以被修改:
|
||||
|
||||
```
|
||||
string* const ps2 = &name; // 指向变量,但指针本身不能被修改
|
||||
*ps2 = "spiderman"; // 正确,允许修改
|
||||
|
||||
```
|
||||
|
||||
再进一步,那就是“*”两边都有const,你看看是什么意思呢:
|
||||
|
||||
```
|
||||
const string* const ps3 = &name; // 很难看懂
|
||||
|
||||
|
||||
```
|
||||
|
||||
实话实说,我对const在“*”后面的用法“深恶痛绝”,每次看到这种形式,脑子里都会“绕一下”,实在是太难理解了,似乎感觉到了代码作者“深深的恶意”。
|
||||
|
||||
还是那句名言:“代码是给人看的,而不是给机器看的。”
|
||||
|
||||
所以,我从来不用“* const”的形式,也建议你最好不要用,而且这种形式在实际开发时也确实没有多大作用(除非你想“炫技”)。如果真有必要,也最好换成其他实现方式,让代码好懂一点,将来的代码维护者会感谢你的。
|
||||
|
||||
## 与类相关的const用法
|
||||
|
||||
刚才说的const用法都是面向过程的,在面向对象里,const也很有用。
|
||||
|
||||
定义const成员变量很简单,但你用过const成员函数吗?像这样:
|
||||
|
||||
```
|
||||
class DemoClass final
|
||||
{
|
||||
private:
|
||||
const long MAX_SIZE = 256; // const成员变量
|
||||
int m_value; // 成员变量
|
||||
public:
|
||||
int get_value() const // const成员函数
|
||||
{
|
||||
return m_value;
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
注意,这里const的用法有点特别。它被放在了函数的后面,表示这个函数是一个“常量”。(如果在前面,就代表返回值是const int)
|
||||
|
||||
“const成员函数”的意思并不是说函数不可修改。实际上,在C++里,函数并不是变量(lambda表达式除外),所以,“只读”对于函数来说没有任何意义。它的真正含义是:函数的执行过程是const的,不会修改对象的状态(即成员变量),也就是说,**成员函数是一个“只读操作”**。
|
||||
|
||||
听起来有点平淡无奇吧,但如果你把它和刚才讲的“常量引用”“常量指针”结合起来,就不一样了。
|
||||
|
||||
因为“常量引用”“常量指针”关联的对象是只读、不可修改的,那么也就意味着,对它的任何操作也应该是只读、不可修改的,否则就无法保证它的安全性。所以,编译器会检查const对象相关的代码,如果成员函数不是const,就不允许调用。
|
||||
|
||||
这其实也是对“常量”语义的一个自然延伸,既然对象是const,那么它所有的相关操作也必然是const。同样,保证了安全之后,编译器确认对象不会变,也可以去做更好的优化。
|
||||
|
||||
看到这里,你会不会觉得常量引用、常量指针、常量函数这些概念有些“绕”呢?别担心,我给你总结了一个表格,看了它,以后你写代码的时候就不会晕了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ed/99/ed894e66f2ee7a651aca07dffbff2799.jpg" alt="">
|
||||
|
||||
这方面你还可以借鉴一下标准库,比如vector,它的empty()、size()、capacity()等查看基本属性的操作都是const的,而reserve()、clear()、erase()则是非const的。
|
||||
|
||||
## 关键字mutable
|
||||
|
||||
说到这里,就要牵扯出另一个关键字“**mutable**”了。
|
||||
|
||||
mutable与volatile的字面含义有点像,但用法、效果却大相径庭。volatile可以用来修饰任何变量,而mutable却只能修饰类里面的成员变量,表示变量即使是在const对象里,也是可以修改的。
|
||||
|
||||
换句话说,就是标记为mutable的成员不会改变对象的状态,也就是不影响对象的常量性,所以允许const成员函数改写mutable成员变量。
|
||||
|
||||
你是不是有些奇怪:“这个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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
```
|
||||
|
||||
不过要当心,mutable也不要乱用,太多的mutable就丧失了const的好处。在设计类的时候,我们一定要仔细考虑,和volatile一样要少用、慎用。
|
||||
|
||||
## 小结
|
||||
|
||||
好了,今天我和你聊了const、volatile、mutable这三个关键字,在这里简单小结一下。
|
||||
|
||||
1.const
|
||||
|
||||
- 它是一个类型修饰符,可以给任何对象附加上“只读”属性,保证安全;
|
||||
- 它可以修饰引用和指针,“const &”可以引用任何类型,是函数入口参数的最佳类型;
|
||||
- 它还可以修饰成员函数,表示函数是“只读”的,const对象只能调用const成员函数。
|
||||
|
||||
2.volatile
|
||||
|
||||
它表示变量可能会被“不被察觉”地修改,禁止编译器优化,影响性能,应当少用。
|
||||
|
||||
3.mutable
|
||||
|
||||
它用来修饰成员变量,允许const成员函数修改,mutable变量的变化不影响对象的常量性,但要小心不要误用损坏对象。
|
||||
|
||||
你今后再写类的时候,就要认真想一想,哪些操作改变了内部状态,哪些操作没改变内部状态,对于只读的函数,就要加上const修饰。写错了也不用怕,编译器会帮你检查出来。
|
||||
|
||||
总之就是一句话:**尽可能多用const,让代码更安全。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f0/b8/f011dc96ff693faa4d763ea36bdc67b8.jpg" alt="">
|
||||
|
||||
这在多线程编程时尤其有用,让编译器帮你检查对象的所有操作,把“只读”属性持续传递出去,避免有害的副作用。
|
||||
|
||||
## 课下作业
|
||||
|
||||
最后是课下作业时间,给你留两个思考题:
|
||||
|
||||
1. 学完了这节课,你觉得今后应该怎么用const呢?
|
||||
1. 给函数的返回值加上const,也就是说返回一个常量对象,有什么好处?
|
||||
|
||||
欢迎你在留言区写下你的思考和答案,如果觉得文章对你有所帮助,也欢迎把文章分享给你的朋友,我们下节课见。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bd/dd/bdd9bb369fcbe65a8c879f37995a77dd.jpg" alt="">
|
||||
284
极客时间专栏/罗剑锋的C++实战笔记/语言特性/08 | smart_ptr:智能指针到底“智能”在哪里?.md
Normal file
284
极客时间专栏/罗剑锋的C++实战笔记/语言特性/08 | smart_ptr:智能指针到底“智能”在哪里?.md
Normal file
@@ -0,0 +1,284 @@
|
||||
<audio id="audio" title="08 | smart_ptr:智能指针到底“智能”在哪里?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/3c/0619ce3b770efca7f227e80d9eea4d3c.mp3"></audio>
|
||||
|
||||
你好,我是Chrono。
|
||||
|
||||
上节课在讲const的时候,说到const可以修饰指针,不过今天我要告诉你:请忘记这种用法,在现代C++中,绝对不要再使用“裸指针(naked pointer)”了,而是应该使用“智能指针(smart pointer)”。
|
||||
|
||||
你肯定或多或少听说过、用过智能指针,也可能看过实现源码,那么,你心里有没有一种疑惑,智能指针到底“智能”在哪里?难道它就是解决一切问题的“灵丹妙药”吗?
|
||||
|
||||
学完了今天的这节课,我想你就会有个明确的答案了。
|
||||
|
||||
## 什么是智能指针?
|
||||
|
||||
所谓的“智能指针”,当然是相对于“不智能指针”,也就是“裸指针”而言的。
|
||||
|
||||
所以,我们就先来看看裸指针,它有时候也被称为原始指针,或者直接简称为指针。
|
||||
|
||||
指针是源自C语言的概念,本质上是一个内存地址索引,代表了一小片内存区域(也可能会很大),能够直接读写内存。
|
||||
|
||||
因为它完全映射了计算机硬件,所以操作效率高,是C/C++高效的根源。当然,这也是引起无数麻烦的根源。访问无效数据、指针越界,或者内存分配后没有及时释放,就会导致运行错误、内存泄漏、资源丢失等一系列严重的问题。
|
||||
|
||||
其他的编程语言,比如Java、Go就没有这方面的顾虑,因为它们内置了一个“垃圾回收”机制,会检测不再使用的内存,自动释放资源,让程序员不必为此费心。
|
||||
|
||||
其实,C++里也是有垃圾回收的,不过不是Java、Go那种严格意义上的垃圾回收,而是广义上的垃圾回收,这就是**构造/析构函数**和**RAII惯用法**(Resource Acquisition Is Initialization)。
|
||||
|
||||
我们可以应用代理模式,把裸指针包装起来,在构造函数里初始化,在析构函数里释放。这样当对象失效销毁时,C++就会**自动**调用析构函数,完成内存释放、资源回收等清理工作。
|
||||
|
||||
和Java、Go相比,这算是一种“微型”的垃圾回收机制,而且回收的时机完全“自主可控”,非常灵活。当然也有一点代价——你必须要针对每一个资源手写包装代码,又累又麻烦。
|
||||
|
||||
智能指针就是代替你来干这些“脏活累活”的。它完全实践了RAII,包装了裸指针,而且因为重载了*和->操作符,用起来和原始指针一模一样。
|
||||
|
||||
不仅如此,它还综合考虑了很多现实的应用场景,能够自动适应各种复杂的情况,防止误用指针导致的隐患,非常“聪明”,所以被称为“智能指针”。
|
||||
|
||||
常用的有两种智能指针,分别是**unique_ptr**和**shared_ptr**,下面我就来分别介绍一下。
|
||||
|
||||
## 认识unique_ptr
|
||||
|
||||
unique_ptr是最简单、最容易使用的一个智能指针,在声明的时候必须用模板参数指定类型:
|
||||
|
||||
```
|
||||
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,它会自动管理初始化时的指针,在离开作用域时析构释放内存。**
|
||||
|
||||
另外,它也没有定义加减运算,不能随意移动指针地址,这就完全避免了指针越界等危险操作,可以让代码更安全:
|
||||
|
||||
```
|
||||
ptr1++; // 导致编译错误
|
||||
ptr2 += 2; // 导致编译错误
|
||||
|
||||
```
|
||||
|
||||
除了调用delete、加减运算,初学智能指针还有一个容易犯的错误是把它当成普通对象来用,不初始化,而是声明后直接使用:
|
||||
|
||||
```
|
||||
unique_ptr<int> ptr3; // 未初始化智能指针
|
||||
*ptr3 = 42 ; // 错误!操作了空指针
|
||||
|
||||
```
|
||||
|
||||
未初始化的unique_ptr表示空指针,这样就相当于直接操作了空指针,运行时就会产生致命的错误(比如core dump)。
|
||||
|
||||
为了避免这种低级错误,你可以调用工厂函数**make_unique()**,强制创建智能指针的时候必须初始化。同时还可以利用自动类型推导([第6讲](https://time.geekbang.org/column/article/237964))的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,好在它的原理比较简单。如果你使用的是C++11,也可以自己实现一个简化版的make_unique(),可以参考下面的代码:
|
||||
|
||||
```
|
||||
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的时候还要特别注意指针的“**所有权**”问题。
|
||||
|
||||
正如它的名字,表示指针的所有权是“唯一”的,不允许共享,任何时候只能有一个“人”持有它。
|
||||
|
||||
为了实现这个目的,unique_ptr应用了C++的“转移”(move)语义,同时禁止了拷贝赋值,所以,在向另一个unique_ptr赋值的时候,要特别留意,必须用**std::move()**函数显式地声明所有权转移。
|
||||
|
||||
赋值操作之后,指针的所有权就被转走了,原来的unique_ptr变成了空指针,新的unique_ptr接替了管理权,保证所有权的唯一性:
|
||||
|
||||
```
|
||||
auto ptr1 = make_unique<int>(42); // 工厂函数创建智能指针
|
||||
assert(ptr1 && *ptr1 == 42); // 此时智能指针有效
|
||||
|
||||
auto ptr2 = std::move(ptr1); // 使用move()转移所有权
|
||||
assert(!ptr1 && ptr2); // ptr1变成了空指针
|
||||
|
||||
```
|
||||
|
||||
如果你对右值、转移这些概念不是太理解,也没关系,它们用起来也的确比较“微妙”,这里你只要记住,**尽量不要对unique_ptr执行赋值操作**就好了,让它“自生自灭”,完全自动化管理。
|
||||
|
||||
## 认识shared_ptr
|
||||
|
||||
接下来要说的是shared_ptr,它是一个比unique_ptr更“智能”的智能指针。
|
||||
|
||||
初看上去shared_ptr和unique_ptr差不多,也可以使用工厂函数来创建,也重载了*和->操作符,用法几乎一样——只是名字不同,看看下面的代码吧:
|
||||
|
||||
```
|
||||
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()); // 可以使用->调用成员函数
|
||||
|
||||
```
|
||||
|
||||
但shared_ptr的名字明显表示了它与unique_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支持安全共享的秘密在于**内部使用了“引用计数”**。
|
||||
|
||||
引用计数最开始的时候是1,表示只有一个持有者。如果发生拷贝赋值——也就是共享的时候,引用计数就增加,而发生析构销毁的时候,引用计数就减少。只有当引用计数减少到0,也就是说,没有任何人使用这个指针的时候,它才会真正调用delete释放内存。
|
||||
|
||||
因为shared_ptr具有完整的“值语义”(即可以拷贝赋值),所以,**它可以在任何场合替代原始指针,而不用再担心资源回收的问题**,比如用于容器存储指针、用于函数安全返回动态创建的对象,等等。
|
||||
|
||||
## shared_ptr的注意事项
|
||||
|
||||
那么,既然shared_ptr这么好,是不是就可以只用它而不再考虑unique_ptr了呢?
|
||||
|
||||
答案当然是否定的,不然也就没有必要设计出来多种不同的智能指针了。
|
||||
|
||||
虽然shared_ptr非常“智能”,但天下没有免费的午餐,它也是有代价的,**引用计数的存储和管理都是成本**,这方面是shared_ptr不如unique_ptr的地方。
|
||||
|
||||
如果不考虑应用场合,过度使用shared_ptr就会降低运行效率。不过,你也不需要太担心,shared_ptr内部有很好的优化,在非极端情况下,它的开销都很小。
|
||||
|
||||
另外一个要注意的地方是**shared_ptr的销毁动作**。
|
||||
|
||||
因为我们把指针交给了shared_ptr去自动管理,但在运行阶段,引用计数的变动是很复杂的,很难知道它真正释放资源的时机,无法像Java、Go那样明确掌控、调整垃圾回收机制。
|
||||
|
||||
你要特别小心对象的析构函数,不要有非常复杂、严重阻塞的操作。一旦shared_ptr在某个不确定时间点析构释放资源,就会阻塞整个进程或者线程,“整个世界都会静止不动”(也许用过Go的同学会深有体会)。这也是我以前遇到的实际案例,排查起来费了很多功夫,真的是“血泪教训”。
|
||||
|
||||
```
|
||||
class DemoShared final // 危险的类,不定时的地雷
|
||||
{
|
||||
public:
|
||||
DemoShared() = default;
|
||||
~DemoShared() // 复杂的操作会导致shared_ptr析构时世界静止
|
||||
{
|
||||
// Stop The World ...
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
```
|
||||
|
||||
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,无法销毁,导致内存泄漏
|
||||
|
||||
```
|
||||
|
||||
在这里,两个节点指针刚创建时,引用计数是1,但指针互指(即拷贝赋值)之后,引用计数都变成了2。
|
||||
|
||||
这个时候,shared_ptr就“犯傻”了,意识不到这是一个循环引用,多算了一次计数,后果就是引用计数无法减到0,无法调用析构函数执行delete,最终导致内存泄漏。
|
||||
|
||||
这个例子很简单,你一下子就能看出存在循环引用。但在实际开发中,指针的关系可不像例子那么清晰,很有可能会不知不觉形成一个链条很长的循环引用,复杂到你根本无法识别,想要找出来基本上是不可能的。
|
||||
|
||||
想要从根本上杜绝循环引用,光靠shared_ptr是不行了,必须要用到它的“小帮手”:**weak_ptr**。
|
||||
|
||||
weak_ptr顾名思义,功能很“弱”。它专门为打破循环引用而设计,只观察指针,不会增加引用计数(弱引用),但在需要的时候,可以调用成员函数lock(),获取shared_ptr(强引用)。
|
||||
|
||||
刚才的例子里,只要你改用weak_ptr,循环引用的烦恼就会烟消云散:
|
||||
|
||||
```
|
||||
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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
好了,今天就先到这里。智能指针的话题很大,但是学习的时候我们不可能一下子把所有知识点都穷尽,而是要有优先级。所以我会捡最要紧的先介绍给你,剩下的接口函数等细节,还是需要你根据自己的情况,再去参考一些其他资料深入学习的。
|
||||
|
||||
我们来回顾一下这节课的重点。
|
||||
|
||||
1. 智能指针是代理模式的具体应用,它使用RAII技术代理了裸指针,能够自动释放内存,无需程序员干预,所以被称为“智能指针”。
|
||||
1. 如果指针是“独占”使用,就应该选择unique_ptr,它为裸指针添加了很多限制,更加安全。
|
||||
1. 如果指针是“共享”使用,就应该选择shared_ptr,它的功能非常完善,用法几乎与原始指针一样。
|
||||
1. 应当使用工厂函数make_unique()、make_shared()来创建智能指针,强制初始化,而且还能使用auto来简化声明。
|
||||
1. shared_ptr有少量的管理成本,也会引发一些难以排查的错误,所以不要过度使用。
|
||||
|
||||
我还有一个很重要的建议:
|
||||
|
||||
**既然你已经理解了智能指针,就尽量不要再使用裸指针、new和delete来操作内存了**。
|
||||
|
||||
如果严格遵守这条建议,用好unique_ptr、shared_ptr,那么,你的程序就不可能出现内存泄漏,你也就不需要去费心研究、使用valgrind等内存调试工具了,生活也会更“美好”一点。
|
||||
|
||||
## 课下作业
|
||||
|
||||
最后是课下作业时间,给你留两个思考题:
|
||||
|
||||
1. 你觉得unique_ptr和shared_ptr的区别有哪些?列举一下。
|
||||
1. 你觉得应该如何在程序里“消灭”new和delete?
|
||||
|
||||
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/51/e5298af2501d0156fcc50d50cdb82351.jpg" alt="">
|
||||
265
极客时间专栏/罗剑锋的C++实战笔记/语言特性/09 | exception:怎样才能用好异常?.md
Normal file
265
极客时间专栏/罗剑锋的C++实战笔记/语言特性/09 | exception:怎样才能用好异常?.md
Normal file
@@ -0,0 +1,265 @@
|
||||
<audio id="audio" title="09 | exception:怎样才能用好异常?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/39/21/398ce491728143f0c00a210fe2b03e21.mp3"></audio>
|
||||
|
||||
你好,我是Chrono。
|
||||
|
||||
上节课,我建议尽量不用裸指针、new和delete,因为它们很危险,容易导致严重错误。这就引出了一个问题,如何正确且优雅地处理运行时的错误。
|
||||
|
||||
实际上,想要达成这个目标,还真不是件简单的事情。
|
||||
|
||||
程序在运行的时候不可能“一帆风顺”,总会遇到这样那样的内外部故障,而我们写程序的人就要尽量考虑周全,准备各种“预案”,让程序即使遇到问题也能够妥善处理,保证“健壮性”。
|
||||
|
||||
C++处理错误的标准方案是“异常”(exception)。虽然它已经在Java、C#、Python等语言中得到了广泛的认可和应用,但在C++里却存在诸多争议。
|
||||
|
||||
你也可能在其他地方听到过一种说法:“**现代C++里应该使用异常**。”但这之后呢?应该怎么去用异常呢?
|
||||
|
||||
所以,今天我就和你好好聊聊“异常那些事”,说一说为什么要有异常,该怎么用好异常,有哪些要注意的地方。
|
||||
|
||||
## 为什么要有异常?
|
||||
|
||||
很多人认为,C++里的“异常”非常可怕,一旦发生异常就是“了不得的大事”,这其实是因为没有理解异常的真正含义。
|
||||
|
||||
实际上,你可以按照它的字面意思,把它理解成“**异于正常**”,就是正常流程之外发生的一些特殊情况、严重错误。一旦遇到这样的错误,程序就会跳出正常流程,甚至很难继续执行下去。
|
||||
|
||||
归根到底,**异常只是C++为了处理错误而提出的一种解决方案,当然也不会是唯一的一种**。
|
||||
|
||||
在C++之前,处理异常的基本手段是“错误码”。函数执行后,需要检查返回值或者全局的errno,看是否正常,如果出错了,就执行另外一段代码处理错误:
|
||||
|
||||
```
|
||||
int n = read_data(fd, ...); // 读取数据
|
||||
|
||||
if (n == 0) {
|
||||
... // 返回值不太对,适当处理
|
||||
}
|
||||
|
||||
if (errno == EAGAIN) {
|
||||
... // 适当处理错误
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这种做法很直观,但也有一个问题,那就是**正常的业务逻辑代码与错误处理代码混在了一起**,看起来很乱,你的思维要在两个本来不相关的流程里来回跳转。而且,有的时候,错误处理的逻辑要比正常业务逻辑复杂、麻烦得多,看了半天,你可能都会忘了它当初到底要干什么了,容易引起新的错误。(你可以对比一下预处理代码与C++代码混在一起的情景。)
|
||||
|
||||
错误码还有另一个更大的问题:**它是可以被忽略的**。也就是说,你完全可以不处理错误,“假装”程序运行正常,继续跑后面的代码,这就可能导致严重的安全隐患。(可能是无意的,因为你确实不知道发生了什么错误。)
|
||||
|
||||
“没有对比就没有伤害”,现在你就应该明白了,作为一种新的错误处理方式,异常就是针对错误码的缺陷而设计的,它有三个特点。
|
||||
|
||||
<li>
|
||||
**异常的处理流程是完全独立的**,throw抛出异常后就可以不用管了,错误处理代码都集中在专门的catch块里。这样就彻底分离了业务逻辑与错误逻辑,看起来更清楚。
|
||||
</li>
|
||||
<li>
|
||||
**异常是绝对不能被忽略的,必须被处理**。如果你有意或者无意不写catch捕获异常,那么它会一直向上传播出去,直至找到一个能够处理的catch块。如果实在没有,那就会导致程序立即停止运行,明白地提示你发生了错误,而不会“坚持带病工作”。
|
||||
</li>
|
||||
<li>
|
||||
**异常可以用在错误码无法使用的场合**,这也算是C++的“私人原因”。因为它比C语言多了构造/析构函数、操作符重载等新特性,有的函数根本就没有返回值,或者返回值无法表示错误,而全局的errno实在是“太不优雅”了,与C++的理念不符,所以也必须使用异常来报告错误。
|
||||
</li>
|
||||
|
||||
记住这三个关键点,是在C++里用好异常的基础,它们能够帮助你在本质上理解异常的各种用法。
|
||||
|
||||
## 异常的用法和使用方式
|
||||
|
||||
C++里异常的用法想必你已经知道了:**用try把可能发生异常的代码“包”起来,然后编写catch块捕获异常并处理**。
|
||||
|
||||
刚才的错误码例子改用异常,就会变得非常干净清晰:
|
||||
|
||||
```
|
||||
try
|
||||
{
|
||||
int n = read_data(fd, ...); // 读取数据,可能抛出异常
|
||||
|
||||
... // do some right thing
|
||||
}
|
||||
catch(...)
|
||||
{
|
||||
... // 集中处理各种错误情况
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
基本的try-catch谁都会写,那么,怎样才能用好异常呢?
|
||||
|
||||
首先你要知道,C++里对异常的定义非常宽松,任何类型都可以用throw抛出,也就是说,你可以直接把错误码(int)、或者错误消息(char*、string)抛出,catch也能接住,然后处理。
|
||||
|
||||
但我建议你最好不要“图省事”,因为C++已经为处理异常设计了一个配套的异常类型体系,定义在标准库的<stdexcept>头文件里。
|
||||
|
||||
下面我画了个简单的示意图,你可以看一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/67/8ba78367ce53d54a393a8963bb62e867.jpg" alt="">
|
||||
|
||||
标准异常的继承体系有点复杂,最上面是基类exception,下面是几个基本的异常类型,比如bad_alloc、bad_cast、runtime_error、logic_error,再往下还有更细致的错误类型,像runtime_error就有range_error、overflow_error,等等。
|
||||
|
||||
我在[第5节课](https://time.geekbang.org/column/article/235301)讲过,如果继承深度超过三层,就说明有点“过度设计”,很明显现在就有这种趋势了。所以,我建议你最好选择上面的第一层或者第二层的某个类型作为基类,不要再加深层次。
|
||||
|
||||
比如说,你可以从runtime_error派生出自己的异常类:
|
||||
|
||||
```
|
||||
class my_exception : public std::runtime_error
|
||||
{
|
||||
public:
|
||||
using this_type = my_exception; // 给自己起个别名
|
||||
using super_type = std::runtime_error; // 给父类也起个别名
|
||||
public:
|
||||
my_exception(const char* msg): // 构造函数
|
||||
super_type(msg) // 别名也可以用于构造
|
||||
{}
|
||||
|
||||
my_exception() = default; // 默认构造函数
|
||||
~my_exception() = default; // 默认析构函数
|
||||
private:
|
||||
int code = 0; // 其他的内部私有数据
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在抛出异常的时候,我建议你最好不要直接用throw关键字,而是要封装成一个函数,这和不要直接用new、delete关键字是类似的道理——**通过引入一个“中间层”来获得更多的可读性、安全性和灵活性**。
|
||||
|
||||
抛异常的函数不会有返回值,所以应该用[第4节课](https://time.geekbang.org/column/article/235295)里的“属性”做编译阶段优化:
|
||||
|
||||
```
|
||||
[[noreturn]] // 属性标签
|
||||
void raise(const char* msg) // 函数封装throw,没有返回值
|
||||
{
|
||||
throw my_exception(msg); // 抛出异常,也可以有更多的逻辑
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用catch捕获异常的时候也要注意,C++允许编写多个catch块,捕获不同的异常,再分别处理。但是,**异常只能按照catch块在代码里的顺序依次匹配,而不会去找最佳匹配**。
|
||||
|
||||
这个特性导致实际开发的时候有点麻烦,特别是当异常类型体系比较复杂的时候,有可能会因为写错了顺序,进入你本不想进的catch块。所以,**我建议你最好只用一个catch块,绕过这个“坑”**。
|
||||
|
||||
写catch块就像是写一个标准函数,所以入口参数也应当使用“const &”的形式,避免对象拷贝的代价:
|
||||
|
||||
```
|
||||
try
|
||||
{
|
||||
raise("error occured"); // 函数封装throw,抛出异常
|
||||
}
|
||||
catch(const exception& e) // const &捕获异常,可以用基类
|
||||
{
|
||||
cout << e.what() << endl; // what()是exception的虚函数
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
关于try-catch,还有一个很有用的形式:**function-try。**我一直都觉得非常奇怪的是,这个形式如此得简单清晰,早在C++98的时候就已经出现了,但知道的人却非常少。
|
||||
|
||||
所谓function-try,就是把整个函数体视为一个大try块,而catch块放在后面,与函数体同级并列,给你看个示例:
|
||||
|
||||
```
|
||||
void some_function()
|
||||
try // 函数名之后直接写try块
|
||||
{
|
||||
...
|
||||
}
|
||||
catch(...) // catch块与函数体同级并列
|
||||
{
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样做的好处很明显,不仅能够捕获函数执行过程中所有可能产生的异常,而且少了一级缩进层次,处理逻辑更清晰,我也建议你多用。
|
||||
|
||||
## 谨慎使用异常
|
||||
|
||||
掌握了异常和它的处理方式,下面我结合我自己的经验,和你讨论一下应该在什么时候使用异常来处理错误。
|
||||
|
||||
目前的C++世界里有三种使用异常的方式(或者说是观点)。
|
||||
|
||||
第一种,是绝不使用异常,就像是C语言那样,只用传统的错误码来检查错误。
|
||||
|
||||
选择禁止异常的原因当然有很多,有的也很合理,但我觉得这就等于浪费了异常机制,对于改善代码质量没有帮助,属于“**因噎废食**”。
|
||||
|
||||
第二种则与第一种相反,主张全面采用异常,所有的错误都用异常的形式来处理。
|
||||
|
||||
但你要知道,异常也是有成本的。
|
||||
|
||||
异常的抛出和处理需要特别的栈展开(stack unwind)操作,如果异常出现的位置很深,但又没有被及时处理,或者频繁地抛出异常,就会对运行性能产生很大的影响。这个时候,程序全忙着去处理异常了,正常逻辑反而被搁置。
|
||||
|
||||
这种观点我认为是“**暴饮暴食**”,也不可取。
|
||||
|
||||
所以,第三种方式就是两者的折中:区分“非”错误、“轻微”错误和“严重”错误,谨慎使用异常。我认为这应该算是“**均衡饮食**”。
|
||||
|
||||
具体来说,就是要仔细分析程序中可能发生的各种错误情况,按严重程度划分出等级,把握好“度”。
|
||||
|
||||
对于正常的返回值,或者不太严重、可以重试/恢复的错误,我建议你不使用异常,把它们归到正常的流程里。
|
||||
|
||||
比如说字符串未找到(不是错误)、数据格式不对(轻微错误)、数据库正忙(可重试错误),这样的错误比较轻微,而且在业务逻辑里会经常出现,如果你用异常处理,就会“小题大做”,影响性能。
|
||||
|
||||
剩下的那些中级、高级错误也不是都必须用异常,你还要再做分析,尽量降低引入异常的成本。
|
||||
|
||||
我自己总结了几个应当使用异常的判断准则:
|
||||
|
||||
1. 不允许被忽略的错误;
|
||||
1. 极少数情况下才会发生的错误;
|
||||
1. 严重影响正常流程,很难恢复到正常状态的错误;
|
||||
1. 无法本地处理,必须“穿透”调用栈,传递到上层才能被处理的错误。
|
||||
|
||||
规则听起来可能有点不好理解,我给你举几个例子。
|
||||
|
||||
比如说构造函数,如果内部初始化失败,无法创建,那后面的逻辑也就进行不下去了,所以这里就可以用异常来处理。
|
||||
|
||||
再比如,读写文件,通常文件系统很少会出错,总会成功,如果用错误码来处理不存在、权限错误等,就显得太啰嗦,这时也应该使用异常。
|
||||
|
||||
相反的例子就是socket通信。因为网络链路的不稳定因素太多,收发数据失败简直是“家常便饭”。虽然出错的后果很严重,但它出现的频率太高了,使用异常会增加很多的处理成本,为了性能考虑,还是检查错误码重试比较好。
|
||||
|
||||
## 保证不抛出异常
|
||||
|
||||
看到这里,你是不是觉得异常是把“双刃剑”呢?优点缺点都有,难以取舍。
|
||||
|
||||
有没有什么办法既能享受异常的好处,又不用承担异常的成本呢?
|
||||
|
||||
还真有这样的“好事”,毕竟,写C++程序追求的就是性能,所以,C++标准就又提出了一个新的编译阶段指令:**noexcept**,但它也有一点局限,不是“万能药”。
|
||||
|
||||
noexcept专门用来修饰函数,告诉编译器:这个函数不会抛出异常。编译器看到noexcept,就得到了一个“保证”,就可以对函数做优化,不去加那些栈展开的额外代码,消除异常处理的成本。
|
||||
|
||||
和const一样,noexcept要放在函数后面:
|
||||
|
||||
```
|
||||
void func_noexcept() noexcept // 声明绝不会抛出异常
|
||||
{
|
||||
cout << "noexcept" << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过你要注意,noexcept只是做出了一个“不可靠的承诺”,不是“强保证”,编译器无法彻底检查它的行为,标记为noexcept的函数也有可能抛出异常:
|
||||
|
||||
```
|
||||
void func_maybe_noexcept() noexcept // 声明绝不会抛出异常
|
||||
{
|
||||
throw "Oh My God"; // 但也可以抛出异常
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
noexcept的真正意思是:“我对外承诺不抛出异常,我也不想处理异常,如果真的有异常发生,请让我死得干脆点,直接崩溃(crash、core dump)。”
|
||||
|
||||
所以,你也不要一股脑地给所有函数都加上noexcept修饰,毕竟,你无法预测内部调用的那些函数是否会抛出异常。
|
||||
|
||||
## 小结
|
||||
|
||||
今天的话题是错误处理和异常,因为它实在太大了,想要快速说清、说透实在是“不可能的任务”,我们可以在课后继续讨论。
|
||||
|
||||
异常也与上一讲的智能指针密切相关,如果你决定使用异常,为了确保出现异常的时候资源会正确释放,就必须禁用裸指针,改成智能指针,用RAII来管理内存。
|
||||
|
||||
由于异常出现和处理的时机都不好确定,当前的C++也没有在语言层面提出更好的机制,所以,你还要在编码阶段写好文档和注释,说清楚哪些函数、什么情况下会抛出什么样的异常,应如何处理,加上一些“软约束”。
|
||||
|
||||
再简单小结一下今天的内容:
|
||||
|
||||
1. 异常是针对错误码的缺陷而设计的,它不能被忽略,而且可以“穿透”调用栈,逐层传播到其他地方去处理;
|
||||
1. 使用try-catch机制处理异常,能够分离正常流程与错误处理流程,让代码更清晰;
|
||||
1. throw可以抛出任何类型作为异常,但最好使用标准库里定义的exception类;
|
||||
1. 完全用或不用异常处理错误都不可取,而是应该合理分析,适度使用,降低异常的成本;
|
||||
1. 关键字noexcept标记函数不抛出异常,可以让编译器做更好的优化。
|
||||
|
||||
## 课下作业
|
||||
|
||||
最后是课下作业时间,给你留两个思考题:
|
||||
|
||||
1. 结合自己的实际情况,谈一下使用异常有什么好处和坏处。
|
||||
1. 你觉得用好异常还有哪些要注意的地方?
|
||||
|
||||
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/6e/96a9e2f3c794a3b24df1a49e1ce8c16e.jpg" alt="">
|
||||
271
极客时间专栏/罗剑锋的C++实战笔记/语言特性/10 | lambda:函数式编程带来了什么?.md
Normal file
271
极客时间专栏/罗剑锋的C++实战笔记/语言特性/10 | lambda:函数式编程带来了什么?.md
Normal file
@@ -0,0 +1,271 @@
|
||||
<audio id="audio" title="10 | lambda:函数式编程带来了什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e2/57/e2839e9fd15c657822c170e1d3542957.mp3"></audio>
|
||||
|
||||
你好,我是Chrono。
|
||||
|
||||
在[第1节课](https://time.geekbang.org/column/article/231454)的时候,我就说到过“函数式编程”,但只是简单提了提,没有展开讲。
|
||||
|
||||
作为现代C++里的五种基本编程范式之一,“函数式编程”的作用和地位正在不断上升,而且在其他语言里也非常流行,很有必要再深入研究一下。
|
||||
|
||||
掌握了函数式编程,你就又多了一件“趁手的兵器”,可以更好地运用标准库里的容器和算法,写出更灵活、紧凑、优雅的代码。
|
||||
|
||||
所以,今天我就和你聊聊函数式编程,看看它给C++带来了什么。
|
||||
|
||||
## C++函数的特殊性
|
||||
|
||||
说到“函数式编程”,那肯定就要先从函数(function)说起。
|
||||
|
||||
C++里的函数概念来源于C,是面向过程编程范式的基本部件。但严格来说,它其实应该叫“子过程”(sub-procedure)、“子例程”(sub-routine),是命令的集合、操作步骤的抽象。
|
||||
|
||||
函数的目的是封装执行的细节,简化程序的复杂度,但因为它有入口参数,有返回值,形式上和数学里的函数很像,所以就被称为“函数”。
|
||||
|
||||
在语法层面上,C/C++里的函数是比较特别的。虽然有函数类型,但不存在对应类型的变量,不能直接操作,只能用指针去间接操作(即函数指针),这让函数在类型体系里显得有点“格格不入”。
|
||||
|
||||
函数在用法上也有一些特殊之处。在C/C++里,所有的函数都是全局的,没有生存周期的概念(static、名字空间的作用很弱,只是简单限制了应用范围,避免名字冲突)。而且函数也都是平级的,不能在函数里再定义函数,也就是**不允许定义嵌套函数、函数套函数**。
|
||||
|
||||
```
|
||||
void my_square(int x) // 定义一个函数
|
||||
{
|
||||
cout << x*x << endl; // 函数的具体内容
|
||||
}
|
||||
|
||||
auto pfunc = &my_square; // 只能用指针去操作函数,指针不是函数
|
||||
(*pfunc)(3); // 可以用*访问函数
|
||||
pfunc(3); // 也可以直接调用函数指针
|
||||
|
||||
|
||||
```
|
||||
|
||||
所以,在面向过程编程范式里,函数和变量虽然是程序里最关键的两个组成部分,但却因为没有值、没有作用域而不能一致地处理。函数只能是函数,变量只能是变量,彼此之间虽不能说是“势同水火”,但至少是“泾渭分明”。
|
||||
|
||||
## 认识lambda
|
||||
|
||||
好了,搞清楚了函数,现在再来看看C++11引入的lambda表达式,下面是一个简单的例子:
|
||||
|
||||
```
|
||||
auto func = [](int x) // 定义一个lambda表达式
|
||||
{
|
||||
cout << x*x << endl; // lambda表达式的具体内容
|
||||
};
|
||||
|
||||
func(3); // 调用lambda表达式
|
||||
|
||||
```
|
||||
|
||||
暂时不考虑代码里面的语法细节,单从第一印象上,我们可以看到有一个函数,但更重要的,是这个函数采用了赋值的方式,存入了一个变量。
|
||||
|
||||
这就是lambda表达式与普通函数最大、也是最根本的区别。
|
||||
|
||||
因为lambda表达式是一个变量,所以,我们就可以“按需分配”,随时随地在调用点“**就地**”定义函数,限制它的作用域和生命周期,实现函数的局部化。
|
||||
|
||||
而且,因为lambda表达式和变量一样是“一等公民”,用起来也就更灵活自由,能对它做各种运算,生成新的函数。这就像是数学里的复合函数那样,把多个简单功能的小lambda表达式组合,变成一个复杂的大lambda表达式。
|
||||
|
||||
如果你比较熟悉C++98,或者看过一些相关的资料,可能会觉得lambda表达式只不过是函数对象(function object)的一种简化形式,只是一个好用的“语法糖”(syntactic sugar)。
|
||||
|
||||
大道理上是没错的,但如果把它简单地等同于函数对象,认为它只是免去了手写函数对象的麻烦,那就实在是有点太“肤浅”了。
|
||||
|
||||
lambda表达式为C++带来的变化可以说是革命性的。虽然它表面上只是一个很小的改进,简化了函数的声明/定义,但深层次带来的编程理念的变化,却是非常巨大的。
|
||||
|
||||
这和C++当初引入bool、class、template这些特性时有点类似,乍看上去好像只是一点点的语法改变,但后果却如同雪崩,促使人们更多地去思考、探索新的编程方向,而lambda引出的全新思维方式就是“函数式编程”——把写计算机程序看作是数学意义上的求解函数。
|
||||
|
||||
C++里的lambda表达式除了可以像普通函数那样被调用,还有一个普通函数所不具备的特殊本领,就是可以**“捕获”外部变量**,在内部的代码里直接操作。
|
||||
|
||||
```
|
||||
int n = 10; // 一个外部变量
|
||||
|
||||
auto func = [=](int x) // lambda表达式,用“=”值捕获
|
||||
{
|
||||
cout << x*n << endl; // 直接操作外部变量
|
||||
};
|
||||
|
||||
func(3); // 调用lambda表达式
|
||||
|
||||
```
|
||||
|
||||
看到这里,如果你用过JavaScript,那么一定会有种眼熟的感觉。没错,lambda表达式就是在其他语言中大名鼎鼎的“**闭包**”(closure),这让它真正超越了函数和函数对象。
|
||||
|
||||
“闭包”是什么,很难一下子说清楚,我就不详细解释了。说得形象一点,你可以把闭包理解为一个“活的代码块”“活的函数”。它虽然在出现时被定义,但因为保存了定义时捕获的外部变量,就可以跳离定义点,把这段代码“打包”传递到其他地方去执行,而仅凭函数的入口参数是无法做到这一点的。
|
||||
|
||||
这就导致函数式编程与命令式编程(即面向过程)在结构上有很大不同,程序流程不再是按步骤执行的“死程序”,而是一个个的“活函数”,像做数学题那样逐步计算、推导出结果,有点像下面的这样:
|
||||
|
||||
```
|
||||
auto a = [](int x) // a函数执行一个功能
|
||||
{...}
|
||||
auto b = [](double x) // b函数执行一个功能
|
||||
{...}
|
||||
auto c = [](string str) // c函数执行一个功能
|
||||
{...}
|
||||
|
||||
auto f = [](...) // f函数执行一个功能
|
||||
{...}
|
||||
|
||||
return f(a, b, c) // f调用a/b/c运算得到结果
|
||||
|
||||
|
||||
```
|
||||
|
||||
你也可以再对比面向对象来理解。在面向对象编程里,程序是由一个个实体对象组成的,对象通信完成任务。而在函数式编程里,程序是由一个个函数组成的,函数互相嵌套、组合、调用完成任务。
|
||||
|
||||
不过,毕竟函数式编程在C++里是一种较新的编程范式,而且面向过程里的函数概念“根深蒂固”,我说了这么多,你可能还是不太能领会它的奥妙,这也很正常。
|
||||
|
||||
下面我就来讲讲lambda表达式的使用细节,掌握了以后多用,就能够更好地理解了。
|
||||
|
||||
## 使用lambda的注意事项
|
||||
|
||||
要学好用好lambda,我觉得就是三个重点:语法形式,变量捕获规则,还有泛型的用法。
|
||||
|
||||
**1.lambda的形式**
|
||||
|
||||
首先你要知道,C++没有为lambda表达式引入新的关键字,并没有“lambda”这样的词汇,而是用了一个特殊的形式“**[]**”,术语叫“**lambda引出符**”(lambda introducer)。
|
||||
|
||||
在lambda引出符后面,就可以像普通函数那样,用圆括号声明入口参数,用花括号定义函数体。
|
||||
|
||||
下面的代码展示了我最喜欢的一个lambda表达式(也是最简单的):
|
||||
|
||||
```
|
||||
auto f1 = [](){}; // 相当于空函数,什么也不做
|
||||
|
||||
```
|
||||
|
||||
这行语句定义了一个相当于空函数的lambda表达式,三个括号“排排坐”,看起来有种奇特的美感,让人不由得想起那句经典台词:“一家人最要紧的就是整整齐齐。”(不过还是差了个尖括号<>)。
|
||||
|
||||
当然了,实际开发中不会有这么简单的lambda表达式,它的函数体里可能会有很多语句,所以一定**要有良好的缩进格式**——特别是有嵌套定义的时候,尽量让人能够一眼就看出lambda表达式的开始和结束,必要的时候可以用注释来强调。
|
||||
|
||||
```
|
||||
auto f2 = []() // 定义一个lambda表达式
|
||||
{
|
||||
cout << "lambda f2" << endl;
|
||||
|
||||
auto f3 = [](int x) // 嵌套定义lambda表达式
|
||||
{
|
||||
return x*x;
|
||||
};// lambda f3 // 使用注释显式说明表达式结束
|
||||
|
||||
cout << f3(10) << endl;
|
||||
}; // lambda f2 // 使用注释显式说明表达式结束
|
||||
|
||||
```
|
||||
|
||||
你可能注意到了,在lambda表达式赋值的时候,我总是使用auto来推导类型。这是因为,在C++里,每个lambda表达式都会有一个独特的类型,而这个类型只有编译器才知道,我们是无法直接写出来的,所以必须用auto。
|
||||
|
||||
不过,因为lambda表达式毕竟不是普通的变量,所以C++也鼓励程序员**尽量“匿名”使用lambda表达式**。也就是说,它不必显式赋值给一个有名字的变量,直接声明就能用,免去你费力起名的烦恼。
|
||||
|
||||
这样不仅可以让代码更简洁,而且因为“匿名”,lambda表达式调用完后也就不存在了(也有被拷贝保存的可能),这就最小化了它的影响范围,让代码更加安全。
|
||||
|
||||
```
|
||||
vector<int> v = {3, 1, 8, 5, 0}; // 标准容器
|
||||
|
||||
cout << *find_if(begin(v), end(v), // 标准库里的查找算法
|
||||
[](int x) // 匿名lambda表达式,不需要auto赋值
|
||||
{
|
||||
return x >= 5; // 用做算法的谓词判断条件
|
||||
} // lambda表达式结束
|
||||
)
|
||||
<< endl; // 语句执行完,lambda表达式就不存在了
|
||||
|
||||
```
|
||||
|
||||
**2.lambda的变量捕获**
|
||||
|
||||
lambda的“捕获”功能需要在“[]”里做文章,由于实际的规则太多太细,记忆、理解的成本高,所以我只说几个要点,帮你快速掌握它们:
|
||||
|
||||
- “[=]”表示按值捕获所有外部变量,表达式内部是值的拷贝,并且不能修改;
|
||||
- “[&]”是按引用捕获所有外部变量,内部以引用的方式使用,可以修改;
|
||||
- 你也可以在“[]”里明确写出外部变量名,指定按值或者按引用捕获,C++在这里给予了非常大的灵活性。
|
||||
|
||||
```
|
||||
int x = 33; // 一个外部变量
|
||||
|
||||
auto f1 = [=]() // lambda表达式,用“=”按值捕获
|
||||
{
|
||||
//x += 10; // x只读,不允许修改
|
||||
};
|
||||
|
||||
auto f2 = [&]() // lambda表达式,用“&”按引用捕获
|
||||
{
|
||||
x += 10; // x是引用,可以修改
|
||||
};
|
||||
|
||||
auto f3 = [=, &x]() // lambda表达式,用“&”按引用捕获x,其他的按值捕获
|
||||
{
|
||||
x += 20; // x是引用,可以修改
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
“捕获”也是使用lambda表达式的一个难点,关键是要理解“**外部变量**”的含义。
|
||||
|
||||
我建议,你可以简单地按照其他语言的习惯,称之为“**upvalue**”,也就是在lambda表达式定义之前所有出现的变量,不管它是局部的还是全局的。
|
||||
|
||||
这就有一个变量生命周期的问题。
|
||||
|
||||
使用“[=]”按值捕获的时候,lambda表达式使用的是变量的独立副本,非常安全。而使用“[&]”的方式捕获引用就存在风险,当lambda表达式在离定义点“很远的地方”被调用的时候,引用的变量可能发生了变化,甚至可能会失效,导致难以预料的后果。
|
||||
|
||||
所以,我建议你在使用捕获功能的时候要小心,对于“就地”使用的小lambda表达式,可以用“[&]”来减少代码量,保持整洁;而对于非本地调用、生命周期较长的lambda表达式应慎用“[&]”捕获引用,而且,最好是在“[]”里显式写出变量列表,避免捕获不必要的变量。
|
||||
|
||||
```
|
||||
class DemoLambda final
|
||||
{
|
||||
private:
|
||||
int x = 0;
|
||||
public:
|
||||
auto print() // 返回一个lambda表达式供外部使用
|
||||
{
|
||||
return [this]() // 显式捕获this指针
|
||||
{
|
||||
cout << "member = " << x << endl;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
**3.泛型的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
|
||||
|
||||
```
|
||||
|
||||
这个新特性在写泛型函数的时候非常方便,摆脱了冗长的模板参数和函数参数列表。如果你愿意的话,可以尝试在今后的代码里都使用lambda来代替普通函数,能够少写很多代码。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我讲了lambda表达式。它不仅仅是对旧有函数对象的简单升级,而是更高级的“闭包”,给C++带来了新的编程理念:函数式编程范式。
|
||||
|
||||
在C语言里,函数是一个“静止”的代码块,只能被动地接受输入然后输出。而lambda的出现则让函数“活”了起来,极大地提升了函数的地位和灵活性。
|
||||
|
||||
比照“智能指针”的说法,lambda完全可以称为是“智能函数”,价值体现在就地定义、变量捕获等能力上,它也给C++的算法、并发(线程、协程)等后续发展方向铺平了道路,在后面讲标准库的时候,我们还会多次遇到它。
|
||||
|
||||
虽然目前在C++里,纯函数式编程还比较少见,但“轻度”使用lambda表达式也能够改善代码,比如用“map+lambda”的方式来替换难以维护的if/else/switch,可读性要比大量的分支语句好得多。
|
||||
|
||||
小结一下今天的要点内容:
|
||||
|
||||
1. lambda表达式是一个闭包,能够像函数一样被调用,像变量一样被传递;
|
||||
1. 可以使用auto自动推导类型存储lambda表达式,但C++鼓励尽量就地匿名使用,缩小作用域;
|
||||
1. lambda表达式使用“[=]”的方式按值捕获,使用“[&]”的方式按引用捕获,空的“[]”则是无捕获(也就相当于普通函数);
|
||||
1. 捕获引用时必须要注意外部变量的生命周期,防止变量失效;
|
||||
1. C++14里可以使用泛型的lambda表达式,相当于简化的模板函数。
|
||||
|
||||
末了我再说一句,和C++里的大多数新特性一样,滥用lambda表达式的话,就会产生一些难以阅读的代码,比如多个函数的嵌套和串联、调用层次过深。这也需要你在实践中慢慢积累经验,找到最适合你自己的使用方式。
|
||||
|
||||
## 课下作业
|
||||
|
||||
最后是课下作业时间,给你留两个思考题吧:
|
||||
|
||||
1. 你对函数式编程有什么样的理解和认识呢?
|
||||
1. lambda表达式的形式非常简洁,可以在很多地方代替普通函数,那它能不能代替类的成员函数呢?为什么?
|
||||
|
||||
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/0d/5ac283e096d87e582fed017597ba4e0d.jpg" alt="">
|
||||
Reference in New Issue
Block a user