Sometimes you really do need an indirection, and so it doesn't make sense to “erase” an abstraction at runtime. Traits solve a variety of additional problems beyond simple abstraction.
All told, the trait system is the secret sauce that gives Rust the ergonomic, expressive feel of high-level languages while retaining low-level control over code execution and data representation. This post will walk through each of the above points at a high level, to give you a sense for how the design achieves these goals, without getting too bogged down in the details.
Before delving into traits, we need to look at a small but important detail of the language: the difference between methods and functions. Are tied to a single concrete “self” type (specified via the imply block header).
Are automatically available on any value of that type -- that is, unlike functions, inherent methods are always “in scope”. The first parameter for a method is always an explicit “self”, which is either self, smut self, or self depending on the level of ownership required.
Unlike interfaces in languages like Java, C# or Scala, new traits can be implemented for existing types (as with Hash above). But assuming Hash is in scope, you can write true.hash(), so implementing a trait extends the set of methods available on a type.
Defining and implementing a trait is really nothing more than abstracting out a common interface satisfied by more than one type. The print_hash function is generic over an unknown type T, but requires that T implements the Hash trait.
That is, as with C++ templates, the compiler will generate two copies of the print_hash method to handle the above code, one for each concrete argument type. That means there is no extra cost dispatching to calls to hash and EQ, as above.
That information is enough to dispatch calls to methods correctly at runtime, and to ensure uniform representation for all T. Static and dynamic dispatch are complementary tools, each appropriate for different scenarios.
Rust's traits provide a single, simple notion of interface that can be used in both styles, with minimal, predictable costs. We've seen a lot of the mechanics and basic use of traits above, but they also wind up playing a few other important roles in Rust.
Rust has a handful of “markers” that classify types: Send, Sync, Copy, Sized. Rust does not support traditional overloading where the same method is defined with multiple signatures.
The point: despite their seeming simplicity, traits are a unifying concept that supports a wide range of use cases and patterns, without having to pile on additional language features. One of the primary ways that languages tend to evolve is in their abstraction facilities, and Rust is no exception: many of our post-1.0 priorities are extensions of the trait system in one direction or another.
This is particularly problematic when you want to return a closure that you'd like to be statically-dispatched -- you simply can't, in today's Rust. Rust does not allow overlap between trait implementations, so there is never ambiguity about which code to run.
This limitation makes it difficult to provide a good set of container traits, which are therefore not included in the current standard library. Hit is a major, cross-cutting feature that will represent a big step forward in Rust's abstraction capabilities.
Finally, while traits provide some mechanisms for reusing code (which we didn't cover above), there are still some patterns of reuse that don't fit well into the language today -- notably, object-oriented hierarchies found in things like the DOM, GUI frameworks, and many games. Accommodating these use cases without adding too much overlap or complexity is a very interesting design problem, and one that Nike Mistakes has started a separate blog series about.
Here's a table of contents of what you'll learn in this lesson:(click on a link to skip to its section) A trait is, essentially, a struct that doesn’t define bodies for its methods.
But, because they fly at dramatically different speeds, the logic in their methods can’t be the same. When we then implement the trait for each of the structs, we can apply different logic to them.
We would most likely need to add more methods to the Flyable trait, like take_off, landing, etc. If we had to write individual methods for both structs, the code would quickly become unnecessarily bloated.
We also force developers to use the same standard set of behaviors for all entities that can fly, even if they do so differently. In the example above, we add another trait called Shoppable with a hopping() method.
If we added a penguin, we could implement Shoppable, but not Flyable. We absolutely love traits (and interfaces in other languages) and recommend using them as much as possible for bigger and more complex projects.
A trait is implemented for a specific struct similar to a function. Trait methods are called normally, nothing special is needed to use them, except an implementation.
By contrast, when rust uses runtime dispatch, it passes interfaces around as a pair of pointers (the table, and the data). The underlying data-structure does not need to know what interfaces are implemented for it or being used at any time, or if it’s being used for compile or runtime dispatch at all.
The two schemes have different strengths and weaknesses, but rusts overall simplification is in re-using the traits for generic type bounds (which C++ will do with a completely new feature, ‘concepts’), and it’s easier to extend/decouple. When there’s only one pointer to something (e.g. trees) they should be the same, and rust has the advantage that you’re not bloating the structure in the cases where you just want static dispatch.
> And I don't see any significant effort being made at making it an easy tool to learn and use. Just to name a few off the top of my head: A strong worse-is-better approach in many aspects of the language design, such as preventing reference-counted cycles (we don't try to), numeric overflow (we don't try except in debug mode), type class desirability (it's deliberately undecidable in corner cases to avoid complex rules), prevention of deadlocks (we don't try), user land threads (we don't implement them anymore), asynchronous I/O (it's out of the domain of lib std for now), etc.
Naming conventions designed to fit well with C, for example choosing “ENIM” over “data”/”datatype” as in ML, “trait” over “class” as in Haskell (since the latter means something totally different), but modified in some cases to avoid leading programmers of C-like languages astray (for example, “interface” changing to “trait”). Certainly we weren't perfect, but there was a lot of effort put into making Rust as easy to use as possible.
What happens in Rust if you define a function that implements a trait but you don't use imply? You need to explicitly implement the trait; happening to have a method with matching name/signature is meaningless for Rust.
Not providing static dispatch can be a significant performance hit for certain cases (e.g. the Iterator one I mention below). This is the only thing that is significantly more powerful and it's ultimately just a replacement for copying and pasting methods with different type signatures.
But I'll cover it in more detail, because it's worth understanding the difference deeply. This allows for optimizations like inclining, because the compiler knows exactly which function is being called.
There are instances where dynamic dispatch is really helpful (and sometimes more performant, by, e.g. reducing code bloat/duplication), but static dispatch allows compilers to inline the call sites and apply all their optimizations, meaning it is normally faster. Furthermore, emphasizing traits and reemphasizing reflection gives Rust much stronger parametric polymorphism : the programmer knows exactly what a function can do with its arguments, because it has to declare the traits the generic types implement in the function signature.
Go's approach is very flexible, but has fewer guarantees for the callers (making it somewhat harder for the programmer to reason about), because the internals of a function can (and do) query for additional type information (there was a bug in the Go standard library where, CIRC, a function taking a writer would use reflection to call Flush on some inputs, but not others). This is somewhat of a sore point, so I'll only talk briefly, but having “proper” generics like Rust has allows for low level data types like Go's map and to actually be implemented directly in the standard library in a strongly type safe way, and written in Rust (Yashmak and DEC respectively).