当前位置: 首页 > news >正文

转Go学习笔记

目录

  • 引言
    • 开发环境与IDE
    • Golang特性简介-优势及缺陷
    • Hello world
  • Golang语法入门
    • 变量的声明
    • 基本数据类型
    • 常量
    • 函数
      • init函数与import
    • 值传递与引用传递;指针
    • defer与延迟函数
      • panic 与 recover:异常处理机制
    • slice与map
      • 数组
      • 切片slice
      • map
    • 面向对象语法特征-struct、封装、继承、多态
      • 通用类型interface{}与类型断言
    • 反射
      • 接口变量的结构:静态类型与动态类型并存
      • 反射机制基本用法
      • 反射解析结构体标签Tag
      • 结构体标签在json上的应用
  • Golang进阶
    • groutine协程并发
      • 概念梳理
      • 创建goroutine语法
    • channel实现goroutine之间通信
      • channel与range、select
    • GoModules
      • Go Modules与GOPATH
      • Go Modules模式
      • 用Go Modules初始化项目
      • 修改模块的版本依赖关系
      • Go Modules 版本号规范
      • vendor 模式实践

引言

本文学习前置要求
1、具备1种后端编程语言开发经验(C/C++/Java/Python/PHP等)
2、具备基本的网络编程能力和并发思想
3、了解计算机基本体系结构
4、了解Linux基础知识


开发环境与IDE

1.下载安装包

首先是Golang安装包的下载:

Go官网下载地址

Go官方镜像站(推荐)

Go国内相关爱好者搭建网站-还有中文的api文档供参考

根据自己系统,自行选择安装。如果是window系统 推荐下载可执行文件版,一路 Next。
参考博客:
在Windows上安装Go编译器并配置Golang开发环境
Golang起步篇三种系统安装配置go环境以及IDE推荐以及入门语法详细释义

这里以linux为例:

在这里插入图片描述

复制tar包连接,然后下载

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

sudo tar -zxvf go1.14.2.linux-amd64.tar.gz -C /usr/local/

解压完之后到/usr/local目录下发现有名为go的文件夹,代表我们当前go环境编译器所在的路径,go文件夹下包括src,因为go是开源的,包括了全部的源码,想学习源码可以看这里的相关代码,比如sort(排序相关),sync(同步相关):

