first commit

This commit is contained in:
张乾
2024-10-15 23:13:09 +08:00
parent bbc9aed40c
commit 201a5889b1
142 changed files with 21536 additions and 2 deletions

View File

@ -0,0 +1,84 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 Go 为开发者的需求设计,带你实现高效工作
你好,我是飞雪无情,在技术领域从业近 10 年,目前在一家互联网公司担任技术总监,负责技术管理和架构设计。
2014 年,我因为 Docker 接触了 Go 语言,其简洁的语法、高效的开发效率和语言层面上的并发支持深深地吸引了我。经过不断地学习和实践,我对 Go 语言有了更深入的了解,不久后,便带领团队转型 Go 语言开发,提升了团队开发效率和系统性能,降低了用人成本。
在带领团队转型 Go 语言的过程中,我不断把自己学习 Go 语言的经验沉淀成文章,方便大家利用碎片时间学习,于是“飞雪无情”的公众号和知乎号就诞生了。现在,我已经发布了 200 多篇相关内容,在帮助数万名朋友有效学习 Go 的同时,还有幸拿到了知乎 Go 语言专题的最高赞。
Go 语言为开发者的需求而设计
K8s、Docker、etcd 这类耳熟能详的工具,就是用 Go 语言开发的,而且很多大公司(如腾讯、字节跳动等)都在把原来 C/C++、Python、PHP 的技术栈迁往 Go 语言。
在我看来Go 作为一门高效率的工业化语言备受推崇,这与其语言本身的优势有直接的关系:
语法简洁,相比其他语言更容易上手,开发效率更高;
自带垃圾回收GC不用再手动申请释放内存能够有效避免 Bug提高性能
语言层面的并发支持,让你很容易开发出高性能的程序;
提供的标准库强大,第三方库也足够丰富,可以拿来即用,提高开发效率;
可通过静态编译直接生成一个可执行文件,运行时不依赖其他库,部署方便,可伸缩能力强;
提供跨平台支持,很容易编译出跨各个系统平台直接运行的程序。
对比其他语言Go 的优势也显著。比如 Java 虽然具备垃圾回收功能,但它是解释型语言,需要安装 JVM 虚拟机才能运行C 语言虽然不用解释,可以直接编译运行,但是它不具备垃圾回收功能,需要开发者自己管理内存的申请和释放,容易出问题。而 Go 语言具备了两者的优势。
如今微服务和云原生已经成为一种趋势,而 Go 作为一款高性能的编译型语言,最适合承载落地微服务的实现 ,又容易生成跨平台的可执行文件,相比其他编程语言更容易部署在 Docker 容器中,实现灵活的自动伸缩服务。
总体来看Go 语言的整体设计理念就是以软件工程为目的的,也就是说它不是为了编程语言本身多么强大而设计,而是为了开发者更好地研发、管理软件工程,一切都是为了开发者着想。
如果你是有 1~3 年经验的其他语言开发者(如 Python、PHP、C/C++Go 的学习会比较容易因为编程语言的很多概念相通。而如果你是有基本计算机知识但无开发经验的小白Go 也适合尽早学习,吃透它有助于加深你对编程语言的理解,也更有职业竞争力。
而在我与 Go 语言学习者进行交流,以及面试的过程中,也发现了一些典型问题,可概括为如下三点:
第一,学习者所学知识过于零碎,缺乏系统性,并且不是太深入,导致写不出高效的程序,也难以在面试中胜出。比如,我面试时常问字符串拼接的效率问题,这个问题会牵涉到 + 加号运算符、buffer 拼接、build 拼接、并发安全等知识点,但应聘者通常只能答出最浅显的内容,缺乏对语言逻辑的深层思考。
第二,很多入门者已有其他语言基础,很难转换语言思维模式,而且 Go 的设计者还做了很多相比其他语言的改进和创新。作为从 Java 转到 Go 语言的过来人我非常理解这种情况比如对于错误的处理Java 语言使用 Exception而 Go 语言则通过函数返回 error这会让人很不习惯。
第三,没有开源的、适合练手的项目。
在过去分享 Go 语言知识的过程中,我融入了应对上述问题的方法并得到好评,比如有用户称“你的文章给我拨云见日的感觉!”“通过你的文章终于懂 context 的用法了!”……这些正向评价更坚定了我分享内容的信心。
于是在经过不断地思考、整理后,我希望设计更有系统性、也更通俗易懂的一门专栏。我的目标是通过这门课程帮助你少走弯路,比其他人更快一步提升职场竞争力。
这门课的亮点和设计思路
系统性设计:从基础知识、底层原理到实战,让你不仅可以学会使用,还能从语言自身的逻辑、框架层面分析问题,并做到能上手项目。这样当出现问题时,你可以不再盲目地搜索知识点。
案例实操:我设计了很多便于运用知识点的代码示例,还特意站在学习者的视角,演示了一些容易出 Bug 的场景,帮你避雷。我还引入了很多生活化的场景,比如用枪响后才能赛跑的例子演示 sync.Cond 的使用,帮你加深印象,缓解语言学习的枯燥感。
贴近实际:我所策划的内容来源于众多学习者的反馈,在不断地交流中,我总结了他们问题的共性和不同,并有针对性地融入专栏。
那我是怎么划分这门课的呢?
作为初学者,不管你是否有编程经验,都需要先学习 Go 语言的基本语法,然后我会在此基础上再向你介绍 Go 语言的核心特性——并发,这也是 Go 最自豪的功能。其基于协程的并发,比我们平时使用的线程并发更轻量,可以随意地在一台普通的电脑上启动成百上千个协程,成本非常低。
掌握了基本知识后,我们来通过底层分析深入理解原理。我会结合源码,并对比其他语言的同类知识,带你理解 Go 的设计思路和底层语言逻辑。
此时你可能还有一些疑惑,比如不知道如何把知识与实际工作结合起来,所以就需要 Go 语言工程质量管理方面的知识了。而最后,我会用两个实战帮你快速上手项目,巩固知识。
所以,我根据这个思路将这门课划分成 5 个模块:
模块一Go 语言快速入门:我挑选了变量、常量等数据类型、函数和方法、结构体和接口等知识点介绍,这部分内容相对简洁,但已经足够你掌握 Go 的基本程序结构。
模块二Go 语言高效并发:主要介绍 goroutine、channel、同步原语等知识让你对 Go 语言层面的并发支持有更深入的理解,并且可以编写自己的 Go 并发程序设计。最后还会有一节课专门介绍常用的并发模式,可以拿来即用,更好地控制并发。
模块三Go 语言深入理解Go 语言底层原理的讲解和高级功能的介绍,比如 slice 的底层是怎样的,为什么这么高效等。这个模块也是我特意设计的,我在初学编程时,也有只学习如何使用,而不想研究底层原理的情况,导致工作遇到障碍后又不得不回头恶补,后来发现这是初学者的通病。但理解了底层原理后,你才能灵活编写程序、高效应对问题。
模块四Go 语言工程管理:学习一门语言,不光要掌握它本身的知识,还要会模块管理、性能优化等周边技能,因为这些技能可以帮助你更好地进行多人协作,提高开发效率,写出更高质量的代码。你可以在这个模块学到如何测试 Go 语言以提高代码质量、如何做好性能优化、如何使用第三方库提高自己项目的开发效率等。
模块五Go 语言实战Go 语言更适合的场景就是网络服务和并发,通过开发 HTTP 服务和 RPC 服务这两个实战,可以把前四个模块的知识运用起来,快速上手。
作者寄语
我一直不厌其烦地跟团队小伙伴说Go 语言是一门现代编程语言,相比其他编程语言,它对我们开发者有更好的用户体验,因为它的目的就是让我们更专注于自己业务的实现,提高开发效率。与此同时,当下的云原生是一种趋势, Go 语言非常适合部署在这种环境中,越早学习越有竞争力。
此外,我在上文中也反复强调了学习底层原理的重要性。编程语言有很多共通之处(比如概念、关键字、特性语法等),吃透后再学习其他的编程语言会简单得多,原因在于你理解了语言本身。所以在学习 Go 语言的过程中,我希望你多想、多练,深入理解,融会贯通。
现在,跟我一起踏上 Go 语言学习之旅吧Lets Go

View File

@ -0,0 +1,211 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 基础入门:编写你的第一个 Go 语言程序
从这节课开始,我会带你走进 Go 语言的世界。我会用通俗易懂的语言,介绍 Go 语言的各个知识点,让你可以从零开始逐步学习,再深入它的世界。不管你以前是否接触过 Go 语言,都可以从这个专栏中受益。
现在让我以一个经典的例子“Hello World”来带你入门 Go 语言,了解它是如何运行起来的。
Hello, 世界
如果你学过 C 语言,对这个经典的例子应该不会陌生。通过它,我先带你大概了解一下 Go 语言的一些核心理念,让你对 Go 语言代码有个整体的印象。如下所示:
ch01/main.go
package main
import "fmt"
func main() {
fmt.Println("Hello, 世界")
}
这五行代码就构成了一个完整的 Go 程序,是不是非常简单?现在我运行这段代码,看看输出的结果,方法是打开终端输入以下命令,然后回车。
$ go run ch01/main.go
Hello, 世界
其中 go run ch01/main.go 是我输入的命令回车后看到的“Hello, 世界”是 Go 程序输出的结果。
代码中的 go 是一个 Go 语言开发工具包提供的命令,它和你平时常用的 ls 命令一样都是可执行的命令。它可以帮助你运行 Go 语言代码,并进行编译,生成可执行的二进制文件等。
run 在这里是 go 命令的子命令,表示要运行 Go 语言代码的意思。最后的 ch01/main.go 就是我写的 Go 语言代码文件了。也就是说,整个 go run ch01/main.go 表示要运行 ch01/main.go 里的 Go 语言代码。
程序结构分析
要让一个 Go 语言程序成功运行起来,只需要 package main 和 main 函数这两个核心部分, package main 代表的是一个可运行的应用程序,而 main 函数则是这个应用程序的主入口。
在“Hello, 世界”这个简单的示例中,包含了一个 Go 语言程序运行的最基本的核心结构。我们以此为例,来逐一介绍程序的结构,了解 Go 语言的核心概念。
第一行的 package main 代表当前的 ch01/main.go 文件属于哪个包,其中 package 是 Go 语言声明包的关键字main 是要声明的包名。在 Go 语言中 main 包是一个特殊的包,代表你的 Go 语言项目是一个可运行的应用程序,而不是一个被其他项目引用的库。
第二行的 import “fmt” 是导入一个 fmt 包,其中 import 是 Go 语言的关键字,表示导入包的意思,这里我导入的是 fmt 包,导入的目的是要使用它,下面会继续讲到。
第三行的 func main() 是定义了一个函数,其中 func 是 Go 语言的关键字表示要定义一个函数或者方法的意思main 是函数名,() 空括号表示这个 main 函数不接受任何参数。在 Go 语言中 main 函数是一个特殊的函数,它代表整个程序的入口,也就是程序在运行的时候,会先调用 main 函数,然后通过 main 函数再调用其他函数,达到实现项目业务需求的目的。
第四行的 fmt.Println(“Hello, 世界”) 是通过 fmt 包的 Println 函数打印“Hello, 世界”这段文本。其中 fmt 是刚刚导入的包要想使用一个包必须先导入。Println 函数是属于包 fmt 的函数这里我需要它打印输出一段文本也就是“Hello, 世界”。
第五行的大括号 } 表示 main 函数体的结束。现在整个代码片段已经分析完了运行就可以看到“Hello, 世界”结果的输出。
从以上分析来看Go 语言的代码是非常简洁、完整的核心程序,只需要 package、import、func main 这些核心概念就可以实现。 在后面的课时中,我还会讲如何使用变量,如何自定义函数等,这里先略过不讲,我们先来看看 Go 语言的开发环境是如何搭建的,这样才能运行上面的 Go 语言代码,让整个程序跑起来。
Go 语言环境搭建
要想搭建 Go 语言开发环境,需要先下载 Go 语言开发包。你可以从官网 https://golang.org/dl/ 和 https://golang.google.cn/dl/ 下载(第一个链接是国外的官网,第二个是国内的官网,如果第一个访问不了,可以从第二个下载)。
下载时可以根据自己的操作系统选择相应的开发包,比如 Window、MacOS 或是 Linux 等,如下图所示:
Windows MSI 下安装
MSI 安装的方式比较简单,在 Windows 系统上推荐使用这种方式。现在的操作系统基本上都是 64 位的,所以选择 64 位的 go1.15.windows-amd64.msi 下载即可,如果操作系统是 32 位的,选择 go1.15.windows-386.msi 进行下载。
下载后双击该 MSI 安装文件按照提示一步步地安装即可。在默认情况下Go 语言开发工具包会被安装到 c:\Go 目录,你也可以在安装过程中选择自己想要安装的目录。
假设安装到 c:\Go 目录,安装程序会自动把 c:\Go\bin 添加到你的 PATH 环境变量中,如果没有的话,你可以通过系统 -> 控制面板 -> 高级 -> 环境变量选项来手动添加。
Linux 下安装
Linux 系统同样有 32 位和 64 位,你可以根据你的 Linux 操作系统选择相应的压缩包,它们分别是 go1.15.linux-386.tar.gz 和 go1.15.linux-amd64.tar.gz。
下载成功后,需要先进行解压,假设你下载的是 go1.15.linux-amd64.tar.gz在终端通过如下命令即可解压
sudo tar -C /usr/local -xzf go1.15.linux-amd64.tar.gz
输入后回车,然后输入你的电脑密码,即可解压到 /usr/local 目录,然后把 /usr/local/go/bin 添加到 PATH 环境变量中,就可以使用 Go 语言开发工具包了。
把下面这段添加到 /etc/profile 或者 $HOME/.profile 文件中,保存后退出即可成功添加环境变量。
export PATH=$PATH:/usr/local/go/bin
macOS 下安装
如果你的操作系统是 macOS可以采用 PKG 安装包。下载 go1.15.darwin-amd64.pkg 后,双击按照提示安装即可。安装成功后,路径 /usr/local/go/bin 应该已经被添加到了 PATH 环境变量中,如果没有的话,你可以手动添加,和上面 Linux 的方式一样,把如下内容添加到 /etc/profile 或者 $HOME/.profile 文件保存即可。
export PATH=$PATH:/usr/local/go/bin
安装测试
以上都安装成功后,你可以打开终端或者命令提示符,输入 go version 来验证 Go 语言开发工具包是否安装成功。如果成功的话,会打印出 Go 语言的版本和系统信息,如下所示:
$ go version
go version go1.15 darwin/amd64
环境变量设置
Go 语言开发工具包安装好之后,它的开发环境还没有完全搭建完成,因为还有两个重要的环境变量没有设置,它们分别是 GOPATH 和 GOBIN。
GOPATH代表 Go 语言项目的工作目录,在 Go Module 模式之前非常重要,现在基本上用来存放使用 go get 命令获取的项目。
GOBIN代表 Go 编译生成的程序的安装目录,比如通过 go install 命令,会把生成的 Go 程序安装到 GOBIN 目录下,以供你在终端使用。
假设工作目录为 /Users/flysnow/go你需要把 GOPATH 环境变量设置为 /Users/flysnow/go把 GOBIN 环境变量设置为 $GOPATH/bin。
在 Linux 和 macOS 下,把以下内容添加到 /etc/profile 或者 $HOME/.profile 文件保存即可。
export GOPATH=/Users/flysnow/go
export GOBIN=$GOPATH/bin
在 Windows 操作系统中,则通过控制面板 -> 高级 -> 环境变量选项添加这两个环境变量即可。
项目结构
采用 Go Module 的方式,可以在任何位置创建你的 Go 语言项目。在整个专栏中,我都会使用这种方式演示 Go 语言示例,现在你先对 Go Module 项目结构有一个大概了解,后面的课时我会详细地介绍 Go Module。
假设你的项目位置是 /Users/flysnow/git/gotour打开终端输入如下命令切换到该目录下
$ cd /Users/flysnow/git/gotour
然后再执行如下命令创建一个 Go Module 项目:
$ go mod init
执行成功后,会生成一个 go.mod 文件。然后在当前目录下创建一个 main.go 文件,这样整个项目目录结构是:
gotour
├── go.mod
├── lib
└── main.go
其中 main.go 是整个项目的入口文件,里面有 main 函数。lib 目录是项目的子模块,根据项目需求可以新建很多个目录作为子模块,也可以继续嵌套为子模块的子模块。
编译发布
完成了你的项目后,可以编译生成可执行文件,也可以把它发布到 $GOBIN 目录以供在终端使用。以“Hello 世界”为例,在项目根目录输入以下命令,即可编译一个可执行文件。
$ go build ./ch01/main.go
回车执行后会在当前目录生成 main 可执行文件,现在,我们来测试下它是否可用。
$ ./main
Hello, 世界
如果成功打印出“Hello, 世界”,证明程序成功生成。
以上生成的可执行文件在当前目录,也可以把它安装到 $GOBIN 目录或者任意位置,如下所示:
$ go install ./ch01/main.go
使用 go install 命令即可,现在你在任意时刻打开终端,输入 main 回车都会打印出“Hello, 世界”,是不是很方便!
跨平台编译
Go 语言开发工具包的另一强大功能就是可以跨平台编译。什么是跨平台编译呢?就是你在 macOS 开发,可以编译 Linux、Window 等平台上的可执行程序,这样你开发的程序,就可以在这些平台上运行。也就是说,你可以选择喜欢的操作系统做开发,并跨平台编译成需要发布平台的可执行程序即可。
Go 语言通过两个环境变量来控制跨平台编译,它们分别是 GOOS 和 GOARCH 。
GOOS代表要编译的目标操作系统常见的有 Linux、Windows、Darwin 等。
GOARCH代表要编译的目标处理器架构常见的有 386、AMD64、ARM64 等。
这样通过组合不同的 GOOS 和 GOARCH就可以编译出不同的可执行程序。比如我现在的操作系统是 macOS AMD64 的,我想编译出 Linux AMD64 的可执行程序,只需要执行 go build 命令即可,如以下代码所示:
$ GOOS=linux GOARCH=amd64 go build ./ch01/main.go
关于 GOOS 和 GOARCH 更多的组合,参考官方文档的 \(GOOS and \)GOARCH 这一节即可。
Go 编辑器推荐
好的编辑器可以提高开发的效率,这里我推荐两款目前最流行的编辑器。
第一款是 Visual Studio Code + Go 扩展插件,可以让你非常高效地开发,通过官方网站 https://code.visualstudio.com/ 下载使用。
第二款是老牌 IDE 公司 JetBrains 推出的 Goland所有插件已经全部集成更容易上手并且功能强大新手老手都适合你可以通过官方网站 https://www.jetbrains.com/go/ 下载使用。
总结
这节课中你学到了如何写第一个 Go 语言程序,并且搭建好了 Go 语言开发环境,创建好了 Go 语言项目,同时也下载好了 IDE 严阵以待,那么现在我就给你留个小作业:
改编示例“Hello 世界”的代码,打印出自己的名字。

View File

@ -0,0 +1,391 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 数据类型:你必须掌握的数据类型有哪些?
上节课的思考题是打印出自己的名字这个作业比较简单属于文本的替换你只需要把我示例中的”Hello 世界”修改成自己的名字即可,比如以我的名字为例,替换为“飞雪无情”。
经过上一节课的学习,你已经对 Go 语言的程序结构有了初步了解也准备好了相应的开发环境。但是一个完整的项目需要更复杂的逻辑不是简单的“Hello 世界”可相比的。这些逻辑通过变量、常量、类型、函数方法、接口、结构体组成,这节课我就将带你认识它们,让你的 Go 语言程序变得更加生动。
变量声明
变量代表可变的数据类型,也就是说,它在程序执行的过程中可能会被一次甚至多次修改。
在 Go 语言中,通过 var 声明语句来定义一个变量,定义的时候需要指定这个变量的类型,然后再为它起个名字,并且设置好变量的初始值。所以 var 声明一个变量的格式如下:
var 变量名 类型 = 表达式
现在我通过一个示例来演示如何定义一个变量,并且设置它的初始值:
ch02/main.go
package main
import "fmt"
func main() {
var i int = 10
fmt.Println(i)
}
观察上面例子中 main 函数的内容,其中 var i int = 10 就是定义一个类型为 int整数、变量名为 i 的变量,它的初始值为 10
这里为了运行程序,我加了一行 fmt.Println(i),你在上节课中就见到过它,表示打印出变量 i 的值。
这样做一方面是因为 Go 语言中定义的变量必须使用,否则无法编译通过,这也是 Go 语言比较好的特性,防止定义了变量不使用,导致浪费内存的情况;另一方面,在运行程序的时候可以查看变量 i 的结果。
通过输入 go run ch02/main.go 命令回车运行,即可看到如下结果:
$ go run ch02/main.go
10
打印的结果是10和变量的初始值一样。
因为 Go 语言具有类型推导功能,所以也可以不去刻意地指定变量的类型,而是让 Go 语言自己推导,比如变量 i 也可以用如下的方式声明:
var i = 10
这样变量 i 的类型默认是 int 类型。
你也可以一次声明多个变量,把要声明的多个变量放到一个括号中即可,如下面的代码所示:
var (
j int= 0
k int= 1
)
同理因为类型推导,以上多个变量声明也可以用以下代码的方式书写:
var (
j = 0
k = 1
)
这样就更简洁了。
其实不止 int 类型,我后面介绍的 float64、bool、string 等基础类型都可以被自动推导,也就是可以省略定义类型。
演示项目目录结构
为了让你更好地理解我演示的例子,这里我给出演示项目的目录结构,以后的所有课时都会按照这个目录进行演示。
我的演示项目结构如下所示:
gotour
├── ch01
│ └── main.go
├── ch02
│ └── main.go
└── go.mod
其中 gotour 是演示项目的根目录,所有 Go 语言命令都会在这里执行,比如 go run。
ch01、ch02 这些目录是按照课时命名的,每一讲都有对应的目录,便于查找相应的源代码。具体的 Go 语言源代码会存放到对应的课时目录中。
基础类型
任何一门语言都有对应的基础类型,这些基础类型和现实中的事物一一对应,比如整型对应着 1、2、3、100 这些整数,浮点型对应着 1.1、3.4 这些小数等。Go 语言也不例外,它也有自己丰富的基础类型,常用的有:整型、浮点数、布尔型和字符串,下面我就为你详细介绍。
整型
在 Go 语言中,整型分为:
有符号整型:如 int、int8、int16、int32 和 int64。
无符号整型:如 uint、uint8、uint16、uint32 和 uint64。
它们的差别在于,有符号整型表示的数值可以为负数、零和正数,而无符号整型只能为零和正数。
除了有用“位”bit大小表示的整型外还有 int 和 uint 这两个没有具体 bit 大小的整型,它们的大小可能是 32bit也可能是 64bit和硬件设备 CPU 有关。
在整型中,如果能确定 int 的 bit 就选择比较明确的 int 类型,因为这会让你的程序具备很好的移植性。
在 Go 语言中,还有一种字节类型 byte它其实等价于 uint8 类型,可以理解为 uint8 类型的别名,用于定义一个字节,所以字节 byte 类型也属于整型。
浮点数
浮点数就代表现实中的小数。Go 语言提供了两种精度的浮点数,分别是 float32 和 float64。项目中最常用的是 float64因为它的精度高浮点计算的结果相比 float32 误差会更小。
下面的代码示例定义了两个变量 f32 和 f64它们的类型分别为 float32 和 float64。
ch02/main.go
var f32 float32 = 2.2
var f64 float64 = 10.3456
fmt.Println("f32 is",f32,",f64 is",f64)
运行这段程序,会看到如下结果:
$ go run ch02/main.go
f32 is 2.2 ,f64 is 10.3456
特别注意:在演示示例的时候,我会尽可能地贴出演示需要的核心代码,也就是说,会省略 package 和 main 函数。如果没有特别说明它们都是放在main函数中的可以直接运行。
布尔型
一个布尔型的值只有两种true 和 false它们代表现实中的“是”和“否”。它们的值会经常被用于一些判断中比如 if 语句以后的课时会详细介绍等。Go 语言中的布尔型使用关键字 bool 定义。
下面的代码声明了两个变量,你可以自己运行,看看打印输出的结果。
ch02/main.go
var bf bool =false
var bt bool = true
fmt.Println("bf is",bf,",bt is",bt)
布尔值可以用于一元操作符 !,表示逻辑非的意思,也可以用于二元操作符 &&、||,它们分别表示逻辑和、逻辑或。
字符串
Go 语言中的字符串可以表示为任意的数据,比如以下代码,在 Go 语言中,字符串通过类型 string 声明:
ch02/main.go
var s1 string = "Hello"
var s2 string = "世界"
fmt.Println("s1 is",s1,",s2 is",s2)
运行程序就可以看到打印的字符串结果。
在 Go 语言中,可以通过操作符 + 把字符串连接起来,得到一个新的字符串,比如将上面的 s1 和 s2 连接起来,如下所示:
ch02/main.go
fmt.Println("s1+s2=",s1+s2)
由于 s1 表示字符串“Hello”s2 表示字符串“世界”,在终端输入 go run ch02/main.go 后就可以打印出它们连接起来的结果“Hello世界”如以下代码所示
s1+s2= Hello世界
字符串也可以通过 += 运算符操作,你自己可以试试 s1+=s2 会得到什么新的字符串。
零值
零值其实就是一个变量的默认值,在 Go 语言中,如果我们声明了一个变量,但是没有对其进行初始化,那么 Go 语言会自动初始化其值为对应类型的零值。比如数字类的零值是 0布尔型的零值是 false字符串的零值是 “” 空字符串等。
通过下面的代码示例,就可以验证这些基础类型的零值:
ch02/main.go
var zi int
var zf float64
var zb bool
var zs string
fmt.Println(zi,zf,zb,zs)
变量
变量简短声明
有没有发现,上面我们演示的示例都有一个 var 关键字但是这样写代码很烦琐。借助类型推导Go 语言提供了变量的简短声明 :=,结构如下:
变量名:=表达式
借助 Go 语言简短声明功能,变量声明就会非常简洁,比如以上示例中的变量,可以通过如下代码简短声明:
i:=10
bf=false
s1:="Hello"
在实际的项目实战中,如果你能为声明的变量初始化,那么就选择简短声明方式,这种方式也是使用最多的。
指针
在 Go 语言中,指针对应的是变量在内存中的存储位置,也就说指针的值就是变量的内存地址。通过 & 可以获取一个变量的地址,也就是指针。
在以下的代码中pi 就是指向变量 i 的指针。要想获得指针 pi 指向的变量值,通过*pi这个表达式即可。尝试运行这段程序会看到输出结果和变量 i 的值一样。
pi:=&i
fmt.Println(*pi)
赋值
在讲变量的时候,我说过变量是可以修改的,那么怎么修改呢?这就是赋值语句要做的事情。最常用也是最简单的赋值语句就是 =,如下代码所示:
i = 20
fmt.Println("i的新值是",i)
这样变量 i 就被修改了,它的新值是 20。
常量
一门编程语言有变量就有常量Go 语言也不例外。在程序中,常量的值是指在编译期就确定好的,一旦确定好之后就不能被修改,这样就可以防止在运行期被恶意篡改。
常量的定义
常量的定义和变量类似,只不过它的关键字是 const。
下面的示例定义了一个常量 name它的值是“飞雪无情”。因为 Go 语言可以类型推导,所以在常量声明时也可以省略类型。
ch02/main.go
const name = "飞雪无情"
在 Go 语言中,只允许布尔型、字符串、数字类型这些基础类型作为常量。
iota
iota 是一个常量生成器,它可以用来初始化相似规则的常量,避免重复的初始化。假设我们要定义 one、two、three 和 four 四个常量,对应的值分别是 1、2、3 和 4如果不使用 iota则需要按照如下代码的方式定义
const(
one = 1
two = 2
three =3
four =4
)
以上声明都要初始化,会比较烦琐,因为这些常量是有规律的(连续的数字),所以可以使用 iota 进行声明,如下所示:
const(
one = iota+1
two
three
four
)
fmt.Println(one,two,three,four)
你自己可以运行程序,会发现打印的值和上面初始化的一样,也是 1、2、3、4。
iota 的初始值是 0它的能力就是在每一个有常量声明的行后面 +1下面我来分解上面的常量
one=(0)+1这时候 iota 的值为 0经过计算后one 的值为 1。
two=(0+1)+1这时候 iota 的值会 +1变成了 1经过计算后two 的值为 2。
three=(0+1+1)+1这时候 iota 的值会再 +1变成了 2经过计算后three 的值为 3。
four=(0+1+1+1)+1这时候 iota 的值会继续再 +1变成了 3经过计算后four 的值为 4。
如果你定义更多的常量,就依次类推,其中 () 内的表达式,表示 iota 自身 +1 的过程。
字符串
字符串是 Go 语言中常用的类型,在前面的基础类型小节中已经有过基本的介绍。这一小结会为你更详细地介绍字符串的使用。
字符串和数字互转
Go 语言是强类型的语言也就是说不同类型的变量是无法相互使用和计算的这也是为了保证Go 程序的健壮性,所以不同类型的变量在进行赋值或者计算前,需要先进行类型转换。涉及类型转换的知识点非常多,这里我先介绍这些基础类型之间的转换,更复杂的会在后面的课时介绍。
以字符串和数字互转这种最常见的情况为例,如下面的代码所示:
ch02/main.go
i2s:=strconv.Itoa(i)
s2i,err:=strconv.Atoi(i2s)
fmt.Println(i2s,s2i,err)
通过包 strconv 的 Itoa 函数可以把一个 int 类型转为 stringAtoi 函数则用来把 string 转为 int。
同理对于浮点数、布尔型Go 语言提供了 strconv.ParseFloat、strconv.ParseBool、strconv.FormatFloat 和 strconv.FormatBool 进行互转,你可以自己试试。
对于数字类型之间,可以通过强制转换的方式,如以下代码所示:
i2f:=float64(i)
f2i:=int(f64)
fmt.Println(i2f,f2i)
这种使用方式比简单,采用“类型(要转换的变量)”格式即可。采用强制转换的方式转换数字类型,可能会丢失一些精度,比如浮点型转为整型时,小数点部分会全部丢失,你可以自己运行上述示例,验证结果。
把变量转换为相应的类型后,就可以对相同类型的变量进行各种表达式运算和赋值了。
Strings 包
讲到基础类型,尤其是字符串,不得不提 Go SDK 为我们提供的一个标准包 strings。它是用于处理字符串的工具包里面有很多常用的函数帮助我们对字符串进行操作比如查找字符串、去除字符串的空格、拆分字符串、判断字符串是否有某个前缀或者后缀等。掌握好它有利于我们的高效编程。
以下代码是我写的关于 strings 包的一些例子你自己可以根据strings 文档自己写一些示例,多练习熟悉它们。
ch02/main.go
//判断s1的前缀是否是H
fmt.Println(strings.HasPrefix(s1,"H"))
//在s1中查找字符串o
fmt.Println(strings.Index(s1,"o"))
//把s1全部转为大写
fmt.Println(strings.ToUpper(s1))
总结
本节课我讲解了变量、常量的声明、初始化,以及变量的简短声明,同时介绍了常用的基础类型、数字和字符串的转换以及 strings 工具包的使用,有了这些,你就可以写出功能更强大的程序。
在基础类型中,还有一个没有介绍的基础类型——复数,它不常用,就留给你来探索。这里给你一个提示:复数是用 complex 这个内置函数创建的。
本节课的思考题是如何在一个字符串中查找某个字符串是否存在提示一下Go 语言自带的 strings 包里有现成的函数哦。

View File

