【问题标题】:Struct Attribute on Discriminated Unions可区分联合的结构属性
【发布时间】:2020-05-01 10:56:55
【问题描述】:

我刚刚意识到 F# 记录是引用类型以及我正在进行多少装箱和拆箱。我有很多这样的小记录:

type InputParam =
    | RegionString of string
    | RegionFloat of float32

但是如果我尝试使用“Struct”属性对其进行标记,我会收到一个编译器错误,指出“FS3204 如果联合类型有多个案例并且是一个结构,那么联合类型中的所有字段都必须是唯一的名字。” language reference 显示创建这样的结构区分联合:

[<Struct>]
type InputParamStruct =
    | RegionString of RegionString: string
    | RegionFloat of RegionFloat: float32

x of string 和 x of x: string 有什么区别?字段如何不是唯一的?为什么 F# 不默认为记录结构?

【问题讨论】:

  • 请注意,如果您查看第一个 DU 的反编译版本,您会发现问题 - RegionString 和 RegionFloat 最终都作为具有单个属性“Item”的类 - sharplab.io/…
  • 另外,作为引用类型应该导致“装箱和拆箱” - 如果您确实经常装箱,那么您的代码中存在不同的问题。实际上,使这个 DU 成为结构可能会显着降低代码的性能(因为整体大小变得比您要传递的引用大很多)。
  • @ReedCopsey "Item" 只是一个名字。如果您使用 x of x: string 定义 ref 类型,它在功能上是相同的,但将名称“Item”交换为“x”。这怎么不会导致更多的拳击? F# 组合在 ref 类型中包装了很多 val 类型。
  • 引用类型保持引用 - 它们不会经常被装箱和拆箱。项目是一个隐藏的名称 - 编译器可以想出一些不同的东西,但没有。

标签: struct f# discriminated-union


【解决方案1】:

毫无疑问,这应该是一个结构。它是不可变的,16 个字节。看反汇编,这个引用类型:

type InputParam =
    | RegionString of string
    | RegionFloat of float32

还有这个引用类型:

type InputParam =
    | RegionString of RegionString: string
    | RegionFloat of RegionFloat: float32

功能相同。唯一的区别在于编译器如何命名事物。它们都创建了一个名为“RegionString”的子类,但具有不同的属性名称——“RegionString.item”与“RegionString.RegionString”。

当您将第一个示例转换为结构时,它会取消子类并尝试在记录上粘贴 2 个“项目”属性,这会导致 FS3204 唯一名称错误。

就性能而言,您应该在编写时对每个像这样的微小类型使用结构。考虑这个示例脚本:

type Name = Name of string
let ReverseName (Name s) =
    s.ToCharArray() |> Array.rev |> System.String |> Name

[<Struct>]
type StrName = StrName of string
let StrReverseName (StrName s) =
    s.ToCharArray() |> Array.rev |> System.String |> StrName

#time
Array.init 10000000 (fun x -> Name (x.ToString()))
|> Array.map ReverseName
|> ignore
#time

#time
Array.init 10000000 (fun x -> StrName (x.ToString()))
|> Array.map StrReverseName
|> ignore
#time

sizeof<Name>
sizeof<StrName>

第一个将 ref 类型包装在 ref 类型中,这使性能提高了一倍:

Real: 00:00:04.637, CPU: 00:00:04.703, GC gen0: 340, gen1: 104, gen2: 7
...
Real: 00:00:02.620, CPU: 00:00:02.625, GC gen0: 257, gen1: 73, gen2: 1
...
val it : int = 8
val it : int = 8

功能域建模很棒,但您必须记住,它们具有相同的性能开销:

let c = CustomerID 5
let i = 5 :> obj

推荐是anything immutable under 16 bytes should be a struct。如果超过 16 个字节,则必须查看行为。如果它被大量传递,您最好传递 64 位 ref 指针并承受 ref 开销。但是对于组合类型或函数内的内部数据,请坚持使用结构。

