Menu

C# 扩展方法:语法、IEnumerable、LINQ 与规则

C# 中的扩展方法是怎么工作的:静态类中第一个参数带 this 的静态方法,像实例方法一样调用它,扩展 string、枚举和 IEnumerable<T>,LINQ 如何用它们构建,为什么实例方法优先,null 接收者,以及它们需要的 using 指令。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

扩展方法可以给已有的类型添加方法,而不需要修改它、继承它或包装它。你写一个普通的静态方法,用 this 标记它的第一个参数,调用方就能用点语法使用它,就好像这个类型本来就有这个方法一样:

输出:

Extension methods let yo...
12
short
Extension...

title.Truncate(24) 会被编译成完全等同的 StringExtensions.Truncate(title, 24)。点语法只是一种便利:方法仍然是静态的,仍然位于 StringExtensions 中,而且只能使用 string 的公共成员。

声明扩展方法的规则

  • 方法必须是 static 的,并且位于一个 static、非泛型、没有嵌套在其他类中的类里。非静态或泛型的类会报 CS1106:Extension method must be defined in a non-generic static class;嵌套的类会报 CS1109:Extension methods must be defined in a top level static class。
  • this 只能放在第一个参数上,这个参数的类型就是被扩展的类型。其余参数都是普通参数。
  • 方法本身可以是泛型的(this IEnumerable<T> source),即使类不能是泛型的。
  • 它能看到的只有任何外部代码都能看到的东西:公共成员(以及同一程序集内的 internal 成员)。私有字段仍然是私有的。

惯例是按被扩展的类型给类命名(StringExtensions、EnumerableExtensions),并把它们归到 MyApp.Extensions 这样的命名空间中。

扩展接口与 IEnumerable<T>

扩展一个接口,就等于给实现它的每一个类型都加上了这个方法。因此,扩展 IEnumerable<T> 会一次性把方法加给数组、列表、集合、字典的键集合以及 LINQ 查询结果:

输出:

9.5
50
65
f0 f3 f6

EveryNth 是泛型的:T 从接收者推断出来,所以它在这里用于字符串,也适用于任何其他元素类型。因为它使用了 yield return,所以它和内置的 LINQ 运算符一样是延迟执行的,并且可以与它们链式调用。

LINQ 建立在扩展方法之上

Where、Select、OrderBy、Sum、First 以及 LINQ 的其余部分,都是 IEnumerable<T> 上的扩展方法,定义在静态类 System.Linq.Enumerable 中。这就是为什么文件顶部必须有 using System.Linq;,list.Where(...) 才能编译通过;也是为什么 LINQ 能用于每一种集合类型,而它们没有一个实现了 Where 方法:

输出:

72, 88, 95
72, 88, 95

这两行是同样的代码。扩展语法把从里往外读的嵌套调用,变成了按步骤发生顺序阅读的链式调用,这正是扩展方法存在的主要原因:它们是在 C# 3 中与 LINQ 一起加入的。LINQ 那一页介绍了各个运算符。

必须导入命名空间

只有用 using 导入了扩展方法所在静态类的命名空间,它才在作用域内。否则调用会失败,就好像这个方法不存在一样:

error CS1061: 'string' does not contain a definition for 'Truncate' and no accessible extension method 'Truncate' accepting a first argument of type 'string' could be found (are you missing a using directive or an assembly reference?)

