Search code examples
powershellhashtable

Hashtable Subclass With Overloaded Operators


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?


Solution

  • Is there anyway for PowerShell to pick up this typed overload?

    Nope, at least not for dictionary types.

    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.