赋值运算符中奇怪的 C++14 和 C++17 差异
Posted
技术标签:
【中文标题】赋值运算符中奇怪的 C++14 和 C++17 差异【英文标题】:Weird C++14 and C++17 difference in assignment operator 【发布时间】:2021-12-14 03:15:25 【问题描述】:我有以下代码:
#include <vector>
#include <iostream>
std::vector <int> a;
int append()
a.emplace_back(0);
return 10;
int main()
a = 0;
a[0] = append();
std::cout << a[0] << '\n';
return 0;
函数append()
作为副作用将向量大小增加一。由于向量的工作方式,这可能会在超出其容量时触发其内存的重新分配。
因此,在执行a[0] = append()
时,如果发生重新分配,则a[0]
无效并指向向量的旧内存。因此,您可以预期该向量最终将是 0, 0
而不是 10, 0
,因为它分配给旧的 a[0]
而不是新的。
让我感到困惑的是,这种行为在 C++14 和 C++17 之间发生了变化。
在 C++14 上,程序将打印 0。在 C++17 上,它将打印 10,这意味着 a[0]
实际上分配了 10。所以,我有以下问题我找不到答案:
a[0]
的内存地址? C++14 之前是否评估过这个,这就是它改变的原因?
这是在 C++17 中修复的错误吗?标准有何变化?
在使用 C++14 或 C++11 时,是否有一种简洁的方法可以使此赋值的行为类似于 C++17 中的行为?
【问题讨论】:
相关/欺骗:***.com/questions/38501587/…Because of this you can expect
,因为它是 UB,你不能指望任何特定的行为。
它是未定义的,或者至少是未指定的,但这是 C++17 中的重大变化。
@BlazKorecic 如果首先评估子表达式 a[0]
,则结果为 UB,因为它是一个悬空引用并且您正在分配给它。在 C++17 之前,子表达式的求值顺序在很大程度上未指定,因此无法保证 a[0]
会在 append()
之前求值,但如果是,那么由于悬空引用,您的程序具有 UB。 C++17 将赋值运算符的求值顺序规则更改为从右到左,因此 C++17 保证 append()
在 a[0]
之前求值,因此没有悬空引用和 UB。跨度>
无论如何,你永远不想像你的例子那样编写代码,因为它不直观。像a[0] = append();
这样的代码没有理由存在于生产代码中。您的 append
函数不遵守 SRP(单一责任原则),因为它做了两件事(附加数据,返回任意值)。
【参考方案1】:
此代码是 C++17 之前的 UB,正如 cmets 中所指出的,由于 C++ evaluation order rules。基本问题:操作顺序不是评估顺序。甚至像 x++ + x++
这样的东西也是 UB。
在 C++17 中,赋值的排序规则发生了变化:
在每个简单的赋值表达式 E1=E2 和每个复合 赋值表达式 E1@=E2,每个值的计算和副作用 E2 在每个值计算和副作用之前排序 E1
【讨论】:
【参考方案2】:在 C++17 之前,该程序具有未定义的行为,因为未指定评估顺序并且 一个 可能的选择会导致使用无效的引用。 (这就是未定义行为的工作原理:即使您先记录两个评估和右侧日志,它也可能以另一种方式评估,未定义行为的影响是不正确的记录。)
虽然这是一个非正式的观点,但 C++17 中为指定某些运算符(包括 =
)的评估顺序所做的更改不被视为 错误 修复,这是为什么编译器不在先前的语言模式中实现新规则。 (损坏的是代码,而不是语言。)
清洁度是主观的,但处理此类排序问题的常用方法是引入一个临时变量:
// limit scope
auto x=append();
a[0]=x; // with std::move for non-trivial types
这有时会干扰将值传递给赋值运算符,这是无济于事的。
【讨论】:
【参考方案3】:虽然现在在更多情况下指定了规则,但只有在新规则使代码更易于理解、更高效或更正确时才应依赖新规则。
你的代码有很多问题:
您正在使用全局变量 你的函数做了 2 件不相关的事情 您的代码中有硬编码常量 您在同一个表达式中修改了两次全局向量更多详情
众所周知,应该避免使用全局变量。这甚至更糟糕,因为您的变量在单个字母名称 (a
) 中容易发生名称冲突。
append
函数做了两件事。它附加值并返回一个不相关的值。最好有两个独立的功能:
void append_0_to_a()
a.emplace_back(0);
// I have no idea what 10 represent in your code so I make a guess
int get_number_of_fingers()
const int hands = 2;
const int fingerPerHands = 5;
return hands * fingerPerHands;
编写后,主代码将更具可读性
a = 0 ;
append_0_to_a();
a[0] = get_number_of_fingers();
但即便如此,仍然不清楚为什么要使用这么多语法来修改向量。为什么不简单地写一些类似的东西
int main()
std::vector <int> a = get_number_of_fingers(), 0 ;
std::cout << a[0] << '\n';
return 0;
通过编写更简洁的代码,您不需要了解评估顺序的高级知识,其他阅读您的代码的人会更容易理解它。
虽然评估规则主要在 C++ 11 和 C++ 17 中更新,但不应滥用这些规则来编写难以阅读的代码。
改进了规则以使代码在某些情况下可见,例如当函数在参数中接收多个 std::unique_ptr
时。通过强制编译器在评估另一个参数之前完整地评估一个参数,它将使代码异常安全(无内存泄漏)在这种情况下:
// Just a simplified example --- not real code
void f(std::unique_ptr<int> a, std::unique_ptr<int> b)
...
f(new 2, new 3);
较新的规则确保一个参数的 std::unique_ptr
构造函数在另一个参数的新调用之前被调用,从而防止在第二次调用 new
时引发异常时可能发生的泄漏。
较新的规则还确保了一些对用户定义的函数和运算符很重要的排序,因此链接调用很直观。据我所知,这对于改进 future
s 和其他 asynch
等库很有用,因为在旧规则中存在太多未定义或未指定的行为。
正如我在评论中提到的,原始规则允许编译器进行更积极的优化。
像i++ + i++
这样的表达式本质上是未定义的,因为在一般情况下,变量是不同的(比如i
和j
),编译器可以重新排序指令以生成更高效的代码,而编译器不必这样做考虑变量可能重复的特殊情况,在这种情况下生成的代码可能会根据其实现方式给出不同的结果。
大部分原始规则基于不支持运算符重载的 C。
但是对于用户定义的类型,这种灵活性并不总是可取的,因为有时它会为看起来正确的代码提供错误的代码(如我上面简化的 f
函数)。
因此,更精确地定义了规则以解决此类不良行为。较新的规则也适用于预定义类型的运算符,例如 int
。
由于永远不应该编写依赖于未定义行为的代码,因此任何在 C++11 之前编写的有效程序在更严格的 C++ 11 规则下也是有效的。C++ 17 也是如此。
另一方面,如果您的程序是使用 C++17 规则编写的,那么如果您使用较旧的编译器进行编译,它可能会出现未定义的行为。
通常,人们会在不必返回旧编译器的时候开始使用更新的规则编写代码。
很明显,如果一个人为其他人编写库,那么他需要确保他的代码对于支持的 C++ 标准没有未定义的行为。
【讨论】:
以上是关于赋值运算符中奇怪的 C++14 和 C++17 差异的主要内容,如果未能解决你的问题,请参考以下文章