diff --git a/src/SharpArena/Collections/ArenaDictionary.cs b/src/SharpArena/Collections/ArenaDictionary.cs
index 80e01fd..4d914ca 100644
--- a/src/SharpArena/Collections/ArenaDictionary.cs
+++ b/src/SharpArena/Collections/ArenaDictionary.cs
@@ -1,4 +1,6 @@
using System.Collections;
+using System.Buffers;
+using System.Text;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
@@ -242,29 +244,125 @@ public bool ContainsKey(TKey key)
}
///
- /// Specialized ContainsKey for ArenaUtf16String using ReadOnlySpan{char} to avoid allocations.
+ /// Specialized ContainsKey for string types using ReadOnlySpan{char} to avoid allocations.
+ /// Supports cross-encoding for ArenaUtf8String.
///
/// The key to locate in the .
/// if the contains an element with the key; otherwise, .
public bool ContainsKey(ReadOnlySpan key)
{
- if (typeof(TKey) != typeof(ArenaUtf16String)) return false;
- CheckAlive();
+ if (typeof(TKey) == typeof(ArenaUtf16String))
+ {
+ CheckAlive();
+
+ uint capacity = (uint)_header->Capacity;
+ int* buckets = _header->Buckets;
+ ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys;
+ uint mask = capacity - 1;
+ uint hash = Hashing.HashString(key);
+ uint index = hash & mask;
+
+ while (true)
+ {
+ int entryIdxPlusOne = buckets[index];
+ if (entryIdxPlusOne == 0) return false;
+ if (keys[entryIdxPlusOne - 1].Equals(key)) return true;
+ index = (index + 1) & mask;
+ }
+ }
- uint capacity = (uint)_header->Capacity;
- int* buckets = _header->Buckets;
- ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys;
- uint mask = capacity - 1;
- uint hash = Hashing.HashString(key);
- uint index = hash & mask;
+ if (typeof(TKey) == typeof(ArenaUtf8String))
+ {
+ CheckAlive();
+ int maxBytes = Encoding.UTF8.GetMaxByteCount(key.Length);
+ byte[]? rented = null;
+ Span buffer = maxBytes <= 512 ? stackalloc byte[512] : (rented = ArrayPool.Shared.Rent(maxBytes));
+ try
+ {
+ int written = Encoding.UTF8.GetBytes(key, buffer);
+ ReadOnlySpan byteKey = buffer.Slice(0, written);
+
+ uint capacity = (uint)_header->Capacity;
+ int* buckets = _header->Buckets;
+ ArenaUtf8String* keys = (ArenaUtf8String*)_header->Keys;
+ uint mask = capacity - 1;
+ uint hash = Hashing.HashUtf8(byteKey);
+ uint index = hash & mask;
+ while (true)
+ {
+ int entryIdxPlusOne = buckets[index];
+ if (entryIdxPlusOne == 0) return false;
+ if (keys[entryIdxPlusOne - 1].Equals(byteKey)) return true;
+ index = (index + 1) & mask;
+ }
+ }
+ finally
+ {
+ if (rented != null) ArrayPool.Shared.Return(rented);
+ }
+ }
- while (true)
+ return false;
+ }
+
+ ///
+ /// Specialized ContainsKey for ArenaUtf8String using ReadOnlySpan{byte} to avoid allocations.
+ ///
+ /// The key to locate in the .
+ /// if the contains an element with the key; otherwise, .
+ public bool ContainsKey(ReadOnlySpan key)
+ {
+ if (typeof(TKey) == typeof(ArenaUtf8String))
{
- int entryIdxPlusOne = buckets[index];
- if (entryIdxPlusOne == 0) return false;
- if (keys[entryIdxPlusOne - 1].Equals(key)) return true;
- index = (index + 1) & mask;
+ CheckAlive();
+ uint capacity = (uint)_header->Capacity;
+ int* buckets = _header->Buckets;
+ ArenaUtf8String* keys = (ArenaUtf8String*)_header->Keys;
+ uint mask = capacity - 1;
+ uint hash = Hashing.HashUtf8(key);
+ uint index = hash & mask;
+ while (true)
+ {
+ int entryIdxPlusOne = buckets[index];
+ if (entryIdxPlusOne == 0) return false;
+ if (keys[entryIdxPlusOne - 1].Equals(key)) return true;
+ index = (index + 1) & mask;
+ }
}
+
+ if (typeof(TKey) == typeof(ArenaUtf16String))
+ {
+ CheckAlive();
+ int maxChars = Encoding.UTF8.GetMaxCharCount(key.Length);
+ char[]? rented = null;
+ Span buffer = maxChars <= 512 ? stackalloc char[512] : (rented = ArrayPool.Shared.Rent(maxChars));
+ try
+ {
+ int written = Encoding.UTF8.GetChars(key, buffer);
+ ReadOnlySpan charKey = buffer.Slice(0, written);
+
+ uint capacity = (uint)_header->Capacity;
+ int* buckets = _header->Buckets;
+ ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys;
+ uint mask = capacity - 1;
+ uint hash = Hashing.HashString(charKey);
+ uint index = hash & mask;
+
+ while (true)
+ {
+ int entryIdxPlusOne = buckets[index];
+ if (entryIdxPlusOne == 0) return false;
+ if (keys[entryIdxPlusOne - 1].Equals(charKey)) return true;
+ index = (index + 1) & mask;
+ }
+ }
+ finally
+ {
+ if (rented != null) ArrayPool.Shared.Return(rented);
+ }
+ }
+
+ return false;
}
///
@@ -288,38 +386,145 @@ public bool TryGetValue(TKey key, out TValue value)
}
///
- /// Specialized TryGetValue for ArenaUtf16String using ReadOnlySpan{char} to avoid allocations.
+ /// Specialized TryGetValue for string types using ReadOnlySpan{char} to avoid allocations.
+ /// Supports cross-encoding for ArenaUtf8String.
///
/// The key whose value to get.
/// When this method returns, the value associated with the specified key, if the key is found; otherwise, the default value for the type of the parameter. This parameter is passed uninitialized.
/// if the contains an element with the specified key; otherwise, .
public bool TryGetValue(ReadOnlySpan key, out TValue value)
{
- if (typeof(TKey) != typeof(ArenaUtf16String))
+ if (typeof(TKey) == typeof(ArenaUtf16String))
{
- value = default;
- return false;
+ CheckAlive();
+
+ uint capacity = (uint)_header->Capacity;
+ int* buckets = _header->Buckets;
+ ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys;
+ TValue* values = (TValue*)_header->Values;
+ uint mask = capacity - 1;
+ uint hash = Hashing.HashString(key);
+ uint index = hash & mask;
+
+ while (true)
+ {
+ int entryIdxPlusOne = buckets[index];
+ if (entryIdxPlusOne == 0) break;
+ if (keys[entryIdxPlusOne - 1].Equals(key))
+ {
+ value = values[entryIdxPlusOne - 1];
+ return true;
+ }
+ index = (index + 1) & mask;
+ }
}
- CheckAlive();
- uint capacity = (uint)_header->Capacity;
- int* buckets = _header->Buckets;
- ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys;
- TValue* values = (TValue*)_header->Values;
- uint mask = capacity - 1;
- uint hash = Hashing.HashString(key);
- uint index = hash & mask;
+ if (typeof(TKey) == typeof(ArenaUtf8String))
+ {
+ CheckAlive();
+ int maxBytes = Encoding.UTF8.GetMaxByteCount(key.Length);
+ byte[]? rented = null;
+ Span buffer = maxBytes <= 512 ? stackalloc byte[512] : (rented = ArrayPool.Shared.Rent(maxBytes));
+ try
+ {
+ int written = Encoding.UTF8.GetBytes(key, buffer);
+ ReadOnlySpan byteKey = buffer.Slice(0, written);
+
+ uint capacity = (uint)_header->Capacity;
+ int* buckets = _header->Buckets;
+ ArenaUtf8String* keys = (ArenaUtf8String*)_header->Keys;
+ TValue* values = (TValue*)_header->Values;
+ uint mask = capacity - 1;
+ uint hash = Hashing.HashUtf8(byteKey);
+ uint index = hash & mask;
+ while (true)
+ {
+ int entryIdxPlusOne = buckets[index];
+ if (entryIdxPlusOne == 0) break;
+ if (keys[entryIdxPlusOne - 1].Equals(byteKey))
+ {
+ value = values[entryIdxPlusOne - 1];
+ return true;
+ }
+ index = (index + 1) & mask;
+ }
+ }
+ finally
+ {
+ if (rented != null) ArrayPool.Shared.Return(rented);
+ }
+ }
- while (true)
+ value = default;
+ return false;
+ }
+
+ ///
+ /// Specialized TryGetValue for ArenaUtf8String using ReadOnlySpan{byte} to avoid allocations.
+ ///
+ /// The key whose value to get.
+ /// When this method returns, the value associated with the specified key, if the key is found; otherwise, the default value for the type of the parameter. This parameter is passed uninitialized.
+ /// if the contains an element with the specified key; otherwise, .
+ public bool TryGetValue(ReadOnlySpan key, out TValue value)
+ {
+ if (typeof(TKey) == typeof(ArenaUtf8String))
{
- int entryIdxPlusOne = buckets[index];
- if (entryIdxPlusOne == 0) break;
- if (keys[entryIdxPlusOne - 1].Equals(key))
+ CheckAlive();
+ uint capacity = (uint)_header->Capacity;
+ int* buckets = _header->Buckets;
+ ArenaUtf8String* keys = (ArenaUtf8String*)_header->Keys;
+ TValue* values = (TValue*)_header->Values;
+ uint mask = capacity - 1;
+ uint hash = Hashing.HashUtf8(key);
+ uint index = hash & mask;
+ while (true)
{
- value = values[entryIdxPlusOne - 1];
- return true;
+ int entryIdxPlusOne = buckets[index];
+ if (entryIdxPlusOne == 0) break;
+ if (keys[entryIdxPlusOne - 1].Equals(key))
+ {
+ value = values[entryIdxPlusOne - 1];
+ return true;
+ }
+ index = (index + 1) & mask;
+ }
+ }
+
+ if (typeof(TKey) == typeof(ArenaUtf16String))
+ {
+ CheckAlive();
+ int maxChars = Encoding.UTF8.GetMaxCharCount(key.Length);
+ char[]? rented = null;
+ Span buffer = maxChars <= 512 ? stackalloc char[512] : (rented = ArrayPool.Shared.Rent(maxChars));
+ try
+ {
+ int written = Encoding.UTF8.GetChars(key, buffer);
+ ReadOnlySpan charKey = buffer.Slice(0, written);
+
+ uint capacity = (uint)_header->Capacity;
+ int* buckets = _header->Buckets;
+ ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys;
+ TValue* values = (TValue*)_header->Values;
+ uint mask = capacity - 1;
+ uint hash = Hashing.HashString(charKey);
+ uint index = hash & mask;
+
+ while (true)
+ {
+ int entryIdxPlusOne = buckets[index];
+ if (entryIdxPlusOne == 0) break;
+ if (keys[entryIdxPlusOne - 1].Equals(charKey))
+ {
+ value = values[entryIdxPlusOne - 1];
+ return true;
+ }
+ index = (index + 1) & mask;
+ }
+ }
+ finally
+ {
+ if (rented != null) ArrayPool.Shared.Return(rented);
}
- index = (index + 1) & mask;
}
value = default;
diff --git a/src/SharpArena/Collections/Hashing.cs b/src/SharpArena/Collections/Hashing.cs
index af5070a..15cf4b7 100644
--- a/src/SharpArena/Collections/Hashing.cs
+++ b/src/SharpArena/Collections/Hashing.cs
@@ -18,8 +18,7 @@ public static uint Hash(T value) where T : unmanaged
return (uint)Unsafe.As(ref value).GetHashCode();
}
- var span = new ReadOnlySpan(&value, sizeof(T));
- return (uint)XxHash3.HashToUInt64(span);
+ return (uint)EqualityComparer.Default.GetHashCode(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
diff --git a/tests/SharpArena.Tests/Collections/ArenaDictionaryTests.cs b/tests/SharpArena.Tests/Collections/ArenaDictionaryTests.cs
index e688905..19ea570 100644
--- a/tests/SharpArena.Tests/Collections/ArenaDictionaryTests.cs
+++ b/tests/SharpArena.Tests/Collections/ArenaDictionaryTests.cs
@@ -1,3 +1,5 @@
+using System.Runtime.InteropServices;
+using System.Text;
using FluentAssertions;
using SharpArena.Allocators;
using SharpArena.Collections;
@@ -5,12 +7,31 @@
namespace SharpArena.Tests.Collections;
+[StructLayout(LayoutKind.Sequential)]
+public struct PaddedKey : IEquatable
+{
+ public byte A;
+ public int B; // padding exists between A and B
+ public bool Equals(PaddedKey other) => A == other.A && B == other.B;
+ public override int GetHashCode() => HashCode.Combine(A, B);
+}
+
public class ArenaDictionaryTests : IDisposable
{
private readonly ArenaAllocator _arena = new();
public void Dispose() => _arena.Dispose();
+ [Fact]
+ public void PaddedStructHashing_IgnoresPadding()
+ {
+ var dict = new ArenaDictionary(_arena);
+
+ var key1 = new PaddedKey { A = 1, B = 2 };
+ dict.Add(key1, 42);
+ dict.ContainsKey(key1).Should().BeTrue();
+ }
+
[Fact]
public void Add_NewEntry_IncrementsCountAndEnablesLookup()
{
@@ -110,6 +131,131 @@ public void ArenaUtf16String_AsKey_WorksCorrectly()
dict.ContainsKey(ArenaUtf16String.Clone("key3", _arena)).Should().BeFalse();
}
+ [Fact]
+ public void ContainsKey_Utf8ByteSpan_Works()
+ {
+ var dict = new ArenaDictionary(_arena);
+ var key = ArenaUtf8String.Clone("test", _arena);
+ dict.Add(key, 123);
+
+ ReadOnlySpan query = "test"u8;
+ dict.ContainsKey(query).Should().BeTrue();
+ }
+
+ [Fact]
+ public void TryGetValue_Utf8ByteSpan_Works()
+ {
+ var dict = new ArenaDictionary(_arena);
+ var key = ArenaUtf8String.Clone("test", _arena);
+ dict.Add(key, 123);
+
+ ReadOnlySpan query = "test"u8;
+ dict.TryGetValue(query, out var val).Should().BeTrue();
+ val.Should().Be(123);
+ }
+
+ [Fact]
+ public void ContainsKey_CharSpan_OnUtf8Dict_Works()
+ {
+ var dict = new ArenaDictionary(_arena);
+ dict.Add(ArenaUtf8String.Clone("hello", _arena), 99);
+
+ dict.ContainsKey("hello".AsSpan()).Should().BeTrue();
+ }
+
+ [Fact]
+ public void TryGetValue_CharSpan_OnUtf8Dict_Works()
+ {
+ var dict = new ArenaDictionary(_arena);
+ dict.Add(ArenaUtf8String.Clone("hello", _arena), 99);
+
+ dict.TryGetValue("hello".AsSpan(), out var val).Should().BeTrue();
+ val.Should().Be(99);
+ }
+
+ [Fact]
+ public void TryGetValue_LargeCharSpan_OnUtf8Dict_Works()
+ {
+ var dict = new ArenaDictionary(_arena);
+ string largeKey = new string('a', 600);
+ dict.Add(ArenaUtf8String.Clone(largeKey, _arena), 1234);
+
+ dict.TryGetValue(largeKey.AsSpan(), out var val).Should().BeTrue();
+ val.Should().Be(1234);
+ }
+
+ [Fact]
+ public void ContainsKey_ByteSpan_OnUtf16Dict_Works()
+ {
+ var dict = new ArenaDictionary(_arena);
+ dict.Add(ArenaUtf16String.Clone("world", _arena), 77);
+
+ ReadOnlySpan query = "world"u8;
+ dict.ContainsKey(query).Should().BeTrue();
+ }
+
+ [Fact]
+ public void TryGetValue_ByteSpan_OnUtf16Dict_Works()
+ {
+ var dict = new ArenaDictionary(_arena);
+ dict.Add(ArenaUtf16String.Clone("world", _arena), 77);
+
+ ReadOnlySpan query = "world"u8;
+ dict.TryGetValue(query, out var val).Should().BeTrue();
+ val.Should().Be(77);
+ }
+
+ [Fact]
+ public void TryGetValue_LargeByteSpan_OnUtf16Dict_Works()
+ {
+ var dict = new ArenaDictionary(_arena);
+ string largeKey = new string('b', 600);
+ dict.Add(ArenaUtf16String.Clone(largeKey, _arena), 5678);
+
+ ReadOnlySpan query = Encoding.UTF8.GetBytes(largeKey);
+ dict.TryGetValue(query, out var val).Should().BeTrue();
+ val.Should().Be(5678);
+ }
+
+ [Fact]
+ public void ContainsKey_LargeByteSpan_OnUtf16Dict_Works()
+ {
+ var dict = new ArenaDictionary(_arena);
+ string largeKey = new string('c', 600);
+ dict.Add(ArenaUtf16String.Clone(largeKey, _arena), 999);
+
+ ReadOnlySpan query = Encoding.UTF8.GetBytes(largeKey);
+ dict.ContainsKey(query).Should().BeTrue();
+ }
+
+ [Fact]
+ public void StressTest_CrossEncoding_LargeStrings()
+ {
+ var dictUtf8 = new ArenaDictionary(_arena);
+ var dictUtf16 = new ArenaDictionary(_arena);
+
+ for (int i = 0; i < 100; i++)
+ {
+ string key = new string((char)('a' + (i % 26)), 513 + i);
+ dictUtf8.Add(ArenaUtf8String.Clone(key, _arena), i);
+ dictUtf16.Add(ArenaUtf16String.Clone(key, _arena), i);
+ }
+
+ for (int i = 0; i < 100; i++)
+ {
+ string key = new string((char)('a' + (i % 26)), 513 + i);
+
+ // Char -> Utf8
+ dictUtf8.TryGetValue(key.AsSpan(), out var val1).Should().BeTrue();
+ val1.Should().Be(i);
+
+ // Byte -> Utf16
+ ReadOnlySpan keyBytes = Encoding.UTF8.GetBytes(key);
+ dictUtf16.TryGetValue(keyBytes, out var val2).Should().BeTrue();
+ val2.Should().Be(i);
+ }
+ }
+
[Fact]
public void Clear_ResetsCountAndLookups()
{