14.5. roman.py,阶段 5

既然 fromRoman 能够正确处理有效的输入,现在是时候解决最后一个难题了:让它能够正确处理无效的输入。这意味着要找到一种方法来检查一个字符串并确定它是否是一个有效的罗马数字。这本质上比在 toRoman验证数字输入更难,但您有一个强大的工具可以使用:正则表达式。

如果您不熟悉正则表达式,并且没有阅读第 7 章,正则表达式,现在是阅读的好时机。

正如您在第 7.3 节,“案例研究:罗马数字”中所见,使用字母 MDCLXVI 构建罗马数字有几个简单的规则。让我们回顾一下这些规则:

  1. 字符是累加的。I1II2III3VI6(字面意思是“51”),VII7VIII8
  2. 十位字符(IXCM)最多可以重复三次。到 4 时,您需要从下一个最高的五位字符中减去。您不能将 4 表示为 IIII;相反,它表示为 IV(“51”)。40 写作 XL(“5010”),41 写作 XLI42 写作 XLII43 写作 XLIII,然后 44 写作 XLIV(“5010,然后比 51”)。
  3. 同样,在 9 时,您需要从下一个最高的十位字符中减去:8VIII,但 9IX(“101”),而不是 VIIII(因为 I 字符不能重复四次)。90XC900CM
  4. 五位字符不能重复。10 总是表示为 X,从不表示为 VV100 总是 C,从不表示为 LL
  5. 罗马数字总是从高位写到低位,从左到右读取,因此字符的顺序非常重要。DC600CD 是一个完全不同的数字(400,“500100”)。CI101IC 甚至不是一个有效的罗马数字(因为您不能直接从 100 中减去 1;您需要将其写成 XCIX,“10010,然后比 101”)。

示例 14.12. roman5.py

此文件位于示例目录的 py/roman/stage5/ 中。

如果您还没有这样做,您可以下载本书中使用的此示例和其他示例

"""Convert to and from Roman numerals"""
import re

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
romanNumeralMap = (('M',  1000),
                   ('CM', 900),
                   ('D',  500),
                   ('CD', 400),
                   ('C',  100),
                   ('XC', 90),
                   ('L',  50),
                   ('XL', 40),
                   ('X',  10),
                   ('IX', 9),
                   ('V',  5),
                   ('IV', 4),
                   ('I',  1))

def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n < 4000):
        raise OutOfRangeError, "number out of range (must be 1..3999)"
    if int(n) <> n:
        raise NotIntegerError, "non-integers can not be converted"

    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result

#Define pattern to detect valid Roman numerals
romanNumeralPattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' 1

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not re.search(romanNumeralPattern, s):                                    2
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s

    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
1 这只是您在第 7.3 节,“案例研究:罗马数字”中讨论的模式的延续。十位是 XC (90)、XL (40),或者是一个可选的 L 后跟 0 到 3 个可选的 X 字符。个位是 IX (9)、IV (4),或者是一个可选的 V 后跟 0 到 3 个可选的 I 字符。
2 将所有这些逻辑编码到正则表达式中后,检查无效罗马数字的代码就变得微不足道了。如果 re.search 返回一个对象,则表示正则表达式匹配,输入有效;否则,输入无效。

在这一点上,您可能会怀疑这个又大又丑的正则表达式是否真的能捕捉到所有类型的无效罗马数字。但不要相信我的话,看看结果吧:

示例 14.13. romantest5.py 针对 roman5.py 的输出


fromRoman should only accept uppercase input ... ok          1
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... ok      2
fromRoman should fail with repeated pairs of numerals ... ok 3
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 2.864s

OK                                                           4
1 关于正则表达式,我没有提到的一件事是,默认情况下,它们区分大小写。由于正则表达式 romanNumeralPattern 是用大写字符表示的,因此 re.search 检查将拒绝任何不是完全大写的输入。所以大写输入测试通过了。
2 更重要的是,无效输入测试也通过了。例如,格式错误的前置测试检查了像 MCMC 这样的情况。正如您所见,这不符合正则表达式,因此 fromRoman 引发了一个 InvalidRomanNumeralError 异常,这正是格式错误的前置测试用例所期望的,因此测试通过了。
3 事实上,所有无效输入测试都通过了。这个正则表达式捕捉到了你在编写测试用例时可能想到的所有情况。
4 而今年的“反高潮奖”则颁给了 unittest 模块在所有测试都通过时打印的“OK”字样。
Note
当所有测试都通过时,停止编码。