如何保证 F# 应用程序中的引用透明性?

How to guarantee referential transparency in F# applications?

所以我正在尝试学习 FP,并且正在努力了解引用透明度和副作用。

我了解到在类型系统中明确显示所有效果是保证引用透明性的唯一方法:

The idea of “mostly functional programming” is unfeasible. It is impossible to make imperative programming languages safer by only partially removing implicit side effects. Leaving one kind of effect is often enough to simulate the very effect you just tried to remove. On the other hand, allowing effects to be “forgotten” in a pure language also causes mayhem in its own way.

Unfortunately, there is no golden middle, and we are faced with a classic dichotomy: the curse of the excluded middle, which presents the choice of either (a) trying to tame effects using purity annotations, yet fully embracing the fact that your code is still fundamentally effectful; or (b) fully embracing purity by making all effects explicit in the type system and being pragmatic - Source

我还了解到像 Scala 或 F# 这样的非纯 FP 语言不能保证引用透明性:

The ability to enforce referential transparency this is pretty much incompatible with Scala's goal of having a class/object system that is interoperable with Java. - Source

并且在非纯 FP 中,由程序员来确保引用透明性:

In impure languages like ML, Scala or F#, it is up to the programmer to ensure referential transparency, and of course in dynamically typed languages like Clojure or Scheme, there is no static type system to enforce referential transparency. - Source

我对 F# 很感兴趣,因为我有 .Net 背景,所以我的下一个问题是:

如果 F# 编译器不强制执行,我可以做些什么来保证 F# 应用程序中的引用透明性?

正如 Mark Seemann 在评论中确认的那样 "Nothing in F# can guarantee referential transparency. It's up to the programmer to think about this."

我一直在网上搜索,我发现 "discipline is your best friend" 和一些建议,试图将 F# 应用程序中的引用透明度级别保持在可能:

  • 不要使用可变的、for 或 while 循环、ref 关键字等。
  • 坚持使用纯粹不可变的数据结构(区分联合、列表、元组、映射等)。
  • 如果您在某个时候需要执行 IO,请构建您的程序,以便将它们与您的纯功能代码分开。不要忘记函数式编程就是限制和隔离副作用。
  • 代数数据类型 (ADT) 又名 "discriminated unions" 而不是对象。
  • 学会爱上懒惰。
  • 拥抱 Monad。

这个问题的简短回答是,在 F# 中无法保证引用透明性。 F# 的一大优点是它与其他 .NET 语言具有出色的互操作性,但与 Haskell 等更孤立的语言相比,它的缺点是存在副作用,您将不得不处理和他们在一起。


在 F# 中实际如何处理副作用完全是另一个问题。

实际上没有什么可以阻止您在 F# 中以与在 Haskell 中相同的方式将效果引入类型系统,尽管实际上您 'opting in' 这种方法而不是它正在对你强制执行。

您真正需要的只是像这样的基础设施:

/// A value of type IO<'a> represents an action which, when performed (e.g. by calling the IO.run function), does some I/O which results in a value of type 'a.
type IO<'a> = 
    private 
    |Return of 'a
    |Delay of (unit -> 'a)

/// Pure IO Functions
module IO =   
    /// Runs the IO actions and evaluates the result
    let run io =
        match io with
        |Return a -> a            
        |Delay (a) -> a()

    /// Return a value as an IO action
    let return' x = Return x

    /// Creates an IO action from an effectful computation, this simply takes a side effecting function and brings it into IO
    let fromEffectful f = Delay (f)

    /// Monadic bind for IO action, this is used to combine and sequence IO actions
    let bind x f =
        match x with
        |Return a -> f a
        |Delay (g) -> Delay (fun _ -> run << f <| g())

return 带来 IO 内的值。

fromEffectful 采用副作用函数 unit -> 'a 并将其引入 IO.

bind 是单子绑定函数,可让您对效果进行排序。

run 运行 IO 来执行所有包含的效果。这就像 Haskell 中的 unsafePerformIO

然后您可以使用这些原始函数定义一个计算表达式构建器,并给自己很多漂亮的语法糖。


另一个值得问的问题是,这在 F# 中有用吗?

F# 和 Haskell 之间的根本区别是 F# 默认是一种急切的语言,而 Haskell 默认是惰性的。 Haskell 社区(我怀疑 .NET 社区,在较小程度上)已经了解到,当您将惰性求值与 side-effects/IO 结合使用时,可能会发生非常糟糕的事情。

当您在 Haskell 中使用 IO monad 时,您(通常)要保证 IO 的顺序性质,并确保一个 IO 在另一个之前完成。您还保证了影响发生的频率和时间。

我喜欢在 F# 中提出的一个示例是:

let randomSeq = Seq.init 4 (fun _ -> rnd.Next())
let sortedSeq = Seq.sort randomSeq

printfn "Sorted: %A" sortedSeq
printfn "Random: %A" randomSeq

乍一看,这段代码似乎生成了一个序列,对同一序列进行排序,然后打印排序和未排序的版本。

没有。它生成两个序列,其中一个已排序,另一个未排序。他们可以而且几乎肯定会拥有完全不同的价值观。

这是在没有引用透明的情况下结合副作用和惰性求值的直接结果。您可以通过使用 Seq.cache 来重新获得一些控制权,这可以防止重复评估,但仍然无法让您控制效果发生的时间和顺序。

相比之下,当您使用急切评估的数据结构时,后果通常不那么隐蔽,因此我认为 F# 中显式效果的 要求 与Haskell.


也就是说,在类型系统中明确显示所有效果的一大优势在于它有助于实施良好的设计。 Mark Seemann 之类的人会告诉您,设计健壮系统的最佳策略,无论是面向对象的还是功能性的,都涉及在系统边缘隔离副作用并依赖引用透明、高度可单元测试的核心。

如果您在类型系统中使用显式效果和 IO,并且您的所有函数最终都是用 IO 编写的,那是一种强烈而明显的设计味道。

回到最初的问题,即这在 F# 中是否值得,我仍然必须回答 "I don't know"。我一直致力于 a library 在 F# 中实现引用透明效果,以亲自探索这种可能性。如果您有兴趣,那里有更多关于这个主题的 material 以及更完整的 IO 实现。


最后,我认为值得记住的是,“中间层排除诅咒”可能更多地针对编程语言设计人员,而不是一般的开发人员。

如果您使用的是一种不纯的语言,您将需要找到一种方法来应对和驯服您的副作用,您所遵循的精确策略是可以解释的,并且最适合您的需求你自己 and/or 你的团队,但我认为 F# 为你提供了很多工具来做到这一点。

最后,我对 F# 的务实和经验丰富的看法告诉我,实际上,"mostly functional" 编程几乎在任何时候都比它的竞争对手有很大的进步。

我认为您需要在适当的上下文中阅读源文章 - 这是一篇来自特定角度的观点文章,它是故意挑衅的 - 但这不是一个铁的事实。

如果您使用的是 F#,您将通过编写 好的代码 获得引用透明性。这意味着将大多数逻辑编写为一系列转换并执行效果以在 运行 转换和 运行 效果之前读取数据以将结果写入之后的某处。 (并非所有程序都适合这种模式,但那些 可以 以引用透明的方式编写的程序通常可以。)

根据我的经验,您可以在 "middle" 中完美幸福地生活。这意味着,大部分时间编写引用透明的代码,但出于某些实际原因需要打破规则。

回应引述中的一些具体点:

It is impossible to make imperative programming languages safer by only partially removing implicit side effects.

我同意不可能使它们 "safe"(如果我们所说的安全是指它们没有副作用),但您可以通过以下方式使它们 更安全消除一些副作用。

Leaving one kind of effect is often enough to simulate the very effect you just tried to remove.

是的,但是模拟效果来提供理论证明不是程序员的工作。如果不鼓励达到效果,您将倾向于以其他(更安全)的方式编写代码。

I have also learned that not-pure FP languages like Scala or F# cannot guarantee referential transparency:

是的,没错 - 但 "referential transparency" 不是函数式编程的意义所在。对我来说,它是关于有更好的方法来建模我的领域和有工具(比如类型系统)来指导我 "happy path"。引用透明度是其中的一部分,但它不是灵丹妙药。引用透明度不会神奇地解决你所有的问题。