2

Background

If you're looking at some codebase, and you see something like:

class A {
    virtual ~A() = default;
    virtual void act() = 0;
};

you would likely say: "Oh, this is basically just the interface for a function-like object, or functor if you will, with no inputs and no outputs. You can probably just drop the whole thing, and instead of writing:

void foo(A& a) { a.act(); }

you could just write:

template <std::invocable F> 
void foo(F& f) { f(); }

and replace all of the A-inheriting-class objects with whatever invocable object you like, with its operator() doing what the act() method was doing before.

You can also achieve the same with pre-C++20 code of course, using std::enable_if or static_assert() to ensure only invocables are passed to foo().

Actual Question

Now suppose you see the following base class definition:

class B {
   virtual ~B() = default;

   /// callbacks are invoked by act(), after concluding its internal work
   void addCallback(const CallbackType& callback);

   virtual void act();
};

i.e. it's not "just" a (stateful) functor, but also carries a sequence of callbacks.

What kind of generic idiom could now replace the function:

void foo(B& b) { b.act(); }

avoiding the bespoke class B?

Notes:

  • You may assume the exact type of b is known at compile time.
  • ... but please don't suggest an std::variant of all the descendents of B, that's not what I'm looking for.
einpoklum
  • 118,144
  • 57
  • 340
  • 684

1 Answers1

1

This post proposes a way to rewrite B as a templated class without inheritance, virtual methods, or variant types.

Basic idea: templated wrapper class

The fundamental idea is to replace A or B in the OP with a templated wrapper (personal term):

#include <concepts>
#include <utility>
#include <iostream>

// core implementation of wrapping (callbacks added later)
template<std::invocable A>
class ActionWithCallbacks {
public:
    using Action = A;

    ActionWithCallbacks(std::convertible_to<Action> auto&& a) :
        action(std::forward<decltype(a)>(a))
    {

    }

    void operator()() {
        action();
        // TODO: invoke the callbacks.
    }
private:
    Action action;
};

// CTAD rule (makes construction less verbose).
template<std::invocable Action>
ActionWithCallbacks(Action) -> ActionWithCallbacks<Action>;

// Creation & usage
int main() {
    // No need to precise the template parameter, thanks to the CTAD.
    ActionWithCallbacks awc = []() { std::cout << "Action"; };
    awc();
}

Live demo (godbolt)

All this class does (for now) is to encapsulate any std::invocable object into a custom type. We can expose as much of the wrapped object's API as we want, with the option to add side effects.

Wrappers like that are quite common in the standard library:

  • std::unique_ptr wraps a raw pointer. It changes its destructor and move semantics. It deletes its copy assign/constructor.
  • std::shared_ptr wraps a raw pointer, and adds a pointer to a reference counter. It changes its destructor and move/copy semantics.
  • std::function wraps a callable object, but for different reasons. The goal is to perform type erasure, a pattern which internally uses wrappers like this one.

Adding callbacks:

Like std::shared_ptr adds a reference counter to the wrapped raw pointer, our type can easily add a container of callbacks:

template<std::invocable A>
class ActionWithCallbacks {
public:
    using Action = A;
    
    ActionWithCallbacks(std::convertible_to<Action> auto&& a) :
        action(std::forward<decltype(a)>(a))
    {

    }

    void addCallback(std::convertible_to<CallbackType> auto&& newCb) {
        callbacks.emplace_back(std::forward<decltype(newCb)>(newCb));
    }

    void operator()() {
        action();
        for (auto& cb : callbacks) {
            cb();
        }
    }
private:
    Action action;
    std::vector<CallbackType> callbacks;
};

Rewriting foo()

If the only thing that foo requires from its argument is using the action, I would personally keep it simple:

void foo(std::invocable auto& b) { b(); }

If it needs the full API of ActionWithCallbacks, I would define a proper concept. There are 2 schools of thought:

// 1) duck-typing concept: accepts any type with the correct API.
template<typename T>
concept cActionWithCallbacks = requires (T t, CallbackType cb) {
    t.addCallback(cb);
    t();
};
// 2) type matching concept: accepts only specializations of ActionWithCallbacks
template<typename T>
concept cActionWithCallbacks = std::is_same_v<T,ActionWithCallbacks<typename T::Action>>;

// Anyway
void foo(cActionWithCallbacks auto& b) { b(); }

Full working demo

That's certainly a bit heavy on setup, but quite compact at the use point:

#include <concepts>
#include <type_traits>
#include <utility>
#include <vector>
#include <functional>
#include <iostream>

// Setup

using CallbackType = std::function<void()>;

template<std::invocable A>
class ActionWithCallbacks {
public:
    using Action = A;

    ActionWithCallbacks(std::convertible_to<Action> auto&& a) :
        action(std::forward<decltype(a)>(a))
    {

    }

    void addCallback(std::convertible_to<CallbackType> auto&& newCb) {
        callbacks.emplace_back(std::forward<decltype(newCb)>(newCb));
    }

    void operator()() {
        action();
        for (auto& cb : callbacks) {
            cb();
        }
    }
private:
    Action action;
    std::vector<CallbackType> callbacks;
};

template<std::invocable Action>
ActionWithCallbacks(Action) -> ActionWithCallbacks<Action>;

template<typename T>
concept cActionWithCallbacks = std::is_same_v<T,ActionWithCallbacks<typename T::Action>>;

// Usage

void foo(cActionWithCallbacks auto& f) {
    std::cout << "foo() start\n";
    f();
    std::cout << "foo() end\n";
}

int main() {
    int i = 0;
    ActionWithCallbacks awc = [&i]() {
        std::cout << "- Action: i = -1\n";
        i = -1;
    };
    awc.addCallback([&i]() {
        std::cout << "- Callback 1: i = i + 5\n";
        i += 5;
    });
    awc.addCallback([&i]() {
        std::cout << "- Callback 2: i = 3 * i\n";
        i *= 3;
    });
    foo(awc);
    std::cout << "main(): i == " << i << '\n';
}

Live demo

Warning: The current implementation of ActionWithCallbacks can store the Action by value, or by reference. Some might see it as a feature, some might see it as a horrible design decision causing dangling references and bugs. For the later group: static_assert(!std::is_reference_v<Action>); or std::remove_reference_t will help.

  • Your real change here seems to be a replacement of inheritance with CRTP: Instead of some class C_1 inheriting B, we would have an action C_2 and pass ActionWithCallbacks to `foo()`. While this is somewhat of an improvement, you've added a bespoke action-with-callbacks class, and implemented it. This is not what I'm after. I was hoping to use existing classes, functions, types from the standard library, with a little glue code. – einpoklum Mar 19 '23 at 15:16
  • @einpoklum 1) My code does not use [CRTP](https://en.cppreference.com/w/cpp/language/crtp) in any way (and CRTP can't replace inheritance, it uses it). 2) I suggest you update your question to include all the constraints, I thought from the comments that getting rid of runtime polymorphism was the primary goal, but forbiding to declare *any* type was unexpected. 3) Replacing `ActionWithCallback` by a lambda capturing `[=action,=vector]` might suit your needs ? – Vincent Saulue-Laborde Mar 19 '23 at 17:42