寻找完美的「返回」

标题:In Search of the Perfect Return

日期:2025/10/18

作者:Walter E. Brown

链接:https://www.youtube.com/watch?v=sIZUg9tN4sk

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。


这是一场精彩的演讲,我希望如此。我以前从未做过完全像这样的演讲。在某些时候,它可能看起来像是各种主题的“大杂烩”(hodgepodge),但我向你们保证,在最后会有意想不到的收获。我必须要告诉大家,这次演讲的时间会比一般的演讲稍长一些。为了控制时间,我请求大家把问题留到最后,看看我们还剩多少时间。我知道你们中有些人需要赶火车之类的,如果你必须提前离开,请自便,我不会介意的。当然,如果你五分钟后就走,那我肯定会介意,不过那就是另一码事了。那么,让我们开始吧。

在进入技术环节之前,我想感谢许多人,事实上我的幻灯片甚至写不下所有的名字。这些人中有些是我的老师,有些是我们在 C++ 标准委员会的同事,有些人的演讲给我留下了深刻的印象。他们都做出了贡献,给了我启发和信息,我对此心怀感激。特别是有一位先生写了一些对我非常有影响力的东西,因为我们今天聚在这里都是为了提升自己的理解。基于这一点,我今晚要讲的内容,很可能是你们已经知道的,但也许你们了解得还不够深入。你不能把你知道的一切都直接告诉一个初学者,事实上,那是一种糟糕的做法。所以,我的目的不是要告诉你们一些你们已经知道的东西(请不要觉得被冒犯),而是要在更深的层次上进行探索。

本次演讲将分为三个部分。我想探索我称之为“目标特性精选”的内容,并且我希望超越我们在教初学者时所教授的“表面行为”。当我们梳理这些内容时,请大家寻找其中的共性,寻找那些能帮助我们更有效地推理我们程序的见解。因为归根结底,推理(reasoning)是我们编程时的主要工具之一

完成这部分(这也将是今晚演讲的主要部分)之后,我将转换话题去探讨一个具体的问题。一开始它似乎与前面的内容毫无关联,但最终它们会联系起来。这个问题影响了标准库中的许多算法,以及其他一些算法,我们要看看问题的核心究竟是什么。

然后在第三部分,我将把第一部分和第二部分连接起来。所以,从某种意义上说,这次演讲是“Y 型”的结构:这里讲一点,那里讲一点,最后在底部汇聚出大量的内容。我们将以一些非常有趣的方式应用第一部分中的特性,来解决第二部分提出的问题。最棒的是,最终产生的代码将异常简短。

第一部分:C++特性探索与值类别 (Value Categories)

我要从值类别(Value Categories)开始。我们都听说过值类别,但根据我的经验,许多 C++ 程序员虽然知道值类别,却并没有意识到它们在 C++ 语言和标准库中渗透得有多深。

讲一点历史:左值(L values)和右值(R values)已经存在 40 多年了。但是自从 C++11 以来,我们对这些类别进行了一些细化。我们现在有了泛左值(GL values)、纯右值(PR values)和将亡值(X values)。这是引自 C++ 标准里的话(当我引用标准时,我会在角落或后面给出引文出处,以防你们想进一步探索)。

我发现很多程序员没有意识到的是,尽管这些名称中包含了“值(value)”这个词,但这些术语并不适用于实际的“值”,它们适用于表达式(expressions)。因此,我们应该讨论的是“纯右值表达式(PR value expression)”。每个表达式都恰好属于这三者之一:左值、将亡值或纯右值。这就是我们所说的值类别。

如果你查看 C++ 标准,每一个 C++ 运算符都规定了两件事:

  1. 每个操作数(operand)所要求的值类别。

  2. 运算符应用后,其结果所承诺的值类别。

你写不出任何一个其结果没有预定值类别的运算符或表达式,也写不出对操作数没有预定值类别要求的表达式。这里有一个分类学和层级结构,你们以前可能见过这个图表或其变体。乍一看,它看起来是不对称的,你可能会认为左值和右值应该在同一层级,但它们不在,这是出于历史原因。如果我们改变了放置左值的位置,所有以前的程序都会以一些神秘的方式突然崩溃(或者有这种潜在风险)。在 C++ 语言演进的过程中,我们非常重视保持向后兼容性。

很多程序员没有意识到的是,这对 decltype 运算符有直接影响:

  • 左值 (L value) 表达式decltype 始终是左值引用类型(我会不时将其简称为 L ref)。

  • 将亡值 (X value) 表达式decltype 始终是右值引用类型

  • 纯右值 (PR value) 表达式decltype 始终是非引用类型

这就是我喜欢称之为“类型编码的值类别”(type encoded value category)。它是将值类别编码到了结果的类型中。

让我给你们举个例子,因为 decltype 在某种意义上有点“精神分裂”。标准规定,带括号表达式的类型和值类别,与不带括号的表达式完全相同。所以,括号并不影响值类别。但是,有一个例外:表达式在 decltype 中的处理方式,与 decltype 处理不带括号的标识符表达式(ID expression,我们可以简单认为就是名字)的方式是不同的。C++ 中有很多看起来很奇怪的名字,比如重载的运算符函数(operator+)、模板特化、带有尖括号的模板 ID 等等,它们都是各种标识符表达式。

请看下面这一小段代码。我向你们保证,这些 static_assert 都不会触发,它们都会通过。我想确保我们能真正掌握它是如何工作的,以及为什么这样工作:

/* 生成代码,仔细甄别 */

// 假设在一个 const 成员函数中
int k;
static_assert(is_same_v<decltype(k), int>);           // 1
static_assert(is_same_v<decltype((k)), int const&>);  // 2

我们先来看标为 1 的那行代码。变量 kdecltype 会报告为 int。为什么?因为 k 被声明为 int,这是一个不带括号的标识符表达式。

看看给它加上括号后会发生什么(标为 2 的代码)。突然间,它变成了 int const&。为什么?int 是因为 k 声明为 intconst 是因为我们处于一个被 const 修饰的成员函数内部;而 &(引用)是因为它仍然是一个左值表达式。这就是类型编码。

所以,请务必记住这些编码规则,至少在今晚剩下的时间里要记住。最后不会有考试,大家不用担心。作为提醒,如果你有一个表达式:

  • 如果是左值表达式,decltype 会得到左值引用。

  • 如果是将亡值表达式,得到右值引用。

  • 如果是纯右值表达式,得到非引用。

这个小表格有趣的地方在于,你可以把“条件(when)”和“结果(then)”反过来。如果 decltype 报告它是 T&,那么你最初拥有的就是一个左值表达式,以此类推。我还没见过有人这样展示过。

表达式与引用类型

我还发现另一件大家不太理解的事情:C++ 表达式本身永远不会具有引用类型

