带标签数据

介绍带标签数据的概念与实现,并探讨其优势与弱点。

带标签数据简介

在创建通用运算符之前,我们必须先有能力跟踪数据的类型。为什么?

回想第 4 课,我们在那里实现了有理数。当时我们决定把有理数表示为一个序对,car 放分子,cdr 放分母。与此同时,我们的”友敌”本·比特迪德尔(Ben Bitdiddle)实现了复数。他也用序对来表示这些数:car 放实部,cdr 放虚部。

现在,给定一个 car 为 3、cdr 为 4 的序对,我们如何判断它表示的是有理数 34\frac{3}{4},还是复数 3+4i3+4i 呢?我们拿到的原始数据两种解释都说得通,所以无法确定!事实上,这个序对也可能两者都不是,而是表示某种其他数据类型。这正是为什么我们需要一套系统,把数据和它们各自的类型关联起来。

(旁注:读到这里,你可能想责怪本——他凭什么用和我们一样的表示?!但他其实无可指责。即使他用了不同的内部表示,我们也不能靠这种区别来检查数据的类型:那样会破坏数据抽象的屏障!)

解决方案是使用带标签数据(tagged data):每一条数据都携带关于自身类型的信息。我们可以给所有数据贴上标签来做到这一点。为此,我们需要一个构造函数来给数据贴标签(attach-tag),还需要选择函数来从带标签数据中取出标签和数据(type-tag 和 contents)。

下面是一种可能的实现:

(define (attach-tag tag data)
  (cons tag data))

(define (type-tag tagged-data)
  (if (pair? tagged-data)
      (car tagged-data)
      (error "Not tagged data")))

(define (contents tagged-data)
  (if (pair? tagged-data)
      (cdr tagged-data)
      (error "Not tagged data")))

你能再设计出一套构造函数和选择函数,用不同的内部表示来实现数据标签吗?

给有理数和复数贴标签

既然已经实现了带标签数据,我们就可以修正有理数和复数数据类型的实现了。我们原来的代码是这样的:

(define (make-rational numer denom)
  (cons num denom))

(define (make-complex real imag)
  (cons real imag))

而现在我们可以这样写:

(define (make-rational numer denom)
  (attach-tag 'rational (cons num denom)))

(define (make-complex real imag)
  (attach-tag 'complex (cons real imag)))

注意,我们本来完全可以把这个函数里的 attach-tag 换成 cons,代码照样能工作。但这样做会破坏我们建立的数据抽象屏障!

接下来,我们可以用 contents 来写选择函数。例如,对于有理数:

(define (numer n)
  (car (contents n)))

(define (denom n)
  (cdr (contents n)))

为带标签数据编写过程

我们的目标是写一个通用的加法过程。它应该对有理数和复数都能工作。

第一步,是针对输入数据类型各自专用的加法过程。利用上一节刚写好的构造函数和选择函数,这一步应该相当直接。

请试试下面几道练习:

  1. 写 add-rational,它接受两个有理数,返回等于两者之和的有理数。记住要通过恰当的构造函数和选择函数来尊重数据抽象。
  2. 写 add-complex,它接受两个复数,返回等于两者之和的复数。记住 (a+bi)+(c+di)=(a+c)+(b+d)i(a+bi) + (c+di) = (a+c)+(b+d)i。
  3. 假设我们已经写好了一个过程 add-rational-complex,它按有理数、复数的顺序接受两者,并正确地把它们相加。
  4. 现在写一个名为 add-numbers 的通用加法操作,它接受两个数,每个数可以是有理数也可以是复数。我们应该依靠标签,把数据引导到上面正确的过程中。

答案就在下面,可以先自己试一试。

参考答案

你的 add-rational 应该差不多是这样:

(define (add-rational x y)
  (make-rational (+ (* (numer x) (denom y))
                    (* (numer y) (denom x)))
                 (* (denom x) (denom y))))

你的 add-complex 应该与此类似。注意,多亏了 make-rational、numer 和 denom 造起来的抽象屏障,我们根本不用操心贴标签的事。

再来看 add-numbers:

(define (add-numbers num1 num2)
  (cond ((and (equal? (type-tag num1) 'rational)
              (equal? (type-tag num2) 'rational))
         (add-rational num1 num2))
        ((and (equal? (type-tag num1) 'complex)
              (equal? (type-tag num2) 'complex))
         (add-complex num1 num2))
        ((and (equal? (type-tag num1) 'rational)
              (equal? (type-tag num2) 'complex))
         (add-rational-complex num1 num2))
        (else
         (add-rational-complex num2 num1))))

太好了!现在我们用一个通用过程就能做加法了!

反思

我们来想一想学到了什么:

  1. 写这个 add-numbers 的时候,我们甚至不需要知道标签是怎么实现的!这是因为我们已经把这些细节妥善地抽象隐藏了起来。所以,我们只用靠选择函数 type-tag 告诉我们在处理什么类型的数据。
  2. 如果以后想给系统再添一种数,我们就得修改通用函数的定义,加一大堆额外的条件来处理新类型。修改本身在我们眼下的情形里很直接,但这在大型系统里就行不通了。换句话说,我们的系统可扩展性很差。

add-numbers 这个例子多少有点刻意,但现实世界里确实有很多系统在用带标签数据。事实上,Racket 解释器就是用带标签数据来求值你的代码的!

带标签数据的弱点

如前文暗示,带标签数据系统有几个关键的弱点。

一个弱点是:每一种数据类型都必须被识别出来,并手工地整合进每一个通用过程。举例来说,假如我们想把一种新的数纳入系统,就得先给这种新表示指定一个类型,然后编辑那里所有的通用过程(add-numbers、multiply-numbers、divide-numbers 等),检查新类型并执行相应的操作。

另一个弱点是:尽管各个表示及其对应的过程可以分开设计,但我们必须保证整个系统中没有两个过程同名。这正是我们创建新过程 add-numbers 的原因——由它去调用 add-rational、add-complex 和 add-rational-complex。

这两个弱点背后的问题是:实现通用接口的技术无法扩展——每当添加一种新的表示或类型,实现通用过程的人都得去改那些过程。此外,原本写有理数系统和复数系统的人,现在也得修改自己的代码来避免命名冲突。这些情况下,要改的代码都很直接,但终究还是得改,这不免带来麻烦和出错的可能。