.NET 7 Reflection Improvements Can't Beat THIS
Transcript
Recently, one of the biggest .NET bloggers talked about how to consume anonymous types with diagnostic listeners in .NET 6. Within a week, another big .NET creator showed off the reflection performance improvements that are coming with .NET 7. This got me thinking about a blog series I wrote back in 2020 that used a neat trick to access private fields on anonymous types. I decided to compare the performance of that trick with the latest reflection updates, and throw in a performance trick with expression trees to see which was fastest. So let's take a quick look at the performance of reflection in both .NET 6 and .NET 7 by using BenchmarkDotNet. This will serve as our baseline for the rest of the video.
A benchmarking sample is going to use a class with two private fields, one of type int and the other of type string, as I want to see whether there are going to be any differences between the reference types and value types. We will run these benchmarks for both get and set operations to ensure that we have an accurate comparison later on. We need to cache the MethodInfo in a private field, negating any additional lookup costs. We will also use this field for our expression tree approach later. Whilst this is running, it's worth noting that I'm using .NET 7 Preview 5 and the numbers in this test don't really matter yet as we're only going to be looking at relative performance.
So how do expression trees compare? Expression trees have been around for a long time and are mainly used for metaprogramming scenarios such as runtime generation of code. When it comes to its use with reflection, it's often overlooked as being complicated and hard to maintain. So let's break down how to create a getter and a setter for a private field using expression trees, starting first with the getters. First, create a new method that returns a Func of TSource and TReturn, taking a single parameter which is the method information of the private field. Next, we need to create a parameter expression that represents our input object. Now we can create the expression that's going to access the field on the object by using Expression.MakeMemberAccess. The first argument to the method is the input parameter we created earlier and the second is the field information that's passed into the method. Essentially, this says access this field on this object.
Because the field information returns an object, we need to be able to cast this to the correct type by using Expression.Convert, passing in the access expression followed by the return type. Lastly, we need to use a special method called Expression.Lambda to take our expression statements and turn it into a function. We must ensure that we call Compile on the resulting expression so that we get our function returned.
We can repeat a similar process for the setter expression. First, create a new method that returns an Action of TSource, TArg, which takes a single parameter which is the method information of the private field. Next, we need to create a parameter expression that represents our input object, and the second one which represents the value that's going to be set on the input object. To assign a value to a field, we need to create an expression that represents the field by using Expression.Field. This needs the input object parameter and the field information. We then call Expression.Assign, passing in the parameter that represents the value we wish to set. Lastly, we create a new lambda function similar to our last one but change the resulting type to Action rather than Func.
Let's quickly create some fields that are going to store our functions and then set them from the constructor. Now this is done, we can add some new benchmarks to our application and execute them.
As we can see, our new expression tree approach is quite the improvement over the standard reflection approach. This is already fast, but what if you need to go faster? Enter my next trick. In general, I read a lot of code. Every now and again I come across a gem that I store in my brain for a later date. Today's gem is the Unsafe.As method. Reading directly from the documentation, this API is used to cast an object to a given type, suppressing the runtime's normal type safety checks. It is the caller's responsibility to ensure that the cast is legal. No InvalidCastException will be thrown. Essentially, this is saying don't do anything stupid because here be dragons. However, we can abuse this feature as a way of accessing information that we probably shouldn't be able to access.
Accessing a private field using this method is actually surprisingly easy. First, we create a new class that mimics the structure of our original class. Then we call Unsafe.As, passing in our new type as the generic argument and then the instance of the class we want to convert from. Once this is done, we can interact with the fields as normal, such as wrapping the fields with properties like I've done here. The small program on screen uses this method to first retrieve the field from the source and the target, before only setting it on the target and verifying the results with another call to the source and target. When I initially wrote this, I didn't expect it to work, so when it did my mind was a little bit blown.
If we use SharpLab's inspect features on the target class instance, we can get an idea of how this works. There is a tight conversion between the source object and the target object, but there is no loss of reference to the underlying memory, which explains why this all works. So how does this compare in terms of performance? Let's quickly add the code to our new benchmark scenario and run it.
When I first saw this result, I honestly couldn't believe it. I went to go and run various console apps to verify the behavior, as it's quite a bit faster. Since you've got this far through the video, I'm going to show you one more thing that I found along my journey with the Unsafe class. We can actually remove a lot of the boilerplate of the property setup and it works in the exact same way as if we had all of the boilerplate. As with anything using reflection, there is an element of maintainability to consider. In theory, some of this could be dealt with by using source generators. It's also worth noting that I've only tried this approach with simple setups like I've shown here, so your mileage may vary. Let me know your thoughts on this trick in the comments below.