Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Detecting a pattern of co-dependent functions with SFINAE

I'm working on a custom, template-heavy serialization library with custom serializers. I want to be able to detect and enforce the Serializer concept in my library using SFINAE (I don't have access to a C++20 compiler with concepts support):

class CustomSerializer
{
    static T Serialize(S);
    static S Deserialize(T);
};

The idea here is that the input type of Serialize must be equal to the output type of Deserialize, and vice versa

Is this possible? If so, how?

I tried looking into std::invoke_result_t, but then you need to provide the argument types. But the argument type of Deserialize is the invoke result of Serialize, and to get the invoke result of Serialize, ...

You see the circular pattern here, I hope, which makes me wonder if it's even possible.

like image 624
Zeenobit Avatar asked May 06 '19 04:05

Zeenobit


Video Answer


2 Answers

Simple solution - check if function pointers are co-dependent

This is actually really simple to do through pattern matching. We can write a constexpr function, which I'll call checkInverse, which returns true if the types are inverted, and false otherwise:

template<class S, class T>
constexpr bool checkInverse(S(*)(T), T(*)(S)) {
    return true;   
}

template<class S, class T, class Garbage>
constexpr bool checkInverse(S(*)(T), Garbage) {
    return false;
}

Because the first case is more specialized, if it's satisfied then the function will return true, and otherwise it'll return false.

We can then use this to check if a class's Serialize and Deserialize methods match each other:

template<class T>
constexpr bool isValidPolicy() {
    return checkInverse(T::Serialize, T::Deserialize); 
}

What if we're not sure that the class has a Serialize and Deserialize method?

We can expand the isValidPolicy to check that using SFINAE. Now, it'll only return true if those methods exist, AND they satisfy the type co-dependency.

If I call isValidPolicy<Type>(0), then it'll attempt to use the int overload. If Serialize and Deserialize don't exist, it'll fall back to the long overload, and return false.

template<class Policy>
constexpr auto isValidPolicy(int)
    -> decltype(checkInverse(Policy::Serialize, Policy::Deserialize))
{
    return checkInverse(Policy::Serialize, Policy::Deserialize); 
}
template<class Policy>
constexpr auto isValidPolicy(long) 
    -> bool
{
    return false; 
}

What are the cons of this solution?

On the face of it, this seems like a good solution, although it does have a few issues. If Serialize and Deserialize are templated, it won't be able to do the conversion to a function pointer.

In addition, future users might want to write Deserialize methods that return an object that can be converted into the serialized type. This could be extremely useful for directly constructing an object into a vector without copying, improving efficiency. This method won't allow Deserialize to be written that way.

Advanced solution - check if Serialize exists for a specific type, and if the value returned by Deserialize can be converted into that type

This solution is more general, and ultimately more useful. It enables a good deal of flexibility with the way Serialize and Deserialize are written, while ensuring certain constraints (namely that Deserialize(Serialize(T)) can be converted to T).

Checking that the output is convertible to some type

We can use SFINAE to check this, and wrap it into a is_convertable_to function.

#include <utility>
#include <type_traits>

template<class First, class... T>
using First_t = First; 

template<class Target, class Source>
constexpr auto is_convertable_to(Source const& source, int) 
    -> First_t<std::true_type, decltype(Target(source))>
{
    return {};
}

template<class Target, class Source>
constexpr auto is_convertable_to(Source const& source, long) 
    -> std::false_type
{
    return {}; 
}

Checking if a type represents a valid Serializer

We can use the above conversion checker to do this. This will check it for a given type, which has to be passed as a parameter to the template. The result is given as a static bool constant.

template<class Serializer, class Type>
struct IsValidSerializer {
    using Serialize_t = 
        decltype(Serializer::Serialize(std::declval<Type>())); 
    using Deserialize_t = 
        decltype(Serializer::Deserialize(std::declval<Serialize_t>()));

    constexpr static bool value = decltype(is_convertable_to<Type, Deserialize_t>(std::declval<Deserialize_t>(), 0))::value; 
};

An example of a lazy deserializer

I mentioned before that it's possible to rely on overlading the conversion operator for serialization / deserialization. This is an extremely powerful tool, and it's one we can use to write lazy serializers and deserializers. For example, if the serialized representation is a std::array of char, we could write the lazy deserializer like so:

template<size_t N>
struct lazyDeserializer {
    char const* _start;
    template<class T>
    operator T() const {
        static_assert(std::is_trivially_copyable<T>(), "Bad T"); 
        static_assert(sizeof(T) == N, "Bad size"); 
        T value;
        std::copy_n(_start, N, (char*)&value);
        return value; 
    }
};

Once we have that, writing a Serialize policy that works with any trivially copyable type is relatively straight-forward:

#include <array>
#include <algorithm>

class SerializeTrivial {
   public:
    template<class T>
    static std::array<char, sizeof(T)> Serialize(T const& value) {
        std::array<char, sizeof(T)> arr;
        std::copy_n((char const*)&value, sizeof(T), &arr[0]); 
        return arr;
    } 

    template<size_t N>
    static auto Deserialize(std::array<char, N> const& arr) {
        return lazyDeserializer<N>{&arr[0]}; 
    }
};
like image 123
Alecto Irene Perez Avatar answered Nov 07 '22 16:11

Alecto Irene Perez


Introspection of a set of overloaded function or of a template function is rather limited. But in the case where Serialize and Deserialize would not be overloaded, it is possible to get the return type and argument type of these functions, for example:

template<class Arg,class Ret>
auto get_arg_type(Ret(Arg)) -> Arg;
template<class Arg,class Ret>
auto get_ret_type(Ret(Arg)) -> Ret;

class CustomSerializer
{
    public:
    static int Serialize(double);
    static double Deserialize(int);
};

//Concept check example:
static_assert(std::is_same_v<decltype(get_arg_type(CustomSerializer::Serialize))
                            ,decltype(get_ret_type(CustomSerializer::Deserialize))>);
static_assert(std::is_same_v<decltype(get_ret_type(CustomSerializer::Serialize))
                            ,decltype(get_arg_type(CustomSerializer::Deserialize))>);

In my opinion this solution is not adequate because the concept will fail for wrong reasons (when Serialize or Deserialize are template and or overloaded).

A mitigation could be to use a trait, so the user may be able to specialize the trait when its type provides functionalities not taken into account in your library:

class CustomSerializer
{
    public:
    static int Serialize(double);
    static int Serialize(char);
    static double Deserialize(int);
};

template<class T>
struct serialize_from{
  using type = decltype(get_arg_type(T::Serialize));
  };
template<class T>
using serialize_from_t = typename serialize_from<T>::type;

template<>
struct serialize_from<CustomSerializer>{
    using type = double;
};

//The concept check use the trait:
static_assert(std::is_same_v<decltype(CustomSerializer::Deserialize(
                               CustomSerializer::Serialize(
                                 std::declval<const serialize_from_t<CustomSerializer>&>())))
                            ,serialize_from_t<CustomSerializer>>);
//N.B.: You should also provide a serialize_to trait, here the concept
//check a convertibility where you expect a type equality... but the code
//would be too long for this answer.
like image 44
Oliv Avatar answered Nov 07 '22 17:11

Oliv