Description
UPDATED 1/22/2024 by @stephentoub
Option 1 (recommended)
namespace System.Collections.Generic
{
public static class CollectionExtensions
{
+ public static Dictionary<TKey, TValue>.MappedLookup<TMappedKey> GetMappedLookup<TKey, TValue, TMappedKey>(
+ this Dictionary<TKey, TValue> dictionary)
+ where TKey : notnull where TMappedKey : allow ref struct;
+ public static bool TryGetMappedLookup<TKey, TValue, TMappedKey>(
+ this Dictionary<TKey, TValue> dictionary,
+ out Dictionary<TKey, TValue>.MappedLookup<TMappedKey> lookup)
+ where TKey : notnull where TMappedKey : allow ref struct;
+ public static HashSet<T>.MappedLookup<TMapped> GetMappedLookup<TMapped>(
+ this HashSet<T> set)
+ where TMapped : allow ref struct;
+ public static bool TryGetMappedLookup(
+ this HashSet<T> set, out HashSet<T>.MappedLookup<TMapped> lookup)
+ where TMapped : allow ref struct;
}
public class Dictionary<TKey, TValue>
{
+ public readonly struct MappedLookup<TMappedKey> where TMappedKey : allow ref struct
+ {
+ public bool ContainsKey(TMappedKey key);
+ public bool TryAdd(TMappedKey key, TValue value);
+ public bool TryGetValue(TMappedKey key, [MaybeNullWhen(false)] out TValue value);
+ public bool TryGetValue<TKey, TValue, TMappedKey>(TMappedKey key, [NotNullWhen(true)] out TKey? actualKey, [MaybeNullWhen(false)] out TValue value);
+ public bool Remove(TMappedKey key);
+ public bool Remove(TMappedKey key, [MaybeNullWhen(false)] out TValue value));
+ }
}
public class HashSet<T>
{
+ public readonly struct MappedLookup<TMapped>
+ {
+ public bool Add(TMapped item);
+ public bool Contains(TMapped item);
+ public bool Remove(TMapped item);
+ public bool TryGetValue(TMapped equalValue, [MaybeNullWhen(false)] out T actualValue);
+ }
}
}
namespace System.Runtime.InteropServices
{
public static class CollectionsMarshal
{
+ public static ref TValue? GetValueRefOrAddDefault<TKey, TValue, TMappedKey>(
+ Dictionary<TKey, TValue>.MappedLookup<TMappedKey> dictionary,
+ TMappedKey key, out bool exists)
+ where TKey : notnull where TMappedKey : allow ref struct;
+ public static ref TValue GetValueRefOrNullRef<TKey, TValue, TMappedKey>(
+ Dictionary<TKey, TValue>.MappedLookup<TMappedKey> dictionary,
+ TMappedKey key)
+ where TKey : notnull where TMappedKey : allow ref struct;
}
}
namespace System.Collections.Concurrent
{
public class ConcurrentDictionary<TKey, TValue>
+ {
+ public ConcurrentDictionary<TKey, TValue>.MappedLookup<TMappedKey> GetMappedLookup<TMappedKey>()
+ where TMappedKey : allow ref struct;
+ public bool TryGetMappedLookup<TMappedKey>(
+ out ConcurrentDictionary<TKey, TValue>.MappedLookup<TMappedKey> lookup)
+ where TMappedKey : allow ref struct;
+ public readonly struct MappedLookup<TMappedKey>
+ {
+ public bool ContainsKey<TKey, TValue, TMappedKey>(TMappedKey key);
+ public bool TryAdd<TKey, TValue, TMappedKey>(TMappedKey key, TValue value);
+ public bool TryGetValue<TKey, TValue, TMappedKey>(TMappedKey key, [MaybeNullWhen(false)] out TValue value);
+ public bool TryGetValue<TKey, TValue, TMappedKey>(TMappedKey key, [NotNullWhen(true)] out TKey? actualKey, [MaybeNullWhen(false)] out TValue value);
+ public bool TryRemove<TKey, TValue, TMappedKey>(TMappedKey key, [MaybeNullWhen(false)] out TValue value);
+ public bool TryRemove<TKey, TValue, TMappedKey>(TMappedKey key, [NotNullWhen(true) out TKey? actualKey, [MaybeNullWhen(false)] out TValue value);
+ }
+ }
}
namespace System.Collections.Immutable
{
public class FrozenDictionary<TKey, TValue>
{
+ public FrozenDictionary<TKey, TValue>.MappedLookup<TMappedKey> GetMappedLookup<TMappedKey>()
+ where TMappedKey : allow ref struct;
+ public bool TryGetMappedLookup<TMappedKey>(
+ out FrozenDictionary<TKey, TValue>.MappedLookup<TMappedKey> lookup)
+ where TMappedKey : allow ref struct;
+ public readonly struct MappedLookup<TMappedKey>
+ {
+ public bool ContainsKey<TKey, TValue, TMappedKey>(TMappedKey key);
+ public bool TryGetValue<TKey, TValue, TMappedKey>(TMappedKey key, [MaybeNullWhen(false)] out TValue value);
+ public bool TryGetValue<TKey, TValue, TMappedKey>(TMappedKey key, [NotNullWhen(true)] out TKey? actualKey, [MaybeNullWhen(false)] out TValue value);
+ }
}
public class FrozenSet<T>
{
+ public FrozenSet<T>.MappedLookup<TMapped> GetMappedLookup<TMapped>()
+ where TMappedKey : allow ref struct;
+ public bool TryGetMappedLookup<TMappedKey>(
+ out FrozenSet<T>.MappedLookup<TMapped> lookup)
+ where TMappedKey : allow ref struct;
+ public readonly struct MappedLookup<TMapped>
+ {
+ public bool Contains(TMapped item);
+ public bool TryGetValue(TMapped equalValue, [MaybeNullWhen(false)] out T actualValue);
+ }
}
}
Pros of Option 1:
- Keeps refstruct-based APIs separated out
- Enables caching any lookup costs
- Makes a "Try" method more tolerable, which is important in cases where you're handed a dictionary, have a span you want to use for lookup, and want to use the span if possible or else ToString or equivalent and use that
Cons of Option 1:
- Poor type inference on With
- Because of the above, makes working with anonymous TValues complicated
Option 2
namespace System.Collections.Generic
{
public static class CollectionExtensions
{
+ public static bool ContainsKey<TKey, TValue, TMappedKey>(
+ this Dictionary<TKey, TValue> dictionary, TMappedKey key)
+ where TKey : notnull where TMappedKey : allow ref struct
+ public static bool Remove<TKey, TValue, TMappedKey>(
+ this Dictionary<TKey, TValue> dictionary, TMappedKey key)
+ where TKey : notnull where TMappedKey : allow ref struct
+ public static bool Remove<TKey, TValue, TMappedKey>(
+ this Dictionary<TKey, TValue> dictionary, TMappedKey key, [MaybeNullWhen(false)] out TValue value))
+ where TKey : notnull where TMappedKey : allow ref struct
+ public static bool TryAdd<TKey, TValue, TMappedKey>(
+ this Dictionary<TKey, TValue> dictionary, TMappedKey key, TValue value)
+ where TKey : notnull where TMappedKey : allow ref struct
+ public static bool TryGetValue<TKey, TValue, TMappedKey>(
+ this Dictionary<TKey, TValue> dictionary, TMappedKey key, [MaybeNullWhen(false)] out TValue value)
+ where TKey : notnull where TMappedKey : allow ref struct
+ public static bool TryGetValue<TKey, TValue, TMappedKey>(
+ this Dictionary<TKey, TValue> dictionary, TMappedKey key, [NotNullWhen(true)] out TKey? actualKey, [MaybeNullWhen(false)] out TValue value)
+ where TKey : notnull where TMappedKey : allow ref struct
+ public static bool Add<T, TMapped>(this HashSet<T> set, TMapped item) where TMapped : allow ref struct;
+ public static bool Contains<T, TMapped>(this HashSet<T> set, TMapped item) where TMapped : allow ref struct;
+ public static bool Remove<T, TMapped>(this HashSet<T> set, TMapped item) where TMapped : allow ref struct;
+ public static bool TryGetValue<T, TMapped>(this HashSet<T> set, TMapped equalValue, [MaybeNullWhen(false)] out T actualValue) where TMapped : allow ref struct;
}
}
namespace System.Runtime.InteropServices
{
public static class CollectionsMarshal
{
+ public static ref TValue? GetValueRefOrAddDefault<TKey, TValue, TMappedKey>(Dictionary<TKey, TValue> dictionary, TMappedKey key, out bool exists) where TKey : notnull where TMappedKey : allow ref struct;
+ public static ref TValue GetValueRefOrNullRef<TKey, TValue, TMappedKey>(Dictionary<TKey, TValue> dictionary, TMappedKey key) where TKey : notnull where TMappedKey : allow ref struct;
}
}
namespace System.Collections.Concurrent
{
+ public static class ConcurrentCollectionExtensions
+ {
+ public static bool ContainsKey<TKey, TValue, TMappedKey>(
+ this ConcurrentDictionary<TKey, TValue> dictionary, TMappedKey key)
+ where TKey : notnull;
+ public static bool TryAdd<TKey, TValue, TMappedKey>(
+ this ConcurrentDictionary<TKey, TValue> dictionary, TMappedKey key, TValue value)
+ where TKey : not null;
+ public static bool TryGetValue<TKey, TValue, TMappedKey>(
+ this ConcurrentDictionary<TKey, TValue> dictionary, TMappedKey key, [MaybeNullWhen(false)] out TValue value)
+ where TKey : not null;
+ public static bool TryGetValue<TKey, TValue, TMappedKey>(
+ this ConcurrentDictionary<TKey, TValue> dictionary, TMappedKey key, [NotNullWhen(true)] out TKey? actualKey, [MaybeNullWhen(false)] out TValue value)
+ where TKey : not null where TMappedKey : allow ref struct;
+ public static bool TryRemove<TKey, TValue, TMappedKey>(
+ this ConcurrentDictionary<TKey, TValue> dictionary, TMappedKey key, [MaybeNullWhen(false)] out TValue value)
+ where TKey : notnull;
+ }
}
namespace System.Collections.Immutable
{
public class FrozenDictionary<TKey, TValue>
{
+ public bool ContainsKey<TMappedKey>(TMappedKey key) where TKey : notnull where TMappedKey : allow ref struct;
+ public bool TryGetValue<TMappedKey>(TMappedKey key, [NotNullWhen(true)] out TKey? actualKey, [MaybeNullWhen(false)] out TValue value) where TKey : not null where TMappedKey : allow ref struct;
+ public bool TryGetValue<TMappedKey>(TMappedKey key, [MaybeNullWhen(false)] out TValue value) where TKey : not null where TMappedKey : allow ref struct;
}
public class FrozenSet<T>
{
+ public bool Contains<TMapped>(TMapped item);
+ public bool TryGetValue<TMapped>(TMapped equalValue, [MaybeNullWhen(false)] out T actualValue);
}
}
Pros of Option 2:
- Fewer types
- Can index directly into the collection with the key without needing the separate lookup
- Inference "just works"
Cons of Option 2:
- More expensive at runtime; every call does a type check
- Worse usability when you have something that casts to
ReadOnlySpan<T>
, e.g. aSpan<T>
; calls bind to the instance members if not exact match to the extensions - Would be a lot more surface area (and confusing in some cases) to add Try variants where false means the dictionary doesn't support the mapped lookup type
Regardless of approach:
namespace System.Collections.Generic
{
// implemented by all string-related comparers, e.g. the comparer instances returned from StringComparer.Ordinal, EqualityComparer<string>.Default, etc.
+ public interface IMappedEqualityComparer<TMapped, T> where TMapped : allow ref struct
+ {
+ bool Equals(TMapped mapped, T other);
+ int GetHashCode(TMapped span);
+ TKey Map(TMapped mapped);
+ }
}
The above is based on the assumption that we will be getting a ref struct anti-constraint in for .NET 9 / C# 13. If that ends up not happening, this will be revamped to look almost the same, except using ReadOnlySpan<TKeyElement>
instead of TMappedKey
.
UPDATED 1/3/2024 by @stephentoub.
namespace System.Collections.Generic
{
// implemented by all string-related comparers, e.g. the comparer instances
// returned from StringComparer.Ordinal, EqualityComparer<string>.Default, etc.
+ public interface IMappedSpanEqualityComparer<TSpanElement, T>
+ {
+ bool Equals(ReadOnlySpan<TSpanElement> span, T other);
+ int GetHashCode(ReadOnlySpan<TSpanElement> span);
+ T Map(ReadOnlySpan<TSpanElement> span);
+ }
public static class CollectionExtensions
{
+ public static bool ContainsKey<TKey, TValue, TSpanElement>(this Dictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key) where TKey : notnull;
+ public static bool Remove<TKey, TValue, TSpanElement>(this Dictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key) where TKey : notnull;
+ public static bool Remove<TKey, TValue, TSpanElement>(this Dictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key, [MaybeNullWhen(false)] out TValue value) where TKey : notnull;
+ public static bool TryAdd<TKey, TValue, TSpanElement>(this Dictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key, TValue value) where TKey : not null;
+ public static bool TryGetValue<TKey, TValue, TSpanElement>(this Dictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key, [MaybeNullWhen(false)] out TValue value) where TKey : notnull;
+ public static bool TryGetValue<TKey, TValue, TSpanElement>(this Dictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key, [NotNullWhen(true)] out TKey? actualKey, [MaybeNullWhen(false)] out TValue value) where TKey : notnull;
+ public static bool Add<T, TSpanElement>(this HashSet<T> set, ReadOnlySpan<TSpanElement> item);
+ public static bool Contains<T, TSpanElement>(this HashSet<T> set, ReadOnlySpan<TSpanElement> item);
+ public static bool Remove<T, TSpanElement>(this HashSet<T> set, ReadOnlySpan<TSpanElement> item);
+ public static bool TryGetValue<T, TSpanElement>(this HashSet<T> set, ReadOnlySpan<TSpanElement> equalValue, [MaybeNullWhen(false)] out T actualValue);
}
}
namespace System.Runtime.InteropServices
{
public static class CollectionsMarshal
{
+ public static ref TValue? GetValueRefOrAddDefault<TKey, TValue, TSpanElement>(Dictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key, out bool exists) where TKey : notnull;
+ public static ref TValue GetValueRefOrNullRef<TKey, TValue, TSpanElement>(Dictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key) where TKey : notnull;
}
}
namespace System.Collections.Concurrent
{
+ public static class ConcurrentCollectionExtensions
+ {
+ public static bool ContainsKey<TKey, TValue, TSpanElement>(this ConcurrentDictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key) where TKey : notnull;
+ public static bool TryAdd<TKey, TValue, TSpanElement>(this ConcurrentDictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key, TValue value) where TKey : not null;
+ public static bool TryGetValue<TKey, TValue, TSpanElement>(this ConcurrentDictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key, [MaybeNullWhen(false)] out TValue value) where TKey : not null;
+ public static bool TryRemove<TKey, TValue, TSpanElement>(this ConcurrentDictionary<TKey, TValue> dictionary, ReadOnlySpan<TSpanElement> key, [MaybeNullWhen(false)] out TValue value) where TKey : notnull;
+ }
}
If .NET 9 and C# 13 and up supporting ref struct constraints, we would tweak these APIs to instead take a TMappingKey : ref struct
rather than a ReadOnlySpan<TElement>
. See #27229 (comment) for more details.
Open issues:
- Name of the interface.
- Name of the element generic method parameter.
- Extension methods vs instance methods. We could make these methods generic methods on the target type instead. Should we? Are we still concerned about lots of new methods on a type like Dictionary (e.g. asm code bloat)? Any concerns about (non-virtual) generic methods on a generic type?
- Do we want Span overloads in addition to ReadOnlySpan overloads? If we keep these as extension methods, you have to explicitly cast to ReadOnlySpan if you have a span, or else the compiler will try and fail to use the instance methods.
- Any other collection types really important to support initially?
Moved from corefxlab#2438 as the proposed changes could not be implemented in a separate library.
Motivation
I would like to perform dictionary lookups using a ReadOnlySpan<char>
value on a string keyed dictionary without having to allocate a string
.
There are two different design approaches proposed, add extension methods to Dictionary<string, TValue>
or add a new Type .
Extension Method Proposed API
namespace System.Collections.Generic {
+ public static class DictionaryExtensions {
+ public static bool ContainsKey<TValue>(this Dictionary<string, TValue> dictionary, ReadOnlySpan<char> key);
+ public static bool Remove<TValue>(this Dictionary<string, TValue> dictionary, ReadOnlySpan<char> key);
+ public static bool TryGetValue<TValue>(this Dictionary<string, TValue> dictionary, ReadOnlySpan<char> key, out TValue value);
+ public static T GetValue<TValue>(this Dictionary<string, TValue> dictionary, ReadOnlySpan<char> key);
+ }
}
namespace System {
public abstract class StringComparer : IComparer<string>, IEqualityComparer<string>, IComparer, IEqualityComparer {
+ public virtual bool Equals(ReadOnlySpan<char> x, ReadOnlySpan<char> y);
+ public virtual int GetHashCode(ReadOnlySpan<char> obj);
}
public sealed class CultureAwareComparer : StringComparer {
+ public override bool Equals(ReadOnlySpan<char> x, ReadOnlySpan<char> y);
+ public override int GetHashCode(ReadOnlySpan<char> obj);
}
public class OrdinalComparer : StringComparer {
+ public override bool Equals(ReadOnlySpan<char> x, ReadOnlySpan<char> y);
+ public override int GetHashCode(ReadOnlySpan<char> obj);
}
}
Implementation Details
- Internal details of
Dictionary<TKey, TValue>
would need to be exposed with theinternal
modifier to implement theReadOnlySpan<char>
overloads. - Only
StringComparer
s that are passed into the constructor would be able to be optimized to not allocate astring
. - The default implementation of the new virtual methods on
StringComparer
would convert theReadOnlySpan<char>
to astring
and use thestring
methods instead.
New Type Proposed API
namespace System.Collections.Specialized {
+ public class StringKeyedDictionary<TValue> : Dictionary<string, TValue> {
+ public StringKeyedDictionary();
+ public StringKeyedDictionary(StringComparer comparer);
+ public StringKeyedDictionary(int capacity);
+ public StringKeyedDictionary(int capacity, StringComparer comparer);
+ public StringKeyedDictionary(IDictionary<string, TValue> dictionary);
+ public StringKeyedDictionary(IDictionary<string, TValue> dictionary, StringComparer comparer);
+ public StringKeyedDictionary(IEnumerable<KeyValuePair<string, TValue>> collection);
+ public StringKeyedDictionary(IEnumerable<KeyValuePair<string, TValue>> collection, StringComparer comparer);
+ public new StringComparer Comparer { get; }
+ public T this[ReadOnlySpan<char> key] { get; }
+ public bool ContainsKey(ReadOnlySpan<char> key);
+ public bool Remove(ReadOnlySpan<char> key);
+ public bool TryGetValue(ReadOnlySpan<char> key, out TValue value);
+ }
}
namespace System {
public abstract class StringComparer : IComparer<string>, IEqualityComparer<string>, IComparer, IEqualityComparer {
+ public virtual bool Equals(ReadOnlySpan<char> x, ReadOnlySpan<char> y);
+ public virtual int GetHashCode(ReadOnlySpan<char> obj);
}
public sealed class CultureAwareComparer : StringComparer {
+ public override bool Equals(ReadOnlySpan<char> x, ReadOnlySpan<char> y);
+ public override int GetHashCode(ReadOnlySpan<char> obj);
}
public class OrdinalComparer : StringComparer {
+ public override bool Equals(ReadOnlySpan<char> x, ReadOnlySpan<char> y);
+ public override int GetHashCode(ReadOnlySpan<char> obj);
}
}
Implementation Details
- Internal details of
Dictionary<TKey, TValue>
would need to be exposed with theprivate protected
modifier to implement theReadOnlySpan<char>
overloads. - The default implementation of the new virtual methods on
StringComparer
would convert the span to a string and use the string methods instead. - A
null
comparer passed to the constructor would default toStringComparer.Ordinal
.
Possible Addition
While not specifically needed for this request a ReadOnlySpan<char>
Compare
overload should probably be added as well while we're updating StringComparer
.
namespace System {
public abstract class StringComparer : IComparer, IEqualityComparer, IComparer<string>, IEqualityComparer<string> {
+ public virtual int Compare(ReadOnlySpan<char> x, ReadOnlySpan<char> y);
}
public sealed class CultureAwareComparer : StringComparer {
+ public override int Compare(ReadOnlySpan<char> x, ReadOnlySpan<char> y);
}
public class OrdinalComparer : StringComparer {
+ public override int Compare(ReadOnlySpan<char> x, ReadOnlySpan<char> y);
}
}
Open Questions
- Should the additional
ReadOnlySpan<char>
Compare
overload be included? - Should
NonRandomizedStringEqualityComparer
now derive fromStringComparer
in order to be supported? - Should a
TryAdd
overload be added as it has the possibility of not requiring to be converted to a string if an equal key is found?
Discussion Summary.
We have two different design approaches, add a new Type or add extension methods to Dictionary<string, TValue>
.
Here's a comparison of the pro's and con's of the different design approaches.
Extension Methods | StringKeyedDictionary | |
---|---|---|
Pros | No new type introduced. | No performance penalty due to casting the comparer. |
Existing code exposing a string keyed Dictionary needs no changes to use which is helpful when they can't easily be changed. |
Indexer support. | |
More scalable | ||
Cons | Performance penalty of casting the comparer. | New type introduced meaning an additional staple of the .NET ecosystem to know. |
No indexer support, must use a GetValue method instead. |
Existing code that exposes a string keyed Dictionary may not easily be changed to using the new type. |
|
Dictionary internals exposed with the internal modifier. |
Dictionary internals exposed with the private protected modifier. |
|
Somewhat opaque to user as to knowing the comparer must derive from StringComparer . |
Updates
- Updated namespaces back to
System.Collections.Specialized
. - Changed extension class name to
DictionaryExtensions
fromStringKeyedDictionaryExtensions
. - Removed proposed
IStringEqualityComparer
interface and instead relies on comparer deriving fromStringComparer
. - Reordered design approaches to better promote my preference. 😄