UCI-Go-语言编程笔记-全-

UCI Go 语言编程笔记(全)

001:课程欢迎 🎉

在本节课中,我们将介绍这门课程的目标、前提假设以及Go语言的定位,帮助你了解接下来将要学习的内容。

欢迎来到这门课程,课程1。本课程的目标是让你获得关于Go语言及其使用方法的第一手工作知识。

我假设你已经具备使用其他编程语言的经验。因此,我不会从零开始讲解。我假设你已经了解许多编程概念,例如数据类型等。你可能熟悉C、Python、Java等语言,现在想转向Go语言。

也许你想开始进行系统或设备编程,希望做一些更底层的开发,但又不想直接使用C语言。或者,你长期使用C语言,希望让编程工作变得更轻松。

事实上,Go语言整体上是一个“甜点区”。它效率高,接近C语言,同时也像Python或Java一样易于使用。它介于两者之间。

在本课程中,我们将涵盖所有基础知识。你应该能够编写一些程序,感受这门语言,并判断自己是否喜欢它、能否适应它。

本节课中,我们一起学习了本课程的欢迎部分,明确了课程目标是为有经验的开发者提供Go语言的入门实践知识,并了解了Go语言在高效与易用性之间的平衡定位。接下来,我们将开始深入Go语言的具体语法和特性。

002:概述

在本模块中,我们将介绍Go语言的基础知识,包括其独特优势、环境搭建、代码组织方式以及变量和类型的基本概念。我们将从动机开始,逐步引导你完成第一个Go程序的编译,并理解Go如何通过包来组织代码以促进协作。

为什么选择Go? 🎯

上一节我们介绍了本模块的整体目标,本节中我们来看看学习Go语言的动机。Go语言的设计旨在解决现代软件开发中的特定问题,它与其他现有语言相比具有独特的优势。我们将探讨Go为何是一个值得学习的优秀语言。

开始使用Go ⚙️

了解了学习Go的原因后,接下来我们需要实际动手。本节将指导你安装Go开发环境并运行你的第一个程序。这是继续后续所有课程的必要前提。

以下是安装和验证步骤:

  1. 访问Go官方网站下载并安装适合你操作系统的Go发行版。
  2. 打开终端或命令提示符,输入 go version 以验证安装是否成功。
  3. 创建一个名为 hello.go 的文件,并输入以下代码:
    package main
    import "fmt"
    func main() {
        fmt.Println("Hello, World!")
    }
    
  4. 在文件所在目录运行命令 go run hello.go 来编译并执行程序。

完成上述步骤是对整个开发环境的一次完整性检查。

代码组织与包 📦

成功运行了第一个程序,现在我们来了解Go如何组织代码。良好的代码组织对于任何实际项目都至关重要,尤其是在团队协作中。Go通过“工作区”和“包”的概念来管理代码,这使得代码共享和协作变得简单。

以下是关于代码组织的关键点:

  • 工作区:这是你所有Go代码的根目录,其结构有特定的约定。
  • :Go代码被组织成包,每个目录对应一个包。包是代码复用和模块化的基本单位。
  • 共享:通过定义清晰的包,你可以轻松地将自己的代码与他人交换或集成到更大的项目中。

变量、类型与作用域 🔤

在建立了对Go项目结构的理解后,我们现在开始接触语言本身的核心要素。本节将介绍Go语言中的变量、基本数据类型以及变量的作用域规则。

以下是相关概念的说明:

  • 变量声明:使用 var 关键字声明变量,例如 var x int
  • 基本类型:包括整数(int, int64)、浮点数(float64)、布尔值(bool)、字符串(string)等。
  • 作用域:变量的作用域决定了在代码的哪些部分可以访问该变量。它通常由变量被声明时所在的花括号 {} 块决定。

理解变量及其作用域对于编写正确且可维护的代码至关重要。

总结 📝

在本模块中,我们一起学习了Go语言的入门知识。我们从了解Go的设计动机和优势开始,然后完成了开发环境的搭建并运行了第一个程序。接着,我们探讨了Go推荐的代码组织方式,包括工作区和包的概念。最后,我们初步接触了Go语言的语法基础,包括变量、类型和作用域。这些内容为后续深入学习Go编程奠定了坚实的基础。

003:Go的优势

在本节课中,我们将探讨Go语言的核心优势,了解它为何在众多编程语言中脱颖而出,以及它如何通过独特的设计在性能、易用性和现代软件开发需求之间取得平衡。


编译与解释:速度的根源

上一节我们提到了Go语言运行速度快,本节中我们来看看这背后的原因。要理解这一点,我们需要先了解编程语言执行方式的根本区别。

编程语言大致可以分为三类:

  1. 机器语言:由0和1组成的二进制指令,直接在CPU上执行。
  2. 汇编语言:使用助记符(如ADDMOV)与机器指令几乎一一对应的低级语言,人类可读性稍强。
  3. 高级语言:如Go、Python、Java等,提供了变量、循环、条件判断等高级抽象,更易于人类编写和理解。

所有软件最终都必须被翻译成目标处理器的机器语言才能执行。这个翻译过程主要有两种方式:

  • 编译:在程序运行之前,通过编译器一次性将整个源代码翻译成机器码。生成的可执行文件直接包含机器指令。
    • 优势:执行速度快,因为运行时无需再进行翻译。
    • 代表语言:C, C++, Go, Rust。
  • 解释:在程序运行期间,通过解释器逐行或逐块地将源代码翻译成机器码并立即执行。
    • 优势:通常更灵活,开发调试便捷(如动态类型)。
    • 劣势:执行速度较慢,因为翻译工作在运行时进行。
    • 代表语言:Python, JavaScript, Ruby。

Go是一种编译型语言,这意味着你的Go代码会被预先编译成高效的机器码,从而获得接近原生程序的运行速度。


自动内存管理:垃圾回收

编译型语言通常速度快,但往往需要程序员手动管理内存,这是一项复杂且容易出错的任务。而解释型语言通常内置了自动内存管理功能。Go语言巧妙地结合了二者的优点。

内存管理主要涉及两个问题:

  1. 为变量或对象分配内存空间。
  2. 在不再需要时,及时释放内存以供重用。

手动管理内存(如在C语言中)极易引发错误:

  • 释放过早:程序后续仍试图访问已释放的内存,导致崩溃或安全漏洞。
  • 释放过晚/未释放:内存未被回收,造成“内存泄漏”,程序占用的内存会不断增长直至耗尽。

Go语言内置了高效的垃圾回收器。它会自动跟踪程序中内存的使用情况,并在确定某块内存不再被需要时自动将其释放。这带来了以下好处:

  • 提升开发效率:程序员无需关心复杂的mallocfree操作。
  • 增强程序安全性:极大地减少了因内存管理不当导致的崩溃和安全漏洞。
  • 平衡性能:虽然垃圾回收过程会消耗少量CPU时间,但Go的垃圾回收器经过高度优化,其带来的开发便利性和程序稳定性远超过微小的性能开销。

简洁的面向对象

Go语言支持面向对象编程,但其实现方式更为简洁和独特。

与Java或C++等语言相比,Go的面向对象特性有所简化:

  • 没有“类”的概念:使用struct定义数据结构。
  • 没有传统的继承:通过组合接口来实现代码复用和多态。
  • 方法可以定义在任何类型上:不仅仅是结构体,甚至可以为内置类型(如int)定义方法。

这种设计使得代码更清晰、更易于理解和维护,避免了复杂继承层次带来的负担。以下是Go中定义和使用“对象”的简单示例:

// 定义一个“类”(结构体)
type Person struct {
    Name string
    Age  int
}

// 为该“类”定义一个方法
func (p Person) SayHello() {
    fmt.Printf("Hello, my name is %s.\n", p.Name)
}

func main() {
    // 创建一个“对象”
    alice := Person{Name: "Alice", Age: 30}
    // 调用方法
    alice.SayHello()
}

强大的内置并发模型

并发编程(让程序同时处理多个任务)在现代软件开发中至关重要,但传统上实现起来非常复杂。Go语言在语言层面提供了优雅且高效的并发原语。

Go并发模型的核心是 GoroutineChannel

  • Goroutine:由Go运行时管理的轻量级线程。创建成本极低,可以轻松创建成千上万个。使用go关键字即可启动一个Goroutine。
    go doSomething() // 在新的Goroutine中异步执行doSomething函数
    
  • Channel:用于在Goroutine之间进行安全通信和数据传递的管道。它帮助Goroutine实现同步,避免共享内存带来的复杂锁问题。
    ch := make(chan int) // 创建一个传递整数的Channel
    go func() { ch <- 42 }() // 在一个Goroutine中向Channel发送数据
    value := <-ch // 在主Goroutine中从Channel接收数据
    

这种“通过通信来共享内存,而不是通过共享内存来通信”的理念,使得编写正确、高效的并发程序变得简单得多。


总结

本节课中我们一起学习了Go语言的几大核心优势:

  1. 运行速度快:作为编译型语言,代码被直接编译为机器码,执行效率高。
  2. 拥有垃圾回收:内置自动内存管理,兼顾了编译型语言的性能和解释型语言开发的安全性及便利性。
  3. 简洁的面向对象:通过结构体、方法和接口提供了清晰、灵活的代码组织方式,避免了过度设计的复杂性。
  4. 强大的内置并发:基于Goroutine和Channel的并发模型,让编写高性能并发程序变得直观且安全。

这些特性使得Go语言特别适合开发网络服务、分布式系统、云原生工具以及需要高并发的后端程序,在性能、开发效率和代码可维护性之间取得了出色的平衡。

004:对象 🧱

在本节课中,我们将要学习Go语言中的面向对象编程概念。Go支持面向对象,但其实现方式与其他主流语言(如Java、Python)有所不同,它更简洁、更轻量。

什么是面向对象编程?

面向对象编程的核心目的是组织代码。它通过将相关的数据和函数封装在一起,来创建特定于应用程序的自定义类型。

一个标准的类型(如整数 int)包含数据(数值本身)和可应用于该数据的函数(如加法、减法)。面向对象编程扩展了这个概念,允许我们创建更复杂、更贴合应用场景的自定义类型。

一个具体的例子:三维几何点

假设我们正在开发一个处理三维几何的应用程序。这个程序的核心概念是“点”。每个点都包含一些数据(例如,x、y、z坐标),并且有一系列函数可以操作这些点(例如,计算到原点的距离、判断所在象限等)。

在典型的面向对象语言中,我们会定义一个“类”(Class)来封装点的数据和函数。然后,通过实例化这个类来创建具体的“对象”(Object),例如三角形中的三个点。

Go语言的实现方式:结构体(Structs)

上一节我们介绍了传统面向对象中的“类”和“对象”概念。本节中我们来看看Go语言是如何实现的。

Go语言不使用“类”这个术语,而是使用结构体(Structs)。结构体最初来源于C语言,其核心作用是将不同类型的数据字段组合在一起

例如,我们可以定义一个 Point 结构体:

type Point struct {
    x float64
    y float64
    z float64
}

这个结构体定义了与一个点相关的数据。

然而,Go的结构体不仅仅是数据的集合。你还可以为结构体定义方法(Methods),也就是与特定结构体类型关联的函数。这使得结构体在功能上类似于其他语言中的“类”,它同时包含了数据字段和操作这些数据的方法。

Go面向对象的特点:简洁与高效

Go语言对结构体和方法的实现相比传统的类更加简化。以下是Go语言面向对象编程的一些关键特点:

  • 没有继承(Inheritance):Go使用组合(Composition)和接口(Interfaces)来替代传统的类继承,鼓励更扁平、更清晰的代码结构。
  • 没有构造函数(Constructors):通常通过编写一个返回结构体实例的普通函数(工厂函数)来初始化对象。
  • 没有泛型(Generics)(在早期版本中):虽然Go 1.18引入了泛型,但其设计哲学最初是避免复杂的泛型系统以保持语言的简洁。

这种简化设计带来了两个主要好处:

  1. 代码更易编写和维护:减少了复杂概念带来的认知负担。
  2. 运行时更高效:更简单的抽象通常意味着更少的运行时开销,程序运行速度更快。

当然,如果你非常依赖继承或复杂的泛型,可能会觉得这是一种限制。但总体而言,Go提供了一种更“精炼”的面向对象编程方式。

总结

本节课中我们一起学习了Go语言的面向对象编程。我们了解到Go通过结构体(Structs)方法(Methods) 来实现面向对象的核心思想——封装数据与行为。虽然它省略了传统面向对象语言中的一些特性(如继承、显式构造函数),但这种“弱”面向对象的设计使得Go语言在保持代码清晰易读的同时,也具备了出色的运行效率。

005:并发 🚀

在本节课中,我们将要学习计算机科学中的一个核心概念——并发。我们将探讨什么是并发、为什么它如此重要,以及Go语言是如何通过其内置特性来优雅地实现并发的。理解并发是编写高效、现代化软件的关键。

计算机的性能限制

上一节我们介绍了课程概述,本节中我们来看看为什么我们需要并发。很多对并发的需求源于对速度的追求。因此,我们首先需要了解计算机的性能限制,以及并发如何帮助我们突破这些限制。

摩尔定律指出,芯片上的晶体管数量大约每18个月翻一番。在过去,这直接导致了时钟频率的持续提升,使得计算机性能飞速增长。程序员甚至可以编写不那么高效的代码,因为他们知道硬件很快会变得更强大来弥补。

然而,这种情况已经改变。摩尔定律已经放缓。最主要的原因是功耗和温度限制。晶体管在开关时会消耗电力并产生热量。随着晶体管密度和时钟频率的不断提高,芯片产生的热量会达到空气散热的物理极限,可能导致芯片熔化。因此,我们无法再像过去那样单纯地通过提高时钟频率来获得性能提升。

并行:性能提升的途径

既然无法单纯提高时钟频率,我们如何获得性能提升呢?一个主要途径是使用并行

并行通常通过增加芯片上的核心数量来实现。例如,我们现在常见的四核机器,甚至在GPU上可能有成千上万个处理核心。拥有多个核心意味着你可以同时执行多个任务。这不一定能降低单个任务的延迟,但可以显著提高系统的整体吞吐量。

然而,实现并行编程存在许多困难。以下是程序员需要面对的几个主要挑战:

  • 任务调度:程序员需要决定任务何时开始、何时结束。
  • 数据依赖:当一个任务需要另一个任务生成的数据时,数据如何在任务间传递?
  • 内存冲突:当多个任务同时运行时,需要确保它们不会互相干扰彼此的内存空间。例如,一个任务不应意外覆盖另一个任务正在使用的变量。

并发编程:管理并行

为了解决并行编程的复杂性,我们需要并发编程。并发是同时管理多个任务的能力。这里的“同时”不一定指物理上的同时执行(例如在单核处理器上通过时间片切换),而是指这些任务在逻辑上是同时活跃的,并且从用户角度看是同时进行的。

对于大型系统而言,并发至关重要。系统中有许多组件在运行,它们并非完全顺序执行。并发编程使得程序能够以并行的方式组织任务。如果底层硬件提供了并行资源(如多核),那么这些并发任务就可以被映射到这些资源上,从而实现真正的并行执行。

你不能简单地将一段普通代码丢到五个核心上运行,这不会奏效。程序必须自行决定如何划分代码和数据。这正是并发编程的内容:程序需要做出那些允许任务在硬件支持时并行运行的决策。

并发编程包含几个关键方面,我们将在后续课程中深入探讨:

  • 任务执行管理:控制任务的启动和停止。
  • 任务间通信:任务之间如何发送和接收数据。
  • 共享内存管理:如果任务共享内存,如何安全地访问。
  • 同步:有时任务之间存在依赖关系,例如一个任务必须在另一个任务完成后才能开始。程序需要能够在代码中表达这些同步点。

Go语言中的并发

Go语言的一个巨大优势就在于其对并发的实现。Go语言内置了高效的并发原语,使得并发编程变得相对简单。

以下是Go语言中用于并发的三个核心概念:

  • Goroutine:代表一个并发执行的任务。你可以将其理解为一个轻量级的线程。
  • Channel:用于在并发任务(Goroutine)之间进行通信。
  • Select:用于处理多个通信操作,是实现同步的关键机制之一。

这些是高级的关键字和概念。在后续的专业课程中,我们将更详细地学习如何使用它们。将并发作为语言的核心特性并高效实现,对于当今多核处理器普及的时代来说,是一个巨大的优势,也使得编写并发程序变得更加重要和可行。

总结

本节课中我们一起学习了并发的核心概念。我们了解到,由于物理限制,单纯提升处理器时钟频率已遇到瓶颈,而通过增加核心数量实现并行是主要的性能提升途径。然而,并行编程本身充满挑战。为此,我们需要并发编程来管理多个同时活跃的任务。最后,我们看到了Go语言如何通过内置的GoroutineChannelSelect等特性,为编写高效、安全的并发程序提供了强大的支持。理解这些基础,是迈向高效Go程序开发的重要一步。

006:安装Go 🛠️

在本节课中,我们将学习如何下载和安装Go编程语言的工具链,以便能够立即开始运行程序。我们将首先讨论下载过程,然后实际操作,展示如何编译你的第一个程序。本节内容主要聚焦于安装步骤,这个过程相当直接明了。

下载Go工具链

首先,你需要访问Go语言的官方网站:golang.org

下图展示了当你访问该网站时看到的部分页面。为了适应幻灯片,这里并非完整页面,但它基本展示了当前页面的核心内容。请注意,网站设计可能会随时间变化。

你会在页面上反复看到地鼠图标,它是Go编程语言的吉祥物。页面上最重要的元素是底部的 “Download Go” 按钮。你的第一步就是点击这个按钮。

此外,在网页的左侧(图中未完全展示),你会看到一个黄色的文本框,可以在其中输入Go代码并点击“运行”按钮。这会在远程服务器上编译并执行你的代码。不过,我们不会使用这个在线工具。相反,我们将把编译器下载到你的本地机器上,进行本地开发。当然,如果你想先体验一下,可以在那里输入一些Go代码并运行。

选择安装包

点击“Download Go”后,你会进入一个类似下图的页面。同样,这里只展示了部分内容,页面下方和右侧还有更多信息。

在这个下载页面,你可以为不同平台下载预编译的版本,包括 WindowsLinuxmacOS。你也可以选择下载源代码,如果你愿意,可以从零开始编译整个Go工具链。不过,我们不建议初学者这样做,因为过程较为复杂。Go是开源的,所有源代码都可以在此下载。

本教程将以Windows平台为例进行演示,但步骤在Linux或macOS上同样适用。

对于Windows用户,最简单的方法是下载页面中高亮显示的 .msi 文件。建议选择最新的稳定预编译版本,避免使用不稳定的测试版。点击对应的链接即可开始下载。

安装过程

下载完成后,你会得到一个.msi安装文件。请注意,如果你的机器上安装了杀毒软件,它可能会弹出警告。请放心允许操作。

运行该安装文件后,会启动一个标准的安装向导。你只需要按照向导的提示操作即可:

以下是安装过程中的关键步骤:

  • 点击“Next”开始安装。
  • 接受许可协议。
  • 选择安装目录(默认位置通常即可,你也可以自定义)。
  • 继续点击“Next”,直到安装完成。

安装向导会引导你完成所有步骤,包括选择安装路径等。使用默认设置对大多数人来说都是合适的。

总结

本节课中,我们一起学习了如何从 golang.org 官网下载并安装Go编程语言的工具链。我们了解了如何选择适合自己操作系统(如Windows的.msi文件)的稳定版本,并跟随安装向导完成了本地环境的搭建。现在,你的机器已经准备好了Go编译器,在下一节中,我们将使用它来编写和运行你的第一个Go程序。

007:工作区与包

在本节课中,我们将要学习Go语言如何组织代码。我们将从理解工作区的概念开始,然后探讨包(Package)是如何构建和使用的。这些概念是Go语言实现代码共享和团队协作的基础。

工作区(Workspace)

上一节我们介绍了Go语言的基本结构,本节中我们来看看代码是如何在文件系统中组织的。Go语言使用一个称为“工作区”的目录来存放所有Go相关的文件。

工作区本质上是一个目录,你的Go源代码文件和其他相关文件都将存放在这里。通常,工作区内部有一个推荐的目录层次结构,用于存放不同类型的Go文件。Go语言定义这种层次结构的主要原因是,统一的组织方式有利于代码共享。Go语言的一个重要设计动机就是让人们能够轻松地协作。请记住,在实际编程工作中,尤其是在公司或团队项目中,你很少会独自工作。你需要与他人共享代码、合并代码或链接代码。因此,拥有一个标准化的文件组织方式非常有益,它能让每个人都知道文件的位置,工具也能更容易地找到所需内容。

以下是工作区中推荐的三个子目录:

  • src 目录:包含源代码文件,即你编写的 .go 代码文件。
  • pkg 目录:包含包文件,即你将要链接使用的其他包(编译后的形式)。
  • bin 目录:包含所有可执行文件,即编译后生成的可运行程序。

一个程序员通常使用一个工作区来管理多个项目。这意味着你可以在同一个工作区目录下进行20个不同的Go项目开发。关于这个目录层次结构,有一点需要记住:它是推荐的做法,但并非强制要求。例如,你可以将可执行文件放在 src 目录中,虽然这不够整洁且不利于共享,但程序依然可以编译和运行。这只是一个为了便于与他人协作的约定。

工作区目录的位置由 GOPATH 环境变量定义。GOPATH 环境变量的设置取决于你的操作系统。在安装Go的过程中,安装向导通常会为你自动设置 GOPATH 环境变量。例如,在Windows系统上,默认的工作区目录通常是 C:\Users\<你的用户名>\go。请注意,有时安装程序可能不会自动创建这个 go 目录,你可能需要手动创建它。所有的Go工具都假定你的代码位于 GOPATH 所指向的目录或其子目录中。

包(Package)

理解了代码的物理存储位置后,现在我们来学习代码的逻辑组织单元——包。你的代码被组织成一个个包。一个包就是一组相关的源代码文件。每个包都可以被其他包导入使用。

这种机制的主要用途在于团队协作。你可以将自己的所有代码写在一个包里,而其他团队的代码写在另一个包里。当你的代码需要使用他们的功能时,只需导入他们的包即可。这极大地促进了软件复用,是Go语言的核心目标之一。

源文件的第一行代码用于声明它所属的包。例如,在示例图片中,两个粉色方框代表两个不同的源文件,它们的第一行都通过 package 关键字声明了各自的包名,文件内的所有代码都属于这个包。蓝色的方框代表另一个源文件,它需要通过顶部的 import 语句来导入并使用前两个包中的功能。这就是包之间建立联系的方式,它为远程或跨团队协作提供了清晰的代码分离。

在Go程序中,必须有一个(且仅有一个)包被命名为 main。程序执行就从这里开始。在本课程目前的代码中,因为我们编写的程序规模较小,尚未涉及多包协作,所以我们只使用一个名为 main 的包。当你编译(build)main 包时,会生成一个可执行文件。而编译非 main 包时,则不会生成直接可运行的文件,它们会被整合到其他(通常是 main)包中使用。

main 包中必须包含一个名为 main 的函数。main 函数是程序执行的入口点。

让我们看一个简单的例子:

package main

import "fmt"

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

在这段代码中:

  • package main 声明这是一个主包。
  • import "fmt" 导入了一个名为 fmt 的标准包。fmt 不是我们编写的,它是随Go工具链一同提供的众多标准包之一,其中包含了许多有用的函数,例如用于格式化输出的函数。
  • func main() 定义了程序入口函数。
  • fmt.Println("Hello, World") 调用了 fmt 包中的 Println 函数来输出文本。

本节课中我们一起学习了Go语言代码组织的基础:工作区和包。我们了解到工作区是存放Go项目文件的根目录,其下通常有 srcpkgbin 三个子目录。代码在逻辑上被划分为包,包支持代码的模块化和复用,而 main 包和 main 函数是每个可执行程序的起点。理解这些概念是进行高效Go开发和团队协作的关键第一步。

008:Go工具 🛠️

在本节课中,我们将要学习Go语言的核心工具链。Go工具是随Go语言安装包一同提供的命令行工具集,用于管理Go源代码的编译、格式化、测试和依赖获取等任务。理解这些工具是高效进行Go开发的基础。

导入(import)语句与包管理

上一节我们介绍了Go程序的基本结构,本节中我们来看看如何引入外部代码功能。import是一个关键字,用于访问其他包。在课程的大部分时间里,我们将导入Go语言内置的包或标准库中的包,以实现所需功能。例如,我们一开始就会使用fmt包,它内置了Printf语句,用于打印输出。

当你使用import语句时,Go工具在构建程序时必须找到被导入的包。它会搜索由GOROOTGOPATH环境变量指定的目录。只要你的代码和依赖包都位于工作空间(即GOPATHGOROOT指定的路径)内,工具就能找到它们。如果你希望从其他位置导入某个包,例如它被安装在不同的目录中,那么你就需要修改GOPATHGOROOT环境变量,以扩展其搜索路径。不过,在本课程的大部分内容中,我们不会遇到这个问题。

Go工具(go tool)概述

Go工具是一个通用工具,用于管理Go源代码。安装Go语言时,你会自动获得这个工具。它包含许多不同的命令。

以下是Go工具的一些核心命令:

  • go build:此命令用于编译程序。你可以不给它任何参数,这样它会编译当前目录下的Go文件。你也可以给它一系列包名或Go文件作为参数,指定要构建的内容。它会为main包创建一个可执行文件。该可执行文件的名称与第一个Go文件的名称相同。在Windows系统上,可执行文件会带有.exe后缀。默认情况下,生成的可执行文件会放在执行构建命令的同一目录中。此命令还有许多其他参数,例如可以指定输出目录,但我们暂时不深入讨论。

  • go doc:此命令用于打印包的文档。作为程序员,你需要在你的包中编写文档注释,go doc命令可以从你的包中提取并显示这些文档。我们将在后续课程中详细介绍。

  • go fmt:此命令用于格式化源代码文件。Go语言对代码缩进等格式没有强制要求,但go fmt可以按照Go语言社区的标准风格自动格式化你的代码,帮助你保持代码整洁一致。

  • go get:此命令用于下载并安装包。如果你想获取非标准库的、功能有趣的第三方包,可以使用go get加上包名。它会从网上找到该包并下载安装。

  • go list:此命令用于列出所有已安装的包。

  • go run:此命令会编译Go文件并立即运行生成的可执行文件。它与先执行go build再手动运行可执行文件的效果类似。如果可执行文件已经存在,它也可能直接运行。

  • go test:此命令用于运行测试。本专项课程的第四门课将专门讲解测试。go test会寻找以_test.go结尾的测试文件并运行其中的测试用例。

总结

本节课中我们一起学习了Go语言工具链的核心命令。我们了解了import语句如何工作以及Go工具如何查找包,并逐一介绍了go buildgo docgo fmtgo getgo listgo rungo test这些常用命令的基本用途。掌握这些工具是开始高效编写和构建Go程序的重要一步。

009:变量

在本节课中,我们将要学习Go语言中一个基础且核心的概念:变量。我们将了解变量的命名规则、声明方式以及类型系统的重要性。

变量命名规则

在编写代码时,我们需要为变量、函数等元素命名。以下是Go语言中命名变量的基本规则。

以下是变量命名的具体规则:

  • 名称必须以字母开头。
  • 名称可以包含任意数量的字母、数字和下划线。
  • 名称在Go语言中是区分大小写的。
  • 不能使用Go语言的关键字(如 varpackageint 等)作为名称。

变量声明与类型

变量本质上是存储在内存某处的数据。每个变量都必须有一个名称和一个类型。所有变量都需要通过声明来指定其名称和类型。

一个简单的变量声明如下:

var x int
  • var 是声明变量的关键字。
  • x 是变量的名称。
  • int 是变量的类型,表示这是一个整数变量。

编译器需要知道变量的类型,以便了解需要为其分配多少内存空间,以及可以对该变量执行哪些操作。

你可以在同一行声明多个同类型的变量:

var x, y int

类型的作用

类型定义了变量可以存储的值的种类,以及可以对该变量执行的操作。

上一节我们介绍了如何声明变量,本节中我们来看看类型具体如何影响变量。以下是几种基本类型及其特点:

  • 整数:变量只能存储整数值。可执行的操作是整数算术运算,如加、减、乘。
  • 浮点数:变量可以存储小数或十进制值。可执行的操作是浮点算术运算。虽然运算符(如 +*)看起来和整数运算一样,但底层可能由不同的硬件指令实现。
  • 字符串:变量存储的是一个字节序列(通常用Unicode表示)。可执行的操作包括字符串比较、搜索、连接等。

类型的作用在于,它告诉编译器变量数据的大小(以便分配内存)以及可以执行的操作。最终,编译器需要将你编写的Go代码操作转换为特定硬件平台的机器码指令。例如,整数加法和浮点数加法可能对应完全不同的机器指令。这就是编译器必须知道变量类型的原因,以便正确地进行编译和代码转换。

本节课中我们一起学习了Go语言中变量的基础知识,包括命名规范、声明语法以及类型系统的重要性。理解这些概念是编写正确、高效Go程序的第一步。

010:变量初始化

在本节课中,我们将要学习Go语言中变量的初始化方法。我们将探讨如何为变量赋予初始值,包括在声明时初始化、声明后初始化以及使用简短声明。同时,我们也会回顾类型别名的概念,并了解变量的零值机制。

类型别名回顾

上一节我们介绍了变量的基本概念,本节中我们来看看如何为变量赋予初始值。但在开始之前,让我们先完成关于类型的讨论。

每个变量都必须有一个类型。你可以通过类型声明来定义一个类型的别名,这在特定应用程序中有时有助于提高代码的清晰度。

例如,假设你有一个处理温度的应用。温度通常用浮点数表示,比如64位浮点数。你可以为这个类型定义一个别名,使其在上下文中意义更明确。

代码示例:

type Celsius float64
type IDnum int

在上面的例子中,Celsius 本质上就是 float64IDnum 本质上就是 int。虽然你完全可以直接声明变量为 float64int,但使用 CelsiusIDnum 这样的别名可以让代码的意图更清晰,尤其是在处理特定领域概念(如温度或用户ID)时。

定义好类型别名后,你就可以用它来声明变量了。

代码示例:

var temp Celsius   // temp 是一个 float64,但类型名是 Celsius
var pid IDnum      // pid 是一个 int,但类型名是 IDnum

变量初始化方法

变量在使用前必须被初始化。Go语言提供了多种初始化变量的方式。

1. 声明时初始化

你可以在声明变量的同时为其赋值。

代码示例:

var x int = 100

这行代码声明了一个整数变量 x 并将其初始化为 100

你也可以省略类型,让编译器根据右侧的值自动推断类型。

代码示例:

var x = 100

在这里,数字 100 被推断为整数,因此变量 x 的类型就是 int

注意: 类型推断有时可能不会得到你期望的结果。例如,如果你写下 var x = 100,但心里希望 x 是一个浮点数以便后续赋值为 100.1,编译器会将其推断为 int,从而导致后续赋值出错。因此,在需要明确类型时,最好显式声明。

2. 声明后初始化

你也可以先声明变量,然后在后续的代码行中为其赋值。

代码示例:

var x int
x = 100

3. 零值初始化

如果你声明了一个变量但没有显式初始化它,Go语言会自动为其赋予一个“零值”。零值取决于变量的类型。

以下是不同类型对应的零值:

  • 整数类型(如 int):0
  • 浮点数类型(如 float64):0.0
  • 字符串类型string):""(空字符串)
  • 布尔类型bool):false

代码示例:

var x int      // x 被初始化为 0
var s string   // s 被初始化为空字符串 ""

4. 简短变量声明

Go语言提供了一种更简洁的声明并初始化变量的方式,称为“简短变量声明”,使用 := 操作符。

代码示例:

x := 100

这行代码完成了两件事:

  1. 声明变量 x
  2. 根据右侧表达式 100 推断 x 的类型为 int,并将其初始化为 100

重要限制: 简短变量声明 := 只能在函数内部使用。在函数外部(全局作用域)使用是不合法的。

总结

本节课中我们一起学习了Go语言中变量的初始化。我们回顾了使用类型别名来提高代码可读性,并详细介绍了四种初始化变量的方法:在声明时初始化、声明后初始化、依赖Go语言提供的零值自动初始化,以及在函数内部使用的简短变量声明。理解这些方法将帮助你更灵活、更清晰地编写Go代码。

011:基础数据类型概述 🧱

在本模块中,我们将深入学习Go语言的基础数据类型。我们将探讨整数、浮点数、布尔值和字符串等常见数据类型,并了解如何声明它们、使用它们,以及可以对它们应用哪些操作。


数据类型简介 📊

上一节我们完成了模块一的概述,本节中我们来看看Go语言的核心构建块——基础数据类型。这些类型是构成所有程序的基础。

Go语言提供了多种基础数据类型,包括整数、浮点数、布尔值和字符串。每种类型都有其特定的用途和可用的操作。

数据类型详解 🔍

以下是Go语言中主要的基础数据类型:

  • 整数:用于表示没有小数部分的数字。例如:42, -7
  • 浮点数:用于表示包含小数部分的数字。例如:3.14, -0.001
  • 布尔值:用于表示逻辑真或假。其值只能是 truefalse
  • 字符串:用于表示文本数据。例如:"Hello, Go!"

类型的变体与声明 📝

我们将详细讨论这些数据类型,特别是如何声明和创建它们。Go允许你根据需要改变某些类型的长度或精度。

例如,整数类型有不同的大小(如int8, int16, int32, int64),浮点数也有不同的精度(如float32, float64)。我们将学习如何根据程序的需求选择合适的类型。

可用的操作与函数 ⚙️

了解类型本身后,我们还需要知道能对它们做什么。本节将介绍可以应用于各种数据类型的函数和操作。

例如,可以对整数进行算术运算,对字符串进行连接和切片,对布尔值进行逻辑判断。我们将学习这些内置的功能,以便在代码中有效地使用这些类型。


