大家好,我是程序员幽鬼。
Go 作为一门相对较新的语言,能够脱颖而出,肯定是多方面的原因。本文聊聊它不同于其他语言的 10 个特性。
Go 的创建者 Robert Griesemer[1] 、Rob Pike[2] 和 Ken Thompson[3] 在 Google 工作,在那里,大规模扩展的挑战激发了他们将 Go 设计为具有大型代码库的项目的快速高效的编程解决方案,由多个开发人员管理,具有严格的性能要求,并跨越多个网络和处理核心。
Go 的创始人在创建新语言时也抓住了这个机会,从其他编程语言的优势,劣势和疏忽中学习。结果是一种干净,清晰和实用的语言,具有相对较小的命令和特性集。
本文将介绍 Go 的 10 个特性,这些特性(根据我个人的观察)将其与其他语言区分开来。
1. Go 始终在构建中包含 runtime
Go 运行时提供内存分配、垃圾回收、并发支持和网络等服务。它被编译进每个 Go 二进制文件。这与许多其他语言不同,其中许多语言使用虚拟机,需要与程序一起安装才能正常工作。
将运行时直接包含在二进制文件中使得分发和运行 Go 程序变得非常容易,并避免了运行时与程序之间的不兼容问题。Python,Ruby 和 JavaScript 等语言的虚拟机也没有针对垃圾回收和内存分配进行优化,这解释了 Go 相对于其他类似语言的优越速度。例如,Go 尽可能多地存储在堆栈[4]上,其中数据按顺序排列,以便比堆[5]更快地访问。稍后将对此进行详细介绍。
关于 Go 的静态二进制文件的最后一件事是,由于不需要运行外部依赖项,因此它们的启动速度非常快。如果你使用像 Google App Engine[6] 这样的服务,这将非常有用,这是一种在 Google Cloud 上运行的平台即服务,可以将你的应用程序扩展到零实例以节省云成本。当有新的请求出现时,App Engine 可以在眨眼间启动你的 Go 程序实例。在 Python 或 Node 中相同的体验通常会导致 3-5 秒的等待(或更长时间),因为所需的虚拟环境也与新实例一起旋转。
2. Go 没有集中托管的程序依赖服务
为了访问已发布的 Go 程序,开发人员不依赖于集中托管的服务,例如用于 Java 的Maven Central[7]或用于 JavaScript 的NPM[8]。相反,项目通过其源代码存储库(通常是 GitHub)共享。go get/install 命令行允许以这种方式下载存储库。
为什么我喜欢这个功能?我一直认为集中托管的依赖服务(如 Maven Central、PIP 和 NPM)有着令人生畏的黑匣子,可能会抽象出下载和安装依赖项(以及依赖项的依赖项)的麻烦,但当依赖项错误发生时,不可避免地会引发可怕的心跳加速(我经历过太多了,无法计数)。
很多时候,我发现令人沮丧的是,我从来没有完全理解它们内部是如何工作的。通过取消中央服务,安装,版本控制和管理 Go 项目的依赖项的过程非常清晰,从而更加清晰。(当然,也有人喜欢集中托管)
此外,将模块提供给其他人就像将其放入版本控制系统中一样简单,这是分发程序的一种非常简单的方法。
3. Go 是按值调用
在 Go 中,当你提供基本类型(数字、布尔值或字符串)或结构(类对象的大致等效项)作为函数的参数时,Go 始终会创建变量值的副本。
在许多其他语言如 Java,Python 和 JavaScript 中,基本类型是通过值传递[9]的,但是对象(类实例)是通过引用传递的,这意味着接收函数实际上接收到指向原始对象的指针,而不是其副本。
这意味着在接收函数中对对象所做的任何更改都将反映在原始对象中。
在 Go 中,结构和基本类型默认按值传递,可以选择通过使用星号运算符传递指针[10]:
// pass by value
func MakeNewFoo(f Foo) (Foo, error) {
f.Field1 = "New val"
f.Field2 = f.Field2 + 1
return f, nil
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
上述函数接收 Foo 的副本,并返回一个新的 Foo 对象。
// pass by reference
func MutateFoo(f *Foo) error {
f.Field1 = "New val"
f.Field2 = 2
return nil
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
上面的函数接收指向 Foo 的指针并改变原始对象。
这种按值调用与按引用调用的明显区别使你的意图显而易见,并减少了调用函数无意中改变传入对象的可能性(这是许多初学者开发人员难以掌握的)。
正如麻省理工学院总结[11]的那样:"可变性使得理解你的程序在做什么变得更加困难,而执行合约也更难"。
更重要的是,按值调用可显著减少垃圾回收器的工作,这意味着更快、更节省内存的应用程序。这篇文章[12]得出的结论是,指针追踪(从堆中检索指针值)比从连续堆栈中检索值慢 10 到 20 倍。要记住的一个很好的经验法则是:从内存中读取的最快方法是按顺序读取它,这意味着将随机存储在 RAM 中的指针数量减少到最低限度。
4. defer 关键字
在 NodeJS 中,在我开始使用knex.js[13]之前,我会在代码中手动管理数据库连接,方法是创建一个数据库池,然后在每个函数的池中打开一个新连接,一旦所需的数据库 CRUD 功能完成,就会在函数结束时释放连接。
这有点像维护的噩梦,因为如果我在每个函数结束时不释放连接,未释放的数据库连接的数量将慢慢增长,直到池中没有更多的可用连接,然后中断应用程序。
现实情况是,程序通常必须发布,清理和执行资源,文件,连接等,因此 Go 引入了defer关键字作为管理这一点的有效方法。
任何前面带有defer的语句都会延迟其调用,直到周围的函数退出。这意味着你可以将清理/拆卸代码放在函数的顶部(很明显),知道一旦函数完成,它就会完成它的工作。
func main() {
if len(os.Args) < 2 {
log.Fatal("no file specified")
}
f, err := os.Open(os.Args[1])
if err != nil {
log.Fatal(err)
}
defer f.Close()
data := make([]byte, 2048)
for {
count, err := f.Read(data)
os.Stdout.Write(data[:count])
if err != nil {
if err != io.EOF {
log.Fatal(err)
}
break
}
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
在上面的示例中,文件关闭方法被延迟。我喜欢这种模式,在函数的顶部声明你的内务管理意图,然后忘记它,知道一旦函数退出,它就会完成它的工作。
5. Go 吸纳了函数式编程的最佳特性
函数式编程是一种高效且富有创造性的范式,值得庆幸的是,Go 采纳了函数式编程的最佳特性。在 Go 中:
— 函数是值,这意味着它们可以作为值添加到 map 中,作为参数传递到其他函数中,设置为变量,并从函数返回(称为"高阶函数",在 Go 中经常用于使用装饰器模式创建中间件)。
— 匿名函数可以创建并自动调用。
— 在其他函数中声明的函数允许闭包(其中在函数内部声明的函数能够访问和修改在外部函数中声明的变量)。在惯用的 Go 中,闭包被广泛使用,限制了函数的作用域,并设置了函数在其逻辑中使用的状态。
func StartTimer (name string) func(){
t := time.Now()
log.Println(name, "started")
return func() {
d := time.Now().Sub(t)
log.Println(name, "took", d)
}
}
func RunTimer() {
stop := StartTimer("My timer")
defer stop()
time.Sleep(1 * time.Second)
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
以上是闭包的一个例子。'StartTimer' 函数返回一个新函数,该函数通过闭包可以访问在其启动作用域中设置的 't' 值。然后,此函数可以将当前时间与 "t" 的值进行比较,从而创建一个有用的计时器。感谢Mat Ryer[14]的这个例子。
6. Go 有隐式接口实现
任何读过SOLID[15]编码和设计模式[16]文献的人都可能听说过 "偏爱组合而不是继承" 的口头禅。简而言之,这表明你应该将业务逻辑分解为不同的接口,而不是依赖于父类中属性和逻辑的分层继承。
另一个流行的方法是 "面向接口编程,而不是实现":API 应该只发布其预期行为的契约(其方法签名),但不能详细介绍如何实现该行为。
这两者都指出了接口在现代编程中的至关重要性。
因此,毫不奇怪,Go 支持接口。事实上,接口是 Go 中唯一的抽象类型。
然而,与其他语言不同,Go 中的接口不是显式实现的,而是隐式实现的。具体类型不声明它实现接口。相反,如果该具体类型的方法集包含基础接口的所有方法集,则 Go 认为该对象实现了该接口。
这种隐式接口实现(正式名称为结构化类型 structural typing)允许 Go 强制实施类型安全和解耦,从而保留了动态语言中表现出的大部分灵活性。
相比之下,显式接口将客户端和实现绑定在一起,例如,在 Java 中替换依赖项比在 Go 中困难得多。
// this is an interface declaration (called Logic)
type Logic interface {
Process(data string) string
}
type LogicProvider struct {}
// this is a method called 'Process' on the LogicProvider struct
func (lp LogicProvider) Process(data string) string {
// business logic
}
// this is the client struct with the Logic interface as a property
type Client struct {
L Logic
}
func(c Client) Program() {
// get data from somewhere
c.L.Process(data)
}
func main() {
c := Client {
L: LogicProvider{},
}
c.Program()
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
LogicProvider 中没有任何声明表明它实现了 Logic 接口。这意味着客户端将来可以轻松替换其逻辑提供程序,只要该逻辑提供程序包含基础接口 (Logic) 的所有方法集。
7. 错误处理
Go 中的错误处理方式与其他语言大不相同。简而言之,Go 通过返回 error 类型的值作为函数的最后一个返回值来处理错误。
当函数按预期执行时,将为 error 参数返回 nil,否则返回错误值。然后,调用函数检查错误返回值,并处理错误,或引发自己的错误。
// the function returns an int and an error
func calculateRemainder(numerator int, denominator int) (int, error) {
// Error returned
if denominator == 0 {
return 9, errors.New("denominator is 0")
}
// No error returned
return numerator / denominator, nil
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
Go 以这种方式运行是有原因的:它迫使编码人员考虑异常并正确处理它们。传统的 try-catch 异常还会在代码中添加至少一个新的代码路径,并以难以遵循的方式缩进代码。Go 更喜欢将"快乐路径"视为非缩进代码,在"快乐路径"完成之前识别并返回任何错误。
8. 并发
并发可以说是 Go 最著名的功能,并发允许在机器或服务器上的可用内核数量上并行运行任务。当单独的进程不相互依赖(不需要按顺序运行)并且时间性能至关重要时,并发性最有意义。I/O 要求通常就是这种情况,其中读取或写入磁盘或网络比除最复杂的内存中进程之外的所有进程慢几个数量级。
函数调用之前的 'go' 关键字将开启并发 goroutine 运行该函数。
func process(val int) int {
// do something with val
}
// for each value in 'in', run the process function concurrently,
// and read the result of process to 'out'
func runConcurrently(in <-chan int, out chan<- int){
go func() {
for val := range in {
result := process(val)
out <- result
}
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
Go 中的并发性是一项深入且相当高级的功能,但在有意义的情况下,它提供了一种有效的方法来确保程序的最佳性能。
9. Go 标准库
Go 具有"电池包含"的理念,现代编程语言的许多需求都融入了标准库中,这使得程序员的生活变得更加简单。
如前所述,Go 是一种相对年轻的语言,这意味着标准库中满足了现代应用程序的许多问题/需求。
首先,Go 为网络(特别是 HTTP/2)和文件管理提供了世界一流的支持。它还提供本地 JSON 编码和解码。因此,设置服务器来处理 HTTP 请求和返回响应(JSON 或其他)非常简单,这解释了 Go 在开发基于 REST 的 HTTP Web 服务方面的受欢迎程度。
正如Mat Ryer[17]还指出的那样,标准库是开源的,是学习 Go 最佳实践的绝佳方式。
10. 调试:Go Playground
使用任何语言进行调试都是一项关键需求。大多数语言都依赖于第三方在线工具或聪明的 IDE 来提供调试工具,使开发人员能够快速检查其代码。Go 提供了 Go Playground — https://go.dev/play 一个免费的在线工具,你可以在其中试用和共享小程序。这是一个非常有用的工具,使调试成为一项简单的练习。
没记错的话,Go 应该开启了 playground 的先河,之后发布的语言也提供类似的功能,比如 Rust 和 Swift。
总结
除了以上介绍的 10 个特性,你认为还有其他特性是 Go 独特的地方吗?
参考资料
[1]Robert Griesemer: https://en.wikipedia.org/wiki/Robert_Griesemer
[2]Rob Pike: https://en.wikipedia.org/wiki/Rob_Pike
[3]Ken Thompson: https://en.wikipedia.org/wiki/Ken_Thompson
[4]堆栈: https://en.wikipedia.org/wiki/Stack-based_memory_allocation
[5]堆: https://www.educba.com/what-is-heap-memory/
[6]Google App Engine: https://cloud.google.com/appengine
[7]Maven Central: https://search.maven.org/
[8]NPM: https://www.npmjs.com/
[9]是通过值传递: https://itnext.io/the-power-of-functional-programming-in-javascript-cc9797a42b60
[10]指针: https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-stacks-and-pointers.html
[11]总结: http://web.mit.edu/6.031/www/fa20/classes/08-immutability/
[12]这篇文章: https://www.forrestthewoods.com/blog/memory-bandwidth-napkin-math/
[13]knex.js: https://knexjs.org/
[14]Mat Ryer: https://twitter.com/matryer
[15]SOLID: https://en.wikipedia.org/wiki/SOLID
[16]设计模式: https://en.wikipedia.org/wiki/Software_design_pattern
[17]Mat Ryer: https://twitter.com/matryer