9
\$\begingroup\$

I'm writing a callback wrapper class for an embedded application in C++11.

The basic idea of the class is to be able to use it as a replacement instead of C-style callback functions, with the added benefit of being able to wrap functors and lambdas.

Requirements

  • No dynamic memory allocations are allowed at all.
  • It only needs to handle small functors (and lambdas).
  • Return type of the wrapped functions is always void.
  • It should be able to wrap C-style callback functions too.

Some implementation details

Basically, the class holds a function pointer with an optional user-specified parameter that is passed in as the first argument to the function pointer. It also has a small statically-allocated byte array in order to be able to wrap small functors and lambdas.

The wrapping is done in a templated operator= function (and a constructor) overload which simply copies the state of the functor (or lambda), and then sets the function pointer to a function that calls it.

The code

Here is the implementation of the class.
I also wrote some Doxygen-style API docs, hope it's okay to keep them intact here.

/**
 * Callback is a functor class that helps wrapping callback functions.
 *
 * It stores a function pointer and an optional user-specified parameter. The
 * function pointer has a signature that takes the user-specified parameter first,
 * and then the specified argument types.
 *
 * It also provides a convenient way to wrap C++ lambda functions, even capturing
 * lambdas into a callback.
 *
 * Template parameters of this class are the argument types of the callback.
 */
template <typename ... TArgs>
class Callback final {

public:

    /**
     * The function pointer type that is stored in this class.
     */
    typedef void (*Function)(void *, TArgs ...);

    /**
     * The function pointer type without the user parameter that is stored in this class.
     */
    typedef void (*SimpleFunction)(TArgs ...);

private:

    /**
     * The function pointer that is executed by the current Callback instance. Default is nullptr.
     */
    Function func_;

    /**
     * The user pointer that is passed as the first argument to the function pointer.
     */
    void *userPtr_;

    /**
     * If the callback is a wrapped functor (eg. a capturing lambda function), its data is stored here.
     */
    uint8_t functorData_[16];

public:

    /**
     * Initializes an empty Callback instance.
     */
    explicit inline Callback() : func_(nullptr), userPtr_(nullptr) {
        this->operator=(nullptr);
    }

    /**
     * Copy constructor that copies the state of another Callback instance.
     */
    inline Callback(const Callback<TArgs...> &other) : Callback() {
        this->operator=(other);
    }

    /**
     * Initializes a Callback instance from a functor object (eg. a capturing lambda function).
     */
    template <typename TFunctor>
    inline Callback(const TFunctor &functor) : Callback() {
        this->operator=(functor);
    }

    /**
     * Sets the callback function and user pointer.
     */
    inline Callback &operator=(const ::std::tuple<Function, void*> &stuff) {
        this->func_ = stuff.first;
        this->userPtr_ = stuff.second;
        ::std::memset(this->functorData_, 0, sizeof(this->functorData_));

        return *this;
    }

    /**
     * Sets the callback function and user pointer by wrapping
     * a functor object (eg. a capturing lambda function).
     */
    template <typename TFunctor>
    inline Callback &operator=(const TFunctor &functor) {
        // Compile-time check to see if the passed functor can be held by the Callback class
        static_assert(::std::is_assignable<::std::function<void(TArgs...)>, TFunctor>::value, "Invalid functor object passed to Callback.");
        static_assert(sizeof(functorData_) >= sizeof(TFunctor), "Functor object is too big.");

        // Copy the functor
        ::std::memcpy(this->functorData_, &functor, sizeof(TFunctor));

        // Set the user pointer to the copied functor
        this->userPtr_ = (void*) this->functorData_;

        // Create a simple function that calls the functor
        this->func_ = [](void *user, TArgs ... args) -> void {
            TFunctor *functorPtr = reinterpret_cast<TFunctor*>(user);
            (*functorPtr)(args...);
        };

        return *this;
    }

    /**
     * Sets the callback function and user pointer by wrapping
     * a simple C function pointer (without the user parameter).
     */
    inline Callback &operator=(SimpleFunction function) {
        this->operator=([function] (TArgs ... args) -> void {
            function(args...);
        });

        return *this;
    }

