Skip to content

Latest commit

ย 

History

History
176 lines (98 loc) ยท 33.9 KB

objects_and_properties.md

File metadata and controls

176 lines (98 loc) ยท 33.9 KB

Objects and properties

Contents

This document contains a complete description of all the options available for objects and their properties. Any information in this document also applies to relationships, as they provide a superset of the functionality that objects offer.

Objects

Each distinct type of object is defined by an individual object{ ... } declaration in the text file. Anything found within the object{ ... } declaration applies only to the type of object defined by it.

Object name

Objects are named by the name{...} key that appears in their definition. The name of an object must be a valid C++ identifier. Additionally, the following identifiers will be used internally: ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_class, ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id, ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id_pair, ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_fat_id, and ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_const_fat_id.

Object ids

For every type of object defined, an ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id class will be created. Instances of this class serve as handles for accessing an properties of a particular instance and for identifying an instance in a relationship. Internally, these classes are strongly typed indexes that are used to access the internal arrays of values managed by the data container.

Each such id contains value_base_t, an alias to the underlying type of the index (either uint8_t, uint16_t, or uint32_t, depending on the number of objects to be stored), zero_is_null_t, an alias to std::true_type1, a member variable value that stores the internal numerical representation of the index, and index() a member function that converts the internal value into an int32_t zero-based index.

Each id can be put into a state indicating that it contains no valid index (internally this is represented by the value zero). Explicitly casting an id to bool will result in true if the index is not in this state, and false if it is. For more robust checking of which ids are valid, the data container provides the function ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_is_valid(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id) (and the member function is_valid() in a fat handle) which returns true only if the id is not storing the invalid value and if its index is a valid object in the container.

As mentioned in the overview, there are also fat handles, which are a version of the ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id class that also supports most of the ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_๐˜ง๐˜ถ๐˜ฏ๐˜ค๐˜ต๐˜ช๐˜ฐ๐˜ฏ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(...) functions as simply ๐˜ง๐˜ถ๐˜ฏ๐˜ค๐˜ต๐˜ช๐˜ฐ๐˜ฏ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(...) member functions. Such a handle can be generated by calling fatten(data_container&, ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id). Like the standard id classes, a fat handle may be explicitly cast to bool to check whether it contains an invalid index. And, as mentioned in the overview, the underlying ordinary handle can be obtained from a a fat handle through its id member variable. Finally, every fat handle is implicitly convertible to a standard handle (via operator ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id), and so can be directly passed to any function expecting an ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id.

Storage types

The storage type for an object is specified by the storage_type{...} key that appears in its definition, with a parameter of contiguous, compactable, or erasable. If no storage type is specified, it defaults to contiguous. The storage type of an object determines how property values are stored internally. In all cases, each property is stored as an array, where the value for a particular instance can be found by looking it up by index in that array.

If the storage type of an object is contiguous, this means that the indexes currently in use are an unbroken sequence from 0 up to some maximum value. This in turn means that new instances can only be appended to the end of this sequence and that an instance can be removed only by dropping it from the back of the sequence (via the ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_pop_back() function).

If the storage type of an object is compactable, the indexes are an unbroken sequence as they are for contiguous storage. However, support is also added for deleting an instance with any index (via delete_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id)). This is accomplished by moving the instance with the last index into the position of the instance to be deleted and then dropping the last instance in the container. The downside of this is that the same handle may not represent the same logical instance after a deletion operation. Internally, this isn't a problem, as all references to the moved instance stored in any relationships will be updated automatically. However, if you want to store handles outside of the data container, you will have to hook the on_move_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ function (see below) and manually update any handles you have stored. You also must take extra care not to hold on to any handles in local variables across a call that may delete an instance.

Finally, if the storage type of an object is erasable then, like compactable objects, it is possible to delete an instance with any index. Unlike compactable objects, however, no instances are moved by the deletion. Instead, empty holes are created within the underlying storage (managed by a free list, so any later creation of new instances will first fill the holes in). This makes life much easier if you want to store handles outside the data container. However, the downside is that iteration over the contained instances is more complicated as the dead values, and any vector operations will still be applied to these deleted instances (see ve integration below).

Size

Each object definition must have a size{...} key. The size that is part of an object definition determines the maximum number of object instances that the data container can manage. Internally, properties are stored as a collection of arrays, each of which has enough room to store at least size many values. Thus, the size must be carefully chosen to be large enough to store as many instances as you could possibly want, but also to be as small as reasonably possible, as a the larger the size is the more memory the data container will require, almost all of which will be allocated up front.

The default behavior when trying to create a new object when there is no more space available is for std::abort() to be called. This is the default because I frequently work with exceptions turned off. If you prefer exceptions, simply define DCON_USE_EXCEPTIONS before including either the common_types.hpp file or any generated container files. If DCON_USE_EXCEPTIONS is defined, a dcon::out_of_space structure will be thrown instead upon attempting to create a new object in a full container.

Finally, there may be times when predetermining a size really is impossible. In such cases an object can instead be defined with size{expandable}. When the size is set to expandable, each property will be backed by a std::vector instead of being backed by statically sized arrays. This vector can grow as necessary which means that the data container will run out of space only when the program as a whole has run out of memory to allocate. While this sounds great, there are a number of downsides to this approach. First, every property access will have to go though an additional layer of indirection, which I would expect to more or less double the cost of reading and writing values (but I have not measured this effect, it is just my gut feeling). More importantly, it means giving up on some of the multi-threaded safety that having all the values at fixed locations for the lifetime of the data container provides (see Multithreading). Thus, I think that expandable is best saved for when it is absolutely necessary, and should not be your default choice.

Tags

An object definition can be given any number of tag{...} keys. The sole function of these tags are to aid in selecting objects for serialization (see Serialization). If you don't plan on using the provided serialization facilities, they can be ignored completely.

Creation and deletion

For each object definition, the data container provides a create_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ() function. This function creates a new instance of the desired type of object (storage space permitting) and returns a handle to it. If the storage type is contiguous or compactable, the data container will provide a pop_back_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ() function, which removes the instance with the greatest index. (In the case of contiguous storage, this is always the ensure most recently created.) If the storage type is compactable or erasable, the data container will provide a delete_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id) function. This function will remove the instance corresponding to the handle given to it as a parameter. It is your responsibility to ensure that only valid handles are deleted. Trying to delete a handle that does not correspond to a valid instance may leave the data container in an inconsistent state.

Hooks

The object instances managed by a data container are not implemented as proper C++ objects, which means that they cannot have conventional constructors or destructors. However, it is possible to access the functionality provided by a constructor or destructor through other means. If an object definition contains a hook{create} or hook{delete} entry, the data container will declare an on_create_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id) or on_delete_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id) member function, respectively, without an implementation. You may then provide an implementation of these member functions in a distinct .cpp file (so that they don't get overwritten if the generator is ever run again). The on_create_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ member function, if it exists, is called every time a new instance of the object type is created, with a handle to that new instance as a parameter. The handle is valid at the time that the member function is called, and all operations on it are permitted. Similarly, the on_delete_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ member function will be called with a handle immediately before removing the instance with that handle.

A third hook is also possible for objects with compactable storage: hook{move}. As with the hooks for creation and deletion, the data container will declare an on_move_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id new_handle, ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id old_handle) member function. This member function will be called after an object is moved from one index to another. The first parameter will be the new, valid handle for the object, while the second parameter will be its old handle, which will no longer be valid. It is possible to use this hook to keep any handles stored outside the data container up to date even as they change internally. But writing updates to every such stored handle and keeping them up to date with changes in the rest of the program is an error-prone process, and not recommended.

Utility features