当我第一次了解到这一点时,我惊呆了。当然,那已经是 20 年前的事了,我的下巴现在已经合上了,但这依然很令人震惊。任何引用类型都没有对应的表达式,这是标准里的一句话。它深埋在标准的其他细节之中。标准说,如果它一开始具有引用类型,那么该引用会在进一步分析之前被丢弃(dropped)。

这非常有用。以防你想确认自己的理解,让我给你展示一小段代码,如果你愿意,可以用它来复查一下自己的理解。我将定义一个模板:

/* 生成代码,仔细甄别 */

template<typename T> struct value_category { 
    static constexpr auto value = "prvalue"; 
};
template<typename T> struct value_category<T&> { 
    static constexpr auto value = "lvalue"; 
};
template<typename T> struct value_category<T&&> { 
    static constexpr auto value = "xvalue"; 
};

// 使用宏来辅助打印
#define PRINT_VC(expr) std::cout << value_category<decltype((expr))>::value << '\n'

首先是一个主模板,然后我为我们关心的其他两个值类别(左值引用和右值引用)做了两个特化。一旦你有了这个,你就可以显示你选择的任何表达式的值类别。你只需要把想测试的表达式 E 插入进去即可。如果你不喜欢 std::cout,用 print 或任何你喜欢的输出方式都可以。

这里有两个大家都知道但理解得不太好的具体例子:

前置递增(pre-increment)表达式的值类别是左值。后置递增(post-increment)表达式的值类别是纯右值。

如果有人告诉你前置和后置递增是等价的,可以直接反驳他们。它们不一样,从来都不一样,一直都是这样。它们不可互换!我都数不清我跟多少经验丰富的程序员争论过这个问题,他们就是不相信我。使用后置递增时会发生拷贝(copy)。你可以试着自己写这个函数,没有拷贝你是写不出来的。你必须在原值被改变后返回原值,你必须保留一个副本。没错,某些编译器在特定设置下可以将其优化掉,但请记住它本质上是需要拷贝的。

decltype(auto) 与初始化

既然我们在谈论 decltype,我想稍微谈谈 decltype(auto)。让我们先从这两个声明开始,在这一页剩下的时间里我们将参考它们。

/* 生成代码,仔细甄别 */

auto x = ...;
decltype(auto) y = ...;

如果是一对直接初始化(direct initializations),它们看起来会给出相同的结果,这是正确的。如果我使用拷贝初始化(copy initialization)而不是直接初始化,我仍然会得到相同的结果。因为那是一个不带括号的初始化器(unparenthesized initializer)。

但看看把它变成一个表达式时会发生什么。decltype 的语义就生效了:如果它是一个左值表达式,你会得到一个左值引用。如果是拷贝初始化且你以将亡值表达式开始,你也会得到对应的结果。所以,autodecltype(auto) 也不是可以互换的。

这里还有更多关于它的细节:

如果是大括号初始化器(braced initializers),由于它不是一个表达式,decltype(auto) 根本不起作用,decltype 不处理这种情况。

如果你给它加上修饰符,例如加上 *(指针),这对 auto 没问题,但绝对不能对 decltype(auto) 这么做。不能有任何修饰。decltype 会处理全部的推导,不需要你帮忙。

当你尝试推导返回类型时会发生什么?

如果写 decltype((x)),我们知道预期的结果:你得到一个左值引用,因为它是左值表达式。但是当你在返回类型上写 decltype(auto) 时,它会去查看 return 语句的操作数。当然,如果你有多个返回语句,它们的推导结果最好是一致的。

关于 return 语句有一些特殊的规则,并且它们随着时间的推移发生了变化。我向你们展示的是最新、可能也是最完善的规则,但如果你处理的是 C++11、14 或 17 的代码库,这些规则可能不完全相同,它们已经改变了。return 语句之所以特殊,是因为我们希望尽可能地启用移动语义(move semantics)。所以随着时间的推移,我们在这方面做了越来越多的工作。

限定符统一 (Qualifier Unification)

换个话题:限定符统一。你不会在其他任何地方找到这个术语。

我们都知道这一点:如果你将引用限定符应用到一个类型上,你当然会得到一个引用限定的类型,没什么新鲜的。大家也都懂。但是,如果你从一个已经带有引用限定符的类型开始会发生什么?

例如,如果在上面有一个类型别名 R(假设 using R = int&;),那么写 R&R&& 是什么意思?正如我们所知,C++ 语言中不存在“引用的引用”这种类型,它不存在。所以发生的事情是,这两个引用(现有的引用和你试图应用的引用)会组合在一起。标准使用的词是折叠(collapse)

这是如何发生的?这是我用一句话概括的解释:如果你从一个左值引用开始,或者你试图应用一个左值引用,你最终都会得到一个左值引用。

所以,R& 就相当于 int& &,它会折叠,你最终得到 int&,这当然和你开始时的 R 是一样的。如果我写 R&&,它仍然会折叠,并且左值引用胜出。你可能见过类似于布尔 AND 或布尔 OR 的表格。没错,只有当你从一个右值引用开始,并且应用一个右值引用时,你才会得到一个右值引用。其他情况下,左值引用都会胜出。

但是还有其他类型的限定符。我们有 CV 限定符(constvolatile)。我得承认我极少需要使用 volatile,但这只是我个人的情况。当组合并统一这些限定符时,我们将其描述为**联合(union)**而不是折叠。所以如果我从一个别名开始,想要对其进行 const 限定,我仍然会得到带有 const 的类型,如果有重复的,它会和开始时一样(比如 int const const 还是 int const)。你们可以用适当的 static_assert 来验证这一切。

如果你从一个同时具有两种限定符的类型开始会发生什么?例如 int const&。在 C++ 中,你不能对一个引用类型进行 CV 限定。这种事不存在。(在某种意义上,每个引用都隐式地被 const 限定了,因为你不能重新绑定(re-seat)一个引用。一旦它指向某个东西,你就不能改变它,它将永远指向那个东西。)所以发生的事情是,**任何多余的 CV 限定符都会被直接忽略,而你想要应用的任何引用限定符都会像往常一样折叠。**无论怎样,只要存在,左值引用总是会胜出。

标准库中有一些类型特征(type traits)可以为我们做这些事。出于某种奇怪的原因,它们被命名为 add_xxx(添加),实际上并不涉及加法,我们只是试图应用限定符。但某些类型需要特殊处理,因为核心语言中有规则限制。

简而言之,有添加引用限定符的 trait:add_lvalue_reference。但是你不能在 void 上添加引用,不存在“对 void 的引用”这种类型,所以这个 trait 不会这么做。如果你传入 void,出来的还是 void。类似地,对于添加右值引用的 trait 也是如此。

