探索 LINQ 查询运算符和延迟执行计划






4.12/5 (10投票s)
LINQ 概述:LINQ 如何利用不同的执行方式,C# 为查询数据集和数据表(LINQ to dataset)提供了哪些支持等。
引言
在本文中,我将涵盖以下主题
- LINQ 如何利用延迟执行,以及它能为开发人员带来哪些好处和影响。
- 定义哪些运算符是延迟的,哪些运算符会强制立即执行查询。
- 如何将 LINQ 用于不支持
IEnumerable<T>;
的旧集合,C# 中提供了不同的查询语法来查询数据。 - 什么是表达式树?
- 通过示例深入了解部分方法。
- 如何从方法中返回投影。
- C# 为查询数据集和数据表(LINQ to dataset)提供了哪些支持。
- 匿名类型的影响。
- 查询运算符,例如
SelectMany
、Concat
、OrderBy
、AsEnumerable
、defaultifempty
,以及如何在 LINQ to SQL 中执行left join
。
延迟和立即执行
默认情况下,LINQ 查询表达式不会被评估,直到你遍历其内容。这种延迟执行的好处在于,相同的 LINQ 查询可以被多次执行,从而获得最新的结果。然而,有时由于每次执行查询的性能原因,延迟执行行为可能无法接受。为了防止这种行为,.NET Framework 定义了许多扩展方法,例如 ToArray<T>()
、ToDictionary<TSource
、TKey>()
和 ToList<T>()
,它们将结果捕获在强类型容器中,这些容器不会反映原始集合中的新更改。下面是一个示例
public static void DifferedAndImmediateExecution()
{
int[] numbers = { 1, 2, 3, 4, 5, 6 };
var lessthan4 = from n in numbers
where n < 4
select n;
Console.WriteLine("Original array with items less than 4");
foreach (int n in lessthan4)
{
Console.WriteLine("{0} < 4", n);
}
//differed execution
numbers[2] = 7;//assigning new value
Console.WriteLine(
"Results based on differed execution after change number[2] = 7");
foreach (int n in lessthan4)
{
Console.WriteLine("{0} < 4", n);
}
//immediate execution
numbers = new int[] { 1, 2, 3, 4, 5, 6 };
var lessthan4immediate = numbers.Where(n => n < 4).Select(n => n).ToArray<int>();
numbers[2] = 7;//assigning new value
Console.WriteLine(
"Results based on immediate execution after change number[2] = 7");
foreach (int n in lessthan4immediate)
{
Console.WriteLine("{0} < 4", n);
}
}

