坚持还是放弃,Go 语言的“美好与丑陋”解读

Posted OSC开源社区

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了坚持还是放弃,Go 语言的“美好与丑陋”解读相关的知识,希望对你有一定的参考价值。


协作翻译

原文:Go: the Good, the Bad and the Ugly

链接:https://bluxte.net/musings/2018/04/10/go-good-bad-ugly/

译者:Tocy, 琪花亿草, 凉凉_, kevinlinkai, Tot_ziens, 边城


作者语:我对 Go 又爱又恨。 Go 有点像一个朋友,你喜欢和他在一起,因为他很有趣,但是当你想要进行更深入的对话时,你会觉得无聊或痛苦,而且你不想与他去一起度假。


Go 确实有一些不错的特性,也就是本文中提到的“好”的部分,但是当我们不将它用于 API 或者网络服务器(这是为它设计的),而是将它用于业务领域逻辑的时候,它看起来比较糟糕。即使是用于网络编程,它在设计和实现方面也有很多缺陷。


最近我开始用 Go 做一个“兼职”项目,因此我用这篇文章总结一下我使用 Go 的体验。我希望这个博客已经让你了解到了一些关于 Go 的你曾经没意识到的问题,这样你就可以避免陷阱而不会被陷进去!


坚持还是放弃,Go 语言的“美好与丑陋”解读


背景


我喜欢静态类型语言。我的第一个重要项目是用 Pascal 编写的。在 90 年代初我开始工作之时,我使用了 Ada 和 C/C ++,后来我迁移到了 Java,最后又使用了 Scala(在期间还用过 Go),最近开始学习 Rust。我还写了大量的 javascript 代码,因为直到最近它是 Web 浏览器中唯一可用的语言。对动态类型语言我感觉不牢靠,并尝试将其应用限制在简单脚本中。我对命令式、函数式和面向对象的方法感到很满意。


我通过三个方面介绍我使用 Go 的体验:好(the Good)、坏(the Bad)、丑(the Ugly)


本文目录


好的方面


  • Go 易于学习

  • 使用协程(goroutines)和通道(channels)简单的并发编程

  • 强大的标准库

  • GO 的高性能

  • 语言所定义的源代码格式

  • 标准化的测试框架

  • Go程序非常适合运维

  • Defer语句,用于避免遗忘清理

  • 自定义类型


不好的方面


  • GO 忽略现代语言设计的进步

  • 接口是结构类型

  • 没有枚举类型

  • := / var 的尴尬

  • 零值恐慌

  • Go 中没有异常


丑的方面


  • 依赖关系管理的噩梦

  • 可变性在语言中是硬编码的

  • Slice 陷阱

  • Mutability 和 channels:更容易产生竞争条件

  • 混乱的错误管理

  • Nil 接口值

  • 结构字段标签:运行时字符串中的 DSL

  • 没有泛型......至少不适合你

  • Go 除了分片和映射之外几乎没有数据结构

  • go generate:还行,但是...


结语


好的方面


Go 易于学习


这是事实:如果你会任何一种编程语言,你可以通过“Go 教程”在几个小时之内学会 Go 的大部分语法,在几天之内就可以写出你的第一个程序。阅读和消化 Effective Go,徘徊在标准库中,运用 web 工具包如 Gorilla 或者 Go kit,你就能成为一个相当不错的 Go 开发者。


这是因为 Go 的首要目标就是简单。当我开始学习 Go 的时候,它让我回忆起了我初次接触 Java:一个丰富却不臃肿的简单语言。与现在 Java 繁重的环境对比,学习 Go 是一个新鲜的体验。由于 Go 的简单,Go 程序是非常易读的,即使错误处理方面有不少麻烦。


但是这可能并不是真的简单。引用 Rob Pike 的话,简单即复杂,我们在下面可以看到在后面有很多的陷阱等着我们,简洁和极简主义阻止了我们编写 DRY 原则的代码。


使用协程(goroutines)和通道(channels)简单的并发编程


Goroutines 可能是 Go 最好的特性。与操作系统线程不同,这是轻量级的计算线程。

当一个 Go 程序执行阻塞 I/O 操作一类的工作时,实际上 Go 实时挂起了这个 goroutine,而且在一个 event 表明一些结果已经可以访问之后,会重新运行。在此期间,其他 goroutines 已经在执行调度。因此我们在使用一个同步编程模型做异步编程的时候有可扩展性的优点。


