P2266R3: Simpler implicit move 对 C++23 函数返回表达式值类别的改变

写在前面

本篇内容参考提案 Simpler implicit move

隐式移动是函数返回值优化的一种,在 C++ 不同版本有不同的规则,这篇文章主要讲隐式移动,不涉及其他返回值优化的内容。

变量表达式(id-expression)指的是仅由变量名组成的表达式,比如 x 或者 (x)。虽然变量本身可能是值类型或者右值引用类型,但是变量表达式是左值。

值类别可见 Value Categories

隐式移动的设计难题

隐式移动使得函数可以在更多的场景用移动构造函数来构造返回对象,从而避免拷贝。但是,隐式移动通常要求编译器在特定的条件下改变返回的表达式的值类别(lvalue / rvalue),这使得一些函数表现出让人意外的语义。

隐式移动的历史

C++11 允许构造返回对象时隐式移动值类型表达式

如果函数返回的是值类型的表达式,且函数声明的返回值类型是一个值类型,则编译器会将返回的表达式隐式转换成亡值(不必我们写 return std::move(x))用于匹配移动构造函数。

struct Widget {
    Widget(Widget&&); // Widget 只能移动,不能复制
};
struct RRefTaker {
    RRefTaker(Widget&&);
};
RRefTaker two(Widget w) {
    return w;  // OK since C++11 + CWG1579
}

C++20 允许构造返回对象时隐式移动亡值变量表达式

参考 P0527  和  P1155

C++11 时,亡值变量表达式由于是变量表达式的一种,因而在返回时被当成左值看待,这可能导致不必要的拷贝:比如返回一个 std::string && 类型的变量,但是函数返回值被声明为 std::string 类型。

C++20 允许亡值变量表达式在用来构造返回对象时的隐式移动。这虽然解决了上一段提到的问题,但是仍然不允许亡值变量表达式在函数返回右值引用类型时的隐式移动:这个时候仍然需要手动添加 std::move,否则无法编译(左值引用不能绑定在右值引用上)。

RRefTaker three(Widget&& w) {
    return w;  // OK since C++20 because of P0527
               // 在 C++20 之前,w 是左值,因而无法匹配移动构造函数,代码无法编译
}

C++20 的隐式移动还有什么问题?

C++20 为了照顾隐式移动亡值变量表达式以构造返回对象的情况,先把变量表达式当右值匹配,失败时则当作左值。这样做有一些问题,下文提到的 A、B 两点是 C++20 规则中对右值引用返回值处理的遗漏,而 C 点是其实现手段带来的新问题。

Note

C++20 之前出现在返回值位置的变量表达式都是左值。

A. 不能把亡值变量表达式绑定在右值引用返回值上

Widget&& four(Widget&& w) {
    return w;  // OK since C++23
}
Widget& five(Widget&& w) {
    return w;  // OK since C++11, until C++23
}
template<class T>
T&& seven(T&& x) { return x; }

void test_seven(Widget w) {
    Widget& r = seven(w);               // OK
    Widget&& rr = seven(std::move(w));  // Error
}

Widget& r = seven(w); 中 seven 的 T 类型是 Widget &,引用折叠后得到的返回值类型是 Widget &。而 Widget&& rr = seven(std::move(w)); 中 seven 的 T 类型是 Widget,得到的返回值类型是Widget &&

B. 对指针和引用的处理不同

struct Mutt {
    operator int*() &&;
};

struct Jeff {
    operator int&() &&;
};

int* five(Mutt x) {
    return x;  // OK since C++20 because P1155
}

int& six(Jeff x) {
    return x;  // Error
}

由于指针是一个可构造的实体(之前叫做对象,但是后来被修订成实体),所以可以 five 触发隐式移动,而 six 要返回的是引用类型,不能触发隐式移动,所以无法匹配上 operator int&

C. 意料之外的函数重载选择

struct Sam {
    Sam(Widget&);        // #1
    Sam(const Widget&);  // #2
};

Sam twelve() {
    Widget w;
    return w;  // calls #2 since C++20 because P1155
}

先将 w 当右值,和常引用的匹配度更高,因此选择 Sam(const Widget&),而把 w 当左值的匹配没有进行。

C++23 的处理(P2266R3)

C++23 中,如果返回的是 id-expression,且它是 move-eligible 的,那么就将其隐式移动。同时还废弃了 C++20 变量表达式先试右值再试左值的两次决议过程。

Note

返回值的类型推导(包括带有 decltype 的情况)发生在确定返回表达式的类型之前,因此是先确定了返回类型,才开始考虑要不要隐式移动。

C++23 里以下代码的 f2g2 将非法,因为返回类型是 int&,返回表达式的类型却是 int&&,两者无法匹配。参考 3.2.1. Interaction with decltype and decltype(auto)

auto f1(int x) -> decltype(x)    { return (x); }  // int
auto f2(int x) -> decltype((x))  { return (x); }  // int&. Ill-formed in C++23
auto f3(int x) -> decltype(auto) { return (x); }  // C++20: int&. Proposed: int&&
auto g1(int x) -> decltype(x)    { return x; }    // int
auto g2(int x) -> decltype((x))  { return x; }    // int&. Ill-formed in C++23
auto g3(int x) -> decltype(auto) { return x; }    // int

如何修改代码适配 C++23

下面的代码片段都是在 C++20 中编译能成功,但在 C++23 中会失败。其实改起来很简单:手动进行类型转换,使得返回的不是变量表达式(而是复合表达式),新规则就不适用了。

LibreOffice OString

// C++20
struct X {
    X(auto&);
};

X f() {
    char a[10];
    return a;
}

f() 函数改成:

// C++23
X f() {
    char a[10];
    return X(a); // 由于返回的不是变量表达式,所以适用于普通规则
}

LibreOffice o3tl::temporary

// 转任何引用为左值引用
template<class T>
T& temporary(T&& x) { return x; }

模板部分改成:

// C++23
template<class T>
T& temporary(T&& x) { return static_cast<T&>(x); }