Best practice with associated methods

I recognize this is an opinion post, and there is no definitive answer. But I'd still like the input from more experienced rust engineers.

In a traditionally object oriented language, I am used to created static methods on classes that act as helpers. The ever present Utils or Helpers classes.

But, in rust module functions are fist class citizens. And my instinct is to make those simply functions in some module.

Is there a best practice here? Is it recommended to only use associated functions as factories? (new() ,make(), with(), from(), etc)?

1 Like

You have correctly identified that we don't need placeholder "Utils classes" because modules suffice.

Use associated functions when the function is inherently related to some type, but doesn't take any of that type directly as input. Factories/constructors are the most common case of that, but another less-common case might be something where the type is involved but not as a single receiver:

impl Foo {
    // Can't be a method, but is very much about Foo
    fn do_something_with_slice_of_foos(&mut [Foo]) {
    }
}
10 Likes

Thank you. I hadn't considered that case.

I'm not sure if the following is idiomatic, but I sometimes implement methods also for slices:

trait SliceOfStrExt {
    fn greet_them(&self);
}

impl SliceOfStrExt for [&str] {
    fn greet_them(&self) {
        for s in self {
            println!("Hi, {s}!");
        }
    }
}

fn main() {
    let v = vec!["Alice", "Bob"];
    v.greet_them();
}

(Playground)

Output:

Hi, Alice!
Hi, Bob!

In that case, there are no associated methods. Not sure if that's good style though.

2 Likes

It may be a valid solution but this requires you to write a lot more code than just declaring an helper function working with slices. I'd define and implement a Trait for a type I don't own only if I need that trait as a reference interface that would work with object of various types implementing the trait.

1 Like

When writing Rust, unless a function is very closely linked to a particular type I'll normally make it a top-level function. I don't really use associated methods unless the coupling of function and type will aid in discoverability or make my code more cohesive.

One exception to the rule is where you'll attach associated methods to a trait. These can be useful when you want people to be able to pass in their own functionality, but the functionality doesn't actually need any state in order to be implemented (e.g. because the associated methods are pure functions) or you aren't allowed to pass state around (e.g. hardware interrupts and signal handlers).

3 Likes

I'm feeling undecided. After your comment, I considered changing my code to use associated methods. But I'm not sure. It's a bit uglier when invoking these associated methods. Let me share the two variants:

Associated methods

Definition

impl Entry {
    pub fn fill_cache(entries: &[Entry], cache: &mut Cache) {
        for entry in entries {
            /* … */
        }
    }
}

Usage

let entries: Vec<Entry> = /* … */
Entry::fill_cache(&entries, &mut cache);

There's a bit of redundancy here. We know that &entries is a collection of Entry's, yet we're forced to write Entry::.

Implementing trait on slice and using trait methods

Definition

pub trait Entries {
    fn fill_cache(&self, cache: &mut Cache);
}

impl Entries for [Entry] {
    fn fill_cache(&self, cache: &mut Cache) {
        for entry in self {
            /* … */
        }
    }
}

This is longer, of course. But not that much longer. And see the easier usage below.

Usage

let entries: Vec<Entry> = /* … */
entries.fill_cache(&mut cache);

This feels less redundant.


I'm curious about people's opinion on that. Use associated methods here? Or define and implement traits for slices of the type?

1 Like

Or an ordinary / free-standing function, which is a third option to consider…

fill_cache(&entries, &mut cache)
7 Likes

Indeed, in particular functions for constructing a value of some type (where I count conversion as a form of construction) are usually associated functions.

In the standard library, in some cases also functions that “ought to be” methods on smart pointer types are associated functions instead in order not to interfere with the implicit dereferencing; take a look at the API of Arc or Rc. This is a rather special case though, of course.

There’s also the case of constructors that construct multiple (related) values together. In this case, a free-standing function is better, because there’s no single type that the function should be associated to, mpsc::channel comes to mind as an example.

Any case where you’d use a helper-class in OOP that doesn’t get instantiated and only has static methods, just use a module instead in Rust.

The only use-case for never-instantiated types in Rust is as type parameters with trait implementations.

1 Like

No idea why I didn't think of that :sweat_smile:.

It may be a namespace issue though. But the same holds with bringing a trait into scope that adds methods to some Vec's/slices. In the latter case, the function name must be different from Vec's methods. If I use an ordinary / free-standing function, the name must be different from all other functions in scope.

1 Like

There’s a potentially far bigger namespace issue with an extension trait, unless you don’t mind falling back to fully-qualified calls. You can just use the qualified path of a free-standing function; multiple free-standing functions with the same name in Rust are common, and module names / qualified paths are a great way to differentiate. If the module has a similar / related name to the type we’re considering anyways, then a qualified path can be quite expressive, too.

