Elixir变量真的是不可变的吗?

82
在Dave Thomas的书《Programming Elixir》中,他指出“Elixir强制执行不可变数据”,并接着说:

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

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

1
这是影子赋值,而不是重新赋值。 - user1804599
阴影是在同一函数体中重新分配。 - Alexander Mills
5个回答

105
不要将 Elixir 中的“变量”视为命令式语言中的变量,“值的空间”。相反,把它们视为“值的标签”。也许您在看看 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 = ..."的东西。

因此,在Elixir中,您可以重新绑定变量(更改“标签”的含义),主要是为了方便起见:

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 和 Elixir 中,变量不是 放置 东西的地方。它只是一个值的 名称/标签 。在 Elixir 中,您可以更改标签的含义。在 Erlang 中,您不能。这就是为什么在Erlang或Elixir中,“为变量分配内存”没有意义的原因,因为变量不占用空间。值才占用空间。现在您可能已经清楚地看到了差异。

如果你想更深入的了解:

1)看看Prolog中“未绑定”和“绑定”变量是如何工作的。这是这个可能有些奇怪的Erlang概念“不变的变量”的源头。

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

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

3) 在 Elixir 中不是这样的。因此,在 Elixir 中必须有一种特殊的语法来强制匹配:

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

1
@DavidC:这是两个问题:1. 可能吗/想象得到吗?2. 是个好主意吗? 我认为第一个问题的答案是“是”,而第二个问题的答案是“不是”。在我看来,有很好的函数机制(map、reduce等)更适合。但这个问题比较广泛,可能不能用几句话回答 :) - Miroslav Prymek
@MiroslavPrymek,在您的答案的第3点中,是否有Elixir中强制进行严格匹配的情况? - simo
@simo 我不知道有这样的。手册中也没有提到过:http://elixir-lang.org/getting-started/pattern-matching.html - Miroslav Prymek
4
x = x +1 让我的数学大脑感到疼痛。Erlang可以止痛,但 Elixir 不行。 :( - Kevin Monk

64

不变性意味着数据结构不会改变。例如,函数HashSet.new返回一个空集合,只要保持对该集合的引用,它就永远不会变为非空。在Elixir中,你可以放弃对某个变量的引用并将其重新绑定到一个新的引用。例如:

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}>

1
那么重新绑定是像本地的可变性吗?在块内,您可以重新绑定一个变量,但一旦超出作用域,该变量就会返回到其原始值 - 是这样的吗? - Odhran Roche
1
本地变量 - 是的。但是当变量超出范围时,它就会停止存在。该变量指向的数据不一定会消失(例如可能从函数返回),并且该数据是不可变的。 - Paweł Obrok
3
如果你想要不可变的变量,你需要使用Erlang或在Elixir变量前加上“^”前缀。重新绑定只是Elixir中一个花哨术语,用来隐藏变量实际上是可变的事实。请记住,我喜欢Elixir,但我真的不喜欢社区试图用花哨的术语和解释来隐藏变量的可变性。 - Exadra37
1
@Exadra37并非如此。您可以将表达式中的pin运算符^视为仅用其值替换变量。此运算符在模式匹配等方面发挥了大部分作用。至于不可变性,对于所有目的而言,Elixir确实是不可变的。如果有意更改变量的值,则指向该变量的位置存储的值本身不能被突变为另一个值。相反,变量会重新绑定到可以存储新值的新位置。因此术语“重新绑定”。 - Sri Kadimisetty
4
@sri 我不关心实现细节,即核心如何工作,我关心接口,即如何使用它。因此,如果我可以两次使用同一变量并得到不同的值,则对我来说它不是不可变的,而是可变的。现在你可以提出所有想要的技术解释,我知道很多,但这永远不会改变这样一个事实:Elixir 变量使用的 API 允许可变变量,但 Erlang 变量使用的 API 只允许不可变变量。 - Exadra37
3
在Elixir编程语言中,变量是不可变的。重新绑定一个变量实际上是在重复使用变量名,这将引用不同的内存位置,即使在重新绑定之前运行的任何代码也仍将引用旧值,这与其它编程语言不同。“可变性”指的是内存中的值,而不是所引用的值。你可以将Elixir的重新绑定视为编译时的技巧,在重新绑定后,将重命名一个变量,以便虚拟机不会尝试将值分配给已分配的变量(这是Elixir编译器的实际行为)。 - tcnj

