我是前端鱼姐,最近失业了,准备自学 golang 转型,有不足的地方还请有转型成功的前辈或者技术大佬来指导一二。
学习资料:
Go 语言没有「类 (class)」,也没有「继承」,它彻底抛弃了传统 OOP 的复杂特性,用「结构体 + 方法」实现了面向对象的所有核心能力,比 Java 的 OOP 更简洁、更灵活。
一、语言结构
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
规则:
- 同一个文件夹下的所有
.go文件,package 包名必须完全一致,否则编译必报错; - Go 中「文件夹 = 包」,物理目录和逻辑包一一对应,约定优于配置;
- 包名可以≠文件夹名,但建议一致;包名小写、简洁、语义化;
- 同包下的所有代码,无需导入,直接互相调用
- ****一个项目中,只能有一个
package main+func main(),不能多个文件写多个main函数。
执行程序
(1)直接运行
****编译 + 运行 一步完成,不生成可执行文件,适合开发调试、单文件 / 简单小程序 快速验证逻辑;
go run hello.go
(2) 编译后运行
先编译生成可执行文件,再执行文件,适合项目上线、多文件工程、需要分发程序 的场景; 没有平台限制。
go build main.go
go 的多包标准项目
├─ go_project/ 【项目根目录】
│ ├─ main.go 【package main + func main() 程序入口】
│ ├─ user/ 【用户模块,依赖包】
│ │ ├─ user.go 【package user】
│ │ └─ login.go【package user】
│ └─ utils/ 【工具模块,依赖包】
│ ├─ str.go 【package utils】
│ └─ num.go 【package utils】
main.go是唯一的入口,package main+func main();user/和utils/是库包,包名分别是user/utils,只能被main包导入调用,不能直接运行;- 库包内的函数 / 结构体,首字母大写才能被
main包调用(访问权限)。
行分隔符
在 Go 程序中,一行代表一个语句结束。每个语句不需要像 C 家族中的其它语言一样以分号 ; 结尾,因为这些工作都将由 Go 编译器自动完成。
fmt.Println("Hello, World!")
fmt.Println("菜鸟教程:runoob.com")
注释
- 单行注释://
- 多行注释: /* --- */
// 单行注释
/*
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 | 整型以十六进制、字母大写方式显示 |
| %U | Unicode 字符 |
| %f | 浮点数 |
| %p | 指针,十六进制方式显示 |
指针
指针是一个变量,但它存储的不是普通的值(比如数字、字符串),而是另一个变量的内存地址。
| 符号 | 作用 | 示例 |
|---|---|---|
& | 取地址符:获取变量的内存地址 | &a表示获取变量 a 的地址 |
* | 解引用符:通过地址访问变量的值 | *p表示访问指针 p 指向的变量的值 |
二、数据类型
Go 语言是 强类型静态语言,核心特点:变量声明时必须指定类型(或自动推导),类型一旦确定,程序运行期间不能改变。
****所有数据类型的变量,声明后必须使用,否则 Go 编译器会直接报错,这是 Go 的强制语法(避免无用代码)。
值类型
变量直接存储「数据本身」,内存中存的是值,赋值 / 传参时,会拷贝一份全新的数据,新旧变量互不影响。 包括:
- 基础类型:整数、浮点、布尔、字符串、字符( byte/rune )
- 复合值类型:数组、结构体
1. 布尔值
- 取值:只有两个固定值 →
true(真) /false(假) - 占用内存:1 个字节
- 格式化占位符:
%t - 注意点:
-
- 不能用 0/1 代替 true/false,和 C 语言不同;
- 布尔值不能参与算术运算(比如
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 ~ 127int16:占 2 字节,范围-32768 ~ 32767int32:占 4 字节,范围-2147483648 ~ 2147483647int64:占 8 字节,范围-9223372036854775808 ~ 9223372036854775807int:最常用! 占 4/8 字节,根据操作系统自动适配(32 位系统 = int32,64 位 = int64)
(2) 无符号整数(只能存正数 / 0)
uint8(byte):占 1 字节,范围0 ~ 255,byte是uint8的别名,专门存「字节」uint16:占 2 字节,范围0 ~ 65535uint32:占 4 字节,范围0 ~ 4294967295uint64:占 8 字节,范围0 ~ 18446744073709551615uint:占 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 文本。
特点:
- Go 的字符串是 不可变的:字符串一旦声明,内容不能修改(比如
str[0] = 'a'编译报错); - 字符串底层是
byte字节数组,支持下标访问(str[0]取第一个字节); - 天然支持中文,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,...}
✅ 核心优点(重点):
- 字段顺序随意,不用和结构体定义顺序一致;
- 支持部分字段赋值,未赋值的字段自动赋零值;
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{},称为「空结构体」,它的特点:
- 没有任何字段;
- 占用的内存大小为 0 字节;
- 常用场景:做集合(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=nil,len=0,cap=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(目标切片, 源切片)
特点:
copy是值拷贝,目标切片和源切片底层数组分离,修改互不影响;- 拷贝的元素个数 = 目标切片长度 和 源切片长度 的较小值;
- 返回值是实际拷贝的元素个数。
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 + 切片截取 实现,核心思路是「保留要的元素,拼接成新切片」。
- 删除指定索引的元素
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=nil,len=0,cap=0 | len(s) == 0 |
| 空切片 | ptr≠nil,len=0,cap=0 | len(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
[...]string- 这是数组(Array)
-
- 固定长度的数组,长度是类型的一部分
...是 Go 编译器自动计算数组长度的语法糖- 你的例子
arr := [...]string{"apple", "banana", "cherry"}创建了一个长度为 3 的数组
[]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) 获取值
- 直接获取
语法:值 := 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. 注意
- Map 不是并发安全的
多个 goroutine 同时读写 Map 会触发 panic;
解决方案:
- 用
sync.Map(Go 内置的并发安全 Map); - 加互斥锁(
sync.Mutex)。
- 遍历顺序不固定
- 未初始化的 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)通道的超时控制
结合select和time.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 文本。
变量的定义方式
- 标准声明(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
}
注意事项
- 常量的值必须是编译期就能确定的(不能用运行时才能计算的值,比如 time.now())
- 常量声明后可以不用(与变量不同,这里不会报错)
- 常量的类型可以是数值型、字符串、布尔型,不支持切片‘映射等复杂类型。
变量的作用域
变量的作用域指的是变量能够被访问和使用的代码范围。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. 合法命名规则
- 以字母、下划线开头,后面跟字母、数字、下划线;
- 区分大小写(
Outer和outer是两个不同标签); - 不能和 Go 关键字重名(比如
if、for、switch等)。
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:
// ;
}
三、标签的常见使用场景(避免滥用)
- 多层循环终止/跳过(最常用):
// 终止外层循环
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)
}
}
总结
- 命名规则:标签命名和变量类似(字母/下划线开头、区分大小写、不重名关键字),建议见名知意、大驼峰命名;
- 核心限制:
-
- 标签必须和跳转语句在同一函数内,且绑定到具体语句;
break/continue只能跳转到循环标签,goto可跳转到任意标签但不能跳过变量声明;- 同一函数内不能重复定义同名标签。
- 使用建议:标签仅在「多层循环控制」「统一错误处理」场景使用,避免滥用(尤其是
goto)。
六、错误处理
核心思想:Go 认为「错误是业务逻辑的一部分」,不是异常,要求开发者主动处理,而不是捕获,这也是 Go 代码健壮性高的原因
设计理念
Go 错误处理的核心原则区别于传统异常机制,是理解其所有用法的基础:
- 错误是普通值:错误并非特殊的异常对象,而是符合标准接口的普通返回值,可像字符串、数字一样被传递、存储、处理;
- 显式检查与返回:函数执行失败时,必须将错误作为返回值显式返回,调用方必须显式检查错误(而非隐式捕获),强制开发者关注错误场景;
- 轻量标准接口:所有错误都实现统一的
error接口,保证错误处理的一致性,同时支持自定义错误,兼顾标准性和灵活性; - 异常与错误分离:仅将不可恢复的致命错误(如数组越界、空指针解引用、系统资源耗尽)归为「异常」,用
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. 自定义错误的核心步骤
- 定义结构体:包含需要的上下文字段(如错误码
Code、错误描述Msg、请求 IDRequestID等); - 为结构体实现 **
Error() string方法 **:满足error接口规范,该方法的返回值即为错误的字符串描述; - 提供构造函数(如
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 用于处理可恢复的普通错误(如参数错误、文件不存在),而 panic 和 recover 用于处理致命的、不可恢复的错误(如数组越界、空指针解引用、系统资源耗尽、断言失败),这类错误会导致程序正常执行流程中断,若不处理会直接崩溃。
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 函数。
- 可执行包:如果一个包的名称是
main,它必须包含一个main函数作为程序入口点 - 库包:任何非
main的包都是库包,不能包含main函数
依赖的分类
开发中会接触到两种依赖,Go Module 会自动区分并管理,无需手动干预:
- 直接依赖:你在代码中
import直接引用的包(比如github.com/gin-gonic/gin),会出现在go.mod的require节点中 - 间接依赖:你引入的「直接依赖」自己所依赖的包(比如 gin 框架依赖的其他工具包),Go 会自动下载,也会写入
go.sum,如果版本冲突会自动处理。如果没有安装第三方依赖,就不会有 go.sum 文件
go Module常用命令
go mod init <module-name>【初始化模块,最常用】
推荐命名规范:使用项目的「远程仓库地址」(比如 github/gitlab/gitee 地址),格式如 github.com/xxx/xxx、gitee.com/xxx/xxx
- 即使项目不上传远程仓库,也建议按这个格式命名,无冲突、规范
go mod tidy【自动整理依赖,高频核心】
- 核心作用:自动分析你的代码,完成「依赖的全量自动整理」,做三件事,完美闭环:① 自动下载代码中
import了、但本地还没有的 所有依赖包(直接 + 间接)② 自动删除go.mod中声明了、但代码中没有被引用的依赖包(清理无用依赖)③ 自动将所有依赖的版本信息同步到go.mod和go.sum中 - 使用场景:
-
- 写完代码引入新包后,执行
go mod tidy自动下载依赖 - 删除代码中无用的包引用后,执行
go mod tidy清理无用依赖 - 团队协作拉取代码后,执行
go mod tidy一键同步所有依赖,无需手动处理版本
- 写完代码引入新包后,执行
高级用法
- 包的别名
使用场景:
- 包的目录名太长,调用时不想写全称;
- 导入的多个包名重复(比如导入了
demo/utils和test/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()。
注意
- 同一目录下的所有
.go文件必须属于同一个包。这就是导致包名冲突的原因。错误示例:
- 项目的入口必须是
package main,且必须有func main()函数,这是 Go 程序的启动入口; main包不能被其他包导入,也不能导入其他main包;- 所有自定义的业务包(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 包其他常用同步工具
除了 WaitGroup,sync 包还有其他解决同步问题的工具,新手需要了解:
表格
| 工具 | 作用 |
|---|---|
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)。
- 调用过少: