Lesson 23: C# 14 — 中等特性合集 Mid-Tier Features

Null 条件赋值 · Lambda 修饰符无类型 · nameof 未绑定泛型 · 三个小痛点,三块创可贴

前置:已完成 Lesson 22(扩展成员)。本课覆盖 C# 14 中三个不是旗舰、但每天都在救你的特性——它们解决的都是"写起来啰嗦但编译器明明应该能自己搞定"的问题。
阅读:Microsoft Learn: C# 14 新增功能 · Null 条件赋值提案 · Lambda 修饰符提案

〇、三个小痛点 Three Papercuts

看看下面三段代码——它们有什么共同点?

// 痛点 ①:null 检查后才敢赋值
if (customer != null)
    customer.Order = GetCurrentOrder();     // 三行就为了一个赋值

// 痛点 ②:out 参数被迫显式写类型
TryParse<int> parse = (string text, out int result) =>
    Int32.TryParse(text, out result);      // 类型声明全是噪音

// 痛点 ③:想用 nameof 获取泛型名——做不到
var name = nameof(List<int>);              // "List" ✅
var name2 = nameof(List<>);              // ❌ C# 13: 编译错误!

三个场景的共同点:编译器明明有足够信息,但语法不允许你省略。C# 14 解决了这三个问题——不需要新关键字,不需要新语法块,只是让已有语法更聪明。

一、Null 条件赋值 Null-Conditional Assignment

1.1 问题:null 检查 → 赋值,每次都是三行

C# 6 带来了 ?. 用于——var name = customer?.Name; 优雅地处理了 null。但 一直没有对应的语法。你只能这样:

// C# 6~13:?. 只能读,不能写
if (customer != null)
{
    customer.Order = GetCurrentOrder();    // 三行样板,只为赋一个值
}

// 更糟的——链式属性:
if (customer?.Address != null)
{
    customer.Address.City = "Beijing";   // 先读才能判 null,再写
}

// 事件处理——最啰嗦的场景:
if (OnDataReceived != null)
{
    OnDataReceived(data);                 // C# 6 前的事件调用样板
}

1.2 C# 14:?. 可以站在赋值左边了

// C# 14:?. 和 ?[] 出现在赋值号左边
customer?.Order = GetCurrentOrder();       // 一行。null 时什么都不做
customers?[0] = newCustomer;              // ?[] 也可以
customer?.Address?.City = "Beijing";       // 链式——任何一环 null 就短路
OnDataReceived?.Invoke(data);             // 事件调用一行搞定
关键语义:右边只在左边非 null 时求值。如果 customer 是 null,GetCurrentOrder() 不会被调用——不是赋值 null,是根本不执行赋值。这和 ??=(左边 null 才赋值)语义完全不同。

1.3 复合赋值也支持

// 所有复合赋值运算符都可用
account?.Balance += deposit;              // null 时跳过——不会炸
cart?.Subtotal -= discount;
context?.Counter *= 2;

1.4 不支持的操作

操作是否支持原因
obj?.Prop = value简单赋值
obj?.Prop += value复合赋值
obj?.Prop ??= valueNull 合并赋值(嵌套语义)
obj?.Prop++++/-- 不是赋值运算符——是增减运算符
ref x = ref obj?.Fieldref 赋值需要确定的内存位置
(a?.B, c?.D) = (x, y)解构赋值不支持 null 条件

1.5 与 ??= 的区别——容易混淆的兄弟

// ??= —— 左边为 null 才赋值(C# 8)
customer ??= new Customer();              // "如果客人不存在,创建一个"

// ?. —— 左边为 null 就不赋值(C# 14)
customer?.Order = GetOrder();             // "如果客人在,给他订单"

// 两者可以组合:
(customer?.Cart) ??= new Cart();          // 先试试获取 Cart(null 短路),
                                            // 如果取到的 Cart 是 null,创建新的
易错点:customer?.Order = x 不会初始化 customer。如果 customer 是 null,什么都不会发生——customer 仍然是 null。这和 ??= 完全相反。选谁取决于你的意图:是"确保有值"还是"如果存在就改它"。

二、Lambda 修饰符——参数类型可省略 Simple Lambda Parameters with Modifiers

2.1 问题:加了修饰符就得写全部类型

C# 10(2021)让 lambda 有了自然类型——你可以写 var f = (int x) => x + 1;。C# 12(2023)允许 lambda 参数带 ref/out/in/scoped 修饰符。但两者结合时出了个裂缝:

// C# 10~13:有修饰符 = 必须显式类型——全部参数都写
delegate bool TryParse<T>(string text, out T result);

// C# 12~13:out 参数必须显式写出类型
TryParse<int> parse = (string text, out int result) =>   // 啰嗦
    Int32.TryParse(text, out result);

// 你写的——类型让编译器去推断:
TryParse<int> parse = (text, out result) =>              // C# 13: ❌ 编译错误
    Int32.TryParse(text, out result);

这是因为 C# 语法规定:一旦参数列表中出现修饰符,所有参数都必须显式声明类型——"全有或全无"规则。

2.2 C# 14:修饰符和类型推断可以共存

C# 14 放宽了这个限制——修饰符可以和隐式类型参数共存,类型从委托签名推断:

// C# 14:修饰符保留,类型靠推断——和你想的一样
TryParse<int> parse = (text, out result) =>           // ✅ 简洁
    Int32.TryParse(text, out result);

// 所有修饰符都支持
delegate void Apply(ref int n);
Apply doubleIt = (ref n) => n *= 2;               // ref + 推断

