现代化面向并行的函数式编程语言推荐

前言

从 2009 年 Go 语言问世以来,才让很多人明白(包括我)原来编程语言可以在原生层面适用于并发场景,使用抽象后的轻量级“并行单元”来代替原生线程进行极低成本的并发执行,以轻松的应对超高的并发量。
其实在这之前有一门老牌并行语言被大家忽略了,的确在 Go 之前我对这“它”也不了解,它便是衍生出本文的主角的来源:Erlang。虽然 Erlang 目前在电信行业仍然有很大的市场,但是面对互联网的流行,Erlang 遗憾在于不仅是没有成为其中的中流砥柱技术基石,甚至算不上是平民级的工业语言。

而本文要介绍的主角 Elixir 则是在继承了 Erlang 的生态和强大核心的情形下,诞生出的语法风格现代化并更加容易令人适应的新语言,Elixir 之于 Erlang 类似于 Scala/Kotlin 之于 Java(不过一脉相承的程度更深)。

未来是面向并行的

随着越来越多的新语言/技术的流行,让我明白“并行编程”是大家都热衷于解决的一个领域,传统的原生多线程编程开发模式正在被革新。

Go 的 goroutine 实现了 M:N 线程模型,让传统的以“原生线程”为并发执行单元转变为以 goroutine 为并发执行单元。这导致在相同开销(假设系统使用了 1000 个线程)的情况下,Go 能执行远超原生线程数量的更多的并行单元(由语言自实现的调度器控制,而非操作系统的线程抢占),一般来讲创建数十万个 goroutine 都是很轻松的。
Elixir 基于 Erlang VM,继承了 Erlang 的并行设计,与 Go 不同的是 Elixir 对并行单元的抽象是“进程”,这里的进程不是操作系统的进程(这是当然,因为进程的开销比线程都大得多得多),不过跟操作系统进程具有某些类似的性质,例如每个进程独立堆内存并分配一个 PID。

Go 的 goroutine 之间的通信通过“通道”实现,通道内部提供队列,Elixir 的进程则是 Actor 模型,“邮箱”不会缓存消息。go 的通道是通过类型匹配消息,而 Elixir 的 receive 可以利用自身更为强大的模式匹配。排除这些 Go 的 goroutine 和 Elixir 的进程还是有一定的相似性的,当然这种“表象”的相似性却因为语言特性有相当大使用便捷性差距:Go 需要通过锁或互斥量等机制来保证数据安全,而 Elixir 由于在函数式特性以及隔离的堆内存的强制保证下性并不存在并发环境下的数据安全问题,这让人们可以更加集中的将思维用在并行编程上,而不用为数据竞争而头疼。

并且它们二者的内部实现模式完全不同,Elixir 的调度策略为抢占式,Go 的 goroutine 则为合作式调度(二者更有优缺),仅从本文以介绍 Elixir 为主题出发的话,抢占式能更好的保证一致性。

互联网平台面对高并发场景是相当常见的,目前主流的方式是通过对业务应用和数据源的横向扩展来分摊压力,当然这意味着更加复杂的系统架构。使用异步框架能让传统的应用承受更高的并发量,比较成熟的方案例如 Java 上的 Vert.x、Python 的 Sonic 或者使用 Node.js。
不过异步编程本质上是“反人类”的编程方式,并且它无法发挥出多核处理器的性能。而 Go 或者 Erlang/Elixir 原生优化处理器多核心。最主要的,异步 API 调用同步 API 会造成问题,所以需要将同步 API 封装成异步,这导致“一处异步、处处异步”,非常麻烦。举个例子:如果你使用 Vert.x 那么你最好就使用 Vert.x 自己封装的异步 jdbc-client,否则你就得将数据访问的 API 调用交给 Worker 线程执行(同步阻塞)。

先进的并行编程策略的无法适合所有场景,但是对于典型的 IO 密集的互联网应用而言是具有天生优势的,所以我觉得互联网技术的未来是面向并行的。再加上函数式特性对并行友好的加持,这也是 Elixir 适合互联网并且应该被大家接受的重要原因。

