Skip to content

Serialization

In most applications, it is necessary that data can be serialized, i.e. transformed into a sequence of bytes. While this is straightforward for data structures that already consist of a single block of memory, it is a more complex task for dynamic structures, e.g. lists, trees, or graphs. Our implementation for streaming data follows the ideas introduced by the C++ iostreams library, i.e., the operators << and >> are used to implement the process of serialization. In contrast to the iostreams library, our implementation guarantees that data is streamed in a way that it can be read back without any special handling, even when streaming into and from text files, i.e. the user of a stream does not need to know about the representation that is used for the serialized data (cf. this section).

On top of the basic streaming class hierarchy, it is also possible to derive classes from class Streamable and implement the mandatory methods read(In&) and write(Out&) const. In addition, the basic concept of streaming data was extended by a mechanism to register information on the structure of serializable datatypes. This information is used to translate between the data in binary form and a human-readable format that reflects the hierarchy of a data structure, its variables, and their actual values.

As a third layer of serialization, two macros allow defining classes that automatically implement the methods read(In&) and write(Out&) const as well as datatype registration.

Streams

The foundation of B-Human's implementation of serialization is a hierarchy of streams. As a convention, all classes that write data into a stream have a name starting with Out, while classes that read data from a stream start with In. In fact, all writing classes are derived from the class Out, and all reading classes are derivations of the class In. All classes support reading or writing basic datatypes with the exceptions of long and long long (both signed and unsigned). They also provide the ability to read or write raw binary data.

All streaming classes derived from In and Out are composed of two components: One for reading/writing the data from/to a physical medium and one for formatting the data from/to a specific format. Classes writing to physical media derive from PhysicalOutStream, classes for reading derive from PhysicalInStream. Classes for formatted writing of data derive from StreamWriter, classes for reading derive from StreamReader. The composition is done by the OutStream and InStream class templates.

A special case are the OutMap and the InMap streams. They only work together with classes that are derived from the class Streamable, because they use the structural information that is gathered in the read and write methods. They are both directly derived from Out and In, respectively.

Currently, the following classes are implemented:

  • PhysicalOutStream: Abstract class
    • OutFile: Writing into files
    • OutMemory: Writing into memory
    • OutMemoryForText: Writing into memory, ensuring that it is null-terminated
    • MessageQueue::OutQueue: Writing into a MessageQueue
  • StreamWriter: Abstract class
    • OutBinary: Formats data binary
    • OutText: Formats data as text
    • OutTextRaw: Formats data as raw text (same output as cout)
  • Out: Abstract class
    • OutStream<PhysicalOutStream, StreamWriter>: Abstract template class
      • OutBinaryFile: Writing into binary files
      • OutBinaryMemory: Writing binary into memory
      • MessageQueue::OutBinary: Writing binary into a MessageQueue
      • OutTextFile: Writing into text files
      • OutTextMemory: Writing into memory as text
      • MessageQueue::OutText: Writing into a MessageQueue as text
      • OutTextRawFile: Writing into raw text files
      • OutTextRawMemory: Writing into memory as raw text
      • MessageQueue::OutTextRaw: Writing into a MessageQueue as raw text
    • OutMap: Writing into a stream in configuration map format. This only works together with serialization, i.e. a streamable object has to be written. This class cannot be used directly.
      • OutMapFile: Writing into a file in configuration map format
      • OutMapMemory: Writing into a memory area in configuration map format
  • PhysicalInStream: Abstract class
    • InFile: Reading from files
    • InMemory: Reading from memory
  • StreamReader: Abstract class
    • InBinary: Binary reading
    • InConfig: Reading configuration file data from streams
    • InText: Reading data as text
  • In: Abstract class
    • InStream<PhysicalInStream, StreamReader>: Abstract class template
      • InBinaryFile: Reading from binary files
      • InTextFile: Reading from text files
      • InConfigFile: Reading from configuration files
      • InBinaryMemory: Reading binary data from memory
      • InTextMemory: Reading text data from memory
      • InConfigMemory: Reading config-file-style text data from memory
    • InMap: Reading from a stream in configuration map format. This only works together with serialization, i.e. a streamable object has to be read. This class cannot be used directly.
      • InMapFile: Reading from a file in configuration map or JSON format
      • InMapMemory: Reading from a memory area in configuration map format

