Search code examples
c++operator-overloadingimplicit-conversion

Implicit conversion between two classes based on integral type


I have the situation where I have a class A, that provides a constructor for an integral type, and a class B that provides a implicit conversion operator for the same integral type. However, if I call a function accepting a reference to class A with an instance of class B, the compilation fails. I would have expected an implicit conversion of class B to the type accepted by the constructor of class A. Of course, if I add a constructor to A accepting class B, everything is fine. Is this behavior intended? Please checkout the example below.

#include <iostream>

class B
{
public:
        B() = default;
        B(const std::uint8_t &val) : mVal(val) {}

        std::uint8_t get() const { return mVal; }

        operator std::uint8_t() const { return mVal; }

private:
        std::uint8_t mVal;
};

class A
{
public:
        A() = default;
        A(const std::uint8_t &val) : mVal(val) {}

        // No problem if this exists
        // A(const B &b) : mVal(b.get()) {}

        std::uint8_t get() const { return mVal; }

private:
        std::uint8_t mVal;
};

void func(const A &a)
{
        std::cout << static_cast<int>(a.get()) << std::endl;
}

int main(int, char*[])
{
        std::uint8_t val = 0xCE;

        A a(val);
        B b(val);

        func(val); // fine
        func(a); // fine
        func(b); // error
}

Solution

  • There is a rule in C++ that no implicit conversion will use two user-defined conversions.

    This is because such "long-distance" conversions can result in extremely surprising results.

    If you want to be able to convert from anything that can convert to a uint8_t you can do:

    template<class IntLike,
      std::enable_if_t<std::is_convertible_v<IntLike, std::uint8_t>, bool> =true,
      std::enable_if_t<!std::is_same_v<A, std::decay_t<IntLike>>, bool> =true
    >
    A( IntLike&& intlike ):A( static_cast<std::uint8_t>(std::forward<IntLike>(intlike)) )
    {}
    

    or you could cast your B to an uint8_t at the point you want to convert to an A.

    You can do a similar thing in B where you create a magical template<class T, /*SFINAE magic*/> operator T that converts to anything that can be constructed by an uint8_t.

    This obscure code:

      std::enable_if_t<std::is_convertible_v<IntLike, std::uint8_t>, bool> =true,
      std::enable_if_t<!std::is_same_v<A, std::decay_t<IntLike>>, bool> =true
    

    exists to make sure that the overload is only used if the type we are converting from has the properties we want.

    The first enable_if clause states that we only want things that can convert to uint8_t. The second states we don't want this constructor to be used for the type A itself, even if it passes the first.

    Whenever you create a forwarding reference implicit constructor for a type, that second clause is pretty much needed or you get some other surprising issues.

    The technique used is called SFINAE or Substitution Failure Is Not An Error. When a type IntType is deduced and those tests fail, there is substitution failure in those clauses. Usually this would cause an error, but when evaluating template overloads it is not an error because SFINAE; instead, it just blocks this template from being considered in overload resolution.