@ -0,0 +1,277 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 控制结构if、for、switch 逻辑语句的那些事儿
在上节课中我留了一个思考题,在一个字符串中查找另外一个字符串是否存在,这个其实是字符串查找的功能,假如我需要在“飞雪无情”这个字符串中查找“飞雪”,可以这么做:
i:=strings.Index("飞雪无情","飞雪")
这就是 Go 语言标准库为我们提供的常用函数,以供我们使用,减少开发。
这节课我们继续讲解 Go 语言今天的内容是Go 语言代码逻辑的控制。
流程控制语句用于控制程序的执行顺序,这样你的程序就具备了逻辑结构。一般流程控制语句需要和各种条件结合使用,比如用于条件判断的 if用于选择的 switch用于循环的 for 等。这一节课,我会为你详细介绍,通过示例演示它们的使用方式。
if 条件语句
if 语句是条件语句,它根据布尔值的表达式来决定选择哪个分支执行:如果表达式的值为 true则 if 分支被执行;如果表达式的值为 false则 else 分支被执行。下面,我们来看一个 if 条件语句示例:
ch03/main.go
func main() {
i:=10
if i >10 {
fmt.Println("i>10")
} else {
fmt.Println("i<=10")
}
}
这是一个非常简单的 if……else 条件语句,当 i>10 为 true 的时候if 分支被执行,否则就执行 else 分支,你自己可以运行这段代码,验证打印结果。
关于 if 条件语句的使用有一些规则:
if 后面的条件表达式不需要使用 (),这和有些编程语言不一样,也更体现 Go 语言的简洁;
每个条件分支if 或者 else中的大括号是必须的哪怕大括号里只有一行代码如示例
if 紧跟的大括号 { 不能独占一行else 前的大括号 } 也不能独占一行,否则会编译不通过;
在 if……else 条件语句中还可以增加多个 else if增加更多的条件分支。
通过 go run ch03/main.go 运行下面的这段代码,会看到输出了 55 && i<=10 成立,该分支被执行。
ch03/main.go
func main() {
i:=6
if i >10 {
fmt.Println("i>10")
} else if i>5 && i<=10 {
fmt.Println("5<i<=10")
} else {
fmt.Println("i<=5")
}
}
你可以通过修改 i 的初始值来验证其他分支的执行情况
你还可以增加更多的 else if以增加更多的条件分支不过这种方式不被推荐因为代码可读性差多个条件分支可以使用我后面讲到的 switch 代替使代码更简洁
和其他编程语言不同 Go 语言的 if 语句中可以有一个简单的表达式语句并将该语句和条件语句使用分号 ; 分开同样是以上的示例我使用这种方式对其改造如下面代码所示
ch03/main.go
func main() {
if i:=6; i >10 {
fmt.Println("i>10")
} else if i>5 && i<=10 {
fmt.Println("5<i<=10")
} else {
fmt.Println("i<=5")
}
}
if 关键字之后i>10 条件语句之前,通过分号 ; 分隔被初始化的 i:=6。这个简单语句主要用来在 if 条件判断之前做一些初始化工作,可以发现输出结果是一样的。
通过 if 简单语句声明的变量,只能在整个 if……else if……else 条件语句中使用,比如以上示例中的变量 i。
switch 选择语句
if 条件语句比较适合分支较少的情况,如果有很多分支的话,选择 switch 会更方便,比如以上示例,使用 switch 改造后的代码如下:
ch03/main.go
switch i:=6;{
case i>10:
fmt.Println("i>10")
case i>5 && i<=10:
fmt.Println("5<i<=10")
default:
fmt.Println("i<=5")
}
switch 语句同样也可以用一个简单的语句来做初始化同样也是用分号 ; 分隔每一个 case 就是一个分支分支条件为 true 该分支才会执行而且 case 分支后的条件表达式也不用小括号 () 包裹
Go 语言中switch case 从上到下逐一进行判断一旦满足条件立即执行对应的分支并返回其余分支不再做判断也就是说 Go 语言的 switch 在默认情况下case 最后自带 break这和其他编程语言不一样比如 C 语言在 case 分支里必须要有明确的 break 才能退出一个 caseGo 语言的这种设计就是为了防止忘记写 break 下一个 case 被执行
那么如果你真的有需要的确需要执行下一个紧跟的 case 怎么办呢Go 语言也考虑到了提供了 fallthrough 关键字现在看个例子如下面的代码所示
ch03/main.go
switch j:=1;j {
case 1:
fallthrough
case 2:
fmt.Println("1")
default:
fmt.Println("没有匹配")
}
以上示例运行会输出 1如果省略 case 1: 后面的 fallthrough则不会有任何输出
不知道你是否可以发现和上一个例子对比这个例子的 switch 后面是有表达式的也就是输入了 ;j而上一个例子的 switch 后只有一个用于初始化的简单语句
switch 之后有表达式时case 后的值就要和这个表达式的结果类型相同比如这里的 j int 类型那么 case 后就只能使用 int 类型如示例中的 case 1case 2如果是其他类型比如使用 case a 会提示类型不匹配无法编译通过
而对于 switch 后省略表达式的情况整个 switch 结构就和 ifelse 条件语句等同了
switch 后的表达式也没有太多限制是一个合法的表达式即可也不用一定要求是常量或者整数你甚至可以像如下代码一样直接把比较表达式放在 switch 之后
ch03/main.go
switch 2>1 {
case true:
fmt.Println("2>1")
case false:
fmt.Println("2<=1")
}
可见 Go 语言的 switch 语句非常强大且灵活。
for 循环语句
当需要计算 1 到 100 的数字之和时,如果用代码将一个个数字加起来,会非常复杂,可读性也不好,这就体现出循环语句的存在价值了。
下面是一个经典的 for 循环示例,从这个示例中,我们可以分析出 for 循环由三部分组成,其中,需要使用两个 ; 分隔,如下所示:
ch03/main.go
sum:=0
for i:=1;i<=100;i++ {
sum+=i
}
fmt.Println("the sum is",sum)
其中:
第一部分是一个简单语句,一般用于 for 循环的初始化,比如这里声明了一个变量,并对 i:=1 初始化;
第二部分是 for 循环的条件,也就是说,它表示 for 循环什么时候结束。这里的条件是 i<=100
第三部分是更新语句,一般用于更新循环的变量,比如这里 i++,这样才能达到递增循环的目的。
需要特别留意的是Go 语言里的 for 循环非常强大,以上介绍的三部分组成都不是必须的,可以被省略,下面我就来为你演示,省略以上三部分后的效果。
如果你以前学过其他编程语言,可能会见到 while 这样的循环语句,在 Go 语言中没有 while 循环,但是可以通过 for 达到 while 的效果,如以下代码所示:
ch03/main.go
sum:=0
i:=1
for i<=100 {
sum+=i
i++
}
fmt.Println("the sum is",sum)
这个示例和上面的 for 示例的效果是一样的,但是这里的 for 后只有 i<=100 这一个条件语句,也就是说,它达到了 while 的效果。
在 Go 语言中,同样支持使用 continue、break 控制 for 循环:
continue 可以跳出本次循环,继续执行下一个循环。
break 可以跳出整个 for 循环,哪怕 for 循环没有执行完,也会强制终止。
现在我对上面计算 100 以内整数和的示例再进行修改,演示 break 的用法,如以下代码:
ch03/main.go
sum:=0
i:=1
for {
sum+=i
i++
if i>100 {
break
}
}
fmt.Println("the sum is",sum)
这个示例使用的是没有任何条件的 for 循环,也称为 for 无限循环。此外,使用 break 退出无限循环,条件是 i>100。
总结
这节课主要讲解 if、for 和 switch 这样的控制语句的基本用法,使用它们,你可以更好地控制程序的逻辑结构,达到业务需求的目的。
这节课的思考题是:任意举个例子,练习 for 循环 continue 的使用。
Go 语言提供的控制语句非常强大,本节课我并没有全部介绍,比如 switch 选择语句中的类型选择for 循环语句中的 for range 等高级能力。这些高级能力我会在后面的课程中逐一介绍,接下来要讲的集合类型,就会详细地为你演示如何使用 for range 遍历集合,记得来听课!

View File

@ -0,0 +1,423 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 集合类型:如何正确使用 array、slice 和 map
上节课的思考题是练习使用 for 循环中的 continue通过上节课的学习你已经了解 continue 是跳出本次循环的意思,现在我就以计算 100 以内的偶数之和为例,演示 continue 的用法:
sum := 0
for i:=1; i<100; i++{
if i%2!=0 {
continue
}
sum+=i
}
fmt.Println("the sum is",sum)
这个示例的关键在于如果 i 不是偶数就会用 continue 跳出本次循环继续下个循环如果是偶数则继续执行 sum+=i然后继续循环这样就达到了只计算 100 以内偶数之和的目的
下面我们开始本节课的学习我将介绍 Go 语言的集合类型
在实际需求中我们会有很多同一类型的元素放在一起的场景这就是集合例如 100 个数字10 个字符串等 Go 语言中数组array)、切片slice)、映射map这些都是集合类型用于存放同一类元素虽然都是集合但用处又不太一样这节课我就为你详细地介绍
Array数组
数组存放的是固定长度相同类型的数据而且这些存放的元素是连续的所存放的数据类型没有限制可以是整型字符串甚至自定义
数组声明
要声明一个数组非常简单语法和第二课时介绍的声明基础类型是一样的
在下面的代码示例中我声明了一个字符串数组长度是 5所以其类型定义为 [5]string其中大括号中的元素用于初始化数组此外在类型名前加 [] 中括号并设置好长度就可以通过它来推测数组的类型
注意[5]string [4]string 不是同一种类型也就是说长度也是数组类型的一部分
ch04/main.go
array:=[5]string{"a","b","c","d","e"}
数组在内存中都是连续存放的下面通过一幅图片形象地展示数组在内存中如何存放
可以看到数组的每个元素都是连续存放的每一个元素都有一个下标Index)。下标从 0 开始比如第一个元素 a 对应的下标是 0第二个元素 b 对应的下标是 1以此类推通过 array+[下标] 的方式我们可以快速地定位元素
如下面代码所示运行它可以看到输出打印的结果是 c也就是数组 array 的第三个元素
ch04/main.go
func main() {
array:=[5]string{"a","b","c","d","e"}
fmt.Println(array[2])
}
在定义数组的时候数组的长度可以省略这个时候 Go 语言会自动根据大括号 {} 中元素的个数推导出长度所以以上示例也可以像下面这样声明
array:=[...]string{"a","b","c","d","e"}
以上省略数组长度的声明只适用于所有元素都被初始化的数组如果是只针对特定索引元素初始化的情况就不适合了如下示例
array1:=[5]string{1:"b",3:"d"}
示例中的1:b”,3:d”」的意思表示初始化索引 1 的值为 b初始化索引 3 的值为 d整个数组的长度为 5如果我省略长度 5那么整个数组的长度只有 4显然不符合我们定义数组的初衷
此外没有初始化的索引其默认值都是数组类型的零值也就是 string 类型的零值 “” 空字符串
除了使用 [] 操作符根据索引快速定位数组的元素外还可以通过 for 循环打印所有的数组元素如下面的代码所示
ch04/main.go
for i:=0;i<5;i++{
fmt.Printf("数组索引:%d,对应值:%s\n", i, array[i])
}
数组循环
使用传统的 for 循环遍历数组输出对应的索引和对应的值这种方式很烦琐一般不使用大部分情况下我们使用的是 for range 这种 Go 语言的新型循环如下面的代码所示
for i,v:=range array{
fmt.Printf("数组索引:%d,对应值:%s\n", i, v)
}
这种方式和传统 for 循环的结果是一样的对于数组range 表达式返回两个结果
第一个是数组的索引
第二个是数组的值
在上面的示例中把返回的两个结果分别赋值给 i v 这两个变量就可以使用它们了
相比传统的 for 循环for range 要更简洁如果返回的值用不到可以使用 _ 下划线丢弃如下面的代码所示
for _,v:=range array{
fmt.Printf("对应值:%s\n", v)
}
数组的索引通过 _ 就被丢弃了只使用数组的值 v 即可
Slice切片
切片和数组类似可以把它理解为动态数组切片是基于数组实现的它的底层就是一个数组对数组任意分隔就可以得到一个切片现在我们通过一个例子来更好地理解它同样还是基于上述例子的 array
基于数组生成切片
下面代码中的 array[2:5] 就是获取一个切片的操作它包含从数组 array 的索引 2 开始到索引 5 结束的元素
array:=[5]string{"a","b","c","d","e"}
slice:=array[2:5]
fmt.Println(slice)
注意这里是包含索引 2但是不包含索引 5 的元素即在 : 右边的数字不会被包含
ch04/main.go
//基于数组生成切片包含索引start但是不包含索引end
slice:=array[start:end]
所以 array[2:5] 获取到的是 cde 这三个元素然后这三个元素作为一个切片赋值给变量 slice
切片和数组一样也可以通过索引定位元素这里以新获取的 slice 切片为例slice[0] 的值为 cslice[1] 的值为 d
有没有发现在数组 array 元素 c 的索引其实是 2但是对数组切片后在新生成的切片 slice 它的索引是 0这就是切片虽然切片底层用的也是 array 数组但是经过切片后切片的索引范围改变了
通过下图可以看出切片是一个具备三个字段的数据结构分别是指向数组的指针 data长度 len 和容量 cap
这里有一些小技巧切片表达式 array[start:end] 中的 start end 索引都是可以省略的如果省略 start那么 start 的值默认为 0如果省略 end那么 end 的默认值为数组的长度如下面的示例
array[:4] 等价于 array[0:4]。
array[1:] 等价于 array[1:5]。
array[:] 等价于 array[0:5]。
切片修改
切片的值也可以被修改这里也同时可以证明切片的底层是数组
对切片相应的索引元素赋值就是修改在下面的代码中把切片 slice 索引 1 的值修改为 f然后打印输出数组 array
slice:=array[2:5]
slice[1] ="f"
fmt.Println(array)
可以看到如下结果
[a b c f e]
数组对应的值已经被修改为 f所以这也证明了基于数组的切片使用的底层数组还是原来的数组一旦修改切片的元素值那么底层数组对应的值也会被修改
切片声明
除了可以从一个数组得到切片外还可以声明切片比较简单的是使用 make 函数
下面的代码是声明了一个元素类型为 string 的切片长度是 4make 函数还可以传入一个容量参数
slice1:=make([]string,4)
在下面的例子中指定了新创建的切片 []string 容量为 8
slice1:=make([]string,4,8)
这里需要注意的是切片的容量不能比切片的长度小
切片的长度你已经知道了就是切片内元素的个数那么容量是什么呢其实就是切片的空间
上面的示例说明Go 语言在内存上划分了一块容量为 8 的内容空间容量为 8但是只有 4 个内存空间才有元素长度为 4其他的内存空间处于空闲状态当通过 append 函数往切片中追加元素的时候会追加到空闲的内存上当切片的长度要超过容量的时候会进行扩容
切片不仅可以通过 make 函数声明也可以通过字面量的方式声明和初始化如下所示
slice1:=[]string{"a","b","c","d","e"}
fmt.Println(len(slice1),cap(slice1))
可以注意到切片和数组的字面量初始化方式差别就是中括号 [] 里的长度此外通过字面量初始化的切片长度和容量相同
Append
我们可以通过内置的 append 函数对一个切片追加元素返回新切片如下面的代码所示
//追加一个元素
slice2:=append(slice1,"f")
//多加多个元素
slice2:=append(slice1,"f","g")
//追加另一个切片
slice2:=append(slice1,slice...)
append 函数可以有以上三种操作你可以根据自己的实际需求进行选择append 会自动处理切片容量不足需要扩容的问题
小技巧在创建新切片的时候最好要让新切片的长度和容量一样这样在追加操作的时候就会生成新的底层数组从而和原有数组分离就不会因为共用底层数组导致修改内容的时候影响多个切片
切片元素循环
切片的循环和数组一模一样常用的也是 for range 方式这里就不再进行举例当作练习题留给你
Go 语言开发中切片是使用最多的尤其是作为函数的参数时相比数组通常会优先选择切片因为它高效内存占用小
Map映射
Go 语言中map 是一个无序的 K-V 键值对集合结构为 map[K]V其中 K 对应 KeyV 对应 Valuemap 中所有的 Key 必须具有相同的类型Value 也同样 Key Value 的类型可以不同此外Key 的类型必须支持 == 比较运算符这样才可以判断它是否存在并保证 Key 的唯一
Map 声明初始化
创建一个 map 可以通过内置的 make 函数如下面的代码所示
nameAgeMap:=make(map[string]int)
它的 Key 类型为 stringValue 类型为 int有了创建好的 map 变量就可以对它进行操作了
在下面的示例中我添加了一个键值对Key 为飞雪无情Value 20如果 Key 已经存在则更新 Key 对应的 Value
nameAgeMap["飞雪无情"] = 20
除了可以通过 make 函数创建 map 还可以通过字面量的方式同样是上面的示例我们用字面量的方式做如下操作
nameAgeMap:=map[string]int{"飞雪无情":20}
在创建 map 的同时添加键值对如果不想添加键值对使用空大括号 {} 即可要注意的是大括号一定不能省略
Map 获取和删除
map 的操作和切片数组差不多都是通过 [] 操作符只不过数组切片的 [] 中是索引 map [] 中是 Key如下面的代码所示
//添加键值对或者更新对应 Key Value
nameAgeMap["飞雪无情"] = 20
//获取指定 Key 对应的 Value
age:=nameAgeMap["飞雪无情"]
Go 语言的 map 可以获取不存在的 K-V 键值对如果 Key 不存在返回的 Value 是该类型的零值比如 int 的零值就是 0所以很多时候我们需要先判断 map 中的 Key 是否存在
map [] 操作符可以返回两个值
第一个值是对应的 Value
第二个值标记该 Key 是否存在如果存在它的值为 true
我们通过下面的代码进行演示
ch04/main.go
nameAgeMap:=make(map[string]int)
nameAgeMap["飞雪无情"] = 20
age,ok:=nameAgeMap["飞雪无情1"]
if ok {
fmt.Println(age)
}
在示例中age 是返回的 Valueok 用来标记该 Key 是否存在如果存在则打印 age
如果要删除 map 中的键值对使用内置的 delete 函数即可比如要删除 nameAgeMap Key 为飞雪无情的键值对我们用下面的代码进行演示
delete(nameAgeMap,"飞雪无情")
delete 有两个参数第一个参数是 map第二个参数是要删除键值对的 Key
遍历 Map
map 是一个键值对集合它同样可以被遍历 Go 语言中map 的遍历使用 for range 循环
对于 mapfor range 返回两个值
第一个是 map Key
第二个是 map Value
我们用下面的代码进行演示
ch04/main.go
//测试 for range
nameAgeMap["飞雪无情"] = 20
nameAgeMap["飞雪无情1"] = 21
nameAgeMap["飞雪无情2"] = 22
for k,v:=range nameAgeMap{
fmt.Println("Key is",k,",Value is",v)
}
需要注意的是 map 的遍历是无序的也就是说你每次遍历键值对的顺序可能会不一样如果想按顺序遍历可以先获取所有的 Key并对 Key 排序然后根据排序好的 Key 获取对应的 Value这里我不再进行演示你可以当作练习题
小技巧for range map 的时候也可以使用一个值返回使用一个返回值的时候这个返回值默认是 map Key
Map 的大小
和数组切片不一样map 是没有容量的它只有长度也就是 map 的大小键值对的个数)。要获取 map 的大小使用内置的 len 函数即可如下代码所示
fmt.Println(len(nameAgeMap))
String []byte
字符串 string 也是一个不可变的字节序列所以可以直接转为字节切片 []byte如下面的代码所示
ch04/main.go
s:="Hello飞雪无情"
bs:=[]byte(s)
string 不止可以直接转为 []byte还可以使用 [] 操作符获取指定索引的字节值如以下示例
ch04/main.go
s:="Hello飞雪无情"
bs:=[]byte(s)
fmt.Println(bs)
fmt.Println(s[0],s[1],s[15])
你可能会有疑惑在这个示例中字符串 s 里的字母和中文加起来不是 9 个字符吗怎么可以使用 s[15] 超过 9 的索引呢其实恰恰就是因为字符串是字节序列每一个索引对应的是一个字节而在 UTF8 编码下一个汉字对应三个字节所以字符串 s 的长度其实是 17
运行下面的代码就可以看到打印的结果是 17
fmt.Println(len(s))
如果你想把一个汉字当成一个长度计算可以使用 utf8.RuneCountInString 函数运行下面的代码可以看到打印结果是 9也就是 9 unicodeutf8字符和我们看到的字符的个数一致
fmt.Println(utf8.RuneCountInString(s))
而使用 for range 对字符串进行循环时也恰好是按照 unicode 字符进行循环的所以对于字符串 s 来说循环了 9
在下面示例的代码中i 是索引r unicode 字符对应的 unicode 码点这也说明了 for range 循环在处理字符串的时候会自动地隐式解码 unicode 字符串
ch04/main.go
for i,r:=range s{
fmt.Println(i,r)
}
总结
这节课到这里就要结束了在这节课里我讲解了数组切片和映射的声明和使用有了这些集合类型你就可以把你需要的某一类数据放到集合类型中了比如获取用户列表商品列表等
数组切片还可以分为二维和多维比如二维字节切片就是 [][]byte三维就是 [][][]byte因为不常用所以本节课中没有详细介绍你可以结合我讲的一维 []byte 切片自己尝试练习这也是本节课要给你留的思考题创建一个二维数组并使用它
此外如果 map Key 的类型是整型并且集合中的元素比较少应该尽量选择切片因为效率更高在实际的项目开发中数组并不常用尤其是在函数间作为参数传递的时候用得最多的是切片它更灵活并且内存占用少

View File

@ -0,0 +1,444 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 函数和方法Go 语言中的函数和方法到底有什么不同?
上一讲的思考题是创建一个二维数组并使用。上节课,我主要介绍了一维数组,其实二维数组也很简单,仿照一维数组即可,如下面的代码所示:
aa:=[3][3]int{}
aa[0][0] =1
aa[0][1] =2
aa[0][2] =3
aa[1][0] =4
aa[1][1] =5
aa[1][2] =6
aa[2][0] =7
aa[2][1] =8
aa[2][2] =9
fmt.Println(aa)
相信你也完成了,现在学习我们本节课要讲的函数和方法。
函数和方法是我们迈向代码复用、多人协作开发的第一步。通过函数,可以把开发任务分解成一个个小的单元,这些小单元可以被其他单元复用,进而提高开发效率、降低代码重合度。再加上现成的函数已经被充分测试和使用过,所以其他函数在使用这个函数时也更安全,比你自己重新写一个相似功能的函数 Bug 率更低。
这节课,我会详细讲解 Go 语言的函数和方法,了解它们的声明、使用和不同。虽然在 Go 语言中有函数和方法两种概念,但它们的相似度非常高,只是所属的对象不同。我们先从函数开始了解。
函数
函数初探
在前面的四节课中,你已经见到了 Go 语言中一个非常重要的函数main 函数,它是一个 Go 语言程序的入口函数,我在演示代码示例的时候,会一遍遍地使用它。
下面的示例就是一个 main 函数:
func main() {
}
它由以下几部分构成:
任何一个函数的定义,都有一个 func 关键字,用于声明一个函数,就像使用 var 关键字声明一个变量一样;
然后紧跟的 main 是函数的名字,命名符合 Go 语言的规范即可,比如不能以数字开头;
main 函数名字后面的一对括号 () 是不能省略的,括号里可以定义函数使用的参数,这里的 main 函数没有参数,所以是空括号 ()
括号 () 后还可以有函数的返回值,因为 main 函数没有返回值,所以这里没有定义;
最后就是大括号 {} 函数体了,你可以在函数体里书写代码,写该函数自己的业务逻辑。
函数声明
经过上一小节的介绍,相信你已经对 Go 语言函数的构成有一个比较清晰的了解了,现在让我们一起总结出函数的声明格式,如下面的代码所示:
func funcName(params) result {
body
}
这就是一个函数的签名定义,它包含以下几个部分:
关键字 func
函数名字 funcName
函数的参数 params用来定义形参的变量名和类型可以有一个参数也可以有多个也可以没有
result 是返回的函数值,用于定义返回值的类型,如果没有返回值,省略即可,也可以有多个返回值;
body 就是函数体,可以在这里写函数的代码逻辑。
现在,我们一起根据上面的函数声明格式,自定义一个函数,如下所示:
ch05/main.go
func sum(a int,b int) int{
return a+b
}
这是一个计算两数之和的函数,函数的名字是 sum它有两个参数 a、b参数的类型都是 int。sum 函数的返回值也是 int 类型,函数体部分就是把 a 和 b 相加,然后通过 return 关键字返回,如果函数没有返回值,可以不用使用 return 关键字。
终于可以声明自己的函数了,恭喜你迈出了一大步!
函数中形参的定义和我们定义变量是一样的,都是变量名称在前,变量类型在后,只不过在函数里,变量名称叫作参数名称,也就是函数的形参,形参只能在该函数体内使用。函数形参的值由调用者提供,这个值也称为函数的实参,现在我们传递实参给 sum 函数,演示函数的调用,如下面的代码所示:
ch05/main.go
func main() {
result:=sum(1,2)
fmt.Println(result)
}
我们自定义的 sum 函数,在 main 函数中直接调用,调用的时候需要提供真实的参数,也就是实参 1 和 2。
函数的返回值被赋值给变量 result然后把这个结果打印出来。你可以自己运行一下能看到结果是 3这样我们就通过函数 sum 达到了两数相加的目的,如果其他业务逻辑也需要两数相加,那么就可以直接使用这个 sum 函数,不用再定义了。
在以上函数定义中a 和 b 形参的类型是一样的,这个时候我们可以省略其中一个类型的声明,如下所示:
func sum(a, b int) int {
return a + b
}
像这样使用逗号分隔变量,后面统一使用 int 类型,这和变量的声明是一样的,多个相同类型的变量都可以这么声明。
多值返回
同有的编程语言不一样Go 语言的函数可以返回多个值,也就是多值返回。在 Go 语言的标准库中,你可以看到很多这样的函数:第一个值返回函数的结果,第二个值返回函数出错的信息,这种就是多值返回的经典应用。
对于 sum 函数,假设我们不允许提供的实参是负数,可以这样改造:在实参是负数的时候,通过多值返回,返回函数的错误信息,如下面的代码所示:
ch05/main.go
func sum(a, b int) (int,error){
if a<0 || b<0 {
return 0,errors.New("a或者b不能是负数")
}
return a + b,nil
}
这里需要注意的是如果函数有多个返回值返回值部分的类型定义需要使用小括号括起来也就是 (int,error)这代表函数 sum 有两个返回值第一个是 int 类型第二个是 error 类型我们在函数体中使用 return 返回结果的时候也要符合这个类型顺序
在函数体中可以使用 return 返回多个值返回的多个值通过逗号分隔即可返回多个值的类型顺序要和函数声明的返回类型顺序一致比如下面的例子
return 0,errors.New("a或者b不能是负数")
返回的第一个值 0 int 类型第二个值是 error 类型和函数定义的返回类型完全一致
定义好了多值返回的函数现在我们用如下代码尝试调用
ch05/main.go
func main() {
result,err := sum(1, 2)
if err!=nil {
fmt.Println(err)
}else {
fmt.Println(result)
}
}
函数有多值返回的时候需要有多个变量接收它的值示例中使用 result err 变量使用逗号分开
如果有的函数的返回值不需要可以使用下划线 _ 丢弃它这种方式我在 for range 循环那节课里也使用过如下所示
result,_ := sum(1, 2)
这样即可忽略函数 sum 返回的错误信息也不用再做判断
提示这里使用的 error Go 语言内置的一个接口用于表示程序的错误信息后续课程我会详细介绍
命名返回参数
不止函数的参数可以有变量名称函数的返回值也可以也就是说你可以为每个返回值都起一个名字这个名字可以像参数一样在函数体内使用
现在我们继续对 sum 函数的例子进行改造为其返回值命名如下面的代码所示
ch05/main.go
func sum(a, b int) (sum int,err error){
if a<0 || b<0 {
return 0,errors.New("a或者b不能是负数")
}
sum=a+b
err=nil
return
}
返回值的命名和参数变量都是一样的名称在前类型在后以上示例中命名的两个返回值名称一个是 sum一个是 err这样就可以在函数体中使用它们了
通过下面示例中的这种方式直接为命名返回参数赋值也就等于函数有了返回值所以就可以忽略 return 的返回值了也就是说示例中只有一个 returnreturn 后没有要返回的值
sum=a+b
err=nil
通过命名返回参数的赋值方式和直接使用 return 返回值的方式结果是一样的所以调用以上 sum 函数返回的结果也一样
虽然 Go 语言支持函数返回值命名但是并不是太常用根据自己的需求情况酌情选择是否对函数返回值命名
可变参数
可变参数就是函数的参数数量是可变的比如最常见的 fmt.Println 函数
同样一个函数可以不传参数也可以传递一个参数也可以两个参数也可以是多个等等这种函数就是具有可变参数的函数如下所示
fmt.Println()
fmt.Println("飞雪")
fmt.Println("飞雪","无情")
下面所演示的是 Println 函数的声明从中可以看到定义可变参数只要在参数类型前加三个点 即可
func Println(a ...interface{}) (n int, err error)
现在我们也可以定义自己的可变参数的函数了还是以 sum 函数为例在下面的代码中我通过可变参数的方式计算调用者传递的所有实参的和
ch05/main.go
func sum1(params ...int) int {
sum := 0
for _, i := range params {
sum += i
}
return sum
}
为了便于和 sum 函数区分我定义了函数 sum1该函数的参数是一个可变参数然后通过 for range 循环来计算这些参数之和
讲到这里相信你也看明白了可变参数的类型其实就是切片比如示例中 params 参数的类型是 []int所以可以使用 for range 进行循环
函数有了可变参数就可以灵活地进行使用了
如下面的调用者示例传递几个参数都可以非常方便也更灵活
ch05/main.go
fmt.Println(sum1(1,2))
fmt.Println(sum1(1,2,3))
fmt.Println(sum1(1,2,3,4))
这里需要注意如果你定义的函数中既有普通参数又有可变参数那么可变参数一定要放在参数列表的最后一个比如 sum1(tip string,params int) params 可变参数一定要放在最末尾
包级函数
不管是自定义的函数 sumsum1还是我们使用到的函数 Println都会从属于一个包也就是 packagesum 函数属于 main Println 函数属于 fmt
同一个包中的函数哪怕是私有的函数名称首字母小写也可以被调用如果不同包的函数要被调用那么函数的作用域必须是公有的也就是函数名称的首字母要大写比如 Println
在后面的包作用域和模块化的课程中我会详细讲解这里可以先记住
函数名称首字母小写代表私有函数只有在同一个包中才可以被调用
函数名称首字母大写代表公有函数不同的包也可以调用
任何一个函数都会从属于一个包
小提示Go 语言没有用 publicprivate 这样的修饰符来修饰函数是公有还是私有而是通过函数名称的大小写来代表这样省略了烦琐的修饰符更简洁
匿名函数和闭包
顾名思义匿名函数就是没有名字的函数这是它和正常函数的主要区别
在下面的示例中变量 sum2 所对应的值就是一个匿名函数需要注意的是这里的 sum2 只是一个函数类型的变量并不是函数的名字
ch05/main.go
func main() {
sum2 := func(a, b int) int {
return a + b
}
fmt.Println(sum2(1, 2))
}
通过 sum2我们可以对匿名函数进行调用以上示例算出的结果是 3和使用正常的函数一样
有了匿名函数就可以在函数中再定义函数函数嵌套定义的这个匿名函数也可以称为内部函数更重要的是在函数内定义的内部函数可以使用外部函数的变量等这种方式也称为闭包
我们用下面的代码进行演示
ch05/main.go
func main() {
cl:=colsure()
fmt.Println(cl())
fmt.Println(cl())
fmt.Println(cl())
}
func colsure() func() int {
i:=0
return func() int {
i++
return i
}
}
运行这个代码你会看到输出打印的结果是
1
2
3
这都得益于匿名函数闭包的能力让我们自定义的 colsure 函数可以返回一个匿名函数并且持有外部函数 colsure 的变量 i因而在 main 函数中每调用一次 cl()i 的值就会加 1
小提示 Go 语言中函数也是一种类型它也可以被用来声明函数类型的变量参数或者作为另一个函数的返回值类型
方法
不同于函数的方法
Go 语言中方法和函数是两个概念但又非常相似不同点在于方法必须要有一个接收者这个接收者是一个类型这样方法就和这个类型绑定在一起称为这个类型的方法
在下面的示例中type Age uint 表示定义一个新类型 Age该类型等价于 uint可以理解为类型 uint 的重命名其中 type Go 语言关键字表示定义一个类型在结构体和接口的课程中我会详细介绍
ch05/main.go
type Age uint
func (age Age) String(){
fmt.Println("the age is",age)
}
示例中方法 String() 就是类型 Age 的方法类型 Age 是方法 String() 的接收者
和函数不同定义方法时会在关键字 func 和方法名 String 之间加一个接收者 (age Age) 接收者使用小括号包围
接收者的定义和普通变量函数参数等一样前面是变量名后面是接收者类型
现在方法 String() 就和类型 Age 绑定在一起了String() 是类型 Age 的方法
定义了接收者的方法后就可以通过点操作符调用方法如下面的代码所示
ch05/main.go
func main() {
age:=Age(25)
age.String()
}
运行这段代码可以看到如下输出
the age is 25
接收者就是函数和方法的最大不同此外上面所讲到的函数具备的能力方法也都具备
提示因为 25 也是 unit 类型unit 类型等价于我定义的 Age 类型所以 25 可以强制转换为 Age 类型
值类型接收者和指针类型接收者
方法的接收者除了可以是值类型比如上一小节的示例也可以是指针类型
定义的方法的接收者类型是指针所以我们对指针的修改是有效的如果不是指针修改就没有效果如下所示
ch05/main.go
func (age *Age) Modify(){
*age = Age(30)
}
调用一次 Modify 方法后再调用 String 方法查看结果会发现已经变成了 30说明基于指针的修改有效如下所示
age:=Age(25)
age.String()
age.Modify()
age.String()
提示在调用方法的时候传递的接收者本质上都是副本只不过一个是这个值副本一是指向这个值指针的副本指针具有指向原有值的特性所以修改了指针指向的值也就修改了原有的值我们可以简单地理解为值接收者使用的是值的副本来调用方法而指针接收者使用实际的值来调用方法
示例中调用指针接收者方法的时候使用的是一个值类型的变量并不是一个指针类型其实这里使用指针变量调用也是可以的如下面的代码所示
(&age).Modify()
这就是 Go 语言编译器帮我们自动做的事情
如果使用一个值类型变量调用指针类型接收者的方法Go 语言编译器会自动帮我们取指针调用以满足指针接收者的要求
同样的原理如果使用一个指针类型变量调用值类型接收者的方法Go 语言编译器会自动帮我们解引用调用以满足值类型接收者的要求
总之方法的调用者既可以是值也可以是指针不用太关注这些Go 语言会帮我们自动转义大大提高开发效率同时避免因不小心造成的 Bug
不管是使用值类型接收者还是指针类型接收者要先确定你的需求在对类型进行操作的时候是要改变当前接收者的值还是要创建一个新值进行返回这些就可以决定使用哪种接收者
总结
Go 语言中虽然存在函数和方法两个概念但是它们基本相同不同的是所属的对象函数属于一个包方法属于一个类型所以方法也可以简单地理解为和一个类型关联的函数
不管是函数还是方法它们都是代码复用的第一步也是代码职责分离的基础掌握好函数和方法可以让你写出职责清晰任务明确可复用的代码提高开发效率降低 Bug
本节课给你留的思考题是方法是否可以作为表达式赋值给一个变量如果可以的话如何通过这个变量调用方法

View File

