c++ c++11 language-lawyer

c++ - Reinterpretar una unión a una unión diferente



c++11 language-lawyer (4)

Esto es UB por omisión. [expr.ref] /4.2:

Si E2 es un miembro de datos no estático y el tipo de E1 es " cq1 vq1 X ", y el tipo de E2 es " cq2 vq2 T ", la expresión [ E1.E2 ] designa al miembro designado del objeto designado por el primera expresión.

Durante la evaluación de la llamada given_b_or_c en given_big , la expresión de objeto en little.h en realidad no designa un objeto Little , y ergo no hay dicho miembro. Debido a que el estándar "omite cualquier definición explícita de comportamiento" para este caso, el comportamiento no está definido.

Tengo una unión de diseño estándar que tiene un montón de tipos en ella:

union Big { Hdr h; A a; B b; C c; D d; E e; F f; };

Cada uno de los tipos A a F tiene un diseño estándar y tiene como primer miembro un objeto de tipo Hdr . El Hdr identifica cuál es el miembro activo de la unión, por lo que es similar a una variante. Ahora, estoy en una situación en la que sé con certeza (porque lo verifiqué) que el miembro activo es una B o una C Efectivamente, reduje el espacio a:

union Little { Hdr h; B b; C c; };

Ahora, ¿el siguiente comportamiento bien definido o indefinido?

