比较 Go 和 Java 两种语言

时间:2016-12-07
相对于java,Go语言是编译成为机器码然后直接运行的。很大程度上像C语言一样。因为它没有虚拟机,这一点和java很不一样,本文章向大家介绍一下 Go 和 Java 两种语言的一些特性,需要的朋友可以参考一下。

首先,我想做一个免责声明,我并不是一个Go语言方面的专家。我是几个星期前开始学习的,因此这里只是对第一印象的描述。在这篇文章的一些主观的方面我可能有所错漏。也许我会在晚些时候做一个复核。但在此之前,如果你是一名java程序员,欢迎阅读我的感受和经验,发表评论。如果我有错漏之处,劳烦纠正我。

令人印象深刻的Go语言

相对于java,Go语言是编译成为机器码然后直接运行的。很大程度上像C语言一样。因为它没有虚拟机,这一点和java很不一样。它是面向对象的,同时在某种程度上讲,它不仅仅是一个简单的自动垃圾收集机制加上C的语言。如果我们认为的编程语言世界是线性的,那么Go语言应该介于C和C++之间的(事实上它不是)。从一个java程序员的视角来看,有些东西是如此不同,以致于学习Go语言变成一件极具挑战性的事情,并且可能更深入地理解java的编程语言结构和对象,类还有其他语言部件。

我的意思是如果你了解Go语言中的面向对象实现方式, 那么你也会理解Java语言中关于这些内容的不同实现方式.

如果你对此没有耐心的话,那么简单来说: 也不要让自己被语言的看似怪异的结构吓坏了。学会它,它就会变成你的知识,即使以后你可能不用Go开发项目.

是否使用垃圾回收

内存管理部分在编程语言中是至关重要的. 汇编语言可以让你做所有的事情,或者更确切地说,它要求你做所有的事情. C标准库中提供一些关于内存管理的基础功能,但这仍需要你在调用malloc分配内存后手动释放他们. 自动化内存管理技术随着C++,Python,Swift和Java一起出现。Golang也是他们的其中一员。

Python和Swift使用引用计数.当对象存在引用时,它自己会持有一个计算有多少引用指向它的计数器.这个计数器不可以直接加减,但当一个新的引用取得值而引用到一个对象时,这个计数器计数增加,而当一个引用变成 null/nil以及其他任何为空的形式或者该引用引用了另一个对象时,这个计数器计数减少.所以,显然,当计数器为0时,对象没有引用并可以被清除.这种方式的问题是当计数器为正的时候,对象仍然有可能不可达.比如,对象互相引用形成环,当这个环中的最后一个对象从静态变量,本地变量和其他可达的的引用中释放时,那么这个环就开始像水中的泡泡一样浮在内存中.这些对象的计数器都是正的然而这些对象都不可达了.Swift教程对这种行为及如何避免它进行了很好的阐释.但这一点不会变:比必须以某种方式关注内存管理.

对Java及其他JVM语言(包括Python的JVM实现)来说,内存由JVM来管理.JVM有非常成熟的垃圾回收,垃圾回收一直在一个或更多的线程中运行----与工作线程并行或者有时候暂停这些工作线程(也就是我们说的java中的"世界停止"问题)来标记那些不可达对象,清除它们并将检测到的分散的内存压缩到一起.所有你需要担心的只是性能.

Golang也是使用的这种方式,不过仍然有一些细微的差别.Golang没有引用,但是有指针.这个不同非常重要.Golang可以和额外的C代码集成,出于性能的原因,没有像引用那样在运行时注册的东西.实际的指针对运行系统是透明的.分配的内存仍然可以被分析以收集为可达性的信息并且无用对象仍然可以被标记和清除,只是内存不能被移到别处做压缩.我在文档上没有清楚地明白这点,当我理解了指针的处理时,我曾试图寻找Golang的巫师们做压缩时使用的魔法.我很遗憾地明白,他们也没做到.没有做到这个的魔法.

Golang有垃圾回收但不是像Java那样完整的GC,因为它没有对内存进行压缩.这也不一定是是坏事.(因为)这对服务器长时间运行仍然不会产生内存碎片很有意义.一些JVM垃圾收集器在清楚旧生代时为了减少GC停顿也会跳过压缩步骤,仅仅把压缩作为最后的措施.Go中没有实施这个最后措施的步骤,这在某些罕见的情景中可能会带来一些问题.当你学习这门语言时,你基本不可能会遇到这个问题.

