Does reinterpret_casting std::aligned_storage* to T* without std::launder violate strict-aliasing rules? [duplicate]
Asked Answered
U

2

11

The following example comes from std::aligned_storage page of cppreference.com:

#include <iostream>
#include <type_traits>
#include <string>

template<class T, std::size_t N>
class static_vector
{
    // properly aligned uninitialized storage for N T's
    typename std::aligned_storage<sizeof(T), alignof(T)>::type data[N];
    std::size_t m_size = 0;

public:
    // Create an object in aligned storage
    template<typename ...Args> void emplace_back(Args&&... args) 
    {
        if( m_size >= N ) // possible error handling
            throw std::bad_alloc{};
        new(data+m_size) T(std::forward<Args>(args)...);
        ++m_size;
    }

    // Access an object in aligned storage
    const T& operator[](std::size_t pos) const 
    {
        return *reinterpret_cast<const T*>(data+pos);
    }

    // Delete objects from aligned storage
    ~static_vector() 
    {
        for(std::size_t pos = 0; pos < m_size; ++pos) {
            reinterpret_cast<T*>(data+pos)->~T();
        }
    }
};

int main()
{
    static_vector<std::string, 10> v1;
    v1.emplace_back(5, '*');
    v1.emplace_back(10, '*');
    std::cout << v1[0] << '\n' << v1[1] << '\n';
}

In the example, the operator[] just reinterpret_casts std::aligned_storage* to T* without std:launder, and performs an indirection directly. However, according to this question, this seems to be undefined, even if an object of type T has been ever created.

So my question is: does the example program really violate strict-aliasing rules? If it does not, what's wrong with my comprehension?

Unific answered 10/12, 2017 at 3:44 Comment(3)
Your question basically boils down to, "does that answer really say what it says?" Yes, it really says what it says.Graybill
@NicolBolas, because cppreference.com is a famous website, so when my comprehension conflicts to it, I think there is something wrong with my comprehension firstly.Unific
The answer https://mcmap.net/q/94989/-small-object-stack-storage-strict-aliasing-rule-and-undefined-behavior states it's actually okay however.Galliard
U
9

I asked a related question in the ISO C++ Standard - Discussion forum. I learned the answer from those discussions, and write it here to hope to help someone else who is confused about this question. I will keep updating this answer according to those discussions.

Before P0137, refer to [basic.compound] paragraph 3:

If an object of type T is located at an address A, a pointer of type cv T* whose value is the address A is said to point to that object, regardless of how the value was obtained.

and [expr.static.cast] paragraph 13:

If the original pointer value represents the address A of a byte in memory and A satisfies the alignment requirement of T, then the resulting pointer value represents the same address as the original pointer value, that is, A.

The expression reinterpret_cast<const T*>(data+pos) represents the address of the previously created object of type T, thus points to that object. Indirection through this pointer indeed get that object, which is well-defined.

However after P0137, the definition for a pointer value is changed and the first block-quoted words is deleted. Now refer to [basic.compound] paragraph 3:

Every value of pointer type is one of the following:

  • a pointer to an object or function (the pointer is said to point to the object or function), or

  • ...

and [expr.static.cast] paragraph 13:

If the original pointer value represents the address A of a byte in memory and A does not satisfy the alignment requirement of T, then the resulting pointer value is unspecified. Otherwise, if the original pointer value points to an object a, and there is an object b of type T (ignoring cv-qualification) that is pointer-interconvertible with a, the result is a pointer to b. Otherwise, the pointer value is unchanged by the conversion.

The expression reinterpret_cast<const T*>(data+pos) still points to the object of type std::aligned_storage<...>::type, and indirection get a lvalue referring to that object, though the type of the lvalue is const T. Evaluation of the expression v1[0] in the example tries to access the value of the std::aligned_storage<...>::type object through the lvalue, which is undefined behavior according to [basic.lval] paragraph 11 (i.e. the strict-aliasing rules):

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:

  • the dynamic type of the object,

  • a cv-qualified version of the dynamic type of the object,

  • a type similar (as defined in [conv.qual]) to the dynamic type of the object,

  • a type that is the signed or unsigned type corresponding to the dynamic type of the object,

  • a type that is the signed or unsigned type corresponding to a cv-qualified version of the dynamic type of the object,

  • an aggregate or union type that includes one of the aforementioned types among its elements or non-static data members (including, recursively, an element or non-static data member of a subaggregate or contained union),

  • a type that is a (possibly cv-qualified) base class type of the dynamic type of the object,

  • a char, unsigned char, or std​::​byte type.

