Lecture 6: Interface Design: Independent and Extensible
In this lecture, we explore the interface-based way of defining objects and observe that it enables two important properties of programs:
Representation independence: programs that use objects only according to their interface cannot observe, and therefore cannot depend upon, the representation of those objects. This enables the designer of the object to choose the representation in any way they see fit.
Extensibility: programs that are designed using interfaces can be extended by adding new kinds of objects that implement the interface without requiring any changes to existing code. This leads to a kind of modularity and separation of concerns that is important in designing and maintaining large software systems.
Let’s consider the alternative characterization of lights not in terms of what they are, but rather what they do that we say in Lecture 5: Classes of Objects: Interface Definitions. A light does two things: it can render as an image and it can transition to the next light; hence our interface definition for a light is:
;; A Light implements ;; on-tick : -> Light ;; Next light after this light. ;; to-draw : -> Image ;; Draw this light.
Now it’s clear that each of the three light classes define sets of objects which are Lights, because each implements the methods in the Light interface, but we can imagine new kinds of implementations of the Light. For example, here’s a class that implements the Light interface:
;; A ModLight is a (new mod-light% Natural) ;; Interp: 0 = green, 1 = yellow, otherwise red. (define-class mod-light% (fields n) ;; on-tick : -> Light ;; Next light after this light. (define (on-tick) (new mod-light% (modulo (add1 (send this n)) 3))) ;; draw : -> Image ;; Draw this light. (define (to-draw) (cond [(= (send this n) 0) (circle LIGHT-RADIUS "solid" "green")] [(= (send this n) 1) (circle LIGHT-RADIUS "solid" "yellow")] [else (circle LIGHT-RADIUS "solid" "red")])))
Notice that every ModLight is a Light. Moreover, any
program that is written to use Lights will be compatible with any
implemention of the Light interface, regardless of its
representation. So notice that the world program only assumes that its
light field is a Light; this is easy to inspect—
(big-bang (new mod-light% 2))
it would work exactly as before.
We’ve now developed a new concept, that of an interface, which is a collection of method signatures. We say that an object is an instance of an interface whenever it implements the methods of the interface.
The idea of an interface is already hinted at in the concept of a
union of objects since a function over a union of data is naturally
written as a method in each class variant of the union. In other
words, to be an element of the union, an object must implement all the
methods defined for the union—
Representation independence
As we’ve seen with the simple world program that contains a light, when a program is written to use only the methods specified in an interface, then the program is representation independent with respect to the interface; we can swap out any implementation of the interface without changing the behavior of the program.
Extensibility
When we write interface-oriented programs, it’s easy to see that they are extensible since we can always design new implementations of an interface. Compare this to the construction-oriented view of programs, which defines a set of values once and for all.
These points become increasingly important as we design larger and larger programs. Real programs consist of multiple interacting components, often written by different people. Representation independence allows us to exchange and refine components with some confidence that the whole system will still work after the change. Extensibility allows us to add functionality to existing programs without having to change the code that’s already been written; that’s good since in a larger project, it may not even be possible to edit a component written by somebody else.
Let’s look at the extensiblity point in more detail. Imagine we had developed the Light data definition and its functionality along the lines of HtDP. We would have (we omit draw for now):
;; A Light is one of: ;; - "Red" ;; - "Green" ;; - "Yellow" ;; light-tick : Light -> Light ;; Next light after the given light (check-expect (light-tick "Green") "Yellow") (check-expect (light-tick "Red") "Green") (check-expect (light-tick "Yellow") "Red") (define (light-tick l) (cond [(string=? "Red" l) "Green"] [(string=? "Green" l) "Yellow"] [(string=? "Yellow" l) "Red"]))
Now imagine if we wanted to add a new kind of light—
(check-expect (light-tick "BlinkingYellow") "BlinkingYellow")
That’s no big deal to implement if we’re allowed to revise
light-tick—
Now let’s compare this situation to one in which the original program was developed with objects and interfaces. In this situation we have an interface for lights and several classes, namely red%, yellow%, and green% that implement the on-tick method. Now what’s involved if we want to add a variant of lights that represents a blinking yellow light? We just need to write a class that implements on-tick:
;; Interp: blinking yellow light (define-class blinking-yellow% ;; on-tick : -> Light ;; Next light after this blinking yellow light. (check-expect (send (new blinking-yellow%) on-tick) (new blinking-yellow%)) (define (next) this))
Notice how we didn’t need to edit red%, yellow%, or green% at all! So if those things are set in stone, that’s no problem. Likewise, programs that were written to use the light interface will now work even for blinking lights. We don’t need to edit any uses of the on-tick method in order to make it work for blinking lights. This program is truly extensible.
Representation independent testing: Our current approach to testing is oriented around the idea of testing for structurally equal representations of values. Testing in a representation independent way requires it’s own technique, which we will see later in the course.
1 An Exercise: Developing Lists of Numbers
A new look at old friend. Let’s look at an object-oriented development of a list of numbers, in particular we will start with an interface-based view of these lists, meaning we will consider the behaviors of a list of numbers (the things we can compute in terms of lists of numbers) rather than their structure.
Let’s start by picking a couple of computations which can be done in terms of a list of numers: length and map.
;; A LoN implements: ;; ;; length : -> Number ;; Compute the length of this list of numbers ;; ;; map : [Number -> Number] -> LoN ;; Apply the given function to each element of this list of numbers
Now we have to think about designing an actual representation of a list of numnbers. Here we just follow the same design (albeit with objects) as we did last semester, using a recursive union data definition:
;; INTERP: Empty list of numbers (define-class empty-lon%) ;; INTERP: Non-empty lists of numbers (define-class cons-lon% (fields first rest))
Using this representation, we can implement the LoN interface by implementing the length and map methods.
Let’s start with empty-lon%. First we should declare our intention that empty-lon% objects implement the LoN interface:
;; A (new empty-lon%) implements LoN ;; INTERP: Empty list of numbers (define-class empty-lon%)
To fulfill this intention, we must actually define the methods listed in the LoN interface.
Let’s make some examples of what these methods should produce in the case of an empty list of numbers.
Writing the code for both is now obvious given the examples:
empty-lon%
;; Compute the length of this empty list of numbers (check-expect (send (new empty-lon%) length) 0) (define (length) 0) ;; map : [Number -> Number] -> LoN ;; Apply the given function to each element of this empty list of numbers (check-expect (send (new empty-lon%) map add1) (new empty-lon%)) (define (map f) (new empty-lon%))
(Notice how the purpose statements are specialized for the particular class in which we are defining the methods.)
Moving on to cons-lon%, we go through the same steps. Declare our intention that cons-lon% implements LoN and make examples:
;; A (new cons-lon% Number LoN) implements LoN ;; INTERP: Non-empty list of numbers (define-class cons-lon% (fields first rest))
> (send (new cons-lon% 3 (new cons-lon% 7 (new empty-lon%))) length) 2
> (send (new cons-lon% 3 (new cons-lon% 7 (new empty-lon%))) map add1) (new cons-lon% 4 (new cons-lon% 8 (new empty-lon%)))
empty-lon%
(define (cons-template ...) (send this first) ... (send (send this rest) cons-template ...))
Instantiating the template for length and map and adjusting the parameters, we get:
empty-lon%
(define (length) (send this first) ... (send (send this rest) length)) (define (map f) (send this first) ... (send (send this rest) map f))
Combining our knowledge of the examples and the partially filled in templates, we can see that (send this first) stands for 3 and (send this rest) stands for (new cons-lon% 7 (new empty-lon%)). Let’s make some more examples, based on the list contained in the rest field.
> (send (new cons-lon% 7 (new empty-lon%)) length) 1
> (send (new cons-lon% 7 (new empty-lon%)) map add1) (new cons-lon% 8 (new empty-lon%))
So in the original example, (send (send this rest) length) is 1 and (send (send this rest) map add1) is (new cons-lon% 8 (new empty-lon%)).
Our goal in length is 2, which can be computed by applying add1 to (send (send this rest) length).
Our goal in map is (new cons-lon% 4 (new cons-lon% 8 (new empty-lon%))), which can be computed by applying f to (send this first) and cons that result onto (send (send this rest) map add1). We are now in a position to write the code:
cons-lon%
;; length : -> Number ;; Compute the length of this non-empty list of numbers (check-expect (send (new cons-lon% 3 (new cons-lon% 7 (new empty-lon%))) length) 2) (define (length) (add1 (send (send this rest) length))) ;; map : [Number -> Number] -> LoN ;; Apply given function to each element of this non-empty list of numbers (check-expect (send (new cons-lon% 3 (new cons-lon% 7 (new empty-lon%))) map add1) (new cons-lon% 4 (new cons-lon% 8 (new empty-lon%)))) (define (map f) (new cons-lon% (f (send this first)) (send (send this rest) map f)))
We’ve now accomplished our initial goal. On your own, work out a similar development for lists of strings.