The basic generic message interface may include the following operations:
Retrieve the message ID.
Read (deserialise) the message contents from raw data in a buffer.
Write (serialise) the message contents into a buffer.
Calculate the serialisation length of the message.
Dispatch the message to an appropriate handling function.
Check the validity of the message contents.
There may be multiple cases when not all of the operations stated above are needed for some specific case. For example, some sensor only reports its internal data to the outside world over some I/O link, and doesn't listen to the incoming messages. In this case the read() operation is redundant and its implementation should not take space in the produced binary code. However, the component that resides on the other end of the I/O link requires the opposite functionality, it only consumes data, without producing anything, i.e. write() operation becomes unnecessary.
There must be a way to limit the basic interface to a particular set of functions, when needed.
Also there must be a way to specify:
type used to store and report the message ID.
type of the read/write iterators
endian used in data serialisation.
type of the message handling class, which is used in dispatch() functionality.
The best way to support such variety of requirements is to use the variadic templates feature of C++11, which allows having non-fixed number of template parameters.
These parameters have to be parsed and used to define all the required internal functions and types. The common message interface class is expected to be defined like this:
where TOptions is a set of classes/structs, which can be used to define all the required types and functionalities.
Below is an example of such possible option classes:
namespace comms
{
namespace option
{
// Define type used to store message ID
template <typename T>
struct MsgIdType{};
// Specify type of iterator used for reading
template <typename T>
struct ReadIterator {};
// Specify type of iterator used for writing
template <typename T>
struct WriteIterator {};
// Use little endian for serialisation (instead of default big)
struct LittleEndian {};
// Include serialisation length retrieval in public interface
struct LengthInfoInterface {};
// Include validity check in public interface
struct ValidCheckInterface {};
// Define handler class
template <typename T>
struct Handler{};
} // namespace option
} // namespace comms
Our PRIMARY OBJECTIVE for this chapter is to provide an ability to create a common message interface class with only requested functionality.
For example, the definition of MyMessage interface class below
class MyHandler;
using MyMessage = comms::Message<
comms::option::MsgIdType<std::uint16_t>, // use std::uint16_t as message ID type
comms::option::ReadIterator<const std::uint8_t*>, // use const std::uint8_t* as iterator for reading
comms::option::WriteIterator<std::uint8_t*>, // use std::uint8_t* as iterator for writing
comms::option::LengthInfoInterface, // add length() member function to interface
comms::option::Handler<MyHandler> // add dispatch() member function with MyHandler as the handler class
>;
should be equivalent to defining:
class MyMessage
{
public:
using MsgIdType = std::uint16_t;
using ReadIterator = const std::uint8_t*;
using WriteIterator = std::uint8_t*;
using Handler = MyHandler;
MsgIdType id() {...}
ErrorStatus read(ReadIterator& iter, std::size_t len) {...}
ErrorStatus write(WriteIterator& iter, std::size_t len) const {...}
std::size_t length() const {...}
void dispatch(Handler& handler) {...}
protected:
template <typename T>
static T readData(ReadIterator& iter) {...} // use big endian by default
template <typename T>
static void writeData(T value, WriteIterator& iter) {...} // use big endian by default
...
};
And the following definition of MyMessage interface class
using MyMessage = comms::Message<
comms::option::MsgIdType<std::uint8_t>, // use std::uint8_t as message ID type
comms::option::LittleEndian, // use little endian in serialisation
comms::option::ReadIterator<const std::uint8_t*> // use const std::uint8_t* as iterator for reading
>;
will be equivalent to:
class MyMessage
{
public:
using MsgIdType = std::uint8_t;
using ReadIterator = const std::uint8_t*;
MsgIdType id() {...}
ErrorStatus read(ReadIterator& iter, std::size_t len) {...}
protected:
template <typename T>
static T readData(ReadIterator& iter) {...} // use little endian
template <typename T>
static void writeData(T value, WriteIterator& iter) {...} // use little endian
...
};
Looks nice, isn't it? So, how are we going to achieve this? Any ideas?
That's right! We use MAGIC!
Sorry, I mean template meta-programming. Let's get started!
Parsing the Options
First thing, that needs to be done, is to parse the provided options and record them in some kind of a summary structure, with predefined list of static const bool variables, which indicate what options have been used, such as one below:
If some variable is set to true, the summary structure may also contain some additional relevant types and/or more variables.
For example the definition of
class MyHandler;
using MyMessage = comms::Message<
comms::option::MsgIdType<std::uint16_t>, // use std::uint16_t
comms::option::ReadIterator<const std::uint8_t*>, // use const std::uint8_t* as iterator for reading
comms::option::WriteIterator<std::uint8_t*>, // use std::uint8_t* as iterator for writing
comms::option::LengthInfoInterface, // add length() member function to interface
comms::option::Handler<MyHandler> // add dispatch() member function with MyHandler as the handler class
>;
Then, handle the provided options one by one, while replacing the initial values and defining additional types when needed.
namespace comms
{
template <typename T, typename... TOptions>
struct MessageInterfaceParsedOptions<comms::option::MsgIdType<T>, TOptions...> :
public MessageInterfaceParsedOptions<TOptions...>
{
static const bool HasMsgIdType = true;
using MsgIdType = T;
};
template <typename... TOptions>
struct MessageInterfaceParsedOptions<comms::option::LittleEndian, TOptions...> :
public MessageInterfaceParsedOptions<TOptions...>
{
static const bool HasLittleEndian = true;
};
template <typename T, typename... TOptions>
struct MessageInterfaceParsedOptions<comms::option::ReadIterator<T>, TOptions...> :
public MessageInterfaceParsedOptions<TOptions...>
{
static const bool HasReadIterator = true;
using ReadIterator = T;
};
... // and so on
} // namespace comms
Note, that inheritance relationship is used, and according to the C++ language specification the new variables with the same name hide (or replace) the variables defined in the base class.
Also note, that the order of the options being used to define the interface class does NOT really matter. However, it is recommended, to add some static_assert() statements in, to make sure the same options are not used twice, or no contradictory ones are used together (if such exist).
Assemble the Required Interface
The next stage in the defining message interface process is to define various chunks of interface functionality and connect them via inheritance.
namespace comms
{
// ID retrieval chunk
template <typename TBase, typename TId>
class MessageInterfaceIdTypeBase : public TBase
{
public:
using MsgIdType = TId;
MsgIdType getId() const
{
return getIdImpl();
}
protected:
virtual MsgIdType getIdImpl() const = 0;
};
// Big endian serialisation chunk
template <typename TBase>
class MessageInterfaceBigEndian : public TBase
{
protected:
template <typename T>
static T readData(ReadIterator& iter) {...} // use big endian
template <typename T>
static void writeData(T value, WriteIterator& iter) {...} // use big endian
};
// Little endian serialisation chunk
template <typename TBase>
class MessageInterfaceLittleEndian : public TBase
{
protected:
template <typename T>
static T readData(ReadIterator& iter) {...} // use little endian
template <typename T>
static void writeData(T value, WriteIterator& iter) {...} // use little endian
};
// Read functionality chunk
template <typename TBase, typename TReadIter>
class MessageInterfaceReadBase : public TBase
{
public:
using ReadIterator = TReadIter;
ErrorStatus read(ReadIterator& iter, std::size_t size)
{
return readImpl(iter, size);
}
protected:
virtual ErrorStatus readImpl(ReadIterator& iter, std::size_t size) = 0;
};
... // and so on
} // namespace comms
Note, that the interface chunks receive their base class through template parameters. It will allow us to connect them together using inheritance. Together they can create the required custom interface.
There is a need for some extra helper classes to implement such connection logic which chooses only requested chunks and skips the others.
The code below chooses whether to add MessageInterfaceIdTypeBase into the inheritance chain of interface chunks.
Let's assume that the interface options were parsed and typedef-ed into some ParsedOptions type:
using ParsedOptions = comms::MessageInterfaceParsedOptions<TOptions...>;
Then after the following definition statement
using NewBaseClass =
comms::MessageInterfaceProcessMsgId<
OldBaseClass,
ParsedOptions,
ParsedOptions::HasMsgIdType
>::Type;
the NewBaseClass is the same as OldBaseClass, if the value of ParsedOptions::HasMsgIdType is false (type of message ID wasn't provided via options), otherwise NewBaseClass becomes comms::MessageInterfaceIdTypeBase, which inherits from OldBaseClass.
Using the same pattern the other helper wrapping classes must be implemented also.
Choose right chunk for endian:
namespace comms
{
template <typename TBase, bool THasLittleEndian>
struct MessageInterfaceProcessEndian;
template <typename TBase>
struct MessageInterfaceProcessEndian<TBase, true>
{
using Type = MessageInterfaceLittleEndian<TBase>;
};
template <typename TBase>
struct MessageInterfaceProcessEndian<TBase, false>
{
using Type = MessageInterfaceBigEndian<TBase>;
};
} // namespace comms
The interface building code just uses the helper classes in a sequence of type definitions:
namespace comms
{
class EmptyBase {};
template <typename... TOptions>
struct MessageInterfaceBuilder
{
// Parse the options
using ParsedOptions = MessageInterfaceParsedOptions<TOptions...>;
// Add ID retrieval functionality if ID type was provided
using Base1 = typename MessageInterfaceProcessMsgId<
EmptyBase, ParsedOptions, ParsedOptions::HasMsgIdType>::Type;
// Add readData() and writeData(), that use the right endian
using Base2 = typename MessageInterfaceProcessEndian<
Base1, ParsedOptions::HasLittleEndian>::Type;
// Add read functionality if ReadIterator type was provided
using Base3 = typename MessageInterfaceProcessReadIterator<
Base2, ParsedOptions, ParsedOptions::HasReadIterator>::Type;
// Add write functionality if WriteIterator type was provided
using Base4 = typename MessageInterfaceProcessWriteIterator<
Base3, ParsedOptions, ParsedOptions::HasWriteIterator>::Type;
// And so on...
...
using BaseN = ...;
// The last BaseN must be taken as final type.
using Type = BaseN;
};
} // namespace comms
Once all the required definitions are in place, the common dynamic message interface class comms::Message may be defined as:
namespace comms
{
template <typename... TOptions>
class Message : public typename MessageInterfaceBuilder<TOptions...>::Type
{
};
} // namespace comms
As the result, any distinct set of options provided as the template parameters to comms::Message class will cause it to have the required types and member functions.
Now, when the interface is in place, it is time to think about providing common comms::MessageBase class which is responsible to provide default implementation for functions, such as readImpl(), writeImpl(), dispatchImpl(), etc...