paint-brush
Mojo Traits: How Do They Compare to Go Interfaces?by@a2svior
1,836 reads
1,836 reads

Mojo Traits: How Do They Compare to Go Interfaces?

by Valentin ErokhinDecember 26th, 2023
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

A walkthrough on working with Mojo Traits with examples, and a comparison of Mojo Traits to Go Interfaces. Limitations and caveats of Mojo traits.

Company Mentioned

Mention Thumbnail
featured image - Mojo Traits: How Do They Compare to Go Interfaces?
Valentin Erokhin HackerNoon profile picture

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.

Mojo Traits vs. Go Interfaces

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.

How to Use Mojo Traits

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!


Trait Errors

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.

Inheritance

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
}

Static Methods

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.

Limitations of Mojo Traits Compared to Go Interfaces

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.