For each object definition, the data container provides a number of utility functions:

  • ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_size() returns a unint32_t, which for objects stored as compactable or contiguous is the number of objects of that type currently managed by the data container. For objects stored as erasable, this is instead an upper bound on the number of objects managed (specifically, there are no valid indexes greater than or equal to the value returned).
  • in_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ this data member is an object providing begin and end methods that allows you to write loops such as for(auto i : container.in_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ) .... In this loop i will be an appropriately typed fat handle that will iterate over each of the object instances in the data container. If the object is defined as having erasable storage, i will skip any empty positions in the underlying storage array.
  • template<typename T> for_each_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(T&& functor) will call the provided function once for each instance managed by the data container, passing it a handle to that instance. If the object is defined as having erasable storage, the function will not be called with handles that correspond to empty positions in the underlying storage arrays.
  • ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_is_valid(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id) returns true if the parameter is a handle to a valid instance managed by the data container. For objects with compactable or contiguous storage, this amounts to checking that the index contained in it does not represent that invalid value, and that it is less than the number of objects currently managed. For objects with erasable storage, this function also checks whether the handle corresponds to an empty position created by some prior deletion.

Ve integration

(This section assumes that you are already familiar with the ve documentation. While depending on the ve library is the default, all integration can be removed by defining DCON_NO_VE before including any generated file.)

Support for the ve library is provided via the following functions:

  • ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_make_vectorizable_float_buffer() returns a ve::vectorizable_buffer<float, ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id> with sufficient space to store one floating point value for each instance managed by the data container. This is intended as a convenience function for generating intermediate storage buffers for SIMD operations carried out on the managed objects.
  • ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_make_vectorizable_int_buffer() as above, but returns a ve::vectorizable_buffer<int32_t, ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id>
  • template<typename T> execute_serial_over_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(T&& functor) calls ve::serial_exact::execute<๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id>(count, functor) with count equal to the number of instances managed by the data container, unless the size of the objects has been declared to be expandable, in which case ve::serial_unaligned::execute<๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id>(..., functor) is called instead. The effect of this is to run the SIMD operation functor on all the instances managed by the data container of this type. Note that, in the case of objects with erasable storage, this may also result in the SIMD operation being called with the values corresponding to some of the empty slots. If this would be a problem, it is your responsibility to mask out operations on those slots as necessary.
  • template<typename T> execute_parallel_over_๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(T&& functor) As above, but with ve::par_exact::execute and ve::par_unaligned::execute. This function will not be available if VE_NO_TBB is defined prior to including the generated file.

Properties

Properties are added to an object definition by adding property{...} keys to it, with each property key containing sub-keys (name{...}, and type{...}, along with the optional hook{...}, hoog{...}, private, and protected) that define it. Each property must have a name and a type, but everything else can be omitted. Each property creates an additional logical value that will be associated with each object instance; you can think of them as corresponding to the member variables of a standard C++ structure or class.

Accessing values

For most properties a basic getter and setter, ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_get_๐˜ฑ๐˜ณ๐˜ฐ๐˜ฑ๐˜ฆ๐˜ณ๐˜ต๐˜บ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id) and ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_set_๐˜ฑ๐˜ณ๐˜ฐ๐˜ฑ๐˜ฆ๐˜ณ๐˜ต๐˜บ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id, property_value_type) are generated to permit access to the stored values. These functions may be supplemented or slightly modified depending on type defined for the property (see Property type below). Where possible, the getter returns a reference to the stored value. This is useful in two ways. First, you may wish to capture the reference in some cases to save yourself some typing, as repeatedly calling getters and setters can be quite verbose. Secondly it makes storing complicated values, such as a std::vector possible, as you can access their member functions without making a copy of them.

As an additional convenience feature, it is (usually) safe to call a getter even with a handle that represents the invalid index. The index for such handles resolves to -1, and the space immediately proceeding the arrays that store the values is also allocated and default constructed. (Thus reading from it will yield some version of 0 for the basic types). The advantage of this is that a branch testing for the invalid index value can be omitted, which can sometimes make life much easier when writing SIMD operations. The two exceptions to this feature are properties defined with a type of object and vector_pool, neither of which have the necessary extra space allocated for them.

OOP (encapsulation) support

By default, all the properties defined for an object are exposed as essentially public members. However, it is possible to hide properties and expose a more conventional OOP interface to your object instances if you are willing to use the nice syntax (as described in the overview). When the key protected is added to any property definition, the corresponding setters will not be generally available for the property (note the difference with the C++ semantics for protected), and if private is added to a property definition instead, neither the standard getters nor the standard setters will be generally available. (private and protected can also be added to link definitions, see Relationships.)

