Go 语言学习笔记--Go介绍

Go(又称 Golang)是 Google 的 Robert Griesemer,Rob Pike 及 Ken Thompson 开发的一种静态强类型编译型语言。Go 语言语法与 C 相近,但功能上有:内存安全,GC(垃圾回收),结构形态及 CSP-style 并发计算

简介

Go(又称Golang)是Google开发的一种静态强类型、编译型、并发型,并具有垃圾回收功能的编程语言

罗伯特·格瑞史莫(Robert Griesemer),罗勃·派克(Rob Pike)及肯·汤普逊(Ken Thompson)于2007年9月开始设计Go,稍后Ian Lance Taylor、Russ Cox加入项目。Go是基于Inferno操作系统所开发的。Go于2009年11月正式宣布推出,成为开放源代码项目,并在LinuxMac OS X平台上进行了实现,后来追加了Windows系统下的实现。在2016年,Go被软件评价公司TIOBE 选为“TIOBE 2016 年最佳语言”。 目前,Go每半年发布一个二级版本(即从a.x升级到a.y)。 [2]



描述

Go的语法接近C语言,但对于变量的声明有所不同。Go支持垃圾回收功能。Go的并行模型是以东尼·霍尔通信顺序进程(CSP)为基础,采取类似模型的其他语言包括OccamLimbo,但它也具有Pi运算的特征,比如通道传输。在1.8版本中开放插件(Plugin)的支持,这意味着现在能从Go中动态加载部分函数。

与C++相比,Go并不包括如枚举异常处理继承泛型断言虚函数等功能,但增加了 切片(Slice) 型、并发、管道、垃圾回收、接口(Interface)等特性的语言级支持。Go 2.0版本将支持泛型,对于断言的存在,则持负面态度,同时也为自己不提供类型继承来辩护。

不同于Java,Go内嵌了关联数组(也称为哈希表(Hashes)或字典(Dictionaries)),就像字符串类型一样。 [2]



撰写风格

