【问题标题】:Is it legal to use the increment operator in a C++ function call?在 C++ 函数调用中使用自增运算符是否合法?
【发布时间】:2009-02-28 15:22:44
【问题描述】:

this question 中关于以下代码是否合法 C++ 存在一些争论:

std::list<item*>::iterator i = items.begin();
while (i != items.end())
{
    bool isActive = (*i)->update();
    if (!isActive)
    {
        items.erase(i++);  // *** Is this undefined behavior? ***
    }
    else
    {
        other_code_involving(*i);
        ++i;
    }
}

这里的问题是erase() 将使有问题的迭代器无效。如果在评估 i++ 之前发生这种情况,那么像这样递增 i 在技术上是未定义的行为,即使它似乎适用于特定的编译器。争论的一方说,在调用函数之前,所有函数参数都已被完全评估。另一方说,“唯一的保证是 i++ 将在下一条语句之前和使用 i++ 之后发生。无论是在调用 erase(i++) 之前还是之后,都取决于编译器。”

我提出这个问题是为了解决这场争论。

【问题讨论】:

  • 喜欢这个问题。一种语言有一些令人着迷的地方,即使像 f(i++) 这样的微不足道的代码也可以引发关于语义的长期争论,对标准的多次交叉引用...... :)
  • 您在这里确实混合了两个问题:(1)在函数调用参数中使用后增量运算符是否通常合法,以及(2)特定用途是否会产生预期的结果。它总是合法的,但它是否会产生预期的结果取决于具体情况。

标签: c++ function standards


【解决方案1】:

C++ standard1.9.16:

调用函数时(无论是 不是函数是内联的),每个 价值计算和副作用 与任何论点相关联 表达式,或带有后缀 指定被调用者的表达 函数,先排序 执行每个表达式或 被调用者正文中的声明 功能。 (注:价值计算 和相关的副作用 不同的参数表达式是 未排序。)

所以在我看来,这段代码:

foo(i++);

完全合法。它将增加i,然后使用之前的i 值调用foo。但是,这段代码:

foo(i++, i++);

产生未定义的行为,因为第 1.9.16 段还说:

如果标量对象的副作用是 相对于另一个无序 对同一标量对象的副作用 或使用该值的值计算 同一个标量对象, 行为未定义。

【讨论】:

  • 这里不是未定义的。它会同时执行这两项操作,但顺序未定义。
  • 第二个是未定义的,是的。在同一个表达式中修改一个变量两次是未定义的,而不仅仅是它们被评估的顺序。实际上,两者都可能增加原始 i,而不是由另一个增量更新的 i。第一个没有问题。
  • foo(++i, ++i) 会不会更未定义?
  • 怎么会“更不确定”?
  • @Ed hehe 没错,0 怎么可能更像一个 0
【解决方案2】:

Kristo's answer 为基础,

foo(i++, i++);

会产生未定义的行为,因为函数参数的求值顺序是未定义的(在更一般的情况下,如果您在同时写入变量的表达式中读取变量两次,结果是未定义的)。你不知道哪个参数会先递增。

int i = 1;
foo(i++, i++);

可能导致函数调用

foo(2, 1);

foo(1, 2);

甚至

foo(1, 1);

运行以下命令以查看您的平台上发生了什么:

#include <iostream>

using namespace std;

void foo(int a, int b)
{
    cout << "a: " << a << endl;
    cout << "b: " << b << endl;
}

int main()
{
    int i = 1;
    foo(i++, i++);
}

在我的机器上我得到

$ ./a.out
a: 2
b: 1

每次,但这段代码不可移植,所以我希望使用不同的编译器会看到不同的结果。

