角落里的长生不老药 加和减

问题的描述

使用 Elixir 编程的时候, 第一个让我困惑的地方就是加减运算符了.
-+ 运算符号如此简单, 大部分情况下, 我们都不到会意识有什么问题存在.
但是在 Elixir 中, 我不止一次在这个地方被编译器无情的打脸.
而且刚开始, 挨打了还莫名其妙.

(1 - 1) |> IO.inspect(label: "1 -1")
a = 1
(1 -a) |> IO.inspect(label: "1 -a")
a -1

在上面的代码示例, 是我学习 Elixir 中, 按照书籍示例的代码, 在 IEx 中输入的.
当我按照书中的代码, 输入 a - 1 时, 不小心写成了 a -1, 就像上面的片段中最后一行那样.
IEx 居然抛出了错误. 这出乎我的意料. 我完全想象不出, 为什么这里会有错误.

首先, 我知道变量 a 的值为 1, 难道 Elixir 中 1 - 1 不等于 0, 而是抛出错误?
我快速的在 IEx 中输入 1 - 1, 当然没有问题了.

那会不会什么时候, 变量 a 的值类型改变了呢?
a 的值不是数值 1, 而是字符串 "1" ?
于是我又加入调试语句, IO.inspect(a,label: "a").
a 的值的确是数值 1, 那么我就更困惑了, 那怎么会出错呢?
困惑中, 我再试了一下, 这次输成了 1 - a, 居然得到了正确答案 0.
仔细比较后发现, 出错的代码, 变量在减号的后面, 那么改回去,
a - 1, 怎么又正确了? 灵异事件?

当时, 我刚开始学习 Elixir, 还有很多的内容等待我去学习,
既然正确了, 我就没有仔细追究背后的原因.

等我开始用 Elixir 写更长代码后, 当这个问题再次出现的时候,
错误提示更加莫名奇妙了. 但是总是和 -+ 相关,
而且最后问题的解决往往也就只是在 -+ 之前加一个空格.

但是为什么呢? 为什么会出现这样的问题呢? 因为不知道错误的原因,
所以即使了解了该如何解决, 还是让我非常的疑惑.
直到有一天我认真的阅读了编译器的错误提示.

"a -1" looks like a function call but there is a variable named "a".
If you want to perform a function call, use parentheses:
a(-1)
If you want to perform an operation on the variable a, use
spaces around the unary operator

如果不是因为理解了 "a - 1" 看起来像函数调用, 但是存在一个变量命名为 "a",
那么这段提示就算我很早就注意到, 也依然是不知所云的.

为什么?

但是为什么会是这个样子呢? 其他编程语言, 写 a -1 不都没问题的吗?

这是几个知识点组合起来引发的迷惑.

1. Elixir 中函数调用时可以省略小括号

这就是为什么, 在 IEx 中, 我们可以像使用 shell 命令那样使用 cd, r h
等等函数的原因.

函数定义在模块中, 当被导入到其他环境后, 可以不带模块名, 而直接使用函数名来调用.
对于整数除法来说, 可以使用 div(1, 2) 来代替 Kernel.div(1,2),
进一步地, 函数调用的小括号也是可以省略的, 省略小括号后, 就成了 div 1, 2.

函数调用可以省略括号, 为什么要这样做呢? 我认为有以下两个原因.

  1. 零元无副作用的函数来实际上和常量一样. Elixir 和 Erlang 中,
    也正是如此来使用零元无副作用的函数的. 比如 :math.pi. 如果必须加上小括号,
    :math.pi() 的话, 就太不像常量了.
  2. Elixir 中使用宏来做元编程, 调用宏的时候不写括号, 代码的可读性更强.
    不带括号, 使得宏调用, 看起来像是其他语言中的关键字. 对比一下:
不带括号的宏调用看起来更像关键字 加上括号的宏调用更像函数调用
import Module, only: [fun1: 1] import(Module, only: [fun1: 1])

2. 操作符 - 身兼二职

  1. -/2 是减法操作符.
  2. -/1 是取反操作符.

3. 变量和函数名使用的标识符规则一样

让我们再来看一下, a -1 引发的编译错误:

(CompileError) : "a -1" looks like a function call but there is a variable named "a".
If you want to perform a function call, use parentheses:
`a(+1)`
If you want to perform an operation on the variable a,
use spaces around the unary operator

看起来是因为编译器优先把标识符 a 解释为函数, 而实际上它是一个变量, 所以才引发错误的.
可是为什么编译器不首先把标识符解释为变量呢? 这样, 就可以把 a -1 解释为对 a 减去 1,
而不是以 -1 为参数来调用函数 a.

答案是编译器实际上就是优先把标识符解释为变量的, 但是有些时候, 编译器不能区分变量和函数.

首先, 让我来证明, 编译器的确是优先把小写字母开头的标识符解释为变量的.

标识符优先被解释为变量