@ -0,0 +1,433 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 struct 和 interface结构体与接口都实现了哪些功能
上节课我留了一个思考题:方法是否可以赋值给一个变量?如果可以,要怎么调用它呢?答案是完全可以,方法赋值给变量称为方法表达式,如下面的代码所示:
age:=Age(25)
//方法赋值给变量,方法表达式
sm:=Age.String
//通过变量要传一个接收者进行调用也就是age
sm(age)
我们知道,方法 String 其实是没有参数的,但是通过方法表达式赋值给变量 sm 后,在调用的时候,必须要传一个接收者,这样 sm 才知道怎么调用。
小提示:不管方法是否有参数,通过方法表达式调用,第一个参数必须是接收者,然后才是方法自身的参数。
下面开始我们今天的课程。之前讲到的类型如整型、字符串等只能描述单一的对象,如果是聚合对象,就无法描述了,比如一个人具备的名字、年龄和性别等信息。因为人作为对象是一个聚合对象,要想描述它需要使用这节课要讲的结构体。
结构体
结构体定义
结构体是一种聚合类型,里面可以包含任意类型的值,这些值就是我们定义的结构体的成员,也称为字段。在 Go 语言中,要自定义一个结构体,需要使用 type+struct 关键字组合。
在下面的例子中,我自定义了一个结构体类型,名称为 person表示一个人。这个 person 结构体有两个字段name 代表这个人的名字age 代表这个人的年龄。
ch06/main.go
type person struct {
name string
age uint
}
在定义结构体时,字段的声明方法和平时声明一个变量是一样的,都是变量名在前,类型在后,只不过在结构体中,变量名称为成员名或字段名。
结构体的成员字段并不是必需的,也可以一个字段都没有,这种结构体成为空结构体。
根据以上信息,我们可以总结出结构体定义的表达式,如下面的代码所示:
type structName struct{
fieldName typeName
....
....
}
其中:
type 和 struct 是 Go 语言的关键字,二者组合就代表要定义一个新的结构体类型。
structName 是结构体类型的名字。
fieldName 是结构体的字段名,而 typeName 是对应的字段类型。
字段可以是零个、一个或者多个。
小提示:结构体也是一种类型,所以以后自定义的结构体,我会称为某结构体或某类型,两者是一个意思。比如 person 结构体和 person 类型其实是一个意思。
定义好结构体后就可以使用了,因为它是一个聚合类型,所以比普通的类型可以携带更多数据。
结构体声明使用
结构体类型和普通的字符串、整型一样,也可以使用同样的方式声明和初始化。
在下面的例子中,我声明了一个 person 类型的变量 p因为没有对变量 p 初始化,所以默认会使用结构体里字段的零值。
var p person
当然在声明一个结构体变量的时候,也可以通过结构体字面量的方式初始化,如下面的代码所示:
p:=person{"飞雪无情",30}
采用简短声明法,同时采用字面量初始化的方式,把结构体变量 p 的 name 初始化为“飞雪无情”age 初始化为 30以逗号分隔。
声明了一个结构体变量后就可以使用了,下面我们运行以下代码,验证 name 和 age 的值是否和初始化的一样。
fmt.Println(p.name,p.age)
在 Go 语言中,访问一个结构体的字段和调用一个类型的方法一样,都是使用点操作符“.”。
采用字面量初始化结构体时,初始化值的顺序很重要,必须和字段定义的顺序一致。
在 person 这个结构体中,第一个字段是 string 类型的 name第二个字段是 uint 类型的 age所以在初始化的时候初始化值的类型顺序必须一一对应才能编译通过。也就是说在示例 {“飞雪无情”,30} 中,表示 name 的字符串飞雪无情必须在前,表示年龄的数字 30 必须在后。
那么是否可以不按照顺序初始化呢?当然可以,只不过需要指出字段名称,如下所示:
p:=person{age:30,name:"飞雪无情"}
其中,第一位我放了整型的 age也可以编译通过因为采用了明确的 field:value 方式进行指定,这样 Go 语言编译器会清晰地知道你要初始化哪个字段的值。
有没有发现,这种方式和 map 类型的初始化很像都是采用冒号分隔。Go 语言尽可能地重用操作,不发明新的表达式,便于我们记忆和使用。
当然你也可以只初始化字段 age字段 name 使用默认的零值,如下面的代码所示,仍然可以编译通过。
p:=person{age:30}
字段结构体
结构体的字段可以是任意类型,也包括自定义的结构体类型,比如下面的代码:
ch06/main.go
type person struct {
name string
age uint
addr address
}
type address struct {
province string
city string
}
在这个示例中我定义了两个结构体person 表示人address 表示地址。在结构体 person 中,有一个 address 类型的字段 addr这就是自定义的结构体。
通过这种方式,用代码描述现实中的实体会更匹配,复用程度也更高。对于嵌套结构体字段的结构体,其初始化和正常的结构体大同小异,只需要根据字段对应的类型初始化即可,如下面的代码所示:
ch06/main.go
p:=person{
age:30,
name:"飞雪无情",
addr:address{
province: "北京",
city: "北京",
},
}
如果需要访问结构体最里层的 province 字段的值,同样也可以使用点操作符,只不过需要使用两个点,如下面的代码所示:
ch06/main.go
fmt.Println(p.addr.province)
第一个点获取 addr第二个点获取 addr 的 province。
接口
接口的定义
接口是和调用方的一种约定,它是一个高度抽象的类型,不用和具体的实现细节绑定在一起。接口要做的是定义好约定,告诉调用方自己可以做什么,但不用知道它的内部实现,这和我们见到的具体的类型如 int、map、slice 等不一样。
接口的定义和结构体稍微有些差别,虽然都以 type 关键字开始,但接口的关键字是 interface表示自定义的类型是一个接口。也就是说 Stringer 是一个接口,它有一个方法 String() string整体如下面的代码所示
src/fmt/print.go
type Stringer interface {
String() string
}
提示Stringer 是 Go SDK 的一个接口,属于 fmt 包。
针对 Stringer 接口来说,它会告诉调用者可以通过它的 String() 方法获取一个字符串,这就是接口的约定。至于这个字符串怎么获得的,长什么样,接口不关心,调用者也不用关心,因为这些是由接口实现者来做的。
接口的实现
接口的实现者必须是一个具体的类型,继续以 person 结构体为例,让它来实现 Stringer 接口,如下代码所示:
ch06/main.go
func (p person) String() string{
return fmt.Sprintf("the name is %s,age is %d",p.name,p.age)
}
给结构体类型 person 定义一个方法,这个方法和接口里方法的签名(名称、参数和返回值)一样,这样结构体 person 就实现了 Stringer 接口。
注意:如果一个接口有多个方法,那么需要实现接口的每个方法才算是实现了这个接口。
实现了 Stringer 接口后就可以使用了。首先我先来定义一个可以打印 Stringer 接口的函数,如下所示:
ch06/main.go
func printString(s fmt.Stringer){
fmt.Println(s.String())
}
这个被定义的函数 printString它接收一个 Stringer 接口类型的参数,然后打印出 Stringer 接口的 String 方法返回的字符串。
printString 这个函数的优势就在于它是面向接口编程的,只要一个类型实现了 Stringer 接口,都可以打印出对应的字符串,而不用管具体的类型实现。
因为 person 实现了 Stringer 接口,所以变量 p 可以作为函数 printString 的参数,可以用如下方式打印:
printString(p)
结果为:
the name is 飞雪无情,age is 30
现在让结构体 address 也实现 Stringer 接口,如下面的代码所示:
ch06/main.go
func (addr address) String() string{
return fmt.Sprintf("the addr is %s%s",addr.province,addr.city)
}
因为结构体 address 也实现了 Stringer 接口,所以 printString 函数不用做任何改变,可以直接被使用,打印出地址,如下所示:
printString(p.addr)
//输出the addr is 北京北京
这就是面向接口的好处,只要定义和调用双方满足约定,就可以使用,而不用管具体实现。接口的实现者也可以更好的升级重构,而不会有任何影响,因为接口约定没有变。
值接收者和指针接收者
我们已经知道,如果要实现一个接口,必须实现这个接口提供的所有方法,而且在上节课讲解方法的时候,我们也知道定义一个方法,有值类型接收者和指针类型接收者两种。二者都可以调用方法,因为 Go 语言编译器自动做了转换,所以值类型接收者和指针类型接收者是等价的。但是在接口的实现中,值类型接收者和指针类型接收者不一样,下面我会详细分析二者的区别。
在上一小节中,已经验证了结构体类型实现了 Stringer 接口,那么结构体对应的指针是否也实现了该接口呢?我通过下面这个代码进行测试:
printString(&p)
测试后会发现,把变量 p 的指针作为实参传给 printString 函数也是可以的,编译运行都正常。这就证明了以值类型接收者实现接口的时候,不管是类型本身,还是该类型的指针类型,都实现了该接口。
示例中值接收者p person实现了 Stringer 接口,那么类型 person 和它的指针类型*person就都实现了 Stringer 接口。
现在,我把接收者改成指针类型,如下代码所示:
func (p *person) String() string{
return fmt.Sprintf("the name is %s,age is %d",p.name,p.age)
}
修改成指针类型接收者后会发现,示例中这行 printString(p) 代码编译不通过,提示如下错误:
./main.go:17:13: cannot use p (type person) as type fmt.Stringer in argument to printString:
person does not implement fmt.Stringer (String method has pointer receiver)
意思就是类型 person 没有实现 Stringer 接口。这就证明了以指针类型接收者实现接口的时候,只有对应的指针类型才被认为实现了该接口。
我用如下表格为你总结这两种接收者类型的接口实现规则:
可以这样解读:
当值类型作为接收者时person 类型和*person类型都实现了该接口。
当指针类型作为接收者时,只有*person类型实现了该接口。
可以发现,实现接口的类型都有*person这也表明指针类型比较万能不管哪一种接收者它都能实现该接口。
工厂函数
工厂函数一般用于创建自定义的结构体,便于使用者调用,我们还是以 person 类型为例,用如下代码进行定义:
func NewPerson(name string) *person {
return &person{name:name}
}
我定义了一个工厂函数 NewPerson它接收一个 string 类型的参数,用于表示这个人的名字,同时返回一个*person。
通过工厂函数创建自定义结构体的方式,可以让调用者不用太关注结构体内部的字段,只需要给工厂函数传参就可以了。
用下面的代码,即可创建一个*person 类型的变量 p1
p1:=NewPerson("张三")
工厂函数也可以用来创建一个接口,它的好处就是可以隐藏内部具体类型的实现,让调用者只需关注接口的使用即可。
现在我以 errors.New 这个 Go 语言自带的工厂函数为例,演示如何通过工厂函数创建一个接口,并隐藏其内部实现,如下代码所示:
errors/errors.go
//工厂函数返回一个error接口其实具体实现是*errorString
func New(text string) error {
return &errorString{text}
}
//结构体内部一个字段s存储错误信息
type errorString struct {
s string
}
//用于实现error接口
func (e *errorString) Error() string {
return e.s
}
其中errorString 是一个结构体类型,它实现了 error 接口,所以可以通过 New 工厂函数,创建一个 *errorString 类型,通过接口 error 返回。
这就是面向接口的编程,假设重构代码,哪怕换一个其他结构体实现 error 接口,对调用者也没有影响,因为接口没变。
继承和组合
在 Go 语言中没有继承的概念所以结构、接口之间也没有父子关系Go 语言提倡的是组合,利用组合达到代码复用的目的,这也更灵活。
我同样以 Go 语言 io 标准包自带的接口为例,讲解类型的组合(也可以称之为嵌套),如下代码所示:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
//ReadWriter是Reader和Writer的组合
type ReadWriter interface {
Reader
Writer
}
ReadWriter 接口就是 Reader 和 Writer 的组合组合后ReadWriter 接口具有 Reader 和 Writer 中的所有方法,这样新接口 ReadWriter 就不用定义自己的方法了,组合 Reader 和 Writer 的就可以了。
不止接口可以组合,结构体也可以组合,现在把 address 结构体组合到结构体 person 中,而不是当成一个字段,如下所示:
ch06/main.go
type person struct {
name string
age uint
address
}
直接把结构体类型放进来,就是组合,不需要字段名。组合后,被组合的 address 称为内部类型person 称为外部类型。修改了 person 结构体后,声明和使用也需要一起修改,如下所示:
p:=person{
age:30,
name:"飞雪无情",
address:address{
province: "北京",
city: "北京",
},
}
//像使用自己的字段一样,直接使用
fmt.Println(p.province)
因为 person 组合了 address所以 address 的字段就像 person 自己的一样,可以直接使用。
类型组合后,外部类型不仅可以使用内部类型的字段,也可以使用内部类型的方法,就像使用自己的方法一样。如果外部类型定义了和内部类型同样的方法,那么外部类型的会覆盖内部类型,这就是方法的覆写。关于方法的覆写,这里不再进行举例,你可以自己试一下。
小提示:方法覆写不会影响内部类型的方法实现。
类型断言
有了接口和实现接口的类型,就会有类型断言。类型断言用来判断一个接口的值是否是实现该接口的某个具体类型。
还是以我们上面小节的示例演示,我们先来回忆一下它们,如下所示:
func (p *person) String() string{
return fmt.Sprintf("the name is %s,age is %d",p.name,p.age)
}
func (addr address) String() string{
return fmt.Sprintf("the addr is %s%s",addr.province,addr.city)
}
可以看到,*person 和 address 都实现了接口 Stringer然后我通过下面的示例讲解类型断言
var s fmt.Stringer
s = p1
p2:=s.(*person)
fmt.Println(p2)
如上所示,接口变量 s 称为接口 fmt.Stringer 的值,它被 p1 赋值。然后使用类型断言表达式 s.(*person),尝试返回一个 p2。如果接口的值 s 是一个*person那么类型断言正确可以正常返回 p2。如果接口的值 s 不是一个 *person那么在运行时就会抛出异常程序终止运行。
小提示:这里返回的 p2 已经是 *person 类型了,也就是在类型断言的时候,同时完成了类型转换。
在上面的示例中,因为 s 的确是一个 *person所以不会异常可以正常返回 p2。但是如果我再添加如下代码对 s 进行 address 类型断言,就会出现一些问题:
a:=s.(address)
fmt.Println(a)
这个代码在编译的时候不会有问题,因为 address 实现了接口 Stringer但是在运行的时候会抛出如下异常信息
panic: interface conversion: fmt.Stringer is *main.person, not main.address
这显然不符合我们的初衷我们本来想判断一个接口的值是否是某个具体类型但不能因为判断失败就导致程序异常。考虑到这点Go 语言为我们提供了类型断言的多值返回,如下所示:
a,ok:=s.(address)
if ok {
fmt.Println(a)
}else {
fmt.Println("s不是一个address")
}
类型断言返回的第二个值“ok”就是断言是否成功的标志如果为 true 则成功,否则失败。
总结
这节课虽然只讲了结构体和接口,但是所涉及的知识点很多,整节课比较长,希望你可以耐心地学完。
结构体是对现实世界的描述,接口是对某一类行为的规范和抽象。通过它们,我们可以实现代码的抽象和复用,同时可以面向接口编程,把具体实现细节隐藏起来,让写出来的代码更灵活,适应能力也更强。

View File

@ -0,0 +1,341 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 错误处理:如何通过 error、deferred、panic 等处理错误?
上节课我为你讲解了结构体和接口,并留了一个小作业,让你自己练习实现有两个方法的接口。现在我就以“人既会走也会跑”为例进行讲解。
首先定义一个接口 WalkRun它有两个方法 Walk 和 Run如下面的代码所示
type WalkRun interface {
Walk()
Run()
}
现在就可以让结构体 person 实现这个接口了,如下所示:
func (p *person) Walk(){
fmt.Printf("%s能走\n",p.name)
}
func (p *person) Run(){
fmt.Printf("%s能跑\n",p.name)
}
关键点在于,让接口的每个方法都实现,也就实现了这个接口。
提示:%s 是占位符,和 p.name 对应,也就是 p.name 的值,具体可以参考 fmt.Printf 函数的文档。
下面进行本节课的讲解。这节课我会带你学习 Go 语言的错误和异常,在我们编写程序的时候,可能会遇到一些问题,该怎么处理它们呢?
错误
在 Go 语言中,错误是可以预期的,并且不是非常严重,不会影响程序的运行。对于这类问题,可以用返回错误给调用者的方法,让调用者自己决定如何处理。
error 接口
在 Go 语言中,错误是通过内置的 error 接口表示的。它非常简单,只有一个 Error 方法用来返回具体的错误信息,如下面的代码所示:
type error interface {
Error() string
}
在下面的代码中,我演示了一个字符串转整数的例子:
ch07/main.go
func main() {
i,err:=strconv.Atoi("a")
if err!=nil {
fmt.Println(err)
}else {
fmt.Println(i)
}
}
这里我故意使用了字符串 “a”尝试把它转为整数。我们知道 “a” 是无法转为数字的,所以运行这段程序,会打印出如下错误信息:
strconv.Atoi: parsing "a": invalid syntax
这个错误信息就是通过接口 error 返回的。我们来看关于函数 strconv.Atoi 的定义,如下所示:
func Atoi(s string) (int, error)
一般而言error 接口用于当方法或者函数执行遇到错误时进行返回,而且是第二个返回值。通过这种方式,可以让调用者自己根据错误信息决定如何进行下一步处理。
小提示:因为方法和函数基本上差不多,区别只在于有无接收者,所以以后当我称方法或函数,表达的是一个意思,不会把这两个名字都写出来。
error 工厂函数
除了可以使用其他函数,自己定义的函数也可以返回错误信息给调用者,如下面的代码所示:
ch07/main.go
func add(a,b int) (int,error){
if a<0 || b<0 {
return 0,errors.New("a或者b不能为负数")
}else {
return a+b,nil
}
}
add 函数会在 a 或者 b 任何一个为负数的情况下返回一个错误信息如果 ab 都不为负数错误信息部分会返回 nil这也是常见的做法所以调用者可以通过错误信息是否为 nil 进行判断
下面的 add 函数示例是使用 errors.New 这个工厂函数生成的错误信息它接收一个字符串参数返回一个 error 接口这些在上节课的结构体和接口部分有过详细介绍不再赘述
ch07/main.go
sum,err:=add(-1,2)
if err!=nil {
fmt.Println(err)
}else {
fmt.Println(sum)
}
自定义 error
你可能会想上面采用工厂返回错误信息的方式只能传递一个字符串也就是携带的信息只有字符串如果想要携带更多信息比如错误码信息该怎么办呢这个时候就需要自定义 error
自定义 error 其实就是先自定义一个新类型比如结构体然后让这个类型实现 error 接口如下面的代码所示
ch07/main.go
type commonError struct {
errorCode int //错误码
errorMsg string //错误信息
}
func (ce *commonError) Error() string{
return ce.errorMsg
}
有了自定义的 error就可以使用它携带更多的信息现在我改造上面的例子返回刚刚自定义的 commonError如下所示
ch07/main.go
return 0, &commonError{
errorCode: 1,
errorMsg: "a或者b不能为负数"}
我通过字面量的方式创建一个 *commonError 返回其中 errorCode 值为 1errorMsg 值为 a 或者 b 不能为负数”。
error 断言
有了自定义的 error并且携带了更多的错误信息后就可以使用这些信息了你需要先把返回的 error 接口转换为自定义的错误类型用到的知识是上节课的类型断言
下面代码中的 err.(*commonError) 就是类型断言在 error 接口上的应用也可以称为 error 断言
ch07/main.go
sum, err := add(-1, 2)
if cm,ok:=err.(*commonError);ok{
fmt.Println("错误代码为:",cm.errorCode,"错误信息为",cm.errorMsg)
} else {
fmt.Println(sum)
}
如果返回的 ok true说明 error 断言成功正确返回了 *commonError 类型的变量 cm所以就可以像示例中一样使用变量 cm errorCode errorMsg 字段信息了
错误嵌套
Error Wrapping
error 接口虽然比较简洁但是功能也比较弱想象一下假如我们有这样的需求基于一个存在的 error 再生成一个 error需要怎么做呢这就是错误嵌套
这种需求是存在的比如调用一个函数返回了一个错误信息 error在不想丢失这个 error 的情况下又想添加一些额外信息返回新的 error这时候我们首先想到的应该是自定义一个 struct如下面的代码所示
type MyError struct {
err error
msg string
}
这个结构体有两个字段其中 error 类型的 err 字段用于存放已存在的 errorstring 类型的 msg 字段用于存放新的错误信息这种方式就是 error 的嵌套
现在让 MyError 这个 struct 实现 error 接口然后在初始化 MyError 的时候传递存在的 error 和新的错误信息如下面的代码所示
func (e *MyError) Error() string {
return e.err.Error() + e.msg
}
func main() {
//err是一个存在的错误可以从另外一个函数返回
newErr := MyError{err, "数据上传问题"}
}
这种方式可以满足我们的需求但是非常烦琐因为既要定义新的类型还要实现 error 接口所以从 Go 语言 1.13 版本开始Go 标准库新增了 Error Wrapping 功能让我们可以基于一个存在的 error 生成新的 error并且可以保留原 error 信息如下面的代码所示
ch07/main.go
e := errors.New("原始错误e")
w := fmt.Errorf("Wrap了一个错误:%w", e)
fmt.Println(w)
Go 语言没有提供 Wrap 函数而是扩展了 fmt.Errorf 函数然后加了一个 %w通过这种方式便可以生成 wrapping error
errors.Unwrap 函数
既然 error 可以包裹嵌套生成一个新的 error那么也可以被解开即通过 errors.Unwrap 函数得到被嵌套的 error
Go 语言提供了 errors.Unwrap 用于获取被嵌套的 error比如以上例子中的错误变量 w 就可以对它进行 unwrap获取被嵌套的原始错误 e
下面我们运行以下代码
fmt.Println(errors.Unwrap(w))
可以看到这样的信息原始错误 e”。
原始错误e
errors.Is 函数
有了 Error Wrapping 你会发现原来用的判断两个 error 是不是同一个 error 的方法失效了比如 Go 语言标准库经常用到的如下代码中的方式
if err == os.ErrExist
为什么会出现这种情况呢由于 Go 语言的 Error Wrapping 功能令人不知道返回的 err 是否被嵌套又嵌套了几层
于是 Go 语言为我们提供了 errors.Is 函数用来判断两个 error 是否是同一个如下所示
func Is(err, target error) bool
以上就是errors.Is 函数的定义可以解释为
如果 err target 是同一个那么返回 true
如果 err 是一个 wrapping errortarget 也包含在这个嵌套 error 链中的话也返回 true
可以简单地概括为两个 error 相等或 err 包含 target 的情况下返回 true其余返回 false我们可以用上面的示例判断错误 w 中是否包含错误 e试试运行下面的代码来看打印的结果是不是 true
fmt.Println(errors.Is(w,e))
errors.As 函数
同样的原因有了 error 嵌套后error 断言也不能用了因为你不知道一个 error 是否被嵌套又嵌套了几层所以 Go 语言为解决这个问题提供了 errors.As 函数比如前面 error 断言的例子可以使用 errors.As 函数重写效果是一样的如下面的代码所示
ch07/main.go
var cm *commonError
if errors.As(err,&cm){
fmt.Println("错误代码为:",cm.errorCode,"错误信息为",cm.errorMsg)
} else {
fmt.Println(sum)
}
所以在 Go 语言提供的 Error Wrapping 能力下我们写的代码要尽可能地使用 IsAs 这些函数做判断和转换
Deferred 函数
在一个自定义函数中你打开了一个文件然后需要关闭它以释放资源不管你的代码执行了多少分支是否出现了错误文件是一定要关闭的这样才能保证资源的释放
如果这个事情由开发人员来做随着业务逻辑的复杂会变得非常麻烦而且还有可能会忘记关闭基于这种情况Go 语言为我们提供了 defer 函数可以保证文件关闭后一定会被执行不管你自定义的函数出现异常还是错误
下面的代码是 Go 语言标准包 ioutil 中的 ReadFile 函数它需要打开一个文件然后通过 defer 关键字确保在 ReadFile 函数执行结束后f.Close() 方法被执行这样文件的资源才一定会释放
func ReadFile(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
//省略无关代码
return readAll(f, n)
}
defer 关键字用于修饰一个函数或者方法使得该函数或者方法在返回前才会执行也就说被延迟但又可以保证一定会执行
以上面的 ReadFile 函数为例 defer 修饰的 f.Close 方法延迟执行也就是说会先执行 readAll(f, n)然后在整个 ReadFile 函数 return 之前执行 f.Close 方法
defer 语句常被用于成对的操作如文件的打开和关闭加锁和释放锁连接的建立和断开等不管多么复杂的操作都可以保证资源被正确地释放
Panic 异常
Go 语言是一门静态的强类型语言很多问题都尽可能地在编译时捕获但是有一些只能在运行时检查比如数组越界访问不相同的类型强制转换等这类运行时的问题会引起 panic 异常
除了运行时可以产生 panic 我们自己也可以抛出 panic 异常假设我需要连接 MySQL 数据库可以写一个连接 MySQL 的函数connectMySQL如下面的代码所示
ch07/main.go
func connectMySQL(ip,username,password string){
if ip =="" {
panic("ip不能为空")
}
//省略其他代码
}
connectMySQL 函数中如果 ip 为空会直接抛出 panic 异常这种逻辑是正确的因为数据库无法连接成功的话整个程序运行起来也没有意义所以就抛出 panic 终止程序的运行
panic Go 语言内置的函数可以接受 interface{} 类型的参数也就是任何类型的值都可以传递给 panic 函数如下所示
func panic(v interface{})
小提示interface{} 是空接口的意思 Go 语言中代表任意类型
panic 异常是一种非常严重的情况会让程序中断运行使程序崩溃所以如果是不影响程序运行的错误不要使用 panic使用普通错误 error 即可
Recover 捕获 Panic 异常
通常情况下我们不对 panic 异常做任何处理因为既然它是影响程序运行的异常就让它直接崩溃即可但是也的确有一些特例比如在程序崩溃前做一些资源释放的处理这时候就需要从 panic 异常中恢复才能完成处理
Go 语言中可以通过内置的 recover 函数恢复 panic 异常因为在程序 panic 异常崩溃的时候只有被 defer 修饰的函数才能被执行所以 recover 函数要结合 defer 关键字使用才能生效
下面的示例是通过 defer 关键字 + 匿名函数 + recover 函数从 panic 异常中恢复的方式
ch07/main.go
func main() {
defer func() {
if p:=recover();p!=nil{
fmt.Println(p)
}
}()
connectMySQL("","root","123456")
}
运行这个代码可以看到如下的打印输出这证明 recover 函数成功捕获了 panic 异常
ip 不能为空
通过这个输出的结果也可以发现recover 函数返回的值就是通过 panic 函数传递的参数值
总结
这节课主要讲了 Go 语言的错误处理机制包括 errordeferpanic errorpanic 这两种错误机制中Go 语言更提倡 error 这种轻量错误而不是 panic

View File

@ -0,0 +1,304 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 并发基础Goroutines 和 Channels 的声明与使用
在本节课开始之前,我们先一起回忆上节课的思考题:是否可以有多个 defer如果可以的话它们的执行顺序是怎么样的
对于这道题,可以直接采用写代码测试的方式,如下所示:
func moreDefer(){
defer fmt.Println("First defer")
defer fmt.Println("Second defer")
defer fmt.Println("Three defer")
fmt.Println("函数自身代码")
}
func main(){
moreDefer()
}
我定义了 moreDefer 函数,函数里有三个 defer 语句,然后在 main 函数里调用它。运行这段程序可以看到如下内容输出:
函数自身代码
Three defer
Second defer
First defer
通过以上示例可以证明:
在一个方法或者函数中,可以有多个 defer 语句;
多个 defer 语句的执行顺序依照后进先出的原则。
defer 有一个调用栈,越早定义越靠近栈的底部,越晚定义越靠近栈的顶部,在执行这些 defer 语句的时候,会先从栈顶弹出一个 defer 然后执行它,也就是我们示例中的结果。
下面我们开始本节课的学习。本节课是 Go 语言的重点——协程和通道,它们是 Go 语言并发的基础,我会从这两个基础概念开始,带你逐步深入 Go 语言的并发。
什么是并发
前面的课程中,我所写的代码都按照顺序执行,也就是上一句代码执行完,才会执行下一句,这样的代码逻辑简单,也符合我们的阅读习惯。
但这样是不够的,因为计算机很强大,如果只让它干完一件事情再干另外一件事情就太浪费了。比如一款音乐软件,使用它听音乐的时候还想让它下载歌曲,同一时刻做了两件事,在编程中,这就是并发,并发可以让你编写的程序在同一时刻做多几件事情。
进程和线程
讲并发就绕不开线程,不过在介绍线程之前,我先为你介绍什么是进程。
进程
在操作系统中,进程是一个非常重要的概念。当你启动一个软件(比如浏览器)的时候,操作系统会为这个软件创建一个进程,这个进程是该软件的工作空间,它包含了软件运行所需的所有资源,比如内存空间、文件句柄,还有下面要讲的线程等。下面的图片就是我的电脑上运行的进程:
(电脑运行的进程)
那么线程是什么呢?
线程
线程是进程的执行空间,一个进程可以有多个线程,线程被操作系统调度执行,比如下载一个文件,发送一个消息等。这种多个线程被操作系统同时调度执行的情况,就是多线程的并发。
一个程序启动,就会有对应的进程被创建,同时进程也会启动一个线程,这个线程叫作主线程。如果主线程结束,那么整个程序就退出了。有了主线程,就可以从主线里启动很多其他线程,也就有了多线程的并发。
协程Goroutine
Go 语言中没有线程的概念,只有协程,也称为 goroutine。相比线程来说协程更加轻量一个程序可以随意启动成千上万个 goroutine。
goroutine 被 Go runtime 所调度这一点和线程不一样。也就是说Go 语言的并发是由 Go 自己所调度的,自己决定同时执行多少个 goroutine什么时候执行哪几个。这些对于我们开发者来说完全透明只需要在编码的时候告诉 Go 语言要启动几个 goroutine至于如何调度执行我们不用关心。
要启动一个 goroutine 非常简单Go 语言为我们提供了 go 关键字,相比其他编程语言简化了很多,如下面的代码所示:
ch08/main.go
func main() {
go fmt.Println("飞雪无情")
fmt.Println("我是 main goroutine")
time.Sleep(time.Second)
}
这样就启动了一个 goroutine用来调用 fmt.Println 函数,打印“飞雪无情”。所以这段代码里有两个 goroutine一个是 main 函数启动的 main goroutine一个是我自己通过 go 关键字启动的 goroutine。
从示例中可以总结出 go 关键字的语法,如下所示:
go function()
go 关键字后跟一个方法或者函数的调用,就可以启动一个 goroutine让方法在这个新启动的 goroutine 中运行。运行以上示例,可以看到如下输出:
我是 main goroutine
飞雪无情
从输出结果也可以看出程序是并发的go 关键字启动的 goroutine 并不阻塞 main goroutine 的执行,所以我们才会看到如上打印结果。
小提示:示例中的 time.Sleep(time.Second) 表示等待一秒,这里是让 main goroutine 等一秒,不然 main goroutine 执行完毕程序就退出了,也就看不到启动的新 goroutine 中“飞雪无情”的打印结果了。
Channel
那么如果启动了多个 goroutine它们之间该如何通信呢这就是 Go 语言提供的 channel通道要解决的问题。
声明一个 channel
在 Go 语言中,声明一个 channel 非常简单,使用内置的 make 函数即可,如下所示:
ch:=make(chan string)
其中 chan 是一个关键字,表示是 channel 类型。后面的 string 表示 channel 里的数据是 string 类型。通过 channel 的声明也可以看到chan 是一个集合类型。
定义好 chan 后就可以使用了,一个 chan 的操作只有两种:发送和接收。
接收:获取 chan 中的值,操作符为 <- chan
发送 chan 发送值把值放在 chan 操作符为 chan <-
小技巧这里注意发送和接收的操作符都是 <- 只不过位置不同接收的 <- 操作符在 chan 的左侧发送的 <- 操作符在 chan 的右侧
现在我把上个示例改造下使用 chan 来代替 time.Sleep 函数的等待工作如下面的代码所示
ch08/main.go
func main() {
ch:=make(chan string)
go func() {
fmt.Println("飞雪无情")
ch <- "goroutine 完成"
}()
fmt.Println("我是 main goroutine")
v:=<-ch
fmt.Println("接收到的chan中的值为",v)
}
运行这个示例可以发现程序并没有退出可以看到飞雪无情的输出结果达到了 time.Sleep 函数的效果如下所示
我是 main goroutine
飞雪无情
接收到的chan中的值为 goroutine 完成
可以这样理解在上面的示例中我们在新启动的 goroutine 中向 chan 类型的变量 ch 发送值 main goroutine 从变量 ch 接收值如果 ch 中没有值则阻塞等待到 ch 中有值可以接收为止
相信你应该明白为什么程序不会在新的 goroutine 完成之前退出了因为通过 make 创建的 chan 中没有值 main goroutine 又想从 chan 中获取值获取不到就一直等待等到另一个 goroutine chan 发送值为止
channel 有点像在两个 goroutine 之间架设的管道一个 goroutine 可以往这个管道里发送数据另外一个可以从这个管道里取数据有点类似于我们说的队列
无缓冲 channel
上面的示例中使用 make 创建的 chan 就是一个无缓冲 channel它的容量是 0不能存储任何数据所以无缓冲 channel 只起到传输数据的作用数据并不会在 channel 中做任何停留这也意味着无缓冲 channel 的发送和接收操作是同时进行的它也可以称为同步 channel
有缓冲 channel
有缓冲 channel 类似一个可阻塞的队列内部的元素先进先出通过 make 函数的第二个参数可以指定 channel 容量的大小进而创建一个有缓冲 channel如下面的代码所示
cacheCh:=make(chan int,5)
我创建了一个容量为 5 channel内部的元素类型是 int也就是说这个 channel 内部最多可以存放 5 个类型为 int 的元素如下图所示
有缓冲 channel
一个有缓冲 channel 具备以下特点
有缓冲 channel 的内部有一个缓冲队列
发送操作是向队列的尾部插入元素如果队列已满则阻塞等待直到另一个 goroutine 执行接收操作释放队列的空间
接收操作是从队列的头部获取元素并把它从队列中删除如果队列为空则阻塞等待直到另一个 goroutine 执行发送操作插入新的元素
因为有缓冲 channel 类似一个队列可以获取它的容量和里面元素的个数如下面的代码所示
ch08/main.go
cacheCh:=make(chan int,5)
cacheCh <- 2
cacheCh <- 3
fmt.Println("cacheCh容量为:",cap(cacheCh),",元素个数为",len(cacheCh))
其中通过内置函数 cap 可以获取 channel 的容量也就是最大能存放多少个元素通过内置函数 len 可以获取 channel 中元素的个数
小提示无缓冲 channel 其实就是一个容量大小为 0 channel比如 make(chan int,0)
关闭 channel
channel 还可以使用内置函数 close 关闭如下面的代码所示
close(cacheCh)
如果一个 channel 被关闭了就不能向里面发送数据了如果发送的话会引起 painc 异常但是还可以接收 channel 里的数据如果 channel 里没有数据的话接收的数据是元素类型的零值
单向 channel
有时候我们有一些特殊的业务需求比如限制一个 channel 只可以接收但是不能发送或者限制一个 channel 只能发送但不能接收这种 channel 称为单向 channel
单向 channel 的声明也很简单只需要在声明的时候带上 <- 操作符即可如下面的代码所示
onlySend := make(chan<- int)
onlyReceive:=make(<-chan int)
注意声明单向 channel <- 操作符的位置和上面讲到的发送和接收操作是一样的
在函数或者方法的参数中使用单向 channel 的较多这样可以防止一些操作影响了 channel
下面示例中的 counter 函数它的参数 out 是一个只能发送的 channel所以在 counter 函数体内使用参数 out 只能对其进行发送操作如果执行接收操作则程序不能编译通过
func counter(out chan<- int) {
//函数内容使用变量out只能进行发送操作
}
select+channel 示例
假设要从网上下载一个文件我启动了 3 goroutine 进行下载并把结果发送到 3 channel 其中哪个先下载好就会使用哪个 channel 的结果
在这种情况下如果我们尝试获取第一个 channel 的结果程序就会被阻塞无法获取剩下两个 channel 的结果也无法判断哪个先下载好这个时候就需要用到多路复用操作了 Go 语言中通过 select 语句可以实现多路复用其语句格式如下
select {
case i1 = <-c1:
//todo
case c2 <- i2:
//todo
default:
// default todo
}
整体结构和 switch 非常像都有 case default只不过 select case 是一个个可以操作的 channel
小提示多路复用可以简单地理解为N channel 任意一个 channel 有数据产生select 都可以监听到然后执行相应的分支接收数据并处理
有了 select 语句就可以实现下载的例子了如下面的代码所示
ch08/main.go
func main() {
//声明三个存放结果的channel
firstCh := make(chan string)
secondCh := make(chan string)
threeCh := make(chan string)
//同时开启3个goroutine下载
go func() {
firstCh <- downloadFile("firstCh")
}()
go func() {
secondCh <- downloadFile("secondCh")
}()
go func() {
threeCh <- downloadFile("threeCh")
}()
//开始select多路复用哪个channel能获取到值
//就说明哪个最先下载好就用哪个
select {
case filePath := <-firstCh:
fmt.Println(filePath)
case filePath := <-secondCh:
fmt.Println(filePath)
case filePath := <-threeCh:
fmt.Println(filePath)
}
}
func downloadFile(chanName string) string {
//模拟下载文件,可以自己随机time.Sleep点时间试试
time.Sleep(time.Second)
return chanName+":filePath"
}
如果这些 case 中有一个可以执行select 语句会选择该 case 执行如果同时有多个 case 可以被执行则随机选择一个这样每个 case 都有平等的被执行的机会如果一个 select 没有任何 case那么它会一直等待下去
总结
在这节课中我为你介绍了如何通过 go 关键字启动一个 goroutine以及如何通过 channel 实现 goroutine 间的数据传递这些都是 Go 语言并发的基础理解它们可以更好地掌握并发
Go 语言中提倡通过通信来共享内存而不是通过共享内存来通信其实就是提倡通过 channel 发送接收消息的方式进行数据传递而不是通过修改同一个变量所以在数据流动传递的场景中要优先使用 channel它是并发安全的性能也不错

View File

