前端转型:自学 golang 入门

0 阅读1小时+

我是前端鱼姐,最近失业了,准备自学 golang 转型,有不足的地方还请有转型成功的前辈或者技术大佬来指导一二。

学习资料:

Golang标准库文档(官方)

go菜鸟教程

Go 语言没有「类 (class)」,也没有「继承」,它彻底抛弃了传统 OOP 的复杂特性,用「结构体 + 方法」实现了面向对象的所有核心能力,比 Java 的 OOP 更简洁、更灵活。

go的在线编辑器

一、语言结构

package main // 包声明

import "fmt" // 引入包

func main() {
   /* 这是我的第一个简单的程序 */
   fmt.Println("Hello, World!")
}

包声明

必须在源文件中非注释的第一行指明这个文件属于哪个包,如:package main。package main表示一个可独立执行的程序,每个 Go 应用程序都包含一个名为 main 的包。

Go 语言没有 public/private/protected 这类权限关键字,访问权限完全由「标识符的首字母大小写」决定 。

  • 首字母 小写 → 包内私有,仅同文件夹(同包)的代码能调用
  • 首字母 大写 → 包外公有,其他文件夹(其他包)导入后可以调用

如何优雅拆分代码?

按功能拆分子文件夹,一个功能一个文件夹(一个包) ,Go 推荐「小而美」的包设计,一个包只做一件事。

├─ go_demo/          # 项目根目录
│  ├─ main.go        # package main 程序入口,只有这个文件写main包
│  ├─ user/          # 用户模块 → package user
│  │  ├─ user.go
│  │  └─ login.go
│  ├─ order/         # 订单模块 → package order
│  │  └─ order.go
│  └─ utils/         # 工具模块 → package utils
│     ├─ str.go
│     └─ num.go

规则:

  1. 同一个文件夹下的所有 .go 文件,package 包名 必须完全一致,否则编译必报错;
  2. Go 中「文件夹 = 包」,物理目录和逻辑包一一对应,约定优于配置;
  3. 包名可以≠文件夹名,但建议一致;包名小写、简洁、语义化;
  4. 同包下的所有代码,无需导入,直接互相调用
  5. ****一个项目中,只能有一个 package main + func main(),不能多个文件写多个main函数。

执行程序

(1)直接运行

****编译 + 运行 一步完成,不生成可执行文件,适合开发调试、单文件 / 简单小程序 快速验证逻辑;

go run hello.go

(2) 编译后运行

先编译生成可执行文件,再执行文件,适合项目上线、多文件工程、需要分发程序 的场景; 没有平台限制。

go build main.go

go 的多包标准项目

├─ go_project/  【项目根目录】
│  ├─ main.gopackage main + func main() 程序入口】
│  ├─ user/      【用户模块,依赖包】
│  │  ├─ user.gopackage user】
│  │  └─ login.gopackage user】
│  └─ utils/     【工具模块,依赖包】
│     ├─ str.gopackage utils】
│     └─ num.gopackage utils】
  • main.go 是唯一的入口,package main + func main()
  • user/utils/库包,包名分别是user/utils,只能被main包导入调用,不能直接运行;
  • 库包内的函数 / 结构体,首字母大写才能被main包调用(访问权限)。

行分隔符

在 Go 程序中,一行代表一个语句结束。每个语句不需要像 C 家族中的其它语言一样以分号 ; 结尾,因为这些工作都将由 Go 编译器自动完成。

fmt.Println("Hello, World!")
fmt.Println("菜鸟教程:runoob.com")

注释

  1. 单行注释://
  2. 多行注释: /* --- */
// 单行注释
/*
 Author by 菜鸟教程
 我是多行注释
 */

格式化字符串

按指定格式拼接 / 格式化数据,并返回一个新的字符串,不会直接打印内容(和 fmt.Printf 的唯一区别:前者返回字符串,后者直接输出到控制台),日常开发中字符串拼接、数据格式化、日志输出等场景百分百用到。


该函数在 fmt 包下,使用前需要 import "fmt" ,无任何依赖。


必记占位符(优先级排序)

  • 万能兜底%v → 适配所有类型,新手首选;
  • 基础类型主力%d(整数)、%s(字符串)、%.2f(浮点保留 2 位)、%t(布尔);
  • 常用进阶%06d(补零,其中6表示补0之后的总位数)、%5s(补空格对齐)、%p(指针)、%%(输出 %)。
package main
import "fmt"

func main() {
	// 1. 拼接用户信息(字符串+整数+浮点)
	name, age, salary := "王五", 30, 20000.50
	userInfo := fmt.Sprintf("用户:%s,年龄:%d,月薪:%.2f 元", name, age, salary)
	fmt.Println(userInfo) // 用户:王五,年龄:30,月薪:20000.50 元

	// 2. 生成固定长度订单号(补零)
	orderId := 1234
	orderNo := fmt.Sprintf("GO%08d", orderId)
	fmt.Println(orderNo) // GO00001234

	// 3. 保留百分比(浮点+字符串拼接)
	rate := 0.9567
	percent := fmt.Sprintf("成功率:%.2f%%", rate*100) // %% 表示输出一个%符号
	fmt.Println(percent) // 成功率:95.67%

	// 4. 日志格式化(对齐+多类型)
	level, msg := "INFO", "服务启动成功"
	log := fmt.Sprintf("[%5s] %s", level, msg)
	fmt.Println(log) // [ INFO ] 服务启动成功

	// 5. 指针地址打印
	num := 666
	ptrLog := fmt.Sprintf("变量值:%d,内存地址:%p", num, &num)
	fmt.Println(ptrLog) // 变量值:666,内存地址:0xc0000100a8

	// 6. 布尔值拼接
	ok := true
	boolStr := fmt.Sprintf("操作是否成功:%t", ok)
	fmt.Println(boolStr) // 操作是否成功:true
}
格 式描 述
%v按值的本来值输出
%+v在 %v 基础上,对结构体字段名和值进行展开
%#v输出 Go 语言语法格式的值
%T输出 Go 语言语法格式的类型和值
%%输出 % 本体
%b整型以二进制方式显示
%o整型以八进制方式显示
%d整型以十进制方式显示
%x整型以十六进制方式显示
%X整型以十六进制、字母大写方式显示
%UUnicode 字符
%f浮点数
%p指针,十六进制方式显示

指针

指针是一个变量,但它存储的不是普通的值(比如数字、字符串),而是另一个变量的内存地址

符号作用示例
&取地址符:获取变量的内存地址&a表示获取变量 a 的地址
*解引用符:通过地址访问变量的值*p表示访问指针 p 指向的变量的值

二、数据类型

Go 语言是 强类型静态语言,核心特点:变量声明时必须指定类型(或自动推导),类型一旦确定,程序运行期间不能改变。

****所有数据类型的变量,声明后必须使用,否则 Go 编译器会直接报错,这是 Go 的强制语法(避免无用代码)。

值类型

变量直接存储「数据本身」,内存中存的是值,赋值 / 传参时,会拷贝一份全新的数据,新旧变量互不影响。 包括:

  • 基础类型:整数、浮点、布尔、字符串、字符( byte/rune )
  • 复合值类型:数组、结构体

1. 布尔值

  • 取值:只有两个固定值true (真) / false (假)
  • 占用内存:1 个字节
  • 格式化占位符:%t
  • 注意点:
    1. 不能用 0/1 代替 true/false,和 C 语言不同;
    2. 布尔值不能参与算术运算(比如 true+1 编译报错);
func main() {
	var flag bool = true
	ok := false // 自动推导类型
	fmt.Printf("类型:%T,值:%t\n", flag, ok) // 类型:bool,值:false
}

2. 整数类型

Go 的整数类型区分符号 / 无符号区分位数,设计非常严谨,全部是值类型,格式化占位符统一为 %d

(1)有符号整数 (可存正数 / 负数 / 0)

  • int8 :占 1 字节,范围 -128 ~ 127
  • int16:占 2 字节,范围 -32768 ~ 32767
  • int32:占 4 字节,范围 -2147483648 ~ 2147483647
  • int64:占 8 字节,范围 -9223372036854775808 ~ 9223372036854775807
  • int最常用! 占 4/8 字节,根据操作系统自动适配(32 位系统 = int32,64 位 = int64)

(2) 无符号整数(只能存正数 / 0)

  • uint8(byte) :占 1 字节,范围 0 ~ 255byteuint8的别名,专门存「字节」
  • uint16:占 2 字节,范围 0 ~ 65535
  • uint32:占 4 字节,范围 0 ~ 4294967295
  • uint64:占 8 字节,范围 0 ~ 18446744073709551615
  • uint :占 4/8 字节,和操作系统适配,无符号版的int

整数使用「黄金原则」(必遵守,避坑)

  • 日常开发优先用 int:不用纠结位数,自动适配系统,兼容性最好;
  • 高精度计算用 int64:比如金额、数量统计,避免溢出;
  • 字节操作 / ASCII 码用 byte(uint8) :比如字符串转字节数组;
  • 格式化占位符:统一用 %d,进制转换用 %b(二进制)、%o(八进制)、%x(十六进制)

3. 浮点类型(小数,float32/float64)

  • 类型:float32(单精度,4 字节)、float64(双精度,8 字节)
  • 黄金原则:开发中无脑用 float64,精度更高,计算更准确,是 Go 的默认浮点类型
  • 格式化占位符:%f(默认保留 6 位小数)、%.2f(保留 2 位,四舍五入,最常用)、%e(科学计数法)
  • 注意点:浮点型存储是近似值,不要用==比较两个浮点数是否相等!

4. 字符串

定义:

用双引号 "内容" 或反引号 `多行内容` 包裹。Go 语言的字符串的字节使用 UTF-8 编码标识 Unicode 文本。

特点:

  1. Go 的字符串是 不可变的:字符串一旦声明,内容不能修改(比如 str[0] = 'a' 编译报错);
  2. 字符串底层是 byte 字节数组,支持下标访问(str[0] 取第一个字节);
  3. 天然支持中文,UTF-8 编码,一个中文占 3 个字节;

格式化占位符:

%s(最常用)、%q(带双引号)、%x(转 16 进制)

5. 数组

数组是 同一种数据类型元素的固定长度序列

数组声明的固定格式:var 数组名 [数组长度]数据类型

  • 数组长度:必须是「常量」,不能是变量(比如不能用 var n int=5; var arr [n]int,编译报错)
  • 下标索引:永远从 0 开始,最后一个元素的下标是 长度-1
  • 未初始化的数组,会被 Go 赋予默认零值:int→0、string→空串、bool→false、float→0.0
5.1. 数组的声明与初始化

(1) 标准声明 + 后续赋值(最基础)

package main
import "fmt"

func main() {
	// 声明:长度为3,类型为int的数组,默认值 [0 0 0]
	var arr [3]int
	// 赋值:通过下标逐个赋值
	arr[0] = 10
	arr[1] = 20
	arr[2] = 30
	fmt.Println(arr) // 输出:[10 20 30]
}

(2)声明时赋值(最常用)

****用大括号 {} 直接赋初始值,推荐日常开发使用

func main() {
	var arr1 [3]int = [3]int{1, 2, 3}
	var arr2 [2]string = [2]string{"Go语言", "数组"}
	var arr3 [2]bool = [2]bool{true, false}
	fmt.Println(arr1) // [1 2 3]
	fmt.Println(arr2) // [Go语言 数组]
}

(3)自动推导长度(语法糖)

[...] 替代「具体长度」,Go 编译器会自动根据初始化的元素个数,计算数组长度,完美解决「数元素个数」的麻烦,日常开发 90% 的场景用这个!

func main() {
	// 编译器自动识别:长度=5
	var arr1 = [...]int{10, 20, 30, 40, 50}
	// 简写:省略var,用 := 变量声明(函数内可用),最简洁写法
	arr2 := [...]string{"Java", "Python", "Go"}
	fmt.Printf("arr1的长度:%d,值:%v\n", len(arr1), arr1) // 长度:5,值:[10 20 30 40 50]
	fmt.Printf("arr2的长度:%d,值:%v\n", len(arr2), arr2) // 长度:3,值:[Java Python Go]
}

(4)指定下标赋值(灵活初始化)

初始化时指定下标 + 对应值,未指定的下标自动填充「零值」,适合数组元素多、只有部分元素需要赋值的场景 。

func main() {
	// 长度=5,下标0赋值1,下标3赋值9,其余为0
	arr := [5]int{0: 1, 3: 9}
	fmt.Println(arr) // 输出:[1 0 0 9 0]
	// 结合自动推导长度,只给下标2赋值666
	arr2 := [...]int{2: 666}
	fmt.Println(arr2) // 输出:[0 0 666]
}

注意:

在 Go 语言中,数组的长度属于数组类型的组成部分

[3]int[5]int是完全不同的两种数据类型!!!

数组的地址就是数组第一个元素的地址,第二个元素的地址就是第一个元素的地址加第二个元素的占位。

5.2. 数组的遍历

(1)for 循环 + 下标遍历

func main() {
	arr := [...]string{"Go", "Java", "Python", "PHP"}
	// len(arr) 获取数组长度,i是下标
	for i := 0; i < len(arr); i++ {
		fmt.Printf("下标:%d,值:%s\n", i, arr[i])
	}
}

(2) for range 遍历( Go 专属写法,推荐优先使用)

语法:for 下标, 元素值 := range 数组名 { ... }

func main() {
	arr := [...]int{10, 20, 30, 40}
	// 完整写法:获取下标和值
	for i, v := range arr {
		fmt.Printf("下标:%d,值:%d\n", i, v)
	}

	fmt.Println("----------分割线----------")
	
	// 简化写法:只获取值,忽略下标
	for _, v := range arr {
		fmt.Printf("值:%d\n", v)
	}
}
  • Go 语言的键值对遍历语法,专门为集合(数组、切片、map)设计;
  • 如果不需要「下标」,可以用下划线 _ 接收(Go 的「空标识符」,表示忽略该值)。
  • 因为数组是值类型,所以如果业务需要修改原数组,不要直接传数组,而是传递「数组的指针」
5.3. 二维数组
  • 二维数组定义:var 数组名 [行数][列数]数据类型
  • 核心规则:只有最外层可以用 [...] 自动推导长度,内层必须指定固定长度
func main() {
	// 1. 声明并初始化二维数组(3行2列)
	arr1 := [3][2]int{{1, 2}, {3, 4}, {5, 6}}
	// 2. 自动推导外层行数,内层列数固定
	arr2 := [...][2]int{{10, 20}, {30, 40}}
	fmt.Println(arr1) // [[1 2] [3 4] [5 6]]
	fmt.Println(arr2) // [[10 20] [30 40]]

	// 3. 二维数组遍历:嵌套for range
	for i, row := range arr1 {
		for j, val := range row {
			fmt.Printf("行:%d,列:%d,值:%d\n", i, j, val)
		}
	}
}
5.4. 数组的比较

数组的长度相同 + 元素类型相同 + 元素值完全相同,就返回 true

func main() {
	arr1 := [3]int{1,2,3}
	arr2 := [3]int{1,2,3}
	arr3 := [3]int{1,2,4}
	arr4 := [2]int{1,2}

	fmt.Println(arr1 == arr2) // true
	fmt.Println(arr1 == arr3) // false
	// fmt.Println(arr1 == arr4) // 编译报错:长度不同的数组类型不同,无法比较
}
5.5. 为什么 Go 中很少直接用数组,而是用切片(slice)?

答:核心原因就是数组的两个特性:固定长度 + 值类型拷贝

  • 固定长度:业务中数据的长度往往是动态的,数组无法满足;
  • 值类型拷贝:数组元素多的时候,拷贝会占用大量内存,效率极低。→ 切片(slice)是数组的封装,解决了数组的所有痛点,这是 Go 中处理「动态序列」的首选,数组是切片的底层基础,学好数组才能学好切片!
5.6. 注意
  • Go 数组 无任何内置方法,只有 len(arr)cap(arr) 2 个通用内置函数可用,且 cap(arr) = len(arr)
  • 数组是「固定长度、值类型」,增删改查的动态操作,必须转切片 实现;
  • 所有数组的排序、查找、筛选,都是「遍历 + 逻辑判断」或「标准库 sort 包」实现。
5.7. 数组的查找和排序

排序的基本介绍

将数据按指定顺序进行排序的过程。

分类:

  • 内部排序:数据量小,将所有数据加载到内部存储进行排序。包括交换排序(冒泡排序、快速排序 ),选择式排序和插入式排序
  • 外部排序:数据量大,无法将全部加载到内存中,需要借助外部存储进行排序。包括合并排序,直接合并排序

冒泡排序:

6. 结构体

Go 中没有类 (class) 的概念,结构体就是 Go 实现面向对象编程思想(封装、属性、方法)的核心载体,也是组织和管理一组「不同类型数据」的核心方式,是必须吃透的知识点。

本质是一组具有「不同 / 相同数据类型」的字段 (field) 的集合

6.1. 定义

结构体定义语法:

type 结构体名 struct {
    字段名1 字段类型
    字段名2 字段类型
    字段名3 字段类型
    // ... 任意多个字段
}
  • type:Go 的关键字,用于自定义类型
  • 结构体名:遵循 Go 的命名规范,首字母大写表示外部包可访问,小写表示仅当前包可访问(比如Person可跨包,person仅当前包)
  • struct:关键字,标识这是一个结构体定义
  • 字段:结构体的「属性」,由「字段名 + 字段类型」组成,字段名和类型之间空格分隔
  • 对比数组 / 切片:数组、切片要求所有元素类型必须一致,结构体无此限制,这是结构体最核心的优势。

示例:

// 定义一个Person结构体,封装:姓名、年龄、身高、是否成年
type Person struct {
	Name    string  // 姓名:字符串类型
	Age     int     // 年龄:整型
	Height  float64 // 身高:浮点型
	IsAdult bool    // 是否成年:布尔型
}

定义了结构体只是声明了「数据模板」,必须创建结构体变量(实例) 并赋值,才能真正使用。Go 中结构体初始化有 4 种常用方式,从简单到灵活,全部都要掌握,日常开发都会用到。

6.2. 结构体初始化

(1) 声明结构体变量,默认零值初始化

语法: var 变量名 结构体名

  • 特点:声明后,结构体的所有字段都会被赋「零值」(Go 中所有变量都有默认零值)
  • 零值规则:string→空字符串"",int→0,float→0,bool→false,切片→nil
func main() {
    // 声明一个Person类型的变量p,所有字段默认零值
	var p Person
	fmt.Println(p) // { 0 0 false }
	// 单独给字段赋值:变量名.字段名
	p.Name = "张三"
	p.Age = 20
	p.Height = 175.5
	p.IsAdult = true
	fmt.Println(p) // {张三 20 175.5 true}
    // 单独访问某个字段
	fmt.Println("姓名:", p.Name) // 姓名: 张三
	fmt.Println("年龄:", p.Age)  // 年龄: 20
}

(2) 声明变量时,按「字段定义顺序」赋值(顺序初始化)

语法:变量名 := 结构体名{值1, 值2, 值3...}

  • 特点:必须和结构体定义的字段顺序、数量完全一致,不能跳过任何字段
  • 优点:写法简洁;缺点:灵活性差,字段顺序改了就报错,不推荐在字段多的场景用
func main() {
    // 按顺序赋值:Name→Age→Height→IsAdult
	p := Person{"李四", 18, 168.9, false}
	fmt.Println(p) // {李四 18 168.9 false}
}

(3) 指定「字段名」赋值(键值对初始化, ⭐⭐⭐ 最常用)

语法:变量名 := 结构体名{字段名1:值1, 字段名2:值2,...}

✅ 核心优点(重点):

  1. 字段顺序随意,不用和结构体定义顺序一致;
  2. 支持部分字段赋值,未赋值的字段自动赋零值;
func main() {
    // 只给部分字段赋值,顺序随意
	p1 := Person{Name: "王五", Age: 25}
	fmt.Println(p1) // {王五 25 0 false}  未赋值的Height/IsAdult是零值

    // 给全部字段赋值,顺序打乱
	p2 := Person{
		Height:  180.2,
		Name:    "赵六",
		IsAdult: true,
		Age:     22,
	}
	fmt.Println(p2) // {赵六 22 180.2 true}
}

(4) 使用 new 关键字创建(返回结构体指针)

