このブログを検索

第10章 リファクタリング

10.1. 没頭しよう

好むと好まざるに関わらず、バグは発生します。網羅的なユニットテストを書いたとしても、バグは発生します。ここでの"バグ"とは何でしょうか?それは、まだ書いていないテストケースのことなのです。
>>> import roman7
>>> roman7.from_roman('') # ①
0
①これはバグです。ローマ数字として有効でない他の文字シーケンスと同様に、空の文字はInvalidRomanNumeralError例外を上げるべきなのです。

バグを再現して修正する前に、バグを説明できるような、失敗するテストケースを書きます。
class FromRomanBadInput(unittest.TestCase):
    .
    .
    .
    def testBlank(self):
        '''from_roman should fail with blank string'''
        self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, '') # ①
①やっていることはとてもシンプルです。from_roman()を空の文字列で呼び出して、InvalidRomanNumeralError例外が出ることを確認しています。

バグを見つけることは、より困難です。今はバグがわかっていますから、かんたんな方、つまりテストをしましょう。コードにバグがあって、バグをテストするこのテストケースを実行すると、失敗します。
you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... FAIL
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

======================================================================
FAIL: from_roman should fail with blank string
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest8.py", line 117, in test_blank
    self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, '')
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 11 tests in 0.171s

FAILED (failures=1)
この段階になってから、バグを修正します。

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not s:                                                                  # ①
        raise InvalidRomanNumeralError('Input can not be blank')
    if not re.search(romanNumeralPattern, s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {}'.format(s))  # ②

    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
①必要なコードは2行だけです。文字列が空かどうかの確認と、raise文です。

②この内容はまだ話していませんね。文字列フォーマットの最終レッスンです。Python3.1以降では、フォーマット指定子の位置インデックスを省略できるようになりました。つまり、format()メソッドの最初のパラメータを参照するときは、フォーマット指定子を{0}としなくても、{}と書くだけでPythonが適切な位置インデックスを当てはめてくれます。どの番号の引数に対しても有効で、最初の{}が{0}、2番目の{}が{1}・・・となります。

you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... ok  # ①
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 11 tests in 0.156s

OK  # ②
①空白文字列のテストケースがパスしたので、バグは修正できました。

②他のすべてのテストケースもパスしています。つまり、バグの修正によって他のものが壊れてはいないということです。コーディング終了です。。

このようなコーディングがバグの最も簡単な修正方法です。(この例のような)シンプルなバグでは、シンプルなテストケースが必要です。複雑なバグではテストケースも複雑です。TDD環境では、バグの修正のほうが時間がかかるように思えます。というのは、(テストケースを書くために)バグが何であるかを正確に把握してからバグを修正しないといけないからです。テストケースがすぐにパスしないなら、バグの修正ができていないのか、テストケース自体にバグがあるかを特定する必要があります。しかし、長い目で見ると、このようにテストコードとコードを行きつ戻りつすることには意義があります。この方法によって、バグがはじめから正しく修正されるようになるからです。また、新しいものと合わせてテストケースをすべて再実行することは簡単ですから、新しいコードを修正しているときに古いコードを破壊してしまうことは少なくなります。今日のユニットテストは明日の回帰テストです。
(訳注: 回帰テスト・・・ソフトウェアの変更後に正常に動作するかをテストする方法)

10.2. 変更要求に対応する

最大限の努力をして顧客をピンで留めて、追加の要求をしないようにハサミやHotWaxといった恐ろしい不快なもので苦痛を与えたとしても、要求は変わってしまいます。多くの顧客は、実物を見るまで欲しいものがわかっておらず、見たとしても、用途に足るものを正確に把握することが得意ではありません。十分なものが何かわかったとしても、次のリリースではより多くのものを、むやみに要求してきます。そういうわけで、要求変更によるテストケースのアップデートに備えておきましょう。

仮に、ローマ数字を変換する関数の範囲を拡張することになったとします。通常はローマ数字の文字は連続して3回までしか繰り返されません。ところが、ローマ人が例外ルールを作って4文字のMで4000を表すようになったとします。この変更があれば、数字が変換できる範囲を1..3999から1..4999に拡張することができます。まずは、テストケースを変更する必要があります。
# roman8.py
class KnownValues(unittest.TestCase):
    known_values = ( (1, 'I'),
                      .
                      .
                      .
                     (3999, 'MMMCMXCIX'),
                     (4000, 'MMMM'),                                      # ①
                     (4500, 'MMMMD'),
                     (4888, 'MMMMDCCCLXXXVIII'),
                     (4999, 'MMMMCMXCIX') )

class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman8.OutOfRangeError, roman8.to_roman, 5000)  # ②

    .
    .
    .

