Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions Containers.Test/ContiguousMapTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace ktsu.Containers.Tests;

using System.Runtime.CompilerServices;
using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
Expand Down Expand Up @@ -605,4 +606,57 @@ public void Enumerate_ChangesMadeThroughEveryMutator_Throw() =>
[TestMethod]
public void Enumerate_Unchanged_VisitsEveryEntry() =>
MapEnumerationAssertions.UnchangedEnumerationVisitsEveryEntry(() => new ContiguousMap<int, string>());

[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<string, int>).GetMethod(nameof(ContiguousMap<,>.AsSpan))!.ReturnType;

Assert.AreEqual(typeof(ReadOnlySpan<ContiguousMap<string, int>.Entry>), returnType);
}

[TestMethod]
public void GetValueRefOrNullRef_ExistingKey_UpdatesValueInPlace()
{
ContiguousMap<string, int> 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<string, int> 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<string, int> 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<string, int> map = [];

Assert.ThrowsExactly<ArgumentNullException>(() => map.GetValueRefOrNullRef(null!));
}
}
19 changes: 19 additions & 0 deletions Containers.Test/ContiguousSetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>).GetMethod(nameof(ContiguousSet<>.AsSpan))!.ReturnType;

Assert.AreEqual(typeof(ReadOnlySpan<int>), returnType);
}

[TestMethod]
public void AsSpan_ReturnsElementsInInsertionOrder()
{
ContiguousSet<int> set = [3, 1, 2];

ReadOnlySpan<int> span = set.AsSpan();

Assert.AreSequenceEqual([3, 1, 2], span.ToArray());
}
}
56 changes: 46 additions & 10 deletions Containers/ContiguousMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace ktsu.Containers;

using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;

/// <summary>
/// Represents a generic map/dictionary that maintains key-value pairs in contiguous memory for optimal cache performance.
Expand Down Expand Up @@ -55,24 +56,30 @@ public class ContiguousMap<TKey, TValue>
/// </remarks>
/// <param name="key">The key of the entry.</param>
/// <param name="value">The value of the entry.</param>
public readonly struct Entry(TKey key, TValue value) : IEquatable<Entry>
public struct Entry(TKey key, TValue value) : IEquatable<Entry>
{
/// <summary>
/// The value of the entry. A field rather than an auto-property so the map can hand out a
/// reference to it from <see cref="GetValueRefOrNullRef"/> without exposing the key.
/// </summary>
internal TValue storedValue = value;
Comment thread
matt-edmondson marked this conversation as resolved.

/// <summary>
/// Gets the key of the entry.
/// </summary>
public TKey Key { get; } = key;
public readonly TKey Key { get; } = key;

/// <summary>
/// Gets the value of the entry.
/// </summary>
public TValue Value { get; } = value;
public readonly TValue Value => storedValue;

/// <summary>
/// Determines whether the specified object is equal to the current entry.
/// </summary>
/// <param name="obj">The object to compare with the current entry.</param>
/// <returns>true if the specified object is equal to the current entry; otherwise, false.</returns>
public override bool Equals(object? obj) =>
public override readonly bool Equals(object? obj) =>
obj is Entry other
&& EqualityComparer<TKey>.Default.Equals(Key, other.Key)
&& EqualityComparer<TValue>.Default.Equals(Value, other.Value);
Expand All @@ -82,15 +89,15 @@ obj is Entry other
/// </summary>
/// <param name="other">An entry to compare with this entry.</param>
/// <returns>true if the current entry is equal to the other parameter; otherwise, false.</returns>
public bool Equals(Entry other) =>
public readonly bool Equals(Entry other) =>
EqualityComparer<TKey>.Default.Equals(Key, other.Key)
&& EqualityComparer<TValue>.Default.Equals(Value, other.Value);

/// <summary>
/// Returns the hash code for this entry.
/// </summary>
/// <returns>A 32-bit signed integer hash code.</returns>
public override int GetHashCode()
public override readonly int GetHashCode()
{
#if NETSTANDARD2_0
int hash = 17;
Expand Down Expand Up @@ -557,14 +564,43 @@ public void TrimExcess()
}

/// <summary>
/// Gets a span representing the entries in the map.
/// Gets a read-only span representing the entries in the map.
/// </summary>
/// <returns>A span over the map's entries.</returns>
/// <returns>A read-only span over the map's entries.</returns>
/// <remarks>
/// 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
/// <see cref="GetValueRefOrNullRef"/> to update a value in place.
/// </remarks>
public ReadOnlySpan<Entry> AsSpan() => new(items, 0, Count);

/// <summary>
/// Gets a reference to the value stored for the specified key, or a null reference if the key is not in the map.
/// </summary>
/// <param name="key">The key whose value to get.</param>
/// <returns>
/// A reference to the stored value, which can be read or written in place, or a null reference when
/// <paramref name="key"/> is not found. Test for the latter with <c>Unsafe.IsNullRef</c>.
/// </returns>
/// <exception cref="ArgumentNullException">Thrown when key is null.</exception>
/// <remarks>
/// 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
/// <c>CollectionsMarshal.GetValueRefOrNullRef</c>.
/// </remarks>
public Span<Entry> 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<TValue>.Empty);
}

/// <summary>
/// Gets a read-only span representing the entries in the map.
Expand Down
9 changes: 5 additions & 4 deletions Containers/ContiguousSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -535,14 +535,15 @@ public void TrimExcess()
}

/// <summary>
/// Gets a span representing the elements in the set.
/// Gets a read-only span representing the elements in the set.
/// </summary>
/// <returns>A span over the set's elements.</returns>
/// <returns>A read-only span over the set's elements.</returns>
/// <remarks>
/// 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.
/// </remarks>
public Span<T> AsSpan() => new(items, 0, Count);
public ReadOnlySpan<T> AsSpan() => new(items, 0, Count);

/// <summary>
/// Gets a read-only span representing the elements in the set.
Expand Down
Loading