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.