Loading...

文章背景图

Golang

2026-04-03
29
-
- 分钟
|

1 Golang 概述

摘要:Go语言是一种兼具C语言性能与Python开发效率的现代编程语言,采用静态编译与垃圾回收机制,具备天然并发特性。本文档系统介绍了Go语言的核心概念与实践应用,涵盖环境搭建、变量与数据类型、运算符、流程控制、函数与错误处理等基础内容。通过丰富的代码示例,帮助读者掌握Go语言的包管理、并发模型(goroutine/channel)、多返回值等特色功能,为后续深入学习面向对象编程、并发编程、网络编程等高级主题奠定基础。

1.1 Golang的特点

Go语言保证了既能到达静态编译语言的安全和性能,又达到了动态语言开发维护的高效率,使用一个表达式来形容Go语言:Go=C+Python,说明Go语言既有C静态语言程序的运行速度,又能达到Python动态语言的快速开发。

  • 从C语言中继承了很多理念,包括表达式语法,控制结构,基础数据类型,调用参数传值,指针等等,也保留了和C语言一样的编译执行方式及弱化的指针
  • 引入包的概念,用于组织程序结构,Go语言的一个文件都要归属于一个包,而不能单独存在
  • 垃圾回收机制,内存自动回收,不需开发人员管理
  • 天然并发:从语言层面支持并发,实现简单;goroutine,轻量级线程,可实现大并发处理,高效利用多核;基于CPS并发模型(Communicating Sequential Processes )实现
  • 吸收了管道通信机制,形成Go语言特有的管道channel通过管道channel,可以实现不同的goroute之间的相互通信。
  • 函数可以返回多个值

1.2 Go开发环境搭建

1.2.1 Windows开发环境搭建

国内可以在阿里云镜像站下载Go相关安装包,以最新版1.26.1为例,32位系统选择go1.26.1.windows-386.msi64位系统选择go1.26.1.windows-amd64.msi下载安装,安装路径不要有中文或者特殊符号如空格等

安装完成后默认配置了PATH,若没有配置可以自行配置下,一般我们配置三个路径,以我的安装目录C:\software\go为例,配置如下三个环境变量

GOROOT=C:\software\go
PATH=$PATH:%GOROOT%\bin
GOPATH=C:\data\code\Golang

其中GOPATH为工作路径,即你GO项目的存放路径,可以自行定义,完成后,使用go env查看配置是否正确

1.2.2 Linux/MacOS开发环境搭建

可以从上述的链接下载,先查看系统是arm架构还是amd架构,如下我的系统是amd64

(base) [root@VM-12-2-opencloudos ~]# uname -a
Linux VM-12-2-opencloudos 5.4.119-20.0009.29 #1 SMP Mon Aug 14 20:03:28 CST 2023 x86_64 x86_64 x86_64 GNU/Linux

选择适合自己版本的下载安装包

wget https://mirrors.aliyun.com/golang/go1.26.1.linux-amd64.tar.gz
tar -zxvf go1.26.1.linux-amd64.tar.gz && rm -f go1.26.1.linux-amd64.tar.gz
vim /etc/profile.d/golang.sh

# 粘贴下面三行到golang.sh GOPATH和GOROOT根据自己的配置来
export GOROOT=/opt/go
export GOPATH=/data/golang
export PATH=$GOPATH/bin:$PATH

source /etc/profile
go env

1.2.3 配置国内镜像代理

Golang部分包会使用国外的,这里可以配置代理来加速下载,目前常用代理如下

在Linux/MacOS,需要运行下面命令

# 启用 Go Modules 功能
go env -w GO111MODULE=on

go env -w GOPROXY=https://mirrors.aliyun.com/goproxy/,direct

# 验证是否启用了
go env | grep GOPROXY

在 Windows 上,需要运行下面命令:

# 启用 Go Modules 功能
$env:GO111MODULE="on"

$env:GOPROXY="https://mirrors.aliyun.com/goproxy/,direct"

如果你使用的 Go 版本 >=1.13, 你可以通过设置 GOPRIVATE 环境变量来控制哪些私有仓库和依赖 (公司内部仓库) 不通过 proxy 来拉取,直接走本地,设置如下:

# Go version >= 1.13
go env -w GOPROXY=https://goproxy.cn,direct
# 设置不走 proxy 的私有仓库,多个用逗号相隔
go env -w GOPRIVATE=*.corp.example.com

1.3 Go快速入门

1.3.1 Go目录工程结构

go-learning/              # 项目根目录(模块名)
├── go.mod                # Go 模块定义文件
├── README.md             # 项目说明文档
└── src/                  # 示例代码目录
    ├── ch01_hello/          # 第 1 章:Hello World - Go 语言入门
    	├── main.go			# 主入口
    ├── ch02_variables/      # 第 2 章:变量与常量
    ├── .......				# 其他章节

注意上述的这个目录需要在GOPATH目录下,按上述环境变量,应该是这样的

C:\data\code\Golang/
├── go-learning/ 

1.3.2 go.mod文件

项目go.mod`是必须的,是 Go 项目的核心文件,定义了

  • 块名称(如 module go-learning
  • Go 版本(如 go 1.21
  • 依赖项及其版本

没有 go.mod,Go 无法识别这是一个模块化项目,这里是个简单的项目,文件内容如下

module go-learning

go 1.21

1.3.3 代码目录

这里我们的代码放在src下,但src目录其实是可选的,取决于项目结构:

  • 模式 1:无 src 目录(推荐,常见于实际项目)
my-project/
├── go.mod
├── main.go
└── internal/
    └── app.go
  • 模式 2:有 src 目录(常见于学习项目、教学项目)
go-learning/
├── go.mod
└── src/
    ├── ch01_hello/
    └── ch02_variables/

还有一种GoPath目录,这种目录结构已经过时了,不推荐使用

$GOPATH/src/
└── my-project/
 └── main.go

1.3.4 目录文件

我们这里来看第一章节,其中里面有一个文件main.go,GO文件的后缀以.go为结尾,这里内容如下

// ============================================================================
// 第 1 章:Hello World - Go 语言入门
// ============================================================================

// package 关键字用于声明当前文件属于哪个包
// main 包是一个特殊的包,表示这是一个可执行程序
package main

// import 关键字用于导入其他包
// fmt 包是 Go 标准库中的格式化输入输出包
import (
	"fmt"
)

// main 函数是程序的入口点
// Go 程序从 main 函数的第一行代码开始执行
// Go方法由一条条语句构成,每个语句后不需要分号(Go语言会在每行后自动加分号)
func main() {
	// fmt.Println 用于输出一行文本,并自动换行
	fmt.Println("Hello, World!")
	fmt.Println("欢迎来到 Go 语言的世界!")

	// fmt.Printf 用于格式化输出
	// %s 是字符串占位符,%d 是整数占位符
	fmt.Printf("我正在学习 %s 语言,这是第 %d 章\n", "Go", 1)

	// fmt.Print 输出文本但不自动换行
	fmt.Print("学习 Go 很有趣!")
	fmt.Print("继续加油!\n")
}

上面AI的注释应该很详细了吧~,这里就不多解释里面的代码了

1.3.5 构建和运行

那怎么运行上面这个代码呢?我们仅需进入这个文件夹,执行下面命令即可

cd C:\data\code\Golang\go-learning\src

go run main.go

Hello, World!
欢迎来到 Go 语言的世界!
我正在学习 Go 语言,这是第 1 章
学习 Go 很有趣!继续加油!

go run命令可以直接运行go程序,类似执行一个脚本文件的形式,我们也可以先编译再执行

go build main.go

执行上述命令后,会生成一个main.exe文件,Go 编译生成的 .exe 文件,在没有安装 Go 环境的 Windows 电脑上可以直接双击运行,不需要安装 Go、不需要配置环境、不需要依赖任何运行库。

这是 Go 语言最核心的优势之一:静态编译

  • go build 会把所有依赖、标准库、运行时全部打包进一个独立的 .exe 文件里,不依赖外部动态链接库(.dll)。
  • 不像 Java 需要 JVM、Python 需要解释器、.NET 需要框架,Go 编译后的程序就是原生可执行文件,Windows 系统直接识别运行。

小补充:跨平台编译(可选):你还可以在自己的电脑上直接编译出能在 32 位 / 64 位 Windows 运行的 exe:

# 64位 Windows(最常用)
SET CGO_ENABLED=0
SET GOOS=windows
SET GOARCH=amd64
go build -o myapp64.exe

# 32位 Windows
SET CGO_ENABLED=0
SET GOOS=windows
SET GOARCH=386
go build -o myapp32.exe

加上 CGO_ENABLED=0 能确保纯静态编译,兼容性最强。

1.3.6 Golang执行流程分析

上述我们提到Golang可以直接运行或者编译后运行两种方案

如果是对源码编译后,再执行,Go的执行流程如下图

┌─────────────────┐		  ┌─────────────────┐		┌────────────────────────────┐
│ .go 文件(源文件) │ —————→ │  go build 编译  │ —————→ │ 可执行文件(.exe/可执行文件) │
└─────────┬───────┘		  └─────────────────┘		└──────────────┬─────────────┘
      													       │
         													   ▼
                            ┌─────────────────┐             ┌─────────────────┐
                            │      结果       │     ←—————   │      运行       │
                            └─────────────────┘             └─────────────────┘
         

如果我们是对源码直接执行 gorun 源码,Go的执行流程如下图

┌─────────────────┐       ┌─────────────────────────┐       ┌─────────────┐
│ .go 文件(源文件) │─────> │  go run 编译运行一步     │─────> │    结果     │
└─────────────────┘       └─────────────────────────┘       └─────────────┘

我们可以发现go build生成包的体积比较大,最简单的 hello world 程序默认编译:约 1.8MB ~ 2.2MB,这是静态编译的正常代价 —— 把所有东西都打包进一个文件里,体积自然会比动态链接的程序大。但 Go 官方提供了超简单的体积压缩方法,不用任何复杂操作,就能把 exe 体积大幅缩小,完全不影响程序运行,只是不能调试而已

go build -ldflags="-s -w" -o myapp.exe
  • -s:去掉符号表
  • -w:去掉调试信息

再结合upx 工具压缩,hello world 能压到 200KB 左右,而且运行完全正常。

// 下载 upx:https://upx.github.io/ 把 upx.exe 和你的程序放同一文件夹
upx myapp.exe

压缩后体积通常能再缩小 50%~70%,且不影响运行速度

1.3.7 Go程序开发的注意事项

  • Go源文件以“go"为扩展名。
  • Go应用程序的执行入口是main()函数。
  • Go语言严格区分大小写。
  • Go方法由一条条语句构成,每个语句后不需要分号(Go语言会在每行后自动加分号)
  • Go编辑器是一行行进行编译的,因此一行就写一条语句,不能把多条语句写在同一行,否则会报错
  • Go语言的变量或者import的包如果没有使用到,代码不能编译通过

2 Golang变量

2.1 变量的介绍

变量表示内存种的一个存储区域,该区域有自己的名称(变量名)和类型(数据类型)。变量的使用步骤为声明变量——非变量负责——使用变量

import "fmt"

func main() {
    var i int
    i = 10
    fmt.Println("i=", i)
}

2.2 变量的声明和使用

变量的声明方式有三种

  1. 指定变量类型,声明后若不赋值,则使用默认值。如上面的代码所示,i就是定义的一个变量,int的默认值为0
  2. 根据值自行判断变量类型(类型推导),如下面代码,height默认为float64类型
var height = 1.75
  1. 省略var,注意:=左侧的变量不应该是已经声明过的,否则会导致编译错误
// 下面方式等价于var name string \n name = "tom"
// := 前面 的 ":" 不能删除 否则会报编译错误
name := "tom"
  1. 多变量声明

在编程中,有时我们需要一次性声明多个变量,Golang也提供这样的语法,一次性声明多个变量同样支持类型推导

n1, name, n3 := 100, "tom", 888

也可以同时声明多个全局变量

var (
	n3 = 300
    n4 = 900
    name2 = "mary"
)

注意事项:一个区域的数据值可以在同一类型范围内不断变化,但不能改变数据类型

func main() {
    var i int = 10
    i = 30
    i = 50
    // 不能这么写 不能改变i的数据类型
    // i = 1.2 
}

2.3 数据类型

2.3.1 整数类型

简单的说,就是用于存放整数值的,比如0,-1,2345等等。

类型占用存储空间(字节)表数范围(有符号)表数范围(无符号)备注
int81- 128 ~ 1270 ~ 255
int162- 2^15^ ~ 2^15^ - 10 ~ 2^16^ - 1
int324- 2^31^ ~ 2^31^ - 10 ~ 2^32^ - 1
int648- 2^63^ ~ 2^63^ - 10 ~ 2^64^ - 1
int32位系统4字节
64位系统8字节
与操作系统类型一致-Go整形默认的类型
uint32位系统4字节
64位系统8字节
-与操作系统类型一致
rune4- 2^31^ ~ 2^31^ - 1-等价int32,表示一个Unicode码

如何在程序种查看某个变量的字节大小和数据类型呢,使用如下方法:

var age int = 25
fmt.Printf("年龄:%d, 类型:%T, 占用字节数:%d \n", age, age, unsafe.Sizeof(age))

2.3.2 小数类型/浮点类型

小数类型就是用于存放小数的,比如 1.2,0.23,-1.911,浮点数都是有符号的,浮点数默认为双精度

类型占用存储空间(字节)表数范围
单精度float324字节-3.403E38 ~ 3.403E38
双精度float648字节-1.798E308 ~ 1.798e308

浮点数=符号位+指数位+尾数位,尾数部分可能丢失,造成精度丢失

// num1 =  -123.0009  num2 =  -123.000901
var num1 float32 = -123.000901
var num2 float64 = -123.000901
fmt.Println("num1 = ", num1, " num2 = ", num2)

浮点型常量有两种表现形式,十进制或者科学计数法

num3 := 5.12
num4 := .123	// => 0.123

num5 := 5.1234e2
num6 := 5.1234E2
num7 := 5.1234E-2

2.3.3 字符类型

Golang中没有专门的字符类型,如果要存储单个字符(字母),一般使用byte来保存。字符串就是一串固定长度的字符连接起来的字符序列。Go的字符串是由单个字节连接起来的。也就是说对于传统的字符串是由字符组成的,而Go的字符串不同,它是由字节组成的。

字符常量是用单引号括起来的单个字符,下面是一些基础使用方法

// 字符类型
var c1 byte = 'a'
var c2 byte = '0'
// 当我们直接输出byte值,go会自动帮我们转成对应的字符, 实际输出的是码值
fmt.Println("c1=", c1) // c1= 97
fmt.Println("c2=", c2) // c2= 48
// 如果我们希望输出对应的字符,需要使用格式化输出
fmt.Printf("c1=%c, c2=%c\n", c1, c2)
// 格式化溢出
// var c3 byte = '啊'
var c3 int = '啊'
fmt.Printf("c3=%c\n", c3)

在Go中,字符的本质是一个整数,直接输出时,是该字符对应的UTF-8编码的码值。可以直接给某个变量赋一个数字,然后按格式化输出时%c,会输出该数字对应的unicode 字符。Go语言的编码都统一成了utf-8。非常的方便,很统一,再也没有编码乱码的困扰了

2.3.4 布尔类型

布尔类型也叫 bool类型,bool 类型数据只允许取值true和falsebodl类型占1个字节。bool 类型适于逻辑运算,一般用于程序流程控制

var isStudent bool = true

2.3.5 字符串类型

字符串就是一串固定长度的字符连接起来的字符序列。Go的字符串是由单个字节连接起来的。Go语言的字符串的字节使用UTF-8编码标识Unicode文本

str := "Hello, Go!"
fmt.Printf("字符串:%s\n", str)
fmt.Printf("长度(字节数):%d\n", len(str))

字符串是不可变的字节序列,即其一旦赋值了,就不能再修改了

上述使用双引号表示字符串,这种字符串会识别转义字符,Go语言还提供了反引号实现字符串的原生形式输出,包括换行和特殊字符,可以实现防止攻击、输出源代码等效果

	multiLine := `这是第一行