在Go中有几项规定,当不匹配以下规定时编译将会产生错误。

  1. 每行程序结束后不需要撰写分号(;)。

  2. 大括号({)不能够换行放置。

  3. if判断式和for循环不需要以小括号包覆起来。

Go亦有内置gofmt工具,能够自动整理代码多余的空白、变量名称对齐、并将对齐空格转换成Tab。 [2]



编译器

当前有两个Go编译器分支,分别为官方编译器gc和gccgo。官方编译器在初期使用C写成,后用Go重写从而实现自举。Gccgo是一个使用标准GCC作为后端的Go编译器。

官方编译器支持跨平台编译(但不支持CGO),允许将源代码编译为可在目标系统、架构上执行的二进制文件。 [2]



优缺点:

优点

Go 很容易学习

这是事实:如果你了解任何一种编程语言,那么通过在「Go 语言之旅」学习几个小时就能够掌握 Go 的大部分语法,并在几天后写出你的第一个真正的程序。阅读并理解 实效 Go 编程,浏览一下「包文档」,玩一玩 Gorilla 或者 Go Kit 这样的网络工具包,然后你将成为一个相当不错的 Go 开发者。

这是因为 Go 的首要目标是简单。当我开始学习 Go,它让我想起我第一次 发现 Java:一个简单的语言和一个丰富但不臃肿的标准库。对比当前 Java 沉重的环境,学习 Go 是一个耳目一新的体验。因为 Go 的简易性,Go 程序可读性非常高,虽然错误处理添加了一些麻烦(更多的内容在下面)。

Go 语言的简单可能是错误的。引用 Rob Pike 的话,简单既是复杂,我们会看到简单背后有很多的陷阱等着我们去踩,极简主义会让我们违背 DRY(Don't Repeat Yourself) 原则。

基于 goroutines 和 channels 的简单并发编程

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

当 Go 程序执行看似阻塞 I/O 的操作时,实际上 Go 运行时挂起了 goroutine ,当一个事件指示某个结果可用时恢复它。与此同时,其他的 goroutines 已被安排执行。因此在同步编程模型下,我们具有了异步编程的可伸缩性优势。

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

我以前的应用程序中有一个 goroutine 漏洞:这些 goroutines 结束之前正在等待一个 channel 关闭,而这个 channel 永远不会关闭(一个常见的死锁问题)。这个进程毫无任何理由吃掉了 90 % 的 CPU ,而检查 expvars 显示有 600 k 空闲的 goroutines! 我猜测 goroutine 调度程序占用了 CPU。

当然,像 Akka 这样的 Actor 系统可以轻松 处理数百万的 Actors,部分原因是 actors 没有堆栈,但是他们远没有像 goroutines 那样简单地编写大量并发的请求/响应应用程序(即 http APIs)。

channel 是 goroutines 的通信方式:它们提供了一个便利的编程模型,可以在 goroutines 之间发送和接收数据,而不必依赖脆弱的低级别同步基本体。channels 有它们自己的一套 用法 模式

但是,channels 必须仔细考虑,因为错误大小的 channels (默认情况下没有缓冲) 会导致死锁。下面我们还将看到,使用通道并不能阻止竞争情况,因为它缺乏不可变性。

丰富的标准库

Go 的 标准库 非常丰富,特别是对于所有与网络协议或 API 开发相关的: http 客户端和服务器,加密,档案格式,压缩,发送电子邮件等等。甚至还有一个html解析器和相当强大的模板引擎去生成 text & html,它会自动过滤 XSS 攻击(例如在 Hugo 中的使用)。

各种 APIs 一般都简单易懂。它们有时看起来过于简单:这个某种程度上是因为 goroutine 编程模型意味着我们只需要关心“看似同步”的操作。这也是因为一些通用的函数也可以替换许多专门的函数,就像 我最近发现的关于时间计算的问题

Go 性能优越

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

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

Go 的垃圾回收器的设计目的是 优先考虑延迟,并避免停机,这在服务器中尤其重要。这可能会带来更高的 CPU 成本,但是在水平可伸缩的体系结构中,这很容易通过添加更多的机器来解决。请记住,Go 是由谷歌设计的,他们从不会在资源上面短缺。

与 Java 相比,Go 的垃圾回收器(GC)需要做的更少:切片是一个连续的数组结构,而不是像 Java 那样的指针数组。类似地,Go maps 也使用小数组作为 buckets,以实现相同的目的。这意味着垃圾回收器的工作量减少,并且 CPU 缓存本地化也更好。

Go 同样在命令行实用程序中优于 Java :作为本地可执行文件,Go 程序没有启动消耗,反之 Java 首先需要加载和编译的字节码。

语言层面定义源代码的格式化

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

不管你喜欢与否,gofmt 定义了如何对代码进行格式化,一次性解决了这个问题。

标准化的测试框架

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()
// 用文件资源的时候,我们再也不需要考虑何时关闭它

当然,Java的 试用资源 没那么冗长,而且 Rust 在其所有者被删除时会 自动声明资源,但是由于 Go 要求您清楚地了解资源清理情况,因此让它接近资源分配很不错。

新类型

我喜欢类型,因为有些事情让我感到恼火和害怕,举个例子,我们到处把持久对象标识符当做 string 或 long 类型传递使用。 我们通常会在参数名称中对 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")
 // 正确的顺序: 没有问题
 AddProduct(userId, productId)
 // 错误的顺序:将会编译错误
 AddProduct(productId, userId)
 // 编译错误:
 // AddProduct 不能用 productId(type ProductId) 作为 type UserId的参数
 // Addproduct 不能用 userId(type UserId) 作为type ProfuctId 的参数
}

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

缺点

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

少既是多中,Rob Pike 解释说 Go 是为了在谷歌取代 C 和 C++,它的前身是 Newsqueak ,这是他在80年代写的一种语言。Go 也有很多关于 Plan9 的参考,Plan9 是一个分布式操作系统,在贝尔实验室的80年代开发的。

甚至有一个直接从 Plan9 获得灵感的Go 汇编。为什么不使用 LLVM 来提供目标范围广泛且开箱即用的体系结构?我此处可能也遗漏了某些东西,但是为什么需要汇编?如果你需要编写汇编以充分利用 CPU ,那么不应该直接使用目标 CPU 汇编语言吗?

Go 的创造者应该得到尊重,但是看起来 Go 的设计发生在平行宇宙(或者他们的 Plan9 lab?)中发生的,这些编译器和编程语言的设计在 90 年代和 2000 年中从未发生过。也可能 Go 是由一个会写编译器的系统程序员设计的。

函数式编程吗?不要提它。泛型?你不需要,看看他们用 C++ 编写的烂摊子!尽管 slice、map 和 channel 都是泛型类型,我们将在下面看到。

Go 的目标是替换 C 和 C++,很明显它的创建者也没有关注其他地方。但他们没有达到目标,因为在谷歌的 C 和 C++ 开发人员没有采用它。我的猜测是主要原因是垃圾回收器。低级别 C 开发人员强烈拒绝托管内存,因为他们无法控制什么时间发生什么情况。他们喜欢这种控制,即使它带来了额外的复杂性,并且打开了内存泄漏和缓冲溢出的大门。有趣的是,Rust 在没有 GC 的情况下采用了完全不同的自动内存管理方法。

Go 反而在操作工具的领域吸引了 Python 和 Ruby 等脚本语言的用户。他们在 Go 中找到了一种方法,可以提高性能,减少 内存/cpu/磁盘 占用。还有更多的静态类型,这对他们来说是全新的。Go 的杀手级应用是 Docker ,它在 devops 世界中引起了广泛的应用。Kubernetes 的崛起加强了这一趋势。

接口是结构类型

Go 接口就像 Java 接口或 Scala 和 Rust 特性(traits):它们定义了后来由类型实现的行为(我不称之为“类”)。

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

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

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

  • 找到实现给定接口的类型很难,因为它依赖于函数定义匹配。我通过搜索实现接口的类,经常发现 Java 或 Scala 中有趣的实现。
  • 当向接口添加方法时,只有当它们用作此接口类型的值时,才会发现哪些类型需要更新。 相当一段时间这可能被忽视。 Go 建议使用非常少的方法构建小型的接口,这是防止这种情况的一种方式。
  • 类型可能在不知不觉中实现了一个接口,因为它作为相应的方法。但是偶然的,实现的语义可能与接口契约所期望的不同。

更新 : 对于接口的一些丑陋问题,请参阅下面的 无接口值(nil interface values)

没有枚举

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

iota 可以快速生成自动递增的值,但它看起来更像一个技巧 而不是一个特性。实际上,由于在一系列的 iota 生成的常量中插入一行会改变下列值的值,这是很危险的。由于生成的值是在整个代码中使用的值,因此这会导致有趣的(而不是!)意外。

这也意味着没有办法让编译器彻底检查 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" 即使 "someCondition" 为真

零值 panic

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

在实践中,许多类型在没有正确初始化的情况下不能做有用的事情。让我们看一下 io.File 对象,从 实效 Go 编程 取出的一个例子:

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
}

