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

6

我正在尝试学习函数式编程,并试图理解引用透明度和副作用。

我已经了解到,在类型系统中显式地表示所有效果是确保引用透明度的唯一方法:

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

不幸的是,没有黄金中间路线,我们面临着一个经典的二分法:排除中间诅咒,这提出了两个选择:(a)尝试使用纯度注释来驯服效果,但完全接受代码仍然基本上具有效果性; 或者(b)通过在类型系统中显式地表示所有效果并实用主义来完全接受纯度 - 来源

我还了解到,像Scala或F#这样的非纯FP语言无法保证引用透明度:

保持引用透明性的能力与Scala的目标不太兼容,即具有与Java互操作的类/对象系统。- 来源

而在非纯函数式编程中,程序员需要确保引用透明性:

在像ML、Scala或F#这样的非纯语言中,需要程序员确保引用透明性;当然,在像Clojure或Scheme这样的动态类型语言中,没有静态类型系统来强制执行引用透明性。- 来源

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

如果F#编译器没有强制执行引用透明性,我该如何保证在F#应用程序中实现引用透明性?


3
我怀疑没有任何方法可以保证这一点。你能做的最好的事情就是为每个东西编写自己的纯函数,并避免使用BCL中的大多数类。在F#类型系统中组合效果可能会非常麻烦,不太有用。 - Lee
5
使用Haskell(假设你非常想要)。说真的,你的问题之一在于F#和Scala或Clojure一样运行在不纯洁的平台上。如果你没有利用.NET/JVM库,那就使用Haskell,如果你使用了这些库,我怀疑这是个无望的事情。 - Jared Smith
那么,所谓的“由程序员保证引用透明性”,这个链接回答并不正确? - Remo H. Jansen
5
相反,该陈述是正确的。在 F# 中没有任何东西可以保证引用透明性。这取决于程序员自己去思考。然而,与例如 C# 相比,F# 更容易实现这一点... - Mark Seemann
1
@OweRReLoaDeD,你可以在任何语言中编写引用透明的代码(根据某种定义)。但是我理解你的问题是“我能得到一个保证吗?” - Jared Smith
2
几年前有关主题的讨论,包括Don Syme的评论:https://fslang.uservoice.com/forums/245727-f-language/suggestions/5670335-pure-functions-pure-keyword。 - Jack Fox
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以执行所有封闭效果。这类似于Haskell中的unsafePerformIO

然后,您可以使用这些基本函数定义计算表达式生成器,并为自己提供大量漂亮的语法糖。


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

F#和Haskell之间的一个根本区别在于,默认情况下,F#是急切的语言,而Haskell是默认惰性的。 Haskell社区(我认为.NET社区在某种程度上也是如此)已经学会了当您结合惰性评估和副作用/ IO时,可能会发生非常糟糕的事情。

当您在Haskell中使用IO单子时,您(通常)保证关于IO的顺序性质,并确保在另一个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,您可以获得一些控制,防止重复评估,但仍无法控制效果发生的时间和顺序。
相比之下,当您使用急切地评估数据结构时,其后果通常不会那么难以捉摸,因此我认为与Haskell相比,F#中显式效果的要求大大降低了。
尽管如此,在类型系统中使所有效果显式的一个很大的优点是它有助于强制执行良好的设计。像Mark Seemann之类的人会告诉您,设计健壮的系统(无论是面向对象还是函数式)的最佳策略涉及将副作用隔离在系统边缘并依赖于参照透明度高、高度单元测试的核心。
如果您正在使用显式效果和IO类型系统,并且所有函数最终都要编写成IO,则这是一个强烈而明显的设计味道。
回到最初的问题,即在F#中是否值得这样做,我仍然必须回答“我不知道”。我一直在开发用于参照透明效果的库,以探索这个可能性。如果您感兴趣,可以在那里找到更多有关此主题以及更完整的IO实现的材料。
最后,我认为值得记住的是排除中间诅咒可能更针对编程语言设计人员而不是普通开发人员。
如果您正在使用不纯的语言,则需要找到一种应对和驯服副作用的方法,具体策略取决于您自己和/或团队的需要,但我认为F#为此提供了很多工具。
最后,我的实用和经验丰富的观点告诉我,实际上,“大多数功能”编程仍然比其竞争对手在绝大多数情况下都要好很多。

