UCSC-Go-编程笔记-全-
UCSC Go 编程笔记(全)
001:Go语言介绍与历史 🚀

在本节课中,我们将要学习Go语言的背景、历史及其设计理念。了解一门语言的起源有助于我们更好地理解它的特性和适用场景。
概述
Go语言是一门现代编程语言,它源自C语言的血统,旨在成为C语言的现代化继任者。它由Google的团队开发,融合了C语言的简洁高效与现代语言的先进特性,如安全性和并发支持。本课程适合所有人,尤其对C程序员非常有用,但无需任何编程基础即可开始学习。
Go语言的起源与设计目标
上一节我们概述了Go语言,本节中我们来看看它的具体起源。Go语言由Google的Robert Griesemer、Rob Pike和Ken Thompson于2007年开始设计。Ken Thompson是Unix操作系统的共同设计者,而Rob Pike也是贝尔实验室Unix团队的重要成员,因此Go语言有着深厚的贝尔实验室和Unix系统传统。
Go语言的设计目标之一是改进C语言。C语言由Dennis Ritchie于1972年在贝尔实验室发明,用于编写Unix操作系统。它贴近硬件、运行高效,但存在安全性问题,例如指针的不当使用可能导致内存错误和安全漏洞。这些问题催生了C++、Java、Python等更安全的语言,但C语言因其高效和在现有系统中的深度嵌入,至今仍被广泛使用。
Go语言在保留C语言许多思想的同时,增加了现代特性。一个关键改进是通过限制指针的使用使其更安全,并引入了垃圾回收机制,从而自动、安全地处理内存分配与释放,避免了常见的内存错误。
Go语言的特点
以下是Go语言的一些核心特点:
- 简洁性:Go是一门小型语言,只有25个关键字,比C语言的27个还少,远少于现代C++的95个。这使得它易于学习和掌握。
- 并发支持:Go内置了强大的并发编程支持,其思想源自1978年Tony Hoare提出的通信顺序进程(CSP) 理论。这使得编写并行运行的程序变得更加简单。
- 现代化与实用性:它被设计为一门优雅的现代语言,既适合作为C语言的继任者,也适合作为编程初学者的第一门语言。
学习准备与课程安排
要开始用Go语言编程,你需要先为你的计算机安装Go编译器。安装文件可以在官方网站 golang.org/doc/install 免费下载。无论你使用的是macOS、Windows还是Linux系统,都能找到对应的版本。
此外,你需要一个文本编辑器来编写代码。课程演示中将使用传统的Unix编辑器vi,但任何你熟悉的编辑器都可以。
本课程的时间要求约为每周5小时,持续五周。编程需要练习和耐心,但掌握后回报丰厚。如果你已经学习过C语言,那会很有帮助,但本课程从零开始,无需任何先验知识。
历史趣闻与总结
最后,分享一个有趣的历史关联。Go语言渊源中的几位关键人物——Dennis Ritchie、Ken Thompson和Tony Hoare——都因其在计算机科学领域的杰出贡献获得了图灵奖。图灵奖被认为是计算机界的诺贝尔奖。Ritchie和Thompson因Unix系统获奖,Hoare则因CSP等理论贡献获奖。这从侧面印证了Go语言所继承的思想具有深厚而卓越的学术与实践根基。

本节课中我们一起学习了Go语言的诞生背景、设计哲学、核心特性以及学习这门语言需要做的准备。Go语言作为一门融合了历史智慧与现代需求的简洁、高效、安全的语言,为系统编程和通用软件开发提供了优秀的选择。接下来,我们将开始动手搭建环境,编写第一个Go程序。
002:你好世界


概述
在本节课中,我们将学习如何编写第一个Go程序——“Hello World”。我们将详细解析程序的结构,理解每个部分的作用,并学习如何运行和调试它。
程序结构解析
自从Kernighan和Ritchie的著作《C程序设计语言》将C语言介绍给世界以来,书中讨论的第一个程序,以及现在每个人编写的第一个程序,都是“Hello World”的某种变体。理解任何语言中的简单程序结构都很重要。
在Go语言中,这个程序写在名为 hello.go 的文件中。你需要使用自己的编辑器,我将使用VI。
以下是该程序的样子:
package main
import "fmt"
func main() {
fmt.Println("Hello World")
}
整个程序包含一个特殊的关键字 package,它声明为 package main。接着是一个 import 语句,它引用了 "fmt" 库,fmt 代表格式化。然后是函数,也就是主程序本身,执行的部分是 func main()。package、import、func 都是语言的关键字。main 后面跟着圆括号 () 和花括号 {}。在花括号内,我们有一行可执行代码 fmt.Println("Hello World")。最后是结束的花括号。这就是我们的整个程序。
深入理解
让我们更好地理解它。Go源代码通常保存在以 .go 为扩展名的文件中,这能让编译器识别它是源代码。它可以通过 go 命令运行。在我的Mac终端窗口中,我将尝试运行 go run hello.go。go 是指令,run 表示尝试将此代码转换为可执行文件并运行它。
如果你做的一切都正确,那么在你运行此程序的计算机上,你将看到打印出的“Hello World”,并且会自动添加一个新行。这就是为什么这个特殊的输出函数被称为 Println,ln 代表换行。我们还会看到其他不一定添加换行的打印函数。
逐行剖析
我喜欢通过“剖析”的方式来讨论程序,这在我早期讨论C和C++语言的书中被称为“C by dissection”和“C++ by dissection”。这是一种逐行检查并详细解释程序的方法。
我们需要理解这段代码,因为它是一种惯用模式。一旦你理解了它,你就可以编写各种其他代码。
首先,我们需要 package main。package main 表明这类文件是一个完全可运行的程序。因为在Go社区中,还有其他包含代码但不一定被运行的包,而 main 包需要一个作为执行起点的 main 函数。
下一个语句是 import "fmt"。这是库代码。fmt 代表格式化,它包含了所有用于读取输入和写入输出的必要函数。如果你是一名C程序员,你会看到这相当于 #include <stdio.h> 语句。但所有这些都消失了。我们不再使用这些 # 语句,因为它们涉及预处理器,而预处理器会导致难以解析的错误。import 是语言内置的,你可以导入任意数量的库。
然后我们有了实际的 main 函数。虽然 main 不是关键字,它是一个标识符,但它用于指示程序执行从此函数开始。func 是 function 的缩写,也是一个关键字。与C语言不同,Go的所有函数都需要这个关键字。C语言以数据类型开始,然后是 main。在C中,你会看到 int main()。最后,我们有 fmt.Println("Hello World")。Println 是一个非常灵活的函数,它可以适当地输出括号内的任何内容。这里是一个单独的字符串。但我们也可以,正如你稍后将看到的,打印变量,变量将根据其隐式类型被打印。所以我们不需要所谓的格式字符串。如果我们想要格式字符串,我们将在后面的一些代码中看到类似函数 Printf 的使用。
因此,Println 非常简单、直接、易于使用。最后,我们看到每个 func 都以右花括号结束。同时,函数体内的代码以一个左花括号开始,然后以一个右花括号结束。
注意事项
现在,我应该提到一些事情。如果你来自其他编程环境,这很容易在早期导致错误。Go社区期望你每行写一行代码,然后换到新的一行。隐式地,Go编译器在读取到行尾时,会认为那里有一个分号。这可能导致以下错误,正如我们在这里看到的:如果你写 func main(),然后像在其他语言中一样,把左花括号放在它下面,它实际上会显示为 func main();,这将导致语法错误。所以请注意这一点。当然,如果你做错了,编译器会给你错误信息。实际上,你应该尝试一下,以便熟悉编译器如何告知你任何类型的语法错误。
实际操作演示
现在,我将转向在我的计算机上实际展示代码的使用。我就在我的Mac终端窗口上,操作系统是Unix。我在一个保存本周课程的程序的目录中。我们刚刚完成的第一个程序是 hello.go,我将对它做一点小改动。
如果我想修改程序,或者只是想看看我已经有什么,我可以使用我的编辑器。所以 vi hello.go 让我进入程序。这就是那个程序。你可以看到,它基本上就是我放在程序顶部的这几行。我称它为程序1.2,名为输出,它展示了输出功能。我有 ////,正如C社区和C++社区所知,这是一种引入单行注释的方式。所以这与代码无关。它只是注释,便于我或其他人阅读和理解。同样,我们有 package main,我们导入了 fmt。
让我对此做一个修改。有时我想要不止一个东西,不止一个库。有一个方便的方法可以做到这一点。我可以用括号,然后在里面放一个或多个库。在这个例子中,只有一个,因为语言的一个有趣特性是:如果你添加了不打算使用的库,它将不会运行你的代码。它期望你只使用在该特定程序中实际有用的库代码。所以不能有无关的库在里面,这是不允许的。这仅仅是使语言比C语言世界更不容易出错,在C中你可以 #include 任意多的库,即使你不使用那个库,程序也不在乎。
如果我这样做,如果我以这种方式导入,哎呀,这将是一个错误。这很有趣,我用了错误的闭合符号。然后在这里,我没有说“Hello World”,而是说“Hello Kate”。所以有点不同。让我们继续尝试运行这个。首先,我必须退出编辑器。现在,正如我之前所说,我们运行 go run hello.go。让我们看看会发生什么。回想一下,我有一个错误。hello.go:5:1: expected string, found '}'。所以我必须修复它。我要回到我的编辑器。现在,我要修复那个错误的符号。记住,在这些编程语言中,有很多东西必须匹配。哎呀,抱歉,我修复了错误的东西。你必须非常小心,是在第5行。好的,我想我现在已经修复了,希望如此。导入部分,不,我还没修复那个,所以。这是实时演示。好的,注意当我修复它时,我得到了一些确认,因为它高亮了左花括号和右花括号。所以这是大多数编辑器的一个好功能。所以如果你把光标放在这个字符上,它会高亮另一个左花括号。好的,我看到那里也错了,所以我最好去纠正它。那必须是右花括号,现在你看到它被正确高亮了。所以我想我现在没问题了,希望如此。好的,我要重新运行 go run hello.go。它输出了“Hello Kate”。
这不是一个非常复杂的程序。希望你对这一切是如何工作的有了一些感觉,并且你应该自己动手做,包括尝试注入错误,看看会发生什么。你也可以改变输出的内容,就像我把“Hello World”改成“Hello Kate”一样。Kate是一位将帮助我的专业程序员,她也将帮助我完成这门课程。

好的。

总结


本节课中,我们一起学习了Go语言中“Hello World”程序的基本结构。我们了解了 package main、import 语句和 func main() 函数的作用,并学习了如何运行和调试简单的Go程序。通过实际操作,我们还看到了如何修改代码和导入库。掌握这个基础程序是学习Go语言的重要第一步。
003:计算圆的面积

概述
在本节课中,我们将学习如何编写一个Go语言程序来计算圆的面积。我们将看到如何从用户那里获取输入(圆的半径),在程序中使用它进行计算,并最终输出结果。这个程序将涵盖变量声明、常量定义、基本输入输出以及使用数学公式进行计算。
代码结构解析
上一节我们概述了程序的目标,本节中我们来看看实现这个目标的代码结构。
任何可执行的Go程序都必须以 package main 开头,并且包含一个 main 函数作为程序的入口点。
以下是程序的核心结构:
package main
import "fmt"
func main() {
// 程序代码将写在这里
}
package main声明这是一个可执行程序包。import "fmt"引入了格式化输入输出包,我们将使用其中的函数来打印信息和读取输入。func main()是程序开始执行的地方。
变量与常量声明
理解了程序的基本框架后,我们需要存储数据。本节我们将学习如何声明变量和常量。
在Go语言中,我们使用 var 关键字来声明变量。对于计算圆的面积,我们需要两个变量:半径和面积。我们选择使用 float32 数据类型,它表示32位精度的浮点数。如果需要更高精度或更大范围,可以使用 float64。
常量使用 const 关键字声明。圆周率 π 是一个不会改变的值,因此我们将其声明为常量。
以下是声明部分的代码:
var area, radius float32
const pi = 3.14159
var area, radius float32声明了两个float32类型的变量。const pi = 3.14159声明了一个常量pi并赋予其值。编译器会根据赋值3.14159自动推断pi为浮点类型。
获取用户输入
声明了存储数据的变量后,我们需要从用户那里获取半径的值。本节我们学习如何进行输入操作。
首先,我们需要提示用户输入。我们使用 fmt.Println 函数在屏幕上显示一条消息。然后,使用 fmt.Scanf 函数来读取用户从键盘输入的数字。&radius 表示将输入的值存储到变量 radius 的内存地址中。
以下是实现输入功能的代码:
fmt.Println("Input the radius in meters:")
fmt.Scanf("%f", &radius)
fmt.Println用于输出提示信息。fmt.Scanf("%f", &radius)中的%f指定了输入格式为浮点数,&radius将输入值存入radius变量。
进行计算与输出结果
获取到半径后,我们就可以进行计算并显示结果了。本节我们完成最后的计算和输出步骤。
计算圆的面积使用公式:面积 = π * 半径 * 半径。在Go语言中,乘法运算符是 *。计算完成后,我们使用 fmt.Printf 函数将结果格式化输出到屏幕。
以下是计算和输出的代码:
area = pi * radius * radius
fmt.Printf("Radius of %f meters, area is %f square meters\n", radius, area)
area = pi * radius * radius执行面积计算。fmt.Printf允许我们控制输出格式,%f会被后面提供的变量值(radius和area)替换。
完整程序与运行示例
现在,让我们把所有的代码片段组合起来,看看完整的程序是什么样子,并观察它的运行。
以下是完整的 circle.go 程序代码:
package main
import "fmt"
func main() {
var area, radius float32
const pi = 3.14159
fmt.Println("Input the radius in meters:")
fmt.Scanf("%f", &radius)
area = pi * radius * radius
fmt.Printf("Radius of %f meters, area is %f square meters\n", radius, area)
}
运行这个程序时,终端会等待你输入一个数字作为半径。例如,输入 1,程序会计算并输出面积约为 3.14159。
使用数学库改进程序
我们之前的程序自己定义了π的值。实际上,Go语言的标准库提供了更精确的常数。本节我们学习如何利用现有的数学库来改进程序。
我们可以导入 math 包,它包含一个预定义的、高精度的 Pi 常量。注意,从包中导出的标识符(如 Pi、Scanf)首字母都是大写的。
以下是改进后的 circle2.go 程序:
package main
import (
"fmt"
"math"
)
func main() {
var area, radius float32
fmt.Println("Input the radius in meters:")
fmt.Scanf("%f", &radius)
area = math.Pi * radius * radius
fmt.Printf("Radius of %f meters, area is %f square meters\n", radius, area)
}
- 使用
import (...)语法可以导入多个包。 math.Pi使用了math包中提供的π值,其精度高于我们之前定义的3.14159。
运行这个程序,输入相同的半径,你会得到一个更精确的结果(例如 3.141592653589793)。
关键概念回顾
本节课中我们一起学习了如何构建一个完整的Go语言程序。以下是核心要点的总结:
- 程序结构:可执行Go程序必须包含
package main和func main()。 - 变量声明:使用
var关键字,语法为var 变量名 数据类型。多个变量可以一起声明。 - 常量声明:使用
const关键字,编译器通常可以自动推断类型。 - 输入输出:
- 使用
fmt包中的函数。 fmt.Println用于输出一行信息。fmt.Scanf(“%f”, &变量名)用于读取浮点数输入。fmt.Printf用于格式化输出。
- 使用
- 计算:使用算术运算符(如
*表示乘法)进行计算。 - 使用标准库:通过
import导入标准库(如math),并使用包名.标识符(如math.Pi)的格式来使用库中的功能。 - 代码可读性:为变量选择有意义的名称,并适当添加注释,这对于编写易于理解和维护的代码至关重要。

通过这个计算圆面积的例子,你已经掌握了Go语言程序的基本组成要素:包导入、主函数、变量常量、基本IO和算术运算。这些是构建更复杂程序的基石。
004:马拉松距离转换程序


在本节课中,我们将编写一个程序,将马拉松的距离从英里和码转换为公里。这将为我们提供编写基础程序的进一步练习,同时也会涉及格式化输出和Go语言中类型转换的概念。与C语言等语言不同,Go语言要求显式地进行类型转换,这使其成为一种更安全的语言。
程序概述
我们将编写一个程序,计算马拉松距离对应的公里数。马拉松的标准距离是26英里385码。程序将声明整数类型的英里和码变量,通过显式类型转换进行计算,最终以浮点数形式输出公里数。
代码实现
以下是完整的Go程序代码:
package main
import "fmt"
func main() {
// 声明并初始化变量
var miles int32 = 26
var yards int32 = 385
// 进行类型转换和计算
var kilometers float32
kilometers = 1.60934 * (float32(miles) + float32(yards)/1760.0)
// 格式化输出结果
fmt.Printf("\nA marathon is %g kilometers.\n\n", kilometers)
}
关键概念解析
上一节我们展示了完整的程序代码,本节中我们来详细解析其中的关键部分。
1. 变量声明与类型
在Go语言中,我们需要显式声明变量的类型。在本程序中,我们使用了int32类型来存储英里和码。
var miles int32 = 26
var yards int32 = 385
int32:表示32位有符号整数,其取值范围约为±21亿。对于本例中的距离值,这个范围完全足够。- 初始化:我们在声明变量的同时赋予了初始值(26和385)。如果不进行初始化,Go语言会将其默认值设为0。
2. 显式类型转换
这是本程序的核心,也是Go语言与C语言的一个重要区别。在C语言中,编译器常常会自动进行隐式类型转换,但这可能导致意外的精度损失或错误。Go语言为了安全,要求开发者进行显式转换。
计算公里数的公式为:
公里数 = 1.60934 * (英里数 + 码数 / 1760)
由于1.60934是一个浮点数常量,而miles和yards是整数,直接运算会导致问题。特别是yards/1760在整数除法中结果将为0。因此,我们必须使用float32()将整数转换为浮点数:
kilometers = 1.60934 * (float32(miles) + float32(yards)/1760.0)
float32(miles):将整数值26转换为浮点数26.0。float32(yards)/1760.0:将整数值385转换为浮点数后与1760.0相除,得到正确的分数部分。- 如果一开始就将
miles和yards声明为float32类型,则不需要这些转换,但那样就无法演示处理混合类型时所需的转换操作。
3. 格式化输出
我们使用fmt.Printf函数进行格式化输出。
fmt.Printf("\nA marathon is %g kilometers.\n\n", kilometers)
%g:这是一个通用的浮点数格式化动词。它会根据数值的大小自动选择%f(普通小数形式,如42.19488)或%e(科学计数法形式)中更紧凑的一种来输出。对于本例的结果,它会输出为小数形式。- 注意:虽然Go语言的
fmt包与C语言的printf函数在用法上很相似,但格式化动词的细节并非完全相同。在使用时需要参考Go语言的具体文档。
程序运行与结果
现在,让我们来看看这段代码运行的结果。
编译并运行上述程序,终端将输出:
A marathon is 42.19488 kilometers.
所以,马拉松的距离大约是42.195公里。下次当你遇到跑马拉松的朋友时,就可以告诉他们这个准确的公里数了。
总结
本节课中我们一起学习了如何编写一个将英里和码转换为公里的Go程序。我们重点掌握了:
- 变量声明:如何使用
var关键字声明并初始化指定类型(如int32)的变量。 - 显式类型转换:理解Go语言要求显式类型转换的设计哲学,并学会使用
float32()等转换函数来确保混合类型运算的正确性。 - 格式化输出:使用
fmt.Printf和%g等格式化动词来控制程序的输出格式。


通过这个简单的例子,你体会到了Go语言在类型安全上的严格性,这是它成为一门可靠系统语言的重要特性之一。
005:温度转换程序

在本节课中,我们将学习编写一个经典的华氏度与摄氏度转换程序。这个程序将展示Go语言中循环结构的使用,这是编程中处理重复任务的核心概念。
循环结构简介
上一节我们介绍了程序的线性执行流程。本节中我们来看看循环。循环允许程序重复执行一段代码,直到满足特定条件。这对于处理大量数据或重复计算至关重要。
以下是循环的基本概念:
- 线性流程:程序像菜谱一样,按顺序一步步执行。
- 循环流程:程序可以根据条件(例如“尝一下汤,如果不够咸就继续加盐”)重复执行某些步骤。
在Go语言中,我们使用 for 关键字来实现循环。
程序目标与设计
我们将编写一个程序,打印从0到250华氏度(步长为10)对应的摄氏度表格。程序将展示如何使用 for 循环来迭代一系列值。
以下是程序的核心变量:
from:起始华氏温度,值为0。to:结束华氏温度,值为250。step:每次增加的步长,值为10。fahrenheit:当前正在处理的华氏温度。centigrade:计算出的对应摄氏温度。
程序逻辑是:从 fahrenheit = from 开始,只要 fahrenheit <= to,就计算对应的摄氏度并打印,然后将 fahrenheit 增加 step,重复此过程。
代码详解与Go语言特性
现在,我们来分析程序代码,并指出Go语言与C语言的一些关键区别。
程序的核心是 for 循环。其通用语法如下:
for 初始化表达式; 条件表达式; 增量表达式 {
// 循环体
}
当初始化表达式和增量表达式被省略时,就得到了 while 循环的效果。Go语言没有专门的 while 关键字,而是用 for 来实现。
在我们的温度转换程序中,循环是这样写的:
for fahrenheit := from; fahrenheit <= to; fahrenheit += step {
centigrade = (5.0 / 9.0) * (float64(fahrenheit) - 32)
fmt.Printf("%d\t%.2f\n", fahrenheit, centigrade)
}
需要注意以下几点:
- 类型转换:
fahrenheit最初是整数,但在计算公式中需要转换为浮点数float64(fahrenheit),因为摄氏度的计算涉及小数。 - 浮点数运算:公式
(5.0 / 9.0)中使用5.0和9.0确保进行浮点数除法。如果写成5/9,在Go语言中整数除法的结果会是0。 - 循环条件:
fahrenheit <= to是一个比较运算,结果为布尔值(true或false)。只要为true,循环就继续。 - 复合赋值:
fahrenheit += step是fahrenheit = fahrenheit + step的简写。 - 格式化输出:
fmt.Printf使用%d打印整数,%.2f打印保留两位小数的浮点数,\t是制表符,用于对齐表格。 - 花括号是必须的:在Go语言中,即使循环体只有一行代码,
for语句后的花括号{}也是强制要求的。这使得代码块非常清晰。
运行示例与扩展练习
运行程序后,会在终端输出一个温度对照表。例如,0华氏度约等于-17.78摄氏度。水的沸点(100摄氏度)约对应212华氏度。
为了让程序更实用,可以尝试进行以下修改:
- 修改
from、to、step的值,生成不同范围和精度的表格。 - 尝试将步长
step改为1,观察输出有何变化。 - 挑战:修改程序,使其能够从用户输入(例如使用
fmt.Scan)读取from、to、step的值,而不是在代码中写死。
总结


本节课中我们一起学习了Go语言中的 for 循环结构。我们通过编写一个经典的华氏度转摄氏度程序,理解了如何使用循环来迭代处理一系列数据。关键点包括:for 循环的语法、省略初始化/增量表达式以实现 while 循环、必要的类型转换以确保计算正确,以及Go语言在语法上(如强制使用花括号、没有 while 关键字)与C语言的一些区别。掌握循环是迈向编写强大、自动化程序的重要一步。
006:格式化


概述
在本节课中,我们将要学习Go语言中的格式化输出。格式化是输入输出操作中的重要部分,我们将重点介绍两个打印函数:Println 和 Printf。通过本课,你将学会如何控制数据的显示方式,包括整数、浮点数、字符和字符串。
打印函数简介
在Go语言中,我们主要使用 fmt 包中的 Println 和 Printf 函数进行屏幕输出。这两个函数都非常方便,但使用方式有所不同。
Println 函数较为简单,它会自动识别变量的类型并以默认格式打印,并在输出结束后自动换行。例如,如果我们有一个整数变量 x 值为55,使用 Println 打印 "X value is " 和 x,屏幕将显示 "X value is 55",并且光标会移动到新的一行。
Printf 函数则更为强大,它允许我们通过格式控制字符串来精确指定输出的格式。例如,要实现与上面相同的输出,我们需要使用 Printf 并提供一个格式字符串:"X value is %d\n"。这里的 %d 是一个格式化指令,表示以十进制整数格式打印后面的值,而 \n 则代表换行。
常用格式化指令
以下是初学者最常用的一些格式化指令。虽然Go语言提供了更多格式选项,但这些足以应对大多数初期编程需求。
整数格式化
整数格式化指令用于控制整数的显示方式,包括不同的进制。
- %d: 以十进制(base 10)格式打印整数。例如,55 会显示为
55。 - %o: 以八进制(base 8)格式打印整数。例如,55 会显示为
67。 - %x: 以十六进制(base 16)格式打印整数。例如,55 会显示为
37。 - %X: 以大写十六进制格式打印整数。
- %b: 以二进制(base 2)格式打印整数。例如,55 会显示为
110111。
浮点数格式化
浮点数格式化指令用于控制小数和科学计数法的显示。
- %f: 以标准小数格式打印浮点数。例如,12.345 会显示为
12.345000。 - %e: 以科学计数法(指数)格式打印浮点数。例如,12.345 会显示为
1.234500e+01。 - %E: 使用大写的
E表示科学计数法。 - %g: 根据数值大小,由Go语言自动选择
%f或%e格式,以生成更简洁、易读的输出。
其他类型格式化
除了数字,我们还需要格式化其他基本类型。
- %c: 打印字符(character)。例如,可以打印出大写字母
A。 - %s: 打印字符串(string)。字符串中可以包含空格和特殊字符,如换行符
\n。
格式化宽度与精度
上一节我们介绍了基本的格式化指令,本节中我们来看看如何进一步控制输出的对齐和精度。通过在格式化指令中指定宽度和精度,我们可以让输出更加整齐或精确。
指定宽度
在百分号 % 和格式字母之间插入一个数字,可以指定该值打印时所占据的最小宽度。输出通常会右对齐填充空格。
代码示例: %10d
这表示将整数打印在至少10个字符宽度的空间中。如果数字不足10位,左侧会用空格填充。
指定浮点数精度
对于浮点数,我们还可以控制显示的小数位数或有效数字位数。格式为 %宽度.精度f 或 %宽度.精度g。
- 精度对于
%f,%e,%E: 表示小数点后的位数。 - 精度对于
%g,%G: 表示总的有效数字的最大位数。
代码示例: %10.2f
这表示将浮点数打印在至少10个字符宽度的空间中,并保留两位小数。
代码示例: %10.2g
这表示将浮点数打印在至少10个字符宽度的空间中,并且总共只显示2位有效数字。
实践演示:整数格式化
让我们通过一个具体的程序来观察整数格式化的效果。以下是演示不同整数格式的代码片段。
package main
import "fmt"
func main() {
i := 55 // 声明并初始化一个整数变量
fmt.Println("Default Println:", i) // 使用 Println 默认打印
fmt.Printf("Decimal (%%d): %d\n", i) // 十进制
fmt.Printf("Width 10 (%%10d): %10d\n", i) // 宽度为10的十进制
fmt.Printf("Binary (%%b): %b\n", i) // 二进制
fmt.Printf("Octal (%%o): %o\n", i) // 八进制(小写o)
fmt.Printf("Octal (%%O): %O\n", i) // 八进制(大写O,带0o前缀)
fmt.Printf("Hex (%%x): %x\n", i) // 十六进制(小写)
}
运行上述程序,你将看到如下输出:
Default Println: 55
Decimal (%d): 55
Width 10 (%10d): 55
Binary (%b): 110111
Octal (%o): 67
Octal (%O): 0o67
Hex (%x): 37
观察输出,我们可以发现:
Println直接输出了值。%10d在数字55左侧添加了空格,使其在10个字符宽度内右对齐。- 二进制、八进制和十六进制输出的是数值55在不同进制下的表示。
- 使用
%O(大写O)时,八进制数会带有0o前缀,这使其在输出中更容易被识别。
实践演示:浮点数格式化
现在,我们来看浮点数格式化的例子。以下程序演示了如何控制浮点数的显示格式、宽度和精度。
package main
import "fmt"
func main() {
x := 3456.6789 // 声明一个浮点数
fmt.Println("Default Println:", x)
fmt.Printf("Standard float (%%f): %f\n", x)
fmt.Printf("Scientific (%%e): %e\n", x)
// 使用 %g,由Go决定格式
fmt.Printf("Auto-format width 10 (%%10g): %10g\n", x)
// 放大数值,观察 %g 的选择
largeX := x * 1000
fmt.Printf("Large number with %%g: %g\n", largeX)
// 控制有效数字位数
fmt.Printf("Width 10, 2 sig figs (%%10.2g): %10.2g\n", x)
}
运行这个程序,输出可能类似于:
Default Println: 3456.6789
Standard float (%f): 3456.678900
Scientific (%e): 3.456679e+03
Auto-format width 10 (%10g): 3456.6789
Large number with %g: 3.4566789e+06
Width 10, 2 sig figs (%10.2g): 3.5e+03
从输出中我们可以学到:
%f默认会补充小数位到6位。%e总是使用科学计数法。%g非常智能:对于x,它选择了类似%f的格式但去掉了无意义的尾随零;对于放大后的largeX,它自动切换到了科学计数法%e,因为这样更简洁。%10.2g指定了宽度为10,并且只显示2位有效数字。数值3456.6789被四舍五入为3.5,并表示为3.5e+03以适应2位有效数字的限制。
总结
本节课中我们一起学习了Go语言格式化输出的核心知识。
我们首先认识了两个基本的打印函数:简单自动的 Println 和功能强大的 Printf。接着,我们详细介绍了用于整数(%d, %b, %o, %x)、浮点数(%f, %e, %g)以及其他类型(%c, %s)的常用格式化指令。
更重要的是,我们学会了如何通过指定宽度(如 %10d)来控制输出的对齐,以及如何为浮点数指定精度(如 %.2f 控制小数位数,%.2g 控制有效数字位数)。这些技巧能帮助你的程序输出更加清晰、专业。

格式化选项有很多,不必死记硬背,在需要时可以随时查阅官方文档或笔记。随着课程的深入,我们还会看到更多用于复杂类型的格式化技巧。
007:语法标记




概述
在本节课中,我们将学习Go编程的基础知识,包括如何选择标识符、语言如何被解析,以及根据语法规则构成合法Go代码的含义。我们将从一个简单的Go程序开始,说明什么是语法上合法的Go代码。本周我们将涵盖语言的词法元素,例如标识符、关键字、表达式和字面量。我们还将讨论声明语句,这是我们为程序获取变量的方式。在本周的第二部分,我们将通过一系列示例展示如何编写一个模拟掷一个公平骰子的简单程序。这些程序将逐步增加功能,最终的程序将允许你计算掷骰子游戏(如“双骰子”)中特定点数(如7点或12点)出现的概率。
语法规则与词法分析
Go语言和自然语言一样,拥有语法规则。这些规则告诉你如何用基本符号(如字母)构成单词,然后将单词组合成句子。这样你就得到了我们所说的语法上合法的Go代码。这是编译器首先要做的事情:将你输入的符号解析成语言的词法元素,这些元素会形成关键字、标识符或表达式等。然后编译器会解析它们,检查你是否以正确的语法将它们组合在一起。
让编译器认为你的程序语法正确,并不意味着它能正确运行,但至少它能通过编译器并开始运行。我们稍后会详细讨论如何确保程序正确运行。
标识符的选择
当你选择标识符时,关键是要使其具有可读性,让人类能够理解。它对程序可读性的贡献与注释一样重要。适当的空格和缩进也同样重要。因此,Go语言有一套关于如何布局程序的完整风格指南。甚至有一个专门的程序叫 go fmt,如果你懒得自己格式化,它可以帮你完成。但我们希望你按照这些关键的风格理念来输入程序,因为它们会使程序更易读。程序易读意味着你以后能够修改它,理解你写了什么,或者让其他需要使用或与你协作的人能够理解它,尽管编译器本身并不关心标识符是否有意义。
除了具有可读性,标识符不能是关键字。你会看到一个Go关键字表,共有25个,你不能将它们用作标识符,尽管从技术上讲它们符合标识符的语法(它们基本上只是一串字母,比如单词 for)。
我们还将讨论所谓的预声明标识符。你可以重新声明它们并赋予新的含义,但你应该避免这样做。这是一个非常糟糕的主意,但我们会展示一个这样做的例子,并再次解释为什么这是一个坏主意。
一个简单的程序示例
现在,我们将看一个简单的程序,其中包含标识符、关键字和标点符号。这些都是我们的标记。让我们看看这个简单的程序。
和往常一样,程序以关键字 package 开始。package 是25个关键字之一。你给 package 一个名字 main。main 确实是一个标识符,但它是一种特殊的标识符,因为它向Go编译器表明这是一段你想要执行的代码。你可以有其他名称的包(如 fmt),但它们不会是你开始运行程序的直接入口点。main 是你开始运行程序的地方。
接着我们有关键字 import。你会看到引用的标识符 fmt。这是一个让你进行输入和输出的代码包。我们在之前的一些程序中已经见过,比如使用了 Println 和 Scanf。
在下一行,我们有一个声明。这个声明是 var MyInt int。var 是一个关键字。MyInt 是我选择的一个标识符。int 是一个预声明的标识符,意味着我们将其用作整数类型。然后我们有标点符号 =,这是另一个标记,后面跟着一个表达式 5,在这个例子中是一个字面量。这就是最初赋值给 MyInt 的值。
你可以看到在注释中,标识符名称 MyInt 被称为驼峰式命名法。驼峰式命名法是一种风格,它不是强制性的,但它是Go社区在选择多单词标识符时喜欢使用的风格规则。所以,把 MyInt 和内部的大写字母看作是分隔单词的一种方式。
关于我们在程序中的位置,那个声明是在 func main 外部的,所以我们称之为全局变量。在 func main 中,我们将使用它,并尝试一些简单的想法。我们打印全局变量 MyInt,它会显示为 5,这是它初始化的值。
但请注意,我使用了一个开大括号 {。开大括号意味着开始一个所谓的“块”,这是一个我可以声明变量的新区域。在这个块内部,我重新使用了名称 MyInt。有时这是可以的,有时因为容易混淆,这是一个坏主意。但在这里,这被称为 MyInt 的内部声明。这意味着它是独立的,与全局声明不同。因为它是内部的,它隐藏了外部的声明。在这里,var 再次是一个关键字(25个关键字之一)。int 不是关键字,int 是预声明的,所以你应该避免重新声明它。
然后我们进行了赋值 6 或初始化到 6。现在,如果我们执行 Println 打印内部变量 MyInt,我们得到 6。然后当我们遇到闭大括号 } 时,意味着我们退出了那个块。接着我们再次打印,这次打印的是全局变量,它又变成了 5。
标识符的语法规则
那么,我们在这个例子中发现了什么?我们发现我们有变量,并且我们使用标识符来命名变量。标识符遵循以下简单的语法规则。
标识符的第一个字符必须是普通的字母字符或特殊的字母字符下划线 _。你需要习惯键盘上的下划线键,它通常在键盘的右上角。下划线可以用作标识符中的一个字母。我们还会看到,单个下划线 _ 作为标识符有特殊用途,我们稍后会讨论。
在第一个字符之后,你可以有字母或数字,并且可以有很多个。所以你可以写很长的标识符。因此,很容易使它们具有可读性,在某种意义上成为你文档中有意义的一部分。同样,如果你想使用多单词的标识符,请选择驼峰式命名法。换句话说,单词内部的开始字母应该大写。
对于那些做过C编程或其他编程的人来说,有一种对应的风格叫做蛇形命名法。在蛇形命名法中,单词用下划线字符分隔。所以你们中的一些人可能从C语言世界熟悉蛇形命名法。顺便说一下,骆驼以什么闻名?它以驼峰闻名。这就是为什么你在这些标识符中间有大写字母,这就是为什么他们称之为驼峰式,而不是蛇形。蛇是地上的动物,很像下划线。
所以,这让我们可以选择创建标识符。正如我们已经说过的,你可以为第一个字符选择字母字符或下划线。之后,你可以选择数字。
其他标记与关键字
还有其他键盘符号,它们也会产生标记。它们通常用于某些标点符号,比如分号 ;,在这个语言中非常重要,因为它可以分隔语句,或者分隔 for 语句的内部部分。我们会看到使用逗号 ,,因为它们可以分隔函数的参数,或者组成列表,比如初始化值列表。我们还有其他可以使用的符号,比如加号 +、减号 - 和星号 * 用于算术运算。我们还有像感叹号 ! 这样的符号,可以用于某些逻辑运算,如“非”。所有这些都会产生标记。如果所有这些都被组装起来并且语法合法,那么编译器会说,好的,我们现在可以运行程序了。
像 main 这样的标识符有特殊用途,所以你倾向于不将其用于其他目的。而像 package 这样的关键字根本不能被重新定义。所以它们必须被使用,并且对语言的核心概念至关重要。随着我们对语言越来越深入的了解,我们最终会使用所有的关键字,包括更复杂的关键字,比如我们稍后会看到的 defer。但在语言的早期,我们使用 var、func,我们将使用 for 进行循环语句。所以每个关键字都有一个目的,你会变得非常熟悉。
标识符命名风格与注意事项
好的,所以字母字符可以包括下划线,但一般来说应该避免使用,因为第一,它们往往有特殊用途,单个下划线字符确实如此,并且通常不需要声明。有时当你使用下划线时,比如声明一个函数,这是一种风格,表示这是某个系统代码包内部的某种内部函数。所以一般来说,我们期望普通的标识符以小写字母开头,这是一种风格。
请注意,像 Q8QQ9 这样的标识符是合法的,它符合语法:Q 是一个小写字母,8 是一个数字,QQ 又是字母,9 是一个数字。所以这都是合法的,但它没有为人类读者、人类程序员提供任何意义。所以它是不可读的,应该避免,除非出于某种原因,你正在编写不希望任何人理解的代码。我猜这是可能的。你不想让人们窃取你的代码。所以也许你想出于某些特殊原因这样做,但这将是非常不寻常的。
然后我们选择在标识符 MyInt 中使用驼峰式命名法,其中我们将 I 大写。当我们打算将标识符读作多单词标识符时,就会使用驼峰式命名法。
我们将展示所有25个关键字,它们有特殊用途。如果你想更详细地理解这门语言,我为你提供了详细规定Go语言的实际文档的URL,并给出了具体位置,参考了如何创建标识符。习惯使用Go语言规范是非常值得的。我无法在这门课中涵盖所有内容,所以这是一个非常有用的资源。它是你找到语言定义方式和一切含义的官方场所。
现在,标识符被用在所有地方,它们可以命名变量、包、类型。以下是语法如何被更正式地描述的方式。
标识符的创建总是首先有一个字母,这就是为什么它不在花括号里。当你看到花括号时,它意味着“尽可能多地重复”。然后你可以选择一个字母,那个上横线是一个“或”的符号,所以你可以选择一个字母或一个Unicode数字(Unicode数字可以认为是0到9)。因此,任何内部的东西都可以是数字或字母,你可以有任意多个。这就是正式的语法。如果你想成为一名计算机科学学生,值得去了解。我不会在这里做很多关于正式语法的内容,但在过去它被称为巴科斯-诺尔范式,对于理解语言语法的合法规范很重要。一般来说,每种语言都有这样的文档,并向你展示如何符合在该语言中创建合法结构。巴科斯-诺尔范式是为了纪念两位最著名的早期计算机科学家约翰·巴科斯和彼得·诺尔而命名的,他们创造了这种给出语法规则的风格。所以正如我所说,如果你希望继续学习计算机科学,可以去维基百科或其他地方阅读巴科斯-诺尔范式是如何使用的。
我们已经看到了如何创建标识符,注意没有提到驼峰式命名法。所以驼峰式命名法只是一种风格规则,可以被违反,没有规定你必须使用它。只是当你在编写可读的Go代码时,社区期望你使用它。
关键字表
现在让我们转向关键字表。表中有25个关键字。我们将看到它们指的是不同的东西。
许多关键字指的是声明事物,比如变量或函数。所以 var 和 func 很重要。其他关键字涉及控制流。所以有一个 goto,它让你跳转到程序中的不同位置。有 if 和 else,它们让你执行所谓的条件语句。有一个 for,它让你进行循环。然后有像 continue 这样的关键字,让你改变循环内的控制流。有 return,它让你从像 func 这样的东西返回。
这些是控制流。还有一些特殊的东西,比如 import 和 package,它们让你创建和导入函数,比如当你导入 fmt 包时找到的 Printf。所有这些在语言中都有关键用途。随着我们在这门课中变得越来越深入,你将学会它们。
到目前为止,我们已经见过 func,它开始一个函数声明;见过 var,它开始创建一个变量;见过 import,它从命名包(如 fmt)获取代码。我们还将在本周的示例中看到如何使用 for 来进行一些简单的控制流和循环结构。哦,我们还使用了 const。const 表示一个变量在代码内部不能更改其值。
所有这些都有关键的概念。Go语言有25个关键字,通常比像C++这样的语言简单得多,C++在现阶段和一些最新的编译器中多达95个关键字。所以这接近Go语言关键概念数量的四倍,也使得C++更难使用和学习。
程序示例的进一步解析
让我们从第一个程序中看一些其他想法。再次强调,这个初始的全局声明是在 main 外部声明的。这意味着如果我们有一个多函数程序,假设 main 调用了其他函数,所有这些函数都可以访问那个全局变量。所以这是一种函数之间相互通信的方式,而不必传入或传出东西,因为它们都可以修改这个全局变量并在需要时使用它。再次强调,这不是我们想要依赖的想法,它有非常特殊的用途,应该谨慎使用。
Println 来自 fmt 包,这就是点表示法的含义。然后,Println 符合Go语言中的另一个特殊概念。在Go语言中,如果你在包内部将标识符大写,这意味着如果该包被导入,它可以在其他地方使用。然而,如果你选择对某个函数或变量使用小写,那么这意味着该变量对该包是私有的,不可导出。所以你会看到很多“包名.大写标识符”的形式。它们之所以大写,是因为它们符合Go语言区分公共和私有标识符的特殊需求。所以这是一个重要的概念。
我们可以重新声明名称。但这不应该是首选,因为它可能导致混淆。相同的名称意味着不同的事物,实际上有两个叫做 MyInt 的实体:一个是在内部块中创建的,另一个是存在于全局环境中的。当我们开始做更复杂的程序时,我们会看到更多这种情况,这些程序有很多层花括号,因为花括号引入了一个新的层级,一个可能的新命名空间或所谓的“块”。好的,闭大括号退出一个块。或者如果它是程序的最后一个,它匹配初始的开大括号并退出程序。当你退出内部花括号时,声明的 MyInt 就消失了。你可以试验这个想法,稍后我们会有一个更大的解释。
N.B. 代表 Nota Bene,只是“注意”的意思,它是拉丁语,意思是“当心”。它说,不要因为重新声明标识符而把自己搞糊涂。在某些地方,当你使用一些非常简单的东西并且非常清楚它们是如何被使用时,这是一种习惯用法。但一般来说,这不是一个好主意。


