日々のあれこれφ(..)

もっぱら壁打ち

if文だけで書いていたvalidationをクラス設計し直した過程

GWはpythonの勉強がてら麻雀の点数計算を行うスクリプトを書いています。

input()のバリデーション部分の実装で、私は最初フローチャートをそのままコードに落とし込んだようなものを書いていたのですが、知り合いからのツッコミでvalidation用のクラスを設計することにしました。その忘備録です。ちなみに言語はPython 3.6.8。

リファクタ前(抜粋)

class Yaku():

    def input_yaku_validate(self, str):
        end_num = self.list.index(self.list[-1])

        if str.count(',') == 0:
            if int(str) > end_num:
                print('有効な数字ではありません')
                sys.exit()
            else:
                hands = [int(str)]
        else:
            if re.search(r'([0-9]{1,2},)*\d', str) == False:
                print('選択肢の数字と「,」以外は含めないでください')
                sys.exit()
            else:
                hands = []
                for n in str.split(','):
                    if int(n) > end_num:
                        print('有効な数字ではありません: ' + n)
                        sys.exit()
                    hands.append(int(n))
        
        return hands

class Mahjang():
    def calculator(self):
        yaku_name_list = [ str(i) + '.' + j for i, j in enumerate(yaku.yaku_name_list()) ]
        h = input('複数ある場合は「,」で区切ってください ex)1,3,5\n' + ' '.join(yaku_name_list) + '\n: ')
        hands = yaku.input_yaku_validate(h)

上記の処理の内容は以下のようになっています。

  • コンソール上に役一覧を数字付きで表示させて、ユーザーが数字で役を入力する

  • 一つの時は「1」二つ以上の時は「1,2」の形を受け入れる

  • その入力値をinput_yaku_validate()でvalidationする

内容がそれほど多くないこともあって、フローチャートのままif文を駆使して処理していました。 ただif文とfor文が入れ子になっていて汚いですし、他のinputでregexの処理をしたいときなどまた改めて re.search()などで定義しなければ行けなくなってしまいます。

リファクタ後

class ValidationStrategy():
    @abstractmethod
    def validate(self, str):
        raise NotImplementedError()

class Validator():
    @abstractmethod
    def validate(self, input):
        raise NotImplementedError()

class YakuValidationStrategy(ValidationStrategy):

    def validate(self, str):
        regex = r'[0-9]{1,2}' if len(str) == 1 else r'([0-9]{1,2},)*\d'
        max_num = len(Yaku.list)
        validators = [
            RegexValidator(regex),
            NumMaxValidator(max_num)
        ]

        for validator in validators:
            result = validator.validate(str)
            if result != True:
                raise ValueError(result)

class RegexValidator(Validator):
    def __init__(self, regex):
        self.regex = regex
    
    def validate(self, str):
        if re.search(self.regex, str) == False:
            return '適切な値を入力してください'

        return True

class NumMaxValidator(Validator):
    def __init__(self, max_num):
        self.max_num = max_num

    def validate(self, str):
        for i in str.split(','):
            if int(i) >= self.max_num:
                return '適切な値を入力してください'
        
        return True

class Mahjang():
    def calculator(self):

        yaku_name_list = [ str(i) + '.' + j for i, j in enumerate(yaku.yaku_name_list()) ]
        h = input('複数ある場合は「,」で区切ってください ex)1,3,5\n' + ' '.join(yaku_name_list) + '\n: ')
        try:
            YakuValidationStrategy().validate(h)
        except ValueError as e:
            print(e)
            sys.exit()      
        hands = [int(i) for i in h.split(',')]

新たに以下のような役割を持つクラスを作りました。

  • YakuValidationStrategy: 役入力用のStrategyクラス。役以外にもDoraValidationStrategyUserAttrValidationStrategy など入力に応じて〜ValidationStrategyというクラスを作りました。

  • ValidationStrategy: 〜ValidationStrategyの親クラス。必ず validateメソッドを定義させるようにします。

  • RegexValidator: 正規表現で入力された文字列がマッチするかを調べるvalidateを行うクラス。

  • NumMaxValidator: 入力された数字が役一覧の数字であることを確認するvalidateを行うクラス。例えば0.ツモ〜46.国士無双十三面待ちまである中で47などない数字が入力されていないかをチェックします。

  • Validator: 渡された値をチェックして返す系の処理を行う〜Validatorクラスの親クラスです。こちらもvalidateメソッドを必ず実装の強制を定義しています。

クラスは大きく分けると二種類、

  • 実際にvalidationを行う内容を定義した〇〇Validatorクラス

  • inputごとになんのどのvalidationを行わせるか(validatorクラスを呼び出すか)ロジックを管理する〇〇ValidationStrategyクラス

です。

どちらもvalidateメソッドを持っていて最初は紛らわしく見えるかもしれませんが、これらのクラスの扱い方としてはinputごとに〇〇ValidationStrategyクラスを作成し↓、

class YakuValidationStrategy(ValidationStrategy):

    def validate(self, str): 
        regex = r'[0-9]{1,2}' if len(str) == 1 else r'([0-9]{1,2},)*\d' 
        max_num = len(Yaku.list)
        validators = [    # ※1
            RegexValidator(regex), # ※2
            NumMaxValidator(max_num)
        ]

        for validator in validators:
            result = validator.validate(str)
            if result != True:
                raise ValueError(result)

この〇〇ValidationStrategyクラスの中でチェックする項目のvalidationを行うクラスを定義して(※1)、順番にvalidateを実行していきます。

class RegexValidator(Validator):
    def __init__(self, regex):
        self.regex = regex
    
    def validate(self, str):
        if re.search(self.regex, str) == False:
            return '適切な値を入力してください'

        return True

regexのようなものはinputによってre.search()の第一引数に入れる正規表現を変えられるようにすべきですが、その部分は〇〇VlidationStrategyクラスの中でValidatorインスタンスを呼び出すときに渡すことで実現可能です。(※2)

validationを行う時は、〇〇ValidationStrategyクラスのvalidate()を呼び出せば、Strategyクラスの中で定義したvalidationが順次行われ、True以外のもの(今回の場合はエラーメッセージ)が返ってきたらエラーを発生させて処理を終わらせられます。

このようにクラス化することで以下のようなメリットが得られるようになりました。

  • validateを行う処理の使い回しが効く

  • あるinputのvalidationロジックを変えても他の箇所に変更が影響しない

  • mainの処理にvalidateの処理を長々と書かなくて良くなった

  • ValidatorのvalidateメソッドはTrueかエラーメッセージが返ってくると決まっているので改修が必要になっても付け加えやすい

まとめ

オブジェクト指向については本を読んだことがあって概要は知っていましたし、FWを使った開発の経験もあったので既存のソースコードを読んで改修することなどはできます。しかしクラス設計等を自分でやったことはなかったので、デザインパターンを読んでいまいちピンと来なかった実装方法を自分の中に落とし込むことができて良かったです。それにオブジェクト指向を使った開発の便利さをまた別の形で実感することができました。

今回の点数計算スクリプトの全体は以下です。※未完成

github.com

ちなみにこのお遊びスクリプト、一応形はできているもののまだまだ完成には程遠いです。 そもそも府計算を切り捨てている時点で正確な実戦で使える正確な点数は出ないのですが、それ以前にまだ七対子や数え役満に対応させないといけません。実用的なものを作りたいわけじゃないので完璧さは追い求めませんが、最低限上に書いたような改良をしていくにはそろそろテストコードがないと動作確認がめんどくさくなってきている。。