Go的基本概念

在 Go 中,程序是通过将包链接在一起来构建的。Go 中进行导入的最基本单位是一个包,而不是.go文件。包其实就是一个文件夹,英文名 package,包内共享所有变量,常量,以及所有定义的类型。包的命名风格建议都是小写字母,并且要尽量简短。

可见性

Go并没有public,pravite等关键字,所以Go提供了另外一种简洁的方式去实现可见性,它控制可见性的方式非常简单,规则如下

  • 名称大写字母开头,即为公有类型/变量/常量
  • 名字小写或下划线开头,即为私有类型/变量/常量
1
2
3
4
5
6
7
package example

// 公有
const MyName = "jack"

// 私有
const mySalary = 20_000

导入包

  1. 导入一个包就是导入这个包的所有公有的类型/变量/常量,导入的语法就是import加上包名
    1
    2
    3
    4
    5
    6
    package main

    import (
    "example"
    "example1"
    )
  2. 如果有包名重复了,或者包名比较复杂,你也可以给它们起别名
    1
    2
    3
    4
    5
    6
    package main

    import (
    e "example"
    e1 "example1"
    )
  3. 匿名导入包
    匿名导入的包无法被使用,这么做通常是为了加载包下的init函数,但又不需要用到包中的类型,例如一个常见的场景就是注册数据库驱动
    1
    2
    3
    4
    5
    6
    package main

    import (
    e "example"
    _ "mysql-driver"
    )

    Go 中无法进行循环导入,不管是直接的还是间接的。例如包 A 导入了包 B,包 B 也导入了包 A,这是直接循环导入,包 A 导入了包 C,包 C 导入了包 B,包 B 又导入了包 A,这就是间接的循环导入,存在循环导入的话将会无法通过编译。

  4. 内部包
    go 中约定,一个包内名为internal 包为内部包,外部包将无法访问内部包中的任何内容,否则的话编译不通过

运算符

  1. go 语言中没有选择将~作为取反运算符,而是复用了^符号,当两个数字使用^时,例如a^b,它就是异或运算符,只对一个数字使用时,例如^a,那么它就是取反运算符。go 也支持增强赋值运算符,如下
    1
    2
    3
    a += 1
    a /= 2
    a &^= 2

    在 Go 语言中,& 和 ^ 是两个不同的运算符,它们可以组合成 &^,具有特定的意义。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    1. ^ 是 按位取反 (^) 的含义:
    - 对每个位取反:0 变成 11 变成 0
    - 适用于整数类型(如 int, uint 等)

    2. &^ 是一个位操作符,用于 按位清除。它对左操作数的位值执行清除操作,根据右操作数的对应位来决定是否清除,广泛用于位操作场景。

    package main

    import "fmt"

    func main() {
    a := 6 // 二进制: 0110
    b := 3 // 二进制: 0011

    result := a &^ b
    fmt.Println("Result:", result) // 结果: 4 (二进制: 0100)
    }

Go 语言中没有自增与自减运算符,它们被降级为了语句statement,并且规定了只能位于操作数的后方,所以不用再去纠结i++和++i这样的问题。

  • a++ // 正确
  • ++a // 错误
  • a— // 正确
    还有一点就是,它们不再具有返回值,因此a = b++这类语句的写法是错误的。

字面量

编程语言源程序中表示固定值的符号叫做字面量,也称字面常量。一般使用裸字符序列来表示不同类型的值。字面量可以被编程语言编译器直接转换为某个类型的值。Go的字面量可以出现在两个地方:一是用于常量和变量的初始化,二是用在表达式中作为函数调用实参。变量初始化语句中如果没有显式地指定变量类型,则Go编译器会结合字面量的值自动进行类型推断。Go中的字面量只能表达基本类型的值,Go不支持用户自定义字面量。

emmm,我没看懂

风格

Go 官方提供了一个格式化工具gofmt,通过命令行就可以使用,该格式化工具没有任何的格式化参数可以传递,仅有的两个参数也只是输出格式化过程,所以完全不支持自定义,也就是说所有通过此工具的格式化后的代码都是同一种代码风格,这会极大的降低维护人员的心智负担,所以在这一块追求个性显然是一个不太明智的选择。

函数花括号换行

在 Go 中所有的花括号都不应该换行

1
2
3
4
5
6
7
8
9
10
// 正确示例
func main() {
fmt.Println("Hello 世界!")
}

// 错误示例 :这样的代码连编译都过不了,所以 Go 强制所有程序员花函数后的括号不换行。
func main()
{
fmt.Println("Hello 世界!")
}

代码间隔

Go 中大部分间隔都是有意义的,从某种程度上来说,这也代表了编译器是如何看待你的代码的,例如下方的数学运算
29 + 1/32
众所周知,乘法的优先级比加法要高,在格式化后,*符号之间的间隔会显得更紧凑,意味着优先进行运算,而+符号附近的间隔则较大,代表着较后进行运算

花括号不可省略

在其它语言中的 if 和 for 语句通常可以简写,像下面这样

1
for (int i=0; i < 10; i++) printf("%d", i)

但在 Go 中不行,你可以只写一行,但必须加上花括号
1
for i := 0; i < 10; i++ {fmt.Println(i)}

Go 中没有三元表达式

变量

在 go 中的类型声明是后置的,变量的声明会用到var关键字,格式为var 变量名 类型名,变量名的命名规则必须遵守标识符的命名规则。
例如

1
2
3
var intNum int
var str string
var char byte

当要声明多个相同类型的变量时,可以只写一次类型
1
var numA, numB, numC int

当要声明多个不同类型的变量时,可以使用()进行包裹,可以存在多个()。
1
2
3
4
5
6
7
8
9
10
var (
name string
age int
address string
)

var (
school string
class int
)

一个变量如果只是声明而不赋值,那么变量存储的值就是对应类型的零值。

变量赋值

  1. 官方提供的语法糖:短变量初始化,可以省略掉var关键字和后置类型,具体是什么类型交给编译器自行推断
    1
    name := "jack" // 字符串类型的变量。
  2. 短变量声明方式无法对一个已存在的变量使用,比如
    1
    2
    3
    4
    5
    6
    7
    // 错误示例
    var a int
    a := 1

    // 错误示例
    a := 1
    a := 2
  3. 但是有一种情况除外,那就是在赋值旧变量的同时声明一个新的变量,比如

    1
    2
    a := 1
    a, b := 2, 2
  4. 在 go 语言中,有一个规则,那就是所有在函数中的变量都必须要被使用,比如下面的代码只是声明了变量,但没有使用它

    1
    2
    3
    4
    5
    func main() {
    a := 1
    }

    // 报错:a declared and not used

    这个规则仅适用于函数内的变量,对于函数外的包级变量则没有这个限制,下面这个代码就可以通过编译

    1
    2
    3
    4
    5
    var a = 1

    func main() {

    }

    匿名

    用下划线可以表示不需要某一个变量

    1
    2
    3
    4
    //Open有两个变量
    Open(name string) (*File, error)
    //比如os.Open函数有两个返回值,我们只想要第一个,不想要第二个,可以按照下面这样写
    file, _ := os.Open("readme.txt")

    交换

    在 Go 中,如果想要交换两个变量的值,不需要使用指针,可以使用赋值运算符直接进行交换,语法上看起来非常直观,例子如下

    1
    2
    num1, num2, num3 := 25, 36, 49
    num1, num2, num3 = num3, num2, num1

代码块

在函数内部,可以通过花括号建立一个代码块,代码块彼此之间的变量作用域是相互独立的。例如下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
a := 1

{
a := 2
fmt.Println(a)
}

{
a := 3
fmt.Println(a)
}
fmt.Println(a)
}
//它的输出是
2
3
1

数组

如果事先就知道了要存放数据的长度,且后续使用中不会有扩容的需求,就可以考虑使用数组,Go 中的数组是值类型,而非引用,并不是指向头部元素的指针。

数组作为值类型,将数组作为参数传递给函数时,由于 Go 函数是传值传递,所以会将整个数组拷贝。

还可以通过内置函数len来访问数组元素的数量 len(nums)
内置函数cap来访问数组容量,数组的容量等于数组长度,容量对于切片才有意义。 cap(nums)

切割

切割数组的格式为arr[startIndex:endIndex],切割的区间为左闭右开,数组在切割后,就会变为切片类型,例子如下

1
2
3
4
5
nums := [5]int{1, 2, 3, 4, 5}
nums[1:] // 子数组范围[1,5) -> [2 3 4 5]
nums[:5] // 子数组范围[0,5) -> [1 2 3 4 5]
nums[2:3] // 子数组范围[2,3) -> [3]
nums[1:3] // 子数组范围[1,3) -> [2 3]

切片

切片的初始化方式有以下几种

1
2
3
4
var nums []int // 值
nums := []int{1, 2, 3} // 值
nums := make([]int, 0, 0) // 值
nums := new([]int) // 指针

