Null 条件赋值 · Lambda 修饰符无类型 · nameof 未绑定泛型 · 三个小痛点,三块创可贴
看看下面三段代码——它们有什么共同点?
// 痛点 ①: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 解决了这三个问题——不需要新关键字,不需要新语法块,只是让已有语法更聪明。
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 前的事件调用样板
}
// C# 14:?. 和 ?[] 出现在赋值号左边
customer?.Order = GetCurrentOrder(); // 一行。null 时什么都不做
customers?[0] = newCustomer; // ?[] 也可以
customer?.Address?.City = "Beijing"; // 链式——任何一环 null 就短路
OnDataReceived?.Invoke(data); // 事件调用一行搞定
customer 是 null,GetCurrentOrder() 不会被调用——不是赋值 null,是根本不执行赋值。这和 ??=(左边 null 才赋值)语义完全不同。
// 所有复合赋值运算符都可用
account?.Balance += deposit; // null 时跳过——不会炸
cart?.Subtotal -= discount;
context?.Counter *= 2;
| 操作 | 是否支持 | 原因 |
|---|---|---|
obj?.Prop = value | ✅ | 简单赋值 |
obj?.Prop += value | ✅ | 复合赋值 |
obj?.Prop ??= value | ✅ | Null 合并赋值(嵌套语义) |
obj?.Prop++ | ❌ | ++/-- 不是赋值运算符——是增减运算符 |
ref x = ref obj?.Field | ❌ | ref 赋值需要确定的内存位置 |
(a?.B, c?.D) = (x, y) | ❌ | 解构赋值不支持 null 条件 |
// ??= —— 左边为 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。这和 ??= 完全相反。选谁取决于你的意图:是"确保有值"还是"如果存在就改它"。
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# 语法规定:一旦参数列表中出现修饰符,所有参数都必须显式声明类型——"全有或全无"规则。
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# 14 | 说明 |
|---|---|---|
out / ref / in 无类型 | ✅ | 从委托签名推断类型 |
scoped / ref readonly 无类型 | ✅ | 同上 |
params 无类型 | ❌ | params 仍要求显式类型——因为它改变整个参数列表结构 |
| 混合显式/隐式类型 | ❌ | 要么全写类型,要么全不写——和 C# 10 规则一致 |
| 参数默认值 | ❌ | 隐式类型参数不能有默认值——没有类型就没法推断默认值语义 |
| 参数特性(attribute) | ❌ | 隐式类型参数不能标注特性 |
C# 14 补齐了最后一块拼图——修饰符和类型推断不再是互斥的。17 年来 lambda 终于完全自由了。
这是一个经典小痛点——你想在错误消息、日志或特性中引用泛型类型的名字:
// 场景:特性中引用类型名
[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<某个>。
// 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(List<>) 返回未绑定泛型的 Type 对象——这个一直合法。但 nameof(List<>) 之前却不行。C# 14 让 nameof 的泛型参数规则和 typeof 对齐。
| 写法 | C# 13 | C# 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 不行?现在没有理由了。
这三个特性没有 extension 块那么宏大,没有 field 关键字那么万众期待——但它们共享一个设计哲学:
?. 能读不能写是一个语言不对称。
C# 14 把左右两边补全了。
委托签名已经有了完整类型——
为什么还逼人把类型再抄一遍?
typeof 能接受未绑定泛型,nameof 不能——没道理的断层。
三个特性的共同逻辑:编译器已经知道答案——别让我再敲一遍。
以下代码中,GetOrder() 会被调用吗?
Customer customer = null;
customer?.Order = GetOrder();
以下哪个 lambda 声明是 C# 13 会报错但 C# 14 合法的?
delegate bool TryParse<T>(string text, out T result);
delegate void Apply(ref int n);
以下代码输出什么?
Console.WriteLine(nameof(Dictionary<,>));
以下哪个操作在 C# 14 中不合法?
关于 C# 14 这三个特性,以下哪个说法正确?
C# 14 还剩最后一课——小特性合集。包括 partial 构造和事件(源生成器新玩具)、Span 隐式转换(性能代码更自然)、自定义复合赋值、以及文件级应用的预处理器指令。
📖 NRT 速查 · ← L22: 扩展成员 · L24: 小特性合集 →