阅读《Go程序设计语言》:从Java的视角掌握Go(一)

本人在阅读《Go程序设计语言》时,通过比较Java和Go的不同来快速掌握Go语言。

程序结构

名称

Go和Java一样,存在一定数量的关键字,不能被用作名称,但Go有三十多个内置的预声明常量、类型和函数,它们不是预留的,是可以重声明的;

和Java不同的是,Go没有复杂的访问修饰符,基本上只包括函数内访问(这里暂时不考虑闭包)、包访问、跨包访问。实体在函数内声明就只在该函数内部局部有效,函数外声明则对包里的所有源文件可见,如果名称以大写字母开头则支持跨包访问。

Go的编程风格偏向于短名称,特别是作用域小的局部变量,名称作用域越大,越使用长和更有意义的名称;

缩写词通常使用统一的大小写,比如HTML或者html,但不用Html;

变量

变量的声明形式和Java存在一定的区别,如下所示:

1
var name type = expression

type和expression不能同时省略,当expression省略时,Go存在零值机制保障。

在Java中,如果是类变量,在连接的准备阶段会赋类型的零值(暂不讨论static final修饰的变量,后续会和const一起讨论),类初始化的时候会赋上声明的初始值;如果是类的普通变量,在本站内的文章Java虚拟机之对象探秘有介绍,new等编译成的指令为普通字段赋上该类型的零值,但初始化器会在每个构造方法中被执行,而且是在显式写进构造方法内的内容执行前执行;如果是方法内的局部变量,必须手动赋值,否则不能使用;

Go和Java不同的是,Go不存在未初始化的变量,也就是前面提到的,它具有零值机制保障;

Go和Java不同的是,在函数中存在短变量声明,用来声明和初始化局部变量;

:=表示声明,=表示赋值,但是如果:=左边存在多变量,并不要求全部声明这些变量(但也要保证声明的变量至少有一个),对其中已经声明过的变量来说,:=相当于=,例如:

1
2
3
4
5
6
func main() {
var i int
a, i := 1, 4
fmt.Println(i)
fmt.Println(a)
}

i在a声明之前已然声明过,所以在执行a, i := 1, 4时,i相当于赋值为4,而非再次声明;

指针

与Java不同的是,Go语言存在指针,指针的值是变量的地址,并非所有的值都有地址,但所有变量都有地址;

借用书中的例子:

1
2
3
4
5
x := 1
p := &x // p是整型指针,只想x
fmt.Println(*p) // "1"
*p = 2 // 等于 x = 2
fmt.Println(x) // 结果 "2"

Go语言的指针形式上类似C指针,但是却也存在明显区别:Go指针可以比较,但是不能运算;

书中提到“每次使用变量的地址或者复制一个指针,我们就创建了新的别名或者方式来标记同一变量”。所以在本人看来,Go指针形式上和C指针相近,但是行为上更像Java的引用;

地址符加上变量名构成的表达式不能被赋值:

1
2
3
4
5
6
7
8
9
func main() {
var i int
var ii *int
a, i := 1, 4
ii = &i
&a = ii // 出错
fmt.Println(i)
fmt.Println(a)
}

这段代码通过了GoLand的语法检查,但build会出现cannot assign to &a的错误;

new函数

和Java不同的是,Go可以采用new函数创建未命名的某个类型的变量,受零值机制保障,返回其地址,使用new创建变量和一般方法创建相比没有什么不同,只是语法上看似便利罢了;

变量生存周期

首先是变量指代的内存被回收的问题。

在Java中,一切都是对象,垃圾回收与否取决于对象是否是根对象,根对象大体上来说包含以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中的引用的对象;
  • 方法区中的类静态属性引用的对象;
  • 方法区中的常量引用的对象;
  • 本地方法栈中JNI引用的对象;

Go与Java不同的是,它没有对象的概念,但判断变量是否要被垃圾回收器回收的方式和Java一样,皆不是采用的引用计数,具体来说是一种Mark Sweep。包级别的变量,以及每个当前执行函数的局部变量,类似Java中的所谓根对象,可以理解为是“根变量”;

Java所谓的闭包,采用的是capture-by-value,而非capture-by-reference,具体做法是在内部类或者Lambda表达式里将引用的外部变量的值拷贝一份,所以为了引用一致以及线程安全,外部变量会被设定为final,或者如Java 8中那样,隐式设定为final;

Go是capture-by-reference的,下面给出一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func add() func() uint{
i := uint(0)
return func() (ret uint) {
ret = i
i += 2
return
}
}
func main() {
add0 := add()
fmt.Println(add0())
fmt.Println(add0())
fmt.Println(add0())
}

输出为:

1
2
3
0
2
4

i是add()的局部变量,但是它从add中逃逸出来,所以是在堆空间分配的内存,add返回的匿名函数会获取i的地址,并在方法体内进行赋值与运算;

赋值

HotSpot虚拟机采用的是直接指针访问,不同变量传递的是字符串对象的地址;

Go语言采用的是值传递,例子见下:

1
2
3
4
5
6
7
8
func printAddr() {
x := "Hello"
fmt.Println(&x)
y := x
fmt.Println(&y)
x = y
fmt.Println(&x)
}

