Flexclass
empowers the user to declare structures and arrays in a single allocation.
To steps are necessary to make a struct be recognized by the library:
- Declare handles that will refer to a sequence of objects in memory
- Declare a
fc_handles
method that the library will use to query
For example:
struct MyType
{
int myInt;
fc::Array<long> longs;
std::string str;
auto fc_handles() { return fc::make_tuple(&longs); }
};
fc::Array<long>
is identified as a Handle
, meaning it wants to point to a group of long
objects.
So we have the following layout:
____________________
| V
| [int] [long*] [std::string] | [long] [long] [long] ...
| |
| MyType |
The flexclass
aware type has dynamic size, so it cannot be allocated on the stack. To allocate one on the heap, use the fc::make_unique
method:
fc::unique_ptr<MyType> m = fc::make_unique<MyType>(...)(...);
This returns a RAII enabled object, which will cleanup after itself when it's destroyed.
If you prefer to manually manage this object, use fc::make
and fc::destroy
:
MyType* m = fc::make<MyType>(...)(...);
fc::destroy(m);
make
and make_unique
methods provide a two step initialization and require the syntax:
fc::make<T>(array arguments...)(T constructor arguments);
In the first parenthesis the function expects the arguments for creating arrays.
So if fc_handles
returns N
handles, then make
expects N
arguments.
The arguments for the array creation is the size of the array, but more avanced forms of initialization are available.
The second pair of parenthesis take the arguments to create the type T
.
Flexclass
provides a handful of handles so the user doesn't have to write them by hand.
*Array
handles provide only thebegin
iterator for the object sequence*Range
handles provide bothbegin
andend
Available handles are:
fc::Array<T>
: Contains oneT*
to indicate the begin of the object sequencefc::Range<T>
: Contains twoT*
to indicate both begin and endfc::AdjacentArray<T, int Idx = -1>
: Contains no data as it assumes its array is adjacent to the data from handle inIdx
- If
Idx
is-1
, it assumes the begin of its array is after the type.
- If
fc::AdjacentRange<T, int Idx = -1>
: Likefc::AdjacentArray<T>
but contains aT*
to also know the end of the object sequence
Note that for Adjacent*
handles to work, they take a pointer to the type on begin
and end
methods:
struct Type
{
auto fc_handles() { return fc::make_tuple(&data); }
auto begin() { return data.begin(this); }
fc::AdjacentArray<char> data;
};
Handles being provided with the library use the framework to implement custom handles. Here is a customization example of a handle that assumes that the data is very close to the base and is very small:
template <class T>
struct NearAndSmallRange : fc::Handle<T> // I know my Range will be very close and very small
{
// This is called by the library to inform where the array for this handle was placed
void setLocation(T* begin, T* end)
{
auto size = end - begin;
auto offset = (uintptr_t)begin - (uintptr_t)this;
assert(size <= std::numeric_limits<std::uint8_t>::max());
assert(offset <= std::numeric_limits<std::uint8_t>::max());
m_beginOffset = offset;
m_size = size;
}
// This is called by the library when the user asks where is the begin of the array
// The pointer of the base itself is provided, in case this handle needs information from the base
// See the implementation of fc::AdjacentArray
template <class Base>
auto begin(const Base* ptr) const
{
return (T*)((uintptr_t)this + m_beginOffset);
}
template <class Base>
auto end(const Base* ptr) const
{
return begin(ptr) + m_size;
}
std::uint8_t m_beginOffset, m_size;
}
For each handle, the user is expected to pass a size of the array that will be allocated for it. Arrays are default initialized, but the user may use fc::arg
to pass an input iterator that will be called to obtain initial values each element:
struct Type
{
auto fc_handles() { return fc::make_tuple(&data); }
fc::Array<int> data;
};
// Create 10 ints
auto m1 = fc::make<Type>(10)();
// Create 10 ints
auto m2 = fc::make<Type>( fc::arg(10) )();
// Create 10 initialized ints
std::vector<int> v {1,2,3,4,5,6,7,8,9,10};
auto m3 = fc::make<Type>( fc::arg(10, v.begin()) )();
In this last scenario, the array will contain values 1
to 10
obtained from the iterator.
Construction with custom allocators is also supported. However, Flexclass
does not store the allocator in the structure like other data structures (std::vector
, std::map
, ... ).
Generally there will be a main entity managing all flexclasses, so it knows which allocator to use. Also, adding the allocator as a member can be done over-the-hood, by the user.
To allocate using a custom allocator, invoke fc::make
with fc::withAllocator
and your allocator in the array argument list:
struct Type
{
auto fc_handles() { return fc::make_tuple(&data); }
fc::Array<int> data;
};
MyAllocator alloc;
auto fclass = fc::make<Type>(fc::withAllocator, alloc, 10)();
...
fc::destroy(fclass, alloc);
Flexclass
is well behaved with respect to lifetimes and exceptions. That means all objects created by it will be destroyed in the reverse order, including the objects in arrays.
If at any point during the construction of the FlexibleClass an exception is thrown, all objects that were fully created will be destroyed in the reverse order of construction.
To understand how this library interacts with other language features, it is necessary to understand what happens under the hood.
When invoking the constructor via fc::make<T>
, the following happens:
- Collect array types:
T
is inspected to collect the return result of the methodfc_handles
. This is expected to be, for example, afc::tuple<Handle<A>*, Handle<B>*>
. - Calculate the size of the buffer: Using the size and alignment of
T
,A
andB
, plus the element count of the array ofA
andB
, it is possible to calculate the number of bytes required to store the whole struct. - Call the constructor of
T
. - Call the constructor for each
A
object in the array, and the same forB
. - Inform the handles the location of their arrays: Use
fc_handles
method to obtain all handles, and set the memory position in which the array was created. So we getHandle<A>*
and call->setLocation
passing the memory positions of where theA
objects were created.
The important aspect to be observed here is that the handle member objects in T
are only initialized after T
is initialized. So, during the constructor of T
the member handles shall not be used.
If we think of these arrays as a way to dynamically extend the functionality of the class, we can draw a parallel with virtual functions and how we cannot call them in the constructor of non-final classes.