【讨论】:

  • 它也可能导致 foo(2,2)。您不知道每个增量使用 i 的哪个值,它可能在两者中都使用原始值。理论上,它也可能导致 foo(42, -263),但我们可能不太可能从现实世界的编译器中看到这种特定结果。 ;)
  • 哦,等等,我们不是在所有示例中都差一个吗?不应该是 foo(1,1), foo(1,2) 和 foo(2,1) 吗?
  • 你确定吗?函数调用不会总是 foo(1,1) 吗?问题是我之后会有什么价值。
  • @Steve Rowe:假设首先计算最左边的增量,产生 1 作为函数的第一个参数,并将 i 设置为 2。然后计算最右边的增量,产生 2 作为第二个参数,并将最后的 i 设置为 3。然后使用 (1,2) 调用 foo,然后 i=3。
  • @Bill:不,它们不需要有不同的值,它是未定义的 (research.att.com/~bs/bs_faq2.html#evaluation-order)。不能保证它们会被按顺序评估(或者根本不会,如果我们想学究气的话)。
【解决方案3】:

标准说副作用发生在调用之前,所以代码同:

std::list<item*>::iterator i_before = i;

i = i_before + 1;

items.erase(i_before);

而不是:

std::list<item*>::iterator i_before = i;

items.erase(i);

i = i_before + 1;

所以在这种情况下是安全的,因为 list.erase() 不会使除已擦除之外的任何迭代器无效。

也就是说,这是不好的风格 - 所有容器的擦除函数专门返回下一个迭代器,因此您不必担心由于重新分配而导致迭代器无效,所以惯用代码:

i = items.erase(i);

对于列表是安全的,对于向量、双端队列和任何其他序列容器(如果您想更改存储)也是安全的。

如果没有警告,您也无法编译原始代码 - 您必须编写

(void)items.erase(i++);

为了避免出现未使用退货的警告,这将是一个很大的线索,表明您正在做一些奇怪的事情。

【讨论】:

  • erase() 返回一个用于序列容器的迭代器,但不用于 set 和 map 等关联容器。
  • 在帖子中添加了“序列”。有些实现确实有回报,但不是标准的。
  • 第一个例子应该是items.erase(i_before);
  • 您肯定需要将警告级别设置得非常高才能收到有关未使用的返回值的警告吗?不得不承认我从来没有为此烦恼过——它需要在大量完全有效的代码前面加上“(void)”。还是我错过了什么?
  • 可能没有那么多有效代码;我没有发现繁重的,并且发现了其他隐藏的错误。
【解决方案4】:

完全没问题。 传递的值将是增量前“i”的值。

【讨论】:

    【解决方案5】:

    ++克里斯托!

    C++ 标准 1.9.16 对如何为类实现 operator++(后缀)非常有意义。当调用该 operator++(int) 方法时,它会自增并返回原始值的副本。正如 C++ 规范所说的那样。

    很高兴看到标准在提高!


    但是,我清楚地记得使用旧的(ANSI 之前的)C 编译器,其中:

    foo -> bar(i++) -> charlie(i++);
    

    没有按照你的想法去做!相反,它编译为:

    foo -> bar(i) -> charlie(i); ++i; ++i;
    

    而且这种行为依赖于编译器实现。 (让移植变得有趣。)


    现在可以很容易地测试和验证现代编译器的行为是否正确:

    #define SHOW(S,X)  cout << S << ":  " # X " = " << (X) << endl
    
    struct Foo
    {
      Foo & bar(const char * theString, int theI)
        { SHOW(theString, theI);   return *this; }
    };
    
    int
    main()
    {
      Foo f;
      int i = 0;
      f . bar("A",i) . bar("B",i++) . bar("C",i) . bar("D",i);
      SHOW("END ",i);
    }
    


    正在回复帖子中的评论...

    ...在几乎每个人的答案的基础上...(谢谢大家!)


    我认为我们需要更好地说明这一点:

    给定:

    baz(g(),h());
    

    那么我们不知道 g() 会在之前还是之后 h() 被调用。它是“未指定”

    但我们确实知道 g()h() 都会在 baz()之前被调用>.

    给定:

    bar(i++,i++);
    

    同样,我们不知道哪个 i++ 将首先被评估,甚至可能不知道 i 是否会在 bar() 之前递增一次或两次 被调用。 结果未定义!(给定 i=0,这可能是 bar(0,0)bar(1,0 )bar(0,1) 或一些非常奇怪的东西!)


    给定:

    foo(i++);
    

    我们现在知道 i 将在 foo() 被调用之前递增。正如Kristothe C++ standard section 1.9.16:指出的那样

    调用函数时(无论函数是否内联),与任何参数表达式或指定被调用函数的后缀表达式相关的每个值计算和副作用,都在执行每个表达式或语句之前排序被调用函数的主体。 [注意:与不同参数表达式相关的值计算和副作用是无序的。 -- 尾注]

    虽然我认为第 5.2.6 节说得更好:

    后缀 ++ 表达式的值是其操作数的值。 [注:得到的值是原始值的副本——尾注] 操作数应为可修改的左值。操作数的类型应为算术类型或指向完整有效对象类型的指针。操作数对象的值通过加 1 来修改,除非该对象是 bool 类型,在这种情况下它被设置为 true。 [注意:不推荐使用此用法,请参阅附件 D。 -- 结束注释] ++ 表达式的值计算在操作数对象的修改之前排序。 对于一个不定序的函数调用,后缀++的操作是单次求值。 [ 注意:因此,函数调用不应干预左值到右值的转换以及与任何单个后缀 ++ 运算符相关的副作用。 -- end note ] 结果是一个右值。结果的类型是操作数类型的 cv 非限定版本。另见 5.7 和 5.17。

    该标准在 1.9.16 节中还列出(作为其示例的一部分):

    i = 7, i++, i++;    // i becomes 9 (valid)
    f(i = -1, i = -1);  // the behavior is undefined
    

    我们可以简单地证明这一点:

    #define SHOW(X)  cout << # X " = " << (X) << endl
    int i = 0;  /* Yes, it's global! */
    void foo(int theI) { SHOW(theI);  SHOW(i); }
    int main() { foo(i++); }
    

    所以,是的,ifoo() 被调用之前递增。


    从以下角度来看,所有这些都很有意义:

    class Foo
    {
    public:
      Foo operator++(int) {...}  /* Postfix variant */
    }
    
    int main() {  Foo f;  delta( f++ ); }
    

    这里 Foo::operator++(int) 必须在 delta() 之前调用。并且增量操作必须在该调用期间完成。


    在我(也许过于复杂)的例子中:

    f . bar("A",i) . bar("B",i++) . bar("C",i) . bar("D",i);
    

    f.bar("A",i) 必须执行才能获得用于object.bar("B",i++)的对象,以此类推对于 "C""D"

    所以我们知道 i++ 在调用 bar("B",i++) 之前会增加 i(即使 bar( "B",...) 使用 i) 的旧值调用,因此 ibar("C ",i)bar("D",i).


    回到 j_random_hacker 的评论:

    j_random_hacker 写道:
    +1,但我必须仔细阅读标准以说服自己这没问题。我是否正确地认为,如果 bar() 是一个返回say int 的全局函数,f 是一个 int,并且这些调用是通过say "^" 而不是 "." 连接的,那么 A、C 和 D 中的任何一个都可以报告“0”?

    这个问题比你想象的要复杂得多......

    将您的问题重写为代码...

    int bar(const char * theString, int theI) { SHOW(...);  return i; }
    
    bar("A",i)   ^   bar("B",i++)   ^   bar("C",i)   ^   bar("D",i);
    

    现在我们只有 ONE 表达式。根据标准(第 1.9 节,第 8 页,pdf 第 20 页):

    注意:只有在运算符真正具有关联性或交换性的情况下,运算符才能根据通常的数学规则重新组合。(7) 例如,在以下片段中:a=a+32760+b+5;表达式语句的行为完全相同: a=(((a+32760)+b)+5);由于这些运算符的关联性和优先级。因此,和的结果 (a+32760) 接下来被添加到 b,然后将该结果添加到 5,这导致分配给 a 的值。在溢出产生异常并且 int 可表示的值范围为 [-32768,+32767] 的机器上,实现不能将此表达式重写为 a=((a+b)+32765);因为如果 a 和 b 的值分别为 -32754 和 -15,则 a+b 之和会产生异常,而原始表达式不会;也不能将表达式重写为 a=((a+32765)+b);或 a=(a+(b+32765));因为 a 和 b 的值可能分别是 4 和 -8 或 -17 和 12。但是在溢出不会产生异常并且溢出结果是可逆的机器上,上述表达式语句可以由实现以上述任何方式重写,因为将出现相同的结果。 -- 尾注]

    所以我们可能会认为,由于优先级,我们的表达式将与以下内容相同:

    (
           (
                  ( bar("A",i) ^ bar("B",i++)
                  )
              ^  bar("C",i)
           )
        ^ bar("D",i)
    );
    

    但是,因为 (a^b)^c==a^(b^c) 没有任何可能的溢出情况,所以可以按任意顺序重写...

    但是,因为 bar() 正在被调用,并且可能涉及副作用,所以这个表达式不能以任何顺序重写。优先规则仍然适用。

    这很好地确定了 bar() 的评估顺序

    现在,i+=1 什么时候发生?那么它仍然必须在 bar("B",...) 被调用之前发生。 (即使 bar("B",....) 使用旧值调用。)

    所以它确定性地发生在 bar(C)bar(D) 之前,以及 bar(A) 之后。

    回答:否。我们将始终拥有“A=0, B=0, C=1, D=1”,如果编译器符合标准。


    但考虑另一个问题:

    i = 0;
    int & j = i;
    R = i ^ i++ ^ j;
    

    R的值是多少?

    如果 i+=1 发生在 j 之前,我们将有 0^0^1=1。但是如果 i+=1 出现在整个表达式之后,我们就会有 0^0^0=0。

    事实上,R 为零。 i+=1 直到表达式被计算之后才会出现。


    我认为这是为什么:

    i = 7, i++, i++; // i 变为 9(有效)

    是合法的……它有三种表达方式:

    • i = 7
    • i++
    • i++

    在每种情况下,i 的值都会在每个表达式的结尾发生变化。 (在计算任何后续表达式之前。)


    PS:考虑一下:

    int foo(int theI) { SHOW(theI);  SHOW(i);  return theI; }
    i = 0;
    int & j = i;
    R = i ^ i++ ^ foo(j);
    

    在这种情况下,i+=1 必须在 foo(j) 之前计算。 theI 是 1。而 R 是 0^0^1=1。

    【讨论】:

    • +1,但我必须仔细阅读标准以说服自己这没问题。我是否正确地认为,如果 bar() 是一个返回say int 的全局函数,f 是一个 int,并且这些调用是通过say "^" 而不是 "." 连接的,那么 A、C 和 D 中的任何一个都可以报告“0”?
    • 回应修改原帖。 (花了 300 多个字符。)
    • R 示例由于在未排序的情况下读取和递增 i 而具有未定义的行为。
    【解决方案6】:

    以 MarkusQ 的回答为基础:;)

    或者更确切地说,比尔对此的评论:

    编辑:啊,评论又不见了……哦,好吧)

    它们被允许并行评估。它是否在实践中发生在技术上无关紧要。

    不过,您不需要线程并行性来实现这一点,只需在第二步(递增 i)之前评估两者的第一步(取 i 的值)。完全合法,一些编译器可能认为它比在开始第二个 i++ 之前完全评估一个 i++ 更有效。

    事实上,我希望它是一种常见的优化。从指令调度的角度来看。您需要评估以下内容:

    1. 将 i 的值作为正确的参数
    2. 在右参数中增加 i
    3. 将 i 的值作为左参数
    4. 在左参数中增加 i

    但是左右参数之间确实没有依赖关系。参数评估以未指定的顺序发生,也不需要按顺序进行(这就是为什么函数参数中的 new() 通常是内存泄漏,即使包装在智能指针中) 当您在同一个表达式中修改同一个变量两次时会发生什么,这也是未定义的。 但是,我们确实在 1 和 2 之间以及在 3 和 4 之间存在依赖关系。 那么为什么编译器会在计算 3 之前等待 2 完成呢?这会增加延迟,并且在 4 可用之前需要更长的时间。 假设每个之间有 1 个周期的延迟,那么从 1 完成到 4 的结果准备好并且我们可以调用该函数需要 3 个周期。

    但如果我们对它们重新排序并按照 1、3、2、4 的顺序进行评估,我们可以在 2 个周期内完成。 1 和 3 可以在同一个循环中启动(甚至可以合并到一条指令中,因为它是同一个表达式),然后可以计算 2 和 4。 所有现代 CPU 每个周期都可以执行 3-4 条指令,一个好的编译器应该尝试利用这一点。

    【讨论】:

    • 分析编译器可能对未定义的东西做什么有什么意义?如果你想要速度,不为未定义的表达式发出代码不是更好吗?
    • 编译器对未定义表达式的处理与速度或其他方面无关,因为首先在您的代码中不应该有任何未定义的内容。如果你的代码调用了未定义的行为,你就已经迷路了。 ;)
    • 我的帖子中的要点只是为了说明表达式可以产生“令人惊讶”值的一种方式,以及编译器实际上可能选择这样做而不是更可预测的结果的原因。当然,依赖任何这些都是愚蠢的。 :)
    【解决方案7】:

    Sutter 的 Guru of the Week #55(以及“More Exceptional C++”中的相应文章)以这种确切的情况为例进行了讨论。

    据他说,这是完全有效的代码,实际上是试图将语句转换为两行的情况:

    items.erase(i); 我++;

    是否生成与原始语句语义等效的代码。

    【讨论】:

      【解决方案8】:

      以蜥蜴比尔的回答为基础:

      int i = 1;
      foo(i++, i++);
      

      也可能导致函数调用

      foo(1, 1);
      

      (意味着并行计算实际值,然后应用 postops)。

      -- MarkusQ

      【讨论】:

      • 我认为它应该是 foo(1,1)。比尔早些时候犯了一个错误,我认为 MarkusQ 只是在他的帖子中复制了这一点
      • 根据标准,它也可能导致计算机变成填充熊猫。未定义是未定义的,争论一个特定的实现可能会做什么是没有意义的。
      • @DavidThornley 希望有实现将 PC 变成填充熊猫...
      猜你喜欢
      • 2020-03-03
      • 2017-07-29
      • 2019-03-06
      • 1970-01-01
      • 2011-01-27
      • 1970-01-01
      • 2020-05-12
      • 2018-05-09
      • 2011-02-23
      相关资源
      最近更新 更多