Constrain your user-defined conversions

Sometimes you want to add an implicit conversion to a type. This can be done by adding an implicit conversion operator. For example, std::string is implicitly convertible to std::string_view:

class string { // template omitted for simplicity
public:
    operator std::string_view() const noexcept {
       return std::string_view(c_str(), size());
    }
};
Simplified definition of std::string's conversion operator

The conversion is safe, cheap, and std::string and std::string_view represent the same platonic value — we match Tony van Eerd's criteria for implicit conversions and using implicit conversions is justified.

At think-cell, we are currently changing our string literals so they no longer have type char const[N], but a custom type (more on that in a future post). Let's call it string_literal for now. For backwards compatibility and convenience, we want to be able to use string_literal as arguments to functions that currently take a char const*. We thus add an implicit conversion:

class string_literal {
public:
    operator const char*() const noexcept {
        return m_ptr;
    }
};
Initial definition of string_literal.

However, unlike std::string's conversion operator, this is not a good idea because we return a built-in a type and conversions can be chained in a so-called user-defined conversion sequence.

A user-defined conversion sequence consists of an initial standard conversion sequence followed by a user-defined conversion ([class.conv]) followed by a second standard conversion sequence.

A standard conversion sequence is a sequence of standard conversions in the following order:

  • Zero or one conversion from the following set: lvalue-to-rvalue conversion, array-to-pointer conversion, and function-to-pointer conversion.
  • Zero or one conversion from the following set: integral promotions, floating-point promotion, integral conversions, floating-point conversions, floating-integral conversions, pointer conversions, pointer-to-member conversions, and boolean conversions.
  • Zero or one function pointer conversion.
  • Zero or one qualification conversion.

The second standard conversion sequence in particular can be problematic as it applies to the result of the conversion operator:

string_literal str = …;
if (str) { // convert pointer to bool
    …
}
str + 1; // convert to pointer, then do arithmetic
Undesired implicit conversions

This is often undesired—we don't want our string type to act like a pointer itself, we just want it to be implicitly convertible to one when initializing a pointer argument.

Luckily, we can fix it and prevent the second standard conversion sequence by (ironically) templating the conversion operator and constraining the template parameter:

class string_literal {
public:
    template <std::same_as<char const*> T>
    operator T() const noexcept {
        return m_ptr;
    }
};
Fixed definition of string_literal

Now string_literal is implicitly convertible to any type T as long as that type is char const*. What's the difference to the previous version? Overload resolution will not consider a second standard conversion sequence because it can directly plug-in the final destination type. If that type isn't char const*, we will have a substitution failure instead:

string_literal str = …;
if (str) { // error: no conversion from `string_literal` to `bool`
    …
}
str + 1; // error: no match for `operator+`
No undesired implicit conversions

I thus propose the following guideline:

One downside is that the conversion operator is now a template even though it only ever returns a single type. So if you have a long definition in the body of the implicit conversion operator, you have to move it to the header. But why are you defining complex implicit conversions in the first place?!

You might be tempted to simplify the definition of the conversion operator:

operator std::same_as<foo> auto() const noexcept;
Wrong definition of a constrained conversion operator

However, this is not a template: It is a non-template function with a deduced return type that is constrained to model the concept std::same_as<foo>. You thus have the exact behavior as operator foo()!

— by Jonathan Müller

Do you have feedback? Send us a message at devblog@think-cell.com !

Sign up for blog updates

Don't miss out on new posts! Sign up to receive a notification whenever we publish a new article.

Just submit your email address below. Be assured that we will not forward your email address to any third party.

Please refer to our privacy policy on how we protect your personal data.

Share