[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 到环境变量里:

sodu vim /etc/profile

文件末尾加入下面几段:

在这里插入图片描述

# 设置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

配完之后保存并测试是否配置成功:

source /etc/profile
go version
go env
go --help

如果系统变量还是不能生效
每次新打开一个命令窗口都要重新输入 source /etc/profile 才能使go env 等配置文件生效:
那就加到用户变量,这样当前用户一登录就会加载到

解决方法:

~/.bashrc 中添加语句(在root账号和子账号里都加一次)

source /etc/profile

保存退出

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 查看依赖,会发现它只依赖少量基础系统库,如 libcpthread 和标准 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 个并发任务:

package mainimport ("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 Modulesgo 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三个文件夹。

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.modgo.sum$GOPATH/pkg/mod 等新位置。但理解这三个目录仍然有助于你深入理解 Go 的编译和运行机制。

在这里插入图片描述

我们在src下创建一个GolangStudy文件夹作为我们的学习项目,先创造第一个案例,再创建一个1-firstGolang文件夹,在该文件夹下新建hello.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 是阻塞函数,常用于调试或控制程序运行节奏。
*/

终端运行:

$ go run hello.go hello Go!
$

go run 表示 直接编译go语言并执行应用程序,一步完成。
你也可以先编译,然后再执行

 $go build hello.go $./hellohello 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.iohttps://goproxy.cn

可以执行下面的命令修改GOPROXY

`go env -w GOPROXY=https://goproxy.cn,direct`



Golang语法入门

变量的声明

package main/*我们来学习一下四种变量的声明方式
*/import ("fmt"
)// 声明全局变量 方法一、方法二、方法三是可以的
var gA int
var gB = 100
var gC = 200//方法四则不能用来声明全局变量
// := 只能够用在 函数体内来声明
//gD := 200 会报错func main() {//方法一:声明一个变量 关键字var+变量名称+变量类型 默认的值是0var a intfmt.Println("a = ", a)            // a =  0fmt.Printf("type of a = %T\n", a) // type of a = int//方法二:声明一个变量,初始化一个值var b = 100fmt.Println("b = ", b)            // b =  100fmt.Printf("type of b = %T\n", b) // type of b = intvar bb = "abcd"fmt.Printf("bb = %s, type of bb = %T\n", bb, bb) // bb = abcd, type of bb = string//方法三:在初始化的时候,可以省去数据类型,通过值自动匹配当前的变量的数据类型var c = 100fmt.Println("c = ", c)            // c =  100fmt.Printf("type of c = %T\n", c) // type of c = intvar cc = "abcd"fmt.Printf("cc = %s, type of cc = %T\n", cc, cc) // cc = abcd, type of cc = string//方法四:(常用的方法) 省去var关键字,直接自动匹配e := 100fmt.Println("e = ", e)            // e =  100fmt.Printf("type of e = %T\n", e) // type of e = intf := "abcd"fmt.Println("f = ", f)            // f =  abcdfmt.Printf("type of f = %T\n", f) // type of f = stringg := 3.14fmt.Println("g = ", g)            // g =  3.14fmt.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, 200fmt.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 = 100jj = true)fmt.Println("vv = ", vv, ", jj = ", jj) // vv =  100 , jj =  true
}

其中fmt.Printlnfmt.Printf 都是用于往标准输出写内容
fmt.Println 不需要也不支持格式化占位符,它会把传入的多个参数用空格分隔并输出,最后自动添加一个换行符。fmt.Printf 需要第一个参数是格式化字符串(含 % 占位符),后面跟对应的值,通过这些占位符来指定输出格式。它不会自动加换行,需要在格式字符串里显式写 \n,参数之间不会自动插入空格,所有间隔都由格式字符串决定。


这里在 Go 的 fmt 包中,%T 是一个格式动词(format verb),用于输出变量或值的 类型(Type)



基本数据类型

中文名称Go 类型大小默认值分类备注
布尔类型bool1 bytefalse布尔只能是 truefalse。在条件判断中直接使用即可,无需与 == true/false 比较。
字符串string""引用类型不可变(immutable),底层是一个指向字节数组的只读切片。可用 len() 获取字节长度,用 for range 按 Unicode 码点遍历。记得区分字节长度和字符(rune)长度。
有符号整型int32 或 64bit0整数(平台依赖)根据平台不同(32/64 位)决定,推荐在不要求精确位宽时使用。与 uintptr 交互时要小心。
有符号整型int81 byte0整数(定宽)范围:-128 ~ 127。通常用于占用精准字节的场景。
有符号整型int162 bytes0整数(定宽)范围:-32768 ~ 32767。
有符号整型int324 bytes0整数(定宽)范围:-2³¹ ~ 2³¹-1。是 rune 的底层类型,用于表示 Unicode 码点。
有符号整型int648 bytes0整数(定宽)范围:-2⁶³ ~ 2⁶³-1。大整数运算时使用。
无符号整型uint32 或 64bit0无符号整数根据平台不同(32/64 位)决定,和 int 一样;不能表示负数。与 int 相互转换时要注意溢出和类型转换。
无符号整型uint81 byte0无符号整数范围:0 ~ 255。别名 byte,常用于处理原始二进制或字节流。
无符号整型uint162 bytes0无符号整数范围:0 ~ 65535。
无符号整型uint324 bytes0无符号整数范围:0 ~ 2³²-1。
无符号整型uint648 bytes0无符号整数范围:0 ~ 2⁶⁴-1。
指针大小类型uintptr32 或 64bit0整数/系统类型根据平台不同(32/64 位)决定,和 int/uint 一样;用于存储指针的整数表示,通常用于底层系统编程。不要做算术运算或存储垃圾值,否则会导致不可预期的行为。
浮点数float324 bytes0.0浮点单精度 IEEE-754,约 6-7 位十进制有效数字。
浮点数float648 bytes0.0浮点双精度 IEEE-754,约 15-16 位十进制有效数字。推荐默认使用 float64
复数类型complex648 bytes(0+0i)复数实部和虚部分别是 float32
复数类型complex12816 bytes(0+0i)复数实部和虚部分别是 float64。推荐默认使用 complex128
Unicode 码点rune (alias)4 bytes0别名/整数别名 int32,用于表示一个 Unicode 码点。与 byte 一起用于处理 UTF-8 编码。
字节byte (alias)1 byte0别名/无符号整数别名 uint8,用于表示原始数据或 ASCII 字符。

常见误区 & 使用注意(补充)

  1. int 与固定宽度整型混用
    在跨平台(32/64 位)项目中混用 intint32/int64 会导致编译时或运行时的类型不匹配,需要频繁转换,也可能引发意外溢出。

  2. 字符串长度 vs Unicode 字符数
    len(s) 返回的是字节数,不是字符数,含中文或 emoji 时,字符数会小于字节数。可用 utf8.RuneCountInString(s)[]rune(s) 来获取实际字符数。

  3. 字符串索引与切片
    直接用下标或切片操作字符串会按字节拆分,可能截断 Unicode 字符。要按码点处理时,先转换为 []rune 再操作。

  4. 浮点数比较
    浮点类型存在精度误差,不要用 == 判断相等,应比较差值绝对值是否小于某个 ε(如 1e-9)。

  5. byte/rune 转换
    直接把 rune 转为 byte 会丢失高位信息,切勿盲目强转;同理 byterune 在非 ASCII 范围也需注意。

  6. 指针算术
    Go 不支持指针直接算术运算,也不保证 uintptr 转回 *T 后安全。除非做底层交互(如 unsafe 包),否则不要使用 uintptr

  7. 默认零值陷阱
    Go 的零值(0false""nil)在声明变量时就已初始化,避免使用 new 或手动赋初值来覆盖零值,除非有特殊需求。

  8. 复数性能
    复数类型运算比实数慢很多,只有在真正需要复数运算(如 FFT)时才使用。

  9. 类型别名 vs 新类型
    type MyInt int 会创建新类型,需要显式转换;而 byterune 是内建别名,不需转换。

  10. 位运算与符号扩展
    对负数做位移 (>>) 会保留符号位。如果需要无符号右移,可先转换为对应的 uint 类型。

  11. 内存对齐
    结构体中字段顺序影响对齐和整体大小,合理安排字段可以减少内存占用;不同类型默认对齐边界不同(如 int64 8 字节对齐)。

  12. JSON 编解码的数字类型
    Go 标准库的 encoding/json 会默认将所有数字解码为 float64,在处理大整数时可能丢失精度,需要使用 UseNumber 或自定义类型。



常量

package mainimport "fmt"// 可以通过 const 来定义枚举类型
const (//可以在const() 添加一个关键字 iota, 每行的iota都会累加1, 第一行的iota的默认值是0BEIJING  = 10 * iota //iota = 0SHANGHAI             //iota = 1 SHANGHAI = 10SHENZHEN             //iota = 2 SHENZHEN = 20
)const (//每个 const 块的 iota 都是从 0 开始,后面的常量也不需要手动定义,可以用来生成一组连续的整型常量。IOTATEST0 = iota //iota = 0 IOTATEST0 = 0IOTATEST1        //iota = 1 IOTATEST1 = 1IOTATEST2        //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 = 2c, d                      // iota = 1, c = iota + 1, d = iota + 2, c = 2, d = 3e, 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 = 9i, k                      // iota = 4, i = iota * 2, k = iota * 3 , i = 8, k = 12
)func main() {//常量(只读属性) var关键字改成const即可const length int = 10fmt.Println("length = ", length) // length =  10//length = 100 //常量是不允许修改的,这里会直接报错。fmt.Println("BEIJIGN = ", BEIJING)   // BEIJIGN =  0fmt.Println("SHANGHAI = ", SHANGHAI) // SHANGHAI =  10fmt.Println("SHENZHEN = ", SHENZHEN) // SHENZHEN =  20fmt.Println("IOTATEST0 = ", IOTATEST0) // IOTATEST0 =  0fmt.Println("IOTATEST1 = ", IOTATEST1) // IOTATEST1 =  1fmt.Println("IOTATEST2 = ", IOTATEST2) // IOTATEST2 =  2fmt.Println("a = ", a, "b = ", b) // a =  1 b =  2fmt.Println("c = ", c, "d = ", d) // c =  2 d =  3fmt.Println("e = ", e, "f = ", f) // e =  3 f =  4fmt.Println("g = ", g, "h = ", h) // g =  6 h =  9fmt.Println("i = ", i, "k = ", k) // i =  8 k =  12// iota 只能够配合const() 一起使用, iota只有在const进行累加效果。//var a int = iota//fmt.Println(a) // 报错
}


函数

函数的基本写法如下示例:

package mainimport "fmt"// Go 中函数使用关键字 `func` 定义,后跟函数名和参数列表。
// 参数的类型写在参数名之后,返回值类型写在最后。
func foo1(a string, b int) int {fmt.Println("---- foo1 ----")fmt.Println("a = ", a)fmt.Println("b = ", b)c := 100return 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 =  0fmt.Println("r2 = ", r2) // r2 =  0//给有名称的返回值变量赋值r1 = 1000r2 = 2000return//使用命名返回值,可以直接 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 = 1000r2 = 2000return
}// 测试字符串类型
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 =  100ret1, ret2 := foo2("haha", 999)fmt.Println("ret1 = ", ret1, " ret2 = ", ret2)//---- foo2 ----//a =  haha//b =  999//ret1 =  666  ret2 =  777ret1, ret2 = foo3("foo3", 333)fmt.Println("ret1 = ", ret1, " ret2 = ", ret2)//---- foo3 ----//a =  foo3//b =  333//r1 =  0//r2 =  0//ret1 =  1000  ret2 =  2000ret1, ret2 = foo4("foo4", 444)fmt.Println("ret1 = ", ret1, " ret2 = ", ret2)//---- foo4 ----//a =  foo4//b =  444//ret1 =  1000  ret2 =  2000str1, str2 := foo5("foo5", 555)fmt.Println("str1 = ", str1, " str2 = ", str2)//---- foo5 ----//a =  foo5//b =  555//r1 =//	r2 =//str1 =  hello  str2 =  world
}


