本文是对 《100 Go Mistackes:How to Avoid Them》 一书的翻译。因翻译水平有限,难免存在翻译准确性问题,敬请谅解
本章涵盖内容:
随着时代和编程技术的发展,计算机系统由过去的一个人编写转变到现在的多人、多团队协作完成。那么就要求我们的代码必须具备可读性、表达性以及可维护性,以保证系统的持续发展。同时,在当今快速发展的世界中,最大限度的提高敏捷性并缩短上市的时间对大多数组织来说至关重要。编程语言也应顺应这种趋势,以确保软件工程师在阅读、编写和维护代码时尽可能高效。
为了应对这些挑战,谷歌在2007年构思了Go编程语言。Go成功的一个关键因素是因为它是一门简单的编程语言。一个新手可以在不到一天的时间内就能学习该语言的所有主要功能。然而,简单易学并不一定意味着容易掌握。
本书意在帮助研发人员最有效的使用Go编程语言。那为什么要读一本关于Go常见错误的书呢?神经科学家证明当我们面对错误时,正是大脑成长的最佳时期。这一特点是因为错误具有促进作用。其主要思想是我们不仅能记住错误,而且还能记住错误发生的场景。这就是为什么从错误中学习是如此有效的原因之一。
遵循这些原则,本书将包含开发人员在该语言的关键领域所犯的100个常见错误。同时,为了加强我们提到的促进作用,每个错误都会尽可能的由真实世界发生的例子。该书不仅是关于理论的。它的主要目标是帮助你走上精通Go的道路。
让我们重新思考是什么让Go成为一种在现代系统中如此流行和高效的语言。
在特性方面,Go没有类型继承,没有异常,没有宏,没有偏函数,不支持惰性求值或不变性,没有运算法重载,没有模式匹配,没有隐式类型转换等。
这些特性为什么在Go语言中不支持呢?官方Go FAQ给我们提供了见解:为什么Go没有特征X?你最喜欢的功能可能会丢失,是因为它不合适,因为>它影响了编译速度或设计的清晰度,或者因为它会使基础系统模型变得太困> 难。 --- Go FAQ
因此,一门编程语言的特性数量不应该成为我们关注的主要方面。至少,这不是Go语言所提倡的。
类型继承就是一个非常好的例子。类型继承的问题在于它在大型代码库中代码的路径会更复杂,以及难以理解。事实上,如果大多数交互都基于继承,那么开发人员维护的心智模型会很快变的复杂。长期以来,人们一直建议程序员应该更喜欢组合而非继承。因此,继承没有被包含在Go语言中。这是众多例子中的一个,在这些例子中,Go设计者有意地倾向于语言的其他方面,而不是添加尽可能多的特性。
另一个例子是关于数据结构的。Go只有三种标准的数据结构类型:
因此,没有链表、没有二叉树、没有二叉堆等。这种标准数据结构的缺乏可能让新手感觉有些惊讶。然而,这是Go设计者有意做出的。例如,大多数情况,切片是CPU访问动态元素列表的最有效的方式。它涉及可预测访问模式并依赖数据局部性,这使得它在大多数请情况下比链表更有效。这三种基本类型可以处理我们使用Go开发时遇到的大多数场景。
稳定性也是Go的一个基本特征。尽管Go收到频繁的更新(性能改进,安全补丁等),但在过去的很多年Go仍然是一门非常稳定的语言。稳定性是在组织规模上采用一种语言的一个重要方面。人们甚至认为它是语言的最佳特征。
总而言之,Go不是包含最多特定的语言。然而,一切都是为了考虑编程语言的所有方面,并为开发人员提供尽可能好的平衡。
今天,我们比以往任何时候都更快的构建,测试和部署。软件编程必须顺应这一趋势。Go被认为是开发人员最具有生产力的语言。让我们看看为什么这么说。
首先,我们提到的是Go是一种简洁的语言:它只有25个关键字。如果与其他语言相比,Java和Rust有50多个,C++有100多个,等等。
例如,由于错误管理(errors处理),人们可以能会争论Go应用程序是否是简洁的。然而,一般来说,Go的简洁性体现在对于新手来说Go的学习曲线很浅。在Go中,开发人员可以通过注入tour.golang.org之类的资源来快速学习Go。
我们可以强调Go是富有表现力的。在编程语言中的表现力意味着我们可以自然的和直观的编写和阅读代码。正如Robert C.Martin在《整洁代码》一书中所写的那样,阅读与写作所花费的时间比远远超过10:1。因此,使用富有表现力的语言工作是至关重要的,尤其是在大型组织中。此外,与其他语言相比,解决常见问题的方法数量减少也使得大型Go代码库通常更容易处理。
开发人员生产力的另一个重要方面是编译时间。例如,作为开发人员,还有什么比必须等待构建完成才能执行单元测试更令人烦恼的吗?
以快速编译为目标一直是Go设计者有意而为的。首先,Go的设计目的是为软件构建提供一个模型,简化依赖性分析,避免C风格的include文件和库的大量开销。因此,为开发人员编译节省了大量时间。
总之,Go被认为是一种高效的语言,主要有三个原因:简洁性,表达性和高效性。然而,正如您想象的那样,生产力并不是语言中唯一需要考虑的方面。让我们看看使Go语言如此流行的其他方面。
Go是一门静态类型的语言。因此,类型检查是在编译阶段而非运行时进行的。这样就保证了我们编写的代码在大多数情况下是类型安全的。
此外,Go具有垃圾收集器来帮助开发者处理内存管理。直接管理内存不是开发人员的责任。垃圾回收器负责跟踪内存分配并在不需要的时候释放内存。但在执行期间也增加了一点开销。出于这个原因,Go不打算用于实时应用程序,因为通常不可能对执行时间做出严格的保证。然而,这是一种假设平衡,因为它显著减少了开发工作并降低了应用程序崩溃或内存泄露的风险。
对于开发人员来说,另一个令人害怕的方面是指针。指针是一个包含另一个变量地址的变量。指针是Go语言的一个核心方面。然而,Go中的指针处理起来并不复杂,因为他们是显式的(与引用不同),并且没有指针运算之类的东西。这是什么原因呢?再次是为了降低编写不安全应用程序的风险。
由于这些特性,Go是一种非常安全的语言,这对Go应用程序的总体可靠性产生了积极的影响。
2005年,注明的C++专家Herb Sutter写了一篇名为 免费的午餐结束了 的博客文章。他提到,在过去的30年里,CPU设计者主要在三个领域取得了显著的进步:
多年来,通过改进这三个领域导致顺序应用程序的性能(非并行,单线程,单进程)的改进。然而,根据Herb Sutter的说法,现在是时候停止期望CPU持续变得更快了。这一假设在过去几年得到了验证。如图1.1所示,从2004年左右开始,单线程执行的速度提升不再是线性的。更糟糕的是,它已经趋于达到上限。
Herb Sutter接着提到现在是改变我们开发应用程序方式的正确时机。同时,CPU设计人员不再只关注时钟速度和优化。相反,他们开始考虑其他方法,例如多核和吵线程(同一物理核上的多个逻辑核)。并发性将成为软件开发人员的下一个重大革命,而不是编写顺序应用程序并期望CPU总是变的更快。
Go编程语言在设计时就考虑到了并发性。它的并发模型基于通信顺序进程(CSP)。我们将在下一节查看此模型。
CSP模型是一种依赖于消息传递的并发范式。进程不必共享内存,而是通过通道交换消息来进行通信。如图1.2所示:
在图1.2中,我们可以看到基于CSP的两个进程之间的交互。每一个进程都是顺序执行的。没有回调使整个交互更加复杂。第一个进程发送一个消息A,同时在某个时刻等待响应。第二个进程等待消息A,执行一些工作,并作为响应,发送一个消息B。
通过内存共享促进消息传递的基本原理是什么呢?
今天,所有的CPU都有不同级别的缓存来加速对主内存(RAM)的访问。跨不同线程共享的变量可能会重复多次。因此,共享内存是现代CPU提供的一种错觉(我们将在并发章节深入研究这些概念)。
采用消息传递符合现代cpu的构建方式,这在大多数情况下对性能有重大影响。此外,她使复杂的交互更容易推理。我们不必处理复杂的回调链:一切都是按顺序编写的。
Go使用两个原语实现了CSP模型:goroutine和channel。
goroutine可以被看成是一个轻量级的线程。与操作系统调度的线程不同,goroutines是由Go运行时调度的。一个goroutine同一时间只属于一个线程,同时,一个线程能处理多个goroutines,如图1.3所示:
操作系统负责在CPU内核上调度线程。同时,Go运行时根据工作负载确定最合适的Go线程数量,并在这些线程上调度goroutine。与线程相比,创建goroutine的成本在启动时间和内存(只有2KB的栈大小)方面更便宜。从一个goroutine到另一个goroutine的上下文切换操作也比线程的上下文切换更快。因此,看到应用程序同时创建数百个甚至数千个goroutine的情况并不少见。
另一方面,channel是一种允许在不同goroutine之间交换数据的数据结构。发送到channel的每一条消息最多由一个goroutine接收。唯一的广播操作(1对N)是一个channel闭包,它传播被多个goroutine接收的事件。
将这些原语座位核心语言的一部分是一个了不起的特性。无需依赖任何外部库。开发人员可以以整洁的、富有表现力和标准的方式编写并发代码。当然,我们仍然可以使用互斥锁的方式来共享内存。然而,在大多数情况下,我们应该支持消息传递的方法,主要是因为,正如所讨论的,这种方法利用了现代CPU的构建方式。
消息传递是一种强大的并发方法,但它不能防止数据竞争。幸运的是,Go提供了一个强大工具来检测数据竞争。
我们通过计的多个方面展示了Go是强大的和简单易学的。那么,你又为什么要阅读一本关于Go的书来扩展你的知识呢?
简单和容易之间存在者细微的差别。简单地应用一项技术意味着学习或理解起来并不复杂。然而,容易意味着我们可以毫不费力的实现一切。Go则简单易学,但难于精通。
我们以并发为例。2019年,发表了一项针对并发错误的研究:Understanding Real-World Concurrency Bugs in Go。这项研究是对并发错误的首次系统分析,重点关注六个流行的Go代码仓库:Docker,Kubernetes,etcd,CockroachDB,BoltDB和gRPC。
这项研究中有许多有趣的结论。在所有这些仓库中,作者表明,尽管在Go中达到了培养,但传递消息的放阿飞的使用频率低于共享内存方法。该研究还强调,尽管人们认为传递消息的方法更容易处理切不易出错,但大多数阻塞错误都是由传递消息的不准确使用引起的。
关于这项研究,我们将得出什么结论?我们是否应该害怕在我们的应用程序中使用消息传递方法呢?当然不是。首先,共享内存和传递消息两种范式可以共存。这也意味着,我们,Go开发人员,需要取得一些进展并彻底理解消息传递方法的含义,以避免重复最常见的并发错误。然而,这也意味着消息传递虽然在理论上易于学习和使用,但在实践中并不容易掌握。
这个观点-- 简单不代表容易 可以推广到Go的很多方面,不仅是并发;例如:
要成为一名熟练的Go开发者,我们应该对该语言的许多方面都要有透彻的了解,这需要大量的时间,精力和错误。本书疑意在通过收集和展示Go语言各个方面的100个常见错误来帮助你成为一名熟练的开发人员:基础知识、代码组织、数据和控制结构、字符串、函数和方法、错误管理、并发、测试、优化和生产。