Core Module

The core module is the basis for all other modules of the framework. It has no dependency on any system specific libraries except the standard C++ library. This chapter covers command-line arguments, application settings, events, type-erased values, the Void marker, non-copyable objects, singletons, fixed-size integers, byte order, atomic operations, streams, zlib streams, dates and times, coroutines, allocators, unicode text, signals and delegates, type traits, conversions, and serialization.

This chapter covers:

Command Line Arguments

Arg reads one option out of the argument vector of main and removes it, so later parameters are whatever is left. Construct it with argc, argv, the option name, and a default value. After construction, get() is the extracted value or the default. isSet() is true only when the option was present. Streaming an Arg writes get().

T is the value type. Extraction uses operator>> on an istringstream, so T must be default-constructible and extractable. int, unsigned, std::string and const char* are the usual cases. const char* and std::string copy the argument text without parsing.

A short option is a hyphen and one character: -n. The value may follow in the same argument (-n42) or in the next (-n 42). A long option is any prefix string the caller passes, commonly --name or /NAME. The value may follow after whitespace (--name 42) or after an equals sign (--name=42). Passing the character 'n' and passing the string "-n" select the same short option.

Each successful extraction deletes that option, and its value if it occupied a separate argument, from argv and shrinks argc. Unrecognized arguments stay in place. After every Arg of interest has been constructed, argv still holds the program name and any leftover operands.

int main(int argc, char* argv[])
{
Pt::Arg<int> n(argc, argv, 'n', 0);
std::cout << "value for -n: " << n << std::endl;
}

Arg<bool> is a switch, not a parsed value. Presence of the flag sets it to true. There is no option argument. Short switches group: -abc is -a, -b and -c. A short switch can be turned off explicitly with -x-.

Pt::Arg<bool> debug(argc, argv, "--debug");
if (debug)
std::cout << "debug flag is set" << std::endl;

Constructing without argc and argv leaves the default. Call set() later to extract. set() does nothing if isSet() is already true, so the first match wins. A constructor that takes only argc and argv extracts the next positional argument, not a named option.

Application Settings

Settings is a tree of named entries that a program can load, change, and save. The unit of persistence is the whole tree. load() replaces it from a std::basic_istream of Pt::Char or from a Formatter. save() writes it the same way. A file is a typical source, but a string stream is equally valid, which is useful in tests.

std::ifstream ifs("app.settings");
Pt::TextIStream tis(ifs, new Pt::Utf8Codec);
Pt::Settings settings;
settings.load(tis);

The text format stores scalars, arrays, and structs. Integers, floating-point values, strings, and booleans are scalars. An array is a bracketed list. A struct is a brace-delimited list of named members. A [section] line is a top-level struct: the names that follow become its members until the next section.

a = 1
b = 3.14
c = "Hello World!"
d = true
e = [ 1, 2, 3 ]
f = { red = 255, green = 0, blue = 0 }
[animals]
a = "dog"
b = "cat"

After a load, that file has top-level entries a through f and a top-level entry animals with subentries a and b. entry() and operator[] return a ConstEntry or Entry by name. A missing name yields an empty entry. Empty entries are false in boolean context. get() extracts a serializable value and returns false when the entry is empty. set() replaces the value of an existing entry. addEntry() and makeEntry() create members; makeEntry() returns the member if it already exists. removeEntry() drops a member.

The stored type must have serialization operators. STL containers already do. A user type needs operator<<= and operator>>= for SerializationInfo, the same operators the rest of the serialization framework uses. Settings privately inherits SerializationInfo; do not use that base as a public API.

int a = 0;
bool ok = settings["a"].get(a);
std::vector<int> e;
ok = settings.entry("e").get(e);
settings.makeEntry("port").set(8080);
settings.save(tos);

load() from malformed text throws SettingsError, which reports the line of the failure. Saving truncates nothing by itself; open the destination stream with ios::trunc when the file should be replaced. Entry iterates like a sibling walk: begin() / end() and operator++ move to the next member of the same parent.

Events

Event is the base of every event that an event loop can copy and dispatch. A loop does not know the concrete type when it stores an event, so the event clones itself into an Allocator, destroys itself from that allocator, and reports a std::type_info that identifies the dynamic type. Slots subscribe to that type.

clone() and destroy() forward to onClone() and onDestroy(). typeInfo() forwards to onTypeInfo(). A derived class that does not use BasicEvent must implement those three hooks. clone() returns a new object of the same dynamic type, allocated from the given allocator. destroy() undoes that allocation. The allocator is the loop's; the event does not own it.

copyConstruct() and destruct() are helpers for those hooks. They placement-new and destroy a concrete EventT through the allocator. Prefer BasicEvent, which already calls them.

Do not slice an Event. Copying the base is not allowed as a value; the type is meant to be cloned through the allocator. Destroy an event only with the allocator that cloned it.

BasicEvent

Derive as class MyEvent : public BasicEvent<MyEvent>. T is the derived type. onTypeInfo() returns typeid(T). onClone() allocates sizeof(T) from the allocator and copy-constructs T. onDestroy() runs the BasicEvent destructor and returns that storage to the allocator.

The derived class must be copy-constructible. It does not override the three hooks unless it needs a different allocation size or copy. Construction of BasicEvent is protected, so it is only a base.

Type-Erased Values

Any holds a single value whose type is chosen at run time. Assigning a value by copy stores a copy, as a standard container would. Assigning another Any copies that stored value. The stored type is recovered with any_cast(); the cast succeeds only when the requested type is the type that was stored.

Pt::Any a = 5;
int i = Pt::any_cast<int>(a);
float f = Pt::any_cast<float>(a); // throws std::bad_cast

Construction and assignment from a value of type T require T to be copy-constructible. Small values live in an internal buffer. Larger values are allocated. An exception during construction or assignment leaves the Any empty on construction, or unchanged on assignment.

Construction and assignment from a pointer store a reference, not a copy. isRef() is then true. The pointed-to object must outlive the Any. There is also a type-erased pointer constructor that takes a void* and a std::type_info, which must describe the object that pointer refers to.

An empty Any stores nothing. empty() is true, type() is typeid(void), and get() returns a null pointer. clear() destroys the stored value and returns to that state. swap() exchanges two Any objects without allocating.

any_cast<T>() copies or binds the stored value as T. A mismatch throws std::bad_cast. any_cast<T*>() returns a pointer to the stored object, or null when the types do not match. Do not any_cast to a type that is only related by conversion: an Any that holds int does not yield a float.

Void Type