    /**
     * Clears the Callback object.
     */
    inline Callback &operator=(nullptr_t) {
        this->func_ = nullptr;
        this->userPtr_ = nullptr;
        ::std::memset(this->functorData_, 0, sizeof(this->functorData_));

        return *this;
    }

    /**
     * Copies the state of another Callback instance.
     */
    inline Callback &operator=(const Callback<TArgs...> &other) {
        this->func_ = other.func_;
        this->userPtr_ = other.userPtr_;
        ::std::memcpy(this->functorData_, other.functorData_, sizeof(this->functorData_));

        return *this;
    }

    /**
     * Executes the callback function. If the Callback is empty,
     * it simply does nothing. No exception is thrown.
     *
     * The specified user pointer is passed as the first argument
     * to the callback function.
     */
    inline void operator()(TArgs ... args) {
        if (this->func_ != nullptr) {
            this->func_(this->userPtr_, args ...);
        }
    }

    /**
     * Compares the current Callback instance to a null pointer,
     * meaning that it returns true if the Callback is empty.
     */
    inline bool operator==(nullptr_t) {
        return nullptr == this->func_;
    }

    /**
     * Compares the current Callback instance to a null pointer,
     * meaning that it returns true if the Callback is NOT empty.
     */
    inline bool operator!=(nullptr_t) {
        return nullptr != this->func_;
    }

    /**
     * Compares two Callback instances and returns true if they
     * are equal.
     */
    inline bool operator==(const Callback &other) {
        return  (this->func_ == other.func_) &&
                (this->userPtr_ == other.userPtr_) &&
                (0 == ::std::memcmp(this->functorData_, other.functorData_, sizeof(this->functorData_)));
    }

    /**
     * Converts the Callback into a boolean value.
     * Returns false if the Callback is empty, true otherwise.
     */
    inline operator bool() {
        return this->operator !=(nullptr);
    }

};

Intended use

Let's assume you have an MCU (microcontroller unit) and have a SerialPort class which knows how to send and receive data (ie. it manipulates the registers of the MCU to control the port).

class SerialPort {

    // ... etc.

public:

    virtual void startTransmit(const void *buffer, uint32_t length) = 0;
    virtual void startReceive(void *buffer, uint32_t length) = 0;
    // ...

};

This is cool, but it would be nice if this class could tell you what it received, wouldn't it?

Now you could do this by adding a function pointer:

private:
    void (*receiveCallback)(const void *buffer, uint32_t length);
public:
    inline void setReceiveDoneCallback(const decltype(receiveCallback) &cb) {
        this->receiveDoneCallback = cb;
    }

Then you „only” have to call the receiveCallback whenever the port has received some data. (For example, from an interrupt service routine, but let's omit the hardware-specific code now for brevity.)

However, this C-style callback mechanism has some serious drawbacks, most notably the fact that using this scheme it is impossible to tell anything about the context of the callback site. Assuming that multiple instances of the SerialPort can exist, C-style callbacks are very hard to use from object-oriented code.

Better C libraries extend the above scheme with a void* „user pointer” that is passed to the called function, making the life of everyone easier. It still remains inconvenient because you can't assign capturing lambdas or functors.

Let's now use the Callback class instead of a function pointer:

private:
    Callback<const uint8_t*, uint32_t> receiveDoneCallback;
public:
    template <typename T>
    inline void setReceiveDoneCallback(const T &cb) {
        this->receiveDoneCallback = cb;
    }

Then the usage of the SerialPort becomes easier:

SerialPort port (...);
// Hardware-specific setup omitted for brevity...

port.setReceiveDoneCallback([&](const void* buffer, uint32_t length) -> void {
    // You can use anything from the context, have access to the `this` ponter, etc.
});

Other notes

  • This code will run on a bare-metal microcontroller, without an operating system.
  • I'm somewhat afraid to “just use” std::function for this purpose because it uses dynamic memory allocations (and its implementation looks scarily complicated).
  • (I've also seen other implementations of the same concept on Code Review, but none of those seem suitable for my use case.)
\$\endgroup\$
2

1 Answer 1

6
\$\begingroup\$

A few thoughts about your code:

1. Use of reinterpret_cast

    // Create a simple function that calls the functor
    this->func_ = [](void *user, TArgs ... args) -> void {
        TFunctor *functorPtr = reinterpret_cast<TFunctor*>(user);
        (*functorPtr)(args...);
    };

This always raises a red flag for me. I understand that the user parameter is used to pass the actual functor instance, and it's a common technique to interface with C-style callback API's.

May be it deserves more commenting why exactly it will be used here, and what exactly user is expected to carry.

2. Using a fixed size for the functorData_ member

This makes your class less flexible for generic usage.

You could use an additional template parameter to specify the size

template <size_t MaxFunctorSize, typename ... TArgs>
class Callback final {
    // ...
    uint8_t functorData_[MaxFunctorSize];
};

and use that with the static_asserts etc.

At least the callback holder could decide then, how many stack storage should me reserved as maximum.

The downside is, that the maximum size cannot be defaulted to a standard value because of the variadic parameter pack must appear as the last parameter.

3. Wasting memory for sake of making client code easier to write

I'm not a 100% sure that wasting (stack) space is worth that (especially when dealing with a small MCU at the bare metal level).

May be the design could be improved, and less memory intrusive with bit a more complicated SFINAE based class model to handle the different cases

  • Simple C-Style function pointer
  • Functor object / Lambda expression

4. Potential slicing problems with functor instances

I smell potential slicing problems with virtual polymorphic functor class hierarchies.

The functor interface using functorData_ may fail to work properly with virtual inheritance:

struct IFunctor {
    virtual void operator()(const uint8_t*, uint32_t) = 0;
    virtual ~IFunctor() = default;
};

struct AFunctor : IFunctor {
    virtual void operator()(const uint8_t*, uint32_t) {
        // Do something
    }
};

void doSomething() {
    SerialPort port;
    AFunctor aFunc;
    IFunctor* iFunc = &aFunc;
    port.setReceiveDoneCallback(*iFunc);
 }

Here is a Demo

You can avoid that adding another static_assert:

static_assert(!std::is_polymorphic<TFunctor>(),"Functor object must not be polymorphic.");

Demo

Note that this is related to my points 1. and 2.

5. The use of inline is superfluous inside the class declaration body

As mentioned above, you don't even need to use inline inside of the class body declaration. That's merely a hint for the compiler, and will be done (or overridden) regarding the chosen optimization strategy anyways.

The only case you must use the inline keyword is, if you're defining global functions in a multiply included header file. Otherwise you are going to violate the One Definition Rule principle.

Regarding GCC's behavior, it's documented here (emphasis mine):

GCC does not inline any functions when not optimizing unless you specify the ‘always_inline’ attribute for the function, like this:

 /* Prototype.  */
inline void foo (const char) __attribute__((always_inline));

Otherwise, it depends on optimization settings.

6. The use of final is superfluous

Since your class doesn't expose any virtual functions that could be potentially overridden by inheriting classes, you simply can omit it.

See also: In C++, when should I use final in virtual method declaration?

\$\endgroup\$
20
  • \$\begingroup\$ inline is not related to optimization at all, not even is this a hint (at least per the Standard). It's instead a means of avoiding ODR violations. \$\endgroup\$
    – Ruslan
    Jan 8, 2017 at 15:03
  • \$\begingroup\$ @Ruslan I mentioned that clearly IMO? \$\endgroup\$ Jan 8, 2017 at 15:04
  • \$\begingroup\$ You say "That's merely a hint for the compiler". It's not. Either you violate ODR, or you don't. \$\endgroup\$
    – Ruslan
    Jan 8, 2017 at 15:05
  • 1
    \$\begingroup\$ @Ruslan As whatever hint the compiler is willing to take it. What's the concrete point of your nitpick? Do you have an actual proposal for improving the statement? \$\endgroup\$ Jan 8, 2017 at 15:08
  • 1
    \$\begingroup\$ Your overly-pedantic comments ignore the realities of the world, @ruslan. There are compilers for which the inline keyword is a hint to actually inline the definition of the function in the caller. Clang and MSVC are among them. You can verify this for yourself with Clang, since it is open source. Naturally, as πάντα said, it's just a hint, and the optimizer will make the final decision, but it is a hint that is taken into account. And the language standard does not forbid that. That said, I agree that its primary purpose (and the one you should think of) is to avoid ODR violations. \$\endgroup\$ Jan 8, 2017 at 17:20

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.