32

我正在研究编程语言设计,我对如何用多方法泛型函数范式替换流行的单调度消息传递 OO 范式感兴趣。在大多数情况下,这似乎很简单,但我最近陷入困境并希望得到一些帮助。

在我看来,消息传递 OO 是一种解决两个不同问题的解决方案。我在下面的伪代码中详细解释了我的意思。

(1) 解决调度问题:

=== 在文件 animal.code ===

   - Animals can "bark"
   - Dogs "bark" by printing "woof" to the screen.
   - Cats "bark" by printing "meow" to the screen.

=== 在文件 myprogram.code ===

import animal.code
for each animal a in list-of-animals :
   a.bark()

在这个问题中,“bark”是一种具有多个“分支”的方法,这些“分支”的操作取决于参数类型。我们为每个我们感兴趣的参数类型(狗和猫)实现一次“吠叫”。在运行时,我们能够遍历动物列表并动态选择要采用的适当分支。

(2) 解决命名空间问题:

=== 在文件 animal.code ===

   - Animals can "bark"

=== 在文件 tree.code ===

   - Trees have "bark"

=== 在文件 myprogram.code ===

import animal.code
import tree.code

a = new-dog()
a.bark() //Make the dog bark

…

t = new-tree()
b = t.bark() //Retrieve the bark from the tree

在这个问题中,“bark”实际上是两个概念上不同的函数,它们恰好具有相同的名称。参数的类型(无论是狗还是树)决定了我们实际指的是哪个函数。


多方法优雅地解决了问题 1。但我不明白他们如何解决问题 2。例如,上面两个示例中的第一个可以直接转换为多方法:

(1) Dogs and Cats 使用多方法

=== 在文件 animal.code ===

   - define generic function bark(Animal a)
   - define method bark(Dog d) : print("woof")
   - define method bark(Cat c) : print("meow")

=== 在文件 myprogram.code ===

import animal.code
for each animal a in list-of-animals :
   bark(a)

关键是方法 bark(Dog) 在概念上与 bark(Cat) 相关。第二个例子没有这个属性,这就是为什么我不明白多方法是如何解决命名空间问题的。

(2) 为什么多方法对动物和树木不起作用

=== 在文件 animal.code ===

   - define generic function bark(Animal a)

=== 在文件 tree.code ===

   - define generic function bark(Tree t)

=== 在文件 myprogram.code ===

import animal.code
import tree.code

a = new-dog()
bark(a)   /// Which bark function are we calling?

t = new-tree
bark(t)  /// Which bark function are we calling?

在这种情况下,应该在哪里定义泛型函数?它应该在动物和树之上的顶层定义吗?将 bark for animal 和 tree 视为相同泛型函数的两种方法是没有意义的,因为这两个函数在概念上是不同的。

据我所知,我还没有找到解决这个问题的任何过去的工作。我看过 Clojure 多方法和 CLOS 多方法,它们有同样的问题。我正在祈祷,希望能找到一个优雅的解决方案,或者一个有说服力的论据,说明为什么它在实际编程中实际上不是问题。

如果问题需要澄清,请告诉我。我认为这是一个相当微妙(但很重要)的观点。


感谢理智、Rainer、Marcin 和 Matthias 的回复。我理解您的回复并完全同意动态调度和命名空间解析是两件不同的事情。CLOS 不会将这两个想法混为一谈,而传统的消息传递 OO 则可以。这也允许将多方法直接扩展到多继承。

我的问题具体是在合意的情况下。

以下是我的意思的一个例子。

=== 文件:XYZ.code ===

define class XYZ :
   define get-x ()
   define get-y ()
   define get-z ()

=== 文件:POINT.code ===

define class POINT :
   define get-x ()
   define get-y ()

=== 文件:GENE.code ===

define class GENE :
   define get-x ()
   define get-xx ()
   define get-y ()
   define get-xy ()

==== 文件:my_program.code ===

import XYZ.code
import POINT.code
import GENE.code

obj = new-xyz()
obj.get-x()

pt = new-point()
pt.get-x()

gene = new-point()
gene.get-x()

由于命名空间解析与分派的混合,程序员可以天真地对所有三个对象调用 get-x()。这也是完全明确的。每个对象都“拥有”自己的一组方法,因此程序员的意思不会混淆。

将此与多方法版本进行对比:


=== 文件:XYZ.code ===

define generic function get-x (XYZ)
define generic function get-y (XYZ)
define generic function get-z (XYZ)

=== 文件:POINT.code ===

define generic function get-x (POINT)
define generic function get-y (POINT)

=== 文件:GENE.code ===

define generic function get-x (GENE)
define generic function get-xx (GENE)
define generic function get-y (GENE)
define generic function get-xy (GENE)

==== 文件:my_program.code ===

import XYZ.code
import POINT.code
import GENE.code

obj = new-xyz()
XYZ:get-x(obj)

pt = new-point()
POINT:get-x(pt)

gene = new-point()
GENE:get-x(gene)

因为 XYZ 的 get-x() 与 GENE 的 get-x() 没有概念上的关系,所以它们被实现为单独的通用函数。因此,最终程序员(在 my_program.code 中)必须明确限定 get-x() 并告诉系统他实际上要调用哪个get-x()。

确实,这种显式方法更清晰,更容易推广到多重分派和多重继承。但是使用(滥用)调度来解决命名空间问题是消息传递 OO 的一个非常方便的特性。

我个人觉得我自己的 98% 的代码都使用单调度和单继承来充分表达。与使用多重分派相比,我更倾向于使用分派来进行命名空间解析,因此我不愿意放弃它。

有没有办法让我两全其美?如何避免在多方法设置中明确限定我的函数调用?


似乎共识是

  • 多方法解决了调度问题,但不攻击命名空间问题。
  • 概念上不同的函数应该有不同的名称,并且应该期望用户手动限定它们。

然后我相信,在单继承单调度就足够的情况下,消息传递 OO 比泛型函数更方便。

这听起来像是开放研究。如果一种语言要为多方法提供一种也可用于命名空间解析的机制,那会是一个理想的特性吗?

我喜欢泛型函数的概念,但目前觉得它们经过优化,可以让“非常难的事情变得不那么难”,而牺牲了“琐碎的事情有点烦人”。由于大多数代码都是微不足道的,我仍然认为这是一个值得解决的问题。

4

7 回答 7

21

动态调度和命名空间解析是两个不同的东西。在许多对象系统中,类也用于命名空间。另请注意,通常类和命名空间都与文件相关联。所以这些对象系统至少融合了三件事:

  • 类定义及其插槽和方法
  • 标识符的命名空间
  • 源代码的存储单元

Common Lisp 及其对象系统 (CLOS) 的工作方式不同:

  • 类不形成命名空间
  • 泛型函数和方法不属于类,因此不在类内部定义
  • 泛型函数被定义为顶级函数,因此不是嵌套的或本地的
  • 泛型函数的标识符是符号
  • 符号有自己的命名空间机制,称为包
  • 通用功能是“开放的”。可以随时添加或删除方法
  • 泛型函数是一流的对象
  • 数学是一流的对象
  • 类和泛型函数也不与文件混为一谈。您可以在一个文件或任意多个文件中定义多个类和多个通用函数。您还可以从正在运行的代码(因此不绑定到文件)或类似 REPL(读取 eval 打印循环)之类的东西中定义类和方法。

CLOS 风格:

  • 如果一个功能需要动态调度并且功能密切相关,那么使用一个具有不同方法的通用函数
  • 如果有许多不同的功能,但具有共同的名称,请不要将它们放在同一个通用功能中。创建不同的通用函数。
  • 具有相同名称但名称在不同包中的通用函数是不同的通用函数。

例子:

(defpackage "ANIMAL" (:use "CL")) 
(in-package "ANIMAL")

(defclass animal () ())
(deflcass dog (animal) ())
(deflcass cat (animal) ()))

