C# 3.0 语言背后的概念






4.84/5 (112投票s)
在本文中,我将描述影响 C# 3.0 语言设计的概念。这些概念大多来自其他编程语言,如 Haskell、LISP 或微软研究院开发的语言。
引言
目前可用的 C# 3.0 预发布版本是 LINQ 项目 [1] 的一部分。该项目的目标是为微软主流通用编程语言提供更好的数据处理支持。它特别针对 .NET 平台,即 C# 和 VB.NET。C# 3.0 中实现的大部分思想都已存在于微软研究院早期开发的、大多为研究性和实验性的语言中。其中最有趣和最具影响力的语言包括 Cω [2] 和 F# [3]。
F# 的贡献
F# 是一种基于 ML 的语言,ML 是一种非纯函数式语言。实际上,它基于一种名为 OCaml 的语言,该语言为标准 ML 添加了几项功能。函数式语言与命令式语言截然不同,大多数广泛使用的语言,如 C++、Java 和 C#,都是命令式的。F# 最大的贡献在于它展示了函数式语言如何为 .NET Runtime (CLR) 编译,因为 .NET CLR 最初是为执行命令式语言编写的代码而设计的。F# 的另一个目标是与其他面向 .NET 平台的语言进行互操作。ILX [6] 项目也是 F# 相关研究的一部分,它展示了如何扩展 .NET 运行时以提供更好的函数式语言支持,例如一流函数。
总的来说,函数式编程对 C# 3.0 的一些特性产生了很大的启发。F# 研究语言已经展示了如何在 .NET 平台上实现这些特性。C# 3.0 包含了受以下特性启发的构造:
- 类型推断:推断表达式类型的能力
- 元组数据类型:表示值对的类型
- 一流函数:将函数作为参数并将其作为结果返回的能力
- 惰性求值:仅在需要时才求值表达式的能力
- 元编程:操作程序源代码的能力
与 F# 和其他函数式语言的实现相比,C# 3.0 中添加的这些特性大多非常有限。然而,看到函数式概念变得越来越重要并能惠及非函数式语言,这非常有趣。
Cω 的贡献
Cω 是一种基于 C# 的语言,并在两个重要领域对其进行了扩展。第一个领域是更好地支持结构化数据(XML)和关系数据(数据库)。该语言扩展了 C# 的类型系统,增加了对关系数据和结构化数据中常见的几种数据类型的支持。它还提供了用于处理这些数据结构的查询功能。第二个领域是作为语言一部分的并发构造支持。在大多数广泛使用的编程语言中,并发支持是通过类库提供的。通过将这些构造包含在语言中,程序变得更具可读性——即程序员的意图可以更好地表达——并且编译器可以自动完成更多工作。
C# 3.0 和 LINQ 项目总体上受到 Cω 扩展的第一个领域的影响。C# 3.0 中的语法扩展与 Cω 中开发的概念非常相似。最大的区别—— Cω 是一种实验性语言——是 C# 3.0 提供了更好的可扩展性。这使得开发人员能够提供一种处理不同数据源的机制。这种可扩展性作为一种语言特性而不是编译器特性提供,而 Cω 是后者。另一方面,Cω 中的一些扩展在 C# 3.0 中被简化了。稍后将以匿名类型为例进行说明。因此,在适当的地方,我将包含 Cω 中可能实现的但在 C# 3.0 中无法实现的示例。
C# 3.0 概述
C# 3.0 的主要目标是简化数据处理,并使其能够以更简单的方式访问关系数据源(数据库)。在 C# 3.0 中,这是以可扩展的方式实现的,而不是简单地添加几个专用构造。得益于新的语言构造,可以编写如下语句:
var query = from c in db.Customers
where City == "London"
orderby c.ContactName;
select new { CustomerID, ContactName };
此语句看起来像 SQL 查询。这是通过添加到语言中的查询运算符(FROM
、WHERE
、SELECT
、ORDERBY
等)实现的。这些运算符是语法糖,简化了查询的编写,但会映射到底层执行投影、过滤或排序的函数。这些函数称为 Select
、Where
、OrderBy
。例如,要执行过滤,Where
函数需要接受另一个函数作为参数。使用一种名为 lambda 表达式的新语言特性,可以将函数作为参数传递给其他函数。这类似于许多函数式语言中已知的 lambda 函数。您还可以看到,查询仅从更复杂的 Customer
结构返回 CustomerID
和 ContactName
。由于 C# 3.0 允许开发人员使用匿名类型,因此无需显式声明仅包含这两个成员的新类。同样,也无需声明查询变量的类型,因为当使用 var
关键字时,类型推断会自动推断出类型。
Cω 和数据访问集成
将数据访问集成到通用编程语言中的最初想法出现在微软研究院的 Cω 研究项目中。Cω 中集成的数据访问功能包括处理数据库和结构化 XML 数据。LINQ 项目主要基于 Cω;然而,也存在一些差异。C# 3.0 和 Cω 中都可以找到的特性包括匿名类型(在 Cω 中不限于局部作用域)、局部类型推断和查询运算符。Cω 中不存在的一个概念是通过表达式树进行的可扩展性。在 Cω 中,您无法编写自己的数据源实现,该实现可以查询除内存数据以外的任何内容。下面的演示展示了 Cω 中处理数据库的示例,它与前面用 C# 3.0 编写的示例非常相似。
query = select CustomerID, ContactName
from db.Customers
where City == "London"
order by ContactName;
Cω 项目将在其他章节中稍后提及,因为 C# 中提供的一些最初在 Cω 中实现的特性在 Cω 中功能更强大。因此,了解这种通用化很有意义。
一流函数
“如果函数可以在程序执行期间创建、存储在数据结构中、作为参数传递给其他函数,并作为其他函数的返回值,则称该编程语言支持一流函数。”
(来源:Wikipedia.org)
一流函数是函数式编程风格所必需的。例如,Haskell 中处理列表的函数(map、foldl 等)都是高阶函数。这意味着它们接受一个函数作为参数。前面的引用总结了一流函数是什么以及支持此特性的语言的优势。在 [5] 中可以找到关于语言必须支持哪些才能将函数视为一流对象的更精确定义。根据这本书,如果函数可以被
- 在任何作用域内声明
- 作为参数传递给其他函数
- 作为函数的结果返回
第 2 点和第 3 点在许多语言中——包括 C/C++——都可以通过允许将函数指针作为参数来完成。在 C# 的第一个版本中,可以使用委托,委托可以简单地描述为类型安全的函数指针。然而,C/C++ 和 C# 的第一个版本都不允许在任何作用域内声明函数,尤其是在另一个函数或方法的体内部。
F# 中的一流函数
我将首先介绍 F# 如何支持一流函数,F# 是一种混合了命令式和函数式的、面向 .NET 平台的语言。我选择 F# 语言是因为它表明在 .NET 平台上实现函数式语言特性是可能的。我将首先展示一些 F# 独有的特性,与其他 .NET 语言相比。
// Declaration of binding 'add' that is initialized // to function using lambda expression let add = (fun x y -> x + y);; // Standard function declaration (shorter version, // but the result is same as in previous example) let add x y = x + y;; // Currying – creates function that adds 10 to parameter let add10 = add 10;;
这个示例首先表明,在 F# 中,函数与其他类型一样。这意味着,与大多数函数不是全局变量的语言不同,F# 中的函数只是普通的全局变量。更确切地说,它们是绑定,因为默认情况下 F# 中的所有数据都是不可变的。这可以通过第一个 add 函数示例来演示,该函数是一个全局变量,其值是使用 lambda 表达式创建的函数。第二个示例展示了声明全局函数的简化语法,但与第一个 add 函数版本相比,唯一的区别在于语法。
下一个示例展示了柯里化(currying),这是一种将函数与其第一个参数绑定到指定值操作。在这种情况下,参数是函数 add
(类型为 int -> int -> int)和常数 10。结果是一个类型为 int -> int 的函数(赋值给 add10
),该函数将 10 添加到指定参数。柯里化操作可以在支持一流函数的任何语言中使用,但 F# 中的语法使其使用起来极其方便。下面的示例展示了一流函数的一些常见用法,我稍后也会用 C# 编写。
// passing function to higher order functions let words = ["Hello"; "world"] in iter (fun str -> Console.WriteLine(str); ) words; // Returning nested function let compose f g = (fun x -> f (g x));;
第一个示例最初声明了一个包含字符串的列表。然后,它使用一个 iter 函数,为列表中的每个元素调用作为第一个参数传递的函数。请注意,F# 并非纯函数式语言,这意味着传递的函数可以具有副作用,例如在控制台上打印字符串。第二个示例是一个返回传递的两个函数的组合的函数。这是一个返回在局部作用域声明的函数的良好示例。此示例中的第二个有趣之处在于函数的类型。没有显式声明类型,因此类型推断算法推断出第一个参数的类型为 'b -> 'c,第二个参数的类型为 'a -> 'b,返回值类型为 'a -> 'c,其中 'a、'b 和 'c 是类型参数。这意味着,对于第二个函数的返回类型与第一个函数的参数类型相同的任何两个函数,都可以使用 compose 函数。
C# 2.0 和 C# 3.0 中的一流函数
自 .NET Framework 和 C# 的第一个版本以来,一直存在一种称为委托(delegates)的机制。委托是类型安全的函数指针,可以作为参数传递或从函数返回。如前所述,C# 的第一个版本不支持在函数体(嵌套函数)内部声明函数,因此只能将委托初始化为全局声明的方法。
C# 2.0 匿名委托
下面的示例展示了如何在 C# 2.0 中使用匿名委托来创建一个返回“计数器”委托的方法。计数器委托是一个函数,它将数字加到一个指定的初始值并返回总和。
CounterDelegate CreateCounter(int init) {
int x = init;
return delegate(int n) {
x += n;
return x;
}
}
CounterDelegate ct = CreateCounter(10);
ct(2); // Returns 12
ct(5); // Returns 17
此示例显示了一个有趣的挑战,它出现在允许在函数体内部声明函数以及允许将此函数作为返回值的问题中。您可以看到匿名委托使用一个名为 x 的局部变量来存储总值。因此,必须有一些机制来创建对方法体内的局部变量的引用。此示例中的另一个问题是,当函数 CreateCounter
返回时,该函数(及其局部变量 x)的激活记录将被销毁。这些问题在函数式语言中使用闭包(closures)来解决,并且 C# 2.0 中添加了类似的机制。闭包捕获局部变量的值,并在激活记录被销毁时保留该值。
虽然匿名委托提供了实现一流函数所需的一切,但其语法对于从函数式语言已知的用途来说非常冗长且不方便。C# 3.0 引入了一种名为 lambda 表达式的新语言特性,它提供了更具函数式风格的语法。还扩展了 .NET 基类库,以包含一些从函数式语言中更重要的函数,例如 map、filter 等。这些函数的用法在以下示例中进行了说明:
IEnumerable<int> nums = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
IEnumerable<int> odd = nums.Where( (n) => (n%2) == 1 );
IEnumerable<int> oddSquares = odd.Select( (n) => n*n );
示例的第一行声明了变量 nums,这是一个包含 1 到 10 的数字的数组。第二行过滤了数字,返回的列表 odd 只包含奇数。Where
函数接受一个委托作为参数,该委托使用 lambda 表达式语法创建。在这种语法中,(n) 指定了委托的参数。符号“=>”右侧的表达式是委托的主体。稍后使用 Select
函数计算列表中所有奇数的平方。此示例中还有一个有趣的观点是,当类型可以从上下文中推断出来时,您不必显式声明 lambda 表达式参数的类型。这将在下一节中讨论。lambda 表达式也可用于访问表达式的数据表示,但这将在元编程部分更详细地介绍。
类型推断
类型推断是强类型编程语言的一项特性,它指的是推断表达式数据类型(data type)的能力。这项特性目前在许多——主要是函数式——编程语言中可用,包括 Haskell 和 ML。它在 F# 中也可用。在支持类型推断的语言中,大多数标识符——例如变量和函数——都可以在没有显式类型声明的情况下声明。但是,由于类型在编译期间自动推断,因此该语言仍然是类型安全的。
F# 中的类型推断
F# 类型系统基于 ML 类型系统,非常依赖类型推断。事实上,F# 中没有声明需要类型的名称,除非出现某种冲突或歧义。在这种情况下,程序员可以使用类型注释(type annotations)来向编译器提供提示。让我们从一个演示 F# 类型推断如何工作的简单示例开始。
// Some value bindings let num = 42;; let msg = "Hello world!";; // Function that adds 42 to the parameter let addNum x = x + num;; // Identity function let id x = x;; let idInt (x:int) = x;;
在此示例中,没有显式声明类型,但所有标识符的类型在编译时都是已知的。值绑定(num 和 msg)的类型可以推断出来,因为绑定右侧表达式的类型是已知的。即 42 是整数,"Hello world!" 是字符串。在最后一个示例中,num 的类型是已知的(它是整数),并且 "+" 运算符接受两个整数参数。因此,x 参数的类型也必须是整数。由于应用于两个整数参数的 "+" 返回整数,因此函数的返回类型也是整数。
最后两个示例展示了如何在 F# 中声明身份函数(identity function),该函数返回传递的参数。类型推断算法推断出返回值的类型与参数的类型完全相同,但它不知道实际类型是什么。在这种情况下,F# 类型系统可以使用类型参数。因此,推断出的类型是 'a -> 'a,其中 'a 是类型参数。如果我只想为整数值定义身份函数,我需要使用类型注释来向编译器提供提示。这在最后一个示例中显示,其中明确声明了参数 x 的类型将是整数。利用这些信息,类型推断算法也可以推断出函数的返回值也是整数。
C# 3.0 中的类型推断
与 F# 中的类型推断相比,C# 3.0 对类型推断的支持非常有限,但它非常有趣,因为它使 C# 成为第一个支持该特性的主流语言。出于某种不明原因,类型推断到目前为止仅在函数式语言中实现。在 C# 3.0 中,类型推断只能与 lambda 表达式一起使用,或者用于推断已初始化为某个值的局部变量的类型。无法将类型推断用于方法返回值或任何类成员。首先,让我们看看 lambda 表达式中可用的类型推断。
IEnumerable<int> nums = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
IEnumerable<int> odd = nums.Where( (n) => (n%2) == 1 );
在此示例中,使用了 lambda 表达式 (n) => (n%2) == 1
,但参数 n 的类型未显式声明。在这种情况下,编译器知道 Where
函数期望的参数类型是委托,它接受 int 作为参数并返回 bool。基于这些信息,编译器推断出 n 的类型必须是 int。它还会检查 lambda 表达式的返回类型是否与委托的预期返回值匹配。C# 3.0 中使用类型推断的第二种情况是使用 var 关键字推断局部变量类型的能力。
// Some simple examples
var str = "Hello world!";
var num = 42;
// Declaration without using type inference
TypeWithLongName<AndWithTypeParameter> v =
new TypeWithLongName<AndWithTypeParameter>();
// Same declaration with type inference
var v = new TypeWithLongName<AndWithTypeParameter>();
前两个示例展示了如何使用类型推断来推断基本变量的类型,第一个是字符串,第二个是整数。下一个示例表明,类型推断可以大大简化源代码,因为它无需在变量声明中重复相同的类型。然而,关于 var 关键字应该在哪些情况下使用以及何时不使用的讨论仍然很激烈。这是因为,如果使用不当,代码会变得不那么可读。C# 中包含类型推断的一个非常重要的原因是匿名类型。稍后将更详细地讨论匿名类型,因此我这里只举一个简单的例子。
var anon = new { Name = "Hello" };
Console.WriteLine(anon.Name);
在此示例中,anon 变量被初始化为匿名类型的实例,该类型有一个名为 Name 的成员,值为 "Hello."。编译器确切地知道类型的结构,但没有可以用于声明该类型变量的名称。在这种情况下,类型推断和 var 关键字变得极其有用。
惰性求值
惰性求值是 Haskell 编程语言中的基本概念之一,由于惰性求值,可以使用列表推导式声明无限列表。我将讨论惰性求值在 C# 语言中的两种不同用法。首先,我将展示如何在 C# 中模拟参数传递的惰性求值,这得益于一流函数的更好支持。在第二部分,我将展示在 C# 中编写无限(惰性)列表几乎和在 Haskell 中一样容易。
惰性参数传递
使用惰性求值策略调用函数时,参数的值仅在需要时才求值。这是 Haskell 的默认行为;它也可以在 Scheme 中使用 delay/force 进行模拟。在 C# 中,可以使用高阶函数实现类似的行为:匿名委托,或者更好的是,使用 lambda 表达式。惰性求值的一个典型示例是带有两个参数的函数,它仅在第一个参数满足某个条件时才使用第二个参数。
// Function that may not need second parameter
int func(int first, int second)
{
if ((first%2) == 0) return 0; else return second;
}
// Call to the 'func'
func(n, expensiveCalculation());
在此示例中,惰性求值可能非常有用,因为它可以在不需要时阻止调用 expensiveCalculation()
。在以下代码中,我将使用 Func<T>
类型,其中 T 是类型参数。此类型表示委托,即 .NET 中用于传递函数、不带参数且返回 T 类型值的类型。
// Now, the parameters are functions that
// can be used for calculating values
int func(Func<int> first, Func<int> second)
{
if ((first()%2) == 0) return 0; else return second();
}
// Parameters are passed using lambda expressions
func(() => n, () => expensiveCalculation());
此函数现在的行为类似于其在 Haskell 中编写的等效函数,因为当第二个参数不需要时,expensiveCalculation 函数永远不会被调用。只有一个重要区别。也就是说,如果需要两次该值,该函数也会被调用两次。然而,这可以通过传递一个包装类来解决,该类包含用于计算值的委托,并在调用评估时将其结果本地存储。
C# 3.0 中的无限列表
Haskell 中由于惰性求值而成为可能的无限列表最接近的等价物是迭代器(iterators)的概念,这是从面向对象编程中已知的。迭代器用于遍历集合中的元素,通常使用一个移动到集合中下一个元素的方法和一个返回当前元素的方法。然而,为创建数字序列编写迭代器比在 Haskell 中使用列表推导式要复杂得多。
如前所述,Cω 语言引入了一种名为流(stream)的新数据类型。Cω 中的流是特定类型的同质集合,类似于数组。然而,与数组不同,流是惰性的。流声明的语法是在元素类型名称后面附加一个星号(*)。该语言还引入了有趣的流生成语法,使得生成流比使用迭代器更容易。
// COmega – stream with fibonacci numbers
public static int* Fibs() {
int tmp, v1=0, v2=1;
while (true) {
yield return v1;
tmp = v1; v1 = v2; v2 = tmp + v2;
}
}
这里的关键概念是 yield return 语句,它返回流的下一个元素。流是惰性的,因此在读取流中的元素时,只会执行所需数量的迭代,并在不再需要更多元素时停止执行。Cω 语言还为流声明提供了行内语法。
int* nums = { for(int i=0; ; i++) yield return i; };
在 Haskell 中处理列表时,最有用的函数是 map(提供投影)和 filter(提供过滤)。这些函数在 Cω 中以两种语法不同的方式提供。第一种方式允许使用 XPath 类运算符,而第二种方式支持 SQL 类查询。功能当然是相同的。在下面的示例中,我将展示如何使用 XPath 类和 SQL 类方式从先前定义的数字序列中获取所有偶数的平方。
// XPath-like filter and projection operators int* squaresOfEven = nums[ (it%2)==0 ].{ it*it }; // SQL-like projection (select) and filter (where) operators int* squaresOfEven = select it*it from nums where (it%2)==0;
此 yield return 语句被认为非常成功,因此它被引入到下一版主流 C# 语言(2.0 版)中。C# 的主要区别在于返回的数据类型不是流(int*
),而是迭代器对象(IEnumerable<int>
)。这引入了一种在 C# 中编写无限列表的简单方法。然而,其他处理流的特性,如 apply-to-all 语句,在 C# 2 中不可用。
C# 2.0 未包含函数式编程中已知函数的(即投影和过滤)主要原因是 C# 2.0 对将函数作为参数传递的支持较差。C# 3.0 改进了这一点,因此,得益于 lambda 表达式,可以声明为序列(IEnumerable<T>
)提供过滤和投影操作的方法。以下示例展示了如何在 C# 3.0 中以类似于 Cω 示例的方式处理序列。
// Function GetNums returns all numbers using yield return
IEnumerable<int> nums = GetNums();
// Manipulation using Where and Select
IEnumerable<int> squaresOfEven =
nums.Where((it) => (it%2)==0 ).Select((it) => it*it );
// Same operation written using query operators
IEnumerable<int> squaresOfEven =
from it in nums where (it%2)==0 select it*it;
在第一个示例中,操作是使用 Where
和 Select
方法完成的,这些方法接受一个 lambda 表达式用于过滤和投影。使用特殊运算符(如 select
、from
、where
等)可以实现完全相同的结果,这些运算符在 C# 3.0 中可用。我还没有展示如何在 C# 3.0 中以类似于 Haskell 中列表推导式的方式声明无限列表。Haskell 中的列表声明可以包含四个部分。
- 初始化部分:声明可用于计算列表其余部分的初始值。
- 生成器部分:声明一个列表,新列表中的值可以从此列表中计算得出;此列表可以递归使用新创建的列表。
- 过滤部分:操作来自生成器列表的元素。
- 投影部分:操作来自生成器列表的元素。
让我们看看这如何在 Haskell 和 C# 3.0 中编写。以下示例定义了一个无限的斐波那契数列。
-- Haskell
fibs = 0 : 1 : [a + b | (a, b) <- zip fibs (tail fibs)]
// C# 3
var fibs = new InfiniteList<int>((t) =>
t.ZipWith(t.Tail()).Select((v) => v.First + v.Second),
0, 1);
两个示例的语义相同。斐波那契数列是使用列表中已知的元素递归定义的。C# 中的生成器部分 t.ZipWith(t.Tail())
等效于 Haskell 中编写的 zip fibs (tail fibs)
。在 C# 版本中,无法在 fibs
变量初始化时引用它。因此,t 是传递给生成器的列表实例,以允许递归声明。C# 中的投影部分 Select((v) => v.First + v.Second)
等效于 Haskell 中的 a + b | (a, b)
。区别在于 C# 不直接支持元组,因此元组表示为带有 First
和 Second
成员的结构。在此示例中未使用过滤部分。最后,最后两个参数(0, 1
)定义了列表的初始值,这等效于 Haskell 中前两个值的声明(0 : 1 :
…)。
惰性求值总结
惰性求值策略可以在许多非函数式语言中模拟,因为它是一个通用概念。然而,在大多数这些语言中,这种模拟将非常困难且使用不便。得益于 C# 3.0 最新版本中提供的新特性,一些源自函数式编程的概念——包括惰性求值和列表推导式——可以在 C# 中自然使用。
匿名类型
匿名类型是 C# 3.0 中出现的另一个源自函数式编程的有用特性的例子。匿名类型基于 Haskell 或微软研究院开发的任何其他函数式编程语言(包括 F#)中的元组。如前所述,第一个实现元组概念的面向对象语言是 Cω。Cω 中的元组——也称为匿名结构——可以包含命名字段或匿名字段。包含匿名字符串和名为 N 的整数字段的元组的类型是 struct{string; int N;}
。可以使用索引表达式访问元组的匿名字段。例如,要从前面的元组中获取字符串字段,可以使用 t[0]
。命名字段可以通过与匿名字段相同的技术访问,或通过名称访问。在 Cω 中,元组是常规类型。因此,它可以在任何变量或方法声明中使用。以下示例展示了 Cω 中元组的用法。
// Method that returns tuple type public struct{string;int Year;} GetPerson() { return new { "Tomas", Year=1985 }; } // Call to the GetPerson method struct{string;int Year;} p = GetPerson(); Console.WriteLine("Name={0}, Year={1}", p[0], p.Year);
元组非常有用的一种情况是使用投影操作。这类似于 Haskell 中的 map 函数,Cω 中的投影或 select 运算符,以及 C# 3.0 中的 Select 方法。以下示例首先创建一个数字流,然后使用投影运算符创建一个元组流,其中每个元素包含原始数字及其平方。
// Get stream with numbers int* nums = GetNumbersStream(); // Projection returns stream of tuples with number and its square struct{int;int;} tuples = nums.{ new { it, it*it } };
投影操作可能是将匿名类型包含在 C# 3.0 中的主要原因。这是因为 C# 3.0 和 LINQ 项目专注于简化数据访问和编写数据库查询。因此,投影非常重要。以下示例展示了 C# 3.0 中匿名类型的用法。
// Creating anonymous type
var anon = new { Name="Tomas", Year=1985 };
// Creating anonymous type in projection operator
from db.People select new { Name=p.FirstName+p.FamilyName, p.Year };
显然,C# 3.0 中的匿名类型基于 Cω 中提出的思想。然而,存在一些差异。第一个显著的差异是,在 C# 中,匿名类型的字段都是命名的。在前面的示例中,第一个字段的名称(Name)被显式指定,第二个字段的名称(Year)会自动推断。第二个,也是可能最重要的区别是,在 C# 3.0 中,可以创建匿名类型的实例,但声明该类型变量的唯一方法是使用 var 关键字。此关键字从表达式推断变量的类型,因此类型信息不会丢失。这意味着在 C# 3.0 中无法引用匿名类型,使其无法用作方法的参数。因此,匿名类型只能在有限的作用域内使用。
C# 3.0 中的这种作用域限制是故意的,因为在面向对象编程中滥用匿名类型可能会导致代码可读性和可维护性降低。然而,在某些情况下,将匿名类型作为方法的返回值会非常有用。Cω 语言表明,这种限制可以得到解决,并且可以无限制地在面向对象语言中使用源自函数式编程的元组。
元编程
面向语言的开发
“面向语言编程是一种编程风格,在这种风格中,程序员首先为问题创建领域特定语言,然后在该语言中解决问题。”
(来源:Wikipedia.org)
C++、C#、Java 或函数式 Haskell 等主流语言属于通用语言(GPL)类别。此类别包含不用于特定用途,而是可用于开发各种应用程序的语言。与这些语言相反的是所谓的领域特定语言(DSL),它们只能用于一个特定目的。这些语言通常为其目的而设计和优化,因此它们比 GPL 更好。从某种意义上说,这些语言类似于面向对象世界中的类库,因为类库也是为服务于特定目的而设计的。SQL 语言是 DSL 的一个很好的例子,它的目的是进行数据库查询。该语言演示了 DSL 的所有特征;它的使用领域非常有限,但在该领域内,它的作用优于任何通用语言。
在他的文章 [4] 中,Martin Fowler 将 DSL 分为外部和内部分类。内部(也称为嵌入式)DSL 是扩展和修改其使用宿主(通用)语言的语言。LISP 是允许开发人员修改它的语言的一个例子——这对于大型项目是必要的。它本身非常简单,但也提供了扩展它的方法。因此,在更复杂的项目中,语言首先使用宏进行扩展,然后可以使用创建的 LISP 方言轻松解决问题。相反,外部 DSL 是独立于其使用语言的语言。SQL 是一个例子。
元编程
“元编程是指编写程序来生成或操作其他程序(或自身)作为其数据。”
(来源:Wikipedia.org)
正如引文中所述,元编程的原理是开发人员编写的代码使用程序代码的某种表示形式,无论是在同一语言还是不同语言中。此代码可以被分析、解释或翻译成另一种语言。显然,元编程用于创建领域特定语言。然而,创建 DSL 并非元编程的唯一可能用途。对于外部 DSL,开发人员必须使用另一种通常是通用语言来编写编译器或转换器。这种选择并不经常使用,因为编写编译器不是一项微不足道的任务。内部 DSL 的情况要有趣得多,因为程序在运行时操作自己的代码。为了实现这一点,语言必须提供某种方式来访问其自身代码的数据表示,以及某种允许用户定义自己的子语言的可扩展性。
从我已提到的内容中,您可以看出,在支持高级元编程形式的语言中,可以开发出类似于 SQL 的领域特定语言。使用元编程,以后可以获得用这种类似 SQL 的子语言编写的代码的表示,将其翻译成 SQL 命令文本并执行!在本文的其余部分,我将使用“元编程”一词来表示使用编程语言提供的特性来操作程序自身的代码。这是因为操作外部代码几乎可以用任何 GPL 语言编写;只需要支持读取和操作文本文件即可。
C# 3.0 中的元编程
元编程支持是 LINQ 项目的关键特性之一,尤其是其用于处理数据库的实现“LINQ to SQL”(先前称为 DLINQ)。在编写用于数据库访问的 LINQ 代码时,用于过滤和投影——以及其他运算符(如 join)——的表达式必须翻译成 SQL 命令。没有获取这些表达式数据表示的能力,就无法做到这一点,因此没有某种形式的元编程就无法做到。
在 C# 3.0 中,只能从其主体是表达式的 lambda 表达式中获取数据表示(称为表达式树)。我认为这是 C# 3.0 的最大限制,因为它无法获取语句或语句块的数据表示。lambda 表达式可以有两种方式进行编译。它可以编译为委托——即可用于执行函数的对象——或者编译为返回 lambda 表达式的表达式树(数据表示)的代码。这两种选项之间的决定基于 lambda 表达式出现的代码的 l 值。当它被赋给类型为 Func
的变量时,它被编译为委托。当变量类型为 Expression
时,它被编译为数据。以下示例展示了相同的 lambda 表达式如何编译为委托以及数据表示。为演示起见,我们将使用一个接受整数作为参数,对其进行一些计算,并在值小于十时返回 true 的函数。
// Lambda expression as executable code
Func<int, bool> =
x => DoSomeMath(x) < 10;
// Lambda expression as data representation
Expression<Func<int, bool>> =
x => DoSomeMath(x) < 10;
这正是 LINQ 项目在应用程序与数据库交互时所使用的原理,因此查询会被翻译成 SQL 语言。以下示例演示了在访问数据库时如何使用两个 lambda 表达式——一个用于过滤,一个用于投影。
// Database query written using Where
// and Select extension methods
var q =
db.Customers.
Where(c => c.City == "London").
Select(c => c.CompanyName);
从这个示例中,您可以看到用于从 Customers 表过滤和投影数据的表达式通过 lambda 表达式传递给 Where
和 Select
方法。具体取决于具体的 LINQ 实现,传递给这些方法的参数类型将是 Func
还是 Expression
。因此,可以编写接受委托类型并执行它们的实现,例如,用于过滤存储在内存中的数据。也可以编写接受代码数据表示并将其翻译成另一种语言——即从 LINQ 到 SQL 实现——或以其他方式执行代码的实现。这通常效果很好。然而,为了使数据访问更加直观,C# 3.0 提供了运算符(from
、select
、where
等)来实现此目的。这些运算符只是语法糖,使用这些运算符编写的代码会被翻译成上一个示例中所示的代码。使用 LINQ 运算符,您可以编写如下内容:
// Database query that uses "syntactic sugar"
var q =
from c in db.Customers
where c.City == "London"
select c.CompanyName;
如果从 DSL 和元编程的角度来看这些示例,您会发现 C# 3.0 中用于表示数据库查询的领域特定语言由可以编译为表达式树的表达式组成,以及由语言内置的查询运算符组成,或者更确切地说,由查询方法(如 Select、Where 等)组成。翻译的目标语言——在 LINQ to SQL 的情况下是 SQL——通常不支持程序员可以编写的所有表达式。因此,转换器必须检查程序员编写的所有构造是否都可以翻译。总的来说,任何可以在 C# 3.0 中创建的 DSL 语言只能使用表达式或 C# 表达式的有限子集,以及某种组合这些表达式的方法。示例包括使用一系列方法调用或使用重载运算符。
F# 中的元编程
在 F# 语言中,对元编程的支持更进一步,它允许使用特殊运算符访问几乎任何用 F# 编写的代码。这包括表达式,如 C# 3.0 中一样,还包括语句和更复杂的结构。以下示例说明了如何获取无限循环编写文本 "Hello." 的数据表示。返回值的类型是 expr,它代表 F# 代码。
let e = <@
while true do
print_string "hello"
done @>;;
正如我之前所说,在 F# 中编写 lambda 表达式是可能的,因为 lambda 表达式是任何函数式语言的基本特性之一。还可以获取这些 lambda 表达式的 expr 表示,因此 F# 引用(quotations)可以与 C# 3.0 中的 lambda 表达式以相同的方式使用。
let e = <@ fun x -> doSomeMath(x) > 10 @>;;
F# 的一个最近发布的版本包含一个示例,展示了如何通过支持类似 LINQ 项目中运算符的数据查询构造来扩展该语言。这显示了 F# 语言的灵活性,因为无需以任何特殊方式扩展语言本身即可允许查询数据库。关键特性——元编程——已经添加,并且可以定义自定义运算符,因此不需要 C# 3.0 中的语法糖,如查询运算符。F# 中数据查询的实现内部将 F# 中表达式的数据表示转换为 C# 3.0 表达式树。该表达式树被传递给“LINQ to SQL”转换器。在下面的示例中,您可以看到如何编写与前面所示的 C# 3.0 示例类似的数据库查询。
let query =
db.active.Customers
|> where <@ fun c -> c.City = "London" @>
|> select <@ fun c -> c.ContactName @>;;
F# 中还有一个我尚未提及的非常有趣的特性。我将在调用 doSomeMath 函数的 lambda 表达式的示例中演示这一点。F# 允许您通过其实际值展开代码表示中的所有顶层定义。这意味着对 doSomeMath 函数的调用可以用其实际实现替换。例如,这可用于允许开发人员在数据查询表达式中使用自定义函数。
元编程总结
从这些示例中,您可以看到 F# 的元编程比 C# 3.0 中的 lambda 表达式更灵活。代码的数据表示可能比 LINQ 中使用的表示更复杂;然而,由于其函数式设计和 F# 中的函数式特性,它易于使用。C# 中没有的一个非常重要的特性是,在 C# 中只能获取表达式的数据表示,而在 F# 中可以获取语句——包括循环和语句块——以及函数声明的代码。第二个特性(在编译器中默认未开启)是,在 F# 中可以获取任何顶层定义的数据表示,即所有声明的函数。这使得可以在表达式中展开函数调用。
参考文献
- LINQ 项目。D. Box 和 A. Hejlsberg。
请参阅 http://msdn.microsoft.com/data/ref/linq/ [^] - Cω 语言。E. Meijer、W. Schulte、G. Bierman 等人。
请参阅 http://research.microsoft.com/Comega/ [^] - F# 语言。D. Syme。
请参阅 http://research.microsoft.com/fsharp/ [^] - 语言工作台:领域特定语言的杀手级应用?Marin Fowler。
请参阅 https://martinfowler.com.cn/articles/languageWorkbench.html [^] - 编程语言中的概念。John C. Mitchell。
剑桥大学出版社,英国剑桥,2003 年 - ILX 项目。D. Syme。
请参阅 http://research.microsoft.com/projects/ilx/ilx.aspx [^]
历史
- 2006 年 10 月 15 日 - 发布原文
- 2007 年 3 月 3 日 - 文章更新
- 2007 年 5 月 30 日 - 文章编辑并发布到 CodeProject.com 主文章库