likes
comments
collection
share

客观的评价令人又爱又恨的Go语言:好的、坏的和丑陋的

作者站长头像
站长
· 阅读数 15

Go 确实有一些不错的功能,因此本文中先列出“好的”部分,但总的来说,当我们超越 API 或网络服务器(这就是它的设计目的)并将其用于业务时,我发现它使用起来很麻烦且痛苦。但即使对于网络编程来说,它在设计和实现方面也存在很多陷阱,这使得它在看似简单的情况下变得危险。

写这篇文章的动机是我最近又开始使用 Go 来做一个业余项目。我在之前的工作中广泛使用 Go 为 SaaS 服务编写网络代理(http 和原始 tcp)。网络部分相当令人愉快(我也在探索这种语言),但随之而来的会计和计费部分却很痛苦。由于我的副项目是一个简单的 API,我认为使用 Go 将是快速完成工作的正确工具,但正如我们所知,许多项目超出了其最初的范围,因此我必须编写一些数据处理来计算统计数据和痛苦Go 回来了。这是我对 Go 困境的看法。

这是一篇很长的文章,所以这里是吊起你胃口的菜单:

好的

Go 很容易学习

这是事实:如果您了解任何一种编程语言,您可以通过“ Go 之旅”在几个小时内学习 Go 的大部分语法,并在几天内编写您的第一个真正的程序。阅读并消化《Effective Go》 ,在标准库中闲逛,使用像GorillaGo kit这样的 Web 工具包,你将成为一名相当不错的 Go 开发人员。

这是因为 Go 的首要目标是简单性。当我开始学习 Go 时,它让我想起了我第一次发现 Java 的时候:一种简单的语言和一个丰富但不臃肿的标准库。在当今 Java 繁重的环境中,学习 Go 是一种令人耳目一新的体验。由于 Go 的简单性,Go 程序的可读性非常好,即使错误处理会增加相当多的噪音(更多内容见下文)。

但这可能是虚假的简单。引用 Rob Pike 的话说,简单是复杂的,我们将在下面看到,在其背后有很多陷阱等待着我们,而简单和极简主义阻止了编写 DRY 代码。

使用 goroutine 和通道轻松进行并发编程

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

当 Go 程序执行看似阻塞 I/O 操作时,Go 运行时实际上会挂起 goroutine,并在事件指示某些结果可用时恢复它。与此同时,其他 goroutine 已被安排执行。因此,我们通过同步编程模型获得了异步编程的可扩展性优势。

Goroutines 也是轻量级的:它们的堆栈按需增长和收缩,这意味着拥有 100 个甚至 1000 个 Goroutine 不是问题。

我曾经在一个应用程序中遇到过 goroutine 泄漏:这些 goroutine 在结束之前等待通道关闭,而该通道从未关闭(一个常见问题)。该进程无缘无故地占用了 90% 的 CPU,并且检查expvars显示有 600k 空闲 goroutine!我猜CPU是被goroutine调度器使用的。

当然,像 Akka 这样的 Actor 系统可以毫不费力地处理数百万个 Actor,部分原因是 Actor 没有堆栈,但它们远没有像 Goroutine 那样易于使用来编写高度并发的请求/响应应用程序(即http API)。

通道是 goroutine 应该如何通信的:它们提供了一种方便的编程模型来在 goroutine 之间发送和接收数据,而不必依赖脆弱的低级同步原语。渠道有自己的一套使用 模式

不过,必须仔细考虑通道,因为通道大小不正确(默认情况下它们是无缓冲的)可能会导致死锁。它们也有大量的陷阱和不一致的地方。我们还将在下面看到,使用通道并不能防止竞争条件,因为 Go 缺乏不变性。

很棒的标准库

Go标准库真的很棒,特别是对于与网络协议或 API 开发相关的所有内容:http 客户端和服务器、加密、存档格式、压缩、发送电子邮件等。甚至还有一个 html 解析器和一个相当强大的模板引擎来生成文本& html 具有自动转义以避免 XSS(例如Hugo使用)。

各种API通常都很简单且易于理解。不过,它们有时看起来很简单:部分原因是 goroutine 编程模型意味着我们只需要关心“看似同步”的操作。这也是因为一些通用函数也可以取代许多专门的函数,正如我最近在时间计算中发现的那样。

Go 是高性能的

Go 编译为本机可执行文件。Go 的许多用户来自 Python、Ruby 或 Node.js。对于他们来说,这是一次令人兴奋的体验,因为他们看到服务器可以处理的并发请求数量大幅增加。当您来自没有并发性(Node.js)或全局解释器锁的解释性语言时,这实际上是很正常的。结合语言的简单性,这解释了 Go 令人兴奋的部分原因。

然而,与 Java 相比,原始性能基准测试中的情况并不那么清楚。Go 击败 Java 的地方在于内存使用。除非你使用Graal 原生镜像,这会将它们放在同一个范围内。

