|
Boost : |
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.
Boost list run by bdawes at acm.org, gregod at cs.rpi.edu, cpdaniel at pacbell.net, john at johnmaddock.co.uk