Member von Strukturen per SFINAE löschen



  • @Finnegan sagte in Member von Strukturen per SFINAE löschen:

    Da in C++ Vererbung auch ohne Polymorphie möglich ist, und eine virtuelle Methode einen minimalen potentiellen Overhead hat (VTable-Lookup), ist meine Philosophie eher die Klasse nur dann polymorph zu machen, wenn ich auch wirklich dynamische Bindung haben will (also sowas wie base->derived_member_function()) oder ich ein Objekt einer abgeleiteten Klasse über die Basisklasse löschen will (Base* b = new Derived; delete b;).

    Das birgt aber enorme Gefahren, dass das jemand missbraucht bzw. es gar nicht merkt, dass er es missbraucht, und damit nicht zu Recht kommt. C++ ist auch so schon kompliziert genug, so dass man hier doch eher zur Komposition ggf. durch Templates zurückgreifen sollte. Dann ist klarer was man eigentlich will.

    Und auch wenn Data polymorph ist, dann muss das nicht zwangsläufig auch jede Klasse sein, von der Data erbt. Man kann die Polymorphie durchaus auch erst tiefer in der Klassenhiercharchie einführen.

    Da öffnest Du aber wirklich Tür und Tor dafür, dass jemand das nicht korrekt nutzt.

    Es ist aber denke ich auch kein allzu grosses Problem, die Basisklasse hier polymorph zu machen. Der Compiler wird das wahrscheinlich ohnehin de-virtualisieren, womit dann auch der kleine Overhead verschwände. Ich versuche aber immer, selbst vernachlässigbare Overheads zu vermeiden, wenn sie nicht unbedingt notwendig sind.

    Man sollte lieber keine Mikrooptimierungen nutzen, wenn dadurch das Design klarer wird.



  • Um mal auf das eigentliche Problem zurückzukommen:
    Ich persönlich nutze meist eher einen leicht anderen Ansatz. Dieses Konstrukt, mit einer bestimmten Basisklasse empfinde ich als unpraktisch und kommuniziert mir persönlich meist das Falsche. Entwickler, die eher aus anderen Sprachen wie Java kommen, schauen sich meiner Erfahrung nach die Klasse an und verwerten alles, was sie meinen zu verstehen. Leider ist eine simple Vererbung einfach genug zu verstehen, sodass dann die Konzepte aus anderen Sprachen wiederverwertet werden, was dann letztendlich zu der immer wieder kommenden Diskussion bzgl. virtual DTor usw. führt.

    template <typename DataPolicy>
    struct BasicData
        : public DataPolicy
    {
        unsigned int Id{};
    
        template <typename... Args>
        BasicData(int id, Args&&... args)
            : DataPolicy{std::forward<Args>(args)...}
        {
    
        }
    };
    

    Statt mich auf eine explizite Basisklasse (mit festen Eigenschaften) festzulegen, nutze ich hier eine template Klasse. Das verschiebt zum einen die wichtigen Details in die Policy und zum einen kann diese einfach (Situationsabhängig) ausgetauscht werden.
    Eine passende Policy sähe dann z.B. folgendermaßen aus:

    template <typename T>
    struct ContextData
    {
    public:
        const T& Context() const noexcept
        {
            return m_Context;
        }
    
    protected:
        ~ContextData() = default;
    
        ContextData(T ctx)
            : m_Context{std::move(ctx)}
        {
        }
    
        ContextData(const ContextData&) = default;
        ContextData& operator =(const ContextData&) = default;
        ContextData(ContextData&&) = default;
        ContextData& operator =(ContextData&&) = default;
    
    private:
        T m_Context{};
    };
    
    template <>
    struct ContextData<void>
    {
    protected:
        ~ContextData() = default;
        ContextData() = default;
    
        ContextData(const ContextData&) = default;
        ContextData& operator =(const ContextData&) = default;
        ContextData(ContextData&&) = default;
        ContextData& operator =(ContextData&&) = default;
    };
    

    Man kann sich hier vieles zu Nutze machen; daher sieht das eigentlich ziemlich ähnlich zu deiner Lösung aus. Der wesentliche Punkt ist aber, dass man von außen eine andere Basisklasse injecten kann und damit als Nutzer die Eigenschaften von BasicData steuert, ohne selbst explizit von Data erben zu müssen. Wie man sieht, habe ich hier auch noch die Eigenschaften eingefügt, dass ein ContextData nur über die erbende Klasse zerstört werden kann (protected DTor). Das lässt sich beliebig granular steuern, erhöht natürlich etwas die Komplexität der Lösung. Aber wenn man einen Member nur unter speziellen Umständen enablen möchte, dann ist man imo in der Komplexität schon eh ein wenig weiter vorgedrungen; dann sollte man es auch richtig machen und dazu gehören dann auch die Gedanken bzgl. Copy, Move, DTor dazu (wurde ja bereits oben besprochen).

    Möchte man nun echte Polymorphie enablen, dann reicht es eine PolymorphicContextData Policy zu basteln und diese entsprechend zu verwenden. Damit macht man nichts kaputt, dass bereits den die vorhandene BasicData<ContextData> Kombo benutzt, kann sich aber meist dennoch das vorhandene (templatisierte) Ökosystem zunutze machen.

    Das Ganze lässt sich im Endeffekt auch für beliebig viele DataPolicies erweitern (Stichwort variadic templates), erhöht dann aber auch wieder etwas die Komplexität, da möglicher Weise auch Sonderfälle betrachtet werden müssen.

    Nicht falsch verstehen, dieser Ansatz ist nicht komplizierter um des kompliziert seins Willen, sondern bietet mir persönlich meist eine beruhigende Flexibilität, sodass ich für Änderungen gewappnet bin. Der zweite Vorteil ist für mich, dass es exakt so viel Unverständnis erzeugt, sodass ihn Kollegen nicht missbrauchen, sondern als Implementierungsdetail akzeptieren.

    Hier mal ein kleines Beispiel: https://godbolt.org/



  • @john-0 sagte in Member von Strukturen per SFINAE löschen:

    Da öffnest Du aber wirklich Tür und Tor dafür, dass jemand das nicht korrekt nutzt.

    Wenn das eine öffentliche Klasse ist, die für den Anwender frei zugänglich ist, dann ja. ContextData<B, T> sieht mir aber eher nach einer internen Klasse aus, die nicht Teil des Public Interface ist und die man wahrscheinlich auch in einem anonymen Namespace unterbringen würde.

    Ich würde auf Anhieb nicht vermuten, dass im Code außerhalb von Data-Memberfunktionen irgendwo mit einem ContextData<B, T> hantiert wird oder dass ein Anwender überhaupt weiß, dass eine solche Klasse existiert. In dem Fall hielte sich das Missbrauchspotential doch arg in Grenzen.

    Oder anders: Sollte man wirklich jedes simple POD-struct (als dass ich ContextData<B, T> hier erachte) mit einem virtuellen Destruktor versehen, nur weil damit mal jemand komische Sachen machen könnte? Konzepte wie "POD" und "Standard Layout" wären damit nämlich hinfällig.

    Auch ein Großteil der Standardbibliothek-Klassen ist nicht polymorph, obwohl damit wohl jeder Programmierer in Berührung kommt und Blödsinn damit machen könnte. Dennoch scheinen wir nur selten darüber zu diskutieren, dass es gefährlich sein könnte, wenn jemand von std::string ableitet und dann versucht, ein Objekt dieses Typs über einen std::basic_string* zu löschen.

    Man sollte lieber keine Mikrooptimierungen nutzen, wenn dadurch das Design klarer wird.

    Man kann sich sicher darüber streiten, ob das Design wirklich "klarer" wird, wenn eine Klasse, bei der Polymorphie nicht vorgesehen ist, einem durch den virtuellen Destruktor eben das genaue Gegenteil sagt.

    Ich habe auch oben argumentiert, dass ich das eben nich* als eine Mikrooptimierung erachte, sondern eher als ein natürliches Code-Design für Klassen, bei denen eben nicht vorgesehen ist, dass man sie im Programm als "Handle" in die tiefere Objekthierarchie verwendet (sprich als Referenz- oder Pointer-Typ für davon abgeleitete dynamische Typen). Damit das nicht versehentlich passiert kann man gerne zusätzliche Maßnahmen treffen, wenn der klobige Name (ContextData<B, T>) nicht schon ausreichend sein sollte: Anonymer Namespace, Klasse als privater Member, auch Deklaration in Übersetzungseinheit, etc.



  • @DocShoe sagte in Member von Strukturen per SFINAE löschen:

    Hat da jemand ne Idee?

    Wie wäre es mit diesem Lösungsansatz?

    #include <type_traits>
    #include <iostream>
    
    template<typename T>
    struct DataVoid {
        int Id = 0;
    };
    
    template<typename T>
    struct DataT {
        int Id = 0;
        T t = 1;
    };
    
    template<typename T, template<typename> typename T1 = DataVoid, template<typename> typename T2 = DataT>
    struct Data {
        using DT = std::conditional<std::is_same_v<T, void>, T1<T>, T2<T>>::type;
        DT data;
    };
    
    
    int main() {
        Data<int> d1;  // OK
        Data<void> d2; // 
    
        std::cout << "d1: " << d1.data.Id << ", " << d1.data.t << "\n";
        std::cout << "d2: " << d2.data.Id << "\n";
    }
    


  • @john-0 sagte in Member von Strukturen per SFINAE löschen:

    Wie wäre es mit diesem Lösungsansatz?

    Das umgeht die offenbar unbeliebte Vererbung und verschwendet auch kein Byte, falls es keinen Context gibt, macht die Datenstruktur aber auch etwas klobig in der Anwendung (data.data.Id). Ich persönlich wäre kein Fan davon, aber mit Gettern ließe sich das immerhin verstecken (data.getId()).

    Man könnte das auch noch eine Ebene höher heben, indem man DT einfach Data nennt und außerhalb der Klasse deklariert. Dann läuft man aber Gefahr, Funktionen mehrfach implementieren zu müssen, wenn man weiterhin Vererbung vermeiden will.



  • So die nächste Variante ohne die Nachteile von vorher.

    #include <type_traits>
    #include <iostream>
    
    template<class T>
    class Data;
    
    template<class T>
    requires std::is_same_v<T, void>
    class Data<T> {
    public:
        int Id = 0;
    };
    
    template<class T>
    requires (! std::is_same_v<T, void>)
    class Data<T> {
    public:
        int Id = 0;
        T t = 1;
    };
    
    int main() {
        Data<int>  d1; // OK
        Data<void> d2; // OK
    
        std::cout << "d1: " << d1.Id << ", " << d1.t << "\n";
        std::cout << "d2: " << d2.Id << "\n";
    }
    


  • Vielen Dank für die verschiedenen Ansätze und Lösungen!

    Ich habe aktuell kein konkretes Problem, für das ich das hier brauche, die Frage entstand aus Neugier. Leider habe ich im Moment auch wenig Zeit, als dass ich die verschiedenen Lösung ausprobieren könnte, ich werde aber drauf zurückkommen, wenn ich mich damit beschäftigt habe.



  • @john-0 sagte in Member von Strukturen per SFINAE löschen:

    So die nächste Variante ohne die Nachteile von vorher.

    #include <type_traits>
    #include <iostream>
    
    template<class T>
    class Data;
    
    template<class T>
    requires std::is_same_v<T, void>
    class Data<T> {
    public:
        int Id = 0;
    };
    
    template<class T>
    requires (! std::is_same_v<T, void>)
    class Data<T> {
    public:
        int Id = 0;
        T t = 1;
    };
    
    int main() {
        Data<int>  d1; // OK
        Data<void> d2; // OK
    
        std::cout << "d1: " << d1.Id << ", " << d1.t << "\n";
        std::cout << "d2: " << d2.Id << "\n";
    }
    

    Hat es denn einen besonderen Grund, warum du das mit einem komplizierten Constraint, anstatt einfach einer normalen specialization löst?
    Das macht es doch nochmal um ein vielfaches cleaner.

    template<class T>
    class Data{
    public:
        int Id = 0;
        T t = 1;
    };
    
    template<>
    class Data<void> {
    public:
        int Id = 0;
    };
    


  • @DNKpp sagte in Member von Strukturen per SFINAE löschen:

    Hat es denn einen besonderen Grund, warum du das mit einem komplizierten Constraint, anstatt einfach einer normalen specialization löst?

    Es würde nach eine SFINAE Lösung gefragt, und bei SFINAE kann man eben nicht nur den Typen als Unterscheidung nutzen, wie bei der Spezialisierung. Nehmen wir mal an, dass wir eine andere Anforderung haben z.B. dass der Datentyp die Bedingungen eines Körpers erfüllt.

    #include <type_traits>
    #include <iostream>
    #include <string>
    
    template <typename T>
    concept Addition = requires (T a, T b) {a + b;};
    
    template <typename T>
    concept Substraction = requires (T a, T b) {a - b;};
    
    template <typename T>
    concept Multiplication = requires (T a, T b) {a * b;};
    
    template <typename T>
    concept Division = requires (T a, T b) {a / b;};
    
    template <typename T>
    concept Field = Addition<T> && Substraction<T> && Multiplication<T> && Division<T>
      && std::regular<T>;
    
    
    template<class T>
    class Data;
    
    template<class T>
    requires (!Field<T>)
    class Data<T> {
    public:
        int Id = 0;
    };
    
    template<class T>
    requires Field<T>
    class Data<T> {
    public:
        int Id = 0;
        T t = 1;
    };
    
    int main() {
        Data<int>  d1;        // OK
        Data<std::string> d2; // OK
    
        std::cout << "d1: " << d1.Id << ", " << d1.t << "\n";
        std::cout << "d2: " << d2.Id << "\n";
    }
    


  • @john-0 sagte in Member von Strukturen per SFINAE löschen:

    @DNKpp sagte in Member von Strukturen per SFINAE löschen:

    Hat es denn einen besonderen Grund, warum du das mit einem komplizierten Constraint, anstatt einfach einer normalen specialization löst?

    Es würde nach eine SFINAE Lösung gefragt, und bei SFINAE kann man eben nicht nur den Typen als Unterscheidung nutzen, wie bei der Spezialisierung. Nehmen wir mal an, dass wir eine andere Anforderung haben z.B. dass der > Datentyp die Bedingungen eines Körpers erfüllt.

    Das mag wohl sein, trotzdem ist es doch recht umständlich ein Primary Template + zwei Spezialisierungen zu verwenden. Immerhin prüfst du auf "hat Eigenschaft" und "hat Eigenschaft nicht". Das mag für dieses binäre Verhalten noch klar gehen, skaliert aber echt schlecht.

    template<class T>
    class Data {
    public:
        int Id = 0;
    };
    
    template<Field T>
    class Data<T> {
    public:
        int Id = 0;
        T t = 1;
    };
    


  • @DNKpp sagte in Member von Strukturen per SFINAE löschen:

    Das mag wohl sein, trotzdem ist es doch recht umständlich ein Primary Template + zwei Spezialisierungen zu verwenden.

    Es ist der Weg, den sich die Macher von C++ ausgedacht haben. Es ist nicht so effizient wie

    template<typename T>
    struct Data {
        int Id = 0;
    #ifdef T_IS_NOT_VOID
        T t = 1;
    #endif
    };
    

    . Es geht auch nicht über constexpr if

    #include <iostream>
    
    template <typename T>
    struct Data {
        int Id = 0;
    
        if constexpr (std::is_same_v<T, void>) {
            T t = 1;
        }
    };
    
    int main() {
        Data<int>  d1;
        Data<void> d2;
    
        std::cout << "d1: " << d1.Id << ", " << d1.t << "\n";
        std::cout << "d2: " << d2.Id << "\n";
    }
    

    Man kann in vielen Fällen constexpr if verwenden, aber nicht in einem Fall in dem man einen Member definieren will.



  • Außerdem löschen eure Lösungen doch gar nicht den Member, sondern ihr kopiert einfach die Klasse und fügt in der "non-void"-Klasse den zusätzlichen Member hinzu.
    Es geht ja darum bei einer beliebigen Klasse (mit beliebig vielen Membern und Memberfunktionen) einen (oder mehrere) Member zu "löschen" (d.h. keinen Speicherplatz dafür zu verschwenden).

    Außerdem finde ich auch den Ansatz mit der Vererbung nicht so gut und würde wohl selber am ehesten den in meinem Link verwendeten

    [[no_unique_address]] std::conditional_t<Cond, T, std::monostate> m;
    

    benutzen.



  • @john-0 sagte in Member von Strukturen per SFINAE löschen:

    @DNKpp sagte in Member von Strukturen per SFINAE löschen:

    Das mag wohl sein, trotzdem ist es doch recht umständlich ein Primary Template + zwei Spezialisierungen zu verwenden.

    Es ist der Weg, den sich die Macher von C++ ausgedacht haben. Es ist nicht so effizient wie

    Es steht doch nirgendwo geschrieben, dass ein Primary Template immer undefiniert sein muss. Es ist ein Fakt, dass es nicht sinnvoll ist, eine Bedingung zu überprüfen und andernfalls die gegenteilige Bedingung nochmal zu prüfen.

    Aber mach du nur, wie du willst. Ich hab dir einen alternativen und kürzeren Weg gezeigt. Mehr wollte ich gar nicht.

    @Th69 sagte in Member von Strukturen per SFINAE löschen:

    Außerdem löschen eure Lösungen doch gar nicht den Member, sondern ihr kopiert einfach die Klasse und fügt in der "non-void"-Klasse den zusätzlichen Member hinzu.
    Es geht ja darum bei einer beliebigen Klasse (mit beliebig vielen Membern und Memberfunktionen) einen (oder mehrere) Member zu "löschen" (d.h. keinen Speicherplatz dafür zu verschwenden).

    Außerdem finde ich auch den Ansatz mit der Vererbung nicht so gut und würde wohl selber am ehesten den in meinem Link verwendeten

    [[no_unique_address]] std::conditional_t<Cond, T, std::monostate> m;
    

    benutzen.

    Dafür hast du halt hier einen Member, der nichts tut und ggf eher irritiert. Hat doch alles seine Vor- und Nachteile. Zumal das no_unique_address Attribut afaik noch gar nicht von allen Compilern supportet wird (msvc bietet z.b. msvc::no_unique_address als Alternative an).



  • @Th69 sagte in Member von Strukturen per SFINAE löschen:

    Außerdem finde ich auch den Ansatz mit der Vererbung nicht so gut und würde wohl selber am ehesten den in meinem Link verwendeten

    [[no_unique_address]] std::conditional_t<Cond, T, std::monostate> m;
    

    benutzen.

    Ausprobiert?
    Ich bekomme mit

    #include <type_traits>
    #include <iostream>
    #include <variant>
    
    template <typename T, bool C = std::is_same_v<T, void>>
    struct Data {
        int Id = 0;
    
        [[no_unique_address]] std::conditional_t<C, T, std::monostate> m;
    };
    
    int main() {
        Data<int>  d1; // OK
        Data<void> d2; // OK
    
        std::cout << "d1: " << d1.Id << ", " << d1.t << "\n";
        std::cout << "d2: " << d2.Id << "\n";
    }
    

    folgende Ausgabe (2. Fehler interessiert nicht)

    sfinae5.cc: In instantiation of ‘struct Data<void>’:
    sfinae5.cc:14:13:   required from here
    sfinae5.cc:9:72: error: ‘Data<T, C>::m’ has incomplete type
        9 |         [[no_unique_address]] std::conditional_t<C, T, std::monostate> m;
          |                                                                        ^
    sfinae5.cc:9:72: error: invalid use of ‘std::conditional_t<true, void, std::monostate>’ {aka ‘void’}
    sfinae5.cc: In function ‘int main()’:
    sfinae5.cc:16:52: error: ‘struct Data<int>’ has no member named ‘t’
       16 |         std::cout << "d1: " << d1.Id << ", " << d1.t << "\n";
          |                                                    ^
    


  • @john-0 sagte in Member von Strukturen per SFINAE löschen:

    std::conditional_t<C, T, std::monostate> m;

    Probiers doch mal mit std::conditional_t<!C, T, std::monostate> m; 🙂



  • Manchmal sieht man die trivialsten Dinge nicht. Und es muss an dieser Stelle sein, da es in der Template Namendefinition nicht funktioniert.



  • @DNKpp sagte in Member von Strukturen per SFINAE löschen:

    Es steht doch nirgendwo geschrieben, dass ein Primary Template immer undefiniert sein muss. Es ist ein Fakt, dass es nicht sinnvoll ist, eine Bedingung zu überprüfen und andernfalls die gegenteilige Bedingung nochmal zu prüfen.

    Der Aspekt stört dich, ok berechtiger Einwand. Ich hatte es per Cut & Paste aus anderem Code übernommen in dem A oder B gültig sein musste, da nicht A und nicht B als Fall nicht übersetzen dürfen. Ich dachte, Du störst Dich an etwas anderem.

    Also korrigiert

    #include <type_traits>
    #include <iostream>
    #include <string>
    
    template <typename T>
    concept Addition = requires (T a, T b) {a + b;};
    
    template <typename T>
    concept Substraction = requires (T a, T b) {a - b;};
    
    template <typename T>
    concept Multiplication = requires (T a, T b) {a * b;};
    
    template <typename T>
    concept Division = requires (T a, T b) {a / b;};
    
    template <typename T>
    concept Field = Addition<T> && Substraction<T> && Multiplication<T> && Division<T>
      && std::regular<T>;
    
    
    template<class T>
    class Data {
    public:
        int Id = 0;
    };
    
    template<class T>
    requires Field<T>
    class Data<T> {
    public:
        int Id = 0;
        T t = 1;
    };
    
    int main() {
        Data<int>  d1; // OK
        Data<std::string> d2; // OK
    
        std::cout << "d1: " << d1.Id << ", " << d1.t << "\n";
        std::cout << "d2: " << d2.Id << "\n";
    }
    


  • [[no_unique_address]] std::conditional_t<Cond, T, std::monostate> m;
    

    Interessante Lösung. Mein Problem damit wäre aber, dass während der Member zwar keinen zusätzlichen Speicher benötigt, er in der Programmlogik immer noch vorhanden ist. Das mag je nach Anwendungsfall kein Problem sein, ich könnte mir aber vorstellen, dass ich bei so einem Design vielleicht irgendwo ein Concept haben wollte, das mir für einen beliebigen "Data"-Typen sagt, ob dieser einen Context hat:

    if constexpr (has_context<decltype(data)>)
        do_something_with_context(data);
    

    Sowas wäre dann weniger geradlinig zu implementieren, zumal das vielleicht auch nicht immer ein std::monostate ist, sondern vielleicht auch mal ein struct MyEmptyContext {};.

    Ich persönlich finde da einen auch in der Programmlogik nicht vorhandenen Member eleganter und Vererbung nicht wirklich verwerflich (Data ist ein potentiell Context-haltendes Objekt) - ja in diesem Fall sogar leichter verständlich. "Komposition statt Vererbung" ist erstens keine harte Regel, sondern eine Empfehlung, diese zu bevorzugen und zweitens macht die m.E. vornehmlich bei tiefen Klassenhierarchien Sinn. Den Data-Zweig sehe ich hier nicht als solche. Da wird die Vererbungshierarchie sehr wahrscheinlich ohnehin nach ein, zwei Schritten bei Data terminiert und dieses dann vermutlich wiederum als Member (Komposition) in irgendeiner anderen Klasse verwendet.

    Aus dem Grund sehe ich hier nicht nur die Vererbung unkritisch, sondern erachte auch Polymorphie als Overkill. Ich sehe Data eher als Lowlevel-Utility-Klasse, die auch Teil einer relativ generischen Bibliothek sein könnte. Da lasse ich andere Regeln gelten, als für große Toplevel-Klassenhierarchien, die "Geschäftslogik" abbilden (und die ich wohl auch "per Default" polymorph machen würde).

    Nur meine persönlich Ansicht, für ich hier nochmal argumentieren wollte - ich bestehe da nicht wirklich drauf. Wählt die Lösung mit der ihr glücklicher seid 😉


Anmelden zum Antworten