Warum funktioniert ein leerer Type-Conversion-Operator ?



  • Hallo!
    Joa, der Titel sagt eigentlich schon alles.. Ich hatte hier gestern beim Experimentieren mit Templates eine Situation, in der der Compiler dummerweise nicht weiter wusste (nicht konvertieren konnte). Darauf hin habe ich ihm einen Cast-OP bereit gestellt, war mir aber nicht sicher was ich zurück geben sollte, also habe ich ihn erstmal leer gelassen.
    Einfach nur "operator T & (){}".
    Und das hat dem Compiler wohl schon gereicht.
    Jertzt frage ich mich aber, wie ich das zu verstehen habe. Erstmal warum er sich nicht beschwert, dass es kein return gibt, und weil es ja keins gibt, was er dann überhaupt macht.

    Ich habe das Beispiel leider nicht gespeichert, aber vielleicht hat ja jemand auch so eine Idee warum das geht?!

    0xMöbius


  • Mod

    Erstmal warum er sich nicht beschwert, dass es kein return gibt

    Weil das Programm wohlgeformt ist

    und weil es ja keins gibt, was er dann überhaupt macht.

    Das ist nicht definiert. Vielleicht krachts.



  • Hmm. Wie passt denn "wohlgeformt" und "nicht definiert" zusammen?

    Mir wärs lieber wenns krachen würde.. Tuts aber nicht.
    Dann frage ich mal so: Woran liegt es, dass es nicht kracht? Also was könnte der Compiler machen, wenn er nichts vom Casp-OP zurück bekommt? Evtl "*this" zurück geben??



  • Er könnte ein uninitialisiertes Stück Speicher als T interpretieren.

    Intern sind Funktionen mit structs/classes als Returntyp oft als void Funktion mit einem zusätzlichen Zeiger-Parameter implementiert, wo der Aufrufer die Adresse des Speichers angibt wo er das Ergebnis reinkonstruiert haben möchte.
    Also quasi

    void Foo(int param1, int param2, void* returnValueAddress);
    

    Wenn man brav return en tut, dann wird daraus sowas wie

    void Foo(int param1, int param2, void* returnValueAddress)
    {
        //...
        new (returnValueAddress) T(); return; // return T();
    }
    

    Und ohne return fällt dann einfach das placement-new weg.

    D.h. dass der Aufrufer glaubt er hat nach dem Aufruf ein fertig konstruiertes T Objekt an Adresse returnValueAddress liegen, in Wirklichkeit liegt da aber bloss ein "Bytehaufen".

    Der Effekt ist der selbe wie wenn man

    char storage[sizeof(T)];
    T* t = reinterpret_cast<T*>(&storage[0]);
    t->UseIt();
    

    macht.
    Also ganz viel UB.


  • Mod

    0xMöbius schrieb:

    Wie passt denn "wohlgeformt" und "nicht definiert" zusammen?

    Ohne zu sehr ins Detail zu gehen:
    Wohlgeformtheit ist eine Eigenschaft des Quelltextes: Für ein Programm, dass nicht wohlgeformt ist, muss der Compiler keine ausführbare Datei erstellen, und in den meisten Fällen hat der Compiler eine entsprechende Diagnostik (Fehlermeldung) zu liefern. Dabei geht es primär um die Syntaxfragen aber z.T. auch Semantik. Fehler dieser Art, die der Compiler nicht erkennen muss (z.B. gewisse ODR-Verletzungen), sind typischerweise solche, die - zumindest im allgemeinen Fall - nur mit unverhältnimässig hohem algorithmischen Aufwand erkannt werden können (Compiler sollen schnell sein) oder die i.d.R. erst in sekundären Übersetzungsschritten (Linker) auftreten. Produziert ein Compiler trotz fehlender Wohlgeformtheit etwas Ausführbares resultiert bei der Ausführung in jedem Fall undefiniertes Verhalten.

    Definiertheit bezieht sich auf das Verhalten eines übersetzten Programmes und kann als solches (für ein wohlgeformtes Programm) auch von Eingabedaten abhängen (z.B. könnte sich ein fehlerhaftes Konstrukt in einem if-Zweig befinden, der aufgrund der konkreten Eingaben nie betreten wird - dann hat das Programm für diese Eingabedaten definiertes Verhalten).



  • Ich denke ich habe das soweit verstanden. Geht ein wenig in die Richtung Deklaration - Definition.. Danke für eure Erklärungen.
    Aber eine Frage hätte ich jetzt noch.
    Hier der Code:

    #include <stdio.h>
    
    struct A{
    	double doubles[5];
    
    	A(void){ for(int i = 0; i<5; ++i){ doubles[i] = 2.*i; } }
    
    	double & getD(void){ return doubles[3]; }
    };
    
    strunct B{
    	double doubles[10];
    
    	B(void){ for(int i = 0; i<10; ++i){ doubles[i] = 2.*i+1.; } }
    
    	double & getD(void){ return doubles[3]; }
    
    	operator A & (){ return *this; }
    //	operator A & (){
    //		A a;
    //		for(int i = 0; i<5; ++i){ a[i] = doubles[i]; }
    //		return a;
    //	}
    };
    
    void fkt(A & a){ printf("D:%f ", a.getD()); }
    
    int main(){
    
    	A a;
    	B b;
    
    	fkt(a);
    	fkt(b);
    
    	return 0;
    }
    

    Mal angenommen, es geht um 2 Klassen, die sich nur durch die Länge eines internen Arrays unterscheiden.
    Muss ich für die Konvertierung von B nach A wirklich ein neues A erstellen und füllen (auskommentierter Bereich)?
    Das ist in diesem Fall zwar kein großer Aufwand, aber bei umfangreicheren Klassen ist das doch ein vermeidbarer Zeitfresser.
    Ist der Aufbau von Klassen, also Member und Methoden, im Speicher irgendwie reglementiert? Schön wäre doch z.B. wenn erst die Member-Vars. kommen und dann die Methoden (andersrum wärs zwar noch schöner, dann müsste man hier garnichts beachten, aber zumindest beim gcc scheint das nicht so zu sein..). In diesem Fall bräuchte ich nur ein Offset um den Teil des Arrays zu überspringen, und schon hätte ich ganz ohne zusätzlichen Zeit- und Speicherverbrauch aus dem B ein A gemacht.

    Also ich vermute schon mal, dass es nicht anders geht 🙄... Aber fragen kostet ja nix! Wie würdet ihr das machen? Bzw. kann man das überhaupt so machen, wie mit dem auskommentierten OP? Ich weiß nämlich nicht genau, wie es sich mit der Lebenszeit des intern erstellten As verhält..


  • Mod

    struct A{
    ...
    };
    
    struct B{
    ...
    	operator A & (){ return *this; }
    };
    

    Dein Konvertierungsoperator B->A& ruft sich selbst auf, wir haben es hier also mit unbeschränkter Rekursion, folglich undefiniertem Verhalten zu tun.

    0xMöbius schrieb:

    Mal angenommen, es geht um 2 Klassen, die sich nur durch die Länge eines internen Arrays unterscheiden.
    Muss ich für die Konvertierung von B nach A wirklich ein neues A erstellen und füllen (auskommentierter Bereich)?

    ja (wobei die Rückgabe einer Referenz nat. auch fehlerhaft ist). Aliasing (d.h. die Benutzung einer Speicherregion eines bestimmten Typs so als ob diese einen anderen Typ hätte) ist in C++ nur unter sehr eng begrenzten Umständen zulässig.


  • Mod

    camper schrieb:

    unbeschränkter Rekursion, folglich undefiniertem Verhalten zu tun.

    Aus Neugier, ist das zwangsläufig so? [intro.multithread]/27 spricht nicht von UB. Oder führen Rahmenbedingungen, die solche Annahmen nicht erfüllen, zu UB?

    Muss ich für die Konvertierung von B nach A wirklich ein neues A erstellen und füllen (auskommentierter Bereich)?

    Folgendes könnte ggf. für dich interessant sein. Die Klassen haben Standardlayout, demnach greift [class.mem]/19 und

    union {B b; A a;} u{}; // Initialisiert b, welches nun der aktive Member ist
    std::cout << u.a.doubles[0];
    

    ist definiert.



  • Dein Konvertierungsoperator B->A& ruft sich selbst auf, wir haben es hier also mit unbeschränkter Rekursion, folglich undefiniertem Verhalten zu tun.

    Also das hätte ich, wenn ich dich richtig verstehe, mal garnicht erwartet. Wieso ruft der Konvertierungs-OP sich selbst auf? Müsste dann nicht irgendwo ein Funktions-Stack explodieren?
    Nach dem OP ist doch aus dem B ein A geworden, und es gibt keinen Grund ihn nochmal aufzurufen.
    Ich hätte jetzt erwartet, dass der this-Zeiger von B als this-Zeiger von A interpretiert wird. Also quasi ein impliziter reinterpret_cast.. Warum erlaubt der Compiler denn dem Cast-OP ein Typ B zurück zu geben, wo eigentlich ein Typ A erwartet wird, ohne irgendeinen Cast??

    Ich habe jetzt einfach mal den (reinterpret_cast<A>(this)) manuell ausgeführt. Und!? Es geht! Aber wie kann das sein?! 😕

    Vielleicht noch ein kurzer Nachtrag zum kompilierten (gcc) Programm: Ohne Cast, so wie es in meinem vorigen Post steht, stürzt es zwar nicht ab, aber "fkt(b)" gibt auch nichts aus und es wird mit 0xC0000005 (Access Violation) beendet. Hier frage ich mich, warum es nicht gleich abstürzt..
    Naja, mit reinterpret_cast gibt "fkt(b)" aus, was sie soll und das Programm wir normal beendet.
    Macht das irgendwie Sinn (zumindest für den o.g. Fall)?

    ja (wobei die Rückgabe einer Referenz nat. auch fehlerhaft ist).

    Ich hatte da im Hinterkopf, dass die Lebenszeit von Referenzen bei Bedarf automatisch verlängert werden kann..
    Das würde ja bedeuten, dass ich (zumindest temporär) 3 Objekte habe - ein verpacktes, eine neue Kopie und eine Kopie der Kopie. Geht das nicht einfacher?
    Das macht mich irgendwie nicht glücklich (wie in letzter Zeit vieles, dass mit Templates zutun hat, nur so nebenbei..).

    Aliasing (d.h. die Benutzung einer Speicherregion eines bestimmten Typs so als ob diese einen anderen Typ hätte) ist in C++ nur unter sehr eng begrenzten Umständen zulässig.

    Das klingt immerhin nach einem Ausweg, um statt mit Kopien von Kopien doch mit nur einem Speicherbereich arbeiten zu können...
    Was wäre z.B. wenn man tatsächlich die Daten des originalen Speichers ändern möchte (in einer Funktion, die nur mit kleineren Speicherbereichen arbeitet)?! So müsste man erst (mindestens) eine Kopie erstellen, diese ändern, und dann damit den eigentlichen Speicherbereich überschreiben. Das klingt schon wie "von hinten durch die Brust ins Auge"..

    Mal sehen, vielleicht ist es Wert, ein neues Thema zu starten, z.B.: "Klasse als Teilmenge einer anderen Klasse. Wie effizient umwandeln?"
    Also sosusagen soetwas wie die Beziehung von Basis zu Abgeleitetem, nur ohne Vererbung... Bzw. den Cast von Derived->Base manuell nachbilden..


  • Mod

    Müsste dann nicht irgendwo ein Funktions-Stack explodieren?

    Wird er wahrscheinlich auch, wenn du das Programm ausführst. Dann wird der Frame-Stack überlaufen.

    denn dem Cast-OP ein Typ B zurück zu geben, wo eigentlich ein Typ A erwartet wird, ohne irgendeinen Cast??

    Warum? B ist doch nach A& konvertierbar. I.e.

    operator A&() {return *this;}
    

    Ist nach Anwendung des eben deklarierten operator A& äquivalent zu

    operator A&() {return this->operator A&();}
    

    .. ein Bilderbuchbeispiel endloser Rekursion. Clang gibt hier auch eine hübsche Fehlermeldung.

    Ich habe jetzt einfach mal den (reinterpret_cast<A>(this)) manuell ausgeführt. Und!? Es geht! Aber wie kann das sein?!

    Warum sollte es nicht gehen? Wenn wir den Standard pedantisch befolgen hat dein Programm undefiniertes Verhalten. Das schließt jedoch nicht aus, dass das resultierende Verhalten dem Erwarteten gleicht.

    es wird mit 0xC0000005 (Access Violation) beendet.

    Jo. Das heißt Abstürzen. Das Programm ist abgekrazt nachdem ein Frame gepushed wurde, welches auf dem Stack keinen Platz mehr hatte.


  • Mod

    Arcoth schrieb:

    camper schrieb:

    unbeschränkter Rekursion, folglich undefiniertem Verhalten zu tun.

    Aus Neugier, ist das zwangsläufig so? [intro.multithread]/27 spricht nicht von UB. Oder führen Rahmenbedingungen, die solche Annahmen nicht erfüllen, zu UB?

    Solche Spitzfindigkeiten hatte ich nicht im Blick. Zweifellos ist die Rekursion hier nicht völlig leer, weil ja ein Wert produziert werden muss. Wenn also der Compiler die rekursion eliminiert (tail-rekursion oder so) und auch noch die resultierende Schleife, weil sie leer ist... haben wir immer noch nichts in der Hand, um den Rückgabewert zu initialisieren.



  • Arcoth schrieb:

    camper schrieb:

    unbeschränkter Rekursion, folglich undefiniertem Verhalten zu tun.

    Aus Neugier, ist das zwangsläufig so? [intro.multithread]/27 spricht nicht von UB. Oder führen Rahmenbedingungen, die solche Annahmen nicht erfüllen, zu UB?

    Ist es nicht so dass C++ von jedem Programm verlangt "progress" in endlicher Zeit zu machen?
    Also dass jedes Programm das (ab einem gewissen Punkt) *nie* mehr irgendetwas beobachtbares macht UB isthat. Bzw. auch jedes Programm welches einen Thread enthält der nie irgend etwas beobachtbares macht.
    Also der Grund warum

    while (true);
    

    verboten ist.

    Würde das nicht auch eine unendliche Rekursion verbieten die keinen beobachtbaren Effekt hat, so wie in diesem Beispiel?


  • Mod

    Lass mich die Frage anders formulieren: ist for(;;); zwangsläufig UB?

    Ist es nicht so dass C++ von jedem Programm verlangt "progress" in endlicher Zeit zu machen?

    Nein. Der von mir angesprochene Paragraph sagt, dass Implementierungen dies annehmen dürfen. Darin besteht der Unterschied der zu meiner Frage führt.


  • Mod

    Doch, es scheint als ob diese endlose Rekursion tatsächlich undefiniertes Verhalten hat: N1528 erklärt, dass die entsprechende Passage in C - die der in C++ sehr ähnlich ist - endlosen Schleifen undefiniertes Verhalten zuschreibt. Dazu kommt ein Zitat aus einem Artikel dessen Autor mit dem Autor des bereits verlinkten Dokuments kollaboriert hat:

    <a href= schrieb:

    Compilers and Termination Revisited">Unfortunately, the words “undefined behavior” are not used. However, anytime the standard says “the compiler may assume P,” it is implied that a program which has the property not-P has undefined semantics.



  • Ich hatte zwar keine Ahnung dass es N1528 gibt, aber meine Logik sagte mir einfach auch so "implementation may assume" == UB 😉 *
    Weil: ich wüsste nicht was es sonst bedeuten soll.

    Sollte aber vermutlich klargestellt werden. Also an allen Stellen wo "implementation may assume" steht den Text ändern (z.B. NOTE ala "this means programs that do X have undefined bevahior" dazumachen). Oder gleich ganz aufräumen und alle Formulierungen vereinheitlichen, und dann irgendwo 1x an einem zentralen Punkt klarstellen dass die Implementierung annehmen darf dass das Programm kein UB erzeugt.

    EDIT:
    *: "UB wenn der Programm die Annahme nicht erfüllt" ist natürlich gemeint



  • Arcoth schrieb:

    Folgendes könnte ggf. für dich interessant sein. Die Klassen haben Standardlayout, demnach greift [class.mem]/19 und

    union {B b; A a;} u{}; // Initialisiert b, welches nun der aktive Member ist
    std::cout << u.a.doubles[0];
    

    ist definiert.

    Das ist wirklich gut zu wissen. Auf sowas hatte ich gehofft. Mit deinem Code-Schnipsel hast du mich jetzt aber etwas verwirrt. Ne union ist dafür doch nicht zwingend nötig, oder??

    Arcoth schrieb:

    denn dem Cast-OP ein Typ B zurück zu geben, wo eigentlich ein Typ A erwartet wird, ohne irgendeinen Cast??

    Warum? B ist doch nach A& konvertierbar. I.e.

    operator A&() {return *this;}
    

    Ist nach Anwendung des eben deklarierten operator A& äquivalent zu

    operator A&() {return this->operator A&();}
    

    ..

    Ach.. Ich doof!? Bei jeder normal aussehenden Funktion wäre mir das sicher aufgefallen - bestimmt... Den impliziten Cast muss ich wohl übersehen haben.. 🕶
    Ne gemeine Falle, sozusagen mit der Deklaration schon den Cast zu "definieren", und ihn anzuwenden, ohne ihn hinzuschreiben.

    Arcoth schrieb:

    ein Bilderbuchbeispiel endloser Rekursion. Clang gibt hier auch eine hübsche Fehlermeldung.

    Das ist schön - für Clang-Nutzer..

    Arcoth schrieb:

    Ich habe jetzt einfach mal den (reinterpret_cast<A>(this)) manuell ausgeführt. Und!? Es geht! Aber wie kann das sein?!

    Warum sollte es nicht gehen? Wenn wir den Standard pedantisch befolgen hat dein Programm undefiniertes Verhalten. Das schließt jedoch nicht aus, dass das resultierende Verhalten dem Erwarteten gleicht.

    Einer der Aspekte, die mich an c++ besonders stören: Mit Betäubung in den Fuß schießen, und dann irgendwann umbemerkt verbluten.
    Lässt sich der reinterpret_cast und das Standard-Layout der Klasse nicht zu definiertem Verhalten verbinden??
    Mit der union weiß ich nicht genau, wie ich weiter machen soll. Ein Cast des Speicherbereichs wäre das, was ich idealerweise erwarten würde.. Also mal direkt gefragt: reinterpret_cast in Fall von Standard-Layout-Klassen gut oder böse?

    Arcoth schrieb:

    es wird mit 0xC0000005 (Access Violation) beendet.

    Jo. Das heißt Abstürzen. Das Programm ist abgekrazt nachdem ein Frame gepushed wurde, welches auf dem Stack keinen Platz mehr hatte.

    Na dann ists ja gut. Ich habe mich nur gewundert, dass es noch bis zum return der main() kommt.. Gibts dafür auch ne Erklärung, oder kann ich das unter UB verbuchen?! 😉

    Ich will euch ja nicht stören, aber eine kleine Frage ;):

    Arcoth schrieb:

    Doch, es scheint als ob diese endlose Rekursion tatsächlich undefiniertes Verhalten hat

    Endlos, nicht weil "while(true) / for( ;; )", sondern weil kein "break;", richtig?


  • Mod

    Ne union ist dafür doch nicht zwingend nötig, oder??

    Doch.

    Lässt sich der reinterpret_cast und das Standard-Layout der Klasse nicht zu definiertem Verhalten verbinden??

    Solange die Klassen völlig unabhängig sind, nicht.

    Ich habe mich nur gewundert, dass es noch bis zum return der main() kommt..

    Ist es nicht.

    Endlos, nicht weil "while(true) / for( ;; )", sondern weil kein "break;", richtig?

    Korrekt. Ob ein bedingter Abbruch durch die Abbruchbedingung oder ein manuelles if (...) break; stattfindet, ist doch egal.



  • Man braucht soweit ich weiss nichtmal einen Abbruch, die Schleife darf ruhig ewig laufen. So lange sie halt immer wieder beobachtbare Effekte hat.



  • 0xMöbius schrieb:

    Endlos, nicht weil "while(true) / for( ;; )", sondern weil kein "break;", richtig?

    Naja, selbst mit einem break kanns ja endlos sein:

    while(true) { if (always_false()) break; }
    

    hustbaer schrieb:

    Man braucht soweit ich weiss nichtmal einen Abbruch, die Schleife darf ruhig ewig laufen. So lange sie halt immer wieder beobachtbare Effekte hat.

    Was sind jetzt eigentlich genau beobachtbare Effekte? Laut dem Link von Arcoth wären das ja nur (informell):

    • Alles mit volatile
    • IO
    • Synchronization/atomic operations

    Woher weiß der Compiler das, hat der eine Liste mit allen Funktionen die so etwas machen können (die letzen beiden Punkte)?


  • Mod

    Der Compiler muss die Information, dass eines dieser "Events" auftreten muss, nicht nutzen, geschweige denn überprüfen.


Anmelden zum Antworten