扩展成员(C# 编程指南)

使用扩展成员可以“添加”方法到现有类型,而无需创建新的派生类型、重新编译或其他修改原始类型。

从 C# 14 开始,可以使用两种语法来定义扩展方法。 C# 14 添加了 extension 块,可在其中为类型或类型的实例定义多个扩展成员。 在 C# 14 之前,将修饰符添加到 this 静态方法的第一个参数,以指示该方法显示为参数类型的实例的成员。

扩展块支持多个成员类型:方法、属性和运算符。 使用扩展块,可以定义实例扩展和静态扩展。 实例扩展用于扩展类型的实例,而静态扩展用于扩展类型本身。 使用 this 修饰符声明的扩展方法的形式支持实例扩展方法。

扩展方法是静态方法,但它们被调用,就像它们是扩展类型的实例方法一样。 对于用 C#、F# 和 Visual Basic 编写的客户端代码,调用扩展方法和类型中定义的方法之间没有明显的区别。 这两种扩展方法的形式都编译为相同的 IL(中间语言)。 扩展成员的使用者不需要知道哪些语法用于定义扩展方法。

最常见的扩展成员是 LINQ 标准查询运算符,用于向现有 System.Collections.IEnumerable 和 System.Collections.Generic.IEnumerable<T> 类型添加查询功能。 若要使用标准查询运算符,请先使用 using System.Linq 指令将其引入范围。 然后,实现IEnumerable<T>的任何类型似乎都有实例方法,例如GroupBy,OrderByAverage等等。 在IEnumerable<T>类型的实例(如List<T>或Array)后键入“dot”时,可以在 IntelliSense 语句完成中看到这些附加方法。

OrderBy 示例

以下示例演示如何对整数数组调用标准查询运算符 OrderBy 方法。 括号中的表达式是 lambda 表达式。 许多标准查询运算符将 lambda 表达式作为参数。 有关详细信息,请参阅 Lambda 表达式。

int[] numbers = [10, 45, 15, 39, 21, 26];
IOrderedEnumerable<int> result = numbers.OrderBy(g => g);
foreach (int i in result)
{
    Console.Write(i + " ");
}
//Output: 10 15 21 26 39 45

扩展方法定义为静态方法,但使用实例方法语法调用。 它们的第一个参数指定方法操作的类型。 参数遵循 此 修饰符。 扩展方法只有在通过using指令显式导入命名空间到源代码中时才会在作用范围内。

宣布扩展成员

从 C# 14 开始,可以声明 扩展块。 扩展块是包含类型或该类型的实例的扩展成员的非嵌套、非泛型静态类中的块。 下面的代码示例定义了 string 类型的扩展块。 扩展块包含一个成员:计算字符串中单词的方法:

namespace CustomExtensionMembers;

public static class MyExtensions
{
    extension(string str)
    {
        public int WordCount() =>
            str.Split([' ', '.', '?'], StringSplitOptions.RemoveEmptyEntries).Length;
    }
}

扩展块中的扩展成员可以使用开放或封闭的泛型。 类型参数可以包括约束:

public static class MyGenericExtensions
{
    extension<T>(IEnumerable<T> source)
        where T : IEquatable<T>
    {
        public IEnumerable<T> ValuesEqualTo(T threshold)
            => source.Where(x => x.Equals(threshold));
    }
}

在 C# 14 之前,通过将修饰符添加到 this 第一个参数来声明扩展方法:

namespace CustomExtensionMethods;

public static class MyExtensions
{
    public static int WordCount(this string str) =>
        str.Split([' ', '.', '?'], StringSplitOptions.RemoveEmptyEntries).Length;
}

必须在非嵌套的非泛型静态类中定义这两种形式的扩展。

它可以通过使用访问实例成员的语法从应用程序调用:

string s = "Hello Extension Methods";
int i = s.WordCount();

虽然扩展成员向现有类型添加了新功能,但扩展成员不会违反封装原则。 扩展类型的所有成员的访问声明适用于扩展成员。

MyExtensions 类和 WordCount 方法都是 static,可以像所有其他 static 成员那样对其进行访问。 WordCount可以像其他static方法一样调用该方法,如下所示:

string s = "Hello Extension Methods";
int i = MyExtensions.WordCount(s);

上述的 C# 代码适用于扩展块和用于扩展成员的this语法。 前面的代码:

  • 声明并赋值一个名为string、值为s的新"Hello Extension Methods"。
  • 调用 MyExtensions.WordCount 给定自变量 s。

有关详细信息,请参阅 如何实现和调用自定义扩展方法。

一般来说,你调用扩展成员的次数可能远远多于实现它们的次数。 扩展成员的调用方式如同它们是扩展类的成员,因此从客户端代码使用它们时无需具备特殊知识。 若要为特定类型启用扩展成员,只需为在其中定义方法的命名空间添加一个 using 指令。 例如,若要使用标准查询运算符,请将此 using 指令添加到代码中:

using System.Linq;

在编译时绑定扩展成员

可以使用扩展成员来扩展类或接口,但不能替代类中定义的行为。 与接口或类成员同名且签名相同的扩展成员永远不会被调用。 在编译时,扩展成员的优先级始终低于类型本身中定义的实例(或静态)成员。 换句话说,如果类型具有一个命名 Process(int i)的方法,并且你有一个具有相同签名的扩展方法,则编译器始终绑定到成员方法。 当编译器遇到成员调用时,它会首先在类型的成员中查找匹配项。 如果没有找到匹配项,系统将搜索该类型定义的任何扩展成员。 它绑定到它找到的第一个扩展成员。 以下示例演示 C# 编译器在确定是绑定到类型上的实例成员还是绑定到扩展成员时所遵循的规则。 静态类 Extensions 包含为实现 IMyInterface 的任何类型定义的扩展成员。

public interface IMyInterface
{
    void MethodB();
}

// Define extension methods for IMyInterface.

// The following extension methods can be accessed by instances of any
// class that implements IMyInterface.
public static class Extension
{
    public static void MethodA(this IMyInterface myInterface, int i) =>
        Console.WriteLine("Extension.MethodA(this IMyInterface myInterface, int i)");

    public static void MethodA(this IMyInterface myInterface, string s) =>
        Console.WriteLine("Extension.MethodA(this IMyInterface myInterface, string s)");

    // This method is never called in ExtensionMethodsDemo1, because each
    // of the three classes A, B, and C implements a method named MethodB
    // that has a matching signature.
    public static void MethodB(this IMyInterface myInterface) =>
        Console.WriteLine("Extension.MethodB(this IMyInterface myInterface)");
}

可以使用 C# 14 扩展成员语法声明等效扩展:

public static class Extension
{
    extension(IMyInterface myInterface)
    {
        public void MethodA(int i) =>
            Console.WriteLine("Extension.MethodA(this IMyInterface myInterface, int i)");

        public void MethodA(string s) =>
            Console.WriteLine("Extension.MethodA(this IMyInterface myInterface, string s)");

        // This method is never called in ExtensionMethodsDemo1, because each
        // of the three classes A, B, and C implements a method named MethodB
        // that has a matching signature.
        public void MethodB() =>
            Console.WriteLine("Extension.MethodB(this IMyInterface myInterface)");
    }
}

A类、B类和C类都实现了该接口:

// Define three classes that implement IMyInterface, and then use them to test
// the extension methods.
class A : IMyInterface
{
    public void MethodB() { Console.WriteLine("A.MethodB()"); }
}

class B : IMyInterface
{
    public void MethodB() { Console.WriteLine("B.MethodB()"); }
    public void MethodA(int i) { Console.WriteLine("B.MethodA(int i)"); }
}

class C : IMyInterface
{
    public void MethodB() { Console.WriteLine("C.MethodB()"); }
    public void MethodA(object obj)
    {
        Console.WriteLine("C.MethodA(object obj)");
    }
}

MethodB从不调用扩展方法,因为它的名称和签名与类已经实现的方法完全匹配。 当编译器找不到具有匹配签名的实例方法时,它将绑定到匹配的扩展方法(如果存在)。

// Declare an instance of class A, class B, and class C.
A a = new A();
B b = new B();
C c = new C();

// For a, b, and c, call the following methods:
//      -- MethodA with an int argument
//      -- MethodA with a string argument
//      -- MethodB with no argument.

// A contains no MethodA, so each call to MethodA resolves to
// the extension method that has a matching signature.
a.MethodA(1);           // Extension.MethodA(IMyInterface, int)
a.MethodA("hello");     // Extension.MethodA(IMyInterface, string)

// A has a method that matches the signature of the following call
// to MethodB.
a.MethodB();            // A.MethodB()

// B has methods that match the signatures of the following
// method calls.
b.MethodA(1);           // B.MethodA(int)
b.MethodB();            // B.MethodB()

// B has no matching method for the following call, but
// class Extension does.
b.MethodA("hello");     // Extension.MethodA(IMyInterface, string)

// C contains an instance method that matches each of the following
// method calls.
c.MethodA(1);           // C.MethodA(object)
c.MethodA("hello");     // C.MethodA(object)
c.MethodB();            // C.MethodB()
/* Output:
    Extension.MethodA(this IMyInterface myInterface, int i)
    Extension.MethodA(this IMyInterface myInterface, string s)
    A.MethodB()
    B.MethodA(int i)
    B.MethodB()
    Extension.MethodA(this IMyInterface myInterface, string s)
    C.MethodA(object obj)
    C.MethodA(object obj)
    C.MethodB()
 */

常见使用模式

集合功能

过去,通常会创建一种“集合类”,这种类实现了System.Collections.Generic.IEnumerable<T>接口,并包含用于对这种类型的集合进行操作的功能。 虽然创建这种类型的集合对象没有任何问题,但可以使用扩展来实现 System.Collections.Generic.IEnumerable<T>相同的功能。 扩展的优势是允许从任何集合(如 System.Array 或实现该类型 System.Collections.Generic.IEnumerable<T> 的 System.Collections.Generic.List<T>)调用功能。 本文前面提供了使用 Int32 数组的一个示例。

特定于层的功能

使用 Onion 体系结构或其他分层应用程序设计时,通常有一组可用于跨应用程序边界进行通信的域实体或数据传输对象。 这些对象通常不包含任何功能,或者只包含应用于应用程序所有层的最小功能。 扩展方法可用于添加特定于每个应用程序层的功能。

public class DomainEntity
{
    public int Id { get; set; }
    public required string FirstName { get; set; }
    public required string LastName { get; set; }
}

static class DomainEntityExtensions
{
    static string FullName(this DomainEntity value)
        => $"{value.FirstName} {value.LastName}";
}

可以使用新的扩展块语法在 C# 14 及更高版本中声明等效 FullName 属性:

static class DomainEntityExtensions
{
    extension(DomainEntity value)
    {
        string FullName => $"{value.FirstName} {value.LastName}";
    }
}

扩展预定义类型

无需在需要创建可重用功能时创建新对象,通常可以扩展现有类型,例如 .NET 或 CLR 类型。 例如,如果不使用扩展方法,您可能会创建一个 Engine 或 Query 类实例来负责在 SQL Server 上执行查询,该查询可能会在我们代码中的多个位置被调用。 但是,你可以改用扩展方法扩展 Microsoft.Data.SqlClient.SqlConnection 类,以便从与 SQL Server 建立连接的任何位置执行该查询。 其他示例可能是向 System.String 类添加常见功能、扩展 System.IO.Stream 和 System.Exception 对象的数据处理功能以实现特定的错误处理功能。 这些类型的用例仅受你的想象力和良好感觉的限制。

使用 struct 类型扩展预定义类型可能很困难,因为它们通过值传递给方法。 这意味着将对结构的副本进行任何结构更改。 扩展方法退出后,这些更改不可见。 您可以在第一个参数上添加 ref 修饰符,使其成为 ref 扩展方法。 关键字 ref 可以在关键字之前或之后 this 显示,没有任何语义差异。 ref添加修饰符表示第一个参数是通过引用传递的。 通过此方法,可以编写扩展方法,以更改要扩展的结构的状态(请注意,私有成员无法访问)。 只有受结构约束的值类型或泛型类型(有关这些规则的详细信息,请参阅约束的文章struct)被允许作为扩展方法的第一个ref参数或扩展块的接收器。 以下示例演示如何使用 ref 扩展方法直接修改内置类型,而无需重新分配结果或使用 ref 关键字通过函数传递它:

public static class IntExtensions
{
    public static void Increment(this int number)
        => number++;

    // Take note of the extra ref keyword here
    public static void RefIncrement(this ref int number)
        => number++;
}

等效的扩展块显示在以下代码中:

public static class IntExtensions
{
    extension(int number)
    {
        public void Increment()
            => number++;
    }

    // Take note of the extra ref keyword here
    extension(ref int number)
    {
        public void RefIncrement()
            => number++;
    }
}

需要不同的扩展块来区分接收方按值和按引用参数模式。

在以下示例中,您可以看到将ref应用于接收方后的差异:

int x = 1;

// Takes x by value leading to the extension method
// Increment modifying its own copy, leaving x unchanged
x.Increment();
Console.WriteLine($"x is now {x}"); // x is now 1

// Takes x by reference leading to the extension method
// RefIncrement changing the value of x directly
x.RefIncrement();
Console.WriteLine($"x is now {x}"); // x is now 2

通过向用户定义的结构类型添加 ref 扩展成员,可以应用相同的技术。

public struct Account
{
    public uint id;
    public float balance;

    private int secret;
}

public static class AccountExtensions
{
    // ref keyword can also appear before the this keyword
    public static void Deposit(ref this Account account, float amount)
    {
        account.balance += amount;

        // The following line results in an error as an extension
        // method is not allowed to access private members
        // account.secret = 1; // CS0122
    }
}

也可以使用 C# 14 中的扩展块创建上述示例:

public static class AccountExtensions
{
    extension(ref Account account)
    {
        // account is the receiver (analogous to the this parameter in extension methods).
        // The ref modifier on the extension block means the receiver is passed by reference.
        public void Deposit(float amount)
        {
            account.balance += amount;

            // The following line results in an error as an extension
            // method is not allowed to access private members
            // account.secret = 1; // CS0122
        }
    }
}

可按如下所示访问这些扩展方法:

Account account = new()
{
    id = 1,
    balance = 100f
};

Console.WriteLine($"I have ${account.balance}"); // I have $100

account.Deposit(50f);
Console.WriteLine($"I have ${account.balance}"); // I have $150

一般准则

最好通过修改对象的代码或派生新类型来添加功能,只要合理且可能这样做。 扩展方法是在整个 .NET 生态系统中创建可重用功能的关键选项。 当原始源不在你的控件下、当派生对象不合适或不可能时,或者当功能有限时,扩展成员是可取的。

有关派生类型的详细信息,请参阅 “继承”。

如果确实为给定类型实现扩展方法,请记住以下几点:

  • 如果扩展方法与类型中定义的方法具有相同的签名,则不会调用扩展方法。
  • 扩展方法在命名空间级别进入范围。 例如,如果多个静态类包含名为 Extensions单个命名空间中的扩展方法,则所有这些静态类都由 using Extensions; 指令引入范围。

对于实现的类库,不应使用扩展方法来避免递增程序集的版本号。 如果要向拥有源代码的库添加重要功能,请遵循程序集版本控制的 .NET 准则。 有关详细信息,请参阅 程序集版本控制。

另请参阅