reiichii’s blog

技術ブログ

yieldとは

はじめに

先日webスクレイピングを実装していた時に初めてyieldを使う機会がありました。

パッとググると概要は出てくるのですが使用例などがなかなかピンと来なかったので、これを機会にきちんと理解しておこうと思い立った次第です。

yieldを知るまでの道のり

今回調べていて、やっぱり説明するのも理解するのも一筋縄では行かないなという印象でした。

なので今回のブログは以下の流れでお送りします。

yieldの動作

ざっくりと説明すると、 returnが値を返してfunctionを終了させるものに対して、 yieldはreturnと同じように値を返した後、そのyieldの続きから処理を続行させることができます。

内部的な動作

↑のような説明は動作の部分を説明しただけなので、"yieldとは"といった目的の部分を説明するのには当てはまりません。(returnの処理を続行させないverという目的で作られたわけではないということ)

yieldはジェネレーターを作っています。 ジェネレータとはリストや辞書などイテレーターの一種です。他の一般的なイテレーターとの違いは「定義された時点で要素数に限りがあるか」だと言えます。

イテレーターは hoge = [1,2,3] のように中身が決まっているもので、このhogeをまとめてfor文に引き渡して回して一つ一つの要素に処理をするのに対して、ジェネレータは定義した時点では要素の中身は決めることができなく、for文で要素に対して処理を行う段階で初めて要素が確定される形です。「遅延評価」などというみたいです。

「定義段階で要素が確定しないリストってなんだよ」と思う人もいるかもしれません。 その一例がスクレイピングしたい対象のwebページだったりします。「そのページのリンクを3階層までクローリングする」といった処理を実行させる時、実行させるまで中身の数は確定しませんよね。

使用例(webスクレイピング実装したときの話)

以下のコードは私がwebスクレイピング入門した時に書いたもので、好きなアーティストのブログをクローリングして、写真があればあるだけローカルPCにDLするものになっています。

class MimorinSpider(scrapy.Spider):
    ''' 略 '''

    def parse(self, response):
        # そのページの画像を取得
        yield self.parse_items(response)

        # 総ページ数を取得
        ''' 略 '''
        # 総ページ分回してurlを生成
        if self.current_page <= int(total_page):
            ''' 略 '''
            next_url = 'https://lineblog.me/mimori_suzuko/?p=' + str(self.current_page)
            # 次のページへのリクエストを実行する
            yield scrapy.Request(next_url, callback=self.parse)


    def parse_items(self, response):
        item = MimorinItem()
        item['image_urls'] = []

        for image in response.css('img.pict::attr(src)').extract():
            item['image_urls'].append(image)

        return item

プログラム的に重要なポイントは二つです。

  • 画像取得: scrapyが実行されたら、そのページのブログの画像を取得して、ImagePipelineに渡す(ImagePipelineの中で渡した画像をローカルに保存したりといった、取得した画像に対する処理を行ってくれるため。超便利だった)
    • → ImagePipelineにitemを渡すために def parse() 内で return item をする必要がある
  • ページ遷移: そのページのスクレイピングが終わったら次のページに進む。進める限り処理を続けるようにする。
    • → 次のページにクローリングを進めるためには、次のページのurlをリクエストするメソッドを入れて def parse() を実行させないといけない。return item で処理を終わらせられない

という要件があります。

ここではyieldは二ヶ所で使われています。

  • yield self.parse_items(response)
    • 一ヶ所はページの画像を取得するメソッドを呼び出すところです。ここでyieldを指定して値を返す処理を入れないと、itemがPipelineに渡されず、画像のローカル保存が行われません。
  • yield scrapy.Request(next_url, callback=self.parse)
    • もう一ヶ所は次のページへのリクエストを実行するscrapy.Request()を実行する部分です。yieldを使うことでスクレイピングを実行する時に呼び出されるdef parseは終わらずに、その中で次のページに対するdef parseが実行され続けています。

以下は完全に余談で、ちなみに私の詰まっていたところ

  • 🤔「(yield self.parse_items(response)に対して)どうしてここにyieldを定義しないといけなかったんだっけ」
    • → yieldがジェネレーターであるというところに意識が行き過ぎてそもそも値をreturnするものであることを忘れていた
  • 🤔「(yield scrapy.Request(next_url, callback=self.parse)に対して、最初yieldを書かないでいて、なんで次のページのスクレイピングが取得されないのかがわからなかった)」
    • → yieldは実行された時点で処理を中断し再開させるもの。yieldを書かなかったコードはscrapy.Request()を実行して処理を正常終了。当然scrapy.Request()で呼ばれた子parse()も親メソッドの終了に合わせて終了する😇

自分の場合は、この部分の意味を理解したことでyieldの使い方や役割についてイメージが湧くようになりました。

その他のユースケースと参考資料

参考にした書籍や記事では以下のようなユースケースで説明されていることが多かったです。

  • メモリの負担を和らげるために使う。一度に大きなリストを返すとメモリを多く消費してしまうため負担がかかる。yieldで小分けに実装することで負担を抑える。
    • → ジェネレーターを生成し、for文でそれを実行させて要素に対して実行させるといった使い方がやっぱり多いらしい。

webスクレイピングのコードを書いている時、当初自分は「ページは限られているのだから一度urlを全ページ分スクレイピングし切った後、リストに入れて全部まとめてImagePipelineに渡せばいい」と考えていたのですが、これはアンチパターンのど真ん中だったわけです...

参考資料:

yieldのまとめ

  • functionの中の処理を中断し、値を返して、また続きから再実行させる ≠ return
  • 素数が未確定のジェネレーターを定義し、都度処理を実行させる

おわりに

本や記事を読んでいても、webスクレイピングをしなければyieldについて今以上にピンときていなかったかもしれません。

またyield fromの書き方についてや、more_itertoolsのようなモジュールについては今回は手を伸ばしきれませんでした。

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

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