Search code examples
c#reflectionf#delegatesimmutable-collections

How to create a fast-call delegate that has parameters and return type of private types, speeding up DynamicInvoke


I'm struggling with creating a call to the private ImmutableDictionary.Add, which allows me to utilize the KeyCollisionBehavior for finer control (the Add method only throws when key and value are different, I need it to throw always).

I can get where I want to be with basic reflection, however, the overhead of calling Invoke on MethodInfo, or DynamicInvoke on the delegate is significant (in fact, it almost triples the time on each call, which is too significant in my scenario).

The signature of the functions I need to call:

/// call property Origin, this returns a private MutationInput<,>
private MutationInput<TKey, TValue> Origin {get; }

/// call ImmutableDictionary<,>.Add, this takes MutationInput<,> and returns private MutationResult<,>
private static MutationResult<TKey, TValue> Add(TKey key, TValue value, KeyCollisionBehavior<TKey, TValue> behavior, MutationInput<TKey, TValue> origin);

/// then call MutationResult<,>.Finalize
internal ImmutableDictionary<TKey, TValue> Finalize(ImmutableDictionary<TKey, TValue> priorMap);

The challenge here being that I need to pass a private type around, and that the private type is part of the signature.

Normally, after calling CreateDelegate, you can simply cast it to a Func<X, Y, Z> and this gives near-direct calling speed. But I don't know how to create a Func<,> if the generic types are private and/or not known at compile time. Using object doesn't work, gives a runtime exception on the cast.

Here's a shortened version (removed a lot of try/catch and checks) of the code I currently have. This works:

/// Copy of enum type from Github source of ImmutableDictionary
type KeyCollisionBehavior =
    /// Sets the value for the given key, even if that overwrites an existing value.
    | SetValue = 0
    /// Skips the mutating operation if a key conflict is detected.
    | Skip = 1
    /// Throw an exception if the key already exists with a different value.
    | ThrowIfValueDifferent = 2
    /// Throw an exception if the key already exists regardless of its value.
    | ThrowAlways = 3

/// Simple wrapper DU to add type safety
type MutationInputWrapper = 
    /// Wraps the type ImmutableDictionary<K, V>.MutationInput, required as 4th param in the internal Add#4 method
    | MutationInput of obj

/// Simple wrapper to add type-safety
type MutationResultWrapper =
    /// Wraps the type ImmutableDictionary<K, V>.MutationResult, which is the result of an internal Add#4 operation
    | MutationResult of obj

/// Type abbreviation
type BclImmDict<'Key, 'Value> = System.Collections.Immutable.ImmutableDictionary<'Key, 'Value>

/// Private extensions to ImmutableDictionary
type ImmutableDictionary<'Key, 'Value>() =
    static let dicType = typeof<System.Collections.Immutable.ImmutableDictionary<'Key, 'Value>>
    static let addMethod = dicType.GetMethod("Add", BindingFlags.NonPublic ||| BindingFlags.Static)
    static let addMethodDelegate = 
        let parameters = addMethod.GetParameters() |> Array.map (fun p -> p.ParameterType)
        let funType = 
            typedefof<Func<_, _, _, _, _>>.MakeGenericType [|
                parameters.[0]
                parameters.[1]
                parameters.[2]
                parameters.[3]
                addMethod.ReturnType
            |]
        Delegate.CreateDelegate(funType, addMethod) // here one would normally cast to Func<X, Y...>

    static let mutationResultFinalizeMethod = 
        if not(isNull addMethod) && not(isNull(addMethod.ReturnParameter)) then
            /// Nested private type MutationResult, for simplicity taken from the return-param type of ImmutableDictionary.Add#4
            let mutationResultType = addMethod.ReturnParameter.ParameterType
            if not(isNull mutationResultType) then
                mutationResultType.GetMethod("Finalize", BindingFlags.NonPublic ||| BindingFlags.Instance ||| BindingFlags.DeclaredOnly)
            else
                null
        else
            null

    /// System.Collections.Immutable.ImmutableDictionary.get_Origin  // of valuetype ImmutableDictionary<,>.MutationInput<,>
    static let getOrigin = dicType.GetProperty("Origin", BindingFlags.NonPublic ||| BindingFlags.Instance)

    /// Calls private member ImmutableDictionary<,>.Add(key, value, behavior, origin), through reflection
    static member private refl_Add(key: 'Key, value: 'Value, behavior: KeyCollisionBehavior, MutationInput origin) =
        // use Invoke or DynamicInvoke makes little difference.
        //addMethod.Invoke(null, [|key; value; behavior; origin|])
        addMethodDelegate.DynamicInvoke([|box key; box value; box <| int behavior; origin|])
        |> MutationResult

    /// Gets the "origin" of an ImmutableDictionary, by calling the private property get_Origin
    static member private refl_GetOrigin(this: BclImmDict<'Key, 'Value>) =
        getOrigin.GetValue this
        |> MutationInput

    /// Finalizes the result by taking the (internal) MutationResult and returning a new non-mutational dictionary
    static member private refl_Finalize(MutationResult mutationResult, map: BclImmDict<'Key, 'Value>) =
        mutationResultFinalizeMethod.Invoke(mutationResult, [|map|])
        :?> BclImmDict<'Key, 'Value>

    /// Actual Add, with added control through CollisionBehavior
    static member InternalAddAndFinalize(key: 'Key, value: 'Value, behavior, thisMap) =
        let origin = ImmutableDictionary.refl_GetOrigin(thisMap)
        let mutationResult = ImmutableDictionary.refl_Add(key, value, behavior, origin)
        let finalizedMap = ImmutableDictionary.refl_Finalize(mutationResult, thisMap)
        finalizedMap