【讨论】:

  • 另外值得注意的是,由较小的 DU 包装的数据大小会影响这一点。使用小数据,您的观察是准确的。对于更大的数据,它最终会被洗劫一空,因为一切都由该数据主导:gist.github.com/cartermp/db3a20150b90421d7bd50b65a7fea4ed
  • 当然,开销百分比会随着工作百分比的增加而缩小,但我很震惊,因为开销 = 开销,这并不是最佳实践。您的顶级类型(客户)应该是 ref 类型。它远远超过 16 个字节并被传递。但是所有的组合类型(字符串的 FName,int 的 ID,Address = { ...})都应该是结构,无论大小,因为它们是完全封装的。
  • 我认为这是一个很好的做法,我认为可以在此处的 F# 编码约定中记录:docs.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions
  • 我添加了一个 PR 来更详细地讨论这个问题:github.com/dotnet/docs/pull/16699
【解决方案2】:

首先,这些不是记录 - 它们是受歧视的工会。 Record 是具有生成相等/散列的命名数据的简单聚合,也可以将其设为结构,但没有额外的要求。

对结构体可区分联合的更严格要求是:

  • 没有可调用的默认构造函数
  • 没有循环引用/没有递归定义
  • 多格必须有唯一的名称

前两点是值类型所固有的。值和引用类型只是不同。

最后一点很有趣。考虑以下几点:

type DU1 =
    | Case1 of string
    | Case2 of float

[<Struct>]
type DU2 =
    | Case1 of sval: string
    | Case2 of fval: float

DU1 的情况下,每种情况都有一个内部类,其中包含用于访问基础数据的属性。这些属性被命名为Item1Item2 等等,因为它们被封装在一个内部类中,所以它们在访问时是唯一的。

DU2 的情况下,svalfval 值是平铺的;没有包含它们的内部类。这是因为目标是结构的性能/大小。联合情况下的数据命名策略 (Item1/Item2/etc.) 不适用,因为所有数据都是平铺的。所以设计决定是要求唯一的命名案例,而不是使用一些技巧来将案例本身的名称和Item1/Item2/等的一些变体拼凑在一起。唯一性问题是编译器中联合本身设计所固有的,而不仅仅是代码生成设计选择。

最后,这个问题还有一个有趣的答案:

为什么 F# 不默认使用结构来记录记录?

F# 中的元组、记录和 DU 都可以标记为[&lt;Struct&gt;],但默认不是结构。这是因为结构不仅仅是您可以按下的“提高效率”按钮。通常,由于结构太大,由于过度复制,CPU 性能会变差。在 F# 中,拥有大元组和非常大的记录以及可区分的联合是很正常的。默认情况下制作这些结构不是一个好的选择。引用类型非常强大,旨在在 .NET 上很好地工作,默认情况下不应仅仅因为在某些情况下结构可能会导致性能稍快而避免使用。

当您关心性能时,切勿仅根据假设或直觉来改变事物:使用 PerfView、dotTrace 或 dotMemory 等分析工具;并使用 BenchmarkDotNet 等统计工具对微小变化进行基准测试。性能是一个极其复杂的空间,一旦解决了明显很糟糕的严重问题(例如大型数据集上的 O(n^2) 算法或其他问题),性能就很少是简单的了。

【讨论】:

  • 好的,ref 类型默认是安全的。使复制效率低下很容易,但是您不能真正做大事。字符串、列表、数组等都是 ref 类型。我正在研究 F# 中的组合发生的所有拳击,而 C# 中不会发生,即充满指针的 ref 类与充满值类型和指针的 ref 类。
  • 另外,去掉 DU2 的 struct 标签并用 dotPeek 检查。它们在功能上是相同的。编译器只是将字符串的Case1的名称设置为“item”,将Case1的Case1的名称:字符串设置为“Case1”。
  • 这不太正确。当每个不是结构时,编译器会为每个内部类生成一个内部类。
  • 是的,除了名称之外,没有结构标签它们是相同的。两者都创建内部类,除了道具名称是 DU1.item vs DU2.sval。
猜你喜欢
  • 2011-11-12
  • 1970-01-01
  • 2020-01-25
  • 2021-05-31
  • 1970-01-01
  • 1970-01-01
  • 2018-11-24
  • 2011-03-10
相关资源
最近更新 更多