4 Likes

Possibly silly question, but what about putting it on Cache instead?

Or even, since what you're doing is add a bunch of things to another thing, what if it was impl Extend<Entry> for Cache?

2 Likes

Yeah, I think I'll use a free-standing function in my case, but I'm still a bit unsure about all the different options to organize the code, and when associated methods are a good thing to use.

This made me think of Dylan and how it treats methods as kinda free-standing. (It has been a long time since I looked into that language, though).

2 Likes

I'd like to throw in another case where associated methods are used: In smart pointers where you want to avoid method name collision. See Arc::as_ptr for an example, which works on this: &Arc<T> instead of &self.

2 Likes

In my case, Cache is not aware of Entry. Cache is very abstract. But I see the point.

I never noticed the .extend method before. I could use that to get rid of some .append(&mut vec) calls, which I find a bit ugly. But will shortly have a question in that matter, but that goes to a new topic.

Anyway, my Cache isn't really a collection I guess… It doesn't hold the items that I'm going to process… well, implicitly yes, but more out of necessity not because it's the main purpose of the Cache.

1 Like

Well, regardless of whether you use Extend or not, I'll give a minor bit of code review feedback that if all you're going to do with something is for loop over it, you might as well take it as an iterator (or impl IntoIterator<Item = Entry>, for maximum generality). That way it doesn't need to be materialized into a vec to call it.

3 Likes

Thank you all for the great discussion. The extend() pattern was new to me. And I hadn't really considered associated functions on Traits. I guess it never even occurred to me that a Trait could/would have an associated function.

I just have to wrangle the "everything is a class" demons in my head.

I've just seen that what I was about to suggest has already been pointed out, anyways... I mostly use associated types with generics and traits for ergonomics and a succinct and reusable code.
Although one could implement an OOP design pattern.

I'll throw in my 2c. It answers more than your original question, though :slight_smile: I find this more important in libraries than applications, but I think the guidelines should be the same in this case:

Using traits to create an extension methods

pub trait Entries {
    fn fill_cache(&self, cache: &mut Cache);
}

impl Entries for [Entry] {
    fn fill_cache(&self, cache: &mut Cache) {
        for entry in self {
            /* … */
        }
    }
}

I think this should be the exeption (and very well documented if you choose this mehtod). It should mainly be used when you're either extending another trait (as in the case of TryStreamExt) or when providing general extensions for a type like new sorting methods on a slice or array etc. The main reason is discoverability and readability since you'll have to import these traits specifically, and for anyone reading the code it's not obvious where the methods come from.

The usage example provided by @jbe misses the import of the trait which you absolutely need to make it work if you use it from an external library or from a different module in the same project. I would never have considered importing the Entries trait to fill a cache (or any trait at all for that matter)...

use entry::Entries;
let entries: Vec<Entry> = /* … */
entries.fill_cache(&mut cache);

Using associated methods

impl Entry {
    pub fn fill_cache(entries: &[Entry], cache: &mut Cache) {
        for entry in entries {
            /* … */
        }
    }
}

This is more a matter of taste, but I would be wary of creating public associated methods that doesn't construct a new type. Private associated methods is a different thing, as it might make sense in many cases to put "helpers" there to improve readability and avoid duplication, but in most cases I would prefer a private/public free function in the same module instead since that seems to be far more common in the projects I've seen.

Using a free-standing function

cache::fill_cache(&entries, &mut Cache);

I see this a lot in Rust and combined with good module naming this tells you pretty much everything you need. Sometimes I have a private utils module if there are helper type methods that I use in the whole project. I would consider "best practice" to use Rust's type system for what it's worth as long as it makes sense, though:

Liberal use of types

struct Entry { /* ... */ };
struct Cache { /* ... */ };
struct Entries(Vec<Entry>);

// Or implement `IntoIterator` or `Iterator` for `Entries`
impl Entries {
   pub fn as_slice(&self) -> &[Entry] { self.0.as_slice() }
}

impl Cache {
    pub fn fill(&mut self, entries: &Entries) {
        for entry in entries.as_slice() { /* ... */ }
    }
}

let mut cache = Cache { /* ... */ };
cache.fill(&mut self, &entries);

Especially for libraries, using the new type idiom and wrapping even simple types like Vec<Entry> makes sure the user (which might be you in 2 years) only sees the methods needed for Entries. It also allows you to change from a Vec to something else to store the collection later on if you wish to change from a Vec to a BTreeMap for some reason and keep the change contained to just the Entries type.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.