(defmethod bark ((an-animal dog)) (print 'woof))
(defmethod bark ((an-animal cat)) (print 'meow)) 

(bark (make-instance 'dog))
(bark (make-instance 'dog))

请注意,类ANIMAL和包ANIMAL具有相同的名称。但这不是必须的。这些名称没有任何联系。DEFMETHOD 隐式地创建了一个相应的泛型函数。

如果您添加另一个包(例如GAME-ANIMALS),则BARK通用功能将有所不同。除非这些包是相关的(例如一个包使用另一个包)。

从不同的包(Common Lisp 中的符号命名空间),可以调用这些:

(animal:bark some-animal)

(game-animal:bark some-game-animal)

符号具有语法

PACKAGE-NAME::SYMBOL-NAME

如果包与当前包相同,则可以省略。

  • ANIMAL::BARK指的BARK是包中命名的符号ANIMAL。注意有两个冒号。
  • AINMAL:BARK指的是包中导出的符号。注意只有一个冒号。导出导入使用是为包及其符号定义的机制。因此它们独立于类和泛型函数,但它可用于为命名它们的符号构建名称空间。BARKANIMAL

更有趣的情况是在泛型函数中实际使用多方法时:

(defmethod bite ((some-animal cat) (some-human human))
  ...)

(defmethod bite ((some-animal dog) (some-food bone))
  ...)

上面使用了CATHUMANDOGBONE。泛型函数应该属于哪个类?特殊的命名空间会是什么样子?

由于泛型函数对所有参数进行分派,因此将泛型函数与特殊命名空间混为一谈并使其成为单个类中的定义是没有直接意义的。

动机:

通用函数在 80 年代由Xerox PARC(用于Common LOOPS)和Symbolics for New Flavors的开发人员添加到 Lisp 。人们想摆脱一种额外的调用机制(消息传递)并将调度带到普通(顶级)函数。New Flavors 具有单一调度,但具有多个参数的通用函数。对 Common LOOPS 的研究带来了多重调度。新口味和通用循环随后被标准化的 CLOS 取代。然后这些想法被带到了像Dylan这样的其他语言中。

由于问题中的示例代码没有使用任何泛型函数必须提供的东西,看起来人们必须放弃一些东西。

当单次分派、消息传递和单次继承就足够了,那么泛型函数可能看起来像是倒退了一步。如前所述,这样做的原因是不想将所有类型相似的命名功能放入一个通用函数中。

什么时候

(defmethod bark ((some-animal dog)) ...)
(defmethod bark ((some-tree oak)) ...)

看起来相似,它们是两个概念上不同的动作。

还有一点:

(defmethod bark ((some-animal dog) tone loudness duration)
   ...)

(defmethod bark ((some-tree oak)) ...)

现在突然之间,同名泛型函数的参数列表看起来不同了。应该允许它成为一种通用功能吗?如果不是,我们如何调用BARK具有正确参数的事物列表中的各种对象?

在真正的 Lisp 代码中,泛型函数通常看起来要复杂得多,带有几个必需的和可选的参数。

在 Common Lisp 中,泛型函数也不仅仅只有一个方法类型。有不同类型的方法和各种组合它们的方法。只有当它们真正属于某个通用功能时,才有意义将它们组合起来。

由于泛型函数也是第一类对象,它们可以被传递、从函数返回并存储在数据结构中。在这一点上,通用函数对象本身很重要,不再是它的名字了。

对于我有一个对象的简单情况,它具有 x 和 y 坐标并且可以充当一个点,我将从一个POINT类(可能作为一些 mixin)继承对象的类。然后我会将GET-XandGET-Y符号导入到某个命名空间中——在必要时。

还有其他语言与 Lisp/CLOS 更不同,并且尝试(ed)支持多方法:

似乎有很多尝试将它添加到 Java 中。

于 2012-03-04T08:35:44.517 回答
9

您的“为什么多方法不起作用”的示例假定您可以在同一语言命名空间中定义两个同名的泛型函数。通常情况并非如此;例如,Clojure 多方法明确属于一个命名空间,因此如果您有两个具有相同名称的通用函数,则需要明确您使用的是哪个。

简而言之,“概念上不同”的函数要么总是有不同的名称,要么存在于不同的命名空间中。

于 2012-03-04T08:29:19.183 回答
3

泛型函数应该对所有实现其方法的类执行相同的“动词”。

在动物/树“树皮”的情况下,动物动词是“执行声音动作”,而在树的情况下,我猜它是 make-environment-shield。

英语恰好称它们为“树皮”只是语言上的巧合。

如果您遇到多个不同的 GF(通用函数)确实应该具有相同名称的情况,那么使用命名空间来分隔它们(可能)是正确的事情。

于 2012-03-04T11:45:14.347 回答
2

一般来说,消息传递 OO 不能解决您所说的命名空间问题。具有结构类型系统的 OO 语言不会区分barkanAnimal或 a中的方法,Tree只要它们具有相同的类型。只是因为流行的 OO 语言使用名义类型系统(例如,Java),它看起来是这样的。

于 2012-03-04T17:21:46.423 回答
2

因为 XYZ 的 get-x() 与 GENE 的 get-x() 没有概念上的关系,所以它们被实现为单独的通用函数

当然。但是由于它们的 arglist 是相同的(只是将对象传递给方法),因此您“可以”将它们实现为同一个泛型函数上的不同方法。

将方法添加到泛型函数时的唯一约束是方法的 arglist 与泛型函数的 arglist 匹配。

更一般地,方法必须具有相同数量的必需参数和可选参数,并且必须能够接受与泛型函数指定的任何 &rest 或 &key 参数相对应的任何参数。

没有限制功能必须在概念上相关。大多数时候它们是(覆盖超类等),但它们当然不必如此。

尽管即使是这种约束(需要相同的 arglist)有时也似乎受到限制。如果您查看 Erlang,函数具有 arity,并且您可以定义多个具有相同名称但具有不同 arity 的函数(具有相同名称和不同 arglist 的函数)。然后一种调度负责调用正确的函数。我喜欢这个。在 lisp 中,我认为这将映射为具有一个通用函数接受具有不同参数列表的方法。也许这是在 MOP 中可配置的东西?

虽然在这里阅读更多,但似乎关键字参数可能允许程序员通过在不同方法中使用不同的键来改变它们的参数数量,从而实现具有完全不同数量的通用函数封装方法:

一个方法可以“接受”在其通用函数中定义的 &key 和 &rest 参数,方法是具有 &rest 参数,具有相同的 &key 参数,或者通过指定 &allow-other-keys 和 &key。方法还可以指定在泛型函数的参数列表中找不到的 &key 参数——当调用泛型函数时,将接受泛型函数指定的任何 &key 参数或任何适用的方法。

另请注意,这种模糊(存储在通用函数中的不同方法在概念上做不同的事情)发生在您的“树有树皮”、“狗吠”示例的幕后。在定义树类时,您需要为树皮槽设置一个自动 getter 和 setter 方法。在定义 dog 类时,您将在实际执行吠叫的 dog 类型上定义一个 bark 方法。这两种方法都存储在 #'bark 通用函数中。

由于它们都包含在同一个通用函数中,因此您可以以完全相同的方式调用它们:

(bark tree-obj) -> Returns a noun (the bark of the tree)
(bark dog-obj) -> Produces a verb (the dog barks)

作为代码:

CL-USER> 
(defclass tree ()
  ((bark :accessor bark :initarg :bark :initform 'cracked)))
#<STANDARD-CLASS TREE>
CL-USER> 
(symbol-function 'bark)
#<STANDARD-GENERIC-FUNCTION BARK (1)>
CL-USER> 
(defclass dog ()
  ())
#<STANDARD-CLASS DOG>
CL-USER> 
(defmethod bark ((obj dog))
  'rough)
#<STANDARD-METHOD BARK (DOG) {1005494691}>
CL-USER> 
(symbol-function 'bark)
#<STANDARD-GENERIC-FUNCTION BARK (2)>
CL-USER> 
(bark (make-instance 'tree))
CRACKED
CL-USER> 
(bark (make-instance 'dog))
ROUGH
CL-USER> 

我倾向于支持这种“语法二重性”或特征模糊等。而且我不认为泛型函数上的所有方法都必须在概念上相似。这只是 IMO 的指导方针。如果发生英语中的语言交互(吠叫作为名词和动词),那么有一种可以优雅地处理这种情况的编程语言会很好。

于 2012-03-04T17:41:39.210 回答
1

您正在使用多个概念并将它们混合在一起,例如:命名空间、全局通用函数、局部通用函数(方法)、方法调用、消息传递等。

在某些情况下,这些概念可能在语法上重叠,难以实施。在我看来,您的脑海中也混杂了很多概念。

函数式语言,不是我的强项,我用 LISP 做了一些工作。

但是,其中一些概念被用于其他范例,例如过程和对象(类)方向。您可能想检查这些概念是如何实现的,然后再返回到您自己的编程语言。

例如,我认为非常重要的事情是使用命名空间(“模块”),作为与过程编程不同的概念,并避免标识符冲突,正如你提到的那样。像您这样具有命名空间的编程语言将是这样的:

=== 在文件 animal.code ===

define module animals

define class animal
  // methods doesn't use "bark(animal AANIMAL)"
  define method bark()
  ...
  end define method
end define class

define class dog
  // methods doesn't use "bark(dog ADOG)"
  define method bark()
  ...
  end define method
end define class

end define module

=== 在文件 myprogram.code ===

define module myprogram

import animals.code
import trees.code

define function main
  a = new-dog()
  a.bark() //Make the dog bark

  …

  t = new-tree()
  b = t.bark() //Retrieve the bark from the tree
end define function main

end define module

干杯。

于 2012-04-30T23:16:16.113 回答
1

这是许多编程语言试图以方便的方式解决调度表的一般问题。

在 OOP 的情况下,我们将其放入类定义中(我们以这种方式拥有类型+函数的具体化,加上继承,它提供了架构问题的所有乐趣)。

对于 FP,我们将它放在调度函数中(我们有一个共享的集中式表,这通常不是那么糟糕,但也不是完美的)。

我喜欢基于接口的方法,因为我可以单独创建任何数据类型和任何共享函数定义(Clojure 中的协议)的虚拟表。

在 Java 中(对不起)它看起来像这样:

假设ResponseBody是一个接口。

public static ResponseBody create(MediaType contentType,
     long contentLength, InputStream content) {

    return new ResponseBody() {
      public MediaType contentType() {
        return contentType;
      }

      public long contentLength() {
        return contentLength;
      }

      public BufferedSource source() {
        return streamBuffered(content);
      }
    };
}

为这个特定create功能创建了虚拟表。这完全解决了命名空间问题,如果你愿意,你也可以有一个非集中式的基于类型的调度(OOP)。

拥有一个单独的实现而不为测试目的声明新的数据类型也变得微不足道。

于 2018-01-13T23:37:37.617 回答