速看!快速学 Go 语言的通关秘籍都在这了

文档大纲

一、初步了解Go语言

(一)Go语言诞生的主要问题和目标

1. 多核硬件架构

伴随计算机硬件的不断发展,多核处理器已成为主流配置,并行计算也因此变得愈发普遍。然而,传统编程语言在应对多核并行性时往往会遇到难题,原因在于它们缺乏合适的原生支持。Go 语言则通过引入轻量级的协程(goroutine)和通道(channel)机制,极大地简化了并发编程。开发者能够轻松创建数千个可并发执行的协程,而无需为复杂的线程管理问题担忧。

2. 超大规模分布式计算集群

随着云计算和分布式系统的蓬勃发展,构建并维护超大规模的分布式计算集群日益常见。这些集群需要具备高效处理大量请求、实现数据共享与协调的能力。Go 语言的并发特性与通道机制,让分布式系统的编写工作变得更加轻松。开发者可借助协程和通道来处理并发任务、进行消息传递以及协调各项工作。

3. Web 模式引发的开发规模与更新速度提升

Web 应用的崛起带来了前所未有的开发规模增长以及持续更新的需求。传统编程语言在开发大型 Web 应用时,常常会面临可维护性不佳、性能不足以及开发效率低下等问题。Go 语言凭借其简洁的语法、高效的编译速度以及强大的并发支持,使开发者能够更迅速地对 Web 应用进行迭代和部署,同时也能更好地应对高并发的网络请求。

综合来看, Go语言在诞生时确实着重解决了多核硬件架构、超大规模分布式计算集群和Web模式下的开发规模与速度等技术挑战,它的设计目标之一是提供一种适应现代软件开发需求的编程语言,使开发者能够更好地应对这些挑战。

(二)Go语言应用典型代表

Go语言在当下应用开发中已经得到广泛应用,许多知名公司和项目都使用Go语言来构建各种类型的应用。以下是一些代表性的产品和项目,它们使用了Go语言作为核心开发语言:

这些仅仅是Go语言应用的一小部分示例,实际上还有许多其他的项目和产品也在使用Go语言来构建高性能、可靠且易于维护的应用程序。这表明Go语言在现代应用开发中发挥了重要作用,特别是在分布式系统、云计算和高性能应用领域。

(三)Java、C++、C 程序员学习编写 Go 时的常见误区

当熟悉 Java、C++、C 等编程语言的程序员开始涉足 Go 语言编程时,往往会陷入一些误区,这是因为 Go 语言在某些特性上与这些传统语言存在显著差异。以下是一些较为常见的误区:

1. 过度依赖传统并发模型

传统编程语言如 Java、C++、C 在处理并发问题时,通常采用线程和锁机制。然而在 Go 语言里,使用协程(goroutine)和通道(channel)才是更为高效的并发实现方式。新接触 Go 语言的程序员可能会延续以往的习惯,继续使用传统并发模型,而未能充分发挥 Go 语言轻量级协程和通道的优势,从而错失了 Go 语言在并发处理方面的独特效能。

2. 滥用指针

C 和 C++ 等语言极为重视指针的运用,但 Go 语言在设计之初就有意规避了过多的指针操作。刚学习 Go 语言的程序员可能会过度使用指针,导致代码的复杂度大幅增加。实际上,在 Go 语言中,除非确实需要对值进行修改,否则应尽量避免使用指针。

3. 忽视错误处理

Go 语言倡导显式地处理错误,而不是对其视而不见。这与部分其他语言的处理方式大相径庭,在那些语言中,错误常常被忽略或者简单地抛出。新学习 Go 语言的程序员可能会延续旧有习惯,忽视错误处理,从而使潜在问题难以被及时发现。

4. 滥用全局变量

在 C 和 C++ 等语言中,使用全局变量是较为常见的做法。但在 Go 语言里,全局变量的使用被视作不良编程习惯。Go 语言鼓励通过使用局部变量和传递参数的方式来实现数据传递,以此避免引入不必要的代码耦合和副作用。

5. 对切片和映射掌握不足

Go 语言中的切片和映射是功能强大的数据结构,但对于熟悉其他语言的程序员来说可能较为陌生。学会正确运用切片和映射至关重要,因为它们在 Go 语言的集合操作和数据处理中应用广泛。

6. 编码风格不符

每种编程语言都有其独特的编码风格和习惯用法。新学习 Go 语言的程序员可能会将其他语言的编码风格照搬到 Go 代码中,这会使代码的可读性和可理解性大打折扣。

为避免陷入这些误区,学习 Go 语言的程序员应当花费时间深入理解 Go 语言的核心概念,如并发模型、错误处理机制、数据结构等。同时,积极融入 Go 语言社区,仔细研读 Go 语言的官方文档和示例代码,以便更好地契合 Go 语言的设计理念和最佳实践。

二、环境准备(以Mac说明)

(一)环境设置

在macOS上设置Go语言开发环境非常简单,可以按照以下步骤进行操作:

  1. 使用Homebrew安装: 如果您使用Homebrew包管理器,这是最方便的方法。打开终端,并运行以下命令来安装Go语言:
   brew install go
  1. 手动安装: 如果想手动安装Go语言,可以按照以下步骤操作:

a. 访问官方网站 下载安装包`goX.X.X.darwin-amd64.pkg

b. 双击下载的安装包,按照指示运行安装程序。按照默认设置即可,安装路径通常是 /usr/local/go

  1. 设置环境变量: 一旦安装完成,需要将Go语言的二进制路径添加到自己的终端配置文件中的PATH环境变量中。这样就可以在终端中直接运行Go命令。

a. 打开终端,并使用文本编辑器(如nano、vim或任何您喜欢的编辑器)编辑终端配置文件。例如:

   nano ~/.bash_profile

b. 在文件中添加以下行(根据安装路径进行调整),然后保存并退出编辑器:

   export PATH=$PATH:/usr/local/go/bin

c. 使配置生效,可以运行以下命令或者重启终端:

   source ~/.bash_profile
  1. 验证安装: 打开终端,输入以下命令来验证Go是否已正确安装:
   go version

如果看到了Go的版本号,表示安装成功。

(二)IDE选择说明

我个人使用的GoLand,直接官网下载后,上网购买破解版即可,这里不在多说!

三、Go语言程序学习

创建自己的工程目录/Users/zyf/zyfcodes/go/go-learning,新建src目录。

(一)第一个Go语言编写

src目录下创建chapter1/hello目录,新建hello.go文件,编写代码如下:

package main

import (
	"fmt"
	"os"
)

/**
 * @author zhangyanfeng
 * @description 第一个godaima
 * @date 2023/8/20  23:45
 * @param
 * @return
 **/
func main() {
	if len(os.Args) > 1 {
		fmt.Println("Hello World", os.Args[1])
	}
}

这段代码是一个简单的Go语言程序,它接受命令行参数并打印出一条带参数的 “Hello World” 消息。下面是对代码的逐行分析:

  1. package main: 声明这个文件属于名为 “main” 的包,这是一个Go程序的入口包名。

  2. import ("fmt" "os"): 引入了两个标准库包,分别是 “fmt” 用于格式化输出,和 “os” 用于与操作系统交互。

  3. func main() { ... }: 这是程序的入口函数,它会在程序运行时首先被调用。

  4. if len(os.Args) > 1 { ... }: 这个条件语句检查命令行参数的数量是否大于1,也就是判断是否有参数传递给程序。 os.Args 是一个字符串切片,它包含了所有的命令行参数,第一个参数是程序的名称。

  5. fmt.Println("Hello World", os.Args[1]): 如果有参数传递给程序,就会执行这行代码。它使用 fmt.Println 函数打印一条消息,消息由字符串 “Hello World” 和 os.Args[1] 组成, os.Args[1] 表示传递给程序的第一个参数。

综上所述,这段代码涵盖了以下知识点:

  1. 包导入和使用标准库:通过 import 关键字导入 “fmt” 和 “os” 包,然后在代码中使用这些包提供的函数和类型。

  2. 命令行参数获取:使用 os.Args 获取命令行参数。

  3. 条件语句:使用 if 条件语句来判断是否有命令行参数传递给程序。

  4. 字符串操作:使用字符串连接操作将 “Hello World” 与命令行参数拼接在一起。

  5. 格式化输出:使用 fmt.Println 函数将消息输出到标准输出。

注意:如果没有传递参数给程序,那么这段代码不会打印任何消息。如果传递了多个参数,代码只会使用第一个参数并忽略其他参数。

在该目录下执行“ go run hello.go ZYF”,运行结果为“Hello World ZYF”。

(二)基本程序结构编写学习

src目录下创建chapter2

1.变量

前提:chapter2目录下创建variables,学习总结如下:

  • 变量声明: 使用 var 关键字声明一个变量,例如: var x int
  • 类型推断: 可以使用 := 操作符进行变量声明和赋值,Go会根据右侧的值自动推断变量类型,例如: y := 5
  • 变量赋值: 使用赋值操作符 = 给变量赋值,例如: x = 10
  • 多变量声明: 可以同时声明多个变量,例如: var a, b, c int
  • 变量初始化: 变量可以在声明时进行初始化,例如: var name string = "John"
  • 零值: 未初始化的变量会被赋予零值,数字类型为0,布尔类型为 false,字符串类型为空字符串等。
  • 短变量声明: 在函数内部,可以使用短变量声明方式,例如: count := 10

新建fib_test.go,背景:简单实用斐波那契数列进行练习

package variables

import "testing"

func TestFibList(t *testing.T) {
	a := 1
	b := 1
	t.Log(a)
	for i := 0; i < 5; i++ {
		t.Log(" ", b)
		tmp := a
		a = b
		b = tmp + a
	}
}

func TestExchange(t *testing.T) {
	a := 1
	b := 2
	// tmp := a
	// a = b
	// b = tmp
	a, b = b, a
	t.Log(a, b)
}

下面逐个解释代码中涉及的知识点:

  1. package variables: 声明了一个名为 “variables” 的包,这是一个用于测试的包名。

  2. import "testing": 导入了Go语言的测试框架 “testing” 包,用于编写和运行测试函数。

  3. func TestFibList(t *testing.T) { ... }: 定义了一个测试函数 “TestFibList”,该函数用于测试斐波那契数列生成逻辑。这是一个测试函数的标准命名,以 “Test” 开头,接着是被测试的函数名。 在测试函数内部,声明了两个整数变量 ab,并将它们初始化为 1,这是斐波那契数列的前两个数。使用 t.Log(a) 打印变量 a 的值到测试日志中。使用循环来生成斐波那契数列的前 5 个数,每次迭代都会将 b 的值打印到测试日志,并更新 ab 的值以生成下一个数。

  4. func TestExchange(t *testing.T) { ... }: 定义了另一个测试函数 “TestExchange”,该函数用于测试变量交换的逻辑。 在测试函数内部,声明了两个整数变量 ab,并分别将它们初始化为 1 和 2。使用注释的方式展示了一种变量交换的写法(通过中间变量),但实际上被注释掉了。然后使用 a, b = b, a 这一行代码来实现 ab 的交换,这是Go语言中的一种特有的交换方式,不需要额外的中间变量。使用 t.Log(a, b) 打印交换后的变量值到测试日志中。

2.常量

前提:chapter2目录下创建constant,学习总结如下:

  • 常量声明: 使用 const 关键字声明一个常量,例如: const pi = 3.14159
  • 常量赋值: 常量的值在声明时必须被赋值,一旦赋值后不可修改。
  • 枚举常量: 可以使用一组常量来模拟枚举,例如:
  const (
      Monday = 1
      Tuesday = 2
      // ...
  )
  • 类型指定: 常量的类型也可以被指定,例如: const speed int = 300000
  • 常量表达式: 常量可使用表达式计算,例如: const secondsInHour = 60 * 60
  • 无类型常量: 常量可以是无类型的,根据上下文自动推断类型。例如, const x = 5 会被推断为整数类型。

新建constant_test.go,写代码如下:

package constant

import "testing"

const (
	Monday = 1 + iota
	Tuesday
	Wednesday
)

const (
	Readable = 1 << iota
	Writable
	Executable
)

func TestConstant1(t *testing.T) {
	t.Log(Monday, Tuesday)
}

func TestConstant2(t *testing.T) {
	a := 1 //0001
	t.Log(a&Readable == Readable, a&Writable == Writable, a&Executable == Executable)
}

下面逐个解释代码中涉及的知识点:

  1. package constant: 声明了一个名为 “constant” 的包,这是一个用于测试的包名。

  2. import "testing": 导入了Go语言的测试框架 “testing” 包,用于编写和运行测试函数。

  3. const (...): 定义了两个常量块。 第一个常量块中,使用了 iota 常量生成器来定义了一系列从 1 开始递增的常量。在这个例子中, Monday 被赋值为 1, Tuesday 被赋值为 2, Wednesday 被赋值为 3。 iota 在常量块中每次被使用时会递增一次,因此后续的常量会依次递增;第二个常量块中,使用了 iota 来定义了一系列按位左移的常量。在这个例子中, Readable 被赋值为 1, Writable 被赋值为 2(二进制中的 10), Executable 被赋值为 4(二进制中的 100)。位运算中,左移操作可以将二进制数向左移动指定的位数。

  4. func TestConstant1(t *testing.T) { ... }: 定义了一个测试函数 “TestConstant1”,用于测试第一个常量块中定义的常量。 使用 t.Log(Monday, Tuesday) 打印常量 MondayTuesday 的值到测试日志中。

  5. func TestConstant2(t *testing.T) { ... }: 定义了另一个测试函数 “TestConstant2”,用于测试位运算和常量的使用。 在测试函数内部,声明了一个整数变量 a,并将其初始化为 1,即二进制中的 0001。使用位运算和按位与操作来检查变量 a 是否具有 ReadableWritableExecutable 属性。例如, a&Readable == Readable 表达式检查 a 的二进制表示是否含有 Readable 标志位。使用 t.Log() 打印三个表达式的结果到测试日志中。

3.数据类型

前提:chapter2目录下创建 type,学习总结如下:

主要数据类型详解

Go 语言拥有丰富多样的内置数据类型,这些数据类型能够精准地表示不同类型的值与数据。下面为您详细总结分析 Go 语言里的一些主要数据类型:

  • 整数类型(Integer Types):Go 语言提供了多种不同大小的整数类型。有带符号的整数类型,如 intint8int16int32int64;也有无符号整数类型,像 uintuint8uint16uint32uint64。整数类型的具体大小会因计算机架构(如 32 位或 64 位)的不同而有所差异。

  • 浮点数类型(Floating - Point Types):Go 语言提供了 float32float64 这两种浮点数类型,分别对应单精度和双精度浮点数,能满足不同精度要求的数值计算。

  • 复数类型(Complex Types):Go 语言提供了 complex64complex128 两种复数类型,它们分别由两个浮点数构成,用于处理复数相关的计算。

  • 布尔类型(Boolean Type):布尔类型主要用于表示真( true)和假( false)这两个值,在条件判断和逻辑运算中发挥着关键作用。

  • 字符串类型(String Type):字符串类型用于表示一系列字符。需要注意的是,字符串是不可变的,在定义字符串时,可以使用双引号 " 或反引号 `

  • 字符类型(Rune Type):字符类型 rune 专门用于表示 Unicode 字符,它实际上是 int32 的别名。通常使用单引号 ' 来表示字符,例如 'A'

  • 数组类型(Array Types):数组是由固定数量的同类型元素组成的集合。在声明数组时,必须明确指定元素的类型和数组的大小。

  • 切片类型(Slice Types):切片是对数组的一种灵活封装,它是长度可变的动态序列。切片本身并不存储元素,只是对底层数组部分元素的引用。

  • 映射类型(Map Types):映射是键值对的无序集合,主要用于数据的存储和检索。键和值可以是任意类型,但键必须是可比较的,以确保能正确查找对应的值。

  • 结构体类型(Struct Types):结构体是一种用户自定义的复合数据类型,它可以包含不同类型的字段,每个字段都有对应的名称和类型,便于组织和管理相关数据。

  • 接口类型(Interface Types):接口是一种抽象类型,它定义了一组方法。当某个类型实现了接口所规定的方法集合时,就意味着该类型实现了这个接口。

  • 函数类型(Function Types):函数类型描述了函数的签名,涵盖了参数和返回值的类型。在 Go 语言中,函数可以作为参数进行传递,也可以作为返回值返回,增加了代码的灵活性。

  • 通道类型(Channel Types):通道是 Go 语言中用于协程之间通信和同步的重要机制。通道支持发送和接收操作,确保协程间能安全、高效地交换数据。

  • 指针类型(Pointer Types):指针类型用于表示变量的内存地址。通过指针,我们可以直接访问和修改对应变量的值,在某些场景下能提高程序的性能和效率。

