[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 都会有问题:

  1. 如果解释成 a@1, a = 2 执行了,后面对 a 进行「赋值」会对 a@2 重复赋值,这违背了 VM 级别的「变量不变原则」。
  2. 如果解释成 a@2,a = 2 未执行时,a 的后缀还停留在 a@1,使用 「变量 a」 的话找不到变量。

如上所述,解释器只能强制后缀加 1 ,使用 a@3 就不会与上面造成冲突。

写到这里,问题已经解释清楚了,前文「知识准备」里面后两个项目,最后还是简单介绍下:

  • mex 是用来还原宏的,使用它可以很方便地还原宏的本质,比如把 && 还原为 case 语句。
  • seqbind 是在 Erlang 里面实现「看上去可变的变量」的方式,同样是使用 @ 后缀,如果没有猜错 elixir 实现「看上去可变的变量」就是借鉴了这个库。