总结 🎯

本节课中我们一起学习了Go语言基础数据类型的概览。我们介绍了整数、浮点数、布尔值和字符串这些核心类型,并预告了本模块将深入探讨它们的声明方式、变体以及可用的操作函数。掌握这些内容是编写有效Go程序的关键第一步。

012:指针 🧭

在本节课中,我们将要学习Go语言中的基本数据类型,并从指针开始。虽然从指针开始讨论数据类型可能有些不同寻常,但考虑到学习本课程的学员通常已有一定的编程基础,我们将直接深入探讨指针的概念。

概述

指针是内存中某个数据的地址。每个变量、函数等都位于内存的某个位置,而指针就是指向该位置的地址。理解指针对于掌握Go语言的内存管理和数据操作至关重要。

指针运算符

与指针相关的有两个主要运算符:&(取地址符)和*(解引用符)。它们是互逆的操作。

  • & 运算符:放在变量名前,返回该变量在内存中的地址。
  • * 运算符:放在指针前,返回该指针所指向地址中存储的数据。

代码示例解析

为了更好地理解这两个运算符,让我们通过一个简单的代码示例来演示它们是如何工作的。

以下是示例代码:

var x int = 1
var y int
var ip *int
ip = &x
y = *ip

现在,我们来逐步分析这段代码:

  1. var x int = 1:声明一个整数变量 x 并将其初始化为 1。此时,内存中某个位置存储了值 1
  2. var y int:声明一个整数变量 y,默认初始化为 0
  3. var ip *int:声明一个变量 ip,其类型为 *int,即它是一个指向整数的指针。
  4. ip = &x&x 获取变量 x 的内存地址,并将其赋值给指针 ip。现在,ip 指向存储值 1 的内存位置。
  5. y = *ip*ip 解引用指针 ip,获取它指向地址中存储的值(即 1),并将其赋值给变量 y。最终,y 的值变为 1

这个例子清晰地展示了 &* 运算符如何互为逆操作,共同完成通过指针访问和修改数据的流程。

new 函数

除了使用 & 运算符获取现有变量的地址,Go语言还提供了 new 函数来创建变量并直接返回指向它的指针。

new 函数的作用是:

  • 创建一个指定类型的变量。
  • 将该变量初始化为其类型的零值(例如,整数为 0,字符串为 "")。
  • 返回一个指向该新变量的指针。

以下是使用 new 函数的示例:

ptr := new(int)
*ptr = 3

代码解析:

  1. ptr := new(int)new(int) 在内存中创建一个新的整数变量(初始值为 0),并返回指向它的指针。该指针被赋值给变量 ptr
  2. *ptr = 3:通过解引用指针 ptr(即 *ptr),我们访问到它指向的那个整数变量,并将其值设置为 3

总结

本节课中我们一起学习了Go语言中指针的核心概念。我们了解到指针是内存地址,并通过 &* 运算符来获取地址和访问数据。我们还介绍了 new 函数,它可以创建变量并直接返回指针。理解指针是掌握Go语言中高效数据操作和内存管理的基础。

013:变量作用域

概述

在本节课中,我们将要学习Go语言中变量作用域的概念。变量作用域决定了在代码的哪些位置可以访问一个变量,以及当程序中存在多个同名变量时,编译器如何确定你引用的是哪一个。理解作用域对于编写正确、无冲突的代码至关重要。

什么是变量作用域?

变量作用域大致上是指变量在代码中可以被访问的位置。它定义了变量引用在代码中如何被解析。例如,当你引用一个变量 x 时,程序需要弄清楚你指的是哪个 x

为了说明这个概念,请看以下两个代码示例。

示例一:变量在函数外定义

x := 1

func f() {
    fmt.Println(x)
}

func g() {
    fmt.Println(x)
}

在这个例子中,变量 x 被定义在两个函数 fg 之外。当 fg 函数内部打印 x 时,它们都能成功访问并打印出 x 的值(即 1)。这是因为根据作用域规则,定义在函数外部的变量可以被其内部的所有函数访问。

示例二:变量在函数内定义

func f() {
    x := 1
    fmt.Println(x)
}

func g() {
    fmt.Println(x) // 错误:未定义的x
}

在这个例子中,变量 x 被定义在函数 f 内部。因此,函数 f 可以正常打印 x。然而,函数 g 内部没有对 x 的定义,当它尝试打印 x 时,编译器无法找到这个变量,从而导致错误。

这类问题需要通过理解变量作用域来避免。我们需要知道变量在哪里被解析,才能编写出没有冲突的代码。

Go语言如何确定作用域?

Go语言使用的概念来实现词法作用域

什么是块?

一个块是由匹配的花括号 {} 包围的一系列声明和语句。花括号内的一切构成了一个块。这些块可以是分层的,即一个块内部可以包含其他块。

除了由花括号显式定义的块,Go语言还存在一些隐式定义的块。

以下是Go语言中主要的块类型:

  • 全域块:包含所有Go源代码,是最大的块。
  • 包块:一个包中的所有源代码构成一个块,它位于全域块内部。一个包可以由多个文件组成。
  • 文件块:单个Go源文件中的所有代码构成一个块,它位于其所属的包块内部。
  • 其他隐式块:包括 ifforswitch 语句以及 switch 中的子句所定义的块。这些结构通常也使用花括号来定义其作用域。

这些块形成了一个层次结构,每个块都可以拥有与其关联的变量环境。

词法作用域与块层次

Go是一门采用块进行词法作用域划分的语言。要理解词法作用域,我们需要理解块之间的“包含”关系。

我们定义:如果块 Bj 定义在块 B 内部,则称 B 包含 Bj(记作 B >= Bj)。B 是外层作用域,Bj 是内层作用域。

让我们用之前的第一个代码示例来分析块的层次关系:

  1. 整个文件构成一个文件块,我们称之为 B1
  2. 函数 f 的花括号内部构成了一个函数块,我们称之为 B2
  3. 函数 g 的花括号内部构成了另一个函数块,我们称之为 B3

根据定义,B2B3 都定义在 B1 内部,因此 B1 >= B2B1 >= B3。而 B2B3 彼此没有包含关系。

变量解析规则

变量解析遵循以下规则:一个变量可以从块 Bj 中访问,当且仅当该变量声明在某个块 B 中,且 B >= Bj

这意味着,当在代码中引用一个变量时,解析过程如下:

  1. 首先在当前的局部块(例如函数块)中查找该变量的声明。
  2. 如果没找到,则向上一层,到包含当前块的外层块中查找(例如文件块)。
  3. 继续这个过程,直到找到变量的声明,或者查找到最外层的块(全域块)仍未找到,此时将报错。

应用这个规则分析我们的例子:

  • 示例一中,变量 x 声明在文件块 B1 中。函数 f 的块 B2 和函数 g 的块 B3 都满足 B1 >= B2B1 >= B3。因此,在 B2B3 内部都可以访问到 x
  • 示例二中,变量 x 声明在函数 f 的块 B2 中。对于函数 g 的块 B3,不存在任何包含 B3 的块 B 使得 x 声明在 B 中(因为 B2 不包含 B3)。因此,在 B3 内部无法访问 x,导致编译错误。

总结

本节课我们一起学习了Go语言中的变量作用域。我们了解到,作用域决定了变量的可访问范围,它通过的层次结构来管理。Go使用词法作用域规则,即变量在其声明的块以及所有内层块中可见。当引用一个变量时,编译器会从当前块开始,逐层向外查找其声明。理解这一机制有助于我们合理组织代码,避免因变量名冲突或不可访问而引发的错误。

014:内存释放 🧠

在本节课中,我们将要学习Go语言中内存管理的基础知识,特别是变量的内存分配与释放机制。理解这些概念对于编写高效且无内存泄漏的程序至关重要。

变量与内存分配

上一节我们介绍了变量的概念,本节中我们来看看变量在内存中的生命周期。变量是数据的引用,这些数据存储在计算机内存的某个位置。

当声明一个变量且程序运行时,必须在内存中为该变量分配空间。例如,对于一个整数变量,需要分配专门的空间来存储该整数值。最终,当不再使用该变量时,必须释放其占用的内存空间,使其可用于其他目的。这个过程称为内存释放

内存释放必须及时进行,否则机器最终会耗尽内存。例如,观察以下代码:

func f() {
    x := 1
    // ... 使用 x
}

程序运行时,必须为变量 x 分配一个内存位置来保存值 1。如果在程序中调用此函数 f() 100次,那么它将为变量 x 分配100个不同的空间。理想情况下,每次函数调用结束后,其对应的 x 变量空间就应该被释放。如果不释放,每次执行此函数都会分配一个新的 x 变量,导致内存中堆积大量不再需要的空间。这种现象被称为内存泄漏,在C语言等语言中很常见,会迅速耗尽所有可用内存。

栈与堆:内存的两个区域

为了理解空间如何被释放,我们需要讨论一下内存空间存储在何处。内存是一个庞大的系统,但目前与我们最相关的是两个主要区域:

栈是一个主要用于函数调用的内存区域。存储在栈中的一个重要内容是函数的局部变量。每次调用函数时,在该函数内定义的变量通常会被分配到栈中。当函数执行完毕时,这些在栈中分配的变量会被自动释放

另一方面,堆是一个持久的内存区域。在堆上分配的内容不会仅仅因为分配它的函数执行完毕而消失。在像C这样的语言中,你必须显式地释放堆内存。

Go语言对此做了一些调整,但理解变量可以存在于栈(函数结束时其内存大部分会自动释放)或堆(内存持久存在)中仍然很重要。

手动内存管理的权衡

在C语言等其他语言中,你必须手动管理堆内存的释放。栈上的内容无需手动释放,函数结束时它们会自动消失。

例如,在C语言中,如果你想在堆上分配内存,可以调用 malloc 函数:

int *x = malloc(32); // 分配32字节内存

之后,当你想要释放它时,可以调用 free 函数:

free(x); // 释放该空间

这种手动内存管理方式的特点是:容易出错但速度快

  • 容易出错:因为在分配和释放时很容易犯错,例如在错误的时间释放或忘记释放,这会导致程序错误和内存泄漏。
  • 速度快:其实现非常高效。与某些解释型语言由解释器自动进行垃圾回收(可能耗时)不同,在C这样的编译型语言中,手动管理避免了运行时开销。

总结

本节课中我们一起学习了内存管理的基础。我们了解到变量需要内存分配,并在不再使用时需要释放,否则会导致内存泄漏。内存主要分为两个区域:栈用于局部变量,通常随函数结束而自动释放;堆用于持久数据,在传统语言中需要手动管理释放。虽然手动管理(如C语言)能带来速度优势,但也增加了出错的风险。理解这些概念是掌握Go语言更高级内存管理机制(如垃圾回收)的重要基础。

015:垃圾回收 🗑️

在本节课中,我们将要学习Go语言中的一个重要特性:垃圾回收。我们将探讨为什么内存释放是一个难题,以及Go语言如何通过内置的垃圾回收机制优雅地解决这个问题。

内存释放的难题

上一节我们介绍了指针和内存地址。本节中我们来看看内存释放的挑战。

释放内存可能很困难,因为需要确定何时释放变量是合适的。原因在于,你只能在确定变量不再被使用时才能释放它。你不希望释放一个变量后,又需要用到它,因为那时它已经不存在了。有时很难判断变量何时在使用,何时不在使用。

以下是一个Go语言的例子,它在Go中是合法的,但在某些其他语言中则不合法:

func f() *int {
    x := 5
    return &x // 返回x的地址
}

func main() {
    var p *int = f()
    // 此时,main函数持有一个指向f函数局部变量x的指针
}

在这个例子中,函数f声明了一个局部变量x。通常,当函数结束时,其局部变量应该被释放。但这里的情况不同,因为函数返回了一个指向x的指针。由于main函数现在持有这个指针,它可能仍会使用变量x。因此,你不能简单地在f函数结束时释放x

这个例子说明了指针如何使得判断释放内存的时机变得复杂。

垃圾回收:自动化的解决方案

由于手动释放内存很复杂,人们采用的一种方法是使用垃圾回收

垃圾回收本质上是一个自动化的工具,用于处理内存释放。这在解释型语言(如Java、Python)中很常见,由解释器(如Java虚拟机、Python解释器)来执行。

以下是垃圾回收的工作原理:

  • 垃圾回收器会跟踪指针和引用。
  • 它判断一个变量何时不再被使用。
  • 一旦确定某个变量绝对不再被使用(即没有任何指针或引用指向它),垃圾回收器就会释放它。

垃圾回收对程序员来说非常方便。程序员无需担心何时释放内存、何时不释放。在其他语言(如C语言)中,手动管理内存是一个巨大的难题。

Go语言的独特之处

但是,垃圾回收通常需要一个解释器。因此,像C++这样的编译型语言一般无法实现它。

Go语言在这方面与众不同,也更出色。Go是一种编译型语言,但它内置了垃圾回收机制。这是一个非常棒的独特功能。

因此,Go编译器可以在一定程度上跟踪这些指针,判断它们是否仍在使用。它基本上会跟踪指向特定对象的所有指针。一旦所有指针都消失了,它就知道该对象可以被释放了。我们不会深入探讨Go垃圾回收的具体实现,因为那很复杂,方法也很多。

垃圾回收的优势与权衡

Go语言的垃圾回收机制带来了两个好处:

  1. 它会自动决定将数据分配在上还是上。作为程序员,你无需自己决定“我想把这个放在堆上”或“我想把这个放在栈上”。Go编译器在编译时会插入代码来判断,并相应地执行垃圾回收。
  2. 如果数据在堆上,垃圾回收器会适当地进行回收。它会查看所有指针是否都已消失,并决定何时可以进行垃圾回收(释放内存)。这是一个非常有用的功能。

当然,也存在一个权衡。主动的垃圾回收确实会占用一些时间,因此会带来一定的性能损耗。但Go的实现非常高效,而且垃圾回收极其有用,将其纳入Go语言中可能是值得的。这就是Go语言做出的权衡:它稍微降低了一点速度,但带来了巨大的优势,因为它使编程变得容易得多,并且你无需像使用解释型语言那样依赖一个完整的解释器。

总结

本节课中我们一起学习了Go语言的垃圾回收机制。我们了解到手动内存管理的困难,以及Go如何通过内置的、高效的垃圾回收器来自动管理内存,从而极大地简化了程序员的工作。虽然这会带来微小的性能开销,但其带来的开发便利性和安全性使得这一特性成为Go语言的核心优势之一。

016:注释与整数打印 📝

在本节课中,我们将学习Go语言中的两个基础但至关重要的概念:代码注释和打印输出。同时,我们也会深入了解整数类型及其相关操作。掌握这些知识是编写清晰、可调试代码的第一步。


注释

上一节我们介绍了变量的基本概念,本节中我们来看看如何通过注释让代码更易于理解。注释是写给程序员看的说明文字,编译器会完全忽略它们。

Go语言的注释风格与C语言类似,主要分为两种:

以下是单行注释的写法:

  • 使用双斜杠 //。从 // 开始,直到该行结束的所有内容都是注释。
  • 例如:
    // 这是一个完整的注释行
    var x int // 这是一个行内注释,只有“//”右侧的内容是注释
    

以下是多行注释(块注释)的写法:

  • 使用 /* 开始,*/ 结束。这两个符号之间的所有内容都是注释。
  • 例如:
    /* 这是一个
       多行注释块,
       可以跨越多行。 */
    

打印语句

为了查看程序运行的结果,我们需要使用打印语句。在Go语言中,这主要通过 fmt 包实现。

首先,你需要在程序顶部导入这个包:

import "fmt"

最基本的打印函数是 fmt.Printf。它接收一个字符串作为参数并打印出来。

以下是打印的基本用法:

  • 直接打印字符串:fmt.Printf("Hello")
  • 使用 + 运算符连接字符串后打印:fmt.Printf("Hello " + name)

然而,更常用和强大的方式是使用格式化字符串


格式化字符串

格式化字符串允许我们更灵活、美观地控制输出格式。其核心是在字符串中插入“转换字符”,然后用变量的值替换它们。

格式化字符串的通用格式是:

fmt.Printf("格式化字符串", 参数1, 参数2, ...)

在“格式化字符串”中,使用 % 加一个字母作为占位符。例如,%s 表示此处将替换为一个字符串。

以下是一个具体示例:

name := "Joe"
fmt.Printf("Hi %s", name) // 输出:Hi Joe

在这个例子中,%s 被变量 name 的值 “Joe” 所替换。


整数类型

现在,让我们把注意力转向整数。整数是编程中最基本的数据类型之一,用于表示没有小数部分的数字。

最通用的声明方式是 var x int。通常,我们只需这样声明,让编译器自动决定使用哪种具体长度的整数。

但Go语言也提供了不同位宽的整数类型,以满足对数值范围和内存占用的精确控制需求。

以下是不同位宽的整数类型:

  • 有符号整数int8, int16, int32, int64
  • 无符号整数uint8, uint16, uint32, uint64

核心概念

  • 位宽(如8, 16, 32, 64)决定了该类型在内存中占用多少比特(位),也决定了它能表示的数字范围。例如,一个 uint8(无符号8位整数)可以表示 0 到 255 之间的数字。
  • 有符号 vs 无符号:有符号整数(int)可以表示负数、零和正数;无符号整数(uint)只能表示零和正数,但因为省去了符号位,在相同位宽下能表示的最大正数更大。

整数运算符

整数支持丰富的运算符,用于进行数学和逻辑运算。

以下是主要的整数运算符分类:

  • 算术运算符+(加), -(减), *(乘), /(除), %(取模/求余数)
  • 比较运算符==(等于), !=(不等于), >(大于), <(小于), >=(大于等于), <=(小于等于)
  • 位运算符&(按位与), |(按位或), ^(按位异或), &^(按位清空), <<(左移), >>(右移)
  • 逻辑运算符(通常用于布尔表达式,但操作数可以是整数):&&(逻辑与), ||(逻辑或)

这些运算符的行为与其他主流编程语言基本一致。


本节课中我们一起学习了Go语言的代码注释写法、如何使用 fmt.Printf 进行基本打印和格式化输出,并深入探讨了整数类型的分类、位宽概念以及可用的运算符。理解这些基础知识,是构建更复杂程序的坚实起点。

017:整数、浮点数和字符串

概述

在本节中,我们将学习Go语言中的三种基本数据类型:整数、浮点数和字符串。我们将了解它们的基本概念、如何声明与使用,以及它们之间如何进行类型转换。此外,我们还将探讨字符串背后的编码原理,包括ASCII、Unicode和UTF-8。


整数与类型转换

上一节我们介绍了整数类型,现在在讨论其他基本类型之前,我们先来了解一下类型转换。

在某些情况下,你需要将一个数字或值从一种类型转换为另一种类型。为此,你需要使用类型转换。请注意,并非所有转换都是可行的。当转换可行时,你可以按照以下方式进行。

例如,假设你有两个不同长度的整数。变量X是32位整数,变量Y是16位整数。如果你尝试执行x = y这样的赋值操作,程序会失败。失败的原因是,尽管它们都是整数,但int32int16被视为两种不同的类型。Go编译器要求赋值操作符左右两边的类型必须相同。

为了完成这个操作,你必须将其中一个转换为另一个类型。例如,你可以将yint16)转换为int32,然后再赋值给x。这样就能成功。

以下是进行类型转换的方法:

x = int32(y)

这行代码会调用内置的int32()函数,尝试将其参数y转换为int32类型。对于整数,这种转换是可行的。转换过程会进行符号扩展。例如,如果y的值是2(一个16位的2),转换会将其符号位(0,表示正数)扩展到高16位,从而得到一个32位的、同样表示2的数值。

需要注意的是,有些类型的转换无法如此简单地进行,会失败。但像整数之间的这类转换是可行的。


浮点数

除了整数,另一种基本类型是浮点数。浮点数本质上就是实数。

浮点数的精度取决于其使用的位数。例如:

  • float32提供大约6位十进制数字的精度。
  • float64提供大约15位十进制数字的精度。

你需要根据所需的精度来决定使用哪种类型。通常,为了避免浮点数运算中常见的精度误差问题,倾向于使用更长的类型(如float64)。当然,更长的类型会占用更多内存,性能也可能有所不同,但精度问题有时更为关键。

你可以用十进制或科学计数法表示浮点数:

var x float64 = 123.45 // 十进制表示
var y float64 = 1.2345e2 // 科学计数法表示,e2 表示乘以 10 的 2 次方

此外,Go语言还支持复数类型。如果你还记得高中或其它地方学过的复数知识,它包含实部和虚部。你可以使用complex函数来创建复数:

var c complex128 = complex(2, 3) // 表示复数 2 + 3i

字符串、ASCII与Unicode

现在,我们来讨论字符串。为了理解字符串,我们需要先了解ASCII和Unicode。

字符串是字节序列(我们将在下一张幻灯片中详细说明)。字符串中的每个字节旨在表示一个你看到的字符。字符串通常用于打印输出,向用户展示信息,例如字符串“hello world”。

字符串中要存储的每个字符,都必须按照一个标准化的编码方案来表示。

ASCII是最早被广泛接受的编码标准,全称是美国信息交换标准代码。它是一种字符编码,每个要表示的字符都用一个8位代码来存储。例如,大写字母‘A’在ASCII中的十六进制代码是0x41。ASCII是8位长编码,最多能表示256个字符(实际是128个,因为其中一位用于其他用途)。这对于英语来说足够了,但无法涵盖其他语言的大量字符。

以中文为例,汉字数量庞大,8位编码无法表示。为了表示不同语言中的各种字符集,甚至是非语言的符号,你需要远超8位的编码能力,这就是Unicode的用途。Unicode是一种32位长的字符编码,可以表示多达2^32个字符,这是一个非常庞大的数量。

UTF-8可以看作是Unicode的一种实现方式。它是一种可变长度编码,可以是8位,也可以长达32位。UTF-8的前128个代码值与ASCII完全一致。例如,大写字母‘A’在ASCII和UTF-8中都是十六进制的0x41。UTF-8还包含许多其他字符,例如中文字符,这些字符超出了前128个代码值,可能需要16位或32位来表示。因此,UTF-8是一种可变长编码,但能表示的字符远多于ASCII。

在Go语言中,默认编码是UTF-8。在UTF-8或Unicode中,“码点”是指一个Unicode字符。在Go语言中,码点被称为rune。所以,字符‘A’有一个对应的rune,用十六进制0x41表示。


字符串详解

现在回到字符串本身。字符串是任意的字节序列,以UTF-8格式表示。每个字节(更准确地说,每个有效的UTF-8编码单元序列对应一个Unicode码点)就是一个rune

字符串是只读的。你无法修改一个已有的字符串,但可以创建一个新的字符串,它是现有字符串的修改版本。字符串通常用于打印或向用户显示信息。

字符串字面量是用双引号标注的字符串。例如:

x := "Hi there"

这就是一个字符串字面量。它是一个字节序列,其中每个字符(H, i, 空格, T, H, E, R, E)都将被表示为一个rune(即UTF-8码点)。这些rune被组合在一起(我们很快会讲到数组),就构成了一个字符串。


总结

本节课我们一起学习了Go语言中的三种基本数据类型。

  1. 整数:我们了解了不同长度的整数类型(如int16, int32)以及它们之间需要进行显式类型转换。
  2. 浮点数:我们认识了float32float64,知道了如何用十进制和科学计数法表示它们,并简单了解了复数类型。
  3. 字符串:我们探讨了字符串的本质是UTF-8编码的字节序列,了解了字符编码从ASCII到Unicode再到UTF-8的发展,并掌握了字符串只读的特性以及字符串字面量的表示方法。

理解这些基础类型是进行更复杂Go编程的基石。

018:字符串包 📦

在本节课中,我们将学习Go语言中用于处理字符串和字符的几个核心包:unicodestringsstrconv。这些包提供了丰富的功能,用于检查字符属性、搜索和操作字符串,以及在字符串与其他数据类型之间进行转换。


Unicode包:字符属性检查 🔍

上一节我们介绍了字符串的基本概念。字符串由Unicode字符(rune)组成。unicode包提供了一系列函数,用于检查单个字符(rune)的属性。这在解析用户输入或文件内容时非常有用。

以下是unicode包提供的一些常用函数,它们都接收一个rune类型参数并返回一个布尔值:

  • IsDigit:判断字符是否为数字。
  • IsSpace:判断字符是否为空格。
  • IsLetter:判断字符是否为字母。
  • IsLower / IsUpper:判断字符是否为小写或大写字母。
  • IsPunct:判断字符是否为标点符号。

此外,unicode包还提供了转换函数,例如ToUpperToLower,它们接收一个rune并返回转换后的rune


Strings包:字符串搜索与操作 🧵

了解了如何检查单个字符后,我们来看看如何操作整个字符串。strings包提供了许多处理完整字符串的函数。

字符串搜索

strings包包含一组用于在字符串中搜索内容的函数。

以下是部分搜索函数:

  • Compare(a, b string) int:比较两个字符串ab。如果a == b则返回0;如果a < b(按字母顺序)则返回-1;如果a > b则返回1
  • Contains(s, substr string) bool:判断字符串s是否包含子串substr
  • HasPrefix(s, prefix string) bool:判断字符串s是否以prefix开头。
  • Index(s, substr string) int:在字符串s中搜索子串substr,并返回其第一次出现的索引位置;如果未找到则返回-1

字符串操作

字符串在Go语言中是不可变的(immutable)。strings包中的操作函数不会修改原字符串,而是返回一个修改后的新字符串。

以下是一些常用的字符串操作函数:

  • Replace(s, old, new string, n int) string:将字符串s中前n次出现的子串old替换为new。如果n-1,则替换所有出现。
  • ToLower(s string) string / ToUpper(s string) string:将字符串s中的所有字符转换为小写或大写。
  • TrimSpace(s string) string:去除字符串s开头和结尾的所有空白字符。这在读取文件时非常有用,可以清理掉数据周围多余的空格。

Strconv包:字符串转换 🔄

最后,我们学习如何在字符串和基本数据类型(如整数、浮点数)之间进行转换。strconv(string conversion)包专门用于此目的。

当从文件或用户输入中读取数字时,它们通常以字符串形式存在。为了进行数学运算,必须将这些字符串转换为数值类型。

以下是strconv包的核心转换函数:

  • Atoi(s string) (int, error):将字符串s转换为int类型。函数名代表“ASCII to Integer”。
  • Itoa(i int) string:是Atoi的反向操作,将int类型i转换为字符串。
  • FormatFloat(f float64, fmt byte, prec, bitSize int) string:将浮点数f根据指定格式转换为字符串。
  • ParseFloat(s string, bitSize int) (float64, error):将字符串s解析为float64类型。

例如,从文件读取到字符串"123"后,可以使用Atoi将其转换为整数123,然后才能进行123 + 1这样的数学运算。


本节课中我们一起学习了Go语言中处理文本数据的三个重要包。unicode包用于检查字符属性,strings包用于搜索和操作字符串,而strconv包则负责字符串与基本数据类型之间的转换。掌握这些工具是进行有效文本处理和数据分析的基础。

019:常量 📘

在本节课中,我们将要学习Go语言中的常量(Constants)以及如何使用iota生成一组相关的常量。常量是程序编译时已知且不可改变的值,而iota则提供了一种便捷的方式来定义一组互不相同但相关的常量。


什么是常量? 🔧

常量是一种表达式,其值在编译时已知且不会改变。你可以将常量视为一个在整个程序运行期间都保持不变的变量。常量的类型由赋值语句右侧的值推断得出。

例如,以下代码声明了一个常量x,其值为1.3,编译器会根据1.3推断出x的类型为浮点数。

const x = 1.3

你可以一次性声明多个常量,如下所示:

const (
    y = 4
    z = "hi"
)

使用iota生成常量 🔄

上一节我们介绍了常量的基本概念,本节中我们来看看如何使用iota生成一组相关的常量。iota用于生成一组互不相同但相关的常量,适用于需要表示某个属性具有多个不同可能值的情况。这种情况也被称为“独热编码”(one-hot)。

例如,一周中的七天或一年中的十二个月。在这些情况下,你关心的是每个常量值互不相同,而不关心具体的数值是多少。iota本质上类似于其他语言(如C语言)中的枚举类型。

以下是使用iota定义一组常量的示例:

type Grades int

const (
    A Grades = iota
    B
    C
    D
    F
)

在这个例子中,我们定义了一个类型Grades,它实际上是int的别名。然后,我们使用iota为常量ABCDF赋值。iota会自动为第一个常量赋值,并为后续的常量依次递增赋值。需要注意的是,虽然当前实现中iota从0开始递增,但你不应依赖具体的数值,因为iota的核心思想是保证常量值互不相同,而不是关心具体的数值。


总结 📝

本节课中我们一起学习了Go语言中的常量以及如何使用iota生成一组相关的常量。常量是编译时已知且不可改变的值,适用于表示程序中的固定数据。而iota则提供了一种简洁的方式来定义一组互不相同但相关的常量,特别适用于枚举类型的场景。记住,使用iota时,你关心的是常量之间的差异性,而不是具体的数值。

020:控制流

在本节课中,我们将要学习Go语言中的控制流。控制流决定了程序语句的执行顺序。我们将介绍三种基本的控制流结构:if条件语句、for循环语句和switch多路选择语句。理解这些结构是编写逻辑清晰、功能正确程序的基础。

基本控制流

程序最基本的控制流是顺序执行,即从上到下逐条执行语句。然而,为了编写更灵活的程序,我们需要改变这种顺序。程序员通过在代码中插入控制流结构来实现这一点,从而改变语句的执行序列。

If 条件语句

if语句是最主要的控制流结构之一。它允许我们根据条件是否成立,来决定是否执行某段代码。

其基本结构如下:

if condition {
    // 条件为真时执行的代码块
}

这里的 condition 必须是一个结果为布尔值(truefalse)的表达式。如果条件为真,则执行花括号 {} 内的代码块;如果为假,则跳过该代码块。

例如:

if x > 5 {
    fmt.Println("x is greater than 5")
}

如果 x 大于5,程序会执行打印语句;否则,程序会完全跳过它。

除了基本的 if 语句,你还可以添加 else 子句。如果 if 的条件为假,程序将执行 else 代码块中的语句。这种结构在几乎所有主流编程语言中都很常见。

For 循环语句

for 循环是另一种改变控制流的方式。循环使得程序可以重复执行一段代码,直到满足特定条件为止。

for 循环会迭代执行一个代码块,只要循环条件为真。Go语言中最常见的 for 循环形式类似于C语言,包含三个部分:

for initialization; condition; update {
    // 循环体
}
  • 初始化语句:在循环开始时执行一次,通常用于设置循环计数器。
  • 条件表达式:在每次迭代开始前检查。其结果必须为布尔值。如果为真,则执行循环体;如果为假,则退出循环。
  • 更新语句:在每次迭代结束时执行,通常用于更新循环计数器。

一个典型的例子是:

for i := 0; i < 10; i++ {
    fmt.Println(i)
}

在这个例子中,i := 0 是初始化,i < 10 是条件,i++ 是更新。更新语句确保循环变量 i 最终会达到或超过10,从而使条件为假,循环得以结束。

以下是 for 循环的几种常见形式:

  1. 完整形式:包含初始化、条件和更新。

    for i := 0; i < 10; i++ { ... }
    
  2. 类似 while 循环的形式:只包含条件,初始化和更新在循环外部或内部处理。

    i := 0
    for i < 10 {
        // 循环体
        i++
    }
    
  3. 无限循环:不包含任何条件,会一直执行下去(通常需要配合 break 语句使用)。

    for {
        // 无限循环体
    }
    

    在常规程序中,我们通常要避免无限循环。

Switch 多路选择语句

switch 语句是另一种控制流结构,它提供了一种清晰的方式来处理多个条件分支,可以看作是多个 if-else if 链的替代。

switch 语句包含一个待检查的标签(通常是一个变量),以及一系列 case 子句。程序会将标签的值与每个 case 后的常量进行比较,执行第一个匹配的 case 代码块。

其基本结构如下:

switch tag {
case constant1:
    // 执行语句1
case constant2:
    // 执行语句2
default:
    // 如果没有case匹配,则执行default
}

例如:

switch x {
case 1:
    fmt.Println("x is 1")
case 2:
    fmt.Println("x is 2")
default:
    fmt.Println("x is neither 1 nor 2")
}

如果 x 等于1,则执行第一个 case;如果等于2,则执行第二个 case;如果都不匹配,则执行可选的 default 代码块。

与C语言不同,Go语言的 switch 语句在每个 case 执行完毕后会自动跳出(break),不会继续执行后续的 case,这通常是一个更安全、更符合直觉的设计。

总结

本节课中我们一起学习了Go语言的三种基本控制流结构。我们首先了解了 if 语句如何根据条件执行代码,然后探讨了 for 循环如何重复执行代码块,最后学习了 switch 语句如何优雅地处理多个条件分支。掌握这些结构是构建复杂程序逻辑的关键步骤。

021:控制流与扫描

在本节课中,我们将继续学习Go语言中的控制流结构。我们将探讨无标签switch语句的用法,了解breakcontinue语句在循环中的作用,并学习如何使用Scan函数从用户处读取输入。

无标签Switch语句

上一节我们介绍了带标签的switch语句。本节中我们来看看switch语句的另一种形式:无标签switch

常规的switch语句带有一个标签,例如switch x,其中x是一个变量,它会与case后面的常量进行比较。但有时你需要的不是这种比较。你可以使用不带标签的switch语句。在这种情况下,程序会执行第一个条件表达式为truecase分支。

这意味着,当switch语句没有标签时,每个case关键字后面跟的不再是常量,而是一个会计算出布尔值(truefalse)的表达式。如果该布尔值为true,则执行该case分支。程序会执行第一个条件为truecase分支。

以下是一个例子:

switch {
case x > 1:
    // 执行代码
case x < -1:
    // 执行代码
default:
    // 执行代码
}

