Design patterns in Rust

I've been thinking and writing a bit about design patterns for Rust, primarily as a way to communicate intermediate/advanced knowledge about Rust. The catalogue is very much work in progress, but hopefully it's interesting already. I'd also love to have some help making it better.

blog post: Design patterns in Rust

catalogue: patterns/README.md at main · rust-unofficial/patterns · GitHub

8 Likes

Anti-patterns

TODO taking an enum rather than having multiple functions

libstd is guilty of this in at least one place: Seek in std::io - Rust

There's also the atomics, but I suspect that's a place where taking an enum is actually better: having fn load_relaxed, fn load_release, ... in 5×9 combinations for just one type doesn't seem so great.

Yeah, if the functions are very similar and you can use enums to avoid the m×n problem, I don't consider it an antipattern as well.

I find the builder pattern really bad. I know it's used in the stdlib, but I didn't catch the RFCs in time to argue about it when it was accepted.

My rants:

  • It's all about repeatedly modifying an object instead of creating an immutable object at once.
  • It's too easy to mistakenly overwrite a setting with another method call, for example by calling .named() twice. And there's no possible error detection for this.
  • It becomes very confusing when the states are not totally orthogonal. For example in glutin calling dimensions(1024, 768) would overwrite the fact that you called fullscreen(), and fullscreen() would automatically imply decorations(false).
  • You're hiding complexity just for a nicer API. For example if your configuration file includes a Vec, in terms of performances it is worse to insert elements one by one through a method instead of setting the whole Vec at once.

I used this builder pattern in glutin because all other similar libraries (GLFW and SDL) were using it (I'm not talking about the Rust wrappers, but the C libraries themselves which are using this pattern).
But over time I have realized that it was a bad idea, and I'm switching to a Configuration-like object with public fields. Not just because of some ideology, but because I regularly get questions about whether this or that behavior is normal, like for example the fact that not calling vsync() sometimes enables vsync and sometimes doesn't.

2 Likes

Hm, I feel like all those problems are addressable by tweaking the exact implementation of the builder being used.

Well, except the first one, but there's the self vs. &mut self choice, and mutability is so much more controlled in Rust compared to other languages that it's not obvious to me how much it matters, at least from a semantic point of view.

It's not too hard to use type parameters to essentially encode a state-machine in the type system. The state-machines can get pretty complex (especially with associated types), but simply stopping a method being called twice is simple enough:

fn main() {
    ThreadBuilder::new()
        .name("foo".to_owned())
        .stack_size(100)
        // errors:
        // .name("bar".to_owned())
        // .stack_size(10)
        .spawn();
}

struct Default;
struct Custom;

struct ThreadBuilder<Name, Stack> {
    name: (Option<String>, Name),
    stack_size: (usize, Stack),
}

impl ThreadBuilder<Default, Default> {
    fn new() -> ThreadBuilder<Default, Default> {
        ThreadBuilder {
            name: (None, Default),
            stack_size: (1 << 20, Default),
        }
    }
}
impl<S> ThreadBuilder<Default, S> {
    fn name(self, n: String) -> ThreadBuilder<Custom, S> {
        ThreadBuilder {
            name: (Some(n), Custom),
            stack_size: self.stack_size
        }
    }
}
impl<N> ThreadBuilder<N, Default> {
    fn stack_size(self, x: usize) -> ThreadBuilder<N, Custom> {
        ThreadBuilder {
            name: self.name,
            stack_size: (x, Custom)
        }
    }
}
impl<N, S> ThreadBuilder<N, S> {
    fn spawn(self) {
        let name = self.name.0;
        let stack_size = self.stack_size.0;
        // do stuff...
        drop((name, stack_size))
    }
}

The methods can be called/not called in any order, and uncommenting either of the second .name or .stack_size calls gives a type error.

This has some downsides, like the error messages can be confusing (although I guess tweaking the names of the Default/Custom types could help a lot), the result of each call being a different type, and needing a lot of type parameters for a complicated builder (default type parameters probably help with this for users), but it is possible to use the type system to get guarantees about this sort of thing.

(There's also the option of doing the detection at run-time, and panic/Err-ing.)

I must be missing something, but I don't see how a configuration struct solves this problem? If you have a fullscreen, dimensions and decorations fields, presumably they'll have the same conflict due to lack-of-orthogonality. Could you clarify?

In both cases, at least the dimensions/fullscreen conflict would be solved by either having the builder method or the struct field take an enum:

pub enum Dimensions {
    Fullscreen.
    Windowed(usize, usize)
}

(And one could probably get a state-machine like the one above to work for this too.)

Only if you choose to only expose a method that appends elements one by one; it's perfectly possible to have a method that takes a Vec<...> to set the whole thing in one go.

2 Likes

That's incredibly hacky! It's not that I hate hacks like this, but only when they are strictly necessary. In other situations it brings a lot of complexity and makes things even more confusing.

You're comparing a basic structure initialization (something that everybody knows how to do) with an alternative API that uses non-idiomatic hacks in the type system in order to bring the same benefits.

Yes, I'm using an enum like you did (or rather I plan to use an enum ; for the curious who may take a look at glutin, the whole thing is a work in progress and fullscreen has not been totally figured out yet).

But if, like you suggest, you have to write:

WindowBuilder.config(Dimensions::Windowed { width: 1024, height: 768, decorations: true, title: "Hello".to_owned() }).build();

Then you're killing the point of using a builder-like API compared to just a basic struct initialization.

I guess you could use the type system to "switch" the builder to a windowed-mode builder or something, but see my previous answer.

1 Like