Your Aspects, Faster: Advice Parameter Binding in PostSharp 2027.0

Your Aspects, Faster: Advice Parameter Binding in PostSharp 2027.0

Every OnEntry( MethodExecutionArgs args ) you have ever written pays three costs on every call: it allocates an object on the heap, it boxes the arguments and the return value into object, and it makes you cast them back. Our own Pattern Libraries stopped paying those costs a long time ago, because they use a mechanism we kept internal: advice parameter binding. With PostSharp 2027.0, this mechanism is public, documented and checked at build time.

What MethodExecutionArgs costs you

Here is a typical logging aspect written as a simple aspect. It logs the first argument, the return value, the exception and the duration of the method:

[PSerializable]
public sealed class LogAttribute : OnMethodBoundaryAspect
{
    public override void OnEntry( MethodExecutionArgs args )
    {
        Console.WriteLine( "Entering {0}({1}).", args.Method.Name, args.Arguments[0] );
        args.MethodExecutionTag = Stopwatch.StartNew();
    }

    public override void OnSuccess( MethodExecutionArgs args )
    {
        Console.WriteLine( "{0} returned {1}.", args.Method.Name, args.ReturnValue );
    }

    public override void OnException( MethodExecutionArgs args )
    {
        Console.WriteLine( "{0} failed: {1}", args.Method.Name, args.Exception.Message );
    }

    public override void OnExit( MethodExecutionArgs args )
    {
        var stopwatch = (Stopwatch) args.MethodExecutionTag;
        Console.WriteLine( "{0} took {1} ms.", args.Method.Name, stopwatch.ElapsedMilliseconds );
    }
}

It works, and it has worked for many years. But look at what it asks of the runtime:

  • One allocation per call. MethodExecutionArgs lives on the heap, and so does the Arguments collection.
  • Boxing. The Arguments collection stores the arguments with their own types, but exposes them as object, so reading an int through args.Arguments[0] boxes it. args.ReturnValue is an object too, so an int return value is boxed as well.
  • Casts. MethodExecutionTag is an object too, so the stopwatch must be cast back, and a wrong cast fails at run time.

On a method called a few times per request, nobody notices. On a hot path, the aspect becomes the most expensive part of the method.

The same aspect with bound parameters

With advice parameter binding, the aspect derives from MethodLevelAspect, and each advice method declares only the values it needs, as typed parameters. A binding attribute on each parameter says where the value comes from:

[PSerializable]
public sealed class LogAttribute : MethodLevelAspect
{
    [OnMethodEntryAdvice]
    public void OnEntry( [DeclarationName] string methodName,
                         [Argument( 0 )] object argument,
                         [State( StateScope.MethodInvocation )] out Stopwatch stopwatch )
    {
        Console.WriteLine( "Entering {0}({1}).", methodName, argument );
        stopwatch = Stopwatch.StartNew();
    }

    [OnMethodSuccessAdvice]
    public void OnSuccess<T>( [DeclarationName] string methodName, [ReturnValue] T returnValue )
    {
        Console.WriteLine( "{0} returned {1}.", methodName, returnValue );
    }

    [OnMethodExceptionAdvice]
    public void OnException( [DeclarationName] string methodName, [Exception] Exception exception )
    {
        Console.WriteLine( "{0} failed: {1}", methodName, exception.Message );
    }

    [OnMethodExitAdvice]
    public void OnExit( [DeclarationName] string methodName,
                        [State( StateScope.MethodInvocation )] Stopwatch stopwatch )
    {
        Console.WriteLine( "{0} took {1} ms.", methodName, stopwatch.ElapsedMilliseconds );
    }
}

The bindings in this example:

  • [DeclarationName] gives the name of the method. It is a constant, computed at build time, so there is no reflection call at run time.
  • [State] declares a typed variable shared by the advices for one execution of the method. The entry advice assigns it with out, and the exit advice reads it. There is no MethodExecutionTag and no cast.
  • [ReturnValue] on a generic parameter T receives the return value without boxing. PostSharp infers T for each target method.
  • [Argument( 0 )] reads the first argument. Here it is still boxed, because the parameter is object. Make the advice generic to avoid it, as shown below.
  • [Exception] gives the exception, typed as Exception.

The aspect is applied exactly as before, with [Log] on a method or with multicasting.

What PostSharp actually generates

Bound parameters are a build-time contract, so the woven code passes the values directly. Take a smaller profiling aspect, with an entry advice that starts a [State] stopwatch and an exit advice that prints the [DeclarationName] and the duration (see Aspect Development Concepts). PostSharp transforms the target method into code equivalent to this:

public void PlaceOrder( Order order )
{
    Stopwatch stopwatch;
    aspect.OnEntry( out stopwatch );
    try
    {
        // Method body.
    }
    finally
    {
        aspect.OnExit( "ProfileAspect.OrderService.PlaceOrder", stopwatch );
    }
}

The state is a local variable, the method name is a string literal, and the advices are plain method calls. Apart from the Stopwatch itself, the woven code allocates nothing.

Generic advices, inferred for each target

An advice method can be generic. PostSharp infers the type arguments separately for each target method, with rules similar to the type inference of the C# compiler, and then calls a different instantiation of the advice from each target.

Take the OnSuccess<T> advice above. On int Square( int value ), PostSharp calls OnSuccess<int>. On string Format( Order order ), it calls OnSuccess<string>. The same source method serves every signature, and an int stays an int all the way.

Inference also works through constructed types and constraints:

[OnMethodEntryAdvice]
public void OnEntry<T>( [DeclarationName] string methodName,
                        [Argument( 0 )] IEnumerable<T> items )
    where T : IComparable<T>
{
    Console.WriteLine( "{0} received {1} items, the first is {2}.",
                       methodName, items.Count(), items.FirstOrDefault() );
}

Applied to a method that takes a List<int>, this advice gets T = int: PostSharp matches IEnumerable<T> against the type of the argument, its base types and its interfaces, as C# would. The rules, in short:

  • No boxing. A value of a value type is passed as it is, and a ref, out or in parameter receives the location itself.
  • Common type. When several bound values give a type argument, it is the type to which all of them can be assigned. OnEntry<T>( [Argument( 0 )] T a, [Argument( 1 )] T b ) on M( string, object ) gives T = object.
  • Constructed types. T[], T?, IList<T> and the like are matched against the value’s type, its base types and its interfaces.
  • Constraints are checked. Including unmanaged, new() and constraints that refer to other type parameters.

When a type argument cannot be inferred for a target, or violates a constraint, the build fails with the error LA0220, which states the reason. Apply the advice above to a method that takes a List<object>, and you learn at build time that object does not implement IComparable<object>.

The type of a parameter can also use the type parameters of a generic aspect class. They are replaced by the type arguments of the aspect instance. See Generic advice methods for the complete rules.

Errors at build time, not in production

A simple aspect accepts any method, then fails at run time if it makes a wrong assumption, for instance by reading args.Arguments[0] on a method that has no parameters.

With bound parameters, PostSharp checks each advice against each target when it weaves the aspect. An advice that does not fit a target is a build error with a message code. Apply the LogAttribute above to a method without parameters, and [Argument( 0 )] causes the error LA0225: the target method has no parameter at this index. A parameter of the wrong type, a binding used in an advice that does not support it, or a type argument that cannot be inferred all have their own codes. PostSharp reports all the errors of an aspect in one build.

The complete list of build messages is in the reference.

Beyond the basics

The logging example uses five bindings. The Advice Parameter Reference lists them all. Among them:

  • Write access with ref and out. Replace an argument, set or replace the return value, or replace the exception.
  • [FlowBehavior] to return without executing the method, or to swallow an exception.
  • Async methods. [CurrentTask] gives the task that the caller observes, including for ValueTask methods, and [AsyncCallId] identifies one execution of the async method.
  • Interception. [Binding] gives an object that invokes the original method, field or property.
  • [This], [Declaration] and [DeclarationIdentifier] for the instance and the reflection object of the target.

Grouping also got simpler. [SelfPointcut] and the Master property are optional when the grouping of the advices is unambiguous, so an aspect that applies to a single method, field, property or event needs neither.

Should you modernize your aspects?

Not necessarily. Simple aspects remain fully supported, and you can keep the AdviceArgs signature in the following cases:

  • The aspect intercepts events, validates fields or parameters, or uses OnInvokeAsync. These advices do not accept bound parameters yet.
  • The aspect reads the value yielded by an iterator, which only MethodExecutionArgs.YieldValue gives.
  • The aspect is not on a performance-sensitive path, and you prefer the simpler programming model.

The other limitations are listed in the reference.

For the aspects that do run on hot paths, the conversion is mechanical. Change the base class to MethodLevelAspect or LocationLevelAspect, turn each override into an advice method with the matching attribute, replace each args member with a bound parameter, and build. PostSharp tells you what does not fit. The Modernizing Your Custom Aspects article gives the steps and a table that maps each member of MethodExecutionArgs, MethodInterceptionArgs and LocationInterceptionArgs to its binding.

Try it

Advice parameter binding is available from PostSharp 2027.0, which also adds support for .NET 11 and C# 15. See What’s New in PostSharp 2027.0 for the full list. Pick one aspect on your hottest path, convert it, and tell us how it went on GitHub.

Happy meta-programming with PostSharp!