在这种情况下,由于没有标签,程序会检查case关键字右侧的条件,例如x > 1,并对其进行求值。如果为true,则执行该case分支,然后结束整个switch语句。如果为false,则继续检查下一个case的条件是否为true,依此类推,直到检查完所有case。如果所有case条件都不满足,并且你包含了default分支,那么将执行default分支。

这就是无标签switch语句。你可以用它来代替一连串的if...else if...else if语句。

Break与Continue语句

breakcontinue也是控制流指令。它们有时被认为是不好的形式,但它们确实存在并被使用。breakcontinue用于循环中。

break语句会退出其所在的循环。假设你在一个循环内部,例如一个for循环。这个循环本应执行多次,但如果在循环内部遇到break语句,它会立即跳出整个循环。

看下面的代码示例:

for i := 0; i < 10; i++ {
    if i == 5 {
        break
    }
    // 循环体代码
}

在这个例子中,循环本应执行10次(i从0到9)。但当i等于5时,会执行break语句。这将导致循环在第五次迭代时提前终止,i为6到9的迭代不会执行。break只是跳出其所在的循环。

另一方面,continue语句也用于循环中,但它不会退出循环。它只是跳过当前这一次循环迭代。

看下面的代码示例:

for i := 0; i < 10; i++ {
    if i == 5 {
        continue
    }
    // 循环体代码
}

在这个例子中,如果没有if语句和continue,循环将执行10次。但这里,当i等于5时,会执行continue语句。这将导致跳过i等于5的那一次迭代,循环体代码在那次迭代中不会执行。循环仍然会继续执行后续的迭代(i为6到9),只是跳过了其中一次。

Scan函数

Scan是一个用于读取用户输入的函数。它本身不是控制流函数,但我们需要了解它,因为在编写代码示例时,读取用户输入是一件常见的事情。

Scan函数位于fmt包中。它的作用是读取用户从键盘输入的内容。Scan函数接受一个指针作为参数。你需要创建一个指针,指向你期望用户输入的值。例如,如果用户要输入一个整数,你就创建一个整数变量,然后将指向该整数的指针传递给Scan函数。

当你调用Scan函数时,程序会阻塞并等待,直到用户输入一些内容并按下回车键。当用户按下回车键后,Scan函数会获取他们输入的内容,并将其放入指针所指向的位置。例如,如果你传递了一个指向整数的指针,Scan会尝试将输入转换为整数,并存入那个整数变量中。

Scan函数返回两个值:

  1. 成功扫描的项目数量(即用户输入的空格分隔的标记数量)。
  2. 一个错误值。如果没有错误,该值为nil;如果有错误,则返回一个非nil的错误代码,我们可以据此进行检查。

我们可以看下面的示例代码:

var appleCount int
fmt.Print("Number of apples? ")
fmt.Scan(&appleCount)
fmt.Printf("%d\n", appleCount)

首先,我们声明一个整数变量appleCount。然后打印提示信息“Number of apples?”,期望用户输入一个整数,例如5。下一行执行Scan函数。代码会在此处停止运行,等待用户输入内容并按下回车。

假设用户输入了5并按下回车。注意,传递给Scan的参数是&appleCount,即变量appleCount的地址。当用户输入5并回车后,Scan函数会获取这个数字5,并将其放入appleCount变量中。因此,在下一行使用Printf打印appleCount时,它将输出5或用户输入的任何整数。


本节课中我们一起学习了Go语言中更丰富的控制流工具。我们了解了如何使用无标签switch语句根据条件执行不同分支,掌握了breakcontinue语句在循环中控制流程的方法,并学会了使用Scan函数来读取用户的键盘输入。这些是构建交互式和逻辑复杂程序的基础组件。

022:复合数据类型概述 🧩

在本节课中,我们将要学习Go语言中的复合数据类型。复合数据类型是能够将其他数据类型聚合在一起的数据类型,它们将许多不同的数据类型组合成一个整体。具体来说,我们将探讨数组、切片、映射和结构体。

复合数据类型对于编写复杂的、真实的代码至关重要,因为基本数据类型不足以描述大型代码中需要引用的复杂概念。因此,你需要将它们聚合起来,例如,你可能有一些字符串和一些整数,然后将它们合并成一个能描述你特定应用程序中概念的整体。

完成本模块的学习后,你将能够在Go代码中使用更复杂的复合数据类型。


什么是复合数据类型?

上一节我们介绍了本模块的学习目标,本节中我们来看看复合数据类型的核心定义。

复合数据类型是聚合了其他数据类型的数据类型。它们将多个不同的数据值组合成一个单一的实体。在Go语言中,主要的复合数据类型包括:

  • 数组:固定长度的、相同类型元素的序列。
  • 切片:动态长度的、相同类型元素的序列,基于数组构建,但更灵活。
  • 映射:存储键值对的无序集合。
  • 结构体:将不同类型的字段组合成一个逻辑单元。

为什么需要复合数据类型?

我们已经了解了复合数据类型的种类,现在来探讨为什么它们如此重要。

基本数据类型(如整数、浮点数、字符串)只能表示单一、简单的值。然而,在现实世界的程序中,我们需要描述更复杂的实体。例如,一个“用户”可能包含姓名(字符串)、年龄(整数)和邮箱(字符串)。使用复合数据类型,我们可以将这些信息聚合到一个结构体中,使代码更清晰、更易于管理。

以下是使用复合数据类型的一些关键优势:

  • 组织数据:将相关的数据字段组合在一起,提高代码可读性。
  • 简化操作:可以一次性传递或操作整个数据集合,而不是多个分散的变量。
  • 建模现实:能够更准确地为程序要解决的现实问题建模。

本模块将涵盖的内容

在接下来的章节中,我们将逐一深入探讨各种复合数据类型。

我们将从数组开始,学习如何创建和操作固定大小的元素集合。接着,我们会学习更常用的切片,了解其动态增长的特性。然后,我们将探索映射,这是一种通过唯一键快速访问值的强大工具。最后,我们将学习结构体,它是构建复杂数据模型的基石。


总结

本节课中我们一起学习了Go语言中复合数据类型的基本概念。我们了解到,复合数据类型(如数组、切片、映射和结构体)能够将多个值聚合起来,以描述程序中的复杂概念,这对于编写超越简单示例的实际应用程序至关重要。在接下来的课程中,我们将详细学习每一种类型的具体用法和特性。

023:数组 📚

在本节课中,我们将要学习Go语言中的复合数据类型,特别是数组。复合数据类型超越了基本数据类型,它们能够将其他数据类型组合或聚合在一起。我们将了解数组的定义、声明、初始化以及如何遍历数组。


复合数据类型简介

上一节我们介绍了基本数据类型。本节中我们来看看复合数据类型。复合数据类型是超越基本数据类型的存在,它们能够聚合其他数据类型。虽然字符串在某种程度上也可以被视为复合数据类型(因为它聚合了字节),但我们现在将更一般地讨论数组

数组是一个固定长度的、由选定类型元素组成的序列。你可以创建字节数组、整数数组、浮点数数组等。数组的关键特性在于其长度是固定的,编译器在编译时就知道它需要为数组分配多少内存空间。

复合数据类型在复杂程序中至关重要。仅使用基本数据类型是不够的,你需要将不同的数据类型组合并聚合到某种复合数据类型中。


数组的声明与初始化

数组中的每个元素都属于选定的类型。例如,在整数数组中,每个元素都是一个整数。

数组的元素使用下标表示法进行索引,即方括号 []。索引从0开始,这是计算机科学中的惯例。

在Go语言中,数组元素会被初始化为其类型的零值。这与某些其他语言(如C语言)不同,在C语言中,除非显式初始化,否则数组不会被初始化。在Go中,整数数组的零值是0,字符串数组的零值是空字符串。

以下是声明和初始化数组的示例:

var x [5]int
x[0] = 2
fmt.Printf("%d", x[1]) // 输出 0,因为 x[1] 被初始化为零值

在这个例子中:

  • var x [5]int 声明了一个包含5个整数的数组。
  • x[0] = 2 将数组的第一个元素(索引0)设置为2。
  • 由于我们没有设置 x[1],它保持为整数的零值0。

数组字面量

数组字面量是用于初始化数组的一组预定义值。

你可以使用数组字面量来初始化数组。例如:

var x = [5]int{10, 20, 30, 40, 50}

这里,{10, 20, 30, 40, 50} 就是一个数组字面量。字面量的长度必须与声明的数组长度相同。

Go语言提供了一个关键字 ...(省略号),用于让编译器根据字面量中的元素数量自动推断数组长度。

x := [...]int{1, 2, 3, 4}

在这个例子中,编译器会推断出数组 x 的长度为4。


遍历数组

编程中对数组最常进行的操作之一就是遍历数组。这意味着依次检查数组中的每个元素,并可能对其进行操作(例如求和、条件判断等)。

在Go语言中,我们使用 for 循环和 range 关键字来遍历数组。

以下是遍历数组的步骤:

  1. for 循环中使用 range 关键字。
  2. range 关键字右边是要遍历的数组名。
  3. range 关键字左边可以接收两个变量:索引

示例代码如下:

x := [3]int{1, 2, 3}
for i, v := range x {
    fmt.Printf("索引 %d 的值是 %d\n", i, v)
}

在这个循环中:

  • 每次迭代,变量 i 会被绑定到当前元素的索引(0, 1, 2)。
  • 变量 v 会被绑定到当前元素的值(1, 2, 3)。
  • 循环体(花括号 {} 内的代码)会对每个元素执行一次,你可以在这里对索引 i 和值 v 进行任何操作。

总结

本节课中我们一起学习了Go语言中的数组。

  • 我们了解到数组是一种固定长度的复合数据类型。
  • 学习了如何声明数组,并知道其元素会被自动初始化为零值。
  • 掌握了使用数组字面量... 语法来初始化数组的方法。
  • 最后,我们学习了如何使用 for 循环和 range 关键字来遍历数组,这是处理数组数据的基础操作。

理解数组是学习更复杂数据结构(如切片和映射)的重要基础。

024:切片 🍰

在本节课中,我们将要学习Go语言中一个非常核心且强大的数据结构——切片。切片提供了比数组更灵活的操作方式,是Go语言中处理序列化数据的主要工具。

什么是切片?

上一节我们介绍了数组,本节中我们来看看切片。切片是一种在许多其他编程语言中不常见的数据类型,但它非常有用。实际上,切片经常被用来替代数组,因为它们具有灵活性,可以改变大小。

本质上,切片是底层数组的一个窗口。每个切片都必须基于一个底层数组。切片就是这个数组的一个片段或视图。例如,一个包含100个元素的数组,其切片可能只包含其中的第3、4、5个元素,或者第5到第9个元素。切片的大小可以变化,最大可以达到底层数组的大小。这是切片的一个显著优点。

切片的三个属性

每个切片都包含三个基本属性:

以下是切片的三个核心属性:

  1. 指针:指向切片在底层数组中开始的第一个元素。
  2. 长度:切片当前包含的元素数量。
  3. 容量:切片可以扩展到的最大元素数量。它由指针位置和底层数组末尾的距离决定。

例如,一个大小为100的数组,如果切片从数组开头开始,其容量就是100。如果切片从数组索引10开始,那么它的容量就只有90。

切片定义与示例

让我们通过一个例子来理解这些概念。

arr := [7]string{"A", "B", "C", "D", "E", "F", "G"}
s1 := arr[1:3] // 包含索引1和2的元素(B, C)
s2 := arr[2:5] // 包含索引2、3、4的元素(C, D, E)

在上面的代码中,arr[1:3] 定义了一个切片,它包含从索引1开始、到索引3(不包含)结束的元素。因此,s1 包含元素 BC。同理,s2 包含元素 CDE。注意,s1s2 都包含了索引2的元素 C,这说明切片可以重叠。

长度与容量

Go语言提供了 len()cap() 函数来获取切片的长度和容量。

arr := [3]int{1, 2, 3}
slice := arr[0:1] // 切片包含 arr[0]
fmt.Println(len(slice)) // 输出: 1
fmt.Println(cap(slice)) // 输出: 3

这个切片的长度是1,因为它只包含一个元素。但它的容量是3,因为它从数组开头开始,可以扩展到包含整个数组。

访问与修改切片元素

对切片元素的读写操作,实际上是在操作其底层数组。重叠的切片可以引用相同的数组元素。

arr := [3]string{"A", "B", "C"}
s1 := arr[0:2] // [A, B]
s2 := arr[1:3] // [B, C]

s1[1] = "Z" // 修改底层数组arr[1]
fmt.Println(s2[0]) // 输出: Z

因为 s1[1]s2[0] 都指向底层数组的同一个元素 arr[1],所以修改其中一个会影响另一个。

切片字面量

与数组类似,你也可以使用字面量来初始化切片。

slice := []int{1, 2, 3, 4, 5} // 这是一个切片字面量

当你使用切片字面量时,Go会先创建一个底层数组来存放这些值,然后创建一个指向整个该数组的切片。因此,这个切片的长度和容量是相等的。

本节课中我们一起学习了Go语言切片的核心概念。我们了解到切片是底层数组的动态视图,具有长度和容量的属性,并且可以通过字面量方便地创建。理解切片是掌握Go语言中高效数据操作的关键。

025:可变切片 🧩

在本节课中,我们将要学习Go语言中切片的第三种创建方式——使用 make 函数,并了解如何使用 append 函数来动态地增加切片的长度。切片是Go语言中一个非常强大且灵活的数据结构,它克服了数组长度固定的限制。

使用 make 函数创建切片

上一节我们介绍了通过数组和切片字面量来创建切片。本节中我们来看看如何使用 make 函数直接创建切片。这种方式适用于你希望切片具有可变大小的能力,但在一开始需要将其初始化为特定大小的情况。

make 函数有两种调用方式:传递两个参数或三个参数。

双参数版本

以下是双参数版本 make 函数的使用方法:

sli := make([]int, 10)
  • 第一个参数:指定切片中元素的类型,例如 []int[]string
  • 第二个参数:指定切片的长度

当使用双参数版本时,切片的长度容量是相等的。这意味着底层数组的大小与切片的大小完全相同,切片从底层数组的起始位置开始。

三参数版本

以下是三参数版本 make 函数的使用方法:

sli := make([]int, 10, 15)
  • 第一个参数:指定切片中元素的类型。
  • 第二个参数:指定切片的长度
  • 第三个参数:指定切片的容量,即底层数组的大小。

使用三参数版本时,你可以将长度和容量分开指定。这意味着底层数组可以比切片本身更大。在上面的例子中,切片长度为10,但底层数组大小为15,因此如果需要,我们可以将这个切片扩展到最大15个元素。

使用 append 函数扩展切片

了解了如何创建具有容量的切片后,我们来看看如何动态地向切片添加元素。这是切片相比传统数组的一个关键优势。

append 函数用于向切片的末尾添加一个或多个元素,从而增加切片的长度。

其工作原理如下:

  1. 它将新元素插入到底层数组中。
  2. 它会将切片的长度增加到不超过底层数组的容量。
  3. 如果切片的长度已经达到了底层数组的容量(即长度等于容量),append 函数会创建一个新的、更大的底层数组,将原有元素复制过去,然后继续添加新元素。这意味着你可以持续地向切片追加元素,而不会受到初始数组大小的限制(尽管重新分配数组会带来一定的性能开销)。

以下是 append 函数的一个使用示例:

sli := make([]int, 0, 3) // 创建一个长度为0,容量为3的切片
sli = append(sli, 100)   // 向切片追加元素100

在这个例子中:

  • 我们首先创建了一个长度为0(空切片)、容量为3的切片。
  • 然后,我们调用 append(sli, 100) 将数字100追加到切片末尾。
  • 为了容纳这个新元素,append 函数会将切片 sli 的长度从0增加到1。

总结

本节课中我们一起学习了:

  1. 使用 make 函数创建切片的两种方式:双参数版本(长度=容量)和三参数版本(可分别指定长度和容量)。
  2. 使用 append 函数向切片动态添加元素,这是切片可变长的核心机制。append 会在必要时自动处理底层数组的扩容,使得切片的使用非常灵活。

掌握 makeappend 是高效使用Go语言切片的基础,它们让你能够轻松管理动态大小的数据集合。

026:哈希表

概述

在本节课中,我们将要学习一种在许多编程语言中都广泛使用的数据结构——哈希表。哈希表允许我们快速访问大量数据,是一种非常实用的工具。我们将了解它的基本概念、工作原理、优缺点,并通过简单的例子来理解其核心思想。

哈希表是什么?

哈希表是一种数据结构,它存储着大量的键值对。每个值都与一个唯一的键相关联。例如,可以将社会保障号作为唯一的键,而将电子邮件地址作为对应的值。另一个例子是GPS坐标和地址,其中GPS坐标是唯一的键,地址是对应的值。哈希表的核心目的是高效地存储和检索这些键值对,并且每个键必须是唯一的,这一点至关重要。

哈希表如何工作?

上一节我们介绍了哈希表存储键值对的基本概念。本节中我们来看看它是如何实现快速访问的。关键在于一个称为“哈希函数”的组件。

哈希函数接收一个键作为输入,经过计算后,返回哈希表中的一个“槽位”索引,这个槽位就是用来存放与该键对应的值的位置。在Go语言中,这个函数是内部自动调用的,开发者无需直接操作它。

为了更直观地理解,请看下面的例子:

假设我们有三个键值对:

  • "Joe""X"
  • "Jane""Y"
  • "Pat""Z"

哈希表在底层可以看作一个数组。哈希函数会分别处理每个键:

  • 对于键 "Joe",哈希函数计算出索引 3,因此值 "X" 被放入数组的第3个槽位。
  • 对于键 "Jane",哈希函数计算出索引 1,因此值 "Y" 被放入数组的第1个槽位。
  • 对于键 "Pat",哈希函数计算出索引 5,因此值 "Z" 被放入数组的第5个槽位。

这样,当我们想查找 "Jane" 的值时,只需再次将 "Jane" 输入哈希函数,它就会直接告诉我们值存放在索引 1 的位置,从而实现快速访问。

哈希表的优势与劣势

了解了哈希表的工作原理后,我们来分析一下它的优点和可能遇到的问题。

优势

以下是哈希表的主要优点:

  1. 快速查找:与需要线性遍历的列表相比,哈希表的查找速度是常数时间 O(1)。你无需从开头逐个比较,哈希函数能直接定位到数据所在位置。
  2. 使用有意义的键:与数组或切片必须使用整数索引不同,哈希表允许使用任意类型且有实际意义的键(如人名 "Joe")。这使代码更易编写和理解。

劣势

哈希表也存在一个潜在的缺点:

  • 哈希冲突:当两个不同的键(如 "Joe""Jane")经过哈希函数计算后,得到了相同的槽位索引,就会发生冲突。此时,两个值需要被放入同一个槽位。虽然Go语言内部有机制处理这种情况(例如使用链表),但这会略微降低操作速度。不过,设计良好的哈希函数会使冲突非常罕见,因此这通常不会造成大问题。

总结

本节课中我们一起学习了哈希表。我们了解到哈希表是一种通过键值对存储数据的高效数据结构,它利用哈希函数将键映射到存储位置,从而实现快速的常数时间查找。它的主要优势是速度快且允许使用有意义的键,而主要的潜在问题是哈希冲突,但在实践中,冲突并不常见。掌握哈希表是理解现代编程中数据管理的重要一步。

027:映射 🗺️

在本节课中,我们将要学习Go语言中的映射(Map)。映射是Go语言对哈希表(Hash Table)的实现,它是一种用于存储键值对(Key-Value Pairs)的数据结构。我们将学习如何创建、访问、修改和遍历映射。


概述

映射是Go语言中一种强大的数据结构,它允许你将一个唯一的键(Key)与一个值(Value)关联起来。你可以通过键来快速查找、添加或删除对应的值。本节将详细介绍映射的基本操作。


创建映射

你可以使用 make 函数来创建一个映射。首先,你需要声明一个映射变量,指定键和值的类型。

var idMap map[string]int
idMap = make(map[string]int)

另一种创建映射的方法是使用映射字面量(Map Literal),在创建时直接初始化键值对。

idMap := map[string]int{
    "Joe": 123,
}

在上面的例子中,键 "Joe" 对应的值是 123。你可以在花括号内添加任意数量的键值对,用逗号分隔。


访问映射元素

访问映射中的元素与访问数组类似,但你需要使用键作为索引。

fmt.Println(idMap["Joe"])

这段代码会打印出键 "Joe" 对应的值。如果键不存在,映射会返回值类型的零值。


添加或修改映射元素

你可以通过赋值操作向映射中添加新的键值对,或者修改已存在的键对应的值。

idMap["Jane"] = 456

如果键 "Jane" 已经存在,其对应的值会被新值覆盖;如果不存在,则会添加一个新的键值对。


删除映射元素

使用 delete 函数可以从映射中删除一个键值对。

delete(idMap, "Joe")

这段代码会删除键 "Joe" 及其对应的值。


映射的其他操作

以下是映射的一些其他常用操作。

检查键是否存在

你可以使用双值赋值(Two-Value Assignment)来检查一个键是否存在于映射中。

id, p := idMap["Joe"]

如果键 "Joe" 存在于映射中,p 的值为 trueid 为对应的值;如果不存在,pfalseid 为值类型的零值。

获取映射长度

使用 len 函数可以获取映射中键值对的数量。

fmt.Println(len(idMap))

遍历映射

你可以使用 for 循环和 range 关键字遍历映射中的所有键值对。

for key, value := range idMap {
    fmt.Printf("Key: %s, Value: %d\n", key, value)
}

在每次循环中,keyvalue 会分别被赋值为映射中的一个键和对应的值,直到遍历完所有键值对。


总结

在本节课中,我们一起学习了Go语言中映射的基本操作。我们了解了如何创建映射、访问映射元素、添加或修改键值对、删除元素,以及如何检查键是否存在、获取映射长度和遍历映射。映射是一种非常实用的数据结构,能够帮助你高效地管理键值对数据。

028:结构体

概述

在本节课中,我们将要学习Go语言中的结构体。结构体是一种复合数据类型,用于将不同类型的数据聚合在一起,形成一个单一的对象。这对于组织和管理相关数据非常有用。

什么是结构体?

结构体是另一种聚合类型,也是一种复合数据类型。它能够将任意数据类型的对象组合成一个对象。结构体通常用于组织目的,能极大地帮助程序员管理相关数据。

为什么使用结构体?

假设我们想要描述一个人,每个人通常有姓名、地址和电话号码这三个特征。一种方法是使用三个独立的变量来表示每个人的信息,例如 name1address1phone1。然而,这种方法要求程序员必须记住这些变量是相互关联的。

更好的方法是创建一个结构体来表示一个人。这个结构体将聚合所有三个变量,使得它们作为一个整体对象存在。这样,当访问这些变量时,可以清楚地知道它们属于同一个人。

定义结构体

以下是定义一个结构体的示例。我们创建一个名为 person 的结构体类型,它包含三个字段:nameaddressphone,每个字段都是字符串类型。

type person struct {
    name    string
    address string
    phone   string
}

定义了这个类型后,我们可以创建任意数量的 person 变量。例如,var p1 personvar p2 person。每个变量都有自己独立的 nameaddressphone 字段。

访问结构体字段

要访问结构体的字段,无论是读取还是写入,我们使用点号表示法。这与数组不同,数组使用方括号和索引。

例如,要将 p1name 字段赋值为 "Joe",可以这样写:

p1.name = "Joe"

同样,要读取 p1address 字段并赋值给变量 x,可以这样写:

x := p1.address

初始化结构体

有多种方法可以初始化结构体。

使用 new 函数new 函数会创建一个结构体,并将所有字段初始化为其零值。对于字符串类型的字段,零值是空字符串。

p1 := new(person)

使用结构体字面量:可以在创建结构体的同时为所有字段赋值。以下是使用结构体字面量初始化 person 的示例。

p1 := person{
    name:    "Joe",
    address: "A Street",
    phone:   "123",
}

总结

本节课中,我们一起学习了Go语言中的结构体。我们了解了结构体的定义、为什么使用结构体、如何访问结构体字段以及如何初始化结构体。结构体是一种强大的工具,能够帮助我们将相关的数据组织在一起,使代码更加清晰和易于管理。

Go语言编程:模块4:协议与格式概述

在本模块中,我们将学习Go语言中用于处理标准化协议和格式的包。当编写代码时,经常需要与自身代码之外的其他系统进行交互,这种交互通常意味着交换数据。无论是通过网络与服务器通信,还是读写特定格式的数据文件(如数据库),都需要遵循通用的标准。本模块将介绍Go语言内置的一些包,它们能帮助我们与这些广泛使用的标准进行对接。

上一节我们明确了本模块的学习目标,本节中我们来看看模块的具体内容结构。

以下是本模块将涵盖的几个核心协议与格式:

  • JSON (JavaScript Object Notation):一种轻量级的数据交换格式,易于人阅读和编写,也易于机器解析和生成。Go语言通过 encoding/json 包提供支持。
  • XML (eXtensible Markup Language):一种标记语言,用于存储和传输数据。Go语言通过 encoding/xml 包提供支持。
  • CSV (Comma-Separated Values):一种用逗号分隔值的纯文本格式,常用于表格数据。Go语言通过 encoding/csv 包提供支持。
  • HTML (HyperText Markup Language):用于创建网页的标准标记语言。Go语言通过 html 包及相关模板包提供支持。

Go语言为所有这些重要的标准都提供了相应的包,使得我们能够轻松地在代码中编码(序列化)和解码(反序列化)数据,从而实现与外部系统的顺畅通信。

本节课中我们一起学习了模块四的概述,了解了在Go语言中处理如JSON、XML、CSV等通用协议和格式的重要性及内置支持。在接下来的课程中,我们将逐一深入探讨这些包的具体用法。

030:RFC与数据交换标准 📜

在本节课中,我们将要学习什么是RFC,以及为什么在编程中,尤其是在Go语言中,理解和使用基于RFC的标准协议与数据格式至关重要。

概述

当我们编写大型程序时,程序经常需要与其他系统或数据块进行交互。为了实现这种交互,数据必须以双方都能理解的格式进行传输。RFC(Request for Comments,征求意见稿)就是定义这些协议或格式的标准文档。它们虽然不是Go语言本身的一部分,但在构建需要网络通信或数据交换的系统时,扮演着核心角色。

上一节我们介绍了程序间交互的必要性,本节中我们来看看实现这种交互的通用“语言”——RFC标准。

什么是RFC? 🤔

RFC在技术上是“征求意见稿”,但其本质是协议或格式的定义,即一种标准规范。我们关注RFC,是因为在编写程序时,你总会需要与其他系统交互。例如,你的程序可能需要读取文件或处理来自数据库的数据。这些数据必须遵循一种公认的格式,你的代码才能处理它。

同样,在网络通信中,例如构建一个Web客户端,它必须按照特定的协议(如HTTP)向服务器发送消息,服务器才能理解。反之,你的程序也必须能理解服务器返回消息的格式。因此,任何需要通过数据传输进行系统间交互的场景,都依赖于这些定义明确、广泛使用的格式和协议。

常见的RFC标准示例 🌐

以下是几个由RFC定义的重要标准示例:

  • HTML (RFC 2854): 超文本标记语言,用于编写网页的标准语言。所有网络浏览器都必须理解它,以便正确渲染页面。
  • URI (RFC 3986): 统一资源标识符,是Web上使用的寻址方法(如URL)。它规定了地址的特定格式,以便所有客户端和服务器都能解析。
  • HTTP (RFC 2616): 超文本传输协议,定义了消息如何在网络上传输,包括消息头、内容、长度等信息,使得Web浏览器能与服务器通信。

这些只是众多标准协议中的几个例子。有些协议简单,有些则非常复杂。

Go语言中的RFC支持包 🛠️

在Go语言中,提供了许多包来帮助处理这些格式。虽然不是所有RFC都有对应的包,但大多数重要的RFC都有。这些包提供了一系列函数,用于将数据编码成特定协议/格式,或将接收到的数据解码成Go对象(如结构体或映射)。

编码(Encode)指将Go对象转换为通用协议格式;解码(Decode)则是相反的过程,将特定格式的数据转换为Go对象。

以下是两个核心包:

  • net/http: 用于HTTP协议。例如,http.Get函数可以发起一个GET请求到指定域名,并返回网页内容。
    resp, err := http.Get("http://example.com")
    
  • net: 用于更基础的TCP/IP和套接字编程。TCP/IP协议族是互联网通信的基础。例如,net.Dial函数可以建立一个TCP连接。
    conn, err := net.Dial("tcp", "uci.edu:80")
    

这些包的存在极大地方便了程序员。如果没有它们,你就需要从零开始理解协议细节并实现所有功能,这将非常耗时。

聚焦JSON数据格式 📄

JSON(JavaScript Object Notation)是一种在全球广泛使用的数据格式,它由RFC 7159定义。虽然名字来源于JavaScript,但JSON的应用远不止于此,它是一种通用的、表示结构化数据的格式。

结构化数据指的是一组属性-值对。这非常自然地对应到Go语言中的结构体(字段和值)或映射(键和值)。JSON中的值和属性可以是基本类型(布尔值、数字、字符串)、数组或其他JSON对象,并且可以层次化地组合。

示例:Go结构体与JSON对象

让我们从一个Go结构体开始。假设我们有一个表示人物的结构体:

type Person struct {
    Name    string
    Address string
    Phone   string
}

p1 := Person{Name: "Joe", Address: "A St.", Phone: "123"}

如果我们想将p1的信息传输给另一台机器或另一个程序,可以将其转换为JSON格式。等效的JSON对象如下所示:

{
    "Name": "Joe",
    "Address": "A St.",
    "Phone": "123"
}

请注意,它与Go结构体非常相似,但属性名称需要用引号括起来。这个JSON对象可以被任何能解析JSON的系统读取,从而获取我们程序中关于此人的所有信息。你可以将大量这样的JSON对象传递给他人,以交换整个数据库的信息。

总结

本节课中我们一起学习了RFC的概念及其作为通信和数据交换标准的重要性。我们了解了几个常见的RFC标准,如HTML、URI和HTTP,并知道了Go语言通过net/httpnet等内置包为使用这些协议提供了强大支持。最后,我们重点介绍了JSON这种通用的结构化数据格式,它通过属性-值对的形式,能够很方便地与Go语言中的结构体和映射进行相互转换,是实现数据序列化和交换的利器。掌握这些知识,是构建能够与外界交互的复杂Go程序的基础。

031:JSON处理 🗂️

在本节课中,我们将要学习Go语言中如何处理JSON数据。JSON是一种轻量级的数据交换格式,在Go语言中,我们可以方便地将数据结构与JSON格式进行相互转换。

JSON格式的优势

JSON作为一种数据格式,具有几个显著的优势。

首先,JSON完全基于Unicode。这意味着任何JSON对象在转换后,都将以Unicode字符表示。这非常有益,因为Unicode是人类可理解的字符集。

事实上,这引出了它的另一个优点:JSON通常是人类可读的。人们可以查看JSON格式的数据,并大致理解其内容。虽然有时可能略显复杂,但总体上对人类是友好的。

此外,JSON是一种相当紧凑的表示形式。之所以说“相当紧凑”,是因为它并非完全极致压缩。如果我们追求极致的紧凑性,那么数据将不再具备可读性。例如,如果你压缩一个JSON对象,你会得到一个更小的文件,但你也无法直接阅读它。因此,JSON是在保持人类可读性的前提下,尽可能紧凑的一种格式。

JSON中的数据类型可以递归组合。这里所说的“类型”指的是你组合在一起的不同类型的数据。请记住,在JSON中,我们希望能够表示Go语言中的各种数据结构,即我们拥有的对象。我们希望将它们表示为JSON对象。

这些类型可以递归组合。因此,你可以拥有一个整数数组、一个结构体数组、一个内部包含结构体的结构体,或者一个内部包含数组、整数和字符串的结构体。你可以将它们全部按层次结构组合起来。例如,一个结构体内部包含其他结构体,而这些内部结构体又包含更多的结构体,依此类推。这样,你可以将任意复杂的Go语言对象转换为JSON。这是一个相当不错的优势。

JSON编组(Marshalling)

“编组”(Marshalling)这个术语,在JSON编组的上下文中,意味着从对象(在我们的例子中是Go对象)生成JSON表示。我们有一个任意复杂的Go对象,我们希望将其转换为符合JSON格式的内容,这个过程就称为JSON编组。

在这个示例中,我们将从我们的person结构体开始。以下是其基本结构:

type person struct {
    name    string
    address string
    phone   string
}

这是一个结构体类型,包含nameaddressphone字段,它们都是字符串类型。

假设我们创建了一个实际的person对象,即这个特定类型的结构体实例。这个person对象p1具有特定的值:name为“Joe”,address为“a street”,phone为“123”。我们之前已经见过类似的例子。

现在,我们想要将这个person结构体转换为一个JSON对象。我们调用函数json.Marshal

请注意,我们传递一个参数p1,即我们想要转换的Go语言结构体,将其作为参数传递给json.Marshal

它返回两个值。在这个例子中,他们称第一个返回值为b(一个数组),第二个为err(错误)。如果没有错误,err将是nil。如果转换正常进行,那么这里应该是nil,表示没有错误。

真正返回的b数组实际上是一个字节数组([]byte)。它包含了JSON表示。请记住,JSON完全是Unicode,这个字节数组基本上就是一堆符文(runes)的数组,它们构成了JSON表示。因此,json.Marshal所做的工作就是:接收一个Go对象,并返回其JSON表示。

JSON解组(Unmarshalling)

JSON解组是相反方向的操作。你试图获取一个表示JSON对象的字节数组,并将其转换为存储相同信息的Go语言对象。

接续上一张幻灯片的内容,我们再次讨论这个person结构体。假设我们已经生成了字节数组bArr,它包含了一个人的JSON表示。