我们能发现什么?

  • 在一个零值 File 调动 Name() 将会导致 panic ,因为它的 file 字段是 nil
  • Read 函数和 几乎所有其他File 方法,首先检查文件是否被初始化。

因此,零值 File 不仅无用,还会导致 panics。你必须使用一个构造函数,比如 Open 或 Create。检查正确的初始化是每个函数调用中需要花费的开销。

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

还有一个很严重的问题: map 的零值:你可以查询它,但是在它里面存储东西会导致 panic:

var m1 = map[string]string{} // 空值 map
var m0 map[string]string     // 零值 map (nil)
println(len(m1))   // 输出 '0'
println(len(m0))   // 输出 '0'
println(m1["foo"]) // 输出 ''
println(m0["foo"]) // 输出 ''
m1["foo"] = "bar"  // 没问题
m0["foo"] = "bar"  // panics!

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

因此,作为一个开发人员,您必须经常检查您想要使用的结构是否需要调用构造函数,或者零值是否可用。这是语言简化对编程带来的沉重负担。

Go 没有异常。哦,等一下……它有!

这篇博客文章「为什么 Go 获得异常的方式是对的」详细解释了为什么异常是糟糕的,为什么Go方法要求返回 错误 是更好的。我可以同意这一点,确实在使用异步编程或像 Java 流这样的函数风格时,异常是很难处理的(前者可以放到一边,由于 goroutines 的原因它在 Go 中是没有必要的,而后者几乎是不可能)。这篇博文提到 panic 「总是对你的程序抛出致命错误,游戏结束」,这很不错。

