Hacker News new | ask | show | jobs
by dasyatidprime 1877 days ago
CLOS (as you presumably know) models method application as calls to generic functions, instead of the now-more-mainstream Smalltalk-like message-dispatch approach which Python uses. The latter allows for things like overriding __getattr__ to intercept ‘all’ method calls and property accesses, for which I don't think there's any equivalent in CLOS.

The way methods are ‘attached’ to classes gives you a natural form of type-directed name lookup. CL generics have the advantage that you can define your own methods on existing classes while naming the methods in your own package so they don't conflict, but also have a curse of inconvenience along the way where importing a class doesn't naturally pull in everything associated with it, and you wind up writing the class name again and again when dealing with fields of an object. with-slots et al. are poor substitutes. (In an experimental sublanguage at one point I actually had local variables with object type declarations implicitly look up the class using the MOP and symbol-macrolet every available var.slot combination within the scope as a brute hack around the most common desirable case.)

Python's short infix/prefix operators are naturally generic, since they're implemented as method calls. In CL there's the generic-cl extension, but I haven't seen it have that much uptake… in particular, any library code that isn't explicitly aware of it won't use it ‘naturally’ on foreign objects, which could be good or bad.

That shades into the very-concrete type system that CL starts out with, where any attempt at ad-hoc polymorphic interop is a disaster unless everyone already agrees on what methods to use. I can't make a thing that acts like a hash table but uses a different implementation underneath, then pass it to something that expects to be able to gethash on it. I especially seem to get bitten by this in cases where alists are the expected way of representing key-value maps: there's no way to extricate yourself from the linear search without rewriting every piece of code that touches it, there's often an implicit contract that you don't want duplicate keys but it's easy to violate by accident and create bad behavior down the line, and so on. By comparison, Java collections in particular got this very right in terms of decoupling intention from implementation, and Python does basically the same thing but with a looser set of ‘expected’ methods.

By default, Python objects have a ‘purely’ dynamic set of properties, rather than the fixed slots CLOS imputes on an object via its class. Indeed the class-level property one can set in Python to constrain this for possible performance gains is called __slots__.

2 comments

> The latter allows for things like overriding __getattr__ to intercept ‘all’ method calls and property accesses, for which I don't think there's any equivalent in CLOS.

CLOS already offered AOP, which you can use to control such calls.

https://lispcookbook.github.io/cl-cookbook/clos.html#dispatc...

I'm not sure what you're pointing at there. I'm aware of method combinations and method qualifiers—are you referring to being able to add a general :before/:after/:around on a single generic? If so, that's not what I mean; what I mean is vaguely similar but on the first-arg dispatch axis. Here's a toy Python example. Given:

  class KnowItAll(object):
      def __getattr__(self, attr):
          return lambda: "yes, I know how to " + attr
We then have:

  >>> k = KnowItAll()
  >>> k.reveal()
  'yes, I know how to reveal'
  >>> k.transfigure()
  'yes, I know how to transfigure'
  >>> k.make_sandwiches()
  'yes, I know how to make_sandwiches'
Ruby does this with method_missing instead, which is where I'm most used to it happening (and I think it's used a lot more in Ruby than in Python owing to the language-culture's higher tolerance for magic). Smalltalk used doesNotUnderstand, IIRC. One of the key secondary results of this is that you can do things like https://paste.ee/p/tUaRP, which is a toy “Tracer” class which intercepts, prints, and forwards method calls and attribute accesses (ignoring some edge cases).

If we were in CLOS, and started with:

  ;;; widget.lisp
  (defclass widget () ((radius :initarg :radius)))
  (defmethod grow ((w widget)) (incf (slot-value w 'radius)))
What I would expect for the equivalent is that, given:

  ;;; tracer.lisp
  (declaim (ftype (function (t) t) make-tracer))
  (defun make-tracer (object) ...)
  ;; ... further code goes here ...
Somewhere else, we can do:

  ;;; fiddle-with-widgets.lisp
  (let* ((w (make-instance 'widget :radius 3))
         (w* (make-tracer w)))
    ;; ???
    (grow w*))
Can you add code to tracer.lisp, without specific reference to anything from widget.lisp, such that this has the effect of (grow w) but prints what it's doing? Note also that my use of slot-value above is very deliberately a ‘raw’ access. I'm here completely ignoring the “make up entirely new methods on the fly as needed” part that method_missing also gets used for, which is even more impossible in CLOS given that it would require intercepting, what, all symbol lookups…

In CLOS, the class doesn't ‘own’ the method, it provides a type for dispatching on, so there's no way to do “give me some control over every generic function so long as the first arg is of ‘my’ type”. Which is a reasonable model, but means you can't do the same thing. Indeed the flip side is that in the Smalltalk-like model, generics are not reified, and methods that are specializations of the ‘same’ thing have no ‘real’ identity to them, so you can't do a type-ignoring :around method for an ‘entire generic’. (Often there will be a superclass to attach to instead, but it's considered dangerous “monkey patching” to mess around with someone else's class hierarchy like that, and in the case of more abstract interfaces there's nothing.)

Does that make sense?

Aha! I was half-wrong, but it's also horrible…

  (defclass tracer () ((actual :initarg :actual)))
  (defun make-tracer (object)
    (make-instance 'tracer :actual object))

  ;; But please don't.
  (defmethod no-applicable-method :around (gf &rest args)
    (if (typep (car args) 'tracer)
        (let* ((tracer (car args))
               (args* (cdr args))
               (actual (slot-value tracer 'actual)))
          (format t "Calling ~S on ~S" gf (cons actual args*))
          (apply gf actual args*))
        (apply #'call-next-method gf args)))
You can't do this with a real specialization on no-applicable-method, incidentally, because the first arg isn't special enough, it's just folded into the &rest. And that, I'm pretty sure, means this doesn't coexist with other uses of no-applicable-method properly… and you still can't do on-the-fly method names that aren't attached to a generic, and so on, but this does sort of account for the object forwarder case (and in fact you could extend it to allow tracers on more of the arguments!). It does, I expect, remain extremely unidiomatic by comparison.
thanks for your points, it's true that python dynamic operator genericity is very handy