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
impl
block, 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
self
but are not object-safe, such as takingself
by 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
Counter
s and one that works for Turnstyle
s 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 ↩︎