这是第二行
这是第三行`

字符串也可以实现拼接操作

// 字符串拼接
greeting := "Hello"
name := "World"
full := greeting + ", " + name + "!"
fmt.Printf("拼接结果:%s\n", full)

2.4 基本数据类型的默认值

在go中,数据类型都有一个默认值,当程序员没有赋值时,就会保留默认值,在go中,默认值又叫零值。

基本数据类型的默认值如下:

// 当声明变量但不初始化时,Go 会自动赋予"零值"
var defaultInt int       // 0
var defaultFloat float64 // 0.0
var defaultString string // ""
var defaultBool bool     // false
fmt.Printf("int 零值:%d\n", defaultInt)
fmt.Printf("float64 零值:%.1f\n", defaultFloat)
fmt.Printf("string 零值:'%s'\n", defaultString)
fmt.Printf("bool 零值:%t\n", defaultBool)

2.5 基本数据类型的相互转换

Golang 和java/c不同,Go在不同类型的变量之间赋值时需要显式转换。也就是说Golang 中数据类型不能自动转换。表达式T(v)将值v转换为类型T,如下

// Go 不允许隐式类型转换,必须显式转换
var intNum int = 42
var floatNum float64 = float64(intNum) // int 转 float64
var intNum2 int = int(floatNum)        // float64 转 int
fmt.Printf("int: %d -> float64: %.1f -> int: %d\n", intNum, floatNum, intNum2)

Go中,数据类型的转换可以是从表示范围小到表示范围大,也可以范围大到范围小,编译时不会报错,只是转换的结果是按溢出处理,和我们希望的结果不一样。因此在转换时,需要考虑范围。

被转换的是变量存储的数据(即值),变量本身的数据类型并没有变化

特别的,在程序开发中,我们经常将基本数据类型转成 string,或者将 string转成基本数据类型。

基本类型转string类型方法如下

  1. 使用fmt.Springtf("%参数", 表达式)
  2. 使用strconv包中的函数
// 方法1: 使用fmt.Sprintf()
str = fmt.Sprintf("%d", myInt)
fmt.Printf("转换为字符串:%s\n", str)
// 方法2: 使用strconv包的函数
str = strconv.FormatInt(int64(myInt), 10)
fmt.Printf("转换为字符串:%s\n", str)

string转基本数据类型就直接用strconv包的函数就行

var num8 int64
str = "123"
// ParseInt() 函数用于将字符串转换为整数, 会返回 int64 和 error
num8, _ = strconv.ParseInt(str, 10, 64)
fmt.Printf("转换为整数:%d\n", num8)

在将String类型转成基本数据类型时,要确保String类型能够转成有效的数据,比如我们可以把"123",转成一个整数,但是不能把"hello”转成一个整数,如果这样做,Golang直接将其转成0,其它类型也是一样的道理(转换为零值)

2.6 指针

指针是指向变量内存地址的引用,& 取地址运算符,* 解引用运算符,使用方法如下

// 普通变量
x := 10
fmt.Printf("x 的值:%d\n", x)									 // x 的值:10
fmt.Printf("x 的内存地址:%p\n", &x) // %p 用于打印地址			    // x 的内存地址:0x1c0c0c0

// 指针变量
var p *int // p 是一个指向 int 类型数据的指针
p = &x     // 将 x 的地址赋值给 p
fmt.Printf("p 的值 (存储的地址):%p\n", p)						   // p 的值 (存储的地址):0x1c0c0c0
fmt.Printf("p 指向的值:%d\n", *p) // 解引用,获取指针指向的值		// p 指向的值:10

// 简写形式
y := 20
q := &y // 直接获取地址
fmt.Printf("\ny = %d, &y = %p, q = %p, *q = %d\n", y, &y, q, *q)  // y = 20, &y = 0x1c0c0c4, q = 0x1c0c0c4, *q = 20

2.7 标识符命名规则

Golang对各种变量、方法、函数等命名时使用的字符序列称为标识符。标识符,遵循下面几个规则

  • 由26个英文字母大小写,0-9,_组成
  • 数字不可以作为标识符的开头
  • Golang中严格区分大小写
  • 标识符不能包含空格
  • 下划线"_"本身在Go中是一个特殊的标识符,称为空标识符。可以代表任何其它的标识符,但是它对应的值会被忽略(比如:忽略某个返回值)。所以仅能被作为占位符使用,不能作为标识符使用
  • 不能以系统保留关键字作为标识符(一共有25个),比如 break,if等等。
  • 使用驼峰命名法(CamelCase),首字母大写表示公开(exported),其他包可以访问,首字母小写表示私有(private),仅当前包内访问

3 运算符

3.1 运算符基本介绍

运算符是一种特殊的符号,用以表示数据的运算、赋值和比较等,包括

  • 算术运算符
  • 赋值运算符
  • 比较运算符/关系运算符
  • 逻辑运算符
  • 位运算符
  • 其它运算符

3.2 算术运算符

算术运算符是对数值类型的变量进行运算的,比如:加减乘除。在Go程序中使用的非常多

运算符运算范例结果
+正号+33
-负号-4-4
+5 + 510
-6 - 42
*3 * 412
/5 / 51
%取模 (取余)7 % 52
++自增a=2; a++a=3
--自减a=2; a--a=1
+字符串相加"He" + "llo""Hello"
fmt.Println("--- 算术运算符 ---")

a, b := 17, 5
fmt.Printf("a = %d, b = %d\n\n", a, b)

fmt.Printf("a + b = %d (加法)\n", a+b)
fmt.Printf("a - b = %d (减法)\n", a-b)
fmt.Printf("a * b = %d (乘法)\n", a*b)
fmt.Printf("a / b = %d (整数除法,舍弃小数)\n", a/b)
fmt.Printf("a %% b = %d (取余/模运算)\n", a%b)

// 浮点数除法
x, y := 17.0, 5.0
fmt.Printf("\n%.1f / %.1f = %.2f (浮点除法)\n", x, y, x/y)

// ------------------------------------------------------------------------
// 2. 自增和自减运算符
// ------------------------------------------------------------------------
fmt.Println("\n--- 自增自减 ---")

counter := 0
counter++ // 加 1(注意:Go 没有++i 或 i++ 表达式,只能是语句)
fmt.Printf("counter++ 后:%d\n", counter)
counter-- // 减 1
fmt.Printf("counter-- 后:%d\n", counter)

重要:Go 只有后缀形式,没有前缀形式

++counter  // 错误!

3.3 关系运算符

关系运算符的结果都是bool型,也就是要么是true,要么是false。经常用在if结构的条件中或循环结构的条件中

运算符运算范例结果
==相等于4==3false
!=不等于4!=3true
<小于4<3false
>大于4>3true
<=小于等于4<=3false
>=大于等于4>=3true
fmt.Println("\n--- 关系运算符 ---")

num1, num2 := 10, 20
fmt.Printf("num1 = %d, num2 = %d\n\n", num1, num2)

fmt.Printf("num1 == num2 : %t (等于)\n", num1 == num2)
fmt.Printf("num1 != num2 : %t (不等于)\n", num1 != num2)
fmt.Printf("num1 > num2  : %t (大于)\n", num1 > num2)
fmt.Printf("num1 < num2  : %t (小于)\n", num1 < num2)
fmt.Printf("num1 >= num2 : %t (大于等于)\n", num1 >= num2)
fmt.Printf("num1 <= num2 : %t (小于等于)\n", num1 <= num2)

// 字符串比较(按字典序)
str1, str2 := "apple", "banana"
fmt.Printf("\n'%s' < '%s' : %t (字典序)\n", str1, str2, str1 < str2)

3.4 逻辑运算符

用于连接多个条件(一般来讲就是关系表达式),最终的结果也是一个bool值

运算符描述实例
&&逻辑与运算符:两边操作数均为 True 时结果为 True,否则为 False(A && B) 为 False
||逻辑或运算符:两边操作数有一个为 True 时结果为 True,否则为 False(A || B) 为 True
!逻辑非运算符:条件为 True 时结果为 False,条件为 False 时结果为 True!(A && B) 为 True
  • &&也叫短路与:如果第一个条件为false,则第二个条件不会判断,最终结果为false
  • ||也叫短路或:如果第一个条件为true,则第二个条件不会判断,最终结果为true
fmt.Println("\n--- 逻辑运算符 ---")

t, f := true, false
fmt.Printf("t = %t, f = %t\n\n", t, f)

fmt.Printf("t && f : %t (与:都为 true 才为 true)\n", t && f)
fmt.Printf("t || f : %t (或:一个为 true 就为 true)\n", t || f)
fmt.Printf("!t     : %t (非:取反)\n", !t)
fmt.Printf("!f     : %t\n", !f)

// 短路求值(重要特性)
fmt.Println("\n--- 短路求值 ---")
// && 运算:如果左边为 false,右边不会执行
// || 运算:如果左边为 true,右边不会执行
result := false && willNotExecute() // willNotExecute 不会调用
fmt.Printf("false && xxx = %t (右边未执行)\n", result)

result2 := true || willNotExecute2() // willNotExecute2 不会调用
fmt.Printf("true || xxx = %t (右边未执行)\n", result2)

3.5 赋值运算符

运算符描述实例
=简单赋值c = A + B(将 A+B 结果赋值给 c)
+=相加后再赋值c += A 等同于 c = c + A
-=相减后再赋值c -= A 等同于 c = c - A
*=相乘后再赋值c *= A 等同于 c = c * A
/=相除后再赋值c /= A 等同于 c = c / A
%=求余后再赋值c %= A 等同于 c = c % A
<<=左移后赋值c <<= 2 等同于 c = c << 2
>>=右移后赋值c >>= 2 等同于 c = c >> 2
&=按位与后赋值c &= 2 等同于 c = c & 2
^=按位异或后赋值c ^= 2 等同于 c = c ^ 2
|=按位或后赋值c |= 2 等同于 c = c | 2
fmt.Println("\n--- 赋值运算符 ---")

val := 10
fmt.Printf("初始值:%d\n", val)

val += 5 // val = val + 5
fmt.Printf("val += 5  -> %d\n", val)
val -= 3 // val = val - 3
fmt.Printf("val -= 3  -> %d\n", val)
val *= 2 // val = val * 2
fmt.Printf("val *= 2  -> %d\n", val)
val /= 4 // val = val / 4
fmt.Printf("val /= 4  -> %d\n", val)
val %= 3 // val = val % 3
fmt.Printf("val %%= 3  -> %d\n", val)
val <<= 2 // val = val << 2
fmt.Printf("val <<= 2 -> %d\n", val)
val >>= 1 // val = val >> 1
fmt.Printf("val >>= 1 -> %d\n", val)
val &= 7 // val = val & 7
fmt.Printf("val &= 7  -> %d\n", val)
val |= 8 // val = val | 8
fmt.Printf("val |= 8  -> %d\n", val)
val ^= 15 // val = val ^ 15
fmt.Printf("val ^= 15 -> %d\n", val)

注意

  • 赋值运算符的运算顺序从右往左
  • 赋值运算符的左边 只能是变量,右边可以是变量、表达式、常量值

3.6 位运算符

运算符描述
&按位与:双目运算符,两数对应二进制位同时为 1 时结果为 1,否则为 0
|按位或:双目运算符,两数对应二进制位有一个为 1 时结果为 1,否则为 0
^按位异或:双目运算符,两数对应二进制位不同时结果为 1,否则为 0
<<左移:双目运算符,将左操作数的二进制位全部左移指定位数,高位丢弃、低位补 0,左移 n 位等价于乘以 2 的 n 次方
>>右移:双目运算符,将左操作数的二进制位全部右移指定位数,右移 n 位等价于除以 2 的 n 次方(图片中该部分内容未完全显示,按可见信息整理)
fmt.Println("\n--- 位运算符 ---")

x1, x2 := 12, 5 // 二进制:12=1100, 5=0101
fmt.Printf("x1 = %d (二进制:%04b)\n", x1, x1)
fmt.Printf("x2 = %d (二进制:%04b)\n\n", x2, x2)

fmt.Printf("x1 & x2  = %d (二进制:%04b) - 按位与\n", x1&x2, x1&x2)
fmt.Printf("x1 | x2  = %d (二进制:%04b) - 按位或\n", x1|x2, x1|x2)
fmt.Printf("x1 ^ x2  = %d (二进制:%04b) - 按位异或\n", x1^x2, x1^x2)
fmt.Printf("x1 << 2  = %d (二进制:%08b) - 左移 2 位\n", x1<<2, x1<<2)
fmt.Printf("x1 >> 2  = %d (二进制:%04b) - 右移 2 位\n", x1>>2, x1>>2)
fmt.Printf("^x1      = %d - 按位取反\n", ^x1)

位运算有两个实用的小技巧:

  • 判断奇偶性
num := 7
if num&1 == 0 {
    fmt.Printf("%d 是偶数\n", num)
} else {
    fmt.Printf("%d 是奇数\n", num)
}
  • 交换两个数(不使用临时变量)
p, q := 10, 20
p = p ^ q
q = p ^ q
p = p ^ q
fmt.Printf("交换后:p=%d, q=%d\n", p, q)

3.7 其他运算符

运算符描述实例
&返回变量存储地址&a; 将给出变量的实际地址
*指针变量(解引用)*a; 是一个指针变量

注意:Go语言明确不支持三元运算符(?:)

3.8 运算符的优先级

见下表:

分类描述关联性优先级
后缀() [] -> . ++ --左到右最高
单目+ - ! ~ (type) * & sizeof右到左次高
乘法* / %左到右
加法+ -左到右中高
移位<< >>左到右
关系< <= > >=左到右中低
相等(关系)== !=左到右中低
按位 AND&左到右较低
按位 XOR^左到右较低
按位 OR|左到右较低
逻辑 AND&&左到右
逻辑 OR||左到右
赋值运算符= += -= *= /= %= >>= <<= &= ^= |=右到左次低
逗号,左到右最低

3.9 键盘输入语句

在编程中,需要接收用户输入的数据,就可以使用键盘输入语句来获取

  • func Scanln(a ...interface{}) {n int, err error}:ScanIn类似Scan,但会在换行时才停止扫描。最后一个条目后必须有换行或者到达结束位置。
  • func Scanf(format string, a ...interface{}) (n int, err error):Scant从标准输入扫描文本,根据format参数指定的格式将成功读取的空白分隔的值保存进成功传递给本函数的参数。返回成功扫描的条目个数和遇到的任何错误。
var name string
fmt.Println("请输入姓名: ")
fmt.Scanln(&name)
fmt.Println("名字是: %v", name)

var age byte
var salary float64
fmt.Printf("请输入姓名, 年龄, 薪水, 用空格隔开")
fmt.Scanf("%s %d %f", &name, &age, &salary)
fmt.Printf("名字是: %v, 年龄是: %v, 薪水是: %v", name, age, salary)

4 程序控制流程

在程序中,程序运行的流程控制决定程序是如何执行的,是我们必须掌握的,主要有三大流程控制语句。

  • 顺序控制
  • 分支控制
  • 循环控制

4.1 顺序控制

程序从上到下逐行地执行,中间没有任何判断和跳转。我们正常上面开发的内容都算是顺序控制。

4.2 分支控制

4.2.1 IF分支控制

分支控制如下:

// 基本形式
age := 18
if age >= 18 {
    fmt.Println("你已经成年了")
}

// if-else // 注意else不能换行
temperature := 25
if temperature > 30 {
    fmt.Println("天气很热")
} else {
    fmt.Println("天气舒适")
}

// if-else if-else
score := 85
if score >= 90 {
    fmt.Println("优秀")
} else if score >= 80 {
    fmt.Println("良好")
} else if score >= 60 {
    fmt.Println("及格")
} else {
    fmt.Println("不及格")
}

Go语言中有一个特色的if带初始化语句,如下

// num 只在 if 语句块内有效
if num := 42; num > 0 {
    fmt.Printf("%d 是正数\n", num)
}
// fmt.Println(num)  // 这里会报错:num 超出作用域

4.2.2 Switch分支控制

switch语句用于基于不同条件执行不同动作,每一个case 分支都是唯一的,从上到下逐一测试,直到匹配为止。

day := 3
switch day {
    case 1:
    	fmt.Println("星期一")
    case 2:
    	fmt.Println("星期二")
    case 3:
    	fmt.Println("星期三")
    case 4:
    	fmt.Println("星期四")
    case 5:
    	fmt.Println("星期五")
    case 6, 7: // 一个 case 可以匹配多个值
    	fmt.Println("周末")
    default:
    	fmt.Println("无效的日子")
}

如果switch后面不带表达式的话,可以类似if--else分支使用

fmt.Println("\n不带表达式的 switch:")
grade := 75
switch {
    case grade >= 90:
    	fmt.Println("A 等级")
    case grade >= 80:
    	fmt.Println("B 等级")
    case grade >= 70:
    	fmt.Println("C 等级")
    default:
    	fmt.Println("D 等级")
}

需要注意的是,与Java等语言不同的是,case后面不需要带break,程序匹配到个case后就会执行对应的代码块,然后退出 switch,如果一个都匹配不到,则执行default,如果在case语句块后增加fallthrough,则会继续执行下一个case,也叫 switch穿透

num := 1
switch num {
    case 1:
    	fmt.Println("case 1")
    	fallthrough // 继续执行下一个 case
    case 2:
    	fmt.Println("case 2")
    	// 没有 fallthrough,不会继续
    case 3:
    	fmt.Println("case 3")
}

switch 语句还可以被用于 type-switch 来判断某个 interface 变量中实际指向的变量类型,这部分后面会讲,这里仅了解即可。

var x interface{}
var y = 10.0
x = y
switch i := x.(type) {
    case nil:
        fmt.Printf(" x 的类型~ :%T",i)
    case int:
        fmt.Printf("x 是 int 型")
    case float64:
        fmt.Printf("x 是 float64 型")
    case func(int) float64:
        fmt.Printf("x 是 func(int) 型")
    case bool, string:
        fmt.Printf("x 是 bool 或 string 型" )
    default:
        fmt.Printf("未知型")
}

4.3 循环控制

听其名而知其意。就是让我们的一段代码循环的执行,完整的for循环(和其他语言的 for 一样)如下

for i := 0; i < 5; i++ {
    fmt.Printf("%d ", i)
}

如果省略初始化(但要有分号),如下

j := 0
for ; j < 3; j++ {
    fmt.Printf("%d ", j)
}

如果省略后增量,如下

k := 0
for k < 3 {
    fmt.Printf("%d ", k)
    k++
}

无限循环如下(需配合break退出循环)

count := 0
for {
    if count >= 3 {
        break // 跳出循环
    }
    fmt.Printf("%d ", count)
    count++
}

与Python很像,Golang也提供了For-range模式,可以方便遍历字符串和数组

// 打印全量
fruits := []string{"apple", "banana", "orange"}
for index, value := range fruits {
    fmt.Printf("[%d] = %s\n", index, value)
}

// 只需要索引
fmt.Println("\n只需要索引:")
for i := range fruits {
    fmt.Printf("%d ", i)
}
fmt.Println()

// 只需要值(使用空白标识符 _ 忽略索引)
fmt.Println("\n只需要值:")
for _, fruit := range fruits {
    fmt.Printf("%s ", fruit)
}
fmt.Println()

// 遍历字符串(得到 rune)
fmt.Println("\n遍历字符串:")
str := "Go 语言"
for i, char := range str {
    fmt.Printf("[%d] = %c\n", i, char)
}

对遍历字符串,需要注意的是,传统我们遍历是采用这种方式

var str string = "hello,world!北京"
str2 := []rune(str)
for i := 0; i < len(str2); i++ {
    fmt.Printf("%c \n", str2[i]) //使用到下标...
}

但我们提过,Go 中 string 的本质是UTF-8 编码的字节切片,其中ASCII 字符(英文字母、数字、符号)在 UTF-8 中占 1 字节,中文、emoji 等 Unicode 字符在 UTF-8 中占 3 字节,上面这种遍历方法,相当于按字节遍历,会把一个汉字拆成 3 个无效字节,输出乱码,因此我们需要采用案例中的For-range进行字符串遍历,这种会得到[]rune,会将 UTF-8 字节流解码为 Unicode 码点切片,每个元素对应一个完整字符,解决了按字节拆分的问题

另外,Go语言没有while和do...while语法,这一点需要注意一下,如果我们需要使用类似其它语言(比如 java/c的while和 do...while),可以通过for 循环来实现其使用效果。

4.4 跳转控制语句

4.4.1 Break

break 语句用于终止循环或 switch 语句,跳出当前循环体,执行循环后面的语句

在 for 循环中使用:

// break 用于跳出整个循环
for i := 0; i < 10; i++ {
    if i == 5 {
        fmt.Println("i 等于 5,跳出循环")
        break // 当 i==5 时,跳出整个循环
    }
    fmt.Printf("i = %d\n", i)
}
// 输出:i = 0, 1, 2, 3, 4

在嵌套循环中使用(配合标签):

// 默认 break 只能跳出当前层循环
for i := 0; i < 3; i++ {
    for j := 0; j < 3; j++ {
        if j == 1 {
            break // 只跳出内层循环
        }
        fmt.Printf("i=%d, j=%d\n", i, j)
    }
}

// 使用标签跳出指定循环
outer: // 定义标签
    for i := 0; i < 3; i++ {
        for j := 0; j < 3; j++ {
            if i == 1 && j == 1 {
                break outer // 跳出 outer 标签标记的外层循环
            }
            fmt.Printf("i=%d, j=%d\n", i, j)
        }
    }

在 switch 中使用:

switch {
case true:
    fmt.Println("匹配到 case")
    break // 跳出 switch(其实 Go 中 switch 默认就会自动跳出,break 可省略)
}

注意:Go 的 switch 默认执行完匹配的 case 后就会自动跳出,不需要像 Java 那样加 break

4.4.2 Continue

continue 语句用于跳过当前循环的剩余语句,直接进入下一次循环

基本用法:

// continue 跳过本次循环剩余代码,进入下一次循环
for i := 0; i < 5; i++ {
    if i == 2 {
        fmt.Println("跳过 i=2")
        continue // 跳过本次循环剩余代码
    }
    fmt.Printf("i = %d\n", i)
}
// 输出:i = 0, 1, 跳过 i=2, i = 3, 4

在嵌套循环中使用:

// continue 只影响当前层循环
for i := 0; i < 3; i++ {
    for j := 0; j < 3; j++ {
        if j == 1 {
            continue // 跳过内层循环的本次迭代
        }
        fmt.Printf("i=%d, j=%d\n", i, j)
    }
}
// j=1 的迭代被跳过,但 i 循环继续

break vs continue 的区别:

  • break:直接终止整个循环,不再执行任何迭代
  • continue:只跳过本次迭代,继续执行下一次迭代

4.4.3 Goto

goto 语句用于无条件跳转到同一函数内的标签处

基本语法:

func main() {
    i := 0
    goto LABEL // 跳转到 LABEL 标签处

LABEL: // 标签定义(注意冒号)
    fmt.Println("跳转到这里了")
    i++
    if i < 3 {
        goto LABEL // 再次跳转,形成循环
    }
}

goto 的实用场景:

虽然 goto 通常被认为是不好的编程习惯,但在某些特定场景下很有用:

  1. 从多层嵌套中快速退出:
func process() error {
    // ... 一些操作
    if err != nil {
        goto CLEANUP
    }
    // ... 更多嵌套操作
    if err != nil {
        goto CLEANUP
    }

CLEANUP:
    // 统一的资源清理代码
    return err
}
  1. 实现简单的状态机或重试逻辑

注意: 滥用 goto 会导致代码难以理解和维护("面条代码"),应优先使用循环、函数等结构化控制流

4.4.4 Return

return 语句用于从函数返回,终止函数的执行,可以返回零个或多个值

基本用法:

// 无返回值函数
func sayHello() {
    fmt.Println("Hello")
    return // 可省略,函数执行完会自动返回
}

// 有返回值函数
func add(a, b int) int {
    return a + b
}

// 多个返回值
func swap(a, b int) (int, int) {
    return b, a
}

// 命名返回值(Go 特色)
func calc(a, b int) (sum, diff int) {
    sum = a + b
    diff = a - b
    return // 裸 return,返回命名变量的值
}

return 在控制流中的作用:

// 提前返回,避免深层嵌套
func checkAge(age int) string {
    if age < 0 {
        return "年龄不能为负数" // 提前返回
    }
    if age < 18 {
        return "未成年"
    }
    return "成年"
}

// 守卫语句(Guard Clauses)—— 提前处理异常情况
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("除数不能为 0") // 守卫语句
    }
    return a / b, nil
}

return、break、continue 的区别:

关键字作用范围作用
return函数终止整个函数执行,返回值给调用者
break循环/switch终止当前循环或 switch,执行后续语句
continue循环跳过本次迭代,继续下一次迭代

5 函数、包和错误处理

5.1 函数基础用法

为完成某一功能的程序指令(语句)的集合,称为函数。在Go中,函数分为:自定义函数和系统函数

函数的基本用法如下

func 函数名 (形参列表) (返回值列表) {
    执行语句
    return 返回值列表
}

例如:

// 函数使用
quotient, remainder := divide(17, 5)

// 函数定义
func divide(a, b int) (int, int) {
	quotient := a / b
	remainder := a % b
	return quotient, remainder
}
  • Go函数支持返回多个值,如果返回多个值时,在接收时,希望忽略某个返回值,则使用"_"符号表示占位忽略
  • 如果返回值只有一个,(返回值类型列表)可以不写
  • 函数的形参列表可以是多个,返回值列表也可以是多个。
  • 形参列表和返回值列表的数据类型可以是值类型和引用类型。
  • 函数的命名遵循标识符命名规范,首字母不能是数字,首字母大写该函数可以被本包文件和其它包文件使用,类似public,首字母小写,只能被本包文件使用,其它包文件不能使用,类似privat
  • 函数中的变量是局部的,函数外不生效
  • 基本数据类型和数组默认都是值传递的,即进行值拷贝。在函数内修改,不会影响到原来的值。
  • 如果希望函数内的变量能修改函数外的变量(指的是默认以值传递的方式的数据类型),可以传入变量的地址&,函数内以指针的方式操作变量。从效果上看类似引用
  • Go函数不支持函数重载
  • 在Go中,函数也是一种数据类型,可以赋值给一个变量,则该变量就是一个函数类型的变量了。通过该变量可以对函数调用
  • 函数既然是一种数据类型,因此在Go中,函数可以作为形参,并且调用

经典案例如下:

// greet 是一个简单的函数,没有返回值
func greet(name string) {
	fmt.Printf("你好,%s!\n", name)
}

// add 演示基本的函数定义
// 参数类型相同可以简写
func add(x, y int) int {
	return x + y
}

// divide 演示多返回值
func divide(a, b int) (int, int) {
	quotient := a / b
	remainder := a % b
	return quotient, remainder
}

// safeDivide 演示错误处理
// 返回 (结果,错误) 是 Go 的惯例
func safeDivide(a, b int) (int, error) {
	if b == 0 {
		return 0, fmt.Errorf("除数不能为零")
	}
	return a / b, nil
}

// getPerson 演示命名返回值
// 返回值有名字,自动初始化为零值
func getPerson() (name string, age int) {
	name = "Bob"
	age = 30
	return // 裸 return,等价于 	return name, age
}

// addNumbers 演示可变参数
// numbers ...int 表示可以传入任意数量的 int
func addNumbers(numbers ...int) int {
	sum := 0
	for _, n := range numbers {
		sum += n
	}
	return sum
}

// makeCounter 返回一个闭包(计数器函数)
func makeCounter() func() int {
	count := 0 // 这个变量在闭包外部
	return func() int {
		count++
		return count
	}
}

// applyOperation 演示函数作为参数
func applyOperation(a, b int, op func(int, int) int) int {
	return op(a, b)
}

// factorial 递归计算阶乘
func factorial(n int) int {
	if n <= 1 {
		return 1
	}
	return n * factorial(n-1)
}

// fibonacci 递归计算斐波那契数
func fibonacci(n int) int {
	if n <= 1 {
		return n
	}
	return fibonacci(n-1) + fibonacci(n-2)
}

5.2 包

go的每一个文件都是属于一个包的,也就是说go是以包的形式来管理文件和项目目录结构。包的三大作用如下

  • 区分相同名字的函数、变量等标识符
  • 当程序文件很多时,可以很好的管理项目
  • 控制函数、变量等访问范围,即作用域

该包对应一个文件夹,utils文件夹对应的包名就是utils。文件的包名通常和文件所在的文件夹名一致,一般为小写字母。当一个文件要使用其它包函数或变量时,需要先引入对应的包

// import "包名"
import (
	"包名"
    // 相当于给包起名为util
    util "包名"
)

在import 包时,路径从$GOPATH的 sre 下开始,不用带src,编译器会自动从src下开始引入。为了让其它包的文件,可以访问到本包的函数,则该函数名的首字母需要大写,类似其它语言的public,这样才能跨包访问。在同一包下,不能有相同的函数名(也不能有相同的全局变量名),否则报重复定义

5.2 init函数

每一个源文件都可以包含一个imit函数,该函数会在main函数执行前,被Go运行框架调用,也就是说init会在main 函数前被调用。

// init 函数在 main 函数之前自动执行
// 每个包可以有多个 init 函数
// 常用于初始化操作
func init() {
	// 这里可以放初始化代码
	// 本示例中为空
}

如果一个文件同时包含全局变量定义,init函数和main函数,则执行的流程全局变量定义>init函数->main函数

5.3 匿名函数

Go支持匿名函数,匿名函数就是没有名字的函数,如果我们某个函数只是希望使用一次,可以考虑使用匿名函数,匿名函数也可以实现多次调用。

// 匿名函数是没有名字的函数
// 可以直接定义并调用
func(msg string) {
    fmt.Printf("匿名函数:%s\n", msg)
}("Hello")

// 赋值给变量
multiply := func(x, y int) int {
    return x * y
}
fmt.Printf("5 * 3 = %d\n", multiply(5, 3))

// 在切片中使用匿名函数
nums := []int{1, 2, 3, 4, 5}
for _, n := range nums {
    // 立即执行的函数
    func(num int) {
        if num%2 == 0 {
            fmt.Printf("%d 是偶数\n", num)
        }
    }(n)
}

5.4 闭包

闭包就是一个函数和与其相关的引用环境组合的一个整体

// makeCounter 返回一个闭包(计数器函数)
func makeCounter() func() int {
	count := 0 // 这个变量在闭包外部
	return func() int {
		count++
		return count
	}
}

func main() {
    // 计数器生成器
	counter := makeCounter()
	fmt.Printf("counter() = %d\n", counter()) // 1
	fmt.Printf("counter() = %d\n", counter()) // 2
	fmt.Printf("counter() = %d\n", counter()) // 3

	// 另一个独立的计数器
	counter2 := makeCounter()
	fmt.Printf("counter2() = %d\n", counter2()) // 1(独立状态)
}
  • 返回的是一个匿名函数,但是这个匿名函数引用到函数外的count,因此这个匿名函数就和count形成一个整体,构成闭包。
  • 当我们反复的调用counter函数时,因为count是初始化一次,因此每调用一次就进行累计。
  • 闭包的关键,就是要分析出返回的函数它使用(引用)到哪些变量,因为函数和它引用到的变量共同构成闭包。

5.5 defer语句

在函数中,程序员经常需要创建资源(比如:数据库连接、文件句柄、锁等),为了在函数执行完毕后,及时的释放资源,Go的设计者提供defer(延时机制)。直到当前函数返回后多个 defer 按 LIFO(后进先出)顺序执行

func main () {
    fmt.Println("开始 defer 演示")
	defer func() {
		fmt.Println("defer 1: 最后执行")
	}()
	defer func() {
		fmt.Println("defer 2: 倒数第二执行")
	}()
	defer fmt.Println("defer 3: 第二个执行")

	fmt.Println("defer 0: 第一个执行")
	fmt.Println("普通代码立即执行")
}
  • 在golang编程中的通常做法是,创建资源后,比如(打开了文件,获取了数据库的链接,或者是锁资源), 可以执行 defer file.Close或者defer connect.Close
  • 在defer后,可以继续使用创建资源.
  • 当函数完毕后,系统会依次从defer栈中,取出语句,关闭资源
  • 这种机制,非常简洁,程序员不用再为在什么时机关闭资源而烦心。

5.6 常用的函数

5.6.1 字符串函数

  • 统计字符串的长度,按字节

golang 的编码统一为 utf-8 (ascii 的字符 (字母和数字) 占一个字节,汉字占用 3 个字节)

func main(){
    //统计字符串的长度,按字节 len(str)
    ////golang的编码统一为utf-8 (ascii的字符(字母和数字) 占一个字节,汉字占用3个字节)
    str := "hello北"
    fmt.Println("str len=", len(str)) // 8
}
  • 字符串遍历,同时处理有中文的问题
str2 := "hello北京"
//字符串遍历,同时处理有中文的问题 r := []rune(str)
r := []rune(str2)
for i := 0; i < len(r); i++ {
    fmt.Printf("字符=%c\n", r[i])
}
  • 字符串转整数
//字符串转整数:  n, err := strconv.Atoi("12")
n, err := strconv.Atoi("hello")
if err != nil {
    fmt.Println("转换错误", err)
}else {
    fmt.Println("转成的结果是", n)
}
  • 整数转字符串
str = strconv.Itoa(12345)
fmt.Printf("str=%v, str=%T", str, str)
  • 字符串 转 []byte
//5)字符串 转 []byte:  var bytes = []byte("hello go")
var bytes = []byte("hello go")
fmt.Printf("bytes=%v\n", bytes)
  • []byte 转 字符串
//6)[ ]byte 转 字符串: str = string([]byte{97, 98, 99})
str = string([]byte{97, 98, 99})
fmt.Printf("str=%v\n", str)
  • 10 进制转 2,8,16 进制
//10进制转 2, 8, 16进制:  str = strconv.FormatInt(123, 2),返回对应的字符串
str = strconv.FormatInt(123, 2)
fmt.Printf("123对应的二进制是=%v\n", str)
str = strconv.FormatInt(123, 16)
fmt.Printf("123对应的16进制是=%v\n", str)
  • 判断子串是否存在于目标字符串,返回布尔值
//查找子串是否在指定的字符串中: strings.Contains("seafood", "foo") //true
b := strings.Contains("seafood", "mary")
fmt.Printf("b=%v\n", b) // 输出 false
  • 统计目标字符串中指定子串的出现次数,重叠子串不重复计数
//统计一个字符串有几个指定的子串 : strings.Count("ceheese", "e") //4
num := strings.Count("ceheese", "e")
fmt.Printf("num=%v\n", num) // 输出 4

注意:strings.Count(s, "") 会返回 len(s)+1,用于获取字符串长度(不推荐)

  • 不区分大小写的字符串比较
//不区分大小写的字符串比较(==是区分字母大小写的): fmt.Println(strings.EqualFold("abc", "Abc")) //true
b = strings.EqualFold("abc", "Abc")
fmt.Printf("b=%v\n", b) //true
fmt.Println("结果", "abc" == "Abc") // false //区分字母大小写
  • 返回子串在目标字符串中第一次出现的下标(从 0 开始),不存在则返回 -1
//返回子串在字符串第一次出现的 index 值,如果没有返回-1 : strings.Index("NLT_abc", "abc") // 4
index := strings.Index("NLT_abcabcabc", "abc") // 4
fmt.Printf("index=%v\n", index)
  • 返回子串在目标字符串中最后一次出现的下标,不存在则返回 -1
//返回子串在字符串最后一次出现的 index,如没有返回-1 : strings.LastIndex("go golang", "go")
index = strings.LastIndex("go golang", "go") //3
fmt.Printf("index=%v\n", index)
  • 将目标字符串中的指定子串替换为新子串,n 控制替换次数,n=-1 表示全部替换
//将指定的子串替换成 另外一个子串: strings.Replace("go go hello", "go", "go语言", n) n可以指定你希望替换几个,如果n=-1表示全部替换
str2 = "go go hello"
str = strings.Replace(str2, "go", "北京", -1)
fmt.Printf("str=%v str2=%v\n", str, str2)
// 输出:str=北京 北京 hello str2=go go hello

注意:原字符串不会被修改,会返回新字符串

  • 按照指定的某个字符,为分割标识,将一个字符串拆分成字符串数组
//按照指定的某个字符,为分割标识,将一个字符串拆分成字符串数组:
//strings.Split("hello,wrold,ok", ",")
strArr := strings.Split("hello,wrold,ok", ",")
for i := 0; i < len(strArr); i++ {
    fmt.Printf("str[%v]=%v\n", i, strArr[i])
}
fmt.Printf("strArr=%v\n", strArr)
  • 将字符串的字母进行大小写的转换:
//将字符串的字母进行大小写的转换:
//strings.ToLower("Go") // go strings.ToUpper("Go") // GO
str = "goLang Hello"
str = strings.ToLower(str)
str = strings.ToUpper(str)
fmt.Printf("str=%v\n", str) //golang hello
  • 将字符串左右两边的空格去掉
//将字符串左右两边的空格去掉:  strings.TrimSpace(" tn a lone gopher ntrn   ")
str = strings.TrimSpace(" tn a lone gopher ntrn   ")
fmt.Printf("str=%q\n", str)
  • 将字符串左右两边指定的字符去掉
//17)将字符串左右两边指定的字符去掉 :
//strings.Trim("! hello! ", " !")  // ["hello"] //将左右两边 ! 和 " "去掉
str = strings.Trim("! he!llo! ", " !")
fmt.Printf("str=%q\n", str)
  • 将字符串左边指定的字符去掉
strings.TrimLeft("! hello!", " !")
  • 将字符串右边指定的字符去掉
strings.TrimRight("! hello!", " !")
  • 判断字符串是否以指定的字符串开头
//20)判断字符串是否以指定的字符串开头:
//strings.HasPrefix("ftp://192.168.10.1", "ftp") // true
b = strings.HasPrefix("ftp://192.168.10.1", "hsp") //false
fmt.Printf("b=%v\n", b)
  • 判断字符串是否以指定的字符串结束
strings.HasSuffix("NLT_abc.jpg", "abc") //false

5.6.2 时间和日期相关函数

在编程中,程序员会经常使用到日期相关的函数,比如:统计某段代码执行花费的时间等。时间和日期相关函数,需要导入time包

  • time.Time类型,用于表示时间,可以提取年月日时分秒等信息
now := time.Now()
fmt.Printf("now=%v now type=%T\n", now, now)

//2.通过now可以获取到年月日,时分秒
fmt.Printf("年=%v\n", now.Year())
fmt.Printf("月=%v\n", now.Month())
fmt.Printf("月=%v\n", int(now.Month()))
fmt.Printf("日=%v\n", now.Day())
fmt.Printf("时=%v\n", now.Hour())
fmt.Printf("分=%v\n", now.Minute())
fmt.Printf("秒=%v\n", now.Second())
  • 格式化日期时间(两种方式)
  1. Printf / Sprintf 格式化
// 通过 fmt.Printf 直接打印格式化时间,或用 fmt.Sprintf 把格式化后的时间存为字符串,方便后续使用
//格式化日期时间
fmt.Printf("当前年月日 %d-%d-%d %d:%d:%d \n", now.Year(),
now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second())

dateStr := fmt.Sprintf("当前年月日 %d-%d-%d %d:%d:%d \n", now.Year(),
now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second())

fmt.Printf("dateStr=%v\n", dateStr)
  1. time.Format() 格式化(Go 专属)
// Go 语言的时间格式化不使用通用占位符,而是以参考时间 2006-01-02 15:04:05 为模板,严格对应位置来定义格式,是 Go 最具特色的时间操作之一。
//格式化日期时间的第二种方式
fmt.Printf(now.Format("2006-01-02 15:04:05"))
fmt.Println()
fmt.Printf(now.Format("2006-01-02"))
fmt.Println()
fmt.Printf(now.Format("15:04:05"))
fmt.Println()
  • 时间常量定义

time 包预定义了时间单位常量,用于时间计算、休眠等操作,所有常量基于纳秒逐级换算。

const (
    Nanosecond  Duration = 1 //纳秒
    Microsecond          = 1000 * Nanosecond  //微秒
    Millisecond          = 1000 * Microsecond //毫秒
    Second               = 1000 * Millisecond //秒
    Minute               = 60 * Second        //分钟
    Hour                 = 60 * Minute        //小时
)

在程序中用于获取指定时间单位的时间,比如获取 100 毫秒:100 * time.Millisecond

  • 结合 time.Sleep

time.Sleep() 实现程序休眠,配合时间常量可以灵活控制休眠时长,实现定时任务、限流等场景。

//需求,每隔1秒中打印一个数字,打印到100时就退出
//需求2:每隔0.1秒中打印一个数字,打印到100时就退出
i := 0
for {
    i++
    fmt.Println(i)
    //休眠
    //time.Sleep(time.Second)
    time.Sleep(time.Millisecond * 100)
    if i == 100 {
        break
    }
}
  • UnixUnixNano 时间戳方法
方法功能返回值
func (t Time) Unix() int64获取 Unix 时间戳(秒)从 1970-01-01 00:00:00 UTC 到当前时间的秒数
func (t Time) UnixNano() int64获取 Unix 时间戳(纳秒)从 1970-01-01 00:00:00 UTC 到当前时间的纳秒数

UnixNano 若时间超出 int64 表示范围,结果未定义;零值调用也会导致未定义结果。

// Unix和UnixNano的使用
// unix时间戳=1527584269 unixnano时间戳=1527584269975756200
fmt.Printf("unix时间戳=%v unixnano时间戳=%v\n", now.Unix(), now.UnixNano())

5.6.3 Go内置函数

Golang 设计者为了编程方便,提供了一些可以直接使用的函数,称为 Go 的内置函数,无需导入包即可直接调用。

官方文档:https://studygolang.com/pkgdoc -> builtin

  • len 函数:用来求长度 / 元素数量,支持的类型包括:stringarrayslicemapchannel
    • string:返回字节数(UTF-8 编码下,英文占 1 字节,中文占 3 字节)
    • array/slice/map:返回元素个数
    • channel:返回通道中未读的元素数
  • new 函数:用来分配内存,主要用于分配值类型(如 intfloat32struct 等),返回的是指向该类型零值的指针
    • 为值类型分配内存,自动初始化为类型零值
    • 返回指针类型,而非值本身
    • 常用于需要指针但不想手动定义变量的场景
func main() {
    num1 := 100
    fmt.Printf("num1的类型%T , num1的值=%v , num1的地址%v\n", num1, num1, &num1)

    num2 := new(int) // *int
    //num2的类型%T => *int
    //num2的值 = 地址 0xc04204c098(这个地址是系统分配)
    //num2的地址%v = 地址 0xc04206a020(这个地址是系统分配)
    //num2指向的值 = 100
    *num2 = 100
    fmt.Printf("num2的类型%T , num2的值=%v , num2的地址%v\n num2这个指针,指向的值=%v",
        num2, num2, &num2, *num2)
}
  • make 函数:用来分配内存,主要用于分配引用类型channelmapslice),返回的是类型本身(而非指针)。

make 会为引用类型初始化底层数据结构(如切片的数组、map 的哈希表),这是引用类型能正常使用的前提。

特性newmake
适用类型值类型(int/struct/ 数组等)引用类型(slice/map/channel
返回值指针(*T类型本身(T
初始化初始化为类型零值初始化引用类型的底层结构
能否直接使用指针需解包后使用直接可用

5.7 异常处理

Go 程序发生 panic 错误时,默认会直接退出(崩溃)。实际开发中需要捕获错误、进行处理(如告警、日志、重试),保证程序继续运行。Go 不支持传统的 try...catch...finally 语法,而是通过 deferpanicrecover 三个机制实现错误处理。

5.7.1 defer + recover捕获异常

defer:延迟执行函数,会在函数返回前按逆序执行。

recover():内置函数,仅在 defer 修饰的函数中生效,用于捕获 panic 抛出的异常,让程序恢复正常执行。

package main

import (
	"fmt"
	"time"
)

func test() {
	// 使用defer + recover 来捕获和处理异常
	defer func() {
		err := recover() // recover()内置函数,可以捕获到异常
		if err != nil {  // 说明捕获到错误
			fmt.Println("err=", err)
		}
	}()

	num1 := 10
	num2 := 0
	res := num1 / num2 // 除0错误,触发panic
	fmt.Println("res=", res)
}

func main() {
	// 测试
	test()
	fmt.Println("main()下面的代码...")
	time.Sleep(time.Second)
}

原本除 0 错误会导致程序崩溃,通过 defer + recover 捕获后,main 函数后续代码会正常执行,程序不会退出。

5.7.2 panic主动抛出错误

panic 是内置函数,接收任意类型(interface{})参数,用于主动抛出错误,终止当前函数的正常执行,执行所有 defer 函数后向上层传播,最终导致程序崩溃(未被 recover 捕获时)。常用于不可恢复的严重错误,如配置文件读取失败、依赖服务不可用等。

5.7.3 自定义错误errors.New

errors.New("错误说明"):返回一个 error 类型的值,用于自定义业务错误。结合 panic 可以主动抛出自定义错误,实现业务逻辑的错误控制。

package main

import (
	"errors"
	"fmt"
)

// 函数去读取以配置文件init.conf的信息
// 如果文件名传入不正确,我们就返回一个自定义的错误
func readConf(name string) (err error) {
	if name == "config.ini" {
		// 读取...
		return nil
	} else {
		// 返回一个自定义错误
		return errors.New("读取文件错误..")
	}
}

func test02() {
	err := readConf("config2.ini")
	if err != nil {
		// 如果读取文件发送错误,就输出这个错误,并终止程序
		panic(err)
	}
	fmt.Println("test02()继续执行....")
}

func main() {
	// 测试自定义错误的使用
	test02()
	fmt.Println("main()下面的代码...")
}

错误处理的核心优势

  • 程序健壮性:捕获错误后程序不会轻易挂掉,可实现告警、重试、降级等逻辑。
  • 简洁优雅:Go 摒弃了传统 try...catch 的嵌套,通过 defer 实现了非侵入式的错误处理。
  • 灵活可控:可自定义错误、主动抛出、选择性捕获,适配不同业务场景。
机制作用适用场景注意事项
defer延迟执行函数资源释放、收尾操作、异常捕获按逆序执行,函数返回前执行
panic主动抛出错误不可恢复的严重错误会终止函数执行,向上传播
recover捕获 panic 异常错误拦截、程序容错仅在 defer 函数中生效
errors.New创建自定义错误业务逻辑错误定义仅返回 error 类型,不主动抛出

error 是 Go 的内置接口类型,定义为 type error interface{ Error() string }errors.New 实现了该接口。

recover 只能捕获同一 goroutine 内的 panic,无法跨 goroutine 捕获。

生产环境中,建议结合日志库(如 zaplogrus)记录错误信息,便于问题排查。

6 数组和切片

6.1 数组介绍

数组可以存放多个同一类型数据。数组也是一种数据类型,在 Go 中,数组是值类型。

数组的长度是类型的一部分,[3]int 和 [4]int 是不同类型

6.2 数组定义和内存布局

// 声明数组
var arr1 [3]int // 创建长度为 3 的 int 数组,元素初始化为 0
fmt.Printf("空数组:%v\n", arr1)		// 空数组:[0 0 0]

// 初始化数组
arr2 := [3]int{1, 2, 3}
fmt.Printf("初始化的数组:%v\n", arr2)   // 初始化的数组:[1 2 3]

// 可以让编译器计算长度
arr3 := [...]int{1, 2, 3, 4, 5}
fmt.Printf("自动计算长度:%v, 长度:%d\n", arr3, len(arr3))	// 自动计算长度:[1 2 3 4 5], 长度:5

// 访问和修改元素
arr2[0] = 10
fmt.Printf("修改后:%v\n", arr2)			// 修改后:[10 2 3]
fmt.Printf("第一个元素:%d\n", arr2[0]) 	   // 第一个元素:10

数组的地址可以通过数组名来获取如 &arr1,数组的第一个元素的地址,就是数组的首地址。数组的各个元素的地址间隔是依据数组的类型决定,比如int64->8、int32->4

四种初始化数组的方式如下:

var numArr01 [3]int = [3]int{1, 2, 3}
var numArr02 = [3]int{5, 6, 7}
var numArr03 = [...]int{8, 9, 10}
var numArr04 = [...]int{1: 800, 0: 900, 2: 999}			// numArr04: [900 800 999]
strArrr05 = [...]string{1: "tom", 0: "jack", 2: "mary"}

数组是值类型,赋值会复制整个数组

a := [3]int{1, 2, 3}
b := a // 复制整个数组
b[0] = 100
fmt.Printf("\n数组是值类型:a=%v, b=%v\n", a, b)
fmt.Println("修改 b 不影响 a\n")

6.3 数组的遍历

传统 for 循环

for i := 0; i < len(fruits); i++ {
    fmt.Printf("%s ", fruits[i])
}

range 遍历

for i, fruit := range fruits {
    fmt.Printf("[%d:%s] ", i, fruit)
}

只需要索引

for i := range fruits {
    fmt.Printf("%d ", i)
}

只需要值

for _, fruit := range fruits {
    fmt.Printf("%s ", fruit)
}

6.4 数组使用注意事项

  • 数组是多个相同类型数据的组合,一个数组一旦声明/定义了,其长度是固定的,不能动态变化
  • var arr[int] 这时 arr 就是一个 slice 切片
  • 数组中的元素可以是任何数据类型,包括值类型和引用类型,但是不能混用。
  • 数组创建后,如果没有赋值,有默认值( 零值 )
  • 使用数组的步骤:1.声明数组并开辟空间 2.给数组各个元素赋值 ( 默认零值 ) 3.使用数组
  • 数组的下标是从 0 开始的
  • 数组下标必须在指定范围内使用,否则报 panic :数组越界
  • Go 的数组属值类型,在默认情况下是值传递,因此会进行值拷贝。数组间不会相互影响
  • 如想在其它函数中,去修改原来的数组,可以使用引用传递 ( 指针方式 )
  • 长度是数组类型的一部分,在传递函数参数时需要考虑数组的长度

6.5 切片的基本介绍

切片是数组的一个引用,因此切片是引用类型,在进行传递时,遵守引用传递的机制。切片的使用和数组类似,遍历切片、访问切片的元素和求切片长度 len(slice) 都一样。切片的长度是可以变化的,因此切片是一个可以动态变化数组。

data := []int{1, 2, 3, 4, 5}

长得和数组很像哈 但注意下切片方括号里有没有指定长度数字。这两种写法是 Go 语法层面的关键区别。

6.6 切片的内存结构

切片在内存中是一个三元组结构,包含指向底层数组的指针、长度和容量。

type sliceHeader struct {
    Data uintptr  // 指向底层数组的指针
    Len  int      // 切片长度(当前元素个数)
    Cap  int      // 切片容量(底层数组可容纳的元素总数)
}
  1. 创建切片
s := []int{10, 20, 30, 40, 50}
栈上(切片头)              堆上(底层数组)
┌─────────────┐        ┌─────────────┐
│ Data: 0x1234├───────►│ 索引: 0  值: 10 │
│ Len:  3     │        │ 索引: 1  值: 20 │
│ Cap:  5     │        │ 索引: 2  值: 30 │
└─────────────┘        │ 索引: 3  值: 40 │
                       │ 索引: 4  值: 50 │
                       └─────────────┘

切片 s 的 Len=3,Cap=5,Data 指向底层数组的起始地址。

  1. 创建子切片
sub := s[1:4]  // 从索引1到4(不包括4)
栈上(切片头)              堆上(底层数组)
┌─────────────┐        ┌─────────────┐
│ Data: 0x123C├───────►│ 索引: 0  值: 10 │←─s[0]
│ Len:  3     │        │ 索引: 1  值: 20 │←─sub[0], s[1]
│ Cap:  4     │        │ 索引: 2  值: 30 │←─sub[1], s[2]
└─────────────┘        │ 索引: 3  值: 40 │←─sub[2], s[3]
                       │ 索引: 4  值: 50 │←─s[4]
                       └─────────────┘

sub 的 Data 指针指向 s[1] 的地址,Len=3,Cap=4(从索引1到底层数组末尾)。

  1. 通过 make 创建切片
s := make([]int, 3, 5)
栈上(切片头)              堆上(底层数组)
┌─────────────┐        ┌─────────────┐
│ Data: 0x4560├───────►│ 索引: 0  值: 0  │←─已初始化部分
│ Len:  3     │        │ 索引: 1  值: 0  │
│ Cap:  5     │        │ 索引: 2  值: 0  │
└─────────────┘        │ 索引: 3  值: ?  │←─未初始化但可用的容量
                       │ 索引: 4  值: ?  │
                       └─────────────┘
  1. 扩容机制

当切片长度超过容量时,Go 会创建一个新的底层数组。旧切片不受影响

s := []int{1, 2, 3}  // len=3, cap=3
s = append(s, 4)    // 需要扩容
扩容前:
┌─────────────┐    ┌─────┬─────┬─────┐
│ ptr: 0x1000│───►│ 1 │ 2 │ 3 │
│ len: 3     │    └─────┴─────┴─────┘
│ cap: 3     │
└─────────────┘

扩容后:
┌─────────────┐    ┌─────┬─────┬─────┬─────┬─────┬─────┐
│ ptr: 0x2000│───►│ 1 │ 2 │ 3 │ 4 │   │   │
│ len: 4     │    └─────┴─────┴─────┴─────┴─────┴─────┘
│ cap: 6     │
└─────────────┘

新容量通常是原来的 2 倍(当容量<1024时)。

6.7 string和slice

string底层是一个byte数组,因此string也可以进行切片处理

str: "hello world"
slice := str[6:]

在 Go 中,string 和 slice 在内存中有相似的结构,但也有重要区别。

特性StringSlice ([]byte)
结构体大小16字节 (64位系统)24字节 (64位系统)
指针字段1个 (str)1个 (Data)
长度字段1个 (len)2个 (len, cap)
底层数据只读字节数组可读写字节数组
内存位置通常只读段通常堆上
零值"" (空字符串)nil

6.8 二维数组

// 二维数组
matrix := [2][3]int{
    {1, 2, 3},
    {4, 5, 6},
}
fmt.Printf("二维数组:%v\n", matrix)
fmt.Printf("matrix[1][2] = %d\n", matrix[1][2])

// 二维切片(切片之切片)
grid := make([][]int, 3)
for i := range grid {
    grid[i] = make([]int, 3)
    for j := range grid[i] {
        grid[i][j] = i*3 + j + 1
    }
}
fmt.Printf("二维切片:\n")
for _, row := range grid {
    fmt.Printf("  %v\n", row)
}

7 Map

7.1 map的基本介绍

map是key-value 数据结构,又称为字段或者关联数组。类似其它编程语言的集合,map 的零值是 nil,不能向 nil map 添加元素,会 panic

基本语法为:var map 变量名 map[type]type,其中中括号里面为key的类型,外面为value的类型,创建方式如下

// 使用 make 创建 map
ages := make(map[string]int)
fmt.Printf("空 map: %v\n", ages)

// 创建带初始容量的 map
scores := make(map[string]int, 100) // 预估容量 100
fmt.Printf("带容量的 map: %v\n", scores)

// 使用字面量初始化
person := map[string]string{
    "name":    "Alice",
    "city":    "Beijing",
    "country": "China",
}

7.2 map的基本使用

增删改查:

// 创建 map
// 空接口 'interface{}' 代表所有类型的集合。空接口类型的变量可以存储任何类型的值
user := make(map[string]interface{})

// 增/改:直接赋值
user["name"] = "Bob"
user["age"] = 30
user["email"] = "bob@example.com"
fmt.Printf("添加后:%v\n", user)

// 查:直接访问
fmt.Printf("name: %v\n", user["name"])

// 访问不存在的键:返回 value 类型的零值
fmt.Printf("不存在的键:%v\n", user["nonexistent"])

// 检查键是否存在(推荐做法)
value, exists := user["age"]
if exists {
    fmt.Printf("age 存在:%d\n", value)
}

// 简洁写法
if email, ok := user["email"]; ok {
    fmt.Printf("email: %s\n", email)
}

// 删除
delete(user, "email")
fmt.Printf("删除 email 后:%v\n", user)

// 删除不存在的键:不会报错
delete(user, "nonexistent")
fmt.Println("删除不存在的键:安全,无错误")

遍历:

colors := map[string]string{
    "red":   "#FF0000",
    "green": "#00FF00",
    "blue":  "#0000FF",
}

// 使用 range 遍历
fmt.Println("遍历 map:")
for key, value := range colors {
    fmt.Printf("  %s: %s\n", key, value)
}

// 只遍历键
fmt.Println("\n只遍历键:")
for key := range colors {
    fmt.Printf("  %s\n", key)
}

// 重要:map 遍历顺序是随机的!
fmt.Println("\n再次遍历(顺序可能不同):")
for key, value := range colors {
    fmt.Printf("  %s: %s\n", key, value)
}

7.3 map的切片

切片的数据类型如果是 map,则我们称为 slice of map ,map 切片,这样使用则 map 个数就可以动态变化了。

// 切片元素是 map
sliceOfMaps := []map[string]int{
    {"math": 90, "english": 85},
    {"math": 75, "english": 88},
    {"math": 95, "english": 92},
}

fmt.Println("遍历 map 切片:")
for i, m := range sliceOfMaps {
    fmt.Printf("  学生 %d: %v\n", i+1, m)
}

// 添加新元素到切片
sliceOfMaps = append(sliceOfMaps, map[string]int{"math": 80, "english": 70})
fmt.Printf("添加后: %v\n", sliceOfMaps)

// 切片元素是 map,修改会影响原 map
if len(sliceOfMaps) > 0 {
    sliceOfMaps[0]["math"] = 100
    fmt.Printf("修改切片中 map 的值: %v\n", sliceOfMaps[0])
}

8 面向对象编程

8.1 Go语言面向编程说明

Golang 也支持面向对象编程 ( OOP ) ,但是和传统的面向对象编程有区别,并不是纯粹的面向对象语言。Golang 没有类 , Go语言的结构体 (struct) 和其它编程语言的类 有同等的地位.Golang 面向对象编程非常简洁,去掉了传统 OOP 语言的继承、方法重载、构造函数和析构函数、隐藏的this指针等等

Golang 仍然有面向对象编程的继承,封装和多态的特性,只是实现的方式和其它 OOP 语言不一样,比如继承: Golang 没有 extends 关键字,继承是通过匿名字段来实现。实现面向编程很优雅, OOP 本身就是语言类型系统的一部分,通过接口关联,耦合性低,也非常灵活。

8.2 结构体

8.2.1 声明结构体

type Person struct {
	Name   string // 公开字段
	Age    int    // 公开字段
	Email  string // 公开字段
	active bool   // 私有字段(首字母小写)
}

// Point 表示二维坐标
type Point struct {
	X, Y float64 // 相同类型可以简写
}

字段声明语法同变量,类型可以为:基本类型、数组或引用类型。在创建一个结构体变量后,如果没有给字段赋值,都对应一个零值 ( 默认值 ) ,规则同前面讲的。不同结构体变量的字段是独立,互不影响,一个结构体变量字段的更改,不影响另外一个。

结构体是值类型。

8.2.2 使用结构体

方式 1:逐个字段初始化

p1 := Person{
    Name:   "Alice",
    Age:    25,
    Email:  "alice@example.com",
    active: true,
}
fmt.Printf("p1: %+v\n", p1) // %+v 显示字段名

方式 2:按顺序初始化(不推荐,容易出错)

p2 := Person{"Bob", 30, "bob@example.com", true}
fmt.Printf("p2: %+v\n", p2)

方式 3:先声明后赋值

var p3 Person
p3.Name = "Charlie"
p3.Age = 35
p3.Email = "charlie@example.com"
fmt.Printf("p3: %+v\n", p3)

方式 4:使用 new(返回指针)

p4 := new(Person)
p4.Name = "David"
p4.Age = 28
fmt.Printf("p4 (指针): %+v\n", *p4)

方式 5:使用&取地址

p5 := &Person{Name: "Eve", Age: 22}
fmt.Printf("p5 (指针): %+v\n\n", *p5)

第4种和第5种方式返回的是结构体指针。结构体指针访问字段的标准方式应该是:(*结构体指针).字段名,比如 (*person).Name = "tom",但 go 做了一个简化,也支持结构体指针.字段名,编译器底层会做上面这个转换

8.2.3 结构体标签

在 Go 语言中,你可以为结构体的每个字段写一个额外的元数据,这就是 Tag。它的语法是在字段声明后面加上一对反引号 ```,里面包含一个键值对(key:value)。