数据不可变和变量重新绑定

通常我们知道,在函数式编程里边我们推崇“纯函数”,一个函数要成为纯函数在保证不调用非纯函数的情况下(例如当前系统时间或者读取外部文件内容)还得保证引用数据的不可变性。也就是说你或者他人(其它函数)都不能修改引用的任何外部变量。

这一点在 Erlang 是强制保证的,虽然数据不可变会给函数式和并行带来极大的好处,但是同时也给编码造成不小的麻烦,最基本的如果你想修改哪怕是本地变量你仍然得手动创建几个”中间“变量接收新值,而这之间创建的临时变量只是起到一个数据”过渡“作用,这是很多余的,例如:

v1 = 1
v2 = v1 + 1
v3 = v2 * 10

如果我要对 v1 变量进行三次操作,每次操作返回的新值都需要用新的变量接收,因为数据不可变。而在 Erlang/Elixir 中变量只是指向数据的“标签”,假设只是函数内部的本地变量,无论它怎么重新绑定(改变标签含义),实际上都不会影响“纯”的性质。这里的重新绑定变量的意思是将变量指向了新创建的数据,而原有的数据不变,即仍然保证数据不可变性。也就是上面提到了改变了标签的含义,但是不会改变原来贴过该标签的内容含义。

这一点在 Elixir 上得到了改善,你可以对变量进行重新绑定(注意 Erlang 是不允许的):

v1 = 1
v1 = v1 + 1
v1 = v1 * 10

IO.puts(v1) # 20

你可能在觉得惊奇的同时又会产生疑问,因为很显然,这并不是我上面提到的对“本地变量”的重新绑定。v1 可以被多个函数引用,同时又因为 v1 被重新绑定,这是不是表示引用 v1 的函数的内部状态被外部影响(也被重新绑定)了呢?

v1 = 1

echo = fn -> v1 end

v1 = v1 + 1
v1 = v1 * 10

IO.puts(v1) # 20

IO.puts(echo.()) # 1

echo 引用的函数(匿名函数)定义在了 v1 初次绑定值(1)的时候,并且即便后来 v1 被重新绑定了两次函数内的 v1 仍然是 1。这个实验证明了 Elixir 并没有因为允许变量重新绑定而造成我们担忧的问题,这是为什么呢?

我们发现,被绑定的变量在被函数访问后该函数内部会持续引用当时的值,即便这个值在之后看起来已经“过时”。所以“重新绑定”实际上也只是 Elixr 对于允许同名变量并多次利用在不同作用域下绑定新值的“语法糖”。在了解这一点之后,不仅能享受到“重新绑定”带来的便捷,同时又能避免正常思维下产生的对数据安全的忧虑。

用模式匹配处理返回

什么是模式匹配呢?

list = [1, 2, 3]
[1, 2, 3] = list

上面这段代码将一个字面量集合(左边)和 list(右边)做匹配,因为数据是一一对应的,所以代码会通过,等号即匹配操作符。
看起来这样的代码基本没有用处。不过实际上,模式匹配几乎贯穿了整个 Elixir 语言:

list = [1, 2, 3]

case list do
  [1, 2, x] -> IO.puts(x)
end

例如上面的 case 就是一个典型的例子。如果用作错误处理的话:

defmodule MyMath do
  def div(n1, n2) do
    if(n1 == 0) do
      {:error, "Divisor cannot be zero"}
    else
      {:ok, n1 / n2}
    end
  end
end

case MyMath.div(0, 5) do
  {:ok, result} -> IO.puts("Result: #{result}")
  {:error, msg} -> IO.puts("Error: #{msg}")
end

上面实现了一个除法函数,执行以后输出:

Error: Divisor cannot be zero

将结果做模式匹配,让不同的情况分开处理,这样的方式比 Go 语言使用 if 判断 error 是否为空的方式优雅多了。同时,返回 :error 和 :ok 这两种情形用作正确返回和出现错误也是 Elixir 上默认的约定。不过如果等号是模式匹配操作符的话,那么赋值又发生在什么时候呢?