Goroutines 也是轻量级的:他们的栈按需增加或减少,也就是说有数百个甚至数千个 goroutines 都不是问题。


在一个应用中我曾经有一个 goroutine 泄露:在结束之前这些 goroutine 等待一个 channel 去关闭,但那个 channel 不会关闭(一个常见的死锁问题)。这个进程平白占了 90% 的 CPU,查看 expvars 发现 60 万个空的 goroutine!我猜 CPU 都被 goroutine 调度占用了。


当然,一个像 Akka 的 actor 系统可以不费力气就处理数百万 actors,一部分是因为 actors 没有栈,但是他们在写复杂并发 request/response 应用(如 http APIs)时不如 goroutine 简单。


Channel 是 goroutine 之间交互的通道:他们提供了一个方便的编程模型可以在 goroutine 之间发送和接收数据,而不用依赖脆弱的底层同步原语。Channel 拥有他们自己的一套使用模式


由于错误的 channel 数量(他们默认无缓冲)会导致死锁,Channel 必须要慎重考虑。我们在下面也会提到因为 Go 缺少不变性,使用 channel 并不能阻止争抢资源。


强大的标准库


Go 标准库真的很强大,特别是对网络协议相关的所有东西或者 API 开发:http 客户端和服务器,加密,压缩格式,压缩,发送邮件等等。甚至还有 html 解析器和相当强大的模板引擎,通过自动 escaping 可以用来产生文字 text & html 来避免 XSS(在 Hugo 模板的示例中使用)。


大多数情况下 API 通常是简单易懂的:这当中,一部分是由于 goroutine 编程模式告诉我们只需要关心“看似同步”的操作。另一部分是因为少数通用的多功能函数能替代大量单一功能的函数,就像最近一段时间,我发现的那些用于时间计算的函数一样。


GO 的高性能 


Go 编译成一个本地可执行文件。许多 Go 的用户来自于 Python,Ruby 或者 Node.js。对他们来说,这是个令人兴奋的体验,因为他们发现服务器可以处理的并发请求数量大幅的增加。对于没有并发的语言(Node.js)或者全局解释器锁(GIL)来说,这实际上是再正常不过的事情。结合语言的简单性,这说明了 Go 语言令人兴奋的一面。


然而相比 Java,在原始性能基准测试中,情况并不是那么清晰。在内存使用和垃圾收集方面 Go 力压 Java。


Go 的垃圾收集的设计目的是优先考虑延迟和避免 stop-the-world 停顿,这在服务器中尤其重要。这可能会带来更高的 CPU 成本,但是在水平可伸缩的架构(horizontally scalable architecture)中通过添加更多的机器这是易于解决的。记住,Go 是 Google 设计的,他们不缺资源。


相比于 Java,Go 的 GC 要做的工作更少:slice 的结构是一个连续的结构数组,而不是像 Java 这样的指针数组。相似地,Go 的 maps 出于同样的目的使用像桶的小数组。这意味着在 GC 上工作量更少,并且还更有利于 CPU 的缓存位置。


在命令行实用程序方面,Go 也可以力压 Java:一个本地可执行的,相对 Java 首先必须加载和编译字节码来说,Go 程序几乎没有启动成本。


语言所定义的源代码格式


在我职业生涯中一些最激烈的争论发生在团队代码格式的定义上。Go 通过为 Go 代码定义规范格式解决了这个问题。gofmt 工具会重新格式化你的代码,并且没有选项。

不管喜不喜欢,gofmt 都定义了 Go 代码应该如何格式化,因此该问题得到一次性解决!


标准化的测试框架


Go 在其标准库中提供了一个很好的测试框架。它支持并行测试、基准测试,并且包含很多用于轻松测试网络客户端和服务器的使用程序。


Go 程序非常适合运维


与 Python、Ruby 或 Node.js 相比,仅安装单个可执行文件对于运维工程师来说是一个梦想。随着越来越多的 Docker 投入使用,这个问题出现的越来越少,但独立的可执行文件也意味着更小的 Docker 镜像。

Go 还具有一些内置的可观察性功能,使用 
expvar 包发布内部状态和指标,并且可以轻松添加新内容。但要小心,因为它们在默认的 http 请求处理程序中自动暴露,变得不受保护。Java 中 JMX 有类似的功能,但它更复杂。


Defer 语句,用于避免遗忘清理 


defer 语句的作用类似于 Java 中的 finally:在当前函数结束时执行一些清理代码,并不管此函数是如何退出的。有关 defer 的有趣的事情是它没有链接到一段代码上,并可以随时出现。这允许清理代码尽可能靠近创建那些需要清理资源的代码:


坚持还是放弃,Go 语言的“美好与丑陋”解读


当然,Java 的“try-with-resource”不是那么冗长,同时 Rust 在资源的所有者被删除时会自动声明资源,但由于 Go 要求你对资源清理明确了解,因此用它靠近资源分配是很不错的。


自定义类型


我喜欢自定义类型,而且我恼怒/害怕一些情况,就好像当我们来回传一个字符串型或者 long 型的持久化对象标识符的时候。我们经常对参数名为 id 的类型编码,但是这就是一些产生小 bug 的原因,即当一个函数有多个标识符作为参数的时候,一些调用就会弄混参数顺序。


Go 的自定义类型支持 first-class,例如那些分配给一个已有类型的独立的标识符的类型,可以与原来的标识符区分开来。与封装相反,自定义类型没有运行时开销。这使得编译器能捕获这种错误:


坚持还是放弃,Go 语言的“美好与丑陋”解读


不幸的是,对那些要求自定义类型与原始类型做转换的人来说,由于不支持泛型,自定义类型在写复用代码的时候用起来比较累赘。


不好的方面


GO 忽略现代语言设计的进步


大道至简(Less is exponentially more)的演讲上,Rob Pike 解释说 Go 是要取代 C 和 C++ 的,它的前身是 Newsqueak,这是他在 80 年代写的一种语言。Go 也有很多关于 Plan9 的参考,这是一个分布式操作系统,80 年代在贝尔实验室开发的。


甚至有一个 Go 组件直接从 Plan9 获得灵感。为什么不使用 LLVM 来提供范围广泛的目标体系结构呢?我可能也在这里漏掉了什么,但为什么需要呢?如果你需要编写程序集以充分利用 CPU,那么你不是应该直接使用目标 CPU 汇编语言吗?


Go 的设计者很值得尊敬,但是他们就像在一个平行宇宙(或者他们的 Plan9 实验室)设计的 Go,在那里大多数编译器和编程语言的设计是在 90 年代, 但在 21 世纪是没有的。或者,是那些能写编译器的系统编程人员设计了 Go。


函数式编程就不提了。泛型你们应该也用不着,看它们在 C++ 里产生的混乱就知道了!


Go 的目标就是代替 C 和 C++,但是很明显它的设计者没有多看看其他语言。他们避开了他们的目标,Google 的 C 和 C++ 开发者不采用它。我猜主要原因就是垃圾回收。低级 C 开发者十分抗拒管理内存,因为他们不了解管理什么,在什么时候管理。他们喜欢这种控制,即使会带来额外的复杂,而且打开内存泄露和 buffer 溢出的大门。有趣的是,Rust 在不使用 GC 的情况下使用另一种方法做自动内存管理。


相反的,在操作工具方面 Go 吸引了那些像使用 Python 和 Ruby 等脚本语言的人。他们在 Go 中发现一个方法,有很好的性能,而且减少了内存/CPU/硬盘的占用空间。而且也是更 static 的类型,这对他们来说是新颖的。对 Go 来说 Docker 是杀手级应用,这使得它在开发界开始被广泛使用。Kubernetes 的提出加强了这个趋势。


