مقالات › C#

آموزش جامع LINQ در C# با مثال‌های کاربردی

LINQ را در سی‌شارپ کامل یاد بگیرید: فیلتر، Projection، مرتب‌سازی، گروه‌بندی، Join، اجرای تأخیری، تفاوت IEnumerable و IQueryable و تله‌های رایج عملکرد.

LINQ (Language Integrated Query) یکی از قابلیت‌هایی است که نوشتن C# را لذت‌بخش می‌کند. با یک نحو واحد و خوانا می‌توانید روی کالکشن‌های داخل حافظه، دیتابیس (از طریق EF Core)، XML و موارد دیگر کوئری بزنید. در این آموزش عملگرهای روزمره، مفهومی که بیشترین سردرگمی را ایجاد می‌کند — اجرای تأخیری — و تله‌های عملکردی مهم در پروژه‌های واقعی را بررسی می‌کنیم.

دو نحو، یک موتور

LINQ دو شکل نوشتاری دارد که هر دو به یک کد کامپایل می‌شوند:

var numbers = new[] { 5, 12, 8, 21, 3, 17 };

// نحو متدی (رایج‌ترین)
var big = numbers.Where(n => n > 10).OrderBy(n => n);

// نحو کوئری (شبیه SQL)
var big2 = from n in numbers
           where n > 10
           orderby n
           select n;

در کدهای امروزی نحو متدی خیلی بیشتر استفاده می‌شود؛ نحو کوئری برای Joinهای پیچیده و عبارت let خواناتر است. در ادامه از نحو متدی استفاده می‌کنیم.

داده‌ی نمونه‌ی ما:

public record Employee(int Id, string Name, string Department, decimal Salary, DateTime HiredAt);

var employees = new List<Employee>
{
    new(1, "Sara",   "Engineering", 4200, new(2021, 3, 1)),
    new(2, "Reza",   "Engineering", 3800, new(2022, 7, 15)),
    new(3, "Mina",   "Design",      3100, new(2020, 1, 10)),
    new(4, "Ali",    "Engineering", 5100, new(2019, 9, 5)),
    new(5, "Neda",   "Marketing",   2900, new(2023, 2, 20)),
};

فیلتر کردن با Where

var seniorEngineers = employees
    .Where(e => e.Department == "Engineering" && e.HiredAt.Year < 2022);

Where یک Overload با اندیس عنصر هم دارد: .Where((e, i) => i % 2 == 0).

Projection با Select و SelectMany

Select هر عنصر را تبدیل می‌کند:

var names = employees.Select(e => e.Name.ToUpper());

var cards = employees.Select(e => new { e.Name, Years = DateTime.Now.Year - e.HiredAt.Year });

SelectMany کالکشن‌های تودرتو را صاف (Flatten) می‌کند:

var orders = customers.SelectMany(c => c.Orders);           // همه‌ی سفارش‌های همه‌ی مشتری‌ها
var tags = posts.SelectMany(p => p.Tags).Distinct();          // همه‌ی تگ‌های یکتا

مرتب‌سازی

var sorted = employees
    .OrderBy(e => e.Department)
    .ThenByDescending(e => e.Salary);

برای کلیدهای دوم همیشه از ThenBy استفاده کنید. صدا زدن دوباره‌ی OrderBy مرتب‌سازی اول را از بین می‌برد.

عملگرهای انتخاب عنصر

عملگر خروجی وقتی خالی است وقتی چندتاست
First() اولین مورد Exception اولی را برمی‌گرداند
FirstOrDefault() اولین مورد default اولی را برمی‌گرداند
Single() تنها مورد Exception Exception
SingleOrDefault() تنها مورد default Exception
Last() / LastOrDefault() آخرین مورد Exception / default آخری را برمی‌گرداند

وقتی باید دقیقاً یک نتیجه وجود داشته باشد (جستجو با کلید یکتا) از Single استفاده کنید؛ هم منظور را مستند می‌کند و هم باگ داده را زود نشان می‌دهد. از ‎.NET 6 به بعد می‌توانید مقدار پیش‌فرض هم بدهید: FirstOrDefault(defaultValue).

کمّی‌سازها و توابع تجمعی

bool anyDesigners = employees.Any(e => e.Department == "Design");
bool allPaid      = employees.All(e => e.Salary > 0);
int engineers     = employees.Count(e => e.Department == "Engineering");
decimal payroll   = employees.Sum(e => e.Salary);
decimal average   = employees.Average(e => e.Salary);
var topEarner     = employees.MaxBy(e => e.Salary);   // .NET 6+

نکته: برای بررسی خالی نبودن کالکشن از Any() استفاده کنید، نه Count() > 0. Any با اولین عنصر متوقف می‌شود؛ Count ممکن است همه را پیمایش کند (یا روی کل جدول COUNT(*) بزند).

گروه‌بندی

var byDepartment = employees
    .GroupBy(e => e.Department)
    .Select(g => new
    {
        Department = g.Key,
        Headcount = g.Count(),
        AverageSalary = g.Average(e => e.Salary),
        Newest = g.MaxBy(e => e.HiredAt)!.Name
    })
    .OrderByDescending(x => x.Headcount);

foreach (var d in byDepartment)
    Console.WriteLine($"{d.Department}: {d.Headcount} people, avg {d.AverageSalary:N0}");

ToLookup نسخه‌ی فوری (Eager) GroupBy است: ساختاری شبیه دیکشنری می‌سازد که می‌توانید بارها از آن بخوانید؛ مثل lookup["Engineering"].

Join کردن

var departments = new[]
{
    new { Name = "Engineering", Floor = 3 },
    new { Name = "Design",      Floor = 2 },
};

// Inner Join
var withFloor = employees.Join(
    departments,
    e => e.Department,
    d => d.Name,
    (e, d) => new { e.Name, d.Floor });

// Left Join: کارمندان بدون دپارتمان متناظر هم حفظ شوند
var leftJoin =
    from e in employees
    join d in departments on e.Department equals d.Name into gj
    from d in gj.DefaultIfEmpty()
    select new { e.Name, Floor = d?.Floor };

در کوئری‌های EF Core به‌ندرت به Join صریح نیاز دارید: Navigation Propertyها (مثل order.Customer.Name) خودشان Join را تولید می‌کنند.

عملیات مجموعه‌ای و تقسیم‌بندی

var a = new[] { 1, 2, 3, 4 };
var b = new[] { 3, 4, 5 };

a.Union(b);      // 1 2 3 4 5
a.Intersect(b);  // 3 4
a.Except(b);     // 1 2
a.Concat(b);     // 1 2 3 4 3 4 5

employees.DistinctBy(e => e.Department);   // .NET 6+
employees.Skip(10).Take(10);                // صفحه‌بندی
employees.Chunk(100);                       // دسته‌های ۱۰۰تایی (.NET 6+)

اجرای تأخیری؛ مهم‌ترین مفهوم LINQ

بیشتر عملگرهای LINQ هنگام صدا زدن اجرا نمی‌شوند. آن‌ها یک Pipeline می‌سازند که فقط هنگام پیمایش نتیجه اجرا می‌شود:

var list = new List<int> { 1, 2, 3 };
var query = list.Where(n => n > 1);   // هنوز چیزی اجرا نشده

list.Add(4);

foreach (var n in query)               // الان اجرا می‌شود: 2, 3, 4
    Console.WriteLine(n);

این موضوع دو پیامد دارد:

  1. پیمایش دوباره یعنی کار دوباره. دو بار پیمایش یک کوئری، آن را دو بار اجرا می‌کند؛ روی دیتابیس یعنی دو رفت‌وبرگشت.
  2. ممکن است داده بین تعریف و اجرا تغییر کند.

وقتی به یک تصویر ثابت نیاز دارید یا نتایج را بیش از یک بار استفاده می‌کنید، آن را با ToList()، ToArray() یا ToDictionary() مادی‌سازی (Materialize) کنید.

عملگرهایی مثل Count()، First()، Sum() و ToList() فوراً اجرا می‌شوند.

تفاوت IEnumerable و IQueryable

این تفاوت در کار با EF Core حیاتی است:

  • IEnumerable<T> عملگرها را در حافظه و با Delegateهای C# اجرا می‌کند.
  • IQueryable<T> یک Expression Tree می‌سازد که Provider (مثلاً EF Core) آن را به SQL ترجمه می‌کند.
// ✅ فیلتر در SQL Server اجرا می‌شود: WHERE Salary > 4000
var rich = db.Employees.Where(e => e.Salary > 4000).ToList();

// ❌ کل جدول بارگذاری و بعد در حافظه فیلتر می‌شود
IEnumerable<Employee> all = db.Employees;
var rich2 = all.Where(e => e.Salary > 4000).ToList();

کوئری را تا آخرین لحظه IQueryable نگه دارید و فقط وقتی شکل آن کامل شد ToList() را صدا بزنید.

تله‌های عملکردی

  1. ToList() زودهنگام — هر چیزی بعد از آن در حافظه اجرا می‌شود.
  2. پیمایش چندباره‌ی یک IEnumerable پرهزینه — یک بار Materialize کنید.
  3. Count() > 0 به‌جای Any().
  4. کوئری‌های N+1 — پیمایش نتایج و کوئری زدن برای داده‌ی مرتبط داخل حلقه. از Include یا Projection استفاده کنید.
  5. عبارت‌های غیرقابل ترجمه — صدا زدن متدهای C# خودتان داخل Where در EF Core در زمان اجرا خطا می‌دهد، چون SQL نمی‌تواند آن‌ها را اجرا کند.
  6. LINQ در مسیرهای خیلی داغ — برای حلقه‌های فشرده روی آرایه در کدهای حساس به کارایی، یک for ساده می‌تواند سریع‌تر باشد. قبل از بهینه‌سازی اندازه بگیرید.

جمع‌بندی

LINQ یک واژگان ثابت — Where، Select، OrderBy، GroupBy، Join، Any — برای کالکشن‌ها و دیتابیس‌ها فراهم می‌کند. اجرای تأخیری را بفهمید، کوئری‌های EF Core را تا تکمیل شدن IQueryable نگه دارید و آگاهانه Materialize کنید. با این عادت‌ها، کد LINQ شما هم زیبا خواهد بود و هم سریع.

پرسش‌های متداول

آیا LINQ از حلقه‌ها کندتر است؟

برای کالکشن‌های داخل حافظه کمی سربار به‌خاطر Delegateها و Enumeratorها وجود دارد. در بیشتر کدهای بیزینسی در مقایسه با I/O ناچیز است؛ فقط مسیرهای داغِ اثبات‌شده را بهینه کنید.

کِی از نحو کوئری استفاده کنم؟

وقتی کوئری چند Join، متغیر let یا گروه‌بندی دارد، نحو کوئری طبیعی‌تر خوانده می‌شود. ترکیب هر دو سبک کاملاً مشکلی ندارد.

پیغام «could not be translated» در EF Core یعنی چه؟

بخشی از عبارت LINQ معادل SQL ندارد (معمولاً یک متد سفارشی C#). آن را با عملیات قابل ترجمه بازنویسی کنید یا وقتی داده کم شد، آن بخش را بعد از AsEnumerable() قرار دهید.