Say I wanted a dictionary type that self-validated its keys, so that only certain string values were allowed.
I tried something like this:
class MyDict : Collections.DictionaryBase, ICloneable {
[void] OnValidate($key, $value) {
$validKeyValues = 'a','b','c'
if ( $key -notin $validKeyValues ) {
throw "Key $key failed validation, must be one of $validKeyValues."
}
}
[object] Clone() {
$clonedDict = [MyDict]::new()
$this.GetEnumerator() | ForEach-Object { $clonedDict.Add($_.Key, $_.Value) }
return $clonedDict
}
}
function StackOverflow {
param(
[MyDict]$Dictionary
)
$Dictionary
}
$dict = [MyDict]::new()
$dict.a = 'x'
StackOverflow $dict
This seems to work for member access setting and getting, e.g., $dict.a = 'x'
works. However, I can no longer index into the dictionary when setting a new value, so $dict['key'] = 'value'
doesn't work, but getting via $dict['a']
does work and would return x
using the above example.
Not being able to set via indexing seems like it's not a robust implementation, and so I am worried I might be missing out on more details.
note I did try inheriting from the generic Dictionary<TKey,TValue>
class instead of DictionaryBase
, but this generic class doesn't seem to use OnValidate($key, $value)
, so I couldn't implement the self-validation. I've also left out ISerializable
and IDeserializationOnCallback
to simplify the question's scope.
PowerShell 7.4.5
The behavior you're seeing is arguably a bug, present up to at least PowerShell (Core) 7 v7.4.x:
If using index notation works for getting an entry's value (e.g. $dict['a']
) it should equally work for setting it (e.g., $dict['a'] = 'y'
), especially given that both operations work with property notation.
The problem is that the parameterized .Item
property that is required for index-notation support is an part of an explicit interface implementation, IDictionary.Item
, in the base class you derive from, System.Collections.DictionaryBase
.
While that isn't a problem for getting an entry's value (except in Windows PowerShell, the legacy, ships-with-Windows, Windows-only edition of PowerShell whose latest and last version is 5.1), it unexpectedly fails on setting.[1]
Given that PowerShell in general surfaces explicit interface implementations as if they were type-native members, the same should apply here.
See GitHub issue #24537 for a discussion.
Caveat:
class
would work as expected from PowerShell once the bug is fixed, thanks to the latter's implicit surfacing of explicit interface implementations, using it from other .NET languages, notably from C#, would require casting each instance to System.Collections.IDictionary
in order to access the dictionary functionality.Workarounds:
Either: Call the parameterized .Item
property underlying the indexer explicitly, using method syntax, instead of using index notation:
Note: This workaround is likely unacceptable, because it requires all callers to employ it; also, it is more obscure and cumbersome than using property notation. However, I'm mentioning it for the sake of completeness.
E.g., calling $dict.Item('a') = 'y'
(instead of $dict['a'] = 'y'
) works.
Or: Define your MyDict
class using embedded C# code via Add-Type
, which allows you to surface a type-native indexer (parameterized Item
property) that internally defers to the explicitly interface implementation, as shown below; this will make setting entry values work with index notation too.
The reason that C# code must be used is that PowerShell's own class
construct doesn't support implementing parameterized properties, as discussed in this conceptually related answer.
Note that C# has syntactic sugar for defining indexers; when you examine an instance of the resulting type via Get-Member
, you'll see a ParameterizedProperty
entry with following definition:
System.Object Item(System.Object key) {get;set;}
Add-Type @'
using System.Collections;
public class MyDict : DictionaryBase {
// Key-validation method.
protected override void OnValidate(object key, object value) {
var validKeyValues = new string[] { "a", "b", "c" };
if (!((IList)validKeyValues).Contains(key)) {
throw new System.ArgumentException(string.Format("Key {0} failed validation, must be one of {1}.", key, string.Join(' ', validKeyValues)));
}
}
// Expose a type-native indexer that defers to the explicit
// interface implementation; at the .NET level this turns
// into a parameterized property named "Item".
public object this[object key] {
get => ((IDictionary)this)[key];
set => ((IDictionary)this)[key] = value;
}
}
'@
$d = [MyDict]::new()
# This works now, due to a type-native indexer being present.
$d['a'] = 'works'
[1] That is, Windows PowerShell fundamentally doesn't support index notation via parameterized .Item
properties that are part of explicit interface implementations only.