首先, 我们导入一个零元函数 :math.pi/0, 然后再定义一个同名的变量 pi,
让我们看看, 使用标识符 pi 的时候, 得到的 :math.pi 的返回值,
还是我们自定义的变量的值.

import :math, only: [pi: 0]
pi |> IO.inspect(label: "before define variable pi")
pi = "This is variabel pi!"
pi |> IO.inspect(label: "after  define variable pi")

在定义变量 pi 之前, 我们使用标识符 pi, 用到的是当然是导入的零元函数.
但当我们定义变量 pi 之后, 再使用标识符 pi, 得到的是变量 pi 的值.
那么有没有可能是变量 pi 遮盖 (shadow) 了函数 :math.pi/0? 或者说,
如果先定义变量, 后导入零元函数, 编译器会不会把标识符 pi 解释为对函数
:math.pi/0 的调用呢?

当前的 IEx 环境中, 已经导入了 pi/0 函数, 如果读者跟随我的思路,
在你的 IEx 中实验的话, 那么你的环境中, 也已经导入了 pi/0. 为了清晰起见,
让我们定义一个新的变量 e 和零元函数 e/0, 来实验我们最新的假设.

e = "var e"

defmodule TempModule do
  def e, do: :math.exp(1)
end
import TemModule
e |> IO.inspect()

输出为: "var e".

因此, 我们可以得出结论, 同名的零元函数和变量, 出现在同一个上下文的时候,
无论变量和函数出现的顺序如何, 编译器都优先把标识符解释为变量.

函数其实没有被覆盖

前面的章节, 我们说, 零元函数被同名的变量覆盖的了. 实际上不是的.
零元函数, 依旧是可访问的, 只是当有同名的变量存在的时候,
对零元函数的调用就不能省略小括号了.
例如下面的代码:

pi |> IO.inspect(label: "pi")
pi() |> IO.inspect(label: "pi() still is")

其输出为:

pi: "This is variabel pi!"
pi() still is: 3.141592653589793

对于有元函数来说, 函数没有不覆盖, 就更加清楚了.
我们以 Kernel.abs/1 为例, 核心模块 Kernel 中的函数都是默认导入的,
当我们定义了 abs 的变量后, 上下文中, 就同时出现了同名的变量和函数 --- --- abs.
但是像下面的代码所展示的那样:
当我们需要一个变量的时候, 获得就是一个变量;
当我们需要一个函数的时候, 获得就是一个函数.

abs = -2

abs
|> abs
|> IO.inspect(label: "abs |> abs")

IO.inspect(abs / 3, label: "abs/3")
IO.inspect(1 / abs, label: "1/abs")
IO.inspect(&abs/1, label: "&abs/1")
IO.inspect(abs -9, label: "abs -9")

上面的片段中, 第1, 3, 7 和 8 行中的 abs 都是变量, 而 4, 9 和 10 行的
abs 都是函数名.

看起来编译器是可以区分变量和函数的. 但是, 不总是这样的.

有时候, 代码是有歧义的, 这时编译器不能确定标识符到底是函数还是变量.

歧义代码

如果遇到这样的代码: abs - abs, 那么这两个 abs 怎么区分呢?
显然是有两种解释的:

  1. 两个 abs 都是变量, 那么结果自然是 0;
  2. 第一个 abs 是函数, 第二个 abs 是变量,
    那么返回的结果应该是变量 abs 的绝对值, 也就是 2.

答案是 0. 也就是说操作符 - 把两边操作数都当作是变量.

但是这样的代码 abs -abs 呢? 也依然有两种解释,
实际上这一次因为 - 和第二个 abs 之间没有空格,
更应该优先把 - 解释为对变量 abs 的取反.
如果这样解释的话, 那么第一个 abs 解释成什么呢?
似乎只能解释为函数, 那么结果应该是 2.

但是实际上, 编译器抛出了一个错误.

(CompileError) :"abs -abs" looks like a function call but there is a variable named "abs".
If you want to perform a function call, use parentheses:
`abs(-abs)`
If you want to perform an operation on the variable abs,
use spaces around the unary operator

为什么, 编译器不优先把 -abs 作为一个整体, 来解释为对 abs 的取反操作呢?
因为在别的语法环境中, -abs 作为整体解释, 会出现问题.

  1. abs-abs 怎么解释呢?

    1. 优先把 -abs 作为一组来解析, 得到的是一个数值.
      那么把第一个 abs 解释为函数, 合乎语法;
    2. 把两个 abs 都解释为变量,语法也是正确的.
    3. 可是两个语义完全不同.

    实际上编译器在这种情况下, 把两个 abs 都解释为变量.

  2. abs -abs -abs 呢?

    如果两个 -abs 都解释为对变量取反, 而把第一个 abs 解释为函数,
    那么整个表达式等价于 abs(2 2) 还是 abs(2) 2?
    但是无论abs(2 2) 还是 abs(2) 2 都不是 Elixir 的正确语法.

  3. 要想 abs -abs -abs 有意义, 只能把两个 -abs 做不同的解释才行.

    1. abs -abs -abs 解释为 (abs) - abs(-abs), 是可行的,
      但是对编译器的实现来说来说, 完成这样的结束, 需要的实现太复杂了.
    2. abs -abs -abs 解释为 (abs - abs) - abs 就彻底放弃了对 -abs 优先解释.