To do anything with properties encapsulated in this way, you will have to declare one or more member functions, which will be able to access the protected getters and setters. To do this, add one or more function{...} or const_function{...} keys to the object (or relationship) definition. The parameter to such a key must be a valid C++ function signature, except with the name of the function and the name of each of its parameters preceded by an @.2 For example, function{float @calculate_result(std::vector<int> const& @inputs)} could be added to an object definition to declare a member function named calculate_result.

You must provide the definitions for any member functions you declare in this way as members of either the appropriate ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_fat_id class (for functions) or ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_const_fat_id class (for const_functions). (A const_function will automatically generate a stub in ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_fat_id that forwards the function to the ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_const_fat_id implementation, so you only have to define it once.) Inside those member functions, the protected getters and setters will be available as private members of the ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_fat_id or ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_const_fat_id, thereby providing the same encapsulation abilities as the standard OOP facilities.

Property name

A property name must be a valid C++ identifier. Additionally, the following identifiers will be used internally: m_๐˜ฑ๐˜ณ๐˜ฐ๐˜ฑ๐˜ฆ๐˜ณ๐˜ต๐˜บ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ, ๐˜ฑ๐˜ณ๐˜ฐ๐˜ฑ๐˜ฆ๐˜ณ๐˜ต๐˜บ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_p, and possibly ๐˜ฑ๐˜ณ๐˜ฐ๐˜ฑ๐˜ฆ๐˜ณ๐˜ต๐˜บ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_storage. The property name _index is also reserved for internal use.

Property type

The type of value that a property will hold is determined by the type{...} key. The parameter to the type key can be a C++ data type, for example type{int32_t} means that the property will contain int32_t integers, or can be one of the special types described below.

bitfield

A property defined as type{bitfield} represents a logical boolean value, and its getters and setters will take and return bools. However, internally the boolean values for all instances of this type of object will be stored together as a packed array of bits. This significantly reduces the amount of space requires to store these values (by a factor of 8), but adds some overhead to accessing them. Note that type{bool} is not supported (partly because of std::vector<bool> managing to make life harder for all of us). If you need a boolean value that is not stored as a packed array of bits, you can define a property as type{uint8_t}.

derived

A property defined as type{derived{type_name}} represents a logical value of type type_name, but which has no underlying storage allocated for it at all. Instead, you must declare a hook for the getter and/or setter (see Property hooks below), which will allow you to define how the property value should be calculated when it is accessed. derived properties are provided primarily to accommodate a scenario in which you are changing the definition of an object to no longer store a particular value. Turning that value into a derived value can allow you make that change without making major changes to the rest of the code base. derived{bitfield} properties will result in getters and setters that match those for a type{bitfield}. Properties that were previously defined as type{object{...}} should be turned into type{derived{...}}. vector_pool and array types cannot be converted into derived types in this way.3

object

A property defined as type{object{type_name}} represents a value of type type_name. In addition, it also informs the generator that type_name cannot moved around with std::memcpy, and that its default constructor and destructor must be run. This also results in the generator providing the declarations for user-defined serialization and deserialization routines for objects of type type_name (since obviously they are not safe to copy directly into and out of a std::byte buffer), which you will have to provide (see Serialization for more details).

vector_pool

A property defined as type{vector_pool{size}{type_name}} represents a variable-length array of values of type type_name (each object instance has its own array of values, and the size of these arrays may vary from instance to instance). This is provided as an alternative to properties of type type{object{std::vector<type_name>}}. The difference are: The underlying value stored for each vector_pool{size}{type_name} property is four bytes, while about 24 bytes (three pointers) would be stored per instance for a object{std::vector<type_name>}} (depending on its implementation). However, the arrays for the vector_pool{size}{type_name} are drawn from a fixed pool of size * 8 bytes, which will be allocated as part of the data container. This puts a limit on how many values may be stored in these arrays in total (reduced even further by the overhead requires to track the current size and capacity of these arrays, as well as to manage the free list that exists within the pool, all of which consume memory allocated to the pool itself). A further restriction is that any type stored in one of these special arrays must be safe to move around with std::memcpy; constructors and destructors will typically not be called.