在此之前,「Defer, panic and recover」 解释了如何从 panic 中恢复(实际上是通过捕获它们),并提到在 go 标准库中 json 包可以看到 panic 和 recover 的真实使用。

事实上, json 解码器有一个 共同的错误处理函数 去 panics,panic 在顶层 unmarshal函数中恢复(recover),检查panic类型 并返回一个错误如果它是一个“本地 panic ”或其它错误再次触发的 panic( 失去最初的 panic 的追溯)。

对于任何 Java 开发人员来说,这明显看上去是一个try / catch (DecodingException ex)。所以 Go 确实有异常处理,它在内部使用了却告诉你不要用。

有趣的事实:几个星期前,一个非谷歌的人修复了 json 解码器,以使用常规的错误冒泡处理。

令人厌恶的点

依赖管理噩梦

首先引用一个在谷歌著名的 Go 语言使用者 Jaana Dogan (aka JBD) 的话,最近在推特上发泄她的不满:

如果依赖管理再过一年还没有解决,我将会考虑退出 Go 并且永远不会回来。 依赖性管理问题经常颠覆我从语言中获得的所有乐趣。
— JBD (@rakyll)  March 21, 2018 简单点说,Go 中没有依赖管理。当前所有的解决方案都只是一些技巧和变通方法。

这要追溯到它的起源 -- 谷歌,以使用了一个 巨大的单片存储库 管理所有源代码而闻名。不需要模块的版本控制,也不需要第三方模块的仓库,你可以从当前分支构建任何项目。不幸的是,这在开放的互联网上是行不通。

在 Go 中添加依赖意味着将依赖的源代码仓库克隆到你的 GOPATH 下。版本是什么?克隆当前的主分支就行了,管它写的是什么。但是如果不同的项目需要不同的版本依赖呢?他们做不到。因为「版本」的概念根本不存在。

另外,您自己的项目必须在 GOPATH 下,否则编译器无法找到它。想让你的项目在单独的目录里清晰地组织起来?那你必须配置每一个项目的 GOPATH ,或者使用符号链接。

社区已经开发了 大量的工具 解决此问题。包管理工具引入了 vendoring 和锁文件来保存您克隆的任何仓库的Git sha1,以提供可复现的构建。

最后,在Go 1.6中,vendor 目录得到了官方支持。但它是关于你克隆的 vendoring,仍然不是正确的版本管理。没有对从传递依赖中导入发生冲突的解决方案,这通常是通过 语义化版本 来解决的。

不过,情况正在好转:dep官方的依赖管理工具 最近被引入以支持文件控制(vendoring)。它支持版本(git tags),并有一个遵循语义版本控制约定的版本解决程序。它还不稳定,但方向是正确的。然而,它仍然需要你的项目存放在 GOPATH 里。

但是 dep 可能不会像 vgo长久,vgo 是谷歌发起的,想要从语言本身带来版本控制,并且最近已经引起了一些波动。

所以 Go 的依赖管理是噩梦。设置起来很痛苦,当你在开发的时候你不会考虑到它,直到你添加一个新的导入(import)或者只是简单地想拉取你团队成员的一个分支到你的 GOPATH 里的时候,程序崩溃了...

