[Elixir Macro #5] 变量不变
写这篇文章的想法,源自一次关于 and 和 && 区别的讨论。这次开门见山把结论放出来:Elixir 本质上变量是不可变的。
下面开始复现之前的讨论,慢慢解释。同样需要一些知识准备,一个文档两个项目:Reconstructing source code Macro Expansion Sequential Binding
iex(1)> a = 1 1 iex(2)> true and (a = 2) 2 iex(3)> a nil iex(4)> b = 1 1 iex(5)> true && (b = 2) 2 iex(6)> b 2
先不讨论在 and 或 && 里给变量赋值的合理性,我们把重点放在 v(3) 和 v(6) 的结果差异。
为了解决这个问题,最有效的办法当然是看下 elixir 在整个过程中,搞了什么鬼,于是我写了这个例子:
defmodule M do def func_a do a = 1 true and (a = 2) # IO.inspect(a) 这里如果这么写会报编译错误 a = 3 end def func_b do b = 1 true && (b = 2) IO.inspect(b) b = 3 end end
这货对应的中间代码,是长这个样子的(转换过程参考知识准备):
func_a() -> a@1 = 1, true andalso (a@2 = 2), a@3 = 3. func_b() -> b@1 = 1, case true of _@1 when (_@1 =:= nil) or (_@1 =:= false) -> b@2 = b@1, _@1; _ -> b@2 = 2 end, 'Elixir.IO':inspect(b@2), b@3 = 3.
逐行分析吧,注释里的区别及原因,稍后解释。
先是 func_a:
a = 1
被解释成了a@1 = 1
,一个最普通的「模式匹配」,看起来像是「赋值」。true and (a = 2)
被解释成了true and (a@2 = 2)
这里的 a@2 和 a@1 不是同一个变量。- 如果写成
IO.inspect(a)
来调用的话,编译是通不过的,相当于IO.inspect(a@3)
,这个时候 a@3 这个变量并不存在。 a = 3
被解释成了a@3 = 1
,所以这里如果是「赋值」,会生成一个新变量。
接下来是 func_b:
b = 1
被解释成b@1 = 1
,同上。- && 被解释成 case 语句,语句体稍后分析。
IO.inspect(b)
命令被正确解释, b 被解释成 b@2。b = 3
同上,不解释。
那么问题就来了,为什么 IO.inspect(b) 被解释成了 IO.inspect(b@2) 但 IO.inspect(a) 被解释成了 IO.inspect(a@3)?
在解释这么问题之前,不难发现解释器会做如下事情:任何对「变量」的「赋值」语句,都会给「变量」的后缀加 1 。
and 和 && 都是宏,但 and 被解释为 andalso 而 && 被解释为 case 语句。
我们先来细看下 case 语句,重点在 L10 b@2 = b@1
这一行。
这一行其实也是解释器做的事情,因为检测到了 case 语句的另一个分支对 「变量 b」 的值做了修改,产生了 b@2 变量,所以这里把当前分支的「变量 b」的后缀同步到 b@2,这样在 case 语句结束之后,不管执行的是哪个分支,b 都是 b@2,所以后面 IO.inspect(b) 的时候,自然会引用 b@2,后面的「赋值」才把 b 的后缀更新到 b@3。
而使用 andalso 为何会强制更新后缀呢?因为:andalso 是短路表达式!andalso 语句的后半部分,就是 a = 2 不一定会被执行。所以后文引用的 a 不管是 a@1,还是 a@2 都会有问题:
- 如果解释成 a@1, a = 2 执行了,后面对 a 进行「赋值」会对 a@2 重复赋值,这违背了 VM 级别的「变量不变原则」。
- 如果解释成 a@2,a = 2 未执行时,a 的后缀还停留在 a@1,使用 「变量 a」 的话找不到变量。
如上所述,解释器只能强制后缀加 1 ,使用 a@3 就不会与上面造成冲突。
写到这里,问题已经解释清楚了,前文「知识准备」里面后两个项目,最后还是简单介绍下:
- mex 是用来还原宏的,使用它可以很方便地还原宏的本质,比如把 && 还原为 case 语句。
- seqbind 是在 Erlang 里面实现「看上去可变的变量」的方式,同样是使用 @ 后缀,如果没有猜错 elixir 实现「看上去可变的变量」就是借鉴了这个库。