本地变量

在Java语言中,本地变量(以及在更新的版本中某些时候的对象)是存储在栈上的.这在C,C++及其他实现了调用栈本身的语言同样如此.Golang也不例外,除非...

除非你从一个函数中简单地返回了一个指向本地变量的指针.这是C语言中的致命错误.对Go来说,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"
)
/* http://www.manongjc.com/article/1611.html */
func CounterFactory(j int) func() int {
    i := j
    return func() int {
        i++
        return i
    }
}

func main() {
    r := CounterFactory(13)
    fmt.Printf("%d\n", r())
    fmt.Printf("%d\n", r())
    fmt.Printf("%d\n", r())
}

函数返回值

函数不仅可以只返回单个值,还可以返回多个值.如果没有合理使用,这似乎是糟糕的实践.Python是这样的.Perl是这样的.返回多个值在某些情况下很好用.它主要用于返回值和‘nil’或者错误码.这样,过去将错误编码进返回类型(通常是像C的标准库调用一样返回-1作为错误码,其他非负值代表其他意思)的习惯将会被这种可读性更高的方式取代.

在赋值两边的多个值不仅仅可以用于函数.你可以写如下代码去交换两个变量的值:

 a,b = b,a 

面向对象

由于将闭包和函数作为一等公民,Go至少可以实现JavaScript一样的面向对象.但实际上Go有接口和结构体,可以做的更多.不过接口和结构体不是真正的类,它们是值类型.它们通过值传递,并且不论它们存储在内存哪里,它们存储的数据都只是纯的数据,不会有对象头或者任何类似对象头的东西.Go中的结构体与C中的结构体十分类似-它们都可以包含字段,但它们都不可以互相继承或者包含方法.面向对象的实现方式略有不同.

你可以在你定义方法本身时指定结构体,而不是把方法塞进类定义中.结构体还可以包含其他结构体,假如你要引用的字段没有名字,那么你可以通过它的类型引用它,该类型也会隐式地成为该字段的名字.或者只要这个字段或方法属于顶层结构体,你就可以引用它.

列如:

package main

import (
    "fmt"
)

type A struct {
    a int
}

func (a *A) Printa() {
    fmt.Printf("%d\n", a.a)
}

type B struct {
    A
    n string
}

func main() {
    b := B{}
    b.Printa()
    b.A.a = 5
    fmt.Printf("%d\n", b.a)
}

这基本相当于一种继承.

当你要指定可以调用方法的结构体,你可以通过这个结构体自己指定或者指向这个结构体的指针.如果方法应用于结构体,那么该方法会获得调用结构体的副本(这个结构体是通过值传递的).如果方法应用于指向结构体的指针,那么该指针将会被传递(类似通过引用传递).在后一种情况下,方法还可以更改结构体(从这个意义上讲,结构体不是值类型,因为值类型是不可变的).两种都可以用于满足接口的需求.在上面的例子中,Printa应用于指向结构体A的指针.Go说A是这个方法的接收者.

Go的语法对结构体和指针也要宽容些.在C中,你可以用b.a来访问你拥有的结构体的a字段.对于C中指向结构体的指针,你则必须使用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("%d\n", a.a)
    }
}

func main() {
    var a *A = nil
    a.Printa()
}

使得,这就是指针-作为真正Java程序员的你千万不要抓狂.我们确实可以在一个空指针上调用方法!这是怎么做到的?

类型在变量上而不是对象上

这就是我为什么在"对象"上加上引号.当Go存储一个结构体时,它就是一块内存.它没有对象头(然而由于这是实现的问题而不是语言定义的,所以它可能会有,但讲道理的话,它不应有).其实是变量包含了值的类型.如果变量是结构体类型,那么我们在编译时就已经知道了.如果变量是接口类型,那么该变量将会指向其值,同时引用值的实际类型.

实现接口

接口在Go中非常简单,同时又非常复杂(或者这么说,至少与Java中的接口很不同).接口声明了一堆方法,如果结构体想与接口兼容,它们必须实现这些方法.继承接口和继承结构体的方式相同.奇怪的是在结构体实现一个接口时你不需要明确指定.实际上毕竟不是接构体实现了接口,而是这组函数把结构体或者指向结构体的指针用作了接收者.如果接口所有的函数被实现,那么这个结构体实现了这个接口.如果部分函数没有被实现,那么这个实现是不完整的.

