diff options
Diffstat (limited to 'posts')
-rw-r--r-- | posts/2022-10-05-goops-issues.md | 278 |
1 files changed, 278 insertions, 0 deletions
diff --git a/posts/2022-10-05-goops-issues.md b/posts/2022-10-05-goops-issues.md new file mode 100644 index 0000000..e351ca8 --- /dev/null +++ b/posts/2022-10-05-goops-issues.md @@ -0,0 +1,278 @@ +title: Issues with object-oriented programming in Guile +date: 2022-10-05 11:00:00 +tags: guile, oop +summary: A list of issues with Guile's OOP system and ways to maybe fix them +--- +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](https://gnu.org/s/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: + +```scheme +(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: + +```lisp +(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: + +```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")) +``` + +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`. + +```scheme +(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. + +## 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. + +```lisp +(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: + +```scheme +(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 +[https://github.com/sbcl/specializable/](specializable) 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: + +```scheme +(define (slot-documentation slot) + (get-keyword #:documentation (slot-definition-options slot))) +``` + +`defgeneric` in CLOS supports docstrings: + +```lisp +(defgeneric greet (obj) + (:documentation "Say hello to an object.")) +``` + +`define-generic` in GOOPS does not: + +```scheme +;; 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. + +In CLOS, methods do not accept documentation strings, only generics +do. The downside of doing the same is that most code using GOOPS that +I've seen does not use `define-generic` for every generic procedure, +because `define-method` will create the generic procedure if needed. +So, it would mean adding a `define-generic` form for every generic +function that should be documented. Would it make sense to allow +documentation strings for methods, too? The documentation for a +generic could then be the union of its own documentation and any of +its methods that have documentation. There's probably a reason why +CLOS doesn't do this and it would be best to understand that first. |