|
Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
|
This document is the framework's authoritative reference for API style. Together with lifecycle.md it forms the standard "read these and you can write Aria-style code". Any naming, namespacing, include-path, error-message, template- diagnostic, or deprecation argument should ultimately cite this file. Every contract item is numbered S-N for citation in code and commit messages.
A "best-in-class C++ MVVM framework" API must satisfy three overarching principles:
- Consistent: equivalent things appear in the same shape across every module.
- Direct: a user knows what to autocomplete BEFORE typing the first character.
- Diagnosable: misuse produces a one-line, human-readable error from the compiler — never a 30-frame SFINAE eruption.
Core MVVM types are exposed under aria:: — users facing the core reactive API write only aria:::
| Category | Public symbol | Notes |
|---|---|---|
| Reactive primitives | Property<T> / Computed<T> / Effect | State, derived values, side effects |
| Subscription | Subscription / SubscriptionBag | Unified RAII detach |
| Command | Command<Args...> | Synchronous command |
| Collections | ObservableList<T> / FilteredList<T> / SortedList<T> / MappedList<U,V> / ListChange<T> / ListChangeKind | Collections + derived collections |
| Validation | Validator<T> / ValidationState | Per-field validator + form state |
| Abstractions | IProperty<T> | ABI-friendly interface |
| Globals | batch / untracked / BatchScope / UntrackedScope / dep | Control primitives |
| Exceptions | CircularDependencyError | Reactive failure signal |
| Concepts | PropertyValue / EqualityComparable / Observable / InvocableR | Template constraints |
Per-domain public exports:
Unified scheduler base: every scheduler (IExecutor / IDelayedScheduler / IDispatcher and their subclasses) virtually inherits from aria::IScheduler and reports its capabilities via caps() returning a SchedulerCaps bitmask. A component checks requirements with a single has_caps(s, SchedulerCaps::Delay | SchedulerCaps::MainThread) line; if the capability is missing, require_caps throws unsupported_capability. The legacy IExecutor::post / IDelayedScheduler::post_after / IDispatcher::post_delayed are retained as equivalent aliases of IScheduler::schedule / schedule_after.
Hot-path callable contract:
- aria::function_ref<R(Args...)> — non-owning, sizeof == 2 * sizeof(void*), trivially copyable. Use it as a parameter type to accept any callable without forcing a std::function copy / heap allocation. Never store it as a field — it does not extend the target's lifetime.
- aria::inplace_function<R(Args...), N=32, Align=alignof(max_align_t)> — owning, N-byte inline buffer; capacity overflow is a compile-time static_assert, never a heap allocation. Copyable / movable iff the erased callable is copyable / movable. Use cases: (1) derived-list owning callbacks (FilteredList::Predicate / SortedList::Comparator / MappedList::Mapper / DistinctList::KeyOf / GroupedList::KeyOf are switched to inplace_function<…, 32>; the zero-heap-allocation contract is type-system enforced); (2) anywhere you want to keep a lambda long-term but absolutely forbid it from silently calling malloc.
- Selection rule: short-lived sync callback → function_ref; long-lived storage with known capacity → inplace_function; long-lived storage with unknown capacity / crossing an ABI boundary → std::function. Together they form the "non-owning, fixed-capacity owning, unbounded owning" trio.
Unified callback-failure reporting channel: every framework- internal boundary that "must stay `noexcept` yet calls into user code" (thread-pool worker / main-thread drain & run_one / SimpleDispatcher pump & run_one / VirtualTimeExecutor advance & run_until_idle / ABI slot trampoline / async detached path) MUST route through aria::report_callback_failure(category, std::current_exception()).
- Category naming: dotted, module.subsystem.action, e.g. "executor.thread_pool.worker" / "executor.main_thread.drain" / "executor.main_thread.run_one" / "runtime.simple_dispatcher.pump" / "runtime.simple_dispatcher.run_one" / "executor.virtual_time.advance" / "executor.virtual_time.run_until_idle" / "abi.slot.invoke" / "async" (legacy async surface).
- Storage location: sink_storage()'s real definition lives in libaria_abi (single TU); every SHARED module shares the same physical slot, avoiding the "inline static across DSOs" duplicate-storage problem. The slot-failure hook (aria::abi::set_slot_invoke_failure_hook) follows the same model.
- Default behaviour: with no sink installed, stderr emits one line [aria.callback_failure] <category>: <message>. The host application calls aria::runtime::install_default_diagnostics() in main() to bridge every sink to aria::Logger::error, with the category prefixed by aria. (e.g. aria.executor.thread_pool.worker).
- Invariant: report_callback_failure itself never throws — if a user-installed sink throws, the framework's stderr fallback handles it. This guarantees no framework-internal noexcept boundary ever calls std::terminate.
- ABI bridge: the abi layer cannot back-depend on core; the SlotInvokeFailureHook is abi's injection point, bridged to report_callback_failure("abi.slot.invoke", …) at startup by runtime::install_default_diagnostics().
Converter failure-semantics contract: historically aria::binding::Converter<T,U> had only to_view / to_model fields, and the built-in to_model silently returned T{} on a parse failure (int → 0, double → 0.0). The business code could not distinguish "user typed 0" from "input is invalid".
- New field: std::function<std::optional<T>(const U&)> try_to_model. std::nullopt means "cannot parse"; this is the channel the binding engine prefers.
- Strict to_model: built-in converters (int_to_string / double_to_string / bool_to_yes_no) throw aria::binding::ConversionError (derives from std::runtime_error) on parse failure rather than silently returning 0. The try_to_model field is filled with the equivalent non-throwing implementation.
- Engine-side contract: BindingEngine::bind_text_converted on the View → Model path:
- Calls try_to_model first; on std::nullopt does not write the Model and reports via aria::report_callback_failure("binding.converter", nullptr, "converter.try_to_model rejected input").
- When the user-supplied converter does not populate try_to_model, falls back to to_model wrapped in a try/catch that routes to the same channel (category "binding.converter"). Both paths guarantee the Model is never written with a fabricated default value.
- Trailing-garbage strictness: the built-in numeric converters use std::stoi(s, &consumed) / std::stod(s, &consumed) and assert consumed == s.size(), so "12abc" is detected as invalid input rather than 12.
- Backward compatibility: the legacy entry points c.to_view(x) / c.to_model(s) are retained — to_model simply upgrades from "return 0 on bad input" to "throw, caught by the engine". Hosts will see binding.converter events in their logs; the model is no longer silently corrupted.
BindingEngine trace helper contract: BindingEngine::dispatch_to_view_<Fn> is a template member, so it instantiates once per binding parameter Fn. Three branches (Direct / SmartMarshal on the main thread / dispatcher.post) each need to emit a TraceCategory::Binding event at two points ("view destroyed" and "VM→View write") — six publish_trace_unchecked call sites in total.
- Helper abstraction: two non-template static members, BindingEngine::trace_drop_(std::string_view platform) noexcept and trace_emit_(std::string_view platform) noexcept, are the only entities behind those six call sites. They encapsulate the aria::trace::Binding{ platform_str, "", "view_destroyed_drop" / "vm_to_view" } payload construction and the publish_trace_unchecked publish.
- Guards retained: call sites still have if (tracing) trace_drop_(platform) / if (aria::has_trace_sink()) trace_emit_(platform_copy) — ensures we pay zero call cost when tracing is off (a noexcept helper still has to copy the input string into its parameter, which is what the guard short-circuits). The template body is not slowed by the helper extraction.
- Why non-template: the helpers are intentionally regular functions, not helper templates, so that dispatch_to_view_<Fn>'s template bloat does not also copy the trace payload-construction code into every instantiation. BindingEngine has 5 instantiation branches today — they share the same helper code, zero duplication.
- Why static: helpers don't depend on *this, only on the platform string. Making them static lets dispatcher-posted lambdas call BindingEngine::trace_drop_(platform_copy) directly without capturing this, structurally avoiding the "engine destructed but in-flight lambda still references this" dangling-access risk.
- Future trace events: any new BindingEngine trace event added later MUST follow the same four invariants: non-template + static + std::string_view parameter + noexcept, with a has_trace_sink() / tracing guard at the call site.
Forbidden:
Allowed:
reactive::GraphInspector MUST be promoted via using reactive::GraphInspector to aria::GraphInspector. Anything users should use must not require typing the implementation namespace. This is a concrete instance of S-1.
aria::detail::TypedSignal<...> and aria::detail::ReactionNode etc. wrap aria::abi::SignalErased and Node-like ABI primitives into strongly-typed internal bridges.
Users MUST NOT use them directly. Inside the framework they MUST be referenced fully qualified as aria::detail::TypedSignal<...>; bare detail::TypedSignal is forbidden because if a user wrote using namespace aria::reactive, the bare detail:: would resolve to aria::reactive::detail and become ambiguous.
aria::async::detail:: / aria::reactive::detail:: / each module's detail:: namespace:
Never require users to write deep paths like <aria/reactive/property.hpp>. Deep paths are an implementation detail.
The framework's own .hpp / .inl files include each other via uniformly long "aria/..." paths; relative paths and bare file names are forbidden:
Correct:
Wrong:
Reasons:
Each module exposes one umbrella header as a "one-stop" entry; samples and quickstarts are encouraged to use it:
| Umbrella | Module |
|---|---|
| <aria/aria.hpp> | All public core APIs |
| <aria/async/async.hpp> | All public async APIs (when present) |
| <aria/binding/binding.hpp> | All public binding APIs (when present) |
| <aria/runtime/runtime.hpp> | All public runtime APIs (when present) |
Sub-headers can still be included individually; the umbrella is just a convenience entry point.
Property / ObservableList / BindingEngine / AsyncCommand. Acronyms are leading-cap-only, not all-caps: IoExecutor, not IOExecutor (per Google Style).
prop.set(v) / prop.on_changed(fn) / list.push_back(x) / engine.bind_text(...).
Private data members are name_:
ARIA_BINDING_API, ARIA_NO_DISCARD, ARIA_DEPRECATED. Never use a macro to declare a user-visible API; only for platform branching and export decoration.
| Shape | Naming | First-fire behaviour |
|---|---|---|
| Subscribe only, never fire | on_changed(fn) / observe(fn) / on_destroy(fn) / on_click(fn) | Not invoked |
| Fire once, then keep observing | bind(fn) | Synchronously invoked once |
| RAII side effect | Effect e{fn} | Construction fires once |
This naming contract is pinned in lifecycle.md L-19; this document just restates it.
Ordered by "how readable to the user", template entry points MUST satisfy at least the first two:
Anti-pattern (30-frame SFINAE):
Correct:
A thrown exception's what() MUST contain:
CircularDependencyError already complies (carries the node-name list). OperationCancelled is regular control flow and does not need a "where".
Per lifecycle.md L-13: exceptions thrown from a user callback during a signal emit MUST be swallowed by the framework. A misbehaving handler MUST NOT prevent later handlers from running.
The reactive subsystem's recompute() is the exception: an exception is propagated back to Graph::pull, which restores the node to Clean and rethrows.
IViewAdapter::platform_name() returns a stable lowercase id that matches IView::kind() exactly:
| Adapter | platform_name() | IView::kind() |
|---|---|---|
| Qt6 | "qt6" | "qt6" |
| AppKit | "appkit" | "appkit" |
| UIKit | "uikit" | "uikit" |
| Fake | "fake" | "fake" |
Reason: trace events / diagnostic sinks / log filters / showcase routing all match on the platform_name() string. Mixing case breaks "filter logs by platform" regexes. New adapters MUST follow the lowercase id rule.
Unsupported-widget behaviour: every set_* / get_* / on_*_changed / on_click that receives a widget class outside the adapter's support matrix MUST go through the corresponding warn_unsupported_(op, native) helper and emit one warning line:
Routed via aria::runtime::Logger::warn(category, msg) with the category set to "qt_adapter" / "appkit_adapter" / "uikit_adapter". After the warn, the adapter MUST safely return with a "zero subscription `Subscription{}`" / "default value 0 / false / empty string"; throwing or accessing nullptr is forbidden.
Reasons:
Aria follows semantic versioning; during evolution we keep no deprecated aliases. Every P0/P1 closure breaks all call sites outright in the same commit, and migrates the adapters, tests, and documentation snippets in that commit too.
Reasons:
Before merging any PR, self-review:
Style adjustments MUST flow as doc change → code change → test change; the reverse is not allowed (avoids "code drifts first, docs catch up later").