Generalising Fields Implementation
The automation of read/write operations of the message required fields to expose predefined minimal 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 read/write operations will probably require knowledge about the serialisation endian used for the protocol. We need to come up with the way to convey the endian information to the field classes. I would recommend doing it by having common base class for all the fields:
The choice of the right endian may be implemented using Tag Dispatch Idiom.
Every field class should receive its base class as a template parameter and may use available readData() and writeData() static member functions when serialising/deserialising internal value in read() and write() member functions.
For example:
When the endian is known and fixed (for example when implementing third party protocol according to provided specifications), and there is little chance it's ever going to change, the base class for all the fields may be explicitly defined:
However, there may be the case when the endian information is not known up front, and the one provided to the message interface definition (comms::Message) must be used. In this case, the message interface class may define common base class for all the fields:
As the result the definition of the message's fields must receive a template parameter of the base class for all the fields:
Multiple Ways to Serialise Fields
The Common Field Types chapter described most common types of fields with various serialisation and handling nuances, which can be used to implement a custom communication protocol.
Let's take the basic integer value field as an example. The most common way to serialise it is just read/write its internally stored value as is. However, there may be cases when serialisation takes limited number of bytes. Let's say, the protocol specification states that some integer value consumes only 3 bytes in the serialised bytes sequence. In this case the value will probably be be stored using std::int32_t or std::uint32_t type. The field class will also require different implementation of read/write/length functionality.
Another possible case is a necessity to add/subtract some predefined offset to/from the value being serialised and subtracting/adding the same offset when the value is deserialised. Good example of such case would be the serialisation of a year information, which is serialised as an offset from year 2000 and consumes only 1 byte. It is possible to store the value as a single byte (comms::IntValueField<..., std::uint8_t>), but it would be very inconvenient. It is much better if we could store a normal year value (2015, 2016, etc ...) using std::uint16_t type, but when serialising, the values that get written are 15, 16, etc... NOTE, that such field requires two steps in its serialisation logic:
add required offset (
-2000in the example above)limit the number of bytes when serialising the result
Another popular way to serialise integer value is to use Base-128 encoding. In this case the number of bytes in the serialisation sequence is not fixed.
What if some protocol decides to serialise the same offset from year 2000, but using the Base-128 encoding? It becomes obvious that having a separate field class for every possible variant is impractical at least. There must be a way to split the serialisation logic into small chunks, which can be applied one on top of another.
Using the same idea of the options and adapting the behaviour of the field class accordingly, we can generalise all the fields into a small subset of classes and make them also part of our generic library.
The options described earlier may be defined using following option classes:
Parsing the Options
In a very similar way to parsing options of the message interface (comms::Message) and message implementation (comms::MessageBase) described in earlier chapters, we will create a struct, that will contain all the provided information to be used later.
Assemble the Required Functionality
Before parsing the options and assembling the right functionality there is a need to start with basic integer value functionality:
Such field receives its base class and the type of the value it stores. The implementation of read/write/length functionalities are very basic and straightforward.
Now, we need to prepare various adaptor classes that will wrap or replace the existing interface functions:
NOTE, that the adaptor classes above wrap one another (TNext template parameter) and either replace or forward the read/write/length operations to the next adaptor or final BasicIntValue class, instead of using inheritance as it was with message interface and implementation chunks. The overall architecture presented in this book doesn't require the field classes to exhibit polymorphic behaviour. That's why using inheritance between adaptors is not necessary, although not forbidden either. Using inheritance instead of containment has its pros and cons, and at the end it's a matter of personal taste of what to use.
Now it's time to use the parsed options and wrap the BasicIntValue with required adaptors:
Wrap with SerOffsetAdaptor if needed
Wrap with FixedLengthAdaptor if needed
And so on for all other possible adaptors.
Now, let's bundle all the required adaptors together:
The final stage is to actually define final IntValueField type:
The definition of the year field which is serialised using offset from year 2000 may be defined as:
Other Options
In addition to options that regulate the read/write behaviour, there can be options which influence how the field is created and/or handled afterwards.
For example, there may be a need to set a specific value when the field object is created (using default constructor). Let's introduce a new options for this purpose:
The template parameter provided to this option is expected to be a class/struct with the following interface:
Then the relevant adaptor class may set the default value of the field using the provided setter class:
Please note, that both comms::option::DefaultValueInitialiser option and DefaultValueInitAdaptor adaptor class are completely generic, and they can be used with any type of the field.
For numeric fields, such as IntValueField defined earlier, the generic library may provide built-in setter class:
And then, create a convenience alias to DefaultValueInitialiser option which receives a numeric value as its template parameter and insures that the field's value is initialised accordingly:
As the result, the making the year field to be default constructed with value 2016 may look like this:
Other Fields
The Common Field Types chapter mentions multiple other fields and several different ways to serialise them. I'm not going to describe each and every one of them here. Instead, I'd recommend taking a look at the documentation of the COMMS library which was implemented using ideas from this book. It will describe all the fields it implements and their options.
Eliminating Dynamic Memory Allocation
Fields like String or List may contain variable number of characters/elements. The default internal value storage type for such fields will probably be std::string or std::vector respectively. It will do the job, mostly. However, they may not be suitable for bare-metal products that cannot use dynamic memory allocation and/or exceptions. In this case there must be a way to easily substitute these types with alternatives, such as custom StaticString or StaticVector types.
Let's define a new option that will provide fixed storage size and will force usage of these custom types instead of std::string and std::vector.
The parsed option structure needs to be extended with new information:
Now, let's implement the logic of choosing StaticString as the value storage type if the option above is used and choosing std::string if not.
comms::util::StaticString is the implementation of a string management class, which exposes the same public interface as std::string. It receives the fixed size of the storage area as a template parameter, uses std::array or similar as its private data member the store the string characters.
The implementation of the String field may look like this:
As the result the definition of the message with a string field that doesn't use dynamic memory allocation may look like this:
And what about the case, when there is a need to create a message with a string field, but substitute the underlying default std::string type with StaticString only when compiling the bare-metal application? In this case the ActualMessage3 class may be defined to have additional template parameter which will determine the necessity to substitute the storage type.
Thanks to the fact that StaticString and std::string classes expose the same public interface, the message handling function doesn't need to worry about actual storage type. It just uses public interface of std::string:
Choosing internal value storage type for List fields to be std::vector or StaticVector is very similar.
Last updated
Was this helpful?