Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Do trivial destructors cause aliasing

C++11 §3.8.1 declares that, for an object with a trivial destructor, I can end its lifespan by assigning to its storage. I am wondering if trivial destructors can prolong the object's lifespan and cause aliasing woes by "destroying an object" that I ended the lifespan of much earlier.

To start, something which I know is safe and alias-free

void* mem = malloc(sizeof(int));
int*  asInt = (int*)mem;
*asInt = 1; // the object '1' is now alive, trivial constructor + assignment
short*  asShort = (short*)mem;
*asShort = 2; // the object '1' ends its life, because I reassigned to its storage
              // the object '2' is now alive, trivial constructor + assignment
free(mem);    // the object '2' ends its life because its storage was released

Now, for something which is not so clear:

{
    int asInt = 3; // the object '3' is now alive, trivial constructor + assignment
    short* asShort = (short*)&asInt; // just creating a pointer
    *asShort = 4; // the object '3' ends its life, because I reassigned to its storage
                  // the object '4' is now alive, trivial constructor + assignment
    // implicitly, asInt->~int() gets called here, as a trivial destructor
}   // 'the object '4' ends its life, because its storage was released

§6.7.2 states that objects of automatic storage duration are destroyed at the end of the scope, indicating that the destructor gets called. If there is an int to destroy, *asShort = 2 is an aliasing violation because I am dereferencing a pointer of unrelated type. But if the integer's lifespan ended before *asShort = 2, then I am calling an int destructor on a short.

I see several competing sections regarding this:

§3.8.8 reads

If a program ends the lifetime of an object of type T with static (3.7.1), thread (3.7.2), or automatic (3.7.3) storage duration and if T has a non-trivial destructor,39 the program must ensure that an object of the original type occupies that same storage location when the implicit destructor call takes place; otherwise the behavior of the program is undefined.

The fact that they call out types T with non-trivial destructor as yielding undefined behavior seems, to me, to indicate that having a different type in that storage location with a trivial destructor is defined, but I couldn't find anywhere in the spec that defined that.

Such a definition would be easy if a trivial destructor was defined to be a noop, but there's remarkably little in the spec about them.

§6.7.3 indicates that goto's are allowed to jump into and out of scopes whose variables have trivial constructors and trivial destructors. This seems to suggest a pattern where trivial destructors are allowed to be skipped, but the earlier section from the spec on destroying objects at the end of the scope mentions none of this.

Finally, there's the sassy reading:

§3.8.1 indicates that I am allowed to start an object's lifespan any time I want, if its constructor is trivial. This seems to indicate that I could do something like

{
    int asInt = 3;
    short* asShort = (short*)&asInt;
    *asShort = 4; // the object '4' is now alive, trivial constructor + assignment
    // I declare that an object in the storage of &asInt of type int is
    // created with an undefined value.  Doing so reuses the space of
    // the object '4', ending its life.

    // implicitly, asInt->~int() gets called here, as a trivial destructor
}

The only one of these reading that seems to suggest any aliasing issues is §6.7.2 on its own. It seems like, when read as part of a whole spec, the trivial destructor should not affect the program in any way (though for various reasons). Does anyone know what happens in this situation?

like image 395
Cort Ammon Avatar asked Sep 06 '13 22:09

Cort Ammon


2 Answers

In your second code snippet:

{
    int asInt = 3; // the object '3' is now alive, trivial constructor + assignment
    short* asShort = (short*)&asInt; // just creating a pointer
    *asShort = 4; 
    // Violation of strict aliasing. Undefined behavior. End of.
}

The same applies to your first code snippet. It is not "safe", but it will generally work because (a) there's no particular reason for a compiler to be implemented such that it doesn't work, and (b) in practice compilers have to support at least a few violations of strict aliasing or else it would be impossible to implement the memory allocator using the compiler.

