Span 隐式转换 · 自定义复合赋值 · partial 构造/事件 · 单文件应用 · C# 14 收官
这四个特性不是 field 关键字那种"人人每天用",也不是扩展成员那种"改变语言叙事"。它们是专用工具——特定场景下的生产力飞跃:
| 特性 | 谁最受益 | 一句话 |
|---|---|---|
| Span 隐式转换 | 性能敏感代码作者 | 数组和字符串直接当 Span 用——不用再写 .AsSpan() |
| 自定义复合赋值 | 数值库/游戏数学作者 | v += other 直接修改原对象——零分配 |
| partial 构造/事件 | 源生成器作者 | 构造和事件可以跨文件拆分——生成代码新维度 |
| 单文件应用 | 脚本/原型开发者 | dotnet run app.cs——不需要 .csproj,不需要 sln |
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>?
// 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()!
ReadOnlySpan<T> 一个重载——数组、Span、string 全部隐式传入。不用为了覆盖不同调用方写 N 个重载。
在 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>
T[] 同时匹配 ReadOnlySpan<T> 和 IEnumerable<T> 两个重载时,C# 14 会选择 Span 重载。这是因为 C# 重载决议的"更好转换"规则:int[] → ReadOnlySpan<int> 不涉及接口分派、不涉及装箱——编译器直接把数组内部指针打包成 Span 结构体——被视为比 int[] → IEnumerable<int>(接口引用转换)更直接的转换。具体类型(或 ref struct)优先于接口类型——这是一条通用规则。在表达式树(expression tree)场景中尤其需要注意:LINQ provider 可能生成不同的 SQL。
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 提升为"一等公民"的真正力量:整个生态的重载决议都往高性能方向倾斜。
| 从 | 到 | C# 13 | C# 14 |
|---|---|---|---|
string | ReadOnlySpan<char> | ❌ 需 .AsSpan() | ✅ 隐式 |
T[] | Span<T> | ❌ 需显式转换 | ✅ 隐式 |
T[] | ReadOnlySpan<T> | ❌ 需显式转换 | ✅ 隐式 |
Span<T> | ReadOnlySpan<T> | ❌ 需显式转换 | ✅ 隐式 |
stackalloc T[] | Span<T> | ✅ 已经是隐式 | ✅ 保持 |
过去定义 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 压力
// 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 让这两者有了不同的编译路径:复合赋值走原地修改,独立运算符走新建。
遇到 a += b 时编译器的查找优先级:
| 运算符 | 签名 | 说明 |
|---|---|---|
+= | 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 ++() | 增减——无参数 |
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 + 是同一套机制。(注意课里的 Vector3 用 decimal——decimal 始终是 checked 的,不需要显式声明。)
C# 2 引入 partial class,C# 3 引入 partial method,C# 9 引入 partial property。但构造和事件——两种最常见的代码生成目标——一直不能 partial。
这对源生成器(Source Generator)是一个真实限制。比如你写了一个 [WeakEvent] 特性,希望源生成器自动生成事件的基础设施代码——做不到,因为事件不能 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; }
}
关键规则:
: base(...) 或 : this(...) 初始化器static 构造和终结器// ──── 你写的代码(定义声明) ────
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);
}
}
想写一个快速脚本——读取文件、发个 HTTP 请求、格式化输出——需要先 dotnet new console,改 .csproj 加 NuGet 包,然后才能写代码。相比之下 Python 的 python script.py 零体力。
// ──── 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
| 指令 | 作用 | 示例 |
|---|---|---|
#: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 编译(保证迭代速度)。
这是最容易误解的地方——两者的编译目标不是一回事:
走常规 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 包(如有)
└── ...
默认开启 Native AOT——产出单个自包含原生二进制,无需 .NET 运行时:
# 发布输出——源码旁边的 artifacts/ 目录
artifacts/publish/app/
└── app.exe ← 单文件原生二进制(Windows)
≈8–15 MB,含你的代码 + NuGet 包 + 运行时子集
扔到任何一台没装 .NET 的机器上直接跑
dotnet run | dotnet publish | |
|---|---|---|
| 编译目标 | IL (.dll) | 原生机器码 (.exe) |
| 输出位置 | 系统临时目录 | artifacts/publish/(源码旁) |
| 首次耗时 | < 1 秒 | 5–30 秒(AOT 编译慢) |
| 启动速度 | ~100ms(JIT) | < 5ms(无 JIT) |
| 需要 .NET 运行时? | 需要 | 不需要 |
| 第三方库 | 独立 .dll 文件 | 全部编译进单一二进制 |
#:property PublishAot=false。如果只需要本地跑脚本,dotnet run 就是全部你需要的——Native AOT 是给分发用的。
单文件应用不是让你把整个生产项目塞进一个文件。它的定位是:
试一个 API、验证一个想法——
不需要建项目、配 NuGet。
数据处理、文件批处理、
CI 工具——C# 强类型安全。
分享一个概念——给对方一个
.cs 文件,不是整个 repo。
这不意味着 C# 成了脚本语言——它仍然是编译型、强类型的。只是绕过了项目文件的仪式,让 C# 在"临时写个东西"的赛道上可以一用。
C# 14 中以下哪个转换不是隐式的?
以下代码中,v += other 走哪条编译路径?
struct V { public void operator +=(V r) => X += r.X; }
关于 partial 构造,以下哪个说法是正确的?
以下哪个不是单文件应用的有效指令?
关于 C# 14 这四个小特性,以下哪个说法正确?
C# 14 是 .NET 10 LTS 的语言——10 个新特性,从"改变叙事"到"消除纸割伤":
| # | 特性 | 课 | 日常频率 | 一句话 |
|---|---|---|---|---|
| 1 | field 关键字 | L21 | ⭐⭐⭐⭐⭐ | 属性访问器直接引用合成后备字段 |
| 2 | 扩展成员 | L22 | ⭐⭐⭐⭐ | extension 块——属性、静态、运算符 |
| 3 | Null 条件赋值 | L23 | ⭐⭐⭐⭐⭐ | ?. 站在赋值左边 |
| 4 | Lambda 修饰符 | L23 | ⭐⭐⭐⭐ | out/ref/in 参数——省略类型 |
| 5 | nameof 泛型 | L23 | ⭐⭐⭐ | nameof(List<>) |
| 6 | Span 隐式转换 | L24 | ⭐⭐⭐ | 数组、string 直接当 Span 用 |
| 7 | 复合赋值 | L24 | ⭐⭐ | void operator +=——零分配 |
| 8 | partial 构造/事件 | L24 | ⭐⭐ | 源生成器新维度 |
| 9 | 单文件应用 | L24 | ⭐⭐ | dotnet run app.cs |
| 10 | 文件级预处理器指令 | L24 §4 | ⭐ | #! / #: ——属于单文件应用 |
25 节课,7 个 C# 版本(8→14),覆盖了从 .NET Framework 4.8 到 .NET 10 LTS 的完整语言演进。你已具备在现代 .NET 项目中自信编码的能力。
接下来的学习路径取决于你的兴趣方向:
Web API · 中间件 · 依赖注入 ·
Minimal API · gRPC · 认证授权
Code First · 迁移 · 查询优化 ·
领域驱动设计 · 性能调优
Span/Memory 深度 · Native AOT ·
源生成器 · BenchmarkDotNet
📖 NRT 速查 · ← L23: 中等特性合集 · 🎯 回顾 Mission