现在,我们想要解组它。我们希望获取这个JSON表示,并创建一个包含相同信息的Go结构体。

我们这样做:首先,我们在顶部声明那个Go语言结构体。我们写var p2 person,称它为p2,类型是person。但此时我们还没有创建它,还没有填充nameaddressphone字段。所以我们只是有一个基本上是空的person变量。

然后,我们调用json.Unmarshal。请注意,json.Unmarshal需要两个参数。第一个参数是字节数组b,它包含了实际的JSON对象。第二个参数是我们希望将结果放入的Go结构体的地址,即&p2。因为请记住,这个b数组包含了关于一个人的信息。我们希望将这些信息放入我们创建的、目前还是空的p2中。

当你调用它时,它基本上会解包字节数组b,并将各个字段的属性值放入person结构体p2的相应字段中。

关于这一点有一个约束:p2,即Unmarshal的第二个参数,必须与JSON数据(JSON字节数组)相匹配。这里所说的“匹配”,是指JSON对象将有一组属性和这些属性的值。第二个参数p2必须具有相同的属性。如果它是一个结构体(就像本例中一样),它必须具有相同的字段名。因此,如果JSON对象有一个名为name的属性,那么Go对象p2最好有一个名为name的字段。这就是基本要求,它必须匹配。

但如果匹配,那么当你调用Unmarshal后,p2将成为一个包含JSON对象中所有信息的结构体。而json.Unmarshal返回的错误值err,如果一切正常、一切匹配,它将是nil。如果不匹配,它将返回一个错误。

总结

本节课中,我们一起学习了Go语言中JSON处理的核心概念。我们了解了JSON格式的优势,包括其Unicode特性、人类可读性和紧凑性。我们重点学习了两个关键操作:JSON编组(使用json.Marshal将Go对象转换为JSON字节数组)和JSON解组(使用json.Unmarshal将JSON字节数组转换回Go对象)。记住,在解组时,目标Go结构体的字段必须与JSON数据的键名相匹配,才能成功解析数据。

032:ioutil

概述

在本节课程中,我们将学习Go语言中如何使用ioutil包进行基本的文件读写操作。文件是程序间交换数据的常用媒介,理解如何访问文件是编程的基础。

文件访问的基本概念

在开始学习具体函数之前,我们需要了解文件访问的一个基本特性:线性访问。这个特性源于早期文件存储在物理磁带上的历史。磁带需要从头到尾线性地读取数据,无法直接跳转到任意位置。尽管现代存储设备(如硬盘、闪存)支持随机访问,但大多数编程语言(包括Go)在抽象层面仍将文件视为线性访问设备,因为这符合大多数使用场景的逻辑。

因此,文件操作通常包含以下几个基本步骤:

  • 打开:获取文件的访问句柄。
  • 读取/写入:从文件读取数据到内存,或将内存数据写入文件。
  • 关闭:完成操作后释放文件资源。
  • 定位:在文件中移动“读写头”到特定位置。

ioutil包简介

Go语言有多个包支持文件操作,ioutil包提供了一些基础且易于使用的函数。如果你的需求比较简单,ioutil是一个很好的起点。它封装了打开和关闭文件的细节,让读写操作变得非常直接。

读取整个文件:ReadFile

ioutil.ReadFile函数用于一次性读取整个文件的内容。它的使用非常简单。

以下是ReadFile函数的基本用法:

data, err := ioutil.ReadFile("input.txt")
if err != nil {
    // 处理错误,例如文件不存在或权限不足
    log.Fatal(err)
}
// 此时,变量`data`是一个字节切片([]byte),包含了文件"input.txt"的全部内容

这个函数接收一个字符串参数(文件名),并返回两个值:文件内容的字节数组和一个错误值。如果读取成功,错误值为nil注意ReadFile函数内部已经处理了文件的打开和关闭,你无需手动操作。

重要限制:由于ReadFile会将整个文件内容加载到内存中,因此它不适合处理非常大的文件。例如,如果你尝试读取一个大小超过可用内存的文件,可能会导致程序内存耗尽而崩溃。对于大文件,需要使用其他支持流式读取的方法。

写入整个文件:WriteFile

ReadFile对应,ioutil.WriteFile函数用于将数据一次性写入文件。如果文件不存在,它会创建该文件;如果文件已存在,则会覆盖原有内容。

以下是WriteFile函数的基本用法:

message := []byte("Hello, World!")
err := ioutil.WriteFile("output.txt", message, 0644)
if err != nil {
    // 处理错误,例如磁盘空间不足或权限问题
    log.Fatal(err)
}

这个函数接收三个参数:

  1. 文件名(字符串)。
  2. 要写入的数据([]byte类型)。
  3. 文件的权限模式(一个八进制数,例如0644表示所有者可读写,其他人只可读)。

ReadFile一样,WriteFile也封装了文件的打开、写入和关闭操作。但请注意,它不支持追加模式,每次调用都会从头开始写入(或覆盖)整个文件。

总结

本节课我们一起学习了Go语言ioutil包的基础文件操作。我们了解到文件访问本质上是线性的,并掌握了两个核心函数:

  • ioutil.ReadFile(filename):用于快速读取整个小文件的内容到内存中。
  • ioutil.WriteFile(filename, data, perm):用于创建新文件或覆盖已有文件,并写入完整的数据。

这两个函数因其简单性而非常适合处理小型配置文件、日志或文本数据。然而,它们的局限性在于无法处理大文件(ReadFile)或向文件追加内容(WriteFile)。在后续课程中,我们将学习更灵活的文件操作接口来应对这些复杂场景。

033:os包

概述

在本节中,我们将学习如何使用Go语言标准库中的os包进行文件操作。与之前介绍的ioutil包相比,os包提供了更精细的控制能力,允许我们按需读取或写入文件的特定部分,而不是一次性处理整个文件。

文件访问控制

上一节我们介绍了ioutil包提供的简单文件读写功能。本节中我们来看看os包,它提供了更多对文件访问的精确控制。使用ioutil时,你只能读取或写入整个文件;而使用os包,你可以进行更灵活的操作,例如读取一小部分、写入一小部分或执行其他不同的操作。

以下是os包中一些核心的文件操作函数:

  • os.Open:打开一个文件。你需要传入文件名,它会返回一个文件描述符(一个*os.File结构体),你可以用它来访问文件。
  • os.Close:当你完成文件操作后,使用此函数关闭文件。
  • os.Read:从文件中读取数据到一个字节数组([]byte)中。它会尝试填满你提供的字节数组,因此你可以通过控制数组的大小来决定读取多少数据。
  • os.Write:将一个字节数组([]byte)中的数据写入文件。它会写入与数组长度相等的数据量。

读取文件示例

让我们通过一个例子来理解如何使用os包读取文件。

首先,我们打开文件。调用os.Open并传入文件名(例如"data.txt")。这个函数会返回一个文件句柄f(用于访问文件)和一个error(如果文件不存在等错误发生)。

f, err := os.Open("data.txt")

假设我们只想从这个文件中读取10个字节。我们可以创建一个大小为10的字节数组。

bArray := make([]byte, 10)

然后,使用文件句柄f调用Read方法,并将字节数组bArray作为参数传入。这个方法会将文件的前10个字节填充到数组中。

n, err := f.Read(bArray)

Read方法返回两个值:一个错误err(例如读到文件末尾)和实际读取的字节数n。需要注意的是,n不一定等于数组大小。如果文件中只剩5个字节,那么n就等于5,这被称为“短读”。

每次调用Read,文件的读取位置(读头)都会向后移动。第一次调用读取前10字节,再次调用就会读取接下来的10字节,直到你关闭文件或重置读头。

完成读取后,记得关闭文件。

f.Close()

写入文件示例

接下来,我们看看如何使用os包写入文件。

首先,我们创建一个新文件。这里使用os.Create函数,它会创建(或覆盖)名为"output.txt"的文件,并返回对应的文件句柄。

f, err := os.Create("output.txt")

现在,我们有一个包含三个元素的字节切片([]byte{1, 2, 3}),我们想把它写入文件。

b := []byte{1, 2, 3}

使用文件句柄f调用Write方法,并传入字节切片b。这个方法会将这三个字节写入文件。

_, err = f.Write(b)

此外,还有一个便捷方法WriteString,它可以直接接受一个字符串(string)并将其写入文件。字符串必须是有效的Unicode序列。

_, err = f.WriteString("Hello")

总结

本节课中,我们一起学习了如何使用Go的os包进行基础的文件输入输出操作。我们了解了如何通过os.Openos.Create打开或创建文件,如何使用Read方法按需读取特定数量的字节,以及如何使用WriteWriteString方法向文件写入数据。与ioutil包相比,os包赋予了你对文件读写过程更精细的控制能力。记住,完成文件操作后,务必使用Close方法关闭文件以释放系统资源。

034:为什么使用函数 🧩

在本节中,我们将探讨函数的基本概念及其在Go语言中的重要性。我们将了解函数的定义、调用方式,以及使用函数的主要优势,包括代码复用和抽象化。


什么是函数?

函数是一组具有名称的指令集合。在Go语言中,函数是程序的基本构建块。每个Go程序都必须包含一个名为main的函数,因为程序的执行从这里开始。

函数的定义以关键字func开头,后跟函数名、参数列表(可选)、返回值(可选),以及用花括号{}包围的函数体。

例如,一个简单的main函数定义如下:

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

在这个例子中,main函数只包含一行代码,用于打印“Hello, World!”。但函数体内可以包含任意数量的指令,这些指令在函数被调用时按顺序执行。


函数的声明与调用

函数声明是定义函数的过程,而函数调用则是执行函数中的指令。除了main函数会在程序启动时自动调用外,其他函数都需要显式调用。

以下是一个包含函数声明和调用的示例:

func printHello() {
    fmt.Println("Hello, World!")
}

func main() {
    printHello() // 调用printHello函数
}

在这个例子中,printHello函数声明了一个打印“Hello, World!”的操作。在main函数中,我们通过printHello()来调用它,从而执行其中的指令。


为什么使用函数?

使用函数主要有两个重要原因:代码复用和抽象化。

代码复用

代码复用意味着避免重复编写相同的代码。通过将常用操作封装成函数,你只需编写一次代码,然后可以在需要时多次调用它。这不仅减少了代码量,还提高了代码的可维护性。

例如,在图形编辑程序中,你可能经常需要对图像进行阈值处理。你可以编写一个名为thresholdImage的函数,然后在程序中多次调用它,而不必每次都重写相同的处理逻辑。

同样,在数据库程序中,查询数据库是一个常见操作。你可以创建一个queryDatabase函数,以便在需要查询时直接调用。

抽象化

抽象化是隐藏复杂细节,只关注输入和输出的过程。函数是实现抽象化的有效工具。通过将复杂行为封装在函数中,并为函数起一个描述性的名称,你可以更容易地理解和使用代码,而不必关心内部实现细节。

例如,排序操作有多种算法实现,如冒泡排序、插入排序等。但作为使用者,你通常只关心结果是否已排序,而不关心具体的排序算法。因此,你可以调用一个sort函数,只需提供输入数据,函数会返回排序后的结果。

以下是一个抽象化的示例:

func findPupil() {
    grabImage()
    filterImage()
    findEllipses()
}

在这个例子中,findPupil函数通过调用其他具有描述性名称的函数(如grabImagefilterImagefindEllipses)来实现复杂操作。即使每个子函数内部逻辑复杂,通过良好的命名和分组,我们也能快速理解整个函数的功能。


总结

在本节中,我们一起学习了函数的基本概念及其在Go语言中的应用。我们了解了如何声明和调用函数,并探讨了使用函数的两大优势:代码复用和抽象化。通过将常用操作封装成函数,我们可以减少代码重复,提高代码的可读性和可维护性。同时,通过抽象化,我们可以隐藏复杂细节,使代码更易于理解和使用。

在接下来的章节中,我们将深入探讨函数的参数、返回值以及更高级的函数用法。

035:函数参数与返回值 🧩

在本节课中,我们将学习Go语言中函数参数与返回值的基本概念。函数通常需要输入数据来执行操作,并且可能产生输出结果。理解如何定义和使用参数与返回值是编写有效函数的关键。

函数参数 📥

上一节我们介绍了函数的基本结构,本节中我们来看看如何为函数添加输入数据,即参数。

参数是函数声明中列在函数名之后、括号内的一组变量。它们作为函数的输入,在函数内部作为局部变量使用。调用函数时传递给参数的具体数据称为实参。

以下是一个带有参数的函数示例:

func foo(x int, y int) {
    fmt.Println(x * y)
}

在这个例子中,foo函数有两个int类型的参数xy。当在main函数中调用foo(2, 3)时,2被传递给x3被传递给y,函数将执行fmt.Println(2 * 3),输出6

以下是关于函数参数的一些要点:

  • 无参数函数:如果函数不需要输入,声明时仍需保留括号,但括号内为空,例如 func bar() { ... }
  • 相同类型参数简写:当多个参数类型相同时,可以只在最后一个参数后声明一次类型。例如,func foo(x, y int) 等同于 func foo(x int, y int)

函数返回值 📤

除了接收输入,函数还可以产生一个或多个输出,即返回值。

返回值类型需要在函数声明的参数列表之后指定。函数内部使用return语句来返回具体的值。

以下是一个带有单个返回值的函数示例:

func foo1(x int) int {
    return x + 1
}

这个函数接收一个int类型参数x,并返回一个int类型的值x + 1。调用此函数时,其返回值可以赋值给一个变量:

y := foo1(1) // y 的值将是 2

如果函数有返回值但调用时未接收,该返回值将被丢弃。

多返回值 🔄

Go语言的一个独特特性是函数可以返回多个值。这在需要同时返回结果和错误状态时非常有用。

返回值类型在参数列表后的另一组括号内声明。return语句后需用逗号分隔所有要返回的值。

以下是一个返回两个值的函数示例:

func foo2(x int) (int, int) {
    return x, x + 1
}

调用此类函数时,可以使用多重赋值来接收所有返回值:

a, b := foo2(3) // a 的值将是 3, b 的值将是 4

总结 📝

本节课中我们一起学习了Go语言函数的核心组成部分:参数与返回值。我们了解了如何定义接收输入的参数,如何让函数通过return语句产生单个或多个输出,以及如何调用这些函数并处理其返回结果。掌握这些概念是构建模块化、可重用代码的基础。

036:值传递与引用传递

在本节课中,我们将要学习Go语言中函数参数传递的两种核心方式:值传递与引用传递。理解这两种方式的区别对于编写正确且高效的Go程序至关重要。

概述

函数调用时,参数如何从调用者传递给被调用函数,不同的编程语言有不同的规则。Go语言默认采用值传递的方式。这意味着传递给函数的参数值会被复制一份,函数内部操作的是这份副本,而非原始数据。同时,我们也可以通过传递指针来实现引用传递的效果,允许函数修改调用者作用域内的变量。

值传递

上一节我们介绍了函数调用的基本概念,本节中我们来看看Go语言默认的参数传递方式——值传递。

值传递描述了在函数调用过程中,实参是如何传递给形参的。具体来说,当调用一个函数时,传递给函数的实参值会被复制到对应的形参中。因此,函数内部操作的是原始数据的副本,而非原始数据本身。

这意味着被调用的函数无法影响调用函数中的原始变量。修改形参的值对调用函数中的变量没有任何作用。

让我们通过一个例子来理解。

func foo(y int) {
    y = y + 1
}

func main() {
    x := 2
    foo(x)
    fmt.Println(x) // 输出:2
}

在这个例子中,变量 x 的值为 2。当调用 foo(x) 时,x 的值 2 被复制给了形参 yfoo 函数内部的 y = y + 1 语句修改的是这个副本 y 的值,使其变为 3。然而,原始变量 x 的值仍然是 2,并未被改变。这就是值传递的核心特征。

以下是值传递的优缺点分析:

  • 优点:数据封装性。函数无法修改调用者的变量,这通常被视为一个优点。它限制了错误的传播范围。被调用函数内部的错误不会“污染”调用者的环境,使得错误更加局部化和易于追踪。
  • 缺点:复制开销。值传递需要将实参的值完整地复制到形参。对于像整数这样的小型数据,开销可以忽略不计。但如果参数是一个包含大量元素的大型切片或复杂结构体,复制整个数据就会带来显著的时间和内存开销。

引用传递(通过指针实现)

上一节我们了解到值传递无法让函数修改外部变量,本节中我们来看看如何通过传递指针来实现类似“引用传递”的效果。

Go语言本身没有内置的引用传递语法,但我们可以通过手动传递指针来达到相同的目的。引用在这里指的就是指针。

其核心思想是:不直接传递想要操作的数据,而是传递指向该数据内存地址的指针。这样,函数虽然获得的是指针的副本(一个地址值),但通过这个地址,它能找到并修改原始数据。

让我们修改之前的例子,使其能够修改 main 函数中的变量 x

func foo(y *int) {
    *y = *y + 1
}

func main() {
    x := 2
    foo(&x) // 传递x的地址
    fmt.Println(x) // 输出:3
}

在这个版本中:

  1. foo 函数的形参 y 类型为 *int,即指向整数的指针。
  2. main 函数中,我们使用 &x 获取变量 x 的内存地址,并将其传递给 foo
  3. foo 函数内部,*y 表示“获取指针 y 所指向位置的值”。语句 *y = *y + 1 的作用是:找到 y 指向的内存地址(即 x 的地址),将该地址存储的值加1,然后存回原地址。
  4. 因此,main 函数中的原始变量 x 的值被成功修改为 3

通过指针实现的“引用传递”优缺点与值传递正好相反:

  • 优点:避免大数据的复制开销。我们只需要复制指针(一个内存地址),而不需要复制指针所指向的整个大型数据结构。这在处理大型数据时能显著提升性能。
  • 缺点:破坏了数据封装性。被调用函数现在有能力直接修改调用者的变量。如果函数内部存在错误,它可能会意外地破坏调用者的数据状态。因此,只有在明确需要函数修改外部变量时,才应该使用指针传递。

总结

本节课中我们一起学习了Go语言中函数参数传递的两种机制。

  • 值传递是默认方式,它安全地将数据封装在函数内部,但可能带来复制开销。
  • 通过传递指针,我们可以实现类似引用传递的效果,允许函数修改外部变量,提高了处理大型数据的效率,但牺牲了部分数据安全性。

在实际编程中,你需要根据具体需求(是否需要修改原始数据、数据大小、对安全性的要求等)来选择合适的参数传递方式。对于小型且不需要修改的数据,使用值传递;对于大型数据或需要函数修改的数据,则考虑使用指针。

037:传递数组与切片 🚀

在本节课中,我们将学习如何在Go语言的函数中传递数组和切片。理解这两者的区别对于编写高效、清晰的代码至关重要。

概述 📋

当我们需要在函数间传递一组数据时,通常会考虑使用数组或切片。然而,由于Go语言“按值传递”的特性,直接传递大型数组会带来性能问题。本节将探讨这个问题,并介绍如何使用切片作为更优的解决方案。

传递数组的问题 ⚠️

在Go语言中,函数的所有参数都是通过“值传递”的方式复制的。这意味着,当你将一个数组作为参数传递给函数时,整个数组都会被复制一份给函数的形参。

如果数组非常大,这将导致两个问题:

  1. 复制过程会消耗大量时间。
  2. 复制操作会占用额外的内存空间。

以下是一个示例代码,展示了传递数组的情况:

func foo(x [3]int) int {
    return x[0]
}

func main() {
    a := [3]int{1, 2, 3}
    fmt.Println(foo(a)) // 输出:1
}

在上面的例子中,函数foo接收一个包含3个整数的数组。当main函数调用foo(a)时,整个数组a会被复制到形参x中。虽然这里的数组很小,但如果数组包含30万个元素,这种复制就会成为性能瓶颈。

使用数组指针的解决方案 🔗

为了解决数组复制带来的开销,一种方法是使用“按引用传递”,即传递指向数组的指针。这样,函数接收的是数组的地址,而不是数组本身的副本。

以下是使用指针的示例:

func foo(x *[3]int) {
    (*x)[0]++ // 通过指针修改原数组的第一个元素
}

func main() {
    a := [3]int{1, 2, 3}
    foo(&a) // 传递数组a的地址
    fmt.Println(a) // 输出:[2 2 3]
}

在这个例子中,foo函数接收一个指向[3]int类型数组的指针。在函数内部,通过解引用指针(*x)来访问并修改原数组。调用时,使用&a将数组a的地址传入。最终,数组a的第一个元素从1被修改为2。

然而,这种方法需要显式地使用取地址符&和解引用操作*,代码显得不够简洁,也并非Go语言推荐的惯用做法。

使用切片:更优雅的方案 ✨

在Go语言中,处理这类问题的标准且优雅的方式是使用切片。事实上,在大多数情况下,你都应该优先考虑使用切片而非数组。

切片可以被看作是一个数组的“视图”或“窗口”。当你创建一个切片时,Go会在背后自动创建一个支撑数组。切片本身是一个包含三个部分的数据结构:

  1. 指针:指向底层数组中切片起始位置的元素。
  2. 长度:切片当前包含的元素数量。
  3. 容量:从切片起始位置到底层数组末尾的元素数量。

当传递一个切片给函数时,虽然Go仍然是“按值传递”(即复制切片这个数据结构),但复制的数据中包含了指向底层数组的指针。因此,函数内部可以通过这个指针直接访问和修改原始数组的数据,而无需复制整个数组。

以下是使用切片的示例:

func foo(sl []int) {
    sl[0]++ // 直接修改切片指向的底层数组元素
}

func main() {
    a := []int{1, 2, 3} // 声明一个切片,注意方括号内没有数字
    foo(a) // 直接传递切片
    fmt.Println(a) // 输出:[2 2 3]
}

请注意声明切片与数组的区别:

  • 数组声明:a := [3]int{1, 2, 3} (方括号内有长度)
  • 切片声明:a := []int{1, 2, 3} (方括号内为空)

函数foo的参数类型是[]int,表示一个整数切片。当main函数调用foo(a)时,切片a被复制给形参sl,但两者共享同一个底层数组。因此,在foo中对sl[0]的修改,直接反映在了main函数的a上。

总结 🎯

本节课我们一起学习了在Go函数中传递数组和切片的区别与最佳实践:

  1. 传递数组:会导致整个数组被复制,对于大型数组存在性能和内存开销。
  2. 传递数组指针:可以避免复制,但代码需要显式的指针操作,不够简洁。
  3. 传递切片:是Go语言中的推荐做法。切片数据结构包含指向底层数组的指针,传递切片时仅复制指针、长度和容量,效率高且代码清晰。

因此,在Go编程中,应养成优先使用切片的习惯,特别是在需要将数据集合传递给函数时。这不仅能提升程序性能,也能使代码更符合Go语言的风格。

038:编写良好的函数 🛠️

在本节课中,我们将要学习如何编写结构良好、易于理解的函数。这不仅是Go语言的最佳实践,也是构建任何高质量、可维护代码的基础。我们将重点探讨代码的“可理解性”及其对调试和维护的重要性。

理解代码的可理解性 🧠

上一节我们介绍了函数的基本概念,本节中我们来看看如何让函数更易于理解。所谓“可理解性”,指的是能够快速定位代码中的特定功能或数据。

任何一段代码都可以看作是一系列函数和数据的集合。函数是执行的操作,数据是操作的对象。一个易于理解的程序意味着:

  • 快速定位功能:例如,在一个图像处理程序中,你能迅速找到“模糊图像”的函数。
  • 快速定位数据:当数据出现错误时,你能快速找到该数据在何处被定义、使用或修改。

这对于调试、代码审查、团队协作以及你本人未来回顾代码都至关重要。组织良好的代码结构是实现高可理解性的关键。

基本的调试原则 🔍

理解了代码组织的重要性后,我们来看看这与调试有何关联。掌握基本的调试原则能帮助我们写出更健壮的代码。

当你运行代码时,如果它在某个函数内部崩溃或产生错误,从高层次看,问题根源通常只有两种可能:

  1. 函数本身编写有误:函数没有执行正确的操作。例如,一个排序函数可能排错了顺序。
  2. 函数使用的数据有误:函数逻辑正确,但输入的数据本身是错误的。例如,排序函数本身没问题,但待排序的切片里包含了错误的数据。

公式化地表示,问题根源可以归结为:
Bug = 函数逻辑错误 || 输入数据错误

函数的输入数据可能来自参数,也可能来自文件、用户输入等其他来源。因此,为了有效支持调试,我们需要做到两点:函数本身要易于理解,数据流向要易于追踪。

支持调试的代码编写原则 🧩

基于上述调试原则,我们可以推导出编写函数时应遵循的两条核心原则,以确保代码易于调试。

以下是编写函数时需要关注的两个核心方面:

  • 函数必须易于理解:当你检查一个函数时,其代码应该清晰明了,让你能够轻松判断它的实际行为是否符合预期。这要求函数逻辑直接、命名清晰、职责单一。
  • 数据必须易于追踪:你需要能够清晰地知道函数所使用的每一条数据从何而来。理想情况下,所有输入都应通过参数明确传递。这样,当数据出错时,你可以直接追溯到调用者。

这里需要特别警惕全局变量的使用。虽然并非完全禁止,但全局变量会显著增加数据追踪的难度。因为任何函数都可能修改全局变量,一旦数据出错,很难定位是哪个函数导致了问题。这会使得调试过程变得复杂。


本节课中我们一起学习了编写良好函数的核心思想:追求代码的可理解性。我们了解到,这不仅能帮助他人理解你的代码,更是你本人进行高效调试和维护的基石。记住两个关键点:确保每个函数本身意图明确、逻辑清晰;同时,尽量让数据的来源和去向清晰可循,谨慎使用全局变量。遵循这些原则,你将能构建出更健壮、更易于协作的代码。

039:函数编写指南 📝

在本节中,我们将学习如何编写高质量的函数。好的函数不仅功能正确,还应易于理解、调试和维护。我们将探讨函数命名、功能内聚性以及参数设计等核心原则,帮助你写出更清晰、更专业的代码。


函数命名的重要性

上一节我们介绍了函数的基本概念,本节中我们来看看如何为函数选择一个好名字。函数和参数的命名至关重要,一个好的名字应该能让人一眼就理解其行为。

以下是命名时应遵循的原则:

  • 描述行为:函数名应清晰描述其功能。例如,computeRMSprocessArray 更能说明函数的作用。
  • 参数名也要清晰:参数名应能表明其含义。例如,samplesa 更能说明传入的是信号样本数据。
  • 简洁且领域相关:名称应简洁,但可以使用特定领域的缩写(如 RMS 代表“均方根”),前提是该领域的协作者都能理解。
  • 避免过长:名称不应过长或包含过多单词,以免显得累赘。

良好的命名不仅有助于他人理解你的代码,也能让你在一个月后回顾时,更快地回忆起代码的逻辑。


确保功能内聚性

理解了命名之后,我们来看看函数设计的另一个核心原则:功能内聚性。一个函数应该只完成一个明确的“操作”。

这里的“操作”并非指一条指令,而是指在特定应用上下文中一个逻辑上完整、独立的动作。

以下是功能内聚性的例子:

  • pointDistance:计算两点距离。
  • drawCircle:绘制一个圆。
  • triangleArea:计算三角形面积。

每个函数都只做一件在几何应用领域内有明确意义的事情。如果将 drawCircletriangleArea 合并到一个函数中,会导致函数职责不清,难以命名和理解。保持函数功能单一,能使代码逻辑对阅读者(包括未来的你)而言更加清晰和直观。


限制参数数量

为了进一步简化函数,我们需要关注参数的个数。参数越多,函数通常越复杂,也越难调试,因为你需要追踪更多的数据流。

参数过多的一个常见原因是函数违反了功能内聚性原则。例如,一个既画圆又计算三角形面积的函数,必然需要传入两类操作的所有参数,导致参数列表膨胀。解决方法是将其拆分为两个独立的函数。

如果函数本身功能内聚,但参数仍然很多,可以考虑以下方法:

将相关的参数分组到结构体中

假设有一个计算三角形面积的函数。最直接但较差的方式是传入9个浮点数(三个顶点的x, y, z坐标)。

// 较差的方式:参数过多,难以理解
func triangleArea(x1, y1, z1, x2, y2, z2, x3, y3, z3 float64) float64 {
    // ... 计算逻辑
}

更好的方式是先定义一个 Point 结构体,然后传入三个 Point 实例。

// 更好的方式:通过结构体组织相关数据
type Point struct {
    x, y, z float64
}

func triangleArea(p1, p2, p3 Point) float64 {
    // ... 计算逻辑
}

更进一步,可以定义一个 Triangle 结构体,这样函数只需要一个参数。

// 更优的方式:语义更清晰
type Triangle struct {
    p1, p2, p3 Point
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uci-go-prog/img/3663f50f5833728bef759957d8023acb_3.png)

func (t Triangle) area() float64 {
    // ... 计算逻辑
}

核心思想:将逻辑上相关的数据封装成结构体,可以有效减少函数签名中的参数数量,提升代码可读性。但请注意,不要将毫不相关的数据强行组合在一起。


总结

本节课中我们一起学习了编写高质量函数的三个关键指南:

  1. 精心命名:为函数和参数选择描述性强、简洁且符合领域习惯的名称。
  2. 保持功能内聚:确保每个函数只完成一个逻辑上独立的操作。
  3. 限制参数数量:通过拆分函数或将相关参数组织成结构体来简化函数接口。

遵循这些原则,你将能编写出更易于理解、调试和维护的Go代码,无论是在个人项目还是团队协作中。

040:函数编写指南 🧭

在本节中,我们将学习如何编写高质量的函数。我们将重点讨论如何通过控制函数的长度和复杂度,使代码更易于理解和维护。


概述

编写函数时,我们的目标是使其简单易懂,避免过于复杂。函数的复杂度可以从多个维度衡量,本节我们将探讨两个关键方面:函数长度控制流复杂度。通过合理的函数设计,我们可以将复杂的逻辑分解为多个简单的部分,从而提升代码的可读性和可调试性。


函数长度与复杂度

上一节我们介绍了函数的基本概念,本节中我们来看看如何控制函数的复杂度。一个最直观的衡量标准是函数的长度。虽然短函数不一定简单,但过长的函数通常意味着更高的复杂度。

以下是一些关于函数长度的考量:

  • 函数应力求简短。这是一个粗略但有效的复杂度近似指标。
  • 短函数能强制实现一定程度的简洁性。例如,有教授要求学生编写的每个函数不得超过10行代码。虽然这可能过于严格,但其核心理念是促使每个函数保持简单。
  • 技术上,你可以将所有代码写在一行,但这并非我们讨论的“函数长度”。我们指的是具有常规换行分隔、逻辑清晰的代码行数。

那么,如何用一系列简单的函数来构建复杂的程序呢?关键在于利用函数调用层次结构。一个主函数可以调用其他函数,而这些被调用的函数又可以继续调用更多函数,形成一个层次结构。

通过这种层次分解,我们可以将复杂任务拆解,确保每个独立函数的复杂度可控。


分解策略示例

假设我们有一段100行代码的复杂逻辑。

以下是两种组织方式的对比:

  • 选项1:将所有逻辑写在一个庞大的函数中。这个函数有100行,理解和调试起来非常困难。
  • 选项2:将逻辑分解为三个函数。第一个主函数很短,它负责调用另外两个各约50行的函数。这样,每个独立函数的代码行数都减少了,整体复杂度在感知上得以降低,理论上更易于调试。

当然,分解不能是机械的切割。我们必须根据代码执行的合理操作进行分组,确保每个函数都有明确的职责。例如,你可以将100行代码按功能拆分为30行和70行的两个函数,然后再进一步分解那70行的函数。这种有意识的层次化分解是控制复杂度的关键。


控制流复杂度

除了长度,衡量代码复杂度的另一个重要维度是控制流复杂度。控制流指的是程序从开始到结束所有可能的执行路径。

控制流复杂度取决于代码中的条件分支(如 if 语句)和循环结构:

  • 一段只有连续赋值语句、没有分支的代码,只有一条执行路径。
  • 加入一个 if 语句,就会产生两条可能的路径。
  • 嵌套的条件语句和循环会指数级增加可能的路径数量。

一个函数可能包含许多不同的控制流路径,具体执行哪条路径取决于输入参数。路径越多,逻辑越复杂,测试和调试也越困难。


降低控制流复杂度

我们可以通过函数划分来降低单个函数的控制流复杂度。让我们看一个例子。

考虑以下函数 foo,它包含两个条件判断,其中一个是嵌套的:

func foo(a int, b int) {
    // ... 一些代码 ...
    if a == 1 {
        // ... 一些代码 ...
        if b == 1 {
            // ... 执行操作X ...
        }
        // ... 一些代码 ...
    }
    // ... 更多代码 ...
}

这个函数共有三条控制流路径:

  1. a != 1
  2. a == 1b != 1
  3. a == 1b == 1

现在,我们通过引入一个新函数来分离条件判断:

func foo(a int, b int) {
    // ... 一些代码 ...
    if a == 1 {
        checkB(b)
    }
    // ... 更多代码 ...
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uci-go-prog/img/f6bc54ee5a8dd0bfc6bdb563d32234d4_3.png)

func checkB(b int) {
    // ... 一些代码 ...
    if b == 1 {
        // ... 执行操作X ...
    }
    // ... 一些代码 ...
}

经过重构:

  • foo 函数现在只包含一个条件判断,因此只有两条路径(a == 1a != 1)。
  • checkB 函数也只有一个条件判断,同样只有两条路径(b == 1b != 1)。

通过将嵌套的条件逻辑拆分到不同函数中,我们减少了每个函数内部的最大控制流路径数量。虽然整体逻辑不变,但每个单元的复杂度降低了,这使得代码通常更易于理解和调试。


总结

