β

Go语言的美好和丑陋

程序师 1477 阅读

这是一个“ Go不好 ”系列的额外文章。Go确实有一些不错的特性,也就是本文中“好的”部分,但是当我们不使用API或者网络服务器(这是为它设计的)而且将它用于业务领域逻辑的时候,总体而言我发现它用起来笨重且糟糕。但是即使在用于网络编程的时候,在设计和实现方面它也有很多缺陷,这导致它在显而易见的简单的表面之下是危险的。

促使我写这篇文章的原因就是最近我又开始用Go做一个副项目。在我之前的工作我广泛地使用Go来写网络代理(包括http和原生tcp)来做SaaS服务。网络部分相当不错(当时我也是初次尝试这个语言),但是账户和账单部分给我带来了痛苦。由于我的副项目做的是一个简单的API,我觉得Go应该是可以快速完成这个工作的合适的工具,但是就像我们知道的,很多项目会扩张并超过他们的初始范围,因此我不得不写一些数据处理来做统计,然后使用Go就又变得痛苦了。因此下面是我对Go的问题的看法。

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

这是篇长文,所以,这是让你开胃口的菜单目录:

好的

  1. Go易于学习
  2. 易于并发编程的协程(goroutines )和通道(channels)
  3. 强大的标准库
  4. 高性能GO
  5. 程序语言定义的源代码格式
  6. Defer声明,避免忘记清理
  7. 新类型

不好之处

  1. GO忽略现代语言设计的进步
  2. 接口是结构类型
  3. 没有枚举
  4. := / var的困境
  5. (让人)恐慌的零值
  6. Go没有异常

烂的

  1. 依赖关系管理的噩梦
  2. 用语言硬编码的 可变性
  3. Slice陷阱
  4. 可变性和渠道:使竞态条件(race conditions)很容易
  5. 混乱的错误管理
  6. Nil接口值
  7. Struct字段标记:字符串中的运行时DSL
  8. 没有泛型…至少不是为了你
  9. Go在slice和map之外几乎没有什么数据结构
  10. go generate: ok-ish,但是…

结语

优点

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泄露:在结束之前这些goroutines等待一个channel去关闭,但那个channel不会关闭(一个常见的死锁问题)。这个进程平白占了90%的CPU,查看 expvars 显示60万个空的goroutine!我猜CPU都被goroutine调度占用了。

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

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

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

强大的标准库

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

大多数情况下APIs通常是简单易懂的。尽管有时候他们看起来过于简单:这当中,一部分是由于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的命令行实用程序:一个本地可执行的,Go程序对Java的首先必须加载和编译字节码来说没有启动成本。

语言所定义的源代码格式

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

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

标准化的测试框架

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

Go程序非常适合运维

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

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

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

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

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

// use file, we don't have to think about closing it anymore

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

自定义类型

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

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

type UserId string // <-- new type
type ProductId string
 
func AddProduct(userId string, productId string) {}
 
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忽略现代语言设计的进步

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

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

Go的设计者很值得尊敬,但是他们就像在一个平行宇宙(或者他们的Plan9实验室)设计的Go,在那里大多数编译器和编程语言的设计都不是在90年代和2000年代。或者是那些能写编译器的系统编程人员设计了Go。
函数式编程?没有提到它。泛型?你不需要它们,看看它们在C++里产生的混乱吧!哪怕slice,map和channels都是泛型类型,就像接下来我们会看到的。
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的interfaces或者Scala和Rust的traits:他们定义行为,之后才会被一个type(我在这不把他们叫做“class”)实现。
不像Java的interfaces和Scala和Rust的traits,一个type不需要明确定义它实现了一个interface:它必须要实现所有定义在interfaces中的函数。因此Go的interfaces的确是 structural types

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

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

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

没有枚举类型

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

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

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

:= / var 的尴尬

Go提供了两种方法声明和分配给变量一个值:var x = “foo” 和 x := “foo”,为什么这样?
主要区别是:var允许声明而不初始化(那你就必须声明类型),就像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 Go 中作为示例的io.Fileobject:

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
}

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

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

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

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

var m1 = map[string]string{} // empty map
var m0 map[string]string     // zero map (nil)

println(len(m1))   // outputs '0'
println(len(m0))   // outputs '0'
println(m1["foo"]) // outputs ''
println(m0["foo"]) // outputs ''
m1["foo"] = "bar"  // ok
m0["foo"] = "bar"  // panics!

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

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