总结


在本节课中,我们一起学习了Go语言的基础语法和词法元素。我们了解了如何根据规则创建合法且具有可读性的标识符,区分了关键字和预声明标识符,并通过一个简单的程序示例观察了变量作用域和声明规则。我们还介绍了Go语言中重要的代码风格,如驼峰式命名法,并强调了编写可读代码的重要性。理解这些基础概念是编写正确、高效Go程序的第一步。
008:命名规范与代码风格 🚫


在本节课中,我们将要学习Go语言中标识符的命名规范与代码风格。我们将探讨什么是好的标识符,以及哪些是应该避免的“坏”实践。理解这些规范对于编写清晰、可维护且符合社区标准的代码至关重要。
上一节我们介绍了标识符的基本语法和重要性,本节中我们来看看一些具体的反面案例,以明确应该避免的命名方式。
反面案例解析
以下是一个短程序,它声明了一些变量并打印出来。我们将通过这个程序展示几种不良的命名实践。
package main
import "fmt"
func main() {
var int32 int = 7
var my_underbar_data float64 = 1.77
var QQQ_mystery float64 = 3.14
fmt.Println(int32)
fmt.Println(my_underbar_data)
fmt.Println(QQQ_mystery)
}
尽管这段代码在语法上是合法的,并且能够编译运行,但它违反了Go语言的命名规范和社区风格约定。
以下是这段代码中存在的三个主要问题:
- 重定义预定义标识符:变量名
int32是一个预定义的类型标识符(表示32位整数)。虽然Go允许这样做,但这会掩盖原有的类型含义,造成混淆,是应该避免的。 - 使用蛇形命名法:变量名
my_underbar_data使用了蛇形命名法(snake_case)。Go社区的标准是使用驼峰命名法(camelCase),因此更合适的命名应该是myUnderbarData或myData。 - 使用含义模糊的标识符:变量名
QQQ_mystery完全无法表达其用途,使得代码难以理解。好的标识符应该像area或radius一样,能够清晰地表明其代表的含义。
代码风格与格式化工具
编译器只关心语法是否正确,并不关心代码风格是否优雅。因此,即使写成下面这样混乱的格式,程序依然可以运行:
package main
import "fmt"
func main(){
var int32 int=7
var my_underbar_data float64=1.77
var QQQ_mystery float64=3.14
fmt.Println(int32)
fmt.Println(my_underbar_data)
fmt.Println(QQQ_mystery)}
然而,这种代码对人类读者极不友好。为了保持代码风格的一致性,Go语言提供了一个强大的工具:go fmt。
运行 go fmt 命令可以自动将杂乱的代码格式化为符合社区标准的样式:
- 在包声明和导入语句后添加空行。
- 在函数声明后添加空行。
- 使用制表符进行规范的缩进。
- 确保操作符周围有适当的空格。
- 将语句整理到单独的行上。
使用 go fmt 格式化后的代码不仅更美观,也极大地提升了可读性和可维护性。


本节课中我们一起学习了Go语言标识符的命名禁忌与代码风格的重要性。我们了解到,除了语法正确外,避免重定义预定义标识符、遵循驼峰命名法、使用有意义的名称以及利用 go fmt 工具保持代码整洁,都是编写高质量Go代码的关键。记住,代码不仅是给机器执行的,更是给人阅读和理解的。
009:声明与基础类型

在本节课中,我们将学习Go语言中的变量声明和基础数据类型。我们将了解如何使用var关键字声明变量,以及Go语言如何通过类型推断简化声明过程。同时,我们也会探讨不同类型之间的转换规则。
声明与类型
上一节我们介绍了程序的基本结构,本节中我们来看看如何声明变量并为其指定类型。
在Go语言中,我们使用var关键字来声明变量,每个变量都会被赋予一个类型。这些类型来自于一组预定义的标识符。虽然它们不是严格意义上的关键字,但应避免重新声明它们,以免造成混淆。
以下是部分基础类型:
intint32,int64,int8float32,float64rune
这些类型决定了变量将如何被使用。
基础类型声明示例
为了加深理解,我们将分析一个教学程序。这个程序本身没有实际用途,目的是帮助我们学习类型声明。
以下是一个main函数,其中声明了多种基础类型:
func main() {
var MyInt int
anotherInt := 36
MyFloat := 8.95
var data1, data2, data3 float32 = 0.5, 1.5, 3.5
var MData float64
var unsignedNumber uint
}
var MyInt int:这是一个标准声明,使用int类型。int类型很有趣,它让编译器根据当前机器的便利性决定使用32位还是64位。而int32或int64则明确指定了位数,两者各有用途。anotherInt := 36:这里使用了:=运算符。它会让编译器推断表达式(此处是字面量36)的类型(推断为int),并将该类型赋予变量anotherInt。这种方式无需使用var关键字或显式指定类型,依赖于编译器的类型推断,有时也被称为“鸭子类型”。MyFloat := 8.95:同理,编译器根据语言规范,会将这样的数字字面量推断为float64类型。var data1, data2, data3 float32 = 0.5, 1.5, 3.5:这条语句演示了如何一次性声明多个同类型变量并分别初始化。var MData float64:当声明变量但没有提供初始化值时,Go语言会将其自动初始化为该类型的零值(例如,数值类型为0)。这避免了像早期C语言那样变量可能包含随机“垃圾值”的问题,提高了语言的安全性。var unsignedNumber uint:uint是无符号整数类型,只能表示正数。因为它使用所有位来表示数值大小,所以在相同位数下,uint能表示的最大绝对值比int大。
类型转换与运算
在程序中,我们还会使用这些变量进行表达式运算和类型转换。
MyInt = MyInt + anotherInt // 正确,同类型相加
unsignedNumber = uint(MyInt) // 需要显式转换
unsignedNumber = uint(-MyInt) // 将负数转换为无符号整数
MData = float64(data1) + float64(data2) + float64(data3) // float32 需转换为 float64
MyInt = MyInt + anotherInt:同类型int相加,没有问题。unsignedNumber = uint(MyInt):Go语言为了安全,要求在不同类型间进行运算或赋值时必须进行显式转换(在C语言中常称为“强制类型转换”)。如果不进行转换uint(MyInt),会导致语法错误。unsignedNumber = uint(-MyInt):将一个负数转换为无符号整数,会得到一个非常大的正数。理解其原因需要了解二进制补码表示法。MData = float64(data1) + ...:float32类型在与float64类型的MData运算前,也必须显式转换为float64。
更多基础类型
我们目前只接触了部分基础类型。Go语言还提供了其他类型,以下是简要介绍:
bool:布尔类型,只有true和false两个值。complex64,complex128:复数类型,分别由两个float32或两个float64构成,表示实部和虚部。int8,int16,int32,int64:指定长度的有符号整数类型。rune:int32的同义词,通常用于表示Unicode码点,可以处理包括中文、阿拉伯文等在内的多种字符。uint8(即byte),uint16,uint32,uint64:指定长度的无符号整数类型。uintptr:一种无符号整数,用于存储指针(即内存地址)。指针从不为负,且可能很大。
程序运行与输出
运行示例程序,观察输出以验证我们的理解:
MyInt: 0
unsignedNumber: 0
MyFloat: 8.95
data1, data2, data3: 0.5, 1.5, 3.5
MyInt after addition: 36
unsignedNumber after conversion: 36
unsignedNumber after converting negative: 4294967260
MData after conversion and addition: 5.5
输出结果符合预期:
- 未显式初始化的
MyInt和unsignedNumber被自动初始化为0。 - 类型推断和初始化正常工作。
- 将负数
-36转换为无符号整数uint后,得到了一个很大的数(4294967260),这正是补码表示的结果。 - 经过转换和相加,
MData的值变为5.5。
总结


本节课中我们一起学习了Go语言的变量声明和基础类型。
- 我们掌握了使用
var关键字进行声明,以及使用:=进行简短声明并利用类型推断。 - 我们了解到Go语言会为未初始化的变量提供零值,确保了安全性。
- 我们认识到Go语言要求在不同类型间进行操作时必须进行显式类型转换,这与C语言不同。
- 我们还浏览了Go语言提供的丰富基础类型,包括整数、浮点数、布尔值、复数和用于字符的
rune类型。 - 最后,通过分析示例程序的运行结果,我们巩固了对这些概念的理解。建议初学者多编写类似的测试程序来验证和探索。
010:字面量与表达式


概述
在本节课中,我们将要学习Go语言中的字面量,以及它们如何在表达式中被使用。理解如何正确获取表达式的值至关重要。我们将从整数开始,逐步介绍字面量的类型、运算符的优先级和结合性,并通过代码示例来巩固这些概念。
字面量与类型
字面量是一个固定的值,并拥有一个关联的类型。例如,整数值 4 是一个字面量,其类型可以是 int 或 int32,具体取决于它的声明方式。在类型推断中,4 通常被推断为 int 类型。
然而,值 4.0 则是一个浮点数字面量。如果我们使用短变量声明 := 来初始化一个变量为 4.0,编译器会将其类型识别为 float64。
同样,字面量 true 的类型是 bool。每种类型都有其对应的字面量。这些字面量既可用于类型推断声明中推导类型,也可在表达式中用于创建该类型的新值,例如将两个整数相加会得到另一个整数。
表达式与运算符优先级
我们已经在一个初始程序中见过如何计算圆的面积:3.14159 * radius * radius。这里的 3.14159 是一个浮点数字面量,radius 很可能被声明为 float64 类型。整个结构构成了一个表达式。
为了理解这个表达式的结果,我们需要知道 * 运算符表示乘法,并且需要理解所谓的运算符优先级。
因此,我们需要知道如何为每种类型书写字面量,以及如何解释表达式,因为表达式同样可以用来初始化变量。
整数类型与运算
我们从整数领域开始,因为整数是我们最早理解的概念之一。在Go中,int 类型的大小通常取决于机器和编译器,常见的是32位或64位。它是我们进行需要整数的计算时通常使用的类型。
此外,还有更专门的整数类型,如 int32 或 int64。除非有特定原因(例如需要压缩数据,或者数值范围超出了 int32 的 ±20亿),我们通常不会使用它们。无符号整数类型也有类似的区分。
运算符优先级与结合性
要理解运算符如何工作,我们必须知道它们的优先级和结合性。这与普通数学中的规则类似,但在编程语言中必须形式化地理解,否则可能导致错误结果。
一个简单的优先级规则是:加法运算符 + 和 - 的优先级低于乘法、除法和取模运算符 *、/、%。这意味着在混合使用这些运算符的表达式中,乘法运算会先于加法执行。
在Go等编程语言中,结合性通常是从左到右。如果我们不希望遵循默认的优先级或结合性,可以使用括号来覆盖它们。即使对于复杂的表达式,为了清晰起见,也建议使用括号,即使不依赖括号也能通过理解优先级来正确计算。
代码示例解析
让我们通过代码来具体理解。以下是一个示例程序:
package main
import "fmt"
func main() {
var j, k int = 4, 5 // 声明并初始化整数变量 j 和 k
var i int
// 表达式 1: i = j * k / 5
i = j * k / 5
fmt.Printf("j=%d, k=%d, i=%d\n", j, k, i)
// 表达式 2: i = j*k + 99%6
i = j*k + 99%6
fmt.Printf("j=%d, k=%d, i=%d\n", j, k, i)
// 表达式 3: i = j * (k + 99) % 6
i = j * (k + 99) % 6
fmt.Printf("j=%d, k=%d, i=%d\n", j, k, i)
// 表达式 4: i = j * -k * 5
i = j * -k * 5
fmt.Printf("j=%d, k=%d, i=%d\n", j, k, i)
}
以下是代码中关键点的解析:
- 字面量:代码中的
4、5、99、6都是整数字面量。 - 表达式
i = j * k / 5:- 运算符
*和/优先级相同,结合性为从左到右。 - 因此计算顺序为:
(j * k) / 5=>(4 * 5) / 5=>20 / 5=>4。 - 所以
i被赋值为4。
- 运算符
- 取模运算符
%:- 取模运算返回除法后的余数。
99 % 6的结果是3,因为99 / 6 = 16余3。 - 这个运算符在后续的随机数生成等场景中会很有用。
- 取模运算返回除法后的余数。
- 表达式
i = j*k + 99%6:- 根据优先级,
*和%先于+计算。 - 计算顺序:
(j*k)和(99%6)先计算,然后相加。即(4*5) + (3)=>20 + 3=>23。
- 根据优先级,
- 表达式
i = j * (k + 99) % 6:- 括号
()覆盖了默认优先级,k + 99先计算。 - 计算顺序:
(k + 99)=>(5 + 99)=>104。然后j * 104=>4 * 104=>416。最后416 % 6=>2(因为416 / 6 = 69余2)。 - 这个结果与上一个表达式不同,展示了括号如何改变运算顺序。
- 括号
- 复合赋值运算符:
- 像
+=、-=这样的运算符是简写形式。例如i += expression等价于i = i + expression。它们源于C语言社区,在Go中也很常见,可以节省一些代码输入。
- 像
- 与C语言的区别:
- 在Go语言中,自增
i++和自减i--是语句,而不是表达式。这意味着你不能将它们混合在其他表达式中使用,例如k = i++在Go中是不允许的。这与C语言不同。
- 在Go语言中,自增


总结
本节课中我们一起学习了Go语言中的字面量和表达式。我们了解到字面量是具有固定值和类型的常量,并探讨了各种运算符的优先级和结合性规则。通过具体的代码示例,我们看到了括号如何改变运算顺序,以及整数除法与取模运算的特点。理解这些基础概念对于编写正确无误的Go程序至关重要。记住,当不确定运算顺序时,使用括号是保证清晰和正确的最佳实践。
011:骰子概率计算


概述
在本节课中,我们将学习如何使用Go语言模拟掷骰子,并通过蒙特卡洛方法计算特定点数出现的概率。我们将从生成单个随机数开始,逐步构建一个能够计算任意骰子点数组合概率的程序。
1. 生成单个随机骰子点数
除了学习Go语言的基本词汇,我们还需要接触一些包含重要思想的有用小程序。在接下来的四个程序中,我们将介绍伪随机数生成器的使用。
我们将展示如何用它来计算概率,具体是计算掷两个骰子时各种点数组合出现的概率。想象一下在赌场玩双骰子游戏,了解掷出7点的概率对你非常有用,因为7点在双骰子游戏中是一个非常重要的数字。
我们将以渐进的方式编写这个程序。这是一个有用的方法:我们先编写一个包含基本思想的小程序,测试并确保它能正常工作,并且我们理解它。然后我们将扩展程序,第二个程序将获得更多功能。类似地,第三个程序也会进行扩展,最后我们将编写一个最终程序,用于计算任意骰子点数的概率。
现在,我们的第一段代码非常简单。它只是从一个名为 math/rand 的库中导入我们需要的函数,以获取伪随机数生成器。
我们当然也需要 fmt 包。因此,我们将使用一个导入语句同时导入 math/rand 和 fmt,这样就不需要使用多个导入语句。
整个程序在 func main 中只是一个 fmt.Println 语句,调用 rand.Intn(6) + 1。
rand.Intn 函数的作用是生成一个伪随机数。它生成0到5之间的数字,即0、1、2、3、4、5。但回想一下,当你掷一个骰子时,你得到的数字是1到6。这就是为什么我们要加1。
这个专用函数接受一个参数。例如,如果我们传入1000,它将允许我们生成0到999之间的数字。你可以自己尝试一下。
从代码中我们可以看到,math/rand 包含了伪随机数生成器。我们已经使用过 math 库来获取常量 Pi。math 是一个非常大的库,我们只需要它的子库 rand,这让我们可以只导入需要的部分。
2. 模拟掷两个骰子并计算特定点数概率
我们已经理解了如何模拟单个骰子的随机投掷。但回想一下,我们的最终目标是获得掷一对骰子的概率值。
第三个程序将着眼于掷两个骰子。我们想要计算掷出7点的概率,这是双骰子游戏中最重要的数字。
我已经写好了代码。让我们来看看它。我们导入了三个不同的包:fmt、math/rand 和 time。
我们将有一对骰子,我们将掷这对骰子,得到掷出的值。回想一下,当我们掷真正的骰子时,每个骰子上的数字是1到6。那么我们的结果可能是什么?结果在2到12之间。但请注意,我们实际上是掷两个骰子,每个随机产生1到6。非常重要的一点是,我们不能只是简单地产生一个2到12之间的随机数。我们必须产生两个1到6之间的随机数,才能正确模拟一对骰子的投掷。
和之前一样,为了开始并获得不同的结果,我们将使用 rand.Seed(time.Now().UnixNano()) 作为种子。现在我们将使用一个非常大的投掷次数。我们将使用 for 循环语句。现在我们可以看到计算机的力量。我们将设置 for i := 0; i < 10000000; i++。这意味着我们将进行1000万次投掷,这应该足以找到一个概率。如果我只用很少的投掷次数,就像做一个实验只掷五次骰子来看7点出现的情况,那将是不准确的。投掷次数越多,所谓的蒙特卡洛计算就越准确。因此,你真的需要强大的计算机来应用蒙特卡洛技术,因为你希望一遍又一遍地重复做这件事。运行这个程序1000万次没有问题,但用真正的骰子掷1000万次并计算出7点出现的频率将是一个非常困难的任务,你会在完成实验之前就非常疲惫。所以这让你能够做到。
在循环内部,我们得到一对骰子的值,注意是 rand.Intn(6) + rand.Intn(6) + 2,而不是加1。为什么?因为我需要为每个骰子加1。否则,我会得到1到11之间的值,而不是2到12。所以这将给我2到12之间的值。然后,我测试我的这对值是否等于7。pair == 7 是一个布尔表达式。== 是布尔运算符,表示两个值是否相同。pair 等于7吗?所以我们不使用单个等号,因为那将是赋值,我们使用双等号。然后,因为它是一个 if 语句,与C语言不同,我们不需要把它放在括号里。相反,我们总是使用花括号来包含条件为真时要执行的代码,在那里我们有一个 howMany 计数器递增。
在1000万次运行之后,howMany 将告诉我们看到7点的次数。我可以打印 howMany,然后我想计算 howMany 的概率。因为 howMany 是一个整数,我希望将其计算为浮点数。所以我用 howMany 除以1000万,我应该得到一个答案。
让我们实际运行一下。我运行了1000万次,得到了7点出现1660139次。概率大约是0.1666139。如果你回想一下,这大约是六分之一的时间。所以在双骰子游戏中掷出7点,大约每六次出现一次。如果我们的模拟是正确的,并且你可以用数学证明这一点,你会发现这非常接近正确。
我们再运行一次,看看有什么不同。这次答案是1668000,上次是1666000,但仍然非常接近。是0.1668,而不是0.1666。所以仍然非常接近,这给了我们一定程度的信任。
我再运行一次,这次我得到1666702。这表明,如果我有更大的数字,我们会得到所谓的收敛。它基本上是0.1666。所以我们怀疑,从技术上讲,你得到7点的概率可能是六分之一。
3. 为随机数生成器设置种子
正如你所看到的,当我们运行那个程序时,我们总是得到数字6。嗯,这不是我们称之为随机的东西所期望的。我们不希望总是看到6。这听起来非常不随机,非常不可能,除非我们动了手脚确保每次都是6点朝上,否则这被称为不公平的骰子。
为什么会这样?我们遇到了一个有趣的小矛盾。计算机是确定性的,它总是做同样的事情。所以如果我们运行随机数生成器并从同一个地方开始,我们总是会得到相同的第一个数字。这不是我们想要的。我们如何避免这种情况?
有一个避免这种情况的标准技术。标准技术是用现实世界中可以被视为随机的东西来启动它。在这种情况下,我们将用它来启动。我们认为这是自操作系统开始运行以来的秒数。虚构地说,这被认为是1970年1月1日。所以我们现在是很多秒之后了。每当我们运行这个程序时,这个数字都会改变。这意味着,如果我们基于那个位置启动我们的随机数序列,我们每次可能会得到不同的随机数。这被称为为随机数生成器设置种子,这也是一个重要技术。如果你真的想了解更多关于这方面的知识,在《计算机程序设计艺术》中有非常详细的描述,尽管相当数学化,关于如何产生有效的随机数生成。
为了用这个特殊的时间开始,我们还需要导入第三个包。这次,我们将导入 time 包。所以现在我们将导入 fmt、math/rand 和 time。
time 将让我们使用这个花哨的东西,叫做 time.Now().UnixNano()。这意味着 Now 是 UnixNano 上的一个方法,它提取出我们处于Unix纪元以来的秒数。然后我们通过调用 rand.Seed 来设置种子。记住所有这些。它们都是大写的,因为它们是从那些包中导出的。一旦我们有了种子,我们将运行这个程序10次。这就是那个 for 语句,运行10次并打印我们认为的10次随机掷骰结果。
正如我已经说过的,time.Now().UnixNano() 计算从Unix时间(1970年1月1日)开始以来的秒数。这成为 rand.Seed 的参数。每次运行程序时它都不同,所以你应该得到不同的结果。
一个我们还没有太多关注的焦点是循环语句。看看循环语句。我们下周会详细讨论循环,因为我们将讨论改变控制流以使程序变得有趣。我们不能只有简单的线性程序。通常,当我们多次做某事时,程序非常有用。在这里,for 语句有一个 i := 0。记住 := 的作用,它初始化一个变量。所以这个变量 i 被声明为循环的局部变量。第二部分是一个测试:i < 10。这是一个布尔表达式。布尔是预声明变量类型之一。它可以是 true 或 false。当它为 false 时,循环停止。所以只要测试为 true,我们就执行循环。for 语句的最后一部分是 i++,i++ 是一个递增语句,变量 i 增加。如果 i 没有递增,循环将是无限的,i 将保持在0。这让我们达到10,10是停止循环的地方。所以这个循环让我们执行那个小的随机函数,产生掷骰子的值10次。
与C语言的不同之处在于,for 循环的写法。在C语言中,for 循环的这三个项目会被括在括号里,而在这里,它们写的时候没有括号,但我们总是用一个左花括号开始,即使是一个简单的单语句循环。在Go中总是使用花括号,而在C语言中并不总是使用。
现在我们将停止运行代码,看看我们是否每次确实得到不同的结果。
4. 计算任意点数的概率并进行输入
现在,让我做最终的计算。为了真正了解双骰子游戏,我必须知道,哦,我能得到“box cars”(两个6点,即12点)的频率是多少?或者“snake eyes”(两个1点,即2点)呢?赌徒们为这些东西起了名字,因为他们经常玩双骰子游戏。而且,如果他们非常擅长双骰子游戏,他们知道任何双骰子点数的概率。
为了找到这个,我们刚刚看到的代码中必须改变什么?在那个代码中,howMany 是针对7进行测试的。每次我们看到7时,我们就递增 howMany。所以,我想要一些可以输入的东西,而不是固定的7。我希望能够输入2、3、4、7、12等。为了使这个程序更通用、更有用,我必须允许用户向程序提供要检查的值。这就是我要做的修改。
这需要什么?回想一下,我们已经偶尔这样做过,这需要使用 scanf。让我们去看看那会是什么。这是我的程序,我称之为 diceRead.go。
所以,一切看起来都一样,除了这里。我有一个叫做 vPair 的东西。所以 vPair 将是之前硬编码为7的那个值。vPair 将是我要查找概率的骰子对的值。
这是我获取它的地方。在这一行,我有 fmt.Scanf("%d", &vPair)。为什么是 %d?因为那是整数的格式。& 是取地址运算符,这就是我们如何将值读入变量。所以它说,读入变量 vPair 的地址。然后,我的计算和之前完全一样,只是在 if 语句中,我说 if pair == vPair,而不是 if pair == 7。然后我计数 howMany,然后打印出我的答案。
我已经改变了我的程序,读入我需要的骰子对的值 vPair,然后计算概率。让我们去做这个。我将构建这个程序,而不是直接运行它,这样我可以在需要时重新运行它。程序是 diceRead.go。现在可执行文件是 diceRead。它说,读入骰子对的值。让我们读入7,因为我们已经用过7了。让我们看看它做了什么,如果我说7。和之前有些不同的数字,但它是0.1667,非常接近我们之前得到的所有其他数字。
让我再次运行这个程序。读取一个值。让我们看看“snake eyes”的值可能是多少。“snake eyes”的值是2。看看它的值低了多少:0.027877。这比7点要不可能得多,差不多差一个数量级。这有道理吗?是的。对于7点,你有很多组合:6和1、2和5、3和4。对于2点,你只有一种组合:1和1。所以你必须要得到那一种确切的组合,而不是7点所有可用的组合。这与我们直观的理解相符。特别是如果你以前玩过双骰子游戏,你会知道这个事实。
关于骰子或双骰子游戏还有一点有趣的是,6和6是另一个极端,但它也应该有一个接近“snake eyes”的概率。让我们试试那个。现在,6和6的值是12。让我们掷一下。哦,非常接近:0.0278。这正是我们期望的。我们期望它以同样的方式出现。看看这个蒙特卡洛模拟多么有价值。它给了我们数值,否则如果我们必须手工计算,将需要一些复杂的大学数学。如果我们必须通过实验来做,那会让我们筋疲力尽。
我要用这个程序尝试的最后一件事是,如果我给它一个不存在的骰子掷出值会怎样?所以我之前告诉过你,我不可能有13点的骰子掷出值,对吧?所以如果我问它得到13点骰子掷出值的概率是多少?我得到了答案,我期望是0。好的,所以我们测试了它。看起来不错。
总结


在本节课中,我们学习了一个小概念,学会了如何使用蒙特卡洛方法和随机数做一些事情,然后我们以一种让我们能够测试并确保我们理解概念的方式构建了它。看到我们如何使用 for 循环也很关键。for 循环非常强大,它是控制流中最重要的结构之一。所以我们下周将详细讨论它,届时我们将大量讨论如何在Go中构建程序。
012:布尔表达式与控制流

概述
在本节课中,我们将学习Go语言中的布尔表达式与控制流。我们将了解布尔变量的基本概念、逻辑运算符(&& 和 ||)以及关系运算符(如 ==、< 等)。这些知识是编写具有分支决策能力的非线性和有趣程序的基础。课程最后,我们将通过一个简单的程序来实践这些概念。
布尔表达式与逻辑运算符
上一节我们介绍了控制流的重要性。本节中我们来看看构成决策基础的布尔表达式。
布尔表达式是最简单的表达式之一,因为它们只求值为两个值之一:true(真)或 false(假)。这与整数类型不同,整数可以表示大量不同的值。
布尔逻辑以19世纪数学家乔治·布尔的名字命名。他有一句名言:“数学的本质不在于熟悉数和量的概念。”
我们将重点学习两个逻辑运算符:与 运算符和 或 运算符。
- 逻辑与运算符 在Go语言中由两个
&&符号表示。请注意,单个&符号是取地址运算符。 - 逻辑或运算符 在键盘上由两个
||符号表示。单个!符号是单目取反运算符。
以下是逻辑运算的真值表:
| P | Q | P && Q | P || Q |
|---|---|---|---|
| true | true | true | true |
| true | false | false | true |
| false | true | false | true |
| false | false | false | false |
与 运算的特点是:只要有一个子项为 false,整个运算结果即为 false。
或 运算的特点是:只要有一个子项为 true,整个运算结果即为 true。
只有当两个子项都为 false 时,两个运算的结果才都是 false。
短路求值
在Go语言中,逻辑运算符 && 和 || 采用 短路求值。
短路求值意味着:如果第一个参数已经能决定整个表达式的结果,则不会对第二个参数进行求值。
- 对于
||运算符:如果第一个参数为true,则整个表达式已确定为true,不会计算第二个参数。 - 对于
&&运算符:如果第一个参数为false,则整个表达式已确定为false,不会计算第二个参数。
短路求值不仅提高了效率,在某些情况下还能避免执行不必要的代码,这在编程中非常重要。
实践程序:BoolExpr.go
为了确保理解布尔表达式的求值方式,我们应该编写程序进行练习。以下是一个示例程序 BoolExpr.go:
package main
import "fmt"
func main() {
// 布尔变量默认初始化为 false
var p, q, r bool
fmt.Println(p, q, r)
// 将 p 和 q 设为 true
p = true
q = true
r = p && q
fmt.Println(p, q, r) // 输出: true true true
// 使用单目取反运算符
p = !p // p 从 true 变为 false
r = p && q // 由于 p 为 false,短路求值,r 为 false
fmt.Println(p, q, r) // 输出: false true false
// 使用或运算符
r = p || q // p 为 false,但 q 为 true,所以 r 为 true
fmt.Println(p, q, r) // 输出: false true true
// 关系运算符示例
a, b, c := 0, 1, 2
fmt.Println(a == b) // false
fmt.Println(a < c) // true
fmt.Println(c != b) // true
}
在这个程序中:
- 我们声明了三个布尔变量
p、q、r,它们默认初始化为false。 - 我们将
p和q设为true,然后计算p && q的结果。 - 我们使用
!运算符对p取反,然后再次计算p && q,观察短路求值。 - 我们计算
p || q的结果。 - 最后,我们使用整数变量演示了关系运算符(
==、<、!=),它们的结果也是布尔值。
关系运算符与运算符优先级
除了逻辑运算符,关系运算符也是改变控制流的关键。它们用于比较值,并返回布尔结果。
常见的关系运算符包括:
==(等于)!=(不等于)<(小于)<=(小于或等于)>(大于)>=(大于或等于)
当表达式中包含多个运算符时,需要了解 运算符优先级。优先级高的运算符先计算。
以下是部分运算符的优先级顺序(从高到低):
*/%<<>>&&^+-|^==!=<<=>>=&&||
这意味着,在混合使用 && 和 || 的表达式中,&& 会先于 || 被计算,就像乘法先于加法计算一样。
如果不确定优先级或为了使意图更清晰,可以始终使用括号 () 来明确指定计算顺序。
总结
本节课中我们一起学习了Go语言中布尔表达式与控制流的基础知识。我们了解了:
- 布尔类型只有
true和false两个值。 - 逻辑运算符
&&(与)和||(或)的真值表及其 短路求值 特性。 - 关系运算符用于比较并产生布尔结果。
- 运算符优先级 决定了复杂表达式的求值顺序,可以使用括号来明确顺序。


请务必修改并运行示例程序,尝试不同的组合,确保你完全理解这些运算的规则和优先级。这是掌握后续 if、for 等控制流语句的关键一步。
013:if语句的使用


在本节课中,我们将要学习Go语言中最基本的流程控制语句之一:if语句。我们将详细探讨它的语法、结构,特别是它与C语言等语言的不同之处,并通过一个示例程序来加深理解。
概述
布尔表达式通常用于控制程序流程。最基础的流程控制语句是if语句,以及更复杂的if-else语句。这些语句允许你根据特定逻辑选择不同的执行路径,从而避免线性算法,实现条件分支。
if语句的语法
if语句以关键字if开始。它的语法结构如下:
if 简单语句; 条件表达式 {
// 条件为真时执行的代码块
}
这与C语言有所不同。在Go中,if语句可以包含一个可选的简单语句,通常用于使用短变量声明(:=)初始化一个变量。这个变量的作用域仅限于这个if语句块内部。
if语句控制一个代码块,代码块由一对花括号 {} 定义,内部可以包含一条或多条语句。这个代码块被称为“真值部分”。如果条件表达式求值为true,则执行该代码块;否则,跳过它。
if语句还可以与else关键字结合使用,形成if-else结构:
if 简单语句; 条件表达式 {
// 条件为真时执行的代码块
} else {
// 条件为假时执行的代码块
}
else部分引入了“假值部分”。如果条件为false,则执行else后的代码块。else后面可以直接跟一个代码块,也可以再跟一个if语句。
关键点在于,与C语言不同,Go语言的if语句不需要用圆括号包裹条件表达式,并且可以包含一个额外的初始化语句。同时,if和else后面必须跟一个代码块,即使是单条语句。
变量作用域与隐藏
在if语句的简单语句部分声明的变量是局部变量,其作用域仅限于该if语句块(包括else块)。如果外部存在同名变量,那么在if块内部,这个局部变量会隐藏外部的同名变量。一旦程序执行离开这个if块,局部变量就会消失,外部变量重新可见。
示例程序解析
上一节我们介绍了if语句的语法和作用域规则,本节中我们来看看一个具体的示例程序,以巩固理解。
以下是演示if语句用法的程序 if.go:
package main
import "fmt"
func main() {
// 声明变量
x := 5
y := 6
i := 3 // 外部作用域的 i
fmt.Printf("x is %d, y is %d\n", x, y)
// 简单的 if-else 语句
if x < y {
fmt.Println("x is less than y")
} else {
fmt.Println("x is greater than or equal to y")
}
// 测试变量作用域:在if块内声明一个同名的 i
if i := 9; i > x { // 此处的 i 是内部变量,隐藏了外部的 i
fmt.Printf("Inner i (%d) is greater than x (%d)\n", i, x)
} else {
fmt.Printf("Inner i (%d) is NOT greater than x (%d)\n", i, x)
}
// 离开if块后,打印外部作用域的 i
fmt.Printf("Outer i is %d\n", i)
}
程序执行步骤如下:
- 声明三个整数变量
x,y, 和i(外部i)。 - 打印
x和y的值。 - 执行第一个
if-else语句:判断x < y。由于5<6为真,因此打印"x is less than y"。 - 执行第二个
if-else语句:在条件判断前,使用i := 9声明并初始化了一个新的局部变量i,它隐藏了外部的i。然后判断i > x(即9>5)。条件为真,因此打印内部i的值和比较结果。 - 离开第二个
if块后,局部变量i失效。打印语句使用的是外部作用域的i,其值仍为3。
运行该程序,输出结果如下:
x is 5, y is 6
x is less than y
Inner i (9) is greater than x (5)
Outer i is 3
输出完全符合预期,清晰地展示了条件判断和变量作用域的效果。
总结

本节课中我们一起学习了Go语言中if语句的核心用法。我们了解到if语句的语法允许包含一个初始化语句,并且条件表达式无需括号。我们重点探讨了在if块内声明的变量具有局部作用域,可以隐藏外部同名变量的特性。通过编写和运行示例程序,我们实践了如何使用if-else进行条件分支,并观察了变量在不同作用域下的行为。理解这些概念对于编写结构清晰、逻辑正确的Go程序至关重要。
014:for语句详解


在本节课中,我们将要学习Go语言中用于实现循环控制的核心结构——for语句。循环是计算机擅长重复执行任务的基础,我们将深入探讨其语法、不同形式以及实际应用。
概述
计算机擅长重复执行任务,而人类则不然。反复做同一件事非常枯燥,但计算机却乐此不疲。它们被设计成拥有循环结构,可以轻松执行重复操作,例如每分钟处理大约十亿次操作。这正是大型数据库、流程图和电子表格等能够近乎即时计算的原因。因此,我们需要一种语言结构来反映这种控制流,这种结构就是for语句。它类似于包括C语言在内的许多其他语言中的同类语句,甚至可以追溯到20世纪60年代最初的学术编程语言Algol 60。
for语句的核心思想是让计算机按照你指定的次数重复执行任务。关键在于如何指定重复的频率。
for语句的三种形式
上一节我们介绍了循环的概念,本节中我们来看看Go语言中for语句的具体形式。在Go中,for语句的控制部分可以是一个条件、一个for子句或一个range子句。Range子句需要等到我们学习名为“切片”的新数据类型时才展示,因为它直到那时才有用。现在,我们先展示条件形式和for子句形式的for语句。
1. 带for子句的for循环
这是我们之前“石头剪刀布”程序中见过的、我认为最地道和最重要的基础for语句形式。它在关键字for后面包含三个部分。
以下是for子句的组成部分:
- 初始语句:通常是一个短变量声明(例如
i := 0)。这意味着在受控的代码块内,i是一个专门声明用于迭代的局部变量。 - 条件表达式:一个布尔表达式,用于决定是否继续循环(例如
i < rounds)。该表达式会被求值为true或false。 - 后置语句:在每次循环迭代结束后执行的语句(例如
i++)。如果我们有一个索引i,通常希望迭代索引递增。如果需要倒数,也经常使用递减。
这三个部分由两个分号分隔。初始语句和后置语句都是简单语句,中间的部分始终是那个布尔表达式。
这是一个非常简单的例子:
for i := 0; i < 5; i++ {
fmt.Printf("%d 的立方是 %d\n", i, i*i*i)
}
i被声明并初始化为0。i < 5是待求值的布尔表达式。i从0开始,所以初始条件为true。后置语句是i++。在循环体(花括号内的代码块)执行完毕后,会执行后置语句。在这个例子中,我们将打印0到4的立方值,结果将是0, 1, 8, 27, 64。
2. 仅带条件的for循环(类似while循环)
这种形式在其他语言中通常被称为while循环。我们省略了初始语句和后置语句,只保留条件。当只保留条件时,不需要任何分号。
以下是这种形式的一个简单示例:
i := 0
for i < 5 {
fmt.Printf("%d 的立方是 %d\n", i, i*i*i)
i++
}
这里假设i已经被声明为变量并初始化为0。从某种意义上说,for子句中的初始语句和后置语句被移到了循环外部。后置语句i++变成了循环体内的最后一条语句。这种形式对于来自拥有while循环编程语言背景的学习者来说非常熟悉,它也是一种非常简单、基本的循环形式,非常有用。
3. 无限循环
我们将讨论的最后一种形式是无限循环。
以下是这种形式的一个非常简单示例:
for {
// 循环体代码
}
当没有条件时,for直接开始控制代码块。其隐含的假设是条件始终为true,这意味着它将永远运行。
当某个程序在你的计算机上永远运行时,必须有一种方法来停止它。虽然你可以直接拔掉电脑电源,但这有点麻烦,通常你并不想这样做。大多数控制系统,如Unix这样的操作系统,都有某种方式向机器、向操作系统发送信号来中断正在运行的程序并停止它。对于使用Unix的用户,这通常通过按Control-C来实现。
但假设在循环中,你希望在某些条件下跳出。我们可以这样做:
i := 0
for {
i++
if i > 4 {
break
}
fmt.Printf("%d 的立方是 %d\n", i, i*i*i)
}
在无限循环中,我们检查i是否大于4,如果是,我们希望停止。我们可以使用break跳出循环。记得在我们之前的讨论中,我们使用了continue。continue会跳转到循环顶部并继续下一次迭代。而break则会完全停止循环,直接跳转到循环后的下一条可执行语句。因此,如果你有一个无限循环,并且想测试某个特定条件,可以放置一个带有break的if语句,这将使你跳出循环。如果没有这个break,如前所述,虽然麻烦,但你可以使用操作系统中断(如Unix下的Control-C)来停止程序。
示例演示
现在让我演示一个相当简单的for语句程序。
在我的终端屏幕上,有一个非常简单的程序,我们称之为simple4.go。你可以查看main函数。
我们看到,我们有一个将要输入的变量n。我们将进行所谓的“倒计时”。想象我们在卡纳维拉尔角,正在进行一次大型太空飞行,我们希望我们的机器人进行倒计时。所以我们将要求用户输入倒计时的起始数字。
以下是我们传统的、非常地道的for语句,包含三个部分:
for i := n; i >= 0; i-- {
if i > 0 {
fmt.Printf("%d...\n", i)
} else {
fmt.Println("发射!")
}
}
这可能是你最常使用的形式,高度地道,学习它很重要,这也是我们重复它的原因。当然,如果你在其他编程语言中见过这个,你真正需要做的只是确保理解其语法。
我们得到这个初始语句,它是i的局部声明,被赋值为输入的初始值n。现在我们测试i是否大于或等于零。倒计时是向下计数的,直到我们达到0。然后我们执行递减语句而不是递增语句。
再次思考,你如何用模仿while的for语句形式,或者用模仿无限循环(需要使用break)的for语句形式来重写这个循环。你可以在每种情况下都做到,这应该变得非常熟悉。
这里我们将检查并打印倒计时,或者在倒计时结束时打印“发射!”。
让我运行那个程序。我将输入20。我们按预期从20、19、18...一直倒数到1,然后“发射!”。没有问题,非常简单,只需确保你理解所有这些。
总结


本节课中我们一起学习了Go语言中for语句的三种主要形式:带for子句的标准循环、仅带条件的while风格循环以及无限循环。我们了解了如何使用初始语句、条件表达式和后置语句来控制循环的迭代次数和行为,并掌握了使用break语句从循环中退出的方法。通过倒计时的示例,我们看到了for循环在实际编程中的典型应用。理解这些循环结构是掌握程序控制流的基础。
015:剪刀石头布游戏开发教程


