Non-nullable pointers vs. Move-Semantik



  • Für einen referenzzählenden Smartpointer ist es ja relativ leicht, non-nullability zu implementieren, d.h. zu verunmöglichen, daß auf legalem Weg eine Smartpointerinstanz mit einem Nullzeiger erstellt werden kann. Allerdings steht non-nullability im Konflikt mit der Move-Semantik, weil diese in C++ nichtdestruktiv ist. Deshalb kann man kein konsequent nicht-nullbares Pendant zu std::unique_ptr<> mit Move-Semantik implementieren:

    void foo(UniqueRef<X> ref);
    void bar(X& ref);
    
    void baz(...)
    {
        UniqueRef<X> ref = makeUniqueRef<X>(...); // mit mandatory copy elision keine Move-Semantik erforderlich
        bar(*ref); // ok, ref kann nicht nullptr sein
        foo(std::move(ref)); // hoppla, durch den Move wird ref doch ein nullptr!
        bar(*ref); // UB, aber kein Compilerfehler
    }
    

    Dennoch hätte dieses UniqueRef<> einen Nutzen; einerseits, weil es nicht leicht ist, einen nullptr drin unterzubringen (kein Defaultkonstruktor, kein std::unique_ptr<> -Konstruktor), andererseits, weil es dann für den Benutzer klar ist, daß ein UniqueRef<> -Argument nicht nullptr sein darf, was bei einem unique_ptr<> -Argument nicht der Fall ist. Bei Github findet man z.B. https://github.com/dropbox/nn, was genau diese Idee umsetzt und angeblich bei Dropbox gerne verwendet wird.

    Nun meine Frage: Würdet ihr lieber so ein UniqueRef<> verwenden, das seine Invariante nicht konsequent erzwingt? Oder dann lieber gleich den std::unique_ptr<> und halt durch Dokumentation und Laufzeit-Assertions klarmachen, daß er nicht nullptr sein sollte?



  • Ich seh das Problem nicht ganz. Eine unique_ptr Implementierung kann doch einfach im Dereferezierungsoperator ein assert() haben das sicherstellt dass der Pointer nicht nullptr ist. Ansonsten: Eine normale Referenz die vor dem move an das Objekt auf das der Pointer zeigt gebunden wurde ist auch nach dem move weiterhin gültig sofern das Objekt vom neuen Owner nicht zerstört wurde...



  • Du kannst halt dummerweise so einen unique_ptr an mehrere Stellen hin-moven. Und alle bis auf den 1. bekommen dann Ranz.

    Generelles Problem mit C++ move. Finde ich allgemein schade - ich mag gerne Objekte die keinen Zombie-Zustand (move victim) haben. Aber vielleicht wird das ja nochmal nachgebessert.



  • @audacia
    Zu deiner Frage: ich weiss es nicht. Der Unterschied wäre dann ja bloss ein Hinweis zur korrekten Verwendung. OK, nicht ganz, man könnte zumindest bei jedem Move checken dass das Move-victim nicht vor dem Move schon leer ist. Bzw. generell bei jeder Operation ausser "is valid". Das wäre ein Vorteil. Also tendenziell eher zu gunsten eigene Klasse.



  • dot schrieb:

    Ich seh das Problem nicht ganz.

    Das Problem ist, daß die nichtdesktruktive Move-Semantik zur Folge hat, daß ein movebares Objekt einen Zombiezustand kennt, in dem es nicht mehr benutzbar, aber dennoch "am Leben" ist. Bei manchen Objekten koinzidiert das mit einem sinnvollen Leerzustand, etwa bei std::unique_ptr<> oder bei std::vector<> . In anderen Fällen gibt es einen sinnvollen Leerzustand nicht, z.B. eben UniqueRef<> , oder auch std::thread oder std::ofstream (welches allerdings schon ohne Move-Semantik einen Zombiezustand hatte).

    Klar kann man jetzt in jeder Memberfunktion ein Flag checken und sinngemäß eine ObjectDisposedException werfen. Aber weil (frei nach hustbaer) "initialisiert und benutzbar" eine sehr attraktive Invariante für ein Objekt ist, möchte man diese Invariante möglichst zur Compilezeit erzwingen.

    hustbaer schrieb:

    Zu deiner Frage: ich weiss es nicht. Der Unterschied wäre dann ja bloss ein Hinweis zur korrekten Verwendung.

    Nein, wie ich schrieb, wird es allgemein erschwert, überhaupt einen nullptr reinzubekommen. Da sehe ich schon einen praktischen Vorteil ggü. std::unique_ptr<> . Nur ist es eben nicht wasserdicht.



  • audacia schrieb:

    dot schrieb:

    Ich seh das Problem nicht ganz.

    Das Problem ist, daß die nichtdesktruktive Move-Semantik zur Folge hat, daß ein movebares Objekt einen Zombiezustand kennt, in dem es nicht mehr benutzbar, aber dennoch "am Leben" ist.

    Statt zu moven lässt sich auch einfach swappen. Das Objekt, in das hineingemovt wird, lebt dann zwar trotzdem noch etwas weiter, aber es kann somit keine leeren Objekte geben. Jedenfalls wenn es außerdem auch keinen Movekonstruktor gibt.



  • @Techel
    Wenn man moven will hat man bloss meist nix zu swappen. Vertauschen und Verschieben sind schon sehr unterschiedliche Konzepte.
    Und ein Dummy-Objekt zu erstellen, nur damit man swappen kann... ne.



  • audacia schrieb:

    Nein, wie ich schrieb, wird es allgemein erschwert, überhaupt einen nullptr reinzubekommen. Da sehe ich schon einen praktischen Vorteil ggü. std::unique_ptr<> . Nur ist es eben nicht wasserdicht.

    Ja. Ich finde so eine Klasse durchaus noch sinnvoll.

    Ich verstehe auch gut, dass es etwas unbefriedigend ist. Idealerweise könnte man das Klassen-Template für ein statisches Analyse-Tool "markieren" (via Attribut oder so), so dass es solche Fälle noch versucht abzufangen. Ob man auf so ein Objekt x noch zugreifen können soll nachdem man std::forward<X>(x) oder std::move(x) schreibt, ist ja Typ-abhängig. Das erfordert natürlich eine Spezialbehandlung von move und forward im Analyse-Tool. Und alle Fälle wird man wahrscheinlich damit immer noch nicht abfangen können. Aber es ist besser als nichts.

    Eine andere Sache, über die man sich bei unique_ptr-ähnlichen Typen Gedanken machen kann, ist "const propagation". Bei unique ownership kann es durchaus sinnvoll sein, das const durchzureichen, so, wie man das bei Containern auch macht.

    class shape {
        public:
            virtual ~shape() = default;
            virtual void scale(double s) = 0; // <-- non-const
            ...
        };
    
        /
        void observe(std::vector<unique_ptr<Shape>> const& ss) {    
            for (auto& p : ss) {                    ^^^^^
                p->scale(3.14); // Upps!
            }
        }
    
        void foo() {
            std::vector<unique_ptr<Shape>> shapes = ...;
            observe(shapes);
        }
    

    Hier könnte man statt unique_ptr<Shape> dann indirect<Shape> schreiben, was sich wie ein Container verhält, der "immer" genau ein Element speichert -- nur eben indirekt, um Polymorphie zu unterstützen, z.B.

    audacia schrieb:

    Allerdings steht non-nullability im Konflikt mit der Move-Semantik, weil diese in C++ nichtdestruktiv ist.

    Da du das so formuliert hast, nehme ich an, dass Du weißt, dass Move-Semantik in einer anderen Sprache nicht notwendigerweise dasselbe Problem hat.



  • krümelkacker schrieb:

    Eine andere Sache, über die man sich bei unique_ptr-ähnlichen Typen Gedanken machen kann, ist "const propagation". Bei unique ownership kann es durchaus sinnvoll sein, das const durchzureichen, so, wie man das bei Containern auch macht.

    Finde ich nicht gut, weil es dann inkonsistent mit anderen Smartpointern ist. Außerdem löst es dein Problem nur für einen Spezialfall (für const propagation), nicht aber für den meiner Ansicht nach viel allgemeineren und wichtigeren Fall (Subtyping).

    Eine m. E. schönere und allgemeinere Lösung dafür wäre, zu ermöglichen, zwischen zwei Typen eine Relation namens Kovarianz herzustellen. Für einige Typen macht die Sprache das schon selbst (vor allem Zeiger), für benutzerdefinierte Typen könnte ich mir so etwas vorstellen:

    #include <type_traits>
    
    struct Base { };
    struct Derived : Base { };
    
    namespace std_proposed
    {
    
    template <typename T> struct tag { };
    
    struct _Unavailable { };
    _Unavailable covariant_to(...);
    template <typename From, typename To>
        struct is_covariant : std::integral_constant<bool,
            !std::is_same_v<_Unavailable, decltype(covariant_to(tag<To>(), std::declval<From&>()))>>
    {
    };
    template <typename From, typename To>
        struct is_covariant<From*, To* const> : std::integral_constant<bool, std::is_convertible_v<From* const, To* const>>
    {
    };
    template <typename From, typename To>
        struct is_covariant<From* const, To* const> : is_covariant<From*, To* const>
    {
    };
    
    template <typename From, typename To>
        constexpr bool is_covariant_v = is_covariant<From, To>::value;
    
    } // namespace std_proposed
    
    static_assert(std_proposed::is_covariant_v<Derived*, Base* const>);
    static_assert(!std_proposed::is_covariant_v<Derived*, Base*>);
    static_assert(!std_proposed::is_covariant_v<Base*, Derived* const>);
    
    template <typename T>
        struct SmartPtr
    {
        //...
    
        template <typename T2, typename = std::enable_if_t<std_proposed::is_covariant_v<T* const, T2* const>>>
            friend const SmartPtr<T2> covariant_to(std_proposed::tag<const SmartPtr<T2>>, const SmartPtr& self) noexcept
        {
            return reinterpret_cast<const SmartPtr<T2>*>(&self);
        }
        template <typename T2, typename = std::enable_if_t<std_proposed::is_covariant_v<T* const, T2* const>>>
            operator const SmartPtr<T2>&(void) const noexcept
        {
            return reinterpret_cast<const SmartPtr<T2>*>(this);
        }
    };
    
    static_assert(std_proposed::is_covariant_v<SmartPtr<Derived>, const SmartPtr<Base>>);
    static_assert(!std_proposed::is_covariant_v<SmartPtr<Base>, const SmartPtr<Derived>>);
    static_assert(!std_proposed::is_covariant_v<SmartPtr<Derived>, SmartPtr<Base>>);
    
    template <typename T>
        struct Vector
    {
        //...
    
        template <typename T2, typename = std::enable_if_t<std_proposed::is_covariant_v<const T, const T2>>>
            friend const Vector<T2>& covariant_to(std_proposed::tag<const Vector<T2>>, const Vector& self) noexcept
        {
            return reinterpret_cast<const Vector<T2>*>(&self);
        }
        template <typename T2, typename = std::enable_if_t<std_proposed::is_covariant_v<const T, const T2>>>
            operator const Vector<T2>&(void) const noexcept
        {
            return reinterpret_cast<const Vector<T2>*>(this);
        }
    };
    
    static_assert(std_proposed::is_covariant_v<Vector<SmartPtr<Derived>>, const Vector<SmartPtr<const Base>>>);
    static_assert(std_proposed::is_covariant_v<Vector<SmartPtr<Derived>>, const Vector<SmartPtr<Base>>>);
    static_assert(!std_proposed::is_covariant_v<Vector<SmartPtr<Base>>, const Vector<SmartPtr<const Derived>>>);
    static_assert(!std_proposed::is_covariant_v<Vector<SmartPtr<Derived>>, Vector<SmartPtr<Base>>>);
    

    Die verwendeten Casts sind, soweit ich weiß, legal, wenn dieselben Casts auf den Feldern selbst durchgeführt werden könnten, was bei einem Vector oder einem Smartpointer der Fall wäre.



  • audacia schrieb:

    Die verwendeten Casts sind, soweit ich weiß, legal, wenn dieselben Casts auf den Feldern selbst durchgeführt werden könnten, was bei einem Vector oder einem Smartpointer der Fall wäre.

    Hoppla, falsch. Das stimmt tatsächlich nur für const SmartPtr<T>& -> const SmartPtr<const T>& . Sobald Mehrfachvererbung ins Spiel kommt, ist es definitiv kaputt 😞 Selbst ohne Mehrfachvererbung ist es wahrscheinlich implementationsabhängig, und einen Kovarianzbegriff, der von technischen Details wie der Reihenfolge der Basisklassen abhängt, fände ich sehr unschön.

    Also nochmal richtig:

    #include <type_traits>
    
    struct Base { };
    struct Derived : Base { };
    
    namespace std_proposed
    {
    
    template <typename T> struct tag { };
    
    struct _Unavailable { };
    _Unavailable covariant_to(...);
    template <typename From, typename To>
        struct is_covariant : std::integral_constant<bool,
            !std::is_same_v<_Unavailable, decltype(covariant_to(tag<To>(), std::declval<From&>()))>>
    {
    };
    template <typename T> struct is_covariant<T, T> : std::true_type { };
    template <typename T> struct is_covariant<T, T const> : std::true_type { };
    template <typename T> struct is_covariant<T* const, const T* const> : std::true_type { };
    template <typename T> struct is_covariant<T*, const T* const> : std::true_type { };
    
    template <typename From, typename To>
        constexpr bool is_covariant_v = is_covariant<From, To>::value;
    
    } // namespace std_proposed
    
    static_assert(std_proposed::is_covariant_v<Base*, Base*>);
    static_assert(std_proposed::is_covariant_v<Base*, Base* const>);
    static_assert(!std_proposed::is_covariant_v<Derived*, Base* const>);
    
    template <typename T>
        struct SmartPtr
    {
        //...
    
        template <typename T2, typename = std::enable_if_t<std_proposed::is_covariant_v<T* const, T2* const>>>
            friend const SmartPtr<T2> covariant_to(std_proposed::tag<const SmartPtr<T2>>, const SmartPtr& self) noexcept
        {
            return reinterpret_cast<const SmartPtr<T2>*>(&self);
        }
        template <typename T2, typename = std::enable_if_t<std_proposed::is_covariant_v<T* const, T2* const>>>
            operator const SmartPtr<T2>&(void) const noexcept
        {
            return reinterpret_cast<const SmartPtr<T2>*>(this);
        }
    };
    
    static_assert(std_proposed::is_covariant_v<SmartPtr<Base>, const SmartPtr<Base>>);
    static_assert(std_proposed::is_covariant_v<SmartPtr<Base>, const SmartPtr<const Base>>);
    static_assert(!std_proposed::is_covariant_v<SmartPtr<Base>, const SmartPtr<Derived>>);
    
    template <typename T>
        struct Vector
    {
        //...
    
        template <typename T2, typename = std::enable_if_t<std_proposed::is_covariant_v<const T, const T2>>>
            friend const Vector<T2>& covariant_to(std_proposed::tag<const Vector<T2>>, const Vector& self) noexcept
        {
            return reinterpret_cast<const Vector<T2>*>(&self);
        }
        template <typename T2, typename = std::enable_if_t<std_proposed::is_covariant_v<const T, const T2>>>
            operator const Vector<T2>&(void) const noexcept
        {
            return reinterpret_cast<const Vector<T2>*>(this);
        }
    };
    
    static_assert(std_proposed::is_covariant_v<Vector<SmartPtr<Base>>, const Vector<SmartPtr<const Base>>>);
    static_assert(!std_proposed::is_covariant_v<Vector<SmartPtr<Base>>, Vector<SmartPtr<const Base>>>);
    static_assert(!std_proposed::is_covariant_v<Vector<SmartPtr<Derived>>, const Vector<SmartPtr<Base>>>);
    

    Bzgl. Subtyping hoffe ich, daß die C++20-Ranges Kovarianz unterstützen.



  • audacia schrieb:

    krümelkacker schrieb:

    Eine andere Sache, über die man sich bei unique_ptr-ähnlichen Typen Gedanken machen kann, ist "const propagation". Bei unique ownership kann es durchaus sinnvoll sein, das const durchzureichen, so, wie man das bei Containern auch macht.

    Finde ich nicht gut, weil es dann inkonsistent mit anderen Smartpointern ist.

    Mir gefällt die Kombination unique ownership mit Zeigersemantik nicht. Das ist inkonsistent zu anderen Standard-Containern. In meinem Beispiel ist der interne Zeiger nur ein Implementierungsdetail, um Laufzeitpolymorphie mit Wertsemantik zu vereinen.

    Es mag Fälle geben, in denen man ein unique ownership "smartpointer" lieber als Zeiger versteht (kann mich nicht an Fälle erinnern, wo das mehr nützt als schadet) und andere, in denen man das, worauf gezeigt wird, logisch als Unterobjekt betrachten will (wie bei anderen Containern).

    audacia schrieb:

    Die verwendeten Casts sind, soweit ich weiß, legal, wenn dieselben Casts auf den Feldern selbst durchgeführt werden könnten, was bei einem Vector oder einem Smartpointer der Fall wäre.

    Ich fürchte auch, Du verletzt diese Regel und rufst damit undefiniertes Verhalten hervor. Die Verwendung eines reinterpret_cast s ist jetzt nicht aus Prinzip falsch, aber macht es einem doch sehr einfach, diese Aliasing-Regel zu verletzen.



  • krümelkacker schrieb:

    Mir gefällt die Kombination unique ownership mit Zeigersemantik nicht. Das ist inkonsistent zu anderen Standard-Containern.

    ??

    Alle Standardcontainer sind darauf ausgelegt, auf Objekten mit Wertsemantik zu operieren. Smartpointer verwalten Objekte mit Referenzsemantik. Ich sehe überhaupt nicht die Verbindung vom Einen zum Anderen.

    krümelkacker schrieb:

    In meinem Beispiel ist der interne Zeiger nur ein Implementierungsdetail, um Laufzeitpolymorphie mit Wertsemantik zu vereinen.

    Wo soll da die Wertsemantik sein?

    krümelkacker schrieb:

    Es mag Fälle geben, in denen man ein unique ownership "smartpointer" lieber als Zeiger versteht (kann mich nicht an Fälle erinnern, wo das mehr nützt als schadet)

    Wenn man den std::unique_ptr<> zu einem std::shared_ptr<> promotet?

    krümelkacker schrieb:

    Ich fürchte auch, Du verletzt diese Regel

    Soweit ich das verstehe, ist der Cast legal, solange es sich um einen standard layout type handelt, was bei SmartPtr<> und Vector<> der Fall sein dürfte:
    https://stackoverflow.com/questions/30617519/reinterpret-cast-from-object-to-first-member


Anmelden zum Antworten