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

Use an Attribute to specify overload resolution priority #821

Open
5 tasks done
gusty opened this issue Dec 17, 2019 · 9 comments
Open
5 tasks done

Use an Attribute to specify overload resolution priority #821

gusty opened this issue Dec 17, 2019 · 9 comments

Comments

@gusty
Copy link

gusty commented Dec 17, 2019

I propose we add a way to use an Attribute to specify overload resolution priority

The existing way of approaching this problem in F# is adding a "marker" dummy parameter with a concrete class which inherit from other objects which will be used to mark less priority, since the overload resolution prefers the type which is closer in hierarchy.

This will be very valuable when using member constraints, projects like F#+ or the Task Builder, including the upcoming version baked into the compiler, make use of this workaround due to lack of a formal mechanism.

Pros and Cons

The advantages of making this adjustment to F# are:

  • More control over overload resolution.
  • Less cases leading to ambiguity resolution compile-time errors

The disadvantages of making this adjustment to F# are:

  • Adding an extra modifier attribute

Extra information

Estimated cost: S:

Related suggestions: #819 #820

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
@Tarmil
Copy link

Tarmil commented Jan 29, 2020

Another inconvenient is that the overload would be resolved differently in F# and in C#. Obviously it's not a problem in your use case of TaskBuilder, but still something to consider in general.

@smoothdeveloper
Copy link
Contributor

I remember trying to consider what it means to have an attribute as evocated in this suggestion, thinking of solutions like absolute index ranking but also other more "out of the box" aproaches.

It feels to me the suggestion is not precise enough (defining exact solution that is proposed) and that, in essence, it is working around flaws in current overload resolution because this mecanism is the only approach F# currently offers for compile time resolved polymorphism.

Enabling such extension of overload resolution is not really attacking more general and simpler issues with those (for example, making code like in dotnet/fsharp#2503 (comment) compile without needing more anotations or being more counter intuitive than what C# offers, to add to @Tarmil armil comment) but providing an escape hatch to enable slightly better story at designing APIs like F#+, whose sole option right now is using overload resolution and some internal knowledge of the compiler implementation quirks of it.

Designing APIs like F#+ provides currently mandates to rely on method overloading mecanism and really are a work around to the lack of extensible and compile time resolved polymorphism constructs in dotnet languages.

F# enables working around in limited maner with SRTP and overloads, accidentally, if I understand what Don Syme has expressed about the reasons SRTP exist in the language design and the F#+ implementation idioms.

The problem for designing those APIs (which do serve purpose, and can't be sumarized as just pointless abstractions) is not all with overload resolution being a bit of an issue as it is right now, but more with lack of other, more appropriate constructs to encode those approach to compile time resolved polymorphism.

It is given that it will be long before the things like traits (Rust parlance), type classes (Haskell parlance) or concepts (C++ from the future parlance) can land in the language (and/or the CLR), and adding what feels like unatural constructs to overload resolution sounds like more bagage for F# to carry in the meantime.

Bagage which should be removed in an ideal F# from the future where alternative(s) mecanism(s) may actually come in picture and enable APIs like F#+ to be encoded idiomatically, not so counterintuively as the languages forces right now.

I'm more keen on efforts to make F# be on par with C# where it currently fails to pick an overload, than exploring exotic solutions that remain strictly on "overload resolution being quirkier down the road" angle.

The aim of deep compiler work on overload resolution should be to be able to simplify the implementation, enabling reasoning about it / optimizing it for general cases (consuming regular OO BCL APIs, and devising similar APIs consumed idiomatically in OO languages) to more people than those currently knowing how it ends up working.

(sorry that was really long, hope some of my points can sprout discussions on the suggestion or the matter of overload definition/resolution ethics...)

@gusty
Copy link
Author

gusty commented Jan 29, 2020

@Tarmil

Another inconvenient is that the overload would be resolved differently in F# and in C#.

Do you think they resolve the same right now :)

I think, an attribute as last resort, knowing that it's an F# only construct as there are many others which will be ignored from other languages, is not that bad.

I still remember the old OverloadId attrbute of F# 1.0

Anyway, if someone have a better suggestion to solve this problem I would like to hear it.

@gusty
Copy link
Author

gusty commented Jan 30, 2020

I just came out with a better option.
Actually it's half a trick, but it doesn't work currently.

Currently the compiler can look for overloads defined in base types:

type TBase =
    static member inline method1 (x) = ...

type T =
    inherit TBase
    static member inline method1 (x) = ...

But by doing a trait call involving T , methods in TBase act exactly as if they were defined on T.

It would be nice to strictly give priority to methods in T, which means there should never be ambiguity between them.

This is much better than having a marker type passed as parameter (like the ones in the taskbuilder), which "pollutes" the signature of the member for the sole purpose of resolving ambiguity.

Another possibility is to use intrinsic extensions, it currently has an effect in certain cases, but not always.

@abelbraaksma
Copy link

abelbraaksma commented Jan 31, 2020

It would be nice to strictly give priority to methods in T, which means there should never be ambiguity between them.

@gusty, isn't that what's currently happening? If I define a method in T that is stricter than the signature in TBase, it will be picked. If they are both equal, T gives also preference. Only if T defines a wider definition, it seems to be ignored, and the base static method is called instead of the wider overload (assuming the argument passed would be valid for both).

Or is the latter precisely what you don't want to happen? If so, changing it would potentially be a backwards compatibility issue.

@gusty
Copy link
Author

gusty commented Jan 31, 2020

@abelbraaksma see answers below:

If I define a method in T that is stricter than the signature in TBase, it will be picked.

True

type TBase = 
    static member M<'T>(x: seq<'T>) = printfn "TBase"; x