可以看到切片与数组在外貌上的区别,仅仅只是少了一个初始化长度。通常情况下,推荐使用make来创建一个空切片,只是对于切片而言,make函数接收三个参数:类型,长度,容量。举个例子解释一下长度与容量的区别,假设有一桶水,水并不是满的,桶的高度就是桶的容量,代表着总共能装多少高度的水,而桶中水的高度就是代表着长度,水的高度一定小于等于桶的高度,否则水就溢出来了。所以,切片的长度代表着切片中元素的个数,切片的容量代表着切片总共能装多少个元素,切片与数组最大的区别在于切片的容量会自动扩张,而数组不会。

切片的底层实现依旧是数组,是引用类型,可以简单理解为是指向底层数组的指针.
通过var nums []int这种方式声明的切片,默认值为nil,所以不会为其分配内存,而在使用make进行初始化时,建议预分配一个足够的容量,可以有效减少后续扩容的内存消耗。

切片可以通过append函数实现许多操作,函数签名如下,slice是要添加元素的目标切片,elems是待添加的元素,返回值是添加后的切片。

  1. 插入数据
    • 尾部插入数据
      1
      2
      3
      nums := make([]int, 0, 0)
      nums = append(nums, 1, 2, 3, 4, 5, 6, 7)
      fmt.Println(len(nums), cap(nums)) // 7 8 可以看到长度与容量并不一致。
      新 slice 预留的 buffer 容量 大小是有一定规律的。 在 golang1.18 版本更新之前网上大多数的文章都是这样描述 slice 的扩容策略的: 当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的 1.25 倍。 在 1.18 版本更新之后,slice 的扩容策略变为了: 当原 slice 容量(oldcap)小于 256 的时候,新 slice(newcap)容量为原来的 2 倍;原 slice 容量超过 256,新 slice 容量 newcap = oldcap+(oldcap+3*256)/4
  • 头部插入数据
    1
    2
    nums = append([]int{-1, 0}, nums...)
    fmt.Println(nums) // [-1 0 1 2 3 4 5 6 7 8 9 10]
  • 从中间下标 i 插入元素
    1
    2
    nums = append(nums[:i+1], append([]int{999, 999}, nums[i+1:]...)...)
    fmt.Println(nums) // i=3,[1 2 3 4 999 999 5 6 7 8 9 10]
  1. 删除元素
    切片元素的删除需要结合append函数来使用,现有如下切片
    1
    nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
  • 从头部删除 n 个元素
    1
    2
    nums = nums[n:]
    fmt.Println(nums) //n=3 [4 5 6 7 8 9 10]
  • 从尾部删除 n 个元素
    1
    2
    nums = nums[:len(nums)-n]
    fmt.Println(nums) //n=3 [1 2 3 4 5 6 7]
  • 从中间指定下标 i 位置开始删除 n 个元素
    1
    2
    nums = append(nums[:i], nums[i+n:]...)
    fmt.Println(nums)// i=2,n=3,[1 2 6 7 8 9 10]
  • 删除所有元素
    1
    2
    nums = nums[:0]
    fmt.Println(nums) // []

    多位切片

    先来看下面的一个例子:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    var nums [5][5]int
    for _, num := range nums {
    fmt.Println(num)
    }
    fmt.Println()
    slices := make([][]int, 5)
    for _, slice := range slices {
    fmt.Println(slice)
    }

    // 结果
    [0 0 0 0 0]
    [0 0 0 0 0]
    [0 0 0 0 0]
    [0 0 0 0 0]
    [0 0 0 0 0]

    []
    []
    []
    []
    []
    可以看到,同样是二维的数组和切片,其内部结构是不一样的。数组在初始化时,其一维和二维的长度早已固定,而切片的长度是不固定的,切片中的每一个切片长度都可能是不相同的,所以必须要单独初始化,切片初始化部分修改为如下代码即可。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    slices := make([][]int, 5)
    for i := 0; i < len(slices); i++ {
    slices[i] = make([]int, 5)
    }
    // 结果
    [0 0 0 0 0]
    [0 0 0 0 0]
    [0 0 0 0 0]
    [0 0 0 0 0]
    [0 0 0 0 0]

    拓展表达式

    只有切片才能使用拓展表达式

    切片与数组都可以使用简单表达式来进行切割,但是拓展表达式只有切片能够使用,该特性于 Go1.2 版本添加,主要是为了解决切片共享底层数组的读写问题,主要格式为如:slice[low:high:max] 需要满足关系low<= high <= max <= cap,使用拓展表达式切割的切片容量为max-low。
    例如下方的例子中省略了max,那么s2的容量就是cap(s1)-low,s2的长度则为4-3=1.索引从3(包括)开始,到4(不包括)结束
    1
    2
    3
    4
    5
    6
    7
    8
    9
    s1 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} // cap = 9
    s2 := s1[3:4] // cap = 9 - 3 = 6
    // 添加新元素,由于容量为6.所以没有扩容,直接修改底层数组
    s2 = append(s2, 1)
    fmt.Println(s2)
    fmt.Println(s1)
    //结果
    [4 1]
    [1 2 3 4 1 6 7 8 9]
    那么这么做就会有一个明显的问题,s1与s2是共享的同一个底层数组,在对s2进行读写时,有可能会影响的s1的数据。拓展表达式就是为了解决此类问题而生的,只需要稍微修改一下就能解决该问题
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    func main() {
    s1 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} // cap = 9
    s2 := s1[3:4:4] // cap = 4 - 3 = 1
    // 容量不足,分配新的底层数组
    s2 = append(s2, 1)
    fmt.Println(s2)
    fmt.Println(s1)
    }
    //结果
    [4 1]
    [1 2 3 4 5 6 7 8 9]

    map

    初始化一个 map 有两种方法,第一种是字面量,

    在 Go 中,map 的键类型必须是可比较的,比如string ,int是可比较的,而[]int是不可比较的,也就无法作为 map 的键

    第二种方法是使用内置函数make,对于 map 而言,接收两个参数,分别是类型与初始容量,map 是引用类型,零值或未初始化的 map 可以访问,但是无法存放元素,所以必须要为其分配内存。例子如下:
    1
    2
    3
    mp := make(map[string]int, 8)

    mp := make(map[string][]int, 10)

    map的访问

    访问一个 map 的方式就像通过索引访问一个数组一样
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    func main() {
    mp := map[string]int{
    "a": 0,
    "b": 1,
    "c": 2,
    "d": 3,
    }
    fmt.Println(mp["a"])
    fmt.Println(mp["b"])
    fmt.Println(mp["d"])
    fmt.Println(mp["f"])
    }
    // 结果
    0
    1
    3
    0
    通过代码可以观察到,即使 map 中不存在”f”这一键值对,但依旧有返回值。map 对于不存的键其返回值是对应类型的零值,并且在访问 map 的时候其实有两个返回值,第一个返回值对应类型的值,第二个返回值一个布尔值,代表键是否存在,例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    func main() {
    mp := map[string]int{
    "a": 0,
    "b": 1,
    "c": 2,
    "d": 3,
    }
    if val, exist := mp["f"]; exist {
    fmt.Println(val)
    } else {
    fmt.Println("key不存在")
    }
    }

    函数

    声明函数有两种办法,一种是通过func关键字直接声明,另一种就是通过var关键字来声明,如下所示
    1
    2
    3
    4
    5
    6
    7
    func sum(a int, b int) int {
    return a + b
    }

    var sum = func(a int, b int) int {
    return a + b
    }

    Go语言中不支持函数重载