综合起来看, 只好放弃对 -abs 的优先解释, 而把 -abs 解析为两个符号(token): -abs.

为什么报错

上面的分析, 我们看到, 只能把 -abs 解释为 -abs 两个符号.
但是这样处理后, 为什么当编译器遇到 abs -abs 的时候会报错呢?
这是因为, 有时候, 我们写 -abs 的时候, 我们的真实的意图就是要对 abs 取反.
如果编译器一律把 x -abs 解释为 x 减去 abs,
那么代码有可能实际上违背了我们的真实意图.

抛出错误, 强迫程序员明确 - 的意义, 可以使得 Elixir 代码更强壮,
这符合 Erlang 的 "Let it crash" 哲学. 在编译时奔溃好过在运行时奔溃.

最后一点, 我们的分析中, 表达式 token -token1 中,
tokentoken1 都是标识符;
但是实际上, 只要 token 是标识符,token1 无论是数值字面量还是标识符,
这个表达式都会引发这里讨论的问题.

我们可以用以下表格来总结表达式的各种情形:

表达式 语法正误 解释
x-y 正确 - 两边的符号与 - 之间都没有空白时, 解释器把 - 解释为减法
1 -1 正确 数字是不能作为函数名的, 所以 - 只能解释为减法.
1 -a 正确 无论 a 是表达式, 还是变量, 都必须把 - 解释为在减号, 整个表达式才正确
a -1 错误 表达式存在歧义, 既可以用作解释为函数调用 a(-1); 又可以解释为变量 a 减去 1.
a -b 错误 同上.

表中 x, y 表示可以是数字也可以是变量标识符; ab 表示变量标识符;
1 代表任意的数值字面量.

这章的标题是 ≪加和减≫, 但是, 到目前为止, 我们的分析只涉及到了 -.
我不是跑题了, 而是因为 + 存在的问题, 和 - 本质是一样的.

-/1 在编程中承担了有意义的工作; +/1 不一样.
+/1 在 Elixir 中真正的意义, 在编程实践中, 几乎用不到.

你能想象到 +/1 的实际意义吗?

首先, 如果 b 是一个数字, +b 并不返回 b 的绝对值.
从 C 到 Javascirpt, ..., 到 Erlang 再到 Elixir, 都是如此.
这还真是一个有趣的现象, 这样一个赘疣一样的语法弥因 (Meme),
怎么就在编程语言设计的领域中扩散开的?
Elixir 继承自 Erlang, +/1 在 Elixir 中就是调用的 Erlang +/1[1].
Erlang 又从哪里继承的, 我就不得而知了.

但是这个赘疣源远流长. 在最早的高级编程语言 Fortran 和 Lisp 中就有它的身影.
在 Lisp 中, + 是一个不定参数的函数, 在定义 + 的时候,
以 0 为初始值, 对所有参数作累加操作, 根本就不需要对单参数的情形作特殊处理.
在 Fortran 中, 为什么也有它的身影, 我就不得而知. 也许是出于与 -/1 的对称考虑?

对于非操作符前缀的语言, 我认为 +/1 就是一个赘疣, 乍看无关痛痒, 后期的发展,
有可能危害生命. 我建议编程语言的设计者: 在自己设计的编程语言中, 消灭 +/1;
如果非要为 +/1 保留一席之地, 那么就给它分配一个有意义的工作; 比如拿来作取绝对值的操作符.

在 Javascirpt 中 +/1 找到了自己的一席之地: 可以用来完成字符串到数值的转化.

a="123"
console.log(+a + 1) //124

Elixir 是强类型语言, +/1 不提供这个功能.
那么 Elixir 中 +/1 的用途是什么呢?
我能想到的唯一用途就是: 检查一个值是否为数值类型,
如果是, 返回其值; 如果不是, 则抛出错误. 也就是说, +/1 可以这样定义:

def +(m) do
  if is_number(m) do
    m
  else
    message = "bad argument in arithmetic expression: +(#{inspect m})"
    raise ArithmeticError, message
  end
end

但是在 Elixir 和 Erlang 中, 抛出错误不是一个常规的编程操作, 所以, 我从来没见过有人用过这个语法.

如果 Elixir 中取消了 +/1 的定义, 只把 + 解释为加法, 那么 a +1, a +b,
这些表达式中的语法歧义就消除了.

- 引发错误是不得已; + 引发的错误, 是赘疣恶化的结果. 你同意吗?


  1. 见 Elixir 核心模块源码 1347~1349 行. ↩

你可能感兴趣的:(角落里的长生不老药 加和减)