@ -0,0 +1,362 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 同步原语sync 包让你对并发控制得心应手
上节课留了一个思考题channel 为什么是并发安全的呢?是因为 channel 内部使用了互斥锁来保证并发的安全,这节课,我将为你介绍互斥锁的使用。
在 Go 语言中,不仅有 channel 这类比较易用且高级的同步机制,还有 sync.Mutex、sync.WaitGroup 等比较原始的同步机制。通过它们,我们可以更加灵活地控制数据的同步和多协程的并发,下面我为你逐一讲解。
资源竞争
在一个 goroutine 中,如果分配的内存没有被其他 goroutine 访问,只在该 goroutine 中被使用,那么不存在资源竞争的问题。
但如果同一块内存被多个 goroutine 同时访问,就会产生不知道谁先访问也无法预料最后结果的情况。这就是资源竞争,这块内存可以称为共享的资源。
我们通过下面的示例来进一步地了解:
ch09/main.go
//共享的资源
var sum = 0
func main() {
//开启100个协程让sum+10
for i := 0; i < 100; i++ {
go add(10)
}
//防止提前退出
time.Sleep(2 * time.Second)
fmt.Println("和为:",sum)
}
func add(i int) {
sum += i
}
示例中你期待的结果可能是和为 1000但当运行程序后可能如预期所示但也可能是 990 或者 980导致这种情况的核心原因是资源 sum 不是并发安全的因为同时会有多个协程交叉执行 sum+=i产生不可预料的结果
既然已经知道了原因解决的办法也就有了只需要确保同时只有一个协程执行 sum+=i 操作即可要达到该目的可以使用 sync.Mutex 互斥锁
小技巧使用 go buildgo rungo test 这些 Go 语言工具链提供的命令时添加 -race 标识可以帮你检查 Go 语言代码是否存在资源竞争
同步原语
sync.Mutex
互斥锁顾名思义指的是在同一时刻只有一个协程执行某段代码其他协程都要等待该协程执行完毕后才能继续执行
在下面的示例中我声明了一个互斥锁 mutex然后修改 add 函数 sum+=i 这段代码加锁保护这样这段访问共享资源的代码片段就并发安全了可以得到正确的结果
ch09/main.go
var(
sum int
mutex sync.Mutex
)
func add(i int) {
mutex.Lock()
sum += i
mutex.Unlock()
}
小提示以上被加锁保护的 sum+=i 代码片段又称为临界区在同步的程序设计中临界区段指的是一个访问共享资源的程序片段而这些共享资源又有无法同时被多个协程访问的特性 当有协程进入临界区段时其他协程必须等待这样就保证了临界区的并发安全
互斥锁的使用非常简单它只有两个方法 Lock Unlock代表加锁和解锁当一个协程获得 Mutex 锁后其他协程只能等到 Mutex 锁释放后才能再次获得锁
Mutex Lock Unlock 方法总是成对出现而且要确保 Lock 获得锁后一定执行 UnLock 释放锁所以在函数或者方法中会采用 defer 语句释放锁如下面的代码所示
func add(i int) {
mutex.Lock()
defer mutex.Unlock()
sum += i
}
这样可以确保锁一定会被释放不会被遗忘
sync.RWMutex
sync.Mutex 小节中我对共享资源 sum 的加法操作进行了加锁这样可以保证在修改 sum 值的时候是并发安全的如果读取操作也采用多个协程呢如下面的代码所示
ch09/main.go
func main() {
for i := 0; i < 100; i++ {
go add(10)
}
for i:=0; i<10;i++ {
go fmt.Println("和为:",readSum())
}
time.Sleep(2 * time.Second)
}
//增加了一个读取sum的函数便于演示并发
func readSum() int {
b:=sum
return b
}
这个示例开启了 10 个协程它们同时读取 sum 的值因为 readSum 函数并没有任何加锁控制所以它不是并发安全的即一个 goroutine 正在执行 sum+=i 操作的时候另一个 goroutine 可能正在执行 b:=sum 操作这就会导致读取的 num 值是一个过期的值结果不可预期
如果要解决以上资源竞争的问题可以使用互斥锁 sync.Mutex如下面的代码所示
ch09/main.go
func readSum() int {
mutex.Lock()
defer mutex.Unlock()
b:=sum
return b
}
因为 add readSum 函数使用的是同一个 sync.Mutex所以它们的操作是互斥的也就是一个 goroutine 进行修改操作 sum+=i 的时候另一个 gouroutine 读取 sum 的操作 b:=sum 会等待直到修改操作执行完毕
现在我们解决了多个 goroutine 同时读写的资源竞争问题但是又遇到另外一个问题性能因为每次读写共享资源都要加锁所以性能低下这该怎么解决呢
现在我们分析读写这个特殊场景有以下几种情况
写的时候不能同时读因为这个时候读取的话可能读到脏数据不正确的数据
读的时候不能同时写因为也可能产生不可预料的结果
读的时候可以同时读因为数据不会改变所以不管多少个 goroutine 读都是并发安全的
所以就可以通过读写锁 sync.RWMutex 来优化这段代码提升性能现在我将以上示例改为读写锁来实现我们想要的结果如下所示
ch09/main.go
var mutex sync.RWMutex
func readSum() int {
//只获取读锁
mutex.RLock()
defer mutex.RUnlock()
b:=sum
return b
}
对比互斥锁的示例读写锁的改动有两处
把锁的声明换成读写锁 sync.RWMutex
把函数 readSum 读取数据的代码换成读锁也就是 RLock RUnlock
这样性能就会有很大的提升因为多个 goroutine 可以同时读数据不再相互等待
sync.WaitGroup
在以上示例中相信你注意到了这段 time.Sleep(2 * time.Second) 代码这是为了防止主函数 main 返回使用一旦 main 函数返回了程序也就退出了
因为我们不知道 100 个执行 add 的协程和 10 个执行 readSum 的协程什么时候完全执行完毕所以设置了一个比较长的等待时间也就是两秒
小提示一个函数或者方法的返回 (return) 也就意味着当前函数执行完毕
所以存在一个问题如果这 110 个协程在两秒内执行完毕main 函数本该提前返回但是偏偏要等两秒才能返回会产生性能问题
如果这 110 个协程执行的时间超过两秒因为设置的等待时间只有两秒程序就会提前返回导致有协程没有执行完毕产生不可预知的结果
那么有没有办法解决这个问题呢也就是说有没有办法监听所有协程的执行一旦全部执行完毕程序马上退出这样既可保证所有协程执行完毕又可以及时退出节省时间提升性能你第一时间应该会想到上节课讲到的 channel没错channel 的确可以解决这个问题不过非常复杂Go 语言为我们提供了更简洁的解决办法它就是 sync.WaitGroup
在使用 sync.WaitGroup 改造示例之前我先把 main 函数中的代码进行重构抽取成一个函数 run这样可以更好地理解如下所示
ch09/main.go
func main() {
run()
}
func run(){
for i := 0; i < 100; i++ {
go add(10)
}
for i:=0; i<10;i++ {
go fmt.Println("和为:",readSum())
}
time.Sleep(2 * time.Second)
}
这样执行读写的 110 个协程代码逻辑就都放在了 run 函数中 main 函数中直接调用 run 函数即可现在只需通过 sync.WaitGroup run 函数进行改造让其恰好执行完毕如下所示
ch09/main.go
func run(){
var wg sync.WaitGroup
//因为要监控110个协程所以设置计数器为110
wg.Add(110)
for i := 0; i < 100; i++ {
go func() {
//计数器值减1
defer wg.Done()
add(10)
}()
}
for i:=0; i<10;i++ {
go func() {
//计数器值减1
defer wg.Done()
fmt.Println("和为:",readSum())
}()
}
//一直等待只要计数器值为0
wg.Wait()
}
sync.WaitGroup 的使用比较简单一共分为三步
声明一个 sync.WaitGroup然后通过 Add 方法设置计数器的值需要跟踪多少个协程就设置多少这里是 110
在每个协程执行完毕时调用 Done 方法让计数器减 1告诉 sync.WaitGroup 该协程已经执行完毕
最后调用 Wait 方法一直等待直到计数器值为 0也就是所有跟踪的协程都执行完毕
通过 sync.WaitGroup 可以很好地跟踪协程在协程执行完毕后整个 run 函数才能执行完毕时间不多不少正好是协程执行的时间
sync.WaitGroup 适合协调多个协程共同做一件事情的场景比如下载一个文件假设使用 10 个协程每个协程下载文件的 110 大小只有 10 个协程都下载好了整个文件才算是下载好了这就是我们经常听到的多线程下载通过多个线程共同做一件事情显著提高效率
小提示其实你也可以把 Go 语言中的协程理解为平常说的线程从用户体验上也并无不可但是从技术实现上你知道他们是不一样的就可以了
sync.Once
在实际的工作中你可能会有这样的需求让代码只执行一次哪怕是在高并发的情况下比如创建一个单例
针对这种情形Go 语言为我们提供了 sync.Once 来保证代码只执行一次如下所示
ch09/main.go
func main() {
doOnce()
}
func doOnce() {
var once sync.Once
onceBody := func() {
fmt.Println("Only once")
}
//用于等待协程执行完毕
done := make(chan bool)
//启动10个协程执行once.Do(onceBody)
for i := 0; i < 10; i++ {
go func() {
//把要执行的函数(方法)作为参数传给once.Do方法即可
once.Do(onceBody)
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
这是 Go 语言自带的一个示例虽然启动了 10 个协程来执行 onceBody 函数但是因为用了 once.Do 方法所以函数 onceBody 只会被执行一次也就是说在高并发的情况下sync.Once 也会保证 onceBody 函数只执行一次
sync.Once 适用于创建某个对象的单例只加载一次的资源等只执行一次的场景
sync.Cond
Go 语言中sync.WaitGroup 用于最终完成的场景关键点在于一定要等待所有协程都执行完毕
sync.Cond 可以用于发号施令一声令下所有协程都可以开始执行关键点在于协程开始的时候是等待的要等待 sync.Cond 唤醒才能执行
sync.Cond 从字面意思看是条件变量它具有阻塞协程和唤醒协程的功能所以可以在满足一定条件的情况下唤醒协程但条件变量只是它的一种使用场景
下面我以 10 个人赛跑为例来演示 sync.Cond 的用法在这个示例中有一个裁判裁判要先等这 10 个人准备就绪然后一声发令枪响 10 个人就可以开始跑了如下所示
//10个人赛跑1个裁判发号施令
func race(){
cond :=sync.NewCond(&sync.Mutex{})
var wg sync.WaitGroup
wg.Add(11)
for i:=0;i<10; i++ {
go func(num int) {
defer wg.Done()
fmt.Println(num,"号已经就位")
cond.L.Lock()
cond.Wait()//等待发令枪响
fmt.Println(num,"号开始跑")
cond.L.Unlock()
}(i)
}
//等待所有goroutine都进入wait状态
time.Sleep(2*time.Second)
go func() {
defer wg.Done()
fmt.Println("裁判已经就位准备发令枪")
fmt.Println("比赛开始大家准备跑")
cond.Broadcast()//发令枪响
}()
//防止函数提前返回退出
wg.Wait()
}
以上示例中有注释说明已经很好理解我这里再大概讲解一下步骤
通过 sync.NewCond 函数生成一个 *sync.Cond用于阻塞和唤醒协程
然后启动 10 个协程模拟 10 个人准备就位后调用 cond.Wait() 方法阻塞当前协程等待发令枪响这里需要注意的是调用 cond.Wait() 方法时要加锁
time.Sleep 用于等待所有人都进入 wait 阻塞状态这样裁判才能调用 cond.Broadcast() 发号施令
裁判准备完毕后就可以调用 cond.Broadcast() 通知所有人开始跑了
sync.Cond 有三个方法它们分别是
Wait阻塞当前协程直到被其他协程调用 Broadcast 或者 Signal 方法唤醒使用的时候需要加锁使用 sync.Cond 中的锁即可也就是 L 字段
Signal唤醒一个等待时间最长的协程
Broadcast唤醒所有等待的协程
注意在调用 Signal 或者 Broadcast 之前要确保目标协程处于 Wait 阻塞状态不然会出现死锁问题
如果你以前学过 Java会发现 sync.Cond Java 的等待唤醒机制很像它的三个方法 WaitSignalBroadcast 就分别对应 Java 中的 waitnotifynotifyAll
总结
这节课主要讲解 Go 语言的同步原语使用通过它们可以更灵活地控制多协程的并发从使用上讲Go 语言还是更推荐 channel 这种更高级别的并发控制方式因为它更简洁也更容易理解和使用
当然本节课讲的这些比较基础的同步原语也很有用同步原语通常用于更复杂的并发控制如果追求更灵活的控制方式和性能你可以使用它们
本节课到这里就要结束了sync 包里还有一个同步原语我没有讲它就是 sync.Mapsync.Map 的使用和内置的 map 类型一样只不过它是并发安全的所以这节课的作业就是练习使用 sync.Map

View File

@ -0,0 +1,380 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 Context你必须掌握的多线程并发控制神器
在上一节课中我留了一个作业,也就是让你自己练习使用 sync.Map相信你已经做出来了。现在我为你讲解 sync.Map 的方法。
Store存储一对 key-value 值。
Load根据 key 获取对应的 value 值,并且可以判断 key 是否存在。
LoadOrStore如果 key 对应的 value 存在,则返回该 value如果不存在存储相应的 value。
Delete删除一个 key-value 键值对。
Range循环迭代 sync.Map效果与 for range 一样。
相信有了这些方法的介绍,你对 sync.Map 会有更深入的理解。下面开始今天的课程:如何通过 Context 更好地控制并发。
协程如何退出
一个协程启动后,大部分情况需要等待里面的代码执行完毕,然后协程会自行退出。但是如果有一种情景,需要让协程提前退出怎么办呢?在下面的代码中,我做了一个监控狗用来监控程序:
ch10/main.go
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
watchDog("【监控狗1】")
}()
wg.Wait()
}
func watchDog(name string){
//开启for select循环一直后台监控
for{
select {
default:
fmt.Println(name,"正在监控……")
}
time.Sleep(1*time.Second)
}
}
我通过 watchDog 函数实现了一个监控狗,它会一直在后台运行,每隔一秒就会打印”监控狗正在监控……”的文字。
如果需要让监控狗停止监控、退出程序,一个办法是定义一个全局变量,其他地方可以通过修改这个变量发出停止监控狗的通知。然后在协程中先检查这个变量,如果发现被通知关闭就停止监控,退出当前协程。
但是这种方法需要通过加锁来保证多协程下并发的安全,基于这个思路,有个升级版的方案:用 select+channel 做检测,如下面的代码所示:
ch10/main.go
func main() {
var wg sync.WaitGroup
wg.Add(1)
stopCh := make(chan bool) //用来停止监控狗
go func() {
defer wg.Done()
watchDog(stopCh,"【监控狗1】")
}()
time.Sleep(5 * time.Second) //先让监控狗监控5秒
stopCh <- true //发停止指令
wg.Wait()
}
func watchDog(stopCh chan bool,name string){
//开启for select循环一直后台监控
for{
select {
case <-stopCh:
fmt.Println(name,"停止指令已收到马上停止")
return
default:
fmt.Println(name,"正在监控")
}
time.Sleep(1*time.Second)
}
}
这个示例是使用 select+channel 的方式改造的 watchDog 函数实现了通过 channel 发送指令让监控狗停止进而达到协程退出的目的以上示例主要有两处修改具体如下
watchDog 函数增加 stopCh 参数用于接收停止指令
main 函数中声明用于停止的 stopCh传递给 watchDog 函数然后通过 stopCh<-true 发送停止指令让协程退出
初识 Context
以上示例是 select+channel 比较经典的使用场景这里也顺便复习了 select 的知识
通过 select+channel 让协程退出的方式比较优雅但是如果我们希望做到同时取消很多个协程呢如果是定时取消协程又该怎么办这时候 select+channel 的局限性就凸现出来了即使定义了多个 channel 解决问题代码逻辑也会非常复杂难以维护
要解决这种复杂的协程问题必须有一种可以跟踪协程的方案只有跟踪到每个协程才能更好地控制它们这种方案就是 Go 语言标准库为我们提供的 Context也是这节课的主角
现在我通过 Context 重写上面的示例实现让监控狗停止的功能如下所示
ch10/main.go
func main() {
var wg sync.WaitGroup
wg.Add(1)
ctx,stop:=context.WithCancel(context.Background())
go func() {
defer wg.Done()
watchDog(ctx,"监控狗1")
}()
time.Sleep(5 * time.Second) //先让监控狗监控5秒
stop() //发停止指令
wg.Wait()
}
func watchDog(ctx context.Context,name string) {
//开启for select循环一直后台监控
for {
select {
case <-ctx.Done():
fmt.Println(name,"停止指令已收到马上停止")
return
default:
fmt.Println(name,"正在监控")
}
time.Sleep(1 * time.Second)
}
}
相比 select+channel 的方案Context 方案主要有 4 个改动点
watchDog stopCh 参数换成了 ctx类型为 context.Context
原来的 case <-stopCh 改为 case <-ctx.Done()用于判断是否停止
使用 context.WithCancel(context.Background()) 函数生成一个可以取消的 Context用于发送停止指令这里的 context.Background() 用于生成一个空 Context一般作为整个 Context 树的根节点
原来的 stopCh <- true 停止指令改为 context.WithCancel 函数返回的取消函数 stop()
可以看到这和修改前的整体代码结构一样只不过从 channel 换成了 Context以上示例只是 Context 的一种使用场景它的能力不止于此现在我来介绍什么是 Context
什么是 Context
一个任务会有很多个协程协作完成一次 HTTP 请求也会触发很多个协程的启动而这些协程有可能会启动更多的子协程并且无法预知有多少层协程每一层有多少个协程
如果因为某些原因导致任务终止了HTTP 请求取消了那么它们启动的协程怎么办该如何取消呢因为取消这些协程可以节约内存提升性能同时避免不可预料的 Bug
Context 就是用来简化解决这些问题的并且是并发安全的Context 是一个接口它具备手动定时超时发出取消信号传值等功能主要用于控制多个协程之间的协作尤其是取消操作一旦取消指令下达那么被 Context 跟踪的这些协程都会收到取消信号就可以做清理和退出操作
Context 接口只有四个方法下面进行详细介绍在开发中你会经常使用它们你可以结合下面的代码来看
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Deadline 方法可以获取设置的截止时间第一个返回值 deadline 是截止时间到了这个时间点Context 会自动发起取消请求第二个返回值 ok 代表是否设置了截止时间
Done 方法返回一个只读的 channel类型为 struct{}在协程中如果该方法返回的 chan 可以读取则意味着 Context 已经发起了取消信号通过 Done 方法收到这个信号后就可以做清理操作然后退出协程释放资源
Err 方法返回取消的错误原因即因为什么原因 Context 被取消
Value 方法获取该 Context 上绑定的值是一个键值对所以要通过一个 key 才可以获取对应的值
Context 接口的四个方法中最常用的就是 Done 方法它返回一个只读的 channel用于接收取消信号 Context 取消的时候会关闭这个只读 channel也就等于发出了取消信号
Context
我们不需要自己实现 Context 接口Go 语言提供了函数可以帮助我们生成不同的 Context通过这些函数可以生成一颗 Context 这样 Context 才可以关联起来 Context 发出取消信号的时候 Context 也会发出这样就可以控制不同层级的协程退出
从使用功能上分有四种实现好的 Context
Context不可取消没有截止时间主要用于 Context 树的根节点
可取消的 Context用于发出取消信号当取消的时候它的子 Context 也会取消
可定时取消的 Context多了一个定时的功能
Context用于存储一个 key-value 键值对
从下图 Context 的衍生树可以看到最顶部的是空 Context它作为整棵 Context 树的根节点 Go 语言中可以通过 context.Background() 获取一个根节点 Context
四种 Context 的衍生树
有了根节点 Context 这颗 Context 树要怎么生成呢需要使用 Go 语言提供的四个函数
WithCancel(parent Context)生成一个可取消的 Context
WithDeadline(parent Context, d time.Time)生成一个可定时取消的 Context参数 d 为定时取消的具体时间
WithTimeout(parent Context, timeout time.Duration)生成一个可超时取消的 Context参数 timeout 用于设置多久后取消
WithValue(parent Context, key, val interface{})生成一个可携带 key-value 键值对的 Context
以上四个生成 Context 的函数中前三个都属于可取消的 Context它们是一类函数最后一个是值 Context用于存储一个 key-value 键值对
使用 Context 取消多个协程
取消多个协程也比较简单 Context 作为参数传递给协程即可还是以监控狗为例如下所示
ch10/main.go
wg.Add(3)
go func() {
defer wg.Done()
watchDog(ctx,"监控狗2")
}()
go func() {
defer wg.Done()
watchDog(ctx,"监控狗3")
}()
示例中增加了两个监控狗也就是增加了两个协程这样一个 Context 就同时控制了三个协程一旦 Context 发出取消信号这三个协程都会取消退出
以上示例中的 Context 没有子 Context如果一个 Context 有子 Context在该 Context 取消时会发生什么呢下面通过一幅图说明
Context 取消
可以看到当节点 Ctx2 取消时它的子节点 Ctx4Ctx5 都会被取消如果还有子节点的子节点也会被取消也就是说根节点为 Ctx2 的所有节点都会被取消其他节点如 Ctx1Ctx3 Ctx6 则不会
Context 传值
Context 不仅可以取消还可以传值通过这个能力可以把 Context 存储的值供其他协程使用我通过下面的代码来说明
ch10/main.go
func main() {
wg.Add(4) //记得这里要改为4原来是3因为要多启动一个协程
//省略其他无关代码
valCtx:=context.WithValue(ctx,"userId",2)
go func() {
defer wg.Done()
getUser(valCtx)
}()
//省略其他无关代码
}
func getUser(ctx context.Context){
for {
select {
case <-ctx.Done():
fmt.Println("获取用户","协程退出")
return
default:
userId:=ctx.Value("userId")
fmt.Println("获取用户","用户ID为",userId)
time.Sleep(1 * time.Second)
}
}
}
这个示例是和上面的示例放在一起运行的所以我省略了上面实例的重复代码其中通过 context.WithValue 函数存储一个 userId 2 的键值对就可以在 getUser 函数中通过 ctx.Value(userId) 方法把对应的值取出来达到传值的目的
Context 使用原则
Context 是一种非常好的工具使用它可以很方便地控制取消多个协程 Go 语言标准库中也使用了它们比如 net/http 中使用 Context 取消网络的请求
要更好地使用 Context有一些使用原则需要尽可能地遵守
Context 不要放在结构体中要以参数的方式传递
Context 作为函数的参数时要放在第一位也就是第一个参数
要使用 context.Background 函数生成根节点的 Context也就是最顶层的 Context
Context 传值要传递必须的值而且要尽可能地少不要什么都传
Context 多协程安全可以在多个协程中放心使用
以上原则是规范类的Go 语言的编译器并不会做这些检查要靠自己遵守
总结
Context 通过 With 系列函数生成 Context 把相关的 Context 关联起来这样就可以统一进行控制一声令下关联的 Context 都会发出取消信号使用这些 Context 的协程就可以收到取消信号然后清理退出你在定义函数的时候如果想让外部给你的函数发取消信号就可以为这个函数增加一个 Context 参数让外部的调用者可以通过 Context 进行控制比如下载一个文件超时退出的需求

View File

@ -0,0 +1,487 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 并发模式Go 语言中即学即用的高效并发模式
上节课我为你讲解了如何通过 Context 更好地控制多个协程,课程最后的思考题是:如何通过 Context 实现日志跟踪?
要想跟踪一个用户的请求,必须有一个唯一的 ID 来标识这次请求调用了哪些函数、执行了哪些代码,然后通过这个唯一的 ID 把日志信息串联起来。这样就形成了一个日志轨迹,也就实现了用户的跟踪,于是思路就有了。
在用户请求的入口点生成 TraceID。
通过 context.WithValue 保存 TraceID。
然后这个保存着 TraceID 的 Context 就可以作为参数在各个协程或者函数间传递。
在需要记录日志的地方,通过 Context 的 Value 方法获取保存的 TraceID然后把它和其他日志信息记录下来。
这样具备同样 TraceID 的日志就可以被串联起来,达到日志跟踪的目的。
以上思路实现的核心是 Context 的传值功能。
目前我们已熟练掌握了 goroutine、channel、sync 包的同步原语,这些都是并发编程比较基础的元素。而这节课要介绍的是如何用这些基础元素组成并发模式,帮助我们更好地编写并发程序。
for select 循环模式
for select 循环模式非常常见,在前面的课程中也使用过,它一般和 channel 组合完成任务,代码格式如下:
for { //for无限循环或者for range循环
select {
//通过一个channel控制
}
}
这是一种 for 循环 +select 多路复用的并发模式,哪个 case 满足就执行哪个,直到满足一定的条件退出 for 循环(比如发送退出信号)。
从具体实现上讲for select 循环有两种模式,一种是上节课监控狗例子中的无限循环模式,只有收到终止指令才会退出,如下所示:
for {
select {
case <-done:
return
default:
//执行具体的任务
}
}
这种模式会一直执行 default 语句中的任务直到 done 这个 channel 被关闭为止
第二种模式是 for range select 有限循环一般用于把可以迭代的内容发送到 channel 如下所示
for _,s:=range []int{}{
select {
case <-done:
return
case resultCh <- s:
}
}
这种模式也会有一个 done channel用于退出当前的 for 循环而另外一个 resultCh channel 用于接收 for range 循环的值这些值通过 resultCh 可以传送给其他的调用者
select timeout 模式
假如需要访问服务器获取数据因为网络的不同响应时间不一样为保证程序的质量不可能一直等待网络返回所以需要设置一个超时时间这时候就可以使用 select timeout 模式如下所示
ch11/main.go
func main() {
result := make(chan string)
go func() {
//模拟网络访问
time.Sleep(8 * time.Second)
result <- "服务端结果"
}()
select {
case v := <-result:
fmt.Println(v)
case <-time.After(5 * time.Second):
fmt.Println("网络访问超时了")
}
}
select timeout 模式的核心在于通过 time.After 函数设置一个超时时间防止因为异常造成 select 语句的无限等待
小提示如果可以使用 Context WithTimeout 函数超时取消要优先使用
Pipeline 模式
Pipeline 模式也称为流水线模式模拟的就是现实世界中的流水线生产以手机组装为例整条生产流水线可能有成百上千道工序每道工序只负责自己的事情最终经过一道道工序组装就完成了一部手机的生产
从技术上看每一道工序的输出就是下一道工序的输入在工序之间传递的东西就是数据这种模式称为流水线模式而传递的数据称为数据流
流水线模式
通过以上流水线模式示意图可以看到从最开始的生产经过工序 1234 到最终成品这就是一条比较形象的流水线也就是 Pipeline
现在我以组装手机为例讲解流水线模式的使用假设一条组装手机的流水线有 3 道工序分别是配件采购配件组装打包成品如图所示
(手机组装流水线)
从以上示意图中可以看到采购的配件通过 channel 传递给工序 2 进行组装然后再通过 channel 传递给工序 3 打包成品相对工序 2 来说工序 1 是生产者工序 3 是消费者相对工序 1 来说工序 2 是消费者相对工序 3 来说工序 2 是生产者
我用下面的几组代码进行演示
ch11/main.go
//工序1采购
func buy(n int) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for i := 1; i <= n; i++ {
out <- fmt.Sprint("配件", i)
}
}()
return out
}
首先我们定义一个采购函数 buy它有一个参数 n可以设置要采购多少套配件采购代码的实现逻辑是通过 for 循环产生配件然后放到 channel 类型的变量 out 最后返回这个 out调用者就可以从 out 中获得配件
有了采购好的配件就可以开始组装了如下面的代码所示
ch11/main.go
//工序2组装
func build(in <-chan string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for c := range in {
out <- "组装(" + c + ")"
}
}()
return out
}
组装函数 build 有一个 channel 类型的参数 in用于接收配件进行组装组装后的手机放到 channel 类型的变量 out 中返回
有了组装好的手机就可以放在精美的包装盒中售卖了而包装的操作是工序 3 完成的对应的函数是 pack如下所示
ch11/main.go
//工序3打包
func pack(in <-chan string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for c := range in {
out <- "打包(" + c + ")"
}
}()
return out
}
函数 pack 的代码实现和组装函数 build 基本相同这里不再赘述
流水线上的三道工序都完成后就可以通过一个组织者把三道工序组织在一起形成一条完整的手机组装流水线这个组织者可以是我们常用的 main 函数如下面的代码所示
ch11/main.go
func main() {
coms := buy(10) //采购10套配件
phones := build(coms) //组装10部手机
packs := pack(phones) //打包它们以便售卖
//输出测试看看效果
for p := range packs {
fmt.Println(p)
}
}
按照流水线工序进行调用最终把手机打包以便售卖过程如下所示
打包(组装(配件1))
打包(组装(配件2))
打包(组装(配件3))
打包(组装(配件4))
打包(组装(配件5))
打包(组装(配件6))
打包(组装(配件7))
打包(组装(配件8))
打包(组装(配件9))
打包(组装(配件10))
从上述例子中我们可以总结出一个流水线模式的构成
流水线由一道道工序构成每道工序通过 channel 把数据传递到下一个工序
每道工序一般会对应一个函数函数里有协程和 channel协程一般用于处理数据并把它放入 channel 整个函数会返回这个 channel 以供下一道工序使用
最终要有一个组织者示例中的 main 函数把这些工序串起来这样就形成了一个完整的流水线对于数据来说就是数据流
扇出和扇入模式
手机流水线经过一段时间的运转组织者发现产能提不上去经过调研分析发现瓶颈在工序 2 配件组装工序 2 过慢导致上游工序 1 配件采购速度不得不降下来下游工序 3 没太多事情做不得不闲下来这就是整条流水线产能低下的原因
为了提升手机产能组织者决定对工序 2 增加两班人手人手增加后整条流水线的示意图如下所示
改进后的流水线
从改造后的流水线示意图可以看到工序 2 共有工序 2-1工序 2-2工序 2-3 三班人手工序 1 采购的配件会被工序 2 的三班人手同时组装这三班人手组装好的手机会同时传给merge 组件汇聚然后再传给工序 3 打包成品在这个流程中会产生两种模式扇出和扇入
示意图中红色的部分是扇出对于工序 1 来说它同时为工序 2 的三班人手传递数据采购配件以工序 1 为中点三条传递数据的线发散出去就像一把打开的扇子一样所以叫扇出
示意图中蓝色的部分是扇入对于 merge 组件来说它同时接收工序 2 三班人手传递的数据组装的手机进行汇聚然后传给工序 3 merge 组件为中点三条传递数据的线汇聚到 merge 组件也像一把打开的扇子一样所以叫扇入
小提示扇出和扇入都像一把打开的扇子因为数据传递的方向不同所以叫法也不一样扇出的数据流向是发散传递出去是输出流扇入的数据流向是汇聚进来是输入流
已经理解了扇出扇入的原理就可以开始改造流水线了这次改造中三道工序的实现函数 buybuildpack 都保持不变只需要增加一个 merge 函数即可如下面的代码所示
ch11/main.go
//扇入函数组件把多个chanel中的数据发送到一个channel中
func merge(ins ...<-chan string) <-chan string {
var wg sync.WaitGroup
out := make(chan string)
//把一个channel中的数据发送到out中
p:=func(in <-chan string) {
defer wg.Done()
for c := range in {
out <- c
}
}
wg.Add(len(ins))
//扇入需要启动多个goroutine用于处于多个channel中的数据
for _,cs:=range ins{
go p(cs)
}
//等待所有输入的数据ins处理完再关闭输出out
go func() {
wg.Wait()
close(out)
}()
return out
}
新增的 merge 函数的核心逻辑就是对输入的每个 channel 使用单独的协程处理并将每个协程处理的结果都发送到变量 out 达到扇入的目的总结起来就是通过多个协程并发把多个 channel 合成一个
在整条手机组装流水线中merge 函数非常小而且和业务无关不能当作一道工序所以我把它叫作组件 merge 组件是可以复用的流水线中的任何工序需要扇入的时候都可以使用 merge 组件
小提示这次的改造新增了 merge 函数其他函数保持不变符合开闭原则开闭原则规定软件中的对象模块函数等等应该对于扩展是开放的但是对于修改是封闭的
有了可以复用的 merge 组件现在来看流水线的组织者 main 函数是如何使用扇出和扇入并发模式的如下所示
ch11/main.go
func main() {
coms := buy(100) //采购100套配件
//三班人同时组装100部手机
phones1 := build(coms)
phones2 := build(coms)
phones3 := build(coms)
//汇聚三个channel成一个
phones := merge(phones1,phones2,phones3)
packs := pack(phones) //打包它们以便售卖
//输出测试看看效果
for p := range packs {
fmt.Println(p)
}
}
这个示例采购了 100 套配件也就是开始增加产能了于是同时调用三次 build 函数也就是为工序 2 增加人手这里是三班人手同时组装配件然后通过 merge 函数这个可复用的组件将三个 channel 汇聚为一个然后传给 pack 函数打包
这样通过扇出和扇入模式整条流水线就被扩充好了大大提升了生产效率因为已经有了通用的扇入组件 merge所以整条流水中任何需要扇出扇入提高性能的工序都可以复用 merge 组件做扇入并且不用做任何修改
Futures 模式
Pipeline 流水线模式中的工序是相互依赖的上一道工序做完下一道工序才能开始但是在我们的实际需求中也有大量的任务之间相互独立没有依赖所以为了提高性能这些独立的任务就可以并发执行
举个例子比如我打算自己做顿火锅吃那么就需要洗菜烧水洗菜烧水这两个步骤相互之间没有依赖关系是独立的那么就可以同时做但是最后做火锅这个步骤就需要洗好菜烧好水之后才能进行这个做火锅的场景就适用 Futures 模式
Futures 模式可以理解为未来模式主协程不用等待子协程返回的结果可以先去做其他事情等未来需要子协程结果的时候再来取如果子协程还没有返回结果就一直等待我用下面的代码进行演示
ch11/main.go
//洗菜
func washVegetables() <-chan string {
vegetables := make(chan string)
go func() {
time.Sleep(5 * time.Second)
vegetables <- "洗好的菜"
}()
return vegetables
}
//烧水
func boilWater() <-chan string {
water := make(chan string)
go func() {
time.Sleep(5 * time.Second)
water <- "烧开的水"
}()
return water
}
洗菜和烧水这两个相互独立的任务可以一起做所以示例中通过开启协程的方式实现同时做的功能当任务完成后结果会通过 channel 返回
小提示示例中的等待 5 秒用来描述洗菜和烧火的耗时
在启动两个子协程同时去洗菜和烧水的时候主协程就可以去干点其他事情示例中是眯一会等睡醒了要做火锅的时候就需要洗好的菜和烧好的水这两个结果了我用下面的代码进行演示
ch11/main.go
func main() {
vegetablesCh := washVegetables() //洗菜
waterCh := boilWater() //烧水
fmt.Println("已经安排洗菜和烧水了我先眯一会")
time.Sleep(2 * time.Second)
fmt.Println("要做火锅了看看菜和水好了吗")
vegetables := <-vegetablesCh
water := <-waterCh
fmt.Println("准备好了可以做火锅了:",vegetables,water)
}
Futures 模式下的协程和普通协程最大的区别是可以返回结果而这个结果会在未来的某个时间点使用所以在未来获取这个结果的操作必须是一个阻塞的操作要一直等到获取结果为止
如果你的大任务可以拆解为一个个独立并发执行的小任务并且可以通过这些小任务的结果得出最终大任务的结果就可以使用 Futures 模式
总结
并发模式和设计模式很相似都是对现实场景的抽象封装以便提供一个统一的解决方案但和设计模式不同的是并发模式更专注于异步和并发