但还有一个陷阱:不存在“对函数类型的右值引用”。这不存在。所以如果你对一个函数类型添加右值引用,你会得到一个左值引用,即使你最初是以非引用类型开始的。

然后我们有三个添加 CV 限定符的 trait:add_const 等。正如我们已经说过的,你不能对函数或引用类型进行 CV 限定。所以这三个 trait 不会这么做。相同的限制适用于 add_volatileadd_cv

**一定要熟悉类型系统。永远如此。**这是我上模板编程课时最先告诉大家的事情之一:你需要非常了解类型系统。C++ 就是以其类型系统而闻名的。

逆向类型推导 (Type Deduction)

现在,我要展示一些我在其他地方没有见过的东西。我们要把刚才的逻辑全部反着来。

这是一个简单的例子来说明我在说什么。

/* 生成代码,仔细甄别 */

// 情况 1
T x; // 我希望 decltype(x) 是 int,T 必须是什么? -> T = int

// 情况 2 和 3:我希望 decltype(x) 是 int const
T x;       // 2. T 必须是 int const
T const x; // 3. T 只要是 int 就足够了

// 情况 4, 5 和 6:我希望 decltype(x) 是 int&
T x;       // 4. T 必须是 int&
T& x;      // 5. T 只要是 int 就足够了
T&& x;     // 6. T 必须是 int& (引用折叠)

// 情况 7 和 8:我希望 decltype(x) 是 int&&
T&& x;     // 7. T 只要是 int 就足够了
T x;       // 8. T 必须是 int&&

在情况 3 中,如果我写成 T constTint 就足够了(it suffices)。请记住这一点,这就足够了。因为我当然也可以让 Tint const,那最终就是求联合,但那是不必要的。

在引用情况中,我想让 decltype(x) 是对 int 的引用(int&)。如果像情况 4 那样写,T 最好是 int&;像情况 5 那样写(T& x),Tint 就足够了;而如果像情况 6 那样写(T&& x),根据引用折叠规则,T 必须是 int& 才能得到我想要的结果。看到了吗?它是逆向运作的,我从一个可能已经发生折叠的类型开始,反向推导。

最后是情况 7 和 8。我想要 int&&,你们可以看看在每种情况下 T 必须是什么。我把这个留在屏幕上几秒钟让你们思考一下。

这个过程寓意深远:我们刚刚所做的就是类型推导(Type Deduction)。我只是到现在才使用这个术语。关键点在于,我们从已知的 decltype(x) 结果开始推导出 T。我知道我想要什么结果,然后我逆向推导。请记住这一点。

重载集与转发引用 (Forwarding References)

换个话题。如果你要编写一个通过引用接收参数的函数重载集,你有四种选择。在 C++11 之前,你只有左边这两种选择(T&T const&)。自从 C++11 以来,你还增加了右边这两种选择(T&&T const&&)。

假设这些是你的参数类型。现在让我们想想参数(Arguments)。传入的参数总是被当作表达式处理。所以我的实参可能是可变的左值,或者是可变的右值。总共有四种可能性。绑定是如何运作的?

如果我传入一个可变左值 (Mutable L-value),如果有 T&,它会首选这个重载;如果没有,它会回退到 T const&。没什么新鲜的,从 C++98 开始就是这样。

如果是常量左值 (Const L-value),那么我只有一个选择,只能绑定到 T const&

自从 C++11 开始,我们也有了处理右值的额外选择。当我说右值时,我指的是纯右值和将亡值,在这个上下文中它们被视为相同的。

如果我有一个常量右值 (Const R-value),它首选 T const&&;如果不可用,它会回退到 T const&

如果我有一个可变右值 (Mutable R-value),实际上有三个选择:首选 T&&,如果没有,回退到 T const&&,如果还不行,最后回退到 T const&。自 C++11 以来一直如此。

关于这个逻辑有两件有趣的事情需要注意:

  1. T const& 对所有参数都是可用的,所以有些人称之为“万能接收器”或“通用参数类型”。你总能绑定到它。

  2. T&& 是启用移动语义的关键。今晚我不打算深入讲移动语义,尽管我准备了几张有趣的幻灯片,但我们时间不够了。

现在我想把几件事结合起来:高里(Gauri)的影响——类型推导。

正如你们大多数人肯定知道的那样,如果你写 && 并将其应用于一个将要被推导的类型,例如完全采用 T&& 这种形式。必须是完全相同的形式。你拥有的东西被称为转发引用(Forwarding Reference)。Scott Meyers 最初称之为“万能引用(Universal Reference)”,有些人仍然喜欢那个叫法,但这并不是标准里的术语。

几个简单的例子。如果你说 auto&&,那是一个转发引用。你得到的是推导出的类型,但那个类型没有其他的名字,你只能通过说 decltype(T) 来获取这个类型。它是匿名的。

这里的陷阱是:在函数参数中也是一样的。

/* 生成代码,仔细甄别 */

template<typename T>
void g(T&& t) { ... }

这里的参数也是转发引用。变量 t 的类型本质上是 decltype(t)(推导后的结果),但是对于 T 本身,推导时并没有直接匹配类型的名字。没接触过这个的人会说:“不,不,类型是 T,那是我给它起的名字。”不,它不是。有时候是,这就意味着总体而言它不是。

类型推导是根据某个初始化器(initializer)工作的。我为什么要谈论初始化器?因为参数传递就是初始化,标准是这么说的,返回值也是初始化。对于转发引用,它不仅看类型,它实际上还看值类别

所以 decltype(t)(注意是小写 t)总是某个引用类型。要么是因为我传入了一个左值表达式,要么是因为我传入了一个右值表达式。有人可能在心里犯嘀咕:“等等,如果是右值,也不总是带引用的类型啊,有一种情况是非引用的。” 啊,但是参数声明里明晃晃地写着 T&& 呢!所以最终 t 的类型总是带有引用的。decltype(t) 要么是左值引用类型,要么是右值引用类型,永远如此。通过这个已知的结果,逆推回去,你就能算出模板参数 T 是什么。正如我们前面逆向推导看到的那样,T 并不总是和 decltype(t) 匹配。

让我们来看一个具体的例子。我调用 g(x),看看这行代码结尾的表达式:decltype((x))&& (这里实际上是在说 static_cast 的内部机制,后面会细讲)。

  • 情况 1: 假设我已经将 x 声明为 int,并给它一个初始值。当调用时,x 将是一个左值表达式。所以在调用 g(x) 时,decltype((x)) 将是 int&。因此 decltype(t) 也将是 int&。由于参数签名是 T&&,它现在表示 int&,根据我们刚才做的逆向引用折叠,T 必须被推导为 int&

  • 情况 2: 假设调用时的表达式是一个纯右值表达式(比如传入了一个临时的 42)。在调用 g(x) 时,参数的 decltype 只是 int(纯右值的 decltype 为非引用)。decltype(t) 将是 int&&(因为参数声明有 &&)。现在 T&& 意味着 int&&,那么 T 被推导为 int 就足够了,而这与 decltype(t)(即 int&&)并不相同。