语法:变量名 := new(结构体名)

  • 特点 1:new 是 Go 内置函数,作用是分配内存,返回的是「结构体的指针类型」(*结构体名
  • 特点 2:结构体的所有字段依然是零值初始化
  • 特点 3:通过指针访问结构体字段时,Go 支持 自动解引用,既可以写 (*p).字段名,也可以直接写 p.字段名(推荐)
func main() {
    // new返回指针,p的类型是 *Person
	p := new(Person)
	fmt.Printf("变量类型:%T,值:%v\n", p, p) // 变量类型:*main.Person,值:&{ 0 0 false}

	// 方式1:标准指针解引用赋值(繁琐,不推荐)
	(*p).Name = "钱七"
	(*p).Age = 30

	// 方式2:Go自动解引用,直接 指针.字段名 赋值(推荐写法)
	p.Height = 178.5
	p.IsAdult = true

	fmt.Println(*p)  // {钱七 30 178.5 true}  打印指针指向的结构体值
	fmt.Println(p)   // &{钱七 30 178.5 true} 打印指针本身
}

✅ 补充:指针结构体的手动赋值写法 &结构体名{字段赋值},也是开发常用:p := &Person{Name: "孙八", Age: 28} 效果和new完全一致,写法更简洁,优先用这个!

6.3. 结构体的比较

结构体是值类型,只要两个结构体的 所有字段值都相等,则两个结构体变量相等;反之不等。

注意:如果结构体中包含 切片、map、函数 这类「不可比较类型」,则结构体变量不能使用 == 比较,会直接编译报错。

func main() {
	p1 := Person{Name: "张三", Age: 20}
	p2 := Person{Name: "张三", Age: 20}
	p3 := Person{Name: "李四", Age: 20}

	fmt.Println(p1 == p2) // true  所有字段相等
	fmt.Println(p1 == p3) // false 字段值不等
}
6.4. 空结构体

Go 中有一个特殊的结构体:struct{},称为「空结构体」,它的特点:

  1. 没有任何字段;
  2. 占用的内存大小为 0 字节
  3. 常用场景:做集合(map 的 value)、做无参数的信号通道,节省内存。
6.5. 绑定方法

在 Go 中,方法就是「绑定了接收者的函数」,这是方法和普通函数的唯一区别。

特性

普通函数

结构体方法

特性普通函数结构体方法
定义语法func 函数名(参数) 返回值 {}func (接收者) 方法名(参数) 返回值 {}
归属关系属于包级别,独立存在属于特定结构体,只能通过结构体调用
访问结构体字段需将结构体作为参数传入可直接访问接收者(结构体)的所有字段
调用方式函数名(参数)结构体变量.方法名(参数)

最简单的对比:

package main

import "fmt"

// 定义结构体
type Person struct {
	Name string
	Age  int
}

// 普通函数:操作Person结构体,需传入结构体参数
func ShowPersonFunc(p Person) {
	fmt.Printf("函数:姓名=%s,年龄=%d\n", p.Name, p.Age)
}

// 结构体方法:绑定Person接收者,直接访问字段
func (p Person) ShowPersonMethod() {
	fmt.Printf("方法:姓名=%s,年龄=%d\n", p.Name, p.Age)
}

func main() {
	p := Person{Name: "张三", Age: 20}
	
	// 调用普通函数:需传结构体参数
	ShowPersonFunc(p) // 函数:姓名=张三,年龄=20
	
	// 调用结构体方法:通过结构体变量调用
	p.ShowPersonMethod() // 方法:姓名=张三,年龄=20
}

接收者是方法的「灵魂」,分为值接收者指针接收者,两者的核心区别在于「是否操作原结构体」,这直接决定了开发中该选哪种接收者。

类型定义语法底层逻辑
值接收者func (p Person) 方法名()方法调用时,会拷贝一份结构体副本作为接收者;方法内修改的是「副本」,原结构体不变
指针接收者func (p *Person) 方法名()方法调用时,传递的是「结构体的内存地址」;方法内修改的是「原结构体」,会同步生效

开发中 90%+ 的场景优先选指针接收者,核心原因是「可修改原结构体 + 高性能」;

6.6. 继承

如果结构体 A 嵌套了结构体 B,那么 A 的变量可以直接调用 B 的所有方法(相当于「继承」了 B 的方法):

package main

import "fmt"

type Person struct {
	Name string
}

func (p *Person) SayHello() {
	fmt.Printf("Hello, 我是%s\n", p.Name)
}

// 嵌套Person的Student结构体
type Student struct {
	Person // 匿名字段
	Id     int
}

func main() {
	stu := &Student{Person: Person{Name: "小明"}, Id: 1001}
	stu.SayHello() // 直接调用Person的方法 → Hello, 我是小明
}

7. 其他数字类型

  • byte:类似 uint8
  • rune: 类似int32
  • uint: 32位或64位
  • int: 与 uint 一样大小
  • uintptr: 无符号整型,用于存放一个指针

引用类型

变量存储的「不是数据本身」,而是数据在内存中的内存地址(指针) ,赋值 / 传参时,只拷贝内存地址,新旧变量指向同一块内存,修改一个,另一个也会变

包括:切片 slice、映射 map、通道 channel、指针 pointer、函数 func、接口 interface

1. 切片

切片是 Go 语言中最常用、最核心的数据结构之一,它是对数组的「动态封装」,解决了数组长度固定、无法灵活扩展的痛点。

1.1. 定义

切片(Slice)是 动态大小的、引用类型的序列,它基于数组实现,相当于「数组的视图 / 窗口」,可以灵活地扩容、缩容。

数组是定长的,切片是变长的,切片是 Go 对数组的封装,日常开发99% 用切片,几乎不用数组。

  • 切片的底层原理:切片是「指针 + 长度 + 容量」的结构体,指向底层的数组
  • 切片的拷贝:copy(目标切片, 源切片),深拷贝,解决引用修改的问题
1.2. 初始化

(1) 声明空切片(零值切片)

语法:var 切片名 []类型

  • 特点:切片的 ptr=nillen=0cap=0,是「nil 切片」,可直接使用(无需初始化)。
package main

import "fmt"

func main() {
    // 声明一个int类型的空切片
	var s []int
	fmt.Println(s)      // []
	fmt.Println(len(s)) // 0
	fmt.Println(cap(s)) // 0
	fmt.Println(s == nil) // true(nil切片)

	// 直接追加元素(nil切片可正常使用append)
	s = append(s, 1, 2, 3)
	fmt.Println(s)      // [1 2 3]
	fmt.Println(len(s)) // 3
	fmt.Println(cap(s)) // 4(扩容后的容量)
}

(2) 字面量初始化(最常用)

语法:切片名 := []类型{元素1, 元素2, ...}

特点:直接指定初始元素,len=cap=元素个数,底层自动创建数组。

func main() {
    // 初始化包含3个元素的int切片
	s1 := []int{10, 20, 30}
	fmt.Println(s1)      // [10 20 30]
	fmt.Println(len(s1)) // 3
	fmt.Println(cap(s1)) // 3

    // 初始化空切片(len=cap=0,非nil)
	s2 := []int{}
	fmt.Println(s2 == nil) // false

    // 初始化字符串切片
	s3 := []string{"Go", "Java", "Python"}
	fmt.Println(s3) // [Go Java Python]
}

(3) 基于数组创建切片(切片的核心本质)

语法:切片名 := 数组名[起始索引:结束索引]

  • 规则:[start:end] 包含 start,不包含 end,即 [start, end)
  • 切片和原数组共享底层数组,修改切片会影响原数组,反之亦然;
  • 省略 start 表示从 0 开始,省略 end 表示到数组末尾。
func main() {
    // 定义数组
	arr := [5]int{1, 2, 3, 4, 5}
	fmt.Println("原数组:", arr) // [1 2 3 4 5]

    // 基于数组创建切片:取索引1到3(包含1,不包含3)
	s1 := arr[1:3]
	fmt.Println("s1:", s1)      // [2 3]
	fmt.Println("len(s1):", len(s1)) // 2
	fmt.Println("cap(s1):", cap(s1)) // 4(从索引1到数组末尾的长度:5-1=4)

    // 省略start:从0开始
	s2 := arr[:3] // 等价于 arr[0:3]
	fmt.Println("s2:", s2) // [1 2 3]

    // 省略end:到数组末尾
	s3 := arr[2:] // 等价于 arr[2:5]
	fmt.Println("s3:", s3) // [3 4 5]

    // 全量切片:等价于 arr[0:5]
	s4 := arr[:]
	fmt.Println("s4:", s4) // [1 2 3 4 5]

    // 修改切片 → 影响原数组
	s1[0] = 200
	fmt.Println("修改后s1:", s1)  // [200 3]
	fmt.Println("修改后数组:", arr) // [1 200 3 4 5]
}

(4) make 函数初始化(指定长度 / 容量,开发高频)

语法:切片名 := make([]类型, 长度, 容量)make([]类型, 长度)

  • 核心场景:提前知道切片的大致大小,指定容量避免频繁扩容,提升性能;
  • 规则:len ≤ cap,未指定容量时 cap=len
  • 初始化的切片元素为对应类型的零值(int→0,string→"")。
func main() {
    // 指定长度=3,容量=5
	s1 := make([]int, 3, 5)
	fmt.Println("s1:", s1)      // [0 0 0](零值初始化)
	fmt.Println("len(s1):", len(s1)) // 3
	fmt.Println("cap(s1):", cap(s1)) // 5

    // 仅指定长度(cap=len=4)
	s2 := make([]string, 4)
	fmt.Println("s2:", s2)      // ["" "" "" ""]
	fmt.Println("len(s2):", len(s2)) // 4
	fmt.Println("cap(s2):", cap(s2)) // 4

    // 追加元素:长度从3→4,容量仍为5
	s1 = append(s1, 10)
	fmt.Println("s1追加后:", s1)      // [0 0 0 10]
	fmt.Println("len(s1):", len(s1)) // 4
	fmt.Println("cap(s1):", cap(s1)) // 5
}

(5) 基于切片创建切片

语法:新切片 := 原切片[start:end]

  • 特点:和原切片共享底层数组,修改一方会影响另一方;
  • 新切片的 cap = 原切片cap - start
func main() {
	s1 := []int{1, 2, 3, 4, 5}
	// 基于切片创建新切片
	s2 := s1[1:4]
	fmt.Println("s2:", s2)      // [2 3 4]
	fmt.Println("len(s2):", len(s2)) // 3
	fmt.Println("cap(s2):", cap(s2)) // 4(原cap=5 - start=1 =4)

	// 修改s2 → 影响s1
	s2[0] = 200
	fmt.Println("s2:", s2) // [200 3 4]
	fmt.Println("s1:", s1) // [1 200 3 4 5]
}
1.3. 切片的核心操作

(1) append 追加元素

语法:切片 = append(切片, 元素1, 元素2, ...)切片 = append(切片, 另一个切片...)

  • append 会返回新的切片(必须赋值回原切片,否则原切片不会变化);
  • 如果追加后长度 ≤ 容量,直接追加,底层数组不变;
  • 如果追加后长度 > 容量,自动扩容(新建更大的底层数组,拷贝原数据);
  • append 本质是数组扩容
  • 扩容策略(Go 1.18+):
    • 容量 < 256:扩容为原容量的 2 倍;
    • 容量 ≥256:扩容为原容量的 1.25 倍(或按需扩容)。
func main() {
	s := []int{1, 2}
	fmt.Println("初始:len=", len(s), "cap=", cap(s)) // len=2 cap=2

	// 追加单个元素
	s = append(s, 3)
	fmt.Println("追加3后:len=", len(s), "cap=", cap(s)) // len=3 cap=4(扩容为2*2=4)

	// 追加多个元素
	s = append(s, 4, 5)
	fmt.Println("追加4,5后:len=", len(s), "cap=", cap(s)) // len=5 cap=8(扩容为4*2=8)

	// 追加另一个切片(注意末尾的...)
	s2 := []int{6, 7}
	s = append(s, s2...)
	fmt.Println("追加s2后:", s) // [1 2 3 4 5 6 7]
}

(2) 切片截取(切割)

语法:切片[start:end:max](第三个参数指定新切片的最大容量)

  • 作用:限制新切片的容量,避免修改超出预期的底层数组;
  • 规则:max ≤ 原切片cap,新切片的 cap = max - start
func main() {
	s := []int{1, 2, 3, 4, 5}
	fmt.Println("原切片cap:", cap(s)) // 5

	// 普通截取:cap=5-1=4
	s1 := s[1:3]
	fmt.Println("s1 cap:", cap(s1)) // 4

	// 带max的截取:cap=3-1=2
	s2 := s[1:3:3]
	fmt.Println("s2 cap:", cap(s2)) // 2

	// s2追加元素:len=2 → 3,超过cap=2,触发扩容(新建数组)
	s2 = append(s2, 100)
	fmt.Println("s2:", s2) // [2 3 100]
	fmt.Println("原切片s:", s) // [1 2 3 4 5](底层数组已分离,无影响)
}

(3) copy 复制切片 (避免共享底层数组)

语法:copy(目标切片, 源切片)

特点:

  1. copy 是值拷贝,目标切片和源切片底层数组分离,修改互不影响;
  2. 拷贝的元素个数 = 目标切片长度 和 源切片长度 的较小值;
  3. 返回值是实际拷贝的元素个数。
func main() {
	src := []int{1, 2, 3, 4, 5}
	// 方式1:目标切片长度足够
	dst1 := make([]int, len(src))
	copy(dst1, src)
	fmt.Println("dst1:", dst1) // [1 2 3 4 5]
	dst1[0] = 100
	fmt.Println("修改dst1后src:", src) // [1 2 3 4 5](无影响)

	// 方式2:目标切片长度不足(只拷贝前2个元素)
	dst2 := make([]int, 2)
	cnt := copy(dst2, src)
	fmt.Println("dst2:", dst2) // [1 2]
	fmt.Println("拷贝个数:", cnt) // 2
}

(4) 删除切片元素(无原生函数,3 种场景)

Go 没有专门的删除函数,需通过 append + 切片截取 实现,核心思路是「保留要的元素,拼接成新切片」。

  1. 删除指定索引的元素
func main() {
	s := []int{1, 2, 3, 4, 5}
	index := 2 // 删除索引2的元素(3)
	// 拼接:[0:2] + [3:] → 跳过索引2
	s = append(s[:index], s[index+1:]...)
	fmt.Println(s) // [1 2 4 5]
}

2. 删除开头元素

s := []int{1, 2, 3, 4, 5}
s = s[1:] // 删除第一个元素
fmt.Println(s) // [2 3 4 5]

3. 删除末尾元素

s := []int{1, 2, 3, 4, 5}
s = s[:len(s)-1] // 删除最后一个元素
fmt.Println(s) // [1 2 3 4]
1.4. 切片仔内存中的形式
1.5. nil 切片 vs 空切片
类型特点判空建议
nil 切片ptr=nillen=0cap=0len(s) == 0
空切片ptr≠nillen=0cap=0len(s) == 0
func main() {
	var s1 []int         // nil切片
	s2 := []int{}        // 空切片
	s3 := make([]int, 0) // 空切片

	fmt.Println(s1 == nil) // true
	fmt.Println(s2 == nil) // false
	fmt.Println(s3 == nil) // false

	// 判空统一用len,不要用==nil
	fmt.Println(len(s1) == 0) // true
	fmt.Println(len(s2) == 0) // true
	fmt.Println(len(s3) == 0) // true
}
1.6. 切片作为函数参数(传值但共享底层数组)

切片传参时,拷贝的是「切片头」(24 字节),效率极高,但修改元素会影响原切片;如果在函数内 append 触发扩容,新切片和原切片会分离:

// 推荐写法:需要修改切片并返回新切片
func addElement(s []int, elem int) []int {
	s = append(s, elem)
	return s
}

func main() {
	s := []int{1, 2}
	s = addElement(s, 3) // 接收返回的新切片
	fmt.Println(s) // [1 2 3]
}
1.7. [...]string vs []string
  1. [...]string - 这是数组(Array)
    • 固定长度的数组,长度是类型的一部分
    • ... 是 Go 编译器自动计算数组长度的语法糖
    • 你的例子 arr := [...]string{"apple", "banana", "cherry"} 创建了一个长度为 3 的数组
  1. []string - 这是切片(Slice)
    • 动态长度的序列
    • 没有固定大小

2. 映射

映射 是 Go 语言中用于存储「键值对 (Key-Value)」的核心数据结构。 Map 解决了「通过键快速查找值」的需求,是日常开发中处理配置、缓存、关联数据的首选。

2.1. 定义

Map 是 无序的键值对集合,每个键(Key)唯一且对应一个值(Value),通过键可以在 O (1) 时间复杂度内快速查找、修改、删除对应的值(哈希表特性)。

特性说明
键的唯一性同一个 Map 中,键不能重复(重复赋值会覆盖原有值)
键的类型要求键必须是「可比较类型」(如 int/string/bool/ 数组),切片 / Map / 函数不能作为键
值的类型要求无限制(可以是任意类型:基础类型、结构体、切片、甚至另一个 Map)
无序性Map 遍历的顺序不固定(每次遍历可能不同),不保证插入顺序
引用类型Map 是引用类型(赋值 / 传参拷贝的是指针,修改会影响所有关联变量)
2.2. 初始化

(1) 字面量初始化(最常用,直接指定键值对)

语法:map变量 := map[键类型]值类型{键1:值1, 键2:值2, ...}

  • 特点:直接指定初始键值对,简洁高效,适合已知初始数据的场景;
  • 空 Map 初始化:map变量 := map[键类型]值类型{}(非 nil,可直接操作)。
package main

import "fmt"

func main() {
    // 1. 初始化包含数据的Map(键:string,值:int)
	scoreMap := map[string]int{
		"张三": 90,
		"李四": 85,
		"王五": 95,
	}
	fmt.Println("scoreMap:", scoreMap) // 输出:map[张三:90 李四:85 王五:95](顺序不固定)
	fmt.Println("张三的成绩:", scoreMap["张三"]) // 90

    // 2. 初始化空Map(键:int,值:string)
	userMap := map[int]string{}
	fmt.Println("空Map:", userMap) // map[]
	fmt.Println(userMap == nil)    // false(非nil,可直接操作)
}

(2) make 函数初始化(指定容量,开发高频)

语法:map变量 := make(map[键类型]值类型, [容量])

  • 核心场景:提前知道 Map 的大致大小,指定「容量」避免频繁扩容,提升性能;
  • 规则:容量是「建议值」(非强制限制),Map 会自动扩容,未指定容量时默认初始容量;
  • 初始化的 Map 是空的(无键值对),但非 nil,可直接操作。
func main() {
    // 1. 指定容量=10(键:int,值:结构体)
	type User struct {
		Name string
		Age  int
	}
	userMap := make(map[int]User, 10)
	fmt.Println("初始化的userMap:", userMap) // map[]
	fmt.Println(len(userMap))               // 0(长度=键值对个数)

    // 2. 不指定容量(默认初始化)
	configMap := make(map[string]string)
	// 赋值
	configMap["port"] = "8080"
	configMap["host"] = "localhost"
	fmt.Println("configMap:", configMap) // map[host:localhost port:8080]
}

(3) 声明 nil Map(仅声明,未初始化)

  • 特点:声明后 Map 是 nil不能直接赋值 / 操作,必须通过 make 或字面量初始化后才能用;
  • 常见坑:直接操作 nil Map 会触发 panic(运行时错误)。
func main() {
    // 声明nil Map
	var nilMap map[string]int
	fmt.Println(nilMap == nil) // true
	fmt.Println(len(nilMap))   // 0(nil Map的长度为0)

	// ❌ 错误:直接赋值会panic
	// nilMap["a"] = 100 // panic: assignment to entry in nil map

	// ✅ 正确:先初始化再操作
	nilMap = make(map[string]int)
	nilMap["a"] = 100
	fmt.Println(nilMap) // map[a:100]
}
2.3. 核心操作

(1) 新增 / 修改键值对(同一语法)

语法:map变量[键] = 值

规则:如果键不存在 → 新增键值对;如果键已存在 → 覆盖原有值。

func main() {
	m := map[string]int{"a": 1}
	fmt.Println("初始:", m) // map[a:1]

	// 新增键值对(键"b"不存在)
	m["b"] = 2
	fmt.Println("新增后:", m) // map[a:1 b:2]

	// 修改键值对(键"a"已存在)
	m["a"] = 100
	fmt.Println("修改后:", m) // map[a:100 b:2]
}

(2) 获取值

  1. 直接获取

语法:值 := map变量[键]

坑点:如果键不存在,会返回值类型的「零值」(int→0,string→""),无法区分「键不存在」和「值就是零值」。

func main() {
	m := map[string]int{"a": 1}
	// 获取存在的键
	fmt.Println(m["a"]) // 1

	// 获取不存在的键(返回零值0)
	fmt.Println(m["b"]) // 0 → 无法判断是键不存在,还是值本来就是0
}

2. 带「存在性判断」获取(推荐,避坑)

语法:值, ok := map变量[键]

规则:

  • ok 是 bool 类型:true → 键存在,false → 键不存在;
  • :键存在时返回对应值,不存在时返回零值。
  • 这是 开发中获取 Map 值的标准写法,避免零值导致的逻辑错误。
func main() {
	m := map[string]int{"a": 1, "c": 0}

	// 场景1:键存在
	val1, ok1 := m["a"]
	fmt.Printf("键a:值=%d,存在=%t\n", val1, ok1) // 键a:值=1,存在=true

	// 场景2:键不存在
	val2, ok2 := m["b"]
	fmt.Printf("键b:值=%d,存在=%t\n", val2, ok2) // 键b:值=0,存在=false

	// 场景3:键存在但值是零值
	val3, ok3 := m["c"]
	fmt.Printf("键c:值=%d,存在=%t\n", val3, ok3) // 键c:值=0,存在=true

	// 实际开发中的判断逻辑
	if val, ok := m["target"]; ok {
		fmt.Println("键存在,值:", val)
	} else {
		fmt.Println("键不存在")
	}
}

3. 删除键值对:delete 函数

语法:delete(map变量, 键)

规则:

  • 如果键存在 → 删除该键值对;
  • 如果键不存在 → 无任何操作(不会报错);
  • Go 没有「清空 Map」的内置函数,清空需遍历删除或重新初始化
func main() {
	m := map[string]int{"a": 1, "b": 2, "c": 3}
	fmt.Println("初始:", m) // map[a:1 b:2 c:3]

	// 删除存在的键
	delete(m, "b")
	fmt.Println("删除b后:", m) // map[a:1 c:3]

	// 删除不存在的键(无报错)
	delete(m, "d")
	fmt.Println("删除d后:", m) // map[a:1 c:3]

	// 清空Map(方式1:重新初始化)
	m = make(map[string]int)
	fmt.Println("清空后:", m) // map[]
}

(4) 获取 Map 长度:len 函数

语法:len(map变量)

  • 规则:返回 Map 中当前键值对的个数,nil Map 的长度为 0。
func main() {
	m := map[string]int{"a": 1, "b": 2}
	fmt.Println("长度:", len(m)) // 2

	// 添加元素后长度增加
	m["c"] = 3
	fmt.Println("添加后长度:", len(m)) // 3

	// 删除元素后长度减少
	delete(m, "a")
	fmt.Println("删除后长度:", len(m)) // 2

	// nil Map长度为0
	var nilMap map[string]int
	fmt.Println("nil Map长度:", len(nilMap)) // 0
}
2.4. 注意
  1. Map 不是并发安全的

多个 goroutine 同时读写 Map 会触发 panic;

解决方案:

  • sync.Map(Go 内置的并发安全 Map);
  • 加互斥锁(sync.Mutex)。
  1. 遍历顺序不固定
  2. 未初始化的 nil Map 直接赋值 / 删除会 panic;

3. 通道

3.1. 定义

通道是 Go 语言提供的并发安全的通信机制,用于不同 goroutine 之间传递数据,实现「以通信代替共享内存」的并发编程范式。可以把它想象成一个安全的 “消息队列”:

  • 一个 goroutine 可以向通道里发送数据
  • 另一个 goroutine 可以从通道里接收数据
  • 通道自带同步机制,发送 / 接收操作会阻塞,直到对方准备好,天然避免了竞态条件
  • 底层:通道基于队列实现,默认是FIFO(先进先出) ,且操作(读 / 写)是原子性的,无需手动加锁。
3.2. 基本使用

(1)声明与创建通道

通道必须先创建才能使用,语法如下:

// 声明通道类型:chan 数据类型
var ch chan int

// 创建通道(make函数):无缓冲通道
ch = make(chan int)

// 简写:创建有缓冲通道(缓冲区大小为3)
ch2 := make(chan string, 3)
  • 无缓冲通道:发送数据后会立即阻塞,直到有 goroutine 接收数据(同步通信)
  • 有缓冲通道:只要缓冲区没满,发送不会阻塞;只要缓冲区非空,接收不会阻塞(异步通信)

(2)发送 / 接收数据

// 向通道发送数据:通道变量 <- 数据
ch <- 10

// 从通道接收数据:变量 := <- 通道变量
num := <- ch

// 忽略接收的值
<- ch

(3)关闭通道

// 关闭通道(只能由发送方关闭,接收方关闭会panic)
close(ch)

// 接收时判断通道是否关闭
num, ok := <- ch
// ok为true:通道未关闭,num是有效数据
// ok为false:通道已关闭,num是数据类型的零值

示例:

(1)无缓冲通道 (同步通信):

go 关键字用于启动一个新的 Goroutine(协程) 执行后面的函数;

package main

import "fmt"

// 向通道发送数据的函数
func sendData(ch chan int) {
    fmt.Println("准备发送数据")
    ch <- 99 // 阻塞,直到有goroutine接收
    fmt.Println("数据发送完成")
    close(ch) // 发送完成后关闭通道
}

func main() {
    // 创建无缓冲int类型通道
    ch := make(chan int)

    // 启动goroutine发送数据
    go sendData(ch)

    fmt.Println("准备接收数据")
    // 接收数据(阻塞,直到有数据发送)
    num := <-ch
    fmt.Printf("接收到的数据:%d\n", num)

    // 尝试接收已关闭通道的数据
    num2, ok := <-ch
    fmt.Printf("关闭后接收:%d,是否有效:%t\n", num2, ok)
}

输出结果:

准备接收数据
准备发送数据
数据发送完成
接收到的数据:99
关闭后接收:0,是否有效:false
  • 无缓冲通道的发送和接收是 “一手交钱一手交货”,必须双方都准备好才会执行
  • main goroutine 先执行到接收操作,阻塞等待;sendData goroutine 发送数据后,双方完成通信,继续执行

(2) 有缓冲通道(异步通信)

package main

import "fmt"

func main() {
    // 创建缓冲区大小为2的字符串通道
    ch := make(chan string, 2)

    // 向通道发送数据(缓冲区未满,不会阻塞)
    ch <- "hello"
    ch <- "world"
    fmt.Println("发送了两个数据,缓冲区已满")

    // 尝试发送第三个数据(会阻塞,因为缓冲区满了)
    // ch <- "go" // 取消注释会导致程序死锁

    // 接收数据(缓冲区非空,不会阻塞)
    fmt.Println("接收第一个数据:", <-ch)
    fmt.Println("接收第二个数据:", <-ch)

    // 接收空通道的数据(会阻塞,最终导致死锁)
    // fmt.Println(<-ch) // 取消注释会panic: all goroutines are asleep - deadlock!

    close(ch)
}

输出结果:

发送了两个数据,缓冲区已满
接收第一个数据: hello
接收第二个数据: world
  • 有缓冲通道的缓冲区相当于 “临时仓库”,只要仓库没满,发送方可以先把数据放进去就走
  • 缓冲区满后,发送会阻塞;缓冲区空后,接收会阻塞

(3)遍历通道

通道支持for range遍历,会自动等待数据,直到通道关闭:

package main

import "fmt"

func sendNumbers(ch chan int) {
    for i := 1; i <= 5; i++ {
        ch <- i // 发送1-5
    }
    close(ch) // 必须关闭通道,否则遍历会一直阻塞
}

func main() {
    ch := make(chan int, 2)
    go sendNumbers(ch)

    // 遍历通道,接收所有数据
    for num := range ch {
        fmt.Printf("接收到:%d\n", num)
    }
    fmt.Println("通道已关闭,遍历结束")
}

输出结果:

接收到:1
接收到:2
接收到:3
接收到:4
接收到:5
通道已关闭,遍历结束
3.3. 高级用法

(1)单向通道(限制通道的读写)

可以声明只写或只读的通道,增强代码安全性:

package main

import "fmt"

// 只写通道:chan<- int
func onlySend(ch chan<- int) {
    ch <- 100
    // <-ch // 错误:只读操作不允许
    close(ch)
}

// 只读通道:<-chan int
func onlyReceive(ch <-chan int) {
    num := <-ch
    fmt.Println("只读通道接收:", num)
    // ch <- 200 // 错误:只写操作不允许
}

func main() {
    ch := make(chan int)
    go onlySend(ch)
    onlyReceive(ch)
}

(2)通道的超时控制

结合selecttime.After可以避免通道操作永久阻塞:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)

    // 模拟一个慢goroutine(3秒后发送数据)
    go func() {
        time.Sleep(3 * time.Second)
        ch <- "最终数据"
    }()

    // 等待通道数据,或2秒超时
    select {
    case msg := <-ch:
        fmt.Println("收到数据:", msg)
    case <-time.After(2 * time.Second):
        fmt.Println("等待超时,放弃接收")
    }
}

