C# 的扩展方法进化史:从 `this` 到 `extension`

C# 的扩展方法进化史:从 thisextension

把别人的类型当成自己的,C# 在这条路上越走越远。

一、传统扩展方法:给类型"加个方法"

C# 3.0 引入扩展方法的时候,解决的问题很朴素:不用修改源码、不用继承,就能给现有类型追加方法。

写法就是在一个静态类里定义一个静态方法,第一个参数前面加 this

public static class StringExtensions
{
    public static bool IsEmail(this string str)
        => str.Contains('@') && str.Contains('.');
}

然后你就能像调用实例方法一样使用它:

var result = "hello@world.com".IsEmail(); // true

这个语法糖之所以讨人喜欢,是因为它跟了 LINQ。你在任何 IEnumerable<T> 上调 .Where().Select().GroupBy(),背后全是扩展方法。如果没有这个特性,LINQ 的调用体验会差很多。

但传统扩展方法有三个天生的局限:

第一,只能加实例方法。 你不能用扩展方法给 int 加一个静态的 ParseOrDefault——静态成员不在扩展方法的射程内。你得老老实实写 Int32Helper.ParseOrDefault(...)

第二,不能加属性和运算符。 你没法给 string 加一个扩展属性 WordCount,也没法给 DateTime 加一个扩展运算符。扩展方法限定在方法这一个维度。

第三,散落各处,没有组织。 十个不同的静态类各自加了一堆扩展方法,调用方永远在猜"我该 using 哪个命名空间"。IDE 虽然能帮你补全,但代码的读者看不到这个结构。

二、C# 14 的 extension:不是加一个方法,是加一整个类型

C# 14 引入了一个新东西:扩展类型(Extension Type)。写法完全不同:

public static class MyExtensions
{
    extension(string str)
    {
        public bool IsEmail => str.Contains('@') && str.Contains('.');

        public int WordCount => str.Split(' ').Length;

        public string ToTitleCase()
            => string.Join(' ', str.Split(' ').Select(w => char.ToUpper(w[0]) + w[1..]));

        public static string Default => "";
    }
}

注意几点关键变化:

  • 不用 this 了,而是用 extension(类型名 参数名) 声明一个扩展块。
  • 块里面可以有实例方法、实例属性、静态成员——不再局限于实例方法。
  • 也支持运算符重载和类型转换。
  • 整个扩展块是一个有名字的整体,所有扩展成员集中在一个地方。

这相当于把"给别人的类型加东西"这件事从"打补丁"升级到了"贴一整块功能面板"。代码的组织性好了一个数量级。

三、对比:一张表说清楚

传统扩展方法 (this) C# 14 扩展类型 (extension)
引入版本 C# 3.0 C# 14
能加什么 仅实例方法 实例方法、静态方法、属性、运算符
写法 static void Foo(this T x) extension(T x) { ... }
生命周期 散落在各个静态类里 集中在一个扩展块内
静态成员 不支持 支持
运算符 不支持 支持
编译后的形态 静态方法 + [Extension] 特性 扩展类型 + 接收者参数

一个不那么直观的差异是:传统扩展方法本质上是"伪装成实例方法的静态方法",编译器在调用点做语法糖替换。而 C# 14 的 extension 类型是一个类型级的概念——它是编译器理解的一种新种类的类型,有自己的接收者类型、自己的成员表、自己的命名规则。

所以当你写 extension(string str) 时,编译器不是创建一个叫 "string 扩展" 的东西然后在调用点做替换。它是给 string 实实在在地附加上一整个类型定义——在这个类型里,str 是隐含的接收者,成员可以自由引用它。编译后的 IL 中,这些成员仍然是静态方法(第一个参数是接收者),但在语义分析阶段,编译器把它们当作目标类型的一部分来对待。

四、在 Jazor 里,扩展类型是我们的"接缝层"

说回到 Jazor 项目。Jazor 的核心工作是把 C# 代码编译成 JavaScript——这件事天然面临一个问题:JavaScript 内置对象在 C# 里没法自然调用。

举例来说,你想在 C# 里调用 Math.abs(-5),但 C# 里压根没有 Math.abs 这个方法。.NET 的 Math.Abs 和 JavaScript 的 Math.abs 虽然名字像,但语义细节不完全一致——参数类型、NaN 处理、返回值政策都可能有差。

传统的做法是写一个 wrapper:

public static class JsMath
{
    public static double Abs(double x) { /* ... */ }
}

// 调用
var result = JsMath.Abs(-5);

