fslang-suggestions icon indicating copy to clipboard operation
fslang-suggestions copied to clipboard

Allow static constraints to be named and reused

Open njlr opened this issue 3 years ago • 18 comments

I propose we allow constraints to be named for easier reuse.

For example, consider this code:

// Invalid
type Point<'t when 't : equality
               and 't : comparison
               and 't : (static member get_Zero : Unit -> 't)
               and 't : (static member ( + ) : 't * 't -> 't)
               and 't : (static member ( - ) : 't * 't -> 't)
               and 't : (static member ( * ) : 't * 't -> 't)
               and 't : (static member ( / ) : 't * 't -> 't)> = 
  {
    X : 't
    Y : 't
  }

type Circle<'t> =
  {
    Center : Point<'t>
    Radius : 't
  }

type Rectangle<'t> =
  {
    Center : Point<'t>
    HalfSize : Point<'t>
  }

This won't compile because the 't in type Circle and type Rectangle must have the constraints of Point:

// Fixed
type Point<'t when 't : equality
               and 't : comparison
               and 't : (static member get_Zero : Unit -> 't)
               and 't : (static member ( + ) : 't * 't -> 't)
               and 't : (static member ( - ) : 't * 't -> 't)
               and 't : (static member ( * ) : 't * 't -> 't)
               and 't : (static member ( / ) : 't * 't -> 't)> = 
  {
    X : 't
    Y : 't
  }

type Circle<'t when 't : equality
                and 't : comparison
                and 't : (static member get_Zero : Unit -> 't)
                and 't : (static member ( + ) : 't * 't -> 't)
                and 't : (static member ( - ) : 't * 't -> 't)
                and 't : (static member ( * ) : 't * 't -> 't)
                and 't : (static member ( / ) : 't * 't -> 't)> =
  {
    Center : Point<'t>
    Radius : 't
  }

type Rectangle<'t when 't : equality
                   and 't : comparison
                   and 't : (static member get_Zero : Unit -> 't)
                   and 't : (static member ( + ) : 't * 't -> 't)
                   and 't : (static member ( - ) : 't * 't -> 't)
                   and 't : (static member ( * ) : 't * 't -> 't)
                   and 't : (static member ( / ) : 't * 't -> 't)> =
  {
    Center : Point<'t>
    HalfSize : Point<'t>
  }

However, across many types this becomes quite repetitive.

Also, if a new constraint were to be added (e.g. 't : (static member get_One : Unit -> 't)) the code must be changed in many places.

I propose we add a way to name a group of constraints so that they can be reused:

// Proposal
constraint Numeric 't = 
                   't : equality
               and 't : comparison
               and 't : (static member get_Zero : Unit -> 't)
               and 't : (static member ( + ) : 't * 't -> 't)
               and 't : (static member ( - ) : 't * 't -> 't)
               and 't : (static member ( * ) : 't * 't -> 't)
               and 't : (static member ( / ) : 't * 't -> 't)

type Point<'t when 't : Numeric> = 
  {
    X : 't
    Y : 't
  }

type Circle<'t when 't : Numeric> =
  {
    Center : Point<'t>
    Radius : 't
  }

type Rectangle<'t when 't : Numeric> =
  {
    Center : Point<'t>
    HalfSize : Point<'t>
  }

The existing way of approaching this problem in F#... is manually write out the constraints every time

Considering F# already allows type aliases, I think constraint aliases fits with the philosophy of the language.

type T = My<Complex<Foo<Bar, Qux>>, int>

My example concerns numeric types, but I'm sure this would be applicable to other scenarios too.

Pros and Cons

The advantages of making this adjustment to F# are...

  • Easier to read and maintain type definitions with generic constraints
  • Better error messages - we can give constraints meaningful names

The disadvantages of making this adjustment to F# are ...

  • More syntax, new keyword
  • Compiler will have to accumulate a map of constraint aliases for type checking
  • Decisions to make around how these work in compiled libraries
  • Other suggestions may make this redundant

Extra information

Estimated cost (XS, S, M, L, XL, XXL): M?

Related suggestions:

  • https://github.com/fsharp/fslang-suggestions/issues/440
  • https://github.com/fsharp/fslang-suggestions/issues/641

Affidavit (please submit!)

Please tick this by placing a cross in the box:

  • [x] This is not a question (e.g. like one you might ask on stackoverflow) and I have searched stackoverflow for discussions of this issue
  • [x] I have searched both open and closed suggestions on this site and believe this is not a duplicate
  • [x] This is not something which has obviously "already been decided" in previous versions of F#. If you're questioning a fundamental design decision that has obviously already been taken (e.g. "Make F# untyped") then please don't submit it.

Please tick all that apply:

  • [x] This is not a breaking change to the F# language design
  • [x] I or my company would be willing to help implement and/or test this

