decltype demystified

读了这个文章,决定做个总结

  1. decltype is given two entirely different purposes that are related enough to be confusing and lead to bad typos
  2. Every expression in C++ has both a type and a value category

这里面值类别(value category)是一个在C++标准中也比较混乱的概念(a tangled mess),每个表达式的值类别为这三种之一:lvalue, prvalue, xvalue 不同的built-in操作符会对操作数有要求,并返回不同的值类别。

作者总结了一个概念"expression decltype",几乎每个表达式都有这个属性,且这个概念可以明确地表明表达式的值类别,并且可以通过调整从而仅包含值类别。

First, in some cases (such as casts), the expression decltype is explicit and maybe even textually part of the expression. 
Second, the expression decltypes of built-in operators are analogous to the return types of overloaded operators. Thus, programmers accustomed to operator overloading should find them intuitive (except maybe for the conditional ternary operator E1 ? E2 : E3). 
Finally, while lvalue, prvalue, and xvalue are kind of abstract concepts, you can use the compiler to check the decltype of an expression

前面提到decltype这个关键词进行两种完全不同的类型计算,这里解释一下,对于decltype(E)

  1. 如果E是一个没有被括号括起来的id-expression(e.g., x, s.field, S::field,注意非静态成员函数不能用decltype形如(s.method_ptr)),那么decltype(E)返回这些变量、成员变量、非类型模板参数被声明的类型,包括lvalue或者rvalue reference。记这种类型为variable decltype。
  2. 如果E是别的东西,包括带括号的id-expression(e.g., (x), (s.field)),那么E中任何引用都会被视作透明且不可被检测到,于是decltype(E)取得E潜在的非引用类型T,并且按照后面的规则决定是否返回一个引用:Eprvalue则返回TElvalue则返回T&Exvalue则返回T&&。记这种类型为expression decltype

safer use of decltype

可以看到,decltype的使用相当不直观,想要正确地使用它,可以在涉及decltype的地方用一种特定的约束风格去写程序。比如,相比于if (x = y),我们应该写if ((x = y))来表明自己的意图,此外,当你明确需要一个expression decltype时,你可以定义一个宏来帮助自己:

#define exprtype(E) decltype((E))

当你明确不需要expression decltype时,根据你真实的需求,采取不同的做法:

  1. neither variable nor expression decltype
    此时,你可能需要一个变量的非引用类型,不管他本身是什么类型。(auto的变量或者return type的类型推导就是这样做的)你应该考虑std::decay_t来移除引用,即:
#define autotype(v) std::decay_t<decltype(v)>
  1. really want variable decltype
    此时,你可以定义一个宏如下:
    这个宏能在输入带括号的变量时触发编译错误,但是它解决不了 vdecltype(++v) 这种情况(依然会返回引用)
#define IGNORE(x) // causes error if invoked with 2 arguments
#define APPLY_IGNORE(x) IGNORE(x)
#define PARENTHESIZED_TO_COMMA(x) ,
#define vdecltype(v) APPLY_IGNORE(PARENTHESIZED_TO_COMMA v) decltype(v)

it's better to think of value categories as synonymous with reference qualifiers on expression decltypes

接下来作者详细介绍了几种值类型的含义。

  1. prvalue
    纯右值,这个是最好理解的。通常用来构造变量,在例子auto s = std::string("hello world");中,prvalue表达式的求值过程本身不会创建对象或调用构造函数,s会直接由构造函数std::string(const char*) 构造。
    pvalue表示一个未具体化的"抽象值"
    只有当它需要被存储或访问时,才会创建实际对象
    构造函数中的表达式(包括成员初始化器)会在具体化时执行,而不是在函数返回时
    可以看出,编译器会尽可能推迟prvalue的具体化(materialization),主要为了:

    • 避免不必要的移动:减少临时对象的移动操作
    • 避免不必要的复制:减少临时对象的复制操作
    • 提高性能:减少运行时开销

    但是prvalue最终必须被具体化:即使其值被丢弃,编译器也必须执行具体化
    只能省略复制和移动构造:延迟具体化只能省略Copy/Move构造函数,不能省略普通构造函数

    在C++17的保证复制消除(Copy Elision)之前,许多情况下需要创建临时对象进行复制或移动。现在编译器会尽可能地避免这种情况,直接在目标位置构造对象。
    C++17 前:纯右值表示“初始化的值”,需要具体化(materialize)为临时对象。
    C++17 起:纯右值直接代表“初始化过程”,无需创建临时对象。

  2. glvalue
    这是程序中的一个实际对象,它有地址。你可以直接给一个glvalue赋值,unless it is a function or a user-defined class with a deleted or inaccessible operator=.

  3. xvalue("expiring glvalue")
    资源即将被移动的对象的值类型。比如,在表达式求值结束就被摧毁的对象(如函数返回值被用来移动构造时)。
    std::move transforms its argument into an xvalue
    在函数返回相关的应用时,表现出跟prvalue有些类似的性质
    关键区别:

    1. 对象来源不同:
      prvalue:返回一个全新创建的临时对象
      xvalue:返回对已存在对象的引用(通常将失去所有权)
    2. xvalue不能应用RVO优化,而prvalue可以
