Jonathan Boccara's blog

Defaulted: A Helper to Work Around the Constraints of C++ Default Parameters

Published August 14, 2018 - 6 Comments

Over the posts of the series on default parameters, we’ve come across two constraints of default parameters in C++.

The first one is that all the default parameters have to be at the end of the arguments list of a function. This can make an interface less natural, because arguments are no longer grouped in a logical order. Instead, they are grouped in a technical order: the non-default parameters first, then the default ones, which can be confusing at call site.

The second constraint is their interdependence: if there are several default parameters , and a call site wants to pass a value for only one of them, it also has to provide a value for all the other default parameters preceding it in the parameters list of the function. This again makes for awkward call sites.

Let me share with you this small component, Defaulted, which aims at working around those two constraints.

I’m not saying it’s perfect, far from that, I consider it rather experimental. But by showing it to you I’m hoping to trigger reflections on how to write clearer code with default parameters, collecting feedback if you have some, and – if you do find it interesting – provide a component that you can use in your code.

This is another part of our series on default parameters:

We see first how to use Defaulted, then get into its implementation.

The basic usage of Defaulted

Placing default parameters between other parameters

Imagine that we have a function f taking 3 parameters xy and z, where we want to give the default value 42 to the parameter y. To achieve this in C++, we have to put y as the last parameter:

void f(int x, int z, int y = 42)
{
    std::cout << "x = " << x << '\n'
              << "y = " << y << '\n'
              << "z = " << z << '\n';
}

And if we call it this way:

f(0, 1);

The program outputs:

x = 0
y = 42
z = 1

Fine. Now does it make sense to group those parameters in the order x, z and then y? This toy example couldn’t tell, but in some cases shuffling the parameters around just for the technical reason of adding a default value sometimes makes for an order that is unnatural. Say that in our case, it’s more natural to pass the parameters in the order x, y and then z.

Here is how to keep this order by using Defaulted:

void f(int x, Defaulted<int, 42> y, int z)
{
    std::cout << "x = " << x << '\n'
              << "y = " << y.get_or_default() << '\n'
              << "z = " << z << '\n';
}

What this interface is supposed to express is that y is an int, that could be defaulted to the value 42. Here is how to use it at call site:

f(0, defaultValue, 1);

defaultValue is a special value coming along with Defaulted (a bit like std::nullopt that comes along with std::optional).

This call site expresses that it won’t take the responsibility of specifying the value of y. Rather, it leaves it to the “defaultValue” of the interface. Like the regular default value in C++.

This program outputs:

x = 0
y = 42
z = 1

But like native default parameters, you could also pass an actual value:

f(0, 55, 1);

which outputs:

x = 0
y = 55
z = 1

Specifying the value of only one default parameter

Let’s say that our function f has not one but two default parameters:

void f(int x, int y = 42, int z = 43)
{
    std::cout << "x = " << x << '\n'
              << "y = " << y << '\n'
              << "z = " << z << '\n';
}

Like we mentioned at the opening of this article, the annoying thing with multiple default parameters is that you can’t just provide a value for only one parameter, if it has other default parameters before it. For example, if we wanted to pass 1 for the parameter z, we would have to write the default value of y (which is 42 here) in the calling code:

f(0, 42, 1);

And this is a problem, because it forces the calling code to take the responsibility of the value of y, even though the interface was proposing a default one that the call site would have been happy with. It makes it harder to change the default value of y in the interface in the future, because we’d have to chase all the call sites that passed it explicitly. And even then, we wouldn’t know if these call sites wanted to use the default value of y, or specifically 42.

Defaulted proposes another way to deal with this:

void f(int x, Defaulted<int, 42> y, Defaulted<int, 43> z)
{
    std::cout << "x = " << x << '\n'
              << "y = " << y.get_or_default() << '\n'
              << "z = " << z.get_or_default() << '\n';
}

In this case the interface no longer relies on the native default parameters. So we can pass specific values (here, 1) for parameters even if they are preceded by other default parameters:

f(0, defaultValue, 1);

Values that won’t fit into a template

All the above examples use ints to demonstrate the purpose of Defaulted. But ints also have this nice property that they can be passed as template arguments:

Defaulted<int, 42> // the second argument is not a type, it's an int

What if we wanted to use a double, a std::string or a user-defined Employee? These can’t fit as template arguments:

Defaulted<double, 42.6> // doesn't compile, can't pass a
                        // floating point number as a template argument

One way to work around that is to define a function that returns the default value, and wrap it in a type:

struct GetDefaultAmount{ static double get(){ return 45.6; } };

And then pass this type as a template argument. Indeed, we can pass any type as a typename template argument.

