长生不老药的变量真的是不变的吗?

在戴夫•托马斯(Dave Thomas)的著作《编程长生不老药》(Programming Elixir)中,他指出“长生不老药强制执行不可变数据”,并接着说:

在 Elixir 中,一旦一个变量引用了一个列表,比如[1,2,3] ,你就知道它总是会引用那些相同的值(直到你重新绑定这个变量)。

这听起来像是“除非您改变它,否则它永远不会改变”,因此我对可变性和重新绑定之间的区别感到困惑。一个突出差异的例子会非常有帮助。

15589 次浏览

不可变性意味着数据结构不会改变。例如,函数 HashSet.new返回一个空集,并且只要您保留对该集的引用,它就永远不会变为非空集。在长生不老药中,可以所做的就是丢弃对某个东西的变量引用,并将其重新绑定到一个新的引用。例如:

s = HashSet.new
s = HashSet.put(s, :element)
s # => #HashSet<[:element]>

不能发生的情况是,引用下的值发生了变化,而您没有显式地重新绑定它:

s = HashSet.new
ImpossibleModule.impossible_function(s)
s # => #HashSet<[:element]> will never be returned, instead you always get #HashSet<[]>

与 Ruby 进行对比,您可以执行以下操作:

s = Set.new
s.add(:element)
s # => #<Set: {:element}>

不要把长生不老药中的“变量”想成命令式语言中的“值的空间”。而是把它们看作“价值标签”。

当你看到变量(“标签”)在 Erlang 是如何工作的时候,也许你会更好地理解它。当您将“标签”绑定到一个值时,它将永远绑定到该值(范围规则当然适用于此处)。

在 Erlang,你可以这样写:

v = 1,      % value "1" is now "labelled" "v"
% wherever you write "1", you can write "v" and vice versa
% the "label" and its value are interchangeable


v = v+1,    % you can not change the label (rebind it)
v = v*10,   % you can not change the label (rebind it)

相反,你必须这样写:

v1 = 1,       % value "1" is now labelled "v1"
v2 = v1+1,    % value "2" is now labelled "v2"
v3 = v2*10,   % value "20" is now labelled "v3"

正如你所看到的,这非常不方便,主要是对代码重构。如果你想在第一行之后插入一个新的行,你需要重新编号所有的 v * 或者写一些类似“ v1a = ...”的东西

因此,在长生不老药中,你可以重新绑定变量(改变“标签”的含义) ,主要是为了方便:

v = 1       # value "1" is now labelled "v"
v = v+1     # label "v" is changed: now "2" is labelled "v"
v = v*10    # value "20" is now labelled "v"

在命令式语言中,变量就像命名的手提箱: 你有一个名为“ v”的手提箱。一开始你放了三明治在里面。然后把一个苹果放进去(三明治丢了,可能被垃圾收集者吃掉了)。在 Erlang 和长生不老药中,放东西的变量不是 ABc0。它只是值的 名称/标签。在长生不老药中,你可以改变标签的含义。在 Erlang 你不能。现在也许你能清楚地看到两者的区别。

如果你想深入挖掘:

1)看看“未绑定”和“绑定”变量在 Prolog 是如何工作的。这就是 Erlang“不变的变量”这个有点奇怪的概念的来源。

2)注意,在 Erlang,“ =”实际上不是赋值运算符,它只是一个匹配运算符!当将未绑定变量与值匹配时,将该变量绑定到该值。匹配绑定变量就像匹配它所绑定的值一样。因此,这将产生一个 火柴错误:

v = 1,
v = 2,   % in fact this is matching: 1 = 2

3)长生不老药不是这样的,所以长生必须有一个特殊的语法来强制匹配:

v = 1
v = 2   # rebinding variable to 2
^v = 3  # matching: 2 = 3 -> error

Erlang 和显然是建立在它之上的长生不老药,拥抱不变性。 它们只是不允许更改某个内存位置中的值。只有当变量被垃圾回收或者超出作用域时,才可以执行。

变量不是不可变的。他们指向的数据是不可改变的。这就是为什么更改变量被称为重新绑定。

你把它指向别的东西,而不是改变它指向的东西。

x = 1后面跟着 x = 2不会改变存储在计算机内存中的数据,其中1为2。它将一个2放在一个新的位置,并将 x指向它。

一次只有一个进程可以访问 x,因此这对并发性没有影响,并发性是主要的考虑因素,即使有些东西是不可变的。

重新绑定根本不会改变对象的状态,值仍然在相同的内存位置,但是它的 label (变量)现在指向另一个内存位置,因此不可变性得以保留。重新绑定在 Erlang 是不可用的,但是由于它的实现,虽然它是在长生不老药中,但是它并没有制止 Erlang 虚拟机强加的任何约束。 Josè Valim 在这个要点很好地解释了这一选择背后的原因。

假设你有一份名单

l = [1, 2, 3]

你还有另外一个过程,那就是获取列表,然后反复对它们执行“ stuff”,在这个过程中更改它们将是不好的。你可以把名单发给

send(worker, {:dostuff, l})

现在,您的下一段代码可能希望用更多的值更新 l,以便进一步完成与另一个进程无关的工作。

l = l ++ [4, 5, 6]

哦,不,现在第一个过程将会有未定义行为,因为你改变了列表,对吗? 错误。

最初的名单保持不变。你真正做的是在旧的基础上做一个新的列表然后把我重新绑定到那个新的列表上。

独立的程序从来没有访问 l。我最初指向的数据没有改变,而另一个进程(假设它忽略了它)有它自己对原始列表的单独引用。

重要的是,您不能在进程之间共享数据,然后在另一个进程查看数据时对其进行更改。在 Java 这样的语言中,你有一些可变的类型(所有的基本类型加上引用本身) ,可以共享一个包含 int 的结构/对象,并在另一个线程读取它的时候从一个线程中改变 int。

事实上,当 Java 被另一个线程读取时,可以部分地改变一个较大的整数类型。或者至少曾经是这样,不确定他们是否用64位转换来限制这方面的东西。无论如何,重点是,您可以通过在两个同时查看的地方更改数据,从其他进程/线程下拉出地毯。

这在 Erlang 是不可能的,长生不老药在这里就是这个意思。

更具体地说,在 Erlang (VM Elixir 的原始语言) ,一切都是单一赋值的不可变变量,而且 Elixir 隐藏了一个 Erlang 程序员开发的模式来解决这个问题。

在 Erlang,如果 a = 3,那么在该变量的存在期间,a 的值就是 a 的值,直到它退出作用域并被垃圾收集。

这有时很有用(赋值或模式匹配之后没有什么变化,所以很容易推断出函数正在做什么) ,但是如果在执行函数的过程中对一个变量或集合执行多项操作,也会有点麻烦。

代码通常是这样的:

A=input,
A1=do_something(A),
A2=do_something_else(A1),
A3=more_of_the_same(A2)

这有点笨重,使得重构比实际需要的更加困难。Elixir 是在幕后做这件事,但是通过宏和编译器执行的代码转换对程序员隐藏它。

讨论得不错

长生不老药

这些变量在某种意义上确实是不可变的,每个新的重新绑定(赋值)只对之后的访问可见。所有以前的访问,在调用时仍然引用旧值。

foo = 1
call_1 = fn -> IO.puts(foo) end


foo = 2
call_2 = fn -> IO.puts(foo) end


foo = 3
foo = foo + 1
call_3 = fn -> IO.puts(foo) end


call_1.() #prints 1
call_2.() #prints 2
call_3.() #prints 4

让事情变得简单

长生不老药中的变量与容器不同,容器中的变量需要不断地从容器中添加、删除或修改项目。

相反,它们就像附加到容器上的标签一样,当你重新分配一个变量时,就像从一个容器中选择一个标签并将其放置在一个新的容器上,其中包含预期的数据一样简单。