大家明白它是如何运作的了吗?

static_cast 与完美转发 (Perfect Forwarding)

换个话题:static_cast

我们都知道 static_cast 是一种转换(conversion),对吧?好吧,不总是转换。标准规定:

  • 如果你的目标类型 T 已经是一个左值引用类型,那么转换结果表达式将是一个左值

  • 如果你从一个右值引用类型开始,转换结果将是一个将亡值表达式。

  • 否则,你得到一个纯右值。这是标准的直接引用。顺便说一句,请注意这与函数调用表达式的相似性。如果函数返回类型是左值引用,函数调用表达式就是左值表达式,以此类推。

现在,我想把重点放在一种特定类型的 static_cast 上,它看起来完全是这样的:

static_cast<T&&>(x)

(当然,由于 T 可能不同,引用折叠规则适用)。假设 x 是某个推导出的类型 T 的函数参数。这里有三种可能性:

  1. 如果 T 是一个左值引用类型,转换结果将是一个左值表达式。

  2. 如果 T 是非引用类型(意味着你原先传入的是右值),结果将是将亡值(右值)表达式。

你看到模式了吗?它无处不在。这套特定规则的有趣之处在于,这个特定的表达式实际上执行了与 decltype 相同类型的编码:它将值类别编码到了结果类型中,保留了参数的类型和原始值类别。

这很重要。我接下来告诉你们原因。请看这两个函数模板:

/* 生成代码,仔细甄别 */

template<typename U> void calc2(U u);

template<typename T> 
void calc1(T t) {
    calc2(t); // 不完美的转发
}

(它们看起来顺序反了,但我只是想确保 calc1 可以调用 calc2)。这样做可以吗?当然可以,我们这里使用了“转发”这个词。有人调用了 calc1calc1 有一个参数 t,然后它将 t 作为参数传给 calc2,所以它转发了那个小 t

但这里有一个问题。问题是,tcalc1 中的类型,可能与 ucalc2 中的类型不一样。为什么?因为小 t 在这个上下文中(作为一个变量名)是一个左值表达式。这意味着,不论 calc1 接收到了什么,传给 calc2 的时候,decltype(u) 总是会推导出一个左值相关的结果,即使最初调用 calc1 时传入的是一个右值。

因此,业界的行话将这称为不完美转发(imperfect forwarding),因为我们把值类别搞砸了。如果我们想要完美转发(perfect forwarding)(相信我,我们确实需要),我们不仅必须保留类型,还要保留值类别。这就是它是如何发生的:我们在上一页刚刚谈到的那个特定的转换操作(static_cast<T&&>)就是实现这个目的的工具。

记住,这个特定的转换保留了编码的值类别。表达这个操作更常见的方法是调用 std::forward。如果你做很多泛型编程,由于这非常普遍,很多人定义了一个宏,通常命名为 FWD,来表示同样的事情:

/* 生成代码,仔细甄别 */

#define FWD(x) std::forward<decltype(x)>(x)
// 或者用强转的形式:
#define FWD(x) static_cast<decltype(x)&&>(x)

正如我们所看到的,它的作用是将左值表达式映射回左值引用类型,将右值表达式映射回右值引用类型,完美保持了原来的属性。

三元运算符 (The Ternary Operator)

换个话题:三元运算符 (?:)。我知道很多人不喜欢三元运算符。那太遗憾了,它是语言的一部分,它不能替代任何其他东西。

问大家一个问题,在脑子里回答就好。这两行代码(指包含三元运算符和不同类型的表达式)语义完全相同吗?提示:不是。它们看起来似乎一样,并且如果 XY 的类型相同,它们确实一样。事实上,如果它们有不同的类型,根据具体类型,这些表达式可能是格式错误的(ill-formed)。

三元运算符的工作方式是,它会寻找一个通常被称为**目标类型(target type)**的东西。并且它会同时考虑类型和值类别。

  • 例如,如果 XintYfloat,那么目标类型将是 float

  • 如果一个是 int,另一个是 unsigned,目标类型将是 unsigned。这很糟糕,但规则就是这样。

  • 另一个例子:如果我有两个类型 struct A 和继承自 Astruct B,这两个表达式的目标类型将是 A

  • 如果我把 C 定义为 B const 的别名,那么 AC 的目标类型将是 A const

我想我从未在任何大一新生的课程中看到过这样的解释。那么值类别到底有什么大不了的呢?我会解释的。假设我有四个声明:一个变量 k,三个函数(返回不同类型)。

/* 生成代码,仔细甄别 */

int k;
int& l();
int&& x();
int pr(); // 纯右值返回

这里有一个表格(三元运算符是满足交换律的,哪个在左哪个在右无关紧要,它会同时查看两种类型和值类别):

  • 如果其中一个操作数是一个普通的变量 k(它是左值),那么你得到什么取决于另一个操作数的值类别:

    • 如果你有两个左值表达式,你会得到一个 int&(左值引用)。

    • 如果其中一个是右值(具体说是纯右值),你会得到一个普通的 int。这很重要。

  • 更多情况:如果我使用函数调用表达式作为左值操作数(比如调用 l() 返回 int&):

    • 如果我结合纯右值(如 pr() 返回 int),得到的结果是一个纯右值表达式 int。注意,此时整个三元运算符给你的是一个纯右值。

    • 如果两个都是左值表达式,我得到一个左值表达式(引用类型)。

  • 最后,如果两个操作数都是将亡值 (X value) 表达式,现在我得到一个 int&&,其结果是一个将亡值表达式。

由于最后这一行,三元运算符将在稍后被证明是个麻烦制造者。但这就是它的工作原理。

偏序类型 (Partially Ordered Types) 与比较

标准库谈到了偏序类型。我们说一个类型是偏序的,如果该类型的变量能够合法使用这四个比较表达式(<, >, <=, >=),并且你能得到一个 bool 结果,或者可以转换为 bool 的结果。

还有一种概念叫“与…部分偏序”。这适用于操作数属于不同类型的情况。当一列来自类型 A,另一列来自类型 B,且比较表达式格式正确且返回类布尔值时,我们说类型 A 与类型 B 是部分偏序的。不同的编程语言对这种表达式有不同的术语,有时被称为跨类型 (cross-type) 表达式。

这绊倒了我见过的几乎每一个新手程序员。一个简单的表达式:

-1 < 1U

