Lambda 表达式
Lambda 表达式是现代 C++ 中最重要的特性之一,而 Lambda 表达式,实际上就是提供了一个类似匿名函数的特性, 而匿名函数则是在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的。这样的场景其实有很多很多, 所以匿名函数几乎是现代编程语言的标配。
基础
Lambda 表达式的基本语法如下:
|
|
上面的语法规则除了 [捕获列表]
内的东西外,其他部分都很好理解,只是一般函数的函数名被略去, 返回值使用了一个 ->
的形式进行(我们在上一节前面的尾返回类型已经提到过这种写法了)。
所谓捕获列表,其实可以理解为参数的一种类型,lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的, 这时候捕获列表可以起到传递外部数据的作用。根据传递的行为,捕获列表也分为以下几种:
值捕获
与参数传值类似,值捕获的前提是变量可以拷贝,不同之处则在于,被捕获的变量在 lambda 表达式被创建时拷贝,而非调用时才拷贝:
|
|
引用捕获
与引用传参类似,引用捕获保存的是引用,值会发生变化。
|
|
隐式捕获
手动书写捕获列表有时候是非常复杂的,这种机械性的工作可以交给编译器来处理,这时候可以在捕获列表中写一个 &
或 =
向编译器声明采用引用捕获或者值捕获.
总结一下,捕获提供了 lambda 表达式对外部值进行使用的功能,捕获列表的最常用的四种形式可以是:
[]
空捕获列表[name1, name2, …]
捕获一系列变量[&]
引用捕获, 让编译器自行推导捕获列表[=]
值捕获, 让编译器执行推导引用列表
表达式捕获
这部分内容需要了解后面马上要提到的右值引用以及智能指针
上面提到的值捕获、引用捕获都是已经在外层作用域声明的变量,因此这些捕获方式捕获的均为左值,而不能捕获右值。
C++14 给与了我们方便,允许捕获的成员用任意的表达式进行初始化,这就允许了右值的捕获, 被声明的捕获变量类型会根据表达式进行判断,判断方式与使用 auto
本质上是相同的:
|
|
在上面的代码中,important
是一个独占指针,是不能够被捕获到的,这时候我们需要将其转移为右值, 在表达式中初始化。
泛型 Lambda
上一节中我们提到了 auto
关键字不能够用在参数表里,这是因为这样的写法会与模板的功能产生冲突。 但是 Lambda 表达式并不是普通函数,所以 Lambda 表达式并不能够模板化。 这就为我们造成了一定程度上的麻烦:参数表不能够泛化,必须明确参数表类型。
幸运的是,这种麻烦只存在于 C++11 中,从 C++14 开始, Lambda 函数的形式参数可以使用 auto
关键字来产生意义上的泛型:
|
|
函数对象包装器
这部分内容虽然属于标准库的一部分,但是从本质上来看,它却增强了 C++ 语言运行时的能力, 这部分内容也相当重要,所以放到这里来进行介绍。
std::function
Lambda 表达式的本质是一个和函数对象类型相似的类类型(称为闭包类型)的对象(称为闭包对象), 当 Lambda 表达式的捕获列表为空时,闭包对象还能够转换为函数指针值进行传递,例如:
|
|
上面的代码给出了两种不同的调用形式,一种是将 Lambda 作为函数类型传递进行调用, 而另一种则是直接调用 Lambda 表达式,在 C++11 中,统一了这些概念,将能够被调用的对象的类型, 统一称之为可调用类型。而这种类型,便是通过 std::function
引入的。
C++11 std::function
是一种通用、多态的函数封装, 它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作, 它也是对 C++ 中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的), 换句话说,就是函数的容器。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理。 例如:
|
|
函数对象 在C++中,函数对象是一种特殊的对象类型,它可以像函数一样被调用。函数对象可以使用
()
运算符来调用,就像调用函数一样。函数对象可以是函数指针、成员函数指针、重载了operator()
运算符的类对象等等。 以下是一些常见的函数对象类型:
- 函数指针:函数指针就是指向函数的指针,它指向函数代码的入口地址,可以像函数一样被调用。
- 成员函数指针:成员函数指针是指向类成员函数的指针,它需要指定类的类型、函数名和参数列表来定义,可以通过指针来调用它所指向的成员函数。
- 仿函数:仿函数是一个类对象,它重载了
operator()
运算符。可以定义函数对象的操作,这些操作可能需要保存一些状态或者执行一些与函数相似的操作。STL中的许多算法和容器都可以接受仿函数作为参数,例如std::sort()
、std::transform()
等等。- Lambda表达式:Lambda表达式是一种定义函数对象的匿名函数,可以在代码内部直接定义一个函数对象,而不需要显式地定义一个单独的类型。Lambda表达式可以在其他函数的作用域中使用,以及在其他容器中使用,这使得它们非常方便和灵活。 这些都是常见的函数对象类型,它们共同构成了C++中强大的函数式编程能力的基础。
函数对象和std::function的关系和区别? 函数对象和
std::function
有密切的关系,但它们又有着一些区别: 1. 函数对象和std::function
都可以被当做函数来调用。 2. 函数对象是一个类对象,它必须重载()
运算符,而std::function
是一个模板类,它是对可调用对象的容器封装。 3. 函数对象需要手动创建,而std::function
可以通过函数指针、成员函数指针、Lambda表达式等等来创建,从而提高了通用性和灵活性。 4.std::function
可以持有不同类型的可调用对象,而函数对象只能持有一种类型的可调用对象。 5.std::function
是一种运行时的概念,而函数对象是一个编译时的概念。 需要注意的是,使用std::function
可能会带来一定的性能损失,因为它需要执行一些额外的操作来对各种可调用对象进行管理,所以在不需要使用它的情况下,应该优先考虑直接使用函数指针或者函数对象。 总的来说,函数对象和std::function
都是可调用对象的概念,它们共同构成了C++中强大的函数式编程能力的基础,可调用类型是一个广义的概念,包括多种可调用类型,函数对象则是一种特殊的可调用类型,而std::function
则是一个通用的可调用对象的封装器,可以方便地管理各种类型的可调用对象。std::function
则更加通用和灵活,可以持有不同类型的可调用对象,并且可以将其作为模板参数传递给其他类或函数,使得程序设计更加高效和优雅。
std::bind 和 std::placeholder
而 std::bind
则是用来绑定函数调用的参数的, 它解决的需求是我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数, 我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。 例如:
|
|
**提示:**注意
auto
关键字的妙用。有时候我们可能不太熟悉一个函数的返回值类型, 但是我们却可以通过auto
的使用来规避这一问题的出现。
右值引用
右值引用是 C++11 引入的与 Lambda 表达式齐名的重要特性之一。它的引入解决了 C++ 中大量的历史遗留问题, 消除了诸如 std::vector
、std::string
之类的额外开销, 也才使得函数对象容器 std::function
成为了可能。
左值、右值的纯右值、将亡值、右值
要弄明白右值引用到底是怎么一回事,必须要对左值和右值做一个明确的理解。
左值 (lvalue, left value),顾名思义就是赋值符号左边的值。准确来说, 左值是表达式(不一定是赋值表达式)后依然存在的持久对象。
右值 (rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。
简而言之 有名字就是左值(可以取地址)
而 C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。
纯右值 (prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10
, true
; 要么是求值结果相当于字面量或匿名临时对象,例如 1+2
。(1)非引用返回的临时变量、(2)运算表达式产生的临时变量、 (3)原始字面量、(4)Lambda 表达式都属于纯右值。
将亡值 (xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念(因此在传统 C++ 中, 纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值。
将亡值可能稍有些难以理解,我们来看这样的代码:
|
|
在这样的代码中,就传统的理解而言,函数 foo
的返回值 temp
在内部创建然后被赋值给 v
, 然而 v
获得这个对象时,会将整个 temp 拷贝一份,然后把 temp
销毁,如果这个 temp
非常大, 这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中,v
是左值、 foo()
返回的值就是右值(也是纯右值)。但是,v
可以被别的变量捕获到, 而 foo()
产生的那个返回值作为一个临时值,一旦被 v
复制后,将立即被销毁,无法获取、也不能修改。 而将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。
在 C++11 之后,编译器为我们做了一些工作,此处的左值 temp
会被进行此隐式右值转换, 等价于 static_cast<std::vector<int> &&>(temp)
,进而此处的 v
会将 foo
局部返回的值进行移动。 也就是后面我们将会提到的移动语义。
右值引用和左值引用
需要拿到一个将亡值,就需要用到右值引用的申明:T &&
,其中 T
是类型。 右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。
C++11 提供了 std::move
这个方法将左值参数无条件的转换为右值, 有了它我们就能够方便的获得一个右值临时对象,例如:
|
|
从内存的角度看,究竟发生了什么?
在执行 std::move(lv1)
操作时,实际上并没有发生内存拷贝,std::move
本质上是将 lv1
转换为右值引用,让 rv1
引用到了 lv1
的底层字符串缓冲区,而 lv1
的值处于一个未定义的状态,它的内容不确定,调用它的操作可能会产生未定义的行为。
从内存的角度来看,std::string
对象实际上由两部分组成:一个控制块和一个缓冲区。控制块包含字符串长度、引用计数等信息,而缓冲区则用于存放字符串内容。在执行 std::move(lv1)
操作时, lv1
的控制块和缓冲区并没有发生拷贝,只是将控制块中的指针传递给了 rv1
,这就相当于 rv1
成为了原先 lv1
所拥有的字符串缓冲区的新主人, lv1
则成为了一个空的对象。
需要注意的是,由于控制块中存在引用计数等信息,因此在执行 std::move
操作后,lv1
所指向的对象并不会被立即销毁,只有当引用计数为零时,该对象的内存才能被释放。因此,如果在移动后仍然需要使用原先的对象,就需要在移动后重新给 lv1
赋值,否则就应该慎重使用 lv1
。
如果编译后 lv2
是 "string,string"
,那么可能是编译器对 std::string
类型的对象做了一些优化,例如小字符串优化。这种优化方式会在 std::string
对象非常小时,将其数据存储在对象本身的空间内,而不是使用动态分配的内存。这样可以避免使用动态内存分配和释放所带来的开销,提高程序的性能。
rv2
虽然引用了一个右值,但由于它是一个引用,所以 rv2
依然是一个左值。
注意,这里有一个很有趣的历史遗留问题,我们先看下面的代码:
|
|
第一个问题,为什么不允许非常量引用绑定到非左值?这是因为这种做法存在逻辑错误:
|
|
由于 int&
不能引用 double
类型的参数,因此必须产生一个临时值来保存 s
的值, 从而当 increase()
修改这个临时值时,从而调用完成后 s
本身并没有被修改。
第二个问题,为什么常量引用允许绑定到非左值?原因很简单,因为 Fortran 需要。
移动语义
传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝 / 复制的概念,但为了实现对资源的移动操作, 调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。 试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、 再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。
传统的 C++ 没有区分『移动』和『拷贝』的概念,造成了大量的数据拷贝,浪费时间和空间。 右值引用的出现恰好就解决了这两个概念的混淆问题,例如:
|
|
在上面的代码中:
- 首先会在
return_rvalue
内部构造两个A
对象,于是获得两个构造函数的输出; - 函数返回后,产生一个将亡值,被
A
的移动构造(A(A&&)
)引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了obj
中,而将亡值的指针被设置为nullptr
,防止了这块内存区域被销毁。
从而避免了无意义的拷贝构造,加强了性能。再来看看涉及标准库的例子:
|
|
完美转发
前面我们提到了,一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:
|
|
对于 pass(1)
来说,虽然传递的是右值,但由于 v
是一个引用,所以同时也是左值。 因此 reference(v)
会调用 reference(int&)
,输出『左值』。 而对于pass(l)
而言,l
是一个左值,为什么会成功传递给 pass(T&&)
呢?
这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用, 但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用, 既能左引用,又能右引用。但是却遵循如下规则:
因此,模板函数中使用 T&&
不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。 更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。 这才使得 v
作为左值的成功传递。
完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候, 保持原来的参数类型(左引用保持左引用,右引用保持右引用)。 为了解决这个问题,我们应该使用 std::forward
来进行参数的转发(传递):
|
|
输出结果为:
|
|
无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发, 所以 std::move
总会接受到一个左值,从而转发调用了reference(int&&)
输出右值引用。
唯独 std::forward
即没有造成任何多余的拷贝,同时完美转发 (传递) 了函数的实参给了内部调用的其他函数。
std::forward
和 std::move
一样,没有做任何事情,std::move
单纯的将左值转化为右值, std::forward
也只是单纯的将参数做了一个类型的转换,从现象上来看, std::forward<T>(v)
和 static_cast<T&&>(v)
是完全一样的。
读者可能会好奇,为何一条语句能够针对两种类型的返回对应的值, 我们再简单看一看 std::forward
的具体实现机制,std::forward
包含两个重载:
|
|
在这份实现中,std::remove_reference
的功能是消除类型中的引用, 而 std::is_lvalue_reference
用于检查类型推导是否正确,在 std::forward
的第二个实现中 检查了接收到的值确实是一个左值,进而体现了坍缩规则。
当 std::forward
接受左值时,_Tp
被推导为左值,而所以返回值为左值;而当其接受右值时, _Tp
被推导为 右值引用,则基于坍缩规则,返回值便成为了 && + &&
的右值。 可见 std::forward
的原理在于巧妙的利用了模板类型推导中产生的差异。
这时我们能回答这样一个问题:为什么在使用循环语句的过程中,auto&&
是最安全的方式? 因为当 auto
被推到为不同的左右引用时,与 &&
的坍缩组合是完美转发。