Builder Pattern without violating DRY?

The builder pattern described in The builder pattern & https://github.com/rust-unofficial/patterns/blob/master/patterns/builder.md involves quite a lot of boilerplate and repetition. Depending on your exact needs, you end up having to repeat each parameter two or, in some cases, even three times which seems pretty bad for code readability and maintenance.

Default sort of works in very simple cases, but if any fields don't have sensible defaults, or you have a lot of Options it won't work. Are there any other alternatives?

I prefer the init struct pattern for this reason. For mandatory arguments without good default, you can pass them directly to the fn init().

7 Likes

Macros can be quite powerful to reduce this kind of dry:

with_builder! {
    #[derive(Debug)]
    struct Foo {
        name: String,
        
        #[default = "Unknown".into()]
        address: String,
    }
}

fn main ()
{
    dbg! {
        Foo::builder("John".into())
            .with_address("Rustopolis".into())
            .build()
    };
    dbg! {
        Foo::builder("John".into())
            // .with_address("Rustopolis".into())
            .build()
    };
}

But the builder pattern usually requires that mandatory fields be given to the builder() initial constructor, which leads to them becoming positional params, which can hinder readability when multiple mandatory fields are present.

In that case, the .init() struct pattern mentioned by @Hyeonu does have the benefit of always using the built-in Rust syntax of struct literals and named fields for a very simple to implement and readable pattern.

2 Likes

I would recommend typed-builder's derive macro. It allows you to avoid the repetition completely.

5 Likes

I looked at this, as well as derive builder, and it seems these both break code analysis on the side of anyone using your crate which, considering builders are the kind of thing you'll often publicly expose in your crate is... not great.

2 Likes

That's a perspective I hadn't thought of before, thank you.

1 Like

Personally I am against this idea. For me adopting a builder pattern is more about safety than passing good defaults. Builder pattern is an good example of using type system to avoid potential errors.

For example:

let builder = Builder::new();
let built = builder.build();
// if you forget finalize the construction step, it's a compile time error

Instead if you have init pattern

let mydata = MyData::new().settings_a(); // And if you forget call init, compiler will silently pass
mydata.some_method(); // this may be problematic

In addition, the init pattern introduce a lot of problems. For example if you have something following

struct MyType {
     file: File,   // If you want to pass the path to the file later, there's no way to construct it
}

You could use Option<File> instead, but this increases the complexity in other places (and also unnecessary runtime checks may hurt performance and option type may increase memory usage).

Finally, it's not difficult to implement a procedural macro helps us make builders. Beyond that I personally think writing code that having more compile time guarantee is way more important than so called DRY dogma anyway and this is the reason why type system is important.

This is a very valid concern, but hopefully code analysis tools will get better at handling procedural macros.

No, you totally misunderstood what init struct pattern is. It's not a init method like we commonly have in other languages. Though you can blame its name, the .init() is symmetrical to .build() since both consume the init/builder type and produces different real type.

Oops, I should look at the post very carefully. The blog post did exactly what I preferred.

But I don't think this is fundamentally different from traditional builder beside how the default value is propagated and how the value is manipulated. And I feel the name init pattern is somehow misleading which sounds like create a uninitialized struct first and than call init - This is what a lot of C++ class did with their init method since it might be impossible to fail in a constructor without aborting or throwing exceptions.

I feel the discussion isn't about a new pattern vs builder pattern, instead, it's about if we need the accessor methods in the builder struct - both proc macro and pub fields + defalts seems good alternative to it.
I think java style accessors are overkill, but I don't think we should totally avoid them, accessors sometimes improves the readability a lot.

Yeah the init pattern above is actually more something like:

struct Foo {
    mandatory_1: T,
    mandatory_2: U,

    opt_1: O1,
    opt_2: O2,
    ...
}

#[derive(::derivative::Derivative)]
#[derivative(Default)]
struct FooOptions {
    #[derivative(Default(value="some_value"))]
    opt_1: O1,

    #[derivative(Default(value="some_value"))]
    opt_2: O2,

    ...

    #[doc(hidden)] pub
    _non_exhaustive: (),
}

impl Foo {
    pub fn new (
        mandatory_1: T,
        mandatory_2: U,
        options: FooOptions,
    ) -> Self
    { ... }
}

so as to have the callers write:

Foo::new(
    ...,
    ...,
    FooOptions { opt_2: overriden_value, ..Default::default() },
)

It's just that in their example there were no mandatory fields, and that instead of calling the outer function new, they called it .init().

Hey, well when it comes to the builder pattern I tend to use generics and the compiler to help me ensuring compile time checks on the instances that can only be build if all mandatory fields are present. Writing this might be a bit cumbersome some time but the benefits of a convinient usage of this is worth it from my point of view. The assumption is, that the mandatory fields to initially build the instance is unlikely to be more then up to 5.

Check this Playground

The only way to instantiate the Thing there is to use it's associated function that returns it's builder.
The builder will then construct a temporary version of the Thing and intantiate the Thing once the build function is called. The compiler can ensure that there is never an instance tryied to build without the mandatory fields in place.

let thing = Thing::builder()
        .with_foo(String::from("Foo Data"))
        .with_baz(42)
        .build();

fails with this nice compiler error:

no method named `build` found for struct `ThingBuilder<WithFoo, MissingBar>`

so the user of this pattern immediately can reason what is missing. And thus - the following compiles just fine

let thing = Thing::builder()
        .with_foo(String::from("Foo Data"))
        .with_bar(true)
        .with_baz(42)
        .build();

I guess it could be possible to also sketch a proc-macro/derive macro that could help with the code to be written/generated

I've found the method that @381 criticized is actually quite a good light weight solution. It tends to make your code flexible and readable (but with the disadvantages outlined above).

It's especially relevant where you have methods for changing all the struct properties later, it addresses the need for named arguments as well as default values. You can explicitly make instances mutable or non-mutable at different points in your code for clarity.

As a gross generalization I have found that, rather than being a hindrance, Rust's lack of named arguments and default values in functions has made me think about what absolutely has to go in the constructor and what could be altered later, and very often that's almost all of it! The problem with the builder (and init as above) pattern (IMO) is that very often the optional builder methods overlap significantly with methods you also want in your built object.

I am not criticizing the pattern, what I am arguing it's just a different (and better) way to implement a builder.

What I said is I don't think the "init pattern" is a good name for this. I suppose the author choose the name just want to emphasize the difference, but again the name is misleading due to:

  • First, init doesn't really describes the practice correcetly
  • More importantly, there used to be tons of "constructor - init method" pattern in C++ due to the exception safety concerns. This "init pattern" sounds really like that.

So what I was arguing is just the author choose a bad name while the practice itself still a builder pattern.

2 Likes

@381 I was referring to your first example. I can see there are some disadvantages but often this kind of thing is an efficient way to do things. And arriving at the absolute minimum functionality of each function - including the ::new() - is a real benefit that is 'forced' on you by not having the optional and named arguments as used in python, say.

    let mut display = pi3d::display::new(W, H, "GL", 2, 1);
            display.set_title("experimental game window");
            display.set_background(&[0.1, 0.1, 0.2, 1.0]);
            display.set_mouse_relative(true);
            display.set_target_fps(30.0);
1 Like

Dude, that's cool. That should be a crate. I like that it doesn't use proc macros so it won't include syn and such.

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.