这是致命的错误陷阱。在数学上是正确的(真),但在 C++ 中是 false。每次我不得不教这个的时候,都觉得非常折磨。为什么?因为操作数属于不同类型。不同类型之间不存在直接比较。所以其中一个会被转换为另一个,我们有了前面说的“目标类型”。当有 intunsigned 时,目标类型是 unsigned。不幸的是,较小的负数 int 被转换成了非常大的 unsigned 整数。

他们为什么要这么做?嗯,自从 C 语言以来就一直这样,大约有 50 年了。我的理论是(我不能百分百确定这是事实,只是推测),因为这种转换成本很低,不需要改变任何比特位,只需改变它们的解释方式即可。

但是,现在标准库中有了一个相对较新的算法,名为 std::cmp_less。它会做正确的事情。所以老天保佑,请尽量比较相同类型的东西,如果你绝对、肯定做不到,第一,你最好有一个非常好的理由;第二,请调用这个算法来获得正确的答案。

如果涉及到浮点数怎么办?比如比较浮点数和整数,或者在可能存在 NaN(非数字)的情况下比较其他浮点数。也有一个算法,叫 std::is_less。你传给它算术类型(整数或浮点数),它应该能正确处理 NaN。这仍然有些棘手,但至少能做一些合理的事情。

通常我们想要进行比较,是为了弄清楚一对值的排序关系。如果你有一个偏序类型,这意味着我可以应用 < 操作符。我见过的几乎每个新手都会掉进这个陷阱:我给你 A 和 B,问“它们是按顺序排列的吗?” 他们的本能反应是:好的,测试 A < B

很抱歉,只有部分时候是对的。如果答案为 true,你是对的,它确实按顺序排了。但如果答案是 false,它就不适用了。即使答案是 false,它们也可能是有序的——因为它们可能相等。如果它们相等,你不能说它们乱序,所以它们必然是有序的。

根据我的经验,在大多数情况下,询问这些值是否“乱序(out of order)”比询问它们是否“有序(in order)”更有用。通常当这个函数返回 true(意味着乱序)时,我往往有额外的工作要做,或者有不同的工作要做,总归不是默认的操作。想象一下在排序时,如果它们已经有顺序了,你就什么都不用做;所以当答案是 false 时,你通常可以提前返回。

构造函数与函数返回规则

换个话题。我告诉过你们,这就像是一个大杂烩(好听点的词叫“精选集” potpourri)。

标准规定,一个简单的类型说明符,后跟一个带括号的可选表达式列表(你们自己去读标准文档吧),核心在于:它根据该初始化器构造指定类型的值。

假设我有某个表达式 E,它的 decltypeT&&。这里有两种引起我们特别关注的情况:

  1. 如果 T 只是一个简单类型(非引用)。那么 decltype(E)T&& 意味着 E 一定是一个右值表达式。所以我通过调用 T 的构造函数(可能是拷贝构造,可能是移动构造等)得到一个 T 类型的东西。

  2. 如果 T 是一个左值引用类型,那么 decltype(E) 就是一个左值引用。我们一开始就有了一个左值表达式,经历了引用折叠等等。**这时候 T(E) 完全没有效果!**你有一个左值引用类型,你试图将它转换成一个左值引用类型,这是一个无用功(no-op)。它什么也不做,也没有性能损失,只是看起来很唬人。

我们再谈谈函数的 return 语句。形式为 return E;,对于某个表达式 E,我们知道这会求值 E,终止函数,并交回 E 的结果。根据函数的返回类型,这里可能会发生隐式转换。

我们今天要讨论两种情况。是不是听起来开始耳熟了?(注意,规则随着 C++ 版本的更新已经改变了,可能不适用于 C++14/17 之前的版本)。

  • 如果你有一个标识符表达式(名字)指向了一个局部变量,在返回时它会被当作将亡值 (X value) 处理。这样做的原因是为了尽可能地调用移动构造函数(如果不满足则回退到拷贝构造函数)来返回结果。

  • 但如果 E 是一个纯右值 (PR value) 表达式,那么你可能会得到拷贝省略 (copy elision)。你会得到直接初始化,实际上没有调用任何构造函数。当然前提是类型要匹配。

大家深呼吸。第一部分到此结束。“精选集”结束了。

第二部分:探寻问题——以 std::min 为例

话题完全转变。到底哪里出了问题?我为什么要引导你们走过这条漫长的特性之路?

我打算以 std::min 作为我的典型反面教材。下面是一个简单且正确的实现,但这并不符合标准:

/* 生成代码,仔细甄别 */

template<typename T>
T min(T a, T b) {
    return (b < a) ? b : a;
}

因为标准库规定调用和返回都必须**通过引用(by reference)**传递:

/* 生成代码,仔细甄别 */

template<typename T>
constexpr const T& min(const T& a, const T& b) {
    return (b < a) ? b : a;
}

所以根据标准的规范,这才是正确的实现。为什么要通过引用?因为它可以缓解可能非常昂贵的拷贝开销。例如,如果你有很长的字符串,拷贝会很昂贵,所以这里有一个适度的性能提升。

这很好,但**返回引用是个大麻烦。**会出什么问题呢?看看这两个声明,它们非常相似:

/* 生成代码,仔细甄别 */

auto m1 = std::min(f(), g());
auto& m2 = std::min(f(), g());

m1 当然是结果的一个拷贝,没有问题。m2 当然是一个引用,但它究竟是对什么的引用?好问题。这些例子中的参数 f()g(),我非常确信地说,它们是临时变量(temporaries)。这些临时变量的生命周期将在该语句结束时终结。所以我留下了一个指向“这具尸体”或“那具尸体”的悬垂引用 (dangling reference)。尸体?难道不是吗,死了,消亡了,埋葬了,连墓地都被回收了。

我们当然想避免这种情况。我发现有人发布了这样的名言:

“我很好奇为什么这一直以来没有被当作一个错误?在某些小众架构上,真的有什么正当理由(legit reason)让任何人想要返回一个指向临时变量的引用吗?”

我不知道这是谁写的,这就是他的心声。

还有更多问题。因为 min 返回的是常量引用(reference to const),调用者不能做这种事:

/* 生成代码,仔细甄别 */

std::min(x, y) = 0; // 错误!

如果那是基本数字类型,将较小的那个置为零,这种需求似乎很合理。或者如果是类类型,去调用它的某个修改器函数,但这都做不到,因为它是对 const 的引用。人们总是试图绕过它,比如用 const_cast 丢弃 const 属性,但那是没用的——它是一个临时变量,它已经死了被埋了,你为什么还要试图修改它?我无法告诉你我和几十个甚至几百个程序员在这个问题上争论过多少次。这是一个非常严重的问题。

