fsharp / fslang-suggestions

The place to make suggestions, discuss and vote on F# language and core library features
346 stars 21 forks source link

Independent Object Expressions #1270

Open woojamon opened 1 year ago

woojamon commented 1 year ago

I propose that F# allow for independent object expressions, i.e. object expressions not requiring a base type or interface.

let x = { new _ with member this.MyMember() = 42 }
// or
let y = { member this.MyMember() = 42 }

The existing way of approaching this problem in F# is to create an interface or class for the object expression to depend on.

type IMyMember = abstract MyMember: unit -> int
let x = { new IMyMember with member this.MyMember() = 42 }

The current way is particularly verbose if I have an object graph that I want to simulate but not take a dependency on. For example, here is some code I'm using to simulate an object graph for unit testing an adapter around an Azure ArmClient. (Note that the interfaces are just boilerplate to be able to make the object expressions.)

open Xunit
open Azure.Core

type IResourceGroupData =
      abstract Name : string

type IResourceGroupResource =
    abstract Data : IResourceGroupData

type ISubscriptionResource =
    abstract GetResourceGroups: unit -> seq<IResourceGroupResource>

type IGetSubscriptionResource =
    abstract GetSubscriptionResource: ResourceIdentifier -> ISubscriptionResource

[<Fact>]
let ``My test`` () =
    let resourceGroupName = "hello world"
    let data = { new IResourceGroupData with member _.Name =  resourceGroupName }
    let expected = { new IResourceGroupResource with member _.Data = data }
    let subscription = { new ISubscriptionResource with member _.GetResourceGroups() = seq { expected } }
    let mockClient = { new IGetSubscriptionResource with member _.GetSubscriptionResource _ = subscription }

    let resourceGroup = AzureAdapter.tryFindResourceGroup "someSubscriptionName" resourceGroupName mockClient

    Assert.Equal(expected, resourceGroup.Value);

And there are many, many more interfaces that would be created for simulating other paths through the ArmClient object graph.

But with independent object expressions all the boilerplate interfaces go away:

[<Fact>]
let ``My test`` () =
    let resourceGroupName = "hello world"
    let data = { member _.Name =  resourceGroupName }
    let expected = { member _.Data = data }
    let subscription = { member _.GetResourceGroups() = seq { expected } }
    let mockClient = { member _.GetSubscriptionResource subscriptionName = subscription }

    let resourceGroup = AzureAdapter.tryFindResourceGroup "someSubscriptionName" resourceGroupName mockClient

    Assert.Equal(expected, resourceGroup.Value);

The compiler would still create a new anonymous type and everything just like usual, for compile-time type safety.

Pros and Cons

The advantages of making this adjustment to F# are that F# can be even less verbose.

The disadvantages of making this adjustment to F# are that it potentially opens the door for some wierd or unexpected things with object expressions.

Extra information

Estimated cost (XS, S, M, L, XL, XXL): I'm not a language writer but I would think S-M.

Related suggestions: none that I can find. (Most object expression improvements seem to end up here https://github.com/fsharp/fslang-suggestions/issues/1253, but that feature actually requires the dependent base type or interface.)

Affidavit (please submit!)

Please tick this by placing a cross in the box:

Please tick all that apply:

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.

charlesroddie commented 1 year ago
let y = { member this.MyMember() = 42 }

What type does y have here?

woojamon commented 1 year ago
let y = { member this.MyMember() = 42 }

What type does y have here?

Whatever type the compiler dynamically generates, with whatever name could be useful in debugging. Object_<file-name>_<line-number>_<column-number> perhaps. The idea is that the compiler would write the interface instead of the developer.

Developer writes an independent object expression whose opening { is on line 3, column 9 in MyFile.fs:

// MyFile.fs
module MyModule

let y = { member this.MyMember() = 42 }

Compiler translates this to:

// MyFile.fs
module MyModule

type private Object_MyFile_4_9 = abstract member MyMember : unit -> int

let y = { new Object_MyFile_4_9 with member this.MyMember() = 42 }

Then if something blows up during runtime the developer can see the name Object_MyFile_4_9 and know where to start looking.

Happypig375 commented 1 year ago

Anonymous records seem to be a better base feature to extend upon. A type like this {| member MyMember: unit -> int |} with value {| member _.MyMember() = 42 |}

woojamon commented 1 year ago

@Happypig375 That could certainly also work, assuming it could still be passed to a function with member constraints ’a when ‘a : (member MyMember: unit -> int).

Also, for the particular use case of ad-hoc mocking up of an object graph, I think it’s important that the implementation allows for composition of these things, whatever they end up being.

let mockClient = {| member _.MyMember() = {| member _.ItsMember() = 42 |} |}

let getValue<‘a, ‘b 
    when ‘a : (member MyMember: unit -> ‘b) 
    and ‘b : (member ItsMember: unit -> int)
    > (client: ‘a) = 
        client.MyMember().ItsMember()

printfn “%A” (getValue mockClient)
// 42

let realClient = SomeThirdPartyLibrary.RealClient()
printfn “%A” (getValue realClient)
// … whatever this happens to be at runtime.
konst-sh commented 1 year ago

@Happypig375 yeah, anonymous object is the closest construct, but they are immutable, and this will not make it possible to mock mutating methods. And also what will it worth to allow self-identifier and method members is another question.

woojamon commented 1 year ago

@konst-sh That's a good point about mutation, especially if its my code that's trying to mutate whatever object is passed into it, whether it be the my mock object or the third-party object.

If its the third-party library that's doing the mutation, though, I think I would stop short of testing that, as in my mind such tests (tests that ensure the third-party type behaves as expected) are the responsibility of the library developer, or if I want to test their stuff then I would have justification for writing an integration test.

Even still, I admit there would be some friction with mutation here...

By the way, just to clarify, did you mean "anonymous records" instead of "anonymous objects"?

konst-sh commented 1 year ago

@woojamon yes, I meant anonymous records. I imagine we could generalize current object expression semantics to append some extension methods as well. Then to create object with minimal set of members we will just need to create object expression which extends Object type