Lesson 24: C# 14 — 小特性合集 Small Features

Span 隐式转换 · 自定义复合赋值 · partial 构造/事件 · 单文件应用 · C# 14 收官

前置:已完成 Lesson 23(中等特性合集)。本课是 C# 14 最后一课——四个"你不一定每天用到,但需要时救你一命"的特性。至此 C# 14(.NET 10 LTS)全部 10 个特性覆盖完毕。
阅读:Microsoft Learn: C# 14 新增功能 · Span 一等公民提案 · 复合赋值提案

〇、四个场景 Four Specialized Tools

这四个特性不是 field 关键字那种"人人每天用",也不是扩展成员那种"改变语言叙事"。它们是专用工具——特定场景下的生产力飞跃:

特性谁最受益一句话
Span 隐式转换性能敏感代码作者数组和字符串直接当 Span 用——不用再写 .AsSpan()
自定义复合赋值数值库/游戏数学作者v += other 直接修改原对象——零分配
partial 构造/事件源生成器作者构造和事件可以跨文件拆分——生成代码新维度
单文件应用脚本/原型开发者dotnet run app.cs——不需要 .csproj,不需要 sln

一、Span 隐式转换 First-Class Span Support

1.1 问题:Span 好用,但创建它很啰嗦

Span 自 C# 7.2 引入以来一直是高性能代码的基石。但把普通类型变成 Span 需要显式转换——每次用一个 API 都要先调用 .AsSpan() 或强制转换:

// C# 7.2~13:常见场景——每一次都多写一行
ReadOnlySpan<char> name = "Christopher".AsSpan();  // 字符串 → Span
Span<int> numbers = stackalloc[] { 1, 2, 3 };     // stackalloc 可以,但…

int[] data = { 1, 2, 3, 4, 5 };
ProcessData(data.AsSpan());                       // 数组 → Span——每次都要 .AsSpan()
ProcessData((Span<int>)data);                    // 或者显式转换

void ProcessData(Span<int> span) { ... }

问题不在于 Span 的 API 设计——问题在于 C# 语言没把 Span 当成一等公民。数组可以隐式转为 IEnumerable<T>,为什么不能隐式转为 Span<T>

1.2 C# 14:五条隐式转换

// C# 14——这些都隐式转换,不需要 .AsSpan()
ReadOnlySpan<char> name = "Christopher";             // ① string → ROSpan<char>
Span<int> numbers = stackalloc[] { 1, 2, 3 }; // ② stackalloc → Span<T>

int[] data = { 1, 2, 3 };
Span<int> s = data;                           // ③ T[] → Span<T>
ReadOnlySpan<int> rs = data;                    // ④ T[] → ReadOnlySpan<T>
ReadOnlySpan<int> rs2 = s;                      // ⑤ Span<T> → ReadOnlySpan<T>

ProcessData(data);                              // 直接传数组——不需要 .AsSpan()!
最大收益:API 作者只需要暴露 ReadOnlySpan<T> 一个重载——数组、Span、string 全部隐式传入。不用为了覆盖不同调用方写 N 个重载。

1.3 扩展方法接收者——历史性突破

在 C# 13 中,数组不能调用 Span 的扩展方法——这是最别扭的限制之一:

// C# 13:数组不能调 Span 扩展方法 ❌
int[] arr = [1, 2, 3];
Console.WriteLine(arr.StartsWith(1));  // CS8773: 数组不能成为 Span 扩展方法的接收者

// C# 14:隐式转换让数组也能调 ✅
int[] arr = [1, 2, 3];
Console.WriteLine(arr.StartsWith(1));  // True——arr 隐式转为 ReadOnlySpan<int>
⚠ 重大更改:Span 重载优先级高于 IEnumerable!T[] 同时匹配 ReadOnlySpan<T>IEnumerable<T> 两个重载时,C# 14 会选择 Span 重载。这是因为 C# 重载决议的"更好转换"规则:int[]ReadOnlySpan<int> 不涉及接口分派、不涉及装箱——编译器直接把数组内部指针打包成 Span 结构体——被视为比 int[]IEnumerable<int>(接口引用转换)更直接的转换。具体类型(或 ref struct)优先于接口类型——这是一条通用规则。在表达式树(expression tree)场景中尤其需要注意:LINQ provider 可能生成不同的 SQL。

