Go语言必知的5个核心知识点:init、路径、输出、切片、Map

Go

init 函数

Go 中的 init() 函数是特殊的初始化函数 ,用于包级别的初始化操作,它的执行时机有严格的规则,是 Go 程序启动流程中固定的一环。在当前包被导入 / 程序启动时,init() 函数会在 main 函数执行之前,自动被 Go 运行时调用执行,无需手动调用。

执行顺序(优先级从高到低)

Go 程序的初始化执行流程是固定的:

1、单个文件/包内规则

  • 一个源文件 中可以写多个 init() 函数 ,按代码顺序从上到下的顺序执行
  • 一个 下有多个文件,每个文件的 init() 都会执行,执行顺序由编译器的文件编译顺序决定
  • init() 函数无参数、无返回值,不能被手动调用

2、多个文件

  • 如果程序导入了多个包,且不相互依赖,按照import顺序执行
  • 不同package且相互依赖,最后被依赖的最先被执行

示例:main 包导入 pkgApkgA 导入 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 还是运行编译后的二进制文件,都能拿到正确路径。

go 复制代码
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 包,也可以用更简洁的方式获取

go 复制代码
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 输出值的类型

代码示例:

go 复制代码
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
go 复制代码
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

它们的共同点是都属于集合类的类型,并且,它们的值也都可以用来存储某一种类型的值(或者说元素)。它们最重要的不同是:数组类型的值的长度是固定的,而切片类型的值是可变长的。

切片的类型字面量中只有元素的类型,而没有长度,切片的长度可以自动地随着其中元素数量的增长而增长,但不会随着元素数量的减少而减小。

数组和切片的关系: 可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。

切片的长度和容量

go 复制代码
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的切片元素容量。

go 复制代码
// 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。

go 复制代码
var userAge map[string]int{
  "张三": 20,
  "李四": 25,
  "王五": 22,
}

for name, age := range userAge {
  fmt.Printf("姓名:%s,年龄:%d\n", name, age)
}

因为每个map的键都要进行哈希值的计算和查找,所以宽度越小的类型速度通常越快,类型的宽度是指它的单个值需要占用的字节数。比如,boolint8uint8类型的一个值需要占用的字节数都是1,因此这些类型的宽度就都是1

相关推荐
苏三说技术1 小时前
Claude Code从失控到起飞,只用了这些技巧
后端
长栎2 小时前
写 for 循环写了十年,你却从没用过迭代器模式最狠的那一面
后端
LiaCode2 小时前
Redis 在生产项目的使用
前端·后端
用户559822481222 小时前
Docker Compose Down 导致容器数据误删——ext4 日志恢复全记录
后端
LiaCode2 小时前
一天学完 redis 的爽翻版核心知识总结
前端·后端
大刚测试开发实战2 小时前
如何内网穿透访问本地私有化部署的TestHub
前端·后端·github
xiaodaoluanzha2 小时前
迄今為止,最簡單的編程語言 Nolang
前端·后端
Csvn2 小时前
Docker 容器管理入门 — 从镜像到容器编排
后端
用户762352425913 小时前
ShardingJDBC
后端
行者全栈架构师3 小时前
IDEA 中 Maven 项目的 15 个红色报错快速解决方法
java·后端