多年前,Python的核心开发成员之一Tim Peter写下的《Python之禅》,成为Python编程和设计的指导原则。
本文作者作为Go语言的开源贡献者及项目成员之一,将着眼于Python之禅,提出他所理解的Go语言之“禅”,其中他谈到“Go语言的成功在很大程度上要归功于处理错误的明确方式”,为何他这么说?咱们一起看看。
作者 | Dave Cheney,Go语言的开源贡献者及项目成员
译者 | 孙薇,责编 | 伍杏玲
出品 | CSDN(ID:CSDNnews)
以下为译文:
最近,我时常思考这个问题:如何编写优秀的代码?假定没有人主动试图编写不好的代码,这个问题随之而来:我们怎么知道现在所编写的就是优秀的Go代码?
如果优秀代码与不良代码之间存在连续性,又怎么区分优秀的那部分?其属性、特性、标志、模型和习语又是什么?
关于 Go
Go的习语
这就说到了Go的习语——所谓习语,指的是其语法遵从时代的风格。非习语是不遵从流行风格的,是不时尚的。
更重要的是:说谁的代码不符合习语,并不能解释它为什么不算习语。原因何在?就像所有真相一样,答案可以在字典上找到。
习语:一组由用法确定其含义的单词,不能以单个单词的含义推导其含义。
习语是共享值(shared values)的标志,Go的习语并非我们从书本习得的内容,而是通过加入Go社区而学到的。
我对Go的习语准则的关注点在于,在很多情况下,习语是排他性的。也就是说,“不能两者兼备。”毕竟,在批评某人作品不符合习语时,我们不就是这个意思么?他们做法有误,看起来有问题,不符合时代风格。
我提出Go的习语在编写优秀Go代码的教学中并非合宜机制的原因在于,习语是定义好的东西,本质上来说,是告诉对方他们犯错了。如果我们所给出的建议,不是在代码编写者最有意愿接受意见的节点质疑他们,并让他们感到不适,这样不是更好么?
谚语
除了有问题的习语之外,Go学家还有什么其他文化习惯?也许我们可以谈谈Rob Pike精彩的Go谚语,这些算是合适的教学工具吗,能否告知新手怎样编写优秀的Go代码吗?
一般来说,我认为不算。并不是要驳斥Pike,只是单纯针对Go谚语,比如Segoe Kensaku的原著,这是观察,而不是价值陈述。
Go谚语的目标是揭露语言设计相关的深层真相,但类似空接口这样的建议,对于一款没有结构化类型的编程语言来说,新手又能从中获益多少呢?
重要的是要认识到,在一个增长的社区中,任何时候学习Go语言的人远远超出声称掌握该语言的人,因此在这种情况下,谚语可能不是最好的教学工具。
工程价值
Dan Luu发现了Mark Lucovsky关于Windows团队在Windows NT到Windows 2000之间这段时间内关于工程文化的一个演讲。之所以提到它,是因为Lukovsky将文化描述为一种评估设计和权衡取舍的常用方法。
讨论文化的方式有很多,但就工程文化而言,Lucovsky的描述很恰当。其中心思想是在未知的设计空间中使用价值指导决策。NT团队的价值观是:可移植性、可靠性、安全性以及可扩展性。工程价值粗略翻译一下,就是完成工作的方式。
Go的价值观
Go语言明确的价值观是什么?Go程序员诠释这个世界的方式如何定义,其核心信念或者哲学是什么?如何公布,如何传授,如何执行,如何随着时间变化?
作为新上手Go语言的程序员,你如何灌输Go的工程价值?或者,如果你是一位经验丰富的Go语言专家,又该如何将自己的价值观传给子孙后代?正因如此,我们很清楚,知识转移的过程并不是可选的,没有血液和新的观念,我们的社区将变得短视。
其他语言的价值观
为了设定场景来了解这一点,我们可以看一下其他语言,了解一下它们的工程价值。
例如:C++(以及其替代方案Rust)都认为,程序员不必为自己不使用的功能付费。假如某个程序不使用该语言某些需要大量计算的功能,就不应强迫该程序承担该功能的成本。该值从语言扩展到其标准库,并用来作为标准,判断C++编写的所有代码的设计。
在Java、Ruby和Smalltalk中,一切都是对象的核心价值推动着消息传递、信息隐藏及多态性等程序设计过程。大众认为:将程序风格甚至功能风格这些东西,一并塞入这些语言的设计是错误的,或者就如Go学家所言,这些是非习语。
回到我们自己的社区,Go程序员所绑定的工程价值是什么?我们社区中的讨论通常很不可靠,因此从最初的原则衍生出一整套价值观会是个巨大的挑战。共识非常关键,但随着讨论贡献者人数增长,难度也呈指数增长。但如果有人替我们完成了艰难的工作呢?
几十年前,Tim Peters写下Python之禅:PEP-20。Peter试图记录他所目睹的Guido van Rossum作为Python的BDFL所实现的工程价值。
本文将继续着眼于Python之禅,并提出疑问:是否有什么东西可以描述Go程序员的工程价值?
好的程序包始于好名字
“命名空间是个绝妙的主意——我们应当好好利用它!” —— Python之禅,第19条
这是相当明确的,Python程序员应当使用命名空间,大量使用。
在Go的说法中,命名空间是一个程序包。我怀疑将组件分到程序包中是否有利于设计和潜在复用。但是,关于正确的方法还可能有问题,尤其将要使用另一种语言长达数十年之久。
在Go语言中,每个程序包都应当有其目标用途,而了解其用途的最佳方式莫过于通过名称这个名词。一个程序包的名称描述了其提供的内容,因此要重新诠释Peter这段话,我们可以说:每个Go程序包都应当有单独的目的。
这不是新主意,我已经说了有一阵子,但为什么应当这样做,而不是将程序包用于细分类法呢?原因在于改变。
“设计是安排代码到工作的艺术,并且永远可变。”——Sandi Metz
改变正是我们身处这盘棋的名字,作为程序员,我们要做的事情就是管理变更。做得好的时候,称之为设计或者架构,而做得差了,就称之为技术负债或者遗留代码。
如果你所编写的代码只执行一次,对于一组固定输入的内容可以完美运行的话,不会有人在意代码优劣,毕竟程序的最终输出结果才是企业所关心的。
但这种事永远不会发生。软件存在Bug、需求变更、输入变更,而很少有程序只是为单次运行而编写,因此程序总会随着时间而变更。或许是你,或许是其他人,总有人要负责更改和维护代码。
那么如何让程序变更更容易些呢?到处放置接口?到处使用模拟?还是使用有害的依赖注入?也许对于某些类别的程序,这些做法是有用的,但这样的程序不算多。对大多程序而言,预先设计一些灵活的方法比工程设计更为重要。
相反,如果我们预设要替换组件,而不是增强组件。则,要了解何时需要替换什么,最好的办法就是知道何时该组件没有完成预设功能。
一个优秀的程序包始于选择一个好名字,将程序包的名称想象成电梯推介,单用一个词就能描述其提供的内容。当名称与需求不再相符时,需要找个替代名称。
简单性非常重要
“简单优于复杂。” —— Python之禅,第三条
PEP-20中提到,简单优于复杂,我完全同意。数年前,我就发了这条推:
大多编程语言一开始都想要简单,但最终屈从于强大。—— 2014年12月2日
至少在当时,在我的观察中,从未见过一种语言不是旨在简洁的。每种新语言都为其固有的简单性提供了理由和诱因。但在我的研究中,我发现对于Go语言同时代的多种语言来说,简单并非其核心价值。也许这只是奚落,但是否这些语言要么不简单,要么并未考虑过这种可能性——它们并未考虑过将简单作为其核心价值。
就当我老派吧,但何时简单过时了?为什么商用软件的开发行业会不断愉快地忘掉这个基础事实。
“构建软件设计的方式有两种:一种是简单化,使得缺陷归零;另一种是复杂化,以至于看不出明显的缺陷。第一种方法要困难得多。—— C. A. R. Hoare,《皇帝的旧衣》,在1980年的图灵奖演讲上。
简单并不意味着容易,我们都知道。通常比起易于构建,还需要做更多事情才能使其易于使用。
“简单性是可靠性的前提。” —— Edsger W Dijkstra,EWD 498,1975年6月18日
为什么要追求简单性?为什么令Go程序简单非常重要?简单并不意味着粗糙,而是可读性和可维护性。简单并不意味着单纯,而是可靠、相关且易于理解。
“控制复杂性是计算机编程的本质。” —— Brian W. Kernighan,软件工具(1976)
Python是否遵守其简单性准则尚有争议,但Go语言则坚持将简单性作为其核心价值。我认为大家都同意这一点:就Go语言而言,简单的代码比聪明的代码更合理。
避免程序包的级别状态
“明了优于隐晦。” —— Python之禅,第二条
我认为这一条更像是Peter的希冀而非现实,Python中的很多内容并不明了,包括装饰器和特殊方法等等。毫无疑问,这些功能很强大,它们有存在的理由。每个功能都是有人足够在意,并努力实现的,尤其是复杂的功能。但大量使用这些功能会让阅读者难以预测其实现成本。
好消息是:作为Go程序员,我们是有选择的,可以选择将代码写得明了。明了可能意味着许多,也许你会认为,明了只是一种官僚和冗长的优雅表达方式,但这只是肤浅的解释。只关注页面语法,苦恼每行长度和殚精竭虑思考表达是错误的。在我看来,更有价值的地方在于:明白与耦合及状态相关。
耦合是衡量某种东西依赖其他东西的程度。如果两者紧密耦合,就会一同运动。影响前者的某个动作会直接反映在后者身上。想象一列火车,每节车厢相连——即紧密耦合:引擎所去之处,车厢随后跟着。
另一种描述耦合的方法是单词“聚合”。聚合力衡量着两者自然结合的程度。我们讨论有粘性的争论,或者有聚合力的团队时,指的是它们所有部件都像设计的一样天然聚合在一起。
为什么耦合很重要?就像火车一样,当你需要更改某段代码时,与之紧密相关的所有代码都必须更改。一个很好的例子就是,某人发布了他们API的新版,现在你所有代码都不适配了。
API是不可避免的耦合源,但有更多隐蔽的耦合形式。大家都知道,如果API的签名发生更改,则相关调用的传入和传出数据也会发生更改。就在功能的签名中:我获取这些类的值,并返回其他类的值。但如果API以其他方式传输数据会怎样?如果每次你调用API时,结果都是基于上次调用该API的结果,即便参数没有更改,又会怎样?
这就是状态,状态管理是计算机科学的问题。
假设我们有个简单的`counter`程序包,可以调用`Increment`来增加计数,甚至在Increment值为零的情况下,返回初始值。
假设必须测试代码,如何在每次测试后重置计数器?假设想要并行测试,能否做到?现在假设想要每次计算不止一个内容,可以做到吗?
不行,答案是将`count`变量封装为一种类型。
现在想象下,这个问题不限于计数器,还包括应用的主要业务逻辑。能否单独测试?能否并行测试?能否一次使用多个实例?如果答案还是否,原因在于程序包的级别状态。
避免程序包的级别状态。通过提供一种类所需要的依赖项,作为该类的字段,而不是用程序包变量,这样可以减少耦合和怪异操作。
为故障做计划
“错误不应悄悄忽略。” —— Python之禅,第10条
有人说,支持异常处理的语言遵循武士道准则,即成功返回或根本不返回。在基于异常的语言中,函数仅返回有效结果。如果未能成功,则控制流程将会采取完全不同的途径。
未经检查的异常很明显属于不安全的编程模型,在不知道哪些语句可能抛出异常的情况下,如何在存在错误的情况下编写出健壮的代码呢?Java试图通过引入“经过检查的异常”这样的概念,降低异常的不安全程度,就我所知,这种做法在其他主流语言中并未重现。有许多语言都在使用异常的概念,但除了Java这个个例之外,全都使用未经检查的异常。
Go语言选择了不一样的方式。Go编程者认为,健壮的程序是由处理故障的案例构成。在Go语言相关的设计空间中,服务器程序、多线程程序、网络输入处理、意外数据处理、超时、连接失败及数据损坏,对于想要创建健壮程序的程序员来说,这些是他们的首要也是核心任务。
“我认为,错误处理应当是明确的,是语言的核心价值。” —— Peter Bourgon, GoTime #91
我想赞同Peter的说法,Go语言的成功在很大程度上要归功于处理错误的明确方式。Go程序员认为,要先考虑故障案例。我们首先解决“如果……会怎样”的案例。这样我们会在编写代码时优先处理故障,而不是在生产环境中处理故障。
与代码的冗长相比,在故障出现时,刻意处理每个故障情况的价值更高。关键在于明确处理各个错误的文化价值。
尽早返回,避免深层嵌套
“扁平优于嵌套。” —— Python之禅,第五条
这是个明智的建议,来自以缩进为主要控制流形式的某个语言。我们如何针对Go语言来解释该建议?`gofmt`控制着Go程序的整体空白,因此无需执行任何操作。
我曾提过程序包名称,这里有些建议可以避免复杂的程序包层次。根据我的经验,程序员越是尝试细分和分类其Go代码库,越有可能陷入程序包输入循环的死角。
Python之禅第五条建议的最佳应用就是关于功能里的控制流。简而言之,就是避免需要深层缩进的控制流。
“视线是顺着观察者畅通视角的一条直线。” —— May Ryer, Code: Align the happy path to the left edge
May Ryer将这个想法描述为视线编程,即:
如果不满足前提条件,则通过保护语句尽早返回。
将成功的返回语句放在函数的末尾,而非放在条件块中。
通过提取函数及方法,降低其整体缩进级别。
该建议的关键在于你所关心的内容,即函数完成的功能永远不会超出屏幕,承担滑出你视线的风险。这种风格还有一个附加作用,就是你的团队无需对代码行长产生无意义的争论了。
每次缩进时,都会向程序员堆栈添加另一个先决条件,从而消耗其7 ±2个短期记忆插槽之一。避免深层嵌套,将函数保持在屏幕左端的位置就是成功的办法。
如果觉得慢,请使用基准测试来验证
“面对不确定性,拒绝妄加猜测。” —— Python之禅,第12条
编程是基于数学和逻辑的,这两个概念极少涉及“机会”这个要素。但作为程序员,我们每天都会有很多猜测。这个变量是做什么的,这个参数是做什么的,如果我在这里传回nil会怎样,如果我调用两次Register会如何?事实上,现代编程中存在很多猜测,尤其是使用未编写过的库时。
“API应当易于使用,并且不易于被滥用。” —— Josh Bloch
根据我的了解,帮助程序员避免猜测的最佳方式之一就是,在构建API时专注于默认用例。使调用者尽可能轻松地执行最常用的案例。不过,我曾写过也谈起过许多API设计的内容,因此我对于第12条的解释便是:不要猜测性能。
无论对Knuth的建议你有什么看法,但Go语言成功的驱动力之一便是其高效的执行力。你可以用Go语言编写高效程序,人们也因此选择Go。关于性能误解很多,我的请求是:当你寻求代码性能优化时,或者面临一些教条式的建议——比如defer很慢、CGO很贵、用原子别用互斥锁时,不要猜。
不要因为过时的教条让你的代码复杂化,而且如果你觉得慢,请先用基准测试验证。Go语言提供了出色的基准测试和性能分析工具,都可以免费获得,用它们来确认自己的瓶颈。
在运行goroutine之前,了解停止机制
谈到这,我认为已经挖掘出了PEP-20中珍贵的点,而且很可能将其解释扩展至其上。这很好,因为尽管这也是一种有用的修辞手法,但最终我们讨论的还是两种不同的语言。
“你输入g o,中间一个空格,然后一个函数调用,三次点击,不能更短了,三击就可以开启一个子过程。” —— Rob Pike, Simplicity is Complicated, dotGo 2015
接下来的两个建议,我将专门针对goroutines,Goroutines是语言的签名功能,也是我们对于一流并发性的答案。它们很易于使用,只需在语句前加一个单词,即可异步启动该功能。非常简单,无需线程,没有堆栈大小,没有线程池执行程序,没有ID和完成状态追踪。
Goroutine成本很低,由于运行时可以将其复用在一个小的线程池中(无需管理),因此可以轻松容纳数十万乃至数百万个goroutine,这就开启了在竞争性并发模型(如线程或事件回调)下并不可行的设计。
但尽管如此,goroutine并非零成本,在启动10^6个goroutine时,至少会有几千字节的堆栈开始累加。这并不代表不应使用数百万个goroutine,只要设计有需求还是应当这样做的,但进行追踪非常关键,因为10^6的数量级,无论是什么都会消耗巨量的资源。
Goroutine是Go语言中资源所有权的关键,要想有用,Goroutine必须做些事情,这意味着它几乎持续引用资源或者占有其所有权,包括锁和网络连接、带有数据的缓冲区、通道发送端。在goroutine处于活动状态时,锁是闭合的,网络连接保持打开,缓冲区保留,且通道的接收端也会持续等待更多数据。
释放这些资源的最简途径便是将其与goroutine的生存周期关联。goroutine退出时,释放资源。因此,在编写那三个字母(g o和空格)前,开启goroutine几乎微不足道,但要确保能够回答以下问题:
何种情况下goroutine会停止?Go语言中不存在一种方式告知gorouinte退出,即不存在stop或kill功能,这是有理由的。如果我们无法命令goroutine停止,则需换个方式,礼貌地要求它。最终方式几乎总是归结于通道操作,当通道关闭时,通道中的范围循环会退出。一个通道会在关闭时成为可选项。从一个goroutine到另一个的信号,最佳表达方式就是关闭通道。
发生这种情况需要什么条件?如果通道既是goroutine之间的通讯途径,又是其完成的信号机制,程序员面临的下一个问题就是,谁来关闭通道,何时关闭?
使用什么信号来获知goroutine已经停止?当发出停止goroutine的信号时,相对于goroutine的引用框架来说,停止会在未来某个时刻发生。就人类的感知而言,可能很快,但计算机每秒执行数十亿条指令,并且从各个goroutine的角度来看,其指令执行是不同步的。解决方案通常是通道返回信号或者需要fan in的等待组。
将并发留给调用者
在编写任何严肃的Go程序时都很可能要涉及并发问题。这就触发了一个问题,我们编写的许多库和代码都属于每个连接或worker模式使用单个goroutine的形式。你会如何管理这些goroutine的生命周期?
`net/http`是一个很好的例子,关闭拥有监听socket的服务器相对来讲是直接了当的,但这个接受socket所产生的goroutine又当如何呢?`net/http`确实在请求对象中提供了`context`对象,可用于向正在监听的代码发送信号,告知取消请求,从而停止goroutine,但关于这些工作何时完毕还不太清楚。调用`context.Cancel`是一回事,了解取消已经完成是另一回事。
关于`net/http`,我要说的一点是,它是良好实践的反例。由于每个连接都是由`net/http.Server`类型内部所产生的goroutine处理的,驻留在`net/http`程序包之外的程序就无法控制接收socket所产生的goroutine了。
这是一个还在发展的设计领域,需要类似go-kit的`run.Group`,以及Go团队的 `ErrGroup`提供的执行、取消、等待等功能异步运行。
更大的设计准则是针对库编写者的,或是任何编写异步运行代码者,将开启goroutine的责任留给你的调用者吧,让他们自行选择想要如何开启、追踪、等待函数执行。
编写测试锁定程序包API的行为
测试是关于你的软件要做什么、不做什么的合约,程序包级别的单元测试应当锁定程序包API的行为,它们在代码中描述了程序包承诺要完成的功能,如果每个输入的排列都有一个单元测试,你会在代码中而,不是文档中定义代码要完成的功能。
简单输入`go test`就可以声明此合约,任何阶段你都能高度确定人们在变更前所依赖的行为,在变更后依然有效。
测试会锁定API行为,任何添加、修改或移除公共API的更改都必须包括对其测试的更改。
适度是一种美德
Go语言是一种简单的语言,只有25个关键字。在某些方面,会使得该语言的内置功能脱颖而出。同样,这些也是该语言的卖点:轻量级并发以及结构化类型。
我认为,我们大家都经历过立即上手尝试所有Go语言的功能而带来的困惑。谁会对通道如此热衷,以至于要尽可能使用它们?个人而言,我发现结果难以测试,脆弱且最终过于复杂,难道只有我这么想吗?
我在goroutine上也有过类似经历,在试图将工作分解成很小的单元时,我创建了一堆难以管理的goroutine,并且最终发现它们大多总是block的,等着依次处理——代码最终是顺序的。我给代码增加了很多复杂性,却几乎没有给实际工作带来任何好处。有人有类似经历吗?
在嵌入方面我也有类似经验,最初我误将其理解为继承,之后通过将已经承担多个职责的复杂类型组合成更复杂的巨大类型,来重建脆弱的基类问题。
这可能是最不可行的建议,但我认为这很重要。建议总是一样,一切都要适度,Go的功能也不例外。如果可以,不要追求goroutine或者通道,或者嵌套结构、匿名函数,或者过渡程序包,给所有安上接口等,相反简单的方法比聪明的方法更有效。
可维护性很重要
我想谈谈PEP-20的这条:
“可读性很重要。” —— Python之禅,第七条
关于可读性的重要性不仅在Go语言中,而是在所有编程语言中都有很多讨论。像我这样站在台上倡导Go语言都会使用简单、可读性、清晰度和生产力等词,但最终它们一个意思——“可维护性”。
我们真正的目标是编写可维护的代码,即在最初作者完成之后还能持续的代码。代码不止在投入的节点存在,而是作为未来价值的基础。并不是说可读性不重要,而是说可维护性更重要。
Go语言不是为聪明人优化的,也不是为程序行数最少而优化的。我们没有针对磁盘上的源代码进行优化,也没有针对程序键入编辑器的时间优化。相对,我们优化的方向是让代码可读性增强,因为代码阅读者才是维护代码的人。
如果是给自己编写程序,也许只用运行一次,或者你是程序的唯一读者,也是程序唯一服务的人。但如果该软件是超过一个人贡献的程序,或者人们要持续使用很久的程序,则其需求、功能或者运行环境都可能发生变化,则你的目标必须是程序具有可维护性。如果无法维护,只能重写,那么这可能是你的公司最后一次将资源投给Go语言。
离开公司后,你之前努力构建的工作可以维护吗?如何做才能让其他人之后更容易维护你所编写的代码?
原文链接:https://dave.cheney.net/2020/02/23/the-zen-of-go
本文整理自笔者的GopherCon Israel 2020演讲。
本文为 CSDN 翻译,转载请注明来源出处。