فصل ۸: مبانی LINQ، Fluent Syntax، Query Syntax و اجرای Deferred
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
فصل ۸: پرسوجوهای LINQ
تصویر آغاز فصل ۸ — LINQ QueriesLINQ یا Language Integrated Query مجموعهای از قابلیتهای زبان و زمان اجرا برای نوشتن پرسوجوهای ساختیافته و دارای ایمنی نوع روی مجموعههای محلی اشیاء و منابع دادهٔ راهدور است.
LINQ به شما امکان میدهد هر مجموعهای را که IEnumerable<T> را پیادهسازی میکند — چه آرایه، چه List یا مدل شیء سند XML (DOM) — و نیز منابع دادهٔ راهدور مانند جدولهای یک پایگاهدادهٔ SQL Server را پرسوجو کنید. LINQ هم مزیت بررسی نوع در زمان کامپایل و هم مزیت ترکیب پویای Query را فراهم میکند.
این فصل معماری LINQ و مبانی نوشتن Queryها را شرح میدهد. تمام Typeهای اصلی در Namespaceهای System.Linq و System.Linq.Expressions تعریف شدهاند.
شروع کار
واحدهای پایهٔ داده در LINQ، Sequence و Element هستند. Sequence هر شیئی است که IEnumerable<T> را پیادهسازی کند و Element هر Item موجود در Sequence است. در مثال زیر، names یک Sequence و رشتههای "Tom"، "Dick" و "Harry" Elementهای آن هستند:
string[] names = { "Tom", "Dick", "Harry" };
این را یک Sequence محلی مینامیم، چون یک Collection محلی از Objectها در حافظه را نمایش میدهد.
Query Operator متدی است که یک Sequence را تبدیل میکند. یک Query Operator معمولی یک Sequence ورودی میگیرد و یک Sequence خروجیِ تبدیلشده تولید میکند. در Class با نام Enumerable در System.Linq حدود ۴۰ Query Operator وجود دارد که همگی بهصورت Static Extension Method پیادهسازی شدهاند. به اینها Standard Query Operators گفته میشود.
Queryهایی که روی Sequenceهای محلی کار میکنند، Local Query یا LINQ-to-Objects Query نام دارند.
Query یک Expression است که هنگام Enumerate شدن، Sequenceها را با Query Operatorها تبدیل میکند. سادهترین Query شامل یک Sequence ورودی و یک Operator است. برای مثال، میتوانیم Operator با نام Where را روی یک Array ساده اعمال کنیم تا Stringهایی را استخراج کنیم که طولشان دستکم چهار Character است:
string[] names = { "Tom", "Dick", "Harry" };
IEnumerable<string> filteredNames = System.Linq.Enumerable.Where
(names, n => n.Length >= 4);
foreach (string n in filteredNames)
Console.WriteLine (n);
Dick
Harry
چون Standard Query Operatorها بهصورت Extension Method پیادهسازی شدهاند، میتوانیم Where را مستقیماً روی names فراخوانی کنیم، انگار یک Instance Method است:
IEnumerable<string> filteredNames = names.Where (n => n.Length >= 4);
برای Compile شدن این کد باید Namespace با نام System.Linq را Import کنید. مثال کامل چنین است:
using System;
using System.Collections.Generic;
using System.Linq;
string[] names = { "Tom", "Dick", "Harry" };
IEnumerable<string> filteredNames = names.Where (n => n.Length >= 4);
foreach (string name in filteredNames) Console.WriteLine (name);
Dick
Harry
بیشتر Query Operatorها یک Lambda Expression را بهعنوان Argument میپذیرند. Lambda Expression به هدایت و شکلدهی Query کمک میکند. در مثال ما Lambda چنین است:
Argument ورودی متناظر با یک Element ورودی است. اینجا n نمایندهٔ هر Name در Array و از Type با نام string است. Operator با نام Where میخواهد Lambda یک مقدار bool برگرداند؛ اگر نتیجه true باشد، Element باید در Sequence خروجی قرار گیرد. Signature آن چنین است:
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource,bool> predicate)
Query زیر همهٔ Nameهایی را استخراج میکند که حرف a را دارند:
IEnumerable<string> filteredNames = names.Where (n => n.Contains ("a"));
foreach (string name in filteredNames)
Console.WriteLine (name); // Harry
تا اینجا Queryها را با Extension Method و Lambda Expression ساختهایم. همانطور که بهزودی میبینید، این روش بهشدت قابل ترکیب است چون اجازه میدهد Query Operatorها را به هم زنجیر کنیم. در این کتاب به آن Fluent Syntax گفته میشود. C# Syntax دیگری هم برای نوشتن Query دارد که Query Expression Syntax نامیده میشود. Query قبلی با Query Expression چنین نوشته میشود:
IEnumerable<string> filteredNames = from n in names
where n.Contains ("a")
select n;
Fluent Syntax و Query Syntax مکمل یکدیگرند. در دو بخش بعدی هرکدام را با جزئیات بیشتر بررسی میکنیم.
Fluent Syntax
Fluent Syntax انعطافپذیرترین و بنیادیترین شیوه است. در این بخش میبینیم چگونه Query Operatorها را برای ساخت Queryهای پیچیدهتر زنجیر کنیم، چرا Extension Methodها در این فرایند مهماند، چگونه Lambda Expression مناسب برای یک Query Operator ساخته میشود و چند Query Operator جدید را معرفی میکنیم.
زنجیرهکردن Query Operatorها
در بخش قبل دو Query ساده دیدیم که هرکدام تنها یک Query Operator داشتند. برای ساخت Queryهای پیچیدهتر، Operatorهای بیشتری به Expression اضافه میکنید و Chain میسازید. Query زیر Stringهایی را که حرف a دارند استخراج میکند، آنها را بر اساس Length مرتب میکند و سپس نتیجه را به Uppercase تبدیل میکند:
using System;
using System.Collections.Generic;
using System.Linq;
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query = names
.Where (n => n.Contains ("a"))
.OrderBy (n => n.Length)
.Select (n => n.ToUpper());
foreach (string name in query) Console.WriteLine (name);
JAY
MARY
HARRY
void Test()
{
foreach (char c in "string1") Console.Write (c);
foreach (char c in "string2") Console.Write (c);
foreach (char c in "string3") Console.Write (c);
}
Where، OrderBy و Select Standard Query Operatorهایی هستند که اگر Namespace با نام System.Linq را Import کرده باشید، به Extension Methodهای Class با نام Enumerable Resolve میشوند. Where نسخهٔ Filterشدهٔ Sequence ورودی را تولید میکند؛ OrderBy نسخهٔ مرتبشده را تولید میکند؛ و Select Sequenceای میسازد که در آن هر Element ورودی با Lambda دادهشده Transform یا Project میشود — در این مثال با n.ToUpper(). داده از چپ به راست در Chain جریان دارد: ابتدا Filter، سپس Sort و بعد Project میشود.
Signature این Extension Methodها، با سادهسازی جزئی OrderBy، چنین است:
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource,bool> predicate)
public static IEnumerable<TSource> OrderBy<TSource,TKey>
(this IEnumerable<TSource> source, Func<TSource,TKey> keySelector)
public static IEnumerable<TResult> Select<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
وقتی Query Operatorها مانند این مثال زنجیر میشوند، Sequence خروجی یک Operator، Sequence ورودی Operator بعدی است. Query کامل شبیه خط تولیدی از تسمهنقالههاست که در شکل 8-1 نشان داده شده است.
شکل 8-1 — زنجیرهکردن Query Operatorهابرچسبها: Filter=فیلتر، Sorter=مرتبساز، Projector=پروژکتور؛ جریان داده از Nameها به خروجی Uppercase است.
" alt="شکل 8-1: زنجیره Query Operatorها"/>شکل 8-1 — زنجیرهکردن Query Operatorها؛ داده از Filter به Sort و سپس Project حرکت میکند.میتوانیم Query کاملاً یکسانی را بهصورت مرحلهای بسازیم:
// You must import the System.Linq namespace for this to compile:
IEnumerable<string> filtered = names .Where (n => n.Contains ("a"));
IEnumerable<string> sorted = filtered.OrderBy (n => n.Length);
IEnumerable<string> finalQuery = sorted .Select (n => n.ToUpper());
finalQuery از نظر Composition دقیقاً همان Query قبلی است. علاوهبراین، هر مرحلهٔ میانی نیز Query معتبری است که میتوانیم اجرا کنیم:
foreach (string name in filtered)
Console.Write (name + "|"); // Harry|Mary|Jay|
Console.WriteLine();
foreach (string name in sorted)
Console.Write (name + "|"); // Jay|Mary|Harry|
Console.WriteLine();
foreach (string name in finalQuery)
Console.Write (name + "|"); // JAY|MARY|HARRY|
چرا Extension Methodها مهماند
بهجای Extension Method Syntax میتوانید Query Operatorها را با Syntax معمول Static Method فراخوانی کنید:
IEnumerable<string> filtered = Enumerable.Where (names,
n => n.Contains ("a"));
IEnumerable<string> sorted = Enumerable.OrderBy (filtered, n => n.Length);
IEnumerable<string> finalQuery = Enumerable.Select (sorted,
n => n.ToUpper());
در واقع Compiler فراخوانی Extension Methodها را همینگونه ترجمه میکند. اما کنارگذاشتن Extension Methodها وقتی بخواهید Query را در یک Statement بنویسید، هزینهٔ خوانایی دارد. شکل خطی طبیعی Extension Syntax جریان چپبهراست داده را نشان میدهد و Lambdaها را کنار Operator مربوط به خود نگه میدارد.
IEnumerable<string> query = names.Where (n => n.Contains ("a"))
.OrderBy (n => n.Length)
.Select (n => n.ToUpper());
بدون Extension Method، Query روانی خود را از دست میدهد:
IEnumerable<string> query =
Enumerable.Select (
Enumerable.OrderBy (
Enumerable.Where (
names, n => n.Contains ("a")
), n => n.Length
), n => n.ToUpper()
);
ساخت Lambda Expressionها
در مثالهای قبل این Lambda را به Operator با نام Where دادیم:
n => n.Contains ("a") // Input type = string, return type = bool.
هدف Lambda Expression به Query Operator مشخص بستگی دارد. در Where مشخص میکند آیا Element باید در Sequence خروجی قرار گیرد. در OrderBy هر Element ورودی را به Sorting Key آن Map میکند. در Select تعیین میکند هر Element پیش از فرستادهشدن به Sequence خروجی چگونه Transform شود.
Query Operator، Lambda شما را برحسب نیاز ارزیابی میکند؛ معمولاً یک بار برای هر Element ورودی. Lambda Expression اجازه میدهد Logic خودتان را وارد Query Operator کنید و همین، Operatorها را در عین سادگی داخلی، بسیار منعطف میکند. پیادهسازی کامل Enumerable.Where با صرفنظر از Exception Handling چنین است:
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource,bool> predicate)
{
foreach (TSource element in source)
if (predicate (element))
yield return element;
}
Lambda Expressionها و Signatureهای Func
Standard Query Operatorها از Delegateهای Generic با نام Func استفاده میکنند. Func خانوادهای از Delegateهای Generic عمومی در Namespace با نام System است. Type Argumentها در Func به همان ترتیبی ظاهر میشوند که در Lambda Expression هستند. بنابراین Func<TSource,bool> با Lambda از شکل TSource => bool مطابقت دارد: یک TSource میگیرد و bool برمیگرداند. به همین ترتیب Func<TSource,TResult> با TSource => TResult منطبق است. Delegateهای Func در بخش «Lambda Expressions» صفحهٔ 188 فهرست شدهاند.
Lambda Expressionها و Type عنصر
Standard Query Operatorها از نامهای Type Parameter زیر استفاده میکنند:
| حرف Type Generic | معنا |
|---|
TSource | Type عنصر Sequence ورودی |
TResult | Type عنصر Sequence خروجی، اگر با TSource متفاوت باشد |
TKey | Type عنصر Key مورد استفاده برای Sorting، Grouping یا Joining |
TSource از Sequence ورودی تعیین میشود؛ TResult و TKey معمولاً از Lambda Expression شما Infer میشوند. Signature Operator با نام Select:
public static IEnumerable<TResult> Select<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
Func<TSource,TResult> یک Element ورودی را به Element خروجی Map میکند. چون TSource و TResult میتوانند Typeهای متفاوتی باشند، Lambda میتواند Type هر Element را تغییر دهد و Type Sequence خروجی را تعیین کند. مثال زیر با Select عناصر string را به int تبدیل میکند:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<int> query = names.Select (n => n.Length);
foreach (int length in query)
Console.Write (length + "|"); // 3|4|5|4|3|
Compiler، TResult را از Return Value Lambda Infer میکند؛ اینجا n.Length یک int برمیگرداند، پس TResult برابر int است.
Operator با نام Where سادهتر است و برای خروجی به Type Inference نیاز ندارد، چون Elementهای ورودی و خروجی همنوعاند؛ منطقی است، زیرا فقط Filter میکند و Transform نمیکند:
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource,bool> predicate)
Signature سادهشدهٔ OrderBy چنین است:
public static IEnumerable<TSource> OrderBy<TSource,TKey>
(this IEnumerable<TSource> source, Func<TSource,TKey> keySelector)
Func<TSource,TKey> Element ورودی را به Sorting Key Map میکند. TKey از Lambda Infer میشود و از Type ورودی و خروجی مستقل است. برای نمونه، میتوانیم Nameها را با Length، یعنی Key از نوع int، یا بهترتیب الفبایی، یعنی Key از نوع string، مرتب کنیم:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> sortedByLength, sortedAlphabetically;
sortedByLength = names.OrderBy (n => n.Length); // int key
sortedAlphabetically = names.OrderBy (n => n); // string key
ترتیب طبیعی
ترتیب اولیهٔ Elementها در Sequence ورودی در LINQ مهم است. بعضی Operatorها مانند Take، Skip و Reverse به این ترتیب متکیاند:
int[] numbers = { 10, 9, 8, 7, 6 };
IEnumerable<int> firstThree = numbers.Take (3); // { 10, 9, 8 }
IEnumerable<int> lastTwo = numbers.Skip (3); // { 7, 6 }
IEnumerable<int> reversed = numbers.Reverse(); // { 6, 7, 8, 9, 10 }
در Local Queryهای LINQ-to-Objects، Operatorهایی مانند Where و Select ترتیب اصلی Sequence ورودی را حفظ میکنند؛ همهٔ Query Operatorهای دیگر نیز همین کار را میکنند مگر آنهایی که مشخصاً ترتیب را تغییر میدهند.
Operatorهای دیگر
همهٔ Query Operatorها Sequence برنمیگردانند. Element Operatorها یک Element را از Sequence ورودی استخراج میکنند؛ از جمله First، Last و ElementAt:
int[] numbers = { 10, 9, 8, 7, 6 };
int firstNumber = numbers.First(); // 10
int lastNumber = numbers.Last(); // 6
int secondNumber = numbers.ElementAt(1); // 9
int secondLowest = numbers.OrderBy(n=>n).Skip(1).First(); // 7
چون این Operatorها یک Element برمیگردانند، معمولاً روی Result آنها Query Operator دیگری فراخوانی نمیکنید، مگر اینکه خود Element یک Collection باشد.
Aggregation Operatorها یک Scalar Value، معمولاً Numeric، برمیگردانند:
int count = numbers.Count(); // 5;
int min = numbers.Min(); // 6;
Quantifierها مقدار bool برمیگردانند:
bool hasTheNumberNine = numbers.Contains (9); // true
bool hasMoreThanZeroElements = numbers.Any(); // true
bool hasAnOddElement = numbers.Any (n => n % 2 != 0); // true
برخی Query Operatorها دو Sequence ورودی میپذیرند؛ مانند Concat که یک Sequence را به دیگری Append میکند، و Union که همین کار را با حذف Duplicateها انجام میدهد:
int[] seq1 = { 1, 2, 3 };
int[] seq2 = { 3, 4, 5 };
IEnumerable<int> concat = seq1.Concat (seq2); // { 1, 2, 3, 3, 4, 5 }
IEnumerable<int> union = seq1.Union (seq2); // { 1, 2, 3, 4, 5 }
Joining Operatorها نیز در همین دسته قرار میگیرند. فصل 9 همهٔ Query Operatorها را با جزئیات پوشش میدهد.
Query Expressionها
C# میانبری نحوی برای نوشتن Queryهای LINQ ارائه میکند که Query Expression نام دارد. برخلاف تصور رایج، Query Expression راهی برای Embed کردن SQL داخل C# نیست. طراحی آن عمدتاً از List Comprehension در زبانهای Functional مانند LISP و Haskell الهام گرفته است، هرچند SQL از نظر ظاهری بر آن اثر داشته است.
Query قبلی که Stringهای دارای a را استخراج، بر اساس Length مرتب و به Uppercase تبدیل میکرد، در Query Syntax چنین است:
using System;
using System.Collections.Generic;
using System.Linq;
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query =
from n in names
where n.Contains ("a") // Filter elements
orderby n.Length // Sort elements
select n.ToUpper(); // Translate each element (project)
foreach (string name in query) Console.WriteLine (name);
JAY
MARY
HARRY
Query Expression همیشه با Clause با نام from آغاز میشود و با select یا group پایان مییابد. from یک Range Variable — اینجا n — تعریف میکند که میتوانید آن را مانند متغیری در نظر بگیرید که Sequence ورودی را شبیه foreach پیمایش میکند. شکل 8-2 Syntax کامل را بهصورت Railroad Diagram نشان میدهد.
شکل 8-2 — Query Syntaxبرچسبهای نمودار: from clause، orderby clause، where boolean expression، let، join clause، select expression و group clause.
" alt="شکل 8-2: Syntax پرسوجوی LINQ"/>شکل 8-2 — Query Syntax؛ Railroad Diagram برای Clauseهای from، where، orderby، let، join، select و group.Compiler یک Query Expression را با ترجمهٔ مکانیکی آن به Fluent Syntax پردازش میکند؛ شبیه تبدیل foreach به فراخوانیهای GetEnumerator و MoveNext. بنابراین هر چیزی که در Query Syntax بنویسید، در Fluent Syntax هم قابل نوشتن است. ترجمهٔ اولیهٔ مثال چنین میشود:
IEnumerable<string> query = names.Where (n => n.Contains ("a"))
.OrderBy (n => n.Length)
.Select (n => n.ToUpper());
سپس Where، OrderBy و Select طبق همان قواعدی Resolve میشوند که اگر Query مستقیماً با Fluent Syntax نوشته شده بود. اینجا به Extension Methodهای Enumerable Bind میشوند، چون System.Linq Import شده و names، IEnumerable<string> را پیادهسازی میکند. Compiler هنگام ترجمهٔ Query Expression بهطور خاص Enumerable را ترجیح نمیدهد؛ میتوانید تصور کنید صرفاً نام Methodهای Where، OrderBy و Select را به Statement تزریق میکند.
سپس Statement را طوری Compile میکند که انگار خودتان این Methodها را نوشتهاید. این موضوع در نحوهٔ Resolve شدن آنها انعطاف ایجاد میکند؛ برای مثال Operatorهای Queryهای Database در بخشهای بعدی به Extension Methodهای Queryable Bind میشوند.
Range Variableها
Identifier بلافاصله بعد از Keyword با نام from، Range Variable نام دارد و به Element جاری در Sequence اشاره میکند که Operation روی آن انجام میشود. در مثالها n در هر Clause دیده میشود، ولی در واقع در هر Clause روی Sequence متفاوتی پیمایش میکند:
from n in names // n is our range variable
where n.Contains ("a") // n = directly from the array
orderby n.Length // n = subsequent to being filtered
select n.ToUpper() // n = subsequent to being sorted
ترجمهٔ Compiler به Fluent Syntax این موضوع را روشن میکند:
names.Where (n => n.Contains ("a")) // Locally scoped n
.OrderBy (n => n.Length) // Locally scoped n
.Select (n => n.ToUpper()) // Locally scoped n
هر نمونهٔ n Scope خصوصی Lambda خودش را دارد. Query Expressionها با Clauseهای let، into، یک from اضافی و join نیز اجازهٔ معرفی Range Variable جدید میدهند. این موارد در «Composition Strategies» صفحهٔ 442 و در فصل 9، بخشهای «Projecting» و «Joining»، بررسی میشوند.
Query Syntax در برابر SQL Syntax
Query Expression از نظر ظاهری شبیه SQL است، اما تفاوتهای بنیادی دارند. LINQ در نهایت یک C# Expression است و قواعد استاندارد C# را دنبال میکند.
برای مثال در LINQ نمیتوانید Variable را پیش از Declaration استفاده کنید، در حالی که در SQL میتوانید Table Alias را در Clause با نام SELECT پیش از تعریف آن در FROM ارجاع دهید. Subquery در LINQ فقط یک C# Expression دیگر است و Syntax ویژهای لازم ندارد؛ Subqueryهای SQL قواعد خاص خود را دارند.
در LINQ داده بهصورت منطقی از چپ به راست در Query جریان دارد؛ در SQL ترتیب از نظر Data Flow ساختارمند نیست. LINQ یک Conveyor Belt یا Pipeline از Operatorهاست که Sequence میگیرند و Sequence میدهند و ترتیب Elementها میتواند مهم باشد؛ SQL شبکهای از Clauseهاست که عمدتاً با Setهای بدون ترتیب کار میکنند.
Query Syntax در برابر Fluent Syntax
هر دو مزایا دارند. Query Syntax برای Queryهایی سادهتر است که یکی از این موارد را درگیر میکنند: Clause با نام let برای معرفی Variable جدید کنار Range Variable؛ یا SelectMany، Join یا GroupJoin که پس از آن به Range Variable بیرونی ارجاع میشود. let در «Composition Strategies» صفحهٔ 442 و Operatorهای دیگر در فصل 9 شرح داده میشوند.
برای استفادهٔ ساده از Where، OrderBy و Select هر دو Syntax مناسباند و انتخاب بیشتر سلیقهای است. برای Query تکOperator، Fluent Syntax کوتاهتر و خلوتتر است.
بسیاری از Operatorها Keyword متناظر در Query Syntax ندارند و باید دستکم بخشی از Query را با Fluent Syntax بنویسید. Operatorهای دارای پشتیبانی Query Syntax عبارتاند از:
Where, Select, SelectMany
OrderBy, ThenBy, OrderByDescending, ThenByDescending
GroupBy, Join, GroupJoin
Queryهای Mixed-Syntax
اگر Query Operator از Query Syntax پشتیبانی نکند، میتوانید Query Syntax و Fluent Syntax را ترکیب کنید. تنها محدودیت این است که هر Component از Query Syntax باید کامل باشد: با from شروع و با select یا group پایان یابد.
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
int matches = (from n in names where n.Contains ("a") select n).Count();
// 3
Query بعدی نخستین Name را بهترتیب الفبایی میگیرد:
string first = (from n in names orderby n select n).First(); // Dick
Mixed Syntax گاهی در Queryهای پیچیده سودمند است. در این مثالهای ساده میتوانیم بدون هزینه فقط Fluent Syntax داشته باشیم:
int matches = names.Where (n => n.Contains ("a")).Count(); // 3
string first = names.OrderBy (n => n).First(); // Dick
در ادامهٔ فصل، هرجا مناسب باشد مفاهیم کلیدی با هر دو Syntax نشان داده میشوند.
اجرای Deferred
ویژگی مهم بیشتر Query Operatorها این است که هنگام ساختهشدن اجرا نمیشوند، بلکه هنگام Enumerate شدن — یعنی زمانی که MoveNext روی Enumerator فراخوانی میشود — اجرا میشوند:
var numbers = new List<int> { 1 };
IEnumerable<int> query = numbers.Select (n => n * 10); // Build query
numbers.Add (2); // Sneak in an extra element
foreach (int n in query)
Console.Write (n + "|"); // 10|20|
عدد اضافهای که بعد از ساخت Query به List وارد کردیم در نتیجه دیده میشود، چون تا اجرای foreach هیچ Filter یا Sortی رخ نمیدهد. این رفتار Deferred Execution یا Lazy Execution نام دارد و شبیه Delegate است:
Action a = () => Console.WriteLine ("Foo");
// We’ve not written anything to the Console yet. Now let’s run it:
a(); // Deferred execution!
همهٔ Standard Query Operatorها Deferred Execution دارند، جز Operatorهایی که یک Element یا Scalar Value برمیگردانند، مانند First یا Count، و Conversion Operatorهای ToArray، ToList، ToDictionary، ToLookup و ToHashSet.
این Operatorها Query را فوراً اجرا میکنند، چون Result Type آنها مکانیزمی برای Deferred Execution ندارد. برای مثال Count یک Integer ساده برمیگرداند که بعداً Enumerate نمیشود:
int matches = numbers.Where (n => n <= 2).Count(); // 1
Deferred Execution مهم است چون ساخت Query را از اجرای Query جدا میکند. بنابراین میتوانید Query را در چند مرحله بسازید و همین ویژگی Queryهای Database را نیز ممکن میسازد.
ارزیابی دوباره
نتیجهٔ دیگر Deferred Execution این است که هر بار Query Deferred را دوباره Enumerate کنید، دوباره ارزیابی میشود:
var numbers = new List<int>() { 1, 2 };
IEnumerable<int> query = numbers.Select (n => n * 10);
foreach (int n in query) Console.Write (n + "|"); // 10|20|
numbers.Clear();
foreach (int n in query) Console.Write (n + "|"); // <nothing>
این Reevaluation گاهی نامطلوب است: ممکن است بخواهید Result را در یک لحظه Freeze یا Cache کنید؛ یا Query از نظر محاسباتی سنگین یا وابسته به Database راهدور باشد و نخواهید بیهوده آن را تکرار کنید.
با Conversion Operatorهایی مثل ToArray یا ToList میتوانید Reevaluation را متوقف کنید. ToArray خروجی را در Array و ToList در List<T> کپی میکند:
var numbers = new List<int>() { 1, 2 };
List<int> timesTen = numbers
.Select (n => n * 10)
.ToList(); // Executes immediately into a List<int>
numbers.Clear();
Console.WriteLine (timesTen.Count); // Still 2
Variableهای Captureشده
اگر Lambdaهای Query، Variableهای بیرونی را Capture کنند، Query مقدار آن Variableها را در زمان اجرای Query در نظر میگیرد:
int[] numbers = { 1, 2 };
int factor = 10;
IEnumerable<int> query = numbers.Select (n => n * factor);
factor = 20;
foreach (int n in query) Console.Write (n + "|"); // 20|40|
این موضوع هنگام ساخت تدریجی Query در Loop با نام for میتواند تله باشد. فرض کنید میخواهیم همهٔ Vowelها را از String حذف کنیم. روش زیر هرچند ناکارآمد است، Result درست میدهد:
IEnumerable<char> query = "Not what you might expect";
query = query.Where (c => c != 'a');
query = query.Where (c => c != 'e');
query = query.Where (c => c != 'i');
query = query.Where (c => c != 'o');
query = query.Where (c => c != 'u');
foreach (char c in query) Console.Write (c); // Nt wht y mght xpct
اگر آن را با for Refactor کنیم، هنگام Enumeration یک IndexOutOfRangeException رخ میدهد:
IEnumerable<char> query = "Not what you might expect";
string vowels = "aeiou";
for (int i = 0; i < vowels.Length; i++)
query = query.Where (c => c != vowels[i]);
foreach (char c in query) Console.Write (c);
دلیل این است که، همانطور که در فصل 4 دیدیم، Compiler Variable تکرار i را چنان Scope میکند که انگار بیرون Loop تعریف شده است؛ پس همهٔ Closureها همان i را Capture میکنند و وقتی Query واقعاً Enumerate میشود، مقدار i برابر 5 است. راهحل این است که Variable Loop را به Variable دیگری درون Block نسبت دهید:
for (int i = 0; i < vowels.Length; i++)
{
char vowel = vowels[i];
query = query.Where (c => c != vowel);
}
در هر Iteration یک Local Variable تازه Capture میشود. راه دیگر، جایگزینکردن for با foreach است:
foreach (char vowel in vowels)
query = query.Where (c => c != vowel);
Deferred Execution چگونه کار میکند
Query Operatorها با برگرداندن Decorator Sequence، Deferred Execution را فراهم میکنند. برخلاف Collection سنتی مثل Array یا Linked List، Decorator Sequence معمولاً Backing Structure مستقلی برای ذخیرهٔ Elementها ندارد؛ Sequence دیگری را که در Runtime میدهید Wrap میکند و وابستگی دائمی به آن دارد. هر زمان از Decorator داده بخواهید، Decorator باید داده را از Sequence ورودیِ Wrapشده درخواست کند.
فراخوانی Where صرفاً Wrapper Decorator را میسازد که Reference به Sequence ورودی، Lambda Expression و Argumentهای دیگر را نگه میدارد. Sequence ورودی تنها هنگام Enumerate شدن Decorator، Enumerate میشود. شکل 8-3 Composition Query زیر را نشان میدهد:
IEnumerable<int> lessThanTen = new int[] { 5, 12, 3 }.Where (n => n < 10);
شکل 8-3 — Decorator SequenceArray ورودی: 5، 12، 3؛ Predicate: n < 10؛ خروجی با نام lessThanTen.
" alt="شکل 8-3: Decorator Sequence"/>شکل 8-3 — Decorator Sequence؛ Where یک Array ورودی و Predicate را Wrap میکند.وقتی lessThanTen را Enumerate میکنید، در عمل Array را از طریق Decorator با نام Where Query میکنید.
اگر بخواهید Query Operator خودتان را بنویسید، پیادهسازی Decorator Sequence با Iterator در C# آسان است. نمونهٔ Select سفارشی:
public static IEnumerable<TResult> MySelect<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
foreach (TSource element in source)
yield return selector (element);
}
بهدلیل Statement با نام yield return، این Method یک Iterator است و از نظر Functionality میانبری برای ساخت Classی مانند SelectSequence است:
public static IEnumerable<TResult> MySelect<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
return new SelectSequence (source, selector);
}
SelectSequence Classی است که Compiler مینویسد و Enumerator آن Logic موجود در Iterator Method را Encapsulate میکند. بنابراین وقتی Operatorی مثل Select یا Where را فراخوانی میکنید، در اصل فقط یک Enumerable Class میسازید که Sequence ورودی را Decorate میکند.
زنجیرهکردن Decoratorها
زنجیرهکردن Query Operatorها لایههایی از Decorator ایجاد میکند:
IEnumerable<int> query = new int[] { 5, 12, 3 }.Where (n => n < 10)
.OrderBy (n => n)
.Select (n => n * 10);
هر Query Operator Decorator جدیدی میسازد که Sequence قبلی را Wrap میکند، شبیه عروسکهای تودرتوی روسی. شکل 8-4 Object Model این Query را نشان میدهد. توجه کنید که Object Model پیش از هر Enumeration بهطور کامل ساخته میشود.
شکل 8-4 — لایههای Decorator Sequenceبرچسبها: Array، Where decorator، OrderBy decorator، Select decorator و Lambda expressionهای predicate/keySelector/selector.
" alt="شکل 8-4: Sequenceهای Decorator لایهای"/>شکل 8-4 — Layered Decorator Sequences؛ Array درون Where، سپس OrderBy و Select قرار میگیرد.با Enumerate کردن query، Array اصلی را پس از عبور از Chainی از Decoratorها Query میکنید.
شکل 8-5 همان Composition را با Syntax زبان UML نشان میدهد. Decorator با نام Select به OrderBy، آن به Where و آن به Array Reference دارد. یکی از ویژگیهای Deferred Execution این است که اگر Query را مرحلهای بسازید، دقیقاً همان Object Model ایجاد میشود:
IEnumerable<int>
source = new int[] { 5, 12, 3 },
filtered = source .Where (n => n < 10),
sorted = filtered .OrderBy (n => n),
query = sorted .Select (n => n * 10);
شکل 8-5 — ترکیب UML برای Decoratorهاهر Decorator Source و Delegate مربوط به خود را نگه میدارد و Query به Decorator بیرونی اشاره میکند.
" alt="شکل 8-5: UML Decorator Composition"/>شکل 8-5 — Composition Decoratorها در UML.Queryها چگونه اجرا میشوند
Enumerate کردن Query قبلی چنین خروجی میدهد:
foreach (int n in query) Console.WriteLine (n);
30
50
در پشت صحنه، foreach روی Decorator با نام Select — آخرین یا بیرونیترین Operator — GetEnumerator را فراخوانی میکند و همهچیز آغاز میشود. نتیجه Chainی از Enumeratorهاست که ساختارش Mirror همان Chain Decorator Sequenceهاست. شکل 8-6 جریان اجرا را هنگام پیشروی Enumeration نشان میدهد.
شکل 8-6 — اجرای یک Query محلیConsumer با درخواست Next زنجیره را فعال میکند و داده در جهت معکوس درخواستها به سمت Consumer بازمیگردد.
" alt="شکل 8-6: اجرای Local Query"/>شکل 8-6 — اجرای Local Query؛ Consumer داده را بهصورت Pull از Select، OrderBy، Where و Array درخواست میکند.در ابتدای فصل Query را به خط تولید تسمهنقاله تشبیه کردیم. LINQ در واقع یک خط تولید Lazy است: Conveyor Beltها فقط بر حسب Demand حرکت میکنند. ساخت Query، خط تولیدی کاملاً آماده میسازد ولی هنوز چیزی حرکت نمیکند. وقتی Consumer Element میخواهد — یعنی Query را Enumerate میکند — راستترین Belt فعال میشود و در صورت نیاز بقیه را نیز وادار به حرکت میکند. LINQ از مدل Demand-driven Pull استفاده میکند نه Supply-driven Push. این ویژگی برای مقیاسپذیری LINQ به Query کردن Databaseهای SQL مهم است.
Subqueryها
Subquery، Queryای است که داخل Lambda Expression یک Query دیگر قرار دارد. مثال زیر Musicians را بر اساس Last Name مرتب میکند:
string[] musos =
{ "David Gilmour", "Roger Waters", "Rick Wright", "Nick Mason" };
IEnumerable<string> query = musos.OrderBy (m => m.Split().Last());
m.Split هر String را به Collectionی از Wordها تبدیل میکند و سپس Last روی آن فراخوانی میشود. m.Split().Last Subquery است و query به Query بیرونی اشاره دارد.
Subquery مجاز است چون در سمت راست Lambda میتوانید هر C# Expression معتبر را قرار دهید؛ Subquery فقط یک C# Expression دیگر است. بنابراین قواعد آن از قواعد Lambda Expression و رفتار عمومی Query Operatorها ناشی میشود.
Subquery Scope خصوصی در Expression محاطکننده دارد و میتواند Parameterهای Lambda بیرونی یا Range Variableهای Query Expression بیرونی را Reference کند. مثال بعدی Stringهایی را میگیرد که Length آنها با کوتاهترین String برابر است:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> outerQuery = names
.Where (n => n.Length == names.OrderBy (n2 => n2.Length)
.Select (n2 => n2.Length).First());
// Tom, Jay
همین Query در Query Expression:
IEnumerable<string> outerQuery =
from n in names
where n.Length ==
(from n2 in names orderby n2.Length select n2.Length).First()
select n;
چون Range Variable بیرونی n در Scope Subquery است، نمیتوانیم دوباره n را Range Variable Subquery بگذاریم. Subquery هر بار که Lambda محاطکننده ارزیابی شود، اجرا میشود؛ یعنی به درخواست Query بیرونی. میتوان گفت اجرا از بیرون به درون پیش میرود. Local Queryها این مدل را واقعاً و Interpreted Queryها مانند Database Query آن را مفهومی دنبال میکنند.
Subquery هر زمان برای تغذیهٔ Outer Query لازم باشد اجرا میشود. شکلهای 8-7 و 8-8 نشان میدهند که Subquery مثال — تسمهٔ بالایی در شکل 8-7 — در هر Iteration حلقهٔ بیرونی یک بار اجرا میشود.
شکل 8-7 — ترکیب SubquerySubquery شامل OrderBy، Select و First است؛ Outer Query شامل Where و Select.
" alt="شکل 8-7: Subquery Composition"/>شکل 8-7 — Composition یک Subquery و Outer Query.میتوان Subquery قبلی را کوتاهتر نوشت:
IEnumerable<string> query =
from n in names
where n.Length == names.OrderBy (n2 => n2.Length).First().Length
select n;
با Aggregation Function با نام Min سادهتر هم میشود:
IEnumerable<string> query =
from n in names
where n.Length == names.Min (n2 => n2.Length)
select n;
در «Interpreted Queries» صفحهٔ 448 میبینیم منابع راهدور مانند Tableهای SQL چگونه Query میشوند. این مثال برای Database مناسب است، چون Query بهصورت یک Unit پردازش میشود و فقط یک Round Trip به Database Server لازم دارد. اما برای Local Collection ناکارآمد است، چون Subquery در هر Outer Loop Iteration دوباره محاسبه میشود. با اجرای جداگانهٔ Subquery این مشکل رفع میشود:
int shortest = names.Min (n => n.Length);
IEnumerable<string> query = from n in names
where n.Length == shortest
select n;
شکل 8-8 — ترکیب UML برای SubquerySubquery در Lambda مربوط به Where در Query بیرونی فراخوانی میشود.
" alt="شکل 8-8: UML Subquery Composition"/>شکل 8-8 — Composition Subquery در UML.Subqueryها و Deferred Execution
وجود Element یا Aggregation Operator مثل First یا Count در Subquery، Outer Query را وادار به اجرای فوری نمیکند؛ Deferred Execution برای Outer Query همچنان برقرار است، چون Subquery بهطور غیرمستقیم فراخوانی میشود: با Delegate در Local Query و با Expression Tree در Interpreted Query.
حالت جالب زمانی است که Subquery را داخل Expression با نام Select بگذارید. در Local Query در واقع Sequenceای از Queryها را Project میکنید که هرکدام خودشان Deferred Execution دارند. این اثر معمولاً شفاف است و میتواند Efficiency را بیشتر کند. Select Subqueryها در فصل 9 دوباره با جزئیات بررسی میشوند.
راهبردهای Composition
سه Strategy برای ساخت Queryهای پیچیدهتر داریم: ساخت تدریجی Query؛ استفاده از Keyword با نام into؛ و Wrap کردن Queryها. هر سه Strategyهای Chaining هستند و در Runtime Query یکسانی تولید میکنند.
ساخت تدریجی Query
در آغاز فصل دیدیم Fluent Query را میتوان مرحلهای ساخت:
var filtered = names .Where (n => n.Contains ("a"));
var sorted = filtered .OrderBy (n => n);
var query = sorted .Select (n => n.ToUpper());
چون هر Query Operator یک Decorator Sequence برمیگرداند، Result همان Chain یا لایهبندی Decoratorهایی است که با Query تکExpression میگرفتیم. مزیتهای احتمالی ساخت تدریجی این است که نوشتن Query آسانتر میشود و میتوانید Operatorها را شرطی اضافه کنید. برای مثال:
if (includeFilter) query = query.Where (...)
از این عبارت کارآمدتر است:
query = query.Where (n => !includeFilter || <expression>)
چون اگر includeFilter برابر false باشد، Operator اضافی ساخته نمیشود.
روش Progressive در Query Comprehension نیز مفید است. فرض کنید میخواهیم همهٔ Vowelها را از Listی از Nameها حذف کنیم و سپس مواردی را که هنوز Length آنها بیشتر از دو است بهترتیب الفبایی نمایش دهیم. در Fluent Syntax، با Project کردن پیش از Filter:
IEnumerable<string> query = names
.Select (n => n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", ""))
.Where (n => n.Length > 2)
.OrderBy (n => n);
// Dck
// Hrry
// Mry
ترجمهٔ مستقیم این Logic به Query Expression مشکل دارد، چون Clause با نام select باید بعد از where و orderby بیاید. اگر Project را آخر انجام دهیم Result فرق میکند:
IEnumerable<string> query =
from n in names
where n.Length > 2
orderby n
select n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "");
// Dck
// Hrry
// Jy
// Mry
// Tm
راههای متعددی برای رسیدن به Result اصلی در Query Syntax وجود دارد. نخست Progressive Query:
IEnumerable<string> query =
from n in names
select n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "");
query = from n in query where n.Length > 2 orderby n select n;
// Dck
// Hrry
// Mry
Keyword با نام into
into اجازه میدهد Query را بعد از Projection «ادامه» دهید و میانبری برای Progressive Query است:
IEnumerable<string> query =
from n in names
select n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
into noVowel
where noVowel.Length > 2 orderby noVowel select noVowel;
تنها جایی که میتوانید into را استفاده کنید بعد از Clause با نام select یا group است. into از دید Query Expression Query را «Restart» میکند و اجازه میدهد where، orderby و select تازه وارد کنید. اما در Fluent Form نهایی همهاش یک Query است، بنابراین into ذاتاً Performance Penalty ندارد. معادل آن در Fluent Syntax فقط Chain طولانیتری از Operatorهاست.
قواعد Scope
بعد از into همهٔ Range Variableهای قبلی از Scope خارج میشوند. کد زیر Compile نمیشود:
var query =
from n1 in names
select n1.ToUpper()
into n2 // Only n2 is visible from here on.
where n1.Contains ("x") // Illegal: n1 is not in scope.
select n2;
Fluent Translation دلیل را نشان میدهد:
var query = names
.Select (n1 => n1.ToUpper())
.Where (n2 => n1.Contains ("x")); // Error: n1 no longer in scope
وقتی Filter با نام Where اجرا میشود، Name اصلی n1 از دست رفته و Sequence ورودی فقط Nameهای Uppercase را دارد؛ پس دیگر نمیتوان براساس n1 Filter کرد.
Wrap کردن Queryها
Queryی که مرحلهای ساخته شده میتواند با Wrap کردن یک Query دور دیگری به یک Statement تبدیل شود. بهطور کلی:
var tempQuery = tempQueryExpr
var finalQuery = from ... in tempQuery ...
قابل بازنویسی است به:
var finalQuery = from ... in (tempQueryExpr)
Wrapping از نظر Semantics با Progressive Query یا into بدون Variable میانی یکسان است و در نهایت Chain خطی Query Operatorها میسازد.
Query قبلی در Wrapped Form چنین است:
IEnumerable<string> query =
from n1 in
(
from n2 in names
select n2.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
)
where n1.Length > 2 orderby n1 select n1;
و با Fluent Syntax همان Chain خطی قبلی میشود:
IEnumerable<string> query = names
.Select (n => n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", ""))
.Where (n => n.Length > 2)
.OrderBy (n => n);
Compiler .Select(n => n) نهایی را تولید نمیکند چون Redundant است. Wrapped Query ممکن است شبیه Subquery بهنظر برسد، چون هر دو Inner و Outer Query دارند. اما Fluent Translation نشان میدهد Wrapping فقط Strategy برای Chaining متوالی Operatorهاست؛ در حالی که Subquery یک Inner Query را داخل Lambda Expression Query دیگری Embed میکند. با تشبیه Conveyor Belt، Inner Query در Wrapping همان Beltهای قبلی است، ولی Subquery بالای Belt قرار میگیرد و توسط Lambda Worker در صورت نیاز فعال میشود.
راهبردهای Projection
Object Initializerها
تا اینجا Clauseهای select ما Scalar Typeها را Project کردهاند. با Object Initializerهای C# میتوانید به Typeهای پیچیدهتر Project کنید. فرض کنید میخواهیم در مرحلهٔ اول Vowelها را از List Nameها حذف کنیم ولی نسخهٔ اصلی را هم برای Query بعدی نگه داریم؛ Class کمکی:
class TempProjectionItem
{
public string Original; // Original name
public string Vowelless; // Vowel-stripped name
}
سپس با Object Initializer به آن Project میکنیم:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<TempProjectionItem> temp =
from n in names
select new TempProjectionItem
{
Original = n,
Vowelless = n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
};
Result از Type با نام IEnumerable<TempProjectionItem> است و میتوانیم بعداً Query کنیم:
IEnumerable<string> query = from item in temp
where item.Vowelless.Length > 2
select item.Original;
// Dick
// Harry
// Mary
Anonymous Typeها
Anonymous Typeها اجازه میدهند Result میانی را بدون نوشتن Class ویژه Structure بدهید؛ بنابراین Class با نام TempProjectionItem حذف میشود:
var intermediate = from n in names
select new
{
Original = n,
Vowelless = n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
};
IEnumerable<string> query = from item in intermediate
where item.Vowelless.Length > 2
select item.Original;
Result همان مثال قبل است، بدون Class یکبارمصرف؛ Compiler یک Class موقت با Fieldهای متناظر با Structure Projection میسازد. Type Query میانی چیزی شبیه IEnumerable<random-compiler-generated-name> است و تنها راه Declaration Variable این Type استفاده از var است؛ اینجا var فقط برای کاهش شلوغی نیست، ضروری است.
کل Query را میتوان با into کوتاهتر نوشت.
var query = from n in names
select new
{
Original = n,
Vowelless = n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
}
into temp
where temp.Vowelless.Length > 2
select temp.Original;
Query Expression برای این نوع Query میانبری دارد: Keyword با نام let.
Keyword با نام let
let یک Variable جدید را در کنار Range Variable معرفی میکند. با آن میتوان Queryای نوشت که Stringهایی را استخراج کند که Length آنها پس از حذف Vowelها بیشتر از دو Character است:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query =
from n in names
let vowelless = n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
where vowelless.Length > 2
orderby vowelless
select n; // Thanks to let, n is still in scope.
Compiler Clause با نام let را با Projection به یک Anonymous Type موقت Resolve میکند که هم Range Variable و هم Expression Variable جدید را دارد؛ یعنی این Query را به مثال قبلی ترجمه میکند.
let دو کار انجام میدهد: Elementهای جدید را کنار Elementهای موجود Project میکند؛ و اجازه میدهد یک Expression چند بار در Query استفاده شود بدون اینکه دوباره نوشته شود. در این مثال مزیت مهم آن است که Clause با نام select میتواند هم Name اصلی n و هم نسخهٔ بدون Vowel یعنی vowelless را Project کند.
میتوانید هر تعداد Statement با نام let قبل یا بعد از where داشته باشید. یک let میتواند به Variableهای معرفیشده در letهای قبلی Reference دهد، با رعایت Boundaryهای ناشی از into. let همهٔ Variableهای موجود را بهطور شفاف دوباره Project میکند. Expression داخل let لازم نیست Scalar باشد؛ گاهی مفید است که Subsequence برگرداند.