class FromRomanBadInput(unittest.TestCase):
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):     # ③
            self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, s)

    .
    .
    .

class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 5000):                                    # ④
            numeral = roman8.to_roman(integer)
            result = roman8.from_roman(numeral)
            self.assertEqual(integer, result)
①これまで知られている値は変わりません(これらはすべて、テストに通る値のままです)。しかし、4000台では少し追加する必要があります。ここで4000(最も短い)、4500(2番目に短い)、4888(最も長い)、4999(最も大きい)を追加しました。

②"大きすぎる入力"の定義が変わりました。このテストでは、to_roman ()を4000で呼び出すとエラーになると想定していましたが、今は4000-4999は正しい値なので、5000までに引き上げなければなりません。

③"多すぎる数字の繰り返し"の定義も変わりました。これはfrom_roman ()を'MMMM'で呼び出すとエラーになることを想定していました。今ではMMMMはローマ数字として成立することになったため、この定義を'MMMMM'に引き上げなければなりません。

④範囲内のすべての数字をサニティチェックするために1から3999をループしていましたが、4999までループするように更新します。

これでテストケースは新しい要求に合わせて更新されましたが、コードはまだ更新されていないので、いくつかのテストケースは失敗するだろうと考えられます。

you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ERROR          # ①
to_roman should give known result with known input ... ERROR            # ②
from_roman(to_roman(n))==n for all n ... ERROR                          # ③
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

======================================================================
ERROR: from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 82, in test_from_roman_known_values
    result = roman9.from_roman(numeral)
  File "C:\home\diveintopython3\examples\roman9.py", line 60, in from_roman
    raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
roman9.InvalidRomanNumeralError: Invalid Roman numeral: MMMM

======================================================================
ERROR: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 76, in test_to_roman_known_values
    result = roman9.to_roman(integer)
  File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
    raise OutOfRangeError('number out of range (must be 0..3999)')
roman9.OutOfRangeError: number out of range (must be 0..3999)

======================================================================
ERROR: from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 131, in testSanity
    numeral = roman9.to_roman(integer)
  File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
    raise OutOfRangeError('number out of range (must be 0..3999)')
roman9.OutOfRangeError: number out of range (must be 0..3999)

----------------------------------------------------------------------
Ran 12 tests in 0.171s

FAILED (errors=3)
①from roman ()の既知の値のテストは'MMMM'になると即座に失敗します。今でもfrom_roman()ではこの値は不正なローマ数字としているからです。

②to_roman()の既知の値のテストは4000になると失敗します。to_roman()ではこの値を範囲外であるとしているからです。

③往復でチェックするとやはり4000で失敗します。to_roman()はこの値を範囲外としているからです。

新しい要求の影響でテストケースはこのように失敗するようになりました。テストケースに合うようにコードの修正することを検討しましょう。(ユニットテストにはじめて着手するときは、テストされるコードがテストケースよりも"前に"行くことが全くないことを奇妙に思うかもしれません。後ろにある間はやることが残っていて、テストケースに追いついたらすぐにコーディングをストップします。慣れてきたらテストなしでどうやってプログラムするのか、と考えるようになります)。