All operations on the array of values stored for this property are accessed through a proxy object returned by the ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_get_๐˜ฑ๐˜ณ๐˜ฐ๐˜ฑ๐˜ฆ๐˜ณ๐˜ต๐˜บ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id) function (and there is no equivalent set function). The proxy object exposes the following functions:

  • range() returns a std::pair containing two pointers, the first to the beginning of the stored array, and the second to the element one past its end (or possibly two null pointers if there is no storage assigned to the array yet)
  • at(uint32_t n) returns the value stored at the nth index of the array (this is a zero-based index)
  • operator[](uint32_t n) as above
  • capacity() returns the maximum size the array can be grown to before it will have to be internally reallocated within the memory pool
  • size() returns the current size of the array
  • contains(type_name) returns true if the value passed compares as equal to one of the values in the array, and false otherwise
  • push_back(type_name) adds the passed value to the end of the array, increasing its size by one
  • pop_back() removes the last value of the array, decreasing its size by one. Nothing happens if the array is already empty
  • add_unique(type_name) adds the passed value to the end of the array, increasing its size by one, if no value currently in the array compares equal to it
  • remove_unique(type_name) removes a single copy of an item in the array that compares equal to the passed value, decreasing its size by one. If no item compares equal to the passed value, the array will not be changed. The order of the items in the array may be changed by this operation.
  • clear() removes all of the items in the array and resets its size to zero
  • remove_at(uint32_t n) remove the item at the nth index from the array (as a zero-based index), by replacing it with what is currently the last value in the array, and then shrinks the size of the array by one. Calling this function with an invalid index into the array will make bad things happen, so don't do it.

The proxy object also provides the standard begin() and end() functions so that it can be used in a range-based for loop. Finally, the functions that modify the contents of the array of values will not be available if the property is access through a constant reference or pointer to the data container.

NOTE: the proxy objects should not be stored in general. They certainly should not be stored over any operation that would create or delete an object instance. You should also never access the same underlying vector_pool property through two different proxy objects; don't reuse an old proxy object after you have obtained a new one for that same property via get.

array

A property defined as type{array{index_type}{value_type}} or type{array{value_type}}, like vector_pool, represents a variable-length array of values. However, unlike a vector_pool property, the length of the array is the same for all of the object instances in the data container. This allows the values for a particular logical index in this array to be stored contiguously across different object instances, which in turn facilitates SIMD operations on these arrays. The designed purpose of properties of this type is to model collections of values where the number of values cannot be determined at compile time, but which is expected to be fixed for most of the runtime of the program. For example, if an object represents a mass value associated with some N-dimensional point, we may not want to fix N at compile time. However, we can expect N to remain constant for the lifetime of the objects, and so a type{array{int32_t}{float}} may be a good choice to represent the coordinates of those objects. Resizing the array is done by calling ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_resize_๐˜ฑ๐˜ณ๐˜ฐ๐˜ฑ๐˜ฆ๐˜ณ๐˜ต๐˜บ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(uint32_t), while the current size can be checked by calling ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_get_๐˜ฑ๐˜ณ๐˜ฐ๐˜ฑ๐˜ฆ๐˜ณ๐˜ต๐˜บ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_size().

In the two parameter version of the key, the first parameter given to array is the type of value that will be used to index into the array (which defaults to uint32_t in the one parameter version). This can be any integer type or strongly typed index, including one created by make_index (see File format documentation). The second parameter in the two parameter version, or the only parameter in one parameter version, is the type of value to be stored in the array. This can be any type that can be safely moved with std::memcpy or bitfield.4