type T = 
    inherit TBase
    static member M<'T>(x: list<'T>) = printfn "T";


T.M ([1])
// prints "T"

let inline traitCall (a: ^A, witnesses: ^W) = ((^A or ^W) : (static member M : _ -> _) (a))
traitCall ([1], Unchecked.defaultof<T>)
// prints "T"

If they are both equal, T gives also preference.

True only for direct overload calls, but false for trait-calls

type TBase = 
    static member M<'T>(x: list<'T>) = printfn "TBase"; x

type T = 
    inherit TBase
    static member M<'T>(x: list<'T>) = printfn "T"; x


T.M ([1])
// prints "T"

let inline traitCall (a: ^A, witnesses: ^W) = ((^A or ^W) : (static member M : _ -> _) (a))
traitCall ([1], Unchecked.defaultof<T>)
// error FS0043: A unique overload for method 'M' could not be determined based on type information prior to this program point.
// A type annotation may be needed. Candidates: static member T.M : x:'T list -> 'T list, static member TBase.M : x:'T list -> 'T list

Only if T defines a wider definition, it seems to be ignored, and the base static method is called instead of the wider overload (assuming the argument passed would be valid for both).

True, and I would say I'm fine with this

type TBase = 
    static member M<'T>(x: list<'T>) = printfn "TBase"; x

type T = 
    inherit TBase
    static member M<'T>(x: seq<'T>) = printfn "T"; x


T.M ([1])
// prints "TBase"

let inline traitCall (a: ^A, witnesses: ^W) = ((^A or ^W) : (static member M : _ -> _) (a))
traitCall ([1], Unchecked.defaultof<T>)
// prints "TBase"

If so, changing it would potentially be a backwards compatibility issue.

All changes are more or less potentially backwards compatibility issues.
As you can see in all 3 cases, trait-calls are treated the same as if they were defined in the same class.
My proposed change will help to mark priority, but more than that as you can see it will also fix an inconsistency in overload resolution (trait vs non-trait).

@dsyme this is something it might interest you, at least for your SRTP tests

@abelbraaksma
Copy link

abelbraaksma commented Feb 6, 2020

My proposed change will help to mark priority, but more than that as you can see it will also fix an inconsistency in overload resolution (trait vs non-trait).

Thanks for the detailed answer and explanation. It's now much clearer to me what you're proposal covers.

@gusty
Copy link
Author

gusty commented Feb 7, 2020

As a side note, in general direct overload call doesn't match trait call overload.
Rules seems to be different, probably unintentionally as in the F# spec the set of rules is only one.

See this small repro:

type A = A with
    static member M (_:obj) = false
    static member M (_: _ option) = true

let inline tcall (x: '``Applicative<'T>``) : bool =
    let inline call (mthd : ^M, input: ^I) =
        ((^M or ^I) : (static member M : _ -> _) input)
    call (A, x)

> tcall (Some 42) ;;
val it : bool = false

> A.M (Some 42) ;;
val it : bool = true

@matthewcrews
Copy link

matthewcrews commented Jun 10, 2020

Could we instead update the compiler to have a deterministic rule to break ambiguity? My thought is to have it pick the last method that was declared which matches. This is a silly example but it illustrates my point:

type IntBuilder () =

    member this.Yield (i:int) =
        i

    member this.For(source:seq<'a>, body:'a -> seq<'b * int>) =
        source
        |> Seq.collect (fun x -> body x |> Seq.map (fun (idx, i) -> (x, idx), i))

    member this.For(source:seq<'a>, body:'a -> int) =
        source |> Seq.map (fun x -> x, body x)

    member inline this.Run(source:seq<'a * int>) =
        source 
        |> Seq.map (fun (x, d) -> x, d)

    member inline this.Run(source:seq<('a * 'b) * int>) =
        source 
        |> Seq.map (fun ((x, y), d) -> (x, y), d)
    member inline this.Run(source:seq<('a * ('b * 'c)) * int>) =
        source 
        |> Seq.map (fun ((x, (y, z)), d) -> (x, y, z), d)

    member inline this.Run(source:seq<('a * ('b * ('c * 'd))) * int>) =
        source 
        |> Seq.map (fun ((x, (y, (z, a))), d) -> (x, y, z, a), d)

let intBuilder = IntBuilder ()
let c = intBuilder {
    for i in 1..2 do
        for j in 1..2 do
            for k in 1..2 do
                for l in 1..2 -> 
                     i + j + k + l
}

The line where I try to create c is reporting a compiler error because it says it is ambiguous. To me it is unambiguous because there is a Run method which matches that tuple shape. Could we tell the compiler to pick that last Run method because it is the last method declared which would work? Personally, I am a little confused as to why this does not work now because the shape of the types matches.

Clearly this wouldn't break any code bases because this code cannot be written right now. It would enable use cases like this one. The behavior of "Last Method Declared Wins" follows the conventions of the F# compiler so it is easy to figure out what the compiler is going to do.

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

6 participants