For Readers

If you would like to see this issue implemented, please click the :+1: emoji on this issue. These counts are used to generally order the suggestions by engagement.

njlr avatar Oct 21 '21 09:10 njlr

This is just approximating type classes.

Happypig375 avatar Oct 21 '21 13:10 Happypig375

The existing way of approaching this problem in F#... is manually write out the constraints every time

Actually, you can use an active pattern for this:

// constraints.fs...
let inline (|HasName|) x = (^a : (member Name: string) x)

// somewhere-else.fs...
let inline printName (HasName name) = printfn $"{name}"

type Person1 = { Name: string; Age: int }
type Person2 = { Name: string; Age: int; IsFunny: bool }

type Name(name) =
    member _.Name = name

let p1 = { Name = "Phillip"; Age = 30 }
let p2 = { Name = "Phillip"; Age = 30; IsFunny = false }
let nm = Name "Phillip"

M.printName p1
M.printName p2
M.printName nm

cartermp avatar Oct 21 '21 14:10 cartermp

@cartermp Except that active patterns don't work for type parameters for types, only local functions.

Happypig375 avatar Oct 21 '21 16:10 Happypig375

I don't think that matters here. My comment is about an existing solution to the problem identified, not about providing an exact way to do what the original issue is looking to accomplish.

cartermp avatar Oct 21 '21 18:10 cartermp

This is just approximating type classes.

I think that type classes would be a much larger feature since it allows you to add implementations for existing types.

njlr avatar Oct 21 '21 18:10 njlr

When dismissing a general proposal by claiming the specific example in the proposal can be solved by active patterns, one should offer the active pattern solution to the example. Let LOCs fall where they will.

I like this idea, and I like the idea of making SRTPs more accessible through normalizing and documenting the feature.

Long live https://github.com/fsharp/fslang-design/blob/main/RFCs/FS-1043-extension-members-for-operators-and-srtp-constraints.md

jackfoxy avatar Oct 21 '21 19:10 jackfoxy

I don't think this discussion should be separated from static interface methods and generic maths which is currently being previewed in .Net. Only if that project fails does this need to be considered. SRTPs are only a workaround feature after all.

I suggest contributing to the discussion on generic maths https://github.com/dotnet/designs/pull/205 . Your Numeric is similar to the numeric operations in INumber, i.e. describing something field-like.

charlesroddie avatar Oct 21 '21 22:10 charlesroddie

SRTPs are only a workaround feature after all.

I don't really view them as a workaround, more as a pragmatic solution that doesn't give everyone what the want

  1. The guaranteed inlining and static resolution of SRTP can be seen as a limitation that leads to a strong performance result - for all its other faults you can at least generally trust SRTP to flatten and perform. To me this gives SRTP a place regardless of https://github.com/dotnet/designs/pull/205. There is no "perfect" in this space, and static resolution tradesoff against other things.

  2. The structural nature of SRTP constraints leads to relatively low impact on framework design

Will .NET generic math succeed? I'm not really sure. I've no real problem with the feature getting added and it will work fine from F#. However I personally fundamentally dislike like programmers using hierarchical math classifications linking to abstract algebra (fields etc.), despite the joys of my Year II abstract algebra class. I prefer structural, inferred constraints emerging from implementations - and I'm sure I'm not the only one. I strongly suspect there will be relatively little generic math code written in C# (I mean there will be a lot - it's a huge ecosystem and will be proportional to the ecosystem size), and what gets written will not necessarily get used, and what gets used will not necessarily be trusted reputationally w.r.t. performance.

That said, I'll probably be glad when it's there and would use it in, say, DiffSharp.

This is just approximating type classes.

Not really, for the two reasons above.

dsyme avatar Oct 22 '21 12:10 dsyme

FIW this is actually a duplicate of the declined https://github.com/fsharp/fslang-suggestions/issues/456

dsyme avatar Oct 22 '21 12:10 dsyme

Three thoughts

  1. We could also consider allowing something like this. Possibly a Constraint attribute would be needed on Has to indicate that the constraints implied by its use in the declaration of Point should be added to the declaration of 'T instead of giving an error
type Has<'T when 'T : equality
             and 'T : comparison
             and 'T : (static member get_Zero : Unit -> 'T)
             and 'T : (static member (+) : 'T * 'T -> 'T)
             and 'T : (static member (-) : 'T * 'T -> 'T)
             and 'T : (static member (*) : 'T * 'T -> 'T)
             and 'T : (static member (/) : 'T * 'T -> 'T)> =  'T