上个月我发现了这个,有人写道:“我最近被一段看起来很无辜的代码坑了,大概是这样的:std::min(B - A, C)。这里面 B - A 是一个临时变量。问题源于 std::min/max 总是返回一对 T const&,不管传入的参数是什么。” 他在乞求一个修复。我不会告诉你们是谁发的,我不想让任何人难堪。

如果这还不能说服你,上个月,我对天发誓我绝对不是刻意去寻找这个的,它刚好出现在我的桌面上。这是 GCC 标准库的一条 commit 提交信息:“修复 GCC 标准库中的两个问题。修复 is_permutation 中的悬垂引用崩溃问题。”

在所有可能的地方,偏偏是在 is_permutation 里!这个在标准里存在了二三十年了吧?代码将一次调用的结果缓存到了一个局部的 auto&& 变量中,问题在于这可能会创建悬垂引用。生命周期延长在这里是不适用的(因为涉及到的表达式类型)。

这让我非常清楚地意识到,即使是最顶尖的专家(这些人是我认识的最顶尖的程序员之一),他们偶尔也会违反这个“不完美返回”的陷阱。同样清楚的是,我们需要一些缓解措施。所以我们要尝试做得更好。

我们有几个目标:

  1. 仍然要避免不必要的拷贝。

  2. 绝对要避免悬垂引用。

  3. 避免任何使用 const_cast 或其他同样丑陋手段的需要。

  4. 当然,我要保证计算结果绝对正确。

  5. (奖励目标)或许我们可以考虑支持跨类型比较。

如果我同时拥有了这四点(或五点),这就是我所说的完美返回(perfect return)。今天我要展示的东西并不是我原创的,除了这个展示过程本身。早有太多聪明的 C++ 程序员在研究这个了。

大约 20 年前,Howard Hinnant 提出过一个解决方案。但那时候,需要大约 175 行左右的代码。标准委员会拒绝了它,因为觉得太复杂了。那时候标准库里还没有 common_type,我在 90 年代末发明了 common_type,后来 Howard 捡起了它并最终让它进入了标准(要把这东西做对需要极其漫长的时间,非常复杂),但当时被拒绝了。从那以后,许多其他人也尝试攻克这个问题。有些结果相当好,有些则相当糟糕。我都实验过了,现在我想和大家一起探索其中的一些。

第三部分:完美返回 (The Perfect Return)

一点分析:返回类型有哪些选项?我们仍在研究 std::min

如果两个参数都不是临时变量(即都是左值表达式),我们可以安全地返回一个引用(不涉及临时变量,可以引用它们)。但是是哪种类型的引用?

  • 如果两个参数都是可变的 (mutable),我肯定可以通过可变左值引用返回,即 T&

  • 否则,我必须通过不可变的左值引用返回,即 T const&

这处理了两个左值参数表达式的情况。

如果任意一个参数是临时变量(这意味着它是某种形式的右值表达式),我绝对不敢返回任何形式的引用,因为极有可能马上变成悬垂引用。即使只有一个是临时变量,只要返回了它,我就麻烦了。所以,唯一的安全选择是执行拷贝或移动,并按值返回(return by value)。这是唯一安全的事情。这和三元运算符的做法很像,不是吗?

所以我得到了三个可行的返回类型:

  1. 我可以返回 T&,这在两个可变左值表达式时是安全的,现在我们不再需要强转去掉 const 了。

  2. 当我至少有一个不可变左值引用时,我可以返回 T const&

  3. 其他情况下,我必须返回纯 T(按值返回),这就是我避免悬垂引用的方法。

看起来我们需要大量的重载。你必须把这些看作一个重载集 (overload set),你不能孤立地看其中任何一个,否则会产生误解。

  • 第一个:返回可变左值引用,很简单。

  • 第二个:返回 const 左值引用,目前为止还不错。

  • 对于临时变量(右值)怎么办?不能写在一个函数里,因为可能一个是,可能另一个是,或者都是。所以我需要三个重载来处理这些情况,并且这里的参数看起来像是 T&&(转发引用)。

  • 因为 T&& 可能会推导为左值引用,但是上面的左值特定重载是更好的匹配,所以重载决议会做正确的事情。(这就是为什么我说要从整体上考虑这个重载集。)

  • 所有这些重载都将具有完全相同的函数体,也就是我们一直看到的那句三元运算符代码。

但这真的不是一个好方案。首先它很丑,其次它无法扩展。如果我的函数里有三个参数怎么办?老天保佑不会发生,但确实会有。

我们再试一次。我们可以只用两个重载

第一种情况是左值的,你需要像这样写(不要尝试用 template 关键字,它会很快变得非常难看)。而且注意有一个 requires 子句来做约束。

第二种情况是,当有一个或两个参数是右值表达式时,现在我必须使用完美转发来获得正确答案,这里的 requires 子句的目的是确保我只处理相同类型的表达式。

这种使用 C++20 Concept 的做法好多了,但我们还能做得更好吗?

剧透警告:是的。

如果我们想做得更好,我将选择一个不同的例子以求简单:identity(恒等函数)。你给我什么,我原封不动地还给你。

但我事先不知道这唯一参数的类型或值类别。所以需要提出并回答三个问题:

  1. 我们如何声明参数?

  2. 我们如何声明返回类型?

  3. 我该怎么写 return 语句的操作数?(其余的主要是样板代码)。

让我们逐一回答:

如何声明参数? 我们知道该怎么做,使用转发引用T&& 完全满足我们的需求,它捕获类型和值类别。

返回类型? 我已经剧透过了。我们必须保留任何引用限定符,所以 decltype(auto) 就是完成这项工作的正确工具。这也就是我为什么在一开始就针对它强调了那么久。

现在到了最有趣的地方:

如何写 return 的操作数?

我需要返回某种表达式,使得:如果我们在调用时传入了左值表达式,我就通过某种引用将其返回;如果调用时传入了右值表达式,我就按值将其返回。一旦我能同时做到这两点,我就实现了完美返回。

这是现在要解决的问题,并且我们已经拥有了所有需要的工具。头脑风暴一下:

  • 最明显的方法是 return t;。但这不行,因为这总是会进行拷贝。如果是左值,没有必要拷贝,我只想返回引用。

  • 如果在 C++23 环境下,有了名为 auto(x) 的新东西(语言级别的 decay_copy)。尝试在返回值上做完美转发如何? return FWD(t); 这对于左值有效。但它会导致右值产生悬垂引用,所以这无法完成工作。因为当调用者传入右值时,推导出的 T 是非引用,而 FWD 会将它强制转换为右值引用返回出去,立刻就悬垂了。我们需要按值返回,以确保在右值情况下不会悬垂。

在这里,奇迹出现了。我们要使用一个强转(cast),或者看起来像强转的东西:C风格的旧式强转

/* 生成代码,仔细甄别 */

template<typename T>
decltype(auto) identity(T&& t) {
    return (T)FWD(t); 
}

