layout: post
title: “Worked example: Roman numerals”
description: “More pattern matching in practice”
nav: thinking-functionally
seriesId: “Expressions and syntax”
seriesOrder: 12

categories: [Patterns, Worked Examples]

Last time we looked at parsing a command line. This time we’ll we’ll look at another pattern matching example, this time using Roman numerals.

As before, we will try to have a “pure” internal model with separate stages to convert the input to the internal model, and then another separate stage to convert from the internal model to the output.

Worked example: Roman numerals - 图1

Requirements

Let’s start with the requirements:

  1. 1) Accept a string of letters like "MMMXCLXXIV" as a string and convert it to an integer.
  2. The conversions are: I=1; V=5; X=10; L=50; C=100; D=500; and M=1000;
  3. If a lower letter comes before a higher one, the value of the higher is reduced accordingly, so
  4. IV=4; IX=9; XC=90; and so on.
  5. 2) As an additional step, validate the string of letters to see if it is a valid number. For example: "IIVVMM" is a not a valid roman numeral.

First version

As before we’ll start by first creating the internal model, and then look at how we can parse the input into the internal model.

Here’s a first stab at the model. We’ll treat a RomanNumeral as a list of RomanDigits.

  1. type RomanDigit = int
  2. type RomanNumeral = RomanDigit list

No, stop right there! A RomanDigit is not just any digit, it has to be taken from a limited set.

Also RomanNumeral should not just be a type alias for a list of digits. It would be better if it was its own special type as well.
We can do this by creating a single case union type.

Here’s a much better version:

  1. type RomanDigit = I | V | X | L | C | D | M
  2. type RomanNumeral = RomanNumeral of RomanDigit list

Output: Converting a numeral to an int

Now let’s do the output logic, converting a Roman numeral to an int.

The digit conversion is easy:

  1. /// Converts a single RomanDigit to an integer
  2. let digitToInt =
  3. function
  4. | I -> 1
  5. | V -> 5
  6. | X -> 10
  7. | L -> 50
  8. | C -> 100
  9. | D -> 500
  10. | M -> 1000
  11. // tests
  12. I |> digitToInt
  13. V |> digitToInt
  14. M |> digitToInt

Note that we’re using the function keyword instead of the match..with expression.

To convert a list of digits, we’ll use a recursive loop again.
There is a special case when we need to look ahead to the next digit, and if it is larger than the current one, then use their difference.

  1. let rec digitsToInt =
  2. function
  3. // empty is 0
  4. | [] -> 0
  5. // special case when a smaller comes before larger
  6. // convert both digits and add the difference to the sum
  7. // Example: "IV" and "CM"
  8. | smaller::larger::ns when smaller < larger ->
  9. (digitToInt larger - digitToInt smaller) + digitsToInt ns
  10. // otherwise convert the digit and add to the sum
  11. | digit::ns ->
  12. digitToInt digit + digitsToInt ns
  13. // tests
  14. [I;I;I;I] |> digitsToInt
  15. [I;V] |> digitsToInt
  16. [V;I] |> digitsToInt
  17. [I;X] |> digitsToInt
  18. [M;C;M;L;X;X;I;X] |> digitsToInt // 1979
  19. [M;C;M;X;L;I;V] |> digitsToInt // 1944

Note that the “less than” operation did not have to be defined. The types are automatically sorted by their order of declaration.

Finally, we can convert the RomanNumeral type itself by unpacking the contents into a list and calling digitsToInt.

  1. /// converts a RomanNumeral to an integer
  2. let toInt (RomanNumeral digits) = digitsToInt digits
  3. // test
  4. let x = RomanNumeral [I;I;I;I]
  5. x |> toInt
  6. let x = RomanNumeral [M;C;M;L;X;X;I;X]
  7. x |> toInt

That takes care of the output.

Input: Converting a string to an Roman Numeral

Now let’s do the input logic, converting a string to our internal model.

First, let’s handle a single character conversion. It seems straightforward.

  1. let charToRomanDigit =
  2. function
  3. | 'I' -> I
  4. | 'V' -> V
  5. | 'X' -> X
  6. | 'L' -> L
  7. | 'C' -> C
  8. | 'D' -> D
  9. | 'M' -> M

