Tip of the Week #176: Prefer Return Values to Output Parameters

Originally posted as TotW #176 on March 12, 2020

By Etienne Dechamps

Updated 2020-04-06

Quicklink: abseil.io/tips/176

The problem

Consider the following:

// Extracts the foo spec and the bar spec from the provided doodad.
// Returns false if the input is invalid.
bool ExtractSpecs(Doodad doodad, FooSpec* foo_spec, BarSpec* bar_spec);

Using (or implementing) this function correctly requires the developer to ask themselves a surprising number of questions:

  • Are foo_spec and bar_spec out or in/out parameters?
  • What happens to pre-existing data in foo_spec or bar_spec? Is it appended to? Is it overwritten? Does it make the function CHECK-fail? Does it make it return false? Is it undefined behavior?
  • Can foo_spec be null? Can bar_spec? If they cannot, does a null pointer make the function CHECK-fail? Does it make it return false? Is it undefined behavior?
  • What are the lifetime requirements on foo_spec and bar_spec? In other words, do they need to outlive the function call?
  • If false is returned, what happens to foo_spec and bar_spec? Are they guaranteed to be unchanged? Are they “reset” in some way? Is it unspecified?

One cannot answer any of these questions from the function signature alone, and cannot rely on the C++ compiler to enforce these contracts. Function comments can help, but often don’t. This function’s documentation, for example, is silent on most of these issues, and is also ambiguous about what “input” means. Does it refer only to doodad, or to the other parameters too?

Furthermore, this approach inflicts boilerplate on every callsite: the caller has to allocate FooSpec and BarSpec objects in advance in order to call the function.

In this case, there’s a simple way to eliminate the boilerplate and encode the contracts in a way that the compiler can enforce.

The solution

Here’s how to make all these questions moot:

struct ExtractSpecsResult {
  FooSpec foo_spec;
  BarSpec bar_spec;
};
// Extracts the foo spec and the bar spec from the provided doodad.
// Returns nullopt if the input is invalid.
std::optional<ExtractSpecsResult> ExtractSpecs(Doodad doodad);

This new API is semantically the same, but it is now much harder to misuse:

  • It is clearer what the inputs and outputs are.
  • There are no questions about pre-existing data in foo_spec and bar_spec because they are created from scratch by the function.
  • There are no questions about null pointers because there are no pointers.
  • There are no questions about lifetimes because everything is passed and returned by value.
  • There are no questions about what happens to foo_spec and bar_spec in case of failure because they cannot even be accessed if nullopt is returned.

This in turn reduces the likelihood of bugs and reduces cognitive load on the developer.

There are other benefits, too. For example the function is more easily composable; that is, it can easily be used as part of a wider expression, e.g. SomeFunction(ExtractSpecs(...)).

Caveats

  • This approach doesn’t work for in-out parameters.
    • In some cases it is possible to use a variant of this approach whereby the parameter is taken by value, mutated, and then returned by value. Whether this is a good idea or not depends on how the function is used and whether the value is efficiently movable (Tip #117).
  • This approach doesn’t let the caller easily customize the creation of the returned objects.
    • For example, if FooSpec and BarSpec are protos, the out-parameter approach lets the caller allocate those protos on a particular arena if they wish. In the return-values approach, the arena would need to be specified as an additional parameter or the callee would have to know it already.
  • Performance might differ depending on which approach you choose and the specifics of the situation.
    • In some cases returning values can be less efficient, for example if it leads to repeated allocation in a loop.
    • In other cases, returning values might be more efficient than you think, thanks to (N)RVO (Tip #11, Tip #166). It might even be more efficient than output parameters because the optimizer doesn’t have to worry about aliasing.
    • As always, avoid premature optimization. Choose the API that makes the most sense, and only cater to performance if you have evidence that it makes a difference.

Recommendations

  • Prefer return values to output parameters whenever possible. This is consistent with the style guide.
  • Use generic wrappers like std::optional to represent a missing return value. Consider returning std::variantif you need a more flexible representation with multiple alternatives.
  • Use a struct to return multiple values from a function.
    • Feel free to write a new struct specifically to represent the return value of the function if that makes sense.
    • Resist the temptation to use std::pair or std::tuple for this purpose.

Subscribe to the Abseil Blog