本节课中我们一起学习了编写清晰函数的两项核心指南:

  1. 控制函数长度:力求函数简短,并通过建立清晰的函数调用层次结构来分解复杂逻辑,使每个函数职责单一。
  2. 管理控制流复杂度:关注函数内执行路径的数量。利用函数划分将复杂的条件逻辑拆分,可以有效减少单个函数中的控制流路径,从而降低复杂度,提升代码可维护性。

记住,目标是让每个函数都简单明了,通过组合这些简单的函数来构建强大的程序。

041:一等值 🎯

在本节课中,我们将要学习Go语言中一个重要的概念:一等值。具体来说,我们将探讨如何像处理整数、字符串等普通数据类型一样,来处理函数。这包括将函数赋值给变量、动态创建函数、将函数作为参数传递或作为返回值,以及将函数存储在数据结构中。掌握这些概念,将帮助你编写更灵活、更强大的Go程序。


变量作为函数 🧩

上一节我们介绍了函数作为一等值的概念。本节中,我们来看看如何将函数赋值给变量。

在Go语言中,你可以声明一个变量,其类型为函数签名。这意味着这个变量可以指向一个具体的函数,并像调用普通函数一样使用它。

以下是实现步骤:

  1. 声明一个变量,并指定其类型为函数签名。函数签名定义了参数类型和返回值类型。
  2. 将一个已定义的函数赋值给这个变量。注意,赋值时使用括号 ()
  3. 之后,你就可以通过这个变量名来调用函数了。

代码示例:

// 1. 声明一个函数类型的变量
var funcVar func(int) int

// 2. 定义一个实际的函数
func incFn(x int) int {
    return x + 1
}

func main() {
    // 3. 将函数赋值给变量(注意没有括号)
    funcVar = incFn

    // 4. 通过变量调用函数
    fmt.Println(funcVar(1)) // 输出:2
}

在这个例子中,funcVar 变量成为了 incFn 函数的一个别名,调用 funcVar(1) 与调用 incFn(1) 效果完全相同。


函数作为参数 📤

我们已经学会了如何将函数赋值给变量。接下来,我们将探讨如何将函数作为参数传递给另一个函数。

这允许你编写更通用的高阶函数。例如,你可以创建一个“应用”函数,它接受一个操作函数和一个值,然后将这个操作应用到该值上。

代码示例:

// 定义一个高阶函数,它接受一个函数 `f` 和一个整数值 `val`
func applyIt(f func(int) int, val int) int {
    return f(val) // 调用传入的函数 `f`,并传入参数 `val`
}

// 定义两个具体的操作函数
func incFn(x int) int { return x + 1 }
func decFn(x int) int { return x - 1 }

func main() {
    // 将 `incFn` 函数作为参数传递给 `applyIt`
    fmt.Println(applyIt(incFn, 2)) // 输出:3
    // 将 `decFn` 函数作为参数传递给 `applyIt`
    fmt.Println(applyIt(decFn, 2)) // 输出:1
}

applyIt 函数本身并不关心你传入的是 incFn 还是 decFn,它只是忠实地执行你给它的任何函数。这使得代码的复用性大大提高。


匿名函数 🎭

上一节我们传递了有名字的函数作为参数。然而,有时为了一次性使用的函数专门起个名字并不方便。本节中,我们来看看匿名函数

匿名函数就是没有名字的函数定义。你可以在需要函数的地方直接定义它,特别是在作为参数传递时,这能让代码更简洁。

以下是使用匿名函数重写上一节例子的方式:

代码示例:

func applyIt(f func(int) int, val int) int {
    return f(val)
}

func main() {
    // 直接定义一个匿名函数作为参数传入
    fmt.Println(applyIt(
        func(x int) int { return x + 1 }, // 匿名递增函数
        2,
    )) // 输出:3

    fmt.Println(applyIt(
        func(x int) int { return x - 1 }, // 匿名递减函数
        2,
    )) // 输出:1
}

如你所见,我们直接在 applyIt 的调用中定义了函数逻辑 func(x int) int { return x + 1 },而无需事先通过 func incFn... 来声明。这种方式在函数逻辑简单且只使用一次时非常便捷。


总结 📝

本节课中我们一起学习了Go语言中将函数视为一等值的核心特性。我们掌握了三个关键技能:

  1. 将函数赋值给变量,让变量成为函数的引用。
  2. 将函数作为参数传递,从而编写出能接受不同操作的高阶函数,提升代码的抽象能力和复用性。
  3. 使用匿名函数,在需要的地方直接定义函数逻辑,使代码更加紧凑。

这些特性源自函数式编程思想,虽然Go不是纯函数式语言,但合理利用这些一等函数特性,能让你写出更清晰、更灵活的代码。在接下来的学习中,你会看到这些概念如何被应用于更复杂的场景,例如闭包和回调函数。

042:函数类型

概述

在本节课中,我们将要学习Go语言中一个高级但强大的概念:函数可以作为其他函数的返回值。我们将探讨为何需要这样做,并通过一个具体的例子来理解其工作原理。此外,我们还将介绍与函数密切相关的两个重要概念:环境闭包


章节 2.1.2:返回函数 🧩

函数也可以将其他函数作为其返回值。那么,为何要这样做呢?

一个主要原因是,当你希望创建一个具有特定目的、且可参数化的新函数时,这非常有用。你希望根据某些输入数据来改变函数的行为,从而创建一个功能不同的新函数。通过这种方式,你可以让一个函数生成另一个具有不同参数集的新函数。

为了更清晰地说明,让我们来看一个具体的例子。

示例:创建可自定义原点的距离计算函数

假设我们想要一个计算点到原点距离的函数。它接收一个点的X、Y坐标,并返回到原点的距离。本质上,它执行的是勾股定理。

但是,如果我们希望原点可以移动呢?例如,在物理学中,原点可能会根据问题情境(如一个移动的汽车)而改变。我们希望距离计算函数能适应不同的原点位置。

我们可以将原点的位置视为这个函数的参数,并为每个不同的原点创建一个新的距离计算函数。

以下是实现方法:

func makeDistOrigin(ox, oy float64) func(float64, float64) float64 {
    fn := func(x, y float64) float64 {
        return math.Sqrt(math.Pow(x-ox, 2) + math.Pow(y-oy, 2))
    }
    return fn
}

让我们详细解析这段代码:

  • makeDistOrigin 函数接收两个 float64 参数 oxoy,它们代表原点的坐标。
  • 它的返回值类型是 func(float64, float64) float64,即一个接收两个 float64 并返回一个 float64 的函数。
  • 在函数体内,我们定义了一个名为 fn 的新函数。这个 fn 函数接收点的坐标 (x, y),并使用勾股定理计算该点到固定原点 (ox, oy) 的距离。
  • 最后,makeDistOrigin 函数返回这个新创建的 fn 函数。

关键点makeDistOrigin 函数本身并不计算距离,它的职责是生成一个专门用于计算到特定原点距离的新函数。

现在,让我们看看如何在主函数中使用它:

func main() {
    // 创建计算到原点 (0,0) 距离的函数
    Dist1 := makeDistOrigin(0, 0)
    // 创建计算到原点 (2,2) 距离的函数
    Dist2 := makeDistOrigin(2, 2)

    // 计算点 (2,2) 到原点 (0,0) 的距离
    fmt.Println(Dist1(2, 2))
    // 计算点 (2,2) 到原点 (2,2) 的距离
    fmt.Println(Dist2(2, 2))
}

运行结果将首先打印出点 (2,2) 到原点 (0,0) 的距离(约2.828),然后打印出到原点 (2,2) 的距离(为0)。

通过这种方式,我们利用 makeDistOrigin 函数创建了两个具有特殊用途的函数,它们的“内置”原点参数各不相同。


上一节我们介绍了如何返回一个函数,并看到了一个实际应用的例子。本节中,我们来深入理解支撑这一机制的两个核心概念:环境闭包

函数的环境

每个函数都有一个环境。环境是指在函数内部有效的所有名称(变量、常量等)的集合。这包括:

  1. 在函数内部局部定义的所有名称。
  2. 根据词法作用域规则,函数可以访问其定义所在代码块中的变量。

Go语言采用词法作用域。请看以下示例代码块:

x := 10 // 外部变量

func fo(y int) int {
    z := 5 // 局部变量
    return x + y + z // fo可以访问x, y, z
}

对于函数 fo 来说,其环境(即可访问的变量)包括:

  • 局部变量 z
  • 参数 y
  • 定义在同一外层代码块中的变量 x

闭包

当我们将函数作为值(例如,作为参数传递或作为返回值)进行处理时,环境会与函数绑定在一起。这个“函数加上其环境”的组合体,就称为闭包

在Go语言的实现中,闭包可以被理解为一个结构体,它包含一个指向函数代码的指针和一个指向其环境的指针。

这意味着,当你传递一个函数时,你同时传递了它定义时所处的环境。当这个函数在别处被调用执行时,它仍然能够访问和操作其原始环境中的变量。

闭包在示例中的应用

让我们回到 makeDistOrigin 的例子:

func makeDistOrigin(ox, oy float64) func(float64, float64) float64 {
    fn := func(x, y float64) float64 {
        // 这里可以访问 ox, oy, x, y
        return math.Sqrt(math.Pow(x-ox, 2) + math.Pow(y-oy, 2))
    }
    return fn // 返回闭包(函数fn + 环境{ox, oy})
}

内部函数 fn 形成了一个闭包。它的环境包含了外部函数 makeDistOrigin 的参数 oxoy

当我们调用 makeDistOrigin(0, 0) 时,它创建并返回了一个闭包。这个闭包中的函数 fn “记住”了此时 ox=0, oy=0 的环境。因此,之后无论在哪里调用 Dist1(2, 2),它计算的都是到原点 (0,0) 的距离。

同理,makeDistOrigin(2, 2) 创建了另一个独立的闭包,其函数 fn 记住了 ox=2, oy=2 的环境。

这就是闭包的力量:它允许函数“携带”其诞生时的数据,并在未来的任何调用中持续使用这些数据。


总结

本节课中我们一起学习了Go语言中函数作为返回值的用法。我们通过构建一个可自定义原点的距离计算生成器,理解了创建具有特定行为的函数的价值。更重要的是,我们深入探讨了环境闭包这两个核心概念。闭包是“函数及其环境的结合体”,它确保了当函数被传递到其他作用域执行时,依然能访问其定义时的变量。掌握闭包对于理解Go中高阶函数的行为至关重要。

043:可变参数与延迟执行

在本节课中,我们将学习Go语言函数的两个高级特性:可变参数和延迟执行。我们将了解如何向函数传递任意数量的参数,以及如何让函数调用延迟到稍后执行。

可变参数函数

上一节我们介绍了函数的基本概念,本节中我们来看看如何定义一个能接受可变数量参数的函数。

通常,定义函数时需要明确指定参数的数量和类型。但有时,我们希望函数能处理任意数量的参数,例如计算一组整数中的最大值,无论这组整数有多少个。

为此,Go语言提供了可变参数功能。在参数类型前使用省略号 ...,即可声明一个可变参数。在函数内部,这个参数被视为一个切片。

以下是定义和使用可变参数函数的示例:

func getMax(vals ...int) int {
    maxV := -1
    for _, v := range vals {
        if v > maxV {
            maxV = v
        }
    }
    return maxV
}

在这个例子中,vals ...int 表示函数 getMax 可以接受任意数量的 int 类型参数。函数内部通过 for 循环遍历 vals 切片,找出最大值。

调用可变参数函数时,可以直接传递一个由逗号分隔的参数列表:

fmt.Println(getMax(1, 3, 6, 4))

此外,也可以将一个切片作为参数传递给可变参数函数。此时,需要在切片变量后加上省略号 ...

vSlice := []int{1, 3, 6, 4}
fmt.Println(getMax(vSlice...))

延迟执行

接下来,我们探讨函数的另一个特性:延迟执行。

延迟执行意味着函数调用不会立即发生,而是被推迟到包含它的函数执行完毕时才执行。这通常用于资源清理工作,例如在操作完成后关闭文件。

使用 defer 关键字即可实现延迟执行。defer 语句会将其后的函数调用推入一个栈中,待周围函数返回前,再按照后进先出的顺序执行。

以下是延迟执行的基本示例:

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

这段代码的输出顺序是:

Hello
Bye

尽管 defer 语句在前,但 fmt.Println("Bye") 的调用被延迟到 main 函数结束前才执行。

关于延迟执行,有一个重要的细节需要注意:传递给延迟函数的参数会立即被求值,而非延迟求值

请看以下示例:

func main() {
    i := 1
    defer fmt.Println(i + 1)
    i++
    fmt.Println("Hello")
}

这段代码的输出是:

Hello
2

尽管在 defer fmt.Println(i + 1) 实际执行时,变量 i 的值已经变为 2,但打印的结果仍然是 2。这是因为 i + 1 这个表达式在遇到 defer 语句时就被立即计算了,当时 i 的值为 1,所以计算结果 2 被保存下来,并在延迟调用时使用。

总结

本节课中我们一起学习了Go语言函数的两个重要特性。

  • 可变参数:通过在参数类型前添加 ...,可以定义接受任意数量参数的函数。参数在函数内部以切片形式访问。调用时可以直接传递参数列表,也可以使用 slice... 的语法传递切片。
  • 延迟执行:使用 defer 关键字可以延迟函数调用,直到包含它的函数返回。延迟调用常用于清理任务。需要记住,延迟函数的参数会立即求值,而非延迟求值。

掌握可变参数和延迟执行,将帮助你编写更灵活、更健壮的Go语言代码。

044:类与封装 🧱

在本节中,我们将学习面向对象编程中的两个核心概念:封装。我们将探讨它们在传统编程语言中的定义,并了解Go语言如何以独特的方式实现这些概念。


什么是类?

类属于面向对象编程范式的一部分。本课程面向中级学习者,因此假设你已经具备一定的编程基础。你很可能已经理解什么是面向对象编程,但这里我们将重新定义,以确保我们有一致的理解。

一个常见的问题是:Go语言是否支持面向对象编程?答案是肯定的。Go语言虽然没有传统意义上的“类”,但它提供了等效的功能,支持大多数相同的特性。

那么,什么是传统意义上的类呢?在众多其他编程语言中,类本质上是一个数据字段函数的集合,这些数据与函数共同承担一个明确定义的职责。数据字段和函数(在类中通常称为方法)被组合在一起。因此,类就是数据方法的结合体。

例如,假设我们想要一个表示二维空间中某个点的Point类。那么,数据可能包括点的x坐标y坐标。函数则可能包括多种操作,例如计算到原点的距离、返回点所在的象限、设置或获取x/y坐标值等。关键在于,这些数据和方法都与同一个概念——二维点——相关。

需要记住的是,类实际上是一个模板。类定义了数据字段,但并不包含具体的数据。这意味着,当你创建Point类时,你是在提供一个如何创建点的模板:它规定了点应包含哪些数据,以及应对这些数据执行哪些操作,但并未提供实际的数据值。


对象:类的实例

一个对象是类的一个实例化实例。它包含实际的数据。

继续上面的例子,假设在我的几何程序中,我有一个三角形,它包含三个点坐标:(0,0)、(5,5)和(6,0)。这里,我想创建三个点对象。这些对象基于Point类模板创建,因此它们拥有模板所规定的x坐标和y坐标字段,并且这些字段被赋予了具体的数值。所以,点(0,0)的x和y值就是0和0,点(5,5)的值就是5和5,依此类推。

对于一个类,你可以创建许多个该类的对象,每个对象都实例化了该类,并用具体数据填充了数据字段。


什么是封装?

封装是另一个通常与面向对象编程相关联的概念。更广义地说,它与抽象的使用有关。在面向对象编程的语境下,封装背后的思想是:你可能希望保护数据,或者对使用你类的程序员隐藏数据

这里“程序员”指的是使用你类的人,而不是定义类的人(你无法对定义者隐藏任何东西)。你可能希望数据只能通过类中提供的方法来访问,而不是允许程序员直接进入并修改数据。

例如,对于一个点,我们不希望程序员直接修改其x和y值,而是强制他们通过类中提供的方法来修改。为什么要这样做?主要原因是我们可能希望确保数据的内部一致性。程序员可能因为需要考虑很多事情而犯错,我们希望减轻他们维护数据一致性的负担。通过封装,我们(类的创建者)可以保证:只要程序员使用我们提供的方法来修改内部数据,数据就能保持一致性。这样,程序员就不必担心这一点,只需使用我们的方法即可。

封装意味着内部数据不能直接从外部访问(或至少部分不能),你建立了一堵墙——一个由访问数据的方法构成的抽象屏障


封装示例

假设我们有一个点,我们想对它执行一个操作:使其到原点的距离加倍。也就是说,我们需要将其x和y坐标都乘以2。

以下是两种实现方式:

  • 选项一(使用封装方法):创建一个名为doubleDistance的方法,该方法精确地执行将x和y都加倍的操作。这是一种更安全的方式。
  • 选项二(直接访问):不创建特定方法,允许程序员直接访问x和y,让他们在需要时自己去加倍。

第二种方式的问题在于,如果程序员犯了一个小错误,比如只加倍了x而忘记了加倍y,或者加倍了x却将y乘以了三倍等等,那么对象内部的x和y值就会变得不一致。而如果你强制他们使用你编写并正确调试过的doubleDistance函数,他们就不可能犯这样的错误。


本节总结

在本节中,我们一起学习了面向对象编程的两个基础概念。我们明确了是数据和方法的模板,而对象是类的具体实例。我们还探讨了封装的重要性,它通过限制对数据的直接访问、强制使用方法操作数据,来保证数据的内部一致性并降低使用者的出错风险。虽然Go语言没有传统的“类”,但它通过其他机制支持这些概念,我们将在后续课程中详细探讨。

045:类的支持(上) 🧩

在本节课中,我们将要学习Go语言如何实现面向对象编程中的“类”概念。Go语言没有class关键字,但它通过独特的方式将数据和方法关联起来,实现了类似的功能。我们将重点介绍接收者类型的概念,以及如何通过它来定义和调用方法。


没有class关键字

Go语言没有class关键字,因此它没有官方定义的“类”。然而,它通过其他机制实现了类似的功能。

在其他面向对象语言(如Python)中,通常会使用class关键字来定义一个类,并在其代码块内定义数据字段和关联的方法。例如,在Python中定义一个Point类:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

在这个例子中,__init__是构造函数,self.xself.y是与Point类关联的数据字段。Go语言不采用这种方式,但它可以通过接收者类型来达到类似的效果。


通过接收者类型关联方法与数据

一个“类”本质上是一组数据以及与这些数据相关联的一组方法。在Go语言中,我们通过接收者类型来将方法与特定类型的数据关联起来。

当你定义一个函数时,可以为其指定一个接收者类型,这个类型就是该方法所关联的数据类型。调用该方法时,使用标准的点号(.)表示法。

以下是定义和使用接收者类型的步骤:

  1. 定义新类型:首先,定义一个自定义类型。
  2. 定义方法:在函数名前,用括号指定接收者类型。
  3. 调用方法:使用该类型的变量,通过点号表示法调用方法。

让我们通过一个例子来具体说明。


示例:为自定义类型添加方法

假设我们定义一个新类型MyInt,它本质上是int类型:

type MyInt int

现在,我们想为MyInt类型添加一个Double方法,该方法将整数值翻倍并返回。

以下是定义和调用该方法的代码:

// 为MyInt类型定义Double方法
func (mi MyInt) Double() int {
    return int(mi * 2)
}

func main() {
    var v MyInt = 3 // v是MyInt类型
    fmt.Println(v.Double()) // 输出: 6
}

代码解析

  • func (mi MyInt) Double() int:这里(mi MyInt)就是接收者声明。它表示Double方法是与MyInt类型关联的。mi是接收者变量,在方法内部代表调用该方法的MyInt值。
  • v.Double():当调用v.Double()时,Go语言会查找与v的类型(即MyInt)关联的Double方法并执行。

重要限制:你只能为同一包内定义的类型添加方法。这意味着你不能为内置类型(如intstring)或其他包中定义的类型直接添加新方法。


隐式的方法参数

虽然Double方法的定义看起来没有参数,但实际上它有一个隐式参数——接收者本身。

当调用v.Double()时,变量v会作为隐式参数传递给Double方法。在方法内部,我们通过接收者变量mi来访问这个值。

这一点非常重要,因为Go语言中所有的参数传递都是按值传递。这意味着:

  • v被传递给Double方法时,传递的是v的一个副本
  • 方法内部对接收者变量mi的修改,不会影响原始的变量v

理解这个机制对于编写正确操作数据的方法至关重要。


总结

本节课中我们一起学习了Go语言对“类”概念的支持方式:

  1. Go语言没有class关键字,但通过接收者类型实现了数据与方法的关联。
  2. 使用func (receiver Type) MethodName() returnType { ... }的语法来定义方法。
  3. 通过variable.MethodName()的点号表示法来调用方法。
  4. 接收者会作为隐式参数按值传递给方法,这意味着方法接收的是原始数据的副本。

在下一节中,我们将进一步探讨如何利用这种机制来模拟更复杂的类行为,例如定义操作结构体的方法。

Go语言编程:模块3:类的支持(下) 🧩

在本节中,我们将探讨Go语言如何通过结构体和方法来支持面向对象编程中的“类”概念。我们将看到如何将多个数据字段组合成一个结构体,并为其关联方法,从而实现类似传统面向对象语言中类的功能。


在传统的面向对象语言中,一个类被定义为一组数据及其关联的方法。通常,你可以将许多不同的数据(例如整数、浮点数等)组合在一起,并与任意数量的方法关联。

在Go语言中,你也可以实现类似的功能。当然,你需要使用接收者类型,正如我们之前讨论的。Go语言没有“类”的概念,但它有接收者类型。你可以使用一个包含多个数据的类型作为接收者类型。

之前我们使用的例子中,接收者类型只是一个int(例如myInt),它只包含一个数据。但更常见的做法是使用某种结构体作为接收者类型。因为结构体基本上允许你将各种不同的数据字段组合在一起。

例如,在myPoint结构体中,我组合了两个数字:xy,它们都是浮点数。所以,两个浮点数组合成了一个结构体。请记住,使用结构体,你可以组合任意数量的信息。

因此,很常见的是,接收者类型是某种包含许多不同数据的结构体。这是类的一个传统特性——能够将许多不同的数据组合在一起。

现在,对于带有方法的结构体,你可以定义一个结构体类型(就像我们刚才对point类型所做的那样),然后为该类型关联方法。这样,你就得到了在其他语言中通常被认为是“类”的东西:一个包含许多不同数据和任意数量方法的结构体。


以下是一个具体的例子:

我们使用上一张幻灯片中定义的point。对于这个point,我想创建一个名为distToOrigin的函数。请注意,在函数名distToOrigin的左侧,我传递了一个point p。这里说的是“传递”,但它是隐式传递的。所以它没有任何显式参数,但其接收者类型是一个名为ppoint,它将隐式地传递给distToOrigin

现在,如果你查看函数内部,它只是执行勾股定理:取x的平方,取y的平方,将它们相加,然后返回平方根。所以它只是执行勾股定理,并不复杂。

然后,在我的main函数中,我可以创建一个点p1,坐标为(3, 4),然后我可以直接调用p1.distToOrigin()。这样,p1及其xy坐标将隐式地传递给distToOrigindistToOrigin将计算勾股定理并返回距离,在这个例子中是5


在本节课中,我们一起学习了Go语言如何通过结构体和方法来模拟面向对象编程中的“类”。我们看到了如何将多个数据字段组合进一个结构体,并为其定义方法,从而创建出功能丰富且灵活的数据类型。这展示了Go语言在简洁语法下强大的面向对象编程能力。

047:封装 🛡️

在本节课中,我们将要学习Go语言中一个核心的面向对象概念:封装。封装意味着将数据(变量)和操作数据的方法(函数)捆绑在一起,并控制外部代码对这些数据的访问。这有助于保护数据的完整性,并确保数据只能通过预定义的方式进行修改。

封装的概念

上一节我们介绍了面向对象编程的基础,本节中我们来看看封装的具体实现。封装的核心思想是隐藏对象的内部状态(数据),并仅通过公开的方法来提供访问和修改的途径。在Go语言中,这主要通过标识符的可见性规则来实现。

包级别的封装

在Go中,标识符(变量、函数、类型等)的名称首字母的大小写决定了其可见性。首字母大写的标识符可以被其他包访问(公开),而首字母小写的标识符则只能在定义它的包内部访问(私有)。

以下是实现包级别封装的一个基本示例:

// 文件:data/data.go
package data

// 私有变量,只能在data包内访问
var x int = 1

// 公开函数,允许其他包间接访问x的值
func PrintX() {
    println(x)
}
// 文件:main.go
package main

import “yourmodule/data”

func main() {
    // 无法直接访问 data.x
    // data.x = 5 // 这行会编译错误

    // 但可以通过公开的PrintX函数访问
    data.PrintX() // 输出:1
}

通过这种方式,main包可以获取x的值,但无法直接修改它,从而实现了对数据的受控访问。

结构体与方法的封装

封装同样适用于自定义类型,特别是结构体。我们可以将结构体的字段设为私有,然后提供公开的方法来操作这些字段。

上一节我们介绍了包级别的封装,本节中我们来看看如何将封装应用于结构体。假设我们有一个表示二维坐标点的结构体。

以下是定义和使用封装结构体的步骤:

首先,在data包中定义结构体和相关方法:

// 文件:data/point.go
package data

import “fmt”

// Point 是一个结构体类型,其字段是私有的
type Point struct {
    x, y float64
}

// InitMe 是一个公开方法,用于初始化Point的坐标
func (p *Point) InitMe(x, y float64) {
    p.x = x
    p.y = y
}

// Scale 是一个公开方法,用于按比例缩放坐标
func (p *Point) Scale(factor float64) {
    p.x *= factor
    p.y *= factor
}

// PrintMe 是一个公开方法,用于打印坐标
func (p *Point) PrintMe() {
    fmt.Printf(“X: %v, Y: %v\n”, p.x, p.y)
}

接下来,在main包中使用这个封装好的结构体:

// 文件:main.go
package main

import “yourmodule/data”

func main() {
    // 创建一个Point类型的变量p
    var p data.Point

    // 通过公开方法初始化坐标
    p.InitMe(3, 4)

    // 通过公开方法缩放坐标
    p.Scale(2)

    // 通过公开方法打印坐标
    p.PrintMe() // 输出:X: 6, Y: 8

    // 无法直接访问或修改私有字段
    // p.x = 10 // 这行会编译错误
    // fmt.Println(p.y) // 这行也会编译错误
}

通过以上方法,我们确保了Point结构体的xy字段只能通过我们定义的InitMeScalePrintMe方法来操作。这提供了对数据修改的完全控制,例如,我们可以确保缩放操作总是同时作用于两个坐标。

封装的优势

以下是封装带来的主要好处:

  • 数据保护:防止外部代码意外或恶意地破坏对象的内部状态。
  • 代码灵活性:内部实现的更改(例如,修改数据存储方式)不会影响使用该对象的其他代码。
  • 易于维护:将相关的数据和操作组织在一起,使代码逻辑更清晰。

总结

本节课中我们一起学习了Go语言中的封装。我们了解到,封装是通过控制标识符的可见性(首字母大小写)来实现的。我们探讨了如何在包级别隐藏变量,并通过公开函数提供受控访问。更重要的是,我们学习了如何将封装应用于结构体,通过将字段设为私有并提供公开的“getter”和“setter”方法,来安全地管理对象的状态。封装是构建健壮、可维护软件的基础。

048:指针接收器 🎯

在本节课中,我们将要学习Go语言中方法的一个关键概念:指针接收器。我们将探讨为什么需要指针接收器,以及如何通过它来修改接收器对象内部的数据,同时避免不必要的内存复制。

概述

上一节我们介绍了方法的基本概念,以及如何为不同类型定义方法。本节中我们来看看当我们需要在方法内部修改接收器对象时,会遇到哪些限制,以及如何通过指针接收器来解决这些问题。

值接收器的限制

方法中的接收器类型会被隐式地作为参数传递给方法。即使没有显式传递,它也是隐式传递的。需要记住的是,Go语言中的参数传递是按值传递的,这意味着传递的是值的副本。

因此,方法无法修改接收器对象内部的数据。例如,假设我们有一个名为offsetX的方法,它应该增加一个点的x坐标。我们想要给某个点的x坐标加上一个常数。

main函数中,我们定义p := Point{3, 4},然后调用p.offsetX(5),希望将x坐标增加5。但这不会改变x坐标的值。

原因在于,offsetX方法接收到的是p的一个副本,而不是指向p的指针。由于它获得的是p的副本,它可以修改这个副本,例如将x从3改为8。但是,一旦方法执行完毕,这个副本就会消失,因为它所在的环境已经结束。

我们想要的是能够改变p内部的实际值,但无法做到,因为方法接收到的p对象实际上是按值传递的。

另一个问题是,如果接收器对象很大,那么在调用方法时会发生大量的复制操作。当按值调用时,接收器对象作为参数传递,整个对象必须被复制到内部的栈上。如果接收器对象很大,那么就会产生大量的复制操作。

例如,假设我们有一个Image类型,它是一个100x100的整数数组。这对于图像来说实际上是很小的,相当于10,000个整数。当我们调用与Image类型关联的blurImage方法时,这个图像必须被传递给blurImage方法,这意味着10,000个整数需要被复制到栈上,这可能会花费很长时间。实际上,图像可能是最糟糕的情况,因为它们可能变得非常庞大,100x100甚至不算大。因此,这可能会浪费大量时间。

指针接收器的解决方案

为了解决这些问题,我们可以像之前处理普通函数参数那样,不按值传递,而是按引用传递。我们可以显式地传递对象的指针,而不是对象本身。

管理这一点的方法是在声明函数时,将接收器类型指定为指针类型。例如,在offsetX方法中,我们将接收器类型设置为*Point,而不是Point。这样,p就是一个指向Point对象的指针。

现在,当我们将这个p值隐式传递给offsetX时,它将传递一个指向我们正在讨论的Point类型的指针。在函数内部,我们可以执行p.x = p.x + v,这实际上会起作用,因为p.x现在指向内存中实际的x值,所以我们可以修改它,因为我们正在进行按引用调用。

以下是实现指针接收器的代码示例:

type Point struct {
    x, y int
}

func (p *Point) offsetX(v int) {
    p.x = p.x + v
}

通过这种方式,我们可以在方法内部修改接收器对象的实际值,同时避免了不必要的内存复制,提高了程序的效率。

总结

本节课中我们一起学习了指针接收器在Go语言中的重要性。我们了解到,通过使用指针接收器,我们可以在方法内部修改接收器对象的数据,并且避免了因按值传递而产生的大量内存复制操作。这对于处理大型数据结构尤其重要,能够显著提升程序的性能。

049:指针接收器的引用与解引用

在本节中,我们将学习Go语言中指针接收器的一个便利特性:编译器会自动处理引用和解引用操作,从而简化代码编写。我们将通过具体示例来理解这一机制,并了解相关的编程最佳实践。


上一节我们介绍了指针接收器的基本概念,本节中我们来看看使用指针接收器时,Go语言如何自动处理引用和解引用。

使用指针接收器时,无需在方法内部手动解引用指针。这意味着,在方法中可以直接通过接收器变量访问字段,而不需要使用*操作符。

以下是一个示例,其中offsetX方法使用指针接收器来修改Point类型的X坐标:

func (p *Point) offsetX(v int) {
    p.x = p.x + v  // 直接使用 p.x,无需写成 (*p).x
}

在这个例子中,接收器p是一个*Point类型的指针。然而,在方法内部,我们直接使用p.x来访问和修改字段,而不是(*p).x。这是因为Go编译器会自动识别并处理解引用,这是一种方便的语法糖。


同样地,在调用方法时也无需手动取引用。即使方法定义要求指针接收器,我们也可以直接通过值类型的变量来调用它。

以下是在main函数中调用offsetX方法的示例:

func main() {
    p := Point{3, 4}  // p 是值类型,不是指针
    p.offsetX(5)       // 直接调用,无需写成 (&p).offsetX(5)
}

这里,变量pPoint类型的值,而不是指针。然而,当我们调用offsetX方法时,可以直接使用p.offsetX(5),而不需要写成(&p).offsetX(5)。Go编译器会自动处理取引用操作,使得代码更加简洁。


关于指针接收器的使用,有一个重要的编程实践建议。

以下是相关的指导原则:

  • 对于一个特定的类型,其所有方法应统一使用指针接收器,或统一不使用指针接收器。
  • 这种做法可以避免混淆,提高代码的可读性和一致性。
  • 虽然语言本身允许混合使用,但遵循此约定是良好的编程习惯。

本节课中我们一起学习了Go语言指针接收器的便利特性:编译器会自动处理方法的引用和解引用操作,从而简化代码。我们还了解了统一使用指针或非指针接收器的最佳实践,这有助于保持代码的清晰和一致。

050:接口与抽象

概述

在本节课中,我们将要学习多态性这一核心概念。多态性是面向对象编程中常见的特性,它允许对象根据上下文呈现不同的形式。我们将探讨多态性的含义、它在传统面向对象语言中的实现方式,并了解Go语言如何以独特的方式支持多态性。


多态性的定义 🧩

多态性是一种属性,它通常与面向对象编程相关联。这种能力指的是,一个对象可以根据上下文呈现不同的形式。

“不同的形式”可以指代许多事物,但通常意味着,你可以拥有一个具有相同名称的函数或方法,例如 area,它对于一种对象执行一种操作,对于另一种类型的对象则执行另一种操作。

例如,计算矩形的面积是 base * height,而计算三角形的面积是 0.5 * base * height。同一个函数名 area 会根据上下文(处理矩形还是三角形)执行两种不同的操作。这就是多态性。因此,我们可以说 area 函数是多态的,因为它能根据上下文执行不同的操作。