为什么我们在Java中需要‘implements’关键字而Go不需要?Go确实不需要是因为它是完全编译型的,而且没有类似Java那样在运行期间分别加载编译代码的类加载器的东西.如果一个结构体应该实现某个接口但却没有实现,那么这个情况将会在编译时被发现,不需要显示地表明该接口是否实现了这个接口.你可以使用Go的反射实现这种情况,这会引起运行时错误,但无论怎样,‘implements’关键字声明起不了任何作用.

简洁的Go

Go代码更紧凑并且语法更宽松.在其他语言中,有些字符根本没用.从C被发明之后过去的40年里,我们习惯了这些字符,所有其他语言也遵循这种语法,但那并不一定意味着这就是最好的方式.我们都知道自从C,在'if'语句中的‘trailing else’问题被通过在代码分支周围使用{和}完美解决 .(或许Perl是第一个有这样要求的主流类C语法语言.)然而,如果我们必须要使用大括号,那么用小括号把条件括起来毫无意义.正如你在上面的代码看到的:

...
    if a == nil {
        fmt.Println("a is nil")
    } else {
        fmt.Printf("%d\n", a.a)
    }
...

小括号是不必要的,Go甚至不允许这样.你或许也注意到了这儿没有分号.你可以使用分号,但是这不是必要的.插入分号是在源代码上的预处理阶段并且插入非常高效.大多数时候,不管怎样,插入顺序是杂乱的.

你可以使用‘:=’ 声明一个新变量并给它赋值.从好的一面来说,这个表达式通常可以定义类型,所以没必要写‘var x typeOfX = expression‘.从另一方面来说,如果你导入了一个包并给一个后来不会用的变量赋值,这是一个bug.由于这可以在编译时检测为代码错误,编译会失败.非常聪明.(然而有时候这非常烦人,比如当我导入了一个我将要用的包,但在引用它之前我保存了代码. IntelliJ IDE则会自动帮我删除这个导入.)

线程和队列

(Go)语言内建了线程和队列。它们被称作协程和通道。要启动一个协程,你仅仅需要编写go函数call(),该函数将在另一个线程中启动。尽管Go标准库中有锁住“对象”的方法/函数,本地多线程编程却是使用的通道。通道在Go中是一个内建类型,该内建类型是其他任何类型的固定大小的先进先出通道。你可以将一个值压入通道中,协程可以从通道里面拉取这个值。如果通道满了,压入会阻塞;在通道是空的情况下,拉取则会阻塞。

Go中有错误,没有异常。是的,叫作Panic!

Go其实有异常处理但缺不是像Java中那样使用的。异常被称作‘panic’,在代码中有一些真正的慌乱状态时使用。从Java的规则来看,‘panic’类似于一些以‘…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编程中惯用的实践完全不符。Go的处理方式非常容易略过某些错误条件并写出忽略这个错误的代码。

package main

import (
    "os"
)

func main() {
    f := os.Open("filename.ext")
    defer f.Close()
}

当我们对一个更长的命令链(如果这些命令中的任一个产生了错误,我们不关心具体是哪个)感兴趣时,检查每一个系统或应用程序调用可能的错误也会很麻烦。

没有Finally,Defer取而代之

紧密耦合异常处理是Java与try/catch/finally特征一起实现的特征。在Java中你可以将无论如何都会执行的代码在finally代码块中。Go提供了关键字‘defer’让你指定一个在方法返回前(即使方法有panic)调用的函数。使用Defer来解决上述问题的方案使得你更不容易滥用.你不能够在deferred一个函数调用后编写任何可执行的代码.但在Java中,你甚至可以在finally块中编写返回声明或者看见非常混乱的try语句用于处理finally块中要执行的代码也可能会抛出异常的情况.Go倾向于前者.我也喜欢前者.

其他一些事

开始时还有些看起来非常奇怪的事:

  • public的函数和变量是大写的,并且没有像‘public’, ‘private’一样的关键词
  • 库的源代码将会被导入项目的代码源(我不确定我理解的是否正确)
  • 缺少泛型
  • 代码生成支持以组件指令的方式内建于语言(这真是wtf)

总的来说,Go是一门有意思的语言。它不是Java的替代品,甚至在语言层面上也不是。Java和Go不是为同类型的任务提供服务——Java是企业级开发语言,Go是系统级编程语言。Go,也和Java一样,正在不断发展中,所以我们未来会看到在各方面的一些改变。