type User struct {
    Name string `json:"name" xml:"user_name"`
    Age  int    `json:"age" xml:"user_age"`
}

在这个例子中,Name字段有两个标签:json:"name"xml:"user_name"

Tag 的作用是什么?

  • 存储元信息:Tag 本身不会参与结构体的运行时逻辑(比如变量赋值或计算),它只是附着在字段上的静态字符串
  • 通过反射读取:程序在运行时可以通过 Go 的反射机制(reflect包) 读取这些标签。

常见用途

  • 序列化与反序列化:这是最常见的场景。当你使用 encoding/json包把结构体转成 JSON 时,它会读取 json:"xxx"标签来决定 JSON 中的键名。当你使用 encoding/xml包把结构体转成 XML 时,它会读取 xml:"xxx"标签。例如上面的 User结构体,序列化成 JSON 后会变成 {"name": "...", "age": ...},而不是 {"Name": "...", "Age": ...}

  • ORM 映射:在数据库操作中,标签可以用来指定字段对应的数据库列名(如 gorm:"column:user_name")。

  • 校验:在表单验证或参数校验库中,标签可以定义验证规则(如 validate:"required")。

  • 自定义处理逻辑:开发者可以自己写反射代码,根据标签执行特定的逻辑。

为什么需要 Tag

  • 解耦:结构体的字段名可能不符合外部协议(如 JSON 或 XML)的命名规范(例如结构体用驼峰命名,JSON 用下划线命名),Tag 可以在不改变结构体字段名的情况下,适配外部格式。
  • 灵活性:同一个结构体可以适配多种不同的序列化格式,只需定义不同的标签即可。
  • 可读性:通过标签可以清晰地看到字段在外部系统中的对应名称。