Widget makeWidget() { return Widget(); }  // 返回prvalue
Widget&& getWidget(Widget& w) { return std::move(w); }  // 返回xvalue
Widget w1 = makeWidget();      // 移动或直接构造
Widget w2 = getWidget(local);  // 移动构造
  1. lvalue
    A glvalue that is not an xvalue. The archetypal lvalue expression is a variable, but things that behave like variables are lvalues, too, such as class data members and function calls returning lvalue references.

  2. rvalue

An rvalue is a prvalue or xvalue.

右值引用主要用于实现移动语义和完美转发,而不是日常变量声明
通常我们应该立即消费右值引用(通过移动构造或移动赋值),而不是保留右值引用到后面使用
返回右值引用容易导致悬垂引用问题,特别是当引用指向局部变量时

Why value categories matter

因为纯右值(prvalue)必须被具体化以初始化其结果对象,它必须是完整类型而非未定义的前向声明结构体。同样,纯右值不能是纯虚类(有纯虚函数),因为这类类只能作为超类被构造,不能直接实例化。
此外,只有类和数组类型的纯右值才能有意义地带有const或volatile限定符。const int这样的纯右值没有太大意义,因为它初始化int左值的效果与普通int纯右值相同。
相比之下,广义左值(glvalue)不需要是完整类型,可以是前向声明的类型,并且可以对超出类和数组类型的类型使用const和volatile限定符。
首先需要对“只有类和数组类型的纯右值才能有意义地带有const或volatile限定符”做个说明,主要是因为类/数组类型纯右值都是复合结构,有内部访问机制,而且在实例化时存在具体对象,例子如下:

// 基本类型:const无效
int a = 5;         // 普通prvalue
int b = const 5;   // const无意义,行为相同

// 类型:const有效
struct Point { int x, y; void move() {} int getX() const { return x; } };

// const显示提供保护
const Point getConstPoint() { return Point{1, 2}; }
getConstPoint().getX();  // 正确:const方法
getConstPoint().move();  // 错误:非const方法

// 非const版本允许修改
Point getNormalPoint() { return Point{1, 2}; }
getNormalPoint().move();  // 正确:可以调用任何方法

然后是广义左值,不完整类型很好理解(函数声明等可能会用到),那为什么广义左值可以带有const或volatile限定符呢,这里主要是C++需要给开发者提供抽象功能,假设未知具体定义的广义左值类型是较复杂的、有内部访问机制的。

内置运算符对其操作数期望特定的值类别,其结果也具有特定的值类别。例如,二元+等算术运算符期望两个操作数都是纯右值(prvalue),并产生纯右值结果。
但当x是左值时,x + 1仍是有效表达式。原因是除函数或数组外的广义左值(glvalue)可以通过一个所谓的"lvalue-to-rvalue conversion"过程转换为同类型的纯右值。

回想一下,纯右值实际上可以直接用来初始化对象。从int等非类glvalue转换时,方法是用旧值的值初始化新的int。转换类类型时,方法是从特定现有广义左值复制或移动初始化该类的新实例。(这可能是为什么类类型的纯右值可以有const和volatile限定符的原因——它们反映了旧广义左值的类型,这个类型应该被用来初始化新实例。)

表达式的值类别决定了表达式可以初始化哪些引用。具体来说,如果T是非引用类型,则:

  • T&只能从左值初始化
  • T&&只能从右值初始化
  • const T&可以从任何值类别初始化,但对于右值,如果有接受两种引用的函数,重载解析会优先选择T&&而非const T&。(这就是为什么复制构造函数可以在缺少移动构造函数时扮演其角色。)

注意,将纯右值绑定到引用时,必须将其具体化为临时对象。通常,临时对象在完整表达式结束时销毁,这会留下悬空引用。
为避免此问题,C++延长了绑定到引用的临时对象的生命周期,使其存活到引用超出作用域为止。(只有直接绑定到引用的纯右值临时对象才会有生命周期延长)
引用绑定规则主要解释了在重载解析中,哪些函数可以接受什么值类别的参数。例如,你不能将纯右值5传递给期望int&的函数,但可以传递给期望const int&或int&&的函数。然而,有一条规则不符合这个逻辑,即函数模板参数看似期望非const、非volatile的右值引用,但实际上是简单typename函数模板参数:

template<typename T> 
decltype(auto) f(T&& t){
    return g(std::forward<T>(t));
}

像T&& t这样的函数参数称为转发引用。它匹配任何值类别的参数,如果提供的参数是左值则t成为左值引用,如果是右值则t成为右值引用。如果U是t的底层非引用类型(即std::remove_reference_t<decltype(t)>),那么对左值参数T会被推导为U&,对右值则是U。(通过引用折叠,如果T是U&,则T&&也是U&。)无论t的变量decltype如何,其表达式decltype始终是左值引用;这就是为什么总需要为std::forward提供显式模板参数。

注意,在示例中,f实际上演示了decltype(auto)返回类型的适当使用,以保留g结果的值类别(包括纯右值)。还要注意,除初始化列表外,auto绑定使用与函数模板相同的类型推导规则。因此,"auto &&x = f()"是另一种形式的转发引用。

现在,如果我们将值类别视为表达式decltype上的引用限定符的同义词,那么描述内置运算符的值类别就有一种更简单的方法。我们可以说+和=的行为就好像有以下这样声明的内置函数(尽管这些显然不是有效代码):

int operator+(int, int);
int& int::operator=(int);

考虑到大多数C++程序员已经理解运算符重载,用与用户定义函数相同的词汇来表达内置运算符的值类别规则不是更清晰吗?表达式decltype让我们相当接近这一点。

Simplified rules for expression decltype

本文中,我们关注的不是decltype((E))的底层非引用类型T,而是decltype((E))是T、T&还是T&&。
从高层次看,有三种情况需要考虑:特殊情况、命名值和未命名值。
特殊情况是字符串字面量和函数,它们的表达式类型始终是相应的左值引用T&。
命名值包括变量、数据成员和数组元素。除非它们会因销毁某个包含它们且具有非左值引用表达式类型的结构体或数组而被销毁,否则它们的表达式类型为T&;如果会被销毁,则表达式类型为T&&。
最后,未命名值的表达式类型与其普通C++类型相同。未命名值可分为两个子情况:具有明显显式类型的表达式(如类型转换和函数调用),以及需要思考内置运算符行为的表达式

特殊左值
与其他类型不同,字符串字面量、函数和函数引用的表达式类型始终是左值引用。

E decltype((E))
"hello" const char(&)[6]
getpid int(&)()
static_cast<int(&)()>(getpid) int(&)()
std::move(getpid) int(&)()

因为字符串字面量是内存中的char[],函数是内存中的指令,所以它们都不能是纯右值,因此给它们非引用decltype没有意义。此外,字符串字面量和函数的生命周期与整个程序相同,所以移动它们没有意义,这正是右值引用的目的。因此,左值引用是唯一合理的表达式类型

命名值
如果E是变量、类或联合体中的数据成员或数组成员的值,那么它对应于一个真实的、已构造的对象,不能是纯右值。因此,E的表达式类型必须是引用。在这种情况下:

如果E位于表达式类型不是左值引用的对象中,且E的变量decltype不是引用,则E的表达式类型是右值引用。
否则,E的表达式类型是左值引用。
例如:

int v;
int &vref = v;
int a[10];
struct S {
    static int static_member;
    int data_member = 0;
    int &lref = static_member;
    int &&rref = std::move(static_member);
};
S s;
S f();
S &lvf();
int S::*fieldp = &S::data_member;
E decltype((E))
v int&
vref int&
a int(&)[10]
a[5] int&
S::static_member int&
s.data_member int&
S::data_member int&
s.*fieldp int&
s.lref int&
s.rref int&
lvf().data_member int&
lvf().lref int&
lvf().rref int&
f().lref int&
f().rref int&
S{}.lref int&
S{}.rref int&
S{}.data_member int&&
S{}.*fieldp int&&
f().data_member int&&
f().*fieldp int&&
std::move(a)[5] int&&