另一种理解方式是,这两种 area 的实现,在高层次抽象上是相同的。它们都计算面积。无论对象是矩形还是三角形,它们都执行“计算面积”这个操作。在忽略具体细节的高层次上,它们做的是同一件事。然而,在低层次上,它们实际计算面积的方式是不同的。因此,多态性本质上是一种建立抽象的方式:这些事物在高层次抽象上是相同的,但在底层实现上却不同。这种能力非常有用,因此我们需要Go语言提供某种支持来实现多态性。


传统面向对象语言中的多态性实现 🏛️

上一节我们介绍了多态性的概念,本节中我们来看看它在传统面向对象语言中是如何实现的。

在面向对象语言中,通常使用继承来支持多态性。但需要明确的是:Go语言没有继承

继承指的是一系列类之间存在类、子类、超类的关系,有时也称为父类和子类。超类是顶级类,子类从超类扩展而来。子类会继承超类的方法和数据。

以下是一个例子:
假设有一个 Speaker(说话者)超类,它代表所有能发出声音的事物。Speaker 超类下可能有 Cat(猫)和 Dog(狗)这两个子类,因为猫和狗都能发出声音。CatDog 这两个子类都会继承超类 Speaker 的属性。

假设超类 Speaker 有一个名为 speak 的方法,它只是打印出某种通用的噪音。由于 Speaker 是通用的,它的 speak 方法只会打印出任意噪音。

CatDog 子类也会有一个 speak 方法,它们从 Speaker 超类继承而来。CatDogSpeaker 的不同形式,这正是多态性概念的体现。


方法重写 ✏️

仅仅有继承还不够,要支持多态性,通常还需要另一个特性:方法重写

当一个子类重新定义了它从超类继承来的方法时,就发生了方法重写。

在我们讨论的例子中,Speaker 超类有 speak 方法,CatDog 继承了它。但如果不重写 speak 方法,那么 CatDogspeak 方法将完全执行超类 Speakerspeak 方法所做的事情——仅仅打印出“噪音”。

我们希望 Catspeak 方法打印出“Meow”,Dogspeak 方法打印出“Wolf”。这就是重写:Cat 子类会重新定义 speak 方法以打印出“Meow”,Dog 子类会重新定义它以打印出“Wolf”。

现在,Speaker 超类有自己的 speak 方法,CatDog 也各自有 speak 方法。但 CatDogspeak 方法做了不同的事情。CatDog 类已经用它们自己的新定义覆盖了 speak 方法的定义。

因此,现在我们可以说 speak 是多态的,因为它为每个类提供了两种不同的实现。在 Cat 的上下文中调用 speak 会打印“Meow”,在 Dog 的上下文中调用则会打印“Wolf”。

需要注意的是,即使它们重写了方法,它们也使用了相同的函数签名speak 方法在 CatDog 类中具有相同的名称、相同的参数和相同的返回类型。在这种情况下,我们称其为多态的。


总结 🎯

本节课中我们一起学习了多态性的核心概念。我们了解到多态性允许同一操作(如函数或方法)根据其作用的对象类型表现出不同的行为。我们探讨了传统面向对象语言如何通过继承方法重写来实现多态性,并特别指出 Go语言没有继承机制。这为后续学习Go语言如何通过接口这一独特机制来实现抽象和多态性奠定了基础。

051:接口

概述

在本节课中,我们将要学习Go语言中的接口。接口是Go实现多态性的核心概念,它允许我们定义一组方法签名,任何实现了这些方法的类型都可以被视为满足该接口。通过这种方式,Go语言无需传统的类继承和方法重写,也能实现类似的多态行为。


什么是接口?🤔

接口是Go语言中的一个概念,它帮助我们实现多态性。Go语言没有继承机制,也不需要方法重写。我们可以使用接口来基本实现相同的功能,并且许多人认为这种方式更清晰、更好。

接口本质上是一组方法签名的集合。所谓“签名”,指的是方法的名称参数(及其类型)以及返回值(及其类型)。接口本身不包含任何方法的实现代码,它只定义方法的规范。

接口用于表达不同类型之间的概念相似性。例如,我们可以定义一个名为 shape2D 的接口,用来表示所有二维形状。我们可以规定,任何二维形状都必须拥有两个方法:areaperimeter。这样,任何实现了这两个方法的类型,都可以被视为一个二维形状。

满足接口的条件

一个类型满足某个接口,当且仅当它实际定义了该接口中指定的所有方法。接口只规定了方法的签名,而类型需要提供这些方法的具体实现。只要实现的方法签名(名称、参数、返回值)与接口定义完全一致,该类型就满足了该接口。

例如,rectangletriangle 类型如果都定义了 areaperimeter 方法,并且参数和返回值符合 shape2D 接口的要求,那么它们就都满足了 shape2D 接口,都可以被视为二维形状。

rectangletriangle 类型除了 areaperimeter 外,还可以拥有任意数量的其他方法和数据字段,这并不影响它们满足接口。关键在于它们是否拥有接口所要求的那组方法。

接口的作用

通过接口,我们实现了在其他面向对象语言(如Java)中通常通过继承和方法重写才能实现的功能。例如,rectangletriangle 都满足 shape2D 接口,因此它们都拥有 areaperimeter 方法。虽然计算矩形面积和计算三角形面积的实现代码完全不同,但它们在高层概念上执行的是相同的操作——计算面积。接口统一了这种概念上的相似性。


如何定义接口?✍️

定义接口的语法非常直接,看起来有点像定义结构体。

以下是定义接口的代码格式:

type shape2D interface {
    area() float64
    perimeter() float64
}
  1. 使用 type 关键字。
  2. 后接接口名称(例如 shape2D)。
  3. 再后接 interface 关键字。
  4. 最后用花括号 {} 包裹所有方法签名。

在上面的例子中,shape2D 接口要求两个方法:area()perimeter()。它们都不接受参数,并且都返回一个 float64 类型的值。


类型如何隐式满足接口?🔗

在Go语言中,类型满足接口是隐式的。你不需要像某些语言那样显式声明“类型X实现了接口Y”。

以下是一个类型隐式满足接口的例子:

type triangle struct {
    // ... 这里可以有任何数据字段
}

func (t triangle) area() float64 {
    // 计算三角形面积的实现
    return 0.0 // 示例返回值
}

func (t triangle) perimeter() float64 {
    // 计算三角形周长的实现
    return 0.0 // 示例返回值
}

我们定义了一个 triangle 类型(可能是一个结构体),并为它定义了 area()perimeter() 方法,其签名与 shape2D 接口完全匹配。

此时,Go编译器会自动识别出 triangle 类型满足了 shape2D 接口。我们无需任何额外的声明。只要类型拥有接口所要求的所有方法,它就会被当作该接口类型来对待。


总结

本节课我们一起学习了Go语言中接口的核心概念。我们了解到接口是一组方法签名的集合,用于定义行为规范。任何类型只要实现了接口中的所有方法,就隐式地满足了该接口,从而实现了多态性。这种方式避免了复杂的继承体系,使代码设计更加灵活和清晰。我们学习了如何定义接口,并通过例子看到了类型如何通过实现方法来隐式满足接口。

052:接口与具体类型 🆚

在本节课中,我们将学习Go语言中接口类型与具体类型的核心区别,并深入探讨接口值的内部结构,包括动态类型和动态值。

概述

具体类型和接口类型在本质上是不同的。具体类型定义了数据的精确表示和所有关联的方法。而接口类型仅指定了一组方法签名,不包含任何数据或方法实现。理解这种差异是掌握Go语言接口的关键。

具体类型与接口类型

具体类型是一种常规类型,它精确指定了数据的表示形式以及所有以该类型为接收器的方法。这意味着具体类型是完全指定的,并且拥有所有方法的完整实现。

具体类型示例:

type Dog struct {
    name string
}

接口类型则不同,它只指定了一些方法签名,不包含任何数据。接口中的方法实现是抽象的,你只能看到方法的签名,而看不到具体的实现代码。

接口类型示例:

type Speaker interface {
    Speak()
}

这就是两者之间的根本区别。但请记住,当你使用一个接口时,它最终会被映射到一个具体的类型。

接口值的构成

上一节我们介绍了接口与具体类型的定义差异,本节中我们来看看接口值的内部结构。当你创建一个接口类型的变量时,这个接口值由两个组件构成:动态类型和动态值。

  • 动态类型:这是接口变量当前所关联的具体类型。
  • 动态值:这是该动态类型所对应的具体值。

更具体地说,假设我们有一个 Shape2D 接口,以及满足该接口的具体类型 RectangleTriangle。当我们把一个 Rectangle 的值赋给一个 Shape2D 接口变量时,该接口的动态类型就是 Rectangle,动态值就是那个具体的矩形对象。

接口值示例

以下是一个具体的代码示例,帮助我们理解接口值的动态类型和动态值。

定义接口和具体类型:

type Speaker interface {
    Speak()
}

type Dog struct {
    name string
}

func (d Dog) Speak() {
    fmt.Println(d.name)
}

使用接口:

func main() {
    var s1 Speaker      // s1 是一个接口变量
    d1 := Dog{"Brian"}  // d1 是一个具体类型 Dog 的值

    s1 = d1             // 合法,因为 Dog 实现了 Speaker 接口
    s1.Speak()          // 输出 "Brian"
}

在这个例子中,赋值 s1 = d1 之后:

  • s1动态类型Dog
  • s1动态值d1,其中包含了名字 “Brian”。
    因此,接口值 s1 实际上是一个 (动态类型: Dog, 动态值: d1) 的组合。

动态值为 nil 的接口

接口可以拥有一个动态类型,但动态值为 nil(即没有具体的值)。这是合法的状态。

示例:

func main() {
    var s1 Speaker
    var d1 *Dog         // d1 是一个指向 Dog 的指针,但未初始化,值为 nil

    s1 = d1             // 合法,s1 的动态类型是 *Dog,动态值为 nil
    s1.Speak()          // 仍然可以调用方法!
}

在这种情况下,s1 拥有动态类型 *Dog,但动态值为 nil。你仍然可以调用 s1.Speak(),因为编译器可以根据动态类型找到对应的方法实现(即 (*Dog).Speak())。

然而,在方法实现内部,通常需要检查接收器是否为 nil,以避免运行时错误。

安全的 Speak 方法实现:

func (d *Dog) Speak() {
    if d == nil {
        fmt.Println("<noise>")
    } else {
        fmt.Println(d.name)
    }
}

动态类型为 nil 的接口

我们刚刚讨论了动态值为 nil 的情况,现在来看另一种状态:动态类型也为 nil 的接口。这描述了一个既没有动态类型也没有动态值的接口。

示例:

var s1 Speaker // 仅声明,未赋值

此时,s1 的动态类型为 nil,动态值也为 nil。在这种状态下,不能调用该接口的任何方法,因为编译器无法确定应该调用哪个具体类型的方法实现。尝试调用 s1.Speak() 会导致运行时错误。

总结

本节课中我们一起学习了:

  1. 具体类型接口类型的根本区别:具体类型包含数据和方法实现,接口类型仅包含方法签名。
  2. 接口值是一个对 (pair),由动态类型动态值组成。
  3. 接口可以处于两种特殊的 nil 状态:
    • 拥有动态类型,但动态值为 nil:可以安全地调用方法(方法内部应处理 nil 情况)。
    • 动态类型和动态值均为 nil:不能调用任何方法,会导致错误。

理解这些概念对于在Go中有效、安全地使用接口至关重要。

053:使用接口

概述

在本节课中,我们将学习Go语言中接口的具体使用方法。接口是Go语言实现抽象和多态的核心机制,它允许我们定义一组方法签名,任何实现了这些方法的类型都自动满足该接口。我们将通过一个具体的例子,理解如何利用接口编写能够处理多种类型的函数。


接口的作用与概念

上一节我们介绍了接口的基本概念。本节中,我们来看看接口在语言层面的具体用途。

接口用于表达不同类型之间的某种概念上的相似性。如果两个类型都满足同一个接口,那么它们必然在应用程序关心的某个方面是相似的。

一个常见且实用的接口使用场景是:当你需要编写一个函数,且该函数需要接受多种类型作为参数时。

通常,一个函数只能接受特定类型的参数。例如,一个接收int类型参数的函数,就只能传入int值。但如果你希望函数能处理intfloatstring等多种类型,并根据不同类型执行不同操作,就可以使用接口来实现。

让我们通过一个抽象的例子来说明:
假设有一个函数 foo,它需要接收一个参数,这个参数可以是类型 X 或类型 Y。我们可以定义一个名为 Z 的接口,并让类型 XY 都满足接口 Z。然后,将 foo 函数的参数类型声明为接口 Z。这样,foo 函数就能接受任何满足接口 Z 的类型作为参数,包括 XY

这种方式是使用接口的常见模式。本质上,接口在此处起到泛化的作用。它隐藏了不同类型之间的具体差异,只强调它们在某些重要方面的相似性。因此,你的函数只需接收接口类型,就意味着它能处理所有具备这种相似性的类型。


具体示例:庭院泳池问题

为了让概念更具体,我们虚构一个关于庭院泳池的问题。

假设我有一个后院,想在里面建一个泳池。在选择泳池形状时,我需要考虑两个限制条件:

  1. 面积限制:泳池的总面积必须小于我院子的面积。
  2. 周长限制:泳池的周长必须小于我能负担得起的围栏长度。

因此,我需要一个函数来判断某个特定形状的泳池是否满足这些条件。我会遍历一系列不同的泳池形状,并挑选出同时满足面积和周长限制的那一个。

我将编写一个名为 fitInYard 的函数,它返回一个布尔值。这个函数接收一个“形状”作为参数,比如三角形或矩形。如果该形状的面积和周长都足够小,函数就返回 true;否则返回 false

关键点在于:fitInYard 函数需要接收一个“形状”作为参数,但我希望它能接收任何形状,无论是三角形、圆形、正方形还是矩形。不过,并非所有“形状”都有效。为了进行计算,这个形状必须能计算出面积和周长。因此,一个有效的形状必须拥有 areaperimeter 这两个方法。

所以,任何具有面积和周长的形状对我来说都是可接受的。

以下是实现步骤:

  1. 定义接口:首先,我定义一个名为 shape2D 的接口,它规定了 areaperimeter 这两个方法,它们都返回 float64 类型。

    type shape2D interface {
        area() float64
        perimeter() float64
    }
    
  2. 定义具体类型:接着,我定义各种具体的类型,比如 trianglerectangle。我不关心这些类型内部具体有哪些数据字段,只要它们拥有以自身为接收者类型的 areaperimeter 方法即可。

    type triangle struct {
        // ... 三角形所需的字段
    }
    func (t triangle) area() float64 {
        // 计算三角形面积的逻辑
    }
    func (t triangle) perimeter() float64 {
        // 计算三角形周长的逻辑
    }
    
    type rectangle struct {
        // ... 矩形所需的字段
    }
    func (r rectangle) area() float64 {
        // 计算矩形面积的逻辑
    }
    func (r rectangle) perimeter() float64 {
        // 计算矩形周长的逻辑
    }
    

    只要 trianglerectangle 类型实现了 areaperimeter 方法,它们就自动满足了 shape2D 接口。

  3. 实现通用函数:现在,我可以实现 fitInYard 函数。它的参数 s 的类型就是接口类型 shape2D

    func fitInYard(s shape2D) bool {
        if s.area() < 100 && s.perimeter() < 100 {
            return true
        }
        return false
    }
    

    这意味着,fitInYard 函数可以接受任何满足 shape2D 接口的类型作为参数,比如 rectangletriangle。函数内部通过调用接口方法 s.area()s.perimeter() 来进行计算和判断。


空接口

Go语言预定义了一个特殊的接口,称为空接口。它不包含任何方法声明。

以下是空接口的定义方式:

interface{}

由于空接口没有指定任何方法,因此任何类型都自动满足空接口。

空接口的用途是:当你希望一个函数参数能够接受任意类型,而不想施加任何类型限制时,就可以使用空接口作为参数类型。

例如,我们有一个 printMe 函数:

func printMe(val interface{}) {
    fmt.Println(val)
}

参数 val 的类型是空接口 interface{},这意味着 val 可以是任何类型。这个函数只是简单地打印传入的值,无论你传入的是 intfloatstring 还是其他任何类型,它都能正常工作。


总结

本节课中,我们一起学习了Go语言接口的具体应用。我们了解到,接口的核心作用之一是定义类型之间的概念相似性,从而允许函数接收多种类型。通过“庭院泳池”的示例,我们实践了如何定义接口、让具体类型实现接口,并编写基于接口的通用函数。最后,我们还介绍了空接口的概念及其在需要完全泛型参数时的用途。掌握接口是编写灵活、可扩展Go程序的关键。

054:类型断言 🧩

在本节课中,我们将要学习Go语言中一个重要的概念:类型断言。接口的主要作用是隐藏类型之间的差异,但有时我们需要知道接口背后具体的类型是什么,这时就需要用到类型断言。

接口:隐藏差异,突出共性

上一节我们介绍了接口如何抽象不同类型。接口的核心在于隐藏类型之间的差异,同时突出它们的共性

例如,在一个图形程序中,Rectangle(矩形)和Triangle(三角形)是不同的类型。但如果它们都实现了Shape2D接口(包含Area()Perimeter()方法),那么我们就可以将它们都视为Shape2D类型来处理。

// 接口允许我们以相同的方式处理具有相似方法的不同类型
func processShape(s Shape2D) {
    area := s.Area()
    perimeter := s.Perimeter()
    // 在这里,s的具体类型是矩形还是三角形并不重要
}

通过接口,我们统一调用了Area()Perimeter()方法,而无需关心s背后具体是矩形还是三角形。接口有效地隐藏了具体类型的差异。

何时需要揭示差异?

然而,在某些场景下,我们必须知道接口背后的具体类型,以便进行不同的处理。这时,我们就需要“揭开”接口的面纱。

设想一个绘图函数DrawShape,它需要能绘制任何二维图形。

func DrawShape(s Shape2D) {
    // 目标:根据s的具体类型(矩形、三角形等)调用不同的底层绘图API
}

虽然我们希望DrawShape能接收任何Shape2D,但底层的图形API可能提供了不同的具体函数,例如DrawRect()只接受RectangleDrawTriangle()只接受Triangle

因此,在DrawShape函数内部,我们需要判断传入的s具体是哪种类型,然后调用对应的绘图函数。这就需要用到类型断言

使用类型断言进行判断

类型断言用于判断接口值背后的具体类型,并获取该类型的值。

以下是使用类型断言实现DrawShape的一种方式:

func DrawShape(s Shape2D) {
    // 尝试断言s是否为Rectangle类型
    rec, ok := s.(Rectangle)
    if ok {
        // 如果断言成功,ok为true,rec就是具体的Rectangle值
        DrawRect(rec)
        return
    }

    // 尝试断言s是否为Triangle类型
    tri, ok := s.(Triangle)
    if ok {
        DrawTriangle(tri)
        return
    }

    // 可以继续添加对其他类型(如Circle)的判断...
}

在上面的代码中:

  • s.(Rectangle) 是一个类型断言表达式。
  • 如果接口值s背后确实是Rectangle类型,那么断言成功:
    • ok 的值为 true
    • rec 被赋值为该Rectangle的具体值。
  • 随后,我们调用DrawRect(rec)来绘制矩形。
  • 如果断言失败(s不是Rectangle),则okfalserec为零值,我们继续尝试下一个类型断言。

更优雅的方式:类型开关(Type Switch)

如果需要判断的类型很多,使用多个if语句会显得冗长。Go语言提供了类型开关,这是一种更简洁的语法,专门用于基于接口值的具体类型进行多分支选择。

以下是使用类型开关重写的DrawShape函数:

func DrawShape(s Shape2D) {
    switch sh := s.(type) {
    case Rectangle:
        // 在此分支中,sh的类型是Rectangle
        DrawRect(sh)
    case Triangle:
        // 在此分支中,sh的类型是Triangle
        DrawTriangle(sh)
    // 可以轻松地添加更多case来处理其他类型,如Circle
    default:
        // 可选的default分支,处理未匹配到的类型
        fmt.Println("未知形状")
    }
}

类型开关的语法 s.(type) 是固定的,它只能在switch语句中使用。

  • 程序会根据接口值s背后的具体类型,跳转到对应的case分支。
  • 在每个case分支中,变量sh已经被自动转换为该分支所声明的具体类型(如RectangleTriangle),我们可以直接使用。

类型开关让处理多种可能类型的代码变得更加清晰和易于维护。

总结

本节课中我们一起学习了:

  1. 接口的初衷是隐藏类型差异,让我们能统一处理具有共同行为的不同类型。
  2. 现实需求有时要求我们知道接口背后的具体类型,以便执行不同的操作(例如调用不同的特定函数)。
  3. 类型断言 (value.(TypeName)) 是揭示接口具体类型的基本工具,它返回一个值和一个表示是否成功的布尔值。
  4. 类型开关 (switch v := i.(type) { ... }) 是处理多种可能类型时的优雅方案,它简化了多分支类型判断的代码结构。

掌握类型断言和类型开关,你就能在享受接口带来的抽象和灵活性的同时,也能在必要时深入到具体类型进行精细化的控制。

055:错误处理 🐛

在本节中,我们将学习Go语言中一个非常常见的接口应用:错误处理。我们将了解error接口是如何定义的,以及如何在程序中检查和处理错误。

概述

Go语言中的许多内置函数和方法都会返回一个error类型的值作为其第二个返回值。error是一个内置接口,任何实现了Error() string方法的类型都可以被视为一个错误。本节将介绍如何检查和处理这些错误,以确保程序的健壮性。

error接口

上一节我们介绍了接口的基本概念,本节中我们来看看一个具体且极其重要的内置接口:error

error接口的定义非常简单,它只要求实现一个名为Error的方法,该方法返回一个描述错误的字符串。

type error interface {
    Error() string
}

这意味着,任何拥有Error() string方法的类型都满足了error接口,可以被当作错误来使用。

错误处理模式

在Go语言中,错误处理遵循一个明确的模式。当调用一个可能失败的操作(例如打开文件)时,函数通常会返回两个值:预期的结果和一个error

以下是处理此类调用的典型步骤:

  1. 调用函数并接收其返回值。
  2. 立即检查返回的error值是否为nil
  3. 如果error不为nil,则说明发生了错误,需要进行处理(例如打印错误信息并退出)。

错误处理示例

让我们通过一个打开文件的例子来具体看看这个模式是如何工作的。

package main

import (
    "fmt"
    "os"
)

func main() {
    // 尝试打开一个文件
    f, err := os.Open("filename.txt")

    // 检查是否发生错误
    if err != nil {
        // 处理错误:打印并退出
        fmt.Println(err)
        return
    }

    // 如果 err 为 nil,说明文件打开成功,可以继续使用文件 f
    // ... 后续对文件 f 的操作 ...
}

在这个例子中:

  • os.Open 函数尝试打开名为 “filename.txt” 的文件。
  • 它返回两个值:一个文件句柄 f 和一个错误 err
  • 我们立即使用 if err != nil 来检查错误。
  • 如果 err 不为 nil(即发生了错误),我们使用 fmt.Println(err) 打印错误信息。fmt 包会自动调用错误值的 Error() 方法来获取要打印的字符串。
  • 打印错误后,我们使用 return 退出函数(或进行其他错误恢复操作)。
  • 如果 errnil,则程序可以安全地继续执行,使用打开的文件 f

总结

本节课中我们一起学习了Go语言的错误处理机制。我们了解到error是一个内置接口,核心是Error() string方法。Go语言通过让函数返回一个error值作为第二返回值来报告操作状态。标准的做法是在调用后立即检查这个错误,如果它不是nil,则进行相应的处理(如打印日志)。这种显式的错误处理模式是Go语言追求代码清晰和可靠性的重要体现。

056:为什么需要并发

在本模块中,我们将探讨Go语言的核心特性之一:并发。我们将从并行执行的基本概念开始,理解为什么在现代计算中并发变得至关重要,并分析硬件发展的限制如何推动了并发编程的普及。

1.1:并行执行 🚀

Go语言的一个重要特性是并发被内置在语言中。我们现在要讨论“并发”这个术语。目前我谈论的是并行执行。并发和并行是两个紧密相关的概念,我们将讨论它们之间的区别。我从并行开始,因为它是Go语言内置的一个重要特性。

这与C、Python等其他语言相比是不同的。在这些语言中,你也可以进行并发编程,但它不是语言内置的。通常你需要导入一些外部库,然后使用其函数并与操作系统交互。然而,Go语言的设计者认为并发足够重要,因此决定将其直接构建到语言中。这使得并发结构成为语言的一部分,通常更易于使用。

现在,我描述的是并行执行,因为我真的想说明为什么需要并发。并行与并发并不完全相同,我们稍后会讲到,但它们很相似。

并行执行是指两个程序在同一时间执行。在某个特定的时刻,你可以说一个程序的指令和另一个程序的指令在同一瞬间执行,那么它们就是在并行执行。

通常,处理器核心被设计为一次执行一条指令。当然,存在不同的架构,但一般来说,一个核心一次只能运行一个任务。如果你想实现真正的并行执行,让两个任务同时运行,你需要两个处理器,或者至少两个处理器核心。你需要复制的硬件。例如,你可以有两台完全独立的计算机,它们显然可以同时运行两个不同的程序。或者,也可以是同一台计算机,但拥有多核处理器(例如四个核心),那么你可以在每个核心上同时运行不同的指令。

为了获得真正的并行执行,你需要多个硬件副本。你需要复制的硬件来实现并行执行。这与并发不同,我们稍后会讨论其区别。

那么,为什么要进行并行执行?它有什么好处?最大的好处是任务可以更快地完成。这里的意思是,一个特定任务并不会仅仅因为与另一个任务并行运行而更快完成,但你可以获得更好的吞吐量。如果你同时做两件事而不是一件,或者同时做多件事,那么总体上所有任务会更快完成。因此,并行执行可以加速进程,总体上提供更好的吞吐量。

一个简单的例子是,假设你有两堆盘子要洗。当你洗一个盘子时,你需要先洗它,然后擦干它。如果你有两个洗碗工,他们可以合作,工作得更快。他们如何合作将取决于他们可用的硬件。有些任务必须按顺序执行。例如,洗盘子和擦干盘子,你必须先洗盘子才能擦干盘子,这是顺序性的,必须按这个顺序进行,它们不能同时发生。

所以,并行执行并非万能,不能使所有事情都变快。有些事情不能并行执行,它们必须按顺序一次一个地执行。你必须在擦干盘子之前先洗盘子。但是,你可以设计一个系统,比如我有一个水槽和一个沥水架,还有一块洗碗布。我可以让两个人工作,一个人洗盘子,然后递给另一个人擦干。这样,当一个人在洗的时候,另一个人在擦干,他们同时在工作,本质上获得了一种交错的并行执行,从而获得了加速和良好的吞吐量。

这里的一个关键点是,有些任务比其他任务更适合并行化。以洗碗任务为例,你不能简单地说:“你们两个同时洗这个盘子,同时擦干它。”如果我只有一个水槽,一次只能有一个人用它洗盘子;如果我只有一个沥水架,一次只能有一个人用它擦干盘子。因为我没有足够的硬件,所以我无法并行化这些步骤。我需要两个水槽才能同时洗,需要两个沥水架才能同时擦干。此外,我永远无法将同一个盘子的清洗和擦干并行化,你必须在开始擦干之前完成清洗。因此,即使有额外的硬件,有些任务也无法并行化。了解这一点很重要,因为人们常常认为任何事都可以通过并行化来加速,但事实并非如此。有些计算本质上就无法并行化,某些事情必须按特定顺序完成,不能同时进行。

上一节我们介绍了并行执行的基本概念和限制,本节中我们来看看为什么在现代计算中,仅仅依靠硬件加速已经不够,从而引出了对并发编程的迫切需求。

1.2:冯·诺依曼瓶颈与硬件加速的极限 ⚡

我们正在讨论并行执行,使用并发编程来并行运行代码。但为什么要这样做?编写并行代码、编写并发代码是困难的,我还没有提到这一点,但当我们深入探讨时,我们会发现由于多种原因它确实很困难。实际上,如果你看看大多数本科课程,学生们并不学习这个。你学习的是顺序编程,无论是C、Python、Java还是其他语言,你学习的是顺序编程。也许有时本科生会有一门关于并行编程的课程,但可能是选修课,他们可能上也可能不上。无论如何,你在本科阶段看到的大多数编程课程都不讨论并发编程,他们只讨论顺序的、常规的旧代码,一次运行一条指令。因此,并发编程实际上非常困难。

那么,为什么要做呢?我们能否在不这样做的情况下获得加速?显然,如果你并行地做事,你可以获得加速,但我们真的需要它吗?我认为,大多数人也会认为,是的,我们需要。现在,在过去,也许我们不需要,但现在我们真的需要。一种无需并行性就能获得加速的方法是直接提高处理器的速度,制造出比旧处理器运行更快的新处理器,这样你就能获得加速,而不必改变编写代码的方式。直到最近,比如五、六、七年前,这一直是事情发展的方式。代码加速的主要方式是因为处理器被制造得更快了。我来自硬件设计领域,我一直觉得这些软件程序员占了很大的便宜,因为他们的加速都是建立在我们设计更快更好的处理器的基础上的,他们什么都不用做,就能获得运行更快的代码。但现在这种情况真的停止了。

有几个原因导致了这种停止。当我还是个孩子的时候(我年纪不小了),我在86到90年代读本科。我记得一台机器上市,你买了它,你会想:“哦,这是市面上最快的东西了。”但说真的,几个月后,也许半年后,他们就会以同样的价格推出速度明显更快的东西,这简直让人崩溃。你会想:“哇,我刚买了这台机器,现在他们就有了更快的东西。”时钟频率比我刚买的机器高出20%,这非常令人沮丧。但这种情况过去经常发生,每隔很短一段时间,时钟频率就会变得越来越快。随着时钟频率的提高,代码执行速度通常也会更快,尽管存在内存瓶颈(我们马上会谈到)。

但处理器变得越来越快。现在,即使现在,速度的另一个限制是所谓的“冯·诺依曼瓶颈”,即访问内存的延迟。如果你想想处理器执行代码的方式,有一个CPU在执行指令,还有内存。CPU必须去内存获取指令,也要获取你想要使用的数据。比如你想计算 x + y = z,你必须去内存取出数据,把它们相加,然后把结果放回内存(或者更准确地说,放到z或x中)。所以CPU需要定期从内存读取数据,写回内存。而内存总是比CPU慢。所以,即使你提高了时钟速度,让CPU工作得更快,内存仍然很慢。内存的速度随时间提升得很慢,比时钟频率的提升速度慢得多。因此,你遇到了所谓的冯·诺依曼瓶颈:即使你提高了时钟频率,比如翻倍,你的代码也只能运行得快一点点,因为即使你提高了时钟速度,你仍然在等待内存访问,你会浪费大量时间等待内存访问。

过去人们为此做了什么(现在能做的更少了)?他们构建了缓存,也就是芯片上的快速内存。这样你就不必去访问速度太慢的主内存,而是去访问快速的缓存。他们喜欢在芯片上集成越来越多的缓存,这可以加速。这就是传统上直到最近(大约五、六、七年前)的做法。因此,时钟频率会上升,内存缓存容量会增加,性能会不断提升。作为程序员,你实际上不需要做任何事情,你可以用同样的方式编写代码,并期望它会神奇地加速,因为处理器本身在不断改进。过去就是这样,但现在情况不同了。

情况发生了变化,有几个原因。首先是摩尔定律(我即将描述)实际上已经失效了,我不知道确切的术语是什么,但它不再真正发生了。摩尔定律基本上预测晶体管密度每两年(或18个月到两年)翻一番。这些处理器都是由晶体管组成的,大量的晶体管用于计算。如果你能使晶体管密度翻倍,那么基本上晶体管会变得越来越小,当它们变小时,开关速度会更快。所以随着密度增加,你会获得这种自然的晶体管加速。摩尔定律并不是真正的物理定律,它只是一个观察结果。过去观察到的是,如果你绘制处理器时钟频率随时间变化的图表,你会看到时钟频率在快速上升,但现在已经趋于平缓。最近你可以看到它已经趋于平缓,时钟频率无法再变得更高了。

但我们曾经在一段时间内获得了密度随时间的指数级增长,从而获得了速度上大致指数级的增长。摩尔定律在某种程度上为我们做了一切(或大部分事情),它只是给了我们加速,程序员不必担心任何事情。当然,为了实现摩尔定律,硬件设计师必须非常努力地工作,让晶体管每几年变小一次并非魔法,这是硬件设计师辛勤工作的结果,他们想办法让这些晶体管变得更小,同时还能精确制造。他们一直在持续这样做,所以软件人员过得很轻松。但现在情况不同了,那种事情已经消失了。因此,为了继续获得加速,软件必须做其他事情来随时间保持实现这些加速。

上一节我们讨论了硬件加速的极限和冯·诺依曼瓶颈,本节中我们将探讨另一个关键限制:功耗墙,它如何进一步迫使计算架构转向多核并发。

1.3:功耗墙与多核时代的必然性 🔥

正如我所说,从摩尔定律获得的加速——即密度增加导致的速度和性能提升——无法持续。你可能会问,为什么?为什么不能永远继续下去?原因在于这些晶体管消耗功率。

当然,处理器上的晶体管密度可以不断增加,但这些晶体管会消耗大量功率,而功率现在已成为一个关键问题。他们称之为“功耗墙”。随着你增加芯片上的晶体管数量(增加密度),这自然会导致芯片的功耗增加。在过去,功耗非常低,而且人们不怎么使用电池供电。但现在,一切都是便携式的,靠电池运行。此外,密度已经变得非常高,功耗也随之变得很高。

