A fizzbuzz example: specialisation vs reusability
Posted | Reading time: 5 minutes and 56 seconds.
Not too long ago, fizzbuzz was a common coding challenge in the software development hiring process. Given the number of solutions, it can still serve as an example for many ideas. This article will look at one topic in specific: Specialisation versus reusability.
Specialisation in this domain means someone builds a solution that will work only for one specific use case. Reusability is the technique to develop a more generalised solution used again in other software parts for slightly different problems.
Let us look at solution spaces in fizzbuzz to understand both characteristics better.
The fizzbuzz problem
The assignment for this exercise is simple and fits in a single sentence:
Print integers 1 to N, but print “Fizz” if an integer is divisible by 3, “Buzz” if an integer is divisible by 5, and “FizzBuzz” if an integer is divisible by both 3 and 5. [1]
A specialised implementation in rust could look like this:
fn fizzbuzz(i: i128) -> String {
match i {
n if (n % 15 == 0) => "FizzBuzz".into(),
n if (n % 3 == 0) => "Fizz".into(),
n if (n % 5 == 0) => "Buzz".into(),
_ => i.to_string()
}
}
fn main() {
let range = 1..=100;
let result = range
.clone()
.map(fizzbuzz)
.collect::<Vec<String>>()
.join(";");
println!("Result: {}", result);
}
Our fizzbuzz method is very straightforward. If the modulo 15 (thus, both 3 and 5) is zero, we write “FizzBuzz”; for 3, we write “Fizz”; and for 5, “Buzz”. Otherwise, we return the number as a string.
While this code is easy to write and read, it is hard to extend. A developer with some years of experience will say: “yes, but this is only the beginning! Next, the PO will ask to print the integers 1 to N, but print “Fry” if an integer is divisible by 2, “Fro” if an integer is divisible by 4, “All” if an integer is divisible by 3, “Frizzy” if an integer is divisible by 5, and any combination in that order if the number is divisible by all of those numbers so that the number 60 reads FryFroAllFrizzy
.”
Extending this code to work with this requirement leads to a long list of conditionals. That’s hard to maintain.
A more reusable approach
When we try to think of a more reusable answer, we often consider abstracting the solution into a decomposed set of capabilities that will allow us to orchestrate the path to the result instead of directly implementing it. In this case, we say that if a rule matches
a number, we include its keyword
in the result:
struct Rule {
keyword: &'static str,
matches: fn(i128) -> bool,
}
fn number_or_all_matching_rules(i: i128, rules: Vec<&Rule>) -> String {
let result = rules
.iter()
.map(|rule| if (rule.matches)(i) { rule.keyword } else { "" })
.collect::<Vec<&str>>()
.join("");
if result.is_empty() {
i.to_string()
} else {
result
}
}
fn main() {
let range = 1..=100;
let result = range
.clone()
.map(|i| {
number_or_all_matching_rules(
i,
vec![
&Rule {
matches: |i| i % 3 == 0,
keyword: "Fizz",
},
&Rule {
matches: |i| i % 5 == 0,
keyword: "Buzz",
},
],
)
})
.collect::<Vec<String>>()
.join(";");
println!("Results: {}", result);
}
Unlike the first solution, this one allows us to chain the results for the positive matches; thus, we do not have to define the combined cases. To adopt this solution to the new requirement, we only have to extend the list of rules to
vec![
&Rule {
matches: |i| i % 2 == 0,
keyword: "Fry",
},
&Rule {
matches: |i| i % 4 == 0,
keyword: "Fro",
},
&Rule {
matches: |i| i % 3 == 0,
keyword: "All",
},
&Rule {
matches: |i| i % 5 == 0,
keyword: "Frizzy",
},
]
In addition, as we provide the caller with the ability to pass a rule set, the behaviour can be extended without modifying the number_or_all_matching_rules
function. Rather than having both the default behaviour of returning either the number or all matching rules in one function, we can decouple those and compose them together to have an even more flexible result.
However, what are the downsides of this implementation?
First, understanding the reusable function and implementation requires more concentration from the reader. They will have to trace the execution to understand the code genuinely, whereas, in the specialised solution, four lines of code will give the exact information about everything happening.
In addition, the decoupling adds a hidden performance cost to the code. For example, running the two fizzbuzz solutions for a range from 1 to 1,000,000 leads to the following durations:
[Specialised] elapsed: 57.89ms
[Reusable] elapsed: 106.73ms
The reusable variant takes almost twice as long as the specialised one. It is a naive implementation and can be improved further, yet it will not be easy to achieve the same numbers as the specialised solution.
And the performance impact is different for various languages. I created example implementations and shared them on github. Even in this simple example, the performance impact for some of the languages is of factor 4. With more sophisticated abstractions, these penalties are even heavier and have to be weighed carefully.
The downsides of specialised and reusable code
Now that we have seen the two different approaches, it is time to review the advantages and disadvantages of both.
Specialised
- Advantage: Can be implemented faster and often is the straightforward solution. May lead to performant code that can be easier to understand
- Disadvantage: Does not work outside the designated use case, and extending it means implementing a whole new case from scratch. This may lead to duplication, which can become cumbersome to maintain and further extend.
Reusable
- Advantage: Abstract solutions can be reused in different contexts without implementing the same behaviour multiple times. Thus, overall it reduces the amount of code and maintenance.
- Disadvantage: Understanding decomposed, reusable code can increase the amount of mental load that developers will have to shoulder. In addition, it may add a performance penalty to the software. The wrong abstraction can be far more expensive than maintaining the required duplication.
So which should one prefer, specialised or reusable code? After reading this article, you probably realise that this is the wrong question to ask. Both approaches have their strengths and drawbacks, so we should choose the one that brings more benefit to us for each situation. Deciding which one to use depends on factors such as the project’s scope (microservice vs framework), the nature of future requirements you may be able to predict, performance requirements, deadlines and the skill level of the people maintaining the code.
Having in mind lean software development and evolutionary architecture, we should go with the most simple solution that can help us validate our metrics. As the code and requirements evolve, we should watch for improvements. If we decide on extracting duplication and creating an abstraction too early, we might accidentally end up with the wrong abstraction. This mistake will not be identified until later when it might be pretty expensive to change. Hence the “choice” between these two states, specialised vs generic, is instead a journey where, at each step, we have to evaluate the tradeoffs between maintaining some duplication until a more appropriate abstraction becomes obvious.
This journey between them should not scare you, as with the right amount of unit tests in place, the refactoring into more abstracted code will be a breeze, and a suite of performance tests will ensure the impact of generalisation will not be big enough to affect end-users. In addition, the decisions around which abstractions to build will become easier to make as we learn more about the system (what is/can provide value for our customers) and as we get comfortable with the tech we are using.