Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
computed.hpp
Go to the documentation of this file.
1#pragma once
2
3// ============================================================================
4// reactive/computed.hpp
5// ----------------------------------------------------------------------------
6// `Computed<T>` is a **Derivation node**: a read-only value produced from
7// other reactive sources via a user-supplied compute function.
8//
9// Dependency discovery
10// --------------------
11// Automatic. During every recompute the Graph installs a fresh
12// TrackingContext; every Property::get() (or nested Computed::get())
13// called from the compute function registers itself as an upstream.
14// Conditional branches therefore "just work": the next recompute gathers
15// a brand-new edge set, and the old edges (that were not re-visited)
16// are quietly dropped.
17//
18// Property<bool> flag{true};
19// Property<int> a{1}, b{2};
20// Computed<int> value{[&]{ return flag.get() ? a.get() : b.get(); }};
21// // value currently depends on {flag, a}. If flag flips to false,
22// // the next recompute will depend on {flag, b}.
23//
24// Laziness & memoization
25// ----------------------
26// * `get()` is always cheap: it returns the cached value plus (if a
27// tracker is active) registers self as an upstream read.
28// * Recompute is triggered by the Graph's flush pass, which only runs
29// Derivations whose upstream versions actually moved.
30// * Equal-to-previous results short-circuit downstream propagation,
31// keeping the graph glitch-free.
32//
33// Replaces the two legacy concepts (`AutoComputed` and the explicit-deps
34// `Computed`) with a single unified name. The "explicit deps" ergonomic
35// is still supported: callers who want to track something they did not
36// actually read can call `dep(x)` inside the body.
37// ============================================================================
38
41
42#include "aria/concepts.hpp"
43
44#include <cassert>
45#include <concepts>
46#include <cstddef>
47#include <deque>
48#include <functional>
49#include <memory>
50#include <optional>
51#include <type_traits>
52#include <utility>
53#include <vector>
54
55// Forward declaration of the unified subscription handle -- its definition
56// lives in <aria/subscription.hpp>, which is included by effect.hpp
57// where the Computed observer methods are actually defined.
58namespace aria { class Subscription; }
59
60namespace aria::reactive {
61
62// ---------------------------------------------------------------------------
63// dep(x)
64// ------
65// Explicit hint: "treat `x` as an upstream dependency of the currently
66// recomputing Derivation/Reaction, even if we do not read its value".
67//
68// For the 99% case where you *do* read the value, you can simply call
69// `x.get()` -- auto-tracking picks it up. `dep(x)` exists for the edge
70// cases where a dependency is inferred from the mere *presence* of a
71// value (for instance, "recompute when the user clicks", where the
72// Property<int> holds a click counter you do not care about otherwise).
73// ---------------------------------------------------------------------------
74template<class Reactive>
75 requires ::aria::ReactiveNode<Reactive>
76void dep(Reactive& r) {
77 if (auto* t = Node::graph().current_tracker()) {
78 t->record_read(static_cast<Node&>(r));
79 }
80}
81
82// ---------------------------------------------------------------------------
83// Computed<T>
84// ---------------------------------------------------------------------------
85template<PropertyValue T>
86class Computed final : public Node {
87public:
88 using value_type = T;
89
90 // S-30 second tier (see Property<T>).
91 static_assert(std::copyable<T>,
92 "Computed<T> requires T to be copyable: every observer is handed "
93 "a copy of the latest computed value.");
94 static_assert(EqualityComparable<T>,
95 "Computed<T> requires T to be equality-comparable: equal-to-cached "
96 "recomputes are skipped, which is what keeps the graph glitch-free.");
97
102 template<std::invocable<> Fn>
103 requires std::convertible_to<std::invoke_result_t<Fn>, T>
104 explicit Computed(Fn fn)
106 compute_(std::move(fn)) {
107 // Eager initial evaluation. Bump version to 1 (Node starts at 1
108 // already; we overwrite via the standard recompute path so edges
109 // are registered correctly).
110 (void)recompute();
111 }
112
113 Computed(const Computed&) = delete;
114 Computed& operator=(const Computed&) = delete;
115 Computed(Computed&&) = delete;
117
124 ~Computed() noexcept override {
125 retire_();
126 // `edge_pool_` will now destroy safely: each Edge slot is no
127 // longer referenced by any source node's intrusive list.
128 }
129
130 // ── Read ────────────────────────────────────────────────────────────
131
135 [[nodiscard]] T get() const {
136 auto& g = graph();
137 // If we are MaybeDirty/Dirty, synchronously evaluate. This makes
138 // Computed::get() usable in non-flush contexts (e.g. a unit test
139 // that reads a Computed right after writing to its source,
140 // without wrapping in batch()).
141 if (state() != NodeState::Clean) {
142 const_cast<Computed*>(this)->pull_self_();
143 }
144 // Register as an upstream of any outer Derivation/Reaction.
145 if (auto* t = g.current_tracker()) {
146 t->record_read(const_cast<Computed&>(*this));
147 }
148 return *cached_;
149 }
150
153 [[nodiscard]] const T& get_ref() const {
154 auto& g = graph();
155 if (state() != NodeState::Clean) {
156 const_cast<Computed*>(this)->pull_self_();
157 }
158 if (auto* t = g.current_tracker()) {
159 t->record_read(const_cast<Computed&>(*this));
160 }
161 return *cached_;
162 }
163
166 [[nodiscard]] T peek() const noexcept(std::is_nothrow_copy_constructible_v<T>) {
167 return *cached_;
168 }
169
171 [[nodiscard]] const T& peek_ref() const noexcept { return *cached_; }
172
173 operator T() const { return get(); }
174
175 // ── Observe ─────────────────────────────────────────────────────────
176 // Defined out-of-line in computed_observe.inl (after Observer/Reaction
177 // are visible) to avoid forward-declaration gymnastics.
178 [[nodiscard]] ::aria::Subscription on_changed(std::function<void(const T&)> fn);
179 [[nodiscard]] ::aria::Subscription bind(std::function<void(const T&)> fn);
180 [[nodiscard]] ::aria::Subscription observe(std::function<void(const T&, const T&)> fn);
181
182 // ── Graph integration ────────────────────────────────────────────────
183
195 bool recompute() override {
196 // 1. Run the user lambda under a fresh tracker, leaving our
197 // existing edges untouched. If compute_() throws, we
198 // propagate the exception with our previous dependency set
199 // fully intact — the graph will continue to wake us up on
200 // the next upstream change.
201 TrackingContext ctx{read_buffer_};
202 T new_val = [&] {
203 TrackerScope guard(ctx);
204 return compute_();
205 }();
206
207 // 2. Prepare storage and commit the value before replacing edges.
208 // Equality and value assignment may throw as well as compute_().
209 // Those failures must preserve our previous dependencies. During
210 // construction, no edges may outlive a failed initial cache move.
211 //
212 // Edge storage is REUSED across recomputes. `clear_sources()`
213 // only unlinks the Edge objects from their upstreams' intrusive
214 // lists; the Edge slots themselves stay in `edge_pool_` (a
215 // std::deque, so element addresses are stable — required because
216 // `attach_as_observer_of` threads `&edge` into the graph's
217 // intrusive lists). The pool's capacity grows monotonically to
218 // the deepest dependency-set size ever observed and is reused
219 // thereafter, so a Computed whose dependency set is stable does
220 // ZERO heap allocation per recompute. This is what makes the
221 // "no hidden allocations on the hot path" contract hold for
222 // Derivation re-evaluation, not just Property set/get.
223 const auto& reads = ctx.reads();
224 // Grow before detaching. Allocation failure preserves the previous
225 // dependency set so a later source change can retry this evaluation.
226 while (edge_pool_.size() < reads.size()) edge_pool_.emplace_back();
227 const bool changed = !cached_ || !(*cached_ == new_val);
228 if (changed) cached_ = std::move(new_val);
229
230 // 3. Only non-throwing graph bookkeeping remains. Reconcile edges
231 // even when the value is equal: conditional dependencies may change.
233 set_depth(0);
234 active_edges_ = 0;
235 for (const auto& read : reads) {
236 // `attach_as_observer_of` fully (re)initialises the Edge's
237 // source/observer/version and link pointers, so a recycled
238 // slot is safe to reuse without an explicit reset.
239 if (read) attach_as_observer_of(*read, edge_pool_[active_edges_++]);
240 }
241
242 if (changed) bump_version_();
243 return changed;
244 }
245
248 [[nodiscard]] std::size_t dependency_count() const noexcept {
249 std::size_t count = 0;
250 for_each_source([&](const Edge&) { ++count; });
251 return count;
252 }
253
254private:
257 void pull_self_() {
258 // Graph::pull handles the MaybeDirty/Dirty state machine.
259 graph().pull(*this);
260 }
261
262 std::function<T()> compute_;
263 std::optional<T> cached_;
270 std::deque<Edge> edge_pool_;
271 std::size_t active_edges_ = 0;
272 TrackingContext::Buffer read_buffer_;
273};
274
275} // namespace aria::reactive
Computed(Fn fn)
Construct and perform the initial compute eagerly, so get() immediately returns the correct value wit...
Definition computed.hpp:104
RAII handle to a single subscription.
Definition subscription.hpp:44
std::size_t dependency_count() const noexcept
Number of upstreams currently in use.
Definition computed.hpp:248
Computed(const Computed &)=delete
const T & peek_ref() const noexcept
Non-tracking snapshot read by const reference.
Definition computed.hpp:171
T peek() const noexcept(std::is_nothrow_copy_constructible_v< T >)
Non-tracking snapshot read.
Definition computed.hpp:166
Computed(Fn fn)
Construct and perform the initial compute eagerly, so get() immediately returns the correct value wit...
Definition computed.hpp:104
::aria::Subscription on_changed(std::function< void(const T &)> fn)
Definition effect.hpp:160
T get() const
Return the cached value, ensuring it is up to date.
Definition computed.hpp:135
Computed & operator=(Computed &&)=delete
~Computed() noexcept override
Explicit destructor: detach every upstream edge before the edge pool releases the storage backing the...
Definition computed.hpp:124
::aria::Subscription observe(std::function< void(const T &, const T &)> fn)
Definition effect.hpp:190
T value_type
Definition computed.hpp:88
bool recompute() override
Called by the Graph when an upstream has changed.
Definition computed.hpp:195
::aria::Subscription bind(std::function< void(const T &)> fn)
Definition effect.hpp:179
Computed & operator=(const Computed &)=delete
const T & get_ref() const
Read by const reference.
Definition computed.hpp:153
Computed(Computed &&)=delete
bool pull(Node &n)
Force-evaluate a single node if it is Dirty / MaybeDirty.
Definition graph.inl:342
Common base for every node participating in the reactive graph.
Definition node.hpp:136
void bump_version_() noexcept
Definition node.hpp:258
static Graph & graph() noexcept
Returns the process-wide singleton Graph this node belongs to.
Definition graph.inl:30
void set_depth(std::uint32_t d) noexcept
Definition node.hpp:209
Node(NodeKind kind) noexcept
Definition node.hpp:144
void for_each_source(F &&f) const
Iterate the source list (used by a Derivation in pull() to compare each upstream's current version ag...
Definition node.hpp:243
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
NodeState state() const noexcept
Definition node.hpp:154
void clear_sources() noexcept
Drop every upstream edge.
Definition graph.inl:107
RAII: push a tracker on construction, pop on destruction.
Definition graph.hpp:305
Per-recompute tracking context for a single Derivation evaluation.
Definition graph.hpp:103
std::vector< detail::NodeHandle > Buffer
Definition graph.hpp:105
const std::vector< detail::NodeHandle > & reads() const noexcept
Definition graph.hpp:130
Type that supports == and != (required for change detection).
Definition concepts.hpp:30
Definition computed.hpp:60
@ Clean
Definition node.hpp:103
NodeKind
Definition node.hpp:87
@ Derivation
Definition node.hpp:89
requires ::aria::ReactiveNode< Reactive > void dep(Reactive &r)
Definition computed.hpp:76
TrackingContext * current_tracker() noexcept
Returns the current tracker of the global graph.
Definition graph.hpp:300
Definition signal.hpp:12
Definition validation_key.hpp:110
A single dependency edge: (upstream source) -> (downstream observer).
Definition node.hpp:113