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

相关推荐
yhole2 小时前
SpringBoot + vue 管理系统
vue.js·spring boot·后端
mcooiedo2 小时前
springboot和springframework版本依赖关系
java·spring boot·后端
IT_陈寒2 小时前
SpringBoot自动配置把我坑惨了,原来它偷偷干了这么多事
前端·人工智能·后端
_Evan_Yao2 小时前
当AI能写SQL时,数据库表设计反而成了最后一道护城河
数据库·人工智能·后端·sql
skiy2 小时前
Spring WebFlux:响应式编程
java·后端·spring
zb200641202 小时前
springboot整合libreoffice(两种方式,使用本地和远程的libreoffice);docker中同时部署应用和libreoffice
spring boot·后端·docker
weixin_704266053 小时前
SpringCloud Feign 声明式服务调用
后端·spring·spring cloud
浪客川3 小时前
【百例RUST - 011】简单键值对
开发语言·后端·rust