Tip of the Week #149: Object Lifetimes vs. `=delete`

Originally posted as TotW #148 on May 3, 2018

by Titus Winters, ([email protected])

Into the blue again after the money’s gone
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.”

class Request {
  ...

  void SetContext(const Context& context);
  void SetContext(Context&& context) = delete;

Was this a good idea? Why or why not?

Don’t Design In a Vaccuum

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 function 'SetContext'

  SetContext(Context{});
  ^~~~~~~~~~

<source>:4:6: 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.

future<bool> DNAScan(Config c, const std::string& sequence) = delete;
future<bool> DNAScan(Config c, std::string&& sequence);

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 will gain an accessor to consume the contained string, presented as an overload set with the existing accessor:

const std::string& str() const &;
std::string& str() &&; 

(See TotW #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.


Subscribe to the Abseil Blog