The “Builder” pattern in Rust

The Builder pattern is a creational design pattern that allows you to create complex objects step by step. It’s particularly useful when you have an object with a large number of optional parameters or configuration options. By using a separate builder struct or type, you can make the construction of the main object more readable and maintainable.

See Rust Design Patterns :

Builder Pattern Jenga

Here’s a simple example of the Builder pattern in Rust:

struct Pizza {
    size: String,
    cheese: bool,
    pepperoni: bool,
    mushrooms: bool,
}

struct PizzaBuilder {
    size: String,
    cheese: bool,
    pepperoni: bool,
    mushrooms: bool,
}

impl PizzaBuilder {
    fn new(size: &str) -> PizzaBuilder {
        PizzaBuilder {
            size: String::from(size),
            cheese: false,
            pepperoni: false,
            mushrooms: false,
        }
    }

    fn add_cheese(&mut self) -> &mut Self {
        self.cheese = true;
        self
    }

    fn add_pepperoni(&mut self) -> &mut Self {
        self.pepperoni = true;
        self
    }

    fn add_mushrooms(&mut self) -> &mut Self {
        self.mushrooms = true;
        self
    }

    fn build(&self) -> Pizza {
        Pizza {
            size: self.size.clone(),
            cheese: self.cheese,
            pepperoni: self.pepperoni,
            mushrooms: self.mushrooms,
        }
    }
}

fn main() {
    let pizza = PizzaBuilder::new("large")
        .add_cheese()
        .add_pepperoni()
        .build();

    println!("Size: {}", pizza.size);
    println!("Cheese: {}", pizza.cheese);
    println!("Pepperoni: {}", pizza.pepperoni);
    println!("Mushrooms: {}", pizza.mushrooms);
}

In this example, we have a Pizza struct and a separate PizzaBuilder struct. The PizzaBuilder struct allows you to construct a Pizza object step by step. You start by creating a PizzaBuilder with the desired size and then use methods like add_cheese, add_pepperoni, and add_mushrooms to customize the pizza. Finally, you call the build method to create the Pizza object with the desired configuration.

The Builder pattern improves code readability and flexibility when creating complex objects with optional parameters. It allows you to avoid having a constructor with numerous parameters and makes it clear which parameters are being set.

Builder Pattern

Breaking down the Code example

Step 1: Define the Main Object (Pizza) In this step, we define the main object that we want to create using the Builder pattern. In our case, it’s a Pizza struct with various attributes like size, cheese, pepperoni, and mushrooms.

struct Pizza {
    size: String,
    cheese: bool,
    pepperoni: bool,
    mushrooms: bool,
}

Step 2: Create the Builder (PizzaBuilder) The next step is to create a separate builder struct, which will be responsible for constructing the Pizza object. This builder struct, in our case, is named PizzaBuilder, and it has fields for the same attributes that the Pizza struct has.

struct PizzaBuilder {
    size: String,
    cheese: bool,
    pepperoni: bool,
    mushrooms: bool,
}

Step 3: Builder Initialization

In the PizzaBuilder implementation, we create a method new that initializes the builder. This method sets the required parameter, which is the pizza’s size. Optional features are initialized as false.

impl PizzaBuilder {
    fn new(size: &str) -> PizzaBuilder {
        PizzaBuilder {
            size: String::from(size),
            cheese: false,
            pepperoni: false,
            mushrooms: false,
        }
    }
}

Step 4: Add Optional Features

We create methods in the PizzaBuilder implementation to add optional features to the pizza. These methods set the corresponding fields of the builder to true.

*By returning &mut Self, you can call methods on the same builder object one after another in a chain

impl PizzaBuilder {
    // ...

    fn add_cheese(&mut self) {
        self.cheese = true;
    }

    fn add_pepperoni(&mut self) {
        self.pepperoni = true;
    }

    fn add_mushrooms(&mut self) {
        self.mushrooms = true;
    }

    // ...
}

Step 5: Build the Main Object

In the PizzaBuilder implementation, the build method is defined. This method creates a Pizza object by cloning the builder’s fields and returns the completed pizza.

impl PizzaBuilder {
    // ...

    fn build(&self) -> Pizza {
        Pizza {
            size: self.size.clone(),ass in what type of enemy you want. The factory takes care of creating the correct enemy without you needing to know how it works behind the scenes.
            cheese: self.cheese,
            pepperoni: self.pepperoni,
            mushrooms: self.mushrooms,
        }
    }
}

Step 6: Use the Builder to Create the Object

fn main() {
    // Step 1: Initialize the builder
    let pizza_builder = PizzaBuilder::new("large");

    // Step 2: Customize the pizza
    let mut pizza = pizza_builder;
    pizza.add_cheese();
    pizza.add_pepperoni();

    // Step 3: Build the final pizza
    let pizza = pizza.build();

    // Step 4: Use the pizza
    println!("Size: {}", pizza.size);
    println!("Cheese: {}", pizza.cheese);
    println!("Pepperoni: {}", pizza.pepperoni);
    println!("Mushrooms: {}", pizza.mushrooms);
}

In the main function, we demonstrate how to use the builder to create a pizza:

  • Step 1: Initialize the builder by calling PizzaBuilder::new("large").
  • Step 2: Customize the pizza by adding cheese and pepperoni.
  • Step 3: Build the final pizza using pizza.build().
  • Step 4: Use the pizza by printing its attributes.

Each section corresponds to one of the six steps involved in implementing the Builder pattern in Rust.

Run the code in Rust Playground

Why is using a builder struct any better than just using the actual pizza struct?

Using a builder struct is beneficial for several reasons when compared to directly using the actual pizza struct with optional parameters:

  1. Improved Readability: The builder pattern makes the code more readable by providing a clear and structured way to set optional parameters. When you’re constructing an object with numerous optional attributes, it can be challenging to remember the order and meaning of parameters or to call constructors with a long list of arguments. The builder pattern names each parameter explicitly and guides you through the construction process.
  2. Eliminating Telescoping Constructors: Without a builder, you might need multiple constructors or initialization methods to handle different combinations of optional parameters. This can lead to “telescoping constructors” where you have constructors with varying numbers of arguments. The builder pattern eliminates the need for multiple constructors, simplifying the API.
  3. Maintainability and Extensibility: If you later add more optional parameters or modify the construction process, you only need to update the builder methods and not every place where the object is being created. This makes your code more maintainable and adaptable to changes.
  4. Default Values: The builder allows you to provide sensible default values for optional parameters. With direct construction, you might need to rely on some default values that are set within the struct, and it can be less clear what those defaults are.
  5. Method Chaining: With the builder pattern, you can use method chaining (returning &mut Self from methods) to set multiple options in a fluent and expressive way. This results in more concise and easy-to-follow code.
  6. Safety and Validation: Builders allow you to perform validation and consistency checks before constructing the object. You can ensure that all required parameters are set, or that the combination of options is valid, which can prevent runtime errors.
  7. Immutable Main Object: Builders can enforce immutability for the main object once it’s constructed. In Rust, immutability is an important concept for safety, and builders make it easier to achieve.

Conclusion

Overall, the builder pattern is a design choice that aims to enhance code readability, maintainability, and flexibility, especially when dealing with complex objects with numerous optional parameters. While it might introduce some additional code, the benefits it offers can make your code more robust and comprehensible.

Here’s another example :