【问题标题】:Can non-persistent data structures be used in a purely functional way?非持久性数据结构可以以纯粹的功能方式使用吗?
【发布时间】:2017-03-05 07:28:12
【问题描述】:

这是一个关于函数式编程的一般问题,但我也很感兴趣它在特定语言中的答案。

我只是函数式语言的初学者知识,所以请耐心等待。

据我了解,函数式语言侧重于与命令式语言不同的数据结构,因为它们喜欢不变性:持久性数据结构

例如,它们都有一个不可变的列表概念,您可以在其中从现有列表 l 和两个新项目 xy 中形成一个新列表 x :: ly,而没有 @ 的所有元素987654327@ 需要复制。这很可能是通过新的列表对象在内部指向旧的作为尾部的对象来实现的。

在命令式语言中,很少使用这种数据结构,因为它们提供的引用局部性不如 c 样式数组。

一般来说,寻找支持函数式风格的数据结构是它自己的一项努力,所以如果不必总是这样做,那就太好了。

现在有一个想法,如果有正确的语言支持,如何在函数式编程中使用所有经典数据结构。

一般来说,命令式语言中的数据结构具有在其上定义的修改操作(在伪代码中):

data.modify(someArgument)

函数式的写法是

newData = modified(data, someArgument)

一般的问题是,这通常需要复制数据结构 - 除非语言知道data 实际上不会被其他任何东西使用:然后,可以以变异原始的形式进行修改没有人能分辨出来。

有一大类语言可以推断出“从未在其他地方使用过”的属性:当modified 的第一个参数是一个未绑定的值时,如下例所示:

newData = modified(modified(data, someArgument))

这里,data 可以在其他地方使用,但 modified(data, someArgument) 显然不是。

这在 C++ 中被称为“右值”,而在 C++ 的最新版本中,具有讽刺意味的是,它根本没有功能,否则可能会重载此类右值。

例如,可以这样写:

Data modified(Data const& data) { // returns a modified copy }
Data modified(Data && data) { // returns the modified original }

这意味着在 C++ 中,实际上可以采用任何可变的高效数据结构并将其转换为具有可以以纯函数方式使用的不可变 api,就像命令式版本一样有效。 em>

(有一点需要注意的是,在 C++ 中,有时强制强制右值重载仍然需要强制转换。当然,在实现此类数据结构时需要小心,即在使用右值重载时。虽然这可能会有所改进.)

现在我的问题:

实际的函数式语言是否有类似的机制?还是因为其他原因不需要这样做?

(我标记了一些我特别感兴趣的特定语言。)

【问题讨论】:

  • @duplode 你并没有真正阅读这个问题,是吗?
  • 你是对的,确实:它不是重复的,我撤回了我的近距离投票。不过,没有必要对问题进行被动积极的编辑——只需要清楚地指出错误(在这种情况下,是我的错误)。在任何情况下:(1)如果您想了解更多关于“[您提到的技巧]出于其他原因不需要”的原因,您可能会发现我链接到的问题中的参考很有用。 (2) 目前有三个“过于宽泛”的近距离投票,我认为这也不合适(也许更具体的标题有助于避免进一步的误解)。
  • @duplode 标题很好的建议。
  • 您声称“这意味着在 C++ 中,实际上可以采用任何可变的高效数据结构并将其转换为具有可以以纯函数方式使用的不可变 api,就像命令式一样有效如果您需要持久性,版本将是“显然是错误的。你到底想说什么?

标签: scala haskell f# functional-programming


【解决方案1】:

Haskell 中的ST(状态线程)monad 是一种确保按顺序调用某些操作的方法(不能在该序列之外进行修改)。在ST 中,您可以在 Haskell 中使用命令式、可变数据结构。请注意,Haskell 被认为是少数纯函数式语言之一。

【讨论】:

    【解决方案2】:

    确实,持久数据结构比可变数据结构慢。对此没有争议。有时差异可以忽略不计(迭代链表与数组),有时差异可能很大(反向迭代),但这不是重点。使用不可变数据的选择是(或应该是)有意识的选择:我们以性能换取稳定性。

    考虑这一点:对于大多数(不是所有)现代程序,本地性能不是问题。对于今天的程序,真正的性能瓶颈在于并行化——无论是在具有共享内存的本地机器上,还是在不同机器上。随着我们现在处理的大量数据,从内存局部性和分支预测中挤出最后一点并不会减少它。我们需要规模。猜猜并行程序中的第一大错误来源?没错——变异。

    现代程序的另一个大问题是稳定性。程序可能在您身上崩溃的日子已经一去不复返了,您只需重新启动它并继续工作。今天的程序需要在无头服务器上运行数月或数年,完全无需人工​​干预。今天的程序不能只是扔掉它的数字武器并期望人类找出问题所在。在这种情况下,本地性能远不如稳定性和并行化重要:购买(或租用)另外 10 台服务器比雇佣一个人不时重启程序要便宜得多。

    确实可以使用突变制作一个可并行且稳定的程序。理论上是可以的。只是更难。对于不可变数据,您实际上必须首先瞄准

    然后,这里有一些观点:我们已经去过那里。您在代码中使用goto 指令的频率如何?你考虑过这是为什么吗?你可以用goto 做各种巧妙的表演技巧,但我们选择不这样做。在编程历史的某个时刻,我们认为goto 麻烦多于其价值。原始指针也发生了同样的事情:许多语言根本没有它们,其他语言则严密保护它们,甚至在那些可以不受限制地访问原始指针的语言中,现在使用它们被认为是一种不好的形式。今天,我们正处于下一个阶段的中期:首先我们放弃了goto,然后我们放弃了原始指针,现在我们正在慢慢放弃突变。

    然而,如果您真的发现自己出于正当理由推动了本地性能的极限,并且您已经确定不可变数据确实是瓶颈(记住:先测量,然后优化),那么大多数函数式语言(Haskell 和 Elm 除外)都会让你摆脱突变,尽管很不情愿。就像 C# 中的原始指针一样,你可以有突变,你只需要明确(并且小心)它。例如,在 F# 中,您可以拥有可变变量、原始数组、可变记录、类、接口等。这是可能的,只是不推荐。到目前为止的普遍共识是,只要它是本地化的(即不会泄漏到外部),就可以使用突变,并且你真的知道你在做什么,并且你已经记录了它,并且对它进行了测试.

    这种情况的一个常见情况是“值构造”,其中您有一个函数最终会产生一个不可变的值,但在执行此操作时会做各种混乱的事情。一个例子是 F# 核心库如何实现List.map:通常,因为列表是从前到后迭代的,但构造是从后到前的,因此需要首先通过迭代来构造转换后的列表,然后将其反转。因此,F# 编译器在此处作弊并在构建列表时对其进行变异,以避免额外的反转。

    还有一个关于“局部性”问题的说明。还记得我提到你可以用goto 做各种巧妙的性能技巧吗?好吧,这不再是真的了。由于程序员开始在没有goto 的情况下编写程序,二进制代码变得更加可预测,因为跳转现在由编译器生成,而不是由人类编码。这使得 CPU 可以很好地预测它们,并根据这些预测优化处理。最终结果是,现在您实际上可能通过不加选择地使用goto 而获得更差的性能,而不是使用公认的高级工具(如循环)。过去,CPU 不能那么聪明,因此选择不使用goto 纯粹是一种稳定性措施。但现在事实证明它实际上对性能有帮助,谁能想到呢?

    我认为不变性也会发生同样的事情。我不确定如何会发生,但我确信它会发生。即使在今天,在没有特殊硬件的情况下,仍然可以在编译时做一些优化:例如,如果编译器知道一个变量是不可变的,它可能会决定将它长期缓存在寄存器中,甚至提升它完全为常数。确实,当今大多数实际的编译器并没有执行所有这些可能的优化(尽管他们确实执行了一些),但他们会。我们才刚刚开始。 :-)

    【讨论】:

    • 我了解并欣赏函数式语言的那些设计目标。我关于典型函数式语言中良好移动语义的问题将支持该目标,而不是与之相矛盾。如果做得好,我相信它可以允许一种纯粹的函数式编程风格,它可以完全按照相应的命令式程序进行翻译,并且执行速度也一样快。
    • 您甚至可以在 Haskell 中进行各种变异。认可的版本在IOSTSTM,但不是唯一可用的版本。如果你足够小心,你可以使用 unsafePerformIO 之类的东西来实现看似不可变的结构,并在引擎盖下进行突变。如果你想活得极其危险,你甚至可以破解IOST 类型并使用realWorld# 甚至runRW# 手动处理状态令牌。这样的代码可能是安全的,但需要非常小心。
    【解决方案3】:

    我很确定 别名分析 之类的功能(检查数据是否在其他地方使用)不是 Scala 编译器的一部分(也不是 Haskell 和 Clojure 等其他 FP 语言的一部分)。 Scala 中的集合 API(例如)被明确拆分为 immutablemutable 包。 immutable 数据结构使用结构共享的概念来消除复制数据的需要,从而减少使用不可变结构的开销(就时间数据量而言)。

    正如您已经指出的那样,像 cons :: 这样的方法会创建一个新的不可变结构,该结构在底层包含对任何现有不可变数据的引用(而不是对其进行复制)。

    mutableimmutable 类型之间的转换(例如在 Scala 中)会复制 mutable 数据(通常以惰性方式),而不是使用任何机制,例如检查 mutable 结构是否没有在其他任何地方提及并允许它发生突变。

    当我第一次从 Java 迁移到 Scala 时,我最初认为在使用不可变结构时必须创建的(通常)大量时间数据可能是一个性能限制,并且涉及一些巧妙的技术来实际允许在它发生变化的地方进行突变这样做是安全的,但事实并非如此,因为这个想法是不可变数据永远不会指向更年轻的值。由于新值在创建旧值时不存在,因此无法在创建时指向它,并且由于值永远不会被修改,因此以后也无法指向它。结果是 Scala/Haskell 等 FP 语言能够生成所有这些时间数据,因为垃圾收集器能够在很短的时间内将其删除。

    简而言之,Scala/Haskell(我不确定 F#)不允许不可变结构的突变,因为当前 JVM 等运行时环境的状态具有非常高效的垃圾收集,因此可以非常快速地删除临时数据.当然,我相信您知道,包含可变元素的不可变结构在 Scala 等 FP 语言中是完全可能的,但尽管可变元素可以更改,但不可变容器不能 ie。既不能添加/删除元素。

    【讨论】:

    • 感谢您的回答 - 这有点像我预期的那样。有趣的是,这意味着在某种程度上,C++ 允许一种比函数式语言本身更具函数式的编程风格,尽管这更像是一种学术观察,因为没有人会以这种方式使用 C++。
    • 顺便说一句,我主要关心的不是内存,而是引用的位置。持久性数据结构通常是某种形式的树,其中的节点单独存在于堆上,而那些很糟糕。这就是为什么哈希表比搜索树更好的原因,我怀疑函数式语言在实践中会因此而慢得多。
    • John,根据用例,Scala(再次举例来说)具有在设计时考虑到局部性的结构。例如,Vector 是一个由 32 个元素组成的数组的不可变浅树。操作可以以 32 个块为单位执行,这恰好与现代处理器中高速缓存行的大小一致。因此,对于像map 这样的批量操作,访问将相当快。对于像List 这样的结构,正如你所说,位置是一个考虑因素,因为每个单元格都指向下一个可以在任何地方的单元格。然而,对于递归操作,List 通常会比Vector 快。
    • 这是一个有趣的观点。我刚刚查了一下,Scala 的 immutable.HashMap 也进行了类似的优化。
    • @John 另外,还有一些类似于 Nio 描述的情况,例如,在 unordered-containers,Haskell 的不可变哈希映射的事实上标准实现。跨度>
    【解决方案4】:

    1) 函数式编程语言支持持久数据结构。当一个数据结构被转换成另一个数据结构或对该数据结构执行任何操作以产生一个新的数据结构时,数据结构的未更改部分通过链接被重用,特别是在列表的情况下。

    在计算中,持久性数据结构是一种在修改时始终保留自身先前版本的数据结构。这种数据结构实际上是不可变的,因为它们的操作不会(明显地)就地更新结构,而是总是产生一个新的更新结构。

    2) 在纯惰性函数式语言中,计算被延迟,并且仅当表达式的结果用于最终值/结果时才进行评估。这种机制将有助于避免不必要的计算。

    【讨论】:

    • 我知道,这就是我在问题开头所说的(参见列表示例)。我添加了 persistent data structure 流行语以更清楚地说明这一点。无论如何,这个问题是关于以函数方式使用非持久性数据结构及其在函数式语言中的支持。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-09-03
    • 1970-01-01
    相关资源
    最近更新 更多