Traits were introduced in Mojo recently, so I thought I'd try them out. Currently, the built-in traits include CollectionElement, Copyable, Destructable, Intable, Movable, Sized, and Stringable(looks like the "-able" suffix is a thing in these naming conventions!).
Traits work on structs. To implement a trait, you simply add a method to a struct that matches the trait; then pass the trait name as a parameter:
@value
struct Duck(Quackable):
fn quack(self):
print("Quack!")
The @value
decorator in Mojo inserts the lifecycle methods like __init__()
, __copyinit__()
and __moveinit__()
into the struct, simplifying our life a bit as we don’t have to add them ourselves.
Traits in Mojo don't support default implementations for methods yet, hence, the ...
in the body of the Quackable
method above. You can also use pass
, which will have the same effect, as in Python.
Despite the different name, the basic approach to traits in Mojo reminds me of Go interfaces. In Go, you would define a Duck
struct and implement a Quackable
interface like this:
type Quackable interface {
quack()
}
And to make a struct that satisfies this interface:
type Duck struct {}
func (d Duck) quack() {
fmt.Println("Quack!")
}
Compare it to a Mojo implementation:
trait Quackable:
fn quack(self):
...
@value
struct Duck(Quackable):
fn quack(self):
print("Quack!")
I think the Mojo version is even more concise, nesting the quack()
method definition inside the Duck
struct type. In addition to working on structs, traits in Mojo don't require an implements
keyword, just like in Go.
Interestingly, the point is made in Mojo docs that default implementations are not allowed yet, meaning they might be allowed in the future. This means that Mojo might pursue an approach different from Go, which focuses on structs satisfying an interface, not implementing it.
Default method implementations are not needed in Go, and a similar effect is achieved with embedding, as interface/trait implementation is simply a concept that doesn't exist in Go. Things might be different in Mojo.
The value of traits and interfaces is to make code reusable. For instance, in Mojo, you can write functions that accept trait types…
fn make_it_quack[T: Quackable](could_be_duck: T):
could_be_duck.quack()
And then pass different structs that implement the same trait as input - everything will just work! For example, here's a Goose
struct that implements Quackable
:
@value
struct Goose(Quackable):
fn quack(self):
print("Honk!")
And here, to call make_it_quack
on both the Goose
and the Duck
(remember, you need the main
function in Mojo as an entry point to your program):
def main():
make_it_quack(Duck())
make_it_quack(Goose())
The output of this will be
Quack!
Honk!
If I tried to pass in something that doesn't implement the Quackable
trait to the make_it_quack
function, let's say StealthCow
, the program won't compile:
@value
struct StealthCow():
pass
make_it_quack(StealthCow())
The error message below could have been more descriptive; maybe the Mojo team will improve it?
error: invalid call to 'make_it_quack': callee expects 1 input parameter,
but 0 were specified
Same if I remove the quack
method from Goose
; here, we get a nice, descriptive error:
struct 'Goose' does not implement all requirements for 'Quackable'
required function 'quack' is not implemented
The advantage of this kind of static type checking over the pure Python approach is that we can catch errors easily and avoid shipping any mistakes to production as the code will simply not compile.
Traits in Mojo already support inheritance, so our `Quackable` trait can extend an `Audible` trait like so:
trait Audible:
fn make_sound(self):
...
trait Quackable(Audible):
fn quack(self):
...
This means that a Duck
struct will have to implement both quack
and make_sound
to conform to the Quackable
trait.
This is similar to the concept of "Interface embedding" in Go, where to make a new interface that inherits from other interfaces, you would embed parent interfaces like this:
type Quackable interface {
Audible // includes methods of Audible in Quackable's method set
}
Traits also accept static methods that work without creating an instance of a struct:
trait Swims:
@staticmethod
fn swim():
...
@value
struct Duck(Quackable, Swims):
fn quack(self):
print("Quack!")
@staticmethod
fn swim():
print("Swimming")
fn make_it_swim[T: Swims]():
T.swim()
You call a static method like this:
def main():
make_it_quack(Duck())
make_it_quack(Goose())
Duck.swim()
Which will output:
Quack!
Honk!
Swimming
Notice that in the last method call, a Duck instance is not created. This is how static methods in Python work, departing somewhat from object-oriented programming. Mojo builds on this Python functionality.
Interestingly, the Go trick with an empty interface{}
that allows to pass any type and was popular with the Go community before Go Generics were introduced, will not work with a Mojo-typed function fn
.
Your struct has to implement at least one of the lifecycle methods like, __len__
or __str__
, which in this case, would make it conform to Sized
or Stringable
, to be used with functions that accept trait types.
This is actually not a real limitation and makes a lot of sense in Mojo, since, with Mojo, if you need dynamic typing, you can just fall back onto the more flexible def
function, and then apply the usual Python magic to work with unknown types.
The more strict Mojo fn
functions also work on generic structs using types like DynamicVector
; read more about that here.
Another limitation I see has to do with structs and their methods rather than with traits and is a potential obstacle to implementing Clean Architecture/separating code into different parts.
Consider one of the previous examples with a Go vs. Mojo struct method definition:
type Duck struct {}
func (d Duck) quack() {
fmt.Println("Quack!")
}
@value
struct Duck(Quackable):
fn quack(self):
print("Quack!")
The Mojo example, following a more Python-like syntax, nests the method definition directly inside the struct, while the Go version allows it to separate it from the struct itself. In this version, if I have a very long struct with many types and methods, it will be somewhat harder to read.
It's not a critical difference though, just something to be aware of.
Mojo traits are already pretty useful, despite the language being in its early days. While Go philosophy is all about simplicity and tries to keep the features to a minimum, it's likely that we'll see more functionality added to Mojo traits in the future, making them even more powerful and enabling tons of different use cases.