Roman Numerals Kata
I wanted to have a play about with pattern matching a bit more in Elixir so I thought I would see if I could do the roman numerals kata using pattern matching and no looping.
Basic tests I -> III
I started off in the usual TDD way of writing a test for “I”
test "one is I" do
assert Roman.to_roman(1) == "I"
end
def to_roman(number)
String.duplicate("I", number)
end
No great shakes, we can print up to “III”. So far so simple. The next step I normally take is “IV”. It’s a bit more interesting in elixir as I wanted to exercise Guard Clauses in elixir.
Pattern Matching IV and V
What you need to do is define a guard for when you want to execute the function.
test "four is IV" do
assert Roman.to_roman(4) == "IV"
end
def to_roman(number) when number == 4 do
"IV"
end
All we have here is a conditional guard clause to say if the number is equal to 4 then run this function. If it doesn’t match, we don’t care. It is not our responsibility to deal with, which for me is extremely interesting, we are in effect following the SRP at a function level.
One thing that caught me out was guard precedence, basically, it’s top to bottom.
Let’s move on, next is 5
def to_roman(number) when number == 5 do
"V"
end
Boom, done.
Implementing VI
You may have noticed that we haven’t implmeneted anything interesting yet. We have some behaviour for 1-3, but that is all. 4 and 5 are just lookups. The next exciting thing to implement is 6, which is funny because 4 hasn’t added anything to help us drive out the behaviour. For 6 (VI) we need to accumulate 5 and 1. So let’s get started
test "six is VI" do
assert Roman.to_roman(6) == "VI"
end
What I want to do is match anything greater than 5 and return “V” along with the remaining match for 1. This what I came up with:
def to_roman(number) when number >= 5 do
to_roman(number - 5, "V")
end
The problem is we don’t have anything that will match to_roman/2
, so let’s implement that.
def to_roman(number, roman_accumulator) do
roman_accumulator <> String.duplicate("I", number)
end
And now are concatenating the two string together, the “V” and the “I”. Now, I’ve taken a bit of leap, we have implemented 7 and 8. But, I think that’s okay.
Now, our code is looking a little bit messy. Let’s go ahead and refactor it a bit.
def to_roman(number, roman_accumulator) do
roman_accumulator <> String.duplicate("I", number)
end
def to_roman(number) when number >= 5 do
to_roman(number - 5, "V")
end
def to_roman(number) when number == 4 do
to_roman(number - 4, "V")
end
def to_roman(number) do
to_roman(number, "")
end
We still don’t quite have anything generic yet. Let’s implement “IX” and “X” in the same way, get some tests round them.
A more generic algorithm
Now there’s still nothing going on to help us design our algorithm, let’s move onto “XIV”. There’s a bit more happening, we will need to combine “X” and “IV”. When we run our test for 14 we get a test failure:
Assertion with == failed
code: assert Roman.to_roman(14) == "XIV"
left: "XIIII"
right: "XIV"
So what we need to have is a way for “X” to fall through into “IV”. To achieve this, I’m just going to add in the parameter.
def to_roman(number, roman_accumulator) when number >= 4 do
to_roman(number - 4, roman_accumulator <> "IV")
end
Now we are starting to see some design emerging, but we have two tests failing.
code: assert Roman.to_roman(4) == "IV"
left: "IIII"
right: "IV"
The reason is simple; we need to ensure that
def to_roman(number, roman_accumulator) do
roman_accumulator <> String.duplicate("I", number)
end
doesn’t catch the case for 4. We can achieve this in two ways; I’m going to implement both as I think it will make the code clearer. We will implement a guard clause and change the order of the code.
And now our test passes. Now I think we’re getting closer to something more generic. Let’s implement “XVI” and tidy up. Again when we write our test for 16 we get
Assertion with == failed
code: assert Roman.to_roman(16) == "XVI"
left: "XIVII"
right: "XVI"
Now, what is going on here? It’s fairly simple when you dig into it. We get the match for 10 (X), remainder 6. We then get match a match on the 6 to “IV” which is incorrect. We need it to match “V”. So let’s go ahead and implement that
def to_roman(number, roman_accumulator) when number >= 5 do
to_roman(number - 5, roman_accumulator <> "V")
end
Now I’m going to tidy up the 9 and 10 so that it follows the same pattern as 4 and 5.
I think we’ve got something that will cover us until the next numeral, let’s just double check with another test.
What I’m going to do now is go ahead and implement the rest of the algorithm and test it using a doctest
Solution
def to_roman(number, roman_accumulator) when number >= 1000 do
to_roman(number - 1000, roman_accumulator <> "M")
end
def to_roman(number, roman_accumulator) when number >= 900 do
to_roman(number - 900, roman_accumulator <> "CM")
end
def to_roman(number, roman_accumulator) when number >= 500 do
to_roman(number - 500, roman_accumulator <> "D")
end
def to_roman(number, roman_accumulator) when number >= 400 do
to_roman(number - 400, roman_accumulator <> "CD")
end
def to_roman(number, roman_accumulator) when number >= 100 do
to_roman(number - 100, roman_accumulator <> "C")
end
def to_roman(number, roman_accumulator) when number >= 90 do
to_roman(number - 90, roman_accumulator <> "XC")
end
def to_roman(number, roman_accumulator) when number >= 50 do
to_roman(number - 50, roman_accumulator <> "L")
end
def to_roman(number, roman_accumulator) when number >= 40 do
to_roman(number - 40, roman_accumulator <> "XL")
end
def to_roman(number, roman_accumulator) when number >= 10 do
to_roman(number - 10, roman_accumulator <> "X")
end
def to_roman(number, roman_accumulator) when number >= 9 do
to_roman(number - 9, roman_accumulator <> "IX")
end
def to_roman(number, roman_accumulator) when number >= 5 do
to_roman(number - 5, roman_accumulator <> "V")
end
def to_roman(number, roman_accumulator) when number == 4 do
roman_accumulator <> "IV"
end
def to_roman(number, roman_accumulator) when number <= 3 do
roman_accumulator <> String.duplicate("I", number)
end
def to_roman(number) do
to_roman(number, "")
end
Pretty cool I think :)
Removing the String.duplicate
As a bit of a mental exercise, I thought it would be interesting to remove the String.duplicate
basically you end up with a pattern match for 0,1,2 and 3. Interestingly most people don’t test 0 when doing this Kata. It becomes obvious that you need it when you don’t implement a pattern match for 0.
The other thing I wanted to look at was removing the unnecessary fall through for 4 down to 0.
Summary
We’ve learnt a bit about pattern matching and guard clauses in Elixir as well as introducing doctest. This sort of thing can be done in a few different ways.
My personal favourite is
String.duplicate("I", number)
|> String.replace("IIIII", "V")
|> String.replace("VV", "X")
|> String.replace("XXXXX", "L")
|> String.replace("LL", "C")
|> String.replace("CCCCC", "D")
|> String.replace("DD", "M")
|> String.replace("IIII", "IV")
|> String.replace("VIV", "IX")
|> String.replace("XXXX", "XL")
|> String.replace("LXL", "XC")
|> String.replace("CCCC", "CD")
|> String.replace("DCD", "CM")
It’s probably a bit terse; you have to understand whats going on. It’s a little hard to figure our but it’s kind of fun.