C# LINQ Where 寫了卻沒有縮小結果,不一定是條件錯誤。更基本的問題是:你是否保留並消費了它回傳的序列?本文以純記憶體集合說明查詢建立、列舉與具體化的界線,使用完整 C# 測試專案驗證,不接資料庫,也不套用任何既有環境設定。

核心結論:Enumerable.Where 回傳描述篩選工作的序列,不會就地刪除來源元素。 丟棄回傳值,後續仍使用來源集合,就等於沒有採用這次篩選。保留查詢之後,還要明確決定何時列舉,以及是否需要用 ToList 固定當下的結果成員。

為什麼呼叫 Where 後,原集合沒有改變?

Where 的回傳值才代表篩選後的序列,原本集合不會因為這個呼叫而移除元素。若只寫方法呼叫卻繼續讀原集合,條件便沒有進入後續資料流程;修正方式是接住回傳值,再明確列舉或具體化。

來源筆記指出一類容易漏看的錯誤:條件寫得合理,程式也能編譯,但查詢運算子的回傳值沒有被使用。本文只採用這個通用技術觀察;不轉載筆記內的專案名稱、實際資料量或事件結果,更不把它改寫成個人績效。

對記憶體中的 List 呼叫 Where,不會改變清單的元素數量。接住結果的變數可以宣告為 IEnumerable,因為我們需要的是可列舉的查詢,而非假設每個運算子都回傳同一種清單。若變數原本由 var 推斷成 List,直接把 Where 的結果指派回去可能遇到型別不相容;不要為了讓指派成立,就在每一段條件後隨意加上 ToList。

可靠的資料流程應該是「來源 → 條件組合 → 結果消費」。條件可以逐段接到同一個查詢變數上,最終只把這個變數交給顯示、匯出或後續運算。若最後一行又意外改回來源變數,前面所有正確條件仍可能失去作用。因此審查不能只看條件本身,還要沿著變數追到真正的輸出位置。

延遲執行不等於保存當下資料

延遲查詢通常在列舉時才評估條件,因此建立查詢並不代表結果已固定。來源集合或被捕捉的變數在列舉前改變,都可能改變輸出;需要固定結果成員時,應在已選定的邊界執行具體化。

Microsoft 的 Enumerable.Where 文件把這種行為稱為延遲執行。方法會建立可列舉物件,而實際篩選在消費序列時發生。本文的測試先丟棄查詢,再檢查計數器仍為零,證明那個條件函式沒有因為單純建立查詢而跑過。這個計數器只是教學觀察工具,不是建議把商業副作用放進篩選條件。

閉包尤其值得單獨檢查。假設查詢使用 value > threshold,先建立查詢,再把 threshold 改成其他數值,列舉時就會讀取被捕捉變數當時的值。它不是自動保存查詢建立瞬間的門檻。若需要固定門檻,可以在組合前取得清楚命名的區域值並確保後續不再修改;若需要固定結果,則在適當時間完成具體化。

相同查詢也可能被列舉多次。例如先計算數量,再逐筆渲染,就可能再次執行條件。對本文的純資料與無副作用條件,這通常只是重複工作;對會讀取變動狀態的條件,結果則可能不同。不要由一次成功列舉,推論所有後續列舉都完全相同,更不能把此處測試延伸成執行緒安全保證。

ToList 固定成員,但不是深層複製

ToList 會立即列舉來源並建立新清單,能固定該次查詢選中的成員;但若元素是參考型別,新清單仍可指向原物件。需要隔離物件內容時,應明確投影所需欄位,而不是把清單複製當成深層複製。

具體化解決的是時間邊界,不是所有權的所有問題。對整數範例,先把大於門檻的結果轉成清單,再對原來源新增元素,已建立的結果清單不會自動增加那個元素。相反地,重新列舉先前的延遲查詢,便可能看到新增內容。兩者都符合契約,沒有哪一方天然更正確,取決於呼叫端需要即時檢視還是一次性結果。

