10 ms·
Traits are a local maximum
- armchairhacker 2y agoI'd propose just getting rid of the orphan rule and keeping global coherence. If a crate has multiple dependencies with the different implementations on the same trait/type, let that crate select or define one implementation that will override the others, even internally. Then I'd trust library authors to write orphan implementations sparingly, making sure they're either "obvious" (there's no other reasonable implementation for the same trait/type) or their internals aren't relied on, just the fact that the trait/type has a reasonable implementation (like defining `Serialize` and `Deserialize` but only relying on both being inverses, so a dependent crate could override them with a different `Serialize` and `Deserialize` implementation and the library would still work). I'd claim the libraries that define bad orphan instances must be poorly written, and you should only depend on well-written libraries. If you want to depend on libraries A and B which both rely on conflicting orphan implementations, don't bother trying to patch one of them, instead re-write the libraries in a better way to keep your codebase ideal. ...I still want that kind of system, but I expect it would fail catastrophically in the real world, where developers aren't perfect, important projects depend on badly-written npm packages, and Hyrum's law is pervasive. --- So instead I propose something more reasonable. Keep global coherence and: - Get rid of the orphan rule for applications. An application has no dependents, so the entire issue "two dependencies differently implement the same trait on the same type" doesn't apply. - Next, create an opt-in category of libraries, "glue" libraries, which can only define orphan implementations (if they absolutely need a unique type, e.g. an associated type of a type/trait implementation, it can be put in another crate that is a third dependency of the glue library). Glue libraries can only be depended on by applications (not libraries, including other glue libraries). This allows orphan code reuse but still prevents the vast majority of code (the code in libraries) from depending on orphan implementations. Library authors who really want to depend on orphan instances can still do ugly workarounds, like asking application developers to copy/paste the library's code, or putting all the library's functions in traits, then implementing all the traits in a separate "glue" library. But I suspect these workarounds won't be an issue in practice, because they require effort and ugly code, and I believe trying to avoid effort and ugly code is what would cause people to write bad orphan instances in the first place. Also note that library authors today have ugly workarounds: they can copy/paste the foreign trait or type into their library, and ask developers to "patch" other libraries that depend on the foreign crate to depend on their library (which can be done in `Cargo.toml` today). But nobody does that. Ideally, a library that really needs an orphan implementation would use a better workaround: create a new trait, that has the foreign trait a supertrait, and methods that implement functionality of the foreign type, then use the new trait everywhere you would use the foreign type. I suspect this solves the global coherence problem, because an application could depend on the glue library that implements your library's trait on the foreign type, but it could alternatively depend on a different glue library that implements the trait on a wrapper, and if there's another library that requires a conflicting implementation of the foreign type, its trait would be implemented on a different wrapper.
- Archit3ch 2y ago> ...I still want that kind of system, but I expect it would fail catastrophically in the real world Julia has its own version of the orphan rule called Type Piracy, which is merely discouraged instead of disallowed: https://viralinstruction.com/posts/latency/#a_brief_primer_on_invalidations https://viralinstruction.com/posts/latency/#a_brief_primer_o... It's not a net gain, but rather a different tradeoff.
- ragebol 2y ago*local maximum. Maxima is plural, maximum is singular. /pedantic Now I'll read the actual article
- azangru 2y ago> Maxima is plural In Latin. I am surprised this word hasn't been internalised enough to just use the regular English plural marker. Maximum / maximums.
- deleted 2y ago[deleted]
- AndrewDucker 2y agoIt's because they've used the plural of "Trait" in an ungrammatical way, and so used the plural of "maxima". Traits aren't a local maxima - the trait design is a local maximum. Or traits are at a local maximum. The traits themselves aren't a maximum (or maxima (or maximae)).
- em-bee 2y ago(the concept of) traits is a local maximum
- em-bee 2y agobased on the downvote this received i guess something is wrong with that sentence. but i am not an english native, and i can't tell what would be wrong with it. i am not bothered by the downvotes, if it is wrong then so be it, but i would like to know why it's wrong.
- smitty1e 2y agoIf one squints, this is the heart of the article: scope. The lust to get to a final, complete set of types across all projects for the system for all time will never be satisfied. The question moves to who has the preferred shims.
- oniony 2y agoRandom number joke is borrowed from XKCD: https://xkcd.com/221/ https://xkcd.com/221/.
- ivanjermakov 2y ago> We can elucidate our woes by conjuring some contrived Rust code I need to work on my English vocabulary..
- deleted 2y ago[deleted]
- ggambetta 2y agoNo. Good technical writing is about making the reader feel smart, not the writer. Using big words goes in the opposite direction. The real art is in explaining complex ideas in simple terms.
- MathMonkeyMan 2y agoI think the idea is that the sentence introduces a contrived example, and so the author used roundabout words. Snake sentences sound sibilantly. It's a blog post.
- em-bee 2y agobut i think i understand the sentence, and so reading it makes me feel very smart ;-)
- thunderseethe 2y agoThat ones on me. I blog as a way to practice writing, and right now I'm working on picking more interesting verbs. I have a usage of `proselytize` later on that is outright smarmy.
- senderista 2y agoRelated nitpick: “maxima” is the plural form, “maximum” is the singular.
- ggambetta 2y agoSince you care about the quality of your writing: you almost certainly meant "one's" or "one is", not "ones". "Outright smarmy" is spicy, I give you that :)
- AndrewDucker 2y agoThat was fascinating, but you can see why the Rust designers decided not to go with that approach.
- withoutboats3 2y agoThis post is written by a fan of implicits, so it frames it as "better" than traits, though at the end it admits it is in fact a complex trade off, which is the truth. In my opinion, the trade off favors traits, but others may feel differently. The core difference between traits (also called type classes) and ML modules is that with traits the instance/implementation has no name, whereas for ML modules they do. The analogy here is between Rust/Haskell's traits/typeclasses and ML's signatures and between Rust/Haskell's impls/instances and ML's structures. In Rust/Haskell, implementations are looked up by a tuple of types and a trait to determine the implementation. The advantage of this is that you don't need to name the impl and then invoke that name every time you use it; since we usually don't think of "Hash for i32" as something which has a meaningful name beyond the relationship between Hash and i32, this is quite nice. But coherence requires that instances resolve consistently: if I hash an integer in one code location to insert into a map and then hash it again in a different location to do a lookup on the same map, I need to hash integers the same way each time. If you care about coherence, and the correctness property it implies, you can't allow overlapping impls if impls aren't named, because otherwise you aren't guaranteed a consistent result every time you look up the impl. This introduces another problem: you can't see all the impls in the universe at once. Two libraries could add impls for types/traits in their upstream dependencies, and the incoherence won't be discovered until they are compiled together later on. This problem, called "orphan impls," causes its own controversy: do you just let downstream users discover the error eventually, when they try to combine the two libraries, or do you prohibit all orphan impls early on? Rust and Haskell have chosen different horns of this dilemma, and the grass is always greener. Of course with implicits, this author intends a different solution to the problem of resolving instances without naming them: just allow incoherence (which they re-brand as "local coherence"). Instead, incoherence impls are allowed and are selected in a manner based on proximity to code location. As the post eventually admits, this does nothing to solve the correctness problem that coherence is meant to solve, because code with different nearest impls can be compiled together, and in Rust such a correctness problem could become a memory safety problem, and how you figure out if the impl you've found for this type is actually the nearest impl to your code is left as an exercise to your reader. But sure, since you've rebranded incoherence to "local coherence" you can do some juxtaposed wordplay to call coherence a "local maxima" because achieving it has the downside that you can't have arbitrary orphan impls.
- logophobia 2y agoWhat is wrong with the following solution? Any trait implementations where the type and trait are not local are: * Private, and cannot be exported from a crate * The local trait implementation always overrides any external implementation That would solve part of the problem right? Only crate libraries that want to offer trait implementations for external traits/types are not possible, but that might be a good thing. The solution proposed by the author with implicits is quite complex, I can see why it wasn't chosen.
- yccs27 2y agoI think Rust assumes that trait implementations are the same across the whole program. This avoids problems e.g. with inlining code or passing data structures between crates. I don't believe this is absolutely necessary though.
- withoutboats3 2y agoImplementations are not exported or public at all: they are used in functions and those functions are exported. For correctness, you want those implementations to be resolved consistently (this is what coherence is). This post gives the example of unioning two sets: you need to know that they're ordered the same way for your algorithm to work. So the problem isn't that the implementation is public, it's that its used somewhere by a function which is public (or called, transitively, by a public function). For a library, code which is not being used by a public function is dead code, so any impl that is actually used is inherently public. You might say, okay, well can binaries define orphan impls? The problem here is that we like backward compatibility: when a new impl is added to your dependency, possibly in a point release, it could conflict with your orphan and break you. You could allow users, probably with some ceremony, to opt into orphan impls in binaries, with the caveat that they are accepting that updating any of their dependencies could cause a compilation failure. But that's it: if you allow this in libraries, downstream users could start seeing unsolvable, unpredictable compilation failures as point releases of their dependencies introduce conflicts with orphan impls in other dependencies.
- pornel 2y ago
- wk_end 2y agoI find the problem of ad hoc polymorphism so interesting. It’s clearly a desirable feature, but we’ve been trying for nearly fifty years and still don’t have a solution that everyone’s unambiguously happy with. Compare that to, like, lexical scoping or parametric polymorphism, which most languages just have by default at this point. They’re almost mathematical facts. And you’d hope there’d be a way to do it. Parametric polymorphism feels underpowered if you can’t make any assumptions about the things you’re abstracting over. But it might be a Halting Problem-esque situation. FWIW if a real, near-trade-off-free solution exists, I think it’ll require a bit of a Copernican revolution in how we approach things. I don’t have any real insight to offer there.
- untilted 2y ago> Compare that to, like, lexical scoping or parametric polymorphism, which most languages just have by default at this point. They’re almost mathematical facts. FWIW emacs lisp (in)famously still defaults to dynamic scoping -- there's been a proposal to change that default earlier this month but interestingly, there's still pushback. See e.g. https://lists.gnu.org/archive/html/emacs-devel/2024-11/msg00205.html https://lists.gnu.org/archive/html/emacs-devel/2024-11/msg00...
- dan-robertson 2y agoAt one time it was thought that implementing lexical scoping for a lisp (with lambdas, etc) was not possible to do performantly and this is one of the reasons that lots of lisps had dynamic scoping. That said, scheme has lexical scoping and I think that predates GNU Emacs by a reasonable amount of time.
- kreetx 2y agoAFAIK, dynamic scoping is still the default because of compatibility: older extensions might rely on it. For any new extension, the author should turn it on from the get go.
- dominicrose 2y agoWithout a hierarchy, a class is just a group of functions that have a same first implicit argument called this. But what if a file was a namespace? (i.e. a javascript/typescript module) Then we can just create functions and they automatically belong to a group. One or multiple constants and variables can contain the data. But do we need a hierarchy let alone multi-inheritance/traits if we just know what function to call and what file to import it from? Isn't that the job of interfaces? Isn't composition better than inheritance?
- deleted 2y ago[deleted]
- hdevalence 2y agoRust just doesn’t really have linker errors. After 8 years of programming ~exclusively in Rust it’s easy for me to take this for granted by forgetting that linker errors even exist — until I am rudely reminded by occasional issues with C/C++ code that ends up in the dep tree. This property is downstream of the orphan rules, and given the benefit I wouldn’t give them up.
- morning-coffee 2y agoSimilar observation here. In a statically linked global problem it's the linkers job to enforce the theme of the old "Highlander" movie... there can be only one. So "global coherence" is not some problem brought about Rust or Traits, but it's a fact of life. And considering the author's own admittance of "Local Implicits" having a "Correctness problem", I don't see how it could even be considered as an alternative. I'll take correctness over convenience any day of the week and twice on Sunday.
- kreetx 2y agoFor context, Haskell's story for orphan instances is currently as follows: - orphan instances are allowed and emit a warning - duplicate instances are not allowed - overlapping instances where one is different from the other, are allowed - incoherent instance use sites are not allowed (where 2+ instances match and neither is more specific than the other) - but you can enable this by adding {-# INCOHERENT #-} to instances. You shouldn't do this though unless you really know why you need it (and perhaps even then there is a better way) - a typical library sets all warnings as errors with -Wall, so you'll notice when you're adding orphans - exceptions in specific files can be made by adding -fno-orphans to the file - defining orphan instances in executables is not a problem as the only user of them will be the program itself - this is what you do if you are writing a package which only provides instances: where both the data types and the type classes are implemented elsewhere and you have no other choice. These libraries should not be used in other libraries, but only in executables and tests - a different instance can also be defined by wrapping the original type with a newtype (thus defining that new instance for this new type, thus not making an orphan) - since newtypes have no runtime overhead, also, with DerivingVia, syntactic overhead is quite low. This is "the way" to override already defined instances. IMO, all the above makes sense when you prefer correctness over flexibility. From the post, this appears to be Rust's choice as well.
- klabb3 2y agoThe newtype pattern is a special case of type composition which is incredibly useful, has low complexity, and if done right almost no boilerplate overhead. It's much dumber and easier to reason about than type-acrobatics with generics, imo.
- kreetx 2y agoDo you mean `Generically`[1]? I've only ever vaguely seen its use - perhaps it can do something a `newtype` can't (or can, but with more boilerplate)? But don't have any first-hand experience currently to comment. [1] https://hackage.haskell.org/package/base/docs/GHC-Generics.html#t:Generically https://hackage.haskell.org/package/base/docs/GHC-Generics.h...
- cjfd 2y agoThe problem seems to be that people want this to be implicit. If you have to call an explicit function to turn an instance of type A into an instance of BTrait anyone can define such a function anywhere.
- magicalhippo 2y agoAlternatively do what's done for functions in other languages (I don't use Rust) and use the most recently defined one. That could lead to different results if you change include/import order but hey if you don't want that then just be explicit.
- marcosdumay 2y agoThat's not exactly what's done for functions. Scoping rules are always more complex than that. But the good news is that the same rules are good for implicits too. You look back your tree to find a definition, and if it's defined only once on the most internal level, use that definition, otherwise require an explicit annotation. Add some way to annotate over the entire scope, and some way to export implicits globally for completeness. This will lead to all kinds of problems with implicit imports that every other kind of named object has too. That's not a big deal, developers are used to those.
- chriswarbo 2y agoThere's a nod given to dependently-typed languages (where types live in the same namespace as values, so they can be passed-into and returned-from functions), but it's useful to note that in those languages this "local coherence" approach doesn't just "look like" lambda calculus, it is lambda calculus. For example, to insert a new element into a sorted list we might define a function like this: insert: (t: Type) -> (o: Ord t) -> t -> List t -> List t insert t o x xs = case xs of Nil _ -> Cons t x (Nil t) Cons _ y ys -> case lessThanOrEq t o x y of True -> Cons t x xs False -> Cons t y (insert t o x ys) (Sure, we could also use a more specific type like 'SortedList t' or whatever; that's orthogonal to my point) Notice that the first argument `t` is a `Type`, and the second argument `o` is an `Ord t` (where `Ord` must be a function which takes a type as argument and returns another type; presumably for a record of functions). It's literally just lambda calculus. However, this obviously gets quite tedious; especially when there's so much repetition. For example, the third argument has type `t` and the fourth has type `List t`, so why do we need to pass around `t` itself as a separate argument; can't the computer work it out? Yes we can, and we usually do this with {braces} syntax, e.g. insert: {t: Type} -> {o: Ord t} -> t -> List t -> List t insert _ _ x xs = case xs of Nil _ -> Cons x Nil Cons _ y ys -> case lessThanOrEq x y of True -> Cons x xs False -> Cons y (insert x ys) Here I've indicated that `t` and `o` can be worked out from the context; I've replaced their argument names with `_` and we're no longer passing them explicitly into the recursive call, or to `Cons`, `Nil` or `lessThanOrEq` (assuming that those functions/constructors have also marked their arguments as such). This is why the feature is called "implicits", since it's leaving some arguments implicit, for the compiler to fill in using information that's in context. It works quite well for types themselves, but can get a bit iffy for values (as evidenced by this blog post; and I've seen all sorts of footguns in Scala, like defining some implicit String values and hoping the right ones get picked up in the right places...)
- themk 2y agoIt's been a while now, but I believe Ed Kmett covers some of this in his talk "Typeclasses vs The World" https://m.youtube.com/watch?v=hIZxTQP1ifo https://m.youtube.com/watch?v=hIZxTQP1ifo
- chriswarbo 2y agoThe role of unification in type systems is interesting here, e.g. for the problem of incompatible set orderings we would like the type of `union` to be something like: union<T, O : Ord<T>>: Set<T, O> -> Set<T, O> -> Set<T, O> This allows any `O : Ord<T>` we like, as long as both `Set` values have the same one. However, it's not clear what "the same" would mean. A whole-program compiler could see whether both symbols unify (i.e. they point to the same thing); but separate compilation would require a system for referencing/naming each implementation, which would come with its own headaches (e.g. stability across versions, avoiding clashes, etc.). The article mentions an approach based on naming, which I assume is related. Maybe it's time to content-address our definitions like Unison does?
- movpasd 2y agoIs there a relationship between this and delegation? Delegation feels to me that it's essentially a "named instance/impl".
- ivanjermakov 2y agoHow often do you need to impl trait crate_a::A for a type crate_b::B? If it is allowed by the language, it would mean that behavior of crate_a or crate_b would change if they link one to another. I really doubt that this is a wanted behavior. And Rust follows this logic, suggesting to use a new type to achieve the same, but not leak your impl into other crates.
- armchairhacker 2y agoFor example, if you want to serialize a type whose fields are all public but it doesn’t implement `serde::Serialize`. A lot of crates have an optional `serde` feature for this exact purpose, but not all. Another use-case is if you want more abstraction than the standard library. There’s a crate named `cc-traits` that exports traits for collections like `Insert` and `Remove`, which are implemented for types on the standard library. But if you’re using a third-party collection library like `btree_vec`, its types don’t implement the `Insert` and `Remove` traits, and you could easily implement them manually except for the orphan rule.
- ivanjermakov 2y agoI understand the orphan problem better now. It's not clear what compiler should do if you add two crates which have conflicting impls (where both implement the same foreign trait for the same foreign type)
- jerf 2y agoA lot. You're not seeing it precisely because it isn't under the streetlight, because it is currently impossible: https://en.wikipedia.org/wiki/Streetlight_effect https://en.wikipedia.org/wiki/Streetlight_effect This really amps up the composibility of a lot of systems and libraries. It would be awesome if there was a "free" way to do this. Unfortunately no one has found one yet; the posted article is a pretty good overview of that problem. Also, bear in mind that no matter what solution you are looking at, it's generally crate_c that wants to create the implementation for crate_a's type using a trait in crate_b, so it isn't as bad as the "behavior of crate_a or crate_b would change if they link one to another", which would indeed be horrifying. crate_c has to be involved somehow in the build, without that crate_a and crate_b carry on completely normally. When it's the end-user application doing the trait, at least the end-user can manage it; the core problem arises when crate_c is third-party, and user wants to use that and also crate_d, which also implements a train from crate_b on crate_a's type. At that point you have a pretty big issue; I would characterize implicits as a way of managing the problem, but not really a "solution". It is not clear there is a "solution". And there is clearly a problem; the Haskell community rammed into this problem decently hard a long time ago, back when they were smaller than they even are today, and certainly smaller back then than the Rust ecosystem as it stands today. The phase transition from theoretical problem to real problem happens in an ecosystem an order of magnitude or two smaller than the current Rust ecosystem, which is itself likely to grow yet by another order or two at least over its lifespan.
- zk4x 2y agoThis. Traits and macros are two real problems with rust. Orphan rule is one, but also const, async and unnamable types (mostly closures). These barely work with traits or do not work at all. If rust did not have closures, it'd be a lot simpler to solve these. Is it so hard to just create a normal function instead of closure? Perhaps we need to go back to the basics a bit? What is a trait? 1. A set of functions, associated types and generic types 2. A marker/tag (e.g. Send, Sync) Orphan rules do not seem to be problem for marker traits. Library authors must be responsible for enforcing whether their types are Send/Sync, etc or not. As for normal traits, it's too late for rust, but I'd just limit traits to being only sets of function definitions, e.g. trait Iterator = fn next<T>(&mut self) -> Option<T> + fn len(&self) -> usize Then adding set operations (and, or, xor, not) for traits would be pretty easy, keeping most of power for defining generics. More importantly traits could be just aliases and two traits with the same set of functions would be equal. This solves orphan problem - you would not need to import or export traits, it would be just normal resolution of functions. Do I call this function from crate A, or crate B? That's a solved problem.
- dgfitz 2y ago> trait Iterator = fn next<T>(&mut self) -> Option<T> + fn len(&self) -> usize This is why I don’t have interest in learning rust. When I see people write c++ code like this I wonder why they feel the need to be so clever. That is an abysmal thing to maintain.
- steveklabnik 2y agoThat isn’t real Rust code, though it’s made up of valid bits of Rust syntax.
- dgfitz 2y agoOh, ok that’s fair. I guess I should at least learn the grammar better before commenting more on rust.
- moomin 2y agoI mean, C#'s IEnumerator is equivalent and is way more complex. You just don't need to think about it much.
- cies 2y agoThe title here on HN says maximum, and on the article it says maxima. I seem to remember maximum is singular where maxima is plural.
- sowbug 2y agoThe editorialized title is correct. The original article isn't saying that each trait is individually a local maximum. It's saying that the concept of Rust traits is one.
- wavemode 2y agoThere's also the option of using the fact that functions are values, to simply create traitlike values and pass them as arguments to functions (a la SYT: https://www.haskellforall.com/2012/05/scrap-your-type-classes.html https://www.haskellforall.com/2012/05/scrap-your-type-classe...)
- thunderseethe 2y agoImplicits are motivated, atleast in part, by a desire to improve upon this baseline. In the world of ML modules this is the current state. Typeclasses (as modules) have to be passed everywhere they're used explicitly and it's exhausting. I think with implicits you can keep that as a baseline but provide an implicit mechanism atop it to remove a lot of the obvious boilerplate for a win-win
- leoc 2y agoThe singular of ‘maxima’ is ‘maximum’.
- cryptonector 2y agoWhat an excellent write-up, and very entertaining at that. I suspect that most devs will prefer the local maximum that traits are. Perhaps with a bit more help to ensure there's only ever one implementation of a given trait -- think of a trait registry. One might still allow multiple implementations, but with one single one chosen at final link-edit time or, if linking dynamically, at run time. To support something like that the registrations in the trait registry would have to be exacting as to each trait's semantics. A registry has its own cost: friction, and someone has to be a registrar, and registration might have a cost. But a registry has its advantages, and one could have namespaced traits, too, to enable multiple registries/registrars. Registries might feel like a hack, but so what, compared to the problems with implicits, they might be worthwhile.
- ithkuil 2y agoI wanted this feature exactly once: I was building a memory manager for a database and I needed to know exactly how much space a deep data structure was using. The servo project has the same problem and they built a library that contained a Sized trait (don't remember the name exactly) and then inplemented that trait for a bunch of stdlib and third party types and for all their types (easy to be done via a derive macro iirc). I wanted to use that library but I also had other third party types that weren't covered by the library. The quick solution I found was to basically copy the whole library into my project so that I could add my impls in the trait definition trait. Anybody knows a better solution?