Tip of the week #173: Wrap arguments in Option structs

Originally published on December 19, 2019 as TotW #173

Created by John Bandela

It came without a package, box or bag. He was puzzled until his brain ached. -Dr. Seuss

Designated initializers

Designated initializers are a feature in C++20, and most compilers now support it. Designated initializers make using options structs easier and safer, since we can construct options objects at function call time. This keeps the code much shorter and avoids many problems with the temporary lifetime of the options 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{<!-- -->});

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

To learn why the options structure is helpful and specifying initializers helps avoid potential problems, read on.

The problem of passing multiple parameters

Functions that require multiple arguments to be passed can be confusing. To illustrate this, let’s consider a function that prints a floating point value.

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

This function offers a lot of flexibility because it accepts a lot of options.

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

The above code will print out: “my_value=5.00”.
However, it’s hard to read this code and know which parameter each corresponds to. For example, here we inadvertently mixed up the order of our precision and thousands_separator.
Historically, we have used parameter annotations to clarify parameter meaning at the point of call to reduce this ambiguity. Adding parameter annotations to the example above will allow ClangTidy to detect errors:

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

However, parameter annotations still have some disadvantages:

  • Not enforced: ClangTidy warnings are not caught at build time. Subtle mistakes (such as missing the = sign) can disable the check entirely without warning, providing a false sense of security.

  • Availability: ClangTidy is not supported by all projects and platforms.

It can also be tedious to specify many options, whether or not your parameters are annotated. Many times, options have sensible defaults. To fix this, we can add default values to the parameters.

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

We can now call PrintDouble with less boilerplate.

PrintDouble(5.0, "my_value=");

However, if we want to specify non-default parameters for scientific, we still need to specify values for all parameters before it:

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

We can solve all these problems by grouping all options in one options 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 name our values and have the flexibility to use default values.

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

However, there are some problems with this solution. First, we now need to add some extra boilerplate when passing options. Second, this style is more prone to temporary lifetime issues.

For example, the following code is safe when we pass all options as arguments:

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

In the above code, we created a temporary string and bound string_view to it. The temporary lifetime is the duration of the function call, so we’re safe, but using the options struct in the same way results in a dangling string_view.

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

We have two ways to solve this problem. The first is to simply change the type of prefix from string_view to string. The downside of this is that the options structure is now less efficient than passing the arguments directly. Another workaround is to add a setter member function.

class PrintDoubleOptions {<!-- -->
 public:
  PrintDoubleOptions & amp; 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 variables in calls.

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

As you can see, the tradeoff is that our options structure becomes a more complex class with more boilerplate.
Using designated initializers is a simplified alternative, as shown above.

Using std::make_unique with a designated initializer requires either explicitly mentioning the option struct type or creating a helper factory function. (This gets around the “perfect forwarding” limitation, where only values of 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});

Conclusion

  1. For functions with multiple arguments that might confuse callers or where you want to specify default arguments without worrying about their order, using the option structure should be strongly considered for convenience and code clarity.

  2. Using a designated initializer can make your code shorter and potentially avoid temporary lifetime issues when calling functions that require an options structure.

  3. Designated initializers, by virtue of their brevity, further favor functions with options structures over functions with many parameters.