xfyuan
xfyuan A Chinese software engineer living and working in Chengdu. I love creating the future in digital worlds, big and small.

《Programming Elixir >= 1.6》第三章:不可变性

《Programming Elixir >= 1.6》第三章:不可变性

《Programming Elixir >= 1.6》第三章介绍了 Elixir 的另一块基石,不可变性(Immutability)。我阅读本书之后的个人体会是,Elixir 一共有三大基石:模式匹配、不可变性和 OTP,以这三块基石为底,José Valim(Elixir 的创建者。他的名字是葡萄牙语,应读作:何塞·瓦里姆)构建起了整个语言的宏伟大厦。

Dave Thomas 同样认识到了这一点,所以紧接着前一章说完模式匹配,这一章就马上对不可变性进行描述,而不是开始堆砌常规语法的说明。因为前两者是 Elixir 基础编程的基石,而 OTP 是 Elixir 并发编程的基石。理解了模式匹配和不可变性,再看后续的基础语法时很多地方才能融会贯通,否则就会一头雾水:OO里不是这样的啊?

来看一下 Dave Thomas 对 Elixir 的不可变性是如何介绍的吧。

【下面是正文】

3. Immutability

如果你听说过函数式编程狂热爱好者,那你应该知道人们针对不可变性做了大量工作——事实上对于一门函数式语言,数据一旦创建就不可被更改。

确实,Elixir 强制实行了不可变数据。

为什么?

You Already Have (Some) Immutable Data

先忘掉 Elixir 一会儿。想一想你现在选用的编程语言,假设你写了这样的代码:

1
2
3
count = 99
do_something_with(count)
print(count)

你期望输出结果 99。当它没有这样输出时你会很吃惊。在你心中,99 就应该一直是 99。

现在,你当然能绑定一个新的值到你的变量,但这不能改变 99 就是 99 的事实。

想象一下,你到了一个无法值得依赖的语言世界中编程——在这里,你的一些代码可能在多处同时运行,且能改变 99 的值。函数do_something_with在后台被调用着,99 作为参数被传递进去。而传递过去的参数内容能被改变。突然间,99 变成了 100。

你彻底混乱了(理所当然)。更糟的是,你无法再保证自己的代码一定能得到正确的运行结果了。

还是你上面的语言,看下这个:

1
2
3
array = [ 1, 2, 3 ]
do_something_with(array)
print(array)

和前面一样,你还是期望print方法输出[1, 2, 3]。但很多语言中,do_something_with会把接收的 array 作为指针。当它改变第二个元素或者完全删除内容时,输出将不再符合你的期望。这样看你的代码以及它做了什么就更困难了。

再进一步——在多个线程,且每个都能访问该 array 的情况下,谁能知道全部线程都开始修改操作后这个 array 的状态会怎样?

导致所有这些问题的原因就在于很多语言中的复合数据结构都是可变的——你可以修改其全部或部分的内容。当你的代码会在多处同时去操作数据时,悲剧就产生了。

巧合的是,在我撰写本章的那天,Jessica Kerr (@jessitron) 恰好发推说:

GOTO was evil because we asked, “how did I get to this point of execution?” Mutability leaves us with, “how di I get to this state?”

说得对极了。

Immutable Data Is Known Data

Elixir 避开了这些问题。Elixir 中,所有的值都是不可变的。即使是特别复杂的嵌套列表(list)、数据库记录——都跟最简单的整数一样,全都不可变。

在 Elixir 中,一旦一个变量指向了一个列表(list),如[1, 2, 3],你就能确信它总是指同样的值,除非你重新绑定它到其他。这就让并发的处理不会再是困扰了。

那么如何把 100 加到[1, 2, 3]的每个元素上呢?Elixir 通过产生一个初始对象的拷贝并包含新的值来实现。初始对象保持不变,这样刚才的操作不会影响到任何使用该初始对象的代码。

这完美契合了编程就是关于数据转换的思想。当我们修改[1, 2, 3]时,并不直接修改,而是把它转换为新的数据。

Performance Implications of Immutability

很容易就会认定这种方式效率低下。毕竟,任何情况下你只要修改数据就必须创建一份它的拷贝,而且会留下太多旧数据作为垃圾回收。那么我们来依次看一下。

Copying Data

尽管一般都认为数据拷贝是效率低下的,但这里恰恰相反。因为 Elixir 知道现有数据不可变,所以在构建新结构时,就能部分或整体的重用它。

来看下面的代码。(它使用了一个新操作符,[head|tail],用来生成一个新列表。head作为其第一元素,tail作为余下的元素。我们后面会用一整章来讲列表和递归。这里先直接用好了。)

1
2
3
4
iex> list1 = [ 3, 2, 1 ]
[3, 2, 1]
iex> list2 = [ 4 | list1 ]
[4, 3, 2, 1]

很多语言中,list2 会通过创建一个包含 4,3,2,1 的新列表来得到。list1 中的三个元素会被复制到 list2 的尾部。因为 list1 可变,所以这是必须的。

但是 Elixir 知道 list1 永不改变,所以它只需要简单地构建一个头部为 4 和尾部为 list1 的新列表就行了。

Garbage Collection

另一个关于数据转换式语言性能的诟病是,当你从旧数据创建新数据时(拷贝方式),经常会遗留下不再使用的旧数据。这会留下太多东西在内存堆栈中,而不得不让垃圾回收器去处理它们。

很多现代语言都有垃圾回收器,而开发者们越来越怀疑其作用——它们会在背后悄悄地影响性能。

然而 Elixir 厉害的地方在于,你可以在代码中用很多很多的进程,而每个进程都有自己的内存堆栈。程序的数据在这些进程中是分开的,所以每个堆栈都非常非常小,远比把全部数据放在一个大的内存堆栈中要小。结果就是,垃圾回收器运行更快。如果一个进程在它的堆栈满了之前就终止,其所有数据被丢弃——那甚至根本都不需要垃圾回收器了。

Coding with Immutable Data

一旦你接受了这个概念,那么以不可变数据来编程会简单的出奇。你只需要记住任何转换数据的函数都会返回一个该数据的新拷贝。因此,我们永远不会把一个字符串转换为大写字母,而是返回一个该字符串被大写字母化后的拷贝。

1
2
3
4
5
6
iex> name = "elixir"
"elixir"
iex> cap_name = String.capitalize name
"Elixir"
iex> name
"elixir"

如果你之前是用面向对象语言,也许你会不喜欢我们使用String.capitalize name而不是name.capitalize()的写法。但在面向对象语言中,对象通常都拥有可变的状态。当你进行诸如name.capitalize()之类的调用时,无法立即清楚这是要更改名称的内部表示,或返回大写字母副本,还是两者都有。有太多产生歧义的空间了。

函数式语言总是转换数据,从不会去修改它。其语法都会时时刻刻提醒我们。

理论知识讲得够多了。该是开始学习它的时候了。下一章我们将快速概览基本的数据类型和一些语法,也会看到函数(function)和模块(module)。

comments powered by Disqus