Some templates cannot use void as a parameter: void is not a complete type, and it cannot be a function argument or a member. Void is an empty complete type that fills that slot. A traits specialization, a signal with no value, or a tuple-like parameter pack can name Void to mean "this parameter is absent" without leaving the type system.

Void has no data and no operations of its own. Compare it with typeid or a template specialization, do not construct values of it as application data.

Non-Copyable Objects

Inherit privately from NonCopyable when a type must not be copied. The copy constructor and assignment operator are private, so a derived class cannot copy or assign, and neither can a caller. The result is a compile-time error, not a run-time failure.

class MyClass : private Pt::NonCopyable
{
// ...
};

Private inheritance is the usual form: NonCopyable is not part of the public interface. Public inheritance also prevents copies, but it exposes the mixin as a base. Do not try to make a type copyable again in a further derived class; the private members of NonCopyable stay private. Types that need to be moved but not copied still inherit here and define their own move operations.

Singletons

Singleton<T> owns one T for the process. instance() creates that object on the first call and returns the same object on every later call. The instance is a function-local static created by create(); it lives until the program exits.

Derive from Singleton<T> with T equal to the derived class, and befriend the base so it can construct T:

class MySingleton : public Pt::Singleton<MySingleton>
{
friend class Pt::Singleton<MySingleton>;
protected:
MySingleton()
{ }
};

The derived constructor stays protected so callers cannot build a second instance. instance() is the only public way to get the object. Singleton is NonCopyable, so the instance cannot be copied either.

Construction is not synchronized. The first call to instance() must happen before other threads call it, or the program must otherwise guarantee a single initializing call. There is no destructor hook and no way to replace the instance. Use this type for a process-wide service that is created on demand, not for objects whose lifetime the caller must control.

Fixed-Size Integers

A width that is part of a contract belongs in a type, not in a comment. File formats, network protocols, atomic cells, and byte-order conversion all name an exact number of bits. The C++ fundamental types do not: int may be 16 or 32 bits, long may be 32 or 64. This group is the portable spelling of those widths.

Each name is a typedef for a fundamental type that has that width on the current platform. Pt::int32_t is a signed 32-bit integer; Pt::uint8_t is an unsigned 8-bit integer. The typedef may be int on one target and long on another. Code that uses the Pt name keeps the width without writing per-platform #if.

The signed and unsigned pairs are:

int8_tsigned 8-bit integeruint8_tunsigned 8-bit integer
int16_tsigned 16-bit integeruint16_tunsigned 16-bit integer
int32_tsigned 32-bit integeruint32_tunsigned 32-bit integer
int64_tsigned 64-bit integeruint64_tunsigned 64-bit integer

Use these types where the width is the contract. Use int or std::size_t where the platform's natural width is the contract. Byte-order conversion in Byte Order and the atomic cell in Atomic Operations are defined in terms of these widths.

Byte Order

A multi-byte integer has a byte order. On a little-endian host the least significant byte is stored first; on a big-endian host the most significant byte is stored first. File formats and network protocols pick one of those orders. This group converts between that external order and the order of the CPU that runs the program.

The host order is a compile-time fact. The headers define PT_LE or PT_BE from the toolchain, or from an explicit build setting. isLittleEndian() and isBigEndian() answer the same question at run time by inspecting a known integer in memory. Prefer the compile-time macros when the conversion can vanish on a matching host. Use the run-time queries when the answer must be a value.

swab() reverses the bytes of a fixed-size integer from Fixed-Size Integers. An 8-bit value is unchanged. 16-bit, 32-bit, and 64-bit values swap every byte. swab() always swaps; it does not know about host order. Overload swab() for a type that is not an integer when that type has a defined byte-wise reverse.

hostToLe() and leToHost() convert between host order and little-endian. On a little-endian host they return the value unchanged. On a big-endian host they call swab(). hostToBe() and beToHost() do the same for big-endian: they are no-ops on a big-endian host and swap on a little-endian host. Passing a value that is already in host order through hostToLe() produces the little-endian layout to store in a file or on the wire. Passing a little-endian value from a file through leToHost() produces a host integer that arithmetic can use.

Pt::uint32_t host = 0x01020304;
Pt::uint32_t le = Pt::hostToLe(host);
Pt::uint32_t back = Pt::leToHost(le);

The conversions are templates on the integer type. They expect a type that swab() already handles, or a type with its own swab() overload. Mixing a host integer with an external integer without these functions is the usual mistake: the value looks right on one endianness and silently wrong on the other.

Atomic Operations

These functions are the portable lock-free cell. They operate on Pt::atomic_t, a union that holds an integer or a pointer at the width the platform uses for atomic instructions. Construct an atomic_t with an initial integer, or leave it at zero. Pass it as a volatile reference to every operation. Do not read or write the union members directly; that bypasses the atomic instruction and the memory fence.

atomicGet() returns the current integer and then issues an acquire fence, so later loads and stores in program order cannot move before the get. atomicSet() issues a release fence and then stores a new integer, so earlier loads and stores cannot move after the set. Together they publish a value from one thread and observe it on another without a mutex.

atomicIncrement() and atomicDecrement() add or subtract one and return the resulting value. They are the usual way to implement a reference count: increment on each new owner, decrement on each release, and destroy when the decrement returns zero.

atomicExchange() stores a new integer and returns the previous one. atomicCompareExchange() stores exch only when the cell still holds comp, and returns the value that was actually present. The compare-exchange is the building block for lock-free updates: read, compute a new value, and retry until the cell has not changed in between. atomicExchangeAdd() adds an integer and returns the previous value.

Pointer overloads of atomicExchange() and atomicCompareExchange() do the same operations on a void* volatile cell. Use them for lock-free lists and other pointer structures. The integer and pointer forms are separate overloads; they do not convert.

These operations are the primitives under Concurrency. A mutex, a condition variable, or a queue is the right tool when the critical section is more than one cell. Use this group when a single integer or pointer must change without a lock, and when the acquire or release fence is the only ordering the algorithm needs.

Streams

These types are the framework's iostream layer. They sit on the standard stream hierarchy and add the operations that a buffer which knows its get and put areas can offer: peek several characters without consuming them, and write only as many characters as still fit.

BasicStreamBuffer is a std::basic_streambuf. Derived buffers implement underflow, overflow, and the rest of the streambuf protocol as usual. On top of that, speekn() copies up to a requested number of characters from the get area, calling underflow once if the get area is empty, and does not advance the get pointer. out_avail() is the number of characters waiting in the put area. A derived buffer that is unbuffered overrides showfull() so out_avail() can still report space.

