Let's start with automation of read and write. In most cases the read() operation of the message has to read all the fields the message contains, as well as write() operation has to write all the fields of the message.
In order to make the generation of appropriate read/write code to be a job of the compiler we have to:
Provide the same interface for every message field.
Introduce a meta-programming friendly structure to hold all the fields, such
as std::tuple.
Use meta-programming techniques to iterate over every field in the
bundle and invoke the required read/write function of
every field.
Let's assume, all the message fields provide the following interface:
class SomeField
{
public:
// Value storage type definition
using ValueType = ...;
// Provide an access to the stored value
ValueType& value();
const ValueType& value() const;
// Read (deserialise) and update internal value
template <typename TIter>
ErrorStatus read(TIter& iter, std::size_t len);
// Write (serialise) internal value
template <typename TIter>
ErrorStatus write(TIter& iter, std::size_t len) const;
// Get the serialisation length
std::size_t length() const;
private:
ValueType m_value;
}
The custom message class needs to define its fields bundled in std::tuple
class ActualMessage1 : public Message
{
public:
using Field1 = ...
using Field2 = ...
using Field3 = ...
using AllFields = std::tuple<
Field1,
Field2,
Field3
>;
...
protected:
virtual ErrorStatus readImpl(ReadIterator& iter, std::size_t len) override
{
...// invoke read() member function of every field
}
virtual ErrorStatus writeImpl(WriteIterator& iter, std::size_t len) const override
{
...// invoke write() member function of every field
}
private:
AllFields m_fields;
};
What remains is to implement automatic invocation of read() and write() member function for every field in AllFields tuple.
As the result the signature of such function may look like this:
where tuple is l- or r-value reference to any std::tuple object, and func is l- or r-value reference to a functor object that must define the following public interface:
Then the body of readImpl() member function of the actual message class may look like this:
class ActualMessage1 : public Message
{
public:
using AllFields = std::tuple<...>;
protected:
virtual ErrorStatus readImpl(ReadIterator& iter, std::size_t len) override
{
auto status = ErrorStatus::Success;
tupleForEach(m_fields, FieldReader(status, iter, len));
return status;
}
private:
AllFields m_fields;
};
From now on, any modification to the AllFields bundle of fields does NOT require any additional modifications to the body of readImpl() function. It becomes a responsibility of the compiler to invoke read() member function of all the fields.
Implementing Write
Implementation of the write functionality is very similar. Below is the implementation of the writer functor class:
Then the body of writeImpl() member function of the actual message class may look like this:
class ActualMessage1 : public Message
{
public:
using AllFields = std::tuple<...>;
protected:
virtual ErrorStatus writeImpl(WriterIterator& iter, std::size_t len) const override
{
auto status = ErrorStatus::Success;
tupleForEach(m_fields, FieldWriter(status, iter, len));
return status;
}
private:
AllFields m_fields;
};
Just like with reading, any modification to the AllFields bundle of fields does NOT require any additional modifications to the body of writeImpl() function. It becomes a responsibility of the compiler to invoke write() member function of all the fields.
Eliminating Boilerplate Code
It is easy to notice that the body of readImpl() and writeImpl() of every ActualMessage* class looks the same. What differs is the tuple of fields which get iterated over.
// Common interface class:
class Message {...};
template <typename TFields>
class MessageBase : public Message
{
public:
using Message::ReadIterator;
using Message::WriteIterator;
using AllFields = TFields;
// Access to fields bundle
AllFields& fields() { return m_fields; }
const AllFields& fields() const { return m_fields; }
protected:
virtual ErrorStatus readImpl(ReadIterator& iter, std::size_t len) override
{
auto status = ErrorStatus::Success;
tupleForEach(m_fields, FieldReader(status, iter, len));
return status;
}
virtual ErrorStatus writeImpl(WriterIterator& iter, std::size_t len) const override
{
auto status = ErrorStatus::Success;
tupleForEach(m_fields, FieldWriter(status, iter, len));
return status;
}
private:
class FieldReader { ... /* same code as from earlier example */ };
class FieldWriter { ... /* same code as from earlier example */ };
AllFields m_fields;
}
All the ActualMessage* classes need to inherit from MessageBase while providing their own fields. The right implementation of readImpl() and writeImpl() is going to be generated by the compiler automatically for every custom message.
using ActualMessage1Fields = std::tuple<...>;
class ActualMessage1 : public MessageBase<ActualMessage1Fields> {...};
using ActualMessage2Fields = std::tuple<...>;
class ActualMessage2 : public MessageBase<ActualMessage2Fields> {...};
...
Other Basic Operations
In addition to read and write, there are other operations that can be automated. For example, the serialisation length of the full message is a summary of the serialisation lengths of all the fields. If every field can report its serialisation length, then the implementation may look like this:
Another example is an automation of validity check. In most cases the message is considered to be valid if all the fields are valid. Let's assume that every fields can also provide an information about validity of its data:
class SomeField
{
public:
// Get validity information
bool valid() const;
...
}
The implementation of message contents validity check may look like this:
It is not uncommon to have some optional fields in the message, the existence of which depends on some bits in previous fields. In this case the default read and/or write behaviour generated by the compiler needs to be modified. Thanks to the inheritance relationship between the classes, nothing prevents us from overriding the readImpl() and/or writeImpl() function and providing the right behaviour:
using ActualMessage1Fields = std::tuple<...>;
class ActualMessage1 : public MessageBase<ActualMessage1Fields>
{
protected:
virtual void readImpl(ReadIterator& iter, std::size_t len) override {...}
virtual void writeImpl(WriteIterator& iter, std::size_t len) const override {...}
}
The MessageBase<...> class already contains the definition of FieldReader and FieldWriter helper classes, it can provide helper functions to read/write only several fields from the whole bundle. These functions can be reused in the overriding implementations of readImpl() and/or writeImpl():
template <typename TFields>
class MessageBase : public Message
{
...
protected:
template <std::size_t TFromIdx, std::size_t TUntilIdx>
ErrorStatus readFieldsFromUntil(
ReadIterator& iter,
std::size_t& size)
{
auto status = ErrorStatus::Success;
tupleForEachFromUntil<TFromIdx, TUntilIdx>(m_fields, FieldReader(status, iter, size));
return status;
}
template <std::size_t TFromIdx, std::size_t TUntilIdx>
ErrorStatus writeFieldsFromUntil(
WriteIterator& iter,
std::size_t size) const
{
auto status = ErrorStatus::Success;
tupleForEachFromUntil<TFromIdx, TUntilIdx>(m_fields, FieldWriter(status, iter, size));
return status;
}
private:
class FieldReader { ... };
class FieldWriter { ... };
AllFields m_fields;
}
Let's take a look at standard algorithm . Its last parameter is a functor object, which must define appropriate operator() member function. This function is invoked for every element being iterated over. What we need is something similar, but instead of receiving iterators, it must receive a full tuple object, and the operator() of provided functor must be able to receive any type, i.e. be a template function.
Implementation of thetupleForEach() function described above can be a nice exercise for practising some meta-programming skills. contains the required code if help is required.
It is possible to eliminate such duplication of boilerplate code by introducing additional class in the class hierarchy, which receives a bundle of fields as a template parameter and implements the required functions. The same technique was used to eliminate boilerplate code for .
The class hierarchy looks like this:
NOTE, that example above used tupleAccumulate() function, which is similar to . The main difference is that binary operation function object, provided to the function, must be able to receive any type, just like with tupleForEach() described earlier. The code of tupleAccumulate() function can be found in .
The provided readFieldsFromUntil() and writeFieldsFromUntil() protected member functions use tupleForEachFromUntil() function to perform read/write operations on a group of selected fields. It is similar to tupleForEach() used earlier, but receives additional template parameters, that specify indices of the fields for which the provided functor object needs to be invoked. The code of tupleForEachFromUntil() function can be found in .