Go 的垃圾收集器旨在优先考虑延迟并避免停止世界的暂停,这在服务器中尤其重要。这可能会带来更高的 CPU 成本,但在水平可扩展的架构中,通过添加更多机器可以轻松解决这个问题。请记住,Go 是由 Google 设计的,而 Google 几乎缺乏资源!

与 Java 相比,Go GC 要做的工作也更少:结构体切片是一个连续的结构数组,而不是像 Java 中的指针数组。类似地,Go 映射使用小数组作为桶来达到相同的目的。这意味着 GC 的工作量更少,并且 CPU 缓存局部性也更好。

Go 在命令行实用程序方面也击败了 Java:作为本机可执行文件,Go 程序没有启动成本,而 Java 则首先必须加载和编译字节码。

语言定义的源代码格式

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

不管你喜欢与否,它gofmt定义了 Go 代码应该如何格式化,因此这个问题就一劳永逸地解决了!

标准化测试框架

Go 在其标准库中附带了一个很棒的测试框架。它支持并行测试、基准测试,并包含许多实用程序来轻松测试网络客户端和服务器。

Go 程序非常适合运维

与 Python、Ruby 或 Node.js 相比,必须安装单个可执行文件是运维工程师的梦想。随着 Docker 使用的增加,这个问题越来越小,但独立的可执行文件也意味着很小的 Docker 镜像。

Go 还具有一些内置的可观察性功能,可以通过expvar包发布内部状态和指标,并可以轻松添加新状态和指标。但要小心,因为它们在默认的 http 请求处理程序上自动公开且不受保护。Java 有 JMX 来实现类似的目的,但它要复杂得多。

Defer 语句,避免忘记清理

defer语句的用途与finallyJava 中类似:在当前函数末尾执行一些清理代码,无论该函数如何退出。有趣的defer是,它不链接到代码块,并且可以随时出现。这使得清理代码的编写尽可能接近创建需要清理的内容的代码:

file, err := os.Open(fileName)
if err != nil {
    return
}
defer file.Close()


// 使用文件,我们不必再考虑关闭它

当然,Java 的try-with-resource不太冗长,Rust在资源所有者被删除时自动声明资源,但由于 Go_要求_您明确资源清理,所以让它接近资源分配是很好的。

新类型

我喜欢类型,而让我恼火/害怕的是,例如,当我们将持久化对象标识符作为stringlong到处传递时。我们通常在参数名称中编码 id 的类型,但是当函数有多个标识符作为参数并且某些调用不匹配参数顺序时,这会导致微妙的错误。

Go 对新类型具有一流的支持,即采用现有类型并赋予其与原始类型不同的单独标识的类型。与包装相反,新类型没有运行时开销。这允许编译器捕获这种错误:


type UserId string // <-- new type
type ProductId string

func AddProduct(userId UserId, productId ProductId) {}

func main() {
    userId := UserId("some-user-id")
    productId := ProductId("some-product-id")

    // Right order: all fine
    AddProduct(userId, productId)

    // Wrong order: would compile with raw strings
    AddProduct(productId, userId)
    // Compilation errors:
    // cannot use productId (type ProductId) as type UserId in argument to AddProduct
    // cannot use userId (type UserId) as type ProductId in argument to AddProduct


不幸的是,缺乏泛型使得新类型的使用变得很麻烦,因为为它们编写可重用的代码需要将值转换为原始类型或从原始类型转换值。

坏处

Go 忽略了现代语言设计的进步

《少即是多》中,Rob Pike 解释说,Go 的目的是取代 Google 的 C 和 C++,而它的前身是Newsqueak,这是他在 80 年代编写的一种语言。Go 还大量引用了Plan9,这是 Go 的作者于 80 年代在贝尔实验室开发的分布式操作系统。

甚至还有直接受 Plan9 启发的Go 汇编。为什么不使用可以提供广泛的开箱即用目​​标架构的LLVM ?我也可能在这里遗漏了一些东西,但为什么需要它呢?如果您需要编写汇编语言来充分利用 CPU,难道不应该直接使用目标 CPU 汇编语言吗?

Go 的创造者值得尊重,但看起来 Go 的设计发生在平行宇宙(或者他们的 Plan9 实验室?)中,90 年代和 2000 年代编译器和编程语言设计中发生的大部分事情从未发生过。或者说 Go 是由也能够编写编译器的系统程序员设计的。

函数式编程?没有提及它。泛型?你不需要它们,看看它们在 C++ 中产生的混乱!即使切片、贴图和通道_都是_通用类型,如下所示。

Go 的目标是取代 C 和 C++,显然它的创建者并没有把目光投向其他地方。但他们未能实现目标,因为 Google 的 C 和 C++ 开发人员没有采用它。我的猜测是主要原因是垃圾收集器。低级 C 开发人员强烈拒绝托管内存,因为他们无法控制发生的情况和时间。他们喜欢这种控制,即使它带来额外的复杂性并打开内存泄漏和缓冲区溢出的大门。有趣的是,Rust 采用了完全不同的方法,无需 GC 即可进行自动内存管理。

相反,Go 在操作工具方面吸引了 Python、Ruby 等脚本语言的用户。他们在 Go 中发现了一种具有出色性能并减少内存/CPU/磁盘占用的方法。还有更多的静态类型,这对他们来说是新的。Go 的杀手级应用程序是 Docker,它引发了它在 DevOps 领域的广泛采用。Kubernetes 的崛起强化了这一趋势。

接口是结构类型

Go 接口类似于 Java 接口或 Scala 和 Rust 特征:它们定义稍后由类型实现的行为(我在这里不会将其称为“类”)。

但与 Java 接口以及 Scala 和 Rust 特征不同,类型不需要显式指定它实现接口:它只需要实现接口中定义的所有函数。所以Go接口实际上是结构类型

我们可能认为这是为了允许在其所适用的类型之外的其他包中实现接口,例如 Scala 或 Kotlin 中存在的类扩展,或 Rust 特征,但事实并非如此:与类型相关的所有方法都必须是在类型的包中定义。

Go 并不是唯一使用结构类型的语言,但我发现它有几个缺点:

  • 找到实现给定接口的类型很困难,因为它依赖于函数定义匹配。我经常通过搜索实现接口的类来发现 Java 或 Scala 中有趣的实现。

  • 当向接口添加方法时,您会发现只有当哪些类型用作该接口类型的值时才需要更新。这可能会在相当长一段时间内被忽视。Go 建议使用极少方法的微小接口,这是防止这种情况的一种方法。

  • 类型可能会在不知不觉中实现接口,因为它具有相应的方法。但出于偶然,实现的语义可能与接口契约所期望的不同。

更新:对于接口的一些丑陋之处,请参阅下面的nil 接口值

接口方法不支持默认实现

_Go 1.13 版本后添加。_这可能看起来没什么大不了的,但请继续阅读。

Go 1.13 引入了方法链,为错误添加了新Unwrap方法。由于 Go 接口不支持其方法的默认实现,因此向现有接口添加方法会破坏许多现有代码。所以这个新方法是一个“约定”而不是error接口的一部分。因此,我们不能只是调用err.Unwrap()来获取包装的错误。我们必须使用单独的函数errors.Unwrap(err),该函数使用动态类型测试来检查Unwrap其参数是否存在。

再见编译时检查,你好,本来可以是简单方法调用的繁琐语法!Java 在 JDK8 中引入了 lambda,也面临着类似的问题,并添加了默认方法实现支持,以允许接口以向后兼容的方式发展。

没有枚举

Go 没有枚举,在我看来这是一个错失的机会。

可以iota快速生成自动递增值,但它看起来更像是一个黑客而不是一个功能。实际上,这是一个危险的行为,因为在一系列 iota 生成的常量中插入一行会改变后面常量的值。由于生成的值是整个代码中使用的值,因此这可能会导致有趣的(不是!)惊喜。

这也意味着 Go 中无法让编译器检查语句是否switch详尽,也无法描述类型中允许的值。

:=/困境var_

Go 提供了两种声明变量并为其赋值的方法:var x = "foo"x := "foo"。这是为什么?

主要区别在于,var允许在不初始化的情况下进行声明(然后您必须声明类型),例如 in var x string,而:=需要赋值并允许混合现有变量和新变量。我的猜测是,它:=的发明是为了让错误处理_不那么_痛苦:

var

var  x ,  err1  =  SomeFunction () 
if  ( err1  !=  nil )  { 
  return  nil 
}

var  y ,  err2  =  SomeOtherFunction () 
if  ( err2  !=  nil )  { 
  return  nil 
}

:=

x ,  err  :=  SomeFunction () 
if  ( err  !=  nil )  { 
  return  nil 
}

y ,  err  :=  SomeOtherFunction () 
if  ( err  !=  nil )  { 
  return  nil 
}

:=语法还很容易意外地隐藏变量。我不止一次被这个问题困扰,因为:=(声明和分配)也太接近=(分配),如下所示:

foo  :=  "bar" 
if  someCondition  { 
  foo  :=  "baz" 
  doSomething ( foo ) 
} 
// foo == "bar" even if "someCondition" is true

令人恐慌的零值

Go 没有构造函数。正因为如此,它坚持“零值”应该易于使用。这是一种有趣的方法,但在我看来,它带来的简化主要是针对语言实现者的。

实际上,如果没有正确的初始化,许多类型就无法做有用的事情。让我们看一下以Effective Goio.File为例的对象:

type File struct {
    *file // os specific
}

func (f *File) Name() string {
    return f.name
}

func (f *File) Read(b []byte) (n int, err error) {
    if err := f.checkValid("read"); err != nil {
        return 0, err
    }
    n, e := f.read(b)
    return n, f.wrapErr("read", e)
}

func (f *File) checkValid(op string) error {
    if f == nil {
        return ErrInvalid
    }
    return nil
}

我们在这里能看到什么?

  • 调用Name()零值File会引起恐慌,因为它的file字段为零。

  • Read函数以及几乎所有其他 File方法都首先检查文件是否已初始化。

所以基本上零值File不仅没有用,而且可能导致恐慌。您_必须_使用构造函数之一,例如OpenCreate。检查正确的初始化是每次函数调用时都必须付出的开销。

标准库中有无数像这样的类型,有些类型甚至不尝试用它们的零值做一些有用的事情。在零值上调用任何方法html.Template:它们都会恐慌。

还有一个严重的问题是 的map值为零:您可以查询它,但在其中存储一些内容会出现恐慌:

var  m1  =  map [ string ] string {}  // 空映射
var  m0  map [ string ] string      // 零映射 (nil)

println ( len ( m1 ))    // 输出 '0' 
println ( len ( m0 ))    // 输出 '0' 
println ( m1 [ "foo" ])  // 输出 '' 
println ( m0 [ "foo" ])  / / 输出 '' 
m1 [ "foo" ]  =  "bar"   // 好的
m0 [ "foo" ]  =  "bar"   // 恐慌!

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

因此,作为开发人员,您必须不断检查要使用的结构是否需要调用构造函数或者零值是否有用。由于语言的一些简化,这给代码编写者带来了很大的负担。

Go 也不例外。哦等等……确实如此!

博客文章“为什么 Go 能够正确处理异常”详细解释了为什么异常不好,以及为什么 Go 要求返回的方法error更好。我可以同意这一点,并且在使用异步编程或像 Java 流这样的函数式风格时,异常很难处理(先不考虑前者在 Go 中由于 goroutine 而不是必需的,而后者根本不可能)。博客文章提到panic“对你的程序总是致命的,游戏结束”,这很好。

现在,早于它的“延迟、恐慌和恢复”解释了如何从恐慌中恢复(通过实际捕获它们),并说“有关恐慌和恢复的真实示例,请参阅 Go 标准库中的 json 包”。

事实上,json 解码器有一个常见的错误处理函数,它只是发生恐慌,恐慌在顶级unmarshal函数中恢复,该函数检查恐慌类型,如果是“本地恐慌”,则将其作为错误返回,或者重新恐慌错误否则(在途中丢失原始恐慌的堆栈跟踪)。

对于任何 Java 开发人员来说,这肯定看起来像try/ catch (DecodingException ex)。所以 Go 确实有例外,在内部使用它们,但告诉你不要这样做。

有趣的事实:几周前,一位非 Google 员工修复了 json 解码器,以使用经常出现的错误。

丑陋的

依赖管理的噩梦

更新:自撰写本文以来,Go 1.11引入了模块支持以及基于语义版本控制的解析。这解决了下面解释的大部分问题,尽管选择最小版本进行依赖解析是有争议的(另请参阅这篇文章,了解为什么 Rust 的 Cargo 选择最大版本)。

让我们首先引用 Jaana Dogan(又名 JBD)的话,她是 Google 的一位知名地鼠,她最近在 Twitter 上发泄了自己的不满:

简单来说:Go 中没有依赖管理。当前的所有解决方案都只是黑客和解决方法。

这可以追溯到谷歌的起源,谷歌以使用一个巨大的整体存储库来存储所有源代码而闻名。不需要模块版本控制,不需要第三方模块存储库,您可以从当前分支构建所有内容。不幸的是,这在开放的互联网上不起作用。

在 Go 中添加依赖项意味着在您的GOPATH. 什么版本?克隆时的当前主分支,无论它包含什么。如果不同的项目需要不同版本的依赖项怎么办?他们不能。“版本”的概念甚至不存在。

另外,您自己的项目必须存在,GOPATH否则编译器将找不到它。想要将您的项目整齐地组织在单独的目录中吗?您必须破解每个项目GOPATH或摆弄符号链接。

社区已经使用大量工具开发了解决方法。包管理工具引入了供应商和锁定文件,其中包含您克隆的任何内容的 Git sha1,以提供可重现的构建。

最终在 Go 1.6 中该vendor目录得到了正式支持。但这是关于供应您克隆的内容,并且仍然没有适当的版本管理。对于通常通过语义版本控制解决的传递依赖项的冲突导入没有答案。

不过,事情正在变得更好:最近推出了官方依赖管理工具dep来支持供应商。它支持版本(git 标签)并具有遵循语义版本控制约定的版本求解器。虽然还不稳定,但正在朝着正确的方向发展。但它仍然需要您的项目才能生存。GOPATH

dep可能不会活太久vgo,因为同样来自谷歌,希望在语言本身中引入版本控制,并且最近引起了一些轰动。

所以 Go 中的依赖管理是一场噩梦。设置起来很痛苦,并且在开发时您不会考虑它,直到当您添加新的导入或只是想将某个团队成员的分支拉到您的GOPATH...

现在让我们回到代码。

可变性在语言中被硬编码

Go 中无法定义不可变结构:结构字段是可变的,并且关键字const不适用于它们。然而,Go 可以轻松地通过简单的赋值来复制整个结构,因此我们可能认为按值传递参数就足以以复制为代价获得不变性。

然而,毫不奇怪,这不会复制指针引用的值。由于内置集合(映射、切片和数组)是引用并且是可变的,因此复制包含其中之一的结构只是将指针复制到相同的底层内存。

下面的例子说明了这一点:

type S struct {
    A string
    B []string
}

func main() {
    x := S{"x-A", []string{"x-B"}}
    y := x // copy the struct
    y.A = "y-A"
    y.B[0] = "y-B"

    fmt.Println(x, y)
    // Outputs "{x-A [y-B]} {y-A [y-B]}" -- x was modified!
}

因此,您必须对此非常小心,如果按值传递参数,则不要假设不变性。

有一些深度复制库尝试使用(慢速)反射来解决此问题,但由于无法通过反射访问私有字段,因此它们的效果不佳。因此,避免竞争条件的防御性复制将很困难,需要大量样板代码。Go 甚至没有一个Clone可以标准化这一点的接口。

切片陷阱

切片有许多陷阱:如“ Go 切片:用法和内部原理”中所述,出于性能原因,重新切片切片不会复制底层数组。这是一个值得称赞的目标,但意味着切片的子切片只是跟随原始切片的突变的视图。copy()因此,如果您想将其与其起源分开,请不要忘记切片。

忘记copy()使用该函数会变得更加危险append:如果底层数组_没有足够的容量_来保存新值,则将值附加到切片会调整底层数组的大小。这意味着 的结果append可能指向也可能不指向原始数组,具体取决于其初始容量。这可能会导致难以发现非确定性错误。

在下面的代码中,我们看到函数将值附加到子切片的效果根据原始切片的容量而变化:

func doStuff(value []string) {
    fmt.Printf("value=%v\n", value)

    value2 := value[:]
    value2 = append(value2, "b")
    fmt.Printf("value=%v, value2=%v\n", value, value2)

    value2[0] = "z"
    fmt.Printf("value=%v, value2=%v\n", value, value2)
}

func main() {
    slice1 := []string{"a"} // length 1, capacity 1

    doStuff(slice1)
    // Output:
    // value=[a] -- ok
    // value=[a], value2=[a b] -- ok: value unchanged, value2 updated
    // value=[a], value2=[z b] -- ok: value unchanged, value2 updated

    slice10 := make([]string, 1, 10) // length 1, capacity 10
    slice10[0] = "a"

    doStuff(slice10)
    // Output:
    // value=[a] -- ok
    // value=[a], value2=[a b] -- ok: value unchanged, value2 updated
    // value=[z], value2=[z b] -- WTF?!? value changed???
}

可变性和通道:竞争条件变得简单

Go 并发性是使用通道基于 CSP 构建的,这使得协调 goroutine 比同步共享数据更简单、更安全。这里的口头禅是“不要通过共享内存来通信;相反,通过通信来共享内存”。然而,这是一厢情愿的想法,在实践中无法安全地实现。

正如我们上面所看到的,Go 中没有办法拥有不可变的数据结构。这意味着一旦我们在通道上发送指针,游戏就结束了:我们在并发进程之间共享可变数据。当然,结构体(而不是指针)的通道会复制在通道上发送的值,但正如我们上面所看到的,这不会深度复制引用,包括本质上可变的切片和映射。接口类型的结构体字段也是如此:它们是指针,并且接口定义的任何突变方法都是竞争条件的大门。

因此,尽管通道显然使并发编程变得容易,但它们并不能防止共享数据上的竞争条件。切片和映射的内在可变性使得它们更有可能发生。

谈到竞争条件,Go 包含一个竞争条件检测模式,该模式可以检测代码以查找不同步的共享访问。但它只能在发生竞争问题时检测到它们,因此主要是在集成或负载测试期间,希望这些问题能够发挥竞争条件。由于运行时成本很高,它实际上无法在生产中启用,临时调试会话除外。

嘈杂的错误管理

在 Go 中你会很快学到的东西是错误处理模式,重复得令人厌烦:

someData ,  err  :=  SomeFunction () 
if  err  !=  nil  { 
    return  err ; 
}

因为 Go 声称不支持异常(尽管它支持),所以每个可能以错误结束的函数都必须有一个error作为其最后结果。这尤其适用于执行某些 I/O 的每个函数,因此这种冗长的模式在网络应用程序中极为普遍,这是 Go 的主要领域。

你的眼睛会很快为这种模式形成一个视觉过滤器,并将其识别为“是的,错误处理”,但它仍然有很多噪音,有时很难在错误处理过程中找到实际的代码。

首先,你的函数应该真正返回error接口类型,而不是有意义的具体类型,否则调用者会遇到可怕的_“为什么我的 nil 错误值不等于 nil?”_ 有专门的常见问题解答条目的问题(另请参阅下面的“nil 接口值”)。

还有一些陷阱,因为错误结果实际上可能是名义上的情况,例如从无处不在的读取时io.Reader

len, err := reader.Read(bytes)
if err != nil {
    if err == io.EOF {
        // All good, end of file
    } else {
        return err
    }
}

在“错误具有价值”中,Rob Pike 建议了一些减少错误处理冗长的策略。我发现它们实际上是一个危险的创可贴:

type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return // Write nothing if we already errored-out
    }
    _, ew.err = ew.w.Write(buf)
}