The compiler doesn’t like that! What happens if we get some other character?

This is a great example of how exhaustive pattern matching can force you to think about missing requirements.

So, what should be done for bad input. How about printing an error message?

Let’s try again and add a case to handle all other characters:

  1. let charToRomanDigit =
  2. function
  3. | 'I' -> I
  4. | 'V' -> V
  5. | 'X' -> X
  6. | 'L' -> L
  7. | 'C' -> C
  8. | 'D' -> D
  9. | 'M' -> M
  10. | ch -> eprintf "%c is not a valid character" ch

The compiler doesn’t like that either! The normal cases return a valid RomanDigit but the error case returns unit. As we saw in the earlier post, every branch must return the same type.

How can we fix this? We could throw an exception, but that seems a bit excessive. If we think about it some more, there’s no way that charToRomanDigit can always return a valid RomanDigit.
Sometimes it can, and sometimes it can’t. In other words, we need to use something like an option type here.

But on further consideration, we might also want the caller to know what the bad char was. So we need to create our own little variant on the option type to hold both cases.

Here’s the fixed up version:

  1. type ParsedChar =
  2. | Digit of RomanDigit
  3. | BadChar of char
  4. let charToRomanDigit =
  5. function
  6. | 'I' -> Digit I
  7. | 'V' -> Digit V
  8. | 'X' -> Digit X
  9. | 'L' -> Digit L
  10. | 'C' -> Digit C
  11. | 'D' -> Digit D
  12. | 'M' -> Digit M
  13. | ch -> BadChar ch

Note that I have removed the error message. Since the bad char is being returned, the caller can print its own message for the BadChar case.

Next, we should check the function signature to make sure it is what we expect:

  1. charToRomanDigit : char -> ParsedChar

That looks good.

Now, how can we convert a string into these digits? We convert the string to a char array, convert that into a list, and then do a final conversion using charToRomanDigit.

  1. let toRomanDigitList s =
  2. s.ToCharArray() // error FS0072
  3. |> List.ofArray
  4. |> List.map charToRomanDigit

But the compiler complains again with “FS0072: Lookup on object of indeterminate type”,

That typically happens when you use a method rather than a function. Any object could implement .ToCharArray() so the type inference cannot tell what type is meant.

In this case, the solution is just to use an explicit type annotation on the parameter — our first so far!

  1. let toRomanDigitList (s:string) =
  2. s.ToCharArray()
  3. |> List.ofArray
  4. |> List.map charToRomanDigit

But look at the signature:

  1. toRomanDigitList : string -> ParsedChar list

It still has the pesky ParsedChar in it rather than RomanDigits. How do we want to proceed? Answer, let’s pass the buck again and let someone else deal with it!

“Passing the buck” in this case is actually a good design principle. This function doesn’t know what its clients might want to do — some might want to ignore errors, while others might want to fail fast. So just pass back the information and let them decide.

In this case, the client is the top level function that creates a RomanNumeral type. Here’s our first attempt:

  1. // convert a string to a RomanNumeral
  2. let toRomanNumeral s =
  3. toRomanDigitList s
  4. |> RomanNumeral

The compiler is not happy — the RomanNumeral constructor requires a list of RomanDigits, but the toRomanDigitList is giving us a list of ParsedChars instead.

Now we finally do have to commit to an error handling policy. Let’s choose to ignore bad chars, but print out errors when they occur. We’ll use the List.choose function for this.
It’s similar to List.map, but in addition has a filter built into it. Elements that are valid (Some something) are returned, but elements that are None are filtered out.

Our choose function should thus do the following:

  • For valid digits return Some digit
  • For the invalid BadChars, print the error message and return None.

If we do this, the output of List.choose will be a list of RomanDigits, exactly as needed as the input to the RomanNumeral constructor.

Here is everything put together:

  1. /// Convert a string to a RomanNumeral
  2. /// Does not validate the input.E.g. "IVIV" would be valid
  3. let toRomanNumeral s =
  4. toRomanDigitList s
  5. |> List.choose (
  6. function
  7. | Digit digit ->
  8. Some digit
  9. | BadChar ch ->
  10. eprintfn "%c is not a valid character" ch
  11. None
  12. )
  13. |> RomanNumeral