输出结果:

等待超时,放弃接收

注意:通道关闭后仍可接收剩余数据,但不能发送;未关闭的通道遍历会永久阻塞;单向通道可限制读写权限,提升代码安全性。

3.4. 常见坑点
  • ❌ 向 nil 通道读写:会永久阻塞(解决:初始化通道后再使用);
  • ❌ 关闭已关闭的通道:会 panic(解决:确保只关闭一次,或用 sync.Once);
  • ❌ 向关闭的通道写入:会 panic(解决:写入前判断通道是否关闭,或通过逻辑避免);
  • ❌ 无缓冲通道的读写未放在协程中:主线程直接读写会阻塞(解决:读写分属不同协程);
  • ❌ 忽略 for range 阻塞:未关闭通道就用 for range 遍历,会导致程序卡死(解决:协程完成后关闭通道)。

4. 指针

4.1. 定义

指针是一个变量,但它存储的不是普通数据(如数字、字符串),而是另一个变量的内存地址

变量 a (值:10) → 内存地址:0x140000a8008
指针 p (值:0x140000a8008) → 存储的是 a 的地址
类型存储内容访问方式示例
普通变量实际数据直接访问a := 10→ 取值 a
指针变量另一个变量的地址间接访问(解引用)p := &a→ 取值 *p
4.2. 为什么需要指针?