46
Erlang和显然建立在其之上的Elixir都支持不可变性。它们简单地不允许某个内存位置中的值发生更改,直到变量被垃圾回收或超出范围。变量并不是不可变的,指向的数据才是不可变的。这就是为什么将更改变量称为重新绑定的原因。你只是将它指向其他东西,而不是改变它所指向的东西。例如,x=1后跟x=2并不会将计算机内存中存储1的数据更改为2。它将2放置在新位置并将x指向它。由于x一次只能由一个进程访问,因此这对并发没有任何影响,而并发恰好是需要关注不可变性的主要领域。
重新绑定不会改变对象的状态,值仍然在同一内存位置,但它的标签(变量)现在指向另一个内存位置,因此不可变性得到保留。Erlang 中不提供重新绑定,但在 Elixir 中提供了这个功能,并且由于其实现方式,这不会违反 Erlang VM 所施加的任何约束。Josè Valim 在 这篇文档 中很好地解释了这个选择背后的原因。
假设你有一个列表。
l = [1, 2, 3]

如果您有另一个进程,需要反复对列表执行"操作"并在此过程中更改它们,则更改它们可能会产生问题。您可以像这样发送该列表:

send(worker, {:dostuff, l})

现在,您的下一段代码可能需要更新l以获取更多值,以进行与其他进程无关的进一步工作。
l = l ++ [4, 5, 6]

哦,不好了,因为你改变了列表,第一个进程现在会出现未定义的行为,对吗?错。

原始列表保持不变,你真正做的是基于旧列表创建一个新列表,并将 l 重新绑定到该新列表。

单独的进程从未访问过 l。l 最初指向的数据保持不变,另一进程(可能是这样,除非它忽略了它)具有其自己独立于原始列表的引用。

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

实际上,在 Java 中部分更改大整数类型,同时被另一个线程读取是可能的。或者至少,过去是这样,不确定他们在 64 位转换时是否限制了该方面的事情。总之,问题是,通过在两个同时查看的地方更改数据,你可以使其他进程/线程失去基础。

在 Erlang 和 Elixir 中是不可能的,这就是这里不可变的含义。

更具体地说,在Erlang中(Elixir运行的原始语言),一切都是单赋值不可变变量,而Elixir隐藏了Erlang程序员开发的一种模式以解决此问题。
在Erlang中,如果a=3,则a将在变量存在期间保持其值,直到它超出范围并被垃圾回收。
这有时很有用(赋值或模式匹配后没有更改,因此容易推断函数正在执行的操作),但如果您要在执行函数的过程中对变量或集合进行多个操作,则有点繁琐。
代码通常看起来像这样:
A=input, 
A1=do_something(A), 
A2=do_something_else(A1), 
A3=more_of_the_same(A2)

这有点笨拙,使得重构比必要的要困难。Elixir在幕后执行此操作,但通过编译器执行宏和代码转换来向程序员隐藏它。

这里有一个很棒的讨论

Elixir中的不可变性


7
非常清晰的答案。它很好地解释了编译器技术是不可变性的基础以及其原因。这个答案和Prymek的答案一起,最终让我对这个问题有了很好的理解。它们两个都应该成为官方Elixir文档的一部分。 - Guido
@subhash,你是说当你在Elixir中执行x = 1; f = fn -> x end; x = 2; #=> 2时,f lambda甚至不通过x访问x变量,而是通过一个次要引用来访问吗?这就是它如何能够仍然访问1,即使x被设置为2的原因吗?我知道这部分来自Elixir的立即编译,但是除此之外,是否有第二个引用用于在重新绑定发生后访问数据? - Tallboy
2
这个答案和其他答案相比,做出了最大的努力来解释这个主题。 - Srle
1
这对我来说是个好的解释!在Erlang中,变量是可变的东西,关键是要理解Erlang强制每个变量只能被创建它的线程使用,因此在那个可变的东西中不会发生并发。 - Pauls
1
很棒的文章,讲得非常清晰,特别是:Elixir 在幕后完成了这个操作,但通过编译器执行的宏和代码转换来隐藏它,使程序员无需关心。 - Ace.Yin

5

变量在本质上是不可变的,每次重新绑定(赋值)只对其后访问可见。所有之前的访问仍然指向它们调用时的旧值。

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

1
为了让它变得非常简单,
Elixir中的变量不像容器,您可以从容器中添加、删除或修改项目。
相反,它们就像附加到容器的标签,当您重新分配变量时,就像您从一个容器中选择标签并将其放置在带有所需数据的新容器中一样简单。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接