On this page:
34.1 Introduction
34.2 “An object is what an object has”
34.3 “An object is what an object does”
34.3.1 Constructors
34.3.2 Inheritance
34.3.3 Methods
34.3.4 Methods with arguments, this, and dynamic binding
34.4 Discussion
8.12

Lecture 34: Implementing Objects🔗

Compiling objects to purely functional code

34.1 Introduction🔗

In the last lecture, we looked at a rather different approach to an object-oriented language: JavaScript used a system of objects that:
  • Was dynamically typed, like the rest of JavaScript,

  • Modeled objects as a simple dictionary of property/value pairs,

  • Used prototypes to implement inheritance, rather than base and derived classes,

  • Allowed the creation and removal of object properties at runtime (because they’re “just” dictionaries),

...and the whole system functions self-consistently. Evidently, Java’s approach to object-oriented programming isn’t the only possible approach!

Now that we’ve seen two such systems, it’s natural to ask: how are they implemented? That is, when programs in these languages are run, what actually happens? To answer that question, we’ll implement objects ourselves, in a language we’re familiar with but that doesn’t have objects of its own: the intermediate student language of DrRacket.

The full Racket language actually has a sophisticated object system, which you may be interested in examining later, possibly after completing some more advanced courses in language design.

We will examine two possible representations of objects (there are of course other designs possible), and see how we can implement all the features of an object-oriented language using them. You are strongly encouraged to run the snippets of code in this lecture, and follow along with each version as we add new features.

Do Now!

What features are essential to object-oriented programming?

At the very least, we must support:
  • Creating objects

  • Properties

  • Methods

  • ...with arguments

  • Dynamic dispatch

  • The notion of this

  • Some form of inheritance or code-reuse

We do not need to support static types or mutation, as these notions are orthogonal to our object system: we can add them later, or not, without affecting the “object-oriented” part of our language. We also don’t need to explicitly support things like function objects — after all, we’re compiling to ISL, which has actual functions!

The two representation choices we will examine arise from two perspectives on what objects are, which we can summarize by the slogans:

“An object is what an object has”,

and

“An object is what an object does”

As our running example, we’ll try to implement Posns, including their distance-to-origin methods, and comparing whether one object is closer to the origin than another.

34.2 “An object is what an object has”🔗

Looking at the JavaScript approach to objects, we see that one possible description of objects is

A dictionary of property/value pairs

This defines the object simply in terms of what it contains. We can represent this in ISL as a list of pairs:
;; An Object is a [Listof PropVal]
;; A PropVal is a (list Name Value)
;; A Name is a symbol
 
(define my-pos1 (list (list 'x 3) (list 'y 4)))
(define my-pos2 (list (list 'x 6) (list 'y 8)))
This handles our first requirement: we can easily create objects, just by writing down a list. And we can easily create properties for those objects, again just by writing them down. Our object quite literally is whatever fields it has.

We need to define a way to access the fields of an object. In Java, we’d write a field access as myPos1.x. In ISL, we’ll define a function (dot obj prop) to do the same thing.

Do Now!

Design this function, given our current representation.

;; dot :: [Object Name -> Value]
(define (dot obj prop)
  (cond
    [(empty? obj) (error prop "No such property found")]
    [(symbol=? (first (first obj)) prop) (second (first obj))]
    [else (dot (rest obj) prop)]))
 
