Home About Eric Topics SourceGear

2023-03-02 13:00:00

Delegates

This is part of a series on Native AOT.
Previous -- Top -- Next


Developing with .NET often involves delegates, which we can think of as objects that represent things that are callable. For example:

public static int count_files_with_e(string path)
{
    return System.IO.Directory.GetFiles(path)
        .Where(x => x.Contains("e"))
        .Count()
        ;
}

The extension method Where accepts a delegate. We're calling it with a lambda expression, which the compiler converts into the correct delegate type.

How do we deal with delegates in a Native AOT library?

The signature for System.Linq.Enumerable.Where() is fairly hard on the eyes:

public static IEnumerable<TSource> Where<TSource>(
    this IEnumerable<TSource> source, 
    Func<TSource,bool> predicate
    );

All those generics! Yikes. We need to start with a simpler example:

public delegate int MapOne(int x);

public static int MapSum(
    int n, 
    MapOne f
    )
{
    var sofar = 0;
    for (var i=0; i<n; i++)
    {
        sofar += f(i);
    }
    return sofar;
}

Calling MapSum from C# with a lambda expression might look like this:

public static int CountDivisibleBy42(int n)
{
    return MapSum(
        n, 
        x => ((x % 42) == 0) ? 1 : 0
        );
}

But functions exposed by a Native AOT library must follow the rules of C, and C doesn't have delegates -- it has function pointers:

typedef int (*MapOne)(int);

For the sake of illustration, let's observe that C# 9 added support for function pointers, so one option here is to just rewrite MapSum to use them:

[UnmanagedCallersOnly(EntryPoint = "map_sum_with_funcptr")]
public static unsafe int MapSumWithFuncPtr(
    int n, 
    delegate* unmanaged<int,int> f
    )
{
    var sofar = 0;
    for (var i=0; i<n; i++)
    {
        sofar += f(i);
    }
    return sofar;
}

This results in a function signature which is compatible with Native AOT, so we could call it from C. First, since C doesn't have lambdas, we need to define the map function:

int divisible_by_42(int x)
{
    return ((x % 42) == 0) ? 1 : 0;
}

And the call from C to the Native AOT function looks like this:

    int32_t total = map_sum_with_funcptr(
        1000, 
        divisible_by_42
        );
    printf("%d\n", total);

But Native AOT libraries won't be much fun if we need to rewrite everything. It would be preferable to leave MapSum unchanged and still provide a way to call it. In other words, we want to convert our function pointer into a delegate. We can do that with System.Delegate.CreateDelegate().

The .NET class libraries provide CreateDelegate as a way to create delegates of a given type from other methods. It has several overloads, but none of them accept a function pointer, so we need to wrap our function pointer in a something that CreateDelegate can accept. I call this wrapper a "shadow" class:

private unsafe class my_shadow 
{
    readonly delegate* unmanaged<int,int> _funcptr;
    public my_shadow(delegate* unmanaged<int,int> funcptr)
    {
        _funcptr = funcptr;
    }
    public unsafe int Invoke(int x)
    {
        return _funcptr(x);
    }
}

Now we have a regular C# object that contains the function pointer and provides a method to invoke it. So we can create a delegate of type MapOne by making an instance of that shadow class and passing it to CreateDelegate:

var shadow = new my_shadow(funcptr);
var del = Delegate.CreateDelegate(
    typeof(MapOne), 
    shadow, 
    typeof(my_shadow).GetMethod("Invoke")
    );

Note that some of the overloads for CreateDelegate cause AOT or trimmer warnings. I'm using an overload that Native AOT likes.

Using the GCHandle approach described previously, we can return the delegate object across the Native AOT boundary so it can be passed back, and for that purpose we need to expose our original MapSum function with a signature that accepts that delegate object handle:

[UnmanagedCallersOnly(EntryPoint = "map_sum_with_delegate")]
public static int MapSumWithDelegate(
    int n, 
    IntPtr del
    )
{
    var actualDelegate = (MapOne) GCHandle.FromIntPtr(del).Target;
    return MapSum(n, actualDelegate);
}

And finally, we can call MapSum from C like this:

    intptr_t del = create_delegate(
        divisible_by_42
        );
    int32_t total = map_sum_with_delegate(
        1000, 
        del
        );
    printf("%d\n", total);

The code for this blog entry is available at:

https://github.com/ericsink/native-aot-samples/tree/main/delegate_i32