开发者热衷于争论语言的优劣,这种争论并非全无益处,但它通常忽略了几件事情:开发者很难只用一种语言完成全部开发工作;打工人或许没有选择开发语言的权利;最优做法并不总是最合适的做法。
我喜欢C语言的直观,C#的工业化,以及Python的哲学。
在各种争论中经常看到一个说法,C#好用的原因是它拥有诸多甜到掉牙的语法糖。有趣的是,这个并不太正确的说法通常出自一知半解者之口。C#发展到现在,确实成为了一门语法糖众多的语言,但除了语法糖之外,对于特性(Feature)的增加和完善才是重中之重。
但无论如何,或许C#的语法糖导致初学者一时难以厘清其中的脉络确实是不争的事实,因为各种教程的出版和更新都有滞后性,这也是我建议利用微软官方文档学习C#的原因。
而本章的主要内容就是针对C#的一些语法糖进行阐释。请记住,这里列举的不是C#的全部语法糖。要了解这些的全部内容,请参考官方文档的C#新增功能相关内容:https://docs.microsoft.com/zh-cn/dotnet/csharp/whats-new/
与微软文档和其他教程的不同之处在于,本章主要讲述如何不滥用某些语法糖,以及某些语法糖的工作原理。这也是题目中“脱糖”二字的由来。
另外再推荐一本书,《深入理解C#》,原名C# In Depth,之前没推荐它是因为它的版本已经旧了,但当我写完这一章的时候却发现本章的大多数核心内容,在《深入理解C#》的第八章都有更为简洁清晰的论述,而且我的很多理念可能都来自多年之前看过的这本书。
零、顶级语句的作用机制
这一节的内容与本章内容无关。之所以把它放在这里,是因为顶级语句写测试代码很方便,也是.NET控制台项目默认的模版,不得不先介绍一下,同时还能顺便介绍一个用来研究C#的工具。所以是第0节。
新手,甚至刚从老版本.NET Framework转到.NET的开发者,可能都会对C#的顶级语句产生疑问。因为当他们创建了一个.NET6的控制台程序时,只在孤零零的Program.cs文件中看到了孤零零的一条代码:
入口点呢!?main函数呢!?Waaaaaagh!
关于顶级语句的说明可以参考这里的文档:https://docs.microsoft.com/zh-cn/dotnet/csharp/fundamentals/program-structure/top-level-statements
简单的说就是,它可以省略Main函数,但必须符合特定的形式:
代码的注释“args 变量永远不会为 null,但如果未提供任何命令行参数,则其 Length 将为零”摘自C#官方文档,不过,此文档是基于C#的最新版本(C#10)书写的。需要注意的是,
在.NET Frameworks中,尽管Main函数的参数args数组同样不会为null,但它的最小Length是1,因为其第一个成员的值是此程序的完全名称。基于此原因,我建议在需要读取命令行参数的时候,采用Environment.GetCommandLineArgs方法,因为它在各C#实现中的行为几乎一致:
https://docs.microsoft.com/zh-cn/dotnet/api/system.environment.getcommandlineargs
并非所有的C#实现都支持其高版本,例如.NET Frameworks最高只支持到C#7.3,关于这部分内容的详细说明请参考:https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/configure-language-version
另外如无特殊说明,本系列中的所有例子均使用.NET6+C#10进行书写。
为了进一步理解顶级语句的特质,我们利用Visual Studio 2022和ILSpy对以上代码进行分析。
当然你也可以用之前提到的dotPeek,不过没法自动与IDE集成。而对于Jetbrains Rider的用户,直接使用Rider里集成的IL分析功能即可,有钱真好:p
ILSpy是一款开源免费的C#程序集分析工具:https://github.com/icsharpcode/ILSpy,它可以很方便的集成到VS或VS Code中:
https://marketplace.visualstudio.com/items?itemName=SharpDevelopTeam.ILSpy2022
https://marketplace.visualstudio.com/items?itemName=icsharpcode.ilspy-vscode
以Visual Studio 2022为例,安装好ILSpy扩展,然后新建一个.NET6命令行程序(默认的名称ConsoleApp1就很不错),并把Program.cs文件的内容修改为上面的代码,编译后,依次点击菜单工具——ILSpy,即可打开ILSpy界面。也可以在解决方案资源管理器中右键单击项目名称,在弹出的右键菜单中选择用ILSpy打开输出。
注意:上面的代码编译后将出现警告CS7022:程序的入口点是全局代码;忽略“Main()”入口点。解决的方式是更改此方法名称,上面的代码只是示例,在正式开发中,定义多个静态Main函数,或者在使用顶级语句的情况下定义静态Main函数的做法并不可取。
如无意外,我们将看到如下界面:
记得在工具栏把语言切换成C#,默认情况下,显示的是IL(中间语言)代码。
我们注意到,编译器自动生成了Program类和作为入口点的Main方法,并把顶级语句的代码加入其中,Program类和SampleClassA类都位于全局命名空间(-)中。
如果你阅读过顶级语句的文档,你会发现,编译器根据顶级语句采用的关键字,会生成不同的Main方法签名。
文档的中文内容很多都是机翻,遇到读不通的情况,请切换英文版本。一个简单的做法是,直接把链接中的zh-cn替换成en-us。
让我们简单验证一下。关掉ILSpy,在顶级语句的最后一部分加上一行代码:
编译后,再用ILSpy打开,我们发现Main函数的签名由上图的“void <Main>$(string[] args)”,变成了带返回值的“int <Main>$(string[] args)”。同理,如果使用await关键字,以及,await 和带有返回值的retrun关键字,则生成异步版本的方法。
这就是顶级语句的真相:在编译时生成完整代码。
一、关键字var的使用原则
一、关键字var的使用原则
关键字var用来定义隐式类型本地变量,应该是我们最常接触的C#语法糖了:
编译器的类型推断系统,会根据右侧的表达式推断出变量s的类型是string。在这个例子中,我们仅仅少打了三个字符,但很显然,对于一些类型名很长的类型:
显然如果我们要实例化ClassC,使用var会更加省事一些:
而从另一方面,即使是对于string,int,long等很短的类型来说,统一输入var无疑是更加方便的事情。
var关键字出现于C#3.0,这意味着C#的所有实现和各种开发工具均支持此语法。
批评者认为,var关键字会影响代码的可读性,使得阅读者无法直接知晓变量的类型。这个看法没有错误,对于var的滥用确实会造成这类问题。但其实,微软早已针对这一情况,提供了详细的编码约定:https://docs.microsoft.com/zh-cn/dotnet/csharp/fundamentals/coding-style/coding-conventions#implicitly-typed-local-variables
其核心逻辑很简单,如果右侧表达式可以明确表示类型(来自字面值、构造函数、显式类型转换),则用var,否则(来自方法、集合成员)则用显式类型。
初学者可能无法熟练掌握这套规则,但不必担心,只需要很简单的步骤就能让我们以良好的编码原则进行书写。详细的内容参见这里:https://docs.microsoft.com/zh-cn/visualstudio/ide/create-portable-custom-editor-options?view=vs-2022
如果你暂时不愿意看这些文字,简单执行以下步骤即可:
A. 下载.NET源码仓库的编码约定文件:https://raw.githubusercontent.com/dotnet/runtime/main/.editorconfig
B. 将“.editorconfig”
文件
添加到你的代码目录下,直接拖拽即可。
IDE会依照根据此规则文件分析你的代码,对符合
微软推荐的编码风格(https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md
)的代码进行提示,并给与修复意见。
P·S:在上面的代码中,左括号{没有占用新的一行,主要是为了简洁。但原则上它应该单独占用新的一行。
二、new表达式——比var更为激进的简写
二、new表达式——比var更为激进的简写
在上一节中,我们意识到var可以用于通过类型构造函数来初始化的变量。但开发者社区显然认为这样做还不够简洁,于是在C#9.0中,new表达式出现了:https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/proposals/csharp-9.0/target-typed-new
显然这里的new()实质上等价于new StringBuilder()和new StringBuilder(1024),我们连var都不用写了,但可读性并没有失去。
上面的例子并未体现出new表达式的激进之处,这种做法实质上是很合适的写法,new表达式的争议性体现在这里:
假设我们有如上的方法Sample,则我们可以使用如下语句调用此方法:
非常简洁,非常炫,对吧?但是它的可读性又如何呢?现代化的IDE已经足够强大,鼠标悬停即可显示它的定义。从这个角度看,代码自然越简洁越干净越好。
问题在于我们并不总是在理想的环境下阅读代码。例如我们可能会想在github上查阅某些代码片段来印证我们的想法,又或者代码打印在纸张上,那么这种写法对于阅读者来说就并不友好了。
所以我个人并不建议把new表达式用在方法的参数中
。相比之下,微软给出的第一个例子则是一个可以接受的做法,因为在这里我们可以很清晰的知道new表达式对应的是类型List<int>:
三、各种初始化方式的弯弯绕——
对象初始值设定项
三、各种初始化方式的弯弯绕——
对象初始值设定项
我接触到的一些C#初学者,很多人都对C#的初始化方法感到头疼。原因是,从C#3.0开始,微软就在语法的简洁性上做了大量的工作,后面几乎每个版本都有相关的新特性出现。上面列举的var和new表达式只是其中的一部分。
让我们从头来过。照例先把相关文档列出来:
初始值设定项:https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/classes-and-structs/object-and-collection-initializers
匿名类型:https://docs.microsoft.com/zh-cn/dotnet/csharp/fundamentals/types/anonymous-types
隐式类型的数组:https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/arrays/implicitly-typed-arrays
文档不着急看,但早晚要看的……不过这里可以先看本节内容。
首先使用上面提到的两个关键字用法:
这段代码编译后,在ILSpy中可以看到它们的真实形式:
这早在我们的意料之中了对吧?有趣的是另一个初始化形式:
像这种为一个新创建的实例的成员赋值的形式,被称作
对象初始值设定项
。
表面上看,初始化sb5的代码,和初始化sb3,sb4的代码效果一致,执行完毕后都会得到一个Capacity长度为1024的StringBuilder。但果真如此吗?
我们查看编译后的代码,发现sb5初始化的语句原来是这样的:
注意:对象初始值设定项出现于C#3.0,所以要看到编译后代码真正的样子,请把ILSpy的C#语言版本设置为1.0或2.0。
在StringBuilder的文档(https://docs.microsoft.com/en-us/dotnet/api/system.text.stringbuilder)中我们看到,其默认构造函数会将其容量设为16个字符,之后再修改容量的话,一定会造成性能的浪费,因为修改Capacity的开销并不算小,这一点我们将在后面的章节讨论。
对象初始值设定项的设计动机是,让我们更简明的初始化那些未能早构造函数中初始化的实例成员。如果滥用可能会产生不必要的性能开销。
另外,当未使用new表达式时,使用初始值设定项进行初始化的对象,以及后文将要提到的集合,可以省略括号:
可能有读者觉得 { Capacity = 1024 }的书写方式在可读性上优于new(1024)或StringBuilder(1024),因为它显式标注了初始化对象的名称。对此,我建议使用C#4.0开始启用的命名参数特性:https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/classes-and-structs/named-and-optional-arguments
实际上,这是个很好的习惯。
在早些时候,对象初始值设定项允许在初始化实例时(其实是之后立刻)设定其中的属性或字段,而从C#6.0开始,我们还可以通过它来设置索引器:
索引器的说明在这里:https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/indexers/
四、各种初始化方式的弯弯绕——
匿名类型和隐式类型数组
四、各种初始化方式的弯弯绕——
匿名类型和隐式类型数组
匿名类型的初始化语法极为固定,只有通过这一种方式初始化和定义匿名类型。它最常见的用法是用于在查询表达式中提取指定的数据。先来看官方文档的示例:
关于查询表达式的内容,我们将在之后进行阐述,目前只关注匿名类型本身。
匿名类型由C#3.0引入,在这个例子中,v是一个匿名类型(AnonymousType)的实例,此类型由两个属性组成:int类型的Amount,以及string类型的Message。显然,Amount和Message的类型并非显式指定,而是依赖类型推断。
在匿名类型的文档中有如下说明:
匿名类型是 class 类型,它们直接派生自 object,并且无法强制转换为除 object 外的任何类型。 虽然你的应用程序不能访问它,编译器还是提供了每一个匿名类型的名称。 从公共语言运行时的角度来看,匿名类型与任何其他引用类型没有什么不同。
这个说明很好印证,将上面的代码作为顶级语句进行编译,用ILSpy查看,不出意外你能够早全局命名空间下看到一个由编译器生成的泛型类型:
由编译器生成的代码中,名称符号通常会加上前导尖括号。
此类型通过显式构造函数初始化实例,通过Set访问器公开两个字段的值,并重写了Equals、GetHashCode、ToString方法。
接着研究。文档中又说:
如果程序集中的两个或多个匿名对象初始值指定了属性序列,这些属性采用相同顺序且具有相同的名称和类型,则编译器将对象视为相同类型的实例。 它们共享同一编译器生成的类型信息。
If two or more anonymous object initializers in an assembly specify a sequence of properties that are in the same order and that have the same names and types, the compiler treats the objects as instances of the same type. They share the same compiler-generated type information.
这又该如何理解呢?我们可以编写代码进行测试:
上面文档的描述不难理解。以上面的代码为例,v0、v1以及vn具有同样的属性名和同样的类型,所以应该共享一个类型,尽管vn位于其他命名空间;v2的属性类型发生了变化,所以应该拥有一个类型;v3的属性名称发生了变化,所以同样拥有一个类型;v4同理——所以理论上我们应该得到4个由编译器生成的类型——但真是如此吗?
事实令人感到惊讶,我们只发现了三个类型:
显然文档这部分说错了
:在泛型的支持下,只要匿名类型的属性的数量、名称、顺序一致,就会共享由编译器生成的类型。
接着看文档的说明:
从C# 9.0开始支持with表达式:https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/operators/with-expression。在此版本中,with表达式只能应用于记录类型(
详细阐述见之后的文章
),但从C#10开始,with表达式开始支持结构和匿名类型。
我们看一下with表达式的实际原理。
以上代码对应的实际代码为:
显然with表达式只是根据旧实例创建新实例的简写形式而已。
无法将字段、属性、时间或方法的返回类型声明为具有匿名类型。 同样,你不能将方法、属性、构造函数或索引器的形参声明为具有匿名类型。 要将匿名类型或包含匿名类型的集合作为参数传递给某一方法,可将参数作为类型
object
进行声明。 但是,对匿名类型使用object
违背了强类型的目的。 如果必须存储查询结果或者必须将查询结果传递到方法边界外部,请考虑使用普通的命名结构或类而不是匿名类型。
这部分内容很好理解,简单的说,最佳做法是仅在局部范围内使用匿名类型,就像最开始说的,匿名类型的设计动机就是用于在查询表达式中提取指定的数据。
表面上看,我们的编码负担不会因为使用限制颇多的匿名类型而减少,但其实它的隐含逻辑为我们提供了两个非常棒的帮助。
首先是相同“签名”的匿名类型共享同一泛型类型。在上面的代码中,变量vn对应匿名类型位于FooNameSpace命名空间之下,但编译器令它共享了全局命名空间下的<>f__AnonymousType0类型。这个效果的好处在于,我们不必专门去定义一些小的、临时的类型,也不必费心为这些类型找一个“合适的位置”,以便以更好的语义在代码间共享。
而匿名类型的另一个好处则是:
由于匿名类型上的 Equals 和 GetHashCode 方法是根据方法属性的
Equals
和GetHashCode
定义的,因此仅当同一匿名类型的两个实例的所有属性都相等时,这两个实例才相等。
如果你不熟悉Equals和GetHashCode方法的相关知识,这部分内容我们将在后面的内容中进行讲解,目前只知道匿名类型对于这两个方法的重写帮我们完成了很多工作,我们可以用匿名类型来实现行为一致且表现良好的比较操作就可以了。
另外,匿名类型对于ToString方法的重写还为我们提供了基础的全部属性打印功能:
尽管在C#更新了字符串内插功能之后,格式化输出变得很简单,但就如上面代码展示的那样,为了健壮性考虑我们还是要写不少判断逻辑,这个重写为我们省了很多事情。
到此为止,匿名类型的相关讨论暂时告一段落,现在让我们来看形势上与匿名类型很相似的一种声明方法,隐式类型数组。在第三节我们讨论了对象初始值设定项用法,对于数组来说,初始值设定项一样适用:
【形式一】
多维数组和交错数组的差异在于,以上面代码为例,b表示一个有两行四列的4*2个元素的数组,而c表示一个元素是两个数组的一维数组。详见 https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/arrays/multidimensional-arrays , https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/arrays/jagged-arrays
上面展示了隐式关键字的隐式类型数组,下面展示另一种写法,使用显式类型,但避免使用new的隐式类型数组:
(我知道“
使用显示类型关键字定义的隐式类型数组
”这一说法有点奇怪,但这个定义来自官方文档:https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/arrays/single-dimensional-arrays)
【形式二】
同匿名类型比起来,隐式类型数组要简单的多,无论是形式一还是形式二,上面代码都会被编译为:
显然,又是类型推断……
需要注意的是,形式一允许对于已经声明的变量进行初始化,但形式二则不行:
其实这部分内容可以完全可以省略,文档说的很详细,但我认为有必要讨论这种初始化形式的应用方式。根据文档所说:
隐式类型化数组通常用于查询表达式、匿名类型、对象和集合初始值设定项。
这话没毛病,而且乍看上去它是匿名类型的好兄弟,但我想说的是,尽管匿名类型可能会对可读性造成破坏,但适当利用的话,它的收益(如上文所说)足以弥补这个问题,但
形式一
的隐式类型数组不一样,它的收益仅仅是少写几个字符——IDE的智能提示表示你图啥——却不一定值得我们牺牲可读性了。
形式二
的隐式类型数组兼顾了简洁与可读性,在形式二的交错数组定义中,元素数组的定义采取了形式一的形式却依然容易理解。但显而易见,
形式二
有且只有这一种定义方式,远不及形式应用广泛。
如果希望在可读性方面衡量是否使用
形式一
的隐式类型数组(以及匿名类型),比较好的方式是参考第一节中关键字var的最佳实践——在目标类型是字面值(int,short,string……),构造函数,以及类型转换的时候使用它们,否则如过没有足够的理由,还是少用为好。
五、各种初始化方式的弯弯绕——集合的初始值设定项
五、各种初始化方式的弯弯绕——集合的初始值设定项
集合的初始化设定项与对象一样:
集合在实现上比数组“高级”,对于集合来说,要使用初始化设定项,要求首先它实现IEnumerable接口,其次要求它拥有合适的Add方法或扩展方法。对于List<int>而言,上面代码对应的实际代码形如:
一个并不重要但读者应该知道的事情是,集合的初始化设定项往往会产生额外的开销。目前我们已经知道集合初始化设定项的实现方式,以List<T>为例,它的默认容量是4,也就是说可以容纳4个T类型元素,一旦添加了超过4个T元素,则必须要对容器进行扩容(分配数组并复制元素),然后,如果元素数量再次超过当前数量,则进行第二次扩容……
相比之下,直接使用new表达式,利用构造函数初始化的方式性能开销要显著降低:
原因在于此:
当实参实现了ICollection<T>接口时,直接避免了重分配空间的开销,而最差的情况下,其开销才与集合的初始值设定项一致……
那我为什么说这个问题并不重要呢?很简单,因为当我们要手动输入内容的时候,这个量注定不会很大,额外开销的影响微乎其微。但无论如何,采取更加高效的做法总不会有错。
那么集合的初始值设定项就这么没有竞争力吗?并不是。在特定的情况下,它不失为一个优秀的选择。在官方文档中列举了一个例子:具有合集类型只读属性的对象的初始值设定项(Object Initializers with collection read-only property initialization)。
假设有此类型:
显然如果我们初始化了Foo的新实例,想要增加Collection的元素,需要手动去Add。但利用此特性,则可以编写这样的代码:
理所应当,它对应如下代码:
六、补充和总结
六、补充和总结
现在我们讨论一下长度推断。我们最熟悉的朴实的初始化数组的方式是这样的:
当我们要为数组分配初始值时,会这样使用初始化设定项:
但其实这个代码并不完整,经过编译器补充,它的完整形式是这样的:
也就是说,编译器通过我们给出的初始化设定项,推断了出数组的长度,并以此长度进行初始化。
这是数组的情况,对于其他合集来说,却没有长度推断,这是因为编译器并不要求通过初始化设定项初始化的合集具有长度属性,就像之前说的,只要此合集实现IEnumerable接口,并且拥有合适的Add方法或Add扩展方法(
其实这是一个非常好玩的特性,鸭子类型。后面的章节会提到
),即可进行利用此特性初始化。
考虑到这篇文章我是随想随写的,写代码的时候也没有仔细设计,对初学者来说可能会被乱七八糟的语法弄晕,这里专门把相关的特殊内容整理出来作为参考:
在初始化相关的语法糖方面,目前我想说的就只有是这些——其实还有一些初始化方面的内容,比如切片,比如匿名方法,不过这些内容很多,而且跟预想中的后面的章节里的内容紧密相关,就不放在这里了。