你应该注意到,新的更改会反映在延迟查询中,而新的更改不会反映在立即执行中,因为结果被缓存到了强类型容器中。在示例中,查询似乎在 lessthan4
被初始化时发生。然而,查询直到你遍历集合时才运行。我观察到的延迟执行的一个缺点是,由于查询的枚举可能发生在代码的更深处,因此 bug 更难追踪。异常也可能在那里抛出。但是,你可能会忘记原始查询才是问题所在。因此,如果延迟执行不是你想要的行为,请使用非延迟的扩展方法,如 ToArray
、ToList
、ToDictionary
或 ToLookup
来强制执行查询。让我们看一个例子
public static void DelayedExceptions()
{
string[] blob = {"Resharper", "is", "great"};
IEnumerable<string> sentence = blob.Select(s => s.Substring(0, 5));
foreach (string word in sentence)
{
Console.WriteLine(word);
}
}
你会注意到,当你在第二次循环时,会抛出 rangeoutofboundexception
异常,而不是在声明查询时抛出异常。以下是按顺序排列的运算符列表,显示了哪些是延迟的,哪些不是。
运算符 | 目的 | Deferred |
Distinct |
Set | 是 |
ElementAt |
元素 | |
ElementAtOrDefault |
元素 | 是 |
Empty |
生成 | 是 |
Except |
Set | 是 |
First |
元素 | |
FirstORDefault |
元素 | |
GroupBy |
分组 | 是 |
GroupJOin |
Join | 是 |
Intersect |
Set | 是 |
Join |
Join | 是 |
Last |
元素 | |
LastOrDefault |
元素 | |
LongCount |
Aggregate | |
最大值 |
Aggregate | |
最小值 |
Aggregate | |
OfType |
转换 | 是 |
OrderBy |
排序 | 是 |
OrderByDescending |
排序 | 是 |
Range |
生成 | 是 |
Reverse |
排序 | 是 |
Repeat |
生成 | 是 |
Select |
Projection | 是 |
SelectMany |
Projection | 是 |
SequenceEqual |
相等 | |
Single |
元素 | |
SingleOrDefault |
元素 | |
Skip |
分区 (Partitioning) | 是 |
SkipWhile |
分区 (Partitioning) | 是 |
Sum |
Aggregate | |
Take |
分区 (Partitioning) | 是 |
TakeWhile |
分区 (Partitioning) | 是 |
ThenBy |
排序 | 是 |
ThenByDescending |
排序 | 是 |
ToDictionary |
转换 | |
ToArray |
转换 | |
ToList |
转换 | |
ToLookup |
转换 | |
Union |
Set | 是 |
其中 |
限制 | 是 |
大多数扩展方法都适用于 IEnumerable<T>
。然而,.NET 中存在一些旧集合,如 Collection
和 ArrayList
,它们不实现 IEnumerable<T>
。幸运的是,通过使用泛型 Enumerable.OfType<T>()
或 Cast 运算符,可以遍历非泛型集合。OfType<T>()
是少数不扩展泛型类型并允许你指定类型的扩展方法之一。OfType
的一个常见用途是在构建 LINQ 查询时指定正确的类型,以便获得设计时支持,并且编译器可以检查错误。OfType
的另一个重要用途是过滤数组中的项,排除不属于正确类型的项,例如 ArrayList
。这允许你添加 Book
和 Car
。为了只提取 Book
而不提取 Car
,你可以使用 OfType
来将集合过滤到正确的类型。
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
}
public class Car
{
public string Make { get; set; }
public string Model { get; set; }
}
public static void OfTypeExtesionMethod()
{
ArrayList books = new ArrayList()
{
new Book{Title="ASP.NET 2.0 Website Programming: Problem -
Design - Solution",
Author="Marco Bellinaso"},
new Book{Title="Pro ASP.NET 3.5 in C# 2008, Second Edition",
Author="MacDonald and Mario Szpuszta"},
new Book{Title="ASP.NET 2.0 Unleashed", Author="Stephen Walther"},
new Book{Title="ASP.NET 3.5 Unleashed ", Author="Stephen Walther"},
new Car{Make="Toyota", Model="Corolla"}
};
IEnumerable<Book> bystephen = from book in books.OfType<Book>()
where book.Author == "Stephen Walther"
select book;
foreach (Book book in bystephen)
{
Console.WriteLine("Book {0} by {1}", book.Title, book.Author);
}
}

