On 30/11/2017 20:48, Andrzej Krzemienski wrote:
In general you should expect to be able to call any method which is valid on a default-constructed object, *especially* assignment operators (as it's relatively common to reassign a moved-from object). (You cannot, however, actually assume that it will return the same answers as a default-constructed object would.)
Agreed (assuming you meant "on a moved-from-object" rather than "on a default-constructed object"), but while such an object is "valid", this information is of little use in some cases. And I think it is such cases that are relevant for creating class invariants.
Not quite. I meant "you should be able to call any method on a moved-from object that is valid for a default-constructed object", ie. those without strict preconditions, ie. the class invariant should still hold and the object should still be in a valid state -- you just can't assume any particular state (neither empty nor full nor somewhere in between). As such it is usually only reasonable to perform those operations which cause a well-defined postcondition state regardless of the initial state -- ie. assignment, destruction, or explicit clearing or resetting or things of that nature. But it would also be legal to perform other operations and then interrogate the object about its resulting state -- but that's rarely useful in practice as it's a possible source of nondeterminism in different environments, and usually we want our software to be more predictable. :)
Let me give you some context. I would like to create a RAII-like class representing a session with an open file. When I disable all moves and copies and the default constructor (so that it is a guard-like object) I can provide a very useful guarantee: When you have an object of type `File` within its lifetime, it means the file is open and you can write to it, or read from it.
This means calling `file.write()` and `file.read()` is *always* valid and always performs the desired IO operation. When it comes to expressing invariant, I can say:
``` bool invariant() const { this->_file_handle != -1; } ```
(assuming that -1 represents "not-a-handle")
But my type is not moveable. So I add move operations (and not necessarily the default constructor), but now I have this moved-from state, so my guarantee ("When you have an object of type `File` within its lifetime, it means the file is open and you can write to it, or read from it") is no longer there. You may have an object to which it is invalid to write. Of course, the moved-from-object is still "valid", but now "valid" only means "you can call function `is_valid()` and then decide" (and of course you can destroy, assign, but that's not the point).
As soon as you add those move operations which can put the class into a state where the invariant no longer holds, then it's not an invariant any more. At best it becomes preconditions for most of the methods. This should be self-evident. (Move-assignment isn't too bad, as that can be implemented as a pure swap, which will maintain invariants. But move-construction is an invariant-killer, because it's effectively a swap with nothingness.) Any time that you have a class that wants to provide a "no empty guarantee", and you want to add a move operation to it, you have a problem. I recommend not trying to mix these concepts -- while not completely incompatible, they don't play nicely together. (This also applies to default construction -- if you find yourself wanting to make something non-default-constructible because that would make it somehow invalid, then it probably shouldn't be moveable.) If you want to make a file handle that you can move, then you should sacrifice the no-empty guarantee and allow it to default-construct to "no file open", and return to that state when moved-from. And yes, then you need to check *at certain boundaries* and after certain operations that you've been given a non-empty handle. Emptiness is not an unexpected state for a file handle, so this should surprise nobody. (And you then have to decide an appropriate balance between setting preconditions but merely asserting them in debug builds, or verifying them explicitly in all builds and returning errors or throwing exceptions. But that's true for anything.) Another option if you really want to retain both no-empty and moveability is to wrap it in a unique_ptr. Now you're moving the pointer to the object, not the object itself, which remains immobile. It still means you have to check if someone's handed you an empty pointer -- but you can be more explicit at the boundaries, with methods taking a unique_ptr<File> (&& or const&) if they will be checking if it's empty or taking a File (& or const&) if they assume they've been given a non-empty one. Granted that it is *possible* to implement move operations on a no-empty class, but AFAIK this invariably leads to producing a zombie object where any attempt to use it other than for assignment or destruction would produce UB due to violated preconditions (and consequently also weakening the class invariant to become method preconditions). This seems like a really bad idea to me.