Clojure协议的简单解释

151

我试图理解Clojure协议及其所要解决的问题。是否有人可以清晰地解释Clojure协议的定义和用途?


9
Clojure 1.2 协议27分钟介绍视频:http://vimeo.com/11236603 - miku
3
协议(Protocols)在Scala中有一个非常相近的类比,那就是特征(mixins)。参考链接:https://dev59.com/32855IYBdhLWcg3wJgxy#4510173 - Vasil Remeniuk
2个回答

315

Clojure中协议的目的是以高效的方式解决表达问题。

那么什么是表达问题?它指的是可扩展性的基本问题:我们的程序使用操作来处理数据类型。随着程序的演进,我们需要用新的数据类型和新的操作来扩展它们。特别地,我们希望能够添加与现有数据类型兼容的新操作,并添加与现有操作兼容的新数据类型。并且我们希望这是真正的扩展,即我们不想修改现有程序,我们希望尊重现有的抽象,我们希望我们的扩展是独立的模块,在单独的命名空间、单独编译、单独部署并进行单独类型检查。我们希望它们是类型安全的。【注:并非所有语言都有这些目标。但例如,即使在Clojure等语言中无法静态检查类型安全,也意味着我们不希望代码随机崩溃,对吧?】

表达问题是,你如何在一种语言中提供这样的可扩展性?

事实证明,在典型的过程化和/或函数式编程的简单实现中,添加新的操作(过程、函数)非常容易,但添加新的数据类型非常困难,因为基本上操作使用一些形式的情况区分(switchcase、模式匹配)来处理数据类型,并且您需要向它们添加新的情况,即修改现有代码:

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 协议由 同时 拥有 pushpop 函数。

那么,为什么不只是添加将多个多方法组合在一起的功能呢?有一个纯粹实用的原因,这也是我在介绍性句子中使用“高效”一词的原因:性能。

Clojure 是一种托管语言。也就是说,它专门设计为在另一种语言的平台上运行。事实证明,您想让 Clojure 运行在的任何平台(JVM、CLI、ECMAScript、Objective-C)都具有专门的高性能支持,可以仅基于第一个参数的类型进行调度。Clojure 多方法则会根据所有参数的任意属性进行调度。

因此,协议限制了您仅在第一个参数以及其类型(或作为特殊情况在nil上)上进行调度。

这并不是针对协议本身的限制,而是出于实用选择,以获得底层平台的性能优化。特别是,它意味着协议与 JVM/CLI 接口具有非常简单的映射,这使它们非常快速。事实上,足够快,可以将 Clojure 的那些当前在 Java 或 C# 中编写的部分重写为 Clojure。

从版本 1.0 开始,Clojure 实际上已经拥有协议:Seq 就是一个协议。但是,在 1.2 之前,您无法在 Clojure 中编写协议,而必须在托管语言中编写。


非常感谢您提供如此详尽的答案,但能否请您澄清一下关于 Ruby 的观点。我想,Ruby 具有在任何类(例如 String、Fixnum)中重新定义方法的能力,类似于 Clojure 中的 defprotocol。 - defhlt
3
一个关于表达问题(Expression Problem)和Clojure协议的优秀文章 - http://www.ibm.com/developerworks/library/j-clojure-protocols/ - navgeet
很抱歉在这么老的答案上发表评论,但您能否详细说明为什么扩展和接口(C#/Java)不是解决表达式问题的好方法? - Onorio Catenacci
Java在这里所说的意义上没有扩展名。 - user100464
Ruby有改进,使得猴子补丁已经过时。 - Marcin Bilski
(顺便问一下:你在示例代码中使用了哪种编程语言?) - ThomasH

74

我认为,将协议与面向对象语言(如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" 参数。
协议的一个非常强大和有用的特性是,即使对象最初并未设计支持该协议,您仍然可以将其扩展到对象上。例如,您可以将上面的协议扩展到 java.lang.String 类(如果您愿意的话)。
(extend-protocol my-protocol
  java.lang.String
    (foo [x] (.length x)))

(foo "Hello")
=> 5

3
就像面向对象语言中隐式的“this”参数一样,我注意到在Clojure代码中,传递给协议函数的变量通常也被称为“this”。 - Kris

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接