跳到正文
我的博客
返回

C# 的语法糖有多甜?——? 家族、=> 与属性模式的一次清点

目录

C# 的语法糖有多甜?——? 家族、=> 与属性模式的一次清点

接触过的语言不算少了——C/C++、C#、Java、Python、Go、JS、PHP。挨个写下来之后,目前最大的感受就一个:C# 的语法糖太严重了,眼中糖分超标啊,666。

趁着还记得,小小总结一下。先来 ? 家族。

? 家族:一个问号,七种意思

写法名称作用加入版本
int?可空值类型实际是 Nullable<int>,真能存 nullC# 2.0
a ? b : c三元条件运算符条件表达式C# 1.0
a ?? bnull 合并左边为 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 ??= bnull 合并赋值只有 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 那样直接炸掉。

这里必须补一句版本,我一开始也记混了:

两者完全等价,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 特性提案)、可空引用类型


分享这篇文章:

上一篇
从函数指针到 event——C# 委托学习笔记
下一篇
委托的线程安全:`+=` 看着是一步,实际是三步