1.4 重载决议细节

int[] data = [1, 2, 3];

void Process(ReadOnlySpan<int> items) => Console.WriteLine("快路径");
void Process(IEnumerable<int> items) => Console.WriteLine("慢路径");

Process(data);  // C# 13: 走 IEnumerable 重载
                // C# 14: 走 Span 重载 ← 重载决议往高性能方向倾斜

这在 99% 的场景中正是你想要的——零分配、更快。你不需要改调用代码,升级编译器就行。这也是 C# 14 把 Span 提升为"一等公民"的真正力量:整个生态的重载决议都往高性能方向倾斜

1.5 转换一览

C# 13C# 14
stringReadOnlySpan<char>❌ 需 .AsSpan()✅ 隐式
T[]Span<T>❌ 需显式转换✅ 隐式
T[]ReadOnlySpan<T>❌ 需显式转换✅ 隐式
Span<T>ReadOnlySpan<T>❌ 需显式转换✅ 隐式
stackalloc T[]Span<T>✅ 已经是隐式✅ 保持

二、自定义复合赋值运算符 User-Defined Compound Assignment

2.1 问题:+= 永远创建新对象

过去定义 operator + 时,编译器自动把 a += b 展开为 a = a + b——每次都创建新实例。对于大类型(矩阵、向量、BigInteger),这产生大量垃圾:

// C# 13:只能定义 operator +
public struct Vector3
{
    public decimal X, Y, Z;

    public static Vector3 operator +(Vector3 left, Vector3 right)
        => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
}

var v = new Vector3 { X = 1, Y = 2, Z = 3 };
v += new Vector3 { X = 4, Y = 5, Z = 6 };
// 编译器生成:v = v + other   ← v + other 创建了新 Vector3,然后赋值回 v
// 循环中反复 += → 大量临时对象 → GC 压力

2.2 C# 14:实例方法形式的复合赋值——原地修改

// C# 14:定义 void operator += —— 原地修改,零分配
public struct Vector3
{
    public decimal X, Y, Z;

    // 传统 operator +(独立的 a + b 表达式仍然可用)
    public static Vector3 operator +(Vector3 left, Vector3 right)
        => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);

    // 🆕 复合赋值——实例方法,void 返回,直接改 this
    public void operator +=(Vector3 right)
    {
        X += right.X;
        Y += right.Y;
        Z += right.Z;
    }
}

var v = new Vector3 { X = 1, Y = 2, Z = 3 };
v += new Vector3 { X = 4, Y = 5, Z = 6 };
// 编译器直接用 void operator += —— 不创建新对象

// operator + 仍然独立存在:
var v3 = v1 + v2;   // 用传统 operator + —— 创建新对象(合理——你需要新值)
关键洞察:过去 a += b 只是 a = a + b 的语法糖——你没法区分"想原地修改"和"想新建赋值"。C# 14 让这两者有了不同的编译路径:复合赋值走原地修改,独立运算符走新建。

2.3 编译器抉择顺序

遇到 a += b 时编译器的查找优先级:

a += b → ① 找 void operator +=(T) 实例方法? → 有 → 用(原地修改) ② 没有 → 降级为 a = a + b → 找 operator +(T, T) 静态方法 ③ 也没有 → 编译错误

2.4 支持的运算符一览

运算符签名说明
+=public void operator +=(T value)加法复合赋值
-=public void operator -=(T value)减法
*=//=/%=public void operator *=(T value)乘除模
&=/|=/^=public void operator &=(T value)位运算
<<=/>>=/>>>=public void operator <<=(int value)移位
++/--public void operator ++()增减——无参数

2.5 checked 上下文——控制溢出行为

C# 中整数运算溢出有两种处理模式:

int max = int.MaxValue;   // 2147483647
int bad = max + 1;         // -2147483648 —— unchecked 是默认,静默溢出

checked
{
    int crash = max + 1;  // 💥 OverflowException!
}

unchecked默认(性能优先),checked 需显式开启——用 checked 块或 /checked+ 编译选项。

复合赋值支持两种版本:

public struct IntVector
{
    public int X, Y;

    // 无标记版本——跟随调用方的 checked/unchecked 上下文
    public void operator +=(IntVector r) { X += r.X; Y += r.Y; }

