Skip to content
Open
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
7 changes: 4 additions & 3 deletions Containers.Benchmarks/ContiguousMapBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,12 @@ public int ContiguousMap_SpanAccess()
map.TryAdd(item.Key, item.Value);
}

ReadOnlySpan<int> keysSpan = map.GetKeysSpan();
// AsReadOnlySpan is the zero-copy view; GetKeysSpan copied every key into a new array
ReadOnlySpan<ContiguousMap<int, string>.Entry> entries = map.AsReadOnlySpan();
int sum = 0;
for (int i = 0; i < keysSpan.Length; i++)
for (int i = 0; i < entries.Length; i++)
{
sum += keysSpan[i];
sum += entries[i].Key;
}
return sum;
}
Expand Down
107 changes: 107 additions & 0 deletions Containers.Test/ContiguousMapTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,108 @@ public void CopyTo_ValidArray_CopiesElements()
Assert.AreEqual(default, array[3]); // Unchanged
}

[TestMethod]
public void CopyKeysTo_FillsDestinationInInsertionOrder()
{
ContiguousMap<int, string> map = new() { { 3, "three" }, { 1, "one" }, { 4, "four" }, { 2, "two" } };
int[] destination = new int[4];

map.CopyKeysTo(destination);

int[] expected = [3, 1, 4, 2];
Assert.AreSequenceEqual(expected, destination);
}

[TestMethod]
public void CopyValuesTo_FillsDestinationInInsertionOrder()
{
ContiguousMap<int, string> map = new() { { 3, "three" }, { 1, "one" }, { 4, "four" }, { 2, "two" } };
string[] destination = new string[4];

map.CopyValuesTo(destination);

string[] expected = ["three", "one", "four", "two"];
Assert.AreSequenceEqual(expected, destination);
}

[TestMethod]
public void CopyKeysTo_LongerDestination_LeavesTailUntouched()
{
ContiguousMap<int, string> map = new() { { 7, "seven" }, { 8, "eight" } };
int[] destination = [-1, -1, -1, -1];

map.CopyKeysTo(destination);

int[] expected = [7, 8, -1, -1];
Assert.AreSequenceEqual(expected, destination);
}

[TestMethod]
public void CopyKeysTo_ShorterDestination_ThrowsWithoutWriting()
{
ContiguousMap<int, string> map = new() { { 7, "seven" }, { 8, "eight" } };
int[] destination = [-1];

ArgumentException exception = Assert.ThrowsExactly<ArgumentException>(() => map.CopyKeysTo(destination));

Assert.AreEqual("destination", exception.ParamName);
Assert.AreEqual(-1, destination[0]);
}

[TestMethod]
public void CopyValuesTo_ShorterDestination_ThrowsWithoutWriting()
{
ContiguousMap<int, string> map = new() { { 7, "seven" }, { 8, "eight" } };
string[] destination = ["untouched"];

ArgumentException exception = Assert.ThrowsExactly<ArgumentException>(() => map.CopyValuesTo(destination));

Assert.AreEqual("destination", exception.ParamName);
Assert.AreEqual("untouched", destination[0]);
}

[TestMethod]
public void CopyKeysAndValuesTo_DoNotAllocate()
{
ContiguousMap<int, int> map = [];
for (int i = 0; i < 1_000; i++)
{
map.Add(i, i * 2);
}

int[] keys = new int[map.Count];
int[] values = new int[map.Count];
map.CopyKeysTo(keys);
map.CopyValuesTo(values);

long before = GC.GetAllocatedBytesForCurrentThread();
map.CopyKeysTo(keys);
map.CopyValuesTo(values);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;

Assert.AreEqual(0, allocated);
Assert.AreEqual(999, keys[999]);
Assert.AreEqual(1_998, values[999]);
}

[TestMethod]
[DataRow(nameof(ContiguousMap<,>.GetKeysSpan), nameof(ContiguousMap<,>.CopyKeysTo))]
[DataRow(nameof(ContiguousMap<,>.GetValuesSpan), nameof(ContiguousMap<,>.CopyValuesTo))]
public void CopyingSpanAccessors_AreObsoleteAndPointAtTheirReplacement(string method, string replacement)
{
// The *Span names read as zero-copy views, but every call allocates and copies the whole map
ObsoleteAttribute? obsolete = typeof(ContiguousMap<int, int>).GetMethod(method)!
.GetCustomAttributes(typeof(ObsoleteAttribute), inherit: false)
.Cast<ObsoleteAttribute>()
.SingleOrDefault();

Assert.IsNotNull(obsolete, $"{method} should be marked [Obsolete]");
Assert.Contains(replacement, obsolete.Message!);
Assert.Contains("AsReadOnlySpan", obsolete.Message!);
Assert.IsFalse(obsolete.IsError, "Obsoleting must not break existing callers");
}

#pragma warning disable CS0618 // GetKeysSpan/GetValuesSpan are obsolete but still supported
[TestMethod]
public void GetKeysSpan_ReturnsCorrectSpan()
{
Expand Down Expand Up @@ -442,7 +544,9 @@ public void GetKeysSpan_ReturnsCorrectSpan()
Assert.Contains(3, spanKeys);
Assert.Contains(4, spanKeys);
}
#pragma warning restore CS0618

#pragma warning disable CS0618 // GetKeysSpan/GetValuesSpan are obsolete but still supported
[TestMethod]
public void GetValuesSpan_ReturnsCorrectSpan()
{
Expand Down Expand Up @@ -471,7 +575,9 @@ public void GetValuesSpan_ReturnsCorrectSpan()
Assert.Contains("three", spanValues);
Assert.Contains("four", spanValues);
}
#pragma warning restore CS0618