BasicIStream, BasicOStream and BasicIOStream are std::basic_istream, std::basic_ostream and std::basic_iostream that hold a BasicStreamBuffer pointer. buffer() and setBuffer() get and replace that pointer and keep rdbuf() in sync. peeksome() forwards to speekn() when the stream's rdbuf is that buffer, so a caller can look ahead by more than one character. writesome() forwards to sputn() for as many characters as out_avail() allows, and writes nothing when the put area is empty. That is the difference from write(): a partial write that does not block on a flush.

The character type is a template argument, with std::char_traits as the default traits. Byte streams use char. Text streams in this module use Pt::Char. A buffer and a stream that work together must use the same character type.

Construct a stream with a buffer, or with a null buffer and call setBuffer() later. The stream does not own the buffer. Destroy the buffer only after the stream has been destroyed or given another buffer. Replacing the buffer while formatted operations are in progress leaves the stream's locale and error state in place and redirects subsequent extraction or insertion.

Pt::BasicStreamBuffer<char>* buf = ...;
Pt::BasicIStream<char> in(buf);
char ahead[16];
std::streamsize n = in.peeksome(ahead, 16);

Zlib Streams uses this model for zlib compression. I/O that fails at this layer is a IOError.

Stream Buffer

BasicStreamBuffer is the buffer in the Streams model. It is a std::basic_streambuf. Derived buffers implement underflow and overflow as they would for any streambuf. This type adds two queries that those derived buffers can serve from the get and put areas without going through the iostream formatting layer.

speekn() copies up to size characters from the get area into buffer and does not consume them. If the get area is empty, it calls underflow once. An unbuffered buffer, where underflow produced a character but no get area, yields that one character. A short count means fewer characters were available, not that the stream has failed.

out_avail() is the number of characters already in the put area. When there is no put pointer, it calls showfull(), which is zero unless a derived unbuffered buffer overrides it.

The stream types BasicIStream, BasicOStream and BasicIOStream hold a pointer to this buffer. They do not own it. A derived buffer must outlive every stream that still uses it.

Input Stream

BasicIStream is the input stream in the Streams model. It is a std::basic_istream that holds a BasicStreamBuffer pointer. Extraction uses that buffer through rdbuf(). peeksome() looks ahead by more than one character: it calls BasicStreamBuffer::speekn() when rdbuf() is still this buffer, and otherwise peeks a single character. The returned count may be less than requested.

buffer() returns the pointer. setBuffer() replaces it and updates rdbuf(). The stream does not own the buffer. Pass a null buffer to construct a stream that will receive one later.

Output Stream

BasicOStream is the output stream in the Streams model. It is a std::basic_ostream that holds a BasicStreamBuffer pointer. Insertion uses that buffer through rdbuf(). writesome() writes at most BasicStreamBuffer::out_avail() characters with sputn() and writes nothing when the put area is empty. That is a partial write; it does not block waiting for overflow to flush.

buffer() returns the pointer. setBuffer() replaces it and updates rdbuf(). The stream does not own the buffer.

Input/Output Stream

BasicIOStream is the bidirectional stream in the Streams model. It is a std::basic_iostream that holds a BasicStreamBuffer pointer. peeksome() is the input lookahead of BasicIStream. writesome() is the partial write of BasicOStream. Both require that rdbuf() still is this buffer.

buffer() returns the pointer. setBuffer() replaces it and updates rdbuf(). The stream does not own the buffer. Use this type when the same buffer is read and written; use BasicIStream or BasicOStream when the direction is fixed.

Zlib Streams

These types put zlib in the stream model of Streams. A ZBuffer is a BasicStreamBuffer of char attached to a target std::ios. Reading the buffer inflates compressed bytes from the target. Writing the buffer deflates uncompressed bytes into the target. The stream wrappers own a ZBuffer and present it as an input stream, an output stream, or both.

ZBuffer::Format selects the on-wire layout. ZBuffer::Zlib is the zlib wrapper with an Adler-32 checksum. ZBuffer::Gzip is the gzip wrapper with a CRC-32 checksum, as in RFC 1952. The format is fixed at construction. It must match the data on the target; a gzip file attached as zlib, or the reverse, fails during inflate.

attach() sets the target stream. detach() drops it without finishing a compressed frame. reset() discards buffered data and returns the zlib state to the start of a new stream, and can take a new target at the same time. discard() throws away the buffer contents and resets the state while keeping the target. finish() flushes the remaining compressed bytes to the target and ends the zlib stream. After finish(), start a new stream with reset() before writing more.

zcount() is the total number of uncompressed bytes produced by inflate so far. It does not count compressed bytes written on the deflate path.

ZIStream, ZOStream and ZIOStream construct a ZBuffer, install it with setBuffer(), and forward attach(), detach(), reset() and finish() to it. ZIStream reads uncompressed bytes from a compressed std::istream. ZOStream writes uncompressed bytes to a compressed std::ostream. ZIOStream does both on a std::iostream. zBuffer() returns the owned buffer when a caller needs the buffer API directly.

std::ifstream file("data.gz", std::ios::binary);
Pt::ZIStream in(file, Pt::ZBuffer::Gzip);
std::string text;
in >> text;

The stream wrappers do not own the target. Keep the target alive until detach() or destruction. Call finish() on an output stream before relying on the target to contain a complete zlib or gzip frame. A format mismatch or a truncated frame surfaces as a stream error, typically a IOError.

ZBuffer

ZBuffer is the buffer in the Zlib Streams model. It is a BasicStreamBuffer of char attached to a target std::ios. Reading inflates compressed bytes from the target. Writing deflates uncompressed bytes into the target.

Format selects zlib or gzip wrapping at construction and must match the target data. attach() sets the target. detach() drops it without finishing a frame. reset() discards buffered data and begins a new zlib stream, optionally with a new target. discard() resets the state and keeps the target. finish() flushes remaining compressed bytes and ends the stream.

zcount() is the total number of uncompressed bytes produced by inflate. The buffer does not own the target; keep the target alive until detach() or destruction.

ZIStream

ZIStream owns a ZBuffer and reads uncompressed bytes from a compressed std::istream. Construct it with a format, or with a target stream and a format. attach(), detach(), reset() and finish() forward to the buffer. zBuffer() returns the owned buffer. The target is not owned.

ZOStream

ZOStream owns a ZBuffer and writes uncompressed bytes to a compressed std::ostream. Construct it with a format, or with a target stream and a format. attach(), detach(), reset() and finish() forward to the buffer. Call finish() before relying on the target to contain a complete frame. The target is not owned.

ZIOStream

ZIOStream owns a ZBuffer and reads and writes uncompressed bytes on a compressed std::iostream. attach(), detach(), reset() and finish() forward to the buffer. zcount() is inflate output so far. The target is not owned.