Go 是值传递语言,指针的核心价值是:

  • 避免值拷贝:对于大结构体 / 数组,传递指针比传递值更节省内存、提升性能;
  • 修改函数外部变量:值传递的函数无法修改外部变量,指针可以直接操作原变量的内存;
  • 实现数据共享:多个指针指向同一内存地址,实现数据共享(如 sync.WaitGroup 指针传递);
  • 兼容底层操作:与 C 交互、操作硬件等场景需要直接访问内存地址。
4.3. 语法

(1)声明指针

语法:var 指针名 *数据类型

  • * 表示 “指向该类型的指针”,如 *int 是指向 int 类型的指针,*string 是指向 string 类型的指针。
  • 未初始化的指针默认值是 nil(空指针),表示不指向任何内存地址。
package main

import "fmt"

func main() {
    // 1. 声明普通变量
    var a int = 10
    fmt.Println("变量 a 的值:", a)       // 输出 10
    fmt.Println("变量 a 的地址:", &a)    // 输出 a 的内存地址(如 0x140000a8008)

    // 2. 声明并初始化指针(& 是取地址符,获取变量的内存地址)
    var p *int = &a // p 是指向 int 类型的指针,值是 a 的地址
    fmt.Println("指针 p 的值(a 的地址):", p) // 输出和 &a 相同的地址
    fmt.Println("指针 p 的类型:", fmt.Sprintf("%T", p)) // 输出 *int

    // 3. 空指针(未初始化)
    var q *string // q 是 *string 类型的空指针
    fmt.Println("空指针 q:", q) // 输出 <nil>
}

(2) 解引用(*):通过指针访问原变量

语法:*指针名 —— 表示 “指针指向的变量”,可以读、也可以写。

package main

import "fmt"

func main() {
    a := 10
    p := &a // p 指向 a

    // 1. 读:通过指针获取原变量的值
    fmt.Println("通过指针读 a 的值:", *p) // 输出 10

    // 2. 写:通过指针修改原变量的值
    *p = 20 // 等价于 a = 20
    fmt.Println("修改后 a 的值:", a)    // 输出 20
    fmt.Println("修改后 *p 的值:", *p)  // 输出 20
}

(3) 简短声明指针(常用)

package main

import "fmt"

func main() {
    name := "张三"
    pName := &name // 简短声明:pName 是 *string 类型的指针

    *pName = "李四" // 修改原变量
    fmt.Println(name) // 输出 李四
}
特性Go 指针C/C++ 指针
算术运算不支持(如 p++)支持(如 p++ 移动地址)
类型转换只能在特定类型间转换任意类型可强制转换
空指针解引用运行时 panic未定义行为(崩溃 / 异常)
指向指针的指针支持(int)支持(int

||

5. 函数

(1)函数的定义

Go 函数通过 func 关键字声明,参数和返回值的类型后置(Go 语言特色),基础语法:

// 无返回值函数
func 函数名(参数1 类型1, 参数2 类型2, ...) {
    函数体
}

// 有返回值函数(指定返回值类型)
func 函数名(参数1 类型1, 参数2 类型2, ...) (返回值类型1, 返回值类型2, ...) {
    函数体
    return 返回值1, 返回值2, ...
}

规则:函数名遵循 Go 命名规范(首字母大写跨包可见,小写仅当前包可见);参数列表中同类型参数可简写(如 a, b int 而非 a int, b int)。

(2)多返回值

多返回值是处理错误的核心方式 。 Go 不支持异常捕获(try/catch),多返回值是处理错误的标准范式,通常第一个返回值是业务结果,第二个是错误对象(error 类型,nil 表示无错误)。

(3)函数参数

(4)函数类型与匿名函数

(5)闭包

(6)延迟执行

  • 作用:defer 语句 会在函数执行完毕后,逆序执行,无论函数正常结束还是报错 panic
  • 核心用途:释放资源(关闭文件、关闭数据库连接、释放锁),防止内存泄漏
  • 特点:多个 defer 按「后进先出」执行,defer 后的表达式会立即求值,执行延后

(7)递归函数

(8)内置函数

6. 接口

Go 语言的接口是非侵入式的抽象类型,核心作用是定义方法集合(行为规范),实现解耦多态,是 Go 实现面向接口编程的核心基础。与 Java/C++ 等语言不同,Go 接口无需显式声明实现(无 implements 关键字),遵循鸭子类型("走起来像鸭子、叫起来像鸭子,那它就是鸭子")—— 只要某个类型实现了接口的所有方法,就隐式实现了该接口,设计简洁且灵活。

类型转换

go是强类型语言,不同类型的变量不能直接运算,核心是:

显式地将一个值从一种类型转换成另一种兼容的类型,语法上是「强制转换」,且必须满足类型兼容条件(否则编译报错)。

类型转换是「同一层级的类型转换」(比如 int→float64、[] byte→string),只能作用于具体类型,不能作用于接口类型(接口类型用类型断言)。

语法:

Go 中类型转换的语法非常固定,没有隐式转换(这是 Go 和 Java/Python 的核心区别):

package main

import "fmt"

func main() {
    // 1. 数值类型转换(最常见)
    var a int = 10
    var b float64 = float64(a) // int → float64
    fmt.Println(b) // 输出:10.0

    var c float64 = 3.14
    var d int = int(c) // float64 → int(直接截断小数,不是四舍五入)
    fmt.Println(d) // 输出:3

    // 2. 字符串 ↔ []byte/[]rune
    var s string = "hello"
    var bs []byte = []byte(s) // string → []byte
    fmt.Println(bs) // 输出:[104 101 108 108 111]

    var rs []rune = []rune(s) // string → []rune(处理中文/多字节字符)
    fmt.Println(rs) // 输出:[104 101 108 108 111]

    var s2 string = string(bs) // []byte → string
    fmt.Println(s2) // 输出:hello

    // 3. 自定义类型转换(同底层类型)
    type MyInt int // 自定义类型,底层是 int
    var mi MyInt = MyInt(a) // int → MyInt
    var a2 int = int(mi)    // MyInt → int
    fmt.Println(a2) // 输出:10
}

类型断言

判断接口的实际类型,语法 v, ok := 变量.(类型)

类型断言,由于接口是一般类型,不知道具体类型,如果要转成具体类型,就需要使用类型断言。

  • 非接口变量不可作类型断言
  • 如果接口变量本身是 nil(既无类型也无值),断言任何类型都会返回 ok = false

继承

Go 语言没有传统面向对象语言中的类(class)和继承(inheritance)概念,而是通过组合(composition)和接口(interface)来实现类似的功能。

三、变量与常量

变量

Go 语言的字符串的字节使用 UTF-8 编码标识 Unicode 文本。

变量的定义方式

  1. 标准声明(var + 变量名 + 类型)
package main

import "fmt"

func main() {
    // 声明单个变量,指定类型,未赋值时会有默认值(int默认0,string默认"",bool默认false)
    var age int
    fmt.Println("默认值:", age) // 输出:默认值:0
    
    // 声明并赋值
    var name string = "张三"
    fmt.Println("赋值后:", name) // 输出:赋值后:张三
}

2. 类型推导(省略类型,go自动推断)

package main

import "fmt"

func main() {
    var score = 95.5 // 自动推断为float64类型
    fmt.Printf("类型:%T,值:%v\n", score, score) // 输出:类型:float64,值:95.5
}

3. 批量声明

package main
import "fmt"
func main() {
    var a string = "Runoob"
    fmt.Println(a)
    // 可以一次声明多个变量:
    var b, c int = 1, 2
    fmt.Println(b, c)

    // 批量声明多个变量
    var (
        a int = 10
        b string = "hello"
        c bool = true
    )
    fmt.Println(a, b, c) // 输出:10 hello true
}

4. 简短声明(仅用于函数内部,最常用)

:= 代替 var,自动推导类型,是 Go 中最简洁的变量声明方式。

package main

import "fmt"

func main() {
    // 简短声明,只能在函数内使用
    num := 20
    str := "Go语言"
    fmt.Println(num, str) // 输出:20 Go语言
    
    // 变量重新赋值(注意:简短声明要求至少有一个新变量)
    num, newStr := 30, "新字符串"
    fmt.Println(num, newStr) // 输出:30 新字符串
}

变量的注意事项

  • 指定变量类型,如果没有初始化,则变量默认为0值
package main
import "fmt"
func main() {

    // 声明一个变量并初始化
    var a = "RUNOOB"
    fmt.Println(a)

    // 没有初始化就为零值
    var b int
    fmt.Println(b)

    // bool 零值为 false
    var c bool
    fmt.Println(c)

    // string 默认是 “”
    var s string
}
  • 变量声明后必须使用(未使用会编译报错,这是 Go 的严格语法);
  • 简短声明 := 不能用于函数外部;
  • 同一作用域内,变量名不能重复声明

常量

常量是程序运行过程中值不能被修改的固定值,核心关键字是 const,常用于定义不变的配置(如 π、固定的端口号等)。

常量的定义方式

package main

import "fmt"

// 常量可以声明在函数外部(全局)
const Pi = 3.1415926
const Port = 8080

func main() {
    // 函数内声明常量
    const maxNum = 100
    fmt.Println(Pi, Port, maxNum) // 输出:3.1415926 8080 100
    
    // 批量声明常量
    const (
        a = 1
        b = "常量"
        c = true
    )
    fmt.Println(a, b, c) // 输出:1 常量 true
    
    // 常量自动推导类型(不能用:=)
    const d = 99.9
    fmt.Printf("类型:%T,值:%v\n", d, d) // 输出:类型:float64,值:99.9
}

常量的特殊用法:iota

iota 是 Go 的常量计数器,仅在常量声明中使用,从 0 开始,每新增一行常量声明自动 + 1,常用于定义枚举值。

package main

import "fmt"

func main() {
    const (
        Monday = iota // 0
        Tuesday       // 1(自动继承iota,无需重复写)
        Wednesday     // 2
        Thursday      // 3
    )
    fmt.Println(Monday, Tuesday, Wednesday, Thursday) // 输出:0 1 2 3
    
    // 跳过某些值
    const (
        v1 = iota // 0
        v2        // 1
        _         // 2(跳过)
        v3        // 3
    )
    fmt.Println(v1, v2, v3) // 输出:0 1 3
}

注意事项

  1. 常量的值必须是编译期就能确定的(不能用运行时才能计算的值,比如 time.now())
  2. 常量声明后可以不用(与变量不同,这里不会报错)
  3. 常量的类型可以是数值型、字符串、布尔型,不支持切片‘映射等复杂类型。

变量的作用域

变量的作用域指的是变量能够被访问和使用的代码范围。Go 语言是静态作用域(也叫词法作用域),变量的作用域在编译期就确定了,主要分为两大类:

  • 局部作用域:变量仅在定义它的代码块(函数、if/for/switch 等 {} 包裹的区域)内有效;

也就是说是在函数、循环、条件语句等代码块内声明的变量,只能在该代码块内部访问,外部无法引用。

  • 全局作用域:全局变量是在函数外部声明的变量,作用域覆盖整个包(package),如果变量名首字母大写,还能被其他包访问。

全局变量的特点

    • 声明在函数外部,作用域覆盖整个包;

    • 首字母大写可跨包访问,小写仅本包可见;

    • 全局变量声明后可以不使用(不会编译报错,和局部变量不同);

    • 全局变量不能用简短声明:=:=仅支持函数内)。

    • 作用域的同名变量覆盖原则,当局部变量和全局变量同名时,局部变量会覆盖全局变量(就近原则),在局部作用域内优先访问局部变量。

