区分联合类型上的结构属性

7

我刚意识到F# records是引用类型,同时也发现了有多少装箱和拆箱操作。我有很多像这样的小型records:

type InputParam =
    | RegionString of string
    | RegionFloat of float32

但是,如果我尝试使用"Struct"属性对它进行标记,我会得到一个编译器错误,指出"FS3204 如果一个联合类型有不止一种情况并且是一个结构体,则联合类型内的所有字段都必须被赋予唯一名称。" 语言参考 显示如何创建结构体判别联合:

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

什么是 string 的 x 和 x: string 的 x 之间的区别?这些字段为什么一开始不是唯一的?为什么 F# 不会默认将记录用结构体表示?

请注意,如果您查看第一个DU的反编译版本,您可以看到问题 - RegionString和RegionFloat都以单个属性“Item”作为类结束 - https://sharplab.io/#v2:DYLgZgzgNAJiDUAfALgTwA4FMAEBJAdugK7IAKAhgE7kC22AvALABQ2b2i2ASpgOYCWAe3wBlZJX75e2QWGwRxk3i3YdufIfgBiwQeWQy5YXfoDMAJhXsWAbQA8YykQDGyAHwBdFmix5CJCmoaRxcDJlZ2Th4BYUclQ3UY0UUpEHkU5Qi2KI1hHT0DWUTNfP004wKLIA - Reed Copsey
1
此外,作为引用类型不应导致"装箱和拆箱" - 如果您真的经常进行装箱操作,则代码存在不同的问题。事实上,将此DU设置为结构体可能会显著降低代码的性能(因为总体大小比您传递的引用要大得多)。 - Reed Copsey
1
@ReedCopsey,“Item”只是一个名称而已。如果您使用x of x: string定义ref类型,则在功能上完全相同,但将名称“Item”替换为“x”。这样做不会导致更多的装箱吗?F#组合将许多val类型包装在ref类型中。 - EricP
1
Ref类型保持引用 - 它们不会频繁地装箱和拆箱。Item是一个隐藏的名称 - 编译器可能会提供不同的名称,但实际上并没有这样做。 - Reed Copsey
2个回答

13

首先,这些不是 Records - 它们是带标签的联合体。记录是具有命名数据的简单聚合,具有生成的相等性/哈希,并且将其作为结构体也是可能的,但不会带来额外的要求。

结构化带标签的联合体的更严格要求是:

  • 不能调用默认构造函数
  • 无循环引用/无递归定义
  • 多个案例必须具有唯一名称

前两个要点是作为值类型固有的。值类型和引用类型只是不同的。

最后一个要点很有趣。请考虑以下内容:

type DU1 =
    | Case1 of string
    | Case2 of float

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

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

对于 DU2svalfval 值被展开;没有包含它们的内部类。这是因为目标是结构的性能/大小。联合情况下数据的命名策略(Item1 / Item2 / 等等)不适用,因为所有数据都是平展开的。因此,设计决策是要求具有唯一名称的情况,而不是将用例本身的名称与 Item1 / Item2 / 等等的某些变体拼凑在一起。独特性问题固有于编译器中联合本身的设计,而不仅仅是代码生成设计选择。

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

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

F# 中的元组、记录和 DU 都可以标记为 [<Struct>],但默认情况下不是结构体。这是因为结构体并不仅仅是一个可以提高效率的按钮。往往由于结构体太大而导致过度复制而获得更低的 CPU 性能。在 F# 中,拥有大型元组、非常非常大的记录和辨别联合十分普遍。如果默认情况下将它们都变成结构体,则不是一个好的选择。引用类型非常强大,并且被设计为在 .NET 上工作得非常好,不应该默认避免使用它们,只是因为在某些情况下使用结构体可能会导致稍微更快的性能。

每当您关心性能时,请不要基于假设或直觉改变事物:使用像 PerfView、dotTrace 或 dotMemory 这样的分析工具;并使用 BenchmarkDotNet 这样的统计工具来对小的更改进行基准测试。性能是一个极其复杂的领域,一旦考虑到明显糟糕的问题(例如在大数据集上的 O(n^2) 算法),就很少是简单的。


好的,引用类型默认是安全的。让事情变得低效复制很容易,但你真的无法使某些东西变得更大。字符串、列表、数组等都是引用类型。我正在查看在 F# 中发生的所有组合装箱,这在 c# 中不会发生,即一个充满指针的引用类与一个充满值类型和指针的引用类。 - EricP
此外,将 DU2 的结构标签去掉并使用 dotPeek 进行检查。它们在功能上是相同的。编译器只是将字符串的 Case1 名称设置为 "item",将 Case1: 字符串的 Case1 设置为 "Case1"。 - EricP
那不是完全正确的。当它不是一个结构体时,编译器会为每个生成一个内部类。 - Phillip Carter
是的,没有结构标签它们是相同的,除了名称之外。两者都创建内部类,只是属性名称分别为DU1.item和DU2.sval。 - EricP

3
毫无疑问,这应该是一个结构体。它是不可变的并且占用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个“item”属性,导致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>

第一个将引用类型包装在引用类型中,导致性能损失翻倍。
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

建议将16字节以下的任何不可变内容都定义为结构体。如果超过16字节,则需要考虑其行为。如果该内容被频繁传递,最好使用64位引用指针并承担引用开销。但是对于在组合类型或函数内部使用的内部数据,请坚持使用结构体。

1
值得注意的是,由较小 DU 包装的数据大小会影响此操作。对于小数据,您的观察是准确的。对于大数据,它最终变成了近似平局,因为一切都被该数据所主导:https://gist.github.com/cartermp/db3a20150b90421d7bd50b65a7fea4ed - Phillip Carter
1
当然,%开销会随着%工作量的增加而降低,但我很惊讶这不被强调为最佳实践,因为overhead = overhead。你的顶级类型(顾客)应该是一个ref类型。它超过了16个字节并被传递。但是所有构成类型(字符串的FName,int的ID,以及Address={...})无论大小都应该是结构体,因为它们完全封装。 - EricP
2
我认为这是一个好的实践,可以在F#编码规范中记录下来:https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions - Phillip Carter
1
我添加了一个PR,以便更详细地进行:https://github.com/dotnet/docs/pull/16699 - Phillip Carter

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接