Draco-lang / Language-suggestions

Collecting ideas for a new .NET language that could replace C#
75 stars 5 forks source link

Delegate types #121

Open LPeter1997 opened 1 year ago

LPeter1997 commented 1 year ago

I'd like to lay out the current situation of delegate types in C#, try to give a bit of (likely incorrect) history and tendencies and propose a direction for Draco.

What are delegates?

In essence, they are the mechanism to describe the types of methods/functions. This allows us to take functions as parameters or return functions from other functions. Likely every .NET developer is familiar with passing in functions as parameters, as it's the heart of LINQ. When we can view functions just like any other values, we call them first-class functions. Some very simple examples:

// A function that takes another function as a parameter
public static void MeasureElapsedTime(Action action)
{
    var sw = new Stopwatch();
    sw.Start();
    action();
    var elapsed = sw.Elapsed;
    Console.WriteLine($"Execution took {elapsed.TotalMilliseconds} ms.");
}

// A function that returns a function
public static Predicate<Item> CreateFilter(int? minPrice, int? maxPrice)
{
    // By default we accept all items
    Predicate<Item> result = i => true;

    // We build a layer of checks on for whatever filter is specified
    if (minPrice is not null) result = i => result(i) && i.Price >= minPrice.Value;
    if (maxPrice is not null) result = i => result(i) && i.Price <= maxPrice.Value;

    return result;
}

Delegate types in C

The first form of delegates came in C# 1.0 and they are still needed today in very rare scenarios. You simply declare a method signature with the keyword delegate, and specify the delegate type name where the method name would come:

delegate int IntegerOperation(int a, int b);

static int Add(int x, int y) => x + y;
static int Multiply(int x, int y) => x * y;

IntegerOperation op = Add;
op = Multiply;

Importantly, delegate types are always considered completely different types, even if they match the signature in every aspect. They can only be explicitly converted to one another:

delegate int IntegerOperation(int a, int b);
delegate int NumericOperation(int a, int b);

static int Add(int x, int y) => x + y;

IntegerOperation a = Add;
NumericOperation b = a; // ERROR
NumericOperation c = new NumericOperation(a); // OK

This means that C# experimented with nominal-typing for methods, trying to attach meaning behind the signature shape. While nominal typing can be great for types with more interesting behavior, it did not really work out on method level, and around C# 3.0 two delegate types were shipped with .NET that we use to this day: Func<...> and Action.

There is nothing magical with Func and Action, they are a bunch of delegate declarations within the BCL (see Func and Action). While this does not eliminate the flaws of nominal typing, as long as everyone uses these declarations, methods with identical signature should be compatible with each other.

There is one thing that these declarations can not fix: since generics can't describe byref parameters and the like, for these methods we still need to declare an old-school delegate type. There is also the annoying differentiation between Func and Action, because void can not be a generic argument.

Delegate-types in F

I'm not too familiar with F# personally, but as far as I know, they have the exact same toolset as C#. They implicitly type their lambdas using FSharpFunc, which is roughly equivalent to Func.

Proposal for Draco

It's very clear to me that nominal typing didn't work out for methods (see Func<T, bool> being used in so-so many places of the BCL instead of Predicate<T>). While the fundamental decision of delegate types can not be removed, we can hide it from the user. I propose a structural notation for our function types:

func add(a: int32, b: int32): int32 = a + b;
func mul(a: int32, b: int32): int32 = a * b;

var op: (int32, int32) -> int32 = add;
op = mul;

Functions will always be implicitly typed with their exact signature type, so the above works even without the type annotations:

var op = add;
op = mul;

Low-level details

Of course, the question is how this is supposed to work on a codegen level.

For the majority of cases, we can use Func and Action ourselves. If there is nothing special in the signature and these types can describe it, we should default to these. When the function returns unit, we use Action, otherwise we use Func. This will greatly help interop with C#.

For cases where these are insufficient, we need to generate a delegate type, in case the signature needs to appear somewhere. For example, the following code:

func swap(ref a: int32, ref b: int32) {
    val tmp = a;
    a = b;
    b = tmp;
}

func main() {
    val swapper = swap;
}

Would be equivalent to the following C# code (only the delegate and main functions):

delegate void swapDelegate(ref int a, ref int b);

static void main() {
    swapDelegate swapper = swap;
}

Whenever during codegen delegates of different types are assigned, we implicitly call the converting constructor that one would explicitly call in C#.

Long-term suggestions

One might argue that nominally typed methods do come in handy sometimes. I believe that we could take inspiration from Java here, namely that we could allow single lambdas to implement single-method interfaces. Not only could this be a nice utility, but it would fill in the gap for the very few cases where nominally typed functions - mainly for behavioral patterns - are preferable.

jl0pd commented 1 year ago

Few notes:

  1. FSharpFunc is an abstract class. In C# myList.Select(x => x) will create static function and pass it into Func<T, T>. While in F# myList |> Seq.map (fun x -> x) will create sealed class inherited from FSharpFunc<T, T>. To my knowledge classes with virtual methods work faster than delegates. Also FSharpFunc is built with mind on currying and optimizes some partial application scenarios.

  2. What type of swapper would be if it stored inside public field? If name would be synthesized it should be reliable, so downstream libraries won't break if upstream is just recompiled.

  3. Java have so called functional interfaces which are equivalent of delegates in JVM world. These are just simple interfaces, that compiler threat differently. If feature like this is to be implemented, it's better to use object expressions which allow to implement arbitrary interface/abstract_class

LPeter1997 commented 1 year ago

So FSharpFunc for us is in the same boat as Func, it can't escape the limitations of generics. I wonder if we should also model delegates as virtual functions and provide a conversion to the C# delegates for interop or something. I guess we need the input of some lowlevel/perf peeps here.

What type of swapper would be if it stored inside public field? If name would be synthesized it should be reliable, so downstream libraries won't break if upstream is just recompiled.

Indeed, this is a hard problem and stability came up as a problem. I can not answer that, but can bring more problematic code that has been brought up, for example a C# method taking SomeDelegate[], in which case we'd have to do some funky variance with arrays that I'd rather avoid. Since there are an infinite number of edge-cases, in the end we'll likely have to introduce delegate type declaration anyway.

I didn't know object expressions were a thing. At first glance they look like anonymous classes from Java.