View File

@ -0,0 +1,221 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 指针详解:在什么情况下应该使用指针?
这节课起我将带你学习本专栏的第三模块:深入理解 Go 语言。这部分主要会为你讲解 Go 语言的高级特性,以及 Go 语言一些特性功能的底层原理。通过这部分的学习,你不光可以更好地使用 Go 语言,还会更深入地理解 Go 语言,比如理解你所使用的 slice 底层是如何实现的等。
什么是指针
我们都知道程序运行时的数据是存放在内存中的,而内存会被抽象为一系列具有连续编号的存储空间,那么每一个存储在内存中的数据都会有一个编号,这个编号就是内存地址。有了这个内存地址就可以找到这个内存中存储的数据,而内存地址可以被赋值给一个指针。
小提示:内存地址通常为 16 进制的数字表示,比如 0x45b876。
可以总结为:在编程语言中,指针是一种数据类型,用来存储一个内存地址,该地址指向存储在该内存中的对象。这个对象可以是字符串、整数、函数或者你自定义的结构体。
小技巧:你也可以简单地把指针理解为内存地址。
举个通俗的例子,每本书中都有目录,目录上会有相应章节的页码,你可以把页码理解为一系列的内存地址,通过页码你可以快速地定位到具体的章节(也就是说,通过内存地址可以快速地找到存储的数据)。
指针的声明和定义
在 Go 语言中,获取一个变量的指针非常容易,使用取地址符 & 就可以,比如下面的例子:
ch12/main.go
func main() {
name:="飞雪无情"
nameP:=&name//取地址
fmt.Println("name变量的值为:",name)
fmt.Println("name变量的内存地址为:",nameP)
}
我在示例中定义了一个 string 类型的变量 name它的值为”飞雪无情”然后通过取地址符 & 获取变量 name 的内存地址,并赋值给指针变量 nameP该指针变量的类型为 *string。运行以上示例你可以看到如下打印结果
name变量的值为: 飞雪无情
name变量的内存地址为: 0xc000010200
这一串 0xc000010200 就是内存地址,这个内存地址可以赋值给指针变量 nameP。
指针类型非常廉价,只占用 4 个或者 8 个字节的内存大小。
以上示例中 nameP 指针的类型是 *string用于指向 string 类型的数据。在 Go 语言中使用类型名称前加 * 的方式,即可表示一个对应的指针类型。比如 int 类型的指针类型是 *intfloat64 类型的指针类型是 *float64自定义结构体 A 的指针类型是 *A。总之指针类型就是在对应的类型前加 * 号。
下面我通过一个图让你更好地理解普通类型变量、指针类型变量、内存地址、内存等之间的关系。
(指针变量、内存地址指向示意图)
上图就是我刚举的例子所对应的示意图,从图中可以看到普通变量 name 的值“飞雪无情”被放到内存地址为 0xc000010200 的内存块中。指针类型变量也是变量,它也需要一块内存用来存储值,这块内存对应的地址就是 0xc00000e028存储的值是 0xc000010200。相信你已经看到关键点了指针变量 nameP 的值正好是普通变量 name 的内存地址,所以就建立指向关系。
小提示:指针变量的值就是它所指向数据的内存地址,普通变量的值就是我们具体存放的数据。
不同的指针类型是无法相互赋值的,比如你不能对一个 string 类型的变量取地址然后赋值给 *int指针类型编译器会提示你 Cannot use &name (type *string) as type *int in assignment。
此外,除了可以通过简短声明的方式声明一个指针类型的变量外,也可以使用 var 关键字声明,如下面示例中的 var intP *int 就声明了一个 *int 类型的变量 intP。
var intP *int
intP = &name //指针类型不同,无法赋值
可以看到指针变量也和普通的变量一样,既可以通过 var 关键字定义,也可以通过简短声明定义。
小提示:通过 var 声明的指针变量是不能直接赋值和取值的,因为这时候它仅仅是个变量,还没有对应的内存地址,它的值是 nil。
和普通类型不一样的是,指针类型还可以通过内置的 new 函数来声明,如下所示:
intP1:=new(int)
内置的 new 函数有一个参数,可以传递类型给它。它会返回对应的指针类型,比如上述示例中会返回一个 *int 类型的 intP1。
指针的操作
在 Go 语言中指针的操作无非是两种:一种是获取指针指向的值,一种是修改指针指向的值。
首先介绍如何获取,我用下面的代码进行演示:
nameV:=*nameP
fmt.Println("nameP指针指向的值为:",nameV)
可以看到,要获取指针指向的值,只需要在指针变量前加 * 号即可,获得的变量 nameV 的值就是“飞雪无情”,方法比较简单。
修改指针指向的值也非常简单,比如下面的例子:
*nameP = "公众号:飞雪无情" //修改指针指向的值
fmt.Println("nameP指针指向的值为:",*nameP)
fmt.Println("name变量的值为:",name)
对 *nameP 赋值等于修改了指针 nameP 指向的值。运行程序你将看到如下打印输出:
nameP指针指向的值为: 公众号:飞雪无情
name变量的值为: 公众号:飞雪无情
通过打印结果可以看到,不光 nameP 指针指向的值被改变了,变量 name 的值也被改变了,这就是指针的作用。因为变量 name 存储数据的内存就是指针 nameP 指向的内存,这块内存被 nameP 修改后,变量 name 的值也被修改了。
我们已经知道,通过 var 关键字直接定义的指针变量是不能进行赋值操作的,因为它的值为 nil也就是还没有指向的内存地址。比如下面的示例
var intP *int
*intP =10
运行的时候会提示 invalid memory address or nil pointer dereference。这时候该怎么办呢其实只需要通过 new 函数给它分配一块内存就可以了,如下所示:
var intP *int = new(int)
//更推荐简短声明法,这里是为了演示
//intP:=new(int)
指针参数
假如有一个函数 modifyAge想要用来修改年龄如下面的代码所示。但运行它你会看到 age 的值并没有被修改,还是 18并没有变成 20。
age:=18
modifyAge(age)
fmt.Println("age的值为:",age)
func modifyAge(age int) {
age = 20
}
导致这种结果的原因是 modifyAge 中的 age 只是实参 age 的一份拷贝,所以修改它不会改变实参 age 的值。
如果要达到修改年龄的目的,就需要使用指针,现在对刚刚的示例进行改造,如下所示:
age:=18
modifyAge(&age)
fmt.Println("age的值为:",age)
func modifyAge(age *int) {
*age = 20
}
也就是说,当你需要在函数中通过形参改变实参的值时,需要使用指针类型的参数。
指针接收者
指针的接收者在[“第 6 讲| struct 和 interface结构体与接口都实现了哪些功能”]中有详细介绍,你可以再复习一下。对于是否使用指针类型作为接收者,有以下几点参考:
如果接收者类型是 map、slice、channel 这类引用类型,不使用指针;
如果需要修改接收者,那么需要使用指针;
如果接收者是比较大的类型,可以考虑使用指针,因为内存拷贝廉价,所以效率高。
所以对于是否使用指针类型作为接收者,还需要你根据实际情况考虑。
什么情况下使用指针
从以上指针的详细分析中,我们可以总结出指针的两大好处:
可以修改指向数据的值;
在变量赋值,参数传值的时候可以节省内存。
不过 Go 语言作为一种高级语言,在指针的使用上还是比较克制的。它在设计的时候就对指针进行了诸多限制,比如指针不能进行运行,也不能获取常量的指针。所以在思考是否使用时,我们也要保持克制的心态。
我根据实战经验总结了以下几点使用指针的建议,供你参考:
不要对 map、slice、channel 这类引用类型使用指针;
如果需要修改方法接收者内部的数据或者状态时,需要使用指针;
如果需要修改参数的值或者内部数据时,也需要使用指针类型的参数;
如果是比较大的结构体,每次参数传递或者调用方法都要内存拷贝,内存占用多,这时候可以考虑使用指针;
像 int、bool 这样的小数据类型没必要使用指针;
如果需要并发安全,则尽可能地不要使用指针,使用指针一定要保证并发安全;
指针最好不要嵌套,也就是不要使用一个指向指针的指针,虽然 Go 语言允许这么做,但是这会使你的代码变得异常复杂。
总结
为了使编程变得更简单,指针在高级的语言中被逐渐淡化,但是它也的确有自己的优势:修改数据的值和节省内存。所以在 Go 语言的开发中我们要尽可能地使用值类型,而不是指针类型,因为值类型可以使你的开发变得更简单,并且也是并发安全的。如果你想使用指针类型,就要参考我上面讲到的使用指针的条件,看是否满足,要在满足和必须的情况下才使用指针。
这节课到这里就要结束了,在这节课的最后同样给你留个思考题:指向接口的指针是否实现了该接口?为什么?思考后可以自己写代码验证下。

View File