Dates and Times

Pt::Timespan, Pt::Date, Pt::Time and Pt::DateTime represent durations and points in calendar time.

Timespan

Time intervals can be represented by Pt::Timespan objects with microsecond accuracy. It is often the result of the calculations involving Pt::Date, Pt::Time ot Pt::DateTime. A Pt::Timespan can be constructed from the number of microseconds and then be converted to other time units with toHours(), toSeconds(), toMSecs() and toUSecs(). When two Pt::Timespans are compared, the shorter one is considered less. Addition and subtraction is supported as shown in the next example:

#include <Pt/Timespan.h>
#include <iostream>
Pt::Timespan ts1(10000);
Pt::Timespan ts2(1000);
Pt::Timespan d = ts2 - ts1;
std::cout << "The difference is " << d.toSecs() << " secs" << std::endl;

Date

Pt::Date is an easy way to handle calendar dates. It can be constructed from the days, month and year components or from an ISO string using fromIsoString(). The date components can be accessed with day(), month() and year(). Once a Date object is created, calendar information can be accessed, for example with dayOfYear(), dayOfWeek() or isLeapYear(). Dates can be compared and a Date is considered less if it is earlier than another Date. It is also possible to add or subtract days from a Date, which yields a new date or modifies it. Dates can be subtracted, which yields the number of days between them, as shown in the next example:

#include <Pt/Date.h>
#include <iostream>
int daysUntilChristmas(const std::string& todayStr)
{
Pt::Date today = Pt::Date::fromIsoString(todayStr);
Pt::Date xmas(today.year(), 12, 24);
return xmas - today;
}

Pt::InvalidDate is thrown, if a date could not be constructed, for example if one of the date components is out of range. To avoid an exception, date components can be validated with isValid().

Time

A Pt::Time object contains a wall-clock time in hours, minutes, seconds amd milliseconds. It can be constructed either from the numeric time values or from a string in ISO format using fromIsoString(). The separate time values can be accessed with hour(), minute(), second() and msec(). Times can be compared and a Time is considered less, if it is earlier than another Time. Subtracting a time from another yields a Pt::Timespan as the result. Pt::Timespans can also be added or subtracted from a Time, yielding a new Time or modifying it, as shown in the following example:

#include <Pt/Date.h>
#include <iostream>
Pt::Timespan untilMidnight(const std::string& timeStr)
{
Pt::Date now = Pt::Time::fromIsoString(timeStr);
Pt::Date midnight(0 ,0, 0);
return midnight - now;
}

Pt::InvalidTime is thrown, if a time could not be constructed, for example if one of the time values is out of range. To avoid an exception, time values can be validated with isValid().

DateTime

Pt::DateTime combines a Pt::Date and a Pt::Time object into one instance. It can either be constructed from the corrsponding numeric values or a string in ISO format. The date and time parts can be accessed with date() and time(). When two DateTimes are compared, one is considered less, if its date is earlier or if the time is earlier in case of equal dates. A Pt::Timespan can be added or subtracted from a DateTime and this is also the result when two DateTimes are subtracted. To avoid the exceptions thrown by the underlying time and date, isValid() can be used to check numeric date and time values.

Coroutines

A Task is a C++20 coroutine that produces a single result and can be cancelled while it is suspended. A Generator yields a sequence of values and may also suspend. Both types integrate with the event loop: awaitable operations resume the coroutine when I/O, timers or other loop-driven work complete.

These types are available when the program is compiled as C++20 or later.

Use a function that returns Task as an asynchronous unit of work. The coroutine starts suspended. Use Task::run() to begin execution on the current thread. After the task has finished, Task::done() is true and Task::result() returns the value.

Inside a task, co_await suspends until an awaitable operation completes. Framework types already provide awaitables, for example Pt::System::Timer::waitAsync() for a one-shot delay. Custom operations derive from Awaiter or BasicAwaiter.

I/O awaitables typically resume from the event loop. The usual pairing is to start the task with Task::run() and then run the loop so completions can resume the coroutine.

Destroying a task or calling Task::cancel() aborts the pending awaitable and destroys the coroutine frame. Nested tasks cancel the inner pending operation the same way. A generator cancels in the same manner.

Exceptions that leave a coroutine body are stored. They are rethrown by Task::result() and by co_await of that task.

The following example starts a task that waits for a timer and then exits the loop:

Pt::Task<> delayThenExit(Pt::System::EventLoop& loop,
Pt::System::Timer& timer)
{
co_await timer.waitAsync(1000);
loop.exit();
}
int main()
{
Pt::System::MainLoop loop;
Pt::System::Timer timer;
timer.setActive(loop);
Pt::Task<> task = delayThenExit(loop, timer);
task.run();
return loop.run();
}

Tasks

A Task is move-only and owns the coroutine frame. A default-constructed task is empty; Task::operator bool() is false until a coroutine is assigned. Move assignment cancels the current task first.

Task<void> produces no value. Task<T&> returns a reference; that object must outlive the consumer.

An outer coroutine can co_await an inner Task. The co_await expression is the inner result:

Pt::Task<int> inner()
{
co_return 41;
}
Pt::Task<int> outer()
{
int n = co_await inner();
co_return n + 1;
}

Task::run() and awaiting a task that is already pending throw std::logic_error.

Awaitables

Derive from Awaiter to wrap an asynchronous operation so a Task or Generator can suspend until it completes. Implement Awaiter::onBegin() to subscribe to completion and start the work. Implement Awaiter::onCancel() to abort that work. Call Awaiter::setReady() when the operation finishes; that resumes the waiting coroutine.

Implement await_resume() in the subclass to deliver the result, or derive from BasicAwaiter when the awaitable only needs to produce a value through BasicAwaiter::onReady().

The following awaiter starts a device operation and resumes when the device signals completion:

class AsyncOp : public Pt::Awaiter
{
public:
explicit AsyncOp(Device& device)
: _device(device)
{}
int await_resume()
{
return _device.endOp();
}
protected:
void onBegin() override
{
_device.finished() += Pt::slot(*this, &AsyncOp::setReady);
_device.beginOp();
}
void onCancel() override
{
_device.cancel();
}
private:
Device& _device;
};

Use BasicAwaiter instead of Awaiter when the awaitable only needs to produce a value, or to complete with no value. Subclasses still implement Awaiter::onBegin() and Awaiter::onCancel(). Implement BasicAwaiter::onReady() to return the result. The co_await expression is that return value.

BasicAwaiter<void> is the specialization for operations that do not produce a result. Its BasicAwaiter::onReady() returns nothing and can finalize or clean up the operation.

