Add string keyed dictionary ReadOnlySpan<char> lookup support #27229

Description

@TylerBrinkley

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. a Span<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 the internal modifier to implement the ReadOnlySpan<char> overloads.
  • Only StringComparers that are passed into the constructor would be able to be optimized to not allocate a string.
  • The default implementation of the new virtual methods on StringComparer would convert the ReadOnlySpan<char> to a string and use the string 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 the private protected modifier to implement the ReadOnlySpan<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 to StringComparer.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 from StringComparer 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 MethodsStringKeyedDictionary
ProsNo 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
ConsPerformance 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 from StringKeyedDictionaryExtensions.
  • Removed proposed IStringEqualityComparer interface and instead relies on comparer deriving from StringComparer.
  • Reordered design approaches to better promote my preference. 😄

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
     blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
    }
    } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
    })();
    (function(){
    try {
    var __m = "github.com";
    var __re = new RegExp('^' + "github\\.com" + '
    
    Skip to content

    Add string keyed dictionary ReadOnlySpan<char> lookup support #27229

    Description

    @TylerBrinkley

    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. a Span<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 the internal modifier to implement the ReadOnlySpan<char> overloads.
    • Only StringComparers that are passed into the constructor would be able to be optimized to not allocate a string.
    • The default implementation of the new virtual methods on StringComparer would convert the ReadOnlySpan<char> to a string and use the string 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 the private protected modifier to implement the ReadOnlySpan<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 to StringComparer.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 from StringComparer 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 MethodsStringKeyedDictionary
    ProsNo 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
    ConsPerformance 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 from StringKeyedDictionaryExtensions.
    • Removed proposed IStringEqualityComparer interface and instead relies on comparer deriving from StringComparer.
    • Reordered design approaches to better promote my preference. 😄

    Metadata

    Metadata

    Assignees

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
      Skip to content

      Add string keyed dictionary ReadOnlySpan<char> lookup support #27229

      Description

      @TylerBrinkley

      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. a Span<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 the internal modifier to implement the ReadOnlySpan<char> overloads.
      • Only StringComparers that are passed into the constructor would be able to be optimized to not allocate a string.
      • The default implementation of the new virtual methods on StringComparer would convert the ReadOnlySpan<char> to a string and use the string 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 the private protected modifier to implement the ReadOnlySpan<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 to StringComparer.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 from StringComparer 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 MethodsStringKeyedDictionary
      ProsNo 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
      ConsPerformance 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 from StringKeyedDictionaryExtensions.
      • Removed proposed IStringEqualityComparer interface and instead relies on comparer deriving from StringComparer.
      • Reordered design approaches to better promote my preference. 😄

      Metadata

      Metadata

      Assignees

      Labels

      Type

      No type

      Projects

      No projects

        Milestone

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions

        , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
        Skip to content

        Add string keyed dictionary ReadOnlySpan<char> lookup support #27229

        Description

        @TylerBrinkley

        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. a Span<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 the internal modifier to implement the ReadOnlySpan<char> overloads.
        • Only StringComparers that are passed into the constructor would be able to be optimized to not allocate a string.
        • The default implementation of the new virtual methods on StringComparer would convert the ReadOnlySpan<char> to a string and use the string 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 the private protected modifier to implement the ReadOnlySpan<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 to StringComparer.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 from StringComparer 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 MethodsStringKeyedDictionary
        ProsNo 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
        ConsPerformance 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 from StringKeyedDictionaryExtensions.
        • Removed proposed IStringEqualityComparer interface and instead relies on comparer deriving from StringComparer.
        • Reordered design approaches to better promote my preference. 😄

        Metadata

        Metadata

        Assignees

        Labels

        Type

        No type

        Projects

        No projects

          Milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
          Skip to content

          Add string keyed dictionary ReadOnlySpan<char> lookup support #27229

          Description

          @TylerBrinkley

          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. a Span<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 the internal modifier to implement the ReadOnlySpan<char> overloads.
          • Only StringComparers that are passed into the constructor would be able to be optimized to not allocate a string.
          • The default implementation of the new virtual methods on StringComparer would convert the ReadOnlySpan<char> to a string and use the string 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 the private protected modifier to implement the ReadOnlySpan<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 to StringComparer.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 from StringComparer 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 MethodsStringKeyedDictionary
          ProsNo 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
          ConsPerformance 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 from StringKeyedDictionaryExtensions.
          • Removed proposed IStringEqualityComparer interface and instead relies on comparer deriving from StringComparer.
          • Reordered design approaches to better promote my preference. 😄

          Metadata

          Metadata

          Assignees

          Labels

          Type

          No type

          Projects

          No projects

            Milestone

            Relationships

            None yet

            Development

            No branches or pull requests

            Issue actions

            , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
            Skip to content

            Add string keyed dictionary ReadOnlySpan<char> lookup support #27229

            Description

            @TylerBrinkley

            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. a Span<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 the internal modifier to implement the ReadOnlySpan<char> overloads.
            • Only StringComparers that are passed into the constructor would be able to be optimized to not allocate a string.
            • The default implementation of the new virtual methods on StringComparer would convert the ReadOnlySpan<char> to a string and use the string 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 the private protected modifier to implement the ReadOnlySpan<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 to StringComparer.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 from StringComparer 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 MethodsStringKeyedDictionary
            ProsNo 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
            ConsPerformance 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 from StringKeyedDictionaryExtensions.
            • Removed proposed IStringEqualityComparer interface and instead relies on comparer deriving from StringComparer.
            • Reordered design approaches to better promote my preference. 😄

            Metadata

            Metadata

            Assignees

            Labels

            Type

            No type

            Projects

            No projects

              Milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              Add string keyed dictionary ReadOnlySpan<char> lookup support #27229

              Description

              @TylerBrinkley

              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. a Span<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 the internal modifier to implement the ReadOnlySpan<char> overloads.
              • Only StringComparers that are passed into the constructor would be able to be optimized to not allocate a string.
              • The default implementation of the new virtual methods on StringComparer would convert the ReadOnlySpan<char> to a string and use the string 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 the private protected modifier to implement the ReadOnlySpan<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 to StringComparer.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 from StringComparer 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 MethodsStringKeyedDictionary
              ProsNo 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
              ConsPerformance 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 from StringKeyedDictionaryExtensions.
              • Removed proposed IStringEqualityComparer interface and instead relies on comparer deriving from StringComparer.
              • Reordered design approaches to better promote my preference. 😄

              Metadata

              Metadata

              Assignees

              Labels

              Type

              No type

              Projects

              No projects

                Milestone

                Relationships

                None yet

                Development

                No branches or pull requests

                Issue actions

                , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                Skip to content

                Add string keyed dictionary ReadOnlySpan<char> lookup support #27229

                Description

                @TylerBrinkley

                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. a Span<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 the internal modifier to implement the ReadOnlySpan<char> overloads.
                • Only StringComparers that are passed into the constructor would be able to be optimized to not allocate a string.
                • The default implementation of the new virtual methods on StringComparer would convert the ReadOnlySpan<char> to a string and use the string 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 the private protected modifier to implement the ReadOnlySpan<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 to StringComparer.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 from StringComparer 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 MethodsStringKeyedDictionary
                ProsNo 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
                ConsPerformance 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 from StringKeyedDictionaryExtensions.
                • Removed proposed IStringEqualityComparer interface and instead relies on comparer deriving from StringComparer.
                • Reordered design approaches to better promote my preference. 😄

                Metadata

                Metadata

                Assignees

                Labels

                Type

                No type

                Projects

                No projects

                  Milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions