Rust traits are better interfaces

Cătălin August 23, 2023 [Rust] #Traits

Intro

Hi folks! Today we'll be comparing how interface polymorphism is handled in Rust and Java.

First, a short primer - Java is an OO programming language, while Rust is multi-paradigm one. Rust gives you the option of organizing your code by OO principles if you want, but it doesn't force you into this.

Java interfaces act as contracts that define a set of method signatures that implementing classes must adhere to. A class can implement multiple interfaces, enabling multiple inheritance of behavior.

Rust doesn't have classes - it uses data types and associated functions or methods. A trait in Rust just defines behavior that a type can exhibit.

Check out the video

If you don't feel like reading:

Flexibility

Practically speaking, for basic usage, they function very similarly. You need to declare that a data structure (or class for Java) implements a trait and interface, and implementing multiple traits or interfaces is allowed in both cases.

There's one slight difference that gives Rust an edge in flexibility - In Rust, you can declare the implementing relationship anywhere.

struct Comment {  
	author: String,  
	content: String  
}  
  
trait Length {  
	fn get_length(&self) -> u32;  
}  
  
impl Length for Comment {  
	fn get_length(&self) -> u32 {  
		self.content.len() as u32  
	}  
}

In Java, I have to declare inside the class definition that it implements an interface. In this way, the relationship is stored inside the class itself.

public class Comment implements Length {  
	private String author;  
	private String content;  

@Override  
	public int getLength() {  
		return content.length();  
	}  
}

In Rust, I'm free to choose where the struct, trait and implementation statement reside. I can have them in the same file one after the other or totally separate files if I wish.

Moreover, I can implement an external trait for a type in my codebase, which is possible in Java as well, but I can also implement a trait in my codebase for an external type, which makes plugging in external components into my API trivial. I can also implement external traits for external types, but this requires a special pattern.

use externalcrate::SomeStruct;

trait Length {  
	fn get_length(&self) -> u32;  
}  
  
impl Length for SomeStruct {  
	fn get_length(&self) -> u32 {  
		self.to_string().len() as u32  
	}  
}

Derives

Too lazy to write implementations? Rust allows for deriving automatic implementations for certain types. Deriving Eq makes it easy to compare structs.

#[derive(Eq)]
struct Person {
    id: u32,
    name: String,
    age: u32,
}

let p1 = Person{...}
let p2 = Person{...}

p1 == p2

Deriving Debug allows for an easy way to print a struct.

#[derive(Debug)]
struct Person {
    id: u32,
    name: String,
    age: u32,
}

let p1 = Person {
	id: 1,
	name: "Jane Doe".to_string(),
	age: 35
};

print!("{:?}", p1);

:? is the debug format string.

Associated types

Another aspect that gives Rust traits more power is the associated type, where we can force implementors to define this type.

pub trait Iterator {
    type Item;

    // Required method
    fn next(&mut self) -> Option<Self::Item>;
    //....
}

impl Iterator for CollectionOfThings {
type Item = Thing
	fn next(&mut self) -> Option<Self::Item> {
	    Some(Thing {...})
	}
}

In the example above from the standard library, implementing the Iterator trait requires defining the Item type. Here we're defining it as a Thing in a custom collection of Things.

You can also use the associated type in the argument of the function

trait SomeTrait {
	type ArgumentType;
	
	fn some_function(some_arg: ArgumentType);
}

Another detail you may have picked up on is the different signatures that can be used in the trait functions:

trait SomeTrait {
	fn mutable_access(&mut self);
	fn immutable_access(&self);
	fn no_access(arg: SomeType);
}

In Rust, it's immediately visible if the trait function has mutable or immutable access to the member data of the struct or if it has no access to this. In Java, even if the interface is more coupled to the class, you don't get this kind of visibility. You'd have to dig into the implementation in order to check this.

Dispatch

One other important difference that is not easily visible is method dispatch. The two languages have almost diametrically opposed approaches.

Rust will use static method dispatch for traits (The appropriate method implementation is baked into the compiled binary directly) by default. The programmer can opt into dynamic dispatch by using trait objects. These are heap-allocated boxes that can hold a struct that implements the trait in a situation where the compiler cannot figure out exactly what type it is.

trait Length {  
	fn get_length(&self) -> u32;  
}

fn print_len(target: Box<dyn Length>) {
	//...
}

Java takes the exact opposite approach, where most calls will use dynamic method dispatch by default, except in some cases where the compiler can figure out that only a certain specific implementation is used.

The Java approach sacrifices some performance and explicitness for convenience, while Rust defaults to performance and transparency around the type of dispatch being used. This increased visibility will come at the cost of needing to type a bit more boilerplate.

Overall, I'd say the approach Rust takes is favorable due to greatly increased flexibility at the cost of some new syntax to learn for the most part.

Back to top