即使你插在墙上,有电源可用,高功耗也会导致高温。如果某个东西运行消耗大量功率,它物理上就会变热。我不知道你是否曾经打开过台式电脑机箱查看内部,如果你看主板,至少会有一堆散热片,实际上你可以在图片中看到风扇。这是主板内部非常常见的东西。有一个处理器,比如主处理器是I7。在主板上的I7芯片上方,会有一个风扇,就像用螺丝固定在芯片上方一样,风扇带有一组铝制散热片,只是为了释放热量,让热量消散,然后风扇进行风冷,吹走热量。这是必要的,因为这些芯片以如此高的功率运行,以至于它们会发热,你需要冷却。如果你没有散热片来散热,没有风扇来吹走热量,那么就会损坏芯片。最终,芯片会物理熔化。这基本上就是你所说的功耗墙。

因此,即使你能在上面放置更多的晶体管,你现在也必须小心功率,特别是功率对温度的影响。温度可能是最大的障碍。但功率本身也很重要,因为如果你想拥有便携设备,你使用的是电池,你肯定不希望电池瞬间耗尽。所以功率本身很重要,但温度可能是最大的限制,因为如果你不冷却芯片,芯片就会熔化。需要注意的是,风冷是标准方式。任何台式机、笔记本电脑或服务器,你都是用风冷。你可以走向极端,说我要用水冷,实际上超级计算机就是这么做的。它们有管道连接到液体冷却系统,通常不使用水,可能使用液氮或其他超冷流体,通过设备泵送以更好地冷却。这提供了好得多的冷却效果,但没有人希望在笔记本电脑或台式机上必须连接到水冷系统。也许在超级计算机上可以,但风冷是我们在实践中能做到的最好方式,人们想要风冷,他们不想进行液体冷却。所以你处在这个极限,只能散发这么多热量。

为了更具体地说明由于功耗使用而产生的这些限制,我在这里展示的是功率的通用公式:P = α * C * F * V²

  • α 是开关时间百分比。这意味着这些晶体管在开关时消耗动态功率。当它们从0切换到1,或从1切换到0时,它们消耗功率。如果它们保持恒定,不进行任何切换,那么它们不消耗动态功率。α 是一个从0到1的数字,表示它们切换的频率。注意,如果你设计得好,它们会频繁切换。你可能希望使用晶体管进行计算,所以 α 应该相当高。
  • C 是电容,我们不想深入细节,但它与晶体管的尺寸有关。电容随着晶体管缩小而减小,这是一件好事,所以功率会在一定程度上降低。
  • F 是时钟频率。这是你想要提高的,为了让你的设备工作得更快,你想提高频率。但请注意,如果你提高频率,你就在增加功率。
  • 中的 V 是从低到高的电压摆幅。这意味着基本上每当晶体管从0切换到1或从1切换到0时,这些二进制值实际上是模拟电压。0通常是0伏特,1可能是5伏特(比如在Arduino上)。在真正的处理器中,他们会降低这个电压。V 很重要,因为注意 V 是一个平方因子。所以如果你降低电压,你可以显著降低功率。也许你会使用从0到1.3伏特或0到1.1伏特的电压摆幅。所以电压是你如果想节省功率首先想要降低的东西。

登纳德缩放是另一个与摩尔定律配对的东西。登纳德缩放是使我们随时间获得这些加速的原因。登纳德缩放的理念是,电压摆幅应该随晶体管尺寸缩放。因此,随着晶体管变小,芯片上晶体管密度增加,你同时也希望按比例降低电压,基本上就是出于我刚才告诉你的功率原因——电压有很大影响。如果你能降低电压,那么你就可以将功耗和温度保持在限度内或较低水平。

问题在于登纳德缩放不能永远持续下去。电压不能降得太低,有物理原因。第一个原因是,高低电平之间的电压摆幅必须高于晶体管的阈值电压。晶体管有一个所谓的阈值电压,低于某个电压它们无法开启。所以你必须有足够的电压来达到阈值。随着你缩小尺寸,你可以操纵阈值电压,但它只能物理上降低到一定程度。另一个问题是会出现噪声问题。拥有从0到5伏特的大电压摆幅的一个好处是,如果你的信号或系统中有某种噪声,比如你的电压有正负0.5伏特的噪声,那么5伏特变成4.5伏特也没关系,因为你知道它原本应该是5伏特,那仍然是高电平。如果0伏特变成0.5伏特,你知道它不完全是0,但很接近,所以你能分辨出区别。之所以可以这样,是因为从0到5伏特的电压摆幅相当大。如果你的电压摆幅降低到,比如说1伏特(0伏特为低,1伏特为高),那么如果你有相同的0.5伏特噪声,当电压是0.5伏特时,你无法判断它原本是高还是低,所以你无法从错误中恢复,你变得对噪声不那么容忍,这是一个大问题。因为任何实际系统中总是存在噪声。由于这些原因,登纳德缩放无法继续,你不能一直降低电压,这有一个极限。

此外,所有这些都没有考虑泄漏功率。我展示的功率公式是所谓的动态功率,即晶体管开关时消耗的功率。还有另一种功率称为泄漏功率,即使晶体管不开关,它也会泄漏功率。在过去,泄漏功率与动态功率相比非常低,主要是因为一切都很大。泄漏发生在绝缘体很薄的时候。基本上,泄漏的发生是因为有导体和导体,电流从一个泄漏到另一个,中间有绝缘体,而绝缘体不够厚。在过去,一切都很大(相对而言),所以你有厚的绝缘体,泄漏很难发生。但随着你按比例缩小一切,这些绝缘体变得越来越薄,泄漏就可能发生。因此,泄漏功率随时间增长。即使降低电压也无法节省泄漏功率,泄漏功率实际上随时间增加。由于这些原因,功率……你甚至无法继续登纳德缩放,你无法持续降低电压,所以功率方程持续上升,这就是他们所说的功耗墙。基本上,我们处在一个境地:如果你让这东西运行得更快,温度会变得如此之高,以至于设备实际上会在系统中熔化。

所以,情况是这样的,我们的功率公式再次出现。你无法提高频率,否则东西会熔化。那么你该怎么做?那么,设计者为了改善性能会做什么呢?即使他们无法提高频率?不仅仅是改善性能,假设你是英特尔,你想销售芯片。如果你推出一代芯片是4 GHz,下一代也是4 GHz,人们可能不会买这个芯片,他们需要一些购买的理由,必须有一些改进。那么人们做什么呢?他们增加芯片上的核心数量,这就是你获得多核系统的地方。你可能听说过这个,一个处理器核心基本上大致执行一个程序,你可以有多个核心。例如,I7可能有四个处理器核心之类的,核心数量可变,但他们仍在增加密度。他们只是在芯片上放置更多这种复制的硬件,但他们不提高频率,他们保持时钟频率大致相同。例如,时钟频率上升缓慢,但比过去上升的速度慢得多,而核心数量仍在继续增加。

拥有多核系统、拥有许多核心的关键在于,你必须进行并行/并发执行来利用多核系统。这意味着如果你的处理器有四个核心,而你无法并行运行任何东西,那么拥有四个核心有什么意义呢?你只使用一个核心,其他三个核心闲置。因此,为了利用这些多核系统,为了获得加速,你必须能够将你的程序分配到一堆核心上,并在不同的核心上并发执行不同的代码。这就是为什么在存在多核系统的情况下,为了持续获得加速,并行执行变得必要。你必须能够利用这种并行硬件,所以你需要能够编写并发代码来利用它。

这就是真正的动机。这就是为什么并发在当今如此重要。因为程序员必须告诉程序它将如何在核心之间分配,这实际上就是并发编程所做的。它是在说:“看,这是一大段代码。你可以把这个放在一个核心上,那个放在另一个核心上,另一个放在另一个核心上,这些东西可以一起运行。”这就是你在进行并发编码时程序所做的事情。你必须这样做,因为实际上……有趣的是,有一种叫做并行编译器的东西,这是一个大的研究领域(或者说曾经是,现在仍然是)。它们应该做的是,它们接受顺序代码或常规的C程序(或任何语言),然后将其并行化。它们自动为你进行并发编程。它们会说:“看,这是一大段代码。我要把它分解成这些可以并发运行的不同任务。”这是一个极其困难的问题,而且基本上……我讨厌这么说,但它效果不是很好。我知道这么说会得罪一批研究人员,但它确实效果不是很好。因此,既然无法轻易自动化,就需要程序员实际去做。这就是并发编程,程序员在说:“看,我可以用以下方式分解这个任务。”这很困难,但现在很重要,因为如果程序员不做,那就不会完成,所有东西都运行在一个核心上,那么拥有四个核心又有什么意义呢?


本节课中我们一起学习了:Go语言内置并发的意义、并行执行与并发的区别、硬件加速的极限(冯·诺依曼瓶颈)、以及功耗墙如何最终推动了多核处理器和并发编程的普及。理解这些背景知识是掌握Go语言并发模型的基础。

057:并发与并行

在本节课中,我们将学习并发与并行这两个核心概念的区别,理解为何并发编程在Go语言中至关重要,以及它如何提升程序性能,即使在没有并行硬件的情况下。


并发与并行的定义

上一节我们介绍了并行执行的概念,本节中我们来看看“并发”与“并行”的具体区别。它们是相关的概念,但存在细微差别。我们主要进行的是并发编程。

并发执行不一定意味着同时执行。如果两个任务的开始时间和结束时间在时间线上有重叠,我们就称这两个任务是并发的。这并不要求它们在同一精确时刻执行。

并行执行则要求任务必须在同一时刻执行。这意味着它们需要同时运行在不同的硬件(通常是不同的CPU核心)上。

为了更清晰地理解,请看以下图示:

  • 左侧图示展示了并发但非并行的执行。任务1和任务2的执行时间段有重叠,但在任何一个时间点上,只有一个任务在执行。
  • 右侧图示展示了并发且并行的执行。任务1和任务2不仅在时间段上重叠,而且在中间区域是真正同时执行的。

并行执行通常能带来更短的总完成时间。一个自然的问题是:如果并发执行不能同时进行,为什么不简单地按顺序执行任务呢?这样做总时间不是一样吗?实际上,并发编程有其独特的优势,我们接下来会探讨。


硬件映射:程序员不直接控制

理解并发与并行的关键在于“硬件映射”这个概念。这是指将任务分配给具体硬件核心执行的过程。

以下是关于硬件映射的几个关键点:

  • 并行任务必须运行在不同的硬件核心上才能实现真正的并行。
  • 并发任务可以运行在同一个硬件核心上(通过交替执行),也可以运行在不同的核心上。
  • 在Go语言以及大多数现代编程语言中,程序员并不直接控制硬件映射。程序员定义的是哪些任务可以并行执行,而哪些任务实际会并行执行则取决于操作系统和Go运行时调度器如何将任务映射到可用的硬件核心上。

你可能会问,如果我的电脑只有一个核心,无法实现真正的并行,为什么还要进行并发编程呢?答案是:并发编程能通过“隐藏延迟”来显著提升性能。


隐藏延迟:并发的性能优势

即使在没有并行硬件的情况下,并发编程也能带来显著的性能提升。原因在于,任务经常需要等待一些“慢事件”完成,例如:

  • 从内存中读取或写入数据
  • 进行网络通信
  • 访问磁盘
  • 与显卡交互

这些输入/输出(I/O)操作相对于CPU的处理速度来说非常慢。例如,执行一条简单的加法指令 x = y + z 可能只需要1个时钟周期,但从内存中读取 yz 的值可能需要等待超过100个时钟周期。

隐藏延迟 的核心思想是:当一个任务(如任务1)因为等待I/O而暂停时,CPU不应该空闲。此时,调度器可以切换到另一个就绪的任务(如任务2)来执行。这样,在任务1等待的期间,任务2完成了有用的工作,从而提高了CPU的整体利用率,缩短了程序的总完成时间。

因此,并发编程的价值在于,它允许程序在等待慢速操作时继续执行其他工作,从而更高效地利用计算资源。


硬件映射的复杂性

为什么像Go这样的语言不把硬件映射的控制权交给程序员呢?因为这是一个极其复杂的问题。程序员通常不希望,也不应该处理这种底层细节。

请看一个简化的多核系统架构图:

在这个系统中,每个核心有自己的本地缓存,所有核心共享一个主内存。进行硬件映射时,需要考虑众多复杂因素:

  1. 数据位置:任务需要的数据在哪里?是在目标核心的本地缓存中,还是在其他核心的缓存或共享内存里?将任务安排在离其数据近的核心上能极大提升速度。
  2. 通信成本:核心与缓存、缓存与共享内存之间的数据传输速度差异巨大。
  3. 硬件异构性:不同机器的核心数量、缓存大小、内存架构都可能不同。

要求程序员在写代码时考虑所有这些硬件细节,会使编程变得异常复杂和困难,且代码将严重依赖于特定硬件,丧失可移植性。因此,这个艰巨的任务被交给了操作系统和语言运行时(如Go的调度器),它们能自动、高效地处理硬件映射。


总结

本节课中我们一起学习了:

  • 并发并行的核心区别:并发是任务时间段的重叠,并行是任务在同一时刻执行。
  • 程序员在Go中进行并发编程时,定义的是任务间可能的并行关系与通信同步机制,而非具体的硬件映射。
  • 硬件映射(决定哪个任务在哪个核心上运行)由操作系统和Go运行时调度器负责,这对程序员是透明的。
  • 并发编程的核心优势在于隐藏I/O延迟,即使在没有多核的硬件上,也能通过让CPU在等待时执行其他任务来提升性能。
  • 硬件映射本身是一个复杂问题,涉及数据位置、通信成本等,因此被封装在语言运行时中,以简化编程并保证可移植性。

058:并发基础

在本模块中,我们将学习并发编程的基础概念。我们将从操作系统层面的进程和线程开始,逐步深入到Go语言特有的goroutine,理解它们如何实现并发执行。

2.1.1:进程 🖥️

上一节我们介绍了本模块的主题,本节中我们来看看并发的基础——进程。

进程本质上是一个正在运行的程序实例。每个进程都拥有一些独特的资源。

以下是每个进程独有的组成部分:

  • 内存空间:每个进程拥有自己独立的虚拟内存地址空间。
  • 代码:进程执行自己的程序代码。
  • :用于处理函数调用的内存区域。
  • :用于动态内存分配的内存区域。
  • 寄存器:存储程序当前状态的小型高速存储器,例如:
    • 程序计数器:指向下一条要执行的指令。
    • 数据寄存器:存储计算中的临时数据。
    • 栈指针:指示当前栈的位置。

这些独特的资源集合被称为进程的上下文。操作系统的主要职责之一就是允许多个进程并发执行,同时确保它们互不干扰。例如,不同进程可能访问相同的虚拟地址(如地址1000),但操作系统必须确保它们访问的是各自物理内存中不同的位置。

此外,操作系统需要公平地为进程分配处理器时间。这个过程称为调度。在单核系统上,操作系统通过快速地在进程间切换(例如,每个进程运行20毫秒),给用户造成它们“同时”运行的假象。操作系统还需要管理其他资源,如内存和I/O设备,确保所有进程都能及时完成。

你可以通过系统工具查看正在运行的进程。在Windows上,可以通过任务管理器查看;在Linux或macOS上,可以在命令行中输入 ps 命令。

2.1.2:调度 ⏱️

上一节我们介绍了进程的概念,本节中我们来看看操作系统如何调度进程以实现并发。

操作系统的核心任务之一是调度,即决定在哪个时间点运行哪个进程。假设我们有三个进程需要运行。

以下是一个简化的调度过程图示(以时间片轮转算法为例):

  1. 进程1运行一个时间片。
  2. 切换到进程2运行一个时间片。
  3. 切换到进程3运行一个时间片。
  4. 再次切换回进程1,如此循环。

这种让每个进程轮流获得相等时间片的算法称为轮转调度。然而,实际的调度算法可能更复杂,会考虑进程的优先级。例如,在汽车系统中,防抱死刹车进程的优先级远高于播放音乐的进程。当高优先级任务就绪时,操作系统会暂停低优先级任务。

当操作系统从一个进程切换到另一个进程时,会发生上下文切换。这个过程包括:

  1. 保存当前运行进程的上下文(所有寄存器状态、内存映射信息等)到内存中。
  2. 将下一个要运行进程的已保存上下文从内存加载到寄存器和系统中。
  3. 开始执行下一个进程。

上下文切换由操作系统的内核代码执行。通常,操作系统会为每个进程设置一个计时器(如20毫秒)。当计时器中断触发时,内核接管,执行上下文切换,然后启动下一个进程。

2.1.3:线程与Goroutine 🧵

上一节我们介绍了进程调度,本节中我们来看看更轻量级的并发执行单元——线程,以及Go语言中的goroutine。

早期操作系统只有进程。进程间上下文切换开销较大,因为需要保存和恢复大量独享资源。为了提升效率,引入了线程,或称“轻量级进程”。

线程与进程的关键区别在于资源共享。一个进程可以包含多个线程。

以下是线程与进程上下文的对比:

  • 进程独有上下文:虚拟内存空间、文件描述符等。
  • 线程独有上下文:栈、程序计数器、数据寄存器等。
  • 线程共享上下文:同一进程内的线程共享虚拟内存空间、文件描述符等。

由于线程间共享大量上下文,在同一进程内切换线程比在不同进程间切换要快得多。现代操作系统调度器通常直接调度线程,而非整个进程。

现在,我们来讨论Go语言。Go使用goroutine来实现并发。Goroutine本质上是Go语言层面的线程。

多个goroutine可以在单个操作系统线程中并发执行。从操作系统视角看,它只调度那个承载Go程序的“主线程”。而在Go程序内部,Go运行时调度器负责在这些goroutine之间进行切换,决定哪个goroutine在何时运行。

Go运行时使用逻辑处理器的概念。默认情况下,一个Go程序使用一个逻辑处理器,它映射到一个操作系统线程。所有goroutine在这个线程上并发运行。

然而,Go也支持利用多核进行真正的并行计算。程序员可以通过设置 GOMAXPROCS 环境变量或调用 runtime.GOMAXPROCS(n) 来指定使用的逻辑处理器数量。例如,在一个4核机器上,可以设置4个逻辑处理器。Go运行时调度器可以将goroutine映射到不同的逻辑处理器,每个逻辑处理器可以映射到不同的操作系统线程,最终由操作系统将这些线程调度到不同的物理核心上执行。

总结
本节课中我们一起学习了并发编程的基础。我们从操作系统层面的进程和线程开始,理解了上下文和调度的概念。最后,我们深入探讨了Go语言特有的goroutine,以及Go运行时调度器如何管理它们,并介绍了如何通过设置逻辑处理器来利用多核进行并行计算。这些概念是理解Go并发模型的核心基础。

059:并发基础

概述

在本节课中,我们将要学习并发编程的基础概念,特别是指令交错竞态条件。理解这些概念是编写正确、可靠的并发程序的关键。


2.1:指令交错 🧵

上一节我们介绍了并发的基本概念,本节中我们来看看为什么并发编程如此困难。核心原因在于,我们很难在脑海中构建程序在任意时刻的确切状态模型。

编写顺序代码时,如果程序在第10行崩溃,我们可以确定第9、8、7行等已经执行完毕。执行顺序是明确的,这有助于我们推断程序崩溃时的状态,从而进行调试。

然而,对于并发代码,要掌握机器的整体状态则困难得多。程序可能在任务1的第10行崩溃,但任务2、3、4、5可能处于不同的执行点。因此,机器的整体状态是非确定性的。即使每次都在任务1的同一行崩溃,任务2、3、4的进度也可能每次都不同,导致每次运行时的整体系统状态都不同。这使得我们难以在脑海中确定某个变量应该是什么值,因为不确定其他任务是否已经执行了某些操作。

为了展示这种复杂性,我们需要理解指令交错的概念。它指的是两个不同任务中指令的执行顺序。

每个任务内部的指令执行顺序是已知的。例如,任务1有三条指令,按1、2、3的顺序执行;任务2也有三条指令,按1、2、3的顺序执行。但是,并发任务之间的执行顺序是未知的、非确定的,每次运行都可能不同。

这意味着这些指令可以以多种方式交错执行。例如,可能先执行任务1的第一条指令,然后执行任务2的第一条指令,接着是任务1的第二条指令,以此类推。这只是一种可能的交错方式,但存在许多种可能性。

以下是几种可能的指令交错示例:

  • 交错方式A:任务1指令1 -> 任务2指令1 -> 任务1指令2 -> 任务2指令2 -> 任务1指令3 -> 任务2指令3
  • 交错方式B:任务1指令1 -> 任务1指令2 -> 任务1指令3 -> 任务2指令1 -> 任务2指令2 -> 任务2指令3

每次运行程序时,都可能得到不同的交错顺序。这使得程序员在思考系统正确性时,必须考虑所有这些可能的交错情况。虽然有一些技术可以最小化这种影响,但推理系统行为时仍需考虑多种不同的交错。

需要指出的另一点是,交错问题甚至更加复杂,因为交错并非发生在Go源代码的层面,而是发生在机器代码指令的层面。

例如,Go代码中的一条简单指令 a = b + c,在机器代码层面可能对应着多条指令:从内存加载b、加载c、将它们相加、然后将结果存储到a。交错可能发生在这四条机器指令之间。

这意味着,我们甚至不能保证任务1中的第一条指令(在源代码层面)会在任务2的第一条指令开始之前完全执行完毕。任务1可能只执行了其第一条指令对应的前两条机器指令,然后就被切换到任务2。因此,交错甚至发生在这些源代码指令的内部,这使得掌握所有可能性变得更加困难。


2.2:竞态条件 🏁

上一节我们介绍了指令交错带来的复杂性,本节中我们来看看由此引发的一个具体问题:竞态条件

竞态条件是由于需要考虑所有可能的指令交错而产生的问题。从技术上讲,竞态条件通常被定义为:程序的运行结果依赖于非确定的指令交错

请记住,指令交错是非确定的,它由操作系统和Go运行时决定,每次运行都可能改变。因此,如果程序的结果依赖于这种非确定的交错,那么程序本身就是非确定的。对于一个程序,给定一组输入,我们通常期望它总是产生相同的输出,这就是确定性。如果程序有时产生一个结果,有时产生另一个结果,这几乎总是一个错误,是我们不希望看到的。

由于指令交错是非确定的,我们必须确保程序的结果不依赖于这些交错。如果依赖,就产生了竞态条件。

这里有一个简单的示例:

  • 任务1
    1. x = 1
    2. x = x + 1
  • 任务2
    1. print(x)

考虑两种不同的交错:

  1. 交错顺序Ax = 1 -> print(x) -> x = x + 1。此时打印出的 x1
  2. 交错顺序Bx = 1 -> x = x + 1 -> print(x)。此时打印出的 x2

这就是一个竞态条件。程序的输出(打印1还是2)依赖于非确定的指令交错,导致输出是非确定的,这基本上意味着程序是有问题的。因此,我们需要避免竞态条件。

竞态条件源于任务(或Go例程)之间的通信。在上述例子中,两个任务通过共享变量 x 进行通信:任务1向 x 写入数据(赋值),任务2从 x 读取数据(打印)。如果两个任务之间没有通信,它们的执行顺序完全独立,那么就不会产生竞态条件,因为交错顺序不会影响各自的结果。

然而,当任务之间存在某种通信时,哪个任务先写入共享变量、哪个任务后读取共享变量就变得至关重要。因此,通信是竞态条件的根源。

在并发编程中,不同任务之间的通信非常普遍。线程(或在Go中称为goroutine)在很大程度上是独立的,这正是我们将任务拆分为不同线程的原因——我们认为它们可以并发执行,而不必关心谁先谁后。

但它们并非完全独立。如果多个线程属于同一个进程,它们会共享信息(如虚拟地址空间),因此线程之间通常存在某种程度的信息共享,这种共享就是通信。

以下是两个需要通信的并发应用示例:

1. Web服务器
Web服务器是一个典型的多线程应用。服务器为每个连接的客户端创建一个线程来处理请求。这些线程大部分是独立的(处理不同客户端的请求),但它们会共享数据,例如:

  • 多个客户端可能请求同一个网页。
  • 客户端可能向服务器提交数据(如表单),从而修改服务器状态(如网页访问计数器)。一个客户端写入(增加计数器),后续的客户端需要读取更新后的值。这就构成了线程间的通信。

2. 图像处理
图像处理任务通常是“令人尴尬的并行”任务。例如,对一个百万像素的图片进行模糊处理,可以将像素分块,由不同的线程并行处理每个块。

  • 核心公式/操作:模糊处理通常涉及对每个像素及其周围像素值进行加权平均。
  • 然而,处理一个像素的线程可能需要访问其邻居像素的值,而这些邻居像素可能正由其他线程处理。因此,线程之间需要共享边界像素的信息,这就产生了通信。

如果线程之间通信过于频繁,可能就不应该将它们拆分为独立的线程。但如果它们大部分时间独立,只是偶尔需要通信,那么使用多个goroutine就非常合理,因为它们大部分时间可以并发执行。

关键在于,如果通信处理不当,就可能成为竞态条件的源头。不同的指令交错可能导致不同的结果,这正是并发编程具有挑战性的部分原因。


总结

本节课中我们一起学习了并发编程的两个核心基础概念:

  1. 指令交错:并发任务中指令的执行顺序是非确定的,可以多种方式交织,这增加了理解和推理程序状态的难度。
  2. 竞态条件:当程序的正确性依赖于非确定的指令交错时,就会发生竞态条件,导致程序产生非确定性的、通常是错误的结果。竞态条件源于并发任务之间通过共享数据进行的通信。

理解这些概念是后续学习如何使用Go语言提供的机制(如通道和互斥锁)来安全地管理并发和避免竞态条件的基础。

060:Go协程

概述

在本节课中,我们将要学习Go语言中实现并发执行的核心概念——Go协程。我们将了解如何创建Go协程,它们如何与主协程交互,以及为什么不能依赖简单的延时来协调协程的执行。


3.1.1:Go协程

在之前的模块中,我们讨论了并发与并行、进程与线程的工作原理,以及它们如何(至少在硬件允许的情况下)并行执行。但我们尚未真正探讨如何在软件中,特别是在Go语言中,实现这些概念。

为了创建多个执行线程,我们必须使用Go语言内置的特定编程结构。这是Go语言的一大优势,它内置了许多易于使用的并发结构。例如,如果你想创建一个Go协程(一种可以与其他协程并发运行的线程),方法很简单。

一个Go协程总是被自动创建以执行main函数。因此,即使你不做任何特殊操作,运行main函数时也会创建一个协程并在其中执行main。其他协程则使用go关键字创建。

以下是一个示例对比:

  • 在左侧示例中,只有一个主协程main。它顺序执行三条指令:a = 1,调用函数f(),然后a = 2。函数调用f()会阻塞主协程,意味着a = 2必须等待f()完全执行完毕后才能运行。
  • 在右侧示例中,我们使用go f()创建了一个新的协程来执行函数f()。此时存在两个协程:主协程和新创建的子协程。主协程执行a = 1后,创建新协程执行f(),然后可以立即执行a = 2,而无需等待f()完成。a = 2可能在f()执行期间、之前或之后运行,具体时机取决于Go运行时调度器的安排。

上一节我们介绍了如何启动Go协程,方法非常简单:使用go关键字后接你想要执行的函数名或函数定义,该协程就会执行与该函数关联的代码。

通常,一个Go协程在其代码执行完毕时退出。也就是说,当它完成所执行函数的任务并返回时,该协程就会退出。


3.1.2:Go协程的退出

我们已经描述了Go协程在完成其代码后会如何退出。但同时,如果主协程提前退出,那么所有被创建的协程都会被强制退出。这意味着它们可能在完成自己的工作之前就提前终止了。

这一点在编写协程时需要特别注意,因为你创建的协程可能因为主协程先完成而无法执行完毕。你可能会遇到这样的情况:编写了代码,但协程似乎没有按预期执行,原因可能就是主协程在协程完成之前就结束了。

让我们来看一个代码示例。请注意,在这些代码片段中,我省略了导入包的语句,但你可以假设必要的导入(如fmt)已经存在。

func main() {
    go fmt.Printf("New routine")
    fmt.Printf("Main routine")
}

在这段main函数中,它首先创建了一个新的协程,该协程应该执行一个Printf打印"New routine"。然后,主协程也会打印"Main routine"。你期望在屏幕上看到"Main routine"和"New routine"两条输出,但它们的顺序不确定,因为调度器可能先调度新协程,也可能先调度主协程。

然而,当我执行这段代码时,只打印出了"Main routine"

为什么会这样?这是因为主协程在新协程开始执行之前就结束了。理论上,我们不知道Go运行时调度器会如何工作,它可能让主协程先运行,也可能让新协程先运行。但在这个例子中,每次运行都只打印主协程的内容。我推测调度器可能优先给了主协程,但这一点无法保证,并且可能随Go版本改变。我们不能依赖这种顺序。

显然,这里发生的情况不符合预期。我需要让主协程不要这么快退出,我希望它能以某种方式等待另一个协程退出,以便能打印出"New routine"。

首先,我将展示一种方法,但这实际上是一种取巧的做法。我展示它是为了让你知道不应该这样做,虽然它有时能工作,但并不安全。

func main() {
    go fmt.Printf("New routine")
    time.Sleep(100 * time.Millisecond) // 新增的延时
    fmt.Printf("Main routine")
}

我添加了高亮显示为红色的time.Sleep行,让主协程睡眠100毫秒。在这段睡眠期间,主协程无法运行,Go运行时调度器很可能会调度我刚刚创建的新协程去执行,打印出"New routine"。然后主协程醒来,打印"Main routine"。这样我就能看到"New routineMain routine"的输出(因为没有空格)。

这是我为了让主协程存活足够长时间以使新协程完成执行的取巧尝试。它在我的机器上能稳定工作,但你不应该这样做

为什么添加这样的延时来等待协程是糟糕的做法?原因在于你做了关于时间的假设,而你的时间假设可能是错误的。

在这个例子中,我做了几个重大假设:

  1. 我假设在主协程中注入的100毫秒延时足以确保新协程有时间执行。
  2. 我假设了调度器的特定行为:如果我强制主协程延迟100毫秒,Go运行时调度器就一定会调度我创建的那个协程。
  3. 我还对操作系统做了假设。运行Go代码的操作系统线程可能在这100毫秒内被换出上下文,转而执行一个完全不同的线程(例如机器上正在运行的PowerPoint)。

虽然目前这些假设没有导致问题,但做出这些假设是不安全的。这些假设可能因操作系统或Go运行时的版本更新而改变。依赖这样的定时是不可靠的,因为定时本身是不确定的——每次运行程序,时间都可能不同。

如果你依赖定时,最终可能会导致出现非常棘手的间歇性错误:大多数时候程序运行正常,但偶尔你的时间假设会被违反,错误就会发生。这类错误是最难调试的之一,因为你可能无法稳定复现。

因此,你不应该依赖定时。你需要使用正式的同步构造,我们将在后续课程中讨论这些内容。


总结

本节课中我们一起学习了Go协程的基础知识。我们了解到:

  • 使用 go 关键字可以轻松创建新的并发执行单元。
  • 主协程退出会导致所有其他协程被强制终止。
  • 不能依赖 time.Sleep 这类延时操作来协调协程,因为这会引入不可靠的时间假设,导致潜在的、难以调试的间歇性错误。
  • 协调并发执行需要依赖接下来将要学习的正式同步机制。

061:线程与Go

概述

在本节课中,我们将要学习Go语言中的基本同步概念,特别是如何使用sync.WaitGroup来协调多个goroutine的执行顺序,确保程序按预期运行。


模块3:线程与Go,主题2.1:基本同步

同步是指多个线程在某个事件的时序上达成一致。

当使用多个线程或goroutine时,每个goroutine通常不了解其他goroutine的执行时序。它们各自独立执行,不关心其他goroutine执行到哪一行代码。

同步打破了这种独立性。同步意味着创建一个所有线程或goroutine都能同时观察到的全局事件。这有助于限制指令交错执行的可能性,从而控制执行顺序。

为了理解同步的重要性,请看以下示例。这个例子展示了两个不同goroutine之间相对顺序的重要性,因此它们需要在某些事件上同步。

左侧表格代表一个简单的线程,它执行两条指令:x = 1x = x + 1。右侧表格代表另一个线程,它只执行一条指令:print(x)

下图展示了两种可能的指令交错执行情况。还有其他可能的交错,这里只展示两种。

在第一种交错中,print(x)发生在左侧线程的第一条指令之后,但在第二条指令之前。此时打印的x等于1。
在第二种交错中,print(x)发生在左侧线程的第二条指令之后。此时打印的x等于2。

这里展示的是一个竞态条件:打印的输出结果取决于指令的交错顺序。而指令的交错顺序是非确定性的。虽然Go运行时调度器和操作系统调度器本身是确定性的程序,但从程序员的角度看,由于不了解其具体算法和可能的中断等因素,调度是非确定性的。

假设程序员的意图是让print(x)发生在x = x + 1这条更新指令之后。这意味着第二种交错是符合预期的,而第一种则不符合。

由于我们无法控制它们的相对顺序(这完全由调度器决定),我们无法控制发生哪一种交错。同步机制允许我们控制这一点。我们需要某种全局事件,使得第一种交错不可能发生。

以下是同步的简单示例。我们有两个相同的任务。任务一(左侧)执行x = 1x = x + 1。任务二(右侧)执行print(x)。但现在我们引入了一个全局事件。

在任务一中,执行完x = x + 1后,它触发这个全局事件。这个事件被所有任务或线程(包括任务二)观察到。
在任务二中,它检查:如果全局事件已发生,则打印x

这样,任务二中的print(x)在任务一触发全局事件之前不能发生。这限制了一些可能的交错顺序。现在,print(x)必须等待x = x + 1执行之后。x = x + 1必须在全局事件之前发生,而print(x)必须在全局事件之后发生,因此顺序受到了限制。

这就是同步的一个例子。在这个例子中我们需要同步,因为我们的意图要求print(x)在任务一的某些指令之后发生。实际编程中有很多类似的例子,某些事件必须以特定顺序发生。