    // 🆕 checked 版本——不管调用方什么上下文,溢出必须抛异常
    public void operator checked +=(IntVector r)
    {
        X = checked(X + r.X);
        Y = checked(Y + r.Y);
    }
}

编译器在 checked 上下文中优先选 checked 版本,否则选无标记版本。这和 C# 11 引入的 operator checked + 是同一套机制。(注意课里的 Vector3decimal——decimal 始终是 checked 的,不需要显式声明。)

三、partial 构造和事件 Partial Constructors & Events

3.1 问题:源生成器只能生成方法,不能生成构造

C# 2 引入 partial class,C# 3 引入 partial method,C# 9 引入 partial property。但构造事件——两种最常见的代码生成目标——一直不能 partial。

这对源生成器(Source Generator)是一个真实限制。比如你写了一个 [WeakEvent] 特性,希望源生成器自动生成事件的基础设施代码——做不到,因为事件不能 partial。你只能用手写方式绕路。

3.2 C# 14:partial 构造

// ──── 你写的代码(定义声明) ────
public partial class User
{
    public partial User(string name);           // ← 声明——只有签名,没有体
}

// ──── 源生成器产生的代码(实现声明) ────
public partial class User
{
    public partial User(string name)             // ← 实现——实际体
    {
        Name = name ?? throw new ArgumentNullException(nameof(name));
    }

    public string Name { get; }
}

关键规则:

3.3 partial 事件

// ──── 你写的代码(定义声明) ────
public partial class Service
{
    [WeakEvent]
    public partial event EventHandler? OnChange;  // ← 字段式声明
}

// ──── 源生成器产生的代码(实现声明) ────
public partial class Service
{
    private readonly WeakEvent _onChange = new();

    public partial event EventHandler? OnChange
    {
        add    => _onChange.Add(value);            // 必须同时写 add 和 remove
        remove => _onChange.Remove(value);
    }
}
现实场景:Xamarin/.NET MAUI 的 Objective-C 互操作、MVVM 工具包的属性变更通知、Avalonia 的绑定代码——都是 partial 构造/事件的目标用户。这个特性的存在理由就是让源生成器能做以前只能手写的事

四、单文件应用 File-Based Apps

4.1 问题:一个 .cs 文件也需要 .csproj

想写一个快速脚本——读取文件、发个 HTTP 请求、格式化输出——需要先 dotnet new console,改 .csproj 加 NuGet 包,然后才能写代码。相比之下 Python 的 python script.py 零体力。

4.2 C# 14:直接 dotnet run 一个 .cs 文件

// ──── script.cs —— 一个文件,零项目文件 ────
#!/usr/bin/env dotnet
#:package Spectre.Console@*

using Spectre.Console;

AnsiConsole.MarkupLine("[bold green]Hello from a single file![/]");

var files = Directory.GetFiles(".", "*.cs");
AnsiConsole.Write(new BarChart()
    .AddItem("C# Files", files.Length, Color.Green));
# 运行——不需要 .csproj,不需要 sln
dotnet run script.cs

# Unix/Mac——加执行权限后直接运行
chmod +x script.cs
./script.cs

4.3 四个 #: 指令

指令作用示例
#:package引用 NuGet 包#:package Spectre.Console@*
#:project引用其他项目#:project ../Lib/Lib.csproj
#:property设置 MSBuild 属性#:property PublishAot=true
#:sdk指定 SDK#:sdk Microsoft.NET.Sdk.Web
边界:#!#: 指令会被 C# 编译器忽略——它们由 MSBuild 在项目文件生成阶段读取。在传统项目中使用这些指令只会产生警告,不会报错。Native AOT 仅针对 dotnet publish——dotnet run 走普通 JIT 编译(保证迭代速度)。

4.4 dotnet run vs dotnet publish——产出完全不同

这是最容易误解的地方——两者的编译目标不是一回事

dotnet run app.cs

走常规 JIT 构建→运行,不开启 Native AOT(否则改一行等几十秒)。SDK 在内存中生成虚拟 .csproj,恢复 NuGet 包,Roslyn 编译为 IL 程序集,输出到系统临时目录:

# 构建输出位置(几乎不会手动去看)
<temp>/dotnet/runfile/app-<hash>/bin/Debug/net10.0/
├── app.dll                  ← IL 程序集
├── app.runtimeconfig.json
├── app.deps.json            ← 依赖图
├── Spectre.Console.dll      ← NuGet 包(如有)
└── ...

dotnet publish app.cs

默认开启 Native AOT——产出单个自包含原生二进制,无需 .NET 运行时

# 发布输出——源码旁边的 artifacts/ 目录
artifacts/publish/app/
└── app.exe    ← 单文件原生二进制(Windows)
                   ≈8–15 MB,含你的代码 + NuGet 包 + 运行时子集
                   扔到任何一台没装 .NET 的机器上直接跑
dotnet rundotnet publish
编译目标IL (.dll)原生机器码 (.exe)
输出位置系统临时目录artifacts/publish/(源码旁)
首次耗时< 1 秒5–30 秒(AOT 编译慢)
启动速度~100ms(JIT)< 5ms(无 JIT)
需要 .NET 运行时?需要不需要
第三方库独立 .dll 文件全部编译进单一二进制
要关闭 Native AOT:#:property PublishAot=false。如果只需要本地跑脚本,dotnet run 就是全部你需要的——Native AOT 是给分发用的。

4.5 定位——不是替代项目,是替代脚本

单文件应用不是让你把整个生产项目塞进一个文件。它的定位是:

🧪 快速原型

试一个 API、验证一个想法——
不需要建项目、配 NuGet。

🔧 运维脚本

数据处理、文件批处理、
CI 工具——C# 强类型安全。

📖 教学/示例

分享一个概念——给对方一个
.cs 文件,不是整个 repo。

这不意味着 C# 成了脚本语言——它仍然是编译型、强类型的。只是绕过了项目文件的仪式,让 C# 在"临时写个东西"的赛道上可以一用。

五、测验 Quiz

1/5 · Span 隐式转换

C# 14 中以下哪个转换不是隐式的?

2/5 · 复合赋值抉择

以下代码中,v += other 走哪条编译路径?

struct V { public void operator +=(V r) => X += r.X; }

3/5 · partial 构造规则

关于 partial 构造,以下哪个说法是正确的?

4/5 · 单文件应用指令

以下哪个不是单文件应用的有效指令?

5/5 · 综合判断

关于 C# 14 这四个小特性,以下哪个说法正确

六、C# 14 全回顾 C# 14 Retrospective

C# 14 是 .NET 10 LTS 的语言——10 个新特性,从"改变叙事"到"消除纸割伤":

#特性日常频率一句话
1field 关键字L21⭐⭐⭐⭐⭐属性访问器直接引用合成后备字段
2扩展成员L22⭐⭐⭐⭐extension 块——属性、静态、运算符
3Null 条件赋值L23⭐⭐⭐⭐⭐?. 站在赋值左边
4Lambda 修饰符L23⭐⭐⭐⭐out/ref/in 参数——省略类型
5nameof 泛型L23⭐⭐⭐nameof(List<>)
6Span 隐式转换L24⭐⭐⭐数组、string 直接当 Span 用
7复合赋值L24⭐⭐void operator +=——零分配
8partial 构造/事件L24⭐⭐源生成器新维度
9单文件应用L24⭐⭐dotnet run app.cs
10文件级预处理器指令L24 §4#! / #: ——属于单文件应用
C# 14 最值得带回日常工作的三个特性:
🥇 field 关键字——消除最常见的样板
🥈 Null 条件赋值——每天都在救你一行 if-null 检查
🥉 扩展属性——把状态查询从方法变回属性

七、C# 8~14 全部学完。下一步?What's Next After C# 14?

25 节课,7 个 C# 版本(8→14),覆盖了从 .NET Framework 4.8 到 .NET 10 LTS 的完整语言演进。你已具备在现代 .NET 项目中自信编码的能力。

接下来的学习路径取决于你的兴趣方向:

🧵 ASP.NET Core

Web API · 中间件 · 依赖注入 ·
Minimal API · gRPC · 认证授权

🗄️ EF Core

Code First · 迁移 · 查询优化 ·
领域驱动设计 · 性能调优

⚡ 性能与工具链

Span/Memory 深度 · Native AOT ·
源生成器 · BenchmarkDotNet

📖 NRT 速查 · ← L23: 中等特性合集 · 🎯 回顾 Mission