func doIt(fd io.Writer) {
    ew := &errWriter{w: fd}
    ew.write(p0[a:b])
    ew.write(p1[c:d])
    ew.write(p2[e:f])
    // and so on
    if ew.err != nil {
        return ew.err
    }
}

基本上,这认识到一直检查错误是痛苦的,并且提供了一种模式来忽略写入序列中的错误直到其结束。因此,一旦错误输出,任何为向写入器提供数据而执行的操作都会执行,即使我们知道不应该这样做。如果这些比只买一片更贵怎么办?我们只是浪费了资源,因为 Go 的错误处理很痛苦。如果评估参数有副作用怎么办?我们刚刚引入了一个严重的错误来简化错误处理......

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

不幸的是,将 Rust 的方法移植到 Go 是不可能的,因为 Go 没有泛型也没有宏。

无接口值

这是Reddit 用户 jmickeyd展示了 和 界面的奇怪行为之后的更新nil,这绝对是丑陋的。我稍微扩展了一下:

type Explodes interface {
    Bang()
    Boom()
}

// Type Bomb implements Explodes
type Bomb struct {}
func (*Bomb) Bang() {}
func (Bomb) Boom() {}

func main() {
    var bomb *Bomb = nil
    var explodes Explodes = bomb
    println(bomb, explodes) // '0x0 (0x10a7060,0x0)'
    if explodes != nil {
        println("Not nil!") // 'Not nil!' What are we doing here?!?!
        explodes.Bang()     // works fine
        explodes.Boom()     // panic: value method main.Bomb.Boom called using nil *Bomb pointer
    } else {
        println("nil!")     // why don't we end up here?
    }
}