Let’s test!

  1. // test good cases
  2. "IIII" |> toRomanNumeral
  3. "IV" |> toRomanNumeral
  4. "VI" |> toRomanNumeral
  5. "IX" |> toRomanNumeral
  6. "MCMLXXIX" |> toRomanNumeral
  7. "MCMXLIV" |> toRomanNumeral
  8. "" |> toRomanNumeral
  9. // error cases
  10. "MC?I" |> toRomanNumeral
  11. "abc" |> toRomanNumeral

Ok, everything is good so far. Let’s move on to validation.

Validation rules

The validation rules were not listed in the requirements, so let’s put down our best guess based on what we know about Roman numerals:

  • Five in a row of any digit is not allowed
  • Some digits are allowed in runs of up to 4. They are I,X,C, and M. The others (V,L,D) can only appear singly.
  • Some lower digits can come before a higher digit, but only if they appear singly. E.g. “IX” is ok but “IIIX” is not.
  • But this is only for pairs of digits. Three ascending numbers in a row is invalid. E.g. “IX” is ok but “IXC” is not.
  • A single digit with no runs is always allowed

We can convert these requirements into a pattern matching function as follows:

  1. let runsAllowed =
  2. function
  3. | I | X | C | M -> true
  4. | V | L | D -> false
  5. let noRunsAllowed = runsAllowed >> not
  6. // check for validity
  7. let rec isValidDigitList digitList =
  8. match digitList with
  9. // empty list is valid
  10. | [] -> true
  11. // A run of 5 or more anything is invalid
  12. // Example: XXXXX
  13. | d1::d2::d3::d4::d5::_
  14. when d1=d2 && d1=d3 && d1=d4 && d1=d5 ->
  15. false
  16. // 2 or more non-runnable digits is invalid
  17. // Example: VV
  18. | d1::d2::_
  19. when d1=d2 && noRunsAllowed d1 ->
  20. false
  21. // runs of 2,3,4 in the middle are invalid if next digit is higher
  22. // Example: IIIX
  23. | d1::d2::d3::d4::higher::ds
  24. when d1=d2 && d1=d3 && d1=d4
  25. && runsAllowed d1 // not really needed because of the order of matching
  26. && higher > d1 ->
  27. false
  28. | d1::d2::d3::higher::ds
  29. when d1=d2 && d1=d3
  30. && runsAllowed d1
  31. && higher > d1 ->
  32. false
  33. | d1::d2::higher::ds
  34. when d1=d2
  35. && runsAllowed d1
  36. && higher > d1 ->
  37. false
  38. // three ascending numbers in a row is invalid
  39. // Example: IVX
  40. | d1::d2::d3::_ when d1<d2 && d2<= d3 ->
  41. false
  42. // A single digit with no runs is always allowed
  43. | _::ds ->
  44. // check the remainder of the list
  45. isValidDigitList ds

Again, note that “equality” and “less than” did not need to be defined.

And let’s test the validation:

  1. // test valid
  2. let validList = [
  3. [I;I;I;I]
  4. [I;V]
  5. [I;X]
  6. [I;X;V]
  7. [V;X]
  8. [X;I;V]
  9. [X;I;X]
  10. [X;X;I;I]
  11. ]
  12. let testValid = validList |> List.map isValidDigitList
  13. let invalidList = [
  14. // Five in a row of any digit is not allowed
  15. [I;I;I;I;I]
  16. // Two in a row for V,L, D is not allowed
  17. [V;V]
  18. [L;L]
  19. [D;D]
  20. // runs of 2,3,4 in the middle are invalid if next digit is higher
  21. [I;I;V]
  22. [X;X;X;M]
  23. [C;C;C;C;D]
  24. // three ascending numbers in a row is invalid
  25. [I;V;X]
  26. [X;L;D]
  27. ]
  28. let testInvalid = invalidList |> List.map isValidDigitList