概述
在本节课中,我们将学习如何使用Go语言开发一个完整的“剪刀石头布”游戏。我们将从基础的用户输入处理开始,逐步引入随机数生成器来模拟机器对手,并最终使用循环结构让游戏可以反复进行。通过这个项目,你将掌握条件判断、随机数生成以及循环控制等核心编程概念。
章节1:游戏基础与用户输入处理
上一节我们介绍了控制流的基本概念。本节中,我们来看看如何利用这些概念编写一个有趣的程序——剪刀石头布游戏。
剪刀石头布是一个经典游戏。玩家有三个选择:石头(R)、布(P)或剪刀(S)。游戏规则如下:
- 平局:双方选择相同。
- 剪刀赢布,布赢石头,石头赢剪刀。
我们需要使用 if 语句来判断游戏结果。现在,我们先编写处理用户输入的基础部分。
以下是处理用户输入的核心代码步骤:
- 声明变量:使用
rune类型变量存储用户的选择。rune是Go语言中用于表示字符的类型。var playerMove rune - 提示并读取输入:提示用户输入,并使用
fmt.Scanf读取一个字符。fmt.Print("Choose either R, P or S: ") fmt.Scanf("%c", &playerMove) - 验证并处理输入:使用
if-else语句检查输入是否为合法的 R、P 或 S,并回显用户的选择。对于非法输入,报告错误。if playerMove == 'R' { fmt.Println("My move is R") } else if playerMove == 'P' { fmt.Println("My move is P") } else if playerMove == 'S' { fmt.Println("My move is S") } else { fmt.Println("Illegal move") }
这个程序演示了如何从用户那里获取输入并进行基本的验证。目前,如果输入非法,程序只会报告错误并结束。在后续章节,我们将学习如何通过循环让用户重新输入。
章节2:引入机器对手
现在我们已经能够处理用户的输入。本节中,我们将为游戏添加一个机器对手,让它能够随机出拳,从而使游戏变得可玩。
为了让游戏公平且有趣,我们不希望机器总是出同样的拳。因此,我们将使用随机数生成器来让机器在石头、布和剪刀中随机选择。这模拟了一个没有固定模式的对手。
以下是引入机器对手的关键步骤:
- 导入包并初始化随机数种子:导入
math/rand和time包。使用当前时间作为随机数生成器的种子,确保每次运行程序时序列都不同。import ( "fmt" "math/rand" "time" ) func main() { rand.Seed(time.Now().UnixNano()) // ... 其他代码 } - 生成机器的出拳:使用
rand.Intn(3)生成一个 0 到 2 之间的随机整数,分别代表石头(0)、布(1)和剪刀(2)。machineMove := rand.Intn(3) // 0=Rock, 1=Paper, 2=Scissors - 判断胜负:在获取用户合法输入后,根据游戏规则,使用一系列
if语句判断机器是否获胜。// 假设 playerMove 已正确读取为 'R', 'P', 或 'S' // machineMove 为 0, 1, 2 if machineMove == 0 && playerMove == 'S' { // 石头赢剪刀 fmt.Println("The machine wins") } else if machineMove == 1 && playerMove == 'R' { // 布赢石头 fmt.Println("The machine wins") } else if machineMove == 2 && playerMove == 'P' { // 剪刀赢布 fmt.Println("The machine wins") } else { // 如果不是机器赢,则可能是玩家赢或平局 fmt.Println("You drew or won") } - 处理非法输入:如果用户输入非法,我们使用
return语句提前结束程序。后续我们将改进这一点。if playerMove != 'R' && playerMove != 'P' && playerMove != 'S' { fmt.Println("Illegal move") return // 退出程序 }
现在,程序已经能够实现用户与机器进行一轮游戏。机器会随机出拳,并根据规则判断本轮是机器获胜,还是玩家获胜/平局。
章节3:使用循环完善游戏体验
目前我们的游戏只能进行一轮。本节中,我们将利用 for 循环来完善游戏体验,实现两个关键功能:允许玩家多次游戏,以及在输入错误时提供重新输入的机会。
计算机非常擅长重复执行任务。在Go语言中,我们使用 for 循环来实现这一功能。我们将用循环包裹游戏的主要逻辑。
以下是使用循环完善游戏的两个主要改进方向:
- 允许重复游戏:通过循环,让玩家可以连续进行多轮游戏,例如10轮,并最终统计胜负和平局的次数。
- 验证输入直至合法:当用户输入非法字符时,不直接退出程序,而是提示错误并再次请求输入,直到获得合法输入为止。
一个简单的 for 循环示例,用于重复游戏10次:
for gameCount := 0; gameCount < 10; gameCount++ {
// 这里是单轮游戏的完整逻辑
// 包括:提示输入、读取并验证输入、机器出拳、判断胜负、记录结果
}
在循环体内,我们可以嵌入另一个循环来处理输入验证:
for {
fmt.Print("Choose either R, P or S: ")
fmt.Scanf("%c", &playerMove)
// 清除输入缓冲区中的换行符(如果需要)
// fmt.Scanf("%c", &dummy)
if playerMove == 'R' || playerMove == 'P' || playerMove == 'S' {
break // 输入合法,跳出这个输入循环
} else {
fmt.Println("Illegal move. Please try again.")
}
}
通过结合这两种循环,我们可以构建一个健壮且用户友好的完整游戏程序。在后续的扩展中,还可以在循环外添加变量来累计胜负平局数,并在10轮结束后打印统计信息。
总结


本节课中我们一起学习了如何使用Go语言逐步构建一个“剪刀石头布”游戏。我们从处理基础的用户输入和条件判断开始,然后引入了随机数生成器来创建机器对手,最后探讨了如何利用 for 循环来实现游戏的重复进行和输入验证。通过这些步骤,你不仅学会了控制流语句的实际应用,还掌握了如何将小功能模块组合成一个完整的小项目。
016:带循环的剪刀石头布


概述
在本节课中,我们将构建剪刀石头布游戏的最终版本。这个版本将允许用户指定与计算机对战的游戏轮数,并学习如何处理用户输入错误。我们将重点介绍Go语言中非常实用的for循环语句,并利用它来控制游戏流程。
游戏基础设置
首先,我们回顾并设置游戏的基础表示。在程序中,我们用数字代表不同的手势:石头是0,布是1,剪刀是2。同时,我们定义字符R、P、S(大写)作为用户输入的手势。
const (
rock = iota // 0
paper // 1
scissors // 2
)
const (
cRock = 'R'
cPaper = 'P'
cScissors = 'S'
)
上一节我们介绍了游戏的基本表示,本节中我们来看看如何让游戏进行多轮。
获取游戏轮数与用户输入
游戏开始前,程序会询问用户想要进行多少轮比赛。我们使用fmt.Scanf函数来获取用户输入的整数。
var rounds int
fmt.Print("How many rounds do you want to play? ")
fmt.Scanf("%d", &rounds)
这里,%d是整数的格式化符号,&rounds用于获取变量rounds的内存地址以存储输入值。
接下来,我们将进入游戏的核心循环部分。
使用for循环控制游戏轮次
计算机非常擅长重复执行任务,这正是循环语句的用武之地。我们将使用for循环来执行指定轮次的游戏。
以下是for循环的基本结构:
for i := 0; i < rounds; i++ {
// 循环体:每轮游戏的逻辑
}
这个结构包含三个部分,由分号分隔:
- 初始化语句:
i := 0。这里声明并初始化了局部变量i,其作用域仅限于这个for循环块内。 - 条件表达式:
i < rounds。这是一个布尔表达式。只要其值为true,循环就会继续执行。 - 后置语句:
i++。每次循环结束后执行,递增i的值。这是确保循环最终能结束的关键。
例如,如果用户输入10,这个循环将恰好执行10次。
处理用户输入与错误恢复
在每一轮游戏中,程序会提示用户输入手势。以下是处理流程:
- 程序提示用户选择
R、P或S。 - 使用
fmt.Scanf(“%c”, &cMove)读取单个字符。 - 将用户输入的字符转换为对应的内部数字表示(0, 1, 2)。
然而,用户可能会输入错误的字符(例如小写字母或无效字母)。以下是处理非法输入的方法:
cMove := ‘ ‘ // 假设这是读取到的用户输入
var move int
if cMove == cRock {
move = rock
} else if cMove == cPaper {
move = paper
} else if cMove == cScissors {
move = scissors
} else {
// 处理非法输入
fmt.Println(“Illegal move. Please try again.”)
i-- // 本轮不计入有效轮次
continue // 跳转回循环开始
}
关键点解释:
i--:因为用户输入非法,我们通过递减计数器i来“撤销”本轮,使其不消耗一个有效游戏轮次。continue:这是Go语言中的流程控制语句。它会立即结束当前循环体的执行,并跳转回for循环的开头,重新进行条件判断并开始下一轮迭代。这样,用户就有机会重新输入正确的手势。
通过这种方式,我们实现了友好的错误恢复机制。
计算机的“人工智能”与胜负判定
为了让游戏有趣,我们为计算机对手设计一个简单的“AI”:随机选择手势。使用随机数可以防止对手轻易猜出我们的模式。
import (
“math/rand”
“time”
)
rand.Seed(time.Now().UnixNano()) // 用当前时间初始化随机数种子,确保每次运行结果不同
machineMove := rand.Intn(3) // 生成0, 1, 2之间的随机整数
胜负判定逻辑如下:
- 平局:如果用户和计算机的手势相同。
- 计算机获胜:满足剪刀赢布、布赢石头、石头赢剪刀这三种情况之一。
- 用户获胜:如果不满足以上两种情况,则用户获胜。
以下是判定逻辑的核心代码框架:
if move == machineMove {
// 平局
draws++
} else if (move == paper && machineMove == scissors) ||
(move == rock && machineMove == paper) ||
(move == scissors && machineMove == rock) {
// 计算机获胜的三种情况
machineWins++
} else {
// 其余情况为用户获胜
userWins++
}
你可以尝试将计算机获胜的三个条件合并成一个更复杂的布尔表达式,作为练习。
运行示例与程序总结
当所有轮次结束后,程序会汇总并输出最终结果:用户获胜次数、计算机获胜次数和平局次数。
运行示例:
How many rounds do you want to play? 5
Choose your move for round 1 (R, P, S): S
Machine chose Rock. Machine wins!
Choose your move for round 2 (R, P, S): S
Machine chose Scissors. It‘s a draw!
...
Game Over!
Your wins: 1
Machine wins: 1
Draws: 3
程序优化思考:
当前计算机完全随机出招。一个有趣的改进方向是让计算机具备学习能力。例如,程序可以记录用户的历史出招偏好,如果发现用户特别偏爱某种手势(比如经常出石头),计算机就可以调整策略,更多地出布来克制。你可以思考如何实现这个功能,作为对本程序的扩展练习。
总结
本节课中我们一起学习了如何构建一个完整的、带循环的剪刀石头布游戏。我们掌握了以下核心知识:
- 使用
for i := 0; i < N; i++结构来控制固定次数的循环。 - 利用
continue语句和计数器调整(i--)来实现用户输入错误的恢复。 - 使用
math/rand包生成随机数为计算机对手创建简单的“AI”。 - 通过清晰的
if-else逻辑链来判断游戏胜负。


通过这个项目,你将循环、条件判断、输入输出和基本错误处理结合运用,完成了一个可以交互的完整命令行游戏程序。
017:switch语句


概述
在本节课中,我们将要学习Go语言中的switch语句。switch是一种多路分支语句,可以用来替代复杂的if-else语句。我们将通过一个“石头剪刀布”游戏的例子,来理解switch语句的语法、工作原理以及它如何使代码逻辑更清晰。
从if-else到switch的演变
上一节我们介绍了使用if-else语句来判断游戏胜负的逻辑。本节中我们来看看如何使用switch语句来重构它。
在之前的“石头剪刀布”程序中,我们使用if-else来判断用户和机器的出招。move代表用户的出招,machineMove是随机生成的机器出招。如果两者相同,则为平局。否则,我们需要检查所有能让机器获胜的条件。
以下是使用if-else的代码逻辑片段:
if move == machineMove {
// 平局,增加平局计数
} else if move == paper && machineMove == scissors {
// 机器获胜(剪刀剪布)
} else if move == scissors && machineMove == rock {
// 机器获胜(石头砸剪刀)
}
// ... 其他条件
else {
// 用户获胜
}
这种写法在条件较多时会变得冗长且难以维护。
switch语句基础
switch语句提供了一种多路执行的方式,可以改变程序的控制流。它主要分为两种类型:表达式switch语句和类型switch语句。本节课我们只讨论表达式switch语句。
在表达式switch语句中,会先计算switch后面的表达式,然后从上到下、从左到右依次与各个case子句进行比较。第一个匹配的case块会被执行。
一个case可以包含多个用逗号分隔的值。如果case后面需要执行多条语句,必须将它们放在一个由花括号{}组成的代码块中。
如果省略switch后面的表达式,则默认为布尔值true。
以下是switch语句的显式语法结构:
switch [简单语句;] 表达式 {
case 值1, 值2:
// 执行的语句块
case 值3:
// 执行的语句块
default:
// 当所有case都不匹配时执行
}
switch是关键字。- 可选的
简单语句(如定义一个局部变量)后跟一个分号。 表达式是被求值并用于比较的对象。case关键字后跟一个或多个值。default关键字是可选的,用于处理所有case都不匹配的情况,通常放在最后。
使用switch重构游戏逻辑
现在,让我们看看如何用switch语句来重构“石头剪刀布”的判断逻辑。这样逻辑会更简单,也更容易理解,因为它更贴合游戏只有三种基本选择的事实。
我们根据用户的出招move进行switch。对于每一种用户出招(石头、布、剪刀),我们再使用if-else来判断与机器出招的比较结果。
以下是核心逻辑的示意:
当用户出石头 (case rock):
case rock:
if machineMove == rock {
// 平局
} else if machineMove == paper {
// 机器获胜(布包石头)
} else {
// 用户获胜
}
当用户出布 (case paper):
如果机器出布,则为平局;如果机器出剪刀,则机器获胜(剪刀剪布);否则用户获胜。
当用户出剪刀 (case scissors):
如果机器出剪刀,则为平局;如果机器出石头,则机器获胜(石头砸剪刀);否则用户获胜。
与之前复杂的if-else链相比,这种结构将问题分解为三个清晰的路径,每条路径内部再进行简单的胜负判断,使得代码的流程控制更加清晰。
如果你想,还可以添加一个default分支来检查用户是否输入了无效的移动,但在这个例子中并非必需。default分支常被用来检测错误。
switch语句的其他特性
除了基本用法,switch语句还有一些需要注意的特性。
穿透执行 (fallthrough)
在Go语言的switch中,默认情况下,执行完一个匹配的case后就会跳出整个switch语句,不会自动执行下一个case。这与C语言不同。如果你需要执行下一个case的代码,必须显式使用fallthrough关键字。
初始语句
和if语句、for循环一样,switch语句也可以在表达式之前包含一个简单的初始语句(通常用于声明一个在该switch块内重要的局部变量),两者之间用分号隔开。
程序演示与运行
让我们快速运行一下这个使用switch语句的程序。程序会询问用户想要进行多少轮游戏,然后每一轮中,用户选择出招(R、P、S),机器随机出招,最后通过我们刚才讨论的switch逻辑来判断胜负并统计结果。
运行示例(用户始终出石头):
How many rounds? 10
Choose R, P or S: R
... (进行10轮)
结果:机器获胜4次,用户获胜1次,平局5次。
由于机器是随机出招,所以结果具有随机性。
总结与拓展
本节课中我们一起学习了Go语言的switch语句。我们了解了它的基本语法,如何用它来替代复杂的if-else判断,并通过“石头剪刀布”游戏实例看到了它如何让代码结构更清晰。


一个可能的课后练习是尝试改进这个程序:让机器能够分析用户的出招模式,而不是完全随机出招。例如,如果机器发现用户总是出石头,它就可以开始总是出布来赢得大多数对局。这在一定程度上体现了机器学习的思想,也是机器能在这类游戏中胜过人类的原因之一——它们能高效分析数据并避免犯错。
018:函数与切片 04_01_01


概述 📚
在本节课中,我们将要学习Go语言中两个非常重要的主题:函数和切片。函数是构建程序的基础模块,而切片是处理数据集合的强大工具。我们将从函数开始,了解如何将代码组织成可重用的逻辑单元。
函数:编程中的段落 📝
函数在编程中的作用,类似于文章中的段落。如果你能写好一个段落,你就能写好一篇文章。同样,如果你能写好一个函数,你就能掌握编写任何代码的能力。
大型的编码项目会被分解成多个部分。每个部分负责处理一个特定的、通常是简单的任务,这个任务就可以被编写成一个函数。
我遵循的一个经验法则是:任何函数的长度都不应超过一屏(大约20行)。如果你的函数开始超过20行,就应该考虑将其拆分成更小的函数。这有助于管理代码的复杂度。
此外,函数允许你重用代码。这是一个非常重要的软件工具特性。就像Go系统提供的Println或Printf函数一样,它们被整个社区持续地、重复地使用。如果每个人都自己编写打印函数,那将是非常糟糕的结果。因此,尽可能使用通用的、经过充分测试的代码,可以节省大量精力并避免错误。
将代码转换为函数 🔄
还记得在第一周,我们编写了将马拉松距离从码和英里转换为公里的代码。现在,我们将把那段代码转换成一个函数。
以下是我们定义的函数,它非常简单:
func convertToKm(miles int, yards int) float64 {
return 1.609 * (float64(miles) + float64(yards)/1760.0)
}
我们来分析一下这个函数的语法:
- 使用关键字
func开始。 - 函数名是
convertToKm。函数名非常重要,我们使用驼峰命名法,并将新单词的首字母大写。 - 参数列表在括号内:
(miles int, yards int)。 - 参数列表后面是返回类型
float64。这个例子中返回类型是未命名的,但也可以命名。 - 大括号
{}内是函数体,包含要执行的代码。这里只是一个简单的数学转换公式。
如何使用函数 📞
我们总是在 func main() 中开始执行程序。下面是如何使用 convertToKm 函数的例子:
func main() {
var miles, yards int
for i := 0; i < 10; i++ {
fmt.Print("Enter miles and yards: ")
fmt.Scan(&miles, &yards)
if miles < 0 {
return // 结束程序
}
fmt.Printf("%d miles %d yards = %.3f km\n", miles, yards, convertToKm(miles, yards))
}
}
在上面的 main 函数中:
- 我们声明了两个变量
miles和yards。 - 使用一个循环,以便可以多次进行转换。
- 提示用户输入两个整数值。
- 如果输入的英里数为负数,则结束程序。
- 否则,在
Printf语句中,我们使用%f或%g来格式化浮点数输出。 - 调用函数的方式是直接使用其名称
convertToKm,并传入当前的参数miles和yards。
需要注意的是,main 函数中的变量名 miles 和 yards 与 convertToKm 函数中的参数名相同,但这只是为了方便。它们是不同的上下文,属于按值传递的变量。这意味着传递给函数的是这些值的副本,就像在C语言中一样。
函数的更多特性 ✨
一个简单的函数语法是:func 关键字,后跟参数列表,再跟可选的返回结果,最后是代码块。
函数可以返回多个值,这是Go语言一个有趣且强大的特性。例如,在C语言中,函数只能返回一个值,如果要返回多个值,需要使用特殊的数据结构。而在Go中,可以直接返回。
下面是一个返回单个值的简单函数示例:
func square(n int) int {
return n * n
}
这个函数名为 square,接收一个 int 型参数 n,返回 n * n 的结果,类型也是 int。如果传入 3,将返回 9。
总结 🎯
本节课中我们一起学习了Go语言中函数的基础知识。我们了解到函数是组织代码、管理复杂度和实现代码复用的核心工具。我们通过将距离转换的代码重构为 convertToKm 函数,实践了如何定义和调用函数,包括函数的命名、参数、返回类型以及按值传递的特性。

记住,将大型任务分解为小而专注的函数,是编写清晰、可维护代码的关键。在下一节中,我们将探讨另一个核心概念——切片,它是Go语言中处理序列化数据的强大方式。
019:数组与切片


概述
在本节课中,我们将要学习Go语言中的两种重要数据结构:数组和切片。我们将了解它们的定义、创建方式、特性以及在实际编程中的应用,特别是切片如何提供比数组更灵活的动态处理能力。
数组:静态的同构数据结构
数组是一种同构数据结构,允许你存储相同类型的元素。例如,在加州大学的海象研究项目中,你可能需要存储500只海象的体重数据,这些数据就可以存储在一个数组中。
在Go语言中,创建数组的方式类似于声明普通变量。你需要指定数组的名称、长度和元素类型。
var data [5]float64
上述代码创建了一个名为data的数组,它可以容纳5个float64类型的元素。如果不对数组进行初始化,Go语言会将其所有元素设置为对应类型的零值(对于数值类型是0,对于布尔类型是false,对于字符串是"")。数组的索引从0开始,因此这个数组的元素是data[0]、data[1]、data[2]、data[3]和data[4]。
另一种声明数组的方法是使用初始化列表。
data := [5]float64{0, 2.81, 32.56, 9}
这种方式可以直接为数组元素赋予非零的初始值。
然而,Go语言中的数组是静态的,其长度是类型的一部分。这意味着一个长度为10的整数数组[10]int和一个长度为5的整数数组[5]int是两种不同的类型,不能直接混合使用,有时甚至需要进行类型转换,这带来了不便。
切片:动态灵活的替代方案
由于数组的静态特性限制了其灵活性,Go语言更常用的是切片。切片是动态的,其长度不是类型的一部分,因此使用起来更加灵活。
切片的声明方式与数组类似,但在方括号内不指定长度。
var data []float64
你也可以在声明时直接初始化切片。
data := []float64{0, 2.81, 32.56, 9}
这段代码会自动创建一个包含5个元素的切片。
使用切片:一个里程转换的例子
为了更好地理解切片,让我们修改之前的里程转换程序,使用切片来处理多个值。
以下是程序的核心部分:
func convertMilesToYards(miles float64) float64 {
return miles * 1760.0
}
func main() {
// 定义两个切片
miles := []float64{1.0, 26.385, 100.0} // 长度len=3,容量cap=3
yards := make([]float64, len(miles)) // 创建一个与miles等长的切片
// 使用for循环和range迭代切片
for i := range miles {
yards[i] = convertMilesToYards(miles[i])
}
// 打印结果
for i := range miles {
fmt.Printf("%.2f 英里 = %.2f 码\n", miles[i], yards[i])
}
}
在这个例子中,我们定义了两个切片:miles和yards。miles切片在声明时初始化了三个值。我们使用内置的make函数创建了yards切片,并指定其初始长度与miles相同。
我们使用for循环和range关键字来迭代miles切片。range会返回两个值:当前元素的索引和值。如果我们不需要使用索引或值,可以使用下划线_将其丢弃。
for i, _ := range miles {
// 仅使用索引i
}
// 或
for _, value := range miles {
// 仅使用值value
}
切片的关键操作
切片是动态的,这意味着我们可以在程序运行时改变它的大小。以下是几个关键的内置函数:
-
make:用于创建具有指定长度和容量的切片。data := make([]int, 100) // 创建一个长度为100的整数切片 -
len:返回切片的当前长度。length := len(data) // length 为 100 -
cap:返回切片的容量(底层数组的大小)。容量可能大于长度,以便为未来的扩展预留空间。capacity := cap(data) -
append:这是扩展切片最重要的函数。它向切片的末尾添加一个或多个新元素,并返回一个新的切片。必须将append的结果重新赋值给原切片变量。data = append(data, 1, 2, 3, 4) // 在data末尾添加四个元素执行后,
data的长度变为104。Go运行时可能会为了效率而将容量扩展得比新长度更大(例如,从100翻倍到200),以避免每次添加元素时都重新分配内存。
总结
本节课中我们一起学习了Go语言中的数组和切片。
- 数组是静态的、长度固定的同构集合,其长度是类型的一部分,因此在实际应用中较少直接使用。
- 切片是基于数组的、动态的、灵活的视图,是Go中最常用的集合类型。
- 我们学习了如何使用
make创建切片,以及如何使用len、cap和append来操作切片。 - 我们重点介绍了
for循环与range关键字结合使用来安全、便捷地迭代切片,这可以避免常见的“差一错误”。 - 我们还了解了如何使用下划线
_来丢弃在迭代中不需要的返回值。


理解切片及其动态特性,是掌握Go语言高效处理数据集合的关键。
020:查找最小值


概述
在本节课中,我们将学习如何结合之前学到的知识,解决一个简单的“排序”类问题——查找一组浮点数中的最小值。我们将学习如何创建和处理切片,如何编写函数来查找切片中的最小值和最大值,以及如何测量代码的运行时间。
切片与函数参数
上一节我们介绍了循环和迭代器,本节中我们来看看如何将切片作为参数传递给函数。
在Go语言中,我们可以将切片传递给函数,这样函数就能处理任意大小的数据集合。传递切片时,使用空方括号 [] 来表示,这与指定长度的数组不同。
以下是定义接收切片参数的函数语法:
func functionName(variableName []type) returnType {
// 函数体
}
例如,一个接收浮点数切片并返回最小值的函数可以这样定义:
func minSlice(ds []float64) float64 {
// 查找最小值的逻辑
}
实现查找最小值的函数
现在,我们来实现查找切片中最小值的函数。其核心思想是遍历切片中的每个元素,并通过比较来更新当前找到的最小值。
我们将使用 for range 循环进行遍历。这次我们不需要索引值,所以使用下划线 _ 将其忽略,只获取元素值 v。
以下是查找最小值的完整函数实现:
func minSlice(ds []float64) float64 {
// 将初始最小值设为一个非常大的数
min := math.MaxFloat64
// 遍历切片中的每个值
for _, v := range ds {
// 如果当前值小于已知的最小值,则更新最小值
if v < min {
min = v
}
}
// 返回找到的最小值
return min
}
这个函数模拟了一个“竞赛”:我们从一个非常大的初始值开始,然后遍历整个切片,每当发现更小的值,就更新最小值。遍历结束后,返回最终的最小值。
生成随机测试数据
为了测试我们的函数,我们需要一些数据。我们将使用随机数生成器来模拟真实世界的数据集。
以下是生成随机数据切片的主要步骤:
- 使用
make函数根据用户指定的大小创建一个切片。 - 使用循环为切片的每个位置生成一个介于0到1000之间的随机浮点数。
以下是 main 函数中生成数据的代码:
func main() {
// 初始化随机数种子,确保每次运行结果不同
rand.Seed(time.Now().UnixNano())
// 假设从用户输入获取切片大小
sliceSize := 100000
// 使用make创建指定大小的切片
data := make([]float64, sliceSize)
// 用随机数填充切片
for i := 0; i < sliceSize; i++ {
// rand.Float64() 生成 [0.0, 1.0) 的随机数,乘以1000得到 [0, 1000) 的数
data[i] = rand.Float64() * 1000.0
}
// 调用函数并打印结果
result := minSlice(data)
fmt.Println("最小值是:", result)
}
使用 make 函数是因为我们在编写代码时并不知道切片的具体大小,它允许我们在运行时动态创建指定大小的切片。
扩展:同时查找最小值和最大值
基于查找最小值的逻辑,我们可以轻松地扩展代码来同时查找最大值。其原理与查找最小值完全相同,只是比较的方向相反。
以下是查找最大值的函数实现:
func maxSlice(ds []float64) float64 {
// 将初始最大值设为一个非常小的数(或0)
max := 0.0
// 遍历切片中的每个值
for _, v := range ds {
// 如果当前值大于已知的最大值,则更新最大值
if v > max {
max = v
}
}
// 返回找到的最大值
return max
}
注意:这里我们将初始 max 设为 0.0。因为我们知道随机数范围在0到1000之间,所以0肯定会被第一个元素替换掉。另一种常见的做法是将初始值设为切片的第一个元素 ds[0]。
这两个函数展示了处理切片的通用模式:使用迭代器遍历,传递切片作为参数,然后返回所需的结果。你应该掌握这种方法,以便处理任何类型的切片。
测量代码运行时间
在处理大量数据时,了解代码的运行效率非常重要。Go语言的 time 包可以帮助我们测量一段代码的执行耗时。
以下是测量函数运行时间的方法:
- 在代码段开始前记录当前时间(
start := time.Now())。 - 执行需要测量的代码。
- 代码段结束后,计算当前时间与开始时间的差值(
duration := time.Since(start))。
我们将这个逻辑应用到查找最小值和最大值的调用中:
func main() {
// ... (数据准备代码与之前相同)
// 开始计时
startTime := time.Now()
// 执行查找操作
minValue := minSlice(data)
maxValue := maxSlice(data)
// 计算耗时
runningTime := time.Since(startTime)
fmt.Printf("最小值: %f\n", minValue)
fmt.Printf("最大值: %f\n", maxValue)
fmt.Printf("运行时间: %v\n", runningTime)
}
通过这种方式,我们可以直观地看到,处理更大规模的数据集(例如从10万增加到1000万)会线性地增加运行时间。这引出了算法复杂度中“线性时间”的概念,即运行时间与输入数据规模成正比。
总结
本节课中我们一起学习了几个重要的Go语言概念和编程技巧:
- 传递切片给函数:学会了如何使用
[]type语法将切片作为函数参数传递,使函数能处理动态大小的数据。 - 实现查找算法:编写了
minSlice和maxSlice函数,通过遍历和比较来查找切片中的最小值和最大值。 - 生成随机数据:使用
make函数和math/rand包创建并填充用于测试的随机数据切片。 - 测量性能:利用
time包测量代码执行时间,并观察了算法运行时间与数据规模之间的线性关系。


这些是构建更复杂程序的基础。你可以尝试修改程序,例如查找平均值或中位数,来进一步巩固对这些概念的理解。
021:简单递归


概述
在本节课中,我们将要学习Go语言中一个重要的编程概念:递归。我们将通过计算阶乘的例子,来理解递归函数是如何工作的,以及在使用时需要注意的边界条件和潜在问题。
函数是封装例程的关键方式
函数是现代编程中封装例程的关键方式。现代编程语言一个非常好的特性是函数可以递归调用。这意味着函数可以调用自身。
如果你有数学背景,可能会熟悉某些被称为归纳定义的定义方式。例如,阶乘就是根据其自身来定义的。
阶乘的递归定义
阶乘的递归定义如下:n的阶乘等于n-1的阶乘乘以n。用公式表示就是:
factorial(n) = factorial(n-1) * n
例如,计算3的阶乘,结果是1 * 2 * 3 = 6。这也可以看作是1 * 2(即2的阶乘)再乘以3。
我们还需要定义递归的基准情况。当n等于1时,阶乘就是1。否则,就按照上述递归公式计算。
在数学上,这也可以被视为一种归纳论证。我们可以在Go语言中实现这个逻辑。
Go语言中的递归函数
Go语言允许递归函数。这里有一个历史背景:在编程的早期,例如IBM在20世纪50年代发明的Fortran语言,是不允许函数递归调用的。后来,国际计算机科学家团队将递归引入了Algol 60语言,此后递归才成为计算机语言的常见特性。
以下是我们在Go中定义阶乘函数的方法。它将接收一个整数参数。由于阶乘增长非常快,我们将使用一个非常大的整数类型(64位整数)作为返回值。
函数定义遵循归纳逻辑:如果n等于1,则返回1。否则,它调用自身,计算n * factorial(n-1)。
这个递归不会无限运行下去,因为每次调用参数都会减1。当参数变为1时,递归停止并返回结果。如果我们犯了一个错误,写成return n * factorial(n),那么函数将无限递归,最终导致程序崩溃。
通常,我们使用正整数调用此函数。由于测试条件的存在,负整数也会返回1。
递归与循环
这个程序也可以写成一个简单的for循环。实际上,许多递归几乎总是可以重写为某种循环。
在某些情况下,这一点非常重要,因为递归涉及函数调用,而函数调用在计算机运行时间上通常比迭代(循环)开销更大。不过,编译器通常会优化递归,使其等效于迭代。
因此,你应该选择能让你最简单表达函数逻辑的方式。正如我们所说,阶乘很适合用递归来定义。
整数溢出问题
由于阶乘增长极快,我们通常不希望计算超过20的阶乘。因为超过20,就会发生所谓的整数溢出。即使使用64位整数,也无法正确存储那么大的数字。
我们可以允许用户输入一个正整数进行计算。程序会询问用户想要计算哪个数的阶乘,然后使用格式化输出计算结果。
需要注意的是,阶乘增长太快,会导致溢出。对于普通的32位整数,在计算13的阶乘后就会溢出。
实际运行示例
让我们实际运行一下这个程序,看看会发生什么。假设我们已经编译好了一个名为factorial的可执行文件。
程序会提示:“输入一个正整数来计算其阶乘。”
首先,我们测试3,因为我们知道结果是6。这是调试过程的一部分,确保在已知案例上程序运行正确。
程序再次询问。我们尝试一个稍复杂的数字12。我们得到了一个相当大的数字,它增长得非常快,呈指数级增长。12的阶乘是479,001,600。
接下来尝试20。对于20,我们仍然能得到正确结果,这是一个非常大的数字,大约是64位整数能表示的最大值之一。
但是,如果我们尝试更大的数字,比如25,就会得到错误的结果。25的阶乘应该是20 * 21 * 22 * 23 * 24 * 25,这个数字会大得多。但屏幕上显示的数字位数和之前差不多,这显然是错误的结果。这就是整数溢出,计算机无法正确计算这个结果。
即使尝试更大的数字,我们也会看到这些非常大的数字,但它们完全不对应正确的值。正确的数字位数应该多得多。
关于计算的思考
对于这类固定计算,必须记住你的计算机并不等同于可以在纸上进行的数学运算。在纸上,只要有足够的空间,你就能算出数字。虽然对人来说这很繁琐,但理论上可以做到。
为了计算这类大数,你需要能够表示任意大的整数。有一些专门的包可以实现这一点。你可以将数字的每一位存储在一个切片中,并编写专门的算法来处理大数运算。当然,编写和解释这样的包会比较复杂。


总结
本节课中,我们一起学习了Go语言中的递归函数。我们通过阶乘的例子,理解了递归的基本原理、基准条件的重要性,以及如何用代码实现递归逻辑。同时,我们也探讨了递归与循环的关系,并重点指出了在处理快速增长的计算(如大数阶乘)时可能遇到的整数溢出问题。理解这些概念对于编写正确、高效的Go程序至关重要。
022:映射与字符串


概述
在本节课中,我们将要学习Go语言中的两个重要概念:映射和字符串。映射是一种高效的数据结构,用于存储键值对;字符串则是我们编程中处理文本的基础。我们将通过一个电话簿的例子来理解映射的工作原理,并简要介绍字符串的特性。
映射:高效的键值查找
上一节我们介绍了切片,本节中我们来看看另一种复杂的数据结构:映射。映射在Go语言中用于关联一个键和一个值,类似于其他语言中的关联数组或字典。它的核心思想是,你可以通过一个“键”来快速查找对应的“值”。
映射的基本概念
映射基于计算机科学中的哈希表实现。哈希表提供了一种非常高效的方式,让你能根据一个通用的键(而不仅仅是整数索引)来查找数据。其核心是一个哈希函数,该函数将键的类型转换为索引类型,从而在内部存储数据的切片中快速定位。
以下是创建一个映射的基本语法:
phoneBook := make(map[string]uint64)
在这个例子中,键的类型是string,值的类型是uint64。
构建一个电话簿示例
让我们通过一个简单的电话簿程序来理解映射的用法。我们将人名(字符串)与电话号码(无符号64位整数)关联起来。
以下是构建和填充映射的步骤:
- 定义数据:首先,我们准备好姓名和电话号码的切片。
- 创建映射:使用
make函数创建一个空的映射。 - 填充映射:通过循环,将每个姓名作为键,对应的电话号码作为值,存入映射中。
- 查询映射:根据用户输入的姓名,从映射中查找并输出对应的电话号码。
以下是实现上述逻辑的代码示例:
package main
import "fmt"
func main() {
// 1. 定义数据
names := []string{"Iipold", "Rand pillll", "Bettydodo", "Daniel Euclid"}
numbers := []uint64{5554123, 5559876, 5551111, 5559999}
// 2. 创建映射
phone := make(map[string]uint64)
// 3. 填充映射
for i := 0; i < len(names); i++ {
phone[names[i]] = numbers[i]
}
// 4. 查询映射
var lookupName string
fmt.Print("Enter name to look up: ")
fmt.Scanln(&lookupName)
if number, exists := phone[lookupName]; exists {
fmt.Printf("Phone number is %d\n", number)
} else {
fmt.Println("Name not found in phone book.")
}
}
运行此程序,输入一个姓名(如“Iipold”),程序会输出对应的电话号码。即使电话簿中有海量条目,这种查找也非常快速。
字符串:文本处理的基础
现在,让我们转向另一个预定义类型:字符串。我们已经在第一个“Hello, World”程序中使用了字符串字面量。字符串可以看作是一系列字符的集合。
字符串的特性
在Go语言中,字符串有以下几个要点:
- 字符串是不可变的。
- 字符串中的字符可以用
rune类型(表示Unicode码点)或byte类型(表示ASCII字符)来存储和处理。 - Go标准库提供了丰富的
strings包,用于执行比较、搜索、替换等复杂字符串操作。
例如,比较两个字符串是否相等:
str1 := "hello"
str2 := "world"
if str1 == str2 {
fmt.Println("Strings are equal")
} else {
fmt.Println("Strings are not equal")
}
总结
本节课中我们一起学习了Go语言中的映射和字符串。
- 映射是一种强大的键值对数据结构,基于哈希表实现,能提供高效的查找功能。我们通过构建一个电话簿程序,实践了如何创建、填充和查询映射。
- 字符串是用于处理文本的基本类型,具有不可变性,并可以通过标准库进行各种操作。


理解这两个概念对于编写实用的Go程序至关重要。在接下来的学习中,你会看到它们被广泛应用在各种场景中。
023:字符串类型详解

概述
在本节课中,我们将深入学习Go语言中的字符串类型。我们将探讨字符串的基本概念、其底层表示、如何创建和操作字符串,并通过一个简单的示例程序来演示字符串的遍历和字符访问。
字符串类型简介
上一节我们介绍了Go语言的基本类型,本节中我们来看看字符串类型。字符串类型是Go语言中一个有趣且重要的类型。string是一个预声明的标识符,虽然技术上不是关键字,但在实践中应将其视为关键字。
我们已经使用过字符串字面量。字面量是常量,我们在之前的Print语句中已经大量使用过。例如,在最早的程序中,我们使用了类似"hello world"的字符串。两个引号之间的字符就是字符串的内容,即使是其中的空格也是字符串的一部分。
字符串的底层表示
字符串的表示方式与切片类似。切片是一个可索引的元素集合,索引从0开始(这是从C语言借鉴的惯例),然后逐个递增。在操作字符串时,能够利用其底层表示(通常是一个字节切片)将非常有用。
记住,byte是一个8位的数据类型。8位数据类型足以表示键盘上的所有标准字符,包括小写字母、大写字母、某些特殊字符(如换行符、响铃符)、标点符号和数字,这些都可以用一个字节表示。但是,如果你想使用更广泛的字符集,如日文、中文或希伯来文字符,则需要使用所谓的Unicode编码。所有这些都可以被操作,实际上,字符串还有一些额外的包提供了标准功能,例如strings包或unicode包,我们将在后续的程序中使用它们。
一个简单的字符串程序
以下是创建一个非常简单的字符串程序的步骤。
package main
import "fmt"
func main() {
var c byte = 'a'
var strOne string = "My idea?"
fmt.Println("String program")
fmt.Println(strOne)
fmt.Println(c)
for i := 0; i < len(strOne); i++ {
fmt.Printf("Character %d is %c\n", i, strOne[i])
}
}
在这个main函数中,我们首先声明了一个byte类型的变量c,并将其初始化为单个字符'a'。请注意,byte是一个预声明的变量名,应避免重新声明它,最好将其视为关键字。
我们想演示字符串本身可以被视为一个字节序列。变量strOne将被赋值为字符串"My idea?"。让我们数一下其中的字符:第一个字符(第0个字节)是大写字母M,第二个字符是y,第三个字符是空格,第四个字符是大写字母I,第五个字符是d,第六个字符是e,第七个字符是a,最后一个字符是问号?。所有这些都可以包含在一个字符串中。
程序首先打印出"String program",然后打印strOne变量。使用Println时,我们不需要格式化,它会自动将字符串变量作为字符串打印。接下来,我们打印第一个字符c,它将显示为整数97,这是小写字母a的ASCII码值。
然后,我们使用一个for循环遍历字符串。循环将运行字符串的长度次。这里我们使用了内置函数len,我们之前对切片使用过它,因为它能告诉我们切片的大小。我们也可以在这种数据类型上使用它,这显示了字符串与切片的相似性。len函数将返回字符串的长度,然后循环将逐个遍历每个字符,并打印每个字符在字符串中的位置及其值。
如果你有C语言背景,你可能习惯于更原始地处理字符串。与C语言的区别在于,C语言总是将字符串视为char*(指向字符的指针),因此更加原始。而Go语言更加现代,这是Go语言的改进之一,字符串本身是一个完全支持的类型。
程序运行结果
运行上述程序,我们会在屏幕上看到以下输出:
String program
My idea?
97
Character 0 is M
Character 1 is y
Character 2 is
Character 3 is I
Character 4 is d
Character 5 is e
Character 6 is a
Character 7 is ?
程序首先打印"String program",然后打印字符串"My idea?"。接着打印变量c的值97,这是小写字母a的整数表示(ASCII码值)。然后,我们逐个查看字符串中的每个字符:字符0是大写字母M,字符1是小写字母y,字符2是空格,依此类推,直到字符7是问号?。请注意,我们从字符0开始计数,因此这个字符串共有8个字符。我们使用for循环遍历了它。
在一般情况下处理字符串时,如果我们需要操作它们或尝试理解字符串内部的内容,经常会使用这种惯用法,因此熟悉这种方法非常有用。
总结


本节课中我们一起学习了Go语言中字符串类型的详细知识。我们了解了字符串的基本概念、底层表示(类似于字节切片),以及如何通过字面量创建字符串。我们编写并分析了一个简单的Go程序,演示了如何声明字符串变量、访问单个字符以及使用for循环和len函数遍历字符串中的每个字符。理解字符串的这些基本操作是进行更复杂文本处理的基础。
024:回文检测程序

在本节课中,我们将学习如何编写一个Go语言程序来判断一个输入的字符串是否为回文。回文是指正读和反读都相同的序列,例如“oto”和“ada”。我们将通过一个简单的示例程序,学习字符串处理、循环控制以及如何忽略字母大小写。
核心概念与程序结构
我们的程序将执行以下步骤:
- 从用户处获取一个字符串输入。
- 使用
strings.ToLower函数将所有字符转换为小写,以确保检测不区分大小写。 - 通过循环,逐个比较字符串首尾对应的字符。
- 如果所有对应的字符都相同,则判定为回文;否则,判定为非回文。
以下是程序的核心逻辑代码片段:
// 将输入字符串转换为全小写
testString := strings.ToLower(inputString)
length := len(testString)
// 遍历字符串前半部分,与对应的后半部分字符比较
for i := 0; i < length; i++ {
if testString[i] != testString[length-1-i] {
fmt.Println("不是回文")
return
}
}
fmt.Println("是回文")
程序实现详解
上一节我们介绍了回文检测的基本思路,本节中我们来看看具体的代码实现。
首先,我们需要导入必要的包并获取用户输入。
package main
import (
"fmt"
"strings"
)
func main() {
var input string
fmt.Print("请输入一个字符串: ")
fmt.Scanf("%s", &input)
// ... 后续处理
}
接下来,我们对字符串进行预处理并开始比较。
以下是处理字符串和进行比较的关键步骤:
- 计算长度并转换大小写:获取字符串长度,并将整个字符串转换为小写形式,以便进行不区分大小写的比较。
- 遍历与比较:使用
for循环遍历字符串。在循环体内,比较位置i的字符与从末尾数起对应位置length-1-i的字符。 - 判断与输出:如果发现任何一对字符不匹配,则立即打印“不是回文”并退出程序。如果循环完整执行完毕,说明所有字符都匹配,则打印“是回文”。
程序优化与思考
我们当前的程序有一个可以优化的地方。在遍历整个字符串时,当字符串是回文时,我们实际上对每个字符位置进行了两次比较(例如,比较 testString[0] 和 testString[length-1] 后,在循环后期又会比较 testString[length-1] 和 testString[0])。这对于长字符串来说是一种计算资源的浪费。
思考一下,如何修改循环条件,使得我们只需要遍历字符串的前一半即可完成所有必要的比较?这是一个很好的练习,能帮助你更深入地理解循环和索引。
运行示例


让我们运行程序看看效果。
- 输入
OtTo,程序会输出“是回文”,因为忽略大小写后是“otto”。 - 输入
abc,程序会很快发现a与c不匹配,并输出“不是回文”。
总结
本节课中我们一起学习了如何用Go语言编写一个回文检测程序。我们掌握了以下知识点:
- 使用
strings.ToLower函数统一字符串大小写。 - 利用
for循环和索引访问字符串中的单个字符。 - 通过比较首尾对称的字符来判断回文属性。
- 在循环中使用
return语句提前结束程序。


这个程序是字符串处理的一个经典练习,希望你通过实践能更好地理解Go语言中字符串和循环的用法。
025:iota与枚举类型

概述
在本节课中,我们将要学习Go语言中的枚举类型概念,以及如何使用预定义标识符 iota 来自动生成一系列命名常量。我们将通过创建表示星期几的枚举类型,并编写相关功能函数来深入理解其用法。
枚举类型与iota简介
上一节我们介绍了Go语言中的基础类型。本节中我们来看看一种特殊的用户定义类型——枚举类型。
枚举类型在现代编程语言中是一种声明用户定义类型的方式。它适用于你的应用程序需要某些语言普通类型中不存在的特殊事物,并且你希望通过命名来获得程序可读性的清晰度和类型安全性。
在Go语言中,我们使用预定义标识符 iota 来自动生成一系列递增的命名常量。iota 从0开始,为列表中的每个常量依次分配递增值。
定义枚举类型
以下是定义一个枚举类型的基本语法结构。我们通过 type 关键字声明一个新类型,并指定其底层类型(通常是 int)。接着,在括号内列出常量名,并使用 iota 进行赋值。
type Color int
const (
Green Color = iota // 值为 0
Yellow // 值为 1
Blue // 值为 2
Red // 值为 3
)
在这个例子中,我们定义了一个 Color 类型,其底层是 int 类型。iota 从0开始,为 Green、Yellow、Blue、Red 分别分配了0、1、2、3的值。这种方式比单独声明每个常量更简洁,并且赋予了 Color 独立的类型身份,提供了类型安全。
实践:星期枚举
现在,让我们将这个概念应用于一个更具体的例子:创建一个表示星期几的枚举类型。
首先,我们定义 Days 类型和对应的常量。
type Days int
const (
Sunday Days = iota // 值为 0
Monday // 值为 1
Tuesday // 值为 2
Wednesday // 值为 3
Thursday // 值为 4
Friday // 值为 5
Saturday // 值为 6
)
通过这个声明,Sunday 到 Saturday 被依次赋予了0到6的整数值。Days 现在是一个独立的类型,虽然底层是 int,但不能直接与 int 混用,需要进行类型转换,这增强了代码的安全性。
功能函数实现
定义了枚举类型后,我们通常需要一些功能函数来操作它。下面我们来实现两个函数。
1. 获取下一天
这个函数接收一个 Days 类型的参数,返回它的下一天。我们利用底层整数值进行计算,并使用取模运算 % 7 来确保结果在0到6的范围内(即从星期六循环回星期日)。
func nextDay(today Days) Days {
// 将 today 转换为 int,加1,对7取模,再转换回 Days 类型
return Days((int(today) + 1) % 7)
}
2. 获取星期名称
为了便于阅读输出,我们还需要一个函数将 Days 类型的值转换为其对应的字符串名称。这里使用 switch 语句非常合适。
func dayName(day Days) string {
switch day {
case Sunday:
return "Sunday"
case Monday:
return "Monday"
case Tuesday:
return "Tuesday"
case Wednesday:
return "Wednesday"
case Thursday:
return "Thursday"
case Friday:
return "Friday"
case Saturday:
return "Saturday"
default:
return "Invalid day"
}
}
switch 语句是处理这种有限常量集合的理想选择,它为每个枚举值提供了清晰的处理路径。
测试程序
最后,我们编写一个主函数来测试上述所有功能。程序将遍历一周中的每一天,打印出当前天的名称和它的下一天。
func main() {
fmt.Println("Weekday Program")
for d := Sunday; d <= Saturday; d++ {
todayName := dayName(d)
tomorrow := nextDay(d)
tomorrowName := dayName(tomorrow)
fmt.Printf("Today is %s, tomorrow is %s.\n", todayName, tomorrowName)
}
}
运行这个程序,你会看到从星期日到星期六,每一天都正确地输出了其名称和下一天的名称。特别地,当输入是星期六(Saturday,值为6)时,nextDay 函数计算 (6+1)%7 = 0,成功返回了星期日(Sunday)。
总结
本节课中我们一起学习了Go语言中枚举类型的使用。我们了解到:
- 使用
type和const块配合iota可以方便地定义枚举类型。 iota从0开始自动为常量生成递增值,简化了声明过程。- 枚举类型提供了类型安全,防止了与底层类型的无意混用。
- 我们可以为枚举类型编写功能函数(如
nextDay)和辅助函数(如dayName)。 switch语句是处理枚举类型各个值的强大工具。


这种模式足以让你在需要表示一组相关常量(如状态码、配置选项、类别等)时有效地使用枚举类型。你可以尝试扩展这个例子,例如实现 previousDay(获取前一天)函数,或者构建更复杂的日历管理程序。
026:子切片

概述
在本节课中,我们将要学习Go语言中一个非常强大且实用的概念——子切片。我们已经了解了切片的基本用法,知道它比C语言中的数组更灵活、更安全。本节我们将深入探讨如何从一个已有的切片中提取一部分来使用,即创建子切片。
切片回顾与子切片引入
上一节我们介绍了切片,它是对C语言中静态数组概念的强大扩展和泛化。切片是动态数组,内置于Go语言中,因此你无需像在C语言中那样手动进行内存管理和指针操作,这避免了许多潜在的错误。
切片不仅可以在声明时指定任意大小,还可以动态扩展。它们还内置了许多实用功能,例如获取长度(len)和容量(cap),以及使用range关键字进行迭代。
现在,我们将探索另一个尚未尝试的功能:从现有切片中提取并使用其中一部分数据的能力,这就是子切片。这是一个非常强大的概念。试想一下,像美国社会保障系统这样的数据库可能拥有数亿条记录,但并非所有操作都需要处理全部数据。通过子切片,你可以高效地访问和处理数据的特定部分。
子切片语法与实践
那么,如何获取数组或切片的一部分呢?以下程序展示了具体方法。
首先,我们声明并初始化一个普通的切片:
data := []int{3, 4, 5, 6, 7}
这里,data是一个整型切片,初始包含五个元素:3, 4, 5, 6, 7。它的长度和容量很可能都是5。
接下来,让我们创建第一个子切片:
data2 := data[:2]
这种语法使用冒号分隔下界和上界。当左边界(下界)被省略时,默认为0。因此,data[:2]等同于data[0:2],它包含原切片中索引0到索引2(不包括2)的元素。
以下是更多子切片的示例:
data3 := data[0:2] // 显式指定下界和上界,与 data[:2] 结果相同
data4 := data[2:4] // 包含索引2和3的元素(即值5和6)
data5 := data[2:] // 省略上界,默认为切片长度,包含索引2到末尾的所有元素(即值5, 6, 7)
子切片的语法是[lower:upper]。下界或上界可以隐式指定,但不能同时省略两者。
使用迭代器遍历子切片
为了演示子切片的效果,我们可以使用for循环和range关键字进行迭代。range会返回两个值:索引和该索引处的值。如果我们不需要索引,可以使用空白标识符_将其忽略。
以下是遍历不同切片的示例:
// 遍历原始切片
for _, v := range data {
fmt.Println(v)
}
// 遍历子切片 data[:2]
for _, v := range data2 {
fmt.Println(v)
}
// 同样方法遍历 data3, data4, data5
通过这种方式,我们可以清楚地看到每个子切片所包含的元素范围。
运行结果分析
运行上述程序,输出结果如下:
- 原始切片
data包含所有五个元素:3, 4, 5, 6, 7。 - 子切片
data[:2]包含前两个元素:3, 4。 - 子切片
data[0:2]结果相同:3, 4。 - 子切片
data[2:4]包含索引2和3的元素:5, 6。 - 子切片
data[2:]包含从索引2开始到末尾的元素:5, 6, 7。
结果完全符合预期,清晰地展示了子切片的创建和使用方法,同时也再次演示了迭代器的应用。这是Go语言中的核心惯用法。
总结
本节课中我们一起学习了Go语言中的子切片概念。我们了解到,子切片允许你高效地引用和处理现有切片中的一部分数据,其语法灵活,下界和上界可以隐式指定。通过结合range迭代器,可以方便地遍历子切片中的元素。


由于切片(或称动态数组)是处理大量信息的关键数据类型,熟练掌握切片和子切片的概念对于编写高效、地道的Go程序至关重要。
027:读取文件


在本节课中,我们将要学习如何使用文件进行输入和输出操作。文件是计算机内存中存储数据的一种极其重要的方式。使用计算机的主要原因通常是需要处理大量数据,并且经常需要永久保存这些数据,以便在程序运行结束后依然可用。例如,社会保障管理局需要记录全国每个人的信息,这些数据就需要存储在永久性的介质上。有时,文件也可能是半永久性的,比如在实验期间保存数据,直到不再需要为止。因此,我们需要掌握如何从文件中读取数据、如何访问文件中的数据,这是另一种形式的输入输出。
打开文件
上一节我们介绍了文件操作的重要性,本节中我们来看看如何打开一个文件进行读取。
首先,我们需要能够打开一个文件以供读取。这可以通过标准函数 os.Open 来实现。该函数来自 os 包。我们需要提供文件名作为参数。这个文件可以位于程序执行的当前目录,也可以包含一个路径,以便在辅助存储器中找到它。
尝试打开文件时可能会失败。失败的原因有多种,例如文件不存在于指定位置,或者文件受到保护。如果无法打开文件,我们会得到一个错误信息;如果成功打开,则会获得一个文件描述符。
使用 Open 函数后,我们需要检查是否有错误发生。可以通过检查错误值是否为 nil 来判断。如果错误不为 nil,则表示存在错误,我们必须尝试从错误中恢复,或者(更常见的情况)终止程序。如果错误为 nil,则可以使用返回的文件描述符进行读取操作。
以下是打开文件进行读取的惯用代码示例:
file, err := os.Open("data")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
在这段代码中,os.Open 返回两个值:文件描述符和错误。许多I/O函数都会返回错误,因为I/O操作很容易出错。我们检查错误,如果不为 nil,则打印错误信息并使用 os.Exit 终止程序。
从文件读取数据
成功打开文件后,接下来我们学习如何从文件中读取数据。
我们将使用 fmt.Fscanf 函数来读取文件。Fscanf 与之前使用的 Scanf 函数类似,区别在于 Fscanf 的第一个参数是文件描述符。因此,在获得文件描述符并存储在变量(例如 file)中之后,我们可以逐项读取数据。
假设我们有一个名为 data 的文件,其中存储了一系列整数。我们想要读取这些整数并处理它们。
以下是读取整数文件数据的 main 函数示例:
var dataSlice []int
for {
var item int
_, err := fmt.Fscanf(file, "%d\n", &item)
if err != nil {
if err == io.EOF {
break
}
fmt.Println(err)
os.Exit(1)
}
dataSlice = append(dataSlice, item)
}
在这段代码中,我们声明了一个名为 dataSlice 的切片来存储数据。然后,我们使用一个无限循环,通过 fmt.Fscanf 逐行读取整数。Fscanf 函数需要文件描述符、格式字符串(%d\n 表示读取一个整数和换行符)以及存储读取值的变量地址(&item)。
Fscanf 返回读取的项目数和错误。我们忽略项目数,只检查错误。如果错误为 nil,表示成功读取了一个项目。如果错误是 io.EOF(文件结束符),则表示已到达文件末尾,没有更多数据可读,我们使用 break 跳出循环。如果是其他错误,则打印错误并退出程序。
成功读取一个项目后,我们使用 append 函数将其添加到 dataSlice 切片中。append 函数会自动扩展切片的容量以容纳新元素,这使得Go语言在处理任意长度的文件时非常灵活。
运行示例程序
现在,让我们在终端中运行这个程序,看看它是如何工作的。
首先,查看当前目录(假设是 week5),其中有一个名为 data 的文件。使用 cat 命令查看文件内容,可以看到一系列整数,例如 45, 66, 76, ..., 88。
接下来,查看并运行名为 readfile.go 的程序。该程序导入了必要的包(fmt、io、os),并按照上述逻辑读取 data 文件中的整数到切片中,最后打印出切片内容。
运行程序后,输出结果应该与 data 文件中的整数序列一致,例如 [44 66 76 ... 88]。这表明程序正确读取了文件中的所有数据。

🎼 好的。

总结


本节课中我们一起学习了如何在Go语言中读取文件。我们首先介绍了使用 os.Open 函数打开文件并检查错误。然后,我们学习了使用 fmt.Fscanf 函数从文件中逐项读取数据,并通过检查 io.EOF 错误来判断文件是否结束。最后,我们使用切片和 append 函数来动态存储读取到的数据,并运行了一个完整的示例程序来验证这些操作。通过这种方式,我们可以灵活地处理任意长度的文件数据。
028:写入文件

概述
在本节课中,我们将要学习如何在Go语言中向文件写入数据。文件写入是严肃计算中的重要环节,因为我们需要永久或半永久地存储计算结果以供后续使用。
文件写入的基本模式
与读取文件类似,Go语言也为写入文件提供了简单的惯用模式。为了实现这一点,我们需要使用一些包来简化文件的打开和写入操作。这些包包括我们之前见过的fmt和os包。
创建与写入文件
当我们向文件写入时,通常需要创建它。我们也可以写入已存在的文件,但这部分内容将留待日后讨论。因此,在这个简单的示例中,我们实际上将创建并打开文件。
此外,在这个例子中,我们将看到,当我们不再需要文件时,需要将其正确地归还给系统。因为在某些系统中,可以同时保持打开的文件数量是有限的。因此,你不仅需要打开文件,还需要关闭它们。
使用defer关闭文件
一个良好的原则是,每次打开文件时,都习惯性地使用defer关键字来调用文件的Close方法。defer意味着将在计算结束时关闭文件。我们稍后在研究并行计算等内容时,会再次看到defer,那时它会变得更加重要。
通过在打开文件后立即安排关闭,我们可以避免忘记执行此操作。这种做法是Go语言的惯用模式,使得将打开和关闭操作配对变得更容易。
示例:写入整数到文件
以下是具体操作步骤。我们将创建一个名为int.dat的文件,用于写入整数。
首先,我们使用os.Create函数创建文件,并将文件句柄存储在变量file中。我们需要检查可能出现的错误。为了处理错误,我将编写一个名为checkFile的函数。如果打开文件时出现错误,该函数将终止程序并给出适当的消息。
紧接着,我们使用defer file.Close()来确保文件最终被关闭。
以下是文件写入的三个关键步骤:
- 创建文件。
- 检查错误,确保一切正常。
- 使用
defer安排关闭,以妥善管理资源。
在文件I/O操作中进行错误检查和处理非常重要。经常会出现无法写入、读取或正确打开文件等错误。在大多数情况下,适当的做法是简单地终止程序。
错误处理函数
让我们展示如何编写错误处理函数。我们将编写一个函数checkFile,它接收一个error类型的参数err。我们检查参数err是否不等于nil。如果是,则调用一个特殊的函数panic。panic类似于os.Exit,是另一种退出程序并输出消息的方式。这是一个有用的惯用模式,建议保留这样一个函数。
使用Fprintf写入数据
打开文件后,我们使用Fprintf向其中写入数据。这很合理,回想一下,读取时我们使用scanf,写入时我们使用printf。对于文件操作,Fprintf会有一个额外的文件参数。我们一直在使用Printf和Println,这里我们将使用Fprintf。
我们将写入一个切片。这里定义了一个包含10个值的切片。现在,我使用一个for range循环,变量v将依次代表切片中的每个元素。
Fprintf的第一个参数是文件描述符。第二个参数是格式字符串,这里是"%d\n",表示每个整数后跟一个换行符。这样,文件中的每个整数都会像之前的数据文件一样单独占一行。写入的值来自变量v。
最后,我们检查写入过程中是否有错误,如果有错误就触发panic。如果没有错误,程序将在最后打印出数据切片的内容,以展示其工作状态。
运行示例
现在,让我们检查这个写入操作是否有效。首先,我之前已经运行过这个程序,并且会删除int.dat文件,所以现在它不存在。查看目录,我们看不到它。
我们有一个名为writefile.go的程序。快速查看一下,确认它符合我们的描述。我们只需要fmt和os包。这里有我们用于检查错误的特殊函数。在这个例子中,我们没有使用exit,而是使用了panic函数,这是一种替代方式。
我们将要写入的数据是初始化数据切片时放入的数据。它可以是任意长度,或者我们也可以生成它。例如,我们可以使用随机数生成器创建一百万个随机项放入数据切片中,然后处理它们,并将这些数据输出到文件中。事实上,我们将在最后的作业问题中使用这种方法。
这里是for range循环。我们遍历数据切片,依次获取每个元素,并将每个元素赋值给变量v。如果一切正常,我们将使用Fprintf将数据打印到文件中。第一个参数是指向名为int.dat的文件的文件描述符。我们检查错误以确保一切正常。
然后,我们只需在最后将其打印出来,以显示它正常工作。现在,我运行它:go run writefile.go。程序运行后,打印出了切片。现在,让我们查看int.dat文件:cat int.dat。确实,你看到的结果完全相同。每个值后面都跟着一个新行,这正是我在Fprintf中为每个打印项添加换行符的效果。


总结
本节课中,我们一起学习了Go语言中基本的文件写入操作。我们了解了创建文件、使用defer确保文件关闭、进行错误处理以及使用Fprintf写入格式化数据的完整流程。这是一个非常有用的惯用模式,现在你应该能够在Go中使用基本的文件I/O了。
029:冒泡排序

概述
在本节课中,我们将要学习排序算法,特别是冒泡排序。排序是计算中最常用的算法之一,它使我们能够对数据进行排序,以便高效地处理。我们将了解冒泡排序的工作原理,使用Go语言实现它,并分析其效率。
排序是计算中使用最广泛的算法之一,也是研究最深入的算法之一。它使你能够对数据进行排序,以便快速高效地处理。
存在许多排序算法。最著名的参考是Knuth的《计算机程序设计艺术》第三卷。对于任何认真对待计算机科学的人来说,这是一本必读的书。它不仅详细描述了算法,还进行了详细分析,以便你理解为什么一种算法比另一种更好。
许多这些算法在标准库中都有提供。因此,虽然你很可能会使用库进行排序,例如Go中名为sort的包,就像有fmt包一样,但学习如何编写代码仍然很重要。如果你想成为一名计算机科学家,掌握这些代码并理解它们为何以及如何工作是绝对基础。
冒泡排序算法简介
上一节我们介绍了排序的重要性,本节中我们来看看一种具体的排序算法:冒泡排序。
从某种意义上说,最简单的代码就是我们即将展示的冒泡排序算法。它被称为冒泡排序,因为形象地说,数字像气泡一样上升到表面。
冒泡排序的问题在于,虽然编码非常简单,但它有一个很大的缺点:效率非常低。我们称之为O(n²) 阶。这意味着对于n个元素,运行时间以平方级增长。例如,如果1000个元素的运行时间是1微秒,那么对于一百万个元素,运行时间将是其平方,会达到许多秒。因此,当使用这种算法时,很快就不可能对非常大量的数据进行排序。想象一下社会保障系统,那里有数亿个元素需要排序,以便工作人员能够高效地搜索数据。
然而,我们知道许多更高效的算法,例如归并排序,其复杂度是O(n log n)。我们使用大写的O表示阶,这就是所谓的大O表示法。我们并不关心其中的小常数项,而是关心像n²、n³、阶乘和log n这样的项。所有这些都用于描述算法的效率。
Go语言中的多重赋值
在编写这个冒泡排序算法时,我们将使用Go的一个特殊功能,这个功能有些不同寻常:Go允许多重赋值。我们已经看到过,例如在for range循环中,我们可以得到两个值,有时我们会使用下划线_丢弃其中一个值。但任何函数都可以产生多个值,并可以赋值给一个由逗号分隔的标识符列表。这在像C这样的语言中是不可能的,所以它有所不同。
当你编写排序算法时,必须交换元素,因为元素被认为是无序的。如果两个元素顺序不对,你将交换它们。在Go中,交换可以通过这种非常简单的方式完成:a, b = b, a。这允许a和b交换值,而不需要一个临时变量,而这在大多数语言中是必需的。这里展示了在C语言中你会如何做:你需要一个临时变量,将a的值赋给临时变量保存,然后将b赋给a,最后将临时变量赋给b。如果你试图直接将a赋给b,会得到错误的值,最终会得到两个b值。因此,你需要一个临时变量。但Go的多重赋值技巧意味着,编译器在背后提供了这种交换功能。我们将在冒泡排序中使用这个功能。
冒泡排序的工作原理
让我们看看冒泡排序是如何工作的。我们假设要对一组整数进行冒泡排序。当然,你可以对任何有顺序的东西进行冒泡排序,例如浮点数,或者使用字典序对字符串进行排序,就像字典或电话簿中的排序一样。
冒泡排序的复杂度是O(n²),我们将看到这是如何产生的。本质上,我们将有一个主循环,其循环次数等于我们存储值的切片的长度。然后,我们会有第二个循环,这就是我们得到n²的地方。外层循环的长度被认为是n,在内层J循环中,我们循环到len(d) - i - 1。这就是所谓的内层循环。
以下是冒泡排序的核心代码结构,展示了双循环:
for i := 0; i < len(d)-1; i++ {
for j := 0; j < len(d)-i-1; j++ {
if d[j] > d[j+1] {
d[j], d[j+1] = d[j+1], d[j] // 交换
}
}
}
在冒泡排序中,我们再次看到了双循环:一个外层循环和一个内层循环。在内层循环内部,我们进行检查和交换。我们检查两个元素是否顺序错误,如果较低索引j的元素大于j+1的元素,则交换这两个元素。你可以看到我们使用了Go语言中通过多重赋值进行交换的小技巧。
思考一下这个过程:如果d[0]是最大值,那么在内层循环中,d[0]会像气泡一样上升到顶部。在内层循环的第一次迭代中,我们知道最大值会被“冒泡”到顶部。它总是赢得比较,总是被交换,并移动到顶部。然后我们可以不再管它。这就是为什么我们在内层循环中不断使用len(d)-i-1,-i来自于这样一个事实:我们已经排序到了那个点,我们已经排序了顶部的元素。每次迭代都会在排序中增加一个元素。因此,每次迭代都将剩余的最大元素移动到切片的当前末尾。所以,剩余的最大元素冒泡到顶部。显然,两个循环都是O(n)阶,它们相乘就得到了O(n²)阶。
考虑一个无序序列,如[9, 7, 15, 3, 6]。第一次迭代会发生什么?首先,7与9比较,无事发生。9与3比较,9移动。9与6比较,9移动。9与15比较,15保持最大。现在,进行下一次迭代,我们知道最大的15已经在末尾。所以我们开始比较7和3,移动7。比较7和6,移动7。比较7和9,9更大,无事发生,依此类推。你应该自己尝试这个过程,说服自己这会正常工作。手动模拟你的算法以确保你理解它们并看到它们是正确的,这是一项非常重要的技能。
测试冒泡排序
我们将如何测试这个算法?我们将有一个main函数。main函数将生成一系列0到10000之间的随机数。由于我们不希望每次运行都重复相同的结果,我们将使用一个种子,我们已经做过很多次了。在这个例子中,我们将对3000个元素进行冒泡排序。我们将创建一个该大小的切片,并在该切片内分配一个0到10000之间的随机数。这就是我们在for循环中为data切片所做的事情。
然后,我们产生了一个随机组合的元素,在这个例子中是3000个。现在我们在这个数据切片上调用bubbleSort。我们不想打印出10000个数字,所以我们只打印前20个和后20个。你可以看到我们在那里使用了切片操作。这些操作将让我们检查前20个元素和后20个元素是否被正确排序,这将相当有说服力地证明我们做对了。
以下是生成测试数据并验证排序结果的示例代码:
func main() {
rand.Seed(time.Now().UnixNano())
size := 3000
data := make([]int, size)
for i := range data {
data[i] = rand.Intn(10000)
}
bubbleSort(data)
fmt.Println("Sorted first 20:", data[:20])
fmt.Println("Sorted last 20:", data[len(data)-20:])
}
总结一下,我们将生成未排序的数据来测试我们的冒泡排序,然后对其进行冒泡排序,接着打印一些切片以查看结果是否合适。
运行演示与效率分析
现在让我们在屏幕上运行它。这里有一个与我们刚才描述的类似的冒泡排序。我将使用a, b = b, a进行交换。如果你是一个老派的C程序员,你可能学会了通过调用一个函数来交换两个值,在该函数中,两个值通过指针传递,然后使用一个内部临时变量来执行交换。你也可以用那种方式编写。Go允许你这样做,但这个非常好的功能意味着你可以避免那样做。
我们将生成数据。这是一个简单的冒泡排序,就像我们看到的,它是O(n²)阶。外层循环for i := 0; i < len(d)-1; i++,内部是内层循环for j := 0; j < len(d)-i-1; j++。所以每次循环,我们少做一次比较。然后,我们在必要时进行交换。
我想提一件事,如果你想尝试,可以尝试改进冒泡排序,因为有时列表在你完成之前就已经排好序了,或者可能偶然得到一个已排序的列表。你怎么能知道列表已经排序了呢?我给你一点时间思考一下。你会知道的方法是,如果在内层循环中没有发生任何交换。所以,你可以在内层循环中设置一个标志。如果没有任何交换,该标志为真;只有当你得到一个交换时,它才变为假。一旦发生交换,你必须继续算法。但如果你在整个内层循环中没有发生任何交换,你就可以退出。你可以从冒泡排序中return,所以你可以思考一下,如果你想的话可以尝试。
在这里,我将打印出一大堆数字。在这个模型中,我将要求用户指定切片大小,然后我将向你展示排序前的值和排序后的值。希望你能被说服。我还将打印时间,因为我也想证明它以O(n²)运行,所以效率不高。
让我们运行它。我们先做一个小问题,比如1000个元素。你可以看到是1000个。你可以看到初始的随机数像9217, 2这样开始,这些切片没有明显的顺序。然后我们调用冒泡排序,你可以看到我们得到了12, 28, 31, 36, 40。我们得到了小数字,然后在切片的末尾得到了大数字。我们在很短的时间内完成了。现在,我将重新运行。我们尝试一个问题大小为100000。它应该可以工作。
现在你可以看到程序在运行,而不是立即产生结果。它只是向你展示排序前的切片。你可以看到一个明显的停顿。在第一个例子中,当我们处理1000个元素时,它非常快,可能几微秒或几毫秒。而现在,突然间,它用了17秒。它增长了。如果我们再进一步,我们真的会在这里遇到延迟,这就是这种排序例程与使用库中的排序例程(如快速排序,或者我将要求你作为最后一个问题编写的归并排序例程)之间的区别。
这里我们处理一百万个元素的问题。生成一百万个值没有问题,看生成一百万个随机值有多快,它们几乎是立即发生的。所以这不是问题所在。问题在于我们得到的是类似一百万平方的东西,那将会非常大。所以,在这里,它花了很长时间。你可以看到,一旦这些数字变大,而一百万对于一个大型数据库来说并不算大,使用冒泡排序就变得不切实际了。好的,我不会等待它完成,你可以自己尝试一下。我就给你这个建议。


总结
本节课中我们一起学习了冒泡排序算法。我们了解了排序的重要性,学习了冒泡排序O(n²)复杂度的原理,并使用Go语言的多重赋值特性实现了它。我们还通过生成随机数据测试了算法,并直观地看到了其对大量数据排序时的效率瓶颈。虽然冒泡排序简单易懂,但其效率低下,因此在实际应用中,我们会选择更高效的算法,如归并排序或快速排序。
030:归并排序


在本节课中,我们将学习如何完成本课程的最后一个编程作业:实现归并排序。我们将解释归并排序的原理、重要性以及实现方法。
归并排序之所以重要,是因为它比冒泡排序高效得多。我们通常用计算复杂度来衡量算法的效率。对于包含 n 个元素的列表,冒泡排序的复杂度是 O(n²),这意味着其运行时间会随着元素数量呈指数级增长。相比之下,归并排序的复杂度是 O(n log n)。当处理大量数据时,例如对1000万个元素进行排序,你将能明显感受到两者在运行时间上的巨大差异。O(n log n) 的增长速度远小于 O(n²)。
归并排序的核心思想
归并排序的核心是“分而治之”的思想。我们首先考虑一个场景:有两个已经排好序的序列。
例如,第一个序列是:3, 9, 18, 23(从小到大)。第二个序列是:2, 16, 17, 29。
归并排序的关键步骤,就是将这两个已排序的序列合并成一个更大的有序序列。通常,我们会使用2的幂次方大小的序列进行合并,这样在算法执行过程中,合并的序列大小会不断翻倍,这正是算法复杂度达到 O(n log n) 的原因。
合并过程详解
接下来,我们具体看看如何合并这两个序列。
我们分别从两个序列的头部开始,比较当前最小的元素。较小的那个元素会被放入新的合并序列中,然后该序列的指针向后移动一位。我们重复这个过程,直到其中一个序列的所有元素都被处理完。
以下是合并过程的逐步演示:
- 比较 2 和 3,2 更小,将 2 放入新序列。
- 比较 3 和 16,3 更小,将 3 放入新序列。
- 比较 9 和 16,9 更小,将 9 放入新序列。
- 比较 18 和 16,16 更小,将 16 放入新序列。
- 比较 18 和 17,17 更小,将 17 放入新序列。
- 比较 18 和 29,18 更小,将 18 放入新序列。
- 比较 23 和 29,23 更小,将 23 放入新序列。
- 第一个序列已空,将第二个序列剩余的 29 放入新序列。
最终,我们得到合并后的有序序列:2, 3, 9, 16, 17, 18, 23, 29。如果原始数据规模更大,我们会不断重复这种合并操作,直到所有元素都合并到一个有序序列中。
关键代码逻辑
为了帮助你理解如何实现合并步骤,这里提供一段接近Go语言的伪代码。这段代码展示了合并两个已排序切片的核心逻辑。
在合并过程中,我们需要三个索引:
index1:用于跟踪在第一个已排序切片sortedSlice1中的当前位置。index2:用于跟踪在第二个已排序切片sortedSlice2中的当前位置。index3:用于跟踪在合并结果切片mergedSlice中的当前位置。
以下是核心循环逻辑的伪代码:
for index1 < len(sortedSlice1) && index2 < len(sortedSlice2) {
if sortedSlice1[index1] < sortedSlice2[index2] {
mergedSlice[index3] = sortedSlice1[index1]
index1++
} else {
mergedSlice[index3] = sortedSlice2[index2]
index2++
}
index3++
}
这个循环会持续比较两个切片当前指针所指的元素,将较小的元素放入 mergedSlice,并移动相应的指针。当循环结束时,意味着其中一个切片的所有元素都已处理完毕。
循环结束后,我们需要将另一个切片中剩余的所有元素直接追加到 mergedSlice 的末尾。这段代码是实现完整归并排序的基础。
作业任务与测试
你的任务是编写一个归并排序程序,对一个包含10000个无序整数的切片进行排序。
以下是生成测试数据和建议:
- 使用随机数生成器来创建这10000个无序整数。
- 为了测试程序的正确性,我们提供了一个名为
sorted.dat的文件。你的程序应该能正确排序文件中的数据。 - 在正式处理10000个数据之前,强烈建议先用小规模数据(例如6个随机数)进行测试和手动画图模拟,以确保你完全理解算法逻辑,并且代码能够正确运行。
实现方式的建议
在实现完整的归并排序时,主要有两种思路:
- 递归方法:这是更经典和优雅的实现方式,它完美体现了“分而治之”的思想。我们鼓励你思考如何使用递归来解决问题。
- 迭代方法:也可以使用循环迭代的方式来实现。
两种方法都能成功实现归并排序算法。
本节课中,我们一起学习了归并排序算法。我们了解了它相比冒泡排序的高效性(O(n log n) vs O(n²)),掌握了其“分而治之”的核心思想与合并两个有序序列的关键步骤,并分析了实现合并逻辑所需的代码结构。最后,明确了本次编程作业的要求与测试方法。归并排序是计算机科学中一个非常重要的算法,希望你通过动手实现能对其有更深刻的理解。






Go语言编程:第二部分:课程介绍与概览

在本节课中,我们将要学习加州大学圣克鲁兹分校的“Go语言编程(面向所有人)”课程第二部分的整体介绍。课程将深入讲解Go语言的高级特性,包括复杂数据结构、接口、并发编程以及泛型。
我是加州大学圣克鲁兹分校的I Raol教授。我邀请大家参加“Go语言编程(面向所有人)”课程的第二部分。
在这门课程中,你将学习如何构建和使用复杂的数据结构,例如结构体(structs)。你还将了解Go语言中一些独特的特性,例如接口(interfaces),以及它们如何用于泛化数据类型。Go语言还允许你在语言内部进行并发编程,这与C/C++必须使用库的方式不同。最后,你将练习使用所有这些高级特性以及Go语言中相对较新的补充——泛型(generics)来编写代码。
本课程为期五周。第一周专注于使用结构体。第二周是关于自引用数据,例如链表。第三周是关于使用接口来构建灵活的数据类型和应用方法。第四周教授并发编程以及如何避免死锁等陷阱。第五周介绍泛型,这是Go语言中相对较新的补充。
每周大约需要五小时的观看和学习时间。如果你是为了获得证书而学习本课程,每周还会有测验和编程作业。
到本课程结束时,你应该能熟练运用Go语言的几乎所有特性。此外,完成课程后将提供证书。
在学习Go语言的同时,也可以学习C/C++。所有这些课程都由我以非常连贯的风格讲授,并且能够很好地衔接在一起。


本节课中我们一起学习了“Go语言编程(面向所有人)”课程第二部分的课程目标、核心内容、时间安排以及学习成果。课程将系统性地引导你掌握Go语言的高级编程技能。
002:用户定义类型与指针

概述
在本节课中,我们将学习Go语言中用户定义类型的基础知识。我们将了解为什么需要创建自己的类型,以及如何使用类型声明、结构体和指针来实现这一目标。掌握这些概念将使你的代码更清晰、更安全、更易于维护。
用户定义类型简介
在上一节中,我们学习了Go语言内置的基本类型,如int和string。然而,这些标准类型并不能完美地适用于所有问题领域。因此,现代编程语言允许我们创建用户定义类型。
通过定义适合特定问题的类型,我们可以获得诸多好处:代码更清晰,类型安全性更高。例如,我们可以定义一个Card类型来表示扑克牌,或者定义一个Student类型来管理大学里的学生信息。
类型声明基础
在Go语言中,我们使用type关键字进行类型声明。以下是一个简单的例子:
type TestGrade uint8
在这个例子中,我们创建了一个名为TestGrade的新类型,它基于uint8(无符号8位整数)。你可以将其理解为存储0到100之间的分数。虽然它本质上是一个整数,但通过赋予其特定的类型名称,我们的代码更具可读性,并且能获得一定程度的类型安全,因为它不能轻易地与其他类型进行转换,从而避免了转换错误。
深入理解指针
在讲解如何使用结构体创建用户定义类型之前,我们需要进一步理解指针,因为指针将与结构体紧密协作,对于使用用户定义类型至关重要。
指针是一个对象的地址。让我们通过一个例子来回顾指针的基本操作:
a := 5 // 创建一个整型变量a,并初始化为5
pointer := &a // 使用 & 操作符获取变量a的地址,并将其赋值给指针变量
变量a可以看作一个“邮箱”,里面存放着值5。&a操作就是获取这个“邮箱”的地址。这个地址通常是一个非常大的数字。
指针变量pointer的类型是*int(指向整型的指针),而不是int。
接下来是解引用操作,使用*操作符:
value := *pointer // 解引用指针,获取指针指向地址中存储的值,此处为5
解引用允许我们通过指针来操作原始变量:
*pointer = *pointer + 1 // 通过指针将a的值增加1
fmt.Println(a) // 现在a的值是6
你需要理解的两个基本操作是:
&:取地址操作符。*:解引用操作符。
指针使用示例
以下是展示指针各种用法的简单程序:
package main
import "fmt"
func main() {
// 声明并初始化一个整型变量
i := 55
// 获取变量i的地址并赋值给指针
pointerToI := &i
fmt.Println("i 的值:", i) // 输出: 55
fmt.Println("pointerToI 的值 (地址):", pointerToI) // 输出一个十六进制地址
// 通过解引用指针来修改i的值
*pointerToI = *pointerToI + 1
fmt.Println("i 的新值:", i) // 输出: 56
// 指针可以重新指向另一个同类型变量
j := 100
pointerToI = &j
fmt.Println("pointerToI 现在指向 j:", *pointerToI)
// 指针可以被赋值为 nil,表示不指向任何内存地址
pointerToI = nil
fmt.Println("pointerToI 被设置为 nil:", pointerToI)
}
运行此程序,你会看到:
- 变量
i的初始值。 - 指针
pointerToI存储的地址(以十六进制显示)。十六进制常用于表示计算机内存中的大数字。 - 通过指针解引用修改后,
i的新值。 - 指针重新指向变量
j。 - 指针被设置为
nil(空值)。nil是Go语言中一个非常重要的特殊常量,常用于表示指针不指向任何有效对象。在后续数据结构(如链表)中,nil常被用作结束标志。
注意:Go语言的指针与C语言的指针类似,但更安全。例如,Go语言不允许进行指针算术运算,这避免了许多潜在的安全漏洞。
总结

本节课我们一起学习了Go语言中用户定义类型的基础。我们首先了解了创建自定义类型的必要性及其优势。接着,我们学习了如何使用type关键字进行简单的类型声明。然后,我们深入探讨了指针的核心概念,包括取地址(&)和解引用(*)操作,并通过一个完整的示例程序演示了指针的声明、赋值、修改和置空(nil)。理解这些概念是接下来学习结构体并构建复杂自定义类型的关键基础。
003:结构体


在本节课中,我们将要学习Go语言中一个非常重要的概念:结构体。我们将回顾指针的用法,并了解如何使用type关键字定义新类型。核心部分将介绍如何通过组合基本类型来创建更复杂的自定义类型——结构体。我们将学习如何声明、初始化和访问结构体,并理解指针在操作结构体时的特殊便利性。
结构体的定义与用途 🏗️
上一节我们讨论了指针和简单的类型声明。本节中我们来看看如何创建更有趣的用户自定义类型,这需要用到一种称为“结构体”的机制。
如果你学习过C语言,那么你已经见过结构体。类似结构体的概念也存在于许多其他编程语言中。结构体对于构建更复杂的用户自定义类型至关重要。
以下是定义一个结构体的基本方法。我们将以定义一个Person类型为例:
type Person struct {
Name string
Address string
SSN uint64
Sex bool
}
在这个例子中,我们定义了一个Person类型。想象一下,在大型公司的软件中,Person(作为员工)会是许多应用的核心。我们使用基本类型来定义结构体的各个字段:
Name字段是字符串类型。Address字段是字符串类型。SSN(社会保险号)字段是64位无符号整数。Sex字段是布尔类型,可以用true或false表示男性或女性。
需要注意的一点是,我们将Person的首字母大写了。这是Go语言风格的一个重要约定。大写意味着这个类型在包中是可导出的。如果你的代码将被大量其他软件使用,并且位于一个包中,你通常会希望它是可导出的,这就是我们遵循大写首字母约定的原因。
结构体的使用与指针操作 🎯
一旦我们定义了Person类型,就可以使用它。这有助于让我们的代码更具可读性。
我们可以声明一个Person类型的变量,并通过点表达式来访问和赋值其字段:
var p Person
p.Name = "Ira"
p.Address = "123 Main St"
p.SSN = 123456789
p.Sex = true
通过为每个独立字段赋值,我们就构建了一个完整的Person实例。顺便提一下,你也可以在声明结构体变量时直接进行初始化,我们稍后会看到。
指针在结构体操作中也非常有用。我们可以获取一个结构体变量的地址:
pp := &p // pp 是一个指向 Person 的指针
这里有一个非常关键的点:在Go语言中,无论是结构体变量本身,还是指向结构体的指针,都使用点操作符来访问字段。
pp.Name = "NewName" // 通过指针访问字段,与 p.Name 效果相同
这对于有C语言背景的程序员来说可能有点困惑。在C语言中,我们倾向于使用指针->字段名的语法。而在Go中,点操作符对两者都适用,这一点需要牢记。
指针在函数调用中尤为重要。因为Go语言中函数参数是值传递的。如果我们想在一个函数内部修改一个结构体,我们不能直接传递结构体本身,因为那样会在函数内部创建一个副本,外部的原始结构体不会被改变。正确的做法是传递指向该结构体的指针,通过指针间接修改,这样函数调用结束后,外部的结构体就能正确反映更改。
结构体的历史背景 📜
让我们简单谈谈结构体的历史背景。在20世纪50年代,编程语言主要用于数值计算,最著名的是Fortran。到了60年代,像IBM这样的公司发现很多计算不再纯粹是数值或科学计算,于是开始开发能使用非数值类型的语言。
例如,IBM开发的PL/I语言就包含了结构体类型。从Algol传统发展而来的语言,如Pascal和Algol W(由我的教授Niklaus Wirth设计,他因对编程语言设计的巨大贡献而获得图灵奖),引入了称为“记录”的概念。Pascal在80年代非常成功,是许多学校的基础教学语言。
后来,Dennis Ritchie为开发Unix系统而创造了C语言,他决定使用关键字struct而不是record,并保留了用struct创建用户自定义类型的能力。这个概念一直延续至今,包括Go语言在内的现代语言都继承了它。
实践示例:学生结构体 👨🎓
让我们看一个具体的例子。我们将定义一个Student结构体并学习如何使用它。
package main
import "fmt"
// 定义Student结构体
type Student struct {
Name string
GPA float64
}
func main() {
// 创建一个Student类型的变量
var s Student
// 创建一个指向s的指针
sp := &s
// 通过指针初始化字段
sp.Name = "Ira"
sp.GPA = 3.5
// 打印结构体内容
fmt.Println("Student is:", s)
// 通过解引用指针打印,结果相同
fmt.Println("Student is (via pointer):", *sp)
}
在这个main函数中,我们使用了Student结构体。我们创建了一个Student类型的变量s,以及一个指向它的指针sp。我们通过指针初始化了它的字段,然后分别通过变量本身和解引用指针来打印内容,两者结果相同。
理解如何通过指针类型或直接通过结构体变量来操作数据非常重要。当我们想开发更复杂的程序时,结构体组合会很有用。例如,也许Student不应该直接包含Name字段,而是应该包含一个Person类型的字段,因为我们已经定义了Person类型。这样,一个学生就是一个Person,然后我们额外添加一个GPA字段。这体现了结构体组合构建复杂类型的思想。
运行上面的代码,输出结果如下:
Student is: {Ira 3.5}
Student is (via pointer): {Ira 3.5}
我们可以看到,无论是直接访问变量s,还是通过解引用指针*sp,打印的结果完全相同。我们必须习惯这两种访问方式。
总结 ✨

本节课中我们一起学习了Go语言中的结构体。我们了解了如何通过struct关键字将基本类型组合起来,定义出像Person或Student这样的新类型。我们掌握了结构体的声明、字段初始化以及通过点操作符访问字段的方法。特别重要的是,我们理解了指针在结构体操作中的角色——无论是结构体变量还是指向它的指针,都使用点操作符访问字段,并且传递指针到函数中是修改外部结构体的有效方式。最后,我们通过一个简单的Student示例实践了这些概念。结构体是构建Go语言中更复杂数据模型的基石,熟练掌握它对后续学习至关重要。
004:结构体进阶与组合

在本节课中,我们将要学习如何使用Go语言中的结构体组合来构建更复杂的数据结构。组合是一种非常强大的技术,它允许我们复用已有的结构体来构建新的、功能更丰富的结构体。
概述
上一节我们介绍了结构体的基本概念。本节中,我们来看看如何通过组合已有的结构体来创建新的结构体。我们将以“人”和“学生”为例,演示如何利用组合来复用代码和数据。
构建复杂数据结构:组合
组合是一种构建复杂数据结构的方式。例如,如果我们已经有一个表示“人”的结构体,并且这个结构体内部可能还包含其他子结构体,那么我们就可以利用这个“人”的结构体来构建一个“学生”结构体。
这样,所有为“人”结构体编写的代码和功能,都可以立即用于操作“学生”结构体。像C++这样更强大的、采用面向对象编程的语言也使用组合。经过长期实践发现,与使用继承来构建复杂结构的面向对象方法相比,组合这种更早期的风格实际上更容易维护。
我们将学习如何从一个“人”结构体构建一个“学生”结构体。
定义结构体
首先,我们定义一个简单的Name结构体,它包含名和姓。
type Name struct {
first string
last string
}
接着,我们使用组合来定义Person结构体。Person结构体的一个字段就是Name类型。
type Person struct {
name Name
address string
age int
}
Person结构体有很多用途,例如在构建员工系统或任何你能想到的软件时,都可以从Person开始,并用它来构建你需要的更复杂的软件。
现在,我们来定义Student结构体。一个学生当然是一个人,所以Student结构体嵌入了Person。此外,学生需要参加考试,因此我们添加一个整数切片来存储考试成绩。
type Student struct {
person Person
testScores []int
gradeAvg int
}
请注意组合是如何工作的:我们利用已有的Person结构体,通过将其作为Student的一个字段,构建了更复杂的Student数据结构。
使用组合结构体
在上一节,我们构建了复杂的数据结构。一些面向对象语言可能会使用继承,但Go语言选择避免完全的面向对象。因此,在Go中构建复杂数据结构时,组合是你最强大的工具。
以下是使用组合结构体的一个例子。我们将为一名学生输入考试成绩。
func inputScores(s *Student, testCount int) {
fmt.Printf("输入 %d 门考试的成绩:\n", testCount)
for i := 0; i < testCount; i++ {
var score int
fmt.Scanf("%d", &score)
s.testScores = append(s.testScores, score)
}
}
在这个函数中,我们循环testCount次,每次提示用户输入一个成绩,并使用Scanf读取它,然后将其添加到学生的testScores切片中。
为结构体定义方法
除了普通函数,我们还可以为结构体定义方法。当处理结构体时,方法是一种非常典型的编码方式,我们可以将它们视为操作这些结构体的“动词”。
例如,我们想为Student结构体计算平均分。以下是testAverage方法的实现:
func (s *Student) testAverage() {
sum := 0
for _, v := range s.testScores {
sum += v
}
s.gradeAvg = sum / len(s.testScores)
}
这个方法接收一个指向Student的指针。它使用for range循环遍历testScores切片中的每个分数,将它们累加到sum变量中。然后,通过整数除法sum / len(s.testScores)计算出平均分,并存储到Student的gradeAvg字段中。这种for range循环是一种迭代器,无需担心索引问题。
主程序流程
现在,我们来看主程序如何将这些部分组合起来。
func main() {
// 1. 创建一个Person
p := Person{
name: Name{first: "Kate", last: "Coder"},
address: "North Pole",
age: 29,
}
// 2. 创建一个Student,组合了上面创建的Person
s := Student{person: p}
// 3. 询问考试数量
var testCount int
fmt.Print("请输入考试数量: ")
fmt.Scanf("%d", &testCount)
// 4. 动态分配切片空间并输入成绩
s.testScores = make([]int, testCount)
inputScores(&s, testCount)
// 5. 计算并打印平均分
s.testAverage()
fmt.Printf("学生 %s %s 的平均分是: %d\n", s.person.name.first, s.person.name.last, s.gradeAvg)
}
程序执行步骤如下:
- 创建一个名为“Kate Coder”的
Person实例。 - 使用这个
Person实例来初始化一个Student。 - 从用户输入获取考试数量。
- 使用
make函数动态分配一个大小为testCount的整数切片来存储成绩,然后调用inputScores函数输入具体分数。 - 调用
testAverage方法计算平均分,并打印结果。
程序运行示例
当运行这个程序时,它会首先询问考试数量。假设输入4。
接着,程序会提示为学生Kate Coder输入4个成绩。例如,输入66, 77, 99, 95。
程序会输出学生的姓名、地址、年龄、所有考试成绩以及计算出的平均分(84分)。
这演示了在Go语言中,使用组合来构建和管理复杂数据结构是多么强大。
扩展思考
你可以尝试修改这个程序,为Student添加更多字段,例如:
- 专业 (
major) - 年级 (
year,如大二)
你也可以扩展程序来处理多个学生,而不仅仅是一个。
总结
本节课中我们一起学习了Go语言结构体的组合。
- 我们了解了组合是一种通过嵌入已有结构体来构建新结构体的强大技术。
- 我们定义了
Name、Person和Student结构体,并演示了如何通过组合来复用Person的字段。 - 我们学习了如何为结构体定义方法,例如为
Student计算平均分。 - 最后,我们编写了一个完整的程序,创建学生、输入成绩、计算并输出平均分,展示了组合在实际中的应用。


通过组合,你可以清晰地组织代码,高效地复用已有组件,从而构建出易于维护的复杂数据结构。
005:方法、结构体与扑克概率计算


在本节课中,我们将结合之前学过的结构体和指针类型,并引入一个名为“方法”的新概念。我们将通过计算七张牌扑克中同花顺的概率来演示这些概念。课程将展示用户自定义类型在特定领域(如扑克牌)中的价值,它能使代码更清晰、更易读、更易理解。我们还将演示如何声明方法,这是一种在面向对象编程中常见的概念。虽然Go语言在技术上并非面向对象语言,但它允许声明以结构体作为显式参数的函数,这种函数就是方法。
定义扑克牌类型
首先,我们需要定义一张扑克牌。一张牌由花色和点数构成。
我们将定义一个Suit(花色)类型,使用iota来创建一组特殊的整数常量,分别代表梅花、方块、红心和黑桃。
type Suit int
const (
Club Suit = iota // 梅花,值为0
Diamond // 方块,值为1
Heart // 红心,值为2
Spade // 黑桃,值为3
)
接下来,我们定义Card(牌)结构体,它包含两个字段:S(花色)和Pip(点数)。点数用整数1到13表示,其中1代表A,11代表J,12代表Q,13代表K。
type Card struct {
S Suit
Pip int // 1 = A, 2-10, 11 = J, 12 = Q, 13 = K
}
为结构体声明方法
上一节我们定义了Card类型,本节中我们来看看如何为它声明一个方法。方法类似于函数,但其第一个参数是一个特定的接收者类型(这里是Card),调用时使用点号语法。
以下是一个PrintCard方法的示例,它用于将一张牌的花色和点数转换为“Ace of Clubs”这样的可读字符串并打印出来。
func (c Card) PrintCard() {
var suitName string
var pipName string
// 将花色整数转换为字符串
switch c.S {
case Club:
suitName = "Clubs"
case Diamond:
suitName = "Diamonds"
case Heart:
suitName = "Hearts"
case Spade:
suitName = "Spades"
}
// 将点数整数转换为字符串
switch c.Pip {
case 1:
pipName = "Ace"
case 11:
pipName = "Jack"
case 12:
pipName = "Queen"
case 13:
pipName = "King"
default:
pipName = strconv.Itoa(c.Pip) // 将数字2-10转换为字符串
}
fmt.Printf("%s of %s\n", pipName, suitName)
}
构建牌组与洗牌算法
为了进行概率计算,我们需要一副完整的52张牌。以下是初始化牌组deck的代码。
deck := make([]Card, 52)
for i := 0; i < 52; i++ {
deck[i].Pip = (i % 13) + 1 // 点数循环1-13
switch {
case i < 13:
deck[i].S = Club
case i < 26:
deck[i].S = Diamond
case i < 39:
deck[i].S = Heart
default:
deck[i].S = Spade
}
}
初始化后,我们需要洗牌。这里使用一个接收指针类型参数的Shuffle函数,以确保能修改原始牌组。洗牌算法通过随机交换牌的位置来实现。
func Shuffle(deck *[]Card) {
rand.Seed(time.Now().UnixNano())
d := *deck
for i := range d {
j := rand.Intn(52)
d[i], d[j] = d[j], d[i] // 交换两张牌
}
}
判断同花顺的逻辑
现在,我们来看如何判断一手7张牌中是否包含同花顺。同花顺的定义是至少有5张牌的花色相同。
以下是IsFlush函数的实现逻辑:
func IsFlush(hand []Card) bool {
cCount, dCount, hCount, sCount := 0, 0, 0, 0
for _, card := range hand {
switch card.S {
case Club:
cCount++
case Diamond:
dCount++
case Heart:
hCount++
case Spade:
sCount++
}
}
// 如果任一花色计数达到5或以上,即为同花顺
return cCount >= 5 || dCount >= 5 || hCount >= 5 || sCount >= 5
}
蒙特卡洛模拟计算概率
我们将使用蒙特卡洛方法来估算概率。其核心思想是:通过随机生成大量牌局(例如10万局),统计其中出现同花顺的局数,然后用这个数量除以总局数,得到近似的概率。
以下是主程序main函数的核心循环结构:
flushCount := 0
fmt.Print("输入模拟局数: ")
var totalTrials int
fmt.Scan(&totalTrials)
hand := make([]Card, 7) // 一手7张牌
for trial := 0; trial < totalTrials; trial++ {
Shuffle(&deck) // 洗牌
// 发前7张牌作为一手牌
for i := 0; i < 7; i++ {
hand[i] = deck[i]
}
if IsFlush(hand) {
flushCount++
// 每100次同花顺打印一次牌型示例(可选,用于验证)
if flushCount%100 == 0 {
fmt.Println("发现同花顺示例:")
for _, c := range hand {
c.PrintCard()
}
}
}
}
probability := float64(flushCount) / float64(totalTrials)
fmt.Printf("在 %d 局中,发现 %d 次同花顺。概率约为 %.4f\n", totalTrials, flushCount, probability)
代码演示与结果分析
运行程序时,它会提示输入模拟的局数。例如:
- 输入100局,可能得到约3次同花顺,但结果波动较大。
- 输入10,000局,可能得到约342次同花顺,概率约为3.42%,结果更稳定。
- 输入100,000局,可能得到约3,033次同花顺,概率约为3.03%,非常接近理论值。
程序在运行时会偶尔打印出同花顺的示例牌型,例如“Jack of Clubs, King of Hearts...”,这有助于验证逻辑的正确性。理论上,七张牌扑克中获得同花顺的概率略高于3%,我们的模拟结果与此相符。
总结与扩展练习
本节课中,我们一起学习了以下核心内容:
- 使用结构体和常量(
iota)为特定领域(扑克牌)创建了用户自定义类型,使代码意图更清晰。 - 学习了如何为结构体声明方法,这是一种将函数与特定类型绑定的语法,使代码组织更接近面向对象风格。
- 实现了洗牌算法和同花顺判断逻辑。
- 应用蒙特卡洛方法,通过大量随机模拟来估算复杂事件的概率。
一个很好的扩展练习是:修改代码,编写一个IsStraight函数来判断一手牌中是否包含顺子(连续点数的五张牌),并计算其在七张牌扑克中出现的概率。根据扑克规则,顺子应该比同花顺更常见,因此其模拟概率应该更高。




006:同行评审作业 - 计算七张牌扑克中同花顺的概率 🃏

概述
在本节课中,我们将学习如何使用Go语言和蒙特卡洛模拟方法,来计算七张牌扑克中出现同花顺的概率。我们将从构建扑克牌数据结构开始,实现随机洗牌算法,并最终通过大量模拟试验来估算这一稀有事件的概率。
构建扑克牌数据结构
上一节我们提到了概率计算的目标,本节中我们来看看如何用代码表示一副扑克牌。为了高效地模拟发牌和判断牌型,我们需要先定义牌的花色和点数。
以下是定义扑克牌类型的代码示例:
// 定义花色类型,使用iota生成枚举值
type Suit int
const (
Club Suit = iota // 梅花,值为0
Diamond // 方块,值为1
Heart // 红心,值为2
Spade // 黑桃,值为3
)
// 定义单张牌的结构体
type Card struct {
suit Suit // 花色
value int // 点数,通常为1到13(A到K)
}
在这段代码中,我们使用iota为四种花色生成了连续的整数值,并用结构体Card将花色和点数组合在一起,形成一张完整的牌。
实现随机洗牌算法
定义好牌之后,我们需要一个方法来随机打乱牌的顺序。在真实的扑克中,简单的“鸽尾式洗牌”往往无法达到真正的随机,因此我们需要在代码中实现一个能保证随机性的算法。
以下是实现高效随机洗牌的代码:
func shuffleDeck(deck []Card) {
rand.Seed(time.Now().UnixNano()) // 初始化随机数种子
for i := range deck {
j := rand.Intn(i + 1) // 生成一个随机位置
deck[i], deck[j] = deck[j], deck[i] // 交换两张牌的位置
}
}
这个算法遍历牌堆,将每张牌与一个随机位置(包括它自身)的牌进行交换。Go语言特有的多重赋值语法deck[i], deck[j] = deck[j], deck[i]使得交换操作无需临时变量,既简洁又高效。
设计蒙特卡洛模拟流程
有了随机牌堆,我们就可以开始模拟发牌和判断牌型的过程了。由于同花顺是极其罕见的事件,我们需要进行数百万次试验才能得到一个稳定的概率估计。
以下是模拟流程的核心步骤:
- 初始化与洗牌:创建一副52张牌并按上述算法洗牌。
- 发牌:从洗好的牌堆中发出7张牌,构成一手牌。
- 判断同花:检查这7张牌中是否有至少5张是同一花色。如果没有,则本次试验失败。
- 判断顺子:在满足同花的牌中,检查是否存在至少5张点数连续(构成顺子)的牌。例如,红心4、5、6、7、8。
- 记录结果:如果同时满足同花和顺子,则记录一次“同花顺”事件。
关键点:一手牌中可能包含超过5张的同花牌(如6张或7张同花),也可能包含多个潜在的顺子组合。在评估时,取其中最高的同花顺作为该手牌的牌力。例如,10, J, Q, K, A 组成的“皇家同花顺”是最高牌型。
运行模拟与计算概率
按照上述流程,我们可以编写一个循环来执行大量试验。
以下是计算概率的公式:
概率 ≈ (发生同花顺的次数) / (总模拟次数)
例如,如果运行1000万次模拟,其中发生了x次同花顺,那么估算的概率就是 x / 10,000,000。模拟次数越多,得到的结果就越接近真实的理论概率。

总结
本节课中我们一起学习了如何用Go语言解决一个复杂的概率计算问题。我们从定义扑克牌的基本数据结构开始,实现了一个高效的随机洗牌函数。接着,我们设计了蒙特卡洛模拟的核心流程,通过“判断同花”和“判断顺子”两个步骤来检测稀有的同花顺牌型。最后,通过运行海量模拟试验并统计频率,我们能够估算出七张牌扑克中出现同花顺的概率。这个方法不仅适用于扑克,也是解决许多复杂统计和概率问题的强大工具。
007:自引用数据结构与单向链表

概述
在本节课中,我们将要学习Go语言中一种重要的数据结构——自引用数据结构,并以最简单的形式——单向链表为例,详细讲解其构建原理、操作方法以及如何与Go语言中的切片进行转换。理解这些概念是掌握更复杂数据结构的基础。
自引用数据结构简介
上一节我们介绍了基础的数据结构。本节中我们来看看自引用数据结构。自引用数据结构的特点是,其元素的定义中引用了自身。这是构建动态、可扩展数据结构的关键。最简单的自引用数据结构是单向线性链表。
在某些编程语言中,链表是语言或标准库提供的基本结构。例如,在早期的AI语言Lisp中,列表就是核心数据结构。在现代语言中,链表虽不一定是语言内置部分,但通常是标准库的一部分。现代开发者通常直接使用库中已实现的结构,但理解其构造与使用原理至关重要,有时你可能需要构建自定义的变体以获得特定优势。
单向链表的核心元素
单向链表的中心元素是一个结构体。这个结构体需要包含两部分:
- 一个数据字段,用于存储实际数据。
- 一个自引用字段,这是一个指向同类结构体的指针,用于链接到下一个元素。
视觉上,可以将其想象成一根晾衣绳。你可以在绳子的前端挂上衣物,需要时继续在前端添加,理论上可以悬挂无限数量的物品。链表也是如此,你可以不断添加元素使其增长,或移除元素使其收缩,因此它是可扩展的。
以下是链表节点的Go语言定义:
type ListElement struct {
data int
next *ListElement
}
在这个例子中,data字段是int类型,但它可以是任何复杂的数据类型,例如之前用过的Person或Student结构体。next字段是一个指向ListElement类型的指针,正是它实现了自引用。
创建链表元素
理解了结构定义后,我们来看看如何创建链表元素。以下是一个创建并初始化链表元素的函数示例:
func createListElement(data int, next *ListElement) *ListElement {
var le ListElement
le.data = data
le.next = next
return &le
}
这个函数在堆上创建了一个ListElement,因此即使在函数退出后,该元素依然存在。它接收数据和下一个节点的指针作为参数,初始化后返回该元素的地址。这是一种简单直接的创建方法。
遍历与打印链表:递归方法
创建了链表之后,我们需要一种方法来查看其中的内容。处理自引用数据结构(如链表)的一个重要惯用法是使用递归而非循环进行遍历。
链表的末尾由一个特殊的nil值标记。在Go语言中,nil是一个非常重要的值,常被用作空指针值,在这里它表示列表的结束。
以下是打印链表的递归方法:
func (h *ListElement) printList() {
if h == nil {
fmt.Print("###")
return
}
fmt.Printf("%d -> ", h.data)
h.next.printList()
}
该方法的工作原理如下:
- 基准情况:如果当前节点
h是nil,表示已到达链表末尾,打印“###”并返回。 - 递归情况:打印当前节点的数据和一个箭头符号“->”,然后递归调用
h.next.printList()。这样就会依次打印出“数据1 -> 数据2 -> ... -> ###”的格式。
递归是遍历链表的模型化方法,你可以基于此模型修改代码来实现其他操作,例如计算链表中所有数据的和。
从切片构建链表
在实际编程中,切片可能是更常用和灵活的数据结构。因此,将切片转换为链表(或反向转换)是一项实用技能。以下函数演示了如何从一个整数切片构建链表:
func fillListFromSlice(head **ListElement, dataSlice []int) {
if len(dataSlice) == 0 {
return
}
current := createListElement(dataSlice[0], nil)
*head = current
for i := 1; i < len(dataSlice); i++ {
nextElement := createListElement(dataSlice[i], nil)
current.next = nextElement
current = nextElement
}
}
构建过程遵循以下步骤:
- 首先,用切片的第一个元素创建头节点,并将链表的头指针指向它。
- 然后,遍历切片中剩余的元素。
- 为每个新元素创建节点,并将前一个节点的
next指针指向这个新节点。 - 更新“当前”指针,使其指向这个新节点,以便为下一次链接做准备。
- 循环结束后,最后一个节点的
next指针自然保持为nil,标志着链表的结束。
完整示例与演示
现在,让我们在main函数中将这些部分组合起来,进行测试:
func main() {
var head *ListElement // 初始化为nil,代表空链表
data := []int{4, 8, 16, 32, 64, 128}
fillListFromSlice(&head, data)
head.printList() // 输出: 4 -> 8 -> 16 -> 32 -> 64 -> 128 -> ###
}
我们首先声明了一个指向ListElement的指针head,它被初始化为nil,代表一个空链表。然后,我们定义了一个包含6个整数的切片。调用fillListFromSlice函数后,这个切片被转换成了一个链表。最后,调用链表的printList方法递归地打印出所有元素。
总结与扩展思考
本节课中我们一起学习了Go语言中自引用数据结构的概念,并重点实现了最简单的形式——单向链表。我们涵盖了链表的节点定义、元素创建、递归遍历以及从切片构建链表的方法。
为了加深理解,你可以尝试以下扩展练习:
- 双向链表:修改结构体,使其包含
next和prev两个指针。这将使某些算法(如反向遍历)更容易、更快速,但需要额外的存储空间和维护开销。 - 复杂数据链表:将链表的数据类型从
int改为更复杂的结构,例如Student,并编写计算平均分、打印学生列表等功能。 - 其他操作:实现链表的插入、删除、反转等常见操作。


通过动手实践这些练习,你将能更牢固地掌握链表这一基础且重要的数据结构。
008:栈

概述
在本节课中,我们将要学习一种非常实用的数据结构——栈。我们将了解栈的基本概念、标准操作,并学习如何在Go语言中基于链表来实现一个栈。
栈的基本概念
上一节我们介绍了链表,本节中我们来看看另一种数据结构——栈。栈是一种标准的数据结构,你会在许多编程语言及其标准库(例如C++)中见到它。
栈可以由其他数据类型(如切片或链表)构建而成。当我们基于这些现有类型创建栈时,这种代码设计模式被称为“外观模式”。它是在现有结构之上附加了一层功能,并且通常会限制你对底层结构的操作。例如,如果我基于数组或切片创建了一个栈,那么切片中任意索引访问任意元素的能力就消失了,因为栈不支持这些操作。因此,尽管栈构建在切片或链表之上,但它被限制为只允许栈的标准操作。我们希望将这种数据类型视为栈,即使它是由更强大的数据类型构建的。
栈的标准操作
典型的栈至少包含四个标准操作。以下是这些操作的介绍:
- Push:将一个元素放入栈顶。
- Pop:从栈顶移除一个元素。
- Top:查看栈顶元素,但不移除它。
- IsEmpty:检查栈是否为空,返回一个布尔值。
在日常生活中,你见过栈的例子,比如大学食堂里的一摞托盘。你应该从顶部取托盘,归还脏托盘时也应该放到顶部。这就是栈的“后进先出”特性。你熟悉栈的push和pop操作,也可以查看栈顶或判断栈是否为空。
基于链表实现栈
现在让我们看看如何用链表来构建栈。
栈的结构与链表基本相同。我们之前称为ListElement的节点,现在称为Node。它包含一些数据(本例中使用字符串string,但可以是任何类型),以及一个指向下一个元素的指针next。栈本身有一个指向顶部节点的指针top(类似于链表的head)。
虽然底层结构相同,但我们将操作限制为栈的标准动词(push, pop, top, isEmpty)。
Push 操作
Push操作将一个元素放入栈顶。
func (s *Stack) Push(data string) {
newNode := &Node{data: data, next: s.top}
s.top = newNode
}
该方法在栈上创建一个新节点。newNode.next被设置为当前的s.top(即旧的栈顶),然后将s.top更新为这个新节点,从而将其置于栈顶。
Pop 操作
Pop操作从栈顶移除一个元素。
func (s *Stack) Pop() string {
if s.top == nil {
return "栈为空"
}
value := s.top.data
s.top = s.top.next
return value
}
该方法首先检查栈是否为空(s.top == nil)。如果为空,则返回一个错误信息。否则,它获取栈顶节点的数据值,然后将s.top指针移动到下一个节点(s.top.next),从而移除原栈顶元素,最后返回获取的数据值。
Top 操作
Top操作仅查看栈顶元素,而不移除它。
func (s *Stack) Top() string {
if s.top == nil {
return "栈为空"
}
return s.top.data
}
该方法与Pop类似,但它只返回栈顶的数据,而不移动s.top指针。有些地方也称之为Peek。
IsEmpty 操作
IsEmpty操作检查栈是否为空。
func (s *Stack) IsEmpty() bool {
return s.top == nil
}
这是一个简单的布尔方法,通过检查s.top是否为nil来判断栈是否为空。你可以在Top和Pop方法中使用IsEmpty()来使代码更具可读性。
栈的使用示例
了解了栈的实现后,我们来看看如何使用它。
首先,我们创建一个初始为空的栈,并询问用户想要放入多少个元素。
var stack Stack
var howMany int
fmt.Print("输入元素数量:")
fmt.Scanf("%d\n", &howMany)
然后,通过循环读取多个字符串(单词),并将它们依次推入栈中。
var word string
for i := 0; i < howMany; i++ {
fmt.Scanf("%s\n", &word)
stack.Push(word)
}
接下来,我们演示如何打印栈顶元素,并开始从栈中弹出所有元素。
fmt.Println("栈顶是:", stack.Top())
fmt.Println("开始弹出元素...")
for !stack.IsEmpty() {
fmt.Println(stack.Pop())
}
我们使用IsEmpty()方法作为循环条件,不断弹出并打印元素,直到栈为空。这展示了栈“后进先出”的特性:最后被推入的元素(Kate)会最先被弹出。
栈的应用与总结
栈有特殊的用途。虽然很多栈能做的事情用切片或链表也能完成,因此引入栈类型有时显得多余,但在某些场景下,栈是非常自然的選擇。例如,在计算波兰表达式时,栈的压入和弹出操作是进行求值的一种方式。


本节课中我们一起学习了栈数据结构。我们了解了栈的四个基本操作(Push, Pop, Top, IsEmpty),并基于链表在Go语言中实现了这些操作。通过示例代码,我们看到了栈“后进先出”的行为。你可以尝试修改代码使用不同的数据类型,或者进行更高级的练习,例如研究如何使用栈来求值波兰表达式。
009:二叉树

概述
在本节课中,我们将要学习一种更复杂的自引用数据结构——二叉树。我们将了解它的结构、用途,并学习如何在Go语言中实现二叉树的创建、插入和搜索功能。
什么是二叉树?🌳
上一节我们介绍了线性链表,它是一种非常重要的数据结构。本节中我们来看看二叉树。
二叉树是一种稍微复杂的自引用数据结构,在计算领域有许多用途。在线性链表中,每个节点有一个指向下一个元素的指针。而二叉树更复杂的地方在于,每个节点有两个指针,分别指向其左子节点和右子节点。这使得二叉树能够呈指数级增长,这一特性对其某些用途至关重要。
二叉树有一个根节点,然后有左、右子节点,这由两个指针表示。一个典型的二叉树可以容纳n个元素。与需要遍历n个元素的线性链表不同,在二叉树中,你需要遍历的路径长度大约是log₂(n)。这要短得多。例如,如果你有1000个元素,log₂(1000)大约是10。因此,在一个相当平衡的二叉树中,你可以存储一千个元素,而搜索最多可能只需要经过10个链接。这使得树成为一种非常高效的数据结构,用于维护、访问和更新大量数据,因此它在计算机科学和算法中极其有用。
视觉上,二叉树看起来像这样:有一个根节点,有左、右子节点。每个树元素最多可以有两个子节点。当然,也有更一般化的结构,比如三叉树或具有任意数量子节点的树。但二叉树是最有用的,因为它已经具有深度大约为log n的特性。
有可能出现一种倾斜的二叉树,例如,只有左子节点,那么元素最终看起来就像一条向左延伸的线性链表,所有右指针都是空的。但这会非常倾斜,并且不那么有用。
在Go中定义二叉树节点
现在让我们看看如何实际构建它。在Go语言中,我们有一个节点。与线性链表节点不同,二叉树节点有一个存放数据的地方(当然,键值可以远不止一个整数,可以是一整批数据),然后有左子节点和右子节点。这是自引用的。树的头部当然也是一个指向节点的指针。所以,一个指向节点的指针是头部,然后每个内部节点都有左、右子节点。
以下是节点结构的定义:
type Node struct {
key int
left *Node
right *Node
}
向二叉树插入节点
接下来,我们想看看如何向二叉树插入一个节点,实际上也就是如何构建一个节点。这将是一个有序的二叉树。我们将把较小的键推到左边,较大的键推到右边。这将使我们以后能够有效地在二叉树中搜索一个键。
以下是插入的逻辑步骤:
首先,检查二叉树是否为空。一个空的二叉树其根节点为nil(记住,nil是空指针的通用值)。如果它是空的,那么我们必须创建二叉树,实际上是创建二叉树的根节点。这意味着我们返回一个节点的地址,其中键是键元素,左元素是nil,右元素是nil。所以它没有子节点,只是一个用键创建的单节点。这是因为二叉树最初是空的。
如果二叉树不为空,那么我们在键值上进行搜索。如果键小于根的键,那么我们进行插入操作,并将其赋值给左指针值。否则,我们进行插入操作,并将其赋值给右指针。这是一个非常简单的例程,有三种情况:树为空,则通过创建根节点来创建树;树不为空,则判断是向右还是向左;如果向右,则在右侧插入,否则在左侧插入。
以下是插入函数的代码:
func insert(root *Node, key int) *Node {
if root == nil {
return &Node{key: key, left: nil, right: nil}
}
if key < root.key {
root.left = insert(root.left, key)
} else {
root.right = insert(root.right, key)
}
return root
}
在二叉树中搜索节点
现在我们将尝试搜索二叉树中现有的键。这里我们将使用递归。就像递归在线性链表情况下很有用一样,递归在这些自引用数据结构中非常有用。递归是一种非常自然的控制流形式。并不是说你不能将其转换为使用循环的东西,但逻辑更适合递归。
同样,我们将尝试在树中查找一个键。一种可能性是键不存在。如果根是nil,那么我们不可能有这个键,也没有东西可搜索。所以一个基本情况是返回false,如果没有东西可搜索了。否则,我们必须将要搜索的键与根的键进行比较。如果找到了,我们可以停止,并可以说找到了,返回true。顺便说一下,你也可以返回该值的位置,即持有该键的节点,因为可能你想对它进行一些更新或进一步的操作。当然,另一种情况是我们没有找到键。所以我们不能停止,然后我们利用树已被排序的特性,如果键小于根键,我们向左走;如果键不小于根键,我们向右走。然后我们执行递归步骤,即search(root.left, key)或search(root.right, key)。这就是递归。最终,它要么以返回true(如果找到键)停止,要么以返回false(如果一切最终到达叶节点,叶节点是nil)停止。
以下是搜索函数的代码:
func search(root *Node, key int) bool {
if root == nil {
return false
}
if key == root.key {
return true
}
if key < root.key {
return search(root.left, key)
}
return search(root.right, key)
}
使用示例:构建与搜索
现在让我们看看如何使用它并展示它。我们将从一个根开始,根只是一个指向节点的指针。这里是我们想要插入的键,我们将有一个整数切片,其中有八个键,它们将出现在那棵树中。然后,遍历该切片,我们将进行插入,插入就是我们构建树的方式。我们之前已经看过插入操作。一旦我们构建了一棵树,我们将进行搜索,寻找searchKey = 6。当然,我们看到6在构建部分被放入了树中,所以我们会说它被找到了。否则,我们就找不到它,但在这个例子中,我们会找到它。这是一个简单的二叉树使用示例,展示了如何创建它,如何搜索它。
以下是主函数的示例代码:
func main() {
var root *Node
keys := []int{8, 3, 10, 1, 6, 14, 4, 7, 13}
for _, key := range keys {
root = insert(root, key)
}
searchKey := 6
if search(root, searchKey) {
fmt.Printf("键 %d 已在二叉树中找到。\n", searchKey)
} else {
fmt.Printf("键 %d 未在二叉树中找到。\n", searchKey)
}
}
代码运行与总结
现在让我们转向运行代码。我们将运行代码,展示如何构建这棵有序树(记住它是有序的,因为我们可以比较键并向左或向右走,并且我们可以递归地搜索它)。稍后,我们可能会看看如何构建一种称为平衡树的东西,这对于搜索和更新更高效。
让我们看看我终端上二叉树程序的代码。同样,是自引用数据结构。所以一个节点里面只嵌入了节点。它有左部分和右部分,两者都是指针,然后是键信息,在我们的简单例子中只是一个int,但显然,如果你想构建一个庞大的数据库,那本身可能是一个非常复杂的结构。这是插入函数。插入的第一部分是,哦,我们还没有树。所以我们必须创建树。这就是为什么我们返回一个&Node{},并用信息nil、nil作为左、右节点来构建一个键。否则,我们向左或向右查找。根据键的位置,我们向左或向右走,并在适当的位置执行插入,以便该树通过这个左右过程进行排序。最后,我们再次展示了一个递归,在其中我们可以返回是否能在树中找到键。这是一个非常简单的搜索。我们实际上并没有返回节点,我们只是说是真还是假。键位于树中的某个地方,我们必须找到它,我们通过首先查看树(如果我们所在的位置是空的,即nil)来找到它,那么我们没有找到它,所以我们返回false。否则,我们进行左、右搜索。首先看看是否找到了键。所以==是测试root.key是否等于key,然后我们返回true,表示找到了。否则,我们必须向左或向右走。在每种情况下,我们都递归地调用该例程。所以最终,我们将以false或true结束。这是我们的主函数。我们正在从一个切片构建一棵树。该切片有九个值,所以将有一个包含九个节点的二叉树。我们对键的范围进行for循环,将各个键插入到二叉树中。然后我们尝试看看是否能找到6。所以如果一切正常,6将产生true。它会说printf,printf会说它已在二叉树中找到。否则,就没有找到。在这个Go代码中,它真的是一个非常简单的二叉树实现,具有插入和搜索功能。让我们试着运行一下代码。你看到的是,确实,键已在二叉树中找到。非常简单,容易完成,你应该尝试玩一下这个例子。


本节课中我们一起学习了二叉树的基本概念、在Go语言中的结构定义、节点的插入与搜索算法。通过递归,我们能够优雅地处理这种自引用数据结构。理解二叉树是学习更复杂树形结构(如平衡树)的重要基础。
010:图数据结构

概述
在本节课中,我们将要学习一种新的数据结构——图。图是列表和树等结构的泛化,在计算领域具有极高的价值。我们将学习如何用Go语言表示图,并理解其在现实世界中的应用,例如地图导航和路径规划。
图的基本概念
上一节我们介绍了树和链表,本节中我们来看看图。图由顶点和边组成。顶点可以代表地图上的城市或交叉路口,边则代表连接这些顶点的道路。
一个边结构连接一个源点和一个目标点。在图中,我们暂时用整数来标识不同的位置。例如,圣克鲁斯可能是1,萨利纳斯可能是17。边可以是有向的(单向)或无向的(双向)。对于每条边,都有一个从A点到B点的成本,这个成本通常用一个浮点数表示。
图本身就是由许多这样的边和顶点组成的集合。顶点就是那些数字。如果我的地图上有20个城市,就会有20个顶点的集合,然后会有一个切片来收集连接所有这些顶点的所有边。
顶点的另一个术语是节点。在图论中,你可能会看到节点这个词。
图的表示方法
我们将用Go语言的结构体来表示图。一个图包含顶点数量和边的集合。
以下是核心数据结构的定义:
// 定义边结构
type Edge struct {
src int // 源顶点
dest int // 目标顶点
weight float64 // 边的权重(成本)
}
// 定义图结构
type Graph struct {
vertices int // 顶点数量
edges []Edge // 边的切片
}
创建图与添加边
就像我们构建二叉树或线性链表一样,我们首先返回这样一个图的地址。我们用一个整数值vertices和一个初始为空的边切片来声明它。
我们将向图中添加边。添加边的函数,在我的注释中,即使它写在两行上,也意味着是一个单行注释。它说“添加一条边”。这是无向的,什么是无向?在道路上,无向不仅意味着我可以从这里开车到那里,也意味着我可以从那里开车到这里。所以可以把无向边想象成一条双车道高速公路。
无向边非常常见,有向边也很常见。但在无向的情况下,我们将把无向情况视为每个源点和目标点之间都有两条边。所以它只是一个元组,对吗?如果我有一条从3到7的边,我必须有一条从7到3的边。
因此,在这种方法中,每当我想要创建一个无向图时,我实际上创建的是两个有向边。在这段小代码中你会看到这一点。这里我们说,一条边是向g.edges切片中追加一个{src, dest, weight},同时也追加第二个边{dest, src, weight}。所以我们实际上是向切片中追加了两个元素。
以下是创建图和添加边的函数:
// 创建新图
func createGraph(v int) *Graph {
return &Graph{
vertices: v,
edges: make([]Edge, 0), // 初始化为空切片
}
}
// 添加无向边
func (g *Graph) addEdge(src, dest int, weight float64) {
// 添加从 src 到 dest 的边
g.edges = append(g.edges, Edge{src, dest, weight})
// 添加从 dest 到 src 的边,构成无向边
g.edges = append(g.edges, Edge{dest, src, weight})
}
示例:构建一个图
让我们使用这个功能。这是main函数中的一个示例,我们将创建一个有五个顶点的图。顶点将被标记为0、1、2、3和4。
以下是添加边的步骤:
- 第一条边是0到1,权重是4。
- 添加边0到4,权重是8。
- 添加边1到2,权重是8。
- 添加边1到3,权重是11。
- 添加边1到4,权重是8。
- 添加边2到3,权重是7。
- 添加边3到4,权重是9。
然后我们将打印这个图。
func main() {
// 创建有5个顶点的图
g := createGraph(5)
// 添加无向边
g.addEdge(0, 1, 4)
g.addEdge(0, 4, 8)
g.addEdge(1, 2, 8)
g.addEdge(1, 3, 11)
g.addEdge(1, 4, 8)
g.addEdge(2, 3, 7)
g.addEdge(3, 4, 9)
// 打印图
fmt.Println("打印图:")
fmt.Printf("图有 %d 个顶点\n", g.vertices)
for _, e := range g.edges {
fmt.Printf("边: %d -> %d, 权重: %.0f\n", e.src, e.dest, e.weight)
}
}
运行这个程序,你会在终端屏幕底部看到打印的内容。它显示图有5个节点。然后列出了节点之间的边,例如:0->1权重4,1->0权重4,0->4权重8,4->0权重8。
请注意,图的无向特性通过添加两对边来体现,其中源点和目标点是相反的,但它们具有相同的权重值。例如,4->0权重8,0->4权重8;1->2权重8,2->1权重8;1->3权重11,3->1权重11。
想象一下,如果这是我们的道路地图,11可能代表11英里,或者代表11分钟,无论我们用什么来优化这类地图问题。
总结


本节课中我们一起学习了图数据结构。我们了解了图由顶点和边组成,并学会了如何在Go语言中用结构体和切片来表示它。我们重点实现了无向图的创建和边的添加,并通过一个示例演示了如何构建一个简单的图。图是路径规划、社交网络分析等众多应用的基础。虽然现实世界的地图可能包含数十万个顶点,但核心原理与我们今天所学的相同。你可以尝试使用随机数生成器来创建不同大小和特性的图,这是探索图论更复杂工具的一个有趣起点。
011:迪杰斯特拉算法

概述
在本节课中,我们将学习最短路径问题,并使用Go语言通过编写迪杰斯特拉算法来解决它。这是一个涉及图数据结构的非平凡问题,我们将再次看到如何使用结构体,并学习一个非常有趣的算法。
最短路径问题简介
在涉及地图和路线的计算中,优化计算是最重要且最常进行的计算之一。想象一下本地联邦快递或UPS卡车司机,他有一个八小时的包裹配送计划。你肯定不希望他取一个包裹,送到目的地,然后返回基地,再取下一个包裹,再返回基地,因为这样成本会非常高。
因此,存在一些方法来规划路线,以最小化某种形式的成本,这本质上是一个优化问题。如果你的汽车带有GPS路线查找器,你可能已经见过这个功能。实际上,你的汽车正在使用某种更复杂的迪杰斯特拉算法版本来为你计算具有特定属性的路径。如今,它们甚至考虑交通状况,因此不一定是计算最短距离的路线。
在本教程中,我们将专注于在地图上优化最短距离路线。
迪杰斯特拉算法背景
迪杰斯特拉算法以荷兰计算机科学家艾兹赫尔·迪杰斯特拉的名字命名。1972年,他是图灵奖的早期获奖者之一,图灵奖在计算机科学领域相当于诺贝尔奖。除了算法,他还以1968年在《ACM通讯》上发表的一篇名为《Go To语句被认为有害》的论文而闻名。正如你可能注意到的,我在本课程中尚未讨论在Go语言中使用goto语句,因为大多数计算机科学家认为应该能够在不使用goto的情况下进行编码,以避免产生难以追踪的“面条式代码”。
算法原理与图表示
上一节我们介绍了最短路径问题的背景,本节中我们来看看迪杰斯特拉算法的工作原理以及如何用Go语言表示图。
迪杰斯特拉算法是一种贪心算法。贪心算法通常非常高效,因为它们倾向于在局部优化某些易于计算的东西,然后基于此继续推进,最终得到正确的解决方案。允许使用贪心算法的问题通常可以非常高效地解决。
我们将从一个无向图开始。无向意味着节点之间的边可以双向通行。你可以将边上的整数值视为路径的成本(例如公里数或英里数),并且所有成本都是正数,因为从一个城市到另一个城市通常不可能花费负时间。
算法步骤
- 我们从节点0(起点)开始,将其视为已访问(或“闭合”集合)。
- 我们查看从起点可以直接到达的节点,并选择成本最小的边。这保证了从起点到该节点的路径是最短的(贪心选择)。
- 将这个新节点加入“闭合”集合。
- 现在,我们从“闭合”集合中的所有节点出发,查看可以到达的下一个未访问节点,计算从起点经由“闭合”集合中的节点到达这些新节点的总距离。
- 从这些可能的距离中,再次选择最小的一个,并将对应的节点加入“闭合”集合。
- 重复此过程,直到找到目标节点(例如节点8)。此时,我们就找到了从起点到目标节点的最短路径及其距离。
本质上,我们是在构建一棵最短路径树。
用Go实现图数据结构
既然我们了解了算法原理,接下来就需要在Go中实现图这个数据结构。图不是Go语言内置的基本数据结构,我们通常通过创建反映该数据结构特征的结构体来实现。
首先,我们定义一个Edge(边)类型:
type Edge struct {
source, destination int
weight int
}
这个结构体包含源节点、目标节点和权重(成本)。我们使用整型标签0到N-1来表示一个有N个节点的图。Edge类型首字母大写,这是Go中创建类型的标准做法,也意味着它可以从包中导出。
接着,我们定义Graph(图)结构体来表示一个具有正边权重的无向图:
type Graph struct {
vertices int
edges []Edge
}
图包含顶点数量和一个边的切片。这样,我们就有了图的基本表示。
实现迪杰斯特拉算法
有了图结构,我们现在可以实现迪杰斯特拉算法本身。算法的核心是计算从起点到所有其他节点的最小距离。
我们将设置一个距离切片dist,初始时,除起点外,所有节点的距离都设为一个非常大的数(例如10000000),表示尚未到达。起点距离设为0。我们还有一个布尔切片visited来标记节点是否已被访问(即是否在“闭合”集合中)。
以下是算法的主要逻辑步骤:
- 初始化距离数组和访问数组。
- 循环
vertices次,每次循环:
a. 找到当前未访问节点中距离最小的节点u。
b. 将节点u标记为已访问。
c. 遍历所有边,如果一条边的源节点是u,并且通过u到达其目标节点v的距离比当前记录的距离更短,则更新v的距离。
以下是该算法的Go代码实现框架:
func dijkstra(g *Graph, start int) []int {
dist := make([]int, g.vertices)
visited := make([]bool, g.vertices)
const INF = 10000000
for i := range dist {
dist[i] = INF
}
dist[start] = 0
for i := 0; i < g.vertices; i++ {
// 找到未访问节点中距离最小的节点 u
u := -1
minDist := INF
for v := 0; v < g.vertices; v++ {
if !visited[v] && dist[v] < minDist {
minDist = dist[v]
u = v
}
}
if u == -1 {
break // 所有可达节点都已处理
}
visited[u] = true
// 更新通过 u 可到达的节点的距离
for _, e := range g.edges {
if e.source == u && !visited[e.destination] {
newDist := dist[u] + e.weight
if newDist < dist[e.destination] {
dist[e.destination] = newDist
}
}
}
}
return dist
}
构建示例图并运行算法
现在,让我们使用定义好的结构体和函数来构建一个具体的图并运行算法。我们将创建一个具有9个节点的图,并添加一系列边来模拟一个简单的道路网络。
以下是构建图和调用算法的示例代码:
func main() {
// 创建一个有9个顶点的图
g := newGraph(9)
// 添加边(无向图,每条边添加两次)
g.addEdge(0, 1, 3)
g.addEdge(0, 2, 6)
g.addEdge(0, 3, 7)
g.addEdge(1, 4, 2)
g.addEdge(1, 2, 1)
g.addEdge(2, 4, 1)
g.addEdge(2, 5, 3)
g.addEdge(3, 5, 2)
g.addEdge(4, 6, 2)
g.addEdge(5, 6, 1)
g.addEdge(5, 7, 2)
g.addEdge(6, 8, 3)
g.addEdge(7, 8, 1)
// 计算从节点0到所有节点的最短距离
distances := dijkstra(g, 0)
// 打印结果
fmt.Println("从节点0到各节点的最短距离:")
for i, d := range distances {
fmt.Printf("到节点%d: ", i)
if d == 10000000 {
fmt.Println("不可达")
} else {
fmt.Println(d)
}
}
}
运行此程序,你将得到从节点0(起点)到图中所有其他节点的最短距离。例如,输出可能显示到节点8(我们的“圣迭戈”)的最短距离是8。
总结
本节课中我们一起学习了如何使用Go语言实现迪杰斯特拉算法来解决最短路径问题。我们从理解问题背景和贪心算法原理开始,然后定义了表示图所需的Edge和Graph结构体。接着,我们逐步实现了迪杰斯特拉算法的核心逻辑,包括初始化、选择最小距离节点和更新距离等步骤。最后,我们构建了一个示例图并运行算法来验证结果。


通过这个练习,你不仅学习了一个经典的图算法,还实践了在Go中使用结构体来构建复杂数据结构的方法。建议你尝试手动模拟算法过程,或者修改代码在更小的图上测试,以加深理解。
012:双链表实现与回文检测 🧠

在本节课中,我们将学习如何实现一个双链表数据结构,并利用它来检测一个字符串是否为回文。我们将从双链表的基本概念开始,逐步构建其完整功能,最后应用它来解决实际问题。
概述
本周的作业是实现一个双链表。本周我们已经学习了如何使用结构体构建更复杂的数据结构,例如单链表和一些树结构。双链表的不同之处在于,它可以在两个方向上遍历,既可以从头部向前,也可以从尾部向后。这使得某些操作(如插入)更加容易。然而,其缺点是增加了内存开销,因为每个元素都需要一个指向前一个元素的指针和一个指向后一个元素的指针。
我们将使用双链表来存储一个字符序列,并判断该序列是否为回文。回文是指正读和反读都相同的序列,例如名字“Otto”。此外,我们还将添加一个标志位,用于控制是否将大写和小写字符视为相同。
双链表的基本概念
上一节我们介绍了双链表的优势与用途。本节中,我们来看看其具体的数据结构表示。
双链表的每个节点包含三个部分:存储的值、指向下一个节点的指针(next)以及指向前一个节点的指针(previous)。在我们的实现中,除了头指针(head),我们还将维护一个尾指针(rear),以便从尾部开始反向遍历。如果链表为空,head和rear都将是 nil。
以下是一个双链表节点的典型Go结构体表示:
type Node struct {
value interface{} // 可以是字符、整数或更复杂的结构体
next *Node
previous *Node
}
与单链表相比,双链表使得在中间插入或删除元素更加容易,因为我们可以直接通过previous和next指针来重新链接节点,而无需进行复杂的计算。
需要实现的功能
为了构建一个功能完整的双链表,我们需要实现一系列方法。以下是需要完成的功能列表:
AddToFront(value): 在链表头部添加一个新元素。AddToRear(value): 在链表尾部添加一个新元素。DeleteFront(): 删除链表头部的元素。DeleteRear(): 删除链表尾部的元素。Find(value): 在链表中搜索给定值,并返回指向该节点的指针(如果未找到则返回nil)。DeleteValue(value): 搜索并删除链表中第一个具有指定值的节点。IsEmpty(): 检查链表是否为空。Length(): 返回链表中元素的数量。ToSlice(): 将链表转换为一个切片。知道链表长度有助于预先分配切片空间。InsertAt(position, value): 在链表的指定位置插入一个新元素。DeleteAt(position): 删除链表中指定位置的元素。
实现这些方法后,我们的双链表将成为一个非常实用的数据结构。
应用:回文检测算法
现在我们已经了解了双链表的功能,本节将利用它来解决回文检测问题。
算法思路如下:
- 从终端读取一个字符串。
- 将字符串中的每个字符依次添加到双链表中。
- 同时从链表的头部(
head)和尾部(rear)开始,向中间移动。 - 在每一步中,比较头指针和尾指针所指向的字符。
- 如果设置了忽略大小写的标志,则在比较前将字符转换为统一的大小写(例如小写)。
- 如果所有对应的字符都相同,直到两个指针相遇或交错,则该字符串是回文。
这个算法有效地利用了双链表双向遍历的特性。
测试要求
在完成所有代码实现后,关键任务是演示其功能:读取字符串并判断是否为回文。
此外,我还要求你为所有其他功能编写测试。例如,你需要编写测试来验证InsertAt和DeleteAt等方法是否能正确工作。在本课程后期,我们将讨论Go语言中的单元测试,这是一种用于检查此类方法的专用工具。但现在,你需要自己编写简单的测试逻辑来确保代码的健壮性。
总结

本节课中我们一起学习了双链表的实现与应用。我们从双链表相对于单链表的优势(双向遍历)和劣势(额外内存开销)开始,详细定义了其数据结构。然后,我们列出并描述了需要实现的一系列核心方法,以构建一个功能完整的双链表。最后,我们探讨了如何利用这个数据结构来解决一个具体问题——回文检测,并强调了为所有功能编写测试的重要性。通过本次实践,你将更深入地理解链表数据结构及其在算法中的应用。
013:接口 03 01 01 🧩
在本节课中,我们将要学习Go语言中一个独特且强大的概念——接口。接口是一种抽象的类型规范,它允许我们定义一组方法,任何实现了这些方法的类型都自动满足该接口。这使得我们可以编写更通用、更灵活的代码。
接口的定义与概念
上一节我们介绍了方法,本节中我们来看看如何通过接口实现多态。接口在Go语言中是一个新颖的主题,在其他语言(如C语言)中并不常见。
接口是一种抽象类型规范。它允许你为某一类类型提供一个“配方”,这些类型必须隐式地匹配接口的规范。一旦某个类型匹配了接口,编译器就能识别它,而无需你显式声明。这种方式有时也被称为鸭子类型。
以下是定义一个名为 shape 的接口的示例:
type Shape interface {
Area() float64
}
这个接口规定,任何想要成为 Shape 的类型,都必须拥有一个名为 Area、返回 float64 类型的方法。
实现接口的具体类型
现在,让我们看看哪些具体类型可以实现这个 Shape 接口。
圆形 (Circle)
首先,我们定义一个表示圆形的结构体:
type Circle struct {
Radius float64
}
接着,我们为 Circle 类型实现 Area 方法:
func (c Circle) Area() float64 {
return 3.14 * c.Radius * c.Radius
}
正方形 (Square)
然后,我们定义一个表示正方形的结构体:
type Square struct {
Side float64
}
同样,为其实现 Area 方法:
func (s Square) Area() float64 {
return s.Side * s.Side
}
矩形 (Rectangle)
最后,我们定义一个表示矩形的结构体:
type Rectangle struct {
Width, Height float64
}
为其实现 Area 方法:
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
接口带来的多态性
接口为我们提供了一种多态的形式,即编写可以用于一系列类型的代码,只要这些类型满足接口规范。
以下是使用接口的示例:
func main() {
c := Circle{Radius: 5}
s := Square{Side: 6}
r := Rectangle{Width: 3, Height: 4}
shapes := []Shape{c, s, r}
for _, shape := range shapes {
fmt.Println(shape.Area())
}
}
在这段代码中:
- 我们创建了三个具体类型的实例:
Circle、Square和Rectangle。 - 我们将它们全部存储在一个
Shape类型的切片中。这正是多态的体现,因为所有这三种类型都满足Shape接口。 - 通过一个简单的
for循环,我们可以遍历切片中的每个形状并计算其面积,而无需关心它们的具体类型。
运行示例与总结
当我们运行上述程序时,会得到每个形状的面积计算结果。
本节课中我们一起学习了Go语言接口的核心概念。我们了解到:
- 接口是一种定义方法集合的抽象类型。
- 任何实现了接口所有方法的类型都隐式地满足了该接口。
- 利用接口可以实现多态,编写出更通用、更灵活的代码,例如将不同类型的对象存储在同一个切片中并进行统一处理。


接口是Go语言强大类型系统的关键组成部分,掌握它将帮助你构建更模块化和可扩展的程序。
014:排序接口详解 🧑💻

在本节课中,我们将学习Go语言中一个非常重要的接口——sort.Interface。我们将了解排序在计算中的重要性,并学习如何通过实现三个核心方法来使自定义类型支持排序。
排序是计算科学中最重要的话题之一。当需要处理海量数据时,例如美国社会保障局需要管理数亿人的信息,高效查找数据的能力至关重要,而这通常要求数据是有序的。因此,高效的排序算法是核心。幸运的是,Go语言的sort包提供了易于使用的接口,允许我们利用专家编写的、高效的排序算法(通常是快速排序的变体)来排序自定义类型。
上一节我们介绍了接口类型排序的概念,本节中我们来看看sort.Interface接口的具体要求。
排序接口的要求
要对数据进行排序,需要满足三个基本条件:
- 需要知道待排序元素的数量。
- 需要知道如何比较两个元素的顺序。
- 需要能够交换两个元素的位置。
Go语言的sort.Interface接口正是定义了这三个方法。
以下是sort.Interface接口的定义:
type Interface interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}
Len()返回集合中元素的数量。Less(i, j int) bool判断索引i的元素是否应排在索引j的元素之前。Swap(i, j int)交换索引i和j的两个元素。
任何实现了这三个方法的类型,都可以使用sort包中的排序功能。
实现排序接口
让我们通过一个具体例子来实践。我们将定义一个名为data的类型,它只是一个整数切片,并为其实现sort.Interface接口。
以下是实现代码:
type data []int
func (d data) Len() int {
return len(d)
}
func (d data) Less(i, j int) bool {
return d[i] < d[j]
}
func (d data) Swap(i, j int) {
d[i], d[j] = d[j], d[i]
}
Len()方法直接返回切片的长度。Less()方法使用小于运算符<比较两个整数。Swap()方法利用了Go语言的多重赋值特性,无需临时变量即可交换两个元素的值。
排序实践与性能演示
现在,我们将使用这个实现了接口的类型进行排序,并观察其性能。
以下是完整的演示程序步骤:
- 生成一个包含大量随机整数的
data切片。 - 打印排序前的前几个元素,确认其无序状态。
- 调用
sort.Sort()函数对数据进行排序。 - 打印排序后的前几个元素,验证排序结果。
package main
import (
"fmt"
"math/rand"
"sort"
"time"
)
func main() {
// 定义元素数量
const numElements = 1000000 // 可尝试修改为 10000000
rand.Seed(time.Now().UnixNano())
// 生成随机数据
d := make(data, numElements)
for i := range d {
d[i] = rand.Intn(numElements)
}
// 打印排序前的前10个元素
fmt.Println("Unsorted (first 10):", d[:10])
// 执行排序
sort.Sort(d)
// 打印排序后的前10个元素
fmt.Println("Sorted (first 10):", d[:10])
}
运行此程序,你会看到程序几乎能瞬间完成对一百万甚至一千万个整数的排序,这充分展示了Go标准库排序算法的高效性。


本节课中我们一起学习了Go语言的sort.Interface接口。我们理解了排序的重要性,掌握了实现该接口所需的三个核心方法:Len()、Less()和Swap(),并通过一个整数切片的例子实践了如何让自定义类型支持排序。最后,我们见证了Go标准库排序算法处理海量数据时的高效性能。通过实现这个接口,你可以轻松地对任何自定义数据类型进行排序。
015:错误处理


在本节课中,我们将要学习Go语言中一个非常重要的接口,它提供了Go语言进行错误检查的标准方式。这是Go社区采用的一种惯用风格。
错误接口概述
上一节我们介绍了接口的基本概念,本节中我们来看看Go语言中用于错误处理的核心接口。在C++等语言中,你可以使用中断来处理错误。但中断会使代码更复杂并增加开销,Go语言不希望提供这种机制。因此,Go语言避免了中断,转而使用一种基于错误接口的、更传统的错误检查方式。
这个接口定义了一个返回字符串的方法。当出现问题时,例如资源耗尽,错误信息可能是“机器资源耗尽,机器停止运行”。在某些情况下,Go语言会产生panic消息。但如果仅仅依赖Go操作系统来处理错误,你可能无法获得所需的全部信息。通过提供一个允许你写出明确问题描述的字符串的接口,你可以在运行时获得一种错误检查形式。
实际问题:求解二次方程
现在,让我们看一个可能产生错误的实际问题。这是一个经典的高中代数问题:求二次方程的根。
回忆一下,二次方程的形式是 A*x^2 + B*x + C = 0。求解该方程就是求其根。通常有两个根,求根公式是:
(-B ± √(B^2 - 4*A*C)) / (2*A)
这个公式给出两个根,正负号对应两个不同的根,除非根相同。当根相同时,判别式 B^2 - 4*A*C 必须等于0。
例如,简单的二次表达式 x^2 = 1,其根是+1和-1。
那么,我们在哪里会遇到错误呢?经典的错误情况是当判别式 B^2 - 4*A*C 为负数时。因为负数的平方根不是实数,它可以是虚数,但这里我们不处理虚数。因此,当我们看到平方根符号下的判别式为负数时,就遇到了错误。
- 正判别式表示二次方程有两个不同的实数解。
- 零判别式表示只有一个解。
- 负判别式将导致错误。
代码实现
让我们看看代码如何实现。二次方程表示为 a*x*x + b*x + c。判别式是 b*b - 4*a*c。然后我们计算:
root1 = (-b + √(discriminant)) / (2*a)root2 = (-b - √(discriminant)) / (2*a)
如果判别式为负,我们将产生一个错误。
现在转向实际的函数。函数名为roots。注意,该函数返回三个值。Go语言与C语言不同,可以返回多个值。这里我们指定前两个返回值是float64类型,最后一个返回值是error类型,即错误接口中指定的类型。
我们将返回两个值作为根。如果没有错误,我们将返回一个名为nil的特殊值。
以下是函数逻辑:
- 计算判别式。
- 检查是否为负数。
- 如果是负数,我们使用
errors.New函数创建一个错误,字符串为“discriminant is negative”。 - 如果判别式不小于零,我们计算判别式的平方根,然后返回两个根,第三个值为
nil,表示没有错误。这同样是一种约定。
主函数调用
在我们的main函数中,我们调用roots函数:root1, root2, err := roots(...)。
然后检查错误是否为nil。如果错误不是nil,则抛出错误;否则,打印出结果。
这是一个非常简单的计算。我们通过导入errors包来使用那个接口。
以下是roots函数的定义:
func roots(a, b, c float64) (float64, float64, error) {
discriminant := b*b - 4*a*c
if discriminant < 0 {
return 0, 0, errors.New("discriminant is negative")
}
sqrtDiscriminant := math.Sqrt(discriminant)
root1 := (-b + sqrtDiscriminant) / (2*a)
root2 := (-b - sqrtDiscriminant) / (2*a)
return root1, root2, nil
}
在main函数中,我们建立root1, root2, err变量来接收调用roots的结果。我们检查错误代码是否为nil。注意,这里使用:=,编译器能理解err是error类型。
检查逻辑如下:
- 如果错误不是
nil,则存在错误,打印错误。 - 如果错误是
nil,则执行else部分,打印出两个根。
我们测试了两个不同的方程:
x^2 + 2x + 1 = 0x^2 + 2x + 4 = 0
我们将看到一种情况没有错误,另一种情况有错误。
运行代码后,在屏幕底部可以看到:
- 第一种情况得到了答案:
Root 1 was -1,Root 2 was -1。这意味着在该特定情况下判别式为0。 - 第二种情况判别式确实为负,因此出现错误,无法在实数域内计算两个根。
你可以自己测试并验证。


总结


本节课中我们一起学习了Go语言中的错误处理接口。我们了解了Go语言如何通过error接口和返回多个值的函数,以简洁、明确的方式进行错误检查,而不是使用复杂的中断或异常机制。我们通过求解二次方程根的实际例子,演示了如何定义返回错误的函数,以及如何在调用处检查和处理错误。这种模式是Go语言编程中的核心惯用法之一。
016:使用接口与映射计算立体体积 📐

在本节课中,我们将学习如何编写一个Go程序来计算不同立体图形的体积。我们将重点运用本周学习的核心概念——接口,以及一个非常有用的数据结构——映射。
概述
我们将创建一个程序,能够计算球体、立方体和金字塔的体积。为了实现这个目标,我们需要定义一个名为 Solid 的接口,并创建一个映射来存储不同形状的立体图形及其对应的体积计算方法。
定义接口与结构体
上一节我们介绍了本次作业的目标。本节中,我们来看看如何定义所需的接口和具体形状的结构体。
首先,我们需要定义一个 Solid 接口。该接口要求实现一个返回 float64 类型的 Volume 方法。
type Solid interface {
Volume() float64
}
接下来,我们为三种立体图形定义对应的结构体。
- 球体 (Sphere):拥有一个半径字段。
type Sphere struct { Radius float64 } - 立方体 (Cube):拥有一个边长字段。
type Cube struct { Length float64 } - 金字塔 (Pyramid):我们假设金字塔的底面为正方形,因此它需要底面边长和高两个字段。
type Pyramid struct { Base float64 Height float64 }
实现体积计算方法
定义了结构体之后,我们需要为它们分别实现 Solid 接口所要求的 Volume 方法。以下是每种形状的体积计算公式。
- 立方体体积:边长的三次方。
func (c Cube) Volume() float64 { return c.Length * c.Length * c.Length } - 球体体积:公式为
(4/3) * π * r³。在Go中,我们可以使用math.Pi来获取π的值。import "math" func (s Sphere) Volume() float64 { return (4.0 / 3.0) * math.Pi * s.Radius * s.Radius * s.Radius } - 金字塔体积:对于底面为正方形的金字塔,其体积公式为
(底边长² * 高) / 3。func (p Pyramid) Volume() float64 { return (p.Base * p.Base * p.Height) / 3.0 }
使用映射存储与遍历
现在我们已经有了可以计算体积的各种形状。本节我们将学习如何使用映射来组织这些数据,并遍历它们以输出结果。
我们将创建一个映射,其键(Key)是表示形状名称的字符串,值(Value)是实现了 Solid 接口的类型。
solidsMap := make(map[string]Solid)
// 示例:向映射中添加形状
solidsMap["小球"] = Sphere{Radius: 2.5}
solidsMap["方盒"] = Cube{Length: 3.0}
solidsMap["金字塔"] = Pyramid{Base: 4.0, Height: 6.0}
我们需要编写一个函数来遍历这个映射,并打印每个形状的名称及其体积。
func printVolumes(solids map[string]Solid) {
for name, solid := range solids {
fmt.Printf("%s 的体积是:%.2f\n", name, solid.Volume())
}
}
从文件读取数据
在实际的作业中,形状及其尺寸信息将被存储在一个文件中。以下是处理流程。
- 按照作业文档描述的格式读取文件。
- 根据读取到的数据,创建相应的结构体实例(如
Sphere、Cube或Pyramid)。 - 将这些实例添加到之前定义的
map[string]Solid映射中。 - 最后,调用
printVolumes函数输出所有形状的体积计算结果。
总结

本节课中我们一起学习了如何利用Go语言的接口和映射来解决一个实际问题——计算多种立体图形的体积。我们定义了 Solid 接口,为三种不同的形状实现了该接口,并使用映射来灵活地存储和管理这些形状。最后,我们还探讨了如何从外部文件读取数据来填充我们的程序。通过这个练习,你应该对接口的用法和映射数据结构的强大之处有了更深入的理解。
017:通道和Go协程 🚀

在本节课中,我们将要学习Go语言中两个核心的并发概念:Go协程和通道。它们是Go语言内置的并发支持,使得编写并发程序变得更加简单和高效。
概述
Go语言的设计者认识到,在现代计算机硬件中,并发和并行执行的可能性越来越大。因此,他们决定将并发支持直接构建到语言中。这与C或C++等语言不同,在那些语言中,你需要依赖外部库(如POSIX线程)来实现并发,并且需要学习大量语言之外的知识。Go语言通过引入Go协程和通道,简化了并发编程。
Go协程:轻量级线程
上一节我们介绍了并发编程的背景,本节中我们来看看Go协程。Go协程是Go语言中实现并发的基本单位。
一个Go协程本质上是一个函数。当你使用 go 关键字调用一个函数时,这个函数就会在一个新的、独立的线程中开始执行,这个线程与主程序(main 函数所在的线程)并发运行。
代码示例:启动一个Go协程
go functionName()
例如,main 函数是程序的起始线程。当你执行 go foo() 时,就启动了一个与 main 并发运行的线程。这允许你编写效率更高的代码,尤其是在代码可以运行在多个处理器核心上时。现代计算机(如多核CPU的Mac)可以独立执行多个线程,从而可能将代码运行速度提升N倍(N是处理器核心数)。
通道:协程间的通信管道
仅仅启动并发任务是不够的,我们还需要一种方式来协调它们。这就是通道的用武之地。通道是一种特殊的管道,用于在不同Go协程之间安全地传递数据。
通道使用 make 函数创建,并指定其传递数据的类型。
公式/代码:创建通道
ch := make(chan int) // 创建一个传递整型数据的通道
你可以创建传递任何类型数据的通道,例如 string、float 或自定义的 struct。通道支持两种基本操作:发送和接收。
公式/代码:通道的发送与接收
ch <- value // 发送值到通道
value := <-ch // 从通道接收值
通道不仅用于传递数据,还可以用于同步协程。有时,你可能不关心通道传递的具体值,而只是将其作为一个信号,表示某个协程已经完成了工作。
并发编程的优势与挑战
使用Go协程和通道进行并发编程主要有两大优势:
- 利用多核处理器:可以显著加速计算密集型任务。
- 简化异步架构:某些编程模型(如生产者-消费者、工作池)用并发来表达会更加自然。
然而,并发编程也带来了挑战。与传统的线性代码相比,多线程代码更难以理解和调试。常见的问题包括:
- 死锁:一个或多个协程因等待彼此持有的资源而永远阻塞。
- 竞态条件:多个协程以不可预测的顺序访问和修改共享数据,导致结果错误。
Go语言采用的并发模型基于托尼·霍尔提出的 CSP(通信顺序进程) 理论。这是一种经过深思熟虑、相对简单且易于推理的并发编程方式,有助于减少上述问题的发生。
实践示例:使用通道协调协程
理论介绍完毕,现在让我们通过一个具体的例子来看看Go协程和通道如何协同工作。
在这个程序中,main 函数将启动两个匿名Go协程,每个协程模拟执行一些“工作”(通过休眠来模拟),然后通过一个共享的通道发送消息。main 函数会从通道中接收这些消息。
代码分析:
- 创建通道:
main函数首先创建一个传递字符串的通道。ch := make(chan string) - 启动第一个Go协程:这个协程将“工作”(休眠)2秒,然后向通道发送消息。
go func() { time.Sleep(2 * time.Second) ch <- “Hello from Go routine 1” }() - 启动第二个Go协程:这个协程将“工作”(休眠)1秒,然后向通道发送消息。
go func() { time.Sleep(1 * time.Second) ch <- “Hello from Go routine 2” }() - 主程序接收消息:
main函数两次从通道接收消息并打印。由于通道操作是阻塞的,main会等待消息到来。fmt.Println(<-ch) // 接收第一个消息 fmt.Println(<-ch) // 接收第二个消息
运行逻辑与结果:
程序启动后,三个线程(main 和两个Go协程)开始并发执行。Go运行时会负责将这些协程调度到可用的处理器核心上。
- 由于第二个协程只休眠1秒,它通常会先完成工作并向通道发送消息。
- 第一个协程休眠2秒,会稍晚发送消息。
- 因此,程序输出通常会是:
Hello from Go routine 2 Hello from Go routine 1 - 整个程序的最短运行时间约为2秒(由耗时最长的协程决定),而不是3秒(1秒+2秒),这体现了并发带来的效率提升。
不确定性:如果两个协程的工作时间相同(例如都休眠1秒),那么哪个消息先到达通道取决于Go运行时的调度,每次运行的结果可能不同。这正说明了并发程序的不确定性和调试的复杂性。
总结
本节课中我们一起学习了Go语言并发的两大基石:
- Go协程:使用
go关键字启动的轻量级线程,是实现并发执行的基础。 - 通道:使用
make(chan Type)创建的类型化管道,是协程间进行通信和同步的安全方式。


通过“休眠-发送消息”的示例,我们看到了如何利用通道来协调多个并发协程的执行顺序,并观察了并发执行带来的效率提升和结果的不确定性。理解并掌握Go协程和通道,是编写高效、可靠Go并发程序的关键。建议你亲自动手修改示例中的休眠时间,观察不同的运行结果,以加深理解。在构思复杂并发逻辑时,绘制简单的线程执行顺序图也会非常有帮助。
018:死锁

概述
在本节课中,我们将要学习并发编程中的一个常见且棘手的问题:死锁。我们将通过一个具体的Go程序示例来理解死锁是如何发生的,以及如何识别和避免它。
编写并发程序的一个难点在于调试。在单线程的非并发程序中,我们可以通过逐步跟踪代码来检查每一步的执行结果。然而,在并发程序中,多个线程(goroutine)独立运行,它们之间的交互方式难以预测。因此,即使单个线程工作正常,线程间的交互也可能引发问题。其中一个关键问题就是死锁。
什么是死锁?🧐
死锁涉及多个线程相互等待对方释放资源。一个经典的比喻是两个法国人在门口互相礼让,都说“您先请”,结果两人都无法通过,形成了僵局。在程序中,当多个goroutine竞争同一资源(如通道),并且没有明确的顺序来决定谁先执行时,就可能发生死锁。
在某些情况下,Go运行时会检测到死锁并触发panic,这是一种运行时错误,会停止程序并给出错误信息。但有时,死锁就像那两个法国人一样,程序会永远等待下去,看起来像是陷入了无限循环。这时,你只能从外部(例如在Unix系统中使用Ctrl+C)终止程序。
一个死锁示例 🔄
下面是一个会导致死锁的简单Go程序。我们将创建两个通道和两个goroutine(主goroutine和一个匿名goroutine),它们试图通过通道交换消息,但由于顺序错误而相互等待。
package main
import "fmt"
func main() {
c1 := make(chan string)
c2 := make(chan string)
go func() {
mess := "in anonymous function"
c1 <- mess
stress := <-c2
fmt.Println(mess, stress)
}()
mess := "in main message"
c2 <- mess
stress := <-c1
fmt.Println(mess, stress)
}
代码解析
c1和c2是两个无缓冲的字符串通道。- 匿名goroutine试图将消息
mess发送到c1,然后从c2接收消息到stress,最后打印两者。 - 主goroutine将消息发送到
c2,然后从c1接收消息,最后打印。
问题在于发送和接收操作的顺序不匹配,导致两个goroutine都在等待对方先发送数据,从而形成死锁。
运行此程序,你会看到类似以下的错误:
fatal error: all goroutines are asleep - deadlock!
如何修复死锁?🔧
要修复这个死锁,我们需要确保通信的顺序一致,避免循环等待。修改后的版本如下:
package main
import "fmt"
func main() {
c1 := make(chan string)
c2 := make(chan string)
go func() {
mess := "in anonymous function"
c1 <- mess
stress := <-c2
fmt.Println(mess, stress)
}()
mess := "in main message"
stress := <-c1 // 先接收
c2 <- mess // 后发送
fmt.Println(mess, stress)
}
修复解析
在修复版本中,我们调整了主goroutine中的操作顺序:
- 主goroutine首先从
c1接收数据(对应匿名goroutine的发送)。 - 然后主goroutine向
c2发送数据(对应匿名goroutine的接收)。
这个顺序与匿名goroutine中的操作顺序(先发后收)相匹配,从而避免了相互等待。
运行修复后的程序,你会发现程序正常执行完毕。但请注意,由于主goroutine没有等待匿名goroutine完成(例如使用sync.WaitGroup),你可能只会看到主goroutine的打印输出,匿名goroutine的输出可能在程序退出前来不及打印。


总结
本节课我们一起学习了并发编程中的死锁问题。我们了解到死锁是多个goroutine因相互等待资源而无法继续执行的状态。通过一个具体的代码示例,我们看到了死锁是如何因通信顺序错误而产生的,并学习了通过调整通道操作的顺序来避免死锁。请记住,在编写并发代码时,仔细规划goroutine间的通信顺序至关重要。在后续课程中,我们将探索更多协调并发任务的方法。
019:工作者模式


概述
在本节课中,我们将要学习Go语言中一个更复杂的并发与并行模式:工作者模式。我们将创建多个工作者(goroutine)来处理一系列任务,并使用带缓冲的通道来协调它们的工作,以避免程序死锁。
工作者模式架构
上一节我们介绍了基础的并发概念,本节中我们来看看如何构建一个实际工作的并发系统。我们的架构包含以下核心部分:
- 工作者:即goroutine,它们是执行具体任务的线程。
- 任务:需要被计算的具体工作项。
- 通道:用于在工作者和主程序之间传递任务和结果。
我们需要确保任务能够被工作者有效处理。在这种情况下,我们需要使用一种更复杂的通道:带缓冲的通道。
带缓冲的通道与死锁
如果使用无缓冲通道,并且任务数量超过工作者数量,程序可能会发生死锁。死锁是指所有goroutine都在等待对方,导致程序无法继续执行。Go运行时通常会检测到这种情况并报告panic。
为了避免死锁,并且因为我们知道希望同时运行的任务数量,我们可以在创建通道时指定其缓冲区大小。这决定了通道中可以暂存多少条消息。
代码示例:创建带缓冲的通道
jobs := make(chan int, numJobs) // 任务通道,缓冲区大小为任务总数
results := make(chan ResultType, numJobs) // 结果通道,缓冲区大小同样为任务总数
程序设计与实现
我们将编写一个程序,计算一系列整数的平方根。这比简单的sleep操作更具实际意义。
定义数据结构
首先,我们定义一个结构体来封装计算结果。通过自定义结构体,我们可以在通道中传递多个信息。
代码示例:定义结果结构体
type sqrtResult struct {
number int // 要计算平方根的原始数字
answer float64 // 计算得到的平方根结果
}
工作者函数
工作者函数负责接收任务、执行计算并返回结果。
以下是工作者函数的核心逻辑:
- 接收一个工作者ID、一个任务输入通道和一个结果输出通道。
- 循环从
jobs通道中读取任务。 - 对每个任务(整数
j)计算其平方根。注意,math.Sqrt函数接收float64类型参数,因此需要进行类型转换。 - 将计算结果封装成
sqrtResult结构体,并发送到results通道。 - 所有任务完成后,打印退出信息。
代码示例:工作者函数
func worker(id int, jobs <-chan int, results chan<- sqrtResult) {
for j := range jobs {
fmt.Printf("worker %d started job %d\n", id, j)
// 执行计算:计算整数j的平方根
result := math.Sqrt(float64(j))
// 将结果发送到通道
results <- sqrtResult{number: j, answer: result}
fmt.Printf("worker %d finished job %d\n", id, j)
}
fmt.Printf("worker %d exiting\n", id)
}
主函数逻辑
在主函数中,我们设置任务和工作者,并启动整个流程。
以下是主函数的步骤:
- 定义任务总数和工作者数量。
- 创建带缓冲的任务通道和结果通道。
- 启动指定数量的工作者goroutine。
- 将所有任务发送到
jobs通道。 - 从
results通道接收所有结果并打印。 - 关闭通道,释放资源。
代码示例:主函数
func main() {
const numJobs = 5
const numWorkers = 3
jobs := make(chan int, numJobs)
results := make(chan sqrtResult, numJobs)
// 启动工作者
for w := 1; w <= numWorkers; w++ {
go worker(w, jobs, results)
}
// 发送任务
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // 任务发送完毕,关闭通道
// 接收结果
for r := 1; r <= numJobs; r++ {
result := <-results
fmt.Printf("square root of %d is %.3f\n", result.number, result.answer)
}
close(results) // 结果接收完毕,关闭通道
}
运行结果分析
程序运行时,输出顺序可能每次都不相同。例如:
- 工作者3可能先处理任务1,然后处理任务4。
- 工作者1可能处理任务2和任务5。
- 结果的打印顺序也可能与任务提交顺序不同。
这是因为Go调度器以非确定性的方式将任务分配给空闲的工作者,并且各个goroutine的执行进度由操作系统调度。对于计算量很小的任务(如平方根),这种调度顺序的随机性会更加明显。
核心概念:在并发程序中,任务的启动顺序、执行顺序和完成顺序没有必然联系。它们由Go运行时调度,可能产生看似“混乱”但高效的执行流。
总结
本节课中我们一起学习了Go语言的工作者模式。我们构建了一个使用带缓冲通道的并发系统,其中多个工作者goroutine从共享通道中获取任务,并将结果发送到另一个通道。我们了解了使用缓冲通道可以防止任务过载导致死锁。通过实际计算平方根的例子,我们观察到并发执行的非确定性和调度器的作用。你可以通过调整工作者数量、任务数量或增加任务复杂度来进一步探索这个模式,并测试在你自己计算机上的性能提升效果。




020:使用Select避免死锁

概述
在本节课中,我们将要学习Go语言中一个用于避免并发程序死锁的关键字:select。我们将了解select语句的语法、工作原理,并通过一个具体的例子来演示它如何解决上一节中遇到的死锁问题。
死锁问题回顾
上一节我们介绍了死锁,这是一个非常棘手的问题。我们不希望程序发生死锁,就像我们不希望程序陷入无限循环一样。
避免死锁的一个关键方法是使用select。
什么是Select?
select是Go语言中的一个关键字,在C语言中并不存在。从某种程度上说,Go是C语言的现代版本。C语言诞生于50年前,并在1989年进行了一次主要修订,形成了ANSI C标准。即使如此,那也是35年前的事情了。如今,许多人转向了C++等新语言,它们有不同的现代化方式。但C语言的优点在于它小巧,概念清晰,因此至今仍被广泛使用。
Go语言的发明者,谷歌的团队,其领导者与当年贝尔实验室参与开发Unix系统并在早期使用C语言的人是同一批,例如Thompson和Pike。他们不希望语言有大量的关键字,但由于Go内置了并发机制,而C语言没有,所以Go需要新的关键字。在C语言中,并发通常通过像pthread这样的特殊库来实现。Go语言则将其作为语言特性,这在很多方面使其更简单、更规范。
除了我们已经见过的用于启动协程的go关键字,select是另一个用于决定哪个通道操作应该优先执行的关键字。
Select的语法
让我们来看看select语句的语法。select语句非常类似于case语句。case语句可以看作是if语句的一种泛化形式。
select语句包含多个case子句,每个case子句使用关键字case。在case关键字之后,是一个发送或接收表达式,这本质上就是对一个通道的操作。然后是一个冒号,以及可能的一条语句(该语句可以为空)。无论如何,case子句将决定哪个通道操作被执行。
你可以根据需要定义任意数量的case子句,通常case的数量与通道的数量或通道的使用方式相关。
此外,你还可以选择性地添加一个default语句,通常放在最后。default意味着:如果没有一个case条件为真,则执行default语句。default语句很可能用于终止程序,因为你原本期望某个通道被选中,但实际没有,这意味着程序出现了混乱,你可以通过default来确保程序以适当的方式结束。
Select示例
以下是一个select的例子:
select {
case foo := <-channel1:
// 什么都不做
case channel2 <- boo:
// 发送操作
default:
fmt.Println("done")
}
这里有多个case可能被匹配。需要注意的是,与switch语句不同,switch会按顺序检查第一个匹配的case并执行。而在select中,所有case具有同等地位。如果有多个case同时满足条件,其中一个会被随机选中执行。因此,程序的逻辑是:如果任何一个case条件满足,就使用它。具体使用哪一个并不重要,因为程序会继续执行,这决定了接下来哪个操作会进行。select机制就是坐在通道之上,决定“谁下一个执行”。
使用Select解决死锁
我们有一个之前导致死锁的程序,它有两个通道。我们还有一个匿名函数用于打印信息,然后在主函数中执行。
这个程序与之前死锁程序的区别在于,我们使用了select。在select内部,我们查看两个case,哪个条件满足就执行哪个。最后,我们执行打印操作。现在,程序将不会出现死锁。
让我们运行这个程序看看效果。之前没有使用select时,程序发生了死锁并阻塞。而使用了select之后,程序成功运行,没有出现死锁情况。select告诉我们:“优先处理这个,因为我匹配到了这个通道表达式。”于是它使用了那个通道,并打印出了信息。
练习与探索
你可以尝试修改这个程序,让两个线程(协程)都能完成它们的打印。你可能需要引入WaitGroup来实现同步。尝试这样做会让你对这部分内容有更深入的理解。


总结
本节课我们一起学习了Go语言中的select语句。我们了解到select是避免通道操作导致死锁的关键工具,它通过提供一种机制来监听多个通道操作,并随机执行一个就绪的case,从而打破了潜在的竞争和等待循环。通过将select应用到之前的死锁示例中,我们成功解决了问题,使程序得以顺利执行。掌握select是编写健壮、高效并发Go程序的重要一步。
021:等待组 (WaitGroup) 🧵

在本节课中,我们将要学习Go语言sync包中的一个重要工具——等待组 (WaitGroup)。它用于协调多个并发执行的goroutine,确保主程序在所有goroutine完成工作后才继续执行,从而避免程序过早结束或产生竞态条件。
概述
上一节我们介绍了并发编程的基本概念。本节中我们来看看如何使用sync.WaitGroup来管理多个goroutine的同步执行。我们将通过一个计算密集型任务(对一个大数组求和)的实例,演示如何利用WaitGroup实现并发计算并等待所有任务完成。
关键概念:sync.WaitGroup
sync包是Go语言中进行并发编程的重要工具包。其中,sync.WaitGroup类型对于管理并发执行顺序、防止线程间相互干扰(如死锁)至关重要。
WaitGroup的核心作用是跟踪正在执行的goroutine数量。它的工作方式就像一个计数器:
- 当启动一个新的goroutine时,我们增加 (Add) 计数器。
- 当一个goroutine完成工作时,我们减少 (Done) 计数器。
- 主程序可以等待 (Wait) 计数器归零,这意味着所有goroutine都已执行完毕。
以下是WaitGroup的三个核心方法:
var wg sync.WaitGroup
wg.Add(delta int) // 增加计数器值
wg.Done() // 减少计数器值(通常减1)
wg.Wait() // 阻塞,直到计数器归零
除了WaitGroup,sync包还提供了其他同步原语,例如互斥锁 (Mutex),用于保护临界区资源,防止被错误顺序访问。
实战示例:并发求和
为了展示WaitGroup的实际应用,我们将创建一个执行“真实工作”的程序,而不是简单的延时模拟。我们将生成一个包含1000万个随机浮点数的数组,然后并发地计算其总和。
1. 工作函数定义
首先,我们定义一个执行实际计算的工作函数addSum。它生成大量随机数据并进行求和。
func addSum() float64 {
const size = 10_000_000 // 1000万个元素
data := make([]float64, size)
// 生成0到10之间的随机数填充数组
for i := 0; i < size; i++ {
data[i] = rand.Float64() * 10
}
// 计算数组总和
sum := 0.0
for _, v := range data {
sum += v
}
return sum
}
代码解释:
rand.Float64()生成一个[0.0, 1.0)区间的随机浮点数。- 乘以10后,我们得到
[0.0, 10.0)区间的随机数。 - 理论上,这些数的平均值应接近5,因此总和应接近
5 * 10,000,000 = 50,000,000。
2. 使用WaitGroup管理并发
接下来,在main函数中,我们将使用WaitGroup来启动多个goroutine并发执行addSum函数,并等待它们全部完成。
以下是使用WaitGroup的标准模式:
func main() {
// 1. 声明一个WaitGroup
var wg sync.WaitGroup
// 2. 设置需要等待的goroutine数量
numThreads := 5
wg.Add(numThreads)
startTime := time.Now()
// 3. 启动多个goroutine
for i := 0; i < numThreads; i++ {
go func(threadID int) {
// 确保在goroutine结束时调用Done
defer wg.Done()
// 执行实际工作
sum := addSum()
fmt.Printf("Thread %d: sum = %f\n", threadID, sum)
}(i) // 将循环变量i作为参数传入,避免闭包捕获问题
}
// 4. 主程序等待所有goroutine完成
wg.Wait()
// 5. 计算并打印总耗时
elapsed := time.Since(startTime)
fmt.Printf("Total time taken: %v\n", elapsed)
}
代码解释:
- 声明与添加:首先声明一个
WaitGroup变量wg。调用wg.Add(5)表示我们将启动5个goroutine,计数器初始为5。 - 启动goroutine:使用
go关键字启动5个匿名函数(goroutine)。每个goroutine执行以下操作:defer wg.Done():使用defer关键字确保无论函数如何退出,wg.Done()都会被调用,将计数器减1。这是一种确保资源被正确释放的常用习惯。- 执行工作:调用
addSum()函数进行计算。 - 打印结果:打印该goroutine计算出的总和及其线程ID。
- 等待完成:主程序调用
wg.Wait()。此调用会阻塞,直到WaitGroup的内部计数器减到0(即所有5个goroutine都执行了wg.Done())。 - 计算耗时:所有goroutine完成后,程序继续执行,计算并打印从开始到结束的总时间。
为什么需要wg.Wait()?
如果没有wg.Wait(),主goroutine(即main函数)会立即继续执行并结束程序,而不会等待其他5个计算goroutine完成。wg.Wait()是同步点,它保证了并发任务的完成。
运行结果与分析
运行上述程序,你可能会看到类似以下的输出(具体数值和顺序会因随机数和调度而异):
Thread 2: sum = 50012841.927364
Thread 4: sum = 49995367.182541
Thread 0: sum = 50001985.653291
Thread 3: sum = 50000536.991234
Thread 1: sum = 50009204.817555
Total time taken: 5.23456789s
观察要点:
- 结果正确性:每个总和都接近5000万(5e7),验证了计算逻辑的正确性。
- 执行顺序:goroutine完成的打印顺序(如
Thread 2最先完成)与启动顺序(0,1,2,3,4)不一致。这完全正常,体现了并发执行的本质:多个任务被调度到不同的CPU核心上并行执行,完成时间取决于系统调度和计算负载,而非启动顺序。 - 性能提升:通过将一个大任务拆分成多个小任务并发执行,在现代多核处理器上,通常能获得比单线程顺序执行更快的总处理速度。你可以尝试调整
numThreads(如改为1或等于CPU核心数)来观察总耗时的变化。
总结
本节课中我们一起学习了Go语言中sync.WaitGroup的核心用法。我们了解到:
WaitGroup的作用:它是一个计数器,用于等待一组goroutine的集合完成执行。- 标准使用模式:遵循
wg.Add(N)-> 启动N个带defer wg.Done()的goroutine ->wg.Wait()的模式,是管理goroutine生命周期的可靠习惯用法。 - 并发与顺序:使用并发编程时,任务的执行和完成顺序是不确定的,这由Go运行时调度器决定,但最终结果的正确性可以得到保证。
- 实践意义:通过将计算密集型任务(如处理大型数据集)分解为可并发的子任务,可以充分利用多核CPU的优势,提高程序运行效率。


建议你动手修改示例代码:改变goroutine的数量、调整数组大小、或者尝试不同的计算任务,以更深入地理解WaitGroup和并发编程的效果。
022:互斥锁

概述
在本节课中,我们将要学习并发编程中的一个核心概念——互斥锁。我们将通过一个经典的“哲学家就餐问题”来理解为什么需要互斥锁,以及如何在Go语言中使用sync.Mutex来实现它,从而确保多个并发线程能够安全地共享资源。
互斥锁的重要性
并发程序经常包含所谓的“临界区”。假设有多个线程在同时运行,每个线程都需要访问一个关键资源。那么就必须有一种机制来告诉其他线程“请等待”。因此,我们需要一种能力,让一个线程能够独占使用某个资源。
在Go语言的sync包中,我们已经见过WaitGroup。该包中还有一个名为sync.Mutex的类型。Mutex代表互斥锁,我们可以使用它的Lock和Unlock操作。这允许一个线程为关键资源或代码的临界区“上锁”,在该线程“解锁”之前,它是唯一能使用该资源的线程,之后其他线程才能使用。这非常重要。
哲学家就餐问题
我们将通过一个非常著名的问题来研究互斥锁,即“哲学家就餐问题”。在这个问题中,5位哲学家围坐在一张圆桌旁,面前放着食物。每两位哲学家之间有一根筷子(或西方版本中的叉子)。为了从碗中吃饭,哲学家必须使用两根筷子或两把叉子。
想象一下,哲学家必须拿起左边的筷子和右边的筷子。如果所有哲学家都同时抓起右边的筷子,那么每个人都只有一根筷子,他们将无法吃饭,导致“饥饿”或“死锁”。我们希望避免这种情况。这个问题由Dijkstra提出,他通过创建一种称为“信号量”的互斥锁形式解决了它。后来,另一位计算机科学家Tony Hoare(他也发明了Go语言并发模型所基于的CSP理论)也使用互斥锁解决了这个问题。
下图展示了这个问题:哲学家们围坐在桌旁,不吃饭时就在思考。但他们必须时不时吃饭,否则就会饿死。
代码解析
在我们的Go代码中,有五位哲学家和五把叉子。每位哲学家都有一个编号(1到5)。每位哲学家都有一把左叉和一把右叉。互斥锁意味着左叉和右叉都可以被锁定和解锁,这样一位特定的哲学家就可以独占这些资源并吃饭。
让我们看看代码是如何实现的。
我们有一个函数,接收一位哲学家,并尝试看他是否会吃饭。这些函数将作为线程运行。首先,哲学家会思考一段时间(随机时长)。然后,当他们尝试吃饭时,会去拿叉子。如果他们能拿到左叉,就锁定它;接着必须去拿右叉。一旦他们获得了这些资源,其他哲学家就被排除在外,无法抓取这些叉子,然后他们就可以开始吃饭。
吃饭过程也会持续一段随机时间。之后,哲学家必须解锁资源,否则其他哲学家会饿死。不同的线程(代表不同的哲学家)可以思考,或者在能够获取资源时尝试吃饭;如果无法获取,就等待。最终,拥有资源的哲学家会解锁它,下一个在随机时间点结束思考的哲学家就会来拿起叉子,进入他的临界区吃饭。
主函数逻辑
在main函数中,我们初始化随机数种子,以确保每个例程从伪随机数序列的不同位置开始。然后我们创建一些叉子(包含互斥锁)和相同数量的哲学家。接着,为每位哲学家分配左叉和右叉。我们使用一个WaitGroup来同步所有线程。在一个循环中,我们为每位哲学家启动一个线程来执行eat方法。每个线程都会尝试吃饭,随机地获取锁、解锁,让下一位哲学家吃饭。不吃饭时,他们就在思考。最后,wg.Wait()等待所有程序同步完成。
在这个机制下,没有人会饿死,因为我们不允许所有哲学家同时抓起一根筷子或叉子,总有人能有两把叉子可用。
代码细节
以下是程序的主要部分:
import (
"fmt"
"math/rand"
"sync"
"time"
)
const numPhilosophers = 5
type Philosopher struct {
id int
leftFork *sync.Mutex
rightFork *sync.Mutex
}
func (p *Philosopher) eat() {
// 思考随机时间
thinkTime := rand.Intn(5) + 1
fmt.Printf("哲学家 %d 思考 %d 秒\n", p.id, thinkTime)
time.Sleep(time.Duration(thinkTime) * time.Second)
// 尝试拿起左叉
p.leftFork.Lock()
fmt.Printf("哲学家 %d 拿起左叉\n", p.id)
// 尝试拿起右叉
p.rightFork.Lock()
fmt.Printf("哲学家 %d 拿起右叉\n", p.id)
// 吃饭随机时间
eatTime := rand.Intn(5) + 1
fmt.Printf("哲学家 %d 吃饭 %d 秒\n", p.id, eatTime)
time.Sleep(time.Duration(eatTime) * time.Second)
// 放下叉子
p.rightFork.Unlock()
fmt.Printf("哲学家 %d 放下右叉\n", p.id)
p.leftFork.Unlock()
fmt.Printf("哲学家 %d 放下左叉\n", p.id)
}
func main() {
rand.Seed(time.Now().UnixNano())
// 创建叉子
forks := make([]*sync.Mutex, numPhilosophers)
for i := 0; i < numPhilosophers; i++ {
forks[i] = &sync.Mutex{}
}
// 创建哲学家并分配叉子
philosophers := make([]*Philosopher, numPhilosophers)
for i := 0; i < numPhilosophers; i++ {
philosophers[i] = &Philosopher{
id: i,
leftFork: forks[i],
rightFork: forks[(i+1)%numPhilosophers], // 环形分配
}
}
var wg sync.WaitGroup
wg.Add(numPhilosophers)
// 启动哲学家线程
for _, p := range philosophers {
go func(phil *Philosopher) {
defer wg.Done()
phil.eat()
}(p)
}
wg.Wait()
fmt.Println("所有哲学家都吃完了。")
}
程序运行与观察
运行程序时,你会看到哲学家们以随机顺序思考和吃饭。没有固定的顺序,因为所有线程都是并发启动的,并且包含随机因素。我们不知道谁会先吃,这取决于谁能先进入并获取临界区资源。
在输出中,你会看到类似这样的信息:
- 哲学家 X 思考 Y 秒
- 哲学家 X 拿起左叉
- 哲学家 X 拿起右叉
- 哲学家 X 吃饭 Z 秒
- 哲学家 X 放下右叉
- 哲学家 X 放下左叉
程序会持续运行,因为当前版本没有终止条件。每位哲学家都有机会吃饭,没有人会饿死,这解决了哲学家就餐问题。
思考与挑战
当前版本的程序没有停止条件。我建议你修改程序,使得当每位哲学家都至少就餐了三次(例如)后,程序可以终止。他们都有了三次吃饭的机会,这足以完成用餐,可以去做其他事情了。请思考如何添加额外的代码来终止程序,而不是让程序无限运行并需要手动中断。
总结

本节课中,我们一起学习了并发编程中的关键概念——互斥锁。我们通过“哲学家就餐问题”这个经典案例,理解了为什么需要互斥来防止资源冲突和死锁。在Go语言中,我们使用sync.Mutex的Lock和Unlock方法来实现临界区的独占访问。通过编写并观察程序运行,我们看到了互斥锁如何确保多个并发线程能够有序、安全地共享资源。最后,我们还提出了一个改进挑战,为程序添加合理的终止条件。
023:哲学家就餐问题扩展作业 🍽️

在本节课中,我们将学习如何扩展经典的“哲学家就餐问题”,以包含一个额外的共享资源(勺子)和一个程序终止条件。我们将运用Go语言的并发机制,如互斥锁(Mutex),来安全地管理这些资源。
概述
第四周的主要内容是关于并行性以及如何编写包含不同线程(在Go中是协程)的代码。我们讨论过的最有趣的例子是经典的“哲学家就餐问题”。这个问题最初由Dijkstra和Tony Hoare研究,Hoare发明的CSP语言正是Go语言并发模型的基础。
在哲学家就餐问题中,每位哲学家坐在一张桌子旁(在我们的例子中是五位哲学家),他们旁边都有一把叉子,但进餐需要两把叉子。如果所有哲学家同时拿起一把叉子,就会陷入僵局,无人能够进餐。因此,我们必须以保护临界资源的方式编写并发进程,这部分代码被称为临界区。
作业扩展要求
我们将扩展本周课堂上看到的问题,增加两个额外的步骤。
- 添加共享勺子:餐桌中央有一个大碗,哲学家们需要用唯一的一把勺子将食物舀到自己的个人碗中。然后,他们再用两把叉子进食。由于只有一把勺子,我们必须确保勺子这个资源不会产生争用。
- 添加终止条件:在课堂上的程序会无限运行,直到被外部中断。现在,我们要求程序在所有哲学家都吃完三份食物后自动终止。
因此,哲学家现在需要依次完成以下动作:拿到勺子 -> 舀取食物 -> 放下勺子 -> 拿到两把叉子 -> 进食 -> 放下叉子 -> 思考。这个过程将重复,直到满足终止条件。
以下是本作业的具体参数:
- 哲学家数量:5(可泛化)
- 叉子(筷子)数量:5
- 勺子数量:1(单例)
核心概念与实现要点
上一节我们介绍了作业的扩展要求,本节中我们来看看实现时需要掌握的核心概念。
临界资源(如勺子和叉子)需要通过锁来保护。在Go语言中,我们可以使用 sync 包中的 Mutex(互斥锁)来实现。
代码示例:创建和使用互斥锁
import "sync"
var ladleLock sync.Mutex // 用于保护勺子的锁
var forkLocks []sync.Mutex // 一个锁的切片,用于保护每一把叉子
// 哲学家尝试获取勺子
func pickUpLadle() {
ladleLock.Lock() // 获取锁,进入临界区
defer ladleLock.Unlock() // 确保函数退出时释放锁
// ... 执行舀取食物的操作 ...
}
关键目标是避免死锁。死锁发生在多个进程相互等待对方释放资源,导致所有进程都无法继续执行。在本次作业中,我们需要仔细设计获取锁的顺序(例如,总是先申请勺子,再按固定顺序申请叉子),以防止死锁发生。
编程规范与评审要求
在实现功能的同时,请遵循以下Go语言社区的编程规范,这将是同行评审的重点。
- 代码文档:为你的包、函数和方法添加清晰的注释。
- 命名风格:使用驼峰式命名法(Camel Case),例如
pickUpLadle。 - 代码格式:注意花括号
{}的位置以及for循环等语句后的代码缩进。建议使用gofmt工具自动格式化代码。
总结


本节课中,我们一起学习了如何扩展哲学家就餐问题。我们引入了新的共享资源(勺子)和程序终止条件(每位哲学家进食三次)。通过使用 sync.Mutex 来保护临界区,我们可以安全地管理并发访问,并需要仔细设计以避免死锁。请记住在实现时遵循Go语言的代码规范。现在,你可以开始动手实现这个有趣的并发程序了。
024:测试基础

概述
在本节课中,我们将要学习Go语言中一个非常重要的概念:单元测试。我们将了解为什么测试代码至关重要,并学习如何使用Go语言内置的go test工具来编写和运行测试,以确保我们的代码按预期工作。
为什么需要测试
编写完全没有错误的代码是不可能的。因此,拥有一个测试策略至关重要。
一种方法是手动模拟代码,用少量数据逐步执行。但通常人们会觉得这种方法太麻烦。
另一种方法是在代码的关键位置添加Print语句,以观察代码的行为并确保其按我们设想的方式执行。但这种方法也比较繁琐。
因此,Go语言提供了专门的方式来测试你的代码。
Go语言中的单元测试
在软件工程中,单元测试被证明是非常有用的。当我们编写Go代码并想确保其正常工作,我们可以为它们编写测试,然后使用go test指令来运行这些测试。
为了实践这一点,我们将回到最初的Go课程示例。
在那个课程中,我们编写了一个著名的将摄氏温度转换为华氏温度的程序。我们使用这个代码是因为它在描述C语言的原始书籍中是一个早期的、非平凡的程序示例。这是一个非常有用的程序,每个人都应该能够编写这样的代码,同时我们也希望能够测试它。
编写可测试的代码
在编写测试代码时,你通常测试的是函数,而不是它们在main函数中的使用。因此,你通常需要将你要使用的函数写出来,并放入一个包中。
以下是我们将要测试的代码。我们将其放在一个名为convert的包中,因为它执行摄氏到华氏的转换。
文件:temperature/tf.go
package convert
func ToFahrenheit(cTemp float64) float64 {
return 32 + 1.8*cTemp
}
这个函数接收一个float64类型的摄氏温度,并使用公式 32 + 1.8 * cTemp 返回一个float64类型的华氏温度。我们将这个文件命名为tf.go。
编写单元测试
现在,让我们来编写单元测试。
我们使用相同的包名。这是因为当使用go test时,它会查找该包下的测试代码来运行。
测试文件的命名规则是:test_加上原始代码的文件名。所以我们的测试文件将命名为tf_test.go。
文件:temperature/tf_test.go
package convert
import "testing"
func TestToFahrenheit(t *testing.T) {
// 以下是测试用例
tests := []struct {
input float64
expected float64
}{
{input: 1.0, expected: 33.8}, // 1°C 应为 33.8°F
{input: 0.0, expected: 32.0}, // 0°C 应为 32°F
{input: 100.0, expected: 212.0}, // 100°C 应为 212°F
}
for _, test := range tests {
result := ToFahrenheit(test.input)
if result != test.expected {
t.Errorf("输入 %.1f°C 时,预期 %.1f°F,但得到 %.1f°F", test.input, test.expected, result)
}
}
}
在上面的测试中,我们导入了testing包。我们创建了三个测试用例:
- 输入1°C,预期输出33.8°F。
- 输入0°C(冰点),预期输出32°F。
- 输入100°C(沸点),预期输出212°F。
对于每个测试用例,我们运行ToFahrenheit函数,并将结果与预期值进行比较。如果不相等,我们使用t.Errorf输出一个错误信息,指出预期的结果和实际得到的结果。
运行测试
要运行测试,只需在包含tf.go和tf_test.go文件的目录中执行以下命令:
go test
如果所有测试都通过,你将看到类似以下的输出:
PASS
ok your/package/path 0.002s
测试失败的情况
现在,让我们看看测试失败时会发生什么。假设我们错误地将公式写成了 32 + 1.9 * cTemp。
修改tf.go文件中的函数:
func ToFahrenheit(cTemp float64) float64 {
return 32 + 1.9*cTemp // 错误的公式!
}
再次运行go test,你将看到测试失败,并输出详细的错误信息:
--- FAIL: TestToFahrenheit (0.00s)
tf_test.go:15: 输入 1.0°C 时,预期 33.8°F,但得到 33.9°F
tf_test.go:15: 输入 100.0°C 时,预期 212.0°F,但得到 222.0°F
FAIL
exit status 1
FAIL your/package/path 0.003s
注意,0°C的测试用例仍然通过了(因为32 + 1.9*0 = 32)。这说明了拥有足够多测试用例的重要性,以便能发现关键性的错误。
总结
本节课中,我们一起学习了Go语言的单元测试。
- 我们了解了测试代码的重要性。
- 我们学习了如何将业务逻辑封装在函数中,以便于测试。
- 我们掌握了Go测试文件的命名规范(
*_test.go)和基本结构。 - 我们编写了一个包含多个测试用例的单元测试函数。
- 我们使用
go test命令来运行测试,并观察了测试通过和失败时的不同输出。


编写和运行单元测试是一种非常好的编程习惯,在工业界被广泛使用。通过为你的代码编写测试,你可以更早地发现错误,更有信心地进行代码重构,并确保代码的长期质量。
025:竞态条件


概述
在本节课中,我们将要学习并发编程中一个常见但隐蔽的错误:竞态条件。我们将了解竞态条件是如何发生的,如何通过Go语言工具检测它,以及如何使用互斥锁来修复它。
什么是竞态条件?
在并发程序中,竞态条件是指两个或多个线程试图同时访问同一资源时可能发生的错误。
具体来说,当多个线程同时读写一个共享变量时,由于操作的执行顺序不确定,可能导致最终结果与预期不符。例如,假设一个计数器当前值为10,两个线程都试图将其增加1。理想情况下,最终结果应为12。但在竞态条件下,可能发生以下情况:两个线程都读取到初始值10,然后分别将其更新为11,导致最终结果错误地停留在11,而不是12。
演示程序:一个存在竞态条件的计数器
为了理解竞态条件,我们来看一个具体的Go程序。这个程序将启动多个goroutine(Go语言中的轻量级线程),每个goroutine都会对一个共享的计数器变量执行1000次递增操作。
以下是实现递增操作的函数:
func incrementCounter(count *int) {
for i := 0; i < 1000; i++ {
*count++
}
}
该函数接收一个指向整数count的指针,并通过循环将其递增1000次。如果程序没有竞态条件,并且我们启动了N个goroutine,那么最终计数器的值应为 N * 1000。
主程序:启动多个goroutine
接下来,我们看看主函数如何启动这些goroutine并等待它们完成。我们使用sync.WaitGroup来协调多个goroutine的执行。
func main() {
var count int
var wg sync.WaitGroup
// 启动10个goroutine
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
incrementCounter(&count)
}()
}
// 等待所有goroutine完成
wg.Wait()
fmt.Println("Final count:", count)
}
在这个程序中,我们创建了10个goroutine。每个goroutine启动前,我们调用wg.Add(1)来增加等待组的计数器。在每个goroutine内部,我们使用defer wg.Done()来确保在函数退出时减少等待组的计数器。最后,wg.Wait()会阻塞主goroutine,直到所有10个goroutine都执行完毕,然后打印出最终的计数值。
竞态条件的后果
理论上,10个goroutine各递增1000次,最终count应为10000。但由于存在竞态条件,实际运行结果总是小于10000。
以下是程序多次运行可能得到的结果示例:
- 8711
- 5910
- 6605
- 7348
每次运行的结果都不同且小于10000,这是因为多个goroutine在没有同步保护的情况下同时读写count变量,导致部分递增操作“丢失”。
使用Go工具检测竞态条件
Go语言内置了强大的竞态检测器,可以帮助开发者发现程序中的竞态条件。我们可以在运行或构建程序时使用 -race 标志来启用它。
在命令行中运行存在竞态条件的程序:
go run -race race.go
启用竞态检测后运行程序,除了输出错误的计数值(例如7310),工具还会在控制台打印详细的警告信息,指出程序中存在数据竞争(data race)的具体位置,并会以状态码66退出。这为定位和修复问题提供了极大帮助。
修复竞态条件:使用互斥锁
要修复竞态条件,我们需要确保对共享资源(这里是count变量)的访问是互斥的,即同一时间只能有一个goroutine对其进行操作。在Go语言中,我们可以使用sync.Mutex(互斥锁)来实现这一点。
以下是修复后的incrementCounter函数和主程序:
import "sync"
func incrementCounterWithMutex(count *int, mu *sync.Mutex) {
for i := 0; i < 1000; i++ {
mu.Lock() // 进入临界区前加锁
*count++
mu.Unlock() // 离开临界区后解锁
}
}
func main() {
var count int
var wg sync.WaitGroup
var mu sync.Mutex // 声明一个互斥锁
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
incrementCounterWithMutex(&count, &mu)
}()
}
wg.Wait()
fmt.Println("Final count:", count) // 现在总是输出 10000
}
核心修改如下:
- 我们声明了一个
sync.Mutex类型的变量mu。 - 在
incrementCounterWithMutex函数中,在对count进行操作(临界区代码)之前,调用mu.Lock()获取锁。如果锁已被其他goroutine持有,当前goroutine会被阻塞,直到锁被释放。 - 操作完成后,调用
mu.Unlock()释放锁,允许其他等待的goroutine进入临界区。
通过互斥锁的保护,对count变量的所有递增操作都变成了串行执行,从而消除了竞态条件。现在无论运行多少次程序,最终结果都将是正确的10000。
如果我们对修复后的程序再次使用-race标志进行检测,竞态检测器将不会报告任何问题,程序会正常输出结果。
总结
本节课我们一起学习了并发编程中的竞态条件。
- 我们首先了解了竞态条件的定义:多个线程无序访问共享资源导致结果错误。
- 然后,我们通过一个计数器程序演示了竞态条件如何导致计算结果不准确且每次运行结果不一致。
- 接着,我们学习了如何使用Go语言的
-race标志来检测程序中的竞态条件,这是一个非常实用的调试工具。 - 最后,我们介绍了使用
sync.Mutex互斥锁来保护共享资源,修复竞态条件,确保并发程序的正确性。
理解并防范竞态条件是编写可靠并发程序的关键一步。




