目录
- 引言
-
- 开发环境与IDE
- Golang特性简介-优势及缺陷
- [Hello world](#Hello world)
- Golang语法入门
-
- 变量的声明
- 基本数据类型
- 常量
- 函数
- 值传递与引用传递;指针
- defer与延迟函数
-
- [panic 与 recover:异常处理机制](#panic 与 recover:异常处理机制)
- slice与map
- 面向对象语法特征-struct、封装、继承、多态
- 反射
- Golang进阶
-
- groutine协程并发
- channel实现goroutine之间通信
- GoModules
-
- [Go Modules与GOPATH](#Go Modules与GOPATH)
- [Go Modules模式](#Go Modules模式)
- [用Go Modules初始化项目](#用Go Modules初始化项目)
- 修改模块的版本依赖关系
- [Go Modules 版本号规范](#Go Modules 版本号规范)
- [vendor 模式实践](#vendor 模式实践)
引言
本文学习前置要求
1、具备1种后端编程语言开发经验(C/C++/Java/Python/PHP等)
3、了解计算机基本体系结构
4、了解Linux基础知识
开发环境与IDE
1.下载安装包
首先是Golang安装包的下载:
根据自己系统,自行选择安装。如果是window系统 推荐下载可执行文件版,一路 Next。
参考博客:
在Windows上安装Go编译器并配置Golang开发环境
Golang起步篇三种系统安装配置go环境以及IDE推荐以及入门语法详细释义
这里以linux为例:

复制tar包连接,然后下载
shell
cd /usr/src
wget https://dl.google.com/go/go1.14.2.linux-amd64.tar.gz
2.解压安装包
Linux 从 https://golang.org/dl/ 下载tar⽂件,并将其解压到 /usr/local。
shell
sudo tar -zxvf go1.14.2.linux-amd64.tar.gz -C /usr/local/
解压完之后到/usr/local目录下发现有名为go的文件夹,代表我们当前go环境编译器所在的路径,go文件夹下包括src,因为go是开源的,包括了全部的源码,想学习源码可以看这里的相关代码,比如sort(排序相关),sync(同步相关):
shell
[root@iZ2ze505h9bgsbp83ct28pZ src]# tar -xvf go1.14.2.linux-amd64.tar.gz -C /usr/local/
[root@iZ2ze505h9bgsbp83ct28pZ src]# cd /usr/local/
[root@iZ2ze505h9bgsbp83ct28pZ local]# ls
aegis bin etc games go include lib lib64 libexec mysql sbin share src

/usr/local/go/bin下有两个指令,go和gofmt,其中go指令就表示当前的go编译环境,即最终通过go来编译我们的代码,所以需要将/usr/local/go/bin添加到PATH环境变量中。
gofmt 是 Go 语言自带的代码格式化工具,作用是:自动把 Go 代码整理成统一、规范的格式。
主要功能:自动缩进、对齐、统一空格;调整花括号、关键字、结构排版;移除多余的空行或空格;提高代码可读性、统一团队编码风格。
Go 官方非常强调"格式统一不依赖争议或习惯",所以几乎整个 Go 社区都遵循 gofmt 的排版风格 ------ 它不是一个建议,而是社区默认约定的规范。
如果你有一个 Go 文件 main.go,只需运行:
gofmt -w main.go.它会直接修改文件内容,把格式整理好(-w 代表 write back)。或者你可以只看格式化后的内容:gofmt main.go很多 IDE(如 GoLand、VS Code)都内置了 gofmt 或者调用 gopls,保存文件时自动格式化。
3.配置GOROOT环境变量
把/usr/local/go/bin目录配置GOROOT 到环境变量里:
shell
sodu vim /etc/profile
文件末尾加入下面几段:

shell
# 设置Go语言的路径 新加入GOROOT GOPATH GOBIN三个环境变量,修改PATH环境变量
# GOROOT环境变量:当前Go语言源码包所在位置
export GOROOT="/usr/local/go"
# GOPATH环境变量:当前用户/开发者写Go语言的工作路径,一般是下面的,当然自定义也无所谓
# Go1.14后,推荐使用`go mod`模式来管理依赖,不再强制代码必须写在`GOPATH`下面的src目录了,可以在电脑的任意位置编写go代码。
export GOPATH=$HOME/go
export GOBIN=$GOROOT/bin
# 修改系统环境变量PATH,将之前的PATH再并集上我们当前设置的GOBIN
export PATH=$PATH:$GOBIN
配完之后保存并测试是否配置成功:
shell
source /etc/profile
go version
go env
go --help
如果系统变量还是不能生效
每次新打开一个命令窗口都要重新输入 source /etc/profile 才能使go env 等配置文件生效:
那就加到用户变量,这样当前用户一登录就会加载到
解决方法:
在 ~/.bashrc 中添加语句(在root账号和子账号里都加一次)
shell
source /etc/profile
保存退出
shell
source /etc/profile
或者
source $HOME/.profile
/etc/profile的作用范围是系统级,影响所有用户,登录时加载(如使用 ssh 或图形界面登录),需要 sudo 权限修改,常见用途是设置所有用户的通用环境变量;
~/.bashrc作用范围是用户级,仅影响当前用户,启动交互式 非登录 shell 时加载(如直接打开终端),当前用户可直接修改,常见用途是设置个人使用的环境变量、别名等
4. 开发工具
vscode(免费) or Goland(收费)
本教程非入门级别教程,故开发者可以用自己喜好的IDE进行配置,这里不再梳理IDE的安装和配置,详细请参考其他教学资料
Golang特性简介-优势及缺陷
Go 语言的一大核心优势,就是部署的极致简洁。在当今各种技术栈动辄依赖几十个第三方库的背景下,Go 的部署过程显得格外干净利落。这种简洁体现在以下三个方面:
-
直接编译为机器码。Go 源码可以直接编译为机器码,也就是说,你写的代码最终会被编译成可以被操作系统直接执行的二进制文件(类似于"101010..."的机器语言),不再需要中间解释层或者虚拟机。生成的可执行文件在终端中通过
./your_app就可以直接运行。 -
无外部依赖,生成的是静态可执行文件。编译后的程序本质上是一个独立的静态可执行文件,不依赖任何第三方库。这意味着,你不需要在部署环境中额外安装任何运行时、依赖库或配置文件。这一点与 Java 依赖 JDK 或 C++ 工程需要链接动态库形成了鲜明对比。
-
即编即用,即拷即跑。由于编译产物是一个完全自足的二进制文件,部署时只需把它拷贝到服务器上就可以运行,不需要任何复杂的安装流程,也无需配置环境变量或依赖管理。这种"拷贝即部署"的模式,大大降低了部署和运维的复杂度。
我们可以简单做个演示来感受一下 Go 的部署体验:
假设我们有一个用 Go 编写的后端服务项目 server,只需要执行一次 go build server.go,编译速度非常快。编译完成后,会生成一个名为 server 的绿色可执行文件,文件大小大约 5MB,虽然略大,但这是因为它已经将所需的库全部静态编译进去了。
使用 ldd server 查看依赖,会发现它只依赖少量基础系统库,如 libc、pthread 和标准 so 库。除了这些底层依赖外,无需额外配置任何环境或安装其他库。
最后,我们通过 ./server 直接运行程序,服务即可启动。这整个过程,无需环境配置、无需依赖安装,真正做到了"编译即部署"。

Go 语言的第二大优势在于,它是一门静态类型语言。这意味着变量的类型在编译阶段就必须确定,程序在编译期间会进行类型检查,从而能在第一时间发现大量潜在的问题。
静态类型语言的最大好处,就是在程序运行之前,就能通过编译器捕捉到错误。例如,当我们使用 go build 编译 Go 程序时,如果代码中存在类型不匹配、未声明的变量或其他静态语义错误,编译器会明确指出问题所在的行号和错误信息。这样,我们在代码尚未运行前,就能提早修复大部分问题,提升了代码的稳定性和可靠性。
这与动态类型语言形成了鲜明对比。像 Python、JavaScript 或 Linux Shell 脚本等语言,它们没有编译阶段,所有错误都只能在运行时逐步暴露。这种"运行时发现问题"的机制往往会导致调试效率低、线上风险高,尤其在大型项目中更容易埋下隐患。
因此,静态类型语言虽然在编码时稍显严格,但从长远来看,它提供的类型安全和编译期保障,极大提升了开发质量和系统稳定性。而 Go 恰恰很好地平衡了静态类型语言的安全性与语法的简洁性,使得它既严谨又高效。
虽然 Java 和 Go 都是静态语言,但 Go 通过去除运行时依赖、直接编译为本地代码,让部署变得更加简单高效。
Go 的第三个核心优势是它在语言层面原生支持并发。这不是通过外部库或框架"拼接"出来的功能,而是 Go 语言设计之初就内建的能力,可以说是"并发写进了语言的基因"。在许多其他语言中,并发是通过额外的线程库、线程池、回调机制,甚至是繁琐的锁机制来实现的。虽然最终也能实现并发,但实现过程复杂,容易出错,且很难高效地利用系统资源。相比之下,Go 的并发模型------基于 goroutine 和 channel 的设计------既简洁又强大。
goroutine 就是协程(coroutine)的一种实现形式,是 Go 中对"协程"的高度封装实现,具备协程的所有特性,且使用更简单、调度更智能、性能更优秀。你可以将其理解为一种"轻量级线程"。它的启动成本极低,远低于传统的操作系统线程,并且 Go 的运行时(runtime)内置了调度器,会自动将这些 goroutine 分发到多个 CPU 核心上运行,充分利用多核处理器的能力。
来看一个例子,我们可以用极简单的方式开启 10,000 个并发任务:
go
package main
import (
"fmt"
"time"
)
func goFunc(i int) {
fmt.Println("goroutine ", i, " ...")
}
func main() {
for i := 0; i < 10000; i++ {
go goFunc(i) //开启一个并发协程
}
time.Sleep(time.Second) // 给所有 goroutine 留出执行时间
}
这段代码中,go goFunc(i) 这一行就是并发的关键。它会在后台启动一个新的 goroutine,去执行函数 goFunc(i)。仅这一行,就可以轻松发起 1 万个并发任务,而你不需要关心线程调度、CPU 绑定、内存分配等复杂问题,这些都由 Go 的运行时自动处理。
最终效果就是:你写的代码非常简洁,系统的并发能力却被充分释放。对于需要高并发、并行处理的后端服务、微服务、网络编程等场景来说,这种原生的并发支持提供了极大的性能优势和开发效率。
"goroutine 本质上就是协程,但在 Go 中,它比传统协程更轻量、更易用、更强大。"
goroutine 相比一般协程的特点
| 特性 | goroutine(Go) | 普通协程(如 Lua、Python greenlet) |
|---|---|---|
| 调度 | Go runtime 自动调度(M:N 调度模型) | 通常需要手动调度或借助框架 |
| 栈大小 | 初始栈很小(几 KB,可动态增长) | 有的固定,有的手动控制 |
| 通信方式 | 内建 channel | 需要自己定义通信机制 |
| 创建方式 | go func() 一行搞定 |
通常更复杂,需要构造协程对象 |
| 性能 | 创建非常快、内存占用极低 | 相对较高(具体实现不同) |
Go 的第四个显著优势是其功能强大、覆盖面广的标准库。这一点对于开发者来说非常重要 ------ 它意味着你在开发过程中,无需频繁依赖第三方库,就能完成绝大多数功能需求。
1.内建的 runtime 系统与高效 GC
Go 的标准库不仅仅是"功能模块"的集合,它还包括底层的 runtime 系统调度机制,这为程序的并发执行和资源管理提供了坚实基础。Go runtime 负责:
- Goroutine 的调度与负载均衡
- 垃圾回收(GC)
- 内存管理
- 时间调度和系统调用封装
特别是在垃圾回收方面,自 Go 1.8 起,GC 引入了 三色标记法与混合写屏障机制,极大地提高了垃圾回收的效率和暂停时间的可控性,使 Go 的 GC 既"高效"又"低打扰",非常适合长时间运行的服务端程序。
2.标准库覆盖范围广、实用性强
Go 的标准库几乎涵盖了日常开发中所需的绝大部分功能模块,例如:
- 基础数据处理:字符串处理、字节数组、时间与日期操作等
- 文件与IO操作:标准输入输出、文件系统管理、权限修改
- 编码与解码:JSON、XML、Base64、URL 等格式的处理
- 网络编程:HTTP、TCP/UDP、Socket 编程、RPC 通信协议
- 压缩算法:如 gzip、zlib、tar、zip 等
- 并发工具:锁(mutex)、条件变量、WaitGroup、channel 等同步机制
- 加解密工具:支持多种哈希算法、对称/非对称加密
- 测试工具:内建 testing 包,支持单元测试、基准测试
- 调试与性能分析:pprof、trace 等工具内建支持
- 构建与部署支持:项目结构、依赖管理、交叉编译等工具链完善
这还只是标准库的一部分,Go 的原生包体系设计清晰、文档详尽,非常适合团队协作和维护。
3.标准库:通用性 vs. 特定优化
当然,在某些场景下,如果你对性能有极致的追求,或者有一些非标准功能需求,第三方库可能会提供更精细化的控制或更高的性能。但在大多数业务开发中,Go 的标准库已经足够健壮、足够高效,完全可以满足日常开发所需。
总结就是在 Go 语言中,标准库不仅广泛覆盖了开发需求,更以高性能、低依赖、良好设计,帮助开发者专注于业务逻辑本身,而非底层实现。"如果你刚上手 Go,会很快发现:你不需要"找库找半天",几乎你要的功能,标准库都给你准备好了。
第五个优势就是(Go)的一个简单易学,它他仅仅有25个关键字,它的语法呢,实际上是C语言过渡来的,它的语法是非常简洁的,而且他是内嵌C语法支持的,即所谓的"Cgo",我们可以在里面内嵌C语法,使得在必要时可以无缝调用C语言编写的底层库,在性能优化或与系统底层打交道时非常有用。
然后呢,它也具有面向对象的特征,虽然 Go 并不像传统面向对象语言那样强调类(class)和继承(inheritance),但它通过 struct + interface 的方式,完整支持了面向对象编程的三大特性,包括,继承、多态、封装特性,面向对象的三要素它都满足。最后呢,它也是一个跨平台语言,不管是mac下,Linux,还是Windows,只要安装Go的环境,是都能够执行的。

Go呢还有一个优势,就是它是"大厂"领军的,就是Golang语言呢,国内很多公司,包括国外很多大公司也在用,他们帮我们去开路,我们只需要用他们铺好的路站在巨人的肩膀上,我们再去使用,简单列几个:
- Google:Go 语言的发明者,至今仍是其最重要的推动者。Google 内部大量项目使用 Go 开发,最具代表性的开源项目是 Kubernetes(K8s),目前已成为云原生领域的核心基础设施。
- Facebook(Meta):Facebook 也在广泛使用 Go,并设立了专门的 GitHub 组织 facebookgo,其中包括多个高质量的 Go 开源项目,比如实现无停机平滑重启的 grace。
- 腾讯:作为国内最早大规模实践容器化的公司之一,腾讯在 2015 年就将 Docker 部署规模扩大到"万台级别",其游戏中台平台"蓝鲸"在容器管理中大量使用 Go。由于腾讯主力语言是 C/C++,迁移到 Go 拥有天然优势。
- 百度:百度也是国内较早推广 Go 的企业之一,早在 2016 年就进行了大量技术分享与开源实践,其中较为知名的是 百度网盘 Go 语言版本,是国内 Star 数较多的 Go 项目之一。

为了简单对比一下Go语言和其他语言,我们尝试用一个fibonacci数列算法我们通过不同的语言进行编译和运行,当然这个并不能绝对的评论某个语言的好坏,只是来做一个分析,有一个数字,咱们简单去排列一下,让大家能够清楚的知道Go语言在一些后端语言的地位和和它的性能到底处在一个什么位置:



说完优点了,那么它有哪些不足呢?网上有很多评论了,说一下我的个人看法。
1.第三方库依赖的不确定性。虽然 Go 拥有现代化的包管理工具,比如 Go Modules 和 go mod,但其第三方库生态依赖过度集中在 GitHub,这一点仍然存在潜在风险。目前,大量 Go 的第三方库都托管在个人 GitHub 仓库中,而这些仓库多数缺乏官方或机构级维护保障。这意味着:
-
- 某个库可能今天还在维护,明天就归档了;
-
- 作者心血来潮可能修改 API,没有版本兼容保障;
-
- 安全更新和长期维护难以保障;
在企业项目中依赖这些"非官方、非组织"的代码,确实存在一定不稳定性。因此,希望未来能有更强的 社区/官方支持的包仓库平台,对流行、高质量的第三方库进行统一管理和运营,从而增强生态的可靠性和可持续性。
2.泛型支持上线较晚,仍在完善中。Go 在很长一段时间内都不支持泛型(Generics),这让很多开发者在面对"通用数据结构"时不得不写大量重复代码。虽然自 Go 1.18 起官方终于引入了泛型支持,但目前仍处于逐步成熟和演进阶段:
- 泛型语法相对简洁,但还不如 Rust / Java 那样灵活;
- 一些标准库和第三方库尚未全面适配泛型;
- 对初学者来说泛型文档相对有限,生态尚未完全跟进。
因此,如果你是老 Go 用户,可能已经适应了无泛型的开发方式;而对于新用户,泛型仍是一个值得关注的语言演进方向。
3.异常处理机制偏极端:没有 try-catch。Go 的错误处理机制采用的是极简风格:没有异常(Exception),只有错误(Error)。也就是说,Go 中不存在 try...catch,所有错误都通过函数返回值(通常是 error 类型)显式传递。这种机制的好处是:
- 错误处理显式,逻辑清晰;
- 减少隐藏的运行时异常,控制权在开发者手里。
但它也有缺点: - 错误处理冗长,容易产生大量 if err != nil 的重复代码;
- 对于来自 Java、Python 等支持异常捕获的开发者,转变思维方式需要时间;
- 不支持堆栈自动回溯、精细异常分类等功能。
这可以看作 Go 与 C 在设计理念上的相似之处 ------ 都希望将错误视为正常流程的一部分来显式处理,而不是运行时异常。这种"极端选择"是否适合,还需要开发者根据自身项目特点判断。
4.对 C 的兼容是"有限兼容",并非无缝集成。Go 可以通过 cgo 调用 C 语言代码,这为性能优化、调用系统级库、底层处理提供了可能性。但需要明确的是,这种兼容并非无缝,具体存在以下问题:
- cgo 引入额外编译复杂度,打包和交叉编译更困难;
- 性能开销较大(调用 C 时会触发运行时边界切换);
- 并不能像 C++ 那样对 C 做到完整语义兼容;
- 一些底层序列化、网络协议、硬件相关操作,仍然更适合用纯 C 来实现。
因此,虽然 Go 可以与 C 协作开发,但在系统编程层面,Go 仍然无法完全取代 C 的地位。比如一些高性能 RPC 框架、Protobuf 序列化库,底层仍然依赖 C 实现。未来如果 Go 在与 C 的互操作性上进一步提升,可能会让其在后端开发中更加"全能"。

Hello world
来认识一下go语言程序的一个基本轮廓。上面我们已经配置好了GOPATH,GOPATH就是Go项目代码存放的位置,对于Ubuntu系统,默认使用Home/go目录作为GOPATH。这个是我们自己定义的目录,就好比是其他IDE的Workspace。
在GOPATH下新建go文件夹,然后在/home/go目录里新建bin / src / pkg三个文件夹。
shell
cd /home
mkdir go
cd /home/go
mkdir bin
mkdir src
mkdir pkg
GO代码必须在工作空间内。工作空间是一个目录,其中包含三个子目录:
- src ... 存放你自己的 Go 源码项目、第三方库的源码等,里面每一个子目录,就是一个包,包内是Go的源码文件。按照包路径组织,比如:
src/github.com/user/project。如果你 import 了某个第三方库,运行go get后,它的源码也会被下载到这里。- pkg... 存放编译后的中间文件(.a 静态库),是 Go 包编译后形成的静态链接包,加快后续编译速度,类似于 C 的
.o/.a文件,这有助于 Go 快速构建大项目而不用重复编译所有依赖。。会按平台和架构分类,比如:pkg/linux_amd64/github.com/user/lib.a- bin ... 存放通过
go install编译生成的可执行程序(如命令行工具)。执行go install main.go后会在这里生成bin/app_name,如果你把$GOPATH/bin加入 PATH 环境变量,就能在任何位置直接运行你安装的工具。
如果你已经切换到Go Modules 模式(Go 1.16+ 默认),这些目录就不再是必须的了,Go 会将依赖管理和编译缓存迁移到go.mod、go.sum和$GOPATH/pkg/mod等新位置。但理解这三个目录仍然有助于你深入理解 Go 的编译和运行机制。

我们在src下创建一个GolangStudy文件夹作为我们的学习项目,先创造第一个案例,再创建一个1-firstGolang文件夹,在该文件夹下新建hello.go
go
package main //程序的包名,声明当前文件所属的包。必须是 main 包,才能构建为可执行程序。
/*
Go 每个 .go 文件都必须归属于某个包(package)。
main 是 Go 中的特殊包名,表示该文件是程序的"入口"所在。
只有在 package 是 main 且包含 main() 函数时,这个文件才会被编译为可执行程序。
如果是其他包名(如 utils、math 等),说明它是库文件,不能单独运行。
*/
/*
import "fmt"
import "time"
*/
// 下面的导入方式与上面等价,导入多个包时可以下面这样写
import (
"fmt" // 引入 fmt 包,用于格式化输出(如 Println)
"time" // 引入 time 包,用于时间相关操作
)
// main函数,程序入口函数,必须命名为 main,且无参数、无返回值
func main() { //注意! Go 语言中函数体的的{ 一定是 和函数名在同一行的,否则编译错误
// golang中的表达式,加";", 和不加 都可以,建议是不加
fmt.Println(" hello Go!") // 使用 fmt.Println 打印一行文本到控制台,并在最后自动增加换行字符 \n
// 使用 fmt.Print("hello, world\n") 可以得到相同的结果。
//Print 和 Println 这两个函数也支持使用变量,如:fmt.Println(arr)。如果没有特别指定,它们会以默认的打印格式将变量 arr 输出到控制台。
// 暂停程序执行 1 秒钟
time.Sleep(1 * time.Second)
}
/*
补充说明:
1. Go 语言中每行语句后面可以加分号 `;`,但通常不需要,Go 编译器会自动处理行尾。
为了代码风格统一、清爽,建议省略分号。
2. fmt 是"format"的缩写,常用于打印、格式化字符串、读写输入输出等。
3. time.Sleep 是阻塞函数,常用于调试或控制程序运行节奏。
*/
终端运行:
shell
$ go run hello.go
hello Go!
$
go run 表示 直接编译go语言并执行应用程序,一步完成。
你也可以先编译,然后再执行
shell
$go build hello.go
$./hello
hello Go!
在windows中则是生成hello.exe,然后终端输入hello.exe或者运行即可执行看到结果。
GOPROXY
Go1.14版本之后,都推荐使用go mod模式来管理依赖了,也不再强制我们把代码必须写在GOPATH下面的src目录了,你可以在你电脑的任意位置编写go代码。
默认GoPROXY配置是:GOPROXY=https://proxy.golang.org,direct,
由于国内访问不到 https://proxy.golang.org 所以我们需要换一个PROXY,这里推荐使用https://goproxy.io 或 https://goproxy.cn。
可以执行下面的命令修改GOPROXY:
shell
`go env -w GOPROXY=https://goproxy.cn,direct`
Golang语法入门
变量的声明
go
package main
/*
我们来学习一下四种变量的声明方式
*/
import (
"fmt"
)
// 声明全局变量 方法一、方法二、方法三是可以的
var gA int
var gB = 100
var gC = 200
//方法四则不能用来声明全局变量
// := 只能够用在 函数体内来声明
//gD := 200 会报错
func main() {
//方法一:声明一个变量 关键字var+变量名称+变量类型 默认的值是0
var a int
fmt.Println("a = ", a) // a = 0
fmt.Printf("type of a = %T\n", a) // type of a = int
//方法二:声明一个变量,初始化一个值
var b = 100
fmt.Println("b = ", b) // b = 100
fmt.Printf("type of b = %T\n", b) // type of b = int
var bb = "abcd"
fmt.Printf("bb = %s, type of bb = %T\n", bb, bb) // bb = abcd, type of bb = string
//方法三:在初始化的时候,可以省去数据类型,通过值自动匹配当前的变量的数据类型
var c = 100
fmt.Println("c = ", c) // c = 100
fmt.Printf("type of c = %T\n", c) // type of c = int
var cc = "abcd"
fmt.Printf("cc = %s, type of cc = %T\n", cc, cc) // cc = abcd, type of cc = string
//方法四:(常用的方法) 省去var关键字,直接自动匹配
e := 100
fmt.Println("e = ", e) // e = 100
fmt.Printf("type of e = %T\n", e) // type of e = int
f := "abcd"
fmt.Println("f = ", f) // f = abcd
fmt.Printf("type of f = %T\n", f) // type of f = string
g := 3.14
fmt.Println("g = ", g) // g = 3.14
fmt.Printf("type of g = %T\n", g) // type of g = float64
// =====
fmt.Println("gA = ", gA, ", gB = ", gB, "gC = ", gC) // gA = 0 , gB = 100 gC = 200
//fmt.Println("gD = ", gD)
// 声明多个变量-相同类型
//var xx, yy int = 100, 200 // 可以直接下面这样写:
var xx, yy = 100, 200
fmt.Println("xx = ", xx, ", yy = ", yy) // xx = 100 , yy = 200
// 声明多个变量-不同类型
var kk, ll = 100, "Aceld"
fmt.Println("kk = ", kk, ", ll = ", ll) // kk = 100 , ll = Aceld
//多行的多变量声明
//var (
// vv int = 100
// jj bool = true
//) // 可以直接下面这样写:
var (
vv = 100
jj = true
)
fmt.Println("vv = ", vv, ", jj = ", jj) // vv = 100 , jj = true
}
其中
fmt.Println和fmt.Printf都是用于往标准输出写内容但
fmt.Println不需要也不支持格式化占位符,它会把传入的多个参数用空格分隔并输出,最后自动添加一个换行符。fmt.Printf需要第一个参数是格式化字符串(含 % 占位符),后面跟对应的值,通过这些占位符来指定输出格式。它不会自动加换行,需要在格式字符串里显式写\n,参数之间不会自动插入空格,所有间隔都由格式字符串决定。这里在 Go 的 fmt 包中,
%T是一个格式动词(format verb),用于输出变量或值的 类型(Type)
基本数据类型
| 中文名称 | Go 类型 | 大小 | 默认值 | 分类 | 备注 |
|---|---|---|---|---|---|
| 布尔类型 | bool |
1 byte | false |
布尔 | 只能是 true 或 false。在条件判断中直接使用即可,无需与 == true/false 比较。 |
| 字符串 | string |
--- | "" |
引用类型 | 不可变(immutable),底层是一个指向字节数组的只读切片。可用 len() 获取字节长度,用 for range 按 Unicode 码点遍历。记得区分字节长度和字符(rune)长度。 |
| 有符号整型 | int |
32 或 64bit | 0 |
整数(平台依赖) | 根据平台不同(32/64 位)决定,推荐在不要求精确位宽时使用。与 uintptr 交互时要小心。 |
| 有符号整型 | int8 |
1 byte | 0 |
整数(定宽) | 范围:-128 ~ 127。通常用于占用精准字节的场景。 |
| 有符号整型 | int16 |
2 bytes | 0 |
整数(定宽) | 范围:-32768 ~ 32767。 |
| 有符号整型 | int32 |
4 bytes | 0 |
整数(定宽) | 范围:-2³¹ ~ 2³¹-1。是 rune 的底层类型,用于表示 Unicode 码点。 |
| 有符号整型 | int64 |
8 bytes | 0 |
整数(定宽) | 范围:-2⁶³ ~ 2⁶³-1。大整数运算时使用。 |
| 无符号整型 | uint |
32 或 64bit | 0 |
无符号整数 | 根据平台不同(32/64 位)决定,和 int 一样;不能表示负数。与 int 相互转换时要注意溢出和类型转换。 |
| 无符号整型 | uint8 |
1 byte | 0 |
无符号整数 | 范围:0 ~ 255。别名 byte,常用于处理原始二进制或字节流。 |
| 无符号整型 | uint16 |
2 bytes | 0 |
无符号整数 | 范围:0 ~ 65535。 |
| 无符号整型 | uint32 |
4 bytes | 0 |
无符号整数 | 范围:0 ~ 2³²-1。 |
| 无符号整型 | uint64 |
8 bytes | 0 |
无符号整数 | 范围:0 ~ 2⁶⁴-1。 |
| 指针大小类型 | uintptr |
32 或 64bit | 0 |
整数/系统类型 | 根据平台不同(32/64 位)决定,和 int/uint 一样;用于存储指针的整数表示,通常用于底层系统编程。不要做算术运算或存储垃圾值,否则会导致不可预期的行为。 |
| 浮点数 | float32 |
4 bytes | 0.0 |
浮点 | 单精度 IEEE-754,约 6-7 位十进制有效数字。 |
| 浮点数 | float64 |
8 bytes | 0.0 |
浮点 | 双精度 IEEE-754,约 15-16 位十进制有效数字。推荐默认使用 float64。 |
| 复数类型 | complex64 |
8 bytes | (0+0i) |
复数 | 实部和虚部分别是 float32。 |
| 复数类型 | complex128 |
16 bytes | (0+0i) |
复数 | 实部和虚部分别是 float64。推荐默认使用 complex128。 |
| Unicode 码点 | rune (alias) |
4 bytes | 0 |
别名/整数 | 别名 int32,用于表示一个 Unicode 码点。与 byte 一起用于处理 UTF-8 编码。 |
| 字节 | byte (alias) |
1 byte | 0 |
别名/无符号整数 | 别名 uint8,用于表示原始数据或 ASCII 字符。 |
常见误区 & 使用注意(补充)
-
int与固定宽度整型混用在跨平台(32/64 位)项目中混用
int和int32/int64会导致编译时或运行时的类型不匹配,需要频繁转换,也可能引发意外溢出。 -
字符串长度 vs Unicode 字符数
len(s)返回的是字节数,不是字符数,含中文或 emoji 时,字符数会小于字节数。可用utf8.RuneCountInString(s)或[]rune(s)来获取实际字符数。 -
字符串索引与切片
直接用下标或切片操作字符串会按字节拆分,可能截断 Unicode 字符。要按码点处理时,先转换为
[]rune再操作。 -
浮点数比较
浮点类型存在精度误差,不要用
==判断相等,应比较差值绝对值是否小于某个 ε(如1e-9)。 -
byte/rune转换直接把
rune转为byte会丢失高位信息,切勿盲目强转;同理byte转rune在非 ASCII 范围也需注意。 -
指针算术
Go 不支持指针直接算术运算,也不保证
uintptr转回*T后安全。除非做底层交互(如unsafe包),否则不要使用uintptr。 -
默认零值陷阱
Go 的零值(
0、false、""、nil)在声明变量时就已初始化,避免使用new或手动赋初值来覆盖零值,除非有特殊需求。 -
复数性能
复数类型运算比实数慢很多,只有在真正需要复数运算(如 FFT)时才使用。
-
类型别名 vs 新类型
type MyInt int会创建新类型,需要显式转换;而byte和rune是内建别名,不需转换。 -
位运算与符号扩展
对负数做位移 (
>>) 会保留符号位。如果需要无符号右移,可先转换为对应的uint类型。 -
内存对齐
结构体中字段顺序影响对齐和整体大小,合理安排字段可以减少内存占用;不同类型默认对齐边界不同(如
int648 字节对齐)。 -
JSON 编解码的数字类型
Go 标准库的
encoding/json会默认将所有数字解码为float64,在处理大整数时可能丢失精度,需要使用UseNumber或自定义类型。
常量
go
package main
import "fmt"
// 可以通过 const 来定义枚举类型
const (
//可以在const() 添加一个关键字 iota, 每行的iota都会累加1, 第一行的iota的默认值是0
BEIJING = 10 * iota //iota = 0
SHANGHAI //iota = 1 SHANGHAI = 10
SHENZHEN //iota = 2 SHENZHEN = 20
)
const (
//每个 const 块的 iota 都是从 0 开始,后面的常量也不需要手动定义,可以用来生成一组连续的整型常量。
IOTATEST0 = iota //iota = 0 IOTATEST0 = 0
IOTATEST1 //iota = 1 IOTATEST1 = 1
IOTATEST2 //iota = 2 IOTATEST2 = 2
)
const (
// 在一个 const 块中,iota 会在每一行自动递增(即使是多重赋值也算一行)。
// 如果后续的行省略了赋值表达式,Go 会默认使用上一行的表达式模式,并将当前行的 iota 值带入。
a, b = iota + 1, iota + 2 // iota = 0, a = iota + 1, b = iota + 2, a = 1, b = 2
c, d // iota = 1, c = iota + 1, d = iota + 2, c = 2, d = 3
e, f // iota = 2, e = iota + 1, f = iota + 2, e = 3, f = 4
// 中间改变赋值表达式:
g, h = iota * 2, iota * 3 // iota = 3, g = iota * 2, h = iota * 3, g = 6, h = 9
i, k // iota = 4, i = iota * 2, k = iota * 3 , i = 8, k = 12
)
func main() {
//常量(只读属性) var关键字改成const即可
const length int = 10
fmt.Println("length = ", length) // length = 10
//length = 100 //常量是不允许修改的,这里会直接报错。
fmt.Println("BEIJIGN = ", BEIJING) // BEIJIGN = 0
fmt.Println("SHANGHAI = ", SHANGHAI) // SHANGHAI = 10
fmt.Println("SHENZHEN = ", SHENZHEN) // SHENZHEN = 20
fmt.Println("IOTATEST0 = ", IOTATEST0) // IOTATEST0 = 0
fmt.Println("IOTATEST1 = ", IOTATEST1) // IOTATEST1 = 1
fmt.Println("IOTATEST2 = ", IOTATEST2) // IOTATEST2 = 2
fmt.Println("a = ", a, "b = ", b) // a = 1 b = 2
fmt.Println("c = ", c, "d = ", d) // c = 2 d = 3
fmt.Println("e = ", e, "f = ", f) // e = 3 f = 4
fmt.Println("g = ", g, "h = ", h) // g = 6 h = 9
fmt.Println("i = ", i, "k = ", k) // i = 8 k = 12
// iota 只能够配合const() 一起使用, iota只有在const进行累加效果。
//var a int = iota
//fmt.Println(a) // 报错
}
函数
函数的基本写法如下示例:
go
package main
import "fmt"
// Go 中函数使用关键字 `func` 定义,后跟函数名和参数列表。
// 参数的类型写在参数名之后,返回值类型写在最后。
func foo1(a string, b int) int {
fmt.Println("---- foo1 ----")
fmt.Println("a = ", a)
fmt.Println("b = ", b)
c := 100
return c
}
// 返回多个返回值,且返回值匿名的
func foo2(a string, b int) (int, int) {
fmt.Println("---- foo2 ----")
fmt.Println("a = ", a)
fmt.Println("b = ", b)
return 666, 777
}
// 返回多个返回值, 且返回值有形参名称的
func foo3(a string, b int) (r1 int, r2 int) {
fmt.Println("---- foo3 ----")
fmt.Println("a = ", a)
fmt.Println("b = ", b)
//r1 r2 属于foo3的形参,就像a和b是调用函数的时候将值传递进来的
//所以r1 r2在没有赋值之前初始化默认的值是0
//r1 r2 的作用域空间是foo3 整个函数体的{}空间
fmt.Println("r1 = ", r1) // r1 = 0
fmt.Println("r2 = ", r2) // r2 = 0
//给有名称的返回值变量赋值
r1 = 1000
r2 = 2000
return
//使用命名返回值,可以直接 return,不需要显式指定返回值,
//当然也可以用显式方式返回:return 1000, 2000
}
// 如果多个参数或返回值的类型相同,可以将类型合并在一起写
func foo4(a string, b int) (r1, r2 int) {
fmt.Println("---- foo4 ----")
fmt.Println("a = ", a)
fmt.Println("b = ", b)
//给有名称的返回值变量赋值
r1 = 1000
r2 = 2000
return
}
// 测试字符串类型
func foo5(a string, b int) (r1, r2 string) {
fmt.Println("---- foo5 ----")
fmt.Println("a = ", a)
fmt.Println("b = ", b)
// 字符串类型的零值是空字符串 ""
fmt.Println("r1 = ", r1) // r1 =
fmt.Println("r2 = ", r2) // r2 =
return "hello", "world"
}
func main() {
c := foo1("abc", 555)
fmt.Println("c = ", c)
//---- foo1 ----
//a = abc
//b = 555
//c = 100
ret1, ret2 := foo2("haha", 999)
fmt.Println("ret1 = ", ret1, " ret2 = ", ret2)
//---- foo2 ----
//a = haha
//b = 999
//ret1 = 666 ret2 = 777
ret1, ret2 = foo3("foo3", 333)
fmt.Println("ret1 = ", ret1, " ret2 = ", ret2)
//---- foo3 ----
//a = foo3
//b = 333
//r1 = 0
//r2 = 0
//ret1 = 1000 ret2 = 2000
ret1, ret2 = foo4("foo4", 444)
fmt.Println("ret1 = ", ret1, " ret2 = ", ret2)
//---- foo4 ----
//a = foo4
//b = 444
//ret1 = 1000 ret2 = 2000
str1, str2 := foo5("foo5", 555)
fmt.Println("str1 = ", str1, " str2 = ", str2)
//---- foo5 ----
//a = foo5
//b = 555
//r1 =
// r2 =
//str1 = hello str2 = world
}
init函数与import
在 Go 语言中,有两个特殊的保留函数:init 和 main。
-
main()函数只能出现在 package main 中,是程序的入口函数。 -
init()函数可以出现在任意 package(包括main) 中,且一个包中可以定义多个 init 函数,包括在同一个文件中也可以有多个。
init 和 main 函数的特点:
- 它们的签名写法固定:不能有参数,也不能有返回值。
- Go 程序在运行时会自动调用
init和main函数,无需手动调用。 init函数是可选的 ,而main函数在 main 包中是必需的。
虽然 init 可以出现多次,但为了代码清晰、维护简单,推荐每个文件最多只写一个 init 函数。
Go 在程序执行前,会按照一定顺序初始化各个包。这个顺序如下:
- 1.从
main包开始递归导入所依赖的包。 - 2.对每个包,执行以下操作:
-
- 1)先导入它依赖的其他包(如果有);
-
- 2)初始化该包的包级变量 和常量;
-
- 3)执行该包中的
init()函数(如果存在)。
- 3)执行该包中的
- 3.当所有依赖包都初始化完成后,再对 main 包执行相同的过程:
-
- 1)初始化常量和变量;
-
- 2)执行 main 包中的 init();
-
- 3)最后执行 main() 函数作为程序入口。
注意: 无论一个包被导入多少次,实际只会初始化并执行一次(如 fmt 包经常被多个包使用,但只会加载一次)。
下图详细地解释了整个执行过程:

我们看一个例子,代码结构如下,之前我们写的都是main包,我们创造两个自己定义的包lib1和lib2,一般一个包都会有个单独的文件夹。

Lib1.go:
go
package lib1
import "fmt"
//当前lib1包提供的API
func Lib1Test() {
fmt.Println("lib1Test()...")
}
func init() {
fmt.Println("lib1. init() ...")
}
Lib2.go:
go
package lib2
import "fmt"
//当前lib2包提供的API
func Lib2Test() {
fmt.Println("lib2Test()...")
}
func init() {
fmt.Println("lib2. init() ...")
}
这里注意: 在 Go 语言中,函数名的首字母大小写是非常关键的,它决定了函数的访问权限(可见性)。
大写开头的函数名 可以被其他包调用(导出)。
小写开头的函数名 只能在定义它的包内部使用。这个规则同样适用于变量、常量、类型、结构体字段。
main.go:
go
package main
import (
"fmt"
// 1.不起别名的导包写法 默认将该包的访问标识符设为 lib1(取路径最后一段)
// 使用方式:lib1.Lib1Test()
"GolangStudy/5-init/lib1"
// 2.给包起别名,使用 mylib1.Lib1Test() 来访问
// mylib1 "GolangStudy/5-init/lib1"
// 3.点导入,直接使用包里的标识符(不加前缀)
// 使用Lib2Test()直接访问(不推荐) 可读性差、易冲突、不推荐滥用
//. "GolangStudy/5-init/lib2"
// 4.空白导入,仅触发 init 函数,但是无法使用包内容,常用于注册、驱动加载等场景
// go编译器较严谨,如果导包但不用任何包内接口会编译错误,所以还是有使用场景的
//_ "GolangStudy/5-init/lib2"
mylib2 "GolangStudy/5-init/lib2"
//. "GolangStudy/5-init/lib2"
)
// main包也可以有init函数
func init() {
fmt.Println("main. init() ...")
}
func main() {
// 第一种导包法
lib1.Lib1Test()
//第一种导包法: lib2.Lib2Test()
//第二种 起别名导法:
mylib2.Lib2Test()
//第三种 点导入导法:
//Lib2Test()
}
这里需要说明的是,import默认会去
GOROOT的src包下和GOPATH的src包下去找导入的包,我们这里是因为我们的工程文件创建在GOPATH的src包下,所以能import到。如果你只是想临时测试而不想搬到 GOPATH,也可以:所有文件(main.go、lib1.go、lib2.go)放在同一个目录下,删掉 import "lib1" 这些包路径引用,直接在 main.go 里调用这些函数,不过这就失去了包管理的练习意义,不推荐长期使用。
后面我们会学习使用Go Modules,通过go.mod 文件来管理依赖,就可以在任意位置编写go代码了。
运行main的结果:
GOROOT=E:\Go\Go1.24.3 #gosetup
GOPATH=C:\Users\87936\go #gosetup
E:\Go\Go1.24.3\bin\go.exe build -o C:\Users\87936\AppData\Local\JetBrains\GoLand2025.1\tmp\GoLand\___go_build_GolangStudy_5_init.exe GolangStudy/5-init #gosetup
C:\Users\87936\AppData\Local\JetBrains\GoLand2025.1\tmp\GoLand\___go_build_GolangStudy_5_init.exe #gosetup
lib1. init() ...
lib2. init() ...
main. init() ...
lib1Test()...
lib2Test()...
进程 已完成,退出代码为 0
可以发现输出的顺序与我们上面图给出的顺序是一致的。
那我们现在就改动一个地方,lib1包导入lib2,main包不管。再运行就发现main包以及lib1包都导入了lib2,但是只出现一次,并且最先输出,
说明如果一个包会被多个包同时导入,那么它只会被导入一次,而先输出lib2是因为main包中导入lib1时,lib1又导入了lib2,会首先初始化lib2包的东西。
值传递与引用传递;指针
如果之前学过c/c++,这节可以不看,这节简单讲一下指针但不会太深入,因为在实际开发中go语言中使用指针的场景并不是太多,因为它也有引用传递的类型。
值传递与引用传递
函数如果使用参数,该变量可称为函数的形参。形参就像定义在函数体内的局部变量。调用函数,可以通过两种方式来传递参数:值传递与引用传递。
值传递是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。默认情况下,Go 语言使用的是值传递,即在调用过程中不会影响到实际参数。
比如如下代码所示,执行之后a和b的值并没有交换成功,执行swap前后的输出是一致的,就是因为Go默认使用了值传递。
go
package main
import "fmt"
func main() {
/* 定义局部变量 */
var a int = 100
var b int = 200
fmt.Printf("交换前 a 的值为 : %d\n", a )
fmt.Printf("交换前 b 的值为 : %d\n", b )
/* 通过调用函数来交换值 */
swap(a, b)
fmt.Printf("交换后 a 的值 : %d\n", a )
fmt.Printf("交换后 b 的值 : %d\n", b )
}
/* 定义相互交换值的函数 */
func swap(x, y int) int {
var temp int
temp = x /* 保存 x 的值 */
x = y /* 将 y 值赋给 x */
y = temp /* 将 temp 值赋给 y*/
return temp;
}
Go 语言中指针是很容易学习的,Go 语言中使用指针可以更简单的执行一些任务。接下来让我们来一步步学习 Go 语言指针。我们都知道,变量是一种使用方便的占位符,用于引用计算机内存地址。
Go 语言的取地址符是 &,放到一个变量前使用就会返回相应变量的内存地址。而*用于"解引用":从地址中取出真实值。二者配合使用,理解 Go 的指针机制就非常清晰了。
引用传递是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。引用传递指针参数传递到函数内,以下是交换函数 swap() 使用了引用传递:
go
package main
import "fmt"
/*
func swap(a int ,b int) {
var temp int
temp = a
a = b
b = temp
}
*/
func swap(pa *int, pb *int) {
var temp int
temp = *pa //temp = main::a
*pa = *pb // main::a = main::b
*pb = temp // main::b = temp
}
func main() {
var a int = 10
var b int = 20
fmt.Println("a = ", a, " b = ", b)
swap(&a, &b)
fmt.Println("a = ", a, " b = ", b)
var p *int
p = &a
fmt.Println(&a)
fmt.Println(p)
var pp **int //二级指针
pp = &p
fmt.Println(&p)
fmt.Println(pp)
}
运行结果:
a = 10 b = 20
a = 20 b = 10
0xc00000a0e8
0xc00000a0e8
0xc000072070
0xc000072070
但其实本质上,
Go 语言中只有值传递------ 无论是传递普通变量,还是传递指针,本质上都是把"值的副本"传给函数。
- 当传递
普通变量时,传递的是变量值的副本,因此函数内部修改变量不会影响到外部变量;- 当传递
指针时,传递的是指针的副本(即内存地址的副本),虽然指针本身是副本,但因为指向的是同一块内存地址,所以通过指针可以修改原始数据的内容。因此,虽然使用指针可以间接修改外部变量,看起来像"引用传递",但其实仍然是值传递 ------ 只是传递的是指针这个值而已。Go 语言本身并不存在像 C++ 那样的真正"引用传递"语法机制。
defer与延迟函数
defer 语句用于延迟函数的执行,直到外围函数(当前函数)返回之前才执行。常用于释放占用的资源;捕捉处理异常;输出日志。有点类似于c++的析构函数或者java里的try-catch的finally。更具体的执行流程则是这样的:
- 执行 return 表达式(如果有)并求出返回值
- 执行所有 defer 语句(按后进先出顺序)
- 返回到调用者
即defer 是在 return 的值计算之后、函数真正返回前 执行的。
一个基本示例:
go
package main
import "fmt"
func main() {
fmt.Println("Start")
defer fmt.Println("Deferred") // 延迟执行 在defer所在函数体结束之前才执行
fmt.Println("End")
}
/*
Start
End
Deferred
*/
如果一个函数中有多个defer语句,它们会以栈(LIFO-后进先出)的顺序执行。
go
func Demo(){
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
defer fmt.Println("4")
}
func main() {
Demo()
}
/*
4
3
2
1
*/
panic 与 recover:异常处理机制
我们在介绍Go特性时说过,Go 没有传统的 try-catch 结构,内建函数 panic 和 recover 实现异常处理机制。。
panic用于主动触发运行时错误 。它会立即中断当前函数的执行 ,并沿调用栈向上传播 ,依次执行每一层函数的defer,直到被recover捕获或程序崩溃。;recover用于捕获 panic 并恢复程序的正常执行 。只能在 defer 的函数中生效,否则返回 nil。
注意事项:
recover只能在 defer 的函数中有效;- 如果没有发生 panic,
recover()返回nil -
nil是Go 中的零值(zero value)之一,用于表示指针、接口、切片、映射、通道、函数等类型的"空"或"无值"状态,类似于 Java 中引用类型的 null);
- 如果
panic没被recover捕获,它会一直向上传播,最终导致程序崩溃。 - 被
panic中断的函数不会"正常返回",它们直接跳转到执行defer的流程。
示例:安全地调用可能 panic 的函数
以下示例展示如何封装一个"安全调用"的函数 safeCall(),使得即使发生 panic,程序仍能继续运行:
go
package main
import "fmt"
// safeCall 是一个"安全调用"的封装函数,
// 它内部通过 defer + recover 捕获 panic,从而避免程序崩溃。
func safeCall() {
// defer 延迟执行的函数 ------ 程序退出 safeCall 之前,会先执行这个匿名函数
defer func() {
// recover 用于捕获 panic,如果没有 panic,r 会是 nil
// 条件语句:如果 recover() 捕获到了 panic(即不为 nil),就进入 if 体
if r := recover(); r != nil {
// 打印出 panic 的信息,程序不会因此崩溃
fmt.Println("Recovered from panic:", r)
}
}()
fmt.Println("Calling risky function...")
// 调用一个可能会触发 panic 的函数
risky() // 会触发 panic
// 如果没有 panic,程序会执行到这里
// 但如果 risky() 触发了 panic,recover 没有捕获前,这一行不会执行
// 一旦 panic 被触发,当前函数会立刻中断,不再继续执行后面的语句,所以下面的语句不会被执行了
fmt.Println("This line will not be executed if panic not recovered")
}
// risky 是一个可能"有风险"的函数,它会直接触发 panic
func risky() {
// panic 会让程序立即中断,并开始向上层调用链传递异常
panic("Something went wrong!")
}
func main() {
// 演示:调用一个封装好的 safeCall,程序不会因 panic 崩溃
safeCall()
fmt.Println("Program continues after recover")
}
输出:
go
Calling risky function...
Recovered from panic: Something went wrong!
Program continues after recover
执行过程如下:
1.main() 调用 safeCall()。
2.safeCall() 注册了一个 defer 的匿名函数,这个 defer 会在函数返回前(包括被 panic 中断时)执行。
3.调用 risky() 后,触发 panic。
4.程序立即中断 risky() 和 safeCall() 的正常流程,并执行 safeCall() 中的 defer。
5.recover() 捕获到 panic 信息,打印出来。
6.safeCall() 的 defer 完成后,函数直接结束,控制流返回 main()。
7.main() 中剩下的代码继续执行。
这个例子中,如果不加 recover,程序将在 risky() 调用时终止。
还有一点要注意的是,为什么 safeCall 的后续代码不会执行,而 main 的会?这是理解 panic/recover 的核心所在。
虽然我们之前介绍defer时说其是在 return 的值计算之后、函数真正返回前执行的,但这里有个容易被误解的点是:当 panic 触发时,当前函数(例如 safeCall())立即中止执行 ,不会继续执行其余语句。但它的 defer 块仍会被执行。在 defer 中使用 recover() 可以捕获 panic 并"拦截异常向上传播",从而阻止程序崩溃。
一旦 recover 成功,panic 就"被处理掉了",接下来的函数(例如 main())就能继续执行。因此,虽然 safeCall()\ 内的 fmt.Println("This line...") 没有机会执行,main() 并未受到影响。
而我们一开始要的效果就是在main中调用safeCall()后程序不会崩溃,并能继续向下执行(比如执行 main 函数中的下一句),如果你想在 safeCall() 中也继续执行后续代码怎么办?
答案是:将 panic 的代码封装到一个独立的匿名函数中调用,这样 panic 的影响就被"隔离"在这个匿名函数作用域里。
go
func safeCall() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r)
}
}()
fmt.Println("Calling risky function...")
// 将 risky 的调用封装在一个匿名函数中
func() {
risky() // panic 发生在这里
}()
// 因为 panic 被匿名函数的 defer 捕获,这里仍会被执行
fmt.Println("This line WILL be executed if panic is recovered")
}
/*
Calling risky function...
Recovered from panic: Something went wrong!
This line WILL be executed if panic is recovered
*/
小结:panic 和 recover 的行为要点
panic不是函数的正常返回路径,它会立即 中断执行并进入 defer。
recover()必须在 defer 中调用,才能有效捕获 panic。将"有风险"的逻辑放入单独函数,有助于隔离 panic 的影响。
被 panic 中断的函数不会继续执行当前语句,但不会影响上层函数继续运行(前提是 panic 被 recover 捕获)。
实际应用场景
- 编写通用的防崩溃组件,比如 HTTP 服务的请求处理器。
- 用于日志记录、故障恢复而不中断整个服务。
- 在 goroutine 中避免 panic 崩溃整个程序。
slice与map
数组
那咱们接下来呢就开始介绍一下有关go中的这个slice,slice它实际上中文翻译叫切片,是一种动态数组的类型。
切片(slice)是 Go 中实现"动态数组"的官方方式,而"动态数组"只是对切片功能的通俗描述,不是 Go 的语法概念。
Go 数组的长度不可改变,在特定场景中这样的集合就不太适用,Go中提供了一种灵活,功能强悍的内置类型切片("动态数组"),与数组相比切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。
那么首先来看一下Go中的数组:
go
package main
import "fmt"
// Go 中数组是值类型,函数参数中必须显式指定数组的长度和类型
// 比如 [4]int 和 [5]int 是不同的类型,不能混用
// 因此,如果函数参数是 [4]int 类型,只能传入长度为 4 的数组,否则编译错误。
// 下面定义一个函数,接收一个长度为 4 的 int 数组作为参数
// 注意:这是按值传递(值拷贝),函数内部修改不会影响原数组
func printArray(myArray [4]int) {
// 使用 range 遍历数组,index 表示索引,value 表示对应的值
for index, value := range myArray {
fmt.Println("index = ", index, ", value = ", value)
}
// 修改数组的第一个元素
// 这不会影响外部的 myArray3,因为传参是值拷贝
myArray[0] = 111
}
func main() {
// 在 Go 中,数组是具有固定长度的同质(相同类型)数据集合。
// 声明一个长度为 10 的 int 数组,数组中的每个元素默认初始化为 0
var myArray1 [10]int
// 声明并初始化一个长度为 10 的数组,仅设置了前 4 个元素
// 剩余的元素会自动填充为 int 的零值(即 0)
myArray2 := [10]int{1, 2, 3, 4}
// 完整初始化一个长度为 4 的数组
myArray3 := [4]int{11, 22, 33, 44}
//for i := 0; i < 10; i++ {
// 使用传统 for 循环遍历 myArray1 len() 获取数组长度
for i := 0; i < len(myArray1); i++ {
fmt.Println(myArray1[i]) // 默认值都是0
}
// 使用 range 遍历 myArray2 range是Go的一个关键字 会根据你遍历的不同集合返回不同的值
// 如果是遍历这种数组或者切片这种动态数组类型 range会返回两个值 第一值是当前元素下标 第二个是元素值本身
for index, value := range myArray2 {
fmt.Println("index = ", index, ", value = ", value)
}
//查看数组的数据类型 数组的长度是类型的一部分 [10]int 和 [4]int 是不同的类型
fmt.Printf("myArray1 types = %T\n", myArray1) // myArray1 types = [10]int
fmt.Printf("myArray2 types = %T\n", myArray2) // myArray2 types = [10]int
fmt.Printf("myArray3 types = %T\n", myArray3) // myArray3 types = [4]int
// 调用定义的函数打印 myArray3,注意这是"值传递",不会修改原数组
printArray(myArray3)
fmt.Println(" ------ ")
// 再次打印 myArray3,观察是否发生变化
// 因为 printArray 中只是值拷贝,原数组没有被修改
for index, value := range myArray3 {
fmt.Println("index = ", index, ", value = ", value)
}
}
注意这里Go 中数组的"长度"是类型的一部分,必须匹配才能传参。
而切片是对数组的抽象,长度不固定,函数参数中最常用,如下代码所示。
go
// 使用 切片(slice) ------ 推荐做法
package main
import "fmt"
// 接收一个切片参数,长度不限
func printSlice(s []int) {
for index, value := range s {
fmt.Println("index =", index, ", value =", value)
}
// 修改切片内容,会影响原始底层数组(如果是引用传入的)
s[0] = 999
}
func main() {
// 声明并初始化一个数组
arr := [5]int{1, 2, 3, 4, 5}
// 将数组转换成切片传入
printSlice(arr[:]) // arr[:] 表示取整个数组作为切片传入
// 打印数组,查看是否被修改
fmt.Println("After printSlice, arr =", arr)
}
/*
index = 0 , value = 1
index = 1 , value = 2
index = 2 , value = 3
index = 3 , value = 4
index = 4 , value = 5
After printSlice, arr = [999 2 3 4 5]
*/
还可以使用 [N]int 的数组指针,这种方式可以修改固定长度数组的值,但是数组长度仍需指定,如下所示:
go
// 使用 数组指针 [N]int 的指针
package main
import "fmt"
// 指针传参可以修改原数组,但数组长度必须匹配。
// 接收一个指向长度为 4 的数组的指针
func printArrayPointer(p *[4]int) {
for index, value := range p {
fmt.Println("index =", index, ", value =", value)
}
// 通过指针修改数组内容
p[0] = 888
}
func main() {
arr := [4]int{10, 20, 30, 40}
// 传入数组的指针
printArrayPointer(&arr)
// 打印原数组,查看是否被修改
fmt.Println("After printArrayPointer, arr =", arr)
}
/*
index = 0 , value = 10
index = 1 , value = 20
index = 2 , value = 30
index = 3 , value = 40
After printArrayPointer, arr = [888 20 30 40]
*/
切片slice
下面详细学习一下Go里的切片-slice。
切片(slice)是 Go 中实现"动态数组"的官方方式,而"动态数组"只是对切片功能的通俗描述,不是 Go 的语法概念。
go
package main
import "fmt"
// 接收一个 int 类型的切片参数
// 注意:切片是引用类型(引用传递),对它的修改会影响原切片
func printArray(myArray []int) {
// 使用 range 遍历切片
// _ 表示匿名的变量 忽略 index(索引),只关注 value(值)
for _, value := range myArray {
fmt.Println("value = ", value)
}
// 修改切片的第一个元素
// 因为切片是引用类型,这里修改会影响 main 函数中的原切片
myArray[0] = 100
}
func main() {
// 定义一个切片(slice),即动态数组,长度可变
myArray := []int{1, 2, 3, 4}
// 打印切片的类型,可以看到是 []int,而不是 [4]int(数组)
fmt.Printf("myArray type is %T\n", myArray)
// 调用定义的函数,切片作为参数被引用传递
printArray(myArray)
fmt.Println(" ==== ")
// 再次遍历切片,验证切片被函数内部修改了
for _, value := range myArray {
fmt.Println("value = ", value)
}
}
其实这里写作引用传递是会让新手有歧义的,因为其实在 Go 中,切片(slice)本身仍是按值传递,只是它内部结构包含一个对底层数组的引用,所以表现出"引用语义"。因为切片是一个轻量级结构体,它包含三个字段:
gotype slice struct { ptr *T // 指向底层数组的指针 len int // 切片当前长度 cap int // 切片容量(底层数组最大可用长度) }
所以当你将切片作为参数传入函数时:实际上传入的是这个结构体的副本(值拷贝)。但由于这个结构体内包含的是对底层数组的引用地址(ptr),所以对切片元素的修改会反映到底层数组上,从而影响调用者。
举例对比理解:
go
func modify(s []int) {
s[0] = 100 // 修改底层数组 => 会影响原切片
s = append(s, 200) // 修改的是副本,不会改变原切片的长度
}
func main() {
s := []int{1, 2, 3}
modify(s)
fmt.Println(s) // 输出:[100 2 3] ------ s[0] 被修改,但长度没变,200 没有加进来
}
s[0] = 100:改的是底层数组,原始切片感知到了变化。
s = append(...):这只改变了函数中的副本 s,原始切片不变。如果按照c语言的理解,这里如果你传递的是一个指针,比如 int*, 那么你可以修改这个指针本身,改变它指向的内容,这里append 返回的是一个新的切片结构(新三元组)。但在 Go 中因为本质上
切片是按值传递的!你传进来的 s 是这个三元组结构的副本(值传递),不是指针。所以你你改变了副本里的 ptr,原始的 s 完全不知道你在函数里发生了什么。这就像你复制了一张地图,然后在复制上画线,原图没变。如果你想要让原始切片变长怎么办?你就得 返回新的切片并赋值回去:
gofunc modify(s []int) []int { s[0] = 100 s = append(s, 200) return s } func main() { s := []int{1, 2, 3} s = modify(s) fmt.Println(s) // 输出:[100 2 3 200] }不同于 C 指针传参中可以直接改变指针本身。这点需要注意。
如果你想在函数内真正改变原来的 slice 本体的"引用",那就要传指针:
gofunc resetSlice(s *[]int) { // 修改 slice 的指向,让它指向一个新 slice *s = []int{100, 200, 300} }
下面再学习一下切片的四种声明方法:
go
package main
import "fmt"
func main() {
// 方法1:声明 slice1 是一个切片,并且直接初始化
// 切片中有三个元素:1, 2, 3,长度(len)是 3,容量(cap)也是 3
//slice1 := []int{1, 2, 3}
// 打印一下切片的长度和详细信息(%v) 此时 len = 3, slice = [1 2 3]
//fmt.Printf("len = %d, slice = %v\n", len(slice1), slice1)
// 方法2:声明slice1是一个切片,但是并没有给slice分配空间
//var slice1 []int // 此时为空切片
//fmt.Printf("len = %d, slice = %v\n", len(slice1), slice1) // len = 0, slice = []
//slice1[0] = 1 // 运行会报错 因为slice1没有开辟空间 此时没有任何值
//slice1 = make([]int, 3) //方法2------开辟3个空间 ,默认值都是0,这个时候才能slice1[0]=1赋值
//slice1[0] = 100
//fmt.Printf("len = %d, slice = %v\n", len(slice1), slice1) // len = 3, slice = [100 0 0]
// 方法3:声明slice1是一个切片,同时给slice分配空间,3个空间,初始化值是0
//var slice1 []int = make([]int, 3)
//fmt.Printf("len = %d, slice = %v\n", len(slice1), slice1) // len = 3, slice = [0 0 0]
// 方法4(更常用的写法),也即方法3的简写方式,通过:=推导出slice是一个切片
//声明slice1是一个切片,同时给slice分配空间,3个空间,初始化值是0
slice1 := make([]int, 3)
fmt.Printf("len = %d, slice = %v\n", len(slice1), slice1) // len = 3, slice = [0 0 0]
//判断一个silce是否为空(不是值为空,而是切片没有任何元素,即是否尚未初始化/未分配内存)
if slice1 == nil {
fmt.Println("slice1 是一个空切片")
} else {
fmt.Println("slice1 是有空间的")
}
}
Go 中
make和new的区别的简洁总结
new:分配内存,返回指针make:初始化内建类型(非指针)
new(T)会分配一块 类型为 T 的零值内存,并返回一个指向它的 指针。适用于所有类型(如结构体、数组、基本类型等)。
gop := new(int) // 分配一个 int,初始为 0,p 是 *int 类型 fmt.Println(*p) // 输出:0 // 你需要手动处理指针访问:*p = 10,fmt.Println(*p)
make(T, ...)只能用来创建切片(slice)、映射(map) 和 通道(chan)。返回的是已经初始化好的值,不是指针。make 不返回指针,是为了直接操作这些类型的内部结构(如容量、缓冲区等)。
gos := make([]int, 3) // 创建一个长度为3的切片,s 是 []int 类型 m := make(map[string]int) // 创建一个 map c := make(chan int) // 创建一个 channel总结:new 是"你要一块内存",make 是"你要一个能用的内建类型"。
下面学习一下切片的追加append:
go
package main
import "fmt"
func main() {
/*
之前我们这样定义切片的时候,长度(len)和容量(cap)都为3:
var slice1 []int
slice1 = make([]int, 3)
*/
// 可以这样显式创建一个长度为 3、容量为 5 的 int 类型切片 numbers。
// 初始化的 3 个元素值为 0(Go 中 int 的零值)
var numbers = make([]int, 3, 5)
// 输出:len = 3, cap = 5, slice = [0 0 0]
fmt.Printf("len = %d, cap = %d, slice = %v\n", len(numbers), cap(numbers), numbers)
//向numbers切片追加一个元素1, 此时长度变为 4,容量仍为 5,[0,0,0,1]
numbers = append(numbers, 1)
// 输出:len = 4, cap = 5, slice = [0 0 0 1]
fmt.Printf("len = %d, cap = %d, slice = %v\n", len(numbers), cap(numbers), numbers)
//向numbers切片追加一个元素2, numbers len = 5, [0,0,0,1,2], cap = 5
numbers = append(numbers, 2)
// 输出:len = 5, cap = 5, slice = [0 0 0 1 2] 长度刚好等于容量,仍不会扩容
fmt.Printf("len = %d, cap = %d, slice = %v\n", len(numbers), cap(numbers), numbers)
// 向一个容量已经满的切片追加元素 3,会触发自动扩容(Go 通常会按 2 倍扩容策略)
numbers = append(numbers, 3)
// 输出:len = 6, cap = 10(或其他倍数,Go 会动态调整),slice = [0 0 0 1 2 3]
fmt.Printf("len = %d, cap = %d, slice = %v\n", len(numbers), cap(numbers), numbers)
fmt.Println("---------")
// 创建另一个切片 numbers2,长度和容量都为 3,默认值为 0
var numbers2 = make([]int, 3)
// 输出:len = 3, cap = 3, slice = [0 0 0]
fmt.Printf("len = %d, cap = %d, slice = %v\n", len(numbers2), cap(numbers2), numbers2)
// 向 numbers2 追加一个元素 1,由于容量满了,会自动扩容
numbers2 = append(numbers2, 1)
// 输出:len = 4, cap = 6(Go 会扩容,通常为原容量的 2 倍),slice = [0 0 0 1]
fmt.Printf("len = %d, cap = %d, slice = %v\n", len(numbers2), cap(numbers2), numbers2)
}
切片扩容时,Go 会新开一块更大的内存,然后将原切片的内容复制过去,返回一个新的切片。
gos := make([]int, 2, 2) ptr1 := &s[0] s = append(s, 99) ptr2 := &s[0] fmt.Println(ptr1 == ptr2) // false,说明底层数组已经变化(新地址)
最后再学习一下切片的截取操作,一个类似python中的语法操作:
go
package main
import "fmt"
func main() {
// 初始化一个切片 s,包含三个元素,长度为3,容量为3
s := []int{1, 2, 3} // len = 3, cap = 3, 内容: [1 2 3]
// === 切片截取基础操作 ===
// 截取 s 的前两个元素,索引区间是 [0:2),左闭右开(不包含2)
s1 := s[0:2] // s1 是 [1 2],共享 s 的底层数组
fmt.Println("s1 =", s1) // 输出: [1 2]
// 修改 s1 会影响 s,因为它们底层数组相同
s1[0] = 100
fmt.Println("s =", s) // 输出: [100 2 3],原切片 s 也被修改
fmt.Println("s1 =", s1) // 输出: [100 2]
// === 使用 copy 创建切片副本,避免共享底层数组 ===
s2 := make([]int, 3) // 创建一个新的切片 s2,初始化为 [0 0 0]
copy(s2, s) // 将 s 的值复制到 s2(复制的是值,不是引用)
s2[0] = 200 // 修改 s2 不影响 s
fmt.Println("s =", s) // 输出: [100 2 3],未变
fmt.Println("s2 =", s2) // 输出: [200 2 3]
// === 补充:切片截取更多例子 ===
arr := []int{10, 20, 30, 40, 50, 60} // len = 6, cap = 6
// 截取从索引 2 到末尾
s3 := arr[2:] // [30 40 50 60]
fmt.Println("s3 =", s3)
// 截取从开头到索引 4(不含4)
s4 := arr[:4] // [10 20 30 40]
fmt.Println("s4 =", s4)
// 截取整个切片
s5 := arr[:] // [10 20 30 40 50 60]
fmt.Println("s5 =", s5)
// 对切片再次切片(切片可以嵌套切)
s6 := s5[1:5] // [20 30 40 50]
fmt.Println("s6 =", s6)
// === 切片容量的继承特性演示 ===
// 注意:切片不仅继承原数组的指针,还继承"剩余容量"
sub := arr[2:4] // [30 40]
fmt.Printf("sub: %v, len: %d, cap: %d\n", sub, len(sub), cap(sub)) // sub: [30 40], len: 2, cap: 4
// cap(sub) = 从 index 2 到数组末尾,共有 4 个元素([30 40 50 60])
// 所以 append(sub, 999, 1000) 在 cap 范围内,不会触发扩容,会影响 arr
sub = append(sub, 999)
fmt.Println("after append(sub, 999):", sub) // after append(sub, 999): [30 40 999]
fmt.Println("arr (after append):", arr) // arr 中原数据也被改了:arr (after append): [10 20 30 40 999 60]
// === 想彻底断开联系,复制即可 ===
independent := make([]int, len(sub))
copy(independent, sub)
independent[0] = 888
fmt.Println("independent copy:", independent) // independent copy: [888 40 999]
fmt.Println("sub still:", sub) // sub still: [30 40 999]
}
map
map和slice的用法类似,只不过是数据结构不同,slice是数组形式而map是key-value这种哈希键值对形式。下面是map的一些声明方式:
go
package main
import "fmt"
func main() {
// ===> 第一种声明方式:使用 var 声明一个空 map(nil map)
// 声明 myMap1 是一种 map 类型:key是string(中括号里声明),value也是string(中括号右声明)
var myMap1 map[string]string
// 此时 myMap1 是 nil,不能直接赋值,否则会 panic
if myMap1 == nil {
fmt.Println("myMap1 是一个空map")
}
// 在使用map前, 需要先用make给map分配数据空间
// 第二个参数10是建议初始容量(cap),实际并不会限制它的大小
// len(myMap1) 此时为0 cap并没有cap函数可用,它是由底层结构管理的
myMap1 = make(map[string]string, 10)
// 添加键值对的语法
myMap1["one"] = "java"
myMap1["two"] = "c++"
myMap1["three"] = "python"
// map 的容量不够时会自动扩容,是的,机制类似 slice:开辟更大的空间并复制旧数据
// map 在底层是通过哈希表实现的,因此打印或遍历时的顺序是无序的
// 按 key 遍历时顺序也是随机的,每次运行都可能不同
fmt.Println(myMap1) // map[one:java three:python two:c++] 顺序无保证
//===> 第二种声明方式(最常用) 使用:= 此时make直接创建一个 map,指不指定容量都行
myMap2 := make(map[int]string)
myMap2[1] = "java"
myMap2[2] = "c++"
myMap2[3] = "python"
fmt.Println(myMap2) // map[1:java 2:c++ 3:python] 顺序也无保证
//===> 第三种声明方式(常用) 字面量方式初始化一个map 此时不需要make
myMap3 := map[string]string{
"one": "php",
"two": "c++",
"three": "python", // 注意最后一行必须加逗号,否则语法错误
}
fmt.Println(myMap3) // map[one:php three:python two:c++] 顺序仍是无序的
//make(map[K]V, cap) 只能设置预期容量(提高性能),不能像slice那样还设置初始长度len,然后生成默认值,map长度只能通过插入元素增加
}
学习了map的三种声明方式再来学习一下map的基本使用方式:
go
package main
import "fmt"
// 遍历map
func printMap(cityMap map[string]string) {
// cityMap 是一个"引用传递"
// 这里和slice一样 传参仍然为值传递: 传递的是对象引用的副本, 引用本身是一个值. 通过这个引用可以修改对象的内容, 但不能改变引用指向其他对象
for key, value := range cityMap {
fmt.Println("key = ", key)
fmt.Println("value = ", value)
}
}
func ChangeValue(cityMap map[string]string) {
// 可以认为是引用传递,在这里的修改会影响到原map
cityMap["England"] = "London" // 修改 map
}
func main() {
// 使用 := make 创建一个空 map,key 为 string,value 也为 string
cityMap := make(map[string]string)
//添加键值对
cityMap["China"] = "Beijing"
cityMap["Japan"] = "Tokyo"
cityMap["USA"] = "NewYork"
//遍历并打印 map
printMap(cityMap)
fmt.Println("-------")
//删除元素:使用 delete 内建函数,指定 key 即可删除
delete(cityMap, "China")
printMap(cityMap)
fmt.Println("-------")
//修改 map 中某个 key 对应的值,直接通过 key 赋值即可
cityMap["USA"] = "DC"
// 函数中修改 map 的值,也会影响到外部
ChangeValue(cityMap)
printMap(cityMap)
}
这里还是要
注意,go中本质上map和slice一样,都是按照"值传递"的!slice 的情况复习一下:
gofunc modify(s []int) { s[0] = 100 // 修改底层数组,原切片受影响 s = append(s, 200) // append 返回了一个新切片,赋值给了 s >的副本,不影响外部 s }map 的行为非常类似:
gofunc modifyMap(m map[string]string) { m["USA"] = "DC" // 修改原 map:有效 m = make(map[string]string) // 创建新 map,改变的是副本,不影响原 map 的引用 m["Japan"] = "Kyoto" // 添加到新 map:不会影响 main 里的 map }如果你想在函数内真正改变原来的 slice/map 本体的"引用",那就要传指针:
gofunc resetMap(m *map[string]string) { // 创建并指向一个新 map *m = map[string]string{ //main中的map也指向新的了 "UK": "London", "USA": "DC", } }
很多新手容易误以为 map 拷贝会"深拷贝",其实它只是引用的浅拷贝:
go
package main
import "fmt"
// 打印 map 的键值对
func printMap(cityMap map[string]string) {
for key, value := range cityMap {
fmt.Println("key =", key, ", value =", value)
}
}
func main() {
// 创建一个 map,并添加初始数据
originalMap := map[string]string{
"China": "Beijing",
"Japan": "Tokyo",
}
// map 的拷贝 ------ 只是复制了引用(浅拷贝),两个变量指向同一个底层数据
copiedMap := originalMap
// 修改 copiedMap 会影响 originalMap
copiedMap["China"] = "Shanghai"
fmt.Println("originalMap:")
printMap(originalMap) // 输出:China: Shanghai
fmt.Println("copiedMap:")
printMap(copiedMap) // 输出:China: Shanghai
// 现在我们让 copiedMap 指向一个新 map
copiedMap = make(map[string]string)
copiedMap["USA"] = "DC"
fmt.Println("----After copiedMap = new map----")
fmt.Println("originalMap:")
printMap(originalMap) // 不受影响,仍然有 China 和 Japan
fmt.Println("copiedMap:")
printMap(copiedMap) // 现在只有 USA
}
如果你想实现真正的 map 深拷贝(deep copy),你需要手动复制每个键值对,例如:
go
func deepCopy(m map[string]string) map[string]string {
newMap := make(map[string]string)
for k, v := range m {
newMap[k] = v
}
return newMap
}
面向对象语法特征-struct、封装、继承、多态
接下来介绍一下go面向对象的一些语法特征,go本身它实际上也是一种面向对象的语言,那么也会有类和对象的概念,介绍类和对象之前需要先介绍一下go语言的结构体struct:
go
package main
import "fmt"
// 学习一下type关键字 下面表示声明一种新的数据类型 myint, 是int的一个别名,本质仍是 int
type myint int
// 定义一个结构体的语法:
type Book struct {
title string
auth string
}
func changeBook(book Book) {
//值传递 传递一个book的副本
book.auth = "666" // 修改副本,不影响 main 中的 book1
}
func changeBook2(book *Book) {
//函数参数是 *Book 类型(指针) 会发生"引用传递"
book.auth = "777" // 修改原始结构体 影响原始main中 book1 的内容
}
func main() {
var a myint = 10
fmt.Println("a = ", a) // a = 10
fmt.Printf("type of a = %T\n", a) //type of a = main.myint
// 实际上main.myint底层就是一个int
var book1 Book
book1.title = "Golang"
book1.auth = "zhang3"
fmt.Printf("%v\n", book1) // {Golang zhang3}
changeBook(book1)
fmt.Printf("%v\n", book1) // {Golang zhang3}
changeBook2(&book1)
fmt.Printf("%v\n", book1) // {Golang 777}
}
学完结构体struct,我们紧接着学习一下go中的类和对象,Go 语言中实际上没有"类(class)"的语法结构,但是它通过通过结构体来绑定方法,实现类似面向对象编程中的"类"和"对象"的功能。下面是Go中面向对象类的表示与封装:
go
package main
import "fmt"
// 在 Go 中没有 class,但可以通过结构体 + 方法模拟"类"的概念
// 如果类名首字母大写,表示其他包也能够访问,否则只能本包内访问,比如fmt.Println中P大写表示是可导出的函数
type Hero struct {
// 字段首字母大写(Name, Ad)表示字段是"导出"的,其他包也能访问
// 字段首字母小写(level)表示字段是私有的,仅当前包可访问
// 私有属性------'当前包内任意函数或方法'都可以这样 hero.level 直接访问私有属性
Name string
Ad int
level int // 私有属性
}
// 方法名首字母大写(如 Show),表示该方法是导出的,可以被外部包调用
// 方法名小写(如 show)则只能在本包内调用(权限控制和字段一样)
// 这类似于类中"公有方法"和"私有方法"的概念
// 类的方法定义如下所示,注意这个格式跟之前学的函数比对一下,在方法名左侧还有括号
// 方法的接收者写在方法名前的括号中:括号中有Hero结构体,表示这个方法是绑定到这个Hero结构体的方法
// 接收者名字(this)不固定,常用的是 `h`, `hero`, `self`, `this`,都可以,不影响语义
// 这里传递的是Hero而不是指针类型 所以是调用该方法的对象的一个副本(拷贝)
// 此时对副本的修改不影响原对象
/*
func (this Hero) Show() {
fmt.Println("Name = ", this.Name)
fmt.Println("Ad = ", this.Ad)
fmt.Println("Level = ", this.Level)
}
func (this Hero) GetName() string {
return this.Name
}
func (this Hero) SetName(newName string) {
this.Name = newName
}
*/
// 这里接收者类型是 *Hero(指针),意味着调用方法时不会复制 Hero 对象 方法内部对对象字段的修改会影响原对象本身
func (this *Hero) Show() {
fmt.Println("Name = ", this.Name)
fmt.Println("Ad = ", this.Ad)
fmt.Println("Level = ", this.level) // 私有字段可以被结构体自己的方法访问
}
// 获取 Hero 的 Name 字段
func (this *Hero) GetName() string {
return this.Name
}
// 修改 Hero 的 Name 字段
func (this *Hero) SetName(newName string) {
//this 是调用该方法的对象的一个副本(拷贝)
this.Name = newName
}
func main() {
// 创建一个 Hero 对象,初始化 Name 和 Ad 字段
hero := Hero{Name: "zhang3", Ad: 100}
// 调用方法:Go 中对象.方法() 的语法与面向对象语言一致
hero.Show()
// 修改 Name 字段
hero.SetName("li4")
// 再次打印 发现Name字段被成功修改
hero.Show()
}
学习了go的面向对象类的表示与封装之后,我们再看一下go的继承:
go
package main
import "fmt"
// 定义一个Human类
type Human struct {
name string
sex string
}
// Human类的两个方法
func (this *Human) Eat() {
fmt.Println("Human.Eat()...")
}
func (this *Human) Walk() {
fmt.Println("Human.Walk()...")
}
// 写一个子类继承Human父类
type SuperMan struct {
Human //这样写就行 表示SuperMan类继承了Human类的方法
level int // SuperMan的额外的属性
}
// 可以去重定义父类的方法Eat()
func (this *SuperMan) Eat() {
fmt.Println("SuperMan.Eat()...")
}
// 也可以声明一个子类的新方法
func (this *SuperMan) Fly() {
fmt.Println("SuperMan.Fly()...")
}
func (this *SuperMan) Print() {
fmt.Println("name = ", this.name)
fmt.Println("sex = ", this.sex)
fmt.Println("level = ", this.level)
}
func main() {
// 声明一个Human类对象
h := Human{"zhang3", "female"}
h.Eat() // Human.Eat()...
h.Walk() // Human.Walk()...
// 定义一个子类对象 应该这样写:先写Human的属性再写自己的属性
//s := SuperMan{Human{"li4", "female"}, 88}
// 如果觉得上面这样写太麻烦也可以下面这样去定义
var s SuperMan
s.name = "li4"
s.sex = "male"
s.level = 88
s.Walk() //父类的方法 Human.Walk()...
s.Eat() //子类的方法 SuperMan.Eat()...
s.Fly() //子类的方法 SuperMan.Fly()...
s.Print()
}
注意,这里
func (this *SuperMan) Fly() {...}是用 指针接收者定义的。但你可以这样用:
gos := SuperMan{...} s.Fly() // 为什么能调用?因为这是 Go 的语法糖!Go 编译器在调用
s.Fly()的时候:发现你调用的是*SuperMan的方法,而s是SuperMan,不是指针。编译器会自动将s转换为&s,然后调用(&s).Fly()这叫做自动取址(auto address-taking)。同样的,反过来:如果方法接收者是值类型,你用的是指针去调用,Go 也会自动解引用调用。
学习了go的面向对象类的封装与继承后,我们再看一下go的多态。实际上我们用刚才继承的这种方式是实现不了多态的,所以go语言中想要做多态的话需要有interface这么一个接口的概念。
一般我们说面向对象的层面,有一类或者说一个家族、一系列对象他们要有一定的接口,接口定义一些所谓的抽象方法 ,然后子类去继承并实现,达成一个抽象接口有很多种不同的动态表现形式,即面向对象的多态。
go
package main
import "fmt"
// 抽象接口 AnimalIF 定义了一个动物的行为接口。
// interface 本质是一个指针 有时间可以去看源码 其实interface内部有一个指针指向当前interface修饰的具体类型
// 以及当前类型所包含的函数列表 可以理解为是一个父类的指针 全部人都要去继承这个interface
type AnimalIF interface {
Sleep()
GetColor() string //获取动物的颜色 带返回值 string类型
GetType() string //获取动物的种类 带返回值 string类型
}
// 一个具体的类Cat 从语法上 Cat继承AnimalIF接口 不像刚刚的继承需要在struct里把父类写下来
// 而这里则不需要 Go 中的接口实现是隐式的 无需显式声明"实现了某个接口" 只需要把这三个方法实现了就行了
// 然后就等于Cat继承了AnimalIF并实现它了 这样的话就可以用AnimalIF指向一个Cat对象了
type Cat struct {
color string //猫的颜色
}
// 必须实现AnimalIF全部的方法 否则就等于没有完全实现接口 这样该接口的指针就无法指向这个具体类了
func (this *Cat) Sleep() {
fmt.Println("Cat is Sleep")
}
func (this *Cat) GetColor() string {
return this.color
}
func (this *Cat) GetType() string {
return "Cat"
}
// 一个具体的类Dog
type Dog struct {
color string
}
func (this *Dog) Sleep() {
fmt.Println("Dog is Sleep")
}
func (this *Dog) GetColor() string {
return this.color
}
func (this *Dog) GetType() string {
return "Dog"
}
// showAnimal 接收一个 AnimalIF 类型的参数,
// 无论传入的是 Cat 还是 Dog,都会调用对应类型实现的方法,也体现出多态性。
func showAnimal(animal AnimalIF) {
animal.Sleep() //多态 传什么类型的对象我就调用什么对象的方法
fmt.Println("color = ", animal.GetColor())
fmt.Println("kind = ", animal.GetType())
}
func main() {
var animal AnimalIF //接口的数据类型,父类指针.接口变量本身就是一个指针类型,无需显式使用 *
animal = &Cat{"Green"} //接口指针指向实现类 将 Cat 类型的实例赋值给接口变量
animal.Sleep() // 调用的是 Cat 的 Sleep 方法,多态表现
animal = &Dog{"Yellow"} // 将 Dog 类型的实例赋值给同一个接口变量
animal.Sleep() // 调用的是 Dog 的 Sleep 方法,多态表现
// 也可以直接将实现了接口的类型传入函数中
cat := Cat{"Green"}
dog := Dog{"Yellow"}
showAnimal(&cat) // 多态现象
showAnimal(&dog) // 多态现象
}
注意这里,我们虽然在上面说了,Go的语法糖中有
自动取址和自动解引用调用,但是这里我们在接口赋值时,Go 不会自动做取地址操作。但自动取址和自动解引用 仅仅在方法调用时生效,跟接口赋值无关。接口赋值时,Go 是静态检查类型的方法集,绝不做自动取址。这里我们为Cat类型实现的接口方法,是在*Cat(指针接收者)上定义的:
gofunc (this *Cat) Sleep() {...} func (this *Cat) GetColor() string {...} func (this *Cat) GetType() string {...}这意味着:
只有 *Cat(指针类型)实现了接口,Cat(值类型)没有实现这些接口,所以,如果你写:
govar b Cat // 这是一个值类型 var r AnimalIF r=b // ❌ 错误: AnimalIF需要的方法都在*Cat (指针接收者) 上,Cat本身没有这些方法这是 Go 的接口机制中非常重要的一点:接收者的类型必须完全匹配。所以必须自己明确:
goanimal = &Cat{"Green"} //注意这里的&因为接口的赋值必须完全匹配接口的方法集(method set)。
有些同学可能会疑惑:如果我把接口方法改用值接收者去实现,是否可以用值类型直接赋值接口?答案是:可以 。
例如:
gofunc (c Cat) Sleep() { ... } func (c Cat) GetColor() string { ... } func (c Cat) GetType() string { ... }此时,不管是值类型还是指针类型,都能赋值给接口变量:
govar a AnimalIF a = Cat{"Green"} // OK a = &Cat{"Green"} // OK这种值接收者的写法特点是方法调用时会复制对象,方法内部修改不会影响原对象。适用于轻量对象、只读操作、不可变逻辑。
而Go社区常见的写法还是使用指针接收者的写法。特点是方法接收的是对象地址,方法内部修改会影响原对象,避免复制开销。适用于需要修改对象状态、对象较大、业务逻辑常用场景。
通用类型interface{}与类型断言
刚刚学习了 Go 语言中的 继承与多态,同时接触到了 interface(接口)这个概念。Go 的接口(interface)除了可以用于定义一组方法行为(也就是我们自定义的接口),还有另一层非常重要的含义:interface{} 是一种通用类型(也称为空接口),可以接收任何类型的值。这就类似于:
- Java 中的
Object - C 语言中的
void*
在 Go 中,常见的类型如 int、string、struct 等都"默认实现"了空接口 interface{},因此我们可以用 interface{} 来引用任意数据类型的值。不过,由于空接口本身不携带类型信息,如果我们希望在运行时获取其真实的底层类型,Go 提供了 类型断言(type assertion)机制来支持这一需求。下面是一个简单的演示代码:
go
package main
import "fmt"
// myFunc 接收一个空接口类型(interface{})的参数,可以传入任意类型的值
func myFunc(arg interface{}) {
fmt.Println("myFunc is called...")
fmt.Println(arg) // 参数打印出来看看
//interface{} 改如何区分 此时引用的底层数据类型到底是什么?
//实际开发中也可能需要根据不同类型做不同业务
//go给interface{}这种万能类型提供了 "类型断言" 的机制 语法:value, ok := arg.(目标类型)
//如果断言成功,ok 为 true,value 是对应的类型值 否则ok为false,value 是该类型的零值
//这里是判断arg是否是字符串 注意虽然interface{}有这种语法且不限于interface{} 可以是任何自定义接口
//但是非接口类型(如 int)则没有这种语法
value, ok := arg.(string) //返回两个值 如果是string类型 则ok为true value则为
if !ok {
fmt.Println("arg is not string type")
} else {
fmt.Println("arg is string type, value = ", value)
fmt.Printf("value type is %T\n", value)
}
}
// Book 是一个简单的结构体类型
type Book struct {
auth string
}
func main() {
book := Book{"Golang"}
myFunc(book) // 传入结构体尝试
/* myFunc is called...
{Golang}
arg is not string type*/
fmt.Println("--------------")
myFunc(100) // 传入整数
/* myFunc is called...
100
arg is not string type*/
fmt.Println("--------------")
myFunc("abc") // 传入字符串
/* myFunc is called...
abc
arg is string type, value = abc
value type is string*/
fmt.Println("--------------")
myFunc(3.14) // 传入浮点数
/* myFunc is called...
3.14
arg is not string type*/
}
类型断言的另一种写法(不推荐用于不确定类型的情况):
govalue := arg.(string) // 如果断言失败,会直接 panic
还有类型 switch(可以判断多种类型):
go
// type switch 是 Go 中用于判断接口变量实际类型的一种语法
// 它的写法类似于普通 switch,但表达式的形式是 `v := arg.(type)`,必须用于接口类型变量上
// 每个 case 分支可以匹配一种具体的类型,编译器会自动为该类型做类型转换
// 非常适合处理 interface{} 类型在运行时可能包含的不同类型值
switch v := arg.(type) {
case string:
// 如果 arg 实际上是 string 类型
fmt.Println("string:", v)
case int:
// 如果 arg 实际上是 int 类型
fmt.Println("int:", v)
default:
// 如果 arg 是其他任何非 string 或 int 的类型
fmt.Println("unknown type")
}
//可以这样用:
type Speaker interface {
Speak()
}
var s Speaker // s 是一个接口类型,也可以用 type switch
switch v := s.(type) {
...
var x int = 42
switch v := x.(type) { // ❌ 错误:x 不是接口类型,不能使用 .(type)
...
var x interface{} = "hello"
switch v := x.(type) { // ✅ x 是接口类型
case string: // 👈 这里判断 x 实际是否装的是 string 类型的值
interface{}是所有interface类型的"终极父接口",它包括了:
所有具体类型(int、string、struct 等)
所有接口类型(io.Reader、error、你自定义的 Speaker 等)
而虽然常见类型(如 int、string、struct 等)确实"默认实现"了空接口 interface{}。那为什么不能对 int 使用类型断言?(比如:
x := 42; x.(int))
因为类型断言(比如x.(T))只能用于接口类型的变量,因为类型断言的本质是"从接口值中取出底层的真实类型"。而int 类型满足空接口,但它本身不是接口类型变量。
特别注意:
接口的 nil 陷阱
接口类型的 nil 是动态类型和值都为 nil才为 true。否则可能会出问题:
go
var p *int = nil
var i interface{} = p
fmt.Println(i == nil) // false!
解释:接口 i 的动态类型是 *int,值是 nil,但这个接口本身不是 nil!
不允许任意类型赋值为 nil
只有支持 nil 的类型才能为 nil,包括:
- 指针(pointer):
var p *int = nil - 接口(interface):
var i interface{} = nil包括空接口和具体接口 - 切片(slice):
var s []int = nil注意nil 切片和空切片不同 - map:
var m map[string]int = nilnil map 不能写入,读时返回零值 - channel:
var ch chan int = nilnil channel 发送/接收会阻塞 - 函数(func):
var f func() = nil函数类型的零值是 nil - unsafe.Pointer低级指针类型:
var up unsafe.Pointer = nil
unsafe.Pointer 是 Go 提供的底层工具,允许绕过类型检查进行指针转换,适用于底层库开发,不建议在业务逻辑中使用。
基本类型(如 int, float64, bool, string)是值类型,不能赋值为 nil。数组(如 [5]int)本身也不能为 nil,但可以是包含 nil 元素的数组。结构体(struct)同样是值类型,不能为 nil,除非使用结构体指针。
反射
接口变量的结构:静态类型与动态类型并存
下面我们介绍 Go 的另一个重要特性:反射(reflection)。在讲反射之前,先回顾上一节的类型断言。我们讲过,类型断言用于 interface 类型的变量,比如 interface{},它通过 .() 语法来判断接口内部实际存储的数据类型。
要理解类型断言为何成立,必须先理解 Go 变量在底层的构造。Go 中每个变量都可以理解为由两个部分组成的一个对(pair):类型(type) 和 值(value) 。比如 var a int = 10,其中 a 的类型是 int,值是 10,这就是一个典型的 <type, value> 对。
但对于接口类型的变量,比如 var x interface{} = 100,这个 pair 的含义更复杂一些:
x的静态类型 (static type)是interface{},也就是你在代码中写的类型;- 它的动态类型 (
dynamic type,也称concrete type)是int,也就是运行时接口变量真正存储的值的类型。
这里要特别注意:静态类型和动态类型并不是"二选一"的关系,而是在接口变量中共存的。
- 所有变量都有静态类型,它是编译期确定的;静态类型不会"存在于变量的值里面"------它存在于编译期,不是运行期数据结构的一部分。接口变量内部只保存动态类型 + 值,而静态类型是编译器用来限制操作范围和类型检查的,它不需要被存储在变量中。
- 只有接口变量才可能有动态类型,用于支持运行时的类型判断(比如类型断言、反射等)。
因此,接口变量本质上是一个 pair:<dynamic type, value>,静态类型虽然不在这个 pair 结构中,但它始终存在,并参与编译期的类型检查。
反射的本质,就是让我们在运行时获取这个 pair 中的 type 和 value 信息------不仅知道值,还能知道它的实际类型。

下面通过一段代码简单说明一下上面的描述:
go
package main
import "fmt"
func main() {
//定义一个变量 a,类型为 string,并赋值
var a string
a = "aceld"
//在 Go 的底层语义中,此时 a 的内部结构可以理解为:
//pair<static type: string, value: "aceld">
//定义一个变量 allType,类型为空接口 interface{}(万能类型) 将变量 a 赋值给 allType
//实际上是把 a 的 "类型信息" 和 "具体值" 封装成接口内部的 pair 结构:
//pair<type: string, value: "aceld"> 注意这里不是static type
//因为接口变量内部保存的是动态类型(dynamic type)和对应的值,而static type其实只是写在代码层面的语义约束
//而接口的意义就在于"运行时才知道类型",所以它只关心当前装的具体是什么类型(也就是 dynamic type)。
var allType interface{}
allType = a
//类型断言不一定要value,ok := allType.(string) 这里我们忽略 ok
str, _ := allType.(string)
fmt.Println(str) // aceld
}
又比如下面的两个例子:
go
package main
import (
"fmt"
"io"
"os"
)
func main() {
// 打开一个特殊文件 "/dev/tty"(Linux 终端设备),以读写模式打开:os.O_RDWR 参数0表示权限不用管
// 观察os.OpenFile源码可知其返回的是一个*File和error os.OpenFile 返回 (*os.File, error)
// tty 的类型是 *os.File ------ 一个具体类型,表示操作系统文件描述符
// 可以理解为:tty = <type: *os.File, value: "/dev/tty" 的文件句柄>
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
fmt.Println("open file error", err)
return
}
/*
1. 接口变量的本质结构:pair<动态类型(type), 实际值(value)>
tty = <type: *os.File, value: "/dev/tty" 的文件句柄> 中的 type 是 静态类型,
因为 tty 是普通变量,不是接口类型变量,所以它没有"静态类型 vs 动态类型"的区分
只有当一个值被赋给接口类型的变量时,才出现「静态类型 vs 动态类型」的区分
*/
// 定义一个变量 r,其静态类型是 io.Reader(接口类型) 编译器知道 r 是 io.Reader 类型,但此时它并未指向任何值
// r = <type: nil, value: nil>
var r io.Reader
// 将 tty(*os.File 类型)赋值给 r 因为 *os.File 实现了 io.Reader 接口,因此可以赋值给 io.Reader 类型的变量
// r: pair<type:*os.File, value:"/dev/tty"文件描述符>
r = tty
/*
2. 演示接口赋值是基于类型实现的:
*os.File 是一个具体类型 它实现了多个接口:io.Reader 和 io.Writer
所以可以赋值给这些接口类型的变量,如: r = tty // ✅ 合法,因为 *os.File 实现了 io.Reader
这里 r 虽然静态类型是 io.Reader,但运行时动态类型是 *os.File
*/
// 定义一个变量 w,其静态类型是 io.Writer(也是接口类型)
// 此时 w = <type: nil, value: nil>
var w io.Writer
// 尝试将 r 强制断言为 io.Writer 接口类型 然后赋值给w
// 这只有在 r 内部实际持有的类型(*os.File)实现了 io.Writer 时才会成功
// 由于 *os.File 同时实现了 io.Reader 和 io.Writer,所以断言成立
// 此时w的结构为:: pair<type:*os.File, value:"/dev/tty"文件描述符>
w = r.(io.Writer)
/*
3. 演示类型断言的用法与场景:
接口之间不能直接赋值: w = r // ❌ 编译错误:io.Reader 不能直接赋值给 io.Writer
但是如果你知道 r 装的是一个实现了 io.Writer 的类型,就可以通过类型断言来转换:
w = r.(io.Writer) // ✅ 合法,因为 r 实际上是 *os.File
这揭示了类型断言的意义:从接口中"还原"出原始类型或判断它是否满足另一个接口。
*/
// 使用 io.Writer 接口进行写操作 实际调用的是 *os.File 的 Write 方法
// 编译器知道 w 是 io.Writer,但运行时会根据 w 的动态类型来调用具体方法
w.Write([]byte("HELLO THIS is A TEST!!!\n"))
/*
4. 演示接口背后的多态性: 虽然你操作的是 io.Writer 接口类型变量 w
实际运行的是 *os.File 的 Write 方法(动态类型决定了调用哪个实现) 这就是 Go 接口背后的运行时多态
*/
}
再比如下面的案例,有点抽象,多看两个例子学习一下:
go
package main
import "fmt"
// 定义 Reader 接口,包含一个 ReadBook 方法
type Reader interface {
ReadBook()
}
// 定义 Writer 接口,包含一个 WriteBook 方法
type Writer interface {
WriteBook()
}
// 定义一个具体类型 Book,它同时实现了 Reader 和 Writer 两个接口
type Book struct {
}
// Book 的指针类型实现了 Reader 接口
func (this *Book) ReadBook() {
fmt.Println("Read a Book")
}
// Book 的指针类型实现了 Writer 接口
func (this *Book) WriteBook() {
fmt.Println("Write a Book")
}
func main() {
// 创建一个*Book类型的实例,并赋给变量b b是一个普通变量,其类型是*Book,不是接口类型
// 所以b没有"动态类型"这一说法,它的类型就是它的静态类型
// 可以理解为:b = <type: *Book, value: Book{} 的地址>
b := &Book{}
// 定义一个'接口类型变量'r,其静态类型是 Reader
// 此时r尚未赋值,内部为 nil:<type: nil, value: nil>
var r Reader
// 将*Book类型的b赋值给Reader接口类型的 r 因为*Book实现了Reader接口,所以赋值合法
// 现在r的结构变为:<dynamic type: *Book, value: Book{} 的地址>
r = b // 注意这里是因为声明b时用了&Book{}才可以直接赋值 如果var b Book然后赋值给r会报错 具体原因我们在面向对象小节有解释过
// 调用接口方法,此时实际调用的是*Book.ReadBook方法
// 尽管你是通过接口r调用,底层调用的是动态类型 *Book 的方法
r.ReadBook()
// 定义另一个接口变量w,其静态类型是Writer,尚未赋值
// w = <type: nil, value: nil>
var w Writer
// 尝试将r强制断言为Writer接口类型,并赋值给 w
// 类型断言语法:r.(Writer) 表示"我认为r中的动态类型实现了 Writer 接口"
// r的静态类型是Reader,不能直接赋值给 Writer(两个接口不兼容)
// 但是,r的动态类型是*Book,而*Book也实现了Writer接口
// 所以这个断言是合法的,w = <dynamic type: *Book, value: Book{} 的地址>
// r: pair<type:Book, value:book{}地址>
w = r.(Writer) //此处的断言为什么会成功? 因为w r 具体的type是一致
// 通过Writer接口调用WriteBook方法
// 仍然是通过接口调用,但底层由动态类型*Book提供实现
w.WriteBook()
}
反射机制基本用法
在了解了 Go 中变量实际上是由一对 type 和 value 组成之后,我们再来学习反射机制。Go 提供了 reflect 包,它允许我们在程序运行时动态地获取一个变量的类型(Type)和值(Value),这在处理一些动态、不确定类型的场景中非常有用。
Go 是一门静态类型语言,变量的类型在编译时就已经确定(称为静态类型 static type),例如 int、string 等基本类型。然而,反射主要是通过接口(interface{})来实现的。当我们将一个具体类型的值赋给接口变量时,接口会记录这个值的动态类型(称为具体类型 concrete type)和具体的值。
反射机制正是建立在接口类型的基础之上。通过 reflect 包,我们可以在运行时检查接口变量的具体类型和对应的值,甚至可以在某些条件下修改它们。这使得 Go 拥有了一定程度的动态编程能力。
go的反射提供了两种类型(或者说两个方法)让我们可以很容易的访问接口变量内容,分别是reflect.ValueOf() 和 reflect.TypeOf(),看看官方的解释:
go
// ValueOf returns a new Value initialized to the concrete value
// stored in the interface i. ValueOf(nil) returns the zero
// ValueOf 返回一个新的 Value,表示接口 i 中存储的具体值。
// 如果传入的是 nil,ValueOf 返回一个零值(空的 Value)。
func ValueOf(i interface{}) Value {...}
// TypeOf returns the reflection Type that represents the dynamic type of i.
// If i is a nil interface value, TypeOf returns nil.
// TypeOf 返回接口 i 的动态类型所对应的反射 Type 类型。
// 如果 i 是一个 nil 接口值,则返回 nil。
func TypeOf(i interface{}) Type {...}
下面我们通过两个案例简单尝试一下reflect包的基本用法:
go
package main
import (
"fmt"
"reflect"
)
// reflectNum 接收一个空接口参数(所以任何类型都可以),并使用反射查看其类型和值
func reflectNum(arg interface{}) {
fmt.Println("type : ", reflect.TypeOf(arg)) // type : float64
fmt.Println("value : ", reflect.ValueOf(arg)) // value : 1.2345
}
func main() {
var num float64 = 1.2345
// 对基本数据类型进行反射测试
reflectNum(num)
}
这里再澄清一下静态类型和动态类型在反射上的区别:
int等基础类型本身只有一个静态类型,不存在动态类型。任何值都有类型信息,只是接口额外记录了动态类型。- 反射包是可以作用于任何类型,包括非接口类型。非接口变量本来就没有"动态类型"的概念,只有一个固定的类型。
- 对非接口类型调用
reflect.TypeOf返回的是静态类型。当var x int = 5,reflect.TypeOf(x)返回的就是它的唯一类型(它的静态类型,也同时是它的实际类型)。- 接口类型会在内部额外记录动态类型和值,反射对此进行了封装支持。对于非接口变量,反射通过编译期信息直接访问类型信息。对于接口变量,
type是一个指针指向实际类型的描述结构。- 所以不是获取不到动态类型就兼容输出静态类型,而是 动态类型这个概念只存在于接口中,非接口值就直接返回它本身的类型。
在看一下第二个例子学习reflect:
go
package main
import (
"fmt"
"reflect"
)
// 定义一个结构体类型 User
type User struct {
Id int
Name string
Age int
}
// 给 User 类型定义一个方法 Call
func (this User) Call() {
fmt.Println("user is called ..") // 打印方法调用标志
fmt.Printf("%v\n", this) // 打印当前 User 对象的内容
}
func main() {
user := User{1, "Aceld", 18}
// 复杂类型反射尝试 对结构体类型进行反射:提取字段和方法
DoFiledAndMethod(user)
}
// DoFiledAndMethod 使用反射获取传入对象的字段信息和方法信息
func DoFiledAndMethod(input interface{}) {
// 获取传入对象input的类型信息(Type)
inputType := reflect.TypeOf(input)
fmt.Println("inputType is :", inputType.Name()) // inputType is : User
// 获取传入对象input的值信息(Value)
inputValue := reflect.ValueOf(input)
fmt.Println("inputValue is:", inputValue) // inputValue is: {1 Aceld 18}
// ----------- 通过type获取结构体字段信息 -----------
//1. 获取interface的reflect.Type,通过Type得到NumField(字段数) ,进行遍历
//2. 得到每个field,数据类型
//3. 通过filed有一个Interface()方法等到 对应的value
for i := 0; i < inputType.NumField(); i++ { // 遍历结构体的每个字段
field := inputType.Field(i) // 获取第 i 个字段的结构信息
value := inputValue.Field(i).Interface() // 获取第 i 个字段的值(转为 interface{} 方便输出)
// 打印字段名、字段类型、字段值
fmt.Printf("%s: %v = %v\n", field.Name, field.Type, value)
}
/* Id: int = 1
Name: string = Aceld
Age: int = 18*/
// ----------- 通过type获取结构体方法并调用 -----------
for i := 0; i < inputType.NumMethod(); i++ {
m := inputType.Method(i) // 获取第 i 个方法
// 打印方法名和方法类型(签名)
fmt.Printf("%s: %v\n", m.Name, m.Type) // Call: func(main.User)
//调用方法:必须传入一个 reflect.Value 类型的接收者
//因为你定义的方法是这样写的:func (this User) Call() { ... }
//这就是一个结构体方法,它在语法上等价于:func Call(this User) { ... } // 只是Go语法糖,把接收者当作第一个参数
//也就是说,方法 = 函数 + 接收者 这里拿到的是方法本体(m.Func),它的签名其实是:func(User)
//所以你调用它时,必须告诉它"哪个 User 来调用这个方法",即:m.Func.Call([]reflect.Value{inputValue})
//这里的 inputValue 就是我们传入的 user 对象(结构体实例),充当了接收者 this。
m.Func.Call([]reflect.Value{inputValue})
//如果如果调用的方法有返回值,你可以这么写: results := m.Func.Call([]reflect.Value{inputValue})
}
}
反射解析结构体标签Tag
了解了反射基本用法之后呢,我们还需要再看一下结构体标签这一go中比较特殊的语法,它需要用反射这种机制才能够解读:
go
package main
import (
"fmt"
"reflect"
)
// 定义一个简单的结构体 resume,包含两个字段:Name 和 Sex。
// 在 Go 语言中,结构体的字段支持通过标签(Tag)添加额外的元信息。
// 标签的语法是用反引号 ` ` 包裹起来,内部是 key:"value" 的格式,可以写多个键值对。
// 这些标签本身不会影响程序运行逻辑,主要用于描述、序列化、验证等场景,常见于 JSON、数据库 ORM、验证库等。
type resume struct {
Name string `info:"name" doc:"我的名字"`
Sex string `info:"sex"`
}
// 通过反射机制解析结构体字段中的标签信息。
// 参数 str 应传入结构体实例的指针(因为使用 Elem() 取元素类型)。
func findTag(str interface{}) {
// reflect.TypeOf() 返回的是实际的类型,如果传入的是指针,需要用 Elem() 取出其指向的类型。
t := reflect.TypeOf(str).Elem()
// 遍历结构体的每一个字段
for i := 0; i < t.NumField(); i++ {
// t.Field(i) 取出第 i 个字段的元信息 再通过 Tag.Get() 方法获取指定 key 的标签值
taginfo := t.Field(i).Tag.Get("info")
tagdoc := t.Field(i).Tag.Get("doc")
fmt.Println("info: ", taginfo, " doc: ", tagdoc)
}
}
func main() {
var re resume // 创建一个 resume 实例
findTag(&re) // 传入结构体指针,供反射使用
/*info: name doc: 我的名字
info: sex doc:*/
}
这里为什么使用
Elem()取元素类型就需要传入结构体实例的指针,传入值类型不行吗?先看下
reflect.TypeOf()做了什么:reflect.TypeOf()返回的是 实际传入值的类型,而不是它的底层类型。如果你传入的是值类型,那么它就返回值类型;传入的是指针类型,就返回指针类型。而
Elem()只有在你拿到的是指针类型时,才有意义。它的作用是:取出指针所指向的那个类型。例如:
got2 := reflect.TypeOf(&re) // *resume t3 := t2.Elem() // resume所以 如果我们这里传入的是值类型:它本身不是指针,没有"指向的元素",所以不能再调用
Elem(),否则就会 panic。你可以不传指针,但要看你想要怎么写。如果传入值类型,可以不调用
Elem(),直接跟之前的例子一样使用t := reflect.TypeOf(str)。但通常我们习惯传入指针,是因为很多时候:结构体比较大,传指针效率高;统一处理逻辑(尤其在通用工具函数里);后续可能需要用到
reflect.Value修改字段值,修改值时必须使用指针。
结构体标签在json上的应用
上面介绍了通过反射手动对结构体标签进行解析,那么结构体标签在我们日常应用中又有哪些呢?其实go语言的json库就用到了结构体标签,如下代码所示,看看go语言解析json文件是如何用到结构体标签的。
go
package main
import (
"encoding/json" // go提供了基本的数据编解码的库(encoding)
"fmt"
)
// 定义一个 Movie 结构体,用于描述电影信息。
// 使用 struct tag 来指定每个字段在 JSON 中对应的键名。
// json:"xxx" 这种是encoding/json规定的固定写法
// 例如:Title 对应 JSON 中的 "title" 键。
type Movie struct {
Title string `json:"title"` // 电影名称
Year int `json:"year"` // 上映年份
Price int `json:"rmb"` // 票价 (单位:人民币)
Actors []string `json:"actors"` // 主演列表
}
/*其实标准写法可以写得更完整一点,支持一些额外控制:字段名 类型 `json:"json字段名,选项"`
示例:
type Movie struct {
Title string `json:"title"` // 普通使用
Year int `json:"year,omitempty"` // Year=0 时不输出 即序列化时忽略这个字段
Secret string `json:"-"` // 完全忽略这个字段 不参与序列化和反序列化
}*/
func main() {
// 创建一个 Movie 实例,准备做序列化和反序列化演示
movie := Movie{"喜剧之王", 2000, 10, []string{"xingye", "zhangbozhi"}}
// ===== JSON 编码(序列化)过程:结构体 --> JSON 字符串 =====
// 使用 json.Marshal 将结构体编码为 JSON 字节切片
jsonStr, err := json.Marshal(movie)
if err != nil {
fmt.Println("JSON 序列化失败:", err)
return
}
// jsonData 是 []byte 类型,这里格式化为字符串输出
fmt.Printf("序列化后的 JSON: %s\n", jsonStr)
// 序列化后的 JSON: {"title":"喜剧之王","year":2000,"rmb":10,"actors":["xingye","zhangbozhi"]}
// ===== JSON 解码(反序列化)过程:JSON 字符串 --> 结构体 =====
//jsonStr = {"title":"喜剧之王","year":2000,"rmb":10,"actors":["xingye","zhangbozhi"]}
myMovie := Movie{}
// 将 JSON 字节切片解码回结构体
err = json.Unmarshal(jsonStr, &myMovie)
if err != nil {
fmt.Println("JSON 反序列化失败:", err)
return
}
//fmt.Printf("%v\n", myMovie) // %v默认格式,打印值本身;%+v类似%v,但对于结构体,会额外打印字段名
fmt.Printf("反序列化后的结构体: %+v\n", myMovie)
// 反序列化后的结构体: {Title:喜剧之王 Year:2000 Price:10 Actors:[xingye zhangbozhi]}
}
Go struct tag ≈ Java annotation 的"轻量级版"
Go用简单的字符串做到了类似的元数据功能,靠反射解析,灵活但不如Java注解那么类型安全和强约束。
Golang进阶
groutine协程并发
概念梳理
Go 语言最具代表性的核心特性之一,就是其轻量级用户态协程------Goroutine。在深入理解 goroutine 之前,我们先回顾一下协程的基本概念以及它们解决的并发痛点。
1.单线程与多线程的限制
(1)单线程模型:在早期单核系统中,计算机只能顺序执行单一任务。当遇到 I/O 阻塞时,整个线程只能等待,导致 CPU 空转,浪费了大量计算资源。

(2)多线程/多进程:为提升 CPU 利用率,引入了多线程/多进程并发模型。通过操作系统的时间片轮转机制,多个线程/进程在逻辑上"同时"执行,实则在 CPU 核心间快速切换:
- 优点:当一个线程阻塞时,CPU 可以调度其他线程执行,提升总体利用率。
- 缺点:频繁的上下文切换带来额外开销(保存/恢复寄存器状态、内核态切换、线程栈空间大(通常几 MB)等),尤其在高并发场景下切换成本呈指数增长,影响整体性能。


2.协程模型演进
为降低多线程模型下的切换和调度开销,业界引入了用户态协程(coroutine)模型,其核心思想是将调度逻辑上移到用户态,避开内核态的频繁切换。常见调度模型对比:
-
1:1 模型:每个用户线程绑定一个内核线程,调度仍完全依赖操作系统调度器,无法解决内核态切换开销。
-
N:1 模型:多个用户协程复用一个内核线程,切换由用户态调度器管理,内核无感,极大减少上下文切换开销。但当某个协程执行系统调用或纯阻塞操作时,会阻塞其所在的内核线程,导致所有复用该线程的协程均被阻塞。
-
M:N 模型:M 个内核线程复用 N 个用户协程,调度逻辑由语言运行时管理,能充分利用多核 CPU 并缓解阻塞问题。Go 语言采用的是 M:N 模型,并通过一套自研的调度器设计高效规避了 N:1 模型的典型阻塞痛点:
-
- P 与 M 解耦:当 goroutine 阻塞时,调度器会将逻辑处理器 P 从阻塞的内核线程 M 中分离,并迁移到其他空闲或新建线程继续调度其他 goroutine,避免阻塞扩散。
-
- 非阻塞 I/O 封装:Go 运行时内部将大部分系统调用(如网络、文件 I/O)封装为非阻塞模型,结合内置的网络轮询器(netpoller)机制,在用户态实现高效的 I/O 多路复用。





Go 在实现 goroutine 时,不仅更名为 "Goroutine",更在核心设计上做了优化:
- 内存占用更小:每个 goroutine 栈空间通常只占用几 KB,且支持按需动态扩展,相比传统线程动辄几 MB 的栈空间大幅降低内存压力,进程甚至可能达到几GB。
- 调度开销更低:轻量化特性让调度器可以频繁快速地切换执行 goroutine,整体并发性能得到大幅提升。

Go语言早期的调度器设计存在较大问题。如下图所示:其中 G 表示 Goroutine,M 表示操作系统线程。早期调度器的做法是:假设有一个 4 核 CPU,它维护一个全局队列,所有新创建的 Goroutine 都被加入到这个全局队列中。每个线程(M)在执行时,会先获取全局队列的锁,拿到一个 Goroutine 执行。执行后,其余未执行的 Goroutine 会被移动到队列头,等待下一个线程调度;执行完成的 Goroutine 会被放回队列尾部。整个流程简单粗暴,但存在以下明显缺陷:
-
激烈的锁竞争:创建、销毁和调度 Goroutine 时,所有线程都需获取全局队列的锁,导致频繁的同步与阻塞,性能大幅下降。
-
任务迁移延迟:如果某个 M 在运行 G 时新创建了 G',理论上希望 G' 也在当前 M 上执行以保持数据局部性。但由于使用全局队列,G' 可能被其他 M 取走,增加了切换成本,降低了缓存命中率。
-
系统调用频繁:线程在切换、阻塞与唤醒过程中,频繁进行系统调用,进一步增加了系统开销。

3.Go 的 GMP 模型
Go 采纳 M:N 模型,并引入了 G(goroutine)、M(machine,内核线程)、P(processor,逻辑处理器) 三元结构──GMP 模型:
-
G:轻量级协程,初始栈大小仅几 KB,按需动态增长,内存占用极小。
-
M:操作系统线程,真正执行 goroutine 的载体。
-
P:逻辑处理器,调度器核心单元,,持有本地任务队列(本地 runnable G 列表),决定哪个 G 由哪个 M 执行。P 的数量由
GOMAXPROCS环境变量决定,最多并行(注意是并行而非并发)运行 P 个协程。
此外,还维护一个全局队列用于存放溢出的 goroutine,保证负载均衡。新创建的 goroutine 优先放入其所属 P 的本地队列,若本地队列已满,才会转移到全局队列,确保整体调度平衡。全队列还有一个锁的保护,所以从全队列取东西效率会比较慢一些。


4.Go 调度器的关键策略
Go调度器的设计包含四大核心策略:线程复用、并行利用、抢占机制、全局G队列。下面分别说明:
(1)线程复用(Work Stealing与Hand Off机制)
Go通过复用线程提升调度效率,主要依靠Work Stealing与Hand Off两种机制:
-
Work Stealing(工作窃取)
每个P(Processor)有自己的本地G队列。当某个M(Machine)空闲时,它会从其他 P 的本地队列尾部"窃取"任务,充分提升资源利用率与并行度,避免任务堆积或线程空闲。

-
Hand Off(让渡机制)
当运行中的G发生阻塞(如IO或锁等待),绑定其所在P的M会尝试将P迁移给其他可用的M(新建或唤醒线程),继续执行本地队列中的其他G任务。阻塞的M进入休眠,待阻塞解除后再参与调度。该机制确保阻塞不会影响其他G的执行,最大化CPU利用率。

(2)并行利用
-
通过设置 GOMAXPROCS 控制 P 数量,合理分配 CPU 资源。
-
比如在 8 核 CPU 下,若将 GOMAXPROCS 设为 4,Go 运行时仅会使用 4 核心资源,剩余可供其他系统任务使用,提供良好的资源隔离能力。
(3)抢占机制
传统协程调度依赖协程主动让出CPU,容易导致长时间占用。Go 从 1.14 版本起引入强制抢占机制:每个G最多运行约10ms,无需其主动让出,调度器可强制将CPU分配给其他等待的G。此设计保证了调度公平性和系统响应性,避免某些G长期独占CPU。

(4)全局G队列
在每个P的本地队列之外,Go还维护一个全局G队列作为任务缓冲。新创建的G优先进入本地队列,若本地已满才进入全局队列。空闲的M在本地与其他P的队列均无任务时,最后尝试从全局队列取任务。全局队列的访问需要加锁,相比本地队列性能略低,但作为兜底机制,保障了任务分配的完整性与平衡性。
总结一下Go 调度器的关键策略:
- 1.线程复用(Work Stealing & Hand Off)
-
- 工作窃取:当某个 P 的本地队列空闲时,会从其它 P 窃取可执行的 G,避免某些线程闲置。
-
- P 与 M 分离(Hand Off):当执行中的 G 阻塞(如网络 I/O),调度器会将对应的 P 从当前 M 分离,挂载到其他空闲或新建的 M 上,保持剩余 G 在本地队列不中断执行。
2.并行 通过 GOMAXPROCS 设置 P 的数量,决定最大并行协程数,灵活利用多核 CPU。
3.抢占 Go 从 1.14 起支持协程抢占,当某个 G 占用 CPU 超过一定时间(约 10 ms)或出现函数调用边界时,可强制调度,避免单个 G 长期占用,保证所有 G 的公平执行。
4.本地与全局队列 大部分 G 都存放在 P 的本地队列,只有在本地队列满时才会入全局队列。空闲时优先窃取本地队列,只有在无其他可用 G 时才访问全局队列,降低全局锁竞争。
小结 ------ 为什么 Goroutine 如此高效?
-
低内存开销:初始栈极小,且支持动态伸缩,百万级并发成为可能;
-
高效调度:用户态调度极大减少内核切换次数,整体并发性能远优于传统线程;
-
抢占式公平性:保证调度不会被单个 goroutine 长时间垄断;
-
本地+全局队列:高效的本地队列配合全局队列兜底,确保任务平衡与快速分发;
-
I/O 封装优化:大部分阻塞 I/O 在用户态实现了非阻塞封装,极大缓解系统调用瓶颈。
创建goroutine语法
如下代码所示,通过go关键字创造goroutine
go
package main
import (
"fmt"
"time"
)
// 一个用于演示的子goroutine任务函数,不断地每秒打印当前计数值。
func newTask() {
i := 0
for {
i++
fmt.Printf("new Goroutine : i = %d\n", i) // 其中 %d 表示格式化为十进制整数
time.Sleep(1 * time.Second) // // 通过 time.Sleep 让当前 goroutine 休眠 1 秒钟
}
}
// main 函数是 Go 程序的入口函数,同时它本身就是一个 goroutine(称为主 goroutine)
func main() {
// 通过 go 关键字创建一个新的 goroutine,去异步执行 newTask() 函数
go newTask()
// 此处主 goroutine 继续往下执行,不会等待 newTask 执行结束
fmt.Println("main goroutine exit")
i := 0
for {
i++
// 主 goroutine 也每秒打印一次当前计数值
fmt.Printf("main goroutine: i = %d\n", i)
time.Sleep(1 * time.Second)
}
// 1. 在 Go 语言中,使用 go 关键字可以在运行时动态创建新的 goroutine(轻量级线程)。
// Go 运行时会负责调度多个 goroutine,通常在一个或多个操作系统线程上并发执行。
//
// 2. 主 goroutine 退出时,整个进程随之结束,所有其他子 goroutine 无论是否完成都会被强制终止。
// 因此,如果将上面的 for 循环注释掉,仅执行 fmt.Println 后主函数直接退出,
// 那么子 goroutine newTask 也无法执行或只执行极短时间后被终止。
//
// 3. 在实际项目中,如果希望主 goroutine 等待其他 goroutine 执行结束,可以使用 sync.WaitGroup、
// channel 或 context 等机制来实现 goroutine 之间的同步与协调。
}
实际上在承载一个go程的时候不一定要把go程写为一个定义好的函数,我们直接写一个匿名函数去加载也可以,这里演示一下:
go
package main
import (
"fmt"
"runtime"
"time"
)
// 本示例主要演示了在 Go 语言中:
// 1. 使用匿名函数(函数字面量)直接创建 goroutine;
// 2. 使用 runtime.Goexit() 退出当前 goroutine;
// 3. 说明 goroutine 函数中无法直接返回值给调用者。
func main() {
// 使用 go 关键字创建 goroutine,并在其中定义并调用匿名函数(没有参数和返回值)
go func() {
defer fmt.Println("A.defer") // 延迟执行,在当前匿名函数退出时执行
// 内层匿名函数
func() {
defer fmt.Println("B.defer") // 延迟执行,在当前匿名函数退出时执行
// 如何在go程中退出当前goroutine? 用runtime.Goexit()
// runtime.Goexit() 用于立即终止当前 goroutine 的执行。
// 注意:它只终止当前 goroutine,不会影响其他 goroutine,包括主 goroutine。
// 此外,它在退出时仍会调用所有已注册的 defer 函数(类似于正常退出时的清理逻辑)。
// 因此 "B.defer" 会被打印,而 "B" 不会被打印。
// 注意如果这里是用return的话 只是退出了当前函数调用栈帧 "A"仍会被打印
runtime.Goexit()
// 由于上面调用了 Goexit(),所以下面这句不会被执行:
fmt.Println("B")
}() // 如果只是写这个函数,就只是定义了但没被调用,加个()等于我定义了这么一个函数,同时调用起来
// 调用时我们没有传递任何参数,因为这里的函数定义就没有任何参数
// 由于外层 goroutine 也被 Goexit() 终止了,因此这句也不会被执行:
fmt.Println("A")
// runtime.Goexit() 并不是像 return 那样只退出当前函数调用栈帧,
// 它直接终止整个当前 goroutine,跳出所有调用栈,当然 defer 仍然会执行。
}()
// 使用匿名函数创建并立即调用带参数的 goroutine
go func(a int, b int) bool {
fmt.Println("a = ", a, ", b = ", b)
return true
}(10, 20) // 这里匿名函数定义后立刻通过()调用,并传入参数 10 和 20
// 即使匿名函数有返回值 (bool),但由于 goroutine 是并发执行的,无法通过 return 直接获取结果
/* 补充说明:
- Go 语言中不支持像 flag := go func() bool {...}() 这样的语法,
因为 go 关键字启动的 goroutine 是异步执行的,其返回值不会传递回主 goroutine。
- goroutine 之间默认无法返回值或传递数据,若要实现结果返回或通信,
需要借助 channel、sync 包或 context 机制来实现同步与通信。
*/
// 死循环用于防止 main goroutine 提前退出,确保前面创建的 goroutine 有机会执行完毕
for {
time.Sleep(1 * time.Second)
}
}
在 Go 语言中,main 函数的退出意味着整个程序的结束。所以如果 main 函数提前退出,所有未执行完的子 goroutine 会立即被强制终止。在实际应用中,通常不建议用死循环阻塞主 goroutine,可以使用 sync.WaitGroup 更优雅地等待子 goroutine 结束。这里写一份goroutine + WaitGroup 基础通用模板:
go
package main
import (
"fmt"
"sync"
"time"
)
// 子任务函数:可以传参,支持 defer、panic 恢复等
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 每启动一个 goroutine,结束时必须调用 Done()
// panic 保护(可选,但建议加上,避免单个 goroutine 崩溃导致全局异常)
defer func() {
if err := recover(); err != nil {
fmt.Printf("Worker %d recovered from panic: %v\n", id, err)
}
}()
fmt.Printf("Worker %d start\n", id)
// 模拟任务执行时间
time.Sleep(time.Duration(id) * time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
numWorkers := 5 // 启动 5 个并发任务
for i := 1; i <= numWorkers; i++ {
wg.Add(1) // 每个任务启动前,先增加计数
go worker(i, &wg)
}
// 阻塞等待所有子 goroutine 完成
wg.Wait()
fmt.Println("所有任务执行完毕,主程序退出")
}
wg.Add(1): 每个 goroutine 启动前,先登记 1 个待完成任务
defer wg.Done(): 每个 goroutine 执行完后自动减一,防止漏掉
recover():捕获 panic,避免整个程序因某个 goroutine 崩溃
wg.Wait(): 阻塞主 goroutine,直到所有登记的任务完成
time.Sleep(): 模拟任务处理时间,实际可替换成任何逻辑
channel实现goroutine之间通信
channel是Go语言中的一个核心数据类型,可以把它看成管道,,主要用来解决go程的同步问题以及go程之间数据共享(数据传递)的问题。并发核心单元通过它就可以发送或者接收数据进行通讯,这在一定程度上又进一步降低了编程的难度。
goroutine运行在相同的地址空间,因此访问共享内存必须做好同步。goroutine 奉行通过通信来共享内存,而不是共享内存来通信。
下面我们学习一下channel的基本用法:
go
package main
import "fmt"
func main() {
// 定义一个 channel,用于传递 int 类型的数据。
// 这里使用的是无缓冲(unbuffered)channel:只能同时存放一个数据。
// 当向无缓冲 channel 发送数据时,发送操作会阻塞直到有其他 goroutine 从 channel 中接收数据。
c := make(chan int)
// 启动一个新的 goroutine(协程,相当于一个轻量级线程)。
// channel 通常用于多个 goroutine 之间的通信,这里就是 main goroutine 和新开启的 goroutine 之间的通信。
go func() {
// 在函数退出时输出一句话,表明这个 goroutine 结束了
defer fmt.Println("goroutine结束")
fmt.Println("goroutine 正在运行...")
// 向 channel 中发送数据:666
// 发送操作:c <- 666
// 因为 channel 是无缓冲的,如果 main goroutine 没有准备好接收数据,发送操作会阻塞在这里
c <- 666 //将666发送给c 这个是发送的语法
}()
// 从 channel 中接收数据:<-c
// 这个接收操作会阻塞,直到有数据被发送到 channel 中
// 接收到的数据赋值给变量 num
num := <-c //从c中接受数据,并赋值给num 这个是接收的语法
// - <-c 是接收操作,把 channel 中的数据取出
// - <-c 也可以单独写成:<-c 只取出数据而不保存(丢弃)
// 例如: <-c // 取出数据但不保存任何变量中,数据被丢弃
fmt.Println("num = ", num) // num = 666
fmt.Println("main goroutine 结束...")
}
这里因为使用的是无缓冲channel,当向无缓冲 channel 发送数据时,发送操作会阻塞直到有其他 goroutine 从 channel 中接收数据,接收操作会阻塞,直到有数据被发送到 channel 中。
在 Go 语言中,channel 分为无缓冲 (unbuffered)和有缓冲(buffered)两种。
-
无缓冲 channel:
-
发送和接收必须同步进行。
-
发送操作会阻塞,直到有接收者从 channel 中取走数据;接收操作也会阻塞,直到有发送者发送数据。
-
适用于需要确保发送方与接收方同步的场景,常用于协程之间的同步控制。
-
-
有缓冲 channel:
-
在内部有一个有限的缓冲区,可以容纳一定数量的元素。
-
发送操作在缓冲未满时不会阻塞;只有当缓冲区满时才会阻塞发送方。
-
接收操作在缓冲非空时不会阻塞;只有当缓冲区为空时才会阻塞接收方。
-
适用于发送和接收速度不完全匹配的场景,可以提升一定的并发性能和吞吐能力。
-
-
简单来说:无缓冲更偏向同步 ,有缓冲更偏向异步。
下面我们测试一下有缓冲channel的效果:
go
package main
import (
"fmt"
"time"
)
func main() {
// 创建一个带缓冲区的 channel,类型为 int,缓冲区大小为 3。
// 这意味着最多可以缓存 3 个尚未被接收的元素。
c := make(chan int, 3)
// 打印当前 channel 的长度和容量:
// len(c): 当前缓冲区中已有的数据个数(初始为 0)
// cap(c): 缓冲区总容量(此处为 3)
fmt.Println("len(c) = ", len(c), ", cap(c)", cap(c)) // 输出: len(c) = 0 , cap(c) = 3
// 启动一个新的 goroutine 来向 channel 中发送数据
go func() {
defer fmt.Println("子go程结束") // 在函数结束时自动打印,标记子 goroutine 结束
// 循环向 channel 中发送 4 个整数(注意:发送次数 > 缓冲区容量)
for i := 0; i < 4; i++ {
c <- i // 发送数据到 channel
fmt.Println("子go程正在运行, 发送的元素=", i, " len(c)=", len(c), ", cap(c)=", cap(c))
}
}()
// 主 goroutine 休眠 2 秒,确保子 goroutine 有时间执行发送操作
// 这只是为了演示方便,实际中应使用同步机制(如 wait group)
time.Sleep(2 * time.Second)
// 从 channel 中依次取出 4 个元素(注意:实际发送了 4 个元素)
for i := 0; i < 4; i++ {
num := <-c //从c中接收数据,并赋值给num
fmt.Println("num = ", num)
}
fmt.Println("main 结束")
}
运行结果为:
len© = 0 , cap© 3
子go程正在运行, 发送的元素= 0 len©= 1 , cap©= 3
子go程正在运行, 发送的元素= 1 len©= 2 , cap©= 3
子go程正在运行, 发送的元素= 2 len©= 3 , cap©= 3
num = 0
num = 1
num = 2
num = 3
main 结束
一开始 len© 是 0,因为还没有任何数据发送到 channel。
子 goroutine 发送前 3 个元素时:因为缓冲区容量为 3,每次发送成功后,缓冲区长度 len© 依次变为 1、2、3。此时发送都是非阻塞的(因为缓冲区未满)。
当尝试发送第 4 个元素(i=3)时:缓冲区已满,发送操作阻塞,直到主 goroutine 从 channel 中读取数据,腾出空间。由于主 goroutine 在 time.Sleep 中睡眠,子 goroutine 此时会卡在 c <- i 第 4 次发送这里,等待空间腾出。
睡眠结束后,主 goroutine 依次从 channel 中读取 4 个数据:前 3 个立即取出缓冲区中的数据(0、1、2)。取出第 3 个数据时,缓冲区变为不满,子 goroutine 解除阻塞,成功发送最后一个元素 3。主 goroutine 继续取出最后一个数据 3。
所有数据接收完成后,程序结束。
介绍了有缓冲和无缓冲channel的基本定义与使用后,我们再来看看channel的关闭特点:
go
package main
import "fmt"
// 在Go语言中,channel不像文件那样需要频繁关闭;通常只有以下两种情况需要关闭:
// 1. 确定不再向channel发送任何数据了(即:发送方完成了全部发送任务)。
// 2. 想要通过关闭channel通知接收方,配合range、for-select等结构优雅退出。
// 注意:关闭channel只是禁止继续发送数据(引发panic错误后导致接收立即返回零值);
// 而接收数据仍然是允许的,直到channel被完全读空。
// 另外:nil channel(值为nil的channel)在收发操作时都会永久阻塞。
func main() {
c := make(chan int) // 创建一个无缓冲的整型channel,类型为chan int
go func() { // 启动一个匿名goroutine作为发送者
for i := 0; i < 5; i++ { // 向channel中发送5个整数:0到4
c <- i // 向channel发送数据,若没有接收方则会阻塞
//close(c) // 注意:如果在这里关闭channel,将在第一次发送后关闭,再发送时panic!
}
//close可以关闭一个channel
close(c) // 循环发送完所有数据后,关闭channel,通知接收方:不会再有新的数据发送进来了
}()
for { // 启动主goroutine作为接收者
// 这里使用了逗号ok的惯用写法:data接收从channel读取的数据
// ok为布尔值,若channel未关闭或还有数据,ok为true;当channel关闭且数据读完后,ok返回false
if data, ok := <-c; ok { // channel仍然有数据可以读取
fmt.Println(data)
} else { // channel已关闭且数据读完,退出循环
break
}
}
fmt.Println("Main Finished..")
// 如果不在子程里调用close(c) 或不在子goroutine里发送数据
// 如果不在子goroutine里发送数据,而直接在主goroutine中执行接收
// 由于主goroutine会阻塞在 <-c ,而没有其他goroutine发送数据,最终会导致:
// fatal error: all goroutines are asleep - deadlock
// 这是因为Go运行时检测到了所有goroutine都阻塞,程序无法继续执行,因此直接panic报死锁。
}
这里
if data, ok := <-c; ok {里面有个分号,这是 Go 语言里 "if 语句支持短变量声明" 的语法,在 Go 里,if 语句可以有两部分:
goif 简短变量声明; 条件判断 { // ... }也就是说:分号 ; 把变量声明和条件判断隔开。if 语句执行时,先执行分号前面的短变量声明(这里是 `data, ok := <-c`),然后判断分号后面的条件(这里是 `ok`)。这句代码拆开理解就是:
godata, ok := <-c // 从channel接收数据,同时判断channel是否已关闭 if ok { fmt.Println(data) }但是因为 Go 允许你把声明写在 if 里,就可以缩写成一行
channel与range、select
下面我们再看一下channel跟两个比较特殊的关键字的配合使用
channel与range
go
package main
import "fmt"
func main() {
c := make(chan int) // 创建一个无缓冲的整型channel,类型为chan int
go func() { // 启动一个匿名goroutine作为发送者
for i := 0; i < 5; i++ { // 向channel中连续发送5个整数:0到4
c <- i // 发送数据到channel,若无接收方会阻塞等待
}
// 发送完所有数据后,关闭channel,关闭channel的作用是通知接收方:不会再有新的数据了
close(c)
}()
// =================== 之前写法(手动 for + ok 检查) ===================
/* for { // 启动主goroutine作为接收者
// 这里使用了逗号ok的惯用写法:data接收从channel读取的数据
// ok为布尔值,若channel未关闭或还有数据,ok为true;当channel关闭且数据读完后,ok返回false
if data, ok := <-c; ok { // channel仍然有数据可以读取
fmt.Println(data)
} else { // channel已关闭且数据读完,退出循环
break
}
}*/
// =================== 更简洁的写法:使用range迭代channel ===================
// 使用range可以自动从channel中不断接收数据,直到channel被关闭且数据读空后自动退出
// 注意:只有关闭了channel,range才能正常结束,否则会一直阻塞等待新数据
for data := range c {
fmt.Println(data)
}
// 本质上两种代码逻辑一样,但写法不同。
fmt.Println("Main Finished..")
// 总结:
// 1. for + ok 写法:更通用,能灵活处理接收结果、区分接收失败(例如关闭时返回零值和ok=false)
// 2. range 写法:语法更简洁,适用于简单读取全部channel数据直到关闭
// 3. 不管哪种写法,关闭channel后都无法再向其中发送数据,否则panic
// 4. 未关闭channel时,range会一直阻塞等待,容易导致程序卡死(死锁)
}
channel与select
单流程下一个go只能监控一个channel的状态,select可以完成监控多个channel的状态:

go
package main
import "fmt"
// 定义一个生成斐波那契数列的函数,使用channel与select控制流程
func fibonacii(c, quit chan int) {
x, y := 1, 1 // 斐波那契数列的前两个数
for {
select {
// select语句可以同时监听多个channel的通信状态
// 当某个case对应的channel准备好后(发送/接收不再阻塞),select就会执行对应的case
case c <- x:
// 当c可写时(即:有人在接收c的数据时),就会进入这个case
// 把当前的x发送到channel c中
// 然后计算下一个斐波那契数
x = y
y = x + y
case <-quit:
// 当从quit channel中接收到数据时(不关心数据内容,所以直接用<-quit)
// 表示收到停止信号,打印"quit",退出函数
fmt.Println("quit")
return // return,当前goroutine结束
}
}
}
func main() {
// 创建两个无缓冲channel:
// c 用于传递斐波那契数列数据
// quit 用于通知fibonacci函数何时退出
c := make(chan int)
quit := make(chan int)
// 启动一个子goroutine负责消费fibonacci生成的数列数据
go func() {
for i := 0; i < 10; i++ {
// 每次从c中接收一个数据并打印
fmt.Println(<-c)
}
// 接收完10个数据后,通知fibonacci函数可以停止了
quit <- 0
}()
// 主goroutine调用fibonacci函数,开始生成数据
// 注意:该函数内是一个无限循环,直到收到quit信号才会退出
fibonacii(c, quit)
}
用你更熟悉的
Java switch来对比着帮你彻底讲清楚:一句话总结:Go 的
select每次执行时,先扫描所有 case 中的 channel,如果有一个或多个可以立即执行的,就随机选择其中一个执行(注意:真的随机,不是顺序!);一旦选定执行一个 case,本轮 select 立即结束,不会执行其他 case。如果没有任何 case 满足条件:如果有default,则直接执行 default;如果没有 default,则整个 select 阻塞等待,直到至少有一个 case 满足条件。注意:只在所有case都无法执行时才会进入default。
每次 select 执行一轮:
+-----------------------------+
| 检查每个 case 是否 ready |
+-----------------------------+
↓
有多个ready? ------→ 是 ------→ 随机选1个执行
↓
否
↓
是否有default? ------→ 有 ------→ 执行default
↓
没有
↓
阻塞等待
补充一点底层:
Go select 底层其实和调度器有关:Go runtime 会维护一个 goroutine 等待队列;
每当执行 select,实际上在 runtime 层面做了一次channel 状态 polling(检测收发是否能立即完成);
只要有任意一个 channel ready,就从 ready set 里随机取一个执行;
所以它既像"非阻塞的多路复用器",也像是轻量的"并发调度器"------这也是为什么 Go select 很适合用来做高性能并发通信控制的原因。
GoModules
Go Modules与GOPATH
1.什么是Go Modules?
Go modules 是 Go 语言官方推荐的依赖管理工具,自 Go 1.11 引入,Go 1.13 后功能基本完善,在 Go 1.16 开始默认启用,完全取代了早期的 GOPATH 模式。
在 Go 1.11 之前,Go 一直依赖 GOPATH 进行代码组织和依赖管理,但存在诸多痛点:
- 缺乏版本控制机制;
- 不便于多个项目管理不同版本依赖;
- 无法轻松复现项目依赖环境;
- 不支持私有模块、镜像代理、校验等高级功能。
Go modules 彻底解决了这些问题,成为 Go 语言现代化开发的标配。
2.GOPATH的工作模式
Go Modoules的目的之一就是淘汰GOPATH, 那么GOPATH是个什么?为什么不再推荐 GOPATH 的模式了呢?
(1) What is GOPATH?
bash
$ go env
GOPATH="/home/itheima/go"
...
我们输入go env命令行后可以查看到 GOPATH 变量的结果,我们进入到该目录下进行查看,如下:
go
go
├── bin # 可执行文件
├── pkg # 预编译缓存
└── src # 所有源码(项目 & 第三方库)
├── github.com
├── golang.org
├── google.golang.org
├── gopkg.in
....
GOPATH目录下一共包含了三个子目录,分别是:
- bin:存储所编译生成的二进制文件。
- pkg:存储预编译的目标文件,以加快程序的后续编译速度。
- src:存储所有.go文件或源代码。在编写 Go 应用程序,程序包和库时,一般会以
$GOPATH/src/github.com/foo/bar的路径进行存放。
因此在使用 GOPATH 模式下,我们需要将应用代码存放在固定的$GOPATH/src目录下,并且如果执行go get来拉取外部依赖会自动下载并安装到$GOPATH目录下。
(2) GOPATH模式的弊端
在 GOPATH 的 $GOPATH/src 下进行 .go 文件或源代码的存储,我们可以称其为 GOPATH 的模式,这个模式拥有一些弊端:
-
没有版本控制:go get 无法指定具体版本,只能拉取最新。
-
依赖不可复现:团队成员很难保持依赖版本一致。
-
无法支持模块多版本共存:如 v1/v2 无法同时存在,容易出现包冲突。
Go Modules模式
我们接下来用Go Modules的方式创建一个项目, 建议为了与GOPATH分开,不要将项目创建在$GOPATH/src下.
(1) 常用go mod命令
| 命令 | 作用 |
|---|---|
| go mod init | 初始化模块,生成 go.mod 文件 |
| go mod download | 下载 go.mod 中声明的依赖 |
| go mod tidy | 整理依赖、清理未使用的依赖 |
| go mod graph | 查看现有的依赖结构 |
| go mod edit | 编辑 go.mod 文件 |
| go mod vendor | 导出项目所有的依赖到vendor目录(依赖本地化) |
| go mod verify | 校验依赖完整性,校验一个模块是否被篡改过 |
| go mod why | 查看某依赖为何被引用 |
可以通go mod help查看学习这些指令,强烈建议多用 go mod tidy,随时清理无效依赖,保持 go.mod & go.sum 干净整洁。
(2) go mod环境变量
可以通过 go env 命令来进行查看:
$ go env
GO111MODULE="auto"
GOPROXY="https://proxy.golang.org,direct"
GONOPROXY=""
GOSUMDB="sum.golang.org"
GONOSUMDB=""
GOPRIVATE=""
...
GO111MODULE
Go语言提供了 GO111MODULE这个环境变量来作为 Go modules 的开关,(Go 1.16 及以后默认已废弃该变量,默认就是on),其允许设置以下参数:
- auto:在含有 go.mod 时启用,目前在 Go1.11 至 Go1.14 中仍然是默认值。
- on:始终启用 Go modules(推荐),未来版本中的默认值。
- off:全禁用 Go modules(不推荐)。
可以通过下面的命令来设置:
bash
$ go env -w GO111MODULE=on
GOPROXY
这个环境变量主要是用于设置 Go 模块代理(Go module proxy),其作用是用于使 Go 在后续拉取模块版本时直接通过镜像站点来快速拉取。
GOPROXY 的默认值是:https://proxy.golang.org,direct
proxy.golang.org国内访问不了,需要设置国内的代理
阿里云:https://mirrors.aliyun.com/goproxy/
七牛云: https://goproxy.cn,direct
如:
bash
$ go env -w GOPROXY=https://goproxy.cn,direct
GOPROXY 的值是一个以英文逗号 "," 分割的 Go 模块代理列表,允许设置多个模块代理,假设你不想使用,也可以将其设置为 "off" ,这将会禁止 Go 在后续操作中使用任何 Go 模块代理。
设置多个模块代理:
bash
$ go env -w GOPROXY=https://goproxy.cn,https://mirrors.aliyun.com/goproxy/,direct
而在刚刚设置的值中,我们可以发现值列表中有 "direct" 标识,它又有什么作用呢?
实际上 "direct" 是一个特殊指示符,用于指示 Go 回源到模块版本的源地址去抓取(比如 GitHub 等),场景如下:当值列表中上一个 Go 模块代理返回 404 或 410 错误时,Go 自动尝试列表中的下一个,遇见 "direct" 时回源,也就是回到源地址去抓取,而遇见 EOF 时终止并抛出类似 "invalid version: unknown revision..." 的错误。
GOSUMDB
它的值是一个 Go checksum database,用于在拉取模块版本时(无论是从源站拉取还是通过 Go module proxy 拉取)保证拉取到的模块版本数据未经过篡改,若发现不一致,也就是可能存在篡改,将会立即中止。
GOSUMDB 的默认值为:sum.golang.org,在国内也是无法访问的,但是 GOSUMDB 可以被 Go 模块代理所代理,即GOPROXY默认充当这个网站。
因此我们可以通过设置 GOPROXY 来解决,而先前我们所设置的模块代理 goproxy.cn 就能支持代理 sum.golang.org,所以这一个问题在设置 GOPROXY 后,你可以不需要过度关心。
另外若对 GOSUMDB 的值有自定义需求,其支持如下格式:
- 格式 1:
<SUMDB_NAME>+<PUBLIC_KEY> - 格式 2:
<SUMDB_NAME>+<PUBLIC_KEY> <SUMDB_URL>
也可以将其设置为"off",也就是禁止 Go 在后续操作中校验模块版本,不推荐。
GONOPROXY/GONOSUMDB/GOPRIVATE
这三个环境变量都是用在当前项目依赖了私有模块,例如像是你公司的私有 git 仓库,又或是 github 中的私有库,都是属于私有模块,都是要进行设置的,否则会拉取失败。
更细致来讲,就是依赖了由 GOPROXY 指定的 Go 模块代理或由 GOSUMDB 指定 Go checksum database 都无法访问到的模块时的场景。
而一般建议直接设置 GOPRIVATE,它的值将作为 GONOPROXY 和 GONOSUMDB 的默认值,所以建议的最佳姿势是直接使用 GOPRIVATE。
并且它们的值都是一个以英文逗号 "," 分割的模块路径前缀,也就是可以设置多个,例如:
bash
$ go env -w GOPRIVATE="git.example.com,github.com/eddycjy/mquote"
如果不想每次都重新设置,还支持通配符:
bash
$ go env -w GOPRIVATE="*.example.com"
设置后,后缀为 .example.com 的模块都会被认为是私有模块,都不会经过GOPROXY并经过GOSUMDB检验。需要注意的是不包括 example.com 本身
用Go Modules初始化项目
(1) 开启Go Modules
bash
$ go env -w GO111MODULE=on
又或是可以通过直接设置系统环境变量(写入对应的~/.bash_profile 文件亦可)来实现这个目的:
bash
$ export GO111MODULE=on
(2) 初始化项目
创建项目目录
bash
$ mkdir -p $HOME/aceld/modules_test
$ cd $HOME/aceld/modules_test
我们后面会在modules_test下写代码,首先要执行Go modules 初始化的工作,如下所示,会在本地创建一个go.mod文件。go mod init后面要跟一个当前模块的名称,这个名称是自定义写的,这个名称他决定于今后导包的时候,即其他人import的时候怎么写
bash
$ go mod init github.com/aceld/modules_test
go: creating new go.mod: module github.com/aceld/modules_test
生成的 go.mod:
go
module github.com/aceld/modules_test
go 1.14
在执行 go mod init 命令时,我们指定了模块导入路径为 github.com/aceld/modules_test。接下来我们在该项目根目录下创建 main.go 文件,如下:
go
package main
import (
"fmt"
"github.com/aceld/zinx/znet"
"github.com/aceld/zinx/ziface"
)
//ping test 自定义路由
type PingRouter struct {
znet.BaseRouter
}
//Ping Handle
func (this *PingRouter) Handle(request ziface.IRequest) {
//先读取客户端的数据
fmt.Println("recv from client : msgId=", request.GetMsgID(),
", data=", string(request.GetData()))
//再回写ping...ping...ping
err := request.GetConnection().SendBuffMsg(0, []byte("ping...ping...ping"))
if err != nil {
fmt.Println(err)
}
}
func main() {
//1 创建一个server句柄
s := znet.NewServer()
//2 配置路由
s.AddRouter(0, &PingRouter{})
//3 开启服务
s.Serve()
}
OK, 我们先不要关注代码本身,我们看当前的main.go也就是我们的aceld/modules_test项目,是依赖一个叫github.com/aceld/zinx库的. znet和ziface只是zinx的两个模块.
明显我们的项目没有下载刚才代码中导入的那互联网上的两个包,我们只是import导入进来了,如果是之前GOPATH模式的话,应该去GOPATH下的src/git/github.com/aceld去go get下来,或者直接手动下载放在指定目录。
但是我们现在是Go Modules,接下来我们在$HOME/aceld/modules_test,本项目的根目录执行下面的命令,假设我们用到了znet包:
bash
$ go get github.com/aceld/zinx/znet
go: downloading github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100
go: found github.com/aceld/zinx/znet in github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100
还有go get github.com/aceld/zinx/ziface,当然你也可以直接把整个模块下载下来:go get github.com/aceld/zinx
这样就会帮我们把代码下载下来了,我们会看到 我们的go.mod被修改,同时多了一个go.sum文件。同时go run main.go也能运行了。
(3) 查看go.mod文件
$HOME/aceld/modules_test/go.mod:
go
module github.com/aceld/modules_test
go 1.14
require github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100 // indirect
发现多了一段require,表示项目需要一个库github.com/aceld/zinx,版本是v0.0.0-20200221135252-8a8954e75100
我们来简单看一下这里面的关键字
module: 用于定义当前项目的模块路径/模块名称,建议填写仓库实际地址go:标识当前Go版本.即初始化版本require: 列出所有直接和间接依赖模块版本// indirect: 示该模块为间接依赖,也就是在当前应用程序中的 import 语句中,并没有发现这个模块的明确引用,有可能是你先手动 go get 拉取下来的,也有可能是你所依赖的模块所依赖的.我们的代码很明显是依赖的"github.com/aceld/zinx/znet"和"github.com/aceld/zinx/ziface",所以就间接的依赖了github.com/aceld/zinx
(4) 查看go.sum文件
在第一次拉取模块依赖后,会发现多出了一个 go.sum 文件,其详细罗列了当前项目直接或间接依赖的所有模块版本,并写明了那些模块版本的 SHA-256 哈希值以备 Go 在今后的操作中保证项目所依赖的那些模块版本不会被篡改。
bash
github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100 h1:Ez5iM6cKGMtqvIJ8nvR9h74Ln8FvFDgfb7bJIbrKv54=
github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100/go.mod h1:bMiERrPdR8FzpBOo86nhWWmeHJ1cCaqVvWKCGcDVJ5M=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
我们可以看到一个模块路径可能有如下两种:
h1:hash情况
github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100 h1:Ez5iM6cKGMtqvIJ8nvR9h74Ln8FvFDgfb7bJIbrKv54=
go.mod hash情况:
github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100/go.mod h1:bMiERrPdR8FzpBOo86nhWWmeHJ1cCaqVvWKCGcDVJ5M=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
h1 hash 是 Go modules 将目标模块版本的 zip 文件开包后,针对所有包内文件依次进行 hash,然后再把它们的 hash 结果按照固定格式和算法组成总的 hash 值。
而go.mod hash顾名思义就是对mod文件做一次hash。而 h1 hash 和 go.mod hash 两者,要不就是同时存在,要不就是只存在 go.mod hash。那什么情况下会不存在 h1 hash 呢,就是当 Go 认为肯定用不到某个模块版本的时候就会省略它的 h1 hash,就会出现不存在 h1 hash,只存在 go.mod hash 的情况。
那我们刚刚go get的文件下载到哪了呢?其实是给我们下载到了$GOPATH/pkg/mod/github.com/aceld下面,这样我
修改模块的版本依赖关系
为了作尝试,假定我们现在对zinx版本作了升级, 由zinx v0.0.0-20200221135252-8a8954e75100 升级到 zinx v0.0.0-20200306023939-bc416543ae24 (注意zinx是一个没有打版本tag打第三方库,如果有的版本号是有tag的,那么可以直接对应v后面的版本号即可)
那么,我们是怎么知道zinx做了升级呢, 我们又是如何知道的最新的zinx版本号是多少呢?
先回到$HOME/aceld/modules_test,本项目的根目录执行:
bash
$ go get github.com/aceld/zinx/znet
go: downloading github.com/aceld/zinx v0.0.0-20200306023939-bc416543ae24
go: found github.com/aceld/zinx/znet in github.com/aceld/zinx v0.0.0-20200306023939-bc416543ae24
go: github.com/aceld/zinx upgrade => v0.0.0-20200306023939-bc416543ae24
这样我们,下载了最新的zinx, 版本是v0.0.0-20200306023939-bc416543ae24, 然后,我们看一下go.mod
go
module github.com/aceld/modules_test
go 1.14
require github.com/aceld/zinx v0.0.0-20200306023939-bc416543ae24 // indirect
我们会看到,当我们执行go get 的时候, 会自动的将本地将当前项目的require更新了.变成了最新的依赖.
好了, 现在我们就要做另外一件事,就是,我们想用一个旧版本的zinx. 来修改当前zinx模块的依赖版本号.
目前我们在$GOPATH/pkg/mod/github.com/aceld(可以理解为本地仓库)下,已经有了两个版本的zinx库:
bash
/go/pkg/mod/github.com/aceld$ ls
zinx@v0.0.0-20200221135252-8a8954e75100
zinx@v0.0.0-20200306023939-bc416543ae24
目前,我们/aceld/modules_test依赖的是zinx@v0.0.0-20200306023939-bc416543ae24 这个是最新版, 我们要改成之前的版本zinx@v0.0.0-20200306023939-bc416543ae24.
回到/aceld/modules_test项目目录下,执行:
bash
$ go mod edit -replace=zinx@v0.0.0-20200306023939-bc416543ae24=zinx@v0.0.0-20200221135252-8a8954e75100
然后我们打开go.mod查看一下:
go
module github.com/aceld/modules_test
go 1.14
require github.com/aceld/zinx v0.0.0-20200306023939-bc416543ae24 // indirect
replace zinx v0.0.0-20200306023939-bc416543ae24 => zinx v0.0.0-20200221135252-8a8954e75100
这里出现了replace关键字.用于将一个模块版本替换为另外一个模块版本。
replace和直接修改require的区别: 直接改require版本是可行的,前提是该版本能被正常下载;而replace不仅可以指定版本,也可以把模块替换到本地路径或 fork 地址,功能更强,适合调试/开发/本地模块。
Go Modules 版本号规范
Go Modules 遵循 语义化版本(Semantic Versioning,SemVer) 标准。
1.基本的语义化版本规则
SemVer 格式为:vMAJOR.MINOR.PATCH,如:v1.2.3
-
MAJOR(主版本号):发生不兼容 API 修改时递增;
-
MINOR(次版本号):向后兼容的新功能递增;
-
PATCH(修订号):向后兼容的问题修正递增。
例如:
| 版本号 | 说明 |
|---|---|
| v1.0.0 | 稳定版本发布 |
| v1.2.0 | 增加了新功能,兼容老版本 |
| v1.2.3 | 修复了某个 bug,兼容老版本 |
| v2.0.0 | 存在破坏性改动,不兼容老版本 |
2.Go Modules 对 MAJOR 版本的特殊处理
Go Modules 在处理 主版本号 v2 及以上 时,有额外要求:主版本号 v2 及以上,必须在模块路径中加入版本后缀。
例如,假设你有一个库:仓库地址: github.com/foo/bar;当前版本: v1.5.0
当你要发布 v2.0.0 时,模块路径需修改为:module github.com/foo/bar/v2
否则,在使用时会导致依赖拉取异常或不兼容的问题。
bash
# 例子
# v1 版本 module 路径
module github.com/foo/bar
# v2 版本及以上 module 路径
module github.com/foo/bar/v2
这种设计的好处:保持对旧版本的兼容性;明确标识重大版本分支;避免不同版本冲突。
实践建议:升级到 v2+ 时,务必修改 go.mod 中的 module 路径;发布新版本时,在 Git 中打上对应 tag,例如:v2.0.0;消费方导入时需使用完整路径:
bash
import "github.com/foo/bar/v2/mypkg"
切勿随意跳过版本号规范,否则会导致下游依赖管理困难,尤其在企业内部的库管理中尤为重要。
vendor 模式实践
1.什么是 vendor 模式?
Go Modules 默认采用 proxy 模式 拉取依赖。但在某些场景下,vendor 模式更适合:企业内网,无法访问公网;离线部署,无法实时拉取依赖;安全审计,依赖需提前锁定;持续集成(CI/CD),确保构建稳定性。
vendor 模式即将所有依赖源码复制到本地的 vendor/ 目录中,构建时直接从本地依赖目录读取,无需访问外部网络。
2.如何启用 vendor 模式
生成 vendor 目录:go mod vendor
执行后,会将 go.mod 和 go.sum 中声明的依赖下载并复制到项目下的 vendor/ 目录。
强制使用 vendor 编译:go build -mod=vendor 或者:GOFLAGS=-mod=vendor go build
测试时也可指定使用 vendor:go test -mod=vendor ./...
日常开发中,启用全局 vendor,可在项目根目录设置环境变量:export GOFLAGS=-mod=vendor,这样执行所有 go 命令时,默认启用 vendor 模式。
3.vendor 模式的优缺点
| 优点 | 缺点 |
|---|---|
| 离线构建、部署更可靠 | 占用磁盘空间 |
| 防止依赖失效、仓库被删 | 需手动维护同步 |
| 方便代码安全审计 | 依赖更新需重新执行 go mod vendor |
| 加速 CI/CD 构建 | -- |
4.实践建议
-
建议在企业内网、私有部署等稳定环境下使用 vendor;
-
建议将 vendor/ 目录纳入版本控制(如 Git);
-
每次更新依赖后,务必重新执行 go mod vendor,确保同步;
-
日常开发中,仍可在本地使用默认的 module 模式,避免频繁维护 vendor。