Constrain your user-defined conversions

This blog post was first published at think-cell's developer blog. Subscribe there to stay up-to-date!

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());
    }
};

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.

However, even when all criteria are fulfilled, the conversion can still be dangerous.

For example, at think-cell, we are currently changing our string literals so they no longer have type const char[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 const char*. We thus add an implicit conversion:

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

But 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.

[over.ics.user]/1

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.

[conv.general]/1

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:

int main()
{
    string_literal str;

    if (str) {} // convert pointer to bool

    str + 1; // convert to pointer, then do arithmetic
}

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<const char*> T>
    operator T() const noexcept
    {
        return m_ptr;
    }
};

Now string_literal is implicitly convertible to any type T as long as that type is const char*. 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 const char*, we will have a substitution failure instead:

int main()
{
    string_literal str;

    if (str) {} // error: no conversion to `bool`

    str + 1; // error: no match for `operator+`
}

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:

operator std::same_as<foo> auto() const noexcept;

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()!