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

もっぱら壁打ち

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のようなモジュールについては今回は手を伸ばしきれませんでした。