But then we need another component, similar to Defaulted but that takes a function (wrapped into a type) instead of a value. Let’s call this component DefaultedF.

We’ll get to its implementation in just a moment, but here is how we would use it in a function taking a default value for a double parameter:

struct GetDefaultAmount{ static double get(){ return 45.6; } };

void g(int x, DefaultedF<double, GetDefaultAmount> y, int z)
{
    std::cout << "x = " << x << ';'
              << "y = " << y.get_or_default() << ';'
              << "z = " << z << ';';
}

Instead of directly taking a value, DefaultedF takes a type representing a function that returns that value. This lets it go around the constraints of the templates parameter of not accepting all types.

Its call site, though, is similar to the one of Defaulted:

g(0, defaultValue, 1);

Which outputs:

x = 0
y = 45.6
z = 1

The particular case of the default default value

A pretty common case for default parameters is when they take the value resulting from a call to the default constructor of their type: T().

To make this easier to express in an interface, we can adopt the convention that if no value is passed in the Defaulted template, then it falls back to calling the default constructor of its underlying type, for a default value:

void h(int x, Defaulted<std::string> y, int z)
{
    std::cout << "x = " << x << ';'
              << "y = " << y.get_or_default() << ';'
              << "z = " << z << ';';
}

The following call:

std::string word = "hello";

h(0, word, 1);

outputs:

x = 0
y = hello
z = 1

While a call using the default value:

h(0, defaultValue, 1);

would output this:

x = 0
y = 
z = 1

because a default constructed std::string is an empty string.

Passing default parameters by const reference

The default parameters that take a default constructed value can be passed by const reference in C++:

void h(int x, int z, std::string const& y = std::string())

This const reference can either bind to the temporary object created by std::string() if the call site doesn’t pass a value, or it can bind to the value passed by the call site.

To achieve a similar behaviour with Defaulted, we can make it wrap a const reference:

void h(int x, Defaulted<std::string const&> y, int z)
{
    std::cout << "x = " << x << ';'
              << "y = " << y.get_or_default() << ';'
              << "z = " << z << ';';
}

which avoids making a copy of the parameter passed, when there is one.

The implementation of Defaulted

Before implementing Defaulted, let’s create a specific type for defaultValue, that Defaulted will recognize:

struct DefaultValue{};
static const DefaultValue defaultValue;

Here is one implementation of Defaulted:

template<typename T, T... DefaultedParameters> // 1-
class Defaulted
{
public:
    Defaulted(T t) : value_(std::move(t)){} // 2-
    Defaulted(DefaultValue) : value_(DefaultedParameters...) {} // 3-
    T const& get_or_default() const { return value_; }
    T & get_or_default() { return value_; }
private:
    T value_; // 4-
};

In case the call site passes an actual value of type T to a Defaulted, then it acts as a wrapper that takes in this value (2-) and stores it (4-). There is an implicit conversion so that the call site doesn’t have to write “Defaulted” explicitly (2-). And if the call site passed an object of type DefaultValue, that is to say defaultValue itself, then the value stored in Defaulted is the one passed in as a template parameter (3-). The variadic pack of values (1-) allows to pass one or zero parameters.

The implementation of DefaultedF is pretty similar, except that it calls the function inside GetDefaultValue when it receives defaultValue:

template<typename T, typename GetDefaultValue>
class DefaultedF
{
public:
    DefaultedF(T t) : value_(std::move(t)){}
    DefaultedF(DefaultValue) : value_(GetDefaultValue::get()) {}
    T const& get_or_default() const { return value_; }
    T & get_or_default() { return value_; }
private:
    T value_;
};

The pros and cons of using Defaulted

The disadvantages I can see of Defaulted are that it resorts to a get function to pass non integral default parameters, that it shows a variadic pack in its interface whereas it is an implementation trick, and that it uses a implicit conversion (towards which I’m generally distrustful).

And its advantages are that it works around the two constraints of default parameters in C++: their position at the end and their interdependence.

Note that this whole issue could also be solved with a completely different approach, by using named parameters: whichever parameters didn’t get named at call site, we use their default values. But this doesn’t exist in the language. Boost has a named parameters library (which are nicely presented in the book of Boris Schäling), but that has a bigger technical impact on the interface than our specific component, as it does many more things. It’s interesting to check out anyway.

The source code of Defaulted is available on its GitHub repository.

If this article made you react about something (about the constraints on default parameters, the interface or implementation of Defaulted, or anything else), I’d love to hear your feedback!

You may also like

Don't want to miss out ? Follow:   twitterlinkedinrss
Share this post!Facebooktwitterlinkedin

Comments are closed