首页 文章

为什么f(i = -1,i = -1)未定义的行为?

提问于
浏览
258

我正在阅读order of evaluation violations,他们举了一个令我困惑的例子 .

1)如果标量对象的副作用相对于同一标量对象的另一个副作用未按顺序排列,则行为未定义 . //剪断
f(i = -1,i = -1); //未定义的行为

在这种情况下, i 是一个标量对象,显然意味着

算术类型(3.9.1),枚举类型,指针类型,指向成员类型的指针(3.9.2),std :: nullptr_t和这些类型的cv限定版本(3.9.3)统称为标量类型 .

在这种情况下,我不明白该陈述是如何含糊不清的 . 在我看来,无论第一个或第二个参数是否先被评估, i 最终都是 -1 ,两个参数也是 -1 .

有人可以澄清一下吗?


更新

我非常感谢所有的讨论 . 到目前为止,我很喜欢@harmic’s answer,因为它暴露了定义这个陈述的陷阱和复杂性,尽管它在第一眼看起来是多么直接 . @acheong87指出了使用引用时出现的一些问题,但我认为这与此问题的未序列副作用方面正交 .


摘要

由于这个问题引起了很多关注,我将总结一下主要观点/答案 . 首先,请允许我进行一个小小的题外话,指出"why"可能具有密切相关但又略有不同的含义,即“因为什么原因", and "为什么目的", and "为什么”他们提到了 .

是什么原因

这里的主要答案来自Paul DraperMartin J提供了类似但不那么广泛的答案 . Paul Draper的答案归结为

这是未定义的行为,因为它没有定义行为是什么 .

答案在解释C标准所说的内容方面总体上非常好 . 它还解决了UB的一些相关案例,如 f(++i, ++i);f(i=1, i=-1); . 在第一个相关案例中,不清楚第一个参数应该是 i+1 还是第二个 i+2 ,反之亦然;在第二个,函数调用后 i 应该是1还是-1还不清楚 . 这两种情况都是UB,因为它们属于以下规则:

如果相对于同一标量对象的另一个副作用,标量对象的副作用未被排序,则行为未定义 .

因此, f(i=-1, i=-1) 也是UB,因为它属于同样的规则,尽管程序员的意图是(恕我直言)显而易见且毫不含糊 .

保罗·德雷珀也在其结论中明确指出

它可以被定义为行为吗?是 . 它被定义了吗?没有 .

这让我们想到“为什么原因/目的是 f(i=-1, i=-1) 留下未定义的行为?”

出于什么原因/目的

虽然C标准中存在一些疏忽(可能是粗心),但许多遗漏都是合理的,并且有特定的用途 . 虽然我知道目的通常是"make the compiler-writer's job easier",或"faster code", I was mainly interested to know if there is a good reason leave f(i=-1, i=-1) as UB.

harmicsupercat提供了为UB提供原因的主要答案 . Harmic指出,优化编译器可能会将表面上原子分配操作分解为多个机器指令,并且可能会进一步交错这些指令以获得最佳速度 . 这可能会导致一些非常令人惊讶的结果: i 在他的场景中最终为-2!因此,harmic演示了如果操作未被排序,如何多次为变量分配相同的值会产生不良影响 .

supercat提供了一个相关的说明,试图让 f(i=-1, i=-1) 做它看起来应该做的事情的陷阱 . 他指出,在某些体系结构中,对同一内存地址的多个同时写入存在严格限制 . 如果我们处理的事情比 f(i=-1, i=-1) 更简单,那么编译器可能很难 grab 这个 .

davidf还提供了非常类似于harmic的交错指令的示例 .

尽管harmic 's, supercat'和davidf的每一个例子都有些人为,但它们仍然有助于提供一个明确的理由,为什么 f(i=-1, i=-1) 应该是未定义的行为 .

我接受了harmic的答案,因为尽管Paul Draper的回答更好地解决了“为什么原因”部分,但它尽力解决了原因的所有含义 .

其他答案

JohnB指出如果我们考虑重载的赋值运算符(而不仅仅是普通的标量),那么我们也会遇到麻烦 .

