Move-Konstruktion und -Zuweisung bei Handle-Typen



  • Weil VC++ seit gestern mandatory copy elision kann, habe ich mal ein paar Klassen angeschaut, die davon profitieren könnten, und dabei leider festgestellt, daß mir in bezug auf Move-Semantik die Stilsicherheit wesentlich abhandengekommen ist.

    Angenommen, ich definiere ein Dateihandle wie folgt:

    class File
    {
    private:
        ...
        void doClose(bool throwOnError);
    public:
        File(const std::string& filename, FileMode mode);
        void close(void)
        {
            doClose(true);
        }
        ~File(void)
        {
            doClose(false);
        }
        ...
    };
    

    Ich frage mich nun: sollte File move-assignable und move-constructible sein?

    Dafür spricht, daß File ein Handle ist und deshalb natürlicherweise billige Moves unterstützen könnte. Außerdem gibt es wegen der aus Gründen der Exceptionhygiene erforderlichen close() -Methode bereits einen Zombiezustand.

    Dagegen spricht aus meiner Sicht, daß der Move-Zuweisungsoperator close() aufrufen muß und deshalb Exceptions werfen kann, was ich unschön finde. Freilich könnte er auch doClose(false); aufrufen; ich weiß aber nicht, was ich davon halten soll.

    Wäre es eine brauchbare Alternative, die Move-Konstruktion, aber nicht die Move-Zuweisung zu erlauben? Soweit ich sehe, braucht es nur den Move-Konstruktor, damit ich File -Objekte in einen std::vector<> stecken kann. Andererseits ist das vielleicht unerwartet asymmetrisch, wenn es das eine gibt und das andere nicht.

    Eine weitere Frage wäre, ob File einen Defaultkonstruktor haben sollte, der es in den Zombiezustand bringt? Es wäre jedenfalls möglich und kostenlos. Allerdings dachte T. Hoare das damals auch...


  • Mod

    audacia|off schrieb:

    Dagegen spricht aus meiner Sicht, daß der Move-Zuweisungsoperator close() aufrufen muß und deshalb Exceptions werfen kann, was ich unschön finde. Freilich könnte er auch doClose(false); aufrufen; ich weiß aber nicht, was ich davon halten soll.

    Wenn er eine Exception wirft, wirft er halt eine Exception; wenn es kein move assignment gibt, dann tritt sie im Destruktor auf, wo sie ebenfalls ignoriert wird. Wo sie ignoriert wird, ist IMO nicht bedeutend. Ich würde dort keine Exceptions durchkommen lassen; der User sollte doClose(true) explizit aufrufen müssen, um nach Exceptions zu sehen.

    Die Filestreams der Standardbibliothek implementieren move assignment via einem Swap, d.h. da enden die Exceptions im Destruktor des anderen Objekts.



  • Arcoth schrieb:

    Die Filestreams der Standardbibliothek implementieren move assignment via einem Swap, d.h. da enden die Exceptions im Destruktor des anderen Objekts.

    Das finde ich wiederum besonders unschön, weil bei einem Swap das Dateihandle des vermeintlich überschriebenen Streams offenbleibt.

    Vielleicht ist die schönste Lösung tatsächlich die, die am konventionellsten zu implementieren ist:

    File& File::operator =(File&& rhs) noexcept
    {
        File(std::move(rhs)).swap(*this);
        return *this;
    }
    

    Ich bin auch nicht ganz sicher, was ich allgemein von dem Exceptionschlucken halten soll. Man könnte es auch machen wie bei std::thread und den Destruktor für noch offene Dateien std::terminate() aufrufen lassen, damit keiner auf die Idee kommt, das close() wäre optional. Andererseits wird dadurch das Benutzen von mehr als einer Datei pro Scope sehr unangenehm. Wahrscheinlich ist das Schlucken der Exceptions im Destruktor das kleinere Übel.



  • audacia|off schrieb:

    Wahrscheinlich ist das Schlucken der Exceptions im Destruktor das kleinere Übel.

    Dieser Meinung bin ich auch.
    Wenn jemand wirklich eine Exception braucht muss er halt close() aufrufen.

    Irgendwie fühlt sich das zwar komisch an, aber andrerseits ist es gerade bei IO sehr üblich dass "es erst gilt" wenn man sich explizit die Bestätigung geholt hat dass es geklappt hat. Das Wegschlucken (bzw. gar nicht erst Werfen) von Exceptions ist zwar doof, aber so lange es in C++ keinen ähnlichen Mechanismus gibt wie using in C#/try-with-resources in Java bleibt halt keine andere Möglichkeit. (Und auch diese sind nicht ideal, aber ich denke das sollte klar sein.)



  • hustbaer schrieb:

    Das Wegschlucken (bzw. gar nicht erst Werfen) von Exceptions ist zwar doof, aber so lange es in C++ keinen ähnlichen Mechanismus gibt wie using in C#/try-with-resources in Java bleibt halt keine andere Möglichkeit.

    Wobei das using , genau wie ein Destruktor in C++, nur eine andere Schreibweise für finally ist. Der entscheidende Unterschied ist, daß C# nicht das Programm terminiert, wenn in einem finally -Block während der Behandlung einer Exception eine weitere Exception ausgelöst wird. Stattdessen wird die innere Exception verworfen.

    Ansonsten gibt es in C# dasselbe Problem, weil IDisposable.Dispose() keine Exception werfen sollte, d.h. in der Praxis wird man dort auch die aufgetretenen Exceptions schlucken.

    Für den Move-Zuweisungsoperator fiel mir noch folgende mögliche Faustregel auf: "implementiere die Move-Semantik so, als wären Moves destruktiv". D.h.: keine Exception bei der Zuweisung, außerdem keine Überreste von lhs in rhs => ein einfaches swap() reicht nicht.



  • audacia|off schrieb:

    Der entscheidende Unterschied ist, daß C# nicht das Programm terminiert, wenn in einem finally -Block während der Behandlung einer Exception eine weitere Exception ausgelöst wird. Stattdessen wird die innere Exception verworfen.

    Ja, gerade darum geht's ja. Denn das ermöglicht es, wenn man meint man braucht es, in IDisposable.Dispose eben doch eine Exception zu werfen.

    audacia|off schrieb:

    Ansonsten gibt es in C# dasselbe Problem, weil IDisposable.Dispose() keine Exception werfen sollte, d.h. in der Praxis wird man dort auch die aufgetretenen Exceptions schlucken.

    Ja, man sollte nicht. Man kann aber, ohne Gefahr zu laufen sein Programm dadurch unbeabsichtigt wegzuschiessen.


Log in to reply