Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow static constraints to be named and reused #1089

Closed
5 tasks done
njlr opened this issue Oct 21, 2021 · 19 comments
Closed
5 tasks done

Allow static constraints to be named and reused #1089

njlr opened this issue Oct 21, 2021 · 19 comments

Comments

@njlr
Copy link

njlr commented Oct 21, 2021

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:

Affidavit (please submit!)

Please tick this by placing a cross in the box:

  • This is not a question (e.g. like one you might ask on stackoverflow) and I have searched stackoverflow for discussions of this issue
  • I have searched both open and closed suggestions on this site and believe this is not a duplicate
  • 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:

  • This is not a breaking change to the F# language design
  • 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 👍 emoji on this issue. These counts are used to generally order the suggestions by engagement.

@Happypig375
Copy link
Contributor

This is just approximating type classes.

@cartermp
Copy link
Member

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

@Happypig375
Copy link
Contributor

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

@cartermp
Copy link
Member

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.

@njlr
Copy link
Author

njlr commented Oct 21, 2021

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.

@jackfoxy
Copy link

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

@charlesroddie
Copy link

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 dotnet/designs#205 . Your Numeric is similar to the numeric operations in INumber, i.e. describing something field-like.

@dsyme
Copy link
Collaborator

dsyme commented Oct 22, 2021

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 Adding a rough draft of the "minimum viable product" for the .NET Libraries APIs to support generic math dotnet/designs#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
Copy link
Collaborator

dsyme commented Oct 22, 2021

FIW this is actually a duplicate of the declined #456

@dsyme
Copy link
Collaborator

dsyme commented Oct 22, 2021

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

@En3Tho
Copy link

En3Tho commented Oct 25, 2021

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.

@dsyme
Copy link
Collaborator

dsyme commented Oct 25, 2021

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.

@En3Tho
Copy link

En3Tho commented Oct 25, 2021

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.

@dsyme
Copy link
Collaborator

dsyme commented Oct 27, 2021

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.

@En3Tho
Copy link

En3Tho commented Oct 27, 2021

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?"

@njlr
Copy link
Author

njlr commented Jun 30, 2022

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.

@Tarmil
Copy link

Tarmil commented Jun 30, 2022

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 ~.

@smoothdeveloper
Copy link
Contributor

@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.

@njlr
Copy link
Author

njlr commented Jan 6, 2023

The new syntax works a treat:

type Numeric<'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 Numeric<'t>> =
  {
    X : 't
    Y : 't
  }

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

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

@njlr njlr closed this as completed Jan 6, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants