在调用者看来是纯粹的函数,但在内部使用了变异。

25 浏览
0 Comments

在调用者看来是纯粹的函数,但在内部使用了变异。

我刚拿到《Expert F# 2.0》一书,看到了以下陈述,有些让我感到惊讶:

例如,在必要时,你可以在算法开始时对私有数据结构使用副作用,然后在返回结果之前丢弃这些数据结构;这样整体上就形成了一个无副作用的函数。F#库中的一个与库分离的示例是List.map的实现,它在内部使用了突变;写操作发生在一个内部的、分离的数据结构上,其他代码无法访问它。

显然,这种方法的优势在于性能。我只是好奇是否存在任何不利之处-是否存在与副作用相关的任何问题?并行性是否受到影响?

换句话说,如果性能被搁置不谈,实现List.map是否最好以纯粹的方式?

(显然,这涉及到特定的F#问题,但我也对一般的哲学问题很感兴趣)

0
0 Comments

调用者看起来纯粹的函数内部使用了可变状态的数据结构,这可能会导致并行化受到影响。如果一个函数在内部创建了一个与列表大小相等的数组,并迭代列表的元素填充数组,那么你仍然可以同时在同一个列表上并发地运行100次map函数,而不必担心,因为每个map实例都有自己私有的数组。因为在数组被填充之前,你的代码无法看到数组的内容,所以它在效果上是纯粹的(请记住,在某个层面上,你的计算机必须实际上改变RAM的状态)。

另一方面,如果一个函数使用全局可变的数据结构,那么并行化可能会受到影响。例如,假设你有一个Memoize函数。显然,它的整个目的就是维护一些全局状态(尽管在函数调用中不是局部的,但从函数外部不可访问),以便不必多次使用相同的参数运行函数,但它仍然是纯粹的,因为相同的输入总是产生相同的输出。如果你的缓存数据结构是线程安全的(如ConcurrentDictionary),那么你仍然可以并行地运行函数本身。如果不是,那么可以说该函数不是纯粹的,因为在并行运行时会观察到副作用。

我应该补充一点,F#中常见的一种技术是先使用纯函数的方式编写,然后根据性能分析的结果,利用可变状态进行优化(例如缓存、显式循环)。

实际上,这是一个常见的误解。非纯粹的内部实现往往会提高可伸缩性,因为原地修改意味着更好的空间重用,因此减少了分配和更好的内存占用,这意味着对垃圾收集器和主内存的争用较少。对共享资源的争用较少意味着更好的可伸缩性。

0
0 Comments

函数在对调用者看来是纯的,但在内部使用了突变,这种情况的出现原因是函数对局部状态的影响通常不会对程序的其他部分产生副作用。副作用本身并不是坏事(就像所说的,即使是纯函数式程序也会不断改变RAM),问题在于副作用的常见副作用(非局部交互)会导致调试/性能/可理解性等问题。因此,对纯粹的局部状态(例如不会逃逸的局部变量)进行副作用是可以接受的。

(我能想到的唯一缺点是,当人类看到这样的局部可变时,他们必须推理出它是否可以逃逸。在F#中,局部可变永远不会逃逸(闭包不能捕获可变),因此唯一潜在的“心理负担”来自于推理可变引用类型。)只要能简单地使自己相信这些效果只会发生在不逃逸的局部变量上,那么使用效果是可以接受的。(在其他情况下使用效果也可以,但是我忽略了这些其他情况,因为在这个问题线程中,我们是试图在合理的时候避免效果的启蒙式函数式程序员。:))

(如果你想深入研究,像F#的List.map实现中的局部效果不仅不会妨碍并行性,而且实际上还有好处,从这个角度来看,更高效的实现分配更少,因此对于GC的共享资源来说,压力更小。)

关于局部效果可以更高效的观点,FUZxxl如下所指出的纯函数式代码可以更容易地通过编译器进行优化。这种效果似乎平衡或超过了更高效的非优化解决方案。

优化编译器是一个神话(除了Haskell可能)。好吧,这个说法太绝对了,但是对于一个不纯语言的编译器来说,优化纯代码往往不如一个人用不纯代码手动优化得好。编译器并不那么好。

考虑到底层硬件是“不纯”的,我认为一个足够聪明的手动优化人写的不纯代码会超过任何编译器。Haskell编译器非常聪明,但是有一个原因使得像ST单子这样的东西存在,而且GHC的所有深度魔法大部分只能让你的代码性能与写得好(不是优化)的不纯代码相似。在没有Haskell提供的广泛静态保证的情况下,期望其他语言有更好的效果是疯狂的(尽管我听说OCaml有时可以取得令人印象深刻的结果)。

你是括号陈述的忠实粉丝,不是吗?:)

这是因为英语没有#light 🙂

但是OCaml通过非常不同的方式获得了很好的性能:通过具有简单和可预测成本模型以及倾向于使短代码快速的语言设计。这基本上与Haskell相反。

优化编译器是一个神话 来吧。你不想为了每个在代码中只使用一次的函数在C++中进行搜索以编写inline,尤其是如果函数很大。但是编译器足够聪明,在链接时优化中看到callret在这里是不必要的,并将其内联。它还可以发现只执行一次或两次的小循环,并展开它-但您不希望在代码中显式执行此操作。此外,编译器可以执行依赖于可用CPU指令的优化,甚至可以针对CPU缓存大小进行优化(Gentoo人喜欢的东西)

0
0 Comments

Functions that look pure to callers but internally use mutation是指在函数外部调用时看起来是纯函数,但在内部使用了状态变化。这种情况可能会带来一些问题,因为它违背了函数式编程的原则,即函数应该是纯的,不应该产生副作用。

这种问题出现的原因是因为在某些编程语言中,如Scala和F#,对于函数内部是否使用了状态变化,编译器并没有强制要求。因此,开发人员可能会在函数内部使用状态变化,使得函数在外部调用时看起来是纯函数,但实际上产生了副作用。

为了解决这个问题,可以借鉴Haskell中的做法,使用类型系统来强制执行封装性。在Haskell中,可以使用Control.Monad.ST来实现这种封装性,编译器会在编译时检查函数是否符合纯函数的要求。这样可以确保函数在外部调用时是纯函数,而在内部使用了状态变化。

然而,在实际的Haskell代码中,不受控制的副作用仍然很常见。有些开发人员为了性能考虑,会使用unsafePerformIO来绕过类型系统的限制,这样就可能出现了看起来是纯函数但实际上产生了副作用的情况。不过,目前已经有一些方法可以解决这个问题,因此unsafePerformIO可能不再是必需的。

举一个例子,开发人员研究了一些开源的Haskell代码,如游戏Frag,发现它们在很多地方都使用了unsafePerformIO。询问作者的原因时,他们都回答是为了性能。但据说后来已经解决了这个问题,因此可能不再需要使用unsafePerformIO

总之,Functions that look pure to callers but internally use mutation是一种在函数外部调用时看起来是纯函数,但在内部使用了状态变化的情况。这种问题的出现是因为某些编程语言对于函数内部是否使用了状态变化并没有强制要求。为了解决这个问题,可以借鉴Haskell中使用类型系统来强制执行封装性的做法。然而,在实际的Haskell代码中,不受控制的副作用仍然很常见,开发人员可能会使用unsafePerformIO来绕过类型系统的限制。不过,目前已经有一些方法可以解决这个问题,因此unsafePerformIO可能不再是必需的。

0