问题的描述
使用 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
.
函数调用可以省略括号, 为什么要这样做呢? 我认为有以下两个原因.
- 零元无副作用的函数来实际上和常量一样. Elixir 和 Erlang 中,
也正是如此来使用零元无副作用的函数的. 比如:math.pi
. 如果必须加上小括号,
像:math.pi()
的话, 就太不像常量了. - Elixir 中使用宏来做元编程, 调用宏的时候不写括号, 代码的可读性更强.
不带括号, 使得宏调用, 看起来像是其他语言中的关键字. 对比一下:
不带括号的宏调用看起来更像关键字 | 加上括号的宏调用更像函数调用 |
---|---|
import Module, only: [fun1: 1] |
import(Module, only: [fun1: 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
怎么区分呢?
显然是有两种解释的:
- 两个
abs
都是变量, 那么结果自然是 0; - 第一个
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
作为整体解释, 会出现问题.
-
abs-abs
怎么解释呢?- 优先把
-abs
作为一组来解析, 得到的是一个数值.
那么把第一个abs
解释为函数, 合乎语法; - 把两个
abs
都解释为变量,语法也是正确的. - 可是两个语义完全不同.
实际上编译器在这种情况下, 把两个
abs
都解释为变量. - 优先把
-
abs -abs -abs
呢?如果两个
-abs
都解释为对变量取反, 而把第一个abs
解释为函数,
那么整个表达式等价于abs(2 2)
还是abs(2) 2
?
但是无论abs(2 2)
还是abs(2) 2
都不是 Elixir 的正确语法. -
要想
abs -abs -abs
有意义, 只能把两个-abs
做不同的解释才行.- 把
abs -abs -abs
解释为(abs) - abs(-abs)
, 是可行的,
但是对编译器的实现来说来说, 完成这样的结束, 需要的实现太复杂了. - 把
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
中,
token
和 token1
都是标识符;
但是实际上, 只要 token
是标识符,token1
无论是数值字面量还是标识符,
这个表达式都会引发这里讨论的问题.
我们可以用以下表格来总结表达式的各种情形:
表达式 | 语法正误 | 解释 |
---|---|---|
x-y |
正确 | 当 - 两边的符号与 - 之间都没有空白时, 解释器把 - 解释为减法 |
1 -1 |
正确 | 数字是不能作为函数名的, 所以 - 只能解释为减法. |
1 -a |
正确 | 无论 a 是表达式, 还是变量, 都必须把 - 解释为在减号, 整个表达式才正确 |
a -1 |
错误 | 表达式存在歧义, 既可以用作解释为函数调用 a(-1) ; 又可以解释为变量 a 减去 1. |
a -b |
错误 | 同上. |
表中 x
, y
表示可以是数字也可以是变量标识符; a
和 b
表示变量标识符;
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
,
这些表达式中的语法歧义就消除了.
-
引发错误是不得已; +
引发的错误, 是赘疣恶化的结果. 你同意吗?
-
见 Elixir 核心模块源码 1347~1349 行. ↩