四、运算符

算数运算符

赋值运算符

比较运算符

逻辑运算符

位运算符

注意:go 没有三元运算符

五、流程控制

条件判断

Go 的 if 语法去掉了冗余的括号,且支持「初始化语句」(常用作变量声明):

package main

import "fmt"

func main() {
    // 基础示例
    score := 85
    if score >= 90 {
        fmt.Println("优秀")
    } else if score >= 80 {
        fmt.Println("良好")
    } else {
        fmt.Println("及格")
    }

    // 带初始化语句(推荐)
    if num := 10; num%2 == 0 {
        fmt.Printf("%d 是偶数\n", num)
    } else {
        fmt.Printf("%d 是奇数\n", num)
    }
}

注意事项:

  • 条件表达式不需要括号(加了也不报错,但不符合 Go 风格);
  • 初始化语句声明的变量,作用域仅限 if/else 块内;
  • 大括号 {} 必须写,即使只有一行代码(Go 不允许裸语句)

选择分支

(1)switch

Go 的 switch 比其他语言更灵活,无需 break(默认自动终止),支持任意类型、表达式判断。

package main

import "fmt"

func main() {
    // 1. 值匹配(自动break)
    day := 3
    switch day {
    case 1, 7:
        fmt.Println("周末")
    case 2, 3, 4, 5, 6:
        fmt.Println("工作日")
    default:
        fmt.Println("无效日期")
    }

    // 2. 无表达式(类 if-else)
    score := 85
    switch {
    case score >= 90:
        fmt.Println("优秀")
    case score >= 80:
        fmt.Println("良好")
    default:
        fmt.Println("及格")
    }

    // 3. 手动 fallthrough(穿透到下一个case)
    num := 1
    switch num {
    case 1:
        fmt.Println("1")
        fallthrough // 穿透到下一个case
    case 2:
        fmt.Println("2") // 也会执行
    default:
        fmt.Println("其他")
    }
}

注意事项:

  • 默认 case 执行后自动 break,无需手动加;
  • 如需穿透到下一个 case,用 fallthrough(仅穿透一层);
  • case 值的类型必须和 switch 表达式类型一致;
  • default 可选,位置任意(但通常放最后)。

(2)select 专用于通道

select 是 Go 特有的分支语句,仅用于处理通道(channel)的读写操作,语法类似 switch

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    // 启动协程向通道发数据
    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "来自ch1的消息"
    }()
    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "来自ch2的消息"
    }()

    // select 监听通道
    select {
    case msg1 := <-ch1:
        fmt.Println(msg1) // 先执行(1秒后)
    case msg2 := <-ch2:
        fmt.Println(msg2)
    case <-time.After(3 * time.Second): // 超时控制
        fmt.Println("超时")
    }
}

注意事项:

  • select 会阻塞,直到其中一个 case 的通道操作就绪;
  • 多个 case 就绪时,随机选一个执行;
  • 常用 time.After 实现超时控制;
  • select {} 会永久阻塞(协程退出用)。

循环语句

Go 没有 while/do-while,所有循环都用 for 实现,支持 3 种写法:

(1)标准 for 循环

// 语法:初始化语句; 条件表达式; 后置语句
for i := 0; i < 10; i++ {
    // 循环体
}

(2) for-range 循环(遍历集合,最常用)

专门用于遍历字符串、数组、切片、map、通道,返回「索引 / 键 + 值」:

package main

import "fmt"

func main() {
    // 1. 遍历字符串(返回索引 + 符文值)
    s := "hello 世界"
    for idx, char := range s {
        fmt.Printf("索引%d:%c(%d)\n", idx, char, char)
    }

    // 2. 遍历切片/数组(返回索引 + 值)
    nums := []int{1, 2, 3}
    for idx, val := range nums {
        fmt.Printf("nums[%d] = %d\n", idx, val)
    }

    // 3. 遍历 map(返回键 + 值)
    m := map[string]int{"a": 1, "b": 2}
    for key, val := range m {
        fmt.Printf("m[%s] = %d\n", key, val)
    }

    // 4. 忽略索引/键(用 _ 占位)
    for _, val := range nums {
        fmt.Println(val)
    }
}

(3)while 风格

// 语法:仅保留条件表达式
var i = 0
for i < 10 {
    fmt.Print(i, " ")
    i++
}

无限循环:

// 语法:无条件,需用 break 终止
for {
    fmt.Println("无限循环")
    break // 终止循环
}

跳转语句

(1)break:终止循环 /switch/select

终止当前循环:

for i := 0; i < 10; i++ {
    if i == 5 {
        break // 终止循环,i=5时退出
    }
    fmt.Print(i, " ") // 输出:0 1 2 3 4
}

终止标签循环(多层循环时):

// 定义标签
outer:
for i := 0; i < 3; i++ {
    for j := 0; j < 3; j++ {
        if i*j == 4 {
            break outer // 终止外层循环
        }
        fmt.Printf("i=%d, j=%d\n", i, j)
    }
}

2. continue:跳过当前循环迭代

  • 跳过当前循环,进入下一次迭代:go运行
for i := 0; i < 10; i++ {
    if i%2 == 0 {
        continue // 跳过偶数
    }
    fmt.Print(i, " ") // 输出:1 3 5 7 9
}
  • 配合标签跳过外层循环迭代:go运行
outer:
for i := 0; i < 3; i++ {
    for j := 0; j < 3; j++ {
        if j == 1 {
            continue outer // 跳过外层当前迭代
        }
        fmt.Printf("i=%d, j=%d\n", i, j)
    }
}

3. goto:跳转到标签(慎用)

goto 用于跳转到函数内的标签位置,易导致代码混乱,仅推荐在「统一错误处理」场景使用:

package main

import "fmt"

func main() {
    num := -1
    if num < 0 {
        goto errorLabel // 跳转到标签
    }
    fmt.Println("num 是正数")
    return

    // 定义标签
errorLabel:
    fmt.Println("错误:num 是负数")
}

4. return:终止函数并返回值

func add(a, b int) int {
    return a + b // 终止函数,返回结果
}

func main() {
    fmt.Println(add(1, 2)) // 输出:3
}

开发小技巧:

  • Go 无 while/do-while,所有循环用 for
  • 循环优先用 for-range:遍历集合时,for-range 比手动索引更简洁、不易出错;
  • if 优先带初始化语句:变量作用域更窄,代码更简洁;
  • switch 替代多条件 if-else:逻辑更清晰,尤其是多值匹配场景;switch 默认自动 break,fallthrough 可穿透;
  • 避免滥用 goto:仅在统一错误处理时使用,否则代码可读性差;
  • 标签命名用 PascalCase:比如 OuterLoop,而非 outer(符合 Go 命名规范);
  • select 必须加超时:避免通道永久阻塞(比如 time.After)。

补充:跳转标签

Go 中的跳转标签(Label)有语法命名规则作用域限制,可以自定义,但必须遵守规则;且标签只能和 break/continue/goto 配合使用,不能单独存在。

一、标签的命名规则(语法层面)

标签的命名和 Go 变量/函数命名基本一致,但有几个细节要注意:

1. 合法命名规则

  • 字母、下划线开头,后面跟字母、数字、下划线;
  • 区分大小写(Outerouter 是两个不同标签);
  • 不能和 Go 关键字重名(比如 ifforswitch 等)。

2. 合法/非法示例

// ✅ 合法标签
outerLoop:    // 字母+驼峰
error_label:  // 字母+下划线
LOOP1:        // 大写+数字
_inner:       // 下划线开头

// ❌ 非法标签
1loop:        // 数字开头(语法错误)
for:          // 和关键字重名(语法错误)
outer-loop:   // 包含短横线(语法错误)

3. 风格建议(非强制,但符合 Go 规范)

  • 标签名要见名知意,比如 OuterLoop(外层循环)、ErrorHandler(错误处理);
  • 多层循环的标签用「范围+类型」命名,比如 RowLoop/ColLoop,而非无意义的 a/b
  • 标签名建议用 PascalCase(大驼峰) ,和变量(camelCase)区分开,更易识别。

二、标签的使用限制(核心规则)

标签不是“想放哪就放哪”,有严格的作用域和绑定规则,这是新手最容易踩坑的地方:

1. 标签必须定义在「语句」前面,且和跳转语句在同一个函数内

  • 标签必须紧跟一个语句(循环、if、switch、甚至空语句 ;),不能单独写在一行;
  • 标签和 break/continue/goto 必须在同一个函数内(跨函数跳转不允许)。

✅ 合法示例:

func main() {
    // 标签绑定到 for 循环
    OuterLoop:
    for i := 0; i < 3; i++ {
        for j := 0; j < 3; j++ {
            if i*j == 4 {
                break OuterLoop // 同一函数内,合法
            }
        }
    }

    // 标签绑定到空语句(; 是空语句)
    errorLabel:
    ;
    goto errorLabel // 合法
}

❌ 非法示例:

func main() {
    goto testLabel // 错误:标签未定义在当前函数内
}

// 标签定义在另一个函数,跨函数跳转不允许
func otherFunc() {
testLabel:
    fmt.Println("test")
}

2. break/continue 只能跳转到「循环标签」,不能跳转到非循环标签

  • break/continue循环相关的跳转,标签必须绑定到 for 循环(包括 for-range);
  • 不能用 break/continue 跳转到 if/switch/空语句的标签(只有 goto 可以)。

✅ 合法(标签绑定循环):

outer:
for i := 0; i < 10; i++ {
    if i == 5 {
        break outer // 合法:标签绑定循环
    }
}

❌ 非法(标签绑定非循环):

errorLabel: // 标签绑定空语句
;
for i := 0; i < 10; i++ {
    if i == 5 {
        break errorLabel // 错误:break 只能跳转到循环标签
        // continue errorLabel // 同样错误
    }
}

3. goto 可以跳转到任意标签,但不能跳过变量声明

  • goto 是通用跳转,可跳转到任意语句的标签(循环、if、空语句);
  • goto 不能跳转到「变量声明语句之后」(避免变量未初始化就被使用)。

✅ 合法(跳转到空语句标签):

func main() {
    num := -1
    if num < 0 {
        goto errorLabel // 合法:跳转到空语句标签
    }

errorLabel:
    ;
    fmt.Println("错误:num 为负数")
}

❌ 非法(跳过变量声明):

func main() {
    goto label // 错误:goto 跳过了变量声明

    var a int = 10 // 变量声明
label:
    fmt.Println(a) // a 未初始化,禁止跳转
}

4. 标签不能重复定义(同一作用域内)

同一函数内,不能定义同名标签,即使绑定到不同语句也不行:

func main() {
outer:
for i := 0; i < 10; i++ {}

// 错误:label "outer" already defined
// outer:
// ;
}

三、标签的常见使用场景(避免滥用)

  1. 多层循环终止/跳过(最常用):
// 终止外层循环
outer:
for i := 0; i < 5; i++ {
    for j := 0; j < 5; j++ {
        if i+j == 5 {
            break outer
        }
        fmt.Printf("i=%d, j=%d\n", i, j)
    }
}

2. 统一错误处理goto 唯一推荐场景):

func readFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        goto errHandler
    }
    defer file.Close()

    // 其他操作
    data := make([]byte, 1024)
    _, err = file.Read(data)
    if err != nil {
        goto errHandler
    }
    return nil

errHandler:
    fmt.Println("读取文件失败:", err)
    return err
}

3. 跳过循环迭代(外层循环):

outer:
for i := 0; i < 3; i++ {
    for j := 0; j < 3; j++ {
        if j == 1 {
            continue outer // 跳过外层当前迭代
        }
        fmt.Printf("i=%d, j=%d\n", i, j)
    }
}

总结

  1. 命名规则:标签命名和变量类似(字母/下划线开头、区分大小写、不重名关键字),建议见名知意、大驼峰命名;
  2. 核心限制
    • 标签必须和跳转语句在同一函数内,且绑定到具体语句;
    • break/continue 只能跳转到循环标签,goto 可跳转到任意标签但不能跳过变量声明;
    • 同一函数内不能重复定义同名标签。
  1. 使用建议:标签仅在「多层循环控制」「统一错误处理」场景使用,避免滥用(尤其是 goto)。

六、错误处理

核心思想:Go 认为「错误是业务逻辑的一部分」,不是异常,要求开发者主动处理,而不是捕获,这也是 Go 代码健壮性高的原因

设计理念

Go 错误处理的核心原则区别于传统异常机制,是理解其所有用法的基础:

  1. 错误是普通值:错误并非特殊的异常对象,而是符合标准接口的普通返回值,可像字符串、数字一样被传递、存储、处理;
  2. 显式检查与返回:函数执行失败时,必须将错误作为返回值显式返回,调用方必须显式检查错误(而非隐式捕获),强制开发者关注错误场景;
  3. 轻量标准接口:所有错误都实现统一的 error 接口,保证错误处理的一致性,同时支持自定义错误,兼顾标准性和灵活性;
  4. 异常与错误分离:仅将不可恢复的致命错误(如数组越界、空指针解引用、系统资源耗尽)归为「异常」,用 panic/recover 处理;普通业务错误、可恢复错误(如文件不存在、参数错误、网络超时)均用 error 处理,避免异常滥用导致的代码混乱。
package main

import (
	"errors"
	"fmt"
)

// 自定义函数:模拟除法运算,除数为0时返回错误
func Divide(a, b int) (int, error) {
	if b == 0 {
		// 创建错误实例,返回「0值+错误」
		return 0, errors.New("除数不能为0")
	}
	// 执行成功,返回「结果+nil」(nil表示无错误)
	return a / b, nil
}

func main() {
	// 调用函数,显式接收「结果+错误」两个返回值
	res, err := Divide(10, 0)
	// 核心:显式检查错误——若err≠nil,说明执行失败,处理错误
	if err != nil {
		fmt.Printf("运算失败:%v\n", err) // 输出:运算失败:除数为0
		return
	}
	// 无错误时,正常使用结果
	fmt.Printf("运算成功:10/0 = %d\n", res)
}
  • Go 约定:有错误返回的函数,必须将 error 作为最后一个返回值
  • 无错误时,error 必须返回 nil(Go 中判断错误的唯一标准);
  • 调用方必须先检查错误,再使用结果(若 err≠nil,结果通常为无意义的 0 值,不可使用)。

自定义错误类型

基础错误(errors.New()/fmt.Errorf())仅能返回字符串描述,在复杂业务场景中(如网络请求、数据库操作、业务校验),往往需要携带更多错误上下文(如错误码、错误等级、请求 ID、堆栈信息),此时需自定义错误类型 —— 通过定义结构体并实现 error 接口(即实现 Error() string 方法)。

1. 自定义错误的核心步骤

  1. 定义结构体:包含需要的上下文字段(如错误码 Code、错误描述 Msg、请求 ID RequestID 等);
  2. 为结构体实现 **Error() string 方法 **:满足 error 接口规范,该方法的返回值即为错误的字符串描述;
  3. 提供构造函数(如 NewXXXError()):简化自定义错误的创建,统一错误初始化逻辑。

2. 实战示例:带错误码的业务自定义错误

(适用于 API 开发、业务系统,错误码便于前端统一处理、后端快速定位问题)

package main

import (
	"fmt"
)

// 1. 定义自定义错误结构体:携带错误码和错误描述
type BusinessError struct {
	Code int    // 业务错误码(如400-参数错误,500-业务异常,10001-用户不存在)
	Msg  string // 错误描述
}

// 2. 实现error接口的Error()方法:满足接口规范
func (e *BusinessError) Error() string {
	// 自定义错误的字符串格式,包含错误码和描述
	return fmt.Sprintf("[%d]%s", e.Code, e.Msg)
}

// 3. 提供构造函数:简化错误创建,推荐首字母大写(可跨包使用)
func NewBusinessError(code int, msg string) *BusinessError {
	return &BusinessError{
		Code: code,
		Msg:  msg,
	}
}

// 模拟业务函数:用户登录校验
func Login(username, password string) error {
	if username == "" {
		// 返回自定义业务错误:参数错误
		return NewBusinessError(400, "用户名不能为空")
	}
	if password == "" {
		return NewBusinessError(400, "密码不能为空")
	}
	if username != "admin" || password != "123456" {
		// 返回自定义业务错误:账号密码错误
		return NewBusinessError(10001, "用户名或密码错误")
	}
	// 无错误返回nil
	return nil
}

func main() {
	// 模拟登录请求
	err := Login("admin", "123")
	if err != nil {
		// 直接打印错误:调用自定义的Error()方法
		fmt.Println("登录失败:", err) // 输出:登录失败: [10001]用户名或密码错误

		// 进阶:类型断言,获取自定义错误的上下文字段(如错误码)
		if bizErr, ok := err.(*BusinessError); ok {
			fmt.Printf("错误码:%d,原始描述:%s\n", bizErr.Code, bizErr.Msg)
			// 可根据错误码做不同处理(如前端返回对应状态码、记录不同等级日志)
			switch bizErr.Code {
			case 400:
				fmt.Println("处理参数错误:返回前端400状态码")
			case 10001:
				fmt.Println("处理账号错误:记录登录失败日志")
			}
		}
		return
	}
	fmt.Println("登录成功")
}

应急处理:panic & recover(处理致命不可恢复错误)

Go 语言中,error 用于处理可恢复的普通错误(如参数错误、文件不存在),而 panicrecover 用于处理致命的、不可恢复的错误(如数组越界、空指针解引用、系统资源耗尽、断言失败),这类错误会导致程序正常执行流程中断,若不处理会直接崩溃。

1. panic:触发程序中断(主动抛出致命错误)

panic 是一个内置函数,用于主动触发程序执行中断,当调用 panic(值) 时,程序会立即停止当前函数的执行,依次执行该函数的 defer 语句,然后向上层函数递归执行,直到程序崩溃并打印panic 值 + 调用堆栈信息

panic 触发场景

  • 手动调用:开发者在检测到不可恢复的错误时主动调用(如配置文件加载失败、数据库初始化失败,程序无法正常运行);
  • 运行时自动触发:Go 运行时检测到严重错误时自动触发(如数组越界 arr[10]、空指针解引用 var p *int; *p = 10、除数为 0)。

手动调用 panic 示例

七、Go Module

Go 1.11 之后全面使用「Go Module(go mod)」模式,这是官方推荐的唯一正确方式GOPATH 模式已经彻底淘汰,本文所有内容都基于 Go Module 规范 讲解,所有代码可直接复用,无版本兼容问题。

Go Module 本质是一个 Go 项目的依赖管理工具,同时也是一个「项目模块」的定义:一个目录下,只要包含了 go.mod 文件,这个目录就是一个 Module(模块) ,这个目录下的所有子目录的代码,都属于当前 Module。

使用

(1) 初始化 Go Module

Go 的本地包导入,必须基于 Go Module 生效,没有初始化 go mod 的项目,导入本地包 100% 报错,这是新手第一大坑!

# 核心命令:go mod init 模块名(项目名)
go mod init myproject
  • 模块名可以自定义(比如 myproject / demo / github.com/你的名字/项目名 都可以);
  • 执行成功后,项目根目录会自动生成一个 go.mod 文件,这个文件是 Go 项目的「包管理核心文件」,后续所有包导入都靠它识别。它是 Go 的模块描述文件,用于 声明项目的「模块身份」+ 声明项目的「直接依赖包」及「版本」 。
  • 手动修改 /go mod命令修改均生效,Go 工具链会自动校验合法性

(2)统一项目目录规范

Go 对包的导入,本质是「根据文件目录结构寻址」,本地包的存放和导入,必须遵循固定的目录规范,目录结构定了,导入路径就定了,先记住一个核心原则:

✔️ Go 中,一个文件夹 = 一个包(package)

✔️ 文件夹里的所有 .go 文件,顶部声明的 package 包名 建议一致(非强制,但必须统一规范)

myproject/  <-- 项目根目录(go mod init 的目录)
├─ go.mod    <-- 初始化后生成的文件
├─ main.go   <-- 程序入口,package main + func main()
├─ utils/    <-- 自定义包:工具类包,文件夹名utils
│  └─ tool.go <-- 工具类代码,顶部写 package utils
├─ model/    <-- 自定义包:数据模型包,文件夹名model
│  └─ user.go <-- 模型代码,顶部写 package model
└─ service/  <-- 自定义包:业务逻辑包,文件夹名service
   └─ order.go <-- 业务代码,顶部写 package service