The getters and setters for a property defined as an array are as follows:

  • ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_get_๐˜ฑ๐˜ณ๐˜ฐ๐˜ฑ๐˜ฆ๐˜ณ๐˜ต๐˜บ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id, index_type)
  • ๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_set_๐˜ฑ๐˜ณ๐˜ฐ๐˜ฑ๐˜ฆ๐˜ณ๐˜ต๐˜บ ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ(๐˜ฐ๐˜ฃ๐˜ซ๐˜ฆ๐˜ค๐˜ต ๐˜ฏ๐˜ข๐˜ฎ๐˜ฆ_id, index_type, property_value_type)

Both require an index into the array as well as a handle to the object. Any SIMD getters and setters generated (see Ve integration for properties below) will also require this index parameter.

Other types

Finally, any C++ type that can be moved by std::memcpy can be passed as a parameter to type. (Although if this is a user defined type, it must be defined before the data container is. This is easiest to guarantee by using the include key, see File format documentation.) The expected usage pattern is to define all of the properties in terms of primitive C++ types, and to rely minimally on user-defined data types, as the values inside a user-defined data type will not be easily available to SIMD operations. Sometimes, however, your expected usage patterns may conflict with the data container's default assumptions. By storing the values for each property together, the data container is optimized under the assumption that, when you need a value, you will likely also need that same value from other, nearby, object instances. Sometimes, however, you know that some subset of the properties will usually be accessed together, and hence that you would like the values for those properties to all be stored together for any given object instance (ideally on the same cache line). In that situation, you can get the result you want by defining a structure that contains the values that you know belong together and then making a single property that stores instances of that structure.

Ve integration for properties

If the type stored in a property is detected by the data container to be amenable to SIMD operations (i.e. a char, unsigned char, signed char, bool, int, unsigned int, short, unsigned short, float, int8_t, uint8_t, int16_t, uint16_t, int32_t, uint32_t, the type of a handle to an object, or a type created by make_index5), the data container will contain additional getters and setters for these properties that will allow you to access multiple of those values at the same time (assuming, again, that DCON_NO_VE is undefined). Specifically, in addition to the standard getters and setters for the property, versions will be generated with the single handle identifying the object replaced by one of ve::tagged_vector<object_id>, ve::partial_contiguous_tags<object_id>, ve::contiguous_tags<object_id>, or ve::unaligned_contiguous_tags<object_id> that will operate on the range of object handles passed rather than on a single object instance at a time.

Note that the getters and setters for ve integration are not available via the nice syntax. The nice syntax only operates on a single instance at a time.

Tags

As with an object definition, a property definition can be given any number of tag{...} keys, which aid in selecting properties for serialization (see Serialization). If you don't plan on using the provided serialization facilities, they can be ignored completely.

Property hooks

If a property definition contains a hook{get} or hook{set} entry, it will not generate the usual getter or setter functions, respectively. Instead it will only declare those functions, and the user is thus required to provide an implementation of them in some other file. For properties defined as derived, this is the only way in which getters or setters will be generated at all. For convenience, the SIMD getters and setters will still be generated if they would have been in the absence of the hook key, but they will be implemented by repeatedly calling the user-defined getter and setter.

Footnotes

  1. At one point there were also strongly typed indexes that represented invalid indexes as -1 (or the maximum value for an unsigned integer type). Currently such indexes cannot be generated, but some of the machinery required to support them remains. โ†ฉ

  2. The reason for the @ is that correctly parsing C++ types, which would be required to find the name of the function and the name of its parameters automatically, is hard. I certainly can't be bothered to attempt to write anything like a robust parser for them, and I am certainly not going to pull into something like Clang as a dependency. โ†ฉ

  3. At least, not yet. If you need this functionality, let me know why and I will consider adding it. โ†ฉ

  4. There is nothing stopping more kinds of things from being stuffed into an array, I just didn't feel like writing more special cases. โ†ฉ

  5. Yes, it must be one of those types verbatim, and not some other alias that will evaluate to one of them. The generator runs before the compiler, and hence does not have a sophisticated understanding of C++ types. While the generator could produce the ve integration functions in all cases, and then make them unavailable based on the types as resolved at compile time with template magic, I decided faster compile times for projects including the generated data container were more important than supporting user-defined / more obscure aliases for these types. โ†ฉ