Unific answered 14/12, 2017 at 13:46 Comment(4)
I would wonder if the object of type std::aligned_storage<T>::type still exists. I believe that given this code std::aligned_storage_t<T> storage; new (static_cast<void*>(&storage)) T{}; there is an implicit call to the trivial destructor of storage before the call to placement new, meaning the lifetime of storage is over and the only object that exists at &storage is of type T. But this might not be correct.Straub
Looking at the C++17 draft, section 6.6.3 specifies that the lifetime of an object with a trivial destructor ends when the storage occupied by that object is either released or reused by another object. So I don't think strict aliasing is violated in this case, because the lifetime of the object of type std::aligned_storage_t<T> has ended by the time of the reinterpret_cast.Straub
@Straub I don't find any special rule for pointer to lifetime-ended object in such case. If rules in my answer still applies for object whose lifetime is ended, it is undefined of course, otherwise I think it is undefined by not mentioning.Unific
I think you're right that the behavior is not defined, but that seems like an oversight. Section 6.6.2.3 defines the concept of "providing storage" (which we can assume that std::aligned_storage_t<T> will satisfy for type T). 6.6.2.4.2 then defines a to be nested within b if b "provides storage" for a. Finally, 6.6.2.8 indicates that distinct objects are permitted to have identical addresses if one is nested within the other. It certainly seems like the intention is to ensure such a cast is well-defined, but it's not made explicit (in 6.6.2 or elsewhere)Straub
P
1

The code doesn't violate the strict aliasing rule in any way. An lvalue of type const T is used to access an object of type T, which is permitted.

The rule in question, as covered by the linked question, is a lifetime rule; C++14 (N4140) [basic.life]/7. The problem is that, according to this rule, the pointer data+pos may not be used to manipulate the object created by placement-new. You're supposed to use the value "returned" by placement-new.

The question naturally follows: what about the pointer reinterpret_cast<T *>(data+pos) ? It is unclear whether accessing the new object via this new pointer violates [basic.life]/7.

The author of the answer you link to, assumes (with no justification offered) that this new pointer is still "a pointer that pointed to the original object". However it seems to me that it is also possible to argue that , being a T *, it cannot point to the original object, which is a std::aligned_storage and not a T.

This shows that the object model is underspecified. The proposal P0137, which was incorporated into C++17, was addressing a problem in a different part of the object model. But it introduced std::launder which is a sort of mjolnir to squash a wide range of aliasing, lifetime and provenance issues.

Undoubtedly the version with std::launder is correct in C++17. However, as far as I can see, P0137 and C++17 don't have any more to say about whether or not the version without launder is correct.

IMHO it is impractical to call the code UB in C++14, which did not have std::launder, because there is no way around the problem other than to waste memory storing all the result pointers of placement-new. If this is UB then it's impossible to implement std::vector in C++14, which is far from ideal.

Pitcher answered 10/12, 2017 at 9:20 Comment(3)
In C++14, I think this is well-defined because in this example, the new object is created before the pointer cast, so the cast result points to the new created object by [basic.compound]/9. However, P0137 changes the words for pointer value, which results in that the cast result points to the original object. std::launder is intended to deal with the case where a new object is created after the pointer cast, but after the change of P0137, it makes the "before" case undefined without std::launder. Am I right?Unific
The new words for static_cast and those for pointer values may be the justifications of that the cast result points to the original object. In addition, the example on the "Notes" part of reinterpret_cast page of cpprefernce.com is also an evidence.Unific
In addition, IMHO, in C++14, although data+pos may not point to the new object automatically, but its pointer value can be still be used in some limited ways (for example, cast to const T*). Since reinterpret_cast<const T*>(data+pos) will point to the new created object, there is no problem (*reinterpret_cast<const T*>(data+pos) is used to manipulate the object rather than data+pos).Unific

© 2022 - 2024 — McMap. All rights reserved.