Matchmaking Like Its 2001
We're going to help John McCrea of Cake find the woman of his slightly sarcastic, oddly specific dreams. He's a particular man:
Yep, you and I both think it: this sounds like a job for the Rust compiler. This band was truly ahead of its time. Let's model the problem:
/// Girl type
struct Girl {}
impl Girl {
/// Construct a Girl
fn new() -> Self {
Self {}
}
}
/// Determine whether given girl matches spec
fn is_dream_girl(girl: &Girl) -> bool {
// we don't know anything about spec yet, so odds are no
false
}
fn main() {
let girl = Girl::new();
println!("Match: {}", is_dream_girl(&girl));
}
Running this with cargo run
yields the expected output: Match: false
.
So, what is it we're looking for specifically? Luckily, our man starts right in with the preferences, on the first line he tells us he wants "a girl with a mind like a diamond". Let's add a member field to test for:
#[derive(Clone, Copy, PartialEq)]
enum Mind {
Computer,
Diamond,
Garden,
Scalpel,
Sieve,
Unknown,
}
struct Girl {
mind: Mind,
}
impl Girl {
fn new(mind: Mind) -> Self {
Self { mind }
}
}
Now the test function can check for the requested variant:
fn is_dream_girl(girl: &Girl) -> bool {
girl.mind == Mind::Diamond
}
fn main() {
let girl = Girl::new(Mind::Diamond);
println!("Match: {}", is_dream_girl(&girl));
}
Great! Now we get Match: true
when passing in this Girl
. Hold on, though - we've got some more criteria. Next, we need "a girl who knows what's best". That's pretty easy - either she does or she doesn't:
struct Girl {
mind: Mind,
knows_best: bool,
}
impl Girl {
fn new(mind: Mind, knows_best: bool) -> Self {
Self { mind, knows_best }
}
}
fn is_dream_girl(girl: &Girl) -> bool {
girl.mind == Mind::Diamond && girl.knows_best
}
Just add it to the parameter list:
fn main() {
let girl = Girl::new(Mind::Diamond, true);
println!("Match: {}", is_dream_girl(&girl));
}
Groovy. Now we need "shoes that cut and eyes that burn like cigarettes". It sounds like we'll need to associate some pairs of strings:
type Attribute = (String, String);
/// Girl type
struct Girl {
items: Vec<Attribute>,
mind: Mind,
knows_best: bool,
}
An attribute will be a tuple like ("shoes", "cut")
. We can ask for the shoes and eye attributes in the constructor:
impl Girl {
fn new(mind: Mind, knows_best: bool, shoes: &str, eyes: &str) -> Self {
let mut ret = Self {
items: Vec::new(),
mind,
knows_best,
};
ret.push_item("shoes", shoes);
ret.push_item("eyes", eyes);
ret
}
fn push_item(&mut self, item_name: &str, attribute: &str) {
self.items.push((item_name.into(), attribute.into()));
}
}
We'll just check through the items to make sure we get what we want:
fn is_dream_girl(girl: &Girl) -> bool {
let mut found_shoes = false;
let mut found_eyes = false;
for item in &girl.items {
if item.0 == "shoes" && item.1 == "cut" {
found_shoes = true;
} else if item.0 == "eyes" && item.1 == "burn like cigarettes" {
found_eyes = true;
}
}
girl.mind == Mind::Diamond && girl.knows_best && found_shoes && found_eyes
}
Awesome! We just need to construct the Girl
with the new attributes:
fn main() {
let girl = Girl::new(Mind::Diamond, true, "cut", "burn like cigarettes");
println!("Match: {}", is_dream_girl(&girl));
}
Okay. Hold on. Do you see the problem here? Let's skim ahead...
I want a girl with the right allocations
Who's fast and thorough
And sharp as a tack
She's playing with her jewelry
She's putting up her hair
She's touring the facility
And picking up slack
...
It just continues from there! This Girl
constructor is already getting out of hand and we just barely made it out of the first stanza. What if John changes his mind? He might decide something's not as important, or add a new criterion. This code is not amenable to changes like that, every call site is dependent on this exact parameter list given in this exact order, but people don't work like that. There could be all sorts of variations.
The Pattern
Let's re-implement this program leveraging the Builder Pattern. When a Girl
is first constructed, we just want to start with some sensible defaults:
struct Girl {
items: Vec<Attribute>,
mind: Mind,
knows_best: bool,
}
impl Girl {
fn new() -> Self {
Self::default()
}
}
impl Default for Girl {
fn default() -> Self {
Self { mind: Mind::Unknown, knows_best: false, items: Vec::new() }
}
}
Everything else is a blank slate. This way we can just use Girl::new()
with no parameters and get a starting point. To add more, we can define methods:
impl Girl {
// ..
fn set_mind(&mut self, mind: Mind) -> &mut Self {
self.mind = mind;
self
}
}
This method takes a mutable reference and returns one, so we can construct first and adjust later:
fn main() {
let mut girl = Girl::new();
girl.set_mind(Mind::Diamond);
println!("Match: {}", is_dream_girl(&girl));
}
Let's add the rest:
impl Girl {
fn push_item(&mut self, item_name: &str, attribute: &str) {
self.items.push((item_name.into(), attribute.into()));
}
fn toggle_knows_best(&mut self) -> &mut Self {
self.knows_best = !self.knows_best;
self
}
}
Now we can add them one at a time, regardless of what we know at the time of construction:
fn main() {
let mut girl = Girl::new();
girl.set_mind(Mind::Diamond);
girl.toggle_knows_best();
girl.push_item("shoes", "cut");
girl.push_item("eyes", "burn like cigarettes");
println!("Match: {}", is_dream_girl(&girl));
}
This is so much easier to work with as the specification grows and evolves.
More complex scenarios may require you to use a separate type, like GirlBuilder, and take ownership at each step. This will allow you to do this all in a one-liner: let girl = GirlBuilder::new().set_mind(Mind::Diamond).toggle_knows_best();
This does limit your configuration options, for instance if you want to conditionally call some builder method in an if
expression. If possible, the non-owning pattern here is more flexible.
Here's hoping we can help Mr. McCrea finally settle down after all this time.
Challenge
Prepare yourself for five years from now when this song is an "oldie".