Go
init 函数
Go 中的 init() 函数是特殊的初始化函数,用于包级别的初始化操作,它的执行时机有严格的规则,是 Go 程序启动流程中固定的一环。在当前包被导入 / 程序启动时,init() 函数会在 main 函数执行之前,自动被 Go 运行时调用执行,无需手动调用。
执行顺序(优先级从高到低)
Go 程序的初始化执行流程是固定的:
1、单个文件/包内规则
- 一个源文件中可以写多个
init()函数,按代码顺序从上到下的顺序执行 - 一个包下有多个文件,每个文件的
init()都会执行,执行顺序由编译器的文件编译顺序决定 init()函数无参数、无返回值,不能被手动调用
2、多个文件
- 如果程序导入了多个包,且不相互依赖,按照import顺序执行
- 不同package且相互依赖,最后被依赖的最先被执行
示例:main 包导入 pkgA,pkgA 导入 pkgB,执行顺序从最底层的pkgB开始执行
Go语言执行顺序
一、全局顺序
1、初始化被依赖的包(递归执行,深度优先)
2、初始化当前包(包级别 常量,包级别 变量(按声明顺序),执行包内的 init() 函数)
3、最后执行主文件中的main()函数
二、单个 Go 文件内部执行顺序(最常用)
同一个 .go 文件里,执行顺序严格如下:
1、import 导入(只触发依赖包初始化)
2、包级常量初始化
3、包级变量初始化
4、文件内的 init 函数(从上往下)
5、main 函数(如果是 main 包)
三、总结中的关键点
1、init 函数自动执行,不能手动调用
2、一个文件可以写多个 init,按从上到下执行
3、一个包无论被导入多少次,init 只执行一次
4、初始化顺序:常量 → 变量 → init → main
5、多包依赖:深度优先,被依赖包先初始化
项目根目录
在GO语言开发中,在 go run、编译二进制、docker、不同工作目录、不同系统下,是 5 个不同的路径,所以读取项目路径显得尤为重要:
os.Executable
是最稳定、最推荐的方法,不管是 go run 还是运行编译后的二进制文件,都能拿到正确路径。
package main
import (
"fmt"
"os"
"path/filepath"
)
// GetProjectRoot 获取项目根目录
func GetProjectRoot() (string, error) {
// 获取当前可执行文件的绝对路径
exePath, err := os.Executable()
if err != nil {
return "", err
}
// 获取可执行文件所在目录
exeDir := filepath.Dir(exePath)
// 如果是 go run 模式,exe 在临时目录,需要向上找 go.mod
if isGoRun() {
return findGoModDir(exeDir)
}
return exeDir, nil
}
// 判断是否是 go run 运行
func isGoRun() bool {
return filepath.Base(os.Args[0]) == "go" ||
filepath.Base(os.Args[0]) == "go.exe" ||
filepath.HasPrefix(os.Args[0], os.TempDir())
}
// 向上查找 go.mod 所在目录 = 项目根目录
func findGoModDir(startDir string) (string, error) {
dir := startDir
for {
modPath := filepath.Join(dir, "go.mod")
if _, err := os.Stat(modPath); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
// 已经到根目录还没找到,退出
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("未找到 go.mod,不是 Go 项目")
}
func main() {
root, err := GetProjectRoot()
if err != nil {
panic(err)
}
fmt.Println("项目根目录:", root)
}
slices 包
Go 1.21 提供了 slices 包,也可以用更简洁的方式获取
package main
import (
"log"
"os"
"path/filepath"
"golang.org/x/tools/go/packages"
)
func main() {
cfg := &packages.Config{Mode: packages.NeedModule}
pkgs, err := packages.Load(cfg, ".")
if err != nil {
log.Fatal(err)
}
root := pkgs[0].Module.Dir
log.Println("项目根目录:", root)
}
环境变量
- 通过GOPATH或者自定义项目根路径的环境变量
- 利用系统自带的环境变量和路径
输出打印
Go 语言通过标准库 fmt 包 实现格式化输出,核心函数是 fmt.Print()、fmt.Println()、fmt.Printf(),其中 fmt.Printf() 支持最灵活的格式化输出,是开发中最常用的。
| 函数 | 作用 | 特点 |
|---|---|---|
fmt.Print(a...) | 普通输出 | 不换行,多个参数直接拼接 |
fmt.Println(a...) | 换行输出 | 自动换行,参数间加空格 |
fmt.Printf(format, a...) | 格式化输出 | 按指定占位符格式输出,不自动换行 |
占位符以 % 开头,后跟类型标识,是 fmt.Printf 的核心。
| 占位符 | 作用 |
|---|---|
%v | 默认格式输出(最常用) |
%+v | 输出结构体时,显示字段名 |
%#v | 输出 Go 语法格式的值(调试用) |
%T | 输出值的类型 |
代码示例:
type User struct {Name string}
u := User{Name: "stark张宇"}
fmt.Printf("%v\n", u) // {stark张宇}
fmt.Printf("%+v\n", u) // {Name:stark张宇}
fmt.Printf("%T\n", u) // main.User
fmt.Printf("%%\n") // %
打印 布尔 / 字符串 / 指针占位符/十进制整数
| 占位符 | 作用 |
|---|---|
%t | 输出布尔值:true/false |
%s | 输出字符串 / 字节切片 |
%q | 输出带双引号的字符串 |
%p | 输出指针地址(十六进制) |
%d | 10 进制整数 |
%#v | 打印 map |
str := "hello"
fmt.Printf("%s\n", str) // hello
fmt.Printf("%q\n", str) // "hello"
fmt.Printf("%p\n", &str) // 0x14000010200
fmt.Printf("map = %#v\n", m) //
fmt.Printf("执行到:%s:%d\n", __FILE__, __LINE__)
fmt.Printf("a=%v, b=%v, c=%v\n", a, b, c)
数组Array、切片Slice
它们的共同点是都属于集合类的类型,并且,它们的值也都可以用来存储某一种类型的值(或者说元素)。它们最重要的不同是:数组类型的值的长度是固定的,而切片类型的值是可变长的。
切片的类型字面量中只有元素的类型,而没有长度,切片的长度可以自动地随着其中元素数量的增长而增长,但不会随着元素数量的减少而减小。
数组和切片的关系: 可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。
切片的长度和容量
s1 := make([]int, 5)
fmt.Printf("S1 length : %d , capacity %d ,value %d \n ", len(s1), cap(s1), s1)
s2 := make([]int, 5, 8)
fmt.Printf("S2 length : %d , capacity %d ,value %d \n ", len(s2), cap(s2), s2)
用make函数初始化切片时,如果不指明其容量,那么它就会和长度一致。如果在初始化时指明了容量,那么切片的实际容量也就是它了。这也正是s2的容量是8的原因。
通过调用内建函数len,得到数组和切片的长度,通过调用内建函数cap,我们可以得到它们的容量,但要注意,数组的容量永远等于其长度,都是不可变的。
Go语言 1.18+ 后 ,当执行 append 时,新长度 len(s)+n > 当前容量 cap(s) ,采用 分段式扩容 + 内存对齐,核心是 小切片翻倍、大切片 1.25 倍渐进增长。
规则:小切片(<256):2 倍扩容,大切片(≥256):~1.25 倍渐进增长,这里的256有一个常见的误区,256 就是纯数字 256,没有任何单位(不是 KB、不是 MB),是元素个数,cap的切片元素容量。
// 1. 容量 cap = 100 < 256 → 小切片
s1 := make([]int, 0, 100)
// 2. 容量 cap = 300 > 256 → 大切片
s2 := make([]int, 0, 300)
一个切片的底层数组永远不会被替换,当切片在扩容的时候 Go 语言一定会生成新的底层数组,但是它也同时生成了新的切片。需要注意的是在无需扩容时,append函数返回的是指向原底层数组的新切片,而在需要扩容时,append函数返回的是指向新底层数组的新切片。
只要新长度不会超过切片的原容量,那么使用append函数对其追加元素的时候就不会引起扩容,这只会使紧邻切片窗口右边的(底层数组中的)元素被新的元素替换掉。
扩容时底层内存变化
当切片发生扩容时,判断容量不够,Go 会申请一块新的、更大的连续内存空间生成新底层数组,把旧数组里的所有元素复制到新数组,把新追加的元素也放进去,切片内部的指针从旧数组地址切换到新数组,旧数组只要还有引用,旧数组就会一直占内存,如果没有任何变量、切片再引用旧数组,Go 垃圾回收(GC)会在未来某个时间自动回收
字典 Map
Map这个词的本意在数学里指的是键值对的映射集合体,Go 语言的字典类型其实是一个哈希表(hash table)的特定实现,哈希表会先用哈希函数(hash function)把键值转换为哈希值。哈希值通常是一个无符号的整数,一个哈希表会持有一定数量的桶(bucket),元素就存在哈希桶中。
每个哈希桶都会把自己包含的所有键的哈希值存起来,Go 语言会用被查找键的哈希值与这些哈希值逐个对比,看看是否有相等的,如果一个相等的都没有,那么就说明这个桶中没有要查找的键值,这时 Go 语言就会立刻返回结果了。
因为上述所说字典中键的查找原理,就要求键类型的值必须要支持判等操作,要求键类型不可以是函数类型、字典类型和切片类型,而元素却可以是任意类型的, 否则在程序运行过程中会引发 panic。
var userAge map[string]int{
"张三": 20,
"李四": 25,
"王五": 22,
}
for name, age := range userAge {
fmt.Printf("姓名:%s,年龄:%d\n", name, age)
}
因为每个map的键都要进行哈希值的计算和查找,所以宽度越小的类型速度通常越快,类型的宽度是指它的单个值需要占用的字节数。比如,bool、int8和uint8类型的一个值需要占用的字节数都是1,因此这些类型的宽度就都是1。