Finally, we add a top level function to test validity of the RomanNumeral type itself.

  1. // top level check for validity
  2. let isValid (RomanNumeral digitList) =
  3. isValidDigitList digitList
  4. // test good cases
  5. "IIII" |> toRomanNumeral |> isValid
  6. "IV" |> toRomanNumeral |> isValid
  7. "" |> toRomanNumeral |> isValid
  8. // error cases
  9. "IIXX" |> toRomanNumeral |> isValid
  10. "VV" |> toRomanNumeral |> isValid
  11. // grand finale
  12. [ "IIII"; "XIV"; "MMDXC";
  13. "IIXX"; "VV"; ]
  14. |> List.map toRomanNumeral
  15. |> List.iter (function
  16. | n when isValid n ->
  17. printfn "%A is valid and its integer value is %i" n (toInt n)
  18. | n ->
  19. printfn "%A is not valid" n
  20. )

The entire code for the first version

Here’s all the code in one module:

  1. module RomanNumeralsV1 =
  2. // ==========================================
  3. // Types
  4. // ==========================================
  5. type RomanDigit = I | V | X | L | C | D | M
  6. type RomanNumeral = RomanNumeral of RomanDigit list
  7. // ==========================================
  8. // Output logic
  9. // ==========================================
  10. /// Converts a single RomanDigit to an integer
  11. let digitToInt =
  12. function
  13. | I -> 1
  14. | V -> 5
  15. | X -> 10
  16. | L -> 50
  17. | C -> 100
  18. | D -> 500
  19. | M -> 1000
  20. /// converts a list of digits to an integer
  21. let rec digitsToInt =
  22. function
  23. // empty is 0
  24. | [] -> 0
  25. // special case when a smaller comes before larger
  26. // convert both digits and add the difference to the sum
  27. // Example: "IV" and "CM"
  28. | smaller::larger::ns when smaller < larger ->
  29. (digitToInt larger - digitToInt smaller) + digitsToInt ns
  30. // otherwise convert the digit and add to the sum
  31. | digit::ns ->
  32. digitToInt digit + digitsToInt ns
  33. /// converts a RomanNumeral to an integer
  34. let toInt (RomanNumeral digits) = digitsToInt digits
  35. // ==========================================
  36. // Input logic
  37. // ==========================================
  38. type ParsedChar =
  39. | Digit of RomanDigit
  40. | BadChar of char
  41. let charToRomanDigit =
  42. function
  43. | 'I' -> Digit I
  44. | 'V' -> Digit V
  45. | 'X' -> Digit X
  46. | 'L' -> Digit L
  47. | 'C' -> Digit C
  48. | 'D' -> Digit D
  49. | 'M' -> Digit M
  50. | ch -> BadChar ch
  51. let toRomanDigitList (s:string) =
  52. s.ToCharArray()
  53. |> List.ofArray
  54. |> List.map charToRomanDigit
  55. /// Convert a string to a RomanNumeral
  56. /// Does not validate the input.E.g. "IVIV" would be valid
  57. let toRomanNumeral s =
  58. toRomanDigitList s
  59. |> List.choose (
  60. function
  61. | Digit digit ->
  62. Some digit
  63. | BadChar ch ->
  64. eprintfn "%c is not a valid character" ch
  65. None
  66. )
  67. |> RomanNumeral
  68. // ==========================================
  69. // Validation logic
  70. // ==========================================
  71. let runsAllowed =
  72. function
  73. | I | X | C | M -> true
  74. | V | L | D -> false
  75. let noRunsAllowed = runsAllowed >> not
  76. // check for validity
  77. let rec isValidDigitList digitList =
  78. match digitList with
  79. // empty list is valid
  80. | [] -> true
  81. // A run of 5 or more anything is invalid
  82. // Example: XXXXX
  83. | d1::d2::d3::d4::d5::_
  84. when d1=d2 && d1=d3 && d1=d4 && d1=d5 ->
  85. false
  86. // 2 or more non-runnable digits is invalid
  87. // Example: VV
  88. | d1::d2::_
  89. when d1=d2 && noRunsAllowed d1 ->
  90. false
  91. // runs of 2,3,4 in the middle are invalid if next digit is higher
  92. // Example: IIIX
  93. | d1::d2::d3::d4::higher::ds
  94. when d1=d2 && d1=d3 && d1=d4
  95. && runsAllowed d1 // not really needed because of the order of matching
  96. && higher > d1 ->
  97. false
  98. | d1::d2::d3::higher::ds
  99. when d1=d2 && d1=d3
  100. && runsAllowed d1
  101. && higher > d1 ->
  102. false
  103. | d1::d2::higher::ds
  104. when d1=d2
  105. && runsAllowed d1
  106. && higher > d1 ->
  107. false
  108. // three ascending numbers in a row is invalid
  109. // Example: IVX
  110. | d1::d2::d3::_ when d1<d2 && d2<= d3 ->
  111. false
  112. // A single digit with no runs is always allowed
  113. | _::ds ->
  114. // check the remainder of the list
  115. isValidDigitList ds
  116. // top level check for validity
  117. let isValid (RomanNumeral digitList) =
  118. isValidDigitList digitList