Generators

A Generator produces values lazily with co_yield. Unlike a synchronous generator, its body may also co_await.

Consume a generator from a Task. Await Generator::next() until it returns false and read each value with Generator::value().

A Generator is move-only and owns the coroutine frame. Generator<T&> yields a reference. That object must remain valid until the next Generator::next() or until the generator is destroyed.

Only one Generator::next() may be pending. Awaiting next() while another next() is already pending throws std::logic_error.

Exceptions that leave the coroutine body are rethrown from the Generator::next() await.

The following task sums the values of a generator:

Pt::Generator<int> squares(int n)
{
for(int i = 1; i <= n; ++i)
co_yield i * i;
}
Pt::Task<int> sumSquares(int n)
{
auto gen = squares(n);
int sum = 0;
while( co_await gen.next() )
sum += gen.value();
co_return sum;
}

Allocators

The Pt::Allocator interface can be used to optimize or customize allocation strategies. Two allocators are provided, which can be approached by the Allocator interface, a pool based allocator and a page based allocator. A pool based allocator is beneficial in all cases where many small objects of small sizes are created. This is for example used to optimize memory usage during serialization. The page based allocator simply places data consecutively in memory and frees the whole block when its no longer in use. This is useful in situations where chunks of memory or objects are created and destroyed at the same time.

The Allocator Interface

Allocators allow a program to use different methods of allocating and deallocating raw memory. The default implementation will simply use new and delete. Custom allocators are implemented by overriding the two methods allocate() and deallocate() of the Pt::Allocator base class. The following example tracks the amount of allocated memory:

class CheckedAllocator : public Pt::Allocator
{
public:
CheckedAllocator()
: _allocated(0)
{}
virtual void* allocate(std::size_t size)
{
void* p = Pt::Allocator::allocate(size);
_allocated += size;
return p;
}
virtual void deallocate(void* p, std::size_t size)
{
Pt::Allocator::deallocate(p, size);
_allocated -= size;
}
std:size_t allocated() const
{ return _allocated; }
private:
std::size_t _allocated;
};

This interface differs from std::allocator used for STL containers, because it allows to allocate memory of different sizes through the same interface. The std::allocator is meant to allocate and also construct objects of the same size. It is however possible, to implement a std::allocator using the raw memory allocators described here.

Pool Allocation

The PoolAllocator uses pools to allocate memory. Each pool consists of blocks of equally sized records, which can be used for allocations up to the size of a record. The record sizes increase from pool to pool. When memory is allocated, a record is used from the pool, which handles the requested size. When memory is deallocated, the record is returned to the corresponding pool. This method of allocation is effective, because larger blocks of memory are allocated and then reused in the form of many smaller records. An advantage of this kind of allocator, compared to free list based allocators, is that it is able to release completely unused blocks.

// Contruct with max. record size, alignment and block size
Pt::PoolAllocator allocator(32, 8, 4096);
// will use a record from the pools
void* p1 = allocator.allocate( sizeof(float) );
// too large, will use operator new
void* p2 = allocator.allocate( 64 );

When a PoolAllocator is constructed, the maximum size for records has to be specified. The reason for this is that this type of allocator is ineffective for large allocations. Therefore, memory which is larger than this limit will be allocated using the new operator, instead of a record from a memory pool. Optionally, the alignment and the maximum block size can be set. The record sizes of the pools will be multiples of the alignment. So if the alignment is 8, the first pool will have records of size 8, the second pool records of size 16 and so forth, until the maximum size is reached. The maximum block size controls the number of records per block. A new block of records is added, when a pool is depleted and has to be extended to allow more allocations.

If memory of uniform sizes has to be allocated, a MemoryPool can be used directly, rather than indirectly as part of the PoolAllocator. This can be faster, because the PoolAllocator has to look up the pool for the requested size of memory each time it allocates and deallocates. To construct a MemoryPool, the size of the records, i.e. the size of memory it can allocate, has to be specified.

Pt::MemoryPool pool( sizeof(float), 4096 );
void* p = pool.allocate();
float* f = new (p) float(3.1415);
pool.deallocate(f);

Optionally, the maximum size of the blocks in the pool can be controlled. In the example shown above, the pool can only allocate memory of the size required for a float. Each time the pool itself requires more memory, it will allocate a new block of 4096 bytes.

Page Allocation

The PageAllocator is useful, when chunks of memory have to be allocated, that can be released simultaneously. This allows the PageAllocator to allocate memory consecutively on pages and simply release all pages together at the end. Therefore, deallocate() will not do anything, but memory will only eventually be released, when clear() is called or the PageAllocator is destructed. The next example illustrates this:

void useAllocator(Pt::Allocator& a)
{
for(std::size_t n = 1; n < 16; ++n)
{
void* p = a.allocate(n);
...
// won't do anything if it's a PoolAllocator
a.deallocate(p, n);
}
}
Pt::PageAllocator allocator;
useAllocator(allocator);
// release all allocated memory
allocator.clear();

Text Processing

This set of classes and functions extends the string and localization support of the C++ standard library to work with unicode characters and strings. A unicode character type and string class (a specialization of std::basic_string) can be used to hold unicode text. A set of functions allows to transform and classify individual characters. Text can be converted e.g. between different encodings using i/o streams and text codecs. A regular expression class allows to search and match patterns in unicode strings. Localization facets are available for the systems which support standard C++ locales.

One of the most common standards for character encoding is the ASCII standard. Each character is encoded using 7 bits of a byte, so 128 different characters can be addressed. Reading and writing ASCII characters is straightforward, because each character is stored in exactly one byte. The builtin C++ type char can be used to represent ASCII characters. The draw-back of ASCII, of course, is the small character set of only 128 characters. There are a lot more characters than that in the languages all around the world.

This problem was addressed by the Unicode standard, which was created to make every known character of the world available in a single character table. Each character has a defined position in the table, a so-called code point. The unicode table contains 0x10FFFF entries at the moment, so a 32 bit type is required to represent a raw unicode character.

The UTF-8 encoding was introduced to store unicode characters in byte sequences, which are compatible to classic null-terminated C strings. One unicode character is encoded into a byte sequence of 1 or more bytes. Further, the characters are encoded such that a character in 7-bit ASCII has the exact same value as in UTF-8, so any valid ASCII text is valid UTF-8 encoded text. This demonstrates the difference between encodings and character types. ASCII and UTF-8 can both be represented by sequences of the character type char, but their values are interpreted according to the encoding. Besides UTF-8 encoding, many more encodings have been developed, for example Latin-1, UTF-16 or, in the broadest sense, Base64.