@ -0,0 +1,320 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 参数传递:值、引用及指针之间的区别?
上节课我留了一个思考题,关于指向接口的指针的思考。在[“第 6 讲| struct 和 interface结构体与接口都实现了哪些功能”]中,你已经知道了如何实现一个接口,并且也知道如果值接收者实现了接口,那么值的指针也就实现了该接口。现在我们再一起来复习一下接口实现的知识,然后再解答关于指向接口的指针的思考题。
在下面的代码中,值类型 address 作为接收者实现了接口 fmt.Stringer那么它的指针类型 *address 也就实现了接口 fmt.Stringer。
ch13/main.go
type address struct {
province string
city string
}
func (addr address) String() string{
return fmt.Sprintf("the addr is %s%s",addr.province,addr.city)
}
在下面的代码示例中,我定义了值类型的变量 add然后把它和它的指针 &add 都作为参数传给函数 printString发现都是可以的并且代码可以成功运行。这也证明了当值类型作为接收者实现了某接口时它的指针类型也同样实现了该接口。
ch13/main.go
func main() {
add := address{province: "北京", city: "北京"}
printString(add)
printString(&add)
}
func printString(s fmt.Stringer) {
fmt.Println(s.String())
}
基于以上结论,我们继续分析,看是否可以定义一个指向接口的指针。如下所示:
ch13/main.go
var si fmt.Stringer =address{province: "上海",city: "上海"}
printString(si)
sip:=&si
printString(sip)
在这个示例中,因为类型 address 已经实现了接口 fmt.Stringer所以它的值可以被赋予变量 si而且 si 也可以作为参数传递给函数 printString。
接着你可以使用 sip:=&si 这样的操作获得一个指向接口的指针,这是没有问题的。不过最终你无法把指向接口的指针 sip 作为参数传递给函数 printStringGo 语言的编译器会提示你如下错误信息:
./main.go:42:13: cannot use sip (type *fmt.Stringer) as type fmt.Stringer in argument to printString:
*fmt.Stringer is pointer to interface, not interface
于是可以总结为:虽然指向具体类型的指针可以实现一个接口,但是指向接口的指针永远不可能实现该接口。
所以你几乎从不需要一个指向接口的指针,把它忘掉吧,不要让它在你的代码中出现。
通过这个思考题,相信你也对 Go 语言的值类型、引用类型和指针等概念有了一定的了解,但可能也存在一些迷惑。这节课我将更深入地分析这些概念。
修改参数
假设你定义了一个函数,并在函数里对参数进行修改,想让调用者可以通过参数获取你最新修改的值。我仍然以前面课程用到的 person 结构体举例,如下所示:
ch13/main.go
func main() {
p:=person{name: "张三",age: 18}
modifyPerson(p)
fmt.Println("person name:",p.name,",age:",p.age)
}
func modifyPerson(p person) {
p.name = "李四"
p.age = 20
}
type person struct {
name string
age int
}
在这个示例中,我期望通过 modifyPerson 函数把参数 p 中的 name 修改为李四,把 age 修改为 20 。代码没有错误,但是运行一下,你会看到如下打印输出:
person name: 张三 ,age: 18
怎么还是张三与 18 呢?我换成指针参数试试,因为在上节课中我们已经知道可以通过指针修改指向的对象数据,如下所示:
modifyPerson(&p)
func modifyPerson(p *person) {
p.name = "李四"
p.age = 20
}
这些代码用于满足指针参数的修改,把接收的参数改为指针参数,以及在调用 modifyPerson 函数时,通过&取地址符传递一个指针。现在再运行程序,就可以看到期望的输出了,如下所示:
person name: 李四 ,age: 20
值类型
在上面的小节中,我定义的普通变量 p 是 person 类型的。在 Go 语言中person 是一个值类型,而 &p 获取的指针是 *person 类型的,即指针类型。那么为什么值类型在参数传递中无法修改呢?这也要从内存讲起。
在上节课中,我们已经知道变量的值是存储在内存中的,而内存都有一个编号,称为内存地址。所以要想修改内存中的数据,就要找到这个内存地址。现在,我来对比值类型变量在函数内外的内存地址,如下所示:
ch13/main.go
func main() {
p:=person{name: "张三",age: 18}
fmt.Printf("main函数p的内存地址为%p\n",&p)
modifyPerson(p)
fmt.Println("person name:",p.name,",age:",p.age)
}
func modifyPerson(p person) {
fmt.Printf("modifyPerson函数p的内存地址为%p\n",&p)
p.name = "李四"
p.age = 20
}
其中,我把原来的示例代码做了更改,分别打印出在 main 函数中变量 p 的内存地址,以及在 modifyPerson 函数中参数 p 的内存地址。运行以上程序,可以看到如下结果:
main函数p的内存地址为0xc0000a6020
modifyPerson函数p的内存地址为0xc0000a6040
person name: 张三 ,age: 18
你会发现它们的内存地址都不一样,这就意味着,在 modifyPerson 函数中修改的参数 p 和 main 函数中的变量 p 不是同一个,这也是我们在 modifyPerson 函数中修改参数 p但是在 main 函数中打印后发现并没有修改的原因。
导致这种结果的原因是 Go 语言中的函数传参都是值传递。 值传递指的是传递原来数据的一份拷贝,而不是原来的数据本身。
main 函数调用 modifyPerson 函数传参内存示意图)
以 modifyPerson 函数来说,在调用 modifyPerson 函数传递变量 p 的时候Go 语言会拷贝一个 p 放在一个新的内存中,这样新的 p 的内存地址就和原来不一样了,但是里面的 name 和 age 是一样的,还是张三和 18。这就是副本的意思变量里的数据一样但是存放的内存地址不一样。
除了 struct 外,还有浮点型、整型、字符串、布尔、数组,这些都是值类型。
指针类型
指针类型的变量保存的值就是数据对应的内存地址,所以在函数参数传递是传值的原则下,拷贝的值也是内存地址。现在对以上示例稍做修改,修改后的代码如下:
func main() {
p:=person{name: "张三",age: 18}
fmt.Printf("main函数p的内存地址为%p\n",&p
modifyPerson(&p)
fmt.Println("person name:",p.name,",age:",p.age)
}
func modifyPerson(p *person) {
fmt.Printf("modifyPerson函数p的内存地址为%p\n",p)
p.name = "李四"
p.age = 20
}
运行这个示例,你会发现打印出的内存地址一致,并且数据也被修改成功了,如下所示:
main函数p的内存地址为0xc0000a6020
modifyPerson函数p的内存地址为0xc0000a6020
person name: 李四 ,age: 20
所以指针类型的参数是永远可以修改原数据的,因为在参数传递时,传递的是内存地址。
小提示:值传递的是指针,也是内存地址。通过内存地址可以找到原数据的那块内存,所以修改它也就等于修改了原数据。
引用类型
下面要介绍的是引用类型,包括 map 和 chan。
map
对于上面的例子,假如我不使用自定义的 person 结构体和指针,能不能用 map 达到修改的目的呢?
下面我来试验一下,如下所示:
ch13/main.go
func main() {
m:=make(map[string]int)
m["飞雪无情"] = 18
fmt.Println("飞雪无情的年龄为",m["飞雪无情"])
modifyMap(m)
fmt.Println("飞雪无情的年龄为",m["飞雪无情"])
}
func modifyMap(p map[string]int) {
p["飞雪无情"] =20
}
我定义了一个 map[string]int 类型的变量 m存储一个 Key 为飞雪无情、Value 为 18 的键值对,然后把这个变量 m 传递给函数 modifyMap。modifyMap 函数所做的事情就是把对应的值修改为 20。现在运行这段代码通过打印输出来看是否修改成功结果如下所示
飞雪无情的年龄为 18
飞雪无情的年龄为 20
确实修改成功了。你是不是有不少疑惑?没有使用指针,只是用了 map 类型的参数,按照 Go 语言值传递的原则modifyMap 函数中的 map 是一个副本,怎么会修改成功呢?
要想解答这个问题,就要从 make 这个 Go 语言内建的函数说起。在 Go 语言中,任何创建 map 的代码(不管是字面量还是 make 函数)最终调用的都是 runtime.makemap 函数。
小提示:用字面量或者 make 函数的方式创建 map并转换成 makemap 函数的调用,这个转换是 Go 语言编译器自动帮我们做的。
从下面的代码可以看到makemap 函数返回的是一个 *hmap 类型,也就是说返回的是一个指针,所以我们创建的 map 其实就是一个 *hmap。
src/runtime/map.go
// makemap implements Go map creation for make(map[k]v, hint).
func makemap(t *maptype, hint int, h *hmap) *hmap{
//省略无关代码
}
因为 Go 语言的 map 类型本质上就是 *hmap所以根据替换的原则我刚刚定义的 modifyMap(p map) 函数其实就是 modifyMap(p *hmap)。这是不是和上一小节讲的指针类型的参数调用一样了?这也是通过 map 类型的参数可以修改原始数据的原因,因为它本质上就是个指针。
为了进一步验证创建的 map 就是一个指针,我修改上述示例,打印 map 类型的变量和参数对应的内存地址,如下面的代码所示:
func main(){
//省略其他没有修改的代码
fmt.Printf("main函数m的内存地址为%p\n",m)
}
func modifyMap(p map[string]int) {
fmt.Printf("modifyMap函数p的内存地址为%p\n",p)
//省略其他没有修改的代码
}
例子中的两句打印代码是新增的,其他代码没有修改,这里就不再贴出来了。运行修改后的程序,你可以看到如下输出:
飞雪无情的年龄为 18
main函数m的内存地址为0xc000060180
modifyMap函数p的内存地址为0xc000060180
飞雪无情的年龄为 20
从输出结果可以看到,它们的内存地址一模一样,所以才可以修改原始数据,得到年龄是 20 的结果。而且我在打印指针的时候,直接使用的是变量 m 和 p并没有用到取地址符 &,这是因为它们本来就是指针,所以就没有必要再使用 & 取地址了。
所以在这里Go 语言通过 make 函数或字面量的包装为我们省去了指针的操作,让我们可以更容易地使用 map。其实就是语法糖这是编程界的老传统了。
注意:这里的 map 可以理解为引用类型,但是它本质上是个指针,只是可以叫作引用类型而已。在参数传递时,它还是值传递,并不是其他编程语言中所谓的引用传递。
chan
还记得我们在 Go 语言并发模块中学的 channel 吗?它也可以理解为引用类型,而它本质上也是个指针。
通过下面的源代码可以看到,所创建的 chan 其实是个 *hchan所以它在参数传递中也和 map 一样。
func makechan(t *chantype, size int64) *hchan {
//省略无关代码
}
严格来说Go 语言没有引用类型,但是我们可以把 map、chan 称为引用类型,这样便于理解。除了 map、chan 之外Go 语言中的函数、接口、slice 切片都可以称为引用类型。
小提示:指针类型也可以理解为是一种引用类型。
类型的零值
在 Go 语言中,定义变量要么通过声明、要么通过 make 和 new 函数,不一样的是 make 和 new 函数属于显式声明并初始化。如果我们声明的变量没有显式声明初始化,那么该变量的默认值就是对应类型的零值。
从下面的表格可以看到,可以称为引用类型的零值都是 nil。
(各种类型的零值)
总结
在 Go 语言中,函数的参数传递只有值传递,而且传递的实参都是原始数据的一份拷贝。如果拷贝的内容是值类型的,那么在函数中就无法修改原始数据;如果拷贝的内容是指针(或者可以理解为引用类型 map、chan 等),那么就可以在函数中修改原始数据。
所以我们在创建一个函数的时候,要根据自己的真实需求决定参数的类型,以便更好地服务于我们的业务。
这节课中,我讲解 chan 的时候没有举例,你自己可以自定义一个有 chan 参数的函数,作为练习题。
下节课我将介绍“内存分配new 还是 make什么情况下该用谁”记得来听课

View File

@ -0,0 +1,316 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 内存分配new 还是 make什么情况下该用谁
程序的运行都需要内存,比如像变量的创建、函数的调用、数据的计算等。所以在需要内存的时候就要申请内存,进行内存分配。在 C/C++ 这类语言中,内存是由开发者自己管理的,需要主动申请和释放,而在 Go 语言中则是由该语言自己管理的开发者不用做太多干涉只需要声明变量Go 语言就会根据变量的类型自动分配相应的内存。
Go 语言程序所管理的虚拟内存空间会被分为两部分:堆内存和栈内存。栈内存主要由 Go 语言来管理,开发者无法干涉太多,堆内存才是我们开发者发挥能力的舞台,因为程序的数据大部分分配在堆内存上,一个程序的大部分内存占用也是在堆内存上。
小提示:我们常说的 Go 语言的内存垃圾回收是针对堆内存的垃圾回收。
变量的声明、初始化就涉及内存的分配,比如声明变量会用到 var 关键字,如果要对变量初始化,就会用到 = 赋值运算符。除此之外还可以使用内置函数 new 和 make这两个函数你在前面的课程中已经见过它们的功能非常相似但你可能还是比较迷惑所以这节课我会基于内存分配进而引出内置函数 new 和 make为你讲解他们的不同以及使用场景。
变量
一个数据类型,在声明初始化后都会赋值给一个变量,变量存储了程序运行所需的数据。
变量的声明
和前面课程讲的一样,如果要单纯声明一个变量,可以通过 var 关键字,如下所示:
var s string
该示例只是声明了一个变量 s类型为 string并没有对它进行初始化所以它的值为 string 的零值,也就是 ““(空字符串)。
上节课你已经知道 string 其实是个值类型,现在我们来声明一个指针类型的变量试试,如下所示:
var sp *string
发现也是可以的,但是它同样没有被初始化,所以它的值是 *string 类型的零值,也就是 nil。
变量的赋值
变量可以通过 = 运算符赋值,也就是修改变量的值。如果在声明一个变量的时候就给这个变量赋值,这种操作就称为变量的初始化。如果要对一个变量初始化,可以有三种办法。
声明时直接初始化,比如 var s string = “飞雪无情”。
声明后再进行初始化,比如 s=“飞雪无情”(假设已经声明变量 s
使用 := 简单声明,比如 s:=“飞雪无情”。
小提示:变量的初始化也是一种赋值,只不过它发生在变量声明的时候,时机最靠前。也就是说,当你获得这个变量时,它就已经被赋值了。
现在我们就对上面示例中的变量 s 进行赋值,示例代码如下:
ch14/main.go
func main() {
var s string
s = "张三"
fmt.Println(s)
}
运行以上代码,可以正常打印出张三,说明值类型的变量没有初始化时,直接赋值是没有问题的。那么对于指针类型的变量呢?
在下面的示例代码中,我声明了一个指针类型的变量 sp然后把该变量的值修改为“飞雪无情”。
ch14/main.go
func main() {
var sp *string
*sp = "飞雪无情"
fmt.Println(*sp)
}
运行这些代码,你会看到如下错误信息:
panic: runtime error: invalid memory address or nil pointer dereference
这是因为指针类型的变量如果没有分配内存,就默认是零值 nil它没有指向的内存所以无法使用强行使用就会得到以上 nil 指针错误。
而对于值类型来说,即使只声明一个变量,没有对其初始化,该变量也会有分配好的内存。
在下面的示例中,我声明了一个变量 s并没有对其初始化但是可以通过 &s 获取它的内存地址。这其实是 Go 语言帮我们做的,可以直接使用。
func main() {
var s string
fmt.Printf("%p\n",&s)
}
还记得我们在讲并发的时候,使用 var wg sync.WaitGroup 声明的变量 wg 吗?现在你应该知道为什么不进行初始化也可以直接使用了吧?因为 sync.WaitGroup 是一个 struct 结构体是一个值类型Go 语言自动分配了内存,所以可以直接使用,不会报 nil 异常。
于是可以得到结论:如果要对一个变量赋值,这个变量必须有对应的分配好的内存,这样才可以对这块内存操作,完成赋值的目的。
小提示:其实不止赋值操作,对于指针变量,如果没有分配内存,取值操作一样会报 nil 异常,因为没有可以操作的内存。
所以一个变量必须要经过声明、内存分配才能赋值才可以在声明的时候进行初始化。指针类型在声明的时候Go 语言并没有自动分配内存,所以不能对其进行赋值操作,这和值类型不一样。
小提示map 和 chan 也一样,因为它们本质上也是指针类型。
new 函数
既然我们已经知道了声明的指针变量默认是没有分配内存的,那么给它分配一块就可以了。于是就需要今天的主角之一 new 函数出场了,对于上面的例子,可以使用 new 函数进行如下改造:
ch14/main.go
func main() {
var sp *string
sp = new(string)//关键点
*sp = "飞雪无情"
fmt.Println(*sp)
}
以上代码的关键点在于通过内置的 new 函数生成了一个 *string并赋值给了变量 sp。现在再运行程序就正常了。
内置函数 new 的作用是什么呢?可以通过它的源代码定义分析,如下所示:
// The new built-in function allocates memory. The first argument is a type,
// not a value, and the value returned is a pointer to a newly
// allocated zero value of that type.
func new(Type) *Type
它的作用就是根据传入的类型申请一块内存,然后返回指向这块内存的指针,指针指向的数据就是该类型的零值。
比如传入的类型是 string那么返回的就是 string 指针,这个 string 指针指向的数据就是空字符串,如下所示:
sp1 = new(string)
fmt.Println(*sp1)//打印空字符串,也就是string的零值。
通过 new 函数分配内存并返回指向该内存的指针后,就可以通过该指针对这块内存进行赋值、取值等操作。
变量初始化
当声明了一些类型的变量时,这些变量的零值并不能满足我们的要求,这时就需要在变量声明的同时进行赋值(修改变量的值),这个过程称为变量的初始化。
下面的示例就是 string 类型的变量初始化,因为它的零值(空字符串)不能满足需要,所以需要在声明的时候就初始化为“飞雪无情”。
var s string = "飞雪无情"
s1:="飞雪无情"
不止基础类型可以通过以上这种字面量的方式进行初始化,复合类型也可以,比如之前课程示例中的 person 结构体,如下所示:
type person struct {
name string
age int
}
func main() {
//字面量初始化
p:=person{name: "张三",age: 18}
}
该示例代码就是在声明这个 p 变量的时候,把它的 name 初始化为张三age 初始化为 18。
指针变量初始化
在上个小节中,你已经知道了 new 函数可以申请内存并返回一个指向该内存的指针,但是这块内存中数据的值默认是该类型的零值,在一些情况下并不满足业务需求。比如我想得到一个 *person 类型的指针,并且它的 name 是飞雪无情、age 是 20但是 new 函数只有一个类型参数,并没有初始化值的参数,此时该怎么办呢?
要达到这个目的,你可以自定义一个函数,对指针变量进行初始化,如下所示:
ch14/main.go
func NewPerson() *person{
p:=new(person)
p.name = "飞雪无情"
p.age = 20
return p
}
还记得前面课程讲的工厂函数吗?这个代码示例中的 NewPerson 函数就是工厂函数,除了使用 new 函数创建一个 person 指针外,还对它进行了赋值,也就是初始化。这样 NewPerson 函数的使用者就会得到一个 name 为飞雪无情、age 为 20 的 *person 类型的指针,通过 NewPerson 函数做一层包装把内存分配new 函数)和初始化(赋值)都完成了。
下面的代码就是使用 NewPerson 函数的示例,它通过打印 *pp 指向的数据值,来验证 name 是否是飞雪无情age 是否是 20。
pp:=NewPerson()
fmt.Println("name为",pp.name,",age为",pp.age)
为了让自定义的工厂函数 NewPerson 更加通用,我把它改造一下,让它可以接受 name 和 age 参数,如下所示:
ch14/main.go
pp:=NewPerson("飞雪无情",20)
func NewPerson(name string,age int) *person{
p:=new(person)
p.name = name
p.age = age
return p
}
这些代码的效果和刚刚的示例一样,但是 NewPerson 函数更通用,因为你可以传递不同的参数,构建出不同的 *person 变量。
make 函数
铺垫了这么多,终于讲到今天的第二个主角 make 函数了。在上节课中你已经知道,在使用 make 函数创建 map 的时候,其实调用的是 makemap 函数,如下所示:
src/runtime/map.go
// makemap implements Go map creation for make(map[k]v, hint).
func makemap(t *maptype, hint int, h *hmap) *hmap{
//省略无关代码
}
makemap 函数返回的是 *hmap 类型,而 hmap 是一个结构体,它的定义如下面的代码所示:
src/runtime/map.go
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
可以看到,我们平时使用的 map 关键字其实非常复杂,它包含 map 的大小 count、存储桶 buckets 等。要想使用这样的 hmap不是简单地通过 new 函数返回一个 *hmap 就可以,还需要对其进行初始化,这就是 make 函数要做的事情,如下所示:
m:=make(map[string]int,10)
是不是发现 make 函数和上一小节中自定义的 NewPerson 函数很像?其实 make 函数就是 map 类型的工厂函数,它可以根据传递它的 K-V 键值对类型,创建不同类型的 map同时可以初始化 map 的大小。
小提示make 函数不只是 map 类型的工厂函数,还是 chan、slice 的工厂函数。它同时可以用于 slice、chan 和 map 这三种类型的初始化。
总结
通过这节课的讲解,相信你已经理解了函数 new 和 make 的区别,现在我再来总结一下。
new 函数只用于分配内存并且把内存清零也就是返回一个指向对应类型零值的指针。new 函数一般用于需要显式地返回指针的情况,不是太常用。
make 函数只用于 slice、chan 和 map 这三种内置类型的创建和初始化,因为这三种类型的结构比较复杂,比如 slice 要提前初始化好内部元素的类型slice 的长度和容量等,这样才可以更好地使用它们。
在这节课的最后,给你留一个练习题:使用 make 函数创建 slice并且使用不同的长度和容量作为参数看看它们的效果。
下节课我将介绍“运行时反射:字符串和结构体之间如何转换?”记得来听课!

View File

@ -0,0 +1,697 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 运行时反射:字符串和结构体之间如何转换?
我们在开发中会接触很多字符串和结构体之间的转换,尤其是在调用 API 的时候,你需要把 API 返回的 JSON 字符串转换为 struct 结构体,便于操作。那么一个 JSON 字符串是如何转换为 struct 结构体的呢?这就需要用到反射的知识,这节课我会基于字符串和结构体之间的转换,一步步地为你揭开 Go 语言运行时反射的面纱。
反射是什么?
和 Java 语言一样Go 语言也有运行时反射,这为我们提供了一种可以在运行时操作任意类型对象的能力。比如查看一个接口变量的具体类型、看看一个结构体有多少字段、修改某个字段的值等。
Go 语言是静态编译类语言比如在定义一个变量的时候已经知道了它是什么类型那么为什么还需要反射呢这是因为有些事情只有在运行时才知道。比如你定义了一个函数它有一个interface{}类型的参数,这也就意味着调用者可以传递任何类型的参数给这个函数。在这种情况下,如果你想知道调用者传递的是什么类型的参数,就需要用到反射。如果你想知道一个结构体有哪些字段和方法,也需要反射。
还是以我常用的函数 fmt.Println 为例,如下所示:
src/fmt/print.go
func Println(a ...interface{}) (n int, err error) {
return Fprintln(os.Stdout, a...)
}
例子中 fmt.Println 的源代码有一个可变参数,类型为 interface{},这意味着你可以传递零个或者多个任意类型参数给它,都能被正确打印。
reflect.Value 和 reflect.Type
在 Go 语言的反射定义中,任何接口都由两部分组成:接口的具体类型,以及具体类型对应的值。比如 var i int = 3因为 interface{} 可以表示任何类型,所以变量 i 可以转为 interface{}。你可以把变量 i 当成一个接口,那么这个变量在 Go 反射中的表示就是 。其中 Value 为变量的值,即 3而 Type 为变量的类型,即 int。
小提示interface{} 是空接口,可以表示任何类型,也就是说你可以把任何类型转换为空接口,它通常用于反射、类型断言,以减少重复代码,简化编程。
在 Go 反射中,标准库为我们提供了两种类型 reflect.Value 和 reflect.Type 来分别表示变量的值和类型,并且提供了两个函数 reflect.ValueOf 和 reflect.TypeOf 分别获取任意对象的 reflect.Value 和 reflect.Type。
我用下面的代码进行演示:
ch15/main.go
func main() {
i:=3
iv:=reflect.ValueOf(i)
it:=reflect.TypeOf(i)
fmt.Println(iv,it)//3 int
}
代码定义了一个 int 类型的变量 i它的值为 3然后通过 reflect.ValueOf 和 reflect.TypeOf 函数就可以获得变量 i 对应的 reflect.Value 和 reflect.Type。通过 fmt.Println 函数打印后,可以看到结果是 3 int这也可以证明 reflect.Value 表示的是变量的值reflect.Type 表示的是变量的类型。
reflect.Value
reflect.Value 可以通过函数 reflect.ValueOf 获得,下面我将为你介绍它的结构和用法。
结构体定义
在 Go 语言中reflect.Value 被定义为一个 struct 结构体,它的定义如下面的代码所示:
type Value struct {
typ *rtype
ptr unsafe.Pointer
flag
}
我们发现 reflect.Value 结构体的字段都是私有的,也就是说,我们只能使用 reflect.Value 的方法。现在看看它有哪些常用方法,如下所示:
//针对具体类型的系列方法
//以下是用于获取对应的值
Bool
Bytes
Complex
Float
Int
String
Uint
CanSet //是否可以修改对应的值
以下是用于修改对应的值
Set
SetBool
SetBytes
SetComplex
SetFloat
SetInt
SetString
Elem //获取指针指向的值,一般用于修改对应的值
//以下Field系列方法用于获取struct类型中的字段
Field
FieldByIndex
FieldByName
FieldByNameFunc
Interface //获取对应的原始类型
IsNil //值是否为nil
IsZero //值是否是零值
Kind //获取对应的类型类别比如Array、Slice、Map等
//获取对应的方法
Method
MethodByName
NumField //获取struct类型中字段的数量
NumMethod//类型上方法集的数量
Type//获取对应的reflect.Type
看着比较多,其实就三类:一类用于获取和修改对应的值;一类和 struct 类型的字段有关,用于获取对应的字段;一类和类型上的方法集有关,用于获取对应的方法。
下面我通过几个例子讲解如何使用它们。
获取原始类型
在上面的例子中,我通过 reflect.ValueOf 函数把任意类型的对象转为一个 reflect.Value而如果想逆向转回来也可以reflect.Value 为我们提供了 Inteface 方法,如下面的代码所示:
ch15/main.go
func main() {
i:=3
//int to reflect.Value
iv:=reflect.ValueOf(i)
//reflect.Value to int
i1:=iv.Interface().(int)
fmt.Println(i1)
}
这是 reflect.Value 和 int 类型互转,换成其他类型也可以。
修改对应的值
已经定义的变量可以通过反射在运行时修改,比如上面的示例 i=3修改为 4如下所示
ch15/main.go
func main() {
i:=3
ipv:=reflect.ValueOf(&i)
ipv.Elem().SetInt(4)
fmt.Println(i)
}
这样就通过反射修改了一个变量。因为 reflect.ValueOf 函数返回的是一份值的拷贝,所以我们要传入变量的指针才可以。 因为传递的是一个指针,所以需要调用 Elem 方法找到这个指针指向的值,这样才能修改。 最后我们就可以使用 SetInt 方法修改值了。
要修改一个变量的值,有几个关键点:传递指针(可寻址),通过 Elem 方法获取指向的值才可以保证值可以被修改reflect.Value 为我们提供了 CanSet 方法判断是否可以修改该变量。
那么如何修改 struct 结构体字段的值呢?参考变量的修改方式,可总结出以下步骤:
传递一个 struct 结构体的指针,获取对应的 reflect.Value
通过 Elem 方法获取指针指向的值;
通过 Field 方法获取要修改的字段;
通过 Set 系列方法修改成对应的值。
运行下面的代码,你会发现变量 p 中的 Name 字段已经被修改为张三了。
ch15/main.go
func main() {
p:=person{Name: "飞雪无情",Age: 20}
ppv:=reflect.ValueOf(&p)
ppv.Elem().Field(0).SetString("张三")
fmt.Println(p)
}
type person struct {
Name string
Age int
}
最后再来总结一下通过反射修改一个值的规则。
可被寻址,通俗地讲就是要向 reflect.ValueOf 函数传递一个指针作为参数。
如果要修改 struct 结构体字段值的话,该字段需要是可导出的,而不是私有的,也就是该字段的首字母为大写。
记得使用 Elem 方法获得指针指向的值,这样才能调用 Set 系列方法进行修改。
记住以上规则,你就可以在程序运行时通过反射修改一个变量或字段的值。
获取对应的底层类型
底层类型是什么意思呢?其实对应的主要是基础类型,比如接口、结构体、指针……因为我们可以通过 type 关键字声明很多新的类型。比如在上面的例子中,变量 p 的实际类型是 person但是 person 对应的底层类型是 struct 这个结构体类型,而 &p 对应的则是指针类型。我们来通过下面的代码进行验证:
ch15/main.go
func main() {
p:=person{Name: "飞雪无情",Age: 20}
ppv:=reflect.ValueOf(&p)
fmt.Println(ppv.Kind())
pv:=reflect.ValueOf(p)
fmt.Println(pv.Kind())
}
运行以上代码,可以看到如下打印输出:
ptr
struct
Kind 方法返回一个 Kind 类型的值,它是一个常量,有以下可供使用的值:
type Kind uint
const (
Invalid Kind = iota
Bool
Int
Int8
Int16
Int32
Int64
Uint
Uint8
Uint16
Uint32
Uint64
Uintptr
Float32
Float64
Complex64
Complex128
Array
Chan
Func
Interface
Map
Ptr
Slice
String
Struct
UnsafePointer
)
从以上源代码定义的 Kind 常量列表可以看到,已经包含了 Go 语言的所有底层类型。
reflect.Type
reflect.Value 可以用于与值有关的操作中,而如果是和变量类型本身有关的操作,则最好使用 reflect.Type比如要获取结构体对应的字段名称或方法。
要反射获取一个变量的 reflect.Type可以通过函数 reflect.TypeOf。
接口定义
和 reflect.Value 不同reflect.Type 是一个接口,而不是一个结构体,所以也只能使用它的方法。
以下是我列出来的 reflect.Type 接口常用的方法。从这个列表来看,大部分都和 reflect.Value 的方法功能相同。
type Type interface {
Implements(u Type) bool
AssignableTo(u Type) bool
ConvertibleTo(u Type) bool
Comparable() bool
//以下这些方法和Value结构体的功能相同
Kind() Kind
Method(int) Method
MethodByName(string) (Method, bool)
NumMethod() int
Elem() Type
Field(i int) StructField
FieldByIndex(index []int) StructField
FieldByName(name string) (StructField, bool)
FieldByNameFunc(match func(string) bool) (StructField, bool)
NumField() int
}
其中几个特有的方法如下:
Implements 方法用于判断是否实现了接口 u
AssignableTo 方法用于判断是否可以赋值给类型 u其实就是是否可以使用 =,即赋值运算符;
ConvertibleTo 方法用于判断是否可以转换成类型 u其实就是是否可以进行类型转换
Comparable 方法用于判断该类型是否是可比较的,其实就是是否可以使用关系运算符进行比较。
我同样会通过一些示例来讲解 reflect.Type 的使用。
遍历结构体的字段和方法
我还是采用上面示例中的 person 结构体进行演示,不过需要修改一下,为它增加一个方法 String如下所示
func (p person) String() string{
return fmt.Sprintf("Name is %s,Age is %d",p.Name,p.Age)
}
新增一个 String 方法,返回对应的字符串信息,这样 person 这个 struct 结构体也实现了 fmt.Stringer 接口。
你可以通过 NumField 方法获取结构体字段的数量,然后使用 for 循环,通过 Field 方法就可以遍历结构体的字段,并打印出字段名称。同理,遍历结构体的方法也是同样的思路,代码也类似,如下所示:
ch15/main.go
func main() {
p:=person{Name: "飞雪无情",Age: 20}
pt:=reflect.TypeOf(p)
//遍历person的字段
for i:=0;i<pt.NumField();i++{
fmt.Println("字段",pt.Field(i).Name)
}
//遍历person的方法
for i:=0;i<pt.NumMethod();i++{
fmt.Println("方法",pt.Method(i).Name)
}
}
运行这个代码可以看到如下结果
字段 Name
字段 Age
方法 String
这正好和我在结构体 person 中定义的一致说明遍历成功
小技巧你可以通过 FieldByName 方法获取指定的字段也可以通过 MethodByName 方法获取指定的方法这在需要获取某个特定的字段或者方法时非常高效而不是使用遍历
是否实现某接口
通过 reflect.Type 还可以判断是否实现了某接口我还是以 person 结构体为例判断它是否实现了接口 fmt.Stringer io.Writer如下面的代码所示
func main() {
p:=person{Name: "飞雪无情",Age: 20}
pt:=reflect.TypeOf(p)
stringerType:=reflect.TypeOf((*fmt.Stringer)(nil)).Elem()
writerType:=reflect.TypeOf((*io.Writer)(nil)).Elem()
fmt.Println("是否实现了fmt.Stringer",pt.Implements(stringerType))
fmt.Println("是否实现了io.Writer",pt.Implements(writerType))
}
小提示尽可能通过类型断言的方式判断是否实现了某接口而不是通过反射
这个示例通过 Implements 方法来判断是否实现了 fmt.Stringer io.Writer 接口运行它你可以看到如下结果
是否实现了fmt.Stringer true
是否实现了io.Writer false
因为结构体 person 只实现了 fmt.Stringer 接口没有实现 io.Writer 接口所以和验证的结果一致
字符串和结构体互转
在字符串和结构体互转的场景中使用最多的就是 JSON struct 互转在这个小节中我会用 JSON struct 讲解 struct tag 这一功能的使用
JSON Struct 互转
Go 语言的标准库有一个 json 通过它可以把 JSON 字符串转为一个 struct 结构体也可以把一个 struct 结构体转为一个 json 字符串下面我还是以 person 这个结构体为例讲解 JSON struct 的相互转换如下面的代码所示
func main() {
p:=person{Name: "飞雪无情",Age: 20}
//struct to json
jsonB,err:=json.Marshal(p)
if err==nil {
fmt.Println(string(jsonB))
}
//json to struct
respJSON:="{\"Name\":\"李四\",\"Age\":40}"
json.Unmarshal([]byte(respJSON),&p)
fmt.Println(p)
}
这个示例是我使用 Go 语言提供的 json 标准包做的演示通过 json.Marshal 函数你可以把一个 struct 转为 JSON 字符串通过 json.Unmarshal 函数你可以把一个 JSON 字符串转为 struct
运行以上代码你会看到如下结果输出
{"Name":"飞雪无情","Age":20}
Name is 李四,Age is 40
仔细观察以上打印出的 JSON 字符串你会发现 JSON 字符串的 Key struct 结构体的字段名称一样比如示例中的 Name Age那么是否可以改变它们呢比如改成小写的 name age并且字段的名称还是大写的 Name Age当然可以要达到这个目的就需要用到 struct tag 的功能了
Struct Tag
顾名思义struct tag 是一个添加在 struct 字段上的标记使用它进行辅助可以完成一些额外的操作比如 json struct 互转在上面的示例中如果想把输出的 json 字符串的 Key 改为小写的 name age可以通过为 struct 字段添加 tag 的方式示例代码如下
type person struct {
Name string `json:"name"`
Age int `json:"age"`
}
struct 字段添加 tag 的方法很简单只需要在字段后面通过反引号把一个键值对包住即可比如以上示例中的 json:"name"。其中冒号前的 json 是一个 Key可以通过这个 Key 获取冒号后对应的 name
小提示json 作为 Key Go 语言自带的 json 包解析 JSON 的一种约定它会通过 json 这个 Key 找到对应的值用于 JSON Key
我们已经通过 struct tag 指定了可以使用 name age 作为 json Key代码就可以修改成如下所示
respJSON:="{\"name\":\"李四\",\"age\":40}"
没错JSON 字符串也可以使用小写的 name age 现在再运行这段代码你会看到如下结果
{"name":"张三","age":20}
Name is 李四,Age is 40
输出的 JSON 字符串的 Key 是小写的 name age并且小写的 name age JSON 字符串也可以转为 person 结构体
相信你已经发现struct tag 是整个 JSON struct 互转的关键这个 tag 就像是我们为 struct 字段起的别名那么 json 包是如何获得这个 tag 的呢这就需要反射了我们来看下面的代码
//遍历person字段中key为json的tag
for i:=0;i<pt.NumField();i++{
sf:=pt.Field(i)
fmt.Printf("字段%s上,json tag为%s\n",sf.Name,sf.Tag.Get("json"))
}
要想获得字段上的 tag就要先反射获得对应的字段我们可以通过 Field 方法做到该方法返回一个 StructField 结构体它有一个字段是 Tag存有字段的所有 tag示例中要获得 Key json tag所以只需要调用 sf.Tag.Get(“json”) 即可
结构体的字段可以有多个 tag用于不同的场景比如 json 转换bson 转换orm 解析等如果有多个 tag要使用空格分隔采用不同的 Key 可以获得不同的 tag如下面的代码所示
//遍历person字段中key为jsonbson的tag
for i:=0;i<pt.NumField();i++{
sf:=pt.Field(i)
fmt.Printf("字段%s上,json tag为%s\n",sf.Name,sf.Tag.Get("json"))
fmt.Printf("字段%s上,bson tag为%s\n",sf.Name,sf.Tag.Get("bson"))
}
type person struct {
Name string `json:"name" bson:"b_name"`
Age int `json:"age" bson:"b_name"`
}
运行代码你可以看到如下结果
字段Name上,key为json的tag为name
字段Name上,key为bson的tag为b_name
字段Age上,key为json的tag为age
字段Age上,key为bson的tag为b_name
可以看到通过不同的 Key使用 Get 方法就可以获得自定义的不同的 tag
实现 Struct JSON
相信你已经理解了什么是 struct tag下面我再通过一个 struct json 的例子演示它的使用
func main() {
p:=person{Name: "飞雪无情",Age: 20}
pv:=reflect.ValueOf(p)
pt:=reflect.TypeOf(p)
//自己实现的struct to json
jsonBuilder:=strings.Builder{}
jsonBuilder.WriteString("{")
num:=pt.NumField()
for i:=0;i<num;i++{
jsonTag:=pt.Field(i).Tag.Get("json") //获取json tag
jsonBuilder.WriteString("\""+jsonTag+"\"")
jsonBuilder.WriteString(":")
//获取字段的值
jsonBuilder.WriteString(fmt.Sprintf("\"%v\"",pv.Field(i)))
if i<num-1{
jsonBuilder.WriteString(",")
}
}
jsonBuilder.WriteString("}")
fmt.Println(jsonBuilder.String())//打印json字符串
}
这是一个比较简单的 struct json 示例但是已经可以很好地演示 struct 的使用在上述示例中自定义的 jsonBuilder 负责 json 字符串的拼接通过 for 循环把每一个字段拼接成 json 字符串运行以上代码你可以看到如下打印结果
{"name":"飞雪无情","age":"20"}
json 字符串的转换只是 struct tag 的一个应用场景你完全可以把 struct tag 当成结构体中字段的元数据配置使用它来做想做的任何事情比如 orm 映射xml 转换生成 swagger 文档等
反射定律
反射是计算机语言中程序检视其自身结构的一种方法它属于元编程的一种形式反射灵活强大但也存在不安全它可以绕过编译器的很多静态检查如果过多使用便会造成混乱为了帮助开发者更好地理解反射Go 语言的作者在博客上总结了反射的三大定律
任何接口值 interface{} 都可以反射出反射对象也就是 reflect.Value reflect.Type通过函数 reflect.ValueOf reflect.TypeOf 获得
反射对象也可以还原为 interface{} 变量也就是第 1 条定律的可逆性通过 reflect.Value 结构体的 Interface 方法获得
要修改反射的对象该值必须可设置也就是可寻址参考上节课修改变量的值那一节的内容理解
小提示任何类型的变量都可以转换为空接口 intferface{}所以第 1 条定律中函数 reflect.ValueOf reflect.TypeOf 的参数就是 interface{}表示可以把任何类型的变量转换为反射对象在第 2 条定律中reflect.Value 结构体的 Interface 方法返回的值也是 interface{}表示可以把反射对象还原为对应的类型变量
一旦你理解了这三大定律就可以更好地理解和使用 Go 语言反射
总结
在反射中reflect.Value 对应的是变量的值如果你需要进行和变量的值有关的操作应该优先使用 reflect.Value比如获取变量的值修改变量的值等reflect.Type 对应的是变量的类型如果你需要进行和变量的类型本身有关的操作应该优先使用 reflect.Type比如获取结构体内的字段类型拥有的方法集等
此外我要再次强调反射虽然很强大可以简化编程减少重复代码但是过度使用会让你的代码变得复杂混乱所以除非非常必要否则尽可能少地使用它们
这节课的作业是自己写代码运行通过反射调用结构体的方法
下节课我将介绍非类型安全让你既爱又恨的 unsafe”,记得来听课

View File

@ -0,0 +1,248 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 非类型安全:让你既爱又恨的 unsafe
上节课我留了一个小作业,让你练习一下如何使用反射调用一个方法,下面我来进行讲解。
还是以 person 这个结构体类型为例。我为它增加一个方法 Print功能是打印一段文本示例代码如下
func (p person) Print(prefix string){
fmt.Printf("%s:Name is %s,Age is %d\n",prefix,p.Name,p.Age)
}
然后就可以通过反射调用 Print 方法了,示例代码如下:
func main() {
p:=person{Name: "飞雪无情",Age: 20}
pv:=reflect.ValueOf(p)
//反射调用person的Print方法
mPrint:=pv.MethodByName("Print")
args:=[]reflect.Value{reflect.ValueOf("登录")}
mPrint.Call(args)
}
从示例中可以看到,要想通过反射调用一个方法,首先要通过 MethodByName 方法找到相应的方法。因为 Print 方法需要参数,所以需要声明参数,它的类型是 []reflect.Value也就是示例中的 args 变量,最后就可以通过 Call 方法反射调用 Print 方法了。其中记得要把 args 作为参数传递给 Call 方法。
运行以上代码,可以看到如下结果:
登录:Name is 飞雪无情,Age is 20
从打印的结果可以看到,和我们直接调用 Print 方法是一样的结果,这也证明了通过反射调用 Print 方法是可行的。
下面我们继续深入 Go 的世界,这节课会介绍 Go 语言自带的 unsafe 包的高级用法。
顾名思义unsafe 是不安全的。Go 将其定义为这个包名,也是为了让我们尽可能地不使用它。不过虽然不安全,它也有优势,那就是可以绕过 Go 的内存安全机制,直接对内存进行读写。所以有时候出于性能需要,还是会冒险使用它来对内存进行操作。
指针类型转换
Go 的设计者为了编写方便、提高效率且降低复杂度将其设计成一门强类型的静态语言。强类型意味着一旦定义了类型就不能改变静态意味着类型检查在运行前就做了。同时出于安全考虑Go 语言是不允许两个指针类型进行转换的。
我们一般使用 *T 作为一个指针类型,表示一个指向类型 T 变量的指针。为了安全的考虑,两个不同的指针类型不能相互转换,比如 *int 不能转为 *float64。
我们来看下面的代码:
func main() {
i:= 10
ip:=&i
var fp *float64 = (*float64)(ip)
fmt.Println(fp)
}
这个代码在编译的时候,会提示 *cannot convert ip (type * int) to type * float64*,也就是不能进行强制转型。那如果还是需要转换呢?这就需要使用 unsafe 包里的 Pointer 了。下面我先为你介绍 unsafe.Pointer 是什么,然后再介绍如何转换。
unsafe.Pointer
unsafe.Pointer 是一种特殊意义的指针,可以表示任意类型的地址,类似 C 语言里的 void* 指针,是全能型的。
正常情况下,*int 无法转换为 *float64但是通过 unsafe.Pointer 做中转就可以了。在下面的示例中,我通过 unsafe.Pointer 把 *int 转换为 *float64并且对新的 *float64 进行 3 倍的乘法操作,你会发现原来变量 i 的值也被改变了,变为 30。
ch16/main.go
func main() {
i:= 10
ip:=&i
var fp *float64 = (*float64)(unsafe.Pointer(ip))
*fp = *fp * 3
fmt.Println(i)
}
这个例子没有任何实际意义,但是说明了通过 unsafe.Pointer 这个万能的指针,我们可以在 *T 之间做任何转换。那么 unsafe.Pointer 到底是什么?为什么其他类型的指针可以转换为 unsafe.Pointer 呢?这就要看 unsafe.Pointer 的源代码定义了,如下所示:
// ArbitraryType is here for the purposes of documentation
// only and is not actually part of the unsafe package.
// It represents the type of an arbitrary Go expression.
type ArbitraryType int
type Pointer *ArbitraryType
按 Go 语言官方的注释ArbitraryType 可以表示任何类型(这里的 ArbitraryType 仅仅是文档需要,不用太关注它本身,只要记住可以表示任何类型即可)。 而 unsafe.Pointer 又是 *ArbitraryType也就是说 unsafe.Pointer 是任何类型的指针,也就是一个通用型的指针,足以表示任何内存地址。
uintptr 指针类型
uintptr 也是一种指针类型,它足够大,可以表示任何指针。它的类型定义如下所示:
// uintptr is an integer type that is large enough
// to hold the bit pattern of any pointer.
type uintptr uintptr
既然已经有了 unsafe.Pointer为什么还要设计 uintptr 类型呢?这是因为 unsafe.Pointer 不能进行运算,比如不支持 +(加号)运算符操作,但是 uintptr 可以。通过它,可以对指针偏移进行计算,这样就可以访问特定的内存,达到对特定内存读写的目的,这是真正内存级别的操作。
在下面的代码中,我以通过指针偏移修改 struct 结构体内的字段为例,演示 uintptr 的用法。
func main() {
p :=new(person)
//Name是person的第一个字段不用偏移即可通过指针修改
pName:=(*string)(unsafe.Pointer(p))
*pName="飞雪无情"
//Age并不是person的第一个字段所以需要进行偏移这样才能正确定位到Age字段这块内存才可以正确的修改
pAge:=(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(p))+unsafe.Offsetof(p.Age)))
*pAge = 20
fmt.Println(*p)
}
type person struct {
Name string
Age int
}
这个示例不是通过直接访问相应字段的方式对 person 结构体字段赋值,而是通过指针偏移找到相应的内存,然后对内存操作进行赋值。
下面我详细介绍操作步骤。
先使用 new 函数声明一个 *person 类型的指针变量 p。
然后把 *person 类型的指针变量 p 通过 unsafe.Pointer转换为 *string 类型的指针变量 pName。
因为 person 这个结构体的第一个字段就是 string 类型的 Name所以 pName 这个指针就指向 Name 字段(偏移为 0对 pName 进行修改其实就是修改字段 Name 的值。
因为 Age 字段不是 person 的第一个字段,要修改它必须要进行指针偏移运算。所以需要先把指针变量 p 通过 unsafe.Pointer 转换为 uintptr这样才能进行地址运算。既然要进行指针偏移那么要偏移多少呢这个偏移量可以通过函数 unsafe.Offsetof 计算出来,该函数返回的是一个 uintptr 类型的偏移量,有了这个偏移量就可以通过 + 号运算符获得正确的 Age 字段的内存地址了,也就是通过 unsafe.Pointer 转换后的 *int 类型的指针变量 pAge。
然后需要注意的是,如果要进行指针运算,要先通过 unsafe.Pointer 转换为 uintptr 类型的指针。指针运算完毕后,还要通过 unsafe.Pointer 转换为真实的指针类型(比如示例中的 *int 类型),这样可以对这块内存进行赋值或取值操作。
有了指向字段 Age 的指针变量 pAge就可以对其进行赋值操作修改字段 Age 的值了。
运行以上示例,你可以看到如下结果:
{飞雪无情 20}
这个示例主要是为了讲解 uintptr 指针运算,所以一个结构体字段的赋值才会写得这么复杂,如果按照正常的编码,以上示例代码会和下面的代码结果一样。
func main() {
p :=new(person)
p.Name = "飞雪无情"
p.Age = 20
fmt.Println(*p)
}
指针运算的核心在于它操作的是一个个内存地址,通过内存地址的增减,就可以指向一块块不同的内存并对其进行操作,而且不必知道这块内存被起了什么名字(变量名)。
指针转换规则
你已经知道 Go 语言中存在三种类型的指针,它们分别是:常用的 *T、unsafe.Pointer 及 uintptr。通过以上示例讲解可以总结出这三者的转换规则
任何类型的 *T 都可以转换为 unsafe.Pointer
unsafe.Pointer 也可以转换为任何类型的 *T
unsafe.Pointer 可以转换为 uintptr
uintptr 也可以转换为 unsafe.Pointer。
(指针转换示意图)
可以发现unsafe.Pointer 主要用于指针类型的转换而且是各个指针类型转换的桥梁。uintptr 主要用于指针运算,尤其是通过偏移量定位不同的内存。
unsafe.Sizeof
Sizeof 函数可以返回一个类型所占用的内存大小,这个大小只与类型有关,和类型对应的变量存储的内容大小无关,比如 bool 型占用一个字节、int8 也占用一个字节。
通过 Sizeof 函数你可以查看任何类型(比如字符串、切片、整型)占用的内存大小,示例代码如下:
fmt.Println(unsafe.Sizeof(true))
fmt.Println(unsafe.Sizeof(int8(0)))
fmt.Println(unsafe.Sizeof(int16(10)))
fmt.Println(unsafe.Sizeof(int32(10000000)))
fmt.Println(unsafe.Sizeof(int64(10000000000000)))
fmt.Println(unsafe.Sizeof(int(10000000000000000)))
fmt.Println(unsafe.Sizeof(string("飞雪无情")))
fmt.Println(unsafe.Sizeof([]string{"飞雪u无情","张三"}))
对于整型来说,占用的字节数意味着这个类型存储数字范围的大小,比如 int8 占用一个字节,也就是 8bit所以它可以存储的大小范围是 -128~~127也就是 2^(n-1) 到 2^(n-1)1。其中 n 表示 bitint8 表示 8bitint16 表示 16bit以此类推。
对于和平台有关的 int 类型,要看平台是 32 位还是 64 位,会取最大的。比如我自己测试以上输出,会发现 int 和 int64 的大小是一样的,因为我用的是 64 位平台的电脑。
小提示:一个 struct 结构体的内存占用大小,等于它包含的字段类型内存占用大小之和。
总结
unsafe 包里最常用的就是 Pointer 指针,通过它可以让你在 *T、uintptr 及 Pointer 三者间转换,从而实现自己的需求,比如零内存拷贝或通过 uintptr 进行指针运算,这些都可以提高程序效率。
unsafe 包里的功能虽然不安全,但的确很香,比如指针运算、类型转换等,都可以帮助我们提高性能。不过我还是建议尽可能地不使用,因为它可以绕开 Go 语言编译器的检查,可能会因为你的操作失误而出现问题。当然如果是需要提高性能的必要操作,还是可以使用,比如 []byte 转 string就可以通过 unsafe.Pointer 实现零内存拷贝,下节课我会详细讲解。
unsafe 包还有一个函数我这节课没有讲,它是 Alignof功能就是函数名字字面的意思比较简单你可以自己练习使用一下这也是这节课的思考题。记得来听下节课哦

View File

@ -0,0 +1,364 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 SliceHeaderslice 如何高效处理数据?
在[第 4 讲|集合类型:如何正确使用 array、slice 和 map]中,你已经学习了 slice切片并且知道如何使用。这节课我会详细介绍 slice 的原理,带你学习它的底层设计。
数组
在讲 slice 的原理之前我先来介绍一下数组。几乎所有的编程语言里都存在数组Go 也不例外。那么为什么 Go 语言除了数组之外又设计了 slice 呢?要想解答这个问题,我们先来了解数组的局限性。
在下面的示例中a1、a2 是两个定义好的数组,但是它们的类型不一样。变量 a1 的类型是 [1]string变量 a2 的类型是 [2]string也就是说数组的大小属于数组类型的一部分只有数组内部元素类型和大小一致时这两个数组才是同一类型。
a1:=[1]string{"飞雪无情"}
a2:=[2]string{"飞雪无情"}
可以总结为,一个数组由两部分构成:数组的大小和数组内的元素类型。
//数组结构伪代码表示
array{
len
item type
}
比如变量 a1 的大小是 1内部元素的类型是 string也就是说 a1 最多只能存储 1 个类型为 string 的元素。而 a2 的大小是 2内部元素的类型也是 string所以 a2 最多可以存储 2 个类型为 string 的元素。一旦一个数组被声明,它的大小和内部元素的类型就不能改变,你不能随意地向数组添加任意多个元素。这是数组的第一个限制。
既然数组的大小是固定的,如果需要使用数组存储大量的数据,就需要提前指定一个合适的大小,比如 10 万,代码如下所示:
a10:=[100000]string{"飞雪无情"}
这样虽然可以解决问题,但又带来了另外的问题,那就是内存占用。因为在 Go 语言中,函数间的传参是值传递的,数组作为参数在各个函数之间被传递的时候,同样的内容就会被一遍遍地复制,这就会造成大量的内存浪费,这是数组的第二个限制。
虽然数组有限制,但是它是 Go 非常重要的底层数据结构,比如 slice 切片的底层数据就存储在数组中。
slice 切片
你已经知道数组虽然也不错但是在操作上有不少限制为了解决这些限制Go 语言创造了 slice也就是切片。切片是对数组的抽象和封装它的底层是一个数组存储所有的元素但是它可以动态地添加元素容量不足时还可以自动扩容你完全可以把切片理解为动态数组。在 Go 语言中,除了明确需要指定长度大小的类型需要数组来完成,大多数情况下都是使用切片的。
动态扩容
通过内置的 append 方法,你可以向一个切片中追加任意多个元素,所以这就可以解决数组的第一个限制。
在下面的示例中,我通过内置的 append 函数为切片 ss 添加了两个字符串,然后返回一个新的切片赋值给 ss。
func main() {
ss:=[]string{"飞雪无情","张三"}
ss=append(ss,"李四","王五")
fmt.Println(ss)
}
现在运行这段代码,会看到如下打印结果:
[飞雪无情 张三 李四 王五]
当通过 append 追加元素时如果切片的容量不够append 函数会自动扩容。比如上面的例子,我打印出使用 append 前后的切片长度和容量,代码如下:
func main() {
ss:=[]string{"飞雪无情","张三"}
fmt.Println("切片ss长度为",len(ss),",容量为",cap(ss))
ss=append(ss,"李四","王五")
fmt.Println("切片ss长度为",len(ss),",容量为",cap(ss))
fmt.Println(ss)
}
其中,我通过内置的 len 函数获取切片的长度,通过 cap 函数获取切片的容量。运行这段代码,可以看到打印结果如下:
切片ss长度为 2 ,容量为 2
切片ss长度为 4 ,容量为 4
[飞雪无情 张三 李四 王五]
在调用 append 之前,容量是 2调用之后容量是 4说明自动扩容了。
小提示append 自动扩容的原理是新创建一个底层数组,把原来切片内的元素拷贝到新数组中,然后再返回一个指向新数组的切片。
数据结构
在 Go 语言中,切片其实是一个结构体,它的定义如下所示:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
SliceHeader 是切片在运行时的表现形式,它有三个字段 Data、Len 和 Cap。
Data 用来指向存储切片元素的数组。
Len 代表切片的长度。
Cap 代表切片的容量。
通过这三个字段,就可以把一个数组抽象成一个切片,便于更好的操作,所以不同切片对应的底层 Data 指向的可能是同一个数组。现在通过一个示例来证明,代码如下:
func main() {
a1:=[2]string{"飞雪无情","张三"}
s1:=a1[0:1]
s2:=a1[:]
//打印出s1和s2的Data值是一样的
fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data)
fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s2)).Data)
}
用上节课学习的 unsafe.Pointer 把它们转换为 *reflect.SliceHeader 指针,就可以打印出 Data 的值,打印结果如下所示:
824634150744
824634150744
你会发现它们是一样的,也就是这两个切片共用一个数组,所以我们在切片赋值、重新进行切片操作时,使用的还是同一个数组,没有复制原来的元素。这样可以减少内存的占用,提高效率。
注意:多个切片共用一个底层数组虽然可以减少内存占用,但是如果有一个切片修改内部的元素,其他切片也会受影响。所以在切片作为参数在函数间传递的时候要小心,尽可能不要修改原切片内的元素。
切片的本质是 SliceHeader又因为函数的参数是值传递所以传递的是 SliceHeader 的副本,而不是底层数组的副本。这时候切片的优势就体现出来了,因为 SliceHeader 的副本内存占用非常少,即使是一个非常大的切片(底层数组有很多元素),也顶多占用 24 个字节的内存,这就解决了大数组在传参时内存浪费的问题。
小提示SliceHeader 三个字段的类型分别是 uintptr、int 和 int在 64 位的机器上,这三个字段最多也就是 int64 类型,一个 int64 占 8 个字节,三个 int64 占 24 个字节内存。
要获取切片数据结构的三个字段的值,也可以不使用 SliceHeader而是完全自定义一个结构体只要字段和 SliceHeader 一样就可以了。
比如在下面的示例中,通过 unsfe.Pointer 转换成自定义的 *slice 指针,同样可以获取三个字段对应的值,你甚至可以把字段的名称改为 d、l 和 c也可以达到目的。
sh1:=(*slice)(unsafe.Pointer(&s1))
fmt.Println(sh1.Data,sh1.Len,sh1.Cap)
type slice struct {
Data uintptr
Len int
Cap int
}
小提示:我们还是尽可能地用 SliceHeader因为这是 Go 语言提供的标准,可以保持统一,便于理解。
高效的原因
如果从集合类型的角度考虑,数组、切片和 map 都是集合类型,因为它们都可以存放元素,但是数组和切片的取值和赋值操作要更高效,因为它们是连续的内存操作,通过索引就可以快速地找到元素存储的地址。
小提示:当然 map 的价值也非常大,因为它的 Key 可以是很多类型,比如 int、int64、string 等,但是数组和切片的索引只能是整数。
进一步对比,在数组和切片中,切片又是高效的,因为它在赋值、函数传参的时候,并不会把所有的元素都复制一遍,而只是复制 SliceHeader 的三个字段就可以了,共用的还是同一个底层数组。
在下面的示例中,我定义了两个函数 arrayF 和 sliceF分别打印传入的数组和切片底层对应的数组指针。
func main() {
a1:=[2]string{"飞雪无情","张三"}
fmt.Printf("函数main数组指针%p\n",&a1)
arrayF(a1)
s1:=a1[0:1]
fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data)
sliceF(s1)
}
func arrayF(a [2]string){
fmt.Printf("函数arrayF数组指针%p\n",&a)
}
func sliceF(s []string){
fmt.Printf("函数sliceF Data%d\n",(*reflect.SliceHeader)(unsafe.Pointer(&s)).Data)
}
然后我在 main 函数里调用它们,运行程序会打印如下结果:
函数main数组指针0xc0000a6020
函数arrayF数组指针0xc0000a6040
824634400800
函数sliceF Data824634400800
你会发现,同一个数组在 main 函数中的指针和在 arrayF 函数中的指针是不一样的,这说明数组在传参的时候被复制了,又产生了一个新数组。而 slice 切片的底层 Data 是一样的,这说明不管是在 main 函数还是 sliceF 函数中,这两个切片共用的还是同一个底层数组,底层数组并没有被复制。
小提示:切片的高效还体现在 for range 循环中,因为循环得到的临时变量也是个值拷贝,所以在遍历大的数组时,切片的效率更高。
切片基于指针的封装是它效率高的根本原因,因为可以减少内存的占用,以及减少内存复制时的时间消耗。
string 和 []byte 互转
下面我通过 string 和 []byte 相互强制转换的例子,进一步帮你理解 slice 高效的原因。
比如我把一个 []byte 转为一个 string 字符串,然后再转换回来,示例代码如下:
s:="飞雪无情"
b:=[]byte(s)
s3:=string(b)
fmt.Println(s,string(b),s3)
在这个示例中,变量 s 是一个 string 字符串,它可以通过 []byte(s) 被强制转换为 []byte 类型的变量 b又可以通过 string(b) 强制转换为 string 类型的变量 s3。打印它们三个变量的值都是
“飞雪无情”。
Go 语言通过先分配一个内存再复制内容的方式,实现 string 和 []byte 之间的强制转换。现在我通过 string 和 []byte 指向的真实内容的内存地址,来验证强制转换是采用重新分配内存的方式。如下面的代码所示:
s:="飞雪无情"
fmt.Printf("s的内存地址%d\n", (*reflect.StringHeader)(unsafe.Pointer(&s)).Data)
b:=[]byte(s)
fmt.Printf("b的内存地址%d\n",(*reflect.SliceHeader)(unsafe.Pointer(&b)).Data)
s3:=string(b)
fmt.Printf("s3的内存地址%d\n", (*reflect.StringHeader)(unsafe.Pointer(&s3)).Data)
运行它们,你会发现打印出的内存地址都不一样,这说明虽然内容相同,但已经不是同一个字符串了,因为内存地址不同。
小提示:你可以通过查看 runtime.stringtoslicebyte 和 runtime.slicebytetostring 这两个函数的源代码,了解关于 string 和 []byte 类型互转的具体实现。
通过以上的示例代码,你已经知道了 SliceHeader 是什么。其实 StringHeader 和 SliceHeader 一样代表的是字符串在程序运行时的真实结构StringHeader 的定义如下所示:
// StringHeader is the runtime representation of a string.
type StringHeader struct {
Data uintptr
Len int
}
也就是说,在程序运行的时候,字符串和切片本质上就是 StringHeader 和 SliceHeader。这两个结构体都有一个 Data 字段,用于存放指向真实内容的指针。所以我们打印出 Data 这个字段的值,就可以判断 string 和 []byte 强制转换后是不是重新分配了内存。
现在你已经知道了 []byte(s) 和 string(b) 这种强制转换会重新拷贝一份字符串,如果字符串非常大,由于内存开销大,对于有高性能要求的程序来说,这种方式就无法满足了,需要进行性能优化。
如何优化呢?既然是因为内存分配导致内存开销大,那么优化的思路应该是在不重新申请内存的情况下实现类型转换。
仔细观察 StringHeader 和 SliceHeader 这两个结构体,会发现它们的前两个字段一模一样,那么 []byte 转 string就等于通过 unsafe.Pointer 把 *SliceHeader 转为 *StringHeader也就是 *[]byte 转 *string原理和我上面讲的把切片转换成一个自定义的 slice 结构体类似。
在下面的示例中s4 和 s3 的内容是一样的。不一样的是 s4 没有申请新内存(零拷贝),它和变量 b 使用的是同一块内存,因为它们的底层 Data 字段值相同,这样就节约了内存,也达到了 []byte 转 string 的目的。
s:="飞雪无情"
b:=[]byte(s)
//s3:=string(b)
s4:=*(*string)(unsafe.Pointer(&b))
SliceHeader 有 Data、Len、Cap 三个字段StringHeader 有 Data、Len 两个字段,所以 *SliceHeader 通过 unsafe.Pointer 转为 *StringHeader 的时候没有问题,因为 *SliceHeader 可以提供 *StringHeader 所需的 Data 和 Len 字段的值。但是反过来却不行了,因为 *StringHeader 缺少 *SliceHeader 所需的 Cap 字段,需要我们自己补上一个默认值。
在下面的示例中b1 和 b 的内容是一样的,不一样的是 b1 没有申请新内存,而是和变量 s 使用同一块内存,因为它们底层的 Data 字段相同,所以也节约了内存。
s:="飞雪无情"
//b:=[]byte(s)
sh:=(*reflect.SliceHeader)(unsafe.Pointer(&s))
sh.Cap = sh.Len
b1:=*(*[]byte)(unsafe.Pointer(sh))
注意:通过 unsafe.Pointer 把 string 转为 []byte 后,不能对 []byte 修改,比如不可以进行 b1[0]=12 这种操作,会报异常,导致程序崩溃。这是因为在 Go 语言中 string 内存是只读的。
通过 unsafe.Pointer 进行类型转换,避免内存拷贝提升性能的方法在 Go 语言标准库中也有使用,比如 strings.Builder 这个结构体,它内部有 buf 字段存储内容,在通过 String 方法把 []byte 类型的 buf 转为 string 的时候,就使用 unsafe.Pointer 提高了效率,代码如下:
// String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
string 和 []byte 的互转就是一个很好的利用 SliceHeader 结构体的示例,通过它可以实现零拷贝的类型转换,提升了效率,避免了内存浪费。
总结
通过 slice 切片的分析,相信你可以更深地感受 Go 的魅力,它把底层的指针、数组等进行封装,提供一个切片的概念给开发者,这样既可以方便使用、提高开发效率,又可以提高程序的性能。
Go 语言设计切片的思路非常有借鉴意义,你也可以使用 uintptr 或者 slice 类型的字段来提升性能,就像 Go 语言 SliceHeader 里的 Data uintptr 字段一样。
在这节课的最后,给你留一个思考题:你还可以找到哪些通过 unsafe.Pointer、uintptr 提升性能的例子呢?欢迎留言讨论。
下节课我们将进入工程管理模块首先学习“质量保证Go 语言如何通过测试保证质量?”记得来听课!