public static void CastOperator()
{
ArrayList list = new ArrayList();
list.Add(1);
list.Add(2);
list.Add("ASP.NET 3.5 Unleashed");
IEnumerable<int> numbers = list.Cast<int>();
foreach (int number in numbers)
{
Console.WriteLine(number);
}
}
当你运行 cast 运算符时,会得到一个异常,因为 cast 运算符会尝试将集合中的每个元素都转换为指定的类型。如果转换失败,它会抛出异常。相比之下,OfType
运算符会返回可以转换为正确类型的对象,并跳过集合中其余的项。
查询语法
.NET Framework 3.5 允许使用 LINQ 以多种不同的方式查询数据
- 扩展方法和 Lambda 表达式
- 查询语法
- 泛型委托
- Enumerable 类型
- 表达式树
- 匿名方法
public static void QuerySyntax()
{
int[] numbers = { 1, 2, 3, 4, 5, 6 };
//using query syntax
var usingquerysntax = from n in numbers
where n < 5
select n;
//Extension Methods and Lamda expressions
var usingextensionmethods = numbers.Where(n => n < 5).Select(n => n);
//using IEnumerable<T>
Enumerable.Where(numbers, n => n < 5).Select(n => n);
//using generic delegate
Func<int, bool> where = n => n < 5;
Func<int, int> select = n => n;
var usinggenericdelegate = numbers.Where(where).Select(select);
//using expressions
Expression<Func<int, bool>> where1 = n => n < 5;
Expression<Func<int, int>> select1 = n => n;
var usingexpression = numbers.Where(where).Select(select);
//anonymous methods
var usinganonymousmethods = numbers.Where(delegate(int n) { return n < 5; })
.Select(delegate(int n) { return n; });
}
上面的代码会得到相同的结果。需要注意的关键点是,查询语法只是执行扩展方法的一种更优雅的方式。凡是需要委托的地方,都可以传递 lambda、泛型委托或匿名方法。
表达式树
表达式树是查询的 lambda 表达式的高效数据表示。就 LINQ to SQL 而言,表达式树会被转换为 SQL,以确保过滤和排序在服务器上执行,而不是将数据下载到本地并在 .NET 对象上应用过滤和排序。对于 lambda 表达式,编译器可以生成 IL 代码或表达式树。如果运算符接受方法委托,则会发出 IL 代码。如果运算符接受方法委托的表达式,则会返回表达式树。让我们看看 Select
的两种不同实现:一种定义在 System.Linq.Enumerable
中,另一种定义在 System.Linq.Queryable
中。
public static IEnumerable<TResult> Select<TSource,
TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector);
public static IQueryable<TResult> Select<TSource,
TResult>(this IQueryable<TSource> source,
Expression<Func<TSource, TResult>> selector);
正如你所见,LINQ to objects 生成 IL 代码,因为它接收一个泛型委托,而 LINQ to SQL 实现接收一个表达式树,该表达式树会被转换为 SQL 以由 SQL Server 执行。
部分方法
通过定义一个 partial
方法,可以在一个文件中原型化一个方法,并在另一个文件中实现它。它们相当于 C# 中的轻量级事件。基本上,你可以在一个 partial
类中定义一个原型,并在另一个 partial
类中定义实际实现。如果找不到实现,则编译的程序集中不会发出 IL 代码。以下是 partial
方法的重要事实
Partial
方法只能在partial
类中定义。Partial
方法必须返回 void。Partial
方法可以是static
或instance
。Partial
方法可以有参数,但不能有out
修饰符。Partial
方法始终是private
。
public partial class PartialTest
{
partial void Partial(int total);
public void TestPartial()
{
int total = 0;
Console.WriteLine("Total before calling Partial method {0}", total);
Partial(++total);
Console.WriteLine("Total after calling Partial method {0}", total);
}
}
PartialTest test = new PartialTest();
test.TestPartial();

