【问题标题】:Is functional programming less efficient for this case?在这种情况下,函数式编程效率会降低吗?
【发布时间】:2016-02-17 03:48:23
【问题描述】:

我正在阅读《Javascript 中的函数式编程》一书。

在第 2 章中,对于查找字符串中仅包含字母的前四个单词的命令式/函数式代码进行了以下比较:

命令式

var words = [], count = 0;
text = myString.split(' ');
for (i=0; count<4, i<text.length; i++) {
  if (!text[i].match(/[0-9]/)) {
    words = words.concat(text[i]);
    count++;
  }
}

功能性

var words = [];
var words = myString.split(' ').filter(function(x){
    return (! x.match(/[1-9]+/));
}).slice(0,4);

我推断,对于任何text 的长度大于四个的情况,命令式 版本会更快,因为它只会找到符合条件的前四个单词,而 functional 版本首先过滤整个数组,然后才分割前四个元素。

我的问题是,我的假设是否正确?

【问题讨论】:

  • 这是一个非常棒的问题。我的暂定答案是“这取决于编译器/语言。”我知道 Haskell 做了一些疯狂的优化,因为它可以对很多行为做出完美的保证。对于 Javascript,情况并非如此。
  • 查看惰性求值。
  • 不管它是否正确,请注意效率并不是函数式编程的重点。还有其他更重要的功能,而且您通常甚至愿意用它们换取执行速度。
  • 这个问题(或者至少你的推理)并不是关于函数与命令式编程的。这是关于您为两者选择的解决方案。您可以采用一种功能性方法,在找到前 4 个项目后也会停止。
  • 此外,“功能”示例虽然非常易读和清晰,但并不是高性能功能代码的最佳示例。该操作归结为一个简单的reduce

标签: javascript functional-programming


【解决方案1】:

在某些情况下(如您的情况)是的,但并非总是如此。许多像 Haskell 或 Scala 这样的函数式语言都内置了惰性。这意味着不会立即评估函数,而是仅在需要时评估函数。

如果你熟悉 Java 8,他们的 Streams API 也是惰性的,这意味着像这样的东西,不会遍历整个流 3 次。

stream.filter(n -> n < 200)
    .filter(n -> n % 2 == 0)
    .filter(n -> n > 15);

这是一个非常有趣的概念,您可以在此处查看 Scala Stream 类的文档http://www.scala-lang.org/api/2.10.0/index.html#scala.collection.immutable.Stream

【讨论】:

  • 我认为您没有正确解释“懒惰”的概念。在您的 Java 流示例中发生的情况是 filter 操作是**交错的** — 元素一次“流动”一个元素通过三个过滤器的管道。这与惰性不同,惰性意味着永远不会访问不可能对结果流做出贡献的基本元素。例如,Java 8 流中的limit(long) 操作就表现出惰性——超过限制的基本流的元素永远不会被要求。
  • 你说得很好!我只是想演示如何优化 API,使其与命令式构造一样具有高性能,这是我首先想到的。
【解决方案2】:

这两个代码片段的比较非常有意义 - 作为教程的一部分。函数式编程要求很高,如果作者没有向读者展示最有效的函数式实现,那么请保持示例简单。

为什么函数式编程要求很高?因为它遵循数学原理(这些原理并不总是人类逻辑),而且因为新手经常习惯于命令式风格。在 FP 中,数据流具有优先权,而实际算法仍处于后台。习惯这种风格需要时间,但如果你做过一次,你可能永远不会回头!

如何以函数式的方式更有效地实现此示例?有几种可能性,我说明了其中两种。请注意,这两种实现都避免使用中间数组:

  1. Lazy Evaluation

Javascript 是经过严格评估的。但是,惰性求值可以用 thunk(空函数)来模拟。此外,foldR(向右折叠)作为迭代函数是必需的,filterN 是从中派生的:

const foldR = rf => acc => xs => xs.length
 ? rf(xs[0])(() => foldR(rf)(acc)(xs.slice(1)))
 : acc;

const filterN = pred => n => foldR(
  x => acc => pred(x) && --n ? [x].concat(acc()) : n ? acc() : [x]
)([]);

const alpha = x => !x.match(/[0-9]/);
let xs = ["1", "a", "b", "2", "c", "d", "3", "e"];

filterN(alpha)(4)(xs); // ["a", "b", "c", "d"]

这种实现的缺点是filterN 不是纯的,因为它是有状态的 (n)。

  1. Continuation Passing Style

CPS 启用 filterN 的纯变体:

const foldL = rf => acc => xs => xs.length
 ? rf(acc)(xs[0])(acc_ => foldL(rf)(acc_)(xs.slice(1)))
 : acc;

const filterN = pred => n => foldL(
  acc => x => cont => pred(x)
   ? acc.length + 1 < n ? cont(acc.concat(x)) : acc.concat(x)
   : cont(acc)
)([]);

const alpha = x => !x.match(/[0-9]/);
let xs = ["1", "a", "b", "2", "c", "d", "3", "e"];

filterN(alpha)(4)(xs); // ["a", "b", "c", "d"]

foldRfoldL 的区别有点令人困惑。区别不在于交换性,而在于结合性。 CPS 实现仍然有一个缺点。 filterN 应该分为filtertakeN,以增加代码的可重用性。

  1. Transducers

转换器允许组合(减少/转换)函数,而不必依赖中间数组。因此,我们可以将filterN 分成两个不同的函数filtertakeN,从而提高它们的可重用性。不幸的是,我还没有找到适合于可理解和可执行示例的转换器的简洁实现。我将尝试开发自己的简化换能器解决方案,然后在此处给出适当的示例。

结论

如您所见,这些实现可能不如命令式解决方案高效。 Bergi 已经指出,执行速度并不是函数式编程最相关的问题。如果微优化对您很重要,您应该继续依赖命令式样式。

【讨论】:

猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-09-25
  • 2015-04-25
相关资源
最近更新 更多