Data abstraction lets us write programs that work independently of the chosen representation for data objects. This helps control complexity.
But it’s not powerful enough: sometimes we want not just an abstracted underlying representation, but multiple representations.
In addition to horizontal abstraction barriers, separating high-level from low-level, we need vertical abstraction barriers, allowing multiple design choices to coexist.
We’ll do this by constructing generic procedures, which can operate on data that may be represented in more than one way.
Representation for Complex Numbers
We can represent a complex number z=x+yi=reiθ as a list in two ways: in rectangular form (x,y) or in polar form (r,θ)
Rectangular form is convenient for addition and substraction, while polar form is convenient for multiplication and division.
Our goal is to implement all the operations to work with either representation:
We can implement the constructors and selectors to use rectangular form or polar form, but how do we allow both?
Tagged Data
One way to view data abstraction is as the “principle of least commitment.” We waited until the last minute to choose a concrete representation, retaining maximum flexibility.
We can take it even further and avoid committing to a single representation at all.
To do this, we need some way of distinguishing between representations. We will do this with a type tag 'rectangular or 'polar.
Each generic selector will strip off the type tag and use case analysis to pass the untyped data object to the appropriate specific selector.
The strategy of calling a procedure based on the type tag is called dispatching on type.
The implementation in Tagged Data has two significant weaknesses:
The generic procedures must know about all the representations.
We must guarantee that no two procedures have the same name.
The underlying issue is that the technique is not additive.
The solution is to use a technique known as data-directed programming.
Imagine a table with operations on one axis and types on the other axis:
Polar
Rectangular
Real Part
real-part-polar
real-part-rectangular
Imaginary part
imag-part-polar
imag-part-rectangular
Magnitude
magntiude-polar
magntiude-rectangular
Angle
angle-polar
angle-rectangular
Before, we implemented each generic procedure using case analysis. Now, we will write a single procedure that looks up the operation name and argument type in a table.
Assume we have the procedure (put op type item) which installs item in the the table, and (get op type) which retrieves it.
Then, we can define a collection of procedures, or package, to install the each representation of complex numbers. For example, the rectangular representation:
(define (install-rectangular-package) ;; Internal procedures (define (real-part z) (car z)) (define (imag-part z) (cdr z)) (define (make-from-real-imag x y) (cons x y)) (define (magnitude z) (sqrt (+ (square (real-part z)) (square (imag-part z))))) (define (angle z) (atan (imag-part z) (real-part z))) (define (make-from-mag-ang r a) (cons (* r (cos a)) (* r (sin a)))) ;; Interface to the rest of the system (define (tag x) (attach-tag 'rectangular x)) (put 'real-part '(rectangular) real-part) (put 'imag-part '(rectangular) imag-part) (put 'magnitude '(rectangular) magnitude) (put 'angle '(rectangular) angle) (put 'make-from-real-imag 'rectangular (lambda (x y) (tag (make-from-real-imag x y)))) (put 'make-from-mag-ang 'rectangular (lambda (r a) (tag (make-from-mag-ang r a)))) 'done)
Next, we need a way to look up a procedure in the table by operation and arguments:
(define (apply-generic op . args) (let ((type-tags (map type-tag args))) (let ((proc (get op type-tags))) (if proc (apply proc (map contents args)) (error "no method for these types" (list op type-tags))))))
Using apply-generic, we can define our generic selectors as follows: