Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
property.hpp
Go to the documentation of this file.
1#pragma once
2
3// ============================================================================
4// reactive/property.hpp
5// ----------------------------------------------------------------------------
6// `Property<T>` is the canonical **Source node** of the reactive graph.
7//
8// Design contract
9// ---------------
10// * A Property owns a value of type T and nothing else. Its identity lives
11// in the reactive graph; no hidden signal, no stored observer list of
12// its own -- those are carried by the intrusive Edge/Node primitives.
13// * Writes (`set`, `operator=`, `mutate`) bump the source's version and
14// push-color the downstream MaybeDirty. If no batch is active, the
15// Graph flushes immediately; otherwise the flush is deferred to the
16// outermost `reactive::batch([&]{ ... })` boundary.
17// * Reads (`get`, `operator T`) auto-record a dependency on the currently
18// active TrackingContext -- so Derivations and Reactions pick the
19// Property up without the user having to call `dep()` explicitly.
20// * Single-threaded. Cross-thread mutations MUST be marshalled to the
21// graph's owning thread via a Dispatcher; Debug builds assert.
22// * Equality-gated updates: `set(v)` with `v == current` is a no-op, so
23// idempotent writes cost nothing.
24//
25// An earlier per-Property `.batch()` API (returning a BatchUpdate guard) is
26// deliberately omitted: a single global `reactive::batch` / `BatchScope`
27// subsumes it with a much cleaner semantics (coalescing across many
28// Properties at once).
29// ============================================================================
30
33
34#include "aria/concepts.hpp"
35#include "aria/i_property.hpp"
36#include "aria/subscription.hpp"
37
38#include <any>
39#include <cassert>
40#include <functional>
41#include <memory>
42#include <typeinfo>
43#include <utility>
44
45namespace aria::reactive {
46
47// ---------------------------------------------------------------------------
48// Internal Reaction node used by Property::observe / on_changed / bind.
49// Declared here (not in effect.hpp) so Property can instantiate it
50// without creating a circular dependency between headers. `Effect` (the
51// public name in effect.hpp) is a thin user-facing wrapper over the same
52// primitive.
53// ---------------------------------------------------------------------------
54namespace detail {
55
56class ReactionNode final : public Node, public std::enable_shared_from_this<ReactionNode> {
57public:
58 explicit ReactionNode(std::function<void()> fn)
59 : Node(NodeKind::Reaction), fn_(std::move(fn)) {}
60
66 ~ReactionNode() noexcept override {
67 retire_();
68 }
69
70 [[nodiscard]] std::shared_ptr<Node> retain_for_recompute() noexcept override {
71 return weak_from_this().lock();
72 }
73
76 bool recompute() override {
77 if (fn_) fn_();
78 return false;
79 }
80
84 void observe_source(Node& src) {
85 // Allocate one Edge per upstream. We own them in a small vector
86 // so that destruction (our dtor -> Node::~Node -> detach) is
87 // automatic and leak-free.
88 edges_.push_back(std::make_unique<Edge>());
89 attach_as_observer_of(src, *edges_.back());
90 }
91
92private:
93 std::function<void()> fn_;
94 std::vector<std::unique_ptr<Edge>> edges_;
95};
96
97} // namespace detail
98
99// ---------------------------------------------------------------------------
100// Property<T>
101// ---------------------------------------------------------------------------
102template<PropertyValue T>
103class Property : public Node, public ::aria::IProperty {
104public:
105 using value_type = T;
106
107 // S-30 second tier: a one-line message that fires when somebody
108 // bypasses the concept (e.g. via aliases) and lands a non-copyable
109 // or non-equality-comparable type here. The concept on the template
110 // header is the first line of defence; this assert exists so that
111 // even when SFINAE picks up a different overload first, the eventual
112 // failure points at *why*.
113 static_assert(std::copyable<T>,
114 "Property<T> requires T to be copyable for snapshot reads. "
115 "If T is move-only, store it via "
116 "std::shared_ptr<T> or model the state with an ObservableList<T>.");
117 static_assert(EqualityComparable<T>,
118 "Property<T> requires T to be equality-comparable (==/!=): writes "
119 "with the same value are silently dropped. Provide an operator== "
120 "for T or wrap it in a thin struct that defines one.");
121
122 explicit Property(T initial = T{})
123 : Node(NodeKind::Source), value_(std::move(initial)) {}
124
125 ~Property() noexcept override { retire_(); }
126
127 // Non-copyable, non-movable: identity in the graph is tied to `this`.
128 Property(const Property&) = delete;
129 Property& operator=(const Property&) = delete;
130 Property(Property&&) = delete;
132
133 // ── Read ────────────────────────────────────────────────────────────
134
138 [[nodiscard]] T get() const {
139 if (auto* t = graph().current_tracker()) {
140 t->record_read(const_cast<Property&>(*this));
141 }
142 return value_;
143 }
144
150 [[nodiscard]] const T& get_ref() const {
151 if (auto* t = graph().current_tracker()) {
152 t->record_read(const_cast<Property&>(*this));
153 }
154 return value_;
155 }
156
164 [[nodiscard]] T peek() const noexcept(std::is_nothrow_copy_constructible_v<T>) {
165 return value_;
166 }
167
169 [[nodiscard]] const T& peek_ref() const noexcept { return value_; }
170
173 operator T() const { return get(); }
174
175 // ── Write ───────────────────────────────────────────────────────────
176
179 void set(const T& new_val) { set_impl_(new_val); }
180 void set(T&& new_val) { set_impl_(std::move(new_val)); }
181
182 Property& operator=(const T& v) { set(v); return *this; }
183 Property& operator=(T&& v) { set(std::move(v)); return *this; }
184
188 template<std::invocable<T&> Fn>
189 void mutate(Fn&& fn) {
190 std::forward<Fn>(fn)(value_);
192 }
193
194 // ── Observe ─────────────────────────────────────────────────────────
195
200 [[nodiscard]] ::aria::Subscription on_changed(std::function<void(const T&)> fn) {
201 auto reaction = std::make_shared<detail::ReactionNode>(
202 [this, fn = std::move(fn)] { fn(value_); });
203 reaction->set_debug_name("Property::on_changed");
204 reaction->observe_source(*this);
205 return ::aria::Subscription{std::move(reaction)};
206 }
207
210 [[nodiscard]] ::aria::Subscription bind(std::function<void(const T&)> fn) {
212 const detail::NodeHandle alive{this};
213 fn(value_); // initial sync — outside the graph, no tracking
214 if (!alive) return {};
215 return on_changed(std::move(fn));
216 }
217
220 [[nodiscard]] ::aria::Subscription observe(std::function<void(const T&, const T&)> fn) {
221 auto last = std::make_shared<T>(value_);
222 return on_changed([fn = std::move(fn), last](const T& v) {
223 T old = std::move(*last);
224 *last = v;
225 fn(old, v);
226 });
227 }
228
229 // ── Type-erased IProperty surface ───────────────────────────────────
230 //
231 // These methods cross the ABI boundary: callers operate on
232 // `IProperty*` without knowing T. The std::any payload tunnels
233 // the value through the dynamic library line. Same threading
234 // contract as the typed accessors — must be invoked on the
235 // graph's owning thread.
236
237 [[nodiscard]] std::any get_any() const override {
238 return std::any{get()};
239 }
240
241 [[nodiscard]] bool set_any(const std::any& value) override {
242 if (auto* typed = std::any_cast<T>(&value)) {
243 set(*typed);
244 return true;
245 }
246 return false;
247 }
248
250 std::function<void(const std::any&)> on_changed_any) override {
251 return on_changed([cb = std::move(on_changed_any)](const T& v) {
252 cb(std::any{v});
253 });
254 }
255
256 [[nodiscard]] const std::type_info& type() const noexcept override {
257 return typeid(T);
258 }
259
260private:
261 template<class U>
262 void set_impl_(U&& new_val) {
264 if (value_ == new_val) return; // equality gate -- no-op
265 // Strong exception guarantee: build a temporary first so a
266 // throwing T constructor cannot leave `value_` in a moved-from
267 // state. The swap step is noexcept for any sane T (and
268 // unconditionally so for nothrow-move-assignable types — the
269 // dominant case in practice). If `T` throws on swap we are no
270 // worse off than the original "value_ = ..." write.
271 T tmp(std::forward<U>(new_val));
272 using std::swap;
273 swap(value_, tmp);
274 notify_changed(); // push-color + (maybe) flush
275 }
276
277 T value_;
278};
279
280} // namespace aria::reactive
Type-erased Property surface.
Definition i_property.hpp:51
Property(T initial=T{})
Definition property.hpp:122
RAII handle to a single subscription.
Definition subscription.hpp:44
void assert_on_graph_thread() const noexcept
Definition graph.hpp:155
Common base for every node participating in the reactive graph.
Definition node.hpp:136
static Graph & graph() noexcept
Returns the process-wide singleton Graph this node belongs to.
Definition graph.inl:30
void notify_changed()
Called by a Source after its value has actually changed: bumps the version and colors all downstream ...
Definition graph.inl:117
Node(NodeKind kind) noexcept
Definition node.hpp:144
void retire_() noexcept
Retire before derived members (including user captures) are destroyed.
Definition graph.inl:42
void attach_as_observer_of(Node &source, Edge &edge) noexcept
Definition graph.inl:64
bool set_any(const std::any &value) override
Try to set the property's value from a std::any.
Definition property.hpp:241
T value_type
Definition property.hpp:105
Property & operator=(T &&v)
Definition property.hpp:183
T peek() const noexcept(std::is_nothrow_copy_constructible_v< T >)
Snapshot read that does NOT register a dependency.
Definition property.hpp:164
Property(T initial=T{})
Definition property.hpp:122
const T & peek_ref() const noexcept
Snapshot read by const reference (never tracks, never copies).
Definition property.hpp:169
Property & operator=(const T &v)
Definition property.hpp:182
void set(T &&new_val)
Definition property.hpp:180
void set(const T &new_val)
Commit a new value.
Definition property.hpp:179
::aria::Subscription observe(std::function< void(const T &, const T &)> fn)
Two-argument form: receive (old, new).
Definition property.hpp:220
Property(const Property &)=delete
Property & operator=(Property &&)=delete
T get() const
Auto-tracked read.
Definition property.hpp:138
~Property() noexcept override
Definition property.hpp:125
::aria::Subscription bind(std::function< void(const T &)> fn)
Fire once with the current value, then on every subsequent change.
Definition property.hpp:210
::aria::Subscription subscribe_any(std::function< void(const std::any &)> on_changed_any) override
Subscribe to value changes.
Definition property.hpp:249
const std::type_info & type() const noexcept override
The runtime type of the wrapped value.
Definition property.hpp:256
Property(Property &&)=delete
std::any get_any() const override
Read the current value as a std::any.
Definition property.hpp:237
void mutate(Fn &&fn)
Mutate-in-place: always fires a change (cannot detect no-op because the mutation is opaque).
Definition property.hpp:189
Property & operator=(const Property &)=delete
::aria::Subscription on_changed(std::function< void(const T &)> fn)
Run fn(new_value) every time the value changes.
Definition property.hpp:200
const T & get_ref() const
Auto-tracked read by const reference.
Definition property.hpp:150
Type that supports == and != (required for change detection).
Definition concepts.hpp:30
Definition computed.hpp:60
NodeKind
Definition node.hpp:87
@ Reaction
Definition node.hpp:90
@ Source
Definition node.hpp:88
TrackingContext * current_tracker() noexcept
Returns the current tracker of the global graph.
Definition graph.hpp:300
Definition validation_key.hpp:110