026:生成求和函数

概述
在本节课中,我们将要学习Go语言中的泛型。泛型是一种强大的代码复用功能,它允许我们编写一个函数,使其能够处理多种不同的数据类型,而无需为每种类型编写重复的代码。
泛型简介
上一节我们介绍了Go语言的基础知识,本节中我们来看看泛型。泛型是Go语言中一个相对较新的功能,于2022年引入。如果你的Go编译器版本是1.18或更高,那么它应该支持泛型。你可以使用命令 go version 来检查你的编译器版本。
与其他现代语言类似,Go语言也通过委员会决策来添加重要功能。Go语言的设计哲学是保证向后兼容,因此泛型的加入不会使旧代码失效。虽然像C++这样的语言早已通过模板实现了泛型,但Go语言以其一贯的简洁风格,提供了一种更简单的泛型实现方式。
泛型的作用
泛型允许你编写一段针对特定类型工作的代码,然后确保这段代码也能正确地用于其他类型。在旧的语言如C语言中,如果你需要为一个整数(int)类型和一个浮点数(float)类型编写求和功能,你必须定义两个不同名称的函数。然而,使用泛型,我们可以只编写一个函数,并指定一个泛型类型,这个类型可以被任何符合代码要求的类型实例化。
非泛型求和示例
首先,我们来看一个对整数切片求和的非泛型代码示例:
func sumInt(data []int) int {
s := 0
for _, v := range data {
s = s + v
}
return s
}
如果我们还需要对浮点数切片求和,就必须编写另一个几乎相同的函数:
func sumFloat(data []float32) float32 {
s := 0.0
for _, v := range data {
s = s + v
}
return s
}
这导致了代码重复和命名空间的污染。
泛型求和实现
泛型让我们可以避免上述重复。以下是实现泛型求和的步骤:
1. 定义类型约束接口
我们首先创建一个名为 number 的接口类型,用于约束泛型类型 T。这个接口定义了哪些具体类型可以使用我们的泛型函数。
type number interface {
int | int8 | int16 | int32 | int64 |
uint | uint8 | uint16 | uint32 | uint64 |
float32 | float64 |
complex64 | complex128 |
string
}
这个接口允许的类型包括:各种整数、无符号整数、浮点数、复数以及字符串。请注意,字符串通常不被视为数字,但这里为了演示泛型的灵活性,我们将其包含在内。
2. 编写泛型函数
接下来,我们编写泛型求和函数 sum。
func sum[T number](slice []T) T {
var s T
for _, v := range slice {
s += v
}
return s
}
让我们分解这个函数声明:
func sum[T number]:T是一个泛型类型参数,它受到number接口的约束。按照Go社区的惯例,我们通常使用大写字母(如T)来表示泛型类型。(slice []T):参数slice是一个类型为T的切片。T:函数的返回值类型也是T。- 在函数体内,我们声明变量
s为类型T。在Go语言中,变量会被自动初始化为其类型的零值(例如,int为0,string为空字符串"")。这确保了我们的代码能正确处理所有number接口中定义的类型。 - 循环遍历切片,使用
+=运算符进行累加,然后返回结果s。
3. 调用泛型函数
现在,我们可以在 main 函数中调用这个泛型函数来处理不同类型的数据。
func main() {
// 整数切片
ints := []int{1, 2, 3, 4, 5}
intSum := sum(ints)
fmt.Println("Sum of ints:", intSum)
// 无符号整数切片
uints := []uint{10, 20, 30, 40}
uintSum := sum(uints)
fmt.Println("Sum of uints:", uintSum)
// 浮点数切片
floats := []float64{1.1, 2.2, 3.3}
floatSum := sum(floats)
fmt.Println("Sum of floats:", floatSum)
// 字符串切片(连接操作)
strs := []string{"If ", "pigs ", "can ", "fly."}
strSum := sum(strs)
fmt.Println("Sum of strings:", strSum)
// 复数切片
complexes := []complex128{complex(1.2, 8.8), 3.4, 5.6, 7.8}
complexSum := sum(complexes)
fmt.Println("Sum of complexes:", complexSum)
}
编译器会根据传入切片的实际类型,自动实例化相应的 sum 函数版本。例如,当传入 []int 时,编译器会生成并调用 sum[int] 的代码。
开发泛型代码的建议
以下是开发泛型代码的一个有效流程:
- 使用典型类型进行调试:首先,使用一个最典型、最方便调试的类型(例如
int)来编写和测试你的核心逻辑。 - 识别通用条件:确保代码逻辑正确后,分析哪些其他类型也满足使这段代码工作的条件(例如,都需要支持
+=操作)。 - 定义约束接口:将这些类型归纳到一个接口中,作为泛型类型
T的约束。 - 转换为泛型函数:将具体类型替换为泛型类型
T,并添加上约束。 - 全面测试:使用接口中定义的各种类型来测试你的泛型函数,确保其行为符合预期。


