Golang——登堂入室!
前言
由于实习的时候需要使用Go,之前用的一直是C++,抽点时间学一学这个语言。
基本的学习路线:环境安装、语法概览、典例编写、小型项目分析。
环境安装
go语言安装可以参考这个博客:
想使用IDE的话可以安装Goland,或者是VSCode。
安装完毕之后,就是一件具有仪式性的事情了——helloworld!
PS:为了便于便于学习和复习,建议单独建一个Go语言的文件夹,而后每一个练习项目代码都在其中分别新建一个,哪怕练习项目只有一个文件,养成良好的习惯,例如这样:
语法概览
关于教程,笔者使用的是微软官方所写的一个简单教程,见下:
docs.microsoft.com/zh-cn/learn…
话说初见Go的这个helloworld程序,长得真的好像java...:
package main
import "fmt"
func main() {
fmt.Println("Hello,World!")
}
让我来一步一步揭开它的面纱吧!
变量、函数、包
变量的声明、初始化
跟C语言不太一样的是,Go在声明变量时统一使用var关键字,并在变量名之后接数据类型。如下:
var firstName string
PS:数据类型学过其他语言的应该都了解,之后会慢慢说;
若想同时声明多个变量,可以这样:
var firstName, lastName string
var age int
也可以这样:
var (
firstName, lastName string
age int
)
若想在声明的时候将变量初始化,那就跟在后面即可:
var (
firstName string = "John"
lastName string = "Doe"
age int = 32
)
同时,在初始化变量的时候也可以不跟数据类型,Go编译器会自动推断,如下:
var (
firstName = "John"
lastName = "Doe"
age = 32
)
或:
var (
firstName, lastName, age = "John", "Doe", 32
)
也就是说Go的变量在声明和初始化的时候用逗号隔开即可。
重点来了,Go还有一个方便的地方在于,对于新变量,可以这样声明和初始化:
firstName, lastName := "John", "Doe"
age := 32
即用冒号等号来直接声明和初始化变量。
注1:不能对已有的变量执行冒号等于;
注2:Go语言中变量声明之后必须使用,否则会报错;常量则可以不使用;
注3:变量用var,常量用const关键字(方法跟var一样),const中有一个概念叫iota,可以简单理解为存在一个常量表,iota即是下标,详见。
数据类型
Go有四种数据类型:
- 基本类型:数字、字符串和布尔值
- 聚合类型:数组和结构
- 引用类型:指针、切片、映射、函数和通道
- 接口类型:接口
这里只将基本类型,其他三种之后。
**整形int:**分为有/无符号数的8/16/32/64位整数(无符号加u)。
值得注意的是,在Go中在进行类型转换的时候,必须显式转换。
注:若只写int,32位机上为32位,64位机为64位;
注:rune 只是 int32 数据类型的别名。 它用于表示 Unicode 字符;
浮点数型float:有float32/float64,数字范围不同。
**注:**go中变量名字可以和一些关键字重复!(不建议)
**布尔型bool:**true/false,且不能够跟0/1隐式转换;
字符串string:双引号表示字符串,单引号表示字符,常用的转移字符如下:
注:所有变量声明时都有默认值,0、0.000e+000、false、空;
显式类型转换:在变量前加数据类型即可,另外还可以使用strconv包,调用其中的函数完成;
函数
函数的定义方式如下:
func name(parameters) (results) {
body-content
}
具体一点:
func sum(number1 string, number2 string) int {
int1, _ := strconv.Atoi(number1)
int2, _ := strconv.Atoi(number2)
return int1 + int2
}
即是func+函数名+形参(借用C++的概念)+返回值的类型+{函数体}。
返回值可以是多个,还可以命名,主要是为了在函数中使用,见下:
func calc(number1 string, number2 string) (sum int, mul int) {
int1, _ := strconv.Atoi(number1)
int2, _ := strconv.Atoi(number2)
sum = int1 + int2
mul = int1 * int2
return
}
(重点)如果想实现在函数中修改函数传递的变量(即函数内修改形参来更改原变量的值),需要使用 指针 没错,就是C语言那个指针(指针是包含另一个变量的内存地址的变量。),见下:
package main
func main() {
firstName := "John"
updateName(&firstName)
println(firstName)
}
func updateName(name *string) {
*name = "David"
}
**注:**如果在调用该函数时,不想使用其中某个返回值,可使用_占位符;
模块与包
最开始的时候go是以.go来管理项目,没有成熟的管理体制。在Go1.12之后终于有了模块这个概念。如以下这个文件架构:
calculator文件夹内是一个模块(mod),其内的所有.go源文件都属于这个包,如minus和sum中都会有package calculator,见下:
模块的建立需要在对应的文件夹内执行:
go mod init modulename
当想在main包中引用某个模块时,可以在go.mod中进行引用:
关于包和模块等进一步了解,请参考。
**注:**包可以看成是C++中的类,**但它没有public、private来定义公有私有变量,是通过大小写来的:**如需将某些内容设为专用内容,请以小写字母开始。如需将某些内容设为公共内容,请以大写字母开始。
控制流
if...else
直接看例子吧:
package main
import "fmt"
func givemeanumber() int {
return -1
}
func main() {
if num := givemeanumber(); num < 0 {
fmt.Println(num, "is negative")
} else if num < 10 {
fmt.Println(num, "has only one digit")
} else {
fmt.Println(num, "has multiple digits")
}
}
跟C++的区别就在于不需要加括号咯。
switch
简单版本:
switch i {
case 0:
fmt.Print("zero...")
case 1:
fmt.Print("one...")
case 2:
fmt.Print("two...")
default:
fmt.Print("no match...")
}
进阶版本(匹配多个中的一个):
package main
import "fmt"
func location(city string) (string, string) {
var region string
var continent string
switch city {
case "Delhi", "Hyderabad", "Mumbai", "Chennai", "Kochi":
region, continent = "India", "Asia"
case "Lafayette", "Louisville", "Boulder":
region, continent = "Colorado", "USA"
case "Irvine", "Los Angeles", "San Diego":
region, continent = "California", "USA"
default:
region, continent = "Unknown", "Unknown"
}
return region, continent
}
func main() {
region, continent := location("Irvine")
fmt.Printf("John works in %s, %s\n", region, continent)
}
再进阶(甚至可以调用函数switch和case字段都可以):
switch {
case email.MatchString(contact):
fmt.Println(contact, "is an email")
case phone.MatchString(contact):
fmt.Println(contact, "is a phone number")
default:
fmt.Println(contact, "is not recognized")
}
等等...似乎switch后面没有接变量。没错,switch之后可以省略,表示直接进case语句。
**注:**go的switch case会自动跳出,而不需要break,如果跟着****fallthrough关键字,就会继续执行匹配的case之后的语句;
for
简单模式:
func main() {
sum := 0
for i := 1; i <= 100; i++ {
sum += i
}
fmt.Println("sum of 1..100 is", sum)
}
其实,前处理和后处理是可选的:
func main() {
sum := 0
for sum <= 100; {
sum += i
}
fmt.Println("sum of 1..100 is", sum)
}
无限循环模式(等同于while),利用break弹出:
for {
fmt.Print("Writting inside the loop...")
if num = rand.Int31n(10); num == 5 {
fmt.Println("finish!")
break
}
fmt.Println(num)
}
一样有continue关键字:
func main() {
sum := 0
for num := 1; num <= 100; num++ {
if num%5 == 0 {
continue
}
sum += num
}
fmt.Println("The sum by 5, is", sum)
}
defer
这个函数做的事情跟它的意思一样“推迟”:defer 语句会推迟函数(包括任何参数)的运行,直到包含 defer 语句的函数(大括号)完成。
defer会将其之后的函数或语句压入类似栈的数据结构,而后逐一弹出执行,如下面这个例子:
package main
import "fmt"
func main() {
for i := 1; i <= 4; i++ {
defer fmt.Println("deferred", -i)
fmt.Println("regular", i)
}
}
先想想如果没有defer,结果是什么样子的?如果有就是下面:
可见,defer其实就是一个栈型结构,它会存储对应的函数参数。如果存在多个defer语句,它们之间也是按照“后来居上”的,如下:
通常情况下,当你想要避免忘记任务(例如关闭文件或运行清理进程)时,可以推迟defer某个函数的运行。
panic
这个函数有点类似于C++的异常机制,panic可以强制程序进入紧急状态,此时控制流完全中断,defer的函数执行输出,而后程序异常退出并打印堆栈信息。
package main
import "fmt"
func main() {
g(0)
fmt.Println("Program finished successfully!")
}
func g(i int) {
if i > 3 {
fmt.Println("Panicking!")
panic("Panic in g() (major)")
}
defer fmt.Println("Defer in g()", i)
fmt.Println("Printing in g()", i)
g(i + 1)
}
结果:
recover
当主动或被动(程序出错)调用panic时,程序通常会崩溃。有时,可能想要避免程序崩溃,改为在内部报告错误。或者,可能想要关闭与某个资源的连接,以免出现更多问题。这时就需要使用recover了,它可以使程序在出现紧急状态时重新获得控制权。它只能在推迟的函数中使用,未出现紧急状态,recover()函数返回nil,无意义;见下例子:
package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered in main", r)
}
}()
g(0)
fmt.Println("Program finished successfully!")
}
func g(i int) {
if i > 3 {
fmt.Println("Panicking!")
panic("Panic in g() (major)")
}
defer fmt.Println("Defer in g()", i)
fmt.Println("Printing in g()", i)
g(i + 1)
}
结果为:
此时程序“看起来是正常结束”,因为没有堆栈信息了。利用panic和recover,go实现了异常检测和处理。
聚合类型
数组
声明的方式跟C++很类似:
var a [3]int
若是想初始化,有这么几种方式:
- 默认初始化
如果不主动初始化,按照数据类型来对数组中的元素进行初始化;
- 手动初始化
cities := [5]string{"New York", "Paris", "Berlin", "Madrid"}
- 省略号方式
在不知道需要数组多长的时候,可以用省略指代:
q := [...]int{1, 2, 3}
- 省略加特定
可以为指定位置的数组元素进行初始化:
numbers := [...]int{99: -1}
多维数组暂不赘述;
切片
切片只是名为基础数组的数组之上的一种数据结构。 通过切片,可访问整个基础数组,也可仅访问部分元素。
切片只有 3 个组件
- 指针,指向基础数组可访问的第一个元素(并非一定是数组的第一个元素)。
- 长度,指示切片中的元素数目。
- 容量,显示切片开头与基础数组结束之间的元素数目。
如下图;
其实,切片只是基础数组的一个子集,截取了数组的某一个部分。
它的声明可以通过以下几种方式:
months := []string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}
quarter1 := months[0:3]
quarter2 := months[3:6]
quarter3 := months[6:9]
quarter4 := months[9:12]
注:在声明切片时,它的len和cap是不一样的,cap是根据当前数组所拥有的最长长度而定;
利用append来对切片进行添加;
cap是翻倍增长的;(会在一个新建数组上进行,而不是基础数组)
删除切片中某一元素,可以利用数组下标,而后缩短切片来实现,例如:
package main
import "fmt"
func main() {
letters := []string{"A", "B", "C", "D", "E"}
remove := 2
fmt.Println("Before", letters)
letters[remove] = letters[len(letters)-1]
letters = letters[:len(letters)-1]
fmt.Println("After", letters)
}
还可以利用切片副本来处理:
slice2 := make([]string, 3)
copy(slice2, letters[1:4])
因为切片和基础数组是绑定在一起的,**修改切片会影响基础数组,修改基础数组会影响切片,实际场景下可能不想要相符影响,那就需要副本了,**见下:
package main
import "fmt"
func main() {
letters := []string{"A", "B", "C", "D", "E"}
fmt.Println("Before", letters)
slice1 := letters[0:2]
slice2 := make([]string, 3)
copy(slice2, letters[1:4])
slice1[1] = "Z"
fmt.Println("After", letters)
fmt.Println("Slice2", slice2)
}
其结果为:
映射(哈希表)
声明映射的方式:
studentsAge := map[string]int{ "john": 32, "bob": 31, }
//等同于
var studentsAge map[string]int
//以上被称为nil映射,下列被称为make映射
studentsAge := make(map[string]int)
如何添加?(映射是动态的)
package main
import "fmt"
func main() {
studentsAge := make(map[string]int)
studentsAge["john"] = 32
studentsAge["bob"] = 31
fmt.Println(studentsAge)
}
直接添加即可(相当于已经进行了初始化),如果是nil映射会报错,加入不进去。
如何访问?
可以使用下标法,而且还能判断是否存在!
package main
import "fmt"
func main() {
studentsAge := make(map[string]int)
studentsAge["john"] = 32
studentsAge["bob"] = 31
age, exist := studentsAge["christy"]
if exist {
fmt.Println("Christy's age is", age)
} else {
fmt.Println("Christy's age couldn't be found")
}
}
如何删除?
——调用delete即可。
delete(studentsAge, "john")
若对一个不存在的键值对进行删除,不会报错(反正也没影响)。
结构体
定义和初始化直接看代码:
type Employee struct {
ID int
FirstName string
LastName string
Address string
}
//初始化(按照数据结构及顺序来)
employee := Employee{1001, "John", "Doe", "Doe's Street"}
//or
employee := Employee{LastName: "Doe", FirstName: "John"}
如果要进行结构体的嵌入,有两种方式:
type Employee struct {
Information Person
ManagerID int
}
//or
type Employee struct {
Person(直接嵌入)
ManagerID int
}
Go语言在标准库中深度集成了json,此处暂略,进一步了解可参考。
方法与接口
方法
声明
声明的方式如下:
func (variable type) MethodName(parameters ...) {
// method functionality
}
其中variable是类的一个对象,type就是一个类名(结构名)。
如果想实现对原本的类对象进行修改(一般调用函数是对类对象的副本进行操作的)就得借助指针:
func (t *triangle) doubleSize() {
t.size *= 2
}
**注:**只能对当前包内的对象添加方法,不能乱来;
如果对其他类型进行了嵌套,那么可以直接调用其他类型的方法,如下:
type coloredTriangle struct {
triangle
color string
}
func main() {
t := coloredTriangle{triangle{3}, "blue"}
fmt.Println("Size:", t.size)
fmt.Println("Perimeter", t.perimeter())
}
注意到了么,嵌套的方法可以直接调用,但是之前说嵌套的成员变量只能够逐级调用。其实是Go语言利用一个包装器模式来帮我们进行了自动推广,类似于:
func (t coloredTriangle) perimeter() int {
return t.triangle.perimeter()
}
重载
Go语言的方法也是可以重载的,相当于子类重写了父类(但其实两者间并不是父子关系),只需要在类中重新实现函数即可,如下:
func (t coloredTriangle) perimeter() int {
return t.size * 3 * 2
}
但是,函数重载之后,如果想调用“子类”中的方法,就不能直接调用了,必须通过具体的对象逐级显式调用。
封装
封装,即是C++中的public或者private之类的,限制类外的代码对类内属性的访问权限。
之前也提到过,Go语言是通过方法名的大小写来实现的:
大写即是Public;
小写即是Private;
接口
接口是指规定一定的实现形式(如函数名、参数、返回等),而具体实现应根据实际情况而定。而且,接口规定的方法必须实现。在go语言中,只要你实现了对应的接口方法,就是实现了接口(而不用再单独设置关键字,Go会自动判定)。
声明
如规定了两个接口:
type Shape interface {
Perimeter() float64
Area() float64
}
那么任何shape类型都要对其进行实现。
实现
接着上面的那个shape例子,如果我想实现一个shape类型,那么就必须进行接口方法的实现:
type Square struct {
size float64
}
func (s Square) Area() float64 {
return s.size * s.size
}
func (s Square) Perimeter() float64 {
return s.size * 4
}
扩展实现
我们可以通过下面这种方式扩展对应方法的自定义版本:
type Stringer interface {
String() string
}
package main
import "fmt"
type Person struct {
Name, Country string
}
func (p Person) String() string {
return fmt.Sprintf("%v is from %v", p.Name, p.Country)
}
func main() {
rs := Person{"John Doe", "USA"}
ab := Person{"Mark Collins", "United Kingdom"}
fmt.Printf("%s\n%s\n", rs, ab)
}
有关接口的进一步了解,可以参考。
并发
Go之所以如此火爆,其中一个关键就在于它在并发上无与伦比的性能!Go的并发跟C++的并发不一样,让我们一起来看看。
——这里只介绍Go的goroutine(轻量级线程)方式,传统的依靠共享内存的线程并发方式不做介绍。
Goroutine
怎么并发一下? go一下就可以了。
func main(){
login()
go launch()
}
也许更喜欢匿名函数?
func main(){
login()
go func() {
launch()
}()//这个()是为了实现匿名函数的调用
}
注意,协程(routine)的并发跟线程并发一样,多个线程/协程之间一般是不会等待对方运行完毕之后总程序才停止,而是多个同时运行,主协程运行完毕之后就提示程序已运行完,如果想实现协程间通信,见后。
channel
go语言不是通过共享内存,而是通过通信通道实现内存共享的!
channel是协程之间传递值的方式,如何创建?
ch := make(chan int)
close(ch)
注意,go的channel是分为有缓冲和无缓冲的,使用make默认是无缓冲的。这有什么区别呢?
无缓冲的channel只有当有接收方的时候,发送方才算真正的发送的。
有缓冲的channel类似于一个消息队列,在创建时指定大小即可:
ch := make(chan string, 10)
无缓冲channel同步通信。它们保证每次发送数据时,程序都会被阻止,直到有人从channel中读取数据。
相反,有缓冲channel将发送和接收操作解耦,相当于异步操作。它们不会阻止程序,但必须小心使用,因为可能最终会导致死锁。
如何读取和发送呢?
ch <- x // sends (or write) x through channel ch
x = <-ch // x receives (or reads) data sent to the channel ch
<-ch // receives data, but the result is discarded
另外,在函数参数中可以对channel的方向进行定义(即只能读取/接收),避免误用,如果误用会编译错误。
package main
import "fmt"
func send(ch chan<- string, message string) {
fmt.Printf("Sending: %#v\n", message)
ch <- message
}
func read(ch <-chan string) {
fmt.Printf("Receiving: %#v\n", <-ch)
}
func main() {
ch := make(chan string, 1)
send(ch, "Hello World!")
read(ch)
}
select
select关键字可以用来与多个channel交互,例如等待事件发生之类的,有点类似于switch。见下:
package main
import (
"fmt"
"time"
)
func process(ch chan string) {
time.Sleep(3 * time.Second)
ch <- "Done processing!"
}
func replicate(ch chan string) {
time.Sleep(1 * time.Second)
ch <- "Done replicating!"
}
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go process(ch1)
go replicate(ch2)
for i := 0; i < 2; i++ {
select {
case process := <-ch1:
fmt.Println(process)
case replicate := <-ch2:
fmt.Println(replicate)
}
}
}
结果是什么?
go中的select类似于网络编程中的多路复用,它会阻塞主程序,等待其候选项“case”的事件就绪。
错误处理及日志
错误处理
panic 和 recover 之类的内置函数来管理程序中的异常或意外行为。但错误是已知的失败,程序应该可以处理它们。
GO语言中错误的处理方式只需要if和return即可,如下述代码:
employee, err := getInformation(1000)
if err != nil {
// Something is wrong. Do something.
}
通过检查返回值中是否有nil,来确定调用这个函数是否有误。
其实现原理是对调用函数内,递归的判断是否出错,这就有两种应对错误的方式,一是直接放回错误,如下:
func getInformation(id int) (*Employee, error) {
employee, err := apiCallEmployee(1000)
if err != nil {
return nil, err // Simply return the error to the caller.
}
return employee, nil
}
二是,添加重试策略如下:
func getInformation(id int) (*Employee, error) {
for tries := 0; tries < 3; tries++ {
employee, err := apiCallEmployee(1000)
if err == nil {
return employee, nil
}
fmt.Println("Server is not responding, retrying ...")
time.Sleep(time.Second * 2)
}
return nil, fmt.Errorf("server has failed to respond to get the employee information")
}
日志系统
log包提供了基本的日志信息,但是不提供日志级别,如下:
import (
"log"
)
func main() {
log.Print("Hey, I'm a log!")
}
它会默认添加时间:
除此之外,
log.Fatal() 函数记录错误并结束程序;
log.Panic()函数同上,并输出堆栈信息;
log.SetPrefix()函数可让日志消息添加前缀(它之后的);
log.SetOutPut()函数可以设置输出到文件中;
至于其他的库:logrus、zerolog、zap、Apex,请百度谷歌相关知识。