在gcc中意外调用了Const重载.编译器错误或兼容性修复程序

Const overload unexpectedly called in gcc. Compiler bug or compatibility fix?

本文关键字:错误 兼容性 程序 编译器 重载 gcc 意外 调用 Const      更新时间:2023-10-16

我们有一个更大的应用程序,它依赖于char和const char数组的模板重载。在gcc7.5、clang和visualstudio中,下面的代码在所有情况下都会打印"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

在我看来,这是一个编译器错误,但我想这可能是与语言更改有关的其他问题。有人确切知道吗?

当您从一个函数(指定了一个函数本地对象)返回一个纯id表达式时,编译器必须执行两次重载解析。首先,它将其视为一个右值,而不是一个左值。只有当第一次重载解析失败时,才会将对象作为左值再次执行。

[class.copy.elision]

3在以下复制初始化上下文中,移动操作可以用来代替复制操作:

  • 如果return语句中的表达式是一个(可能加了括号)id表达式,该表达式使用automatic在body或参数声明子句中声明的存储持续时间最里面的封闭函数或lambda表达式,或

  • 。。。

选择副本构造函数的重载解析是第一个执行时就好像对象是由右值指定的一样。如果第一个过载解析失败或未执行,或者如果所选构造函数的第一个参数不是右值引用对于对象的类型(可能是cv限定的),重载解析为再次执行,将对象视为左值。[注意:无论是否会发生复制省略,都必须执行此两阶段过载解析。如果不执行省略,它将确定要调用的构造函数,并且即使调用被省略,所选的构造函数也必须是可访问的。—尾注]

如果我们要添加右值过载,

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的行为,它认为第一个过载解决方案是成功的。这是因为常量左值引用可能绑定到右值。但是,它会忽略文本">",或者如果所选构造函数的第一个参数的类型不是对对象类型">"的右值引用。根据这一点,它必须放弃第一次过载解决的结果,然后再做一次。

无论如何,这就是C++17之前的情况。目前的标准草案有所不同。

如果第一次重载解析失败或未执行,则会再次执行重载解析,将表达式或操作数视为左值。

删除了C++17之前的文本。所以这是一个穿越时间的bug。GCC实现了C++20行为,但即使标准是C++17,它也会这样做。

评论中有一个关于这是否是"直觉行为"的争论,所以我想我会尝试一下这种行为背后的原因。

CPPCON有一个非常好的演讲,让我更清楚地了解了这一点(演讲,幻灯片)。基本上,一个使用非常量引用的函数意味着什么?输入对象必须是读/写。更强的是,它意味着我打算修改这个对象,这个函数有副作用。const-ref表示只读,rvalue-ref表示我可以占用资源。如果test_1()最终调用NON-CONST构造函数,这将意味着我打算修改这个对象,即使在我完成后它不再存在,这(我认为)将是一个错误(我认为在初始化期间如何绑定引用取决于传入的参数是否为常量)。

更让我担心的是test_2()引入的微妙之处。在这里,复制列表初始化是进行的,而不是上面引用的关于[class.copy.elision]的规则。现在您实际上是在说返回MyClass类型的对象,就好像我用buf初始化了它一样,所以NON-CONST行为被调用。我一直认为init列表是更简洁的方法,但这里的大括号在语义上有很大的不同。如果MyClass的构造函数采用了大量的参数,那么这将更加重要。然后,假设您希望创建一个buf,修改它,然后用大量参数返回它,调用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

现在,为了通过复制列表初始化获得正确的语义,缓冲区需要在最后"反弹"。我想,如果这个对象的目标是初始化其他MyClass对象,那么如果move/copy构造函数调用了任何合适的行为,那么只在返回副本列表中使用NON-CONST行为就可以了,但这听起来很微妙。