总结
本节课中我们一起学习了Go语言中的泛型。我们了解到泛型是一种强大的工具,它通过允许我们编写处理多种数据类型的单一函数,极大地促进了代码的复用,并保持了代码的简洁性。关键步骤包括:定义一个约束接口来限定可用的类型,然后在函数签名中使用泛型类型参数 [T Constraint]。通过这种方式,我们可以用一份代码,高效地处理整数、浮点数、字符串乃至复数等多种数据类型的求和(或连接)操作。
027:生成栈


在本节课中,我们将要学习如何编写适用于容器类型的泛型代码。我们将以栈(Stack)这种数据结构为例,展示如何使用Go的泛型和any类型来创建一个可以存储任意数据类型的通用容器。
容器类型与泛型
上一节我们介绍了泛型函数的基本概念。本节中我们来看看泛型在容器类型中的应用。
容器类型指的是用于存储一系列值的结构,例如切片(slice)。但除了切片,我们还可以创建链表、栈、队列等多种容器。为什么需要这么多类型?因为每种容器在存取数据时遵循不同的规则,并且各有优缺点。切片或数组可能是最方便、最常用的容器类,我们已经见过如何为切片编写泛型代码。
如果你学习过C++,就会知道标准库中包含大量容器,它们都是通过泛型实例化的,因此可以用于任何类型。
在这段代码中,我们还将使用一种通用类型。在之前的泛型代码中,我们为数字类型指定了一组具体的约束类型,但这排除了结构体或指针等其他类型。当我们只需要考虑存入和取出值,而不关心具体操作时,可以使用Go语言中名为any的通用类型。
栈数据结构简介
以下是栈的基本概念和操作。
栈是一种后进先出(LIFO)的数据结构,就像食堂里叠放的盘子。你只能从顶部拿走盘子(弹出),也只能将新盘子放到顶部(压入)。栈通常有以下几种基本操作:
- 压入(Push):将一个新元素添加到栈顶。
- 弹出(Pop):移除并返回栈顶的元素。
- 查看栈顶(Peek/Top):返回栈顶元素但不移除它。
- 判断是否为空(IsEmpty):检查栈中是否没有元素。
我们希望栈能存储任意类型的值,因此将使用any类型。本质上,栈是对切片的一种封装(或称为外观模式),因为我们想限制对数据的操作,只允许上述几种栈操作,而不允许像切片那样随机访问任意位置的元素。
实现泛型栈
现在,我们来看看如何用Go语言实现一个泛型栈。我们将定义一个结构体,并实现其Push方法。
// 定义一个泛型栈结构体
type Stack[T any] struct {
values []T // 底层使用切片存储值
}
// Push方法:将值压入栈顶
func (s *Stack[T]) Push(value T) {
s.values = append(s.values, value)
}
我们定义了一个泛型结构体Stack[T any],它内部使用一个类型为T的切片来存储数据。Push方法接收一个类型为T的参数value,并使用append函数将其添加到切片末尾(即栈顶)。
其他操作如Pop、Peek和IsEmpty将作为练习留给读者完成。接下来,让我们看看如何使用这个栈。
使用泛型栈
以下是使用我们定义的泛型栈的示例代码。
func main() {
// 创建一个整数栈
intStack := Stack[int]{}
fmt.Println("初始整数栈:", intStack)
// 向整数栈压入值
intStack.Push(15)
fmt.Println("压入15后:", intStack)
intStack.Push(-5)
fmt.Println("压入-5后:", intStack)
// 创建一个字符串栈
stringStack := Stack[string]{}
fmt.Println("初始字符串栈:", stringStack)
// 向字符串栈压入值
stringStack.Push("Ira")
fmt.Println("压入‘Ira’后:", stringStack)
}
我们首先创建了一个Stack[int]类型的整数栈并打印其初始状态。然后,我们依次压入整数15和-5,并每次打印栈的状态以观察变化。接着,我们创建了一个Stack[string]类型的字符串栈,并压入字符串"Ira"。
运行这段代码,输出结果如下:
初始整数栈: {[]}
压入15后: {[15]}
压入-5后: {[15 -5]}
初始字符串栈: {[]}
压入‘Ira’后: {[Ira]}
输出显示,整数栈和字符串栈初始时都为空。压入操作后,相应的值被成功添加到各自的栈中。这证明了我们的泛型栈可以用于int、string乃至任何其他类型,包括自定义结构体或指针类型。
总结
本节课中我们一起学习了如何创建和使用泛型容器。我们以栈为例,定义了泛型结构体Stack[T any],并实现了Push方法。通过实例化Stack[int]和Stack[string],我们演示了泛型代码如何作用于不同的具体类型,使得容器既通用又类型安全。你可以基于这个框架,继续实现Pop、Peek等操作来完成一个功能完整的栈。


