Taking the example Hashtable subclass created in c#:
public class CustomHashtable : Hashtable
{
public CustomHashtable() : base() { }
public CustomHashtable(Hashtable a) : base(a) {}
public string GetFoo()
{
return "bar";
}
public static CustomHashtable operator +(CustomHashtable a, Hashtable b)
{
foreach (DictionaryEntry entry in b)
{
a.Add(entry.Key, entry.Value + "z");
}
return a;
}
}
While running this as a csharp console application you get the appropriate typed overload:
var ch = new CustomHashtable(new Hashtable(){
{ "Age", 54 }
});
ch += new Hashtable(){
{ "LastName", "Jobs" },
{ "FirstName", "Steve"}
};
System.Console.WriteLine(ch.GetType()); // CustomHashtable
System.Console.WriteLine(ch.GetFoo()); // bar
However, when importing this in PowerShell I get an error stating the method doesn't exist for System.Collections.Hashtable
:
Import-Module "$PSScriptRoot/CustomHashtable.dll"
$Hash = [CustomHashtable]::New(@{
Foo = 'Bar';
})
$Hash += @{
Name = 'Toot';
}
$Hash.GetFoo()
Line |
18 | $Hash.GetFoo()
| ~~~~~~~~~~~~~~
| Method invocation failed because [System.Collections.Hashtable] does not contain a method named 'GetFoo'
This works however if you explicitly cast to type of:
Import-Module "$PSScriptRoot/CustomHashtable.dll"
$Hash = [CustomHashtable]::New(@{
Foo = 'Bar';
})
[CustomHashtable]$Hash += @{
Name = 'Toot';
}
$Hash.GetFoo() //bar
Which for many reasons isn't ideal.
Is there anyway for PowerShell to pick up this typed overload?
Is there anyway for PowerShell to pick up this typed overload?
TL;DR: PowerShell's binary operation binder (the runtime component that figures out how to evaluate binary operator expressions, including @{} + @{}
), has some hardcoded logic specifically for adding two objects together that both implement the IDictionary
interface - and it'll never return your custom dictionary type.
Let's dive into the source code and find out what that looks like!
PowerShell's parser is going to turn the expression @{} + @{}
into a BinaryExpressionAst
object. PowerShell's internal compiler then in turn has to resolve the appropriate operator implementation for the given operands, and construct an expression tree that can actually be evaluated at runtime.
If we look specifically at the code path evaluating binary expressions, we'll find that the compiler defers the operation to the PSBinaryOperationBinder
class:
public object VisitBinaryExpression(...)
{
// ...
switch (binaryExpressionAst.Operator)
{
// ...
case TokenKind.Plus:
if (lhs.Type == typeof(double) && rhs.Type == typeof(double))
{
return Expression.Add(lhs, rhs);
}
binder = PSBinaryOperationBinder.Get(ExpressionType.Add);
return DynamicExpression.Dynamic(binder, typeof(object), lhs, rhs);
}
// ...
}
Alright, getting closer - now we know that the actual implementation for +
applied to two hashtables is resolved somewhere in PSBinaryOperationsBinder
and we'll want to look for code constrained by the expression type Add
.
Digging into the source code once again, we'll find this path in the binder:
private DynamicMetaObject BinaryAdd(...)
{
// ...
if (target.Value is IDictionary)
{
if (arg.Value is IDictionary)
{
return new DynamicMetaObject(
Expression.Call(CachedReflectionInfo.HashtableOps_Add,
target.Expression.Cast(typeof(IDictionary)),
arg.Expression.Cast(typeof(IDictionary))),
target.CombineRestrictions(arg));
}
return target.ThrowRuntimeError(new DynamicMetaObject[] { arg }, BindingRestrictions.Empty,
"AddHashTableToNonHashTable", ParserStrings.AddHashTableToNonHashTable);
}
// ...
}
So obviously, something special happens when the target
(the left-hand side operand) and the arg
(the right-hand side operand) both implement the IDictionary
interface - as is the case here!
The dynamic expression passed to Expression.Call
as CachedReflectionInfo.HashtableOps_Add
is a reference to this method, HashtableOps.Add(...)
, where the problem becomes apparent:
internal static object Add(IDictionary lvalDict, IDictionary rvalDict)
{
IDictionary newDictionary;
if (lvalDict is OrderedDictionary)
{
// If the left is ordered, assume they want orderedness preserved.
newDictionary = new OrderedDictionary(StringComparer.CurrentCultureIgnoreCase);
}
else
{
newDictionary = new Hashtable(StringComparer.CurrentCultureIgnoreCase);
}
// Add key and values from left hand side...
foreach (object key in lvalDict.Keys)
{
newDictionary.Add(key, lvalDict[key]);
}
// and the right-hand side
foreach (object key in rvalDict.Keys)
{
newDictionary.Add(key, rvalDict[key]);
}
return newDictionary;
}
As you can see, newDictionary
is either an ordered dictionary (equivalent to [ordered]@{}
) or a hashtable (@{}
), hence the behavior you observe.