[Note: I'm stuck with C++17.]
I have a class template that is instantiated over a fairly large number of types (~25). A few of them can correctly be converted among one another, and I would like to enable that to happen implicitly. But I don't want the compiler to attempt to convert the others and wind up with errors.
My idea was to build a conversion operator into the template that relies on a user-defined free function that actually does the conversion; that free function would be defined only for the types where conversion should happen. Here's a simple example:
#include <type_traits>
template <typename T>
class Template;
class SourceType {};
class TargetType {};
class SpectatorType {};
// forward-declare the convert() so that the function template can look at it
const Template<TargetType> & convert(const Template<SourceType> &);
template <typename T>
class Template
{
public:
template <typename U>
operator const U& () const { return convert(*this); }
};
// need the Template<T> class template to be defined
// before this function is defined because we'll need to use its members
const Template<TargetType> & convert(const Template<SourceType> &)
{
// obviously not a real implementation
return *new Template<TargetType>();
}
// function to try the implicit conversion with
void TestFunc(const Template<TargetType> & t)
{}
int main()
{
Template<SourceType> src;
Template<SpectatorType> spec;
// try to explicitly call the conversion operator, should succeed
src.operator const Template<TargetType>&();
// this should succeed: const Template<SourceType>& should be implicitly convertible to const Template<TemplateType>&
TestFunc(src);
// this should fail: const Template<SpectatorType>& != const Template<SpectatorType>& and there's no implicit conversion
TestFunc(spec);
return 0;
}
Unfortunately, this does not work (try it: https://godbolt.org/z/xaazsGc8c). With -Wall
, gcc 13.3 tells me that there's an infinite recursion:
sfinae-conv-op2.cpp: In member function ‘Template<T>::operator const U&() const [with U = Template<SourceType>; T = TargetType]’:
sfinae-conv-op2.cpp:19:5: warning: infinite recursion detected [-Winfinite-recursion]
19 | operator const U& () const { return convert(*this); }
| ^~~~~~~~
sfinae-conv-op2.cpp:19:48: note: recursive call
19 | operator const U& () const { return convert(*this); }
| ~~~~~~~^~~~~~~
(and if I ignore that and run the program, it segfaults, as you might expect.) Apparently when it tries to flesh out the template for TargetType
, it sees that it could build a conversion operator for itself so long as it could call convert(const Template<TargetType>)
, so it attempts to use its conversion operator to convert Template<TargetType>
to Template<SourceType>
(the wrong way around)! Then of course it needs to convert SourceType
to TargetType
, and round and round we go.
I thought I could perhaps fix this by using SFINAE to remove the wrong-direction conversion operator from the overload set (changes from previous in bold):
#include <type_traits>
template <typename T>
class Template;
class SourceType {};
class TargetType {};
class SpectatorType {};
// disallow conversion by default
template <typename S, typename T, typename = std::false_type>
const Template<T> & convert(const Template<S> &) { static_assert(false, "Not allowed"); }
// explicitly opt-in for relevant types
template <typename = std::true_type>
const Template<TargetType> & convert(const Template<SourceType> &);
// type trait for detecting that the class has a nested `type` type
template<class, class = void>
struct has_type_member : std::false_type {};
template <typename T>
struct has_type_member<T, std::void_t<typename T::type>> : std::true_type {};
template <typename T>
class Template
{
public:
template <typename U, std::enable_if_t<has_type_member<U>::value
&& std::is_invocable_v<decltype(convert<T, typename U::type>), const Template<T>&>,
int> = 0>
operator const U& () const { return convert(*this); }
using type = T;
};
// need the Template<T> class template to be defined
// before this function is defined because we'll need to use its members
const Template<TargetType> & convert(const Template<SourceType> &)
{
// obviously not a real implementation
return *new Template<TargetType>();
}
void TestFunc(const Template<TargetType> & t)
{}
int main()
{
Template<SourceType> src;
Template<SpectatorType> spec;
// try to explicitly call the conversion operator, should succeed
src.operator const Template<TargetType>&();
// this should succeed: SourceType should be implicitly convertible to TargetType
TestFunc(src);
// this should fail: there's no implicit conversion
TestFunc(spec);
return 0;
}
But for reasons I can't understand, this doesn't work either (https://godbolt.org/z/8Me79jMrh); the attempt to convert the wrong way still happens, and I still wind up in infinite recursion.
Why is the wrong conversion operator not removed from the overload set this way?
The main problem is that in original form the conversion operator
return convert(*this);
is triggering recursion twice, as call to convert
result in invoking very same conversion operator and _returning it by reference does the same. Even something stupidly straightforward like
template <typename U>
operator const U& () const {
if constexpr (!std::is_same_v< std::remove_const_t<std::remove_reference_t<decltype(*this)>>,
std::remove_const_t<std::remove_reference_t<U>>>)
return ::convert(*this);
else
return *this;
}
wouldn't work because we assume that call convert() is correct after using conversion. The program woud compile for "incorrect" type, but would crash here.
Compilers which try to unwind it, will trigger a cryptic error. For older MSVC this is quite a readable warning, infinite recursion is IFNDR so it may compile. If you can run it compiled with MSVC with disabled inlining, step-wise, you would see that.
Now an attempt to use a gobal function definition's presence as a way to detect if you can convert not only breaks incapsulation paradigm but also leads to same cyclic logic.The function can be called with conversion operator because that's an impicit conversion, so any equivalent of result_of
would succeed at substition. By using implicit coversion operator you're trying to define.
IMO whole concept is flawed and ability to convert from one to another should be defined otherwise. Again, a "stupid" way would be a define class with a specialization for you class in question would to "delay" definition of Template:
// There is no general conversion
template <typename Src, typename Dst> struct Copyable {
/* may add a nice error here if can return _something_
[[noreturn]] const Dst& dummy() {}
template <typename U = Dst>
operator const Dst& () const {
static_assert(false, "Cannot convert Src to Dst");
return dummy();
} */
};
// A definition of conversion of wrapper of SourceType
// to one of TargetType.
template<> struct Copyable<Template<SourceType>, Template<TargetType>> :
public CopyMachine<Template<SourceType>, Template<TargetType>> {};
// This is a CRTP class
template <typename T>
class Template : public Copyable<Template<T>, Template<TargetType>>
{};
What should CopyMachine do? Right, it should be able to copy and we should define it beforehand:
template<typename Src, typename Target>
class CopyMachine {
public:
operator const Target& () const {
// CRTP magic:
// cast const Src* because operator is cv-qualified.
return ::convert(*static_cast<const Src*>(this));
}
};
Other way would be to define trait types, which would have "copyable" flag for SFINAE and there is might be need for intermediate function member between convert
and operator as using SFINAE on operator, a function without return type, is problematic.
I reckon it achieves the minimal goal set by provided minimal example. If there are other challenges related to this design, that's another story.