非功能性方式:
arr = [1, 2, 3]
变为arr = [1, 5, 3]
。这里我们改变相同的数组。
在功能编程中不鼓励这样做。我知道,由于计算机每天变得越来越快,存储的内存越来越多,函数式编程似乎更可行,更好的可读性和清晰的代码。
功能方式:
arr = [1, 2, 3]
arr2 = [1, 5, 3]
未被更改O(1)
。我看到一个大趋势,我们使用更多的内存和时间来改变一个变量。
在这里,我们将内存加倍,时间复杂度从O(n)
更改为{{1}}。
对于更大的算法,这可能是昂贵的。这在哪里得到补偿?或者既然我们可以负担得起更昂贵的计算(比如量子计算成为主流),我们是否只是为了提高可读性而换取速度?
答案 0 :(得分:6)
功能数据结构不一定占用更多空间或需要更多处理时间。这里的重要方面是纯粹的功能数据结构是不可变的,但这并不意味着你总是制作完整的东西副本。事实上,不变性正是高效工作的关键。
我将提供一个简单列表作为示例。假设我们有以下列表:
列表的头部是元素1
。列表的尾部是(2, 3)
。假设此列表完全不可变。
现在,我们想在该列表的开头添加一个元素。我们的新列表必须如下所示:
您无法更改现有列表,它是不可变的。所以,我们必须做一个新的,对吗?但请注意我们新列表的尾部是(1, 2 ,3)
。这与旧列表完全相同。所以,你可以重复使用它。新列表只是元素0
,其指针指向旧列表的开头作为尾部。这是新列表,其中突出显示了各个部分:
如果我们的名单是可变的,这将是不安全的。如果您更改了旧列表中的某些内容(例如,将元素2
替换为另一个元素),则更改也会反映在新列表中。这恰好是可变性的危险所在:需要同步数据结构上的并发访问以避免不可预测的结果,并且更改可能会产生意想不到的副作用。但是,因为不可能的数据结构不会发生这种情况,所以在新的结构中重复使用另一个结构的一部分是安全的。有时你希望一件事的变化反映在另一件事上;例如,当您删除Java中Map
的密钥集中的条目时,您也希望删除映射本身。但在其他情况下,可变性会导致麻烦(Java中臭名昭着的Calendar
类)。
那么,如果您无法改变数据结构本身,这怎么可行呢?你如何制作新名单?请记住,如果我们纯粹在功能上工作,我们会使用可变指针从经典数据结构中移开,而是评估函数。
在函数式语言中,使用cons
函数完成列表制作。 cons
创建一个"单元格"两个元素。如果要创建仅包含一个元素的列表,则第二个元素为nil
。因此,只有一个3
元素的列表是:
(cons 3 nil)
如果上述内容是一项功能而您询问其head
是什么,则会得到3
。询问tail
,您获得nil
。现在,尾部本身可以是一个函数,如cons
。
我们的第一个清单就是这样表达的:
(cons 1 (cons 2 (cons 3 nil)))
询问上述功能的head
,即可获得1
。请求tail
,然后获得(cons 2 (cons 3 nil))
。
如果我们想在前面添加0
,您只需创建一个新的函数,其值cons
为0
为头,上面为尾。
(cons 0 (cons 1 (cons 2 (cons 3 nil))))
由于我们创建的函数是不可变的,因此我们的列表变得不可变。添加元素之类的事情是制作一个新函数,在正确的位置调用旧函数。以命令式和面向对象的方式遍历列表是指向从一个元素到另一个元素的指针。以功能方式遍历列表是评估函数。
我喜欢将数据结构视为:数据结构基本上是在内存中存储运行某些算法的结果。它"缓存"计算的结果,所以我们不必每次都进行计算。纯功能数据结构通过函数对计算本身进行建模。
这实际上意味着它可以非常节省内存,因为可以避免大量数据复制。随着对处理中并行化的日益重视,不可变数据结构非常有用。
修改
鉴于评论中的其他问题,我会尽我所能为上述内容添加一些内容。
我的例子怎么样?它是缺点(1 fn)并且该函数可以是缺点(2 fn2)其中fn2是cons(3 nil)而在其他情况下cons(5 fn2)?
cons
函数最好与单链接列表进行比较。正如您可能想象的那样,如果您给出了由cons
单元格组成的列表,那么您获得的是头部,因此无法随机访问某些索引。在你的数组中,您可以调用arr[1]
并在常量时间内获取数组中的第二项(因为它的0索引)。如果您说明val list = (cons 1 (cons 2 (cons 3 nil)))
之类的内容,则无法在不遍历第二项的情况下询问第二项,因为list
现在实际上是您评估的函数。因此访问需要线性时间,访问最后一个元素所需的时间比访问head元素要长。此外,鉴于它等同于单链表,遍历只能在一个方向上进行。因此,行为和性能更像是单链表,而不是例如arraylist或数组。
纯功能数据结构不一定能为某些操作(如索引访问)提供更好的性能。 A"经典"对于某些操作,数据结构可以具有O(1),其中功能性操作可以具有针对相同操作的O(log n)。这是一个权衡;功能数据结构不是一个银弹,就像面向对象一样。你可以在有意义的地方使用它们。如果您总是要遍历整个列表或部分列表并希望能够安全并行访问,则由cons
单元组成的结构可以完美地运行。在函数式编程中,您经常使用递归调用遍历结构,在命令式编程中,您使用for
循环。
当然还有许多其他功能数据结构,其中一些更接近于建模允许随机访问和更新的数组。但它们通常比上面的简单示例复杂得多。当然有优势:由于不变性,并行计算可以轻松实现; memoization 允许我们根据输入缓存函数调用的结果,因为纯函数方法总是会为同一输入产生相同的结果。
我们实际存放在底下的是什么?如果我们需要遍历列表,我们需要一种机制来指向下一个元素吗?或者如果我想一点,我觉得遍历列表是无关紧要的问题,因为每当需要列表时,它应该每次重建?
我们存储包含函数的数据结构。什么是cons
?一个由两个元素组成的简单结构:head
和tail
。它只是指针下面的指针。在像Java这样的面向对象语言中,您可以将其建模为类Cons
,其中包含两个最终字段head
和tail
在构造时分配(不可变)并有相应的方法来获取这些。这是LISP变体
(cons 1 (cons 2 nil))
等同于
new Cons(1, new Cons(2, null))
。
函数式语言的最大区别在于函数是一等类型。它们可以像对象引用一样传递并分配给变量。你可以撰写功能。我可以用功能语言轻松做到这一点
val list = (cons 1 (max 2 3))
如果我问list.head
我得到1,如果我问list.tail
我得到(max 2 3)
并评估只是给我3.你撰写函数。将其视为建模行为而非数据。这将我们带到
你能否详细说明"纯功能数据结构通过函数对计算本身进行建模。"?
在上面的列表中调用list.tail
会返回可以评估的内容,然后返回一个值。换句话说,它返回一个函数。如果我在该示例中调用list.tail
,则返回(max 2 3)
,显然是一个函数。评估它会产生3
,因为它是参数的最大数量。在这个例子中
(cons 1 (cons 2 nil))
调用tail
评估为新的cons
((cons 2 nil)
},而后者又可以使用。
假设我们需要列表中所有元素的总和。在Java中,在引入lambdas之前,如果你有一个数组int[] array = new int[] {1, 2, 3}
,你可以做类似的事情
int sum = 0;
for (int i = 0; i < array.length; ++i) {
sum += array[i];
}
在函数式语言中,它就像(简化的伪代码)
(define sum (arg)
(eq arg nil
(0)
(+ arg.head (sum arg.tail))
)
)
这使用前缀表示法,就像我们目前使用的cons
一样。因此a + b
写为(+ a b)
。 define
允许我们定义一个函数,使用名称(sum
)作为参数,函数((arg)
)的参数列表,然后是实际的函数体(其余的)。
函数体由eq
函数组成,我们将其定义为比较前两个参数(arg
和nil
)以及它们是否相等计算其下一个参数(在这种情况下为(0)
),否则为其后的参数(总和)。所以把它想象为(eq arg1 arg2 true false)
,无论你想要什么都是真的和假(一个值,一个函数......)。
递归位然后是总和(+ arg.head (sum arg.tail))
。我们正在声明我们在尾部添加head
参数,并对sum
函数本身进行递归调用。假设我们这样做:
val list = (cons 1 (cons 2 (cons 3 nil)))
(sum list)
明智地逐步完成最后一行的工作,看看它如何评估list
中所有元素的总和。
注意,现在,sum
是功能的方式。在Java示例中,我们有一些数据结构,然后对其进行迭代,对其执行访问,以创建总和。在功能示例中,评估是计算。一个有用的方面是sum
作为一个函数可以传递并仅在实际需要时进行评估。那是懒惰评估。
数据结构和算法如何以不同的形式实际上是同一个东西的另一个例子。拿一个set
。对于元素相等的某些定义,集合只能包含元素的一个实例。对于像整数这样的东西,它很简单;如果它们是相同的值(如1 == 1
),则它们相等。但是,对于对象,我们通常会进行一些相等性检查(如Java中的equals()
)。那你怎么知道一个集合是否已经包含一个元素?您将遍历集合中的每个元素,并检查它是否等于您正在寻找的元素。
但是,hash set
计算每个元素的一些散列函数,并在相应的存储桶中放置具有相同散列的元素。对于良好的散列函数,存储桶中很少会有多个元素。如果您现在提供一些元素并想要检查它是否在集合中,则操作为:
要求是两个相等的元素必须具有相同的哈希值。
所以现在你可以在常数时间检查集合中是否有东西。原因是我们的数据结构本身存储了一些计算信息:哈希。如果将每个元素存储在与其哈希对应的存储桶中,我们将一些计算结果放在数据结构本身中。如果我们想要检查集合是否包含元素,这将节省时间。这样,数据结构实际上是在内存中冻结的计算。我们不是每次都进行整个计算,而是预先完成一些工作并重新使用这些结果。
当您认为数据结构和算法以这种方式类似时,函数可以更清楚地模拟同一事物。
请务必查看经典书籍&#34;计算机程序的结构和插入&#34; (通常缩写为SICP)。它会给你更多的洞察力。你可以在这里免费阅读:https://mitpress.mit.edu/sicp/full-text/book/book.html
答案 1 :(得分:3)
这是一个非常广泛的问题,有很多自以为是的答案,但G_H提供了一些非常好的细分差异
你能否详细说明“纯功能数据结构通过函数对计算本身进行建模。”?
这是我最喜欢的主题之一,所以我很高兴在JavaScript中分享一个示例,因为它允许您在浏览器中运行代码并自己查看答案
下面您将看到使用功能实现的链接列表。我使用一些数字作为示例数据,我使用一个字符串,以便我可以将某些内容记录到控制台供您查看,但除此之外,它只是函数 - 没有花哨的对象,没有数组,没有其他自定义的东西。
const cons = (x,y) => f => f(x,y)
const head = f => f((x,y) => x)
const tail = f => f((x,y) => y)
const nil = () => {}
const isEmpty = x => x === nil
const comp = f => g => x => f(g(x))
const reduce = f => y => xs =>
isEmpty(xs) ? y : reduce (f) (f (y,head(xs))) (tail(xs))
const reverse = xs =>
reduce ((acc,x) => cons(x,acc)) (nil) (xs)
const map = f =>
comp (reverse) (reduce ((acc, x) => (cons(f(x), acc))) (nil))
// this function is required so we can visualise the data
// it effectively converts a linked-list of functions to readable strings
const list2str = xs =>
isEmpty(xs) ? 'nil' : `(${head(xs)} . ${list2str(tail(xs))})`
// example input data
const xs = cons(1, cons(2, cons(3, cons(4, nil))))
// example derived data
const ys = map (x => x * x) (xs)
console.log(list2str(xs))
// (1 . (2 . (3 . (4 . nil))))
console.log(list2str(ys))
// (1 . (4 . (9 . (16 . nil))))
当然,这在现实世界的JavaScript中并没有实际应用,但这不是重点。它只是向您展示如何单独使用函数来表示复杂的数据结构。
这是使用函数和数字实现有理数的另一个例子 - 同样,我们只使用字符串,因此我们可以将函数结构转换为我们在控制台中可以理解的直观表示 - 这个确切的场景在G_H提到的SICP书
我们甚至使用rat
实施高阶数据cons
。这显示了功能数据结构如何容易地由(由其他功能数据结构组成)
const cons = (x,y) => f => f(x,y)
const head = f => f((x,y) => x)
const tail = f => f((x,y) => y)
const mod = y => x =>
y > x ? x : mod (y) (x - y)
const gcd = (x,y) =>
y === 0 ? x : gcd(y, mod (y) (x))
const rat = (n,d) =>
(g => cons(n/g, d/g)) (gcd(n,d))
const numer = head
const denom = tail
const ratAdd = (x,y) =>
rat(numer(x) * denom(y) + numer(y) * denom(x),
denom(x) * denom(y))
const rat2str = r => `${numer(r)}/${denom(r)}`
// example complex data
let x = rat(1,2)
let y = rat(1,4)
console.log(rat2str(x)) // 1/2
console.log(rat2str(y)) // 1/4
console.log(rat2str(ratAdd(x,y))) // 3/4