嗒哒!这就做了完全正确的事情。就一行代码。

  • 如果我是用一个左值表达式调用的:T 会被推导为左值引用(int&)。所以这是将一个左值引用强制转换(cast)为一个左值引用。正如我们前面所学,这完全是一个无用功(no-op)。它原封不动地返回了左值引用。

  • 如果我一开始传入的是一个右值表达式(比如临时变量):T 会被推导为非引用(int)。所以我调用的是目标类型的构造函数(因为强转非引用类型意味着构造)。执行强转,我得到了一个拷贝或移动构造的对象。由于这里适用拷贝省略 (copy elision) 规则,在大多数情况下,这个结果实际上是直接在调用者的内存空间中构造的,避免了任何额外的拷贝或移动。

如果你不喜欢使用旧式的强制转换语法,你可以这样写:

static_cast<T>(static_cast<T&&>(t))

(其中里面那个 static_cast 就是 FWD 宏展开的样子)。我认为这种写法晦涩难懂,近乎天书。写成 (T)FWD(t) 才是原汁原味的 C++ 风格。

现在让我们回到最初的那个典型案例 std::min

嗒哒。完成了。

/* 生成代码,仔细甄别 */

template <typename A, typename B>
    requires std::totally_ordered_with<A, B>
decltype(auto) perfect_min(A&& a, B&& b) {
    return std::cmp_less(b, a) ? (B)FWD(b) : (A)FWD(a);
}

快速回顾一下。我以参数 a 为例,对 b 也是完全一样适用的。

  • 如果你以左值表达式开始:其 decltype 将是左值引用,所以 A 被推导的类型也将是左值引用。而被转发的左值引用仍然是左值引用。所以 (A)FWD(a) 就是一个左值引用转左值引用,这是一个身份标识,是 no-op。成本为零。

  • 如果你以右值表达式开始:decltype 是右值引用。所以 A 被推导为非引用类型。被转发后的类型是右值引用。但是当你应用外部的那层强转 (A) 时,由于目标是非引用类型,你在调用构造函数(发生移动/拷贝),最后通过值返回。所以它做了正确的事。

  • 最后,最棒的是,三元运算符恰好完美契合!哪怕我遇到一列来自 A 类型,一列来自 B 类型(跨类型比较),三元运算符的求值规则恰好会基于这两个强转后的结果,给出一个安全且一致的共同类型返回。大功告成!

我们做得怎么样?还记得我提出的那五个改进目标吗?

  1. 没有不必要的拷贝。——勾选。

  2. 没有悬垂引用。——勾选。

  3. 不需要 const_cast。——勾选。

  4. 保持正确的结果。——勾选。

  5. 跨类型 (Cross typing) 支持。——勾选!你注意到了吗?三元运算符天生支持它。

还能支持仅可移动 (move-only) 的类型。——勾选。

在这个过程中,我认为我们获得了对语言更深入的见解。这一切最终凝结为了四行代码,其中有一行非常有趣。所有的这些绝对都是标准的 C++ 语言和库,没有任何凭空捏造。

这就是我所谓的“完美返回”:

  1. 使用转发引用来初始化一个或多个参数。

  2. 应用算法所需的任何逻辑。

  3. 打算返回传入的某个值时,你通过一个看似“强制转换”或“构造函数”的方式转发它。

我们在我们的两个案例中对此进行了原型验证,这就作为最后的复习了。

讲到这里,谢谢大家。

问答环节 (Q&A)

如果你们还有耐心,我也有时间。我很乐意回答问题,当然只能到某个时间点为止,我今晚还是得回家的。好了,看谁先来。大声点,我可是个老头子了。请讲。

提问者: 我有一个问题。为什么?既然编译器已经知道它是怎么回事,也知道调用者想要什么。它为什么要这么做?为什么要搞这么多复杂的规则,而不是直接给我想要的?我不想做所有这些工作,这对我来说是额外的工作。你刚才列出了两种可能的返回类型,我不想那么做。我只想写 decltype(auto) 就完事。但如果我使用你的代码,生命周期依然存在风险…

Walter: 不。编译器不知道你想要什么。编译器永远不知道你的“意图”,它只知道你“写了什么”。编译器怎么知道你在远端接收时打算把它绑定到一个引用上?你在看一个函数的实现时,并不知道调用者会对结果做什么。也许调用者打算拷贝它,那种情况下返回引用是安全的。记住一点:分离编译(Separate compilation)。我不知道你会对我的结果做什么,那也不关我的事。

提问者: 但是模板代码是特殊的…

Walter: 不,不,不。模板代码并不特殊。模板代码只是代码。它并不特殊。不开玩笑。模板并不特殊,它们只是不同,有自己的一套规则,我应该说“额外的规则”。但在使用模板编程时,它仍然只是编程。如果你不相信我,来参加我关于模板的为期两天的研讨会,相信我,两天就能搞明白。

这位先生,下一个是你。

提问者: 当你有两个引用发生“引用折叠”时,它们是如何在二进制层面折叠的?

Walter: 它在二进制层面不存在。引用类型的实体并不一定必须占用任何内存空间。它可能占用,也可能不占用。这取决于你的编译器实现。所以,抱歉,我没法告诉你它在二进制层面怎么工作的,因为这个问题不成立。

(提问者追问关于编译器现代优化的问题,因为麦克风收音不清)

Walter: 现代编译器非常好。如果你不相信我,去翻翻这位绅士——我们的朋友 Matt Godbolt(Compiler Explorer 的作者)的演讲。现代编译器惊人地优秀。它们并非没有 bug,但已经非常棒了。我已经很久没有发现过编译器 bug 了。

好的,回到你,先生。

提问者: 有任何常量右值引用(const T&&)的实际应用场景吗?

Walter: 不多。没有。但当时标准委员会没有理由去禁止它。我记不起来我是否曾经写过接收 const 右值引用的重载。唯一的应用场景可能是去主动 delete 某个函数(防止绑定),但我依然不太明白这有多大帮助。这取决于你的具体应用程序,我无法给你一概而论的答案。

有人还有问题吗?前排这位能读心术的先生。

提问者: (大致意思)我没有考虑过这么多复杂的特性。对于像比较函数,如果我知道输入是很小的整数类型,我宁愿直接按值传递 int x, int y 而不是用什么 const&。如果是大对象我可能才会考虑复杂的情况。

Walter: 我们都需要就如何声明我们的参数类型做出决定,我也尝试过类似你的思路。但这与我们刚才讨论的内容并没有什么关系。我们刚才讨论的重点不是“如何传递参数”,实际上也不全是为了比较操作。在大多数程序中,比较操作实际上处理得非常糟糕。我不知道为什么现在还会有人想自己编写 operator<operator>,直接写太空船运算符(Spaceship Operator,<=>)就完事了。也许如果你想做短路逻辑的话,可以补充一个 operator==(比如对于 std::vector,如果大小不同它们就不相等,你自己写一个快速判断的等于号,剩下的交给太空船去处理)。这是我今天的友情建议。我就不长篇大论了。