8.3 结构体的内存结构

结构体的所有字段在内存中是连续的,如一个结构体定义如下

type Example struct {
    a bool      // 1字节
    b int32     // 4字节
    c int8      // 1字节
    d int64     // 8字节
    e byte      // 1字节
}

实际内存布局(64位系统)

地址偏移 | 字段 | 大小 | 实际存储
--------|------|------|----------
0x00    | a    | 1字节 | [a]
0x01    | 对齐填充 | 3字节 | [空 空 空]
0x04    | b    | 4字节 | [b b b b]
0x08    | c    | 1字节 | [c]
0x09    | 对齐填充 | 7字节 | [空 空 空 空 空 空 空]
0x10    | d    | 8字节 | [d d d d d d d d]
0x18    | e    | 1字节 | [e]
0x19    | 结构体末尾填充 | 7字节 | [空 空 空 空 空 空 空]
总大小: 32字节

这就涉及到Go语言里面的内存对齐规则:

  1. 字段对齐:每个字段的起始地址必须是其类型对齐值的整数倍
  2. 结构体对齐:结构体总大小必须是最大字段对齐值的整数倍
  3. 内存地址:结构体起始地址必须是最大字段对齐值的整数倍

各个数据类型对齐的参考如下

数据类型大小对齐值
bool1字节1
int8, byte, uint81字节1
int16, uint162字节2
int32, uint32, float32, rune4字节4
int64, uint64, float64, complex648字节8
complex12816字节8
指针8字节8
string(16字节)16字节8
切片 (24字节)24字节8
映射/通道 (8字节)8字节8
接口 (16字节)16字节8