上面的代码验证了这explodes不是nil,但是代码在Boom但不是在中发生了恐慌Bang。这是为什么?解释如下printlnbomb指针是0x0有效的nil,但是explodes非零的(0x10a7060,0x0)

这是因为接口值是胖指针。该对的第一个元素是指向该类型实现接口的方法调度表的指针BombExplodes第二个元素是实际对象的地址Explodes,即nil

对 的调用会Bang成功,因为它适用于指向 a 的_指针_Bomb:无需取消引用指针即可调用该方法。该Boom方法作用于一个_值_,因此调用会导致指针被取消引用,从而导致恐慌。

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

那么我们应该如何安全地编写测试呢?我们必须对接口值进行 nil 检查,如果非 nil,则检查接口对象指向的值...使用反射!

if explodes != nil && !reflect.ValueOf(explodes).IsNil() {
    println("Not nil!") // we no more end up here
    explodes.Bang()
    explodes.Boom()
} else {
    println("nil!")     // 'nil' -- all good!
}

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

尽管如此,这还是很丑陋,并且可能会导致非常微妙的错误。在我看来,这似乎是语言设计中的一个大缺陷,以使其实现更容易。

_19 年 12 月添加:_当接口类型的字段同时更新时,可能会出现其他细微错误(实际上是硬崩溃)。由于胖指针的分配不是原子的,因此我们最终可能会得到一个指向一个类型和另一个不同类型的值的胖指针。这破坏了 Go 的内存安全,并在安全挑战期间的漏洞利用中得到了证明。如果您的测试使用了 Go 的竞争检测器,则可能会发现此问题。

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

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

type User struct {
    Id string    `json:"id"`
    Email string `json:"email"`
    Name string  `json:"name,omitempty"`
}

这些是结构标记,语言规范称它们是一个字符串“通过反射接口可见并参与结构的类型标识,但在其他情况下会被忽略”。所以基本上,将您想要的任何内容放入该字符串中,并在运行时使用反射对其进行解析。如果语法不正确,则会在运行时出现恐慌。

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

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

当您使用多个库时,事情可能会变得尴尬:下面是一个取自 Protocol Buffer 的Go 文档的示例:

type Test struct {
    Label         *string             `protobuf:"bytes,1,req,name=label" json:"label,omitempty"`
    Type          *int32              `protobuf:"varint,2,opt,name=type,def=77" json:"type,omitempty"`
    Reps          []int64             `protobuf:"varint,3,rep,name=reps" json:"reps,omitempty"`
    Optionalgroup *Test_OptionalGroup `protobuf:"group,4,opt,name=OptionalGroup" json:"optionalgroup,omitempty"`
}

旁注:为什么这些标签在使用 JSON 时如此常见?因为在 Go 中公共字段必须使用UpperCamelCase,或者至少以大写字母开头,而 JSON 中命名字段的常见约定是lowerCamelCasesnake_case。因此需要繁琐的标记。

