为什么 F# 中允许使用可变变量?它们什么时候必不可少?

Why are mutables allowed in F#? When are they essential?

来自 C#,试图了解这门语言。

据我了解,F# 的主要好处之一是您放弃了状态的概念,这应该(在许多情况下)使事情变得更加健壮。

如果是这样(如果不是,请纠正我),为什么允许我们用可变变量打破这个原则?对我来说,感觉它们不属于该语言。我知道您不必使用它们,但它为您提供了偏离轨道并以 OOP 方式思考的工具。

任何人都可以提供一个可变值必不可少的示例吗?

声明性(无状态)代码的当前编译器不是很智能。这导致大量的内存分配和复制操作,这是相当昂贵的。改变对象的某些 属性 允许在新状态下重新使用该对象,这要快得多。

假设您制作了一个游戏,其中有 10000 个单位以每秒 60 刻的速度移动。您可以在 F# 中执行此操作,包括在单个 CPU 核心上与可变四叉树或八叉树发生冲突。

现在假设单位和四叉树是不可变的。编译器没有比 每秒分配和构造 600000 个单元并每秒创建 60 棵新树 更好的主意了。这不包括其他管理结构的任何变化。在具有复杂单位的实际用例中,这种解决方案会太慢。

F# 是一种多范式语言,它使程序员能够编写函数式、面向对象的程序,并且在一定程度上是命令式程序。目前,每个变体都有其有效用途。也许,在未来的某个时候,更好的编译器将允许更好地优化声明式程序,但现在,当性能成为问题时,我们必须退回到命令式编程。

出于性能等原因,能够使用可变状态通常很重要。

考虑实施 API List.take: count : int -> list : 'a list -> 'a list,其中 return 是一个仅包含输入列表中前 count 个元素的列表。

如果您受限于不变性,则只能构建列表 back-to-front。实施 take 然后归结为

  • 建立结果列表 back-to-front,第一 count 人来自输入:O(count)
  • 反转该结果 return O(count)

出于性能原因,F# 运行时具有在需要时构建列表 front-to-back 的神奇特殊能力(即改变最后一个人的尾巴以指向新的尾巴元素)。 List.take 使用的基本算法是:

  • 建立结果列表 front-to-back,第一 count 人来自输入:O(count)
  • Return结果

相同的渐近性能,但实际上在这种情况下使用变异的速度是原来的两倍。

普遍的可变状态可能是一场噩梦,因为代码很难推理。但是如果你分解你的代码以便可变状态被紧密封装(例如在 List.take 的实现细节中),那么你可以在有意义的地方享受它的好处。因此,将不可变性设为默认值,但仍允许可变性,是该语言的一项非常实用和有用的功能。

首先,在我看来,使 F# 强大的不仅仅是默认的不可变性,而是一整套功能组合,例如:默认的不可变性、类型推断、轻量级语法、求和 (DU) 和乘积默认情况下,类型(元组)、模式匹配和柯里化。可能更多。

默认情况下,这些使 F# 非常实用,它们使您能够以某种方式进行编程。特别是当你使用可变状态时,它们会让你感到不舒服,因为它需要 mutable 关键字。从这个意义上说,不舒服意味着更加小心。这正是你应该成为的。

可变状态本身并不是禁止或邪恶的,但它应该受控。明确使用 mutable 的需要就像一个警告标志,让您意识到危险。如何控制它的好方法是在函数内部使用它。这样你就可以拥有自己的内部可变状态,并且仍然是完全线程安全的,因为你没有 共享可变状态。事实上,即使它在内部使用了可变状态,你的函数仍然可以是引用透明的。

至于为什么 F# 允许可变状态;如果没有它的可能性,将很难编写通常的现实世界代码。例如在 Haskell 中,像随机数这样的事情不能像在 F# 中那样以相同的方式完成,而是需要显式地通过线程处理状态。

当我编写应用程序时,我倾向于将大约 95% 的代码库置于非常实用的风格中,这样 1:1 可移植性可以说 Haskell 没有任何问题。但是随后在系统边界或某些性能关键的内部循环可变状态被使用。这样您就可以两全其美。