Programming Languages: Application and Interpretation【译10】

Programming Languages: Application and Interpretation【译10】

lotuclotuc

审校:@MrMathematica

原文:PLAI 第二版

GitHub:PLAI-cn

GitBook:PLAI-cn

翻译声明见 Github 仓库


10 对象

一门语言将函数作为值,就最为自然地提供了表示计算的最小单位。假设程序员需要把某个函数f参数化。任何语言都会允许把被动的数据——比如数字和字符串——用作函数参数。但是如果主动的数据——可以计算出结果的数据,比如说响应某种信息——也可以用作参数,这个想法就很有吸引力了。此外,作为参数传给f的函数——假设它遵从词法作用域——可以使用它的调用者提供的数据,而这些数据无需暴露给f,这给安全和隐私提供了基石。正因如此,遵从词法作用域的函数成了设计很多安全编程技术的核心。

函数是好的东西,但是它太过简洁。有时候我们希望多个函数闭合于同一份共享的数据;共享的意义在于,当这份数据被其中某个函数修改时,我们希望其他函数能够看到修改后的结果。在这种情况下,不可能仅仅发送一个函数作为参数;发送一组函数更有用。接收方则需要能够从这组函数中提取出各个函数。这么一组函数,外加从中选取函数的方法,便是对象(object)的精髓。我们已经学过了函数(第七章)和可变结构(第八章),现在正是学习对象的最佳时机——同时前面学习的递归(第九章)也将派上用场。

我们来把此概念的对象添加到自己的语言中。然后我们将不断改进和扩展它,从而探究关于对象系统设计的各种维度。首先展示一下怎么将对象加入到核心语言中,但是由于想要快速构建许多不同的想法,我们很快就会转向基于去语法糖的策略。使用哪种方式取决于你是否认为理解它们对理解你的语言的本质至关重要。判断这点的一种方法是,看你的去语法糖过程变得有多复杂,以及,在给核心语言添加一些关键特性后,去语法糖的复杂度能否大幅降低。

我不能指望这里能讨论关于对象系统的一切,你可以阅读Éric Tanter的《面对对象编程语言:应用和解释》 来了解更多细节以及我们没有涉及到的主题。

10.1 不支持继承的对象

最简单的对象概念——可能是唯一所有谈论对象的人都能认同的定义——对象是:

  • 值,
  • 够将一些名字映射成
  • 其它东西:值或者“方法(methods)”

从简约的角度来看,方法似乎就是函数,由于我们的语言已经实现了函数,我们先忽略它们之间的区别。

之后我们会发现“方法”和函数极其相似,但是在某些重要的方面有所不同:调用方式,还有其内部所绑定的东西。

10.1.1 核心语言中的对象

让我们往支持一等函数的核心语言(译注,即第七章中实现的语言)中加入简单的对象。显然我们必须扩展值的概念:

(define-type Value
  [numV (n : number)]
  [closV (arg : symbol) (body : ExprC) (env : Env)]
  [objV (ns : (listof symbol)) (vs : (listof Value))])

还要扩展语法,支持对象的构造表达式:

[objC (ns : (listof symbol)) (es : (listof ExprC))]
这里语言的设计中已做了一个抉择。在某些语言(如JavaScript)中,程序员可以直接写出对象。这是个非常受欢迎的概念,JavaScript中该功能的部分语法成了网络标准——JSON。在其他语言(如Java)中,对象只能通过调用某个类的构造函数来创建。这两种设计我们都可以模拟。要模拟后一种语言模型,我们必须遵从后文讨论到去语法糖中提出的程式化惯例,只在特定位置直接写出对象。

对这个对象表达式的求值很简单:对每个表达式位置都求值就行:

[objC (ns es) (objV ns (map (lambda (e)
                              (interp e env))
                            es))]

不幸的是,我们无法实际使用对象,因为无法获取其内容。为此,我们添加一个操作来提取成员:

[msgC (o : ExprC) (n : symbol)]  ;消息,核心语言

其行为就是直接:

[msgC (o n) (lookup-msg n (interp o env))]

练习

实现函数
; lookup-msg : symbol * Value -> Value
第二个参数的类型应该是objV。

原则上,msgC可以被用于获取任意类型的成员,但是简单起见,我们假设成员中只有函数。要使用某个成员,需要给其传入参数值。在核心语言的语法中这么写有点笨拙,所以我们假设去语法糖过程降低了语法复杂性:表层语法中消息调用同时提供了消息名和参数:

[msgS (o : ExprS) (n : symbol) (a : ExprS)]  ;消息,表层语言

去语法糖得msgC和函数调用:

[msgS (o n a) (appC (msgC (desugar o) n) (desugar a))]

至此,一个包含对象的语言就诞生了。例如,下面是对象定义和调用:

(letS 'o (objS (list 'add1 'sub1)
               (list (lamS 'x (plusS (idS 'x) (numS 1)))
                     (lamS 'x (plusS (idS 'x) (numS -1)))))
      (msgS (idS 'o) 'add1 (numS 3)))

它计算得(numV 4)

10.1.2 通过去语法糖实现对象

在语言核心中定义对象也许是值得的,但是对于学习它来说这么做太麻烦了。替代方案是我们直接使用Racket语言中那些我们的解释器已经实现过的特性来表示对象。也就是说,假设我们看到的是去语法糖后的结果。(基于这个理由,我们会使用程式化的代码,可能某些表达式看上去并不必要,但请注意这是程序生成器输出的代码。)

注意:后面所有的代码都使用#lang plai而不是typed(静态类型)语言。

练习

为什么使用#lang plai?不然的话,在运行后面的代码的时候会碰到什么问题?这些问题好解决吗,比如引入新的数据结构来保证代码类型正确?如果简化我们的模型呢,比如让方法只接受一个参数?或者其中有些问题很难解决?

10.1.3 对象作为名称集合

首先实现我们之前实现的对象语言。对象是对给定名称进行分派的一种值。简单起见,我们用lambda表示对象,用case实现分派:

(define o-1
  (lambda (m)
    (case m
      [(add1) (lambda (x) (+ x 1))]
      [(sub1) (lambda (x) (- x 1))])))
注意到这个简单对象的实现是泛化了的lambda,带有多个“入口点”。相反,lambda可以理解为只有一个入口点的对象,也因此它不需要“方法名”。

这和本章前面的定义的对象相同,使用其方法的方式也相同:

(test ((o-1 'add1) 5) 6)  ;;这个测试会通过

当然,这种嵌套的函数调用有点臃肿(并且将变得更加臃肿),所以我们最好提供一种方便的语法来调用方法——和前文msgS一样,不过我们可以简单将其定义为函数:

(define (msg o m . a)
  (apply (o m) a))
这里使用了Racket的可变参数目数语法:. a的意思是,将剩下所有参数——零个或多个——绑定到名为a的列表。apply将列表中的值取出作为参数来进行函数调用。

这样我们的测试就可以这么写:

(test (msg o-1 'add1 5) 6)

思考题

换用去语法糖的方式后,有些重大改变。你意识到是什么吗?

回忆一下之前定义的语法:

[msgC (o : ExprC) (n : symbol)]

注意到消息“名字”的位置必须是符号。即程序员在该位置必须字面写上符号。而在去语法糖的版本中,名字的位置只是表达式,当然该表达式必须计算得到符号;例如,可以这么写:

(test ((o-1 (string->symbol "add1")) 5) 6)  ;;这也会通过

这是去语法糖的常见问题:目标语言中有些表达式可能在源码中没有对应的表示,于是它们不能映射回去。幸运的是,通常我们不需要进行反向映射,不过某些调试和程序分析工具中可能需要这么做。重要的是,我们必须保证目标语言中不会出现无法在源码中对应的

有了基本的对象实现,接下来我们添加那些大多数对象系统中都有的特性。

10.1.4 构造器

构造器就是在对象构造时调用的函数。我们还没定义过这种函数。只要将对象从字面值转换成接受构造参数的函数,便可以达到效果:

(define (o-constr-1 x)
  (lambda (m)
    (case m
      [(addX) (lambda (y) (+ x y))])))

(test (msg (o-constr-1 5) 'addX 3) 8)
(test (msg (o-constr-1 2) 'addX 3) 5)

在第一个例子中,我们传入5作为构造器的参数,所以加3得8。第二个例子是类似的,这表明构造器的两次调用不会相互干扰。

10.1.5 状态

许多人认为对象的主要目的就是用来封装状态。【注释】我们当然保有这种能力。如果去除语法糖后的语言支持变量(当然支持box也行,代价是去语法糖过程会更麻烦些),我们很容易实现多个方法对同一个状态赋值,例如修改构造参数:

(define (o-state-1 count)
  (lambda (m)
    (case m
      [(inc) (lambda () (set! count (+ count 1)))]
      [(dec) (lambda () (set! count (- count 1)))]
      [(get) (lambda () count)])))
Alan Kay——因发明Smalltalk和现代对象技术而获得图灵奖——不同意这一观点。他说,“在Smalltalk的早期历史中,往小了说(面向对象编程的动机)是寻找更易用的赋值,进一步则是尝试完全消除赋值。他补充说:“不幸的是,今天所谓的‘面向对象程序设计’大部分都是新瓶装旧酒。很多程序都充满了‘赋值式的’操作,只不过由更昂贵的附加子程序完成罢了。”

可以使用下面的代码序列测试:

(test (let ([o (o-state-1 5)])
        (begin (msg o 'inc)
               (msg o 'dec)
               (msg o 'get)))
      5)

请注意,对一个对象进行赋值不会影响到另一个对象:

(test (let ([o1 (o-state-1 3)]
            [o2 (o-state-1 3)])
        (begin (msg o1 'inc)
               (msg o1 'inc)
               (+ (msg o1 'get)
                  (msg o2 'get))))
      (+ 5 3))

10.1.6 私有成员

另一个常见的对象语言特性是私有成员:只在对象内部可见,外部就不可见。【注释】看上去这个特性还有待我们去实现,但我们已经有了局部作用域的、词法绑定的变量:

(define (o-state-2 init)
  (let ([count init])
    (lambda (m)
      (case m
        [(inc) (lambda () (set! count (+ count 1)))]
        [(dec) (lambda () (set! count (- count 1)))]
        [(get) (lambda () count)]))))
除此之外,在Java中,相同类型的其他类的实例也能访问“私有”成员。否则就没办法实现抽象数据类型了。

这么去除语法糖之后,不存在访问count的方法,词法作用域则确保它对外部不可见。

10.1.7 静态成员

对于对象的使用者来说,另一个有用的特性是静态成员:所有“相同”类型对象实例共享的成员。【注释】实际上,这就是(私有的)词法范围标识符,并且位于构造函数之外(这使其对所有构造函数的调用来说都是共享的):

(define o-static-1
  (let ([counter 0])
    (lambda (amount)
      (begin
        (set! counter (+ 1 counter))
        (lambda (m)
          (case m
            [(inc) (lambda (n) (set! amount (+ amount n)))]
            [(dec) (lambda (n) (set! amount (- amount n)))]
            [(get) (lambda () amount)]
            [(count) (lambda () counter)]))))))
这里用引号是因为,对象有许多“相同”的概念。太多了。

我们把增加counter的那行放在该对象的“构造器”所在的位置,尽管它也可以在方法内部被操纵。。

测试就是构造多个对象,并确保它们每一个都影响了全局的count

(test (let ([o (o-static-1 1000)])
        (msg o 'count))
      1)

(test (let ([o (o-static-1 0)])
        (msg o 'count))
      2)

10.1.8 带自引用的对象

到目前为止,我们的对象还只是打包的实名函数;或者你可以这么说,有多个实名入口点的函数。可以看到,很多对象系统中被认为很重要的特性可以通过函数和作用域实现,事实上很长一段时间里懂得lambda的程序员的确是这么做的,只是没有给这种做法起名字罢了。

对象系统一个不同与众不同的特征是,每个对象都自带了对该对象自己的引用,通常称为self或者this。【注释】我们可以方便的实现这一点吗?

对象的倡导者们经常采用的拟人化的术语“了解自己”,而我更喜欢这种略显枯燥的描述。事实上,请注意,我们无需要求助于拟人化,已经描述了很多对象系统的属性了。

10.1.8.1 使用赋值实现自引用

是的,可以这么实现,之前实现递归的时候我们已经见过此模式了;只需要将其一般化,引用对象自身而不是box或者函数:

(define o-self!
  (let ([self 'dummy])
    (begin
      (set! self
            (lambda (m)
              (case m
                [(first) (lambda (x) (msg self 'second (+ x 1)))]
                [(second) (lambda (x) (+ x 1))])))
      self)))

可以看见这就是递归的模式(递归函数),稍作调整。在方法first中使用自引用调用了方法second。测试表明这么做可行:

(test (msg o-self! 'first 5) 7)

10.1.8.2 不用赋值实现自引用

如果你研究过怎么不使用赋值实现递归,那么你会发现该方案也适用于这里。

(define o-self-no!
  (lambda (m)
    (case m
      [(first) (lambda (self x) (msg/self self 'second (+ x 1)))]
      [(second) (lambda (self x) (+ x 1))])))

现在每个方法需要传入self参数。这意味着方法调用也需要修改,以遵循新模式:

(define (msg/self o m . a)
  (apply (o m) o a))

也就是说,当调用对象o的方法时,必须把o作为参数传递给方法。显然这种方式存在隐患,调用方法的时候可以传入不同的对象作为self。因此将这个功能提供给程序员可能是个坏主意;如果使用这种技术,则只能通过去语法糖来实现。

尽管如此,Python还是在其表层语法中这么做了。尽管这种致敬Y-combinator的行为令人感动,但是由此带来的脆弱性也许不必要。

10.1.9 动态分发

最后,我们希望我们的对象可以处理对象系统的这个特性,调用者可以进行方法调用,而无需知道或者决定哪个对象会处理该调用。假设我们有个二叉树数据结构,树中要么是不含值的节点或者含值的叶节点(译注:原文如此,和后面的代码有相反之处)。传统的函数中,我们需要借助某种形式的条件判断——condtype-case、模式匹配,或与之等价的东西——穷举不同形式的树并根据对应形式来选择执行。如果树的定义扩展了,包含了新的类型,那么所有相应的代码段必须修改。动态分发(dynamic dispatch)将该条件选择移到语言内部,使得用户程序可以不用处理这种情况,从而解决此问题。它提供的关键特性是可扩展的条件。这也是对象提供的可扩展性的一个方面。

动态分发使得系统具有黑盒可扩展性,因为系统的某个部分可以在不触及其他部分(代码修改)的情况下扩展,这个属性也被认为是面向对象编程的一大好处。这的确是对象相比函数的优势,然而函数相比对象有个对等的优势,事实上很多对象程序员使用访问者模式(Visitor pattern)来组织代码,使其看起来更像函数式的。请参阅Synthesizing Object-Oriented and Functional Design to Promote Re-Use ,其中包括具体的例子,给出此问题的完整描述。试着用你最喜欢的语言解决这个问题,然后可以看看Racket中的解决方案

先来定义两种类型的树对象:

(define (mt)
  (let ([self 'dummy])
    (begin
      (set! self
            (lambda (m)
              (case m
                [(add) (lambda () 0)])))
      self)))

(define (node v l r)
  (let ([self 'dummy])
    (begin
      (set! self
            (lambda (m)
              (case m
                [(add) (lambda () (+ v
                                     (msg l 'add)
                                     (msg r 'add)))])))
      self)))

于是,我们可以构造具体的树:

(define a-tree
  (node 10
        (node 5 (mt) (mt))
        (node 15 (node 6 (mt) (mt)) (mt))))

最后,测试一下:

(test (msg a-tree 'add) (+ 10 5 15 6))

注意到,在测试案例中,还有在nodeadd方法中,都调用了add方法而没有检查接收方是mt还是node。运行时系统提取出接收方的add方法并执行。用户的程序中没有条件表达式,这正是动态分发的精髓。

10.2 成员访问的设计空间

对于成员名称的处理我们已经有两个正交的纬度。一个维度是名字是静态给定还是计算给出的,另一纬度是名字的集合是固定的还是可变的:

名字是静态的名字是计算求得的成员固定基本的JavaJava中通过反射计算出的名字成员可变无法想象大部分脚本语言

只有一种情况毫无意义:如果强制程序员在源码中显式指定成员名,那么就无法添加新的可访问的成员了(当然,访问曾经存在过但是被删除的成员还是会报错)。其它的几种情况都已经在各种语言中被尝试过了。

右下方那种情况密切对应于那些使用哈希表表示对象的语言。成员名字即哈希表的索引。一些语言将这种风格推到极限,当索引是数字时也同样处理,于是对象和和字典(甚至数组)都混到了一起。即使对象只处理“成员名字”,这种风格的对象也给类型检查带来极大困难,这可不是什么好事。

因此,本章的其余部分,我们将坚持使用“传统的”对象,成员固定,甚至会让它的名字只能是静态的(对应于左上角那种)。即使这样,我们将发现仍有很多待学习的东西。

10.3 还有点啥(else中放什么)?

截至目前,我们的case表达式并不包含else子句。这么做的一个原因是,方便使得我们的成员(及成员数量)可变;尽管前面我们也讨论过,使用其它方式实现,例如哈希表,可能是更好的选择。相反,假如对象成员固定,把对象去语法糖实现为条件表达式从演示的角度来讲很合理(因为这种实现方式强调了成员名称固定这一点,而哈希表实现就将这一点交给了解释器,这么做容易导致错误)。不过,还有一个很好的原因,需要用上else子句:继承(inheritance)。它指的是,将控制“链式地”交给另一个对象,称为父对象

还是从前文中去语法糖对象模型开始。为了实现继承,需要提供给对象“某种东西”,当遇到其识别不了的方法,委托它实现。“某种东西”怎么选择将导致迥异的设计结果。

一种简单的选择,另一个对象。

(case m
  ...
  [else (parent-object m)])

基于我们的实现,这么做的话,我们将在父对象中搜索当前对象中不存在的方法(并且递归的搜素父对象的父对象)。如果找到与名称对应的方法,那么方法就会链式的返回最初的msg调用。如果找不到方法,最后那个对象可以报错“未找到消息”。

练习

注意到调用(parent-object m)就像“半个msg”一样,和左值是“半个查找”类似。两者有什么联系吗?

让我们来试试这个想法,扩展我们的树实现另一方法size。我们通过给对象nodemt分别实现“扩展”(你可能想叫它“子类”,但现在请先忍住)的方式实现,也就是使用前述的模式。

这里不会对现有的定义做任何编辑,这正是对象继承的意义所在:以黑盒的形式重用代码。这意味着,彼此不认识的各方可以各自扩展相同的基本代码。如果他们必须编辑基本代码,首先他们必须知道对方的修改,此外,某一方可能不喜欢另一方做的编辑。继承就可以完全避开这种麻烦。

10.3.1 类

我们立刻就遇到了难题。构造器的模式是这样的吗?

(define (node/size parent-object v l r)
  ...)

这段代码表明,父对象和对象构造器的其他参数处于“同一级别”。这看上去很合理,只要所有这些参数都给定了,该对象也就被“完全定义”了。然而,我们的代码中还有:

(define (node v l r)
  ...)

我们需要把所有的参数写两遍吗?(当有什么相同的东西需要写两次,应该考虑一下我们是不是有啥地方没有保持一致,因此引入了微妙的错误。)以下是替代方案:node/size可以构造其父对象的实例。也就是说,传给node/size指明父对象的参数不是父对象本身,而是父对象的构造函数

(define (node/size parent-maker v l r)
  (let ([parent-object (parent-maker v l r)]
        [self 'dummy])
    (begin
      (set! self
            (lambda (m)
              (case m
                [(size) (lambda () (+ 1
                                     (msg l 'size)
                                     (msg r 'size)))]
                [else (parent-object m)])))
      self)))

(define (mt/size parent-maker)
  (let ([parent-object (parent-maker)]
        [self 'dummy])
    (begin
      (set! self
            (lambda (m)
              (case m
                [(size) (lambda () 0)]
                [else (parent-object m)])))
      self)))

每次调用对象构造器的时候,就必须要记得传入父对象的构造函数:

(define a-tree/size
  (node/size node
             10
             (node/size node 5 (mt/size mt) (mt/size mt))
             (node/size node 15
                        (node/size node 6 (mt/size mt) (mt/size mt))
                        (mt/size mt))))

显然我们可以通过合适的语法糖简化上面这一堆东西。写两个测试来确保原功能和新加功能都正确:

(test (msg a-tree/size 'add) (+ 10 5 15 6))
(test (msg a-tree/size 'size) 4)

练习

把这段代码改写成self调用模式的,不使用赋值(第10.1.8.2节)。

这里展示的就是(class)的精髓。给函数加上父参数后它就是……好吧,真的有点棘手。现在我们把它称为blob(难以名状的一团)。blob对应于Java程序员在编写类时定义的内容:

class NodeSize extends Node { ... }

思考题

那么,为什么我们不把它叫做“类”呢?

当程序员调用Java的类构造器时,它实际上构造了继承链上的所有对象(当然,编译器可能会对此优化,只需要进行一次构造器调用和一次对象分配)。每个父类都会对应创建一个私有的对象(对于静态方法来说是私有的)。问题是,这些对象中有多少是可见的。Java的选择和我们上述的实现不同,是对于每个给定名字(和签名)的方法只保留一个,不管该方法在继承链上被实现了多少次,而所有的字段都被保留,可以通过强制类型转换去访问。后者细想是合理的,因为对字段来说,可能会有一些基于它的不变量,所以保证它们彼此分离(因此所有字段都存在)是很有必要的。相比之下,很容易想出来一种方式可以使所有方法可用,而不仅是继承层次中最低(即最精炼)的方法。很多脚本语言采用这种方法。

练习

前面的代码犯了一个本质错误。self引用的是同一个语法上的对象,而它需要引用的是最精炼(继承层次中最低)的对象:这个问题被称为开放式递归(open recursion)。【注释】修改对象的表示法,使得self总是引用对象最精炼的版本。提示:你会发现,self调用的方式(第10.1.8.2节)更方便。
这展示了从传统对象获得的另一种可扩展性形式:可扩展递归(extensible recursion)。

10.3.2 原型

在前文的描述中,我们给每个类提供了其父的描述。构造对象时将沿着继承链创建每个类的实例。关于父代还有一种想法:它不是需要实例化的类,而就是对象本身。这样拥有相同父代的子代都会看到同一个对象,这意味着从某个子对象中修改该对象内部状态将对其它子对象可见。该共有对象被称为原型(prototype)

代表性的基于原型的语言是Self。虽然你可能听说过JavaScript是“基于”Self的,但是从其源头来研究这个想法是有意义的,而且Self展示了原型这个概念最纯粹的形式。

一些语言设计者认为原型比类更为基础,因为原型(外加语言中的其他基本机制,比如函数)可以实现类——但是反之则不行。前面我们基本上就是这么做的:每个“类”函数中都包含了对对象的描述,所以类就是返回对象的函数。如果我们假设这是两个不同的操作,直接继承对象,我们将得到类似原型的东西。

练习

修改继承模式,实现类似Self的、基于原型的语言,而不是基于类的语言。因为类为每个对象提供其父对象的不同拷贝,所以基于原型的语言可以提供克隆操作,从而简化在原型上模拟类的操作。

10.3.3 多重继承

你可能会想到,为什么(方法在本对象中找不到时)只提供一个选项呢?很容易把这个推广到多个选项的情况,这也很自然的导出多重继承(multiple inheritance)。有多个父辈之后很显然的问题是,查找方法时按照何种顺序进行。继承关系组织成树状结构,糟糕的是,并没有权威的顺序可供使用:比如是深度优先呢还是广度优先呢(两种做法都能找到论据支持)。更糟糕的是,例如blob A扩展自B和C;而B和C都扩展自D。【注释】问题来了:A的实例中包含一个还是两个D对象呢?只包含一个既节省空间且行为可能更符合期望,那么,访问该对象时是访问一次还是两次呢?两次访问之间应该没有什么区别,所以似乎没有必要。但一次访问意味着B或C之一的行为可能会改变。诸如此类。结果,几乎每一个支持多重继承的语言都伴随着一个微妙的算法,仅仅是定义查找的顺序。

这就是臭名昭著的菱形继承(diamond inheritance)问题。如果你选择在语言中包含多重继承,关于这个问题涉及的设计抉择可能需要你纠结好长时间。你几乎不可能找到规范的解决方案,所以你的痛苦才刚刚开始。。

多重继承只有在你思考之前才有吸引力。

10.3.4 (高超的)Super

很多语言中支持super调用,即调用继承链上一层中的方法或者访问上一层中的字段。【注释】包括在对象构造的时候这样做,在那里通常需要调用所有的构造函数,以确保对象被正确定义。

注意这里说的是“链”。在多重继承的情况下这些概念要复杂的多。

我们已经对向“上”调用习以为常,也许我们忘了问这是否是最自然的方向。请记住,构造器和方法的任务是维护不变量。我们应该更信任谁,超类还是子类?有些人认为,子类更为精炼,所以它拥有关于对象最全面的描述。但反过来说,超类必须保护其不变量不受无知的子类胡乱篡改。

这是关于继承到底是什么的两种截然不同的认知。向上意味着我们认为扩展是要替代超类。向下意味着我们认为扩展是改善父代。通常我们将子类继承视为后者(改善和精炼),但是为什么我们的语言进行调用的时候却选择了“错误的”方向呢?因此,有些语言探索了默认向下调用。

gbeta是一门由众多有趣特性的现代语言,它支持 inner(即向下调用)。考虑结合这两个方向也是非常有趣的。

10.3.5 Mixin和Trait

回过头讨论我们的“blob”。

在Java中当我们写下一个类时候,那对大括号中事实上是什么东西呢?它不是完整的类:完整的类取决父类,那又递归的取决于它的父类。其实,我们在大括号内定义的是类扩展。仅当把同一个定义中的父类加入后,它才是个完整的类。

自然我们要问:为什么?为什么不把扩展的定义将扩展应用于基类这两个行为分开呢?即,将这段代码:

class C extends B { ... }

分割成:

classext E { ... }

class C = E(B)

其中B是某个定义好的类。

看上去这样好像只是用更长的代码实现一样的东西。但是这种类似函数调用的语法不禁让我们浮想联翩:可以将某个扩展应用于多个不同的基类。比如说:

class C1 = E(B1);
class C2 = E(B2);
// ...

诸如此类。通过将E的定义和其扩展的类分离开,我们将扩展从固定基类的暴政中解放出来。这种扩展有个名字:mixin

“mixin”一词起源于Common Lisp,是多重继承的特定使用模式。鸡窝里飞出金凤凰。

Mixin使得类定义具有更好的组合性。它提供了很多多重继承的好处(重用多段功能代码),但是避免了多重继承的麻烦(例如没有前面讨论的复杂的查询顺序问题)。采用去语法糖的方式的话,mixin还非常容易实现。Mixin基本上就是“类的函数”。我们的目标语言支持函数,而且已经确定了类去语法糖后的表达式,该表达式可以放入函数中,这意味着实现简单的mixin模型非常容易。

这里的情况是,去除语法糖后的目标语言拥有良好的通用性,如果我们将其映射回源码语言,就能获得更好的结构。

在静态类型语言中,好的mixin设计完全可以改善面向对象编程的实践。假设我们要定义一个基于mixin的 Java。如果mixin等效于类到类的函数,那么这个“函数”的“类型”是什么?显然,mixin应该使用接口(interface)来描述其输入和输出。Java支持后者(但不强制要求),但是不支持前者:类(的扩展)扩展的是另一个——这个类中所有的成员对扩展都是可见的——而不是其接口。这意味着子类获取了父类所有的行为,而不是其规范。如果修改父类,就有可能导致子类出错。

在支持mixin的语言中,我们就可以这么写:

mixin M extends I { ... }

其中I是接口。这样M可以用来扩展实现了接口I的类,语言能保证只有I中指定的成员在M中可见。这就遵循了好的软件设计的重要原则之一。

“面向接口编程,而不是面向实现(Program to an interface, not an implementation)” —— 《设计模式》

好的mixin设计还可以更进一步。按照定义,一个类在继承链中只能使用一次(如果某个类的引用它自己,那么继承链上势必存在环路,这会导致无限循环)。反之,当我们编写函数时,就不会有这种顾虑(例如:(map ... (filter (map ...))))。使用某个mixin两次有意义吗?

当然有!请参阅Classes and Mixins 的第3和第4节。

mixin解决了库设计中出现的一个重要问题。假设我们有十几个不同的特性可以用不同的方式进行组合,我们应该提供多少个类?更甚之,并不是所有特性都可以相互组合。显然,产生所有组合对应的类不现实。更好的方案是允许程序员选择他们关心的特性,且提供必要的机制防止不合理的组合。这正是mixin所解决的问题:mixin提供类的扩展,程序员可以自行组合,而接口必须要能对上,从而创建自己需要的类。

Racket的GUI库中广泛使用了mixin。例如color:text-mixin的输入是基本的文本编辑器接口,输出是彩色的文本编辑器接口。后者本身也是一种基本的文本编辑器接口,于是其他基本文本相关的mixin还可以继续应用于其输出。

练习

你最喜欢的面向对象语言的库是怎么解决上述问题的?

Mixin也有局限:只能进行线性的组合。这种限制有时会给程序员带来不必要的负担。将mixin泛化,不是只对单个mixin扩展,而是扩展一mixin,这被称为trait。当然,允许扩展多个就必须要处理潜在的名字冲突。因此实现trait必须同时提供解决名字冲突的机制,通常是某种名称组合代数。Trait是mixin的补充,程序员可以自行选择最满足其需求的机制。Racket支持mixin和trait。

「真诚赞赏,手留余香」
还没有人赞赏,快来当第一个赞赏的人吧!
2 条评论
推荐阅读