Go 代码结构
与 Rust 不同的是,Go 将代码划分为包。一个 Go 语言必须在每个源文件的头部显式写明所属包名。另一点是 Go 对代码的格式进行了严格要求,例如大括号不能换行等
Go 会在语句的末尾自动添加分号。因此一般无须手动添加。但是如果希望在同一行写多个语句,那么需要手动添加分号
Go 语言的注释与 C++ 相同,分为行注释和块注释,且写法相同。
基础语法
Go 语言提供了布尔、浮点数、复数、字符串、指针、数组、结构等类型。并且使用了后置类型声明。
另外还提供了一个 nil 值/类型
以下几种类型为 nil:
var a *int
var a []int
var a map[string] int
var a chan int
var a func(string) int
var a error // error 是接口
Go 语言 声明 变量有以下几种方式:
var a = 10
a := 10
var a, b, c = 1, 2, 3
var a, b, c int32 = 1, 2, 3
// 下面的写法一般用于声明全局变量
var (
A int64
B string
)
局部变量声明后必须要使用,否则产生编译错误。全局变量则不然
声明常量使用 const 修饰
数组的声明方式为:
a := []int{1, 2, 3, 4, 5}
for _, it := range a {
fmt.Println(it)
}
数组的大小可以手动指定,也可以直接忽略
Go 语言中的空指针被称为 nil,统一用来代表空值
另外介绍一个关键字 iota
iota 用于 const 表达式中,让编译期自动计算它的值,计算方式如下:
从第一个 const 表达式开始计算,初值为 0
每次碰到一个 const 变量加一
没有显式初始化的变量将被编译期赋值
例如:
const (
a = 2
b = iota // b = 1
c // c = 3
d = "yes"
e // e = "yes"
k = iota // 5
j // j = 6
)
从中也可以看出这种初始化的特点:未显式初始化的变量会延续上一个变量的类型和值。多次使用 itoa 不会更改编译期的计数
Go 语言中支持的运算符与 C++ 完全相同
引用类型
Go 语言中单字数据使用拷贝语义,多字数据使用引用语义
条件语句
条件语句与 Rust 一样:
a := true
if a {
fmt.Println("true")
}
循环语句
Go 语言中的循环语句如下:
// 一般格式
for i := 0; i < 10; i++ {
fmt.Println(i)
}
i := 1
// 类 while 形式
for i < 10 {
fmt.Println(i)
i++
}
// for true{}
for {
break
}
str := "hello"
// 展开 dict, list
for index, ch := range str {
fmt.Printf("%d, %c\n",index , ch)
}
函数
Go 语言中的函数也采用了后置类型声明,但是和 Rust 不同的是不再需要箭头:
func add(a, b int) int {
return a + b
}
func main() {
fmt.Println(add(1, 2))
}
还可以返回多个值
func swap(x, y int) (int, int) {
return y, x
}
Go 语言中的参数默认是值传递,如果需要引用传递,则需要使用指针:
func plus(a *int) int {
*a++
return *a
}
func main() {
a := 10
fmt.Println(plus(&a))
}
结构体
type Student struct {
id string
name string
age uint16
}
func main() {
stu1 := Student{id: "12345", name: "小明", age: 16}
fmt.Println(stu1.name)
stuPtr := &stu1
fmt.Println(stuPtr.age)
}
从中也可以看出来 Go 语言中的指针语法声明的实际上是类似于引用的变量
切片
Go 语言切片的方式和其它语言相同:
arr := []int{1, 2, 3, 4, 5}
slice1 := arr[1:]
slice2 := arr[:2]
slice3 := arr[1:2]
fmt.Println("slice1")
for _, it := range slice1 {
fmt.Println(it)
}
fmt.Println("slice2")
for _, it := range slice2 {
fmt.Println(it)
}
fmt.Println("slice3")
for _, it := range slice3 {
fmt.Println(it)
}
defer
关键字 defer 用来将一个语句推迟到函数结束时调用。并遵循以下规则:
函数的调用顺序与 defer 声明的顺序相反。也就是说先声明的被后调用
defer 执行的函数的参数在声明的时候被固定。也就是说参数的传递是拷贝而不是引用
func main(){
if true {
defer fmt.Println("defer")
}
fmt.Println("main body")
}
输出结果为 :
main body defer
字典
字典的使用方式为:
map1 := map[string]string{}
map1["a"] = "b"
map1["c"] = "d"
for k, v := range map1 {
fmt.Println(k, v)
}
字典底层使用的是 hash 表,因此是无序的
面向对象
Go 语言和 Rust 类似,将数据和方法进行分离,函数的实现是面向接口的。
type Student struct {
id string
name string
age int16
}
type show interface {
output()
}
func (stu Student) output() {
fmt.Println(stu.id, stu.name, stu.age)
}
func main() {
stu := Student{"12345", "小明", 16}
stu.output()
}
可以看到,实际上和 Rust 走的路子相同。可以理解为特化接口。只是 Rust 从代码中可以理解出来,而 Go 相对隐晦
另外,和结构体绑定的函数称为方法,方法会将结构体按指针或值传递并绑定为参数:
type Stu struct {
id int
}
func (t Stu)setId(){
t.id = 20
}
func (t *Stu)setPid(){
t.id = 30
}
如代码所示,setId 传入的结构体为按值传入,setPid 传入的结构体则是按指针传入。第一个方法自然没办法实现想要的语义。
如果使用值类型调用 setPid,那么传入的实际上是值的一个副本(毕竟右值是没办法取地址的)
匿名字段
如代码所示:
type User struct {
id int
name string
}
type Manager struct {
User
}
这样,Manager 就获得了 User 的字段。这就是 Go 中的继承。自然,Manager 的同名方法也会覆盖 User 的同名方法
接口
Go 中的接口 是类型的一种 。接口用来保证实现此接口的结构体必定实现了此接口的函数。
接口同样可以被继承。另外,与其他语言中显式实现接口不同,Go 中 实现了接口中的所有方法也就实现了接口 。例如:
type Anmial interface {
say()
}
type Dog struct {
name string
}
func (self Dog) say() {
fmt.Println(self.name)
}
func main() {
var xab Anmial = Dog{}
dog := Dog{}
dog.name = "Dog"
xab.say()
}
如代码所示,只要实现了接口需要的函数,那么就实现了接口
错误处理
错误被声明为一个接口,然后通过返回值返回出去
func Sqrt(f float64) (float64, error) {
if f < 0 {
return 0, errors.New("error")
} else {
return math.Sqrt(f), errors.New("")
}
}
func main() {
if _, err := Sqrt(-1); err.Error() == "error" {
fmt.Println("error")
}
}
定义一个接口的方式只是简单地实现 Error 接口:
type ParameterError struct {
Message string
}
func (prarm *ParameterError) Error() string {
return prarm.Message
}
类型断言
类型断言可以当作强制类型转换,用来判断值是否能转换到目的类型。其格式为:
value, ok := x.(T)
当 x 可以转为类型 T 时,ok 不为 nil,且由 value 储存结果值。一个常见的用法是:
if exitError, ok := err.(*exec.ExitError); ok {
os.Exit(exitError.ExitCode())
} else {
panic(err)
}
协程
协程的开启非常简单,只需要在函数或者代码块前面添加 go 关键字即可:
func output(str string) {
for i := 10; i > 0; i-- {
time.Sleep(100 * time.Millisecond)
fmt.Println(str)
}
}
func main() {
go output("hello")
output("world")
}
注意:如果主线程结束的太快,协程可能由于得不到调度而退出(外观和线程一样),为了解决这个问题,可以使用 WaitGroup:
import (
"sync"
)
var waitGroup sync.WaitGroup
func func1(){
defer waitGroup.Done()
}
func main(){
waitGroup.Add(1)
go func1()
waitGroup.Wait()
}
WaitGroup.Add 设置了需要等待的协程数量,而 WaitGroup.Done() 会将该值减小 1。当减小为零时,WaitGroup.Wait() 退出
通道
通道用来在两个协程之间传递数据,通道有两种定义方式:
ch1 := make(chan int) // 声名一个无限容量的通道
ch2 := make(chan int, 3) // 声名一个具有三个容量缓存的通道
写一个已满的通道会导致写端被阻塞。
读无数据的通道,读端就会被阻塞。
如果一个通道在运行时没有了读端,那么此通道会被自动 close,再进行写就会触发 panic |
此外,通道还可以被关闭或者置为 nil :
ch1 := make(chan bool)
close(ch1)
ch2 := make(chan bool)
ch2 = nil
对已经关闭的通道进行写会触发 panic
对已经关闭的通道进行读会返回相关类型的零值
对值为 nil 的通道进行读写会触发 panic
如果通道被置为 nil,则不会被 select 选中
如下形式的通道调用会导致 ok 设置为 false:
x, ok := <-c
一个无缓存的使用例子为:
func sum(arr []int, c chan int) {
sum := 0
for _, v := range arr {
sum += v
}
c <- sum
}
func main() {
arr := []int{1, 2, 3, 4, 5}
c := make(chan int)
go sum(arr[:3], c)
go sum(arr[3:], c)
x, y := <-c, <-c
fmt.Println(x, y)
}
调试
Go 程序本身可以被 gdb 调试,但是默认的构建不利于调试。要想启用完整的 gdb 支持,构建时需要使用 :
$ go build -gcflags "-N -l"
主要是禁用了优化
在发布时构建则为 :
$ go build -ldflags "-s -w"
构建时初始化变量
Go 可以在构建时通过添加 -ldflags -X "importpath.varname=value"
的形式初始化变量。例如:
package main
import "fmt"
var (
version string
buildTime string
osArch string
)
func main() {
fmt.Printf("Version: %s\nBuilt: %s\nOS/Arch: %s\n", version, buildTime, osArch)
}
然后执行 :
$ go run -ldflags "-X 'main.version=0.1' -X 'main.buildTime=2022-03-25' -X 'main.osArch=darwin/amd64'" main.go
输出为 :
Version: 0.1 Built: 2022-03-25 OS/Arch: darwin/amd64
导出变量和函数
Go 使用约定来保证变量和函数的可见性。
只有首字母为大写的函数和变量才能为外部所看到。
例如:
type Student struct{
ID int // 外部可见
name string // 外部不可见
}
stu := Student{
ID: 10, // 正确
name : "xiaoming" // 错误
}
如果尝试初始化 name 变量,则会报错 :
implicit assignment to unexported field message in xxx literal compiler (UnexportedLitField)
与结构体类似,包中的变量和函数也是以类似的方式导出的
调用私有方法和成员
调用私有方法的方式是使用 go:linkname
注释:
//go:linkname setResources github.com/containerd/cgroups/v2.setResources
func setResources(path string, resources *v2.Resources) error
调用私有成员的方式是使用 unsafe
:
type Manager struct { // v2.Manager for access private path
_ string
path string
}
p := *(*Manager)(unsafe.Pointer(manager))
if err := setResources(p.path, &v.Resources); err != nil {
if err := manager.Delete(); err != nil {
return err
}
return err
}
Go Mod
使用指定版本的分支 :
github.com/containerd/cgroups main
然后直接 go build
,会自动更改为 hash :
github.com/containerd/cgroups v1.0.4-0.20220317195426-f8328fdc061d
泛型
若一个类型指定了[类型参数],则此类型成为泛型类型。
type List[T any] struct {
next *List[T]
value T
}
类型参数
类型参数的语法如下:
TypeParameters = "[" TypeParamList [ "," ] "]" .
TypeParamList = TypeParamDecl { "," TypeParamDecl } .
TypeParamDecl = IdentifierList TypeConstraint .
其中 TypeConstraint
即为:
TypeElem = TypeTerm { "|" TypeTerm } .
TypeTerm = Type | UnderlyingType .
UnderlyingType = "~" Type .
类型约束
泛型中可以使用类型约束简化对 T 类型的描述,语法为:
type Number interface {
int | float32
}
这等价于:
func Add[T int | float32](a T, b T) T {
return a + b
}
类型断言
可以通过类型断言来获取模板的具体类型:
func Add[T int | float32](a T, b T) T {
switch any(a).(type) {
case int:
println("int")
case float32:
println("float")
}
return a + b
}
内存管理
除了使用生命的方式创建对象外,Go 还可以提供了 make 来创建 slice, map 或者 chan、提供了 new 来分配内存。new 分配的内存是初始化为零的,类似于 calloc。
尽管 new 返回的是指针,但是并不意味着类型是分配在堆上的。 |
Go 会尽可能将对象分配在栈上,但是以下情况除外:
变量太大。
变量被取地址且发生了逃逸。
然而,若被分配的内存大小为 0。即使变量通过了逃逸分析,依然不会发生内存分配。这是因为 mallocgc 中会对分配的大小进行检查:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// ...
if size == 0 {
return unsafe.Pointer(&zerobase)
}
// ...
}