Suppose I'm writing a C++ class with the PImpl idiom for the usual reasons of providing a stable ABI and/or reducing #include
dependencies. I want the class to have value semantics: modifying a copy of the object has no visible effect on the original object. Assume doing a semantic copy (copying the implementation members) is not "fast" in some meaningful sense.
class Thing {
public:
Thing();
Thing(const Thing&);
Thing(Thing&&);
Thing& operator=(const Thing&);
Thing& operator=(Thing&&);
~Thing();
// Various accessor and mutator functions...
private:
class Impl;
std::unique_ptr<Impl> m_pimpl;
};
A tempting implementation of the move constructor would be
Thing::Thing(Thing&&) = default;
which is essentially the same as
Thing::Thing(Thing&& src) : m_pimpl(std::move(src.m_pimpl)) {}
But either of these leaves the moved-from Thing
with a null m_pimpl
member. Otherwise, I'd like to have a non-null m_pimpl
as a class invariant. If any member function of Thing
doesn't check for and deal with a null m_pimpl
, that opens up accidental undefined behavior. Herb Sutter's "Move, Simply" post makes a good argument that allowing that invariant violation for moved-from objects is bad design.
The options I can think of are:
A moved-from
Thing
is in a special restricted state. The only public member functions which can be called on it are the destructor, the assignment operators, and maybe some other functions with names likereset
orassign
. All other public member functions have a precondition that the object is not in this moved-from state. This is exactly what I read Sutter's article as warning against.Don't implement move semantics for
Thing
at all. Every move is a full copy.Allow
m_pimpl
to be null. Every public member function ofThing
must check for this case before using*m_pimpl
. Perhaps a default-initializedThing
also has nullm_pimpl
.Make
m_pimpl
astd::shared_ptr<Thing::Impl>
which is never null, and implement copy on write. Non-mutating functions can simply access*m_pimpl
. Mutating member functions which use the existing state rather than replace it begin with a call tovoid Thing::copy_on_write() { if (m_pimpl.use_count() > 1) m_pimpl = std::make_shared<Impl>(*m_pimpl); }
All of these options have some benefits and drawbacks, it seems. What solutions, above or otherwise, work well and with a clean, maintainable design as a best practice for modern C++? What considerations are important when choosing how to implement the PImpl pattern?
[The Q&A "Object lifetime invariants vs. move semantics" is a more general version of this question, but I'm specifically asking about the PImpl use case.]
1 Answer 1
Option 2 is a good option if copying is a cheap operation anyway.
Option 3 is also a good option if having a NULL m_pimpl
can be seen as a natural state of the object.
Besides those, there is also an option 5:
Thing::Thing(Thing&& src) : m_pimpl(std::make_unique<Impl>(std::move(*src.m_pimpl))) {}
Here, you create a new unique_ptr
with a moved version of the Impl
class. The Impl class can then decide for itself how to implement move semantics.
-
This is a valid option, but it does have the effect that the move constructor cannot be noexcept. However, option 2 (copies only) leaves us in that same situation, so it's never worse than that.Stewart Becker– Stewart Becker05/26/2024 06:47:16Commented May 26, 2024 at 6:47