目录
引言
本文学习前置要求
1、具备1种后端编程语言开发经验(C/C++/Java/Python/PHP等)
2、具备基本的网络编程能力和并发思想
3、了解计算机基本体系结构
4、了解Linux基础知识
开发环境与IDE
1.下载安装包
首先是Golang安装包的下载:
根据自己系统,自行选择安装。如果是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
查看依赖,会发现它只依赖少量基础系统库,如 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 个并发任务:
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三个文件夹。
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
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
$./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
:
`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+变量名称+变量类型 默认的值是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
类型。 -
内存对齐
结构体中字段顺序影响对齐和整体大小,合理安排字段可以减少内存占用;不同类型默认对齐边界不同(如int64
8 字节对齐)。 -
JSON 编解码的数字类型
Go 标准库的encoding/json
会默认将所有数字解码为float64
,在处理大整数时可能丢失精度,需要使用UseNumber
或自定义类型。
常量
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) // 报错
}
函数
函数的基本写法如下示例:
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导包方式
init函数
在 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:
package lib1
import "fmt"
//当前lib1包提供的API
func Lib1Test() {
fmt.Println("lib1Test()...")
}
func init() {
fmt.Println("lib1. init() ...")
}
Lib2.go:
package lib2
import "fmt"
//当前lib2包提供的API
func Lib2Test() {
fmt.Println("lib2Test()...")
}
func init() {
fmt.Println("lib2. init() ...")
}
这里注意
: 在 Go 语言中,函数名的首字母大小写是非常关键的,它决定了函数的访问权限(可见性)。
大写开头的函数名可以被其他包调用(导出)。
小写开头的函数名只能在定义它的包内部使用。
这个规则同样适用于变量、常量、类型、结构体字段。
而对于init和main函数来说,必须是小写的init和main才会生效(识别为特殊的初始化函数和Go程序的入口点),切签名写法固定,不能有参数,也不能有返回值。
Init()
(大写):不是特殊的初始化函数;只是一个导出函数(可以被其他包调用),不具备自动执行的特性;需要手动调用。
Main()
(大写):普通导出函数,不会自动执行;不会被视为程序入口点;可供其他代码调用(如果在其他包中定义并导出)。
不同的import导包方式
main.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默认使用了值传递。
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() 使用了引用传递:
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++ 那样的真正“引用传递”语法机制。
对于:
但其实本质上,
Go 语言中只有值传递
—— 无论是传递普通变量,还是传递指针,本质上都是把“值的副本
”传给函数。
举个例子,可以等学习完切片后再回看。
slice 是“值传递”,但它内部持有指针,所以可能会影响原始数据,也可能不会,取决于是否触发扩容。
func main() {
var arr []int
for i := 0; i < 3; i++ {
arr = append(arr, i) // arr = [0, 1, 2]
}
test(arr)
fmt.Println(arr) // 输出的是 [1024 1 2]
}
func test(arr []int) {
arr = append(arr, 2048) // arr = [0, 1, 2, 2048](可能触发扩容)
arr[0] = 1024
}
个具体例子发生了什么?
1.main 中的 arr = [0, 1, 2],长度是 3,容量是 4(Go 通常会预留多一点容量);
2.把 arr 传给 test(),复制了 slice 结构体(值传递),但指针还是同一个;
3.append(arr, 2048)
:
- 如果容量够用,不会触发扩容 ⇒ 改变底层数组,对 main 中的 arr 有影响;
- 如果容量不够,会触发扩容,新建底层数组 ⇒ test 中的 arr 与 main 中的 arr 数据分离;
4.不管有没有扩容,test 中执行 arr[0] = 1024,这个 arr[0] 是对底层数组的修改。
5.如果 append 后没扩容,main 的 arr[0] 就被修改成了 1024。
所以最终你看到的是:fmt.Println(arr) // [1024 1 2]
因为只是 append 一次(添加一个元素),大概率不会触发扩容,arr[0] = 1024 影响了原数组。
假设我们把 main 中的初始化改成这样:
arr := make([]int, 3, 3) // cap == len == 3(刚好,没有多余容量)
此时再调用 test(arr):func test(arr []int) { arr = append(arr, 2048) // 扩容了!创建了新数组 arr[0] = 1024 }
此时:
append 后触发扩容,test 中的 arr 是新的 slice,指向新的底层数组;
arr[0] = 1024 只会修改新的数组;
main 的 arr 保持不变,依然是 [0 1 2]。
正确用法(如果要让 main 感知 test 的修改)
func main() {
var arr []int
for i := 0; i < 3; i++ {
arr = append(arr, i)
}
arr = test(arr) // ✅ 接收返回值
fmt.Println(arr) // [1024 1 2 2048]
}
func test(arr []int) []int {
arr = append(arr, 2048) // 可能扩容
arr[0] = 1024
return arr
}
defer与延迟函数
defer
语句用于延迟函数的执行,直到外围函数(当前函数)返回之前才执行
。常用于释放占用的资源;捕捉处理异常;输出日志。有点类似于c++的析构函数或者java里的try-catch的finally。更具体的执行流程则是这样的:
- 执行 return 表达式(如果有)并求出返回值
- 执行所有 defer 语句(按后进先出顺序)
- 返回到调用者
即defer
是在 return
的值计算之后、函数真正返回前 执行的。
一个基本示例:
package main
import "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
*/
defer 中的变量传递问题
值传递:当传递非指针类型(如普通变量)给 defer 时,defer 在执行时会使用传递时的快照值,即使在 defer 执行之前变量的值已改变,defer 中也不会感知这些变化。
引用传递:当传递指针、slice、map 等引用类型给 defer 时,defer 会看到对这些变量的修改。也就是说,defer 会引用这些变量的内存地址,因此修改会立即生效。
最佳实践:为了避免不可预期的行为,通常推荐在 defer 中使用匿名函数,并通过引用(如指针)传递变量,这样可以确保 defer 执行时使用最新的变量值。
1.非指针变量,作为参数传递
num := 0
// num 的修改不会影响到 innerNum
// 输出:innerNum = 0
defer func(innerNum int) {
fmt.Println("innerNum =", innerNum)
}(num)
num = 1
// 输出:innerNum = 0
在这个例子中,num 被传递给了 defer 语句中的匿名函数,并且是作为一个值传递的。即使在 defer 语句之后,num 的值发生了变化,defer 中的 innerNum 始终保持初始传入的值。因为是值传递,所以在 defer 被执行时,innerNum 始终为 0。
2.指针变量,作为参数传递
如果我们使用指针作为参数传递,情况就不同了:
intMap := make(map[int]int)
// intMap 的修改会被 defer 感知
// 输出:intMap = {"1":1}
defer func(innerMap map[int]int) {
byteArr, _ := json.Marshal(innerMap)
fmt.Println("intMap =", string(byteArr))
}(intMap)
intMap[1] = 1
// 输出:intMap = {"1":1}
这里,intMap 是一个指向 map[int]int 的指针,它被传递给 defer 语句中的匿名函数。因为是传递指针,所以 innerMap 在匿名函数执行时指向的是 intMap 的实际内存地址。这意味着在 defer 执行时,intMap 的修改(即 intMap[1] = 1)会影响 innerMap,并且 defer 会输出更新后的值。
3.匿名函数与变量引用
为了确保在 defer 中能够获取到函数外部变量的最新状态,我们可以使用匿名函数来捕获变量的引用。
num := 0
// num 的修改会被 defer 感知
// 输出:num = 1
defer func() {
fmt.Println("num =", num)
}()
num = 1
// 输出:num = 1
在这个例子中,defer 语句中的匿名函数会引用外部的 num 变量。在 defer 执行时,它会使用 num 在 defer 被执行时的最新值。因此,输出结果为 num = 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,程序仍能继续运行:
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")
}
输出:
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 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 中数组的“长度”是类型的一部分,必须匹配才能传参。
而切片
是对数组的抽象,长度不固定,函数参数中最常用,如下代码所示。
// 使用 切片(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 的数组指针,这种方式可以修改固定长度数组的值,但是数组长度仍需指定,如下所示:
// 使用 数组指针 [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 的语法概念。
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)本身仍是按值传递
,只是它内部结构包含一个对底层数组的引用,所以表现出“引用语义”。因为切片是一个轻量级结构体,它包含三个字段:
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] = 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 本体的“引用”,那就要传指针:func resetSlice(s *[]int) { // 修改 slice 的指向,让它指向一个新 slice *s = []int{100, 200, 300} }
下面再学习一下切片的四种声明方法:
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 的零值内存,并返回一个指向它的 指针。适用于所有类型(如结构体、数组、基本类型等)。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 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 会新开一块更大的内存,然后将原切片的内容复制过去,返回一个新的切片。
s := make([]int, 2, 2) ptr1 := &s[0] s = append(s, 99) ptr2 := &s[0] fmt.Println(ptr1 == ptr2) // false,说明底层数组已经变化(新地址)
最后再学习一下切片的截取
操作,一个类似python中的语法操作:
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的一些声明方式
:
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的基本使用方式
:
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 的情况复习一下: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 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),你需要手动复制每个键值对,例如:
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 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中面向对象类的表示与封装
:
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的继承
:
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() {...}
是用 指针接收者定义的。
但你可以这样用:s := SuperMan{...} s.Fly() // 为什么能调用?
因为这是 Go 的语法糖!Go 编译器在调用
s.Fly()
的时候:发现你调用的是*SuperMan
的方法,而s
是SuperMan
,不是指针。编译器会自动将s
转换为&s
,然后调用(&s).Fly()
这叫做自动取址
(auto address-taking)。同样的,反过来:如果方法接收者是值类型,你用的是指针去调用,Go 也会自动解引用调用
。
学习了go的面向对象类的封装与继承后,我们再看一下go的多态。实际上我们用刚才继承的这种方式是实现不了多态的,所以go语言中想要做多态的话需要有interface
这么一个接口
的概念。
一般我们说面向对象的层面,有一类或者说一个家族、一系列对象他们要有一定的接口,接口定义一些所谓的抽象方法 ,然后子类去继承并实现,达成一个抽象接口有很多种不同的动态表现形式,即面向对象的多态
。
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(指针接收者)上定义的: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 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*/
}
类型断言的另一种写法(不推荐用于不确定类型的情况):
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{} = 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 = 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 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
}
又比如下面的两个例子:
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 接口背后的运行时多态
*/
}
再比如下面的案例,有点抽象,多看两个例子学习一下:
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()
,看看官方的解释:
// 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 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:
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中比较特殊的语法,它需要用反射这种机制才能够解读:
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()
只有在你拿到的是指针类型时,才有意义。它的作用是:取出指针所指向的那个类型。例如:t2 := reflect.TypeOf(&re) // *resume t3 := t2.Elem() // resume
所以 如果我们这里传入的是值类型:它本身不是指针,没有“指向的元素”,所以不能再调用
Elem()
,否则就会 panic。
你可以不传指针,但要看你想要怎么写。如果传入值类型,可以不调用Elem()
,直接跟之前的例子一样使用t := reflect.TypeOf(str)
。
但通常我们习惯传入指针,是因为很多时候:结构体比较大,传指针效率高;统一处理逻辑(尤其在通用工具函数里);后续可能需要用到reflect.Value
修改字段值,修改值时必须使用指针。
结构体标签在json上的应用
上面介绍了通过反射手动对结构体标签进行解析,那么结构体标签在我们日常应用中又有哪些呢?其实go语言的json库就用到了结构体标签,如下代码所示,看看go语言解析json文件是如何用到结构体标签的。
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注解那么类型安全和强约束。
泛型
在 Go 引入泛型之前,我们通常使用接口(如 interface{})进行类型抽象,这在性能和安全性上有一定代价:
- 牺牲类型安全(编译期不能检查)
- 需要断言或反射
- 性能较差
泛型的引入解决了这些痛点,特别是在以下场景非常有用:
- 容器类型(如列表、栈、队列、Map)
- 工具函数(如 Min/Max、Map/Reduce)
- 可重用组件(如分页逻辑、数据过滤器)
基本语法与概念
Go 泛型语法类似函数或类型上的类型参数,形式如下:
func Name[T any](arg T) T
T
是类型参数名any
是类型约束(相当于 Java 的 Object)
示例一:通用 Swap 函数
// Swap 是一个通用函数,交换两个同类型值的位置
func Swap[T any](a, b T) (T, T) {
return b, a
}
func main() {
x, y := Swap[int](10, 20)
fmt.Println("x =", x, "y =", y)
s1, s2 := Swap[string]("Go", "Java")
fmt.Println("s1 =", s1, "s2 =", s2)
}
T any
表示 T 是任意类型- 你也可以省略
[int]
,Go 会自动推导
示例二:类型约束(Comparable)
// FindIndex 返回目标元素在切片中的下标,找不到返回 -1
func FindIndex[T comparable](arr []T, target T) int {
for i, v := range arr {
if v == target {
return i
}
}
return -1
}
func main() {
index := FindIndex([]string{"apple", "banana", "cherry"}, "banana")
fmt.Println("Index:", index) // 输出:1
}
comparable
是 Go 内置的约束,表示该类型可以使用==
比较- 类似 Java 中的
equals()
支持的类型
示例三:泛型数据结构 - Stack
// 定义一个泛型栈结构体
type Stack[T any] struct {
data []T
}
// Push 将元素压入栈
func (s *Stack[T]) Push(v T) {
s.data = append(s.data, v)
}
// Pop 弹出栈顶元素,返回值和是否成功
func (s *Stack[T]) Pop() (T, bool) {
if len(s.data) == 0 {
var zero T
return zero, false
}
val := s.data[len(s.data)-1]
s.data = s.data[:len(s.data)-1]
return val, true
}
func main() {
var intStack Stack[int]
intStack.Push(1)
intStack.Push(2)
v, ok := intStack.Pop()
if ok {
fmt.Println("Popped:", v) // 输出:2
}
}
- 通过
Stack[T]
实现通用数据结构 - 在 Web 项目中可以用于处理分页、缓存栈、操作日志等结构
示例四:通用分页函数(后端常用)
// Paginate 对任意类型的数据进行分页
func Paginate[T any](items []T, page, pageSize int) []T {
start := (page - 1) * pageSize
if start > len(items) {
return []T{}
}
end := start + pageSize
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func main() {
users := []string{"Alice", "Bob", "Charlie", "David", "Eve"}
page1 := Paginate(users, 1, 2)
page2 := Paginate(users, 2, 2)
fmt.Println("Page1:", page1) // ["Alice", "Bob"]
fmt.Println("Page2:", page2) // ["Charlie", "David"]
}
示例五:带多个类型参数的函数
// KeyValuePair 泛型结构体,模拟 Java 的 Map.Entry<K, V>
type KeyValuePair[K comparable, V any] struct {
Key K
Value V
}
func main() {
// 构造一个键值对
pair := KeyValuePair[string, int]{Key: "age", Value: 30}
fmt.Printf("Key: %s, Value: %d\n", pair.Key, pair.Value)
}
类型约束扩展:自定义约束
type Number interface {
~int | ~float64
}
// Sum 所有数字元素求和
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
-
~int
表示允许自定义类型底层是 int(如type MyInt int
) -
更加接近 Java 中的泛型边界约束
<T extends Number>
小结
特性 | Java 中概念 | Go 中实现方式 |
---|---|---|
泛型函数 | <T> T func(T arg) | func Func[T any](arg T) |
泛型类/结构体 | class Box<T> | type Box[T any] struct |
多类型参数 | <K, V> | [K comparable, V any] |
上界约束 | <T extends Number> | T Number (自定义接口) |
泛型容器 | List<T> | []T + 工具函数 |
推荐实践建议
- 使用泛型封装常用工具逻辑(分页、过滤、查找)
- 定义可读性强的类型参数(如 T, K, V, E 等)
- 谨慎使用 any,尽量加上类型约束
- 尽量封装成可复用包,提升代码质量