Issues with object-oriented programming in Guile

October 05, 2022
Tags:

Scheme is often thought of as a functional programming language, but really it is a multi-paradigm language, including object-oriented programming. My Scheme of choice for the past decade has been Guile. It comes with support for OOP via GOOPS: The Guile Object Oriented Programming System. It's a silly name. GOOPS is modeled after the almighty Common Lisp Object System or CLOS for short. Overall, it does a good job of adapting the concepts of CLOS in a Schemey way. However, most Scheme programmers never use OOP, and I think that has left GOOPS a little rough around the edges. By mimicking CLOS a bit more, I think GOOPS could make someone used to the excellence of CLOS feel a little more at home in Schemeland. Also I want these features to make my code more elegant and less prone to bugs due to hacky workarounds.

Setter specialization and inheritance do not compose

In Guile, slot accessor specialization and inheritance do not compose. You can't specialize an accessor's setter in a parent class and have it apply to a child class. The child class defines new slot accessor methods specialized for itself, so they come before the specialized parent methods in the precedence list.

Example:

(use-modules (oop goops))

(define-class <person> ()
  (name #:init-keyword #:name #:accessor name))

(define-method ((setter name) (person <person>) new-name)
  (display "renaming!\n")
  (slot-set! person 'name new-name))

(define-class <child> (<person>))

(define p1 (make <person> #:name "Alice"))
(define p2 (make <child> #:name "Bob"))

;; Only the first set! call uses the specialized setter method defined
;; above.
(set! (name p1) "Ada")
(set! (name p2) "Ben")

CLOS does not clobber the method from the parent class:

(defclass person ()
  ((name :initarg :name :accessor name)))

(defmethod (setf name) (new-name (obj person))
  (format t "renaming!~&")
  (setf (slot-value obj 'name) new-name))

(defclass child (person) ())

(defvar p1 (make-instance 'person :name "Alice"))
(defvar p2 (make-instance 'child :name "Bob"))

;; Both of these setf calls use the specialized setf method defined
;; above.
(setf (name p1) "Ada")
(setf (name p2) "Ben")

As a workaround, each child class can specialize the getter/setter/accessor and call next-method, but it doesn't feel like the way things ought to be. I find the CLOS behavior much more desirable. I think this is actually a bug, but I'm waiting on confirmation from Guile's maintainers about that before attempting to fix it.

No before/after/around method qualifiers

GOOPS does not support the handy before/after/around method qualifiers.

Here's what those look like in Common Lisp:

(defclass person ()
  ((name :initarg :name :accessor name)))

(defmethod greet ((p person))
  (format t "You: 'Hello, ~a!'~&" (name p)))

(defmethod greet :before ((p person))
  (format t "> You gather the courage to talk to ~a.~&" (name p)))

(defmethod greet :after ((p person))
  (format t "> That wasn't so bad.~&" (name p)))

(defmethod greet :around ((p person))
  (format t "> You take a deep breath.~&")
  (call-next-method)
  (format t "> You are socially exhausted.  You should rest.~&"))

(greet (make-instance 'person :name "Alice"))

Expected output:

> You take a deep breath.
> You gather the courage to talk to Alice.
Hello, Alice!
> That wasn't so bad.
> You are socially exhausted.  You should rest.

I often want to tack on some logic before or after a method, so these qualifiers are extremely useful and they compose nicely with inheritance because child classes that implement more specialized versions of the base method do not clobber the qualified methods.

With GOOPS, if a parent class wanted to wrap some code around a method call, that behavior could only apply to the parent, its ancestors, and child classes that do not specialize that same method. If a method specialized for the child class exists, the wrapper breaks. It's not called at all if the more specialized method does not call next-method, and even if it is called, there is no way to execute the body of the more specialized method in the context of the wrapper code. With an around qualifier, this wouldn't be a problem. To workaround this in Guile and preserve the behavior of the previous example, a new method around-greet needs to be defined, which calls before-greet, greet, and after-greet.

(use-modules (ice-9 format) (oop goops))

(define-class <person> ()
  (name #:init-keyword #:name #:accessor name))

(define-method (greet (p <person>))
  (format #t "Hello ~a!~%" (name p)))

(define-method (before-greet (p <person>))
  (format #t "> You gather the courage to talk to ~a.~%" (name p)))

(define-method (after-greet (p <person>))
  (format #t "> That wasn't so bad.~%"))

(define-method (around-greet (p <person>))
  (format #t "> You take a deep breath.~%")
  (before-greet p)
  (greet p)
  (after-greet p)
  (format #t "> You are socially exhausted.  You should rest.~%"))

(around-greet (make <person> #:name "Alice"))

This is just an ad hoc, informally specified, bug ridden version of less than half of what CLOS method qualifiers do. There are now four methods in the greet API instead of one. Any further specializations to before-greet and after-greet must be sure to call next-method to emulate the semantics of CLOS before/after qualifiers. around-greet requires 3 method calls where the CLOS version only need to call call-next-method. It would be challenging to modify an API that people already depended on to accomodate these new wrappers without introducing backwards incompatible changes.

define-method could be changed in a backwards compatible way to support these qualifiers and the method dispatching system could be changed to accomodate them.

No control over method combination algorithm

This is related to the qualifier issue. Those before/after/around qualifiers are part of the standard CLOS method combination algorithm. There are other ways to combine methods in CLOS and generics can be configured to use a non-standard one:

(defgeneric multi-greet (p)
  (:method-combination progn))

The multi-greet generic would call all of the primary methods associated with it, not just the most specific one. I haven't seen a way to control how methods are applied in GOOPS. Adding this feature would be complicated by the fact that generics do not have a fixed arity, but I think it would still be possible to implement. Maybe for the GOOPS version of the progn combination, GOOPS would call all of the primary methods that match the number of arguments passed to the generic and ignore the others.

Method arguments can only be specialized by class

CLOS supports specializing method arguments on more than just classes. Here's an example program that specializes the second argument of greet on a keyword.

(defclass person ()
  ((name :initarg :name :accessor name)))

(defmethod greet ((p person) (type (eql :formal)))
  (format t "Hello, ~a.~&" (name p)))

(defmethod greet ((p person) (type (eql :casual)))
  (format t "Hey!  What's up, ~a?'~&" (name p)))

(defvar p1 (make-instance 'person :name "Alice"))

(greet p1 :formal)
(greet p1 :casual)

With GOOPS we'd have to do something like this:

(use-modules (ice-9 format) (oop goops))

(define-class <person> ()
  (name #:init-keyword #:name #:accessor name))

(define-method (greet (p <person>) type)
  (case type
    ((formal)
     (format #t "Hello, ~a.~%" (name p)))
    ((casual)
     (format #t "Hey!  What's up, ~a?~%" (name p)))))

(define p1 (make <person> #:name "Alice"))

(greet p1 'formal)
(greet p1 'casual)

This works, but it tightly couples the implementations of each type of greeting, and the technique only becomes more cumbersome in real, non-trivial programs. What if a child class wanted to specialize just the formal greeting? It would require another case form with an else clause that calls next-method. It works but it takes extra code and it doesn't look as nice, IMO. It would be good to take inspiration from SBCL's specializable library since it allows for user extendable specializers. A similar system could be added to GOOPS methods. More advanced method argument specializers would be a nice complement to the excellent (ice-9 match) general-purpose pattern matching module.

Methods do not support keyword arguments

CLOS methods support keyword arguments and rest arguments, GOOPS method only support rest arguments. Like rest arguments, keyword arguments cannot be specialized. I don't know if this can be added to GOOPS in a backwards compatible way or at all. If it's possible, a new define-method* form, mirroring define*, could be added. One important difference between CLOS and GOOPS where I like the GOOPS behavior better is that generics can support methods of arbitary arity, but CLOS generics set the arity and all methods must conform. Would this difference complicate a keyword argument implementation?

Classes, slots, and generics do not have documentation strings

CLOS supports a :documentation option in defclass as both slot and class options, and defgeneric, but GOOPS has no equivalent for any of them.

Since slots in GOOPS can have arbitrary keyword arguments applied to them, a simple slot-documentation procedure to get the #:documentation slot option could be added:

(define (slot-documentation slot)
  (get-keyword #:documentation (slot-definition-options slot)))

defgeneric in CLOS supports docstrings:

(defgeneric greet (obj)
  (:documentation "Say hello to an object."))

define-generic in GOOPS does not:

;; Nothing other than a symbol for the name is allowed.
(define-generic greet)

The <generic> class would need to be modified to accept a #:documentation initialization argument. define-generic syntax would need to be modified to optionally accept a documentation string. A new generic-function-documentation procedure would return the documentation for a generic. One complicating factor is that generic-function-name and generic-function-methods are defined in libguile, which is C, not Scheme. I don't know if this hypothetical generic-function-documentation would have to be in C, too. defmethod also accepts documentation strings, but Guile’s define-method does not.

Generics cannot be merged outside of a module

If I'm writing a Guile script, not a library module, I use use-modules to import the modules I need to get the job done. However, if two or more modules export a generic with the same name, there's no way to tell use-modules that I'd like them to be merged into a single generic. Merging generics is an option when using the define-module form, but it doesn't feel right to use it when you're not actually writing a reusable module and it feels weird to have to change the syntax of how I'm importing modules just because generics are present.