正如你所见,没有为 partial
方法定义实现。因此,分配给 total 的值等于 0
,这意味着 Partial
方法从未被调用。
从方法返回投影
你可能已经注意到,上面的代码使用了隐式类型变量(使用 var
),它会推断类型。然而,隐式类型不能用于定义参数、返回值或类类型的字段。因此,一种返回数据的方法是停止使用隐式类型,而是返回一个强类型对象,如 IEnumerable<Book>
或 IEnumerable<string>
。然而,在某些情况下,你希望返回投影,而此时你不知道实际的数据类型,并且必须使用 var
关键字。在这种情况下,你可以使用 ToArray<T>()
扩展方法返回标准的 CLR 数组。
public static Array ReturnProjections()
{
Book[] books =
{
new Book{Title="ASP.NET 2.0 Unleashed", Author="Stephen Walther"},
new Book{Title="ASP.NET 3.5 Unleashed ", Author="Stephen Walther"}
};
var projections = from book in books
select new { book.Title };
return projections.ToArray();
}
请注意,我们没有在 ToArray
的返回类型中指定底层类型,因为我们不知道底层类型。
Array books = ReturnProjections();
foreach (object o in books)
{
Console.WriteLine(o);
}
LINQ over Dataset
开箱即用,数据集、数据表和数据视图没有必要的基础设施来在 LINQ 查询中使用。这时 Microsoft 推出了名为 _System.Data.DataSetExtensions.dll_ 的新扩展,其中包括 DataTable
扩展、DataRow
扩展和 TypedTableBasedExtesions
。要使数据表具有 LINQ 感知能力,只需调用 AsEnumerable
扩展,它将返回一个由 DataRows
组成的 EnumerableRowCollection
。另一个非常有用的扩展方法是 DataRowExtensions.Field<T>()
,它允许你在 LINQ 查询中进行安全类型转换,并有助于防止运行时异常。一旦使用 LINQ 查询对数据表应用了过滤器,结果就是一个 EnumerableRowCollection
。为了将结果返回为一个表,我们可以使用结果上的新 copytodatatable
扩展方法。
public static void DataSetExample(DataTable books)
{
var implicittype = from book in books.AsEnumerable()
where book.Field<string>("Author") == "Stephen Walther"
select book;
var datatable = implicittype.CopyToDataTable();
}
匿名类型
C# 3.0 语言包含了动态创建新的未命名类的能力。这种类型的类被称为匿名类型。匿名类型没有名称,由编译器根据实例化对象的初始化来生成。由于该类没有类型名称,因此分配给匿名类型对象的任何变量都必须有一种声明它的方式。这就是新的 C# 3.0 var
关键字的用途。匿名类型允许你使用对象初始化语法即时定义类,并将其分配给 var
关键字。在编译时,C# 编译器将生成一个唯一的类,该类无法从你的代码中访问。所有匿名类都派生自 System.Object
,因此继承了 object
类的所有属性和方法。匿名类提供了重写的 Equals()
、GetHashCode
和 ToString()
方法。匿名 Book 类的 ToString
实现可能如下所示
public override string ToString()
{
StringBuilder builder = new StringBuilder();
builder.Append("{ BookTitle = ");
builder.Append(this.<BookTitle>i__Field);
builder.Append(", BookAuthor = ");
builder.Append(this.<BookAuthor>i__Field);
builder.Append(" }");
return builder.ToString();
}
GetHashCode()
通过将每个匿名类型的成员变量传递给 EqualityComparer<T>
来计算哈希值。因此,如果两个匿名类型具有相同的属性集并分配了相同的值,它们将返回相同的哈希码。Equals
和 ==
实现匿名类型的方式不同。当你使用 Equals
比较两个匿名类时,它通过测试对象每个属性的值与其他匿名类型的值来使用基于值的比较。然而,使用 ==
时,它会检查引用,以确定两个引用是否相等。让我们看一个例子
public static void AnonymousTypeSemantics()
{
var book1 = new { BookTitle = "ASP.NET 3.5 Unleashed",
BookAuthor = "Stephen Walther" };
var book2 = new { BookTitle = "ASP.NET 3.5 Unleashed",
BookAuthor = "Stephen Walther" };
//check for ==
Console.WriteLine("Checking if book1 == book2");
if (book1 == book2)
{
Console.WriteLine("Same object");
}
else
{
Console.WriteLine("Not same object");
}
//check for equals
Console.WriteLine("Checking if book1.Equals(book2)");
if (book1.Equals(book2))
{
Console.WriteLine("Same object");
}
else
{
Console.WriteLine("Not same object");
}
}
结果是:

标准查询运算符
标准查询运算符的列表非常多。我将介绍一些我发现有趣的,并且需要一些理解的运算符。
Select Many 和 Concat 运算符
SelectMany
运算符用于创建一对多投影。SelectMany
运算符为每个输入元素返回零个或多个输出。原型如下
public static IEnumerable<S> SelectMany<T, S>(
this IEnumerable<T> source,
Func<T, IEnumerable<S>> selector);
该运算符接受一个 T 类型的输入序列和一个选择器委托,该委托接受一个 T 类型的元素。它返回一个 S 类型的 IEnumerable
,这是一个中间输出序列。让我们看一个例子
public static void SelectManyOperator()
{
string[] items = { "this", "is", "a", "test" };
IEnumerable<char> characters = items.SelectMany(s => s.ToCharArray());
foreach (char character in characters)
{
Console.WriteLine(character);
}
}

