我和我的同事正在我们正在开发的应用程序中与一个相当奇怪的错误作斗争。最终我们修复了它,但我们仍然不确定编译器所做的是否合法。
假设我们有这样的代码:
class B {
public:
virtual int foo(int d) { return d - 10; }
};
class C : public B {
public:
virtual int foo(int d) { return d - 11; }
};
class A {
public:
A() : count(0) { member = new B;}
int bar() {
return member->foo(renew());
}
int renew() {
count++;
delete member;
member = new C;
return count;
}
private:
B *member;
int count;
};
int square() {
A a;
cout << a.bar() << endl;
return 0;
}
Visual Studio x86 编译器,用于函数A::bar
,使用/O1
编译时生成如下内容(您可以在 godbolt 上查看完整代码):
push esi
push edi
mov edi, ecx
mov eax, DWORD PTR [edi] ; eax = member
mov esi, DWORD PTR [eax] ; esi = B::vtbl
call int A::renew(void) ; Changes the member, vtable and esi are no longer valid
mov ecx, DWORD PTR [edi]
push eax
call DWORD PTR [esi] ; Calls wrong stuff (B::vtbl[0])
pop edi
pop esi
ret 0
这种优化是标准允许的还是未定义的行为? 我无法用 GCC 或叮当声获得类似的组装。
为了清楚起见,以下是已经链接的评估文档 Jarod42 的顺序,以及相关的引用:
14) 在函数调用表达式中,命名函数的表达式在每个参数表达式和每个默认参数之前排序。
所以我们应该阅读声明
return member->foo(renew());
如
return function-call-expression;
其中函数调用表达式是
{function-naming-expression member->foo} ( {argument-expression renew()} )
因此,函数命名表达式member->foo
在参数表达式之前排序。已经链接的文档说
排序,则 A 的评估将在 B 的评估开始之前完成。
因此,我们必须首先全面评估member->foo
。我认为它应该像
// 1. evaluate function-naming-expression
auto tmp_this_member = this->member;
int (B::*tmp_foo)(int) = tmp_this_member->foo;
// 2. evaluate argument expression
int tmp_argument = this->renew();
// 3. make the function call
(tmp_this_member->*tmp_foo) ( tmp_argument );
。这正是你所看到的。这是C++17所要求的测序,在此之前,测序和行为都是不确定的。
tl;编译器博士是对的,即使它有效,该代码也会令人讨厌。
虽然评估顺序是在 C++17 之前特定于实现的,但 C++17 强加了一些排序,请参阅评估顺序。
所以在
this->member->foo(renew());
在评估this->member
之前(C++17 之前),可能会调用renew()
。
要保证之前的订单,C++17,您必须分成几个不同的语句:
auto m = this->member;
auto param = renew(); // m is now pointing on deleted memory
m->foo(param); // UB.
或者,对于其他订单:
auto param = renew();
this->member->foo(param);