这些规则的一个反直觉结果是,E的表达式decltype的引用限定符通常独立于其变量decltype的引用限定符。例如,v和vref具有相同的表达式类型,s.lref和s.rref也是如此。唯一的例外是在具有非左值表达式类型的对象内部。f().data_member的表达式类型是int&&,因为它位于f()的返回值内,而该返回值的表达式类型是非引用的S,所以当f()的返回值过期时,f().data_member也会过期。相比之下,f().lref和f().rref的表达式类型都是int&,因为它们引用的int不会在f返回的S对象被销毁时被销毁。进一步使用文件系统的类比,销毁类或数组就像递归删除目录——只有目录中的常规文件会过期,目录中符号链接命名的文件不会过期。

值得解释的棘手情况是为什么非静态数据成员(如S::data_member)始终是左值。在S的方法内部,表达式S::data_member等同于(*this).S::data_member,这更明显具有表达式类型int&。在S之外,评估S::data_member是不合法的,但该表达式在未评估的上下文中仍然是左值,如decltype((S::data_member))。它甚至可以在纯右值表达式中使用,如decltype(S::data_member + 5)。(为了说明情况,写sizeof(S::data_member + 42)也是可以的。)

• 显式类型的值

当未命名值表达式E具有显式类型时,E的表达式类型就是该表达式的类型。具体来说:

  • 所有字面量(除字符串字面量外)、枚举标签、this和非类型模板参数的表达式类型与其类型相同(不添加引用)。
E decltype((E))
true bool
5 int
'A' char
nullptr std::nullptr_t
枚举标签 相应的枚举类型
T::method()中的this T或const T
  • 函数调用,包括重载运算符,具有与函数返回类型相同的表达式类型。
int v;
E decltype((E))
std::terminate() void
std::to_string(5) std::string
std::move(v) int&&
co_await a decltype(a.await_resume())
std::string("hello") + " world" std::string
  • 类型转换和类型转换的表达式类型正是目标类型。
E decltype((E))
double(1) double
static_cast<int&>(v) int&
static_cast<int&&>(v) int&&
std::string std::string
  • new的表达式类型是所请求类型的指针。
E decltype((E))
new T T*

内置运算符
隐式类型的内置运算符具有与为用户定义类型重载它们类似的类型。

  • 三元运算符E1 ? E2 : E3是一个例外,因为你不能重载它。它尝试统一E2和E3的表达式类型和const/volatile限定符。如果可以找到一个通用引用类型,它将使用该类型,这就是为什么博文开头的fn_G返回引用的原因(E2和E3都具有表达式类型int&,可以统一)。相比之下,fn_F统一了int&和int,只能是int而不是int&,所以fn_F返回int。
    注意:三元运算符是唯一能有右值引用表达式类型的隐式类型内置运算符。
  • 解引用指针(p)(类型为T)具有表达式类型T&。
  • 所有其他不修改参数的内置运算符表达式具有与结果值类型相同的非引用表达式类型。这包括算术(a+b,-a)和位(a|b, a<<b, ~a)运算,其表达式类型与操作数相同或是隐式转换的结果。它包括逻辑(a&&b, !a)和比较(a==b)运算符,其表达式类型为bool(或对于非浮点/<=>浮点类型为std::strong_ordering/std::partial_ordering)。还包括取地址(&a),其表达式类型为T*,其中T是a的底层非引用类型。
  • 赋值(=, +=等)运算符具有左操作数类型的左值引用表达式类型。
  • 前置递增/前置递减(++c,--c)具有操作数类型的左值引用表达式类型。
  • 后置递增和后置递减(c++, c--)具有与操作数的非引用类型相同的表达式类型(因为它们必须返回不再是操作数值的值,这个值没有固有的居住地,必须是纯右值)。
  • throw和delete具有表达式类型void。
  • sizeof(E)具有表达式类型std::size_t。
  • typeid(v)具有表达式类型const std::type_info&。

结论

C++因其许多无法从基本原则重新推导的特殊规则而难以学习。然而,至少部分复杂性来自语言规范的组织不良,而非语言本身。如果我们以不同方式解释语言,尤其是当语言本身得到改进时(如C++17中的纯右值),这可以得到修复。

在这篇文章中,我认为值类别和表达式decltype是两个本应合二为一的概念。我希望以统一的方式呈现它们,能借助大多数C++程序员已经理解的运算符重载和函数类型,使它们更直观、更容易学习。

posted @ 2024-12-23 19:07  火焰龙卷风  阅读(21)  评论(0)    收藏  举报