void given_big(Big const& big) { switch(big.h.type) { case B::type: // fallthrough case C::type: given_b_or_c(reinterpret_cast<Little const&>(big)); break; // ... other cases here ... } } void given_b_or_c(Little const& little) { if (little.h.type == B::type) { use_a_b(little.b); } else { use_a_c(little.c); } }

El objetivo de Little es servir de manera efectiva como documentación, que ya he comprobado que es una B o C por lo que en el futuro nadie agrega código para verificar que sea una A o algo así.

¿Es el hecho de que estoy leyendo el subobjeto B como B suficiente para hacerlo bien formado? ¿Puede la regla de secuencia inicial común ser usada de manera significativa aquí?


No estoy seguro, si esto realmente aplica aquí. En la sección reinterpret_cast - Notes se habla de objetos puntero-interconvertibles.

Y de [basic.compound]/4 :

Dos objetos a y b son puntero-interconvertibles si:

  • son el mismo objeto, o
  • uno es un objeto de unión y el otro es un miembro de datos no estático de ese objeto, o
  • uno es un objeto de clase de diseño estándar y el otro es el primer miembro de datos no estáticos de ese objeto, o, si el objeto no tiene miembros de datos no estáticos, el primer subobjeto de clase base de ese objeto, o
  • existe un objeto c tal que a y c son puntero-interconvertibles, y c y b son puntero-interconvertibles.

Si dos objetos son puntero-interconvertibles, entonces tienen la misma dirección, y es posible obtener un puntero a uno desde un puntero al otro mediante un reinterpret_cast .

En este caso, tenemos Hdr h; (c) como miembro de datos no estáticos en ambas uniones, lo que debería permitir (debido al segundo y último punto)

Big* (a) -> Hdr* (c) -> Little* (b)


No puedo encontrar texto en n4296 (borrador del estándar C ++ 14) que lo haría legal. Lo que es más, no puedo encontrar ninguna redacción dada:

union Big2 { Hdr h; A a; B b; C c; D d; E e; F f; };

podemos reinterpret_cast una referencia a Big en una referencia a Big2 y luego usar la referencia. (Tenga en cuenta que Big y Big2 son compatibles con el diseño ).


Para poder tomar un puntero a A y reinterpretarlo como un puntero a B, deben ser puntero-interconvertibles .

El puntero-interconvertible trata de objetos , no de tipos de objetos .

En C ++, hay objetos en lugares. Si tiene un Big en un lugar en particular con al menos un miembro existente, también hay un Hdr en ese mismo lugar debido a la interconversión del puntero.

Sin embargo, no hay ningún objeto Little en ese lugar. Si no hay ningún objeto Little allí, no puede ser puntero-interconvertible con un Little objeto que no está allí.

Parecen compatibles con el diseño , suponiendo que son datos planos (datos simples antiguos, trivialmente copiables, etc.).

Esto significa que puede copiar su representación de bytes y funciona. De hecho, los optimizadores parecen entender que una memcpy a una pila de buffer local, una ubicación nueva (con un constructor trivial), luego una memcpy back es realmente un noop.

template<class T> T* laundry_pod( void* data ) { static_assert( std::is_pod<Data>{}, "POD only" ); // could be relaxed a bit char buff[sizeof(T)]; std::memcpy( buff, data, sizeof(T) ); T* r = ::new( data ) T; std::memcpy( data, buff, sizeof(T) ); return r; }

la función anterior es un nodo en el tiempo de ejecución (en una versión optimizada), sin embargo, convierte los datos compatibles con T-layout en data en una T real.

Entonces, si estoy en lo cierto y Big y Little son compatibles con el diseño cuando Big es un subtipo de los tipos en Little , puede hacer esto:

Little* inplace_to_little( Big* big ) { return laundry_pod<Little>(big); } Big* inplace_to_big( Little* big ) { return laundry_pod<Big>(big); }

o

void given_big(Big& big) { // cannot be const switch(big.h.type) { case B::type: // fallthrough case C::type: auto* little = inplace_to_little(&big); // replace Big object with Little inplace given_b_or_c(*little); inplace_to_big(little); // revive Big object. Old references are valid, barring const data or inheritance break; // ... other cases here ... } }

si Big tiene datos no planos (como referencias o datos const ), lo anterior se rompe horriblemente.

Tenga en cuenta que laundry_pod no hace ninguna asignación de memoria; usa la colocación nueva que construye una T en el lugar donde data puntos de data usan los bytes en los data . Y aunque parece que está haciendo muchas cosas (copiando memoria), optimiza a un noop.

c ++ tiene un concepto de "existe un objeto". La existencia de un objeto casi no tiene nada que ver con qué bits o bytes están escritos en la máquina física o abstracta. No hay instrucciones en su binario que correspondan a "ahora existe un objeto".

Pero el lenguaje tiene este concepto.

Los objetos que no existen no se pueden interactuar. Si lo hace, el estándar de C ++ no define el comportamiento de su programa.

Esto permite que el optimizador haga suposiciones sobre lo que su código hace y lo que no hace, y sobre qué ramas no se puede alcanzar y cuáles se pueden alcanzar. Permite al compilador suponer sin aliasing; la modificación de datos a través de un puntero o referencia a A no puede cambiar los datos alcanzados a través de un puntero o una referencia a B, a menos que de alguna manera ambos, A y B, existan en el mismo lugar.

El compilador puede demostrar que los objetos Big y Little no pueden existir en el mismo lugar. Entonces, ninguna modificación de ningún dato a través de un puntero o referencia a Little podría modificar cualquier cosa existente en una variable de tipo Big . Y viceversa.

Imagine si given_b_or_c modifica un campo. Bueno, el compilador podría given_big y given_b_or_c y use_a_b , notar que no se modificó ninguna instancia de Big (solo una instancia de Little ), y probar que los campos de datos de Big caché antes de llamar a su código no se pudieron modificar.

Esto le ahorra una instrucción de carga, y el optimizador es bastante feliz. Pero ahora tienes un código que dice:

Big b = whatever; b.foo = 7; ((Little&)b).foo = 4; if (b.foo!=4) exit(-1);

que está optimizado para

Big b = whatever; b.foo = 7; ((Little&)b).foo = 4; exit(-1);

porque puede probar que b.foo debe ser 7 , se estableció una vez y nunca se modificó. El acceso a través de Little no pudo modificar Big debido a las reglas de aliasing.

Ahora hacer esto:

Big b = whatever; b.foo = 7; (*laundry_pod<Little>(&b)).foo = 4; Big& b2 = *laundry_pod<Big>(&b); if (b2.foo!=4) exit(-1);

y supone que el gran no se modificó, porque hay una memcpy y una ::new que podrían cambiar legalmente el estado de los datos. Sin violación de aliasing estricta.

Todavía puede seguir la memcpy y eliminarla.

Ejemplo en vivo de laundry_pod optimizado. Tenga en cuenta que si no fue optimizado, el código debería tener un condicional y un printf. Pero como lo fue, se optimizó en el programa vacío.