为扩展方法的命名空间加上 using(或者从 C# 10 起,为整个项目写一次 global using):

输出:

EUR 49.90

反过来看,这一点也很有用:不想到处可见的扩展方法,可以放在只有部分文件才导入的命名空间里。

实例方法优先

如果类型已经有一个适用的实例方法,编译器就会用它,根本不会考虑扩展方法。签名相同的扩展方法,在每个使用点语法的调用处都是无效代码:

输出:

Invoice (instance method): 120
Invoice (extension): 120 USD
extension: only reachable as a static call

只有一个参数的扩展方法输给了实例方法;有两个参数的那个被使用了,因为没有哪个实例方法接收字符串参数。这也意味着,如果库的某次更新添加了一个与你的扩展方法同名的实例方法,你的调用就会悄无声息地切换到库的版本。请选择不太可能冲突的扩展方法名。

在 null 上调用

由于这个调用实际上是静态调用,null 接收者不会在调用处抛出异常。方法收到 null 作为第一个实参,并决定这意味着什么:

输出:

True
AL
ArgumentNullException: fullName

missing.IsBlank() 返回 true 很方便,但可能会让以为在 null 上调用成员就会抛出异常的读者感到意外。只在名称一看就明白的方法中接受 null(IsBlank、OrEmpty);其他地方,像 LINQ 那样抛出 ArgumentNullException。

扩展枚举

枚举不能有自己的方法,这让它们成为扩展方法的天然目标:

enum OrderStatus { Pending, Paid, Shipped, Delivered, Cancelled }

static class OrderStatusExtensions
{
    public static bool IsFinal(this OrderStatus s) =>
        s == OrderStatus.Delivered || s == OrderStatus.Cancelled;
}

// usage: if (order.Status.IsFinal()) { ... }

C# 14 中的扩展成员

在 C# 14 之前,只有方法可以作为扩展。C# 14 新增了 extension 块,它把同一个接收者的成员归在一起,还允许扩展属性和静态成员:

// C# 14 and later
public static class StringExtensions
{
    extension(string s)
    {
        public bool IsBlank => string.IsNullOrWhiteSpace(s);   // extension property
        public string Truncate(int max) => s.Length <= max ? s : s[..max] + "...";
    }
}

经典的 this 参数扩展方法依然有效,几乎所有现有代码和库用的都是它。

什么时候该写扩展方法

当类型不归你所有(string、DateTime、框架接口)、当你希望某个接口的每个实现都有同一个辅助方法,或者当链式调用比嵌套调用更易读时,扩展方法很合适。如果类是你自己的,就直接添加真正的实例方法:它可以使用私有状态,而且出现在读者会去找的地方。另外,避免扩展 object,那会让你的方法出现在程序中每个值的自动补全列表里。

常见问题

C# 中的扩展方法是什么?

扩展方法是一种静态方法,调用时就像它是另一个类型的实例方法一样。你把它写在静态类中,并在第一个参数前加上 this:public static bool IsBlank(this string s) => string.IsNullOrWhiteSpace(s);。这样,任何字符串都能使用 name.IsBlank(),而无需修改或继承 string。

为什么找不到我的扩展方法?

只有导入了扩展方法所在的命名空间,它才可见。如果静态类在 MyApp.Extensions 中,就在调用它的文件里加上 using MyApp.Extensions;;否则编译器会报 CS1061,说这个类型不包含该名称的定义,也找不到可访问的同名扩展方法。另外还要检查这个类是否是 static 的、非泛型的、没有嵌套在其他类中。

扩展方法能覆盖实例方法吗?

不能。编译器会先查找适用的实例方法,只有在找不到时才考虑扩展方法。与实例方法同名且参数相同的扩展方法,在每个使用实例语法的调用处都会被悄悄忽略。

C# 中可以在 null 上调用扩展方法吗?

可以。这个调用会编译成一次以 null 为第一个实参的静态方法调用,所以调用本身不会抛出 NullReferenceException。由方法决定怎么处理:把 null 当作合法输入(类似 IsNullOrEmpty 那样的辅助方法),或者像 LINQ 的方法那样抛出 ArgumentNullException。

C# 中有扩展属性吗?

C# 14 之前没有。在那之前,只有方法可以作为扩展,所以本想写成属性的东西只能写成方法(GetFullName())。C# 14 新增了扩展块,除了方法,还可以声明扩展属性和静态成员。

Coddy 编程语言插图

和 Coddy 一起学编程

开始