我们可以利用Go语言的这种特性,来优化内存布局,比如一个浪费空间的map

type BadStruct struct {
    a bool      // 1字节 + 7字节填充
    b int64     // 8字节
    c int32     // 4字节 + 4字节填充
    d bool      // 1字节 + 7字节填充
}
// 总大小: 32字节

我们仅需调整下顺序,优化后

type GoodStruct struct {
    b int64     // 8字节
    c int32     // 4字节
    a bool      // 1字节
    d bool      // 1字节
    // 自动添加2字节填充
}
// 总大小: 16字节 (节省50%!)

内存对比:

BadStruct (32字节)       GoodStruct (16字节)
┌─────────────┐         ┌─────────────┐
│ a (1)       │         │ b (8)       │
│ 填充 (7)    │         ├─────────────┤
├─────────────┤         │ c (4)       │
│ b (8)       │         │ a (1) d (1) │
├─────────────┤         │ 填充 (2)    │
│ c (4)       │         └─────────────┘
│ 填充 (4)    │
├─────────────┤
│ d (1)       │
│ 填充 (7)    │
└─────────────┘

特殊规则:

  1. 空结构体大小 = 0
  2. 空结构体地址可以与其他对象相同
  3. 空结构体数组不占用空间

内存布局优化建议

  1. 字段重排序:从大到小排列字段(8字节→4字节→2字节→1字节)
  2. 热字段放前面:频繁访问的字段放在结构体开头
  3. 冷字段放后面:不常访问的字段放在末尾
  4. 避免过度填充:合理分组相似大小的字段
  5. 考虑缓存行:通常64字节,让相关数据在同一缓存行