Second version

The code works, but there is something that’s bugging me about it. The validation logic seems very complicated. Surely the Romans didn’t have to think about all of this?

And also, I can think of examples that should fail validation, but pass, such as “VIV”:

  1. "VIV" |> toRomanNumeral |> isValid

We could try to tighten up our validation rules, but let’s try another tack. Complicated logic is often a sign that you don’t quite understand the domain properly.

In other words — could we change the internal model to make everything simpler?

What about if we stopped trying to map letters to digits, and created a domain that mapped how the Romans thought it. In this model “I”, “II”, “III”, “IV” and so on would each be a separate digit.

Let’s run with it and see what happens.

Here’s the new types for the domain. I now have a digit type for every possible digit. The RomanNumeral type stays the same.

  1. type RomanDigit =
  2. | I | II | III | IIII
  3. | IV | V
  4. | IX | X | XX | XXX | XXXX
  5. | XL | L
  6. | XC | C | CC | CCC | CCCC
  7. | CD | D
  8. | CM | M | MM | MMM | MMMM
  9. type RomanNumeral = RomanNumeral of RomanDigit list

Output: second version

Next, converting a single RomanDigit to an integer is the same as before, but with more cases:

  1. /// Converts a single RomanDigit to an integer
  2. let digitToInt =
  3. function
  4. | I -> 1 | II -> 2 | III -> 3 | IIII -> 4
  5. | IV -> 4 | V -> 5
  6. | IX -> 9 | X -> 10 | XX -> 20 | XXX -> 30 | XXXX -> 40
  7. | XL -> 40 | L -> 50
  8. | XC -> 90 | C -> 100 | CC -> 200 | CCC -> 300 | CCCC -> 400
  9. | CD -> 400 | D -> 500
  10. | CM -> 900 | M -> 1000 | MM -> 2000 | MMM -> 3000 | MMMM -> 4000
  11. // tests
  12. I |> digitToInt
  13. III |> digitToInt
  14. V |> digitToInt
  15. CM |> digitToInt

Calculating the sum of the digits is now trivial. No special cases needed:

  1. /// converts a list of digits to an integer
  2. let digitsToInt list =
  3. list |> List.sumBy digitToInt
  4. // tests
  5. [IIII] |> digitsToInt
  6. [IV] |> digitsToInt
  7. [V;I] |> digitsToInt
  8. [IX] |> digitsToInt
  9. [M;CM;L;X;X;IX] |> digitsToInt // 1979
  10. [M;CM;XL;IV] |> digitsToInt // 1944

Finally, the top level function is identical:

  1. /// converts a RomanNumeral to an integer
  2. let toInt (RomanNumeral digits) = digitsToInt digits
  3. // test
  4. let x = RomanNumeral [M;CM;LX;X;IX]
  5. x |> toInt

Input: second version

For the input parsing, we’ll keep the ParsedChar type. But this time, we have to match 1,2,3, or 4 chars at a time.
That means we can’t just pull off one character like we did in the first version — we have to match in the main loop. This means the loop now has to be recursive.