init函数与import

在 Go 语言中,有两个特殊的保留函数:initmain

  • main() 函数只能出现在 package main 中,是程序的入口函数。

  • init() 函数可以出现在任意 package(包括main) 中,且一个包中可以定义多个 init 函数,包括在同一个文件中也可以有多个。

init 和 main 函数的特点:

  • 它们的签名写法固定:不能有参数,也不能有返回值
  • Go 程序在运行时会自动调用 initmain 函数,无需手动调用
  • init 函数是可选的,而 main 函数在 main 包中是必需的

虽然 init 可以出现多次,但为了代码清晰、维护简单,推荐每个文件最多只写一个 init 函数。


Go 在程序执行前,会按照一定顺序初始化各个包。这个顺序如下:

  • 1.从 main 包开始递归导入所依赖的包。
  • 2.对每个包,执行以下操作:
    • 1)先导入它依赖的其他包(如果有);
    • 2)初始化该包的包级变量常量
    • 3)执行该包中的 init()函数(如果存在)。
  • 3.当所有依赖包都初始化完成后,再对 main 包执行相同的过程:
    • 1)初始化常量和变量;
    • 2)执行 main 包中的 init();
    • 3)最后执行 main() 函数作为程序入口。

注意: 无论一个包被导入多少次,实际只会初始化并执行一次(如 fmt 包经常被多个包使用,但只会加载一次)。

下图详细地解释了整个执行过程:

在这里插入图片描述

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

在这里插入图片描述

Lib1.go:

package lib1import "fmt"//当前lib1包提供的API
func Lib1Test() {fmt.Println("lib1Test()...")
}func init() {fmt.Println("lib1. init() ...")
}

Lib2.go:

package lib2import "fmt"//当前lib2包提供的API
func Lib2Test() {fmt.Println("lib2Test()...")
}func init() {fmt.Println("lib2. init() ...")
}