标准 JSON 编码器/解码器不允许提供命名策略来自动转换,就像Jackson 在 Java 中所做的那样。这可能解释了为什么 Docker API 中的所有字段都是UpperCamelCase:避免了开发人员为其大型 API 编写这些笨重的标签。

但请注意,JSON 解析器不区分大小写,可能是为了更轻松地读取camelCase常见的 JSON,而不需要注释。但这也意味着写回相同的数据结构将使用默认UpperCamelCase命名,因此会产生不同的 JSON...

没有仿制药......至少不适合你

更新(2020-06):Go 团队提出了一个相当不错的建议,将类型参数添加到 Go 中,这将使本段和下一段在最终登陆该语言后变得过时。我唯一的抱怨是内置comparable约束可以定义为任何类型(而不仅仅是内置类型)都可以实现的常规接口,并且类似地缺乏广泛使用的概念,例如Orderedor Hashable。但这可能会在适当的时候到来。

很难想象一种没有泛型的现代静态类型语言,但这就是 Go 的结果:它没有泛型……或者更准确地说几乎_没有_泛型,正如我们将看到的,这比没有泛型更糟糕。

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

然而,没有用户可定义的通用数据结构。这意味着您无法以类型安全的方式定义可以与任何类型一起使用的可重用抽象。您必须使用无类型interface{}并将值转换为正确的类型。任何错误只会在运行时被发现,并会导致恐慌。对于 Java 开发人员来说,这就像回到Java 之前的 5 次,即 2004 年

在“少即是多”中,Rob Pike 令人惊讶地将泛型和继承放在同一个“类型化编程”包中,并表示他更喜欢组合而不是继承。不喜欢继承是可以的(实际上我写了很多 Scala,但继承很少),但泛型解决了另一个问题:在保持类型安全的同时可重用性。

正如我们将在下面看到的,带有泛型的内置函数和不带泛型的用户定义之间的隔离不仅会影响开发人员的“舒适度”和编译时类型安全性:它还会影响整个 Go 生态系统。

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

Go 生态系统没有很多数据结构可以提供与内置切片和映射不同的功能。Go 的最新版本添加了提供其中一些的容器包。它们都有相同的警告:它们处理interface{}值,这意味着您失去所有类型安全。

让我们看一个示例,sync.Map其中并发映射的线程争用比使用互斥锁保护常规映射要少:

type MetricValue struct {
    Value float64
    Time time.Time
}

func main() {
    metric := MetricValue{
        Value: 1.0,
        Time: time.Now(),
    }

    // Store a value

    m0 := map[string]MetricValue{}
    m0["foo"] = metric

    m1 := sync.Map{}
    m1.Store("foo", metric) // not type-checked

    // Load a value and print its square

    foo0 := m0["foo"].Value // rely on zero-value hack if not present
    fmt.Printf("Foo square = %f\n", math.Pow(foo0, 2))

    foo1 := 0.0
    if x, ok := m1.Load("foo"); ok { // have to make sure it's present (not bad, actually)
        foo1 = x.(MetricValue).Value // cast interface{} value
    }
    fmt.Printf("Foo square = %f\n", math.Pow(foo1, 2))

    // Sum all elements

    sum0 := 0.0
    for _, v := range m0 { // built-in range iteration on map
        sum0 += v.Value
    }
    fmt.Printf("Sum = %f\n", sum0)

    sum1 := 0.0
    m1.Range(func(key, value interface{}) bool { // no 'range' for you! Provide a function
        sum1 += value.(MetricValue).Value        // with untyped interface{} parameters
        return true // continue iteration
    })
    fmt.Printf("Sum = %f\n", sum1)
}

这很好地说明了为什么 Go 生态系统中没有很多数据结构:与内置的切片和映射相比,它们使用起来很痛苦。原因很简单:Go 中有两类数据结构:

  • 贵族,内置切片、映射、数组和通道:类型安全且通用,方便使用range
  • 世界其他地方用 Go 代码编写:无法提供类型安全,由于需要强制转换而使用起来很笨拙。

