Files
2026-08-12 06:16:00 +08:00

678 lines
22 KiB
Markdown

# Structive Property Core Guide
[中文](CORE_GUIDE.zh-CN.md)
## 1. Include and CMake target
```cpp
#include <structive/property/property.hpp>
```
```cmake
target_link_libraries(my_target PRIVATE structive::property_core)
```
Property Core is C++20.
## 2. Define a managed object
```cpp
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
```cpp
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`.
### 3.1 Type-level metadata
Extensions can attach compile-time attributes to the described type itself, independently
from property metadata and inheritable property defaults:
```cpp
struct Action_Category {};
struct Action {
using attribute_category = Action_Category;
static constexpr bool single_valued = false;
static constexpr bool inheritable = false;
std::string_view name;
};
return object<Device>(
type_metadata(Action{"reset"}, Action{"calibrate"}),
field<&Device::temperature>(key<"temperature">)
);
```
Use `Schema::type_attribute_count<Category>`, `Schema::has_type_attribute<Category>`,
`schema.type_attribute<Category>()` for a single-valued category, and
`schema.for_each_type_attribute<Category>(callback)` for traversal. Core stores and
validates the generic Attribute protocol but does not interpret extension-owned categories.
Type metadata is not a property default and is never copied into individual properties.
## 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:
```cpp
static_assert(Property_Described_Object<Device>);
using Schema = type_descriptor_schema_t<Device>;
static_assert(Valid_Property_Schema<Schema>);
```
## 5. Access the schema
```cpp
const auto& schema = type_descriptor<Device>();
const auto& same_schema = device.schema();
```
Properties can be selected by index or member pointer:
```cpp
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:
```cpp
using Property = std::remove_cvref_t<decltype(schema.property<&Device::temperature>())>;
static_assert(Property::readable);
static_assert(Property::writable);
static_assert(Property::runtime_copy_writable);
using Value = Property::value_type;
using Accessor = Property::accessor_type;
using Dependencies = Property::dependency_spec;
```
Every custom `Property_Accessor` must declare `using dependency_spec = ...`. Use `No_Property_Dependencies` when it has no dependencies; there is no implicit fallback.
A `read_only` field reports:
```cpp
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:
```cpp
auto key_value = schema.property<&Device::temperature>().key();
```
## 7. Intrinsic capability
Core capability values are:
```cpp
Property_Capability::none
Property_Capability::read
Property_Capability::write
Property_Capability::read_write
```
Convenience Attributes are:
```cpp
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:
```text
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:
```cpp
key<"temperature">
```
The key is the structural protocol identifier used by runtime lookup and adapters.
### 9.2 Capability
```cpp
read_only
write_only
read_write
inaccessible
```
Capability Attributes are single-valued and non-inheritable.
### 9.3 Unit
```cpp
unit<"C">
unit<"kPa">
```
Core stores unit metadata but does not perform conversion.
### 9.4 Sensitive
```cpp
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.
```cpp
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(...)`:
```cpp
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:
```cpp
min_value<0>
max_value<100>
finite
```
Custom constraints:
```cpp
constraint<"even">([](int value) {
return value % 2 == 0;
})
```
Validation is explicit:
```cpp
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:
```cpp
auto temperature = device.read<&Device::temperature>();
device.write<&Device::temperature>(30.0);
```
Compile-time key access is also available:
```cpp
auto temperature = device.read<"temperature">();
device.write<"temperature">(30.0);
```
The typed interfaces are constrained by intrinsic capability. A write to `read_only` does not participate in overload resolution.
```cpp
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:
```cpp
field<&Device::serial_number>(key<"serial_number">, read_only)
```
Structive resolves:
```text
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
Synchronization is intentionally a topology layer rather than a property permission system. It answers only one question: when managed mutable state is accessed concurrently, which properties share a consistency domain?
Stored intrinsic read-only properties are removed before lock slots are materialized. A broad rule such as `sync_all_shared` therefore never creates a mutex merely for a stored `read_only` property.
### 14.1 Default topologies
| Default | Meaning for properties that require synchronization |
| --- | --- |
| `sync_all_independent` | each property receives its own lock domain |
| `sync_all_shared` | all properties share one lock domain |
| `sync_all_unsynchronized` | no real lock domain is created |
```cpp
synchronization(sync_all_independent)
```
`sync_all_unsynchronized` is an explicit opt-out. Structive still provides managed access, but the caller owns the thread-safety consequences of concurrent reads and writes.
### 14.2 Compile-time member rules
Use member pointers when the schema is known in C++ code:
```cpp
synchronization(
sync_all_shared,
sync_independent<&Device::temperature>(),
sync_unsynchronized<&Device::debug_counter>()
)
```
Member validity is checked while the synchronization specification is materialized for the schema.
### 14.3 Runtime-key rules
Adapters or configuration code may build a `Synchronization_Plan` from keys:
```cpp
Synchronization_Plan plan;
plan.set_default(Synchronization_Default::independent);
plan.unsynchronized("debug_counter");
```
Key validity cannot be known until the plan is resolved. Unknown properties, duplicate property configuration, empty groups and duplicate group names are rejected with `std::invalid_argument`.
### 14.4 Groups are consistency domains
```cpp
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 actually require synchronization. A read-only stored member may appear in a broad rule or group, but it still resolves to `unsynchronized_slot` because there is no managed writer to protect.
A group should express a real invariant or snapshot boundary. It should not be used merely to reduce the mutex count.
### 14.5 Why the synchronization API has several forms
The forms represent different information availability, not duplicate concepts:
- type-level defaults describe the normal topology once per object type;
- compile-time member rules give typed C++ code compile-time schema checking;
- runtime-key rules support adapters that discover property names dynamically;
- per-instance overrides support objects whose synchronization topology genuinely differs from the type default;
- guards express a temporary multi-property consistency operation.
The common semantic model is always the same resolved lock-slot topology.
## 15. Per-instance synchronization override
An object can explicitly override the type default:
```cpp
Device device{
property_synchronization(
synchronization(sync_all_shared)
)
};
```
For typed member rules:
```cpp
Device device{
property_synchronization<Device>(
synchronization(
sync_all_independent,
sync_group<&Device::temperature, &Device::pressure>("environment")
)
)
};
```
The default topology is resolved once and shared per type. Only an object with an explicit override stores a compact override layout. Copy and move construction preserve an object's override topology; assignment preserves the destination object's existing topology because assignment changes object state, not the synchronization policy chosen for that instance.
## 16. Inspect resolved synchronization
```cpp
auto view = device.resolved_synchronization();
auto count = view.lock_count;
```
Inspect a member slot:
```cpp
auto slot = device.lock_slot<&Device::temperature>();
```
No-lock properties use:
```cpp
Resolved_Synchronization_View::unsynchronized_slot
```
For a stored read-only property this is automatic. `resolved_synchronization()` and `lock_slot()` are primarily diagnostics and framework-level inspection APIs; normal business code should usually express its intent through `read`, `write`, `lock_shared` and `lock_unique` instead of reasoning about numeric slots.
## 17. Static multi-property guards
Read guard:
```cpp
auto guard = device.lock_shared<&Device::temperature, &Device::pressure>();
auto by_member = guard.get<&Device::temperature>();
auto by_key = guard.get<"pressure">();
```
Write guard:
```cpp
auto guard = device.lock_unique<&Device::min_speed, &Device::max_speed>();
guard.set<&Device::min_speed>(20);
guard.set<"maximum_speed">(120);
```
Member and compile-time-key access intentionally use the same `get`/`set` names. Capability constraints are part of the overload itself: a typed unique guard cannot be requested for a read-only property, and `set` does not exist for a non-writable property in a `requires` expression.
Static guards resolve member slots, deduplicate repeated domains and acquire locks in stable numeric slot order. Request order therefore does not become mutex acquisition order, avoiding lock-order inversion when two callers request the same domains in different member order.
A read-only stored property may participate in a shared guard's logical held set without introducing a mutex. This allows a guard API to read it consistently with its intrinsic contract while preserving the read-only zero-lock fast path.
## 18. Dynamic-key guards
```cpp
auto read_guard = device.lock_shared({"temperature", "pressure"});
auto write_guard = device.lock_unique({"temperature", "pressure"});
auto value = read_guard.get<"temperature">();
```
Dynamic guards exist for callers whose selected property set is known only at runtime. Because the keys are dynamic, errors that typed guards reject through constraints become runtime errors:
- an unknown key throws `std::invalid_argument`;
- `lock_shared` rejects a non-readable property;
- `lock_unique` rejects a non-writable property;
- a later `get`/`set` throws `std::logic_error` if the requested property is outside the guard's held set.
Dynamic and static guards use the same resolved lock topology and the same stable lock ordering.
The behavior above is covered directly by `core/tests/synchronization_test.cpp`, including independent/shared/unsynchronized defaults, groups, overrides, invalid plans, runtime-key guards, blocking behavior and reversed member-order acquisition.
## 19. Traversal
Schema-only traversal:
```cpp
schema.for_each_property([&](auto index, const auto& descriptor) {
});
```
Readable value traversal:
```cpp
device.for_each_readable([&](auto index, const auto& descriptor, const auto& value) {
});
```
Locked readable traversal:
```cpp
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:
```cpp
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:
```cpp
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">)
```
`depends_on<...>` makes dependencies explicit Schema facts. `depends_on_keys<"a", "b">` is available when key-based declaration is more appropriate. `depends_on<>` is a valid explicit zero-dependency declaration. A computed view can read only its declared direct dependencies.
Writable dependencies that must form one snapshot should share a synchronization domain with each other:
```cpp
synchronization(
sync_all_independent,
sync_group("speed", "min_speed", "max_speed")
)
```
Do not put the computed property itself in a synchronization rule. Its read slot is derived from the dependency graph; a schema or per-instance topology is rejected when synchronized dependencies do not resolve to one domain. Read-only stored dependencies need no lock slot.
`schema_property_dependency_indices<Schema, Index>()` exposes the direct dependency indices for schema consumers. `schema_dependency_graph_acyclic<Schema>()` exposes the DAG invariant; `object_schema(...)` rejects cyclic dependency graphs at compile time.
A descriptor stores Attributes and Constraints in one metadata tuple. `for_each_metadata()` traverses both kinds, `for_each_attribute()` traverses only Attributes, and `for_each_constraint()` traverses only Constraints.
## 21. Trusted accessor properties
Trusted getter:
```cpp
trusted_computed_property<&Device::value>(key<"value">)
```
Trusted getter/setter:
```cpp
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
`Property_Object_Base` is the dynamic adapter boundary. It is intended for GUI inspectors, serialization adapters, scripting bridges, RPC layers and other code that learns a property key only at runtime. Normal typed C++ code should prefer `read` and `write`.
```cpp
Property_Object_Base& erased = device;
```
Introspection:
```cpp
erased.runtime_object_type();
erased.runtime_property_count();
```
### 22.1 Runtime read
```cpp
auto result = erased.runtime_read("temperature", context, callback);
```
On success the callback is invoked exactly once with the schema index, key, exact `type_info` and a pointer to the current value. The value pointer is borrowed and is valid only during the callback; copy or consume it synchronously and never retain it.
For a synchronized writable property, Structive keeps the corresponding managed read lock held while the callback executes. The callback should therefore not re-enter a conflicting managed write on the same lock domain. A stored read-only property follows the intrinsic zero-lock fast path and does not acquire a mutex merely because the access is dynamic.
### 22.2 Runtime write
```cpp
auto result = erased.runtime_write("temperature", typeid(double), &value);
```
Runtime write intentionally performs no implicit conversion. `typeid(double)` must exactly match the property's declared value type, and the pointer must address a live value of that exact type for the duration of the call. The boundary copies from a `const` input. If a writable accessor cannot accept that copy input, its descriptor exposes `runtime_copy_writable == false` and runtime access returns `unsupported_runtime_write`; typed `write` still accepts move-only values when the accessor supports them. A successful runtime write uses the same managed synchronization path as typed `write`.
### 22.3 Result contract
| Result | Meaning |
| --- | --- |
| `ok` | lookup and access completed |
| `unknown_property` | no schema property has that runtime key |
| `not_readable` | the property exists but its intrinsic capability is not readable |
| `not_writable` | the property exists but its intrinsic capability is not writable |
| `unsupported_runtime_write` | the property is intrinsically writable but its accessor cannot accept the runtime copy-input boundary |
| `type_mismatch` | runtime write supplied a type different from the declared property value type |
There is no external/persistence access mode and no access-control policy in this API. An adapter decides whether it wants to expose or call runtime read/write; Structive reports only the property's intrinsic capability.
### 22.4 Why this API is deliberately low-level
The runtime boundary uses `type_info`, `void*` and a callback because the value type is unknown to the caller at compile time. Core does not impose a universal `variant`, heap-owned `any`, serialization format or conversion registry, because any of those would add ownership and conversion policy that belongs to a higher-level adapter.
This is therefore an adapter API rather than the preferred business-code API. A higher-level extension may wrap it in domain-specific value containers without changing Structive Core.
The contract is covered directly by `core/tests/runtime_api_test.cpp`, including every result code, callback metadata, exact-type writes, managed blocking behavior and the read-only zero-lock runtime fast path.
## 23. Lock policies
Default:
```cpp
Property_Object<Device, Shared_Mutex_Policy>
```
No-lock:
```cpp
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
```cpp
Device& raw = device.unsafe_object();
```
or normal public member access:
```cpp
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:
1. Use ordinary C++ members for storage.
2. Register only fields that belong to the structural model.
3. Prefer member-pointer typed APIs in C++ business code.
4. Use `read_only` when the Structive managed model must never write a stored property.
5. Rely on the resulting zero-lock optimization for stored read-only data.
6. Keep raw writes to read-only properties outside concurrent managed code.
7. Use synchronization groups for mutable cross-field consistency.
8. Keep validation explicit.
9. Use runtime access only for genuinely dynamic adapters.
10. Let GUI/RPC/persistence/authorization layers own their own exposure and access policy.