这里注意 : 在 Go 语言中,函数名的首字母大小写是非常关键的,它决定了函数的访问权限(可见性)。
大写开头的函数名可以被其他包调用(导出)。
小写开头的函数名只能在定义它的包内部使用。
这个规则同样适用于变量、常量、类型、结构体字段

main.go:

package mainimport ("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默认使用了值传递。

package mainimport "fmt"func main() {/* 定义局部变量 */var a int = 100var b int = 200fmt.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 inttemp = x /* 保存 x 的值 */x = y    /* 将 y 值赋给 x */y = temp /* 将 temp 值赋给 y*/return temp;
}

Go 语言中指针是很容易学习的,Go 语言中使用指针可以更简单的执行一些任务。接下来让我们来一步步学习 Go 语言指针。我们都知道,变量是一种使用方便的占位符,用于引用计算机内存地址。

Go 语言的取地址符是 &,放到一个变量前使用就会返回相应变量的内存地址。而*用于“解引用”:从地址中取出真实值。二者配合使用,理解 Go 的指针机制就非常清晰了。

引用传递是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。引用传递指针参数传递到函数内,以下是交换函数 swap() 使用了引用传递:

package mainimport "fmt"/*
func swap(a int ,b int) {var temp inttemp = aa = bb = temp
}
*/func swap(pa *int, pb *int) {var temp inttemp = *pa //temp = main::a*pa = *pb  // main::a = main::b*pb = temp // main::b = temp
}func main() {var a int = 10var b int = 20fmt.Println("a = ", a, " b = ", b)swap(&a, &b)fmt.Println("a = ", a, " b = ", b)var p *intp = &afmt.Println(&a)fmt.Println(p)var pp **int //二级指针pp = &pfmt.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 的值计算之后、函数真正返回前 执行的。

一个基本示例:

package mainimport "fmt"func main() {fmt.Println("Start")defer fmt.Println("Deferred") // 延迟执行 在defer所在函数体结束之前才执行fmt.Println("End")
}
/*
Start
End
Deferred
*/

如果一个函数中有多个defer语句,它们会以栈(LIFO-后进先出)的顺序执行。

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 结构,内建函数 panicrecover 实现异常处理机制。。

  • 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,程序仍能继续运行:

package mainimport "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")
}

输出:

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 的影响就被“隔离”在这个匿名函数作用域里。

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中的数组

package mainimport "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 数组,数组中的每个元素默认初始化为 0var 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]intfmt.Printf("myArray2 types = %T\n", myArray2) // myArray2 types = [10]intfmt.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 中数组的“长度”是类型的一部分,必须匹配才能传参。

切片是对数组的抽象,长度不固定,函数参数中最常用,如下代码所示。

// 使用 切片(slice) —— 推荐做法
package mainimport "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 的数组指针,这种方式可以修改固定长度数组的值,但是数组长度仍需指定,如下所示:

// 使用 数组指针 [N]int 的指针
package mainimport "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 的语法概念。

package mainimport "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)本身仍是按值传递,只是它内部结构包含一个对底层数组的引用,所以表现出“引用语义”。因为切片是一个轻量级结构体,它包含三个字段:

type slice struct {ptr *T   // 指向底层数组的指针len int  // 切片当前长度cap int  // 切片容量(底层数组最大可用长度)
}

所以当你将切片作为参数传入函数时:实际上传入的是这个结构体的副本(值拷贝)。但由于这个结构体内包含的是对底层数组的引用地址(ptr),所以对切片元素的修改会反映到底层数组上,从而影响调用者。

举例对比理解:

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 完全不知道你在函数里发生了什么。这就像你复制了一张地图,然后在复制上画线,原图没变。
如果你想要让原始切片变长怎么办?你就得 返回新的切片并赋值回去:

func modify(s []int) []int {s[0] = 100s = append(s, 200)return s
}func main() {s := []int{1, 2, 3}s = modify(s)fmt.Println(s) // 输出:[100 2 3 200]
}

不同于 C 指针传参中可以直接改变指针本身。这点需要注意。
如果你想在函数内真正改变原来的 slice 本体的“引用”,那就要传指针:

func resetSlice(s *[]int) {// 修改 slice 的指向,让它指向一个新 slice*s = []int{100, 200, 300}
}

下面再学习一下切片的四种声明方法:

package mainimport "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个空间,初始化值是0slice1 := 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 中 makenew 的区别的简洁总结

  • new:分配内存,返回指针
  • make:初始化内建类型(非指针)

new(T) 会分配一块 类型为 T 的零值内存,并返回一个指向它的 指针。适用于所有类型(如结构体、数组、基本类型等)。

p := new(int)     // 分配一个 int,初始为 0,p 是 *int 类型
fmt.Println(*p)   // 输出:0
// 你需要手动处理指针访问:*p = 10,fmt.Println(*p)

make(T, ...) 只能用来创建切片(slice)、映射(map) 和 通道(chan)。返回的是已经初始化好的值,不是指针。make 不返回指针,是为了直接操作这些类型的内部结构(如容量、缓冲区等)。

s := make([]int, 3)  // 创建一个长度为3的切片,s 是 []int 类型
m := make(map[string]int) // 创建一个 map
c := make(chan int)       // 创建一个 channel

总结:new 是“你要一块内存”,make 是“你要一个能用的内建类型”。


下面学习一下切片的追加append

