sdgfsdh
sdgfsdh

Reputation: 37045

Strongly typed but user-extensible collection in F#?

I am designing a data-structure for interacting with a C# API from F#. Broadly speaking, it is a strongly-typed collection of components (ComponentCollection), where components may have different types.

A first-pass looks like this:

type Foo = 
  { 
    Foo : int
  }

type Bar = 
  { 
    Bar : string
  }

type ComponentCollection = 
  {
    FooComponents : Map<Foo, Component<Foo>>
    BarComponents : Map<Bar, Component<Bar>>
  }

module ComponentCollection = 

  let addFoo foo comp xs = 
    { xs with FooComponents = xs.FooComponents |> Map.add foo comp }

  let addBar bar comp xs = 
    { xs with BarComponents = xs.BarComponents |> Map.add bar comp }

  let tryFindFoo foo xs = 
    xs.FooComponents |> Map.tryFind foo

  let tryFindBar bar xs = 
    xs.BarComponents |> Map.tryFind bar

There are two problems with this design:

  1. Repetition of boiler-plate code (e.g. addFoo, addBar, tryFindFoo, ...)
  2. The type of components is not extensible without changing the type ComponentCollection, e.g. a user cannot add QuxComponents : Map<Qux, Component<Qux>> themselves

I can redesign things using interfaces, but this loses much of the type safety F# is famous for!

open System

type ComponentKey = 
  interface 
    inherit IComparable
  end

type Foo = 
  { 
    Foo : int
  }
  with interface ComponentKey

type Bar = 
  { 
    Bar : string
  }
  with interface ComponentKey

type ComponentCollection = 
  {
    Components : Map<ComponentKey, obj>
  }

module ComponentCollection = 

  let addFoo (foo : Foo) (comp : Component<Foo>) xs = 
    { xs with Components = xs.Components |> Map.add (foo :> ComponentKey) (comp :> obj) }

  let addBar (bar : Bar) (comp : Component<Bar>) xs = 
    { xs with Components = xs.Components |> Map.add (bar :> ComponentKey) (comp :> obj) }

  let tryFindFoo (foo : Foo) xs = 
    xs.Components 
    |> Map.tryFind (foo :> ComponentKey)
    |> Option.map (fun x -> x :?> Component<Foo>) // Big assumption!

  let tryFindBar (bar : Bar) xs = 
    xs.Components 
    |> Map.tryFind (bar :> ComponentKey)
    |> Option.map (fun x -> x :?> Component<Bar>) // Big assumption!

  // User can easily add more in their own code
    

How can I design ComponentCollection achieving type-safety and extensibility?

Upvotes: 2

Views: 75

Answers (1)

scrwtp
scrwtp

Reputation: 13577

There's a few layers to this question, so I'll try give an answer that addresses the essence of it without running too long.

What you're essentially trying to get here is a map that is heterogenous both with regard to the key and to the value types. This is not something F# type system is well suited to represent, as it requires support for existential types that F# doesn't have. Let's leave the key part for a moment and talk about values.

What you have done in your second approach with boxing the values is generally a reasonable solution to storing heterogenous values in a collection, and a trade-off that people would often make. You give up some type safety by boxing and casting, but if you restrict access to your Components map by making it private/internal, and ensure it's only accessed through the module functions, you can easily keep the invariant that the type of key matches the type of component.

So you can remove the boilerplate with something like this:

module ComponentCollection =

    let add<'key when 'key :> ComponentKey> (key: 'key) (comp: Component<'key>) coll =
        { coll with Components = 
            coll.Components |> Map.add (key :> ComponentKey) (box comp) }

    let tryFind<'key when 'key :> ComponentKey> (key: 'key) coll =
        coll.Components
        |> Map.tryFind (key :> ComponentKey)
        |> Option.map (fun x -> x :?> Component<'key>)

There is a solution around this problem that would work within the F# type system without resorting to casting or reflection, and it involves emulating existential types using a set of wrappers. This approach has been covered in other questions as well as this blog post in great detail. It is however a bulky encoding that would be hard for me to justify in a small project.

There is a separate problem with your proposed solution however - the fact you're using heterogenous keys for the map. As it currently stands, your approach (a marker interface that is essentially an alias for IComparable) will work fine as long as all the keys are of the same type, but the moment you try to add a key of a different type, the operation will fail in the Map internals - as the default comparison implementation would throw an exception when the compared values are of different runtime types.

To avoid that, you should either wrap your keys in a type that would have a custom implementation of comparisons that would circumvent that, or you should consider defining a common key type that you could use with all the component types you expect.

Upvotes: 1

Related Questions