Questions about the devirt optimization, `-fstrict-vtable-pointers` seems to change the program behavior

Hi guys

Given the following code snippets.

struct A {
  virtual int foo() { return 1; }
};
struct B : public A {
  int foo() { return 2; }
};
void bar(A* a) { new(a) B(); } // placement new
int main() {
    auto *a = new A();
    bar(a);
    std::cout << a->foo() << std::endl;
    delete a;
    return 0;
}

With the -fstrict-vtable-pointers -O3, will print 1. In contrast, in the case of only -O3, will print 2.

There are two questions here that need help.

  • Is this a bug?
  • Adding -fstrict-vtable-pointers will guarantee we will get the same result? Even in the case of UB.

Btw, I’m not sure if this code is UB.

Thanks
Henry Wong

I think this is a case where you need std::launder. From C++17 [basic.life]p8:

If, after the lifetime of an object has ended and before the storage which the object occupied is reused or released, a new object is created at the storage location which the original object occupied, a pointer that pointed to the original object, a reference that referred to the original object, or the name of the original object will automatically refer to the new object and, once the lifetime of the new object has started, can be used to manipulate the new object, if:
[…]

  • the new object is of the same type as the original object (ignoring the top-level cv-qualifiers)

[…] [Note: If these conditions are not met, a pointer to the new object can be obtained from a pointer that represents the address of its storage by calling std::launder]

So you’d use std::launder(a)->foo().

Adding -fstrict-vtable-pointers will guarantee we will get the same result?

The reverse I’d say: -fno-strict-vtable-pointers (which is probably the default) would guarantee consistent behaviour as you want. I’m not completely certain there though, the documentation is a bit sparse.

1 Like

The code has UB. It runs the constructor on a twice, once with new A() and another in bar. You can’t do that.

1 Like

I think that bit’s OK. Particularly for objects without real destructors, you can pretty much declare their lifetime over whenever you want and reuse the storage:

  1. A program may end the lifetime of any object by reusing the storage which the object occupies or by explicitly calling the destructor for an object of a class type with a non-trivial destructor. [It goes on to say what happens if you don’t call the destructor when there is one, and even that is allowed]

It actually seems to go even further than I’d remembered. An object can suicide itself, one of the examples is:

void B::mutate() {
  new (this) D2; // reuses storage — ends the lifetime of *this
}
1 Like

Thanks for the detailed explanation about the object lifetime and std::launder indeed works here.

The code has UB. It runs the constructor on a twice, once with new A() and another in bar . You can’t do that.

What confused me is that UBSan cannot catch this bug. @Enna1 is this a UBSan bug or false negative?

I checked UBSan’s available checks: UndefinedBehaviorSanitizer — Clang 16.0.0git documentation.
It seems there is no check for such case.

Tim is correct about the UB analysis. The pointer produced by new A formally points to the object just constructed. When a new object is created in that same storage, the lifetime of the old object ends. Because the conditions for forwarding are not satisfied, the existing pointer is considered to still refer to the old object, meaning it is a dangling reference even though a new object now exists in that storage.

It’s out of UBSan’s scope to catch this: too expensive, invasive, and non-local. UBSan would have to track not just the dynamic types associated with storage but also some kind of generation counter associated with specific pointer values, without actually increasing the size of a pointer.

At best we could try to tie something up with the devirt optimization to emit code to validate the optimization.

2 Likes