Message dispatching with C++

December 10, 2024

Taking C++ to the skatepark

Intro

A typical receiver in C++ might look something like:

class Receiver {
private:
  void handle_message_A(MessageA const& message) {}
  void handle_message_B(MessageB const& message) {}
  void handle_message_C(MessageC const& message) {}

public:
  void dispatch(Message const& message) {
    switch (message.id) {
    case MessageId::A:
      handle_message_A(static_cast<MessageA const&>(message));
      break;
    case MessageId::B:
      handle_message_B(static_cast<MessageB const&>(message));
      break;
    case MessageId::C:
      handle_message_C(static_cast<MessageC const&>(message));
      break;
    default:
      send_fax_and_halt();
    }
  }
};

There’s nothing wrong with this. This is fast, readable and easy to understand. It feels bloaty though. Wouldn’t something like this be nice:

Receiver::Receiver() {
  register_handler(&Receiver::handle_message_A);
  register_handler(&Receiver::handle_message_B);
  register_handler(&Receiver::handle_message_C);
}

void Receiver::dispatch(Message const& message) {
  m_handlers[message.id](message);
}

The supported messages are now defined in a single place: In the function signatures of the message handlers.

Implementation

To understand how to implement this, let’s first define our messages:

enum class MessageId { A, B, C };

struct Message {
  Message(MessageId id) : id(id) {}
  MessageId id;
};

template<MessageType Id>
struct BaseMessage : Message {
  BaseMessage() : Message(Id) {}
  constexpr static auto id = Id;
};

struct MessageA : BaseMessage<MessageId::A> {};
struct MessageB : BaseMessage<MessageId::B> {};
struct MessageC : BaseMessage<MessageId::C> {};

This is nice because we have both compile time (BaseMessage::id) and runtime (Message::id) information about the message identifier, while the messages themselves are easy to write.

Now, what could a registration function look like? After all, we can’t store functions with different signatures in the same container without some form of type erasure. This is what we want to achieve:

std::map<MessageType, FunctionPointer> Receiver::m_handler;

template<typename F>
void Receiver::register_handler(F&& func) {
  m_handlers.insert({ type_of_message_handler(func), func });
}

void Receiver::dispatch(Message const& message) {
  m_handlers[message.id](message);
}

Two issues with this:

The first question might lead to the answer for the other question, so let’s tackle that one first. It’s a function that maps the type of a function to the type of the function’s first parameter. Dealing with types means template trickery in C++ land. Imagine we have some template func_traits which can “reflect” on function signatures:

template<typename F>
void Receiver::register_handler(F&& func) {
  using message_type = std::decay_t<func_traits<F>::param_at<0>>;
  static_assert(std::is_base_of_v<Message, message_type>);
		
  m_handlers.insert({ message_type::id, FunctionPointer });
}

func_traits can be easily implemented using template specialization:

template<typename T>
struct func_traits : func_traits<decltype(&T::operator())> {};

template<typename T, typename Ret, typename... Params>
struct func_traits<Ret(T::*)(Params...)> {
  using return_type = Ret;
  using parameters = std::tuple<Params...>;

  template<size_t Index>
  using param_at = typename std::tuple_element<Index, parameters>::type;
};

This is missing specializations for non-member functions and lambdas. But we don’t need that for now, so let’s ignore them.

The other riddle left is how do we got all of these different function pointers into the same container. We can make use of type erasure using lambdas:

template<typename F>
void Receiver::register_handler(F&& func) {
  using message_type = std::decay_t<func_traits<F>::param_at<0>>;
  static_assert(std::is_base_of_v<Message, message_type>);

  auto handler = [this, func](Message const& message) {
    (this->*func)(static_const<message_type const&>(message));
  };

  m_handlers.insert({ message_type::id, handler });
}

After some refactoring this is the final result:

#include <type_traits>
#include <functional>
#include <iostream>
#include <cassert>
#include <map>

// -----------------------------------

template<typename T>
struct func_traits : func_traits<decltype(&T::operator())> {};

template<typename T, typename Ret, typename... Params>
struct func_traits<Ret(T::*)(Params...)> {
  using return_type = Ret;
  using parameters = std::tuple<Params...>;

  template<size_t Index>
  using param_at = typename std::tuple_element<Index, parameters>::type;
};

// -----------------------------------

enum class MessageId { A, B, C };

struct Message {
  Message(MessageId id) : id(id) {}
  MessageId id;
};

template<MessageId Id>
struct BaseMessage : Message {
  BaseMessage() : Message(Id) {}
  constexpr static MessageId id = Id;
};

struct MessageA : BaseMessage<MessageId::A> {};
struct MessageB : BaseMessage<MessageId::B> {};
struct MessageC : BaseMessage<MessageId::C> {};

// -----------------------------------

template<typename TOwner>
class MessageDispatcher {
public:
  void dispatch(Message const& message) {
    m_handlers[message.id](message);
  }

protected:
  template<typename F>
  void register_handler(F&& func) {
    using message_type =
      std::decay_t<typename func_traits<F>::param_at<0>>;

    auto handler = [this, func](Message const& message) {
      assert(message.id == message_type::id);
      (static_cast<TOwner*>(this)->*func)(
	      static_cast<message_type const&>(message));
    };

     m_handlers.insert({ message_type::id, handler });
  }

  std::map<MessageId, std::function<void(Message const&)>> m_handlers;
};

// -----------------------------------

class Receiver : public MessageDispatcher<Receiver> {
public:
  Receiver() {
    register_handler(&Receiver::handle_A);
    register_handler(&Receiver::handle_B);
    register_handler(&Receiver::handle_C);
  }

private:
  void handle_A(MessageA const& message) { printf("A"); }
  void handle_B(MessageB const& message) { printf("B"); }
  void handle_C(MessageC const& message) { printf("C"); }
};

// -----------------------------------

int main() {
  Receiver r;
  r.dispatch(MessageA{});
}

Static Implementation

A message receiver might need runtime configurability, but if it doesn’t, there’s room for optimization! We can save ourselves some allocations by using a static dispatcher map. Let’s help the compiler understand:

template<typename TOwner, typename... Messages>
class StaticMessageDispatcher {
public:
  void dispatch(const Message& message) {
    dispatch_impl(
        message,
        std::make_index_sequence<sizeof...(Messages)>{}
    );
  }

private:
  template<size_t... Is>
  void dispatch_impl(
    const Message& message,
    std::index_sequence<Is...>)
  {
    bool handled = (dispatch_single<Is>(message) || ...);
    if (!handled) {
      printf("Unknown message, didn't handle");
    }
  }

  template<size_t I>
  bool dispatch_single(const Message& message) {
    using MessageType = typename std::tuple_element<
        I,
        std::tuple<Messages...>
    >::type;
    
    if (message.id == MessageType::id) {
      static_cast<TOwner*>(this)->handle(
        static_cast<const MessageType&>(message));
      return true;
    }
    return false;
  }
};

struct Receiver : StaticMessageDispatcher<
  Receiver,
  MessageA,
  MessageB,
  MessageC
> {
  void handle(const MessageA& message) { printf("MsgA"); }
  void handle(const MessageB& message) { printf("MsgB"); }
  void handle(const MessageC& message) { printf("MsgC"); }
};

Sadly, the

When compiled with clang and -O2, the following boils down to about 40 instructions on x64:

int main() {
    MessageA a;
    MessageB b;
    MessageC c;
    std::array<Message*, 3> messages = {&a, &b, &c};

    Message& msg = *messages[rand() % 3];

    Receiver r;
    r.dispatch(msg);
}
Message dispatching with C++ - December 10, 2024 - Arne Elster