正确的结构体内存布局可以显著减少内存使用,提高缓存利用率,从而提升程序性能。

8.4 方法

基本用法如下

type Person struct {
	Name   string // 公开字段
	Age    int    // 公开字段
	Email  string // 公开字段
	active bool   // 私有字段(首字母小写)
}

// 为 Person 添加方法
// (p Person) 是接收者,表示这个方法属于 Person 类型

// SayHello 是指针接收者(可以修改结构体)
func (p *Person) SayHello() {
	fmt.Printf("你好,我是 %s,今年 %d 岁\n", p.Name, p.Age)
}

// Birthday 修改年龄
func (p *Person) Birthday() {
	p.Age++
}

// IsAdult 返回值接收者(不可修改,适合查询)
func (p Person) IsAdult() bool {
	return p.Age >= 18
}

func main() {
    p1 := Person{
		Name:   "Alice",
		Age:    25,
		Email:  "alice@example.com",
		active: true,
	}
    
    fmt.Println("--- 调用方法 ---")

	p1.SayHello()
	p1.Birthday()
	fmt.Printf("生日后年龄:%d\n", p1.Age)

	fmt.Printf("是否成年:%t\n", p1.IsAdult())

	// 指针和值调用方法
	fmt.Println("\n指针和值接收者:")
	p := Person{Name: "Test", Age: 17}
	p.Birthday() // Go 自动转为 (&p).Birthday()
	fmt.Printf("Age: %d\n", p.Age)
}

方法的调用和传参机制和函数基本一样,不一样的地方是方法调用时,会将调用方法的变量,当做实参也传递给方法。

语法糖person.SetName("Alice")Person_SetName(person, "Alice")的语法糖,Go 编译器会自动处理值/指针的转换

// 原始代码
type T struct { x int }
func (t T) Get() int { return t.x }
func (t *T) Set(x int) { t.x = x }

func main() {
    var t T
    t.Set(10)      // 方法调用
    _ = t.Get()
}

// 编译器处理后(概念上)
func T_Get(t T) int { return t.x }
func T_Set(t *T, x int) { t.x = x }

func main() {
    var t T
    T_Set(&t, 10)  // 转换为函数调用
    _ = T_Get(t)
}

在 Go 语言中,方法(Method)是一种特殊的函数,它必须绑定到一个特定的类型上。这个类型被称为“接收者”(Receiver)。这意味着,方法不仅可以绑定结构体,还可以绑定任何自定义类型,甚至像 int、float32 这样的基本类型

// 定义一个基于 int 的新类型
type MyInt int

// 为这个新类型添加方法
func (m MyInt) Increment() MyInt {
    return m + 1
}

func main() {
    var num MyInt = 5
    newNum := num.Increment() // 调用方法
    fmt.Println(newNum)       // 输出 6
}

当你调用 fmt.Printlnfmt.Printffmt.Sprintf等函数时,如果传入的参数实现了 Stringer接口,Go 会自动调用该参数的 String()方法,并将返回的字符串作为输出内容。这是 Go 的“鸭子类型”:只要看起来像鸭子(实现了 String()方法),就可以被当作鸭子用(fmt包会调用它)。

// 为 Person 实现 String() 方法
func (p Person) String() string {
    return fmt.Sprintf("Person{姓名: %s, 年龄: %d}", p.Name, p.Age)
}

func main() {
    p := Person{Name: "Alice", Age: 30}
    
    // fmt.Println 会自动调用 p.String()
    fmt.Println(p) // 输出: Person{姓名: Alice, 年龄: 30}
    
    // 对比:如果不实现 String(),输出会是: {Alice 30}
}

8.5 面向对象的三大特性

Golang仍然有面向对象编程的继承,封装和多态的特性,只是实现的方式和其它语言不一样,我们来看看Go语言对三大特性是如何实现的。

8.5.1 封装

封装(encapsulation) 就是把抽象出的字段和对字段的操作封装在一起,数据被保护在内部,程序的其它包只有通过被授权的操作( 方法 )才能对字段进行操作。在Go中, 我们可以对结构体中的属性进行封装,也可以通过方法包实现封装

  • 将结构体、字段 ( 属性 ) 的首字母小写 ( 不能导出了,其它包不能使用,类似 pnvate)
  • 给结构体所在包提供一个工厂模式的函数,首字母大写。类似一个构造函数
  • 提供一个首字母大写的 Set 方法 ,用于对属性判断并赋值
  • 提供一个首字母大写的 Get 方法 ,用于获取属性的值

在 Golang 开发中并没有特别强调封装,Golang本身对面向对象的特性做了简化

8.5.2 继承

当多个结构体存在相同的属性 ( 字段 ) 和方法时,可以从这些结构体中抽象出结构体 ,在该结构体中定义这些相同的属性和方法。

type Animal2 struct {
	name string
	age  int
}

func (a *Animal2) SetName(name string) {
	a.name = name
}

func (a Animal2) GetName() string {
	return a.name
}

func (a *Animal2) SetAge(age int) {
	if age >= 0 {
		a.age = age
	}
}

func (a Animal2) GetAge() int {
	return a.age
}

// Cat 嵌入 Animal2,获得其字段和方法
type Cat struct {
	Animal2
	Breed string
}

func main() {
	cat := Cat{
		Animal2: Animal2{name: "小白", age: 2},
		Breed:   "波斯猫",
	}

	dog := Dog2{
		Animal2: Animal2{name: "大黄", age: 3},
		Breed:   "金毛",
	}
    
    // 猫的名字:小白,年龄:2,品种:波斯猫
	// 狗的名字:大黄,年龄:3,品种:金毛
    fmt.Printf("猫的名字:%s,年龄:%d,品种:%s\n", cat.GetName(), cat.GetAge(), cat.Breed)
	fmt.Printf("狗的名字:%s,年龄:%d,品种:%s\n", dog.GetName(), dog.GetAge(), dog.Breed)
}
  • 结构体可以使用嵌套匿名结构体所有的字段和方法即:首字母大写或者小写的字段、方法,都可以使用。
  • 当结构体和匿名结构体有相同的字段或者方法时,编译器采用就近访问原则访问,如希望访问匿名结构体的字段和方法,可以通过匿名结构体名来区分
  • 结构体嵌入两个 ( 或多个 ) 匿名结构体,如两个匿名结构体有相同的字段和方法 ( 同时结构体本身没有同名的字段和方法 ) ,在访问时,就必须明确指定匿名结构体名字,否则编译报错。
  • 如果一个结构体嵌套了一个有名结构体,这种模式就是组合,如果是组合关系,那么在访问组合的结构体的字段或方法时,必须带上结构体的名字
  • 嵌套匿名结构体后,也可以在创建结构体变量时,直接指定各个匿名结构体字段的值

8.5.3 多态

变量 ( 实例 ) 具有多种形态。面向对象的第三大特征,在 Go语言,多态特征是通过接口实现的。可以按照统一的接口来调用不同的实现。这时接口变量就呈现不同的形态。

8.6 接口

interface 类型可以定义一组方法,但是这些不需要实现。并且 interface 不能包含任何变量。到某个自定义类型要使用的时候,在实现这些方法。

// Shape 接口:任何有 Area 和 Perimeter 方法的类型都实现了 Shape 接口
type Shape interface {
	Area() float64
	Perimeter() float64
}

// Rectangle 矩形
type Rectangle struct {
	Width, Height float64
}

func (r Rectangle) Area() float64 {
	return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
	return 2 * (r.Width + r.Height)
}

一个接口可以继承多个别的接口,这时如果要实现 A 接口,也必须将 B , C 接口的方法也全部实现。

// Named 接口(用于组合)
type Named interface {
	Name() string
}

// Aged 接口(用于组合)
type Aged interface {
	Age() int
}

// Person 组合接口
type Person interface {
	Named
	Aged
}

// Student 实现 Person 接口
type Student struct {
	name string
	age  int
}

func (s Student) Name() string { return s.name }
func (s Student) Age() int     { return s.age }
  • 接口本身不能创建实例,但是可以指向一个实现了该接口的自定义类型的变量
  • 接口中所有的方法都没有方法体即都是没有实现的方法。
  • 接口类型默认是一个指针 ( 引用类型 ) ,如果没有对 interface 初始化就使用,那么会输出 nil
  • 空接口没有任何方法。所以所有类型都买现了空接口,即我们可以把任何一个变量赋给空接口。

8.7 类型断言

由于上面我们说过,空接口没有任何方法。所以所有类型都买现了空接口,即我们可以把任何一个变量赋给空接口,由于接口是一般类型,不知道具体类型,如果要转成具体类型,就需要使用类型断言

func main() {
    var a interface{}
    var point Point = Point{1, 2}
    a = point
    var b Point
    b = a.(Point) //类型断言
    fmt.Println(b)
}

在进行类型断言时,如果类型不匹配,就会报 panic, 因此进行类型断言时,要确保原来的空接口指向的就是断言的类型,

