static_cast von void* auf Derived und FirstBase



  • Also...
    Wenn ich mit placement new ein Objekt in einen Speicherbereich reinmache (der natürlich ausreichend gross und passend aligned ist)...
    Und wenn ich sicher weiss dass das Objekt ein Derived ist, welches als erste Basisklasse Base hat (und natürlich public von Base ableitet)...

    Kann ich dann den selben void* den ich bei placement new übergeben hatte sicher in wahlweise einen Base* bzw. einen Derived* casten? Also laut strict Aliasing, Pointer casting Regeln und was sonst noch relevant sein könnte.

    Quasi

    class MyVariant
    {
    public:
    
        void* storage_address() { return ... }
    
        template <class T>
        void construct()
        {
            // T's erste public Basisklasse ist garantiert immer "Base"!
            new (storage_address()) T();
        }
    
        Base* get_base()
        {
            return static_cast<Base*>(storage_address());
        }
    
        template <class T>
        T* get()
        {
            return static_cast<T*>(storage_address());
        }
    };
    

    Grund ist dass ich get_base() nicht als Template haben möchte -- ich bräuchte das an Stellen wo ich den Typ nicht kenne. Aber da alle T die da mit
    construct reingemacht werden eben immer von Base abgeleitet sind... sollte das doch eigentlich OK sein. Oder übersehe ich da was?

    Dass es funktioniert - zumindest mit aktuellen Compilern - weiss ich. Ich würde gerne wissen ob es erlaubt ist, also ob es laut Standard funktionieren muss.


  • Mod

    Laut aktuellem Standard ist das... vertretbarerweise in Ordnung.

    Ab C++17 wirst du

    Base* get_base()
        {
            return std::launder(static_cast<Base*>(storage_address()));
        }
    

    schreiben müssen.



  • Arcoth schrieb:

    Ab C++17 wirst du

    Base* get_base()
        {
            return std::launder(static_cast<Base*>(storage_address()));
        }
    

    schreiben müssen.

    Sicher?
    Also auch wenn storage_address z.B. so implementiert ist

    class MyVariant
    {
    public:
    
        void* storage_address() { &m_storage.bytes[0]; }
    
    private:
        union AlignmentSizeHelper
        {
            char sizer1[sizeof(T1)];
            typename boost::type_with_alignment<boost::alignment_of<T1>::value>::type aligner1;
            char sizer2[sizeof(T2)];
            typename boost::type_with_alignment<boost::alignment_of<T2>::value>::type aligner2;
            // ...
        };
    
        union Storage
        {
            char bytes[sizeof(AlignmentSizeHelper)];
            AlignmentSizeHelper helper;
        };
    
        Storage m_storage;
    };
    

    Ich hätte gedacht dass in dem Fall die strict-aliasing Ausnahme für char reicht.

    Ich hab' die ganze std::launder() Sache aber auch noch nicht so ganz verstanden. 😞
    Gibt es dazu ne brauchbare (verständliche) Erklärung irgendwo?


  • Mod

    &m_storage.bytes[0] ist ein Zeiger auf das erste Element eines char Arrays. Dieser Zeiger ist nicht mit dem Objekt, für welches dieses Array Speicher bereitstellt, pointer-interconvertible.

    Ergo müssen wir launder n. Dabei ist zu beachten, dass das char Array genau sizeof T groß sein muss, sonst gibt es Bytes die durch den Zeiger auf das erste Element reachable sind, aber nicht durch den Zeiger auf T .



  • Arcoth schrieb:

    &m_storage.bytes[0] ist ein Zeiger auf das erste Element eines char Arrays. Dieser Zeiger ist nicht mit dem Objekt, für welches dieses Array Speicher bereitstellt, pointer-interconvertible.

    OK. Allerdings verstehe ich nicht warum das ein Thema ist. Ich verwende das char Array nie als char Array, also gibt es nichts was die "pointer optimization barrier" Blocken müsste.

    Arcoth schrieb:

    Ergo müssen wir launder n. Dabei ist zu beachten, dass das char Array genau sizeof T groß sein muss, sonst gibt es Bytes die durch den Zeiger auf das erste Element reachable sind, aber nicht durch den Zeiger auf T .

    Ist nicht der Fall, das char Array kann grösser sein - ist ja ein Variant, und die Typen die darin "leben" können sind nicht alle gleich gross.

    Ich verstehe die launder Sache aber immer noch nicht so ganz. Die Beispiele die man immer wieder findet machen placement new T auf ein Objekt, mit einer Adresse wo vorher auch bereits ein T drinnen war. Und verwenden dann std::launder um dem Compiler mitzuteilen dass da jetzt ein neues T drinnen ist. Dabei wird auch nie auf den Returnwert von placement new eingegangen, was ich auch einigermassen verwirrend finde. Denn mMm. müsste es reichen in dem Beispiel zu schreiben

    struct X { const int n; };
    X *p = new X{3};
    const int a = p->n;
    p = new (p) X{5};
    const int b = p->n; // all fine(?)
    

    Aber das nur so nebenbei, ich will ja den T* Zeiger nirgends aufheben, ich weiss ja die Adresse des Objekts sowieso - weil ich das Storage Array kenne wo ich es reinkonstruiert habe.

    Aber davon abgesehen dass ich das nicht ganz verstehe: Gibt es einen Weg zu vermeiden dass man bei sowas (Variants) std::launder bzw. Probleme mit fehlendem std::launder vermeiden kann?


  • Mod

    hustbaer schrieb:

    Ich hab' die ganze std::launder() Sache aber auch noch nicht so ganz verstanden. 😞
    Gibt es dazu ne brauchbare (verständliche) Erklärung irgendwo?

    Willkommen im Club. Die ganzen Änderungen dazu gehen ja auf p0137r1 zurück. Allerdings ist daraus tatsächlich schwer zu erkennen, wie restriktiv die Regeln ausfallen sollen. Die interessanten Beispiele beziehen sich ja tatsächlich nur auf den Sonderfall von Referenzen/const-Membern und Konstruktion eines zweiten Objektes in einem Speicherbereich. Der Normtext dagegen ist, jedenfalls isoliert betrachtet, recht streng formuliert.
    Sicher ist: (1) launder ist nicht erforderlich, sofern alle Zeiger, mit denen auf ein Objekt zu gegriffen wird, von dem Resultat des zugehörigen new-Ausdrucks, der das Objekt erzeugt hat, abgeleitet sind.
    (2) launder ist erforderlich, sofern ein Zeiger, der ursprünglich auf ein früher an der Stelle lebendes Objekt gezeigt hat, benutzt wird, um auf ein danach dort lebendes Objekt zuzugreifen, und das vorher dort lebende Objekt (nicht-statische)Referenz- oder const-Subobjekte enthielt.
    Dann bleiben nach meiner Einschätzung folgende Fälle übrig, bei denen ich auch nach 2-maligem Lesen nicht gänzlich sicher bin:
    (3) wie (2), aber keine Referenz-/const-Member + gleicher Typ von ursprünglichen und nachfolgendem Objekt,
    (4) wie (3) aber unterschiedliche Typen,
    (5) der ursprüngliche Zeiger zeigt auf rohen Speicher, in dem nichts lebt (z.B. malloc&friends)
    Und: spielt es u.U. eine Rolle, ob Zeiger von einem vollständigen Objekt oder einem Subojekt abgeleitet sind?

    Theoretisch müssten ja Diskussionen vor p0137r1 existieren, die dort allerdings nicht referenziert sind. Und ich bin zu faul zum Suchen 🙂


  • Mod

    Der Normtext dagegen ist, jedenfalls isoliert betrachtet, recht streng formuliert.

    Das ist richtig so. Ich habe mich letztens darüber mit dem Autor des papers unterhalten. Die Regeln müssen zuerst formalisiert werden, bevor wir ihre Strenge anpassen können. Indem wir die Regeln zuerst strenger auslegen, erlauben wir Implementierungen, als extensions bestimmte Regeln zu lockern (à la - fno-strict-aliasing ); sind die Regeln aber laxer als erwünscht, kann eine Implementierung kaum strengere Regeln anbieten. Deswegen haben wir gerade streng erscheinende Regeln, wobei man hier erwähnen sollte, dass bestimmte Implementierungen sogar noch striktere befolgen: Der XL C++ compiler von IBM, bspw., hat folgende Optimierung durchgeführt: wird S.b an eine Funktion übergeben, hat diese keinen Weg, auf andere Member von S zuzugreifen. Stimmt aber nicht, wenn S Standardlayout hat und b der erste Member ist, oder? Pointer-interconvertibility gibt eine klarere Antwort als der extrem vage Text von früheren Standards.

    Und: spielt es u.U. eine Rolle, ob Zeiger von einem vollständigen Objekt oder einem Subojekt abgeleitet sind?

    Ja, tut es. Es ist erlaubt (wie schon jeher erwartet), wenn wir von einer Standardlayout Klasse oder einer Union sprechen, sonst eben nicht. #pointerinterconvertibility

    Dann bleiben nach meiner Einschätzung folgende Fälle übrig, bei denen ich auch nach 2-maligem Lesen nicht gänzlich sicher bin:
    (3) wie (2), aber keine Referenz-/const-Member + gleicher Typ von ursprünglichen und nachfolgendem Objekt,
    (4) wie (3) aber unterschiedliche Typen,
    (5) der ursprüngliche Zeiger zeigt auf rohen Speicher, in dem nichts lebt (z.B. malloc&friends)

    (3) ist fast buchstäblich [basic.life]/8, also kein launder notwendig (außer das Ursprungsobjekt war const ). (4) und (5) müssen ganz klar ge launder t werden. Ich denke da an dead store elimination & co.

    hustbaer schrieb:

    Arcoth schrieb:

    &m_storage.bytes[0] ist ein Zeiger auf das erste Element eines char Arrays. Dieser Zeiger ist nicht mit dem Objekt, für welches dieses Array Speicher bereitstellt, pointer-interconvertible.

    OK. Allerdings verstehe ich nicht warum das ein Thema ist. Ich verwende das char Array nie als char Array, also gibt es nichts was die "pointer optimization barrier" Blocken müsste.

    Du verstehst das vielleicht falsch. Die Optimierung besteht doch darin, dass wir schauen, ob irgendwelche Verweise auf das Objekt bestehen. Du hast deinen Zeiger quasi aus dem Finger gezogen; das kann der Optimizer nicht nachvollziehen.

    hustbaer schrieb:

    Arcoth schrieb:

    Ergo müssen wir launder n. Dabei ist zu beachten, dass das char Array genau sizeof T groß sein muss, sonst gibt es Bytes die durch den Zeiger auf das erste Element reachable sind, aber nicht durch den Zeiger auf T .

    Ist nicht der Fall, das char Array kann grösser sein - ist ja ein Variant, und die Typen die darin "leben" können sind nicht alle gleich gross.

    Das ist kein Problem: du musst dann halt deinen Zeiger anders beziehen. Beispielsweise als den Rückgabewert von placement new.

    Dabei wird auch nie auf den Returnwert von placement new eingegangen, was ich auch einigermassen verwirrend finde. Denn mMm. müsste es reichen in dem Beispiel zu schreiben [...]

    Jo, das ist natürlich in Ordnung.

    Aber das nur so nebenbei, ich will ja den T* Zeiger nirgends aufheben, ich weiss ja die Adresse des Objekts sowieso - weil ich das Storage Array kenne wo ich es reinkonstruiert habe.

    Der Punkt ist aber, dass du den Zeiger legitim herleiten musst, damit der Optimizer weiß, auf welches Objekt du zugreifst.

    Aber davon abgesehen dass ich das nicht ganz verstehe: Gibt es einen Weg zu vermeiden dass man bei sowas (Variants) std::launder bzw. Probleme mit fehlendem std::launder vermeiden kann?

    Du hörst dich gerade an wie ein Anfänger an, der versucht, fundamentale Schwierigkeiten in einer Sprache mit Faustregeln wegzuerklären. Nein, du musst einfach verstehen, wie die Zeigersemantik funktioniert. Das musste man eigentlich aber schon immer; pointer provenance war stets ein Thema. Wir haben schon vor Jahren mit Krümelkacker darüber diskutiert, ob es erlaubt ist, durch ein multidimensionales Array flach zu iterieren. Mit den aktuellen Regeln ist es unmöglich. Meine Antwort hier hat von etwas ähnlichem gehandelt. Ich kann mir aber nur schwer vorstellen, dass jemand, der so erfahren ist wie du, auch nur annähernd Schwierigkeiten hat, das zu lernen.



  • Arcoth schrieb:

    hustbaer schrieb:

    Arcoth schrieb:

    &m_storage.bytes[0] ist ein Zeiger auf das erste Element eines char Arrays. Dieser Zeiger ist nicht mit dem Objekt, für welches dieses Array Speicher bereitstellt, pointer-interconvertible.

    OK. Allerdings verstehe ich nicht warum das ein Thema ist. Ich verwende das char Array nie als char Array, also gibt es nichts was die "pointer optimization barrier" Blocken müsste.

    Du verstehst das vielleicht falsch. Die Optimierung besteht doch darin, dass wir schauen, ob irgendwelche Verweise auf das Objekt bestehen. Du hast deinen Zeiger quasi aus dem Finger gezogen; das kann der Optimizer nicht nachvollziehen.

    Kann leicht sein dass ich das falsch verstehe. Denn jetzt, nach dieser Antwort, denke ich mir: Wenn der Optimizer das nicht nachvollziehen kann, dann kann er wohl auch nix optimieren, und dann sollte keine "pointer optimization barrier" notwendig sein.

    Arcoth schrieb:

    hustbaer schrieb:

    Arcoth schrieb:

    Ergo müssen wir launder n. Dabei ist zu beachten, dass das char Array genau sizeof T groß sein muss, sonst gibt es Bytes die durch den Zeiger auf das erste Element reachable sind, aber nicht durch den Zeiger auf T .

    Ist nicht der Fall, das char Array kann grösser sein - ist ja ein Variant, und die Typen die darin "leben" können sind nicht alle gleich gross.

    Das ist kein Problem: du musst dann halt deinen Zeiger anders beziehen. Beispielsweise als den Rückgabewert von placement new.

    Keine Ahnung wie du das aus dem zitierten Absatz rausliest. Da steht nichts über Arrays drinnen. Und was da über Bytes steht, kann ich nur so verstehen, dass über das Resultat nicht mehr Speicher erreichbar sein darf als über den ursprünglichen Zeiger.
    Wobei ich nicht weiss wie das gemeint ist, da beide den selben Typ haben. Und wie soll dann über den einen Zeiger mehr oder weniger Speicher erreichbar sein als über den anderen?

    Und... wohl ist das ein Problem! Weil Typen wie boost::variant vermutlich keine Lust haben das Ergebnis von placement new zu speichern. Völlig unnötiger Overhead, die Adresse und welches Objekt darin jetzt lebt (welcher Typ) sind ja bekannt.

    Arcoth schrieb:

    Aber das nur so nebenbei, ich will ja den T* Zeiger nirgends aufheben, ich weiss ja die Adresse des Objekts sowieso - weil ich das Storage Array kenne wo ich es reinkonstruiert habe.

    Der Punkt ist aber, dass du den Zeiger legitim herleiten musst, damit der Optimizer weiß, auf welches Objekt du zugreifst.

    Verstehe ich nicht. Entweder der Optimizer weiss es, dann kann er optimieren. Oder er weiss es nicht, dann kann er halt nicht optimieren.

    Vielleicht mal anders rum gefragt, vielleicht verstehe ich es dann: Welche Optimierungen sollen dadurch möglich werden, die es ohne diese Regel(n) nicht sind?

    Arcoth schrieb:

    Aber davon abgesehen dass ich das nicht ganz verstehe: Gibt es einen Weg zu vermeiden dass man bei sowas (Variants) std::launder bzw. Probleme mit fehlendem std::launder vermeiden kann?

    Du hörst dich gerade an wie ein Anfänger an, der versucht, fundamentale Schwierigkeiten in einer Sprache mit Faustregeln wegzuerklären. Nein, du musst einfach verstehen, wie die Zeigersemantik funktioniert. Das musste man eigentlich aber schon immer; pointer provenance war stets ein Thema. Wir haben schon vor Jahren mit Krümelkacker darüber diskutiert, ob es erlaubt ist, durch ein multidimensionales Array flach zu iterieren. Mit den aktuellen Regeln ist es unmöglich. Meine Antwort hier hat von etwas ähnlichem gehandelt. Ich kann mir aber nur schwer vorstellen, dass jemand, der so erfahren ist wie du, auch nur annähernd Schwierigkeiten hat, das zu lernen.

    Wenn die Regeln irgendwo in lesbarem Englisch und idealerweise mit ausreichend Beispielen + Erklärung zu den Beispielen dokumentiert wären, dann wahrscheinlich ja, dann könnte ich das wohl in relativ kurzer Zeit lernen. Was man aber vermutlich schnell vergisst wenn man anfängt sich in den Standard einzuarbeiten: das Ding (der Standard) ist für nicht-Experten quasi unlesbar. Ich bin Software-Entwickler und kein Standardwälzer.


  • Mod

    hustbaer schrieb:

    Wenn der Optimizer das nicht nachvollziehen kann, dann kann er wohl auch nix optimieren

    Er wird trotzdem optimieren. Es wird nur nicht in Einklang mit dem sein, was du erwartest. Weil du dich nicht an den Standard hältst.

    hustbaer schrieb:

    Keine Ahnung wie du das aus dem zitierten Absatz rausliest. Da steht nichts über Arrays drinnen. Und was da über Bytes steht, kann ich nur so verstehen, dass über das Resultat nicht mehr Speicher erreichbar sein darf als über den ursprünglichen Zeiger.

    Doch, da steht was über Arrays drinnen. Lies doch mal.

    <a href= schrieb:

    [ptr.launder]/3">A byte of storage is reachable through a pointer value that points to an object Y if it is within the storage occupied by Y , an object that is pointer-interconvertible with Y , or the immediately-enclosing array object if Y is an array element.

    Ein Zeiger auf ein Element von bytes ist demnach durch jeden anderen Zeiger auf ein Element von bytes reachable. Aber: Ein Zeiger auf ein Objekt in bytes kann nicht auf Bytes reachen, die außerhalb des von ihm belegten Speichers liegen. Ergo wäre so ein launder nicht möglich.

    Wobei ich nicht weiss wie das gemeint ist, da beide den selben Typ haben.

    Welche beiden?

    Und... wohl ist das ein Problem! Weil Typen wie boost::variant vermutlich keine Lust haben das Ergebnis von placement new zu speichern. Völlig unnötiger Overhead, die Adresse und welches Objekt darin jetzt lebt (welcher Typ) sind ja bekannt.

    Die Lösung dafür braucht auch keinen zusätzlichen Zeiger. Statt die Objekte selbst im char Array zu speichern, welche von unterschiedlicher Größe sein könnten, speichern wir einen Wrapper, den wir manuell so padden, dass er die Größe des größten aller Typen hat. Geht relativ elegant mit einer union , die ein entsprechendes char Array hat. Dann noch einen static_assert draufhauen, dass die union s alle auch wirklich gleich groß sind. Diese union s konstruieren wir dann im char Array; da sie groß genug sind, sind alle Bytes reachable. :p 👍

    Arcoth schrieb:

    Aber das nur so nebenbei, ich will ja den T* Zeiger nirgends aufheben, ich weiss ja die Adresse des Objekts sowieso - weil ich das Storage Array kenne wo ich es reinkonstruiert habe.

    Der Punkt ist aber, dass du den Zeiger legitim herleiten musst, damit der Optimizer weiß, auf welches Objekt du zugreifst.

    Verstehe ich nicht. Entweder der Optimizer weiss es, dann kann er optimieren. Oder er weiss es nicht, dann kann er halt nicht optimieren.

    Das ist doch der springende Punkt: Der Optimizer optimiert auch, wenn du Dinge tust, von denen er nicht weiß, wenn du sie auf eine Art und Weise abwickelst, die nicht den Regeln entsprechen! Deswegen gibt es doch solche Einschränkungen: damit der Optimizer Annahmen machen darf, die es erlauben, schnelleren Code zu produzieren. Aber wem sag ich das?!

    Vielleicht mal anders rum gefragt, vielleicht verstehe ich es dann: Welche Optimierungen sollen dadurch möglich werden, die es ohne diese Regel(n) nicht sind?

    Common subexpression elimination, dead store elimination, Aliasing Optimierungen, etc. In vielen Fällen ist es von entscheidender Bedeutung, ob ein gewisser Zeiger nun auf ein Objekt zeigt oder nicht. Ich kann auch gerne Beispiele zeigen (aber ohne Garantie, dass GCC da tatsächlich was reißt 😉 ).

    Wenn die Regeln irgendwo in lesbarem Englisch und idealerweise mit ausreichend Beispielen + Erklärung zu den Beispielen dokumentiert wären, dann wahrscheinlich ja, dann könnte ich das wohl in relativ kurzer Zeit lernen.

    Dann musst du nur kurz warten; ich bin sicher, jemand schreibt bald einen Artikel dazu. Oder vielleicht gibt es den schon.



  • Arcoth schrieb:

    hustbaer schrieb:

    Wenn der Optimizer das nicht nachvollziehen kann, dann kann er wohl auch nix optimieren

    Er wird trotzdem optimieren. Es wird nur nicht in Einklang mit dem sein, was du erwartest. Weil du dich nicht an den Standard hältst.

    Jain.
    Die Beispiele wo ein "alter" Zeiger wiederverwendet wird, verstehe ich.
    Wenn du aber schreibst ich müsse statt

    T* p = ...
    ...
    p = reinterpret_cast<T*>(&m_storage[0]);
    

    richtigerweise

    T* p = ...
    ...
    p = std::launder(reinterpret_cast<T*>(&m_storage[0]));
    // oder
    p = reinterpret_cast<T*>(std::launder(&m_storage[0])); // ???
    

    schreiben, dann verstehe ich es nicht.
    Denn der Compiler sieht hier die Zuweisung p = ..., und kann daher verstehen dass er nicht versteht welchen Wert p jetzt bekommt. Er kann doch nicht einfach annehmen dass alles was er nicht versteht bedeutet dass p noch auf das alte Objekt zeigt. Das wäre total beknackt!

    D.h. die Frage ist vielmehr: Darf der Compiler annehmen dass so ein reinterpret_cast<T*>(&m_storage[0]) Konstrukt, sofern die selbe Adresse dabei rauskommt, auch immer auf das selbe Objekt zeigt? (Also speziell nicht auf ein neues Objekts des selben Typs an der selben Adresse.)

    Arcoth schrieb:

    hustbaer schrieb:

    Keine Ahnung wie du das aus dem zitierten Absatz rausliest. Da steht nichts über Arrays drinnen. Und was da über Bytes steht, kann ich nur so verstehen, dass über das Resultat nicht mehr Speicher erreichbar sein darf als über den ursprünglichen Zeiger.

    Doch, da steht was über Arrays drinnen. Lies doch mal.

    OK, hab' ich übersehen.

    Arcoth schrieb:

    Wobei ich nicht weiss wie das gemeint ist, da beide den selben Typ haben.

    Welche beiden?

    Na Parameter und Returnwert von std::launder.

    Arcoth schrieb:

    Und... wohl ist das ein Problem! Weil Typen wie boost::variant vermutlich keine Lust haben das Ergebnis von placement new zu speichern. Völlig unnötiger Overhead, die Adresse und welches Objekt darin jetzt lebt (welcher Typ) sind ja bekannt.

    Die Lösung dafür braucht auch keinen zusätzlichen Zeiger. Statt die Objekte selbst im char Array zu speichern, welche von unterschiedlicher Größe sein könnten, speichern wir einen Wrapper, den wir manuell so padden, dass er die Größe des größten aller Typen hat. Geht relativ elegant mit einer union , die ein entsprechendes char Array hat. Dann noch einen static_assert draufhauen, dass die union s alle auch wirklich gleich groß sind. Diese union s konstruieren wir dann im char Array; da sie groß genug sind, sind alle Bytes reachable. :p 👍

    Ein Ruleset dass das von mir verlangt, nur um irgendwelchen komischen Regeln gerecht zu werden, würde ich als kompletten Mist bezeichnen.

    Aber nochmal: ich kann das aus dem Text da nicht rauslesen. Da steht bloss dass über das Resultat von std::launder nicht mehr erreichbar sein darf als über den Parameter. Der umgekehrte Fall, also dass über den Parameter mehr erreichbar ist, scheint mir durchaus erlaubt zu sein. Desweiteren sind aber Parameter und Returnwert beide vom Typ T*, d.h. über beide ist sowieso exakt die selbe Menge an Bytes erreichbar. Ich vermute fast dass hier einfach der Text schlecht formuliert ist.

    Der Optimizer optimiert auch, wenn du Dinge tust, von denen er nicht weiß, wenn du sie auf eine Art und Weise abwickelst, die nicht den Regeln entsprechen! Deswegen gibt es doch solche Einschränkungen: damit der Optimizer Annahmen machen darf, die es erlauben, schnelleren Code zu produzieren. Aber wem sag ich das?!

    Siehe oben: für den Fall ganz ohne Zuweisung, wo quasi der alte Zeiger weiterverwendet wird, kann ich das verstehen. Für den Fall wo die Adresse jedes mal neu "aus der Luft gezogen wird", nicht ganz. Dann kann er solche Konstrukte halt nicht optimieren. Oder besser: nur wie bisher, d.h. wenn er "sehen" kann dass es so oder so keinen Unterschied machen kann.

    Common subexpression elimination, dead store elimination, Aliasing Optimierungen, etc. In vielen Fällen ist es von entscheidender Bedeutung, ob ein gewisser Zeiger nun auf ein Objekt zeigt oder nicht. Ich kann auch gerne Beispiele zeigen (aber ohne Garantie, dass GCC da tatsächlich was reißt 😉 ).

    Was genau ist dead store elimination? Und was für Aliasing Optimierungen meinst du?

    Desweiteren... das sind doch alles Optimierungen die der Compiler sowieso schon machen kann. "As if" halt. Sicher, wenn jetzt jemand die Adresse eines Objekts an eine "undurchsichtige" Funktion übergibt, dann muss der Compiler damit rechnen dass das Objekt von der Funktion modifiziert wurde - und danach Werte neu aus dem Speicher laden, auch wenn er das davor schon getan hat. Muss er aber dann auch noch, nur halt mit Ausnahme der const und Reference-Member. Und da const und Reference-Member eigentlich eher selten sind...

    Ich bin mir nicht sicher ob die Optimierungen die damit legal werden dafür stehen dass wieder zigtausende von C++ Programmierern mehr falschen Code schreiben, weil sie die Regeln nicht verstanden haben (bzw. vermutlich öfter: von den Regeln noch nichtmal was gehört haben).

    Arcoth schrieb:

    Wenn die Regeln irgendwo in lesbarem Englisch und idealerweise mit ausreichend Beispielen + Erklärung zu den Beispielen dokumentiert wären, dann wahrscheinlich ja, dann könnte ich das wohl in relativ kurzer Zeit lernen.

    Dann musst du nur kurz warten; ich bin sicher, jemand schreibt bald einen Artikel dazu. Oder vielleicht gibt es den schon.

    Also ich hab vor ein paar Monaten das letzte mal ausgiebiger gegoogelt, und da hab' ich diesbezüglich nichts gefunden.



  • p.s.:
    Wenn ich das richtig verstehe würde das auch die meisten Container betreffen. D.h. z.B. dieser Code wäre dann falsch:

    vector<std::string> v;
    v.reserve(2);
    v.push_back("foo");
    std::string* p = &v[0];
    v.push_back("bar");
    std::cout << p[1];
    v.pop_back();
    v.push_back("baz");
    std::cout << p[1];
    

    ?



  • Der war schon immer falsch, da Iteratoren beim Hinzufügen und Entfernen nicht mehr gültig sind.
    Ach, da ist ja noch ein reserve 🙄



  • v.reserve(2);

    Und wo siehst du da Iteratoren?


  • Mod

    Techel schrieb:

    Der war schon immer falsch, da Iteratoren beim Hinzufügen und Entfernen nicht mehr gültig sind.

    Bloedsinn.

    hustbaer schrieb:

    p.s.:
    Wenn ich das richtig verstehe würde das auch die meisten Container betreffen. D.h. z.B. dieser Code wäre dann falsch:

    vector<std::string> v;
    v.reserve(2);
    v.push_back("foo");
    std::string* p = &v[0];
    v.push_back("bar");
    std::cout << p[1];
    v.pop_back();
    v.push_back("baz");
    std::cout << p[1];
    

    ?

    Wenn wir von Containern sprechen, gibt es ein ganz anderes Problem bzgl. P0137. Container allozieren rohen Speicher und packen dort ihre Objekte mittels placement new rein. Das bedeutet aber, dass Zeigerarithmetik nicht funktioniert, da bspw. p+1 ein pointer past-the-end (des ersten Objekts) ist. In anderen Worten, Zeigerarithmetik funktioniert nur ueber Arrays, und das gibt es hier nicht.

    Auf den anderen Beitrag werde ich heute Nacht antworten muessen.


  • Mod

    hustbaer schrieb:

    Denn der Compiler sieht hier die Zuweisung p = ..., und kann daher verstehen dass er nicht versteht welchen Wert p jetzt bekommt. Er kann doch nicht einfach annehmen dass alles was er nicht versteht bedeutet dass p noch auf das alte Objekt zeigt. Das wäre total beknackt!

    Ich verstehe nicht, auf welches Beispiel du dich beziehst. Zeig mal eins.

    D.h. die Frage ist vielmehr: Darf der Compiler annehmen dass so ein reinterpret_cast<T*>(&m_storage[0]) Konstrukt, sofern die selbe Adresse dabei rauskommt, auch immer auf das selbe Objekt zeigt?

    Der Compiler weiß, dass reinterpret_cast<T*>(&m_storage[0]) auf das erste Element von m_storage zeigt. Mehr nicht.

    Aber nochmal: ich kann das aus dem Text da nicht rauslesen. Da steht bloss dass über das Resultat von std::launder nicht mehr erreichbar sein darf als über den Parameter. Der umgekehrte Fall, also dass über den Parameter mehr erreichbar ist, scheint mir durchaus erlaubt zu sein.

    Oh, stimmt sogar, da habe ich mich verlesen. Ich hab mich schon gefragt, was diese Regel sollte, das macht nämlich andersherum keinen Sinn.

    Desweiteren sind aber Parameter und Returnwert beide vom Typ T*, d.h. über beide ist sowieso exakt die selbe Menge an Bytes erreichbar. Ich vermute fast dass hier einfach der Text schlecht formuliert ist.

    Um die Typen geht es gar nicht, sondern um die Pointees der Zeiger. Der Pointee hat mit dem Typen nichts mehr zu tun.


  • Mod

    hustbaer schrieb:

    Arcoth schrieb:

    hustbaer schrieb:

    Wenn der Optimizer das nicht nachvollziehen kann, dann kann er wohl auch nix optimieren

    Er wird trotzdem optimieren. Es wird nur nicht in Einklang mit dem sein, was du erwartest. Weil du dich nicht an den Standard hältst.

    Jain.
    Die Beispiele wo ein "alter" Zeiger wiederverwendet wird, verstehe ich.
    Wenn du aber schreibst ich müsse statt

    T* p = ...
    ...
    p = reinterpret_cast<T*>(&m_storage[0]);
    

    richtigerweise

    T* p = ...
    ...
    p = std::launder(reinterpret_cast<T*>(&m_storage[0]));
    // oder
    p = reinterpret_cast<T*>(std::launder(&m_storage[0])); // ???
    

    schreiben, dann verstehe ich es nicht.
    Denn der Compiler sieht hier die Zuweisung p = ..., und kann daher verstehen dass er nicht versteht welchen Wert p jetzt bekommt. Er kann doch nicht einfach annehmen dass alles was er nicht versteht bedeutet dass p noch auf das alte Objekt zeigt. Das wäre total beknackt!

    Du denkst hier viel zu pragmatisch. Die Regel besagt, dass ein Zeiger auf ein Objekt zeigt, bist du ihn launderst, oder bis ein neues Objekt gleichen Typs darin konstruiert wird, etc. hier hast du einfach einen pointer to char zu einem pointer to T gecastest; das aendert den Pointee nicht. So sind die Regeln. Die Regeln sind vielleicht nicht perfekt, aber wir muessen sowieso einen Kompromiss zwischen Simplizitaet und optimaler Semantik treffen; sobald wir anfangen, Sonderfaelle fuer "offensichtliche" Zuweisungen einzufuehren, wird dieses ganze Thema noch viel schwerer zu lehren als jetzt schon.

    Was die Optimierungen angeht: dead store elimination handelt davon, stores zu eliminieren, die die Semantik einer wohldefinierten Ausfuehrung des Programs nicht aendern wuerden. Bspw.

    void f(char*);
    
    int main() {
      char arr[][2] {1, 2, 3, 4, 5, 6};
      f(arr);
    }
    

    arr hat keine linkage, also kann f nicht direkt auf andere Unterarrays von arr zugreifen; das folgt aus der Definition von launder und pointer-interconvertibility. Also duerfen wir, falls arr nirgends sonst verwendet (oder ueberschrieben) wird, die stores auf arr[1] und arr[2] eliminieren.

    Das obige Beispiel mag ein wenig gekuenstelt erscheinen, aber in echten Funktionen gibt es branches etc. in denen solche Szenarien durchaus denkbar sind.

    Ein anderes Beispiel, dass sich auf die erwaehnte Optimierung von XL stuetzt:

    struct Foo {
        int a;
        int b;
        virtual void f();
    };
    
    Foo foo{1, 2};
    if (someBoolean)
        g(&foo.a);
    

    Der Optimizer sieht, dass g nicht auf andere Member von Foo zugreifen kann (weder mittels launder noch durch einen cast). Das heisst, dass die Initialisierung von foo.b hinter die Branch verschoben werden kann, und das ermoeglicht uns, diesen Store als branch delay slot zu verwenden. Das mag zwar nur in Prozessoren mit MIPS pipelines relevant sein, aber die allgemeine Idee von reordering ist durchaus relevant, e.g. wenn wir in der Lage sind, Zugriffe auf dieselbe cache line naeher zu bringen um Lokalitaet zu erhoehen.

    Also ich hab vor ein paar Monaten das letzte mal ausgiebiger gegoogelt, und da hab' ich diesbezüglich nichts gefunden.

    Ich kann den Autor im IRC nach ein paar grundlegenden Ideen fragen, und dann koennte ich das wohl zusammenfassen.



  • Arcoth schrieb:

    Du denkst hier viel zu pragmatisch.

    Das kann sein 😉

    Arcoth schrieb:

    Die Regel besagt, dass ein Zeiger auf ein Objekt zeigt, bist du ihn launderst, oder bis ein neues Objekt gleichen Typs darin konstruiert wird, etc. hier hast du einfach einen pointer to char zu einem pointer to T gecastest; das aendert den Pointee nicht. So sind die Regeln. Die Regeln sind vielleicht nicht perfekt, aber wir muessen sowieso einen Kompromiss zwischen Simplizitaet und optimaler Semantik treffen; sobald wir anfangen, Sonderfaelle fuer "offensichtliche" Zuweisungen einzufuehren, wird dieses ganze Thema noch viel schwerer zu lehren als jetzt schon.

    Ja, Sonderregeln sind schlecht. Wenn dann müsste die Sache mit anders formulierten, generischen Regeln erschlagen werden. Keine Ahnung ob das möglich ist. Bzw. für diesen Fall überhaupt sinnvoll.

    Man könnte die Idee aber auch verwerfen 😃 und statt dessen versuchen was anderes nettes zu machen - wie z.B. ne C++-taugliche Formulierung von "restrict".

    Arcoth schrieb:

    Was die Optimierungen angeht: dead store elimination handelt davon, stores zu eliminieren, die die Semantik einer wohldefinierten Ausfuehrung des Programs nicht aendern wuerden. Bspw.

    void f(char*);
    
    int main() {
      char arr[][2] {1, 2, 3, 4, 5, 6};
      f(arr);
    }
    

    arr hat keine linkage, also kann f nicht direkt auf andere Unterarrays von arr zugreifen; das folgt aus der Definition von launder und pointer-interconvertibility. Also duerfen wir, falls arr nirgends sonst verwendet (oder ueberschrieben) wird, die stores auf arr[1] und arr[2] eliminieren.

    OK, verstehe ich soweit. Gerade dieses Beispiel halte ich aber für recht problematisch. Ich würde schätzen dass das enorm viel Code bricht. Arrays-of-arrays sind in bestimmten Bereichen oft zu finden, und genau so oft findet man da vermutlich Code der dann mit Funktionen draufkloppt die nur "einfache" Arrays unterstützen. Ich denke da z.B. an Matrizen. Wobei mir dann gleich noch SIMD Libraries einfallen. Die sind zwar sowieso ein Problem wegen strict aliasing, aber selbst wenn das gelöst wäre (ala __attribute__((__may_alias__)) ) bliebe auch da das selbe Problem. Also dass man dann nur "einfache" Arrays verwenden könnte, wenn man vor hat mit einer SIMD Library draufzuklopfen.

    Arcoth schrieb:

    Das obige Beispiel mag ein wenig gekuenstelt erscheinen, aber in echten Funktionen gibt es branches etc. in denen solche Szenarien durchaus denkbar sind.

    Ja, schon klar. Allerdings hat man in echten Programmen auch oft LTCG, und wenn "f" in einem "LTCG-kompatiblen" .a/.o definiert ist, dann hätte der Compiler alles was er braucht um die Optimierung trotzdem zu machen -- selbst wenn er sich entscheidet "f" an der Stelle nicht zu inlinen 😉

    Arcoth schrieb:

    Ein anderes Beispiel, dass sich auf die erwaehnte Optimierung von XL stuetzt:

    struct Foo {
        int a;
        int b;
        virtual void f();
    };
    
    Foo foo{1, 2};
    if (someBoolean)
        g(&foo.a);
    

    Der Optimizer sieht, dass g nicht auf andere Member von Foo zugreifen kann (weder mittels launder noch durch einen cast). Das heisst, dass die Initialisierung von foo.b hinter die Branch verschoben werden kann, und das ermoeglicht uns, diesen Store als branch delay slot zu verwenden. Das mag zwar nur in Prozessoren mit MIPS pipelines relevant sein, aber die allgemeine Idee von reordering ist durchaus relevant, e.g. wenn wir in der Lage sind, Zugriffe auf dieselbe cache line naeher zu bringen um Lokalitaet zu erhoehen.

    Bzw. u.U. könnte er die Initialisierung von foo.b gleich ganz weglassen und nichtmal Storage dafür reservieren -- wenn da sonst nix mehr ist als der Code in deinem Beispiel sogar sicherlich.
    Auch das kann er aber sowieso, sobald er sich an der Stelle wo er den "g" Aufruf compilieren muss die Definition von "g" angucken kann.

    Und auch das halte ich für halbwegs problematisch.

    Wobei mir jetzt ein paar weitere Fragen zu dem Thema einfallen:
    * Was ist mit memcpy ? Soweit ich weiss darf man mit memcpy aktuell auch arrays-von-arrays kopieren. Wie sähe das dann mit der aktuellen Formulierung dieser "pointer-interconvertibility" Regeln aus?
    * Was ist mit handgestricktem memcpy ? Also nem Loop der ( unsigned ) char Pointer verwendet um Speicherblöcke zu kopieren.
    * Und wenn man die Sache für ( unsigned ) char erlaubt (so wie bei der Aliasing-Ausnahme für ( unsigned ) char ), dann erzeugt das gleich die nächste Falle in die man reintappen kann: mit ( unsigned ) char ) wäre die Funktion OK, mit int nicht mehr -- selbst wenn ein array-of-arrays-of- int übergeben wird.
    * Was ist mit Sachen wie

    struct RefCountedWString
    {
    	size_t length;
    	size_t refs;
    	wchar_t data[1];
    };
    
    wchar_t* Rcws_Create(wchar_t* sz)
    {
    	size_t const length = wcslen(sz);
    	RefCountedWString* const rcws = static_cast<RefCountedWString*>(malloc(sizeof(RefCountedWString) + sizeof(wchar_t) * length));
    	if (!rcws)
    		return 0;
    	rcws->length = length;
    	rcws->refs = 1;
    	memcpy(&rcws->data[0], sz, sizeof(wchar_t) * (length + 1));
    	return &rcws->data[0];
    }
    
    void Rcws_AddRef(wchar_t* s)
    {
    	if (s)
    	{
    		RefCountedWString* const rcws = reinterpret_cast<RefCountedWString*>(reinterpret_cast<char*>(s) - (sizeof(size_t) * 2));
    		rcws->refs++;
    	}
    }
    
    void Rcws_Release(wchar_t* s)
    {
    	if (s)
    	{
    		RefCountedWString* const rcws = reinterpret_cast<RefCountedWString*>(reinterpret_cast<char*>(s) - (sizeof(size_t) * 2));
    		rcws->refs--;
    		if (!rcws->refs)
    			free(rcws);
    	}
    }
    

    Soweit ich weiss ist das nach aktuellem Standard legal. Wäre dann ja wohl auch verboten, oder? Und dass es viel solchen und ähnlichen Code gibt, da bin ich mir halbwegs sicher.


  • Mod

    Arrays-of-arrays sind in bestimmten Bereichen oft zu finden, und genau so oft findet man da vermutlich Code der dann mit Funktionen draufkloppt die nur "einfache" Arrays unterstützen.

    Das ist an sich kein Problem, da multidimensionale Arrays sowieso nur Syntaxzucker sind; wir können sie einfach durch ein Template simulieren.

    hustbaer schrieb:

    Wobei mir jetzt ein paar weitere Fragen zu dem Thema einfallen:
    * Was ist mit memcpy ? Soweit ich weiss darf man mit memcpy aktuell auch arrays-von-arrays kopieren. Wie sähe das dann mit der aktuellen Formulierung dieser "pointer-interconvertibility" Regeln aus?

    Warum sollte das jetzt anders sein als vorher? memcpy wurde schon jeher über [basic.types]/3 unterstützt. Und im Übrigen wird die Implementierung von memcpy nicht vorgegeben, also muss man sich über die Semantik von Zeigerarithmetik keine Gedanken machen.

    * Was ist mit handgestricktem memcpy ? Also nem Loop der ( unsigned ) char Pointer verwendet um Speicherblöcke zu kopieren.

    Das ist laut aktuellem wording nicht möglich, da wir nicht über ein Array iterieren. Wobei das wohl in Ordnung ist, es gibt wahrscheinlich zu viel Code, der so etwas tut.

    * Und wenn man die Sache für ( unsigned ) char erlaubt (so wie bei der Aliasing-Ausnahme für ( unsigned ) char ), dann erzeugt das gleich die nächste Falle in die man reintappen kann: mit ( unsigned ) char ) wäre die Funktion OK, mit int nicht mehr -- selbst wenn ein array-of-arrays-of- int übergeben wird.

    Stimmt.

    * Was ist mit Sachen wie

    // [..]
    RefCountedWString* const rcws = static_cast<RefCountedWString*>(malloc(sizeof(RefCountedWString) + sizeof(wchar_t) * length));
    if (!rcws)
      return 0;
    rcws->length = length;
    

    Das war schon immer illegal. malloc erzeugt nämlich keine Objekte. Wird auch als drafting note in P0137 erwähnt:

    Drafting note: this maintains the status quo that malloc alone is not sufficient to create an object.

    Aber selbst wenn wir das mal übersehen, und davon ausgehen dass jemand dort ein placement new reinhaut, sticht immer noch

    RefCountedWString* const rcws = reinterpret_cast<RefCountedWString*>(reinterpret_cast<char*>(s) - (sizeof(size_t) * 2));
    

    ins Auge. Das wird definitiv nicht mehr wohldefiniert sein.


  • Mod

    Okay, ich habe mich gerade noch einmal mit dem Autor unterhalten.

    IRC/Richard Smith schrieb:

    suppose we have:

    struct X { virtual void f(); }; 
    struct Y : X { void f(); }; 
    struct Z : X { void f(); };
    alignas(Y, Z) char buf[max(sizeof(Y), sizeof(Z))];
    X *p = new (buf) Y;
    

    and now we do this:

    p->f(); p->f();
    

    [22:30:54] <zygoloid> maybe we're calling the virtual function in a loop or similar
    [22:30:58] <Arcoth> You're saying f does something
    [22:31:00] <Arcoth> funny
    [22:31:03] <zygoloid> well
    [22:31:04] <Arcoth> like replacing the objects
    [22:31:15] <zygoloid> it turns out to be important for performance that we don't load the vptr on each iteration
    [22:31:30] <zygoloid> and maybe we can even prove that p points to a Y object and devirtualize
    [22:31:38] <zygoloid> but yeah, what happens if f does something funny
    [22:32:13] <Arcoth> Good point.
    [22:32:23] <zygoloid> the language says that can't happen: if the pointer value in p points to a Y object, that same value is never going to point to a Z
    [22:32:48] <zygoloid> we have rules that say that the pointer transparently updates to point to new Y objects you create in the same storage
    [22:32:54] <zygoloid> but it does not transparently update to point to a Z object
    [22:33:01] <zygoloid> so the devirtualization is permitted
    [22:33:13] <zygoloid> you need launder if f does in fact do something funny

    Was memcpy angeht: Es war nie beabsichtigt, Nutzer eigene Varianten davon definieren zu lassen (die Fußnoten in [basic.types], die memcpy lediglich als Beispiel betiteln, sind insofern irreführend).



  • Arcoth schrieb:

    * Was ist mit Sachen wie

    // [..]
    RefCountedWString* const rcws = static_cast<RefCountedWString*>(malloc(sizeof(RefCountedWString) + sizeof(wchar_t) * length));
    if (!rcws)
      return 0;
    rcws->length = length;
    

    Das war schon immer illegal. malloc erzeugt nämlich keine Objekte. Wird auch als drafting note in P0137 erwähnt:

    Drafting note: this maintains the status quo that malloc alone is not sufficient to create an object.

    Also das letzte mal als ich mich in die Untiefen des Standards (C++98) vorgekämpft habe, hatte ich entschieden den Eindruck dass PODs sich quasi selbst erzeugen, indem man sie einfach in den Speicher schreibt. Was mMn. auch "Memberweise" erlaubt sein müsste, und auf jeden Fall mit memcpy.

    Ist aber im Endeffekt egal. Man kann das ganze auch ganz ohne struct schreiben, dann muss man bloss etwas mehr Zeigerarithmetik mit Hand machen.

    Arcoth schrieb:

    Aber selbst wenn wir das mal übersehen, und davon ausgehen dass jemand dort ein placement new reinhaut, sticht immer noch

    RefCountedWString* const rcws = reinterpret_cast<RefCountedWString*>(reinterpret_cast<char*>(s) - (sizeof(size_t) * 2));
    

    ins Auge. Das wird definitiv nicht mehr wohldefiniert sein.

    Tja, doof. Gibt nämlich wie gesagt definitiv Code der genau das macht.

    Arcoth schrieb:

    Arrays-of-arrays sind in bestimmten Bereichen oft zu finden, und genau so oft findet man da vermutlich Code der dann mit Funktionen draufkloppt die nur "einfache" Arrays unterstützen.

    Das ist an sich kein Problem, da multidimensionale Arrays sowieso nur Syntaxzucker sind; wir können sie einfach durch ein Template simulieren.

    Es geht nicht darum was man mit welchen Mitteln wie umschreiben könnte, damit es den neuen Regeln entspricht. Es geht um die Menge an bestehendem Code der dadurch ungültig wird. Und darum dass das vermutlich alles "stille" Fehler sind, also welche wo der Compiler nix meldet, bzw. nur wenn man Glück hat und er sieht dass man 'was falsch macht.
    Das ist schlimm, das kann man nicht einfach ignorieren.

    Arcoth schrieb:

    Was memcpy angeht: Es war nie beabsichtigt, Nutzer eigene Varianten davon definieren zu lassen (die Fußnoten in [basic.types], die memcpy lediglich als Beispiel betiteln, sind insofern irreführend).

    Ich müsste mir die entsprechenden Stellen nochmal raussuchen, aber was ich mich erinnere ist es im Standard ziemlich eindeutig erlaubt eingebaute Typen sowie PODs zu erzeugen indem man sie Byteweise (per char/unsigned char) schreibt.
    Zumindest ist es eindeutig erlaubt beliebige Speicherbereiche, die beliebige Objekte enthalten dürfen, per char*/unsigned char* zu überschreiben. Und durch die strict-Aliasing Ausnahme für char/unsigned char ist es, nachdem man vollständig mit char/unsigned drübergeschrieben hat, mMn. auch erlaubt den überschriebenen Speicherbereich in einen POD "umzuwidmen" indem man ihn einfach als POD liest.

    Zumindest hab' ich das damals so verstanden.

    Ich bin mir da also nicht so sicher ob das "nie beabsichtigt" war. Wie kommst du zu der Auffassung? Gibt es dementsprechende Statements von Stroustrup oder anderen die am 1. C++ Standard mitgewirkt haben?

    ps: Der VTable Load ist AFAIK nicht wirklich das grosse Problem. Das Problem ist dass hier 3 jeweils vom Vorgänger abhängige Loads in Folge passieren. 1) VTable 2) Function-Pointer 3) Function-Code.
    Wenn du (1) durch einen Vergleich ersetzt ob der VTable eh immer noch der selbe ist, sollte das ganze um einiges schneller werden.
    Also quasi

    for (...)
    {
       if (obj->vtable == vt)       // cmp reg, [reg] + jne else
           funptr();                // call reg
       else
       {
          vt = obj->vtable;
          funptr = vt->someSlot;
       }
    }
    

    Natürlich wäre es besser es ganz wegzuoptimieren, aber der Unterschied zwischen dem was mit diesen neuen Regeln möglich wäre, und dem was so schon geht, ist glaube ich nicht so besonders gross.


Log in to reply