diff --git a/R3.DynamicData/Cache/SourceCache.cs b/R3.DynamicData/Cache/SourceCache.cs index a065115..b281909 100644 --- a/R3.DynamicData/Cache/SourceCache.cs +++ b/R3.DynamicData/Cache/SourceCache.cs @@ -72,10 +72,11 @@ public IEnumerable> KeyValues /// Initializes a new instance of the class. /// /// The function to extract the key from an object. - public SourceCache(Func keySelector) + /// Optional initial capacity for the internal dictionary. + public SourceCache(Func keySelector, int initialCapacity = 0) { _keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector)); - _data = new Dictionary(); + _data = initialCapacity > 0 ? new Dictionary(initialCapacity) : new Dictionary(); _changes = new Subject>(); _countChanged = new Subject(); } diff --git a/R3.DynamicData/List/ObservableListAggregates.cs b/R3.DynamicData/List/ObservableListAggregates.cs index 24163a0..1bb732d 100644 --- a/R3.DynamicData/List/ObservableListAggregates.cs +++ b/R3.DynamicData/List/ObservableListAggregates.cs @@ -145,431 +145,446 @@ public static Observable Max(this Observable(observer => - { - // Track per-item value to support Refresh. - var itemValues = new Dictionary(); - var valueCounts = new Dictionary(); - bool hasValue = false; - TProperty currentMax = default; - - void Increment(TSource item) - { - var value = selector(item); - itemValues[item] = value; - if (valueCounts.TryGetValue(value, out var count)) - { - valueCounts[value] = count + 1; - } - else - { - valueCounts[value] = 1; - } - - if (!hasValue || value.CompareTo(currentMax) > 0) - { - currentMax = value; - hasValue = true; - } - } - - void Decrement(TSource item, TProperty value) - { - if (!valueCounts.TryGetValue(value, out var count)) - { - return; - } - - if (count == 1) - { - valueCounts.Remove(value); - - // If we removed the current max, recalc. - if (hasValue && value.CompareTo(currentMax) == 0) - { - RecalculateMax(); - } - } - else - { - valueCounts[value] = count - 1; - } - - itemValues.Remove(item); - if (valueCounts.Count == 0) - { - hasValue = false; - currentMax = default; - } - } - - void RecalculateMax() - { - if (valueCounts.Count == 0) - { - hasValue = false; - currentMax = default; - return; - } - - // Linear scan of distinct values. - var max = default(TProperty); - bool first = true; - foreach (var kvp in valueCounts.Keys) - { - if (first) - { - max = kvp; - first = false; - } - else if (kvp.CompareTo(max) > 0) - { - max = kvp; - } - } + return Observable.Create>( + new MaxState(source, selector), + static (observer, state) => state.Subscribe(observer)); + } - currentMax = max; - hasValue = true; - } + private sealed class MaxState + where TSource : notnull + where TProperty : struct, IComparable + { + private readonly Observable> _source; + private readonly Func _selector; + private readonly Dictionary _itemValues = new(); + private readonly Dictionary _valueCounts = new(); + private bool _hasValue; + private TProperty _currentMax; + + public MaxState(Observable> source, Func selector) + { + _source = source; + _selector = selector; + } - void Publish() - { - try - { - observer.OnNext(hasValue ? currentMax : default); - } - catch (Exception ex) - { - observer.OnErrorResume(ex); - } - } + public IDisposable Subscribe(Observer observer) + { + return _source.Subscribe( + (this, observer), + static (changes, tuple) => tuple.Item1.OnNext(changes, tuple.observer), + static (ex, tuple) => tuple.observer.OnErrorResume(ex), + static (result, tuple) => tuple.observer.OnCompleted(result)); + } - return source.Subscribe( - changes => + private void OnNext(IChangeSet changes, Observer observer) + { + foreach (var change in changes) { - foreach (var change in changes) + switch (change.Reason) { - switch (change.Reason) - { - case ListChangeReason.Add: - Increment(change.Item); - break; - case ListChangeReason.AddRange: - if (change.Range.Count > 0) - { - foreach (var i in change.Range) - { - Increment(i); - } - } - else + case ListChangeReason.Add: + Increment(change.Item); + break; + case ListChangeReason.AddRange: + if (change.Range.Count > 0) + { + foreach (var i in change.Range) { - Increment(change.Item); + Increment(i); } + } + else + { + Increment(change.Item); + } - break; + break; - case ListChangeReason.Remove: - if (itemValues.TryGetValue(change.Item, out var vRemove)) - { - Decrement(change.Item, vRemove); - } + case ListChangeReason.Remove: + if (_itemValues.TryGetValue(change.Item, out var vRemove)) + { + Decrement(change.Item, vRemove); + } - break; + break; - case ListChangeReason.RemoveRange: - if (change.Range.Count > 0) + case ListChangeReason.RemoveRange: + if (change.Range.Count > 0) + { + foreach (var i in change.Range) { - foreach (var i in change.Range) + if (_itemValues.TryGetValue(i, out var vR)) { - if (itemValues.TryGetValue(i, out var vR)) - { - Decrement(i, vR); - } + Decrement(i, vR); } } - else if (itemValues.TryGetValue(change.Item, out var vR2)) - { - Decrement(change.Item, vR2); - } + } + else if (_itemValues.TryGetValue(change.Item, out var vR2)) + { + Decrement(change.Item, vR2); + } - break; + break; - case ListChangeReason.Replace: - if (change.PreviousItem != null && itemValues.TryGetValue(change.PreviousItem, out var vPrev)) - { - Decrement(change.PreviousItem, vPrev); - } + case ListChangeReason.Replace: + if (change.PreviousItem != null && _itemValues.TryGetValue(change.PreviousItem, out var vPrev)) + { + Decrement(change.PreviousItem, vPrev); + } - Increment(change.Item); + Increment(change.Item); - break; + break; - case ListChangeReason.Refresh: - // Refresh generated by AutoRefresh does not carry an item. - // Re-evaluate all items to reflect potential value changes. - if (itemValues.Count > 0) + case ListChangeReason.Refresh: + // Refresh generated by AutoRefresh does not carry an item. + // Re-evaluate all items to reflect potential value changes. + if (_itemValues.Count > 0) + { + _valueCounts.Clear(); + _hasValue = false; + _currentMax = default; + var keys = _itemValues.Keys.ToList(); + foreach (var it in keys) { - valueCounts.Clear(); - hasValue = false; - currentMax = default; - var keys = itemValues.Keys.ToList(); - foreach (var it in keys) + var newVal2 = _selector(it); + _itemValues[it] = newVal2; + _valueCounts[newVal2] = _valueCounts.TryGetValue(newVal2, out var c) ? c + 1 : 1; + + if (!_hasValue || newVal2.CompareTo(_currentMax) > 0) { - var newVal2 = selector(it); - itemValues[it] = newVal2; - if (valueCounts.TryGetValue(newVal2, out var c)) - { - valueCounts[newVal2] = c + 1; - } - else - { - valueCounts[newVal2] = 1; - } - - if (!hasValue || newVal2.CompareTo(currentMax) > 0) - { - currentMax = newVal2; - hasValue = true; - } + _currentMax = newVal2; + _hasValue = true; } } + } - break; + break; - case ListChangeReason.Clear: - itemValues.Clear(); - valueCounts.Clear(); - hasValue = false; - currentMax = default; + case ListChangeReason.Clear: + _itemValues.Clear(); + _valueCounts.Clear(); + _hasValue = false; + _currentMax = default; - break; - } + break; } + } - Publish(); - }, observer.OnErrorResume, observer.OnCompleted); - }); - } + Publish(observer); + } - /// - /// Finds the minimum value of a property across all items in the list, reactively updating as items change. - /// - /// The type of items in the list. - /// The type of the property to compare (must be comparable). - /// The source observable list. - /// Function to select the property value from each item. - /// An observable that emits the current minimum value, or default if empty. - public static Observable Min(this Observable> source, Func selector) - where TSource : notnull - where TProperty : struct, IComparable - { - if (selector == null) + private void Increment(TSource item) { - throw new ArgumentNullException(nameof(selector)); + var value = _selector(item); + _itemValues[item] = value; + _valueCounts[value] = _valueCounts.TryGetValue(value, out var count) ? count + 1 : 1; + + if (!_hasValue || value.CompareTo(_currentMax) > 0) + { + _currentMax = value; + _hasValue = true; + } } - return Observable.Create(observer => + private void Decrement(TSource item, TProperty value) { - var itemValues = new Dictionary(); - var valueCounts = new Dictionary(); - bool hasValue = false; - TProperty currentMin = default; + if (!_valueCounts.TryGetValue(value, out var count)) + { + return; + } - void Increment(TSource item) + if (count == 1) { - var value = selector(item); - itemValues[item] = value; - if (valueCounts.TryGetValue(value, out var count)) - { - valueCounts[value] = count + 1; - } - else - { - valueCounts[value] = 1; - } + _valueCounts.Remove(value); - if (!hasValue || value.CompareTo(currentMin) < 0) + // If we removed the current max, recalc. + if (_hasValue && value.CompareTo(_currentMax) == 0) { - currentMin = value; - hasValue = true; + RecalculateMax(); } } - - void Decrement(TSource item, TProperty value) + else { - if (!valueCounts.TryGetValue(value, out var count)) - { - return; - } + _valueCounts[value] = count - 1; + } - if (count == 1) - { - valueCounts.Remove(value); - if (hasValue && value.CompareTo(currentMin) == 0) - { - RecalculateMin(); - } - } - else - { - valueCounts[value] = count - 1; - } + _itemValues.Remove(item); + if (_valueCounts.Count == 0) + { + _hasValue = false; + _currentMax = default; + } + } - itemValues.Remove(item); - if (valueCounts.Count == 0) - { - hasValue = false; - currentMin = default; - } + private void RecalculateMax() + { + if (_valueCounts.Count == 0) + { + _hasValue = false; + _currentMax = default; + return; } - void RecalculateMin() + // Linear scan of distinct values. + var max = default(TProperty); + bool first = true; + foreach (var kvp in _valueCounts.Keys) { - if (valueCounts.Count == 0) + if (first) { - hasValue = false; - currentMin = default; - return; + max = kvp; + first = false; } - - var min = default(TProperty); - bool first = true; - foreach (var kvp in valueCounts.Keys) + else if (kvp.CompareTo(max) > 0) { - if (first) - { - min = kvp; - first = false; - } - else if (kvp.CompareTo(min) < 0) - { - min = kvp; - } + max = kvp; } - - currentMin = min; - hasValue = true; } - void Publish() + _currentMax = max; + _hasValue = true; + } + + private void Publish(Observer observer) + { + try { - try - { - observer.OnNext(hasValue ? currentMin : default); - } - catch (Exception ex) - { - observer.OnErrorResume(ex); - } + observer.OnNext(_hasValue ? _currentMax : default); } + catch (Exception ex) + { + observer.OnErrorResume(ex); + } + } + } - return source.Subscribe( - changes => + /// + /// Finds the minimum value of a property across all items in the list, reactively updating as items change. + /// + /// The type of items in the list. + /// The type of the property to compare (must be comparable). + /// The source observable list. + /// Function to select the property value from each item. + /// An observable that emits the current minimum value, or default if empty. + public static Observable Min(this Observable> source, Func selector) + where TSource : notnull + where TProperty : struct, IComparable + { + if (selector == null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create>( + new MinState(source, selector), + static (observer, state) => state.Subscribe(observer)); + } + + private sealed class MinState + where TSource : notnull + where TProperty : struct, IComparable + { + private readonly Observable> _source; + private readonly Func _selector; + private readonly Dictionary _itemValues = new(); + private readonly Dictionary _valueCounts = new(); + private bool _hasValue; + private TProperty _currentMin; + + public MinState(Observable> source, Func selector) + { + _source = source; + _selector = selector; + } + + public IDisposable Subscribe(Observer observer) + { + return _source.Subscribe( + (this, observer), + static (changes, tuple) => tuple.Item1.OnNext(changes, tuple.observer), + static (ex, tuple) => tuple.observer.OnErrorResume(ex), + static (result, tuple) => tuple.observer.OnCompleted(result)); + } + + private void OnNext(IChangeSet changes, Observer observer) + { + foreach (var change in changes) { - foreach (var change in changes) + switch (change.Reason) { - switch (change.Reason) - { - case ListChangeReason.Add: - Increment(change.Item); - break; + case ListChangeReason.Add: + Increment(change.Item); + break; - case ListChangeReason.AddRange: - if (change.Range.Count > 0) - { - foreach (var i in change.Range) - { - Increment(i); - } - } - else + case ListChangeReason.AddRange: + if (change.Range.Count > 0) + { + foreach (var i in change.Range) { - Increment(change.Item); + Increment(i); } + } + else + { + Increment(change.Item); + } - break; + break; - case ListChangeReason.Remove: - if (itemValues.TryGetValue(change.Item, out var vRemove)) - { - Decrement(change.Item, vRemove); - } + case ListChangeReason.Remove: + if (_itemValues.TryGetValue(change.Item, out var vRemove)) + { + Decrement(change.Item, vRemove); + } - break; + break; - case ListChangeReason.RemoveRange: - if (change.Range.Count > 0) + case ListChangeReason.RemoveRange: + if (change.Range.Count > 0) + { + foreach (var i in change.Range) { - foreach (var i in change.Range) + if (_itemValues.TryGetValue(i, out var vR)) { - if (itemValues.TryGetValue(i, out var vR)) - { - Decrement(i, vR); - } + Decrement(i, vR); } } - else if (itemValues.TryGetValue(change.Item, out var vR2)) - { - Decrement(change.Item, vR2); - } + } + else if (_itemValues.TryGetValue(change.Item, out var vR2)) + { + Decrement(change.Item, vR2); + } - break; + break; - case ListChangeReason.Replace: - if (change.PreviousItem != null && itemValues.TryGetValue(change.PreviousItem, out var vPrev)) - { - Decrement(change.PreviousItem, vPrev); - } + case ListChangeReason.Replace: + if (change.PreviousItem != null && _itemValues.TryGetValue(change.PreviousItem, out var vPrev)) + { + Decrement(change.PreviousItem, vPrev); + } - Increment(change.Item); + Increment(change.Item); - break; + break; - case ListChangeReason.Refresh: - // AutoRefresh-generated refresh does not carry an item; recompute all. - if (itemValues.Count > 0) + case ListChangeReason.Refresh: + // AutoRefresh-generated refresh does not carry an item; recompute all. + if (_itemValues.Count > 0) + { + _valueCounts.Clear(); + _hasValue = false; + _currentMin = default; + var keys = _itemValues.Keys.ToList(); + foreach (var it in keys) { - valueCounts.Clear(); - hasValue = false; - currentMin = default; - var keys = itemValues.Keys.ToList(); - foreach (var it in keys) + var newVal2 = _selector(it); + _itemValues[it] = newVal2; + _valueCounts[newVal2] = _valueCounts.TryGetValue(newVal2, out var c) ? c + 1 : 1; + + if (!_hasValue || newVal2.CompareTo(_currentMin) < 0) { - var newVal2 = selector(it); - itemValues[it] = newVal2; - if (valueCounts.TryGetValue(newVal2, out var c)) - { - valueCounts[newVal2] = c + 1; - } - else - { - valueCounts[newVal2] = 1; - } - - if (!hasValue || newVal2.CompareTo(currentMin) < 0) - { - currentMin = newVal2; - hasValue = true; - } + _currentMin = newVal2; + _hasValue = true; } } + } + + break; + + case ListChangeReason.Clear: + _itemValues.Clear(); + _valueCounts.Clear(); + _hasValue = false; + _currentMin = default; - break; + break; + } + } - case ListChangeReason.Clear: - itemValues.Clear(); - valueCounts.Clear(); - hasValue = false; - currentMin = default; + Publish(observer); + } - break; - } + private void Increment(TSource item) + { + var value = _selector(item); + _itemValues[item] = value; + _valueCounts[value] = _valueCounts.TryGetValue(value, out var count) ? count + 1 : 1; + + if (!_hasValue || value.CompareTo(_currentMin) < 0) + { + _currentMin = value; + _hasValue = true; + } + } + + private void Decrement(TSource item, TProperty value) + { + if (!_valueCounts.TryGetValue(value, out var count)) + { + return; + } + + if (count == 1) + { + _valueCounts.Remove(value); + if (_hasValue && value.CompareTo(_currentMin) == 0) + { + RecalculateMin(); } + } + else + { + _valueCounts[value] = count - 1; + } - Publish(); - }, observer.OnErrorResume, observer.OnCompleted); - }); + _itemValues.Remove(item); + if (_valueCounts.Count == 0) + { + _hasValue = false; + _currentMin = default; + } + } + + private void RecalculateMin() + { + if (_valueCounts.Count == 0) + { + _hasValue = false; + _currentMin = default; + return; + } + + var min = default(TProperty); + bool first = true; + foreach (var kvp in _valueCounts.Keys) + { + if (first) + { + min = kvp; + first = false; + } + else if (kvp.CompareTo(min) < 0) + { + min = kvp; + } + } + + _currentMin = min; + _hasValue = true; + } + + private void Publish(Observer observer) + { + try + { + observer.OnNext(_hasValue ? _currentMin : default); + } + catch (Exception ex) + { + observer.OnErrorResume(ex); + } + } } /// @@ -589,122 +604,144 @@ public static Observable Avg(this Observable(observer => - { - var itemValues = new Dictionary(); - double sum = 0.0; - int count = 0; + return Observable.Create>( + new AvgState(source, selector), + static (observer, state) => state.Subscribe(observer)); + } - void AddValue(TSource item) - { - var v = Convert.ToDouble(selector(item)); - itemValues[item] = v; - sum += v; - count += 1; - } + private sealed class AvgState + where TSource : notnull + where TProperty : struct, IConvertible + { + private readonly Observable> _source; + private readonly Func _selector; + private readonly Dictionary _itemValues = new(); + private double _sum; + private int _count; - void RemoveValue(TSource item) - { - if (itemValues.TryGetValue(item, out var v)) - { - sum -= v; - count -= 1; - itemValues.Remove(item); - } - } + public AvgState(Observable> source, Func selector) + { + _source = source; + _selector = selector; + } - void Publish() - { - try - { - observer.OnNext(count == 0 ? 0.0 : sum / count); - } - catch (Exception ex) - { - observer.OnErrorResume(ex); - } - } + public IDisposable Subscribe(Observer observer) + { + return _source.Subscribe( + (this, observer), + static (changes, tuple) => tuple.Item1.OnNext(changes, tuple.observer), + static (ex, tuple) => tuple.observer.OnErrorResume(ex), + static (result, tuple) => tuple.observer.OnCompleted(result)); + } - return source.Subscribe( - changes => + private void OnNext(IChangeSet changes, Observer observer) + { + foreach (var change in changes) { - foreach (var change in changes) + switch (change.Reason) { - switch (change.Reason) - { - case ListChangeReason.Add: - AddValue(change.Item); - break; + case ListChangeReason.Add: + AddValue(change.Item); + break; - case ListChangeReason.AddRange: - if (change.Range.Count > 0) - { - foreach (var i in change.Range) - { - AddValue(i); - } - } - else + case ListChangeReason.AddRange: + if (change.Range.Count > 0) + { + foreach (var i in change.Range) { - AddValue(change.Item); + AddValue(i); } + } + else + { + AddValue(change.Item); + } - break; + break; - case ListChangeReason.Remove: - RemoveValue(change.Item); - break; + case ListChangeReason.Remove: + RemoveValue(change.Item); + break; - case ListChangeReason.RemoveRange: - if (change.Range.Count > 0) - { - foreach (var i in change.Range) - { - RemoveValue(i); - } - } - else + case ListChangeReason.RemoveRange: + if (change.Range.Count > 0) + { + foreach (var i in change.Range) { - RemoveValue(change.Item); + RemoveValue(i); } + } + else + { + RemoveValue(change.Item); + } - break; + break; - case ListChangeReason.Replace: - if (change.PreviousItem != null) - { - RemoveValue(change.PreviousItem); - } + case ListChangeReason.Replace: + if (change.PreviousItem != null) + { + RemoveValue(change.PreviousItem); + } - AddValue(change.Item); + AddValue(change.Item); - break; + break; - case ListChangeReason.Refresh: - if (itemValues.TryGetValue(change.Item, out var oldVal)) + case ListChangeReason.Refresh: + if (_itemValues.TryGetValue(change.Item, out var oldVal)) + { + var newVal = Convert.ToDouble(_selector(change.Item)); + if (Math.Abs(newVal - oldVal) > double.Epsilon) { - var newVal = Convert.ToDouble(selector(change.Item)); - if (Math.Abs(newVal - oldVal) > double.Epsilon) - { - sum += newVal - oldVal; - itemValues[change.Item] = newVal; - } + _sum += newVal - oldVal; + _itemValues[change.Item] = newVal; } + } - break; + break; - case ListChangeReason.Clear: - itemValues.Clear(); - sum = 0.0; - count = 0; + case ListChangeReason.Clear: + _itemValues.Clear(); + _sum = 0.0; + _count = 0; - break; - } + break; } + } - Publish(); - }, observer.OnErrorResume, observer.OnCompleted); - }); + Publish(observer); + } + + private void AddValue(TSource item) + { + var v = Convert.ToDouble(_selector(item)); + _itemValues[item] = v; + _sum += v; + _count += 1; + } + + private void RemoveValue(TSource item) + { + if (_itemValues.TryGetValue(item, out var v)) + { + _sum -= v; + _count -= 1; + _itemValues.Remove(item); + } + } + + private void Publish(Observer observer) + { + try + { + observer.OnNext(_count == 0 ? 0.0 : _sum / _count); + } + catch (Exception ex) + { + observer.OnErrorResume(ex); + } + } } /// @@ -724,139 +761,161 @@ public static Observable StdDev(this Observable(observer => - { - var itemValues = new Dictionary(); - double sum = 0.0; - double sumSquares = 0.0; - int count = 0; + return Observable.Create>( + new StdDevState(source, selector), + static (observer, state) => state.Subscribe(observer)); + } - void AddValue(TSource item) - { - var v = Convert.ToDouble(selector(item)); - itemValues[item] = v; - sum += v; - sumSquares += v * v; - count += 1; - } + private sealed class StdDevState + where TSource : notnull + where TProperty : struct, IConvertible + { + private readonly Observable> _source; + private readonly Func _selector; + private readonly Dictionary _itemValues = new(); + private double _sum; + private double _sumSquares; + private int _count; + + public StdDevState(Observable> source, Func selector) + { + _source = source; + _selector = selector; + } - void RemoveValue(TSource item) - { - if (itemValues.TryGetValue(item, out var v)) - { - sum -= v; - sumSquares -= v * v; - count -= 1; - itemValues.Remove(item); - } - } + public IDisposable Subscribe(Observer observer) + { + return _source.Subscribe( + (this, observer), + static (changes, tuple) => tuple.Item1.OnNext(changes, tuple.observer), + static (ex, tuple) => tuple.observer.OnErrorResume(ex), + static (result, tuple) => tuple.observer.OnCompleted(result)); + } - void Publish() + private void OnNext(IChangeSet changes, Observer observer) + { + foreach (var change in changes) { - try + switch (change.Reason) { - if (count == 0) - { - observer.OnNext(0.0); - return; - } + case ListChangeReason.Add: + AddValue(change.Item); + break; - double mean = sum / count; - double variance = (sumSquares / count) - (mean * mean); // population variance - if (variance < 0) - { - variance = 0; // guard against negative due to precision - } + case ListChangeReason.AddRange: + if (change.Range.Count > 0) + { + foreach (var i in change.Range) + { + AddValue(i); + } + } + else + { + AddValue(change.Item); + } - observer.OnNext(Math.Sqrt(variance)); - } - catch (Exception ex) - { - observer.OnErrorResume(ex); - } - } + break; - return source.Subscribe( - changes => - { - foreach (var change in changes) - { - switch (change.Reason) - { - case ListChangeReason.Add: - AddValue(change.Item); - break; + case ListChangeReason.Remove: + RemoveValue(change.Item); + break; - case ListChangeReason.AddRange: - if (change.Range.Count > 0) - { - foreach (var i in change.Range) - { - AddValue(i); - } - } - else + case ListChangeReason.RemoveRange: + if (change.Range.Count > 0) + { + foreach (var i in change.Range) { - AddValue(change.Item); + RemoveValue(i); } + } + else + { + RemoveValue(change.Item); + } - break; + break; - case ListChangeReason.Remove: - RemoveValue(change.Item); - break; + case ListChangeReason.Replace: + if (change.PreviousItem != null) + { + RemoveValue(change.PreviousItem); + } - case ListChangeReason.RemoveRange: - if (change.Range.Count > 0) - { - foreach (var i in change.Range) - { - RemoveValue(i); - } - } - else - { - RemoveValue(change.Item); - } + AddValue(change.Item); - break; + break; - case ListChangeReason.Replace: - if (change.PreviousItem != null) + case ListChangeReason.Refresh: + if (_itemValues.TryGetValue(change.Item, out var oldVal)) + { + var newVal = Convert.ToDouble(_selector(change.Item)); + if (Math.Abs(newVal - oldVal) > double.Epsilon) { - RemoveValue(change.PreviousItem); + _sum += newVal - oldVal; + _sumSquares += (newVal * newVal) - (oldVal * oldVal); + _itemValues[change.Item] = newVal; } + } - AddValue(change.Item); + break; - break; + case ListChangeReason.Clear: + _itemValues.Clear(); + _sum = 0.0; + _sumSquares = 0.0; + _count = 0; - case ListChangeReason.Refresh: - if (itemValues.TryGetValue(change.Item, out var oldVal)) - { - var newVal = Convert.ToDouble(selector(change.Item)); - if (Math.Abs(newVal - oldVal) > double.Epsilon) - { - sum += newVal - oldVal; - sumSquares += (newVal * newVal) - (oldVal * oldVal); - itemValues[change.Item] = newVal; - } - } + break; + } + } + + Publish(observer); + } - break; + private void AddValue(TSource item) + { + var v = Convert.ToDouble(_selector(item)); + _itemValues[item] = v; + _sum += v; + _sumSquares += v * v; + _count += 1; + } + + private void RemoveValue(TSource item) + { + if (_itemValues.TryGetValue(item, out var v)) + { + _sum -= v; + _sumSquares -= v * v; + _count -= 1; + _itemValues.Remove(item); + } + } - case ListChangeReason.Clear: - itemValues.Clear(); - sum = 0.0; - sumSquares = 0.0; - count = 0; + private void Publish(Observer observer) + { + try + { + if (_count == 0) + { + observer.OnNext(0.0); + return; + } - break; - } + double mean = _sum / _count; + double variance = (_sumSquares / _count) - (mean * mean); // population variance + if (variance < 0) + { + variance = 0; // guard against negative due to precision } - Publish(); - }, observer.OnErrorResume, observer.OnCompleted); - }); + observer.OnNext(Math.Sqrt(variance)); + } + catch (Exception ex) + { + observer.OnErrorResume(ex); + } + } } } diff --git a/R3.DynamicData/Operators/FilterOperator.cs b/R3.DynamicData/Operators/FilterOperator.cs index 287fa9f..c5eb21b 100644 --- a/R3.DynamicData/Operators/FilterOperator.cs +++ b/R3.DynamicData/Operators/FilterOperator.cs @@ -32,103 +32,119 @@ public static Observable> Filter( throw new ArgumentNullException(nameof(predicate)); } - return Observable.Create>(observer => + return Observable.Create, StaticFilterState>( + new StaticFilterState(source, predicate), + static (observer, state) => state.Subscribe(observer)); + } + + private sealed class StaticFilterState + where TKey : notnull + { + private readonly Observable> _source; + private readonly Func _predicate; + private readonly Dictionary _filteredData = new(); + + public StaticFilterState(Observable> source, Func predicate) { - var filteredData = new Dictionary(); + _source = source; + _predicate = predicate; + } - return source.Subscribe( - changes => + public IDisposable Subscribe(Observer> observer) + { + return _source.Subscribe( + (this, observer), + static (changes, tuple) => tuple.Item1.OnNext(changes, tuple.observer), + static (ex, tuple) => tuple.observer.OnErrorResume(ex), + static (result, tuple) => tuple.observer.OnCompleted(result)); + } + + private void OnNext(IChangeSet changes, Observer> observer) + { + var filteredChanges = new ChangeSet(); + + foreach (var change in changes) + { + var key = change.Key; + var current = change.Current; + var matchesFilter = _predicate(current); + var wasInFilter = _filteredData.TryGetValue(key, out var previousValue); + + switch (change.Reason) { - var filteredChanges = new ChangeSet(); + case Kernel.ChangeReason.Add: + if (matchesFilter) + { + _filteredData[key] = current; + filteredChanges.Add(new Change( + Kernel.ChangeReason.Add, + key, + current)); + } + + break; + + case Kernel.ChangeReason.Update: + if (matchesFilter && wasInFilter) + { + _filteredData[key] = current; + filteredChanges.Add(new Change( + Kernel.ChangeReason.Update, + key, + current, + previousValue)); + } + else if (matchesFilter && !wasInFilter) + { + _filteredData[key] = current; + filteredChanges.Add(new Change( + Kernel.ChangeReason.Add, + key, + current)); + } + else if (!matchesFilter && wasInFilter) + { + _filteredData.Remove(key); + filteredChanges.Add(new Change( + Kernel.ChangeReason.Remove, + key, + previousValue, + previousValue)); + } + + break; + + case Kernel.ChangeReason.Remove: + if (wasInFilter) + { + _filteredData.Remove(key); + filteredChanges.Add(new Change( + Kernel.ChangeReason.Remove, + key, + previousValue, + previousValue)); + } - foreach (var change in changes) - { - var key = change.Key; - var current = change.Current; - var matchesFilter = predicate(current); - var wasInFilter = filteredData.ContainsKey(key); + break; - switch (change.Reason) + case Kernel.ChangeReason.Refresh: + if (wasInFilter) { - case Kernel.ChangeReason.Add: - if (matchesFilter) - { - filteredData[key] = current; - filteredChanges.Add(new Change( - Kernel.ChangeReason.Add, - key, - current)); - } - - break; - - case Kernel.ChangeReason.Update: - if (matchesFilter && wasInFilter) - { - var previous = filteredData[key]; - filteredData[key] = current; - filteredChanges.Add(new Change( - Kernel.ChangeReason.Update, - key, - current, - previous)); - } - else if (matchesFilter && !wasInFilter) - { - filteredData[key] = current; - filteredChanges.Add(new Change( - Kernel.ChangeReason.Add, - key, - current)); - } - else if (!matchesFilter && wasInFilter) - { - var previous = filteredData[key]; - filteredData.Remove(key); - filteredChanges.Add(new Change( - Kernel.ChangeReason.Remove, - key, - previous, - previous)); - } - - break; - - case Kernel.ChangeReason.Remove: - if (wasInFilter) - { - var previous = filteredData[key]; - filteredData.Remove(key); - filteredChanges.Add(new Change( - Kernel.ChangeReason.Remove, - key, - previous, - previous)); - } - - break; - - case Kernel.ChangeReason.Refresh: - if (wasInFilter) - { - filteredChanges.Add(new Change( - Kernel.ChangeReason.Refresh, - key, - current)); - } - - break; + filteredChanges.Add(new Change( + Kernel.ChangeReason.Refresh, + key, + current)); } - } - - if (filteredChanges.Count > 0) - { - observer.OnNext(filteredChanges); - } - }, - observer.OnErrorResume, - observer.OnCompleted); - }); + + break; + } + } + + if (filteredChanges.Count > 0) + { + observer.OnNext(filteredChanges); + } + } } /// @@ -154,155 +170,176 @@ public static Observable> Filter( throw new ArgumentNullException(nameof(predicateChanged)); } - return Observable.Create>(observer => + return Observable.Create, DynamicFilterState>( + new DynamicFilterState(source, predicateChanged), + static (observer, state) => state.Subscribe(observer)); + } + + private sealed class DynamicFilterState + where TKey : notnull + { + private readonly Observable> _source; + private readonly Observable> _predicateChanged; + private readonly Dictionary _allData = new(); + private readonly Dictionary _filteredData = new(); + private Func? _currentPredicate; + + public DynamicFilterState( + Observable> source, + Observable> predicateChanged) { - var allData = new Dictionary(); - var filteredData = new Dictionary(); - Func? currentPredicate = null; + _source = source; + _predicateChanged = predicateChanged; + } - var predicateSubscription = predicateChanged.Subscribe( - predicate => + public IDisposable Subscribe(Observer> observer) + { + var predicateSubscription = _predicateChanged.Subscribe( + (this, observer), + static (predicate, tuple) => tuple.Item1.OnPredicateChanged(predicate, tuple.observer), + static (ex, tuple) => tuple.observer.OnErrorResume(ex), + static (result, tuple) => tuple.observer.OnCompleted(result)); + + var sourceSubscription = _source.Subscribe( + (this, observer), + static (changes, tuple) => tuple.Item1.OnSourceChanged(changes, tuple.observer), + static (ex, tuple) => tuple.observer.OnErrorResume(ex), + static (result, tuple) => tuple.observer.OnCompleted(result)); + + return Disposable.Combine(predicateSubscription, sourceSubscription); + } + + private void OnPredicateChanged(Func predicate, Observer> observer) + { + _currentPredicate = predicate; + + // Re-evaluate all items with the new predicate + var changes = new ChangeSet(); + var newFilteredKeys = new HashSet(); + + foreach (var kvp in _allData) + { + var key = kvp.Key; + var item = kvp.Value; + var matchesFilter = _currentPredicate(item); + var wasInFilter = _filteredData.TryGetValue(key, out _); + + if (matchesFilter) { - currentPredicate = predicate; + newFilteredKeys.Add(key); + } - // Re-evaluate all items with the new predicate - var changes = new ChangeSet(); - var newFilteredKeys = new HashSet(); + if (matchesFilter && !wasInFilter) + { + changes.Add(new Change( + Kernel.ChangeReason.Add, + key, + item)); + } + else if (!matchesFilter && wasInFilter) + { + changes.Add(new Change( + Kernel.ChangeReason.Remove, + key, + item, + item)); + } + } + + _filteredData.Clear(); + foreach (var key in newFilteredKeys) + { + _filteredData[key] = _allData[key]; + } + + if (changes.Count > 0) + { + observer.OnNext(changes); + } + } - foreach (var kvp in allData) - { - var key = kvp.Key; - var item = kvp.Value; - var matchesFilter = currentPredicate(item); - var wasInFilter = filteredData.ContainsKey(key); + private void OnSourceChanged(IChangeSet changes, Observer> observer) + { + var outputChanges = new ChangeSet(); - if (matchesFilter) - { - newFilteredKeys.Add(key); - } + foreach (var change in changes) + { + var key = change.Key; + var current = change.Current; + + switch (change.Reason) + { + case Kernel.ChangeReason.Add: + case Kernel.ChangeReason.Update: + _allData[key] = current; - if (matchesFilter && !wasInFilter) + if (_currentPredicate != null) { - changes.Add(new Change( - Kernel.ChangeReason.Add, - key, - item)); + var matchesFilter = _currentPredicate(current); + var wasInFilter = _filteredData.TryGetValue(key, out var previousValue); + + if (matchesFilter && !wasInFilter) + { + _filteredData[key] = current; + outputChanges.Add(new Change( + Kernel.ChangeReason.Add, + key, + current)); + } + else if (matchesFilter && wasInFilter) + { + _filteredData[key] = current; + outputChanges.Add(new Change( + Kernel.ChangeReason.Update, + key, + current, + previousValue)); + } + else if (!matchesFilter && wasInFilter) + { + _filteredData.Remove(key); + outputChanges.Add(new Change( + Kernel.ChangeReason.Remove, + key, + previousValue, + previousValue)); + } } - else if (!matchesFilter && wasInFilter) + + break; + + case Kernel.ChangeReason.Remove: + _allData.Remove(key); + + if (_filteredData.TryGetValue(key, out var removedValue)) { - changes.Add(new Change( + _filteredData.Remove(key); + outputChanges.Add(new Change( Kernel.ChangeReason.Remove, key, - item, - item)); + removedValue, + removedValue)); } - } - - filteredData.Clear(); - foreach (var key in newFilteredKeys) - { - filteredData[key] = allData[key]; - } - - if (changes.Count > 0) - { - observer.OnNext(changes); - } - }, - observer.OnErrorResume, - observer.OnCompleted); - - var sourceSubscription = source.Subscribe( - changes => - { - var outputChanges = new ChangeSet(); - foreach (var change in changes) - { - var key = change.Key; - var current = change.Current; + break; - switch (change.Reason) + case Kernel.ChangeReason.Refresh: + if (_filteredData.ContainsKey(key)) { - case Kernel.ChangeReason.Add: - case Kernel.ChangeReason.Update: - allData[key] = current; - - if (currentPredicate != null) - { - var matchesFilter = currentPredicate(current); - var wasInFilter = filteredData.ContainsKey(key); - - if (matchesFilter && !wasInFilter) - { - filteredData[key] = current; - outputChanges.Add(new Change( - Kernel.ChangeReason.Add, - key, - current)); - } - else if (matchesFilter && wasInFilter) - { - var previous = filteredData[key]; - filteredData[key] = current; - outputChanges.Add(new Change( - Kernel.ChangeReason.Update, - key, - current, - previous)); - } - else if (!matchesFilter && wasInFilter) - { - var previous = filteredData[key]; - filteredData.Remove(key); - outputChanges.Add(new Change( - Kernel.ChangeReason.Remove, - key, - previous, - previous)); - } - } - - break; - - case Kernel.ChangeReason.Remove: - allData.Remove(key); - - if (filteredData.ContainsKey(key)) - { - var previous = filteredData[key]; - filteredData.Remove(key); - outputChanges.Add(new Change( - Kernel.ChangeReason.Remove, - key, - previous, - previous)); - } - - break; - - case Kernel.ChangeReason.Refresh: - if (filteredData.ContainsKey(key)) - { - outputChanges.Add(new Change( - Kernel.ChangeReason.Refresh, - key, - current)); - } - - break; + outputChanges.Add(new Change( + Kernel.ChangeReason.Refresh, + key, + current)); } - } - if (outputChanges.Count > 0) - { - observer.OnNext(outputChanges); - } - }, - observer.OnErrorResume, - observer.OnCompleted); + break; + } + } - return Disposable.Combine(predicateSubscription, sourceSubscription); - }); + if (outputChanges.Count > 0) + { + observer.OnNext(outputChanges); + } + } } } diff --git a/R3.DynamicData/Operators/TransformOperator.cs b/R3.DynamicData/Operators/TransformOperator.cs index f2ddff9..3c030b4 100644 --- a/R3.DynamicData/Operators/TransformOperator.cs +++ b/R3.DynamicData/Operators/TransformOperator.cs @@ -33,60 +33,63 @@ public static Observable> Transform>(observer => - { - return source.Subscribe( - changes => - { - var transformed = new ChangeSet(changes.Count); - - foreach (var change in changes) + return Observable.Create, (Observable> source, Func transformFactory)>( + (source, transformFactory), + static (observer, state) => + { + return state.source.Subscribe( + (observer, state.transformFactory), + static (changes, tuple) => { - switch (change.Reason) + var transformed = new ChangeSet(changes.Count); + + foreach (var change in changes) { - case Kernel.ChangeReason.Add: - transformed.Add(new Change( - Kernel.ChangeReason.Add, - change.Key, - transformFactory(change.Current))); - break; - - case Kernel.ChangeReason.Update: - var transformedCurrent = transformFactory(change.Current); - var transformedPrevious = change.Previous.HasValue - ? transformFactory(change.Previous.Value) - : default(TDestination)!; - - transformed.Add(new Change( - Kernel.ChangeReason.Update, - change.Key, - transformedCurrent, - transformedPrevious)); - break; - - case Kernel.ChangeReason.Remove: - var removedItem = transformFactory(change.Current); - transformed.Add(new Change( - Kernel.ChangeReason.Remove, - change.Key, - removedItem, - removedItem)); - break; - - case Kernel.ChangeReason.Refresh: - transformed.Add(new Change( - Kernel.ChangeReason.Refresh, - change.Key, - transformFactory(change.Current))); - break; + switch (change.Reason) + { + case Kernel.ChangeReason.Add: + transformed.Add(new Change( + Kernel.ChangeReason.Add, + change.Key, + tuple.transformFactory(change.Current))); + break; + + case Kernel.ChangeReason.Update: + var transformedCurrent = tuple.transformFactory(change.Current); + var transformedPrevious = change.Previous.HasValue + ? tuple.transformFactory(change.Previous.Value) + : default(TDestination)!; + + transformed.Add(new Change( + Kernel.ChangeReason.Update, + change.Key, + transformedCurrent, + transformedPrevious)); + break; + + case Kernel.ChangeReason.Remove: + var removedItem = tuple.transformFactory(change.Current); + transformed.Add(new Change( + Kernel.ChangeReason.Remove, + change.Key, + removedItem, + removedItem)); + break; + + case Kernel.ChangeReason.Refresh: + transformed.Add(new Change( + Kernel.ChangeReason.Refresh, + change.Key, + tuple.transformFactory(change.Current))); + break; + } } - } - observer.OnNext(transformed); - }, - observer.OnErrorResume, - observer.OnCompleted); - }); + tuple.observer.OnNext(transformed); + }, + static (ex, tuple) => tuple.observer.OnErrorResume(ex), + static (result, tuple) => tuple.observer.OnCompleted(result)); + }); } /// @@ -113,60 +116,63 @@ public static Observable> Transform>(observer => - { - return source.Subscribe( - changes => - { - var transformed = new ChangeSet(changes.Count); - - foreach (var change in changes) + return Observable.Create, (Observable> source, Func transformFactory)>( + (source, transformFactory), + static (observer, state) => + { + return state.source.Subscribe( + (observer, state.transformFactory), + static (changes, tuple) => { - switch (change.Reason) + var transformed = new ChangeSet(changes.Count); + + foreach (var change in changes) { - case Kernel.ChangeReason.Add: - transformed.Add(new Change( - Kernel.ChangeReason.Add, - change.Key, - transformFactory(change.Current, change.Key))); - break; - - case Kernel.ChangeReason.Update: - var transformedCurrent = transformFactory(change.Current, change.Key); - var transformedPrevious = change.Previous.HasValue - ? transformFactory(change.Previous.Value, change.Key) - : default(TDestination)!; - - transformed.Add(new Change( - Kernel.ChangeReason.Update, - change.Key, - transformedCurrent, - transformedPrevious)); - break; - - case Kernel.ChangeReason.Remove: - var removedItem = transformFactory(change.Current, change.Key); - transformed.Add(new Change( - Kernel.ChangeReason.Remove, - change.Key, - removedItem, - removedItem)); - break; - - case Kernel.ChangeReason.Refresh: - transformed.Add(new Change( - Kernel.ChangeReason.Refresh, - change.Key, - transformFactory(change.Current, change.Key))); - break; + switch (change.Reason) + { + case Kernel.ChangeReason.Add: + transformed.Add(new Change( + Kernel.ChangeReason.Add, + change.Key, + tuple.transformFactory(change.Current, change.Key))); + break; + + case Kernel.ChangeReason.Update: + var transformedCurrent = tuple.transformFactory(change.Current, change.Key); + var transformedPrevious = change.Previous.HasValue + ? tuple.transformFactory(change.Previous.Value, change.Key) + : default(TDestination)!; + + transformed.Add(new Change( + Kernel.ChangeReason.Update, + change.Key, + transformedCurrent, + transformedPrevious)); + break; + + case Kernel.ChangeReason.Remove: + var removedItem = tuple.transformFactory(change.Current, change.Key); + transformed.Add(new Change( + Kernel.ChangeReason.Remove, + change.Key, + removedItem, + removedItem)); + break; + + case Kernel.ChangeReason.Refresh: + transformed.Add(new Change( + Kernel.ChangeReason.Refresh, + change.Key, + tuple.transformFactory(change.Current, change.Key))); + break; + } } - } - observer.OnNext(transformed); - }, - observer.OnErrorResume, - observer.OnCompleted); - }); + tuple.observer.OnNext(transformed); + }, + static (ex, tuple) => tuple.observer.OnErrorResume(ex), + static (result, tuple) => tuple.observer.OnCompleted(result)); + }); } /// @@ -198,40 +204,43 @@ public static Observable> TransformImmutable>(observer => - { - return source.Subscribe( - onNext: upstreamChanges => - { - var downstreamChanges = new ChangeSet(upstreamChanges.Count); - - try + return Observable.Create, (Observable> source, Func transformFactory)>( + (source, transformFactory), + static (observer, state) => + { + return state.source.Subscribe( + (observer, state.transformFactory), + static (upstreamChanges, tuple) => { - foreach (var change in upstreamChanges) + var downstreamChanges = new ChangeSet(upstreamChanges.Count); + + try { - var previous = change.Previous.HasValue - ? Kernel.Optional.Some(transformFactory(change.Previous.Value)) - : Kernel.Optional.None; - - downstreamChanges.Add(new Change( - reason: change.Reason, - key: change.Key, - current: transformFactory(change.Current), - previous: previous, - currentIndex: change.CurrentIndex, - previousIndex: change.PreviousIndex)); + foreach (var change in upstreamChanges) + { + var previous = change.Previous.HasValue + ? Kernel.Optional.Some(tuple.transformFactory(change.Previous.Value)) + : Kernel.Optional.None; + + downstreamChanges.Add(new Change( + reason: change.Reason, + key: change.Key, + current: tuple.transformFactory(change.Current), + previous: previous, + currentIndex: change.CurrentIndex, + previousIndex: change.PreviousIndex)); + } + + tuple.observer.OnNext(downstreamChanges); } - - observer.OnNext(downstreamChanges); - } - catch (Exception error) - { - // Propagate transformation errors - observer.OnErrorResume(error); - } - }, - observer.OnErrorResume, - observer.OnCompleted); - }); + catch (Exception error) + { + // Propagate transformation errors + tuple.observer.OnErrorResume(error); + } + }, + static (ex, tuple) => tuple.observer.OnErrorResume(ex), + static (result, tuple) => tuple.observer.OnCompleted(result)); + }); } } diff --git a/R3Ext.Tests/TestModuleInitializer.cs b/R3Ext.Tests/TestModuleInitializer.cs new file mode 100644 index 0000000..9a776be --- /dev/null +++ b/R3Ext.Tests/TestModuleInitializer.cs @@ -0,0 +1,22 @@ +using System.Runtime.CompilerServices; +using R3; + +namespace R3Ext.Tests; + +/// +/// Module initializer that ensures ObservableSystem.DefaultFrameProvider is set +/// before any tests run. This fixes the issue where running individual tests +/// from VS Code GUI would fail because the collection fixture hadn't initialized yet. +/// +internal static class TestModuleInitializer +{ + [ModuleInitializer] + internal static void Initialize() + { + // Only set if not already configured (allows collection fixtures to override) + if (ObservableSystem.DefaultFrameProvider is null) + { + ObservableSystem.DefaultFrameProvider = new FakeFrameProvider(); + } + } +} diff --git a/R3Ext/Bindings/BindingRegistry.cs b/R3Ext/Bindings/BindingRegistry.cs index 011d08b..ebe466e 100644 --- a/R3Ext/Bindings/BindingRegistry.cs +++ b/R3Ext/Bindings/BindingRegistry.cs @@ -75,7 +75,7 @@ public override string ToString() public static void RegisterOneWay(string fromPath, string toPath, Func?, IDisposable> factory) { - string key = fromPath + "|" + toPath; + string key = string.Concat(fromPath, "|", toPath); Log($"[BindingRegistry] RegisterOneWay {key} as {typeof(TFrom).Name}->{typeof(TTarget).Name}"); if (!_oneWay.TryGetValue(key, out List? list)) { @@ -94,7 +94,7 @@ public static void RegisterOneWay(string public static void RegisterTwoWay(string fromPath, string toPath, Func?, Func?, IDisposable> factory) { - string key = fromPath + "|" + toPath; + string key = string.Concat(fromPath, "|", toPath); Log($"[BindingRegistry] RegisterTwoWay {key} as {typeof(TFrom).Name}<->{typeof(TTarget).Name}"); if (!_twoWay.TryGetValue(key, out List? list)) { @@ -142,7 +142,7 @@ public static void RegisterWhenObserved(string whenPath, Func(string fromPath, string toPath, TFrom fromObj, TTarget targetObj, Func? conv, out IDisposable disposable) { - string key = fromPath + "|" + toPath; + string key = string.Concat(fromPath, "|", toPath); Log($"[BindingRegistry] TryCreateOneWay lookup {key} for {typeof(TFrom).Name}->{typeof(TTarget).Name}"); if (_oneWay.TryGetValue(key, out List? list)) { @@ -162,7 +162,7 @@ public static bool TryCreateOneWay(strin public static bool TryCreateTwoWay(string fromPath, string toPath, TFrom fromObj, TTarget targetObj, Func? hostToTarget, Func? targetToHost, out IDisposable disposable) { - string key = fromPath + "|" + toPath; + string key = string.Concat(fromPath, "|", toPath); Log($"[BindingRegistry] TryCreateTwoWay lookup {key} for {typeof(TFrom).Name}<->{typeof(TTarget).Name}"); if (_twoWay.TryGetValue(key, out List? list)) { @@ -333,7 +333,13 @@ private static (string typePart, string pathPart) SplitTypePath(string whenPath) return (string.Empty, whenPath); } +#if DEBUG + // In debug mode, include type part for logging return (whenPath.Substring(0, idx), whenPath.Substring(idx + 1)); +#else + // In release mode, avoid allocating the unused typePart string + return (string.Empty, whenPath.Substring(idx + 1)); +#endif } private static void Log(string message) diff --git a/R3Ext/Bindings/InternalLeaf.cs b/R3Ext/Bindings/InternalLeaf.cs index 4488b57..70202a7 100644 --- a/R3Ext/Bindings/InternalLeaf.cs +++ b/R3Ext/Bindings/InternalLeaf.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using R3Ext.Utilities; namespace R3Ext; @@ -18,7 +19,7 @@ internal string Name } _name = value; - this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); + this.PropertyChanged?.Invoke(this, PropertyEventArgsCache.GetPropertyChanged(nameof(Name))); } } @@ -40,7 +41,7 @@ internal InternalLeaf Leaf } _leaf = value; - this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Leaf))); + this.PropertyChanged?.Invoke(this, PropertyEventArgsCache.GetPropertyChanged(nameof(Leaf))); } } @@ -62,7 +63,7 @@ internal InternalMid Mid } _mid = value; - this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Mid))); + this.PropertyChanged?.Invoke(this, PropertyEventArgsCache.GetPropertyChanged(nameof(Mid))); } } diff --git a/R3Ext/Interactions/Interaction.cs b/R3Ext/Interactions/Interaction.cs index a9bf8c6..9c57bcb 100644 --- a/R3Ext/Interactions/Interaction.cs +++ b/R3Ext/Interactions/Interaction.cs @@ -4,7 +4,7 @@ namespace R3Ext; public class Interaction : IInteraction { - private readonly List, Observable>> _handlers = new(); + private readonly List, Observable>> _handlers = new(4); private readonly object _sync = new(); public IDisposable RegisterHandler(Action> handler) @@ -38,13 +38,9 @@ public IDisposable RegisterHandler(Func Wrapper(IInteractionContext context) - { - return handler(context).Select(_ => Unit.Default); - } - - this.AddHandler(Wrapper); - return Disposable.Create(() => this.RemoveHandler(Wrapper)); + var wrappedHandler = new HandlerWrapper(handler); + this.AddHandler(wrappedHandler.Invoke); + return new HandlerUnregistration(this, wrappedHandler.Invoke); } public IDisposable RegisterHandler(Func, IObservable> handler) @@ -111,7 +107,7 @@ protected virtual IOutputContext GenerateContext(TInput input) return new InteractionContext(input); } - private void AddHandler(Func, Observable> handler) + internal void AddHandler(Func, Observable> handler) { lock (_sync) { @@ -119,11 +115,44 @@ private void AddHandler(Func, Observable, Observable> handler) + internal void RemoveHandler(Func, Observable> handler) { lock (_sync) { _handlers.Remove(handler); } } + + /// + /// Wraps a handler function to convert Observable<TDontCare> to Observable<Unit>. + /// This avoids creating a closure for each registration. + /// + private sealed class HandlerWrapper(Func, Observable> handler) + { + public Observable Invoke(IInteractionContext context) + { + return handler(context).Select(static _ => Unit.Default); + } + } +} + +/// +/// Disposable that removes a handler from an interaction when disposed. +/// This avoids creating a closure for Disposable.Create. +/// +internal sealed class HandlerUnregistration( + Interaction interaction, + Func, Observable> handler) : IDisposable +{ + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + { + return; + } + + interaction.RemoveHandler(handler); + } } diff --git a/R3Ext/RxObject.cs b/R3Ext/RxObject.cs index 9ba4af6..87046d2 100644 --- a/R3Ext/RxObject.cs +++ b/R3Ext/RxObject.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using R3; +using R3Ext.Utilities; namespace R3Ext; @@ -59,7 +60,7 @@ public void RaisePropertyChanging(string propertyName) return; // delay skips changing events } - PropertyChangingEventArgs args = new(propertyName); + var args = PropertyEventArgsCache.GetPropertyChanging(propertyName); this.PropertyChanging?.Invoke(this, args); _changing.OnNext(args); } @@ -78,7 +79,7 @@ public void RaisePropertyChanged(string propertyName) return; } - PropertyChangedEventArgs args = new(propertyName); + var args = PropertyEventArgsCache.GetPropertyChanged(propertyName); this.PropertyChanged?.Invoke(this, args); _changed.OnNext(args); } @@ -89,7 +90,7 @@ public void RaisePropertyChanged(string propertyName) public IDisposable SuppressChangeNotifications() { Interlocked.Increment(ref _suppressCount); - return new ActionDisposable(() => Interlocked.Decrement(ref _suppressCount)); + return new SuppressDisposable(this); } /// @@ -98,20 +99,7 @@ public IDisposable SuppressChangeNotifications() public IDisposable DelayChangeNotifications() { Interlocked.Increment(ref _delayCount); - return new ActionDisposable(() => - { - if (Interlocked.Decrement(ref _delayCount) == 0 && _delayedProperties is { Count: > 0, }) - { - foreach (string prop in _delayedProperties) - { - PropertyChangedEventArgs args = new(prop); - this.PropertyChanged?.Invoke(this, args); - _changed.OnNext(args); - } - - _delayedProperties.Clear(); - } - }); + return new DelayDisposable(this); } /// @@ -122,7 +110,22 @@ public bool AreChangeNotificationsEnabled() return NotificationsEnabled && _delayCount == 0; } - private sealed class ActionDisposable(Action onDispose) : IDisposable + private sealed class SuppressDisposable(RxObject owner) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + { + return; + } + + Interlocked.Decrement(ref owner._suppressCount); + } + } + + private sealed class DelayDisposable(RxObject owner) : IDisposable { private int _disposed; @@ -133,7 +136,17 @@ public void Dispose() return; } - onDispose(); + if (Interlocked.Decrement(ref owner._delayCount) == 0 && owner._delayedProperties is { Count: > 0, }) + { + foreach (string prop in owner._delayedProperties) + { + var args = PropertyEventArgsCache.GetPropertyChanged(prop); + owner.PropertyChanged?.Invoke(owner, args); + owner._changed.OnNext(args); + } + + owner._delayedProperties.Clear(); + } } } } diff --git a/R3Ext/RxRecord.cs b/R3Ext/RxRecord.cs index 3f3846c..16f1dc9 100644 --- a/R3Ext/RxRecord.cs +++ b/R3Ext/RxRecord.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using R3; +using R3Ext.Utilities; namespace R3Ext; @@ -52,7 +53,7 @@ public void RaisePropertyChanging(string propertyName) return; } - PropertyChangingEventArgs args = new(propertyName); + var args = PropertyEventArgsCache.GetPropertyChanging(propertyName); this.PropertyChanging?.Invoke(this, args); _changing.OnNext(args); } @@ -71,7 +72,7 @@ public void RaisePropertyChanged(string propertyName) return; } - PropertyChangedEventArgs args = new(propertyName); + var args = PropertyEventArgsCache.GetPropertyChanged(propertyName); this.PropertyChanged?.Invoke(this, args); _changed.OnNext(args); } @@ -79,26 +80,13 @@ public void RaisePropertyChanged(string propertyName) public IDisposable SuppressChangeNotifications() { Interlocked.Increment(ref _suppressCount); - return new ActionDisposable(() => Interlocked.Decrement(ref _suppressCount)); + return new SuppressDisposable(this); } public IDisposable DelayChangeNotifications() { Interlocked.Increment(ref _delayCount); - return new ActionDisposable(() => - { - if (Interlocked.Decrement(ref _delayCount) == 0 && _delayedProperties is { Count: > 0, }) - { - foreach (string prop in _delayedProperties) - { - PropertyChangedEventArgs args = new(prop); - this.PropertyChanged?.Invoke(this, args); - _changed.OnNext(args); - } - - _delayedProperties.Clear(); - } - }); + return new DelayDisposable(this); } public bool AreChangeNotificationsEnabled() @@ -106,7 +94,22 @@ public bool AreChangeNotificationsEnabled() return NotificationsEnabled && _delayCount == 0; } - private sealed class ActionDisposable(Action onDispose) : IDisposable + private sealed class SuppressDisposable(RxRecord owner) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + { + return; + } + + Interlocked.Decrement(ref owner._suppressCount); + } + } + + private sealed class DelayDisposable(RxRecord owner) : IDisposable { private int _disposed; @@ -117,7 +120,17 @@ public void Dispose() return; } - onDispose(); + if (Interlocked.Decrement(ref owner._delayCount) == 0 && owner._delayedProperties is { Count: > 0, }) + { + foreach (string prop in owner._delayedProperties) + { + var args = PropertyEventArgsCache.GetPropertyChanged(prop); + owner.PropertyChanged?.Invoke(owner, args); + owner._changed.OnNext(args); + } + + owner._delayedProperties.Clear(); + } } } } diff --git a/R3Ext/Timing/TimingExtensions.Buffer.cs b/R3Ext/Timing/TimingExtensions.Buffer.cs index 4a00777..6c95453 100644 --- a/R3Ext/Timing/TimingExtensions.Buffer.cs +++ b/R3Ext/Timing/TimingExtensions.Buffer.cs @@ -26,7 +26,7 @@ public static Observable BufferUntilInactive(this Observable source, { Lock gate = new(); bool disposed = false; - List buffer = new(); + List buffer = new(16); // Initial capacity to reduce early resizes IDisposable? upstream = null; ITimer? timer = null; diff --git a/R3Ext/Utilities/PropertyEventArgsCache.cs b/R3Ext/Utilities/PropertyEventArgsCache.cs new file mode 100644 index 0000000..fdd80fa --- /dev/null +++ b/R3Ext/Utilities/PropertyEventArgsCache.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; +using System.ComponentModel; + +namespace R3Ext.Utilities; + +/// +/// Cache for PropertyChangedEventArgs and PropertyChangingEventArgs to avoid repeated allocations. +/// Uses a thread-safe concurrent dictionary with a size limit to prevent unbounded growth. +/// +internal static class PropertyEventArgsCache +{ + private const int MaxCacheSize = 256; + + private static readonly ConcurrentDictionary ChangedCache = new(); + private static readonly ConcurrentDictionary ChangingCache = new(); + + /// + /// Gets or creates a cached PropertyChangedEventArgs for the specified property name. + /// + public static PropertyChangedEventArgs GetPropertyChanged(string propertyName) + { + // Only cache if we haven't exceeded the limit (approximate check for performance) + if (ChangedCache.Count < MaxCacheSize) + { + // Atomically get or add to cache - avoids allocation on cache hit + return ChangedCache.GetOrAdd(propertyName, static n => new PropertyChangedEventArgs(n)); + } + + // If cache is full, check if already cached, otherwise allocate new + return ChangedCache.TryGetValue(propertyName, out var cached) + ? cached + : new PropertyChangedEventArgs(propertyName); + } + + /// + /// Gets or creates a cached PropertyChangingEventArgs for the specified property name. + /// + public static PropertyChangingEventArgs GetPropertyChanging(string propertyName) + { + // Only cache if we haven't exceeded the limit (approximate check for performance) + if (ChangingCache.Count < MaxCacheSize) + { + // Atomically get or add to cache - avoids allocation on cache hit + return ChangingCache.GetOrAdd(propertyName, static n => new PropertyChangingEventArgs(n)); + } + + // If cache is full, check if already cached, otherwise allocate new + return ChangingCache.TryGetValue(propertyName, out var cached) + ? cached + : new PropertyChangingEventArgs(propertyName); + } +}