#pragma warning disable CS0618 // GetKeysSpan/GetValuesSpan are obsolete but still supported
[TestMethod]
public void ContiguousMemoryLayout_OptimalForCachePerformance()
{
Expand Down Expand Up @@ -506,6 +612,7 @@ public void ContiguousMemoryLayout_OptimalForCachePerformance()
}
Assert.HasCount(1000, spanKeys);
}
#pragma warning restore CS0618

[TestMethod]
public void WorksWithCustomTypes()
Expand Down
80 changes: 64 additions & 16 deletions Containers/ContiguousMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -623,39 +623,87 @@
public ReadOnlySpan<Entry> AsReadOnlySpan() => new(items, 0, Count);

/// <summary>
/// Gets a read-only span representing the keys in the map.
/// Copies the keys of the map, in insertion order, into the start of <paramref name="destination"/>.
/// </summary>
/// <returns>A read-only span over the map's keys.</returns>
/// <param name="destination">The span to fill. It must hold at least <see cref="Count"/> elements.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="destination"/> is shorter than <see cref="Count"/>.</exception>
/// <remarks>
/// This method provides direct access to the keys from the contiguous memory, enabling high-performance
/// operations. The keys are extracted from the underlying Entry array.
/// O(n), with no allocation. For indexed access without any copy, use <see cref="AsReadOnlySpan"/> and read
/// <see cref="Entry.Key"/>.
/// </remarks>
public ReadOnlySpan<TKey> GetKeysSpan()
public void CopyKeysTo(Span<TKey> destination)
{
TKey[] keys = new TKey[Count];
if (destination.Length < Count)
{
throw new ArgumentException(
$"The destination span holds {destination.Length} elements, but the map has {Count}.",
nameof(destination)
);
}

for (int i = 0; i < Count; i++)
{
keys[i] = items[i].Key;
destination[i] = items[i].Key;
}
return keys.AsSpan();
}

/// <summary>
/// Gets a read-only span representing the values in the map.
/// Copies the values of the map, in insertion order, into the start of <paramref name="destination"/>.
/// </summary>
/// <returns>A read-only span over the map's values.</returns>
/// <param name="destination">The span to fill. It must hold at least <see cref="Count"/> elements.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="destination"/> is shorter than <see cref="Count"/>.</exception>
/// <remarks>
/// This method provides direct access to the values from the contiguous memory, enabling high-performance
/// operations. The values are extracted from the underlying Entry array.
/// O(n), with no allocation. For indexed access without any copy, use <see cref="AsReadOnlySpan"/> and read
/// <see cref="Entry.Value"/>.
/// </remarks>
public ReadOnlySpan<TValue> GetValuesSpan()
public void CopyValuesTo(Span<TValue> destination)
{
TValue[] values = new TValue[Count];
if (destination.Length < Count)
{
throw new ArgumentException(
$"The destination span holds {destination.Length} elements, but the map has {Count}.",
nameof(destination)
);
}

for (int i = 0; i < Count; i++)
{
values[i] = items[i].Value;
destination[i] = items[i].Value;
}
return values.AsSpan();
}

/// <summary>
/// Copies the keys in the map into a new array and returns a span over it.
/// </summary>
/// <returns>A read-only span over a new array holding the map's keys, in insertion order.</returns>
/// <remarks>
/// Despite the name this is not a view: every call allocates an array and copies all n keys, so calling it
/// inside an indexed loop is O(n²). Use <see cref="CopyKeysTo"/> to fill a buffer you own, or
/// <see cref="AsReadOnlySpan"/> for zero-copy access to <see cref="Entry.Key"/>.
/// </remarks>
[Obsolete("GetKeysSpan allocates and copies every key on each call. Use CopyKeysTo to fill a buffer you own, or AsReadOnlySpan() for zero-copy access to Entry.Key.")]

Check warning on line 684 in Containers/ContiguousMap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 684 in Containers/ContiguousMap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 684 in Containers/ContiguousMap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 684 in Containers/ContiguousMap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 684 in Containers/ContiguousMap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 684 in Containers/ContiguousMap.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Containers&issues=AaGoFgbYe7_wUn51d_Oa&open=AaGoFgbYe7_wUn51d_Oa&pullRequest=145
public ReadOnlySpan<TKey> GetKeysSpan()
{
TKey[] keys = new TKey[Count];
CopyKeysTo(keys);
return keys;
}

/// <summary>
/// Copies the values in the map into a new array and returns a span over it.
/// </summary>
/// <returns>A read-only span over a new array holding the map's values, in insertion order.</returns>
/// <remarks>
/// Despite the name this is not a view: every call allocates an array and copies all n values, so calling it
/// inside an indexed loop is O(n²). Use <see cref="CopyValuesTo"/> to fill a buffer you own, or
/// <see cref="AsReadOnlySpan"/> for zero-copy access to <see cref="Entry.Value"/>.
/// </remarks>
[Obsolete("GetValuesSpan allocates and copies every value on each call. Use CopyValuesTo to fill a buffer you own, or AsReadOnlySpan() for zero-copy access to Entry.Value.")]

Check warning on line 701 in Containers/ContiguousMap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 701 in Containers/ContiguousMap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 701 in Containers/ContiguousMap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 701 in Containers/ContiguousMap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 701 in Containers/ContiguousMap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 701 in Containers/ContiguousMap.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Containers&issues=AaGoFgbYe7_wUn51d_Ob&open=AaGoFgbYe7_wUn51d_Ob&pullRequest=145
public ReadOnlySpan<TValue> GetValuesSpan()
{
TValue[] values = new TValue[Count];
CopyValuesTo(values);
return values;
}

/// <summary>
Expand Down
Loading