Skip to content

API proposal: Activator.CreateFactory and ConstructorInfo.CreateDelegate #36194

Closed
@GrabYourPitchforks

Description

@GrabYourPitchforks

(See also #32520.)

API proposal

namespace System
{
    public static class Activator
    {
      // EXISTING APIs (abridged):
      public static T CreateInstance<T>();
      public static object? CreateInstance(Type type);
      public static object? CreateInstance(Type type, bool nonPublic);

      // NEW PROPOSED APIs:
      public static Func<T> CreateFactory<T>(); // no new() constraint, matches existing API
      public static Func<object?> CreateFactory(Type type);
      public static Func<object?> CreateFactory(Type type, bool nonPublic);
    }
}

// STRETCH GOAL APIs (see end of proposal):
namespace System.Reflection
{
    public class ConstructorInfo
    {
      public static TDelegate CreateDelegate<TDelegate>();
      public static Delegate CreateDelegate(Type delegateType);
    }
}

// REMOVED FROM PROPOSAL
/*
namespace System.Runtime.CompilerServices
{
    public static class RuntimeHelpers
    {
        // EXISTING API:
        public static object GetUninitializedObject(Type type);

        // NEW PROPOSED API:
        public static Func<object> GetUninitializedObjectFactory(Type type);
    }
}
*/

Discussion

Instantiating objects whose types are not known until runtime is fairly common practice among frameworks and libraries. Deserializers perform this task frequently. Web frameworks and DI frameworks might create per-request instances of objects, then destroy those objects at the end of the request.

APIs like Activator.CreateInstance and the System.Reflection surface area can help with this to a large extent. However, those are sometimes seen as heavyweight solutions. We've built some caching mechanisms into the framework to suppose these use cases. But it's still fairly common for high-performance frameworks to bypass Activator and the reflection stack and to go straight to manual codegen. See below for some examples.

See below for some concrete samples.

System.Text.Json

if (realMethod == null)
{
LocalBuilder local = generator.DeclareLocal(type);
generator.Emit(OpCodes.Ldloca_S, local);
generator.Emit(OpCodes.Initobj, type);
generator.Emit(OpCodes.Ldloc, local);
generator.Emit(OpCodes.Box, type);
}
else
{
generator.Emit(OpCodes.Newobj, realMethod);
}

ASP.NET Core

dotnet/aspnetcore#14615 (though they're using TypeBuilder to work around this right now)

Other runtime + libraries

// Implemet the static factory
// public object Create(IDictionary<string, object>)
// {
// return new <ProxyClass>(dictionary);
// }
MethodBuilder factoryMethodBuilder = proxyTypeBuilder.DefineMethod(MetadataViewGenerator.MetadataViewFactoryName, MethodAttributes.Public | MethodAttributes.Static, typeof(object), CtorArgumentTypes);
ILGenerator factoryIL = factoryMethodBuilder.GetILGenerator();
factoryIL.Emit(OpCodes.Ldarg_0);
factoryIL.Emit(OpCodes.Newobj, proxyCtor);
factoryIL.Emit(OpCodes.Ret);

Ref emit incurs a substantial upfront perf hit, but it does generally provide a win amortized over the lifetime of the cached method as it's invoked over and over again. However, this comes with its own set of problems. It's difficult for developers to get the exact IL correct across the myriad edge cases that might exist. It's not very memory-efficient. And as runtimes that don't allow codegen become more commonplace, it complicates the callers' code to have to decide the best course of action to take for any given runtime.

These proposed APIs attempt to solve the problem of creating a basic object factory using the best mechanism applicable to the current runtime. The exact mechanism used can vary based on runtime: perhaps it's codegen, perhaps it's reflection, perhaps it's something else. But the idea is that the performance of these APIs should rival the best hand-rolled implementations that library authors can create.

Shortcomings, not solved here

This API is not a panacea to address all performance concerns developers have with the reflection stack. For example, this won't change the perf characteristics of MethodInfo.Invoke. But it could be used to speed up the existing Activator.CreateInstance<T> APIs and to make other targeted improvements. In general, this API provides an alternative pattern that developers can use so that they don't have to roll solutions themselves.

It also does not fully address the concern of calls to parameterized ctors, such as you might find in DI systems. The API RuntimeHelpers.GetUninitializedObjectFactory does help with this to some extent. The caller can cache that factory to instantiate "blank" objects quickly, then use whatever existing fast mechanism they wish to call the object's real ctor over the newly allocated instance.

I hope to have a better solution for this specific scenario in a future issue.

Stretch goal APIs

The APIs ConstructorInfo.CreateDelegate<TDelegate>() and friends are meant to allow invoking parameterless or parameterful ctors. These are primarily useful when the objects you're constructing have a common signature in their constructors, but you don't know the actual type of the object at compile time.

Consider:

public class MyService
{
    public MyService(IServiceProvider serviceProvider) { }
}

public class MyOtherService
{
    public MyOtherService(IServiceProvider serviceProvider) { }
}

In these cases, the caller would get the ConstructorInfo they care about, then call constructorInfo.CreateDelegate<Func<IServiceProvider, object>>(). Depending on which ConstructorInfo was provided, the returned delegate will create either a MyService or a MyOtherService, calling the appropriate ctor with the caller-provided IServiceProvider.

I say "stretch goal" because the parameter shuffling involved here would involve spinning up the JIT. We can keep this overhead to a minimum because within the runtime we can take shortcuts that non-runtime components can't take with the typical DynamicMethod-based way of doing things. So while there's less JIT cost compared to a standard DynamicMethod-based solution, there's still some JIT cost, so it doesn't fully eliminate startup overhead. (This overhead isn't much different than the overhead the runtime already incurs when it sees code like Func<string, bool> func = string.IsNullOrEmpty;, since the runtime already needs to create a parameter shuffling thunk for this scenario.)

Metadata

Metadata

Assignees

Labels

Cost:MWork that requires one engineer up to 2 weeksapi-approvedAPI was approved in API review, it can be implementedarea-System.Reflection

Type

No type

Projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions