Kapok 的设计与实现: Protocol

Kapok 是我设计与实现的一个基于 Erlang VM 的现代 Lisp 编程语言。《念念不忘,亦有回响》这篇文章叙述了这门编程语言的概况。下面我们来聊一聊其中 Protocol 的具体设计和实现。

Protocol 是什么

为了更好地进行程序的编写,编程语言往往需要引入一些抽象,比如一个函数是一系列操作的封装,一个模块是一堆函数的封装,然后出于通用性的需要,又定义了函数签名来区分具有相同参数类型和参数个数的函数,对于具有相同函数签名的模块,在 Erlang 我们定义了行为(Behavior)。类似地,Protocol 也是一种对数据类型和基于数据类型上实现的一些函数的抽象。在面向对象编程语言(OOP)非常流行的今天,这种抽象更多地被称为类与接口。

下面简单举例来说明 Protocol 是什么。在 Erlang 里面,如果我们要编写一段代码来处理不同的数据类型,往往需要在一大块集中代码里面针对每一种数据类型进行不同的处理。假设我们正在编写一个 Json 模块,这个模块要处理 list, binary, number 三种类型的 encode(编码)操作,那么就有代码:

1
2
3
4
5
6
7
8
-module(json)

encode(Item) when is_list(Item) ->
  % ...
encode(Item) when is_binary(Item) ->
  % ...
encode(Item) when is_number(Item) ->
  % ...

如果要添加更多的数据类型,那么就在模块内部添加对应那个数据类型的分支。但是如果你没有这个模块的源代码就无法添加,而且经过长时间添加多个类型支持后这个模块变得非常大,难于维护。Elixir 引入了 Protocol,针对上述的 encode 操作,可以定义成一个 Protocol,代码如下:

1
2
3
defprotocol JSON do
  def encode(item)
end

对于任何实现了JSON Protocol 的数据类型对象data,都可以直接调用下述函数:

1
JSON.encode(data)

这些数据类型的JSON Protocol 实现可以分散放在各个文件中,没有要集中维护的问题,当你需要添加一个新的数据类型的支持时,也不需要已有模块的源代码。

1
2
3
4
5
6
7
8
9
10
11
defimpl JSON, for: List do
  def encode(item) # ...
end

defimpl JSON, for: BitString do
  def encode(item) # ...
end

defimpl JSON, for: Number do
  def encode(item) # ...
end

通过 Protocol 这样一个抽象,我们可以在函数式的编程语言中,定义对数据类型绑定一系列的接口,然后针对这些通用的接口来进行编程。针对接口编程一个常常被提到的用法是,在需要快速编写原型的时候,使用简单的数据类型进行接口编程,先快速实现功能,等到程序稳定下来,后期需要进行性能优化的时候,再将接口下面的数据类型更换成更高效的实现,这个过程中所有的接口使用代码都不需要修改。

Elixir 中 Protocol 的实现

Protocol,或者说类接口,它的广义的概念可以追溯到 20 世纪 80 年代开始流行的面向对象编程语言,比如 C++,甚至更早之前 60~70 年代就已经存在的 Lisp,从那个年代流传至今的很多 Lisp 方言中,都或多或少有着这种抽象,比如 Common Lisp 中的 Sequence 的概念。而在 Erlang VM 上实现了 Protocol 的 Elixir 语言,它则是参考了 Clojure 的 Protocol 定义和实现。下面简单描述一下 Elixir 的 Protocol 实现。

从上面的例子我们可以看到,Protocol 的本质是将数据类型和接口分开定义,并在运行时进行动态绑定。上述例子中,对于 List 类型,定义了对应的 encode 实现,调用 Protocol 的接口JSON.encode(data)时,如果 data 是 List 类型,就进行动态分发,执行对应的 encode 实现。那么如何实现这种动态绑定或者说分发呢?由于 Erlang 是一个函数式编程语言,Erlang VM 的基本语义中也只支持模块和函数,因为 Protocol 的每个实现可以分开,而 Erlang VM 中基础的编译单元是模块,一个简单的映射方法就是将每个 Protocol 实现映射为一个模块。而对于接口模块JSON,很自然地也成为一个单独的模块。那么就只剩下一个问题了,即如何实现当调用JSON.encode()时,将执行路径转到defimpl JSON, for: List模块的encode()函数,这里可以实现为在运行时拼接模块名,然后调用具体模块的函数。上述的 Elixir 代码中的例子,将编译成对应的 Erlang 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
      Elixir                                  Erlang
defprotocol JSON do                     -module(JSON).
  def encode(item)         生成          encode(item) ->
end                                        case item of
                                         data when is_list(data) ->
                                           'JSON.List':encode(data);
                                         data when is_binary(data) ->
                                           'JSON.BitString':encode(data);
                                         data when is_number(data) ->
                                           'JSON.Number':encode(data)
                                         data when is_struct_X(data) ->
                                           'JSON.X': encode(data)
                                       ...

这里的 struct_X 表示除了几个基本数据类型以外的用户自定义 struct 类型,其中 X 为 struct 的名字,即对应 Elixir 中的代码

1
2
3
defmodule X do
  defstruct # ...
end

对于各个数据的类型的实现,可以映射为各个 Erlang 模块:

1
2
3
4
5
6
7
8
9
10
11
12
      Elixir                                         Erlang
defimpl JSON, for: List do            生成      -module('JSON.List').
  def encode(item) # ...                        encode(item) ->
end                                               %% ...

defimpl JSON, for: BitString do                 -module('JSON.BitString').
  def encode(item) # ...                        encode(item) ->
end                                               %% ...

defimpl JSON, for: Number do                    -module('JSON.Number').
  def encode(item) # ...                        encode(item) ->
end                                               %% ...

注意其中的模块名是由 Protocol 名字,即这个例子中的 JSON,和具体的数据类型名,即这个例子中的 List, BitString, Number 等,两者拼接而成。

Kapok 中 Protocol 的实现

既然 Elixir 已经实现了 Protocol,而且 Kapok 和 Elixir 都兼容于 Erlang VM,那么除了另外捣腾一套 Protocol 的用法和实现之外,比较好的做法就是兼容 Elixir 的实现,从而达到重用所有 Elixir 己经有的库和代码的效果。Elixir 中定义了每个 Protocol 模块都必需具备的接口函数,具体列出如下,它们都是通过 defprocotol 宏来实现的:

  • __protocol__/1 当参数是:name,返回 Protocol 名字 当参数是:functions,返回一个元素为 Protocol 接口函数和参数个数的关键字列表 当参数是:impls,返回一个实现的列表

  • impl_for/1 接收一个 struct,返回为此 struct 实现当前 Protocol 的模块名,如果不存在这样的实现模块则返回:nil

  • impl_for!/1 类似于上面的impl_for/1,区别在于当实现模块不存在时不返回:nil而是抛出异常

在 Kapok 中,也定义类似的 Macrodefprotocol来生成这些接口函数。类似地,对于 Elixir 中的defstruct, defimpl Macro,Kapok 也定义了对应的 Macro 来生成对应的 struct 结构,和实现函数接口。

以上所述的种种 Protocol 结构都是通过 Macro,这个强大的 Lisp 编程方式,来实现的。通过 Macro 处理手写代码,生成新代码,我们可以在 Kapok 实现一套类似于 Elixir Protocol 原语,无缝对接到已有的 Elixir 代码和库中。

Kapok 的 Protocol 使用示例

最后附上一个 Kapok 代码示例,展示一下 Protocol 在 Kapok 中的定义和使用,便于理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
;; 定义一个用于此示例的 Protocol
(defprotocol pr
  "A protocol to print something."
  ;; 此 Protocol 唯一接口用来打印某个数据
  (print [self]))

;; 为 Atom 数据类型实现 pr Protocol
(defimpl pr Atom
  (require io)

  (defn print [atom]
    (io.format "print an atom: #'~p'~n" [atom])))

;; 为 List 数据类型实现 pr Protocol
(defimpl pr List
  ;; 在 print 的实现代码中,直接使用了 Elixir 的 Enum Protocol
  ;; 所以这里用了一行代码引入 Elixir.Enum 模块
  (require (Elixir.Enum :as enum)
           io)

  (defn print [list]
    (io.format "print a char list: #\"")
    ;; 直接在 List 上调用 enum.map,直接使用 Elixir 库代码
    (enum.map list
              (fn [x] (io.format "~c" [x])))
    (io.format "\"~n")))

(ns protocol-examples
  (require pr))

(defn main []
  ;; 下面先后声明了 Atom 和 List 两种数据类型的实例,并调用 pr.print 接口
  (let [data #abc]
    (pr.print data))
  (let [data #"abc"]
    (pr.print data)))
;; 得到输出
print an atom: #'abc'
print a char list: #"abc"

Comments