Into the blue again after the money’s gone<br/> Once in a lifetime, water
flowing underground –David Byrne
=delete for Lifetimes
Imagine you have an API that requires a reference to some long-lived object, but
doesn’t take ownership of it.
class Request {
...
// The provided Context must live as long as the current Request.
void SetContext(const Context& context);
};
You think to yourself, “Hey, what happens if someone passes a temporary? That’s
going to be a bug. But this is modern C++, I can prevent that!” So you rig
together a change in the API, adding a deleted overload.
class Request {
...
// The provided Context must live as long as the current Request.
void SetContext(const Context& context);
void SetContext(Context&& context) = delete;
};
Pleased with your work, you think “Hey, now the API says everything, the comment
isn’t necessary.”
As presented, you might think that this is a good idea. However, as in many
cases of API design it is tempting to look at the definition of the API, but
more useful to look at how the API is used. So lets replay this scenario,
taking into account usage.
A user attempting to use the original SetContext(), trying to get something
simple to build and not knowing where to find the right Context object, just
makes the suggested call.
request.SetContext(Context());
Without your =delete change, this builds, but fails at runtime (probably in a
mysterious fashion). When looking at the SetContext API, the lifetime
requirement is documented, and the code is changed to comply.
request.SetContext(request2.context());
A user attempting to use the “improved” SetContext() with your =delete
change and no comment, on the other hand, first encounters the build break:
error: call to deleted member function 'SetContext'
request.SetContext(Context());
~~~~~~~~^~~~~~~~~~
:4:8: note: candidate function has been explicitly deleted
void SetContext(Context&& context) = delete;
The user then thinks “Well, I can’t pass a temporary”, but having no information
about the actual requirement, what is the most likely fix?
Context context;
request.SetContext(context);
Now, the crux of the matter: How likely is it that the scope of the new
automatic variable context is the right lifetime for this call? If your answer
is anything less than 100%, the lifetime requirement comment is still necessary.
class Request {
...
// The provided Context must live as long as the current Request.
void SetContext(const Context& context);
void SetContext(Context&& context) = delete;
};
Deleting a member of an overload set in this fashion is at best a half-measure.
Yes, you avoid one class of bugs, but you also complicate the API. Relying on
such a design is a sure-fire way to get a false sense of security: the C++ type
system is simply not capable of encoding the necessary details about lifespan
requirements for parameters.
Since the type system can’t actually get this right, we recommend you not
complicate things with half-measures. Keep it simple - don’t try to rely on this
pattern to disallow temporaries, it doesn’t work well enough to help.
=delete for “Optimization”
Let’s flip the situation around: perhaps it isn’t that you want to prevent
temporaries, maybe you want to prevent copies.
How likely is it that a caller of your API will never need to keep their value?
If you cannot be 100% sure that you know exactly how your API will be used, this
is a recipe for annoying your users. Consider making copies and invoking such an
API given normal (non-deleted) design:
Config c1 = GetConfig();
Config c2 = GetConfig();
std::string s = GetDna();
// Kick off scans for both configs.
auto scan1 = DnaScan(c1, s);
auto scan2 = DnaScan(c2, std::move(s));
Since we see that the second scan is the last use of s, we can just
std::move into the value-consuming call. With the “cleverly optimized”
version, the code gets more sloppy looking.
Config c1 = GetConfig();
Config c2 = GetConfig();
std::string s = GetDna();
std::string s2 = s;
// Kick off scans for both configs.
auto scan1 = DnaScan(c1, std::move(s));
auto scan2 = DnaScan(c2, std::move(s2));
APIs are provided as building blocks and abstractions - the ecosystem of APIs is
a platform to be assembled together in new and surprising ways that are more
than what the provider of any single API might predict. Believing that you know
certainly that nobody should ever make a copy runs counter to that. Further, the
problem of inefficiency and copying when moving would suffice is far broader
than any single API, and likely better solved with some combination of
profiling, training, code review, and static analysis.
In the rare case when you can know for sure that an API has to be used in a
particular fashion: you probably should encode that in the types in question.
Don’t operate on std::string as your representation of a DNA sequence; write a
Dna class and make it move-only with an explicit (easy to scan for) way to do
the expensive copy operation. Put another way: properties of types should be
expressed in those types, not in the APIs that operate on them.
Ref-qualification
As a side note: it’s possible to apply the same reasoning for ref-qualifiers on
destructive accessors. Consider a class like std::stringbuf - in C++20 it
gained an accessor to consume the contained string, presented as an overload set
with the existing accessor:
(See Tip #148 for more info on reference-qualified methods and
overload sets). Looking at existing usage of std::stringbuf, nearly every use
has a single stringbuf that is used to produce one string. Ignoring the legacy
code, would it not be best to enforce that, and provide only the “efficient”
ref-qualified destructive member?
Of course not, for similar reasons to the DnaScan example above: you cannot
know certainly that nobody needs it, and it’s not unsafe to provide the const
overload. Use ref-qualifiers only as an overload set for optimization, or when
the ref-qualifiers are necessary to enforce semantic correctness.
Summary
It is tempting to try to use rvalue-references or reference qualifiers in
conjunction with =delete to provide a more “user friendly” API, enforcing
lifetimes or preventing optimization problems. In practice, those are usually
bad temptations. Lifetime requirements are much more complicated than the C++
type system can express. API providers can rarely predict every future valid
usage of their API. Avoiding these types of =delete tricks keeps things
simple.