Typically I wouldn’t say I like writing about preview features for multiple reasons. Most of my posts aim to help folks solve problems they might have rather than get on a soap box or advertise. But I thought I would cover this .NET preview feature as it’s a sister topic to something I’ve wanted in the .NET ecosystem for the longest time: monkey patching. If you’re unfamiliar with the topic, I suggest you read my post on monkey patching. In general, monkey patching allows you to substitute one implementation for another, and what do you know, .NET 8 is introducing the concept of Interceptors.
As the name implies, Interceptors allow developers to target specific method invocations and intercept them with a new implementation. Interceptors have several purposes and meaningful distinctions that we’ll get into in this post. So let’s get started.
What is an Interceptor?
In .NET 8 preview 6, the SDK introduces additional functionality to “intercept” any method call within your codebase. The word “interceptor” is clear as to the purpose of this new functionality. It only replaces methods intentionally and does not replace method implementations globally. The approach means you, as the developer, must be systematic about using an Interceptor.
The .NET team uses interceptors to overwrite infrastructure code that previously relied on reflection with compile-time versions specific to your application. Interceptors will hopefully reduce your programs’ start-up time and efficiency. The .NET team designed interceptors to work with source generators, as source generators can surgically deal with abstract syntax trees and code files to target method invocations. While you could hand-write interceptor calls, it becomes impractical in a real-world application.
Let’s get into setting up your project to work with interceptors.
Getting Started with Interceptors
Interceptors are a .NET 8 preview 6 feature, so you’ll need the matching SDK version or higher to get this working. Begin by creating a new console application, or really any .NET application.
Next, in your .csproj
, you must add the following PropertyGroup
element.
<PropertyGroup>
<Features>InterceptorsPreview</Features>
</PropertyGroup>
Also be sure to set your LangVersion
element to preview
to get access to the feature.
<PropertyGroup>
<LangVersion>preview</LangVersion>
</PropertyGroup>
Next, add the following attribute definition to your project.
namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
public sealed class InterceptsLocationAttribute : Attribute
{
public InterceptsLocationAttribute(string filePath, int line, int character)
{
}
}
Yes, it’s weird that the attribute isn’t part of the BCL, but since this is a preview feature, I imagine the .NET team didn’t want to pollute the .NET framework with a potential API change later.
You’ll notice that the attribute takes three parameters: filePath, line, and character. You’ll also note that these values aren’t assigned anywhere, and you’d be correct. The attribute is a marker the compiler will read at compile-time, so setting the values for runtime use is pointless.
Now, let’s intercept some code. Add the following to your Program.cs
file. Note, the line numbers and spacing are critically important. If you reformat the code, this solution might break. Also be sure to change the file path to the absolute path of your Program.cs
file.
using System.Runtime.CompilerServices;
C.M(); // What the Fudge?!
C.M(); // Original
class C
{
public static void M() => Console.WriteLine("Original");
}
// generated
class D
{
[InterceptsLocation("/Users/khalidabuhakmeh/RiderProjects/ConsoleApp12/ConsoleApp12/Program.cs",
line: 3, character: 3)]
public static void M() => Console.WriteLine("What the Fudge?!");
}
Running the application above, you’ll see the most peculiar thing. Two different outputs from the same method call! What the heck?!
What the Fudge?!
Original
But how? What does the compiled code look like after compilation? We can see what happened using JetBrains Rider’s IL Viewer.
// Decompiled with JetBrains decompiler
// Type: Program
// Assembly: ConsoleApp12, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
// MVID: 09D7E1E0-5709-4A62-884A-AB84DAA1E08C
// Assembly location: /Users/khalidabuhakmeh/RiderProjects/ConsoleApp12/ConsoleApp12/bin/Debug/net8.0/ConsoleApp12.dll
// Local variable names from /users/khalidabuhakmeh/riderprojects/consoleapp12/consoleapp12/bin/debug/net8.0/consoleapp12.pdb
// Compiler-generated code is shown
using System.Runtime.CompilerServices;
[CompilerGenerated]
internal class Program
{
private static void <Main>$(string[] args)
{
D.M();
C.M();
}
public Program()
{
base..ctor();
}
}
We can now see that the compiler replaced the first method invocation with our intercepting implementation. Wow!
After the razzle-dazzle wears off, you’ll likely think this is impractical. Who has time to hardcode full paths to files, count lines, and count columns? Well, as mentioned previously, this is where source generators come in.
While I won’t demonstrate it here when dealing with syntax trees, you do have access to information like FilePath
, and every CSharpSyntaxNode
has a GetLocation
method that gives you access to line numbers and location within a code file. If you’re already proficient in writing source generators, this information is already available.
Conclusion
This feature is definitely for a select group of individuals in the .NET community, specifically those who write and maintain source generators. Within that subgroup, you likely have framework authors looking to squeeze every last bit of performance out of .NET. As you’ve seen, Interceptors only change specific implementations and cannot target methods globally. If you’re using a source generator to do an interception of all methods, you’ll have to generate an interception call for each location. Generating a large amount of custom code can adversely affect the size of your compiled assets, so be aware of using this feature. Also, you may consider avoiding this feature altogether. Interceptors are still in preview, and the primary intention is to help .NET authors to improve ASP.NET Core and other frameworks within the .NET SDK. Either way, it’s good to understand this feature exists next time you’re debugging your .NET 8 applications because the method you think you’re calling might not be the method you’re actually calling.
I hope you enjoyed this blog post, and as always, thanks for reading and sharing my posts with friends and colleagues.