reinterpret_cast vs strict aliasing

2020-07-08 06:50发布

问题:

I was reading about strict aliasing, but its still kinda foggy and I am never sure where is the line of defined / undefined behaviour. The most detailed post i found concentrates on C. So it would be nice if you could tell me if this is allowed and what has changed since C++98/11/...

#include <iostream>
#include <cstring>

template <typename T> T transform(T t);

struct my_buffer {
    char data[128];
    unsigned pos;
    my_buffer() : pos(0) {}
    void rewind() { pos = 0; }    
    template <typename T> void push_via_pointer_cast(const T& t) {
        *reinterpret_cast<T*>(&data[pos]) = transform(t);
        pos += sizeof(T);
    }
    template <typename T> void pop_via_pointer_cast(T& t) {
        t = transform( *reinterpret_cast<T*>(&data[pos]) );
        pos += sizeof(T);
    }            
};    
// actually do some real transformation here (and actually also needs an inverse)
// ie this restricts allowed types for T
template<> int transform<int>(int x) { return x; }
template<> double transform<double>(double x) { return x; }

int main() {
    my_buffer b;
    b.push_via_pointer_cast(1);
    b.push_via_pointer_cast(2.0);
    b.rewind();
    int x;
    double y;
    b.pop_via_pointer_cast(x);
    b.pop_via_pointer_cast(y);
    std::cout << x << " " << y << '\n';
}

Please dont pay too much attention to a possible out-of-bounds access and the fact that maybe there is no need to write something like that. I know that char* is allowed to point to anything, but I also have a T* that points to a char*. And maybe there is something else I am missing.

Here is a complete example also including push/pop via memcpy, which afaik isn't affected by strict aliasing.

TL;DR: Does the above code exhibit undefined behaviour (neglecting a out-of-bound acces for the moment), if yes, why? Did anything change with C++11 or one of the newer standards?

回答1:

I know that char* is allowed to point to anything, but I also have a T* that points to a char*.

Right, and that is a problem. While the pointer cast itself has defined behaviour, using it to access a non-existent object of type T is not.

Unlike C, C++ does not allow impromptu creation of objects*. You cannot simply assign to some memory location as type T and have an object of that type be created, you need an object of that type to be there already. This requires placement new. Previous standards were ambiguous on it, but currently, per [intro.object]:

1 [...] An object is created by a definition (6.1), by a new-expression (8.3.4), when implicitly changing the active member of a union (12.3), or when a temporary object is created (7.4, 15.2). [...]

Since you are not doing any of these things, no object is created.

Furthermore, C++ does not implicitly consider pointers to different object at the same address as equivalent. Your &data[pos] computes a pointer to a char object. Casting it to T* does not make it point to any T object residing at that address, and dereferencing that pointer has undefined behaviour. C++17 adds std::launder, which is a way to let the compiler know that you want to access a different object at that address than what you have a pointer to.

When you modify your code to use placement new and std::launder, and ensure you have no misaligned accesses (I presume you left that out for brevity), your code will have defined behaviour.

* There is discussion on allowing this in a future version of C++.



回答2:

Aliasing is a situation when two refer to the same object. That might be references or pointers.

int x;
int* p = &x;
int& r = x;
// aliases: x, r и *p  refer to same object.

It's important for compiler to expect that if a value was written using one name it would be accessible through another.

int foo(int* a, int* b) {
  *a = 0;
  *b = 1;
  return *a; 
  // *a might be 0, might be 1, if b points at same object. 
  // Compiler can't short-circuit this to "return 0;"
}

Now if pointers are of unrelated types, there is no reason for compiler to expect that they point at same address. This is the simplest UB:

int foo( float *f, int *i ) { 
    *i = 1;               
    *f = 0.f;            
   return *i;
}

int main() {
    int a = 0;

    std::cout << a << std::endl; 
    int x = foo(reinterpret_cast<float*>(&a), &a);
    std::cout << a << "\n"; 
    std::cout << x << "\n";   // Surprise?
}
// Output 0 0 0 or 0 0 1 , depending on optimization. 

Simply put, strict aliasing means that compiler expects names of unrelated types refer to object of different type, thus located in separate storage units. Because addresses used to access those storage units are de-facto same, result of accessing stored value is undefined and usually depends on optimization flags.

memcpy() circumvents that by taking the address, by pointer to char, and makes copy of data stored, within code of library function.

Strict aliasing applies to union members, which described separately, but reason is same: writing to one member of union doesn't guarantee the values of other members to change. That doesn't apply to shared fields in beginning of struct stored within union. Thus, type punning by union is prohibited. (Most compilers do not honor this for historical reasons and convenience of maintaining legacy code.)

From 2017 Standard: 6.10 Lvalues and rvalues

8 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

(8.1) — the dynamic type of the object,

(8.2) — a cv-qualified version of the dynamic type of the object,

(8.3) — a type similar (as defined in 7.5) to the dynamic type of the object,

(8.4) — a type that is the signed or unsigned type corresponding to the dynamic type of the object,

(8.5) — a type that is the signed or unsigned type corresponding to a cv-qualified version of the dynamic type of the object,

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

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

(8.8) — a char, unsigned char, or std::byte type.

In 7.5

1 A cv-decomposition of a type T is a sequence of cvi and Pi such that T is “cv0 P0 cv1 P1 · · · cvn−1 Pn−1 cvn U” for n > 0, where each cvi is a set of cv-qualifiers (6.9.3), and each Pi is “pointer to” (11.3.1), “pointer to member of class Ci of type” (11.3.3), “array of Ni”, or “array of unknown bound of” (11.3.4). If Pi designates an array, the cv-qualifiers cvi+1 on the element type are also taken as the cv-qualifiers cvi of the array. [ Example: The type denoted by the type-id const int ** has two cv-decompositions, taking U as “int” and as “pointer to const int”. —end example ] The n-tuple of cv-qualifiers after the first one in the longest cv-decomposition of T, that is, cv1, cv2, . . . , cvn, is called the cv-qualification signature of T.

2 Two types T1 and T2 are similar if they have cv-decompositions with the same n such that corresponding Pi components are the same and the types denoted by U are the same.

Outcome is: while you can reinterpret_cast the pointer to a different, unrelated and not similar type, you can't use that pointer to access stored value:

char* pc = new char[100]{1,2,3,4,5,6,7,8,9,10}; // Note, initialized.
int* pi = reinterpret_cast<int*>(pc);  // no problem.
int i = *pi; // UB
char* pc2 = reinterpret_cast<char*>(pi+2)); 
char c = *pc2; // no problem, unless increment didn't put us beyond array bound.

Reinterpret cast also doesn't create objects they point to and assigning value to non-existing object is UB, so you can't use dereferenced result of cast to store data either if class it points to wasn't trivial.



回答3:

Short answer:

  1. You may not do this: *reinterpret_cast<T*>(&data[pos]) = until there has been an object of type T constructed at the pointed-to address. Which you can accomplish by placement new.

  2. Even then, you might need to use std::launder as for C++17 and later, since you access the created object (of type T) through a pointer &data[pos] of type char*.

"Direct" reinterpret_cast is allowed only in some special cases, e.g., when T is std::byte, char, or unsigned char.

Before C++17 I would use the memcpy-based solution. Compiler will likely optimize away any unnecessary copies.