delegate int Square(in int value);
Square sq = (in v) => v * v;                     // in + 推断

delegate void Capture(scoped ref int x);
Capture c = (scoped ref n) => Console.WriteLine(n); // scoped ref + 推断

delegate void Swap<T>(ref readonly T a, ref T b);
Swap<int> s = (ref readonly a, ref b) => {};    // ref readonly + 推断
核心规则:修饰符必须显式写出——编译器只推断类型,不推断修饰符。这是故意的:C# 团队认为调用约定变更(ref/out/in)应该在调用点可见,不能藏在类型推断后面。

2.3 不支持的情况

情况C# 14说明
out / ref / in 无类型从委托签名推断类型
scoped / ref readonly 无类型同上
params 无类型params 仍要求显式类型——因为它改变整个参数列表结构
混合显式/隐式类型要么全写类型,要么全不写——和 C# 10 规则一致
参数默认值隐式类型参数不能有默认值——没有类型就没法推断默认值语义
参数特性(attribute)隐式类型参数不能标注特性

2.4 完整演进路线

C# 3
Lambda 诞生
C# 10
自然类型/var 推断
C# 12
ref/out/in 参数
C# 14
修饰符 + 无类型 ✅

C# 14 补齐了最后一块拼图——修饰符和类型推断不再是互斥的。17 年来 lambda 终于完全自由了。

三、nameof 支持未绑定泛型 nameof with Unbound Generics

3.1 问题:知道类型名,但就是写不出来

这是一个经典小痛点——你想在错误消息、日志或特性中引用泛型类型的名字

// 场景:特性中引用类型名
[TypeConverter(typeof(ListConverter<int>))]   // 绑定了 int——不够通用

// 想写 List<> 的 nameof——C# 13 不让:
var name = nameof(List<>);              // ❌ C# 13: 未绑定泛型不能用于 nameof
var dic = nameof(Dictionary<,>);         // ❌ 同样的错误

// 只能绕路:
var name = nameof(List<int>);            // "List"——但必须绑定一个具体类型参数
var dic = nameof(Dictionary<int, string>); // "Dictionary"——任意类型都行但不能空着

强迫你指定一个毫无意义的类型参数——破坏了语义。你关心的只是类型名 List,不是 List<某个>

3.2 C# 14:未绑定泛型直接用于 nameof

// C# 14:把类型参数留空——像 typeof 一样
Console.WriteLine(nameof(List<>));             // "List"
Console.WriteLine(nameof(Dictionary<,>));        // "Dictionary"
Console.WriteLine(nameof(Nullable<>));           // "Nullable"
Console.WriteLine(nameof(IEnumerable<>));        // "IEnumerable"

// 实用场景 ①——泛型特性中引用类型名
public class TypeConverterAttribute : Attribute
{
    public TypeConverterAttribute(string converterTypeName) { }
}
[TypeConverter(nameof(ListConverter<>))]     // 不绑定任何具体类型——最通用

// 实用场景 ②——异常消息中引用泛型类型
throw new InvalidOperationException(
    $"{nameof(List<>)} requires a non-null comparer.");

// 实用场景 ③——日志/诊断
_logger.LogWarning("Resolving {TypeName} failed", nameof(Dictionary<,>));
和 typeof 一致了:typeof(List<>) 返回未绑定泛型的 Type 对象——这个一直合法。但 nameof(List<>) 之前却不行。C# 14 让 nameof 的泛型参数规则和 typeof 对齐。

3.3 对比一览

写法C# 13C# 14结果
nameof(List<int>)"List"
nameof(List<>)"List"
nameof(Dictionary<,>)"Dictionary"
nameof(Nullable<>)"Nullable"
typeof(List<>)System.Collections.Generic.List`1[T]

这可能是 C# 14 中最小的特性——一句话就能说清楚。但它消除了一个设计不一致:为什么 typeof 可以接受未绑定泛型而 nameof 不行?现在没有理由了。

四、三个特性的共同主题 The Common Thread

这三个特性没有 extension 块那么宏大,没有 field 关键字那么万众期待——但它们共享一个设计哲学:

🪞 消除不对称

?. 能读不能写是一个语言不对称。
C# 14 把左右两边补全了。

🧠 编译器已有信息

委托签名已经有了完整类型——
为什么还逼人把类型再抄一遍?

🧩 API 表里如一

typeof 能接受未绑定泛型,
nameof 不能——没道理的断层。

三个特性的共同逻辑:编译器已经知道答案——别让我再敲一遍。

五、测验 Quiz

1/5 · Null 条件赋值语义

以下代码中,GetOrder() 会被调用吗?

Customer customer = null;
customer?.Order = GetOrder();

2/5 · Lambda 修饰符语法

以下哪个 lambda 声明是 C# 13 会报错但 C# 14 合法的?

delegate bool TryParse<T>(string text, out T result);
delegate void Apply(ref int n);

3/5 · nameof 未绑定泛型

以下代码输出什么?

Console.WriteLine(nameof(Dictionary<,>));

4/5 · null 条件赋值限制

以下哪个操作在 C# 14 中不合法

5/5 · 综合判断

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

六、接下来是什么?What's Next

C# 14 还剩最后一课——小特性合集。包括 partial 构造和事件(源生成器新玩具)、Span 隐式转换(性能代码更自然)、自定义复合赋值、以及文件级应用的预处理器指令。

L21
field 关键字
L22
扩展成员
L23
中等特性 ✅
→ L24
小特性合集

📖 NRT 速查 · ← L22: 扩展成员 · L24: 小特性合集 →