BitStream
|
This library was made with C++17 in mind and is not compatible with earlier versions. Many of the features use if constexpr
, which is only available from 17 and up. If you really want it to work with earlier versions, you should just be able to replace the newer features with C++1x counterparts.
As this is a header-only library, you can simply copy the header files directly into your project and include them where relevant. The header files can either be downloaded from the releases page or from the include/
directory on the master branch.
The source and header files inside the src/
directory are only tests and should not be included into your project, unless you wish to test the library as part of your pipeline.
The library has a main header file (bitstream/bitstream.h
) which includes every other header file in the library.
If you only need certain features you can instead opt to just include the files you need. The files are stored in categories:
quantization/
- Files relating to quantizing floats and quaternions into fewer bitsstream/
- Files relating to streams that read and write bitstraits/
- Files relating to various serialization traits, like serializble strings, integrals etc.Unlike most serilization libraries the default type traits are setup to use in
and out
parameters and thus share the same interface. This greatly simplifies user-defined serialization logic, as you can now share the same template function for both reading and writing.
An important aspect of the serialiaztion is performance, since the library is meant to be used in a tight loop, like with networking. This is why the default operations don't use exceptions, but instead return true on success and false on failure. It's important to check these return values after every operation, especially when reading from an unknown source. You can check it manually or use the BS_ASSERT(x)
macro for this, if you want your function to return false on failure.
It is also possible to dynamically put a break point or trap when a bitstream would have otherwise returned false. This can be great for debugging custom serialization code, but should generally be left out of production code. Simply #define BS_DEBUG_BREAK
before including any of the library header files if you want to break when an operation fails.
For more concrete examples of usage, see the Serialization Examples below. If you need to add your own serializable types you should also look at the Extensibility section. You can also look at the unit tests to get a better idea about what you can expect from the library.
Refer to the documentation for more information about what different classes provide.
The examples below follow the same structure: First writing to a buffer and then reading from it. Each example is littered with comments about the procedure, as well as what outcome is expected.
Writing the first 5 bits of an int to the buffer, then reading it back:
Writing a signed int to the buffer, within a range:
Writing a c-style string into the buffer:
Writing a std::string into the buffer:
Writing a float into the buffer with a bounded range and precision:
These examples can also be seen in src/test/examples_test.cpp
.
Below is a noncomprehensive list of serializable traits. A big feature of the library is extensibility, which is why you can add your own types as you please, or choose not to include specific types if you don't need them.
A trait that covers a single bool.
Takes the bool by reference and serializes it as a single bit.
The call signature can be seen below:
As well as a short example of its usage:
A trait that covers all signed and unsigned integers.
Takes the integer by reference and a lower and upper bound.
The upper and lower bounds will default to T's upper and lower bounds if left unspecified, effectively making the object unbounded.
The call signature can be seen below:
As well as a short example of its usage:
A trait that covers all signed and unsigned integers within a bounded_int
wrapper.
Takes the integer by reference and a lower and upper bound as template parameters.
This is preferable if you know the bounds at compile time as it skips having to calculate the number of bits required.
The upper and lower bounds will default to T's upper and lower bounds if left unspecified, effectively making the object unbounded.
The call signature can be seen below:
As well as a short example of its usage:
A trait that only covers c-style strings.
Takes the pointer and a maximum expected string length.
Note: In C++20 UTF-8 strings were given their own type, which means that you either have to cast your char8_t*
to a char*
or use serialize<const char8_t*>
instead.
The call signature can be seen below:
As well as a short example of its usage:
A trait that only covers c-style strings.
Takes the pointer as argument and a maximum expected string length as template parameter.
This is preferable if you know the maximum length at compile time as it skips having to calculate the number of bits required.
The call signature can be seen below:
As well as a short example of its usage:
A trait that covers any combination of basic_string, including strings with different allocators.
Takes a reference to the string and a maximum expected string length.
The, somewhat bloated, call signature can be seen below:
As well as a short example of its usage:
A trait that covers any combination of basic_string, including strings with different allocators.
Takes a reference to the string as argument and a maximum expected string length as template parameter.
This is preferable if you know the maximum length at compile time as it skips having to calculate the number of bits required.
The, somewhat bloated, call signature can be seen below:
As well as a short example of its usage:
A trait that covers an entire double, with no quantization.
Takes a reference to the double.
The call signature can be seen below:
As well as a short example of its usage:
A trait that covers an entire float, with no quantization.
Takes a reference to the float.
The call signature can be seen below:
As well as a short example of its usage:
A trait that covers a float which has been quantized to 16 bits.
Takes a reference to the float.
The call signature can be seen below:
As well as a short example of its usage:
A trait that covers a bounded float.
Takes a reference to the bounded_range and a reference to the float.
The call signature can be seen below:
As well as a short example of its usage:
A trait that covers any quaternion type in any order, as long as it's consistent.
Quantizes the quaternion using the given BitsPerElement.
Takes a reference to the quaternion.
The call signature can be seen below:
As well as a short example of its usage:
The library is made with extensibility in mind. The bit_writer<T>
and bit_reader<T>
use a template trait specialization of the given type to deduce how to serialize and deserialize the object. The only requirements of the trait is that it has (or can deduce) 2 static functions which take a bit_writer<T>&
and a bit_reader<T>&
respectively as their first argument. The 2 functions must also return a bool indicating whether the serialization was a success or not, but can otherwise take any number of additional arguments.
The general structure of a trait looks like the following:
As with any functions, you are free to overload them if you want to serialize an object differently, depending on any parameters you pass. As long as the first parameter can be deduced to bit_writer<T>&
and bit_reader<T>&
respectively they will be able to be called.
The serialization can also be unified with templating, if writing and reading look similar. If some parts of the serialization process don't match entirely you can query the Stream::reading
or Stream::writing
and branch depending on the value. An example of this can be seen below:
The specialization can also be templated to work with a number of types. It also works with enable_if
as the second argument:
Note that TRAIT_TYPE
does not necessarily have to be part of the serialize function definitions. It can just be used to specify which trait to use when serializing, if it cannot be deduced from the arguments.
Below is an example where we serialize an object by explicitly defining the trait type:
When calling the serialize
function on a bit_writer
or bit_reader
, the trait can sometimes be deduced instead of being explicitly declared. This can only be done if the type of the second argument in the static bool serialize(...)
function is (roughly) the same as the trait type. An example of the structure for an implicit trait can be seen below:
The above trait could then be used when implicitly serializing an object of type TRAIT_TYPE
:
It doesn't work on all types, and there is some guesswork involved relating to const qualifiers. E.g. a trait of type char
is treated the same as const char&
and thus the call would be ambiguous if both had a trait specialization. In case of ambiguity you will still be able to declare the trait explicitly when calling the serialize
function.
More concrete examples of traits can be found in the traits/
directory.
The tests require premake5 as build system. Generating project files can be done by running:
Afterwards the tests can be built using the command below:
You can also run the tests using the command below, or simply run the binary located in bin/{{config}}-{{platform}}-{{architecture}}
:
The library has no dependencies, but does build upon some code from the NetStack library by Stanislav Denisov, which is free to use, as per their MIT license. The code in question is about quantizing floats and quaternions, which has simply been translated from C# into C++ for the purposes of this library.
If you do not wish to use float, half or quaternion quantization, you can simply remove the quantization/
directory from the library, in which case you will not need to include or adhere to the license for NetStack.
The library is licensed under the BSD-3-Clause license and is subject to the terms and conditions in that license. In addition to this, everything in the quantization/
directory is subject to the MIT license from NetStack.