因此,库定义的数据结构确实必须为我们开发人员提供坚实的好处,让我们愿意付出失去类型安全性和额外代码冗长的代价。

当我们想要编写可重用的算法时,内置结构和 Go 代码之间的二元性在更微妙的方面是痛苦的。sort这是标准库包中用于对切片进行排序的示例:

import "sort"

type Person struct {
    Name string
    Age  int
}

// ByAge implements sort.Interface for []Person based on the Age field.
type ByAge []Person

func (a ByAge) Len() int           { return len(a) }
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }

func SortPeople(people []Person) {
    sort.Sort(ByAge(people))
}

等等……认真的吗?我们必须定义一个新类型ByAge,它必须实现 3 个方法来桥接通用(在“可重用”意义上)排序算法和类型化切片。

对于我们开发人员来说,唯一重要的是_比较_Less两个对象并且与域相关的函数。其他一切都是噪音和样板文件,因为 Go 没有泛型这一简单事实。我们必须对_我们想要排序的每种类型_重复它。还有每个比较器。

更新:Michael Stapelberg指示我排序。我错过了的切片。看起来更好,尽管它在底层使用了反射(哎呀!)并且需要比较器函数作为切片上的闭包来排序,这仍然很丑陋。

每一篇解释 Go 不需要泛型的文本都表明这是“Go 方式”,它允许拥有可重用的算法,同时避免向下转型interface{}......

好的。现在为了减轻痛苦,如果 Go 有可以生成这个无意义的样板的宏就好了,对吧?好吧,请继续阅读...

去生成:好吧,但是......

Go 1.4引入了从源代码中的注释触发代码生成的go generate命令。嗯,这里的“注释”实际上是指具有严格规则的神奇//go:generate注释:“注释必须从行首开始,并且 和 之间没有空格//go:generate。如果出错,添加一个空格,没有工具会警告您。

这涵盖了几种用例:

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

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

  • 添加穷人的泛型支持。由于 Go 没有泛型,因此出现了一些巧妙的创可贴解决方案来从模板代码生成泛型。

第一个用例是可以的,附加值是您不必摆弄Makefiles 并且生成指令可以接近生成代码的用法。

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

许多语言也不支持宏,所以这里没有什么根本性的错误,除了这种脆弱的注释驱动语法,它看起来又像是一个以某种方式完成工作的快速黑客,而不是经过仔细考虑的连贯语言设计。

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

结论

正如你可能猜到的,我对 Go 又爱又恨。Go 有点像你喜欢和他一起出去玩的朋友,因为他很有趣,很适合围绕啤酒闲聊,但当你想进行更深入的对话时,你会觉得无聊或痛苦,并且不想继续下去。假期与.

我喜欢用 Go 来快速开发简单的 API(尽管与 Java 的Jackson或 Rust 的serde相比,JSON 支持相当基本)或 Goroutines 使之易于推理的网络内容,但当我必须实现时,我讨厌它有限的表达能力和半生不熟的类型系统商业逻辑,我讨厌它所有的怪癖和陷阱等待着给你带来沉重打击。Go 需要大量的注意力和纪律来避免它们。

直到最近,Go 所占据的领域还没有真正的替代方案,即开发高效的本机可执行文件,而不会带来 C 或 C++ 的痛苦。Rust进展很快,我玩得越多,就越发现它非常有趣且设计精美。我有一种感觉,Rust 是那些需要一些时间才能相处的朋友之一,但你最终会想与之建立长期关系。

回顾更多技术方面,您会发现一些文章说 Rust 和 Go 不在同一个公园,Rust 是一种系统语言,因为它没有 GC,等等。我认为这种情况正在变得越来越少不太真实。Rust 凭借出色的Web 框架和出色的ORM在堆栈中不断攀升。它还给你一种“如果编译成功,错误将来自我编写的逻辑,而不是我忘记注意的语言怪癖”的温暖感觉。

我们还看到容器/服务网格领域发生了一些有趣的变化,Buoyant(Linkerd的开发人员)开发了新的 Kubernetes 服务网格Conduit,将 Go 用作控制平面(我猜是因为可用的Kubernetes 库)和 Rust 用作控制平面。数据平面的效率和稳健性,以及Sozu proxy

Swift也是这个家族的一部分,或者是 C 和 C++ 的最新替代品。但它的生态系统仍然过于以苹果为中心,即使它现在可以在 Linux 上使用,并且拥有新兴的服务器端 APINetty 框架

当然,没有灵丹妙药,也没有放之四海而皆准的方法。但了解您使用的工具的陷阱很重要。我希望这篇博文能教会你一些你不知道的关于 Go 的事情,这样你就可以避免陷入陷阱而不是被抓住!