In the Ruby world TDD is often demonstrated with a kata of converting decimal numbers into roman numerals. Here’s my take on it with a gentle slope in introducing complexity, with a stepwise discovery of the concepts of…
- repetition
- transliteration
- concatenation
- prefixing
We start with the minimal implementation for 1.
class Integer
def to_roman
"I"
end
end
For 2 we discover our first concept: repetition.
class Integer
def to_roman
"I" * self
end
end
That works for 3 as well, then we add a special case for 4. We decide, we can live with that.
class Integer
def to_roman
return "IV" if self == 4
"I" * self
end
end
This will break again, at 5. To go green asap, we quickly add yet another special case.
class Integer
def to_roman
return "V" if self == 5
return "IV" if self == 4
"I" * self
end
end
And then we refactor, thereby discovering the second core concept: transliteration.
class Integer
NUMERALS = [[5, "V"], [4, "IV"], [1, "I"]]
def to_roman
NUMERALS.each do |decimal, roman|
count = self / decimal
return roman * count if count > 0
end
end
end
For 6, again, we simply add a special case and move on.
class Integer
NUMERALS = [[6, "VI"], [5, "V"], [4, "IV"], [1, "I"]]
def to_roman
NUMERALS.each do |decimal, roman|
count = self / decimal
return roman * count if count > 0
end
end
end
At 7, breaking again, we must get green quickly, even if it comes at the price of a second special case.
class Integer
NUMERALS = [[7, "VII"], [6, "VI"], [5, "V"], [4, "IV"], [1, "I"]]
def to_roman
NUMERALS.each do |decimal, roman|
count = self / decimal
return roman * count if count > 0
end
end
end
And because this is now ripe to refactor, we introduce the third concept: concatenation.
class Integer
NUMERALS = [[5, "V"], [4, "IV"], [1, "I"]]
def to_roman
result, remainder = "", self
NUMERALS.each do |decimal, roman|
count, remainder = remainder.divmod decimal
result << roman * count
end
result
end
end
Working with the translation table we can complete the converter, also adding error handling.
class Integer
NUMERALS = [
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"],
[ 100, "C"], [ 90, "XC"], [ 50, "L"], [ 40, "XL"],
[ 10, "X"], [ 9, "IX"], [ 5, "V"], [ 4, "IV"],
[ 1, "I"]
]
def to_roman
raise "There's no such a roman number" if self < 1 || self > 3999
result, remainder = "", self
NUMERALS.each do |decimal, roman|
count, remainder = remainder.divmod decimal
result << roman * count
end
result
end
end
The TDD approach guided us separating the discovery of the core concepts – repetition, transliteration and concatenation – into distinct steps. Nice.
However.
There’s still quite some fugly duplication going on in the translation table, indicating that we’ve ignored the fourth concept lurking behind the remaining double-letter values: prefixing. Whether the additional algorithmic complexity is worth the elimination of conceptual duplication, is left as an exercise for the reader to implement and decide.