Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
property_ops.hpp
Go to the documentation of this file.
1#pragma once
2
3// Reactive operators on Property<T> — inspired by ReactiveX (RxCpp).
4//
5// Each operator returns `std::shared_ptr<Property<T>>` ("downstream") that
6// reflects a transformed view of the source. The chain auto-cleans when
7// the last reference is dropped: the upstream Subscription, the per-op
8// state and the downstream Property are bundled into a single heap node
9// owned by the returned shared_ptr (via aliasing constructor), so there
10// are no raw `new`/`delete`, no two-phase weak_ptr patching, and the
11// teardown order is defined by member declaration order.
12//
13// Property<std::string> raw_query{""};
14// auto debounced = debounce(raw_query, 300ms, ui_dispatcher);
15// auto distinct = distinct_until_changed(*debounced);
16// auto sub = distinct->bind([](const std::string& q) { do_search(q); });
17//
18// ── Why shared_ptr<Property<T>> as the public return?
19// The operator needs to attach a subscription that writes back into the
20// downstream Property. Returning by value would rely on RVO; when RVO
21// kicks out (debug builds, vector placement, etc.) the Property is
22// moved and the subscription's captured pointer dangles. Wrapping in
23// shared_ptr eliminates that whole class of lifetime hazards.
24//
25// The "delay" family (debounce / throttle) needs a place to schedule
26// timers. To keep core dependency-free we accept a tiny
27// `IDelayedScheduler` interface that ANY of the following can implement:
28//
29// - SimpleDispatcher (real wall-clock, runtime module)
30// - VirtualTimeExecutor (deterministic, async module)
31// - any custom timer that delivers callbacks on the graph owner thread
32//
33// All operators, their source/output properties, and timer delivery use the
34// graph owner thread. A worker timer must marshal its callback to that thread.
35// Keep the scheduler alive until the returned chain is released; pending timer
36// callbacks hold only weak references and safely expire with the chain.
37
38#include "aria/abi/export.hpp"
39#include "aria/property.hpp"
40#include "aria/scheduler.hpp"
41#include "aria/subscription.hpp"
42
43#include <chrono>
44#include <cstdint>
45#include <functional>
46#include <memory>
47#include <type_traits>
48#include <utility>
49
50namespace aria {
51
63public:
65
69 virtual void post_after(std::chrono::milliseconds delay,
70 std::function<void()> fn) = 0;
71
72 // ── IScheduler bridge ────────────────────────────────────────────
73 [[nodiscard]] SchedulerCaps caps() const noexcept override {
75 }
76 void schedule(std::function<void()> fn) override {
77 post_after(std::chrono::milliseconds{0}, std::move(fn));
78 }
79 void schedule_after(std::chrono::milliseconds delay,
80 std::function<void()> fn) override {
81 post_after(delay, std::move(fn));
82 }
83};
84
85namespace detail {
86
87// ─────────────────────────────────────────────────────────────────────────
88// ChainedNode<T, State>
89// The heap-allocated bundle that owns one operator's downstream Property,
90// its per-op State, and the upstream Subscription. Constructed in two
91// phases (Property + State first, upstream wired afterwards) because the
92// upstream callback must be able to weak-reference the very node that
93// holds the Subscription.
94//
95// Member-order matters: destruction runs bottom-up, so the upstream
96// Subscription is detached BEFORE the State and Property are torn down.
97// That guarantees no late callback can race with destruction.
98// ─────────────────────────────────────────────────────────────────────────
99template<PropertyValue T, class State>
100struct ChainedNode {
101 // 1. Per-operator state (gen counters, last value, accumulator, ...).
102 State state;
103
104 // 2. Downstream Property — the value users observe.
105 Property<T> property;
106
107 // 3. Upstream subscription — wired LAST (after the node is shared_ptr-
108 // managed) and torn down FIRST. The default-constructed subscription
109 // is detached, so a partially-constructed node is safe even on
110 // exceptions in the wiring step.
111 Subscription upstream;
112
113 // In-place construct State from `state_args...`, then construct
114 // Property<T> from `initial`. Using a tag dispatch (instead of a
115 // forwarding ctor) lets us support States that are neither copyable
116 // nor movable (e.g. those holding std::atomic members).
117 template<class... StateArgs>
118 ChainedNode(std::in_place_t, T initial, StateArgs&&... state_args)
119 : state{std::forward<StateArgs>(state_args)...}
120 , property(std::move(initial)) {}
121};
122
126template<PropertyValue T, class State>
127[[nodiscard]] inline std::shared_ptr<Property<T>>
128expose_property(std::shared_ptr<ChainedNode<T, State>> node) noexcept {
129 Property<T>* p = &node->property;
130 return std::shared_ptr<Property<T>>(std::move(node), p);
131}
132
133} // namespace detail
134
135// ════════════════════════════════════════════════════════════════════════════
136// distinct_until_changed
137// Forwards values from `source`, but suppresses notifications when the
138// new value equals the previous one.
139// ════════════════════════════════════════════════════════════════════════════
140template<PropertyValue T>
141[[nodiscard]] std::shared_ptr<Property<T>>
143 struct State { T last; };
144
145 auto node = std::make_shared<detail::ChainedNode<T, State>>(
146 std::in_place, source.get(), source.get());
147
148 std::weak_ptr<detail::ChainedNode<T, State>> weak = node;
149 node->upstream = source.on_changed([weak](const T& v) {
150 if (auto n = weak.lock()) {
151 if (v == n->state.last) return;
152 n->state.last = v;
153 n->property.set(v);
154 }
155 });
156
157 return detail::expose_property(std::move(node));
158}
159
160// ════════════════════════════════════════════════════════════════════════════
161// debounce
162// Emit only when the source has been quiet for `quiet` duration.
163// ════════════════════════════════════════════════════════════════════════════
164template<PropertyValue T>
165[[nodiscard]] std::shared_ptr<Property<T>>
167 std::chrono::milliseconds quiet,
168 IDelayedScheduler& timer) {
169 struct State {
170 std::uint64_t gen = 0;
171 T pending;
172
173 explicit State(T initial) : pending(std::move(initial)) {}
174 };
175
176 auto node = std::make_shared<detail::ChainedNode<T, State>>(
177 std::in_place, source.get(), source.get());
178
179 std::weak_ptr<detail::ChainedNode<T, State>> weak = node;
180 node->upstream = source.on_changed(
181 [weak, &timer, quiet](const T& v) {
182 auto n = weak.lock();
183 if (!n) return;
184 n->state.pending = v;
185 const auto my_gen = ++n->state.gen;
186 timer.post_after(quiet, [weak, my_gen]() {
188 if (auto nn = weak.lock()) {
189 if (nn->state.gen != my_gen) return;
190 nn->property.set(nn->state.pending);
191 }
192 });
193 });
194
195 return detail::expose_property(std::move(node));
196}
197
198// ════════════════════════════════════════════════════════════════════════════
199// throttle (leading edge)
200// ════════════════════════════════════════════════════════════════════════════
201template<PropertyValue T>
202[[nodiscard]] std::shared_ptr<Property<T>>
204 std::chrono::milliseconds cooldown,
205 IDelayedScheduler& timer) {
206 struct State {
207 bool blocked = false;
208 };
209
210 auto node = std::make_shared<detail::ChainedNode<T, State>>(
211 std::in_place, source.get());
212
213 std::weak_ptr<detail::ChainedNode<T, State>> weak = node;
214 node->upstream = source.on_changed(
215 [weak, &timer, cooldown](const T& v) {
216 auto n = weak.lock();
217 if (!n) return;
218 if (n->state.blocked) return;
219 n->state.blocked = true;
220 try {
221 n->property.set(v);
222 timer.post_after(cooldown, [weak]() {
224 if (auto nn = weak.lock()) nn->state.blocked = false;
225 });
226 } catch (...) {
227 // A failed output update or rejected timer must not leave
228 // the operator in a cooldown that can never expire.
229 n->state.blocked = false;
230 throw;
231 }
232 });
233
234 return detail::expose_property(std::move(node));
235}
236
237// ════════════════════════════════════════════════════════════════════════════
238// scan (a.k.a. fold / accumulate)
239// ════════════════════════════════════════════════════════════════════════════
240template<PropertyValue T, PropertyValue Acc, typename Reducer>
241[[nodiscard]] std::shared_ptr<Property<Acc>>
242scan(Property<T>& source, Acc seed, Reducer reduce) {
243 struct State {
244 Acc acc;
245 Reducer reducer;
246 };
247
248 Acc initial = seed;
249 auto node = std::make_shared<detail::ChainedNode<Acc, State>>(
250 std::in_place, std::move(initial), std::move(seed), std::move(reduce));
251
252 std::weak_ptr<detail::ChainedNode<Acc, State>> weak = node;
253 node->upstream = source.on_changed([weak](const T& v) {
254 if (auto n = weak.lock()) {
255 n->state.acc = std::invoke(n->state.reducer, n->state.acc, v);
256 n->property.set(n->state.acc);
257 }
258 });
259
260 return detail::expose_property(std::move(node));
261}
262
263// ════════════════════════════════════════════════════════════════════════════
264// combine_latest
265// Combine the latest values of two source Properties through a binary
266// function into a derived Property. Emits whenever EITHER source changes,
267// carrying the most recent value of the other.
268//
269// Property<int> a{1};
270// Property<int> b{2};
271// auto sum = combine_latest(a, b, [](int x, int y){ return x + y; });
272// // *sum == 3; a = 10 → *sum == 12; b = 5 → *sum == 15
273//
274// Note: for the common case where the combiner only READS reactive
275// sources, `Computed<R>([&]{ return f(a.get(), b.get()); })` is the more
276// idiomatic, glitch-free path (it participates in the dependency graph).
277// `combine_latest` is provided for parity with Rx and for combining
278// sources that are NOT both reactive-graph nodes, or when a detached
279// shared_ptr<Property<R>> handle (rather than a graph Computed) is wanted.
280// ════════════════════════════════════════════════════════════════════════════
281template<PropertyValue A, PropertyValue B, typename Combiner>
282[[nodiscard]] auto
283combine_latest(Property<A>& a, Property<B>& b, Combiner combine)
284 -> std::shared_ptr<Property<std::invoke_result_t<Combiner&, const A&, const B&>>> {
285 using R = std::invoke_result_t<Combiner&, const A&, const B&>;
286 struct State {
287 A last_a;
288 B last_b;
289 Combiner fn;
290 // Second upstream subscription (source `b`). The node's own
291 // `upstream` member holds source `a`; this one holds `b`. Both are
292 // torn down when the node dies (State is destroyed after `upstream`,
293 // which is fine — both callbacks are weak-guarded against the node).
294 Subscription b_sub;
295 };
296
297 auto last_a = a.get();
298 auto last_b = b.get();
299 // Invoke before moving the callable: function-argument evaluation order
300 // must not decide whether the initial call sees a moved-from object.
301 R initial = std::invoke(combine, last_a, last_b);
302 auto node = std::make_shared<detail::ChainedNode<R, State>>(
303 std::in_place, std::move(initial), std::move(last_a), std::move(last_b),
304 std::move(combine), Subscription{});
305
306 std::weak_ptr<detail::ChainedNode<R, State>> weak = node;
307 node->upstream = a.on_changed([weak](const A& v) {
308 if (auto n = weak.lock()) {
309 n->state.last_a = v;
310 n->property.set(std::invoke(n->state.fn, n->state.last_a, n->state.last_b));
311 }
312 });
313 node->state.b_sub = b.on_changed([weak](const B& v) {
314 if (auto n = weak.lock()) {
315 n->state.last_b = v;
316 n->property.set(std::invoke(n->state.fn, n->state.last_a, n->state.last_b));
317 }
318 });
319
320 return detail::expose_property(std::move(node));
321}
322
323} // namespace aria
Tiny interface — anything that can post a function to run after a delay.
Definition property_ops.hpp:62
void schedule(std::function< void()> fn) override
Submit fn for execution "soon". Defines Caps::Post.
Definition property_ops.hpp:76
virtual void post_after(std::chrono::milliseconds delay, std::function< void()> fn)=0
Legacy / canonical timer entry point.
void schedule_after(std::chrono::milliseconds delay, std::function< void()> fn) override
Submit fn for execution after delay.
Definition property_ops.hpp:79
SchedulerCaps caps() const noexcept override
Capability bitmask.
Definition property_ops.hpp:73
~IDelayedScheduler() override
Definition scheduler.hpp:152
RAII handle to a single subscription.
Definition subscription.hpp:44
void assert_on_graph_thread() const noexcept
Definition graph.hpp:155
static Graph & graph() noexcept
Returns the process-wide singleton Graph this node belongs to.
Definition graph.inl:30
Definition property.hpp:103
T get() const
Auto-tracked read.
Definition property.hpp:138
::aria::Subscription on_changed(std::function< void(const T &)> fn)
Run fn(new_value) every time the value changes.
Definition property.hpp:200
#define ARIA_ABI_API
Definition export.hpp:21
Definition signal.hpp:12
std::shared_ptr< Property< T > > distinct_until_changed(Property< T > &source)
Definition property_ops.hpp:142
std::shared_ptr< Property< T > > throttle(Property< T > &source, std::chrono::milliseconds cooldown, IDelayedScheduler &timer)
Definition property_ops.hpp:203
auto combine_latest(Property< A > &a, Property< B > &b, Combiner combine) -> std::shared_ptr< Property< std::invoke_result_t< Combiner &, const A &, const B & > > >
Definition property_ops.hpp:283
std::shared_ptr< Property< Acc > > scan(Property< T > &source, Acc seed, Reducer reduce)
Definition property_ops.hpp:242
SchedulerCaps
Definition scheduler.hpp:83
@ Post
Can submit "fire now" work.
Definition scheduler.hpp:89
@ Delay
Can submit work after a wall-clock or virtual-time delay.
Definition scheduler.hpp:93
std::shared_ptr< Property< T > > debounce(Property< T > &source, std::chrono::milliseconds quiet, IDelayedScheduler &timer)
Definition property_ops.hpp:166
Definition validation_key.hpp:110