diff --git a/Containers.Test/ContiguousMapTests.cs b/Containers.Test/ContiguousMapTests.cs index 23d504a..b4c6503 100644 --- a/Containers.Test/ContiguousMapTests.cs +++ b/Containers.Test/ContiguousMapTests.cs @@ -2,6 +2,7 @@ namespace ktsu.Containers.Tests; +using System.Runtime.CompilerServices; using Microsoft.VisualStudio.TestTools.UnitTesting; [TestClass] @@ -605,4 +606,57 @@ public void Enumerate_ChangesMadeThroughEveryMutator_Throw() => [TestMethod] public void Enumerate_Unchanged_VisitsEveryEntry() => MapEnumerationAssertions.UnchangedEnumerationVisitsEveryEntry(() => new ContiguousMap()); + + [TestMethod] + public void AsSpan_IsReadOnly_SoKeysCannotBypassTheKeyIndex() + { + // A writable span would let a caller overwrite entry "a" with key "b", after which map["a"] read the new value. + Type returnType = typeof(ContiguousMap).GetMethod(nameof(ContiguousMap<,>.AsSpan))!.ReturnType; + + Assert.AreEqual(typeof(ReadOnlySpan.Entry>), returnType); + } + + [TestMethod] + public void GetValueRefOrNullRef_ExistingKey_UpdatesValueInPlace() + { + ContiguousMap map = new() { ["a"] = 1, ["b"] = 2 }; + + ref int value = ref map.GetValueRefOrNullRef("a"); + value = 99; + + Assert.AreEqual(99, map["a"]); + Assert.AreEqual(2, map["b"]); + Assert.AreSequenceEqual(["a", "b"], map.Keys); + Assert.AreEqual(99, map.AsSpan()[0].Value); + } + + [TestMethod] + public void GetValueRefOrNullRef_ExistingKey_KeepsKeyIndexConsistent() + { + ContiguousMap map = new(StringComparer.OrdinalIgnoreCase) { ["Apple"] = 1 }; + + map.GetValueRefOrNullRef("APPLE") += 41; + + Assert.AreEqual(42, map["apple"]); + Assert.AreSequenceEqual(["Apple"], map.Keys); + Assert.IsTrue(map.Remove("Apple")); + Assert.IsEmpty(map); + } + + [TestMethod] + public void GetValueRefOrNullRef_MissingKey_ReturnsNullRef() + { + ContiguousMap map = new() { ["a"] = 1 }; + + Assert.IsTrue(Unsafe.IsNullRef(ref map.GetValueRefOrNullRef("z"))); + Assert.IsFalse(Unsafe.IsNullRef(ref map.GetValueRefOrNullRef("a"))); + } + + [TestMethod] + public void GetValueRefOrNullRef_NullKey_ThrowsArgumentNullException() + { + ContiguousMap map = []; + + Assert.ThrowsExactly(() => map.GetValueRefOrNullRef(null!)); + } } diff --git a/Containers.Test/ContiguousSetTests.cs b/Containers.Test/ContiguousSetTests.cs index fd1d91a..275894d 100644 --- a/Containers.Test/ContiguousSetTests.cs +++ b/Containers.Test/ContiguousSetTests.cs @@ -558,4 +558,23 @@ public void UnionWith_Itself_DoesNotThrow() Assert.AreSequenceEqual([1, 2, 3], set); } + + [TestMethod] + public void AsSpan_IsReadOnly_SoElementsCannotBypassTheUniquenessIndex() + { + // A writable span would let a caller turn {1, 2, 3} into {2, 2, 3} while Contains(1) stayed true. + Type returnType = typeof(ContiguousSet).GetMethod(nameof(ContiguousSet<>.AsSpan))!.ReturnType; + + Assert.AreEqual(typeof(ReadOnlySpan), returnType); + } + + [TestMethod] + public void AsSpan_ReturnsElementsInInsertionOrder() + { + ContiguousSet set = [3, 1, 2]; + + ReadOnlySpan span = set.AsSpan(); + + Assert.AreSequenceEqual([3, 1, 2], span.ToArray()); + } } diff --git a/Containers/ContiguousMap.cs b/Containers/ContiguousMap.cs index 3f3a0d4..554f501 100644 --- a/Containers/ContiguousMap.cs +++ b/Containers/ContiguousMap.cs @@ -4,6 +4,7 @@ namespace ktsu.Containers; using System.Collections; using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; /// /// Represents a generic map/dictionary that maintains key-value pairs in contiguous memory for optimal cache performance. @@ -55,24 +56,30 @@ public class ContiguousMap /// /// The key of the entry. /// The value of the entry. - public readonly struct Entry(TKey key, TValue value) : IEquatable + public struct Entry(TKey key, TValue value) : IEquatable { + /// + /// The value of the entry. A field rather than an auto-property so the map can hand out a + /// reference to it from without exposing the key. + /// + internal TValue storedValue = value; + /// /// Gets the key of the entry. /// - public TKey Key { get; } = key; + public readonly TKey Key { get; } = key; /// /// Gets the value of the entry. /// - public TValue Value { get; } = value; + public readonly TValue Value => storedValue; /// /// Determines whether the specified object is equal to the current entry. /// /// The object to compare with the current entry. /// true if the specified object is equal to the current entry; otherwise, false. - public override bool Equals(object? obj) => + public override readonly bool Equals(object? obj) => obj is Entry other && EqualityComparer.Default.Equals(Key, other.Key) && EqualityComparer.Default.Equals(Value, other.Value); @@ -82,7 +89,7 @@ obj is Entry other /// /// An entry to compare with this entry. /// true if the current entry is equal to the other parameter; otherwise, false. - public bool Equals(Entry other) => + public readonly bool Equals(Entry other) => EqualityComparer.Default.Equals(Key, other.Key) && EqualityComparer.Default.Equals(Value, other.Value); @@ -90,7 +97,7 @@ public bool Equals(Entry other) => /// Returns the hash code for this entry. /// /// A 32-bit signed integer hash code. - public override int GetHashCode() + public override readonly int GetHashCode() { #if NETSTANDARD2_0 int hash = 17; @@ -557,14 +564,43 @@ public void TrimExcess() } /// - /// Gets a span representing the entries in the map. + /// Gets a read-only span representing the entries in the map. /// - /// A span over the map's entries. + /// A read-only span over the map's entries. /// /// This method provides direct access to the contiguous memory, enabling high-performance - /// operations and interoperability with other APIs that work with spans. + /// operations and interoperability with other APIs that work with spans. The span is read-only + /// because writing an entry through it would change a key without updating the key index; use + /// to update a value in place. + /// + public ReadOnlySpan AsSpan() => new(items, 0, Count); + + /// + /// Gets a reference to the value stored for the specified key, or a null reference if the key is not in the map. + /// + /// The key whose value to get. + /// + /// A reference to the stored value, which can be read or written in place, or a null reference when + /// is not found. Test for the latter with Unsafe.IsNullRef. + /// + /// Thrown when key is null. + /// + /// The key cannot be changed through the returned reference, so the key index stays consistent. + /// The reference is valid only until the map is next added to or removed from, since either can move + /// the entry; writing through it does not invalidate enumerators, matching + /// CollectionsMarshal.GetValueRefOrNullRef. /// - public Span AsSpan() => new(items, 0, Count); + public ref TValue GetValueRefOrNullRef(TKey key) + { + Ensure.NotNull((object?)key); + + if (keyToIndex.TryGetValue(key, out int index)) + { + return ref items[index].storedValue; + } + + return ref MemoryMarshal.GetReference(Span.Empty); + } /// /// Gets a read-only span representing the entries in the map. diff --git a/Containers/ContiguousSet.cs b/Containers/ContiguousSet.cs index cffdd28..ef286e4 100644 --- a/Containers/ContiguousSet.cs +++ b/Containers/ContiguousSet.cs @@ -535,14 +535,15 @@ public void TrimExcess() } /// - /// Gets a span representing the elements in the set. + /// Gets a read-only span representing the elements in the set. /// - /// A span over the set's elements. + /// A read-only span over the set's elements. /// /// This method provides direct access to the contiguous memory, enabling high-performance - /// operations and interoperability with other APIs that work with spans. + /// operations and interoperability with other APIs that work with spans. The span is read-only + /// because writing an element through it would bypass the uniqueness index. /// - public Span AsSpan() => new(items, 0, Count); + public ReadOnlySpan AsSpan() => new(items, 0, Count); /// /// Gets a read-only span representing the elements in the set.