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

Make it optional to repeat the constraints in extensions

Open Tarmil opened this issue 1 year ago • 13 comments

I propose we make it optional to repeat the constraints on type parameters when writing an extension on a generic type.

type Foo<'T when 'T : struct> = { x: 'T }

// OK
type Foo<'T when 'T : struct> with
    member this.Y = 1

// Currently: error FS0957: One or more of the declared type parameters for this type extension
//   have a missing or wrong type constraint not matching the original type constraints on 'Foo<_>'
// Proposal: make this valid
type Foo<'T> with
    member this.Z = 1

The existing way of approaching this problem in F# is to repeat all constraints. However this is not always possible:

This is actually causing a regression in F# 9, as mentioned here. The problem is that .NET 9 introduces a new constraint allows ref struct which cannot (yet) be expressed in F#. So types that have this constraint currently cannot be extended. This is especially problematic because this constraint has been added to existing standard library types such as IEnumerable<T>.

// F# 8: OK
// F# 9: error FS0957
type System.Collections.Generic.IEnumerable<'T> with
    member _.Foo = ()

Pros and Cons

The advantages of making this adjustment to F# are

  • more conciseness when extending a type that has constraints.
  • working around the allows ref struct regression mentioned above, and avoiding similar future regressions.

The disadvantages of making this adjustment to F# are

  • constraints are less explicit I guess?

Extra information

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

Related suggestions: N/A

Affidavit (please submit!)

Please tick these items 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] This is a language change and not purely a tooling change (e.g. compiler bug, editor support, warning/error messages, new warning, non-breaking optimisation) belonging to the compiler and tooling repository
  • [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
  • [x] I have searched both open and closed suggestions on this site and believe this is not a duplicate

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.

Tarmil avatar Nov 14 '24 09:11 Tarmil

The motivating example with extending "allows ref struct" is by itself a reason to resolve this - I agree.

Even though we might add syntactical support for "allows ref struc" some time in the future, having a forward-compatible handling of yet-unknown constraints is good., especially in order to avoid syncing challenges with C# or runtime.

T-Gro avatar Nov 14 '24 09:11 T-Gro

It of course follows that the constraints would have to be copied over and still checked, not just ignored. Which is tricky for "allows ref struct" since F# will not be able to enforce it.

(e.g. prevent creation of a 'T array)

T-Gro avatar Nov 14 '24 09:11 T-Gro

Is this issue related?

// old code
type Dictionary<'Key, 'Value when 'Key: not null> with // FS0957 ... the type parameter 'Key requires a constraint of the form 'Key: not null...
    member t.TryGetValueSafe(k: 'Key) =
        let (b, v) = t.TryGetValue(k)
        if b then ValueSome v else ValueNone

type Dictionary<'Key, 'Value when 'Key: not null> with... // same error

Whatever is causing these things making constraints optional would be a possible temporary workaround, much better would be to express these type constraints in F#.

charlesroddie avatar Nov 16 '24 00:11 charlesroddie

"not null" constraint can be expressed, this is the syntax for doing a type augmentation for a Dictionary:

module TypeAugmentDictionary =
    open System.Collections.Generic
    type Dictionary<'TKey,'TValue when 'TKey:not null> with
        member x.WhatEver() = x.Count

Even when optional in a possibly new syntax, the constraints should still be typechecked - e.g. it should not be alowed to declare a local of TKey type and assign null to it, because this is not a support operation.

T-Gro avatar Nov 18 '24 11:11 T-Gro

I think it makes sense to think about constraints and anti-constraints ("allows ref struct") separately in the context of a possible design.

In the new proposal, if the constraints are syntactically optional in a type extension:

  • Are they automatically considered as if they were repeated and typechecked? This might be tricky with "allows ref struct". Not only because F# compiler does not know how to typecheck it in generic context as of now, but also because it might impose a complicated feature onto users who might have been living just fine without knowing what a "ref struct" is. (imagine wanting to write a .ToArray extension)
  • Are they not considered, and typechecking must treat the invocation of the augmentation member separately?

If the idea is the latter, it will enable members of an augmentation to further restrict the reach compared to the type being augmented. Either by adding a regular constraints, or by dropping an anti-constraint.

T-Gro avatar Nov 18 '24 11:11 T-Gro

It of course follows that the constraints would have to be copied over and still checked, not just ignored. Which is tricky for "allows ref struct" since F# will not be able to enforce it.

(e.g. prevent creation of a 'T array)

For an actual constraint, like eg 'T : null, then yes definitely. For an anti-constraint such as allows ref struct, I think we can do without. The question of what would happen if you pass a ref struct is kind of moot, since right now you can't have ref struct type parameters at all in F#. And if/when that is implemented, then the anti-constraint can be implicitly added to the augmentation as a non-breaking change.

For a point of comparison, see what happens with a function definition, where constraints are also optional.


let f (s: seq<'T>) = ()
/// val f: seq<'T> -> unit   (without the "allows ref struct" constraint)

let g (s: seq<'T>) = Seq.exists isNull s
/// val g: s: 'T seq -> bool when 'T: null

To me it would make sense that augmentations work similarly in these two cases.

Tarmil avatar Nov 18 '24 13:11 Tarmil

If you would do a type augmentation for Seq<>, and the generic code would not be typechecked for meeting "allows ref struct" criteria, what would happen if someone tries to invoke that extension member e.g. on Seq<Span<char>>.NewMember... ?

We cannot do without unless the member is checked separately, and then the member would not be offered for ref structs.

T-Gro avatar Nov 18 '24 14:11 T-Gro

"not null" constraint can be expressed, this is the syntax for doing a type augmentation for a Dictionary:

module TypeAugmentDictionary =
    open System.Collections.Generic
    type Dictionary<'TKey,'TValue when 'TKey:not null> with

OK so overall not relevant to this thread. On further analysis: with netstandard2.0 you can't have the not null constraint here, with dotnet9 you must have it, and multitargeting isn't possible with a single definition. Not a big deal to worry about since netstandard2.0 is on the way to obsolescence.

charlesroddie avatar Nov 18 '24 16:11 charlesroddie

#if for the constraint definition if augmenting a type that has different definitions across the TFM targets, I am afraid I do not have a better proposal here.

T-Gro avatar Nov 19 '24 11:11 T-Gro

I will mark this as approved in principle, but needing an RFC - the design must consider both constraints as well as anti-constraints and ensure correctness of generated code. It should also elaborate on backwards compatibility for existing code and potential syntax for opting in/out of "constraint copying" .(could it go just via attributes?)

T-Gro avatar Jan 20 '25 10:01 T-Gro

Is this why for example you have to use dummy types in SRTP for things like this? Apologies for any grossness in the code it's just a copy paste of an old snippet I have.

type ICounts() =
     static member inline Count< ^C when ^C: (member itemCount: int)> o =  (^C: (member itemCount: int) o)
     static member inline Count< ^C, 'Dummy1 when ^C: (member thingCount: int)> o =  (^C: (member thingCount: int) o)
     static member inline Count< ^C, 'Dummy1, 'Dummy2 when ^C: (member otherCount: int)> o =  (^C: (member otherCount: int) o)
     static member inline Count< ^C, 'Dummy1, 'Dummy2, 'Dummy3 when ^C: (member Count: int)> o =  (^C: (member Count: int) o)

let say count = 
    printfn "%d" count
    ()

say (ICounts.Count {|itemCount = 1|})
say (ICounts.Count {|thingCount = 2|})
say (ICounts.Count {|otherCount = 3|})
say (ICounts.Count {|Count = 4|})

voronoipotato avatar Feb 25 '25 17:02 voronoipotato

Is this why for example you have to use dummy types in SRTP for things like this? Apologies for any grossness in the code it's just a copy paste of an old snippet I have.

This is a different limitation that would remain - the need to have unique combinations of " arguments w/ types and number of type parameters" for overloads within the same type. This limitation flows from .NET rules, where witnesses for the members are emitted (even though F# will inline the calls).

T-Gro avatar Feb 25 '25 19:02 T-Gro

I sometimes wish it could automatically add the dummy types for you :P

voronoipotato avatar Feb 26 '25 03:02 voronoipotato