如果在 Erlang 中,等号并不是用来赋值的,只能用作“模式匹配”。只不过,Erlang 中的等号左边如果是一个未绑定过数据的变量则具有“赋值”的功能,并且“赋值”实际上仍然是“模式匹配”,为什么这么说呢?
因为一个未绑定过的变量会先绑定到右边的数据上,然后再进行模式匹配,既然是绑定到右边的数据自然就匹配通过了。你可以理解为模式匹配中如果存在未绑定的变量会先进行绑定再进行匹配,这便是赋值的产生条件。

但是上面提过了,Elixir 中变量可以被多次“绑定”,那么被绑定过的变量出现在模式匹配中到底是重新绑定后再匹配还是直接使用当前的值匹配呢?

x = 1
x = 2

上面的代码看起来有点搞笑,这不是典型的将 x 重新绑定了两次吗?是这样,不过这同时也进行了两次模式匹配不是吗?显然这段代码不需要运行就知道能顺利通过,因为重新绑定完全允许这样的情况出现,所以在 Elixir 中即使是被绑定过的变量出现在模式匹配中也会被再绑定一次。

如果要避免这样的情况出现,就需要用 ^ 符号:

x = 1
^x = 2

这段代码会出现异常:

(MatchError) no match of right hand side value: 2

这就跟 Erlang 是一样的了,当我们需要使用这种场景的时候便可以使用 ^ 符号在防止被重新绑定的情况下将被绑定过的变量进行模式匹配。

模式匹配带来了巨大的作用,它可以用来“解构”复杂的结构,更重要的对不同的返回情形进行了平行处理。而使用 with 甚至能做到让一系列的函数调用匹配到同一个错误返回上:

with {:ok, r1} <- MyMath.div(5, 5),
     {:ok, r2} <- MyMath.div(r1, 5),
     {:ok, r3} <- MyMath.div(r2, 5) do
  IO.puts(r3)
else
  {:error, msg} -> IO.puts(msg)
end

上面连续调用了三次 MyMath.div 函数,并且后面的调用都依赖前面的返回结果。我们使用 with 将调用链顺序的排列了下来,并且匹配到同一个错误模式上。假设如果我们不适用 with 那么代码将惨不忍睹:

case MyMath.div(5, 5) do
  {:ok, r1} ->
    case MyMath.div(r1, 5) do
      {:ok, r2} ->
        case MyMath.div(r1, 5) do
          {:ok, r2} ->
            IO.puts(r3)
          {:error, msg} ->
            IO.puts(msg)
        end
      {:error, msg} ->
        IO.puts(msg)
    end
  {:error, msg} ->
    IO.puts(msg)
end

为了得到结果 r3,我们分别调用三次 div 的同时又分别进行了三次的正确与错误的返回模式匹配。嵌套层级会随着调用依赖链的增长而加深,并且所有的错误匹配都被独自处理。这样的代码极其难看,即便是 case 也应付不来,更别说别的语言还在用 if/else 了。

所以模式匹配是一个很好的东西,但是同时也需要 case/with 进行增强支持。而 Go 语言也采用类似的约定来返回错误与正确结果,但是不支持模式匹配,更没有对多种返回情形的平行处理支持(仅仅是增强了一下 if),对开发者而言很不友好:)说直接点就是写出来的代码很 low,当然我不在意这个,我只是说出了大家一直认为的。

基于管道哲学设计 API

在 Unix 中管道是一个十分有用的概念,它让一系列仅通过 Bash 调用的 CLI 程序协同工作,当前进程的输出会作为下一个进程的输入,这让开发人员省了不少麻烦。

类似的,在编程的过程中如果想让一个函数的输入等于另一个函数的输出,则需要将子函数调用放进父函数的参数中,调用链越长,嵌套越多。

假设我们要从 “Your code: 12345” 这个字符串中获得 12345 这串数字,需要经过一系列函数的先后依赖调用。例如:

{n, _} = Integer.parse(List.last(List.first(Regex.scan(~r/code:\s(\d+)$/i, "Your code: 12345"))))

上面就是以子函数结果作为父函数的参数的输出输入先后依赖的调用方式,最终得到了结果 n 即是 12345 数字。从里向外看,先调用了 Regex.scan 函数获得了匹配结果,然后先后用 List.first 到 List.last 两个函数将结果中需要的部分提取出来,最后调用 Integet.parse 转换为数字。这看起来好像挺简洁的,毕竟只有一行代码,但是调用过程却极其的不直观,甚至还不如将步骤展开用中间变量接收的方式多行编写。毕竟从里到外看代码反人类。

Elixr 借鉴了 Unix 中的管道概念,让调用链保证了从左到右的顺序,避免了传统编程方式中让调用链从里到外嵌套:

Regex.scan(~r/code:\s(\d+)$/i, "Your code: 12345")
|> List.first()
|> List.last()
|> Integer.parse()
|> (fn {n, _} -> n end).()

|> 符号便是管道操作符(类似于 Unix 中的 |),它将函数结果(输出)作为下一个函数的参数(输入)。当然上面的代码也可以写作一行(应该说原本就是一行),只不过通常为了美观和直观性会被格式化为多行。对于最后的多返回值使用了一个匿名函数进行处理,仅返回需要的值 n。

因为有了管道,函数式编程中常见的需要将“状态”作为参数显式传递给函数的麻烦便减轻了许多。例如:

defmodule User do
  defstruct [:name, :age]

  def create(name, age) do
    %__MODULE__{name: name, age: age}
  end

  def rename(user, name) do
    %{user | name: name}
  end

  def celebrate_birthday(user) do
    %{user | age: user.age + 1}
  end
end

上面定义了一个包含 Struct 的 Module,用作对 User 这个实体状态的保存。按照传统的方式:

u = User.create("小明", 18)
u = User.rename(u, "小鸣")
u = User.celebrate_birthday(u)

IO.inspect(u) # %User{age: 19, name: "小鸣"}

我们创建了用户小明,然后让小明改了名字,再然后让小明过了一次生日(年龄+1)。在这个过程里,最让人容忍不了的是每一个函数的调用都需要将当前的状态传递过去,也就是代码中的 u,再使用变量接收新的状态。这对于习惯面向对象编程的人一定受不了,但是没办法函数式编程就是如此。面向对象中的函数本质上就是要依靠同一个状态的副作用,状态即对象,函数即方法,这点跟函数式是截然相反的。函数式编程为了保证函数的”纯“的性质,就像上面那样,状态 u 每一次都是要传递给函数的,函数不能依赖任何外部可以被修改的共享状态。

如果使用管道则可以让面向对象程序员好接受一点:

u =
  User.create("小明", 18)
  |> User.rename("小鸣")
  |> User.celebrate_birthday()

IO.inspect(u) # %User{age: 19, name: "小鸣"}

上面这段代码的两次函数调用都没有传递 user 这个状态,因为这个状态被管道隐式传递过来了。被管道传递过来的输出会作为下一个函数的第一个参数,因此第一个参数被省略,所以后面两次函数调用都没有传递 user。因为这种隐式传递的机制,给人一种后面的调用过程都在操作“同一个”共享变量的感觉,所以我说这对于面向对象程序员或许好接受一些:)

管道看起来是一种简单的语法支持,但是带来了巨大的代码设计和编码友好程度上的提升。作为 Elixir 开发者,必须了解管道的好处,并学会基于管道哲学设计 API:例如说,你不能将 User.create 函数中的 name 放在第一个参数上,user 放在第二个参数位置,这会给管道调用带来麻烦。也就是说要学会设计对管道调用友好的 API。

至少我爱上管道了,我写的 Elixir 代码到处都是管道。真的,它太有用了:)

从根源杜绝数据竞争

待更