Go语言是最近几年增长最快的编程语言之一,特别是云的逐渐普及更是促进了Go语言的普及。本文详细总结了Go语言的语法以及特性,这些知识也是成为一个合格的Go语言开发者必须具备的基本条件。本文主要参考了Go语言官网的Effective Go。
1、Hello World
按照惯例,以一个Hello World开始。你可以在任何你喜欢的编辑器中输入下面的代码,保存成HelloWorld.go文件(当然,你也可以取其它的名字,但后缀必须是.go)。零君目前使用的IDE是Visual Studio Code。
package main
import "fmt"
func main() {
fmt.Println("Hello World")
}
Go与C、Java一样,程序的入口点也是main函数。"fmt"是Go语言的一个标准包(package)。
运行上面的代码之前,需要先配置好go语言环境。如下两步即可:
1、首先下载适合本地平台的go语言包,安装。然后配置环境变量GOROOT指向go语言的安装目录。
2、配置环境变量GOPATH。GOPATH设置的是Go语言的工作目录,可以设置成任意目录。$里面包含三个子目录,分别是src、pkg、bin。src是存放source code的地方,pkg和bin则是存放编译产生的二进制文件。
将上面HelloWorld.go放到如下地方:
$/src/example/HelloWorld.go
然后执行下面的命令,就会看到期望的"Hello World",
cd $/src/example
go run HelloWorld.go
2、编码风格
编码风格很大程度上决定了代码可读性,特别是一个团队共同开发维护一个项目时,如果不同的人采用不同的风格,比如不同的缩进,那么每个人阅读别人的代码时就会很不习惯。Go语言提供了一个工具gofmt处理这些问题,例如使用如下命令格式化上面的HelloWorld.go,
go fmt HelloWorld.go
也可以直接对一个package里面所有的源文件同时格式化,
go fmt github.com/coreos/etcd/client
这里要特别提两点:
(1) Go语言里面缩进是使用Tab;
(2) 当代码中存在多个连续的空行,gofmt格式化之后会保留一个空行。如果你无意中在某个地方按了几次回车,而你压根就不期望在那里出现空行,那么gofmt之后最终还是会保留一个空行;这时你就需要手动删除这个多余的空行。
Go语言既支持/* xxx */式的跨行的注释,也支持//单行注释。例如:
/*
Package test is a example for demo.
You can update the demo yourself.
*/
// Package test is a example for demo.
// You can update the demo yourself
对于命名规则,Go语言推荐驼峰式,而不建议下划线式,例如"softLink"、“SoftLink”是推荐的名字,而"soft_Link","Soft_Link"则不是。
用过C++或Java的人都熟悉Getter/Setter函数,以Java为例,比如有一个成员变量“int score”,那么可以为这个变量定义两个函数如下:
public int GetScore() {
return score
}
public void SetScore(int score) {
this.score = score
}
Go语言虽然不是面向对象的开发语言,但同样可以为某个类型定义Getter/Setter方法,但是Go语言对于Getter方法的命令习惯不同。还是上面的例子,Go语言推荐的Getter方法名是Score,而不是GetScore。Setter方法相同。当然,如果将变量score直接命名为Score,那么就不需要对应的Getter/Setter方法了。
在Go语言中,任何命名的首字母如果是大写,那么该变量或函数(或方法)在包外面就是可见的,类似于C++/Java中的public修饰符。反之,如果是小写的,那么只能在包内使用。
3、控制结构
像其它语言一样,Go也有if、for、switch等控制结构。除此之外,Go还支持基于类型的switch,以及多路通讯选择select。
声明与赋值
Go语言中的变量既可以显示声明类型,也可以不用显示声明类型。例如下面就是显示声明了一个int类型的变量:
var i int
而下面的例子中就没有显示声明变量的类型,因为rand.Intn的返回类型是int,所以变量v的类型也就是int。注意这里操作符是:=,表示声明一个新的变量并给这个变量赋值。如果操作符是=,那仅仅是给变量赋值,这时如果变量之前没有声明过,就会编译失败。
v := rand.Intn(10)
Go语言与C、Java的一个不同,就是Go的函数或方法可以返回多个值,例如:
f, err := os.Open(filename)
if err != nil {
return err
}
如果文件成功打开,则f就是指向新打开文件的File指针,err则为nil;如果打开失败,err就对应具体的错误。这是Go语言里面错误处理方式。
上面的例子同时声明了两个新的变量,f和err。如果将上面的代码改成如下,也是合法的。但是第二次打开文件的那条语句,只是声明了变量f2;由于err之前声明过,所以这里只是重新赋值。对于这种只有部分变量声明的情况,操作符也必须用:=,否则就会编译错误。
f1, err := os.Open(filename)
if err != nil {
return err
}
f2, err := os.Open(filename)
if err != nil {
return err
}
if条件
if条件后面的语句必须用大括号括起来,哪怕里面只有一条语句,例如:
if i
fmt.Println("i
} else if i >= 0 && i
fmt.Println("0-10")
} else {
fmt.Println("i>10")
}
但是条件判断是不需要用小括号括起来的(当然,括起来也不会报错)。
if条件可以有初始化语句,如下例所示。注意初始化语句和判断语句之前用分号';'隔开。
if v:=rand.Intn(10); v
fmt.Println("v
}
看到这里,你可以发现if条件后面紧跟着的 '{' 都是在同一行的结尾。在Java或者C++中,这个'{'位置比较随意。但是在Go语言中,强制必须在同一行的结尾,否则编译会抱错。这条规则适用于if、for、switch、select以及函数或方法体的第一个'{'。之所以会有这样的强制要求,原因在于Go语言编译器自动插入分号‘;’分隔不同的语句。
Go编译器与C/Java类似,也是使用分号‘;’分隔不同的语句,但是区别在于Go不需要程序员输入分号,而是编译器自动插入了分号。只有if、for里面有初始化语句时,才需要程序员手动输入分号分隔初始化语句和条件语句(for还有继续语句)。Go编译器插入分号的规则是:当行结尾的token是下列情形时,就会在行结尾插入一个分号:
a) 结尾的token是一个类型标识符,比如int;
b) 结尾的token是一个常量,比如数字,或字符串常量;
c) 是下列任何一种token:
break continue fallthrough return ++ -- ) }
回到之前说到的强制要求(以if为例),如果将紧跟着if条件的大括号‘{’写到了下一行,如下例所示。那么Go编译器就会在"if i
{
fmt.Println("i
}
for循环
Go语言中没有do、while循环,只有for循环。下面通过几个例子说明。
for i:=0; i
fmt.Println(i)
}
上面的例子也可以改写为:
i:=0
fmt.Println(i)
i++
}
这里要注意一点,i++以及i--是语句(statement),而不是表达式(expression)。所以下面的语句是非法的。
v1 := myArray[i++]
v2 := myArray[i++]
for循环也可以遍历array、slice、map、string、channel,这些概念下面会提到。主要是通过关键字range来实现的。这里以map为例,我们都知道map就是key、value对的集合。下面的例子就是输出所有的key/value对的值。
for key, value := range myMap {
fmt.Printf("key = %s, value = %s\n", key, value)
}
如果只需要输出key的值,那么上面的例子可以改写为:
for key := range myMap {
fmt.Printf("key = %s\n", key)
}
如果只需要输出value的值,则可以改写为:
for _, value := range myMap {
fmt.Printf("value = %s\n", value)
}
上面的'_'是blank identifier,此处的作用是忽略key的值。在Go语言中,blank identifer还是用途比较大的,这里就不多介绍了。
switch
Go语言中的Switch比C/C++/Java中的更灵活。switch后面的表达式可以不是常量;cases分支从上至下执行,直到遇到一个匹配成功的为止。
例如下面的例子其实与if-else if-else的作用是相同的。
switch {
case j
fmt.Println("case i
case j>=0 && j
fmt.Println("case j>0 && j
default:
fmt.Println("Last case")
}
注意switch中的case之间是不会fall-through的,但是可以将多个case用逗号合并。如下例所示:
switch k {
case 1, 2, 3:
fmt.Println("less than 3")
case 4,5:
fmt.Println("less than 5")
default:
fmt.Println("greater than 5")
}
Go中的switch还有一种特殊的用法,那就是用于动态发现变量的类型,如下例所示。用Go语言的术语这种用法就是Type Switch。
var v interface{}
v = SomeFunc()
switch v.(type) {
case int:
fmt.Println("Integer")
case string:
fmt.Println("string")
default:
fmt.Println("Unknown")
}
上面的例子通过另一种称为type assertion的方式改写如下:
var v interface{}
v = SomeFunc()
if _, ok := v.(int); ok {
fmt.Println("Integer")
} else if _, ok := v.(string); ok{
fmt.Println("String")
} else {
fmt.Println("Unknown")
}
select
与switch类似,select也是一种多路选择器,但区别在于select只针对I/O操作。示例如下:
chan1 := make(chan int, 1)
chan2 := make(chan int, 1)
select {
case i :=
fmt.Printf("data read from chan1: %d\n", i)
case i :=
fmt.Printf("data read from chan1: %d\n", i)
default:
fmt.Println("no data available")
}
上面的例子中用到了channel,下面会具体说明。
4、数据结构
Go语言常用的数据结构有Array、slice、map。
内存分配
谈到数据结构,首先要知道如何分配内存。Go提供了两个内置的函数new和make来分配内存。先来看一个new的例子:
type Example struct {
val1 int
val2 string
}
obj := new(Example)
对于new,要注意两点:(1) new返回的是一个指针,所以上面例子obj的类型是Example指针;(2) new只是分配内存,并不会初始化,new只是将分配的内存置0,所以上面new创建的Example中val1的值是0,而val2是一个空字符串。
make则不仅分配内存,还初始化内存。但是make只能创建slice、map、channel这三种数据类型。示例如下:
s := make([]int, 2, 10)
上面的例子就是分配了一个int类型的slice,这个slice的初始长度为2,容量为10。注意:make返回的不是一个指针,而是一个slice结构。每个slice结构包含三个元素:指向数据内存的指针、长度、容量。具体到上面的例子,实际上先创建了一个具有10个元素的int数组,然后创建一个slice结构,其中slice结构的长度为2,容量为10,而slice结构的指针就指向数组的前两个元素。
注:slice的长度可以动态变化
很多时候,也可以直接用下面的方式创建并初始化结构体:
v := Example
或者定义成指针:
v := &Example
Array
Array就是数组,与其他语言中的数组用法类似。例如下面就创建了一个长度为3的int数组:
v := [3]int
注意几点:
(1) 当一个array赋值给另一个array时,会拷贝array中所有的元素,两个array会对应不同的内存区域。同样,当函数的参数是array时,将一个array传给函数会拷贝所有的元素。
(2) array的长度是类型的一部分,例如[10]int和[20]int是两种不同的类型。
通常情况,Array很少使用,一般是使用Slice。
Slice
Slice其实是对Array的封装及扩展,从而变得更通用、功能更强大。前面已经提到过,slice结构中包含一个指向底层Array的指针,所以当你把一个Slice赋值给另一个slice时,那么这两个slice会指向相同的内存区域。前面在内存分配小节中已经对slice举例说明过了,这里就不再重复举例了。
虽然Slice包含指向底层Array的指针,但slice的长度可以动态扩展。当底层Array的空间不足时,Slice会创建新的容量更大的Array,拷贝所有数据,然后指向新的Array。
我们也可以定义二维Array或者二维Slice,例如:
type array1 [3][3]int // 2-dimension array
type slice1 [][]int // 2-dimension slice
二维Slice也就是每个元素也是一个Slice。由于Slice是变长的,所以二维Slice中,每个内部Slice的长度可以不同。
Map
Map就是key/value对的集合,是Go语言提供的一种内置的数据结构。例如下面定义的map,key是string,而value则是int。
var timeMap = map[string]int {
"SECOND": 1,
"MINUTE": 60,
"HOUR": 60*60,
"DAY": 60*60*24,
}
一般通过前面提过的range来遍历map:
for k, v := range timeMap {
fmt.Printf("%s, %d\n", k, v)
}
注意用某个key直接从map中读取某个value时,会返回一个bool变量,来标示对应的key是否存在,示例如下:
if v, ok := timeMp["HOUR"]; ok {
fmt.Println(v)
} else {
fmt.Println("Not found")
}
5、函数与方法
Go语言中的函数与C/Java中的函数一个重要的不同是,Go的函数可以返回多个值,上面也已经提过了。但是值得一提的是,Go中可以对返回值命名,在函数体内可以象其它变量一样操作返回值变量。这样带来的好处是函数返回时,只需要一个return命令即可,后面无需再提供返回的值,示例如下:
func TestFunc1() (retValue1, retValue2 int) {
retValue1 = 2
retValue2 = 3
return
}
在其它地方可以调用这个函数:
v1, v2 := TestFunc()
fmt.Printf("%d, %d\n", v1,v2)
Go中有一个特殊的关键字defer,它的作用是延迟函数的执行时间。下面的例子中,对f.Close()的调用并不会马上执行,而是在函数TestFunc返回时才执行。这样做有两个好处:1、不管TestFunc函数执行过程中从什么路径返回,f.Close()最后都会被执行;2、将f.Close()与os.Open()放在一起,使代码更清晰更易维护。
func TestFunc(name string) {
f, err := os.Open(name)
if err != nil {
fmt.Printf("Failed to open file %s, err: %v\n", name, err)
return
}
defer f.Close()
......
}
但有一点一定要注意,defer虽然会推迟函数的执行时间,但如果执行的函数有参数,那么参数的计算不会推迟。例如下面的例子,函数SampleFunc有一个输入参数,取当前的系统时间。这个时间的计算是defer语句执行的时候就确定了,而不是SampleFunc函数执行的时候计算的。
defer SampleFunc(time.Now())
值得一提的是,可以为每个源代码文件中定义init函数,该函数会在该文件加载时自动执行。可以在init函数中做一些初始化或者校验的操作。init函数是在包中所有变量都初始化完成之后才执行的。如果还import了其它的包,那么import的包的初始化最先实施。
Go中的方法(Method)和函数(Function)是两个不同的概念。当一个函数与某种数据类型绑定时,就是方法。下面的例子中TestFunc就是一个方法,它与类型Example绑定:
type Example struct {
val1 int
val2 string
}
func (p *Example) TestFunc() {
p.val1 = 11
p.val2 = "abc"
}
在其它地方可以调用这个方法,示例如下,obj就是Example类型的指针,obj就称为接收器(receiver)。
obj := &Example{}
obj.TestFunc()
fmt.Printf("%d, %s", obj.val1, obj.val2)
虽然Go不是一种面向对象的开发语言,但通过上面的例子可以看出,将数据与操作结合在一起,Go同样具有面向对象的思维。
上面的例子中因为方法TestFunc对应的receiver是指针类型,所以方法体内对receiver内的数据做的修改,对调用者是可见的,所以上面的例子调用者最后输出的内容为:
11, abc
但是如果将方法TestFunc对应的receiver定义为非指针的形式(如下),那么在方法体内所作的修改,对于调用者是不可见的。
func (p Example) TestFunc() {
p.val1 = 11
p.val2 = "abc"
}
6. 接口
前面提到的方法(method)因为将数据和操作绑定在了一起,所以Go也吸收了面向对象的思想。Go体现面向对象的另一个重要因素在于接口(Interface)。Go语言的接口与Java的Interface本质上是相同的,都是对行为的抽象。但是在具体用法上差异还是挺大的。
例如Go语言的io包中定义的Reader和Writer就是两个接口,
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
任何命名的类型都可以实现上面两个接口。只要为某种类型(指针和interface类型除外)绑定的方法中,包含某个接口中定义的方法,那么该类型就实现这个接口。例如下面的例子,Example就实现了io.Reader接口。
type Example struct {
val1 int
val2 string
}
func (p *Example) Read(p []byte)(n int, err error){
......
}
一个类型也可以实现多个接口,例如我们还可以为Example实现io.Writer接口,只要再绑定一个具有相同签名的Write方法即可。
还有一种常见的用法就是联合多个接口,从而形成一个新的接口,例如io.ReadWriter就是上面的io.Reader和io.Writer的联合。
type ReadWriter interface {
Reader
Writer
}
7、并发
C++/Java中对于多线程并发的处理比较复杂,多个线程之间通过共享内存的方式来交互,这时对于共享数据的保护就很重要,这样就需要引入锁等同步机制,很容易导致新的问题。而Go则通过完全不同的途径解决了并发的问题。在Go中,多个线程(准确的说是go routine)之间无需共享内存,多个go routine之间是通过channel来交换数据。这里引用Go官方的一句口号:
Do not communicate by sharing memory; instead, share memory by communicating.
不要通过共享内存来通信;而是应该通过通信来共享内存。
Go引入了Goroutine概念。Goroutine相对于thread来说更轻量级。任意一个函数或方法的并发执行都是一个goroutine。通过关键字go来执行任何函数或方法,就会启动一个新的goroutine来执行那个函数或方法。例如下面的语句会启动一个新的goroutine来执行函数TestFunc。当函数执行结束,goroutine就会退出。
go TestFunc()
Channel是Go语言中一个非常重要的概念,Goroutine之间就是通过channel通信的,channel是一个很大的话题,这里只是通过一个完整的简单的例子来说明,如下:
package main
import (
"fmt"
"time"
)
func TestFunc(c chan string) {
for {
str :=
fmt.Printf("Received: %s\n", str)
if str == "exit" {
fmt.Println("bye")
break
}
}
}
func main() {
ch := make(chan string, 1)
go TestFunc(ch) // start a new goroutine to execute TestFunc()
for i:=0; i
ch
time.Sleep(1*time.Second)
}
ch
time.Sleep(1*time.Second)
fmt.Println("main exiting...")
}
上面例子中,在main函数里,首先用内置函数make创建一个channel,第二个参数1表示channel里可以缓存一个字符串。如果是0的话,则表示非缓存的channel;那么如果没有Goroutine从channel中读取的时候,其他Goroutine试图往channel里写入的时候会block住。TestFunc()不断的从channel里读取数据,然后直接输出。如果读取到的是"exit",那么就退出,否则就继续读取。而main函数往channel里每隔1秒写入一个"hello",连续写入三个"hello"之后,就写入一个"exit",通知TestFunc退出。程序的输出如下:
Received: hello
Received: hello
Received: hello
Received: exit
bye
main exiting...
8、错误处理
Go语言中函数或方法可以返回多个值,所以一般情况下,函数或方法会返回一个error对象,用来表示具体发生的错误。如果没有任何错误发生,返回的error对象就是nil(相当于Java里的null)。在前面也已经举例说明过了。
Go语言定义了一个内置的error interface,如下:
type error interface {
Error() string
}
Go的SDK中的各种Error类型都实现了这个接口,比如os.PathError。我们自己也可以定义各种应用相关的Error。只要实现了上面这个error接口即可,其实也就是实现Error()方法。
panic和recover是Go语言提供两个内置的函数。panic相当于Java里面的异常,当程序发生了比较严重的错误的时候,就可以调用panic,相当于抛出一个异常,程序立即停止执行,并开始沿着调用栈一层层向调用者返回(unwinding),如果没有任何地方处理,那么程序最后会退出,并输出调用栈信息。我们可以在返回的过程中,在某处调用了recover来处理这种异常情况。 下面是一个完整的例子来说明panic和recover的用法:
package main
import "fmt"
func doSomething(name string) {
if name == "" {
panic("empty name")
} else {
fmt.Println(name)
}
}
func panicTest() {
defer func() {
if err := recover(); err != nil {
fmt.Println("doSomething failed:", err)
}
}()
doSomething("")
}
func main() {
panicTest()
}
通常panic意味着不可恢复的错误,让程序退出有时是安全的做法。所以谨慎使用recover。
9、垃圾回收
Go语言提供了垃圾回收的机制,也就意味着与Java一样,将开发人员从内存管理的噩梦中解放出来了。垃圾回收是个很大的话题,将来有机会会单独深入分析。
10、结束语
如果耐心阅读到这里,就会发现Go语言的特点(或者说优点):
(1) 虽然不是面向对象的语言,但是吸收了面向对象的思想;
(2) 提供接口这样抽象的概念;
(3) 支持指针;
(4) 提供了垃圾回收机制;
(5) 用package来管理代码,与Java类似;
(6) 提供了一个reflection包,可以在运行时动态获取Value和Type信息(限于篇幅,本文正文中没有涉及到reflection);
(7) defer提供的延迟执行特性,可以很方便的释放资源;
(8) 灵活的错误处理机制;没有异常,也没有try-catch-finally,不过panic类似于Java的异常;
(9) 函数可以返回多个值,通常会返回一个error对象;
(10) Goroutine更轻量级,提供并发支持;
(11) 侧重通过channel通信,而不是共享内存;
(12) 提供了gofmt工具来格式化source code。
本文对Go语言进行了系统的总结,尽量涵盖了Go语言了方方面面的特性。但任何一个方面都值得进一步的深入分析,比如垃圾回收等。以后有时间可能还会挑选一些点深入分析。
--END--
领取专属 10元无门槛券
私享最新 技术干货