现在让我们再次回到代码的问题上。

易变性是用语言硬编码的。

在 Go 中没有定义不可变结构的方法: struct 字段是可变的,const 关键字不适用于它们。Go 通过简单的赋值就可以轻松地复制整个struct,因此,我们可能认为,通过值传递参数来保证不变性,只需要复制的代价。

然而,不出所料的,它不复制指针引用的值。而且由于内置的集合(map、slice 和 array)是引用和可变的,复制包含其中任意一项的 struct 只是复制了指向底层内层的指针。

下面的例子说明了这个问题:

type S struct {
 A string
 B []string
}
func main() {
 x := S{"x-A", []string{"x-B"}}
 y := x // 复制 struct
 y.A = "y-A"
 y.B[0] = "y-B"
 fmt.Println(x, y)
 // 输出 "{x-A [y-B]} {y-A [y-B]}" -- x 被修改!
}

所以你必须非常小心,如果你通过值传递参数,不要认定它就是不变的。

有一些 深度复制库 试图使用(慢)反射来解决这个问题,但是它们有不足之处,因为私有字段不能通过反射访问。因此,为了避免竞争条件而进行防御性复制将会很困难,需要大量的重复代码。Go甚至没有一个可以标准化这个的克隆接口。

切片(slice)陷阱

切片带来了很多问题。正如「Go slice: usage and internals」中所解释的那样,考虑到性能原因,再次切片一个切片不会复制底层的数组。这是一个值得赞赏的目标,但也意味着切片的子切片只是遵循原始切片变化的视图。因此,如果您想要将它与初始的切片分开请不要忘记 copy()

对于 append 函数,忘记 copy() 会变得更加危险:如果它没有足够的容量来保存新值,底层数组将会重新分配内存和大小。这意味着 append 的结果能不能指向原始数组取决于它的初始容量。这会导致难以发现的不确定 bugs。

在下面的代码中,我们看到为子切片追加值的影响取决于原始切片的容量:

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"} // 长度 1, 容量 1
 doStuff(slice1)
 // Output:
 // value=[a] -- ok
 // value=[a], value2=[a b] -- ok: value 未改变, value2 被更新
 // value=[a], value2=[z b] -- ok: value 未改变, value2 被更新
 slice10 := make([]string, 1, 10) // 长度 1, 容量 10
 slice10[0] = "a"
 doStuff(slice10)
 // Output:
 // value=[a] -- ok
 // value=[a], value2=[a b] -- ok: value 未改变, value2 被更新
 // value=[z], value2=[z b] -- WTF?!? value 改变了???
}

易变性和 channels: 竞争条件更容易发生。

Go 并发性是 通过 channels 建立在CSP 上的,它使用 channel 使得协调 goroutines 比在共享数据上同步更简单和安全。老话说的是「不要通过共享内存来通信;而应该通过通信来共享内存」。这是一厢情愿的想法,在实践中是不能安全实现的。

正如我们在上面看到的那样,Go 没办法获得不可变的数据结构。这意味着一旦我们在 channel 上发送一个指针,游戏就结束了:我们在并发进程之间共享了可变的数据。当然,一个 channel 的结构是赋值 channel 传送的值(而不是指针),但是正如我们在上面看到的,这些没有深度复制引用,包括 slices 和 maps 本质上都是可变的。与接口类型的 struct 字段相同:它们是指针,接口定义的任何可变方法都是对竞争条件的开放。

因此,尽管 channels 表面上使并发编程变得容易,但它们并不能阻止共享数据上的竞争条件。而 slices 和 maps 本身的可变性使这种情况更有可能发生。

谈到竞争条件时,Go 包含一个 竞争条件检测模式,该模式检测代码以找到不同步的共享访问。它只能在事件发生的时候检测到竞争问题,所以大多数情况下是在集成或负载测试期间,希望这些能够运行比赛条件。由于它的高运行时成本(除了临时的调试会话),它不能实际应用于生产环境。

