多方法如何解决命名空间问题?

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”是一种带有多个“分支”的方法,它根据参数类型的不同而有所不同。我们为我们感兴趣的每种参数类型(狗和猫)实现“bark”。运行时,我们能够遍历动物列表并动态选择适当的分支。
(2)它解决了命名空间问题:
=== 在 animal.code 文件中 ===
   - Animals can "bark"

=== 在文件树.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)使用多方法的狗和猫

=== 在 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)相关。第二个例子没有这个属性,这就是我不理解多方法如何解决命名空间问题的原因。
为什么多方法对动物和树不起作用?===在文件animal.code中===
   - define generic function bark(Animal a)

=== 在文件树.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?

在这种情况下,通用函数应该在哪里定义?它应该定义在顶层,在animal和tree之上吗?将动物和树的叫声视为同一通用函数的两个方法是不合理的,因为这两个函数在概念上是不同的。
据我所知,我还没有找到任何解决此问题的先前工作。我查看了Clojure多方法和CLOS多方法,它们都有相同的问题。我抱着希望能够解决这个问题或者通过令人信服的论点证明这实际上并不是一个真正的问题。
如果需要澄清问题,请告诉我。我认为这是一个相当微妙(但重要)的问题。
感谢sanity、Rainer、Marcin和Matthias的回复。我理解了你们的回答,并完全同意动态分派和命名空间解析是两个不同的概念。CLOS没有混淆这两个概念,而传统的消息传递OO则是如此。这也允许直接扩展到多继承的多方法。
我的问题具体涉及当混淆是“可取”的情况。以下是我想表达的示例。
=== 文件:XYZ.code ===
define class XYZ :
   define get-x ()
   define get-y ()
   define get-z ()

=== file: POINT.code ===

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

=== file: 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)

=== file: 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)

==== file: 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()。
虽然这种显式的方法更清晰,而且容易推广到多分派和多继承,但是滥用分派来解决命名空间问题是面向对象消息传递中非常方便的特性。
我个人认为,我的代码有98%可以使用单分派和单继承来充分表达。我更多地使用分派来解决命名空间问题,而不是使用多分派,因此我不愿放弃它。
有没有一种方法可以让我在多方法设置中避免需要显式限定函数调用呢?
似乎共识是:
  • 多方法解决了分派问题,但没有解决命名空间问题。
  • 在概念上不同的函数应该具有不同的名称,并且用户应该手动限定它们。
我认为,在单继承单分派足够的情况下,面向对象消息传递比通用函数更方便。
这听起来像是一个开放性的研究问题。如果一种语言提供了一种既可以用于多方法,又可以用于命名空间解析的机制,那么这是否是一个理想的功能呢?
我喜欢通用函数的概念,但目前感觉它们被优化为在使“非常困难的事情变得不那么困难”的同时,牺牲了使“琐碎的事情稍微有点烦人”的代价。由于大多数代码都是琐碎的,所以我仍然认为这是一个值得解决的问题。

3
你发现了什么问题?这种方法应该对两种类型都有效。 - Marcin
3
事实上,情况恰恰相反:多方法可以正确处理命名空间。特别是它们使多重继承起作用。想象一下当您发现需要定义一个Treant类来模拟既是树(Tree)又是动物(Animal)的东西时会发生什么。使用多方法并没有问题,因为它们与类独立地进行命名空间分隔。而使用类命名空间方法,则会导致冲突。 - Matthias Benkard
7个回答

21

动态分派和命名空间解析是两个不同的概念。在许多对象系统中,类也用于命名空间。此外请注意,通常类和命名空间都与文件相关联。因此这些对象系统混淆了至少三件事:

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

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

  • 类不形成命名空间
  • 一般函数和方法不属于类,因此不在类内定义
  • 一般函数被定义为顶级函数,因此不是嵌套或本地的
  • 一般函数的标识符是符号
  • 符号有自己的命名空间机制称为包
  • 一般函数是'开放式'的。可以随时添加或删除方法
  • 一般函数是第一类对象
  • 方法是第一类对象
  • 类和一般函数也不与文件混淆。您可以在一个文件或尽可能多的文件中定义多个类和多个一般函数。您还可以从运行代码(因此不与文件关联)或类似于REPL(读取评估打印循环)的东西中定义类和方法。

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 指的是包 ANIMAL 中名为 BARK 的符号。请注意,有两个冒号。
  • AINMAL:BARK 指的是在包 ANIMAL导出的符号 BARK。请注意,只有一个冒号。导出导入使用 是为包及其符号定义的机制。因此,它们独立于类和通用函数,但可用于结构化命名这些符号的名称空间。

更有趣的情况是当多态方法实际上在通用函数中被使用时:

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

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

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

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

动机:

泛型函数是在80年代由Xerox PARC(用于Common LOOPS)和Symbolics(用于New Flavors)的开发人员添加到Lisp中的。一个人想要摆脱额外的调用机制(消息传递),并将分派带到普通(顶层)函数中。New Flavors有单一分派,但是具有多个参数的泛型函数。对Common LOOPS的研究随后带来了多重分派。然后用标准化的CLOS取代了New Flavors和Common LOOPS。这些思想随后被带到其他语言中,如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-XGET-Y符号导入到某个命名空间中 - 在必要的地方。

还有其他与Lisp/CLOS不同并试图支持多方法的语言:

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