In addition, a number of special-purpose streams exist, e.g. as part of message en-/decoding in the team communication and in the implementation of the data view.

Streaming Data

To write data into a stream, Src/Libs/Streaming/OutStreams.h must be included, a stream must be constructed, and the data must be written into the stream. For example, to write data into a text file, the following code would be appropriate:

#include "Streaming/OutStreams.h"
// ...
OutTextFile stream("MyFile.txt");
stream << 1 << 3.14 << "Hello Dolly" << endl << 42;

The file will be written into the configuration directory, e.g. Config/MyFile.txt on the PC. It will look like this:

 1 3.14 "Hello Dolly"
 42

As spaces are used to separate entries in text files, the string "Hello Dolly" is enclosed in double quotes. The data can be read back using the following code:

#include "Streaming/InStreams.h"
// ...
InTextFile stream("MyFile.txt");
int a, d;
double b;
std::string c;
stream >> a >> b >> c >> d;

It is not necessary to read the symbol endl here, although it would also work, i.e. it would be ignored.

For writing to text streams without the separation of entries and the addition of double quotes, OutTextRawFile can be used instead of OutTextFile. It formats the data such as known from the ANSI C++ cout stream. The example above is formatted as following:

13.14Hello Dolly
42

To make streaming independent of the kind of the stream used, it could be encapsulated in functions. In this case, only the abstract base classes In and Out should be used to pass streams as parameters, because this makes the functions usable independently from the type of the streams:

#include "Streaming/InOut.h"

void write(Out& stream)
{
  stream << 1 << 3.14 << "Hello Dolly" << endl << 42;
}

void read(In& stream)
{
  int a, d;
  double b;
  std::string c;
  stream >> a >> b >> c >> d;
}
// ...
OutTextFile stream("MyFile.txt");
write(stream);
// ...
InTextFile stream("MyFile.txt");
read(stream);

Generating Streamable Classes

Values of basic types are not the only kind of data that can be streamed. Instances of classes can be streamed as well. However, in contrast to basic data types, the streaming framework cannot know how to stream an object that is composed of several member variables. Therefore, additional information must be provided that allows serializing and de-serializing the instances of a class and even transmitting and querying its specification. The underlying details are given in this section. Here, we only describe how two macros (defined in Src/Libs/Streaming/AutoStreamable.h) generate this extra information for a streamable struct1 and optionally also initialize its member variables. The first is:

STREAMABLE(<class>,
{ <header>,
  <comma-separated-declarations>,
});

The second is very similar:

STREAMABLE_WITH_BASE(<class>, <base>, ...

The parameters have the following meaning:

  • class: The name of the struct to be declared.
  • base: Its base class. It must be streamable itself.
  • header: Everything that can be part of a class body except for the member variables that should be streamable. Please note that this part must not contain commas that are not surrounded by parentheses, because C++ would consider it to be more than a single macro parameter otherwise. A workaround is to use the macro COMMA instead of an actual comma. However, the use of that macro should be avoided if possible, e.g. by defining constructors with comma-separated initializer lists outside of the STREAMABLE's body.
  • comma-separated-declarations: Declarations of the streamable member variables2 in two possible forms:

    (<type>) <var>
    (<type>)(<init>) <var>
    
    • type: The type of the member variable that is declared.
    • var: The name of the member variable that is declared.
    • init: The initial value of the member variable, or if an object is declared, the parameter(s) passed to its constructor.

Please note that all these parts, including each declaration of a streamable member variable, are separated by commas, since they are parameters of a macro. Here is an example:

STREAMABLE(Example,
{
  ENUM(ABC,
  {,
    a,
    b,
    c,
  });

  Example()
  {
    std::memset(array, 0, sizeof(array));
  },

  (int) anInt,
  (float)(3.14f) pi,
  (int[4]) array,
  (Vector2f)(1.f, 2.f) aVector,
  (ABC) aLetter,
  (MotionRequest::Motion)(MotionRequest::stand) motionId,
});

In this example, all member variables except for anInt and aLetter would be initialized when an instance of the class is created.

Instances of Example can now be streamed like any basic data type. Since it is sometimes necessary to do some post-processing after an object was read from a stream, e.g. to recompute member variables that were not streamed, a method onRead() can be defined within the STREAMABLE, which is called after all streamed member variables were read.

Type Registry

The class TypeRegistry stores the specification of all streamable data types. It is automatically filled at the start of the program and is not changed afterwards. It has mainly three purposes:

  • Sending the type specifications to a remote PC. Thereby, the PC can exchange and visualize data, even if it is not running the same version of the B-Human software.
  • Providing the specification of all types for logging. This allows to replay log files even if some of the type specifications have changed since the file was recorded.
  • Mapping between the values and names of enumeration constants when streaming data as text.

The specification of a type is recorded through a static or global function. For instance, the STREAMABLE used as an example in this section generated a function like this:

static void reg()
{
  REG_CLASS(Example);
  REG(int, anInt);
  REG(float, pi);
  REG(int[4], array);
  REG(Vector2f, aVector);
  REG(ABC, aLetter);
  REG(MotionRequest::Motion, motionId);
}

If the class that is registered has a streamable base class, it must be registered with another macro, because otherwise the type registry cannot know about this relation:

REG_CLASS_WITH_BASE(Example, SomeBaseClass);

To execute the function at the start of the program, it must be published:

PUBLISH(reg);

PUBLISH must be placed in a position of the code that is guaranteed to be linked. It publishes the registration function through its pure existence3, not by being run through the normal flow of execution. In most cases, PUBLISH can just be placed in the registration function itself. However, for templates, it must be written into a function that is actually referenced from somewhere, otherwise it will never be executed, because it is not part of the executable. The same is true for code that is linked in from a static library.

The TypeRegistry is optimized for performance. It mostly stores addresses of names rather than the whole strings. Type names are kept in the mangled form the compiler generated. This means the data is only valid for the current execution of the program. To store the information into a file or to transmit it to another computer, an instance of the class TypeInfo must be filled first, which can then be streamed. TypeInfo can represent specification data that is independent from the current executable, e.g., it can contain the type specifications read from an older log file. All type names are de-mangled in a platform-independent way.

Streamable Classes

A class is made streamable by deriving it from the class Streamable, implementing the abstract methods read(In& stream) and write(Out& stream) const, and adding a method that registers its type (cf. this section). For data types derived from Streamable, streaming operators are provided, meaning they may be used as any other data type with standard streaming operators implemented. The following two macros can be used to specify the data to stream in the methods read and write. Both macros expect that the parameter of both methods is actually called stream. The use of these macros must be identical in both methods:

  • STREAM_BASE(<class>) streams the base class. If present, it must be the first STREAM statement in the read and write methods.

  • STREAM(<member>) streams a member variable.

For instance, the STREAMABLE used as an example in this section generated a function like this:

void read(In& stream) override
{
  STREAM(anInt);
  STREAM(pi);
  STREAM(array);
  STREAM(aVector);
  STREAM(aLetter);
  STREAM(motionId);
}

void write(Out& stream) const override
{
  STREAM(anInt);
  STREAM(pi);
  STREAM(array);
  STREAM(aVector);
  STREAM(aLetter);
  STREAM(motionId);
}

In addition to these two macros there is also a version to be used in streaming operators, i.e. in operator<< and operator>>. These macros do not expect that the stream has a pre-defined name. Instead, the stream is passed as their first parameter:

template<typename T> Out& operator<<(Out& out, const Range<T>& range)
{
  STREAM_EXT(out, range.min);
  STREAM_EXT(out, range.max);
  return out;
}

Configuration Maps

Configuration maps introduce the ability to handle serialized data from files in an arbitrary order. The sequence of entries in the file does not have to match the order of the member variables in the C++ data structure that is filled with them. In contrast to most streams presented in this section, configuration maps do not contain a serialization of a data structure, but rather a hierarchical representation.

Since configuration maps can be read from and be written to files, there is a special syntax for such files. A file consists of an arbitrary number of pairs of keys and values, separated by an equality sign, and completed by a semicolon. Values can be lists (encased by square brackets), structured values (encased by curly brackets), or plain values. If a plain value does not contain any whitespaces, periods, semicolons, commas, or equality signs, it can be written without quotation marks, otherwise it has to be encased in double quotes. Configuration map files have to follow this grammar:

map     ::= record
record  ::= { field ';' }
field   ::= literal '=' ( literal | '{' record '}' | array )
array   ::= '[' [ element { ',' element } [ ',' ] ] ']'
element ::= literal | '{' record '}'
literal ::= '"' { anychar1 } '"' | { anychar2 }

anychar1 must escape doublequotes and the backslash with a backslash. anychar2 cannot contain whitespace and other characters used by the grammar. However, when such a configuration map is read, each literal must be a valid literal for the datatype of the variable it is read into. As in C++, comments can be denoted either by // for a single line or by /* ... */ for multiple lines. Here is an example:

// A record
defensiveGoaliePose = {
  rotation = 0;
  translation = {x = -4300; y = 0;};
};

/* An array of
 * three records
 */
kickoffTargets = [
  {x = 2100; y = 0;},
  {x = 1500; y = -1350;},
  {x = 1500; y = 1350;}
];

// Some individual values
outOfCenterCircleTolerance = 150.0;
ballCounterThreshold = 10;

Configuration maps can only be read or written through the streams derived from OutMap and InMap. Accordingly, they require an object of a streamable class to either parse the data in map format or to produce it. Here is an example of code that reads the file shown above:

STREAMABLE(KickOffInfo,
{,
  (Pose2f) defensiveGoaliePose,
  (std::vector<Vector2f>) kickoffTargets,
  (float) outOfCenterCircleTolerance,
  (int) ballCounterThreshold,
});

InMapFile stream("kickOffInfo.cfg");
KickOffInfo info;
if(stream.exists())
  stream >> info;

Since some league-wide standardized configuration files use the JSON format, an InMapFile stream will parse the following grammar instead if the given file ends with .json:

map     ::= record
record  ::= '{' [ field { ',' field } [ ',' ] ] '}'
field   ::= literal ':' element
array   ::= '[' [ element { ',' element } [ ',' ] ] ']'
element ::= literal | record | array
literal ::= '"' { anychar1 } '"' | { anychar2 }

Enumerations

To support streaming, enumeration types should be defined using the macro ENUM defined in Src/Libs/Streaming/Enum.h rather than using the C++ enum keyword directly. The macro's first parameter is the name of the enumeration type. The second and the last parameter are reserved for curly brackets and are ignored. All other parameters are the elements of the enumeration type defined. It is not allowed to assign specific integer values to the elements of the enumeration type, with one exception: It is allowed to initialize an element with the symbolic value of the element that has been defined immediately before (see example below). The macro automatically registers the enumeration type and its elements (except for elements used to initialize other elements) in the TypeRegistry).

The macro also automatically defines a constant numOf<Typename>s, which reflects the number of elements in the enumeration type. Since the name of that constant has an added s at the end, enumeration type names should be singular. If the enumeration type name already ends with an s, it might be a good idea to define a constant outside the enumeration type that can be used instead, e.g. static constexpr unsigned char numOfClasses = numOfClasss for an enumeration type with the name Class.

The following example defines an enumeration type Letter with the "real" enumeration elements a, b, c, and d, a user-defined helper constant numOfLettersBeforeC, and an automatically defined helper constant numOfLetters. The numerical values of these elements are a = 0, b = 1, c = 2, d = 3, numOfLettersBeforeC = 2, numOfLetters = 4.

ENUM(Letter,
{,
  a,
  b,
  numOfLettersBeforeC,
  c = numOfLettersBeforeC,
  d,
});

Iterating over Enumerations

It is often necessary to enumerate all constants defined in an enumeration type. This can easily be done using the FOREACH_ENUM macro:

FOREACH_ENUM(Letter, letter, numOfLettersBeforeC)
{
  // do something with "letter", which is of type "Letter"
}

The third parameter is optional. If specified, it defines a different upper limit than the end of the enumeration.

Enumerations as Array Indices

In the B-Human code, enumerations are often used as indices for arrays, because this gives entries a name, but still allows to iterate over these entries. However, in configuration files and in the UI, it is hard to find specific entries in arrays, in particular if they have a larger number of elements. For instance, the arrays of all joint angles have 26 elements, making it hard to identify the angle of a specific joint. Therefore, a special macro (defined in Libs/Streaming/EnumIndexedArray.h) that is backed by a template class allows to define an array indexed by an enumeration type that solves this problem by streaming such an array as if it were a structure with a member variable for each of its elements named after the respective enumeration constant. Technically, such an array is derived from std::array and simply defines the methods read and write (cf. this section). For example, an array of all joint angles could be defined by using the enumeration Joints::Joint as index:

ENUM_INDEXED_ARRAY(Angle, Joints::Joint) jointAngles;

jointAngles still behaves like an array, i.e. it is derived from std::array<Angle, Joints::numOfJoints>, but when, e.g., written to a file using OutMapFile (cf. this section), it would appear differently:

headYaw = 0deg;
headPitch = 0deg;
lShoulderPitch = 0deg;
...

Functions

Not all data in a representation can reasonably be computed before it is used. This would lead to a certain overhead, because the data has to be computed in advance without knowing whether it will actually be required in the current situation. In addition, there is a lot of data that cannot be computed in advance, because its computation depends on external values. For instance, a path planner must know the target to which it has to plan a path before it can be executed. However, in many situations a path planner is not needed at all, because the motion is generated reactively.

Functions in representations allow modules that require the representation to execute code of the module that provided that representation at any time and they allow to pass parameters to that implementation. These modules can be switched to other implementations as all other B-Human modules can, giving a greater flexibility when improving the functionality of the system.

These functions are based on std::function from the C++ runtime library and the assigned implementations are usually lambda expressions. However, to make them more compliant with the general B-Human architecture, they differ from the normal standard implementation in their behavior if no value was assigned to them (their default behavior). Instead of throwing an exception as std::function would do when called, they simply do nothing. If they have a return value, they return the default value of the type returned or Zero() in case of Eigen types. However, there are some specialties to functions in representations:

  • Functions cannot be streamed in any way, although they must be part of a class that is derived from the class Streamable. This also means that a module cannot call a function of a representation the provider of which is executed in another thread. However, representations containing functions can be streamed, but the functions cannot be part of the data that is streamed, i.e. they must be ignored during streaming.
  • Representations containing functions are treated in a special way. They are always reset when their provider is switched. The reason is that the function object would otherwise still contain a reference to the module that originally provided the implementation, but the module does not exist anymore. In order to make this work with classes derived from classes containing functions, but not declaring functions themselves, the derived class has to contain the macro BASE_HAS_FUNCTION in its body.

For instance, the path planner provides its functionality through the representation PathPlanner:

STREAMABLE(PathPlanner,
{
  FUNCTION(MotionRequest(const Pose2f& target, const Pose2f& speed,
           bool excludePenaltyArea)) plan,
});

It assigns the function in its method update:

pathPlanner.plan = [this](const Pose2f& target, const Pose2f& speed,
                          bool excludePenaltyArea) -> MotionRequest
{ // ...
};

In the behavior control, the path planner is executed by this call:

theMotionRequest = thePathPlanner.plan(target, Pose2f(speed, speed, speed), avoidOwnPenaltyArea);

  1. Meaning, the default access for member variables is public

  2. Currently, the macros support up to 119 entries. 

  3. Technically, a template is instantiated that adds itself to a linked list of all registration functions at the start of the program. 


Last update: October 12, 2023