對可變物件,必須再問一個問題:新清單內裝的是什麼?ToList 建立新的容器,不代表建立每個元素的新物件。測試會檢查兩份清單不是同一個容器,但第一個元素仍是同一個物件;修改來源元素的標籤後,淺層複製也看到新標籤。

本文的修正示範使用 Select 建立新的 Entry,只帶入整數識別值與字串標籤。這能隔離範例的後續屬性指派,但不是通用物件圖複製器;如果實際模型還含有可變的巢狀清單,就必須另行定義複製邊界。本文不取代站內共用快取物件隔離文章,只用這個反例避免把 ToList 描述得過於強大。

驗證與重現:固定 SDK 的三組 C# 測試

在全新本機測試目錄建立下列三個檔案,使用固定版本建置與執行,即可觀察回傳值丟棄、延遲列舉及淺層複製差異。每個檢查都有明確斷言,失敗會拋出例外並產生非零結束碼,不以畫面看似正常判定通過。

前置條件是已安裝 .NET SDK 10.0.400、可寫入的獨立目錄,以及首次還原所需的標準工具鏈能力。目標框架為 net10.0,不引用第三方套件。這是固定的重現基準,不是建議其他專案盲目升級,也不代表它永遠是最新版本。

  1. 建立空目錄,依下列名稱儲存 global.json、LinqLab.csproj 與 Program.cs。不要覆寫既有專案;確認目錄內只有這個測試專案。
  2. 進入該目錄,執行 dotnet --version,必須得到 10.0.400。接著執行 dotnet run --project LinqLab.csproj --configuration Release。若缺少指定 SDK,先補足前置條件,不要把版本錯誤當成語意測試失敗。
  3. 檢查 CHECK1、CHECK2、CHECK3 與 ALL PASS,並檢查結束碼為零。若任一斷言失敗、缺少標記或無法建置,本次驗證就未通過;不可用前一次輸出代替。

global.json:

{"sdk":{"version":"10.0.400","rollForward":"disable"}}

LinqLab.csproj:


  
    Exe
    net10.0
    enable
    enable
  

Program.cs:

using System.Runtime.InteropServices;

static void Check(bool condition, string name)
{
    if (!condition) throw new InvalidOperationException(name);
}

var source = new List { 1, 2, 3, 4 };
var predicateCalls = 0;
source.Where(value => { predicateCalls++; return value % 2 == 0; });
Check(predicateCalls == 0, "discarded query executed unexpectedly");
Check(source.SequenceEqual(new[] { 1, 2, 3, 4 }), "source changed");
IEnumerable filtered = source;
filtered = filtered.Where(value => value % 2 == 0);
var firstResult = filtered.ToArray();
Check(firstResult.SequenceEqual(new[] { 2, 4 }), "filter result mismatch");
Check(source.Count == 4, "filter mutated source");
Console.WriteLine("CHECK1 PASS discardedCalls=0 sourceCount=4 filtered=2,4");

var threshold = 2;
var deferred = source.Where(value => value > threshold);
threshold = 3;
Check(deferred.SequenceEqual(new[] { 4 }), "captured threshold mismatch");
var snapshot = deferred.ToList();
source.Add(5);
Check(deferred.SequenceEqual(new[] { 4, 5 }), "deferred result mismatch");
Check(snapshot.SequenceEqual(new[] { 4 }), "materialized result changed");
Console.WriteLine("CHECK2 PASS deferred=4,5 snapshot=4");

var items = new List { new() { Id = 1, Label = "before" } };
var shallow = items.ToList();
Check(!ReferenceEquals(items, shallow), "list was not copied");
Check(ReferenceEquals(items[0], shallow[0]), "element reference changed");
var projected = items.Select(item => new Entry { Id = item.Id, Label = item.Label }).ToList();
items[0].Label = "after";
Check(shallow[0].Label == "after", "shallow reference mismatch");
Check(projected[0].Label == "before", "projection isolation mismatch");
var empty = source.Where(value => value > 100).ToArray();
Check(empty.Length == 0, "empty selection mismatch");
Console.WriteLine("CHECK3 PASS shallow=after projected=before emptyCount=0");
Console.WriteLine($"ALL PASS runtime={RuntimeInformation.FrameworkDescription}");

