فصل ۹: Joining، Ordering، Grouping، Set، Conversion و Aggregation Operators
فصل ۹: Joining، Ordering، Grouping، Set، Conversion و Aggregation Operators
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
راهحل درست برای افزودن فیلتر قیمت به left outer join این است که بند Where را با یک subquery پیش از DefaultIfEmpty قرار دهیم:
from c in dbContext.Customers
from p in c.Purchases.Where (p => p.Price > 1000).DefaultIfEmpty()
select new {
c.Name,
Descript = p == null ? null : p.Description,
Price = p == null ? (decimal?) null : p.Price
};
EF Core این الگو را به left outer join ترجمه میکند و این شیوه برای چنین پرسوجوهایی مؤثر است.
Joining
| روش | شرح | معادل SQL |
| Join | با یک راهبرد lookup عناصر دو collection را تطبیق میدهد و result set تخت منتشر میکند. | INNER JOIN |
| GroupJoin | شبیه Join است، اما result set سلسلهمراتبی میدهد. | INNER JOIN، LEFT OUTER JOIN |
| Zip | دو دنباله را همقدم، مثل زیپ، پیمایش میکند و تابعی را روی هر جفت عنصر اعمال میکند. | Exception thrown |
Join و GroupJoin
IEnumerable<TOuter>, IEnumerable<TInner> → IEnumerable<TResult>
آرگومانهای Join| آرگومان | نوع |
|---|
| Outer sequence | IEnumerable<TOuter> |
| Inner sequence | IEnumerable<TInner> |
| Outer key selector | TOuter => TKey |
| Inner key selector | TInner => TKey |
| Result selector | (TOuter,TInner) => TResult |
آرگومانهای GroupJoin| آرگومان | نوع |
|---|
| Outer sequence | IEnumerable<TOuter> |
| Inner sequence | IEnumerable<TInner> |
| Outer key selector | TOuter => TKey |
| Inner key selector | TInner => TKey |
| Result selector | (TOuter,IEnumerable<TInner>) => TResult |
Query syntax:
from outer-var in outer-enumerable
join inner-var in inner-enumerable on outer-key-expr equals inner-key-expr
[ into identifier ]
مرور کلی
Join و GroupJoin دو دنبالهٔ ورودی را در یک دنبالهٔ خروجی درهم میآمیزند. Join خروجی تخت و GroupJoin خروجی سلسلهمراتبی میدهد.
این دو اپراتور راهبردی جایگزین برای Select و SelectMany هستند. مزیتشان در collectionهای محلیِ داخل حافظه این است که ابتدا inner sequence را در یک keyed lookup بار میکنند و از پیمایش مکرر همهٔ inner elementها جلوگیری میکنند. عیبشان این است که فقط معادل inner join و left outer join را ارائه میکنند؛ cross join و non-equi join همچنان باید با Select/SelectMany انجام شوند. در queryهای EF Core، Join و GroupJoin مزیت واقعی نسبت به Select و SelectMany ندارند.
جدول ۹-۱ — راهبردهای Joining| راهبرد | شکل نتیجه | کارایی query محلی | Inner join | Left outer join | Cross join | Non-equi join |
| Select + SelectMany | تخت | ضعیف | بله | بله | بله | بله |
| Select + Select | تودرتو | ضعیف | بله | بله | بله | بله |
| Join | تخت | خوب | بله | — | — | — |
| GroupJoin | تودرتو | خوب | بله | بله | — | — |
| GroupJoin + SelectMany | تخت | خوب | بله | بله | — | — |
Join
اپراتور Join یک inner join انجام میدهد و دنبالهٔ خروجی تخت منتشر میکند. پرسوجوی زیر بدون navigation property همهٔ مشتریان را کنار خریدهایشان فهرست میکند:
IQueryable<string> query =
from c in dbContext.Customers
join p in dbContext.Purchases on c.ID equals p.CustomerID
select c.Name + " bought a " + p.Description;
نتایج با query مبتنی بر SelectMany برابر است:
Tom bought a Bike
Tom bought a Holiday
Dick bought a Phone
Harry bought a Car
برای دیدن مزیت Join نسبت به SelectMany باید آن را به query محلی تبدیل کنیم؛ ابتدا مشتریان و خریدها را در آرایه کپی میکنیم:
Customer[] customers = dbContext.Customers.ToArray();
Purchase[] purchases = dbContext.Purchases.ToArray();
var slowQuery = from c in customers
from p in purchases where c.ID == p.CustomerID
select c.Name + " bought a " + p.Description;
var fastQuery = from c in customers
join p in purchases on c.ID equals p.CustomerID
select c.Name + " bought a " + p.Description;
هر دو نتیجه یکساناند، اما query با Join بهمراتب سریعتر است، چون پیادهسازی Enumerable inner collection یعنی purchases را از قبل در keyed lookup بار میکند.
قالب کلی join در query syntax:
join inner-var in inner-sequence on outer-key-expr equals inner-key-expr
در LINQ میان outer و inner sequence تمایز وجود دارد: outer همان input sequence است (اینجا customers) و inner collection جدید معرفیشده است (اینجا purchases). Join inner join است؛ پس مشتری بدون خرید حذف میشود. در inner join میتوانید جای inner/outer را عوض کنید و همان نتیجه را بگیرید:
from p in purchases
join c in customers on p.CustomerID equals c.ID
...
میتوانید joinهای بیشتری به همان query اضافه کنید. اگر هر purchase یک یا چند purchase item داشته باشد:
from c in customers
join p in purchases on c.ID equals p.CustomerID
join pi in purchaseItems on p.ID equals pi.PurchaseID
...
purchases در join اول inner و در join دوم outer است. همان نتیجه را بهشکل ناکارآمد با foreachهای تودرتو میتوان نوشت:
foreach (Customer c in customers)
foreach (Purchase p in purchases)
if (c.ID == p.CustomerID)
foreach (PurchaseItem pi in purchaseItems)
if (p.ID == pi.PurchaseID)
Console.WriteLine (c.Name + "," + p.Price + "," + pi.Detail);
در query syntax، متغیرهای joinهای قبلی همچنان در scope هستند؛ درست مانند queryهای مبتنی بر SelectMany. همچنین میتوانید بین joinها where و let قرار دهید.
Joining روی چند کلید
با anonymous type میتوان روی چند کلید join کرد:
from x in sequenceX
join y in sequenceY on new { K1 = x.Prop1, K2 = x.Prop2 }
equals new { K1 = y.Prop3, K2 = y.Prop4 }
...
برای کارکرد صحیح، دو anonymous type باید ساختار کاملاً یکسان داشته باشند. کامپایلر هر دو را با یک نوع داخلی واحد پیادهسازی میکند و کلیدهای join سازگار میشوند.
Joining در fluent syntax
join زیر:
from c in customers
join p in purchases on c.ID equals p.CustomerID
select new { c.Name, p.Description, p.Price };
در fluent syntax چنین است:
customers.Join (
purchases,
c => c.ID,
p => p.CustomerID,
(c, p) => new { c.Name, p.Description, p.Price }
);
result selector انتهایی هر عنصر دنبالهٔ خروجی را میسازد. اگر پیش از projection بندهای بیشتری مانند orderby داشته باشید:
from c in customers
join p in purchases on c.ID equals p.CustomerID
orderby p.Price
select c.Name + " bought a " + p.Description;
در fluent syntax باید در result selector یک anonymous type موقت بسازید تا هر دو c و p بعد از join در scope بمانند:
customers.Join (
purchases,
c => c.ID,
p => p.CustomerID,
(c, p) => new { c, p } )
.OrderBy (x => x.p.Price)
.Select (x => x.c.Name + " bought a " + x.p.Description);
برای joinها معمولاً query syntax ترجیحپذیر است، چون دردسر کمتری دارد.
GroupJoin
GroupJoin همان کار Join را انجام میدهد، اما بهجای نتیجهٔ تخت، خروجی سلسلهمراتبی گروهبندیشده بر اساس هر outer element میدهد و left outer join را هم ممکن میکند. در متن کتاب، GroupJoin در EF Core پشتیبانی نمیشود.
query syntax آن مانند Join است، اما بعدش into میآید:
Customer[] customers = dbContext.Customers.ToArray();
Purchase[] purchases = dbContext.Purchases.ToArray();
IEnumerable<IEnumerable<Purchase>> query =
from c in customers
join p in purchases on c.ID equals p.CustomerID
into custPurchases
select custPurchases;
نتیجه دنبالهای از دنبالههاست:
foreach (IEnumerable<Purchase> purchaseSequence in query)
foreach (Purchase p in purchaseSequence)
Console.WriteLine (p.Description);
این خروجی بهتنهایی چندان مفید نیست چون purchaseSequence ارجاعی به customer ندارد. معمولاً اینطور مینویسید:
from c in customers
join p in purchases on c.ID equals p.CustomerID
into custPurchases
select new { CustName = c.Name, custPurchases };
این همان نتیجهٔ Select subquery زیر را میدهد، با این تفاوت که آن نسخه برای دادهٔ محلی ناکارآمد است:
from c in customers
select new
{
CustName = c.Name,
custPurchases = purchases.Where (p => c.ID == p.CustomerID)
};
بهطور پیشفرض GroupJoin معادل left outer join است. برای inner join باید روی custPurchases فیلتر کنید:
from c in customers join p in purchases on c.ID equals p.CustomerID
into custPurchases
where custPurchases.Any()
select ...
بندهای پس از group-join into روی زیردنبالههای child elementها کار میکنند، نه روی child element منفرد. برای فیلتر خریدهای منفرد، Where را پیش از joining اجرا کنید:
from c in customers
join p in purchases.Where (p2 => p2.Price > 1000)
on c.ID equals p.CustomerID
into custPurchases ...
queryهای لامبدا با GroupJoin را مانند Join میتوان ساخت.
Flat outer joins
اگر هم outer join و هم result set تخت بخواهید، GroupJoin outer join میدهد و Join خروجی تخت. راهحل: ابتدا GroupJoin، سپس DefaultIfEmpty روی هر child sequence و در آخر SelectMany:
from c in customers
join p in purchases on c.ID equals p.CustomerID into custPurchases
from cp in custPurchases.DefaultIfEmpty()
select new
{
CustName = c.Name,
Price = cp == null ? (decimal?) null : cp.Price
};
DefaultIfEmpty اگر زیرمجموعهٔ خریدها خالی باشد دنبالهای با یک null منتشر میکند. from دوم به SelectMany ترجمه میشود.
در این نقش، SelectMany همهٔ زیردنبالههای purchase را گسترش و تخت کرده و در یک دنبالهٔ واحد از purchase elementها به هم متصل میکند.
Joining با lookupها
متدهای Join و GroupJoin در Enumerable دو مرحله دارند: ابتدا inner sequence را در lookup بار میکنند و سپس outer sequence را همراه lookup پرسوجو میکنند.
lookup دنبالهای از groupingهاست که با کلید مستقیماً قابل دسترسی است؛ میتوان آن را «دیکشنریِ دنبالهها» یا multidictionary دانست، چون زیر هر کلید چند عنصر میپذیرد. lookup فقطخواندنی است:
public interface ILookup<TKey,TElement> :
IEnumerable<IGrouping<TKey,TElement>>, IEnumerable
{
int Count { get; }
bool Contains (TKey key);
IEnumerable<TElement> this [TKey key] { get; }
}
برای collectionهای محلی میتوانید بهجای join operatorها lookup را دستی بسازید و پرسوجو کنید. دو مزیت: میتوان lookup را در چند query یا کد imperative عادی دوباره استفاده کرد؛ و پرسوجوی lookup راه خوبی برای فهم کار Join/GroupJoin است.
ToLookup یک lookup میسازد. همهٔ خریدها را با کلید CustomerID بار میکنیم:
ILookup<int?,Purchase> purchLookup =
purchases.ToLookup (p => p.CustomerID, p => p);
آرگومان اول کلید و آرگومان دوم شیءهایی را که بهعنوان value در lookup قرار میگیرند انتخاب میکند. indexer lookup برخلاف dictionary یک دنباله از موارد تطبیقیافته میدهد:
foreach (Purchase p in purchLookup [1])
Console.WriteLine (p.Description);
با lookup میتوانید queryهای SelectMany/Select را با کارایی Join/GroupJoin اجرا کنید. Join معادل SelectMany روی lookup است:
from c in customers
from p in purchLookup [c.ID]
select new { c.Name, p.Description, p.Price };
// Tom Bike 500
// Tom Holiday 2000
// Dick Bike 600
// Dick Phone 300
// ...
افزودن DefaultIfEmpty آن را outer join میکند:
from c in customers
from p in purchLookup [c.ID].DefaultIfEmpty()
select new {
c.Name,
Descript = p == null ? null : p.Description,
Price = p == null ? (decimal?) null : p.Price
};
GroupJoin معادل خواندن lookup داخل projection است:
from c in customers
select new {
CustName = c.Name,
CustPurchases = purchLookup [c.ID]
};
پیادهسازی Enumerable.Join
public static IEnumerable <TResult> Join
<TOuter,TInner,TKey,TResult> (
this IEnumerable <TOuter> outer,
IEnumerable <TInner> inner,
Func <TOuter,TKey> outerKeySelector,
Func <TInner,TKey> innerKeySelector,
Func <TOuter,TInner,TResult> resultSelector)
{
ILookup <TKey, TInner> lookup = inner.ToLookup (innerKeySelector);
return
from outerItem in outer
from innerItem in lookup [outerKeySelector (outerItem)]
select resultSelector (outerItem, innerItem);
}
پیادهسازی GroupJoin شبیه Join ولی سادهتر است:
public static IEnumerable <TResult> GroupJoin
<TOuter,TInner,TKey,TResult> (
this IEnumerable <TOuter> outer,
IEnumerable <TInner> inner,
Func <TOuter,TKey> outerKeySelector,
Func <TInner,TKey> innerKeySelector,
Func <TOuter,IEnumerable<TInner>,TResult> resultSelector)
{
ILookup <TKey, TInner> lookup = inner.ToLookup (innerKeySelector);
return
from outerItem in outer
select resultSelector
(outerItem, lookup [outerKeySelector (outerItem)]);
}
اپراتور Zip
IEnumerable<TFirst>, IEnumerable<TSecond> → IEnumerable<TResult>. دو دنباله را همقدم پیمایش میکند و تابعی را روی هر جفت عنصر اعمال میکند:
int[] numbers = { 3, 5, 7 };
string[] words = { "three", "five", "seven", "ignored" };
IEnumerable<string> zip = numbers.Zip (words, (n, w) => n + "=" + w);
// 3=three
// 5=five
// 7=seven
عناصر اضافه در هر یک از دنبالهها نادیده گرفته میشوند. Zip در EF Core پشتیبانی نمیشود.
Ordering
IEnumerable<TSource> → IOrderedEnumerable<TSource>
| روش | شرح | معادل SQL |
|---|
| OrderBy, ThenBy | مرتبسازی صعودی | ORDER BY ... |
| OrderByDescending, ThenByDescending | مرتبسازی نزولی | ORDER BY ... DESC |
| Reverse | بازگرداندن دنباله در ترتیب معکوس | Exception thrown |
اپراتورهای ordering همان عناصر را با ترتیب متفاوت برمیگردانند.
OrderBy، OrderByDescending، ThenBy، ThenByDescending
| آرگومان OrderBy/Descending | نوع |
|---|
| Input sequence | IEnumerable<TSource> |
| Key selector | TSource => TKey |
| Return type | IOrderedEnumerable<TSource> |
| آرگومان ThenBy/Descending | نوع |
|---|
| Input sequence | IOrderedEnumerable<TSource> |
| Key selector | TSource => TKey |
| Query syntax | orderby expression1 [descending] [, expression2 [descending] ... ] |
OrderBy با استفاده از keySelector نسخهای مرتب از ورودی میدهد. مرتبسازی الفبایی:
IEnumerable<string> query = names.OrderBy (s => s);
مرتبسازی بر اساس طول:
IEnumerable<string> query = names.OrderBy (s => s.Length);
// { "Jay", "Tom", "Mary", "Dick", "Harry" }
ترتیب نسبی عناصری که کلید مرتبسازی یکسان دارند نامعین است، مگر ThenBy اضافه کنید:
IEnumerable<string> query = names.OrderBy (s => s.Length).ThenBy (s => s);
// { "Jay", "Tom", "Dick", "Mary", "Harry" }
ThenBy فقط عناصر دارای کلید برابر در مرتبسازی قبلی را مرتب میکند. میتوان هر تعداد ThenBy زنجیره کرد:
names.OrderBy (s => s.Length).ThenBy (s => s[1]).ThenBy (s => s[0]);
معادل query syntax:
from s in names
orderby s.Length, s[1], s[0]
select s;
from s in names
orderby s.Length
orderby s[1]
...
OrderByDescending و ThenByDescending همان کار را در جهت معکوس انجام میدهند. query زیر خریدها را بر اساس قیمت نزولی و موارد همقیمت را الفبایی میچیند:
dbContext.Purchases.OrderByDescending (p => p.Price)
.ThenBy (p => p.Description);
در query syntax:
from p in dbContext.Purchases
orderby p.Price descending, p.Description
select p;
Comparerها و Collationها
در query محلی، خود objectهای key selector الگوریتم ترتیب را از طریق پیادهسازی پیشفرض IComparable تعیین میکنند. با IComparer میتوانید الگوریتم را جایگزین کنید:
names.OrderBy (n => n, StringComparer.CurrentCultureIgnoreCase);
دادن comparer در query syntax یا EF Core پشتیبانی نمیشود. در پایگاه داده، collation ستون الگوریتم مقایسه را تعیین میکند. اگر collation حساس به بزرگی/کوچکی حروف باشد، میتوانید با ToUpper sort غیرحساس درخواست کنید:
from p in dbContext.Purchases
orderby p.Description.ToUpper()
select p;
IOrderedEnumerable و IOrderedQueryable
اپراتورهای ordering subtype ویژهای از IEnumerable<T> برمیگردانند: در Enumerable نوع IOrderedEnumerable<TSource> و در Queryable نوع IOrderedQueryable<TSource>. این subtypeها باعث میشوند ThenBy ترتیب موجود را refine کند، نه جایگزین.
اعضای اضافی این subtypeها بهصورت عمومی دیده نمیشوند، بنابراین مانند دنبالهٔ عادی به نظر میرسند، اما هنگام ساخت progressive query تفاوت نوع مهم میشود:
IOrderedEnumerable<string> query1 = names.OrderBy (s => s.Length);
IOrderedEnumerable<string> query2 = query1.ThenBy (s => s);
اگر query1 را IEnumerable<string> اعلام کنیم، خط دوم compile نمیشود چون ThenBy ورودی IOrderedEnumerable<string> میخواهد. با var میتوان این نگرانی را کم کرد:
var query1 = names.OrderBy (s => s.Length);
var query2 = query1.ThenBy (s => s);
اما implicit typing مشکل دیگری میسازد:
var query = names.OrderBy (s => s.Length);
query = query.Where (n => n.Length > 3); // Compile-time error
کامپایلر query را IOrderedEnumerable<string> استنباط کرده، ولی Where یک IEnumerable<string> عادی برمیگرداند. با explicit typing یا AsEnumerable() پس از OrderBy حل میشود:
var query = names.OrderBy (s => s.Length).AsEnumerable();
query = query.Where (n => n.Length > 3); // OK
معادل در interpreted queryها AsQueryable است.
Grouping
| روش | شرح | معادل SQL |
|---|
| GroupBy | دنباله را به زیردنبالهها گروهبندی میکند. | GROUP BY |
| Chunk | دنباله را به آرایههایی با اندازهٔ ثابت گروهبندی میکند. | — |
GroupBy
IEnumerable<TSource>→IEnumerable<IGrouping<TKey,TElement>>
| آرگومان | نوع |
|---|
| Input sequence | IEnumerable<TSource> |
| Key selector | TSource => TKey |
| Element selector (optional) | TSource => TElement |
| Comparer (optional) | IEqualityComparer<TKey> |
query syntax: group element-expression by key-expression
GroupBy یک ورودی تخت را به دنبالهای از گروهها سازمان میدهد. مثال: گروهبندی همهٔ فایلهای Path.GetTempPath() بر اساس extension:
string[] files = Directory.GetFiles (Path.GetTempPath());
IEnumerable<IGrouping<string,string>> query =
files.GroupBy (file => Path.GetExtension (file));
یا با implicit typing:
var query = files.GroupBy (file => Path.GetExtension (file));
پیمایش نتیجه:
foreach (IGrouping<string,string> grouping in query)
{
Console.WriteLine ("Extension: " + grouping.Key);
foreach (string filename in grouping)
Console.WriteLine (" - " + filename);
}
// Extension: .pdf
// -- chapter03.pdf
// -- chapter04.pdf
// Extension: .doc
// -- todo.doc
// -- menu.doc
// -- Copy of menu.doc
Enumerable.GroupBy عناصر ورودی را در یک dictionary موقت از listها میخواند تا عناصر همکلید در یک sublist قرار بگیرند و سپس دنبالهای از groupingها میدهد. grouping دنبالهای دارای property Key است:
public interface IGrouping <TKey,TElement> : IEnumerable<TElement>,
IEnumerable
{
TKey Key { get; }
}
بهطور پیشفرض عناصر هر گروه همان input elementهای تغییرنیافتهاند، مگر elementSelector بدهید. مثال، uppercase کردن هر عنصر:
files.GroupBy (file => Path.GetExtension (file), file => file.ToUpper());
elementSelector مستقل از keySelector است، پس Key گروه در مثال همچنان case اصلی را حفظ میکند.
Extension: .pdf
-- CHAPTER03.PDF
-- CHAPTER04.PDF
Extension: .doc
-- TODO.DOC
زیرمجموعهها بر اساس کلید مرتب نمیشوند. GroupBy فقط گروهبندی میکند و ترتیب اصلی را حفظ میکند. برای sort باید OrderBy اضافه شود:
files.GroupBy (file => Path.GetExtension (file), file => file.ToUpper())
.OrderBy (grouping => grouping.Key);
معادل مستقیم query syntax:
from file in files
group file.ToUpper() by Path.GetExtension (file);
همانند select، group query را «تمام» میکند مگر query continuation اضافه شود:
from file in files
group file.ToUpper() by Path.GetExtension (file) into grouping
orderby grouping.Key
select grouping;
query continuation در group by مفید است. مثال، حذف گروههایی با کمتر از پنج فایل:
from file in files
group file.ToUpper() by Path.GetExtension (file) into grouping
where grouping.Count() >= 5
select grouping;
گاهی فقط نتیجهٔ aggregation روی هر grouping مهم است و میتوان subsequenceها را کنار گذاشت:
string[] votes = { "Dogs", "Cats", "Cats", "Dogs", "Dogs" };
IEnumerable<string> query = from vote in votes
group vote by vote into g
orderby g.Count() descending
select g.Key;
string winner = query.First(); // Dogs
GroupBy در EF Core
Grouping هنگام پرسوجوی پایگاه داده نیز به همان شکل کار میکند. اگر navigation propertyها تنظیم شده باشند، نیاز به group نسبت به SQL سنتی کمتر میشود. برای مشتریانی با حداقل دو خرید، نیازی به group نیست:
from c in dbContext.Customers
where c.Purchases.Count >= 2
select c.Name + " has made " + c.Purchases.Count + " purchases";
نمونهٔ مناسب grouping، مجموع فروش به تفکیک سال است:
from p in dbContext.Purchases
group p.Price by p.Date.Year into salesByYear
select new {
Year = salesByYear.Key,
TotalValue = salesByYear.Sum()
};
Grouping در LINQ از GROUP BY SQL قدرتمندتر است، چون از نظر مفهومی میتواند همهٔ detail rowها را بدون aggregation برگرداند:
from p in dbContext.Purchases
group p by p.Date.Year
اما این حالت در EF Core کار نمیکند. workaround ساده این است که درست پیش از grouping، .AsEnumerable() را صدا بزنید تا grouping روی client انجام شود. اگر filtering را قبل از grouping انجام دهید، از نظر واکشی داده از server ناکارآمدتر نیست.
تفاوت دیگر با SQL سنتی: الزامی ندارید variableها یا expressionهایی را که برای grouping یا sorting استفاده شدهاند در projection نهایی بیاورید.
Grouping با چند کلید
با anonymous type میتوان composite key ساخت:
from n in names
group n by new { FirstLetter = n[0], Length = n.Length };
Custom equality comparer
در query محلی میتوانید comparer سفارشی به GroupBy بدهید، هرچند معمولاً تغییر key selector کافی است. برای grouping غیرحساس به بزرگی حروف:
Chunk
IEnumerable<TSource>→IEnumerable<TElement[]>. آرگومانها: input sequence از نوع IEnumerable<TSource> و size از نوع int. Chunk در .NET 6 معرفی شد و دنباله را به chunkهایی با اندازهٔ دادهشده (یا کمتر، اگر عناصر کافی نمانده باشد) تقسیم میکند:
foreach (int[] chunk in new[] { 1, 2, 3, 4, 5, 6, 7, 8 }.Chunk (3))
Console.WriteLine (string.Join (", ", chunk));
// 1, 2, 3
// 4, 5, 6
// 7, 8
Set Operators
IEnumerable<TSource>, IEnumerable<TSource>→IEnumerable<TSource>
| روش | شرح | معادل SQL |
|---|
| Concat | عناصر دو دنباله را پشت سر هم برمیگرداند. | UNION ALL |
| Union, UnionBy | ترکیب عناصر دو دنباله بدون تکراریها. | UNION |
| Intersect, IntersectBy | عناصر موجود در هر دو دنباله. | WHERE ... IN (...) |
| Except, ExceptBy | عناصر موجود در دنبالهٔ اول و غایب در دومی. | EXCEPT یا WHERE ... NOT IN (...) |
Concat، Union، UnionBy
Concat همهٔ عناصر دنبالهٔ اول و سپس دومی را میدهد؛ Union همان کار را با حذف duplicate انجام میدهد:
int[] seq1 = { 1, 2, 3 }, seq2 = { 3, 4, 5 };
IEnumerable<int>
concat = seq1.Concat (seq2), // { 1, 2, 3, 3, 4, 5 }
union = seq1.Union (seq2); // { 1, 2, 3, 4, 5 }
اعلام صریح type argument وقتی دنبالهها type متفاوت ولی base type مشترک دارند مفید است؛ مثلاً API Reflection، MethodInfo و PropertyInfo را دارد که base class مشترک MemberInfo دارند.
MethodInfo[] methods = typeof (string).GetMethods();
PropertyInfo[] props = typeof (string).GetProperties();
IEnumerable<MemberInfo> both = methods.Concat<MemberInfo> (props);
میتوان پیش از concatenation متدها را فیلتر کرد:
var methods = typeof (string).GetMethods().Where (m => !m.IsSpecialName);
var props = typeof (string).GetProperties();
var both = methods.Concat<MemberInfo> (props);
این مثال متکی به variance پارامتر نوع interface است: IEnumerable<MethodInfo> بهطور covariant به IEnumerable<MemberInfo> تبدیل میشود و نشان میدهد variance چگونه رفتار طبیعیتر مورد انتظار را ممکن میکند.
UnionBy در .NET 6 معرفی شد و یک key selector میگیرد تا duplicate بودن را تعیین کند. union غیرحساس به case:
string[] seq1 = { "A", "b", "C" };
string[] seq2 = { "a", "B", "c" };
var union = seq1.UnionBy (seq2, x => x.ToUpperInvariant());
// { "A", "b", "C" }
همین کار با Union و equality comparer:
var union = seq1.Union (seq2, StringComparer.InvariantCultureIgnoreCase);
Intersect، IntersectBy، Except و ExceptBy
Intersect اشتراک دو دنباله و Except عناصر دنبالهٔ اول را که در دومی نیستند میدهد:
int[] seq1 = { 1, 2, 3 }, seq2 = { 3, 4, 5 };
IEnumerable<int>
commonality = seq1.Intersect (seq2), // { 3 }
difference1 = seq1.Except (seq2), // { 1, 2 }
difference2 = seq2.Except (seq1); // { 4, 5 }
Enumerable.Except درون خود ابتدا عناصر collection اول را در dictionary بار میکند و سپس عناصر موجود در دنبالهٔ دوم را از dictionary حذف میکند. معادل SQL یک NOT EXISTS یا NOT IN subquery است:
SELECT number FROM numbers1Table
WHERE number NOT IN (SELECT number FROM numbers2Table)
IntersectBy و ExceptBy (از .NET 6) اجازه میدهند پیش از equality comparison یک key selector اعمال شود.
Conversion Methods
LINQ عمدتاً با دنبالهها، یعنی collectionهای IEnumerable<T> کار میکند. روشهای conversion به/از انواع دیگر collection تبدیل میکنند:
| روش | شرح |
|---|
| OfType | IEnumerable را به IEnumerable<T> تبدیل و عناصر با type نامناسب را حذف میکند. |
| Cast | تبدیل به IEnumerable<T> و exception در صورت عنصر با type نامناسب. |
| ToArray | IEnumerable<T> به T[] |
| ToList | به List<T> |
| ToDictionary | به Dictionary<TKey,TValue> |
| ToLookup | به ILookup<TKey,TElement> |
| AsEnumerable | upcast به IEnumerable<T> |
| AsQueryable | cast/convert به IQueryable<T> |
OfType و Cast
یک IEnumerable nongeneric را میگیرند و IEnumerable<T> generic تولید میکنند:
ArrayList classicList = new ArrayList();
classicList.AddRange ( new int[] { 3, 4, 5 } );
IEnumerable<int> sequence1 = classicList.Cast<int>();
تفاوت هنگام عنصر ناسازگار است: Cast exception میدهد، OfType عنصر ناسازگار را نادیده میگیرد:
DateTime offender = DateTime.Now;
classicList.Add (offender);
IEnumerable<int>
sequence2 = classicList.OfType<int>(), // OK
sequence3 = classicList.Cast<int>(); // Throws exception
قواعد سازگاری عنصر دقیقاً از اپراتور is در C# پیروی میکند؛ فقط reference conversion و unboxing conversion را در نظر میگیرد. بخشی از پیادهسازی OfType:
public static IEnumerable<TSource> OfType <TSource> (IEnumerable source)
{
foreach (object element in source)
if (element is TSource)
yield return (TSource)element;
}
Cast پیادهسازی مشابه دارد، فقط تست type compatibility را حذف میکند:
public static IEnumerable<TSource> Cast <TSource> (IEnumerable source)
{
foreach (object element in source)
yield return (TSource)element;
}
نتیجهٔ این پیادهسازیها آن است که از Cast برای numeric conversion یا custom conversion نمیتوان استفاده کرد؛ برای آنها باید Select بهکار برد. Cast بهاندازهٔ cast operator خود C# منعطف نیست:
int i = 3;
long l = i; // Implicit numeric conversion int->long
int i2 = (int) l; // Explicit numeric conversion long->int
تلاش برای تبدیل دنبالهٔ int به long:
int[] integers = { 1, 2, 3 };
IEnumerable<long> test1 = integers.OfType<long>();
IEnumerable<long> test2 = integers.Cast<long>();
در پیمایش، test1 صفر عنصر میدهد و test2 exception. در OfType عبارت (element is long) برای int false است چون رابطهٔ inheritance وجود ندارد.
راهحل استفاده از Select عادی است:
IEnumerable<long> castLong = integers.Select (s => (long) s);
OfType و Cast برای downcast عناصر در input sequence generic هم مفیدند؛ مثلاً اگر IEnumerable<Fruit> دارید، OfType<Apple> فقط سیبها را میدهد؛ این قابلیت بهویژه در LINQ to XML کاربرد دارد.
Cast در query syntax پشتیبانی میشود: type را پیش از range variable بنویسید:
from TreeNode node in myTreeView.Nodes
...
ToArray، ToList، ToDictionary، ToHashSet، ToLookup
ToArray، ToList و ToHashSet نتیجه را به array، List<T> یا HashSet<T> میریزند و هنگام اجرا input sequence را فوراً enumerate میکنند.
آرگومانهای ToDictionary و ToLookup| آرگومان | نوع |
|---|
| Input sequence | IEnumerable<TSource> |
| Key selector | TSource => TKey |
| Element selector (optional) | TSource => TElement |
| Comparer (optional) | IEqualityComparer<TKey> |
ToDictionary نیز immediate execution را تحمیل میکند و نتیجه را در Dictionary مینویسد. key selector باید برای هر ورودی مقدار یکتا بدهد وگرنه exception. در مقابل، ToLookup چند عنصر با یک کلید را میپذیرد.
AsEnumerable و AsQueryable
AsEnumerable دنباله را به IEnumerable<T> upcast میکند و کامپایلر را مجبور میکند اپراتورهای بعدی را به Enumerable bind کند نه Queryable. AsQueryable اگر دنباله IQueryable<T> را پیادهسازی کند آن را downcast میکند، در غیر این صورت wrapper از IQueryable<T> روی query محلی میسازد.
Element Operators
IEnumerable<TSource>→TSource
| روش | شرح | معادل SQL |
|---|
| First, FirstOrDefault | اولین عنصر، بهصورت اختیاری مطابق predicate. | SELECT TOP 1 ... ORDER BY ... |
| Last, LastOrDefault | آخرین عنصر، بهصورت اختیاری مطابق predicate. | SELECT TOP 1 ... ORDER BY ... DESC |
| Single, SingleOrDefault | مانند First/FirstOrDefault، اما اگر بیش از یک match باشد exception. | — |
| ElementAt, ElementAtOrDefault | عنصر در موقعیت مشخص. | Exception thrown |
| MinBy, MaxBy | عنصر دارای کوچکترین/بزرگترین مقدار. | Exception thrown |
| DefaultIfEmpty | اگر دنباله خالی باشد دنبالهٔ تکعنصری با default(TSource). | OUTER JOIN |
متدهای دارای پسوند OrDefault اگر ورودی خالی یا بدون match باشد بهجای exception مقدار default(TSource) برمیگردانند. این مقدار برای reference type برابر null، برای bool برابر false و برای numeric type صفر است.
First، Last و Single
int[] numbers = { 1, 2, 3, 4, 5 };
int first = numbers.First(); // 1
int last = numbers.Last(); // 5
int firstEven = numbers.First (n => n % 2 == 0); // 2
int lastEven = numbers.Last (n => n % 2 == 0); // 4
int firstBigError = numbers.First (n => n > 10); // Exception
int firstBigNumber = numbers.FirstOrDefault (n => n > 10); // 0
برای جلوگیری از exception، Single دقیقاً یک match و SingleOrDefault صفر یا یک match میخواهد:
int onlyDivBy3 = numbers.Single (n => n % 3 == 0); // 3
int divBy2Err = numbers.Single (n => n % 2 == 0); // Error: 2 & 4 match
int singleError = numbers.Single (n => n > 10); // Error
int noMatches = numbers.SingleOrDefault (n => n > 10); // 0
int divBy2Error = numbers.SingleOrDefault (n => n % 2 == 0); // Error
Single سختگیرترین عضو این خانواده است؛ FirstOrDefault و LastOrDefault تحمل بیشتری دارند. در EF Core، Single اغلب برای گرفتن row بر اساس primary key استفاده میشود:
Customer cust = dataContext.Customers.Single (c => c.ID == 3);
ElementAt
عنصر nام را برمیگزیند:
int[] numbers = { 1, 2, 3, 4, 5 };
int third = numbers.ElementAt (2); // 3
int tenthError = numbers.ElementAt (9); // Exception
int tenth = numbers.ElementAtOrDefault (9); // 0
Enumerable.ElementAt اگر input sequence، IList<T> باشد از indexer آن استفاده میکند؛ وگرنه n بار enumerate کرده و عنصر بعدی را برمیگرداند. در EF Core پشتیبانی نمیشود.
MinBy و MaxBy
در .NET 6 معرفی شدند و با key selector، عنصر دارای کوچکترین یا بزرگترین مقدار را میدهند:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
Console.WriteLine (names.MaxBy (n => n.Length)); // Harry
Console.WriteLine (names.Max (n => n.Length)); // 5
Console.WriteLine (names.MinBy (n => n.Length)); // Tom
اگر چند عنصر min/max مشترک داشته باشند، اولین مورد برگردانده میشود.
اگر input sequence خالی باشد، MinBy و MaxBy برای element type nullable مقدار null میدهند و برای type غیرnullable exception میدهند.
DefaultIfEmpty
اگر دنبالهٔ ورودی خالی باشد، دنبالهٔ تکعنصری با default(TSource) برمیگرداند؛ وگرنه ورودی را بدون تغییر میدهد. کاربرد اصلی آن در flat outer joinهاست.
Aggregation Methods
IEnumerable<TSource>→scalar
| روش | شرح | معادل SQL |
|---|
| Count, LongCount | تعداد عناصر، با predicate اختیاری. | COUNT (...) |
| Min, Max | کوچکترین یا بزرگترین عنصر. | MIN (...), MAX (...) |
| Sum, Average | مجموع یا میانگین عددی. | SUM (...), AVG (...) |
| Aggregate | aggregation سفارشی. | Exception thrown |
Count و LongCount
Count دنباله را پیمایش و تعداد را برمیگرداند:
int fullCount = new int[] { 5, 6, 7 }.Count(); // 3
پیادهسازی Enumerable.Count بررسی میکند آیا ورودی ICollection<T> است؛ اگر بله مستقیم Count آن را میخواند، وگرنه همهٔ عناصر را enumerate و شمارنده را افزایش میدهد. predicate اختیاری:
int digitCount = "pa55w0rd".Count (c => char.IsDigit (c)); // 3
LongCount همان کار را با عدد ۶۴ بیتی انجام میدهد و دنبالههای بیش از دو میلیارد عنصر را پوشش میدهد.
Min و Max
کوچکترین یا بزرگترین عنصر دنباله را میدهند:
int[] numbers = { 28, 32, 14 };
int smallest = numbers.Min(); // 14
int largest = numbers.Max(); // 32
اگر selector بدهید، هر عنصر ابتدا project میشود:
int smallest = numbers.Max (n => n % 10); // 8
اگر خود itemها ذاتاً قابل مقایسه نباشند و IComparable<T> را پیادهسازی نکنند، selector الزامی است:
Purchase runtimeError = dbContext.Purchases.Min (); // Error
decimal? lowestPrice = dbContext.Purchases.Min (p => p.Price); // OK
selector هم روش مقایسه و هم نوع نتیجه را تعیین میکند. برای گرفتن خودِ ارزانترین purchase به subquery نیاز است:
Purchase cheapest = dbContext.Purchases
.Where (p => p.Price == dbContext.Purchases.Min (p2 => p2.Price))
.FirstOrDefault();
در این مورد میتوان بهجای aggregation از OrderBy سپس FirstOrDefault نیز استفاده کرد.
Sum و Average
مانند Min/Max استفاده میشوند:
decimal[] numbers = { 3, 4, 8 };
decimal sumTotal = numbers.Sum(); // 15
decimal average = numbers.Average(); // 5
int combinedLength = names.Sum (s => s.Length); // 19
Sum و Average از نظر type محدودترند؛ تعریفشان برای numeric typeهای int, long, float, double, decimal و nullableهای آنها hard-wire شده است. Min/Max میتوانند روی هر چیزی که IComparable<T> دارد، مانند string، کار کنند.
Average همیشه بر اساس selector type یکی از decimal، float یا double را میدهد:
| Selector type | Result type |
|---|
| decimal | decimal |
| float | float |
| int, long, double | double |
پس این compile نمیشود:
int avg = new int[] { 3, 4 }.Average();
ولی این میشود:
double avg = new int[] { 3, 4 }.Average(); // 3.5
Average ورودی را ضمنی upscale میکند تا precision از دست نرود؛ در مثال integerها میانگین 3.5 میدهند. در query پایگاه داده، Sum و Average به aggregationهای استاندارد SQL ترجمه میشوند. مشتریانی با میانگین خرید بالاتر از ۵۰۰ دلار:
from c in dbContext.Customers
where c.Purchases.Average (p => p.Price) > 500
select c.Name;
Aggregate
الگوریتم accumulation سفارشی برای aggregationهای غیرمعمول میدهد؛ در EF Core پشتیبانی نمیشود و کاربرد تخصصی دارد. انجام کار Sum:
int[] numbers = { 1, 2, 3 };
int sum = numbers.Aggregate (0, (total, n) => total + n); // 6
آرگومان اول seed و آرگومان دوم expression بهروزرسانی accumulated value با عنصر جدید است. آرگومان سوم اختیاری میتواند نتیجهٔ نهایی را project کند.
Unseeded aggregations
میتوانید seed را حذف کنید؛ در این صورت عنصر اول seed ضمنی میشود و aggregation از عنصر دوم ادامه مییابد:
int[] numbers = { 1, 2, 3 };
int sum = numbers.Aggregate ((total, n) => total + n); // 6
نتیجه مثل قبل است، اما محاسبه متفاوت: قبلاً 0+1+2+3 و اکنون 1+2+3. تفاوت با ضرب روشنتر میشود:
int[] numbers = { 1, 2, 3 };
int x = numbers.Aggregate (0, (prod, n) => prod * n); // 0
int y = numbers.Aggregate ( (prod, n) => prod * n); // 6
aggregation بدون seed میتواند بدون overload ویژه parallel شود، اما trapهایی دارد.
Trapهای unseeded aggregation
متدهای بدون seed برای delegateهایی طراحی شدهاند که commutative و associative باشند. در غیر این صورت نتیجه در query عادی غیرشهودی و در PLINQ nondeterministic میشود. تابع زیر هیچکدام نیست:
(total, n) => total + n * n
برای مربعهای ۲، ۳ و ۴:
int[] numbers = { 2, 3, 4 };
int sum = numbers.Aggregate ((total, n) => total + n * n); // 27
// انتظار ریاضی: 2*2 + 3*3 + 4*4 = 29
// محاسبه واقعی: 2 + 3*3 + 4*4 = 27
یک اصلاح ظاهری، افزودن 0 به ابتدای دنباله است:
int[] numbers = { 0, 2, 3, 4 };
این راه نهتنها زیبا نیست، در parallel هم غلط میشود چون PLINQ بر اساس فرض associativity چند عنصر را بهعنوان seed انتخاب میکند. اگر تابع را f(total,n)=total+n*n بنامیم، LINQ to Objects محاسبه میکند:
اما PLINQ ممکن است:
f(f(0,2),f(3,4))
First partition: a = 0 + 2*2 (= 4)
Second partition: b = 3 + 4*4 (= 19)
Final result: a + b*b (= 365)
OR EVEN: b + a*a (= 35)
دو راهحل خوب وجود دارد: اول، seeded aggregation با seed صفر (در PLINQ برای parallel ماندن نیاز به overload ویژه دارد). دوم، بازساخت query بهگونهای که aggregation commutative و associative باشد:
int sum = numbers.Select (n => n * n).Aggregate ((total, n) => total + n);
Math.Sqrt (numbers.Average (n => n * n))
double mean = numbers.Average();
double sdev = Math.Sqrt (numbers.Average (n =>
{
double dif = n - mean;
return dif * dif;
}));
این محاسبات امن، کارآمد و کاملاً parallelizable هستند. کتاب برای یک custom aggregation واقعیتر به فصل ۲۲ ارجاع میدهد.
Quantifiers
IEnumerable<TSource>→bool
| روش | شرح | معادل SQL |
|---|
| Contains | آیا دنباله عنصر دادهشده را دارد؟ | WHERE ... IN (...) |
| Any | آیا حداقل یک عنصر predicate را ارضا میکند؟ | WHERE ... IN (...) |
| All | آیا همهٔ عناصر predicate را ارضا میکنند؟ | WHERE (...) |
| SequenceEqual | آیا دنبالهٔ دوم عناصری دقیقاً یکسان و با ترتیب یکسان دارد؟ | — |
Contains و Any
Contains آرگومان TSource و Any predicate اختیاری میگیرد:
bool hasAThree = new int[] { 2, 3, 4 }.Contains (3); // true
bool hasAThree2 = new int[] { 2, 3, 4 }.Any (n => n == 3); // true
bool hasABigNumber = new int[] { 2, 3, 4 }.Any (n => n > 10); // false
Any بدون predicate وقتی دستکم یک عنصر باشد true میدهد:
bool hasABigNumber = new int[] { 2, 3, 4 }.Where (n => n > 10).Any();
Any در subqueryها بسیار مفید و در query پایگاه داده رایج است:
from c in dbContext.Customers
where c.Purchases.Any (p => p.Price > 1000)
select c
All و SequenceEqual
All وقتی همهٔ عناصر predicate را برآورده کنند true است. مشتریانی که همهٔ خریدهایشان کمتر از ۱۰۰ دلار است:
dbContext.Customers.Where (c => c.Purchases.All (p => p.Price < 100));
SequenceEqual دو دنباله را مقایسه میکند؛ برای true، عناصر و ترتیب باید یکسان باشند. equality comparer اختیاری است و پیشفرض EqualityComparer<T>.Default.
Generation Methods
void→IEnumerable<TResult>
| روش | شرح |
|---|
| Empty | دنبالهٔ خالی میسازد. |
| Repeat | دنبالهای از عنصر تکراری میسازد. |
| Range | دنبالهای از integerها میسازد. |
Empty، Repeat و Range متدهای static و nonextension هستند که دنبالههای محلی ساده میسازند.
Empty
فقط type argument میخواهد:
foreach (string s in Enumerable.Empty<string>())
Console.Write (s); // <nothing>
در کنار ??، Empty عکس DefaultIfEmpty عمل میکند. آرایهٔ jagged زیر یک null دارد و SelectMany ساده شکست میخورد:
int[][] numbers =
{
new int[] { 1, 2, 3 },
new int[] { 4, 5, 6 },
null
};
IEnumerable<int> flat = numbers.SelectMany (innerArray => innerArray);
با Empty و ??:
IEnumerable<int> flat = numbers
.SelectMany (innerArray => innerArray ?? Enumerable.Empty <int>());
foreach (int i in flat)
Console.Write (i + " "); // 1 2 3 4 5 6
Range و Repeat
Range starting index و count میگیرد:
foreach (int i in Enumerable.Range (5, 3))
Console.Write (i + " "); // 5 6 7
Repeat یک عنصر و تعداد تکرار میگیرد:
foreach (bool x in Enumerable.Repeat (true, 3))
Console.Write (x + " "); // True True True