因为我正在寻找有关"F#中如何处理副作用的实际方法",所以选择了您的答案。非常感谢! - Remo H. Jansen
1
值得注意的是(我认为),在F#中,没有人会像第一个答案的前半部分所示的那样使用IO单子。这并不_保证_任何东西,因为您仍然可以在代码的任何地方调用printfn或执行其他副作用。它只会使您的代码变慢且更冗长。它主要有趣的是因为它说明了Haskell中单子IO的工作原理... - Tomas Petricek
2
@TomasPetricek 是的,我故意模棱两可地建议这个作为一个实际解决方案,希望我的意思已经传达出来了。我认为它可以帮助你推理你的代码,特别是如果你结合惰性求值和IO,这是Meijer文章中提到的一种情况,但在其他地方提供的信息较少。话虽如此,在Scala中有很多关于monadic IO的材料,它也具有无约束的副作用。目前,我认为值得研究一下,以了解我们是否可以从中获得任何收益,但我肯定不会在生产中使用它。 - TheInnerLight

5
我认为你需要在适当的背景下阅读源文章——它是一篇带有特定视角和故意挑衅性质的观点文章,但它并不是硬性事实。
如果你使用F#,你将通过编写良好的代码获得引用透明度。这意味着将大部分逻辑编写为转换序列,并执行效果以在运行转换之前读取数据,然后在运行效果以将结果写入某个地方之后运行。 (并非所有程序都符合此模式,但那些可以以引用透明方式编写的程序通常会这样做。)
根据我的经验,你可以在“中间”过得很愉快。这意味着,大多数情况下编写引用透明的代码,但在某些实际原因下打破规则。
针对引述内容中的一些具体观点进行回应:
“仅部分删除隐式副作用无法使命令式编程语言更安全。”
我同意,如果安全的含义是没有副作用,那么使它们变得“安全”是不可能的,但是通过删除一些副作用,你可以使它们变得更加“安全”。
“留下一种效果通常足以模拟刚刚尝试移除的效果。”
是的,但是为了提供理论证明而模拟效果并不是程序员所做的。如果被足够地反对以实现效果,你往往会以其他(更安全)的方式编写代码。
“我还学到了像Scala或F#这样的非纯FP语言无法保证引用透明度。”
是的,这是正确的,但是“引用透明度”并不是函数式编程的全部。对我来说,它是关于拥有更好的建模域的方法和指导我沿着“快乐路径”前进的工具(例如类型系统)。引用透明度是其中的一部分,但它不是万能药。引用透明度不会神奇地解决你所有的问题。

3
尽管我同意你所说的,托马斯,但还是有些难回答的问题:1)函数式编程不是关于使用(数学意义上的)函数进行编程吗?这些函数是否具有隐含的引用透明性?2)许多语言和范例可以声称在安全性和实用性之间取得良好的平衡,你认为F#能够达到高效的“快乐路径”是否有实际理由?例如,更多的引用透明性是否会有任何负面影响? - TheInnerLight
3
@TheInnerLight 我认为这些都是非常好的问题,它们询问了问题的实际核心!(1)根据某个定义,我可能更喜欢现在更关注“组合性”的定义。(2)不,只是凭经验而已,我想知道是否可以以更结构化的方式进行评估...(3)我认为,比如在Haskell中存在弊端,但我相信可以以一种不具有这些弊端的方式来完成。 - Tomas Petricek

0

正如Mark Seemann在评论中确认的那样,“F#中没有任何东西可以保证引用透明性。这取决于程序员对此的思考。”

我在网上进行了一些搜索,并发现“纪律是你最好的朋友”,并建议尽可能保持F#应用程序中引用透明度的水平:

  • 不要使用可变的、for或while循环、ref关键字等。
  • 坚持使用纯不可变数据结构(区分联合、列表、元组、映射等)。
  • 如果您需要在某个时刻进行IO,请架构您的程序,使其与您的纯函数代码分离。不要忘记函数式编程是关于限制和隔离副作用的。
  • 代数数据类型(ADT)又称“区分联合”而非对象。
  • 学会喜欢惰性。
  • 拥抱Monad。

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