23 April 2023

Creating Objects using Closures

You don't need Java (or C++, or an "OOP" language) to create objects and classes!

What are objects and classes?

For this post, consider objects to be instances of encapsulated state with associated methods, and classes to be the general definitions of a type of object.

When are objects useful?

Objects can be useful when:

  • an interface to an external resource needs to have state
  • a subcomponent of the system needs to have state

When to avoid using objects?

Don't be overzealous when creating new classes and objects. They are inherently more complicated than just adding a function, and (particularly in the case of classes within the system itself) assess whether the class could be refactored into a simple procedure, or, even better, a pure function. Almost any class that has only one method - especially if this is only called once - should actually be a function.

Objects can be a useful abstraction, but becoming object-obsessed can lead to systems full of trivial objects (even worse when coupled with gratuitous use of inheritance). Encapsulation can reduce system complexity by allowing the designer to consider different components separately. However, if the classes are so small that the system becomes encoded in the relationships between the classes, rather than within the classes, this is undone. A system that is mainly encoded in the relationships between classes becomes very hard to understand. A large number of small classes is bad because the complexity of their interactions becomes overwhelming.

How to create objects/classes in a language without built-in OOP support?

Objects have per-instance state, and associated functionality. They can be passed messages to instruct them to perform an action and/or to request information. In Scheme (and many other languages) it is possible to associate state with a particular function object, by creating a closure, which binds the local values with the function object instance. By using a switch case within the function, the object can also be given multiple associated actions. There are many ways to implement objects, and this is only one - but it is one well-suited to Scheme.

A contrived example

The class point below is implemented as a function that has local values x and y, and local procedures to set these values, and returns a function that "captures" these local values (the values become bound to the returned function object). The returned function takes as its arguments a message, composed of a method, possibly followed by a series of arguments. The choice of method determines what the object should do/return.

(define (point x y)
  (let ([x x]
        [y y])

    (define (set-x! x2)
      (set! x x2))

    (define (set-y! y2)
      (set! y y2))

    (lambda (method . args)
      (case method
        ('x x)
        ('y y)
        ('set-x! (apply set-x! args))
        ('set-y! (apply set-y! args))))))

This can be used as follows:

(define p1 (point 5 5))
(p1 'x) ; returns: 5
(p1 'set-x! 7)
(p1 'x) ; returns: 7

Class Inheritance / Extension

Classes of object can be extended by using the else clause of the switch case, and by adding an instance of the parent class to the let statement of the child class.

In this case, it is easier if the lambda's arguments are not destructured immediately, but are kept whole as message, so that they can be passed on whole to the parent class if necessary. message can still be broken down using car and cdr (Scheme's head and tail functions) to method and args.

For example:

(define (circle x y r)
  (let ([p (point x y)]
        [r r])

    (define (set-r! r2)
      (set! r r2))

    (lambda message 
      (let ([method (car message)]
            [args (cdr message)])
        (case method
          ('r r)
          ('set-r! (apply set-r! args))
          (else (apply p message)))))))

Usage:

(define c1 (circle 2 4 5))
(c1 'r) ; returns: 5
(c1 'x) ; returns: 2

What about a realistic example?

One use case for the object pattern is to "remember" information about a particular instance of a connection to a resource. For example, a logger that logs to a file, and keeps count of how many lines have been logged in this particular session.

; (assuming functions `open-file` and `append-to-file` are defined already)

(define (logger file-path)
  (let ([file (open-file file-path)]
        [lines-logged-count 0])

    (define (log msg)
      (append-to-file file msg)
      (set! lines-logged-count (add1 lines-logged-count)))

    (lambda (method . args)
      (case method
        ('lines-logged-count lines-logged-count)
        ('log (apply log args))))))

Usage:

(define tmp-logger (logger "/tmp/example-log-file"))
(tmp-logger 'lines-logged-count) ; returns: 0
(tmp-logger 'log "foo")
(tmp-logger 'log "bar")
(tmp-logger 'lines-logged-count) ; returns: 2
Tags: Scheme Tech