sealed class Entry
{
    public int Id { get; init; }
    public string Label { get; set; } = "";
}

完整執行指令,工作目錄須是上述三個檔案所在位置:

dotnet --version
dotnet run --project LinqLab.csproj --configuration Release

排程環境已實際執行。 使用 SDK 10.0.400,程式回報 runtime .NET 10.0.11,結束碼為零,真實輸出如下。這些值是人工建立的小型測試資料,不是效能量測、資料庫筆數或個人工作成果。

CHECK1 PASS discardedCalls=0 sourceCount=4 filtered=2,4
CHECK2 PASS deferred=4,5 snapshot=4
CHECK3 PASS shallow=after projected=before emptyCount=0
ALL PASS runtime=.NET 10.0.11

第一組確認丟棄查詢時條件呼叫數為零,來源仍有四個整數,而接住查詢並列舉後得到二與四。第二組確認修改門檻與新增來源元素會影響再次列舉,但不會改寫已具體化的整數清單。第三組確認清單複製共享元素、明確投影隔離此範例的屬性變更,並加入無符合項目的空結果檢查。

查詢審查要看輸出契約,不只看語法

審查重點應是查詢結果是否一路傳到消費端,以及列舉時間是否符合需求,而不是僅確認條件能編譯。用少量、可辨認身分的資料建立預期結果,才能抓出回傳值丟棄、來源變更與空結果處理的問題。

建議每次修正先列出三種輸入:部分符合、完全不符合,以及所有項目都符合。只測所有項目符合的案例,很容易讓「根本沒採用條件」的錯誤蒙混過關。斷言應比較實際成員或識別值;單看數量不夠,因為兩份錯誤資料也可能恰好一樣長。若輸出順序也是契約的一部分,再另加排序條件與順序斷言。

同時記錄查詢的擁有者:哪一層負責組合條件、哪一層允許列舉、哪一層決定具體化。避免讓呼叫端只拿到含糊的變數名稱,不知道那是持續反映來源的查詢,還是已固定的結果清單。也不要以反覆具體化掩蓋流程不清楚;這會增加中間集合,卻未必修正最後消費錯變數的問題。

  • 回傳值: 每次條件組合是否保留新查詢?
  • 消費端: 最終輸出是否使用正確的查詢變數?
  • 時間點: 捕捉值與來源內容是否可能在列舉前改變?
  • 物件邊界: 新清單內是否仍共享可變元素?
  • 失敗條件: 空結果及不相符成員是否能被測試清楚辨識?

適用範圍與官方依據

本文只驗證 Enumerable 對記憶體集合的行為,不把結果直接外推成資料庫提供者的查詢轉譯能力。來源筆記提供問題線索,官方文件提供方法契約,實際測試則證明固定工具鏈與指定資料下的可觀察結果。

若操作對象是 IQueryable,條件可能成為運算式樹並交給提供者處理,查詢是否能轉譯、何時存取資料、使用哪些排序規則,都需要另行驗證。本文沒有連接資料庫,也沒有宣稱任何 SQL 產生方式或效能提升。因此它與站內 EF Core 查詢效能、nullable Expression 轉譯文章屬於不同的主要搜尋意圖。

同樣地,「LINQ 不會就地修改來源」在本文是對 Where 的篩選行為所作的精確描述,不應擴大為「所有 LINQ 條件都沒有副作用」。開發者仍能在委派中自行修改外部狀態;這正是需要避免或明確測試的事情。來源筆記中的簡寫觀念,必須經過這類範圍限定才能成為可靠的技術說明。

官方文件:

下一步: 先在測試專案執行三組斷言,再檢查自己的 Where 呼叫是否接住回傳值。把「查詢有寫」提升為「結果已驗證」,比只補上一個方法呼叫更能防止相同錯誤再次出現。