Member von Strukturen per SFINAE löschen



  • Hi,

    ich versuche gerade Datenelemente von Strukturen per SFINAE zu löschen, ist das überhaupt möglich?

    template<bool B, typename T>
    struct disable_if;
    
    template<typename T>
    struct disable_if<false,T>
    {
       using type = T;
    };
    
    template<bool B, typename T>
    using disable_if_t = typename disable_if<B,T>::type;
    
    template<typename T>
    struct Data
    {
       unsigned int                           Id = 0;
       disable_if_t<std::is_same_v<T,void>,T> Context = T{};
    };
    
    int main()
    {
       Data<int> d1;   // OK
       Data<void> d2; // implicit instantiation of undefined template 'disable_if<true,void>' in line 11
    }
    

    Vermutlich ist der Ansatz Käse, weil durch SFINAE für ungültige Funktionen kein Code generiert wird, aber Member in Strukturen was völlig anderes sind. Ich könnte bei void auch ne Ersetzung durch zB char oder irgendwas anderes machen, aber dann habe ich einen Member in der Struktur, der für nix gebraucht wird aber trotzdem da ist.
    Hat da jemand ne Idee?



  • Nein, so direkt funktioniert das nicht. Man muß bei Membern immer einen alternativen Typ angeben (dies kann aber eine leere Klasse/Struktur sein). Daher schau dir mal die Lösungen (pre C++20/C++20) in Is it possible to enable_if at member variable level? an.



  • Danke für den Link, ich teste mal mit diesem Ansatz:

    template<bool B, typename T>
    struct ContextData
    {
       T Context{};
    
       ContextData() = default;
       virtual ~ContextData() = default;
    };
    
    template<typename T>
    struct ContextData<true,T>
    {
       ContextData() = default;
       virtual ~ContextData() = default;
    };
    
    template<typename T>
    struct Data : ContextData<std::is_same_v<T,void>, T>
    {
       unsigned int Id = 0;
    
       Data() = default;
    }
    

    Bisher sieht's gut aus 🙂



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

    Danke für den Link, ich teste mal mit diesem Ansatz:

    Ja, Vererbung hätte ich auch vorgeschlagen, vor allem wegen Empty Base Optimization.

      virtual ~ContextData() = default;
    

    Ich wollte nur fragen, ob du wirklich eine (laufzeit-) polymorphe Klasse (mit virtual) brauchst? Ich hätte das erstmal nur als simples struct gemacht: Eins leer und eins nur mit dem Member. Also ohne expliziten CTOR/DTOR.

    Und selbst wenn z.B. ConxtextData auf Member von Data zugreifen will, täte es wahrscheinlich auch statische Polymorphie: Z.B.static_cast<Data*>(this) in einer ConxtextData-Memberfunktion, falls ausschließlich Data davon abgeleitet wird. Oder im Zweifel mit CRTP (oder ganz modern ab C++23 mit explizitem this-Parameter).

    Ich erwähne das, weil ich mir nur schwer vorstellen kann, dass man über eine ContextData<bool, T>-Referenz irgendwelche virtuellen Funktionen aufrufen will, die in Data implementiert sind. Vor allem wenn T auch noch ein Template-Parameter von Data ist. Das sieht doch sehr nach einem Fall für statische (oder auch gar keine) Polymorphie aus 😉



  • @Finnegan
    Sobald ich von irgendwas erbe spendiere ich der Basisklasse nen virtuellen Destruktor. ContextData ist in diesem Fall ja auch nur eine Zwischenklasse, von der Data erbt, in meinem Spielprojekt ist Data schon polymorph.
    Nen richtigen Anwendungsfall für das Problem habe ich eigentlich nicht, habe nur etwas rumgespielt. Ziel war es, einen Container für iwas zu haben, wo man dem iwas optional irgendwelche Kontextinformationen zuordnen kann, die mit dem iwas eigentlich nichts zu tun haben. Und wie man den Container dann ohne Kontextinformationen umsetzt, da void als Kontextdatentyp besonders ist.



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

    @Finnegan
    Sobald ich von irgendwas erbe spendiere ich der Basisklasse nen virtuellen Destruktor. ContextData ist in diesem Fall ja auch nur eine Zwischenklasse, von der Data erbt, in meinem Spielprojekt ist Data schon polymorph.
    Nen richtigen Anwendungsfall für das Problem habe ich eigentlich nicht, habe nur etwas rumgespielt. Ziel war es, einen Container für iwas zu haben, wo man dem iwas optional irgendwelche Kontextinformationen zuordnen kann, die mit dem iwas eigentlich nichts zu tun haben. Und wie man den Container dann ohne Kontextinformationen umsetzt, da void als Kontextdatentyp besonders ist.

    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;).

    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.

    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. Irgendwann summieren die sich vielleicht auf und man hat ein Programm, das "irgendwie langsam" ist, aber keinen wirklichen "Hot Path" an dem man das festmachen und den man optimieren könnte.

    Das erachte ich auch nicht als Mikro-Optimierung, für mich ist das nur eines der Standard-Pattern, mit denen ich Code formuliere. Sowas wie z.B. einmal eine Referenz auf ein Map-Element zu holen (auto& element = map[key];) und das dann zu nutzen, anstatt in Folge mehrfach map[key] zu verwenden 😉



  • Da muss ich leider zugeben, dass mir an manchen Stellen das Wissen einfach fehlt. Das sind so Dinge, die man irgendwann mal behandelt hat und die Details dazu wieder vergessen hat. Sobald man von irgendwas erbt macht man mit einem virtuellen Destruktor erst mal nichts falsch. Wenn's dann ohne vtable geht isses Bonus mit Fleißkärtchen. Wir schreiben keine Software wie google oder facebook, wo man jeden CPU Zyklus einsparen will, wobei ich generell aber schon drauf achte, dass ich nicht absichtlich langsamen Code schreibe.



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

    Sobald man von irgendwas erbt macht man mit einem virtuellen Destruktor erst mal nichts falsch.

    Das stimmt.

    Wenn's dann ohne vtable geht isses Bonus mit Fleißkärtchen. Wir schreiben keine Software wie google oder facebook, wo man jeden CPU Zyklus einsparen will, wobei ich generell aber schon drauf achte, dass ich nicht absichtlich langsamen Code schreibe.

    Und es sollte auch nur ein Denkanstoß sein. Ich hoffe es wurde deutlich, dass die Auswirkungen meistens (wenn überhaupt) nur minimal sind.



  • Hm, ich bin da gerade ein bisschen zwiegespalten. Der Vorteil, einer Basisklasse immer einen virtuellen Destruktor zu verpassen ist, wenn doch mal jemand (anderes vielleicht) auf die Idee kommt, die Klasse als polymorphe Basisklasse zu verwenden, muss der nicht daran denken, dass es da ein Problem geben könnte. Man hat also eine potentielle zukünftige Fehlerquelle weniger.
    Auf der anderen Seite verhindet ein Destruktor die Compiler generierten Move Assignement und Move Ctor und man handelt sich den VTable Overhead ein.



  • @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.


Anmelden zum Antworten