【问题标题】:How do multimethods solve the namespace issue?多方法如何解决命名空间问题?
【发布时间】:2012-03-22 03:04:48
【问题描述】:

我正在研究编程语言设计,我对如何用多方法泛型函数范式替换流行的单调度消息传递 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) 相关。第二个例子没有这个属性,这就是为什么我不明白multimethods是如何解决命名空间问题的。

(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% 的代码都使用单调度和单继承来充分表达。我使用 dispatch 进行命名空间解析的便利性比使用多分派要多得多,所以我不愿意放弃它。

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


似乎共识是这样的

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

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

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

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

【问题讨论】:

  • 您在这里看到了什么问题?该方法应该适用于这两种类型。
  • 事实上,恰恰相反:多方法获得正确的命名空间。特别是,它们使多重继承工作。想想当你发现你需要定义一个树人和动物模型的树人类时会发生什么。使用多方法没有问题,因为它们的命名空间独立于类。使用类命名空间方法会产生冲突。

标签: clojure lisp common-lisp language-design multimethod


【解决方案1】:

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

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

【讨论】:

    【解决方案2】:

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

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

    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 指的是 ANIMAL 包中名为 BARK 的符号。请注意,有两个冒号。
    • AINMAL:BARK 指的是包ANIMAL 中的exported 符号BARK。注意只有一个冒号。 Exportingimportingusing 是为包及其符号定义的机制。因此它们独立于类和泛型函数,但它可用于为命名它们的符号构建命名空间。

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

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

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

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

    动机:

    通用函数在 80 年代由 Xerox PARC(用于 Common LOOPS)和 Symbolics 用于 的开发人员添加到 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-XGET-Y 符号导入到某个命名空间中——如果需要的话。

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

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

    【讨论】:

    • 赞成对 CLOS 哲学进行清晰简洁的解释。但是,我对需要合并的(IMO 常见)情况感兴趣。我在原帖中做了澄清。
    • 这种权衡是绝对必要的吗?如果我想要多方法,我是否必须放弃命名空间解析?这是我的问题的核心,是否有任何关于消息传递便利的多方法研究?
    • @user1156849:你可以看看 MultiJava 之类的语言。全部或 C#,看看它们如何支持多方法之类的东西。不过,它们可能无法提供通用函数的所有便利。
    【解决方案3】:

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

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

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

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

    【讨论】:

      【解决方案4】:

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

      【讨论】:

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

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

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

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

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

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

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

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

      方法可以“接受”在其泛型中定义的 &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 的指导方针。如果发生英语语言中的语言交互(吠叫作为名词和动词),那么有一种可以优雅地处理这种情况的编程语言是很好的。

      【讨论】:

      • 感谢您的回复。我也喜欢你所说的这种“语法二重性”。尽管它将命名空间解析与调度混为一谈,但它是消息传递系统的主要优势。您给出的方法对于泛型函数很麻烦的原因是,每次我为某个任意类声明一个新的 getter 函数时,如果我想允许它,我必须确保在最顶层相应地声明一个泛型函数以这种方式使用。
      • @user1156849 “双重语法”一词来自 Doug Hoyte 的 Let Over Lambda 书。 CLOS 实际上是在做你所说的。当 defclass 表单有一个带有 :accessor 参数的槽时,它会自动编写将 getter 添加到通用函数的代码。这是我最喜欢的 CLOS 功能之一。没有程序员必须重复编写的 getter/setter 样板代码。
      【解决方案6】:

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

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

      函数式语言,不是我的强项,我用 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
      

      干杯。

      【讨论】:

        【解决方案7】:

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

        在 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)如果您愿意

        为了测试目的,在不声明新数据类型的情况下单独实现也变得微不足道。

        【讨论】:

          猜你喜欢
          • 2012-02-15
          • 2018-07-03
          • 1970-01-01
          • 1970-01-01
          • 2011-10-17
          • 1970-01-01
          • 2010-09-05
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多