转Go学习笔记(1)语法入门

引言

本文学习前置要求
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 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 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 
 $./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.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+变量名称+变量类型 默认的值是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.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 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 语言中,有两个特殊的保留函数: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 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 结构,内建函数 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 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 中 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 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 的方法,而 sSuperMan,不是指针。编译器会自动将 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 = 5reflect.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,尽量加上类型约束
  • 尽量封装成可复用包,提升代码质量



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值