Rust’s type system is a marvel of modern programming language design. It’s one of the reasons I fell in love with the language. But there’s a feature that often flies under the radar: variance. It’s a concept that can make your generic types more flexible and powerful, and today we’re going to explore it in depth.
Let’s start with the basics. Variance in Rust determines how subtyping relationships between types are affected when those types are used in more complex structures. It’s a key part of making generic code that’s both safe and flexible.
There are three main types of variance: covariance, contravariance, and invariance. Each has its own use cases and implications for how you design your types.
Covariance is probably the most intuitive. It means that if A is a subtype of B, then Container is a subtype of Container. This is often what we intuitively expect when working with generics. For example, if we have a Vec<&‘static str>, we can use it where a Vec<&‘a str> is expected, because ‘static is a subtype of any other lifetime ‘a.
Let’s see this in action:
fn print_strings<'a>(strings: &Vec<&'a str>) {
for s in strings {
println!("{}", s);
}
}
fn main() {
let static_strings: Vec<&'static str> = vec!["hello", "world"];
print_strings(&static_strings); // This works because Vec<&T> is covariant in T
}
Contravariance is a bit trickier. It’s the opposite of covariance: if A is a subtype of B, then Container is a subtype of Container. This isn’t as common, but it’s crucial for function types. In Rust, fn(T) -> U is contravariant in T and covariant in U.
Here’s an example to illustrate:
trait Animal {}
struct Dog;
struct Cat;
impl Animal for Dog {}
impl Animal for Cat {}
fn feed_animal(animal: &dyn Animal) {}
fn feed_dog(dog: &Dog) {}
fn main() {
let feed_any_animal: fn(&dyn Animal) = feed_dog;
// This works because fn(&Dog) is a subtype of fn(&dyn Animal)
// due to contravariance of function argument types
}
Invariance is the simplest: it means there’s no subtyping relationship at all. This is the default for most generic types in Rust, including Vec
Understanding variance becomes crucial when you’re designing your own generic types. Let’s say you’re creating a custom container type:
struct MyContainer<T> {
data: T,
}
By default, this type is invariant in T. But what if you want to make it covariant? You can do this by using PhantomData:
use std::marker::PhantomData;
struct MyCovariantContainer<T> {
data: *const T,
_marker: PhantomData<T>,
}
Now, MyCovariantContainer<&‘static str> can be used where MyCovariantContainer<&‘a str> is expected.
But be careful! Making a type covariant can sometimes lead to unsoundness. For example, if our container allowed mutations, covariance could break Rust’s safety guarantees. That’s why types like Cell
Variance gets even more interesting when we start dealing with multiple type parameters. Each parameter can have its own variance behavior. For example, in std::slice::Iter<‘a, T>, ‘a is covariant but T is invariant.
When working with lifetimes, variance plays a crucial role. It’s what allows us to use longer lifetimes where shorter ones are expected. This is essential for writing flexible code that works with references.
Here’s a more complex example that demonstrates how variance affects lifetime parameters:
struct Holder<'a, T: 'a> {
data: &'a T,
}
fn process<'a, 'b: 'a, T: 'a>(h: Holder<'b, T>) -> Holder<'a, T> {
// This works because Holder is covariant in its lifetime parameter
h
}
fn main() {
let data = 42;
let long_holder = Holder { data: &data };
let _short_holder = process(long_holder);
}
In this example, we can pass a Holder with a longer lifetime ‘b to a function expecting a Holder with a shorter lifetime ‘a, thanks to covariance.
Variance also interacts in interesting ways with Rust’s ownership system. When you’re dealing with owned types, variance is less of a concern because Rust’s ownership rules prevent many of the potential issues. But when you start working with shared references (&T) or mutable references (&mut T), variance becomes crucial.
For instance, &T is covariant in T, but &mut T is invariant in T. This invariance is necessary to preserve Rust’s aliasing rules and prevent data races.
Let’s look at why this matters:
fn main() {
let mut data: &'static str = "hello";
let r: &'static mut &'static str = &mut data;
// If &mut T were covariant in T, this would compile:
// let r2: &'static mut &'a str = r;
// *r2 = "world"; // This would violate the 'static guarantee of data
}
If &mut T were covariant in T, we could create a mutable reference to a shorter lifetime and use it to replace the ‘static reference with a shorter-lived one, breaking Rust’s safety guarantees.
As you dive deeper into Rust, you’ll find that understanding variance is key to creating flexible, reusable abstractions. It’s what allows us to write generic code that works with a wide range of types while still maintaining Rust’s strong safety guarantees.
In my experience, mastering variance has been crucial for designing robust libraries. It’s allowed me to create APIs that are both flexible and safe, adapting to a wide range of use cases without compromising on Rust’s safety promises.
One area where I’ve found variance particularly useful is in creating extensible plugin systems. By carefully designing the variance of trait objects and containers, I’ve been able to create systems where users can provide their own implementations that work seamlessly with the existing codebase.
Here’s a simplified example of how you might use variance in a plugin system:
trait Plugin {
fn execute(&self);
}
struct PluginManager<'a> {
plugins: Vec<&'a dyn Plugin>,
}
impl<'a> PluginManager<'a> {
fn add_plugin(&mut self, plugin: &'a dyn Plugin) {
self.plugins.push(plugin);
}
fn run_plugins(&self) {
for plugin in &self.plugins {
plugin.execute();
}
}
}
// User-defined plugin
struct MyPlugin;
impl Plugin for MyPlugin {
fn execute(&self) {
println!("MyPlugin is running!");
}
}
fn main() {
let my_plugin = MyPlugin;
let mut manager = PluginManager { plugins: Vec::new() };
manager.add_plugin(&my_plugin);
manager.run_plugins();
}
In this example, the covariance of &dyn Plugin allows us to store plugins with different concrete lifetimes in the same PluginManager.
As you continue your Rust journey, I encourage you to think deeply about variance when designing your types. Ask yourself: Should this type parameter be covariant, contravariant, or invariant? What are the implications for safety and flexibility? How will this affect the users of my API?
Remember, with great power comes great responsibility. Variance gives you the tools to create incredibly flexible abstractions, but it’s up to you to ensure that this flexibility doesn’t come at the cost of safety or clarity.
Mastering variance is a journey, not a destination. Even after years of working with Rust, I still find new and interesting ways that variance impacts system design. It’s a topic that rewards continued study and experimentation.
So go forth and explore! Play with different variance configurations in your types. See how they affect what you can and can’t do. And most importantly, use this knowledge to create better, more flexible Rust code. The power of variance is in your hands - use it wisely!