rhombus-prototype
rhombus-prototype copied to clipboard
Revise the class/object systems to be simpler and more harmonious with structs/generics
It seems like a lot of folks are intimidated by the current class system due to its complexity. The current class system in Racket feels very disconnected from the rest of the language such as using global scope for method-ids by default instead of lexical scope. Generics are better in this regard, but actually using generics is very clunky in my opinion.
Also, there isn't a great conceptual mapping from structs to objects and from methods to generics. These features feel extremely similar, but not quite the same, and neither is implemented in terms of the other as far as I know.
Satisfying both of these goals might be tricky, but I do have a few ideas.
- Get rid of the weird default global scope thing for method-ids. Instead, if the name gets resolved by symbol or something have an association between each method-id and a symbol for its name.
- If two method-id symbols conflict and you try to call it via symbol instead of id, raise an error.
- Instead of dynamic resolution, encourage use of interfaces (maybe even get rid of dynamic resolution and require using interfaces explicitly).
- Allow for delegating implementation of an interface to an object contained in a field. This would allow for resolving symbol conflicts by just creating an adapter which only implements one of the conflicting interfaces.
- Get rid of traits but have equivalents to stuff like
trait-sumfor interfaces. - Get rid of
augmentandaugride. These don't really seem fundamental to an object system and are very confusing. - Allow for implementing an interface for existing types in a similar way to how generics work in Racket currently at the definition site of the interface. Probably don't allow for implementing an interface for existing types outside of the interface definition since this plays fast and loose with separate compilation.
- Get rid of the distinction between public/private and rely on the module system for this. i.e., just provide out the field-ids that are not internal. This also pretty much eliminates the need for private methods since you can just use regular functions which have access to all the fields already.
- For high performance code, have a way to convert a method-id and a class to a normal function which just checks that the class of the input object is correct but does not do any resolution to find the actual ID of method in case it has been overriden, sort of like a non-virtual call in C++. Something like
(method-and-class->proc method-id class-id).
Part of the reason the class system is so disconnected is that it existed before the module system and the macro system(s). In modern racket, the class system doesn't solve any problems I have and the only reason I'd use it is because the GUI framework forces me to use it.
So maybe a better approach to unifying the class system with the rest of racket would be to ignore it entirely and write a GUI system that doesn't require users write classes in the first place.
@jackfirth there is still a need for something like an object system. Even if many people avoid using classes, the generics system is pretty unavoidable since it gets used for very fundamental things like equality, hashing, etc.
I personally think both generics and classes are a bit clunky, just for different reasons.
Perhaps this could be expanded as a discussion for how Rhombus should dynamically dispatched functions (i.e. dispatched according to both the function and one or more arguments). Obviously higher order functions provide a type of dynamicism out of the box.
I think that task of reimplementing the class system and the unit system as macros instead of within the compiler and runtime was one of the main drivers of the Racket macro system's early development and its divergence from Scheme. So I don't consider the history to be a cause of the disconnect.
The disconnect is mainly because the class system offers a different way to organize code, and it occupies a slightly more dynamic point on the dynamic/static spectrum than most of the rest of Racket. That's useful, and I use the class system often for non-GUI related programming.
I think that task of reimplementing the class system and the unit system as macros instead of within the compiler and runtime was one of the main drivers of the Racket macro system's early development and its divergence from Scheme. So I don't consider the history to be a cause of the disconnect.
I think of it as a cause of disconnect because there wasn't really any way to change existing APIs to stop using classes and units, since they needed to maintain backwards compatibility. There's plenty of things in the main distribution that use units but which probably wouldn't if they were redesigned today.
there wasn't really any way to change existing APIs to stop using classes and units
Interesting, that's been a concern I've had about generic interfaces, a concern which I think https://github.com/racket/racket/pull/3627 is addressing. Maybe there could be something like that for classes too, letting a struct type property or generic interface inherit from a class? Then existing systems that use classes could have generic-interface-based versions phased in to take the spotlight from them. (Or if not generic interfaces, whatever Rhombus has in place of generic interfaces.)
There were a lot of different topics about class systems discussed today in the Rhombus meeting. One of the topics was how to harmonize the approaches (i.e., this topic).
In particular, I believe @rmculpepper mentioned one reason to prefer Racket's classes to generic interfaces is for situations where the interface seen by users and the interface seen by implementers involve different sets of methods. @rmculpepper, do you have examples of that?
I see structure type properties and generic interfaces (which I believe are now inter-expressible thanks to make-generic-struct-type-property) as kind of a sufficient low-level mechanism for lots of things, although maybe not as ergonomic as they could be for common patterns. This might be one of those points.
With structure type properties in particular, If I want to give an arbitrarily different interface to implementers, I can define something like build-flat-contract-property just for implementers to use.
With generic interfaces, there's no particularly obvious way to do that. The methods in a #:methods block are identified by the same bindings that users can invoke the method by, so they're publicly visible methods. There are a few exceptions that aren't publicly visible, like equal-proc, hash-proc, and hash2-proc of gen:equal+hash, but I think these expose some weird scoping behavior. (If I recall correctly, I think these have to be unbound in order to be recognized in the #:methods block.)
So when I think I might want to do this, I lean toward using structure type properties. I often find functions like build-flat-contract-property to be less readable than a #:methods block, and I don't think they feel as seamless in a library's design since the documentation for implementing a method and the documentation for calling it aren't one in the same, but in situations where this distinction is already part of the essential complexity, they seem like an acceptable compromise.
It sounds like perhaps there's a pretty ergonomic way to do this with Racket's classes? I think classes have a documentation advantage similar to generic interfaces, so maybe they don't lead to as much of a compromise here.
In particular, I believe @rmculpepper mentioned one reason to prefer Racket's classes to generic interfaces is for situations where the interface seen by users and the interface seen by implementers involve different sets of methods.
What's wrong with solving this with an adapter between two interfaces? As in, interface X is the public interface for users, interface Y is the interface for implementors, and there exists a standard implementation of X in terms of Y.