Also, we want to convert IIII into a single IIII digit rather than 4 separate I digits, so we put the longest matches at the front.

  1. type ParsedChar =
  2. | Digit of RomanDigit
  3. | BadChar of char
  4. let rec toRomanDigitListRec charList =
  5. match charList with
  6. // match the longest patterns first
  7. // 4 letter matches
  8. | 'I'::'I'::'I'::'I'::ns ->
  9. Digit IIII :: (toRomanDigitListRec ns)
  10. | 'X'::'X'::'X'::'X'::ns ->
  11. Digit XXXX :: (toRomanDigitListRec ns)
  12. | 'C'::'C'::'C'::'C'::ns ->
  13. Digit CCCC :: (toRomanDigitListRec ns)
  14. | 'M'::'M'::'M'::'M'::ns ->
  15. Digit MMMM :: (toRomanDigitListRec ns)
  16. // 3 letter matches
  17. | 'I'::'I'::'I'::ns ->
  18. Digit III :: (toRomanDigitListRec ns)
  19. | 'X'::'X'::'X'::ns ->
  20. Digit XXX :: (toRomanDigitListRec ns)
  21. | 'C'::'C'::'C'::ns ->
  22. Digit CCC :: (toRomanDigitListRec ns)
  23. | 'M'::'M'::'M'::ns ->
  24. Digit MMM :: (toRomanDigitListRec ns)
  25. // 2 letter matches
  26. | 'I'::'I'::ns ->
  27. Digit II :: (toRomanDigitListRec ns)
  28. | 'X'::'X'::ns ->
  29. Digit XX :: (toRomanDigitListRec ns)
  30. | 'C'::'C'::ns ->
  31. Digit CC :: (toRomanDigitListRec ns)
  32. | 'M'::'M'::ns ->
  33. Digit MM :: (toRomanDigitListRec ns)
  34. | 'I'::'V'::ns ->
  35. Digit IV :: (toRomanDigitListRec ns)
  36. | 'I'::'X'::ns ->
  37. Digit IX :: (toRomanDigitListRec ns)
  38. | 'X'::'L'::ns ->
  39. Digit XL :: (toRomanDigitListRec ns)
  40. | 'X'::'C'::ns ->
  41. Digit XC :: (toRomanDigitListRec ns)
  42. | 'C'::'D'::ns ->
  43. Digit CD :: (toRomanDigitListRec ns)
  44. | 'C'::'M'::ns ->
  45. Digit CM :: (toRomanDigitListRec ns)
  46. // 1 letter matches
  47. | 'I'::ns ->
  48. Digit I :: (toRomanDigitListRec ns)
  49. | 'V'::ns ->
  50. Digit V :: (toRomanDigitListRec ns)
  51. | 'X'::ns ->
  52. Digit X :: (toRomanDigitListRec ns)
  53. | 'L'::ns ->
  54. Digit L :: (toRomanDigitListRec ns)
  55. | 'C'::ns ->
  56. Digit C :: (toRomanDigitListRec ns)
  57. | 'D'::ns ->
  58. Digit D :: (toRomanDigitListRec ns)
  59. | 'M'::ns ->
  60. Digit M :: (toRomanDigitListRec ns)
  61. // bad letter matches
  62. | badChar::ns ->
  63. BadChar badChar :: (toRomanDigitListRec ns)
  64. // 0 letter matches
  65. | [] ->
  66. []

Well, this is much longer than the first version, but otherwise basically the same.

The top level functions are unchanged.

  1. let toRomanDigitList (s:string) =
  2. s.ToCharArray()
  3. |> List.ofArray
  4. |> toRomanDigitListRec
  5. /// Convert a string to a RomanNumeral
  6. let toRomanNumeral s =
  7. toRomanDigitList s
  8. |> List.choose (
  9. function
  10. | Digit digit ->
  11. Some digit
  12. | BadChar ch ->
  13. eprintfn "%c is not a valid character" ch
  14. None
  15. )
  16. |> RomanNumeral
  17. // test good cases
  18. "IIII" |> toRomanNumeral
  19. "IV" |> toRomanNumeral
  20. "VI" |> toRomanNumeral
  21. "IX" |> toRomanNumeral
  22. "MCMLXXIX" |> toRomanNumeral
  23. "MCMXLIV" |> toRomanNumeral
  24. "" |> toRomanNumeral
  25. // error cases
  26. "MC?I" |> toRomanNumeral
  27. "abc" |> toRomanNumeral

Validation: second version