嘈杂的错误管理

你可以很快学会 Go 的错误处理模式,重复到令人作呕:

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

因为 Go 声称不支持异常(虽然它已经支持),每个能够以错误结尾的函数都必须把错误作为其最后一个结果。这特别适用于执行某些 I/O 的每个函数,因此这种啰嗦的模式在网络应用程序中非常普遍,这是 Go 的主要领域。

您很快就会忽视这种模式,并将其识别为「好,错误处理了」,但是仍然很杂乱,有时很难在错误处理中找到实际的代码。

这里有几个问题,因为一个错误的结果可能有名无实,例如当从无所不在的 io.Reader读取时:

len, err := reader.Read(bytes)
if err != nil {
 if err == io.EOF {
  // 一切正常,文件结尾
 } else {
  return err
 }
}

在“Error has values”中,Rob Pike 提出了一些减少错误处理冗余的策略。我发现它们实际上是危险的创可贴:

type errWriter struct {
 w   io.Writer
 err error
}
func (ew *errWriter) write(buf []byte) {
 if ew.err != nil {
  return // 当已经出错时,什么都不写入
 }
 _, 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])
 // 等等
 if ew.err != nil {
  return ew.err
 }
}

基本来说,一直检查错误是很痛苦的,所以这里提供了直到结束之前都会忽略错误的方法。任何写入操作一旦出错它还是会执行,即使我们知道不该再执行了。如果这样做资源消耗更高呢?我们刚刚浪费了资源,因为 Go 的错误处理是一种痛苦。

Rust 有类似的问题:没有异常(真的没有,跟 Go 相反),方法失败返回 Result<T, Error>,并且需要对结果模式匹配。所以,Rust1.0 添加了 try! 宏并且认识到这一模式的普遍性,使它成为 一流的语言特征。因此,在保持正确的错误处理的同时,也有上述代码的简洁性。

由于 Go 没有泛型和宏,所以很不幸地,更换为 Rust 的方法是不可能的。

Nil 接口值

这是在看到 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 {
  println("Not nil!") // 'Not nil!' 我们为什么会走到这里?!?!
  explodes.Bang()     // 运行正常
  explodes.Boom()     // panic: value method main.Bomb.Boom called using nil *Bomb pointer
 } else {
  println("nil!")     // 为什么没有在这里结束?
 }
}

上面的代码验证了 explodes 不是nil,但是代码在 Boom 中 panics,在 Bang 中没有。这是为什么呢?解释在 println 这一行:bomb 指针是 0x0,它实际上是 nil,但是 explodes 是非nil (0x10a7060,0x0)

这两个元素的第一个元素是通过 Explodes 类型来实现 Bomb 接口的方法分派表的指针,第二个元素是实际 Explodes 对象的地址,它是 nil

对 Bang 的调用成功是因为它需要传递的是 Bomb 指针:没有必要取消指针来调用方法。Boom 方法作用于一个,因此调用会导致指针取消引用,这会造成 panic。

注意,如果我们写了 var explodes Explodes = 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!
}

这是漏洞还是特性? Go语言之旅 有一个 专门的页面 来解释这种行为,并清楚地表示 「注意,一个具有nil值的接口值本身就是非空值」

尽管如此,这仍然是丑陋的,并且会导致非常细微的错误。在我看来,这是语言设计中的一个很大的缺陷,只是为了使它的实现更加容易。

Struct 字段标记:字符串中的运行时DSL。

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

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

这些是 结构标记(struct tags),语言规范说这是一个字符串「通过反射接口可见,并参与结构的类型标识,但是却被忽略了」。所以,基本上,把你想要的东西放到这个字符串中,并在运行时使用反射来解析它。如果语法不对,运行时就会出现 panic。

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