028:与C代码集成

概述
在本节课中,我们将学习如何在Go语言项目中集成C语言代码。Go语言虽然现代且强大,但在某些特定场景(如实时系统)下,C语言因其高效性和无垃圾回收的特性而更具优势。通过使用名为Cgo的工具,我们可以将两种语言的代码编译在一起,从而利用C语言庞大的现有代码库和操作系统级接口。
Go与C集成的必要性
Go语言是一门相对现代的语言,其设计在某种程度上基于C语言,可被视为C语言家族的最新改进。然而,C语言在许多情况下比Go更高效。例如,Go的一个优点是垃圾回收机制,这意味着开发者无需过多操心内存分配,也避免了各种内存错误。但在某些计算领域,如实时应用,垃圾回收机制是不被允许的。因此,当你需要时,有一种简单的方案可以让你集成C代码。
C语言至今仍被广泛使用,它拥有丰富的库,并且是许多操作系统(包括最初为Unix发明的系统)的通用语言。这使得C语言因其广泛的应用而极具价值,尽管以现代标准来看,它可能显得有些古老。
使用Cgo进行集成
要将两种语言一起使用并编译,你需要一个名为Cgo的工具。在接下来的代码中,你会看到我们导入了名为“C”的包。然后,我们将要编写的C代码会放在Go语言的注释中。Cgo能够识别这些注释中的C代码,并将其与Go代码区分开来。之后,我们可以使用go build命令来构建一个同时使用C和Go代码的可执行文件。
代码示例解析
以下是集成C代码的Go程序示例。
package main
import "fmt"
/*
#cgo LDFLAGS: -lm
#include <stdio.h>
void hello() {
printf("Hello world\n");
}
*/
import "C"
func printHiFromGo() {
fmt.Println("Hi from Go")
}
func main() {
printHiFromGo()
C.hello()
}
就像普通的Go程序一样,我们有一个package main,然后导入了fmt包,因为我们将使用Go的I/O功能。接着,我们进行了一些特殊的操作。
多行注释/* ... */包裹着C代码。这里展示了一个非常简单的C程序。如果你已经了解C语言,这可能是你最早编写的那种程序——经典的“Hello World”程序。在C语言中,#include类似于Go的import。#include <stdio.h>引入了标准I/O头文件,这相当于Go的I/O包。然后,这个函数所做的就是调用printf打印一行“Hello world”消息。
如果你没有学过C语言,这里可以看到它与Go函数的一些细微差别。在C语言中,你不使用特殊的关键字func,而是使用一个类型来声明函数。在这个例子中,由于函数不返回值,其类型是void。此外,与Go风格不同,C语言倾向于在每行末尾加上分号,这就是为什么你会看到printf("Hello world\n");以分号结尾。
然后我们看到import "C",接着是我们正常的Go代码。在这段Go代码中,请注意最后一行C.hello()。这告诉编译器,这是与编译的C语言部分相关的代码。实际上,通过使用C.hello(),我们调用了C语言部分的void hello()函数。
你必须确保将import "C"紧接在包含C代码的注释块之后。我自己在学习时曾在此处留有空行,导致编译出错。所以,不要在这两者之间留任何空行。然后,使用go build来获取可执行文件并运行代码。当然,你也可以在不构建可执行文件的情况下以正常方式运行代码。
编译与运行
让我们继续并确保这一切正常工作。在终端中,我们拥有幻灯片中展示的代码。这是普通的Go代码:package main和import "fmt"。但接下来是新颖的部分。
我们在/*之后开始,可以包含一些传递给C编译器的指令,因为C编译器将与Go编译器一起被调用。这里我调用了#cgo LDFLAGS: -lm,对于这个特定程序来说并非必需,但-lm会加载数学库。在某些情况下,我试验过需要数学库的C代码。这里你包含了stdio.h。同样,这对许多有C语言背景的人来说可能很熟悉。
C语言的语法与Go的func语法略有不同。这里我们总是先声明返回类型。在这个例子中,它是void,意味着没有返回值。它仅仅是打印“Hello world”。然后我们立即执行import "C"。
这里是混合了C调用的Go代码。在这段代码中,我们定义了函数printHiFromGo,然后我们将调用C.hello(),这将调用程序的C语言部分,执行打印“Hello world”的操作。当然,这只是一个示例。在实际代码中,我们不会真正使用这样的例子,因为它对我们的代码类型没有实际价值。但如前所述,C世界拥有庞大的现有代码库,包括在Go中可能不安全的代码。如果你的应用程序需要那种超高效的代码,你可能还需要使用unsafe包。
现在,让我们继续并确保这一切正常工作。退出编辑器后,我将直接运行它。果然,它完全按预期输出了“Hi from Go”和“Hello world”。这里还有很多其他细节,但这确实非常基础,它展示了将C与Go结合是多么容易。这是一项我希望你们所有人都尝试去掌握的技能。


总结
本节课中,我们一起学习了如何在Go代码中集成C语言代码。我们了解了在某些场景下使用C语言的优势,并通过Cgo工具实现了两种语言的混合编译。我们分析了一个简单的“Hello World”示例,演示了如何通过特殊的注释块嵌入C代码,并使用import "C"和C.函数名()的语法在Go中调用C函数。最后,我们成功编译并运行了混合代码。掌握这项技能,将能帮助你利用C语言庞大的现有生态,解决Go在特定领域(如高性能计算、系统编程)的局限性。
029:Go与C/C++的关系

概述
在本节课中,我们将要学习Go语言与C/C++这两种经典编程语言之间的历史渊源和实际联系。我们将了解它们为何能协同工作,以及掌握这些语言如何提升你作为开发者的价值。
Go与C的渊源
上一节我们介绍了Go语言的基本特性,本节中我们来看看它的“家族背景”。Go是C语言的直系后代。Go语言的许多开发者,正是当年在贝尔实验室参与C和Unix系统开发的原班人马。他们在2009年为谷歌设计了Go语言。
在许多方面,Go语言清理了C语言中存在的一些问题。C语言本身取得了巨大的成功,尤其在编写操作系统方面非常有用,这也是它最初的设计目标。C语言所属的类别被认为是“系统实现语言”。
C语言的演变与通用性
C语言变得如此有用,以至于它像后来的Java或Python一样,演变成了一种更通用的语言,尽管这并非其最初的设计意图。
以下是C语言与Go语言关系的两个关键点:
- 许多学习本课程的学生已经了解C语言,这是一个巨大的优势。
- 如果你将Go作为第一门语言,在学完Go后也能轻松地学习C,因为这两种语言非常相似,属于同一语言类别。正如我们所见,它们甚至可以被一起编译。因此,同时掌握这两种语言的知识非常有用。
Go与C/C++的互操作性
既然两种语言可以协同使用,我们来看看具体方式。通过CGo,Go和C可以一起使用,我们刚刚已经演示过这一点。
同样,Go也可以与C++一起使用。C++由Bjarne Stroustrup于20世纪80年代初在贝尔实验室开发,大约在1985年向公众发布,并逐渐流行起来,成为一门非常重要的工业语言。因为它改进了早期的C语言,并增加了面向对象等关键特性,而当时面向对象编程正开始流行。
然而,与CGo技术相比,Go与C++结合使用的技术更为复杂,因此不在本课程的讨论范围内。
学习C/C++的价值
如果你在本课程之前没有学过C,可以利用我在Coursera上的C语言入门课程进行学习,这将使你作为一名Go程序员更具价值。
C语言非常有用,因为它拥有庞大的代码库。这意味着你可以直接使用现成的C代码,否则你可能需要在Go中从头开始编写。在许多情况下,C代码的运行速度更快,尤其是当你使用C代码中那些“不安全”的部分时。事实上,你也可以使用Go代码中的“不安全”部分来避免垃圾回收等机制,这在某些编码应用(特别是实时应用)中是必要的。
课程与证书
我们将为Go和C课程提供证书,并且更棒的是,还会加入C++。因为C++同样是一门非常重要的工业语言,拥有庞大的工具集。它拥有所谓的“标准模板库”,是大量使用泛型的先驱典范之一。
因此,你可以学完我的Go课程后,直接跳转到C++学习,或者学习全部六门课程。即使你已经具备一些C语言知识,这些课程也能帮你巩固C语言知识,并引导你进入C++的世界。
总结

本节课中我们一起学习了Go语言与C/C++的历史联系和实际协作方式。我们了解到Go脱胎于C,两者语法相似,可通过CGo互操作。同时,掌握C/C++能让你利用丰富的现有库,并在特定场景下获得性能优势。结合这些语言的学习,将大大提升你的编程能力和职业竞争力。
030:扩展泛型栈与单元测试 🧪

在本节课中,我们将学习如何扩展一个泛型栈数据结构,为其添加一系列标准操作,并利用Go语言的单元测试功能来验证每个操作的正确性。
栈数据结构回顾 📚
上一节我们介绍了栈的基本概念。栈是一种“后进先出”(LIFO)的数据结构,可以想象成一叠盘子,你只能从顶部放入或取出盘子。
在本节中,我们将基于一个泛型栈进行扩展。这个栈使用类型参数 T,其约束为 any,这意味着它可以存储任何类型的数据。我们之所以使用 any 而不是像 number 这样的特定约束,是因为栈的核心操作(如存储)不依赖于任何特定的运算(如加法),它只需要能存放数据即可。
栈的内部实现本质上是一个类型为 T 的切片(slice),而我们为其编写的方法则定义了栈的特定行为。
基础操作:入栈(Push) ➕
以下是栈的一个基础操作,我们之前已经见过:将值压入栈顶。
我们通过接收一个值,并将其追加(append)到内部切片来实现这个操作。Go语言的内存管理机制使得 append 操作可以高效地处理切片的扩容。
func (s *Stack[T]) Push(value T) {
*s = append(*s, value)
}
需要实现的操作列表 📋
现在,我们来看看需要为栈实现的其他标准操作,以使其成为一个功能完整的数据集合类型。
以下是需要编码实现的操作列表:
- Pop:从栈顶移除一个元素并返回它。
- IsEmpty:检查栈是否为空。在尝试弹出元素前进行此检查很重要,因为不能从空栈弹出元素。如果栈为空,则返回
true。 - Top(有时也称为 Peek):返回栈顶的元素,但不将其从栈中移除。它只是“查看”栈顶的值。
- CopyFromSlice:将切片中的所有值复制到栈中。这允许我们使用切片来初始化或填充栈。
- CopyToSlice:将栈中的所有内容弹出并复制到一个切片中。这便于我们在需要切片进行某些操作时,将栈的数据转移出来。
单元测试要求 ✅
对于上面列出的每一个操作,你都需要编写相应的单元测试(unit test)。Go语言内置了强大的测试框架,可以帮助你调试代码并确保其正确性。你将使用 test 包来创建测试,验证每个栈操作是否按预期工作。
总结 🎯

本节课中,我们一起学习了如何扩展一个泛型栈数据结构。我们回顾了栈的LIFO特性,明确了使用 any 类型参数的原因,并列出需要实现的核心操作:Push、Pop、IsEmpty、Top、CopyFromSlice 和 CopyToSlice。最后,我们强调了为每个操作编写单元测试的重要性,这是保证代码质量的关键步骤。

浙公网安备 33010602011771号