天坑,這樣一個lambda隨機取資料也有Bug

楊中科發表於2022-12-07

前幾天,一位網友跟我說他編寫的一段很簡單的程式碼遇到了奇怪的Bug,他要達到的效果是從一個List中隨機取出來一條資料,程式碼如下:

1 var random = new Random();
2 var users = Enumerable.Range(0, 10).Select(p => new User(p, "A" + p)).ToList();
3 var user = users.Find(p => p.Id == random.Next(0, 10));
4 Debug.Assert(user != null); 
5 
6 record User(int Id,string Name);

第2行程式碼生成了一個包含10個User物件的List,這些User的Id值從0遞增到9;第3行程式碼中呼叫List的Find方法來根據lambda表示式來查詢一條資料,這裡透過random.Next()來獲取一個[0,10)之間的隨機數,然後用這個隨機數來和Id進行比較。按照邏輯來講,Find一定可以找到一條資料,所以在第4行程式碼中斷言user一定不為null。但是這段程式碼有的時候執行正常,有的時候則會斷言失敗,從而程式丟擲異常,令人不解。

當然,他的這段程式碼寫的過於複雜,其實改成users[random.Next(0, 10)]就簡單又高效。但是為了揭示問題的本質,我這裡繼續分析為什麼用Find+lambda方法會出現問題。

我們檢視一下Find方法的原始碼,如下:

public T? Find(Predicate<T> match)
{
    for (int i = 0; i < _size; i++)
    {
        if (match(_items[i]))//注意這裡
        {
            return _items[i];
        }
    }
    return default;
}

Find方法的邏輯很簡單,就是遍歷List中的資料,對於每條資料都呼叫match這個委託來判斷當前這條資料是否滿足條件,如果找到一條滿足條件的資料,就把它返回。如果走到最後都沒有找到,就返回預設值(比如null)。這個邏輯簡單到貌似看不到任何問題。

問題的關鍵就在if (match(_items[i]))這一句程式碼。它是在每一次迴圈都呼叫一下match的委託來判斷當前資料的匹配性。而match指向的委託的方法體是p => p.Id == random.Next(0, 10),也就是每次匹配判斷都要獲取一個新的隨機數來進行比較。假設在迴圈的時候生成的10個隨機數為:9,8,8,7,9,1,1,2,3,4,那麼就會每次match(_items[i])判斷的結果都為false,從而導致最後返回null,也就是找不到任何的資料。

明白了原理之後,解決這個問題的思路就是不要在lambda中生成待比較的隨機數,而是提前生成隨機數,程式碼如下:

int randId = random.Next(0, 10);
var user = users.Find(p => p.Id == randId);

同樣的原理也適用於Single()、Where()等LINQ操作。在這些操作中也要避免在lambda表示式中再進行復雜的計算,這樣不僅可以避免類似這篇文章中提到的bug,而且可以提升程式的執行效率。

歡迎閱讀我編寫的《ASP.NET Core技術內幕與專案實戰》,這本書的宗旨就是“講微軟文件中沒有的內容,講原理、講實踐、講架構”。具體見右邊公告。

相關文章