View File

@ -0,0 +1,434 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 质量保证Go 语言如何通过测试保证质量?
从这节课开始,我会带你学习本专栏的第四模块:工程管理。现在项目的开发都不是一个人可以完成的,需要多人进行协作,那么在多人协作中如何保证代码的质量,你写的代码如何被其他人使用,如何优化代码的性能等, 就是第四模块的内容。
这一讲首先来学习 Go 语言的单元测试和基准测试。
单元测试
在开发完一个功能后,你可能会直接把代码合并到代码库,用于上线或供其他人使用。但这样是不对的,因为你还没有对所写的代码进行测试。没有经过测试的代码逻辑可能会存在问题:如果强行合并到代码库,可能影响其他人的开发;如果强行上线,可能导致线上 Bug、影响用户使用。
什么是单元测试
顾名思义,单元测试强调的是对单元进行测试。在开发中,一个单元可以是一个函数、一个模块等。一般情况下,你要测试的单元应该是一个完整的最小单元,比如 Go 语言的函数。这样的话,当每个最小单元都被验证通过,那么整个模块、甚至整个程序就都可以被验证通过。
单元测试由开发者自己编写,也就是谁改动了代码,谁就要编写相应的单元测试代码以验证本次改动的正确性。
Go 语言的单元测试
虽然每种编程语言里单元测试的概念是一样的但它们对单元测试的设计不一样。Go 语言也有自己的单元测试规范,下面我会通过一个完整的示例为你讲解,这个例子就是经典的斐波那契数列。
斐波那契数列是一个经典的黄金分隔数列:它的第 0 项是 0第 1 项是 1从第 2 项开始每一项都等于前两项之和。所以它的数列是0、1、1、2、3、5、8、13、21……
说明:为了便于总结后面的函数方程式,我这里特意写的从第 0 项开始,其实现实中没有第 0 项。
根据以上规律,可以总结出它的函数方程式。
F(0)=0
F(1)=1
F(n)=F(n - 1)+F(n - 2)
有了函数方程式,再编写一个 Go 语言函数来计算斐波那契数列就比较简单了,代码如下:
ch18/main.go
func Fibonacci(n int) int {
if n < 0 {
return 0
}
if n == 0 {
return 0
}
if n == 1 {
return 1
}
return Fibonacci(n-1) + Fibonacci(n-2)
}
也就是通过递归的方式实现了斐波那契数列的计算
Fibonacci 函数已经编写好了可以供其他开发者使用不过在使用之前需要先对它进行单元测试你需要新建一个 go 文件用于存放单元测试代码刚刚编写的 Fibonacci 函数在*ch18/main.go*文件中那么对 Fibonacci 函数进行单元测试的代码需要放在*ch18/main_test.go***测试代码如下
ch18/main_test.go
func TestFibonacci(t *testing.T) {
//预先定义的一组斐波那契数列作为测试用例
fsMap := map[int]int{}
fsMap[0] = 0
fsMap[1] = 1
fsMap[2] = 1
fsMap[3] = 2
fsMap[4] = 3
fsMap[5] = 5
fsMap[6] = 8
fsMap[7] = 13
fsMap[8] = 21
fsMap[9] = 34
for k, v := range fsMap {
fib := Fibonacci(k)
if v == fib {
t.Logf("结果正确:n为%d,值为%d", k, fib)
} else {
t.Errorf("结果错误期望%d,但是计算的值是%d", v, fib)
}
}
}
在这个单元测试中我通过 map 预定义了一组测试用例然后通过 Fibonacci 函数计算结果同预定义的结果进行比较如果相等则说明 Fibonacci 函数计算正确不相等则说明计算错误
然后即可运行如下命令进行单元测试
go test -v ./ch18
这行命令会运行 ch18 目录下的所有单元测试因为我只写了一个单元测试所以可以看到结果如下所示
go test -v ./ch18
=== RUN TestFibonacci
main_test.go:21: 结果正确:n为0,值为0
main_test.go:21: 结果正确:n为1,值为1
main_test.go:21: 结果正确:n为6,值为8
main_test.go:21: 结果正确:n为8,值为21
main_test.go:21: 结果正确:n为9,值为34
main_test.go:21: 结果正确:n为2,值为1
main_test.go:21: 结果正确:n为3,值为2
main_test.go:21: 结果正确:n为4,值为3
main_test.go:21: 结果正确:n为5,值为5
main_test.go:21: 结果正确:n为7,值为13
--- PASS: TestFibonacci (0.00s)
PASS
ok gotour/ch18 (cached)
在打印的测试结果中你可以看到 PASS 标记说明单元测试通过而且还可以看到我在单元测试中写的日志
这就是一个完整的 Go 语言单元测试用例它是在 Go 语言提供的测试框架下完成的Go 语言测试框架可以让我们很容易地进行单元测试但是需要遵循五点规则
含有单元测试代码的 go 文件必须以 _test.go 结尾Go 语言测试工具只认符合这个规则的文件
单元测试文件名 _test.go 前面的部分最好是被测试的函数所在的 go 文件的文件名比如以上示例中单元测试文件叫 main_test.go因为测试的 Fibonacci 函数在 main.go 文件里
单元测试的函数名必须以 Test 开头是可导出的公开的函数
测试函数的签名必须接收一个指向 testing.T 类型的指针并且不能返回任何值
函数名最好是 Test + 要测试的函数名比如例子中是 TestFibonacci表示测试的是 Fibonacci 这个函数
遵循以上规则你就可以很容易地编写单元测试了单元测试的重点在于熟悉业务代码的逻辑场景等以便尽可能地全面测试保障代码质量
单元测试覆盖率
以上示例中的 Fibonacci 函数是否被全面地测试了呢这就需要用单元测试覆盖率进行检测了
Go 语言提供了非常方便的命令来查看单元测试覆盖率还是以 Fibonacci 函数的单元测试为例通过一行命令即可查看它的单元测试覆盖率
go test -v --coverprofile=ch18.cover ./ch18
这行命令包括 coverprofile 这个 Flag它可以得到一个单元测试覆盖率文件运行这行命令还可以同时看到测试覆盖率Fibonacci 函数的测试覆盖率如下
PASS
coverage: 85.7% of statements
ok gotour/ch18 0.367s coverage: 85.7% of statements
可以看到测试覆盖率为 85.7%从这个数字来看Fibonacci 函数应该没有被全面地测试这时候就需要查看详细的单元测试覆盖率报告了
运行如下命令可以得到一个 HTML 格式的单元测试覆盖率报告
go tool cover -html=ch18.cover -o=ch18.html
命令运行后会在当前目录下生成一个 ch18.html 文件使用浏览器打开它可以看到图中的内容
单元测试覆盖率报告
红色标记的部分是没有测试到的绿色标记的部分是已经测试到的这就是单元测试覆盖率报告的好处通过它你可以很容易地检测自己写的单元测试是否完全覆盖
根据报告我再修改一下单元测试把没有覆盖的代码逻辑覆盖到代码如下
fsMap[-1] = 0
也就是说由于图中 n 的部分显示为红色表示没有测试到所以我们需要再添加一组测试用例用于测试 n 的情况现在再运行这个单元测试查看它的单元测试覆盖率就会发现已经是 100%
基准测试
除了需要保证我们编写的代码的逻辑正确外有时候还有性能要求那么如何衡量代码的性能呢这就需要基准测试了
什么是基准测试
基准测试Benchmark是一项用于测量和评估软件性能指标的方法主要用于评估你写的代码的性能
Go 语言的基准测试
Go 语言的基准测试和单元测试规则基本一样只是测试函数的命名规则不一样现在还以 Fibonacci 函数为例演示 Go 语言基准测试的使用
Fibonacci 函数的基准测试代码如下
ch18/main_test.go
func BenchmarkFibonacci(b *testing.B){
for i:=0;i<b.N;i++{
Fibonacci(10)
}
}
这是一个非常简单的 Go 语言基准测试示例它和单元测试的不同点如下
基准测试函数必须以 Benchmark 开头必须是可导出的
函数的签名必须接收一个指向 testing.B 类型的指针并且不能返回任何值
最后的 for 循环很重要被测试的代码要放到循环里
b.N 是基准测试框架提供的表示循环的次数因为需要反复调用测试的代码才可以评估性能
写好了基准测试就可以通过如下命令来测试 Fibonacci 函数的性能
go test -bench=. ./ch18
goos: darwin
goarch: amd64
pkg: gotour/ch18
BenchmarkFibonacci-8 3461616 343 ns/op
PASS
ok gotour/ch18 2.230s
运行基准测试也要使用 go test 命令不过要加上 -bench 这个 Flag它接受一个表达式作为参数以匹配基准测试的函数.表示运行所有基准测试
下面着重解释输出的结果看到函数后面的 -8 了吗这个表示运行基准测试时对应的 GOMAXPROCS 的值接着的 3461616 表示运行 for 循环的次数也就是调用被测试代码的次数最后的 343 ns/op 表示每次需要花费 343 纳秒
基准测试的时间默认是 1 也就是 1 秒调用 3461616 每次调用花费 343 纳秒如果想让测试运行的时间更长可以通过 -benchtime 指定比如 3 代码如下所示
go test -bench=. -benchtime=3s ./ch18
计时方法
进行基准测试之前会做一些准备比如构建测试数据等这些准备也需要消耗时间所以需要把这部分时间排除在外这就需要通过 ResetTimer 方法重置计时器示例代码如下
func BenchmarkFibonacci(b *testing.B) {
n := 10
b.ResetTimer() //重置计时器
for i := 0; i < b.N; i++ {
Fibonacci(n)
}
}
这样可以避免因为准备数据耗时造成的干扰
除了 ResetTimer 方法外还有 StartTimer StopTimer 方法帮你灵活地控制什么时候开始计时什么时候停止计时
内存统计
在基准测试时还可以统计每次操作分配内存的次数以及每次操作分配的字节数这两个指标可以作为优化代码的参考要开启内存统计也比较简单代码如下即通过 ReportAllocs() 方法
func BenchmarkFibonacci(b *testing.B) {
n := 10
b.ReportAllocs() //开启内存统计
b.ResetTimer() //重置计时器
for i := 0; i < b.N; i++ {
Fibonacci(n)
}
}
现在再运行这个基准测试就可以看到如下结果
go test -bench=. ./ch18
goos: darwin
goarch: amd64
pkg: gotour/ch18
BenchmarkFibonacci-8 2486265 486 ns/op 0 B/op 0 allocs/op
PASS
ok gotour/ch18 2.533s
可以看到相比原来的基准测试多了两个指标分别是 0 B/op 0 allocs/op前者表示每次操作分配了多少字节的内存后者表示每次操作分配内存的次数这两个指标可以作为代码优化的参考尽可能地越小越好
小提示以上两个指标是否越小越好这是不一定的因为有时候代码实现需要空间换时间所以要根据自己的具体业务而定做到在满足业务的情况下越小越好
并发基准测试
除了普通的基准测试外Go 语言还支持并发基准测试你可以测试在多个 goroutine 并发下代码的性能还是以 Fibonacci 为例它的并发基准测试代码如下
func BenchmarkFibonacciRunParallel(b *testing.B) {
n := 10
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Fibonacci(n)
}
})
}
可以看到Go 语言通过 RunParallel 方法运行并发基准测试RunParallel 方法会创建多个 goroutine并将 b.N 分配给这些 goroutine 执行
基准测试实战
相信你已经理解了 Go 语言的基准测试也学会了如何使用现在我以一个实战帮你复习
还是以 Fibonacci 函数为例通过前面小节的基准测试会发现它并没有分配新的内存也就是说 Fibonacci 函数慢并不是因为内存排除掉这个原因就可以归结为所写的算法问题了
在递归运算中一定会有重复计算这是影响递归的主要因素解决重复计算可以使用缓存把已经计算好的结果保存起来就可以重复使用了
基于这个思路我将 Fibonacci 函数的代码进行如下修改
//缓存已经计算的结果
var cache = map[int]int{}
func Fibonacci(n int) int {
if v, ok := cache[n]; ok {
return v
}
result := 0
switch {
case n < 0:
result = 0
case n == 0:
result = 0
case n == 1:
result = 1
default:
result = Fibonacci(n-1) + Fibonacci(n-2)
}
cache[n] = result
return result
}
这组代码的核心在于采用一个 map 将已经计算好的结果缓存便于重新使用改造后我再来运行基准测试看看刚刚优化的效果如下所示
BenchmarkFibonacci-8 97823403 11.7 ns/op
可以看到结果为 11.7 纳秒相比优化前的 343 纳秒性能足足提高了 28
总结
单元测试是保证代码质量的好方法但单元测试也不是万能的使用它可以降低 Bug 但也不要完全依赖除了单元测试外还可以辅以 Code Review人工测试等手段更好地保证代码质量
在这节课的最后给你留个练习题在运行 go test 命令时使用 -benchmem 这个 Flag 进行内存统计
下一讲我将介绍性能优化Go 语言如何进行代码检查和优化记得来听课

View File

@ -0,0 +1,430 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 性能优化Go 语言如何进行代码检查和优化?
在上节课中,我为你留了一个小作业:在运行 go test 命令时,使用 -benchmem 这个 Flag 进行内存统计。该作业的答案比较简单,命令如下所示:
➜ go test -bench=. -benchmem ./ch18
运行这一命令就可以查看内存统计的结果了。这种通过 -benchmem 查看内存的方法适用于所有的基准测试用例。
今天要讲的内容是 Go 语言的代码检查和优化,下面我们开始本讲内容的讲解。
在项目开发中,保证代码质量和性能的手段不只有单元测试和基准测试,还有代码规范检查和性能优化。
代码规范检查是对单元测试的一种补充,它可以从非业务的层面检查你的代码是否还有优化的空间,比如变量是否被使用、是否是死代码等等。
性能优化是通过基准测试来衡量的,这样我们才知道优化部分是否真的提升了程序的性能。
代码规范检查
什么是代码规范检查
代码规范检查,顾名思义,是从 Go 语言层面出发,依据 Go 语言的规范,对你写的代码进行的静态扫描检查,这种检查和你的业务无关。
比如你定义了个常量,从未使用过,虽然对代码运行并没有造成什么影响,但是这个常量是可以删除的,代码如下所示:
ch19/main.go
const name = "飞雪无情"
func main() {
}
示例中的常量 name 其实并没有使用,所以为了节省内存你可以删除它,这种未使用常量的情况就可以通过代码规范检查检测出来。
再比如,你调用了一个函数,该函数返回了一个 error但是你并没有对该 error 做判断,这种情况下,程序也可以正常编译运行。但是代码写得不严谨,因为返回的 error 被我们忽略了。代码如下所示:
ch19/main.go
func main() {
os.Mkdir("tmp",0666)
}
示例代码中Mkdir 函数是有返回 error 的,但是你并没有对返回的 error 做判断,这种情况下,哪怕创建目录失败,你也不知道,因为错误被你忽略了。如果你使用代码规范检查,这类潜在的问题也会被检测出来。
以上两个例子可以帮你理解什么是代码规范检查、它有什么用。除了这两种情况,还有拼写问题、死代码、代码简化检测、命名中带下划线、冗余代码等,都可以使用代码规范检查检测出来。
golangci-lint
要想对代码进行检查,则需要对代码进行扫描,静态分析写的代码是否存在规范问题。
小提示:静态代码分析是不会运行代码的。
可用于 Go 语言代码分析的工具有很多,比如 golint、gofmt、misspell 等,如果一一引用配置,就会比较烦琐,所以通常我们不会单独地使用它们,而是使用 golangci-lint。
golangci-lint 是一个集成工具,它集成了很多静态代码分析工具,便于我们使用。通过配置这一工具,我们可以很灵活地启用需要的代码规范检查。
如果要使用 golangci-lint首先需要安装。因为 golangci-lint 本身就是 Go 语言编写的,所以我们可以从源代码安装它,打开终端,输入如下命令即可安装。
➜ go get github.com/golangci/golangci-lint/cmd/[email protected]
使用这一命令安装的是 v1.32.2 版本的 golangci-lint安装完成后在终端输入如下命令检测是否安装成功。
➜ golangci-lint version
golangci-lint has version v1.32.2
小提示:在 MacOS 下也可以使用 brew 来安装 golangci-lint。
好了,安装成功 golangci-lint 后,就可以使用它进行代码检查了,我以上面示例中的常量 name 和 Mkdir 函数为例,演示 golangci-lint 的使用。在终端输入如下命令回车:
➜ golangci-lint run ch19/
这一示例表示要检测目录中 ch19 下的代码,运行后可以看到如下输出结果。
ch19/main.go:5:7: `name` is unused (deadcode)
const name = "飞雪无情"
^
ch19/main.go:8:10: Error return value of `os.Mkdir` is not checked (errcheck)
os.Mkdir("tmp",0666)
通过代码检测结果可以看到,我上一小节提到的两个代码规范问题都被检测出来了。检测出问题后,你就可以修复它们,让代码更加符合规范。
golangci-lint 配置
golangci-lint 的配置比较灵活,比如你可以自定义要启用哪些 linter。golangci-lint 默认启用的 linter包括这些
deadcode - 死代码检查
errcheck - 返回错误是否使用检查
gosimple - 检查代码是否可以简化
govet - 代码可疑检查,比如格式化字符串和类型不一致
ineffassign - 检查是否有未使用的代码
staticcheck - 静态分析检查
structcheck - 查找未使用的结构体字段
typecheck - 类型检查
unused - 未使用代码检查
varcheck - 未使用的全局变量和常量检查
小提示golangci-lint 支持的更多 linter可以在终端中输入 golangci-lint linters 命令查看,并且可以看到每个 linter 的说明。
如果要修改默认启用的 linter就需要对 golangci-lint 进行配置。即在项目根目录下新建一个名字为 .golangci.yml 的文件,这就是 golangci-lint 的配置文件。在运行代码规范检查的时候golangci-lint 会自动使用它。假设我只启用 unused 检查,可以这样配置:
.golangci.yml
linters:
disable-all: true
enable:
- unused
在团队多人协作开发中,有一个固定的 golangci-lint 版本是非常重要的,这样大家就可以基于同样的标准检查代码。要配置 golangci-lint 使用的版本也比较简单,在配置文件中添加如下代码即可:
service:
golangci-lint-version: 1.32.2 # use the fixed version to not introduce new linters unexpectedly
此外,你还可以针对每个启用的 linter 进行配置,比如要设置拼写检测的语言为 US可以使用如下代码设置
linters-settings:
misspell:
locale: US
golangci-lint 的配置比较多,你自己可以灵活配置。关于 golangci-lint 的更多配置可以参考官方文档,这里我给出一个常用的配置,代码如下:
.golangci.yml
linters-settings:
golint:
min-confidence: 0
misspell:
locale: US
linters:
disable-all: true
enable:
- typecheck
- goimports
- misspell
- govet
- golint
- ineffassign
- gosimple
- deadcode
- structcheck
- unused
- errcheck
service:
golangci-lint-version: 1.32.2 # use the fixed version to not introduce new linters unexpectedly
集成 golangci-lint 到 CI
代码检查一定要集成到 CI 流程中效果才会更好这样开发者提交代码的时候CI 就会自动检查代码,及时发现问题并进行修正。
不管你是使用 Jenkins还是 Gitlab CI或者 Github Action都可以通过Makefile的方式运行 golangci-lint。现在我在项目根目录下创建一个 Makefile 文件,并添加如下代码:
Makefile
getdeps:
@mkdir -p ${GOPATH}/bin
@which golangci-lint 1>/dev/null || (echo "Installing golangci-lint" && go get github.com/golangci/golangci-lint/cmd/[email protected])
lint:
@echo "Running $@ check"
@GO111MODULE=on ${GOPATH}/bin/golangci-lint cache clean
@GO111MODULE=on ${GOPATH}/bin/golangci-lint run --timeout=5m --config ./.golangci.yml
verifiers: getdeps lint
小提示:关于 Makefile 的知识可以网上搜索学习一下,比较简单,这里不再进行讲述。
好了,现在你就可以把如下命令添加到你的 CI 中了,它可以帮你自动安装 golangci-lint并检查你的代码。
make verifiers
性能优化
性能优化的目的是让程序更好、更快地运行,但是它不是必要的,这一点一定要记住。所以在程序开始的时候,你不必刻意追求性能优化,先大胆地写你的代码就好了,写正确的代码是性能优化的前提。
堆分配还是栈
在比较古老的 C 语言中,内存分配是手动申请的,内存释放也需要手动完成。
手动控制有一个很大的好处就是你需要多少就申请多少,可以最大化地利用内存;
但是这种方式也有一个明显的缺点,就是如果忘记释放内存,就会导致内存泄漏。
所以为了让程序员更好地专注于业务代码的实现Go 语言增加了垃圾回收机制,自动地回收不再使用的内存。
Go 语言有两部分内存空间:栈内存和堆内存。
栈内存由编译器自动分配和释放,开发者无法控制。栈内存一般存储函数中的局部变量、参数等,函数创建的时候,这些内存会被自动创建;函数返回的时候,这些内存会被自动释放。
堆内存的生命周期比栈内存要长,如果函数返回的值还会在其他地方使用,那么这个值就会被编译器自动分配到堆上。堆内存相比栈内存来说,不能自动被编译器释放,只能通过垃圾回收器才能释放,所以栈内存效率会很高。
逃逸分析
既然栈内存的效率更高,肯定是优先使用栈内存。那么 Go 语言是如何判断一个变量应该分配到堆上还是栈上的呢?这就需要逃逸分析了。下面我通过一个示例来讲解逃逸分析,代码如下:
ch19/main.go
func newString() *string{
s:=new(string)
*s = "飞雪无情"
return s
}
在这个示例中:
通过 new 函数申请了一块内存;
然后把它赋值给了指针变量 s
最后通过 return 关键字返回。
小提示:以上 newString 函数是没有意义的,这里只是为了方便演示。
现在我通过逃逸分析来看下是否发生了逃逸,命令如下:
➜ go build -gcflags="-m -l" ./ch19/main.go
# command-line-arguments
ch19/main.go:16:8: new(string) escapes to heap
在这一命令中,-m 表示打印出逃逸分析信息,-l 表示禁止内联,可以更好地观察逃逸。从以上输出结果可以看到,发生了逃逸,也就是说指针作为函数返回值的时候,一定会发生逃逸。
逃逸到堆内存的变量不能马上被回收,只能通过垃圾回收标记清除,增加了垃圾回收的压力,所以要尽可能地避免逃逸,让变量分配在栈内存上,这样函数返回时就可以回收资源,提升效率。
下面我对 newString 函数进行了避免逃逸的优化,优化后的函数代码如下:
ch19/main.go
func newString() string{
s:=new(string)
*s = "飞雪无情"
return *s
}
再次通过命令查看以上代码的逃逸分析,命令如下:
➜ go build -gcflags="-m -l" ./ch19/main.go
# command-line-arguments
ch19/main.go:14:8: new(string) does not escape
通过分析结果可以看到,虽然还是声明了指针变量 s但是函数返回的并不是指针所以没有发生逃逸。
这就是关于指针作为函数返回逃逸的例子,那么是不是不使用指针就不会发生逃逸了呢?下面看个例子,代码如下:
fmt.Println("飞雪无情")
同样运行逃逸分析,你会看到如下结果:
➜ go build -gcflags="-m -l" ./ch19/main.go
# command-line-arguments
ch19/main.go:13:13: ... argument does not escape
ch19/main.go:13:14: "飞雪无情" escapes to heap
ch19/main.go:17:8: new(string) does not escape
观察这一结果,你会发现「飞雪无情」这个字符串逃逸到了堆上,这是因为「飞雪无情」这个字符串被已经逃逸的指针变量引用,所以它也跟着逃逸了,引用代码如下:
func (p *pp) printArg(arg interface{}, verb rune) {
p.arg = arg
//省略其他无关代码
}
所以被已经逃逸的指针引用的变量也会发生逃逸。
Go 语言中有 3 个比较特殊的类型,它们是 slice、map 和 chan被这三种类型引用的指针也会发生逃逸看个这样的例子
ch19/main.go
func main() {
m:=map[int]*string{}
s:="飞雪无情"
m[0] = &s
}
同样运行逃逸分析,你看到的结果是:
➜ gotour go build -gcflags="-m -l" ./ch19/main.go
# command-line-arguments
ch19/main.go:16:2: moved to heap: s
ch19/main.go:15:20: map[int]*string literal does not escape
从这一结果可以看到,变量 m 没有逃逸,反而被变量 m 引用的变量 s 逃逸到了堆上。所以被map、slice 和 chan 这三种类型引用的指针一定会发生逃逸的。
逃逸分析是判断变量是分配在堆上还是栈上的一种方法,在实际的项目中要尽可能避免逃逸,这样就不会被 GC 拖慢速度,从而提升效率。
小技巧:从逃逸分析来看,指针虽然可以减少内存的拷贝,但它同样会引起逃逸,所以要根据实际情况选择是否使用指针。
优化技巧
通过前面小节的介绍,相信你已经了解了栈内存和堆内存,以及变量什么时候会逃逸,那么在优化的时候思路就比较清晰了,因为都是基于以上原理进行的。下面我总结几个优化的小技巧:
第 1 个需要介绍的技巧是尽可能避免逃逸,因为栈内存效率更高,还不用 GC。比如小对象的传参array 要比 slice 效果好。
如果避免不了逃逸,还是在堆上分配了内存,那么对于频繁的内存申请操作,我们要学会重用内存,比如使用 sync.Pool这是第 2 个技巧。
第 3 个技巧就是选用合适的算法,达到高性能的目的,比如空间换时间。
小提示:性能优化的时候,要结合基准测试,来验证自己的优化是否有提升。
以上是基于 GO 语言的内存管理机制总结出的 3 个方向的技巧,基于这 3 个大方向基本上可以优化出你想要的效果。除此之外,还有一些小技巧,比如要尽可能避免使用锁、并发加锁的范围要尽可能小、使用 StringBuilder 做 string 和 [ ] byte 之间的转换、defer 嵌套不要太多等等。
最后推荐一个 Go 语言自带的性能剖析的工具 pprof通过它你可以查看 CPU 分析、内存分析、阻塞分析、互斥锁分析,它的使用不是太复杂,你可以搜索下它的使用教程,这里就不展开介绍。
总结
这节课主要介绍了代码规范检查和性能优化两部分内容,其中代码规范检查是从工具使用的角度讲解,而性能优化可能涉及的点太多,所以是从原理的角度讲解,你明白了原理,就能更好地优化你的代码。
我认为是否进行性能优化取决于两点:业务需求和自我驱动。所以不要刻意地去做性能优化,尤其是不要提前做,先保证代码正确并上线,然后再根据业务需要,决定是否进行优化以及花多少时间优化。自我驱动其实是一种编码能力的体现,比如有经验的开发者在编码的时候,潜意识地就避免了逃逸,减少了内存拷贝,在高并发的场景中设计了低延迟的架构。
最后给你留个作业,把 golangci-lint 引入自己的项目吧,相信你的付出会有回报的。
下一讲我将介绍“协作开发:模块化管理为什么能够提升研发效能”,记得来听课!

View File

@ -0,0 +1,258 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 协作开发:模块化管理为什么能够提升研发效能?
任何业务,都是从简单向复杂演进的。而在业务演进的过程中,技术是从单体向多模块、多服务演进的。技术的这种演进方式的核心目的是复用代码、提高效率,这一讲,我会为你介绍 Go 语言是如何通过模块化的管理,提升开发效率的。
Go 语言中的包
什么是包
在业务非常简单的时候,你甚至可以把代码写到一个 Go 文件中。但随着业务逐渐复杂,你会发现,如果代码都放在一个 Go 文件中,会变得难以维护,这时候你就需要抽取代码,把相同业务的代码放在一个目录中。在 Go 语言中,这个目录叫作包。
在 Go 语言中一个包是通过package 关键字定义的最常见的就是main 包,它的定义如下所示:
package main
此外,前面章节演示示例经常使用到的 fmt 包,也是通过 package 关键字声明的。
一个包就是一个独立的空间,你可以在这个包里定义函数、结构体等。这时,我们认为这些函数、结构体是属于这个包的。
使用包
如果你想使用一个包里的函数或者结构体,就需要先导入这个包,才能使用,比如常用的 fmt包代码示例如下所示。
package main
import "fmt"
func main() {
fmt.Println("先导入fmt包才能使用")
}
要导入一个包,需要使用 import 关键字;如果需要同时导入多个包,则可以使用小括号,示例代码如下所示。
import (
"fmt"
"os"
)
从以上示例可以看到,该示例导入了 fmt 和 os 这两个包,使用了小括号,每一行写了一个要导入的包。
作用域
讲到了包之间的导入和使用,就不得不提作用域这个概念,因为只有满足作用域的函数才可以被调用。
在Java 语言中,通过 public、private 这些修饰符修饰一个类的作用域;
但是在Go 语言中,并没有这样的作用域修饰符,它是通过首字母是否大写来区分的,这同时也体现了 Go 语言的简洁。
如上述示例中 fmt 包中的Println 函数:
它的首字母就是大写的 P所以该函数才可以在 main 包中使用;
如果 Println 函数的首字母是小写的 p那么它只能在 fmt 包中被使用,不能跨包使用。
这里我为你总结下 Go 语言的作用域:
Go 语言中,所有的定义,比如函数、变量、结构体等,如果首字母是大写,那么就可以被其他包使用;
反之,如果首字母是小写的,就只能在同一个包内使用。
自定义包
你也可以自定义自己的包,通过包的方式把相同业务、相同职责的代码放在一起。比如你有一个 util 包,用于存放一些常用的工具函数,项目结构如下所示:
ch20
├── main.go
└── util
└── string.go
在 Go 语言中,一个包对应一个文件夹,上面的项目结构示例也验证了这一点。在这个示例中,有一个 util 文件夹,它里面有一个 string.go 文件,这个 Go 语言文件就属于 util 包,它的包定义如下所示:
ch20/util/string.go
package util
可以看到Go 语言中的包是代码的一种组织形式,通过包把相同业务或者相同职责的代码放在一起。通过包对代码进行归类,便于代码维护以及被其他包调用,提高团队协作效率。
init 函数
除了 main 这个特殊的函数外Go 语言还有一个特殊的函数——init通过它可以实现包级别的一些初始化操作。
init 函数没有返回值,也没有参数,它先于 main 函数执行,代码如下所示:
func init() {
fmt.Println("init in main.go ")
}
一个包中可以有多个 init 函数,但是它们的执行顺序并不确定,所以如果你定义了多个 init 函数的话,要确保它们是相互独立的,一定不要有顺序上的依赖。
那么 init 函数作用是什么呢? 其实就是在导入一个包时,可以对这个包做一些必要的初始化操作,比如数据库连接和一些数据的检查,确保我们可以正确地使用这个包。
Go 语言中的模块
如果包是比较低级的代码组织形式的话,那么模块就是更高级别的,在 Go 语言中,一个模块可以包含很多个包,所以模块是相关的包的集合。
在 Go 语言中:
一个模块通常是一个项目,比如这个专栏实例中使用的 gotour 项目;
也可以是一个框架,比如常用的 Web 框架 gin。
go mod
Go 语言为我们提供了 go mod 命令来创建一个模块(项目),比如要创建一个 gotour 模块,你可以通过如下命令实现:
➜ go mod init gotour
go: creating new go.mod: module gotour
运行这一命令后,你会看到已经创建好一个名字为 gotour 的文件夹,里面有一个 go.mod 文件,它里面的内容如下所示:
module gotour
go 1.15
第一句是该项目的模块名,也就是 gotour
第二句表示要编译该模块至少需要Go 1.15 版本的 SDK。
小提示:模块名最好是以自己的域名开头,比如 flysnow.org/gotour这样就可以很大程度上保证模块名的唯一不至于和其他模块重名。
使用第三方模块
模块化为什么可以提高开发效率最重要的原因就是复用了现有的模块Go 语言也不例外。比如你可以把项目中的公共代码抽取为一个模块,这样就可以供其他项目使用,不用再重复开发;同理,在 Github 上也有很多开源的 Go 语言项目,它们都是一个个独立的模块,也可以被我们直接使用,提高我们的开发效率,比如 Web 框架 gin-gonic/gin。
众所周知,在使用第三方模块之前,需要先设置下 Go 代理,也就是 GOPROXY这样我们就可以获取到第三方模块了。
在这里我推荐 goproxy.io 这个代理,非常好用,速度也很快。要使用这个代理,需要进行如下代码设置:
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.io,direct
打开终端,输入这一命令回车即可设置成功。
在实际的项目开发中,除了第三方模块外,还有我们自己开发的模块,放在了公司的 GitLab上这时候就要把公司 Git 代码库的域名排除在 Go PROXY 之外,为此 Go 语言提供了GOPRIVATE 这个环境变量帮助我们达到目的。通过如下命令即可设置 GOPRIVATE
# 设置不走 proxy 的私有仓库,多个用逗号相隔(可选)
go env -w GOPRIVATE=*.corp.example.com
以上域名只是一个示例,实际使用时你要改成自己公司私有仓库的域名。
一切都准备好就可以使用第三方的模块了,假设我们要使用 Gin 这个 Web 框架,首先需要安装它,通过如下命令即可安装 Gin 这个 Web 框架:
go get -u github.com/gin-gonic/gin
安装成功后,就可以像 Go 语言的标准包一样,通过 import 命令导入你的代码中使用它,代码如下所示:
package main
import (
"fmt"
"github.com/gin-gonic/gin"
)
func main() {
fmt.Println("先导入fmt包才能使用")
r := gin.Default()
r.Run()
}
以上代码现在还无法编译通过,因为还没有同步 Gin 这个模块的依赖也就是没有把它添加到go.mod 文件中。通过如下命令可以添加缺失的模块:
go mod tidy
运行这一命令,就可以把缺失的模块添加进来,同时它也可以移除不再需要的模块。这时你再查看 go.mod 文件,会发现内容已经变成了这样:
module gotour
go 1.15
require (
github.com/gin-gonic/gin v1.6.3
github.com/golang/protobuf v1.4.2 // indirect
github.com/google/go-cmp v0.5.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/stretchr/testify v1.6.1 // indirect
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
)
所以我们不用手动去修改 go.mod 文件,通过 Go 语言的工具链比如 go mod tidy 命令,就可以帮助我们自动地维护、自动地添加或者修改 go.mod 的内容。
总结
在 Go 语言中,包是同一目录中,编译在一起的源文件的集合。包里面含有函数、类型、变量和常量,不同包之间的调用,必须要首字母大写才可以。
而模块又是相关的包的集合,它里面包含了很多为了实现该模块的包,并且还可以通过模块的方式,把已经完成的模块提供给其他项目(模块)使用,达到了代码复用、研发效率提高的目的。
所以对于你的项目(模块)来说,它具有模块 ➡ 包 ➡ 函数类型这样三层结构,同一个模块中,可以通过包组织代码,达到代码复用的目的;在不同模块中,就需要通过模块的引入,达到这个目的。
编程界有个谚语:不要重复造轮子,使用现成的轮子,可以提高开发效率,降低 Bug 率。Go 语言提供的模块、包这些能力,就可以很好地让我们使用现有的轮子,在多人协作开发中,更好地提高工作效率。
最后,为你留个作业:基于模块化拆分你所做的项目,提取一些公共的模块,以供更多项目使用。相信这样你们的开发效率会大大提升的。

