Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
distinct_list.hpp
Go to the documentation of this file.
1// ============================================================================
2// aria/derived/distinct_list.hpp
3// ----------------------------------------------------------------------------
4// `DistinctList<T, Key>` -- a derived list that drops duplicates of
5// the source. Joins the family of derived collections (FilteredList
6// / SortedList / MappedList) and follows the same incremental
7// contract spelled out in `docs/list-diff-contract.md` LD-2 / LD-7.
8//
9// Semantics (PD-N IDs, Pinned Distinct):
10//
11// PD-1 (canonical key). Each source item is hashed/compared by
12// `Key key_of(const T&)`. Default Key = T -> identity, requires
13// T to be hashable + equality-comparable.
14//
15// PD-2 (first-appearance, source-ordered). When multiple source
16// slots share a key, the derived list keeps the FIRST
17// occurrence (smallest source index at the moment the key
18// enters the visible set) and hides the rest. The visible
19// order is the source order of those representatives. If the
20// source inserts a new-key item between two existing source
21// positions p_left < p_right, the new derived slot lands
22// between the derived slots that mirror p_left and p_right
23// (not appended to the end). Mirrors `std::ranges::unique`
24// on a stable, source-ordered view.
25//
26// PD-3 (incremental events, complexity envelope). Every source
27// mutation maps to AT MOST one derived event (Insert / Remove
28// / Replace / ItemChanged / Reset). Hidden-duplicate Insert /
29// Remove are silent. The implementation is O(N_visible) per
30// derived event in the worst case (we maintain visible
31// slot_id -> derived_pos via a sorted vector of slot_ids;
32// binary search + linear shift on the slot_id vector). For
33// the common append-at-end case both halves degenerate to
34// amortised O(1). This is the same complexity envelope as
35// FilteredList / SortedList per LD-2.
36//
37// PD-4 (key-changing ItemChanged). When T's `on_changed` fires
38// AND the new key differs from the old, this manifests as
39// Remove (of the old representative if it was visible) plus
40// Insert (of the new representative if the new key was
41// previously hidden), each obeying PD-2 / PD-3.
42//
43// PD-5 (lifetime). Destroying the source while DistinctList is
44// still alive is safe (weak_ptr capture in the source
45// observer; final emission is dropped).
46// ============================================================================
47#pragma once
48
50#include "aria/list_source.hpp"
52#include "aria/subscription.hpp"
53#include "aria/detail/list_signal_mixin.hpp"
54
55#include <algorithm>
56#include <cstddef>
57#include <cstdint>
58#include <memory>
59#include <mutex>
60#include <shared_mutex>
61#include <unordered_map>
62#include <utility>
63#include <vector>
64
65namespace aria {
66
67template<typename T, typename Key = T,
68 typename Source = ObservableList<T>>
71 : public detail::ListSignalMixin<DistinctList<T, Key, Source>, T> {
72 friend detail::ListSignalMixin<DistinctList<T, Key, Source>, T>;
73
74public:
75 using value_type = T;
77 using KeyOf = aria::inplace_function<Key(const T&), 32>;
78 using Signal = detail::ListSignal<T>;
79
83 DistinctList(std::shared_ptr<Source> source,
84 KeyOf key_of = default_key_of_())
85 : source_(std::move(source)),
86 signal_(std::make_shared<Signal>()),
87 state_(std::make_shared<SharedState>())
88 {
89 state_->key_of = std::move(key_of);
90 rebuild_initial_();
91
92 std::weak_ptr<SharedState> weak_state = state_;
93 std::weak_ptr<Signal> weak_signal = signal_;
94 std::weak_ptr<Source> weak_source{source_};
95 source_sub_ = source_->observe(
96 [weak_state, weak_signal, weak_source](const ListChange<T>& ch) {
97 auto st = weak_state.lock();
98 auto sig = weak_signal.lock();
99 auto src = weak_source.lock();
100 if (!st || !sig || !src) return;
101 handle_source_change_(*st, *sig, *src, ch);
102 });
103 }
104
105 ~DistinctList() = default;
106
107 DistinctList(const DistinctList&) = delete;
109
110 // ── Read surface ──────────────────────────────────────────────────
111 [[nodiscard]] std::size_t size() const {
112 std::shared_lock lk(state_->m);
113 return state_->visible_slots.size();
114 }
115
116 [[nodiscard]] bool empty() const { return size() == 0; }
117
118 [[nodiscard]] std::shared_ptr<T> at(std::size_t derived_pos) const {
119 std::shared_lock lk(state_->m);
120 const auto sid = state_->visible_slots.at(derived_pos);
121 return state_->slots.at(sid).rep;
122 }
123
124 [[nodiscard]] std::vector<std::shared_ptr<T>> snapshot() const {
125 std::shared_lock lk(state_->m);
126 std::vector<std::shared_ptr<T>> out;
127 out.reserve(state_->visible_slots.size());
128 for (auto sid : state_->visible_slots) {
129 out.push_back(state_->slots.at(sid).rep);
130 }
131 return out;
132 }
133
134private:
135 using SlotId = std::uint64_t;
136
137 // A Slot represents one Key currently or formerly visible in the
138 // derived list. Its `slot_id` is allocated when the key is first
139 // promoted into the visible set and is *order-preserving*: bigger
140 // slot_ids correspond to later derived positions. We rebalance
141 // slot_ids only when the source inserts a new-key item *between*
142 // two existing visible representatives -- see allocate_slot_id_().
143 struct Slot {
144 Key key;
145 std::shared_ptr<T> rep;
146 // Hidden-duplicate bag, in source-arrival order. Front is
147 // the next promotion candidate when rep is removed.
148 std::vector<std::shared_ptr<T>> dups;
149 };
150
151 struct SharedState {
152 mutable std::shared_mutex m;
153 KeyOf key_of;
154
155 // Slots indexed by SlotId. We never reuse slot_ids for
156 // different keys, but a slot is erased once its rep is
157 // removed AND its dup bag is empty.
158 std::unordered_map<SlotId, Slot> slots;
159
160 // Visible-representative slot_ids, sorted ascending. The
161 // index in this vector IS the derived_pos. Maintained by
162 // binary insert / erase. Worst case O(N_visible).
163 std::vector<SlotId> visible_slots;
164
165 // Key -> slot_id (only entries whose key currently has *any*
166 // backing item -- representative or hidden dup). Erased
167 // together with the slot.
168 std::unordered_map<Key, SlotId> key_to_slot;
169
170 // Item* -> slot_id (every source item we currently track,
171 // representative or hidden duplicate). Erased on Remove.
172 std::unordered_map<const T*, SlotId> item_to_slot;
173
174 // Item* -> the key that item currently maps to. Recorded on
175 // every Insert / ItemChanged so that Remove can recover the
176 // old key without re-running key_of on a possibly-mutated
177 // value.
178 std::unordered_map<const T*, Key> item_key;
179
180 SlotId next_slot_id{1};
181 std::vector<std::shared_ptr<T>> source_items;
182 };
183
184 std::shared_ptr<Source> source_;
185 std::shared_ptr<Signal> signal_;
186 std::shared_ptr<SharedState> state_;
187 Subscription source_sub_;
188
189 static KeyOf default_key_of_() {
190 return [](const T& v) -> Key {
191 if constexpr (std::is_same_v<Key, T>) {
192 return v;
193 } else {
194 static_assert(std::is_same_v<Key, T>,
195 "DistinctList<T, Key>: default key extractor needs Key == T. "
196 "Provide a custom KeyOf for differing Key.");
197 return Key{};
198 }
199 };
200 }
201
206 void rebuild_initial_() {
207 rebuild_(*state_, source_->snapshot());
208 }
209
210 static void rebuild_(SharedState& st, std::vector<std::shared_ptr<T>> snap) {
211 std::unique_lock lk(st.m);
212 st.slots.clear();
213 st.visible_slots.clear();
214 st.key_to_slot.clear();
215 st.item_key.clear();
216 st.item_to_slot.clear();
217 st.next_slot_id = 1;
218 st.source_items = std::move(snap);
219 st.slots.reserve(st.source_items.size());
220 st.visible_slots.reserve(st.source_items.size());
221 st.key_to_slot.reserve(st.source_items.size());
222 st.item_key.reserve(st.source_items.size());
223 st.item_to_slot.reserve(st.source_items.size());
224 for (const auto& sp_ : st.source_items) {
225 const Key k = st.key_of(*sp_);
226 st.item_key[sp_.get()] = k;
227 auto it = st.key_to_slot.find(k);
228 if (it == st.key_to_slot.end()) {
229 const SlotId sid = st.next_slot_id++;
230 st.key_to_slot.emplace(k, sid);
231 st.slots.emplace(sid, Slot{k, sp_, {}});
232 st.visible_slots.push_back(sid);
233 st.item_to_slot[sp_.get()] = sid;
234 } else {
235 st.slots[it->second].dups.push_back(sp_);
236 st.item_to_slot[sp_.get()] = it->second;
237 }
238 }
239 }
240
254 static std::size_t derived_pos_for_new_rep_(SharedState& st,
255 std::size_t source_idx) {
256 // Count: how many visible representatives sit at source
257 // positions strictly before source_idx? That count IS the
258 // derived position where the new representative belongs.
259 std::unordered_map<SlotId, bool> counted;
260 std::size_t count = 0;
261 const std::size_t bound =
262 std::min<std::size_t>(source_idx, st.source_items.size());
263 for (std::size_t i = 0; i < bound; ++i) {
264 auto sp_ = st.source_items[i];
265 auto it = st.item_to_slot.find(sp_.get());
266 if (it == st.item_to_slot.end()) continue;
267 const SlotId sid = it->second;
268 // Is this item the representative? (A representative's
269 // slot.rep.get() == item.) Hidden duplicates point to
270 // the same slot but slot.rep != them.
271 auto sl_it = st.slots.find(sid);
272 if (sl_it == st.slots.end()) continue;
273 if (sl_it->second.rep.get() == sp_.get() && counted.emplace(sid, true).second) ++count;
274 }
275 return count;
276 }
277
280 static std::size_t derived_pos_of_slot_(const SharedState& st, SlotId sid) {
281 auto it = std::lower_bound(st.visible_slots.begin(),
282 st.visible_slots.end(), sid);
283 if (it == st.visible_slots.end() || *it != sid) {
284 return st.visible_slots.size();
285 }
286 return static_cast<std::size_t>(it - st.visible_slots.begin());
287 }
288
296 static SlotId allocate_ordered_slot_id_(SharedState& st,
297 std::size_t derived_pos) {
298 const auto sz = st.visible_slots.size();
299 if (derived_pos == sz) {
300 // Append at the end.
301 const SlotId sid = st.next_slot_id;
302 st.next_slot_id += kSlotIdStep;
303 return sid;
304 }
305 if (derived_pos == 0) {
306 // Prepend at the front. Use the half-way point between 0
307 // and the current first visible slot id.
308 const SlotId right = st.visible_slots.front();
309 if (right > 1) {
310 return right / 2;
311 }
312 // Right-side is 1; rebalance the entire visible vector.
313 return rebalance_and_insert_(st, derived_pos);
314 }
315 // Insert strictly between two existing visible slot ids.
316 const SlotId left = st.visible_slots[derived_pos - 1];
317 const SlotId right = st.visible_slots[derived_pos];
318 if (right - left >= 2) {
319 return left + (right - left) / 2;
320 }
321 return rebalance_and_insert_(st, derived_pos);
322 }
323
330 static SlotId rebalance_and_insert_(SharedState& st,
331 std::size_t derived_pos) {
332 const std::size_t sz = st.visible_slots.size();
333 std::vector<SlotId> old_ids = st.visible_slots;
334 std::unordered_map<SlotId, Slot> new_slots;
335 new_slots.reserve(st.slots.size());
336 SlotId cursor = kSlotIdStep;
337 // Re-key visible slots first.
338 std::unordered_map<SlotId, SlotId> remap;
339 remap.reserve(sz);
340 for (std::size_t i = 0; i < sz; ++i) {
341 if (i == derived_pos) cursor += kSlotIdStep;
342 const SlotId old_id = old_ids[i];
343 const SlotId new_id = cursor;
344 remap[old_id] = new_id;
345 cursor += kSlotIdStep;
346 }
347 // Re-key any non-visible slots (slot whose representative
348 // has been removed but whose dup queue is still non-empty
349 // -- these never appear in visible_slots, so they keep
350 // their old ids; nothing to do).
351 for (auto& [old_id, slot] : st.slots) {
352 auto rm_it = remap.find(old_id);
353 if (rm_it == remap.end()) {
354 new_slots.emplace(old_id, std::move(slot));
355 } else {
356 new_slots.emplace(rm_it->second, std::move(slot));
357 }
358 }
359 st.slots = std::move(new_slots);
360 // Rewrite key_to_slot.
361 for (auto& [k, sid] : st.key_to_slot) {
362 auto rm_it = remap.find(sid);
363 if (rm_it != remap.end()) sid = rm_it->second;
364 }
365 // Rewrite item_to_slot.
366 for (auto& [p, sid] : st.item_to_slot) {
367 auto rm_it = remap.find(sid);
368 if (rm_it != remap.end()) sid = rm_it->second;
369 }
370 // Rewrite visible_slots.
371 for (std::size_t i = 0; i < sz; ++i) {
372 st.visible_slots[i] = remap[old_ids[i]];
373 }
374 st.next_slot_id = cursor + kSlotIdStep;
375 // Now there is a kSlotIdStep-wide gap at derived_pos.
376 // Reserve the midpoint.
377 if (derived_pos == 0) {
378 return st.visible_slots.empty() ? cursor / 2
379 : st.visible_slots.front() / 2;
380 }
381 const SlotId left = st.visible_slots[derived_pos - 1];
382 const SlotId right = derived_pos < sz ? st.visible_slots[derived_pos]
383 : cursor + kSlotIdStep;
384 return left + (right - left) / 2;
385 }
386
387 static constexpr SlotId kSlotIdStep = 1024;
388
389 static void handle_source_change_(SharedState& st, Signal& sig, Source&, ListChange<T> ch) {
390 std::shared_ptr<T> previous;
391 {
392 std::unique_lock lk(st.m);
393 auto& items = st.source_items;
394 const auto pos = static_cast<std::ptrdiff_t>(ch.index);
395 switch (ch.kind) {
396 case ListChangeKind::Insert: items.insert(items.begin() + pos, ch.item); break;
398 previous = items.at(ch.index);
399 items.erase(items.begin() + pos);
400 break;
402 previous = items.at(ch.index);
403 items[ch.index] = ch.item;
404 break;
406 auto moved = items.at(ch.from_index);
407 items.erase(items.begin() + static_cast<std::ptrdiff_t>(ch.from_index));
408 items.insert(items.begin() + pos, std::move(moved));
409 return;
410 }
411 default: break;
412 }
413 }
414 switch (ch.kind) {
415 case ListChangeKind::Insert: handle_insert_(st, sig, ch, ch.item); return;
417 ch.item = previous;
418 handle_remove_(st, sig, ch);
419 return;
421 ListChange<T> removed{ListChangeKind::Remove, ch.index, previous, 0};
422 handle_remove_(st, sig, removed);
423 handle_insert_(st, sig, ch, ch.item);
424 return;
425 }
426 case ListChangeKind::ItemChanged: handle_item_changed_(st, sig, ch); return;
428 rebuild_(st, *ch.snapshot);
429 sig.emit(reset_event_(st));
430 return;
431 case ListChangeKind::Move: return;
432 }
433 }
434
435 static void handle_insert_(SharedState& st, Signal& sig,
436 const ListChange<T>& ch,
437 std::shared_ptr<T> sp_) {
438 const Key k = st.key_of(*sp_);
439
440 std::optional<std::size_t> emit_at;
441 SlotId emit_sid = 0;
442 {
443 std::unique_lock lk(st.m);
444 st.item_key[sp_.get()] = k;
445 auto it = st.key_to_slot.find(k);
446 if (it == st.key_to_slot.end()) {
447 // PD-2: derived position is the count of visible
448 // representatives at source positions strictly
449 // before ch.index. Fast path -- if ch.index is at
450 // the source tail, the new derived slot is at the
451 // visible tail too (no need to walk the source).
452 std::size_t derived_pos;
453 if (ch.index >= st.source_items.size() - 1) {
454 derived_pos = st.visible_slots.size();
455 } else if (ch.index == 0) {
456 derived_pos = 0;
457 } else {
458 derived_pos =
459 derived_pos_for_new_rep_(st, ch.index);
460 }
461 const SlotId sid =
462 allocate_ordered_slot_id_(st, derived_pos);
463 st.key_to_slot.emplace(k, sid);
464 st.slots.emplace(sid, Slot{k, sp_, {}});
465 st.item_to_slot[sp_.get()] = sid;
466 // Insert sid into visible_slots at derived_pos
467 // (sorted ordering is preserved by construction).
468 st.visible_slots.insert(
469 st.visible_slots.begin()
470 + static_cast<std::ptrdiff_t>(derived_pos),
471 sid);
472 emit_at = derived_pos;
473 emit_sid = sid;
474 } else {
475 // Hidden duplicate -- attach to the slot's bag.
476 st.slots[it->second].dups.push_back(sp_);
477 st.item_to_slot[sp_.get()] = it->second;
478 }
479 }
480 if (emit_at.has_value()) {
481 sig.emit(ListChange<T>{ListChangeKind::Insert, *emit_at,
482 sp_, 0});
483 (void)emit_sid;
484 }
485 }
486
487 static void handle_remove_(SharedState& st, Signal& sig,
488 const ListChange<T>& ch) {
489 if (ch.item == nullptr) return;
490 std::optional<std::size_t> emit_remove_at;
491 std::shared_ptr<T> removed_sp;
492 std::shared_ptr<T> promoted_sp;
493 std::optional<std::size_t> emit_replace_at;
494 {
495 std::unique_lock lk(st.m);
496 auto its_it = st.item_to_slot.find(ch.item.get());
497 if (its_it == st.item_to_slot.end()) return;
498 const SlotId sid = its_it->second;
499 const bool survives = std::any_of(st.source_items.begin(), st.source_items.end(),
500 [&](const auto& item) { return item.get() == ch.item.get(); });
501 if (!survives) {
502 st.item_to_slot.erase(its_it);
503 st.item_key.erase(ch.item.get());
504 }
505
506 auto sl_it = st.slots.find(sid);
507 if (sl_it == st.slots.end()) return;
508 Slot& slot = sl_it->second;
509
510 if (slot.rep.get() != ch.item.get()) {
511 // Hidden duplicate -- drop it from the bag (linear
512 // in the bag's size, typically very small).
513 for (auto bi = slot.dups.begin(); bi != slot.dups.end(); ++bi) {
514 if (bi->get() == ch.item.get()) { slot.dups.erase(bi); break; }
515 }
516 return;
517 }
518
519 // Removed item WAS the representative.
520 removed_sp = slot.rep;
521 if (!slot.dups.empty()) {
522 // Promote -- same slot, new representative -> Replace.
523 promoted_sp = std::move(slot.dups.front());
524 slot.dups.erase(slot.dups.begin());
525 slot.rep = promoted_sp;
526 emit_replace_at = derived_pos_of_slot_(st, sid);
527 } else {
528 // No duplicate to promote -> erase the slot entirely.
529 const std::size_t derived_pos = derived_pos_of_slot_(st, sid);
530 if (derived_pos < st.visible_slots.size()) {
531 st.visible_slots.erase(
532 st.visible_slots.begin()
533 + static_cast<std::ptrdiff_t>(derived_pos));
534 }
535 st.key_to_slot.erase(slot.key);
536 st.slots.erase(sl_it);
537 emit_remove_at = derived_pos;
538 }
539 }
540 if (emit_remove_at.has_value()) {
541 sig.emit(ListChange<T>{ListChangeKind::Remove, *emit_remove_at,
542 removed_sp, 0});
543 }
544 if (emit_replace_at.has_value()) {
545 sig.emit(ListChange<T>{ListChangeKind::Replace, *emit_replace_at,
546 promoted_sp, 0});
547 }
548 }
549
550 static ListChange<T> reset_event_(const SharedState& st) {
551 std::shared_lock lock(st.m);
552 std::vector<std::shared_ptr<T>> items;
553 items.reserve(st.visible_slots.size());
554 for (const auto sid : st.visible_slots) items.push_back(st.slots.at(sid).rep);
555 return ListChange<T>::reset(std::move(items));
556 }
557
558 static void handle_item_changed_(SharedState& st, Signal& sig,
559 const ListChange<T>& ch) {
560 if (!ch.item) return;
561 std::shared_ptr<T> item;
562 std::vector<std::size_t> occurrences;
563 std::optional<std::size_t> visible;
564 bool key_changed;
565 {
566 std::shared_lock lk(st.m);
567 auto found = st.item_key.find(ch.item.get());
568 if (found == st.item_key.end()) return;
569 key_changed = found->second != st.key_of(*ch.item);
570 const auto sid = st.item_to_slot.at(ch.item.get());
571 const auto& slot = st.slots.at(sid);
572 if (slot.rep.get() == ch.item.get()) visible = derived_pos_of_slot_(st, sid);
573 for (std::size_t i = 0; i < st.source_items.size(); ++i) {
574 if (st.source_items[i].get() == ch.item.get()) {
575 item = st.source_items[i];
576 if (key_changed) occurrences.push_back(i);
577 }
578 }
579 }
580 if (!key_changed) {
581 if (visible) sig.emit(ListChange<T>{ListChangeKind::ItemChanged, *visible, item, 0});
582 return;
583 }
584 // Every occurrence refers to the same mutable object. Move all of
585 // them together; updating only the reported (last) source index
586 // leaves an old-key duplicate behind indefinitely.
587 for (const auto index : occurrences) {
588 handle_remove_(st, sig, ListChange<T>{ListChangeKind::Remove, index, item, 0});
589 }
590 {
591 std::unique_lock lk(st.m);
592 st.item_key.erase(item.get());
593 st.item_to_slot.erase(item.get());
594 }
595 for (const auto index : occurrences) {
596 handle_insert_(st, sig, ListChange<T>{ListChangeKind::Insert, index, item, 0}, item);
597 }
598 }
599
600};
601
602// ---------------------------------------------------------------------------
603// Factory helper — deduces the source type so pipelines stay readable.
604// See the note on `aria::filtered` in filtered_list.hpp.
605// ---------------------------------------------------------------------------
606template<typename Key,
607 typename Source,
608 typename KeyFn,
609 typename T = list_source_value_t<Source>>
611[[nodiscard]] std::shared_ptr<DistinctList<T, Key, Source>>
612distinct(std::shared_ptr<Source> source, KeyFn key_of) {
613 using Derived = DistinctList<T, Key, Source>;
614 return std::make_shared<Derived>(
615 std::move(source), typename Derived::KeyOf{std::move(key_of)});
616}
617
618} // namespace aria
Definition distinct_list.hpp:71
DistinctList(const DistinctList &)=delete
std::vector< std::shared_ptr< T > > snapshot() const
Definition distinct_list.hpp:124
DistinctList(std::shared_ptr< Source > source, KeyOf key_of=default_key_of_())
Construct a DistinctList.
Definition distinct_list.hpp:83
aria::inplace_function< Key(const T &), 32 > KeyOf
Owning, heap-free key extractor (capacity 32 bytes).
Definition distinct_list.hpp:77
DistinctList & operator=(const DistinctList &)=delete
bool empty() const
Definition distinct_list.hpp:116
std::shared_ptr< T > at(std::size_t derived_pos) const
Definition distinct_list.hpp:118
std::size_t size() const
Definition distinct_list.hpp:111
T value_type
Definition distinct_list.hpp:75
~DistinctList()=default
detail::ListSignal< T > Signal
Definition distinct_list.hpp:78
Observable sequence of owning element handles.
Definition observable_list.hpp:40
Definition inplace_function.hpp:106
Definition list_source.hpp:80
@ Source
Definition node.hpp:88
Definition signal.hpp:12
typename detail::list_source_value_impl< std::remove_cvref_t< L > >::type list_source_value_t
Element type for a list source.
Definition list_source.hpp:76
@ Replace
Definition list_change.hpp:10
@ Remove
Definition list_change.hpp:10
@ Reset
Definition list_change.hpp:10
@ Move
Definition list_change.hpp:10
@ ItemChanged
Definition list_change.hpp:10
@ Insert
Definition list_change.hpp:10
std::shared_ptr< DistinctList< T, Key, Source > > distinct(std::shared_ptr< Source > source, KeyFn key_of)
Definition distinct_list.hpp:612
Definition validation_key.hpp:110
An owning event in a sequential list edit stream.
Definition list_change.hpp:16
static ListChange reset(Snapshot items)
Definition list_change.hpp:24