因为对CLOS哲学的清晰简洁的解释,我已经点赞了。然而,我对(在我看来常见的)需要合并的情况很感兴趣。我已经在原帖中添加了澄清。 - Patrick Li
这种权衡是绝对必要的吗?如果我想使用多方法,我就必须放弃命名空间解析吗?这是我的问题的核心,是否有任何关于多方法的研究都具备了消息传递的所有便利性? - Patrick Li
@user1156849:你可以看看像MultiJava这样的语言。或者C#,看看它们如何支持多方法。虽然它们可能不提供泛型函数的所有便利性。 - Rainer Joswig

9

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

简而言之,“概念上不同”的函数将始终具有不同的名称或存在于不同的命名空间中。


3

通用函数应该为其所实现方法的所有类执行相同的“动作”。

在动物/树木的“树皮”情况下,动物的动作是“发出声音”,而在树木情况下,则是建立环境屏蔽。

英语恰好将它们都称为“bark”,这只是一种语言巧合。

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


2
由于XYZ的get-x()与GENE的get-x()没有概念关联,因此它们被实现为单独的通用函数。
不错。但是由于它们的arglist相同(只需将对象传递给方法),因此您“可以”将它们实现为同一通用函数上的不同方法。
添加方法到通用函数时唯一的约束是方法的arglist必须与通用函数的arglist匹配。
更一般地说,方法必须具有相同数量的必需和可选参数,并且必须能够接受与通用函数指定的任何&rest或&key参数对应的任何参数。
没有约束函数必须在概念上相关。大多数情况下它们是(覆盖超类等),但是它们确实不必如此。
尽管即使这个限制(需要相同的arglist)有时也会有所限制。如果您查看Erlang,则函数具有arity,并且您可以定义具有不同arity(具有相同名称和不同arglists的函数)的多个函数。然后,某种分派负责调用正确的函数。我喜欢这个。在lisp中,我认为这将映射到通用函数接受具有不同arglists的方法。也许这是MOP中可配置的内容?
尽管在这里阅读更多内容后,似乎关键字参数可以让程序员通过在不同的方法中使用不同的键来改变它们的参数数量,从而实现通用函数封装具有完全不同arity的方法:
方法可以通过具有&rest参数,具有相同&key参数或指定&allow-other-keys以及&key来“接受”其通用函数中定义的&key和&rest参数。方法还可以指定在通用函数参数列表中找不到的&key参数-当调用通用函数时,将接受通用函数或任何适用的方法指定的任何&key参数。
还要注意,在您的“树有树皮”,“狗吠”的示例中,存储在通用函数中的不同方法执行概念上不同的操作。在定义树类时,您将为树皮插槽设置自动getter和setter方法。在定义狗类时,您将在狗类型上定义一个吠叫方法,该方法实际上进行吠叫。这两种方法都存储在#'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> 

我倾向于支持这种“语法的双重性”,或者说是特征的模糊化等等。我认为并不是所有泛型函数的方法都必须在概念上相似。这只是我的一个指导方针。如果在英语语言中出现了一种语言交互(如bark作为名词和动词),那么有一个能够优雅地处理这种情况的编程语言会很好。


谢谢回复。我也喜欢你所说的“语法二元性”。尽管它将命名空间解析与分派混为一谈,但它是消息传递系统的一个重要优势。你提供的方法之所以对于通用函数来说很麻烦,是因为每次我为某个任意类声明新的getter函数时,如果我想允许它以这种方式使用,我必须确保相应地在最高级别声明一个通用函数。 - Patrick Li
@user1156849,“语法的二元性”这个术语来自Doug Hoyte的《Let Over Lambda》一书。实际上,CLOS在幕后确实正在做你所说的事情。当defclass表单具有一个:accessor参数的插槽时,它会自动编写添加getter到通用函数的代码。这是我最喜欢的CLOS功能之一。程序员不需要重复编写getter/setter样板代码。 - Clayton Stanley

2

消息传递的面向对象编程并不能一般地解决你所说的命名空间问题。在具有结构类型系统的面向对象语言中,只要它们具有相同的类型,就不会区分AnimalTree中的bark方法。这只是因为流行的面向对象语言使用了名义类型系统(例如Java),才会出现这种情况。


嗨Asumu,你能详细说明一下你的意思吗?显然Java解决了命名空间问题,因为类型系统允许编译器静态确定调用哪个函数。在具有结构类型系统的动态语言中(例如Python / Ruby),编译器不区分Animal:bark和Tree:bark。但是在运行时,由于调度机制,您仍然可以确保调用正确的方法。换句话说,如果像Java那样使用它,则仍将像Java中的工作一样。缺乏名义类型系统并不会阻止这种情况发生。 - Patrick Li
1
是的,将调用正确的实现。但是,假设您在代码的某个部分具有一个不变量,即bark方法只应在动物上调用。Java的类型系统可以让您强制执行此操作。但是,未经过类型检查的语言或结构化类型的语言(例如OCaml)无法在编译时区分。因此,这是一个接口问题。 - Asumu Takikawa
我现在明白你的意思了。是的,在没有名义类型系统的情况下,消息传递方案可能会导致一些奇怪的错误。但它仍然非常方便。在Python/Ruby的情况下,我已经放弃了许多编译时保证,因此再牺牲另一个并不过分。 - Patrick Li

1
这是许多编程语言都试图以方便的方式解决分派表放置的一般问题。
在面向对象编程中,我们将其放在类定义中(这样我们就有了类型+函数具体化,加上继承,它可以提供所有架构问题的乐趣)。
在函数式编程中,我们将其放在分派函数内部(我们有一个共享的集中表,通常不会太糟糕,但也不完美)。
我喜欢基于接口的方法,当我可以单独创建虚拟表而不受任何数据类型和任何共享函数定义的限制时(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)。此外,轻松实现单独的实现以进行测试而无需声明新的数据类型。

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

干杯。


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