Eine aktuelle Diskussion um UB beim type-punning via unions brachte mich
zu der vorschnellen Aussage: der UB-sanitizer wird das aufdecken.
Folgender Code, der klar UB enthält:
1
to_typetest1(constfrom_typed){
2
Uu;
3
u.d=d;// activate member d;
4
returnu.i;// read from inactive member (UB!)
5
}
Leider ist es aber tatsächlich so, dass der UB-sanitizer gerade diesen
Fall nicht abdeckt.
Allerdings ist es auch so, dass in einem constexpr-Kontext jedes(!) UB
verboten ist. Dazu kann man auch die Deklaration als
sog. immediate-function verwenden (consteval):
1
constevalto_typetest1(constfrom_typed){
2
Uu;
3
u.d=d;// activate member d;
4
returnu.i;// read from inactive member (UB!)
5
}
(Bemerkung: der Optimizer darf die Funktion nicht entfernt haben)
Jetzt bekommt man wie erwartet den Fehler, dass hier UB vorliegt, und es
lässt sich nicht compilieren.
Wunderbar.
Macht man es richtig, ist es auch in einer immediate-funktion möglich:
1
constevalto_typetest1(constfrom_typed){
2
Uu;
3
u.d=d;// activate member d;
4
u.d.~from_type();// end-of-life
5
new(&u.i)to_type();// begin-of-life
6
returnu.i;// read from now active member
7
}
Die "normale" Variante, die Objekt-Repräsentationen auszutauschen, geht
für triviale Typen über std::memcpy(), wie wir alle wissen:
1
constexprto_typetest2(constfrom_typed){// violates constexpr because of std::memcpy (use std::bit_cast)
2
to_typer;// begin-of-life
3
std::memcpy(&r,&d,(sizeof(from_type)>sizeof(to_type)?sizeof(to_type):sizeof(from_type)));// copy-state (must be trivially-copyable)
4
returnr;
5
}
Natürlich ist std::memcpy() genauso effizient (noop) wie der union-Weg.
Folgendes
1
autofoo(){
2
constexprfrom_typex;
3
returntest1(x);
4
}
5
intmain(){
6
from_typex;
7
returnfoo().m+test2(x).m;
8
}
ergibt dann (from_type == to_type == char):
1
test1(char):
2
ret
3
.sizetest1(char),.-test1(char)
4
.typetest2(char),@function
5
test2(char):
6
ret
7
.sizetest2(char),.-test2(char)
8
.typefoo(),@function
9
foo():
10
ldir24,lo8(1);,
11
ret
12
.sizefoo(),.-foo()
13
.section.text.startup,"ax",@progbits
14
.typemain,@function
15
main:
16
ldir24,lo8(2);,
17
ldir25,0;
18
ret
Bei einem punning von double -> int:
1
test1(double):
2
ret
3
.sizetest1(double),.-test1(double)
4
.typetest2(double),@function
5
test2(double):
6
ret
7
.sizetest2(double),.-test2(double)
8
.typefoo(),@function
9
foo():
10
ldir22,0;
11
ldir23,0;
12
ldir24,lo8(-128);,
13
ldir25,lo8(63);,
14
ret
15
.sizefoo(),.-foo()
16
.section.text.startup,"ax",@progbits
17
.typemain,@function
18
main:
19
ldir25,0;
20
ldir24,0;
21
ret
Man könnte daraus schließen, dass es trotz UB korrekt funktioniert.
Dass das aber nicht so ist, sieht man, wenn man statt primitiver
Datentypen auch mal UDT verwendet, wie etwa:
1
structS{
2
constexprS(){
3
if(!std::is_constant_evaluated()){
4
#ifdef USE_ASM
5
asm(";S()");
6
#endif
7
}
8
}
9
uint8_tm{42};
10
};
11
12
structV{
13
constexprV(){
14
if(!std::is_constant_evaluated()){
15
#ifdef USE_ASM
16
asm(";V()");
17
#endif
18
}
19
}
20
uint8_tm{43};
21
};
Das ergibt dann (ohne UB):
1
test1(S):
2
;test1()>
3
;V()
4
;test1()<
5
ldir24,lo8(43);,
6
ret
7
.sizetest1(S),.-test1(S)
8
.typetest2(S),@function
9
test2(S):
10
;test2()>
11
;V()
12
;test2()<
13
ret
14
.sizetest2(S),.-test2(S)
15
.typefoo(),@function
16
foo():
17
;test1()>
18
;V()
19
;test1()<
20
ldir24,lo8(43);,
21
ret
22
.sizefoo(),.-foo()
23
.section.text.startup,"ax",@progbits
24
.typemain,@function
25
main:
26
;S()
27
;test1()>
28
;V()
29
;test1()<
30
;test2()>
31
;V()
32
;test2()<
33
ldir24,lo8(85);,
34
ldir25,0;
35
ret
und wiedre mit UB:
1
test1(S):
2
;test1()>
3
;test1()<
4
ret
5
.sizetest1(S),.-test1(S)
6
.typetest2(S),@function
7
test2(S):
8
;test2()>
9
;V()
10
;test2()<
11
ret
12
.sizetest2(S),.-test2(S)
13
.typefoo(),@function
14
foo():
15
;test1()>
16
;test1()<
17
ldir24,lo8(42);,
18
ret
19
.sizefoo(),.-foo()
20
.section.text.startup,"ax",@progbits
21
.typemain,@function
22
main:
23
;S()
24
;test1()>
25
;test1()<
26
;test2()>
27
;V()
28
;test2()<
29
ldir24,lo8(84);,
30
ldir25,0;
31
ret
Man sieht also gut, dass die Lebensdauer des V-Objektes bei der
union-Variante nicht korrekt begonnen hat (der Konstruktor wurde nicht
aufgerufen, und dieser könnte auch noch einen Seiteneffekt haben). Im
Sinne von C++ gibt es das V-Objektes noch gar nicht. Daher UB.
Damit das mit der union-Variante korrekt funktioniert, d.h. das Objekt
korrekt "geboren" wird und(!) die Objektrepräsentation des anderen
übernimmt, braucht man also entweder noch einen
Typumwandlungskonstruktor,
der dann auch korrekterweise aufgerufen werden müsste, oder etwa einen
speziellen Konstruktor, der die Objektrepräsentation unangetastet lässt.
Setzt man dafür den Copy-Ctor ein, würde das bei double->int zusätzlich
dazu führen, dass auch noch __fixfsi() aufgerufen wird, um das Runden
Richtung 0 durchzuführen. Das ist dann aber eine andere Semantik als das
direkte
Kopieren der Objektrepräsentation via std::memcpy().
Nachtrag: std::memcpy() ist nicht constexpr, kann also für
immediate-funktions nicht eingesetzt werden (wir warten auf
std::bit_cast()).
Bottom-line: immediate-functions decken jedes UB auf. Auch dort, wo der
UB-Sanitizer das nicht macht (weil nicht implementiert, wie hier), oder
wenn man auf seiner Plattform (bare-metal) keinen hat.
(Mit dem angehängten Beispiel kann man etwas herum spielen.)
Wilhelm M. schrieb:> Folgender Code, der klar UB enthält:>> to_type test1(const from_type d) {> U u;> u.d = d; // activate member d;> return u.i; // read from inactive member (UB!)> }>> Leider ist es aber tatsächlich so, dass der UB-sanitizer gerade diesen> Fall nicht abdeckt.
Hat vielleicht damit zu tun, dass es eine GCC Erweiterung ist?
1
To fix the code above, you can use a union instead of a cast (note that this is a GCC extension which might not work with other compilers):
2
3
#include <stdio.h>
4
5
int main()
6
{
7
union
8
{
9
short a[2];
10
int i;
11
} u;
12
13
u.a[0]=0x1111;
14
u.a[1]=0x1111;
15
16
u.i = 0x22222222;
17
18
printf("%x %x\n", u.a[0], u.a[1]);
19
return 0;
20
}
21
Now the result will always be "2222 2222".
Ist zwar unter "C" gelistet, aber die C/C++ Frontends teilen sich
manchen Code. Dieses Punning wird zum Beispiel in der libgcc in
float-Emulation verwendet, um an die interne Darstellung von float zu
kommen. libgcc ist aber auch in C geschrieben.
Johann L. schrieb:> Hat vielleicht damit zu tun, dass es eine GCC Erweiterung ist?
Wie meinst Du das?
In C ist es ja erlaubt (auch aus den Gründen, die ich oben erläutert
habe, warum es in C++ nicht erlaubt sein kann).
Der UB-sanitizer deckt diesen Fall laut Doku einfach nicht ab. Die Frage
ist, warum er das nicht macht. Denn offensichtlich erkennt der GCC
dieses UB ja, nur runtime ist eben NDR, und compile-time muss er es
ablehnen.
Wilhelm M. schrieb:> Man sieht also gut, dass die Lebensdauer des V-Objektes bei der> union-Variante nicht korrekt begonnen hat (der Konstruktor wurde nicht> aufgerufen, und dieser könnte auch noch einen Seiteneffekt haben).
Das ist doch aber genau das, was man erwarten würde, wenn man
type-punning anwendet: Das Objekt wird nicht konstruiert, sondern direkt
aus der Reinterpretation einer Repräsentation gewonnen. Da die schon da
ist, muss das Objekt halt schon konstruiert worden sein.
Dass type-punning ( auch mit gcc Erweiterung ) UB ist, liegt wohl eher
an komplexeren Aliasing Effekten:
1
int *ip = &u.i;
2
3
... (viel mehr code - unmöglich alles nachzuvollziehen)
4
5
u.f = 2.0f;
6
*ip = 1;
7
cout << u.f;
hier verliert der Compiler ggf. das Tracking, und weiß nicht mehr, dass
u.f durch einen Write auf *ip geändert werden kann. Die Doku der
Erweiterung sagt auch, dass Aliasing nur bei direkten Zugriffen durch
union member erkannt wird.
Heiko L. schrieb:> Da die schon da> ist, muss das Objekt halt schon konstruiert worden sein.
Nein, die Lebensdauer eines Objektes beginnt erst mit seiner
Konstruktion, nicht mit dem Vorhandensein (irgendeine) einer
Objektrepräsentation.
Man kann natürlich eine Typw.-ctor im placement-new aufrufen.
Heiko L. schrieb:> Dass type-punning ( auch mit gcc Erweiterung ) UB ist, liegt wohl eher> an komplexeren Aliasing Effekten
Nein.
Das Aliasing und die daraus ggf. folgende, falsche Optimierung hat mit
dem UB des Type-punning nichts zu tun. Aber: eine Verletzung der
strict-aliasing-rule ist selbst natürlich wieder UB.
Wilhelm M. schrieb:> Nein, die Lebensdauer eines Objektes beginnt erst mit seiner> Konstruktion, nicht mit dem Vorhandensein (irgendeine) einer> Objektrepräsentation.
Und doch ist es im Falle der union einfach da. Creatio ex nihilo. Wie
mysteriös.
Wilhelm M. schrieb:> Nein.> Das Aliasing und die daraus ggf. folgende, falsche Optimierung hat mit> dem UB des Type-punning nichts zu tun. Aber: eine Verletzung der> strict-aliasing-rule ist selbst natürlich wieder UB.
Nur in theoretischem C++. In gcc funktioniert type-punning über unions
problemlos mit der gemachten Einschränkung.
Wilhelm M. schrieb:> Man sieht also gut, dass die Lebensdauer des V-Objektes bei der> union-Variante nicht korrekt begonnen hat (der Konstruktor wurde nicht> aufgerufen, und dieser könnte auch noch einen Seiteneffekt haben).
Was mir da noch einfällt:
Bei einer Klasse
1
class X {
2
X(int x) : m(x) { cout<<"called"; }
3
X() : m(0) {}
4
5
operator int() { return m; }
6
private:
7
int m;
8
};
ist es eigentlich unmöglich, ein X zu beobachten, mit Wert != 0, ohne
"called" als Ausgabe zu erhalten. Das stimmt natürlich auch schon mit
memcpy nicht mehr.
Das sind diese Momente, wo ich mit dem Programmieren aufhören möchte und
stattdessen lieber Besenstiele in Einheitslänge drechsle...
Höchst interessant ausgeführt von Wilhelm, aber m.M.n. leider auch immer
mehr ein Zeichen dafür, wie schwer es mittlerweile geworden ist, moderne
Programmiersprachen zu verstehen.
Ich mein, den Sachverhalt um Unions und dass nur das zuletzt
beschriebene Element gelesen werden darf, weiß vermutlich "jeder"
Programmierer. Aber die Implikationen in Gänze überblicken mit
Sicherheit nur wenige.
Heiko L. schrieb:> Was mir da noch einfällt:> Bei einer Klasseclass X {> X(int x) : m(x) { cout<<"called"; }> X() : m(0) {}>> operator int() { return m; }> private:> int m;> };> ist es eigentlich unmöglich, ein X zu beobachten, mit Wert != 0, ohne> "called" als Ausgabe zu erhalten. Das stimmt natürlich auch schon mit> memcpy nicht mehr.
Es geht nicht wirklich darum, ob ein spezieller Konstruktor aufgerufen
wurde. Es existiert ein X, bevor du mit memcpy etwas hineinkopierst.
Beim type-punning existiert kein X.
mh schrieb:> Es geht nicht wirklich darum, ob ein spezieller Konstruktor aufgerufen> wurde. Es existiert ein X, bevor du mit memcpy etwas hineinkopierst.> Beim type-punning existiert kein X.
Ja, nee - es geht darum, dass das "Seiteneffekte"-Argument ohnehin nicht
ganz hält. Da spielt es eine Rolle.
Sven P. schrieb:> Ich mein, den Sachverhalt um Unions und dass nur das zuletzt> beschriebene Element gelesen werden darf, weiß vermutlich "jeder"> Programmierer.
Naja, da bin ich mir nicht so sicher ...
Mit diesem Beitrag wollte ich einfach mal es ganz klar machen, warum es
in C++ (im Gegensatz zu C) nicht erlaubt sein kann. Sicher haben es
viele Leute schon mal gehört. Doch die Antwort ist ja oft: es geht
trotzdem.
Und das zweite, was ich wesentlich interessanter finde, ist, dass man
jedes(!) UB mit einer immediate-function oder einer constexpr-function
in einem constexpr-Kontext sichtbar machen kann. Zur Compilezeit ohne
sanitizer (der es ja in diesem speziellen Fall auch gar nicht aufdeckt).
Auch andere Sachen wie eine Verletzung der strict-aliasing-rule, etc.
werden sofort sichtbar.
Leider werden wegen NDR solche Sachen normalerweise nicht gewarnt,
obwohl der Compiler ja offensichtlich die Diagnose darüber stellt /
stellen kann.
Wilhelm M. schrieb:> Und das zweite, was ich wesentlich interessanter finde, ist, dass man> jedes(!) UB mit einer immediate-function oder einer constexpr-function> in einem constexpr-Kontext sichtbar machen kann.
Wobei es vermutlich als bug anzusehen ist, dass der Compiler trotz
entsprechender Warning-Flags das nicht von vornherein beanstandet. Oder
war dein konkreter Use-Case so komplex, dass der da ausgestiegen ist?
Guest schrieb:> Wilhelm M. schrieb:>> Doch die Antwort ist ja oft: es geht>> trotzdem.>>> Timur Doumler sagte hier: "Wenn das Verkehrsschild anweist, links zu> fahren, kann es gut gehen trotzdem rechts zu fahren" ;-)>> https://www.youtube.com/watch?v=_qzMpk-22cc
Genau!
Und übrigens sind die strict-aliasing-rules und die daraus folgenden
Optimierungen ein sehr starkes Argument für domänenspezifische
Datentypen bzw. templates. Dann wird immer der optimale Code generiert,
und man kommst gar nicht erst in die Versuchung, die Regeln zu
verletzen.
Heiko L. schrieb:> mh schrieb:>> Es geht nicht wirklich darum, ob ein spezieller Konstruktor aufgerufen>> wurde. Es existiert ein X, bevor du mit memcpy etwas hineinkopierst.>> Beim type-punning existiert kein X.>> Ja, nee - es geht darum, dass das "Seiteneffekte"-Argument ohnehin nicht> ganz hält. Da spielt es eine Rolle.
Ich habe bei Wilhelm kein "Seiteneffekte"-Argument gesehen, es sei denn
du meinst den Konjunktiv in Klammern hinter dem eigentlichen Argument.
Es gibt auch bessere Beispiele mit korrektem C++, wo Konstruktoren mit
"Seiteneffekten" nicht aufgerufen werden.
mh schrieb:> Heiko L. schrieb:>> mh schrieb:>>> Es geht nicht wirklich darum, ob ein spezieller Konstruktor aufgerufen>>> wurde. Es existiert ein X, bevor du mit memcpy etwas hineinkopierst.>>> Beim type-punning existiert kein X.>>>> Ja, nee - es geht darum, dass das "Seiteneffekte"-Argument ohnehin nicht>> ganz hält. Da spielt es eine Rolle.>> Ich habe bei Wilhelm kein "Seiteneffekte"-Argument gesehen, es sei denn> du meinst den Konjunktiv in Klammern hinter dem eigentlichen Argument.> Es gibt auch bessere Beispiele mit korrektem C++, wo Konstruktoren mit> "Seiteneffekten" nicht aufgerufen werden.
Naja, wenn das "eigentliche Argument" damit begründet wird, hinkt es.
Ich bezweifle auch nicht, dass der Standard das als UB definiert. Das
ist mir nur reichlich egal, weil C++ < 20 so lückenhaft ist, dass man,
ohne auf Compiler-Extensions zurückzugreifen, eine z.T. unbrauchbare
Sprache vor sich hat.
Edit: Ich sollte nicht "< 20" schreiben. Dass es sowieso immer
(Definitions-)lücken geben muss sagt uns schon Kurt Gödel.
Heiko L. schrieb:> Das> ist mir nur reichlich egal, weil C++ < 20 so lückenhaft ist, dass man,> ohne auf Compiler-Extensions zurückzugreifen, eine z.T. unbrauchbare> Sprache vor sich hat.
Kannst Du diese Deine persönliche Einschätzung etwas begründen?
Wilhelm M. schrieb:> Heiko L. schrieb:>> Das>> ist mir nur reichlich egal, weil C++ < 20 so lückenhaft ist, dass man,>> ohne auf Compiler-Extensions zurückzugreifen, eine z.T. unbrauchbare>> Sprache vor sich hat.>> Kannst Du diese Deine persönliche Einschätzung etwas begründen?
Schau mal das Video oben: Eigentlich fängt fast jedes cppcon-Video damit
an, das einer erzählt, warum man mit C++ theoretisch überhaupt gar
nichts anfangen kann, weil, wenn man jetzt den und den Satz so und so
interpretiert, auf einmal alles kaputt ist. Und da kann man immer noch
abwarten: Irgendeine Definitionslücke gibt es immer.
Es ist also sowieso der wohlwollenden Auslegung der Definitionen zu
verdanken, dass "Hello, World" keine Kernschmelze initiiert.
"Volatilität" im Zusammenhang mit Multithreading war ja auch lange etwas
wackelig.
Ansonsten muss man ja nur mal einen Blick in die Liste mit den
restlichen paar Core Language Defects werfen :)
Sven P. schrieb:> "Volatilität" im Zusammenhang mit Multithreading war ja auch lange etwas> wackelig.
Das hat aber gar nicht erstmal mit MT zu tun. Ein simpler
Definitionsdefekt seit K&R.
Wilhelm M. schrieb:> Sven P. schrieb:>> "Volatilität" im Zusammenhang mit Multithreading war ja auch lange etwas>> wackelig.>> Das hat aber gar nicht erstmal mit MT zu tun. Ein simpler> Definitionsdefekt seit K&R.
Gab es bei K&R überhaupt schon das Konzept "MT"?
WIMRE gab es das selbst bei C89 und C99 noch nicht.
Man hat es halt mit "volatile" relativ breitbandig erschlagen.
Sven P. schrieb:> Gab es bei K&R überhaupt schon das Konzept "MT"?
Jein. Nicht in Unix/Multics.D eswegen schrieb ich das ja auch. Aber auf
anderen Systemen sehr wohl.
Dennis/Ritchie hatten das Problem der Nebenläufigkeit durch HW
(Register).