The thing that I know can and does provoke compilers to break this kind of code is if you read asInt afterwards, the DFA is allowed to "detect" that asInt is not modified (since it's modified only by the strict-alias violation, which is UB), and move the initialization of asInt after the write to *asShort. That's UB by either of our interpretations of the standard though -- in my interpretation because of the strict aliasing violation and in your interpretation because asInt is read after the end of its lifetime. So we're both happy for that not to work.

However I don't agree with your interpretation. If you consider that assigning to part of the storage of asInt ends the lifetime of asInt, then that's a direct contradiction of the statement that the lifetime of an automatic object is its scope. OK, so we might accept that this is an exception to the general rule. But that would mean that the following is not valid:

{
    int asInt = 0;
    unsigned char *asChar = (unsigned char*)&asInt;
    *asChar = 0; // I've assigned the storage, so I've ended the lifetime, right?
    std::cout << asInt; // using an object after end of lifetime, undefined behavior!
}

Except that the whole point of allowing unsigned char as an aliasing type (and of defining that all-bits-0 means "0" for integer types) is to make code like this work. So I'm very reluctant to make an interpretation of any part of the standard, which implies that this doesn't work.

Ben gives another interpretation in comments below, that the *asShort assignment simply doesn't end the lifetime of asInt.

like image 139
Steve Jessop Avatar answered Oct 08 '22 07:10

Steve Jessop


I cannot say I have all the answers, as this is a part of the standard that I have tried hard to digest and it is non-trivial (euphemism for really complicated). Still, since I disagree with the answer by Steve Jessop, here is my take.

void f() {
   alignas(alignof(int)) char buffer[sizeof(int)];
   int *ip = new (buffer) int(1);                 // 1
   std::cout << *ip << '\n';                      // 2
   short *sp = new (buffer) short(2);             // 3
   std::cout << *sp << '\n';                      // 4
}

The behavior of that function is well defined and guaranteed by the standard. There is no problem with the strict aliasing rules at all. The rules determine when it is safe to read the value written to a variable. In the code above, the read in [2] extracts the value written in [1] through an object of the same type. The assignment reuses the memory of the chars and terminates their lifetime, so an object of type int becomes over the space previously taken by the chars. The strict aliasing rules don't have a problem with that since the read is with a pointer of the same type. In [3], a short is written over the memory previously ocupied by the int, reusing the storage. The int is gone and a short starts its lifetime. Again the read in [4] is through a pointer of the same type that was used to store the value, and is perfectly fine by the aliasing rules.

The key at this point is the first sentence of the aliasing rules: 3.10/10 If a program attempts to access the stored value of an object through a glvalue of other than one of the following types the behavior is undefined:

Regarding the lifetime of objects, and in particular when the lifetime of an object ends, the quote you provide is not complete. It is perfectly fine for a destructor not to run as long as the program does not depend on the destructor being run. This only matters to some extent, but I think it is important to make it clear. While not explicitly stated as such, the fact is that a trivial destructor is a no-op (this can be derived from the definition of a what a trivial destructor is).[See edit below]. The quote in 3.8/8 means that if you have an object with trivial destructor for example any of the fundamental types with static storage you can reuse the memory as shown above and this won't cause undefined behavior (by itself). The premise is that since the destructor for the type is trivial, it is a no-op and what is currently living on that location is not important for the program. (At this point, if what was stored over that location is trivial or if the program does not depend on its destructor being run the program will be well defined; if the program behavior depends on the destructor of the overwriting type to run, well, tough luck: UB)


Trivial destructor

The standard (C++11) defines a destructor as trivial in 12.4/5:

A destructor is trivial if it is not user-provided and if:

— the destructor is not virtual,

— all of the direct base classes of its class have trivial destructors, and

— for all of the non-static data members of its class that are of class type (or array thereof), each such class has a trivial destructor.

The requirements can be rewritten as: the destructor is implicitly defined and not virtual, none of the subobjects has a non-trivial destructor. The first requirement means that dynamic dispatch is not needed for the destructor call, and that makes the value of the vptr not needed to start the destruction chain.

An implicitly defined destructor won't do anything at all for any non-class type (fundamental types, enums), but will call the destructors of the class members and bases. This means that none of the data stored in the complete object will be touched by the destructors, since after all everything is composed of members of fundamental types. From this description it could seem that a trival destructor is a no-op since no data is touched. But that is not the case.

The detail that I misremembered is that the requirement is not that there are no virtual functions at all, but rather that the destructor is not virtual. So a type can have a virtual function and also a trivial destructor. The implication is that, at least conceptually, the destructor is not a no-op, since the vptr (or vptrs) present in the complete objects are updated during the chain of destruction as the type changes. Now, while a trivial destructor may conceptually not be a no-op, the only side effects of the evaluation of the destructor would be the modification of the vptrs, which is not visible, and thus following the as-if rule, the compiler can effectively make the trivial destructor a no-op (i.e. it can not generate any code at all), and that is what compilers actually do, that is, a trivial destructor won't have any generated code.

like image 35
David Rodríguez - dribeas Avatar answered Oct 08 '22 07:10

David Rodríguez - dribeas