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 :
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.
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:
- 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.
- 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.
- 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.
- 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.
- 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. - 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.
- 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 :