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是Lisp类型,就进行动态分发,执行对应的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"