11 回答

  • 207

    由于操作未被排序,因此无法说出执行分配的指令不能交错 . 这可能是最佳选择,具体取决于CPU架构 . 引用的页面说明了这一点:

    如果在A之前未对A进行排序,而在A之前未对B进行排序,则存在两种可能性:A和B的评估未被排序:它们可以在任何情况下执行顺序和可能重叠(在单个执行线程内,编译器可以交错组成A和B的CPU指令)A和B的评估是不确定的顺序:它们可以按任何顺序执行但可能不重叠:A将在B之前完成,或者B将在A之前完成 . 下次评估相同表达式时,顺序可能相反 .

    这本身似乎不会导致问题 - 假设正在执行的操作是将值-1存储到内存位置 . 但也没有什么可说的,编译器不能将其优化为具有相同效果的单独指令集,但如果操作与同一存储器位置上的另一个操作交错,则可能会失败 .

    例如,假设与加载值-1相比,将内存归零然后递减它更有效 . 然后这样:

    f(i=-1, i=-1)
    

    可能成为:

    clear i
    clear i
    decr i
    decr i
    

    现在我是-2 .

    这可能是一个虚假的例子,但它是可能的 .

  • 25

    首先,"scalar object"表示类似 intfloat 或指针的类型(请参阅What is a scalar Object in C++?) .


    其次,这似乎更明显

    f(++i, ++i);
    

    会有不确定的行为 . 但

    f(i = -1, i = -1);
    

    不太明显 .

    一个稍微不同的例子:

    int i;
    f(i = 1, i = -1);
    std::cout << i << "\n";
    

    发生了什么任务"last", i = 1i = -1 ?它没有在标准中定义 . 真的,这意味着 i 可能是 5 (参见harmic的答案,对于如何实现这种情况的完全合理的解释) . 或者你的程序可能会段错误 . 或者重新格式化硬盘 .

    但现在你问:“我的例子怎么样?我对两个作业使用相同的值( -1 ) . 有什么可能不清楚的?”

    你是正确的...除了C标准委员会描述的方式 .

    如果相对于同一标量对象的另一个副作用,标量对象的副作用未被排序,则行为未定义 .

    对于你的特殊情况,他们可能会特别例外,但他们没有 . (他们为什么要这么做?有什么用呢?)所以, i 仍然可以 5 . 或者你的硬盘可能是空的 . 因此,您的问题的答案是:

    It is undefined behavior because it is not defined what the behavior is.

    (这值得强调,因为许多程序员认为"undefined"表示"random",或"unpredictable" . 它没有;它意味着没有标准定义 . 行为可能是100%一致的,仍然是未定义的 . )

    可以定义行为吗?是 . 它被定义了吗?不,因此,它是“未定义的” .

    也就是说,"undefined"并不意味着编译器会格式化你的硬盘......这意味着它可以,它仍然是一个符合标准的编译器 . 实际上,我是'm sure g++, Clang, and MSVC will all do what you expected. They just wouldn' t "have to" .


    一个不同的问题可能是为什么C标准委员会选择使这种副作用无效?答案将涉及委员会的历史和意见 . 或者,这种副作用在C?中有什么好处,这允许任何理由,无论它是否是标准委员会的实际推理 . 你可以在这里或者在programmers.stackexchange.com上提出这些问题 .

  • 333

    一个实际的理由,因为这两个值是相同的,所以不能从规则中例外:

    // config.h
    #define VALUEA  1
    
    // defaults.h
    #define VALUEB  1
    
    // prog.cpp
    f(i = VALUEA, i = VALUEB);
    

    考虑一下这是允许的情况 .

    现在,几个月后,需要改变

    #define VALUEB 2
    

    看似无害,不是吗?然而突然prog.cpp不再编译了 . 然而,我们认为汇编不应该依赖于文字的 Value .

    底线:规则没有例外,因为它会使编译成功取决于常量的值(而不是类型) .

    编辑

    @HeartWare pointed outB 为0时,某些语言中不允许使用 A DIV B 形式的常量表达式,并导致编译失败 . 因此,更改常量可能会导致某些其他位置的编译错误 . 这是,恕我直言,不幸 . 但将这些事情局限于不可避免的当然是好事 .

  • 5

    如果有一些可以想象的原因,为什么尝试“有用”的编译器可能会做一些会导致完全意外行为的事情,那么行为通常被指定为undefined .

    如果变量被多次写入以确保写入在不同时间发生,则某些类型的硬件可能允许使用双端口存储器同时对不同地址执行多个"store"操作 . 但是,一些双端口存储器明确禁止两个存储同时命中同一地址的情况,无论写入的值是否匹配 . 如果这样的机器的编译器注意到两个未经测试的尝试写入相同的变量,它可能会拒绝编译或确保无法同时调度这两个写入 . 但是,如果访问中的一个或两个是通过指针或引用,则编译器可能无法始终判断两个写入是否可能点击相同的存储位置 . 在这种情况下,它可能会同时调度写入,从而导致访问尝试的硬件陷阱 .

    当然,有人可能在这样的平台上实现C编译器的事实并不表明当使用足够小的类型的存储以便原子处理时,不应该在硬件平台上定义这种行为 . 如果编译器没有意识到这一点,那么尝试以无序的方式存储两个不同的值可能会导致奇怪;例如,给定:

    uint8_t v;  // Global
    
    void hey(uint8_t *p)
    {
      moo(v=5, (*p)=6);
      zoo(v);
      zoo(v);
    }
    

    如果编译器在线调用“moo”并且可以告诉它不修改“v”,它可能会存储5到v,然后存储6到* p,然后将5传递给“zoo”,然后将v的内容传递给“zoo” . 如果“zoo”没有修改“v”,那么两个调用都不应该传递不同的值,但这很容易发生 . 另一方面,如果两个商店都写出相同的 Value ,那么这种奇怪的情况就不会发生,并且在大多数平台上都没有明智的理由让实施做任何奇怪的事情 . 不幸的是,一些编译器编写者不需要任何借口来愚蠢的行为,因为“因为标准允许它”,所以即使这些情况也不安全 .

  • 2

    令人困惑的是,将常量值存储到局部变量中并不是C设计为运行的每个体系结构上的一条原子指令 . 在这种情况下,代码运行的处理器比编译器更重要 . 例如,在ARM上,每条指令都不能携带完整的32位常量,在变量中存储int需要多于一条指令 . 使用此伪代码的示例,您一次只能存储8位并且必须在32位寄存器中工作,我是int32:

    reg = 0xFF; // first instruction
    reg |= 0xFF00; // second
    reg |= 0xFF0000; // third
    reg |= 0xFF000000; // fourth
    i = reg; // last
    

    您可以想象,如果编译器想要优化它可能会将相同的序列交错两次,并且您不知道将写入i的值是什么;让我们说他不是很聪明:

    reg = 0xFF;
    reg |= 0xFF00;
    reg |= 0xFF0000;
    reg = 0xFF;
    reg |= 0xFF000000;
    i = reg; // writes 0xFF0000FF == -16776961
    reg |= 0xFF00;
    reg |= 0xFF0000;
    reg |= 0xFF000000;
    i = reg; // writes 0xFFFFFFFF == -1
    

    然而,在我的测试中,gcc非常友好地认识到相同的值被使用了两次并且只产生一次并且没有做任何奇怪的事情 . 我得到-1,-1但是我的例子仍然有效,因为重要的是要考虑即使是常数也可能不像它看起来那么明显 .

  • 1

    在这种情况下,大多数实现中结果相同的事实是偶然的;评估的顺序仍未定义 . 考虑 f(i = -1, i = -2) :在这里,订单很重要 . 在您的示例中无关紧要的唯一原因是两个值均为 -1 的事故 .

    鉴于表达式被指定为具有未定义行为的表达式,当您评估 f(i = -1, i = -1) 并中止执行时,恶意兼容的编译器可能会显示不适当的图像 - 并且仍然被认为是完全正确的 . 幸运的是,我所知道的编译器都没有这样做 .

  • 10

    在我看来,关于函数参数表达式排序的唯一规则是:

    3)当调用函数时(无论函数是否为内联函数,以及是否使用了显式函数调用语法),与任何参数表达式相关联的每个值计算和副作用,或者使用指定被调用函数的后缀表达式,在执行被调用函数体内的每个表达式或语句之前对其进行排序 .

    这不会定义参数表达式之间的顺序,所以我们最终在这种情况下:

    1)如果相对于同一标量对象的另一个副作用,标量对象的副作用未被排序,则行为未定义 .

    实际上,在大多数编译器中,您引用的示例都可以正常运行(与"erasing your hard disk"和其他理论上未定义的行为结果相反) .
    然而,它是一种负担,因为它取决于特定的编译器行为,即使两个指定的值相同 . 另外,显然,如果您尝试分配不同的值,结果将是"truly" undefined:

    void f(int l, int r) {
        return l < -1;
    }
    auto b = f(i = -1, i = -2);
    if (b) {
        formatDisk();
    }
    
  • 8

    C++17 定义了更严格的评估规则 . 特别是,它对函数参数进行排序(尽管以未指定的顺序排列) .

    N5659§4.6:15评估A和B是不确定的,当A在B之前测序或B在B之前测序时,但未指定哪一个 . [注意:不确定顺序的评估不能重叠,但可以先执行 . -end note]N5659§8.2.2:5参数的初始化,包括每个相关的值计算和副作用,相对于任何其他参数的初始化是不确定的 .

    它允许一些以前是UB的情况:

    f(i = -1, i = -1); // value of i is -1
    f(i = -1, i = -2); // value of i is either -1 or -2, but not specified which one
    
  • 8

    赋值运算符可能会重载,在这种情况下,顺序可能很重要:

    struct A {
        bool first;
        A () : first (false) {
        }
        const A & operator = (int i) {
            first = !first;
            return * this;
        }
    };
    
    void f (A a1, A a2) {
        // ...
    }
    
    
    // ...
    A i;
    f (i = -1, i = -1);   // the argument evaluated first has ax.first == true
    
  • 11

    这只是回答“我不确定什么”标量对象“可能意味着除了像int或float这样的东西” .

    我会将"scalar object"解释为"scalar type object"的缩写,或者只是"scalar type variable" . 然后, pointer5998368(常数)是标量类型 .

    这是Scalar Types的MSDN文章 .

  • 9

    实际上,有理由不依赖因为编译器将检查 i 被分配两次相同的值,以便可以用单个赋值替换它 . 如果我们有一些表达怎么办?

    void g(int a, int b, int c, int n) {
        int i;
        // hey, compiler has to prove Fermat's theorem now!
        f(i = 1, i = (ipow(a, n) + ipow(b, n) == ipow(c, n)));
    }
    

相关问题