但这样做有两个问题。第一,笨重——你得为每一个 JS 内置对象手写一个 C# wrapper 类。第二,和 JavaScript 运行时的"形状"不一致——在 JS 里你写 Math.abs(x),在 C# 里却变成了 JsMath.Abs(x),这破坏了代码在两个平台之间的可移植感。

extension 关键字恰好解决了这个问题。 它让我们能把 JavaScript 内置对象直接投影成 C# 的扩展类型,调用点的写法非常自然地映射到 JS 运行时。

来看看我们在 src/ECMAScript/internal/ 下是怎么做的。

4.1 extension(Math)——把 JS 的 Math 对象变成 C# 的"类型"

public static partial class Global
{
    extension(Math)
    {
        [Description("@#abs")]
        public extern static Number AbsFn(Number x);

        [Description("@#max")]
        public extern static Number MaxFn(Number val1, Number val2);

        [Description("@#random")]
        public extern static Number RandomFn();

        [Description("@#PI")]
        public extern static Number PI { get; }

        [Description("@#E")]
        public extern static Number E { get; }
    }
}

调用端怎么写?跟直接调用 Math 一模一样:

var abs = Math.AbsFn(-5);        // → 编译成 Math.abs(-5)
var rnd = Math.RandomFn();       // → 编译成 Math.random()
var pi = Math.PI;                // → 编译成 Math.PI

[Description("@#abs")] 告诉编译器:"这个方法在 JavaScript 端的名字是 abs,挂在 Math 对象上"。C# 端叫 AbsFn 只是因为 C# 不允许方法和属性同名(Math.abs 既是一个方法名也是一个可能的属性冲突点)——加了 Fn 后缀作为 C# 端的语法逃逸,不影响 JS 输出。

这里的关键在于:没有任何 wrapper 对象。编译后的 JavaScript 里,Math.AbsFn(-5) 直接变成 Math.abs(-5)。零开销、零中间层。

4.2 extension(string str)——把 String.prototype 变成 string 的方法

JavaScript 的字符串有很多原型方法——includesstartsWithendsWithpadStartpadEnd 等等。这些东西在 .NET 的 string 上要么没有,要么名字和语义不完全一致。

我们用扩展类型把它们接过来:

public static partial class Global
{
    extension(string str)
    {
        [Description("@#includes")]
        public extern bool Includes(string? searchString);

        [Description("@#startsWith")]
        public extern bool StartsWith(string searchString, Number? position = null);

        [Description("@#padStart")]
        public extern string PadStart(Number maxLength, string fillString = " ");

        [Description("@#toUpperCase")]
        public extern string ToUpperCase();

        public extern static bool operator >(string x, string y);

        public extern static bool operator <(string x, string y);
    }
}

调用端非常自然:

var result = "hello".Includes("ell");    // → "hello".includes("ell")
var upper = name.ToUpperCase();          // → name.toUpperCase()
var padded = "7".PadStart(3, "0");       // → "7".padStart(3, "0")

注意这里的成员分类:不带 static 的是实例成员,对应 String.prototype.*;带 static 的是构造函数上的静态成员,对应 String.fromCharCode 这类。扩展类型把这两层都容纳进去了。

4.3 extension(Console)——把全局对象也"收容"进来

JavaScript 有一些方法不属于任何原型链,而是挂在全局对象或特定全局命名空间上。console.log 就是典型。

public static partial class Global
{
    extension(Console)
    {
        [Description("@#log")]
        public extern static void Log(params object?[] obj);

        [Description("@#error")]
        public extern static void Error(params object?[] obj);

        [Description("@#warn")]
        public extern static void Warn(params object?[] obj);

        [Description("@#table")]
        public extern static void Table(object? data, IEnumerable<string>? columns = null);
    }
}

调用端:

Console.Log("hello", 42);     // → console.log("hello", 42)
Console.Error("something");   // → console.error("something")

Console 不是一个真正的 C# 类——它是一个扩展类型的名字。编译器看到 Console.Log(...) 时,会识别出这个方法声明在 extension(Console) 里,然后把它 Lower 成 console.log(...) 这个 JavaScript 表达式。

4.4 编译器怎么处理扩展类型

在编译器端(SemanticWalker.cs.Reference.cs),扩展类型的处理是一个两阶段流程:

阶段一:识别。 当 SemanticWalker 遍历到某个方法的 ContainingType 时,它会检查这个类型的 display name 里有没有 .extension( 标记。如果有,说明这是一个扩展类型,提取括号里的接收者名称。

// 核心逻辑:从类型名称中解析 extension(Xxx) 的接收者
const string marker = ".extension(";
var start = display.IndexOf(marker, ...);
// 提取括号内的接收者名字 → 就是 JS 里的宿主对象名

阶段二:路由。 当方法被调用时,如果判定它的声明宿主是扩展类型,就不走普通的"包含类型.方法名"路径,而是直接构造 接收者.方法名 的 JavaScript 调用。

// 如果是扩展类型上的静态方法
callee = new MemberExpression(extensionHost, property, ...);
// extensionHost 就是解析出来的 "Math" / "Console" 等

所以在 JS 输出里,extension(Math) 上的 AbsFn 调用最终变成 Math.abs(...),而不是 Global.Math.AbsFn(...) 或任何中间包装。扩展类型是一条透明的投影管道。

4.5 为什么选择 extension 而不是传统扩展方法

这个问题其实在我们的代码里自己回答了自己。

如果用传统扩展方法(this 参数)来做 Math 的投影,写出来大概是:

public static class MathExtensions
{
    public static Number AbsFn(this Math math, Number x) { ... }
}

但这样会遇到三个问题:

  1. 你需要一个 Math 的实例。 Math.abs 在 JS 里是静态方法,没有实例。传统扩展方法要求第一个参数是接收者实例,这就对不上。
  2. 没有组织。 十个 JS 内置对象的投影散落在十个静态类里,每一个都需要 using。而用 extension 可以把它们全部放在 Global 这个 partial class 的不同文件里,共用同一个命名空间。
  3. 不支持属性。 传统扩展方法做不到 Math.PI 这样的属性投影——extension 可以。

所以与其说我们"选择了" extension,不如说 extension 恰好是我们需要的那种抽象:一组附加到现有类型上的、包含多种成员形态的声明块,编译后被干净地 Lower 到目标运行时。

五、扩展类型真正的威力:Host Projection

透过 Jazor 的用例,能看到 C# 14 extension 超越"帮已有类型加点方法"的深层价值。它本质上是一套宿主投影(Host Projection)机制

什么是宿主投影?就是你有一个目标运行环境(比如浏览器 JavaScript 引擎),它提供了一组内置对象和 API。你想在 C# 源码里以接近原生的方式使用这些 API,但又不想为它们写 wrapper 层。这时,你可以用 extension 把目标环境的类型系统投影到 C# 的命名空间里。

这跟传统扩展方法的根本区别在于方向。传统扩展方法是:我(C# 作者)要给我的类型加功能。宿主投影是:目标平台已经有一套类型,我要让 C# 认识它们,并且不插入中间层

在 Jazor 的语境下,这个思路推得很远:

  • extension(Math) 不是"给 Math 加方法",而是"把 JS 的 Math 对象原样投影到 C#"。
  • extension(string str) 不是"给 string 加方法",而是"把 JS 的 String.prototype 投影到 C# 的 string 类型上"。
  • extension(Console) 不是"给 Console 加方法",而是"把 JS 的 console 全局对象投影到 C#"。

每一条投影管道都是透明的。C# 端加 Fn 后缀只是因为 C# 语法不允许方法和属性同名——这是 C# 语法逃逸,不是语义偏差。编译到 JS 时,这些逃逸全部去掉,输出的是原生 JavaScript 调用。

这就是为什么我说 extension 是 Jazor 的"接缝层"——它是 C# 类型系统和 JavaScript 运行时之间的那层薄薄的、透明的、零开销的桥。

六、小结

C# 的类型扩展能力经历了一次从"打补丁"到"贴面板"的跃迁。

传统扩展方法this)解决的是"我想给一个类型加几个方便的方法",它的使用场景是轻量级的、临时的、针对性的。

扩展类型extension)解决的是"目标平台有一整套类型,我想让 C# 用户以原生体验使用它们",它的使用场景是体系化的、结构性的、跨语言边界的。

在 Jazor 里,扩展类型承担的角色不是"方便的工具",而是核心基础设施。没有它,我们把 C# 编译到 JavaScript 这件事就会退化成一个笨重的 wrapper 工厂——每一个 JS 内置对象都得配一个手写的 C# 代理类。有了它,编译器只需要认 extension(Xxx) 声明,自动完成宿主路由,输出就是干净的原生 JS 调用。

如果你在做类似的跨语言编译工作,或者只是想在 C# 里给现有类型加一套成体系的功能,C# 14 的 extension 值得花一个下午试试。

项目地址:https://github.com/devhxj/Jazor

posted @ 2026-05-11 13:33  形而下晓寒  阅读(11)  评论(0)    收藏  举报