Characters and Strings

The unicode character type Pt::Char can directly represent a unicode code point. It is used as the character type for Pt::String or Pt::StringStream. Characters can be classified or transformed using a set of functions similar to what can be found in the cctype header of the standard library:

Pt::Char ch = 'a';
// check character category
assert( isalpha(ch) );
assert( islower(ch) );
// convert to upper case
Pt::Char ch2 = toupper(ch);
assert( isupper(ch) );

This class Pt::String is not yet another unicode string class, but it is a specialization of the std::basic_string template for the unicode character type Pt::Char:

typedef std::basic_string<Pt::Char> String;

It offers all the functionality of the std::basic_string template. This has the advantage, that all generic algorithms that work with std::basic_string should also work with Pt::String. Please refer to a standard c++ manual for a complete overview. Additional methods make it easier to work with other character types. For example, the relational operators are also overloaded for char and wchar_t.

Since a specialization of std::char_traits is also provided, the C++ iostreams can be instantiated for Pt::Char, including the string streams. Three typedefs provide shorter names for the unicode capable string streams:

typedef std::basic_istringstream<Pt::Char> IStringStream;
typedef std::basic_ostringstream<Pt::Char> OStringStream;
typedef std::basic_stringstream<Pt::Char> StringStream;

The insertion and extraction operators (<< and >>) for iostreams require certain localization facets to be present in the std::locale. Pt will install specializations of std::num_put, std::num_get, std::numpunct and std::ctype for Pt::Char. This means that all other facilities that use localization facets will also work.

Text Streams and Codecs

A Pt::TextCodec is used by text converters to encode and decode external byte sequences, hence the name codec. It implements the std::codecvt facet interface, on systems provide the std::locale facilities. Codecs are stateless, which means that one codec can be used with multiple text converters. A TextCodec is constructed with a reference counter that indicates whether the converter or locale manages the lifetime of the codec. If that value is 0, as it is the case if the TextCodec is default constructed, the text converter or locale will delete the codec.

Pt::TextOStream tos(new Pt::Utf8Codec);

Therefore, a default constructed TextCodec has to be cretaed with new, as it is the rule for all localization facets. This can be avoided by passing a value different from 0 to the codecs constructor, in which case the codec must exist at least as long as the stream that uses it:

Pt::Utf8Codec codec(1);
Pt::TextOStream tos(&codec);

This stream decodes an external character sequence using a codec. Reading from the stream will convert from the the encoding of external characters.

Text streams do not only convert between text encodings, but also between character types of different size. The first template parameter CharT is the character type of the decoded text and the second one ByteT is the character type of the encoded text. They are also called the internal and external character types and may be of the same type. The internal character type is used as the character type of the standard C++ stream base class.

A text stream always works with another stream as input or output. This stream works with another std::basic_istream to read the encoded input, using the external character type. A text stream can be constructed with an underlying stream and a codec, but both can also be set or reset later. If no codec is set, the stream will directly assign characters, instead of converting them. If no target stream is set, the text stream will always be EOF.

The following example demonstrates how a string stream is used as the input for a text stream, which uses a Pt::Utf8Codec to decode UTF-8 encoded text:

std::istringstream iss("UTF-8 encoded text");
Pt::String s;
Pt::TextIStream tis(iss, new Pt::Utf8Codec());
std::getline(tis, s);

The std::getline() function reads all input into a Pt::String. The extraction operator can also be used, for example to directly read numbers from the stream.

This stream encodes an external character sequence using a codec. Writing to the stream will convert the written characters to the external character types in the external encoding.

The following example shows how to encode text to an UTF-8 byte sequence:

std::ostringstream oss;
Pt::String s = L"Hello World!";
Pt::TextOStream tos(oss, new Pt::Utf8Codec());
tos << s;
tos.flush();

The string stream serves as the output of the text stream, which uses a Pt::Utf8Codec to encode text to UTF-8. The insertion operator can be used for strings or to format numbers. When all data has been written to the text stream, flush() needs to be called to finish off the output byte sequence. This is especially important for encodings with shift states.

Base-64 Encoding

The base-64 encoding scheme is not a character encoding in the classical sense, but works very similar to other types of encodings. Base64Codec can be used with the basic text stream templates, where the internal and external character types are both char. The following example shows how text is converted to base-64:

std::ostringstream oss;
BasicTextOStream<char, char> b64(oss, new Base64Codec());
b64 << "Hello World!";
b64.flush();

The string stream serves as the output for the base-64 encoded text. It is important to terminate the output sequence by calling flush(), because the base-64 format requires padding at the end. Inserting std::endl also terminates the base-64 sequence.

Regular Expressions

The Pt::Regex class allows to match a string pattern in unicode text. It resembles the std::basic_regex class and can be used to support systems, where std::basic_regex is not available in the standard C++ implementation. The syntax for the match pattern is similar to the extended POSIX syntax. The following table shows the special characters that can be used to write regular expressions:

.Any character
[ ]A character in a given set
[^ ]A character not in a given set
^Begin of line
$End of line
\<Begin of a word
\>End of a word
( )A marked subexpression
*Matches the preceding element zero or more times
?Matches the preceding element zero or one time
+Matches the preceding element one or more times
|Matches either the expression before or after the operator
\ Escapes the next character

The regular expression is constructed from a unicode string, either a Pt::String or a null-terminated sequence of unicode characters of type Pt::Char. It can then be used to match it against unicode strings as shown in the next example:

Pt::String expr = L"[hc]ats";
Pt::Regex regex(expr);
Pt::String str1 = L"I like cats!";
Pt::String str2 = L"I like hats!";
Pt::String str3 = L"I like bats!";
// this does match
bool matched = regex.match(str1);
// this does also match
matched = regex.match(str2);
// this does not match
matched = regex.match(str3);

It is also possibe to match a regular expression against a unicode input string and find out what tokens in the string actually matched. The match() member function has an overload, which fills a Pt::RegexSMatch with the result. Note that the first result at index 0 is always the input string itself. The following example illustrates this:

Pt::String expr = L"([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)";
Pt::Regex regex(expr);
Pt::String str = L"My IP address is 192.168.0.77";
Pt::RegexSMatch smatch;
bool matched = regex.match(str, smatch);
if(matched)
{
std::cout << "IP: " << smatch.str(1).narrow() << std::endl;
}
else
{
std::cout << "No IP in " << smatch.str(0).narrow() << std::endl;
}

Signals and Delegates