下一位?是的,女士。

提问者: 在你的 identity 实现中,关于使用 (T) 来转换被转发的参数。这种分两步的强转可以被封装成一个完美返回的特定函数吗?你能不能隐式地帮我做这个 T,而不是必须显式地写出来。

Walter: 可以封装,当然可以。但我不相信有什么“魔法棒”。说这话的人(指自己)以前可是靠做舞台魔术师谋生的,不然你以为我是怎么供自己读完研究生的?不开玩笑。

一般来说,我建议永远不要显式传递模板参数。当然,在某些情况下你无法避免,但通常类型推导就能做你想让它做的事情。话虽如此,我见过有人在他们自己的类型上显式调用 std::min<MyType>,这是一种应该被阻止的做法。原因非常务实:你正在对标准库的工作方式做出主观的假设。标准只规定了功能,并不规定实现。实现甚至不必看起来像标准规定的那样,它只需要在特定的名字下做正确的事情即可。

如果你有机会看一眼我个人实现的标准库,你甚至认不出它来,因为我不需要支持客户代码的兼容性,所以我可以为所欲为。更有趣的是,除了作为成员函数,我现在极少编写函数模板。我的所有算法实现都是 struct(结构体)。每一个都是!无论你调用 sortexchangeswap 还是 iter_swap 等等。它们都是带有一个函数调用操作符(operator())的结构体对象。

这让我彻底远离了 ADL(参数依赖查找)的地狱。如果我想启用 ADL,我可以在我的 struct 内部去做。如果你想知道怎么做,给我发封电子邮件,我会给你一些参考资料。这并不难,只是轻微的麻烦。它涉及到一些诸如被命名为“给声明投毒(poisoning a declaration)”的技术,也就是定义一个被设为 delete 的同名函数,迫使编译器启用 ADL 在其他命名空间里去寻找最佳匹配。我是 ADL 的粉丝,我对 ADL 没有意见,但我知道如何更优雅地编写我的算法。

还有人吗?这位先生。

提问者: 当你之前说“x 是 42”,这是一个任意值,但我不在乎它是什么… 大家都知道 42 是宇宙中最重要的数字吧?(指《银河系漫游指南》的梗)。

Walter: (笑)我知道有些人相信 42 是宇宙中最重要的数字。但我心里有比它重要得多的数字,比如 0。

先生,请讲。

提问者: 我对你展示的 FWD 宏很感兴趣。我看不到你该如何实现它。当你想做标准转发时,你得指定 T

Walter: 不,你不需要指定。decltype 是你的好朋友。我不知道大家有没有听清这个问题,他在问关于我应用的 FWD 宏如何不用显示传入类型。decltype 就是答案。

宏的定义是这样的:

#define FWD(x) static_cast<decltype(x)&&>(x)

你不需要额外去加引用之类,推导出来的就是对的。我见过的使用 forward 的唯一场景就是当你想转发一个参数时。当你转发参数时,你可以直接对那个参数求 decltype,它会给你你想要的大部分东西。如果任何人需要更多细节,给我发电子邮件,我会给你一行代码的回复。

Zoom 线上有人提问吗?没声音。这真是一个 Slack(聊天软件)式的沉默。非常抱歉 Zoom 上的朋友。

这位先生。

提问者: 关于你最终版本的完美实现。我同意把它折叠成你最后的四行代码更优雅。我的问题是,在性能或编译器输出等方面,我们能否假设它也会更加紧凑,还是说这取决于编译器?

Walter: 你可以肯定地假设,它占用的编译器资源会更少

如果我有 5 个不同的模板(针对左右值不同的重载集),那就是 5 种潜在的模板实例化分析,然后有 4 个被抛弃等等,这些都是编译时的工作量,不是免费的。现在只有一个模板,一次实例化,搞定。别忘了,我还免费获得了跨类型比较的支持!

我不能绝对肯定跨类型比较总是一个好主意,但有了三元运算符,它至少会做正确的事情。不知道你有没有注意到,前面的 min 是受到 Concept 约束的,确保比较运算符确实能够合法运行。

我希望我前面花了快一个小时大谈特谈语言底层的这些枯燥特性,在最终得到了回报。因为这就是我想要达到的目标。我希望大家至少能从中带走一点点东西。

好的,时间不多了,最后一个问题。请讲。

提问者: 你刚才说,什么时候三元运算符会返回右值引用?那是一个有效情况吗?

Walter: 如果你传递给三元运算符一对正确种类的表达式,是的,你可以在结果上获得右值引用。这里并没有专门列出单独的一种情况,而是隐式处理的。前面我们看到有好几个地方用到了转发引用,虽然它看起来可能会做错事,但因为存在上面更匹配的函数调用重载,所以它覆盖了那部分逻辑。而且三元操作符有非常复杂的类型推导规则。

我们关于将亡值(X-value)表达式讨论了很多。但实际上,在正常的代码中,你不可能“意外地”得到一个将亡值表达式。你几乎总是必须显式地写 std::move(xxx)(这不是唯一的方法,但绝对是最常见的)。所以这绝不是一个意外,当你有将亡值时,这就是在显式地请求编译器:“是的,我知道你这原本是个左值,但请把它当成纯右值来对待。” 我们恰好把这种特定的妥协状态称为将亡值。所以在底层,很多操作你其实只关注左值和右值,右值的概念把后两者(PR 值和 X 值)都包含了。我有时在表述上会有点随意,但这背后的推理逻辑就是这样。

提问者: 三元运算符在跨类型时,难道不会面临类型切片(type slicing)的问题吗?

Walter: 我还没有深入思考过这个问题,可能会吧,但大概率不常见。关于三元运算符,我们今晚没有讨论的东西还有很多。C++ 对它有一些极其怪异的规则。比如:如果其中一个操作数的类型是 void 会怎样?这是完全合法的!例如,在里面放一个 throw 表达式。对于三元运算符来说,throw 表达式的类型就是 void。三元运算符的细则在 C++ 标准里占了整整一页半还多非常非常小的字。

我最后再引用我的一位好朋友 Barry 总结的话:“三元运算符真是有毒(The ternary operator is toxic)。” 他说得对。

时间很晚了,朋友们。那么,跟 Gracie 说晚安吧。(注:“Say good night, Gracie” 是美国经典喜剧组合 Burns and Allen 结束表演时的经典台词,也是 Walter 的招牌谢幕语。)

谢谢大家。能和家乡的人交流总是一件乐事。