Skip to main content
Nora Codes

Methods Should Be Object Safe

Leonora Tindall 2024/05/04

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:

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.