C# 的语法糖有多甜?——? 家族、=> 与属性模式的一次清点
接触过的语言不算少了——C/C++、C#、Java、Python、Go、JS、PHP。挨个写下来之后,目前最大的感受就一个:C# 的语法糖太严重了,眼中糖分超标啊,666。
趁着还记得,小小总结一下。先来 ? 家族。
? 家族:一个问号,七种意思
| 写法 | 名称 | 作用 | 加入版本 |
|---|---|---|---|
int? | 可空值类型 | 实际是 Nullable<int>,真能存 null | C# 2.0 |
a ? b : c | 三元条件运算符 | 条件表达式 | C# 1.0 |
a ?? b | null 合并 | 左边为 null 就用右边 | C# 2.0 |
x?.Foo() | null 条件运算符 | x 为 null 就整条短路返回 null,不抛异常 | C# 6.0 |
x?[i] | null 条件索引 | 同上 | C# 6.0 |
string? | 可空标注 | 提示编译器”可能为 null”,仅编译期 | C# 8.0 |
a ??= b | null 合并赋值 | 只有 a 为 null 时才赋值 | C# 8.0 |
x! | null 包容(null-forgiving) | 告诉编译器”我保证不为 null”,压制警告,运行时不检查 | C# 8.0 |
这里有个容易混的点,我一开始就没分清:int? 和 string? 的问号根本不是一回事。
int? 是真的换了个类型——它是 Nullable<int> 结构体,HasValue / .Value 都是实打实的成员,这个问号有运行时开销(多一个 bool 标记位)。而 string? 只是给编译器看的标注,编译出来的 IL 和 string 一模一样,运行时零开销、零检查。所以它才叫”标注”不叫”类型”——你写错了,它一句都不会拦你,只在 IDE 里给你画条波浪线。
一个 ?. 踩出来的坑:bool? 塞不进 if
写串口代码的时候我顺手写了这么一行,编译直接没过:
if (serialPort?.IsOpen) // 编译错误:无法将"bool?"转换为"bool"
原因是 ?. 会把后面的结果整体提升成可空类型。IsOpen 自己是个 bool,但被 ?. 一摸就变成了 bool?,而 if 只吃 bool。这个必须理解成:serialPort 为 null 时,整条表达式的值既不是 true 也不是 false,而是”不知道”,编译器不肯替你猜。
三种改法都行:
if (serialPort?.IsOpen == true) // 最直观,null 时判等结果为 false
if (serialPort?.IsOpen ?? false) // 给个兜底值
if (serialPort is { IsOpen: true }) // 直接就是 bool,不用补 == true
第三种是我现在最常写的,也就引出下面那一大块。
x! 是唯一一颗会咬人的糖
这一家子里,x! 的脾气跟别的不一样。别的糖最多是”写起来省事”,! 是你主动把编译器的嘴堵上:它只在编译期起作用,运行时一行代码都不生成。你保证了,它就信;你保证错了,那就是个原原本本的 NullReferenceException,跟没写这感叹号一模一样。
所以上位机代码里我基本不用它。真要保证不为 null,我宁可写成:
var port = serialPort ?? throw new InvalidOperationException("串口还没建出来就用了");
起码这一句在运行时是真的会替我兜住的。
那 C# 到底算不算最甜?
这个问题我自己纠结过:我确实觉得 C# 最甜,但查了一圈之后,公认的”糖界珠峰”是 Scala,不是 C#。两边都说得通,关键在甜法不一样。
先看别人家的。
Scala —— 公认的糖界珠峰。同一个循环你能写成 for (x <- xs)、xs.map()、xs foreach println、for-comprehension 带 yield、或者某个库自定义的 <*> 符号。下划线 _ 在不同上下文有十几种含义;隐式转换 + 隐式参数能让一段代码”看起来在调用不存在的方法”;运算符可以是任意符号组合(++=:、|@|)。业界那句黑话很精准:Scala 是每个库都在发明自己的 DSL。
Perl / Ruby —— TMTOWTDI(做一件事总有不止一种方法)是写进语言哲学里的。
Haskell —— $ . <$> <*> >>= =<< 加上几十个 language pragma,读别人的代码像在读密码本。
C++ —— 另一个维度的重:引用、运算符重载、模板元编程、lambda、结构化绑定、CTAD……而且它的糖普遍带副作用,这才是真正劝退的地方。
那我为什么还是觉得 C# 最甜?想明白之后是这么回事:
我的样本里根本没有上面这几门。 C/C++、Java、Python、Go、JS、PHP 这几门,除了 C++ 以外几乎都不怎么加糖——Go 甚至是把糖往外删的门派。在这么一组”清淡派”里做对比,C# 的甜度当然鹤立鸡群。这不是 C# 的问题,是我的参照系的问题。
更本质的区别在于糖是谁定的。 C# 的糖是语言级的:糖的写法由语言规定,微软一家说了算,任何人写出来的 ?.、??= 都长一个样,读别人的代码不需要先学他家的方言。Scala 的糖是库级的:糖能被库重新定义,所以才有 <*>、|@| 这种东西——你读一段 scalaz 的代码,得先学 scalaz 的 DSL。
所以准确的结论应该是:C# 的糖多但收敛,Scala 的糖多且发散。 同样是甜,一个是标准普通话,一个是各地方言。作为一个天天要读别人代码的人,我宁愿吃前一种。
=> 表达式体属性
说回 C# 自己的糖。这是之前看 Dispatcher 源码时记下的(Dispatcher 那篇见《从 Dispatcher 的构造函数往下读》):
// 伪代码
class
{
public Dispatcher Dispatcher => _dispatcher; // 只读,之后不可改
}
这里的 => 是表达式体属性(expression-bodied property),C# 6.0 引入的。它只是个简写,等价于:
public Dispatcher Dispatcher
{
get { return _dispatcher; }
}
顺着往下再来一段,是我自己在串口服务里写的:
public bool IsOpen => serialPort is { IsOpen: true };
// └────┬────┘ └──────┬──────┘
// 被检查的对象 属性模式
=> 那半截跟上面一样,就不重复了。但要记住一件事:它是只读的,而且每次访问都重新求值。所以 IsOpen 是”实时查询串口状态”,不是缓存的快照——下次访问会真的再去问一次 serialPort。想缓存就得自己加个私有字段,别指望这个语法。
is { … } 属性模式
后半段那个 { IsOpen: true } 是 C# 8.0 引入的属性模式(property pattern)。花括号里放的是”要匹配的属性子模式”,意思是:先确认对象非空,再检查它的 IsOpen 属性是否等于 true。
等价写法其实很朴素:
serialPort != null && serialPort.IsOpen
单个属性只是最基础的形态,这个语法能一层层往下套:
// 多属性同时匹配(相当于 &&)
serialPort is { IsOpen: true, BaudRate: 9600 }
// 带上类型(即使字段声明已经是 SerialPort,也能显式写)
serialPort is SerialPort { IsOpen: true }
// 递归匹配嵌套对象的属性
serialPort is { BaseStream: { CanWrite: true } }
// 属性模式 + 变量捕获(声明模式组合)
serialPort is { IsOpen: true, PortName: var name }
顺带说个好消息:嵌套访问也是 null 安全的。serialPort is { BaseStream: { CanWrite: true } } 里,哪怕 BaseStream 是 null,模式匹配也会安全判定为 false,不会像 serialPort.BaseStream.CanWrite 那样直接炸掉。
这里必须补一句版本,我一开始也记混了:
{ BaseStream: { CanWrite: true } }这种一层层花括号的写法,是 C# 8.0 就有的{ BaseStream.CanWrite: true }这种用点连起来的写法,叫扩展属性模式(extended property patterns),是 C# 10(.NET 6)才加的
两者完全等价,C# 10 只是省掉中间那对花括号。项目要是还卡在 .NET Framework 4.x 上(C# 7.3),属性模式整个都用不了——这个在老上位机项目里是常态,踩到过。
另外还有个常被忽略的成员,is { }:空属性模式,就表示”非空”。
if (serialPort is { }) // 等价于 serialPort != null
if (serialPort is { } port) // 非空,且顺便把变量抓出来
去糖:它编译出来其实很朴素
最后用原生写法记一遍,防止自己哪天忘了这些糖到底在干什么——知道去糖后长什么样,才知道它会不会有隐藏开销。
// serialPort is { IsOpen: true, BaudRate: 9600 }
SerialPort __temp = serialPort; // 输入表达式只求值一次
bool matched = __temp != null
&& __temp.IsOpen == true // 常量模式 true → 判等
&& __temp.BaudRate == 9600; // 常量模式 9600 → 判等
// serialPort is { IsOpen: true, PortName: var name }
if (serialPort != null && serialPort.IsOpen == true)
{
string name = serialPort.PortName; // var 模式:无条件匹配成功,赋值到 name
// ↑ 原来 if 内部的语句搬到这里,name 在此可用
}
看明白这个就清楚了:属性模式没有魔法,编译出来就是一串 null 检查加判等,性能上不会比手写差。它省的是视觉噪音,不是机器指令。
一句话收尾:C# 的糖齁是齁了点,但每一颗都能还原成朴素的 null 检查和判等——记住去糖的样子,就不至于被糖噎着。
参考:模式匹配 - is、switch 表达式与 and/or/not 模式、扩展属性模式(C# 10 特性提案)、可空引用类型