3.1 Lambda Expression
Lambda表达式是现代c++中最重要的特性之一,而Lambda表达式实际上提供了一个类似于匿名函数的特性。匿名函数在需要函数时使用,但您不希望使用名称调用函数。实际上有很多很多这样的场景。所以匿名函数几乎是现代编程语言的标准。
3.1.1 Basic
Lambda表达式的基本语法如下:
[capture list] (parameter list) mutable(optional) exception attribute -> return type {
// function body
}
除了[capture list]中的内容外,以上语法规则都很好理解,只是通用函数的函数名被省略了。返回值的形式是->(我们在前一节的尾部返回类型中已经提到过这一点)。
所谓的捕获列表可以理解为一种参数类型。默认情况下,lambda表达式的内部函数体不能使用函数体之外的变量。在这时,捕获列表可以用于传输外部数据。根据所通过的行为,捕获列表还分为以下几种类型:
- Value capture 与参数传递类似,值捕获基于变量可以被复制的事实,除了捕获的变量是在创建lambda表达式时复制的,而不是在调用时复制:
void lambda_value_capture() {
int value = 1;
auto copy_value = [value] {
return value;
};
value = 100;
auto stored_value = copy_value();
std::cout << "stored_value = " << stored_value << std::endl;
// At this moment, stored_value == 1, and value == 100.
// Because copy_value has copied when its was created.
}
- Reference capture 与引用传递类似,引用捕获保存引用和值的更改。
void lambda_reference_capture() {
int value = 1;
auto copy_value = [&value] {
return value;
};
value = 100;
auto stored_value = copy_value();
std::cout << "stored_value = " << stored_value << std::endl;
// At this moment, stored_value == 100, value == 100.
// Because copy_value stores reference
}
-
Implicit capture 手动编写捕获列表有时非常复杂。这种机械工作可以由编译器处理。此时,您可以向编译器编写&或=来声明引用或值捕获。总而言之,capture为lambda表达式提供了使用外部值的能力。四种最常见的捕获列表形式是:
- [] 空的捕获列表
- [name1, name2,…]捕获一系列变量
- [&]引用捕获,让编译器自己派生捕获列表
- 值捕获,让编译器执行派生应用程序列表
-
Expression capture
本节需要理解右值引用和智能指针,这些将在后面提到。
上面提到的值捕获和引用捕获是在外部范围中声明的变量,因此这些捕获方法捕获左值而不捕获右值。
c++ 14为我们提供了方便,允许用任意表达式初始化捕获的成员,这允许捕获右值。根据表达式判断被声明的捕获变量的类型,该判断与使用auto相同。
void lambda_expression_capture() {
auto important = std::make_unique<int>(1);
auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int {
return x+y+v1+(*v2);
};
std::cout << add(3,4) << std::endl;
}
在上面的代码中,important是一个无法捕获的独占指针。此时,我们需要将其转换为右值并在表达式中对其进行初始化。
3.1.2 Generic Lambda
在上一节中,我们提到auto关键字不能在参数列表中使用,因为它会与模板的功能冲突。但是Lambda表达式不是普通的函数,所以Lambda表达式没有模板化。这给我们带来了一些麻烦:参数表不能一般化,必须明确参数表的类型。
幸运的是,这个问题只存在于c++ 11中,从c++ 14开始。Lambda函数的形式参数可以使用auto关键字生成通用意义:
void lambda_generic() {
auto generic = [](auto x, auto y) {
return x+y;
};
std::cout << generic(1, 2) << std::endl;
std::cout << generic(1.1, 2.2) << std::endl;
}
3.2 Function Object Wrapper
尽管这些特性是标准库的一部分,并且在运行时中找不到,但它增强了c++语言的运行时功能。这部分内容也很重要,所以放在这里作为介绍。
3.2.1 std::function
Lambda表达式的本质是类类型(称为闭包类型)的对象,它类似于函数对象类型(称为闭包对象)。当Lambda表达式的捕获列表为空时,闭包对象也可以转换为函数指针值以进行传递。例如:
#include <iostream>
using foo = void(int); // function pointer
void functional(foo f) {
f(1);
}
int main() {
auto f = [](int value) {
std::cout << value << std::endl;
};
functional(f); // call by function pointer
f(1); // call by lambda expression
return 0;
}
上面的代码给出了两种不同的调用形式,一种是作为函数类型调用Lambda,另一种是直接调用Lambda表达式。在c++ 11中,这些概念是统一的。可以调用的对象类型统称为可调用类型。这个类型是由std::function引入的。
function是一个通用的多态函数包装器,它的实例可以存储、复制和调用任何可以调用的目标实体。它本身也是一个现有的可调用的对象。它是一个类型安全的实体包(相对而言,对函数指针的调用不是类型安全的),换句话说,一个函数的容器。当我们有一个函数容器时,我们可以更容易地将函数和函数指针作为对象处理。
#include <functional>
#include <iostream>
int foo(int para) {
return para;
}
int main() {
// std::function wraps a function that take int paremeter and returns int value
std::function<int(int)> func = foo;
int important = 10;
std::function<int(int)> func2 = [&](int value) -> int {
return 1+value+important;
};
std::cout << func(10) << std::endl;
std::cout << func2(10) << std::endl;
}
3.2.2 std::bind and std::placeholder
和std::bind用于绑定函数调用的参数。它解决了我们不可能每次都能得到一个函数的所有参数的要求。通过这个函数,我们可以将部分调用参数预先绑定到函数,成为一个新的对象,然后在参数完成后完成调用。
int foo(int a, int b, int c) {
;
}
int main() {
// bind parameter 1, 2 on function foo, and use std::placeholders::_1 as placeholder
// for the first parameter.
auto bindFoo = std::bind(foo, std::placeholders::_1, 1,2);
// when call bindFoo, we only need one param left
bindFoo(1);
}
提示:注意auto关键字的魔力。有时我们可能不熟悉函数的返回类型,但我们可以通过使用auto来绕过这个问题。
3.3 rvalue reference
右值引用与Lambda表达式一起是c++ 11引入的重要特性之一。它的引入解决了c++中大量的历史问题。消除多余的开销,如std::vector, std::string,使函数对象容器std::函数成为可能。
3.3.1 lvalue, rvalue, prvalue, xvalue
要理解右值引用是关于什么的,您必须清楚地理解左值和右值。
顾名思义,左值是赋值符号左边的值。确切地说,左值是在表达式(不一定是赋值表达式)之后仍然存在的持久对象。
右值,右值,右边的值指的是表达式结束后不再存在的临时对象。在c++ 11中,为了引入强大的右值引用,右值的概念被进一步划分为:prvalue和xvalue。
pvalue,纯右值,纯右值,或者纯文字,比如10,true;求值的结果要么相当于一个文字或匿名临时对象(例如1+2)、非引用返回的临时变量、操作表达式生成的临时变量、原始文本和Lambda表达式都是纯右值。
注意,一个字符串在类中变成了右值,在其他情况下仍然是左值(例如,在函数中):
class Foo {
const char*&& right = "this is a rvalue";
public:
void bar() {
right = "still rvalue"; // the string literal is a rvalue
}
};
int main() {
const char* const &left = "this is an lvalue"; // the string literal is an lvalue
}
xvalue, expiring value,过期值是c++ 11提出的引入右值引用的概念(所以在传统c++中,纯右值和右值是同一个概念),一个被破坏但可以移动的值。
这将是有点难以理解的xvalue,让我们看看这样的代码:
std::vector<int> foo() {
std::vector<int> temp = {1, 2, 3, 4};
return temp;
}
std::vector<int> v = foo();
在这样的代码中,就传统的理解而言,函数foo的返回值temp被内部创建,然后赋值给v,而当v得到这个对象时,整个temp被复制。然后销毁temp,如果这个temp非常大,这将导致很多额外的开销(这是传统c++一直被批评的问题)。在最后一行中,v是左值,foo()返回的值是右值(也是纯右值)。但是,v可以被其他变量捕获,foo()生成的返回值被用作临时值。一旦被v复制,它将立即被销毁,无法获得或修改。xvalue定义了一种行为,在这种行为中,可以在移动临时值的同时识别临时值。
在c++ 11之后,编译器为我们做了一些工作,其中lvalue temp被进行了隐式的右值转换,相当于static_cast<std::vector &&>(temp),这里v将foo返回的值移动到本地。这就是我们稍后将提到的move语义。
3.3.2 rvalue reference and lvalue reference
要获得一个xvalue,您需要使用右值引用的声明:T &&,其中T是类型。rvalue引用的语句扩展了这个临时值的生命周期,只要变量是活动的,xvalue就会继续存在。
c++ 11提供了std::move方法来无条件地将左值参数转换为右值。有了它,我们可以很容易地获得一个rvalue临时对象,例如:
#include <iostream>
#include <string>
void reference(std::string& str) {
std::cout << "lvalue" << std::endl;
}
void reference(std::string&& str) {
std::cout << "rvalue" << std::endl;
}
int main()
{
std::string lv1 = "string,"; // lv1 is a lvalue
// std::string&& r1 = s1; // illegal, rvalue can't ref to lvalue
std::string&& rv1 = std::move(lv1); // legal, std::move can convert lvalue to rvalue
std::cout << rv1 << std::endl; // string,
const std::string& lv2 = lv1 + lv1; // legal, const lvalue reference can extend temp variable's lifecycle
// lv2 += "Test"; // illegal, const ref can't be modified
std::cout << lv2 << std::endl; // string,string
std::string&& rv2 = lv1 + lv2; // legal, rvalue ref extend lifecycle
rv2 += "string"; // legal, non-const reference can be modified
std::cout << rv2 << std::endl; // string,string,string,string
reference(rv2); // output: lvalue
return 0;
}
rv2指的是一个右值,但因为它是一个引用,所以rv2仍然是一个左值。
注意这里有一个非常有趣的历史问题,让我们看看下面的代码:
#include <iostream>
int main() {
// int &a = std::move(1); // illegal, non-const lvalue reference cannot ref rvalue
const int &b = std::move(1); // legal, const lvalue reference can
std::cout << b << std::endl;
}
第一个问题,为什么不允许非线性引用绑定到非左值?这是因为这种方法存在逻辑错误:
void increase(int & v) {
v++;
}
void foo() {
double s = 1;
increase(s);
}
因为int&不能引用double类型的参数,所以必须生成一个临时值来保存s的值。因此,当increase()修改这个临时值时,在调用完成后,s本身不会被修改。
第二个问题,为什么常量引用允许绑定到非左值?原因很简单,因为Fortran需要它。
3.3.3 Move semantics
传统c++通过复制构造函数和赋值操作符为类对象设计了复制/复制的概念,但是为了实现资源的移动,调用者必须先使用复制然后析构的方法,否则你需要自己实现移动对象的接口。想象一下,把你的家直接搬到你的新家,而不是复制(重新购买)到你的新家。扔掉(毁坏)所有原始的东西是非常反人类的行为。
传统c++没有区分“移动”和“复制”的概念,导致大量的数据复制,浪费时间和空间。右值引用的出现解决了这两个概念的混淆,例如:
#include <iostream>
class A {
public:
int *pointer;
A():pointer(new int(1)) {
std::cout << "construct" << pointer << std::endl;
}
A(A& a):pointer(new int(*a.pointer)) {
std::cout << "copy" << pointer << std::endl;
} // meaningless object copy
A (A&& a):pointer(a.pointer) {
a.pointer = nullptr;
std::cout << "move" << pointer << std::endl;
}
~A(){
std::cout << "destruct" << pointer << std::endl;
delete pointer;
}
};
// avoid compiler optimization
A return_rvalue(bool test) {
A a,b;
if(test) return a; // equal to static_cast<A&&>(a);
else return b; // equal to static_cast<A&&>(b);
}
int main() {
A obj = return_rvalue(false);
std::cout << "obj:" << std::endl;
std::cout << obj.pointer << std::endl;
std::cout << *obj.pointer << std::endl;
return 0;
}
construct0x31498
construct0x314c8
move0x314c8
destruct0
destruct0x31498
obj:
0x314c8
1
destruct0x314c8
在上述代码中:
- 首先在return_rvalue内部构造两个A对象,并获取两个构造函数的输出;
- 在函数返回后,它将生成一个xvalue,该xvalue被A的移动结构引用A(A&&),从而扩展生命周期,并在rvalue中获取指针并保存到obj。在中间,指向xvalue的指针被设置为nullptr,这可以防止内存区域被破坏。
这样可以避免无意义的复制构造并提高性能。让我们来看一个涉及到标准库的例子:
#include <iostream> // std::cout
#include <utility> // std::move
#include <vector> // std::vector
#include <string> // std::string
int main() {
std::string str = "Hello world.";
std::vector<std::string> v;
// use push_back(const T&), copy
v.push_back(str);
// "str: Hello world."
std::cout << "str: " << str << std::endl;
// use push_back(const T&&), no copy
// the string will be moved to vector, and therefore std::move can reduce copy cost
v.push_back(std::move(str));
// str is empty now
std::cout << "str: " << str << std::endl;
return 0;
}
3.3.4 Perfect forwarding
如前所述,声明的右值引用实际上是左值。这给我们带来了参数化(传递)的问题:
#include <iostream>
#include <utility>
void reference(int& v) {
std::cout << "lvalue reference" << std::endl;
}
void reference(int&& v) {
std::cout << "rvalue reference" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << " normal param passing: ";
reference(v);
}
int main() {
std::cout << "rvalue pass:" << std::endl;
pass(1);
std::cout << "lvalue pass:" << std::endl;
int l = 1;
pass(l);
return 0;
}
rvalue pass:
normal param passing: lvalue reference
lvalue pass:
normal param passing: lvalue reference
对于pass(1),虽然值是右值,但由于v是一个引用,所以它也是一个左值。因此,reference(v)将调用reference(int&)并输出左值。对于pass(l), l是一个左值,为什么它被成功传递给pass(T&&)?
这是基于引用崩溃规则:在传统的c++中,我们不能继续引用一个引用类型。然而,随着右值引用的出现,c++放宽了这种做法,产生了一个引用折叠规则,允许我们引用引用,左值和右值。但请遵循以下规则:
Function parameter | Argument parameter type | Post-derivation function parameter type |
---|---|---|
T& | lvalue ref | T& |
T& | rvalue ref | T& |
T&& | lvalue ref | T& |
T&& | rvalue ref | T&& |
因此,在模板函数中使用T&&可能无法进行右值引用,当传递左值时,对该函数的引用将派生为左值。更准确地说,无论模板参数是什么类型的引用,当且仅当参数类型是正确的引用时,模板参数都可以被派生为正确的引用类型。这使得v成功地交付了lvalue。
完美转发就是基于上述规则。所谓完美转发就是让我们传递参数,保留原始参数类型(左值引用保留左值引用,右值引用保留右值引用)。为了解决这个问题,我们应该使用std::forward来转发(传递)参数:
#include <iostream>
#include <utility>
void reference(int& v) {
std::cout << "lvalue reference" << std::endl;
}
void reference(int&& v) {
std::cout << "rvalue reference" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << " normal param passing: ";
reference(v);
std::cout << " std::move param passing: ";
reference(std::move(v));
std::cout << " std::forward param passing: ";
reference(std::forward<T>(v));
std::cout << "static_cast<T&&> param passing: ";
reference(static_cast<T&&>(v));
}
int main() {
std::cout << "rvalue pass:" << std::endl;
pass(1);
std::cout << "lvalue pass:" << std::endl;
int l = 1;
pass(l);
return 0;
}
rvalue pass:
normal param passing: lvalue reference
std::move param passing: rvalue reference
std::forward param passing: rvalue reference
static_cast<T&&> param passing: rvalue reference
lvalue pass:
normal param passing: lvalue reference
std::move param passing: rvalue reference
std::forward param passing: lvalue reference
static_cast<T&&> param passing: lvalue reference
无论传递参数是左值还是右值,普通的传递参数都将以左值的形式转发参数。所以std::move总是接受一个左值,它将调用转发到reference(int&&)以输出右值引用。
只有std::forward不会导致任何额外拷贝,并且完美地将(传递)函数的参数传递给其他内部调用的函数。
std::forward和std::move一样,什么都不做。move只是将左值转换为右值。std::转发只是一个简单的参数转换。从现象上看,std::forward(v)与static_cast<T&&>(v)是完全相同的。
读者可能会好奇为什么语句可以返回两种类型的返回值。让我们快速看一下std::forward的具体实现。std::forward包含两个重载:
template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }
template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
" substituting _Tp is an lvalue reference type");
return static_cast<_Tp&&>(__t);
}
在这个实现中,std::remove_reference的函数是消除类型中的引用。而std::is_lvalue_reference用于检查类型派生是否正确,在std::forward的第二个实现中检查接收到的值是否确实是左值,这反过来反映了折叠规则。
当std::forward接受左值时,_Tp被泛化派生为左值,因此返回值为左值; 当它接受右值时,_Tp被派生为一个右值引用,并且基于折叠规则,返回值成为&& + &&的右值。可以看出std::forward的原则是巧妙利用模板类型派生中的差异。
至此,我们可以回答这个问题:为什么auto&&是使用循环语句最安全的方法?因为当auto被推到不同的左值和右值引用时,带有&&的折叠组合被完美转发。
3.4 Conclusion
这一章介绍了现代c++中最重要的运行时增强,我相信这一节中提到的所有特性都是值得了解的:
Lambda表达式 函数对象容器std::function 右值引用