注意:

在 Go 语言中,被其他包导入的包(即库包)不能main 函数。只有可执行程序的主包(main package)才能包含 main 函数。

  1. 可执行包:如果一个包的名称是 main,它必须包含一个 main 函数作为程序入口点
  2. 库包:任何非 main 的包都是库包,不能包含 main 函数

依赖的分类

开发中会接触到两种依赖,Go Module 会自动区分并管理,无需手动干预:

  • 直接依赖:你在代码中 import 直接引用的包(比如github.com/gin-gonic/gin),会出现在go.modrequire节点中
  • 间接依赖:你引入的「直接依赖」自己所依赖的包(比如 gin 框架依赖的其他工具包),Go 会自动下载,也会写入go.sum,如果版本冲突会自动处理。如果没有安装第三方依赖,就不会有 go.sum 文件

go Module常用命令

  1. go mod init <module-name> 【初始化模块,最常用】

推荐命名规范:使用项目的「远程仓库地址」(比如 github/gitlab/gitee 地址),格式如 github.com/xxx/xxxgitee.com/xxx/xxx

  • 即使项目不上传远程仓库,也建议按这个格式命名,无冲突、规范
  1. go mod tidy 【自动整理依赖,高频核心】
  • 核心作用:自动分析你的代码,完成「依赖的全量自动整理」,做三件事,完美闭环:① 自动下载代码中 import 了、但本地还没有的 所有依赖包(直接 + 间接)② 自动删除 go.mod 中声明了、但代码中没有被引用的依赖包(清理无用依赖)③ 自动将所有依赖的版本信息同步到 go.modgo.sum
  • 使用场景
    1. 写完代码引入新包后,执行go mod tidy自动下载依赖
    2. 删除代码中无用的包引用后,执行go mod tidy清理无用依赖
    3. 团队协作拉取代码后,执行go mod tidy一键同步所有依赖,无需手动处理版本

高级用法

  1. 包的别名

使用场景:

  • 包的目录名太长,调用时不想写全称;
  • 导入的多个包名重复(比如导入了 demo/utilstest/utils);
import (
	// 给utils包起别名:u,调用时用u.xxx替代utils.xxx
	u "myproject/utils"
	m "myproject/model"
)

func main() {
	u.Add(10,20) // 用别名调用,简洁高效
	m.NewUser()
}

2. 匿名导入 → 只导入包,不调用包内内容(执行包的初始化逻辑)

适用场景:有些包中写了 init() 初始化函数(比如初始化数据库连接、加载配置),我们不需要调用包内的函数,只需要让这个包的init()执行即可。

语法:导入时包路径前加 _ 下划线


包的初始化顺序(面试考点)

Go 中包的初始化是自动执行的,顺序固定,无需手动控制: ① 先初始化「被导入的包」,再初始化「当前包」; ② 同一个包内,先初始化常量 → 变量 → 最后执行 init() 函数; ③ 一个包只会被初始化一次,即使被多次导入,也只执行一次init()。

注意

  1. 同一目录下的所有 .go 文件必须属于同一个包。这就是导致包名冲突的原因。错误示例:

  1. 项目的入口必须是 package main,且必须有 func main() 函数,这是 Go 程序的启动入口;
  2. main包不能被其他包导入,也不能导入其他main包;
  3. 所有自定义的业务包(utils、model 等),包名不能写main

八、并发(goroutine + channel)

Go 并发的核心是 Goroutine(轻量级协程)+ Channel(通道) ,这是 Go 区别于其他语言的核心优势:

并发 vs 并行

  • 并发(Concurrency):多个任务「交替执行」(单核也能实现),关注 “任务管理”;
  • 并行(Parallelism):多个任务「同时执行」(多核),关注 “硬件利用”;
  • Go 的并发模型是 CSP(通信顺序进程):通过「通道通信」共享数据,而非共享内存(避免锁竞争)。

核心优势:Goroutine 比线程轻量(初始栈仅 2KB,可动态扩缩),Go 运行时可调度上万 Goroutine,而线程仅能创建千级。

8.1 协程 Goroutine

Goroutine 是 Go 实现并发的最小单元,由 Go 运行时(runtime)调度,而非操作系统内核。

语法:

// 启动 Goroutine:go + 函数调用(无返回值)
go 函数名(参数)

// 匿名函数启动 Goroutine
go func(参数) {
    // 并发执行逻辑
}(参数)

示例:

package main

import (
    "fmt"
    "time"
)

// 定义一个普通函数
func sayHello(name string) {
    for i := 0; i < 3; i++ {
        fmt.Printf("Hello, %s! %d\n", name, i)
        time.Sleep(100 * time.Millisecond) // 模拟耗时操作
    }
}

func main() {
    // 启动 Goroutine 执行 sayHello
    go sayHello("Goroutine")

    // 主线程执行
    sayHello("Main")

    // 等待 Goroutine 执行完成(否则主线程退出,Goroutine 被终止)
    time.Sleep(500 * time.Millisecond)
    fmt.Println("主线程结束")
}

特点:

  • 轻量级:每个 Goroutine 初始栈 2KB,可动态扩展到 GB 级,创建 / 销毁开销远低于线程;
  • 调度模型:M(操作系统线程)+ P(处理器)+ G(Goroutine),Go 运行时调度器将 G 绑定到 M-P 对执行;
  • 主线程退出:如果 main 函数(主线程)退出,所有 Goroutine 都会被强制终止(无论是否执行完成);
  • 无返回值:Goroutine 执行的函数不能直接返回值(需通过 Channel 传递结果)。

Goroutine 泄漏

如果 Goroutine 因通道阻塞、死循环等原因无法退出,会导致内存泄漏:

// 错误示例:Goroutine 因通道无接收方永久阻塞
func leakGoroutine() {
    ch := make(chan int)
    go func() {
        ch <- 1 // 无接收方,永久阻塞,Goroutine 泄漏
    }()
}

8.2 通道 Channel - 协程间的通信方式

Go 的核心哲学:不要通过共享内存通信,要通过通信共享内存

Channel 是 Goroutine 间的通信机制,实现「共享内存不如通信」的 CSP 模型,用于传递数据、同步执行。

基本使用

// 1. 创建通道:make(chan 数据类型, [缓冲区大小])
// 无缓冲通道(同步):发送/接收都阻塞,直到对方就绪
ch := make(chan int)
// 有缓冲通道(异步):缓冲区未满可发送,未空可接收
ch := make(chan int, 10)

// 2. 发送数据:ch <- 数据
ch <- 10

// 3. 接收数据:变量 := <-ch 或 <-ch(忽略值)
val := <-ch

// 4. 关闭通道:close(ch)(仅发送方关闭,接收方判断通道是否关闭)
close(ch)

// 5. 接收时判断通道是否关闭
val, ok := <-ch // ok=true:有数据;ok=false:通道关闭且无数据

核心类型

类型语法特点
无缓冲通道make(chan T)同步通信,发送方阻塞直到接收方接收,接收方阻塞直到发送方发送
有缓冲通道make(chan T, n)异步通信,缓冲区未满可发送,未空可接收,缓冲区满 / 空时才阻塞
只读通道<-chan T只能接收数据,不能发送(用于函数参数,限制权限)
只写通道chan<- T只能发送数据,不能接收(用于函数参数,限制权限)
package main

import "fmt"

// 只写通道:chan<- int
func sendOnly(ch chan<- int, val int) {
    ch <- val
    // <-ch // 错误:只读通道不能接收
}

// 只读通道:<-chan int
func recvOnly(ch <-chan int) {
    val := <-ch
    fmt.Println("接收:", val)
    // ch <- 2 // 错误:只写通道不能发送
}

func main() {
    ch := make(chan int)

    go sendOnly(ch, 10)
    go recvOnly(ch)

    time.Sleep(100 * time.Millisecond)
    close(ch)
}

8.3 同步等待 - sync 包基础

虽然 Go 推荐用 Channel 通信,但某些场景(如计数、互斥)需要共享内存,此时用 sync 包。

sync 包是实现同步等待(协调多个 goroutine 执行顺序、等待任务完成)的核心工具包,它提供了一系列原语来解决并发编程中的同步问题。

核心场景:等待多个 goroutine 完成

最常见的同步等待需求是:主 goroutine 启动多个子 goroutine 后,需要等待所有子 goroutine 执行完毕再继续(比如汇总结果、释放资源)。sync 包中解决这个问题的核心工具是 sync.WaitGroup

  • Add(n int):设置需要等待的 goroutine 数量(n 是子任务数);
  • Done():每个子 goroutine 执行完毕后调用,等价于 Add(-1)
  • Wait():阻塞当前 goroutine,直到等待的数量减为 0。

示例:

package main

import (
	"fmt"
	"sync"
	"time"
)

// 子任务函数:模拟耗时操作
func task(id int, wg *sync.WaitGroup) {
	// 延迟调用 Done,确保即使函数提前返回也能执行
	defer wg.Done()

	fmt.Printf("子任务 %d 开始执行\n", id)
	time.Sleep(time.Second) // 模拟耗时操作(比如网络请求、计算)
	fmt.Printf("子任务 %d 执行完毕\n", id)
}

func main() {
	// 1. 初始化 WaitGroup
	var wg sync.WaitGroup

	// 2. 设置需要等待的 goroutine 数量(这里启动 3 个子任务)
	wg.Add(3)

	// 3. 启动 3 个 goroutine
	for i := 1; i <= 3; i++ {
		go task(i, &wg) // 注意:必须传递 WaitGroup 的指针,否则会拷贝(导致等待失效)
	}

	// 4. 同步等待:主 goroutine 阻塞,直到所有子任务调用 Done()
	fmt.Println("主 goroutine 等待所有子任务完成...")
	wg.Wait()

	// 5. 所有子任务完成后,主 goroutine 继续执行
	fmt.Println("所有子任务执行完毕,主 goroutine 继续执行")
}

运行结果(子任务顺序可能随机,因为 goroutine 调度是异步的):

主 goroutine 等待所有子任务完成...
子任务 1 开始执行
子任务 2 开始执行
子任务 3 开始执行
子任务 1 执行完毕
子任务 2 执行完毕
子任务 3 执行完毕
所有子任务执行完毕,主 goroutine 继续执行

sync 包其他常用同步工具

除了 WaitGroupsync 包还有其他解决同步问题的工具,新手需要了解:

表格

工具作用
sync.Mutex互斥锁:保证同一时间只有一个 goroutine 访问共享资源(解决数据竞争)
sync.RWMutex读写锁:读共享、写互斥(读多写少场景优化性能)
sync.Once保证函数只执行一次(比如单例初始化、资源只加载一次)
sync.Cond条件变量:让 goroutine 等待某个条件满足后再执行(比 sleep 轮询高效)

sync.Once 示例:

package main

import (
	"fmt"
	"sync"
)

var (
	once sync.Once
	data string
)

func initData() {
	fmt.Println("初始化数据...")
	data = "hello sync"
}

func main() {
	// 多次调用,但 initData 只会执行一次
	for i := 0; i < 3; i++ {
		once.Do(initData)
		fmt.Println("数据:", data)
	}
}

运行结果:

初始化数据...
数据: hello sync
数据: hello sync
数据: hello sync

新手常遇到的坑

  • WaitGroup 的 Add 时机:必须在启动 goroutine 之前 调用 Add,否则可能出现子 goroutine 已经执行完 Done(),主 goroutine 才调用 Add,导致 Wait() 直接返回(没等到子任务)。
  • 禁止拷贝 WaitGroup:WaitGroup 是值类型,拷贝后会生成新实例,原实例的 Wait() 永远等不到信号(可以通过 go vet 检查这类问题)。
  • Done 调用次数匹配Done() 的调用次数必须等于 Add() 的值,否则会导致:
    • 调用过少:Wait() 永久阻塞;
    • 调用过多:触发 panic(panic: sync: negative WaitGroup counter)。