我试图理解Clojure协议及其所要解决的问题。是否有人可以清晰地解释Clojure协议的定义和用途?
Clojure中协议的目的是以高效的方式解决表达问题。
那么什么是表达问题?它指的是可扩展性的基本问题:我们的程序使用操作来处理数据类型。随着程序的演进,我们需要用新的数据类型和新的操作来扩展它们。特别地,我们希望能够添加与现有数据类型兼容的新操作,并添加与现有操作兼容的新数据类型。并且我们希望这是真正的扩展,即我们不想修改现有程序,我们希望尊重现有的抽象,我们希望我们的扩展是独立的模块,在单独的命名空间、单独编译、单独部署并进行单独类型检查。我们希望它们是类型安全的。【注:并非所有语言都有这些目标。但例如,即使在Clojure等语言中无法静态检查类型安全,也意味着我们不希望代码随机崩溃,对吧?】
表达问题是,你如何在一种语言中提供这样的可扩展性?
事实证明,在典型的过程化和/或函数式编程的简单实现中,添加新的操作(过程、函数)非常容易,但添加新的数据类型非常困难,因为基本上操作使用一些形式的情况区分(switch
、case
、模式匹配)来处理数据类型,并且您需要向它们添加新的情况,即修改现有代码:
func print(node):
case node of:
AddOperator => print(node.left) + '+' + print(node.right)
NotOperator => '!' + print(node)
func eval(node):
case node of:
AddOperator => eval(node.left) + eval(node.right)
NotOperator => !eval(node)
现在,如果你想添加一个新的操作,比如类型检查,那很容易。但是,如果你想添加一个新的节点类型,你就必须修改所有现有操作中的模式匹配表达式。
对于典型的OO编程,情况恰好相反: 添加新数据类型以便与现有操作一起使用(通过继承或覆盖它们)很容易,但是添加新操作很难,因为这基本上意味着修改现有的类/对象。
class AddOperator(left: Node, right: Node) < Node:
meth print:
left.print + '+' + right.print
meth eval
left.eval + right.eval
class NotOperator(expr: Node) < Node:
meth print:
'!' + expr.print
meth eval
!expr.eval
在这里,添加一个新的节点类型很容易,因为你可以继承、覆盖或实现所有必需的操作,但是添加一个新的操作很困难,因为你需要将其添加到所有叶类或基类中,从而修改现有的代码。
几种语言都有解决表达式问题的构造:Haskell 有类型类,Scala 有隐式参数,Racket 有 Units,Go 有接口,CLOS 和 Clojure 有多方法。还有一些“解决方案”试图解决它,但在某种程度上失败了:C#和 Java 中的接口和扩展方法,在 Ruby、Python、ECMAScript 中的 Monkeypatching。
请注意,Clojure 实际上已经有了解决表达式问题的机制:多方法。 OO 面临的问题是他们将操作和类型捆绑在一起。使用多方法,它们是分开的。FP 面临的问题是他们将操作和案例区分捆绑在一起。再次使用多方法,它们是分开的。
那么,让我们将协议与多方法进行比较,因为它们都可以完成同样的工作。或者换句话说:如果我们已经有了多方法,为什么要使用协议?
协议相对于多方法提供的主要优势是分组:你可以将多个函数分组在一起,并说“这三个函数共同形成协议Foo
”。使用多方法无法做到这一点,它们总是独立的。例如,您可以声明一个 Stack
协议由 同时 拥有 push
和 pop
函数。
那么,为什么不只是添加将多个多方法组合在一起的功能呢?有一个纯粹实用的原因,这也是我在介绍性句子中使用“高效”一词的原因:性能。
Clojure 是一种托管语言。也就是说,它专门设计为在另一种语言的平台上运行。事实证明,您想让 Clojure 运行在的任何平台(JVM、CLI、ECMAScript、Objective-C)都具有专门的高性能支持,可以仅基于第一个参数的类型进行调度。Clojure 多方法则会根据所有参数的任意属性进行调度。
因此,协议限制了您仅在第一个参数以及其类型(或作为特殊情况在nil
上)上进行调度。
这并不是针对协议本身的限制,而是出于实用选择,以获得底层平台的性能优化。特别是,它意味着协议与 JVM/CLI 接口具有非常简单的映射,这使它们非常快速。事实上,足够快,可以将 Clojure 的那些当前在 Java 或 C# 中编写的部分重写为 Clojure。
从版本 1.0 开始,Clojure 实际上已经拥有协议:Seq
就是一个协议。但是,在 1.2 之前,您无法在 Clojure 中编写协议,而必须在托管语言中编写。
我认为,将协议与面向对象语言(如Java)中的"接口"在概念上进行类比是最有帮助的。协议定义了一组抽象函数,可以针对给定对象以具体方式实现。
例如:
(defprotocol my-protocol
(foo [x]))
定义了一个协议,该协议有一个名为“foo”的函数,该函数作用于一个参数“x”。
然后,您可以创建实现该协议的数据结构,例如:
(defrecord constant-foo [value]
my-protocol
(foo [x] value))
(def a (constant-foo. 7))
(foo a)
=> 7
注意这里实现协议的对象作为第一个参数 `x` 传递 - 类似于面向对象语言中的隐式 "this" 参数。(extend-protocol my-protocol
java.lang.String
(foo [x] (.length x)))
(foo "Hello")
=> 5