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
- Define a struct for a configurable object.
- Define a builder responsible for populating its optional fields.
- 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
}
}
}
Go
// GO
type Config struct{
address string
port uint32
}
type ConfigBuilder struct {
address string
port uint32
}
func NewConfigBuilder() *ConfigBuilder {
return &ConfigBuilder{}
}
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
- Implement methods to set each field on the Builder struct.
- 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
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
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
- Construct an object using the builder.
- 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
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
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(),
}
}
}
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,
}
}
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:
-
Some(T)
, where T is generic over any type, and 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!
Top comments (5)
Awesome idea for a series and great first edition, Joshua! Side by side comparisons are awesome...
Looking forward to seeing more of these. 🙌
p.s. We have a feature for creating a series that you can read about here in case you wanna group these posts together in an official way.
Thanks so much for your comment and suggestion, Michael!
I believe I've successfully tagged the post as a series, which should show very soon once I've published a second, I believe?
Sweet!! Good stuff, Joshua! And you're exactly right...
Once you post that second article in the series, the series name and navigator (way to click through the list of posts in the series) should appear.
In case ya are curious and haven't already seen this feature out in the wild, you can view my latest Music Monday post, to see what articles in a series look like.
Maybe you should implement String and/or MarshalText in go to align to the standard library.
Thanks for you suggestion, Peter. I'll definitely be checking out MarshalText. You're very likely a lot more skilled in Go than I am!