Search code examples
c++contravariance

Contravariance for templated callback parameter like in C#


The set-up

Consider two types, one of which inherits from the other:

#include <iostream>

class shape {  };
class circle : Shape {  };

And two functions which accept an object of that type, respectively:

void shape_func(const shape& s) { std::cout << "shape_func called!\n"; }
void circle_func(const circle& c) { std::cout << "circle_func called!\n"; }

The function template

Now I want a function template, to which I can pass:

  1. An object of one of those types (or others like it).
  2. A pointer to one of those functions (or others like it) that is compatible with the passed object.

The following is my attempt at declaring this function template:

template<class T>
void call_twice(const T& obj, void(*func)(const T&))
{
    func(obj);
    func(obj);
}

(In practice its body would do something more useful, but for demonstration purpose I simply let it call the passed function with the passed object twice.)

Observed behavior

int main() {
    shape s;
    circle c;

    call_twice<shape>(s, &shape_func);   // works fine
    call_twice<circle>(c, &circle_func); // works fine
    //call_twice<circle>(c, &shape_func);  // compiler error if uncommented
}

Expected behavior

I was hoping that the third call would also work.

After all, since shape_func accepts any shape, it also accepts a circle — so by substituting circle for T it should be possible for the compiler to resolve the function template without conflicts.

In fact, this is how the corresponding generic function behaves in C#:

// C# code
static void call_twice<T>(T obj, Action<T> func) { ... }

It can be called as call_twice(c, shape_func) without problem because, to say it in C# lingo, the type parameter T of Action<T> is contravariant.

Question

Is this possible in C++?

That is, how would the function template call_twice have to be implemented in order to accept all three of the calls in this example?


Solution

  • One way to do this is via SFINAE, best shown by example:

    #include <iostream>
    #include <type_traits>
    
    struct Shape {};
    struct Circle : public Shape {};
    
    template<class Bp, class Dp>
    std::enable_if_t<std::is_base_of<Bp,Dp>::value,void>
    call_fn(Dp const& obj, void (*pfn)(const Bp&))
    {
        pfn(obj);
    }
    
    void shape_func(const Shape& s) { std::cout << "shape_func called!\n"; }
    void circle_func(const Circle& s) { std::cout << "circle_func called!\n"; }
    
    int main()
    {
        Shape shape;
        Circle circle;
    
        call_fn(shape, shape_func);
        call_fn(circle, circle_func);
        call_fn(circle, shape_func);
    }
    

    Output

    shape_func called!
    circle_func called!
    shape_func called!
    

    How It Works

    This implementation uses a simple (perhaps too much so) exercise utilizing std::enable_if in conjunction with std::is_base_of to provide qualified overload resolution with potentially two different types (one of the object, the other of the provide function's argument list). Specifically, this:

    template<class Bp, class Dp>
    std::enable_if_t<std::is_base_of<Bp,Dp>::value,void>
    call_fn(Dp const& obj, void (*pfn)(const Bp&))
    

    says that this function template requires two template arguments. If they're either the same type OR Bp is somehow a base of Dp, then provide a type (in this case void). We then use that type as the result type of our function. Therefore, for the first invocation, the resulting instantiation looks like this after deduction:

    void call_fn(Shape const& obj, void (*pfn)(const Shape&))
    

    which was what we desired. A similar instantiation results from the second invocation:

    void call_fn(Circle const& obj, void (*pfn)(const Circle&))
    

    The third instantiation will produce this:

    void call_fn(Circle const& obj, void (*pfn)(const Shape&))
    

    because Dp and Bp are different, but Dp is a derivative.


    Failure Case

    To see this fail (as we want it to do so), modify the code with non-related types. Simply remove Shape from the base-class inheritance list of Circle:

    #include <iostream>
    #include <type_traits>
    
    struct Shape {};
    struct Circle {};
    
    template<class Bp, class Dp>
    std::enable_if_t<std::is_base_of<Bp,Dp>::value,void>
    call_fn(Dp const& obj, void (*pfn)(const Bp&))
    {
        pfn(obj);
    }
    
    void shape_func(const Shape& s) { std::cout << "shape_func called!\n"; }
    void circle_func(const Circle& s) { std::cout << "circle_func called!\n"; }
    
    int main()
    {
        Shape shape;
        Circle circle;
    
        call_fn(shape, shape_func);   // still ok.
        call_fn(circle, circle_func); // still ok.
        call_fn(circle, shape_func);  // not OK. no overload available, 
                                      // since a Circle is not a Shape.
    }
    

    The result would be no-matching function call for the third invoke.