为什么要决定使用一个原始字符串,任何库都可以决定使用它想要的任何 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 公共字段中,必须使用大写字母,或者至少以大写字母开头,而在 JSON 中命名字段的常见约定是小写的 camelcase 或 snake_case。因此需要进行冗长的标记。

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

没有泛型…至少不是为了你。

很难想象一种没有泛型的现代静态类型化语言,但这就是你在 Go 中看到的:它没有泛型...或者更精确地说,几乎没有泛型,我们会看到它比没有泛型更糟糕。

内置的 slice、map、array和 channel 都是泛型。声明一个 map[string]MyStruct 清楚地显示了具有两个参数的泛型类型的使用。这很好,因为它允许类型安全编程捕获各种错误。

然而,没有用户可定义的泛型数据结构。这意味着您不能定义可重用的抽象,它可以以类型安全的方式使用任何类型。您必须使用非类型 interface{},并将值转换为适当的类型。任何错误只会在运行时被抓住,会导致 panic。对于 Java 开发人员来说,这就像回到 回退 Java 5 个版本到 2004 年

在「少即是多」中,Rob Pike 意外地将泛型和继承放在同一个「类型编程」包中,并说他喜欢组合而不是继承。不喜欢继承很好(实际上我写了很多没有继承的Scala),但是泛型回答了另一个问题:可重用性,同时保护类型安全。

正如下面我们将看到的,在用泛型做内部构建和用户无法定义泛型之间的区别会对开发人员「舒适」和编译时类型安全产生更多的影响:它会影响整个 Go 生态系统。

Go 在 slice 和 map 之外几乎没有什么数据结构。

Go 生态系统没有很多数据结构,它们可以从内置的 slices 和 maps 中提供额外或不同的功能。Go 最新版本添加了提供其中几个容器包。它们都有相同的警告:它们处理 interface{} 值,这意味着您将失去所有类型的安全性。

让我们来看一个 snc.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,array 和 channel:类型安全和泛型,方便使用 range
  • 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 种方法来实现通用的(严格讲是「可重用」)排序算法和类型切片。

对我们开发人员来说唯一重要的是,用较少的函数比较两个对象,并且是域依赖的。其他的东西都是噪音和重复,简单的事实是,Go 没有泛型。我们需要对每一种我们想排序的类型重复它,每个比较器也是。

更新: Michael Stapelberg 指出我忘记了 sort.Slice。看起来好多了,尽管它在 hood (eek!)下使用反射,并要求比较器函数在切片上做一个闭包去排序,这仍然很难看。

每个解释 Go 不需要泛型的文章,把这个当做「Go 的方式」,它允许使用可重用的算法,同时避免向下转换到 interface{}...

好了。为了缓解疼痛,如果 Go 有可以生成这个无意义的样板的宏就好了,对吗?

go generate,还说得过去,但是...

Go 1.4 介绍了 go generate 命令,从源代码的注释中触发代码的生成。嗯,这里的「注释」实际上指的是 //go:generate 的注释,需要符合严格的规则:「注释必须从行开始处开始,在 // 和 go:generate 之间没有空格」。如果写错了,加了一个空格,没有工具会警告你出错了。

这实际上涵盖了两种应用场景:

  • 从其他来源生成 Go 代码:ProtoBuf / Thrift / Swagger 模式,语言语法,等等。
  • 生成的 Go 代码补充了现有的代码,例如作为示例的 stringer ,它为一系列类型常量生成 String() 方法。

第一个用例是可以的,附加价值是你不需要摆弄 makefiles,而生成的说明可以接近生成的代码的用法。

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

许多语言也不支持宏,所以在这里没有什么根本的错误,除了这个脆弱的由逗号驱动的语法,它看起来像一个快速的技巧,以某种方式完成工作,而不是作为清晰的语言设计被慎重考虑。

哦,你知道 Go 编译器实际上有 很多注释/程序 和 条件编译 使用这个脆弱的注释语法吗?

参考:

Go 语言的优点,缺点和令人厌恶的设计 - 知乎

go(计算机编程语言)_百度百科

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值