C++14 和 C++17 赋值运算符的奇怪区别

Weird C++14 and C++17 difference in assignment operator

我有以下代码:

#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] 将失效并指向vector 的旧内存。正因为如此,你可以期望向量最终成为 {0, 0} 而不是 {10, 0},因为它分配给旧的 a[0] 而不是新的

让我感到困惑的奇怪事情是这种行为在 C++14 和 C++17 之间发生了变化。

在 C++14 上,程序将打印 0。在 C++17 上,它将打印 10,这意味着 a[0] 实际上分配给了 10。所以,我有以下问题我找不到答案:

由于 C++ evaluation order rules,正如评论中指出的那样,此代码是 C++17 之前的 UB。基本问题:操作顺序不是评估顺序。即使像 x++ + x++ 这样的东西也是 UB。

在 C++17 中,赋值的排序规则发生了变化:

  1. In every simple assignment expression E1=E2 and every compound assignment expression E1@=E2, every value computation and side-effect of E2 is sequenced before every value computation and side effect of E1

程序在 C++17 之前具有未定义的行为,因为评估顺序未指定并且 一个 可能的选择导致使用无效的引用。 (这就是未定义行为的工作原理:即使您首先记录了两个评估和右侧日志,它也可能已经评估了另一种方式,未定义行为的影响是不正确的记录。)

虽然这是一个非正式的问题,但 C++17 中指定某些运算符(包括 =)的求值顺序的更改不被视为 错误 修复,这就是为什么编译器不在先前的语言模式中实施新规则的原因。 (损坏的是代码,而不是语言。)

清洁度是主观的,但处理此类排序问题的通常方法是引入一个临时变量:

{  // limit scope
  auto x=append();
  a[0]=x;  // with std::move for non-trivial types
}

这偶尔会干扰按值传递给赋值运算符,这是没有办法的。

虽然现在在更多情况下指定了规则,但只有当新规则使代码更易于理解或更高效或更正确时,才应依赖新规则。

您的代码有很多问题:

  • 您正在使用全局变量
  • 你的函数做了 2 件不相关的事情
  • 您在代码中有硬编码常量
  • 您在同一表达式中两次修改全局向量

更多详情

众所周知,应该避免使用全局变量。这更糟糕,因为您的变量在单个字母名称 (a) 中很容易发生名称冲突。

append 函数做了两件事。它附加值和 return 一个不相关的值。最好有2个独立的功能:

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.[=26 时抛出异常时可能发生的泄漏=]

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

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

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

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

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

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

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

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

通常,人们会在不需要 return 旧编译器的时候开始使用较新的规则进行编写。

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