Go 语言的数据类型具有清晰明确的语法和语义,并且支持丰富的内置功能。合理地选择和运用不同的数据类型,能够有效提升程序的运行效率和代码的可读性。

具体代码展开分析
package main

import "fmt"

type Person struct {
	FirstName string
	LastName  string
	Age       int
}

type Shape interface {
	Area() float64
}

type Circle struct {
	Radius float64
}

func (c Circle) Area() float64 {
	return 3.14 * c.Radius * c.Radius
}

func add(a, b int) int {
	return a + b
}

func subtract(a, b int) int {
	return a - b
}

type Operation func(int, int) int

func main() {
	fmt.Println("整数类型(Integer Types)")
	var x int = 10
	var y int64 = 100

	fmt.Println(x)
	fmt.Println(y)

	fmt.Println("浮点数类型(Floating-Point Types)")
	var a float32 = 3.14
	var b float64 = 3.14159265359

	fmt.Println(a)
	fmt.Println(b)

	fmt.Println("布尔类型(Boolean Type)")
	var isTrue bool = true
	var isFalse bool = false

	fmt.Println(isTrue)
	fmt.Println(isFalse)

	fmt.Println("字符串类型(String Type)")
	str1 := "Hello, "
	str2 := "Go!"

	concatenated := str1 + str2
	fmt.Println(concatenated)

	fmt.Println("切片类型(Slice Types)")
	numbers := []int{1, 2, 3, 4, 5}
	fmt.Println(numbers)

	// 修改切片元素
	numbers[0] = 10
	fmt.Println(numbers)

	// 切片操作
	subSlice := numbers[1:4]
	fmt.Println(subSlice)

	fmt.Println("映射类型(Map Types)")
	ages := map[string]int{
		"Alice": 25,
		"Bob":   30,
		"Eve":   28,
	}

	fmt.Println(ages)
	fmt.Println("Alice's age:", ages["Alice"])

	// 添加新的键值对
	ages["Charlie"] = 22
	fmt.Println(ages)

	fmt.Println("结构体类型(Struct Types)")
	person := Person{
		FirstName: "John",
		LastName:  "Doe",
		Age:       30,
	}

	fmt.Println(person)
	fmt.Println("Name:", person.FirstName, person.LastName)

	fmt.Println("接口类型(Interface Types)")
	var shape Shape
	circle := Circle{Radius: 5}

	shape = circle
	fmt.Println("Circle Area:", shape.Area())

	fmt.Println("函数类型(Function Types)")
	var op Operation
	op = add
	result := op(10, 5)
	fmt.Println("Addition:", result)

	op = subtract
	result = op(10, 5)
	fmt.Println("Subtraction:", result)

	fmt.Println("通道类型(Channel Types)")
	messages := make(chan string)

	go func() {
		messages <- "Hello, Go!"
	}()

	msg := <-messages
	fmt.Println(msg)

	fmt.Println("指针类型(Pointer Types)")
	x = 10
	var ptr *int
	ptr = &x

	fmt.Println("Value of x:", x)
	fmt.Println("Value stored in pointer:", *ptr)

	*ptr = 20
	fmt.Println("Updated value of x:", x)
}

下面逐个解释代码中涉及的知识点:

  1. type Person struct { ... }: 定义了一个结构体类型 Person,表示一个人的信息,包括 FirstNameLastNameAge 字段。

  2. type Shape interface { ... }: 定义了一个接口类型 Shape,该接口要求实现一个方法 Area() 返回一个 float64 类型。

  3. type Circle struct { ... }: 定义了一个结构体类型 Circle,表示一个圆的半径。 func (c Circle) Area() float64 { ... }:为 Circle 类型实现了 Shape 接口的 Area() 方法,用于计算圆的面积。

  4. func add(a, b int) int { ... }: 定义了一个函数 add,用于执行整数相加操作。

  5. func subtract(a, b int) int { ... }: 定义了一个函数 subtract,用于执行整数相减操作。

  6. type Operation func(int, int) int: 定义了一个函数类型 Operation,它接受两个整数参数并返回一个整数结果。

  7. main() { ... }: 程序的入口函数。

  • 定义了多种不同类型的变量,包括整数、浮点数、布尔、字符串、切片、映射、结构体、接口、函数、通道和指针类型。
  • 演示了不同类型变量的初始化、赋值、访问以及基本操作。
  • 使用切片操作提取部分切片。
  • 演示了映射的使用,包括添加新的键值对和访问键值对。
  • 演示了结构体的定义和初始化,并访问结构体字段。
  • 展示了接口的使用,将 Circle 类型赋值给 Shape 类型变量,并调用接口方法。
  • 演示了函数类型的定义和使用,将不同函数赋值给 Operation 类型变量,并进行调用。
  • 使用通道来实现并发通信,通过匿名函数在 goroutine 中发送和接收消息。
  • 演示了指针的使用,包括创建指针变量、通过指针修改变量的值等操作。
Go 语言类型转换详解

Go 语言支持类型转换,但在进行转换时,需留意相关规则与限制。类型转换可将一个数据类型的值转变为另一个数据类型,以满足不同场景的使用需求。以下是关于 Go 语言类型转换的重要信息:

1. 基本类型间的转换

基本数据类型之间能够进行转换,但要特别关注类型的兼容性以及可能出现的数据丢失问题。例如,将 int 类型转换为 float64 类型是安全的,因为不会损失数据;然而,将 float64 类型转换为 int 类型时,小数部分会被直接截断,从而造成数据丢失。

2. 显式类型转换

在 Go 语言里,借助强制类型转换来明确指定将一个值转换为另一种类型。其语法格式为 destinationType(expression)。例如,float64(10) 就是将整数 10 显式转换为 float64 类型。

3. 非兼容类型的转换

对于不兼容的类型,编译器不会自动执行转换操作。比如,无法直接将 string 类型转换为 int 类型,若要实现此类转换,需借助特定的函数或方法来完成。

4. 类型别名的转换

当存在类型别名(Type Alias)时,进行转换时要注意别名的兼容性。在使用类型别名进行转换时,需确保转换符合类型的实际定义和要求。

以下是一些示例来展示类型转换:

package main

import "fmt"

func main() {
	// 显式类型转换
	var x int = 10
	var y float64 = float64(x)
	fmt.Println(y)

	// 类型别名的转换
	type Celsius float64
	type Fahrenheit float64
	c := Celsius(25)
	f := Fahrenheit(c*9/5 + 32)
	fmt.Println(f)
}

4.运算符

前提:chapter2目录下创建 operator,学习总结如下:

其实这部分和其他语言都差不多,个人觉得没啥可复习巩固的。Go语言支持多种运算符,用于执行各种算术、逻辑和比较操作。

常规运算符

以下是一些常见的运算符及其在Go中的使用方式和知识点:

算术运算符(Arithmetic Operators):

  • +:加法
  • -:减法
  • *:乘法
  • /:除法
  • %:取模(取余数)

赋值运算符(Assignment Operators):

  • =:赋值
  • +=:加法赋值
  • -=:减法赋值
  • *=:乘法赋值
  • /=:除法赋值
  • %=:取模赋值

逻辑运算符(Logical Operators):

  • &&:逻辑与(AND)
  • ||:逻辑或(OR)
  • !:逻辑非(NOT)

比较运算符(Comparison Operators):

  • ==:等于
  • !=:不等于
  • <:小于
  • >:大于
  • <=:小于等于
  • >=:大于等于

位运算符(Bitwise Operators):

  • &:按位与(AND)
  • |:按位或(OR)
  • ^:按位异或(XOR)
  • <<:左移
  • >>:右移

其他运算符:

  • &:取地址运算符
  • *:指针运算符
  • ++:自增运算符
  • --:自减运算符

在使用运算符时,需要考虑以下几点:

  • 运算符的操作数必须与运算符的预期类型匹配。
  • 某些运算符具有更高的优先级,需要使用括号来明确优先级。
  • 运算符的操作数可以是变量、常量、表达式等。

新建operator_test.go,以下是一些示例来展示运算符的使用:

package operator

import (
	"fmt"
	"testing"
)

const (
	Readable = 1 << iota
	Writable
	Executable
)

func TestOperatorBasic(t *testing.T) {
	// 算术运算符
	a := 10
	b := 5
	fmt.Println("Sum:", a+b)
	fmt.Println("Difference:", a-b)
	fmt.Println("Product:", a*b)
	fmt.Println("Quotient:", a/b)
	fmt.Println("Remainder:", a%b)

	// 逻辑运算符
	x := true
	y := false
	fmt.Println("AND:", x && y)
	fmt.Println("OR:", x || y)
	fmt.Println("NOT:", !x)

	// 比较运算符
	fmt.Println("Equal:", a == b)
	fmt.Println("Not Equal:", a != b)
	fmt.Println("Greater Than:", a > b)
	fmt.Println("Less Than:", a < b)
	fmt.Println("Greater Than or Equal:", a >= b)
	fmt.Println("Less Than or Equal:", a <= b)
}

func TestCompareArray(t *testing.T) {
	a := [...]int{1, 2, 3, 4}
	b := [...]int{1, 3, 2, 4}
	//	c := [...]int{1, 2, 3, 4, 5}
	d := [...]int{1, 2, 3, 4}
	t.Log(a == b)
	//t.Log(a == c)
	t.Log(a == d)
}

func TestBitClear(t *testing.T) {
	a := 7 //0111
	a = a &^ Readable
	a = a &^ Executable
	t.Log(a&Readable == Readable, a&Writable == Writable, a&Executable == Executable)
}

下面逐个解释代码中涉及的知识点:

  1. const (...): 定义了三个常量 ReadableWritableExecutable,使用位移操作生成不同的值。

  2. func TestOperatorBasic(t *testing.T) { ... }: 定义了一个测试函数 “TestOperatorBasic”,用于测试基本运算符的使用。 算术运算符:展示了加法、减法、乘法、除法和取余运算;逻辑运算符:展示了逻辑与、逻辑或和逻辑非运算;比较运算符:展示了等于、不等于、大于、小于、大于等于和小于等于运算。

  3. func TestCompareArray(t *testing.T) { ... }: 定义了一个测试函数 “TestCompareArray”,用于测试数组的比较。 声明了两个整数数组 ab,以及另一个数组 d,其中数组 a 和数组 d 的内容相同;使用比较运算符 == 检查数组 ab 是否相等,以及数组 ad 是否相等。

  4. func TestBitClear(t *testing.T) { ... }: 定义了一个测试函数 “TestBitClear”,用于测试位清除操作。 声明一个整数变量 a,并将其初始化为 7,即二进制表示 0111; 使用位清除操作 &^a 中的 ReadableExecutable 位清除;使用按位与运算 & 检查 a 是否具有 ReadableWritableExecutable 属性。

按位清除运算符 &^

在Go语言中, &^ 是按位清除运算符(Bit Clear Operator)。它用于将某些位置上的位清零,即将指定位置上的位设置为0。 &^ 运算符在处理二进制位操作时非常有用。

&^ 运算符执行以下操作:

  1. 对于每个位,如果右侧操作数的对应位为 0,则结果位与左侧操作数相同。
  2. 对于每个位,如果右侧操作数的对应位为 1,则结果位被强制设置为 0。

这意味着, &^ 运算符用于“清除”左侧操作数的特定位,使其与右侧操作数的相应位不受影响。写个代码验证下:

func TestOther(t *testing.T) {
	var a uint8 = 0b11001100 // 二进制表示,十进制为 204
	var b uint8 = 0b00110011 // 二进制表示,十进制为 51

	result := a &^ b

	fmt.Printf("a: %08b\n", a)               // 输出:11001100
	fmt.Printf("b: %08b\n", b)               // 输出:00110011
	fmt.Printf("Result: %08b\n", result)     // 输出:11000000
	fmt.Println("Result (Decimal):", result) // 输出:192
}

5.条件语句(Conditional Statements)

前提:chapter2目录下创建 condition,学习总结如下:

if 语句

if 语句用于基于条件来决定是否执行某段代码。它的基本语法如下:

if condition {
    // 代码块
} else if anotherCondition {
    // 代码块
} else {
    // 代码块
}

switch 语句

switch 语句用于基于表达式的不同值执行不同的代码分支。 与其他语言不同,Go的 switch 可以自动匹配第一个满足条件的分支,而无需使用 break 语句。 它的语法如下:

switch expression {
case value1:
    // 代码块
case value2:
    // 代码块
default:
    // 代码块
}

创建condition_test.go进行验证分析, 具体代码如下:

package condition

import (
	"fmt"
	"testing"
)

func TestConditionIf(t *testing.T) {
	age := 18

	if age < 18 {
		fmt.Println("You are a minor.")
	} else if age >= 18 && age < 60 {
		fmt.Println("You are an adult.")
	} else {
		fmt.Println("You are a senior citizen.")
	}
}

func TestConditionSwitch(t *testing.T) {
	dayOfWeek := 3

	switch dayOfWeek {
	case 1:
		fmt.Println("Monday")
	case 2:
		fmt.Println("Tuesday")
	case 3:
		fmt.Println("Wednesday")
	case 4:
		fmt.Println("Thursday")
	case 5:
		fmt.Println("Friday")
	default:
		fmt.Println("Weekend")
	}
}

func TestSwitchMultiCase(t *testing.T) {
	for i := 0; i < 5; i++ {
		switch i {
		case 0, 2:
			t.Logf("%d is Even", i)
		case 1, 3:
			t.Logf("%d is Odd", i)
		default:
			t.Logf("%d is not 0-3", i)
		}
	}
}

func TestSwitchCaseCondition(t *testing.T) {
	for i := 0; i < 5; i++ {
		switch {
		case i%2 == 0:
			t.Logf("%d is Even", i)
		case i%2 == 1:
			t.Logf("%d is Odd", i)
		default:
			t.Logf("%d is unknow", i)
		}
	}
}

下面逐个解释每个测试函数的内容:

  1. func TestConditionIf(t *testing.T) { ... }:测试 if 语句的使用。 根据年龄的不同情况,通过 ifelse ifelse 分支判断是否为未成年人、成年人或老年人。

  2. func TestConditionSwitch(t *testing.T) { ... }:测试 switch 语句的使用。根据 dayOfWeek 的值,使用 switch 语句输出对应的星期几。

  3. func TestSwitchMultiCase(t *testing.T) { ... }:测试 switch 语句多个 case 值的情况。使用 switch 语句判断每个数字的奇偶性,并输出相应的信息。

  4. func TestSwitchCaseCondition(t *testing.T) { ... }:测试 switch 语句中的条件表达式。使用 switch 语句通过对数字取余判断数字的奇偶性,并输出相应的信息。

这些测试函数展示了Go语言中条件语句的不同用法,包括基于条件的分支判断和多个 case 值的处理,以及在 switch 语句中使用条件表达式的情况。

6.循环语句(Loop Statements)

前提:chapter2目录下创建 loop,学习总结如下:

for 循环

for 循环用于重复执行代码块,支持初始化语句、循环条件和循环后的语句。它的基本形式如下:

for initialization; condition; post {
    // 代码块
}

在初始化语句中,您可以初始化循环变量,然后在循环体中使用条件来控制循环,最后在 post 语句中执行递增或递减操作。

for 循环的简化形式

Go语言的 for 循环还可以简化成只有循环条件部分,类似于其他语言中的 while 循环:

for condition {
    // 代码块
}

range 循环

range 循环用于迭代数组、切片、映射、字符串等可迭代的数据结构。它返回每次迭代的索引和值。示例:

for index, value := range iterable {
    // 使用 index 和 value
}

创建loop_test.go进行验证分析, 具体代码如下:

package loop

import (
	"fmt"
	"testing"
)

func TestLoopFor(t *testing.T) {
	for i := 1; i <= 5; i++ {
		fmt.Println("Iteration:", i)
	}
}

func TestLoopForBasic(t *testing.T) {
	i := 1
	for i <= 5 {
		fmt.Println("Iteration:", i)
		i++
	}
}