type Point<'T when 'T: Has<'T> > = 
  {
    X : 'T
    Y : 'T
  }
  1. This is actually useful for adhoc combinations of other constraints too, including generic math subtype constraints and equality/comparison shown above

  2. I've been wondering whether we should make constraints on nominal types entirely programmatic via type-provider-addins. That is a type provider could implement, a System.Type -> bool using whatever logic it likes. Things like unmanaged, equality and comparison could have been implemented like that. The advantages of putting that sort of thing at the type provider level is that you get total computational power while not making whacky constraint definition it central to the language

dsyme avatar Oct 22 '21 14:10 dsyme

I think this proposal will naturally be realized in some or another form when static abstract members land officially. I do not think that overloading SRTP with functionality is a good way forward.

En3Tho avatar Oct 25 '21 16:10 En3Tho

I think this proposal will naturally be realized in some or another form when static abstract members land officially. I do not think that overloading SRTP with functionality is a good way forward.

@En3Tho Note that the proposal above has nothing fundamentally to do with SRTP - it's allowing collections of constraints of any kind to be named (including F# equality and comparison constraints).

It's is reasonable to give names to adhoc collections of constraints in F# code, and it's orthogonal to anything else being considered.

dsyme avatar Oct 25 '21 16:10 dsyme

Yeah, but while I think using "shapes" or collections of constraints is perfectly good in inline functions, using them with types is a different matter - I fear it might bring even more interop related uncertainty.

How such Point type will be consumed by C#, for example? If there won't be a way, then we might just end up with 2 totally different syntaxes when static abstracts land, one that is .Net oriented and one that is F# only.

Maybe I'm too fixed on those, but in examples the main requirement are statics in generic constraints.

En3Tho avatar Oct 25 '21 18:10 En3Tho

How such Point type will be consumed by C#, for example?

That's orthogonal to this proposal, which is simply to allow collections of constraints to be named. SRTP constraints can already be declared on F# type parameters (but only invoked in F# inline code). The inlined methods are in theory usable from C# via the "witness-passing" entry points but in practice no one does this.

we might just end up with 2

This is inevitable. F# is going to end up with both nominal/hierarchical/.NET-compatible constraints (for all code) via "static abstract" and structural/adhoc/F#-only constraints (for inlined code) via SRTP. That's just how it is.

dsyme avatar Oct 27 '21 10:10 dsyme

I see. Thanks.

Well, my fear is that in general simplifying SRTP usage (e.g. reducing boilerplate, repetitive code etc. (https://github.com/fsharp/fslang-design/blob/main/RFCs/FS-1024-simplify-call-syntax-for-statically-resolved-member-constraints.md)) can lead to a larger use of SRTP than "indented", introducing an F# only split. I and hope mainstream famous F# libraries won't abuse it too much.

On the other hand usually, SRTP is used by those who know what they are dealing with. And I personally thought sometimes that "why couldn't there be SRTP shapes/interfaces?"

En3Tho avatar Oct 27 '21 15:10 En3Tho

I was wondering what the thinking is behind this syntax:

type Has<'T when 'T : equality
             and 'T : comparison
             and 'T : (static member get_Zero : Unit -> 'T)
             and 'T : (static member (+) : 'T * 'T -> 'T)
             and 'T : (static member (-) : 'T * 'T -> 'T)
             and 'T : (static member (*) : 'T * 'T -> 'T)
             and 'T : (static member (/) : 'T * 'T -> 'T)> =  'T

Normally = in F# means binding a type or value to a name, but here 'T appears on both sides of the equation.

njlr avatar Jun 30 '22 07:06 njlr

If interfaces with static abstract members are a thing, we could use them to describe a set of constraints. So if instead of 'T :> IFoo, we use another operator (tentatively: 'T ~ IFoo, read "T looks like IFoo") then it's an SRTP constraint instead of an interface implementation constraint.

type IHas<'T when 'T : equality and 'T : comparison> =
    static abstract Zero : 'T
    static abstract (+) : 'T * 'T -> 'T
    static abstract (-) : 'T * 'T -> 'T
    static abstract (*) : 'T * 'T -> 'T
    static abstract (/) : 'T * 'T -> 'T

let inline f<'T when 'T ~ IHas<'T>> (x: 'T) (y: 'T) =
    if x > y then x - y else 'T.Zero

There could also be an attribute or something on the interface declaration to indicate that this is only intended as a constraint, and shouldn't be compiled into an actual .NET interface. But this attribute wouldn't necessarily be mandatory to be able to use ~.

Tarmil avatar Jun 30 '22 08:06 Tarmil

@njlr, not sure if this answers your question, but it is based on type abbreviation syntax: https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/type-abbreviations

What is nice about them, compared to C# using alias is that they actually carry as abbreviations in places you open the module or namespace, in C# you define those in each place where using declarations can be defined.

@Tarmil, this looks like really good way to encode SRTP constraints by lifting IWSAM to define those.

smoothdeveloper avatar Jun 30 '22 11:06 smoothdeveloper