Levelwise
فارسی
پایه‌های C# و .NET

LINQ و Collections

کوئری LINQ تا وقتی کسی نتیجه را نخواهد اجرا نمی‌شود. روی IQueryable کوئری به SQL ترجمه می‌شود و در دیتابیس اجرا می‌شود، روی IEnumerable در حافظه برنامه. collection را بر اساس کاری انتخاب کن که بیشتر با آن انجام می‌دهی.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۴ دقیقهمثال کاتالوگ محصولات فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: صفحه‌ای که یک میلیون ردیف می‌خواند

فروشگاه ما یک میلیون محصول دارد. صفحه «محصولات یک دسته» کند است و حافظه سرور بالا می‌رود. کد این است:

public IEnumerable<Product> GetAll() => db.Products;

// somewhere else
var phones = repository.GetAll()
    .Where(p => p.CategoryId == categoryId)
    .ToList();

کد درست به نظر می‌رسد. شرط Where هست. پس چرا کل جدول خوانده می‌شود؟ جواب در دو ایده است: اجرای تأخیری و فرق IEnumerable با IQueryable.

اجرای تأخیری (Deferred Execution)

یک کوئری LINQ مثل دستور پخت است، نه خود غذا. وقتی Where و Select را می‌نویسی، فقط دستور را می‌نویسی. هیچ کاری انجام نمی‌شود.

productsلیست محصولاتWhereفقط ارزان‌هاSelectفقط اسمToList / foreachاینجا اجرا شروع می‌شود۱. «یکی بده» از آخر به اول می‌رود۲. هر محصول یکی‌یکی از همه مرحله‌ها رد می‌شودتا کسی نتیجه را نخواهد، هیچ کاری انجام نمی‌شود
متد ToList یا حلقه foreach از آخر زنجیر «یکی بده» می‌خواهد. هر محصول یکی‌یکی از همه مرحله‌ها رد می‌شود.

کوئری فقط وقتی اجرا می‌شود که کسی نتیجه را بخواهد:

  • حلقه foreach روی آن.
  • متدهایی مثل ToList، ToArray، ToDictionary.
  • متدهایی که یک مقدار برمی‌گردانند، مثل Count، First، Any، Sum.
var prices = new List<decimal> { 900, 5 };

var cheap = prices.Where(p => p < 100); // nothing runs here

prices.Add(8);

Console.WriteLine(cheap.Count()); // 2: the query runs now and sees the new price

خطر: اجرای چندباره

چون کوئری فقط یک دستور است، هر بار که نتیجه را بخواهی، دوباره از اول اجرا می‌شود.

var newProducts = db.Products.Where(p => p.CreatedAt > lastWeek);

if (newProducts.Any())                 // query 1 to the database
{
    foreach (var p in newProducts)     // query 2 to the database
        Notify(p);
}

اینجا دو بار به دیتابیس می‌رویم. اگر بین این دو، محصولی اضافه شود، نتیجه دو کوئری هم با هم فرق دارد. راه درست: یک بار با ToListAsync نتیجه را بگیر و بعد با همان لیست کار کن.

IEnumerable یا IQueryable؟

این دو اینترفیس شبیه هم هستند، ولی فرق بزرگی دارند:

  1. نوع IEnumerable کوئری را با کد C# اجرا می‌کند. شرط Where یک متد معمولی است. داده باید اول در حافظه برنامه باشد.
  2. نوع IQueryable کوئری را به شکل یک درخت (Expression Tree) نگه می‌دارد. EF Core این درخت را می‌خواند و آن را به SQL ترجمه می‌کند. شرط در خود دیتابیس اجرا می‌شود.
IQueryableبرنامه.Where(p => p.CategoryId == 7)دیتابیسWHERE CategoryId = 7کوئری به زبان دیتابیس ترجمه می‌شود۲۰ ردیف برمی‌گرددIEnumerableبرنامهفیلتر در حافظهدیتابیسSELECT * FROM Productsبدون شرطیک میلیون ردیف برمی‌گردد، بعد ۲۰ تا می‌ماند
همان شرط Where، ولی دو نتیجه کاملاً متفاوت.

حالا کد اول درس را دوباره ببین. متد GetAll نوع IEnumerable برمی‌گرداند. پس قدم به قدم:

  1. متد Where که بعد از آن صدا زده می‌شود، Where مخصوص IEnumerable است، نه IQueryable.
  2. این Where نمی‌تواند به SQL ترجمه شود. پس EF Core کوئری بدون شرط می‌فرستد.
  3. یک میلیون ردیف از شبکه می‌آید و یک میلیون شیء در حافظه ساخته می‌شود.
  4. بعد، در حافظه، همه به جز چند صد ردیف دور ریخته می‌شوند.

همین اتفاق با متد AsEnumerable هم می‌افتد. هر چیزی بعد از آن، در حافظه اجرا می‌شود.

کد درست

شرط و انتخاب ستون‌ها را قبل از اجرای کوئری بنویس. فقط ستون‌هایی را بخوان که لازم داری:

public sealed record ProductRow(int Id, string Name, decimal Price);

public Task<List<ProductRow>> GetByCategoryAsync(int categoryId, CancellationToken ct) =>
    db.Products
        .Where(p => p.CategoryId == categoryId)
        .OrderBy(p => p.Price)
        .Select(p => new ProductRow(p.Id, p.Name, p.Price))
        .Take(50)
        .ToListAsync(ct);
هر کد C# به SQL ترجمه نمی‌شود. اگر داخل Where یک متد معمولی خودت را صدا بزنی، EF Core نمی‌تواند آن را به SQL تبدیل کند و خطا می‌دهد. راه حل این نیست که با AsEnumerable همه را به حافظه بیاوری. شرط را با چیزهایی بنویس که دیتابیس می‌فهمد.

انتخاب collection درست

حالا داده در حافظه است. کدام collection را انتخاب کنیم؟ سؤال اصلی این است: بیشتر چه کاری با آن انجام می‌دهی؟

List.Contains("P-907")P-101P-233P-318P-450P-512P-777P-850P-907هشت مقایسه. با یک میلیون محصول، تا یک میلیون مقایسهHashSet.Contains("P-907")hash("P-907") → 501234567یک پرشتعداد محصولات هر چه باشد، تقریباً یک قدم
متد Contains در List همه را یکی‌یکی چک می‌کند. در HashSet، با hash مستقیم به جای درست می‌رود.
نوع کار اصلی جستجو مثال در فروشگاه
List ترتیب مهم است، دسترسی با شماره یکی‌یکی (کند برای داده زیاد) ردیف‌های سبد خرید
Dictionary پیدا کردن با کلید تقریباً یک قدم محصول با شناسه
HashSet فقط «هست یا نیست؟»، بدون تکرار تقریباً یک قدم شناسه محصولات تمام‌شده
Queue اول آمده، اول می‌رود ندارد صف سفارش‌ها برای پردازش
SortedDictionary کلیدها همیشه مرتب سریع، ولی کندتر از Dictionary قیمت‌ها به ترتیب
FrozenDictionary یک بار ساخته می‌شود، فقط خوانده می‌شود خیلی سریع تنظیمات و جدول هزینه ارسال
ConcurrentDictionary چند thread با هم می‌نویسند تقریباً یک قدم کش در یک Singleton

یک مثال واقعی. می‌خواهیم از ۱۰ هزار ردیف سفارش، آن‌هایی را پیدا کنیم که کالایشان تمام شده است:

// Slow: for each line, Contains checks the whole list
List<int> outOfStock = await LoadOutOfStockIdsAsync(ct);
var blocked = lines.Where(l => outOfStock.Contains(l.ProductId)).ToList();

// Fast: one hash lookup for each line
HashSet<int> outOfStockSet = [.. await LoadOutOfStockIdsAsync(ct)];
var blocked2 = lines.Where(l => outOfStockSet.Contains(l.ProductId)).ToList();

با ۱۰ هزار ردیف و ۱۰ هزار شناسه، نسخه اول تا صد میلیون مقایسه دارد. نسخه دوم حدود ۱۰ هزار.

نوع برگشتی متدها

  • برای ورودی متد، کلی‌ترین نوع را بگیر. اگر فقط روی داده حلقه می‌زنی، IEnumerable کافی است.
  • برای خروجی متد، نوع فقط‌خواندنی برگردان. مثل IReadOnlyList یا IReadOnlyCollection. صدازننده می‌داند داده کامل در حافظه است و می‌تواند تعدادش را بدون اجرای دوباره بگیرد.
  • هیچ وقت از Repository نوع IEnumerable روی یک DbSet برنگردان. همان مشکل اول درس.

چند متد مفید

  • متد Any به جای مقایسه Count با صفر. متد Any با اولین مورد متوقف می‌شود. روی دیتابیس هم کوئری سبک‌تری می‌سازد.
  • متد ToDictionary یا ToLookup برای جستجوی زیاد. اگر در یک حلقه بارها با Where یا First دنبال یک مورد می‌گردی، اول یک Dictionary بساز.
  • متد CountBy از .NET 9. شمردن بر اساس یک کلید، بدون ساختن گروه کامل.
// How many orders does each customer have?
foreach (var (customerId, count) in orders.CountBy(o => o.CustomerId))
    Console.WriteLine($"{customerId}: {count}");

قانون‌های مهم

  1. کوئری LINQ یک دستور است، نه نتیجه. تا کسی نتیجه را نخواهد، اجرا نمی‌شود.
  2. نتیجه را یک بار بگیر. اگر بیش از یک بار از نتیجه استفاده می‌کنی، اول ToList.
  3. روی دیتابیس، شرط، Select و Take را قبل از اجرا بنویس. تا همه این‌ها در SQL باشند.
  4. مرز IQueryable را بشناس. بعد از AsEnumerable یا بعد از برگرداندن IEnumerable، همه چیز در حافظه است.
  5. برای جستجوی زیاد، List نه. از Dictionary یا HashSet استفاده کن.
  6. در کد پرفشار، LINQ هزینه دارد. هر lambda و هر مرحله allocation کوچکی دارد. در کد عادی مهم نیست. در کد پرفشار اندازه بگیر.

اشتباه‌های رایج

اشتباه نتیجه راه درست
برگرداندن IEnumerable از روی DbSet کل جدول به حافظه می‌آید. شرط روی IQueryable، بعد اجرا.
صدا زدن AsEnumerable قبل از Where فیلتر در حافظه، نه در دیتابیس. اول Where، بعد اجرا.
استفاده چندباره از یک کوئری چند کوئری به دیتابیس، شاید با نتیجه‌های متفاوت. یک بار ToList.
متد Contains روی List بزرگ در حلقه زمان به توان دو رشد می‌کند. HashSet یا Dictionary.
مقایسه Count با صفر به جای Any شمردن همه، فقط برای یک بله یا نه. متد Any.
خواندن همه ستون‌ها برای یک لیست ساده داده اضافه روی شبکه و در حافظه. انتخاب ستون‌ها با Select.

خلاصه در شش خط

  1. کوئری LINQ تا وقتی کسی نتیجه را نخواهد اجرا نمی‌شود.
  2. هر بار که نتیجه را بخواهی، کوئری از اول اجرا می‌شود.
  3. روی IQueryable، کوئری به SQL ترجمه می‌شود. روی IEnumerable، در حافظه اجرا می‌شود.
  4. شرط و انتخاب ستون‌ها را قبل از اجرا و روی IQueryable بنویس.
  5. برای جستجو با کلید یا «هست یا نیست؟»، Dictionary و HashSet را انتخاب کن، نه List.
  6. خروجی متدها را از نوع فقط‌خواندنی برگردان.