Interfaces 是结构化类型(structural types


Go 的 interfaces 就像 Java 的 interface 或者 Scala 和 Rust 的 trait:他们定义行为,之后才会被一个 type(我在这不把他们叫做“class”)实现。


不像 Java 的 interface 和 Scala 和 Rust 的 trait,一个 type 不需要明确定义它实现了一个 interface:它必须要实现所有定义在 interface 中的函数。因此 Go 的 interfaces 的确是 structural types


我们也许认为 Go 允许在其他的 package 中实现 interface,而不仅仅是在 type 所在的 package 中申请,就像 Scala、Kotlin 的类扩展和 Rust 的 trait 一样。但事实并非如此:与 type 相关的所有方法都必须在这个 type 的 package 中定义。

Go 并不是唯一使用 structural typin g的语言,但我发现它存在几个缺点:


  • 寻找有哪些 type 实现了 interface 是困难的,因为它依赖于函数定义匹配。在 Java 或 Scala 中,我经常通过搜索实现了 interface 的类来寻找相关的实现。

  • 当给 interface 添加一个方法时,你将会发现只有当那些 types 被用作 interface type 的值时,type 才会被更新。很长一段时间内你会忽视这种问题。Go 建议尽少使用方法来创建 interface,以此来防止该问题的发生。

  • 因为 type 中有一个方法与 interface 相同,这个 type 可能会无意中实现了一个 interface。但是偶然的情况下,它所实现的功能可能与预想的 interface 协议不同。


更新:interface 的一些丑陋的地方,请详看后面的“interface 空值”章节。


没有枚举类型


Go 中没有枚举值,我觉得这是它的损失。


iota 可以快速生成自增的数值,但它看起来更像是一种修改而非特性。而实际上,由于在一系列 iota 所生成的常量中插入一行会改变其后面的值是一个危险的操作。由于所生成的值是在整个代码中使用的,因此这可能会触发意外。


这也意味着在 Go 中没有办法让编译器检查 switch 语句是否详尽,并且无法描述给定类型所支持的值。


 := / var 的尴尬


Go 提供了两种方法声明和分配给变量一个值:var x = "foo" 和 x := "foo”,为什么这样?


主要区别是:var 允许声明而不初始化(那你就必须声明类型),就像 var x string,然而 := 要求分配一个值,而且这种方法可以同样用于已有变量和新变量。我猜发明 := 就是用来让我们在捕获错误的时候不那么痛苦:


使用 var:


坚持还是放弃,Go 语言的“美好与丑陋”解读


使用 := :


坚持还是放弃,Go 语言的“美好与丑陋”解读


:= 语法也容易在不经意中对一个变量重新赋值。我曾经不止一次遇到这个问题,就像 :=(声明和分配)与=(分配)太像了,就像下面这样:


坚持还是放弃,Go 语言的“美好与丑陋”解读


零值恐慌


Go 里没有构造函数。因此,它奉行“零值”应该可以随时使用。这是一个有趣的方法,但在我看来,它所带来的简化化主要是针对语言实现者的。

在实践中,如果没有正确的初始化,许多类型都不能做有用的事情。让我们来看一下在 
Effective Go 中作为示例的 io.Fileobject:


坚持还是放弃,Go 语言的“美好与丑陋”解读


我们在这里能看到什么呢?


  • 在零值文件上调用 Name() 将会出现问题,因为它的 file 字段为 nil。

  • Read 函数和 File 几乎所有其他的方法都一样,首先检查文件是否已初始化。


所以基本上零值文件不仅没用,而且会导致问题。你必须使用以下构造函数中的一个:如“Open”或“Create”。检查是否正确的初始化是每次函数调用都必须承受的开销。


标准库中有无数类似这样的类型,有些甚至不试图使用它们的零值做一些有用的事情。在零值的 html.Template 上调用任何方法:它们都引起问题。


同时 map 的零值有个严重的缺陷:它可以查询,但在 map 中存储任何数据都有导致 panic 异常:


坚持还是放弃,Go 语言的“美好与丑陋”解读


当结构具有 map 字段时,就要当心了,因为在向其添加条目之前必须对其进行初始化。


因此,身为一名开发人员,你必须经常检查你要使用的结构体是否需要调用构造函数或者零值是否有用。为了一些语言上的简化,这将给代码编写者带来很大的负担。


Go 的异常 


博客文章“为何 Go 处理异常是正确的”中详细解释了为什么异常是很糟糕的,以及为什么 Go 中的方法需要返回错误是更好的作法。我同意这一点,并且在使用异步编程或像 Java 流这样的函数式风格时,异常是很难处理的(让我们暂且将之抛之脑后,因为前者在 Go 中是不需要的,这要归功于 goroutine;而后者根本不可能)。该博文中提到 panic 是“对你的程序总是致命的,游戏结束”,这是对的。


现在,“Defer, panic 和 recove”在这之前,解释了如何从 panic 中恢复过来(实际上通过捕获它们),并说:“对于一个真实世界的 panic 和恢复示例,请参阅 Go 标准库中的 json 包。


事实上,json 解码器有一个会触发 panic 的通用的错误处理函数,在最顶层的 unmarshal l函数中可恢复该 panic,该函数将检查 panic 类型,并在其是“local panic”时将其作为错误返回,或重新触发 panic 错误(在此丢失了原来的 panic 堆栈跟踪信息)。


对于任一 Java 开发人员来说,这看起来像try / catch (DecodingException ex)。所以 Go 确实有异常,可以在内部使用,但不建议你这么做。


有趣的是:几个星期前,一个非 googler 修复了 json 解码器,其中使用常规错误冒泡。


丑陋的方面


依赖关系管理


一位知名的 Google Go 开发者 Jaana Dogan(又名 JBD),最近在推特上发泄他的不满:


如果依赖关系管理不能在一年解决,我会考虑弃用 Go 并且永远不再回来。依赖性管理问题通常会改变我从语言中获得的所有乐趣。


让我们把它简单化:Go 中没有依赖项管理,所有当前的解决方案都只是一些技巧和变通方法。


这可以追溯回谷歌的起源阶段,众所周知,谷歌使用了一个巨大的单块存储库,用于所有源代码。不需要模块版本控制,不需要第三方模块存储库,你在你当前分支上build任何(你想要的)东西。不幸的是,这在开放的互联网上行不通。


为 Go 添加依赖就表示将依赖项的源代码库拷贝到 GOPATH 中。但是是什么版本呢?是克隆时的 master 分支,不管它是哪个版本。如果不同项目需要不同版本的依赖项怎么办呢?没办法。版本的概念甚至不存在。


同时,自己的项目也要放在 GOPATH,否则编译器就找不到它。你是否想让项目整洁的组织在各自独立的目录中呢?那就必须想为每个项目设置 GOPATH 或恰当的建立符合连接。


社区中设计出来的方法带来了大量工具。包管理工具引入了提供方和 lock 文件,它们包含的 Git ShA1 可以支持重复构建。


vendor 目录最终在 Go 1.6 中得到了官方支持。但对于克隆的供应内容,仍然没有合适的版本管理。也不能通过语义化版本解决混淆导入和依赖传递的问题。


不过情况正在好转:dep,最近出现了这个官方依赖管理工具用于支持供应内容。它支持版本(git tags),同时具有支持语义化版本约定的版本解析器。这个工具尚未达到稳定版本,但它在做正确的事情。而且,它仍然需要你把项目放在 GOPATH 目录下。


但dep可能不会存在太久,因为 vgo,也来自 Google,想在语言本身中引入版本信息并且近期一直在发起一些此类的浪潮。


所以 Go 中的依赖管理是噩梦般的存在。完成配置是很痛苦的,而你在开发过程中从没有考虑过它,直到你添加一个新的导入或者简单地想把你的一个团队成员的一个分支拉到你的 GOPATH 中时...


现在让我们回到代码上吧。


可变性在语言中是硬编码的


在 Go 中没有办法定义不可变的结构体:struct 字段是可变的,而 const 关键词不适用于它们。Go 可以很容易地通过简单的赋值来完成整个结构的复制,因此我们可能会认为按值传参是以复制为代价以实现不变性的前提。


然而,毫不奇怪,这不会复制由指针引用的值。由于内置集合(map,slice 和 array)是引用并且是可变的,所以复制包含其中之一的结构体只会将复制指向同一后台内存的指针。


下面的示例说明这一点:


坚持还是放弃,Go 语言的“美好与丑陋”解读


所以你必须对此非常小心,并且如果你是通过传值来传递参数的话,则不要假定它是不可变的。


有一些 deepcopy 库试图用(慢)反射来解决这个问题,但由于专有字段不能被反射访问,所以它们存在不足之处。因此可避免竞态条件的预防式复制将会是很困难的,需要大量的样板代码。Go 甚至没有可以标准化的 Clone 接口。


Slice 陷阱


Slice 带来了很多陷阱:就像在“Go slices: usage and internals”中解释的一样,由于一些性能原因,re-slicing 一个 slice 不会复制底层数组。它的目的是好的,但这意味着一个 slice 中的子 slice 仅仅是继承了原始 slice 的 mutations 的视图。因此如果你想将子 slice 与原始的 slic e区分,不要忘了 copy() 这个 slice。


因为 append 函数,忘记调用 copy() 会很危险:如果它没有足够的容量存储新值,在一个 slice 中 append 一个值会改变底层数组的大小。这就意味着 append 的结果可能会也可能不会指向依赖初始化容量的原始的数组。这会导致很难找到不确定的 bug。


在下面的代码我们看到一个函数将值 append 到一个子 slice 改变了使用容量初始化的原始 slice 产生的结果。


坚持还是放弃,Go 语言的“美好与丑陋”解读


Mutability 和 channels:更容易产生竞争条件


Go 的并发是使用 channel 在 CSP 上建立的,这会使相应的 goroutine 比同步共享数据更加简单和安全。这里的 mantra 是“不要通过共享内存来通信;相反,通过通信来共享内存”。然而这只是一厢情愿,实际上并不能安全的完成这个目标。


就像我们之前看到的,在 Go 中没有方法使用不可变数据结构。这意味着我们使用 channel 发送一个指针,就玩完了:我们在并发进程共享了可变数据。当然 structure(不是指针)的一个 channel 复制了在 channel 上发送的值,但是就像我们之前看到的,这不是深度复制引用,包括 slice 和 map,他们本质上都是可变的。一个 interface type 的结构字段也是一样:他们是指针,interface 定义的任何 mutation 方法都是通向竞争条件的大门。


因此虽然 channel 明显让并发编程更简单,但他们不阻止在共享数据里的竞争条件。而且 slice 和 map 的本质可变性让这种情况更容易发生。


来说一下竞争条件,Go 包含了一个竞争条件的检测模式,这些代码工具是用来寻找未同步的共享访问。它只能在他们出问题的时候检测竞争问题,因此大多都是在集成或负载测试中使用,借此期望产生会引发竞争条件的问题。在生产中,实际上这并不可行,因为它的高运行时代价,除了临时 debug sessions。


混乱的错误管理


在 Go 中你需要快速学习的是错误处理模式,因为反复出现:


坚持还是放弃,Go 语言的“美好与丑陋”解读


由于 Go 声称不支持异常(虽然它支持异常),但每个可能以错误结尾的函数都必须有 error 作为其最终处理结果。 这尤其适用于执行一些 I/O 功能,因此这种冗长的模式在网络应用程序中非常普遍,这是 Go 的主要领域。


你的眼睛会很快为这种模式开发一个可视化过滤器,并将其识别为“是的,错误处理”,但仍然有很多其他干扰,有时很难在错误处理过程中找到实际的代码。


虽然有一些陷阱,因为错误结果实际上可能只是一个表面上的情况,例如从普遍存在的 io.Reader 读取时:


坚持还是放弃,Go 语言的“美好与丑陋”解读


在“有价值错误”中,Rob Pike 提出了一些减少冗长错误处理的策略。 我发现他们实际上是危险的救急:


坚持还是放弃,Go 语言的“美好与丑陋”解读


以上的检查错误一直令人痛苦,这种模式忽略了写入时的序列错误,而是在写完时才提示。 因此,任何执行的操作都会在执行完错误后执行。 如果这些比分片更昂贵呢? 我们只是浪费资源。


Rust 有一个类似的问题:没有异常(与 Go 相反),函数可能失败后返回 Result <T,Error>,并且需要对结果进行一些模式匹配。 所以 Rust 1.0 带有 try!  宏指令认识到这种模式的普遍性,并做成一流的语言功能。 因此,您在保持正确的错误处理的同时保持上述代码的简洁。


不幸的是,将 Rust 的方法转换为 Go 是不可能的,因为 Go 没有泛型或宏。


无接口值


在一次更新后,出现 redditor jmickeyd 显示 nil 和接口的奇怪行为,这十分丑陋。 我把它扩展了一点:


坚持还是放弃,Go 语言的“美好与丑陋”解读


上面的代码验证了 explode 不是 nil,但是 code 在 Boom 中冒出来,但不在 Bang 中。 这是为什么? 解释一下在 println 行中:bomb 指针是0x0,实际上是 nil,但 explodes 是非空值(0x10a7060,0x0)。



对 Bang 的调用成功了,因为它应用在指向 Bomb 的指针上:不需要解引用该指针来调用该方法。Boom 方法操作一个值,因此一个调用导致指针被解引用,这会导致 panic。


请注意,如果我们写了 var explode Explodes = nil,那么 != nil 将不会成功。


那么我们应该如何以安全的方式编写测试? 我们必须对接口值和非零值都进行 nil-check,检查接口对象指向的值...使用反射!


坚持还是放弃,Go 语言的“美好与丑陋”解读


错误或功能? Tour of Go 有一个专门的页面来解释这种行为,并明确指出:“请注意,一个具有 nil 值的接口值本身不是零”。


不过,这很丑陋,可能会导致很微小的错误。 它在语言设计中看起来像是一个很大的缺陷,使其实现更容易。


结构字段标签:运行时字符串中的 DSL


如果您在 Go 中使用过 JSON,您肯定遇到过类似的情况:


坚持还是放弃,Go 语言的“美好与丑陋”解读


这些语言规范所说的结构标签是一个字符串“通过反射接口可见并参与结构的类型标识,但是被忽略”。 所以基本上,写上任何你想要的字符串,并在运行时使用反射来解析它。 如果语法不对,会在运行时会出现宕机。


这个字符串实际上是字段元数据,在许多语言中已经存在了数十年的“注释”或“属性”。 通过语言支持,它们的语法在编译时被正式定义和检查,同时仍然是可扩展的。


为什么 Go 决定使用原始字符串,并且任何库都可以决定是否使用它想要的任何 DSL,在运行时解析?


当您使用多个库时,情况可能会变得尴尬:下面是从协议缓冲区的 Go 文档中取出的一个例子:


坚持还是放弃,Go 语言的“美好与丑陋”解读


边注:为什么在使用 JSON 的时候有很多常见的标签。因为在 Go 中,public 的字段必须使用大骆驼命名法,或者至少以大写字母开始。然而在 JSON 中,常见的字段命名习惯用小骆驼命名法或者蛇形命名法。因此需要很多冗长的标签。


JSON 编码器和解码器标准不允许提供命名策略来转自动转化,就像 Java 中的 Jackso n文档。这就解释了为什么在 Docker API 的所有的字段都是大驼峰命名法:避免他的开发人员为他们的大型 API 写这些麻烦的标签。


没有泛型......至少不适合你


很难想象一个没有泛型的现代静态类型语言,但这就是你用 Go 得到的东西:它没有泛型......或者更确切地说几乎没有泛型,正如我们将看到的那样,这使得它比没有泛型更糟糕。


内置切片,地图,数组和通道是通用的。 声明一个 map [string] MyStruct 清楚地显示了使用具有两个参数的泛型类型。 这很好,因为它允许类型安全编程捕捉各种错误。


然而,没有用户可定义的泛型数据结构。这意味着你无法以类型安全的方式定义可用于任何一个 type 的可复用 abstractions。你必须使用 untyped 的 interface{} 并且需要将值转成合适的 type。任何错误都只会在运行时捕获,并且产生了 panic。作为一个 Java 开发者,这就像回到了之前 2004 年 Java 5 时代


在 "Less is exponentially more"中,Rob Pike 惊人的将泛型和继承放进了同一个“typed programming”包中,说他赞成组合替换继承。不喜欢继承是可以的(事实上,我写Scala的时候很少使用继承)但是泛型解决了另一个问题:在保持类型安全的同时有可复用性。


正如接下来我们将看到的,把内置的泛型与用户定义的非泛型分隔开,对开发者的“舒适度”和编译时的类型安全产生了影响:它影响了整个Go的生态系统。


Go 除了分片和映射之外几乎没有数据结构


Go 生态系统没有很多数据结构,它们可以从内置切片和贴图中提供额外的功能或不同的功能。 Go的最新版本添加了其中几个的容器包。 他们都有同样的说明:他们处理interface{}值,这意味着你失去了所有类型的安全机制。


我们来看看 sync.Map 的一个例子,它是一个具有较低线程争用的并发映射,而不是使用互斥锁来保护常规映射:


坚持还是放弃,Go 语言的“美好与丑陋”解读

坚持还是放弃,Go 语言的“美好与丑陋”解读


这是个很好的例子来解释为什么 Go 的生态系统中没有太多的数据结构:与内置的 slice 和 map 相比它们用起来很痛苦。出于一个简单的原因:Go 的数据结构中只有两大类。


  • aristocracy,内置的 slice,map,array 和 channel:类型安全,通用且调用 range 方便,

  • Go 代码写的其他的类型:不提供类型安全,因为需要强制转换所以用起来笨拙。


所以库定义的数据结构必须为我们开发者提供很多实在的好处,让我们愿意付出失去类型安全和额外冗长代码的代价。


当我们想要编写可重用的算法时,内置结构和 Go 代码之间的双重性更加微妙。 这是标准库的排序包对排序片段的一个例子:


坚持还是放弃,Go 语言的“美好与丑陋”解读


等等...这是真的吗? 我们必须定义一个新的 ByAge 类型,它必须实现 3 种方法来桥接泛型(“可重用”)排序算法和类型化片段。


对于我们开发者来说,唯一需要关注的一件事,就是用于比较两个对象的Less函数,并且它是域依赖的。其他一切都是干扰,因为 Go 没有泛型所以出现了模板。我们不得不一次次地重复使用它,包括我们想去排序的每个 type 和 comparator。


更新:Michael Stapelberg 指导我去看被我遗漏的 sort.Slice。它在底层使用了反射,而且要求排序的时候,在 slice上comparator 函数得形成一个闭包。虽然这看起来会好些,但它依旧丑陋。


对于 Go 不需要泛型的所有解释都是在告诉我们这就是“Go 方式”,Go 允许有可复用的算法来避免向下转型成 interface{}...


好了,现在来缓解一下痛苦,如果 Go 能用宏来生成这些无意义的模板将会变得美好一些,对吗?


go generate:还行,但是...


Go 1.4 引入了 go generate command 命令来触发源代码中注释的代码生成。 那么,这里的“注释”实际上意味着一个神奇的 //go:generate,用严格的规则生成注释:“注释必须从行的开始处开始并且在 // 和 go:generate 之间没有空格”。 弄错了,增加一个空格,没有空格工具会警告你。


这实际上涵盖了两种用例:


  • 从其他来源生成 Go 代码:ProtoBuf / Thrift / Swagger 模式,语言文法等

  • 生成补充现有代码的 Go 代码,例如作为示例给出的 stringer,它为一系列类型常量生成一个String() 方法。


第一个用例是可以正常使用的,附加的是你不必使用 Makefiles,生成指令可以接近生成的代码的用法。


对于第二种用例,许多语言(如 Scala 和 Rust)都有宏(在设计文档中提到)可在编译期间访问源代码的 AST。 Stringer 实际上导入了 Go 编译器的解析器来遍历 AST。 Java 没有宏,但注释处理器扮演着相同的角色。


许多语言也不支持宏,因此除了这种脆弱的注释驱动语法之外,没有任何根本性的错误,除了这种脆弱的注释驱动的语法之外,它看起来像是一种快速破解,它不知道怎么做了这个工作,而不是被认真考虑为连贯的语言设计。


哦,你知道Go编译器实际上有许多注释/杂注条件编译使用这种脆弱的注释语法?


结论


正如你可能猜到的那样,我对 Go 又爱又恨。 Go 有点像一个朋友,你喜欢和他在一起,因为他很有趣,很适合一起喝啤酒闲谈,但是当你想要进行更深入的对话时,你会觉得无聊或痛苦,而且你不想与他去一起度假。


我喜欢 Go 编写高效的 API 及网络工具的简单性,这归功于 Goroutine,我讨厌它在我必须实现业务逻辑时限制我的表现力,并且我讨厌它的所有怪异和陷阱等着你踩进去。


直到最近,Go 还没有真正的替代品,它正在开发高效的本地可执行文件,而不会产生 C 或 C ++ 的痛苦。Rust 正在迅速发展,我越玩越多,我发现它越来越有趣和设计得非常好。我有一种感觉,Rust 是需要一段时间才能相处的朋友之一,但是你最终会想要与他们建立长期合作关系。


回到更技术的层面,你会发现文章中说的 Rust 和 Go 并不是一个层面的,由于 Rust 没有 GC 等原因,Rust 是一个系统语言。我认为这越来越不符合实际。Rust 在大型 web 框架和优秀的 ORM 中的地位正在逐渐升高。它也给你一种亲切感:“如果它是编译器,错误会出现在我写的逻辑上,而不是我忘记注意的语言特性上”。


我们也从容器/服务网格领域上看到一些有趣的活动,包括使用 Rust 写的高效 Sozu 代理,或者 Buoyant(Likerd 的开发者)开发的他们的新 Kubernetes 服务网格 Conduit 来作为 Go 和 Rust 的结合,其中 Go 作为控制层(我猜由于现有的 Kubernetes 库),Rust 作为数据层因为它的高效和健壮。


Swift 也是可以替代 C 和 C++ 语言的家族的一部分。尽管它的生态仍然太以 Apple为 中心,但是现在它在 Linux 是可以用的,而且出现了服务端 API 和 Netty 框架


现在当然没有万能和完全通用的技术。但是了解你使用的这些工具的优点和缺点是很重要的。我希望这个博客已经让你了解到了一些关于 Go 的你曾经没意识到的问题,这样你就可以避免陷阱而不会被陷进去!


坚持还是放弃,Go 语言的“美好与丑陋”解读


推荐阅读





点击“阅读原文”查看更多精彩内容

以上是关于坚持还是放弃,Go 语言的“美好与丑陋”解读的主要内容,如果未能解决你的问题,请参考以下文章

读 《我为什么放弃Go语言》 有感

GO快速入门一

GO快速入门一

go语言到底有啥好处

go语言操作符 ^ 和 &^

Go单元测试--模拟服务请求和接口返回