14 KiB
Structive
Structive enhances ordinary C++ structs with explicit structural metadata and managed property behavior without replacing the native C++ data model.
中文文档 · Design Philosophy · Core Guide · Extension Guide
What Structive is
Structive is a C++20 structural property system built around two separate layers:
C++ object model
ordinary members and member functions
│
├── Type_Descriptor<T> → Object_Schema
│ type-level structure, keys, intrinsic capabilities,
│ attributes, constraints and synchronization description
│
└── Property_Object<T>
instance-level managed read/write, synchronization,
traversal and type-erased runtime access
A type remains ordinary C++:
#include <structive/property/property.hpp>
using namespace structive;
struct Device : Property_Object<Device> {
double temperature{25.0};
int serial_number{1001};
};
template <>
struct structive::Type_Descriptor<Device> {
static auto get() {
return object<Device>(
field<&Device::temperature>(key<"temperature">, unit<"C">),
field<&Device::serial_number>(key<"serial_number">, read_only)
);
}
};
The members are still real members. Structive only adds explicit structural meaning around them.
Core idea
Structive follows one central rule:
Enhance the struct; do not replace the struct.
That means:
- registered fields remain ordinary C++ members;
- unregistered members remain outside Structive;
- member pointers are preferred compile-time identities;
- string keys exist for dynamic and adapter boundaries;
- metadata does not force storage wrappers such as
Property<T>; - raw C++ access remains possible;
- Structive does not try to enforce a security boundary around a public member;
- external systems decide for themselves what they expose or allow;
- Core only describes what the property itself can intrinsically do.
No access-control subsystem
Structive intentionally has no built-in internal, external, persistence, role, context or policy access modes.
Core does not expose domain-specific access views, permission enums or persistence-specific access modes.
A GUI, RPC service, serializer, plugin system or persistence layer is responsible for deciding which properties it exposes and which operations it permits. Structive does not own that policy.
The Core only answers intrinsic structural questions:
Can this property be read?
Can this property be written?
What is its key?
What metadata and constraints are attached?
What synchronization is required for managed access?
Intrinsic property capability
Every property has one intrinsic capability:
none
read
write
read_write
For normal accessors Structive derives this from the accessor automatically. A member field is normally read_write; a getter-only computed property is naturally read.
Member_Accessor is the default for stored members. When an object exposes a property through business methods or a child control, trusted_callable_accessor<Object>(reader, writer) supplies that access strategy while consumers continue to use the same Property_Accessor protocol without mirrored object state.
The schema can explicitly narrow the capability:
field<&Device::serial_number>(
key<"serial_number">,
read_only
)
The predefined capability attributes are:
read_only
write_only
read_write
inaccessible
They are structural contracts, not user permissions.
For a read-only property:
auto id = device.read<&Device::serial_number>();
is valid, while:
device.write<&Device::serial_number>(1002);
is unavailable at compile time.
The raw C++ member remains accessible if the C++ type itself makes it accessible:
device.serial_number = 1002;
That raw write deliberately bypasses the Structive contract and its synchronization guarantees.
Read-only means no property lock
Property metadata is used for optimization, not only documentation.
A stored property whose intrinsic capability is read-only does not participate in the synchronization topology:
read-only stored property
↓
no lock slot
↓
no mutex contribution
↓
managed read performs no lock lookup
↓
direct accessor read
For example:
struct Device : Property_Object<Device> {
int id{1};
int value{0};
};
template <>
struct structive::Type_Descriptor<Device> {
static auto get() {
return object<Device>(
synchronization(sync_all_shared),
field<&Device::id>(key<"id">, read_only),
field<&Device::value>(key<"value">)
);
}
};
id resolves to unsynchronized_slot. Only value contributes a mutex to the managed object.
This relies on the Structive managed contract. If another thread deliberately writes device.id through raw C++ access while a managed read is occurring, that code has bypassed Structive and owns the resulting synchronization responsibility.
Computed read-only properties
A computed property is usually intrinsically read-only, but it may read writable dependencies.
computed_property<Device, int>(depends_on<&Device::min_speed, &Device::max_speed>, [](const auto& view) {
return view.template get<&Device::max_speed>() - view.template get<&Device::min_speed>();
}, key<"speed_span">)
The computed value has no writable storage of its own. Dependencies are explicit Schema facts, and the computed view can read only declared direct dependencies. Read-only stored dependencies can be read directly because they cannot change through the managed path.
Writable dependencies that must form one snapshot should be placed in the same synchronization group with each other. The computed property's read slot is derived from its dependency graph and must not be configured directly in synchronization rules. Dependency cycles are rejected when the Schema is formed. depends_on<> is a valid explicit zero-dependency declaration and produces an unsynchronized computed read.
Managed access and raw access
These operations are deliberately different:
device.temperature = 30.0;
device.write<&Device::temperature>(30.0);
The first is raw C++ access. The second is the managed Structive path.
Managed access provides the behavior described by the schema, including intrinsic capability checks and synchronization. Raw access bypasses that behavior.
Structive follows a cooperative model: it helps correct code express and use structure efficiently; it does not attempt to stop code that intentionally bypasses the system.
Schema and managed object are separate
Type_Descriptor<T> describes the type. Property_Object<T> adds per-instance managed behavior.
The default synchronization topology is resolved once per type. Instances do not keep a per-property vector for the default layout. An instance only stores real mutex state required by writable synchronization domains and a compact override layout when that instance explicitly supplies Property_Synchronization.
No_Lock_Policy removes real mutex storage entirely.
Unified property metadata model
A property descriptor has one metadata store containing Attributes and Constraints. Core and extensions use one Attribute protocol for descriptive metadata, while Constraints keep their validation protocol.
struct Label_Category {};
template <Fixed_String Value>
struct Label_Attribute {
using attribute_category = Label_Category;
static constexpr bool single_valued = true;
static constexpr bool inheritable = false;
static constexpr auto value = Value;
};
An extension can attach its metadata to the same descriptor:
field<&Device::temperature>(
key<"temperature">,
presentation::label<"Temperature">
)
Core stores extension metadata but does not interpret extension-owned categories. for_each_metadata() traverses all property metadata, for_each_attribute() traverses only Attributes, and for_each_constraint() traverses only Constraints.
Validation is explicit
Constraints are metadata. write() does not automatically execute them.
auto error = validate_property_value<&Device::temperature>(device.schema(), 500.0);
Validation, transactions, rollback and synchronization are separate concerns.
Synchronization
Synchronization is a topology layer, not access control. It defines which intrinsically mutable properties share a managed consistency domain. Stored read-only properties are removed before lock slots are created, so metadata directly reduces runtime synchronization cost.
Structive provides three defaults:
sync_all_independent
sync_all_shared
sync_all_unsynchronized
and typed or runtime-key overrides/groups:
synchronization(
sync_all_independent,
sync_group<&Device::min_speed, &Device::max_speed>("speed_range")
)
The several entry forms are intentional: compile-time member rules serve typed C++ code, runtime-key plans serve adapters, per-instance overrides serve exceptional objects, and guards express temporary multi-property consistency. They all resolve to the same compact lock-slot model.
Multi-property guards deduplicate lock domains and acquire them in stable slot order:
auto guard = device.lock_unique<&Device::min_speed, &Device::max_speed>();
auto old_min = guard.get<&Device::min_speed>();
auto old_max = guard.get<"maximum_speed">();
guard.set<"minimum_speed">(20);
guard.set<&Device::max_speed>(120);
Typed guard capability is constrained at compile time. Runtime-key guards validate keys and capability at runtime. See Core Guide: Synchronization and core/tests/synchronization_test.cpp for topology, validation, blocking and lock-order coverage.
Runtime access
Property_Object_Base is deliberately a low-level type-erased adapter boundary for code that discovers keys only at runtime:
Property_Object_Base& erased = device;
auto type = erased.runtime_object_type();
auto count = erased.runtime_property_count();
Runtime operations are key based:
runtime_read(key, context, callback)
runtime_write(key, type_info, value)
They return ok, unknown_property, not_readable, not_writable, unsupported_runtime_write or type_mismatch. Runtime write is an exact-type copy-input boundary and performs no implicit conversion. A property can remain intrinsically writable while exposing runtime_copy_writable == false when its accessor requires move-only input; typed write still supports that property. The read callback receives a borrowed pointer that is valid only during the callback; synchronized writable state remains read-locked while the callback executes. Stored read-only properties retain the same zero-lock fast path as typed reads.
Core intentionally does not impose variant, any, conversion registries or serialization ownership on this boundary. Higher-level adapters may wrap it. There is no runtime access mode or access-control policy: an external system decides what it exposes, while Structive reports only intrinsic property capability. See Core Guide: Runtime access and core/tests/runtime_api_test.cpp.
Current Core metadata
Core currently defines:
key<"...">read_onlywrite_onlyread_writeinaccessibleunit<"...">sensitive<>min_value<...>max_value<...>finiteconstraint<"code">(...)
read_only, write_only, read_write and inaccessible describe the property itself. They are not access-control policies.
Current extension metadata
type_metadata(...) attaches extension-owned Attributes directly to a described C++ type.
It uses the same Attribute protocol as property metadata while remaining separate from
property defaults. Consumers query or traverse these attributes through the type's
Object_Schema; Core does not interpret their domain semantics.
The presentation extension defines:
presentation::label<"...">presentation::description<"...">presentation::group<"...">presentation::order<N>
A presentation consumer may independently decide whether a property should be visible or editable. That policy is outside Property Core.
Build
add_subdirectory(path/to/Structive)
target_link_libraries(my_target PRIVATE structive::property_core)
For linked extensions:
target_link_libraries(my_target PRIVATE structive::property_extensions)
Build and test:
cmake -S . -B build -DBUILD_TESTING=ON
cmake --build build
ctest --test-dir build --output-on-failure
Standalone configuration exposes STRUCTIVE_BUILD_EXAMPLES, STRUCTIVE_BUILD_TESTS and STRUCTIVE_INSTALL. Examples default on only when Structive is the top-level project; tests follow BUILD_TESTING; standalone install defaults on. Installation exports structive::property_core and structive::property_extensions through find_package(Structive CONFIG), and the standalone CTest suite verifies an external install consumer.
Tests are divided by contract so the same behavior is not repeated in one catch-all executable:
property_core_test.cpp: schema, attributes, explicit validation, typed access, traversal, computed properties and object copy semantics;runtime_api_test.cpp: type-erased result codes, callback metadata, the copy-write boundary, managed blocking and the read-only zero-lock path;synchronization_test.cpp: topology, invalid plans, guard held sets, blocking relationships and stable lock order;compile_fail/: duplicate keys/storage/single-valued attributes, missing keys and invalid capability/constraint/member contracts;- standalone public-header and install-consumer tests: include self-sufficiency and exported-package boundaries.