[翻译]Go与C#对比 第三篇:编译、运行时、类型系统、模块和其它的一切
Go vs C#, Part 3: Compiler, Runtime, Type System, Modules, and Everything Else | by Alex Yakunin | ServiceTitan — Titan Tech | Medium
译者注
本文90%通过机器翻译,另外10%译者按照自己的理解进行翻译,和原文相比有所删减,可能与原文并不是一一对应,但是意思基本一致。
译者水平有限,如果错漏欢迎批评指正
原文发表于2020年1月,当时使用的.NET Core版本应该是3.1,Go版本应该是1.13版本。而现在.NET版本已经到6 Pre5,Go也到了1.16,经过这么多版本的迭代,Go和.NET的性能都有很大提高,所以数据仅供参考,当然也欢迎大家能在新的版本上跑一下最新的结果发一篇帖子出来。
译者@Bing Translator、@InCerry,另外感谢@晓青、@贾佬、@晓晨、@黑洞、@maaserwen、@帅张、@3wlinecode、@huchenhao百忙之中抽出时间帮忙review和检查错误。
这一个系列中还有其他两篇文章:
- 第一篇:Goroutines vs Async-Await 【中文翻译版】
- 第二篇:Garbage Collection. 【垃圾回收-中文翻译版】
想知道谁在这里吗?请一直读到最后。
这是本系列中最后一篇,希望是最有趣的一篇。第一篇和第二篇主要研究了Golang的协程和几乎无暂停的GC,这篇文章补充了所有缺失的部分。
相似性
两种语言的相似性:
- 可以编译成本机代码
- 可以在多个平台上运行
- 依赖于垃圾收集
- 支持模块【.NET中是程序集(assemblies)】
- 支持类【在Go中叫结构(structs)】,接口【interfaces】和函数指针【function pointers .NET中叫委托(delegates)】
- 提供一套错误处理的选项
- 支持异步执行
- 拥有丰富的基础类库
- 具有类似的运行时性能
但是在这些功能的实现上,差异多于相同之处。让我们跳到这一部分 😃
编译
Go编译成本机二进制文件,也就是说它的二进制文件是与它所编译的操作系统“捆绑”在一起的【作者应该指的是平台相关性】。
.NET Core默认编译跨平台的二进制文件,你需要通过.NET Core Runtime 的 "dotnet [executable]"命令来运行程序;这些二进制文件包含了MSIL代码,这是一种类似Native Code的代码,通过.NET的JIT【即时编译器】来编译。JIT编译的效率很高,它缓存了以前编译的模块【当你安装.NET Core时,BCL的大多数模块都被预先编译和缓存了】,它的速度很快,默认情况下,它在第一次调用时生成没有复杂优化的代码,当它发现方法被频繁调用时,就会生成一个优化的版本【分层编译】。也就是说你可以免费得到一个"轻量级的PGO【Profile Guided Optimization】"。
你一样可以使用.NET Native来制作一个完全AOT的本地二进制文件【应该是指NGen和NativeAOT】。
垃圾回收
在表面上,两者非常相似,但是他们的实现过程存在巨大的差异。
.NET的GC是针对于吞吐量(内存分配速率)和运行时性能进行优化的【.NET GC 分代+标记整理算法】:
- 它是分代的,这也就意味着它的构建对CPU的缓存非常友好,当你的代码在运行时,它最近分配或者使用过的对象很可能都在CPU的L0(那是0代的位置)或者在L1缓存中(那是1代的位置)。
- 因为它是一个具有整理功能的分代GC,所以在C#中分配内存的消耗很低:基本上就是一个指针自增+比较,也就是说堆内存和栈内存分配消耗一样的小。
- 劣势也是因为它需要进行整理。它分配的每个对象可能会在堆中移动几次(每代(Gen0~Gen2)之间的转移 + Full GC),更糟糕的是,整理意味着.NET必须修正它所移动的任何对象的引用指针【由于整理后对象地址发生变化,需要重新建立引用】,对象可能在CPU寄存器中,在栈上或者在队中,所以它在整理时需要更长的暂停时间来进行修正工作【详情可参考垃圾回收过程】。
与之相反,GO的垃圾回收被设计成几乎无暂停的【Go GC】:
- 它对缓存不那么友好,老实说,除了让相同类型的对象彼此能靠近外,它对缓存不友好。
- Go没有分代GC,所以每一次GC都是Full GC,如果你的应用程序快速的分配和取消引用对象,你更有可能看到OOM或者分配失败,因为GC没有足够快的扫描对象图来释放未引用的对象。
- 但是它同样没有整理和指针修复带来的问题,这意味着Go应该有一个完全无暂停的GC,Go需要为每个指针都花费一点额外的时间(如在GC的"标记"阶段,标记每个目标引用都为"活着"状态,当然,细节是很复杂的)。但是Go并不是从一开始就没有暂停,在2017年左右,开发者设法将其暂停时间减少到毫秒内。
Go中低停顿时间是用牺牲性能的代价来替换的,如果你想了解更多的细节,请翻阅本系列的第二篇,不过那里的比较是在.NET Core 2.1下进行的,我计划下周分享我最新的更新,将使用.NET Core 3.1和Go 1.13.6,但是初步来看,差距很大。
下图,突发分配速度-单位GB/s(越大越好)
- 在单线程突发内存分配测试中,C#要快4.5倍。当线程数量到到48个时,差异将增长到23倍。C#每秒7.7亿次分配与Go的0.34亿次分配。
- 在分配速度与.NET上的线程数和核心数呈线性关系(测试机上1至48核心),至于Go,它在12至36个线程范围内达到最大值,但是当它接近48个线程时,分配速率下降了近40%(0.33亿次)。
对于分配速度和STW暂停时间,我们可以比较在48核上使用36个线程分配32GB静态集合的结果:
- .NET 10.05GB/s的分配速度,最大暂停时间2.6秒,百分之99.99%的暂停时间72ms。
- Go 2.89GB/s的分配速度,最大暂停时间0.1秒,99.99%的暂停时间46ms。
- "我们可以比较…."意味着这是Go在128GB上能够完成的最复杂的测试;它在每一次的测试中都以OOM的方式崩溃(静态集合>=1GB 和 线程数=48/48核心)。此外,它在(静态集合>=64GB, 线程数=36/48核心)上使Windows桌面管理器奔溃,目前为止还不清楚怎么回事,感觉它是在OOM时是冻结了而不是终止所以导致Windows桌面管理器崩溃。
- .NET Core完成了所有测试,并且没有OOM。
下图:持续分配速度-单位GB/S(越大越好)
下图:最大STW停顿时间-单位ms(越小越好)
下图:STW停顿时间-99.99%基线 单位ms(越小越好)
如果你对详情感兴趣,可以点击链接看原始的测试数据【机器配置:AMD 线程撕裂者3960x 和 128GB内存】,现在只有Windows上的测试。
模块
同样的,表面上一样,但是本质上非常不同。
Go中的相关概念:
- 包【Package】:一个有源代码的文件夹。所以添加一个包意味着你在你的项目中添加更多的源代码。每个包只有在它或者它依赖的包发生变更时才会被重新编译。只有Go关注包编译版本,你甚至不应该知道它的存在。包最终会产生库或者可执行的文件,尽管库没有明确的编译结果,它们最终是以源代码的形式被使用的。
- 模块【Module】(1.13版本新增):一个包含了模块的版本、所有依赖关系、源码和.mod的文件夹。它可以被发布到Go模块库。
同样,在C#中也有3个与模块相关的概念:
-
项目【Project】:一个包含了C#文件 + .csproj文件的文件夹,该文件描述了他所有依赖关系和程序集的属性。
-
程序集【Assembly】:它是一个项目编译结果,它包含了MSIL代码+描述它的元数据(方法、类型等)。记住,.NET依靠它的JIT编译器来运行代码,所以基本上.NET程序集像C语言中的.obj(或.o) + .h/.hpp文件的混合体。它们不存储源代码,尽管所有的符号和编译后的实现都在那里。同样,程序集可以库,也可以是可执行文件,或者两者都是(没有什么可以阻止你从包含入口的程序中导入任何你想要的东西【作者应该是说可以通过Assembly.Load在运行时加载程序集,通过反射来调用程序集的方法,或者直接在项目中依赖一个可执行文件】)。
-
NuGet包【Nuget package】:一个.nuget文件(实际上它是一个zip压缩的文件),包含.NET程序集 + 其它你想要的东西 + .nuspec文件,这样的文件通常被发布到公共的NuGet仓库中,你也可以使用私人仓库。通常情况下,你引用NuGet包而不是你C#解决方案中的项目(.csproj文件);当你的项目被构建时(例如使用"dotnet build"命令),它会自动下载和安装。但由于NuGet格式与.NET无关,也可以使用其它工具如Chocolatey将其用于自己的包。
所以两者的区别就是Go的软件包含有源代码,而.NET的包没有源码吗?
不,最大的区别是.NET可以在运行时加载和卸载程序集,将其中的类型与当前的主程序集整合在一起,特备是以下这几种情况。
- 插件:你可以在你的应用程序中声明一个IMyAppPlugi接口,实现一个逻辑从Plugins文件夹中加载所有程序集,在那里创建所有实现了IPlugin类型的实例,并通过IPlugin.Embed(myApp)方式调用。这就是为什么.NET应用程序是具有扩展性的。
- 运行时代码生成:.NET有Reflection.Emit 和 LambdaExpression.Compile 方法(它底层使用Reflection.Emit)。两者都可以生成动态的程序集,而且几乎是实时的。你可以生成任何.NET代码,这个新代码可以使用当前运行时内所有类型,也可以生成自己的类型。这一特性被大量用于加速复杂的逻辑(所有主要的.NET序列化程序都使用这一特性;编译后的Regex实现性能让其它语言的实现方式都望尘莫及,包括Go)或者依赖注入的逻辑(大多数的IOC容易都依赖它),这也使得AOP方案成为可能。
- 代码层面的自检:由于你的代码可以访问应用程序任何部分的MSIL和元数据,你的代码可以自检(像Cecil这样的工具对此帮助很大);例如,生成在GPU上并行运行的版本(查看这个样例子ILGPU)。
- 所有这些都使一些奇怪的(但显然是相当有趣的)场景成为可能:例如,即使是那些从来没有想过要扩展的应用程序,也因为这个而以黑客的方式得到扩展。我所知道的最明显的现代例子是Beat Saber,过去两年中最流行的VR游戏,我是它的忠实粉丝。不同的人为它制作了50多个插件和20000多个社区制作的地图,尽管这个游戏没有官方的插件API。怎么做到的?嗯,它主要是一个.NET应用程序 - Beat Saber是建立在Unity上的,它使用C#/.NET作为其主要的 "脚本 "语言。有许多针对.NET的开源工具(Fody, Harmony)能够对已经编译好的程序集进行后处理,以嵌入、改变或删除你喜欢的东西。所以有人为Beat Saber制作了BSIPA,它将插件的调用端点直接嵌入到游戏程序集中,并确保游戏在启动时加载插件。Viola! Oculus Quest版本的Beat Saber有一个类似的mod(BMBF),即使Quest运行在Android上(但Unity for Android仍然运行.NET)。
Go提供了"插件"包,技术上允许你动态加载.so文件,但是:
- 只能工作在Linux和Mac OS上
- 主机和插件的编译环境必须完成相同,特别是所有包引用必须完全匹配。
- 还有很多其他的缺点,所以 "很多人误解了插件今天能做什么。他们目前不容易使第三方为你的应用程序制作插件;[......]在实践中,只有原始构建系统可以可靠地构建插件。这些问题充满了人们在构建环境中发现的所有小差异。"
- 这就解释了为什么Hashicorp(Terraform、Consul、Vault等背后的公司--他们要求第三方供应商提供一种编写插件的方法)依靠他们自己的插件API在子进程中托管插件并通过IPC调用它们。
类、结构、接口
- 类总是生活在堆中,结构体生活在调用栈和堆中。如果生活在堆中,要么是作为其它类的字段,或者以装箱的形式存在。因此"new"关键字:对于类,进行堆分配+调用它的构造函数。对于结构体:只是调用构造函数,此时已经为结构体保留了内存空间(在当前的栈上,或者堆上的类或结构体中)
- 类永远都是引用传递,结构体默认是值传递,你可以通过(in/ref/out parameter/ref return/ref struct,在有些情况下手受限的)【详情戳我】进行引用传递。
- 类可以有虚方法也可以继承其他类,结构体没有虚方法也不能继承。
- 当打包成数组时,结构体需要的内存大小就是它各个字段每项的大小。类的话是指针(64位系统占用8字节)+ 显然还有它本身实例的内存。
- 每个实例在堆中都有两个指针,一个指向虚方法表(类型描述符)和一个系统保留的指针大小的数据(存储一个用来比较的伪随机值+为GC和同步保留几个byte)。
- 所有接口类型的值都需要一个指针。
然后Go只有结构体,但是:
- 它们支持通过嵌入的方式进行集成【组合模式】。
- 结构体能存在于堆中或者调用栈上:
- 默认的情况下,你创建结构体时不会明确的指定它应该在哪创建,逃逸分析可以帮助编译器决定其放置在哪里,调用栈或者堆上。据我所知它可以把它放在调用栈上,然后在移动到堆上,你也可以明确的在堆上分配结构体。
- Go中的堆存储对象没有对象头,因此结构体在goroutine栈、堆、其他结构体的字段以及数组/片中占用相同的空间。没有堆头意味着没有好的方法来实现此类对象的基于引用的相等比较。如果你没有发现其中的联系,不要担心,我将在后面的 "相等【Equality:相等性,平等,按照上下文意思判断两个实例是否一样】"部分解释。
- 结构体没有虚方法,但是结构体可以实现接口。所以你可以将一个结构体强转成接口。
- 有趣的是,它的接口类型的值需要两个指针(所以它们在64位的平台上需要16字节或者两个CPU寄存器):第一个指针指向底层的结构体,而第二个指针指向接口的方发表,所以类型信息在.NET上是和对象一起呆着,因为类型信息在实例header里面。而Go中类型信息是通过指针指向的。
这两种方法都有明显的优点和缺点。
- 总的来说,Go中的结构体与.NET中的结构体工作原理非常类似。只是.NET中的结构体还需要有一些改进(嵌入+转换到接口时不需要装箱)。
- .NET需要更多的时间来调用接口成员(虽然它缓存了对接口方法表的引用,但是仍然需要更多的时间)。
- Go需要更多的空间来传递接管口的引用(在寄存器中,在调用栈中,在数组和切片中,等等)。
这里值得一提的是,Go:
- 需要从几乎所有可能失败的方法中返回"err"值(错误类型,这是一个接口)。
- 总是通过调用堆栈来传递值,而不是通过寄存器。另外,请注意每个调用的不寻常的“序言”,它检查堆栈扩展的潜在需求【作者应该是说Go为了实现协程的协作式抢占,sysmon 协程标记某个协程运行过久,需要切换出去,该协程在运行函数时会检查栈标记,然后让出当前线程给其它协程用,详情可以看这篇文章】。这是Go实现协程付出的代价,其它大多数的静态语言在每次调用时都不做这样的额外检查【据我所知,在Go 1.14版本通过SIGURG信号的方式实现了异步抢占,但是不清楚会不会带来其他性能问题】。
- 因此,这个额外的 "err "需要在调用栈上增加16字节。此外,从调用中得到 "err "的代码必须对 "err == nil "进行额外的检查......这种每个调用的 "额外"(调用栈上的16字节+两次比较)是不是有点太昂贵了?
还有一些其它看法:
- 在Go中,接口字段的大小超过了机器字的大小,所以它不能被原子化地更新。我不确定这是否会造成任何大的问题,但我知道在.NET中经常会有指针被原子化更新(例如,指向一些共享的不可变模型的根)。尽管在大多数情况下,将接口指针包装成一个结构并使用它的指针的解决方法可能是可行的--只是访问速度会慢一点(解决一个额外的指针)+ 更新时需要额外分配。
- 从好的方面看,这个功能(似乎--我没有检查过)允许Go将任何结构(例如存储在数组中或另一个结构的字段中)投向它所支持的接口,而不需要装箱。对于.NET来说,这是不可能的(尽管你可以在通用方法中实现类似的功能,也就是说,有一些变通方法可以让你在类似情况下摆脱额外的分配)。
总体而言,Go模式似乎更简单/更有吸引力:
- 没有对象头(我猜如果需要分代GC,你还是需要对象头)。
- 没有值类型和引用类型。
- 结构体嵌入+接口的集成似乎更容易理解,而且更接近于底层。
但这一切都不足以成为交易的障碍;此外,Go也有自己的问题,例如,我发现了逃逸分析有一些缺陷;早些时候,我写过关于切片的一个类似问题,下面的 "相等性 "部分描述了另一个问题。因此,我觉得可能会有更多这样的问题......尽管我对它还不够了解,不能肯定地声称这一点。
目前的结论是两者打平。
错误处理
C# 使用"经典"的异常处理,如果你对此细节感兴趣,可以查看我的这篇Exception Handling 101文章。
Go 选择了一条相当独特的道路,有两种选择:
- 显式错误传递:有一个优雅的约定,一个可能失败的方法返回的最后一个值必须是"err"(错误类型),如果一切正常,则为nil(空指针),如果不正常,则为某个对象,调用者必须明确的检查nil。
- 还有defer、panic和recover,这是不优雅的失败处理。
但是我不得不提的是,Go的模式显然更耗性能:
- .NET经典的异常处理方式只有在发生异常的时候才会有性能损耗,否则几乎没有性能损耗。
- 相反,Go的异常处理模式让你的程序为每一个返回"err"的调用和每一个"defer"买单。
最后,如果panic→recover模式与常规异常处理没有太大区别,您是否仍然觉得到处返回“错误”的最初想法在概念上仍然是好的——否则为什么你需要两者?
相等性(==, !=)
它在.NET和Go中的工作方式完全不同。
首先,简单介绍一下:相等性通常需要的两个操作:
- 比较两个实例是否相等
- 以符合相等性的方式来计算实例的hashcode【实例相等hashcode必然相等】
这意味着如果实例相等那么hashcode必须是相等的,对于不相等的实例则hashcode极有可能不相等(它们是可能相等的,这被称为哈希碰撞)。换句话说,如果你比较实例的hash值,如果它们不相等,那么实例肯定不相等;如果hash值相等,那也说明不了什么,这些实例也可能不相等。
最后,对于不可变的实例来说,hash值不应该随时间而变化。Set、Map和集合都依赖于hash值,如果你把(key1,value1)放到一个hashmap中,然后key1的hash值改变了,那么map[key1]将查找不到value1。
所有这一切意味着相等和Hash对于可变对象几乎没有意义——除非你在 Equals 和 GetHashCode 操作中只使用它们的不可变部分:
- 如果没有GC压缩,内存中的对象地址就符合“不可变部分”的特征。它对于每个对象都是唯一的,而且永远不会改变。
- 还有一些对象从其公共API方面看是不可变的,但其内部状态是可变的,例如,因为它们缓存了一些东西。例如,它可能是你自己的字符串包装器,它缓存了字符串的哈希代码以避免重新计算(假设你处理的字符串可能很长)。它的全部状态是可变的,但其中公开的部分是不可变的。这就是为什么你可以为它实现相等性和哈希代码的计算。
在.NET上,相等性大多是用户定义的,你必须为结构体(逐值传递类型)手动编码,而且【详情可以看如何重写Equals方法】:
- 通常情况下,你会将大部分的结构标记为只读(不可变)。GetHashCode和Equals可以直接的实现。
- 如果你在写非只读结构,你应该应用我上面描述的规则,即理想情况下,只比较不可变的部分。
- Visual Studio和Rider可以自动生成Equals和GetHashCode的实现。
与结构体相反,类(pass-by-reference类型)自动获得基于引用的平等:如果两个引用指向同一个实例,那么它们就是平等的。通常情况下,你不会改变这一点,尽管你可以。
- 基于引用的平等需要在具有压缩GC的语言中进行一些额外的处理。你不能假设指针在未被触动的情况下保留其价值--指针在堆压缩时被GC修正。这个问题给基于指针的平等带来了额外的问题:也许你可以实现比较(你需要原子地读取和比较两个指针),但你如何计算哈希值,它必须对同一个指针保持不变,即使它改变了?
- 在.NET中,这个 "额外 "是一个存储在对象头中的伪随机数,它作为一个哈希代码用于引用平等。不幸的是,我不知道它是如何计算的,尽管它很可能是由对象地址和一些种子(很可能是一个加法)衍生出来的,这些种子会随着时间的推移而变化(如果你有压缩,可能会匹配到很多地址)。
但在GO中却非常不同,在GO中,总是进行结构上相等比较。我猜这是由于两个因素。
- 所有的结构都表现得像是通过值传递的,尽管指针是在幕后传递的。由于指针是你在这里不应该考虑的东西,从相等性的角度来看,忽略它也是合乎逻辑的。
- 我写道,基于引用的相等性需要头或类似的东西,在一个有压缩GC的语言中。尽管Go还没有压缩的GC,但它保留了在未来添加它的可能性。这就是为什么它明确地禁止你假设指针是稳定的。但是由于Go中的所有对象都没有头,基于引用的相等性在这里是不可能的。
- 这样做的后果之一是接口的相等性如何:如果底层实例具有相同的类型,并且在结构上是相等的,它们就是相等的。对于比较而言,在.NET和Java中,如果且仅当它们属于同一个实例时,接口是相等的(即基于引用的相等)。
此外,在Go中:
- 你不能重写相等性的工作方式,即使对于你自己的类型。结构相等性是你所拥有的全部【作者的意思应该是只没办法通过自定义的方式两个实例是否相等,比如在一个结构中有id,name这些字段,在业务场景中只要id字段相等就认为是相等,C#可通过重写Equals实现,Go则不行】。
- 没有标准的哈希函数/API用于相等判断,也没有办法在Go中调用map类型使用的内部哈希函数,所以如果你需要为你自己的集合提供哈希,Go不能帮你解决这个问题。而且据我所知,甚至关于如何暴露它的讨论也还没有结束。
- 似乎没有办法让例如map依赖你自己的相等比较器(有时你需要这样做),而且说实话,我不知道如何实现一个假设相等总是结构性的变通方法,例如,即使你开始用你自己的包装器替代键,包装器仍然不能覆盖他们自己的平等/哈希,所以...
像往常一样,有利有弊。
- 在这里,Go胜在比较简单:是的,Go里面更容易理解平等的作用。
- 而在其他方面上都输了:有很多非常通用的情况下,你确实需要一个自定义的相等判断逻辑或基于引用的相等性判断。
基础类库
这里最显著的区别是.NET BCL有相当数量的方法和接口被C#编译器特别对待(尽管编译器并没有寻找特定的接口。它寻找的是具有相同名称的方法)。一些例子【这可能说的就是鸭子类型】。
- IDisposable/IAsyncDisposable:在 "using "语句中使用,提供对资源处置的支持/(类似stream.Close的情况)。在实现中,编译器会寻找是否存在Dispose/DisposeAsync方法。如果你需要处理托管或非托管资源,你要实现这些接口中的一个。
- IEnumerable<T> & IAsyncEnumerable<T>:在 "foreach "循环和带有 "yield return "的方法中使用,提供对序列枚举的支持。在实现中,编译器会寻找GetEnumerator方法。
- Task/Task<T>/ValueTask/ValueTask<T>:用于 "await "表达式,提供对异步完成通知的支持。在现实中,编译器会寻找GetAwaiter方法。
- Enumerable/Queryable.Select/Where/...(数十种其他扩展方法):用于LINQ表达式(见 "from"、"where"、"select"、"group "及其他关键字);编译器将这些表达式转换为方法调用链。
- IEquatable<T>和IComparable<T>接口:NET中所有的通用集合都依赖它们来测试相等或相对顺序。特别是,Dictionary<TKey, TValue>使用IEquatable<T>来比较相等和获取HashCode。
- 即使是最基本的类型,Object也提供了GetHashCode()和Equals(...),你可以在子类中重写,+ GetType()和其他一些你可以调用的方法。
相反,Go只为系统类型(切片、映射等)提供语言支持(即特殊语法),但没有任何接口或类型可以实现或扩展,这些都是语言所支持的。
主要内容:
- C#与它的BCL很好地结合在一起。相等性/哈希、序列/LINQ、资源释放--所有这些都被C#部分地支持。
- Go采取了不同的方式,尽可能少地提供这种集成。
两种语言中存在的其他类似特征
- Go的切片(Slice)约等于.NET中的Span
- 扩展方法:非常类似,你可以自由地将方法 "附加 "到Go或者.NET中的任何结构(类)和接口上。
- 这两种语言都支持不安全指针/不安全代码。
类似的反模式/设计错误
- 这两种语言都有null/nil指针的十亿美元的错误,但C#在几个月前通过nullable引用类型解决大部分问题【作者应该是指null指针是个坑爹的东西,绝大多数的问题都是因为null指针】。
- 大括号,为什么,为什么不只是缩进? 😃【我觉得大括号挺好的,(逃】
C#中缺少的Go功能
- 类似Go的异步执行模型,下面有专门的章节介绍goroutines和async-await【这其实本质是stackcopy和stackless协程实现方式的区别】。
- 公共/私有成员的约定而不是额外的关键字,C# 成员声明中的修饰符的数量有时甚至会吓到老手:“protected internal static readonly 真的吗? 真的”【我也觉得这个比较复杂,常用的也就public protected private】 。
- 主要就是这样。
Go中缺少的C#功能
拿起一杯咖啡,这个列表很长。
- 泛型 - 说实话,这很重要。如果你看看其他任何现代静态编译语言,泛型都在那里。而我担心要把它们添加到Go中是相当困难的,主要是因为其静态类型系统。我将进一步展开,但这的主要后果是:
- 在Go上设计真正有效的通用数据结构和算法比较困难(尽管它的一些特性,主要是接口的实现方式,部分地缓解了这一问题)。
- 很明显,由于这个原因,你在编译器支持的类型检查方面受到了更多限制。同样,这不是你不能没有的东西,但泛型和类型检查是(可以说)开发人员越来越倾向于使用TypeScript而不是JavaScript的主要原因。
- 有一种观点认为Go中没有添加泛型以使事情更简单 ,这显然不是真的。 泛型根本就没有那么容易实现。 在一种旨在在运行时具有静态类型系统的语言中。 在这种情况下,它不是添加,而是一次重大的重构; 此外,这可能是 Go 路线图上最基本的功能。 这解释了为什么泛型是在大约 2.5 年前宣布的,但仍然没有与之相关的 PR/问题(我尽了最大努力寻找这个;也许我错了)。
- 泛型影响着你写的一切,但最主要的是--你的BCL。老实说,你应该早些加入它们,而不是晚些--你越是等待,你的BCL的大部分内容就会在你加入它们之后变得过时。.NET的前4年(2002年...2006年)没有泛型,而且有些遗留问题仍然存在(例如Hashtable和System.Collections的其他非类型集合/接口仍然在BCL中--甚至在.NET Core中)。而Go现在已经有10年历史了。
- Lambda表达式;更确切地说,Go提供了匿名函数(闭包),但没有对参数进行类型推理,所以依赖它们的代码看起来很丑。
- 序列生成器(带有 "yield return "的方法)。
- LINQ(语言集成查询):有一些模块试图为Go实现LINQ-to-Enumerable。但即使快速浏览一下这些例子,也会发现那里既不方便,性能也不好。
- 缺少Lambda会让你写更多的代码。
- 缺少的泛型使你把每个函数参数从interface{}类型(它类似于C#中的Object)转换为它的实际类型,这是对函数的每次调用都要评估的标准。
- 编译器不能帮助你进行任何类型检查--所有的序列都有相同的类型(像.NET中的IEnumerable