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
:
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:
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:
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:
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:
I thus propose the following guideline:
When writing an implicit conversion operator to a type foo
, write it as a template and constrain the type to be the same as foo
:
template <std::same_as<foo> T>
operator T() const noexcept;
That way, you prevent additional implicit conversions in the user-defined conversion sequence. This is especially important if foo
is a built-in type.
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:
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()
!
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.