Finally, let’s see how the new domain model affects the validation rules. Now, the rules are much simpler. In fact, there is only one.

  • Each digit must be smaller than the preceding digit
  1. // check for validity
  2. let rec isValidDigitList digitList =
  3. match digitList with
  4. // empty list is valid
  5. | [] -> true
  6. // a following digit that is equal or larger is an error
  7. | d1::d2::_
  8. when d1 <= d2 ->
  9. false
  10. // A single digit is always allowed
  11. | _::ds ->
  12. // check the remainder of the list
  13. isValidDigitList ds
  14. // top level check for validity
  15. let isValid (RomanNumeral digitList) =
  16. isValidDigitList digitList
  17. // test good cases
  18. "IIII" |> toRomanNumeral |> isValid
  19. "IV" |> toRomanNumeral |> isValid
  20. "" |> toRomanNumeral |> isValid
  21. // error cases
  22. "IIXX" |> toRomanNumeral |> isValid
  23. "VV" |> toRomanNumeral |> isValid

Alas, after all that, we still didn’t fix the bad case that triggered the rewrite!

  1. "VIV" |> toRomanNumeral |> isValid

There is a not-too-complicated fix for this, but I think it’s time to leave it alone now!

The entire code for the second version

Here’s all the code in one module for the second version:

  1. module RomanNumeralsV2 =
  2. // ==========================================
  3. // Types
  4. // ==========================================
  5. type RomanDigit =
  6. | I | II | III | IIII
  7. | IV | V
  8. | IX | X | XX | XXX | XXXX
  9. | XL | L
  10. | XC | C | CC | CCC | CCCC
  11. | CD | D
  12. | CM | M | MM | MMM | MMMM
  13. type RomanNumeral = RomanNumeral of RomanDigit list
  14. // ==========================================
  15. // Output logic
  16. // ==========================================
  17. /// Converts a single RomanDigit to an integer
  18. let digitToInt =
  19. function
  20. | I -> 1 | II -> 2 | III -> 3 | IIII -> 4
  21. | IV -> 4 | V -> 5
  22. | IX -> 9 | X -> 10 | XX -> 20 | XXX -> 30 | XXXX -> 40
  23. | XL -> 40 | L -> 50
  24. | XC -> 90 | C -> 100 | CC -> 200 | CCC -> 300 | CCCC -> 400
  25. | CD -> 400 | D -> 500
  26. | CM -> 900 | M -> 1000 | MM -> 2000 | MMM -> 3000 | MMMM -> 4000
  27. /// converts a RomanNumeral to an integer
  28. let toInt (RomanNumeral digits) = digitsToInt digits
  29. // ==========================================
  30. // Input logic
  31. // ==========================================
  32. type ParsedChar =
  33. | Digit of RomanDigit
  34. | BadChar of char
  35. let rec toRomanDigitListRec charList =
  36. match charList with
  37. // match the longest patterns first
  38. // 4 letter matches
  39. | 'I'::'I'::'I'::'I'::ns ->
  40. Digit IIII :: (toRomanDigitListRec ns)
  41. | 'X'::'X'::'X'::'X'::ns ->
  42. Digit XXXX :: (toRomanDigitListRec ns)
  43. | 'C'::'C'::'C'::'C'::ns ->
  44. Digit CCCC :: (toRomanDigitListRec ns)
  45. | 'M'::'M'::'M'::'M'::ns ->
  46. Digit MMMM :: (toRomanDigitListRec ns)
  47. // 3 letter matches
  48. | 'I'::'I'::'I'::ns ->
  49. Digit III :: (toRomanDigitListRec ns)
  50. | 'X'::'X'::'X'::ns ->
  51. Digit XXX :: (toRomanDigitListRec ns)
  52. | 'C'::'C'::'C'::ns ->
  53. Digit CCC :: (toRomanDigitListRec ns)
  54. | 'M'::'M'::'M'::ns ->
  55. Digit MMM :: (toRomanDigitListRec ns)
  56. // 2 letter matches
  57. | 'I'::'I'::ns ->
  58. Digit II :: (toRomanDigitListRec ns)
  59. | 'X'::'X'::ns ->
  60. Digit XX :: (toRomanDigitListRec ns)
  61. | 'C'::'C'::ns ->
  62. Digit CC :: (toRomanDigitListRec ns)
  63. | 'M'::'M'::ns ->
  64. Digit MM :: (toRomanDigitListRec ns)
  65. | 'I'::'V'::ns ->
  66. Digit IV :: (toRomanDigitListRec ns)
  67. | 'I'::'X'::ns ->
  68. Digit IX :: (toRomanDigitListRec ns)
  69. | 'X'::'L'::ns ->
  70. Digit XL :: (toRomanDigitListRec ns)
  71. | 'X'::'C'::ns ->
  72. Digit XC :: (toRomanDigitListRec ns)
  73. | 'C'::'D'::ns ->
  74. Digit CD :: (toRomanDigitListRec ns)
  75. | 'C'::'M'::ns ->
  76. Digit CM :: (toRomanDigitListRec ns)
  77. // 1 letter matches
  78. | 'I'::ns ->
  79. Digit I :: (toRomanDigitListRec ns)
  80. | 'V'::ns ->
  81. Digit V :: (toRomanDigitListRec ns)
  82. | 'X'::ns ->
  83. Digit X :: (toRomanDigitListRec ns)
  84. | 'L'::ns ->
  85. Digit L :: (toRomanDigitListRec ns)
  86. | 'C'::ns ->
  87. Digit C :: (toRomanDigitListRec ns)
  88. | 'D'::ns ->
  89. Digit D :: (toRomanDigitListRec ns)
  90. | 'M'::ns ->
  91. Digit M :: (toRomanDigitListRec ns)
  92. // bad letter matches
  93. | badChar::ns ->
  94. BadChar badChar :: (toRomanDigitListRec ns)
  95. // 0 letter matches
  96. | [] ->
  97. []
  98. let toRomanDigitList (s:string) =
  99. s.ToCharArray()
  100. |> List.ofArray
  101. |> toRomanDigitListRec
  102. /// Convert a string to a RomanNumeral
  103. /// Does not validate the input.E.g. "IVIV" would be valid
  104. let toRomanNumeral s =
  105. toRomanDigitList s
  106. |> List.choose (
  107. function
  108. | Digit digit ->
  109. Some digit
  110. | BadChar ch ->
  111. eprintfn "%c is not a valid character" ch
  112. None
  113. )
  114. |> RomanNumeral
  115. // ==========================================
  116. // Validation logic
  117. // ==========================================
  118. // check for validity
  119. let rec isValidDigitList digitList =
  120. match digitList with
  121. // empty list is valid
  122. | [] -> true
  123. // a following digit that is equal or larger is an error
  124. | d1::d2::_
  125. when d1 <= d2 ->
  126. false
  127. // A single digit is always allowed
  128. | _::ds ->
  129. // check the remainder of the list
  130. isValidDigitList ds
  131. // top level check for validity
  132. let isValid (RomanNumeral digitList) =
  133. isValidDigitList digitList