如何在进行断言时,带上检测机制,如果成功就ok ,否则也不要报panic呢,如下

//类型断言(带检测的)
var x interface{}
var b2 float32 = 2.1
x = b2  //空接口,可以接收任意类型
// x=>float32 [使用类型断言]

//类型断言(带检测的)
if y, ok := x.(float32); ok {
    fmt.Println("convert success")
    fmt.Printf("y 的类型是 %T 值是=%v", y, y)
} else {
    fmt.Println("convert fail")
}
fmt.Println("继续执行...")

9 文件操作

文件在程序中是以流的形式来操作的,流(Stream):你可以把它想象成一根“水管”。数据通过这根水管流动,从一端流向另一端。它是数据在数据源(如文件、网络、键盘)和程序(内存)之间传输的路径。

输入流(Input Stream / 读文件)

  • 方向:文件 -> 程序(内存)。
  • 动作读取(Read)
  • Go 语言对应os.Open()打开文件后,通过 File.Read()bufio.Reader读取数据。

输出流(Output Stream / 写文件)

  • 方向:程序(内存) -> 文件。
  • 动作写入(Write)
  • Go 语言对应os.Create()os.OpenFile()打开文件后,通过 File.Write()bufio.Writer写入数据。

一般我们操作文件需要引入os

9.1 打开和关闭文件

package main

import (
    "fmt"
    "os"
)

func main() {
    // 1. 创建文件
    f1, err := os.Create("test.txt")
    if err != nil {
        panic(err)
    }
    defer f1.Close()
    fmt.Println("文件创建成功")

    // 2. 打开文件(只读)
    f2, err := os.Open("test.txt")
    if err != nil {
        panic(err)
    }
    defer f2.Close()

    // 3. 以指定模式打开文件
    f3, err := os.OpenFile("test.txt", os.O_RDWR|os.O_CREATE, 0644)
    if err != nil {
        panic(err)
    }
    defer f3.Close()
}

打开模式

  • os.O_RDONLY:只读
  • os.O_WRONLY:只写
  • os.O_RDWR:读写
  • os.O_CREATE:不存在则创建
  • os.O_APPEND:追加
  • os.O_TRUNC:清空文件
  • os.O_EXCL:与O_CREATE一起用,文件必须不存在

9.2 读写文件

func basicRW() {
    // 写入文件
    data := []byte("Hello, Go文件操作!\n")
    err := os.WriteFile("demo.txt", data, 0644)
    if err != nil {
        panic(err)
    }

    // 读取文件
    content, err := os.ReadFile("demo.txt")
    if err != nil {
        panic(err)
    }
    fmt.Printf("文件内容: %s", content)
}

这种读文件适用于文件不大的情况下,如果文件很大我们可以用流式操作

import (
    "bufio"
    "fmt"
    "os"
)

func bufioExample() {
    // 写入文件(带缓冲)
    file, err := os.Create("buffered.txt")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    // 创建带缓冲的Writer
    writer := bufio.NewWriter(file)
    
    // 写入数据
    writer.WriteString("第一行\n")
    writer.WriteString("第二行\n")
    writer.Write([]byte("第三行\n"))
    
    // 刷新缓冲区(重要!)
    writer.Flush()
    fmt.Println("写入完成")

    // 读取文件(带缓冲)
    f, err := os.Open("buffered.txt")
    if err != nil {
        panic(err)
    }
    defer f.Close()

    // 创建带缓冲的Reader
    reader := bufio.NewReader(f)
    
    for {
        // 逐行读取
        line, err := reader.ReadString('\n')
        if err != nil {
            break
        }
        fmt.Print("读取到: ", line)
    }
}

还可以使用Scanner扫描

func scannerExample() {
    file, err := os.Open("data.txt")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    scanner := bufio.NewScanner(file)
    
    // 设置扫描的分隔符
    // scanner.Split(bufio.ScanWords)  // 按单词
    // scanner.Split(bufio.ScanBytes)   // 按字节
    
    lineNum := 1
    for scanner.Scan() {
        fmt.Printf("第%d行: %s\n", lineNum, scanner.Text())
        lineNum++
    }
    
    if err := scanner.Err(); err != nil {
        fmt.Printf("扫描错误: %v\n", err)
    }
}

9.3 判断文件是否存在

在 Go 语言中,判断文件是否存在最核心的方法是利用 os.Stat 函数,它不仅能判断存在与否,还能获取文件信息(如大小、修改时间等),并且在判断时会顺便检查权限问题。

os.Stat会返回文件的信息(FileInfo)和一个错误(error)。它的逻辑是:如果返回的错误是 nil,说明文件存在;如果错误是 os.IsNotExist(err),说明文件不存在。

package main

import (
    "fmt"
    "os"
)

func main() {
    fileName := "test.txt"

    // 1. 调用 Stat 获取文件信息
    _, err := os.Stat(fileName)

    // 2. 通过判断 err 是否为 nil 来确定文件是否存在
    if err == nil {
        fmt.Println("文件存在")
        return
    }

    // 3. 专门判断是否为 "文件不存在" 的错误
    if os.IsNotExist(err) {
        fmt.Println("文件不存在")
        return
    }

    // 4. 其他错误(例如权限不足、路径是目录等)
    fmt.Println("发生其他错误:", err)
}

在使用 os.Stat时,可能会遇到以下几种常见的错误:

  • nil: 成功,文件存在。
  • os.IsNotExist(err): 文件或目录不存在。
  • os.IsPermission(err): 权限被拒绝(例如你想读一个没有读权限的文件)。
  • os.IsExist(err): 这个通常用于 OpenFile的独占创建模式,在 Stat中不常用。

os.Stat返回的 FileInfo接口包含了文件的元数据:

info, err := os.Stat("test.txt")
if err != nil {
    panic(err)
}

fmt.Println("文件名:", info.Name())       // 文件名
fmt.Println("大小:", info.Size())         // 字节大小
fmt.Println("是否为目录:", info.IsDir())  // 是否是文件夹
fmt.Println("修改时间:", info.ModTime())  // 最后修改时间
fmt.Println("权限:", info.Mode())         // 文件权限(如 -rw-r--r--)

9.4 拷贝文件

func copyFileStream(src, dst string) (int64, error) {
    // 打开源文件
    source, err := os.Open(src)
    if err != nil {
        return 0, err
    }
    defer source.Close()

    // 创建目标文件
    destination, err := os.Create(dst)
    if err != nil {
        return 0, err
    }
    defer destination.Close()

    // 使用Copy进行流式复制
    nBytes, err := io.Copy(destination, source)
    return nBytes, err
}

// 带缓冲的复制
func bufferedCopy(src, dst string) error {
    source, err := os.Open(src)
    if err != nil {
        return err
    }
    defer source.Close()

    destination, err := os.Create(dst)
    if err != nil {
        return err
    }
    defer destination.Close()

    // 使用bufio包装
    buf := make([]byte, 1024 * 4) // 4KB缓冲区
    
    for {
        n, err := source.Read(buf)
        if err != nil && err != io.EOF {
            return err
        }
        if n == 0 {
            break
        }
        
        if _, err := destination.Write(buf[:n]); err != nil {
            return err
        }
    }
    
    return nil
}

9.5 命令行参数

我们希望能够获取到命令行输入的各种参数,该如何处理?os.Args 是一个 string 的切片,用来存储所有的命令行参数

func basicArgs() {
	// os.Args[0] 是程序名
	// os.Args[1:] 是传入的参数
	args := os.Args
	
	fmt.Printf("程序名: %s\n", args[0])
	fmt.Printf("参数个数: %d\n", len(args)-1)
	
	for i, arg := range args[1:] {
		fmt.Printf("参数[%d]: %s\n", i+1, arg)
	}
}

前面的方式是比较原生的方式,对解析参数不是特别的方便,特别是带有指定参数形式的命令行。go语言额外提供了flag包来解析

import "flag"

func flagExample() {
	// 定义命令行参数
	var (
		name     string
		age      int
		verbose  bool
		filePath string
		count    int
		timeout  time.Duration
	)
	
	// 绑定参数
	flag.StringVar(&name, "name", "默认名", "用户姓名")
	flag.StringVar(&name, "n", "默认名", "用户姓名(简写)")
	flag.IntVar(&age, "age", 0, "用户年龄")
	flag.IntVar(&age, "a", 0, "用户年龄(简写)")
	flag.BoolVar(&verbose, "verbose", false, "显示详细信息")
	flag.BoolVar(&verbose, "v", false, "显示详细信息(简写)")
	flag.StringVar(&filePath, "file", "", "文件路径")
	flag.StringVar(&filePath, "f", "", "文件路径(简写)")
	flag.IntVar(&count, "count", 1, "执行次数")
	flag.DurationVar(&timeout, "timeout", 5*time.Second, "超时时间")
	
	// 解析参数
	flag.Parse()
	
	// 使用参数
	fmt.Printf("参数值:\n")
	fmt.Printf("  姓名: %s\n", name)
	fmt.Printf("  年龄: %d\n", age)
	fmt.Printf("  详细模式: %v\n", verbose)
	fmt.Printf("  文件路径: %s\n", filePath)
	fmt.Printf("  执行次数: %d\n", count)
	fmt.Printf("  超时时间: %v\n", timeout)
	
	// 获取非标志参数(位置参数)
	fmt.Printf("\n位置参数: %v\n", flag.Args())
	fmt.Printf("位置参数个数: %d\n", flag.NArg())
	
	for i, arg := range flag.Args() {
		fmt.Printf("  [%d] %s\n", i, arg)
	}
}

9.6 Json序列化和反序列化

JSON(JavaScript Object Notation) 是一种轻量级的据交换格式·易于人读和编写。同时也易于机器解析和生成。

9.6.1 Json的序列化

import (
	"encoding/json"
	"fmt"
	"time"
)

// 定义一个结构体
type Person struct {
	Name    string    `json:"name"`
	Age     int       `json:"age"`
	Email   string    `json:"email,omitempty"`  // omitempty: 为空时忽略
	Address Address   `json:"address"`
	Hobbies []string  `json:"hobbies"`
	Salary  float64   `json:"salary,string"`     // 序列化为字符串
	Active  bool      `json:"active"`
	Created time.Time `json:"created"`
	Secret  string    `json:"-"`                 // 忽略字段
}

type Address struct {
	City    string `json:"city"`
	Country string `json:"country"`
}

func jsonExample() {
	// 创建实例
	person := Person{
		Name:  "张三",
		Age:   30,
		Email: "zhangsan@example.com",
		Address: Address{
			City:    "北京",
			Country: "中国",
		},
		Hobbies: []string{"编程", "读书", "游泳"},
		Salary:  15000.50,
		Active:  true,
		Created: time.Now(),
		Secret:  "敏感信息",
	}
	
	// 1. 序列化为JSON字符串
	jsonData, err := json.Marshal(person)
	if err != nil {
		panic(err)
	}
	fmt.Printf("JSON字符串: %s\n", string(jsonData))
	
	// 2. 带缩进的格式化JSON
	jsonIndent, err := json.MarshalIndent(person, "", "  ")
	if err != nil {
		panic(err)
	}
	fmt.Printf("格式化的JSON:\n%s\n", string(jsonIndent))
	
	// 3. 写入文件
	err = os.WriteFile("person.json", jsonIndent, 0644)
	if err != nil {
		panic(err)
	}
	fmt.Println("JSON已写入文件")
}

9.6.2 Json的反序列化

func jsonUnmarshalExample() {
	// JSON字符串
	jsonStr := `{
		"name": "李四",
		"age": 25,
		"email": "lisi@example.com",
		"address": {
			"city": "上海",
			"country": "中国"
		},
		"hobbies": ["音乐", "旅行"],
		"salary": "12000.75",
		"active": true,
		"created": "2024-01-15T10:30:00Z"
	}`
	
	// 1. 从字符串反序列化
	var person Person
	err := json.Unmarshal([]byte(jsonStr), &person)
	if err != nil {
		panic(err)
	}
	
	fmt.Printf("反序列化结果:\n")
	fmt.Printf("姓名: %s\n", person.Name)
	fmt.Printf("年龄: %d\n", person.Age)
	fmt.Printf("邮箱: %s\n", person.Email)
	fmt.Printf("城市: %s\n", person.Address.City)
	fmt.Printf("爱好: %v\n", person.Hobbies)
	fmt.Printf("薪资: %.2f\n", person.Salary)
	fmt.Printf("创建时间: %v\n", person.Created.Format("2006-01-02 15:04:05"))
	
	// 2. 从文件读取并反序列化
	fileData, err := os.ReadFile("person.json")
	if err != nil {
		fmt.Printf("读取文件失败: %v\n", err)
		return
	}
	
	var filePerson Person
	err = json.Unmarshal(fileData, &filePerson)
	if err != nil {
		panic(err)
	}
	fmt.Printf("从文件读取: %s\n", filePerson.Name)
}

10 单元测试

Go 语言中自带有一个轻量级的测试框架 testing 和自带的go test命令来实现单元测试和性能测试, testing框架和其他语言中的测试框架类似,可以基于这个框架写针对相应函数的测试用例,也可以基于该框架写相应的压力测试用例。通过单元测试,可以解决如下问题,

  • 确保每个函数是可运行,并且运行结果是正确的
  • 确保写出来的代码性能是好的,
  • 单元测试能及时的发现程序设计或实现的逻辑错误,使问题及早暴露,便于问题的定位解决,而性能测试的重点在于发现程序设计上的一些问题,让程序能够在高并发的情况下还能保持稳定

假如我们有一个文件 比如main.go,我们定义几个方法

// Add 两数相加
func Add(a, b int) int {
	return a + b
}

// Divide 除法,带错误处理
func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("除数不能为零")
	}
	return a / b, nil
}

// ReverseString 反转字符串
func ReverseString(s string) string {
	runes := []rune(s)
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}
	return string(runes)
}

然后我们创建一个文件 testing_test.go,内容如下

package main

import (
	"fmt"
	"sort"
	"testing"
)

// TestAdd 测试 Add 函数
func TestAdd(t *testing.T) {
	tests := []struct {
		name string
		a    int
		b    int
		want int
	}{
		{"正数", 2, 3, 5},
		{"负数", -2, -3, -5},
		{"零", 0, 0, 0},
		{"一正一负", 5, -3, 2},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Add(tt.a, tt.b)
			if got != tt.want {
				t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
			}
		})
	}
}

// TestDivide 测试 Divide 函数
func TestDivide(t *testing.T) {
	tests := []struct {
		name    string
		a       int
		b       int
		want    int
		wantErr bool
	}{
		{"正常除法", 10, 2, 5, false},
		{"不能整除", 7, 2, 3, false}, // 整数除法
		{"除零错误", 10, 0, 0, true},
		{"负数", -10, 2, -5, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := Divide(tt.a, tt.b)

			if tt.wantErr {
				if err == nil {
					t.Errorf("Divide() expected error, got nil")
				}
			} else {
				if err != nil {
					t.Errorf("Divide() unexpected error: %v", err)
				}
				if got != tt.want {
					t.Errorf("Divide(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
				}
			}
		})
	}
}

