书名:Go语言高级编程(第2版)
ISBN:978-7-115-67324-4
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
著 柴树杉 曹春晖
绘 王 敏
责任编辑 杨海玲
人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
网址 http://www.ptpress.com.cn
读者服务热线:(010)81055410
反盗版热线:(010)81055315
本书从实践出发,全面讲解Go语言的高级编程技术和应用场景,涵盖Go语言的底层机制、性能优化、系统编程及前沿应用等多个方面。全书共10章,第1章回顾Go语言的发展历程,帮助读者理解其设计理念和演进过程;第2章和第3章系统介绍CGO编程与Go汇编语言的使用方法,使读者能够高效调用C库并深入理解Go的底层实现;第4章和第5章深入解析Go运行时和编译器,包括内存管理、调度器、垃圾收集机制等相关技术;第6章和第7章探讨Go在RPC和Web编程中的应用,介绍Protobuf、gRPC等核心技术,并剖析Web框架的设计和优化方案;第8章和第9章拓展Go的应用边界,涵盖WebAssembly和GUI编程,使Go语言不仅限于服务器端编程;第10章新增Go语言与大模型的结合,探索人工智能技术在Go生态系统中的应用场景。
本书适合对Go语言有一定基础,希望深入理解其底层机制和高级应用的开发者阅读。无论是系统工程师、后端开发者,还是对Go语言运行时、编译器及新兴技术感兴趣的Go程序员,都能在本书中找到翔实的技术解析和实践指南,达到高效开发和技术进阶的目的。
自Go语言问世以来,它凭借着简洁、高效和强大的特性,赢得了全球开发者的青睐。Go语言以其“少即是多”(less is more)的设计哲学,重新定义了现代编程语言的标准。在我多年的开发实践中,Go语言始终是我最为推崇的工具之一,因为它不仅易学易用,还能够在高并发、高性能的应用场景中表现出色。经过10多年的发展,Go语言已经证明了其在多个领域的广泛适用性,特别是在云计算、微服务架构及容器化技术的推动下,Go语言的应用场景愈加广泛。
这本书正是在这一背景下应运而生的。它不仅深入探讨了Go语言的核心技术和应用,尤其在性能优化、系统架构等方面做了详尽的讲解,同时也融入了Go语言近年来在WebAssembly和人工智能领域的最新应用和技术。这使得这本书在传统Go编程图书中脱颖而出,成为广大开发者提升技术深度和广度的重要工具。
对许多开发者而言,Go语言最大的魅力在于其简洁明了的语法和高效的并发模型。作为一种面向高性能系统设计的编程语言,Go语言通过goroutine和通道等核心特性,使得并发编程变得直观且易于管理。这使得Go在处理高并发的网络服务时,能够保持卓越的性能,成为开发高效服务系统的首选语言。
这本书的内容紧跟Go语言的新发展,对基础语法、复杂的并发模型、底层的内存管理、垃圾收集机制等都进行了深入剖析。书中不仅讲解了Go语言的常见应用模式,还通过大量实际案例帮助读者深入理解Go语言的高效运作方式。此外,书中的每一章都提供了丰富的代码示例,帮助开发者在实践中掌握关键技术。
特别值得一提的是,这本书在深入Go语言核心特性的同时,还专注于解决实际开发中遇到的复杂问题。例如,在介绍CGO和Go汇编语言时,作者结合了大量性能优化的案例,帮助开发者更好地理解如何通过底层技术实现系统性能的极致优化。这些内容对高性能计算、嵌入式系统及需要跨语言互操作的项目开发者尤为重要。
而这一版的亮点之一是其对新兴领域内容的拓展。例如,WebAssembly(简称WASM)作为近年来蓬勃发展的技术,已经在浏览器端和云端之间架起了一座桥梁。这本书详细讲解了Go语言如何与WebAssembly结合,为开发者提供了更多的可能性,特别是在前端开发和跨平台应用开发方面。
另外,人工智能和机器学习的快速发展也带来了新的技术挑战。这一版中深入探讨了Go语言在人工智能领域的应用,通过实例讲解了Go语言如何与现代人工智能框架结合,帮助开发者在人工智能相关项目中发挥Go语言的高效性能。
总的来说,这是一本兼具深度与广度的经典之作。无论是Go语言的初学者,还是有一定基础的开发者,都能从中汲取大量宝贵的知识。特别是对于那些希望深入理解Go语言底层原理、提升开发效率并进行性能优化的开发者,这本书无疑是一本不可多得的好书。它不仅帮助开发者理解Go语言的高级特性,还能够提升解决实际开发问题的能力,特别是在分布式系统、微服务架构及现代高并发应用开发方面。
作为一名从事高并发、高性能系统开发的开发者,我深知掌握一门语言的底层原理和高级特性对技术成长的重要性。这正是一本帮助开发者深入理解Go语言的优质教程,它不仅提供了丰富的技术细节,而且鼓励开发者从设计哲学的角度去思考和探索,最终掌握Go语言的核心精髓。
丁尔男,武汉航天远景科技股份有限公司副总裁、凹语言联合发起人
自2009年诞生到2025年,Go语言已经在多个领域取得显著成就,在全球收获众多开发者,并长期位居TIOBE编程语言排行榜前列。
这本书的作者柴树杉和曹春晖是国内最早的一批Gopher,他们最早将Go语言应用到真实产品项目中,最早对CGO、Go汇编语言等Go语言核心原理进行深入探索,最早向Go工具官方仓库贡献代码。他们为国内Gopher摸索出了一条从使用者到探索者再到贡献者的升级路径,鼓舞了更多中国Gopher向社区上游贡献代码。
除此之外,两位作者还是中国最大的Go语言社区Gopher China的讲师,在Gopher China大会上贡献了丰富的干货。另外,柴树杉还出版了其他数本畅销技术书,并创立了凹语言项目。
市面上关于Go语言的书很多,2019年出版的这本书的第1版是其中销量名列前茅且极具特色的一本。6年后两位作者再接再厉,推出了多角度更新的第2版,对CGO、Go汇编语言等传统内容进行了更新,还讲解了Go语言在近年来兴起的WebAssembly和AI领域的应用。
希望读者能从这本书中获得更多的知识和技能。
史斌,全球排名前100的Go语言代码贡献者、LLVM项目代码贡献者
Go语言在近10年的发展中已经奠定了非常稳固的基础,并进入了新的发展阶段。《Go语言高级编程》的第1版于2019年正式出版。当时,Go 1.11刚刚发布,Go的工作区与模块化工具、WebAssembly 支持等特性都是在那个时期出现的,为后续引入泛型、迭代器等特性铺平了道路。尽管这些变化为《Go语言高级编程》一书的版本升级提供了动力,但这并非唯一的理由。
我们写《Go语言高级编程》的初衷是希望覆盖一些入门教程中少有涉及的“冷知识”,如CGO、Go汇编语言、运行时和编译器等。但因当时个人能力和经验的局限,我们未能深入探讨运行时和编译器的相关内容。随着我们在使用Go语言和参与开发Go语言的过程中积累的经验越来越多,我们对Go语言的设计理念、运行时机制和编译器架构的思考和理解也越来越深入,因此,我们希望在这一版中弥补之前的不足,增加这些内容,同时增加对WebAssembly和大语言模型的讨论。
本书的内容结构如下。
● 第1章“Go语言基础”简要回顾Go语言的发展历程。
● 第2章“CGO编程”系统介绍CGO编程的使用方法。
● 第3章“Go汇编语言”系统介绍Go汇编语言的使用方法。
● 第4章“Go运行时”介绍与Go语言运行时相关的包及其应用。
● 第5章“Go编译器”探讨标准库中与编译器相关的话题。
● 第6章“RPC和Protobuf”深入讲解RPC和Protobuf技术,并展示如何构建一个自定义的RPC系统。
● 第7章“Go Web编程”讨论工业级Web系统的开发及其所用的技术。
● 第8章“Go和WebAssembly”探索Go语言与WebAssembly的结合及应用场景。
● 第9章“Go GUI编程”讨论Go语言开发GUI程序所使用的部分框架。
● 第10章“大模型”讨论Go语言与大模型相关的技术及应用。
作为Go语言爱好者与学习者,我们不敢妄称自己已经达到了多高的水平。尽管我们已经尽力而为,但仍难免存在不足之处,欢迎大家提出宝贵的改进意见。
首先,我们要感谢Go语言的创始人及每一位为Go语言贡献代码和提交补丁的开发者。感谢Fango(樊虹剑)撰写了第一部以Go语言为主题的网络小说《胡文Go.ogle》及第一本中文Go语言图书《Go语言·云动力》,正是您的分享激发了大家学习Go语言的热情。感谢韦光京完善了CGO对Windows系统的支持,否则本书可能不会有专门讲解CGO的章节。感谢所有为本书第1版提交过Issue或PR的朋友,尤其是Fuwensun、Lewgun等。特别感谢史斌在Go语言运行时和编译器方向的工作;感谢蒙卓将Go语言的plugin包引入RISC-V 64平台;感谢朱德江在CGO性能优化方向的工作;感谢崔爽对go vet工具的改进及对本书的细致审校。诸位的关注和支持始终是我们写作的最大动力。感谢王敏为本书创作很多精美的插画。最后,感谢人民邮电出版社杨海玲编辑,没有您的帮助和耐心指导,本书不可能顺利出版。再次感谢大家!
本章首先简要介绍Go语言的发展历史,并较详细地分析“Hello, World”程序在各个祖先语言中的演化过程;然后,对以数组、字符串和切片为代表的基础结构,以函数、方法和接口体现的面向过程编程和鸭子型的面向对象编程,以及Go语言特有的并发编程模型和错误处理哲学做简单介绍;最后,针对macOS、Windows、Linux几种主流的操作系统,推荐几种较友好的Go语言编辑器和集成开发环境,因为好的工具可以极大地提高我们的效率。
Go语言由谷歌公司的Robert Griesemer、Ken Thompson和Rob Pike这3位技术大咖于2007年开始设计,设计新语言的最初动力来自对超级复杂的C++ 11特性的吹捧报告的鄙视,最终的目标是设计网络和多核时代的C语言。2008年中,语言的大部分特性已经设计完成并开始着手实现编译器和运行时,此时Russ Cox作为主力开发者加入。到2009年,Go语言已经逐步趋于稳定。同年9月,Go语言正式发布并开源了代码。
Go语言很多时候被描述为“类C语言”或者“21世纪的C语言”。从各种角度看,Go语言确实从C语言继承了相似的表达式语法、控制流结构、基础数据类型、调用参数传值、指针等诸多编程思想,并彻底继承和发扬了C语言简单直接的暴力编程哲学等。图1-1展示了《Go语言程序设计》(The Go Programming Language,这本书在国内Go 语言社区中被誉为“Go语言圣经”,阅读这本书可以系统地学习Go语言)中给出的Go语言基因族谱,我们可以从中看到有哪些编程语言对Go语言产生了影响。
首先看基因族谱的左边一支。可以明确看出Go语言的并发特性是由CSP理论演化而来的。CSP理论是贝尔实验室的Hoare于1978年提出的。其后,CSP并发模型在Squeak/Newsqueak和Alef等编程语言中逐步完善并走向实际应用,最终这些设计经验被消化并吸收到了Go语言中。业界比较熟悉的Erlang编程语言的并发编程模型也是CSP理论的一种实现。
图1-1 Go语言基因族谱
接着看基因族谱的中间一支。这一支展现了Go语言中面向对象和包特性的演化历程。Go语言中包和接口及面向对象等特性继承自Niklaus Wirth所设计的Pascal语言及其后衍生的相关编程语言。其中包、包的导入和声明等语法主要来自Modula-2编程语言,面向对象特性所提供的方法的声明语法等则来自Oberon编程语言。最终Go语言演化出了自己特有的支持鸭子型的面向对象模型的隐式接口等诸多特性。
最后看基因族谱的右边一支。这一支是对C语言的致敬。Go语言是对C语言最彻底的一次扬弃,在语法上对C语言做了很多简化和改进,最重要的是舍弃了C语言中灵活但是危险的指针运算。Go语言还重新设计了C语言中部分不太合理的运算符优先级,并在很多细微的地方都做了必要的打磨和改变。当然,C语言中少即是多、简单直接的暴力编程哲学被Go语言更彻底地发扬光大了(Go语言居然只有25个关键字,语言规范还不到50页)。
Go语言的其他特性零散地来自其他一些编程语言,例如,iota语法是从APL语言借鉴的,词法作用域与嵌套函数等特性来自Scheme语言(和其他很多编程语言)。Go语言中也有很多自己发明创新的设计。例如Go语言的切片为轻量级动态数组提供了有效的随机存取的功能,这可能会让人联想到链表的底层共享机制。还有Go语言新发明的defer
语句(Ken发明)也是神来之笔。
作为Go语言标志性的并发编程特性来自贝尔实验室的Tony Hoare于1978年发表的鲜为外界所知的关于并发研究的基础文献:通信顺序进程(communicating sequential processes,CSP)。在最初的CSP论文中,程序只是一组没有中间共享状态的并发运行的处理过程,它们之间使用通道进行通信和控制同步。Tony Hoare的CSP并发模型只是一种用于描述并发性基本概念的描述语言,并不是编写可执行程序的通用编程语言。
CSP并发模型最经典的实际应用是爱立信公司发明的Erlang编程语言。不过,在Erlang将CSP理论作为并发编程模型的同时,同样来自贝尔实验室的Rob Pike及其同事也在不断尝试将CSP并发模型引入当时新发明的编程语言中。他们尝试在Newsqueak语言中支持CSP模式的并发编程。此后的Alef语言试图将C语言改造为系统编程语言(Alef也是C语言之父Ritchie比较喜爱的编程语言),但是因为缺少垃圾收集机制而导致并发编程很痛苦(这也是继承C语言手工管理内存的代价)。在Alef语言之后还有一款名为Limbo的编程语言,是运行在虚拟机中的脚本语言。Limbo语言是与Go语言最接近的祖先,它和Go语言有着最接近的语法。到设计Go语言时,Rob Pike在CSP并发编程模型的实践中已经积累了20多年的经验,关于Go语言并发编程的特性完全是信手拈来,新编程语言的到来也是水到渠成。
图1-2展示了Go语言代码库的早期开发日志(在Git中用git log --before={2008-03-03} --reverse
命令查看),由此可以看出Go语言最直接的演化历程。
图1-2 Go语言开发日志
从早期提交的日志中可以看出,Go语言是从Ken Thompson发明的B语言、Dennis M. Ritchie发明的C语言逐步演化过来的,它先是C语言家族的成员,因此很多人将Go语言称为21世纪的C语言。
图1-3给出的是Go语言中来自贝尔实验室特有并发编程基因的演化过程。
图1-3 Go语言并发演化历史
回顾贝尔实验室开发编程语言的进程,从B语言、C语言到Newsqueak、Alef、Limbo,一路走来,Go语言继承了来自贝尔实验室的近半个世纪的软件设计基因,终于完成了C语言革新的使命。纵观近几年的发展趋势,Go语言已经成为云计算、云存储时代甚至大语言模型时代最重要的基础编程语言。
按照惯例,介绍所有编程语言的第一个程序都是“Hello, World!”。虽然本书假设读者已经了解了Go语言,但是我们还是不想打破这个惯例(因为这个传统正是从Go语言的前辈C语言传承而来的)。下面的代码展示的Go语言程序输出的是中文“你好,世界!”。
package main import "fmt" func main() { fmt.Println("你好, 世界!") }
将这段代码保存到hello.go文件中。因为代码中有非ASCII的中文字符,我们需要将文件的编码显式指定为无BOM的UTF-8编码格式(源文件采用UTF-8编码是Go语言规范所要求的)。然后进入命令行并切换到hello.go文件所在的目录。此时,我们可以将Go语言当作脚本语言,在命令行中直接输入go run hello.go
来运行程序。如果一切正常的话,应该可以在命令行看到输出“你好, 世界!”的结果。
现在,让我们简单介绍一下程序。所有的Go程序都由最基本的函数和变量构成,函数和变量被组织到一个个单独的Go源文件中,这些源文件再按照作者的意图组织成合适的包(
package)
,最终这些包
有机地组成一个完整的Go语言程序。其中,函数用于包含一系列语句(指明要执行的操作序列),以及执行操作时存储数据变量。这个程序中函数的名字是main
。虽然Go语言对函数名没有太多限制,但是main
包中的main()
函数默认是每个可执行程序的入口。而包
则用于包装和组织相关的函数、变量和常量。在使用一个包
之前,需要使用import
语句导入包。例如,这个程序中导入了fmt
包(fmt
是format的缩写,表示格式化相关的包),然后我们才可以使用fmt
包中的Println
()
函数。
双引号包含的“你好, 世界!”是Go语言的字符串字面值常量。和C语言中的字符串不同,Go语言中的字符串内容是不可变更的。在将字符串作为参数传递给fmt
.
Println
()
函数时,字符串的内容并没有被复制——传递的仅是字符串的地址和长度(字符串的结构在reflect. S
tringHeader
中定义)。在Go语言中,函数参数都是以复制的方式(不支持以引用的方式)传递的(比较特殊的是,Go语言闭包函数对外部变量是以引用的方式使用的)。
1.1节中简单介绍了Go语言基因族谱,对其中来自贝尔实验室的特有并发编程基因做了重点介绍,最后引出了Go语言版的“Hello, World”程序。其实“Hello, World”程序是展示各种语言特性的最好的例子,是通向该语言的一个窗口。本节将沿着编程语言演化的时间轴(如图1-3所示),简单回顾一下“Hello, World”程序是如何逐步演化到目前的Go语言形式并最终完成它的使命的。
B语言是“Go语言之父”——贝尔实验室的Ken Thompson早年间开发的一种通用的程序设计语言,用于辅助UNIX系统的开发。但是,由于B语言缺乏灵活的类型系统,因此使用比较困难。后来,Ken Thompson的同事Dennis Ritchie以B语言为基础开发出了C语言,C语言提供了丰富的类型,极大地增强了语言的表达能力。到目前为止,C语言依然是世界上最常用的程序设计语言之一。而B语言自从被它取代之后,就只存在于各种文献之中,成为了历史。
目前见到的B语言版本的“Hello, World”,一般认为是来自Brian W. Kernighan(Go核心代码库中第一个提交者的名字正是Brian W. Kernighan)编写的B语言入门教程,程序如下:
main() { extrn a, b, c; putchar(a); putchar(b); putchar(c); putchar('!*n'); } a 'hell'; b 'o, w'; c 'orld';
因为B语言缺乏灵活的数据类型,所以只能分别以全局变量a
、
b
、
c
来定义要输出的内容,并且每个变量的长度必须对齐到4字节(有一种写汇编语言的感觉)。然后通过多次调用putchar
()
函数输出字符,最后的'!*n'
表示输出一个换行的意思。
总体来说,B语言简单,功能也比较有限。
C语言是由Dennis Ritchie在B语言的基础上改进而来,它增加了丰富的数据类型,并最终实现了用它重写UNIX的伟大目标。C语言可以说是现代IT行业最重要的软件基石,目前主流的操作系统几乎全部是用C语言开发的,许多基础系统软件也是用C语言开发的。C系家族的编程语言占据程序设计语言统治地位达几十年之久,半个多世纪以来依然充满活力。
在Brian W. Kernighan于1974年左右编写的C语言入门教程中,出现了第一个C语言版本的“Hello, World”程序。自此,后来的大部分编程语言教程都以“Hello, World”为第一个程序示例。第一个C语言版本的“Hello, World”程序如下:
main() { printf("hello, world"); }
关于这个程序,有几点需要说明:首先,main()
函数没有明确返回值类型,因此默认返回int
类型;其次,printf()
函数默认不需要导入函数声明即可以使用;最后,main()
没有明确返回语句,但默认返回0。在这个程序出现时,C语言还未被标准化,我们看到的是早先的C语言语法:函数不用写返回值,函数参数也可以忽略,使用printf()
时不需要包含头文件等。
这个例子同样出现在了1978年出版的《C程序设计语言》(The C Programming Language,简称K&R)中,作者正是Brian W. Kernighan和Dennis M. Ritchie。书中的“Hello, World”程序末尾增加了一个换行符:
main() { printf("hello, world\n"); }
这个例子在字符串末尾增加了一个换行符,C语言的换行符\n
比B语言的换行符'!*n'
看起来简洁了一些。
在《C程序设计语言》面世10年之后的1988年,此书的第2版终于出版了。此时ANSI C语言的标准化草案已经初步完成,但正式版本的文档尚未发布。《C程序设计语言(第2版)》中的“Hello, World”程序根据新规范增加了头文件包含语句#include <
stdio
.h>
,用于包含printf()
函数的声明(在C89标准中,对printf()
函数依然可以不用声明函数而直接使用)。
#include <stdio.h> main() { printf("hello, world\n"); }
1989年,ANSI通过了C语言的第一个官方标准,一般称为C89。C89是流行最广泛的一版C语言标准,目前依然被大量使用。《C程序设计语言》也针对新发布的C89规范出版了新版,给main()
函数的参数增加了void
输入参数说明,表示没有输入参数的意思。
#include <stdio.h> main(void) { printf("hello, world\n"); }
至此,C语言本身的进化基本完成。后面的C92/C99/C11都只是针对一些语言细节做了完善。因为各种历史因素,C89依然是当前使用最广泛的C语言标准。
Newsqueak是Rob Pike发明的“老鼠”语言的第二代,是他用于实践CSP并发编程模型的战场。Newsqueak是“新Squeak语言”的意思,其中squeak是老鼠“吱吱吱”的叫声,也可以理解成类似鼠标点击的声音。Squeak语言是一种提供鼠标和键盘事件处理的编程语言,其通道是静态创建的。对Squeak进行了改进的Newsqueak语言是一种带垃圾收集机制的纯函数式语言,它仍然针对键盘、鼠标和窗口事件管理,但其通道是动态创建的,属于第一类值,可以保存到变量中。Newsqueak还提供了类似C语言的语句和表达式的语法,还有类似Pascal语言的推导语法。
类似于脚本语言,Newsqueak内置了一个print()
函数,它的“Hello, World”程序看不出什么特色:
print("Hello,", "World", "\n");
从上面的程序中我们除了能猜测出print()
函数可以支持多个参数,很难看到Newsqueak语言相关的特性。由于Newsqueak语言和Go语言相关的特性主要是并发和通道,因此下面我们通过一个并发版本的“素数筛”算法来略窥Newsqueak语言的特性。“素数筛”的原理如图1-4所示。
图1-4 素数筛
Newsqueak语言并发版本的“素数筛”程序如下:
// 向通道输出从2开始的自然数序列 counter := prog(c:chan of int) { i := 2; for(;;) { c <-= i++; } }; // 针对listen通道获取的数列,过滤掉是prime倍数的数 // 新序列输出到send通道 filter := prog(prime:int, listen, send:chan of int) { i:int; for(;;) { if((i = <-listen)%prime) { send <-= i; } } }; // 主函数 // 每个通道第一个流出的数必然是素数 // 然后基于这个新素数构建新的素数过滤器 sieve := prog() of chan of int { c := mk(chan of int); begin counter(c); prime := mk(chan of int); begin prog(){ p:int; newc:chan of int; for(;;){ prime <-= p =<- c; newc = mk(); begin filter(p, c, newc); c = newc; } }(); become prime; }; // 启动素数筛 prime := sieve();
程序中的counter()
函数用于向通道输出原始的自然数序列,每个filter()
函数对象则对应每个新的素数过滤通道,这些素数过滤通道根据当前的素数筛将流入输入通道的数列筛选后重新输出到输出通道。mk(chan of int)
用于创建通道,类似Go语言的make(chan int)
语句;begin filter(p, c, n
ewc
)
关键字启动素数筛的并发体,类似Go语言的go filter(p, c, n
ewc
)
语句;become
用于返回函数结果,类似return
语句。
Newsqueak语言中并发体和通道的语法与Go语言已经比较接近了,后置的类型声明和Go语言的语法也很相似。
在Go语言出现之前,Alef语言是比较完美的并发语言,Alef语法和运行时基本是无缝兼容C语言。Alef语言对线程并发体和进程并发体都提供了支持,其中proc receive(c)
以线程方式启动一个并发体,task receive(c)
以线程方式启动一个并发体,它们之间通过通道c
进行通信。不过,由于Alef语言同时支持进程并发体和线程并发体,而且在并发体中可以再次启动更多的并发体,因此Alef的并发状态异常复杂。同时Alef没有自动垃圾收集机制(Alef保留的C语言灵活的指针特性也导致自动垃圾收集机制实现比较困难),各种资源充斥于不同的线程和进程之间,导致并发体的内存资源管理也异常复杂。
Alef语言只在Plan 9系统中得到过短暂的支持,其他操作系统中并没有实际可以运行的Alef开发环境。关于Alef语言的资料也只有Plan 9 Programmer’s Manual(Plan 9程序员手册)中的“Alef Language Reference Manual”(Alef语言参考手册)和“Alef User’s Guide”(Alef用户指南)部分,因此在贝尔实验室之外关于Alef语言的讨论并不多。
图1-5展示了Alef用户指南中给出的一个可能的并发体状态。
图1-5 Alef并发模型
Alef语言并发版本的“Hello, World”程序如下:
#include <alef.h> void receive(chan(byte*) c) { byte *s; s = <- c; print("%s\n", s); terminate(nil); } void main(void) { chan(byte*) c; alloc c; proc receive(c); task receive(c); c <- = "hello proc or task"; c <- = "hello proc or task"; print("done\n"); terminate(nil); }
程序开头的#include <
alef
.h>
语句用于包含Alef语言的运行时库。receive()
是一个普通函数,用作程序中每个并发体的入口函数;main()
函数中的alloc
c
语句先创建一个chan(byte*)
类型的通道,类似Go语言的make(chan []byte)
语句;然后分别以进程和线程的方式运行receive()
函数,启动并发体;启动并发体之后,main()
函数向c
通道发送了两个字符串数据;而以进程和线程状态运行的receive()
函数会以不确定的顺序先后从通道收到数据后,分别打印字符串;最后每个并发体都通过调用terminate(nil)
来结束自己。
Alef的语法和C语言基本保持一致,可以认为它是在C语言的语法基础上增加了并发编程相关的特性,可以看作是另一个维度的C++语言。
Limbo是用于开发运行在小型计算机上的分布式应用的编程语言,它支持模块化编程、编译时和运行时的强类型检查、进程内基于具有类型的通信通道、原子性垃圾收集和简单的抽象数据类型。Limbo的设计初衷是使代码即便在没有硬件内存保护的小型设备上也能安全运行。Limbo语言主要运行在Inferno系统之上。
Limbo语言版本的“Hello, World”程序如下:
implement Hello; include "sys.m"; include "draw.m"; sys: Sys; Hello: module { init: fn(ctxt: ref Draw->Context, args: list of string); }; init(ctxt: ref Draw->Context, args: list of string) { sys = load Sys Sys->PATH; sys->print("hello, world\n"); }
从这个版本的“Hello, World”程序中,已经可以发现很多Go语言特性的雏形。第一句implement Hello;
基本对应Go语言的包声明语句package Hello
。随后的include "sys.m";
和include "draw.m";
语句用于导入其他模块,类似Go语言的import "sys"
和import "draw"
语句。Hello
包模块还提供了模块初始化函数init()
,并且函数的参数的类型也是后置的,不过Go语言的初始化函数是没有参数的。
贝尔实验室后来经历了多次动荡,包括Ken Thompson在内的Plan 9项目原班人马最终加入了谷歌公司。在Limbo等前辈语言诞生10多年后的2007年底,Go语言3个最初的设计者因为偶然的因素聚集到一起批斗C++(传说是C++语言的布道师在谷歌公司到处鼓吹C++11各种强大的特性彻底惹恼了他们),他们终于抽出了20%的自由时间创造了Go语言。最初的Go语言规范从2008年3月开始编写,最初的Go程序也是直接编译为C语言,然后再二次编译为机器码。到2008年5月,谷歌公司终于发现了Go语言的巨大潜力,从而开始全力支持这个项目(谷歌的创始人甚至还贡献了func
关键字),让他们可以将全部工作时间投到Go语言的设计和开发中。在Go语言规范初版完成之后,Go语言的编译器终于可以直接生成机器码了。
下面是初期Go语言程序正式开始测试的版本:
package main func main() int { print "hello, world\n"; return 0; }
内置的用于调试的print
语句已经存在,不过是以命令的方式使用的。入口main()
函数还和C语言中的main()
函数一样返回int
类型的值,而且需要return
显式地返回值。每个语句末尾的分号也还存在。
下面是2008年6月27日的Go代码:
package main func main() { print "hello, world\n"; }
入口函数main()
已经去掉了返回值,程序默认通过隐式调用exit(0)
来返回。Go语言朝着简单的方向逐步进化。
下面是2008年8月的代码:
package main func main() { print("hello, world\n"); }
用于调试的内置的print
由开始的命令改为普通的内置函数,使语法更加简单一致。
下面是2008年10月的代码:
package main import "fmt" func main() { fmt.printf("hello, world\n"); }
作为C语言中招牌的printf()
格式化函数已经移植到了Go语言中,函数放在fmt
包中。不过printf()
函数名的开头字母依然是小写字母,采用大写字母表示导出的特性还没有出现。
下面是2009年1月的代码:
package main import "fmt" func main() { fmt.Printf("hello, world\n"); }
Go语言开始采用是否大小写首字母来区分符号是否可以导出。大写字母开头表示导出的公共符号,小写字母开头表示包内部的私有符号。但需要注意的是,汉字中没有大小写字母的概念,因此以汉字开头的符号目前是无法导出的(针对该问题,虽然我国的Go语言用户早在2013年就已经给出过相关建议,但是目前仍没有结果)。
下面是2009年12月的代码:
package main import "fmt" func main() { fmt.Printf("hello, world\n") }
Go语言终于移除了语句末尾的分号。这是Go语言在2009年11月10日正式开源之后第一个比较重要的语法改进。从1978年《C程序设计语言》引入分号分隔的规则到现在,Go语言的设计者花了整整32年终于移除了语句末尾的分号。在这32年的演化过程中必然充满了各种八卦故事,我想这一定是Go语言设计者深思熟虑的结果(现在Swift等新语言也是默认忽略分号的,可见分号确实并不是那么重要)。
经过半个世纪的涅槃重生,Go语言不仅打印出了Unicode版本的“Hello, World”,而且可以方便地向全球用户提供打印服务。下面的版本通过HTTP服务向每个访问的客户端打印中文的“你好, 世界!”和当前的时间信息:
package main import ( "fmt" "log" "net/http" "time" ) func main() { fmt.Println("Please visit http://127.0.0.1:12345/") http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { s := fmt.Sprintf("你好, 世界! -- Time: %s", time.Now().String()) fmt.Fprintf(w, "%v\n", s) log.Printf("%v\n", s) }) if err := http.ListenAndServe(":12345", nil); err != nil { log.Fatal("ListenAndServe: ", err) } }
这里我们通过Go语言标准库自带的net/http
包,构造了一个独立运行的HTTP服务,其中http.
HandleFunc
("/", ...)
针对根路径/
请求注册了响应处理函数。在响应处理函数中,我们依然使用fmt
.Fprintf()
格式化输出函数实现了通过HTTP向请求的客户端打印格式化的字符串,同时通过标准库的日志包在服务器端也打印相关字符串。最后通过http.
ListenAndServe
()
函数调用来启动HTTP服务。
至此,Go语言终于完成了从单机单核时代的C语言到21世纪互联网时代多核环境的通用编程语言的蜕变。
在主流的编程语言中,数组及其相关的数据结构是使用最为频繁的,只有在数组及其相关的数据结构不能满足时才会考虑链表、哈希表(哈希表可以看作是数组和链表的混合体)和更复杂的自定义数据结构。
在Go语言中,数组、字符串和切片三者是密切相关的数据结构。这3种数据类型的底层原始数据有着相同的内存结构,但因为语法的限制,在上层却有着不同的行为表现。首先,Go语言的数组是一种值类型,虽然数组的元素可以被修改,但是数组本身的赋值和函数传参都是以整体复制的方式处理的。其次,Go语言的字符串底层数据也是字节数组,但是字符串的只读属性禁止了在程序中对底层字节数组的元素的修改,字符串赋值只是复制了数据地址和对应的长度,而不会导致底层数据的复制。最后,切片的行为更为灵活,切片的结构和字符串结构类似,但是解除了只读限制。虽然切片的底层数据也是相应数据类型的数组,但是每个切片还有独立的长度和容量信息,切片赋值和函数传参时也是将切片头信息部分以传值的方式处理。因为切片头含有底层数据的指针,所以它的赋值也不会导致底层数据的复制。Go语言的赋值和函数传参规则很简单,除闭包函数以引用的方式对外部变量访问之外,其他赋值和函数传参都以传值的方式处理。要理解数组、字符串和切片这3种不同的处理方式的原因,需要详细了解它们的底层数据结构。
数组是一个由固定长度的特定类型元素组成的序列,一个数组可以由零个或多个元素组成。数组的长度是数组类型的一部分,所以不同长度或不同类型的元素组成的数组都是不同类型的数组。因此,Go语言中很少直接使用数组(因不同长度的数组属于不同的数组类型而无法直接赋值)。与数组对应的类型是切片,切片是可以动态增长和收缩的序列,切片的功能也更加灵活,但是要理解切片的工作原理还是要先理解数组。
我们先看看数组有哪些定义方式:
var a [3]int // 定义长度为3的int型数组,元素全部为0 var b = [...]int{1, 2, 3} // 定义长度为3的int型数组,元素为1, 2, 3 var c = [...]int{2: 3, 1: 2} // 定义长度为3的int型数组,元素为0, 2, 3 var d = [...]int{1, 2, 4: 5, 6} // 定义长度为6的int型数组,元素为1, 2, 0, 0, 5, 6
第一种方式是定义一个数组变量的最基本的方式,数组的长度明确指定,数组中的每个元素都以零值初始化。
第二种方式是在定义数组的时候顺序指定全部元素的初始值,数组的长度根据初始化元素的数目自动计算。
第三种方式是以索引的方式来初始化数组的元素,因此元素的初始值出现顺序比较随意。这种初始化方式和map[int]Type
类型的初始化语法类似。数组的长度以出现的最大的索引为准,没有明确初始化的元素依然用零值初始化。
第四种方式是混合了第二种和第三种的初始化方式,前面两个元素采用顺序初始化,第三个和第四个元素采用零值初始化,第五个元素通过索引初始化,最后一个元素跟在前面的第五个元素之后采用顺序初始化。
数组的内存结构比较简单。例如,图1-6给出的是数组[4]int{2,3,5,7}
的内存结构。
图1-6 数组[4]int{2,3,5,7}
的内存结构
Go语言中数组是值语义。一个数组变量即表示整个数组,它并不是隐式地指向第一个元素的指针(C语言的数组变量是指针),而是一个完整的值。当一个数组变量被赋值或者被传递的时候,实际上会复制整个数组。如果数组较大的话,数组的赋值也会有较大的开销。为了避免复制数组带来的开销,可以传递一个指向数组的指针,但是数组指针并不是数组。
var a = [...]int{1, 2, 3} // a是一个数组 var b = &a // b是指向数组的指针 fmt.Println(a[0], a[1]) // 打印数组的前两个元素 fmt.Println(b[0], b[1]) // 通过数组指针访问数组元素的写法和直接访问数组类似 for i, v := range b { // 通过数组指针遍历数组的元素 fmt.Println(i, v) }
其中b
是指向数组a
的指针,但是通过b
访问数组中元素的写法和直接访问a
是
类似的。还可以通过for range
来遍历数组指针指向的数组元素。其实数组指针类型除类型和数组不同之外,通过数组指针操作数组的方式和通过数组本身的操作类似,而且数组指针赋值时只会复制一个指针。但是数组指针类型依然不够灵活,因为数组的长度是数组类型的一部分,指向不同长度数组的数组指针类型也是完全不同的。
可以将数组看作一个特殊的结构体,结构的字段名对应数组的索引,同时结构体成员的数目是固定的。内置函数len()
可以用于计算数组的长度,cap()
函数可以用于计算数组的容量。不过对数组类型来说,len()
和cap()
函数返回的结果始终是一样的,都是对应数组类型的长度。
我们可以用for
循环来遍历数组。下面常见的几种方式都可以用来遍历数组:
for i := range a { fmt.Printf("a[%d]: %d\n", i, a[i]) } for i, v := range b { fmt.Printf("b[%d]: %d\n", i, v) } for i := 0; i < len(c); i++ { fmt.Printf("c[%d]: %d\n", i, c[i]) }
for range
循环的性能可能会更好一些,因为这种循环可以保证不会出现数组越界的情形,在每次迭代对数组元素访问时可以省去对下标越界的判断。
使用for range
循环
遍历还可以忽略迭代时的下标:
var times [5][0]int for range times { fmt.Println("hello") }
其中,times
对应一个[5][0]int
类型的数组,虽然第一维数组有长度,但是数组的元素[0]int
大小是0
,因此整个数组占用的内存大小依然是0
。不用付出额外的内存代价,我们就通过for range
循环实现了times
次快速迭代(Go 1.22已经支持基于一个整数的for range
用法)。
数组不仅可以定义数值数组,还可以定义字符串数组、结构体数组、函数数组、接口数组、通道数组等:
// 字符串数组 var s1 = [2]string{"hello", "world"} var s2 = [...]string{"你好", "世界"} var s3 = [...]string{1: "世界", 0: "你好", } // 结构体数组 var line1 [2]image.Point var line2 = [...]image.Point{image.Point{X: 0, Y: 0}, image.Point{X: 1, Y: 1}} var line3 = [...]image.Point{{0, 0}, {1, 1}} // 图像解码器数组 var decoder1 [2]func(io.Reader) (image.Image, error) var decoder2 = [...]func(io.Reader) (image.Image, error){ png.Decode, jpeg.Decode, } // 接口数组 var unknown1 [2]interface{} var unknown2 = [...]interface{}{123, "你好"} // 通道数组 var chanList = [2]chan int{}
我们还可以定义一个空的数组:
var d [0]int // 定义一个长度为0的数组 var e = [0]int{} // 定义一个长度为0的数组 var f = [...]int{} // 定义一个长度为0的数组
长度为0的数组(空数组)在内存中并不占用空间。空数组虽然很少直接使用,但是可以用于强调某种特有类型的操作时避免分配额外的内存空间,如用于通道的同步操作:
c1 := make(chan [0]int) go func() { fmt.Println("c1") c1 <- [0]int{} }() <-c1
在这里,我们并不关心通道中传输数据的真实类型,其中通道接收和发送操作只是用于消息的同步。对于这种场景,我们用空数组作为通道类型可以减少通道元素赋值时的开销。当然,一般更倾向于用无类型的匿名结构体代替空数组:
c2 := make(chan struct{}) go func() { fmt.Println("c2") c2 <- struct{}{} // struct{}部分是类型,{}表示对应的结构体值 }() <-c2
我们可以用fmt
.Printf()
函数提供的%T
或%#v
谓词语法来打印数组的类型和详细信息:
fmt.Printf("b: %T\n", b) // b: [3]int fmt.Printf("b: %#v\n", b) // b: [3]int{1, 2, 3}
在Go语言中,数组类型是切片和字符串等结构的基础。以上对于数组的很多操作都可以直接用于字符串或切片中。
一个字符串是一个不可变的字节序列。字符串通常用来存储人类可读的文本数据。与数组不同,字符串的元素不可修改,因此字符串类似于一个只读的字节数组。虽然每个字符串的长度是固定的,但是长度并不是字符串类型的一部分。由于Go语言的源文件要求使用UTF-8编码,因此Go源文件中出现的字符串字面值常量一般也是UTF-8编码的。源文件中的文本字符串通常被解释为采用UTF-8编码的Unicode码点(rune)序列。因为字节序列对应的是二进制字节序列,所以字符串可以包含任意的数据,包括字节值0。我们也可以用字符串表示GBK等非UTF-8编码的数据,不过这时候将字符串看作是一个只读的二进制数组更准确,因为for range
等语法并不能支持非UTF-8编码的字符串的遍历。
Go语言字符串的底层结构在reflect.
StringHeader
中定义:
type StringHeader struct { Data uintptr Len int }
字符串结构由两个信息组成:第一个是指向字符串底层的字节数组的地址;第二个是字符串底层的字节数组长度。字符串其实是一个结构体,因此字符串的赋值操作也就是reflect.
StringHeader
结构体的复制过程,并不会涉及底层字节数组的复制。1.3.1节中提到的[2]string
字符串数组对应的底层结构和[2]reflect.
StringHeader
对应的底层结构是一样的,可以将字符串数组看作一个结构体数组。
我们可以看看字符串"hello, world"
的内存结构,如图1-7所示。
图1-7 字符串"hello, world"
的
内存结构
分析可以发现,字符串"hello, world"
的
底层数据和以下数组是完全一致的:
var data = [...]byte{ 'h', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd', }
字符串虽然不是切片,但是支持切片操作,不同位置的切片底层访问的是同一块内存数据(因为字符串是只读的,所以相同的字符串字面值常量通常对应同一个字符串常量):
s := "hello, world" hello := s[:5] world := s[7:] s1 := "hello, world"[:5] s2 := "hello, world"[7:]
字符串和数组类似,内置的len()
函数返回字符串的长度。也可以通过reflect. S
tringHeader
结构访问字符串的长度(这里只是为了演示字符串的结构,并不是推荐的做法):
fmt.Println("len(s):", (*reflect.StringHeader)(unsafe.Pointer(&s)).Len) // 12 fmt.Println("len(s1):", (*reflect.StringHeader)(unsafe.Pointer(&s1)).Len) // 5 fmt.Println("len(s2):", (*reflect.StringHeader)(unsafe.Pointer(&s2)).Len) // 5
根据Go语言规范,Go语言的源文件都采用UTF-8编码。因此,Go源文件中出现的字符串字面值常量一般也是UTF-8编码的(对于转义字符,则没有这个限制)。提到Go字符串时,一般都会假设字符串对应的是一个合法的UTF-8编码的字节序列。可以用内置的print
调试函数或fmt
.Print()
函数直接打印,也可以用for range
循环直接遍历UTF-8解码后的Unicode码点值。
下面的"hello,
世界"
字符串中包含了中文字符,可以通过打印对应的字节切片来查看字符底层对应的数据:
fmt.Printf("%#v\n", []byte("hello, 世界"))
输出的结果是:
[]byte{0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0xe4, 0xb8, 0x96, 0xe7, \ 0x95, 0x8c}
分析结果可以发现,0xe4, 0xb8, 0x96
对应中文“世”,0xe7, 0x95, 0x8c
对应中文“界”。我们也可以在字符串字面值中直接指定UTF-8编码后的值(源文件中全部是ASCII码,可以避免出现多字节的字符)。
fmt.Println("\xe4\xb8\x96") // 打印“世” fmt.Println("\xe7\x95\x8c") // 打印“界”
图1-8展示了字符串"
hello, 世界"
的内存结构。
图1-8 字符串"hello,
世界
"
的
内存结构
Go语言的字符串中可以存放任意的二进制字节序列。即使是UTF-8字节序列,也可能会遇到错误的编码。如果遇到一个错误的UTF-8编码输入,将生成一个特别的Unicode字符,这个字符在不同的软件中的显示效果可能不太一样,在印刷中,这个字符通常是一个黑色六角形或钻石形状,里面包含一个白色的问号,即“�”。
在下面的字符串中,我们故意损坏了第一字符的第二和第三字节,因此第一字符将会打印为“�”,第二和第三字节则被忽略,后面的“abc”依然可以正常解码打印(错误编码不会向后扩散是UTF-8编码的优秀特性之一)。
fmt.Println("\xe4\x00\x00\xe7\x95\x8cabc") // �界abc
在用for range
循环
遍历这个损坏的UTF-8字符串时,第一字符的第二和第三字节依然会被单独遍历到,不过此时得到的值是损坏后的0:
for i, c := range "\xe4\x00\x00\xe7\x95\x8cabc" { fmt.Println(i, c) } // 0 65533 // \uFFF,对应� // 1 0 // 空字符 // 2 0 // 空字符 // 3 30028 // 界 // 6 97 // a // 7 98 // b // 8 99 // c
如果不想解码UTF-8字符串,而想直接遍历原始的字节码,可以将字符串强制转换为[]byte
字节序列后再进行遍历(这里的转换一般不会产生运行时开销):
for i, c := range []byte("世界abc") { fmt.Println(i, c) }
或者采用传统的下标方式遍历字符串的字节数组:
const s = "\xe4\x00\x00\xe7\x95\x8cabc" for i := 0; i < len(s); i++ { fmt.Printf("%d %x\n", i, s[i]) }
Go语言除了for range
语法对UTF-8字符串提供了特殊支持外,还对字符串和[]rune
类型的相互转换提供了特殊的支持。
fmt.Printf("%#v\n", []rune("世界")) // []int32{19990, 30028} fmt.Printf("%#v\n", string([]rune{'世', '界'})) // 世界
从上面代码的输出结果可以发现,[]rune
其实是[]int32
类型,这里的rune
只是int32
类型的别名,并不是重新定义的类型。rune
用于表示每个Unicode码点,目前只使用了21位。
字符串相关的强制类型转换主要涉及[]byte
和[]rune
两种类型。每种转换都可能隐含重新分配内存的代价,在最坏的情况下,它们运算的时间复杂度都是O
(
n
)
。不过字符串和[]rune
的转换要更为特殊一些,因为通常的强制类型转换要求两个类型的底层内存结构要尽量一致,但字符串和[]rune
底层对应的[]byte
和[]int32
类型是完全不同的内存结构,因此这种转换可能隐含重新分配内存的操作。
下面分别用伪代码简单模拟Go语言对字符串内置的一些操作,这样对每个操作的时间复杂度和空间复杂度都会有较明确的认识。
(1)for range
对字符串的遍历模拟实现如下:
func forOnString(s string, forBody func(i int, r rune)) { for i := 0; len(s) > 0; { r, size := utf8.DecodeRuneInString(s) forBody(i, r) s = s[size:] i += size } }
用
for range
循环
遍历字符串时,每次解码一个Unicode字符,然后进入for
循环体,遇到损坏的编码并不会导致循环停止。
(2)[]byte(s)
转换模拟实现如下:
func str2bytes(s string) []byte { p := make([]byte, len(s)) for i := 0; i < len(s); i++ { c := s[i] p[i] = c } return p }
模拟实现中新创建了一个切片,然后将字符串的数组逐一复制到切片中,这是为了保证字符串只读的语义。当然,在将字符串转换为[]byte
时,如果转换后的变量没有被修改,编译器可能会直接返回原始的字符串对应的底层数据。
(3)string(bytes)
转换模拟实现如下:
func bytes2str(s []byte) (p string) { data := make([]byte, len(s)) for i, c := range s { data[i] = c } hdr := (*reflect.StringHeader)(unsafe.Pointer(&p)) hdr.Data = uintptr(unsafe.Pointer(&data[0])) hdr.Len = len(s) return p }
因为Go语言的字符串是只读的,无法以直接构造底层字节数组的方式生成字符串。在模拟实现中通过unsafe
包获取字符串的底层数据结构,然后将切片的数据逐一复制到字符串中,这同样是为了保证字符串只读的语义不受切片的影响。如果转换后的字符串在生命周期中原始的[]byte
的变量不发生变化,编译器可能会直接基于[]byte
底层的数据构建字符串。
(4)[]rune(s)
转换模拟实现如下:
func str2runes(s string) []rune { var p []int32 for len(s) > 0 { r, size := utf8.DecodeRuneInString (s) p = append(p, int32(r)) s = s[size:] } return []rune(p) }
因为底层内存结构的差异,所以字符串到[]rune
的转换必然会导致重新分配[]rune
内存空间,然后依次解码并复制对应的Unicode码点值。这种强制转换并不存在前面提到的字符串和字节切片转换时的优化情况。
(5)string(runes)
转换模拟实现如下:
func runes2string(s []int32) string { var p []byte buf := make([]byte, 3) for _, r := range s { n := utf8.EncodeRune(buf, r) p = append(p, buf[:n]...) } return string(p) }
同样因为底层内存结构的差异,[]rune
到字符串的转换也必然会导致重新构造字符串。这种强制转换并不存在前面提到的优化情况。
简单地说,切片(slice)就是一种简化版的动态数组。因为动态数组的长度不固定,所以切片的长度自然也就不能是类型的组成部分了。数组虽然有适用的地方,但是数组的类型和操作都不够灵活,因此在Go语言中数组使用得并不多。而切片则使用得相当广泛,理解切片的原理和用法是Go程序员的必备技能。
我们先看一下切片的结构定义,即reflect
包里定义的SliceHeader
:
type SliceHeader struct { Data uintptr Len int Cap int }
由此可以看出切片的开头部分和Go字符串是一样的,但是切片多了一个Cap
成员表示切片指向的内存空间的最大容量(元素的个数,而不是字节数)。图1-9给出了x := []int{2,3,5, 7,11}
和y := x[1:3]
两个切片的内存结构。
图1-9 切片的内存结构
让我们看看切片有哪些定义方式:
var ( a []int // nil切片,和nil相等,一般用来表示一个不存在的切片 b = []int{} // 空切片,和nil不相等,一般用来表示一个空的集合 c = []int{1, 2, 3} // 有3个元素的切片,len和cap都为3 d = c[:2] // 有2个元素的切片,len为2,cap为3 e = c[0:2:cap(c)] // 有2个元素的切片,len为2,cap为3 f = c[:0] // 有0个元素的切片,len为0,cap为3 g = make([]int, 3) // 有3个元素的切片,len和cap都为3 h = make([]int, 2, 3) // 有2个元素的切片,len为2,cap为3 i = make([]int, 0, 3) // 有0个元素的切片,len为0,cap为3 )
和数组一样,内置的len()
函数返回切片中有效元素的长度,内置的cap()
函数返回切片容量大小,容量必须大于或等于切片的长度。也可以通过reflect.
SliceHeader
结构访问切片的信息(只是为了说明切片的结构,并不是推荐的做法)。切片可以和nil
进行比较,只有当切片底层数据指针为空时切片本身才为nil
,这时候切片的长度和容量信息将是无效的。如果有切片的底层数据指针为空,但是长度和容量不为0的情况,说明切片本身已经损坏了(如直接通过reflect.
SliceHeader
或unsafe
包对切片作了不正确的修改)。
遍历切片的方式和遍历数组的方式类似:
for i := range a { fmt.Printf("a[%d]: %d\n", i, a[i]) } for i, v := range b { fmt.Printf("b[%d]: %d\n", i, v) } for i := 0; i < len(c); i++ { fmt.Printf("c[%d]: %d\n", i, c[i]) }
其实,只要切片的底层数据指针、长度和容量没有发生变化,对切片的遍历、元素的读取和修改就和数组一样。在对切片本身进行赋值或参数传递时,和数组指针的操作方式类似,但是只复制切片头信息(reflect.
SliceHeader
),而不会复制底层的数据。在类型上,与数组最大的不同是,切片的类型和长度信息无关,只要是相同类型元素构成的切片均对应相同的切片类型。
如前所述,切片是一种简化版的动态数组,这是切片类型的灵魂。除构造切片和遍历切片之外,添加切片元素、删除切片元素都是切片类型的常用操作。
内置的泛型函数append()
可以在切片的尾部追加n个元素:
var a []int a = append(a, 1) // 追加1个元素 a = append(a, 1, 2, 3) // 追加多个元素,手写解包方式 a = append(a, []int{1,2,3}...) // 追加1个切片,切片需要解包
不过,要注意的是,在容量不足的情况下,append()
操作会重新分配内存,可能导致巨大的内存分配和复制数据的代价。即使容量足够,依然需要用append()
函数的返回值来更新切片本身,因为新切片的长度已经发生了变化。
除了在切片的尾部追加元素,还可以在切片的开头添加元素:
var a = []int{1,2,3} a = append([]int{0}, a...) // 在开头添加1个元素 a = append([]int{-3,-2,-1}, a...) // 在开头添加1个切片
在开头添加元素一般都会重新分配内存,而且会导致已有的元素全部复制一次。因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。
由于append()
函数返回新的切片,也就是说它支持链式操作,我们就可以将多个append()
操作组合起来,实现在切片中间插入元素:
var a []int a = append(a[:i], append([]int{x}, a[i:]...)...) // 在第i个位置插入x a = append(a[:i], append([]int{1,2,3}, a[i:]...)...) // 在第i个位置插入切片
每个添加操作中的第二个append()
调用都会创建一个临时切片,并将a[i:]
的内容复制到新创建的切片中,然后将临时创建的切片再追加到a[:i]
。
用copy()
和append()
组合可以避免创建中间的临时切片。同样是完成添加元素的操作:
a = append(a, 0) // 切片扩展1个空间 copy(a[i+1:], a[i:]) // a[i:]向后移动1个位置 a[i] = x // 设置新添加的元素
第一句中的append()
用于扩展切片的长度,为要插入的元素留出空间。第二句中的copy()
操作将要插入位置开始之后的元素向后移动1个位置。第三句真实地将新添加的元素赋值给对应的位置。操作语句虽然冗长了一点,但是相比前面的方法,可以减少中间创建的临时切片。
用copy()
和append()
组合也可以实现在中间位置插入多个元素(也就是插入1个切片):
a = append(a, x...) // 为x切片扩展足够的空间 copy(a[i+len(x):], a[i:]) // a[i:]向后移动len(x)个位置 copy(a[i:], x) // 复制新添加的切片
稍显不足的是,在第一句扩展切片容量的时候,扩展空间部分的元素复制是没有必要的。没有专门的内置函数用于扩展切片的容量,append()
本质是用于追加元素而不是扩展容量,扩展切片容量只是append()
的一个副作用。
根据要删除元素的位置,有从开头位置删除、从中间位置删除和从尾部删除3种情况,其中删除切片尾部的元素最快:
a = []int{1, 2, 3} a = a[:len(a)-1] // 删除尾部1个元素 a = a[:len(a)-N] // 删除尾部N个元素
要删除开头的元素可以直接移动数据指针:
a = []int{1, 2, 3} a = a[1:] // 删除开头1个元素 a = a[N:] // 删除开头N个元素
删除开头的元素也可以不移动数据指针,而将后面的数据向开头移动。可以用append()
原地完成(所谓原地完成是指在原有的切片数据对应的内存空间内完成,不会导致内存空间结构的变化):
a = []int{1, 2, 3} a = append(a[:0], a[1:]...) // 删除开头1个元素 a = append(a[:0], a[N:]...) // 删除开头N个元素
也可以用copy()
删除开头的元素:
a = []int{1, 2, 3} a = a[:copy(a, a[1:])] // 删除开头1个元素 a = a[:copy(a, a[N:])] // 删除开头N个元素
对于删除中间的元素,需要对剩余的元素进行一次整体移动,同样可以用append()
或copy()
原地完成:
a = []int{1, 2, 3, ...} a = append(a[:i], a[i+1:]...) // 删除中间1个元素 a = append(a[:i], a[i+N:]...) // 删除中间N个元素 a = a[:i+copy(a[i:], a[i+1:])] // 删除中间1个元素 a = a[:i+copy(a[i:], a[i+N:])] // 删除中间N个元素
删除开头的元素和删除尾部的元素都可以认为是删除中间的元素操作的特殊情况。
在1.3.1节中我们提到过类似[0]int
的空数组,空数组一般很少用到。但是对切片来说,len
为0
但cap
不为0
是非常有用的特性。当然,如果len
和cap
都为0
的话,则变成一个真正的空切片,虽然它并不是一个nil
的切片。当判断一个切片是否为空时,通常通过len
获取切片的长度来判断,一般很少将切片和nil
做直接比较。
例如,下面的TrimSpace
()
函数用于删除[]byte
中的空格。函数实现利用了长度为0的切片特性,实现高效而且简洁。
func TrimSpace(s []byte) []byte { b := s[:0] for _, x := range s { if x != ' ' { b = append(b, x) } } return b }
其实类似的根据过滤条件原地删除切片元素的算法都可以采用类似的方式处理(因为是删除操作,所以不会出现内存不足的情形):
func Filter(s []byte, fn func(x byte) bool) []byte { b := s[:0] for _, x := range s { if !fn(x) { b = append(b, x) } } return b }
切片高效操作的要点是要降低内存分配的次数,尽量保证append()
操作不会超出cap
,减少触发内存分配的次数和每次分配内存的大小。
如前所述,切片操作并不会复制底层的数据。底层的数组会被保存在内存中,直到它不再被引用。但是有时候可能会因为一个小的内存引用而导致底层整个数组处于被使用的状态,这会延迟垃圾收集器对底层数组的回收。
例如,FindPhoneNumber
()
函数加载整个文件到内存,然后搜索第一个出现的电话号码,最后结果以切片方式返回。
func FindPhoneNumber(filename string) []byte { b, _ := ioutil.ReadFile(filename) return regexp.MustCompile("[0-9]+").Find(b) }
这段代码返回的[]byte
指向保存整个文件的数组。由于切片引用了整个原始数组,因此垃圾收集器不能及时释放底层数组的空间。一个小的需求可能导致需要长时间保存整个文件数据。这虽然不是传统意义上的内存泄漏,但是可能会降低系统的整体性能。
要解决这个问题,可以将感兴趣的数据复制到一个新切片中(数据的传值是Go语言编程的一个哲学,虽然传值有一定的代价,但是换取的好处是切断了对原始数据的依赖):
func FindPhoneNumber(filename string) []byte { b, _ := ioutil.ReadFile(filename) b = regexp.MustCompile("[0-9]+").Find(b) return append([]byte{}, b...) }
类似的问题在删除切片元素时也可能会遇到。假设切片里存放的是指针对象,那么下面删除尾部的元素后,被删除的元素依然被切片底层数组引用,从而导致不能及时被垃圾收集器回收(这要依赖回收器的实现方式):
var a []*int{ ... } a = a[:len(a)-1] // 被删除的最后一个元素依然被引用,可能导致垃圾收集操作被阻碍
保险的方式是先将指向需要回收内存的指针设置为nil
,保证垃圾收集器可以发现需要回收的对象,然后再进行切片的删除操作:
var a []*int{ ... } a[len(a)-1] = nil // 垃圾收集器回收最后一个元素的内存 a = a[:len(a)-1] // 从切片中删除最后一个元素
当然,如果切片的生命周期很短,可以不用刻意处理这个问题。因为如果切片本身已经可以被垃圾收集器回收,切片对应的每个元素自然也就可以被回收了。
为了安全,当两个切片类型[]T
和[]Y
的底层数据类型不同时,Go语言是无法直接转换类型的。不过,有时候这种类型转换是有它的价值的——可以简化编码或者提升代码的性能。例如在64位系统上,需要对一个[]float64
类型且没有负数的切片进行快速排序,我们可以将它强制转换为整型切片[]int
,然后以整数的方式进行排序(因为float64
遵循IEEE 754浮点数标准特性,所以当几个非负浮点数有序时,其底层内存数据作为整数时也必然是有序的)。
下面的代码通过两种方法将[]float64
类型的切片转换为[]int
类型的切片:
// +build amd64 arm64 import "sort" var a = []float64{4, 2, 5, 7, 2, 1, 88, 1} func SortFloat64FastV1(a []float64) { // 强制类型转换 var b []int = ((*[1 << 20]int)(unsafe.Pointer(&a[0])))[:len(a):cap(a)] // 以int方式给float64排序(不支持负数) sort.Ints(b) } func SortFloat64FastV2(a []float64) { // 通过reflect.SliceHeader更新切片头部信息实现转换 var c []int aHdr := (*reflect.SliceHeader)(unsafe.Pointer(&a)) cHdr := (*reflect.SliceHeader)(unsafe.Pointer(&c)) *cHdr = *aHdr // 以int方式给float64排序(不支持负数) sort.Ints(c) }
第一种强制类型转换是先将切片数据的开始地址转换为一个指向长度较大的数组的指针,然后对数组指针对应的数组重新做切片操作。中间需要unsafe.Pointer
来连接两个不同类型的指针传递。第二种转换操作是分别取两个不同类型的切片头信息指针,任何类型的切片头部信息底层都对应reflect.
SliceHeader
结构,然后通过更新结构体方式来更新切片信息,从而实现a
对应的[]float64
切片到c
对应的[]int
类型的
切片的转换。
在Go 1.17到Go 1.20之间,unsafe
包提供了类似的功能,因此新的写法如下:
func SortFloat64FastV3(a []float64) { c := unsafe.Slice( (*int)(unsafe.Pointer(unsafe.SliceData(a))), len(a), ) // 以int方式给float64排序(不支持负数) sort.Ints(c) }
unsafe.
SliceData
()
从切片提取底层数据的指针,unsafe.Slice()
则根据数据指针和长度构建新的切片。
通过基准测试可以发现,用sort.
Ints
对转换后的[]int
排序的性能要比用sort.Float64s
排序的性能好一点。不过需要注意的是,这个方法可行的前提是要保证[]float64
中没有NaN
和Inf
等非规范的浮点数(因为浮点数中NaN
不可排序,正0和负0相等,但是整数中没有这类情形)。
函数对应操作序列,是程序的基本组成元素。在Go语言中,函数有具名函数和匿名函数之分,具名函数一般对应于包级函数,是匿名函数的一种特例。如果匿名函数引用了外部作用域中的变量,就成了闭包函数,闭包函数是函数式编程语言的核心。方法是绑定到一个具体类型的特殊函数,Go语言中的方法是依托于类型的,必须在编译时静态绑定。接口定义了方法的集合,这些方法依托于运行时的接口对象,因此接口对应的方法是在运行时动态绑定的。Go语言通过隐式接口机制实现了鸭子型的面向对象模型。
在Go语言中,函数是第一类对象,可以将函数保存到变量中。当然,Go语言中每个类型还可以有自己的方法,方法其实也是函数的一种。
// 具名函数 func Add(a, b int) int { return a+b } // 匿名函数 var Add = func(a, b int) int { return a+b }
Go语言中的函数可以有多个参数和多个返回值,参数和返回值都是以传值的方式和被调用者交换数据。在语法上,函数还支持可变数量的参数,可变数量的参数必须是最后出现的参数,可变数量的参数其实是一个切片类型的参数。
// 多个参数和多个返回值 func Swap(a, b int) (int, int) { return b, a } // 可变数量的参数 // more对应[]int切片类型 func Sum(a int, more ...int) int { for _, v := range more { a += v } return a }
当可变数量的参数是一个空接口类型时,调用者是否解包可变数量的参数会导致不同的结果:
func main() { var a = []interface{}{123, "abc"} Print(a...) // 123 abc Print(a) // [123 abc] } func Print(a ...interface{}) { fmt.Println(a...) }
第一个Print
调用时传入的是参数a...
,等价于直接调用Print(123, "abc")
;第二个Print
调用时传入的是未解包的a
,等价于直接调用Print([]interface{}{123, "abc"}
)
。
不仅函数的参数可以有名字,也可以给函数的返回值命名:
func Find(m map[int]int, key int) (value int, ok bool) { value, ok = m[key] return }
如果返回值命名了,可以通过名字来修改返回值,也可以通过defer
语句在return
语句之后修改返回值:
func Inc() (v int) { defer func(){ v++ } () return 42 }
其中defer
语句延迟执行了一个匿名函数,因为这个匿名函数捕获了外部函数的局部变量v
,这种函数一般称为闭包。闭包对捕获的外部变量并不是以传值方式访问,而是以引用方式访问。
闭包的这种以引用方式访问外部变量的行为可能会导致一些问题。以Go 1.22版本为界,下面的例子执行会有差异:
func main() { for i := 0; i < 3; i++ { defer func(){ println(i) } () } } // 输出(版本高于Go 1.22): // 2 // 1 // 0 // 输出(版本低于Go 1.22): // 3 // 3 // 3
在for
循环
语句中,循环变量i
只会被创建一次,因此defer
语句中的闭包函数每次捕获的都是同一个i
变量,在循环结束后这个变量的值为3,因此最终输出的都是3。
上述工作机制完全符合C语言程序员对for
循环的经验习惯,但是Go语言依然存在大量由这种用法引起的bug。因此,在Go 1.22版本之后,为了配合自定义迭代器必然带来的语义变化,将for range
的循环变量改成了每次迭代都重新创建一次。
修复的思路是在每次迭代中为每个defer()
函数
生成独有的变量。可以用下面两种方式:
func main() { for i := 0; i < 3; i++ { i := i // 定义一个循环体内的局部变量i defer func(){ println(i) } () } } func main() { for i := 0; i < 3; i++ { // 通过函数传入i // defer语句对调用参数求值 defer func(i int){ println(i) } (i) } }
第一种方式是在循环体内部再定义一个局部变量,这样,在每次迭代中,defer
语句中的闭包函数捕获的都是不同的变量,这些变量的值对应迭代时的值。第二种方式是将循环变量通过闭包函数的参数传入,defer
语句对调用参数求值。两种方式都是可以工作的。不过,一般来说,在for
循环内部执行defer
语句并不是一个好的习惯(可能导致大量的defer
延迟执行函数堆积),此处仅为示例,不建议如此使用。
在Go语言中,如果以切片为参数调用函数,有时候会给人一种参数采用了传引用的方式的假象:因为在被调用函数内部可以修改传入的切片的元素。其实,任何可以通过函数参数修改调用参数的情形,都是因为函数参数中显式或隐式传入了指针参数。函数参数传值的规范更准确说是只针对数据结构中固定的部分传值,例如字符串或切片对应结构体中的指针和字符串长度传值,但是并不包含指针指向的内容。将切片类型的参数替换为类似reflect.
SliceHeader
结构体就能更好地理解切片传值的含义了:
func twice(x []int) { for i := range x { x[i] *= 2 } } type IntSliceHeader struct { Data []int Len int Cap int } func twice(x IntSliceHeader) { for i := 0; i < x.Len; i++ { x.Data[i] *= 2 } }
因为切片中的底层数组部分通过隐式指针传递(指针本身依然是传值的,但是指针指向的却是同一份数据),所以被调用函数可以通过指针修改调用参数切片中的数据。除数据之外,切片结构中还包含了切片长度和切片容量,这两个信息也是传值的。如果被调用函数中修改了Len
或Cap
信息,是无法反映到调用参数的切片中的,这时候我们一般会通过返回修改后的切片来更新之前的切片。这也是内置的append()
必须返回一个切片的原因。
在Go语言中,函数还可以直接或间接地调用自己,也就是支持递归调用。Go语言函数的递归调用深度在逻辑上没有限制,函数调用的栈是不会出现溢出错误的,因为Go语言运行时会根据需要动态地调整函数栈的大小。每个goroutine刚启动时只会分配很小的栈(4 KB或8 KB,具体大小依赖实现),根据需要动态调整栈的大小,栈最大可以达到GB级。在Go 1.4以前,采用的是分段式的动态栈,通俗地说就是采用一个链表来实现动态栈,每个链表的节点内存位置不会发生变化。但是链表实现的动态栈对某些跨越链表不同节点的热点调用的性能影响较大,因为相邻的链表节点在内存位置一般不是相邻的,这会增加CPU高速缓存命中失败的概率。为了解决热点调用的CPU缓存命中率问题,Go 1.4之后改用连续的动态栈实现,也就是采用一个类似动态数组的结构来表示栈。不过连续动态栈也带来了新的问题:当连续栈动态增长时,需要将之前的数据移到新的内存空间,这会导致之前栈中全部变量的地址发生变化。虽然Go语言运行时会自动更新引用了地址变化的栈变量的指针,但Go语言中的指针不再是固定不变的了,因此不能再随意将指针保存到数值变量中,地址也不能随意保存到不在垃圾收集器控制的环境中,而且在使用CGO时不能在C语言中长期持有Go语言对象的地址。
因为Go语言函数的栈会自动调整大小,所以普通Go程序员已经很少需要关心栈的运行机制了。在Go语言规范中甚至故意没有讲到栈和堆的概念。我们无法知道也不需要知道函数参数或局部变量到底是保存在栈中还是堆中,我们只需要知道它们能够正常工作就可以了。看看下面这个例子:
func f(x int) *int { return &x } func g() int { x = new(int) return *x }
第一个函数直接返回了函数参数变量的地址——这似乎是不可以的,因为如果参数变量在栈中,函数返回之后栈变量就失效了,返回的地址自然也应该失效了。但是Go语言的编译器和运行时比我们聪明得多,它会保证指针指向的变量在合适的地方。第二个函数内部虽然调用new()
函数创建了*int
类型的指针对象,但是依然不知道它具体保存在哪里。对于有C/C++编程经验的程序员需要强调的是:不用关心Go语言中函数栈和堆的问题,编译器和运行时会帮我们搞定;同样不要假设变量在内存中的位置是固定不变的,指针随时可能会变化,特别是在你不期望它变化的时候。
方法一般是面向对象编程(object-oriented programming,OOP)的一个特性,在C++语言中方法对应一个类对象的成员函数,是关联到具体对象上的虚表中的。但是Go语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定。一个面向对象的程序会用方法来表达其属性对应的操作,这样使用这个对象的用户就不需要直接去操作对象,而是借助方法来做这些事情。面向对象编程进入主流开发领域一般认为是从C++开始的,C++就是在兼容C语言的基础上支持了类等面向对象的特性。Java编程则号称是纯粹的面向对象语言,因为Java中函数是不能独立存在的,每个函数都必然是属于某个类的。
面向对象编程更多的只是一种思想,很多号称支持面向对象编程的语言只是将经常用到的特性内置到语言中而已。Go语言的祖先C语言虽然不是一个支持面向对象的语言,但是C语言的标准库中与文件
相关的函数也用到了面向对象编程的思想。下面我们实现一组C语言风格的与文件
相关的函数:
// 文件对象 type File struct { fd int } // 打开文件 func OpenFile(name string) (f *File, err error) { // ... } // 关闭文件 func CloseFile(f *File) error { // ... } // 读文件数据 func ReadFile(f *File, offset int64, data []byte) int { // ... }
其中OpenFile()
类似于构造函数,用于打开文件对象,CloseFile
()
类似于析构函数,用于关闭文件对象,ReadFile
()
则类似于普通的成员函数,这3个函数都是普通函数。CloseFile
()
和ReadFile
()
作为普通函数,需要占用包级空间中的名字资源。不过CloseFile
()
和ReadFile
()
函数只是针对File
类型对象的操作,这时候我们更希望这类函数和操作对象的类型紧密绑定在一起。
Go语言的做法是将函数CloseFile
()
和ReadFile
()
的第一个参数移到函数名的开头:
// 关闭文件 func (f *File) CloseFile() error { // ... } // 读文件数据 func (f *File) ReadFile(offset int64, data []byte) int { // ... }
这样的话,函数CloseFile
()
和ReadFile
()
就成了File
类型独有的方法了(而不是File
对象方法)。它们也不再占用包级空间中的名字资源,同时File
类型已经明确了它们的操作对象,因此方法名字一般简化为Close
和Read
:
// 关闭文件 func (f *File) Close() error { // ... } // 读文件数据 func (f *File) Read(offset int64, data []byte) int { // ... }
将第一个函数参数移到函数前面,从代码角度看虽然只是一个小的改动,但是从编程哲学角度看,Go语言已经是进入面向对象语言的行列了。我们可以给任何自定义类型添加一个或多个方法。每种类型对应的方法必须和类型的定义在同一个包中,因此是无法给int
这类内置类型添加方法的(因为方法的定义和类型的定义不在一个包中)。对于给定的类型,每个方法的名字必须是唯一的,同时方法和函数一样也不支持重载。
方法由函数演变而来,只是将函数的第一个对象参数移到了函数名前面而已。因此,我们依然可以按照原始的过程式思维来使用方法。通过称为方法表达式的特性可以将方法还原为普通类型的函数:
// 不依赖具体的文件对象 // func CloseFile(f *File) error var CloseFile = (*File).Close // 不依赖具体的文件对象 // func ReadFile(f *File, offset int64, data []byte) int var ReadFile = (*File).Read // 文件处理 f, _ := OpenFile("foo.dat") ReadFile(f, 0, data) CloseFile(f)
有些场景更关心一组相似的操作。例如,Read()
读取一些数组,然后调用Close()
关闭。在此种场景中,用户并不关心操作对象的类型,只要能满足通用的Read()
和Close()
行为就可以了。不过在方法表达式中,因为得到的ReadFile
()
和CloseFile
()
函数参数中含有File
这个特有的类型参数,这使得File
相关的方法无法与其他不是File
类型但是有着相同Read()
和Close()
方法的对象无缝适配。这种小困难难不倒Go语言程序员,我们可以结合闭包特性来消除方法表达式中第一个参数类型的差异:
// 打开文件对象 f, _ := OpenFile("foo.dat") // 绑定到f对象 // func Close() error var Close = func Close() error { return (*File).Close(f) } // 绑定到f对象 // func Read(offset int64, data []byte) int var Read = func(offset int64, data []byte) int { return (*File).Read(f, offset, data) } // 文件处理 Read(0, data) Close()
这刚好是方法值也要解决的问题。我们可以用方法值特性简化实现:
// 打开文件对象 f, _ := OpenFile("foo.dat") // 方法值:绑定到f对象 // func Close() error var Close = f.Close // 方法值:绑定到f对象 // func Read(offset int64, data []byte) int var Read = f.Read // 文件处理 Read(0, data) Close()
Go语言不支持传统面向对象中的继承特性,而是以其特有的组合方式支持了方法的继承。Go语言中,通过在结构体内置匿名的成员来实现继承:
import "image/color" type Point struct{ X, Y float64 } type ColoredPoint struct { Point Color color.RGBA }
虽然我们可以将ColoredPoint
定义为一个有3个字段的扁平结构的结构体,但是这里将Point
嵌入ColoredPoint
来提供X
和Y
这两个字段:
var cp ColoredPoint cp.X = 1 fmt.Println(cp.Point.X) // "1" cp.Point.Y = 2 fmt.Println(cp.Y) // "2"
通过嵌入匿名的成员,不仅可以继承匿名成员的内部成员,而且可以继承匿名成员类型所对应的方法。我们一般会将Point
看作基类,把ColoredPoint
看作Point
的继承类或子类。不过这种方式继承的方法并不能实现C++中虚函数的多态特性。所有继承来的方法的接收者参数依然是那个匿名成员本身,而不是当前的变量。
type Cache struct { m map[string]string sync.Mutex } func (p *Cache) Lookup(key string) string { p.Lock() defer p.Unlock() return p.m[key] }
Cache
结构体类型通过嵌入一个匿名的sync.Mutex
来继承它的方法Lock()
和Unlock()
。但是在调用p.Lock()
和p.Unlock()
时,p
并不是方法Lock()
和Unlock()
的真正接收者,而是会将它们展开为p.Mutex.Lock()
和p.Mutex.Unlock()
调用。这种展开是编译时完成的,并没有运行时代价。
在传统的面向对象语言(如C++或Java)的继承中,子类的方法是在运行时动态绑定到对象的,因此基类实现的某些方法看到的this
可能不是基类类型对应的对象,这个特性会导致基类方法运行的不确定性。而在Go语言通过嵌入匿名的成员来“继承”的基类方法,this
就是实现该方法的类型的对象,Go语言中方法是编译时静态绑定的。如果需要虚函数的多态特性,我们需要借助接口来实现。
Go语言之父Rob Pike曾说过一句名言:“试图禁止白痴行为的编程语言,本身会变得白痴。”(Languages that try to disallow idiocy become themselves idiotic.)一般静态编程语言都有严格的类型系统,这使编译器可以深入检查程序员有没有做出什么出格的举动。但是,过于严格的类型系统却会使得编程太过烦琐,让程序员把时间都浪费在和编译器的斗争中。Go语言试图让程序员能在安全和灵活的编程之间取得一种平衡。它在提供严格的类型检查的同时,通过接口类型实现了对鸭子型的类型的支持,使得安全动态的编程变得相对容易。
Go的接口类型是对其他类型行为的抽象和概括,因为接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式,我们可以让对象更加灵活和更具有适应能力。很多面向对象的语言都有相似的接口概念,但Go语言中接口类型的独特之处在于它是满足隐式实现的鸭子型。所谓鸭子型说的是:只要走起路来像鸭子、叫起来也像鸭子,那么就可以把它当作鸭子。Go语言中的面向对象就是如此,如果一个对象只要看起来像是某种接口类型的实现,那么它就可以作为该接口类型使用。这种设计使程序员可以创建一个新的接口类型满足已经存在的具体类型却不用去破坏这些类型原有的定义。当使用的类型来自不受我们控制的包时这种设计尤其灵活有用。Go语言的接口类型是延迟绑定,可以实现类似于虚函数的多态功能。
接口在Go语言中无处不在,在“Hello, World”的例子中,fmt
.Printf()
函数的设计就是完全基于接口的,它的真正功能由fmt
.Fprintf()
函数完成。用于表示错误的error
类型更是内置的接口类型。在C语言中,printf
只能将几种有限的基础数据类型打印到文件对象中。但是Go语言由于灵活的接口特性,fmt
.Fprintf
可以向任何自定义的输出流对象打印,可以打印到文件或标准输出,也可以打印到网络,甚至可以打印到一个压缩文件;同时,打印的数据也不仅局限于语言内置的基础类型,任意隐式满足fmt
.Stringer
接口的对象都可以打印,不满足fmt
.Stringer
接口的依然可以通过反射的技术打印。fmt
.Fprintf()
函数的签名如下:
func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)
其中io.Writer
是
用于输出的接口,error
是内置的错误接口,它们的定义如下:
type io.Writer interface { Write(p []byte) (n int, err error) } type error interface { Error() string }
我们可以通过定制自己的输出对象,将每个字符转换为大写字符后输出:
type UpperWriter struct { io.Writer } func (p *UpperWriter) Write(data []byte) (n int, err error) { return p.Writer.Write(bytes.ToUpper(data)) } func main() { fmt.Fprintln(&UpperWriter{os.Stdout}, "hello, world") }
当然,我们也可以定义自己的打印格式来实现将每个字符转换为大写字符后输出的效果。对于每个要打印的对象,如果满足了fmt
.Stringer
接口,则默认使用对象的String()
方法返回的结果打印:
type UpperString string func (s UpperString) String() string { return strings.ToUpper(string(s)) } type fmt.Stringer interface { String() string } func main() { fmt.Fprintln(os.Stdout, UpperString("hello, world")) }
Go语言中,对于基础类型(非接口类型)不支持隐式的转换,我们无法将一个int
类型的值直接赋值给int64
类型的变量,也无法将int
类型的值赋值给底层是int
类型的新定义具名变量。Go语言对基础类型的类型一致性要求可谓是非常严格,但是Go语言对于接口类型的转换则非常灵活。对象和接口之间的转换、接口和接口之间的转换都可能是隐式的转换。可以看下面的例子:
var ( a io.ReadCloser = (*os.File)(f) // 隐式转换,*os.File满足io.ReadCloser接口 b io.Reader = a // 隐式转换,io.ReadCloser满足io.Reader接口 c io.Closer = a // 隐式转换,io.ReadCloser满足io.Closer接口 d io.Reader = c.(io.Reader) // 显式转换,io.Closer不满足io.Reader接口 )
有时候对象和接口之间太灵活了,需要人为地限制这种无意之间的适配。常见的做法是定义一个特殊方法来区分接口。例如,runtime
包中的Error
接口就定义了一个特有的RuntimeError
()
方法,用于避免其他类型无意中适配该接口:
type runtime.Error interface { error // RuntimeError is a no-op function but // serves to distinguish types that are run time // errors from ordinary errors: a type is a // run time error if it has a RuntimeError method. RuntimeError() }
在Protobuf中,Message
接口也采用了类似的方法,定义了一个特有的ProtoMessage
()
方法,用于避免其他类型无意中适配该接口:
type proto.Message interface { Reset() String() string ProtoMessage() }
不过这种做法只是“君子协定”,如果有人故意伪造一个proto.Message
接口也是很容易的。再严格一点的做法是给接口定义一个私有方法。只有满足了这个私有方法的对象才可能满足这个接口,而私有方法的名字是包含包的绝对路径名的,因此只有在包内部实现这个私有方法才能满足这个接口。测试包中的testing.TB
接口就是采用类似的技术:
type testing.TB interface { Error(args ...interface{}) Errorf(format string, args ...interface{}) ... // A private method to prevent users implementing the // interface and so future additions to it will not // violate Go 1 compatibility. private() }
不过这种通过私有方法禁止外部对象实现接口的做法也是有代价的:首先,这个接口只能在包内部使用,外部包在正常情况下是无法直接创建满足该接口的对象的;其次,这种防护措施也不是绝对的,恶意用户依然可以绕过这种保护机制。
1.4.2节中讲到,通过在结构体中嵌入匿名类型成员,可以继承匿名类型的方法。其实这个被嵌入的匿名成员不一定是普通类型,也可以是接口类型。我们可以通过嵌入匿名的testing.TB
接口来伪造私有
方法,因为接口方法是延迟绑定,所以编译时私有
方法是否真的存在并不重要。
package main import ( "fmt" "testing" ) type TB struct { testing.TB } func (p *TB) Fatal(args ...interface{}) { fmt.Println("TB.Fatal disabled!") } func main() { var tb testing.TB = new(TB) tb.Fatal("Hello, playground") }
我们在自己的TB
结构体类型中重新实现了Fatal()
方法,然后通过将对象隐式转换为testing.TB
接口类型(因为内嵌了匿名的testing.TB
对象,所以是满足testing.TB
接口的),再通过testing.TB
接口来调用自己的Fatal()
方法。
这种通过嵌入匿名接口或嵌入匿名指针对象来实现继承的做法其实是一种纯虚继承,继承的只是接口指定的规范,真正的实现在运行时才被注入。例如,可以模拟实现一个gRPC的插件:
type grpcPlugin struct { *generator.Generator } func (p *grpcPlugin) Name() string { return "grpc" } func (p *grpcPlugin) Init(g *generator.Generator) { p.Generator = g } func (p *grpcPlugin) GenerateImports(file *generator.FileDescriptor) { if len(file.Service) == 0 { return } p.P(`import "google.golang.org/grpc"`) // ... }
构造的grpcPlugin
类型对象必须满足generate.Plugin
接口:
type Plugin interface { // Name identifies the plugin. Name() string // Init is called once after data structures are built but before // code generation begins. Init(g *Generator) // Generate produces the code generated by the plugin for this file, // except for the imports, by calling the generator's methods // P, In, and Out. Generate(file *FileDescriptor) // GenerateImports produces the import declarations for this file. // It is called after Generate. GenerateImports(file *FileDescriptor) }
而
generate.Plugin
接口对应的grpcPlugin
类型的GenerateImports
()
方法中使用的p.P(...)
函数却是通过Init()
函数注入的generator.Generator
对象实现的。这里的generator.Generator
对应一个具体类型,如果generator.Generator
是接口类型,我们甚至可以传入真正的实现。
Go语言通过几种简单特性的组合,就轻易实现了鸭子型的面向对象和虚拟继承等高级特性,真的是不可思议。
在早期,CPU都是以单核的形式顺序执行机器指令。Go语言的祖先C语言正是这种顺序编程语言的代表。顺序编程语言中的顺序是指:所有的指令都是以串行的方式执行,在相同的时刻有且仅有一个CPU在顺序执行程序的指令。
随着处理器技术的发展,单核时代以提升处理器频率来提高运行效率的方式遇到了瓶颈,目前各种主流的CPU频率基本被锁定在了3 GHz附近。单核CPU发展的停滞为多核CPU的发展带来了机遇。相应地,编程语言也开始逐步向并行化的方向发展。Go语言正是在多核和网络化的时代背景下诞生的原生支持并发的编程语言。
常见的并发编程有多种模型,主要有多线程并发编程模型、消息传递并发编程模型等。从理论上看,多线程并发编程模型和消息传递并发编程模型是等价的。由于多线程可以自然对应到多核的处理器,因此主流的操作系统都提供了系统级的多线程支持,同时从概念上讲,多线程似乎也更直观,因此多线程并发编程模型逐步被吸纳到主流的编程语言特性或语言扩展库中。而主流编程语言对消息传递并发编程模型的支持相对较少,Erlang语言是支持消息传递并发编程模型的代表者,它的并发体之间不共享内存。Go语言是消息传递并发编程模型的集大成者,它将基于CSP模型的并发编程内置到了语言中,通过一个go
关键字就可以轻易地启动一个goroutine,与Erlang不同的是,Go语言的goroutine之间是共享内存的。
goroutine是Go语言特有的并发体,是一种轻量级的线程,由go
关键字启动。在真实的Go语言实现中,goroutine和系统线程不是等价的。尽管两者的区别实际上只是一个量的区别,但正是这个量变引发了Go语言并发编程质的飞跃。
每个系统线程都会有一个固定大小的栈(一般默认是2 MB),这个栈主要用来保存函数递归调用时的参数和局部变量。固定栈的大小导致了两个问题:对于很多只需要小的栈空间的线程,这是一种巨大的浪费;对于少数需要巨大栈空间的线程,这又增大了栈溢出的风险。针对这两个问题,要么减小固定的栈大小,提升空间的利用率,要么增大栈的大小以允许更深的函数递归调用,但这两者是无法兼得的。而goroutine可以以一个很小的栈启动(可能是2 KB或4 KB),当遇到深度递归导致当前栈空间不足时,goroutine会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到1 GB)。因为启动的代价很小,所以我们可以轻易地启动成千上万个goroutine。
Go语言的运行时还包含了其自己的调度器,这个调度器使用了一些技术手段,可以在n个操作系统线程上多工调度m个goroutine。Go调度器的工作原理和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的goroutine。在Go 1.14之前,goroutine采用的是半抢占式的协作调度,只有在当前goroutine发生阻塞时才会导致调度;调度发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。Go 1.14开始支持goroutine异步抢占调度,通过操作系统的信号机制让运行goroutine的底层系统线程进入休眠模式,从而完成调度工作。此外,运行时有一个runtime.GOMAXPROCS
变量,用于控制当前正常非阻塞运行goroutine的系统线程数目。
在Go语言中启动一个goroutine不仅和调用函数一样简单,而且在goroutine之间调度代价也很低,这些因素极大地促进了并发编程的流行和发展。
所谓的原子操作就是并发编程中“最小的且不可并行化”的操作。通常,如果多个并发体对同一个共享资源进行的操作是原子操作,那么同一时刻最多只能有一个并发体对该资源进行操作。从线程角度看,在当前线程修改共享资源期间,其他线程是不能访问该资源的。原子操作对多线程并发编程模型来说,不会发生有别于单线程的意外情况,共享资源的完整性可以得到保证。
一般情况下,原子操作都是通过“互斥”访问来保证的,通常由特殊的CPU指令提供保护。当然,如果只是想模拟粗粒度的原子操作,可以借助sync.Mutex
来实现:
import ( "sync" ) var total struct { sync.Mutex value int } func worker(wg *sync.WaitGroup) { defer wg.Done() for i := 0; i <= 100; i++ { total.Lock() total.value += i total.Unlock() } } func main() { var wg sync.WaitGroup wg.Add(2) go worker(&wg) go worker(&wg) wg.Wait() fmt.Println(total.value) }
在worker
的迭代中,为了保证total.value += i
的原子性,我们通过sync.Mutex
加锁和解锁来保证该语句在同一时刻只被一个线程访问。对多线程并发编程模型的程序而言,进出临界区前后进行加锁和解锁都是必需的。如果没有锁的保护,total
的最终值将由于多线程之间的竞争而可能不正确。
用互斥锁来保护一个数值型的共享资源麻烦且效率低下。标准库的sync/atomic
包对原子操作提供了丰富的支持。我们可以重新实现上面的例子:
import ( "sync" "sync/atomic" ) var total uint64 func worker(wg *sync.WaitGroup) { defer wg.Done() var i uint64 for i = 0; i <= 100; i++ { atomic.AddUint64(&total, i) } } func main() { var wg sync.WaitGroup wg.Add(2) go worker(&wg) go worker(&wg) wg.Wait() }
atomic.
AddUint
64()
函数调用保证了total
的读取、更新和保存是一个原子操作,因此在多线程中访问也是安全的。
原子操作配合互斥锁可以实现非常高效的单件模式(singleton pattern)。互斥锁的代价比普通整数的原子读写高很多,在性能敏感的地方可以增加一个数值型的标志位,通过原子检测标志位状态降低互斥锁的使用次数来提高性能。
type singleton struct {} var ( instance *singleton initialized uint32 mu sync.Mutex ) func Instance() *singleton { if atomic.LoadUint32(&initialized) == 1 { return instance } mu.Lock() defer mu.Unlock() if instance == nil { defer atomic.StoreUint32(&initialized, 1) instance = &singleton{} } return instance }
我们将通用的代码提取出来,就成了标准库中sync.Once
的实现:
type Once struct { m Mutex done uint32 } func (o *Once) Do(f func()) { if atomic.LoadUint32(&o.done) == 1 { return } o.m.Lock() defer o.m.Unlock() if o.done == 0 { defer atomic.StoreUint32(&o.done, 1) f() } }
基于sync.Once
重新实现单件模式:
var ( instance *singleton once sync.Once ) func Instance() *singleton { once.Do(func() { instance = &singleton{} }) return instance }
sync/atomic
包对基本数值类型及复杂对象的读写都提供了原子操作的支持。atomic. Value
原子对象提供了Load()
和Store()
两个原子方法,分别用于加载和保存数据,返回值和参数都是interface{}
类型,因此可以用于任意自定义复杂类型。
var config atomic.Value // 保存当前配置信息 // 初始化配置信息 config.Store(loadConfig()) // 启动一个后台线程,加载更新后的配置信息 go func() { for { time.Sleep(time.Second) config.Store(loadConfig()) } }() // 用于处理请求的工作者线程始终采用最新的配置信息 for i := 0; i < 10; i++ { go func() { for r := range requests() { c := config.Load() // ... } }() }
这是一个简化的生产者-消费者模型:后台线程生成最新的配置信息;前台多个工作者线程获取最新的配置信息。所有线程共享配置信息资源。
如果只是想简单地在线程之间进行数据同步的话,原子操作已经为编程人员提供了一些同步保障。不过这种保障有一个前提:顺序一致性内存模型。要了解顺序一致性,先看一个简单的例子:
var a string var done bool func setup() { a = "hello, world" done = true } func main() { go setup() for !done {} print(a) }
我们创建了setup
线程,用于对字符串a
的初始化工作,初始化完成之后设置done
标志为true
。main()
函数所在的主线程中,通过for !done {}
检测done
变为true
时,认为字符串初始化工作完成,然后进行字符串的打印工作。
但是,Go语言并不保证在main()
函数中观测到的对done
的写入操作发生在对字符串a
的写入操作之后,因此程序很可能打印一个空字符串。更糟糕的是,因为两个线程之间没有同步事件,setup
线程对done
的写入操作甚至无法被main
线程看到,main()
函数有可能陷入死循环中。
在Go语言中,同一个goroutine内部,顺序一致性内存模型是得到保证的。但是不同的goroutine之间,并不满足顺序一致性内存模型,需要通过明确定义的同步事件来作为同步的参考。如果两个事件不可排序,那么就说这两个事件是并发的。为了最大化并行,Go语言的编译器和处理器在不影响上述规定的前提下可能会对执行语句重新排序(CPU也会对一些指令进行乱序执行)。
因此,如果在一个goroutine中顺序执行a = 1;
和b = 2;
这
两个语句,虽然在当前的goroutine中可以认为a = 1;
语句先于b = 2;
语句执行,但是在另一个goroutine中b = 2;
语句可能会先于a = 1;
语句执行,甚至无法看到它们的变化(可能始终在寄存器中)。也就是说,在另一个goroutine看来,a = 1;
和b = 2;
这
两个语句的执行顺序是不确定的。如果一个并发程序无法确定事件的顺序关系,那么程序的运行结果往往会不确定。例如,下面这个程序:
func main() { go println("你好, 世界") }
根据Go语言规范,main()
函数退出时程序结束,不会等待任何后台线程。但因为goroutine的执行和main()
函数的返回事件是并发的,谁都有可能先发生,所以什么时候打印、能否打印都是未知的。
用前面的原子操作并不能解决问题,因为我们无法确定两个原子操作之间的顺序。解决问题的办法就是通过同步原语来给两个事件明确排序:
func main() { done := make(chan int) go func(){ println("你好, 世界") done <- 1 }() <-done }
当<-done
执行时,必然要求done <- 1
也已经执行。根据同一个goroutine依然满足顺序一致性规则,可以判断当done <- 1
执行时,println
("
你好
,
世界
")
语句必然已经执行完成了。因此,现在的程序确保可以正常打印结果。
当然,通过sync.Mutex
互斥量也是可以实现同步的:
func main() { var mu sync.Mutex mu.Lock() go func(){ println("你好, 世界") mu.Unlock() }() mu.Lock() }
可以确定,后台线程的mu.Unlock()
必然在println
("
你好
,
世界
")
完成后发生(同一个线程满足顺序一致性),main()
函数的第二个mu.Lock()
必然在后台线程的mu.Unlock()
之后发生(sync.Mutex
保证),此时后台线程的打印工作已经顺利完成了。
Go程序的初始化和执行总是从main.main()
函数开始的。但是如果main
包里导入了其他包,则会按照顺序将它们包含到main
包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入,那么在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其他包,则先将其他包都包含进来,然后创建和初始化这个包的常量和变量。再调用包里的init()
函数,如果一个包有多个init()
函数,实现可能是以文件名的顺序调用,那么同一个文件内的多个init()
是以出现的顺序依次调用的(init()
不是普通函数,可以定义多个,但是不能被其他函数调用)。最终,在main
包的所有包常量、包变量被创建和初始化,并且只有在init()
函数被执行后,才会进入main.main()
函数,程序开始正常执行。图1-10展示了Go程序启动时的包初始化流程。
图1-10 Go程序启动时的包初始化流程
要注意的是,在main.main()
函数执行之前,所有代码都运行在同一个goroutine中,也是运行在程序的主系统线程中。如果某个init()
函数内部用go
关键字启动了新的goroutine,那么新的goroutine和main.main()
函数是并发执行的。
因为所有的init()
函数和main()
函数都是在主线程完成,它们也满足顺序一致性模型。
go
语句会在当前goroutine对应函数返回前创建新的goroutine。例如:
var a string func f() { print(a) } func hello() { a = "hello, world" go f() }
执行go f()
语句创建goroutine和hello()
函数是在同一个goroutine中执行,根据语句的书写顺序可以确定goroutine的创建发生在hello()
函数返回之前,但是新创建goroutine对应的f()
的执行事件和hello()
函数返回的事件则是不可排序的,也就是并发的。调用hello()
可能会在将来的某一时刻打印“hello, world”,也很可能是在hello()
函数执行完成后才打印。
通道(channel)是在goroutine之间进行同步的主要方法。在无缓存的通道上的每一次发送操作都有与其对应的接收操作,发送操作和接收操作通常发生在不同的goroutine中(在同一个goroutine中执行两个操作很容易导致死锁)。无缓存的通道上的发送操作总在对应的接收操作完成前发生。
var done = make(chan bool) var msg string func aGoroutine() { msg = "你好, 世界" done <- true } func main() { go aGoroutine() <-done println(msg) }
以上程序可保证打印出“你好, 世界”。该程序首先对msg
进行写入,然后在done
通道上发送同步信号,随后从done
接收对应的同步信号,最后执行println
()
函数。
若在关闭通道后继续从中接收数据,接收者就会收到该通道返回的零值。因此在这个例子中,用close(done)
关闭通道代替done <- true
依然能保证该程序产生相同的行为。
var done = make(chan bool) var msg string func aGoroutine() { msg = "你好, 世界" close(done) } func main() { go aGoroutine() <-done println(msg) }
根据Go语言内存模型规范,对于从无缓存的通道进行的接收,发生在对该通道进行的发送完成之前。基于上面这个规则可知,交换两个goroutine中的接收操作和发送操作也是可以的(但是很危险):
var done = make(chan bool) var msg string func aGoroutine() { msg = "hello, world" <-done } func main() { go aGoroutine() done <- true println(msg) }
这样也可保证打印出“hello, world”。因为main
线程中done <- true
发送完成前后台线程<-done
接收已经开始(这保证msg = "hello, world"
被执行了),所以之后println
(msg)
的msg
已经被赋过值了。简而言之,后台线程首先对msg
进行写入,然后从done
中接收信号,随后main
线程向done
发送对应的信号,最后执行println
()
函数完成。但是,若该通道为带缓存的(如done = make(chan bool, 1)
),main
线程的done <- true
接收操作将不会被后台线程的<-done
接收操作阻塞,该程序将无法保证打印出“hello, world”。
对于带缓存的通道,对通道中的第K个接收操作发生在第K+C个发送操作完成之前,其中C是通道的缓存大小。如果将C
设置为0,自然就对应无缓存的通道,也就是第K个接收操作在第K个发送操作完成之前。因为无缓存的通道只能同步发1个,所以也就简化为前面无缓存通道的规则:对于从无缓存的通道进行的接收,发生在对该通道进行的发送完成之前。
我们可以根据控制通道的缓存大小来控制并发执行的goroutine的最大数目,例如:
var limit = make(chan int, 3) var work = []func(){ func() { println("1"); time.Sleep(1 * time.Second) }, func() { println("2"); time.Sleep(1 * time.Second) }, func() { println("3"); time.Sleep(1 * time.Second) }, func() { println("4"); time.Sleep(1 * time.Second) }, func() { println("5"); time.Sleep(1 * time.Second) }, } func main() { for _, w := range work { go func(w func()) { limit <- 1 w() <-limit }(w) } select{} }
在循环创建goroutine过程中,使用了匿名函数并在函数中引用了循环变量w
,由于w
是传引用的而非传值的,因此无法保证goroutine在运行时调用的w
与循环创建时的w
是同一个值。为了解决这个问题,可以利用函数传递参数的值副本来为每个goroutine单独复制一份w
。
最后的select{}
是一个空的通道选择语句,该语句会导致main
线程阻塞,从而避免程序过早退出。还有for{}
、<-make(chan int)
等诸多方法可以达到类似的效果。因为main
线程被阻塞了,如果需要程序正常退出,可以调用os.Exit(0)
。
前面我们已经分析过,下面代码无法保证正常打印结果,实际的运行也大概率不能正常输出结果。
func main() { go println("你好, 世界") }
如果刚接触Go语言,可能希望通过加入一个随机的休眠时间来保证正常的输出:
func main() { go println("hello, world") time.Sleep(time.Second) }
因为主线程休眠了1秒,所以这个程序大概率是可以正常输出结果的。因此,很多人会觉得这个程序已经没有问题了。但是这个程序是不稳健的,依然有失败的可能。我们先假设程序是可以稳定输出结果的。因为Go线程的启动是非阻塞的,main
线程显式休眠了1秒退出导致程序结束,我们可以近似地认为程序总共执行了1秒多。现在假设println
()
函数内部实现休眠的时间大于main
线程休眠的时间,这样就会导致矛盾:后台线程既然先于main
线程完成打印,那么执行时间肯定是小于main
线程执行时间的。当然这是不可能的。
严谨的并发程序的正确性不应该依赖于CPU的执行速度和休眠时间等不靠谱的因素。严谨的并发应该是可以静态推导出结果的:根据线程内顺序一致性,结合通道或sync
事件的可排序性来推导,最终完成各个线程各段代码的偏序关系排序。如果两个事件无法根据此规则来排序,那么它们就是并发的,也就是执行先后顺序不可靠的。
解决同步问题的思路是相同的:使用显式的同步。
其实Go 2的开发从Go 1.10逐步启动,到Go 1.18主要的泛型特性落地。泛型的使用教程已经很多,这里从几个侧面谈谈泛型。
泛型的价值主要在几个方面:一是简化一些相似代码的生成;二是为一些通用容器提供类型安全的API;三是为优化提供了更高的天花板。但是,泛型的代价也是明显的:设计复杂,实现复杂,用户心智负担大。
现在看一个Go语言的泛型的例子:
func main() { s := []int{1, 3, 5, 2, 4} fmt.Println(index(s, 3)) fmt.Println(index(s, 6)) } func index[E comparable](s []E, v E) int { for i, vs := range s { if v == vs { return i } } return -1 }
其中,index
就是一个泛型函数,可以针对不同类型的切片做索引查询操作。[E comparable]
是在编译阶段定义一个可以被比较的comparable
的类型E
,然后基于E
定义切片和要查询的元素的类型。
在main()
函数调用index()
函数的时候,Go语言的泛型编译器会自动进行类型推导得到泛型函数需要的参数类型信息。如果是Go 1.17之前的版本,可以通过空接口实现类似的函数:
func indexGo17(list, value interface{}) int { sv := reflect.ValueOf(list) if sv.Kind() != reflect.Slice { panic(fmt.Sprintf("called with non-slice value of type %T", list)) } if reflect.ValueOf(value).Kind() != sv.Type().Elem().Kind() { panic("type(value) != type(list[_])") } for i := 0; i < sv.Len(); i++ { if reflect.DeepEqual(sv.Index(i).Interface(), value) { return i } } return -1 }
因为是在运行时才能检查切片和对应值的类型,所以可能会出现运行时panic的情况,而泛型则可以在编译阶段给出panic对应的错误(运行时同时依赖执行路径,编译时则没有函数执行路径的问题)。
在前面的例子中出现了comparable
类型,它是针对泛型新加的内置接口类型。查看comparable
接口类型的文档,其中详细说明了它的功能:
$ go doc builtin.comparable package builtin // import "builtin" type comparable interface{ comparable } comparable is an interface that is implemented by all comparable types (booleans, numbers, strings, pointers, channels, arrays of comparable types, structs whose fields are all comparable types). The comparable interface may only be used as a type parameter constraint, not as the type of a variable.
comparable
表示可比较类型的集合。但是,传统Go语言接口只能通过方法集来筛选类型集合,无法做到对某些不涉及方法的类型的集合定义,因此Go泛型设计在接口语法的基础之上扩展了具体类型的定义语法。例如,interface{ int|string|bool }
定义了3种类型的集合。
可以说,泛型是类似静态的接口,可以将某些运行时的类型错误检查提前到编译阶段(并且不依赖执行路径),而其他性能的差别主要是和实现优化有关;也可以说,泛型是接口在编译时的形态,如果将运行时接口和编译时接口看作是二维坐标系的x轴和y轴,那么x轴和y轴的交点则是interface{}
空接口,也就是很多语言的any
类型。如果从静态接口和动态接口的交点any
回推,那么是否存在一种编译时的接口呢?编译时的接口的使用场景是什么?
例如,Point2D
和Point3D
两种类型都实现了Clone()
方法,代码如下:
type Point2D struct { X, Y int } type Point3D struct { X, Y, Z int } func (p Point2D) Clone() Point2D { return p } func (p Point3D) Clone() Point3D { return p }
那么,如何为这两种类型的Clone()
方法抽象出一个接口?在Go 1.18之前没有支持泛型的版本中是不行的,因为Clone()
方法的返回值类型不同。但是,通过泛型接口则可以这样定义:
type Cloner[Self any] interface { Clone() Self }
Cloner
接口引入了一个Self
编译时参数类型,作为Clone()
方法的返回值。因此可以这样使用:
func main() { var c1 Cloner[Point2D] = Point2D{1, 2} var c2 Cloner[Point3D] = Point3D{4, 5, 6} fmt.Printf("c1 type: %T\n", c1) // c1 type: main.Point2D fmt.Printf("c2 type: %T\n", c2) // c2 type: main.Point3D }
c1
和c2
是分别采用Point2D
和Point3D
类型特化的Cloner
接口,在运行时对应的是不同的类型。这种静态接口在运行时是一个具体类型,并不是传统意义上的Go语言运行时接口。这种静态接口其实是给泛型的类型约束使用的,具体看下面的例子:
func DoClone[T Cloner[T]](c T) T { return c.Clone() } func main() { var c1 Cloner[Point2D] = Point2D{1, 2} var c2 Cloner[Point3D] = Point3D{1, 2, 3} fmt.Printf("%[1]T: %+[1]v\n", DoClone(Point2D{1, 2})) fmt.Printf("%[1]T: %+[1]v\n", DoClone(c1.(Point2D))) fmt.Printf("%[1]T: %+[1]v\n", DoClone(Point3D{1, 2, 3})) fmt.Printf("%[1]T: %+[1]v\n", DoClone(c2.(Point3D))) }
DoClone
泛型函数的参数是T
类型,T
类型满足Cloner
[T]
静态接口约束,必须有Clone()
方法。因此Point2D{1, 2}
字面值满足DoClone
()
函数的参数类型约束。而c1
对应静态接口类型,必须通过静态类型断言转换为Point2D
类型才可以。
如果直接用c1
作为参数调用DoClone
(c1)
,将产生以下错误:
T (type Cloner[Point2D]) does not satisfy Cloner[T] (wrong type for method Clone) have Clone() Point2D want Clone() T
简单来理解,c1
是Cloner
接口,Cloner
接口中只定义了Clone() T
,而Cloner
[Point2D]
特化后接口定义的Clone() Point2D
方法与Clone() T
不匹配。
DoClone
的静态类型还可以写到函数参数类型位置:
func DoCloneV2[T any](c Cloner[T]) T { return c.Clone() }
DoClone
()
函数类型为func
(main.Point2D) main.Point2D
,DoCloneV2
函数类型为func
(main.
Cloner
[main.Point2D]) main.Point2D
。对于正常代码都可以通过编译,只是错误代码的提示会有区别,例如一个缺少Clone()
方法的Point4D
类型值作为参数,在Go 1.21中DoClone
的错误提示为“T (type Point4D) does not satisfy Cloner[T] (missing method Clone)”,而DoCloneV
2
的错误提示为“type Point4D of Point4D{…} does not match Cloner[T] (cannot infer T)”。
从输入参数似乎看不出这种差异,我们可以将返回值也做一些调整:
func DoCloneV3[T any](c Cloner[T]) Cloner[T] { return c.Clone() }
但是,这个函数编译会出现下面的错误提示:“cannot use c.Clone() (value of type T constrained by any) as Cloner[T] value in return statement”。虽然输入的c
参数满足了Cloner
[T]
,但是Clone()
方法返回的T
并不能保证满足Cloner
[T]
。
因此,我们再构建一个DoCloneV4
:
func DoCloneV4[T Cloner[T]](c T) Cloner[T] { return c.Clone() }
现在可以编译了:泛型参数T
满足Cloner
[T]
,因此函数参数T
类型也必须满足Cloner
[T]
;而返回值c.Clone()
返回的T
类型也满足Cloner
[T]
,因此返回值类型也可以写成Cloner
[T]
。
那么DoCloneV
4
函数在运行时返回值是什么类型呢?返回的结果是什么类型呢?构造以下代码:
func main() { var f4 = DoCloneV4[Point2D] fmt.Printf("f4 %T\n", f4) // f4 func(main.Point2D) main.Cloner[main.Point2D] var x4 = DoCloneV4(Point2D{4,4}) fmt.Printf("x4 %T\n", x4) // x4 main.Point2D }
测试发现,DoCloneV
4
泛型函数特化后的返回值类型为main.
Cloner
[main.Point2D]
,但是其调用的返回结果类型却是main.Point2D
。那么在运行时两者是什么关系?有何区别?其实x4
返回的就是Point2D.Clone()
方法返回的Point2D
类型。
前者有点类似于无类型的字面值常量的类型,后者是运行时绑定到具体变量的类型。当具体的值绑定到某个变量就必然被特化到具体类型。例如:
func main() { var c1 Cloner[Point2D] = Point2D{1, 2} fmt.Printf("c1 %T\n", c1) // c1 main.Point2D }
c1
对应的类型Cloner
[Point2D]
是编译时的约束,在运行时DoCloneV
4
泛型函数也可以返回该类型的结果,但是结果依然是编译时的约束。而Point2D{1, 2}
字
面值和Point2D.Clone()
方法返回的都是Point2D
类型的值,因此在运行时看到的c1
是Point2D
类型。
读者可以思考是否有办法在运行时构造出Cloner
[Point2D]
类型的变量。
Go语言诞生时,方法和全局函数本质上是一样的。例如,有一个自定义的打印机对象MyPrinter
:
type MyPrinter struct{}
我们再给MyPrinter
定义一个Print()
方法,以及一个等价的全局MyPrinter
_Print()
函数:
func (p *MyPrinter) Print(v any) {} func MyPrinter_Print(p *MyPrinter, v any) {}
可以通过以下代码验证Print()
方法和MyPrinter
_Print()
全局函数底层是等价的:
func main() { f := MyPrinter_Print[any] f = (*MyPrinter).Print _ = f }
在Go 1.18引入泛型之后,我们自然会想将Print()
方法和MyPrinter
_Print()
全局函数改成泛型版本:
func (p *MyPrinter) Print[T any](v T) {} func MyPrinter_Print[T any](p *MyPrinter, v T) {}
不过,以上泛型版本的方法编译时出现了以下错误(方法不支持类型参数):
syntax error: method must have no type parameters
因为泛型的引入,这里出现了一个违反直觉的用法:在加泛型前,方法与全局函数等价或者具有相似的语法规则,但是加了泛型后方法就变成了“二等公民”!
如果单从全局函数支持泛型的类型参数开始推导,Go语言的方法自然也应该支持泛型,而且这个特性在编译时可以和谐存在。但是Go 1最开始是围绕接口设计的运行时泛型,而接口通过在运行时判断规则与静态方法集形成完全正交的设计。
在引入泛型后,Go语言选择了通过扩展接口的语法来实现编译时的类型参数约束。例如:
type IPinter[T any] interface { Print(v T) } func MyPrint[T IPinter[T]](v T) { v.Print(v) }
泛型工作在编译时阶段,MyPrint
全局函数的T
类型通过IPinter
[T]
来约束。接口要求T
类型必须带有一个Print()
方法,而该方法的参数T
也是类型参数。
针对不同类型特化的IPinter
接口(如IPinter
[int]
和
IPinter
[float32]
)会产生两个不同签名的Print()
方法,它们的返回值参数分别是int
和float32
,从运行时接口看,这两个是不同的方法。根据目前Go语言接口的运行机制,同一个方法名只能有一个确定的函数签名。这导致了接口在编译时和运行时语义的矛盾,这也是泛型引入带来的核心设计哲学的分裂。
为了避免泛型接口的方法在编译时和运行时分裂(接口的方法因为T
类型参数导致了签名的差异),Go语言在方法的定义一侧禁止了泛型的类型参数特性,与此同时也象征性地禁止了接口中方法的类型参数。方法和接口分别代表编译时和运行时两端,Go语言通过微妙的部分禁止方法泛型和扩展接口的部分泛型特性,来避免在编译时类型约束和运行时方法同时出现泛型参数而引起的语义矛盾。
Go语言的方法不支持泛型参数是为了适配接口不能在运行时支持静态泛型的妥协。此外,在泛型类型的参数类型约束中,无法通过接口明确泛型方法的类型参数,这将导致泛型函数中无法静态使用泛型的方法。因此,泛型方法只能在它所依附的类型彻底特化之后被调用,这将是一个比较孤立的特性。可能是两个因素最终影响了设计,方法的泛型也最终被禁止。
泛型可以提高代码的可复用性,但它也引入了一些的新复杂性和潜在的问题,对Go语言原本相对和谐、正交的设计产生了根本性的破坏。
Go语言的泛型除了因功能相对较弱(如内置运算符无法被重载导致很多自定义类型与内置类型的代码风格不一致)不被看好外,其采用方括号来表示参数的方式也受到一些批评,因为这和C++、Java等主流编程语言的泛型参数用尖括号表示习惯不同。官方解释了这样设计的原因:用方括号表示泛型参数可以简化编译器中语法分析器的编写。例如,在C++中,老版本的编译器不能直接处理std::vector<std::vector <int>>
,因为C++编译器不能识别右边的>>
符号。尽管这确实是一个正当的理由,并且有C++反面案例为证,但我更愿意相信Go语言用方括号作为泛型语法是经过深思熟虑的,因为使用方括号也可能带来其他复杂性。例如,m[x]
可能不是一个切片取元素的表达式,而是一个泛型的类型参数。如果对比Go语言的前辈Limbo的前身Alef语言,可以发现Alef语言也是用方括号表示泛型参数:
adt Stack[T] { int tos; T data[100]; void push(*Stack, T); T pop(*Stack) }
其中,adt
关键字用于定义新的抽象类型(与C++的struct
关键字
类似),然后用类型参数T
实例化Stack
泛型容器,这里就是用方括号。方括号作为泛型参数的使用正在成为主流,Google新推出的Carbon语言也是用方括号表示泛型参数。
Go 1.24版本的strings
包增加了5个新方法,它们都与自定义迭代器有关。自定义迭代器是Go 1.23引入的特性,用于支持用户自定义迭代器并通过for range
循环来
迭代。本节将简单介绍自定义迭代器的用法和原理。
下面以分割字符串的strings.Split()
为例,基于Go 1.24版本,添加基于迭代器版本的strings.
SplitSeq
()
。这两个函数的输入参数相同,区别仅在于返回值:前者返回一个字符串切片,后者返回迭代器。两个函数的签名如下:
package strings // import "strings"
func Split(s, sep string) []string
func SplitSeq(s, sep string) iter.Seq[string]
它们的用法也很类似,都支持for range
循环迭代:
package main
import (
"fmt"
"strings"
)
func main() {
for _, s := range strings.Split("abc,123,456", ",") {
fmt.Println(s)
}
fmt.Println("---")
for s := range strings.SplitSeq("abc,123,456", ",") {
fmt.Println(s)
}
}
在这段代码中,第一个for range
迭代的是切片的返回值,因此如果只有一个循环变量,则应是切片元素的索引下标,如果仅读取切片元素,则需要两个循环变量,其中第一个是下划线占位符;第二个for range
只有一个循环变量(就是元素),这与Python的迭代习惯比较相似。
这两种循环迭代主要有两处差别:一是切片的仅元素迭代中存在索引下标的语法噪声;二是第一种循环迭代需要返回一个切片,这需要在迭代中额外为临时切片分配空间,但这并非自定义迭代器的核心优势。自定义迭代器的本质是基于迭代器的循环是可以自定义的,可以被定义为最适合用户场景的迭代方式。
strings.
SplitSeq
()
函数的返回值是iter.
Seq
[string]
泛型类型,该类型是在Go 1.23中引入的iter
包定义的。查看iter.
Seq
类型的文档,其中详细说明了它的用法:
$ go doc iter.Seq package iter // import "iter" type Seq[V any] func(yield func(V) bool) Seq is an iterator over sequences of individual values. When called as seq(yield), seq calls yield(v) for each value v in the sequence, stopping early if yield returns false. See the iter package documentation for more details.
因此,strings.
SplitSeq
()
函数的返回值类型展开后对应func
(yield
func
(string)bool)
闭包函数:
// SplitSeq returns an iterator over all substrings of s separated by sep. // The iterator yields the same strings that would be returned by [Split](s, sep), // but without constructing the slice. // It returns a single-use iterator. func SplitSeq(s, sep string) iter.Seq[string] { return splitSeq(s, sep, 0) } // splitSeq is SplitSeq or SplitAfterSeq, configured by how many // bytes of sep to include in the results (none or all). func splitSeq(s, sep string, sepSave int) iter.Seq[string] { return func(yield func(string) bool) { for { i := Index(s, sep) if i < 0 { break } frag := s[:i+sepSave] if !yield(frag) { return } s = s[i+len(sep):] } yield(s) } }
返回的迭代器其实是一个函数,它将要迭代的元素依次传递给一个名为yield
的回调函数。迭代器函数会在回调函数yield()
返回false
时提前终止,或者在迭代操作全部结束时终止。
前面已经讲过strings.
SplitSeq
()
如何返回一个迭代器函数,那么迭代器的原理是怎样的呢?其实,所有的魔法在回调函数yield()
,它是一个编译器帮助实现的函数,对应for range
循环体部分的语句。
例如,基于自定义迭代器的循环代码
func main() { for s := range strings.SplitSeq("abc,123,456", ",") { fmt.Println(s) } }
会被编译器转换为
func main() { strings.SplitSeq("abc,123,456", ",")(func(s string) bool { fmt.Println(s) return true }) }
然后继续用迭代器函数内联展开为
func main() { yield := func(s string) bool { fmt.Println(s) return true } s, sep := "abc,123,456", "," for { i := strings.Index(s, sep) if i < 0 { break } if !yield(s[:i]) { return } s = s[i+len(sep):] } yield(s) }
虽然这只是最简单的情形,但是可以展示自定义迭代器的基本原理,同时可以看出迭代器本身对泛型特性并无实质性的依赖。更复杂的情况可能涉及多层嵌套,以及包含continue
、break
和defer
等语句,具体的转换规则可以参考$GOROOT/src/cmd/compile/internal/rangefunc/rewrite.go文件。
Go 1.22中引入了一个语义变化,如果在构建时设置了GOEXPERIMENT=loopvar
,for range
将在每次迭代时产生新的循环变量。最直接的影响是以下被很多新手抱怨的代码:
func Print123() { var prints []func() for i := 1; i <= 3; i++ { prints = append(prints, func() { fmt.Println(i) }) } for _, print := range prints { print() } }
很多新手期望这段代码输出123,但是实际上输出了444。这其实是闭包函数捕获外部变量的正常行为,在JavaScript中也是类似的:
function main() { var funcs = []; for(var i = 0; i < 10; i++) { funcs.push(function() { return console.log(i); }) } for(let k = 0; k < 10; k++) { funcs[k](); } }
为了向新手妥协,JavaScript的ES6标准引入了补丁特性:在循环体内的每次迭代产生一个与循环变量同名的临时变量。Go语言引入类似的语义调整,似乎也有和JavaScript同样的原因。
但是,Go语言的loopvar
语义的调整更多的是为Go 1.24增加自定义迭代器扫清障碍。因为如果for range
每次迭代都对应迭代函数调用,自然会产生不同的参数变量。这种设计最大的代价就是破坏了Go 1的用法兼容性承诺。
本书定位为Go语言进阶图书,因此读者需要有一定的Go语言基础。如果读者对Go语言不太了解,推荐通过以下资料开始学习Go语言。学习Go语言需要先安装Go语言环境,再结合Golang官方网站提供的“A Tour of Go”教程学习。在学习“A Tour of Go”教程的同时,也可以阅读Go语言官方团队编写的《Go程序设计语言》(The Go Programming Language)一书。读者可以尝试一边学习一边用Go语言解决一些小问题,如果要查阅API,可以通过go doc
命令打开Go语言自带的文档查询。Go语言代码仓库不仅包含了所有的文档,也包含了所有标准库的实现代码,是最权威的第一手Go语言资料。