I realize the code above is in F#, but if you know how to fix this in C# I've no problem with translating your answer into my preferred target language.


Solution

  • I think other commenters have a point that this strategy might not be the best in this particular case. However, there is a general problem here which I will take at face value: How to create a delegate to a method that has types that you can't access by name because they are private or internal?

    Since you can't reference some type names you can't create a strongly typed delegate which would be much faster than Invoke/DynamicInvoke. In this case, the idea is to generate the IL for a wrapper method at runtime using System.Reflection.Emit.DynamicMethod that calls the methods with the inaccessible types but this wrapper only exposes types you have access to. A DynamicMethod can be "owned" by a type in another assembly thereby bypassing some visibility checks. The difficulty is that you have to tell the runtime exactly what IL to emit for this wrapper method, so it can be difficult to implement complex logic in it. In this case, things are simple enough to write by hand: get a property (Origin) and invoke two methods (Add and Finalize).

    Here is an implementation in C# to do this:

    enum KeyCollisionBehavior
    {
        SetValue = 0,
        Skip = 1,
        ThrowIfValueDifferent = 2,
        ThrowAlways = 3,
    }
    
    internal static class ImmutableDictionaryHelper<TKey, TValue>
    {
        private static readonly MethodInfo OriginPropertyGetter = typeof(ImmutableDictionary<TKey, TValue>)
            .GetProperty("Origin", BindingFlags.Instance | BindingFlags.NonPublic).GetGetMethod(true);
    
        private static readonly MethodInfo AddMethod = typeof(ImmutableDictionary<TKey, TValue>)
            .GetMethods(BindingFlags.NonPublic | BindingFlags.Static).Where(m => m.Name == "Add" && m.GetParameters().Length == 4).FirstOrDefault();
    
        private static readonly Type MutationResultType = AddMethod.ReturnType;
    
        private static readonly MethodInfo FinalizeMethod = MutationResultType
            .GetMethod("Finalize", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly);
    
        private static readonly Func<TKey, TValue, KeyCollisionBehavior, ImmutableDictionary<TKey, TValue>, ImmutableDictionary<TKey, TValue>> AddAndFinalize = CreateAddAndFinalize();
    
        private static Func<TKey, TValue, KeyCollisionBehavior, ImmutableDictionary<TKey, TValue>, ImmutableDictionary<TKey, TValue>> CreateAddAndFinalize()
        {
            var method = new DynamicMethod(
                nameof(AddAndFinalize),
                typeof(ImmutableDictionary<TKey, TValue>),
                new[] { typeof(TKey), typeof(TValue), typeof(KeyCollisionBehavior), typeof(ImmutableDictionary<TKey, TValue>) },
                typeof(ImmutableDictionary<TKey, TValue>));
    
            var ilGen = method.GetILGenerator();
            ilGen.DeclareLocal(OriginPropertyGetter.ReturnType);
            ilGen.DeclareLocal(AddMethod.ReturnType);
    
            // var origin = dictionary.Origin;
            ilGen.Emit(OpCodes.Ldarg_3);
            ilGen.Emit(OpCodes.Callvirt, OriginPropertyGetter);
            ilGen.Emit(OpCodes.Stloc_0);
    
            // var result = Add(key, value, behavior, origin)
            ilGen.Emit(OpCodes.Ldarg_0);
            ilGen.Emit(OpCodes.Ldarg_1);
            ilGen.Emit(OpCodes.Ldarg_2);
            ilGen.Emit(OpCodes.Ldloc_0);
            ilGen.Emit(OpCodes.Call, AddMethod);
            ilGen.Emit(OpCodes.Stloc_1);
    
            // var newDictionary = result.Finalize(dictionary);
            ilGen.Emit(OpCodes.Ldloca_S, 1);
            ilGen.Emit(OpCodes.Ldarg_3);
            ilGen.Emit(OpCodes.Call, FinalizeMethod);
    
            // return newDictionary;
            ilGen.Emit(OpCodes.Ret);
    
            var del = method.CreateDelegate(typeof(Func<TKey, TValue, KeyCollisionBehavior, ImmutableDictionary<TKey, TValue>, ImmutableDictionary<TKey, TValue>>));
            var func = (Func<TKey, TValue, KeyCollisionBehavior, ImmutableDictionary<TKey, TValue>, ImmutableDictionary<TKey, TValue>>)del;
            return func;
        }
    
        public static ImmutableDictionary<TKey, TValue> Add(ImmutableDictionary<TKey, TValue> source, TKey key, TValue value, KeyCollisionBehavior behavior)
        {
            if (source == null)
                throw new ArgumentNullException(nameof(source));
    
            return AddAndFinalize(key, value, behavior, source);
        }
    }
    

    One detail to point out is that the CLR handles enumerations as integers so we can create our own KeyCollisionBehavior enum that is compatible with the private enum we don't have access to without any explicit conversion.