赋值运算符中奇怪的 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。所以,我有以下问题我找不到答案:

C++17 是否在评估赋值表达式的 RHS 后评估 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 时引发异常时可能发生的泄漏。

较新的规则还确保了一些对用户定义的函数和运算符很重要的排序,因此链接调用很直观。据我所知,这对于改进 futures 和其他 asynch 等库很有用,因为在旧规则中存在太多未定义或未指定的行为。

正如我在评论中提到的,原始规则允许编译器进行更积极的优化。

i++ + i++ 这样的表达式本质上是未定义的,因为在一般情况下,变量是不同的(比如ij),编译器可以重新排序指令以生成更高效的代码,而编译器不必这样做考虑变量可能重复的特殊情况,在这种情况下生成的代码可能会根据其实现方式给出不同的结果。

大部分原始规则基于不支持运算符重载的 C。

但是对于用户定义的类型,这种灵活性并不总是可取的,因为有时它会为看起来正确的代码提供错误的代码(如我上面简化的 f 函数)。

因此,更精确地定义了规则以解决此类不良行为。较新的规则也适用于预定义类型的运算符,例如 int

由于永远不应该编写依赖于未定义行为的代码,因此任何在 C++11 之前编写的有效程序在更严格的 C++ 11 规则下也是有效的。C++ 17 也是如此。

另一方面,如果您的程序是使用 C++17 规则编写的,那么如果您使用较旧的编译器进行编译,它可能会出现未定义的行为。

通常,人们会在不必返回旧编译器的时候开始使用更新的规则编写代码。

很明显,如果一个人为其他人编写库,那么他需要确保他的代码对于支持的 C++ 标准没有未定义的行为。

【讨论】:

以上是关于赋值运算符中奇怪的 C++14 和 C++17 差异的主要内容,如果未能解决你的问题,请参考以下文章

C中奇怪的初始化

C ++中奇怪的运行时异常

翻译过程中奇怪的 MVP 行为

CodeIgniter 2.0.3 中 Active Record 中奇怪的反引号行为

UIImageView 中奇怪的对齐行为

C++ 中奇怪的函数声明和 lambdas