# roman9.py
roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,4}              # thousands - 0 to 4 Ms  # ①
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    ''', re.VERBOSE)

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not isinstance(n, int):
        raise NotIntegerError('non-integers can not be converted')
    if not (0 < n < 5000):                        # ②
        raise OutOfRangeError('number out of range (must be 1..4999)')

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result

def from_roman(s):
    .
    .
    .
①from_roman()関数では変更は全く必要ありません。唯一変えたのはroman_numeral_patternです。よく見てみると、最初の部分のオプションMの最大値を3から4に変えています。これによってローマ数字は3999までではなく4999までになりました。実際のfrom_roman ()関数は完全にジェネリックです。つまり、繰り返されているローマ数字文字を探して、何度繰り返されているかは気にせずに単純に足していきます。これまで"MMMM"を扱わなかった理由は1つで、正規表現パターンマッチングで明示的に止めていたからです。

②to_roman()関数は、範囲確認の部分で1つだけ小さな変更が必要です。これまでは0<n<4000を確認していましたが、これからは0<n<5000 を確認します。さらに、エラーメッセージも新しく適用範囲を反映して変更します(1..3999に代えて1..4999)。関数の残りの部分には何も変更する必要がありません。新しい条件も最初から扱うことができるのです。
(1000を見つける度に喜んで'M'を追加します。例えば4000なら、'MMMM'を出力します。これまでそうならなかったのは、範囲確認で明示的に止めていたからです)。

この2つの小さな変更だけで必要なものが揃っているのか、疑いたくなるかもしれません。
では、私の言葉を信じないで、自分で見てみることにしましょう。

you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.203s

OK  # ①
①テストケースはすべてパスしました。コーディング終了です。網羅的なユニットテストの持つ意味は、プログラマの「信じて」という言莱に頼る必要がない、ということです。

10.3. リファクタリング

網羅的なユニットテストの醍醐味は、テストケースがすべてパスしたときの達成感ではありませんし、他の人にコードを壊したと責められたときに違うと証明できたときの感情でもありません。ユニットテストの醍醐味は、無慈悲にリファクタリングする自由があることです。

リファクタリングは、動作しているコードがよりよく動くように改善することです。たいていの『よりよい』という言葉は『より速い』という意味ですが、『メモリ消費を抑える』、『ディスク使用を抑える』、または単純に『よりエレガントに』という意味にもなります。あなたにとって、プロジェクトにとって、環境にとって、どんな意味であっても、リファクタリングはどのプログラムにとっても長期的に使うためには重要なことです。

今は、『よりよい』というのは、『速い』と『メンテナンスしやすい』を意味しています。具体的に言うと、from_roman()関数は私の想定よりも遅く複雑なものになっています。大きくて扱いにくい正規表現を使ってローマ数字を判定しているからです。そこであなたはこう考えるかもしれません。「確かに、正規表現は大きくて面倒くさいものだけど、他の方法でどうやって任意の文字列をローマ数字として判定するのだろう?」

答え:ローマ数字は5000までしかありません。lookupテーブルを作ったらどうでしょうか?

正規表現を使わないでよいとわかったら、このアイデアはさらによくなります。
lookupテーブルを作って整数をローマ数字に置き換えると、ローマ数字を整数に置き換えられる逆のlookupテーブルを作ることができます。任意の文字列がローマ数字として有効か確認する必要が出てくるまでに、すべての有効なローマ数字を集められるでしょう。『判定』が1つの辞書のlookupに短縮されたのです。

嬉しいことに、完成したユニットテストのセットはすでに用意できています。モジュール内のコードの半分に渡って変更してきましたが、ユニットテストは変わりません。つまり、新しいコードは元のコードと同じように動くことを、自分自身や他の人に証明することができるのです。

# roman10.py
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
class InvalidRomanNumeralError(ValueError): pass

roman_numeral_map = (('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))

to_roman_table = [ None ]
from_roman_table = {}

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]

def build_lookup_tables():
    def to_roman(n):
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)
        to_roman_table.append(roman_numeral)
        from_roman_table[roman_numeral] = integer

build_lookup_tables()
消化できる量に分けていきましょう。間違いなく、最も重要な行は最後の1行です。
build_lookup_tables()
関数のコールはあるのがわかりますが、if文は周りにありません。if __name__ == '__main__'ブロックではなく、モジュールがインポートされるときに呼び出されるのです。
(モジュールは1度だけインポートされ、キャッシュされるということは重要なので理解しておくべきです。すでにインポートしたモジュールを再度インポートしようとすると、何もしません。つまり、このコードはこのモジュールを最初にインポートしたときにだけ、呼び出されるのです。)

ではbuild_lookup_tables ()は何をするのでしょうか?聞いてくれて嬉しいです。
to_roman_table = [ None ]
from_roman_table = {}
.
.
.
def build_lookup_tables():
    def to_roman(n):                                # ①
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)          # ②
        to_roman_table.append(roman_numeral)       # ③
        from_roman_table[roman_numeral] = integer
①これはプログラミングの賢い部分です...賢すぎるかもしれません。to_roman()関数は既に定義されていて、lookupテーブルを調べて値を返します。しかし、このbuild_lookup_tables()関数の中で、to_roman()関数を再定義しています(loopupテーブルを追加する前に、例でやったものと同じです)。build_loopup_tables()内でto_roman()関数を呼び出すと、こちらの再定義されたバージョンを呼ぶことになります。build_loopup_tables()から抜けると、再定義バージョンは無くなります。build_loopup_tables()関数のローカルスコープだけで定義されているからです。

②この行のコードは再定義されたto_roman()関数を呼び出して、実際にローマ数字を計算します。

③(再定義されたto_roman()関数から)結果が出てから・・・整数とそれに対応するローマ数字を共にloopupテーブルに追加します。

loopupテーブルを作ったあとは、残りのコードはかんたんです。

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]                                            # ①

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]                                          # ②
①先ほどと同じように範囲の境界を確認をしたあとに、to_roman ()関数は単純に適切な値をlookupテーブルから探して返します。

②同様に、from_rowan()関数は境界の確認と1行のコードを減らしています。これで正規表現はなくなりました。ループもなくなりました。このローマ数字の変換のオーダーはO(1)です。

動作するでしょうか?はい、動きます。証明してみせましょう。

you@localhost:~/diveintopython3/examples$ python3 romantest10.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.031s                                                  # ①

OK
①意識していませんでしたが、これは速いです!おおむね10倍の速さです。もちろん、完全に公平な比較とは言えません。このバージョンは(loopupテーブルを作る)インポートに時間がかかるからです。しかし、インポートが1度終わってしまえば、開始のコストはro_roman()、from_roman()関数で償却されることになります。テストは何千回も関数を呼び出しをしますので(往復のテストだけで10,000です)、この節約がすぐに積み上がります!

教訓は何でしょうか?
  • シンプルさは美徳
  • 特に正規表現が含まれるとき
  • ユニットテストは、大きなリファクタリングをする自信を与えてくれる

10.4. まとめ

ユニットテストは強力なコンセプトで、適切に実装されたならば、どんなに長い期間のプロジェクトに対してもメンテナンスのコストを減らしたり、柔軟性を増すことができるものです。一方、ユニットテストが万能薬ではないこと、魔法の問題ソルバではないこと、銀の弾丸ではないことを理解しておくことも重要です。適切なテストケースを書くことは難しく、日々アップデートすることには訓練が必要になります(特に、顧客が致命的なバグ修正を熱望しているとき)。ユニットテストは他の形式のテスト、例えば機能テスト、結合テスト、ユーザ受け入れテストの代わりではありませんが、柔軟性があって、十分な効果があります。効果を理解したら、どうやってユニットテストなしでやっていくのか、と思うようになるでしょう。

ここまでの章で、基礎的なことを多くカバーしてきましたが、その大部分はPythonに限った話ではありません。多くの言語にユニットテストのフレームワークがありますが、すべてに通じる基本コンセプトがあります。

  • 設計するテストケースは具体的で、自動化されていて、独立していること
  • テストするコードを書く前にテストケースを書くこと
  • 正しい入力をテストして適切な結果を確認するテストを書くこと
  • 間違った入力をテストして適切な失敗した結果を確認するテストを書くこと
  • 新しい要求を反映してテストを書きアップデートすること
  • 無慈悲にリファクタリングを実行して性能、スケーラビリティ、可読性、メンテナンス性、その他の性能を改良すること

0 件のコメント:

コメントを投稿