3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

LINQのSelectは「遅延」するので使い方に気をつけるべし

Last updated at Posted at 2021-01-18

LINQのSelect関数は、配列などのIEnumerable<T>オブジェクトの要素一つ一つについて、指定した処理を施した結果を別のIEnumerable<T>として返してくれる。

using System.Collections.Generic;
using System.Linq;

void Func() {
    var list = new List<int>{1, 2, 3, 4, 5};

    foreach (var item in list.Select(i => i * i)) {
        System.Console.WriteLine(item); // 1, 4, 9, 16, 25 が1行ずつで表示される
    }
}

見落としがちなのが、このSelectで施そうとする処理は、実際に結果となるイテレート可能なオブジェクトが利用されるまで、実行が遅れるということである。

void Func() {
    var list = new List<int>{1, 2, 3, 4, 5};

    var sqList = list.Select(i => i * i); // この時点で、i * i は「計算されていない」
}

この例だと、Selectで作ったものはただの数値なのでSelectの実行が遅れてもさほど問題にはならないだろう。
問題になるのは、要素からクラスを作ろうとする時だ。

バグにつながる使い方

この例はUnity3D (UniTask) での利用をあげているが、そのほかのasyncのやり方でも同じことが考えられる。

あるクラスをnewするときに重い処理が必要なので、コンストラクタからasyncコンテクストでその重い処理を行わせる指示だけをしておいて、準備ができたらメンバ変数のフラグをtrueにすることを考えよう。そして、そのクラスを別の配列の値からSelectを経由して作り、全てのクラスインスタンスから「準備完了」が返ってくるのを待つ処理を考える。

インターネット経由で指定したURLにある大きなデータを取ってくるという目的で、上記のような実装を考えるとこういったものになるだろう:

public class FetchBigData {
    private bool _isReadyToRead = false;
    public bool IsReadyToRead { get {
            return _isReadyToRead;
        } 
    };
    
    private object _data;
    public object Data { get {
            return _data;
        }
    };

    public FetchBigData(string url) {
        FetchURL(url).Forget();
    }

    private async UniTask FetchURL(string url) {
        _data = await ...... // ダウンロード処理
        _isReadyToRead = true;
    }
}

そして、このクラスを複数のURLで作ってこのように処理完了を待つことを考える。

void LoadManyGoneWrong() {
    var urls = new List<string>{"url1", "url2", "url3"};
    var fetchBigDataList = urls.Select(url => new FetchBigData(url)); // Uh oh

    // NG!!!
    await UniTask.WaitUntil(() => fetchBigDataList.All(i => i.IsReadyToLoad)); // 条件がfalseの間await
    foreach (var data in fetchBigDataList.Select(i => i.Data)) {
        // Do something about data
    }
}

残念ながら、この実装は await でソフトロックする。そればかりか、おそらくサーバーに大量のリクエストが飛んで怒られる羽目になる。

その理由は、Selectの実行が遅れることにある。fetchBigDataListを作ったSelectでは、URLをFetchBigDataのコンストラクタに流してFetchBigDataの配列を作ることになっている。この処理の実行が遅れているのだ。つまり、この時点でFetchBigDataは作成されていない

遅れた処理は、それによって出来上がる要素が必要とされるまで実行されない。WaitUntilを評価しようとしてAllを実行して初めてSelectが作る配列の中身が必要になるため、Selectの中身が実行されてFetchBigDataが作成され、コンストラクタで呼んだダウンロード処理が発生する。

ちなみに、UniTask.WaitUntilはそれに与えた条件がtrueになるまで、その条件を毎フレーム評価し続ける。ということは、Alltrueを返すまで、Allは呼ばれ続ける。
都合の悪いことに、これは**Selectの中身まで毎フレーム呼ばれることを意味する**。つまりawaitに実行が進んだ時点で、大量のFetchBigDataが作られ続け、その時点でのダウンロード完了状態 == false が確認され、いつまで経っても実行が進まなくなる ばかりか FetchBigDataからのリクエストが飛び続ける事故が発生する。

回避方法

ではどうすればいいのかというと、Allを打つ前にSelectが作る配列の中身が必要な処理を実行してやれば良いのである。例えばTolistだ。

void CorrectlyLoadMany() {
    var urls = new List<string>{"url1", "url2", "url3"};
    var fetchBigDataList = urls.Select(url => new FetchBigData(url)).ToList(); // ToList()を打ち、強制的にSelectを実行させる

    await UniTask.WaitUntil(() => fetchBigDataList.All(i => i.IsReadyToLoad)); // 条件がfalseの間await
    foreach (var data in fetchBigDataList.Select(i => i.Data)) {
        // Do something about data
    }
}

このようにすれば、最初のSelectで生成したFetchBigDataがその後も使い続けられ、無事に正しい数のリクエストが飛ぶこととなる。

Select以外も遅延する

今回はSelectを使ってハマったが、コメントでの指摘の通り、Selectに限らずIEnumerable<T>が戻り値の型になっているLINQメソッドには同じような遅延実行が起こる(e.g. Where GroupBy Take Skip)。実は公式ドキュメントの注釈にも書かれている。

3
5
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?