角落里的 ElixirElixir 编程

角落里的长生不老药 括号

2022-08-09  本文已影响0人  桂叶圣

本章的目的

在这一章我们讨论的是 语法上可以如何做, 而 不是实际编码上应该怎么做.
可以做是一个硬规则, 是面向编译器的; 应该怎么做是一个规范, 是面向程序员的.
所以很多地方, 在讨论完可以如何做后, 我又用黑体给出了社区推荐的做法,
也就是应该如何作.

本章讨论可以做的方法绝大部分, 不符合社区的推荐做法. 一边展示不推荐的做法,
一边规劝大家不要这样做, 这看起来似乎是在左右互博,自相矛盾.
所以这样做, 是为了搞清楚所以然 --- --- 社区给出这些规范的所以然, 也就是规范背后的原因.

当真正的理解了什么是可以做的, 以及什么必须做的时候, 才能理解规范所以如此的原因.

使用括号改变优先级

使用括号改变优先级, 这是最普通的括号的用法. 这和我们在数学课堂上使用括号的情形一样.
例如数学公式 a\times{}(b+c), 对应的程序表达式就是 a*(b+c).
如果要消除括号, 那么需要调整代码, 并需要引入中间变量. 例如上面的等价的表达式,
可以写作 tem=b+ca*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 语法定义匿名函数情形几乎一样, 使用函数捕获操作符 &
定义匿名函数的也不需要小括号, 但是这里有一点点的不同.

  1. fnend 是作为保留字存在的, 优先级高于其他的操作符.

  2. 匿名函数可以作为参数传递给其他高阶函数

    当把匿名函数通过管道操作符 |>, 传递给其他高阶函数的时候,
    使用函数捕获操作符 & 定义的匿名函数就必须使用括号,
    因为管道操作符符 |> 的优先级高于函数捕获操作符 &.

例如:

(& &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. 函数捕获操作符 & 操作优先级最低, 所以整个表达式等价于
    &(&1 + &2 + &3 |> apply([1,2,3])).
  2. 加法运算符 + 优先级高于管道运算符 |>, 所以
    &1 + &2 + &3 |> apply([1,2,3]) 等价于 (&1 + &2 + &3) |> apply([1,2,3]).
  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/2defmacrop/2 用来在模块中定义函数,
私有函数, 宏以及私有宏. 这里为了论述的方便, 用函数指代命名函数, 私有函数,
宏以及私有宏.

函数的调用格式

查看文档可以知道这些宏接受的第一个参数叫做 call,
而且我们知道这些宏都有一个同名的一元宏, 其功能就是定义函数或宏的签名,
所以 call 在其他语言中的对应物, 就是函数签名 (function signature)
或者函数头 (function head).

但是为什么不叫函数签名或函数头, 而叫 call 呢? 用一个动词命名参数名,
这多少还是有悖正常的思维. 其中又什么深意吗?

还真是的, 我在 Elixir 论坛中, 得到了答案: 这些宏接受的第一个参数,
必须是函数的调用的形式. 或者说, 代码 def fun(a,b) do...end,
是在编译时调用 def/2, 这个函数接受两个参数, 第一个是 fun(a,b)
这个函数调用的返回值, 当然这个函数不存在.
def 会解析出函数名, 参数列表, 并按照我们的调用的方式, 为我们创建这个函数.

现在让我们来看看函数调用. 函数调用有两种形式:

  1. fun_name arg1, arg2
  2. fun_name(arg1, arg2)

所以 call 参数, 也就这些宏接受的第一个参数, 也必须符合这两种模式之一.
要特别注意第二种形式, 括号和函数名之间不能有空格的.

但是为什么要有两种形式呢? 大部分的编程语言, 实际上都只支持第 2 种形式.
Elixir 所以支持第 1 种形式有两个原因:

  1. 零元函数调用, 省略括号, 看起来像常数的引用, 对于纯函数来说, 零元函数表现的也真的就像一个常数.
  2. 宏忽略小括号, 可以更像其他语言的关键字

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 中定义函数体的代码块的有以下两种方式:

  1. do...end 直接创建的是一个 do-block.
  2. 还可以使用 () 作为块的分界符, 换行或分号作为语句之间分隔符, 来创建块表达式.
    也就是说, 还可以使用 [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, divmultiply 来演示函数定义的全部 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
语言的一个设计缺陷. 理由如下:

  1. 这样做使得命名函数, 不再是一等公民了, 一个函数式编程的语言, 命名函数不是一等公民,
    总是些不协调的.
  2. 允许不带括号调用函数, 使得不能直接以函数名来引用函数, 而必须使用 & 或者
    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 中的用法.

括号小结

上面的几节中, 我们学习了括号在函数定义, 调用, 以及其他场景下的作用.
总结一下, 其实共有三个作用:

  1. 作为参数的列表的分界符
  2. 改变代码运行的优先级
  3. 创建块表达式

现在问一个问题: 以下的代码, 语法上正确吗? 如果正确的话, 返回值是什么呢?

[], {}, ()

第一个问题的答案是: 这三个都是语法正确的.
前两个非常的常见, 它们是空的列表和空的元组.

() 的返回的是 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 中的变量, 注入到运行时.


  1. Meta-expression, 这是 List 语言的设计者最初打算实现的,
    给程序员使用的表达式格式, 但是最终 S 表达式流行起来, M 表达式从来没有实现.

  2. 这一小结节中, 函数就只是函数, 不再包括宏.

上一篇下一篇

猜你喜欢

热点阅读