8 ms·
The Expression Problem and its solutions (2016)
- kragen 1y agoWe were discussing this a few days ago in https://news.ycombinator.com/item?id=45121080 https://news.ycombinator.com/item?id=45121080.
- jauntywundrkind 1y agoRust doesn't merely have a way around the Expression Problem, the entire language & it's set of libraries is built constructively / additively bottom-up atop very dumb types, with operations added latter! Traits and impls invert the responsibility to define operations, are still static definitions & static types but late-created / created-elsewhere types, that aggregate more and more impls than the type is initially defined with. An inversion of control of typing. Dynamic but still compile-time static types. It's refreshing to see this very clear problem statement, which feels like an ideal anti-thesis that reveals the hidden glory of Rust's traits/impls: > It turns out that adding new operations isn't as easy as adding new types. We'd have to change the Expr interface, and consequently change every existing expression type to support the new method(s). If we don't control the original code or it's hard to change it for other reasons, we're in trouble. > In other words, we'd have to violate the venerable open-closed principle, one of the main principles of object-oriented design, defined as: > software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification Apologies for the self link, but this came up very recently in a sub thread about Rust & I'm so glad to close the loop & find a name for this. I said there & I'll say again, I think this is one of Rust's Greta superpowers, that we are not trapped by the design parameters of the libraries we consume, that Rust allows us to layer in more and more to our types, as we please. An amazing superpower of Rust that imo gets way too little attention, where-as the borrow-checking tends to soak all the attention/fixation for the language. https://news.ycombinator.com/item?id=45041286#45045202 https://news.ycombinator.com/item?id=45041286#45045202 There's a whole chapter of the Rust book on Rust & OOP, btw, which provides a side-ways look at how Rust & OOP and the Expression Problem relate. https://doc.rust-lang.org/book/ch18-00-oop.html https://doc.rust-lang.org/book/ch18-00-oop.html C# and others have extension methods, where you can add operations. But most of the language & it's use is pretty conventional OOP; it's a feature not the core. Mentioned in the thread above, there's a claim that Standard ML functors have similarities to rust: I haven't investigated yet but that'a piqued my interest & I'm eager to find out at some point!
- Kranar 1y agoNothing you mention is related to this article and neither Rust or C# solve the expression problem. The expression problem is about being able to extend both data types (new cases) and operations (new functions) without modifying existing code while preserving static type safety. C#'s extension methods can't be virtual, so you can't use them to actually add any new operations which can be dispatched on. They're just syntactic sugar for adding static methods which in non-OOP languages can be accomplished by declaring a free function. Rust traits let you add new types (just impl the Trait), but it doesn't let you add new functions, so it doesn't do anything to solve the problem.
- justinpombrio 1y agoRust's traits _do_ solve the expression problem. Each data type is a `struct`. Each operation is a trait. You `impl` each trait on each struct. This works even if you're using a library that has declared `struct A` and `struct B` and `trait F` and `trait G`, and you want to add both a `struct C` and a `trait H`, and fill out the whole 3x3 grid without modifying the library. The library says: struct A { ... } struct B { ... } trait F { ... } impl F for A { ... } impl F for B { ... } trait G { ... } impl G for A { ... } impl G for B { ... } fn some_function<T: F + G>(data: T) { ... } Your code says: use library::{A, B, F, G}; struct C { ... } impl F for C { ... } impl G for C { ... } trait H { ... } impl H for A { ... } impl H for B { ... } impl H for C { ... } fn another_function<T: F + G + H>(data: T); Now `library::some_function()` can be called with an A, B, or C, even though C was defined by the user of the library. And `another_function()` can be called with A, B, or C, even though A was defined by the library.
- wavemode 1y agoThat's not the expression problem. The expression problem is the question of, what is required to add new methods to an existing trait. In your example, it requires modifying the existing impls for all types implementing the trait. What you've done is different, you've simply added a new trait entirely. That's not unique to Rust, you can add new interfaces in any language...
- mkleczek 1y agoI think this is missing mentioning: Object algebras: https://i.cs.hku.hk/~bruno/oa/ https://i.cs.hku.hk/~bruno/oa/ Tagless final: https://okmij.org/ftp/tagless-final/course/lecture.pdf https://okmij.org/ftp/tagless-final/course/lecture.pdf and Data types a’la carte: https://webspace.science.uu.nl/~swier004/publications/2008-jfp.pdf https://webspace.science.uu.nl/~swier004/publications/2008-j...
- amluto 1y agoThis article discusses details of trying to use language features to model a fairly basic problem but doesn’t discuss the fundamentals of the problem very much. If I have O operations (e.g. evaluate, stringify) and T types, then I need O•T implementations. If I fix O, I can add types until the cows come home by adding O implementations per new type. If I fix T, I can add new operations with T implementations per operation. If I want to let O and T vary, then the number of implementations I need to add per additional operation/type varies depending on what I’ve added before. No amount of programming language magic will change this basic count. ISTM what’s really going on in the article is that the author is using the programming language as a registry of implementations. Those O•T implementations need to be stored somewhere so their callers can find them. In C++ this can be done when very verbose inheritance and virtual calls. In other languages, it’s less verbose. If multiple dispatch is built in, then one can use it directly. One can, of course, also build an actual table indexed on operation and type as a data structure in just about any language and use it, and in a language like C++ the result may well be quite a bit nicer than using a class hierarchy.
- kqr 1y agoHuh, this is a very neat way to put it. Something about it seems... too neat? But I'm the wrong person to poke holes in it. Can I see other people debate it civilly?
- magnio 1y agoMy first exposure to the expression problem is the book Crafting Interpreters, Section 5.3.1,[1] which is very similar to the GP comment. The subsection is a short few paragraphs, with one or two tables for illustration, and it was one of the most eureka moments of my programming career. Ever since then, I see this trade-off everywhere, and I get reminded that engineering is all about finding the best trade-off and that the holy-grail of a perfect language or design is a waste of time. [1]: https://craftinginterpreters.com/representing-code.html#the-expression-problem https://craftinginterpreters.com/representing-code.html#the-...
- moelf 1y agothis is counting it as if O only takes an argument, which is single dispatch, which OOP covers already (the `self` is the single argument). In practice, Multiple Dispatch shines when you have 1) more than one argument type (duh) 2) higher order `O` operation [1] [1]: think of a numerical routine that calls eigen or something, and you want eigen to exploit the matrix's type, such as symmetry or upper-triangular, and this is encoded as a matrix type)
- nine_k 1y agoIn short, the solution is to define an interface / typeclass / trait / protocol that factors out the particular kind of operation, and separates it from any specific class or data type. Basically it allows to tag any particular data representation with a trait, and then offer an implementation of that trait, without touching any pre-existing code related to that data representation. In languages that do not allow that (e.g. Java), one has to implement it by hand, using a Visitor pattern. Instead of relying on the language to do the dynamic dispatch, one has to explicitly add a visiting method for another data type. To me, the turn towards interfaces / typeclasses / traits / protocols is one of the most notable bits of progress in the newer crop of programming languages (and, importantly, their standard libraries).
- gf000 1y agoThat just rotates the problem 90 degrees - now adding new functions is easy, but adding new data is hard (requires rewriting every use site). I really recommmend giving this a read: https://craftinginterpreters.com/representing-code.html#the-expression-problem https://craftinginterpreters.com/representing-code.html#the-...
- geokon 1y agoYou should read the linked article to the end (or maybe you did but missed it) The problem as explained in what you linked: > an object-oriented language wants you to orient your code along the rows of types. A functional language instead encourages you to lump each column’s worth of code together into a function. The point was that the final protocol implementation in Clojure solves this. There is no more lumping. The whole thing is freeform. You can extend an existing record (say it's coming from a library or somewhere outside your control) and add interfaces. Or you can make a new record and implement all the existing interfaces
- mdaniel 1y agoSableCC used that visitor pattern for its outputs, and then immediately had an "empty implementation" of the interface to allow folks to subsclass the parts that were relevant https://github.com/SableCC/sablecc/blob/sablecc-4-beta.4/src/org/sablecc/sablecc/core/analysis/IGrammarVisitor.java https://github.com/SableCC/sablecc/blob/sablecc-4-beta.4/src... https://github.com/SableCC/sablecc/blob/sablecc-4-beta.4/src/org/sablecc/sablecc/core/analysis/GrammarVisitor.java https://github.com/SableCC/sablecc/blob/sablecc-4-beta.4/src... https://github.com/SableCC/sablecc/blob/sablecc-4-beta.4/src/org/sablecc/sablecc/core/walker/AssignabilityVerifier.java https://github.com/SableCC/sablecc/blob/sablecc-4-beta.4/src... I think Antlr 4 does similar, I just haven't used it in as much anger because its runtime always jammed me up https://github.com/antlr/antlr4/blob/4.13.2/doc/listeners.md#parse-tree-listeners https://github.com/antlr/antlr4/blob/4.13.2/doc/listeners.md... I thought that IntelliJ did similarly but I am misremembering since they generate recursive descent style https://github.com/JetBrains/Grammar-Kit/blob/v2023.3/HOWTO.md#21-parser-basics https://github.com/JetBrains/Grammar-Kit/blob/v2023.3/HOWTO....
- DarkSucker 1y agoThe article's `expression problem matrix` section states that the goal is make it `easy to add ops` and `easy to add types`. My learning of Rust so far indicates Rust satisfies both: traits satisfies the `ops` problem for all traits you want to support the op, and Rust's implementations (impl) solves the problem of adding types. Of course, for each new {op,type} combination, one must write the code, but Rust allows you to do that with its trait and generic systems. Am I missing something important?
- wavemode 1y agoWhen people talk about the "expression problem", what they're describing is the fact that, (in your example) if you add a new method to the trait, you have to go around and implement that new method on every type that implements the trait. This is in contrast to if you had used an enum (sum type) instead, wherein adding a new operation is easy and can be done in a single place. But then in exchange, adding new variants requires going around and updating every existing pattern match to support the new variant.
- DarkSucker 1y agoThanks. I wasn't thinking of enums. To the extent one designs a trait to use an enum type (or the enum to satisfy the trait), one wins. But it seems impossible to avoid code to handle all future {type, op} combinations. The nice thing I've seen with Rust is the ability to add to what's been done before without breaking what already works. I'm thinking of "orphan rules" here.
- TuringTest 1y agoI'm thinking, didn't inheritance and abstract methods work to solve the problem? I know inheritance has its own severe problems, but adding a generic abstract method at the base class could create reusable code that can be accessed by any new class that inherits from it. P.S. ah ok, it's mentioned in the article at the Visitor section.
- zelphirkalt 1y ago
- dang 1y agoRelated. Others? The Expression Problem and its solutions - https://news.ycombinator.com/item?id=11683379 https://news.ycombinator.com/item?id=11683379 - May 2016 (49 comments)
- almostgotcaught 1y agoITT: lots of people who think they understand the expression problem but don't actually understand the expression problem.
- zelphirkalt 1y agoI think one of the solutions is the data oriented approach, that is already shown in SICP, where you have a global lookup table, in which you lookup by types and operation. In SICP the characters or you then add modules, which is done by registering new types-operations pairs in the table, if I remember correctly.
- monocularvision 1y agoA pretty decent solution for the Expression Problem using Swift: https://www.youtube.com/watch?v=EsanJ7_U89A https://www.youtube.com/watch?v=EsanJ7_U89A We use this pretty effectively in our iOS app to model and implement Effect types in a functional core / imperative shell architecture.
- mccoyb 1y agoDoes Clojure solve this to a satisfying degree? One gives up static typing to embrace Clojure’s solutions. On the other hand, Rust / Haskell provide partial solutions with static typing — but run into annoying boilerplate related to the orphan rule. I’m not sure if there’s a true solution, given the level of thinking which has gone into these two forks in the road.
- mrkeen 1y agoI was also confused by the claim that Clojure solves it and Haskell does not. > run into annoying boilerplate related to the orphan rule I always forget this, but it's not an orphan if you put the instance in the same module as the class definition (as opposed to bundling it with a newtype elsewhere). Class definitions and instances would go in Evaluatable and Stringable.
- geokon 1y agoMaybe Haskell has some solution .. but it seems just an inherent problem with static typing. It's mostly a matter of boilerplate in a sense. In Clojure, if in your application you extend a record (say from some library) and implement a new protocol, you've effectively implicitly created a new extended record type. In Clojure that happens transparently b/c its dynamic. In the static typing scenario you have to explicitly create this "extended type" that has the parent type and also implements the new protocol. You'd then explicitly use ExtendedRecordA. It can still be used where the parent was expected. That's more verbose but maybe manageable.. Now if you have multiple libraries extending one record with different protocols .. and then you have a library/application where you included all these libraries. You want to use all these protocols from that one record.. how do you not do that? you'd need some aggregation step to make ExtendedRecordA and ExtendedRecordB and ExtendedRecordC all come together under some new super-type that has all the protocols. It's not impossible but it's just messier