Callback mechanisms for event handling have become ubiqitous in todays application frameworks. Older examples are the use of function pointers as callbacks or the message maps found in the MFC toolkit. More modern approaches include the .NET delegates and so-called signal-slot techniques. When signals and slots are used, objects can communicate with each other by connecting a signal of one object to the slot of another object. In most cases connection management features are built-in so that an object closes all it connections automatically when it gets destroyed. Once the connection has been established, all connected slots are called when a signal is send. Connecting signals to slots is type-safe i.e. a signal can only be connected to a slot that matches the signal's signature. At the same time it allows a great deal of flexibility (loose coupling), since the caller has no intimite knowledge of the callee. A simple but real example might look like this:

int main()
{
Pt::System::Application app;
Pt::System::Timer timer;
timer.setActive( app.loop() );
timer.start(1000);
timer.timeout() += Pt::slot(app, &Pt::System::Application::exit);
return app.run();
}

This program will simply exit, when the timer expires after 1000 ms. The application object is the callee and the member function Application::exit serves as a slot. The timer is the caller, which has a signal called timeout.

Non-generic lambdas and function objects derive their slot signature from operator(). Generic lambdas use an explicitly stated slot signature. The callable must be copyable because a connection clones its slot. A lambda slot cannot be removed with operator-= because captured values do not have general equality. Retain the returned Connection and close it when no context is supplied.

Pt::Signal<int> signal;
Pt::Connection connection = signal +=
Pt::slot([this](int) { add(42); });
Pt::Delegate<int, int> delegate;
delegate += Pt::slot<int, int>([](auto value) { return value + 1; });
int result = delegate.call(41);
connection.close();
Pt::Connectable context;
signal += Pt::slot(context, [this](int) { add(42); });

Destroying the context closes its bound connections automatically. The context must outlive every unsafe capture while the connection remains open.

Signals

Signals are normally members of objects and are being sent e.g. When the object state changes or some event occurs. When a signal is sent, it calls all slots it is connected to. Callable entities, like functions or member functions can serve as slots for signals. The template parameter list of the Pt::Signal class template determines the signature of the signal. If a signal does not have any arguments the parameter list is left empty:

Pt::Signal<> sig0; // Signal without arguments
Pt::Signal<int> sig1; // Signal with one argument
Pt::Signal<int, int> sig2; // Signal with two arguments

Slots can be constructed with the slot() function, which is overloaded for various types of callable entities, most notably functions or member functions. Slots are lightweight proxy-objects and one example is the Pt::MethodSlot, which allows to use a member function as a slot.

A signal can be connected to a slot if the signatures are compatible. One important feature of Pt::Signal is that the return value of a slot is ignored and therefore a slot is compatible to a signal no matter what type it returns. The following code example shows how a signal is connected to a function and a member function:

class Callee : public Pt::Connectable
{
public:
void slot()
{ std::cout << "Callee::slot() called" << std::endl; }
};
void slot()
{ std::cout << "slot() called." << std::endl; }
int main()
{
Callee callee;
Pt::Signal<> signal;
signal += Pt::slot(slot);
signal += Pt::slot(callee, &Callee::slot);
return 0;
}

Two slots are constructed, one from a function pointer and another one from a member function pointer and the object instance to be called. The signal is connected to both slots. Signals can only be connected to objects that derive from Pt::Connectable, to ensure that all connections are closed when the object runs out of scope and no dangling connections are left. The += operator, to connect a signal with a slot, returns a connection object, which can be used to disconnect signals from slots manually. The following code illustrates this:

void slot()
{ std::cout << "slot() called." << std::endl; }
int main()
{
Pt::Signal<> signal;
Connection c = signal += Pt::slot(slot);
c.isValid() // returns true
c.close();
c.isValid() // returns false
return 0;
}

A connection is reference counted and can not be duplicated as such, but always refers to the same shared connection data. If one peer of a connection is destroyed or the connection is closed manually, the connection becomes invalid. Once a connection has been established, signals can be send to invoke the connected slots. This happens by calling send() with the appropriate arguments, if any.

void tellAge(int age)
{ std::cout << "I am " << age << " years old\n"; }
int main ()
{
Pt::Signal<int> signal;
signal += Pt::slot(tellAge);
signal.send(26);
return 0;
}

When the signal is send, the slot is called with the same value passed to Signal::send. Nothing will happen if the signal is not connected to any slots. When a signal is sent, the slot is called immediatly and directly and does not depend on an event loop. If multiple slots are connected to a signal, the slots will be called one after another.

Delegates

The Pt::Delegate is an alternative to the Pt::Signal and differs from it in two ways. Firstly, delegates forward the return value of the slot and secondly, delegates can only be connected to one slot at a time. The same types of slots can be used for signals and delegates. The template parameter list of the Delegate determines its signature, where the first parameter represents the return type:

Pt::Delegate<int> del0; // Delegate only returns int
Pt::Delegate<int, int> del1; // Delegate with one argument
Pt::Delegate<int, int, int> del2; // Delegate with two arguments

The syntax for connecting delegates is identical to how signals are connected to slots. However, since a delegate forwards the return value of its slot, not only the arguments passed to the slot must be compatible, but also the return value. Furthermore, when an already connected delegate is connected again, the current connection will be closed and the delegate is connected to its new target.

int slotA()
{ return 5; }
int slotB()
{ return 6; }
int main()
{
Pt::Delegate<int> delegate;
delegate += Pt::slot(slotA);
delegate += Pt::slot(slotB); // disconnects from slotA
return 0;
}

The example above constructs a delegate which can be connected to any slot that returns an int. It is first connected to a slot of the function slotA. When it is connected for the second time to a slot of the function slotB, the old connection will be closed and only slotB is called when the delegate is called.

There are two possibilities how a delegate can call its slot. The member function call() will return the return value of the slot. If the delegate is not connected to a slot, an exception is thrown. The second method is through invoke(), where the return value is ignored, but if the delegate is not connected, no exception will be thrown.

int slot()
{ return 5; }
int main()
{
try
{
Pt::Delegate<int> delegate;
Pt::Connection connection = delegate += Pt::slot(slot);
int i = delegate.call(); // i is 5 now
connection.close();
delegate.invoke() // does not throw
delegate.call(); // will throw because not connected
}
catch(const std::logic_error& ex)
{
std::cerr << "could not call delegate" << std::endl;
}
return 0;
}

The program above connects a delegate to a slot and then calls it. After the connection was closed, the delegate is invoked, which has no effect. Finally, when the delegate is called again, an exception is thrown and catched.

Type Traits and Information

Two types are useful to get type information: Pt::TypeTraits and Pt::TypeInfo. TypeTraits are used for generic programming, for example to deduce the pointer type in templated code, or to branch differently for const and non-const types. The TypeInfo class is a wrapper for std::type_info, which makes it easier to store and compare type information. The std::type_info is normally not copyable and comparable. Pt::SourceInfo is used to store information about a location in the source code.

