Tip of the Week #173: Wrapping Arguments in Option Structs

Originally posted as TotW #173 on December 19, 2019

By John Bandela

Updated 2020-04-06

Quicklink: abseil.io/tips/173

It came without packages, boxes or bags. And he puzzled and puzzled ‘till his puzzler was sore.

-Dr. Seuss

Designated Initializers

Designated initializers are a C++20 feature that is available in most compilers today. Designated initializers make using option structs easier and safer since we can construct the options object in the call to the function. This results in shorter code and avoids a lot of temporary lifetime issues with option structs.

struct PrintDoubleOptions {
  absl::string_view prefix = "";
  int precision = 8;
  char thousands_separator = ',';
  char decimal_separator = '.';
  bool scientific = false;
};

void PrintDouble(double value,
                 const PrintDoubleOptions& options = PrintDoubleOptions{});

std::string name = "my_value";
PrintDouble(5.0, {.prefix = absl::StrCat(name, "="), .scientific = true});

For more background on why option structs are helpful and the potential pitfalls in using them that designated initializers help avoid, read on.

The Problem of Passing Many Arguments

Functions that take many arguments can be confusing. To illustrate, let us consider this function for printing out a floating point value.

void PrintDouble(double value, absl::string_view prefix,  int precision,
                 char thousands_separator, char decimal_separator,
                 bool scientific);

This function provides us a lot of flexibility because it takes so many options.

PrintDouble(5.0, "my_value=", 2, ',', '.', false);

The above code will print out: “my_value=5.00”.

However, it is hard to read this code and know to which parameter each argument corresponds. For instance, here we have inadvertently mixed up the order of our precision and thousands_separator.

PrintDouble(5.0, "my_value=", ',', '.', 2, false);

Historically, we have used argument comments to clarify argument meanings at call sites to reduce this sort of ambiguity. The addition of argument comments to the above example would allow ClangTidy to detect the error:

PrintDouble(5.0, "my_value=",
            /*precision=*/2,
            /*thousands_separator=*/',',
            /*decimal_separator=*/'.',
            /*scientific=*/false);

However, argument comments still have several drawbacks:

  • No enforcement: ClangTidy warnings are not caught at buildtime. Subtle errors (e.g. a missing = sign) can disable the check entirely with no warning, providing a false sense of security.
  • Availability: not all projects and platforms support ClangTidy.

No matter whether your arguments are commented or not, specifying lots of options can also be tedious. Many times there are sensible defaults for the options. To address this concern, we can add defaults to the parameters.

void PrintDouble(double value, absl::string_view prefix = "", int precision = 8,
                 char thousands_separator = ',', char decimal_separator = '.',
                 bool scientific = false);

Now we can call PrintDouble with less boilerplate.

PrintDouble(5.0, "my_value=");

However, if we want to specify a non-default argument for scientific, we would still be forced to specify values for all of the parameters that come before it:

PrintDouble(5.0, "my_value=",
            /*precision=*/8,              // unchanged from default
            /*thousands_separator=*/',',  // unchanged from default
            /*decimal_separator=*/'.',    // unchanged from default
            /*scientific=*/true);

We can address all of these issues by grouping all of the options together in an option struct:

struct PrintDoubleOptions {
  absl::string_view prefix = "";
  int precision = 8;
  char thousands_separator = ',';
  char decimal_separator = '.';
  bool scientific = false;
};

void PrintDouble(double value,
                 const PrintDoubleOptions& options = PrintDoubleOptions{});

Now we can have names for our values, as well as flexibly use defaults.

PrintDoubleOptions options;
options.prefix = "my_value=";
PrintDouble(5.0, options);

There are some issues with this solution, though. First is that we now have some extra boilerplate in passing options. The second is that this style is more at risk for temporary lifetime issues.

For example, when we took all the options as parameters the following code was safe:

std::string name = "my_value";
PrintDouble(5.0, absl::StrCat(name, "="));

In the code above, we are creating a temporary string and binding a string_view to that. The temporary lifetime is the duration of the function call so we are safe, but using an options struct in the same manner, results in a dangling string_view.

std::string name = "my_value";
PrintDoubleOptions options;
options.prefix = absl::StrCat(name, "=");
PrintDouble(5.0, options);

There are two ways we can fix this. The first is to simply change the type of prefix from string_view to string. The downside of doing this is that now the option struct is less efficient than directly passing the arguments. The other way that we can fix this is to add setter member functions.

class PrintDoubleOptions {
 public:
  PrintDoubleOptions& set_prefix(absl::string_view prefix) {
    prefix_ = prefix;
    return *this;
  }

  absl::string_view prefix() const { return prefix_; }

  // Setters and getters for the other member variables.

 private:
  absl::string_view prefix_ = "";
  int precision_ = 8;
  char thousands_separator_ = ',';
  char decimal_separator_ = '.';
  bool scientific_ = false;
};

This can then be used to set the variables in the call.

std::string name = "my_value";
PrintDouble(5.0, PrintDoubleOptions{}.set_prefix(absl::StrCat(name, "=")));

As you can see, the cost is that our option struct became a more complicated class with a lot more boilerplate.

The simpler alternative is to use designated initializers as shown at the top.

Using std::make_unique with designated initializers requires explicitly mentioning the options struct type, or creating a helper factory function. (This works around a limitation of “perfect forwarding”, that only values with known types can be forwarded.)

class DoublePrinter {
  explicit DoublePrinter(const PrintDoubleOptions& options);
  static std::unique_ptr<DoublePrinter> Make(const PrintDoubleOptions& options);
  ...
};

auto printer1 = std::make_unique<DoublePrinter>(
    PrintDoubleOptions{.scientific=true});
auto printer2 = DoublePrinter::Make({.scientific=true});

Conclusions

  1. For functions which take multiple arguments which may be confused by the caller or where you want to specify default arguments without having to worry about the order, strongly consider using option structs to increase both convenience and code clarity.

  2. When calling functions that take option structs, using designated initializers can result in shorter code as well as potentially avoiding temporary lifetime issues.

  3. Designated initializers by virtue of their conciseness and clarity further tip the balance towards preferring functions that take option structs over those that have many parameters.


Subscribe to the Abseil Blog