首页 > 解决方案 > F# 中强类型但用户可扩展的集合?

问题描述

我正在设计一个用于与 F# 中的 C# API 交互的数据结构。从广义上讲,它是一个强类型的组件集合 ( ComponentCollection),其中组件可能具有不同的类型。

第一次通过看起来像这样:

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

这种设计有两个问题:

  1. 重复样板代码(例如addFoo, addBar, tryFindFoo, ...)
  2. 组件的类型在不改变类型的情况下是不可扩展的ComponentCollection,例如用户不能添加QuxComponents : Map<Qux, Component<Qux>>自己

我可以使用接口重新设计东西,但这失去了 F# 著名的类型安全性!

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
    

如何设计ComponentCollection实现类型安全可扩展性?

标签: f#printf

解决方案


这个问题有几个层次,所以我会尝试给出一个解决它本质的答案,而不会运行太长时间。

您实际上想要在这里获得的是一个在键和值类型方面都是异构的映射。这不是 F# 类型系统非常适合表示的东西,因为它需要对 F# 所没有的存在类型的支持。让我们暂时离开关键部分,谈谈价值观。

您在第二种方法中对值进行装箱通常是在集合中存储异构值的合理解决方案,并且是人们经常做出的权衡。您通过装箱和强制转换放弃了一些类型安全性,但是如果您Components通过将其设为私有/内部来限制对地图的访问,并确保它只能通过模块函数访问,您可以轻松地保持密钥类型与类型匹配的不变量的组件。

因此,您可以使用以下内容删除样板:

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>)

有一个解决这个问题的解决方案可以在 F# 类型系统中工作,而无需借助强制转换或反射,它涉及使用一组包装器模拟存在类型。此方法已在其他问题以及此博客文章中详细介绍。然而,这是一个庞大的编码,我很难在一个小项目中证明这一点。

但是,您提出的解决方案存在一个单独的问题 - 您正在为地图使用异构键这一事实。就目前而言,只要所有键的类型相同,您的方法(本质上是 IComparable 别名的标记接口)就可以正常工作,但是当您尝试添加不同类型的键时,操作将在 Map 内部失败 - 因为当比较值属于不同的运行时类型时,默认比较实现会抛出异常。

为避免这种情况,您应该将您的密钥包装在一个具有自定义比较实现的类型中,以规避这种情况,或者您应该考虑定义一个可以与您期望的所有组件类型一起使用的通用密钥类型。


推荐阅读