
Golang 新人入坑指南
Golang 新人入坑指南
前置知识:
Java / C / C++ 基本编程基础;
Java 的面向对象
C 的指针
Golang 最基本的认识,可参考该教程看到 “Go语言指针”
如果有 Python 编程基础,有些地方会更容易理解
本教程的最终目的是:可以完成 WeRun 实验室 2024 春季纳新的 Golang 第一次作业,并实现所有加分项。
目标:模仿哈课表,实现一个简单的成绩管理系统。
核心功能:
输入成绩:允许输入一个学生的成绩。
删除成绩:允许删除一个学生的成绩。
修改成绩:允许修改一个学生某一科目的成绩。
计算:计算一个学生的平均分和总分。
输出成绩:输出一个或所有学生的成绩,包括总分和平均分信息。
实现要求:
使用结构体数组表示所有学生的相关信息,结构体应包含姓名、学号、各科成绩等信息。
在
main
函数中模拟数据,验证上述方法的实现。加分项:
并发处理:
使用
go routine
和channel
优化项目。解释使用并发的目的和设计思路。
异常处理:
利用
err
,defer
,panic
,recover
等关键字和类型进行异常处理。增强程序的健壮性。
反射:
利用反射功能进行动态类型判断和处理。
描述反射在项目中的应用及其带来的优势。
单元测试:
探索Go语言的单元测试功能。
对项目中的关键点进行合理的测试。
其他功能:
根据个人理解,增加和完善其他功能。
1. Golang “面向对象”
Golang 虽然没有类的概念,但是作为前身 Java 程序员,其实可以从“类和对象”的角度去学习 Golang 的语法,然后再进行拓展,比较 Golang 和 Java 的不同。
Java 的学习是从类开始的,那么不妨我们从 Golang 的学习从类似于类的结构——结构体开始。
1.1 结构体写法
我们第一次听说结构体通常是在C语言,C语言结构体写法大致如下:
typedef struct StuStruct {
char name[20]; // 使用方括号来声明数组
int score;
} Student;
int main() {
Student student;
student.score = 88;
strcpy(student.name, "刘硕");
printf("Student Name: %s\n", student.name);
printf("Student Score: %d\n", student.score);
return 0;
}
定义一个学生结构体,内含一个字符串(名字)和一个整数(分数)
C++ 的类如下:
class Student {
private:
std::string name;
int score;
public:
// 构造函数
Student(const std::string& name, int score) : name(name), score(score) {}
// 为便于复制 这里没有省略 getter 和 setter
std::string getName() const { return name; }
void setName(const std::string& newName) { name = newName; }
int getScore() const { return score; }
void setScore(int newScore) { score = newScore;}
};
// C++中main函数需要返回int类型
int main() {
Student student("刘硕", 88);
std::cout << "Student Name: " << student.getName() << ", Score: " << student.getScore() << std::endl;
return 0;
}
Java的类如下:
public class Student {
private String name;
private Integer score;
public Student (String name, Integer score) {
this.name = name;
this.score = score;
}
public static void main(String[] args){
Student student = new Student("刘硕", 88); // 需要全参构造方法
System.out.println(student.toString()); // 需要重写 toString()
}
}
定义一个学生类,内含两个私有属性:字符串(名字)和整数(分数)
Golang 的结构体实现如下:
type Student struct{
Name string
Score int
}
定义一个学生结构体,内含一个字符串(名字)和一个整数(分数)
和C、Java的不同点:
先声明变量名,后声明变量类型
每个变量名首字母大写,使它们可以被直接访问;首字母小写会类似于 Java 的
protected
类似Python,没有分号
在 Go 语言中,
type
关键字还可以用于定义新的类型名称(类似C语言的typedef
),包括基本类型的别名、结构体、接口、数组、切片、映射、通道等。type MyInt int var myVar MyInt = 10
在 Go 语言中,变量和函数的首字母大小写会对它们的可访问性产生影响,类似 Java 的
public
和protected
,或者Python 的双下划线。后续在 1.5.2 会详细说明这一点,这里有一个大致印象即可。
使用 C 语言的想法把Student结构体实例化,简单写了一个:
package main
import "fmt"
type Student struct {
Name string
Score int
}
func main(){
var s Student
s.Name = "刘硕"
s.Score = 80
fmt.Println(s)
}
// {刘硕 80}
1.2 实例化
1.2.1 回顾 C、C++、Java
C语言、C++和Java在结构体或类的实例化方面确实存在一些语法上的差异。以下是它们各自的实例化方法和一些关键区别:
在C语言中,结构体是通过关键字 struct
定义的。实例化结构体通常涉及以下几个步骤:
静态分配:静态分配由编译器管理。在栈上定义结构体变量。变量存储在程序的静态存储区(也称为全局存储区或数据段),这个区域在程序编译时就已经分配好。静态分配的变量在程序的整个运行期间都存在,它们的生命周期是全局的。适用于那些在整个程序运行期间都需要存在,且大小固定的变量。
Student student; student.score = 88; strcpy(student.name, "刘硕");
动态分配:动态分配需要程序员手动管理。在堆上使用
malloc
或calloc
分配内存。动态分配的内存生命周期需要程序员手动管理,包括分配和释放。如果忘记释放,可能会导致内存泄漏。适用于那些大小不确定或需要在运行时改变的变量,或者需要在函数之间传递复杂数据结构的情况。Student *student = malloc(sizeof(Student)); if (student != NULL) { student->score = 88; strcpy(student->name, "刘硕"); } free(student);
在C++中,类是通过关键字 class
或 struct
定义的(struct
在C++中与 class
几乎相同,但默认成员访问权限为 public
)。实例化类通常涉及以下几个步骤:
静态分配:在栈内存上分配空间。相比C语言,C++提供了构造方法。
Student student; // 如果没定义构造方法,默认提供这一个构造方法 Student student(); // 这是Java写法,C++ 编译器会爆Warning让你去掉括号 Student student("刘硕", 88); // 若属性为public 可以直接用点操作符访问 student.name; // 若属性为私有 需要用getter student.getName();
动态分配:使用
new
关键字在堆上分配内存。也可以使用构造方法。// 使用 new 操作符动态分配 Student 对象 Student* student = new Student("刘硕", 88); // 使用 -> 操作符访问public属性或public方法 student->getName(); student->getScore(); // 释放动态分配的内存 delete student;
在Java中,因为没有指针的概念,实例化类就只有这一种 new
的方式,和C++的动态分配很像:
Student student = new Student("刘硕", 88); // student 是栈内存的引用,指向堆内存的空间
student.getName();
// 有GC机制,不用手动释放
我Java才不管你内存释放不释放,关注OOP就完了!
1.2.2 Go 字面量初始化
使用字面量直接初始化变量时,通常发生在栈上。
对于基本类型的值(例如整型、浮点型、布尔型和字符串),它们直接存储在栈上的固定大小的空间内。
对于复合类型,如数组、结构体、切片和映射,它们可能在栈上分配较小的固定大小的空间,而较大的部分或动态分配的数据(如切片的底层数组)可能在堆上分配。
student := Student{
Name: "刘硕",
Score: 88,
}
println(student.Name) // 变量首字母大写相当于public,可以直接访问
声明一个student对象并初始化赋值;使用点运算符访问属性
有点像 C++ 的静态分配,并使用 key-value形式初始化,有点像JavaScript
一种简化版本可以写法如下:
student := Student{"刘硕", 88}
很像构造方法对吧?但是在 Golang 这里,这还算不上叫构造方法,因为内存分配和Java有大不同,这里对象新建在栈内存上。
Goland 会贴心的给你提示:
使用Key-value形式时,可以不把所有属性赋值:
student := Student{
Name: "刘硕",
}
此时student的Score为其零值
在 Go 语言中,零值(zero value)和
nil
是两个相关但不同的概念。
零值:每种类型的零值是当变量被声明后未初始化时自动赋予的值。不同类型的零值如下:
布尔型:
false
整型(包括 int、int8、int16、int32、int64等):
0
浮点型(包括 float32、float64):
0.0
复数型(complex64、complex128):
0 + 0i
字符串:
""
指针:
nil
接口:
nil
切片:
nil
映射(map):
nil
通道(chan):
nil
结构体:每个字段都被设置为其类型的零值
nil:是 Go 中的一个特殊值,用于表示“没有值”或“空”。
nil
可以被赋值给指针类型、接口类型、切片、映射、通道等引用类型。nil
不是任何类型的零值,它是一种特殊的标识符,用来表示变量没有指向任何内存地址或没有实现任何接口。两者的关系可以这样理解:
所有引用类型的零值是
nil
。非引用类型的零值则不是
nil
,而是各自类型的特定零值。例如,如果你声明了一个未初始化的整型变量
var a int
,它的值将是0
,这是整型的零值。而如果你声明了一个未初始化的指针变量var p *int
,它的值将是nil
,因为nil
是指针类型的零值。在实际编程中,
nil
常用于初始化引用类型的变量,表示它们不指向任何有效的内存地址或值。而零值则用于初始化非引用类型的变量,表示它们具有该类型的初始状态。
1.2.3 Go 使用 new 关键字与复合字面量写法
new 函数总是分配堆内存,并返回指向该内存的指针。
s := new(Student) // s 是类型为 *Student 的指针 指向内存的内容为 Student 类型的零值
s.Name = "刘硕" // 与C语言不同,仍使用点运算符访问对象
s.Score = 88
// Golang 也有 GC 机制,无需手动释放 new 出来的内存
s 为 *Student 指针;仍用点运算符访问对象
在Go中,`new(T)` 会分配类型为 T
的零值,并返回类型 *T
的指针。对于结构体来说,这将返回一个指向新分配的、零初始化的结构体的指针。
注意:与C语言不同,Go语言中,无论这个变量类型是结构体还是其指针,访问其成员的方式都是点运算符。
我们也可以挂羊头卖狗肉,先定义一个指针,然后再把它的值进行字面量初始化:
// s 是一个 *Student 指针
s := new(Student)
*s = Student{
Name: "刘硕",
Score: 88,
}
还有一种写法叫复合字面量写法 可认为和前者等价,马上就会用到:
// s 是一个 *Student 指针
s := &Student{
Name: "李四",
Score: 88,
}
// 更简洁的写法
s := &Student{"李四", 88}
1.2.4 Go 使用工厂函数
Golang 没有构造函数的概念,但是如果我们就想要一种”构造函数“来实例化呢?比如类似这种:
s := NewStudent("刘硕", 88) // 我想要 s 是一个 *Student 指针
那么我们就可以定义一个 NewStudent()
函数,返回上述的复合字面量写法即可:
func NewStudent(name string, score int) *Student {
return &Student{
Name: name,
Score: score,
}
}
student := NewStudent("刘硕", 88)
通过这种工厂函数写法,我们就可以做一些限制,比如异常处理等等,或者防止一些不合理的数据:
func NewStudent(name string, score int) *Student {
// 不合理的数据?我们直接返回零值!(这里只是举个例子)
if score < 0 {
return nil
}
// 换一种写法,更简洁
return &Student{name, score}
}
student := NewStudent("刘硕", 88)
在Go中,通常推荐使用第一种方式(字面量初始化),因为它既简洁又易于理解。如果你需要在创建对象时执行额外的逻辑,可以使用第三种方式(模拟构造函数)。
1.3 接收者函数(方法)
1.3.1 方法的编写
在 Go 语言中,方法(Method)是与特定类型关联的函数。
接收者方法是定义在特定类型上的方法。这些类型可以是用户定义的结构体、内置类型,甚至是原始类型(如 int、float64 等)。接收者方法看起来像常规函数,但它们有一个额外的参数,称为接收者(Receiver),它出现在函数签名的开始处。
例如,为 Student 结构体定义一个方法:
type Student struct {
Name string
Score int
}
// 为Student结构体定义一个 setter 方法
func (s *Student) SetScore(score int) {
s.Score = score
}
// 输出该学生的信息,并返回该学生的所有信息
func (s *Student) PrintScore() (name string, score int) {
name = s.Name // 直接赋值,不需要转换
scoreStr := strconv.Itoa(s.Score) // 将int类型的成绩转换为字符串
fmt.Println("姓名:" + s.Name)
fmt.Println("成绩:" + scoreStr) // 使用转换后的字符串
return name, s.Score // 返回学生的名字和成绩
}
// 测试函数
func test() {
// 初始化赋值 stuLiushuo
stuLiushuo := &Student{"刘硕", 88}
// 打印结构体
_, oldScore := stuLiushuo.PrintScore()
stuLiushuo.SetScore(90)
fmt.Printf("原成绩:%d\n现成绩:%d\n", oldScore, stuLiushuo.Score)
}
Go 语言里没有void(同时void也不是关键字);若函数或方法不返回值,不写返回类型即可。
通用语法格式如下:
func (variable_name variable_data_type) function_name() [return_type]{
/* 函数体*/
}
1.3.2 String() 方法
在 Go 语言中,并没有直接类似于Java中的 toString()
方法,因为Go是一种静态类型、编译型语言,并且不使用传统的面向对象编程范式。然而,Go提供了一种方式来实现类似toString()
的功能,即通过定义一个方法,该方法接收一个接收者(receiver)并返回一个字符串。
通常,这个方法会被命名为 String()
,并且可以被定义在任何自定义类型上。当需要将自定义类型的实例转换为字符串表示时,可以使用fmt
包中的Println
、Printf
或Sprintf
等函数,这些函数会调用String()
方法来获取对象的字符串表示。
import (
"fmt"
)
// Person 是一个包含个人信息的结构体
type Person struct {
Name string
Age int
Address string
}
// String 方法实现了将 Person 实例转换为字符串的功能
// 这里使用指针接受者与值接收者均能编译通过
func (p *Person) String() string {
return fmt.Sprintf("Name: %s, Age: %d, Address: %s", p.Name, p.Age, p.Address)
}
func main() {
p := &Person{Name: "Alice", Age: 30, Address: "123 Wonderland"}
fmt.Println(p) // 使用 Println 打印 Person 实例,自动调用 String 方法
}
更具体一点的例子可以到1.6查看,我在那里手搓了一个 json 。
1.4 接口与多态
从本节开始,我们讲述 OOP 的三大特性:
封装:将某些属性设为非导出(可以理解为
protected
),于 1.5 节讲解继承:Golang 没有继承,替代方法称为组合,于 1.6 节讲解
多态:于 1.4 节讲解
本节内容需要前置知识:
Golang 数组
foreach range 语法
已经掌握这些前置知识的可以直接跳转到 1.4.3 节
1.4.1 从 Java 接口来看 Golang
复习一下 Java 的接口:
interface Learner {
String getHello();
}
class Student implements Learner {
private String name;
private int score;
@Override
public String getHello() {
return "Hello, Student! " + name + " " + score;
}
}
class Teacher implements Learner {
private String name;
@Override
public String getHello() {
return "Hello, Teacher! " + name;
}
}
一个 Learner 接口 有 getHello()
抽象方法;有两个实现类 Student 和 Teacher
Learner learner1 = new Student();
Learner learner2 = new Teacher();
String hello1 = learner1.getHello();
String hello2 = learner2.getHello();
实例化 Learner 接口,并调用接口方法
可以看出,Java 中接口与实现类的关键关联在于 implements 这个关键字,而且实现类里,还需要 @Override
来指明复写方法。
要想实现和上述Java代码一样的逻辑,Golang 语言需要这么写(我们先实现一个 Student):
import (
"fmt"
"strconv"
)
// 这里是 Golang 接口定义 返回 string 类型
type Learner interface {
getHello() string
}
type Student struct {
Name string
Score int
}
// 通过编写与接口的同名方法来与接口形成关联
func (s Student) getHello() (str string) {
return "Hello " + s.Name + " " + strconv.Itoa(s.Score)
}
一个 Learner 接口 有 getHello()
抽象方法,返回类型为 string ;有一个实现类 Student
Golang 不像 Java 那样使用关键字,是通过直接编写与接口的同名方法( 这里是 getHello()
)来将实现类与接口关联起来。
// Learner 的实现类为 Student
func (s Student) getHello() (str string) {
return "Hello " + s.Name + " " + strconv.Itoa(s.Score)
}
// Learner 的实现类为 *Student
func (s *Student) getHello() (str string) {
return "Hello " + s.Name + " " + strconv.Itoa(s.Score)
}
实现类既可以是类,也可以是指针
现在我们将它进行实例化:
func main() {
learner := Student{"刘硕", 88}
// learner := &Student{"刘硕", 88} // 若使用实现类为指针的,需要这样写
s := learner.getHello()
fmt.Println(s)
}
实例化 Learner 接口,并调用接口方法
注意看第二行,嗯?怎么感觉哪里不对劲…… 这和直接实例化 Student 对象没有区别呀,无非就是变量改了个名字?甚至我把接口代码注释掉,运行得到的结果都是一样的!那么为什么 Golang 还出了个 interface 呢?
由此我们可以想到,Golang 使用接口的使用场景和 Java 会略有不同,其设计模式和设计哲学也会和 Java 有诸多不同。
我们不妨再想想,在Java里面,我们什么时候要用到接口?八成是我们用到类型集合的时候:
// 构造一个 Learner 集合
List<Learner> learners = new ArrayList<>();
// 向集合中添加元素
learners.add(new Student());
learners.add(new Teacher());
// 遍历集合下元素
for (Learner learner : learners) {
System.out.println("learner.getHello() = " + learner.getHello());
}
在集合中添加 Learner 接口实例化的对象,并调用接口方法
所以,我们在接触 Golang 的接口知识之前,需要简单了解一下 Golang 的数组。
1.4.2 Go 数组及遍历
Golang 数组定义方式如下:
// 声明数组
var array [3]int
// 给数组赋值(暂不关注)
array = [3]int{1, 2, 3}
// foreach遍历
for i, value := range array {
fmt.Printf("Index: %d, Value: %d\n", i, value)
}
当使用 range
遍历数组或切片时,range
会返回两个值:
索引 (对应上文的
i
)元素的值(对应上文的
value
)
我们也可以建立结构体数组:
// 声明数组
var students [2]Student
// 给数组赋值
students[0] = Student{"Alice", 88}
students[1] = Student{"Bob", 89}
// foreach遍历 注意下划线
for _, student := range students {
fmt.Println(student.getHello())
}
在 Go 语言中,_(下划线)是一个特殊的空白标识符,用来表示在某个上下文中不需要某个值。
在 for 循环中使用 range 关键字遍历数组、切片或映射时,你可以使用下划线来忽略不需要的索引或值。
1.4.3 Go 接口数组
好了,现在我们使用Golang接口数组时,接口就有用武之地了。
这里我们定义了一个 Learner
接口和两个结构体 Student
和 Teacher
;不过类的实现这里,既可以把结构体本身作为实现类,也可以把指针作为实现类。并且作为接受者,结构体本身和指针两者可以混用!(实际开发中应该是强烈不建议吧,这里只是举个例子)
// 定义 Learner 接口并声明它有一个 getHello()方法 返回类型为 string
type Learner interface {
getHello() string
}
// 定义 Student 结构体
type Student struct {
Name string
Score int
}
// 定义 Teacher 结构体
type Teacher struct {
Name string
}
// 将 Student 定义为 Learner 的实现类 并实现 getHello() 方法
func (s Student) getHello() string {
return "Hello Student " + s.Name + " " + strconv.Itoa(s.Score)
}
// 将 *Teacher 定义为 Learner 的实现类 并实现 getHello() 方法
func (t *Teacher) getHello() string {
return "Hello Teacher " + t.Name
}
func test() {
var learners [2]Learner // 创建一个 Learner 类型的数组
// 初始化元素
learners[0] = Student{"刘硕", 88} // 元素为 Student 类型
learners[1] = &Teacher{"恒则成"} // 元素为 *Teacher 类型
for _, learner := range learners {
// 由于类型本身和指针访问元素都是点运算符,因此可以直接这么操作
fmt.Println(learner.getHello())
}
}
接口数组完整代码,实现Golang多态
在main函数中调用 test()
最后输出:
Hello Student 刘硕 88
Hello Teacher 恒则成
1.5 包与访问权限
本节主要介绍 Golang 的封装。和 Java 类似,Golang也有包的概念。
1.5.1 Go 包
一个分包示例
每个 .go 文件都属于一个包。包提供了一种将代码组织到一起的方式,并且定义了一个命名空间。在一个包中定义的所有名称(变量、常量、类型、函数等)都在这个包的命名空间内。
每个 .go 文件的顶部都有一个包声明语句,指明了该文件属于哪个包。如
package main
Go 编译器将每个包作为编译单元。当编译一个包时,它会编译该包下所有的
.go
文件。一个包可以通过
import
语句导入其他包,这样就可以使用被导入包中导出的(首字母大写)名称。如inport "fmt"
在 Go 中,一个目录应该只包含属于同一个包的
.go
文件。go.mod
文件是 Go 语言模块(Go Modules)的依赖项声明文件。Go 模块是 Go 1.11 版本引入的一项特性,旨在提供一种更好的依赖管理方式,特别是在大型项目和开源项目中。我们会在稍后介绍。
1.5.2 Go 包与访问权限
Golang 包与访问权限不仅限于结构体的成员变量,直接在包内声明的变量的首字母大小写也会有访问权限之分。
Go 语言中的访问权限主要通过以下方式控制:
导出(Exported):如果一个标识符(类型、变量、常量、函数等)的名称以大写字母开头,那么它是导出的,可以被其他包访问。
非导出(unexported):如果一个标识符的名称以小写字母开头,那么它是非导出的,只能在其所在的包内部访问。
package mypackage
// Exported constant
const ExportedConst = 42
// Unexported constant
const unexportedConst = 100
// Exported function
func ExportedFunc() {
// ...
}
// Unexported function
func unexportedFunc() {
// ...
}
// Exported type
type ExportedType struct {
Field1 string
}
// Unexported type
type unexportedType struct {
field1 string // 注意字段也是非导出的
}
// Method on exported type (can be exported or unexported)
func (et *ExportedType) Method1() {
// ...
}
// Method on unexported type (can only be unexported)
func (ut *unexportedType) method1() {
// ...
}
在结构体中,Golang 的非导出字段可以认为是 Java 的 protected
:
type Student struct {
Name string
Score int
weight float32 // 比如这里 weight 就是非导出的
}
这里不做重点讲述,了解即可。
1.5.3 Go 模块
众所周知,各种语言都有它的包管理工具。
Python 有 pip;
Java 有 Maven 和 Gradle;
Node.js 有 npm、yarn、pnpm 等等
Golang 从 1.11 版本起也引入了它自己的一个包管理工具,就叫做 Go 模块(Go Modules)
包管理工具(即模块)一般会有这几种作用,可以和前文几种包管理工具对比一下来,看看是不是都有这些功能:
版本控制:Go 模块支持语义化版本控制(Semantic Versioning),允许开发者指定依赖项的具体版本或版本范围。
自动依赖管理:Go 工具链会自动下载和管理模块依赖,存储在
$GOPATH/pkg/mod
目录下。模块缓存:Go 模块会缓存下载的模块,以避免重复下载相同的依赖。
模块镜像:Go 模块支持使用模块镜像(如
goproxy.io
或私有模块镜像)来加速模块的下载。模块代理:Go 1.15 引入了模块代理的功能,允许通过代理服务器来访问模块。
后续引入框架时可以具体的感受到这一点,这里明白 go mod
命令大概是做什么的即可。
1.6 Go 组合初探
刚才讲过了多态、封装,差一个继承。不过……Go语言是不支持继承的,Go语言更推荐一种叫做“组合” 的方式。
Go 语言如何用组合来替代继承呢?这需要引入一种概念:“匿名字段”。
type Student struct {
Name string
Score int
}
// 定义一个 大学生 结构体
type CollageStudent struct {
// 一个匿名字段 只声明类型不声明变量名
Student
Major string
}
// String() 方法
func (cs CollageStudent) String() string {
// 直接通过访问类型来访问属性 以组合方式实现了继承
name := cs.Student.Name
score := cs.Student.Score
major := cs.Major
// 使用 fmt.Sprintf 格式化 Stirng
return fmt.Sprintf("{\n \"Name\": \"%s\",\n \"Score\": %d,\n \"Major\": \"%s\"\n}", name, score, major)
}
func test() {
stuLiushuo := CollageStudent{
Student{
"刘硕", 88,
},
"计算机科学与技术",
}
fmt.Println(stuLiushuo)
}
输出:
{
"Name": "刘硕",
"Score": 88,
"Major": "计算机科学与技术"
}
Go 组合的特点如下:
隐式命名:匿名字段不需要显式地命名,它直接继承了嵌入结构体的字段和方法。
类型共享:嵌入的结构体类型可以被共享,即多个结构体可以嵌入相同的类型作为匿名字段。
访问控制:匿名字段的访问权限遵循嵌入结构体的字段访问权限。
简单了解如何实现继承即可,后续会有更多Go专属的设计模式。
Go 之所以没有继承,是因为它秉持“组合优于继承”的思想。关于这种设计哲学可以参考此链接。
1.7 Go 反射初探
Go语言的反射(Reflection)是一种在运行时检查类型和操作值的能力。它允许程序在运行时动态地获取类型信息,并且可以操作这些类型对应的值。反射在Go中是通过标准库中的reflect
包实现的。
以下是Go反射的一些关键概念和用法:
反射类型:
reflect.Type
接口表示Go中的类型,可以通过调用reflect.TypeOf()
函数来获取一个值的类型。反射值:
reflect.Value
表示一个反射后的值,可以通过调用reflect.ValueOf()
函数来获取。reflect.Value
提供了方法来查询和修改这个值。类型断言:使用反射时,你可能需要将
reflect.Value
转换回其原始类型,这可以通过类型断言实现。可反射的类型:不是所有的类型都可以被反射。例如,指针、结构体、切片、映射、接口和通道是可反射的,而数组和函数则不是。
……
下面是一个简单的示例,演示如何使用反射来获取和打印一个结构体的字段名和值:
import (
"fmt"
"reflect"
)
type Student struct {
Name string
Score int
}
func test2(){
stu := Student{"刘硕", 88}
rt := reflect.TypeOf(stu)
rv := reflect.ValueOf(stu)
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
value := rv.Field(i)
fmt.Printf("Field: %s Type: %s Value: %v\n", field.Name, field.Type, value.Interface())
}
}
输出:
Field: Name Type: string Value: 刘硕
Field: Score Type: int Value: 88
反射是一个强大的工具,但应该谨慎使用,因为它可能会使代码更难理解和维护,并且可能会影响程序的性能。
2. Golang 复合类型
Go提供了一些内置的复合类型,这些类型可以被用作集合:
切片(Slice):切片是对数组的抽象,提供了动态数组的功能。切片可以用来存储一系列元素,并且可以动态地增长和缩小。
映射(Map):映射是一个无序的键值对集合,可以用来存储键到值的映射。映射在Go中是通过
map
关键字实现的。
数组:数组是固定长度的元素集合。一旦声明,其长度不能改变。
其中数组我们在 1.4.2 已经介绍过,这里不再赘述。
2.1 切片 Slices (待完成)
2.1.1 切片简介
强烈建议看这个视频:【【GO语言】5分钟带你理解数组与切片】 https://www.bilibili.com/video/BV1B24y1H7Cp/?share_source=copy_web&vd_source=0acc90ba529bf1b28fdeb3351912e2f2
首先,声明一个数组:
arr := [4]int{0, 1, 2, 3}
数组的大小必须是编译器可确定的值,只能使用字面量或者声明常量来初始化。
// var a = 4 // 错误
const a = 4 // 正确
arr := [a]int{0, 1, 2, 3}
接下来,我们基于这个数组声明一个索引从 1 到 3 的切片:
arr := [4]int{0, 1, 2, 3}
slice := arr[1:3]
切片索引是一个左闭右开区间,因此包含 1 不包含 3。
(00:47)
切片底层实现是一个结构体,包含三个属性:
数据指针:指向切片开头第一个元素的地址
长度
len(slice1)
:切片中包含元素的数量容量
cap(slice1)
:切片开头直到底层数组结束位置的长度
(01:14)
如果我们把切片的结束索引改为4,则长度扩增为3,容量仍为3。
如果我们把切片是初始索引改为0,结束索引放在结尾,则长度为4,容量也为4,指针也会指向数组第一个元素地址。
可以认为,切片实际上就相当于C语言的指针,只不过它又带上了长度和容量这俩概念。(可以理解成“指针切片”?)
切片的遍历方式与数组基本一致。
slice := []int{1, 2, 3, 4}
for i := 0; i < len(slice); i++ {
fmt.Println(slice[i])
}
// 或者使用 range
for i, value := range slice {
fmt.Println(i, value)
}
2.1.2 append
append
函数用于向切片追加元素。它可以接受一个或多个参数,并将它们追加到切片的末尾。如果切片的底层数组容量不足以容纳更多的元素,append
将触发扩容机制。
示例:
arr := [4]int{0, 1, 2, 3}
slice := arr[1:3] // 从索引 1 开始到索引 3 结束的切片
newSlice := append(slice, 4) // 向切片追加一个元素
fmt.Println(newSlice) // 输出: [1, 2, 3, 4]
切片的扩容机制
当使用 append
向切片追加元素时,如果切片的容量不足以容纳新元素,Go 将执行以下步骤来扩容:
计算新容量:Go 会根据当前容量和所需的额外容量来计算新的容量。通常,新容量会是当前容量的两倍,直到达到一定的上限。
分配新数组:Go 将分配一个具有新容量的数组。
复制元素:将原切片中的元素复制到新分配的数组中。
更新切片:更新切片的指针、长度和容量,使其指向新的数组。
slice := []int{1, 2, 3} // 初始切片,底层数组容量可能更大
for i := 0; i < 10; i++ {
slice = append(slice, i+4) // 逐步追加元素
}
fmt.Println(slice) // 输出追加后的完整切片
在这个示例中,随着追加操作的进行,切片可能需要多次扩容,每次扩容都会增加底层数组的容量。
append
函数和切片的扩容机制是 Go 语言中处理动态数组非常有用的工具,它们使得在编程时处理可变大小的序列变得更加容易和高效。
2.2 映射 Maps
Go语言中的映射(Maps)是一种内置的数据结构,它存储了键值对(key-value pairs),其中每个键都映射到一个特定的值。映射是一种非常灵活的数据结构,常用于多种编程场景,包括但不限于缓存实现、计数器、数据库索引等。
以下是Go映射的一些关键特性:
动态大小:映射的大小是动态的,可以根据需要自动增长。
无序性:映射中的元素是无序的。当你遍历映射时,元素的顺序是不确定的,不应该依赖于元素的特定顺序。
键的类型:映射的键必须是支持
==
和!=
操作符的类型,这包括大多数基本类型、结构体、指针等。值的类型:映射的值可以是任何类型,包括内置类型、用户定义的类型、甚至其他映射或切片。
以下是Go映射的写法:
// 使用 make 声明并初始化,键类型为string,值为int
Scores := make(map[string]int)
// 添加元素(赋值)
Scores["微积分"] = 88
Scores["线性代数"] = 98
Scores["C语言"] = 100
// 访问元素 如果键不存在,结果为类型的零值。
calculasScore := Scores["微积分"]
fmt.Println(calculasScore)
// 访问元素可以返回两个元素,可以检查键是否存在
goScore , ok := Scores["Go语言"]
if !ok {
fmt.Println("key Go语言 does not exist in the map")
} else {
fmt.Println(goScore)
}
// 遍历元素,与数组切片类似
for key, value := range Scores {
fmt.Printf("刘硕的%s成绩为:%d\n", key, value)
}
// 删除元素
delete(Scores, "微积分")
以下是Go映射的几个特性:
映射是引用类型:映射是引用类型,这意味着当你将一个映射赋值给另一个变量时,两者都会引用相同的映射。
内存分配:映射的内存分配机制比切片和数组复杂,因为它们需要存储键、值以及映射的哈希表结构。
零值:映射的零值是
nil
,尝试访问nil
映射的元素或对其进行操作会导致运行时错误。并发安全性:映射不是并发安全的。如果你需要在并发环境中使用映射,应该使用互斥锁或其他同步机制来避免竞争条件。
映射是Go中处理键值对数据的强大工具,但使用时需要注意它们的性能特点,尤其是在大量数据和高频率的更新操作时。映射的哈希表实现可能会导致较高的内存使用,并且在极端情况下,如果哈希函数分布不均匀,性能可能会受到影响。
2.3 复合类型操作函数
在 Go 语言中,cap()
、len()
和 make()
是与复合类型密切相关的内置函数,它们用于获取或创建不同类型的信息。以下是对这些函数的总结:
cap()cap()
函数用于返回一个容器类型的最大容量。容器类型包括切片(slice)、映射(map)和通道(chan)。对于数组,由于其容量是固定的,cap()
返回的值与 len()
相同。
切片:返回切片的最大容量,即切片底层数组的长度。
映射:返回映射可以存储的元素数量的理论上限,但实际容量可能会更多。
通道:返回通道的缓冲区大小。
示例:
s := make([]int, 0, 5)
fmt.Println(cap(s)) // 输出: 5
len()len()
函数用于返回容器类型中的元素数量。它适用于数组、切片、映射、字符串和通道。
数组:返回数组的长度。
切片:返回切片中元素的数量。
映射:返回映射中元素的数量。
字符串:返回字符串中字符的数量。
通道:返回通道中元素的数量,如果通道是空的,则返回 0。
示例:
s := []int{1, 2, 3}
fmt.Println(len(s)) // 输出: 3
make()make()
函数用于创建新的切片、映射和通道,它们都是引用类型。make()
会分配内存,并初始化这些类型,返回一个引用这些已初始化内存的值。
切片:创建一个具有指定长度和容量的切片。
映射:创建一个映射,用于存储键值对。
通道:创建一个通道,可以指定缓冲区大小。
示例:
s := make([]int, 5) // 创建一个长度和容量都为 5 的切片
m := make(map[string]int) // 创建一个映射
ch := make(chan int, 2) // 创建一个缓冲区大小为 2 的通道
range() 虽然 range()
不是一个内置函数,但它是一个在 Go 中非常重要的关键字,用于遍历数组、切片、映射、字符串等序列类型。
数组/切片:
range
会返回索引和值。映射:
range
会返回键和值。字符串:
range
会返回 Unicode 码点(rune)及其索引。
示例:
arr := [3]int{1, 2, 3}
for index, value := range arr {
fmt.Println("Index:", index, "Value:", value)
}
这些函数和关键字是 Go 语言中处理复合类型的基础工具,它们在编写高效、可读性强的并发代码时非常重要。
3. Golang 并发编程
Go 的并发编程和Java的想法很不一样,对于Go程序员而言,Go语法层面的并发真的很简单!
在开始并发编程之前,请抛弃OOP的想法,转回面向过程编程的想法,从头开始!
3.1 协程模型 goroutine
3.1.1 开启 goroutine
Go 的并发模型叫做 goroutine,我们在一开始可以将它理解为轻量级线程即可。
使用 go [函数名](参数)
就可以开启一个新的 goroutine,以下简称为“协程”。
当开启一个 goroutine 时,就像新开一个线程一样,使得程序“同时”处理两个任务:
import (
"fmt"
"time"
)
// 把一个字符串打印到控制台
func say(str string) {
for i := 0; i < 10; i++ {
// 使效果更明显,需要使用Sleep
time.Sleep(100 * time.Millisecond)
fmt.Println("Say " + str)
}
}
func main(){
// 使用 go [函数名] 开启一个新 goroutine
go say ("刘硕真丑")
// 这条语句与主线程是同步进行的
say ("刘硕真帅")
}
执行以上代码,你会看到输出的 帅和丑 没有固定先后顺序。因为它们是两个 goroutine 在执行:
Say 刘硕真丑
Say 刘硕真帅
Say 刘硕真丑
Say 刘硕真帅
Say 刘硕真帅
Say 刘硕真丑
Say 刘硕真帅
Say 刘硕真丑
Say 刘硕真帅
Say 刘硕真丑
Say 刘硕真帅
Say 刘硕真丑
Say 刘硕真帅
Say 刘硕真丑
Say 刘硕真帅
Say 刘硕真丑
Say 刘硕真帅
Say 刘硕真丑
Say 刘硕真帅
你会发现帅比丑多一个,这是因为帅是主线程(主协程),当它结束的时候它就不等丑的协程结束了,因此最后一个丑就没有输出出来。(所以说丑话不能往外说)
我们也可以像JS的匿名函数或者Java的lambda表达式一样,直接匿名开一个新线程。
func main() {
go func() {
for i := 0; i < 3; i++ {
fmt.Println("go")
}
}() // 注意最后面这个括号
// 让主线程阻塞一下 等待上面的线程结束 否则控制台就看不到东西了
time.Sleep(100 * time.Millisecond)
}
注意:一旦我们使用了go关键字,函数的返回值就会被忽略,故不能使用函数返回值来与主线程进行数据交换,而只能使用channel。
3.1.2 goroutine 与线程的区别
这里是原理部分,有一个简单的概念和印象即可。
首先,我们简单对线程做一下区分:
线程的用户态和内核态的概念是操作系统领域的基本概念。(这里可以查阅一下GMP模型)用户态线程和内核态线程的区别在于线程管理和调度的责任归属不同。
用户态线程: 在用户态线程模型中,线程的创建、销毁、调度等操作都由用户空间的程序或者库来完成,而不需要依赖操作系统内核的支持。这种模型的优点是轻量级和灵活性高,但也存在一些局限性,比如阻塞操作可能会导致整个线程被阻塞,无法进行其他任务。
内核态线程: 在内核态线程模型中,线程的创建、销毁、调度等操作由操作系统内核来管理,内核负责线程的调度、上下文切换等操作。内核态线程通常由操作系统内核调度,是操作系统调度的基本单位。
Go 语言的 goroutine 机制就是在语言层面加以利用了用户态线程的概念,利用 Go 运行时系统(runtime system)来管理和调度 goroutine。这种轻量级的用户态线程模型使得 Goroutine 能够更加高效地创建、销毁和调度,而无需依赖于操作系统的内核线程。这使得 Go 语言能够轻松处理大规模的并发任务,而不会因为线程创建和销毁的开销而降低性能。
然后,我们把 goroutine 和 Java 或者 C语言 的线程做一下简单的区分,
实现机制:
Go的goroutine:是Go语言特有的轻量级线程,由Go运行时管理。它们比传统的操作系统线程更轻量,因为它们的栈是动态的,并且可以按需调整大小。
Java线程:是Java虚拟机(JVM)中的线程,由操作系统的线程支持。每个Java线程通常映射到一个操作系统线程,因此它们的资源消耗和创建成本较高。
C语言线程:通常是通过POSIX线程库(pthread)或其他操作系统特定的线程库实现的。C语言的线程直接映射到操作系统的线程,因此它们的资源消耗和行为与Java线程类似。
资源消耗:
Go的goroutine:由于其轻量级的特性,创建和销毁的开销较小。可以轻松创建数以百万计的goroutines。
Java线程:每个线程都消耗较多的系统资源,创建和销毁的成本较高。
C语言线程:与Java类似,每个线程都消耗较多的系统资源。
调度方式:
Go的goroutine:由Go运行时的调度器进行调度,多个goroutines可以被映射到较少的系统线程上。这种多路复用减少了线程切换的开销。
Java线程:由JVM的线程调度器进行调度,通常由操作系统的线程调度器进一步调度。
C语言线程:由操作系统的线程调度器直接调度。
并发模型:
Go的goroutine:推荐使用通信顺序进程(CSP)模型,通过channels进行goroutines之间的通信,而不是共享内存。
Java线程:通常使用共享内存模型,线程之间通过锁(如synchronized块)和同步机制(如volatile变量)进行通信和同步。
C语言线程:也通常使用共享内存模型,需要使用互斥锁(如pthread_mutex)和原子操作来保护共享数据。
编程复杂性:
Go的goroutine:Go的并发模型相对简单,开发者可以更容易地编写并发程序。goroutines的通信和同步机制(如channels和select语句)使得并发编程更加直观。
Java线程:多线程编程通常更复杂,需要处理线程之间的同步、互斥和死锁等问题。
C语言线程:线程编程复杂性高,需要手动管理线程的生命周期和资源,容易引发数据竞争和死锁。
操作系统依赖性:
Go的goroutine:是Go语言特有的,与操作系统无关。它们由Go运行时管理,不直接依赖于操作系统的线程。
Java线程:依赖于JVM和操作系统的线程管理机制。
C语言线程:直接依赖于操作系统的线程管理机制。
总的来说,Go的goroutine提供了一种更轻量级、更易于管理的并发执行方式,而Java线程和C语言线程则提供了更接近硬件的并发执行能力,但管理起来更为复杂。选择哪种并发机制取决于具体的应用需求和性能考虑。
3.2 协程间通信 Go channel
3.2.1 为什么要使用 Go channel
Java 线程之间若要通信其实很简单,直接访问同一块内存即可,即共享内存模型。
Go 语言其实也是可以访问共享内存的:
func test5(){
str := "Hello"
go func() {
for i := 0; i < 3; i++ {
fmt.Println(str)
}
}()
time.Sleep(100 * time.Millisecond)
}
// 会输出三个 Hello
但是Go觉得,这样直接用内存来共享实在是太low了,它直接创造了一个新的模型叫做 go channel 来让进程间共享。
这个 go channel 需要用来:
提供了同步的通信机制:channel 是线程安全的。让 goroutine 访问 channel 时能够同步执行。
解耦 goroutines:使用channel可以让goroutines之间的依赖更松散。goroutines不需要直接引用其他goroutine的内存,而是通过channel来交换数据。这可以降低系统各部分之间的耦合度。
通道关闭:Channel可以被关闭,这个特性可以用来通知 goroutines 不再发送数据,或者结束接收循环。这是直接共享内存所不能直接实现的。
选择和超时:select 语句允许多个channel操作同时进行,并且可以设置超时,这在基于共享内存的通信中很难实现。
缓冲channel:带缓冲的channel可以作为goroutines之间的缓冲层,平衡生产者和消费者的速度,这是共享内存所不具备的特性。
下文起,通道、管道、go channel 三者都是同样的意思。
3.2.2 通道的数据结构:队列
通道(channel)的实现其实很简单,顾名思义,具体来讲就是一个队列。
可以把它暂时理解成 Java 中
Queue<T>
的简化版。
我们来熟悉一下操作这个“队列”的语法:
// 声明通道并使用 make 初始化
// 类型为 string 容量为 3
queue := make(chan string, 3)
// 通道是引用类型
fmt.Printf("queue 的值为:%v\n", queue) // 0xc000078060
// 向通道存放数据
queue <- "刘硕"
queue <- "刘烨"
namePyy := "彭于晏"
queue <- namePyy
fmt.Printf("通道的实际长度:%v,通道的容量为:%v\n", len(queue), cap(queue))
// 若不开新的协程,不能存放大于容量的数据,否则报错
// 弹出数据
name := <-queue
fmt.Println(name)
// 若不开新的协程,不能再向空队列取出数据,否则报错
fmt.Printf("通道的实际长度:%v,通道的容量为:%v\n", len(queue), cap(queue))
输出为:
queue 的值为:0xc000078060
通道的实际长度:3,通道的容量为:3
刘硕
通道的实际长度:2,通道的容量为:3
不过通道可不是为了充当一个单纯的数据结构而设计的,它的作用可远不止于此。
3.2.3 通道的关闭与遍历
那么这个通道“队列”如何遍历呢?我们很容易就想到 for - range
结构:
ch := make(chan string, 3)
ch <- "刘硕"
ch <- "刘烨"
ch <- "彭于晏"
// 尝试遍历
for name := range ch {
fmt.Println("通道里有名字为:" + name)
}
我们一个愉快运行,却发现程序报错 fatal error 终止了:
通道里有名字为:刘硕
通道里有名字为:刘烨
通道里有名字为:彭于晏
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.test2()
D:/code/golang/homework01/main/main.go:16 +0xf1
main.maiD:/code/golang/homework01/main/main.go:61 +0xf
这是因为上述代码有一个问题:由于通道没有被显式关闭,for 循环将无限进行下去。为了正确地遍历通道,你需要在填充完通道后关闭它,这样 range 循环在接收完所有值后可以退出:
ch := make(chan string, 3)
ch <- "刘硕"
ch <- "刘烨"
ch <- "彭于晏"
// 关闭通道
close(ch)
for name := range ch {
fmt.Println("通道里有名字为:" + name)
}
此时程序会正常退出
关闭通道有几个重要特征:
通道关闭后,只可读不可写,只可出不可入。
通道关闭是不可逆的操作。
3.2.4 使用通道进行协程间通信
现在,我们以另一个视角来观察 go channel:
现在它不作为存储数据类型的队列,而是协程间通信的通道。
队列的长度,则是通道的缓冲区。
我们可以设立一个不带缓冲区的通道:
ch := make(chan int) // 不带缓冲的通道
协程间通信一定会有发送方和接收方,不带缓冲的通道在发送数据时,发送方会阻塞,直到接收方从通道中接收了值,可以说实现了同步:
func test3() {
ch := make(chan int) // 不带缓冲的通道
go func() {
fmt.Println("子协程 2:准备接收数据,阻塞中")
msg := <-ch
fmt.Println("子协程 2 接收到的数据:", msg)
}()
go func() {
fmt.Println("子协程 1: 于 1 秒后发送数据")
time.Sleep(1 * time.Second)
ch <- 42
fmt.Println("子协程 1: 数据发送完毕")
}()
time.Sleep(2 * time.Second) // 主goroutine故意延迟,模拟子协程
}
输出为:
子协程 2:准备接收数据,阻塞中
子协程 1: 于 1 秒后发送数据
(等待 1 秒)
子协程 1: 数据发送完毕
子协程 2 接收到的数据: 42
带缓冲区的通道也是类似。带缓冲区的通道允许发送端的数据发送和接收端的数据获取处于异步状态,就是说发送端发送的数据可以放在缓冲区里面,可以等待接收端去获取数据,而不是立刻需要接收端去获取数据。
不过由于缓冲区的大小是有限的,所以还是必须有接收端来接收数据的,否则缓冲区一满,数据发送端就无法再发送数据了。
注意:如果通道不带缓冲,发送方会阻塞直到接收方从通道中接收了值。如果通道带缓冲,发送方则会阻塞直到发送的值被拷贝到缓冲区内;如果缓冲区已满,则意味着需要等待直到某个接收方获取到一个值。接收方在有值可以接收之前会一直阻塞。
3.2.5 Select 语句
在 Go 语言中,select
是一个用于处理多个通道操作的关键字,它允许你同时监控多个通道,然后执行第一个准备就绪的通道操作。select
通常用于以下场景:
非阻塞地从多个通道接收数据:当有多个通道操作需要同时进行时,
select
可以等待所有给定的通道操作,然后执行第一个完成的操作。超时和计时控制:
select
可以与time.After
结合使用,实现对某个操作的超时控制。实现多路复用:在网络编程中,
select
可以用来同时监听多个网络连接的通道,实现高效的 I/O 多路复用。
select
的基本语法如下:
select {
case <-channel1:
// 通道1可读,执行这里的代码
case channel2 <- value:
// 通道2可写,执行这里的代码
default:
// 如果没有通道操作可以进行,执行这里的代码
// 可选,如果没有default分支,select将阻塞
}
每个 case
子句都包含一个或两个通道操作,可以是接收操作 <-
或发送操作 ->
。select
随机选择一个准备就绪的操作(如果有多个操作同时就绪,随机选择其中一个),然后执行相应的 case
代码块。
如果没有任何通道操作可以立即进行,select
将阻塞,除非提供了 default
分支,此时 select
将执行 default
分支中的代码。
package main
import (
"fmt"
"time"
)
func main() {
c1 := make(chan int)
c2 := make(chan int)
// 启动一个 Goroutine 发送数据到 c1
go func() {
time.Sleep(1 * time.Second)
c1 <- 1
}()
// 启动另一个 Goroutine 发送数据到 c2
go func() {
time.Sleep(2 * time.Second)
c2 <- 2
}()
// 使用 select 监控两个通道
select {
case msg1 := <-c1:
fmt.Println("Received on channel 1:", msg1)
case msg2 := <-c2:
fmt.Println("Received on channel 2:", msg2)
case <-time.After(3 * time.Second):
fmt.Println("Timed out waiting for channels")
}
}
在这个示例中,select
同时监控来自 c1
和 c2
的数据,以及一个 3 秒的超时。由于 c1
首先在 1 秒后发送数据,select
将接收 c1
的数据并打印出来。如果两个通道都没有数据发送,或者发送操作花费的时间超过了超时时间,select
将执行 default
分支中的代码(在这个例子中没有 default
分支,所以程序将阻塞直到某个通道准备好)。
3.3 锁机制 Mutex
如果你还是不想用 go channel,还是想用共享内存方法时,Go也提供了一种互斥锁 Mutex 确保你共享内存变量的协程安全。
在 Go 语言中,sync.Mutex
是标准库 sync
包提供的一个互斥锁(Mutex),用于控制对共享资源的并发访问。当多个 Goroutine 需要访问同一资源时,使用互斥锁可以保证在同一时刻只有一个 Goroutine 能够访问该资源,从而避免数据竞争和一致性问题。
以下是 sync.Mutex
的基本用法:
初始化:
sync.Mutex
是一个结构体,使用默认方式初始化即可。import "sync" var mu sync.Mutex
锁定: 使用
mu.Lock()
方法锁定互斥锁。如果互斥锁已经被其他 Goroutine 锁定,则调用Lock()
的 Goroutine 将阻塞,直到互斥锁被解锁。mu.Lock()
解锁: 使用
mu.Unlock()
方法释放互斥锁。这应该总是在持有锁的 Goroutine 中完成访问共享资源后执行。mu.Unlock()
示例:
func test5() {
var mu sync.Mutex
var sharedResource int
for i := 0; i < 5; i++ {
// 启动了 5 个 Goroutine
go func(i int) {
mu.Lock()
// 临界区开始
sharedResource++
fmt.Println("Access by Goroutine", i, "sharedResource:", sharedResource)
time.Sleep(time.Second) // 模拟一些操作
// 临界区结束
mu.Unlock()
}(i)
}
time.Sleep(10 * time.Second)
}
在这个示例中,我们创建了一个共享资源 sharedResource
,并且启动了 5 个 Goroutine 来并发地修改这个资源。使用 sync.Mutex
确保每次只有一个 Goroutine 可以修改 sharedResource
,避免了并发访问导致的数据竞争问题。
Access by Goroutine 0 sharedResource: 1
Access by Goroutine 4 sharedResource: 2
Access by Goroutine 2 sharedResource: 3
Access by Goroutine 3 sharedResource: 4
Access by Goroutine 1 sharedResource: 5
使用互斥锁是实现并发程序中同步机制的一种基本方法,但开发者需要谨慎使用,以避免死锁和性能问题。
4. Golang 异常处理
一说异常和错误处理,我们就可以想到一个非常简单的场景:
// 不带任何处理的除法函数
func Divide(a, b int) int {
return a / b
}
func test(){
// 除数为 0
fmt.Println(Divide(10, 0))
}
运行 test()
程序会报错,控制台输出如下:
panic: runtime error: integer divide by zero
goroutine 1 [running]:
main.Divide(...)
/home/main.go:17
main.test(...)
/home/main.go:28
main.main()
/home/main.go:40 +0x13
...Program finished with exit code 2
报了一个 panic,让当前协程停止运行,并终止程序
现在我们想要通过一些机制,让这个函数执行除零操作后告诉用户不允许除零,并继续正常程序运行。这就是我们所说的异常处理。
正如 Java 有 Error(错误)和 Exception(异常)之分,Golang 也有 error 和 panic 之分:
error:
是 Go 语言的错误处理机制的标准方式
是预期中的错误管理,例如文件不存在、网络请求失败等。
是显式的,它通过返回值来传递错误信息。调用者需要检查函数返回的错误值并作出相应的处理。
panic:
是 Go 语言中用于异常情况的一种机制,
表示程序遇到了预料之外的错误,比如运行时的断言失败。
会导致当前 goroutine 立即停止执行,并开始寻找最近的
recover
函数调用,如果没有找到,则终止程序。
是隐式的,一旦触发,就会立即影响程序的执行流程。
下面我们介绍用各种方式处理上文的“异常”。
4.1 error 错误接口
4.1.1 错误的创建
在 Go 中,error
是一个内置的接口类型,定义如下:
type error interface {
Error() string
}
任何实现了 Error() string
方法的类型都可以作为错误值使用。
我们可以使用 errors.New("")
如此创建一个 error :
func test3(){
// 创建一个 error 需要掌握写法
divideByZeroError := errors.New("cannot divide by zero!")
fmt.Println(divideByZeroError)
// 利用反射机制,验证 divideByZeroError 是否实现了 error 接口
concreteType := reflect.TypeOf(divideByZeroError)
ifaceType := reflect.TypeOf((*error)(nil)).Elem()
fmt.Printf("divideByZeroError的类型为:%v\n是否实现了error接口:%v",concreteType ,concreteType.Implements(ifaceType))
}
程序输出如下,可以看出它创建出的 divideByZeroError
实现了 error
的 Error()
方法:
cannot divide by zero!
divideByZeroError的类型为:*errors.errorString
是否实现了error接口:true
这样一来,我们就可以使用 errors.New
非常清晰的对除法函数进行错误处理了:
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero") // 返回错误信息
}
return a / b, nil // 返回正常结果
}
func test1(){
_ , err := Divide(10, 0)
if err != nil {
fmt.Println("Error:", err) // 处理错误
}
fmt.Println("Hello World")
}
输出结果中,异常处理后面的 Hello world 也能正常输出:
Error: cannot divide by zero
Hello World
4.1.2 错误类型
除了使用 error.New()
创建 *errors.errorString
以外, error
还有多种类型:
首先我们可以自定义错误类型,只要实现 Error()
即可:
// 自定义错误类型
type customError struct {
message string
}
func (e *customError) Error() string {
return e.message
}
// 使用自定义错误类型
custom := &customError{message: "something went wrong"}
fmt.Println(custom)
除此之外还有几种错误类型,简单介绍一下:
标准库错误类型:
*os.PathError
:表示与路径相关的错误,例如文件不存在。*os.SyscallError
:包装底层系统调用返回的错误。*os.LinkError
:与链接操作相关的错误。*net.OpError
:网络操作相关的错误。*fmt.Errorf
:用于创建自定义错误,通常返回error
类型。
错误包装:
Go 1.13 引入了错误包装的概念,允许创建包含原始错误的新错误。使用
fmt.Errorf
或errors.New
可以创建这样的错误。
4.2 defer 语句
在介绍后面的异常处理之前,Golang 有一个逆天的语句叫做defer。
defer
语句用于延迟执行一个函数调用,直到包含该 defer
语句的函数返回时才执行。这在资源释放、日志记录等场景中尤为有用:
package main
import "fmt"
func main() {
defer fmt.Println("Closing file...")
// 执行文件操作...
}
// 输出:Closing file...
如果有多个defer
语句,它们按后进先出(LIFO)顺序执行:
package main
import "fmt"
func main() {
defer fmt.Println("Second deferred call")
defer fmt.Println("First deferred call")
// 执行其他操作...
}
// 输出:
// First deferred call
// Second deferred call
defer
语句的执行时机在return
语句之后,函数返回之前:
package main
import (
"fmt"
"time"
)
func process() {
// 假设这是一些需要执行的逻辑
fmt.Println("Processing data...")
}
func measureExecutionTime(fn func()) {
start := time.Now() // 记录开始时间
defer func() {
// 这个 defer 会在函数结束前执行
fmt.Printf("Function took %v to execute.\n", time.Since(start))
}()
fn() // 调用传入的函数
}
func main() {
measureExecutionTime(process)
}
在这个例子中,measureExecutionTime
函数接收一个函数 fn
作为参数,并在调用它之前记录了当前时间。defer
语句用于记录函数执行完毕后的时间,并计算持续时间。即使 process
函数中没有任何返回语句,defer
语句也会在 measureExecutionTime
函数返回之前执行,输出函数执行所需的时间。
输出结果可能如下:
Processing data...
Function took 10.000µs to execute.
这个例子展示了 defer
语句如何在函数正常返回时执行,即使它位于 return
语句的逻辑之前。这种特性使得 defer
成为处理资源清理和记录日志等场景的理想选择。
4.3 panic 与 recover
4.3.1 触发运行时错误
panic
语句用于触发一个运行时错误,立即停止当前函数的执行,并开始回溯调用栈,直到遇到recover
或程序终止:
package main
import "fmt"
func mayPanic() {
if condition {
panic("An error occurred!")
}
}
func main() {
mayPanic()
fmt.Println("This line will not be reached.")
}
panic
可以接受任意类型作为参数,通常传递一个字符串或错误接口实例,以便于错误信息的传递和处理:
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
if b == 0 {
panic(errors.New("Division by zero"))
}
return a / b, nil
}
func main() {
_, err := divide(10, 0)
if err != nil {
fmt.Println(err) // 输出:Division by zero
}
}
注意:不能随意使用panic
处理非严重错误。panic
应主要用于处理不可恢复的运行时错误,对于可处理的错误,应通过返回错误值的方式传递给调用者。
4.3.2 捕获panic
recover
函数只能在defer
语句中调用,用于捕获当前goroutine发生的panic,并返回panic传入的值。如果没有panic发生,recover
返回nil
:
package main
import "fmt"
func mayPanic() {
panic("An error occurred!")
}
func handlePanic() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r)
}
}()
mayPanic()
}
func main() {
handlePanic()
fmt.Println("Program continues after panic recovery.")
}
易错点:错误地认为recover
可以跨goroutine捕获panic。recover
只能捕获同一goroutine内发生的panic,对于其他goroutine引发的panic无能为力。在并发编程中,应结合sync.Once
、context.Context
等工具实现跨goroutine的错误传播与处理。
5. Golang 单元测试
Go 语言的单元测试是验证代码单个组件或单元正确性的一种方式。Go 标准库中的 testing
包提供了丰富的支持来编写和自动运行单元测试。
单元测试的基本组成部分:
测试函数:以
Test
开头,后跟一个大写字母的名称,用于描述测试的功能。例如,TestAdd
测试加法函数。断言函数:
testing
包提供了断言函数,如assert.Equal
、assert.NotEqual
、assert.True
等,用于验证测试结果是否符合预期。测试执行:使用
go test
命令来执行包中的所有测试函数。测试覆盖率:使用工具如
go test -cover
来检查测试覆盖率。基准测试:用于测量和比较代码的性能。
子测试:允许你将一个测试分割成多个子测试,每个子测试可以独立执行和报告。
编写单元测试的步骤:
创建测试文件:通常在与被测试的代码相同的包中创建一个名为
*_test.go
的文件。导入
testing
包:所有测试代码都需要导入testing
包。编写测试函数:使用
func TestXxx(t *testing.T)
格式定义测试函数。使用断言:在测试函数中使用
assert
或require
断言函数来验证条件。运行测试:使用
go test
命令执行测试。
示例:
假设我们有一个简单的加法函数:
// 在 add.go 文件中
package math
func Add(x, y int) int {
return x + y
}
我们可以编写以下单元测试:
// 在 add_test.go 文件中
package math
import "testing"
func TestAdd(t *testing.T) {
sum := Add(1, 1)
if sum != 2 {
t.Errorf("Add(1, 1) = %d; expected 2", sum)
}
}
在这个测试中,我们调用了 Add
函数并检查结果是否符合预期。如果不符合,我们使用 t.Errorf
来报告错误。
在命令行中,导航到包含 add_test.go
文件的目录,然后运行:
go test
这将自动执行所有以 Test
开头的函数,并报告测试结果。
高级特性:
并发测试:使用
t.Parallel()
可以让测试并行执行,提高测试效率。测试配置:使用
test SetUp
和test TearDown
模式来初始化和清理测试环境。测试分割:使用
t.Run
可以将测试分割成子测试,每个子测试可以独立执行。测试覆盖率:使用
go test -cover
来生成测试覆盖率报告。测试标记:使用
-t
标记来运行具有特定标记的测试函数。
Go 的单元测试框架简洁而强大,支持多种测试模式和高级特性,使得编写可靠和高效的测试变得容易。
参考
Kimi.ai:信华信的机房里面实在是没法跨越长城的那面墙,能访问Kimi先生就已经很不错了。 https://kimi.moonshot.cn/
Golang 并发原理方面简要参考的抒情熊学长的博客 http://bearsattack.top:8090/archives/go
Go 并发 | 菜鸟教程 (runoob.com) https://www.runoob.com/go
感谢 onlinegdb 让我在没有go环境的电脑上还能愉快的学习 https://www.onlinegdb.com/online_go_compiler
Go Channel 部分参考了 https://www.bilibili.com/video/BV1bN4y1Z7BT?p=137&vd_source=f846b8752667515d0546faab7abb577e