本章的目的
在这一章我们讨论的是 语法上可以如何做, 而 不是实际编码上应该怎么做.
可以做是一个硬规则, 是面向编译器的; 应该怎么做是一个规范, 是面向程序员的.
所以很多地方, 在讨论完可以如何做后, 我又用黑体给出了社区推荐的做法,
也就是应该如何作.
本章讨论可以做的方法绝大部分, 不符合社区的推荐做法. 一边展示不推荐的做法,
一边规劝大家不要这样做, 这看起来似乎是在左右互博,自相矛盾.
所以这样做, 是为了搞清楚所以然 --- --- 社区给出这些规范的所以然, 也就是规范背后的原因.
当真正的理解了什么是可以做的, 以及什么必须做的时候, 才能理解规范所以如此的原因.
使用括号改变优先级
使用括号改变优先级, 这是最普通的括号的用法. 这和我们在数学课堂上使用括号的情形一样.
例如数学公式 , 对应的程序表达式就是 a*(b+c)
.
如果要消除括号, 那么需要调整代码, 并需要引入中间变量. 例如上面的等价的表达式,
可以写作 tem=b+c
和 a*tem
两个表达式.
但是这里有一个前提: 最初的表达式, 在一个块环境中. 因为多个表达式, 必须在块环境中.
括号与匿名函数定义
使用 fn ... -> end
定义匿名函数
当我们使用 fn ... -> end
来定义匿名函数的时候, 括号是可选的.
pi = fn -> :math.pi() end
e= fn ()-> :math.exp(1) end
pi.() |> IO.inspect(label: "pi.()")
e.() |> IO.inspect(label: " e.()")
id = fn v -> v end
add_1 = fn ( v ) -> v + 1 end
id.(1) |> IO.inspect(label: " id.(1)")
add_1.(1) |> IO.inspect(label: "add_1.(1)")
add = fn a, b -> a + b end
sub = fn (a,b) -> a-b end
add.(1,2) |> IO.inspect(label: "add.(1,2)")
sub.(1,2) |> IO.inspect(label: "sub.(1,2)")
上面的代码中, 对于零元, 一元和二元匿名函数, 用带括号和不带括号两种样式分别做了定义.
可以看出, 在定义匿名函数的时候, 括号完全是可选的.
社区的推荐: 使用 fn...->end
定义匿名函数的时候, 不用括号来包裹参数列表.
那么为什么 fn...->end
定义匿名函数的时候可以不用括号呢?
从形式来看, ->
是参数列表结束而函数分句开始的标识.
所以不需要一个额外的括号来界定参数什么时候开始什么时候结束.
使用函数捕获操作符 & 定义匿名函数
使用函数捕获操作符 &
定义定义函数的时候, ()
的作用只限于调整代码的优先级.
而 Elixir 中函数捕获操作符 &
的优先级只高于 =>
, |
, ::
<-
和 \\
这几个操作符, 而这几个操作符, 又都是在特殊的环境下才有意义的, 所以几乎可以说,
函数捕获操作符 &
是最低的优先级的操作符.
因此, 对定义匿名函数的表达式 &(expression)
来说,
因为函数捕获操作符 &
的优先级最低, 而且在 expression
中不允许嵌套函数捕获表达式,
因此 expression
中的其他的操作符, 优先级都比函数捕获操作符 &
的高.
所以, 在表达式 &(expression)
中有没有括号, 表达式的计算顺序都一样.
换句话说, 这里的括号就是多余的, 可以省略.
当需要把定义的函数赋值给一个变量的时候, 匹配操作符 =
的优先级高于函数捕获操作符 &
,
但是匹配操作符 =
是右结合的, 会优先完成左值的计算.
所以使用函数捕获操作符, 在匹配操作符 =
右侧定义匿名函数的时候,
像表达式 a=(& expression)
这样, 其中的括号也可以省略.
下面的代码片段, 展示了这种这种情形.
id2 = & &1
add_1 = & &1 + 1
sum = & &1 + &2
sub = & &1 - &2
id2.(1) |> IO.inspect(label: " id2.(1)")
add_1.(1) |> IO.inspect(label: "add_1.(1)")
sum.(1, 2) |> IO.inspect(label: "sum.(1,2)")
sub.(1, 2) |> IO.inspect(label: "sub.(1,2)")
所以和 fn -> end
语法定义匿名函数情形几乎一样, 使用函数捕获操作符 &
定义匿名函数的也不需要小括号, 但是这里有一点点的不同.
fn
和end
是作为保留字存在的, 优先级高于其他的操作符.-
匿名函数可以作为参数传递给其他高阶函数
当把匿名函数通过管道操作符
|>
, 传递给其他高阶函数的时候,
使用函数捕获操作符&
定义的匿名函数就必须使用括号,
因为管道操作符符|>
的优先级高于函数捕获操作符&
.
例如:
(& &1 + &2 + &3) |> apply([1, 2, 3])
因为管道操作符 |>
优先级高于函数捕获操作符 &
,
所以要表达, 把匿名函数作为管道操作符 |>
的左操作数, 这样的意图,
就必须为函数捕获操作符 &
定义匿名函数的表达式加上括号.
现在我们分析一下, 表达式 & &1 + &2 + &3 |> apply([1,2,3])
的语义.
fun = & &1 + &2 + &3 |> apply([1,2,3])
true = is_function(fun,3)
所以, 这个表达式 & &1+&2+&3 |> apply([1,2,3])
计算后的结果为一个匿名的三元函数.
如果我们以参数 1,2,3
来调用这个匿名函数, 得到一个错误:
** (BadFunctionError) expected a function, got: 6.
为什么会这样呢?
- 函数捕获操作符
&
操作优先级最低, 所以整个表达式等价于
&(&1 + &2 + &3 |> apply([1,2,3]))
. - 加法运算符
+
优先级高于管道运算符|>
, 所以
&1 + &2 + &3 |> apply([1,2,3])
等价于(&1 + &2 + &3) |> apply([1,2,3])
. - 因此整个表达式的意思就是: 这是一个三元匿名函数(
&1,&2,&3
),
参数求和后 (&1+ &2 + &3
), 把结果作为第一个参数传递给apply/2
.
虽然我们获得了一个三元的匿名函数, 但是这个匿名函数无论我们输入的参数是什么,
都是要报错的.
三个参数按加法计算, 所以接受的只能是数值类型, 计算后的结果也只能是 数值,
但是 apply/2
要求的第一个参数是一个 函数,
所以无论我们以什么参数来调用这个匿名函数, 其结果都是抛出报错.
结论: 在用函数捕获操作符 &
定义匿名函数时, 不需要括号的参与,
但是要直接把函数捕获操作符 &
定义的函数, 通过管道操作符 |>
传递给其他函数的时候,
因为涉及优先级的问题, 所以必须使用括号, 把函数捕获操作符的优先级提高.
但是在使用函数捕获操作符 &
的时候, 基于代码的可读性, 社区的推荐使用括号.
社区推荐做法: 使用 &
定义匿名函数的时候, 使用括号把表达式括起来,
即使很多时候语法上是不必要的.
也就是说, 按照社区的规范上面的代码中对匿名函数 id2
, add_2
等函数的定义, 应该写作:
id2 = & &1
add_1 = &(&1 + 1)
sum = &(&1 + &2)
sub = &(&1 - &2)
括号与命名函数定义
def/2
, defp/2
, defmacro/2
和 defmacrop/2
用来在模块中定义函数,
私有函数, 宏以及私有宏. 这里为了论述的方便, 用函数指代命名函数, 私有函数,
宏以及私有宏.
函数的调用格式
查看文档可以知道这些宏接受的第一个参数叫做 call
,
而且我们知道这些宏都有一个同名的一元宏, 其功能就是定义函数或宏的签名,
所以 call
在其他语言中的对应物, 就是函数签名 (function signature)
或者函数头 (function head).
但是为什么不叫函数签名或函数头, 而叫 call 呢? 用一个动词命名参数名,
这多少还是有悖正常的思维. 其中又什么深意吗?
还真是的, 我在 Elixir 论坛中, 得到了答案: 这些宏接受的第一个参数,
必须是函数的调用的形式. 或者说, 代码 def fun(a,b) do...end
,
是在编译时调用 def/2
, 这个函数接受两个参数, 第一个是 fun(a,b)
这个函数调用的返回值, 当然这个函数不存在.
def
会解析出函数名, 参数列表, 并按照我们的调用的方式, 为我们创建这个函数.
现在让我们来看看函数调用. 函数调用有两种形式:
fun_name arg1, arg2
fun_name(arg1, arg2)
所以 call
参数, 也就这些宏接受的第一个参数, 也必须符合这两种模式之一.
要特别注意第二种形式, 括号和函数名之间不能有空格的.
但是为什么要有两种形式呢? 大部分的编程语言, 实际上都只支持第 2 种形式.
Elixir 所以支持第 1 种形式有两个原因:
- 零元函数调用, 省略括号, 看起来像常数的引用, 对于纯函数来说, 零元函数表现的也真的就像一个常数.
- 宏忽略小括号, 可以更像其他语言的关键字
Elixir 很多功能是宏提供的, 这就使得必须支持宏调用的时候可以省略括号,
否则就需要使用大量的嵌套的括号, 这会使得代码看起来非常的繁琐.
例如这样的代码:
defmodule(M,
do: (
def(
fun_name(a, c),
do: (
a + c ))))
看起来真正像 M 表达式[1]啊.
函数体
def*/2
这几个宏的, 第一个参数 call
我们已经学习了,
他们的第二个参数是 expression
, 也就是表达式. 但是实际上必须是 do-block
,
其他的表达式, 都是语法错误, 比如我们就不能这样写代码 def add(a,b), a+b
.
也就说, 这几个 def*/2
宏接受的第二个参数必须是一个 [do: expression]
.
但是需要注意的是, :do
的值只能是一个表达式, 当函数体有不只一个表达式的时候,
就需要用代码块, 或者说块表达式.
而 Elixir 中定义函数体的代码块的有以下两种方式:
-
do...end
直接创建的是一个do-block
. - 还可以使用
()
作为块的分界符, 换行或分号作为语句之间分隔符, 来创建块表达式.
也就是说, 还可以使用[do: (...;...)]
来创建了一个do-block
.
函数定义
这几个 def*/2
宏, 2 个参数各有两种风格, 总共就有 4 种组合.
但是这四种组合中, 不带括号的函数调用 与 [do: (...)]
的组合不完全支持.
例如下面的代码:
defmodule FunDefine do
def add(a, b) do
a + b
end
def sub(a, b), do: a - b
def div(a, b) do
if is_integer(a) and is_integer(b) do
Kernel.div(a, b)
else
a / b
end
end
# def multiply a, b, do: a * b
# def multiply a, b do: a * b
# 注意这里没有逗号 ^, 可是依旧不能通过编译
end
我试图通过定义 add
, sub
, div
和 multiply
来演示函数定义的全部 4 种组合.
但是第 4 种样式报错, 为什么呢?
def multiply a, b, do: a * b
不正确的原因在于,
词法分析器无法判断函数参数列表什么时候结束. 那么对于代码:
def add a,b do
a + b
end
词法分析器是怎么就能知道参数列表什么时候结束呢? 这就是 do
的作用了.
否则, 如果编译器只是通过 :do
前面是不是有逗号, 来判断参数定义是否结束的话,
那么 def multiply a,b do: (a*b)
就应该通过编译的.
现在这样的代码不能通过编译, 说明在编译的词法分析阶段, 对 do
是做了特殊处理.
这是正是 do
作为保留字的原因, do
在这里并没有引入控制结构,
它充当了参数列表与函数体之间的分界符, 并创建了 do-block
.
上面我们说, 第四种组合不被完全支持, 换句话说, 这种组合方式得到了部分支持的.
那么什么时候支持这种格式呢? 那就是定义无参数函数的时候.
例如下面的代码, 语法上是正确的:
def e, do: :math.exp(1)
推荐规范: 定义函数或宏的时候, 零元函数除外, 推荐带有括号的函数头格式.
函数调用
命名的函数调用可以省略括号, 而匿名函数调用的时候必须使用括号.
函数调用的时候, 如果使用括号, 函数名和括号之间不能有空白.
命名函数的省略括号调用
我认为允许命名函数和私有函数调用可以省略括号[2], 这是 Elixir
语言的一个设计缺陷. 理由如下:
- 这样做使得命名函数, 不再是一等公民了, 一个函数式编程的语言, 命名函数不是一等公民,
总是些不协调的. - 允许不带括号调用函数, 使得不能直接以函数名来引用函数, 而必须使用
&
或者
Function.capture/3
.
允许函数调用省略括号带来的好处非常的小; 但因此导致必须使用函数捕获的相关语法,
才能引用函数. 这使得函数引用的语法非常不经济.
在我编码的时候, 首先思考的是哪个函数可以满足需求, 无论是要调用它,
还是要把它作为参数传递其他高阶函数. 所以首先确定是函数的名字,
然后才是思考是 调用 还是 引用.
如果是要调用这个函数, 写括号也非常的流畅,
因为这时的思维的运行步骤与代码的书写步骤是一致的.
但是在需要引用这个函数的地方, 因为函数捕获操作符 &
放在函数名的前面,
当意识到需要的是这个函数的引用的时候, 必须把光标重新移动到函数名的前面,
更糟心的是, 引用命名函数 (准确的说是捕获), 还需要指定函数的元数,
这意味着, 还需要再次把光标移动到函数名的后面.
也就是说, 对于代码, &String.length/1
, 我往往是先写出中间的 String.length
部分,
然后向左移动光标, 到这个表达式的头部补上函数捕获操作符 &
,
再向右移动光标到表达式末尾, 指定函数的元数. 这种体验实在是太糟糕了.
很多时候, 看到代码中 &String.length(&1)
这样的表达式,
我忍不住就想把其修改为 &String.length/1
. 也许 &String.length/1
更正确和高效,
但是 &String.length(&1)
更加符合思维的顺序.
我的期望, 应该禁止命名函数的无括号调用语法, 而只允许宏调用可以省略括号.
这样函数名就是对函数的引用, 命名函数可以作为第一类公民了,
或者非常接近第一类公民了. 考虑到命名函数, 可能有多个同名而元数不同的函数,
引用命名函数的时候可以使用Eralng 函数导出一样的语法: String.length/1
.
函数类型是不能作为除法的运算数的, 所以这样就没必要使用函数捕获操作符了.
实际上 Erlang 中就是这样来区分函数的引用, 还是函数的调用的.
可是这是一个大工程, 必须在 Elixir 的源码级别改动, 且如此改动, 还会引发不兼容问题.
匿名函数调用必须用括号
调用匿名函数的时候, 必须使用括号.
例如这样的代码:
fun=fn a, b -> (a**2 + b**2)**0.5 end;
fun.(3,5)
关于 Elixir 的匿名函数调用需要一个句号 .
, Erlang 之父 Joe Armstrong 在
A Weak of Elixir 的文章中,
认为这是 Elixir 语义设计的不好的地方. 当然了, 也有不少人认为这不是问题.
我想从另一个角度来考虑这个问题. 一个函数, 尤其是支持函数编程语言中的函数,
总是需要从语法上来区分 调用 还是 引用 的.
Elixir 的命名函数, 牺牲了对函数的引用的便捷, 换来调用命名函数时括号的可省略.
匿名函数的值, 本身就是存在一个变量中的, 所以引用非常的方便, 那么调用这个匿名函数的时候,
变量名后面的这个 .
实际上就是一种宣告: 这是对函数的调用, 而不是引用其值.
所以理论上, 对匿名函数的调用也没必要必须使用括号.
但是为什么 Elixir 中却要求必须使用括号呢? 这里我们先把问题搁下,
让我们先来总结一些括号在 Elixir 中的用法.
括号小结
上面的几节中, 我们学习了括号在函数定义, 调用, 以及其他场景下的作用.
总结一下, 其实共有三个作用:
- 作为参数的列表的分界符
- 改变代码运行的优先级
- 创建块表达式
现在问一个问题: 以下的代码, 语法上正确吗? 如果正确的话, 返回值是什么呢?
[]
, {}
, ()
第一个问题的答案是: 这三个都是语法正确的.
前两个非常的常见, 它们是空的列表和空的元组.
()
的返回的是 nil
. 不知道这个答案有没有让你感到意外, 但是我第一次知道的时候,
感觉非常的意外.
true = is_list []
true = is_tuple {}
true = nil === ()
这个知识点, 实际上解释了函数调用的这个规则: 命名函数的调用, 如果要使用括号,
括号必须紧跟着函数名,其间 不能有任何空白字符. 实际上函数定义的时候也是这样.
其他编程语言中, 基本上没有这样的语法规则, 大家所以这样做, 不过只是编码规范.
但是在 Elixir 中, 因为允许调用命名函数的时候, 可以不使用括号,
而一个空括号 ()
的返回值又是 nil
.
这样 fun_name ()
的语义就变成了: 以空括号 ()
表达式的结果 nil
为参数,
调用函数 fun_name
.
所以 Elixir 中就多了这样一个关于函数调用时括号的语法.
这是一个语法, 而不是编码规范.
但是为什么 ()
的返回值是 nil
呢? 这里 ()
实际上是空的块表达式.
块表达式的值是块中的最后一个表达式的值, 而空块表达式 ()
没有表达式,
Elixir 所有代码都是表达式, 因此都需要有返回值. 对于空的块表达式,
最合理的返回值只能是用来表示空的 nil
了.
其他语言中, 括号的作用只有括号在 Elixir 中的前两个功能,
所以其他语言函数调用的时候, 语法上不用做这个要求.
现在重新来探索上面搁置的问题: 匿名函数的调用为什么必须使用括号呢?
思考代码: fun. (3, 4)
语法正确吗? 如果正确, 那么等价于 fun.(3,4)
还是 fun.(4)
?
如果 fun
是一个二元函数, 你会发现, fun. (3,4)
和 fun.(3,4)
是一样的.
也就是说, 匿名函数的调用, 虽然必须使用小括号把参数括起来, 但是小括号和 .
之间可以有空白.
意外不?
实际上, 我们甚至可以把函数名和 .
之间也添加空白, 像这样: fun . (3, 4)
.
我所以感到意外, 是因为自己先入为主的错误偏见: 命名函数调用时,
括号和函数名之间都不能有空白, 而 Elixir 的官方对匿名函数后面的 .
解释是:
本来这个点后面应该是函数名的, 但是因为这是一个无名的函数, 所以就只剩下括号了.
虽然没有说, 点和括号直接不可以有空格, 但是, 如果我们接受 Elixir 官方对 .
的解释,
自然的推断就是不能有空格.
但是从另外一个角度来看, 其实 .
两边可以有空白又是那么的自然, 完全不应该惊讶:
.
是一个二元操作符. 所有的二元操作符, 比如 +
, -
和操作数之间不都可以有空白的吗?
这种惊讶只是思维盲区带来的. 一旦我们认识到 .
是一个二元操作符, 就豁然开朗了.
但是如果我们意识到 .
是一个操作符, 那么为什么调用匿名函数的时候又必须使用括号呢?
毕竟 (2)
的返回值就是 2 吗? 在抽象语法上表示中, (2)
和 2
也没有任何的区别.
但是实际上是有区别的, (2)
所以等于 2
, 是 (2)
被作为块表达式,
计算后的结果等于 2. 但是如果当作参数表来处理, (2)
就不等于2.
我们不可以在不需要参数列表的时候, 提供参数列表的语法的.
例如单独的 (1,2)
就不是一个合法的表达式. 匿名函数调用, 所以必须使用括号,
是因为 .
操作符要求, 当其左操作数是函数的时候, 右操作数必须是参数列表.
块表达式不是作用域
最后需要讨论一下块表达式和作用域的关系.
块表达式, 还是表达式, 它没有创建新的作用域.
但是 do ... end
的代码块, 因为是跟在作用域相关的语法结构后面, 所以,
其中的代码一般都是在新的作用域中的.
例如下面的代码:
a = 0
(
b = 1
IO.inspect(a, label: "in block exprssion, a")
a = b + 1
)
IO.puts(inspect(a: a, b: b))
在块作用表达式中, 当然可以访问外部的变量 a
就像第 4 行代码显示的那样.
而且, 还可以对外部作用域中定义的变量 a
赋新值. 而第 7 行表明,
不但变量 a
的值改变了, 而且块表达式中定义的变量 b
, 在块外部也可见.
结论: 块表达式并不创建新的作用域.
不但块表达式不创建新的作用域, 使用括号表示的参数列表, 其作用域也属于外部.
例如下面的代码:
b = :math.sin(a = :math.pi() / 2)
{a, b} |> IO.inspect() # {1.5707963267948966, 1.0}
但是宏调用不一样. 宏是特殊的函数, 它接受 ast, 返回的也是 ast.
以宏的功能的不同, 可能会向运行时中注入宏参数一样的变量,
也可能不注入, 甚至还可能注入和宏参数完全无关的变量.
(c = 1) && (d = 2)
false = :d in (binding() |> Keyword.keys())
false = c in (binding() |> Keyword.keys())
上面的代码展示了, &&
宏, 并不把其接受的 ast 中的变量, 注入到运行时.
-
Meta-expression, 这是 List 语言的设计者最初打算实现的,
给程序员使用的表达式格式, 但是最终 S 表达式流行起来, M 表达式从来没有实现. ↩ -
这一小结节中, 函数就只是函数, 不再包括宏. ↩