DEV Community

Patrick Kelly
Patrick Kelly

Posted on

C#'s Generic Math from F#

At this rate I should just legally rename myself to the language bindings guy or something. Fourth year in a row and I've got more to talk about when it comes to .NET bindings. But hey, I'm not the only one this time! This year? The team introduced some goodies with .NET 7.0 and C# 11. Generic Math is one of particular interest for use from F#, because of how well it fits with how F# already works. Like everything Microsoft has developed however, it clearly was rushed and has some rough spots. We'll be exploring those, and how I've found for working around them.

Getting Started

I must really be milking this subject, right? You've read my last articles, you know how this is going to work. The generic math feature introduced with C# 11 and .NET 7.0 is just methods off generic interfaces. We can just call them directly like this:

let inline abs (value:^a) = ^a.Abs(value)
Enter fullscreen mode Exit fullscreen mode

And that's it! I don't need to write another article on this again. Try it though. It fails. Miserably. The syntax analyzer will recognize the problem: this just isn't valid F#.

So what about this?

let inline abs (value:^a) = INumberBase< ^a>.Abs(value)
Enter fullscreen mode Exit fullscreen mode

Not invalid syntax anymore, but this still won't compile. So how do we actually call this then? The trick is in a simplified form of what we've already done before:

public static T Abs<T>(this T value)
    where T : INumberBase<T> => T.Abs(value);
Enter fullscreen mode Exit fullscreen mode

and

let inline abs (value) = Extensions.Abs(value)
Enter fullscreen mode Exit fullscreen mode

This actually solves another problem as well, or, at least what I consider a problem: numerical functions from the Generic Math API have poor discoverability the way they were implemented. If you don't want extension methods on the C# side, just remove the this part; no other changes are necessary.

Cranking to 11

Okay, that was simple, just unexpected. Surely that's it, and I'm still just milking an article. Four years straight of this? Well... We're about to need to dive into the internals of the F# standard library and features that only exist inside of it. Yeah, this just went hard. The problem lies with a few of the Generic Math interfaces that are kinda already represented in F#, but not in the same way: operators. What do I mean? Let's look at how F# implements the operators:

let inline (+) (x: ^T) (y: ^U) : ^V = 
    AdditionDynamic<(^T),(^U),(^V)>  x y 
    when ^T : int32       and ^U : int32      = (# "add" x y : int32 #)
    when ^T : float       and ^U : float      = (# "add" x y : float #)
    when ^T : float32     and ^U : float32    = (# "add" x y : float32 #)
    when ^T : int64       and ^U : int64      = (# "add" x y : int64 #)
    when ^T : uint64      and ^U : uint64     = (# "add" x y : uint64 #)
    when ^T : uint32      and ^U : uint32     = (# "add" x y : uint32 #)
    when ^T : nativeint   and ^U : nativeint  = (# "add" x y : nativeint #)
    when ^T : unativeint  and ^U : unativeint = (# "add" x y : unativeint #)
    when ^T : int16       and ^U : int16      = (# "conv.i2" (# "add" x y : int32 #) : int16 #)
    when ^T : uint16      and ^U : uint16     = (# "conv.u2" (# "add" x y : uint32 #) : uint16 #)
    when ^T : char        and ^U : char       = (# "conv.u2" (# "add" x y : uint32 #) : char #)
    when ^T : sbyte       and ^U : sbyte      = (# "conv.i1" (# "add" x y : int32 #) : sbyte #)
    when ^T : byte        and ^U : byte       = (# "conv.u1" (# "add" x y : uint32 #) : byte #)
    when ^T : string      and ^U : string     = (# "" (String.Concat((# "" x : string #),(# "" y : string #))) : ^T #)
    when ^T : decimal     and ^U : decimal    = (# "" (Decimal.op_Addition((# "" x : decimal #),(# "" y : decimal #))) : ^V #)
    // According to the somewhat subtle rules of static optimizations,
    // this condition is used whenever ^T is resolved to a nominal type or witnesses are available
    when ^T : ^T = ((^T or ^U): (static member (+) : ^T * ^U -> ^V) (x,y))
Enter fullscreen mode Exit fullscreen mode

If you've never seen (# #) blocks in F# before, congratulations, we're about to learn about something you can never use in your own code! This is a special feature only usable within the F# standard library, allowing for inline "IL" to be written. Kinda. It's mostly IL but there's some conveniences. We can't use this, but it is useful to understand what is going on in this method, to see what we can or can't do about it. If we want to include the Generic Math operators, this behavior has to be reimplemented in entirety. So let's break this down.

AdditionDynamic<(^T),(^U),(^V)>  x y
Enter fullscreen mode Exit fullscreen mode

This is clearly some internal from the standard library, so, what does that look like?

let AdditionDynamic<'T1, 'T2, 'U> (x: 'T1) (y: 'T2) : 'U =
    if type3eq<'T1, 'T2, 'U, int32> then convPrim<_,'U> (# "add" (convPrim<_,int32> x) (convPrim<_,int32> y) : int32 #) 
    elif type3eq<'T1, 'T2, 'U, float> then convPrim<_,'U> (# "add" (convPrim<_,float> x) (convPrim<_,float> y) : float #) 
    elif type3eq<'T1, 'T2, 'U, float32> then convPrim<_,'U> (# "add" (convPrim<_,float32> x) (convPrim<_,float32> y) : float32 #) 
    elif type3eq<'T1, 'T2, 'U, int64> then convPrim<_,'U> (# "add" (convPrim<_,int64> x) (convPrim<_,int64> y) : int64 #) 
    elif type3eq<'T1, 'T2, 'U, uint64> then convPrim<_,'U> (# "add" (convPrim<_,uint64> x) (convPrim<_,uint64> y) : uint64 #) 
    elif type3eq<'T1, 'T2, 'U, uint32> then convPrim<_,'U> (# "add" (convPrim<_,uint32> x) (convPrim<_,uint32> y) : uint32 #) 
    elif type3eq<'T1, 'T2, 'U, nativeint> then convPrim<_,'U> (# "add" (convPrim<_,nativeint> x) (convPrim<_,nativeint> y) : nativeint #) 
    elif type3eq<'T1, 'T2, 'U, unativeint> then convPrim<_,'U> (# "add" (convPrim<_,unativeint> x) (convPrim<_,unativeint> y) : unativeint #) 
    elif type3eq<'T1, 'T2, 'U, int16> then convPrim<_,'U> (# "conv.i2" (# "add" (convPrim<_,int16> x) (convPrim<_,int16> y) : int32 #) : int16 #)
    elif type3eq<'T1, 'T2, 'U, uint16> then convPrim<_,'U> (# "conv.u2" (# "add" (convPrim<_,uint16> x) (convPrim<_,uint16> y) : uint32 #) : uint16 #)
    elif type3eq<'T1, 'T2, 'U, char> then convPrim<_,'U> (# "conv.u2" (# "add" (convPrim<_,char> x) (convPrim<_,char> y) : uint32 #) : char #)
    elif type3eq<'T1, 'T2, 'U, sbyte> then convPrim<_,'U> (# "conv.i1" (# "add" (convPrim<_,sbyte> x) (convPrim<_,sbyte> y) : int32 #) : sbyte #)
    elif type3eq<'T1, 'T2, 'U, byte> then convPrim<_,'U> (# "conv.u1" (# "add" (convPrim<_,byte> x) (convPrim<_,byte> y) : uint32 #) : byte #)
    elif type3eq<'T1, 'T2, 'U, string> then convPrim<_,'U> (String.Concat(convPrim<_,string> x, convPrim<_,string> y))
    elif type3eq<'T1, 'T2, 'U, decimal> then convPrim<_,'U> (Decimal.op_Addition(convPrim<_,decimal> x, convPrim<_,decimal> y))
    else BinaryOpDynamicImplTable<OpAdditionInfo, 'T1, 'T2, 'U>.Invoke "op_Addition" x y
Enter fullscreen mode Exit fullscreen mode

Oh dear god, this is a mess, and it's still not even the entire thing, because there's that BinaryOpDynamicImplTable<OpAdditionInfo, 'T1, 'T2, 'U> type as well. However, we have everything we need to look at here, to see what behavior we need to provide. Going back to the (+) definition, let's look at one of the lines.

when ^T : int32       and ^U : int32      = (# "add" x y : int32 #)
Enter fullscreen mode Exit fullscreen mode

This isn't too bad. When ^T and ^U, which are both input types, are both int32, do (# "add" x y : int32 #); just a simple add instruction. There's many more lines like this that you can interpret the same way. So let's look at one with a bit more going on.

when ^T : int16       and ^U : int16      =
    (# "conv.i2" (# "add" x y : int32 #) : int16 #)
Enter fullscreen mode Exit fullscreen mode

You recognize the (# "add" x y : int32 #) part from before. Critically here though, you'll recognize the : int32 portion clashes with the int16 type of the parameters. However, if you're used to C#, you might recognize this is correct behavior. .NET adds smaller numbers as 32-bit numbers. You have to convert back if you want smaller numbers again. (# "conv.i2" is the instruction for converting the top of the stack to an i2, or a two-byte integer; in F# you know this as a 16-bit integer, or int16. And lastly : int16 #) is just a typemark, which we now know matches the i2 part of conv.

To summarize at this point, we've seen two behaviors. For numerics larger than 32-bits, they are added directly. For numerics smaller than 32-bits, they are upsized, added, and then converted back to their input size.

when ^T : string      and ^U : string     = (# ""
    (String.Concat((# "" x : string #),(# "" y : string #)))
    : ^T #)
Enter fullscreen mode Exit fullscreen mode

There's parts of this that I could explain but won't. The critical part is something you've probably already figured out: when the input is two strings, concatenate them. We see this behavior in C# as well, and it's exactly the same.

This is now three different behaviors. Lovely.

when ^T : decimal     and ^U : decimal    = (# ""
    (Decimal.op_Addition((# "" x : decimal #),(# "" y : decimal #)))
    : ^V #)
Enter fullscreen mode Exit fullscreen mode

Read this the same as the string one, and you can see that given two decimal, we're calling Decimal.op_Addition on them. This is the internal name for the addition operator, however your language presents it.

That leaves one last line in the main section:

when ^T : ^T = ((^T or ^U):
    (static member (+) : ^T * ^U -> ^V) (x,y))
Enter fullscreen mode Exit fullscreen mode

And uhh... this is one I understand but don't understand, if you understand me. (static member (+) : ^T * ^U -> ^V) (x,y)) is something you've seen before if you've read my previous articles. We're calling op_Addition on the parameters. when ^T : ^T is the part I'm a little confused by. Not constraining ^T at all theoretically makes sense as a catch all, but considering I could never actually get this line of code to execute, I think they've made a mistake here. Regardless, the intention is for the standard + operator to fallback to any defined op_Addition that exists.

So what about the next block, the AdditionDynamic part? I'll let you read through that. It's another case where I understand but don't understand. It's basically a repeat of what we already covered, but is supposed to include behavior to automatically convert compatible types. If this sounds like something F# doesn't do, congratulations, you see why I'm confused.

So why do I say F#'s fallback addition doesn't work? Well, the reference project I have for writing this has the following definitions:

/// <inheritdoc/>
public static Percent<T> operator +(Percent<T> left, Percent<T> right) =>
    new(left.Value + right.Value);

/// <inheritdoc/>
public static T operator +(T left, Percent<T> right) =>
    left + right.Of(left);
Enter fullscreen mode Exit fullscreen mode

The thing is, the ^T percent -> ^T percent -> ^T percent one works fine, it's the ^T -> ^T percent -> ^T one that doesn't. Looking back at the + definition, it should have caught this, so, what's going on here?

Let's review what necessary behaviors we're after:

  1. Numerics greater than the working size of .NET (32-bit) can be added together directly and their results returned.
  2. Numerics lesser than the working size of .NET (32-bit) must be converted to 32-bit numerics of compatible typeclass then added together, with their results converted back to the origin type.
  3. Strings are concatenated together.
  4. Any types with a defined op_Addition should have that used.

Is there another way we can get all this behavior? Well, yeah, actually. Let's start with this:

/// <summary>Adds two values together to compute their sum.</summary>
let inline ( + ) (left:^left) (right:^right):^result =
    ((^left or ^right) :
    (static member (+) : ^left -> ^right -> ^result)(left, right))
Enter fullscreen mode Exit fullscreen mode

As you can probably tell, this is very similar to that catch-all line from the standard definition, although there's some differences. Funnily enough, this works in almost all of the cases we need it to. For 1, 3, and 4 of the requirements we laid out, this already works great. All of these tests will compile and pass fine:

Assert.Equal(2, 1 + 1)
Assert.Equal(2.0, 1.0 + 1.0)
Assert.Equal("hello!", "hello" + "!")
Assert.Equal(12.5, 10.0 + percent(25.0))
Enter fullscreen mode Exit fullscreen mode

And that's pretty awesome, because there's wayyyyy less code on our end.

But what about requirement 2?

I'll leave this one to you to fix, as practice makes perfect and there's a lot to digest in this article. My tip is that the solution is something we covered right here in this article, but there's another way to solve it that comes from a prior article. As it stands now, adding numerics smaller than 32-bits adds fine, but returns a 32-bit numeric, which is standard behavior for .NET itself, C#, and VB. All that needs to be done is that in these cases, the return value be converted back to the origin type. In the rest of the cases, we're already got correct behavior.

If you've made it this far, the good news is that I've already done all the work in implementing both the base library and F# bindings for this.

Top comments (0)