LINQ之路 8: 解釋查詢(Interpreted Queries)
LINQ提供了兩個平行的架構:針對本地對象集合的本地查詢(local queries),以及針對遠程數據源的解釋查詢(Interpreted queries)。
在討論LINQ to SQL等具體技術之前,我們有必要先對這兩種架構進行了解和學習,只有在完全理解了他們的特點和原理后,才能夠在LINQ to SQL等的學習過程中做到知其然且知其所以然,才能充分利用本地查詢和解釋查詢的各自優勢,寫出高效正確的LINQ查詢。本篇目的就是試圖對解釋查詢的工作方式和實現原理進行剖析。
簡單回憶一下之前我們討論的本地查詢架構,它用來操作實現了IEnumerable<T>的對象集合。本地查詢對應Enumerable類的查詢運算符,返回裝飾sequence以支持延遲執行。在創建本地查詢時提供的lambda表達式最終會生成對應IL代碼,就像其它C#方法那樣。
而解釋查詢用來操作實現了IQueryable<T>的sequence,并對應Queryable類中的查詢運算符,這些運算符會生成運行時能被檢測的表達式樹,相應的LINQ Provider通過分析表達式樹最終得到查詢結果。
當前,.NET Framework提供了IQueryable<T>的兩個具體實現:LINQ to SQL、Entity Framework(EF)。這些LINQ-to-db技術對LINQ查詢的支持非常類似,所以我們寫出的查詢一般會同時適用于LINQ to SQL和EF。
IQueryable<T>泛型借口繼承自IEnumerable<T>,并添加了新的方法用來構造表達式樹。通常來講,系統會間接而透明的調用他們,我們可以不用理會。
下面這個簡單的示例假設我們在SQL Server中創建了Customer表并填充了幾行數據:
create table Customer
(
ID int not null primary key,
Name varchar(30)
)
insert Customer values(1, 'Tom Chen')
insert Customer values(2, 'Vincent Ke')
insert Customer values(3, 'Alan' )
insert Customer values(4, 'Jay Heyssi')
insert Customer values(5, 'Daisy Liu')
現在,我們可以創建LINQ query來查詢包含字母”a”的Employee了:
[Table]
public class Customer
{
[Column(IsPrimaryKey = true)]
public int ID;
[Column]
public string Name;
}
class Test
{
static void Main()
{
DataContext dataContext = new DataContext("connection string");
Table<Customer> customers = dataContext.GetTable<Customer>();
IQueryable<string> query = from c in customers
where c.Name.Contains("a")
orderby c.Name.Length
select c.Name.ToUpper();
foreach (string name in query) Console.WriteLine(name);
}
}
LINQ to SQL把上面的查詢翻譯成如下的SQL語句:
SELECT UPPER([to].[Name]) AS [value]
FROM [Employee] AS [to]
WHERE [to].[Name] LIKE @p0
ORDER BY LEN([to].[Name])
最終得到如下結果:
ALAN
DAISY LIU
JAY HEYSSI
解釋查詢的工作方式
讓我們來仔細的了解一下上面query的運行過程:首先,編譯器會把查詢表達式轉換成方法語法,這一點和本地查詢完全一致,轉換后的查詢如下:
IQueryable<string> query = customers
.Where(n => n.Name.Contains("a"))
.OrderBy(n => n.Name.Length)
.Select(n => n.Name.ToUpper());
接下來,編譯器將會解析查詢操作方法。這里就是本地查詢和解釋查詢不同的地方了,解釋查詢將會使用Queryable類中的查詢運算符而不是Enumerable類。因為employees的類型是Table<>,它實現了IQueryable<T>接口(IQueryable<T>進而繼承自IEnumerable<T>)。編譯器為employees.Where選擇了Queryable類中的擴展方法是因為它的簽名具有更加確切的類型匹配:
public static IQueryable<TSource> Where<TSource>(
this IQueryable<TSource> source, Expression <Func<TSource, bool>> predicate)
Queryable.Where方法接受的predicate參數類型為Expression <Func<TSource, bool>>型,它指示編譯器將提供的lambda表達式(e => e.Name.Contains(“a”))翻譯成一個表達式樹而不是一個編譯的委托方法。表達式樹是一個基于System.Linq.Expressions中類型的對象模型,需要知道的是,表達式樹并不包含代碼的執行結果,而只是代碼的數據表現形式。并且表達式樹可以在運行時被檢測,因此LINQ to SQL可以將其翻譯成SQL查詢語句。
因為Queryable.Where方法也是返回IQueryable<T>,所以我們可以像本地查詢那樣在后面鏈接其它查詢運算符,如OrderBy、Select等,他們的處理方式與Where一樣。這樣,查詢的最終結果是一個描述了整個查詢的表達式樹。
表達式樹和Lambda表達式的同像性(Homoiconicity)
那么表達式樹是如何生成的呢?答案是C#語言(從3.0開始)為lambda表達式提供的同像性功能,該特性通常存在于函數式編程語言LISP中,這意味著lambda表達式使用相同的語法形式來表示代碼(IL指令)和數據表示(表達式樹)。比如下面的代碼,我們無法確定編譯器如何翻譯該lambda表達式:
Calculate(x => x + 1, 42)
我們只有在查看接收該lambda表達式的參數聲明后,才能知道編譯器的處理方式。這里有兩種可能:第一種就是委托參數,如下所示:
// 對于函數調用
Calculate(x => x + 1, 42);
// 如果參數為委托類型
int Calculate(Func<int, int> op, int arg);
// 這時編譯器會為lambda表達式生成等價的匿名方法:
Calculate(delegate (int x) { return x + 1; }, 42);
//我們在Calculate方法里面可以通過委托調用語法來調用該匿名方法:
int Calculate(Func<int, int> op, int arg)
{
return op(arg);
}
然而,如果lambda表達式賦予的不是一個委托類型而是一個Expression<TDelegate>,編譯器將會為lambda表達式生成表達式樹,如下所示:
// 對于函數調用
Calculate(x => x + 1, 42);
// 如果參數為Expression<Func<int, int>>類型
int Calculate(Expression<Func<int, int>> op, int arg);
// 編譯器將會為之生成如下等價代碼
var x = Expression.Parameter(typeof(int), “x”);
var f = Expression.Lambda<Func<int, int>>(
Expression.Add(
x,
Expression.Constant(1)
),
x
);
Calculate(f, 42);
下面這幅圖更加直觀的比較了lambda表達式的兩種處理方式:

我們已經看到了lambda表達式可以被翻譯成一個表達式樹,那么他們有何作用呢?由于表達式樹也是一個“普通”的對象,所以我們可以通過該對象的方法和屬性來進行了解,下面是使用表達式樹時的智能提示:

這些表達式樹的成員讓我們能夠分析他們代表的代碼以及用戶的意圖。LINQ Provider最終將之轉換成領域專用的查詢語言比如SQL,被轉換的SQL被發送到相應的數據庫服務器,得到LINQ查詢的結果。
解釋查詢的執行
與本地查詢一樣,解釋查詢也是延遲執行的。這意味著直到我們真正遍歷查詢結果時,相應的SQL語句才會生成。并且,如果多次遍歷查詢會導致對數據庫的多次查詢,所以要注意由此帶來的性能問題。比如:
DataContext context = new DataContext("connection string"); // 謝謝園友 A_明~堅持 提供了此示例
context.Log = new StreamWriter(@"D:\Documents\Blog\Linq2Sql.log", true) { AutoFlush = true }; // Append to & Auto Flush the log file
var query = from n in context.GetTable<Purchase>() select n.Price;
int count = query.Count(); // 上面的查詢第一次被執行
decimal average = query.Average(); // 第二次
decimal sum = query.Sum(); // 第三次
解釋查詢不同于本地查詢的地方在于它的執行方式。當我們開始枚舉一個解釋查詢時,最外層的sequence會運行一個程序來遍歷整個表達式樹,并將其處理成一個單元。在我們的例子中,LINQ to SQL將表達式樹翻譯成SQL查詢語句,然后運行并返回結果序列。而本地查詢會針對每個查詢運算符調用相應的擴展方法,形成一個執行鏈。
盡管我們可以非常方便的使用迭代器編寫自己的擴展方法來對本地查詢進行擴展,但解釋查詢的執行方式使得我們很難對IQueryable<>進行擴展,因為各個LINQ Provider對表達式樹的處理是不一樣的,這樣的好處是Queryable的一系列方法定義了查詢遠程數據源的標準詞匯。解釋查詢的另一個問題是:一個IQueryable Provider可能無法處理某些查詢,甚至是對標準查詢運算符也是如此。例如LINQ to SQL和EF都會受到目標數據庫服務器的限制,一個例子是SQL Server不支持正則表達式的使用。
組合使用解釋查詢和本地查詢
一個LINQ查詢可以同時包含解釋查詢和本地查詢運算符,通常,我們先使用解釋查詢獲取數據,然后使用本地查詢做進一步的處理,這個模式非常適用于LINQ-to-database查詢。
比如針對上面的示例,我們定義了如下的擴展方法來解析姓名中的FirstName和LastName:
public class SplittedName
{
public string FirstName;
public string LastName;
}
public static IEnumerable<SplittedName> SplitName(this IEnumerable<string> source)
{
foreach (string name in source)
{
int index = name.LastIndexOf(" ");
if (index > 0)
{
yield return new SplittedName { FirstName = name.Substring(0, index), LastName = name.Substring(index + 1) };
}
else
{
yield return new SplittedName { FirstName = name, LastName = "" };
}
}
}
我們可以使用上面的擴展方法來組合LINQ to SQL和本地查詢運算符:
static void TestInterpretedQuery()
{
DataContext dataContext = new DataContext("Data Source=localhost; Initial Catalog=test; Integrated Security=SSPI;");
Table<Customer> customers = dataContext.GetTable<Customer>();
IEnumerable<string> query = customers
.Select(n => n.Name.ToUpper())
.OrderBy(n => n)
.SplitName() // 從這里開始就是本地查詢了
.Select((n, i) => "First Name " + i.ToString() + " = " + n.FirstName);
foreach (string element in query) Console.WriteLine(element);
}
因為customer是實現了IQueryable<T>的類型,所以customer.Select對應Queryable.Select并返回IQueryable<T>類型。直到遇到自定義的運算符SplitName,因為它只有針對IEnumerable<>的版本,所以它被解析到我們自定義的本地SplitName,從而將一個解釋查詢包裝在本地查詢里。對LINQ to SQL來講,最終生成的SQL語句如下:
SELECT UPPER(Name) FROM Customer ORDER BY UPPER(Name)
剩下的工作則在本地完成,LINQ to Objects接管了余下的工作。換句話說,我們創建了一個本地查詢,它的數據源來自一個解釋查詢。
AsEnumerable
Enumerable.AsEnumerable是所有查詢運算符中最簡單的一個,它的完整定義如下:
public static IEnumerable<TSource> AsEnumerable<TSource>(
this IEnumerable<TSource> source)
{
return source;
}
它的目的是將IQueryable<T> sequence轉換成一個IEnumerable,這將會強制將后續的查詢運算符綁定到Enumerable類而不是Queryable,意味著其后的執行都將會是在本地執行的。
舉個例子, 假設我們SQL Server中有一個Article表,我們想使用LINQ to SQL列出所有Topic等于LINQ并且Abstract小于100個字符的文章,我們會寫出如下的查詢:
Regex wordCounter = new Regex(@"\b(\w|[-'])+\b");
var query = articles
.Where(article => article.Topic == "LINQ" &&
wordCounter.Matches(article.Abstract).Count < 100);
但上面的查詢并不能成功運行,因為SQL SERVER并不支持正則表達式。為了解決這個問題,我們可以將其分成2步查詢:
Regex wordCounter = new Regex(@"\b(\w|[-'])+\b");
IEnumerable<Article> sqlQuery = articles.Where(article => article.Topic == "LINQ"); //注意返回類型為IEnumerable<>
IEnumerable<Article> localQuery = sqlQuery // 因為sqlQuery類型是IEnumerable<>,所以這是一個本地查詢
.Where(article => wordCounter.Matches(article.Abstract).Count < 100);
通過使用AsEnumerable,我們可以將上面的兩個查詢合二為一:
Regex wordCounter = new Regex(@"\b(\w|[-'])+\b");
var sqlQuery = articles
.Where(article => article.Topic == "LINQ")
.AsEnumerable() // 把IQueryable<>轉換成IEnumerable<>
.Where(article => wordCounter.Matches(article.Abstract).Count < 100);
除了AsEnumerable,我們還可以使用ToArray或者ToList來把一個解釋查詢轉換成本地查詢,而AsEnumerable的好處就是延遲執行,并且不會創建任何的存儲結構。

浙公網安備 33010602011771號