从结果中可以看出,对于每个单词的输入,select many 运算符都会生成一个字符数组。select many 运算符将每个字符数组连接成一个单一序列。concat
运算符允许将多个序列合并成一个单一序列。我发现 concat
和 selectmany
运算符可以互换使用。事实上,我发现的主要区别是,selectmany
运算符更灵活,允许你 concat
两个以上的序列(一个序列的数组),而 concat
只适用于两个序列。下面是一个说明合并两个序列的示例,使用了 concat
和 selectmany
public static void ConcatAndSelectManyOperator()
{
string[] books = {
"ASP.NET 2.0 Website Programming: Problem -
Design - Solution",
"Pro ASP.NET 3.5 in C# 2008, Second Edition",
"ASP.NET 2.0 Unleashed",
"ASP.NET 3.5 Unleashed "
};
IEnumerable<string> concatbooks = books.Take(2).Concat(books.Skip(2));
IEnumerable<string> selectmanybooks = new[]{
books.Take(2),
books.Skip(2)
}.SelectMany(b => b);
}
使用 concat
或 selectmany
会产生相同的结果,但 selectmany
的优势在于它允许你合并两个以上的序列并返回一个单一序列。
OrderBy 运算符
order
运算符允许使用 orderby
、orderbydescending
、thenby
和 thenbydescending
对集合进行排序。原型如下
public static IOrderedEnumerable<T> OrderBy<T, K>(
this IEnumerable<T> source,
Func<T, K> keySelector)
where
K : IComparable<K>
该运算符接受一个 IEnumerable<T>
并根据返回序列中每个元素的键值的键选择器对集合进行排序。唯一值得一提的重要一点是,orderby
和 orderbydescending
的排序被认为是“不稳定的”,这意味着如果两个元素根据键选择器返回相同的键,则输出的顺序可能保持不变,也可能不同。除了在 orderby
子句中指定的字段外,你不应该依赖于 orderby
调用产生的元素的顺序。
我发现的另一个有趣的观点是,orderby
、orderbydescending
和 orderbyascending
接受 IEnumerable<T>
作为输入源,如原型所示。然而,return
类型是 IORderedEnumerable<T>
。问题在于,如果你想按一个以上的列排序,你就不能传递 IOrderedEnumerable<T>
,因为 orderby
接受 IEnumerable<T>
。解决这个问题的方法是使用 ThenBy
或 ThenByDescending
运算符,它们接受 IOrderedEnumerble<T>
。让我们继续看一个例子
public static void OrderByOperators()
{
Book[] books =
{
new Book{Title="ASP.NET 2.0 Website Programming: Problem -
Design - Solution",
Author="Marco Bellinaso"},
new Book{Title="ASP.NET 2.0 Unleashed", Author="Stephen Walther"},
new Book{Title="Pro ASP.NET 3.5 in C# 2008, Second Edition",
Author="MacDonald and Mario Szpuszta"},
new Book{Title="ASP.NET 3.5 Unleashed ", Author="Stephen Walther"},
};
IEnumerable<Book> orderedbooks =
books.OrderBy(b => b.Author).ThenBy(b => b.Title);
}
在上面的例子中,我使用了 orderby
和 thenby
。由于 orderby
只在 IEnumerable<T>
上工作,所以我还使用了 thenby
扩展方法。orderby
的第二个原型接受 Icomparable
,并且不需要 return
类型为 Collection
的 keyselector
委托实现 IComparable
。原型如下
public static IOrderedEnumerable<T> OrderBy<T, K>(
this IEnumerable<T> source,
Func<T, K> keySelector,
IComparer<K> comparer);
interface IComparer<T> {
int Compare(T x, T y);
}
compare
方法将返回一个大于 int
的值(如果第二个参数大于第一个),等于零(如果两个参数相等),小于零(如果第一个参数小于第二个)。让我们看一个例子
public class LengthComparer : IComparer<string>
{
public int Compare(string title1, string title2)
{
if (title1.Length < title2.Length) return -1;
else if (title1.Length > title2.Length) return 1;
else return 0;
}
}
我创建了一个自定义比较器,它根据 title
的长度来比较 book
的标题。当第一个 title
的长度小于第二个 title
的长度时,我返回 -1。如果更大,我返回 1。否则,我返回 0 表示相等。这就是我在 LINQ 查询中使用比较器的方式。
public static void OrderByUsingCustomComparer()
{
Book[] books =
{
new Book{Title="ASP.NET 2.0 Website Programming: Problem -
Design - Solution",
Author="Marco Bellinaso"},
new Book{Title="ASP.NET 2.0 Unleashed", Author="Stephen Walther"},
new Book{Title="Pro ASP.NET 3.5 in C# 2008, Second Edition",
Author="MacDonald and Mario Szpuszta"},
new Book{Title="ASP.NET 3.5 Unleashed ", Author="Stephen Walther"},
};
IEnumerable<Book> orderedbooks = books.OrderBy(b => b.Title,
new LengthComparer());
}
AsEnumerable 运算符
我发现 AsEnumerable
运算符对于理解查询的执行位置非常重要,也就是说,它是会转换为 SQL 并在 SQL Server 上执行查询,还是会使用 LINQ to objects 并在内存中执行查询。我发现 AsEnumerable
的理想用途是,当我已知某些功能在 SQL Server 中不可用时,我可以部分查询使用 LINQ to SQL (Iqueryable
),而其余部分作为 LINQ to objects (IEnumerable<T>
) 执行。基本上,AsEnumerable
是一个提示,表明该部分执行应该使用 LINQ to objects。原型如下
public static IEnumerable<T> AsEnumerable<T>(
this IEnumerable<T> source);
原型操作的是 IEnumerable<T>
的源,也返回 IEnumerable<T>
。这是因为标准查询运算符操作的是 IEnumerable<T>
,而 LINQ to SQL 操作的是 IQueryable<T>
,后者也恰好实现了 IEnumerable<T>
。因此,当你在 IQueryable <T>
(域对象)上执行一个运算符(如 where 子句)时,它会使用 LINQ to SQL 实现。结果是,查询在 SQL Server 上执行。但是,如果我们提前知道某个运算符在 SQL Server 上会失败,因为 SQL Server 没有实现它怎么办?最好使用 AsEnumerable
运算符来告诉查询引擎在内存中使用 LINQ to objects 执行该部分查询。让我们看一个例子
public static void AsEnumerableExample()
{
NorthwindDataContext db = new NorthwindDataContext();
var firstproduct = (from product in db.Products
where product.Category.CategoryName == "Beverages"
select product
).ElementAt(0);
Console.WriteLine(firstproduct.ProductName);
}
当你运行这个查询时,它会抛出一个异常,说 elementat
不受支持,因为 SQL Server 不知道如何执行 elementAt
。在这种情况下,当我添加 as enumerable 时,查询将按如下方式成功执行
public static void AsEnumerableExample()
{
NorthwindDataContext db = new NorthwindDataContext();
var firstproduct = (from product in db.Products
where product.Category.CategoryName == "Beverages"
select product
).AsEnumerable().ElementAt(0);
Console.WriteLine(firstproduct.ProductName);
}
DefaultIfEmpty
DefaultIfEmpty
运算符在输入序列为空时返回一个默认元素。如果输入序列为空,DefaultIfEmpty
运算符将返回一个包含单个默认值 (T) 的序列,对于引用类型,该值为 null
。此外,该运算符还允许你指定要返回的默认值。
public static void DefaultIfEmptyExample()
{
string[] fruits = { "Apple", "pear", "grapes", "orange" };
string banana = fruits.Where(f => f.Equals("Banana")).First();
Console.WriteLine(banana);
}
上面的例子会抛出异常,因为第一个运算符要求序列不能为空。因此,如果我们使用 defaultifEmpty
,它看起来会是这样
public static void DefaultIfEmptyExample1()
{
string[] fruits = { "Apple", "pear", "grapes", "orange" };
string banana =
fruits.Where(f => f.Equals("Banana")).DefaultIfEmpty("Not Found").First();
Console.WriteLine(banana);
}
DefaultIfEmpty
的另一个有趣用途是执行 left outer join
,方法是使用 GroupJoin
。下面是一个说明这个用法的例子
public class Category
{
public string CategoryName { get; set; }
}
public class Product
{
public string ProductName { get; set; }
public string CategoryName { get; set; }
}
public static void LeftOuterJoin()
{
Category[] categories = {
new Category{CategoryName="Beverages"},
new Category{CategoryName="Condiments"},
new Category{CategoryName="Dairy Products"},
new Category{CategoryName="Grains/Cereals"}
};
Product[] products = {
new Product{ProductName="Chai",
CategoryName="Beverages"},
new Product{ProductName="Northwoods Cranberry Sauce",
CategoryName="Condiments"},
new Product{ProductName="Butter",
CategoryName="Dairy Products"},
};
var prodcategory =
categories.GroupJoin(
products,
c => c.CategoryName,
p => p.CategoryName,
(category, prodcat) => prodcat.DefaultIfEmpty()
.Select(pc => new { category.CategoryName,
ProductName = pc != null ? pc.ProductName : "No" })
).SelectMany(s => s);
foreach (var product in prodcategory)
{
Console.WriteLine("Category :{0}, Product = {1}", product.CategoryName,
product.ProductName);
}
}
在上面的例子中,我使用 left outer join
来列出所有类别,无论它们是否有任何产品。
历史
- 2008 年 1 月 30 日 -- 发布原始版本
- 2008 年 3 月 5 日 -- 文章更新