Comparing the two versions

Which version did you like better? The second one is more longwinded because it has many more cases, but on the other hand, the actual logic is the same or simpler in all areas, with no special cases.
And as a result, the total number of lines of code is about the same for both versions.

Overall, I prefer the second implementation because of the lack of special cases.

As a fun experiment, try writing the same code in C# or your favorite imperative language!

Making it object-oriented

Finally, let’s see how we might make this object oriented. We don’t care about the helper functions, so we probably just need three methods:

  • A static constructor
  • A method to convert to a int
  • A method to convert to a string

And here they are:

  1. type RomanNumeral with
  2. static member FromString s =
  3. toRomanNumeral s
  4. member this.ToInt() =
  5. toInt this
  6. override this.ToString() =
  7. sprintf "%A" this

Note: you can ignore the compiler warning about deprecated overrides.

Let’s use this in an object oriented way now:

  1. let r = RomanNumeral.FromString "XXIV"
  2. let s = r.ToString()
  3. let i = r.ToInt()

Summary

In this post we’ve seen lots and lots of pattern matching!

But again, as with the last post, what’s equally important is that we’ve seen how easy it is to create a properly designed internal model for very trivial domains.
And again, our internal model used no primitive types — there is no excuse not to create lots of little types in order to represent the domain better. For example, the ParsedChar type — would you have bothered to create that in C#?

And as should be clear, the choice of an internal model can make a lot of difference to the complexity of the design. But if and when we do refactor, the compiler will almost always warn us if we have forgotten something.