Graham King

Solvitas perambulum

Traits: Rust's unifying concept

software rust
Summary
Rust's traits unify various programming concepts such as interfaces, abstract classes, mix-ins, operator overloading, constraints on generics, behavioral markers, and even method overloading through a single construct. Unlike other languages, Rust does not use distinct syntax for these features, which allows for a more streamlined and flexible approach. For example, a trait can serve as an interface with methods that can also have default implementations, and operators in Rust map directly to traits for overloading. By defining traits, I can impose constraints on generics and utilize marker traits to inform the compiler about type behaviors. Rust’s handling of method resolution through generics permits the creation of methods that can adapt their behavior based on input types, showcasing the power of traits in encapsulating diverse functionalities.

Rust’s traits, which come from Haskell’s type classes, are a single concept that unifies all of this:

This is amazing!

Every other language I’ve used [1] has had some subset of the list above. I don’t think any of them have had them all, and they have been distinct concepts with their own keywords and syntax. Being able to unify all of that is frankly wonderful.

Rust doesn’t have a special syntax for interfaces, because traits cover that. Rust doesn’t have a special syntax for operator overloading, because traits cover that. And so on.

To underline how flexible this idea is, here’s how to do everything on the list above with just traits:

Interfaces

Logger is an interface with two methods. It looks quite similar to other languages, except it says trait instead of interface.

trait Logger {
	fn log(&self, s: &str);
	fn err(&self, e: &str);
}

struct StdoutLogger {}

impl Logger for StdoutLogger {
	fn log(&self, s: &str) {
		println!("{}", s);
	}
	fn err(&self, e: &str) {
		self.log(e);
	}
}

fn run(l: &dyn Logger) {
	l.log("Hello");
	l.err("Oops");
}

fn main() {
	let l = StdoutLogger {};
	run(&l);
}

Simple enough.

Abstract classes

An abstract class is something you can’t instantiate directly, you have to extend it to fill in the missing methods. In Rust that looks like a trait with default implementations. Continuing the example above:

trait Logger {
	fn log(&self, s: &str);
	fn err(&self, e: &str) {
		self.log(e);
	}
}

struct StdoutLogger {}

impl Logger for StdoutLogger {
	fn log(&self, s: &str) {
		println!("{}", s);
	}
}

Logger now provides a default implementation of err, so StdoutLogger doesn’t have to. StdoutLogger could still provide it’s own implementation, which would override the trait default.

Mix-ins

A mix-in is behavior you add to an existing type. In our example above we defined both the trait and the struct. You can of course implement a trait from a different package. Here we implement the standard library’s Display trait, which is how Rust converts your type to a user-friendly string (like Go’s String() or Java’s toString()).

struct StdoutLogger {}

impl std::fmt::Display for StdoutLogger {
	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
		writeln!(f, "I am StdoutLogger")
	}
}

fn main() {
	let l = StdoutLogger {};
	println!("{}", l);
}

But you can do it the other way, which was quite surprising to me at first. You can implement traits you define on types from other packages, thereby adding behavior to them (“mixing-in” new behavior). That behavior is only visible to packages that import your implementation.

trait SurelyNot {
	fn can_we_build_it(&self);
}
impl SurelyNot for String {
	fn can_we_build_it(&self) {
		println!("Yes we can");
	}
}

fn main() {
	let s = String::new();
	s.can_we_build_it();
}

You have to own either the trait or the struct to implement one for the other. You can’t implement a trait you don’t own for a type you don’t own.

Operator overloading

Rust maps all of it’s operators (+, %, etc) to a trait in the ops crate. If your type implements that trait, you can use that operator.

struct Sponge {
	name: String,
}

impl std::ops::Add for Sponge {
	type Output = Self;
	fn add(self, other: Self) -> Self {
		Sponge {
			name: self.name + "X" + &other.name,
		}
	}
}

fn main() {
	let m1 = Sponge {
		name: "m1".to_string(),
	};
	let m2 = Sponge {
		name: "m2".to_string(),
	};

	let m3 = m1 + m2;
	println!("{}", m3.name);
}

Still just traits. Nothing extra.

Constraints on generics

This is what C++ calls Concepts, Java calls “bounded type parameters”, and the Go generics proposal calls “type constraints”. If you can define any interface as a trait, and you can also define any operator as a trait, you now have everything you need to constrain a generic type parameter.

Here’s a Nameable trait for things that can have their name set. We restrict it to being called with types that can be converted to a string (that implement the ToString trait).

trait Nameable<T: std::string::ToString> {
	fn set_name(&mut self, T);
}

Thanks to traits, this is straightforward. This is not usually the case! Constraining generics is normally a hard problem.

C++ got generics (the STL) in the mid-90’s. It almost got Concepts in C++11, got very close again in C++17, and finally merged them in C++20, fifteen years later.

Go has been trying to add generics for a decade. It struggled through the years to constrain the generics with several forms of contract, before deciding to side-step the problem. In the most recent proposal instead of a general constraint you can provide a list of concrete types the generic type could be.

Behavioral markers / Attributes

Rust defines some empty marker traits to inform the compiler about the type. This feels similar to what the C / C++ / C# call attributes.

This is somewhat complicated by Rust also having actual attributes. To be fair, there are few things Rust doesn’t have. It is a large language.

An example of a behavioral marker is the Copy trait. It says that your type can be duplicated by copying it’s bits. You would implement this (in practice you “derive” it because it has no methods) for a type that holds only integers, for example. A counter-example would be a type that holds memory or file references; that would not be Copy.

Bonus: Method overloading, with generics

Rust does not officially have method overloading, for the same reason many languages don’t: if the methods are different, give them different names. There are however two different ways you can do something that feels the same.

Rust has a way of resolving ambiguities if you implement multiple traits that define methods with the same names (a single trait cannot use a name more than once, even if the parameters differ). This requires a new syntax (TheTrait::method(&myObj)), so I didn’t include it in my list in the introduction. It isn’t “just traits” any more.

A nicer approach is to use generics. I didn’t include this in the original list either because it’s no longer just traits providing the functionality, it’s generics + traits. Here’s an example of a method (set_name) that can take either a string or an integer.

trait Nameable<T> {
	fn set_name(&mut self, T);
}

struct Cyborg{
	name: Option<String>,
}

impl Nameable<&str> for Cyborg {
	fn set_name(&mut self, s: &str) {
		self.name = Some(s.to_string());
	}
}

impl Nameable<usize> for Cyborg {
	fn set_name(&mut self, serial_number: usize) {
		self.name = Some(serial_number.to_string());
	}
}

fn main() {
	let mut mostly_human = Cyborg{name: None};
	mostly_human.set_name("Bob");

	let mut mostly_machine = Cyborg{name: None};
	mostly_machine.set_name(2077);

	println!("{} vs {}",
		mostly_human.name.unwrap(),
		mostly_machine.name.unwrap(),
	);
}

Note that in practice you would implement this just once, accept anything that implements ToStr (as in the “Constraints on generics” section above), and call to_string() on that. Examples are hard, OK.

It gets better. A method can be generic over it’s return type. This is one of my favorite things in Rust. The function that gets called depends on the type of the variable you are assigning the output to. Check it out:

trait Position<T> {
	fn pos(&self) -> T;
}

struct Location {
	lat: f32,
	lon: f32,
}

impl Position<String> for Location {
	fn pos(&self) -> String {
		format!("{},{}", self.lat, self.lon)
	}
}

#[derive(Debug)]
struct Point {
	x: f32,
	y: f32,
}

impl Position<Point> for Location {
	fn pos(&self) -> Point {
		Point {
			x: self.lat,
			y: self.lon,
		}
	}
}

fn main() {
	let l = Location {
		lat: 37.05655,
		lon: -121.88823,
	};

	let s: String = l.pos();
	let p: Point = l.pos();
	println!("As string: {}", s);
	println!("As point: {:?}", p);
}

See how at the bottom we call l.pos() twice and it returns different things!. I love that.

The standard library makes great use of this with parse that will parse a string into a huge range of other types, including your own types if you implement FromStr. It’s like atoi but ato*. I use it far more than I probably should because it makes me happy.

Conclusion

The official Rust blog has a more in-depth discussion of traits. If you got this far, that’s a great article to read next.

Given all the different ideas you can subsume into traits, why would any future language not use them?


[1] I haven’t used Haskell but I did use Miranda. If you’re one of the four people on the Internet who know what that is (and how it relates to Haskell), message me!.