|
5 | 5 | [](LICENSE) |
6 | 6 | [](https://github.com/ChrisPulman/ReactiveList/actions) |
7 | 7 |
|
8 | | -A lightweight, high-performance reactive list with fine-grained change tracking built on [DynamicData](https://github.com/reactivemarbles/DynamicData) and [System.Reactive](https://github.com/dotnet/reactive). |
| 8 | +A lightweight, high-performance reactive list with fine-grained change tracking built on [System.Reactive](https://github.com/dotnet/reactive). |
9 | 9 |
|
10 | 10 | **Targets:** .NET Standard 2.0 | .NET 8 | .NET 9 | .NET 10 |
11 | 11 |
|
@@ -65,6 +65,15 @@ Install-Package ReactiveList |
65 | 65 |
|
66 | 66 | ## Quick Start |
67 | 67 |
|
| 68 | +This library provides: |
| 69 | + |
| 70 | +- A `ReactiveList<T>` implementation that allows you to observe changes in real-time. |
| 71 | +- A `Reactive2DList<T>` for managing a list of lists (2D structure) with reactive capabilities. |
| 72 | +- A `QuaternaryDictionary<TKey, TValue>` for high-performance key-value storage with reactive features. |
| 73 | +- A `QuaternaryList<T>` for optimized list operations at scale with reactive capabilities. |
| 74 | + |
| 75 | +Here's a quick example to get you started: |
| 76 | + |
68 | 77 | ```csharp |
69 | 78 | using CP.Reactive; |
70 | 79 |
|
@@ -750,38 +759,6 @@ Inherits from `ReactiveList<ReactiveList<T>>` and adds: |
750 | 759 |
|
751 | 760 | - **Disposal**: Always dispose `ReactiveList` instances to clean up subscriptions and internal resources. |
752 | 761 |
|
753 | | - |
754 | | ---- |
755 | | - |
756 | | -## QuaternaryList VS DynamicData SourceCache - Benchmark Results |
757 | | - |
758 | | -### Performance Comparison (Mean Time in nanoseconds) |
759 | | - |
760 | | -| Operation | Count | Before (ns) | After (ns) | Improvement | |
761 | | -|-----------|-------|-------------|------------|-------------| |
762 | | -| AddRange | 100 | 60,227 | 66,080 | ~same | |
763 | | -| AddRange | 1,000 | 439,706 | 77,779 | **5.6x faster** | |
764 | | -| AddRange | 10,000 | 3,246,674 | 98,279 | **33x faster** | |
765 | | -| RemoveRange | 100 | 60,217 | 67,212 | ~same | |
766 | | -| RemoveRange | 1,000 | 643,564 | 91,937 | **7x faster** | |
767 | | -| RemoveRange | 10,000 | 5,734,650 | 1,292,425 | **4.4x faster** | |
768 | | -| Clear | 100 | 59,980 | 66,179 | ~same | |
769 | | -| Clear | 1,000 | 443,622 | 76,897 | **5.8x faster** | |
770 | | -| Clear | 10,000 | 3,720,656 | 98,732 | **37x faster** | |
771 | | -| Contains | 1,000 | 443,302 | 76,718 | **5.8x faster** | |
772 | | -| Contains | 10,000 | 3,979,264 | 98,848 | **40x faster** | |
773 | | - |
774 | | -### Memory Allocation Comparison (bytes) |
775 | | - |
776 | | -| Operation | Count | Before (bytes) | After (bytes) | Improvement | |
777 | | -|-----------|-------|----------------|---------------|-------------| |
778 | | -| AddRange | 1,000 | 29,424 | 10,676 | **2.75x less** | |
779 | | -| AddRange | 10,000 | 337,449 | 46,949 | **7.2x less** | |
780 | | -| RemoveRange | 1,000 | 36,813 | 12,783 | **2.9x less** | |
781 | | -| RemoveRange | 10,000 | 406,655 | 49,535 | **8.2x less** | |
782 | | -| Clear | 1,000 | 29,452 | 10,676 | **2.76x less** | |
783 | | -| Clear | 10,000 | 337,472 | 46,958 | **7.2x less** | |
784 | | - |
785 | 762 | --- |
786 | 763 |
|
787 | 764 | ## QuaternaryDictionary vs SourceCache Benchmark Results |
@@ -838,8 +815,186 @@ Inherits from `ReactiveList<ReactiveList<T>>` and adds: |
838 | 815 |
|
839 | 816 | --- |
840 | 817 |
|
| 818 | +## QuaternaryDictionary and QuaternaryList |
| 819 | + |
| 820 | +High-performance, low-allocation key-value and list collections optimized for large-scale reactive applications. |
| 821 | +Performance benchmarks show significant advantages over traditional collections in batch operations and memory usage. |
| 822 | +This makes them ideal for scenarios involving large datasets, frequent updates, and real-time data processing. |
| 823 | + |
| 824 | +The Stream property exposes an observable sequence of changes, enabling reactive programming patterns. |
| 825 | + |
| 826 | +Example usage in an Address Book application: |
| 827 | + |
| 828 | +```csharp |
| 829 | +using System.Collections.ObjectModel; |
| 830 | +using System.Reactive.Linq; |
| 831 | +using System.Reactive.Subjects; |
| 832 | +using CP.Reactive; |
| 833 | +using ReactiveUI; // For RxApp.MainThreadScheduler |
| 834 | +
|
| 835 | +public class AddressBookViewModel : IDisposable |
| 836 | +{ |
| 837 | + private readonly QuaternaryList<Contact> _contactList = []; |
| 838 | + private readonly QuaternaryDictionary<Guid, Contact> _contactMap = []; |
| 839 | + private readonly BehaviorSubject<string> _searchText = new(string.Empty); |
| 840 | + private bool _disposedValue; |
| 841 | + |
| 842 | + public AddressBookViewModel() |
| 843 | + { |
| 844 | + InitializeIndices(); |
| 845 | + InitializePipelines(); |
| 846 | + } |
| 847 | + |
| 848 | + public ReadOnlyObservableCollection<Contact> AllContacts { get; private set; } |
| 849 | + |
| 850 | + public ReadOnlyObservableCollection<Contact> FavoriteContacts { get; private set; } |
| 851 | + |
| 852 | + public ReadOnlyObservableCollection<Contact> NewYorkContacts { get; private set; } |
| 853 | + |
| 854 | + public ReadOnlyObservableCollection<Contact> SearchResults { get; private set; } // Dynamic |
| 855 | +
|
| 856 | + public string SearchQuery |
| 857 | + { |
| 858 | + get => _searchText.Value; |
| 859 | + set => _searchText.OnNext(value ?? string.Empty); |
| 860 | + } |
| 861 | + |
| 862 | + public void BulkImport(int count) |
| 863 | + { |
| 864 | + var newContacts = Enumerable.Range(0, count).Select(i => |
| 865 | + new Contact( |
| 866 | + Guid.NewGuid(), |
| 867 | + $"User{i}", |
| 868 | + $"Smith{i}", |
| 869 | + $"user{i}@company.com", |
| 870 | + i % 2 == 0 ? "Engineering" : "HR", |
| 871 | + i % 10 == 0, // 10% are favorites |
| 872 | + new Address("123 Main", i % 5 == 0 ? "New York" : "London", "10001", "USA"))).ToList(); |
| 873 | + |
| 874 | + // High-Speed Parallel Add |
| 875 | + _contactList.AddRange(newContacts); |
| 876 | + _contactMap.AddRange(newContacts.Select(c => new KeyValuePair<Guid, Contact>(c.Id, c))); |
| 877 | + } |
| 878 | + |
| 879 | + public void BulkRemoveInactive() |
| 880 | + { |
| 881 | + // Query utilizing Secondary Index for speed |
| 882 | + var hrDept = _contactList.Query("ByDepartment", "HR").ToList(); |
| 883 | + |
| 884 | + // Bulk Thread-Safe Remove |
| 885 | + _contactList.RemoveRange(hrDept); |
| 886 | + |
| 887 | + // Sync Dictionary |
| 888 | + foreach (var c in hrDept) |
| 889 | + { |
| 890 | + _contactMap.Remove(c.Id); |
| 891 | + } |
| 892 | + } |
| 893 | + |
| 894 | + public void UpdateCityName(string oldCity, string newCity) |
| 895 | + { |
| 896 | + // 1. Find targets using Index (Fast) |
| 897 | + var targets = _contactList.Query("ByCity", oldCity).ToList(); |
| 898 | + |
| 899 | + // 2. Modify and Update |
| 900 | + // Since Records are immutable, we replace the object |
| 901 | + var updates = new List<Contact>(); |
| 902 | + foreach (var c in targets) |
| 903 | + { |
| 904 | + updates.Add(c with { HomeAddress = c.HomeAddress with { City = newCity } }); |
| 905 | + } |
| 906 | + |
| 907 | + // 3. Apply updates (Remove old, Add new) effectively performs an update |
| 908 | + _contactList.RemoveRange(targets); |
| 909 | + _contactList.AddRange(updates); |
| 910 | + } |
| 911 | + |
| 912 | + public void Dispose() |
| 913 | + { |
| 914 | + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method |
| 915 | + Dispose(disposing: true); |
| 916 | + GC.SuppressFinalize(this); |
| 917 | + } |
| 918 | + |
| 919 | + protected virtual void Dispose(bool disposing) |
| 920 | + { |
| 921 | + if (!_disposedValue) |
| 922 | + { |
| 923 | + if (disposing) |
| 924 | + { |
| 925 | + _contactList.Dispose(); |
| 926 | + _contactMap.Dispose(); |
| 927 | + _searchText.Dispose(); |
| 928 | + } |
| 929 | + |
| 930 | + _disposedValue = true; |
| 931 | + } |
| 932 | + } |
| 933 | + |
| 934 | + private static bool Matches(Contact? c, string query) |
| 935 | + { |
| 936 | + if (c == null) |
| 937 | + { |
| 938 | + return false; |
| 939 | + } |
| 940 | + |
| 941 | + if (string.IsNullOrWhiteSpace(query)) |
| 942 | + { |
| 943 | + return true; |
| 944 | + } |
| 945 | + |
| 946 | + return c.LastName.Contains(query, StringComparison.OrdinalIgnoreCase) || |
| 947 | + c.Email.Contains(query, StringComparison.OrdinalIgnoreCase); |
| 948 | + } |
| 949 | + |
| 950 | + private void InitializeIndices() |
| 951 | + { |
| 952 | + // Add High-Speed Lookup Indices (O(1) access) |
| 953 | + _contactList.AddIndex("ByCity", c => c.HomeAddress.City); |
| 954 | + _contactList.AddIndex("ByDepartment", c => c.Department); |
| 955 | + |
| 956 | + // Map Dictionary for ID-based updates |
| 957 | + _contactMap.AddValueIndex("ByEmail", c => c.Email); |
| 958 | + } |
| 959 | + |
| 960 | + private void InitializePipelines() |
| 961 | + { |
| 962 | + // 1. ALL CONTACTS (Throttled 100ms) |
| 963 | + _contactList.CreateView(c => true, RxApp.MainThreadScheduler, throttleMs: 100) |
| 964 | + .ToProperty(x => AllContacts = x); |
| 965 | + |
| 966 | + // 2. FAVORITES (Filtered Subset) |
| 967 | + _contactList.CreateView(c => c.IsFavorite, RxApp.MainThreadScheduler, throttleMs: 100) |
| 968 | + .ToProperty(x => FavoriteContacts = x); |
| 969 | + |
| 970 | + // 3. SECONDARY KEY SUBSET (City == "New York") |
| 971 | + // Uses the Stream for updates, but efficient logic for the filter |
| 972 | + _contactList.CreateView(c => c.HomeAddress.City == "New York", RxApp.MainThreadScheduler, throttleMs: 200) |
| 973 | + .ToProperty(x => NewYorkContacts = x); |
| 974 | + |
| 975 | + // 4. DYNAMIC SEARCH QUERY (Complex Pipeline) |
| 976 | + // Combines the Cache Stream + Search Text Stream |
| 977 | + var searchPipeline = _contactList.Stream |
| 978 | + .CombineLatest(_searchText, (change, query) => new { change, query }) |
| 979 | + .Where(x => Matches(x.change.Item, x.query)) |
| 980 | + .Select(x => x.change); // Project back to notification |
| 981 | +
|
| 982 | + // Note: For a true search view, we usually rebuild the collection when query changes. |
| 983 | + // This simulates a "Live Search Result" stream. |
| 984 | + new ReactiveView<Contact>( |
| 985 | + searchPipeline, |
| 986 | + [.. _contactList], // Initial Snapshot |
| 987 | + c => Matches(c, _searchText.Value), |
| 988 | + TimeSpan.FromMilliseconds(50), |
| 989 | + RxApp.MainThreadScheduler) |
| 990 | + .ToProperty(x => SearchResults = x); |
| 991 | + } |
| 992 | +} |
| 993 | +``` |
| 994 | + |
| 995 | +--- |
| 996 | + |
841 | 997 | **Dependencies:** |
842 | | -- [DynamicData](https://github.com/reactivemarbles/DynamicData) |
843 | 998 | - [System.Reactive](https://github.com/dotnet/reactive) |
844 | 999 |
|
845 | 1000 | --- |
|
0 commit comments