其实两年前就接触过Golang,但是当时对Golang的理解仅停留在“基本语法”这一块,没有去比较Golang相对于Java的其他差异,后续也因为使用Java作为主力语言而没有再使用过Golang了。现在从几个角度来和Java进行对比,以便更好地使用及理解Golang,希望本文能对刚入门Golang的朋友有所帮助。如有其他疏漏或错误也望大家不吝赐教。
Golang基本的使用可以参考我之前写的笔记:《Golang核心知识总结》
本文是Golang与Java对比的第二篇,主要对比Golang与Java在面向对象、异常处理、并发编程及垃圾回收方面的差异。
第一篇文章传送门:Golang与Java各方面使用对比(上)
Golang是一门具备面向对象编程风格的语言,但是却不具备Java等传统面向对象语言中“继承(extends)、实现(implements)”的关键字。
在Golang中,通过接口或结构体的组合来实现非严格的“继承”,通过非侵入式的接口来实现非严格的“多态”,通过结构体及包和函数实现了代码细节的“封装”,有了封装、继承与多态,就可以很好地通过OO思维实现与现实需求所对应的程序了。
假设有这么一个场景:动物(Animal)具备名字(Name)、年龄(Age)的基本特性,现在需要实现一个Dog类型,且Dog类型需要具备Animal所需的所有特性,并且自身具备犬吠(bark())的方法,使用Java和Golang来实现该场景会有什么区别呢?
首先来看看最熟悉的Java要如何写,很简单,使用抽象类描述Animal作为所有动物的超类,Dog extends Animal:
Java public abstract class Animal { protected String name; protected int age; } public class Dog extends Animal { public void bark() { System.out.println(age + "岁的" + name + "在汪汪汪..."); } } public class Test { public static void main(String[] args) { Dog dog = new Dog(); dog.name = "tom"; dog.age = 2; dog.bark(); // 2岁的tom在汪汪汪... } }在Golang中,可以这样通过结构体的组合来实现继承:
Golang package oom type Animal struct { Name string Age int } type Dog struct { *Animal } func (d *Dog) Bark() { fmt.Printf("%d岁的%s在汪汪汪...", d.Age, d.Name) } // ---------- package main func main() { dog := &oom.Dog{&oom.Animal{ Name: "tom", Age: 2, }} dog.Bark() // 2岁的tom在汪汪汪... }Golang使用了非侵入式接口来实现“多态”。
Go语言的接口并不是其他语言(C++、Java、C#等)中所提供的接口概念。 在Go语言出现之前,接口主要作为不同组件之间的契约存在。对契约的实现是强制的,你必须声明你的确实现了该接口。为了实现一个接口,你需要从该接口继承:
interface IFoo { void Bar(); } class Foo implements IFoo { // Java文法 // ... } class Foo : public IFoo { // C++文法 // ... } IFoo foo = new Foo;这类接口我们称为侵入式接口。“侵入式”的主要表现在于实现类需要明确声明自己实现了某个接口。这种强制性的接口继承是面向对象编程思想发展过程中一个遭受相当多置疑的特性。
Golang的非侵入式接口不需要通过任何关键字声明类型与接口之间的实现关系,只要一个类型实现了接口的所有方法,那么这个类型就是这个接口的实现类型。
假设现在有一个Factory接口,该接口中定义了Produce()方法及Consume()方法,CafeFactory结构体作为其实现类型,那么可以通过以下代码实现:
package oom type Factory interface { Produce() bool Consume() bool } type CafeFactory struct { ProductName string } func (c *CafeFactory) Produce() bool { fmt.Printf("CafeFactory生产%s成功", c.ProductName) return true } func (c *CafeFactory) Consume() bool { fmt.Printf("CafeFactory消费%s成功", c.ProductName) return true } // -------------- package main func main() { factory := &oom.CafeFactory{"Cafe"} doProduce(factory) doConsume(factory) } func doProduce(factory oom.Factory) bool { return factory.Produce() } func doConsume(factory oom.Factory) bool { return factory.Consume() }可以看到,只要CafeFactory实现了所有的Factory方法,那么它就是一个Factory了,而不需要使用implements关键字去显式声明它们之间的实现关系。
Golang的非侵入式接口有许多好处:
1.在Go中,类型的继承树并无意义,我们只需要知道这个类型实现了哪些方法,每个方法是啥含义就足够了
2.实现类型的时候,只需要关心自己应该提供哪些方法,不用再纠结接口需要拆得多细才合理。接口由使用方按需定义,而不用事前规划
3.不用为了实现一个接口而导入一个包,因为多引用一个外部的包,就意味着更多的耦合。接口由使用方按自身需求来定义,使用方无需关心是否有其他模块定义过类似的接口
一句话总结非侵入式接口的好处就是简单、高效、按需实现。
interface{} 空接口是任意类型的接口,所有的类型都是空接口的实现类型。因为Golang对于实现类型的要求是实现了接口的所有方法,而空接口不存在方法,所以任意类型都可以充当空接口。
以下是一个使用空接口充当参数的类型判断例子:
func getType(key interface{}) string { switch key.(type) { case int: return "this is a integer" case string: return "this is a string" default: return "unknown" } }在Java中通过try..catch..finally的方式进行异常处理,有可能出现异常的代码会被try块给包裹起来,在catch中捕获相关的异常并进行处理,最后通过finally块来统一执行最后的结束操作(释放资源、释放锁)。
而Golang中的异常处理(更贴切地说是错误处理)方式比Java的简单太多,所有可能出现异常的方法或者代码直接把错误当作第二个响应值进行返回,程序中对返回值进行判断,非空则进行处理并且立即中断程序的执行,避免错误的传播。
value, err := func(param) if err != nil { // 返回了异常,进行处理 fmt.Printf("Error %s in pack1.Func1 with parameter %v", err.Error(), param1) return err } // func执行正确,继续执行后续代码 Process(value)Golang引入了一个关于错误处理的标准模式,即error接口,该接口的定义如下:
type error interface { Error() string }对于大多数函数,如果要返回错误,大致上都可以定义为如下模式,将 error 作为多种返回值中的最后一个,但这并非是强制要求:
unc main() { if res, err := compute(1, 2, "x"); err != nil { panic(err) } else { fmt.Println(res) } } func compute(a, b int, c string)(res int, err error) { switch c { case "+" : return a + b, nil case "-": return a - b, nil case "*": return a * b, nil case "/": return a / b, nil default: return -1, fmt.Errorf("操作符不合法") } }当然了,Golang中也可以像Java一样灵活地自定义错误类型,定义PathError结构体,并且实现Error接口后,该结构体就是一个错误类型了:
PathError type PathError struct { Op string Path string Err error } func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() } main func GetStat(name string) (fi FileInfo, err error) { var stat syscall.Stat_t err = syscall.Stat(name, &stat) if err != nil { // 返回PathError错误类型 return nil, &PathError {"stat", name, err} } // 程序正常,返回nil return fileInfoFromStat(&stat, name), nil }这种异常处理方式是Golang的一大特色,外界对这种异常处理方式有褒有贬:
优点:代码清晰,所有的异常都需要被考虑到,出现异常后马上就需要处理缺点:代码冗余,所有的异常都需要通过if err != nil {}去做判断和处理,不能够做到统一捕捉和处理在使用Golang编写代码的过程中,许多方法经常在一个表达式返回2个参数时使用这种模式:,ok,第一个参数是一个值或者nil,第二个参数是true/false或者一个错误error。在一个需要赋值的if条件语句中,使用这种模式去检测第二个参数值会让代码显得优雅简洁。这种模式在Golang编码规范中非常重要。这也是Golang自身的函数多返回值特性的体现。
defer、pannic及recover是Golang错误处理中常用的关键字,它们各自的用途为:
defer的作用是延迟执行某段代码,一般用于关闭资源或者执行必须执行的收尾操作,无论是否出现错误defer代码段都会执行,类似于Java中的finally代码块的作用:
func CopyFile(dst, src string) (w int64, err error) { srcFile, err := os.Open(src) if err != nil { return } // 延迟关闭srcFile defer srcFile.Close() dstFile, err := os.Create(dstName) if err != nil { return } // 延迟关闭dstFile defer dstFile.Close() return io.Copy(dstFile, srcFile) }defer也可以执行函数或者是匿名函数:
defer func() { // 清理工作 } () // 这是传递参数给匿名函数时的写法 var i := 1 defer func(i int) { // 做你复杂的清理工作 } (i)需要注意的是,defer使用一个栈来维护需要执行的代码,所以defer函数所执行的顺序是和defer声明的顺序相反的。
defer fmt.Println(1) defer fmt.Println(2) defer fmt.Println(3)上述执行结果为
3 2 1panic的作用是抛出错误,制造系统运行时恐慌,当在一个函数执行过程中调用panic()函数时,正常的函数执行流程将立即终止,但函数中之前使用defer关键字延迟执行的语句将正常展开执行,之后该函数将返回到调用函数,并导致逐层向上执行 panic流程,直至所属的goroutine中所有正在执行的函数被终止。
panic和Java中的throw关键字类似,用于抛出错误,阻止程序执行。
以下是基本使用方法:
panic(404) panic("network broken") panic(Error("file not exists"))recover的作用是捕捉panic抛出的错误并进行处理,需要联合defer来使用,类似于Java中的catch代码块:
func main() { fmt.Println("main begin") // 必须要先声明defer,否则不能捕获到panic异常 defer func() { fmt.Println("defer begin") if err := recover(); err != nil { // 这里的err其实就是panic传入的内容 fmt.Println(err) } fmt.Println("defer end") }() f() // f中出现错误,这里开始下面代码不会再执行 fmt.Println("main end") } func f() { fmt.Println("f begin") panic("error") //这里开始下面代码不会再执行 fmt.Println("f end") }最后的执行结果为:
main begin f begin defer begin error defer end利用recover处理panic指令,defer必须在panic之前声明,否则当panic时,recover无法捕获到panic。
在Java中,通常借助于共享内存(全局变量)作为线程间通信的媒介,但在Golang中使用的是通道(channel)作为协程间通信的媒介,这也是Golang中强调的:
不要通过共享内存通信,而通过通信来共享内存
在Java中,使用共享内存来进行通信常会遇到线程不安全问题,所以我们经常需要进行大量的额外处理,方式包括加锁(同步化)、使用原子类、使用volatile提升可见性等等。
CSP的指的是是Communicating Sequential Processes (CSP)的缩写,中文为顺序通信进程。CSP的核心思想是多个线程之间通过Channel来通信(对应到golang中的chan结构),这里的Channel可以理解为操作系统中的管道或者是消息中间件(不同之处在于这个MQ是为不同协程间服务的,而不是进程)
说到了CSP就得提一下Golang自身的并发模型MPG,MPG中M指的是内核线程、P指的是上下文环境、G指的是协程,其中M与P一起构成了G可运行的环境,M和P是一一对应关系,通过P来动态地对不同的G做映射和控制,所以Golang中的协程是建立在某个线程之上的用户态线程。
具体的细节譬如MPG如何映射、G的状态有哪些、调度器如何工作等不在此文展开。
在Java中开启一个线程需要创建Thread实现类或Runnable实现类、重写run方法、通过t.start()开启线程执行特定任务,但在Golang中要开启一个Goroutine十分简单,只需使用go这个关键字即可。
// 开启协程执行一段代码 go fmt.Println("go") // 开启协程执行函数 go SomeMethod(1, 1) // 开启协程执行匿名函数 go func() { go fmt.Println("go") }()关于协程,有一些注意点:
main函数运行的协程为主协程,其他协程为主协程的守护协程,当主协程死亡其它协程也会死亡协程在执行完所需执行的方法及代码后会死亡,遇到panic导致程序结束时也会死亡channel是Golang在语言级别提供的goroutine间的通信方式。我们可以使用channel在两个或多个goroutine之间传递消息,因此通过channel传递对象的过程和调用函数时的参数传递行为比较一致,比如也可以传递指针等。
channel是类型相关的。也就是说,一个channel只能传递一种类型的值,这个类型需要在声明channel时指定。
一般channel的声明形式为:
var chanName chan ElementType
与一般的变量声明不同的地方仅仅是在类型之前加了chan关键字。 ElementType 指定这个channel所能传递的元素类型。举个例子,我们声明一个传递类型为 int channel:
var ch chan int
或者,我们声明一个 map ,元素是 bool 型的channel:
var m map[string] chan bool
初始化一个channel也很简单,直接使用内置的函数 make() 即可:
ch := make(chan int)
在channel的用法中,最常见的包括写入和读出。将一个数据写入(发送)至channel的语法很直观,如下:
ch <- value
向channel写入数据通常会导致程序阻塞,直到有其他goroutine从这个channel中读取数据。从channel中读取数据的语法是
value := <-ch
如果channel之前没有写入数据,那么从channel中读取数据也会导致程序阻塞,直到channel中被写入数据为止。我们之后还会提到如何控制channel只接受写或者只允许读取,即单向channel。
channel有如下特性:
读取、写入操作为原子操作,无需担心并发时的数据安全问题,channel内数据的写入对所有协程可见channel中阻塞的协程是FIFO的,严格按照入队顺序读写数据对于非缓冲channel的读取和写入是同步发生的,写入会阻塞直到有读者,读取会阻塞直到有写者,类似于Java中的synchronousqueue;对于缓冲channel的读取和写入是异步的,写入时若队列已满则阻塞,直到有读者、读取时若队列为空则阻塞,直到有写者,类似于Java中的linkedblockingqueue对于为nil的channel的写入和读取都会永久阻塞Java基于JVM完成了垃圾收集的功能,其体系很庞大,包括了垃圾回收器(G1、CMS、Serial、ParNew等)、垃圾回收算法(标记-清除、标记-整理、复制、分代收集)、可达性算法(可达性分析、引用计数法)、引用类型、JVM内存模型等内容,目前Java在JDK 1.7开始使用G1垃圾收集器来进行垃圾回收,其特性及回收过程大致如下:
三色标记法,主要流程如下:
所有对象最开始都是白色从root开始找到所有可达对象,标记为灰色,放入待处理队列遍历灰色对象队列,将其引用对象标记为灰色放入待处理队列,自身标记为黑色处理完灰色对象队列,执行清扫工作要进一步学习可以参考这篇文章:http://legendtkl.com/2017/04/28/golang-gc/
