6

因此,我正在尝试学习 FP,并且正在尝试了解引用透明度和副作用。

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

“主要是函数式编程”的想法是不可行的。仅通过部分消除隐式副作用来使命令式编程语言更安全是不可能的。留下一种效果通常足以模拟您刚刚尝试删除的效果。另一方面,允许在纯语言中“忘记”效果也会以自己的方式造成混乱。

不幸的是,没有黄金中间,我们面临着一个经典的二分法:排中间的诅咒,它提供了以下两种选择:(a) 尝试使用纯度注释来驯服效果,但完全接受这样一个事实,即您的代码是仍然基本有效;或(b)通过在类型系统中明确所有效果并务实来完全接受纯度 -来源

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

强制引用透明性的能力与 Scala 拥有与 Java 可互操作的类/对象系统的目标非常不兼容。-来源

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

在 ML、Scala 或 F# 等非纯语言中,确保引用透明性取决于程序员,当然在 Clojure 或 Scheme 等动态类型语言中,没有静态类型系统来强制引用透明性。-来源

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

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

4

3 回答 3

7

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


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

实际上,没有什么可以阻止您以与在 Haskell 中非常相似的方式将效果引入 F# 中的类型系统,尽管实际上您是“选择加入”这种方法,而不是强制执行。

你真正需要的只是一些这样的基础设施:

/// 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 来执行所有封闭的效果。这就像unsafePerformIO在 Haskell 中一样。

然后,您可以使用这些原始函数定义一个计算表达式构建器,并为自己提供许多不错的语法糖。


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

F# 和 Haskell 之间的根本区别在于 F# 默认情况下是渴望的语言,而 Haskell 默认情况下是惰性的。Haskell 社区(我怀疑.NET 社区,在较小程度上)已经了解到,当您将惰性评估和副作用/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.cachewhich 防止重复评估来重新获得一些控制权,但仍然无法控制效果发生的时间和顺序。

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


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

如果您正在使用显式效果并IO在类型系统中工作,并且您的所有功能最终都是用 编写的IO,那么这是一种强烈而明显的设计气味。

回到最初的问题,即这在 F# 中是否值得,我仍然必须回答“我不知道”。我一直在为 F# 中的引用透明效果开发一个库,以自己探索这种可能性。IO如果您有兴趣,那里有更多关于这个主题的材料以及更完整的实现。


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

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

最后,我对 F# 的务实和经验丰富的观点告诉我,实际上,“大部分函数式”编程在几乎所有时间里仍然比它的竞争对手有很大的改进。

于 2016-08-19T17:14:42.020 回答
5

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

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

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

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

仅通过部分消除隐式副作用来使命令式编程语言更安全是不可能的。

我同意使它们“安全”是不可能的(如果安全是指它们没有副作用),但是您可以通过消除一些副作用使它们更安全。

留下一种效果通常足以模拟您刚刚尝试删除的效果。

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

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

是的,这是真的——但“引用透明度”并不是函数式编程的意义所在。对我来说,它是关于有更好的方法来为我的域建模并拥有引导我走上“快乐之路”的工具(如类型系统)。参考透明度是其中的一部分,但它不是灵丹妙药。参照透明不会神奇地解决你所有的问题。

于 2016-08-19T17:40:28.960 回答
0

就像 Mark Seemann 在评论中所证实的那样,“F# 中没有任何东西可以保证引用透明性。这由程序员来考虑。”

我一直在网上进行一些搜索,发现“纪律是你最好的朋友”以及一些建议,以尽量保持 F# 应用程序中的引用透明度级别尽可能高:

  • 不要使用 mutable、for 或 while 循环、ref 关键字等。
  • 坚持使用纯粹不可变的数据结构(可区分联合、列表、元组、映射等)。
  • 如果您需要在某些时候进行 IO,请构建您的程序,以便将它们与您的纯功能代码分开。不要忘记函数式编程就是限制和隔离副作用。
  • 代数数据类型 (ADT) 又名“可区分联合”,而不是对象。
  • 学会爱懒惰。
  • 拥抱单子。
于 2016-08-19T16:29:14.487 回答