package mainimport "fmt"func main() {/*之前我们这样定义切片的时候,长度(len)和容量(cap)都为3:var slice1 []intslice1 = 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 = 5numbers = 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,默认值为 0var 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 会新开一块更大的内存,然后将原切片的内容复制过去,返回一个新的切片。

s := make([]int, 2, 2)
ptr1 := &s[0]s = append(s, 99)
ptr2 := &s[0]fmt.Println(ptr1 == ptr2) // false,说明底层数组已经变化(新地址)

最后再学习一下切片的截取操作,一个类似python中的语法操作:

package mainimport "fmt"func main() {// 初始化一个切片 s,包含三个元素,长度为3,容量为3s := []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] = 100fmt.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 不影响 sfmt.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 范围内,不会触发扩容,会影响 arrsub = 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] = 888fmt.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的一些声明方式

package mainimport "fmt"func main() {// ===> 第一种声明方式:使用 var 声明一个空 map(nil map)// 声明 myMap1 是一种 map 类型:key是string(中括号里声明),value也是string(中括号右声明)var myMap1 map[string]string// 此时 myMap1 是 nil,不能直接赋值,否则会 panicif 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 此时不需要makemyMap3 := 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的基本使用方式

package mainimport "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) {// 可以认为是引用传递,在这里的修改会影响到原mapcityMap["England"] = "London" // 修改 map
}func main() {// 使用 := make 创建一个空 map,key 为 string,value 也为 stringcityMap := make(map[string]string)//添加键值对cityMap["China"] = "Beijing"cityMap["Japan"] = "Tokyo"cityMap["USA"] = "NewYork"//遍历并打印 mapprintMap(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 的情况复习一下:

func modify(s []int) {s[0] = 100           // 修改底层数组,原切片受影响s = append(s, 200)   // append 返回了一个新切片,赋值给了 s >的副本,不影响外部 s
}

map 的行为非常类似:

func modifyMap(m map[string]string) {m["USA"] = "DC"         // 修改原 map:有效m = make(map[string]string) // 创建新 map,改变的是副本,不影响原 map 的引用m["Japan"] = "Kyoto"    // 添加到新 map:不会影响 main 里的 map
}

如果你想在函数内真正改变原来的 slice/map 本体的“引用”,那就要传指针:

func resetMap(m *map[string]string) {// 创建并指向一个新 map*m = map[string]string{ //main中的map也指向新的了"UK":  "London","USA": "DC",}
}

很多新手容易误以为 map 拷贝会“深拷贝”,其实它只是引用的浅拷贝

package mainimport "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 会影响 originalMapcopiedMap["China"] = "Shanghai"fmt.Println("originalMap:")printMap(originalMap) // 输出:China: Shanghaifmt.Println("copiedMap:")printMap(copiedMap)    // 输出:China: Shanghai// 现在我们让 copiedMap 指向一个新 mapcopiedMap = make(map[string]string)copiedMap["USA"] = "DC"fmt.Println("----After copiedMap = new map----")fmt.Println("originalMap:")printMap(originalMap) // 不受影响,仍然有 China 和 Japanfmt.Println("copiedMap:")printMap(copiedMap)   // 现在只有 USA
}

如果你想实现真正的 map 深拷贝(deep copy),你需要手动复制每个键值对,例如:

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

package mainimport "fmt"// 学习一下type关键字 下面表示声明一种新的数据类型 myint, 是int的一个别名,本质仍是 int
type myint int// 定义一个结构体的语法:
type Book struct {title stringauth  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 = 10fmt.Println("a = ", a)            // a =  10fmt.Printf("type of a = %T\n", a) //type of a = main.myint// 实际上main.myint底层就是一个intvar book1 Bookbook1.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中面向对象类的表示与封装

package mainimport "fmt"// 在 Go 中没有 class,但可以通过结构体 + 方法模拟“类”的概念
// 如果类名首字母大写,表示其他包也能够访问,否则只能本包内访问,比如fmt.Println中P大写表示是可导出的函数
type Hero struct {// 字段首字母大写(Name, Ad)表示字段是“导出”的,其他包也能访问// 字段首字母小写(level)表示字段是私有的,仅当前包可访问// 私有属性——'当前包内任意函数或方法'都可以这样 hero.level 直接访问私有属性Name  stringAd    intlevel 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的继承

package mainimport "fmt"// 定义一个Human类
type Human struct {name stringsex  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 SuperMans.name = "li4"s.sex = "male"s.level = 88s.Walk() //父类的方法 Human.Walk()...s.Eat()  //子类的方法 SuperMan.Eat()...s.Fly()  //子类的方法 SuperMan.Fly()...s.Print()
}

注意,这里func (this *SuperMan) Fly() {...} 是用 指针接收者定义的。
但你可以这样用:

s := SuperMan{...}
s.Fly() // 为什么能调用?

因为这是 Go 的语法糖!Go 编译器在调用 s.Fly() 的时候:发现你调用的是 *SuperMan 的方法,而 sSuperMan,不是指针。编译器会自动将 s 转换为 &s,然后调用 (&s).Fly() 这叫做 自动取址(auto address-taking)。同样的,反过来:如果方法接收者是值类型,你用的是指针去调用,Go 也会自动解引用调用


学习了go的面向对象类的封装与继承后,我们再看一下go的多态。实际上我们用刚才继承的这种方式是实现不了多态的,所以go语言中想要做多态的话需要有interface这么一个接口的概念。

一般我们说面向对象的层面,有一类或者说一个家族、一系列对象他们要有一定的接口,接口定义一些所谓的抽象方法 ,然后子类去继承并实现,达成一个抽象接口有很多种不同的动态表现形式,即面向对象的多态

package mainimport "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(指针接收者)上定义的:

func (this *Cat) Sleep() {...}
func (this *Cat) GetColor() string {...}
func (this *Cat) GetType() string {...}

这意味着:只有 *Cat(指针类型)实现了接口Cat(值类型)没有实现这些接口,所以,如果你写:

var b Cat    // 这是一个值类型
var r AnimalIF
r=b // ❌ 错误: AnimalIF需要的方法都在*Cat (指针接收者) 上,Cat本身没有这些方法

这是 Go 的接口机制中非常重要的一点:接收者的类型必须完全匹配。所以必须自己明确:

animal = &Cat{"Green"} //注意这里的&

因为接口的赋值必须完全匹配接口的方法集(method set)。

有些同学可能会疑惑:如果我把接口方法改用值接收者去实现,是否可以用值类型直接赋值接口?
答案是:可以
例如:

func (c Cat) Sleep() { ... }
func (c Cat) GetColor() string { ... }
func (c Cat) GetType() string { ... }

此时,不管是值类型还是指针类型,都能赋值给接口变量:

var 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)机制来支持这一需求。下面是一个简单的演示代码:

package mainimport "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...100arg is not string type*/fmt.Println("--------------")myFunc("abc") // 传入字符串/*	myFunc is called...abcarg is string type, value =  abcvalue type is string*/fmt.Println("--------------")myFunc(3.14) // 传入浮点数/*	myFunc is called...3.14arg is not string type*/
}

类型断言的另一种写法(不推荐用于不确定类型的情况):

value := arg.(string) // 如果断言失败,会直接 panic

还有类型 switch(可以判断多种类型):

// 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。否则可能会出问题:

var p *int = nil
var i interface{} = pfmt.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 = nil nil map 不能写入,读时返回零值
  • channel:var ch chan int = nil nil 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 信息——不仅知道值,还能知道它的实际类型。

在这里插入图片描述

下面通过一段代码简单说明一下上面的描述:

package mainimport "fmt"func main() {//定义一个变量 a,类型为 string,并赋值var a stringa = "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) 这里我们忽略 okstr, _ := allType.(string)fmt.Println(str) // aceld
}

又比如下面的两个例子:

package mainimport ("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 接口背后的运行时多态*/
}

再比如下面的案例,有点抽象,多看两个例子学习一下:

package mainimport "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(),看看官方的解释:

// 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包的基本用法:

package mainimport ("fmt""reflect"
)// reflectNum 接收一个空接口参数(所以任何类型都可以),并使用反射查看其类型和值
func reflectNum(arg interface{}) {fmt.Println("type : ", reflect.TypeOf(arg))   // type :  float64fmt.Println("value : ", reflect.ValueOf(arg)) // value :  1.2345
}func main() {var num float64 = 1.2345// 对基本数据类型进行反射测试reflectNum(num)
}

这里再澄清一下静态类型和动态类型在反射上的区别:

  • int 等基础类型本身只有一个静态类型,不存在动态类型。任何值都有类型信息,只是接口额外记录了动态类型。
  • 反射包是可以作用于任何类型,包括非接口类型。非接口变量本来就没有“动态类型”的概念,只有一个固定的类型。
  • 对非接口类型调用 reflect.TypeOf 返回的是静态类型。当var x int = 5reflect.TypeOf(x) 返回的就是它的唯一类型(它的静态类型,也同时是它的实际类型)。
  • 接口类型会在内部额外记录动态类型和值,反射对此进行了封装支持。对于非接口变量,反射通过编译期信息直接访问类型信息。对于接口变量,type 是一个指针指向实际类型的描述结构。
  • 所以不是获取不到动态类型就兼容输出静态类型,而是 动态类型这个概念只存在于接口中,非接口值就直接返回它本身的类型。

在看一下第二个例子学习reflect:

package mainimport ("fmt""reflect"
)// 定义一个结构体类型 User
type User struct {Id   intName stringAge  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()方法等到 对应的valuefor 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 = 1Name: string = AceldAge: 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中比较特殊的语法,它需要用反射这种机制才能够解读:

package mainimport ("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() 只有在你拿到的是指针类型时,才有意义。它的作用是:取出指针所指向的那个类型。例如:

t2 := reflect.TypeOf(&re)     // *resume
t3 := t2.Elem()               // resume

所以 如果我们这里传入的是值类型:它本身不是指针,没有“指向的元素”,所以不能再调用 Elem(),否则就会 panic。
你可以不传指针,但要看你想要怎么写。如果传入值类型,可以不调用 Elem(),直接跟之前的例子一样使用 t := reflect.TypeOf(str)
但通常我们习惯传入指针,是因为很多时候:结构体比较大,传指针效率高;统一处理逻辑(尤其在通用工具函数里);后续可能需要用到 reflect.Value 修改字段值,修改值时必须使用指针。



结构体标签在json上的应用

上面介绍了通过反射手动对结构体标签进行解析,那么结构体标签在我们日常应用中又有哪些呢?其实go语言的json库就用到了结构体标签,如下代码所示,看看go语言解析json文件是如何用到结构体标签的。

package mainimport ("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

package mainimport ("fmt""time"
)// 一个用于演示的子goroutine任务函数,不断地每秒打印当前计数值。
func newTask() {i := 0for {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 := 0for {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程写为一个定义好的函数,我们直接写一个匿名函数去加载也可以,这里演示一下:

package mainimport ("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 仍然会执行。}()// 使用匿名函数创建并立即调用带参数的 goroutinego 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 基础通用模板:

package mainimport ("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.WaitGroupnumWorkers := 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的基本用法:

package mainimport "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 中// 接收到的数据赋值给变量 numnum := <-c //从c中接受数据,并赋值给num 这个是接收的语法// - <-c 是接收操作,把 channel 中的数据取出// - <-c 也可以单独写成:<-c 只取出数据而不保存(丢弃)//   例如: <-c  // 取出数据但不保存任何变量中,数据被丢弃fmt.Println("num = ", num) // num =  666fmt.Println("main goroutine 结束...")
}

这里因为使用的是无缓冲channel,当向无缓冲 channel 发送数据时,发送操作会阻塞直到有其他 goroutine 从 channel 中接收数据,接收操作会阻塞,直到有数据被发送到 channel 中。


在 Go 语言中,channel 分为无缓冲(unbuffered)和有缓冲(buffered)两种。

  • 无缓冲 channel:

    • 发送和接收必须同步进行。

    • 发送操作会阻塞,直到有接收者从 channel 中取走数据;接收操作也会阻塞,直到有发送者发送数据。

    • 适用于需要确保发送方与接收方同步的场景,常用于协程之间的同步控制。

  • 有缓冲 channel:

    • 在内部有一个有限的缓冲区,可以容纳一定数量的元素。

    • 发送操作在缓冲未满时不会阻塞;只有当缓冲区满时才会阻塞发送方。

    • 接收操作在缓冲非空时不会阻塞;只有当缓冲区为空时才会阻塞接收方。

    • 适用于发送和接收速度不完全匹配的场景,可以提升一定的并发性能和吞吐能力。

  • 简单来说:无缓冲更偏向同步,有缓冲更偏向异步


下面我们测试一下有缓冲channel的效果:

package mainimport ("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 // 发送数据到 channelfmt.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中接收数据,并赋值给numfmt.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的关闭特点:

package mainimport "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 intgo func() { // 启动一个匿名goroutine作为发送者for i := 0; i < 5; i++ { // 向channel中发送5个整数:0到4c <- i // 向channel发送数据,若没有接收方则会阻塞//close(c) // 注意:如果在这里关闭channel,将在第一次发送后关闭,再发送时panic!}//close可以关闭一个channelclose(c) // 循环发送完所有数据后,关闭channel,通知接收方:不会再有新的数据发送进来了}()for { // 启动主goroutine作为接收者// 这里使用了逗号ok的惯用写法:data接收从channel读取的数据// ok为布尔值,若channel未关闭或还有数据,ok为true;当channel关闭且数据读完后,ok返回falseif 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 语句可以有两部分:

if 简短变量声明; 条件判断 {// ...
}

也就是说:分号 ; 把变量声明和条件判断隔开。if 语句执行时,先执行分号前面的短变量声明(这里是 `data, ok := <-c`),然后判断分号后面的条件(这里是 `ok`)。这句代码拆开理解就是:
data, ok := <-c  // 从channel接收数据,同时判断channel是否已关闭
if ok {fmt.Println(data)
}    

但是因为 Go 允许你把声明写在 if 里,就可以缩写成一行



channel与range、select

下面我们再看一下channel跟两个比较特殊的关键字的配合使用

channel与range

package mainimport "fmt"func main() {c := make(chan int) // 创建一个无缓冲的整型channel,类型为chan intgo func() { // 启动一个匿名goroutine作为发送者for i := 0; i < 5; i++ { // 向channel中连续发送5个整数:0到4c <- i // 发送数据到channel,若无接收方会阻塞等待}// 发送完所有数据后,关闭channel,关闭channel的作用是通知接收方:不会再有新的数据了close(c)}()// =================== 之前写法(手动 for + ok 检查) ===================/*	for { // 启动主goroutine作为接收者// 这里使用了逗号ok的惯用写法:data接收从channel读取的数据// ok为布尔值,若channel未关闭或还有数据,ok为true;当channel关闭且数据读完后,ok返回falseif 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的状态:

在这里插入图片描述

package mainimport "fmt"// 定义一个生成斐波那契数列的函数,使用channel与select控制流程
func fibonacii(c, quit chan int) {x, y := 1, 1 // 斐波那契数列的前两个数for {select {// select语句可以同时监听多个channel的通信状态// 当某个case对应的channel准备好后(发送/接收不再阻塞),select就会执行对应的casecase c <- x:// 当c可写时(即:有人在接收c的数据时),就会进入这个case// 把当前的x发送到channel c中// 然后计算下一个斐波那契数x = yy = x + ycase <-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?

$ go envGOPATH="/home/itheima/go"
...

我们输入go env命令行后可以查看到 GOPATH 变量的结果,我们进入到该目录下进行查看,如下:

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(不推荐)。

可以通过下面的命令来设置:

$ 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

如:

$ go env -w GOPROXY=https://goproxy.cn,direct

GOPROXY 的值是一个以英文逗号 “,” 分割的 Go 模块代理列表,允许设置多个模块代理,假设你不想使用,也可以将其设置为 “off” ,这将会禁止 Go 在后续操作中使用任何 Go 模块代理。

设置多个模块代理:

$ 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。

并且它们的值都是一个以英文逗号 “,” 分割的模块路径前缀,也就是可以设置多个,例如:

$ go env -w GOPRIVATE="git.example.com,github.com/eddycjy/mquote"

如果不想每次都重新设置,还支持通配符:

$ go env -w GOPRIVATE="*.example.com"

设置后,后缀为 .example.com 的模块都会被认为是私有模块,都不会经过GOPROXY并经过GOSUMDB检验。需要注意的是不包括 example.com 本身



用Go Modules初始化项目

(1) 开启Go Modules

 $ go env -w GO111MODULE=on

又或是可以通过直接设置系统环境变量(写入对应的~/.bash_profile 文件亦可)来实现这个目的:

$ export GO111MODULE=on

(2) 初始化项目

创建项目目录

$ mkdir -p $HOME/aceld/modules_test
$ cd $HOME/aceld/modules_test

我们后面会在modules_test下写代码,首先要执行Go modules 初始化的工作,如下所示,会在本地创建一个go.mod文件。go mod init后面要跟一个当前模块的名称,这个名称是自定义写的,这个名称他决定于今后导包的时候,即其他人import的时候怎么写

$ go mod init github.com/aceld/modules_testgo: creating new go.mod: module github.com/aceld/modules_test

生成的 go.mod:

module github.com/aceld/modules_testgo 1.14

在执行 go mod init 命令时,我们指定了模块导入路径为 github.com/aceld/modules_test。接下来我们在该项目根目录下创建 main.go 文件,如下:

package mainimport ("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...pingerr := 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库的. znetziface只是zinx的两个模块.

明显我们的项目没有下载刚才代码中导入的那互联网上的两个包,我们只是import导入进来了,如果是之前GOPATH模式的话,应该去GOPATH下的src/git/github.com/aceldgo get下来,或者直接手动下载放在指定目录。
但是我们现在是Go Modules,接下来我们在$HOME/aceld/modules_test,本项目的根目录执行下面的命令,假设我们用到了znet包:

$ go get github.com/aceld/zinx/znetgo: 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:

module github.com/aceld/modules_testgo 1.14require 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 在今后的操作中保证项目所依赖的那些模块版本不会被篡改。

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,本项目的根目录执行:

$ 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

module github.com/aceld/modules_testgo 1.14require github.com/aceld/zinx v0.0.0-20200306023939-bc416543ae24 // indirect

我们会看到,当我们执行go get 的时候, 会自动的将本地将当前项目的require更新了.变成了最新的依赖.

好了, 现在我们就要做另外一件事,就是,我们想用一个旧版本的zinx. 来修改当前zinx模块的依赖版本号.

目前我们在$GOPATH/pkg/mod/github.com/aceld(可以理解为本地仓库)下,已经有了两个版本的zinx库:

/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项目目录下,执行:

$ go mod edit -replace=zinx@v0.0.0-20200306023939-bc416543ae24=zinx@v0.0.0-20200221135252-8a8954e75100

然后我们打开go.mod查看一下:

module github.com/aceld/modules_testgo 1.14require github.com/aceld/zinx v0.0.0-20200306023939-bc416543ae24 // indirectreplace 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

否则,在使用时会导致依赖拉取异常或不兼容的问题。

# 例子# v1 版本 module 路径
module github.com/foo/bar# v2 版本及以上 module 路径
module github.com/foo/bar/v2

这种设计的好处:保持对旧版本的兼容性;明确标识重大版本分支;避免不同版本冲突。

实践建议:升级到 v2+ 时,务必修改 go.mod 中的 module 路径;发布新版本时,在 Git 中打上对应 tag,例如:v2.0.0;消费方导入时需使用完整路径:

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.modgo.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。



http://www.dtcms.com/a/266611.html

相关文章:

  • RT‑DETRv2 详解:Real‑Time DETR 的 Bag‑of‑Freebies 与部署优化
  • PNG图像压缩优化工具
  • 钉钉小程序开发技巧:getSystemInfo 系统信息获取全解析
  • IRIV算法详解 | 变量选择的迭代保留法
  • 全星稽核管理软件系统——企业智能化稽核管理的最佳解决方案
  • zxing去白边
  • 督皇口粮酱酒 平价不平质
  • 第十五节:第三部分:特殊文件:XML概述、解析
  • C语言中的输入输出函数:构建程序交互的基石
  • Linux的压缩与解压缩
  • WPF 右键菜单 MenuItem 绑定图片时只显示最后一个 Icon
  • OpenCV 相机标定中的畸变系数及调试硬件
  • 前端渲染大量图片的首屏加载优化方案
  • 刷题笔记--串联所有单词的子串
  • [附源码+数据库+毕业论文]基于Spring+MyBatis+MySQL+Maven+jsp实现的个人财务管理系统,推荐!
  • [附源码+数据库+毕业论文]基于Spring+MyBatis+MySQL+Maven+jsp实现的电影小说网站管理系统,推荐!
  • 儿童益智玩具+AI大模型能不能原地起飞?
  • Unity URP法线贴图实现教程
  • 三、jenkins使用tomcat部署项目
  • RK-Android11-性能优化-限制App内存上限默认512m
  • 利用TCP协议,创建一个多人聊天室
  • 使用reactor-rabbitmq库监听Rabbitmq
  • Go中使用Google Authenticator
  • 东软8位MCU低功耗调试总结
  • 如何使用python识别出文件夹中全是图片合成的的PDF,并将其移动到指定文件夹
  • 【ASP.NET Core】REST与RESTful详解,从理论到实现
  • 当前主流AI智能代理框架对比分析报告
  • 分布式光伏监控系统防孤岛保护装置光功率预测
  • 【论文阅读】VARGPT-v1.1
  • Webpack构建工具