// TestReverseString 测试 ReverseString 函数
func TestReverseString(t *testing.T) {
	tests := []struct {
		input string
		want  string
	}{
		{"hello", "olleh"},
		{"Go", "oG"},
		{"", ""},
		{"a", "a"},
		{"你好", "好你"},
	}

	for _, tt := range tests {
		t.Run(tt.input, func(t *testing.T) {
			got := ReverseString(tt.input)
			if got != tt.want {
				t.Errorf("ReverseString(%q) = %q, want %q", tt.input, got, tt.want)
			}
		})
	}
}

执行go test -v

PS K:\Code\Golang\go-learning\src\testing> go test -v
=== RUN   TestAdd
=== RUN   TestAdd/正数
=== RUN   TestAdd/负数
=== RUN   TestAdd/零
=== RUN   TestAdd/一正一负
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/正数 (0.00s)
    --- PASS: TestAdd/负数 (0.00s)
    --- PASS: TestAdd/零 (0.00s)
    --- PASS: TestAdd/一正一负 (0.00s)
=== RUN   TestDivide
=== RUN   TestDivide/正常除法
=== RUN   TestDivide/不能整除
=== RUN   TestDivide/除零错误
=== RUN   TestDivide/负数
--- PASS: TestDivide (0.00s)
    --- PASS: TestDivide/正常除法 (0.00s)
    --- PASS: TestDivide/不能整除 (0.00s)
    --- PASS: TestDivide/除零错误 (0.00s)
    --- PASS: TestDivide/负数 (0.00s)
=== RUN   TestReverseString
=== RUN   TestReverseString/hello
=== RUN   TestReverseString/Go
=== RUN   TestReverseString/#00
=== RUN   TestReverseString/a
=== RUN   TestReverseString/你好
--- PASS: TestReverseString (0.00s)
    --- PASS: TestReverseString/hello (0.00s)
    --- PASS: TestReverseString/Go (0.00s)
    --- PASS: TestReverseString/#00 (0.00s)
    --- PASS: TestReverseString/a (0.00s)
    --- PASS: TestReverseString/你好 (0.00s)
ok      go-learning/src/testing    0.417s
  • 测试用例文件名必须以test.go 结尾。
  • 测试用例函数必须以 Test 开头,一般来说就是 Test被测试的函数名,比如 TestAdd
  • TestAdd(t *testing.T) 的形参类型必须是t *testing.T
  • 一个测试用例文件中,可以有多个测试用例函数,比如 TestAdd、 TestReverseString
  • 测试单个文件,一定要带上被测试的原文件

还可以检测单元覆盖率 如下

PS K:\Code\Golang\go-learning\src\testing> go test -cover
PASS
coverage: 60.5% of statements
ok      go-learning/src/testing    0.567s

11 goruntine

Goroutine是Go语言的“轻量级线程”

11.1 Go协程和Go主线程

Go 主线程 ( 有程序员直接称为线程/也可以理解成进程 ):一个Go线程上,可以起多个协程,协程是轻量级的线程(编译器做优化)

Go 协程的特点

  • 有独立的栈空间
  • 共享程序堆空间
  • 调度由用户控制
  • 协程是轻量级的线程
对比项Go 主线程Go 协程
数量1个可以成千上万个
创建成本高(几MB)低(几KB)
调度操作系统调度Go运行时调度
退出影响退出则程序结束退出不影响程序
package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("=== 餐厅开始营业 ===")
	
	// 经理(主线程)在做的事情
	fmt.Println("经理:我在整理账本...")
	
	// 招聘3个服务员(协程)
	go waiter("小明")
	go waiter("小红") 
	go waiter("小刚")
	
	// 经理继续做自己的事
	time.Sleep(time.Second)  // 假装在忙
	fmt.Println("经理:我在检查库存...")
	
	// 等待一下,让服务员有时间工作
	time.Sleep(3 * time.Second)
	fmt.Println("=== 餐厅打烊 ===")
}

func waiter(name string) {
	fmt.Printf("%s:开始服务第1桌\n", name)
	time.Sleep(500 * time.Millisecond)  // 服务需要时间
	fmt.Printf("%s:第1桌服务完成\n", name)
	
	fmt.Printf("%s:开始服务第2桌\n", name) 
	time.Sleep(500 * time.Millisecond)
	fmt.Printf("%s:第2桌服务完成\n", name)
}

11.2 goruntine的调度模型

11.2.1 G-M-P 模型

  • G = Goroutine (你要执行的代码)
  • M = Machine/OS Thread (操作系统线程)
  • P = Processor (逻辑处理器)
P1 (传送带1)           P2 (传送带2)
     /        \             /        \
    M1        M2          M3        M4
 (工人1)    (工人2)     (工人3)    (工人4)
    |          |           |          |
   G队列       G队列       G队列      G队列

"Go调度器像个聪明的工厂调度员:

  1. 给每个工人(M)一个传送带(P)
  2. 传送带上有任务队列(G)
  3. 工人干完活就去别的传送带偷任务
  4. 谁闲了就给谁派活"
package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	// 1. 查看CPU核心数(决定了P的数量)
	cpuNum := runtime.NumCPU()
	fmt.Printf("CPU核心数: %d\n", cpuNum)
	
	// 2. 默认P的数量 = CPU核心数
	pNum := runtime.GOMAXPROCS(0)
	fmt.Printf("逻辑处理器(P)数量: %d\n", pNum)
	
	// 3. 看调度
	fmt.Println("\n=== 调度演示 ===")
	
	// 启动很多goroutine
	for i := 0; i < 10; i++ {
		go worker(i)
	}
	
	time.Sleep(2 * time.Second)
}

func worker(id int) {
	fmt.Printf("Goroutine %d 在运行\n", id)
	time.Sleep(time.Second)
}

11.2.2 调度过程

场景1:正常调度

时间线:
t0: G1 在 P1 上运行
t1: G1 遇到网络IO阻塞
t1: P1 把 G1 拿走,换成 G2 运行
t2: G1 的IO完成了
t2: G1 被放回某个队列等待

场景2:工作窃取,每个P有自己的结构如下

┌─────────────────┐
│  本地队列(256)   │ ← 优先从这里取G
├─────────────────┤
│  全局队列        │ ← 所有P共享
├─────────────────┤
│  网络轮询器      │ ← 处理网络IO
├─────────────────┤
│  系统调用        │ ← 处理阻塞调用
└─────────────────┘

这个队列优先级:本地队列(256个) > 全局队列 > 网络轮询器 > 从其他P偷

抢占时机

  • 主动让出runtime.Gosched()
  • 函数调用时:检查是否要抢占
  • 系统调用返回时
  • 每10ms检查一次

11.3 channel

11.3.1 基本用法

Channel(通道):goroutine之间传递数据,使用如下

ch := make(chan 类型)  // 创建
ch <- 数据            // 发送
数据 := <-ch          // 接收
func channelBasics() {
	// 1. 无缓冲channel
	ch1 := make(chan int)
	
	go func() {
		fmt.Println("开始发送数据...")
		ch1 <- 42
		fmt.Println("数据发送完成")
	}()

	time.Sleep(100 * time.Millisecond)
	value := <-ch1
	fmt.Printf("接收到: %d\n", value)

	// 2. 有缓冲channel
	ch2 := make(chan string, 3)
	ch2 <- "A"
	ch2 <- "B"
	ch2 <- "C"

	fmt.Println("缓冲区大小:", cap(ch2))
	fmt.Println("当前元素数:", len(ch2))

	for i := 0; i < 3; i++ {
		fmt.Printf("接收: %s\n", <-ch2)
	}
}
  • channel 本质就是一个数据结构一队列
  • 数据是先进先出 (FIFO)
  • 线程安全,多 goroutine 访问时,不需要加锁,就是说 channel 本身就是线程安全的
  • channel 有类型的,一个 string 的 channel 只能存放 string 类型数据。
  • channle 的数据放满后,就不能再放入了,如果从 channel 取出数据后,可以继续放入
  • 在没有使用协程的情况下,如果 channel 数据取完了,再取,就会报 dead lock

11.3.2 Channel 遍历

最基础的遍历:for-range

package main

import (
	"fmt"
	"time"
)

func main() {
	// 1. 创建channel
	ch := make(chan int, 5)
	
	// 2. 生产者:往channel里放数据
	go func() {
		for i := 1; i <= 5; i++ {
			ch <- i
			fmt.Printf("生产: %d\n", i)
			time.Sleep(200 * time.Millisecond)
		}
		close(ch)  // ⭐️ 重要:必须关闭,否则for-range会死锁!
	}()
	
	// 3. 消费者:遍历channel
	fmt.Println("开始消费:")
	for num := range ch {  // ⭐️ 关键:for-range自动从ch取数据
		fmt.Printf("消费: %d\n", num)
	}
	
	fmt.Println("channel已关闭,遍历结束")
}
  • 在遍历时,如果 channel 没有关闭,则回出现 deadlock 的错误
  • 在遍历时,如果 channel 己经关闭,则会正常遍历数据,遍历完后,就会退出遍历。

11.3.2 只读/只写channel

channel 可以声明为只读,或者只写性质

func advancedChannels() {
	ch := make(chan int, 5)
	var sendCh chan<- int = ch // 只写
	var recvCh <-chan int = ch // 只读

	go func() {
		for i := 0; i < 5; i++ {
			sendCh <- i
		}
		close(sendCh)
	}()

	for v := range recvCh {
		fmt.Printf("接收到: %d\n", v)
	}
}

使用select可以解决从管道取数据的阻塞问题

ch1 := make(chan string)
ch2 := make(chan string)

go func() {
    time.Sleep(100 * time.Millisecond)
    ch1 <- "来自ch1"
}()

go func() {
    time.Sleep(200 * time.Millisecond)
    ch2 <- "来自ch2"
}()

for i := 0; i < 2; i++ {
    select {
        case msg1 := <-ch1:
        fmt.Println(msg1)
        case msg2 := <-ch2:
        fmt.Println(msg2)
        case <-time.After(50 * time.Millisecond):
        fmt.Println("超时")
    }
}

11 反射

11.1 反射介绍

反射可以在运行时动态获取变量的各种信息,比如变量的类型,类别,如果是结构体变量,还可以获取到结构体本身的信息 ( 包括结构体的字段、方法 )通过反射,可以修改变量的值,可以调用关联的方法。

使用反射,需要引入reflect

11.2 反射获取类型和值

反射一般常用的方法就是TypeOf和ValueOf

// 反射获取类型和值
var num int = 42
numType := reflect.TypeOf(num)
numValue := reflect.ValueOf(num)

fmt.Printf("类型: %v\n", numType)				// 类型: int
fmt.Printf("种类: %v\n", numType.Kind())		// 种类: int
fmt.Printf("值: %v\n", numValue)				// 值: 42
fmt.Printf("接口类型: %T\n\n", num)				// 接口类型: int

Kind和Type区别如下:

  • Type(类型): 指的是变量在代码中具体叫什么名字,通常是你定义的那个结构体名或者基础类型名。
  • Kind(种类): 指的是这个变量在底层本质上属于哪一类(比如是结构体、切片、指针还是基本数据类型)。
var num int = 10 // Type 是 int, Kind 是 int
var stu Student // Type 是 pkg1.Student , Kind 是 struct

var u User
var p *User = &u

// 对 p 进行反射
t := reflect.TypeOf(p)
fmt.Println("Type:", t)   // 输出: *pkg.User (类型是带星号的指针)
fmt.Println("Kind:", t.Kind()) // 输出: ptr (种类是指针)

11.3 结构体反射

p := Person{Name: "张三", Age: 25}

pType := reflect.TypeOf(p)
pValue := reflect.ValueOf(p)

fmt.Printf("类型: %v\n", pType)			// 类型: main.Person
fmt.Printf("种类: %v\n", pType.Kind())	// 种类: struct

// 通过反射获取字段
fmt.Println("\n字段信息:")
for i := 0; i < pType.NumField(); i++ {
    field := pType.Field(i)
    value := pValue.Field(i)
    fmt.Printf("  字段%d: 名称=%s, 类型=%v, 值=%v\n", i, field.Name, field.Type, value)
}

11.4 指针和接口反射

var ptr *int = &num
ptrType := reflect.TypeOf(ptr)
ptrValue := reflect.ValueOf(ptr)

fmt.Printf("指针类型: %v\n", ptrType)			// 指针类型: *int
fmt.Printf("指针种类: %v\n", ptrType.Kind())	// 指针种类: ptr
fmt.Printf("指针值: %v\n", ptrValue)			// 指针值: 0x180c09c

// 通过 Elem 获取指向的值
fmt.Printf("指针指向的值: %v\n", ptrValue.Elem())	// 指针指向的值: 42

// 接口反射
var iface interface{} = p
ifaceType := reflect.TypeOf(iface)
ifaceValue := reflect.ValueOf(iface)

fmt.Printf("\n接口类型: %v\n", ifaceType)		// 接口类型: main.Person
fmt.Printf("接口值: %v\n", ifaceValue)			// 接口值: {张三 25}

11.5 修改可反射的值

x := 10
v := reflect.ValueOf(&x).Elem()

fmt.Printf("修改前: %d\n", x)				// 修改前: 10

// 设置新值(必须是可设置的)
v.SetInt(20)
fmt.Printf("修改后: %d\n", x)				// 修改后: 20	

// 使用 Addr 获取可设置的值的地址
v2 := reflect.ValueOf(&x).Elem()
v2.Set(reflect.ValueOf(30).Convert(v2.Type()))
fmt.Printf("再次修改: %d\n", x)				// 再次修改: 30

11.6 动态调用方法

s := Student{Person: Person{Name: "李四", Age: 20}, Grade: 3}

sType := reflect.TypeOf(s)
sValue := reflect.ValueOf(s)

// 查找并调用方法
if method, ok := sType.MethodByName("Introduce"); ok {
    fmt.Printf("找到方法: %s\n", method.Name)
    // 调用方法(对于值方法)
    method.Func.Call([]reflect.Value{sValue})
}

// 使用指针调用方法
sp := &s
spType := reflect.TypeOf(sp)
spValue := reflect.ValueOf(sp)

if method, ok := spType.MethodByName("Study"); ok {
    fmt.Printf("找到指针方法: %s\n", method.Name)
    method.Func.Call([]reflect.Value{spValue})
}

11.7 运行时创建类型

personType := reflect.TypeOf(Person{})

// 创建新的 Person 实例
newPerson := reflect.New(personType).Elem()

// 设置字段值
newPerson.FieldByName("Name").SetString("王五")
newPerson.FieldByName("Age").SetInt(30)

fmt.Printf("新创建的实例: %+v\n", newPerson.Interface())

11.8 检查实现了特定接口的类型

// 动态检查类型是否实现接口
testTypes := []interface{}{
    Person{Name: "测试", Age: 10},
    Student{Person: Person{Name: "学生", Age: 15}, Grade: 2},
    "字符串",
    42,
}

for _, tt := range testTypes {
    val := reflect.ValueOf(tt)
    // 检查是否有 String 方法
    if _, ok := val.Type().MethodByName("String"); ok {
        fmt.Printf("%T 实现了 String 方法\n", tt)
    } else {
        fmt.Printf("%T 没有实现 String 方法\n", tt)
    }
}
评论交流

文章目录