Rust's builder pattern, as described in the book "Effective Rust"

Over Christmas, I read a few pages of David Drysdale's book "Effective Rust", which is also available online at Effective Rust - Effective Rust.

I think that book is actually a quite good second Rust book -- from the text quality and issue rate perhaps one of the best Rust book.
The author explains some of the Rust concepts in his own words, which is quite useful as a repetition and to re-foster the understanding.

In Item #7 he explains Rust's builder pattern, which can be useful to initialize structs with fields without a valid default value, like time and date values. He introduces the builder scheme, with a helper Build struct and an explicit build() function. I think I have read about that strategy at other places as well. So he uses

pub struct DetailsBuilder(Details);

impl DetailsBuilder {
    /// Start building a new [`Details`] object.
    pub fn new(
        given_name: &str,
        family_name: &str,
        date_of_birth: time::Date,
    ) -> Self {
        DetailsBuilder(Details {
            given_name: given_name.to_owned(),
            preferred_name: None,
            middle_name: None,
            family_name: family_name.to_owned(),
            mobile_phone: None,
            date_of_birth,
            last_seen: None,
        })
    }
}

and

/// Consume the builder object and return a fully built [`Details`]
/// object.
pub fn build(self) -> Details {
    self.0
}

to allow a variable construction like

let also_bob = DetailsBuilder::new(
    "Robert",
    "Builder",
    time::Date::from_calendar_date(1998, time::Month::November, 28)
        .unwrap(),
)
.middle_name("the")
.preferred_name("Bob")
.just_seen()
.build();

See Item 7: Use builders for complex types - Effective Rust

I wonder why (for this use case) the additional Builder struct and calling the build() function is actually required. The same functionality seems to be possible with just a constructor function requiring the parameters that needs to be explicitly specified. So calling a final build() is not required. For me this looks like a more natural instantiation strategy also known from other programming languages:

/// Phone number in E164 format.
#[derive(Debug, Clone)]
pub struct PhoneNumberE164(pub String);

#[derive(Debug)]
pub struct Details {
    pub given_name: String,
    pub preferred_name: Option<String>,
    pub middle_name: Option<String>,
    pub family_name: String,
    pub mobile_phone: Option<PhoneNumberE164>,
    pub date_of_birth: time::Date,
    pub last_seen: Option<time::OffsetDateTime>,
}

impl Details {
    /// Start building a new [`Details`] object.
    pub fn new(given_name: &str, family_name: &str, date_of_birth: time::Date) -> Self {
        Details {
            given_name: given_name.to_owned(),
            preferred_name: None,
            middle_name: None,
            family_name: family_name.to_owned(),
            mobile_phone: None,
            date_of_birth,
            last_seen: None,
        }
    }

    pub fn preferred_name(mut self, preferred_name: &str) -> Self {
        self.preferred_name = Some(preferred_name.to_owned());
        self
    }

    pub fn middle_name(mut self, middle_name: &str) -> Self {
        self.middle_name = Some(middle_name.to_owned());
        self
    }

    pub fn just_seen(mut self) -> Self {
        self.last_seen = Some(time::OffsetDateTime::now_utc());
        self
    }
}

fn main() {
    let bob = Details::new(
        "Robert",
        "Builder",
        time::Date::from_calendar_date(1998, time::Month::November, 28).unwrap(),
    )
    .middle_name("the")
    .preferred_name("Bob")
    .just_seen();

    println!("OK, {:?}", bob.preferred_name);
}

See playground link: Rust Playground

This code seems to compile and work. So what is the actual advantage of the explicit builder pattern used in the book? Or is it just that the book was written in 2024 but still for the Rust 2018 edition?

The extra builder type is usually done to separate the builder methods from the methods that provide the actual logic. You certainly can combine the builder methods with the logic methods on a single type (Details in your case), but many people like the "separation of concerns" . See also:

4 Likes

I don’t see it as just separation of concerns.

  • In many cases, a builder may allow specifying optional values that can't be modified after the output is built. For example, std::thread::Builder allows specifying the stack size, which can’t be changed after the thread is created.
  • Even if the result is not “immutable” in this way, the parameters provided by the builder may not have any 1:1 correspondence with fields in the result, but instead be derived in complex ways, such that providing a builder method is easy and providing a setter method is hard.
  • In method naming: a builder has methods named after the values being provided (.preferred_name("Bob")) but outside of builders, the same method name would normally be used for a getter of that value. Therefore, having a separate builder type allows usually shorter, less cluttered names for both use cases.

I would say that, in general, builders are more obviously distinct from what they build whenever the type is not just a “plain old data” struct.

5 Likes

Another consideration is that you've made all the fields public and didn't use non_exhaustive, so now it's a breaking change to add more fields (or change the existing ones).

3 Likes