$include_dir="/home/hyper-archives/boost/include"; include("$include_dir/msg-header.inc") ?>
Subject: Re: [boost] [contract] move operations and class invariants
From: Gavin Lambert (gavinl_at_[hidden])
Date: 2017-11-30 23:43:45
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.