函数返回值

  1. 当函数没有返回值时,不需要void,不带返回值即可。
  2. Go 允许函数有多个返回值,此时就需要用括号将返回值围起来。
    1
    2
    3
    4
    5
    6
    func Div(a, b float64) (float64, error) {
    if a == 0 {
    return math.NaN(), errors.New("0不能作为被除数")
    }
    return a / b, nil
    }
  3. Go 也支持具名返回值,不能与参数名重复,使用具名返回值时,return关键字可以不需要指定返回哪些值。
    1
    2
    3
    4
    5
    func SumAndMul(a, b int) (c, d int) {
    c = a + b
    d = a * b
    return
    }

    return关键字的优先级最高

    函数的延时调用

    defer关键字可以使得一个函数延迟一段时间调用,在函数返回之前这些 defer 描述的函数最后都会被逐个执行,看下面一个例子:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    func main() {
    Do()
    }

    func Do() {
    defer func() {
    fmt.Println("1")
    }()
    fmt.Println("2")
    }
    //结果
    2
    1
    当有多个 defer 描述的函数时,就会像栈一样先进后出的顺序执行
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    func main() {
    fmt.Println(0)
    Do()
    }

    func Do() {
    defer fmt.Println(1)
    fmt.Println(2)
    defer fmt.Println(3)
    defer fmt.Println(4)
    fmt.Println(5)
    }
    //结果
    0
    2
    5
    4
    3
    1

    函数的参数预计算

    对于延迟调用有一些反直觉的细节,比如下面这个例子
    1
    2
    3
    4
    5
    6
    7
    8
    9
    func main() {
    defer fmt.Println(Fn1())
    fmt.Println("3")
    }

    func Fn1() int {
    fmt.Println("2")
    return 1
    }
    这个坑还是比较隐晦的,可以猜一下结果
    [结果]

    2
    3
    1

    从这里我们不难看出defer只是延时了fmt.Println()函数,但是并不会影响Fn1()函数,这称为参数预计算