需要注意的是,这与并发是相反的。并发和并行的美妙之处在于交错顺序是任意的,甚至是同时运行的。即使不是并行,只是并发,顺序也是任意的。我们不关心顺序,这给我们带来了很多优势,例如可以加速代码执行。如果一个线程被阻塞等待,由于顺序无关紧要,调度器可以在此期间切换到另一个线程执行。我们不限制调度,从而获得了优化和速度提升。

但通过使用同步,我们限制了调度。需要理解的是,每次使用同步,都会降低效率,减少可能的并发量。我们减少了调度器的选择,调度器可能无法那么有效地利用硬件。有时可能会因为等待同步事件而导致没有任务可以执行。

总的来说,同步会降低性能和效率,但在某些情况下是必要的,比如必须限制某些操作的执行顺序。同步是一个必要之恶。同时,使用同步可能很复杂,尽管Go语言提供了相对简单的结构使其比在其他系统(如Pthreads)中更容易使用。这只是一个同步的例子,它不好但必要。


模块3:线程与Go,主题2.2:WaitGroup

WaitGroup是一种特定类型的同步机制,很常见。

我们现在开始讨论sync包。你需要导入sync包,它包含了WaitGroup。WaitGroup对于同步很有用,我们稍后会介绍sync包的其他部分,但现在让我们使用WaitGroup来解决我们之前遇到的问题:主goroutine在它启动的goroutine完成之前过早结束。

sync包内置了许多不同的同步方法和函数。sync.WaitGroup的作用是强制一个goroutine等待其他goroutine完成。你可以将WaitGroup视为一组goroutine,你的goroutine正在等待这组goroutine,并且在你的goroutine继续执行之前,WaitGroup中的所有goroutine都必须完成。

这正是我们之前示例所需要的。回想一下,主goroutine打印一条消息,然后新的goroutine打印另一条消息,但新goroutine的消息从未被打印,因为主goroutine在新goroutine有机会执行之前就执行完毕并退出了,这迫使新goroutine也退出而无法完成。我们想要做的是让主goroutine等待,直到它创建的新goroutine完成退出,然后主goroutine再继续。这样,我们就能确保新goroutine在主goroutine完成之前有机会实际执行。

这就是我们想要的:我们想让一个goroutine(本例中是主goroutine)等待另一个goroutine。WaitGroup是一个通用的对象及其关联的方法集。你可以等待任意多个goroutine。你可以让一个goroutine等待10个不同的goroutine,直到它们全部完成。在我们的例子中,我们只等待一个,但要理解这是可推广的。

sync.WaitGroup对象包含一个内部计数器(通常称为计数信号量)。你可以将其视为一个计数器,初始值为0。

对于每个你想要等待的goroutine,你递增这个计数器。例如,如果你想等待3个goroutine,你就递增计数器3次,使其变为3。
然后,当每个goroutine完成时,你递减这个计数器。如果计数器是3,你正在等待3个goroutine,每当一个goroutine完成,计数器就从3减到2,再到1,再到0。
等待的goroutine(例如主goroutine)正在等待这些goroutine,它不能继续,直到计数器降为0,这意味着所有不同的goroutine都已完成。

这就是sync.WaitGroup对象的工作原理。它有一组关联的方法来实现这个功能。

以下是这些方法的说明:

  • Add方法:递增计数器。你为每个想要等待的goroutine调用一次Add。你可以传递一个参数,例如Add(3),会将计数器增加3。
  • Done方法:递减计数器。在每个被等待的goroutine结束时,需要执行Done来将计数器减1。
  • Wait方法:在等待的goroutine(如主goroutine)中调用。Wait会阻塞,直到计数器等于0。如果计数器大于0,意味着有些被等待的goroutine仍在执行,因此必须等待。一旦计数器降为0,Wait调用完成,等待的goroutine可以继续执行之后的代码。

让我们看一个示意图。左侧代表主线程(主goroutine),右侧代表F线程(新创建的goroutine)。

在主线程中:

  1. 首先,创建一个WaitGroup变量,例如var wg sync.WaitGroup
  2. 然后,调用wg.Add(1),因为我知道我的主线程要等待1个goroutine。
  3. 接着,创建新的goroutine:go F(),并将WaitGroup(的指针)传递给F,因为F需要在结束时调用Done
  4. 最后,主线程调用wg.Wait()。此时,主线程会阻塞在这一行,直到我们等待的F线程实际完成。

F线程中:

  1. 它被创建并开始执行其代码。
  2. 无论它做什么,在其最后,它调用wg.Done()。这将递减WaitGroup内部的计数器。
  3. 在这个例子中,我们在主线程中通过Add(1)将计数器加到了1。当F线程调用Done()时,计数器减为0。
  4. 此时,主线程中的wg.Wait()调用将完成,主线程可以继续执行Wait之后的代码。

这是一个使用WaitGroup的例子。在本例中,我们只等待一个线程(F goroutine),但我们可以通过增加Add的次数来等待任意多个。

作为程序员,你必须确保正确使用这些方法:

  • 你必须在想要等待的goroutine(如主goroutine)中,在创建新goroutine之前调用Add,并将其递增到你要等待的goroutine数量。
  • 你必须确保你等待的每一个新线程(goroutine)在结束时都调用Done方法。这必须在它们完成时调用,不能提前。通常人们使用defer来确保它在函数退出时发生。
  • 你必须在等待的goroutine(如主goroutine)中调用Wait。如果你不调用Wait,就不会发生任何等待行为。

程序员的责任是在正确的位置插入AddDoneWait调用,以使整个机制正常工作。

以下是一个稍作修改的打印问题的示例。在简单版本中,主函数创建了执行F的新goroutine。

查看主函数,它调用了go F()。我添加了一些代码:之后它应该打印"main routine",而F应该打印"new routine"。

F函数中,它打印"new routine",但我添加了wg.Done()这一行,所以它必须调用Done。这是我必须添加的内容。

在主函数中,我添加了以下内容:

  1. 定义WaitGroup:var wg sync.WaitGroup
  2. 调用wg.Add(1),因为我知道将有一个新的goroutine需要等待。
  3. 然后创建goroutine:go F()
  4. 接着调用wg.Wait(),这样我的主goroutine将实际等待这个goroutine完成。
  5. 只有在F goroutine完成其工作并打印了"new routine"之后,主goroutine才会继续执行并打印"main routine"。

通过这种方式,主goroutine不会过早结束,从而导致F goroutine永远无法完成。现在,主goroutine在F goroutine完成并打印"new routine"之前,不会打印"main routine"并退出。


总结

本节课中我们一起学习了Go语言中的基本同步概念。我们了解到,当多个goroutine需要协调执行顺序时,同步是必要的,尽管它会限制并发性并可能影响性能。我们重点介绍了sync.WaitGroup这一同步工具,它通过内部计数器机制,允许一个goroutine等待一组其他goroutine完成。我们学习了AddDoneWait三个核心方法的使用方式,并通过示例理解了如何正确应用WaitGroup来确保主goroutine等待其创建的子goroutine完成,从而避免程序非预期地提前结束。正确使用同步机制是编写可靠并发程序的关键。

062:Go程通信 🧵

在本节课中,我们将要学习Go程之间如何进行通信。到目前为止,我们已经讨论了如何创建Go程以及一些同步操作,比如等待Go程退出。然而,Go程之间有时也需要进行通信。通常,Go程会协同工作以完成一个更大的任务,这意味着它们并非完全独立,而是需要交换信息来协作。

为什么需要通信? 🤔

上一节我们介绍了Go程的基本概念,本节中我们来看看它们为何需要通信。Go程通常作为更大程序中的半独立部分运行。例如,在构建一个Web服务器时,通常会采用多线程(或多Go程)模型。每当一个新的浏览器连接到服务器,就会创建一个新的Go程来处理与该浏览器的通信。虽然每个连接的处理是独立的,但它们都服务于同一组网页数据,因此这些Go程之间需要共享和交换数据。

通信示例:计算乘积 🧮

为了理解通信机制,我们来看一个简单的例子。假设我们需要计算四个整数的乘积,并决定使用两个Go程来并行计算两对整数的乘积,然后由主Go程汇总结果。

以下是实现此逻辑的步骤:

  1. 主Go程创建两个子Go程。
  2. 每个子Go程接收两个整数并计算其乘积。
  3. 子Go程将计算结果发送回主Go程。
  4. 主Go程接收这两个结果,计算最终乘积并输出。

在这个流程中,数据需要从主Go程流向子Go程(传递待计算的整数),也需要从子Go程流回主Go程(返回计算结果)。

通道:Go程通信的桥梁 🌉

Go程之间的通信通过通道完成。通道是类型化的,用于在Go程之间传递特定类型的数据。

你可以使用 make 函数创建一个通道。例如,创建一个传递整数的通道:

c := make(chan int)

通道使用箭头操作符 <- 来发送和接收数据:

  • 发送数据到通道:c <- 3 (将整数3发送到通道c)
  • 从通道接收数据:x := <- c (从通道c接收数据并赋值给变量x)

以下是完整的示例代码,演示了如何使用通道完成上述乘积计算任务:

package main
import "fmt"

// 子Go程函数,计算v1和v2的乘积,并通过通道c发送结果
func prod(v1 int, v2 int, c chan int) {
    c <- v1 * v2
}

func main() {
    // 创建一个整数通道
    c := make(chan int)
    // 启动两个Go程,并传入它们需要计算的数据及共享的通道
    go prod(1, 2, c)
    go prod(3, 4, c)
    // 从通道接收两个子Go程的计算结果
    a := <-c
    b := <-c
    // 主Go程计算并输出最终乘积
    fmt.Println(a * b)
}

需要注意的是,在启动Go程时,通过函数参数传递数据(如 1, 2, c)是另一种初始数据传递方式。但在Go程启动后的任何时刻,如果需要进行数据交换,就必须使用通道。

通道的阻塞行为 🚧

默认情况下,通道是无缓冲的。这意味着通道本身没有存储空间,数据的发送和接收操作是同步且阻塞的。

无缓冲通道的阻塞规则如下:

  • 发送操作会阻塞,直到另一个Go程执行了对应的接收操作。
  • 接收操作也会阻塞,直到另一个Go程执行了对应的发送操作。

这种机制确保了数据不会在传输中丢失,同时也提供了同步功能。例如,即使接收方丢弃数据(<-c 而不赋值),发送和接收操作仍然会同步两个Go程的执行,这可以作为一种简单的等待机制。

缓冲通道:提升并发灵活性 ⚡

为了减少因速度不匹配导致的阻塞,Go提供了缓冲通道。缓冲通道拥有一定的容量,可以在其中暂存一定数量的数据。

创建缓冲通道时,需要指定其容量:

c := make(chan int, 3) // 创建一个能缓冲3个整数的通道

缓冲通道的阻塞行为有所不同:

  • 发送操作仅在通道缓冲区已满时才会阻塞。
  • 接收操作仅在通道缓冲区为空时才会阻塞。

缓冲通道的典型应用场景是生产者-消费者模型。在这种模型中,一个Go程(生产者)生成数据,另一个Go程(消费者)处理数据。如果生产者和消费者的处理速度存在短暂的不匹配,缓冲区可以平滑这种差异,允许两者继续执行而不必立即相互等待,从而提高了程序的整体并发性能。

总结 📚

本节课中我们一起学习了Go程通信的核心机制——通道。

  • 我们了解了Go程需要通过通信来协作完成复杂任务。
  • 我们学习了如何使用 make(chan type) 创建通道,并使用 <- 操作符进行数据的发送和接收。
  • 我们探讨了无缓冲通道的同步与阻塞特性。
  • 最后,我们介绍了缓冲通道,它通过提供临时存储空间,允许生产者和消费者以略有差异的速度运行,从而增强了程序的并发灵活性。

掌握通道是编写高效、正确并发Go程序的关键。

063:同步通信

概述

在本节课中,我们将要学习Go语言中通道(channel)的高级用法,包括如何使用for range循环持续从通道接收数据,以及如何使用select语句处理多个通道的读写操作。这些是构建并发程序时实现同步通信的核心技术。


4.1.1:通道上的阻塞操作

上一节我们介绍了通道的基本概念,本节中我们来看看如何持续地从通道接收数据。

一个常见的通道操作是遍历通道。这意味着迭代地从通道中读取数据。这通常发生在生产者-消费者场景中:消费者从一个通道持续接收数据,并以某种方式处理这些数据。这是一个非常常见的模式:接收数据、处理数据、再接收数据、再处理数据,只要程序在运行,这个过程就会持续下去。

有一个专门为此设计的结构:for range循环。我们已经见过for循环,但你可以使用range关键字来持续地从通道读取数据。

例如,我们可以这样写:

for i := range c {
    // 处理 i
}

这段代码会持续迭代。for循环会为通道c上接收到的每一个数据项执行一次循环体。每次有数据从通道接收时,变量i就会被赋值为该数据,然后执行花括号内的代码。这个循环会一直持续下去,直到通道被关闭(我们稍后会讨论)。

在这个简单的例子中,我们只是打印数据,但你可以在这里做任何处理数据的事情。这是一种非常常见的做法。

现在,这个for循环可能是一个无限循环。那么它何时结束呢?它会在通道被关闭时结束。这是通道的另一个方法:你可以关闭(close)一个通道。在上面的例子中,发送者(即向通道写入数据的一方)可以调用close(c)

当发送者关闭通道后,接收者会感知到这个关闭操作,for range循环就会结束。你并不总是需要关闭通道。它不像文件操作那样必须关闭。除非你正在使用for range结构来持续读取通道数据,否则不需要关闭通道。如果你使用了range关键字来持续读取,那么发送者需要关闭通道,以便接收者知道可以退出循环了。因此,只有当发送者确定不会再向该通道发送更多数据时,才应该调用close。这基本上是在向接收者发送一个信号,告知通道已关闭,可以跳出正在迭代的循环了。

这是一种从通道读取数据的常见方式。


处理多个通道

现在,我们来看看另一种情况:你可能需要从多个通道读取数据,这些通道可能与多个goroutine相关联。

以下是处理多个通道的不同场景。

需要所有通道的数据

在某些情况下,你需要从多个通道获取数据才能完成任务。例如,假设我们有三个goroutine:T1、T2和T3。T3需要从T1(通过channel1)和T2(通过channel2)接收数据。T3需要这两个通道的数据来计算两个数的乘积。

在这种情况下,你可以顺序地从两个通道读取:

a := <-c1
b := <-c2
fmt.Println(a * b)

T3需要来自c1c2两个通道的数据来完成其计算乘积的任务。这些读取操作是阻塞的。第一个读取会等待直到有数据写入c1,第二个读取会等待c2。最终,T3会从两个通道都读到数据并完成任务。这是一个需要多个通道数据的例子。

只需一个通道的数据(使用select

但有时你有一个选择:一个goroutine可以从不同的通道读取,但它不需要读取所有通道,只需要其中一个。这是一种“或”的关系:可以从这个通道或那个通道获取数据,但不需要全部。

如果你有选择,并且希望使用最先到达的数据(先到先服务),那么你就不想顺序地从所有通道读取。如果你顺序读取,你可能会在其中一个通道上被阻塞。例如,如果数据先到达c1,那么读取c1会成功,但随后对c2的读取可能永远不会发生,因为可能永远没有数据发送到c2。反之亦然。

在这种情况下,我们不希望在所有通道上等待,因为我们不知道数据会从哪个通道来。我们希望只等待其中一个,但我们不知道是哪一个。这时就需要使用select语句。

select语句允许你等待一组通道中的第一个数据到达。在select中,你可以有多个case。例如,第一个case等待c1,第二个case等待c2。这两个case中,哪一个先有数据到达,哪一个就会被执行。如果数据先到达c1,就执行第一个case并跳过第二个;反之亦然。


4.1.2:Select语句详解

上一节我们介绍了select的基本概念,本节中我们更深入地看看它的用法。

我们讨论了select如何让你从多个通道中选择数据,而不必在所有通道上阻塞。你可以只阻塞在最先有数据到达的那个通道上,从而更快地继续执行。

之前描述select时,我们假设是在阻塞接收数据。但实际上,select也可以用于阻塞发送数据。在select中,每个case可以是从通道接收数据,也可以是向通道发送数据。

例如,考虑以下代码:

select {
case a := <-inChan:
    fmt.Println("Received", a)
case outChan <- b:
    fmt.Println("Sent", b)
}

第一个case是从inChan通道接收数据并赋值给a。第二个case是向outChan通道发送数据b。这两个case都可能阻塞:如果没有人向inChan发送数据,读取会阻塞;如果没有人从outChan接收数据,写入也会阻塞。select的作用是,无论读写,哪个case先解除阻塞(即先就绪),就执行哪个case。如果inChan先有数据到达,就执行第一个case;如果outChan先变得可写(即有接收者),就执行第二个case。因此,select允许你选择发送或接收操作,并执行最先完成的那一个。


Select的常见用途

以下是select语句的一些常见使用模式。

1. 使用中止(Abort)通道

select的一个常见用途是设置一个独立的中止通道。假设你有一个不断重复执行的任务,例如在生产者-消费者场景中,消费者持续从通道接收并处理数据。

消费者的代码可能是一个无限for循环,在循环内部使用select。第一个case是从主通道c接收数据并处理(例如打印)。消费者会一直这样做:接收数据、处理数据。

但有时,你可能需要从外部中止这个循环。这时,你可以设置一个独立的abort通道。在select中加入第二个case来监听这个abort通道。大多数时候,循环都会执行第一个case,处理正常通道的数据。但一旦有数据发送到abort通道(例如,另一个goroutine收到了用户的“退出”指令),就会触发第二个case,从而执行return语句退出循环。

注意,在abortcase中,我们并不关心具体收到了什么数据(case <-abort),我们只关心有信号到达这个事实,这表示需要中止当前过程。

for {
    select {
    case a := <-c:
        // 处理数据 a
        fmt.Println(a)
    case <-abort:
        // 收到中止信号,退出循环
        return
    }
}

2. 使用默认(Default)分支

select的另一个特性是可以有一个default分支。这类似于switch语句中的default,但作用不同。

select语句中包含default时,它不会阻塞。它会检查所有case:如果没有任何一个case就绪(即没有通道可读或可写),那么它会立即执行default分支中的代码。

例如:

select {
case a := <-channel1:
    // 处理来自 channel1 的数据
case b := <-channel2:
    // 处理来自 channel2 的数据
default:
    // 没有通道就绪,执行其他操作(非阻塞)
    fmt.Println("No data ready.")
}

在这个例子中,如果channel1channel2都没有数据可读,程序不会等待,而是直接执行default分支,打印“No data ready.”。这常用于实现非阻塞的通道操作。


总结

本节课中我们一起学习了Go语言同步通信的高级主题。

  1. 我们学习了如何使用for range循环持续从通道读取数据,并了解到需要由发送者在适当时机关闭通道来优雅地结束循环。
  2. 我们探讨了从多个通道读取数据的两种场景:需要所有通道数据时顺序读取,以及只需一个通道数据时使用select语句实现非确定性选择。
  3. 我们深入了解了select语句,它不仅可以处理接收操作,还可以处理发送操作,并会执行最先就绪的case
  4. 我们介绍了select的两个常见模式:使用独立的中止通道来安全地退出循环,以及使用default分支来实现非阻塞的通道操作。

掌握这些技术对于编写高效、清晰的并发Go程序至关重要。

064:线程与Go

概述

在本节课中,我们将要学习Go语言中并发编程的一个重要概念:互斥。我们将探讨当多个Go协程共享变量时可能遇到的问题,并学习如何使用互斥锁来确保数据访问的安全性。


4.2.1:互斥 🛡️

上一节我们介绍了通过通道在Go协程间传递数据。本节中我们来看看共享变量。

在Go协程之间共享变量可能导致问题。两个Go协程向同一个共享变量写入数据时,可能会相互干扰。例如,一个协程尝试写入一个数字,另一个协程尝试写入另一个数字,这可能导致数据状态不一致。稍后我们将给出一个更具体的例子。

如果一个线程程序能够与其他Go协程并发执行,且不会以不当方式干扰其他Go协程,那么这个程序就被称为并发安全的。这意味着,当一个协程运行时,它不会以破坏其他协程的方式修改它们的变量。

具体来说,一个Go协程可能会干扰另一个Go协程的执行。例如,一个Go协程正在使用变量X,而另一个Go协程在第一个协程使用期间写入该变量X。第二个协程可能本意并非让第一个协程写入该变量。因此,第一个Go协程可能会干扰其他Go协程。

如果一个Go函数不会发生这种不安全的干扰,那么它就是并发安全的。

共享变量的问题示例

以下是变量共享的一个示例。这个程序的功能是递增一个变量I。

变量I初始化为0。在main函数中,我们创建两个线程(Go协程),每个线程都将I递增一次。因此,当它们执行完毕后,I应该等于2。

以下是代码结构:

  • 在顶部声明全局变量I和一个等待组。
  • increment函数执行i = i + 1,然后调用wg.Done()
  • main函数中,调用wg.Add(2),然后启动两个执行increment的Go协程,接着调用wg.Wait()等待它们完成,最后打印I。

我们期望打印出2,因为I从0开始,被递增了两次。但这并不总是发生。

问题根源:指令交错

初看之下,似乎没有问题,因为不同的执行顺序看起来都是合理的。但问题在于,并发发生在机器代码级别,而非源代码级别。

源代码中的一条指令(如i = i + 1)通常对应多条机器指令。例如:

  1. 读取I:从内存中将I的值读入寄存器。
  2. 递增:将寄存器中的值加1。
  3. 写入I:将寄存器中的新值写回内存。

并发交错实际上发生在这些更细粒度的机器指令之间,而不是完整的Go源代码指令之间。程序员通常在源代码层面思考,这可能导致他们忽略潜在的并发问题。

一个出错的交错示例

考虑以下机器指令级别的交错执行顺序:

步骤 协程1 (T1) 协程2 (T2) I的值
1 读取 I (得到 0) 0
2 读取 I (得到 0) 0
3 递增 (0 -> 1) 0
4 写入 I (写入 1) 1
5 递增 (0 -> 1) 1
6 写入 I (写入 1) 1

在这个交错中:

  1. T1和T2都读取了I的初始值0。
  2. T1将0递增为1并写入内存,此时I变为1。
  3. T2将其读取的旧值0递增为1并写入内存,此时I被覆盖为1。

最终结果是I等于1,而不是预期的2。问题的关键在于,当两个Go协程共享并写入同一个变量I时,在机器代码级别的细粒度交错可能引入程序员未曾预料到的复杂性。


4.2.2:互斥锁 🧱

上一节我们看到了共享数据可能导致的并发问题。本节中我们来看看如何正确地在Go协程间共享数据。

一个经验法则是:不要让两个Go协程同时写入一个共享变量,因为这可能导致我们刚刚看到的问题。

我们需要限制这些Go协程可能的执行交错。如果有两个Go协程都要写入共享变量I,我们必须以某种方式限制它们的交错,确保它们不能同时写入。

对共享变量的访问不能交错。程序员必须确保它们不能同时进行写入操作。这被称为互斥。程序员需要声明一些代码段,这些代码段在不同的Go协程中不能并发执行,即它们不能被交错。

例如,两个Go协程都执行i = i + 1,我们必须确保一个协程中写入I的代码段与另一个协程中写入I的代码段是互斥的。

Go语言在sync包中提供了实现互斥的构造。我们将使用Mutex(互斥锁)对象。Mutex确保互斥访问。

互斥锁的概念:信号量

Mutex通常使用一种称为二元信号量的机制。可以将其想象为邮箱上的标志旗。

  • 当标志旗升起时,表示共享变量正在被使用。这意味着某个Go协程正在写入共享变量I。
  • 当标志旗降下时,表示共享变量可用。

规则如下:

  • 如果一个Go协程想使用共享变量,它必须首先检查标志旗。
  • 如果标志旗降下,它可以升起标志旗(声明使用权),然后使用共享变量,使用完毕后降下标志旗。
  • 如果标志旗已经升起,它必须等待,直到标志旗被降下。

所有Go协程都必须遵守这个协议,该机制才能正常工作。


4.2.3:Mutex 方法 🔐

上一节我们介绍了互斥锁和信号量的概念。本节中我们来看看Go语言中Mutex的具体方法。

在Mutex内部,升起和降下标志旗的操作通过一组方法实现:LockUnlock

  • Lock:相当于升起标志旗。
  • Unlock:相当于降下标志旗。

Go协程在即将使用共享数据前应调用Lock。其工作原理如下:

  • 如果当前没有协程持有锁(即标志旗降下),那么第一个调用Lock的协程将成功获得锁。它会将内部标志从0设为1,然后继续执行其互斥区内的代码,访问共享变量。此时Lock调用不会阻塞
  • 如果在此过程中,另一个Go协程也调用了Lock,而此时标志旗已经升起,那么这个Lock调用将阻塞。第二个协程会被阻止继续执行并访问共享数据。
  • 先调用Lock的协程获得锁,其他协程必须等待。这个机制可以扩展到任意数量的Go协程。

当Go协程完成对共享数据的使用后,它必须调用Unlock。这会降下标志旗。随后,在等待队列中阻塞的某个Go协程(通常是第一个调用Lock的)将被允许继续执行,获得锁并访问共享变量。

只要每个Go协程在其互斥代码段的开始处调用Lock,在结束处调用Unlock,就能确保同一时间只有一个Go协程位于该互斥区域内(即写入共享变量的代码段)。

修复递增问题的示例

以下是如何使用Mutex修复之前递增问题的示例。

我们对increment函数做了几处修改:

  1. 创建了一个Mutex,命名为mut
  2. i = i + 1操作之前调用mut.Lock()
  3. i = i + 1操作之后调用mut.Unlock()

现在,位于LockUnlock之间的区域与任何其他Lock/Unlock区域是互斥的。在这个例子中,两个Go协程都执行increment函数,因此它们都有LockUnlock调用。这样就能确保同一时间只有一个Go协程在执行i = i + 1。当一个协程获得锁并进入该区域时,另一个协程会在其Lock调用处被阻塞,直到第一个协程调用Unlock释放锁。

总结

本节课中我们一起学习了Go语言并发编程中的互斥概念。我们首先看到了多个Go协程并发写入共享变量时,由于机器指令级别的交错执行可能导致的数据不一致问题。接着,我们引入了互斥锁作为解决方案,它通过LockUnlock方法确保同一时间只有一个协程能访问临界区(共享数据)。正确使用互斥锁是编写并发安全Go程序的关键之一。

065:线程与Go

概述

在本节课中,我们将要学习Go语言中关于并发编程的两个重要概念:使用sync.Once进行一次性初始化,以及并发编程中常见的死锁问题及其经典案例——哲学家就餐问题。我们将通过简单的示例和解释,帮助你理解这些核心概念。


章节 3.1:一次性同步 🔄

上一节我们介绍了Go协程的基本概念,本节中我们来看看如何使用sync包中的工具进行同步。sync包提供了一系列方法,用于在不同的Go协程之间进行同步。其中一个非常有用的惯用法是初始化

设想你有一个多协程程序在运行,你需要执行一些初始化任务。根据定义,初始化应该只发生一次,并且必须在所有其他操作之前发生。但在多个并行运行的协程中,有时很难保证这一点,因为你无法精确控制这些协程的执行顺序。

那么,如何保证某个初始化函数只执行一次,并且在所有其他操作之前执行呢?一种方法是在启动其他协程之前,在主协程中执行初始化。但有时你可能没有这个选项。

sync包中的sync.Once提供了另一种解决方案。sync.Once对象有一个方法Do,你可以将一个函数作为参数传递给它。

var once sync.Once
once.Do(func() {
    // 初始化代码
})

你可以将once.Do的调用放在许多不同的协程中。Go运行时会保证,即使once.Do在20个不同的协程中被调用,传入的函数也只会被执行一次。同时,它还能保证在所有协程中,任何对once.Do的调用都会阻塞,直到第一个(也是唯一一个)执行该函数的调用返回。这确保了初始化操作在其他任何操作进行之前完成。

因此,你可以在所有协程的开头放置once.Do。其中一个协程会实际执行初始化函数,其他协程则会阻塞直到它执行完毕。sync.Once通过once.Do,既保证了初始化只发生一次,也保证了初始化先于其他所有操作发生。

下面是一个使用示例。假设我们有两个协程,它们都需要执行相同的初始化操作。

以下是代码的主要结构:

package main

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup
var once sync.Once

func setup() {
    fmt.Println("Init")
}

func doStuff() {
    once.Do(setup) // 初始化保证只执行一次
    fmt.Println("Hello")
    wg.Done()
}

func main() {
    wg.Add(2)
    go doStuff()
    go doStuff()
    wg.Wait()
}

在这个例子中,我们定义了一个sync.Once对象once和一个初始化函数setupdoStuff函数是每个协程要执行的代码,它首先调用once.Do(setup)。运行此程序,你会看到输出"Init"一次,并且出现在两个"Hello"之前,这完全符合我们的预期。


章节 3.2:死锁 ⚠️

现在我们来讨论同步可能带来的问题。我们一直在讨论同步,无论是sync包提供的工具还是通道,都能实现同步。但如果不小心,同步也会导致问题,特别是死锁。这是编码时必须避免的。

死锁源于同步依赖。这意味着多个Go协程之间,一个协程的执行可能依赖于另一个协程。例如,协程G1向通道写入数据,而协程G2从该通道读取数据。G2在读取行会阻塞,直到G1执行写入。这就产生了执行依赖,G2依赖于G1。

当依赖关系形成循环时,就会发生死锁。即G1等待G2做某事(如解锁互斥锁),同时G2也在等待G1做某事(如向通道写入)。两者都在等待对方,但都被阻塞,导致程序无法继续执行。这就是死锁。

这种循环依赖可能由等待通道或等待互斥锁解锁引起。

以下是一个死锁的代码示例。我们有两个协程执行相同的doStuff函数。

func doStuff(c1, c2 chan int) {
    <-c1        // 等待从第一个通道接收
    c2 <- 1     // 向第二个通道发送
}

main函数中,我们如下启动协程:

go doStuff(ch1, ch2) // 协程1:等ch1, 写ch2
go doStuff(ch2, ch1) // 协程2:等ch2, 写ch1

第一个协程等待从ch1接收,然后向ch2写入。第二个协程等待从ch2接收,然后向ch1写入。这样,第一个协程在等待第二个协程写入ch1,而第二个协程在等待第一个协程写入ch2。两者相互等待,形成死锁。

如果你运行这段代码,Go运行时能够检测到这种所有协程都被锁定的死锁情况,并会报错。然而,Go运行时无法检测只有部分协程陷入死锁的情况。那种情况更难以调试,程序会表现异常。因此,程序员必须主动避免这种循环依赖。


章节 3.3:哲学家就餐问题 🍽️

现在我们来讨论一个经典的并发问题——哲学家就餐问题。这个问题常被用来讲解同步和展示死锁的可能性,是并发教学中的经典案例。我们将通过它来展示死锁如何悄然而至,以及如何小心避免。

问题描述

有五名哲学家围坐在一张圆桌旁,每人面前有一盘米饭。每两位相邻的哲学家之间放有一根筷子,共有五根筷子。哲学家需要用两根筷子才能吃饭(即拿起左边和右边的筷子)。一次只能有一名哲学家持有一根筷子。

建模

在代码中,我们将每根筷子建模为一个互斥锁(sync.Mutex),因为一根筷子一次只能被一位哲学家拿起。每位哲学家对应一个Go协程,并关联其左右两根筷子。

以下是核心的数据结构和哲学家“吃”的行为:

type Chopstick struct{ sync.Mutex }

type Philosopher struct {
    leftCS, rightCS *Chopstick
}

func (p Philosopher) eat() {
    for {
        p.leftCS.Lock()   // 拿起左边筷子
        p.rightCS.Lock()  // 拿起右边筷子

        fmt.Println("eating") // 吃饭

        p.rightCS.Unlock() // 放下右边筷子
        p.leftCS.Unlock()  // 放下左边筷子
    }
}

main函数中,我们创建5根筷子和5位哲学家,并为他们分配左右筷子(注意圆桌的环形结构,最后一位哲学家的右手筷子是第一根)。然后为每位哲学家启动一个执行eat方法的协程。

死锁风险

问题在于,如果所有哲学家同时拿起了自己左边的筷子,那么所有五根筷子都被锁住了。当每个哲学家试图去拿右边的筷子时,会发现右边的筷子已经被他右边的哲学家作为左边筷子拿走了。于是,所有哲学家都会无限期地等待,程序陷入死锁。

解决方案之一

解决此问题的方法之一是改变拿筷子的顺序,避免循环等待。例如,著名计算机科学家Dijkstra提出,让每位哲学家总是先拿起编号较小的筷子。在我们的设定中,这需要修改代码,让最后一位哲学家(其左右筷子编号为4和0)先拿起0号筷子,而不是4号。

这样修改后,当其他人都拿起左边(编号较小)的筷子时,最后一位哲学家会因无法拿起0号筷子而阻塞,从而让他左边的哲学家(持有4号筷子)可以顺利拿起右边的筷子吃饭,打破了循环等待链。

然而,这种解决方案可能导致饥饿——某位哲学家(如最后一位)可能总是最后吃到饭,甚至长时间吃不到。饥饿是并发中另一个需要关注的问题,但相比导致程序完全停滞的死锁,通常更容易接受和处理。

总结

本节课中我们一起学习了Go并发编程中的三个关键点:

  1. 使用 sync.Once 确保在多协程环境中,初始化函数安全且仅执行一次。
  2. 理解了 死锁 的成因——即同步操作导致的循环依赖,并知道了如何通过谨慎设计来避免。
  3. 通过经典的 哲学家就餐问题,深入分析了死锁发生的具体场景,并了解了一种通过破坏循环等待来避免死锁的策略。

记住,并发编程的核心在于管理好协程间的同步与通信,避免竞态条件和死锁,是写出正确、高效并发程序的关键。

posted @ 2026-03-29 09:28  布客飞龙II  阅读(10)  评论(0)    收藏  举报