在 gcc 中意外调用了 Const 重载。编译器错误或兼容性修复?
Const overload unexpectedly called in gcc. Compiler bug or compatibility fix?
我们有一个更大的应用程序,它依赖于 char 和 const char 数组的模板重载。在 gcc 7.5、clang 和 visual studio 中,下面的代码在所有情况下都打印 "NON-CONST"。但是,对于 gcc 8.1 及更高版本,输出如下所示:
#include <iostream>
class MyClass
{
public:
template <size_t N>
MyClass(const char (&value)[N])
{
std::cout << "CONST " << value << '\n';
}
template <size_t N>
MyClass(char (&value)[N])
{
std::cout << "NON-CONST " << value << '\n';
}
};
MyClass test_1()
{
char buf[30] = "test_1";
return buf;
}
MyClass test_2()
{
char buf[30] = "test_2";
return {buf};
}
void test_3()
{
char buf[30] = "test_3";
MyClass x{buf};
}
void test_4()
{
char buf[30] = "test_4";
MyClass x(buf);
}
void test_5()
{
char buf[30] = "test_5";
MyClass x = buf;
}
int main()
{
test_1();
test_2();
test_3();
test_4();
test_5();
}
gcc 8 和 9 输出(来自 godbolt)是:
CONST test_1
NON-CONST test_2
NON-CONST test_3
NON-CONST test_4
NON-CONST test_5
在我看来,这似乎是一个编译器错误,但我想这可能是与语言更改相关的其他一些问题。有人确切地知道吗?
当您 return 来自函数(指定函数局部对象)的普通 id 表达式时,编译器被强制执行两次重载解析。首先,它将它视为右值,而不是左值。只有第一次重载决议失败,才会以对象为左值再次执行。
[class.copy.elision]
3 In the following copy-initialization contexts, a move operation
might be used instead of a copy operation:
If the expression in a return statement is a (possibly parenthesized) id-expression that names an object with automatic
storage duration declared in the body or parameter-declaration-clause
of the innermost enclosing function or lambda-expression, or
...
overload resolution to select the constructor for the copy is first
performed as if the object were designated by an rvalue. If the first
overload resolution fails or was not performed, or if the type of the
first parameter of the selected constructor is not an rvalue reference
to the object's type (possibly cv-qualified), overload resolution is
performed again, considering the object as an lvalue. [ Note: This two-stage overload resolution must be performed regardless of whether copy elision will occur. It determines the constructor to be called if elision is not performed, and the selected constructor must be accessible even if the call is elided. — end note ]
如果我们要添加右值重载,
template <size_t N>
MyClass (char (&&value)[N])
{
std::cout << "RVALUE " << value << '\n';
}
输出会变成
RVALUE test_1
NON-CONST test_2
NON-CONST test_3
NON-CONST test_4
NON-CONST test_5
这是正确的。如您所见,不正确的是 GCC 的行为。它认为第一个重载决议是成功的。这是因为 const 左值引用可以绑定到右值。但是,它会忽略文本 "or if the type of the first parameter of the selected constructor is not an rvalue reference to the object's type"。根据它必须丢弃第一次重载决议的结果,然后再做一次。
好吧,无论如何,直到 C++17 都是这种情况。当前的标准草案有不同的说法。
If the first overload resolution fails or was not performed, overload resolution is performed again, considering the expression or operand as an lvalue.
删除了 C++17 之前的文本。所以这是一个时间旅行的错误。 GCC 实现了 C++20 行为,但即使标准是 C++17,它也会这样做。
评论中有关于这是否是 "intuitive behavior" 的争论,所以我想我会刺探一下这种行为背后的原因。
在 CPPCON 上有一个很好的演讲,让我更清楚了这一点 {talk, slides}。基本上,采用非常量引用的函数意味着什么?输入对象必须是read/write。更厉害的是,它暗示我打算修改这个对象,这个函数有副作用。 const ref 意味着只读,而右值 ref 意味着我可以使用资源。如果 test_1()
最终调用 NON-CONST
构造函数,这将意味着 我打算修改这个对象,即使在我完成之后它不再存在, 这(我认为)将是一个错误(我正在考虑在初始化期间如何绑定引用取决于传入的参数是否为 const 的情况)。
我更关心的是 test_2()
引入的微妙之处。在这里,copy-list-initialization 代替了上面引用的关于 [class.copy.elision] 的规则。现在你真的在说 return 一个 MyClass 类型的对象,就好像我已经用 buf
, 初始化了它,所以调用了 NON-CONST
行为。我一直认为 init-lists 是更简洁的方法,但这里的大括号在语义上有很大的不同。如果 MyClass
的构造函数采用大量参数,这会更重要。然后,假设您希望创建一个 buf
,修改它,然后使用大量参数 return 它,调用 CONST
行为。例如,假设您有构造函数:
template <size_t N>
MyClass(const char (&value)[N], int)
{
std::cout << "CONST int " << value << '\n';
}
template <size_t N>
MyClass(char (&value)[N], int)
{
std::cout << "NON-CONST int " << value << '\n';
}
并测试:
MyClass test_0() {
char buf[30] = "test_0";
return {buf,0};
}
Godbolt 告诉我们我们得到了 NON-CONST
行为,即使 CONST
可能是我们想要的(在你喝了函数参数语义上的 cool-aid 之后)。但是现在复制列表初始化并没有按照我们的意愿进行。下面的测试让我的观点更好:
MyClass test_0() {
char buf[30] = "test_0";
buf[0] = 'T';
const char (&bufR)[30]{buf};
return {bufR,0};
}
// OUTPUT: CONST int Test_0
现在要通过复制列表初始化获得正确的语义,缓冲区需要在末尾 "rebound"。我想如果这个对象的目标是初始化其他一些 MyClass
对象,那么只要使用 return 复制列表中的 NON-CONST
行为就可以了,如果 move/copy-constructor调用任何适当的行为,但这听起来很微妙。
我们有一个更大的应用程序,它依赖于 char 和 const char 数组的模板重载。在 gcc 7.5、clang 和 visual studio 中,下面的代码在所有情况下都打印 "NON-CONST"。但是,对于 gcc 8.1 及更高版本,输出如下所示:
#include <iostream>
class MyClass
{
public:
template <size_t N>
MyClass(const char (&value)[N])
{
std::cout << "CONST " << value << '\n';
}
template <size_t N>
MyClass(char (&value)[N])
{
std::cout << "NON-CONST " << value << '\n';
}
};
MyClass test_1()
{
char buf[30] = "test_1";
return buf;
}
MyClass test_2()
{
char buf[30] = "test_2";
return {buf};
}
void test_3()
{
char buf[30] = "test_3";
MyClass x{buf};
}
void test_4()
{
char buf[30] = "test_4";
MyClass x(buf);
}
void test_5()
{
char buf[30] = "test_5";
MyClass x = buf;
}
int main()
{
test_1();
test_2();
test_3();
test_4();
test_5();
}
gcc 8 和 9 输出(来自 godbolt)是:
CONST test_1
NON-CONST test_2
NON-CONST test_3
NON-CONST test_4
NON-CONST test_5
在我看来,这似乎是一个编译器错误,但我想这可能是与语言更改相关的其他一些问题。有人确切地知道吗?
当您 return 来自函数(指定函数局部对象)的普通 id 表达式时,编译器被强制执行两次重载解析。首先,它将它视为右值,而不是左值。只有第一次重载决议失败,才会以对象为左值再次执行。
[class.copy.elision]
3 In the following copy-initialization contexts, a move operation might be used instead of a copy operation:
If the expression in a return statement is a (possibly parenthesized) id-expression that names an object with automatic storage duration declared in the body or parameter-declaration-clause of the innermost enclosing function or lambda-expression, or
...
overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue. If the first overload resolution fails or was not performed, or if the type of the first parameter of the selected constructor is not an rvalue reference to the object's type (possibly cv-qualified), overload resolution is performed again, considering the object as an lvalue. [ Note: This two-stage overload resolution must be performed regardless of whether copy elision will occur. It determines the constructor to be called if elision is not performed, and the selected constructor must be accessible even if the call is elided. — end note ]
如果我们要添加右值重载,
template <size_t N>
MyClass (char (&&value)[N])
{
std::cout << "RVALUE " << value << '\n';
}
输出会变成
RVALUE test_1
NON-CONST test_2
NON-CONST test_3
NON-CONST test_4
NON-CONST test_5
这是正确的。如您所见,不正确的是 GCC 的行为。它认为第一个重载决议是成功的。这是因为 const 左值引用可以绑定到右值。但是,它会忽略文本 "or if the type of the first parameter of the selected constructor is not an rvalue reference to the object's type"。根据它必须丢弃第一次重载决议的结果,然后再做一次。
好吧,无论如何,直到 C++17 都是这种情况。当前的标准草案有不同的说法。
If the first overload resolution fails or was not performed, overload resolution is performed again, considering the expression or operand as an lvalue.
删除了 C++17 之前的文本。所以这是一个时间旅行的错误。 GCC 实现了 C++20 行为,但即使标准是 C++17,它也会这样做。
评论中有关于这是否是 "intuitive behavior" 的争论,所以我想我会刺探一下这种行为背后的原因。
在 CPPCON 上有一个很好的演讲,让我更清楚了这一点 {talk, slides}。基本上,采用非常量引用的函数意味着什么?输入对象必须是read/write。更厉害的是,它暗示我打算修改这个对象,这个函数有副作用。 const ref 意味着只读,而右值 ref 意味着我可以使用资源。如果 test_1()
最终调用 NON-CONST
构造函数,这将意味着 我打算修改这个对象,即使在我完成之后它不再存在, 这(我认为)将是一个错误(我正在考虑在初始化期间如何绑定引用取决于传入的参数是否为 const 的情况)。
我更关心的是 test_2()
引入的微妙之处。在这里,copy-list-initialization 代替了上面引用的关于 [class.copy.elision] 的规则。现在你真的在说 return 一个 MyClass 类型的对象,就好像我已经用 buf
, 初始化了它,所以调用了 NON-CONST
行为。我一直认为 init-lists 是更简洁的方法,但这里的大括号在语义上有很大的不同。如果 MyClass
的构造函数采用大量参数,这会更重要。然后,假设您希望创建一个 buf
,修改它,然后使用大量参数 return 它,调用 CONST
行为。例如,假设您有构造函数:
template <size_t N>
MyClass(const char (&value)[N], int)
{
std::cout << "CONST int " << value << '\n';
}
template <size_t N>
MyClass(char (&value)[N], int)
{
std::cout << "NON-CONST int " << value << '\n';
}
并测试:
MyClass test_0() {
char buf[30] = "test_0";
return {buf,0};
}
Godbolt 告诉我们我们得到了 NON-CONST
行为,即使 CONST
可能是我们想要的(在你喝了函数参数语义上的 cool-aid 之后)。但是现在复制列表初始化并没有按照我们的意愿进行。下面的测试让我的观点更好:
MyClass test_0() {
char buf[30] = "test_0";
buf[0] = 'T';
const char (&bufR)[30]{buf};
return {bufR,0};
}
// OUTPUT: CONST int Test_0
现在要通过复制列表初始化获得正确的语义,缓冲区需要在末尾 "rebound"。我想如果这个对象的目标是初始化其他一些 MyClass
对象,那么只要使用 return 复制列表中的 NON-CONST
行为就可以了,如果 move/copy-constructor调用任何适当的行为,但这听起来很微妙。