func TestLoopForRange(t *testing.T) {
	numbers := []int{1, 2, 3, 4, 5}

	for index, value := range numbers {
		fmt.Printf("Index: %d, Value: %d\n", index, value)
	}
}

func TestLoopForUnLimit(t *testing.T) {
	i := 1
	for {
		fmt.Println("Iteration:", i)
		i++
		if i > 5 {
			break
		}
	}
}

下面逐个解释每个测试函数的内容:

  1. func TestLoopFor(t *testing.T) { ... }:测试基本的 for 循环。使用 for 循环,从 1 到 5 迭代输出循环迭代次数。

  2. func TestLoopForBasic(t *testing.T) { ... }:测试不带初始化语句的 for 循环。使用 for 循环,从 1 到 5 迭代输出循环迭代次数,但没有在循环头部声明初始化语句。

  3. func TestLoopForRange(t *testing.T) { ... }:测试使用 for range 迭代切片。定义一个整数切片 numbers,使用 for range 循环迭代切片中的每个元素,输出元素的索引和值。

  4. func TestLoopForUnLimit(t *testing.T) { ... }:测试无限循环及 break 语句。使用无限循环和 break 语句,在循环体内部判断是否终止循环,当 i 大于 5 时退出循环。

这些测试函数展示了Go语言中不同类型的 for 循环的用法,包括标准的计数循环、不带初始化语句的循环、遍历切片以及无限循环与循环终止条件。

7.跳转语句(Jump Statements)

前提:chapter2目录下创建 jump,学习总结如下:

Go语言也支持几种跳转语句,用于在循环和条件中控制流程:

  • break:跳出循环。
  • continue:跳过本次循环迭代,继续下一次迭代。
  • goto:在代码中直接跳转到指定标签处 (不推荐使用)

创建jump_test.go进行验证分析, 具体代码如下:

package jump

import (
	"fmt"
	"testing"
)

func TestJumpBreak(t *testing.T) {
	for i := 1; i <= 5; i++ {
		if i == 3 {
			break
		}
		fmt.Println("Iteration:", i)
	}
}

func TestJumpContinue(t *testing.T) {
	for i := 1; i <= 5; i++ {
		if i == 3 {
			continue
		}
		fmt.Println("Iteration:", i)
	}
}

func TestJumpGoto(t *testing.T) {
	i := 1

start:
	fmt.Println("Iteration:", i)
	i++
	if i <= 5 {
		goto start
	}
}

下面逐个解释每个测试函数的内容:

  1. func TestJumpBreak(t *testing.T) { ... }:测试 break 语句的使用。使用 for 循环迭代从 1 到 5,但当迭代变量 i 等于 3 时,使用 break 语句终止循环。

  2. func TestJumpContinue(t *testing.T) { ... }:测试 continue 语句的使用。使用 for 循环迭代从 1 到 5,但当迭代变量 i 等于 3 时,使用 continue 语句跳过该次迭代继续下一次迭代。

  3. func TestJumpGoto(t *testing.T) { ... }:测试 goto 语句的使用。使用 goto 语句实现了一个无限循环,即使用标签 startgoto start 在循环体内部跳转到循环的起始位置。循环的终止条件是当 i 大于 5 时。

这些测试函数展示了Go语言中的循环控制跳转语句,包括用于终止循环的 break、用于跳过当前迭代的 continue,以及用于无限循环的 goto 语句。

(三)常用集合和字符串

src目录下创建chapter3, 在Go语言中,集合是存储一组值的数据结构。常用的集合类型包括数组、切片、映射和通道。

1.数组

前提:chapter3目录下创建 array,学习总结如下:

Go语言中的数组是一种固定长度、同类型元素的集合。

数组的特点
  • 数组在声明时就需明确指定其长度,而且一旦创建,其长度便无法再进行更改,具有固定性。
  • 数组属于值类型。当把数组赋值给一个新变量,或者将其作为参数传递给函数时,系统会创建一个全新的数组副本,而非传递原数组的引用。
  • 数组在内存中是连续存放的,这一特性使得它支持随机访问,能够快速地通过索引定位并获取数组中的任意元素。
数组的声明和初始化
var arrayName [size]dataType
  • arrayName:数组的名称。
  • size:数组的长度,必须是一个常量表达式。
  • dataType:数组存储的元素类型。
数组的初始化方式
// 使用指定的值初始化数组
var arr = [5]int{1, 2, 3, 4, 5}

// 根据索引初始化数组
var arr [5]int
arr[0] = 10
arr[1] = 20

// 部分初始化
var arr = [5]int{1, 2}

// 自动推断数组长度
arr := [...]int{1, 2, 3, 4, 5}

数组的访问和遍历
// 访问单个元素
value := arr[index]

// 遍历数组
for index, value := range arr {
    fmt.Printf("Index: %d, Value: %d\n", index, value)
}

数组作为函数参数

数组在函数参数传递时会创建副本,因此对函数内的数组修改不会影响原始数组。如果需要在函数内修改原始数组,可以传递指向数组的指针。

func modifyArray(arr [5]int) {
    arr[0] = 100
}

func modifyArrayByPointer(arr *[5]int) {
    arr[0] = 100
}
多维数组

Go语言支持多维数组,例如二维数组和三维数组。多维数组的初始化和访问与一维数组类似,只需要指定多个索引。

var matrix [3][3]int = [3][3]int{
    {1, 2, 3},
    {4, 5, 6},
    {7, 8, 9},
}

数组在存储固定数量的同类型元素时非常有用,但由于其固定长度的限制,通常在实际开发中更常用的是切片,它具有动态长度的特性。 切片可以根据需要进行增加、删除和重新分配,更加灵活。

创建array_test.go进行验证分析, 具体代码如下:

package array

import "testing"

func TestArrayInit(t *testing.T) {
	var arr [3]int
	arr1 := [4]int{1, 2, 3, 4}
	arr3 := [...]int{1, 3, 4, 5}
	arr1[1] = 5
	t.Log(arr[1], arr[2])
	t.Log(arr1, arr3)
}

func TestArrayTravel(t *testing.T) {
	arr3 := [...]int{1, 3, 4, 5}
	for i := 0; i < len(arr3); i++ {
		t.Log(arr3[i])
	}
	for _, e := range arr3 {
		t.Log(e)
	}
}

func TestArraySection(t *testing.T) {
	arr3 := [...]int{1, 2, 3, 4, 5}
	arr3_sec := arr3[:]
	t.Log(arr3_sec)
}

下面逐个解释每个测试函数的内容:

  1. func TestArrayInit(t *testing.T) { ... }:测试数组的初始化。 使用不同的方式初始化数组 arrarr1arr3; 修改 arr1 的第二个元素为 5; 使用 t.Log() 输出不同数组的元素值和内容。
  2. func TestArrayTravel(t *testing.T) { ... }:测试数组的遍历。 使用 for 循环遍历数组 arr3,分别输出每个元素的值;使用 for range 循环遍历数组 arr3,同样输出每个元素的值。
  3. func TestArraySection(t *testing.T) { ... }:测试数组切片的使用。 创建一个数组切片 arr3_sec,基于整个数组 arr3; 使用 t.Log() 输出数组切片 arr3_sec 的内容。

2.切片

前提:chapter3目录下创建 slice,学习总结如下:

Go语言中的切片(Slice)是对数组的一层封装,提供了更灵活的动态长度序列。

切片的特点
  • 切片属于引用类型,它自身并不存储数据,而是对底层数组的部分元素进行引用。这种引用机制使得切片能够灵活地操作数组中的数据,而无需复制大量的数据内容。
  • 切片的长度具有动态性,能够根据实际需求进行扩容或缩减。在程序运行过程中,当数据量发生变化时,切片可以自适应地调整自身的长度,以满足不同的使用场景。
  • 切片支持索引操作,通过索引可以方便地访问切片中的元素。同时,还可以利用切片索引对切片进行切割操作,从而获取切片的子集,进一步提高了数据处理的灵活性。
切片的声明和初始化
var sliceName []elementType
切片的初始化方式
// 声明切片并初始化
var slice = []int{1, 2, 3, 4, 5}

// 使用 make 函数创建切片
var slice = make([]int, 5) // 创建长度为 5 的 int 类型切片

// 使用切片切割已有数组或切片
newSlice := oldSlice[startIndex:endIndex] // 包括 startIndex,但不包括 endIndex

切片的内置函数和操作
  • len(slice):返回切片的长度。
  • cap(slice):返回切片的容量,即底层数组的长度。
  • append(slice, element):将元素追加到切片末尾,并返回新的切片。
  • copy(destination, source):将源切片中的元素复制到目标切片。
切片的遍历
for index, value := range slice {
    // 使用 index 和 value
}

切片作为函数参数

切片作为参数传递给函数时,函数内部对切片的修改会影响到原始切片。

func modifySlice(s []int) {
    s[0] = 100
}

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    modifySlice(numbers)
    fmt.Println(numbers) // 输出:[100 2 3 4 5]
}

切片在Go语言中广泛用于处理动态数据集,例如集合、列表、队列等。它提供了方便的方法来管理元素,同时避免了固定数组的限制。在实际应用中,切片经常被用于存储和处理变长数据。

创建slice_test.go进行验证分析, 具体代码如下:

package slice

import (
	"fmt"
	"testing"
)

func TestSlice(t *testing.T) {
	// 声明和初始化切片
	numbers := []int{1, 2, 3, 4, 5}
	fmt.Println("Original Slice:", numbers)

	// 使用 make 函数创建切片
	slice := make([]int, 3)
	fmt.Println("Initial Make Slice:", slice)

	// 添加元素到切片
	slice = append(slice, 10)
	slice = append(slice, 20, 30)
	fmt.Println("After Append:", slice)

	// 复制切片
	copySlice := make([]int, len(slice))
	copy(copySlice, slice)
	fmt.Println("Copied Slice:", copySlice)

	// 切片切割
	subSlice := numbers[1:3]
	fmt.Println("Subslice:", subSlice)

	// 修改切片的值会影响底层数组和其他切片
	subSlice[0] = 100
	fmt.Println("Modified Subslice:", subSlice)
	fmt.Println("Original Slice:", numbers)
	fmt.Println("Copied Slice:", copySlice)

	// 遍历切片
	for index, value := range slice {
		fmt.Printf("Index: %d, Value: %d\n", index, value)
	}
}

下面逐个解释每个测试函数的内容:

func TestSlice(t *testing.T) { ... }:测试切片的基本操作。

  • 声明和初始化切片 numbers,输出初始切片内容。
  • 使用 make 函数创建初始容量为 3 的切片 slice,输出初始切片内容。
  • 使用 append 函数向切片 slice 添加元素。
  • 使用 copy 函数复制切片 slice 到新的切片 copySlice
  • 使用切片 numbers 进行切片切割,创建子切片 subSlice
  • 修改 subSlice 的第一个元素为 100,输出修改后的切片和原始切片,以及复制的切片。
  • 使用 for range 循环遍历切片 slice,输出每个元素的索引和值。

这个测试函数展示了Go语言中切片的各种操作,包括切片的创建、添加元素、复制切片、切片切割、修改切片元素等。

3.Map

前提:chapter3目录下创建 map,学习总结如下:

Go语言中的映射(Map)是键值对的无序集合,也被称为关联数组或字典。

Map的特点
  • Map 是一种用于存储键值对集合的数据结构,其中每一个键都具有唯一性,确保了数据的精确存储与快速查找。
  • Map 中的键值对是无序排列的,这意味着无法保证键值对会按照特定顺序出现。在使用 Map 时,不能依赖键值对的顺序来进行操作。
  • Map 的键可以是任何可进行比较操作的类型,而值则可以是任意类型。这种灵活性使得 Map 能够适应各种不同的数据存储需求。
  • Map 属于引用类型,这意味着它可以被赋值给其他变量,也可以作为参数传递给函数。在进行赋值或传递时,传递的是 Map 的引用,而非其副本。
Map的声明和初始化
var mapName map[keyType]valueType
Map的初始化方式
// 声明和初始化映射
var ages = map[string]int{
    "Alice": 25,
    "Bob":   30,
    "Eve":   28,
}

// 使用 make 函数创建映射
var ages = make(map[string]int)

Map的操作
  • 添加键值对: ages["Charlie"] = 35
  • 删除键值对: delete(ages, "Eve")
  • 获取值: value := ages["Alice"]
Map的遍历
for key, value := range ages {
    fmt.Printf("Name: %s, Age: %d\n", key, value)
}

Map作为函数参数

Map作为参数传递给函数时,函数内部对Map的修改会影响到原始Map。

func modifyMap(m map[string]int) {
    m["Alice"] = 30
}

func main() {
    ages := map[string]int{
        "Alice": 25,
        "Bob":   30,
    }
    modifyMap(ages)
    fmt.Println(ages) // 输出:map[Alice:30 Bob:30]
}

Map 在Go语言中用于存储和检索数据,是一种非常常用的数据结构。它在存储一组关联的键值对时非常有用,比如存储姓名与年龄的对应关系、单词与定义的对应关系等。在实际应用中,Map 是处理和存储键值数据的重要工具。

创建map_test.go进行验证分析, 具体代码如下:

package my_map

import (
	"fmt"
	"testing"
)

func TestBasic(t *testing.T) {
	// 声明和初始化映射
	ages := map[string]int{
		"Alice": 25,
		"Bob":   30,
		"Eve":   28,
	}
	fmt.Println("Original Map:", ages)

	// 添加新的键值对
	ages["Charlie"] = 35
	fmt.Println("After Adding:", ages)

	// 修改已有键的值
	ages["Bob"] = 31
	fmt.Println("After Modification:", ages)

	// 删除键值对
	delete(ages, "Eve")
	fmt.Println("After Deletion:", ages)

	// 获取值和检查键是否存在
	age, exists := ages["Alice"]
	if exists {
		fmt.Println("Alice's Age:", age)
	} else {
		fmt.Println("Alice not found")
	}

	// 遍历映射
	for name, age := range ages {
		fmt.Printf("Name: %s, Age: %d\n", name, age)
	}
}

type Student struct {
	Name  string
	Age   int
	Grade string
}

func TestComplex(t *testing.T) {
	// 声明和初始化映射,用于存储学生信息和成绩
	studentScores := make(map[string]int)
	studentInfo := make(map[string]Student)

	// 添加学生信息和成绩
	studentInfo["Alice"] = Student{Name: "Alice", Age: 18, Grade: "A"}
	studentScores["Alice"] = 95

	studentInfo["Bob"] = Student{Name: "Bob", Age: 19, Grade: "B"}
	studentScores["Bob"] = 85

	// 查找学生信息和成绩
	aliceInfo := studentInfo["Alice"]
	aliceScore := studentScores["Alice"]
	fmt.Printf("Name: %s, Age: %d, Grade: %s, Score: %d\n", aliceInfo.Name, aliceInfo.Age, aliceInfo.Grade, aliceScore)

	// 遍历学生信息和成绩
	for name, info := range studentInfo {
		score, exists := studentScores[name]
		if exists {
			fmt.Printf("Name: %s, Age: %d, Grade: %s, Score: %d\n", info.Name, info.Age, info.Grade, score)
		} else {
			fmt.Printf("No score available for %s\n", name)
		}
	}
}

下面逐个解释每个测试函数的内容:

  1. func TestBasic(t *testing.T) { ... }:测试映射的基本操作。 声明和初始化映射 ages,存储人名和年龄的键值对;输出初始映射内容;使用 ages["Charlie"] 添加新的键值对;使用 ages["Bob"] 修改已有键的值;使用 delete 函数删除键值对;使用 age, exists 来获取值并检查键是否存在;使用 for range 循环遍历映射,输出每个键值对的信息。

  2. type Student struct { ... }:定义了一个名为 Student 的结构体,用于存储学生信息。

  3. func TestComplex(t *testing.T) { ... }:测试包含复杂值的映射操作。 声明和初始化两个映射, studentScores 用于存储学生分数, studentInfo 用于存储学生信息;添加学生信息和分数到映射;使用 studentInfo["Alice"] 获取学生信息,使用 studentScores["Alice"] 获取学生分数;使用 for range 循环遍历映射,输出每个学生的信息和分数。

这些测试函数展示了Go语言中 Map 的各种操作,包括创建、添加、修改、删除键值对,检查键是否存在,以及遍历映射的键值对。

4.实现Set

前提:chapter3目录下创建 set,学习总结如下:

在Go语言中,虽然标准库没有提供内置的Set类型,但你可以使用多种方式来实现Set的功能。以下是几种常见的实现Set的方式介绍:

使用切片

创建set_slice_test.go练习

使用切片来存储元素,通过遍历切片来检查元素是否存在。 这是一个简单的实现方式,适用于小型的集合。

package set

import (
	"fmt"
	"testing"
)

type IntSet struct {
	elements []int
}

func (s *IntSet) Add(element int) {
	if !s.Contains(element) {
		s.elements = append(s.elements, element)
	}
}

func (s *IntSet) Contains(element int) bool {
	for _, e := range s.elements {
		if e == element {
			return true
		}
	}
	return false
}

func TestSet(t *testing.T) {
	set := IntSet{}
	set.Add(1)
	set.Add(2)
	set.Add(3)
	set.Add(2) // Adding duplicate, should be ignored

	fmt.Println("Set:", set.elements) // Output: [1 2 3]
}

使用 Map

创建set_map_test.go练习

使用 Map 来存储元素,Map 的键代表集合的元素,值可以是任意类型。 这样的实现方式更快速,适用于大型的集合,因为 Map 的查找复杂度为 O(1)

package set

import (
	"fmt"
	"testing"
)

type Set map[int]bool

func (s Set) Add(element int) {
	s[element] = true
}

func (s Set) Contains(element int) bool {
	return s[element]
}

func TestSetMap(t *testing.T) {
	set := make(Set)
	set.Add(1)
	set.Add(2)
	set.Add(3)
	set.Add(2) // Adding duplicate, should be ignored

	fmt.Println("Set:", set) // Output: map[1:true 2:true 3:true]
}
使用第三方库

创建set_third_test.go练习

为了避免自行实现,你可以使用一些第三方库,例如 github.com/deckarep/golang-set,它提供了更丰富的Set功能。

添加个代理:go env -w GOPROXY=https://goproxy.io,direct

然后安装包:go get github.com/deckarep/golang-set

package set

import (
	"fmt"
	"github.com/deckarep/golang-set"
	"testing"
)

func TestSetThird(t *testing.T) {
	intSet := mapset.NewSet()
	intSet.Add(1)
	intSet.Add(2)
	intSet.Add(3)
	intSet.Add(2) // Adding duplicate, will be ignored

	fmt.Println("Set:", intSet) // Output: Set: Set{1, 2, 3}
}

以上是几种实现Set的方式,你可以根据需求和性能考虑选择适合的实现方式。第三方库可以提供更多功能和性能优化,适用于大规模的数据集合。

5.字符串

前提:chapter3目录下创建 string,学习总结如下:

字符串的声明与初始化

在Go语言中,字符串是由一系列字符组成的,可以使用双引号 " 或反引号 ``` 来声明和初始化字符串。

package main

import "fmt"

func main() {
    str1 := "Hello, World!"   // 使用双引号声明
    str2 := `Go Programming` // 使用反引号声明

    fmt.Println(str1) // Output: Hello, World!
    fmt.Println(str2) // Output: Go Programming
}

字符串的长度

使用内置函数 len() 可以获取字符串的长度,即字符串中字符的个数。

package main

import "fmt"

func main() {
    str := "Hello, 世界!"
    length := len(str)
    fmt.Println("String Length:", length) // Output: String Length: 9
}

字符串的索引与切片

字符串中的字符可以通过索引访问,索引从0开始。可以使用切片操作来获取字符串的子串。

package main

import "fmt"

func main() {
    str := "Hello, World!"

    // 获取第一个字符
    firstChar := str[0]
    fmt.Println("First Character:", string(firstChar)) // Output: First Character: H

    // 获取子串
    substring := str[7:12]
    fmt.Println("Substring:", substring) // Output: Substring: World
}

字符串拼接

使用 + 运算符可以将两个字符串连接成一个新的字符串。另外, strings.Join 函数用于将字符串切片连接成一个新的字符串,可以用来拼接多个字符串。

最后,使用字节缓冲可以在不产生多余字符串副本的情况下进行高效的字符串拼接。

package main

import (
    "fmt"
    "strings"
    "bytes"
)

func main() {
    str1 := "Hello, "
    str2 := "World!"

    result := str1 + str2
    fmt.Println("Concatenated String:", result) // Output: Concatenated String: Hello, World!

    strSlice := []string{"Hello", " ", "World!"}
    result := strings.Join(strSlice, "")
    fmt.Println(result) // Output: Hello World!

    var buffer bytes.Buffer

    buffer.WriteString(str1)
    buffer.WriteString(str2)

    result := buffer.String()
    fmt.Println(result) // Output: Hello, World!
}

多行字符串

使用反引号 ``` 来创建多行字符串。

package main

import "fmt"

func main() {
    multiLineStr := `
        This is a
        multi-line
        string.
    `
    fmt.Println(multiLineStr)
}

字符串迭代

使用 for range 循环迭代字符串的每个字符。

package main

import "fmt"

func main() {
    str := "Go语言"

    for _, char := range str {
        fmt.Printf("%c ", char) // Output: G o 语 言
    }
}

字符串和字节数组之间的转换

在Go语言中,字符串和字节数组之间可以进行相互转换。

package main

import "fmt"

func main() {
    str := "Hello"
    bytes := []byte(str) // 转换为字节数组
    strAgain := string(bytes) // 字节数组转换为字符串

    fmt.Println("Bytes:", bytes)       // Output: Bytes: [72 101 108 108 111]
    fmt.Println("String Again:", strAgain) // Output: String Again: Hello
}

字符串比较

字符串的比较可以使用 ==!= 运算符。当然还有其他函数类型的直接应用的: strings.Compare 函数用于比较两个字符串,并根据比较结果返回一个整数。

也可以使用自定义的比较函数来比较字符串,根据自己的需求定义比较逻辑。

package main

import (
    "fmt"
    "strings"
)

func customCompare(str1, str2 string) bool {
    // 自定义比较逻辑
    return str1 == str2
}

func main() {
    str1 := "Hello"
    str2 := "World"

    if str1 == str2 {
        fmt.Println("Strings are equal")
    } else {
        fmt.Println("Strings are not equal") // Output: Strings are not equal
    }

    result := strings.Compare(str1, str2)
    if result == 0 {
        fmt.Println("Strings are equal")
    } else if result < 0 {
        fmt.Println("str1 is less than str2")
    } else {
        fmt.Println("str1 is greater than str2") // Output: str1 is less than str2
    }

    if customCompare(str1, str2) {
        fmt.Println("Strings are equal")
    } else {
        fmt.Println("Strings are not equal") // Output: Strings are not equal
    }
}

这些基本概念和操作可以帮助你更好地理解和使用Go语言中的字符串。要注意字符串的不可变性,以及与其他数据类型的转换和比较。

创建string_test.go练习

package string

import (
	"strconv"
	"strings"
	"testing"
)

func TestString(t *testing.T) {
	var s string
	t.Log(s) //初始化为默认零值“”
	s = "hello"
	t.Log(len(s))
	//s[1] = '3' //string是不可变的byte slice
	//s = "\xE4\xB8\xA5" //可以存储任何二进制数据
	s = "\xE4\xBA\xBB\xFF"
	t.Log(s)
	t.Log(len(s))
	s = "中"
	t.Log(len(s)) //是byte数

	c := []rune(s)
	t.Log(len(c))
	//	t.Log("rune size:", unsafe.Sizeof(c[0]))
	t.Logf("中 unicode %x", c[0])
	t.Logf("中 UTF8 %x", s)
}

func TestStringToRune(t *testing.T) {
	s := "中华人民共和国"
	for _, c := range s {
		t.Logf("%[1]c %[1]x", c)
	}
}

func TestStringFn(t *testing.T) {
	s := "A,B,C"
	parts := strings.Split(s, ",")
	for _, part := range parts {
		t.Log(part)
	}
	t.Log(strings.Join(parts, "-"))
}

func TestConv(t *testing.T) {
	s := strconv.Itoa(10)
	t.Log("str" + s)
	if i, err := strconv.Atoi("10"); err == nil {
		t.Log(10 + i)
	}
}

下面逐个解释每个测试函数的内容:

  1. func TestString(t *testing.T) { ... }:测试字符串的基本操作。 声明一个字符串变量 s,输出其默认零值;将字符串赋值为 “hello”,输出字符串长度;尝试修改字符串的某个字符,但会报错,因为字符串是不可变;使用字符串存储二进制数据和 Unicode 编码;使用字符串存储一个中文字符,并输出其长度;将字符串转换为 rune 类型切片,输出切片长度和中文字符的 Unicode 和 UTF-8 编码。
  2. func TestStringToRune(t *testing.T) { ... }:测试字符串到 rune 的转换。 声明一个包含中文字符的字符串 s,通过 range 遍历将字符串转换为 rune 类型并输出。
  3. func TestStringFn(t *testing.T) { ... }:测试字符串相关的函数。 声明一个包含逗号分隔的字符串 s,使用 strings.Split 函数拆分字符串并输出每个部分。使用 strings.Join 函数将拆分的部分合并为一个新的字符串,并输出。
  4. func TestConv(t *testing.T) { ... }:测试字符串与其他类型的转换。 使用 strconv.Itoa 将整数转换为字符串;拼接字符串和整数,并输出结果;使用 strconv.Atoi 将字符串转换为整数,并进行加法运算,处理错误情况。

这些测试函数展示了Go语言中字符串的各种操作,包括字符串长度、UTF-8 编码、 rune 类型转换、字符串拆分和合并,以及字符串与其他类型的转换。

(四)函数

src目录下创建chapter4,在Go语言中,函数是一种用于执行特定任务的代码块,可以被多次调用。

1.函数的声明

在Go中,函数的声明由关键字 func 开始,后面跟着函数名、参数列表、返回值和函数体。

func functionName(parameters) returnType {
    // 函数体
    // 可以包含多个语句
    return returnValue
}

2.函数参数

函数可以有零个或多个参数,参数由参数名和参数类型组成。参数之间使用逗号分隔。

func greet(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

3.多返回值

Go语言的函数可以返回多个值。返回值用括号括起来,逗号分隔。

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

4.命名返回值

函数可以声明命名的返回值,在函数体内可以直接使用这些名称进行赋值,最后不需要显式使用 return 关键字。

func divide(a, b float64) (result float64, err error) {
    if b == 0 {
        err = errors.New("division by zero")
        return
    }
    result = a / b
    return
}

5.可变数量的参数

Go语言支持使用 ... 语法来表示可变数量的参数。这些参数在函数体内作为切片使用。

func sum(numbers ...int) int {
    total := 0
    for _, num := range numbers {
        total += num
    }
    return total
}

6.函数作为参数

在Go语言中,函数可以作为参数传递给其他函数。

func applyFunction(fn func(int, int) int, a, b int) int {
    return fn(a, b)
}

func add(a, b int) int {
    return a + b
}

func main() {
    result := applyFunction(add, 3, 4)
    fmt.Println(result) // Output: 7
}

7.匿名函数和闭包

Go语言支持匿名函数,也称为闭包。这些函数可以在其他函数内部定义,并访问外部函数的变量。

func main() {
    x := 5
    fn := func() {
        fmt.Println(x) // 闭包访问外部变量
    }
    fn() // Output: 5
}

8.defer语句

defer 语句用于延迟执行函数,通常用于在函数返回前执行一些清理操作。

func main() {
    defer fmt.Println("World")
    fmt.Println("Hello")
}

以上是一些关于Go语言函数的基本知识点。函数在Go中扮演着非常重要的角色,用于组织代码、实现功能模块化和提高代码的可维护性。

验证一:基本使用用例验证

在chapter4下新建basic,在创建func_basic_test.go练习

package basic

import (
	"errors"
	"fmt"
	"testing"
)

// 普通函数
func greet(name string) {
	fmt.Printf("Hello, %s!\n", name)
}

// 多返回值函数
func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

// 命名返回值函数
func divideNamed(a, b int) (result int, err error) {
	if b == 0 {
		err = errors.New("division by zero")
		return
	}
	result = a / b
	return
}

// 可变数量的参数函数
func sum(numbers ...int) int {
	total := 0
	for _, num := range numbers {
		total += num
	}
	return total
}

// 函数作为参数
func applyFunction(fn func(int, int) int, a, b int) int {
	return fn(a, b)
}

// 匿名函数和闭包
func closureExample() {
	x := 5
	fn := func() {
		fmt.Println(x)
	}
	fn() // Output: 5
}

// defer语句
func deferExample() {
	defer fmt.Println("World")
	fmt.Println("Hello") // Output: Hello World
}

func TestBasic(t *testing.T) {
	greet("Alice") // Output: Hello, Alice!

	q, err := divide(10, 2)
	if err != nil {
		fmt.Println("Error:", err)
	} else {
		fmt.Println("Quotient:", q) // Output: Quotient: 5
	}

	qNamed, errNamed := divideNamed(10, 0)
	if errNamed != nil {
		fmt.Println("Error:", errNamed) // Output: Error: division by zero
	} else {
		fmt.Println("Quotient:", qNamed)
	}

	total := sum(1, 2, 3, 4, 5)
	fmt.Println("Sum:", total) // Output: Sum: 15

	addResult := applyFunction(func(a, b int) int {
		return a + b
	}, 3, 4)
	fmt.Println("Addition:", addResult) // Output: Addition: 7

	closureExample()

	deferExample()
}

验证二:业务小举例

在chapter4下新建biz,在创建func_biz_test.go练习,假设你正在开发一个简单的订单处理系统,需要计算订单中商品的总价和应用折扣。你可以使用函数来处理这些业务逻辑。以下是一个简单的示例:

package biz

import (
	"fmt"
	"testing"
)

type Product struct {
	Name  string
	Price float64
}

func calculateTotal(products []Product) float64 {
	total := 0.0
	for _, p := range products {
		total += p.Price
	}
	return total
}

func applyDiscount(amount, discount float64) float64 {
	return amount * (1 - discount)
}

func TestBiz(t *testing.T) {
	products := []Product{
		{Name: "Product A", Price: 10.0},
		{Name: "Product B", Price: 20.0},
		{Name: "Product C", Price: 30.0},
	}

	total := calculateTotal(products)
	fmt.Printf("Total before discount: $%.2f\n", total)

	discountedTotal := applyDiscount(total, 0.1)
	fmt.Printf("Total after 10%% discount: $%.2f\n", discountedTotal)
}

(五)面向对象编程

src目录下创建chapter5,Go语言支持面向对象编程(Object-Oriented Programming,OOP),尽管与一些传统的面向对象编程语言(如Java和C++)相比,Go的实现方式可能略有不同。在Go语言中,没有类的概念,但可以通过结构体和方法来实现面向对象的特性。

1.结构体定义

在Go语言中,结构体是一种自定义的数据类型,用于组合不同类型的字段(成员变量)以创建一个新的数据类型。创建struct目录,编写struct_test.go,以下是结构体的定义、使用和验证示例:

package _struct

import (
	"fmt"
	"testing"
)

// 定义一个结构体
type Person struct {
	FirstName string
	LastName  string
	Age       int
}

func TestStruct(t *testing.T) {
	// 创建结构体实例并初始化字段
	person1 := Person{
		FirstName: "Alice",
		LastName:  "Smith",
		Age:       25,
	}

	// 访问结构体字段
	fmt.Println("First Name:", person1.FirstName) // Output: First Name: Alice
	fmt.Println("Last Name:", person1.LastName)   // Output: Last Name: Smith
	fmt.Println("Age:", person1.Age)              // Output: Age: 25

	// 修改结构体字段的值
	person1.Age = 26
	fmt.Println("Updated Age:", person1.Age) // Output: Updated Age: 26
}

结构体的定义可以包含多个字段,每个字段可以是不同的数据类型。你还可以在结构体中嵌套其他结构体,形成更复杂的数据结构。编写struct_cmpx_test.go示例:

package _struct

import (
	"fmt"
	"testing"
)

type Address struct {
	Street  string
	City    string
	ZipCode string
}

type PersonNew struct {
	FirstName string
	LastName  string
	Age       int
	Address   Address
}

func TestCmpxStruct(t *testing.T) {
	person2 := PersonNew{
		FirstName: "Bob",
		LastName:  "Johnson",
		Age:       30,
		Address: Address{
			Street:  "123 Main St",
			City:    "Cityville",
			ZipCode: "12345",
		},
	}

	fmt.Println("Full Name:", person2.FirstName, person2.LastName)
	fmt.Println("Address:", person2.Address.Street, person2.Address.City, person2.Address.ZipCode)
}

2. 实例创建及初始化

在 Go 语言里,结构体实例的创建与初始化方式丰富多样。首先,创建一个名为 creatinit 的目录,后续的代码文件 creatinit_test.go 中将展示几种常见的实例创建和初始化方法。

  • 字面量初始化:借助花括号 {},能够直接对结构体实例的各个字段进行初始化操作。
  • 部分字段初始化:若仅需对结构体的部分字段进行初始化,其余字段可直接省略,系统会自动处理。
  • 按字段名初始化:依据字段名来指定相应字段的值,这种方式无需遵循结构体字段的原始顺序,提升了初始化的灵活性。
  • 默认值初始化:结构体的字段会按照其所属类型的默认值完成初始化,为开发者提供了便捷的初始化选择。
  • 使用 new 函数:通过调用 new 函数,可以创建一个指向结构体的指针,并将该指针返回,方便后续对结构体进行操作。
  • 按字段顺序初始化:在初始化时可以选择省略字段名,但此时必须严格按照结构体字段的定义顺序进行赋值,以确保初始化的准确性。
package creatinit

import (
	"fmt"
	"testing"
)

type Person struct {
	FirstName string
	LastName  string
	Age       int
}

/**
 * @author zhangyanfeng
 * @description 字面量初始化
 * @date 2023/8/26  15:09
 **/
func TestCreateObj1(t *testing.T) {
	person1 := Person{
		FirstName: "Alice",
		LastName:  "Smith",
		Age:       25,
	}
	fmt.Println(person1.FirstName, person1.LastName, person1.Age) // Output: Alice Smith 25
}

/**
 * @author zhangyanfeng
 * @description 部分字段初始化
 * @date 2023/8/26  15:10
 **/
func TestCreateObj2(t *testing.T) {
	person2 := Person{
		FirstName: "Bob",
		Age:       30,
	}
	fmt.Println(person2.FirstName, person2.LastName, person2.Age) // Output: Bob  30
}

/**
 * @author zhangyanfeng
 * @description 使用字段名初始化
 * @date 2023/8/26  15:12
 **/
func TestCreateObj3(t *testing.T) {
	person3 := Person{
		LastName:  "Johnson",
		FirstName: "Chris",
		Age:       28,
	}
	fmt.Println(person3.FirstName, person3.LastName, person3.Age) // Output: Chris Johnson 28
}

/**
 * @author zhangyanfeng
 * @description 默认值初始化
 * @date 2023/8/26  15:13
 **/
func TestCreateObj4(t *testing.T) {
	var person4 Person
	fmt.Println(person4.FirstName, person4.LastName, person4.Age) // Output:   0
}

/**
 * @author zhangyanfeng
 * @description 使用 new 函数
 * @date 2023/8/26  15:14
 **/
func TestCreateObj5(t *testing.T) {
	person5 := new(Person)
	person5.FirstName = "David"
	person5.Age = 22
	fmt.Println(person5.FirstName, person5.LastName, person5.Age) // Output: David  22
}

/**
 * @author zhangyanfeng
 * @description 字段顺序初始化
 * @date 2023/8/26  15:24
 **/
func TestCreateObj6(t *testing.T) {
	// 使用字段顺序初始化
	person := Person{"Alice", "Smith", 25}
	fmt.Println(person.FirstName, person.LastName, person.Age) // Output: Alice Smith 25
}

3.行为(方法)定义

在Go语言中,方法是与特定类型相关联的函数,它可以在这个类型的实例上调用。方法使得类型的操作能够与该类型的定义放在一起,提高了代码的可读性和可维护性。

创建method目录进行代码练习,以下是关于Go语言方法的定义、使用和分析:

方法的定义

在Go语言中,方法是通过为函数添加接收者(receiver)来定义的。接收者是一个普通的参数,但它在方法名前放置,用于指定该方法与哪种类型相关联。创建method_define_test.go

package method

import (
	"fmt"
	"testing"
)

type Circle struct {
	Radius float64
}

// 定义 Circle 类型的方法
func (c Circle) Area() float64 {
	return 3.14159 * c.Radius * c.Radius
}

func TestMethodDef(t *testing.T) {
	c := Circle{Radius: 5}
	area := c.Area()
	fmt.Printf("Circle area: %.2f\n", area) // Output: Circle area: 78.54
}

在上述示例中,我们定义了一个 Circle 结构体,然后为其定义了一个名为 Area 的方法。这个方法可以通过 c.Area() 的方式调用,其中 c 是一个 Circle 类型的实例。

方法的调用

方法调用的语法为 实例.方法名(),即通过实例来调用方法。创建method_rpc_test.go

package method

import (
	"fmt"
	"testing"
)

type Rectangle struct {
	Width  float64
	Height float64
}

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

func TestMethonRpc(t *testing.T) {
	rect := Rectangle{Width: 3, Height: 4}
	area := rect.Area()
	fmt.Printf("Rectangle area: %.2f\n", area) // Output: Rectangle area: 12.00
}

指针接收者

Go语言支持使用指针作为方法的接收者,这样可以修改接收者实例的字段值。创建method_rec_test.go

package method

import (
	"fmt"
	"testing"
)

type Counter struct {
	Count int
}

func (c *Counter) Increment() {
	c.Count++
}

func TestMethonRec(t *testing.T) {
	counter := Counter{Count: 0}
	counter.Increment()
	fmt.Println("Count:", counter.Count) // Output: Count: 1
}

在上述示例中, Increment 方法使用了指针接收者,这样调用方法后, Count 字段的值会被修改。

方法与函数的区别

方法和函数的核心差异在于,方法本质上是依附于特定类型的函数,它与该类型的联系极为紧密,能够直接访问该类型的字段以及其他方法。而函数则是独立存在的代码块,不与特定类型绑定。方法一般用于实现特定类型所特有的行为,函数则常用于执行通用的操作。

定义方法能够让针对特定类型的操作显得更加自然和连贯,有助于提升代码的可读性,增强代码的模块化程度。

method_rpc_test.go 中的代码为例,我们为 Rectangle 结构体定义了一个名为 Area 的方法。在使用时,可以通过 rect.Area() 这种简洁直观的方式来调用该方法。由于这个方法与 Rectangle 类型直接关联,所以它能够直接访问 Rectangle 结构体中的字段,如 WidthHeight

我们为了与方法作对比,在对应方法体中创建一个方法如下

// 定义一个函数来计算矩形的面积
func CalculateArea(r Rectangle) float64 {
    return r.Width * r.Height
}

在这个示例当中,我们定义了一个名为 CalculateArea 的函数,它接收一个 Rectangle 类型的参数,其功能是计算矩形的面积。由于此函数独立于 Rectangle 类型存在,所以它不能直接访问 Rectangle 类型的字段。

总结: 方法和函数存在明显区别。方法属于特定类型的函数,与类型的关联更为紧密,能够访问该类型的字段以及其他方法。而函数是独立于特定类型的代码块,通常用于执行通用操作。在上述示例里,方法与矩形这一类型紧密相连,能够直接访问矩形的字段;函数则是一个独立的计算流程,并不直接与任何特定类型相关联。

运用方法能够让代码的表达更加自然、逻辑更加连贯,有助于提升代码的可读性,增强代码的模块化程度,尤其在实现特定类型的行为时,这种优势更为显著。

4.接口定义使用

在Go语言中,接口是一种定义方法集合的方式,它规定了一组方法的签名,而不涉及实现细节。通过接口,可以实现多态性和代码解耦,使不同类型的对象能够按照一致的方式进行操作。

创建interface目录用于后续练习,以下是关于Go语言接口的讲解:

定义接口

接口是一组方法的集合,通过 type 关键字定义。接口定义了一组方法签名,但不包含方法的实现。创建interface_test.go进行代码练习

package interface_test

import (
	"fmt"
	"testing"
)

// 定义一个简单的接口
type Shape interface {
	Area() float64
}

// 定义两个实现 Shape 接口的结构体
type Circle struct {
	Radius float64
}

func (c Circle) Area() float64 {
	return 3.14159 * c.Radius * c.Radius
}

type Rectangle struct {
	Width  float64
	Height float64
}

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

func TestInterface(t *testing.T) {
	shapes := []Shape{
		Circle{Radius: 2},
		Rectangle{Width: 3, Height: 4},
	}

	for _, shape := range shapes {
		fmt.Printf("Area of %T: %.2f\n", shape, shape.Area())
	}
}

在上面的示例中,我们定义了一个名为 Shape 的接口,该接口要求实现一个 Area 方法,用于计算图形的面积。然后,我们定义了两个结构体 CircleRectangle,并分别实现了 Area 方法。通过使用接口,我们可以将不同类型的图形对象放入同一个切片中,然后通过循环调用它们的 Area 方法。

接口的实现

在 Go 语言里,只要某个类型实现了接口所定义的全部方法,就可认定该类型实现了此接口。而且,接口的实现是隐式的,无需进行显式声明。也就是说,只要类型的方法签名与接口中的方法签名一致,该类型便会被视作实现了此接口。

接口的多态性

得益于接口的多态特性,我们能够把实现了接口的对象当作接口类型来使用。在上述示例中,shapes 切片里存储着不同类型的对象,不过它们都实现了 Shape 接口。所以,我们可以采用统一的方式来调用这些对象的 Area 方法。

借助接口,能够实现代码的抽象与解耦,让代码变得更加灵活且易于扩展。在 Go 语言中,接口应用十分广泛,常被用于定义通用的行为和设定约束条件。

5. 扩展和复用

Go 语言在代码扩展和复用方面,与传统的面向对象语言(例如 Java)存在差异。Go 语言倡导运用组合、接口以及匿名字段等特性来达成代码的扩展和复用,而非依赖类继承机制。

创建extend目录用于后续练习,以下是关于Go语言中扩展和复用的详细讲解:

组合和嵌套

Go语言中的组合(composition)允许你将一个结构体类型嵌套在另一个结构体类型中,从而实现代码的复用。嵌套的结构体可以通过字段名直接访问其成员。创建composition_test.go

package extend

import (
	"fmt"
	"testing"
)

type Engine struct {
	Model string
}

type Car struct {
	Engine
	Brand string
}

func TestComposition(t *testing.T) {
	car := Car{
		Engine: Engine{Model: "V6"},
		Brand:  "Toyota",
	}

	fmt.Println("Car brand:", car.Brand)
	fmt.Println("Car engine model:", car.Model) // 直接访问嵌套结构体的字段
}

在这个示例中,我们使用了组合来创建 Car 结构体,其中嵌套了 Engine 结构体。通过嵌套, Car 结构体可以直接访问 Engine 结构体的字段。

接口实现

通过接口,可以定义一组方法,然后不同的类型可以实现这些方法。这样可以实现多态性和代码解耦,使得不同类型的对象可以通过相同的接口进行操作。创建interface_ext_test.go

package extend

import (
	"fmt"
	"math"
	"testing"
)

// 定义 Shape 接口
type Shape interface {
	Area() float64
	Perimeter() float64
}

// 定义 Circle 结构体
type Circle struct {
	Radius float64
}

// 实现 Circle 结构体的方法,以满足 Shape 接口
func (c Circle) Area() float64 {
	return math.Pi * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
	return 2 * math.Pi * c.Radius
}

// 定义 Rectangle 结构体
type Rectangle struct {
	Width  float64
	Height float64
}

// 实现 Rectangle 结构体的方法,以满足 Shape 接口
func (r Rectangle) Area() float64 {
	return r.Width * r.Height
}

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

func TestInterfaceExt(t *testing.T) {
	circle := Circle{Radius: 3}
	rectangle := Rectangle{Width: 4, Height: 5}

	shapes := []Shape{circle, rectangle}

	for _, shape := range shapes {
		fmt.Printf("Shape Type: %T\n", shape)
		fmt.Printf("Area: %.2f\n", shape.Area())
		fmt.Printf("Perimeter: %.2f\n", shape.Perimeter())
		fmt.Println("------------")
	}
}

在上述示例中,我们定义了一个名为 Shape 的接口,它有两个方法 Area()Perimeter(),分别用于计算形状的面积和周长。然后,我们分别实现了 CircleRectangle 结构体的这两个方法,使它们满足了 Shape 接口。

通过将不同类型的形状实例放入一个 []Shape 切片中,我们可以使用统一的方式调用 Area()Perimeter() 方法,实现了代码的多态性和解耦。这样,无论我们后续添加新的形状,只要它们实现了 Shape 接口的方法,就可以无缝地集成到计算器中。

匿名字段和方法重用

通过使用匿名字段,一个结构体可以继承另一个结构体的字段和方法。创建other_ext_test.go

package extend

import (
	"fmt"
	"testing"
)

type Animal struct {
	Name string
}

func (a Animal) Speak() {
	fmt.Println("Animal speaks")
}

type Dog struct {
	Animal
	Breed string
}

func TestOtherExt(t *testing.T) {
	dog := Dog{
		Animal: Animal{Name: "Buddy"},
		Breed:  "Golden Retriever",
	}

	fmt.Println("Dog name:", dog.Name)
	dog.Speak() // 继承了 Animal 的 Speak 方法
}

在上述示例中, Dog 结构体嵌套了 Animal 结构体,从而继承了 Animal 的字段和方法。

通过这些方式,你可以在Go语言中实现代码的扩展和复用。尽管Go不像传统的面向对象语言那样强调类继承,但通过组合、接口和匿名字段等特性,你仍然可以实现类似的效果,使代码更灵活、可读性更高,并保持低耦合性。

6. 空接口和断言

空接口与断言是 Go 语言里处理不确定类型以及进行类型转换的关键概念。

我们创建一个名为 emptyassert 的目录,用于后续相关练习。以下是关于空接口和断言的学习总结:

空接口(Empty Interface)

空接口是 Go 语言中最为基础的接口形式,它没有声明任何方法。正因如此,空接口能够表示任意类型的值。其声明方式为 interface{}

空接口的主要应用场景是处理类型不确定的数据。借助空接口,我们可以接收并存储任意类型的值,这和其他编程语言中的动态类型有相似之处。不过,需要特别留意的是,使用空接口可能会降低代码的类型安全性,因为在编译阶段无法对具体类型进行检查。

断言(Type Assertion)

断言是一种从空接口中还原具体类型的机制。它使我们能够在程序运行时检查空接口中值的实际类型,并将其转换为对应的具体类型。断言的语法格式为 value.(Type),其中 value 代表接口值,Type 则是要进行断言的具体类型。

创建emptyassert_test.go进行验证:

package emptyassert

import (
	"fmt"
	"testing"
)

func DoSomething(p interface{}) {
	switch v := p.(type) {
	case int:
		fmt.Println("Integer", v)
	case string:
		fmt.Println("String", v)
	default:
		fmt.Println("Unknow Type")
	}
}

func TestEmptyInterfaceAssertion(t *testing.T) {
	DoSomething(10)
	DoSomething("10")
}

func TestEmptyAssert(t *testing.T) {
	var x interface{} = "hello"
	str, ok := x.(string)
	if ok {
		fmt.Println("String:", str)
	} else {
		fmt.Println("Not a string")
	}
}

下面逐个解释每个测试函数的内容:

  1. func DoSomething(p interface{}) { ... }:定义了一个函数 DoSomething,该函数接受一个空接口参数 p,然后根据接口值的实际类型进行类型断言,根据不同的类型输出不同的信息。

  2. func TestEmptyInterfaceAssertion(t *testing.T) { ... }:测试空接口的断言操作。 调用 DoSomething(10),将整数 10 传递给函数,函数根据类型断言输出整数类型信息。调用 DoSomething("10"),将字符串 "10" 传递给函数,函数根据类型断言输出字符串类型信息。

  3. func TestEmptyAssert(t *testing.T) { ... }:测试空接口的类型断言操作。 声明一个空接口变量 x,并将字符串 "hello" 赋值给它。使用类型断言 x.(string) 判断 x 是否为字符串类型,如果是,将其赋值给变量 str,并输出字符串值;否则输出 “Not a string”。

这些测试函数展示了Go语言中空接口的断言操作,通过类型断言可以判断空接口中的具体类型,并执行相应的操作。

总结: 空接口和断言是Go语言中处理不确定类型和类型转换的强大工具。空接口允许存储任何类型的值,而断言允许我们在运行时检查和转换接口值的实际类型。使用这些机制,可以在需要处理不同类型的值时实现更灵活和通用的代码。但在使用空接口和断言时,要注意维护类型安全性,并进行适当的错误处理。

7. Go 接口最佳实践

在 Go 语言里,遵循接口的最佳实践能够显著提升代码的可读性、可维护性与灵活性。以下是具体的实践建议:

小接口优于大接口

应尽量设计功能单一、方法数量少的小接口,而非追求大而全的接口设计。这样做能避免实现接口时产生不必要的负担,让接口更具通用性和可复用性。

基于使用场景设计接口

设计接口时,需从实际使用场景出发,而非着眼于具体的实现细节。要深入思考在应用程序中如何运用接口,以及接口需要提供哪些方法来满足这些场景的需求。

使用恰当的命名

为接口和方法选取清晰、具有表达性的名称,使它们能直观地反映自身的用途和功能。良好的命名有助于其他开发者快速理解接口的作用。

避免创建不必要的接口

不要为每个类型都创建接口,仅在多个类型确实存在共享行为和功能时,才考虑使用接口。过度使用接口会增加代码的复杂性,应尽量避免。

以接口作为函数参数和返回值

将接口用作函数的参数和返回值,能增强函数的通用性,使其可以接受不同类型的参数,并返回不同类型的结果,进而提高代码的复用性和扩展性。

提供清晰的注释和文档

为接口撰写详细的文档和注释,阐明接口的用途、方法的功能以及预期行为。这有助于其他开发者更好地理解和使用接口。

采用用例驱动设计

设计接口时,可从实际使用角度出发,先设想接口在实际场景中的调用方式,再据此设计接口的方法和签名。

分离接口的实现与定义

将接口的实现与定义分开,能使实现更加灵活。在不改动接口定义的前提下,就可以实现新的类型。

提供默认实现

在接口定义中,可为部分方法提供默认实现,从而减少实现接口时的工作量。这对于可选方法或具有默认行为的方法尤为实用。

谨慎使用空接口

使用空接口( interface{} )时要格外谨慎,因为它会降低代码的类型安全性。只有在确实需要处理不同类型的值时才使用,同时要做好类型断言和错误处理。

总之,在设计和使用接口时,要充分结合实际需求和项目特点,选择最为合适的方案。

(六)编写好错误机制

src目录下创建chapter6,Go语言中的错误处理机制是通过返回错误值来实现的,而不是使用异常。这种错误处理机制非常清晰、可控,使得开发者能够精确地处理各种错误情况。

1.基本使用介绍

创建basic目录,编写basic_error_test.go

错误类型

在Go中,错误被表示为一个实现了 error 接口的类型。 error 接口只有一个方法,即 Error() string,它返回一个描述错误的字符串。

type error interface {
    Error() string
}
返回错误值

当一个函数遇到错误情况时,通常会返回一个错误值。这个错误值可以是一个实现了 error 接口的自定义类型,也可以是Go标准库中预定义的错误类型,如 errors.New() 创建的错误。

错误检查

调用者通常需要显式地检查函数返回的错误,以判断是否发生了错误。这可以通过在调用函数后使用 if 语句来实现。

以上两个直接写代码如下:

package basic

import (
	"errors"
	"fmt"
	"testing"
)

var LessThanTwoError = errors.New("n should be not less than 2")
var LargerThenHundredError = errors.New("n should be not larger than 100")

func GetFibonacci(n int) ([]int, error) {
	if n < 2 {
		return nil, LessThanTwoError
	}
	if n > 100 {
		return nil, LargerThenHundredError
	}
	fibList := []int{1, 1}

	for i := 2; /*短变量声明 := */ i < n; i++ {
		fibList = append(fibList, fibList[i-2]+fibList[i-1])
	}
	return fibList, nil
}

func TestGetFibonacci(t *testing.T) {
	if v, err := GetFibonacci(1); err != nil {
		if err == LessThanTwoError {
			fmt.Println("It is less.")
		}
		t.Error(err)
	} else {
		t.Log(v)
	}

}

2.错误链

创建chain目录,编写error_chain_test.go

在某些情况下,错误可以包含附加信息,以便更好地理解错误的原因。可以通过 fmt.Errorf() 函数来创建包含附加信息的错误。

假设我们正在构建一个文件操作的库,其中包含文件读取和写入功能。有时,在文件读取或写入过程中可能会出现各种错误,例如文件不存在、权限问题等。我们希望能够提供有关错误的更多上下文信息。

package chain

import (
	"errors"
	"fmt"
	"testing"
)

// 自定义文件操作错误类型
type FileError struct {
	Op   string // 操作类型("read" 或 "write")
	Path string // 文件路径
	Err  error  // 原始错误
}

// 实现 error 接口的 Error() 方法
func (e *FileError) Error() string {
	return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err)
}

// 模拟文件读取操作
func ReadFile(path string) ([]byte, error) {
	// 模拟文件不存在的情况
	return nil, &FileError{Op: "read", Path: path, Err: errors.New("file not found")}
}

func TestChain(t *testing.T) {
	filePath := "/path/to/nonexistent/file.txt"
	_, err := ReadFile(filePath)
	if err != nil {
		fmt.Println("Error:", err)
		// 在这里,我们可以检查错误类型,提取上下文信息
		if fileErr, ok := err.(*FileError); ok {
			fmt.Printf("Operation: %s\n", fileErr.Op)
			fmt.Printf("File Path: %s\n", fileErr.Path)
			fmt.Printf("Original Error: %v\n", fileErr.Err)
		}
	}
}

下面是代码的解释:

  1. FileError 结构体:定义了一个自定义错误类型 FileError,包含以下字段: Op:操作类型,表示是读取(”read”)还是写入(”write”)操作; Path:文件路径,表示涉及哪个文件; Err:原始错误,包含底层的错误信息。

  2. Error() 方法:为 FileError 结构体实现了 error 接口的 Error() 方法,用于生成错误的文本描述。

  3. ReadFile() 函数:模拟文件读取操作。在这个示例中,该函数返回一个 FileError 类型的错误,模拟了文件不存在的情况。

  4. TestChain() 测试函数:演示如何在错误处理中使用自定义错误类型。 定义了一个文件路径 filePath,并调用 ReadFile(filePath) 函数来模拟文件读取操作;检查错误,如果发生错误,输出错误信息;在错误处理中,通过类型断言检查错误是否为 *FileError 类型,如果是,则可以提取更多上下文信息,如操作类型、文件路径和原始错误信息。

3.Panic 和 Recover

在Go语言中, panicrecover 是用于处理异常情况的机制,但它们应该谨慎使用,仅用于特定的情况,而不是替代正常的错误处理机制。以下是对 panicrecover 的详细解释,并给出一个具体用例:

panic

创建 panic 目录,编写 panic_test.go。 panic 是一个内置函数,用于引发运行时恐慌。当程序遇到无法继续执行的致命错误时,可以使用 panic 来中断程序的正常流程。但应该避免滥用 panic,因为它会导致程序崩溃,不会提供友好的错误信息。典型情况下, panic 用于表示程序中的不可恢复错误,例如切片索引越界。

package panic

import (
	"fmt"
	"testing"
)

func TestPanic(t *testing.T) {
	arr := []int{1, 2, 3}
	index := 4
	if index >= len(arr) {
		panic("Index out of range")
	}
	element := arr[index]
	fmt.Println("Element:", element)
}

在上述示例中,如果索引 index 超出了切片 arr 的范围,会触发 panic,导致程序崩溃。这种情况下, panic 用于表示程序的不可恢复错误。

recover

创建 recover 目录,编写 recover_test.go。 recover 也是一个内置函数,用于恢复 panic 引发的运行时恐慌。它只能在延迟函数( defer)内部使用,并且用于恢复程序的控制流,而不是用于处理错误。通常,在发生 panic 后, recover 可以在延迟函数中捕获 panic,并执行一些清理工作,然后程序会继续执行。

package recover

import (
	"fmt"
	"testing"
)

func cleanup() {
	if r := recover(); r != nil {
		fmt.Println("Recovered from panic:", r)
	}
}

func TestRecover(t *testing.T) {
	defer cleanup()
	panic("Something went wrong")
	fmt.Println("This line will not be executed")
}

在上述示例中, panic 触发后, cleanup 函数中的 recover 捕获了 panic,并打印了错误消息。然后程序会继续执行,但需要注意的是,控制流不会回到触发 panic 的地方,因此 fmt.Println 不会被执行。

总之, panicrecover 应该谨慎使用,只用于特殊情况,如不可恢复的错误或在延迟函数中进行清理操作。在大多数情况下,应该优先使用错误返回值来处理异常情况,因为这种方式更安全、可控,能够提供更好的错误信息和错误处理。只有在特定的情况下,例如遇到不可恢复的错误时,才应该考虑使用 panicrecover

4.自定义错误类型

创建 define 目录,编写 error_define_test.go。

在Go中,你可以根据需要定义自己的错误类型,只需满足 error 接口的要求即可。这允许你创建更具描述性和上下文的错误类型。

在Go中,自定义错误类型是一种强大的方式,可以创建更具描述性和上下文的错误,以提供更好的错误信息。自定义错误类型必须满足 error 接口的要求,即实现 Error() string 方法。以下是一个示例,展示如何自定义错误类型和验证其用例:

package define

import (
	"fmt"
	"testing"
	"time"
)

// 自定义错误类型
type TimeoutError struct {
	Operation string    // 操作名称
	Timeout   time.Time // 超时时间
}

// 实现 error 接口的 Error() 方法
func (e TimeoutError) Error() string {
	return fmt.Sprintf("Timeout error during %s operation. Timeout at %s", e.Operation, e.Timeout.Format("2006-01-02 15:04:05"))
}

// 模拟执行某个操作,可能会超时
func PerformOperation() error {
	// 模拟操作超时
	timeout := time.Now().Add(5 * time.Second)
	if time.Now().After(timeout) {
		return TimeoutError{Operation: "PerformOperation", Timeout: timeout}
	}
	// 模拟操作成功
	return nil
}

func TestDefineError(t *testing.T) {
	err := PerformOperation()
	if err != nil {
		// 检查错误类型并打印错误信息
		if timeoutErr, ok := err.(TimeoutError); ok {
			fmt.Println("Error Type:", timeoutErr.Operation)
			fmt.Println("Timeout At:", timeoutErr.Timeout)
		}
		fmt.Println("Error:", err)
	} else {
		fmt.Println("Operation completed successfully.")
	}
}

下面是代码的解释:

  1. TimeoutError 结构体:定义了一个自定义错误类型 TimeoutError,包含以下字段: Operation:操作名称,表示哪个操作超时; Timeout:超时时间,表示操作发生超时的时间点。

  2. Error() 方法:为 TimeoutError 结构体实现了 error 接口的 Error() 方法,用于生成错误的文本描述。

  3. PerformOperation() 函数:模拟执行某个操作,可能会超时。在这个示例中,如果当前时间超过了超时时间,则返回一个 TimeoutError 类型的错误。

  4. TestDefineError() 测试函数:演示如何在错误处理中使用自定义错误类型。 调用 PerformOperation() 函数来模拟操作,并检查是否发生了错误;如果发生错误,首先检查错误类型是否为 TimeoutError,如果是,则提取超时操作和超时时间,并输出相关信息;最后,无论是否发生错误,都会输出错误信息或成功完成的消息。

这个示例展示了如何自定义错误类型以及如何在错误处理中利用这些自定义错误类型来提供更多的上下文信息,使错误处理更加有信息和灵活。在这里, TimeoutError 提供了有关超时操作和超时时间的额外信息。

(七)包和依赖管理

src目录下创建chapter7,Go 语言的包和依赖管理主要 通过其内置的模块系统(Go Modules)来实现。Go Modules 于 Go 1.11 版本首次引入,并在 Go 1.13 版本中成为默认的依赖管理方式。

1. package(包)的基本知识点

基本复用模块单元

在 Go 语言中, package 是代码复用的基本单元。一个 package 可以包含多个 Go 源文件,这些文件可以共享同一个包中的代码,并通过包的导入机制被其他包使用。

包的可见性在 Go 语言中,通过首字母大写来表明一个标识符(如变量、函数、类型等)可以被包外的代码访问。反之,首字母小写的标识符只能在包内使用。

// mypackage.go
package mypackage

// 公有函数,其他包可以访问
func PublicFunction() {
    // 实现细节
}

// 私有函数,仅在当前包内可访问
func privateFunction() {
    // 实现细节
}

代码的 package 可以和所在的目录不一致

Go 语言的文件组织结构鼓励但不强制 package 名称与其所在目录名称一致。通常情况下,开发者会遵循这种约定以保持代码的一致性和可读性,但 Go 并不强制执行这一规则。

实际应用:你可以在 chapter7 目录下创建多个文件,并在这些文件中定义相同的包名 mypackage,也可以选择一个不同于目录名的包名。

// src目录下的代码
// src/chapter7/utility.go
package utility  // 包名与所在目录名不同

func UtilityFunction() {
    // 实现细节
}

同一目录里的 Go 代码的 package 要保持一致

在同一目录中的所有 Go 文件必须声明相同的 package 名称。这是 Go 语言的一个基本规则,确保同一目录下的所有文件都属于同一个包,从而能够互相访问这些文件中声明的标识符。

违例情况:如果你在同一目录下使用不同的 package 名称,Go 编译器将会报错,提示包声明不一致。这个在上面的案例中也可以直接看到。

2.构建一个自身可复用的 package

src目录下创建chapter7后,再次新建series,编写my_series.go如下:

package series

import "fmt"

func init() {
	fmt.Println("init1")
}

func init() {
	fmt.Println("init2")
}

func Square(n int) int {
	return n * n
}

func GetFibonacciSerie(n int) []int {
	ret := []int{1, 1}
	for i := 2; i < n; i++ {
		ret = append(ret, ret[i-2]+ret[i-1])
	}
	return ret
}

然后在chapter7中新建client,编写package_test.go将上面的内容引入:

package client

import (
	"go-learning/src/chapter7/series"
	"testing"
)

func TestPackage(t *testing.T) {
	t.Log(series.GetFibonacciSerie(5))
	t.Log(series.Square(5))
}

通过在 chapter7 目录下创建一个名为 series 的包,把与数学相关的函数(如求平方和斐波那契数列)集中在一起。这样在其他地方需要使用这些功能时,只需引入这个包即可,不必重复编写相同的代码。

知识点:包的初始化
  • 在 Go 语言里,借助 init() 函数机制能够实现包的初始化操作。值得注意的是,每个包可以定义多个 init() 函数。当包首次被加载时,这些 init() 函数会按照代码中出现的顺序自动执行。
  • series 包中,我们编写了两个 init() 函数。当该包被引入时,这两个函数会自动运行。这种机制十分实用,一方面可用于在包加载时完成一些必要的初始化工作,例如设置默认值、加载配置文件等;另一方面,也可用于调试包的加载过程,方便开发者了解包的加载情况。

3.导入和应用远程依赖(即外部包)

获取和更新远程依赖

  • 使用 go get 命令来下载并添加远程依赖到项目中。Go Modules 会自动管理这些依赖,并更新 go.modgo.sum 文件。

  • 如果需要强制从网络获取最新版本的依赖,可以使用 -u 参数:

    • 示例go get -u github.com/user/repo

    这将更新指定包及其依赖项到最新的次要版本或修订版本。

代码在 GitHub 上的组织形式

  • 确保代码库的目录结构直接反映包的导入路径,而不要使用 src 目录作为根目录。这使得项目更容易与 Go 的依赖管理工具兼容,确保导入路径的简洁和一致性。
  • ”`bash github.com/username/project/ ├── mypackage/ │ └── mypackage.go └── anotherpackage/ └── anotherpackage.go

  **最佳实践**:在 GitHub 上组织代码时,目录结构应与包名匹配,例如:
- 这样可以避免导入路径中的多余层级,并确保使用 `go get` 时能正确定位包。

按照该思路我们进行验证,在在 `chapter7` 目录下创建一个名为 `remote_package` 的包,我们先进行下载 **“go get github.com/easierway/concurrent\_map”** 的下载,然后创建remote\_package\_test.go进行验证:

```Go
package remote

import (
	"fmt"
	"testing"

	cm "github.com/easierway/concurrent_map"
)

func TestConcurrentMap(t *testing.T) {
	m := cm.CreateConcurrentMap(99)
	m.Set(cm.StrKey("key"), 10)
	value, ok := m.Get(cm.StrKey("key"))
	if ok {
		fmt.Println("Key found:", value)
		t.Log(m.Get(cm.StrKey("key")))
	}
}

concurrent_map的介绍: concurrent_map 是一个由 GitHub 用户 easierway 创建的 Go 包,主要用于实现 线程安全的并发 map 数据结构。这个包提供了一种简单且高效的方式来处理并发环境下的 map 操作,避免了传统 map 在多 goroutine 访问时出现的竞争问题。

功能/特点 说明
线程安全 通过 分段锁机制(分片锁) 确保 map 在多 goroutine 并发访问时的数据安全。
高效的读写操作 将 map 分成多个子 map,减少锁的粒度,提高并发访问的效率。
简单易用的 API 提供类似标准 map 的接口,如 SetGetRemove,使用方式简单。
动态扩展 根据使用需求动态扩展或收缩分段,提高资源利用率。

4.包的依赖管理

Go 语言在早期的依赖管理中(使用 GOPATH)确实存在一些未解决的问题:

同一环境下,不同项目使用同一包的不同版本

在 Go Modules 引入之前,Go 的依赖管理依赖于 GOPATH 目录。所有的项目共享同一个 GOPATH,这就导致了一个问题:如果两个项目需要使用同一包的不同版本,由于 GOPATH 中同一个包只能有一个版本,无法同时满足这两个项目的需求。这种情况下,开发者往往需要手动管理和切换包版本,带来了很大的麻烦和不确定性。

无法管理对包的特定版本的依赖

在没有 Go Modules 之前,Go 的依赖管理缺乏对包版本的精确控制。通常情况下,开发者只能获取最新版本的包,这就导致了以下问题:

  • 当某个包发布了不兼容的新版本时,项目可能会因自动升级到新版本而导致编译或运行错误。
  • 难以重现历史版本的构建,因为无法确定项目依赖的具体版本。
Go Modules 如何解决这些问题

为有效解决 Go 语言在依赖管理方面存在的问题,自 1.11 版本起,Go 引入了 Go Modules,这一举措从根本上革新了 Go 的依赖管理模式。Go Modules 提供了版本控制和模块隔离机制,成功规避了前文提及的各类问题。

不同项目使用同一包的不同版本

独立的模块空间

每个 Go 项目借助 go.mod 文件对自身的依赖关系进行独立管理。该文件会明确界定项目所依赖的全部包及其对应版本。这些依赖包会被下载至 $GOPATH/pkg/mod 目录下,并且依据模块名和版本号进行隔离存储。如此一来,不同项目能够使用同一包的不同版本,彼此之间不会产生干扰。

** 无需全局 GOPATH**

Go Modules 打破了对全局 GOPATH 的依赖,转而采用模块级的依赖管理方式。每个项目的依赖包版本在项目目录下实现独立管理,从源头上避免了版本冲突问题。

管理对包的特定版本的依赖

** 精确的版本控制**

go.mod 文件中,开发者能够精准指定依赖包的具体版本。Go Modules 支持语义化版本控制(Semantic Versioning),开发者可通过 @ 符号指定某个依赖包的版本号(例如 v1.2.3),也可以使用 go get <package>@<version> 命令来更新某个依赖的版本。通过这种方式,开发者能够明确指定并锁定项目依赖的版本,从而确保项目的可重现性。

** 版本兼容性和依赖解析**

Go Modules 借助 go.modgo.sum 文件对版本依赖进行管理,确保项目在构建过程中所使用的依赖版本具有可预测性和稳定性。即便某个依赖包发布了新版本,只要开发者未主动升级,项目仍会继续使用 go.mod 中指定的版本。

尽管 Go Modules 成功解决了诸多依赖管理难题,但它也带来了一些新的挑战:

  • 多模块项目的管理:在大型项目中,往往会包含多个模块。当这些模块之间存在依赖关系时,其依赖管理需要开发者谨慎对待。
  • 依赖冲突:若不同的依赖项依赖于同一个包的不同版本,Go Modules 会尝试找出一个可用的共同版本。然而,这种解决方案并非总是理想的。

总体而言,Go Modules 通过模块化和版本控制,基本解决了 Go 语言早期在依赖管理方面存在的主要问题,例如同一环境下不同项目使用同一包的不同版本,以及对包的特定版本的依赖管理问题。不过,随着项目规模的不断扩大和依赖关系日益复杂,依赖管理工作依然需要开发者予以高度重视。

(八)并发编程

src目录下创建chapter8,展开后续的学习。

1.协程机制

Thread vs Goroutine

Java 的线程(Thread)和 Go 语言的协程(Goroutine)在设计理念和实现细节上存在显著差异,尤其体现在栈大小以及与内核空间实体(KSE)的对应关系上,具体如下:

比较项 Java Thread Goroutine
栈的初始大小 在 JDK 5 及后续版本中为 1MB 仅 2KB
栈的增长方式 栈大小是 固定的,当使用超出此大小时会抛出 StackOverflowError 异常 栈空间能够 动态增长,最大可扩展至 1GB
与内核线程的对应关系 采用 1:1 模型,即每个 Java 线程都对应一个内核线程 遵循 M:N 模型,多个 Goroutine 可以复用少量的内核线程
调度方式 由操作系统负责调度 由 Go 运行时系统进行调度
创建和调度的开销 开销较大,线程的创建和切换操作会消耗较多系统资源 开销极小,创建和调度 Goroutine 所带来的系统负担非常低
并发处理能力 若创建大量线程,可能会对系统性能造成负面影响 能够高效地创建和管理大量的 Goroutine
Goroutine 的调度原理

左侧图示展示了 Goroutine 在 正常调度情况 下的工作原理:

  1. M(System Thread):代表操作系统的线程,图中有两个系统线程, M0M1M0 正在执行某个 Goroutine。
  2. P(Processor):代表处理器,这里是 Go 的调度器中的一个抽象概念,而不是实际的 CPU 核心。P 负责执行 Goroutine 的队列,并将它们映射到系统线程(M)上。 图中有一个 P,它将 Goroutine 分配给 M 进行执行。
  3. G(Goroutine):代表 Goroutine,图中 G0, G1, G2 等分别表示不同的 Goroutine。 G0 正在被 M0 执行。 G1, G2 仍在等待被调度执行。

右侧图示展示了 Goroutine 发生 系统调用(Syscall)时 的工作原理:

  1. Syscall:当 Goroutine 需要执行系统调用时,执行该 Goroutine 的系统线程会被阻塞。 这里 M0 在处理 G0 的系统调用,因此 M0 被阻塞在系统调用中。
  2. M1:系统线程 M1 被调度来继续处理 P 中的其他 Goroutine。 P 调度了其他的 Goroutine(如 G1, G2)到新的系统线程 M1 上继续执行,从而避免了因为一个 Goroutine 阻塞而导致整个线程阻塞的情况。

调度机制总结

  • Go 运行时调度器通过M:P:G 模型实现了 Goroutine 的高效调度。
  • M(系统线程)可以执行多个 G(Goroutine),而 P(Processor)则决定哪些 Goroutine 应该运行在 M 上。
  • 当一个 Goroutine 被阻塞时(如执行系统调用),Go 运行时会将该系统线程从调度队列中移除,并将剩余的 Goroutine 调度到其他空闲的系统线程上继续执行。
  • 这样可以有效地利用系统资源,避免线程阻塞导致的资源浪费,体现了 Goroutine 的轻量化和高效性。

这张图很直观地展示了 Go 语言中 Goroutine 的 M 模型,如何通过 M, P, G 之间的协作,实现高效的并发调度。

直接的代码展示

直接在chapter8下新建groutine,编写groutine_test.go代码如下:

package groutine

import (
	"fmt"
	"testing"
	"time"
)

func sayHello() {
	fmt.Println("Hello, Goroutine!")
}

func TestGroutine(t *testing.T) {
	for i := 0; i < 10; i++ {
		go func(i int) {
			//time.Sleep(time.Second * 1)
			fmt.Println(i)
		}(i)
	}
	time.Sleep(time.Millisecond * 50)
}

func TestSayHello(t *testing.T) {
	go sayHello() // 启动一个新的 Goroutine 执行 sayHello 函数

	// 主函数等待一段时间,确保 Goroutine 有机会执行
	time.Sleep(time.Millisecond * 10)
	fmt.Println("Main function finished")
}

  • TestGroutine 展示了如何在循环中创建多个 Goroutine 并并发执行任务,同时说明了 Goroutine 的变量捕获问题。
  • TestSayHello 展示了如何使用 Goroutine 并发执行一个简单的函数,并突出 Goroutine 的非阻塞特性。

通过这两个函数,可以更好地理解 Go 语言中 Goroutine 的基本使用方式以及它们在并发编程中的作用。

2.共享内存并发机制

在chapter8下新建share_mem,我们可以先写下面的代码share_mem_test.go来体验共享内存并发机制的控制:

未引入同步处理情况
package share_mem

import (
	"testing"
	"time"
)

func TestCounter(t *testing.T) {
	counter := 0
	for i := 0; i < 5000; i++ {
		go func() {
			counter++
		}()
	}
	time.Sleep(1 * time.Second)
	t.Logf("counter = %d", counter)

}

运行结果为:

=== RUN   TestCounter
    share_mem_test.go:17: counter = 4426
--- PASS: TestCounter (1.01s)
PASS

在代码中,5000 个 goroutine 同时对 counter 变量进行递增操作( counter++),但 counter++ 并不是原子操作,它实际上包含了三步:

  • 读取 counter 的当前值。
  • counter 的值加 1。
  • 将新的值写回 counter

在并发环境下,不同的 goroutine 可能在同一时间读取 counter,并且在写入时产生冲突。举个例子,两个 goroutine 可能在几乎同一时刻读取到 counter 的值为 100,然后都试图将 counter 更新为 101。由于没有同步机制,其中一个更新可能会被覆盖,从而导致 counter 的实际值小于预期的 5000。

注意,虽然程序使用了 time.Sleep(1 * time.Second) 来等待 goroutine 执行完毕,但这并不能解决数据竞争的问题。即使所有 goroutine 都在一秒内完成, counter 变量的递增操作依然存在竞争。

sync.Mutex

要解决这个问题,需要引入同步机制来保护对共享变量的访问。 常见的同步方法包括:

  • 使用 sync.Mutex 来确保每次只有一个 goroutine 能够修改 counter
  • 使用 sync/atomic 包中的原子操作,如 atomic.AddInt32atomic.AddInt64

我们使用 sync.Mutex修改代码实现如下:

func TestCounterThreadSafe(t *testing.T) {
	var mut sync.Mutex
	counter := 0
	for i := 0; i < 5000; i++ {
		go func() {
			mut.Lock()         // 先获取锁
			defer mut.Unlock() // 确保函数退出时解锁
			counter++          // 安全递增
		}()
	}
	time.Sleep(1 * time.Second)
	t.Logf("counter = %d", counter) // 输出最终的 counter 值
}

这个时候运行结果符合我们的预期:

=== RUN   TestCounterThreadSafe
    share_mem_test.go:32: counter = 5000
--- PASS: TestCounterThreadSafe (1.00s)
PASS
sync.WaitGroup

上面的代码中去掉 time.Sleep(1 * time.Second) 后,虽然代码本身有加锁保护 counter 的访问,但依然出现错误的运行结果,这与并发 goroutine 的执行时机有关:

  • goroutine 的非阻塞执行:在 Go 语言中,go 关键字启动的 goroutine 是 并发 运行的,而不是 同步 运行的。启动 goroutine 后,主程序并不会等待它们执行完毕就继续执行。也就是说, go func() 语句启动的 5000 个 goroutine 是异步执行的。而 t.Logf("counter = %d", counter) 是在主函数中执行的。当主函数到达 t.Logf 时,主程序并没有等待这些 goroutine 执行完毕,而是直接打印了 counter 的值。
  • 主程序过早结束:由于去掉了 time.Sleep(1 * time.Second),主程序并不会等到所有 goroutine 执行完成,而是可能在 goroutine 尚未执行或部分执行完时就已经输出了 counter 的值。因此, counter 的值通常会小于 5000,因为并不是所有的 goroutine 都有机会执行 counter++ 操作。

我们需要一种机制来确保主程序等待所有 goroutine 完成后再输出 counter。可以使用 sync.WaitGroup 来实现这个目标,具体代码如下:

func TestCounterWaitGroup(t *testing.T) {
	var mut sync.Mutex
	counter := 0
	var wg sync.WaitGroup // 声明 WaitGroup

	for i := 0; i < 5000; i++ {
		wg.Add(1) // 每启动一个 goroutine,WaitGroup 计数器加 1
		go func() {
			defer wg.Done()    // 当 goroutine 完成时,计数器减 1
			mut.Lock()         // 先获取锁
			defer mut.Unlock() // 确保函数退出时解锁
			counter++          // 安全递增
		}()
	}

	wg.Wait()                       // 等待所有 goroutine 完成
	t.Logf("counter = %d", counter) // 输出最终的 counter 值
}

此时运行结果正常。 WaitGroup 是 Go 语言中的一种用于并发控制的同步机制,主要用于等待一组 Goroutines 完成执行。当你有多个 Goroutines 需要同时执行并等待它们全部完成时, WaitGroup 提供了一种简单的方式来实现这一需求。

WaitGroup 主要有三个方法:

  • Add(delta int):添加或减少等待的 Goroutines 计数。参数 delta 表示增加或减少的 Goroutines 数量,通常是正数增加等待数量,负数减少等待数量。
  • Done():表示一个 Goroutine 完成了工作,通常在 Goroutine 结束时调用,等价于 Add(-1)
  • Wait():阻塞当前 Goroutine,直到 WaitGroup 计数为零,也就是所有添加的 Goroutines 都完成了工作。

WaitGroup 非常适合用于多个 Goroutines 并发执行并且需要在主程序中等待它们全部完成的场景。例如:

  • 并发下载文件后统一处理。
  • 并行处理多个任务,最终汇总结果。

这种机制在 Go 语言中非常常用,结合 Goroutines,可以极大地提高程序的并发能力。

3. CSP 并发机制

Go 语言的并发机制构建于 CSP(Communicating Sequential Processes,通信顺序进程) 模型之上。CSP 作为一种并发模型,倡导多个独立进程通过消息传递的方式进行通信,而非借助共享内存来交换数据。Go 语言借助 协程(Goroutines)和通道(Channels) 实现了这一并发机制,让并发编程变得更为简易和安全。

基本原理

我们之前已经对协程(Goroutine)有所了解,接下来着重理解一下通道(Channels)。在 Go 语言里,通道是用于实现协程之间通信的重要机制,可通过 chan 关键字进行定义。借助通道,一个协程能够发送数据,另一个协程则可以接收数据。通道可分为无缓冲通道(阻塞式)和带缓冲通道(非阻塞式)

  • 左边的图片 展示了 Goroutines(标记为“GR”)使用 无缓冲通道 进行通信的过程。在无缓冲通道中,发送和接收操作是阻塞的,意味着发送方必须等待接收方准备好接收,反之亦然。 在第一张图中,Goroutine 1 和 Goroutine 2 之间的通道是空的,表示发送或接收操作的阻塞状态。在后面的图中,Goroutines 通过 Channel 成功交换了数据。
  • 右边的图片 展示了 有缓冲通道 的情况。与无缓冲通道不同,有缓冲通道允许在不阻塞的情况下存储一定数量的数据。 每个缓冲通道都有一定数量的存储槽位(图中绿色块代表缓冲区)。发送者可以连续发送多个数据,直到缓冲区被填满。例如,在第一张图中,通道缓冲区未满,Goroutine 能继续向缓冲区发送数据。当缓冲区满时,发送方将阻塞,直到接收方消费数据。
  • 无缓冲通道:发送和接收必须同步,发送方和接收方必须同时准备好进行通信。
  • 有缓冲通道:发送方可以发送多条消息,直到缓冲区满后阻塞,接收方可以在缓冲区不为空时接收消息。

Goroutines 使用 channel 进行通信时,可以通过 <- 操作符 发送或接收数据:

  • 发送数据: ch <- value
  • 接收数据: value := <-ch

通过这种方式,Goroutines 之间通过消息传递进行同步,无需显式的锁机制。

代码体验

我们在chapter8下新建csp,写async_service_test.go如下:

package concurrency

import (
	"fmt"
	"testing"
	"time"
)

func service() string {
	time.Sleep(time.Millisecond * 50)
	return "Done"
}

func otherTask() {
	fmt.Println("working on something else")
	time.Sleep(time.Millisecond * 100)
	fmt.Println("Task is done.")
}

func TestService(t *testing.T) {
	fmt.Println(service())
	otherTask()
}

func AsyncService() chan string {
	retCh := make(chan string, 1)
	//retCh := make(chan string, 1)
	go func() {
		ret := service()
		fmt.Println("returned result.")
		retCh <- ret
		fmt.Println("service exited.")
	}()
	return retCh
}

func TestAsynService(t *testing.T) {
	retCh := AsyncService()
	otherTask()
	fmt.Println(<-retCh)
	time.Sleep(time.Second * 1)
}

  • 同步服务调用 ( serviceotherTask): 函数 service() 模拟了一个耗时的操作,使用 time.Sleep 来模拟延迟。这个函数同步返回结果。 otherTask() 是另一个任务,在 service() 完成之前启动,显示了 Go 中任务的顺序执行。在 TestService 测试函数中, service() 会首先执行,之后才执行 otherTask()。这表明在没有并发情况下的顺序执行。
  • 异步服务调用 ( AsyncService)AsyncService() 函数使用了 Goroutine 来并发执行 service()。返回值通过一个带缓冲的 Channel ( retCh) 返回给调用方。这里的 go func() 表示 Goroutine 是并发执行的; retCh <- retservice() 的结果通过 Channel 传递给主 Goroutine,从而实现异步操作。带缓冲的 Channel(大小为 1)保证了即使主 Goroutine没有及时接收,数据依然可以被发送 Goroutine 存入缓冲区。
  • 异步服务的测试 ( TestAsynService): 在 TestAsynService() 测试函数中,首先通过 AsyncService() 启动异步任务。同时, otherTask() 开始执行,展示了异步执行 service() 的同时还能执行其他任务。最后通过 <-retCh 来等待 AsyncService 的结果。

AsyncService()TestAsynService() 的设计展示了如何在执行某个任务的同时,能够处理其他任务。通过 Channel 传递结果的机制,主 Goroutine 不必等待 service() 的完成,可以同时进行其他工作,然后通过 Channel 来获取异步任务的结果。

整个并发过程没有使用任何锁,而是通过 Channels 保证了数据的安全传递,避免了共享内存的竞争问题。这符合 Go 并发模型的核心思想:“ 不要通过共享内存来通信,而要通过通信来共享内存”。

4.多路选择和超时

多路选择( select)和超时是 Go 并发编程中非常重要的特性

多路选择( select

Go 提供了 select 语句,它类似于 switch,但专门用于处理 Channel 操作。通过 select,可以在多个 Channel 操作中等待,哪个 Channel 准备好就处理哪个。 select 使得处理多个 Goroutines 和 Channel 的通信变得更加简洁和高效。 select 的基本语法:

select {
case msg1 := <-chan1:
    fmt.Println("Received", msg1)
case msg2 := <-chan2:
    fmt.Println("Received", msg2)
default:
    fmt.Println("No channel is ready")
}

  • case 语句:每个 case 都包含一个 Channel 操作。 select 会等待第一个准备好的 Channel 并执行相应的 case一旦选择了一个 case,其他的 case 将不会被执行。也就是说, select 语句每次只会执行一个 case,然后退出 select 语句。
  • default:当所有的 Channel 都没有准备好时, default 会被执行。它可以用于防止 select 阻塞。

我们在chapter8下新建multiplexing,新建multiplexing_test.go验证:

package multiplexing

import (
	"testing"
	"time"
)

func TestMultiplexing(t *testing.T) {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() {
		time.Sleep(2 * time.Second)
		ch1 <- "result from ch1"
	}()

	go func() {
		time.Sleep(1 * time.Second)
		ch2 <- "result from ch2"
	}()

	select {
	case msg1 := <-ch1:
		t.Log("msg1 := <-ch1:" + msg1)
		if msg1 != "result from ch1" {
			t.Errorf("Expected 'result from ch1', got %s", msg1)
		}
	case msg2 := <-ch2:
		t.Log("msg2 := <-ch2:" + msg2)
		if msg2 != "result from ch2" {
			t.Errorf("Expected 'result from ch2', got %s", msg2)
		}
	}
}

运行无异常,符合我们的预期。

超时机制

在并发编程中,有时我们希望设置一个操作的最大等待时间。如果超过指定时间,程序不再等待,而是进行其他操作。 Go 中的 select 可以结合 time.After() 实现超时控制

time.After(d) 返回一个 Channel, d 时间后会向该 Channel 发送一个时间值。当 select 等待时,如果其他 Channels 没有准备好,而超时时间已经到了, select 会选择执行 time.After 对应的 case

我们在chapter8下新建 timeout,新建 time_test.go验证:

package timeout

import (
	"testing"
	"time"
)

func TestTimeout(t *testing.T) {
	ch := make(chan string)

	go func() {
		time.Sleep(3 * time.Second)
		ch <- "result"
	}()

	select {
	case result := <-ch:
		t.Errorf("Expected timeout, but received %s", result)
	case <-time.After(2 * time.Second):
		// Test passes if timeout occurs
		t.Log("Test passed: Operation timed out as expected")
	}
}

这样运行结果如下,符合我们的预期:

=== RUN   TestTimeout
    time_test.go:21: Test passed: Operation timed out as expected
--- PASS: TestTimeout (2.00s)
PASS

5.channel的关闭和广播

在 Go 语言中,通道(channel)的关闭和广播是两个重要的概念。它们分别涉及如何管理通道的生命周期和如何向多个接收者发送相同的数据。

通道的关闭(Channel Closure)

通道的关闭是一种信号,表示没有更多的数据会被发送到通道。关闭通道可以用来通知接收方,表示数据流的结束。关闭通道不会影响已经存在的接收操作,但会阻止进一步的发送操作。

通道的关闭操作使用 close 函数来实现:

close(ch)

关闭通道的作用:

  • 通知接收方:关闭通道可以通知接收方数据已经发送完毕,没有更多的数据会发送到通道。
  • 防止发送:一旦通道被关闭,任何试图向通道发送数据的操作都会导致 panic。在某些情况下,关闭通道可以帮助避免死锁,因为接收方可以检测到通道是否已经关闭,从而采取适当的行动。

如何检测通道是否关闭: 接收通道的值时,可以通过两个返回值来检测通道是否关闭(ok为bool 值, true 表示正常接受,false 表示通道关闭):

msg, ok := <-ch
if !ok {
    // 通道已关闭
}

测试通道的安全关闭和终止消费者:我们在chapter8下新建closechannel,创建channelclose_consumer_termination_test.go验证通道关闭后的消费者是否能够正确地接收到所有数据并安全地退出:

package closechannel

import (
	"fmt"
	"testing"
	"time"
)

func TestChannelCloseAndConsumerTermination(t *testing.T) {
	dataCh := make(chan int)
	doneCh := make(chan struct{}) // 用于通知消费者结束

	// 启动生产者
	go func() {
		for i := 0; i < 5; i++ {
			dataCh <- i
			fmt.Printf("Produced: %d\n", i)
			time.Sleep(500 * time.Millisecond)
		}
		close(dataCh) // 关闭数据通道
		fmt.Println("Producer closed the channel")
	}()

	// 启动消费者
	go func() {
		for {
			select {
			case data, ok := <-dataCh:
				if !ok {
					// 数据通道已关闭,退出循环
					doneCh <- struct{}{}
					fmt.Println("Consumer detected channel closure")
					return
				}
				fmt.Printf("Received data: %d\n", data)
			}
		}
	}()

	// 等待消费者完成
	<-doneCh
	fmt.Println("Consumer has finished processing")
}

基本执行如下:

=== RUN   TestChannelCloseAndConsumerTermination
Produced: 0
Received data: 0
Produced: 1
Received data: 1
Received data: 2
Produced: 2
Produced: 3
Received data: 3
Produced: 4
Received data: 4
Consumer detected channel closure
Producer closed the channel
Consumer has finished processing
--- PASS: TestChannelCloseAndConsumerTermination (2.51s)
PASS
广播(Broadcast)

广播是指将一条消息同时发送给多个接收者的操作。在 Go 语言中,通常可通过以下方法来实现广播功能:

  • 利用多接收者通道:创建多个协程(goroutine),让每个协程都从同一个通道接收数据。这样,当有消息被发送到该通道时,多个协程都能获取到相同的消息。
  • 借助 sync.WaitGroup 确保处理完成:使用 sync.WaitGroup 来等待所有接收者完成消息处理。这能保证在广播操作结束后,所有接收者都已经处理好了接收到的消息。

下面,我们以一个具体的广播场景为例进行演示。在这个场景中,生产者负责将消息发送到通道,而所有消费者都能够接收到这些消息。具体实现步骤如下:

  • 生产者:由一个生产者将若干条消息发送至通道。
  • 消费者:多个消费者从该通道接收消息,并将接收到的消息输出显示。

我们在chapter8下新建broadcast,创建broadcast_test.go验证广播功能,即一个生产者发出的消息可以被所有消费者接收到:

package broadcast

import (
	"fmt"
	"sync"
	"testing"
	"time"
)

func TestSimpleBroadcast(t *testing.T) {
	dataCh := make(chan int)
	var wg sync.WaitGroup

	numConsumers := 3
	numMessages := 5

	// 启动消费者
	for i := 0; i < numConsumers; i++ {
		wg.Add(1)
		go func(consumerID int) {
			defer wg.Done()
			for data := range dataCh {
				fmt.Printf("Consumer %d received: %d\n", consumerID, data)
			}
			fmt.Printf("Consumer %d finished\n", consumerID)
		}(i)
	}

	// 启动生产者
	go func() {
		for i := 0; i < numMessages; i++ {
			dataCh <- i
			fmt.Printf("Producer broadcasted: %d\n", i)
			time.Sleep(500 * time.Millisecond)
		}
		close(dataCh)
		fmt.Println("Producer finished, channel closed")
	}()

	// 等待所有消费者完成
	wg.Wait()
}

运行结果可直观感知到:

=== RUN   TestSimpleBroadcast
Consumer 1 received: 0
Producer broadcasted: 0
Consumer 0 received: 1
Producer broadcasted: 1
Producer broadcasted: 2
Consumer 1 received: 2
Producer broadcasted: 3
Consumer 2 received: 3
Producer broadcasted: 4
Consumer 0 received: 4
Consumer 0 finished
Consumer 1 finished
Producer finished, channel closed
Consumer 2 finished
--- PASS: TestSimpleBroadcast (2.51s)
PASS

从运行结果来看,当前的输出并未达成预期的“广播”效果。理想状态下,所有消费者 都应接收到生产者广播的 每一条 消息。然而在实际结果里,每条消息似乎仅被一个消费者获取。这表明通道中的消息并未广播给所有消费者,而是由其中一个消费者进行处理。这种情况与 Go 语言通道的默认行为相符,即采用点对点的通信模式,也就是说每条消息只会被一个协程(消费者)接收。

若要实现真正意义上的广播,即让所有消费者都能接收到每条消息,我们可以采取以下两种方式:一是为每个消费者创建一个单独的通道副本;二是借助 sync.Cond 或其他高级同步机制。通过这些方法,确保每个消费者都能获取到相同的消息。

func TestImprovedBroadcast(t *testing.T) {
	numConsumers := 3
	numMessages := 5

	// 为每个消费者创建一个接收通道
	channels := make([]chan int, numConsumers)
	for i := range channels {
		channels[i] = make(chan int)
	}

	var wg sync.WaitGroup

	// 启动消费者
	for i := 0; i < numConsumers; i++ {
		wg.Add(1)
		go func(consumerID int, ch <-chan int) {
			defer wg.Done()
			for data := range ch {
				fmt.Printf("Consumer %d received: %d\n", consumerID, data)
			}
			fmt.Printf("Consumer %d finished\n", consumerID)
		}(i, channels[i])
	}

	// 启动生产者
	go func() {
		for i := 0; i < numMessages; i++ {
			fmt.Printf("Producer broadcasted: %d\n", i)
			// 将消息广播给所有消费者
			for _, ch := range channels {
				ch <- i
			}
			time.Sleep(500 * time.Millisecond)
		}
		// 关闭所有消费者通道
		for _, ch := range channels {
			close(ch)
		}
		fmt.Println("Producer finished, channels closed")
	}()

	// 等待所有消费者完成
	wg.Wait()
}

从运行结果看,确保了每个消费者都能接收到生产者广播的每一条消息。输出应类似于以下内容:

=== RUN   TestImprovedBroadcast
Producer broadcasted: 0
Consumer 1 received: 0
Consumer 2 received: 0
Consumer 0 received: 0
Producer broadcasted: 1
Consumer 2 received: 1
Consumer 0 received: 1
Consumer 1 received: 1
Producer broadcasted: 2
Consumer 2 received: 2
Consumer 0 received: 2
Consumer 1 received: 2
Producer broadcasted: 3
Consumer 2 received: 3
Consumer 0 received: 3
Consumer 1 received: 3
Producer broadcasted: 4
Consumer 0 received: 4
Consumer 2 received: 4
Consumer 1 received: 4
Producer finished, channels closed
Consumer 0 finished
Consumer 1 finished
Consumer 2 finished
--- PASS: TestImprovedBroadcast (2.51s)
PASS

通过为每个消费者创建独立的通道副本,你可以确保所有消费者都能接收到广播的所有消息。

6.简单的任务取消机制

我们实现一个简单的任务取消机制,主要依靠一个 cancelChan 来控制任务的终止。通过向该通道发送信号或者关闭通道,可以通知多个 goroutine 停止运行。这种机制与 Go 中的 context 取消机制有相似之处,但它是手动实现的,功能上略微简化。

我们在chapter8下新建simplecancel,然后写simplecancel_test.go如下:

package simplecancel

import (
	"fmt"
	"testing"
	"time"
)

func isCancelled(cancelChan chan struct{}) bool {
	select {
	case <-cancelChan:
		return true
	default:
		return false
	}
}

func cancel_1(cancelChan chan struct{}) {
	cancelChan <- struct{}{}
}

func cancel_2(cancelChan chan struct{}) {
	close(cancelChan)
}

func TestCancel(t *testing.T) {
	cancelChan := make(chan struct{}, 0)
	for i := 0; i < 5; i++ {
		go func(i int, cancelCh chan struct{}) {
			for {
				if isCancelled(cancelCh) {
					break
				}
				time.Sleep(time.Millisecond * 5)
			}
			fmt.Println(i, "Cancelled")
		}(i, cancelChan)
	}
	cancel_1(cancelChan)
	time.Sleep(time.Second * 1)
}

  • isCancelled 函数: 这个函数检查 cancelChan 是否已经关闭或是否有信号通过。通过 select 语句,非阻塞地检查通道状态。如果通道已被关闭或者已经收到信号,则返回 true,表示任务应当取消。
  • 取消机制: cancel_1--- 这个函数通过向 cancelChan 发送一个空的结构体,通知所有监听该通道的 goroutine 任务应当取消。每个 goroutine 会在 isCancelled 函数中收到此信号; cancel_2--- 这个函数通过关闭通道来通知所有监听的 goroutine 任务已取消。不同于 cancel_1close 操作会通知所有等待在该通道上的接收者,不需要发送多个信号,因此更适用于广播取消的场景。
  • goroutine 的取消逻辑:TestCancel 中启动 5 个 goroutine,每个 goroutine 不断地检查 cancelChan 来判断是否需要停止。每个 goroutine 在循环中使用 isCancelled 函数检查通道状态。如果通道被关闭或接收到信号,它们将退出循环,并打印消息确认已被取消。 time.Sleep(time.Millisecond * 5) 防止 goroutine 占用过多 CPU 资源,减少了空转等待的开销。

所以你会看到在TestCancel中使用 cancel_1时 只有一个 goroutine 能够收到取消信号,原因是因为 cancel_1(cancelChan) 只向通道发送了一个信号,而不是关闭通道,换成 cancel_2就 会广播给所有等待通道的 goroutine。

7.context与任务取消

在 Go 语言中, context 包是处理任务取消、超时控制和跨 API 边界传递请求范围数据的强大工具,特别是在并发编程和网络应用中。 context 提供了一种简洁的机制来管理多个 goroutine 之间的协作,尤其是在需要取消任务或控制超时时,它能够让程序高效响应用户请求或系统事件。

原文阅读