精翻《Go 编程语言:为什么所有类 C 语言都很糟糕,除了它》
原文: https://www.syntax-k.de/projekte/go-review 作者: Jörg Walter info@syntax-k.de
一个简短的更新:许多事情发生了变化。Go在软件世界中占据了一席之地,但其他语言也在发展。我已经不再编写新的Go代码了,很多人对Go语言提出了(在我看来是合理的)批评。如今,C++有了一个据称安全的子集,Rust则是一种全新的语言,如果你能驾驭它复杂的语义,它可以提供强大的安全保障。这只是其中几个例子。所以,请以审慎的态度看待下面的内容。我仍然更喜欢使用Go软件,而不是随意的C/C++程序,Go依然有它的优势,但它已不再是唯一一个安全、快速且舒适的系统编程语言了。
就我个人而言,我开始不喜欢它那有意为之的冗长。隐藏复杂性可以有助于维护,并使代码的逻辑更加清晰。C++在这段时间内有了巨大的改进,但由于历史遗留问题,你仍然需要知道如何避免踩坑。话虽如此,Go本身也容易出现一类常见的错误。鉴于我现在的大部分工作都在(深入的)嵌入式设备上,Go已经不再适合我的需求。我可能需要认真学习一下Rust,看看它是否真如某些人所说的那样是解决方案。但另一方面,“我应该学习Rust”这句话在网上似乎过于常见,这本身也说明了一些问题……
这篇文章本来是对Go编程语言的评论,它自2007年以来由Robert Griesemer、Rob Pike和Ken Thompson在Google开发。到目前为止,Ian Lance Taylor、Russ Cox和Andrew Gerrand也加入了内核团队。这是一种类似于C的语言,具有一些动态脚本语言的特性和一些在并发与面向对象编程领域的新颖方法(至少在通用语言领域)。它旨在成为一种系统编程语言,这就是为什么这篇评论将其与其他类似C的语言进行比较,而不是与脚本语言比较。
在撰写这篇评论的过程中,我注意到Go的许多方面在进行评估之前需要更详细的解释。Go非常不同,你不能从经典的面向对象背景中判断它。因此,这既是对Go的介绍,也是对它的评论。我自己是Go的新手,写这篇文章帮助我理解了Go的功能和特点,但请记住,我仍在编写我的第一个Go应用程序的过程中。我使用过许多语言,因此我会将Go与其中的许多语言进行比较。
Go很年轻。它今年才被宣布为稳定版本。通过这篇评论,我还希望为关于Go未来发展方向的讨论做出贡献,帮助其成为一种真正优秀的语言。这包括指出现有的不足之处。
我对新编程语言总是很感兴趣。通常,我使用方便的动态语言,如JavaScript、Perl,最近是Python。大多数时候,我更看重可读性、可维护性和开发效率,而不是原始的速度基准测试。过早的优化通常是不值得的。安全性也很重要,而脚本语言能让你避免缓冲区溢出、格式字符串漏洞和其他低级别攻击(假设运行时本身没有漏洞)。
但这些语言也有其缺点。它们无法很好地扩展,而我的工作涵盖从8位MCU到嵌入式ARM系统和智能手机,再到经典的桌面应用程序。对于这些,我倾向于使用C(++),但表达能力大打折扣。没有方便的字符串操作,笨拙的正则表达式需要外部库,手动内存管理,当然还有过去40年来所有的安全噩梦。但对于它擅长的工作,它几乎是你能得到的最快、最节省内存的工具。
因此,低级语言领域存在着根本性的缺失。我们用来编写低级工具和操作系统的语言已经过时,并且对当今系统环境中的挑战不敏感。为什么会这样,我将在这里展示。这里仅涵盖C风格的语言,因为这是人们习惯使用的风格,但你可以轻松添加关于Pascal、Modula、Oberon、Smalltalk、Lisp等曾经是计算机系统内核的语言条目。
我喜欢C语言,真的喜欢。它的简单性让它显得非常优雅。除非你想用C作为主要API去写一个复杂的GUI工具包,否则它的简洁是难以否认的。
一个真正好的方面是你几乎可以像汇编语言一样控制程序的每一步,这在某些场景中非常重要。当然,前提是你没有使用优化器。如果你使用优化器,C语言的复杂语义就会反击,比如不清除包含敏感数据的内存,或者在同步屏障之间重新排列语句。这是无法避免的。如果你有指针并对其进行算术运算,那么你需要语言中不明显的语义来确保优化不会糟糕透顶。
但真正的缺点在于字符串、内存管理、数组,几乎所有东西都容易受到攻击。而且操作起来也很笨拙。所以虽然C可能是最轻量的语言,但它并不真正适合超过1万行代码的项目。
C++在某些方面改进了C,但在其他方面更糟糕,特别是没必要的语法冗长。它一直像是个附加补丁,这一点你能明显感觉到。它确实是经典应用开发的优越替代品,一些好用且功能齐全的GUI工具包很好地利用了它的优势。
但是,当你尝试用C++做动态语言的现代“花哨”功能(如lambda函数、map/reduce、类型独立性等)时,往往会有这样的反应:“哇,这个用C++也能实现!你只需要做的是:
dynamic_cast<MyData*>(funky_iterator<MyData &const*>(foo::iterator_type<MyData>(obj)))
嗯,好吧。
别误会,我喜欢模板,但现代C++使用STL看起来像是典型的“只有锤子时,所有问题都是钉子”的情况。GCC甚至不得不实现特殊的诊断简化功能,这样你才可以搞清楚那个5行的错误信息其实只是一个简单的const不匹配错误。更糟糕的是,它们可能非常慢。你有等待过Boost编译吗?好主意,但实现很糟糕。
在这里我感到有点像异端。我本不应该贬低来自NeXT的任何东西。但我还是忍不住——那种“附加补丁”的感觉同样适用于Objective-C。它没有C++那样没必要的冗长语法,但那种括号语法就像是一个并行世界进入了C,只是为了不打扰C语言的“神圣”语法。
你不会像在C++中那样写出令人印象深刻的模板转换(也许这是一个优点^^),而且内存管理仍然是半手动的。至少在C++中,你可以免费获得引用计数的指针(如果你能找到成千上万种错误语法中的唯一正确模板语法)。
Objective-C通过官方提供的可选GC来规避这一问题。等一下——垃圾收集器(GC)一直是C类语言的可选功能。boehm-gc存在多少年了?但标准的做法是使用内存池,在很多情况下这是一种不错的临时解决方案,但终究是个权宜之计。
你真的认为我会在对C类语言的吐槽中忘记Java吗?Java几乎是一个解决方案。几乎,但并不完全,因为现实总是残酷的。
我们有了二进制平台独立性、一份详细的语言规范、有多种实现并且没有明显的陷阱、经典的面向对象编程、垃圾回收机制、一些真正的动态特性,最近甚至有了泛型——但内存消耗大且启动速度慢。
实际上,没有任何理由这应该是问题。先进的JIT编译器理论上可以比任何静态预编译的编译器更好地优化。GCJ可以将代码编译为机器代码,如果你愿意的话。虚拟机甚至可以被完全硬件实现。但JDK为臃肿奠定了基础,许多现代Java项目具有复杂到极点的结构。
现在,你可以非常舒适地用现代Java编写代码。Web服务提供了一些非常不错的抽象。直到你深入了解,发现它是一个鲁布·戈德堡机器。每一层都创建在去年的流行抽象层之上。你不能打破向后兼容,对吧?
看看你平均Web应用程序的库目录。我不会惊讶地看到那里有一百个JAR文档,而这些文档只是为了一个简单的搜索数据库或购物网站,这些工作用PHP写大概只需1万行代码。而且,如果你敢冒险,尝试自己构建它吧。那真是太有趣了!从头开始设置一个Linux系统,没有任何逐步说明,比这个要简单得多。相信我,我做过这两件事。开始之前,确保你已经会拼写“依赖地狱”这个词,正着和反着都能拼。
所有这些都被包裹在过于冗长的语法和老派的对象模型中。这并没有根本错误,但有些语言做得更好。现代的语言设计应该是另一个样子。看看Perl 6,它真的试图将现代语言设计的成果引入一种可用的(某些价值下的“可用”)语言。而且这些首次投入生产的特性已经存在几十年了!Java在这方面还远远不够,除了可能在泛型上有一点。
我差点忘了这个。我实际上已经忘了它,直到一些反馈提醒了我。坦白说,我对C#的了解并不多。作为一门语言,它看起来很不错,是C和C++的伟大进化。然而,让我远离它的原因是它的非自由性质。是的,Mono项目存在,但我并不想将我的工作基于一种因为微软的“仁慈许可”而存在的语言,而微软随时可能将这种许可转变为专利诉讼。我们都知道那些大公司(实际上任何大公司)有哪些花招。
我看不出编写非跨平台代码的意义,所以Mono项目因为受到威胁,我保持距离。另外,CLR必须先赢得足够的声誉,而Java虚拟机(JVM)的优缺点已经被充分理解了。也许有一天我会写C#代码,但不会是近期。
由于缺乏开放的语言工具和替代及/或特殊用途的实现生态系统,它根本不适合作为系统编程语言。它只是一个公司独享的项目。
哦,还有一点——一家公司通过语言开发本身赚钱,这不是通往健康演化的道路。企业利益最终会迫使做出不利于语言质量的选择。永远都存在改进的压力,否则你就无法卖出任何东西。作为对比,看看TeX,它在30多年的时间里基本保持不变,并且几乎没有bug。你不能真正将两者进行比较,但它展示了光谱的另一端,而C#正好处于错误的一端。
JavaScript听起来不像应该出现在这里的语言,因为它是一种完全动态的脚本语言。然而,它是最广泛使用的编程语言之一,确实也属于C类语言。而且,更重要的是,JS引擎如今支持相当先进的优化JIT编译器,因此其性能可以与本文提到的其他语言相媲美。
那么,JS的根本问题是什么?并不多。唯一的问题是它不适合小型系统。它是嵌入应用程序的一个绝佳语言,也适合编写网络服务,但它的设计明确规定了不能与外部世界进行交互。宿主应用程序定义了所有交互API,以实现定义的方式,因此,按照定义,它不能成为系统的宿主语言。
此外,JIT和运行时需求使其嵌入性有限。如果你拥有一部Palm Pre,你已经在使用JS作为嵌入语言,它非常方便。只是那500MHz/256MB的系统已经是有用范围的低端设备。也许使用JS(或更确切地说,ECMAScript)作为其主要系统语言的最低规格设备是Chumby,它可以播放Adobe Flash Lite影片,硬件规格为450MHz/64MB。这可不是通用语言。
亲爱的圣诞老人,所有低级语言都糟透了。圣诞节我想要一门具有以下功能的编程语言(参考已创建的语言示例),重要性按顺序排列:
表达能力
我想要一门足够高级的语言,它让我能够实际表达我的想法和算法,而不是浪费时间和屏幕空间在繁琐的管理任务或80%都是样板代码的模式上。表达能力的最重要元素列在以下部分。一个好的测试是编写一个解析器。如果生成的代码与解析的构造有很多相似之处,那这就是正确的方向。(示例:Perl 6语法)
简单性
我想要一门优雅而简单的语言。只有少数几个概念可以表达所有的可能性。正交性是其关键。简单性也使语言更易于学习。应为相似目的重复使用语法结构,并允许用户代码与这些结构交互,以便它们能够创造附加价值。此外,不要强迫用户使用复杂的结构。提供类、单元测试框架或文档注释没有错,但如果用户不想使用它们,就应保持它们不在视线内。理想情况下,做到“程序员想做什么,语言就能做什么”(DWIM)。
平等权利
如果内置的关联数组算法对我的特定数据集效率不高,我希望能够创建一个替代实现,并像使用内置的那样使用它。语言不应有特权。因此,运算符应允许重载。哦,我在Python中做了什么魔法啊……此外,内置的数据类型应该能够在语言的对象语法中使用。所有这些都不必是动态的,大多数可以是静态评估的语法糖。(示例:Perl的原型子程序、Python的运算符重载)
元编程
元编程有两个方面。一方面是模板和/或泛型,它们太有用了,不能没有。即使是C语言也有一些模仿这一概念的邪恶黑客:预处理器。每个人都需要这个。另一方面是编译时执行代码生成代码,比如Lisp的宏或Perl的源代码过滤器。如果这允许创建新的语言结构或领域特定的语言,则可以获得额外加分。
效率与可预测性
如果一门语言的目标是系统编程,它必须相当高效,并且其复杂性必须是可预测的。我必须能够直观地估计某一操作的时间和内存需求的大致量级。内核语言功能必须经过良好的优化。库可以提供更高层次的构造,提高编码效率,但降低运行时效率。对象系统不应需要昂贵的运行时支持(无论是时间还是内存),应尽可能进行静态优化。 理想情况下,用几千字节的代码和几百字节的内存编写一个有用的控制应用程序应该是可能的。当然,效率和功能通常是一个权衡的两端,所以语言可以让我选择。
可用性
归根结底,语言必须可用。抛开所有理想,最重要的是它必须解决实际问题。一点点的实用主义没有坏处。语言应该与其他语言足够接近,以便你可以用熟悉的术语进行思考。如果你偏离太多,那必须值得。要遵循“最小惊讶原则”!
模块库与包管理器
我希望所有我已经习惯的脚本语言中的便利功能内置或成为标准库的一部分。拥有一个公共的包存储库和一个像样的可移植包管理器则更好。典型的包应包括互联网协议、常见语法的解析、GUI、加密、常见的数学算法、数据处理等。(示例:Perl 5的CPAN)
数据结构
我想要哈希表!没有将关联数组作为集成数据类型的语言简直是个笑话。面向对象(或其他方便的封装方法)也是必须的,显然。布尔类型很不错,但更重要的是对任何数据类型在布尔上下文中的合理解释。如果标准库中包含各种高级数据结构,如树、列表、集合等,尤其是跳跃表,那就更好了。 字符串应是面向对象的,即相同的操作(例如,len())应同时适用于字符串变量、数组或模拟数组的对象。如果所有的原始数据类型都使用相同的对象语法,那就再好不过了。你不必像Ruby那样极端,只需要将其作为语法糖就行。(示例:Python及其标准库)
控制结构
听起来显而易见,但要有像样的循环构造,允许一次从多个嵌套层中跳出。彻底消除goto,真的。最后一次我需要它是在……好吧,我承认,上周我还在用它,我在做Retro-Show-Programming(复古编程)时用到了Commodore C64。这不算数。所以消灭goto,但请给我foreach。除此之外需求不大。异常机制也许是个例外,但请不要用它来解决每个问题。 我从未使用过JavaScript的with语句,虽然这在某种程度上是个好主意,但我猜这是“少即是多”的典型案例。(示例:所有脚本语言在这里都做得不错) 实际上,有一件事我还没见过。在最近的许多循环中,循环入口和条件测试/循环出口并不是相邻的。如果有一种方法可以表达一个从中间开始的循环,那将非常酷。否则你将不得不重复循环的一部分。有点像Duff设备,只是它不是为了优化,而是为了减少代码的冗余。
表达式语法
许多人回避它,但三元运算符?:是个好主意,如果使用得当的话。Python的foo if bar else baz更冗长,但也还可以。然而,大多数动态语言凭借其布尔运算符AND和OR不仅仅返回true或false,而是返回被视为真的实际值。想象一下:value = cmdline_option || "default",这需要对所有数据类型有合理的布尔解释。
表达式的函数特性
如果我想写Lisp,我会去写。但没有什么比map()更好的了。闭包和匿名函数(lambda表达式)是杀手级功能。也许有点复杂,但诸如any()和all()这样的超运算符(Perl 6称其为“超操作符”)非常棒,并且提供了隐式并行化的机会。欢迎来到新千年,或者至少是上世纪90年代。
对象
有许多不同的面向对象模型,但最低要求是封装和多态性。还应存在某种组合类的方法,例如继承或混入。接口应该存在,或者用多重继承代替。重载很重要,至少应该有默认参数。既然提到了参数,那么命名参数也非常酷。
并发
我用这个词来宽泛地描述并行计算。生成器和协程也适合这种范畴。重点不是必须内置多线程,而是方便多个代码片段同时工作。它们不需要真正同时执行,但应该可以同时处理数据集的不同部分。将应用程序结构化为事件处理应该很容易。如果能支持真正的多进程,那就更好了。(示例:Perl5的POE、Python的生成器)
字符串和Unicode
现在已经是2011年了,我们只需要Unicode。所以请提供一种安全的字符串类型和全程Unicode支持,没有例外。我已经受够了隐式转换造成的双重编码等问题,或者手动转换却没有语言支持。我更喜欢UTF-8。谁在乎字符串的常数时间索引呢?如果有这种特殊需求,请用字符数组。正则表达式应该用于常见情况,并应成为常规字符串API的一部分。
低级接口
在某个时候,你会想手动操作一些位。尤其是在针对微控制器或嵌入式ARM内核时,应该有一种方法能够深入到硬件层面。理想情况下,用这门语言编写操作系统内核是可能的,完全不需要汇编代码(除非是无法通过其他方式实现的平台启动代码)。
当我第一次读到谷歌发布的新编程语言时,我有些怀疑,并没有理会这个消息。毕竟,下一个“伟大的新语言”总是在路上。它们中的一些经历了短暂的热潮,但随后逐渐消失,其他一些则一直处于不为人知的状态,或者还需要几年时间才能准备好面向公众。
一段时间后,我再次遇到了它。这次,我仔细看了一下。我最初没有注意到的一点是:该语言的发明者之一是Ken Thompson,他因Unix和Plan9而闻名,并且间接参与了C语言的设计。如果一门新语言是由那些曾在大型机时代战壕中服役过的人设计的,那么可能它有些特别之处。
那么Go到底能做什么,是Perl、Python和JavaScript无法提供的?它又能做些什么,这些语言曾经做不到的呢?是什么让它与那些失败或有限成功的语言不同?它会在30年后依然存在吗?最重要的是:它能否满足我的需求?
Go的最重要特性在于它的目标受众。它被设计为系统编程语言,因此瞄准的是底层软件,甚至可能是操作系统内核。因此,它可能缺少一些高级构造,因为它们过于复杂,不适合映射到硬件指令上。讽刺的是,大部分方便的特性其实都存在。
接下来你会了解到,这是一门C后裔的语言,所以请准备好你的美国键盘布局以方便访问花括号。但如果你查看一些示例代码,它看起来并不像你想象的那样C风格。括号减少了,几乎看不到分号,变量声明也很少,至少从表面上看是这样。这种语法非常简洁,关键词和控制结构有明显的不同,但仍然可以理解。
对于那些熟悉C/C++的人来说,让我们快速比较一下Go的不同之处:
没有分号! 没开玩笑!当然,其实还是有分号的,但它们不鼓励使用。它的工作方式类似于JavaScript,有一条简单的规则使解析器在某些行末插入分号。而且这条规则非常简单,因此在源代码处理工具中很容易复制这一规则。正因为如此,它比JavaScript中的分号更不易出错。
接下来是在“纯粹的异端”类别中:Go定义了一个标准的缩进样式和唯一的真括号风格(OTBS)。甚至有一个工具gofmt,可以自动格式化你的源代码。好吧,Java开发者对此很熟悉,只不过他们卡在一些愚蠢的细节上(比如缩进深度为2?真的?!)。Go的源代码格式可以总结如下:
使用制表符进行缩进(这样用户可以调整编辑器的设置,以获得自己舒适的水平空白)
花括号与它们所属的控制语句在同一行上
连续的行末不能以右花括号或标识符结束,也就是说,操作符应出现在旧行的末尾,而不是新行的开头。
第三点是简单的分号插入规则的一个结果。我希望有一种方法能绕过它,因为我更喜欢将组合操作符放在续行的开始位置,以突出正在发生的事情,而不是将其隐藏在许多其他东西之后。
说到花括号,没有不带花括号的if和for循环形式。作为这些简洁代码形式的忠实粉丝,我认为这是个遗憾。代码风格纯粹主义者可能会喜欢这一点。但最终,我对此也不太在意。对于非常短的语句,我仍然可以这样写:
if cur < min { min = cur }
上述观点的一个重要技术原因是这些控制语句不再需要括号。唯一尝试使用不带括号的if语句的是Perl6,而我们都知道Perl的解析器曾经有多复杂(显然,现在依然如此)。所以这实际上是对哪些语法形式是强制性的一个交换。因为大多数情况下你需要花括号,所以这是相当合理的。刚开始阅读时会感到不习惯,因为缺少了那些视觉上的分界符,但一旦适应了,Go代码会比C代码感觉轻松得多。
引入这些特性时,使用了关键字type、func和var。这使得代码的可读性更强,你可以更好地理解其流向。对于更技术性的原因,请继续往下看。
变量总是静态类型的,就像C语言一样。但它们看起来好像不是。如果你省略了类型,类型将从赋值中推断出来。你甚至可以通过使用新的声明和初始化操作符省略声明:
foo := Bar{1,2,3}
这声明了一个名为foo的变量,并为其赋值一个Bar类型的对象,使用对象初始化语法。它与以下代码完全相同:
var foo Bar = Bar{1,2,3}
这并没有引入动态类型,也不允许更改已声明变量的类型,也不会移除声明变量的需求,或者允许你多次声明变量。语义上,这与之前完全相同,但语法上更简洁。它感觉像一种即兴脚本语言,但仍然提供了静态类型的所有优势。
在Go中,变量的类型和函数的返回类型跟在变量名后面。在C中,你很容易犯错,例如:
int* ptr_to_amount, amount; // 一个是指针,另一个是整数
int* ptr1, ptr2; // 嗯……这不是你以为的那样
Go将类型和指针、数组修饰符的顺序颠倒过来,使得代码的可读性更强。单个声明现在只能声明一个类型的变量,因此上面的错误不会发生:
var ptr1, ptr2, ptr_to_amount *int
var amount int
考虑到前面提到的自动类型推断功能,这样做更有意义。
仍然存在指针,但它们现在只是作为简单的引用类型使用。所有对象都是值类型,因此赋值会复制整个对象。指针提供了引用语义,这在Java中是默认的。但是,没有指针算术。你必须明确表示数组访问,从而避免了大量与安全相关的问题。是的,没错,我们确实有边界检查!
因此,指针不再是算法的内核部分。它们仅用于指定引用与值语义的差异。由于指针没有特殊的解引用成员访问器,foo.Bar()在指针和值中都能正常工作。
前一点提到的内容在你能够安全地传递指针并获取每个值的地址时意义重大,比如在C和C++中你无法做到的情况。
你可以做到这一点,因为内存管理是由垃圾回收器处理的。终于有了!与boehm-gc这样的外部垃圾回收器相比,集成垃圾回收器的好处在于你可以安全地传递局部变量的指针,甚至获取临时值的地址,而这都可以正常工作!万岁!
对于那些不了解垃圾回收研究进展的人,你可能会对GC不仅仅因为它能避免使用malloc/free时的诸多错误而感到兴趣。一个合适的垃圾回收器实际上可能比手动内存管理更快,因为它可以将记账推迟到有时间的时候,并通过重用未使用的对象完全避免记账。一些最先进的GC结合了更高的内存效率和缓存使用率,因为减少了碎片化。现在可不是C64时代了。
Go目前拥有一个相对简单的GC,但更高级的实现正在开发中。在大多数情况下,GC是一个胜利,但确实会带来一定的开销。在少数关键情况下,你可以使用预分配对象的数组。
这不是指大小在运行时确定但随后不再变化的数组,而是指需要使用realloc()的数组。数组始终具有固定大小,这是GNU扩展C的一步倒退。但作为替代,Go提供了切片(slice)。
切片看起来像数组,但实际上它们只是映射到一个固定大小数组的子范围。由于我们有了垃圾回收器,切片可以引用匿名数组。这样你就获得了真正的动态大小数组。Go内置函数提供了调整切片大小和替换底层数组的功能,因此在其基础上编写一个矢量类非常简单。
Go支持反射,即你可以查看任意类型并获取其类型信息、结构、方法等。这与Java和C++的RTTI类似,但在C中并不存在。
常量可以是无类型的,或者更确切地说,无尺寸。一个数值常量可以在任何有效的上下文中使用,并将采用分配的目标数据类型的精度。常量表达式以完全精度计算,然后才转换为目标类型。没有像C中那样的常量尺寸后缀。
Go没有异常。等等——你是认真的吗?这违反了所有关于安全和稳定编程的普遍认知!这难道不是一个巨大的倒退吗?
结果表明并非如此。坦白说,你有多少次真正使用异常来做细粒度的错误检查和处理?大多数时候,它是这样的:
try {
openSomeFile();
doSomeWorkOnTheData();
yadda...
yadda...
yadda...
closeItAgain();
} catch (IOException foo) {
alert("出了问题,但没有时间做适当的错误处理");
}
这是冗长的,增加了一个没有多大好处的缩进级别,也没有解决根本问题——程序员的懒惰。如果你想为每个调用单独处理错误,那么冗长程度会非常糟糕,真的会影响代码的清晰度。
因此,我们可以回到老派的现场错误处理。返回值多年来一直被污名化,但Go有一个很好的现代特性——多返回值。因此,忘记atoi()那种脑残的设计吧,我们有合适的带外信号传递。对于那些在意的人来说这是个好消息,对于那些不在意的人,他们即使在Java中强制处理错误时也不在意。
接着还有panic(恐慌)。它保留给那些“无法继续”的严重错误。比如严重的初始化错误、可能威胁数据完整性或计算准确性的情况,即不可恢复的错误,简而言之。语言运行时也可能引发panic,比如数组越界时。
当然,这将我们带回资源清理的问题。为此,Go提供了defer语句,它非常优雅,将错误处理放在最合适的地方,问题发生的现场:
handle, err := openSomeFile()
if err != nil { return nil, err }
defer closeSomeFile(handle)
return happilyDoWork()
defer几乎像Java中的finally子句,但它看起来像是修饰函数调用。它确保closeSomeFile无论函数以何种方式退出都能被调用。作为一个副作用,成功时你可以跳过关闭操作。减少了代码重复,简洁且可见的错误处理。允许使用多个defer,并以LIFO顺序正确调用。
对于那些希望在panic后继续执行的情况,可以使用recover。结合使用panic和recover可以(滥用)创建通用异常。由于它们会模糊程序的控制流,官方建议是非致命的panic不应跨越包边界传播。对于错误处理没有完美的解决方案,因此你可以根据任务的复杂程度选择合适的解决方案。
Go只保留了for循环,并且通过range关键字可以让它表现得像foreach循环。考虑到while循环始终是for循环的一个特例,并且语法更加简洁(如前所述),我对此没有意见。更好的是:你可以在嵌套循环上设置标签,并使用它们打破多个层次的循环。终于有了!
而且,你仍然可以通过goto自找麻烦。好吧,实际上没那么糟糕,因为许多更危险的事情是被禁止的。但那些喜欢为了简化代码而稍微投机取巧的人仍然可以这样做。
所有这些功能的开销很小。虽然有一些瑕疵(稍后会讨论),但总体来说相对于C来说是一个很大的进步。仅凭这一点,就足以让很多人高兴地编写无对象的程序代码了。
Go的真正优势在于那些无法映射到C、C++或任何其他语言的特性。正是这些让Go大放异彩:
没有类。类型和类型上的方法是相互独立的。你可以为任何类型声明方法,并且可以将任何类型声明为新类型,这有点类似于C中的typedef。与C或C++的区别在于,重命名的类型会有自己的一组方法,并且基础类型(或者基于它们的类型)也可以拥有方法:
type Handle int64
func (this Handle) String() string {
return "这是一个无法用字符串表示的句柄对象。"
}
var global Handle
在这个例子中,你现在可以调用global.String()。实际上,我们得到了一个简单的对象系统,但没有虚拟方法。这在运行时上没有任何额外开销,因为它实际上只是语法糖。
类型声明不能用于使两个不同的类型看起来一样。它们是不同的类型,在严格类型化的语言中,这并不允许你创建类似于多态的泛型类型。一个常见的例子是将值转换为字符串表示形式。Handle类型声明了一个这样的方法,Go的约定也是如此,但它并没有展示如何处理任何有此类String方法的类型。
C++使用继承(可能是抽象基类)和类型转换操作符重载来实现此功能。Java的toString方法是其根类的一部分,因此被继承,而其他调用约定通过接口表达。
Go只使用接口。与Java不同的是,你不必声明某个类型符合某个接口。如果它符合接口的要求,它就自动可以作为该接口使用:
type Stringer interface {
String() string
}
这就是我们所需的一切。自动地,Handle对象现在也可以作为Stringer对象使用。如果它像鸭子走路,像鸭子叫声,并且看起来像鸭子,那么在实际使用中,它就是一只鸭子。最棒的是:这一切是动态工作的。接口声明不需要被导入,甚至不需要程序员知道它。
每当一个类型作为接口使用时,运行时通过其运行时反射能力为该接口构建一个函数指针表。因此,这里确实有一些运行时开销。不过,它得到了优化,所以开销相对较小。接口表只在实际使用时计算,并且每种类型只计算一次。如果参与的类型可以在编译时确定,运行时开销就完全避免了。方法调度应该比苹果的Objective-C调度器还快一点(这已经很酷了)。
类型起到了一种类的作用,但有着关键的不同,因为没有继承层次。在前面的例子中,Handle没有继承任何来自int64的方法。你可以通过在结构体类型中嵌入基础数据类型来获得类似继承的效果:(这个例子来自“Effective Go”)
type ReadWriter struct {
*bufio.Reader
*bufio.Writer
}
这个类型拥有bufio.Reader的所有方法,以及bufio.Writer的所有方法。冲突通过一个简单的规则解决。这不是多重继承!两个基础类型作为独立的数据对象存在于复合类型中,并且子类型的每个方法只看到它自己的对象。这样,你可以获得完全可预测的行为,而不会遇到经典多重继承带来的所有问题。并且没有任何额外的运行时开销——这基本上仍然是语法糖,使代码更具表现力而不会增加运行时成本。
这种方法也很好地适用于接口。复合类型符合所有它的组成部分所符合的接口。当然,这可能会导致嵌套的组合,使得它有点像继承。解决模糊方法引用的规则非常简单,非直观的情况将被禁止:如果在同一级嵌套中有两个相同的方法,这就是一个编译时错误。否则,嵌套层次最低的方法获胜。结果是,复合类型可以自由覆盖其组成部分的方法。
主要的开发单位是包。一个或多个文档实现一个包,你可以控制包外部可见的内容。没有复杂的可见性系统,只有“可见”和“不可见”两种。这是通过一个排版约定来控制的:公共名称以大写字母开头,私有名称以小写字母开头。这适用于所有有命名的内容。
这是一个相当务实的解决方案。与非常强大的类型系统不同,这里Go采用了一种中间立场:大多数脚本语言根本不关心可见性,或者依赖于自愿的约定,而老派的C类语言则有详细的访问控制。然而,这似乎是Go面向对象模型的一个很好的解决方案。由于没有经典的继承,嵌入的对象保持完全封装,因此不需要受保护的访问规范。这种设计在实践中如何表现还有待观察。
Go的对象系统没有特殊的构造函数。Go引入了“零值”的概念,即所有字段都被零初始化后的值。你需要编写代码,使得零值成为一个有效的“空白”对象。如果这不可能,你可以提供包级函数作为构造函数。
这是这种通用编程语言中最不寻常的特性之一。Goroutine可以看作是极轻量级的线程。Go运行时将这些Goroutine映射为类似于pth的协作式多任务伪线程或真正的操作系统线程,前者具有较低的开销,后者具有真正线程的非阻塞行为,因此我们可以同时获得这两者的优点。
Channels是类型化的消息队列,可以是缓冲或无缓冲的。很简单,真的。将对象放入一端,从另一端取出来。大多数并发算法不需要更多功能。
Go竭尽全力使Goroutine成为一等公民,它们可以构成许多算法的内核。分段堆栈使每个线程的最小堆栈使用量较低,除非你使用阻塞系统调用,否则你将获得伪线程的性能优势,即只比简单的函数调用多一点开销。
结合Go真正的闭包,Goroutine可以用于许多不属于经典并发算法的场景。Python的生成器函数可以用它们来模拟,或者自定义内存管理器也可以借助它们实现。查看在线文档,你会发现廉价并发功能是多么强大和多样。
顺便提一下,通过设置环境变量,你可以告诉Go运行时使用多少CPU内核,以便Goroutines从一开始就会映射到多个原生线程(默认情况下,在调用阻塞系统调用之前不会启动第二个线程)。
没有系统是完美的。Go也有一些缺点,以下是我到目前为止遇到的一些问题:
一个基本的Go二进制文档是静态链接的,没有调试符号的情况下大约有750KB大小,这与一个类似的C程序大致相当。我用Go主页上的树比较示例进行了测试,并与我自己编写的一个结构相似的C实现进行了比较。
gccgo可以编译出动态链接的可执行文档,但libc存在于每个系统上,通常不被认为是一个依赖项,而libgo则是一个额外的8MB包。作为对比,libstdc++小于1MB,libc小于2MB。公平地说,它们的功能比Go的标准库少得多。尽管如此,差别很大,而且是一个依赖项。
6g/8g,原始的Go编译器,产生的可执行文档类似,但它们甚至不依赖于libc,这些是真正的独立程序。然而,无法动态链接运行时。
对于小型系统来说,这也是一个问题。我手边有一台古老的16MB Pentium-100笔记本电脑,它运行着X和JWM桌面,非常流畅,还能播放我的音乐收藏。它甚至还剩下5MB的内存用于磁盘缓存。如果使用Go编写一个系统,这是否可能?
在Go的几个地方,语言是有特权的。比如,特殊的make()函数执行一些用户代码无法扩展的操作。乍一看这似乎很糟糕,因为你无法为make()添加自定义行为。不过,实际上你可以编写与make()行为几乎一致的代码,只是你无法完全接入这个语言结构。同样的情况也适用于其他一些调用和关键字,比如range。你几乎被迫使用goroutines和channels来扩展它。
我不确定这是否真的是个问题。假设映射、切片、goroutines和channels已经得到了最佳实现,这些限制的影响几乎是不存在的。它们并不会损害代码的可读性或清晰度,但如果你习惯了像Perl或Python那样的“可以模拟一切”语言,可能会觉得不公平。
重载带来了许多语义上的歧义。考虑到这一点,将其排除是个好理由。但与此同时,重载(特别是运算符重载)实在是非常方便且可读的,因此我真的很想念它。Go没有自动类型转换,因此情况不会像C++那样复杂。
作为重载可能的用途的示例,想象一下一个大数库、(数值)矢量、矩阵或有限范围的守卫数据类型。在处理跨平台数据交换时,能够改变某个特定数据类型的数学语义将是一个巨大的胜利。你可以为1s补码数值类型编写代码,例如,当处理来自古老机器的数据文档时,这样可以精确地模拟目标平台的算术操作,而不必担心当前平台的语义差异。
遗憾的是,Go的鸭子类型并不完整。设想一个如下的接口:
type Arithmetic interface {
Add(other int) Arithmetic
}
Add函数的参数和返回值会限制自动类型推断。一个拥有func (this MyObj) Add(other int) MyObj方法的对象并不符合Arithmetic接口。这种情况还有很多,对于其中一些例子,要决定鸭子类型是否应该涵盖它们并不容易。你可能会陷入许多不明显的麻烦中,因此再一次,我们遇到了“或许保持简单更好”的情况,但我依然不完全信服。
Go的内核开发者之一Russ Cox指出:
这之所以不起作用,是因为MyObj的内存布局不同于Arithmetic的内存布局。即便内存布局相匹配,其他语言也在这个问题上挣扎不已。Go只是说“不”。
我想我们需要声明一个func (this MyObj) Add(other int) Arithmetic
来替代。这样做的妥协为我们赢得了编译器和生成机器码的简洁性。
关于指针和值的处理,我不确定自己是否喜欢这种做法。Java中一切皆引用的语义要简单得多。C++的引用与值语法也很不错。积极的一面是,Go提供了对结构体内存布局和使用的更多控制,尤其是当结构体包含其他结构体时,而且值与引用语义在函数调用时显得更加显式,而C++在这一点上却令人捉摸不透。
顺便说一句,映射和切片是引用类型。起初我对此有些不满,但你可以自己制作行为相似的对象:包含(私有)指针的结构体,这基本上就是映射和切片的本质。现在,如果只有一种方法可以接入它们的[...]语法,那就好了……
布尔上下文提供了许多简化代码的可能性。不幸的是,即使是指针,也必须显式与nil进行比较,尽管!pointer非常直观。尤其是考虑到没有指针算术。此外,参见愿望清单中的第10项。这将非常有意义,并使代码更加简洁明了。
既然已经存在每种类型的零值概念,将其扩展到布尔上下文是很简单的。
有些东西没能进入Go,我非常想念它们。我希望社区能够找到一种方法,以Go轻量级的风格将它们加入进来。我希望看到若干不太重要的功能,但有一项我真的想要,那就是元编程。
我说的不是泛型或模板。接口可以在一定程度上替代它们,尽管由于上述的限制,鸭子类型目前还不够完整。
我说的是“生成代码的代码”这种元编程。想象一下,能够实现领域特定语言的可能性。如果我要处理SQL,那么无论如何SQL都将成为源代码的一部分。SQL库可以提供一种集成的解决方案,允许在编译时进行语法和类型检查。这样可能会增加编译时间,但我宁愿在编译时检查所有代码,而不是在运行时发现SQL语法错误(同时还要为每次调用付出解析时间)。
这种功能还可以改进语言的演化,使实验性特性在被考虑纳入内核语言之前更容易实现和测试。当然,这一切的前提是实现设计得当。没有人希望再次陷入像C/C++那样的混乱中。
运算符/方法重载在这个功能下将非常有意义,因此也将其纳入讨论。这样,Go将获得更多的表达能力。Python通过特殊命名方法的模型有着良好的语义,并且解决了现实世界中的问题。
不幸的是,这个话题已经讨论过很多次了,讨论结果却很少。对于像我这样的新手来说,有太多的讨论,却没有太多结果,使得很难看出这个话题的未来发展方向。引用一位讨论者的话:
欢迎提出反馈。
在邮件列表中搜索这些主题,你会发现成千上万封电子邮件讨论每个话题。
这令人困扰。如果某些主题已经讨论过这么多次,为什么没有将其正式记录下来?这样的争论会让潜在的贡献者感到气馁。而老成员也厌倦了重复回应。
理想的情况是,社区应当创建一个提案流程(就像Python、Java、Perl6、XMPP等项目那样),记录这些建议,概述满足哪些要求的提案会被认真考虑,并总结当前的开发状态。这样,想法可以成熟、可以被拒绝并附有清晰的理由,也可以被更好的想法替代,最终被纳入语言,而不损害语言的设计目标。从清晰记录的拒绝中,潜在的贡献者可以学到该避免什么、不要期待什么,以及应该做什么。
这个过程不必是民主的。无论是“集市”式的管理方式,还是“仁慈的独裁者”式的管理方式,两者都有成功和失败的案例。更重要的是有这样一个过程,并且它是透明且易于理解的。
2011-06-09 更新:回答了另一个问题
2011-06-09 添加:互联网讨论的反馈
2011-06-09 添加:三元运算符的反馈
2011-06-09 更新:回答了另一个问题
2011-06-09 添加:控制结构
2011-06-09 更新:最后的话
2011-06-09 更新:嵌入
2011-06-09 添加:错误处理
2011-06-09 更新:变量声明
2011-06-09 更新:指针 vs 值
2011-06-09 更新:有限的鸭子类型
2011-06-09 更新:回答了大部分问题
2011-06-09 更新:range关键字
2011-06-09 更新:可选括号 -> 减少括号
2011-06-09 添加:对C#的看法
2011-06-09 添加:对D语言的看法
2011-06-08 移除了“一个文档就是一个包”的误解
2011-06-07 初始发布
Translation and raw version of The Go Programming Language, or: Why all C-like languages except one suck. by Jörg Walter is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.