17 KiB
Structive Property Core Guide
1. Include and target
Include the complete Core surface with:
#include <structive/property/property.hpp>
CMake target:
target_link_libraries(my_target PRIVATE structive::property_core)
The Core target is header-only and requires C++20.
2. Define a managed object
A managed object normally derives from Property_Object<Derived>:
using namespace structive;
struct Device : Property_Object<Device> {
double temperature{25.0};
double pressure{101.3};
double min_speed{10.0};
double max_speed{100.0};
std::string name{"device-1"};
};
The members remain ordinary C++ members.
3. Define the type descriptor
Specialize Type_Descriptor<T> and return an Object_Schema through object<T>(...):
template <>
struct structive::Type_Descriptor<Device> {
static auto get() {
return object<Device>(
defaults(
external_access<External_Access::read_write>,
persistence_access<Persistence_Access::load_store>
),
synchronization(
sync_all_independent,
sync_group<&Device::min_speed, &Device::max_speed>("speed_range")
),
field<&Device::temperature>(
key<"temperature">,
unit<"C">,
min_value<-50.0>,
max_value<200.0>
),
field<&Device::pressure>(key<"pressure">, unit<"kPa">),
field<&Device::min_speed>(key<"min_speed">),
field<&Device::max_speed>(key<"max_speed">),
field<&Device::name>(key<"name">)
);
}
};
field<Member>(...) is an alias of property<Member>(...) and produces a member-backed Property_Descriptor.
4. Schema guarantees
The schema enforces several structural conditions:
- every property has a non-empty
key; - property keys are unique;
- the same member storage identity cannot be registered twice;
- single-valued Attribute categories cannot appear more than once on the same declaration;
- constraints must be compatible with the property value type;
- object defaults may contain only inheritable Attributes;
- external/persistence capabilities cannot claim operations unsupported by the accessor.
Many schema errors are therefore compile-time errors.
5. Access the schema
For a described type:
const auto& schema = type_descriptor<Device>();
For an instance:
Device device;
const auto& schema = device.schema();
Property lookup supports a numeric compile-time index or a member pointer:
const auto& first = schema.property<0>();
const auto& temperature = schema.property<&Device::temperature>();
Use member pointers in typed business code. Use indexes mainly in generic traversal.
6. Property descriptor information
A property descriptor exposes:
auto key_value = temperature.key();
and compile-time traits such as:
using Property = std::remove_cvref_t<decltype(temperature)>;
static_assert(Property::readable);
static_assert(Property::writable);
Category-based Attribute lookup is available through:
static_assert(Property::has_attribute<Unit_Category>);
const auto& attribute = temperature.attribute<Unit_Category>();
All declared Attributes can be traversed:
temperature.for_each_attribute([](const auto& attribute) {
// inspect attribute type/value
});
Constraints can be traversed separately:
temperature.for_each_constraint([](const auto& constraint_value) {
// inspect or evaluate a constraint
});
7. Object defaults and effective Attributes
defaults(...) provides object-wide values for inheritable Attribute categories:
defaults(
external_access<External_Access::read_write>,
persistence_access<Persistence_Access::load_store>,
sensitive<false>
)
A property may override a default:
field<&Device::name>(
key<"name">,
external_access<External_Access::read>
)
Core exposes effective access traits such as effective_external_access_v, external_readable_v, external_writable_v, persistence_loadable_v, persistence_storable_v and effective_sensitive_v for generic code.
Extensions can use declared_effective_attribute<Index, Category>(schema) for extension-owned inheritable categories when either the property or object defaults declare that category.
8. Core Attributes
8.1 Key
key<"temperature">
Required for every property. Non-empty and unique per schema.
8.2 External access
external_access<External_Access::none>
external_access<External_Access::read>
external_access<External_Access::write>
external_access<External_Access::read_write>
This Attribute is inheritable through defaults(...).
8.3 Persistence access
persistence_access<Persistence_Access::none>
persistence_access<Persistence_Access::load>
persistence_access<Persistence_Access::store>
persistence_access<Persistence_Access::load_store>
This Attribute is also inheritable.
8.4 Unit
unit<"C">
This is descriptive metadata and is not inheritable.
8.5 Sensitive
sensitive<>
sensitive<false>
This is inheritable metadata. Core exposes its effective value but does not automatically redact data.
9. Constraints and validation
Built-in constraints:
min_value<0>
max_value<100>
finite
Custom constraint:
constraint<"even">([](int value) {
return value % 2 == 0;
})
Validation by member pointer:
auto error = validate_property_value<&Device::temperature>(device.schema(), candidate);
Validation by compile-time key:
auto error = validate_property_key_value<"temperature">(device.schema(), candidate);
A failure returns:
struct Validation_Error {
std::string_view property_key;
std::string_view code;
};
Validation is explicit and is not automatically executed by managed writes.
10. Intrinsic managed read/write
Typed member-pointer access:
auto temperature = device.read<&Device::temperature>();
device.write<&Device::temperature>(30.0);
Compile-time key access:
auto temperature = device.read_key<"temperature">();
device.write_key<"temperature">(30.0);
These operations use the property's intrinsic capability directly. There is no separate internal capability mode; readability and writability come from the accessor itself.
read() returns a value object, not a reference to storage. The read is performed while the configured shared lock is held when synchronization is enabled.
11. External capability view
Object-level defaults may expose properties externally:
defaults(external_access<External_Access::read_write>)
Use:
device.external().write<&Device::temperature>(30.0);
auto value = device.external().read<&Device::temperature>();
A const object produces a const capability view and therefore has no write API.
The typed API rejects statically inaccessible operations at compile time.
12. Persistence capability view
Use persistence terminology instead of generic write/read:
device.persistence().load<&Device::temperature>(30.0);
auto value = device.persistence().store<&Device::temperature>();
Compile-time key forms are also available:
device.persistence().load_key<"temperature">(30.0);
auto value = device.persistence().store_key<"temperature">();
The persistence view is controlled by Persistence_Access metadata.
13. Synchronization plans
13.1 Default independent
The default Synchronization_Plan is independent: each synchronized property receives its own logical lock domain.
Explicit form:
synchronization(sync_all_independent)
13.2 Shared
synchronization(sync_all_shared)
All properties use the same lock slot unless overridden.
13.3 Unsynchronized
synchronization(sync_all_unsynchronized)
Properties use the unsynchronized_slot and no real Structive mutex protects them.
13.4 Per-property override
By member pointer:
sync_independent<&Device::temperature>()
sync_unsynchronized<&Device::immutable_id>()
Runtime string forms also exist:
sync_independent("temperature")
sync_unsynchronized("immutable_id")
Prefer member-pointer rules when the property is statically known.
13.5 Groups
Member-pointer form:
sync_group<&Device::min_speed, &Device::max_speed>("speed_range")
String form:
sync_group("speed_range", "min_speed", "max_speed")
All properties in a group share one lock slot.
The resolver rejects unknown runtime keys, duplicate group names, empty groups and multiple explicit configurations of the same property.
14. Per-instance synchronization override
The type descriptor supplies a default synchronization plan, but an instance may override it through Property_Synchronization:
struct Device : Property_Object<Device> {
Device() = default;
explicit Device(Property_Synchronization synchronization) : Property_Object(std::move(synchronization)) {}
};
Device shared_device{
property_synchronization(synchronization(sync_all_shared))
};
For schema-aware compile-time synchronization specifications:
auto policy = property_synchronization<Device>(
synchronization(
sync_all_independent,
sync_group<&Device::temperature, &Device::pressure>("environment")
)
);
The schema remains the same; only that instance uses an override lock topology. Default instances share one resolved topology per type, so the default plan is not re-resolved for every object.
15. Inspect resolved synchronization
For a typed member:
auto slot = device.lock_slot<&Device::temperature>();
For the complete view:
auto resolved = device.resolved_synchronization();
auto count = resolved.lock_count;
auto slot = resolved.slot(0);
auto uses_lock = resolved.uses_lock(0);
Resolved_Synchronization_View::unsynchronized_slot identifies unsynchronized properties.
16. Multi-property static guards
Shared guard:
auto guard = device.lock_shared<&Device::temperature, &Device::pressure>();
auto temperature = guard.get<&Device::temperature>();
auto pressure = guard.get<&Device::pressure>();
Unique guard:
auto guard = device.lock_unique<&Device::min_speed, &Device::max_speed>();
auto old_min = guard.get<&Device::min_speed>();
guard.set<&Device::min_speed>(20.0);
guard.set<&Device::max_speed>(120.0);
The guard only permits access to properties inside its held synchronization set.
Capability views provide equivalent static guards constrained by their access mode:
auto guard = device.external().lock_shared<&Device::temperature>();
17. Dynamic-key guards
When a property set is known only at runtime:
std::array<std::string_view, 2> keys{"temperature", "pressure"};
auto guard = device.lock_shared(keys);
or:
auto guard = device.lock_unique({"min_speed", "max_speed"});
Dynamic guards validate keys and capability visibility at runtime. Lock slots are sorted and deduplicated before acquisition.
Read/write through a dynamic guard can still use compile-time members/keys after the guard is acquired; access outside the held set throws std::logic_error.
18. Traversal
Schema traversal:
schema.for_each_property([](auto index, const auto& property) {
// compile-time index and descriptor
});
Managed value traversal:
device.for_each_readable([](auto index, const auto& descriptor, const auto& value) {
// one managed read per property
});
Locked traversal acquires the complete readable synchronization set first:
device.external().for_each_readable_locked([](auto index, const auto& descriptor, const auto& value) {
// all selected readable properties are held under the guard
});
For coordinated writable access:
device.with_all_writable_locked([](auto& guard) {
// use guard.get / guard.set
});
The persistence view provides with_all_loadable_locked(...).
19. Computed property
A synchronized computed property receives a read view:
computed_property<Device, double>([](const auto& view) {
return view.template get<&Device::max_speed>() - view.template get<&Device::min_speed>();
}, key<"speed_span">, external_access<External_Access::read>)
The computed property itself and every dependency read through the view must resolve to the same lock slot.
For example:
synchronization(
sync_all_independent,
sync_group("speed", "min_speed", "max_speed", "speed_span")
)
Computed properties are read-only.
20. Trusted accessor properties
Core also supports member-function-based trusted access:
trusted_computed_property<&Device::get_temperature>(key<"temperature">)
and getter/setter pairs:
trusted_accessor_property<&Device::get_temperature, &Device::set_temperature>(key<"temperature">)
These accessors invoke the object member functions directly and mark themselves as trusted object access. They do not use the synchronized computed-view dependency restriction. Use them when the member functions themselves define the correct synchronization/consistency contract.
21. Runtime type-erased access
Any Property_Object<T> is also a Property_Object_Base:
Property_Object_Base& erased = device;
Introspection:
erased.runtime_object_type();
erased.runtime_property_count();
Intrinsic runtime write does not require a mode:
double value = 35.0;
auto result = erased.runtime_write(
"temperature",
typeid(double),
&value
);
A boundary projection is selected explicitly when an adapter must respect external or persistence capability metadata:
auto external_result = erased.runtime_write(
Managed_Access_Mode::external,
"temperature",
typeid(double),
&value
);
Runtime read uses the same overload model and returns the value through a callback:
static void read_double(void* context, std::size_t, std::string_view, const std::type_info& type, const void* value) {
if (type == typeid(double)) {
*static_cast<double*>(context) = *static_cast<const double*>(value);
}
}
double output = 0.0;
auto result = erased.runtime_read(
"temperature",
&output,
&read_double
);
auto external_result = erased.runtime_read(
Managed_Access_Mode::external,
"temperature",
&output,
&read_double
);
Possible results:
Runtime_Access_Result::ok
Runtime_Access_Result::unknown_property
Runtime_Access_Result::not_readable
Runtime_Access_Result::not_writable
Runtime_Access_Result::type_mismatch
The intrinsic runtime overload uses the accessor's intrinsic capability and the same synchronization topology as typed read()/write(). The overload taking Managed_Access_Mode applies the selected external or persistence projection before using the same synchronization rules.
22. Lock policy
Default:
struct Device : Property_Object<Device, Shared_Mutex_Policy> {
};
Equivalent shorthand:
struct Device : Property_Object<Device> {
};
A no-op lock policy exists:
struct Device : Property_Object<Device, No_Lock_Policy> {
};
No_Lock_Policy uses Null_Shared_Mutex; it removes real mutual exclusion and should only be selected when external ownership guarantees make that correct. Its default managed-object path stores no mutex array and performs no per-instance heap allocation for synchronization. A custom Property_Synchronization may still allocate a compact override topology because computed-property synchronization-domain checks must preserve the selected per-instance layout.
23. Raw object access
unsafe_object() exposes the derived object directly:
Device& raw = device.unsafe_object();
Direct field access is also normal C++:
device.temperature = 40.0;
These paths intentionally bypass Structive-managed access. They are useful when the caller already owns the required synchronization or when a field is being handled outside Structive’s managed contract.
24. Recommended usage rules
- Use member pointers for typed business access.
- Use string keys at runtime integration boundaries.
- Treat keys as protocol identifiers.
- Keep object-wide access policy in
defaults(...)and override only exceptions. - Use synchronization groups for fields that must be observed consistently.
- Validate explicitly at the business operation boundary.
- Use static multi-property guards for cross-field invariants.
- Prefer synchronized computed properties over trusted accessors.
- Use runtime access for adapters, not ordinary typed code.
- Use raw access only when the caller intentionally owns the missing managed guarantees.