(check-expect (dot my-pos1 'x) 3)
(check-expect (dot my-pos2 'y) 8)
(check-error (dot my-pos2 'z) "z: No such property found")

This seems plain enough: our dot function is basically the standard find function, and so we have property lookup.

How can we implement methods? We might simply say that methods are just properties whose values happen to be functions. But this approach gets slightly tricky, rather quickly. Instead, let’s examine the second perspective of objects in more detail.

In practice, these tricky details can be solved, and all compilers for object-oriented languages do use a representation similar to this dictionary-of-properties approach. But understanding those details actually distracts from what’s going on; if you are interested in more information, take a course on compilers!

34.3 “An object is what an object does”🔗

Recall our IList interface that we’ve seen so frequently. If we have an object that we know to be an IList, do we know in advance what fields it has? No — we merely know what behaviors we can invoke on it. After all, that’s the whole point of an interface in Java: it hides the implementation details behind a promise to support some set of behaviors. All we know is that our object can do whatever the interface promises. Our object is whatever it can do.

Do Now!

What kind of value in ISL also acts like this? What values are what they can do?

Of all the kinds of values we’ve seen in ISL, functions sound most like this description: however a function was implemented, all we can observe of it is the result of calling it on some arguments. This leads us to our second representation for objects:
;; An Object is [Name -> Value]
;; A Name is a symbol

Do Now!

Define my-pos1 and my-pos2 in this new representation.

(define my-pos1
  (λ(prop)
    (cond
      [(symbol=? prop 'x) 3]
      [(symbol=? prop 'y) 4]
      [else (error prop "No such property found")])))
(define my-pos2
  (λ(prop)
    (cond
      [(symbol=? prop 'x) 6]
      [(symbol=? prop 'y) 8]
      [else (error prop "No such property found")])))
Looking at these definitions, we see we can again easily create objects, just by writing down functions with the appropriate signature, and we again can easily create properties. We need to define a new version of dot, though:
;; dot :: [Object Name -> Value]
;; (1st attempt)
(define (dot obj prop)
  (obj prop))
 
(check-expect (dot my-pos1 'x) 3)
(check-expect (dot my-pos2 'y) 8)
(check-error (dot my-pos2 'z) "z: No such property found")

Notice that our tests are completely unchanged: even though we’ve totally changed our representation of objects, we can still interact with them via dot.

34.3.1 Constructors🔗

Looking at the definitions of my-pos1 and my-pos2, it’s obvious that there is a great deal of repetitive code. The design recipe for abstraction says we ought to isolate the parts of the code that vary and extract them as function parameters.

Do Now!

Do this.

If we do this, we’ll have a function that takes parameters that represent the values of the 'x and 'y fields, and that returns the lambda that is our object. In other words: by abstracting out these parameters, we’ve created object constructors, which are nothing more than functions that return objects. We obtain the following code:
(define (make-pos x y)
  (λ(prop)
    (cond
      [(symbol=? prop 'x) x]
      [(symbol=? prop 'y) y]
      [else (error prop "No such property found")])))
 
(define my-pos1 (make-pos 3 4))
(define my-pos2 (make-pos 6 8))

34.3.2 Inheritance🔗

Suppose we want to make a 3-d point. A 3-d point shares some of the implementation of a 2-d point, so we might want to reuse our code from above, but we also want to add a 'z property.

Do Now!

Design a make-3d-pos constructor. Can you reuse make-pos in some way?

The simplest approach is just to copy and paste the make-pos constructor and modify it:
(define (make-3d-pos x y z)
  (λ(prop)
    (cond
      [(symbol=? prop 'x) x]
      [(symbol=? prop 'y) y]
      [(symbol=? prop 'z) z]
      [else (error prop "No such property found")])))
 
(define my-pos3 (make-3d-pos 3 4 12))
(check-expect (dot my-pos3 'z) 12)
(check-error (dot my-pos 'w) "w: No such property found")
But it’s visually apparent that there’s duplicated code here. What if we constructed a 2-d point with the 'x and 'y fields, and used it as a helper for our 3-d point?
(define (make-3d-pos x y z)
  (local [(define 2dpos (make-pos x y))]
    (λ(prop)
      (cond
        [(symbol=? prop 'z) z]
        [else (dot 2dpos prop)]))))
 
(define my-pos3 (make-3d-pos 3 4 12))
(check-expect (dot my-pos3 'z) 12)
(check-error (dot my-pos 'w) "w: No such property found")
Our 3-d point uses 2dpos as a helper function to handle 'x and 'y. In other words, it delegates to 2dpos. Moreover, given the particular pattern of delegation being used here, we might well rename 2dpos: it’s being used as the base object from which our 3-d point inherits, so we can call it super:
(define (make-3d-pos x y z)
  (local [(define super (make-pos x y))]
    (λ(prop)
      (cond
        [(symbol=? prop 'z) z]
        [else (dot super prop)]))))
In fact, we can reuse this mechanism in make-pos itself. After all, every object we create needs some catch-all clause to handle the “No such property found” error cases. So let’s define an object that fails when looking up every single possible property:
(define failure
  (λ(prop)
    (error prop "No such property found")))
We can now revise make-pos to delegate to this object for all the properties it doesn’t know about:
(define (make-pos x y)
  (local [(define super failure)]
    (λ(prop)
      (cond
        [(symbol=? prop 'x) x]
        [(symbol=? prop 'y) y]
        [else (dot super prop)]))))

Again, we can rename some of our variables: failure is the simplest possible object, and the one at the root of our object hierarchy. We might well just call it object.

34.3.3 Methods🔗

Do Now!

Design a method 'dist-to-0 that computes the distance to the origin for 2-d points.

All we need to do is add a new case to our cond, which will recognize 'dist-to-0 and use the Pythagorean formula to compute the right answer.
(define (make-pos x y)
  (local [(define super failure)]
    (λ(prop)
      (cond
        [(symbol=? prop 'x) x]
        [(symbol=? prop 'y) y]
        ;; NEW:
        [(symbol=? prop 'dist-to-0)
         (sqrt (+ (sqr x) (sqr y)))]
        [else (dot super prop)]))))
 
(check-expect (dot my-pos1 'dist-to-0) 5)
(check-expect (dot my-pos2 'dist-to-0) 10)
Notice that in this representation, methods and properties behave uniformly: we just write whatever code we need for the body of the method as the right-hand side of our cond branch, and we call the method using dot just as we do to access fields.

Do Now!

If we try to run 'dist-to-0 on my-pos3, we get the wrong answer. Fix this problem.

Our 3-d points inherit the implementation of 'dist-to-0 from make-pos’s 2-d points, but of course this is the wrong answer. Instead, we need to define a better method that is specialized for 3-d points:
(define (make-3d-pos x y z)
  (local [(define super (make-pos x y))]
    (λ(prop)
      (cond
        [(symbol=? prop 'z) z]
        ;; NEW:
        [(symbol=? prop 'dist-to-0)
         (sqrt (+ (sqr x) (sqr y) (sqr z)))]
        [else (dot super prop)]))))
Thanks to the natural behavior of cond, which evaluates its branches in order, we have now easily implemented overriding, just by defining a new handler for our 'dist-to-0 method where it belongs.

34.3.4 Methods with arguments, this, and dynamic binding🔗

The next level of support for object-oriented features requires a few upgrades all at once, and these can be subtle at first glance. Read through this section slowly, and try stepping through the examples in DrRacket until they make sense.

Let’s try to implement the 'closer-than method, which checks whether this point is closer to the origin than the given one.

Do Now!

What’s different about the 'closer-than method as compared to the 'dist-to-0 method?

That is, we’d like to write the following:
(define (make-pos x y)
  (local [(define super failure)]
    (λ(prop)
      (cond
        [(symbol=? prop 'x) x]
        [(symbol=? prop 'y) y]
        [(symbol=? prop 'dist-to-0)
         (sqrt (+ (sqr x) (sqr y)))]
        ;; NEW:
        [(symbol=? prop 'closer-than)
         (< (dot this 'dist-to-0) (dot that 'dist-to-0))]
        [else (dot super prop)]))))
This code doesn’t work, because neither this nor that are defined anywhere. Handling that is relatively straightforward; handling this is quite subtle.

The 'closer-than method requires passing in parameters to our method calls. That means, we need to revise dot to take parameters, and pass them along to our objects:
;; dot :: [Object Name Params -> Value]
(define (dot obj prop args)
  (obj prop args))
This means we need to revise our definition of Object: an object needs to be a function that takes both a property name and a list of arguments
;; An Object is [Name Params -> Value]
;; Params is [Listof Value]
 
(define (make-pos x y)
  (local [(define super failure)]
    ;; NEW: args
    (λ(prop args)
      (cond
        [(symbol=? prop 'x) x]
        [(symbol=? prop 'y) y]
        [(symbol=? prop 'dist-to-0)
         (sqrt (+ (sqr x) (sqr y)))]
        [(symbol=? prop 'closer-than)
         ????]
        [else (dot super prop args)]))))

Do Now!

Now that we have args passed in to our object, how can we define that in the 'closer-than branch?

(define (make-pos x y)
  (local [(define super failure)]
    (λ(prop args)
      (cond
        [(symbol=? prop 'x) x]
        [(symbol=? prop 'y) y]
        [(symbol=? prop 'dist-to-0)
         (sqrt (+ (sqr x) (sqr y)))]
        [(symbol=? prop 'closer-than)
         ;; NEW:
         (local [(define that (first args))]
           (< (dot this 'dist-to-0) (dot that 'dist-to-0)))]
        [else (dot super prop args)]))))
We can find that as the first argument that’s passed in. Unfortunately, we still need a way to define this.

Do Now!

What value does this refer to in Java? What expression in the ISL code above represents that value?

In Java, this refers to the current object. In the code above, this is represented by the lambda itself. We somehow need to bind that lambda to the name this in such a way that it is available while defining the lambda itself... in other words, we need a recursive definition:
(define (make-pos x y)
  (local
    [(define super object)
     ;; NEW:
     (define this
       (λ(prop args)
         (cond
           [(symbol=? prop 'x) x]
           [(symbol=? prop 'y) y]
           [(symbol=? prop 'dist-to-0)
            (sqrt (+ (sqr x) (sqr y)))]
           [(symbol=? prop 'closer-than)
            (local [(define that (first args))]
              (< (dot this 'dist-to-0) (dot that 'dist-to-0)))]
           [else (dot super prop args)])))]
    this))
Now, this is bound to the lambda, and in the body of the 'closer-than method, we can use this to invoke the 'dist-to-0 method on this object!

Let’s test this, to ensure we’ve gotten it right:
;; Test 1: (3,4) is closer to the origin than (6,8)
(check-expect (dot my-pos1 'closer-than (list my-pos2)) true)
;; Test 2: (6,8) is not closer to the origin than (3,4)
(check-expect (dot my-pos2 'closer-than (list my-pos1)) false)
;; Test 3: (6,8) is closer to the origin than (3,4,12)
(check-expect (dot my-pos2 'closer-than (list my-pos3)) true)
;; Test 4: (3,4,12) is not closer to the origin than (6,8)
(check-expect (dot my-pos3 'closer-than (list my-pos2)) false)
Unfortunately, the last of these tests fails.

Do Now!

Why?

Let’s trace very carefully through the execution of the last two tests. In test 3:
  • When we invoke 'closer-than on my-pos2 with an argument of my-pos3, we evaluate whether (dot this 'dist-to-0) is less than (dot that 'dist-to-0), where this is bound to my-pos2, and that is bound to my-pos3.

  • Evaluating the 'dist-to-0 method on my-pos2 runs the 2-d version of the Pythagorean formula within make-pos, and results in 10. Evaluating 'dist-to-0 on my-pos3 runs the 3-d version within make-3d-pos, and results in 13.

  • Together, 10 < 13, so we obtain true and the test passes.

However in test 4:
  • When we invoke 'closer-than on my-pos3 with an argument of my-pos2, we find that a 3-d point doesn’t have its own 'closer-than method, so we invoke (dot super prop args), where super is bound to the helper (make-pos 3 4) object. This will invoke 'closer-than on this helper object, with an argument of my-pos2.

  • When we evaluate 'closer-than on the helper object, that is bound to my-pos2, and this is bound to the helper object, which is (make-pos 3 4).

  • Evaluating the 'dist-to-0 method on my-pos2 runs the 2-d version of the Pythagorean formula within make-pos, and results in 10. Evaluating 'dist-to-0 on the helper object runs the 2-d version again, and results in 5 (instead of the correct 13).

  • Together, 5 < 10 is true, so we obtain true and the test fails.

The problem here is that when we delegate up to a super object, we “forget” which object we actually invoked the method on! How can we resolve this?

Do Now!

Look carefully at where that is bound. Look carefully at where this is bound. What is the difference?

The that parameter is defined by finding it in the arguments supplied to the method call: it is defined dynamically each time we invoke the method. In contrast, this is defined by the local define and never changes between calls: it is defined statically. Our only way out of this conundrum is to somehow make this be defined dynamically also — we must pass in the object on which we’re invoking a method as a parameter to that method!

This requires changing our representation of objects one last time:
;; An Object is [Itself Name Params -> Value]
;; A Name is a symbol
;; Params is [Listof Value]
;; Itself is the object on which the method is being invoked
 
(define object
  (λ(this prop args)
    (error prop "No such property found")))
(define (make-pos x y)
  (local [(define super object)]
    (λ(this prop args)
      (cond
        [(symbol=? prop 'x) x]
        [(symbol=? prop 'y) y]
        [(symbol=? prop 'dist-to-0)
         (sqrt (+ (sqr x) (sqr y)))]
        [(symbol=? prop 'closer-than)
         (local [(define that (first args))]
           (< (dot this 'dist-to-0 '()) (dot that 'dist-to-0 '())))]
        ;; NEW:
        [else (super this prop args)]))))
(define (make-3d-pos x y z)
  (local [(define super (make-pos x y))]
    (λ(this prop args)
      (cond
        [(symbol=? prop 'z) z]
        [(symbol=? prop 'dist-to-0)
         (sqrt (+ (sqr x) (sqr y) (sqr z)))]
        ;; NEW:
        [else (super this prop args)]))))
(define (dot obj prop args)
  ;; Passes the object to itself
  (obj obj prop args))
Look carefully at the last line of make-pos and make-3d-pos: when we make a super call, we must pass the original object up to the parent, in case the parent needs to invoke a method on it. By passing in the object, we ensure that it is bound dynamically, just like that is, and this dynamic binding is the essential idea behind dynamic dispatch: we don’t know until runtime precisely which object we’re invoking a method on. If we run our four tests above again, they now all pass:

;; Test 1: (3,4) is closer to the origin than (6,8)
(check-expect (dot my-pos1 'closer-than (list my-pos2)) true)
;; Test 2: (6,8) is not closer to the origin than (3,4)
(check-expect (dot my-pos2 'closer-than (list my-pos1)) false)
;; Test 3: (6,8) is closer to the origin than (3,4,12)
(check-expect (dot my-pos2 'closer-than (list my-pos3)) true)
;; Test 4: (3,4,12) is not closer to the origin than (6,8)
(check-expect (dot my-pos3 'closer-than (list my-pos2)) false)

(You might have noticed that the local definition of this has disappeared. The recursion that we needed hasn’t vanished though: instead, we obtain it by passing the object to itself as its first parameter.)

34.4 Discussion🔗

We’ve actually implemented all the essential features of an object system: we have objects and constructors, fields, methods (with parameters), dynamic dispatch, inheritance and code reuse. Everything else from here on is a bonus. Some possibilities include:
  • If we abstract the binding for super and pass it in as an argument to the constructor, we obtain the prototype inheritance pattern of JavaScript.

  • If instead we abstract just the constructor being called to create super and pass that in instead, we obtain a pattern called mixins, which are essentially “higher-order classes”.

  • If we do a fair bit of error checking that prop is a symbol, that args is always of the expected length and contains values of the expected kinds, then we essentially are performing runtime type-checking, analogous to the typechecking that Java performs statically.

Many other patterns are possible with this representation, because everything is “just” more and more patterns of lambdas!