I think we should use “method”, in Rust, to refer specifically to associated functions which would be object safe if put in a trait.
In this terminology scheme:
- associated function refers to any function defined in an
implblock, which is how most people use it now1,2 - trait function refers to any function defined in a trait
- method specifically refers to associated functions with receivers like those specified in the object safety section of the Reference3
- and we should come up with another name for associated functions that take
selfbut are not object-safe, such as takingselfby value.
That’s the whole idea; if you’re unfamiliar with what any of that means, read on.
Object Oriented Programming in Rust
I was chatting with a friend about object oriented programming and wanted to use Rust to illustrate why I find it odd that programmers often act like “OOP” is one big concept, when, really, it’s more like a kind of Vietnamese spring roll of concepts that can be inserted or taken out depending on preference.
For instance, in Rust, it’s entirely possible - indeed, quite normal - to write so-called free functions, like so:
// counter_free.rs
pub struct Counter {
pub count: u32,
}
pub fn increment(c: Counter) -> Counter {
Counter { count: c.count + 1 }
}
mod test {
use super::{Counter, increment};
#[test]
fn count_up() {
let c = Counter { x: 0 };
let c = increment(c);
assert_eq!(c.count, 1);
}
}
It’s hard to call this “object oriented” in any meaningful way, although I’m
sure some readers will enthusiastically correct me (which I welcome!)
Counter does not encapsulate its data,
nor is its behavior in any way bound to that data.
We can also write associated functions, which are a lot like free functions
but are named differently and written inside impl blocks.
Since the compiler knows which type the function is “about”, it also gives us
a shortcut; instead of naming the type when it’s used in that function, we can
just write Self.
// counter_associated.rs
pub struct Counter {
pub count: u32,
}
impl Counter {
pub fn increment(c: Self) -> Self {
Self { count: c.count + 1 }
}
}
mod test {
use super::Counter;
#[test]
fn count_up() {
let c = Counter { x: 0 };
let c = Counter::increment(f);
assert_eq!(c.count, 1);
}
}
Here, Counter still doesn’t encapsulate its data, but its behavior -
increment - is bound, at least in name, to Counter as a type.
I could define a Cycle::increment in the same module without any confusion
occurring.
Is this OOP? I say no; we’ve essentially just changed the name of the function,
and made it a bit more ergonomic to import.
Rust gives us another bit of special syntax, though, which changes things.
This is the self argument, or receiver, and it looks like so:
// counter_receiver.rs
pub struct Counter {
count: u32,
}
impl Counter {
pub fn new() -> Self {
Self { count: 0 }
}
pub fn count(&self) -> u32 {
self.count
}
pub fn increment(self) -> Self {
Self { count: self.count + 1 }
}
}
mod test {
use super::Counter;
#[test]
fn count_up() {
let c = Counter::new();
let c = c.increment();
assert_eq!(c.count(), 1);
}
}
This looks a lot more like object oriented programming, at least to me.
The data is encapsulated; we could have some complex memory-saving method of
recording the count value inside of Count, for all the user knows, and just
convert it to a u32 when Counter::count is called.
Generics and Static Dispatch
But - is it object oriented in Rust?
In Rust, we use object to mean something very specific:
a type with a name including dyn Trait.
Counter could implement a trait, certainly; let’s call it Increment.
// counter_trait.rs
pub struct Counter {
count: u32,
}
impl Counter {
pub fn new() -> Self {
Self { count: 0 }
}
pub fn count(&self) -> u32 {
self.count
}
}
pub trait Increment {
fn increment(self) -> Self;
}
impl Increment for Counter {
fn increment(self) -> Self {
Self { count: self.count + 1 }
}
}
mod test {
use super::{Counter, Increment};
#[test]
fn count_up() {
let c = Counter::new();
let c = c.increment();
assert_eq!(c.count(), 1);
}
}
Again, we’ve changed very little here.
We’ve indirectly renamed increment again;
it can be referred to as Increment::count,
or as <Count as Increment>::increment.
We’ve also given ourselves, as programmers, some flexibility.
Since Increment could apply to many different types, it’s possible to make our
code a bit more future proof, using generics.
For instance, we could write a function like this:
// counter_trait.rs (append)
fn double_count<Inc: Increment>(i: Inc) -> Inc {
i.increment().increment()
}
#[test]
fn counter_double_count() {
let c = Counter::new();
let c = double_count(c);
assert_eq!(c.count(), 2);
}
Not very novel, but it’s interesting in that it would work just as well with any
other type that is Increment.
For instance:
// counter_trait.rs (append)
pub struct Turnstyle {
entries: u32,
exits: u32,
}
impl Turnstyle {
fn new() -> Self {
Self {
entries: 0,
exits: 0,
}
}
fn occupants(&self) -> u32 {
assert!(self.entries >= self.exits);
self.entries - self.exits
}
}
impl Increment for Turnstyle {
fn increment(self) -> Self {
Self {
entries: self.entries + 1,
exits: self.exits
}
}
}
#[test]
fn turnstyle_double_count() {
let t = Turnstyle::new();
let t = double_count(t);
assert_eq!(t.occupants(), 2);
}
This is getting a lot closer to what languages like Java tend to use their
object oriented paradigm for.
double_count doesn’t care much what the datatype it’s called on is,
so long as it implements Increment.
The compiler will generate one version of double_count that works for
Counters and one that works for Turnstyles in a process known as
monomorphization.
Unlike Java, though, we can’t have a collection (say, a Vec) of Increment;
we have to either explicitly name a particular type, like Vec<Counter>, or use
dynamic dispatch.
Dynamic Dispatch and Object Safety
This is where the existing definition of Increment falls short.
If we try to name such a collection - say, for instance, by putting our various
Increment types on the heap with Box - we get a very interesting compiler
error.
// counter_trait.rs (append)
fn increment_all(items: Vec<Box<dyn Increment>>) {
items.into_iter().map(|i| i.increment()).collect()
}
// ERROR: `Increment` cannot be made into an object
// note: for a trait to be "object safe" it needs to allow building a vtable to
// allow the call to be resolvable dynamically
In Rust, grouping values by trait rather than type is done through dynamic
dispatch.
Types like Box, Arc, and so forth allow pointers to the data to be stored
along with pointers to the behavior related to that data, known as a vtable.
The compiler doesn’t necessarily know the types of all implementers of
Increment at the time increment_all is being generated, so monomorphization
isn’t an option.
An object-safe version of Increment would take &mut self and modify the
Counter, Turnstyle, or whatever other value in place, much like a Java
method with similar functionality.
For that reason, all the functions in a trait that’s being used to define a
trait object (like &dyn Increment, Box<dyn Increment>, etc.) must take
pointer receivers, like &self, Box<Self>, and so forth.
This is why I say we should only call associated functions that would be object safe “methods”. They are the associated functions that could be used in an “object-oriented” way, both in that they work with what we call “objects” in Rust, and in that they can be used in the dynamic way that “method” often connotes in object-oriented languages.
-
The Rust Reference 6.15 Associated Items - Associated functions and methods ↩︎
-
Rust by Example 9.1 Associated functions & Methods ↩︎
-
The Rust Reference 6.11 Traits - Object Safety ↩︎