13 KiB
Structive Property Core Guide
1. Include and CMake target
#include <structive/property/property.hpp>
target_link_libraries(my_target PRIVATE structive::property_core)
Property Core is C++20.
2. Define a managed object
using namespace structive;
struct Device : Property_Object<Device> {
double temperature{25.0};
double pressure{101.3};
int serial_number{1001};
};
Property_Object<Device> adds managed operations. The fields remain ordinary members.
3. Define the type descriptor
template <>
struct structive::Type_Descriptor<Device> {
static auto get() {
return object<Device>(
synchronization(sync_all_independent),
field<&Device::temperature>(
key<"temperature">,
unit<"C">,
min_value<-50.0>,
max_value<200.0>
),
field<&Device::pressure>(
key<"pressure">,
unit<"kPa">
),
field<&Device::serial_number>(
key<"serial_number">,
read_only
)
);
}
};
The descriptor is the structural definition of Device.
4. Schema guarantees
A valid schema guarantees:
- every registered property has a non-empty key;
- keys are unique;
- member selectors belong to the described object type;
- single-valued Attribute categories are not duplicated on the same property;
- capability metadata cannot request operations unsupported by the underlying accessor;
- constraints are compatible with the property value type.
Use:
static_assert(Property_Described_Object<Device>);
using Schema = type_descriptor_schema_t<Device>;
static_assert(Valid_Property_Schema<Schema>);
5. Access the schema
const auto& schema = type_descriptor<Device>();
const auto& same_schema = device.schema();
Properties can be selected by index or member pointer:
const auto& first = schema.property<0>();
const auto& temperature = schema.property<&Device::temperature>();
6. Property descriptor information
A property descriptor exposes compile-time structural facts:
using Property = std::remove_cvref_t<decltype(schema.property<&Device::temperature>())>;
static_assert(Property::readable);
static_assert(Property::writable);
using Value = Property::value_type;
using Accessor = Property::accessor_type;
A read_only field reports:
using Serial = std::remove_cvref_t<decltype(schema.property<&Device::serial_number>())>;
static_assert(Serial::readable);
static_assert(!Serial::writable);
The key is available at runtime without dynamic allocation:
auto key_value = schema.property<&Device::temperature>().key();
7. Intrinsic capability
Core capability values are:
Property_Capability::none
Property_Capability::read
Property_Capability::write
Property_Capability::read_write
Convenience Attributes are:
read_only
write_only
read_write
inaccessible
The capability belongs to the property itself. It is not an authorization rule.
If capability metadata is absent, Structive derives capability from the accessor.
For a normal non-const member field, the default is read/write.
For a getter-only computed property, the default is read-only.
Capability metadata can narrow the accessor, but it cannot create an operation the accessor does not support.
8. No access-control API
Property Core intentionally does not provide access modes or domain views.
There is no built-in distinction between:
internal
external
persistence
A consumer decides its own policy. For example, a GUI may choose to expose only selected properties even though all of them are structurally readable.
Core only exposes intrinsic readable and writable facts.
9. Core Attributes
9.1 Key
Every property requires one key:
key<"temperature">
The key is the structural protocol identifier used by runtime lookup and adapters.
9.2 Capability
read_only
write_only
read_write
inaccessible
Capability Attributes are single-valued and non-inheritable.
9.3 Unit
unit<"C">
unit<"kPa">
Core stores unit metadata but does not perform conversion.
9.4 Sensitive
sensitive<>
sensitive<false>
sensitive is inheritable metadata. It does not implement authorization. A consumer may use it as one input to its own policy.
10. Custom Attributes and defaults
Any type following the Attribute protocol can be attached to a property.
struct Group_Category {};
template <Fixed_String Value>
struct Group_Attribute {
using attribute_category = Group_Category;
static constexpr bool single_valued = true;
static constexpr bool inheritable = true;
static constexpr auto value = Value;
};
Inheritable Attributes can be supplied by defaults(...):
object<Device>(
defaults(sensitive<>),
field<&Device::temperature>(key<"temperature">)
)
Capability is intentionally non-inheritable because each property owns its intrinsic operation set.
11. Constraints and validation
Built-in constraints include:
min_value<0>
max_value<100>
finite
Custom constraints:
constraint<"even">([](int value) {
return value % 2 == 0;
})
Validation is explicit:
auto result = validate_property_value<&Device::temperature>(device.schema(), candidate);
if (result) {
auto key_value = result->property_key;
auto code = result->code;
}
write() does not automatically call validation.
12. Typed managed read/write
Member-pointer access is the preferred C++ API:
auto temperature = device.read<&Device::temperature>();
device.write<&Device::temperature>(30.0);
Compile-time key access is also available:
auto temperature = device.read_key<"temperature">();
device.write_key<"temperature">(30.0);
The typed interfaces are constrained by intrinsic capability. A write to read_only does not participate in overload resolution.
template <class Object>
concept Can_Write_Serial = requires(Object& object) {
object.template write<&Device::serial_number>(1);
};
static_assert(!Can_Write_Serial<Device>);
13. Read-only fast path
Stored read-only properties are statically removed from managed locking.
Given:
field<&Device::serial_number>(key<"serial_number">, read_only)
Structive resolves:
serial_number -> unsynchronized_slot
and read<&Device::serial_number>() does not query a lock slot or construct a shared_lock.
This remains true even when the broad default is sync_all_shared.
If an object has only stored read-only properties, those properties contribute zero mutexes to resolved_synchronization().lock_count.
14. Synchronization plans
14.1 Independent
synchronization(sync_all_independent)
Each property that actually requires synchronization gets its own lock domain.
14.2 Shared
synchronization(sync_all_shared)
All properties that require synchronization share one lock domain.
Read-only stored properties are excluded before lock slots are materialized.
14.3 Unsynchronized
synchronization(sync_all_unsynchronized)
Managed access performs no real locking even for writable properties.
14.4 Per-property override
synchronization(
sync_all_independent,
sync_unsynchronized<&Device::temperature>()
)
Dynamic-key rule forms are also available.
14.5 Groups
synchronization(
sync_all_independent,
sync_group<&Device::min_speed, &Device::max_speed>("speed_range")
)
Members in one group resolve to the same lock slot if they require synchronization.
15. Per-instance synchronization override
An object can explicitly override the type default:
Device device{
property_synchronization(
synchronization(sync_all_shared)
)
};
For typed member rules:
Device device{
property_synchronization<Device>(
synchronization(
sync_all_independent,
sync_group<&Device::temperature, &Device::pressure>("environment")
)
)
};
The default topology is shared per type. Only an object with an explicit override stores its compact override layout.
16. Inspect resolved synchronization
auto view = device.resolved_synchronization();
auto count = view.lock_count;
Inspect a member slot:
auto slot = device.lock_slot<&Device::temperature>();
No-lock properties use:
Resolved_Synchronization_View::unsynchronized_slot
For a read-only stored property this is automatic.
17. Static multi-property guards
Read guard:
auto guard = device.lock_shared<&Device::temperature, &Device::pressure>();
auto temperature = guard.get<&Device::temperature>();
Write guard:
auto guard = device.lock_unique<&Device::min_speed, &Device::max_speed>();
guard.set<&Device::min_speed>(20);
guard.set<&Device::max_speed>(120);
Static guards deduplicate slots and acquire them in stable slot order.
A typed unique guard requires all selected properties to be intrinsically writable.
18. Dynamic-key guards
auto read_guard = device.lock_shared({"temperature", "pressure"});
auto write_guard = device.lock_unique({"temperature", "pressure"});
Unknown keys throw std::invalid_argument.
A dynamic unique guard rejects a read-only property because the key is only known at runtime.
19. Traversal
Schema-only traversal:
schema.for_each_property([&](auto index, const auto& descriptor) {
});
Readable value traversal:
device.for_each_readable([&](auto index, const auto& descriptor, const auto& value) {
});
Locked readable traversal:
device.for_each_readable_locked([&](auto index, const auto& descriptor, const auto& value) {
});
The locked traversal acquires only actual synchronization slots. Read-only stored properties do not introduce locks.
Writable transaction-style guard acquisition:
device.with_all_writable_locked([&](auto& guard) {
});
This acquires all writable managed lock domains; it does not perform validation or rollback automatically.
20. Computed properties
A synchronized computed property receives a read view:
computed_property<Device, int>([](const auto& view) {
return view.template get<&Device::max_speed>() - view.template get<&Device::min_speed>();
}, key<"speed_span">)
Writable dependencies that must form one snapshot should share the computed synchronization domain:
synchronization(
sync_all_independent,
sync_group("speed", "min_speed", "max_speed", "speed_span")
)
A read-only stored dependency may be read through the computed view without joining a lock slot, because it has no managed writer.
21. Trusted accessor properties
Trusted getter:
trusted_computed_property<&Device::value>(key<"value">)
Trusted getter/setter:
trusted_accessor_property<&Device::get_value, &Device::set_value>(key<"value">)
These forms access the object directly rather than using synchronized dependency views. They should only be used when the caller understands the synchronization contract of those member functions.
22. Runtime type-erased access
Use Property_Object_Base for dynamic adapters:
Property_Object_Base& erased = device;
Introspection:
erased.runtime_object_type();
erased.runtime_property_count();
Read:
auto result = erased.runtime_read("temperature", context, callback);
Write:
auto result = erased.runtime_write("temperature", typeid(double), &value);
Possible results:
ok
unknown_property
not_readable
not_writable
type_mismatch
Runtime access has no external/persistence mode. An adapter owns its own policy and chooses whether it calls the intrinsic read/write operation.
Runtime read of a stored read-only property follows the same no-lock fast path as typed read.
23. Lock policies
Default:
Property_Object<Device, Shared_Mutex_Policy>
No-lock:
Property_Object<Device, No_Lock_Policy>
No_Lock_Policy stores no real mutex array and avoids real lock objects on managed hot paths.
24. Raw object access
Device& raw = device.unsafe_object();
or normal public member access:
device.temperature = 30.0;
Both bypass the Structive managed contract.
This is intentional. Structive is cooperative structural infrastructure, not forced encapsulation.
25. Recommended usage rules
Use these defaults:
- Use ordinary C++ members for storage.
- Register only fields that belong to the structural model.
- Prefer member-pointer typed APIs in C++ business code.
- Use
read_onlywhen the Structive managed model must never write a stored property. - Rely on the resulting zero-lock optimization for stored read-only data.
- Keep raw writes to read-only properties outside concurrent managed code.
- Use synchronization groups for mutable cross-field consistency.
- Keep validation explicit.
- Use runtime access only for genuinely dynamic adapters.
- Let GUI/RPC/persistence/authorization layers own their own exposure and access policy.