View File

@ -0,0 +1,454 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 网络编程Go 语言如何玩转 RESTful API 服务?
从这一讲开始,我将带你学习本专栏的第五模块,在这个模块中,你将学到我们项目中最常用的编码操作,也就是编写 RESTful API 和 RPC 服务。在实际开发项目中,你编写的这些服务可以被其他服务使用,这样就组成了微服务的架构;也可以被前端调用,这样就可以前后端分离。
今天我就先来为你介绍什么是 RESTful API以及 Go 语言是如何玩转 RESTful API 的。
什么是 RESTful API
RESTful API 是一套规范,它可以规范我们如何对服务器上的资源进行操作。在了解 RESTful API 之前,我先为你介绍下 HTTP Method因为 RESTful API 和它是密不可分的。
说起 HTTP Method最常见的就是POST和GET其实最早在 HTTP 0.9 版本中只有一个GET方法该方法是一个幂等方法用于获取服务器上的资源也就是我们在浏览器中直接输入网址回车请求的方法。
在 HTTP 1.0 版本中又增加了HEAD和POST方法其中常用的是 POST 方法,一般用于给服务端提交一个资源,导致服务器的资源发生变化。
随着网络越来越复杂,发现这两个方法是不够用的,就继续新增了方法。所以在 HTTP1.1 版本的时候,一口气增加到了 9 个,新增的方法有 HEAD、OPTIONS、PUT、DELETE、TRACE、PATCH 和 CONNECT。下面我为你一一介绍它们的作用。
GET 方法可请求一个指定资源的表示形式,使用 GET 的请求应该只被用于获取数据。
HEAD 方法用于请求一个与 GET 请求的响应相同的响应,但没有响应体。
POST 方法用于将实体提交到指定的资源,通常导致服务器上的状态变化或副作用。
PUT 方法用于请求有效载荷替换目标资源的所有当前表示。
DELETE 方法用于删除指定的资源。
CONNECT 方法用于建立一个到由目标资源标识的服务器的隧道。
OPTIONS 方法用于描述目标资源的通信选项。
TRACE 方法用于沿着到目标资源的路径执行一个消息环回测试。
PATCH 方法用于对资源应用部分修改。
从以上每个方法的介绍可以看到HTTP 规范针对每个方法都给出了明确的定义,所以我们使用的时候也要尽可能地遵循这些定义,这样我们在开发中才可以更好地协作。
理解了这些 HTTP 方法,就可以更好地理解 RESTful API 规范了,因为 RESTful API 规范就是基于这些 HTTP 方法规范我们对服务器资源的操作,同时规范了 URL 的样式和 HTTP Status Code。
在 RESTful API 中,使用的主要是以下五种 HTTP 方法:
GET表示读取服务器上的资源
POST表示在服务器上创建资源
PUT表示更新或者替换服务器上的资源
DELETE表示删除服务器上的资源
PATCH表示更新 / 修改资源的一部分。
以上 HTTP 方法在 RESTful API 规范中是一个操作,操作的就是服务器的资源,服务器的资源通过特定的 URL 表示。
现在我们通过一些示例让你更好地理解 RESTful API如下所示
HTTP GET https://www.flysnow.org/users
HTTP GET https://www.flysnow.org/users/123
以上是两个 GET 方法的示例:
第一个表示获取所有用户的信息;
第二个表示获取 ID 为 123 用户的信息。
下面再看一个 POST 方法的示例,如下所示:
HTTP POST https://www.flysnow.org/users
这个示例表示创建一个用户,通过 POST 方法给服务器提供创建这个用户所需的全部信息。
注意:这里 users 是个复数。
现在你已经知道了如何创建一个用户,那么如果要更新某个特定的用户怎么做呢?其实也非常简单,示例代码如下所示:
HTTP PUT https://www.flysnow.org/users/123
这表示要更新 / 替换 ID 为 123 的这个用户,在更新的时候,会通过 PUT 方法提供更新这个用户需要的全部用户信息。这里 PUT 方法和 POST 方法不太一样的是,从 URL 上看PUT 方法操作的是单个资源,比如这里 ID 为 123 的用户。
小提示:如果要更新一个用户的部分信息,使用 PATCH 方法更恰当。
看到这里,相信你已经知道了如何删除一个用户,示例代码如下所示:
HTTP DELETE https://www.flysnow.org/users/123
DELETE 方法的使用和 PUT 方法一样,也是操作单个资源,这里是删除 ID 为 123 的这个用户。
一个简单的 RESTful API
相信你已经非常了解什么是 RESTful API 了,现在开始,我会带你通过一个使用 Golang 实现 RESTful API 风格的示例,加深 RESTful API 的理解。
Go 语言的一个很大的优势,就是可以很容易地开发出网络后台服务,而且性能快、效率高。在开发后端 HTTP 网络应用服务的时候,我们需要处理很多 HTTP 的请求访问比如常见的RESTful API 服务,就要处理很多 HTTP 请求然后把处理的信息返回给使用者。对于这类需求Golang 提供了内置的 net/http 包帮我们处理这些 HTTP 请求,让我们可以比较方便地开发一个 HTTP 服务。
下面我们来看一个简单的 HTTP 服务的 Go 语言实现,代码如下所示:
ch21/main.go
func main() {
http.HandleFunc("/users",handleUsers)
http.ListenAndServe(":8080", nil)
}
func handleUsers(w http.ResponseWriter, r *http.Request){
fmt.Fprintln(w,"ID:1,Name:张三")
fmt.Fprintln(w,"ID:2,Name:李四")
fmt.Fprintln(w,"ID:3,Name:王五")
}
这个示例运行后,你在浏览器中输入 http://localhost:8080/users, 就可以看到如下内容信息:
ID:1,Name:张三
ID:2,Name:李四
ID:3,Name:王五
也就是获取所有的用户信息,但是这并不是一个 RESTful API因为使用者不仅可以通过 HTTP GET 方法获得所有的用户信息,还可以通过 POST、DELETE、PUT 等 HTTP 方法获得所有的用户信息,这显然不符合 RESTful API 的规范。
现在我对以上示例进行修改,使它符合 RESTful API 的规范,修改后的示例代码如下所示:
ch20/main.go
func handleUsers(w http.ResponseWriter, r *http.Request){
switch r.Method {
case "GET":
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w,"ID:1,Name:张三")
fmt.Fprintln(w,"ID:2,Name:李四")
fmt.Fprintln(w,"ID:3,Name:王五")
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w,"not found")
}
}
这里我只修改了 handleUsers 函数,在该函数中增加了只在使用 GET 方法时,才获得所有用户的信息,其他情况返回 not found。
现在再运行这个示例,会发现只能通过 HTTP GET 方法进行访问了,使用其他方法会提示 not found。
RESTful JSON API
在项目中最常见的是使用 JSON 格式传输信息,也就是我们提供的 RESTful API 要返回 JSON 内容给使用者。
同样用上面的示例,我把它改造成可以返回 JSON 内容的方式,示例代码如下所示:
ch20/main.go
//数据源类似MySQL中的数据
var users = []User{
{ID: 1,Name: "张三"},
{ID: 2,Name: "李四"},
{ID: 3,Name: "王五"},
}
func handleUsers(w http.ResponseWriter, r *http.Request){
switch r.Method {
case "GET":
users,err:=json.Marshal(users)
if err!=nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w,"{\"message\": \""+err.Error()+"\"}")
}else {
w.WriteHeader(http.StatusOK)
w.Write(users)
}
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w,"{\"message\": \"not found\"}")
}
}
//用户
type User struct {
ID int
Name string
}
从以上代码可以看到,这次的改造主要是新建了一个 User 结构体,并且使用 users 这个切片存储所有的用户,然后在 handleUsers 函数中把它转化为一个 JSON 数组返回。这样,就实现了基于 JSON 数据格式的 RESTful API。
运行这个示例,在浏览器中输入 http://localhost:8080/users可以看到如下信息
[{"ID":1,"Name":"张三"},{"ID":2,"Name":"李四"},{"ID":3,"Name":"王五"}]
这已经是 JSON 格式的用户信息,包含了所有用户。
Gin 框架
虽然 Go 语言自带的 net/http 包,可以比较容易地创建 HTTP 服务,但是它也有很多不足:
不能单独地对请求方法POST、GET 等)注册特定的处理函数;
不支持 Path 变量参数;
不能自动对 Path 进行校准;
性能一般;
扩展性不足;
……
基于以上这些不足,出现了很多 Golang Web 框架,如 MuxGin、Fiber 等,今天我要为你介绍的就是这款使用最多的 Gin 框架。
引入 Gin 框架
Gin 框架是一个在 Github 上开源的 Web 框架,封装了很多 Web 开发需要的通用功能,并且性能也非常高,可以让我们很容易地写出 RESTful API。
Gin 框架其实是一个模块,也就是 Go Mod所以采用 Go Mod 的方法引入即可。我在第 18讲的时候详细介绍过如何引入第三方的模块这里再复习一下。
首先需要下载安装 Gin 框架,安装代码如下:
$ go get -u github.com/gin-gonic/gin
然后就可以在 Go 语言代码中导入使用了,导入代码如下:
import "github.com/gin-gonic/gin"
通过以上安装和导入这两个步骤,就可以在你的 Go 语言项目中使用 Gin 框架了。
使用 Gin 框架
现在,已经引入了 Gin 框架,下面我就是用 Gin 框架重写上面的示例,修改的代码如下所示:
ch21/main.go
func main() {
r:=gin.Default()
r.GET("/users", listUser)
r.Run(":8080")
}
func listUser(c *gin.Context) {
c.JSON(200,users)
}
相比 net/http 包Gin 框架的代码非常简单,通过它的 GET 方法就可以创建一个只处理 HTTP GET 方法的服务,而且输出 JSON 格式的数据也非常简单,使用 c.JSON 方法即可。
最后通过 Run 方法启动 HTTP 服务,监听在 8080 端口。现在运行这个 Gin 示例,在浏览器中输入 http://localhost:8080/users看到的信息和通过 net/http 包实现的效果是一样的。
获取特定的用户
现在你已经掌握了如何使用 Gin 框架创建一个简单的 RESTful API并且可以返回所有的用户信息那么如何获取特定用户的信息呢
我们知道,如果要获得特定用户的信息,需要使用的是 GET 方法,并且 URL 格式如下所示:
http://localhost:8080/users/2
以上示例中的 2 是用户的 ID也就是通过 ID 来获取特定的用户。
下面我通过 Gin 框架 Path 路径参数来实现这个功能,示例代码如下:
ch21/main.go
func main() {
//省略没有改动的代码
r.GET("/users/:id", getUser)
}
func getUser(c *gin.Context) {
id := c.Param("id")
var user User
found := false
//类似于数据库的SQL查询
for _, u := range users {
if strings.EqualFold(id, strconv.Itoa(u.ID)) {
user = u
found = true
break
}
}
if found {
c.JSON(200, user)
} else {
c.JSON(404, gin.H{
"message": "用户不存在",
})
}
}
在 Gin 框架中,路径中使用冒号表示 Path 路径参数,比如示例中的 :id然后在 getUser 函数中可以通过 c.Param(“id”) 获取需要查询用户的 ID 值。
小提示Param 方法的参数要和 Path 路径参数中的一致,比如示例中都是 ID。
现在运行这个示例,通过浏览器访问 http://localhost:8080/users/2就可以获得 ID 为 2 的用户,输出信息如下所示:
{"ID":2,"Name":"李四"}
可以看到,已经正确的获取到了 ID 为 2 的用户,他的名字叫李四。
假如我们访问一个不存在的 ID会得到什么结果呢比如 99示例如下所示
➜ curl http://localhost:8080/users/99
{"message":"用户不存在"}%
从以上示例输出可以看到,返回了『用户不存在』的信息,和我们代码中处理的逻辑一样。
新增一个用户
现在你已经可以使用 Gin 获取所有用户,还可以获取特定的用户,那么你也应该知道如何新增一个用户了,现在我通过 Gin 实现如何新增一个用户,看和你想的方案是否相似。
根据 RESTful API 规范,实现新增使用的是 POST 方法,并且 URL 的格式为 http://localhost:8080/users ,向这个 URL 发送数据,就可以新增一个用户,然后返回创建的用户信息。
现在我使用 Gin 框架实现新增一个用户,示例代码如下:
func main() {
//省略没有改动的代码
r.POST("/users", createUser)
}
func createUser(c *gin.Context) {
name := c.DefaultPostForm("name", "")
if name != "" {
u := User{ID: len(users) + 1, Name: name}
users = append(users, u)
c.JSON(http.StatusCreated,u)
} else {
c.JSON(http.StatusOK, gin.H{
"message": "请输入用户名称",
})
}
}
以上新增用户的主要逻辑是获取客户端上传的 name 值,然后生成一个 User 用户,最后把它存储到 users 集合中,达到新增用户的目的。
在这个示例中,使用 POST 方法来新增用户,所以只能通过 POST 方法才能新增用户成功。
现在运行这个示例,然后通过如下命令发送一个新增用户的请求,查看结果:
➜ curl -X POST -d 'name=飞雪' http://localhost:8080/users
{"ID":4,"Name":"飞雪"}
可以看到新增用户成功,并且返回了新增的用户,还有分配的 ID。
总结
Go 语言已经给我们提供了比较强大的 SDK让我们可以很容易地开发网络服务的应用而借助第三方的 Web 框架,可以让这件事情更容易、更高效。比如这篇文章介绍的 Gin 框架,就可以很容易让我们开发出 RESTful API更多关于 Gin 框架的使用可以参考 Golang Gin 实战系列文章。
在我们做项目开发的时候,要善于借助已经有的轮子,让自己的开发更有效率,也更容易实现。
在我们做项目开发的时候,会有增、删、改和查,现在增和查你已经学会了,那么就给你留 2 个作业,任选其中 1 个即可,它们是:
修改一个用户的名字;
删除一个用户。
下一讲,也就是本专栏的最后一讲,我将为你介绍如何使用 Go 语言实现 RPC 服务,记得来听课哦。

View File

@ -0,0 +1,587 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 网络编程Go 语言如何通过 RPC 实现跨平台服务?
在上一讲中,我为你讲解了 RESTful API 的规范以及实现,并且留了两个作业,它们分别是删除和修改用户,现在我为你讲解这两个作业。
删除一个用户比较简单,它的 API 格式和获取一个用户一样,但是 HTTP 方法换成了DELETE。删除一个用户的示例代码如下所示
ch21/main.go
func main() {
//省略没有修改的代码
r.DELETE("/users/:id", deleteUser)
}
func deleteUser(c *gin.Context) {
id := c.Param("id")
i := -1
//类似于数据库的SQL查询
for index, u := range users {
if strings.EqualFold(id, strconv.Itoa(u.ID)) {
i = index
break
}
}
if i >= 0 {
users = append(users[:i], users[i+1:]...)
c.JSON(http.StatusNoContent, "")
} else {
c.JSON(http.StatusNotFound, gin.H{
"message": "用户不存在",
})
}
}
这个示例的逻辑就是注册 DELETE 方法达到删除用户的目的。删除用户的逻辑是通过ID 查询:
如果可以找到要删除的用户,记录索引并跳出循环,然后根据索引删除该用户;
如果找不到要删除的用户,则返回 404。
实现了删除用户的逻辑后,相信你已经会修改一个用户的名字了,因为它和删除一个用户非常像,实现代码如下所示:
func main() {
//省略没有修改的代码
r.PATCH("/users/:id",updateUserName)
}
func updateUserName(c *gin.Context) {
id := c.Param("id")
i := -1
//类似于数据库的SQL查询
for index, u := range users {
if strings.EqualFold(id, strconv.Itoa(u.ID)) {
i = index
break
}
}
if i >= 0 {
users[i].Name = c.DefaultPostForm("name",users[i].Name)
c.JSON(http.StatusOK, users[i])
} else {
c.JSON(http.StatusNotFound, gin.H{
"message": "用户不存在",
})
}
}
整体代码逻辑和删除的差不多的,只不过这里使用的是 PATCH方法。
什么是RPC 服务
RPC也就是远程过程调用是分布式系统中不同节点调用的方式进程间通信属于 C/S 模式。RPC 由客户端发起,调用服务端的方法进行通信,然后服务端把结果返回给客户端。
RPC的核心有两个通信协议和序列化。在 HTTP 2 之前,一般采用自定义 TCP 协议的方式进行通信HTTP 2 出来后也有采用该协议的比如流行的gRPC。
序列化和反序列化是一种把传输内容编码和解码的方式,常见的编解码方式有 JSON、Protobuf 等。
在大多数 RPC的架构设计中都有Client、Client Stub、Server、Server Stub这四个组件Client 和 Server 之间通过 Socket 进行通信。RPC 架构如下图所示:
(图片来自于 Google 搜索)
下面我为你总结下 RPC 调用的流程:
客户端Client调用客户端存根Client Stub同时把参数传给客户端存根
客户端存根将参数打包编码,并通过系统调用发送到服务端;
客户端本地系统发送信息到服务器;
服务器系统将信息发送到服务端存根Server Stub
服务端存根解析信息,也就是解码;
服务端存根调用真正的服务端程序Sever
服务端Server处理后通过同样的方式把结果再返回给客户端Client
RPC 调用常用于大型项目,也就是我们现在常说的微服务,而且还会包含服务注册、治理、监控等功能,是一套完整的体系。
Go 语言 RPC 简单入门
RPC这么流行Go 语言当然不会错过,在 Go SDK 中,已经内置了 net/rpc 包来帮助开发者实现 RPC。简单来说net/rpc 包提供了通过网络访问服务端对象方法的能力。
现在我通过一个加法运算来演示 RPC的使用它的服务端代码如下所示
ch22/server/math_service.go
package server
type MathService struct {
}
type Args struct {
A, B int
}
func (m *MathService) Add(args Args, reply *int) error {
*reply = args.A + args.B
return nil
}
在以上代码中:
定义了MathService用于表示一个远程服务对象
Args 结构体用于表示参数;
Add 这个方法实现了加法的功能,加法的结果通过 replay这个指针变量返回。
有了这个定义好的服务对象就可以把它注册到暴露的服务列表中以供其他客户端使用了。在Go 语言中要注册一个一个RPC 服务对象还是比较简单的,通过 RegisterName 方法即可,示例代码如下所示:
ch22/server_main.go
package main
import (
"gotour/ch22/server"
"log"
"net"
"net/rpc"
)
func main() {
rpc.RegisterName("MathService",new(server.MathService))
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
rpc.Accept(l)
}
以上示例代码中,通过 RegisterName 函数注册了一个服务对象,该函数接收两个参数:
服务名称MathService
具体的服务对象也就是我刚刚定义好的MathService 这个结构体。
然后通过 net.Listen 函数建立一个TCP 链接,在 1234 端口进行监听,最后通过 rpc.Accept 函数在该 TCP 链接上提供 MathService 这个 RPC 服务。现在客户端就可以看到MathService这个服务以及它的Add 方法了。
任何一个框架都有自己的规则net/rpc 这个 Go 语言提供的RPC 框架也不例外。要想把一个对象注册为 RPC 服务,可以让客户端远程访问,那么该对象(类型)的方法必须满足如下条件:
方法的类型是可导出的(公开的);
方法本身也是可导出的;
方法必须有 2 个参数,并且参数类型是可导出或者内建的;
方法必须返回一个 error 类型。
总结下来,该方法的格式如下所示:
func (t *T) MethodName(argType T1, replyType *T2) error
这里面的 T1、T2都是可以被 encoding/gob 序列化的。
第一个参数 argType 是调用者(客户端)提供的;
第二个参数 replyType是返回给调用者结果必须是指针类型。
有了提供好的RPC 服务,现在再来看下客户端如何调用,它的代码如下所示:
ch22/client_main.go
package main
import (
"fmt"
"gotour/ch22/server"
"log"
"net/rpc"
)
func main() {
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
args := server.Args{A:7,B:8}
var reply int
err = client.Call("MathService.Add", args, &reply)
if err != nil {
log.Fatal("MathService.Add error:", err)
}
fmt.Printf("MathService.Add: %d+%d=%d", args.A, args.B, reply)
}
在以上实例代码中,首先通过 rpc.Dial 函数建立 TCP 链接,需要注意的是这里的 IP、端口要和RPC 服务提供的一致,确保可以建立 RCP 链接。
TCP 链接建立成功后就需要准备远程方法需要的参数也就是示例中的args 和 reply。参数准备好之后就可以通过 Call 方法调用远程的RPC 服务了。Call 方法有 3 个参数,它们的作用分别如下所示:
调用的远程方法的名字这里是MathService.Add点前面的部分是注册的服务的名称点后面的部分是该服务的方法
客户端为了调用远程方法提供的参数示例中是args
为了接收远程方法返回的结果,必须是一个指针,也就是示例中的& replay这样客户端就可以获得服务端返回的结果了。
服务端和客户端的代码都写好了,现在就可以运行它们,测试 RPC调用的效果了。
首先运行服务端的代码,提供 RPC 服务,运行命令如下所示:
➜ go run ch22/server_main.go
然后运行客户端代码,测试调用 RPC的结果运行命令如下所示
➜ go run ch22/client_main.go
如果你看到了 MathService.Add: 7+8=15的结果那么恭喜你你完成了一个完整的RPC 调用。
基于 HTTP的RPC
RPC 除了可以通过 TCP 协议调用之外还可以通过HTTP 协议进行调用而且内置的net/rpc 包已经支持,现在我修改以上示例代码,支持 HTTP 协议的调用,服务端代码如下所示:
ch22/server_main.go
func main() {
rpc.RegisterName("MathService", new(server.MathService))
rpc.HandleHTTP()//新增的
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
http.Serve(l, nil)//换成http的服务
}
以上是服务端代码的修改,只需修改两处,我已经在代码中标注出来了,很容易理解。
服务端修改的代码不算多,客户端修改的代码就更少了,只需要修改一处即可,修改的部分如下所示:
ch22/client_main.go
func main() {
client, err := rpc.DialHTTP("tcp", "localhost:1234")
//省略了其他没有修改的代码
}
从以上代码可以看到,只需要把建立链接的方法从 Dial 换成 DialHTTP 即可。
现在分别运行服务端和客户端代码就可以看到输出的结果了和上面使用TCP 链接时是一样的。
此外Go 语言 net/rpc 包提供的 HTTP 协议的 RPC 还有一个调试的 URL运行服务端代码后在浏览器中输入 http://localhost:1234/debug/rpc 回车即可看到服务端注册的RPC 服务,以及每个服务的方法,如下图所示:
如上图所示,注册的 RPC 服务、方法的签名、已经被调用的次数都可以看到。
JSON RPC 跨平台通信
以上我实现的RPC 服务是基于 gob 编码的这种编码在跨语言调用的时候比较困难而当前在微服务架构中RPC 服务的实现者和调用者都可能是不同的编程语言,因此我们实现的 RPC 服务要支持多语言的调用。
基于 TCP 的 JSON RPC
实现跨语言 RPC 服务的核心在于选择一个通用的编码这样大多数语言都支持比如常用的JSON。在 Go 语言中,实现一个 JSON RPC 服务非常简单,只需要使用 net/rpc/jsonrpc 包即可。
同样以上面的示例为例,我把它改造成支持 JSON的RPC 服务,服务端代码如下所示:
ch22/server_main.go
func main() {
rpc.RegisterName("MathService", new(server.MathService))
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
for {
conn, err := l.Accept()
if err != nil {
log.Println("jsonrpc.Serve: accept:", err.Error())
return
}
//json rpc
go jsonrpc.ServeConn(conn)
}
}
从以上代码可以看到,相比 gob 编码的RPC 服务JSON 的 RPC 服务是把链接交给了jsonrpc.ServeConn这个函数处理达到了基于 JSON 进行 RPC 调用的目的。
JSON RPC 的客户端代码也非常少,只需要修改一处,修改的部分如下所示:
ch22/client_main.go
func main() {
client, err := jsonrpc.Dial("tcp", "localhost:1234")
//省略了其他没有修改的代码
}
从以上代码可以看到,只需要把建立链接的 Dial方法换成 jsonrpc 包中的即可。
以上是使用 Go 语言作为客户端调用 RPC 服务的示例,其他编程语言也是类似的,只需要遵守 JSON-RPC 规范即可。
基于 HTTP的JSON RPC
相比基于 TCP 调用的RPC 来说,使用 HTTP肯定会更方便也更通用。Go 语言内置的jsonrpc 并没有实现基于 HTTP的传输所以就需要自己来实现这里我参考 gob 编码的HTTP RPC 实现方式,来实现基于 HTTP的JSON RPC 服务。
还是上面的示例,我改造下让其支持 HTTP 协议RPC 服务端代码如下所示:
ch22/server_main.go
func main() {
rpc.RegisterName("MathService", new(server.MathService))
//注册一个path用于提供基于http的json rpc服务
http.HandleFunc(rpc.DefaultRPCPath, func(rw http.ResponseWriter, r *http.Request) {
conn, _, err := rw.(http.Hijacker).Hijack()
if err != nil {
log.Print("rpc hijacking ", r.RemoteAddr, ": ", err.Error())
return
}
var connected = "200 Connected to JSON RPC"
io.WriteString(conn, "HTTP/1.0 "+connected+"\n\n")
jsonrpc.ServeConn(conn)
})
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
http.Serve(l, nil)//换成http的服务
}
以上代码的实现基于 HTTP 协议的核心,即使用 http.HandleFunc 注册了一个 path对外提供基于 HTTP 的 JSON RPC 服务。在这个 HTTP 服务的实现中通过Hijack方法劫持链接然后转交给 jsonrpc 处理,这样就实现了基于 HTTP 协议的 JSON RPC 服务。
实现了服务端的代码后,现在开始实现客户端调用,它的代码如下所示:
func main() {
client, err := DialHTTP("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
args := server.Args{A:7,B:8}
var reply int
err = client.Call("MathService.Add", args, &reply)
if err != nil {
log.Fatal("MathService.Add error:", err)
}
fmt.Printf("MathService.Add: %d+%d=%d", args.A, args.B, reply)
}
// DialHTTP connects to an HTTP RPC server at the specified network address
// listening on the default HTTP RPC path.
func DialHTTP(network, address string) (*rpc.Client, error) {
return DialHTTPPath(network, address, rpc.DefaultRPCPath)
}
// DialHTTPPath connects to an HTTP RPC server
// at the specified network address and path.
func DialHTTPPath(network, address, path string) (*rpc.Client, error) {
var err error
conn, err := net.Dial(network, address)
if err != nil {
return nil, err
}
io.WriteString(conn, "GET "+path+" HTTP/1.0\n\n")
// Require successful HTTP response
// before switching to RPC protocol.
resp, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: "GET"})
connected := "200 Connected to JSON RPC"
if err == nil && resp.Status == connected {
return jsonrpc.NewClient(conn), nil
}
if err == nil {
err = errors.New("unexpected HTTP response: " + resp.Status)
}
conn.Close()
return nil, &net.OpError{
Op: "dial-http",
Net: network + " " + address,
Addr: nil,
Err: err,
}
}
以上这段代码的核心在于通过建立好的TCP 链接,发送 HTTP 请求调用远程的HTTP JSON RPC 服务,这里使用的是 HTTP GET 方法。
分别运行服务端和客户端就可以看到正确的HTTP JSON RPC 调用结果了。
总结
这一讲基于 Go 语言自带的RPC 框架,讲解了 RPC 服务的实现以及调用。通过这一讲的学习相信你可以很好地了解什么是 RPC 服务,基于 TCP 和 HTTP 实现的RPC 服务有什么不同,它们是如何实现的等等。
不过在实际的项目开发中使用Go 语言自带的 RPC 框架并不多,但是这里我还是以自带的框架为例进行讲解,这样可以更好地理解 RPC 的使用以及实现原理。如果你可以很好地掌握它们,那么你使用第三方的 RPC 框架也可以很快上手。
在实际的项目中比较常用的是Google的gRPC 框架它是通过Protobuf 序列化的,是基于 HTTP/2 协议的二进制传输,并且支持很多编程语言,效率也比较高。关于 gRPC的使用可以看官网的文档入门是很容易的。

View File

@ -0,0 +1,63 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 结束语 你的 Go 语言成长之路
我们从 Go 语言的基础知识,到底层原理,再到实战,相信你已经学会了如何使用 Go 语言,并可以上手做项目了。这一路走来,非常感谢你对学习的坚持,以及对我的支持。
在本专栏的最后,我会和你聊下 Go 语言的前景,以及对于你学习 Go 语言编程和在今后职业发展方面,我的一些建议。
Go 语言的发展前景
随着这几年 Dokcer、K8s 的普及,云原生的概念也越来越火,而 Go 语言恰恰就是为云而生的编程语言,所以在云原生的时代,它就具备了天生的优势:易于学习、天然的并发、高效的网络支持、跨平台的二进制文件编译等。
CNCF云原生计算基金会对云原生的定义是
应用容器化;
面向微服务架构;
应用支持容器的编排调度。
我们可以看到,对于这三点有代表性的 Docker、K8s 以及 istio 都是采用 Go 语言编写的,所以 Go 语言在云原生中发挥了极大的优势。
在涉及网络通信、对象存储、协议等领域的工作中Go 语言所展现出的优势要比 Python、C /C++ 更大,所以诸如字节跳动、腾讯等很多大厂都在拥抱 Go 语言的开发,甚至很多公司在业务这一层也采用 Go 语言来开发微服务,从而提高开发和运行效率。
总体来说,对 Go 语言的前景我还是比较看好的,所以本专栏是你 Go 语言学习的敲门砖,接下来我建议你可以对这一语言进行更加系统和全面的学习。
Go 语言学习建议
关于 Go 语言的学习,我建议从官方文档和官方作者著作的书开始,这样你可以看到“原汁原味”的讲解。其实不只 Go 语言,任何一门语言都应该是这样,官方的内容是比较权威的。
基于官方文档入门后,你就可以参考一些第三方大牛写的相关书籍了。阅读不同人写的 Go 语言书籍,你可以融会贯通,更好地理解 Go 语言的知识点。比如在其他书上看不懂的内容,换一本你可能就看懂了。
阅读书籍还有一个好处是让你的学习具备系统性,而非零散的。现在大部分的我们都选择碎片化学习,其实通过碎片化的时间,系统地学习才是正确的方式。
不管是通过书籍、官网文档,还是视频、专栏的学习,我们都要结合示例进行练习,不能只用眼睛看,这样的学习效率很低,一定要将代码动手写出来,这样你对知识的理解程度和只看是完全不一样的,在这个过程中你可以通过写加深记忆、通过调试加深理解、通过结果验证你的知识。
有了这些基础后,就可以看一些实战类的书籍、文章和视频了,这样你不只是学会了 Go 语言,还能用 Go 语言做项目,了解如何编码、分库、微服务、自动化部署等。
不管是学习 Go 语言还是其他编程语言,都要阅读源代码,通过阅读源代码了解底层的实现原理,以及学习他人优秀的代码设计,进而提升自己在 Go 语言上的技术能力。
当然一个工程师“源于代码”,但不能“止于代码”。
不止于编程语言
无论你是想走技术专家路线,还是技术管理路线,要想更多地发挥自己的价值,必然是要带人的,因为一个人再怎么努力、技术如何厉害,也比不上多人团队的协作。
所以,当你工作 3 年具备骨干的能力后,就要开始尝试带人、做导师了,把自己学习编程的经验教给新人,让他们少走弯路,同时也能锻炼自己带人的能力,协调更多的人一起做事情。
这样当你有 5 年、7 年,甚至以上工作经验的时候,你的团队会越来越壮大,在团队中你所发挥的价值也越来越大;而在个人方面,你也可以做架构设计、技术难点攻关等更有价值的事情。
关于技术编程人员的成长,我有过一次分享。我把成长经历分为 9 个阶段,每一个阶段需要哪些技术,如何提升自己的段位,都有详细的介绍,你可以在《技术编程人员成长的 9 个段位》中查看。
总结
具备自我驱动力,以及学习能力的人,在职场中的竞争力都不会太差。
希望这个专栏可以很好地帮到你,让你学到 Go 语言的知识,让你在职场中更具备竞争力。