Side by Side Series: Rust & Go - The Builder

synthetic_rain

Joshua Newell Diehl

Posted on September 27, 2023

Side by Side Series: Rust & Go - The Builder

Welcome to the very first episode in my side-by-side series!

Our work is to explore the unique implementations of various design patterns in two popular languages.

Our goal: to obtain a clearer understanding of language-agnostic strategies that can be applied to nearly any programming project!


The experiments in this series are best suited to readers with prior programming experience.

Important Notes

  • Though the examples used do emulate a practical use case to some extent, they are in no way meant to represent "the best" way of doing things. In the interest of not distracting from the underlying pattern in question:
  • Examples make use of core libraries but avoid third-party packages wherever possible.
  • Performance implications are ignored.

The Pattern

A definition excerpted from my favorite design patterns reference:

"Builder is a creational design pattern that lets you construct complex objects step by step. The pattern allows you to produce different types and representations of an object using the same construction code." - Refactoring Guru

In contrast to a tightly-coupled inheritance model, the Builder Pattern offers a compositional approach to instantiating objects. It empowers us to accomplish a kind of polymorphism by using the same core constructors to account for a variety of possibilities, all while designing code that is organized and safely encapsulated.

Our example features a derived example of a "Configuration Builder", wherein our app's Server configuration can be constructed piece by piece without concern for empty fields, as the builder pattern allows us to directly manage the population of each field in the object.


The Implementation

PSEUDOCODE

  1. Define a struct for a configurable object.
  2. Define a builder responsible for populating its optional fields.
  3. Begin with an empty object which can be incrementally "built".

The code in both examples is made public for the sake of consistency.

Rust

// RUST

struct Config {
    address: String,
    port: u32,
    secure: bool
}

pub struct ConfigBuilder {
    address: Option<String>,
    port: Option<u32>,
    secure: Option<bool>
}

impl ConfigBuilder {
    pub fn new() -> ConfigBuilder {
        Self {
            address: None,
            port: None,
            secure: None
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Go

// GO

type Config struct{
    address string
    port uint32 
}

type ConfigBuilder struct {
    address string 
    port uint32
}

func NewConfigBuilder() *ConfigBuilder {
    return &ConfigBuilder{}
}
Enter fullscreen mode Exit fullscreen mode

The apparent redundancy in the Go code is a bit off-putting at first. Why do we plan to implement two different structs with seemingly identical fields?

We'll see how the builder pattern will allow us to overcome the lack of a nil-handling type and respond to the inevitability of emptiness | ^_^ |.

PSEUDOCODE

  1. Implement methods to set each field on the Builder struct.
  2. Implement a build method that returns a completed struct with the appropriate fields.

Rust

// RUST

... // snipped

impl ConfigBuilder {
    pub fn new() -> ConfigBuilder {
        Self {
            address: None,
            port: None,
            secure: None
        }
    }

    pub fn set_address(mut self, addr: String) -> Self {
        self.address = Some(addr);
        return self;
    }

    pub fn set_port(mut self, port: u32) -> Self {
        self.port = Some(port);
        return self;
    }

    pub fn set_secure(mut self, secure: bool) -> Self {
        self.secure = Some(secure);
        return self;
    }

    pub fn build(self) -> Config {
        Config {
            address: self.address.unwrap_or_default(),
            port: self.port.unwrap_or_default(),
            secure: self.secure.unwrap_or_default(),
        }
    }
}

... // snipped

Enter fullscreen mode Exit fullscreen mode

Go

// GO

... // snipped

func (cb *ConfigBuilder) setAddress(addr string) {
    cb.Address = addr
}

func (cb *ConfigBuilder) setPort(port uint32) {
    cb.Port = port
}

func (cb *ConfigBuilder) setSecure(secure bool) {
    cb.Secure = secure
}

func (cb *ConfigBuilder) build() *Config {
    if cb.Port == 0 {
        cb.Port = 3001
    }

    if cb.Address == "" {
        cb.Address = "127.0.0.1"
    }

    return &Config{
        Address: cb.Address,
        Port:    cb.Port,
        Secure:  cb.Secure,
    }
}

... // snipped

Enter fullscreen mode Exit fullscreen mode

Notes

  • My understanding is that method chaining is not entirely idiomatic in Go, but Rust, in contrast, favors this syntax.
  • This is why Rust's builder methods return Self, while Go's do not.
  • In either case, only once build is called do we return a completed struct.

PSEUDOCODE

  1. Construct an object using the builder.
  2. Print the resulting object using a custom display implementation.

Rust

// RUST

... // snipped


fn main() {
    let cfg = ConfigBuilder::new()
        .set_address(String::from("127.0.0.1"))
        .set_port(8080)
        .set_secure(true)
        .build();

    println!("{cfg}");
}


impl std::fmt::Display for Config {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "PORT: {}\nADDRESS: {}\nSECURE: {}",
            self.address, self.port, self.secure
        )
    }
}

... // snipped

Enter fullscreen mode Exit fullscreen mode

Go

// GO

... // snipped

func main() {
    bldr := NewConfigBuilder()
    bldr.setAddress("128.20.10.3")
    bldr.setPort(8080)
    bldr.setSecure(true)

    config := bldr.build()

    config.display()
}

func (c *Config) display() {
    fmt.Printf("PORT: %v\nADDRESS: %v\nSECURE: %v", c.Port, c.Address, c.Secure)
}

... // snipped

Enter fullscreen mode Exit fullscreen mode

Everything Together

Rust Final

// RUST

fn main() {
    // method chaining! lookin' good
    let cfg = ConfigBuilder::new()
        .set_address(String::from("127.0.0.1"))
        .set_port(8080)
        .set_secure(true)
        .build();

    // Print struct as defined by our std::fmt::Display implementation
    println!("{cfg}");
}

// what we're building
pub struct Config {
    pub address: String,
    pub port: u32,
    pub secure: bool,
}

/*
Rust std lib includes a standardized trait for implementing
a custom display method!
*/
impl std::fmt::Display for Config {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "PORT: {}\nADDRESS: {}\nSECURE: {}",
            self.address, self.port, self.secure
        )
    }
}

// The struct responsible for creating our Config
pub struct ConfigBuilder {
    address: Option<String>,
    port: Option<u32>,
    secure: Option<bool>,
}

impl ConfigBuilder {
    // instantiate empty
    pub fn new() -> ConfigBuilder {
        Self {
            address: None,
            port: None,
            secure: None,
        }
    }

    pub fn set_address(mut self, addr: String) -> Self {
        self.address = Some(addr);
        return self; // enable method chaining
    }

    pub fn set_port(mut self, port: u32) -> Self {
        self.port = Some(port);
        return self;
    }

    pub fn set_secure(mut self, secure: bool) -> Self {
        self.secure = Some(secure);
        return self;
    }

    pub fn build(self) -> Config {
        /* 
        Since Option is a Rust type, it comes with a plethora of 
        useful methods out-of-the-box.
        unwrap_or_default() will either return the value wrapped 
        in Some or the default for that value.  (All primitives 
        have clearly-defined defaults)
        */
        Config {
            address: self.address.unwrap_or_default(),
            port: self.port.unwrap_or_default(),
            secure: self.secure.unwrap_or_default(),
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Go Final

// GO

package main

import "fmt"

func main() {
    // call methods line by line in Go?
    bldr := NewConfigBuilder()
    bldr.setAddress("128.20.10.3")
    bldr.setPort(8080)
    bldr.setSecure(true)

    config := bldr.build()

    config.display()
}

// The object we're building
type Config struct {
    Address string
    Port    uint32
    Secure  bool
}

// a helper method for custom printing
func (c *Config) display() {
    fmt.Printf("PORT: %v\nADDRESS: %v\nSECURE: %v", c.Port, c.Address, c.Secure)
}

// struct responsible for creating our config
type ConfigBuilder struct {
    Address string
    Port    uint32
    Secure  bool
}

func NewConfigBuilder() *ConfigBuilder {
    return &ConfigBuilder{}
}

func (cb *ConfigBuilder) setAddress(addr string) {
    cb.Address = addr
        // not returning self
}

func (cb *ConfigBuilder) setPort(port uint32) {
    cb.Port = port
}

func (cb *ConfigBuilder) setSecure(secure bool) {
    cb.Secure = secure
}

func (cb *ConfigBuilder) build() *Config {
        // simple, traditional handling of defaults
    if cb.Port == 0 {
        cb.Port = 3001
    }

    if cb.Address == "" {
        cb.Address = "127.0.0.1"
    }

    return &Config{
        Address: cb.Address,
        Port:    cb.Port,
        Secure:  cb.Secure,
    }
}
Enter fullscreen mode Exit fullscreen mode

Side-by-Side, PRETTIFIED
Go & Rust program examples side-by-side




The Insights

The Rust standard prelude includes some powerful tools for implementing the Builder Pattern in Rust.

Namely, the std::Option enum type. Option offers versatility and expressiveness without sacrificing clarity. It is a safer, more semantic alternative to the empty nil type.
The Option type is simple.

There are only two variants:

  1. Some(T), where T is generic over any type, and
  2. None

The Option type allows us to easily maximize the flexibility offered by The Builder Pattern.
Deciding on our own how best to represent an empty value isn't necessary.

Both Go and Rust forgo more traditional Object-Oriented features like inheritance. Instead, we first define custom data structures, and then define separately their implementations.

The inherently decoupled nature of these languages encourages composition. One could write structs representing more complex combinations of builders without modifying or duplicating the underlying implementation details of any!


Conclusion

I hope you had fun exploring The Builder Pattern as represented by these two powerful, comparable, but ultimately unique languages.

We should always remember that while we write code for execution by the computer, the design of our code is an effort made to ease the burden on ourselves and others in understanding our work.

Keep going,
keep growing,
and don't forget to have fun!

💖 💪 🙅 🚩
synthetic_rain
Joshua Newell Diehl

Posted on September 27, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related