一个 Java 程序员眼中的 Go 语言
首先,我想做个免责声明,我不是 Go 语言专家。几周前我才开始学习,所以本文是我对 Go 的第一印象。文中我的一些主观看法可能是错的。以后我可能会发文再探讨本文的一些观点。在此之前,先看看本文吧。如果你是一个 Java 开发者,很高兴与你分享我的感受和经历,更期待你的留言评论,如果我有一些错误阐述,请不吝指教。
Go 语言令人印象深刻
不同于 Java,Go 编译生成机器码,并被直接执行,非常类似 C。因为它不是一个虚拟机,这与 Java 有着天壤之别。Go 支持面向对象,并在一定程度上支持函数式编程,因此它不仅仅是一种具备自动垃圾回收机制的类 C 语言。如果我们将程序语言发展看作线性的话(事实上不是),Go 介于 C 和 C++ 之间的某种状态。在 Java 开发者看来,Go 是如此的与众不同,以至于学习它本身就是一种挑战。通过对 Go 的学习,可以更深入理解程序语言的构造,对象及类等等都是如何实现的。这些知识在 Java 中同样适用。
我相信,如果你知道 Go 是如何实现面向对象的,你也会明白 Java 以不同的途径实现的一些原因。
免得你觉得我絮絮叨叨,简言之吧:不要被 Go 中看起来怪异的结构吓到,即便你没有项目要用 Go 开发,也去了解它,这会增加你的知识和理解。
GC 还是不 GC,这是个问题
内存管理对于编程语言至关重要。汇编允许你操作所有东西,或者说要求你必须全权处理所有细节更合适。C 语言中虽然标准库函数提供一些内存管理支持,但是对于之前调用 malloc 申请的内存,还是依赖于你亲自 free 掉。从C++、Python、Swift 和 Java 开始,才在不同程度上支持内存管理,Go 语言也是他们中的一员。
Python 和 Swift 采用引用计数方案。当存在一个对象引用时,对象自身持有一个计数器,用于统计有多少个引用指向当前对象。对象中并没有反向引用或指针。当一个引用获取对象的值,并指向这个对象时,计数器自增;当一个引用变为 null/nil/其他值 时,计数器自减。很显然,当计数器为0时,这个对象就没有被引用,可以被作废了。这种方法的问题是,计数器大于0,但是对象却可能已失效。当对象彼此形成环形引用时,通过静态、局部或者其他有效引用释放环中最后一个对象时,整个引用环就悬在内存中,就像气泡悬浮在水中:所有对象的计数器都大于 0,但是所有对象都已失效。Swift 教程对这种情况做了很好的解释,并说明了避免的方法。可惜,结论还是那样:你始终需要在某种程度上关心内存管理。
对于 Java 和其他语言的 JVM (包括 JVM 的Python实现),内存是完全由 JVM 管理的。与工作线程同时运行着 1 个或者多个线程,周期性的运行全局垃圾回收,或者暂停所有线程(众所周知的 stop the world),标记所有失效对象,清理它们,并压缩可能存在的内存碎片。你唯一需要操心的是性能问题。
Go 语言与上述情况大同,又有点小异。Go 中没有引用,只有指针,这是非常重要的区别。Go 语言可以被外部 C 代码集成,出于性能考虑,Go 运行时中也没有类似引用表之类的东西。真实的指针对调用者是不可知的。申请到的内存依然被分析,以获得对象有效性相关信息,无用“对象”依然可被标记和清理,但是内存不能通过移动实现压缩。我在文档中没有找到太多相关信息,由于我理解指针的处理机制,我一直期待 Go 语言存在某种实现内存压缩的天才魔法。我很失望的了解到,它根本没有内存压缩。毕竟,魔法不常有。
Go 包含垃圾回收机制,但是不是跟 Java 一样完整的垃圾回收机制,它不能进行内存压缩。这也未尝是一件坏事。它可以持续运行服务很长一段时间,而且不会产生内存碎片。某些 JVM 垃圾回收器也会跳过内存压缩,以减少垃圾回收造成的服务停顿,直到必要时才执行。Go 语言中,必要时才进行的这一步没有了,在个别情况下可能会引起一些问题。不过在你学习该语言时,不大可能需要考虑这个问题。
局部变量
Java 语言中,局部变量(新版本中,有时候对象也是)被保存在栈中。C、C++等等其他类似实现调用栈的语言也是如此。Go 语言也差不多,除了… …
除了函数可以返回局部变量的指针。这种做法在 C 语言中绝对是致命错误。当 Go 编译器发现被创建的“对象”(晚点晚再解释用引号的原因)将会脱离函数作用域,它会妥善处理这种情况,保证该对象在函数返回后继续存活,其指针不会指向废弃的内存地址,获得不确定的数据。
像这样写是绝对合法的:
package main import ( "fmt" ) type Record struct { i int } func returnLocalVariableAddress() *Record { return &Record{1} } func main() { r := returnLocalVariableAddress() fmt.Printf("%d", r.i) }
闭包
你可以实现一个函数中的函数,然后返回这个函数本身,就像函数式语言一样(Go 也是一种函数式语言),所有的局部变量都将成为闭包中的变量。
package main import ( "fmt" ) func CounterFactory(j int) func() int { i := j returnfunc() int { i++ return i } } func main() { r := CounterFactory(13) fmt.Printf("%dn", r()) fmt.Printf("%dn", r()) fmt.Printf("%dn", r()) }
函数返回值
函数可以返回多个返回值。如果没有谨慎使用,该特性貌似一种糟糕的实践。python 也这么做的,perl 也是,其实是可以善用的。最主要的用法是返回一个值,外加 nil 或者 错误信息。如此,将错误信息编码为无意义的负值这种传统(比如 C 标准库中的做法,通常返回 -1 作为错误码,非负值则表示有意义的返回值),转换为一种更加可读的方式。
多赋值不只用在函数上。可以如下完成一对值的交换。
a,b = b,a
面向对象
由于支持闭包,而且函数是第一类值,Go 语言至少可以做到接近 Javascript 程度的面向对象支持,然而事实远不止如此。Go 语言支持接口和结构体,但是它们不是真正意义上的类,而是值类型。他们通过值传递,数据在内存中保存时,只包含纯粹的数据,没有类头部之类的信息。Go 中的结构体非常像 C——可以包含域(fields),但不能互相扩展,也不能包含函数方法。Go 另辟蹊径支持面向对象。
不同于在类定义中包含方法定义,你可以在定义方法自身时定义结构体。结构体中也可以包含其他结构体,当内部结构体匿名时,其类型隐式的变为名称,你可以直接用其类型名引用内部结构体。或者你可以直接引用内部结构体的一个域或者方法,因为它们都是顶级结构体的成员。
例如:
package main import ( "fmt" ) type A struct { a int } func (a *A) Printa() { fmt.Printf("%dn", a.a) } type B struct { A n string } func main() { b := B{} b.Printa() b.A.a = 5 fmt.Printf("%dn", b.a) }
这就是一个接口了。
当明确哪些方法可以通过结构体调用时,你可以用值或者指针表示结构体。如果通过结构体调用方法,方法将访问结构体的副本(值传递);如果通过结构体指针调用方法,方法将获得结构体的指针(引用传递)。后一种情况下,方法可以操作结构体(此时,结构就不能被认为是一种类型,因为值类型应当是不可变的)。上述方法都可以完整的实现接口。在上面的示例中,通过结构体 A 的指针调用了 Printa 方法,Go 表述为:A 是 Printa 方法的接收者(reviver)。
Go 对结构体和指针的语法也很宽松。在C中,通过结构体时,可以用 b.a 来访问结构体成员;通过结构体指针时,可以用 b->a 访问结构体中同一成员。对于指针,试图用 b.a 访问则是语法错误。Go 认为 b->a 是无意义的(你可以字面意思理解)。为什么要用 -> 使得代码混乱不堪呢,明明可以用 “点” 操作符重载它啊。通过结构体访问成员,与通过指针访问结构体成员等同对待,非常符合逻辑啊。
因为结构体自身与其指针是等效的,你可以这样写:
package main import ( "fmt" ) type A struct { a int } func (a *A) Printa() { if a == nil { fmt.Println("a is nil") } else { fmt.Printf("%dn", a.a) } } func main() { var a *A = nil a.Printa() }
是的,这就是指针——作为一个 Java 开发者,你应该不会觉得奇怪。我们通过一个 nil 指针调用了方法!这是什么情况?
键入值类型,而非对象。
这就是我为什么用引号的“对象”。Go保存的结构体,其实是内存中的一小片区域。其中不存在对象头信息(确实有可能存在,这与具体的实现有关,而非语言本身的规定,通常是没有类头信息的)。变量本身就保存着值的类型信息。如果变量类型是一个结构体,那么在编译阶段这些信息就是已知的。如果变量类型是接口,那么它就成为值的指针,与此同时引用该值真正的类型。
如果变量即不是接口也不是结构体的指针,你无法完成同样的功能:只会得到一个运行时错误。
接口的实现
Go 中的接口实现非常简单,同时也有非常复杂(换言之,至少与 Java 的实现差别很大)。接口定义了一组函数,如果希望结构体可以使用接口,结构体就应当实现这些函数。继承的实现与结构体类似。比较奇特的是,你不需要明确定义即将实现接口的结构体。从根本上讲,与其说结构体实现了接口,不如说接口中的函数将结构体或结构体指针当作接受者(reciver)。如果接口中所有函数都被实现了,那么结构体就实现了这个接口。如果部分函数没有实现,接口的实现就是不完整的。
为什么我们在 Go 中不需要 “implements” 关键字,而 Java 需要呢?Go 不需要它是因为 Go 完全编译的,其中不存在运行时加载独立编译的代码的类加载器。如果一个本来要实现接口的结构体没有实现接口,这个错误会在编译阶段就被发现,不需要明确说明这个结构体会实现接口。如果你使用反射技术(Go 是支持的),你就可以绕过这一点,并引发运行时错误,“implements” 声明对这种做法无能为力。
Go 很清爽
Go 代码非常清爽,令人过目难忘。在其他语言中,存在一些不太常用的字符。 C 发明出来之后的 40 年里,我们逐渐适应了它们,众多语言都在跟随这种语法,但是这并不能说明这种设计是最好的。通过 C 我们都了解,在 “if 表达式” 中使用 “{” 和 “}” 将各代码分支括起来,很好的解决了 “长尾else” (trailing else)问题。(可能 Perl 是第一个使用这种特性的主流类 C 语法的语言)既然如此,如果我们必须有花括号,那就没必要用圆括号将条件语句括起来了。就像你看到下面的代码:
... if a == nil { fmt.Println("a is nil") } else { fmt.Printf("%dn", a.a) } ...
Go 中即不需要,也不允许用圆括号包含条件语句。也许你也发现了,语句中没有分号。你可以使用分号,但是不是必须的。在预编译阶段,它们会被自动插入代码中,非常高效。通常额外书写它们都会带来一些干扰。你可以用 ‘:=’ 声明一个新变量,同时为之赋值。等式的右值通常就可以定义类型,因此没必要编写 ‘var x typeOfX = expression‘。另一方面讲,如果你 import 一个不被使用的包或者定义了一个未用变量,这被认为是个bug。这些在编译阶段就会被检测为代码错误,还是非常智能的(虽然有时候挺闹心,我会 import 一个晚点用到的包,但是在我引用这个包之前,每当我保存代码时, IntelliJ 就会自动帮我删掉这个包)。
线程和队列
线程和队列是 Go 的内建功能。它们被称为 go协程(goroutines) 和 管道(channels)。只要你编写 go functioncall(),这个函数就会以不同的线程运行。 虽然在 Go 库中有对 “对象” 加锁的方法/函数,但是 Go 原生的多线程编程是利用 channels 实现的。channel 是 Go 的内建类型—— 适用于任何类型的固定大小先进先出(FIFO)管道。你可以向 channel 中 push 一个新值,goroutine 则从中 pull 出此值。如果 channel 已满,push 操作阻塞;如果 channel 已空,则 pull 操作阻塞。
只有错误,没有异常。Panic!
Go 有异常处理机制,但是与 Java 中的用法不同。异常被称为 ‘panic’ ,当代码中出现问题的时候会被调用。在 Java 中异常实现以抛出类似 ‘…Error’ 之类的信息实现。当出现可被处理的异常情况或者错误时,错误状态由系统调用返回,然后程序中的函数以如下模式处理。例如:
package main import ( "log" "os" ) func main() { f, err := os.Open("filename.ext") if err != nil { log.Fatal(err) } defer f.Close() }
‘Open’ 函数要么返回文件句柄和nil,要么返回nil和错误码。如果你在 Go Playground 中运行上面的代码(猛戳上面的连接),就会看到错误提示。这种方式跟我们习惯的 Java 编码实践不太匹配。我们可以简单的略去一些错误测试语句,这样写
package main import ( "os" ) func main() { f := os.Open("filename.ext") defer f.Close() }
直接忽略错误就好。对所有的系统或者函数调用,都检查其所有可能的错误是非常繁琐而不必要的,尤其是我们关注很长的调用链时(如果其中任何一个环节出现错误,我们并不关心具体是哪个环节)。
没有 finally,用 Defer。
Java 通过 try/catch/finally 特性实现了紧密耦合的异常处理机制。在 Java 中你可以有一段绝对会在最后执行的代码。Go 通过 ‘defer’ 关键字实现了这个特性,它允许你指定一个函数调用,该函数会在当前方法返回前调用,即使在出现 panic 的情况下也是。这在解决问题的同时,几乎不会给你滥用的机会。你不能在函数里随便写点代码,然后延迟调用该函数。在 Java 中你甚至可以让 finally 代码块返回状态码,或者为了处理 finally 代码块中可能出现的异常,把一切搞得一团混乱。Go 把一切处理的很简洁,我喜欢!
多说几句 …
还有一些第一次看到会觉得诡异的事:
- 公共函数和变量是首字母大写的,Go 没有类似 ‘public’, ‘private’ 的关键字。
- 库的源代码会被导入到工程代码中(我不是很确定我真的明白这个特性)。
- 不支持泛型
- 代码生成特性的支持是语言内建的,以注释指令方式实现。(简直 Bee 了狗)
总而言之,Go 是个有意思的语言。即便在语言层面,Go 也不是 Java 的替代品。Java 和 Go 本不是服务于相同任务的 —— Java 是企业开发语言, Go 则是系统开发语言。Go 和 Java 一样,都在不断的开发中,相信在未来我们会看到更多变化。
本文文字及图片出自 伯乐在线
你也许感兴趣的:
- Go语言有个“好爹”反而被程序员讨厌?
- 【外评】为什么人们对 Go 1.23 的迭代器设计感到愤怒?
- 【译文】Go语言性能从 1.0 版到 1.22 版
- Go 语言程序员的进化
- 【译文】面试时,有人问我喜欢Go语言什么?
- 4 秒处理 10 亿行数据! Go 语言的 9 大代码方案,一个比一个快
- 【译文】Go语言设计:我们做对了什么,做错了什么
- 最好的 Go 框架就是不用框架?
- 吵翻了!到底该选 Rust 还是 Go,成 2023 年最大技术分歧
- “Go 语言的优点、缺点和平淡无奇之处”的十年
什么是企业开发语言?,什么又是系统开发语言呢?