Type Information

The normal std::type_info class is not copyable, so only raw pointers can be stored in containers such as std::vector. The TypeInfo class addresses this problem, by wrapping std::type_info into a type with value semantics. It also adds comparison operators, like less-than comparison, which allow to use it as the key for assoziative containers:

// OK, TypeInfo is copyable
std::vector<Pt::TypeInfo> typeVector;
// OK, TypeInfo is less-than comparable
std::map<Pt::TypeInfo, std::string> typeMap;

Conversions

The framework includes functions for fast conversion between strings and numbers. The overloaded functions Pt::parseInt() and Pt::formatInt() convert between strings and integers, and Pt::parseFloat() and Pt::formatFloat() convert between strings and floats. The functions work with iterators as input or output instead of string objects, so they can be used with simple buffers or even streams, as shown in the following example:

#include <Pt/Convert.h>
#include <iterator>
#include <iostream>
std::ostream_iterator<char> it(std::cout);
Pt::formatInt(it, 42);
const char* buf = "42";
const char* bufend = buf + 2;
int n = 0;
Pt::parseInt(buf, bufend, n);

A stream iterator is used to format a number directly to std::cout and then a number is parsed from a raw character buffer. Floating point numbers can be formatted and parsed in a similar way like integers:

#include <Pt/Convert.h>
#include <iterator>
#include <iostream>
std::ostream_iterator<char> it(std::cout);
Pt::formatFloat(it, 42.123);
const char* buf = "42.123";
const char* bufend = buf + 6;
float f = 0;
Pt::parseFloat(buf, bufend, f);

By default, decimal format is used when numbers are parsed and formatted, but overloads exist that accept an additional format object. A few format objects are already provided by the framework, named Pt::DecimalFormat, Pt::OctalFormat, Pt::HexFormat and Pt::BinaryFormat, which allow numeric conversion in a different base. The next example shows how integers in hex format can be parsed and formatted:

#include <Pt/Convert.h>
#include <iterator>
#include <iostream>
Pt::HexFormat<char> fmt;
std::ostream_iterator<char> it(std::cout);
Pt::formatInt(it, 0x42, fmt);
const char* buf = "0x42";
const char* bufend = buf + 4;
int n = 0;
Pt::parseInt(buf, bufend, n, fmt);

All parse functions used so far throw an exception of type Pt::ConversionError, if the conversion failed. Overloads of the parse functions are available, which set a bool flag instead, to indicate a conversion error. Both, the format and parse functions return an iterator pointing to the position after the last character that was written or read, respectively. Partial consumption of the input is not treated as an error.

Numeric assignments can lead to loss of data, if the operation narrows the data type to a smaller one. For example, numeric conversion from int to short can be an error, if the assigned value exceeds the maximum or minimum value possible for shorts. Pt::narrow() can be used instead of a normal assignment, to protect against this. In case of an error, an exception of type Pt::ConversionError is thrown.

#include <Pt/Convert.h>
#include <limits>
#include <iostream>
long l = ...;
short s = 0;
try
{
s = Pt::narrow<short>(l);
}
catch(const Pt::ConversionError& e)
{
std::cerr << "numeric value is out of range: " << l << std::endl;
}

Serialization

Data structures and types can be serialized to text or binary formats using Pt's serialization. This is used within the framework to load and store data or to implement remote procedure calls. It is extensible to work with all kinds of types, including STL containers, PODs (plain old data types), builtin language types or custom data types. The framework separates the process of composing and decomposing types from the formatting stage, resulting in a two-phase serialization process. This also allows to resolve and fixup shared pointers or references.

A type is serializable, if two operators are implemented to compose and decompose it to a SerializationInfo. The SerializationContext provides improved memory management, a mechanism to generate IDs for shared pointers and a way to further customize or override serialization for a type. Alternatively, performance can be increased by implementing a Composer or Decomposer for the type, however it is more complicated to do so.

Various formats are supported by implementing Formatters. Other modules of the framework also implement Formatters, for example to support serialization to XML. The Serializer and Deserializer combine a Formatter and a SerializationContext, manage composition and decomposition and thus form the high-level interface for the serialization of a set of types.

The Pt framework already provides serialization support for the C++ builtin types and the types provided by the C++ standard library, like std::string or std::vector. To make custom types serializable, the serialization operators have to be implemented. The next example shows a simple data type and the declarations of the serialization operators:

struct Address
{
Address()
: code(0)
{}
std::string country;
std::string city;
std::string street;
unsigned code;
};
void operator >>=(const Pt::SerializationInfo& si, Address& address);
void operator <<=(Pt::SerializationInfo& si, const Address& address);

Similar to the insertion and extraction operators for standard C++ iostreams, one operator has to be overloaded to serialize a type and another one to deserialize it. The types of operators (<<= and >>=) indicate that this is an assignment operation. Each type has to be composed from or decomposed to Pt::SerializationInfo objects, which form a tree representing the object graph. It contains all meta information so composition and decomposition can be separated from formatting and parsing. Building up the tree is highly optimized and, for example, requires only very few allocations. The next example shows the definition of the serialization operator:

void operator<<=(Pt::SerializationInfo& si, const Address& address)
{
si.addMember("country") <<= address.country;
si.addMember("city") <<= address.city;
si.addMember("street") <<= address.street;
si.addMember("code").setUInt32(address.code);
si.setTypeName("Address");
}

The SerializationInfo passed by reference to the operator is meant to represent an Address object in the object graph. SerializationInfo child nodes are added for each member variable using Pt::SerializationInfo::addMember(), which also assigns the name. Member types can be serialized to the returned SerializationInfo using their specific overload of the operator. For builtin integer types it is recommended to use a setter (here Pt::SerializationInfo::setUInt32()) instead of the serialization operator, to be specific on the type. Integer types could potentially be serialized differently depending on the platform. For example, a long could be serialized as a 32-bit or a 64-bit integer type. Finally, the type name is set for the parent node, representing the Address object.

The deserialization operator performs the same process, just in reverse. The SerializationInfo object passed to it contains the meta information for all members. SerializationInfo child nodes can be obtained by name using Pt::SerializationInfo::getMember() and members can be deserialized with their overloads of the deserialization operators. The following example illustrates this:

void operator>>=(const Pt::SerializationInfo& si, Address& address)
{
si.getMember("country") >>= address.country;
si.getMember("city") >>= address.city;
si.getMember("street") >>= address.street;
si.getMember("code") >>= address.code;
}