



cppinsights是一款基于Clang和LLVM的开源工具,可以帮助C++初学者深入了解和学习C++语言。它可以将C++代码转译为更加易于理解的形式,将编译过程中的类型推导、函数模板实例化、constexpr计算等细节展现出来。使用cppinsights,程序员可以更加清晰地了解C++的各种机制和语言特性,从而提高自己的代码编写和调试能力。
本节将利用cppinsights帮助读者深入研究lambda表达式的实现和原理。
cppinsights的初始界面如图2-1所示。
cppinsights工作界面的左侧为源代码,右侧为编译器生成的代码(左侧源代码的底层实现),底部为运行结果的输出,顶部为相应的属性选择命令,用于选择C++标准等。
图2-1
lambda表达式的计算结果是临时的纯右值。这个临时对象称为闭包对象。
C++11中的每个表达式具备两个独立的属性:类型(分为引用类型和非引用类型,但编译器会将引用类型调整为非引用类型)和值类别(value category)。在C++11之前,值类别只有左值(可用于=操作符左边的表达式)和右值(只能用于=操作符右边的表达式)两种。由于C++11标准引入了右值引用以支持移动操作,C++标准委员会重新定义了表达式的值类型系统,将C++中表达式的值类别定义为3个核心类型和2个组合类型。C++17标准规定3个核心类型为左值、纯右值和将亡值,2个组合类型为广义左值和右值。值类别的分类如图2-2所示。
图2-2
在C++17标准中,图2-2中各个术语的详细解释如下。
● 广义左值(generalized left value,glvalue):一种表达式,其求值的结果决定了对象、位字段或函数(即占有内存的实体)的身份。
● 右值(right value,rvalue):一种表达式,要么为纯右值,要么为将亡值。
● 左值(left value,lvalue):排除将亡值之后的广义左值。
● 将亡值(expiring value,xvalue):自身资源可以被重复使用的对象或者位字段,这通常是因为将亡值的使用寿命即将结束。
● 纯右值(pure right value,prvalue):一种表达式,其求值的结果用于初始化对象或位字段,或者作为内置运算符的操作数的值。
下面通过一些示例代码来进一步理解上述概念。
std::string s;
s // 左值,标识一个对象
s + " qls" // 纯右值,用来初始化对象或计算某个值
std::string q();
std::string& l();
std::string&& s();
q() // 纯右值
l() // 左值
s() // 将亡值
struct Q {
std::string s;
};
Q{}.s // 将亡值,资源可以被重复利用
[](int a) {}; // lambda表达式,纯右值
也许你仍然对值类型的定义感到困惑。下面我们来看看关于这些值类型的名称的由来。
在C++11标准之前,C++中只有左值和右值的概念,并且每个编程者均有如下意识。
● 一个值要么是一个左值,要么是一个右值。
● 一个左值不是一个右值,同理,一个右值也不是一个左值。
但是在制定C++11标准时,C++标准委员会引入了移动语义和右值引用的概念,此时一个表达式的值类型便有如下两个相互正交的属性。
● 有一个身份(identity,又称为标识),例如一个地址、一个指针等,用来保证用户可以比较两个对象是否相同。
● 表达式的求值所产生的对象能够被移动(move),并且这个对象被移动后,其内部状态是一个有效的状态。
“C++之父”本贾尼·斯特劳斯特卢普将上述两个属性分别简称为i和m(推荐阅读他写的文章“‘New’ Value Terminology”)。此外,如果一个值类型没有身份,那么此时这个值类型的身份属性表示为I;同理,如果一个值类型不能被移动,那么这个值类型的移动属性表示为M。通过排列组合,本贾尼·斯特劳斯特卢普归纳出以下3种基本的值类型。
● iM,有一个身份但不能被移动。
● im,有一个身份且能够被移动(例如当一个左值被转换为右值引用的时候)。
● Im,没有身份但能够被移动。
除了上述3种基本的值类型,显而易见,还有以下两种更泛化的值类型。
● i,有一个身份。
● m,可以被移动。
综上所述,这些值类型可以用图2-3来归纳。
图2-3
在对上述各种值类型命名时,本贾尼·斯特劳斯特卢普搜索了标准库中的所有单词,发现值类型iM和人们约定的左值概念基本相同,所以值类型iM便被命名为左值;而值类型m和人们约定的右值概念基本相同,所以值类型m便被命名为右值。
因为值类型i是iM(即左值)的推广,所以i被命名为广义左值。
因为值类型Im只能被移动,但不能被再次引用,所以它是一种特殊的右值,最终被命名为纯右值。
最后还剩下一种值类型im,因为这种值类型没有任何实际的命名约束,所以C++标准委员会选择用“x”来表示它,以表达“中心、未知、奇怪、仅限于专家”等概念,最终被命名为xvalue。
需要注意的是,值类型指的是表达式的属性,而非变量的属性。例如:
float cpp = 5; // cpp并不是一个左值,5是一个纯右值 double cppd = cpp; // 这里cpp是一个左值
一个右值要么是一个纯右值,要么是一个将亡值,那么什么情况下一个右值会是一个将亡值呢?C++17中有一个临时物化(temporary materialization)的概念,也就是纯右值会在一定场景下转换为将亡值(生成一个临时对象),这些场景包括但不限于以下几种。
● 一个纯右值被绑定到一个引用上。
● 访问一个纯右值类的成员。
● 纯右值作为sizeof或typeid的操作数。
可以通过decltype操作符来判断表达式的值类型。假设一个表达式q的类型为type,那么decltype((q))可能产生如下结果。
● 如果q的值类型是纯右值,那么decltype的结果为q的值类型,即type。
● 如果q的值类型是左值,那么decltype的结果为type&。
● 如果q的值类型是将亡值,那么decltype的结果为type&&。
可以使用如下实现来测试相应的表达式的值类型。
struct Foo {
int i{0};
};
if constexpr (std::is_lvalue_reference_v<decltype((Foo{}.i + 1))>) {
std::cout << "表达式 是一个左值\n";
} else if constexpr (std::is_rvalue_reference_v<decltype((Foo{}.i + 1))>) {
std::cout << "表达式 是一个将亡值\n";
} else {
std::cout << "表达式 是一个纯右值\n";
}
回到lambda表达式。lambda表达式的类型(也是闭包对象的类型)是一种唯一的、未命名的非union(联合)类型,称为闭包类型。
lambda表达式分无状态lambda表达式和有状态lambda表达式两种。本节首先探索有状态lambda表达式。有状态lambda表达式的捕获列表会捕获相应的值。有状态lambda表达式的捕获列表有如下6种形式。
● 按值捕获,形如[a]。
● 按引用捕获,形如[&a]。
● 默认捕获,形如[=]。
● 按值和按引用混合捕获,形如[a, &b]。
● 按值捕获模板参数包,形如[packs...]。
● 按引用捕获模板参数包,形如[&packs...]。
lambda表达式可以直接访问全局变量和静态变量,就像捕获这两种变量一样。当lambda表达式捕获全局变量时,有些编译器可能会报错,所以最好不要在lambda表达式的捕获列表中捕获全局变量。对于静态变量的捕获,lambda表达式并不会产生其副本,相当于未捕获,所以最好也不要在lambda表达式的捕获列表中捕获静态变量。
例如以下测试用例:
int a{0};
int main() {
static int b{0};
[a, b]() mutable {
++a;
b+=2;
return;
}();
std::cout << "a: " << a << "\n";
std::cout << "b: " << b << "\n";
return 0;
}
lambda表达式分别捕获全局变量和静态变量,GCC会发出警告,其警告信息如下所示:
warning: capture of variable 'a' with non-automatic storage duration
7 | auto task = [a, b]() mutable {
| ^
note: 'int a' declared here
3 | int a{0};
| ^
warning: capture of variable 'b' with non-automatic storage duration
7 | auto task = [a, b]() mutable {
| ^
note: 'int b' declared here
6 | static int b{0};
程序输出结果如下:
a: 1 b: 2
此外,与lambda表达式关联的闭包类型具有已删除的默认构造函数和已删除的复制赋值运算符。
通过cppinsights可知,auto lambdaA = [](int a) { return a; }会被编译器转换为如下形式。
class __lambda_2_18
{
public:
inline /*constexpr */int operator()(int a) const
{
return a;
}
using retType_2_18 = int (*)(int);
inline constexpr operator retType_2_18() const noexcept
{
return __invoke;
}
private:
static inline /*constexpr */int __invoke(int a)
{
return __lambda_2_18{}.operator()(a);
}
};
由上可进一步得知,GCC会将lambda表达式转换为一个类,类名形如__lambda_2_18(具体的命名规则可参考第8章)。因为表达式lambdaA为无状态表达式,所以其内部会生成一个静态成员函数。无状态lambda表达式可以转换为函数指针,例如如下测试代码:
int (*p)(int) = lambdaA;
通过cppinsights可知,上述代码会被转换为如下形式。
__lambda_2_18 lambdaA = __lambda_2_18{};
lambdaA.operator()(1);
using FuncPtr_6= int(*)(int);
FuncPtr_6 p = static_cast<int (*)(int)>(lambdaA.operator __1ambda_2_18::
retType_2_18());
当我们将表达式lambdaA更改为有状态表达式时,如下所示:
int a{0};
auto lambdaA = [a](int) { return a; };
通过cppinsights可知,编译器会将上述代码转换为如下形式。
class __lambda_3_18
(
public:
inline /*constexpr */int operator()(int b) const
{
return a;
}
private:
int a;
public:
__lambda_3_18(int & _a)
: a{_a}
{}
};
由此可知,当按值捕获局部变量,即在lambda表达式的捕获列表中直接捕获相应的局部变量时,编译器会在生成的闭包类中生成相应的成员变量,并在闭包类的构造函数中初始化相应的成员变量。对于有状态lambda表达式,因为编译器没有生成相应的静态成员函数,所以其无法转换为函数指针。
上述两个例子展示了cppinsights对于学习C++的帮助。下面讲解另一款工具——Compiler Explorer(又称godbolt)。