输出为:

1
2
3
0xc42007a1d0
0xc42007a1e0
0xc42007a1d0

Go打印的是变量的地址,传递的是变量内承载的值,接下来看看这里的值,指的是什么。

扩展前面的例子,添加代码:

1
2
3
4
5
6
7
8
lenx := unsafe.Sizeof(x)
fmt.Println(lenx)
p01 := (* uint32)(unsafe.Pointer(uintptr(unsafe.Pointer(&x))))
fmt.Println(*p01)
p02 := (* uint32)(unsafe.Pointer(uintptr(unsafe.Pointer(&y))))
fmt.Println(*p02)
plen := (* uint8)(unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + 8))
fmt.Println(*plen)

打印为:

1
2
3
4
16
17558375
17558375
5

可以看出,x变量的大小为16个单位(字节),正好是&x和&y值的差,从两个变量的起始地址开始计算32位的值,结果都是17558375,说明它们都引用了同一地址的数据;从x变量的起始位置开始,计算出偏移量为8的字节表示的值为5,正好是“Hello”字符串的长度。

所以一个字符串变量承载的信息包括了字符串数据和字符串长度,用一个图简单表示:

go-learning01

和Java相同的是,Go的字符串也是不可变的。虽然Go采用的是所谓的值传递,但是不同的变量共用同一段底层数据,所以变量间的赋值开销都很低廉。

多重赋值

和Java不同的是,Go可以多重赋值,例如交换两个变量的值可以用很简单的形式表达:

1
x, y = y, x

多重赋值常常搭配函数的多重返回,但有时候返回的值并非都是需要的,不需要的值可以赋给空标识符:

1
_, ok = method()

可赋值性

Java和Go在很多地方都是隐式赋值,比如函数隐式的将参数的值赋给对应参数的变量等;

但是Go和Java不同的地方在于,赋值语句是显示赋值,也就是类型必须精准匹配(赋值左右变量类型相同),nil可以被赋给任何借口变量或引用类型;

==!=首先需要满足可赋值性;

类型声明

和Java不同的是,Go提供关键字type来声明定义一个新的命名类型,它和某个已有的类型使用同样的底层类型,但是它们并不兼容,也就是说它们不是同一类型,不满足前面提到的可赋值性;

如果两个类型具有相同的底层类型或二者都是指向相同底层类型变量的未命名指针类型,则二者是可以相互转换的;

包导入

在Java中,包名和文件夹名是一样的,包下的每个Java文件内只存在一个public类,且类名和文件名必须相同;

和Java类似的是,在Go中一个文件夹下(不包括子文件夹)通常也只存在一个包,例外是如果存在测试文件,那么测试文件象征的代码属于测试相关包;

和Java不同的是,文件夹名和包名并不要求一定一致;

和Java不同的是,Go并不存在类的概念,Go文件名也不需要和代码中的相关模块名称一致;

在Go中,import的是具体的路径,但是在代码中,如果要引用其它包的成员,则需要用包名来调用;

通常存在这样的约定:导入路径的最后一段(也就是文件夹名)和包名相匹配,这样方便预测包名是什么;

不能导入没有被引用的包;

包初始化

在Java中以下情形变量声明初始化的顺序是不能通过编译的:

1
2
int a = b; // 错误处
int b = 4;

static块还存在一种非法的向前引用:

1
2
3
4
5
6
static {
a = 2;
System.out.println(a); // 错误处
}
static int a = 3;

和Java不同的是,Go变量通常是按照顺序从前往后初始化,但在某个变量的初始化需要依赖其它变量时,被依赖的变量先初始化;

和Java不同的是,Go存在一种默认的函数init,它的作用类似Java类初始化的static,或者Java对象初始化的构造器。

考虑这样一种情形,在同一个包下,不同的go文件包含各自的字段和init函数,首先是main.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main
import "fmt"
var bbb = ppr()
func ppr() int {
fmt.Println("Hello bbb")
return 77
}
func init() {
fmt.Println("main")
}
func main() {
fmt.Println(aa)
}

接下来是submain.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main
import "fmt"
var aa = 3
var aaa = pr()
func pr() int {
fmt.Println("Hello aaa")
return 7
}
func init() {
fmt.Println("Submain")
}

它们在同一个包里,执行打印的顺序如下:

1
2
3
4
5
Hello bbb
Hello aaa
main
Submain
3

由此可知:

  • 字段的初始化先于init函数,包内所有的字段都初始化后,init函数才执行;
  • 包内包含main函数的文件的打印语句先于未包含main函数的文件的打印语句执行,字段先于字段,init方法先于init方法;

但如果不在同一个包下,比如A文件import了B,那么B内的初始化先执行(包括init函数),然后再进行A的初始化;

作用域

Go和Java不同的是,在一个作用域内,如果变量未被使用,编译会报错:

1
2
3
if f, err := os.Open(fname); err != nil { // 编译错误:未使用f
return err
}

参考

Go程序设计语言

What is the difference between a virtual machine and any runtime environment?

Golang闭包的实现

Go closure variable scope

如何获得java对象的内存地址