Go中没有异常

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

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

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

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

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

丑陋的

依赖关系管理的噩梦

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

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

133

有44人在谈论这件事

让我们把它简单化: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)是引用并且是可变的,所以复制包含其中之一的结构体只会将复制指向同一后台内存的指针。

下面的示例说明这一点:

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!
}

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

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

Slice陷阱
Slices带来了很多陷阱:就像在“ Go slices: usage and internals ”中解释的一样,由于一些性能原因,re-slicing一个slice不会复制底层数组。它 的目的是好的,但这意味着一个slice中的子slice仅仅是继承了原始slice的mutations的视图。因此如果你想将子slice与原始的slice区分,不要忘了copy()这个slice。
因为append函数,忘记调用copy()会很危险:如果它没有足够的容量存储新值,在一个slice中append一个值会改变底层数组的大小。这就意味着append的结果可能会也可能不会指向依赖初始化容量的原始的数组。这会导致很难找到不确定的bugs。

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

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???
}

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

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

就像我们之前看到的,在Go中没有方法使用不可变数据结构。这意味着我们使用channel发送一个指针,就玩完了:我们在并发进程共享了可变数据。当然structures(不是指针)的一个channel复制了在channel上发送的值,但是就像我们之前看到的,这不是深度复制引用,包括slices和maps,他们本质上都是可变的。一个interface type的结构字段也是一样:他们是指针,interface定义的任何mutation方法都是通向竞争条件的大门。
因此虽然channels明显让并发编程更简单,他们不阻止在共享数据里的竞争条件。而且slices和maps的本质可变性让这种情况更容易发生。
来说一下竞争条件,Go包含了一个 竞争条件的检测模式 ,这些代码工具是用来寻找未同步的共享访问。它只能在他们出问题的时候检测竞争问题,因此大多都是在集成或负载测试中使用,借此期望产生会引发竞争条件的问题。在生产中,实际上这并不可行,因为它的高运行时代价,除了临时debug sessions。

杂乱的错误管理

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

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

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

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

虽然有一些陷阱,因为错误结果实际上可能只是一个表面上的情况,例如从普遍存在的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>,并且需要对结果进行一些模式匹配。 所以Rust1.0带有try! 宏指令认识到这种模式的普遍性,并做成 一流的语言功能 。 因此,您在保持正确的错误处理的同时保持上述代码的简洁。

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

无接口值

在一次更新后,出现redditor 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 {
        explodes.Bang() // works fine
        explodes.Boom() // panic: value method main.Bomb.Boom called using nil *Bomb pointer
    }
}

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

该pair的第一个元素是指向由Explodes类型所实现的Bomb接口的方法的调度表的指针,第二个元素是实际Explodes对象的地址,该地址为nil。

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

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

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

if explodes != nil && !reflect.ValueOf(explodes).IsNil() {
    explodes.Bang() // works fine
    explodes.Boom() // works fine
}

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

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

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

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

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

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

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

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

当您使用多个库时,情况可能会变得尴尬:下面是从协议缓冲区的 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中,public的字段必须使用大骆驼命名法,或者至少以大写字母开始。然而在JSON中,常见的字段命名习惯用小骆驼命名法或者蛇形命名法。因此需要很多冗长的标签。
JSON编码器和解码器标准不允许提供命名策略来转自动转化,就像 Java中的Jackson文档 。这就解释了为什么在Docker APIs的所有的字段都是大驼峰命名法:避免他的开发人员为他们的大型API写这些麻烦的标签。

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

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

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

然而,没有用户可定义的泛型数据结构。这意味着你无法以类型安全的方式定义可用于任何一个type的可复用abstractions。你必须使用untyped的interface{}并且需要将值转成合适的type。任何错误都只会在运行时捕获,并且产生了panic。作为一个Java开发者,这就像回到了之前 2004年Java5时代
在 “ Less is exponentially more “中,Rob Pike惊人的将泛型和继承放进了同一个“typed programming”包中,说他赞成组合替换继承。不喜欢继承是可以的(事实上,我写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的生态系统中没有太多的数据结构:与内置的slice和map相比它们用起来很痛苦。出于一个简单的原因:Go的数据结构中只有两大类。

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

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

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没有泛型所以出现了模板。我们不得不一次次地重复使用它,包括我们想去排序的每个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语言的美好和丑陋, 感谢原作者分享。

发表评论