first commit
This commit is contained in:
146
专栏/TonyBai·Go语言第一课/00开篇词这样入门Go,才能少走弯路.md
Normal file
146
专栏/TonyBai·Go语言第一课/00开篇词这样入门Go,才能少走弯路.md
Normal file
@@ -0,0 +1,146 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 这样入门Go,才能少走弯路
|
||||
你好,我是白明(英文名:Tony Bai),欢迎你和我一起学习Go语言。
|
||||
|
||||
我现在在一家初创企业东软睿驰工作,是一名车联网平台的架构师,同时我也是技术博客tonybai.com的博主、GopherChina大会讲师。
|
||||
|
||||
从2011年开始我便关注了Go语言,是Go语言在国内的早期接纳者。那个时候,离Go开源还不过两年,没有人想到它会成长到今天这样,成为后端开发的主流语言之一。
|
||||
|
||||
在对Go长达十年的跟随和研究中,我沉淀了很多个人的经验和思考。我也希望通过这门课,跟你分享我学习和使用Go语言的一些心法。
|
||||
|
||||
我与Go的这十年
|
||||
|
||||
2011年,一次偶然的机会,我非常幸运地看到了Go语言之父Rob Pike的Go语言课程幻灯片。当时我正经受着C语言内存管理、线程调度和跨平台运行等问题的折磨,看到Go语言的语法清新简洁,还支持内存垃圾回收、原生支持并发,便一见钟情。
|
||||
|
||||
我是个对编程语言非常“挑剔”的人,这跟我从事的方向有关。十多年来,我一直在电信领域从事高并发、高性能、大容量的网关类平台服务端的开发,这两年也进入了智能网联汽车行业。由于长期从事后端服务开发,我涉猎过很多后端编程语言。
|
||||
|
||||
我曾深入研究过C++,短暂研究过Java、Ruby、Erlang、Haskell与Common Lisp,但都因为复杂度、耗资源、性能不够、不适用于大规模生产等种种原因放弃了。
|
||||
|
||||
而且,如果你对我所在的行业有所了解,你可能会知道,我参与开发的系统对性能十分敏感。我也曾长期使用C语言作为生产语言,同时使用Python开发各种辅助工具。但是,C语言的生产效率不高,各种陷阱也很多,而Python开发效率确实很高,但性能又不好。
|
||||
|
||||
难道就没有一门相对“完美”、符合我使用需求的编程语言吗?这个时候,Go来了。
|
||||
|
||||
但在我开始接触和学习Go的2011年,Go语言还未发布Go 1.0稳定版本,还处于“周发布(weekly release)”的状态。我还记得我接触的第一个Go版本还是release.r60,也就比Rob Pike的Go课程里使用的版本稍新一些。
|
||||
|
||||
但这个时候的Go版本,存在着很多不尽如人意的地方,尤其是GC延迟比较大,成为了Go语言上生产环境的最大障碍。
|
||||
|
||||
虽然Go早期版本无法上生产环境,但我却一直紧跟Go语言的演化进程。
|
||||
|
||||
从Go 1.4版本开始,每当Go发布一个大版本,我都撰文分析这个大版本中Go语言的主要变化。这一系列文章至今仍在继续,未来也将持续进行下去。
|
||||
|
||||
特别是在 Go 1.5版本实现自举、Go 1.11版本解决Go包依赖问题后,Go语言逐渐成熟,我也逐渐尝试在生产中使用Go。从开始替代Python编写一些辅助工具,到编写一些网关所需的网络协议,我发现Go都可以完美适用。
|
||||
|
||||
一直到近些年,Go替代了C、Python,成为了我的第一生产语言。我开始直接使用Go编写生产系统,诸如短信网关、5G消息网关、MQTT网关,还有API网关等等。事实证明,Go语言不仅生产效率高,其应用的执行效率也完全能满足要求。
|
||||
|
||||
从2019年开始,我将自己每天阅读到的Go社区的优秀技术资料整理成公开的Gopher日报供大家参阅。在国内Go社区中,Gopher日报得到了圈里许多同学的欢迎。
|
||||
|
||||
紧跟Go演进十年的我,已经将Go语言的点点滴滴深深地烙印在大脑中。
|
||||
|
||||
推荐你入坑Go的三大理由
|
||||
|
||||
如果说十年前的我是因为“一见钟情”瞬间入坑Go,那么在十年后的今天,我们应该做的是系统地、认真地思考一下为什么要选择学习Go。
|
||||
|
||||
我想了想,我会从这三个角度建议你现在开始学习Go语言。
|
||||
|
||||
第一个理由:对初学者足够友善,能够快速上手。
|
||||
|
||||
十多年来,业界都公认:Go是一种非常简单的语言。到底有多简单呢?在2011年,我从一个C语言程序员的身份开始学习Go,使用Rob Pike的Go教程,我在一天之内就学完了Go的全部语法,一周内就可以编写一些简单、实用,而且质量不低的小程序了。
|
||||
|
||||
而且,跟现在很多逐渐添加各种特性的语言相比,Go不仅一开始简单,直到现在也都保持“简单”。Go的设计者们在发布Go 1.0版本和兼容性规范后,似乎就把主要精力放在精心打磨Go的实现、改进语言周边工具链,还有提升Go开发者体验上了。演化了十多年,Go新增的语言特性也同样是“屈指可数”。
|
||||
|
||||
正因为如此,作为静态编程语言的Go已经将入门门槛,降低到和动态语言一个水平线上了。
|
||||
|
||||
第二个理由:生产力与性能的最佳结合。
|
||||
|
||||
Go的简单和对初学者的友善可以让更多的开发者走进Go语言大门,但要让更多开发者留在Go语言世界,Go还需体现出自己的核心竞争力。这个核心竞争力就是,Go语言是生产力与性能的最佳结合。
|
||||
|
||||
如果你熟悉的是静态语言,那你刚好就是Go最初的目标用户。Go创建的最初目的,就是构建流行的、高性能的服务器端编程语言,用以替代当时在这个领域使用较多的Java和C++。而且,Go也实现了它的这个目标。
|
||||
|
||||
Go语言的性能在带有GC和运行时的语言中名列前茅,与不带GC的静态编程语言(比如C/C++)之间也没有数量级的差距。在各大基准测试网站上,在相同的资源消耗水平的前提下,Go的性能虽然低于C++,但高出Java不少。
|
||||
|
||||
如果你熟悉的是动态语言,那也完全不用担心。Go的大部分早期采用者,就来自动态语言程序员群体,包括Python、JavaScript、Ruby和PHP等语言的使用群体。因为和动态语言相比,Go能够在保持生产力的同时,大幅度提高性能。比如,全球知名的非营利教育组织可汗学院从2019年末开始,就将其在线教育平台的实现从Python迁移到了Go。虽然Go代码行数要多于Python,但他们收获了近10倍的性能提升。
|
||||
|
||||
如果你立志或者已经上手云开发,那你就更应该马上开始学习Go语言。现在,Go已经成为了云基础架构语言,它在云原生基础设施、中间件与云服务领域大放异彩。同时,GO在DevOps/SRE、区块链、命令行交互程序(CLI)、Web服务,还有数据处理等方面也有大量拥趸,我们甚至可以看到Go在微控制器、机器人、游戏领域也有广泛应用。
|
||||
|
||||
第三个理由:快乐又有“钱景”。
|
||||
|
||||
Go最初的目标,就是重新为开发人员带来快乐。这个快乐来自哪里呢?相比C/C++,甚至是Java来说,Go在开发体验上的确有很大提升。笼统来说,这个提升来自于简单的语法、得心应手的工具链、丰富和健壮的标准库,还有生产力与性能的完美结合、免除内存管理的心智负担,对并发设计的原生支持等等。而这些良好的体验,恰恰是你写Go代码时的快乐源泉。
|
||||
|
||||
当然了,我相信你学习和使用Go肯定不是为了自嗨。运用Go体现自身价值,赢得理想职位才是最终目标。在十年后的今天,无论是在国内还是国外,无论是在大厂还是初创小公司,Go都有着广泛的应用。Go语言人才越来越抢手,对他们的争夺也日益激烈。
|
||||
|
||||
有报告表明,在腾讯、字节跳动、Uber等许多公司,Go都是极其受欢迎,在字节跳动、Uber内部甚至已经成长为主力语言。
|
||||
|
||||
更何况,相对于C、C++以及Java等主流语言,Go语言人才目前仍处于蓝海阶段。根据stackoverflow 2021调查报告的结果,仅考虑主流语言的话,Go语言平均薪水位于头部位置。
|
||||
|
||||
|
||||
|
||||
这还仅仅是以欧美开发人员调查数据为主的计算结果。在Go更加火爆的国内,Go的平均薪水水平位次可能还要更高。
|
||||
|
||||
怎样学才能少走弯路?
|
||||
|
||||
看到Go语言对初学者如此友好,又是生产力与性能结合得最好的语言,写起来还能体会到快乐。关键是当前国内外互联网大厂、初创小厂也都广泛接纳并应用Go,就业“钱景”极佳!很多人都纷纷投身于Go语言的学习中,但是盲目的“一头热”只会让你多走许多弯路。
|
||||
|
||||
我总结了一下,最常见的无非就是这几个:
|
||||
|
||||
|
||||
“入错行”,从开始到放弃。如果你在最开始缺乏对这门语言的认真评估,盲目投入进去,后期沉没的时间和精力成本都会巨大;
|
||||
不动手。语言学习不是“纸上谈兵”,要动手去用,并且动手越早效果越好;
|
||||
用其他编程语言思维编写Go代码。我认为,每门编程语言都有着自己独特的编程思维方式,如果你用其他编程语言的思维方式去写Go代码,那只能“形神皆丧”,无法掌握语言真谛;
|
||||
没有建立起“设计意识”。编程语言学习的最终目的是写出具有现实实用意义的程序,所以你要培养自己选择适当的语言元素构造程序骨架的能力,也就是“设计意识”。尤其是要弄清楚不同语言元素所在的层次,不然你只能停留在“Hello, World”的世界里。
|
||||
|
||||
|
||||
那么,我们到底要怎么学才能学好Go呢?
|
||||
|
||||
学好Go的前提是能坚持学下去。而要保证持续学下去,做好Go入门学习就至关重要。入门学习就好比一座在建大厦的地基,只有地基坚实、稳固,大厦才可能迎来建成,并耸立云霄的那天。
|
||||
|
||||
那么如何做好入门学习呢?这里告诉你三个诀窍与五个阶段。所谓三个诀窍是“心定、手勤、脑勤”。什么意思呢?我将这个入门课的学习分为下面五个阶段,我会结合这五个阶段来和你说明这三个诀窍。
|
||||
|
||||
第一个阶段:前置篇,“心定”建立认同感。
|
||||
|
||||
这一部分,我会带你了解Go的前世今生和设计哲学。如果你是有其他语言编程经验的开发人员,你就更应该完成前置篇的学习了。
|
||||
|
||||
这一部分存在的意义就是让你“心定”。所谓“心定”就是为了建立对Go语言的认同感。这种认同感是全方位的,包括对Go语言的设计目标、设计哲学、演化思路,还有社区行为规范等等。只有“心定”,才能避免出现“Hello-and-Bye”的情况,这是学好Go的前提。
|
||||
|
||||
第二个阶段:入门篇,“手勤”多动手实践。
|
||||
|
||||
在这一部分中,我将告诉你在不同平台上安装各种Go版本的方法,带你了解一个Go程序应该长什么模样,看看一些实用Go程序都有哪些语法元素和结构。关于Go的版本,如果我在课程中没有特地说明,那便默认使用的是Go最新的稳定版本,这里请你注意下。
|
||||
|
||||
编程不是“纸上谈兵”。我们最终都是要将编写完的源码提交给计算机编译运行的,因此,我希望你在这部分多动手、多实践。我在入门篇中将会让你拥有“照猫画虎”的能力。只有拥有这种能力,你才能“随心所欲”地动手实践。
|
||||
|
||||
第三个阶段:基础篇,“脑勤”多理解,夯实基础。
|
||||
|
||||
这一部分,我们会围绕着“程序=数据+算法”的逻辑,从变量、常量等基本概念,到数据类型,再到广义的算法,让你可以用Go建立对现实世界的抽象认知,也能明白Go程序运行的基本逻辑。
|
||||
|
||||
在基础篇的结尾,我们会结合已学习的基础语法做一个小练习项目。实践与理论的结合才能达到更好的学习效果。
|
||||
|
||||
第四个阶段:核心篇,“脑勤+”建立自己的Go应用设计意识。
|
||||
|
||||
在这部分,我会跟你介绍Go语言独有或经过较大创新的接口类型与goroutine等并发原语类型,这些语法元素是Go语言的核心。
|
||||
|
||||
Go接口与Goroutine等并发原语类型有一个共同的特点,那就是它们都是可以影响到Go应用程序的结构设计的语法元素。Goroutine等并发原语是Go应用运行时并发设计的基础,而接口则是Go推崇的面向组合设计的抓手,这一动一静共同构成了Go应用程序的骨架。通过这一部分,你就能建立自己的Go应用“设计意识”。
|
||||
|
||||
第五个阶段:实战篇,攻克Go开发的“最后一公里”。
|
||||
|
||||
编程就是要做到学以致用。在掌握了Go语言的基础语法、核心语法并建立起自己的“设计意识”后,我们就是时候应用这些Go语言的特性来解决实际问题了。
|
||||
|
||||
在这部分中,我们将通过一个实战的例子,展示如何做好学习与使用之间的衔接,帮助你走完“使用Go进行生产级开发”这“最后一公里”。
|
||||
|
||||
|
||||
|
||||
更具体的目录,我也放在了这里,你可以看一下:
|
||||
|
||||
|
||||
|
||||
在开篇词的最后,我想说,Go是一门非常优秀的后端编程语言,它简单而不失表达力,兼具高生产力与战斗力(高性能)。它既能给你带去编码的快乐,也能因市场的广泛接受与热捧而提升你的个人价值。
|
||||
|
||||
所以,我衷心地希望你能完成这门课程的学习。希望我的这门课能帮助你将Go语言之路走得更顺畅,早日成长为一名优秀的Go语言开发工程师。
|
||||
|
||||
不要再犹豫了,来和我一起开启Go语言的学习之旅吧。
|
||||
|
||||
|
||||
|
||||
|
||||
133
专栏/TonyBai·Go语言第一课/01前世今生:你不得不了解的Go的历史和现状.md
Normal file
133
专栏/TonyBai·Go语言第一课/01前世今生:你不得不了解的Go的历史和现状.md
Normal file
@@ -0,0 +1,133 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 前世今生:你不得不了解的Go的历史和现状
|
||||
你好,我是Tony Bai。
|
||||
|
||||
今天是我们的第一堂课。第一堂课的开场,我要做的事很简单,就想跟你聊一聊Go语言的前世今生。
|
||||
|
||||
我一直认为,当你开始接触一门新语言的时候,你一定要去了解它的历史和现状。因为这样,你才能建立起对这门语言的整体认知,了解它未来的走向。而且,也能建立你学习的“安全感”,相信它能够给你带来足够的价值和收益,更加坚定地学习下去。
|
||||
|
||||
所以,在这一节课,我就来跟你聊聊Go的前世今生,讲清楚Go到底是一门怎么样的语言,Go又是怎么诞生的,它经历了怎样的历史演进,它的现状和未来又会如何?
|
||||
|
||||
无论后面你是否会选择学习Go语言,无论你是否会真正成为一名Go程序员,我都建议你先了解一下这些内容,它会让你对编程语言的发展有更进一步的理解。
|
||||
|
||||
首先,我们就来看看Go语言是怎么诞生的,这可以让你真实地了解Go的诞生缘由、设计目标,以及它究竟要解决哪些问题。
|
||||
|
||||
Go语言是怎样诞生的?
|
||||
|
||||
Go语言的创始人有三位,分别是图灵奖获得者、C语法联合发明人、Unix之父肯·汤普森(Ken Thompson),Plan 9操作系统领导者、UTF-8编码的最初设计者罗伯·派克(Rob Pike),以及Java的HotSpot虚拟机和Chrome浏览器的JavaScript V8引擎的设计者之一罗伯特·格瑞史莫(Robert Griesemer)。
|
||||
|
||||
他们可能都没有想到,他们三个人在2007年9月20日下午的一次普通讨论,就这么成为了计算机编程语言领域的一次著名历史事件,开启了一个新编程语言的历史。
|
||||
|
||||
|
||||
|
||||
那天下午,在谷歌山景城总部的那间办公室里,罗伯·派克启动了一个C++工程的编译构建。按照以往的经验判断,这次构建大约需要一个小时。利用这段时间,罗伯·派克和罗伯特·格瑞史莫、肯·汤普森坐在一处,交换了关于设计一门新编程语言的想法。
|
||||
|
||||
之所以有这种想法,是因为当时的谷歌内部主要使用C++语言构建各种系统,但C++的巨大复杂性、编译构建速度慢以及在编写服务端程序时对并发支持的不足,让三位大佬觉得十分不便,他们就想着设计一门新的语言。在他们的初步构想中,这门新语言应该是能够给程序员带来快乐、匹配未来硬件发展趋势并适合用来开发谷歌内部大规模网络服务程序的。
|
||||
|
||||
趁热打铁!在第一天的简短讨论后,第二天这三位大佬又在谷歌总部的“雅温得(Yaounde)”会议室里具体讨论了这门新语言的设计。会后罗伯特·格瑞史莫发出了一封题为“prog lang discussion”的电邮,对这门新编程语言的功能特性做了初步的归纳总结:
|
||||
|
||||
|
||||
|
||||
这封电邮对这门新编程语言的功能特性做了归纳总结。主要思路是,在C语言的基础上,修正一些明显的缺陷,删除一些被诟病较多的特性,增加一些缺失的功能,比如,使用import替代include、去掉宏、增加垃圾回收、支持接口等。这封电邮成为了这门新语言的第一版特性设计稿,三位大佬在这门语言的一些基础语法特性上达成了初步一致。
|
||||
|
||||
9月25日,罗伯·派克在一封回复电邮中把这门新编程语言命名为“go”:
|
||||
|
||||
|
||||
|
||||
在罗伯·派克的心目中,“go”这个单词短小、容易输入并且在组合其他字母后便可以用来命名Go相关的工具,比如编译器(goc)、汇编器(goa)、链接器(gol)等(go的早期版本曾如此命名go工具链,但后续版本撤销了这种命名方式,仅保留go这一统一的工具链名称 )。
|
||||
|
||||
这里我还想澄清一个误区,很多Go语言初学者经常称这门语言为Golang,其实这是不对的:“Golang”仅应用于命名Go语言官方网站,而且当时没有用go.com纯粹是这个域名被占用了而已。
|
||||
|
||||
从“三人行”到“众人拾柴”
|
||||
|
||||
经过早期讨论,Go语言的三位作者在语言设计上达成初步一致后,便开启了Go语言迭代设计和实现的过程。
|
||||
|
||||
2008年初,Unix之父肯·汤普森实现了第一版Go编译器,用于验证之前的设计。这个编译器先将Go代码转换为C代码,再由C编译器编译成二进制文件。
|
||||
|
||||
到2008年年中,Go的第一版设计就基本结束了。这时,同样在谷歌工作的伊恩·泰勒(Ian Lance Taylor)为Go语言实现了一个gcc的前端,这也是Go语言的第二个编译器。
|
||||
|
||||
伊恩·泰勒的这一成果不仅仅是一种鼓励,也证明了Go这一新语言的可行性 。有了语言的第二个实现,对Go的语言规范和标准库的建立也是很重要的。随后,伊恩·泰勒以团队的第四位成员的身份正式加入Go语言开发团队,后面也成为了Go语言,以及其工具设计和实现的核心人物之一。
|
||||
|
||||
罗斯·考克斯(Russ Cox)是Go核心开发团队的第五位成员,也是在2008年加入的。进入团队后,罗斯·考克斯利用函数类型是“一等公民”,而且它也可以拥有自己的方法这个特性巧妙设计出了http包的HandlerFunc类型。这样,我们通过显式转型就可以让一个普通函数成为满足http.Handler接口的类型了。
|
||||
|
||||
不仅如此,罗斯·考克斯还在当时设计的基础上提出了一些更泛化的想法,比如io.Reader和io.Writer接口,这就奠定了Go语言的I/O结构模型。后来,罗斯·考克斯成为Go核心技术团队的负责人,推动Go语言的持续演化。
|
||||
|
||||
到这里,Go语言最初的核心团队形成,Go语言迈上了稳定演化的道路。
|
||||
|
||||
2009年10月30日,罗伯·派克在Google Techtalk上做了一次有关Go语言的演讲“The Go Programming Language”,这也是Go语言第一次公之于众。十天后,也就是2009年11月10日,谷歌官方宣布Go语言项目开源,之后这一天也被Go官方确定为Go语言的诞生日。
|
||||
|
||||
|
||||
|
||||
在Go语言项目开源后,Go语言也迎来了自己的“吉祥物”,是一只由罗伯·派克夫人芮妮·弗伦奇(Renee French)设计的地鼠,从此地鼠(gopher)也就成为了世界各地Go程序员的象征,Go程序员也被昵称为Gopher,在后面的课程中,我会直接使用Gopher指代Go语言开发者。
|
||||
|
||||
|
||||
|
||||
Go语言项目的开源使得Go语言吸引了全世界开发者的目光,再加上Go三位作者在业界的影响力以及谷歌这座大树的加持,更多有才华的程序员加入到Go核心开发团队中,更多贡献者开始为Go语言项目添砖加瓦。于是,Go在宣布开源的当年,也就是2009年,就成为了著名编程语言排行榜TIOBE的年度最佳编程语言。
|
||||
|
||||
2012年3月28日,Go 1.0版本正式发布,同时Go官方发布了“Go 1兼容性”承诺:只要符合Go 1语言规范的源代码,Go编译器将保证向后兼容(backwards compatible),也就是说我们使用新版编译器也可以正确编译用老版本语法编写的代码。
|
||||
|
||||
|
||||
|
||||
从此,Go语言发展得非常迅猛。从正式开源到现在,十一年的时间过去了,Go语言发布了多个大版本更新,逐渐成熟。这里,我也梳理了迄今为止Go语言的重大版本更新,希望能帮助你快速了解Go语言的演化历史。
|
||||
|
||||
|
||||
|
||||
Go是否值得我们学习?
|
||||
|
||||
时间已经来到了2021年。经过了十余年的打磨与优化,如今的Go语言已经逐渐成为了云计算时代基础设施的编程语言。你能想到的现代云计算基础设施软件的大部分流行和可靠的作品,都是用Go编写的,比如:Docker、Kubernetes、Prometheus、Ethereum(以太坊)、Istio、CockroachDB、InfluxDB、Terraform、Etcd、Consul等等。当然,这个列表还在持续增加,可见Go语言的影响力已经十分强大。
|
||||
|
||||
Go除了在云计算基础设施领域,拥有上面这些杀手级应用之外,Go语言的用户数量也在近几年快速增加。Go语言项目技术负责人罗斯·考克斯甚至还专门写过一篇文章,来估算全世界范围的Gopher数量。按照他的估算结果,全世界范围的Gopher数量从2017年年中的最多100万,增长到2019年11月的最多196万,大概两年半翻了一番。庞大的Gopher基数为Go未来的发展提供持续的增长潜力和更大的想象空间。
|
||||
|
||||
那么Go语言前景究竟如何,值不值得投入去学习呢?
|
||||
|
||||
我在想,是否存在一种成熟的方法,能相对客观地描绘出Go语言的历史发展趋势,并对未来Go的走势做出指导呢?我想来想去,觉得Gartner的技术成熟度曲线(The Hype Cycle)可以借来一试。
|
||||
|
||||
Gartner的技术成熟度曲线又叫技术循环曲线,是企业用来评估新科技是否要采用或采用时机的一种可视化方法,它利用时间轴与该技术在市面上的可见度(媒体曝光度)决定要不要采用,以及什么时候采用这种新科技,下面就是一条典型的技术成熟度曲线的形状:
|
||||
|
||||
|
||||
|
||||
同理,如果我们将这条技术成熟度曲线应用于某种编程语言,比如Go,我们就可以用它来判断这门编程语言所处的成熟阶段,来辅助我们决定要不要采用,以及何时采用这门语言。
|
||||
|
||||
我们从知名的TIOBE编程语言指数排行榜获取Go从2009年开源以来至今的指数曲线图,并且根据Go版本发布历史在图中标记出了各个时段的Go发布版本,你可以看看。
|
||||
|
||||
|
||||
|
||||
对比前面的Gartner成熟度曲线,我们可以得出这样的结论:Go在经历了一个漫长的技术萌芽期后,从实现自举的Go 1.5版本开始逐步进入“期望膨胀期”,在经历从Go 1.6到Go 1.9版本的发布后,业界对Go的期望达到了峰值。
|
||||
|
||||
但随后“泡沫破裂”,在Go 1.11发布前跌到了“泡沫破裂期”的谷底,Go 1.11版本引入了Go module,给社区解决Go包依赖问题注射了一支强心剂,于是Go又开始了缓慢爬升。
|
||||
|
||||
从TIOBE提供的曲线来看,Go 1.12到Go 1.15版本的发布让我们有信心认为Go已经走出“泡沫破裂谷底期”,进入到“稳步爬升的光明期”。
|
||||
|
||||
至于Go什么时候能达到实质生产高峰期呢?
|
||||
|
||||
我们还不好预测,但这应该是一个确定性事件。我认为现在离它到达实质生产高峰期只是一个时间问题了。也许预计在2022年初发布的支持Go泛型特性的Go 1.18版本,会是继Go 1.5版本之后又一“爆款”,很可能会快速推动Go迈入更高的发展阶段。
|
||||
|
||||
小结
|
||||
|
||||
到这里,我们今天这节课就结束了。在这一节课里,我们一起探讨了“Go从哪里来,并可能要往哪里去”的问题。
|
||||
|
||||
我前面也说了,一门编程语言的历史和现状,能给你带来学习的“安全感”,相信它可以提升你的个人价值,也会让你获得丰厚的回报。你也会更加清楚地认识到:自己为什么要学习它?它未来的发展趋势又是怎样的?而且,当这门语言的现状能给予你极大“安全感”的时候,我们才会“死心塌地”地学习和钻研这门语言,而不会有太多的后顾之忧。
|
||||
|
||||
从Go本身的发展来看,和多数编程语言一样,Go语言在诞生后,度过了一个较长的“技术萌芽期”。然后,实现了自举,而且对GC延迟进行了大幅优化的Go 1.5版本,成为了Go语言演化过程中的第一个“引爆点”,推动Go语言进入“技术膨胀期”。
|
||||
|
||||
也正是在这段时间内,Go语言以迅雷不及掩耳盗铃之势推出了以Docker、Kubernetes为典型代表的“杀手级应用”,充分展现了实力,在世界范围收获了百万粉丝,迸发出极高的潜力和持续的活力。
|
||||
|
||||
Go开源于2009年末,如果从那时算起,Go才11岁。但在Go核心开发团队眼中,Go的真正诞生年份是2007年,距今已13个年头有余了。
|
||||
|
||||
回顾一下计算机编程语言的历史,我们会发现,绝大多数主流编程语言,都将在其15至20年间大步前进。Java、Python、Ruby、JavaScript和许多其他编程语言都是这样。如今Go语言也马上进入自己的黄金5~10年,从前面的技术成熟度曲线分析也可以印证这一点:Go已经重新回到“稳步爬升的光明期”。
|
||||
|
||||
对于开发人员来说,Go语言学习的最佳时刻已经到来了!
|
||||
|
||||
思考题
|
||||
|
||||
相较于传统的静态编译型编程语言(如C、C++),Go做出了哪些改进?你可以思考一下,欢迎在留言区留下你的答案。
|
||||
|
||||
感谢你和我一起学习,也欢迎你把这节课分享给更多对Go语言感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
243
专栏/TonyBai·Go语言第一课/02拒绝“HelloandBye”:Go语言的设计哲学是怎么一回事?.md
Normal file
243
专栏/TonyBai·Go语言第一课/02拒绝“HelloandBye”:Go语言的设计哲学是怎么一回事?.md
Normal file
@@ -0,0 +1,243 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 拒绝“Hello and Bye”:Go语言的设计哲学是怎么一回事?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
上一讲,我们探讨了“Go从哪里来,并可能要往哪里去”的问题。根据“绝大多数主流编程语言将在其15至20年间大步前进”这个依据,我们给出了一个结论:Go语言即将进入自己的黄金5~10年。
|
||||
|
||||
那么此时此刻,想必你已经跃跃欲试,想要尽快开启Go编程之旅。但在正式学习Go语法之前,我还是要再来给你泼泼冷水,因为这将决定你后续的学习结果,是“从入门到继续”还是“从入门到放弃”。
|
||||
|
||||
很多编程语言的初学者在学习初期,可能都会遇到这样的问题:最初兴致勃勃地开始学习一门编程语言,学着学着就发现了很多“别扭”的地方,比如想要的语言特性缺失、语法风格冷僻与主流语言差异较大、语言的不同版本间无法兼容、语言的语法特性过多导致学习曲线陡峭、语言的工具链支持较差,等等。
|
||||
|
||||
其实以上的这些问题,本质上都与语言设计者的设计哲学有关。所谓编程语言的设计哲学,就是指决定这门语言演化进程的高级原则和依据。
|
||||
|
||||
设计哲学之于编程语言,就好比一个人的价值观之于这个人的行为。
|
||||
|
||||
因为如果你不认同一个人的价值观,那你其实很难与之持续交往下去,即所谓道不同不相为谋。类似的,如果你不认同一门编程语言的设计哲学,那么大概率你在后续的语言学习中,就会遇到上面提到的这些问题,而且可能会让你失去继续学习的精神动力。
|
||||
|
||||
因此,在真正开始学习Go语法和编码之前,我们还需要先来了解一下Go语言的设计哲学,等学完这一讲之后,你就能更深刻地认识到自己学习Go语言的原因了。
|
||||
|
||||
我将Go语言的设计哲学总结为五点:简单、显式、组合、并发和面向工程。下面,我们就先从Go语言的第一设计哲学“简单”开始了解吧。
|
||||
|
||||
简单
|
||||
|
||||
知名Go开发者戴维·切尼(Dave Cheney)曾说过:“大多数编程语言创建伊始都致力于成为一门简单的语言,但最终都只是满足于做一个强大的编程语言”。
|
||||
|
||||
而Go语言是一个例外。Go语言的设计者们在语言设计之初,就拒绝了走语言特性融合的道路,选择了“做减法”并致力于打造一门简单的编程语言。
|
||||
|
||||
选择了“简单”,就意味着Go不会像C++、Java那样将其他编程语言的新特性兼蓄并收,所以你在Go语言中看不到传统的面向对象的类、构造函数与继承,看不到结构化的异常处理,也看不到本属于函数编程范式的语法元素。
|
||||
|
||||
其实,Go语言也没它看起来那么简单,自身实现起来并不容易,但这些复杂性被Go语言的设计者们“隐藏”了,所以Go语法层面上呈现了这样的状态:
|
||||
|
||||
|
||||
仅有25个关键字,主流编程语言最少;
|
||||
内置垃圾收集,降低开发人员内存管理的心智负担;
|
||||
首字母大小写决定可见性,无需通过额外关键字修饰;
|
||||
变量初始为类型零值,避免以随机值作为初值的问题;
|
||||
内置数组边界检查,极大减少越界访问带来的安全隐患;
|
||||
内置并发支持,简化并发程序设计;
|
||||
内置接口类型,为组合的设计哲学奠定基础;
|
||||
原生提供完善的工具链,开箱即用;
|
||||
… …
|
||||
|
||||
|
||||
看,我说的没错吧,确实挺简单的。当然了,任何的设计都存在着权衡与折中。我们看到Go设计者选择的“简单”,其实是站在巨人肩膀上,去除或优化了以往语言中,已经被开发者证明为体验不好或难以驾驭的语法元素和语言机制,并提出了自己的一些创新性的设计。比如,首字母大小写决定可见性、变量初始为类型零值、内置以go关键字实现的并发支持等。
|
||||
|
||||
Go这种有些“逆潮流”的“简单哲学”并不是一开始就能得到程序员的理解的,但在真正使用Go之后,我们才能真正体会到这种简单所带来的收益:简单意味着可以使用更少的代码实现相同的功能;简单意味着代码具有更好的可读性,而可读性好的代码通常意味着更好的可维护性以及可靠性。
|
||||
|
||||
总之,在软件工程化的今天,这些都意味着对生产效率提升的极大促进,我们可以认为简单的设计哲学是Go生产力的源泉。
|
||||
|
||||
显式
|
||||
|
||||
好,接下来我们继续来了解学习下Go语言的第二大设计哲学:显式。
|
||||
|
||||
首先,我想先带你来看一段C程序,我们一起来看看“隐式”代码的行为特征。
|
||||
|
||||
在C语言中,下面这段代码可以正常编译并输出正确结果:
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
short int a = 5;
|
||||
|
||||
int b = 8;
|
||||
long c = 0;
|
||||
|
||||
c = a + b;
|
||||
printf("%ld\n", c);
|
||||
}
|
||||
|
||||
|
||||
我们看到在上面这段代码中,变量a、b和c的类型均不相同,C语言编译器在编译c = a + b这一行时,会自动将短整型变量a和整型变量b,先转换为long类型然后相加,并将所得结果存储在long类型变量c中。那如果换成Go来实现这个计算会怎么样呢?我们先把上面的C程序转化成等价的Go代码:
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
var a int16 = 5
|
||||
var b int = 8
|
||||
var c int64
|
||||
|
||||
c = a + b
|
||||
fmt.Printf("%d\n", c)
|
||||
}
|
||||
|
||||
|
||||
如果我们编译这段程序,将得到类似这样的编译器错误:“invalid operation: a + b (mismatched types int16 and int)”。我们能看到Go与C语言的隐式自动类型转换不同,Go不允许不同类型的整型变量进行混合计算,它同样也不会对其进行隐式的自动转换。
|
||||
|
||||
因此,如果要使这段代码通过编译,我们就需要对变量a和b进行显式转型,就像下面代码段中这样:
|
||||
|
||||
c = int64(a) + int64(b)
|
||||
fmt.Printf("%d\n", c)
|
||||
|
||||
|
||||
而这其实就是Go语言显式设计哲学的一个体现。
|
||||
|
||||
在Go语言中,不同类型变量是不能在一起进行混合计算的,这是因为Go希望开发人员明确知道自己在做什么,这与C语言的“信任程序员”原则完全不同,因此你需要以显式的方式通过转型统一参与计算各个变量的类型。
|
||||
|
||||
除此之外,Go设计者所崇尚的显式哲学还直接决定了Go语言错误处理的形态:Go语言采用了显式的基于值比较的错误处理方案,函数/方法中的错误都会通过return语句显式地返回,并且通常调用者不能忽略对返回的错误的处理。
|
||||
|
||||
这种有悖于“主流语言潮流”的错误处理机制还一度让开发者诟病,社区也提出了多个新错误处理方案,但或多或少都包含隐式的成分,都被Go开发团队一一否决了,这也与显式的设计哲学不无关系。
|
||||
|
||||
组合
|
||||
|
||||
接着,我们来看第三个设计哲学:组合。
|
||||
|
||||
这个设计哲学和我们各个程序之间的耦合有关,Go语言不像C++、Java等主流面向对象语言,我们在Go中是找不到经典的面向对象语法元素、类型体系和继承机制的,Go推崇的是组合的设计哲学。
|
||||
|
||||
在诠释组合之前,我们需要先来了解一下Go在语法元素设计时,是如何为“组合”哲学的应用奠定基础的。
|
||||
|
||||
在Go语言设计层面,Go设计者为开发者们提供了正交的语法元素,以供后续组合使用,包括:
|
||||
|
||||
|
||||
Go语言无类型层次体系,各类型之间是相互独立的,没有子类型的概念;
|
||||
每个类型都可以有自己的方法集合,类型定义与方法实现是正交独立的;
|
||||
实现某个接口时,无需像Java那样采用特定关键字修饰;
|
||||
包之间是相对独立的,没有子包的概念。
|
||||
|
||||
|
||||
我们可以看到,无论是包、接口还是一个个具体的类型定义,Go语言其实是为我们呈现了这样的一幅图景:一座座没有关联的“孤岛”,但每个岛内又都很精彩。那么现在摆在面前的工作,就是在这些孤岛之间以最适当的方式建立关联,并形成一个整体。而Go选择采用的组合方式,也是最主要的方式。
|
||||
|
||||
Go语言为支撑组合的设计提供了类型嵌入(Type Embedding)。通过类型嵌入,我们可以将已经实现的功能嵌入到新类型中,以快速满足新类型的功能需求,这种方式有些类似经典面向对象语言中的“继承”机制,但在原理上却与面向对象中的继承完全不同,这是一种Go设计者们精心设计的“语法糖”。
|
||||
|
||||
被嵌入的类型和新类型两者之间没有任何关系,甚至相互完全不知道对方的存在,更没有经典面向对象语言中的那种父类、子类的关系,以及向上、向下转型(Type Casting)。通过新类型实例调用方法时,方法的匹配主要取决于方法名字,而不是类型。这种组合方式,我称之为垂直组合,即通过类型嵌入,快速让一个新类型“复用”其他类型已经实现的能力,实现功能的垂直扩展。
|
||||
|
||||
你可以看看下面这个Go标准库中的一段使用类型嵌入的组合方式的代码段:
|
||||
|
||||
// $GOROOT/src/sync/pool.go
|
||||
type poolLocal struct {
|
||||
private interface{}
|
||||
shared []interface{}
|
||||
Mutex
|
||||
pad [128]byte
|
||||
}
|
||||
|
||||
|
||||
在代码段中,我们在poolLocal这个结构体类型中嵌入了类型Mutex,这就使得poolLocal这个类型具有了互斥同步的能力,我们可以通过poolLocal类型的变量,直接调用Mutex类型的方法Lock或Unlock。-
|
||||
另外,我们在标准库中还会经常看到类似如下定义接口类型的代码段:
|
||||
|
||||
// $GOROOT/src/io/io.go
|
||||
type ReadWriter interface {
|
||||
Reader
|
||||
Writer
|
||||
}
|
||||
|
||||
|
||||
这里,标准库通过嵌入接口类型的方式来实现接口行为的聚合,组成大接口,这种方式在标准库中尤为常用,并且已经成为了Go语言的一种惯用法。-
|
||||
垂直组合本质上是一种“能力继承”,采用嵌入方式定义的新类型继承了嵌入类型的能力。Go还有一种常见的组合方式,叫水平组合。和垂直组合的能力继承不同,水平组合是一种能力委托(Delegate),我们通常使用接口类型来实现水平组合。
|
||||
|
||||
Go语言中的接口是一个创新设计,它只是方法集合,并且它与实现者之间的关系无需通过显式关键字修饰,它让程序内部各部分之间的耦合降至最低,同时它也是连接程序各个部分之间“纽带”。
|
||||
|
||||
水平组合的模式有很多,比如一种常见方法就是,通过接受接口类型参数的普通函数进行组合,如以下代码段所示:
|
||||
|
||||
// $GOROOT/src/io/ioutil/ioutil.go
|
||||
func ReadAll(r io.Reader)([]byte, error)
|
||||
|
||||
// $GOROOT/src/io/io.go
|
||||
func Copy(dst Writer, src Reader)(written int64, err error)
|
||||
|
||||
|
||||
也就是说,函数ReadAll通过io.Reader这个接口,将io.Reader的实现与ReadAll所在的包低耦合地水平组合在一起了,从而达到从任意实现io.Reader的数据源读取所有数据的目的。类似的水平组合“模式”还有点缀器、中间件等,这里我就不展开了,在后面讲到接口类型时再详细叙述。
|
||||
|
||||
此外,我们还可以将Go语言内置的并发能力进行灵活组合以实现,比如,通过goroutine+channel的组合,可以实现类似Unix Pipe的能力。
|
||||
|
||||
总之,组合原则的应用实质上是塑造了Go程序的骨架结构。类型嵌入为类型提供了垂直扩展能力,而接口是水平组合的关键,它好比程序肌体上的“关节”,给予连接“关节”的两个部分各自“自由活动”的能力,而整体上又实现了某种功能。并且,组合也让遵循“简单”原则的Go语言,在表现力上丝毫不逊色于其他复杂的主流编程语言。
|
||||
|
||||
并发
|
||||
|
||||
好,前面我们已经看过3个设计哲学了,紧接着我带你看的是第4个:并发。
|
||||
|
||||
“并发”这个设计哲学的出现有它的背景,你也知道CPU都是靠提高主频来改进性能的,但是现在这个做法已经遇到了瓶颈。主频提高导致CPU的功耗和发热量剧增,反过来制约了CPU性能的进一步提高。2007年开始,处理器厂商的竞争焦点从主频转向了多核。
|
||||
|
||||
在这种大背景下,Go的设计者在决定去创建一门新语言的时候,果断将面向多核、原生支持并发作为了新语言的设计原则之一。并且,Go放弃了传统的基于操作系统线程的并发模型,而采用了用户层轻量级线程,Go将之称为goroutine。
|
||||
|
||||
goroutine占用的资源非常小,Go运行时默认为每个goroutine分配的栈空间仅2KB。goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个Go程序中可以创建成千上万个并发的goroutine。而且,所有的Go代码都在goroutine中执行,哪怕是go运行时的代码也不例外。
|
||||
|
||||
在提供了开销较低的goroutine的同时,Go还在语言层面内置了辅助并发设计的原语:channel和select。开发者可以通过语言内置的channel传递消息或实现同步,并通过select实现多路channel的并发控制。相较于传统复杂的线程并发模型,Go对并发的原生支持将大大降低开发人员在开发并发程序时的心智负担。
|
||||
|
||||
此外,并发的设计哲学不仅仅让Go在语法层面提供了并发原语支持,其对Go应用程序设计的影响更为重要。并发是一种程序结构设计的方法,它使得并行成为可能。
|
||||
|
||||
采用并发方案设计的程序在单核处理器上也是可以正常运行的,也许在单核上的处理性能可能不如非并发方案。但随着处理器核数的增多,并发方案可以自然地提高处理性能。
|
||||
|
||||
而且,并发与组合的哲学是一脉相承的,并发是一个更大的组合的概念,它在程序设计的全局层面对程序进行拆解组合,再映射到程序执行层面上:goroutines各自执行特定的工作,通过channel+select将goroutines组合连接起来。并发的存在鼓励程序员在程序设计时进行独立计算的分解,而对并发的原生支持让Go语言也更适应现代计算环境。
|
||||
|
||||
面向工程
|
||||
|
||||
最后,我们来看一下Go的最后一条设计哲学:面向工程。
|
||||
|
||||
Go语言设计的初衷,就是面向解决真实世界中Google内部大规模软件开发存在的各种问题,为这些问题提供答案,这些问题包括:程序构建慢、依赖管理失控、代码难于理解、跨语言构建难等。
|
||||
|
||||
很多编程语言设计者和他们的粉丝们认为这些问题并不是一门编程语言应该去解决的,但Go语言的设计者并不这么看,他们在Go语言最初设计阶段就将解决工程问题作为Go的设计原则之一去考虑Go语法、工具链与标准库的设计,这也是Go与其他偏学院派、偏研究型的编程语言在设计思路上的一个重大差异。
|
||||
|
||||
语法是编程语言的用户接口,它直接影响开发人员对于这门语言的使用体验。在面向工程设计哲学的驱使下,Go在语法设计细节上做了精心的打磨。比如:
|
||||
|
||||
|
||||
重新设计编译单元和目标文件格式,实现Go源码快速构建,让大工程的构建时间缩短到类似动态语言的交互式解释的编译速度;
|
||||
如果源文件导入它不使用的包,则程序将无法编译。这可以充分保证任何Go程序的依赖树是精确的。这也可以保证在构建程序时不会编译额外的代码,从而最大限度地缩短编译时间;
|
||||
去除包的循环依赖,循环依赖会在大规模的代码中引发问题,因为它们要求编译器同时处理更大的源文件集,这会减慢增量构建;
|
||||
包路径是唯一的,而包名不必唯一的。导入路径必须唯一标识要导入的包,而名称只是包的使用者如何引用其内容的约定。“包名称不必是唯一的”这个约定,大大降低了开发人员给包起唯一名字的心智负担;
|
||||
故意不支持默认函数参数。因为在规模工程中,很多开发者利用默认函数参数机制,向函数添加过多的参数以弥补函数API的设计缺陷,这会导致函数拥有太多的参数,降低清晰度和可读性;
|
||||
增加类型别名(type alias),支持大规模代码库的重构。
|
||||
|
||||
|
||||
在标准库方面,Go被称为“自带电池”的编程语言。如果说一门编程语言是“自带电池”,则说明这门语言标准库功能丰富,多数功能不需要依赖外部的第三方包或库,Go语言恰恰就是这类编程语言。
|
||||
|
||||
由于诞生年代较晚,而且目标比较明确,Go在标准库中提供了各类高质量且性能优良的功能包,其中的net/http、crypto、encoding等包充分迎合了云原生时代的关于API/RPC Web服务的构建需求,Go开发者可以直接基于标准库提供的这些包实现一个满足生产要求的API服务,从而减少对外部第三方包或库的依赖,降低工程代码依赖管理的复杂性,也降低了开发人员学习第三方库的心理负担。
|
||||
|
||||
而且,开发人员在工程过程中肯定是需要使用工具的,Go语言就提供了足以让所有其它主流语言开发人员羡慕的工具链,工具链涵盖了编译构建、代码格式化、包依赖管理、静态代码检查、测试、文档生成与查看、性能剖析、语言服务器、运行时程序跟踪等方方面面。
|
||||
|
||||
这里值得重点介绍的是gofmt,它统一了Go语言的代码风格,在其他语言开发者还在为代码风格争论不休的时候,Go开发者可以更加专注于领域业务中。同时,相同的代码风格让以往困扰开发者的代码阅读、理解和评审工作变得容易了很多,至少Go开发者再也不会有那种因代码风格的不同而产生的陌生感。Go的这种统一代码风格思路也在开始影响着后续新编程语言的设计,并且一些现有的主流编程语言也在借鉴Go的一些设计。
|
||||
|
||||
在提供丰富的工具链的同时,Go在标准库中提供了官方的词法分析器、语法解析器和类型检查器相关包,开发者可以基于这些包快速构建并扩展Go工具链。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我和你一起了解了Go语言的设计哲学:简单、显式、组合、并发和面向工程。
|
||||
|
||||
|
||||
简单是指Go语言特性始终保持在少且足够的水平,不走语言特性融合的道路,但又不乏生产力。简单是Go生产力的源泉,也是Go对开发者的最大吸引力;
|
||||
显式是指任何代码行为都需开发者明确知晓,不存在因“暗箱操作”而导致可维护性降低和不安全的结果;
|
||||
组合是构建Go程序骨架的主要方式,它可以大幅降低程序元素间的耦合,提高程序的可扩展性和灵活性;
|
||||
并发是Go敏锐地把握了CPU向多核方向发展这一趋势的结果,可以让开发人员在多核时代更容易写出充分利用系统资源、支持性能随CPU核数增加而自然提升的应用程序;
|
||||
面向工程是Go语言在语言设计上的一个重大创新,它将语言要解决的问题域扩展到那些原本并不是由编程语言去解决的领域,从而覆盖了更多开发者在开发过程遇到的“痛点”,为开发者提供了更好的使用体验。
|
||||
|
||||
|
||||
这些设计哲学直接影响了Go语言自身的设计。理解这些设计哲学,也能帮助我们理解Go语言语法、标准库以及工具链的演化决策过程。
|
||||
|
||||
好了,学完这节课之后,你认同Go的设计哲学吗?认同的话就继续跟着我学下去吧。
|
||||
|
||||
思考题
|
||||
|
||||
今天,我还想问下你,你还能举出哪些符合Go语言设计哲学的例子吗?欢迎在留言区多多和我分享讨论。
|
||||
|
||||
感谢你和我一起学习,也欢迎你把这节课分享给更多对Go语言的设计哲学感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
282
专栏/TonyBai·Go语言第一课/03配好环境:选择一种最适合你的Go安装方法.md
Normal file
282
专栏/TonyBai·Go语言第一课/03配好环境:选择一种最适合你的Go安装方法.md
Normal file
@@ -0,0 +1,282 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 配好环境:选择一种最适合你的Go安装方法
|
||||
你好,我是Tony Bai。
|
||||
|
||||
经过上一节的对Go设计哲学的探讨后,如果你依然决定继续Go语言编程学习之旅,那么欢迎你和我一起正式走进Go语言学习和实践的课堂。
|
||||
|
||||
编程不是“纸上谈兵”,它是一门实践的艺术。编程语言的学习离不开动手实践,因此学习任何一门编程语言的第一步都是要拥有一个这门编程语言的开发环境,这样我们才可以动手编码,理论与实践结合,不仅加速学习效率,还能取得更好的学习效果。
|
||||
|
||||
在这一讲中我们就先来学习下如何安装和配置Go语言开发环境。如果你的机器上还没有Go,那么就请跟我一起选择一种适合你的Go安装方法吧。第一步,先来挑一个合适的Go版本。
|
||||
|
||||
选择Go版本
|
||||
|
||||
挑版本之前,我们先来看看Go语言的版本发布策略。
|
||||
|
||||
如今,Go团队已经将版本发布节奏稳定在每年发布两次大版本上,一般是在二月份和八月份发布。Go团队承诺对最新的两个Go稳定大版本提供支持,比如目前最新的大版本是Go 1.17,那么Go团队就会为Go 1.17和Go 1.16版本提供支持。如果Go 1.18版本发布,那支持的版本将变成Go 1.18和Go 1.17。支持的范围主要包括修复版本中存在的重大问题、文档变更以及安全问题更新等。
|
||||
|
||||
基于这样的版本发布策略,在你选择版本时可以参考这几种思路:
|
||||
|
||||
一般情况下,我建议你采用最新版本。因为Go团队发布的Go语言稳定版本的平均质量一直是很高的,少有影响使用的重大bug。你也不用太担心新版本的支持问题,Google的自有产品,比如Google App Engine(以下简称为GAE)支持都会很快,一般在Go新版本发布不久后,GAE便会宣布支持最新版本的Go。
|
||||
|
||||
你还可以根据不同实际项目需要或开源社区的情况使用不同的版本。
|
||||
|
||||
有的开源项目采纳了Go团队的建议,在Go最新版本发布不久就将当前项目的Go编译器版本升级到最新版,比如Kubernetes项目;而有的开源项目(比如:docker项目)则比较谨慎,在Go团队发布Go 1.17版本之后,这些项目可能还在使用两个发布周期之前的版本,比如Go 1.15。
|
||||
|
||||
但多数项目处于两者之间,也就是使用次新版,即最新版本之前的那个版本。比如,当前最新版本为Go 1.17,那么这些项目会使用Go 1.16版本的最新补丁版本(Go 1.16.x),直到发布Go 1.18后,这些项目才会切换到Go 1.17的最新补丁版本(Go 1.17.x)。如果你不是那么“激进”,也可以采用这种版本选择策略。
|
||||
|
||||
因为我们这门课是Go语言学习的课,所以我这里建议你直接使用Go最新发布版,这样我们可以体验到Go的最新语言特性,应用到标准库的最新API以及Go工具链的最新功能。在这一节课里我们以Go 1.16.5版本为例讲述一下其安装、配置和使用方法。
|
||||
|
||||
选定Go版本后,接下来我们就来看看几种常见的Go安装方法。
|
||||
|
||||
安装Go
|
||||
|
||||
Go从2009年开源并演化到今天,它的安装方法其实都已经很成熟了,接下来呢,我们就逐一介绍在Linux、macOS、Windows这三大主流操作系统上安装Go的方法,我们假设这些操作系统都安装在X86-64的平台上,首先我们来看Linux。
|
||||
|
||||
在Linux上安装Go
|
||||
|
||||
Go几乎支持Linux所有的主流发行版操作系统,常见的包括Ubuntu、CentOS(Redhat企业版Linux的社区开源版)、Fedora、SUSE等等,Go在这些主流的Linux发行版操作系统上的安装方法都是一样的(当然某个发行版也可能会利用其软件安装管理器提供仅属于其自己的安装方法)。你可以参考下面这样的安装步骤。
|
||||
|
||||
首先,我们需要下载并解压Go Linux安装包:
|
||||
|
||||
$wget -c https://golang.google.cn/dl/go1.16.5.linux-amd64.tar.gz
|
||||
|
||||
|
||||
这里有个小提醒:虽然Go官方下载站点是golang.org/dl,但我们可以用针对中国大陆的镜像站点golang.google.cn/dl来下载,在中国大陆地区使用大陆镜像站点可以大幅缩短下载时间。
|
||||
|
||||
第二步,将下载完毕的Go安装包解压到安装目录中:
|
||||
|
||||
$tar -C /usr/local -xzf go1.16.5.linux-amd64.tar.gz
|
||||
|
||||
|
||||
执行完上面解压缩命令后,我们将在/usr/local下面看到名为go的目录,这个目录就是Go的安装目录,也是Go官方推荐的Go安装目录。我们执行下面命令可以查看该安装目录下的组成:
|
||||
|
||||
$ls -F /usr/local/go
|
||||
AUTHORS CONTRIBUTORS PATENTS SECURITY.md api/ doc/ lib/ pkg/ src/
|
||||
CONTRIBUTING.md LICENSE README.md VERSION bin/ favicon.ico misc/ robots.txt test/
|
||||
|
||||
|
||||
不过呢,为了可以在任意路径下使用go命令,我们需要将Go二进制文件所在路径加入到用户环境变量PATH中(以用户使用bash为例),具体操作是将下面这行环境变量设置语句添加到$HOME/.profile文件的末尾:
|
||||
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
|
||||
然后执行下面命令使上述环境变量的设置立即生效:
|
||||
|
||||
$source ~/.profile
|
||||
|
||||
|
||||
最后,我们可以通过下面命令验证此次安装是否成功:
|
||||
|
||||
$go version
|
||||
|
||||
|
||||
如果这个命令输出了“go version go1.16.5 linux/amd64”,那么说明我们这次的Go安装是成功的。
|
||||
|
||||
在Mac上安装Go
|
||||
|
||||
在Mac上我们可以在图形界面的引导下进行Go的安装。不过,我们先要下载适用于Mac的Go安装包:
|
||||
|
||||
$wget -c https://golang.google.cn/dl/go1.16.5.darwin-amd64.pkg
|
||||
|
||||
|
||||
安装包下载完毕后,我们可以双击安装包,打开标准的Mac软件安装界面,如下图所示:
|
||||
|
||||
|
||||
|
||||
按软件安装向导提示,一路点击“继续”,我们便可以完成Go在Mac上的安装。
|
||||
|
||||
和Linux一样,Mac上的Go安装包默认也会将Go安装到/usr/local/go路径下面。因此,如果要在任意路径下使用Go,我们也需将这个路径加入到用户的环境变量PATH中。具体操作方法与上面Linux中的步骤一样,也是将下面环境变量设置语句加入到$HOME/.profile中,然后执行source命令让它生效就可以了:
|
||||
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
|
||||
最后,我们同样可以通过go version命令验证一下这次安装是否成功。
|
||||
|
||||
当然了,在Mac上,我们也可以采用像Linux那样的通过命令行安装Go的方法,如果采用这种方法,我们就要下载下面的Mac Go安装包:
|
||||
|
||||
$wget -c https://golang.google.cn/dl/go1.16.5.darwin-amd64.tar.gz
|
||||
|
||||
|
||||
后续的步骤与Linux上安装Go几乎没有差别,你直接参考我上面讲的就好了。
|
||||
|
||||
在Windows上安装Go
|
||||
|
||||
在Windows上,我们最好的安装方式就是采用图形界面引导下的Go安装方法。
|
||||
|
||||
我们打开Go包的下载页面,在页面上找到Go 1.16.5版本的Windows msi安装包(AMD64架构下的):go1.16.5.windows-amd64.msi,通过浏览器自带的下载工具它下载到本地任意目录下。
|
||||
|
||||
双击打开已下载的go1.16.5.windows-amd64.msi文件,我们就能看到下面这个安装引导界面:
|
||||
|
||||
|
||||
|
||||
和所有使用图形界面方式安装的Windows应用程序一样,我们只需一路点击“继续(next)”就可完成Go程序的安装了,安装程序默认会把Go安装在C:\Program Files\Go下面,当然你也可以自己定制你的安装目录。
|
||||
|
||||
除了会将Go安装到你的系统中之外,Go安装程序还会自动为你设置好Go使用所需的环境变量,包括在用户环境变量中增加GOPATH,它的值默认为C:\Users[用户名]\go,在系统变量中也会为Path变量增加一个值:C:\Program Files\Go\bin,这样我们就可以在任意路径下使用Go了。
|
||||
|
||||
安装完成后,我们可以打开Windows的“命令提示符”窗口(也就是CMD命令)来验证一下Go有没有安装成功。在命令行中输入go version,如果看到下面这个输出结果,那证明你这次安装就成功了:
|
||||
|
||||
C:\Users\tonybai>go version
|
||||
go version go1.16.5 windows/amd64
|
||||
|
||||
|
||||
到这里,我们已经把Go语言在主流操作系统上的安装讲完了,但这里其实我讲的都是安装一个版本的Go的方法,有些时候我们会有安装多个Go版本的需求,这点我们接着往下看。
|
||||
|
||||
安装多个Go版本
|
||||
|
||||
一般来说,Go初学者安装一个最新的Go版本就足够了,但随着Go编程的深入,我们通常会有使用多个Go版本的需求,比如一个版本用于学习或本地开发,另外一个版本用于生产构建等等。
|
||||
|
||||
安装多个Go版本其实也很简单,这里我给你介绍三种方法。
|
||||
|
||||
方法一:重新设置PATH环境变量
|
||||
|
||||
你只需要将不同版本的Go安装在不同路径下,然后将它们的Go二进制文件的所在路径加入到PATH环境变量中就可以了。
|
||||
|
||||
我们以Linux环境为例,在前面介绍Go标准安装方法的时候,我们已经将Go 1.16.5版本安装到了/usr/local/go下面,也将/usr/local/go/bin这个路径加入到了PATH路径下了,当前状态我们在任意路径下敲入go,执行的都是Go 1.16.5版本对应的Go二进制文件。
|
||||
|
||||
那这个时候,如果我们想再安装一个Go 1.15.13版本要怎么办呢?
|
||||
|
||||
首先,你需要按照标准步骤将Go 1.15.13安装到事先建好的/usr/local/go1.15.13路径下:
|
||||
|
||||
$mkdir /usr/local/go1.15.13
|
||||
$wget -c https://golang.google.cn/dl/go1.15.13.linux-amd64.tar.gz
|
||||
$tar -C /usr/local/go1.15.13 -xzf go1.15.13.linux-amd64.tar.gz
|
||||
|
||||
|
||||
接下来,我们来设置PATH环境变量,将原先$HOME/.profile中的PATH变量的值由:
|
||||
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
|
||||
改为:
|
||||
|
||||
export PATH=$PATH:/usr/local/go1.15.13/go/bin
|
||||
|
||||
|
||||
这样通过执行source命令重新使PATH环境变量生效后,我们再执行go version命令,会得到下面这样的结果:
|
||||
|
||||
$go version
|
||||
go version go1.15.13 linux/amd64
|
||||
|
||||
|
||||
这样,我们已经安装好两个Go版本了。这之后,我们如果要在Go 1.16.5和Go 1.15.13两个版本之间切换,只需要重新设置PATH环境变量并生效即可。
|
||||
|
||||
不过,你可能依然会觉得通过重新设置PATH环境变量的方法有些麻烦。没关系,Go官方也提供了一种在系统中安装多个Go版本的方法,下面我们就来看一下第二种方法。
|
||||
|
||||
方法二:go get命令
|
||||
|
||||
这种方法有一个前提,那就是当前系统中已经通过标准方法安装过某个版本的Go了。
|
||||
|
||||
我们还以Linux环境为例,假设目前环境中已经存在了采用标准方法安装的Go 1.16.5版本,我们接下来想再安装一个Go 1.15.13版本。按照Go官方方法,我们可以这样来做:
|
||||
|
||||
首先,将 $ HOME/go/bin加入到PATH环境变量中并生效,即便这个目录当前不存在也没关系:
|
||||
|
||||
export PATH=$PATH:/usr/local/go/bin:~/go/bin
|
||||
|
||||
|
||||
然后,我们要执行下面这个命令安装Go 1.15.13版本的下载器:
|
||||
|
||||
$go get golang.org/dl/go1.15.13
|
||||
|
||||
|
||||
这个命令会将名为Go 1.15.13的可执行文件安装到$HOME/go/bin这个目录下,它是Go 1.15.13版本的专用下载器,下面我们再来执行Go 1.15.13的下载安装命令:
|
||||
|
||||
$go1.15.13 download
|
||||
|
||||
Downloaded 0.0% ( 16384 / 121120420 bytes) ...
|
||||
Downloaded 1.8% ( 2129904 / 121120420 bytes) ...
|
||||
Downloaded 84.9% (102792432 / 121120420 bytes) ...
|
||||
Downloaded 100.0% (121120420 / 121120420 bytes)
|
||||
Unpacking /root/sdk/go1.15.13/go1.15.13.linux-amd64.tar.gz ...
|
||||
Success. You may now run 'go1.15.13'
|
||||
|
||||
|
||||
现在,我们看到这个命令下载了go1.15.13.linux-amd64.tar.gz安装包,也将它安装到$HOME/sdk/go1.15.13下面了。下载安装结束后,我们就可以利用带有版本号的go命令来使用特定版本的Go了:
|
||||
|
||||
$go1.15.13 version
|
||||
go version go1.15.13 linux/amd64
|
||||
|
||||
|
||||
同样的,我们也可以通过下面这个命令查看特定Go版本的安装位置:
|
||||
|
||||
$go1.15.13 env GOROOT
|
||||
/root/sdk/go1.15.13
|
||||
|
||||
|
||||
方法三:go get命令安装非稳定版本
|
||||
|
||||
其实,除了Go团队正式发布的稳定版本(stable version),像前面安装的Go 1.16.5或Go 1.15.13,我们还可以通过go get的方法安装Go团队正在开发的非稳定版本(Unstable Version),包括每个稳定版发布前的beta版本或当前最新的tip版本,这些非稳定版本可以让Go开发人员提前体验到即将加入到稳定版本中的新特性。
|
||||
|
||||
但是,通过go get安装不同Go版本的方法在中国大陆地区会因网络问题而失败。如果你已经克服了网络问题,那安装非稳定版本的步骤其实和上面的步骤一样。现在,我们以Go 1.17beta1和Go Tip版本为例,带你体验一下它们的安装步骤和验证方法。
|
||||
|
||||
首先我们来看Go 1.17beta1:
|
||||
|
||||
$go get golang.org/dl/go1.17beta1
|
||||
$go1.17beta1 download
|
||||
Downloaded 0.0% ( 3272 / 134470397 bytes) ...
|
||||
Downloaded 21.4% ( 28819248 / 134470397 bytes) ...
|
||||
Downloaded 58.1% ( 78069168 / 134470397 bytes) ...
|
||||
Downloaded 100.0% (134470397 / 134470397 bytes)
|
||||
Unpacking /root/sdk/go1.17beta1/go1.17beta1.linux-amd64.tar.gz ...
|
||||
Success. You may now run 'go1.17beta1'
|
||||
$go1.17beta1 version
|
||||
go version go1.17beta1 linux/amd64
|
||||
|
||||
|
||||
接着来看Go Tip版本:
|
||||
|
||||
$go get golang.org/dl/gotip
|
||||
$gotip download
|
||||
|
||||
|
||||
go get为我们安装tip版本提供了极大方便,要知道在以前,如果我们要安装tip版本,需要手工下载Go源码并自行编译。但你要注意的是:不是每次gotip安装都会成功,因为这毕竟是正在积极开发的版本,一次代码的提交就可能会导致gotip版本构建失败!
|
||||
|
||||
安装好Go之后,我们就该讲讲怎么配置了。
|
||||
|
||||
配置Go
|
||||
|
||||
其实Go在安装后是开箱即用的,这也意味着我们在使用Go之前无需做任何配置。但为了更好地了解和学习Go,我们还是要认识一些Go自带的常用配置项。Go的配置项是以环境变量的形式存在的,我们可以通过下面这个命令查看Go的这些配置项:
|
||||
|
||||
$go env
|
||||
|
||||
|
||||
这里我也给你总结了一些常用配置项:
|
||||
|
||||
|
||||
|
||||
如果你还要了解更多关于Go配置项的说明,你可以通过go help environment命令查看。
|
||||
|
||||
小结
|
||||
|
||||
好了,到这里我们的Go安装配置方法就讲解完毕了,选好你要使用的Go安装方法了吗?
|
||||
|
||||
在这一节课中我们首先讲解了三种Go版本的选择策略:
|
||||
|
||||
|
||||
第一种,也是我们推荐的一种,那就是使用Go最新的版本,这样你可以体验到Go的最新语言特性,应用到标准库的最新API以及Go工具链的最新功能,并且很多老版本中的bug在最新版本中都会得到及时修复;
|
||||
如果你还是对最新版本的稳定性有一丝担忧,你也可以选择使用次新版;
|
||||
最后,如果你要考虑现存生产项目或开源项目,那你按照需要选择,与项目策略保持一致就好了。
|
||||
|
||||
|
||||
确定完Go版本后,我们就可以来安装这个Go版本了。这一节课我们也详细介绍了在三个主流操作系统上安装Go稳定版本的方法。
|
||||
|
||||
对于使用Windows或macOS操作系统的开发者,使用基于图形界面的安装方式显然是最方便、最简洁的;对于使用Linux操作系统的开发者,使用自解压的安装包,或者是通过操作系统自带安装工具来进行Go安装比较普遍。
|
||||
|
||||
如果你是要在本地开发环境安装多个Go版本,或者是要抢先体验新版Go,我们还讲解了两种在本地安装多个Go版本的方法。这里再强调一下,通过go get方式安装最新的Go tip版本存在失败的可能性哦!
|
||||
|
||||
最后,我们讲解了Go的一些常用配置项的功用,对于中国地区的Go开发者而言,你在真正使用Go构建应用之前,唯一要做的就是配置GOPROXY这个Go环境变量。
|
||||
|
||||
有了Go开发环境,我们就有了编写和构建Go代码的基础,在下一讲中我们就将开始学习如何编写Go代码。
|
||||
|
||||
思考题
|
||||
|
||||
今天的课后思考题,我想请你在安装好的Go开发环境中,使用go help命令查看和总结一下Go命令的使用方法。
|
||||
|
||||
感谢你和我一起学习,也欢迎你把这节课分享给更多对Go语言学习感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
340
专栏/TonyBai·Go语言第一课/04初窥门径:一个Go程序的结构是怎样的?.md
Normal file
340
专栏/TonyBai·Go语言第一课/04初窥门径:一个Go程序的结构是怎样的?.md
Normal file
@@ -0,0 +1,340 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 初窥门径:一个Go程序的结构是怎样的?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
经过上一讲的学习,我想现在你已经成功安装好至少一个Go开发环境了,是时候撸起袖子开始写Go代码了!
|
||||
|
||||
程序员这个历史并不算悠久的行当,却有着一个历史悠久的传统,那就是每种编程语言都将一个名为“hello, world”的示例作为这门语言学习的第一个例子,这个传统始于20世纪70年代那本大名鼎鼎的由布莱恩·科尼根(Brian W. Kernighan)与C语言之父丹尼斯·里奇(Dennis M. Ritchie)合著的《C程序设计语言》。
|
||||
|
||||
|
||||
|
||||
在这一讲中,我们也将遵从传统,从编写一个可以打印出“hello, world”的Go示例程序开始我们正式的Go编码之旅。我希望通过这个示例程序你能够对Go程序结构有一个直观且清晰的认识。
|
||||
|
||||
在正式开始之前,我要说明一下,我们这节课对你开发Go程序时所使用的编辑器工具没有任何具体的要求。
|
||||
|
||||
如果你喜欢使用某个集成开发环境(Integrated Development Environment,IDE),那么就用你喜欢的IDE好了。如果你希望我给你推荐一些好用的IDE,我建议你试试GoLand或Visual Studio Code(简称VS Code)。GoLand是知名IDE出品公司JetBrains针对Go语言推出的IDE产品,也是目前市面上最好用的Go IDE;VS Code则是微软开源的跨语言源码编辑器,通过集成语言插件(Go开发者可以使用Go官方维护的vscode-go插件),可以让它变成类IDE的工具。
|
||||
|
||||
如果你有黑客情怀,喜欢像黑客一样优雅高效地使用命令行,那么像Vim、Emacs这样的基于终端的编辑器同样可以用于编写Go源码。以Vim为例,结合vim-go、coc.nvim(代码补全)以及Go官方维护的gopls语言服务器,你在编写Go代码时同样可以体会到“飞一般”的感觉。但在我们这门课中,我们将尽量使用与编辑器或IDE无关的说明。
|
||||
|
||||
好,我们正式开始吧。
|
||||
|
||||
创建“hello,world”示例程序
|
||||
|
||||
在Go语言中编写一个可以打印出“hello,world”的示例程序,我们只需要简单两步,一是创建文件夹,二是开始编写和运行。首先,我们来创建一个文件夹存储编写的Go代码。
|
||||
|
||||
创建“hello,world”文件夹
|
||||
|
||||
通常来说,Go不会限制我们存储代码的位置(Go 1.11之前的版本另当别论)。但是针对我们这门课里的各种练习和项目,我还是建议你创建一个可以集合所有项目的根文件夹(比如:~/goprojects),然后将我们这门课中所有的项目都放在里面。
|
||||
|
||||
现在,你可以打开终端并输入相应命令,来创建我们用于储存“hello,world”示例的文件夹helloworld了。对于Linux系统、macOS系统,以及Windows系统的PowerShell终端来说,用下面这个命令就可以建立helloworld文件夹了:
|
||||
|
||||
$mkdir ~/goprojects // 创建一个可以集合所有专栏项目的根文件夹
|
||||
$cd ~/goprojects
|
||||
$mkdir helloworld // 创建存储helloworld示例的文件夹
|
||||
$cd helloworld
|
||||
|
||||
|
||||
建好文件夹后,我们就要开始编写我们第一个Go程序了。
|
||||
|
||||
编写并运行第一个Go程序
|
||||
|
||||
首先,我们需要创建一个名为main.go的源文件。
|
||||
|
||||
这里,我需要跟你啰嗦一下Go的命名规则。Go源文件总是用全小写字母形式的短小单词命名,并且以.go扩展名结尾。
|
||||
|
||||
如果要在源文件的名字中使用多个单词,我们通常直接是将多个单词连接起来作为源文件名,而不是使用其他分隔符,比如下划线。也就是说,我们通常使用helloworld.go作为文件名而不是hello_world.go。
|
||||
|
||||
这是因为下划线这种分隔符,在Go源文件命名中有特殊作用,这个我们会在以后的讲解中详细说明。总的来说,我们尽量不要用两个以上的单词组合作为文件名,否则就很难分辨了。
|
||||
|
||||
现在,你可以打开刚刚创建的main.go文件,键入下面这些代码:
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("hello, world")
|
||||
}
|
||||
|
||||
|
||||
写完后,我们保存文件并回到终端窗口,然后在Linux或macOS系统中,你就可以通过输入下面这个命令来编译和运行这个文件了:
|
||||
|
||||
$go build main.go
|
||||
$./main
|
||||
hello, world
|
||||
|
||||
|
||||
如果是在Windows系统中呢,你需要把上面命令中的./main替换为.\main.exe。
|
||||
|
||||
>go build main.go
|
||||
>.\main.exe
|
||||
hello, world
|
||||
|
||||
|
||||
不过,无论你使用哪种操作系统,到这里你都应该能看到终端输出的“hello, world”字符串了。如果你没有看到这个输出结果,要么是Go安装过程的问题,要么是源文件编辑出现了问题,需要你再次认真地确认。如果一切顺利,那么恭喜你!你已经完成了第一个Go程序,并正式成为了Go开发者!欢迎来到Go语言的世界!
|
||||
|
||||
“hello,world”示例程序的结构
|
||||
|
||||
现在,让我们回过头来仔细看看“hello,world”示例程序中到底发生了什么。第一个值得注意的部分是这个:
|
||||
|
||||
package main
|
||||
|
||||
|
||||
这一行代码定义了Go中的一个包package。包是Go语言的基本组成单元,通常使用单个的小写单词命名,一个Go程序本质上就是一组包的集合。所有Go代码都有自己隶属的包,在这里我们的“hello,world”示例的所有代码都在一个名为main的包中。main包在Go中是一个特殊的包,整个Go程序中仅允许存在一个名为main的包。
|
||||
|
||||
main包中的主要代码是一个名为main的函数:
|
||||
|
||||
func main() {
|
||||
fmt.Println("hello, world")
|
||||
}
|
||||
|
||||
|
||||
这里的main函数会比较特殊:当你运行一个可执行的Go程序的时候,所有的代码都会从这个入口函数开始运行。这段代码的第一行声明了一个名为main的、没有任何参数和返回值的函数。如果某天你需要给函数声明参数的话,那么就必须把它们放置在圆括号()中。
|
||||
|
||||
另外,那对花括号{}被用来标记函数体,Go要求所有的函数体都要被花括号包裹起来。按照惯例,我们推荐把左花括号与函数声明置于同一行并以空格分隔。Go语言内置了一套Go社区约定俗称的代码风格,并随安装包提供了一个名为Gofmt的工具,这个工具可以帮助你将代码自动格式化为约定的风格。
|
||||
|
||||
Gofmt是Go语言在解决规模化(scale)问题上的一个最佳实践,并成为了Go语言吸引其他语言开发者的一大卖点。很多其他主流语言也在效仿Go语言推出自己的format工具,比如:Java formatter、Clang formatter、Dartfmt等。因此,作为Go开发人员,请在提交你的代码前使用Gofmt格式化你的Go源码。
|
||||
|
||||
好,回到正题,我们再来看一看main函数体中的代码:
|
||||
|
||||
fmt.Println("hello, world")
|
||||
|
||||
|
||||
这一行代码已经完成了整个示例程序的所有工作了:将字符串输出到终端的标准输出(stdout)上。不过这里还有几个需要你注意的细节。
|
||||
|
||||
注意点1:标准Go代码风格使用Tab而不是空格来实现缩进的,当然这个代码风格的格式化工作也可以交由gofmt完成。
|
||||
|
||||
注意点2:我们调用了一个名为Println的函数,这个函数位于Go标准库的fmt包中。为了在我们的示例程序中使用fmt包定义的Println函数,我们其实做了两步操作。
|
||||
|
||||
第一步是在源文件的开始处通过import声明导入fmt包的包路径:
|
||||
|
||||
import "fmt"
|
||||
|
||||
|
||||
第二步则是在main函数体中,通过fmt这个限定标识符(Qualified Identifier)调用Println函数。虽然两处都使用了“fmt”这个字面值,但在这两处“fmt”字面值所代表的含义却是不一样的:
|
||||
|
||||
|
||||
import “fmt” 一行中“fmt”代表的是包的导入路径(Import),它表示的是标准库下的fmt目录,整个import声明语句的含义是导入标准库fmt目录下的包;
|
||||
fmt.Println函数调用一行中的“fmt”代表的则是包名。
|
||||
|
||||
|
||||
通常导入路径的最后一个分段名与包名是相同的,这也很容易让人误解import声明语句中的“fmt”指的是包名,其实并不是这样的。
|
||||
|
||||
main函数体中之所以可以调用fmt包的Println函数,还有最后一个原因,那就是Println函数名的首字母是大写的。在Go语言中,只有首字母为大写的标识符才是导出的(Exported),才能对包外的代码可见;如果首字母是小写的,那么就说明这个标识符仅限于在声明它的包内可见。
|
||||
|
||||
另外,在Go语言中,main包是不可以像标准库fmt包那样被导入(Import)的,如果导入main包,在代码编译阶段你会收到一个Go编译器错误:import “xx/main” is a program, not an importable package。
|
||||
|
||||
注意点3:我们还是回到main函数体实现上,把关注点放在传入到Println函数的字符串“hello, world”上面。你会发现,我们传入的字符串也就是我们执行程序后在终端的标准输出上看到的字符串。
|
||||
|
||||
这种“所见即所得”得益于Go源码文件本身采用的是Unicode字符集,而且用的是UTF-8标准的字符编码方式,这与编译后的程序所运行的环境所使用的字符集和字符编码方式是一致的。
|
||||
|
||||
这里,即便我们将代码中的”hello, world”换成中文字符串“你好,世界”,像下面这样:
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("你好,世界")
|
||||
}
|
||||
|
||||
|
||||
我们依旧可以在终端的标准输出上看到正确的输出。
|
||||
|
||||
最后,不知道你有没有发现,我们整个示例程序源码中,都没有使用过分号来标识语句的结束,这与C、C++、Java那些传统编译型语言好像不太一样呀?
|
||||
|
||||
不过,其实Go语言的正式语法规范是使用分号“;”来做结尾标识符的。那为什么我们很少在Go代码中使用和看到分号呢?这是因为,大多数分号都是可选的,常常被省略,不过在源码编译时,Go编译器会自动插入这些被省略的分号。
|
||||
|
||||
我们给上面的“hello,world”示例程序加上分号也是完全合法的,是可以直接通过Go编译器编译并正常运行的。不过,gofmt在按约定格式化代码时,会自动删除这些被我们手工加入的分号的。
|
||||
|
||||
在分析完这段代码结构后,我们来讲一下Go语言的编译。虽然刚刚你应该已经运行过“hello, world”这个示例程序了,在这过程中,有一个重要的步骤——编译,现在我就带你来看看Go语言中程序是怎么进行编译的。
|
||||
|
||||
Go语言中程序是怎么编译的?
|
||||
|
||||
你应该也注意到了,刚刚我在运行”hello, world”程序之前,输入了go build命令,还有它附带的源文件名参数来编译它:
|
||||
|
||||
$go build main.go
|
||||
|
||||
|
||||
假如你曾经有过C/C++语言的开发背景,那么你就会发现这个步骤与gcc或clang编译十分相似。一旦编译成功,我们就会获得一个二进制的可执行文件。在Linux系统、macOS系统,以及Windows系统的PowerShell中,我们可以通过输入下面这个ls命令看到刚刚生成的可执行文件:
|
||||
|
||||
$ls
|
||||
main* main.go
|
||||
|
||||
|
||||
上面显示的文件里面有我们刚刚创建的、以.go为后缀的源代码文件,还有刚生成的可执行文件(Windows系统下为main.exe,其余系统下为main)。
|
||||
|
||||
如果你之前更熟悉某种类似于Ruby、Python或JavaScript之类的动态语言,你可能还不太习惯在运行之前需要先进行编译的情况。Go是一种编译型语言,这意味着只有你编译完Go程序之后,才可以将生成的可执行文件交付于其他人,并运行在没有安装Go的环境中。
|
||||
|
||||
而如果你交付给其他人的是一份.rb、.py或.js的动态语言的源文件,那么他们的目标环境中就必须要拥有对应的Ruby、Python或JavaScript实现才能解释执行这些源文件。
|
||||
|
||||
当然,Go也借鉴了动态语言的一些对开发者体验较好的特性,比如基于源码文件的直接执行,Go提供了run命令可以直接运行Go源码文件,比如我们也可以使用下面命令直接基于main.go运行:
|
||||
|
||||
$go run main.go
|
||||
hello, world
|
||||
|
||||
|
||||
当然像go run这类命令更多用于开发调试阶段,真正的交付成果还是需要使用go build命令构建的。
|
||||
|
||||
但是在我们的生产环境里,Go程序的编译往往不会像我们前面,基于单个Go源文件构建类似“hello,world”这样的示例程序那么简单。越贴近真实的生产环境,也就意味着项目规模越大、协同人员越多,项目的依赖和依赖的版本都会变得复杂。
|
||||
|
||||
那在我们更复杂的生产环境中,go build命令也能圆满完成我们的编译任务吗?我们现在就来探讨一下。
|
||||
|
||||
复杂项目下Go程序的编译是怎样的
|
||||
|
||||
我们还是直接上项目吧,给go build 一个机会,看看它的复杂依赖管理到底怎么样。
|
||||
|
||||
现在我们创建一个新项目“hellomodule”,在新项目中我们将使用两个第三方库,zap和fasthttp,给go build的构建过程增加一些难度。和“hello,world”示例一样,我们通过下面命令创建“hellomodule”项目:
|
||||
|
||||
$cd ~/goprojects
|
||||
$mkdir hellomodule
|
||||
$cd hellomodule
|
||||
|
||||
|
||||
接着,我们在“hellomodule“下创建并编辑我们的示例源码文件:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var logger *zap.Logger
|
||||
|
||||
func init() {
|
||||
logger, _ = zap.NewProduction()
|
||||
}
|
||||
|
||||
func fastHTTPHandler(ctx *fasthttp.RequestCtx) {
|
||||
logger.Info("hello, go module", zap.ByteString("uri", ctx.RequestURI()))
|
||||
}
|
||||
|
||||
func main() {
|
||||
fasthttp.ListenAndServe(":8081", fastHTTPHandler)
|
||||
}
|
||||
|
||||
|
||||
这个示例创建了一个在8081端口监听的http服务,当我们向它发起请求后,这个服务会在终端标准输出上输出一段访问日志。
|
||||
|
||||
你会看到,和“hello,world“相比,这个示例显然要复杂许多。但不用担心,你现在大可不必知道每行代码的功用,你只需要我们在这个稍微有点复杂的示例中引入了两个第三方依赖库,zap和fasthttp就可以了。
|
||||
|
||||
我们尝试一下使用编译“hello,world”的方法来编译“hellomodule”中的main.go源文件,go编译器的输出结果是这样的:
|
||||
|
||||
$go build main.go
|
||||
main.go:4:2: no required module provides package github.com/valyala/fasthttp: go.mod file not found in current directory or any parent directory; see 'go help modules'
|
||||
main.go:5:2: no required module provides package go.uber.org/zap: go.mod file not found in current directory or any parent directory; see 'go help modules'
|
||||
|
||||
|
||||
看这结果,这回我们运气似乎不佳,main.go的编译失败了!
|
||||
|
||||
从编译器的输出来看,go build似乎在找一个名为go.mod的文件,来解决程序对第三方包的依赖决策问题。
|
||||
|
||||
好了,我们也不打哑谜了,是时候让Go module登场了!
|
||||
|
||||
Go module构建模式是在Go 1.11版本正式引入的,为的是彻底解决Go项目复杂版本依赖的问题,在Go 1.16版本中,Go module已经成为了Go默认的包依赖管理机制和Go源码构建机制。
|
||||
|
||||
Go Module的核心是一个名为go.mod的文件,在这个文件中存储了这个module对第三方依赖的全部信息。接下来,我们就通过下面命令为“hello,module”这个示例程序添加go.mod文件:
|
||||
|
||||
$go mod init github.com/bigwhite/hellomodule
|
||||
go: creating new go.mod: module github.com/bigwhite/hellomodule
|
||||
go: to add module requirements and sums:
|
||||
go mod tidy
|
||||
|
||||
|
||||
你会看到,go mod init命令的执行结果是在当前目录下生成了一个go.mod文件:
|
||||
|
||||
$cat go.mod
|
||||
module github.com/bigwhite/hellomodule
|
||||
|
||||
go 1.16
|
||||
|
||||
|
||||
其实,一个module就是一个包的集合,这些包和module一起打版本、发布和分发。go.mod所在的目录被我们称为它声明的module的根目录。
|
||||
|
||||
不过呢,这个时候的go.mod文件内容还比较简单,第一行内容是用于声明module路径(module path)的。而且,module隐含了一个命名空间的概念,module下每个包的导入路径都是由module path和包所在子目录的名字结合在一起构成。
|
||||
|
||||
比如,如果hellomodule下有子目录pkg/pkg1,那么pkg1下面的包的导入路径就是由module path(github.com/bigwhite/hellomodule)和包所在子目录的名字(pkg/pkg1)结合而成,也就是github.com/bigwhite/hellomodule/pkg/pkg1。
|
||||
|
||||
另外,go.mod的最后一行是一个Go版本指示符,用于表示这个module是在某个特定的Go版本的module语义的基础上编写的。
|
||||
|
||||
有了go.mod后,是不是我们就可以构建hellomodule示例了呢?
|
||||
|
||||
来试试看!我们执行一下构建,Go编译器输出结果是这样的:
|
||||
|
||||
$go build main.go
|
||||
main.go:4:2: no required module provides package github.com/valyala/fasthttp; to add it:
|
||||
go get github.com/valyala/fasthttp
|
||||
main.go:5:2: no required module provides package go.uber.org/zap; to add it:
|
||||
go get go.uber.org/zap
|
||||
|
||||
|
||||
你会看到,Go编译器提示源码依赖fasthttp和zap两个第三方包,但是go.mod中没有这两个包的版本信息,我们需要按提示手工添加信息到go.mod中。
|
||||
|
||||
这个时候,除了按提示手动添加外,我们也可以使用go mod tidy命令,让Go工具自动添加:
|
||||
|
||||
$go mod tidy
|
||||
go: downloading go.uber.org/zap v1.18.1
|
||||
go: downloading github.com/valyala/fasthttp v1.28.0
|
||||
go: downloading github.com/andybalholm/brotli v1.0.2
|
||||
... ...
|
||||
|
||||
|
||||
从输出结果中,我们看到Go工具不仅下载并添加了hellomodule直接依赖的zap和fasthttp包的信息,还下载了这两个包的相关依赖包。go mod tidy执行后,我们go.mod的最新内容变成了这个样子:
|
||||
|
||||
module github.com/bigwhite/hellomodule
|
||||
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/valyala/fasthttp v1.28.0
|
||||
go.uber.org/zap v1.18.1
|
||||
)
|
||||
|
||||
|
||||
这个时候,go.mod已经记录了hellomodule直接依赖的包的信息。不仅如此,hellomodule目录下还多了一个名为go.sum的文件,这个文件记录了hellomodule的直接依赖和间接依赖包的相关版本的hash值,用来校验本地包的真实性。在构建的时候,如果本地依赖包的hash值与go.sum文件中记录的不一致,就会被拒绝构建。
|
||||
|
||||
有了go.mod以及hellomodule依赖的包版本信息后,我们再来执行构建:
|
||||
|
||||
$go build main.go
|
||||
$ls
|
||||
go.mod go.sum main* main.go
|
||||
|
||||
|
||||
这次我们成功构建出了可执行文件main,运行这个文件,新开一个终端窗口,在新窗口中使用curl命令访问该http服务:curl localhost:8081/foo/bar,我们就会看到服务端输出如下日志:
|
||||
|
||||
$./main
|
||||
{"level":"info","ts":1626614126.9899719,"caller":"hellomodule/main.go:15","msg":"hello, go module","uri":"/foo/bar"}
|
||||
|
||||
|
||||
这下,我们的“ hellomodule”程序可算创建成功了。我们也看到使用Go Module的构建模式,go build完全可以承担其构建规模较大、依赖复杂的Go项目的重任。还有更多关于Go Module的内容,我会在第7节课再详细跟你讲解。
|
||||
|
||||
小结
|
||||
|
||||
到这里,我们终于亲手编写完成了Go语言的第一个程序“hello, world”,我们终于知道一个Go程序长成啥样子了,这让我们在自己的Go旅程上迈出了坚实的一步!
|
||||
|
||||
在这一节课里,我们通过helloworld示例程序,了解了一个Go程序的源码结构与代码风格自动格式化的约定。
|
||||
|
||||
我希望你记住这几个要点:
|
||||
|
||||
|
||||
Go包是Go语言的基本组成单元。一个Go程序就是一组包的集合,所有Go代码都位于包中;
|
||||
Go源码可以导入其他Go包,并使用其中的导出语法元素,包括类型、变量、函数、方法等,而且,main函数是整个Go应用的入口函数;
|
||||
Go源码需要先编译,再分发和运行。如果是单Go源文件的情况,我们可以直接使用go build命令+Go源文件名的方式编译。不过,对于复杂的Go项目,我们需要在Go Module的帮助下完成项目的构建。
|
||||
|
||||
|
||||
最后,我们结合hellomodule示例初步学习了一个基于Go Module构建模式编写和构建更大规模Go程序的步骤并介绍了Go Module涉及到的各种概念。而且,Go Module机制日渐成熟,我希望你学会基于Go Module构建Go应用。关于Go Module构建模式,我们还会在后面的讲解中详细介绍。
|
||||
|
||||
思考题
|
||||
|
||||
今天我给你留了一道思考题,经过今天这节课,你喜欢Go统一的代码风格吗?你觉得Go这么做的利弊都有哪些呢?欢迎在留言区和我探讨。
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言学习感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
326
专栏/TonyBai·Go语言第一课/05标准先行:Go项目的布局标准是什么?.md
Normal file
326
专栏/TonyBai·Go语言第一课/05标准先行:Go项目的布局标准是什么?.md
Normal file
@@ -0,0 +1,326 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 标准先行:Go项目的布局标准是什么?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在前面的讲解中,我们编写的Go程序都是简单程序,一般由一个或几个Go源码文件组成,而且所有源码文件都在同一个目录中。但是生产环境中运行的实用程序可不会这么简单,通常它们都有着复杂的项目结构布局。弄清楚一个实用Go项目的项目布局标准是Go开发者走向编写复杂Go程序的第一步,也是必经的一步。
|
||||
|
||||
但Go官方到目前为止也没有给出一个关于Go项目布局标准的正式定义。那在这样的情况下,Go社区是否有我们可以遵循的参考布局,或者事实标准呢?我可以肯定的告诉你:有的。在这一节课里,我就来告诉你Go社区广泛采用的Go项目布局是什么样子的。
|
||||
|
||||
要想了解Go项目的结构布局以及演化历史,全世界第一个Go语言项目是一个最好的切入点。所以,我们就先来看一下Go语言“创世项目”的结构布局是什么样的。
|
||||
|
||||
Go语言“创世项目”结构是怎样的?
|
||||
|
||||
什么是“Go语言的创世项目”呢?其实就是Go语言项目自身,它是全世界第一个Go语言项目。但这么说也不够精确,因为Go语言项目从项目伊始就混杂着多种语言,而且以C和Go代码为主,Go语言的早期版本C代码的比例还不小。
|
||||
|
||||
我们先用loccount工具对Go语言发布的第一个Go 1.0版本分析看看:
|
||||
|
||||
$loccount .
|
||||
all SLOC=460992 (100.00%) LLOC=193045 in 2746 files
|
||||
Go SLOC=256321 (55.60%) LLOC=109763 in 1983 files
|
||||
C SLOC=148001 (32.10%) LLOC=73458 in 368 files
|
||||
HTML SLOC=25080 (5.44%) LLOC=0 in 57 files
|
||||
asm SLOC=10109 (2.19%) LLOC=0 in 133 files
|
||||
... ...
|
||||
|
||||
|
||||
你会发现,在1.0版本中,Go代码行数占据一半以上比例,但是C语言代码行数也占据了32.10%的份额。而且在后续Go版本演进过程中,Go语言代码行数占比还在逐步提升,直到Go 1.5版本实现自举后,Go语言代码行数占比将近90%,C语言比例下降为不到1%,这一比例一直延续至今。
|
||||
|
||||
虽然C代码比例下降,Go代码比例上升,但Go语言项目的布局结构却整体保留了下来,十多年间虽然也有一些小范围变动,但整体没有本质变化。作为Go语言的“创世项目”,它的结构布局对后续Go社区的项目具有重要的参考价值,尤其是Go项目早期src目录下面的结构。
|
||||
|
||||
为了方便查看,我们首先下载Go语言创世项目源码:
|
||||
|
||||
$git clone https://github.com/golang/go.git
|
||||
|
||||
|
||||
进入Go语言项目根目录后,我们使用tree命令来查看一下Go语言项目自身的最初源码结构布局,以Go 1.3版本为例,结果是这样的:
|
||||
|
||||
$cd go // 进入Go语言项目根目录
|
||||
$git checkout go1.3 // 切换到go 1.3版本
|
||||
$tree -LF 1 ./src // 查看src目录下的结构布局
|
||||
./src
|
||||
├── all.bash*
|
||||
├── clean.bash*
|
||||
├── cmd/
|
||||
├── make.bash*
|
||||
├── Make.dist
|
||||
├── pkg/
|
||||
├── race.bash*
|
||||
├── run.bash*
|
||||
... ...
|
||||
└── sudo.bash*
|
||||
|
||||
|
||||
从上面的结果来看,src目录下面的结构有这三个特点。
|
||||
|
||||
首先,你可以看到,以all.bash为代表的代码构建的脚本源文件放在了src下面的顶层目录下。
|
||||
|
||||
第二,src下的二级目录cmd下面存放着Go相关可执行文件的相关目录,我们可以深入查看一下cmd目录下的结构:
|
||||
|
||||
$ tree -LF 1 ./cmd
|
||||
./cmd
|
||||
... ...
|
||||
├── 6a/
|
||||
├── 6c/
|
||||
├── 6g/
|
||||
... ...
|
||||
├── cc/
|
||||
├── cgo/
|
||||
├── dist/
|
||||
├── fix/
|
||||
├── gc/
|
||||
├── go/
|
||||
├── gofmt/
|
||||
├── ld/
|
||||
├── nm/
|
||||
├── objdump/
|
||||
├── pack/
|
||||
└── yacc/
|
||||
|
||||
|
||||
我们可以看到,这里的每个子目录都是一个Go工具链命令或子命令对应的可执行文件。其中,6a、6c、6g等是早期Go版本针对特定平台的汇编器、编译器等的特殊命名方式。
|
||||
|
||||
第三个特点,你会看到src下的二级目录pkg下面存放着运行时实现、标准库包实现,这些包既可以被上面cmd下各程序所导入,也可以被Go语言项目之外的Go程序依赖并导入。下面是我们通过tree命令查看pkg下面结构的输出结果:
|
||||
|
||||
# tree -LF 1 ./pkg
|
||||
./pkg
|
||||
... ...
|
||||
├── flag/
|
||||
├── fmt/
|
||||
├── go/
|
||||
├── hash/
|
||||
├── html/
|
||||
├── image/
|
||||
├── index/
|
||||
├── io/
|
||||
... ...
|
||||
├── net/
|
||||
├── os/
|
||||
├── path/
|
||||
├── reflect/
|
||||
├── regexp/
|
||||
├── runtime/
|
||||
├── sort/
|
||||
├── strconv/
|
||||
├── strings/
|
||||
├── sync/
|
||||
├── syscall/
|
||||
├── testing/
|
||||
├── text/
|
||||
├── time/
|
||||
├── unicode/
|
||||
└── unsafe/
|
||||
|
||||
|
||||
虽然Go语言的创世项目的src目录下的布局结构,离现在已经比较久远了,但是这样的布局特点依然对后续很多Go项目的布局产生了比较大的影响,尤其是那些Go语言早期采纳者建立的Go项目。比如,Go调试器项目Delve、开启云原生时代的Go项目Docker,以及云原生时代的“操作系统”项目Kubernetes等,它们的项目布局,至今都还保持着与Go创世项目早期相同的风格。
|
||||
|
||||
当然了,这些早期的布局结构一直在不断地演化,简单来说可以归纳为下面三个比较重要的演进。
|
||||
|
||||
演进一:Go 1.4版本删除pkg这一中间层目录并引入internal目录
|
||||
|
||||
出于简化源码树层次的原因,Go语言项目的Go 1.4版本对它原来的src目录下的布局做了两处调整。第一处是删除了Go源码树中“src/pkg/xxx”中pkg这一层级目录而直接使用src/xxx。这样一来,Go语言项目的源码树深度减少一层,更便于Go开发者阅读和探索Go项目源码。
|
||||
|
||||
另外一处就是Go 1.4引入internal包机制,增加了internal目录。这个internal机制其实是所有Go项目都可以用的,Go语言项目自身也是自Go 1.4版本起,就使用internal机制了。根据internal机制的定义,一个Go项目里的internal目录下的Go包,只可以被本项目内部的包导入。项目外部是无法导入这个internal目录下面的包的。可以说,internal目录的引入,让一个Go项目中Go包的分类与用途变得更加清晰。
|
||||
|
||||
演进二:Go1.6版本增加vendor目录
|
||||
|
||||
第二次的演进,其实是为了解决Go包依赖版本管理的问题,Go核心团队在Go 1.5版本中做了第一次改进。增加了vendor构建机制,也就是Go源码的编译可以不在GOPATH环境变量下面搜索依赖包的路径,而在vendor目录下查找对应的依赖包。
|
||||
|
||||
Go语言项目自身也在Go 1.6版本中增加了vendor目录以支持vendor构建,但vendor目录并没有实质性缓存任何第三方包。直到Go 1.7版本,Go才真正在vendor下缓存了其依赖的外部包。这些依赖包主要是golang.org/x下面的包,这些包同样是由Go核心团队维护的,并且其更新速度不受Go版本发布周期的影响。
|
||||
|
||||
vendor机制与目录的引入,让Go项目第一次具有了可重现构建(Reproducible Build)的能力。
|
||||
|
||||
演进三:Go 1.13版本引入go.mod和go.sum
|
||||
|
||||
第三次演进,还是为了解决Go包依赖版本管理的问题。在Go 1.11版本中,Go核心团队做出了第二次改进尝试:引入了Go Module构建机制,也就是在项目引入go.mod以及在go.mod中明确项目所依赖的第三方包和版本,项目的构建就将摆脱GOPATH的束缚,实现精准的可重现构建。
|
||||
|
||||
Go语言项目自身在Go 1.13版本引入go.mod和go.sum以支持Go Module构建机制,下面是Go 1.13版本的go.mod文件内容:
|
||||
|
||||
module std
|
||||
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7
|
||||
golang.org/x/sys v0.0.0-20190529130038-5219a1e1c5f8 // indirect
|
||||
golang.org/x/text v0.3.2 // indirect
|
||||
)
|
||||
|
||||
|
||||
我们看到,Go语言项目自身所依赖的包在go.mod中都有对应的信息,而原本这些依赖包是缓存在vendor目录下的。
|
||||
|
||||
总的来说,这三次演进主要体现在简化结构布局,以及优化包依赖管理方面,起到了改善Go开发体验的作用。可以说,Go创世项目的源码布局以及演化对Go社区项目的布局具有重要的启发意义,以至于在多年的Go社区实践后,Go社区逐渐形成了公认的Go项目的典型结构布局。
|
||||
|
||||
现在的Go项目的典型结构布局是怎样的?
|
||||
|
||||
一个Go项目通常分为可执行程序项目和库项目,现在我们就来分析一下这两类Go项目的典型结构布局分别是怎样的。
|
||||
|
||||
首先我们先来看Go可执行程序项目的典型结构布局。
|
||||
|
||||
可执行程序项目是以构建可执行程序为目的的项目,Go社区针对这类Go项目所形成的典型结构布局是这样的:
|
||||
|
||||
$tree -F exe-layout
|
||||
exe-layout
|
||||
├── cmd/
|
||||
│ ├── app1/
|
||||
│ │ └── main.go
|
||||
│ └── app2/
|
||||
│ └── main.go
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── internal/
|
||||
│ ├── pkga/
|
||||
│ │ └── pkg_a.go
|
||||
│ └── pkgb/
|
||||
│ └── pkg_b.go
|
||||
├── pkg1/
|
||||
│ └── pkg1.go
|
||||
├── pkg2/
|
||||
│ └── pkg2.go
|
||||
└── vendor/
|
||||
|
||||
|
||||
这样的一个Go项目典型布局就是“脱胎”于Go创世项目的最新结构布局,我现在跟你解释一下这里面的几个要点。
|
||||
|
||||
我们从上往下按顺序来,先来看cmd目录。cmd目录就是存放项目要编译构建的可执行文件对应的main包的源文件。如果你的项目中有多个可执行文件需要构建,每个可执行文件的main包单独放在一个子目录中,比如图中的app1、app2,cmd目录下的各app的main包将整个项目的依赖连接在一起。
|
||||
|
||||
而且通常来说,main包应该很简洁。我们在main包中会做一些命令行参数解析、资源初始化、日志设施初始化、数据库连接初始化等工作,之后就会将程序的执行权限交给更高级的执行控制对象。另外,也有一些Go项目将cmd这个名字改为app或其他名字,但它的功能其实并没有变。
|
||||
|
||||
接着我们来看pkgN目录,这是一个存放项目自身要使用、同样也是可执行文件对应main包所要依赖的库文件,同时这些目录下的包还可以被外部项目引用。
|
||||
|
||||
然后是go.mod和go.sum,它们是Go语言包依赖管理使用的配置文件。我们前面说过,Go 1.11版本引入了Go Module构建机制,这里我建议你所有新项目都基于Go Module来进行包依赖管理,因为这是目前Go官方推荐的标准构建模式。
|
||||
|
||||
对于还没有使用Go Module进行包依赖管理的遗留项目,比如之前采用dep、glide等作为包依赖管理工具的,建议尽快迁移到Go Module模式。Go命令支持直接将dep的Gopkg.toml/Gopkg.lock或glide的glide.yaml/glide.lock转换为go.mod。
|
||||
|
||||
最后我们再来看看vendor目录。vendor是Go 1.5版本引入的用于在项目本地缓存特定版本依赖包的机制,在Go Modules机制引入前,基于vendor可以实现可重现构建,保证基于同一源码构建出的可执行程序是等价的。
|
||||
|
||||
不过呢,我们这里将vendor目录视为一个可选目录。原因在于,Go Module本身就支持可再现构建,而无需使用vendor。 当然Go Module机制也保留了vendor目录(通过go mod vendor可以生成vendor下的依赖包,通过go build -mod=vendor可以实现基于vendor的构建)。一般我们仅保留项目根目录下的vendor目录,否则会造成不必要的依赖选择的复杂性。
|
||||
|
||||
当然了,有些开发者喜欢借助一些第三方的构建工具辅助构建,比如:make、bazel等。你可以将这类外部辅助构建工具涉及的诸多脚本文件(比如Makefile)放置在项目的顶层目录下,就像Go创世项目中的all.bash那样。
|
||||
|
||||
另外,这里只要说明一下的是,Go 1.11引入的module是一组同属于一个版本管理单元的包的集合。并且Go支持在一个项目/仓库中存在多个module,但这种管理方式可能要比一定比例的代码重复引入更多的复杂性。 因此,如果项目结构中存在版本管理的“分歧”,比如:app1和app2的发布版本并不总是同步的,那么我建议你将项目拆分为多个项目(仓库),每个项目单独作为一个module进行单独的版本管理和演进。
|
||||
|
||||
当然如果你非要在一个代码仓库中存放多个module,那么新版Go命令也提供了很好的支持。比如下面代码仓库multi-modules下面有三个module:mainmodule、module1和module2:
|
||||
|
||||
$tree multi-modules
|
||||
multi-modules
|
||||
├── go.mod // mainmodule
|
||||
├── module1
|
||||
│ └── go.mod // module1
|
||||
└── module2
|
||||
└── go.mod // module2
|
||||
|
||||
|
||||
我们可以通过git tag名字来区分不同module的版本。其中vX.Y.Z形式的tag名字用于代码仓库下的mainmodule;而module1/vX.Y.Z形式的tag名字用于指示module1的版本;同理,module2/vX.Y.Z形式的tag名字用于指示module2版本。
|
||||
|
||||
如果Go可执行程序项目有一个且只有一个可执行程序要构建,那就比较好办了,我们可以将上面项目布局进行简化:
|
||||
|
||||
$tree -F -L 1 single-exe-layout
|
||||
single-exe-layout
|
||||
├── go.mod
|
||||
├── internal/
|
||||
├── main.go
|
||||
├── pkg1/
|
||||
├── pkg2/
|
||||
└── vendor/
|
||||
|
||||
|
||||
你可以看到,我们删除了cmd目录,将唯一的可执行程序的main包就放置在项目根目录下,而其他布局元素的功用不变。
|
||||
|
||||
好了到这里,我们已经了解了Go可执行程序项目的典型布局,现在我们再来看看Go库项目的典型结构布局是怎样的。
|
||||
|
||||
Go库项目仅对外暴露Go包,这类项目的典型布局形式是这样的:
|
||||
|
||||
$tree -F lib-layout
|
||||
lib-layout
|
||||
├── go.mod
|
||||
├── internal/
|
||||
│ ├── pkga/
|
||||
│ │ └── pkg_a.go
|
||||
│ └── pkgb/
|
||||
│ └── pkg_b.go
|
||||
├── pkg1/
|
||||
│ └── pkg1.go
|
||||
└── pkg2/
|
||||
└── pkg2.go
|
||||
|
||||
|
||||
我们看到,库类型项目相比于Go可执行程序项目的布局要简单一些。因为这类项目不需要构建可执行程序,所以去除了cmd目录。
|
||||
|
||||
而且,在这里,vendor也不再是可选目录了。对于库类型项目而言,我们并不推荐在项目中放置vendor目录去缓存库自身的第三方依赖,库项目仅通过go.mod文件明确表述出该项目依赖的module或包以及版本要求就可以了。
|
||||
|
||||
Go库项目的初衷是为了对外部(开源或组织内部公开)暴露API,对于仅限项目内部使用而不想暴露到外部的包,可以放在项目顶层的internal目录下面。当然internal也可以有多个并存在于项目结构中的任一目录层级中,关键是项目结构设计人员要明确各级internal包的应用层次和范围。
|
||||
|
||||
对于有一个且仅有一个包的Go库项目来说,我们也可以将上面的布局做进一步简化,简化的布局如下所示:
|
||||
|
||||
$tree -L 1 -F single-pkg-lib-layout
|
||||
single-pkg-lib-layout
|
||||
├── feature1.go
|
||||
├── feature2.go
|
||||
├── go.mod
|
||||
└── internal/
|
||||
|
||||
|
||||
简化后,我们将这唯一包的所有源文件放置在项目的顶层目录下(比如上面的feature1.go和feature2.go),其他布局元素位置和功用不变。
|
||||
|
||||
好了,现在我们已经了解完目前Go项目的典型结构布局了。不过呢,除了这些之外,还要注意一下早期Go可执行程序项目的经典布局,这个又有所不同。
|
||||
|
||||
注意早期Go可执行程序项目的典型布局
|
||||
|
||||
很多早期接纳Go语言的开发者所建立的Go可执行程序项目,深受Go创世项目1.4版本之前的布局影响,这些项目将所有可暴露到外面的Go包聚合在pkg目录下,就像前面Go 1.3版本中的布局那样,它们的典型布局结构是这样的:
|
||||
|
||||
$tree -L 3 -F early-project-layout
|
||||
early-project-layout
|
||||
└── exe-layout/
|
||||
├── cmd/
|
||||
│ ├── app1/
|
||||
│ └── app2/
|
||||
├── go.mod
|
||||
├── internal/
|
||||
│ ├── pkga/
|
||||
│ └── pkgb/
|
||||
├── pkg/
|
||||
│ ├── pkg1/
|
||||
│ └── pkg2/
|
||||
└── vendor/
|
||||
|
||||
|
||||
我们看到,原本放在项目顶层目录下的pkg1和pkg2公共包被统一聚合到pkg目录下了。而且,这种早期Go可执行程序项目的典型布局在Go社区内部也不乏受众,很多新建的Go项目依然采用这样的项目布局。
|
||||
|
||||
所以,当你看到这样的布局也不要奇怪,并且在我的讲解后,你应该就明确在这样的布局下pkg目录所起到的“聚类”的作用了。不过,在这里还是建议你在创建新的Go项目时,优先采用前面的标准项目布局。
|
||||
|
||||
小结
|
||||
|
||||
到这里,我们今天这门课就结束了。在这一节课里,我们学习了Go创世项目,也就是Go语言项目自身的项目源码布局,以及演进情况。在Go创世项目的启发下,Go社区在多年实践中形成了典型的Go项目结构布局形式。
|
||||
|
||||
我们将Go项目分为可执行程序项目和Go库项目两类进行了详细的项目典型布局讲解,这里简单回顾一下。
|
||||
|
||||
首先,对于以生产可执行程序为目的的Go项目,它的典型项目结构分为五部分:
|
||||
|
||||
|
||||
放在项目顶层的Go Module相关文件,包括go.mod和go.sum;
|
||||
cmd目录:存放项目要编译构建的可执行文件所对应的main包的源码文件;
|
||||
项目包目录:每个项目下的非main包都“平铺”在项目的根目录下,每个目录对应一个Go包;
|
||||
internal目录:存放仅项目内部引用的Go包,这些包无法被项目之外引用;
|
||||
vendor目录:这是一个可选目录,为了兼容Go 1.5引入的vendor构建模式而存在的。这个目录下的内容均由Go命令自动维护,不需要开发者手工干预。
|
||||
|
||||
|
||||
第二,对于以生产可复用库为目的的Go项目,它的典型结构则要简单许多,我们可以直接理解为在Go可执行程序项目的基础上去掉cmd目录和vendor目录。
|
||||
|
||||
最后,早期接纳Go语言的开发者所建立的项目的布局深受Go创世项目1.4版本之前布局的影响,将可导出的公共包放入单独的pkg目录下,我们了解这种情况即可。对于新建Go项目,我依旧建议你采用前面介绍的标准布局形式。
|
||||
|
||||
现在,如果你要再面对一个要用于生产环境的Go应用项目的布局问题,是不是胸有成竹了呢?
|
||||
|
||||
思考题
|
||||
|
||||
如果非要你考虑Go项目结构的最小标准布局,那么你觉得这个布局中都应该包含哪些东西呢?欢迎在留言区留下你的答案。
|
||||
|
||||
感谢你和我一起学习,也欢迎你把这节课分享给更多对Go项目布局感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
330
专栏/TonyBai·Go语言第一课/06构建模式:Go是怎么解决包依赖管理问题的?.md
Normal file
330
专栏/TonyBai·Go语言第一课/06构建模式:Go是怎么解决包依赖管理问题的?.md
Normal file
@@ -0,0 +1,330 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 构建模式:Go是怎么解决包依赖管理问题的?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
通过前面的讲解,我们已经初步了解了Go程序的结构,以及Go项目的典型布局了。那么,接下来,我们是时候来系统学习一下Go应用的构建了,它们都是我们继续Go语言学习的前提。
|
||||
|
||||
所以在这一节课,我们就来了解Go构建模式演化的前世今生。理解了这个发展史后,我们会重点来探讨现在被广泛采用的构建模式,Go Module的基本概念和应用构建方式。 接着,知道了怎么做后,我们会再深一层,继续分析Go Module的工作原理。这样层层深入地分析完后,你就能彻底、透彻地掌握Go Module构建模式了。
|
||||
|
||||
好了,我们直接开始吧。我们先来了解一下Go构建模式的演化过程,弄清楚Go核心开发团队为什么要引入Go module构建模式。
|
||||
|
||||
Go构建模式是怎么演化的?
|
||||
|
||||
Go程序由Go包组合而成的,Go程序的构建过程就是确定包版本、编译包以及将编译后得到的目标文件链接在一起的过程。
|
||||
|
||||
Go语言的构建模式历经了三个迭代和演化过程,分别是最初期的GOPATH、1.5版本的Vendor机制,以及现在的Go Module。这里我们就先来介绍一下前面这两个。
|
||||
|
||||
首先我们来看GOPATH。
|
||||
|
||||
Go语言在首次开源时,就内置了一种名为GOPATH的构建模式。在这种构建模式下,Go编译器可以在本地GOPATH环境变量配置的路径下,搜寻Go程序依赖的第三方包。如果存在,就使用这个本地包进行编译;如果不存在,就会报编译错误。
|
||||
|
||||
我这里给出了一段在GOPATH构建模式下编写的代码,你先来感受一下:
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/sirupsen/logrus"
|
||||
|
||||
func main() {
|
||||
logrus.Println("hello, gopath mode")
|
||||
}
|
||||
|
||||
|
||||
你可以看到,这段代码依赖了第三方包logrus(logrus是Go社区使用最为广泛的第三方log包)。
|
||||
|
||||
接下来,这个构建过程演示了Go编译器(这里使用Go 1.10.8)在GOPATH环境变量所配置的目录下(这里为/Users/tonybai/Go),无法找到程序依赖的logrus包而报错的情况:
|
||||
|
||||
$go build main.go
|
||||
main.go:3:8: cannot find package "github.com/sirupsen/logrus" in any of:
|
||||
/Users/tonybai/.bin/go1.10.8/src/github.com/sirupsen/logrus (from $GOROOT)
|
||||
/Users/tonybai/Go/src/github.com/sirupsen/logrus (from $GOPATH)
|
||||
|
||||
|
||||
那么Go编译器在GOPATH构建模式下,究竟怎么在GOPATH配置的路径下搜寻第三方依赖包呢?
|
||||
|
||||
为了给你说清楚搜寻规则,我们先假定Go程序导入了github.com/user/repo这个包,我们也同时假定当前GOPATH环境变量配置的值为:
|
||||
|
||||
export GOPATH=/usr/local/goprojects:/home/tonybai/go
|
||||
|
||||
|
||||
那么在GOPATH构建模式下,Go编译器在编译Go程序时,就会在下面两个路径下搜索第三方依赖包是否存在:
|
||||
|
||||
/usr/local/goprojects/src/github.com/user/repo
|
||||
/home/tonybai/go/src/github.com/user/repo
|
||||
|
||||
|
||||
这里注意一下,如果你没有显式设置GOPATH环境变量,Go会将GOPATH设置为默认值,不同操作系统下默认值的路径不同,在macOS或Linux上,它的默认值是$HOME/go。
|
||||
|
||||
那么,当遇到像上面例子一样,没有在本地找到程序的第三方依赖包的情况,我们该如何解决这个问题呢?
|
||||
|
||||
这个时候就要让go get登场了!
|
||||
|
||||
我们可以通过go get命令将本地缺失的第三方依赖包下载到本地,比如:
|
||||
|
||||
$go get github.com/sirupsen/logrus
|
||||
|
||||
|
||||
这里的go get命令,不仅能将logrus包下载到GOPATH环境变量配置的目录下,它还会检查logrus的依赖包在本地是否存在,如果不存在,go get也会一并将它们下载到本地。
|
||||
|
||||
不过,go get下载的包只是那个时刻各个依赖包的最新主线版本,这样会给后续Go程序的构建带来一些问题。比如,依赖包持续演进,可能会导致不同开发者在不同时间获取和编译同一个Go包时,得到不同的结果,也就是不能保证可重现的构建(Reproduceable Build)。又比如,如果依赖包引入了不兼容代码,程序将无法通过编译。
|
||||
|
||||
最后还有一点,如果依赖包因引入新代码而无法正常通过编译,并且该依赖包的作者又没用及时修复这个问题,这种错误也会传导到你的程序,导致你的程序无法通过编译。
|
||||
|
||||
也就是说,在GOPATH构建模式下,Go编译器实质上并没有关注Go项目所依赖的第三方包的版本。但Go开发者希望自己的Go项目所依赖的第三方包版本能受到自己的控制,而不是随意变化。于是Go核心开发团队引入了Vendor机制试图解决上面的问题。
|
||||
|
||||
现在我们就来看看vendor机制是怎么解决这个问题的。
|
||||
|
||||
Go在1.5版本中引入vendor机制。vendor机制本质上就是在Go项目的某个特定目录下,将项目的所有依赖包缓存起来,这个特定目录名就是vendor。
|
||||
|
||||
Go编译器会优先感知和使用vendor目录下缓存的第三方包版本,而不是GOPATH环境变量所配置的路径下的第三方包版本。这样,无论第三方依赖包自己如何变化,无论GOPATH环境变量所配置的路径下的第三方包是否存在、版本是什么,都不会影响到Go程序的构建。
|
||||
|
||||
如果你将vendor目录和项目源码一样提交到代码仓库,那么其他开发者下载你的项目后,就可以实现可重现的构建。因此,如果使用vendor机制管理第三方依赖包,最佳实践就是将vendor一并提交到代码仓库中。
|
||||
|
||||
下面这个目录结构就是为上面的代码示例添加vendor目录后的结果:
|
||||
|
||||
.
|
||||
├── main.go
|
||||
└── vendor/
|
||||
├── github.com/
|
||||
│ └── sirupsen/
|
||||
│ └── logrus/
|
||||
└── golang.org/
|
||||
└── x/
|
||||
└── sys/
|
||||
└── unix/
|
||||
|
||||
|
||||
在添加完vendor后,我们重新编译main.go,这个时候Go编译器就会在vendor目录下搜索程序依赖的logrus包以及后者依赖的golang.org/x/sys/unix包了。
|
||||
|
||||
这里你还要注意一点,要想开启vendor机制,你的Go项目必须位于GOPATH环境变量配置的某个路径的src目录下面。如果不满足这一路径要求,那么Go编译器是不会理会Go项目目录下的vendor目录的。
|
||||
|
||||
不过vendor机制虽然一定程度解决了Go程序可重现构建的问题,但对开发者来说,它的体验却不那么好。一方面,Go项目必须放在GOPATH环境变量配置的路径下,庞大的vendor目录需要提交到代码仓库,不仅占用代码仓库空间,减慢仓库下载和更新的速度,而且还会干扰代码评审,对实施代码统计等开发者效能工具也有比较大影响。
|
||||
|
||||
另外,你还需要手工管理vendor下面的Go依赖包,包括项目依赖包的分析、版本的记录、依赖包获取和存放,等等,最让开发者头疼的就是这一点。
|
||||
|
||||
为了解决这个问题,Go核心团队与社区将Go构建的重点转移到如何解决包依赖管理上。Go社区先后开发了诸如gb、glide、dep等工具,来帮助Go开发者对vendor下的第三方包进行自动依赖分析和管理,但这些工具也都有自身的问题。
|
||||
|
||||
就在Go社区为包依赖管理焦虑并抱怨没有官方工具的时候,Go核心团队基于社区实践的经验和教训,推出了Go官方的解决方案:Go Module。
|
||||
|
||||
创建你的第一个Go Module
|
||||
|
||||
从Go 1.11版本开始,除了GOPATH构建模式外,Go又增加了一种Go Module构建模式。
|
||||
|
||||
在04讲中,我们曾基于Go Module构建模式编写过一个“hello, world”程序,当时是为了讲解Go程序结构,这里我再带你回顾一下Go Module的基础概念。
|
||||
|
||||
一个Go Module是一个Go包的集合。module是有版本的,所以module下的包也就有了版本属性。这个module与这些包会组成一个独立的版本单元,它们一起打版本、发布和分发。
|
||||
|
||||
在Go Module模式下,通常一个代码仓库对应一个Go Module。一个Go Module的顶层目录下会放置一个go.mod文件,每个go.mod文件会定义唯一一个module,也就是说Go Module与go.mod是一一对应的。
|
||||
|
||||
go.mod文件所在的顶层目录也被称为module的根目录,module根目录以及它子目录下的所有Go包均归属于这个Go Module,这个module也被称为main module。
|
||||
|
||||
你可能也意识到了,Go Module的原理和使用方法其实有点复杂,但“千里之行始于足下”,下面我们先从如何创建一个Go Module说起。我们先来将上面的例子改造成为一个基于Go Module构建模式的Go项目。
|
||||
|
||||
创建一个Go Module
|
||||
|
||||
将基于当前项目创建一个Go Module,通常有如下几个步骤:
|
||||
|
||||
第一步,通过go mod init创建go.mod文件,将当前项目变为一个Go Module;
|
||||
|
||||
第二步,通过go mod tidy命令自动更新当前module的依赖信息;
|
||||
|
||||
第三步,执行go build,执行新module的构建。
|
||||
|
||||
我们一步一步来详细看一下。
|
||||
|
||||
我们先建立一个新项目module-mode用来演示Go Module的创建,注意我们可以在任意路径下创建这个项目,不必非要在GOPATH环境变量的配置路径下。
|
||||
|
||||
这个项目的main.go修改自上面的例子,修改后的main.go的代码是这样的,我们依旧依赖外部包logrus:
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/sirupsen/logrus"
|
||||
|
||||
func main() {
|
||||
logrus.Println("hello, go module mode")
|
||||
}
|
||||
|
||||
|
||||
你可以看到,这个项目目录下只有main.go一个源文件,现在我们就来为这个项目添加Go Module支持。我们通过go mod init命令为这个项目创建一个Go Module(这里我们使用的是Go版本为1.16.5,Go 1.16版本默认采用Go Module构建模式):
|
||||
|
||||
$go mod init github.com/bigwhite/module-mode
|
||||
go: creating new go.mod: module github.com/bigwhite/module-mode
|
||||
go: to add module requirements and sums:
|
||||
go mod tidy
|
||||
|
||||
|
||||
现在,go mod init在当前项目目录下创建了一个go.mod文件,这个go.mod文件将当前项目变为了一个Go Module,项目根目录变成了module根目录。go.mod的内容是这样的:
|
||||
|
||||
module github.com/bigwhite/module-mode
|
||||
|
||||
go 1.16
|
||||
|
||||
|
||||
这个go.mod文件现在处于初始状态,它的第一行内容用于声明module路径(module path),最后一行是一个Go版本指示符,用于表示这个module是在某个特定的Go版本的module语义的基础上编写的。
|
||||
|
||||
go mod init命令还输出了两行日志,提示我们可以使用go mod tidy命令,添加module依赖以及校验和。go mod tidy命令会扫描Go源码,并自动找出项目依赖的外部Go Module以及版本,下载这些依赖并更新本地的go.mod文件。我们按照这个提示执行一下go mod tidy命令:
|
||||
|
||||
$go mod tidy
|
||||
go: finding module for package github.com/sirupsen/logrus
|
||||
go: downloading github.com/sirupsen/logrus v1.8.1
|
||||
go: found github.com/sirupsen/logrus in github.com/sirupsen/logrus v1.8.1
|
||||
go: downloading golang.org/x/sys v0.0.0-20191026070338-33540a1f6037
|
||||
go: downloading github.com/stretchr/testify v1.2.2
|
||||
|
||||
|
||||
我们看到,对于一个处于初始状态的module而言,go mod tidy分析了当前main module的所有源文件,找出了当前main module的所有第三方依赖,确定第三方依赖的版本,还下载了当前main module的直接依赖包(比如logrus),以及相关间接依赖包(直接依赖包的依赖,比如上面的golang.org/x/sys等)。
|
||||
|
||||
Go Module还支持通过Go Module代理服务加速第三方依赖的下载。在03讲我们讲解Go环境安装时,就提到过GOPROXY环境变量,这个环境变量的默认值为“https: // proxy.golang.org,direct”,不过我们可以配置更适合于中国大陆地区的Go Module代理服务。
|
||||
|
||||
由go mod tidy下载的依赖module会被放置在本地的module缓存路径下,默认值为$GOPATH[0]/pkg/mod,Go 1.15及以后版本可以通过GOMODCACHE环境变量,自定义本地module的缓存路径。
|
||||
|
||||
执行go mod tidy后,我们示例go.mod的内容更新如下:
|
||||
|
||||
module github.com/bigwhite/module-mode
|
||||
|
||||
go 1.16
|
||||
|
||||
require github.com/sirupsen/logrus v1.8.1
|
||||
|
||||
|
||||
你可以看到,当前module的直接依赖logrus,还有它的版本信息都被写到了go.mod文件的require段中。
|
||||
|
||||
而且,执行完go mod tidy后,当前项目除了go.mod文件外,还多了一个新文件go.sum,内容是这样的:
|
||||
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
|
||||
这同样是由go mod相关命令维护的一个文件,它存放了特定版本module内容的哈希值。
|
||||
|
||||
这是Go Module的一个安全措施。当将来这里的某个module的特定版本被再次下载的时候,go命令会使用go.sum文件中对应的哈希值,和新下载的内容的哈希值进行比对,只有哈希值比对一致才是合法的,这样可以确保你的项目所依赖的module内容,不会被恶意或意外篡改。因此,我推荐你把go.mod和go.sum两个文件与源码,一并提交到代码版本控制服务器上。
|
||||
|
||||
现在,go mod init和go mod tidy已经为我们当前Go Module的构建铺平了道路,接下来,我们只需在当前module的根路径下,执行go build就可以完成module的构建了!
|
||||
|
||||
go build命令会读取go.mod中的依赖及版本信息,并在本地module缓存路径下找到对应版本的依赖module,执行编译和链接。如果顺利的话,我们会在当前目录下看到一个新生成的可执行文件module-mode,执行这个文件我们就能得到正确结果了。
|
||||
|
||||
整个过程的执行步骤是这样的:
|
||||
|
||||
$go build
|
||||
$$ls
|
||||
go.mod go.sum main.go module-mode*
|
||||
$./module-mode
|
||||
INFO[0000] hello, go module mode
|
||||
|
||||
|
||||
好了,到这里,我们已经完成了一个有着多个第三方依赖的项目的构建了。但关于Go Module的操作还远不止这些。随着Go项目的演进,我们会在代码中导入新的第三方包,删除一些旧的依赖包,更新一些依赖包的版本等。关于这些内容,我会在下一节课再给你详细讲解。
|
||||
|
||||
那么,在看到我们的Go Module机制会自动分析项目的依赖包,并选出最适合的版本后,不知道你会不会有这样的疑惑:项目所依赖的包有很多版本,Go Module是如何选出最适合的那个版本的呢?要想回答这个问题,我们就需要深入到Go Module构建模式的工作原理中去。
|
||||
|
||||
深入Go Module构建模式
|
||||
|
||||
Go语言设计者在设计Go Module构建模式,来解决“包依赖管理”的问题时,进行了几项创新,这其中就包括语义导入版本(Semantic Import Versioning),以及和其他主流语言不同的最小版本选择(Minimal Version Selection)等机制。只要你深入理解了这些机制,你就能真正掌握Go Module构建模式。
|
||||
|
||||
首先我们看一下Go Module的语义导入版本机制。
|
||||
|
||||
在上面的例子中,我们看到go.mod的require段中依赖的版本号,都符合vX.Y.Z的格式。在Go Module构建模式下,一个符合Go Module要求的版本号,由前缀v和一个满足语义版本规范的版本号组成。
|
||||
|
||||
你可以看看下面这张图,语义版本号分成3部分:主版本号(major)、次版本号(minor)和补丁版本号(patch)。例如上面的logrus module的版本号是v1.8.1,这就表示它的主版本号为1,次版本号为8,补丁版本号为1。
|
||||
|
||||
|
||||
|
||||
Go命令和go.mod文件都使用上面这种符合语义版本规范的版本号,作为描述Go Module版本的标准形式。借助于语义版本规范,Go命令可以确定同一module的两个版本发布的先后次序,而且可以确定它们是否兼容。
|
||||
|
||||
按照语义版本规范,主版本号不同的两个版本是相互不兼容的。而且,在主版本号相同的情况下,次版本号大都是向后兼容次版本号小的版本。补丁版本号也不影响兼容性。
|
||||
|
||||
而且,Go Module规定:如果同一个包的新旧版本是兼容的,那么它们的包导入路径应该是相同的。怎么理解呢?我们来举个简单示例。我们就以logrus为例,它有很多发布版本,我们从中选出两个版本v1.7.0和v1.8.1.。按照上面的语义版本规则,这两个版本的主版本号相同,新版本v1.8.1是兼容老版本v1.7.0的。那么,我们就可以知道,如果一个项目依赖logrus,无论它使用的是v1.7.0版本还是v1.8.1版本,它都可以使用下面的包导入语句导入logrus包:
|
||||
|
||||
import "github.com/sirupsen/logrus"
|
||||
|
||||
|
||||
那么问题又来了,假如在未来的某一天,logrus的作者发布了logrus v2.0.0版本。那么根据语义版本规则,该版本的主版本号为2,已经与v1.7.0、v1.8.1的主版本号不同了,那么v2.0.0与v1.7.0、v1.8.1就是不兼容的包版本。然后我们再按照Go Module的规定,如果一个项目依赖logrus v2.0.0版本,那么它的包导入路径就不能再与上面的导入方式相同了。那我们应该使用什么方式导入logrus v2.0.0版本呢?
|
||||
|
||||
Go Module创新性地给出了一个方法:将包主版本号引入到包导入路径中,我们可以像下面这样导入logrus v2.0.0版本依赖包:
|
||||
|
||||
import "github.com/sirupsen/logrus/v2"
|
||||
|
||||
|
||||
这就是Go的“语义导入版本”机制,也就是说通过在包导入路径中引入主版本号的方式,来区别同一个包的不兼容版本,这样一来我们甚至可以同时依赖一个包的两个不兼容版本:
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
logv2 "github.com/sirupsen/logrus/v2"
|
||||
)
|
||||
|
||||
|
||||
不过到这里,你可能会问,v0.y.z版本应该使用哪种导入路径呢?
|
||||
|
||||
按照语义版本规范的说法,v0.y.z这样的版本号是用于项目初始开发阶段的版本号。在这个阶段任何事情都有可能发生,其API也不应该被认为是稳定的。Go Module将这样的版本(v0)与主版本号v1做同等对待,也就是采用不带主版本号的包导入路径,这样一定程度降低了Go开发人员使用这样版本号包时的心智负担。
|
||||
|
||||
Go语义导入版本机制是Go Module机制的基础规则,同样它也是Go Module其他规则的基础。
|
||||
|
||||
接下来,我们再来看一下Go Module的最小版本选择原则。
|
||||
|
||||
在前面的例子中,Go命令都是在项目初始状态分析项目的依赖,并且项目中两个依赖包之间没有共同的依赖,这样的包依赖关系解决起来还是比较容易的。但依赖关系一旦复杂起来,比如像下图中展示的这样,Go又是如何确定使用依赖包C的哪个版本的呢?-
|
||||
-
|
||||
在这张图中,myproject有两个直接依赖A和B,A和B有一个共同的依赖包C,但A依赖C的v1.1.0版本,而B依赖的是C的v1.3.0版本,并且此时C包的最新发布版为C v1.7.0。这个时候,Go命令是如何为myproject选出间接依赖包C的版本呢?选出的究竟是v1.7.0、v1.1.0还是v1.3.0呢?你可以暂停一两分钟思考一下。
|
||||
|
||||
其实,当前存在的主流编程语言,以及Go Module出现之前的很多Go包依赖管理工具都会选择依赖项的“最新最大(Latest Greatest)版本”,对应到图中的例子,这个版本就是v1.7.0。
|
||||
|
||||
当然了,理想状态下,如果语义版本控制被正确应用,并且这种“社会契约”也得到了很好的遵守,那么这种选择算法是有道理的,而且也可以正常工作。在这样的情况下,依赖项的“最新最大版本”应该是最稳定和安全的版本,并且应该有向后兼容性。至少在相同的主版本(Major Verion)依赖树中是这样的。
|
||||
|
||||
但我们这个问题的答案并不是这样的。Go设计者另辟蹊径,在诸多兼容性版本间,他们不光要考虑最新最大的稳定与安全,还要尊重各个module的述求:A明明说只要求C v1.1.0,B明明说只要求C v1.3.0。所以Go会在该项目依赖项的所有版本中,选出符合项目整体要求的“最小版本”。
|
||||
|
||||
这个例子中,C v1.3.0是符合项目整体要求的版本集合中的版本最小的那个,于是Go命令选择了C v1.3.0,而不是最新最大的C v1.7.0。并且,Go团队认为“最小版本选择”为Go程序实现持久的和可重现的构建提供了最佳的方案。
|
||||
|
||||
了解了语义导入版本与最小版本选择两种机制后,你就可以说你已经掌握了Go Module的精髓。
|
||||
|
||||
但很多Go开发人员的起点,并非是默认开启Go Module构建模式的Go 1.16版本,多数Go开发人使用的环境中都存在着多套Go版本,有用于体验最新功能特性的Go版本,也有某些遗留项目所使用的老版本Go编译器。
|
||||
|
||||
它们工作时采用的构建模式是不一样的,并且即便是引入Go Module的Go 1.11版本,它的Go Module机制,和后续进化后的Go版本的Go Module构建机制在表现行为上也有所不同。因此Go开发人员可能需要经常在各个Go版本间切换。而明确具体版本下Go Module的实际表现行为对Go开发人员是十分必要的。
|
||||
|
||||
Go各版本构建模式机制和切换
|
||||
|
||||
我们前面说了,在Go 1.11版本中,Go开发团队引入Go Modules构建模式。这个时候,GOPATH构建模式与Go Modules构建模式各自独立工作,我们可以通过设置环境变量GO111MODULE的值在两种构建模式间切换。
|
||||
|
||||
然后,随着Go语言的逐步演进,从Go 1.11到Go 1.16版本,不同的Go版本在GO111MODULE为不同值的情况下,开启的构建模式几经变化,直到Go 1.16版本,Go Module构建模式成为了默认模式。
|
||||
|
||||
所以,要分析Go各版本的具体构建模式的机制和切换,我们只需要找到这几个代表性的版本就好了。
|
||||
|
||||
我这里将Go 1.13版本之前、Go 1.13版本以及Go 1.16版本,在GO111MODULE为不同值的情况下的行为做了一下对比,这样我们可以更好地理解不同版本下、不同构建模式下的行为特性,下面我们就来用表格形式做一下比对:
|
||||
|
||||
|
||||
|
||||
了解了这些,你就能在工作中游刃有余的在各个Go版本间切换了,不用再担心切换后模式变化,导致构建失败了。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们初步了解了Go语言构建模式的演化历史。
|
||||
|
||||
Go语言最初发布时内置的构建模式为GOPATH构建模式。在这种构建模式下,所有构建都离不开GOPATH环境变量。在这个模式下,Go编译器并没有关注依赖包的版本,开发者也无法控制第三方依赖的版本,导致开发者无法实现可重现的构建。
|
||||
|
||||
那么,为了支持可重现构建,Go 1.5版本引入了vendor机制,开发者可以在项目目录下缓存项目的所有依赖,实现可重现构建。但vendor机制依旧不够完善,开发者还需要手工管理vendor下的依赖包,这就给开发者带来了不小的心智负担。
|
||||
|
||||
后来,Go 1.11版本中,Go核心团队推出了新一代构建模式:Go Module以及一系列创新机制,包括语义导入版本机制、最小版本选择机制等。语义导入版本机制是Go Moudle其他机制的基础,它是通过在包导入路径中引入主版本号的方式,来区别同一个包的不兼容版本。而且,Go命令使用最小版本选择机制进行包依赖版本选择,这和当前主流编程语言,以及Go社区之前的包依赖管理工具使用的算法都有点不同。
|
||||
|
||||
此外,Go命令还可以通过GO111MODULE环境变量进行Go构建模式的切换。但你要注意,从Go 1.11到Go 1.16,不同的Go版本在GO111MODULE为不同值的情况下,开启的构建模式以及具体表现行为也几经变化,这里你重点看一下前面总结的表格。
|
||||
|
||||
现在,Go核心团队已经考虑在后续版本中彻底移除GOPATH构建模式,Go Module构建模式将成为Go语言唯一的标准构建模式。所以,学完这一课之后,我建议你从现在开始就彻底抛弃GOPATH构建模式,全面使用Go Module构建模式。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们的思考题是:如何将基于GOPATH构建模式的现有项目迁移为使用Go Module构建模式?欢迎在留言区和我分享你的答案。
|
||||
|
||||
感谢你和我一起学习,也欢迎你把这节课分享给更多对Go构建模式感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
273
专栏/TonyBai·Go语言第一课/07构建模式:GoModule的6类常规操作.md
Normal file
273
专栏/TonyBai·Go语言第一课/07构建模式:GoModule的6类常规操作.md
Normal file
@@ -0,0 +1,273 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 构建模式:Go Module的6类常规操作
|
||||
你好,我是Tony Bai。
|
||||
|
||||
通过上一节课的讲解,我们掌握了Go Module构建模式的基本概念和工作原理,也初步学会了如何通过go mod命令,将一个Go项目转变为一个Go Module,并通过Go Module构建模式进行构建。
|
||||
|
||||
但是,围绕一个Go Module,Go开发人员每天要执行很多Go命令对其进行维护。这些维护又是怎么进行的呢?
|
||||
|
||||
具体来说,维护Go Module 无非就是对Go Module 依赖包的管理。但在具体工作中还有很多情况,我们接下来会拆分成六个场景,层层深入给你分析。可以说,学好这些是每个Go开发人员成长的必经之路。
|
||||
|
||||
我们首先来看一下日常进行Go应用开发时遇到的最为频繁的一个场景:为当前项目添加一个依赖包。
|
||||
|
||||
为当前module添加一个依赖
|
||||
|
||||
在一个项目的初始阶段,我们会经常为项目引入第三方包,并借助这些包完成特定功能。即便是项目进入了稳定阶段,随着项目的演进,我们偶尔还需要在代码中引入新的第三方包。
|
||||
|
||||
那么我们如何为一个Go Module添加一个新的依赖包呢?
|
||||
|
||||
我们还是以上一节课中讲过的module-mode项目为例。如果我们要为这个项目增加一个新依赖:github.com/google/uuid,那需要怎么做呢?
|
||||
|
||||
我们首先会更新源码,就像下面代码中这样:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logrus.Println("hello, go module mode")
|
||||
logrus.Println(uuid.NewString())
|
||||
}
|
||||
|
||||
|
||||
新源码中,我们通过import语句导入了github.com/google/uuid,并在main函数中调用了uuid包的函数NewString。此时,如果我们直接构建这个module,我们会得到一个错误提示:
|
||||
|
||||
$go build
|
||||
main.go:4:2: no required module provides package github.com/google/uuid; to add it:
|
||||
go get github.com/google/uuid
|
||||
|
||||
|
||||
Go编译器提示我们,go.mod里的require段中,没有哪个module提供了github.com/google/uuid包,如果我们要增加这个依赖,可以手动执行go get命令。那我们就来按照提示手工执行一下这个命令:
|
||||
|
||||
$go get github.com/google/uuid
|
||||
go: downloading github.com/google/uuid v1.3.0
|
||||
go get: added github.com/google/uuid v1.3.0
|
||||
|
||||
|
||||
你会发现,go get命令将我们新增的依赖包下载到了本地module缓存里,并在go.mod文件的require段中新增了一行内容:
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.3.0 //新增的依赖
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
)
|
||||
|
||||
|
||||
这新增的一行表明,我们当前项目依赖的是uuid的v1.3.0版本。我们也可以使用go mod tidy命令,在执行构建前自动分析源码中的依赖变化,识别新增依赖项并下载它们:
|
||||
|
||||
$go mod tidy
|
||||
go: finding module for package github.com/google/uuid
|
||||
go: found github.com/google/uuid in github.com/google/uuid v1.3.0
|
||||
|
||||
|
||||
对于我们这个例子而言,手工执行go get新增依赖项,和执行go mod tidy自动分析和下载依赖项的最终效果,是等价的。但对于复杂的项目变更而言,逐一手工添加依赖项显然很没有效率,go mod tidy是更佳的选择。
|
||||
|
||||
到这里,我们已经了解了怎么为当前的module添加一个新的依赖。但是在日常开发场景中,我们需要对依赖的版本进行更改。那这又要怎么做呢?下面我们就来看看下面升、降级修改依赖版本的场景。
|
||||
|
||||
升级/降级依赖的版本
|
||||
|
||||
我们先以对依赖的版本进行降级为例,分析一下。
|
||||
|
||||
在实际开发工作中,如果我们认为Go命令自动帮我们确定的某个依赖的版本存在一些问题,比如,引入了不必要复杂性导致可靠性下降、性能回退等等,我们可以手工将它降级为之前发布的某个兼容版本。
|
||||
|
||||
那这个操作依赖于什么原理呢?
|
||||
|
||||
答案就是我们上一节课讲过“语义导入版本”机制。我们再来简单复习一下,Go Module的版本号采用了语义版本规范,也就是版本号使用vX.Y.Z的格式。其中X是主版本号,Y为次版本号(minor),Z为补丁版本号(patch)。主版本号相同的两个版本,较新的版本是兼容旧版本的。如果主版本号不同,那么两个版本是不兼容的。
|
||||
|
||||
有了语义版本号作为基础和前提,我们就可以从容地手工对依赖的版本进行升降级了,Go命令也可以根据版本兼容性,自动选择出合适的依赖版本了。
|
||||
|
||||
我们还是以上面提到过的logrus为例,logrus现在就存在着多个发布版本,我们可以通过下面命令来进行查询:
|
||||
|
||||
$go list -m -versions github.com/sirupsen/logrus
|
||||
github.com/sirupsen/logrus v0.1.0 v0.1.1 v0.2.0 v0.3.0 v0.4.0 v0.4.1 v0.5.0 v0.5.1 v0.6.0 v0.6.1 v0.6.2 v0.6.3 v0.6.4 v0.6.5 v0.6.6 v0.7.0 v0.7.1 v0.7.2 v0.7.3 v0.8.0 v0.8.1 v0.8.2 v0.8.3 v0.8.4 v0.8.5 v0.8.6 v0.8.7 v0.9.0 v0.10.0 v0.11.0 v0.11.1 v0.11.2 v0.11.3 v0.11.4 v0.11.5 v1.0.0 v1.0.1 v1.0.3 v1.0.4 v1.0.5 v1.0.6 v1.1.0 v1.1.1 v1.2.0 v1.3.0 v1.4.0 v1.4.1 v1.4.2 v1.5.0 v1.6.0 v1.7.0 v1.7.1 v1.8.0 v1.8.1
|
||||
|
||||
|
||||
在这个例子中,基于初始状态执行的go mod tidy命令,帮我们选择了logrus的最新发布版本v1.8.1。如果你觉得这个版本存在某些问题,想将logrus版本降至某个之前发布的兼容版本,比如v1.7.0,那么我们可以在项目的module根目录下,执行带有版本号的go get命令:
|
||||
|
||||
$go get github.com/sirupsen/[email protected]
|
||||
go: downloading github.com/sirupsen/logrus v1.7.0
|
||||
go get: downgraded github.com/sirupsen/logrus v1.8.1 => v1.7.0
|
||||
|
||||
|
||||
从这个执行输出的结果,我们可以看到,go get命令下载了logrus v1.7.0版本,并将go.mod中对logrus的依赖版本从v1.8.1降至v1.7.0。
|
||||
|
||||
当然我们也可以使用万能命令go mod tidy来帮助我们降级,但前提是首先要用go mod edit命令,明确告知我们要依赖v1.7.0版本,而不是v1.8.1,这个执行步骤是这样的:
|
||||
|
||||
$go mod edit -require=github.com/sirupsen/[email protected]
|
||||
$go mod tidy
|
||||
go: downloading github.com/sirupsen/logrus v1.7.0
|
||||
|
||||
|
||||
降级后,我们再假设logrus v1.7.1版本是一个安全补丁升级,修复了一个严重的安全漏洞,而且我们必须使用这个安全补丁版本,这就意味着我们需要将logrus依赖从v1.7.0升级到v1.7.1。
|
||||
|
||||
我们可以使用与降级同样的步骤来完成升级,这里我只列出了使用go get实现依赖版本升级的命令和输出结果,你自己动手试一下。
|
||||
|
||||
$go get github.com/sirupsen/[email protected]
|
||||
go: downloading github.com/sirupsen/logrus v1.7.1
|
||||
go get: upgraded github.com/sirupsen/logrus v1.7.0 => v1.7.1
|
||||
|
||||
|
||||
好了,到这里你就学会了如何对项目依赖包的版本进行升降级了。
|
||||
|
||||
但是你可能会发现一个问题,在前面的例子中,Go Module的依赖的主版本号都是1。根据我们上节课中学习的语义导入版本的规范,在Go Module构建模式下,当依赖的主版本号为0或1的时候,我们在Go源码中导入依赖包,不需要在包的导入路径上增加版本号,也就是:
|
||||
|
||||
import github.com/user/repo/v0 等价于 import github.com/user/repo
|
||||
import github.com/user/repo/v1 等价于 import github.com/user/repo
|
||||
|
||||
|
||||
但是,如果我们要依赖的module的主版本号大于1,这又要怎么办呢?接着我们就来看看这个场景下该如何去做。
|
||||
|
||||
添加一个主版本号大于1的依赖
|
||||
|
||||
这里,我们还是先来回顾一下,上节课我们讲的语义版本规则中对主版本号大于1情况有没有相应的说明。
|
||||
|
||||
有的。语义导入版本机制有一个原则:如果新旧版本的包使用相同的导入路径,那么新包与旧包是兼容的。也就是说,如果新旧两个包不兼容,那么我们就应该采用不同的导入路径。
|
||||
|
||||
按照语义版本规范,如果我们要为项目引入主版本号大于1的依赖,比如v2.0.0,那么由于这个版本与v1、v0开头的包版本都不兼容,我们在导入v2.0.0包时,不能再直接使用github.com/user/repo,而要使用像下面代码中那样不同的包导入路径:
|
||||
|
||||
import github.com/user/repo/v2/xxx
|
||||
|
||||
|
||||
也就是说,如果我们要为Go项目添加主版本号大于1的依赖,我们就需要使用“语义导入版本”机制,在声明它的导入路径的基础上,加上版本号信息。我们以“向module-mode项目添加github.com/go-redis/redis依赖包的v7版本”为例,看看添加步骤。
|
||||
|
||||
首先,我们在源码中,以空导入的方式导入v7版本的github.com/go-redis/redis包:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/go-redis/redis/v7" // “_”为空导入
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logrus.Println("hello, go module mode")
|
||||
logrus.Println(uuid.NewString())
|
||||
}
|
||||
|
||||
|
||||
接下来的步骤就与添加兼容依赖一样,我们通过go get获取redis的v7版本:
|
||||
|
||||
$go get github.com/go-redis/redis/v7
|
||||
go: downloading github.com/go-redis/redis/v7 v7.4.1
|
||||
go: downloading github.com/go-redis/redis v6.15.9+incompatible
|
||||
go get: added github.com/go-redis/redis/v7 v7.4.1
|
||||
|
||||
|
||||
我们可以看到,go get为我们选择了go-redis v7版本下当前的最新版本v7.4.1。
|
||||
|
||||
不过呢,这里说的是为项目添加一个主版本号大于1的依赖的步骤。有些时候,出于要使用依赖包最新功能特性等原因,我们可能需要将某个依赖的版本升级为其不兼容版本,也就是主版本号不同的版本,这又该怎么做呢?
|
||||
|
||||
我们还以go-redis/redis这个依赖为例,将这个依赖从v7版本升级到最新的v8版本看看。
|
||||
|
||||
升级依赖版本到一个不兼容版本
|
||||
|
||||
我们前面说了,按照语义导入版本的原则,不同主版本的包的导入路径是不同的。所以,同样地,我们这里也需要先将代码中redis包导入路径中的版本号改为v8:
|
||||
|
||||
import (
|
||||
_ "github.com/go-redis/redis/v8"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
||||
接下来,我们再通过go get来获取v8版本的依赖包:
|
||||
|
||||
$go get github.com/go-redis/redis/v8
|
||||
go: downloading github.com/go-redis/redis/v8 v8.11.1
|
||||
go: downloading github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f
|
||||
go: downloading github.com/cespare/xxhash/v2 v2.1.1
|
||||
go get: added github.com/go-redis/redis/v8 v8.11.1
|
||||
|
||||
|
||||
这样,我们就完成了向一个不兼容依赖版本的升级。是不是很简单啊!
|
||||
|
||||
但是项目继续演化到一个阶段的时候,我们可能还需要移除对之前某个包的依赖。
|
||||
|
||||
移除一个依赖
|
||||
|
||||
我们还是看前面go-redis/redis示例,如果我们这个时候不需要再依赖go-redis/redis了,你会怎么做呢?
|
||||
|
||||
你可能会删除掉代码中对redis的空导入这一行,之后再利用go build命令成功地构建这个项目。
|
||||
|
||||
但你会发现,与添加一个依赖时Go命令给出友好提示不同,这次go build没有给出任何关于项目已经将go-redis/redis删除的提示,并且go.mod里require段中的go-redis/redis/v8的依赖依旧存在着。
|
||||
|
||||
我们再通过go list命令列出当前module的所有依赖,你也会发现go-redis/redis/v8仍出现在结果中:
|
||||
|
||||
$go list -m all
|
||||
github.com/bigwhite/module-mode
|
||||
github.com/cespare/xxhash/v2 v2.1.1
|
||||
github.com/davecgh/go-spew v1.1.1
|
||||
... ...
|
||||
github.com/go-redis/redis/v8 v8.11.1
|
||||
... ...
|
||||
gopkg.in/yaml.v2 v2.3.0
|
||||
|
||||
|
||||
这是怎么回事呢?
|
||||
|
||||
其实,要想彻底从项目中移除go.mod中的依赖项,仅从源码中删除对依赖项的导入语句还不够。这是因为如果源码满足成功构建的条件,go build命令是不会“多管闲事”地清理go.mod中多余的依赖项的。
|
||||
|
||||
那正确的做法是怎样的呢?我们还得用go mod tidy命令,将这个依赖项彻底从Go Module构建上下文中清除掉。go mod tidy会自动分析源码依赖,而且将不再使用的依赖从go.mod和go.sum中移除。
|
||||
|
||||
到这里,其实我们已经分析了Go Module依赖包管理的5个常见情况了,但其实还有一种特殊情况,需要我们借用vendor机制。
|
||||
|
||||
特殊情况:使用vendor
|
||||
|
||||
你可能会感到有点奇怪,为什么Go Module的维护,还有要用vendor的情况?
|
||||
|
||||
其实,vendor机制虽然诞生于GOPATH构建模式主导的年代,但在Go Module构建模式下,它依旧被保留了下来,并且成为了Go Module构建机制的一个很好的补充。特别是在一些不方便访问外部网络,并且对Go应用构建性能敏感的环境,比如在一些内部的持续集成或持续交付环境(CI/CD)中,使用vendor机制可以实现与Go Module等价的构建。
|
||||
|
||||
和GOPATH构建模式不同,Go Module构建模式下,我们再也无需手动维护vendor目录下的依赖包了,Go提供了可以快速建立和更新vendor的命令,我们还是以前面的module-mode项目为例,通过下面命令为该项目建立vendor:
|
||||
|
||||
$go mod vendor
|
||||
$tree -LF 2 vendor
|
||||
vendor
|
||||
├── github.com/
|
||||
│ ├── google/
|
||||
│ ├── magefile/
|
||||
│ └── sirupsen/
|
||||
├── golang.org/
|
||||
│ └── x/
|
||||
└── modules.txt
|
||||
|
||||
|
||||
我们看到,go mod vendor命令在vendor目录下,创建了一份这个项目的依赖包的副本,并且通过vendor/modules.txt记录了vendor下的module以及版本。
|
||||
|
||||
如果我们要基于vendor构建,而不是基于本地缓存的Go Module构建,我们需要在go build后面加上-mod=vendor参数。
|
||||
|
||||
在Go 1.14及以后版本中,如果Go项目的顶层目录下存在vendor目录,那么go build默认也会优先基于vendor构建,除非你给go build传入-mod=mod的参数。
|
||||
|
||||
小结
|
||||
|
||||
好了,到这里,我们就完成了维护Go Module的全部常见场景的学习了,现在我们一起来回顾一下吧。
|
||||
|
||||
在通过go mod init为当前Go项目创建一个新的module后,随着项目的演进,我们在日常开发过程中,会遇到多种常见的维护Go Module的场景。
|
||||
|
||||
其中最常见的就是为项目添加一个依赖包,我们可以通过go get命令手工获取该依赖包的特定版本,更好的方法是通过go mod tidy命令让Go命令自动去分析新依赖并决定使用新依赖的哪个版本。
|
||||
|
||||
另外,还有几个场景需要你记住:
|
||||
|
||||
|
||||
通过go get我们可以升级或降级某依赖的版本,如果升级或降级前后的版本不兼容,这里千万注意别忘了变化包导入路径中的版本号,这是Go语义导入版本机制的要求;
|
||||
通过go mod tidy,我们可以自动分析Go源码的依赖变更,包括依赖的新增、版本变更以及删除,并更新go.mod中的依赖信息。
|
||||
通过go mod vendor,我们依旧可以支持vendor机制,并且可以对vendor目录下缓存的依赖包进行自动管理。
|
||||
|
||||
|
||||
在了解了如何应对Go Modules维护的日常工作场景后,你是不是有一种再也不担心Go源码构建问题的感觉了呢?
|
||||
|
||||
思考题
|
||||
|
||||
如果你是一个公共Go包的作者,在发布你的Go包时,有哪些需要注意的地方?
|
||||
|
||||
感谢你和我一起学习,也欢迎你把这节课分享给更多对Go构建模式感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
433
专栏/TonyBai·Go语言第一课/08入口函数与包初始化:搞清Go程序的执行次序.md
Normal file
433
专栏/TonyBai·Go语言第一课/08入口函数与包初始化:搞清Go程序的执行次序.md
Normal file
@@ -0,0 +1,433 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 入口函数与包初始化:搞清Go程序的执行次序
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在刚开始学习Go语言的时候,我们可能经常会遇到这样一个问题:一个Go项目中有数十个Go包,每个包中又有若干常量、变量、各种函数和方法,那Go代码究竟是从哪里开始执行的呢?后续的执行顺序又是什么样的呢?
|
||||
|
||||
事实上,了解这门语言编写应用的执行次序,对我们写出结构合理、逻辑清晰的程序大有裨益,无论你用的是归属为哪种编程范式(Paradigm)的编程语言,过程式的、面向对象的、函数式的,或是其他编程范式的,我都建议你深入了解一下。
|
||||
|
||||
所以今天这节课,我就带你来了解一下Go程序的执行次序,这样在后续阅读和理解Go代码的时候,你就好比拥有了“通往宝藏的地图”,可以直接沿着Go代码执行次序这张“地图”去阅读和理解Go代码了,不会在庞大的代码库中迷失了。
|
||||
|
||||
Go程序由一系列Go包组成,代码的执行也是在各个包之间跳转。和其他语言一样,Go也拥有自己的用户层入口:main函数。这节课我们就从main函数入手,逐步展开,最终带你掌握Go程序的执行次序。
|
||||
|
||||
那么下面,我们就先来看看Go应用的入口函数。
|
||||
|
||||
main.main函数:Go应用的入口函数
|
||||
|
||||
Go语言中有一个特殊的函数:main包中的main函数,也就是main.main,它是所有Go可执行程序的用户层执行逻辑的入口函数。Go程序在用户层面的执行逻辑,会在这个函数内按照它的调用顺序展开。
|
||||
|
||||
main函数的函数原型是这样的:
|
||||
|
||||
package main
|
||||
|
||||
func main() {
|
||||
// 用户层执行逻辑
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
你会发现,main函数的函数原型非常简单,没有参数也没有返回值。而且,Go语言要求:可执行程序的main包必须定义main函数,否则Go编译器会报错。在启动了多个Goroutine(Go语言的轻量级用户线程,后面我们会详细讲解)的Go应用中,main.main函数将在Go应用的主Goroutine中执行。
|
||||
|
||||
不过很有意思的是,在多Goroutine的Go应用中,相较于main.main作为Go应用的入口,main.main函数返回的意义其实更大,因为main函数返回就意味着整个Go程序的终结,而且你也不用管这个时候是否还有其他子Goroutine正在执行。
|
||||
|
||||
另外还值得我们注意的是,除了main包外,其他包也可以拥有自己的名为main的函数或方法。但按照Go的可见性规则(小写字母开头的标识符为非导出标识符),非main包中自定义的main函数仅限于包内使用,就像下面代码这样,这是一段在非main包中定义main函数的代码片段:
|
||||
|
||||
package pkg1
|
||||
|
||||
import "fmt"
|
||||
|
||||
func Main() {
|
||||
main()
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("main func for pkg1")
|
||||
}
|
||||
|
||||
|
||||
你可以看到,这里main函数就主要是用来在包pkg1内部使用的,它是没法在包外使用的。
|
||||
|
||||
好,现在我们已经了解了Go应用的入口函数main.main的特性。不过对于main包的main函数来说,你还需要明确一点,就是它虽然是用户层逻辑的入口函数,但它却不一定是用户层第一个被执行的函数。
|
||||
|
||||
这是为什么呢?这跟Go语言的另一个函数init有关。
|
||||
|
||||
init函数:Go包的初始化函数
|
||||
|
||||
除了前面讲过的main.main函数之外,Go语言还有一个特殊函数,它就是用于进行包初始化的init函数了。
|
||||
|
||||
和main.main函数一样,init函数也是一个无参数无返回值的函数:
|
||||
|
||||
func init() {
|
||||
// 包初始化逻辑
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
那我们现在回到前面这个“main函数不一定是用户层第一个被执行的函数”的问题,其实就是因为,如果main包依赖的包中定义了init函数,或者是main包自身定义了init函数,那么Go程序在这个包初始化的时候,就会自动调用它的init函数,因此这些init函数的执行就都会发生在main函数之前。
|
||||
|
||||
不过对于init函数来说,我们还需要注意一点,就是在Go程序中我们不能手工显式地调用init,否则就会收到编译错误,就像下面这个示例,它表示的手工显式调用init函数的错误做法:
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
fmt.Println("init invoked")
|
||||
}
|
||||
|
||||
func main() {
|
||||
init()
|
||||
}
|
||||
|
||||
|
||||
这样,在构建并运行上面这些示例代码之后,Go编译器会报下面这个错误:
|
||||
|
||||
$go run call_init.go
|
||||
./call_init.go:10:2: undefined: init
|
||||
|
||||
|
||||
实际上,Go包可以拥有不止一个init函数,每个组成Go包的Go源文件中,也可以定义多个init函数。
|
||||
|
||||
所以说,在初始化Go包时,Go会按照一定的次序,逐一、顺序地调用这个包的init函数。一般来说,先传递给Go编译器的源文件中的init函数,会先被执行;而同一个源文件中的多个init函数,会按声明顺序依次执行。
|
||||
|
||||
那么,现在我们就知晓了main.main函数可能并不是第一个被执行的函数的原因了。所以,当我们要在main.main函数执行之前,执行一些函数或语句的时候,我们只需要将它放入init函数中就可以了。
|
||||
|
||||
了解了这两个函数的执行顺序之后,我们现在就来整体地看看,一个Go包的初始化是以何种次序和逻辑进行的。
|
||||
|
||||
Go包的初始化次序
|
||||
|
||||
我们从程序逻辑结构角度来看,Go包是程序逻辑封装的基本单元,每个包都可以理解为是一个“自治”的、封装良好的、对外部暴露有限接口的基本单元。一个Go程序就是由一组包组成的,程序的初始化就是这些包的初始化。每个Go包还会有自己的依赖包、常量、变量、init函数(其中main包有main函数)等。
|
||||
|
||||
在这里你要注意:我们在阅读和理解代码的时候,需要知道这些元素在在程序初始化过程中的初始化顺序,这样便于我们确定在某一行代码处这些元素的当前状态。
|
||||
|
||||
下面,我们就通过一张流程图,来了解学习下Go包的初始化次序:
|
||||
|
||||
|
||||
|
||||
这里,我们来看看具体的初始化步骤。
|
||||
|
||||
首先,main包依赖pkg1和pkg4两个包,所以第一步,Go会根据包导入的顺序,先去初始化main包的第一个依赖包pkg1。
|
||||
|
||||
第二步,Go在进行包初始化的过程中,会采用“深度优先”的原则,递归初始化各个包的依赖包。在上图里,pkg1包依赖pkg2包,pkg2包依赖pkg3包,pkg3没有依赖包,于是Go在pkg3包中按照“常量 -> 变量 -> init函数”的顺序先对pkg3包进行初始化;
|
||||
|
||||
紧接着,在pkg3包初始化完毕后,Go会回到pkg2包并对pkg2包进行初始化,接下来再回到pkg1包并对pkg1包进行初始化。在调用完pkg1包的init函数后,Go就完成了main包的第一个依赖包pkg1的初始化。
|
||||
|
||||
接下来,Go会初始化main包的第二个依赖包pkg4,pkg4包的初始化过程与pkg1包类似,也是先初始化它的依赖包pkg5,然后再初始化自身;
|
||||
|
||||
然后,当Go初始化完pkg4包后也就完成了对main包所有依赖包的初始化,接下来初始化main包自身。
|
||||
|
||||
最后,在main包中,Go同样会按照“常量 -> 变量 -> init函数”的顺序进行初始化,执行完这些初始化工作后才正式进入程序的入口函数main函数。
|
||||
|
||||
现在,我们可以通过一段代码示例来验证一下Go程序启动后,Go包的初始化次序是否是正确的,示例程序的结构如下:
|
||||
|
||||
prog-init-order
|
||||
├── go.mod
|
||||
├── main.go
|
||||
├── pkg1
|
||||
│ └── pkg1.go
|
||||
├── pkg2
|
||||
│ └── pkg2.go
|
||||
└── pkg3
|
||||
└── pkg3.go
|
||||
|
||||
|
||||
我们设定的各个包的依赖关系如下:
|
||||
|
||||
|
||||
main包依赖pkg1包和pkg2包;
|
||||
pkg1包和pkg2包都依赖pkg3包。
|
||||
|
||||
|
||||
这里我只列出了main包的代码,pkg1、pkg2和pkg3包的代码与main包都是类似的,你可以自己尝试去列一下。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
_ "github.com/bigwhite/prog-init-order/pkg1"
|
||||
_ "github.com/bigwhite/prog-init-order/pkg2"
|
||||
)
|
||||
|
||||
var (
|
||||
_ = constInitCheck()
|
||||
v1 = variableInit("v1")
|
||||
v2 = variableInit("v2")
|
||||
)
|
||||
|
||||
const (
|
||||
c1 = "c1"
|
||||
c2 = "c2"
|
||||
)
|
||||
|
||||
func constInitCheck() string {
|
||||
if c1 != "" {
|
||||
fmt.Println("main: const c1 has been initialized")
|
||||
}
|
||||
if c2 != "" {
|
||||
fmt.Println("main: const c2 has been initialized")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func variableInit(name string) string {
|
||||
fmt.Printf("main: var %s has been initialized\n", name)
|
||||
return name
|
||||
}
|
||||
|
||||
func init() {
|
||||
fmt.Println("main: first init func invoked")
|
||||
}
|
||||
|
||||
func init() {
|
||||
fmt.Println("main: second init func invoked")
|
||||
}
|
||||
|
||||
func main() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,在main包中其实并没有使用pkg1和pkg2中的函数或方法,而是直接通过空导入的方式“触发”pkg1包和pkg2包的初始化(pkg2包也是通过空导入的方式依赖pkg3包的),下面是这个程序的运行结果:
|
||||
|
||||
$go run main.go
|
||||
pkg3: const c has been initialized
|
||||
pkg3: var v has been initialized
|
||||
pkg3: init func invoked
|
||||
pkg1: const c has been initialized
|
||||
pkg1: var v has been initialized
|
||||
pkg1: init func invoked
|
||||
pkg2: const c has been initialized
|
||||
pkg2: var v has been initialized
|
||||
pkg2: init func invoked
|
||||
main: const c1 has been initialized
|
||||
main: const c2 has been initialized
|
||||
main: var v1 has been initialized
|
||||
main: var v2 has been initialized
|
||||
main: first init func invoked
|
||||
main: second init func invoked
|
||||
|
||||
|
||||
你看,正如我们预期的那样,Go运行时是按照“pkg3 -> pkg1 -> pkg2 -> main”的顺序,来对Go程序的各个包进行初始化的,而在包内,则是以“常量 -> 变量 -> init函数”的顺序进行初始化。此外,main包的两个init函数,会按照在源文件main.go中的出现次序进行调用。
|
||||
|
||||
还有一点,pkg1包和pkg2包都依赖pkg3包,但根据Go语言规范,一个被多个包依赖的包仅会初始化一次,因此这里的pkg3包仅会被初始化了一次。
|
||||
|
||||
所以简而言之,记住Go包的初始化次序并不难,你只需要记住这三点就可以了:
|
||||
|
||||
|
||||
依赖包按“深度优先”的次序进行初始化;
|
||||
每个包内按以“常量 -> 变量 -> init函数”的顺序进行初始化;
|
||||
包内的多个init函数按出现次序进行自动调用。
|
||||
|
||||
|
||||
到这里,我们已经知道了Go程序中包的初始化次序,也了解了每个包中常量、变量以及init函数的运行次序,以及init函数作为包初始化函数的一些特性。
|
||||
|
||||
搞完了这些最主线的内容之后,不知你有没有发现,我们好像还忘记了一件事:我们好像忘记分析init函数的用途了?别急,我们现在就把这落下的功课补上,看看作为Go包初始化函数的init函数,在日常Go语言开发中怎么来使用呢?
|
||||
|
||||
init函数的用途
|
||||
|
||||
其实,init函数的这些常用用途,与init函数在Go包初始化过程中的次序密不可分。我们前面讲过,Go包初始化时,init函数的初始化次序在变量之后,这给了开发人员在init函数中对包级变量进行进一步检查与操作的机会。
|
||||
|
||||
这里我们先来看init函数的第一个常用用途:重置包级变量值。
|
||||
|
||||
init函数就好比Go包真正投入使用之前唯一的“质检员”,负责对包内部以及暴露到外部的包级数据(主要是包级变量)的初始状态进行检查。在Go标准库中,我们能发现很多init函数被用于检查包级变量的初始状态的例子,标准库flag包对init函数的使用就是其中的一个,这里我们简单来分析一下。
|
||||
|
||||
flag包定义了一个导出的包级变量CommandLine,如果用户没有通过flag.NewFlagSet创建新的代表命令行标志集合的实例,那么CommandLine就会作为flag包各种导出函数背后,默认的代表命令行标志集合的实例。
|
||||
|
||||
而在flag包初始化的时候,由于init函数初始化次序在包级变量之后,因此包级变量CommandLine会在init函数之前被初始化了,你可以看一下下面的代码:
|
||||
|
||||
var CommandLine = NewFlagSet(os.Args[0], ExitOnError)
|
||||
|
||||
func NewFlagSet(name string, errorHandling ErrorHandling) *FlagSet {
|
||||
f := &FlagSet{
|
||||
name: name,
|
||||
errorHandling: errorHandling,
|
||||
}
|
||||
f.Usage = f.defaultUsage
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *FlagSet) defaultUsage() {
|
||||
if f.name == "" {
|
||||
fmt.Fprintf(f.Output(), "Usage:\n")
|
||||
} else {
|
||||
fmt.Fprintf(f.Output(), "Usage of %s:\n", f.name)
|
||||
}
|
||||
f.PrintDefaults()
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,在通过NewFlagSet创建CommandLine变量绑定的FlagSet类型实例时,CommandLine的Usage字段被赋值为defaultUsage。
|
||||
|
||||
也就是说,如果保持现状,那么使用flag包默认CommandLine的用户就无法自定义usage的输出了。于是,flag包在init函数中重置了CommandLine的Usage字段:
|
||||
|
||||
func init() {
|
||||
CommandLine.Usage = commandLineUsage // 重置CommandLine的Usage字段
|
||||
}
|
||||
|
||||
func commandLineUsage() {
|
||||
Usage()
|
||||
}
|
||||
|
||||
var Usage = func() {
|
||||
fmt.Fprintf(CommandLine.Output(), "Usage of %s:\n", os.Args[0])
|
||||
PrintDefaults()
|
||||
}
|
||||
|
||||
|
||||
这个时候我们会发现,CommandLine的Usage字段,设置为了一个flag包内的未导出函数commandLineUsage,后者则直接使用了flag包的另外一个导出包变量Usage。这样,就可以通过init函数,将CommandLine与包变量Usage关联在一起了。
|
||||
|
||||
然后,当用户将自定义的usage赋值给了flag.Usage后,就相当于改变了默认代表命令行标志集合的CommandLine变量的Usage。这样当flag包完成初始化后,CommandLine变量便处于一个合理可用的状态了。
|
||||
|
||||
init函数的第二个常用用途,是实现对包级变量的复杂初始化。
|
||||
|
||||
有些包级变量需要一个比较复杂的初始化过程,有些时候,使用它的类型零值(每个Go类型都具有一个零值定义)或通过简单初始化表达式不能满足业务逻辑要求,而init函数则非常适合完成此项工作,标准库http包中就有这样一个典型示例:
|
||||
|
||||
var (
|
||||
http2VerboseLogs bool // 初始化时默认值为false
|
||||
http2logFrameWrites bool // 初始化时默认值为false
|
||||
http2logFrameReads bool // 初始化时默认值为false
|
||||
http2inTests bool // 初始化时默认值为false
|
||||
)
|
||||
|
||||
func init() {
|
||||
e := os.Getenv("GODEBUG")
|
||||
if strings.Contains(e, "http2debug=1") {
|
||||
http2VerboseLogs = true // 在init中对http2VerboseLogs的值进行重置
|
||||
}
|
||||
if strings.Contains(e, "http2debug=2") {
|
||||
http2VerboseLogs = true // 在init中对http2VerboseLogs的值进行重置
|
||||
http2logFrameWrites = true // 在init中对http2logFrameWrites的值进行重置
|
||||
http2logFrameReads = true // 在init中对http2logFrameReads的值进行重置
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,标准库http包定义了一系列布尔类型的特性开关变量,它们默认处于关闭状态(即值为false),但我们可以通过GODEBUG环境变量的值,开启相关特性开关。
|
||||
|
||||
可是这样一来,简单地将这些变量初始化为类型零值,就不能满足要求了,所以http包在init函数中,就根据环境变量GODEBUG的值,对这些包级开关变量进行了复杂的初始化,从而保证了这些开关变量在http包完成初始化后,可以处于合理状态。
|
||||
|
||||
说完了这个,我们现在来讲init函数的第三个常用用途:在init函数中实现“注册模式”。
|
||||
|
||||
为了让你更好地理解,首先我们来看一段使用lib/pq包访问PostgreSQL数据库的代码示例:
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
db, err := sql.Open("postgres", "user=pqgotest dbname=pqgotest sslmode=verify-full")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
age := 21
|
||||
rows, err := db.Query("SELECT name FROM users WHERE age = $1", age)
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
其实,这是一段“神奇”的代码,你可以看到示例代码是以空导入的方式导入lib/pq包的,main函数中没有使用pq包的任何变量、函数或方法,这样就实现了对PostgreSQL数据库的访问。而这一切的奥秘,全在pq包的init函数中:
|
||||
|
||||
func init() {
|
||||
sql.Register("postgres", &Driver{})
|
||||
}
|
||||
|
||||
|
||||
这个奥秘就在,我们其实是利用了用空导入的方式导入lib/pq包时产生的一个“副作用”,也就是lib/pq包作为main包的依赖包,它的init函数会在pq包初始化的时候得以执行。
|
||||
|
||||
从上面代码中,我们可以看到在pq包的init函数中,pq包将自己实现的sql驱动注册到了sql包中。这样只要应用层代码在Open数据库的时候,传入驱动的名字(这里是“postgres”),那么通过sql.Open函数,返回的数据库实例句柄对数据库进行的操作,实际上调用的都是pq包中相应的驱动实现。
|
||||
|
||||
实际上,这种通过在init函数中注册自己的实现的模式,就有效降低了Go包对外的直接暴露,尤其是包级变量的暴露,从而避免了外部通过包级变量对包状态的改动。
|
||||
|
||||
另外,从标准库database/sql包的角度来看,这种“注册模式”实质是一种工厂设计模式的实现,sql.Open函数就是这个模式中的工厂方法,它根据外部传入的驱动名称“生产”出不同类别的数据库实例句柄。
|
||||
|
||||
这种“注册模式”在标准库的其他包中也有广泛应用,比如说,使用标准库image包获取各种格式图片的宽和高:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif" // 以空导入方式注入gif图片格式驱动
|
||||
_ "image/jpeg" // 以空导入方式注入jpeg图片格式驱动
|
||||
_ "image/png" // 以空导入方式注入png图片格式驱动
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 支持png, jpeg, gif
|
||||
width, height, err := imageSize(os.Args[1]) // 获取传入的图片文件的宽与高
|
||||
if err != nil {
|
||||
fmt.Println("get image size error:", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("image size: [%d, %d]\n", width, height)
|
||||
}
|
||||
|
||||
func imageSize(imageFile string) (int, int, error) {
|
||||
f, _ := os.Open(imageFile) // 打开图文文件
|
||||
defer f.Close()
|
||||
|
||||
img, _, err := image.Decode(f) // 对文件进行解码,得到图片实例
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
b := img.Bounds() // 返回图片区域
|
||||
return b.Max.X, b.Max.Y, nil
|
||||
}
|
||||
|
||||
|
||||
你可以看到,上面这个示例程序支持png、jpeg、gif三种格式的图片,而达成这一目标的原因,正是image/png、image/jpeg和image/gif包都在各自的init函数中,将自己“注册”到image的支持格式列表中了,你可以看看下面这个代码:
|
||||
|
||||
// $GOROOT/src/image/png/reader.go
|
||||
func init() {
|
||||
image.RegisterFormat("png", pngHeader, Decode, DecodeConfig)
|
||||
}
|
||||
|
||||
// $GOROOT/src/image/jpeg/reader.go
|
||||
func init() {
|
||||
image.RegisterFormat("jpeg", "\xff\xd8", Decode, DecodeConfig)
|
||||
}
|
||||
|
||||
// $GOROOT/src/image/gif/reader.go
|
||||
func init() {
|
||||
image.RegisterFormat("gif", "GIF8?a", Decode, DecodeConfig)
|
||||
}
|
||||
|
||||
|
||||
那么,现在我们了解了init函数的常见用途。init函数之所以可以胜任这些工作,恰恰是因为它在Go应用初始化次序中的特殊“位次”,也就是main函数之前,常量和变量初始化之后。
|
||||
|
||||
小结
|
||||
|
||||
好了,我们今天这一节课就到这里了。
|
||||
|
||||
在这一节课中,我们一起了解了Go应用的用户层入口函数main.main、包初始化函数init,还有Go程序包的初始化次序和包内各种语法元素的初始化次序。
|
||||
|
||||
其中,你需要重点关注init函数具备的几种行为特征:
|
||||
|
||||
|
||||
执行顺位排在包内其他语法元素的后面;
|
||||
每个init函数在整个Go程序生命周期内仅会被执行一次;
|
||||
init函数是顺序执行的,只有当一个init函数执行完毕后,才会去执行下一个init函数。
|
||||
|
||||
|
||||
基于上面这些特征,init函数十分适合做一些包级数据初始化工作以及包级数据初始状态的检查工作,我们也通过实例讲解了init函数的这些常见用途。
|
||||
|
||||
最后,大多Go程序都是并发程序,程序会启动多个Goroutine并发执行程序逻辑,这里你一定要注意主Goroutine的优雅退出,也就是主Goroutine要根据实际情况来决定,是否要等待其他子Goroutine做完清理收尾工作退出后再行退出。
|
||||
|
||||
思考题
|
||||
|
||||
今天我给你留了一个思考题:当init函数在检查包数据初始状态时遇到失败或错误的情况,我们该如何处理呢?欢迎在留言区留下你的答案。
|
||||
|
||||
感谢你和我一起学习,也欢迎你把这门课分享给更多对Go语言感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
524
专栏/TonyBai·Go语言第一课/09即学即练:构建一个Web服务就是这么简单.md
Normal file
524
专栏/TonyBai·Go语言第一课/09即学即练:构建一个Web服务就是这么简单.md
Normal file
@@ -0,0 +1,524 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 即学即练:构建一个Web服务就是这么简单
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在入门篇前面的几节课中,我们已经从Go开发环境的安装,一路讲到了Go包的初始化次序与Go入口函数。讲解这些,不仅仅是因为它们是你学习Go语言的基础,同时我也想为你建立“手勤”的意识打好基础。
|
||||
|
||||
作为Go语言学习的“过来人”,学到这个阶段,我深知你心里都在跃跃欲试,想将前面学到的知识综合运用起来,实现一个属于自己的Go程序。但到目前为止,我们还没有开始Go基础语法的系统学习,你肯定会有一种“无米下炊”的感觉。
|
||||
|
||||
不用担心,我在这节课安排了一个实战小项目。在这个小项目里,我希望你不要困在各种语法里,而是先跟着我““照猫画虎”地写一遍、跑一次,感受Go项目的结构,体会Go语言的魅力。
|
||||
|
||||
预热:最简单的HTTP服务
|
||||
|
||||
在想选择以什么类型的项目的时候,我还颇费了一番脑筋。我查阅了Go官方用户2020调查报告,找到Go应用最广泛的领域调查结果图,如下所示:
|
||||
|
||||
|
||||
|
||||
我们看到,Go应用的前4个领域中,有两个都是Web服务相关的。一个是排在第一位的API/RPC服务,另一个是排在第四位的Web服务(返回html页面)。考虑到后续你把Go应用于Web服务领域的机会比较大,所以,在这节课我们就选择一个Web服务项目作为实战小项目。
|
||||
|
||||
不过在真正开始我们的实战小项目前,我们先来预热一下,做一下技术铺垫。我先来给你演示一下在Go中创建一个基于HTTP协议的Web服务是多么的简单。
|
||||
|
||||
这种简单又要归功于Go“面向工程”特性。在02讲介绍Go的设计哲学时,我们也说过,Go“面向工程”的特性,不仅体现在语言设计方面时刻考虑开发人员的体验,而且它还提供了完善的工具链和“自带电池”的标准库,这就使得Go程序大大减少了对外部第三方包的依赖。以开发Web服务为例,我们可以基于Go标准库提供的net/http包,轻松构建一个承载Web内容传输的HTTP服务。
|
||||
|
||||
下面,我们就来构建一个最简单的HTTP服务,这个服务的功能很简单,就是当收到一个HTTP请求后,给请求方返回包含“hello, world”数据的响应。
|
||||
|
||||
我们首先按下面步骤建立一个simple-http-server目录,并创建一个名为simple-http-server的Go Module:
|
||||
|
||||
$mkdir simple-http-server
|
||||
$cd simple-http-server
|
||||
$go mod init simple-http-server
|
||||
|
||||
|
||||
由于这个HTTP服务比较简单,我们采用最简项目布局,也就是在simple-http-server目录下创建一个main.go源文件:
|
||||
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request){
|
||||
w.Write([]byte("hello, world"))
|
||||
})
|
||||
http.ListenAndServe(":8080", nil)
|
||||
}
|
||||
|
||||
|
||||
这些代码就是一个最简单的HTTP服务的实现了。在这个实现中,我们只使用了Go标准库的http包。可能你现在对http包还不熟悉,但没有关系,你现在只需要大致了解上面代码的结构与原理就可以了。
|
||||
|
||||
这段代码里,你要注意两个重要的函数,一个是ListenAndServe,另一个是HandleFunc。
|
||||
|
||||
你会看到,代码的第9行,我们通过http包提供的ListenAndServe函数,建立起一个HTTP服务,这个服务监听本地的8080端口。客户端通过这个端口与服务建立连接,发送HTTP请求就可以得到相应的响应结果。
|
||||
|
||||
那么服务端是如何处理客户端发送的请求的呢?我们看上面代码中的第6行。在这一行中,我们为这个服务设置了一个处理函数。这个函数的函数原型是这样的:
|
||||
|
||||
func(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
|
||||
这个函数里有两个参数,w和r。第二个参数r代表来自客户端的HTTP请求,第一个参数w则是用来操作返回给客户端的应答的,基于http包实现的HTTP服务的处理函数都要符合这一原型。
|
||||
|
||||
你也发现了,在这个例子中,所有来自客户端的请求,无论请求的URI路径(RequestURI)是什么,请求都会被我们设置的处理函数处理。为什么会这样呢?
|
||||
|
||||
这是因为,我们通过http.HandleFunc设置这个处理函数时,传入的模式字符串为“/”。HTTP服务器在收到请求后,会将请求中的URI路径与设置的模式字符串进行最长前缀匹配,并执行匹配到的模式字符串所对应的处理函数。在这个例子中,我们仅设置了“/”这一个模式字符串,并且所有请求的URI都能与之匹配,自然所有请求都会被我们设置的处理函数处理。
|
||||
|
||||
接着,我们再来编译运行一下这个程序,直观感受一下HTTP服务处理请求的过程。我们首先按下面步骤来编译并运行这个程序:
|
||||
|
||||
$cd simple-http-server
|
||||
$go build
|
||||
$./simple-http-server
|
||||
|
||||
|
||||
接下来,我们用curl命令行工具模拟客户端,向上述服务建立连接并发送http请求:
|
||||
|
||||
$curl localhost:8080/
|
||||
hello, world
|
||||
|
||||
|
||||
我们看到,curl成功得到了http服务返回的“hello, world”响应数据。到此,我们的HTTP服务就构建成功了。
|
||||
|
||||
当然了,真实世界的Web服务不可能像上述例子这么简单,这仅仅是一个“预热”。我想让你知道,使用Go构建Web服务是非常容易的。并且,这样的预热也能让你初步了解实现代码的结构,先有一个技术铺垫。
|
||||
|
||||
下面我们就进入这节课的实战小项目,一个更接近于真实世界情况的图书管理API服务。
|
||||
|
||||
图书管理API服务
|
||||
|
||||
首先,我们先来明确一下我们的业务逻辑。
|
||||
|
||||
在这个实战小项目中,我们模拟的是真实世界的一个书店的图书管理后端服务。这个服务为平台前端以及其他客户端,提供针对图书的CRUD(创建、检索、更新与删除)的基于HTTP协议的API。API采用典型的RESTful风格设计,这个服务提供的API集合如下:
|
||||
|
||||
|
||||
|
||||
这个API服务的逻辑并不复杂。简单来说,我们通过id来唯一标识一本书,对于图书来说,这个id通常是ISBN号。至于客户端和服务端中请求与响应的数据,我们采用放在HTTP协议包体(Body)中的Json格式数据来承载。
|
||||
|
||||
业务逻辑是不是很简单啊?下面我们就直接开始创建这个项目。
|
||||
|
||||
项目建立与布局设计
|
||||
|
||||
我们按照下面步骤创建一个名为bookstore的Go项目并创建对应的Go Module:
|
||||
|
||||
$mkdir bookstore
|
||||
$cd bookstore
|
||||
$go mod init bookstore
|
||||
go: creating new go.mod: module bookstore
|
||||
|
||||
|
||||
通过上面的业务逻辑说明,我们可以把这个服务大体拆分为两大部分,一部分是HTTP服务器,用来对外提供API服务;另一部分是图书数据的存储模块,所有的图书数据均存储在这里。
|
||||
|
||||
同时,这是一个以构建可执行程序为目的的Go项目,我们参考Go项目布局标准一讲中的项目布局,把这个项目的结构布局设计成这样:
|
||||
|
||||
├── cmd/
|
||||
│ └── bookstore/ // 放置bookstore main包源码
|
||||
│ └── main.go
|
||||
├── go.mod // module bookstore的go.mod
|
||||
├── go.sum
|
||||
├── internal/ // 存放项目内部包的目录
|
||||
│ └── store/
|
||||
│ └── memstore.go
|
||||
├── server/ // HTTP服务器模块
|
||||
│ ├── middleware/
|
||||
│ │ └── middleware.go
|
||||
│ └── server.go
|
||||
└── store/ // 图书数据存储模块
|
||||
├── factory/
|
||||
│ └── factory.go
|
||||
└── store.go
|
||||
|
||||
|
||||
现在,我们既给出了这个项目的结构布局,也给出了这个项目最终实现的源码文件分布情况。下面我们就从main包开始,自上而下逐一看看这个项目的模块设计与实现。
|
||||
|
||||
项目main包
|
||||
|
||||
main包是主要包,为了搞清楚各个模块之间的关系,我在这里给出了main包的实现逻辑图:
|
||||
|
||||
|
||||
|
||||
同时,我也列出了main包(main.go)的所有代码,你可以先花几分钟看一下:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "bookstore/internal/store"
|
||||
"bookstore/server"
|
||||
"bookstore/store/factory"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
s, err := factory.New("mem") // 创建图书数据存储模块实例
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
srv := server.NewBookStoreServer(":8080", s) // 创建http服务实例
|
||||
|
||||
errChan, err := srv.ListenAndServe() // 运行http服务
|
||||
if err != nil {
|
||||
log.Println("web server start failed:", err)
|
||||
return
|
||||
}
|
||||
log.Println("web server start ok")
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select { // 监视来自errChan以及c的事件
|
||||
case err = <-errChan:
|
||||
log.Println("web server run failed:", err)
|
||||
return
|
||||
case <-c:
|
||||
log.Println("bookstore program is exiting...")
|
||||
ctx, cf := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cf()
|
||||
err = srv.Shutdown(ctx) // 优雅关闭http服务实例
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Println("bookstore program exit error:", err)
|
||||
return
|
||||
}
|
||||
log.Println("bookstore program exit ok")
|
||||
}
|
||||
|
||||
|
||||
在Go中,main包不仅包含了整个程序的入口,它还是整个程序中主要模块初始化与组装的场所。那对应在我们这个程序中,主要模块就是第16行的创建图书存储模块实例,以及第21行创建HTTP服务模块实例。而且,你还要注意的是,第21行创建HTTP服务模块实例的时候,我们把图书数据存储实例s作为参数,传递给了NewBookStoreServer函数。这两个实例的创建原理,我们等会再来细细探讨。
|
||||
|
||||
这里,我们重点来看main函数的后半部分(第30行~第42行),这里表示的是,我们通过监视系统信号实现了http服务实例的优雅退出。
|
||||
|
||||
所谓优雅退出,指的就是程序有机会等待其他的事情处理完再退出。比如尚未完成的事务处理、清理资源(比如关闭文件描述符、关闭socket)、保存必要中间状态、内存数据持久化落盘,等等。如果你经常用Go来编写http服务,那么http服务如何优雅退出,就是你经常要考虑的问题。
|
||||
|
||||
在这个问题的具体实现上,我们通过signal包的Notify捕获了SIGINT、SIGTERM这两个系统信号。这样,当这两个信号中的任何一个触发时,我们的http服务实例都有机会在退出前做一些清理工作。
|
||||
|
||||
然后,我们再使用http服务实例(srv)自身提供的Shutdown方法,来实现http服务实例内部的退出清理工作,包括:立即关闭所有listener、关闭所有空闲的连接、等待处于活动状态的连接处理完毕,等等。当http服务实例的清理工作完成后,我们整个程序就可以正常退出了。
|
||||
|
||||
接下来,我们再重点看看构成bookstore程序的两个主要模块:图书数据存储模块与HTTP服务模块的实现。我们按照main函数中的初始化顺序,先来看看图书数据存储模块。
|
||||
|
||||
图书数据存储模块(store)
|
||||
|
||||
图书数据存储模块的职责很清晰,就是用来存储整个bookstore的图书数据的。图书数据存储有很多种实现方式,最简单的方式莫过于在内存中创建一个map,以图书id作为key,来保存图书信息,我们在这一讲中也会采用这种方式。但如果我们要考虑上生产环境,数据要进行持久化,那么最实际的方式就是通过Nosql数据库甚至是关系型数据库,实现对图书数据的存储与管理。
|
||||
|
||||
考虑到对多种存储实现方式的支持,我们将针对图书的有限种存储操作,放置在一个接口类型Store中,如下源码所示:
|
||||
|
||||
// store/store.go
|
||||
|
||||
type Book struct {
|
||||
Id string `json:"id"` // 图书ISBN ID
|
||||
Name string `json:"name"` // 图书名称
|
||||
Authors []string `json:"authors"` // 图书作者
|
||||
Press string `json:"press"` // 出版社
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Create(*Book) error // 创建一个新图书条目
|
||||
Update(*Book) error // 更新某图书条目
|
||||
Get(string) (Book, error) // 获取某图书信息
|
||||
GetAll() ([]Book, error) // 获取所有图书信息
|
||||
Delete(string) error // 删除某图书条目
|
||||
}
|
||||
|
||||
|
||||
这里,我们建立了一个对应图书条目的抽象数据类型Book,以及针对Book存取的接口类型Store。这样,对于想要进行图书数据操作的一方来说,他只需要得到一个满足Store接口的实例,就可以实现对图书数据的存储操作了,不用再关心图书数据究竟采用了何种存储方式。这就实现了图书存储操作与底层图书数据存储方式的解耦。而且,这种面向接口编程也是Go组合设计哲学的一个重要体现。
|
||||
|
||||
那我们具体如何创建一个满足Store接口的实例呢?我们可以参考《设计模式》提供的多种创建型模式,选择一种Go风格的工厂模式(创建型模式的一种)来实现满足Store接口实例的创建。我们看一下store/factory包的源码:
|
||||
|
||||
// store/factory/factory.go
|
||||
|
||||
var (
|
||||
providersMu sync.RWMutex
|
||||
providers = make(map[string]store.Store)
|
||||
)
|
||||
|
||||
func Register(name string, p store.Store) {
|
||||
providersMu.Lock()
|
||||
defer providersMu.Unlock()
|
||||
if p == nil {
|
||||
panic("store: Register provider is nil")
|
||||
}
|
||||
|
||||
if _, dup := providers[name]; dup {
|
||||
panic("store: Register called twice for provider " + name)
|
||||
}
|
||||
providers[name] = p
|
||||
}
|
||||
|
||||
func New(providerName string) (store.Store, error) {
|
||||
providersMu.RLock()
|
||||
p, ok := providers[providerName]
|
||||
providersMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("store: unknown provider %s", providerName)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
||||
这段代码实际上是效仿了Go标准库的database/sql包采用的方式,factory包采用了一个map类型数据,对工厂可以“生产”的、满足Store接口的实例类型进行管理。factory包还提供了Register函数,让各个实现Store接口的类型可以把自己“注册”到工厂中来。
|
||||
|
||||
一旦注册成功,factory包就可以“生产”出这种满足Store接口的类型实例。而依赖Store接口的使用方,只需要调用factory包的New函数,再传入期望使用的图书存储实现的名称,就可以得到对应的类型实例了。
|
||||
|
||||
在项目的internal/store目录下,我们还提供了一个基于内存map的Store接口的实现,我们具体看一下这个实现是怎么自注册到factory包中的:
|
||||
|
||||
// internal/store/memstore.go
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
mystore "bookstore/store"
|
||||
factory "bookstore/store/factory"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func init() {
|
||||
factory.Register("mem", &MemStore{
|
||||
books: make(map[string]*mystore.Book),
|
||||
})
|
||||
}
|
||||
|
||||
type MemStore struct {
|
||||
sync.RWMutex
|
||||
books map[string]*mystore.Book
|
||||
}
|
||||
|
||||
|
||||
从memstore的代码来看,它是在包的init函数中调用factory包提供的Register函数,把自己的实例以“mem”的名称注册到factory中的。这样做有一个好处,依赖Store接口进行图书数据管理的一方,只要导入internal/store这个包,就可以自动完成注册动作了。
|
||||
|
||||
理解了这个之后,我们再看下面main包中,创建图书数据存储模块实例时采用的代码,是不是就豁然开朗了?
|
||||
|
||||
import (
|
||||
... ...
|
||||
_ "bookstore/internal/store" // internal/store将自身注册到factory中
|
||||
)
|
||||
|
||||
func main() {
|
||||
s, err := factory.New("mem") // 创建名为"mem"的图书数据存储模块实例
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
至于memstore.go中图书数据存储的具体逻辑,就比较简单了,我这里就不详细分析了,你课后自己阅读一下吧。
|
||||
|
||||
接着,我们再来看看bookstore程序的另外一个重要模块:HTTP服务模块。
|
||||
|
||||
HTTP服务模块(server)
|
||||
|
||||
HTTP服务模块的职责是对外提供HTTP API服务,处理来自客户端的各种请求,并通过Store接口实例执行针对图书数据的相关操作。这里,我们抽象处理一个server包,这个包中定义了一个BookStoreServer类型如下:
|
||||
|
||||
// server/server.go
|
||||
|
||||
type BookStoreServer struct {
|
||||
s store.Store
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
|
||||
我们看到,这个类型实质上就是一个标准库的http.Server,并且组合了来自store.Store接口的能力。server包提供了NewBookStoreServer函数,用来创建一个BookStoreServer类型实例:
|
||||
|
||||
// server/server.go
|
||||
|
||||
func NewBookStoreServer(addr string, s store.Store) *BookStoreServer {
|
||||
srv := &BookStoreServer{
|
||||
s: s,
|
||||
srv: &http.Server{
|
||||
Addr: addr,
|
||||
},
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/book", srv.createBookHandler).Methods("POST")
|
||||
router.HandleFunc("/book/{id}", srv.updateBookHandler).Methods("POST")
|
||||
router.HandleFunc("/book/{id}", srv.getBookHandler).Methods("GET")
|
||||
router.HandleFunc("/book", srv.getAllBooksHandler).Methods("GET")
|
||||
router.HandleFunc("/book/{id}", srv.delBookHandler).Methods("DELETE")
|
||||
|
||||
srv.srv.Handler = middleware.Logging(middleware.Validating(router))
|
||||
return srv
|
||||
}
|
||||
|
||||
|
||||
我们看到函数NewBookStoreServer接受两个参数,一个是HTTP服务监听的服务地址,另外一个是实现了store.Store接口的类型实例。这种函数原型的设计是Go语言的一种惯用设计方法,也就是接受一个接口类型参数,返回一个具体类型。返回的具体类型组合了传入的接口类型的能力。
|
||||
|
||||
这个时候,和前面预热时实现的简单http服务一样,我们还需为HTTP服务器设置请求的处理函数。
|
||||
|
||||
由于这个服务请求URI的模式字符串比较复杂,标准库http包内置的URI路径模式匹配器(ServeMux,也称为路由管理器)不能满足我们的需求,因此在这里,我们需要借助一个第三方包github.com/gorilla/mux来实现我们的需求。
|
||||
|
||||
在上面代码的第11行到第16行,我们针对不同URI路径模式设置了不同的处理函数。我们以createBookHandler和getBookHandler为例来看看这些处理函数的实现:
|
||||
|
||||
// server/server.go
|
||||
|
||||
func (bs *BookStoreServer) createBookHandler(w http.ResponseWriter, req *http.Request) {
|
||||
dec := json.NewDecoder(req.Body)
|
||||
var book store.Book
|
||||
if err := dec.Decode(&book); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bs.s.Create(&book); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (bs *BookStoreServer) getBookHandler(w http.ResponseWriter, req *http.Request) {
|
||||
id, ok := mux.Vars(req)["id"]
|
||||
if !ok {
|
||||
http.Error(w, "no id found in request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
book, err := bs.s.Get(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response(w, book)
|
||||
}
|
||||
|
||||
func response(w http.ResponseWriter, v interface{}) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
|
||||
这些处理函数的实现都大同小异,都是先获取http请求包体数据,然后通过标准库json包将这些数据,解码(decode)为我们需要的store.Book结构体实例,再通过Store接口对图书数据进行存储操作。如果我们是获取图书数据的请求,那么处理函数将通过response函数,把取出的图书数据编码到http响应的包体中,并返回给客户端。
|
||||
|
||||
然后,在NewBookStoreServer函数实现的尾部,我们还看到了这样一行代码:
|
||||
|
||||
srv.srv.Handler = middleware.Logging(middleware.Validating(router))
|
||||
|
||||
|
||||
这行代码的意思是说,我们在router的外围包裹了两层middleware。什么是middleware呢?对于我们的上下文来说,这些middleware就是一些通用的http处理函数。我们看一下这里的两个middleware,也就是Logging与Validating函数的实现:
|
||||
|
||||
// server/middleware/middleware.go
|
||||
|
||||
func Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
log.Printf("recv a %s request from %s", req.Method, req.RemoteAddr)
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
func Validating(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
contentType := req.Header.Get("Content-Type")
|
||||
mediatype, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if mediatype != "application/json" {
|
||||
http.Error(w, "invalid Content-Type", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
我们看到,Logging函数主要用来输出每个到达的HTTP请求的一些概要信息,而Validating则会对每个http请求的头部进行检查,检查Content-Type头字段所表示的媒体类型是否为application/json。这些通用的middleware函数,会被串联到每个真正的处理函数之前,避免我们在每个处理函数中重复实现这些逻辑。
|
||||
|
||||
创建完BookStoreServer实例后,我们就可以调用其ListenAndServe方法运行这个http服务了,显然这个方法的名字是仿效http.Server类型的同名方法,我们的实现是这样的:
|
||||
|
||||
// server/server.go
|
||||
|
||||
func (bs *BookStoreServer) ListenAndServe() (<-chan error, error) {
|
||||
var err error
|
||||
errChan := make(chan error)
|
||||
go func() {
|
||||
err = bs.srv.ListenAndServe()
|
||||
errChan <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-errChan:
|
||||
return nil, err
|
||||
case <-time.After(time.Second):
|
||||
return errChan, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们看到,这个函数把BookStoreServer内部的http.Server的运行,放置到一个单独的轻量级线程Goroutine中。这是因为,http.Server.ListenAndServe会阻塞代码的继续运行,如果不把它放在单独的Goroutine中,后面的代码将无法得到执行。
|
||||
|
||||
为了检测到http.Server.ListenAndServe的运行状态,我们再通过一个channel(位于第5行的errChan),在新创建的Goroutine与主Goroutine之间建立的通信渠道。通过这个渠道,这样我们能及时得到http server的运行状态。
|
||||
|
||||
编译、运行与验证
|
||||
|
||||
到这里,bookstore项目的大部分重要代码我们都分析了一遍,是时候将程序跑起来看看了。
|
||||
|
||||
不过,因为我们在程序中引入了一个第三方依赖包,所以在构建项目之前,我们需要执行下面这个命令,让Go命令自动分析依赖项和版本,并更新go.mod:
|
||||
|
||||
$go mod tidy
|
||||
go: finding module for package github.com/gorilla/mux
|
||||
go: found github.com/gorilla/mux in github.com/gorilla/mux v1.8.0
|
||||
|
||||
|
||||
完成后,我们就可以按下面的步骤来构建并执行bookstore了:
|
||||
|
||||
$go build bookstore/cmd/bookstore
|
||||
$./bookstore
|
||||
2021/10/05 16:08:36 web server start ok
|
||||
|
||||
|
||||
如果你看到上面这个输出的日志,说明我们的程序启动成功了。
|
||||
|
||||
现在,我们就可以像前面一样使用curl命令行工具,模仿客户端向bookstore服务发起请求了,比如创建一个新书条目:
|
||||
|
||||
$curl -X POST -H "Content-Type:application/json" -d '{"id": "978-7-111-55842-2", "name": "The Go Programming Language", "authors":["Alan A.A.Donovan", "Brian W. Kergnighan"],"press": "Pearson Education"}' localhost:8080/book
|
||||
|
||||
|
||||
此时服务端会输出如下日志,表明我们的bookstore服务收到了客户端请求。
|
||||
|
||||
2021/10/05 16:09:10 recv a POST request from [::1]:58021
|
||||
|
||||
|
||||
接下来,我们再来获取一下这本书的信息:
|
||||
|
||||
$curl -X GET -H "Content-Type:application/json" localhost:8080/book/978-7-111-55842-2
|
||||
{"id":"978-7-111-55842-2","name":"The Go Programming Language","authors":["Alan A.A.Donovan","Brian W. Kergnighan"],"press":"Pearson Education"}
|
||||
|
||||
|
||||
我们看到curl得到的响应与我们预期的是一致的。
|
||||
|
||||
好了,我们不再进一步验证了,你课后还可以自行编译、执行并验证。
|
||||
|
||||
小结
|
||||
|
||||
到这里,我们就完成了我们第一个实战小项目,不知道你感觉如何呢?
|
||||
|
||||
在这一讲中,我们带你用Go语言构建了一个最简单的HTTP服务,以及一个接近真实的图书管理API服务。在整个实战小项目的实现过程中,你也能初步学习到Go编码时常用的一些惯用法,比如基于接口的组合、类似database/sql所使用的惯用创建模式,等等。
|
||||
|
||||
通过这节课的学习,你是否体会到了Go语言的魅力了呢?是否察觉到Go编码与其他主流语言不同的风格了呢?其实不论你的理解程度有多少,都不重要。只要你能“照猫画虎”地将上面的程序自己编写一遍,构建、运行起来并验证一遍,就算是完美达成这一讲的目标了。
|
||||
|
||||
你在这个过程肯定会有各种各样的问题,但没关系,这些问题会成为你继续向下学习Go的动力。毕竟,带着问题的学习,能让你的学习过程更有的放矢、更高效。
|
||||
|
||||
思考题
|
||||
|
||||
如果你完成了今天的代码,觉得自己学有余力,可以再挑战一下,不妨试试基于nosql数据库,我们怎么实现一个新store.Store接口的实现吧?
|
||||
|
||||
欢迎把这节课分享给更多对Go语言感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
资源链接
|
||||
|
||||
这节课的图书管理项目的完整源码在这里!
|
||||
|
||||
|
||||
|
||||
|
||||
349
专栏/TonyBai·Go语言第一课/10变量声明:静态语言有别于动态语言的重要特征.md
Normal file
349
专栏/TonyBai·Go语言第一课/10变量声明:静态语言有别于动态语言的重要特征.md
Normal file
@@ -0,0 +1,349 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 变量声明:静态语言有别于动态语言的重要特征
|
||||
你好,我是Tony Bai。
|
||||
|
||||
今天我们将深入Go语法细节,学习静态语言有别于动态语言的一个重要特征:变量声明。那么变量声明究竟解决的是什么问题呢?我们先从变量这个概念说起。
|
||||
|
||||
在编程语言中,为了方便操作内存特定位置的数据,我们用一个特定的名字与位于特定位置的内存块绑定在一起,这个名字被称为变量。
|
||||
|
||||
但这并不代表我们可以通过变量随意引用或修改内存,变量所绑定的内存区域是要有一个明确的边界的。也就是说,通过这样一个变量,我们究竟可以操作4个字节内存还是8个字节内存,又或是256个字节内存,编程语言的编译器或解释器需要明确地知道。
|
||||
|
||||
那么,编程语言的编译器或解释器是如何知道一个变量所能引用的内存区域边界呢?
|
||||
|
||||
其实,动态语言和静态语言有不同的处理方式。动态语言(比如Python、Ruby等)的解释器可以在运行时通过对变量赋值的分析,自动确定变量的边界。并且在动态语言中,一个变量可以在运行时被赋予大小不同的边界。
|
||||
|
||||
而静态编程语言在这方面的“体验略差”。静态类型语言编译器必须明确知道一个变量的边界才允许使用这个变量,但静态语言编译器又没能力自动提供这个信息,这个边界信息必须由这门语言的使用者提供,于是就有了“变量声明”。通过变量声明,语言使用者可以显式告知编译器一个变量的边界信息。在具体实现层面呢,这个边界信息由变量的类型属性赋予。
|
||||
|
||||
作为身处静态编程语言阵营的Go语言,它沿袭了静态语言的这一要求:使用变量之前需要先进行变量声明。
|
||||
|
||||
首先,让我们先来看看Go语言的变量声明方法。
|
||||
|
||||
Go语言的变量声明方法
|
||||
|
||||
我们前面说过,Go是静态语言,所有变量在使用前必须先进行声明。声明的意义在于告诉编译器该变量可以操作的内存的边界信息,而这种边界通常又是由变量的类型信息提供的。
|
||||
|
||||
在Go语言中,有一个通用的变量声明方法是这样的:
|
||||
|
||||
|
||||
|
||||
这个变量声明分为四个部分:
|
||||
|
||||
|
||||
var是修饰变量声明的关键字;
|
||||
a为变量名;
|
||||
int为该变量的类型;
|
||||
10是变量的初值。
|
||||
|
||||
|
||||
你看啊,其实Go语言的变量声明形式与其他主流静态语言有一个显著的差异,那就是它将变量名放在了类型的前面。这样做有什么好处呢?我先不说,我想请你思考一下。这个类型为变量提供了边界信息,在Go语言中,无论什么类型的变量,都可以使用这种形式进行变量声明。
|
||||
|
||||
但是,如果你没有显式为变量赋予初值,Go编译器会为变量赋予这个类型的零值:
|
||||
|
||||
var a int // a的初值为int类型的零值:0
|
||||
|
||||
|
||||
什么是类型的零值呢?Go语言的每种原生类型都有它的默认值(这些原生类型我们后面再讲),这个默认值就是这个类型的零值。这里我给你写了Go规范定义的内置原生类型的默认值(即零值):
|
||||
|
||||
|
||||
|
||||
另外,像数组、结构体这样复合类型变量的零值就是它们组成元素都为零值时的结果。
|
||||
|
||||
除了单独声明每个变量外,Go语言还提供了变量声明块(block)的语法形式,可以用一个var关键字将多个变量声明放在一起,像下面代码这样:
|
||||
|
||||
var (
|
||||
a int = 128
|
||||
b int8 = 6
|
||||
s string = "hello"
|
||||
c rune = 'A'
|
||||
t bool = true
|
||||
)
|
||||
|
||||
|
||||
你看在这个变量声明块中,我们通过一个var关键字声明了5个不同类型的变量。而且,Go语言还支持在一行变量声明中同时声明多个变量:
|
||||
|
||||
var a, b, c int = 5, 6, 7
|
||||
|
||||
|
||||
这样的多变量声明同样也可以用在变量声明块中,像下面这样:
|
||||
|
||||
var (
|
||||
a, b, c int = 5, 6, 7
|
||||
c, d, e rune = 'C', 'D', 'E'
|
||||
)
|
||||
|
||||
|
||||
当然了,虽然我们现在写的多变量声明都是在声明同一类型的变量,但是它也适用于声明不同类型的变量,这个我们等会儿会详细讲讲。
|
||||
|
||||
除了上面这种通用的变量声明形式,为了给开发者带来更好的使用体验,Go语言还提供了两种变量声明的“语法糖”,下面我们逐一来学习一下。
|
||||
|
||||
1. 省略类型信息的声明:
|
||||
|
||||
在通用的变量声明的基础上,Go编译器允许我们省略变量声明中的类型信息,它的标准范式是“var varName = initExpression”,比如下面就是一个省略了类型信息的变量声明:
|
||||
|
||||
var b = 13
|
||||
|
||||
|
||||
那么Go编译器在遇到这样的变量声明后是如何确定变量的类型信息呢?
|
||||
|
||||
其实很简单,Go编译器会根据右侧变量初值自动推导出变量的类型,并给这个变量赋予初值所对应的默认类型。比如,整型值的默认类型int,浮点值的默认类型为float64,复数值的默认类型为complex128。其他类型值的默认类型就更好分辨了,在Go语言中仅有唯一与之对应的类型,比如布尔值的默认类型只能是bool,字符值默认类型只能是rune,字符串值的默认类型只能是string等。
|
||||
|
||||
如果我们不接受默认类型,而是要显式地为变量指定类型,除了通用的声明形式,我们还可以通过显式类型转型达到我们的目的:
|
||||
|
||||
var b = int32(13)
|
||||
|
||||
|
||||
显然这种省略类型信息声明的“语法糖”仅适用于在变量声明的同时显式赋予变量初值的情况,下面这种没有初值的声明形式是不被允许的:
|
||||
|
||||
var b
|
||||
|
||||
|
||||
结合多变量声明,我们可以使用这种变量声明“语法糖”声明多个不同类型的变量:
|
||||
|
||||
var a, b, c = 12, 'A', "hello"
|
||||
|
||||
|
||||
在这个变量声明中,我们声明了三个变量a、b和c,但它们分别具有不同的类型,分别为int、rune和string。
|
||||
|
||||
在这种变量声明语法糖中,我们省去了变量类型信息,但Go编译器会为我们自动推导出类型信息。那是否还有更简化的变量声明形式呢?答案是有的。下面我们就来看看短变量声明。
|
||||
|
||||
2. 短变量声明:
|
||||
|
||||
其实,Go语言还为我们提供了最简化的变量声明形式:短变量声明。使用短变量声明时,我们甚至可以省去var关键字以及类型信息,它的标准范式是“varName := initExpression”。我这里也举了几个例子:
|
||||
|
||||
a := 12
|
||||
b := 'A'
|
||||
c := "hello"
|
||||
|
||||
|
||||
这里我们看到,短变量声明将通用变量声明中的四个部分省去了两个,但它并没有使用赋值操作符“=”,而是使用了短变量声明专用的“:=”。这个原理和上一种省略类型信息的声明语法糖一样,短变量声明中的变量类型也是由Go编译器自动推导出来的。
|
||||
|
||||
而且,短变量声明也支持一次声明多个变量,而且形式更为简洁,是这个样子的:
|
||||
|
||||
a, b, c := 12, 'A', "hello"
|
||||
|
||||
|
||||
不过呢,短变量声明的使用也是有约束的,并不是所有变量都能用短变量声明来声明的,这个你会在下面的讲解中了解到。
|
||||
|
||||
好了,现在我们已经学习了至少三种变量声明形式了。这时候你可能有些犯迷糊了:这些变量声明形式是否适合所有变量呢?我到底该使用哪一种呢?别急,在揭晓答案之前,我们需要学习点预备知识:Go语言的两类变量。
|
||||
|
||||
通常来说,Go语言的变量可以分为两类:一类称为包级变量(package varible),也就是在包级别可见的变量。如果是导出变量(大写字母开头),那么这个包级变量也可以被视为全局变量;另一类则是局部变量(local varible),也就是Go函数或方法体内声明的变量,仅在函数或方法体内可见。而我们声明的所有变量都逃不开这两种。
|
||||
|
||||
有了这个预备知识,接下来我们就来分别说明一下这两类变量在声明形式选择上的方法,以及一些最佳实践。
|
||||
|
||||
包级变量的声明形式
|
||||
|
||||
首先,我先下个结论:包级变量只能使用带有var关键字的变量声明形式,不能使用短变量声明形式,但在形式细节上可以有一定灵活度。具体这个灵活度怎么去考虑呢?我们可以从“变量声明时是否延迟初始化”这个角度,对包级变量的声明形式进行一次分类。
|
||||
|
||||
第一类:声明并同时显式初始化。
|
||||
|
||||
你先看看这个代码:
|
||||
|
||||
// $GOROOT/src/io/io.go
|
||||
var ErrShortWrite = errors.New("short write")
|
||||
var ErrShortBuffer = errors.New("short buffer")
|
||||
var EOF = errors.New("EOF")
|
||||
|
||||
|
||||
我们可以看到,这个代码块里声明的变量都是io包的包级变量。在Go标准库中,对于变量声明的同时进行显式初始化的这类包级变量,实践中多使用这种省略类型信息的“语法糖”格式:
|
||||
|
||||
var varName = initExpression
|
||||
|
||||
|
||||
就像我们前面说过的那样,Go编译器会自动根据等号右侧InitExpression结果值的类型,来确定左侧声明的变量的类型,这个类型会是结果值对应类型的默认类型。
|
||||
|
||||
当然,如果我们不接受默认类型,而是要显式地为包级变量指定类型,那么我们有两种方式,我这里给出了两种包级变量的声明形式的对比示例。
|
||||
|
||||
//第一种:
|
||||
plain
|
||||
var a = 13 // 使用默认类型
|
||||
var b int32 = 17 // 显式指定类型
|
||||
var f float32 = 3.14 // 显式指定类型
|
||||
|
||||
//第二种:
|
||||
var a = 13 // 使用默认类型
|
||||
var b = int32(17) // 显式指定类型
|
||||
var f = float32(3.14) // 显式指定类型
|
||||
|
||||
|
||||
虽然这两种方式都是可以使用的,但从声明一致性的角度出发,Go更推荐我们使用后者,这样能统一接受默认类型和显式指定类型这两种声明形式,尤其是在将这些变量放在一个var块中声明时,你会更明显地看到这一点。
|
||||
|
||||
所以我们更青睐下面这样的形式:
|
||||
|
||||
var (
|
||||
a = 13
|
||||
b = int32(17)
|
||||
f = float32(3.14)
|
||||
)
|
||||
|
||||
|
||||
而不是下面这种看起来不一致的声明形式:
|
||||
|
||||
var (
|
||||
a = 13
|
||||
b int32 = 17
|
||||
f float32 = 3.14
|
||||
)
|
||||
|
||||
|
||||
第二类:声明但延迟初始化。
|
||||
|
||||
对于声明时并不立即显式初始化的包级变量,我们可以使用下面这种通用变量声明形式:
|
||||
|
||||
var a int32
|
||||
var f float64
|
||||
|
||||
|
||||
我们知道,虽然没有显式初始化,Go语言也会让这些变量拥有初始的“零值”。如果是自定义的类型,我也建议你尽量保证它的零值是可用的。
|
||||
|
||||
这里还有一个注意事项,就是声明聚类与就近原则。
|
||||
|
||||
正好,Go语言提供了变量声明块用来把多个的变量声明放在一起,并且在语法上也不会限制放置在var块中的声明类型,那我们就应该学会充分利用var变量声明块,让我们变量声明更规整,更具可读性,现在我们就来试试看。
|
||||
|
||||
通常,我们会将同一类的变量声明放在一个var变量声明块中,不同类的声明放在不同的var声明块中,比如下面就是我从标准库net包中摘取的两段变量声明代码:
|
||||
|
||||
// $GOROOT/src/net/net.go
|
||||
|
||||
var (
|
||||
netGo bool
|
||||
netCgo bool
|
||||
)
|
||||
|
||||
var (
|
||||
aLongTimeAgo = time.Unix(1, 0)
|
||||
noDeadline = time.Time{}
|
||||
noCancel = (chan struct{})(nil)
|
||||
)
|
||||
|
||||
|
||||
我们可以看到,上面这两个var声明块各自声明了一类特定用途的包级变量。那我就要问了,你还能从中看出什么包级变量声明的原则吗?
|
||||
|
||||
其实,我们可以将延迟初始化的变量声明放在一个var声明块(比如上面的第一个var声明块),然后将声明且显式初始化的变量放在另一个var块中(比如上面的第二个var声明块),这里我称这种方式为“声明聚类”,声明聚类可以提升代码可读性。
|
||||
|
||||
到这里,你可能还会有一个问题:我们是否应该将包级变量的声明全部集中放在源文件头部呢?答案不能一概而论。
|
||||
|
||||
使用静态编程语言的开发人员都知道,变量声明最佳实践中还有一条:就近原则。也就是说我们尽可能在靠近第一次使用变量的位置声明这个变量。就近原则实际上也是对变量的作用域最小化的一种实现手段。在Go标准库中我们也很容易找到符合就近原则的变量声明的例子,比如下面这段标准库http包中的代码就是这样:
|
||||
|
||||
// $GOROOT/src/net/http/request.go
|
||||
|
||||
var ErrNoCookie = errors.New("http: named cookie not present")
|
||||
func (r *Request) Cookie(name string) (*Cookie, error) {
|
||||
for _, c := range readCookies(r.Header, name) {
|
||||
return c, nil
|
||||
}
|
||||
return nil, ErrNoCookie
|
||||
}
|
||||
|
||||
|
||||
在这个代码块里,ErrNoCookie这个变量在整个包中仅仅被用在了Cookie方法中,因此它被声明在紧邻Cookie方法定义的地方。当然了,如果一个包级变量在包内部被多处使用,那么这个变量还是放在源文件头部声明比较适合的。
|
||||
|
||||
接下来,我们再来看看另外一种变量:局部变量的声明形式。
|
||||
|
||||
局部变量的声明形式
|
||||
|
||||
有了包级变量做铺垫,我们再来讲解局部变量就容易很多了。和包级变量相比,局部变量又多了一种短变量声明形式,这是局部变量特有的一种变量声明形式,也是局部变量采用最多的一种声明形式。
|
||||
|
||||
这里我们也从“变量声明的时候是否延迟初始化”这个角度,对本地变量的声明形式进行分类说明。
|
||||
|
||||
第一类:对于延迟初始化的局部变量声明,我们采用通用的变量声明形式
|
||||
|
||||
其实,我们之前讲过的省略类型信息的声明和短变量声明这两种“语法糖”变量声明形式都不支持变量的延迟初始化,因此对于这类局部变量,和包级变量一样,我们只能采用通用的变量声明形式:
|
||||
|
||||
var err error
|
||||
|
||||
|
||||
第二类:对于声明且显式初始化的局部变量,建议使用短变量声明形式
|
||||
|
||||
短变量声明形式是局部变量最常用的声明形式,它遍布在Go标准库代码中。对于接受默认类型的变量,我们使用下面这种形式:
|
||||
|
||||
a := 17
|
||||
f := 3.14
|
||||
s := "hello, gopher!"
|
||||
|
||||
|
||||
对于不接受默认类型的变量,我们依然可以使用短变量声明形式,只是在”:=“右侧要做一个显式转型,以保持声明的一致性:
|
||||
|
||||
a := int32(17)
|
||||
f := float32(3.14)
|
||||
s := []byte("hello, gopher!")
|
||||
|
||||
|
||||
这里我们还要注意:尽量在分支控制时使用短变量声明形式。
|
||||
|
||||
分支控制应该是Go中短变量声明形式应用得最广泛的场景了。在编写Go代码时,我们很少单独声明用于分支控制语句中的变量,而是将它与if、for等控制语句通过短变量声明形式融合在一起,即在控制语句中直接声明用于控制语句代码块中的变量。
|
||||
|
||||
你看一下下面这个我摘自Go标准库中的代码,strings包的LastIndexAny方法为我们很好地诠释了如何将短变量声明形式与分支控制语句融合在一起使用:
|
||||
|
||||
// $GOROOT/src/strings/strings.go
|
||||
func LastIndexAny(s, chars string) int {
|
||||
if chars == "" {
|
||||
// Avoid scanning all of s.
|
||||
return -1
|
||||
}
|
||||
if len(s) > 8 {
|
||||
// 作者注:在if条件控制语句中使用短变量声明形式声明了if代码块中要使用的变量as和isASCII
|
||||
if as, isASCII := makeASCIISet(chars); isASCII {
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if as.contains(s[i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
for i := len(s); i > 0; {
|
||||
// 作者注:在for循环控制语句中使用短变量声明形式声明了for代码块中要使用的变量c
|
||||
r, size := utf8.DecodeLastRuneInString(s[:i])
|
||||
i -= size
|
||||
for _, c := range chars {
|
||||
if r == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
|
||||
而且,短变量声明的这种融合的使用方式也体现出“就近”原则,让变量的作用域最小化。
|
||||
|
||||
另外,虽然良好的函数/方法设计都讲究“单一职责”,所以每个函数/方法规模都不大,很少需要应用var块来聚类声明局部变量,但是如果你在声明局部变量时遇到了适合聚类的应用场景,你也应该毫不犹豫地使用var声明块来声明多于一个的局部变量,具体写法你可以参考Go标准库net包中resolveAddrList方法:
|
||||
|
||||
// $GOROOT/src/net/dial.go
|
||||
func (r *Resolver) resolveAddrList(ctx context.Context, op, network,
|
||||
addr string, hint Addr) (addrList, error) {
|
||||
... ...
|
||||
var (
|
||||
tcp *TCPAddr
|
||||
udp *UDPAddr
|
||||
ip *IPAddr
|
||||
wildcard bool
|
||||
)
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们学习了多种Go变量声明的方法,还学习了不同类型Go变量可以采用的变量声明形式和惯用法,以及一些变量声明的最佳实践原则。
|
||||
|
||||
具体来说,Go语言提供了一种通用变量声明形式以及两种变量声明“语法糖”形式,而且Go包级变量和局部变量会根据具体情况选择不同的变量声明形式,这里我们用一幅图来做个形象化的小结:-
|
||||
-
|
||||
你可以看到,良好的变量声明实践需要我们考虑多方面因素,包括明确要声明的变量是包级变量还是局部变量、是否要延迟初始化、是否接受默认类型、是否是分支控制变量并结合聚类和就近原则等。
|
||||
|
||||
说起来,Go语言崇尚“做一件事只用一种方法”,但变量声明却似乎是一个例外。如果让Go语言的设计者重新来设计一次变量声明语法,我觉得他们很大可能不会给予开发们这么大的变量声明灵活性。作为开发者,我们要注意的是,在统一项目范围内,我们选择的变量声明的形式应该是一致的。
|
||||
|
||||
思考题
|
||||
|
||||
今天,我们的思考题是:与主流静态语言不同,在Go语言变量声明中,类型是放在变量名的后面的,你认为这样做有什么好处?欢迎在留言区给我留言。
|
||||
|
||||
感谢你和我一起学习,也欢迎你把这节课分享给更多对Go语言的类型声明感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
294
专栏/TonyBai·Go语言第一课/11代码块与作用域:如何保证变量不会被遮蔽?.md
Normal file
294
专栏/TonyBai·Go语言第一课/11代码块与作用域:如何保证变量不会被遮蔽?.md
Normal file
@@ -0,0 +1,294 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 代码块与作用域:如何保证变量不会被遮蔽?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在上一节课,我们学习了变量的几种声明形式,还掌握了不同类型的变量应该采用哪种声明形式。在这一节课里,我们还是继续聊聊有关变量的事情。聊什么呢?别急,我们从一个Go变量遮蔽(Variable Shadowing)的问题说起。
|
||||
|
||||
什么是变量遮蔽呢?我们来看下面这段示例代码:
|
||||
|
||||
var a = 11
|
||||
|
||||
func foo(n int) {
|
||||
a := 1
|
||||
a += n
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("a =", a) // 11
|
||||
foo(5)
|
||||
fmt.Println("after calling foo, a =", a) // 11
|
||||
}
|
||||
|
||||
|
||||
你可以看到,在这段代码中,函数foo调用前后,包级变量a的值都没有发生变化。这是因为,虽然foo函数中也使用了变量a,但是foo函数中的变量a遮蔽了外面的包级变量a,这使得包级变量a没有参与到foo函数的逻辑中,所以就没有发生变化了。
|
||||
|
||||
变量遮蔽是Go开发人员在日常开发工作中最容易犯的编码错误之一,它低级又不容易查找,常常会让你陷入漫长的调试过程。上面的实例较为简单,你可以通过肉眼很快找到问题所在,但一旦遇到更为复杂的变量遮蔽的问题,你就可能会被折腾很久,甚至只能通过工具才能帮助捕捉问题所在。
|
||||
|
||||
变量遮蔽只是个引子,我真正想跟你说的是代码块(Block,也可译作词法块)和作用域(Scope)这两个概念,因为要想彻底保证不出现变量遮蔽问题,我们需要深入了解这两个概念以及其背后的规则。
|
||||
|
||||
现在了,我们就来先学习一下代码块与作用域的概念。
|
||||
|
||||
代码块与作用域
|
||||
|
||||
我们先来解析一下Go里面的代码块。
|
||||
|
||||
Go语言中的代码块是包裹在一对大括号内部的声明和语句序列,如果一对大括号内部没有任何声明或其他语句,我们就把它叫做空代码块。Go代码块支持嵌套,我们可以在一个代码块中嵌入多个层次的代码块,如下面示例代码所示:
|
||||
|
||||
func foo() { //代码块1
|
||||
{ // 代码块2
|
||||
{ // 代码块3
|
||||
{ // 代码块4
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这个示例中,函数foo的函数体是最外层的代码块,这里我们将它编号为“代码块1”。而且,在它的函数体内部,又嵌套了三层代码块,由外向内看分别为代码块2、代码块3以及代码块4。
|
||||
|
||||
像代码块1到代码块4这样的代码块,它们都是由两个肉眼可见的且配对的大括号包裹起来的,我们称这样的代码块为显式代码块(Explicit Blocks)。既然提到了显式代码块,我们肯定也不能忽略另外一类代码块的存在,也就是隐式代码块(Implicit Block)。顾名思义,隐式代码块没有显式代码块那样的肉眼可见的配对大括号包裹,我们无法通过大括号来识别隐式代码块。
|
||||
|
||||
虽然隐式代码块身着“隐身衣”,但我们也不是没有方法来识别它,因为Go语言规范对现存的几类隐式代码块做了明确的定义,你可以先花一两分钟看看下面这张图。
|
||||
|
||||
|
||||
|
||||
我们按代码块范围从大到小,逐一说明一下。
|
||||
|
||||
首先是位于最外层的宇宙代码块(Universe Block),它囊括的范围最大,所有Go源码都在这个隐式代码块中,你也可以将该隐式代码块想象为在所有Go代码的最外层加一对大括号,就像图中最外层的那对大括号那样。
|
||||
|
||||
在宇宙代码块内部嵌套了包代码块(Package Block),每个Go包都对应一个隐式包代码块,每个包代码块包含了该包中的所有Go源码,不管这些代码分布在这个包里的多少个的源文件中。
|
||||
|
||||
我们再往里面看,在包代码块的内部嵌套着若干文件代码块(File Block),每个Go源文件都对应着一个文件代码块,也就是说一个Go包如果有多个源文件,那么就会有多个对应的文件代码块。
|
||||
|
||||
再下一个级别的隐式代码块就在控制语句层面了,包括if、for与switch。我们可以把每个控制语句都视为在它自己的隐式代码块里。不过你要注意,这里的控制语句隐式代码块与控制语句使用大括号包裹的显式代码块并不是一个代码块。你再看一下前面的图,switch控制语句的隐式代码块的位置是在它显式代码块的外面的。
|
||||
|
||||
最后,位于最内层的隐式代码块是switch或select语句的每个case/default子句中,虽然没有大括号包裹,但实质上,每个子句都自成一个代码块。
|
||||
|
||||
有了这些代码块的概念后,你能更好理解作用域的概念了。作用域的概念是针对标识符的,不局限于变量。每个标识符都有自己的作用域,而一个标识符的作用域就是指这个标识符在被声明后可以被有效使用的源码区域。
|
||||
|
||||
显然,作用域是一个编译期的概念,也就是说,编译器在编译过程中会对每个标识符的作用域进行检查,对于在标识符作用域外使用该标识符的行为会给出编译错误的报错。
|
||||
|
||||
不过,我们可以使用代码块的概念来划定每个标识符的作用域。这个划定原则是什么呢?原则就是声明于外层代码块中的标识符,其作用域包括所有内层代码块。而且,这一原则同时适于显式代码块与隐式代码块。现在,对照上面的示意图,我们再举一些典型的例子,让你对作用域这个抽象的概念有更进一步的了解。
|
||||
|
||||
首先,我们来看看位于最外层的宇宙隐式代码块的标识符。
|
||||
|
||||
我们先来看第一个问题:我们要怎么声明这一区域的标识符呢?
|
||||
|
||||
这个问题的答案是,我们并不能声明这一块的标识符,因为这一区域是Go语言预定义标识符的自留地。这里我整理了Go语言当前版本定义里的所有预定义标识符,你可以看看下面这张表:
|
||||
|
||||
|
||||
|
||||
由于这些预定义标识符位于包代码块的外层,所以它们的作用域是范围最大的,对于开发者而言,它们的作用域就是源代码中的任何位置。不过,这些预定义标识符不是关键字,我们同样可以在内层代码块中声明同名的标识符。
|
||||
|
||||
那现在第二个问题就来了:既然宇宙代码块里存在预定义标识符,而且宇宙代码块的下一层是包代码块,那还有哪些标识符具有包代码块级作用域呢?
|
||||
|
||||
答案是,包顶层声明中的常量、类型、变量或函数(不包括方法)对应的标识符的作用域是包代码块。
|
||||
|
||||
不过,对于作用域为包代码块的标识符,我需要你知道一个特殊情况。那就是当一个包A导入另外一个包B后,包A仅可以使用被导入包包B中的导出标识符(Exported Identifier)。
|
||||
|
||||
这是为什么呢?而且,什么是导出标识符呢?
|
||||
|
||||
按照Go语言定义,一个标识符要成为导出标识符需同时具备两个条件:一是这个标识符声明在包代码块中,或者它是一个字段名或方法名;二是它名字第一个字符是一个大写的Unicode字符。这两个条件缺一不可。
|
||||
|
||||
从我们前面的讲解中,你一定发现了大部分在包顶层声明的标识符都具有包代码块范围的作用域,那还有标识符的作用域是文件代码块范围的吗?
|
||||
|
||||
确实不多了。但还有一个,我一说你肯定会有一种恍然大悟的感觉,它就是导入的包名。也就是说,如果一个包A有两个源文件要实现,而且这两个源文件中的代码都依赖包B中的标识符,那么这两个源文件都需要导入包B。
|
||||
|
||||
在源文件层面,去掉拥有包代码块作用域的标识符后,剩余的就都是一个个函数/方法的实现了。在这些函数/方法体中,标识符作用域划分原则更为简单,因为我们可以凭借肉眼可见的、配对的大括号来明确界定一个标识符的作用域范围,我们来看下面这个示例:
|
||||
|
||||
func (t T) M1(x int) (err error) {
|
||||
// 代码块1
|
||||
m := 13
|
||||
|
||||
// 代码块1是包含m、t、x和err三个标识符的最内部代码块
|
||||
{ // 代码块2
|
||||
|
||||
// "代码块2"是包含类型bar标识符的最内部的那个包含代码块
|
||||
type bar struct {} // 类型标识符bar的作用域始于此
|
||||
{ // 代码块3
|
||||
|
||||
// "代码块3"是包含变量a标识符的最内部的那个包含代码块
|
||||
a := 5 // a作用域开始于此
|
||||
{ // 代码块4
|
||||
//... ...
|
||||
}
|
||||
// a作用域终止于此
|
||||
}
|
||||
// 类型标识符bar的作用域终止于此
|
||||
}
|
||||
// m、t、x和err的作用域终止于此
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,上面示例中定义了类型T的一个方法M1,方法接收器(receiver)变量t、函数参数x,以及返回值变量err对应的标识符的作用域范围是M1函数体对应的显式代码块1。虽然t、x和err并没有被函数体的大括号所显式包裹,但它们属于函数定义的一部分,所以作用域依旧是代码块1。
|
||||
|
||||
说完了函数体外部的诸如函数参数、返回值等元素的作用域后,我们现在就来分析函数体内部的那些语法元素。
|
||||
|
||||
函数内部声明的常量或变量对应的标识符的作用域范围开始于常量或变量声明语句的末尾,并终止于其最内部的那个包含块的末尾。在上述例子中,变量m、自定义类型bar以及在代码块3中声明的变量a均符合这个划分规则。
|
||||
|
||||
接下来,我们再看看位于控制语句隐式代码块中的标识符的作用域划分。我们以下面这个if条件分支语句为例来分析一下:
|
||||
|
||||
func bar() {
|
||||
if a := 1; false {
|
||||
} else if b := 2; false {
|
||||
} else if c := 3; false {
|
||||
} else {
|
||||
println(a, b, c)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这是一个复杂的“if - else if - else”条件分支语句结构,根据我们前面讲过的隐式代码块规则,我们将上面示例中隐式代码块转换为显式代码块后,会得到下面这段等价的代码:
|
||||
|
||||
func bar() {
|
||||
{ // 等价于第一个if的隐式代码块
|
||||
a := 1 // 变量a作用域始于此
|
||||
if false {
|
||||
|
||||
} else {
|
||||
{ // 等价于第一个else if的隐式代码块
|
||||
b := 2 // 变量b的作用域始于此
|
||||
if false {
|
||||
|
||||
} else {
|
||||
{ // 等价于第二个else if的隐式代码块
|
||||
c := 3 // 变量c作用域始于此
|
||||
if false {
|
||||
|
||||
} else {
|
||||
println(a, b, c)
|
||||
}
|
||||
// 变量c的作用域终止于此
|
||||
}
|
||||
}
|
||||
// 变量b的作用域终止于此
|
||||
}
|
||||
}
|
||||
// 变量a作用域终止于此
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们看到,经过这么一个等价转换,各个声明于if表达式中的变量的作用域就变得一目了然了。声明于不同层次的隐式代码块中的变量a、b和c的实际作用域都位于最内层的else显式代码块之外,于是在println的那个显式代码块中,变量a、b、c都是合法的,而且还保持了初始值。
|
||||
|
||||
好了,到这里我们已经了解代码块与作用域的概念与规则了,那么我们要怎么利用这些知识避免在实际编码中的变量遮蔽问题呢?避免变量遮蔽的原则又是什么呢?
|
||||
|
||||
避免变量遮蔽的原则
|
||||
|
||||
变量是标识符的一种,所以我们前面说的标识符的作用域规则同样适用于变量。在前面的讲述中,我们已经知道了,一个变量的作用域起始于其声明所在的代码块,并且可以一直扩展到嵌入到该代码块中的所有内层代码块,而正是这样的作用域规则,成为了滋生“变量遮蔽问题”的土壤。
|
||||
|
||||
变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,这样,内层代码块中的同名变量就会替代那个外层变量,参与此层代码块内的相关计算,我们也就说内层变量遮蔽了外层同名变量。现在,我们先来看一下这个示例代码,它就存在着多种变量遮蔽的问题:
|
||||
|
||||
... ...
|
||||
var a int = 2020
|
||||
|
||||
func checkYear() error {
|
||||
err := errors.New("wrong year")
|
||||
|
||||
switch a, err := getYear(); a {
|
||||
case 2020:
|
||||
fmt.Println("it is", a, err)
|
||||
case 2021:
|
||||
fmt.Println("it is", a)
|
||||
err = nil
|
||||
}
|
||||
fmt.Println("after check, it is", a)
|
||||
return err
|
||||
}
|
||||
|
||||
type new int
|
||||
|
||||
func getYear() (new, error) {
|
||||
var b int16 = 2021
|
||||
return new(b), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
err := checkYear()
|
||||
if err != nil {
|
||||
fmt.Println("call checkYear error:", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("call checkYear ok")
|
||||
}
|
||||
|
||||
|
||||
这个变量遮蔽的例子还是有点复杂的,为了讲解方便,我给代码加上了行编号。我们首先运行一下这个例子:
|
||||
|
||||
$go run complex.go
|
||||
it is 2021
|
||||
after check, it is 2020
|
||||
call checkYear error: wrong year
|
||||
|
||||
|
||||
我们可以看到,第20行定义的getYear函数返回了正确的年份(2021),但是checkYear在结尾却输出“after check, it is 2020”,并且返回的err并非为nil,这显然是变量遮蔽的“锅”!
|
||||
|
||||
根据我们前面给出的变量遮蔽的根本原因,我们来“找找茬”,看看上面这段代码究竟有几处变量遮蔽问题(包括标识符遮蔽问题)。
|
||||
|
||||
第一个问题:遮蔽预定义标识符。
|
||||
|
||||
面对上面代码,我们一眼就看到了位于第18行的new,这本是Go语言的一个预定义标识符,但上面示例代码呢,却用new这个名字定义了一个新类型,于是new这个标识符就被遮蔽了。如果这个时候你在main函数下方放上下面代码:
|
||||
|
||||
p := new(int)
|
||||
*p = 11
|
||||
|
||||
|
||||
你就会收到Go编译器的错误提示:“type int is not an expression”,如果没有意识到new被遮蔽掉,这个提示就会让你不知所措。不过,在上面示例代码中,遮蔽new并不是示例未按预期输出结果的真实原因,我们还得继续往下看。
|
||||
|
||||
这时我们发现了第二个问题:遮蔽包代码块中的变量。
|
||||
|
||||
你看,位于第7行的switch语句在它自身的隐式代码块中,通过短变量声明形式重新声明了一个变量a,这个变量a就遮蔽了外层包代码块中的包级变量a,这就是打印“after check, it is 2020”的原因。包级变量a没有如预期那样被getYear的返回值赋值为正确的年份2021,2021被赋值给了遮蔽它的switch语句隐式代码块中的那个新声明的a。
|
||||
|
||||
不过,同一行里,其实还有第三个问题:遮蔽外层显式代码块中的变量。
|
||||
|
||||
同样还是第7行的switch语句,除了声明一个新的变量a之外,它还声明了一个名为err的变量,这个变量就遮蔽了第4行checkYear函数在显式代码块中声明的err变量,这导致第12行的nil赋值动作作用到了switch隐式代码块中的err变量上,而不是外层checkYear声明的本地变量err变量上,后者并非nil,这样checkYear虽然从getYear得到了正确的年份值,但却返回了一个错误给main函数,这直接导致了main函数打印了错误:“call checkYear error: wrong year”。
|
||||
|
||||
通过这个示例,我们也可以看到,短变量声明与控制语句的结合十分容易导致变量遮蔽问题,并且很不容易识别,因此在日常go代码开发中你要尤其注意两者结合使用的地方。
|
||||
|
||||
不过,依靠肉眼识别变量遮蔽问题终归不是长久之计,有没有工具可以帮助我们识别这类问题呢?其实是有的,下面我们就来介绍一下可以检测变量遮蔽问题的工具。
|
||||
|
||||
利用工具检测变量遮蔽问题
|
||||
|
||||
Go官方提供了go vet工具可以用于对Go源码做一系列静态检查,在Go 1.14版以前默认支持变量遮蔽检查,Go 1.14版之后,变量遮蔽检查的插件就需要我们单独安装了,安装方法如下:
|
||||
|
||||
$go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
|
||||
go: downloading golang.org/x/tools v0.1.5
|
||||
go: downloading golang.org/x/mod v0.4.2
|
||||
|
||||
|
||||
一旦安装成功,我们就可以通过go vet扫描代码并检查这里面有没有变量遮蔽的问题了。我们现在就来检查一下前面的示例代码,看看效果怎么样。执行检查的命令如下:
|
||||
|
||||
$go vet -vettool=$(which shadow) -strict complex.go
|
||||
./complex.go:13:12: declaration of "err" shadows declaration at line 11
|
||||
|
||||
|
||||
我们看到,go vet只给出了err变量被遮蔽的提示,变量a以及预定义标识符new被遮蔽的情况并没有给出提示。可以看到,工具确实可以辅助检测,但也不是万能的,不能穷尽找出代码中的所有问题,所以你还是要深入理解代码块与作用域的概念,尽可能在日常编码时就主动规避掉所有遮蔽问题。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们学习了另外两个变量相关的概念:代码块与作用域。
|
||||
|
||||
代码块有显式与隐式之分,显式代码块就是包裹在一对配对大括号内部的语句序列,而隐式代码块则不容易肉眼分辨,它是通过Go语言规范明确规定的。隐式代码块有五种,分别是宇宙代码块、包代码块、文件代码块、分支控制语句隐式代码块,以及switch/select的子句隐式代码块,理解隐式代码块是理解代码块概念以及后续作用域概念的前提与基础。
|
||||
|
||||
作用域的概念是Go源码编译过程中标识符(包括变量)的一个属性。Go编译器会校验每个标识符的作用域,如果它的使用范围超出其作用域,编译器会报错。
|
||||
|
||||
不过呢,我们可以使用代码块的概念来划定每个标识符的作用域。划定原则就是声明于外层代码块中的标识符,其作用域包括所有内层代码块。但是,Go的这种作用域划定也带来了变量遮蔽问题。简单的遮蔽问题,我们通过分析代码可以很快找出,复杂的遮蔽问题,即便是通过go vet这样的静态代码分析工具也难于找全。
|
||||
|
||||
因此,我们只有了解变量遮蔽问题本质,在日常编写代码时注意同名变量的声明,注意短变量声明与控制语句的结合,才能从根源上尽量避免变量遮蔽问题的发生。
|
||||
|
||||
思考题
|
||||
|
||||
今天的思考题,你知道怎么来修正我们这节课最后那个复杂的变量遮蔽的例子吗?期待在留言区见到你的答案。
|
||||
|
||||
感谢你和我一起学习,也欢迎你把这节课分享给更多对Go语言感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
338
专栏/TonyBai·Go语言第一课/12基本数据类型:Go原生支持的数值类型有哪些?.md
Normal file
338
专栏/TonyBai·Go语言第一课/12基本数据类型:Go原生支持的数值类型有哪些?.md
Normal file
@@ -0,0 +1,338 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 基本数据类型:Go原生支持的数值类型有哪些?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在上一课中,我们学习了Go变量的声明形式,知道了变量所绑定的内存区域应该有明确的边界,而这个边界信息呢,是由变量的类型赋予的。那么,顺着这个脉络,从这一节课开始,我们就来深入讲解Go语言类型。
|
||||
|
||||
你可能会有点不解,类型是每个语言都有的东西,我们有必要花那么多时长、讲那么详细吗?
|
||||
|
||||
有必要。对像Go这样的静态编程语言来说,类型是十分重要的。因为它不仅是静态语言编译器的要求,更是我们对现实事物进行抽象的基础。对这一方面的学习,可以让你逐渐建立起代码设计的意识。
|
||||
|
||||
Go语言的类型大体可分为基本数据类型、复合数据类型和接口类型这三种。其中,我们日常Go编码中使用最多的就是基本数据类型,而基本数据类型中使用占比最大的又是数值类型。
|
||||
|
||||
那么,我们今天就先来讲数字类型。Go语言原生支持的数值类型包括整型、浮点型以及复数类型,它们适用于不同的场景。我们依次来看一下。
|
||||
|
||||
被广泛使用的整型
|
||||
|
||||
Go语言的整型,主要用来表示现实世界中整型数量,比如:人的年龄、班级人数等。它可以分为平台无关整型和平台相关整型这两种,它们的区别主要就在,这些整数类型在不同CPU架构或操作系统下面,它们的长度是否是一致的。
|
||||
|
||||
我们先来看平台无关整型,它们在任何CPU架构或任何操作系统下面,长度都是固定不变的。我在下面这张表中总结了Go提供的平台无关整型:
|
||||
|
||||
|
||||
|
||||
你可以看到,这些平台无关的整型也可以分成两类:有符号整型(int8~int64)和无符号整型(uint8~uint64)。两者的本质差别在于最高二进制位(bit位)是否被解释为符号位,这点会影响到无符号整型与有符号整型的取值范围。
|
||||
|
||||
我们以下图中的这个8比特(一个字节)的整型值为例,当它被解释为无符号整型uint8时,和它被解释为有符号整型int8时表示的值是不同的:
|
||||
|
||||
|
||||
|
||||
在同样的比特位表示下,当最高比特位被解释为符号位时,它代表一个有符号整型(int8),它表示的值为-127;当最高比特位不被解释为符号位时,它代表一个无符号整型(uint8),它表示的值为129。
|
||||
|
||||
这里你可能就会问了:即便最高比特位被解释为符号位,上面的有符号整型所表示值也应该为-1啊,怎么会是-127呢?
|
||||
|
||||
这是因为Go采用2的补码(Two’s Complement)作为整型的比特位编码方法。因此,我们不能简单地将最高比特位看成负号,把其余比特位表示的值看成负号后面的数值。Go的补码是通过原码逐位取反后再加1得到的,比如,我们以-127这个值为例,它的补码转换过程就是这样的:
|
||||
|
||||
|
||||
|
||||
与平台无关整型对应的就是平台相关整型,它们的长度会根据运行平台的改变而改变。Go语言原生提供了三个平台相关整型,它们是int、uint与uintptr,我同样也列了一张表:
|
||||
|
||||
|
||||
|
||||
在这里我们要特别注意一点,由于这三个类型的长度是平台相关的,所以我们在编写有移植性要求的代码时,千万不要强依赖这些类型的长度。如果你不知道这三个类型在目标运行平台上的长度,可以通过unsafe包提供的SizeOf函数来获取,比如在x86-64平台上,它们的长度均为8:
|
||||
|
||||
var a, b = int(5), uint(6)
|
||||
var p uintptr = 0x12345678
|
||||
fmt.Println("signed integer a's length is", unsafe.Sizeof(a)) // 8
|
||||
fmt.Println("unsigned integer b's length is", unsafe.Sizeof(b)) // 8
|
||||
fmt.Println("uintptr's length is", unsafe.Sizeof(p)) // 8
|
||||
|
||||
|
||||
现在我们已经搞清楚Go语言中整型的分类和长度了,但是在使用整型的过程中,我们还会遇到一个常见问题:整型溢出。
|
||||
|
||||
整型的溢出问题
|
||||
|
||||
无论哪种整型,都有它的取值范围,也就是有它可以表示的值边界。如果这个整型因为参与某个运算,导致结果超出了这个整型的值边界,我们就说发生了整型溢出的问题。由于整型无法表示它溢出后的那个“结果”,所以出现溢出情况后,对应的整型变量的值依然会落到它的取值范围内,只是结果值与我们的预期不符,导致程序逻辑出错。比如这就是一个无符号整型与一个有符号整型的溢出情况:
|
||||
|
||||
var s int8 = 127
|
||||
s += 1 // 预期128,实际结果-128
|
||||
|
||||
var u uint8 = 1
|
||||
u -= 2 // 预期-1,实际结果255
|
||||
|
||||
|
||||
你看,有符号整型变量s初始值为127,在加1操作后,我们预期得到128,但由于128超出了int8的取值边界,其实际结果变成了-128。无符号整型变量u也是一样的道理,它的初值为1,在进行减2操作后,我们预期得到-1,但由于-1超出了uint8的取值边界,它的实际结果变成了255。
|
||||
|
||||
这个问题最容易发生在循环语句的结束条件判断中,因为这也是经常使用整型变量的地方。无论无符号整型,还是有符号整型都存在溢出的问题,所以我们要十分小心地选择参与循环语句结束判断的整型变量类型,以及与之比较的边界值。
|
||||
|
||||
在了解了整型的这些基本信息后,我们再来看看整型支持的不同进制形式的字面值,以及如何输出不同进制形式的数值。
|
||||
|
||||
字面值与格式化输出
|
||||
|
||||
Go语言在设计开始,就继承了C语言关于数值字面值(Number Literal)的语法形式。早期Go版本支持十进制、八进制、十六进制的数值字面值形式,比如:
|
||||
|
||||
a := 53 // 十进制
|
||||
b := 0700 // 八进制,以"0"为前缀
|
||||
c1 := 0xaabbcc // 十六进制,以"0x"为前缀
|
||||
c2 := 0Xddeeff // 十六进制,以"0X"为前缀
|
||||
|
||||
|
||||
Go 1.13版本中,Go又增加了对二进制字面值的支持和两种八进制字面值的形式,比如:
|
||||
|
||||
d1 := 0b10000001 // 二进制,以"0b"为前缀
|
||||
d2 := 0B10000001 // 二进制,以"0B"为前缀
|
||||
e1 := 0o700 // 八进制,以"0o"为前缀
|
||||
e2 := 0O700 // 八进制,以"0O"为前缀
|
||||
|
||||
|
||||
为提升字面值的可读性,Go 1.13版本还支持在字面值中增加数字分隔符“_”,分隔符可以用来将数字分组以提高可读性。比如每3个数字一组,也可以用来分隔前缀与字面值中的第一个数字:
|
||||
|
||||
a := 5_3_7 // 十进制: 537
|
||||
b := 0b_1000_0111 // 二进制位表示为10000111
|
||||
c1 := 0_700 // 八进制: 0700
|
||||
c2 := 0o_700 // 八进制: 0700
|
||||
d1 := 0x_5c_6d // 十六进制:0x5c6d
|
||||
|
||||
|
||||
不过,这里你要注意一下,Go 1.13中增加的二进制字面值以及数字分隔符,只在go.mod中的go version指示字段为Go 1.13以及以后版本的时候,才会生效,否则编译器会报错。
|
||||
|
||||
反过来,我们也可以通过标准库fmt包的格式化输出函数,将一个整型变量输出为不同进制的形式。比如下面就是将十进制整型值59,格式化输出为二进制、八进制和十六进制的代码:
|
||||
|
||||
var a int8 = 59
|
||||
fmt.Printf("%b\n", a) //输出二进制:111011
|
||||
fmt.Printf("%d\n", a) //输出十进制:59
|
||||
fmt.Printf("%o\n", a) //输出八进制:73
|
||||
fmt.Printf("%O\n", a) //输出八进制(带0o前缀):0o73
|
||||
fmt.Printf("%x\n", a) //输出十六进制(小写):3b
|
||||
fmt.Printf("%X\n", a) //输出十六进制(大写):3B
|
||||
|
||||
|
||||
到这里,我们对整型的学习就先告一段落了。我们接下来看另外一个数值类型:浮点型。
|
||||
|
||||
浮点型
|
||||
|
||||
和使用广泛的整型相比,浮点型的使用场景就相对聚焦了,主要集中在科学数值计算、图形图像处理和仿真、多媒体游戏以及人工智能等领域。我们这一部分对于浮点型的学习,主要是讲解Go语言中浮点类型在内存中的表示方法,这可以帮你建立应用浮点类型的理论基础。
|
||||
|
||||
浮点型的二进制表示
|
||||
|
||||
要想知道Go语言中的浮点类型的二进制表示是怎样的,我们首先要来了解IEEE 754标准。
|
||||
|
||||
IEEE 754是IEEE制定的二进制浮点数算术标准,它是20世纪80年代以来最广泛使用的浮点数运算标准,被许多CPU与浮点运算器采用。现存的大部分主流编程语言,包括Go语言,都提供了符合IEEE 754标准的浮点数格式与算术运算。
|
||||
|
||||
IEEE 754标准规定了四种表示浮点数值的方式:单精度(32位)、双精度(64位)、扩展单精度(43比特以上)与扩展双精度(79比特以上,通常以80位实现)。后两种其实很少使用,我们重点关注前面两个就好了。
|
||||
|
||||
Go语言提供了float32与float64两种浮点类型,它们分别对应的就是IEEE 754中的单精度与双精度浮点数值类型。不过,这里要注意,Go语言中没有提供float类型。这不像整型那样,Go既提供了int16、int32等类型,又有int类型。换句话说,Go提供的浮点类型都是平台无关的。
|
||||
|
||||
那float32与float64这两种浮点类型有什么异同点呢?
|
||||
|
||||
无论是float32还是float64,它们的变量的默认值都为0.0,不同的是它们占用的内存空间大小是不一样的,可以表示的浮点数的范围与精度也不同。那么浮点数在内存中的二进制表示究竟是怎么样的呢?
|
||||
|
||||
浮点数在内存中的二进制表示(Bit Representation)要比整型复杂得多,IEEE 754规范给出了在内存中存储和表示一个浮点数的标准形式,见下图:
|
||||
|
||||
|
||||
|
||||
我们看到浮点数在内存中的二进制表示分三个部分:符号位、阶码(即经过换算的指数),以及尾数。这样表示的一个浮点数,它的值等于:
|
||||
|
||||
|
||||
|
||||
其中浮点值的符号由符号位决定:当符号位为1时,浮点值为负值;当符号位为0时,浮点值为正值。公式中offset被称为阶码偏移值,这个我们待会再讲。
|
||||
|
||||
我们首先来看单精度(float32)与双精度(float64)浮点数在阶码和尾数上的不同。这两种浮点数的阶码与尾数所使用的位数是不一样的,你可以看下IEEE 754标准中单精度和双精度浮点数的各个部分的长度规定:
|
||||
|
||||
|
||||
|
||||
我们看到,单精度浮点类型(float32)为符号位分配了1个bit,为阶码分配了8个bit,剩下的23个bit分给了尾数。而双精度浮点类型,除了符号位的长度与单精度一样之外,其余两个部分的长度都要远大于单精度浮点型,阶码可用的bit位数量为11,尾数则更是拥有了52个bit位。
|
||||
|
||||
接着,我们再来看前面提到的“阶码偏移值”,我想用一个例子直观地让你感受一下。在这个例子中,我们来看看如何将一个十进制形式的浮点值139.8125,转换为IEEE 754规定中的那种单精度二进制表示。
|
||||
|
||||
步骤一:我们要把这个浮点数值的整数部分和小数 部分,分别转换为二进制形式(后缀d表示十进制数,后缀b表示二进制数):
|
||||
|
||||
|
||||
整数部分:139d => 10001011b;
|
||||
小数部分:0.8125d => 0.1101b(十进制小数转换为二进制可采用“乘2取整”的竖式计算)。
|
||||
|
||||
|
||||
这样,原浮点值139.8125d进行二进制转换后,就变成10001011.1101b。
|
||||
|
||||
步骤二:移动小数点,直到整数部分仅有一个1,也就是10001011.1101b => 1.00010111101b。我们看到,为了整数部分仅保留一个1,小数点向左移了7位,这样指数就为7,尾数为00010111101b。
|
||||
|
||||
步骤三:计算阶码。
|
||||
|
||||
IEEE754规定不能将小数点移动得到的指数,直接填到阶码部分,指数到阶码还需要一个转换过程。对于float32的单精度浮点数而言,阶码 = 指数 + 偏移值。偏移值的计算公式为2^(e-1)-1,其中e为阶码部分的bit位数,这里为8,于是单精度浮点数的阶码偏移值就为2^(8-1)-1 = 127。这样在这个例子中,阶码 = 7 + 127 = 134d = 10000110b。float64的双精度浮点数的阶码计算也是这样的。
|
||||
|
||||
步骤四:将符号位、阶码和尾数填到各自位置,得到最终浮点数的二进制表示。尾数位数不足23位,可在后面补0。
|
||||
|
||||
|
||||
|
||||
这样,最终浮点数139.8125d的二进制表示就为0b_0_10000110_00010111101_000000000000。
|
||||
|
||||
最后,我们再通过Go代码输出浮点数139.8125d的二进制表示,和前面我们手工转换的做一下比对,看是否一致。
|
||||
|
||||
func main() {
|
||||
var f float32 = 139.8125
|
||||
bits := math.Float32bits(f)
|
||||
fmt.Printf("%b\n", bits)
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,我们通过标准库的math包,将float32转换为整型。在这种转换过程中,float32的内存表示是不会被改变的。然后我们再通过前面提过的整型值的格式化输出,将它以二进制形式输出出来。运行这个程序,我们得到下面的结果:
|
||||
|
||||
1000011000010111101000000000000
|
||||
|
||||
|
||||
我们看到这个值在填上省去的最高位的0后,与我们手工得到的浮点数的二进制表示一模一样。这就说明我们手工推导的思路并没有错。
|
||||
|
||||
而且,你可以从这个例子中感受到,阶码和尾数的长度决定了浮点类型可以表示的浮点数范围与精度。因为双精度浮点类型(float64)阶码与尾数使用的比特位数更多,它可以表示的精度要远超单精度浮点类型,所以在日常开发中,我们使用双精度浮点类型(float64)的情况更多,这也是Go语言中浮点常量或字面值的默认类型。
|
||||
|
||||
而float32由于表示范围与精度有限,经常会给开发者造成一些困扰。比如我们可能会因为float32精度不足,导致输出结果与常识不符。比如下面这个例子就是这样,f1与f2两个浮点类型变量被两个不同的浮点字面值初始化,但逻辑比较的结果却是两个变量的值相等。至于其中原因,我将作为思考题留给你,你可以结合前面讲解的浮点类型表示方法,对这个例子进行分析:
|
||||
|
||||
var f1 float32 = 16777216.0
|
||||
var f2 float32 = 16777217.0
|
||||
fmt.Println(f1 == f2) // true
|
||||
|
||||
|
||||
看到这里,你是不是觉得浮点类型很神奇?和易用易理解的整型相比,浮点类型无论在二进制表示层面,还是在使用层面都要复杂得多。即便是浮点字面值,有时候也不是一眼就能看出其真实的浮点值是多少的。下面我们就接着来分析一下浮点型的字面值。
|
||||
|
||||
字面值与格式化输出
|
||||
|
||||
Go浮点类型字面值大体可分为两类,一类是直白地用十进制表示的浮点值形式。这一类,我们通过字面值就可直接确定它的浮点值,比如:
|
||||
|
||||
3.1415
|
||||
.15 // 整数部分如果为0,整数部分可以省略不写
|
||||
81.80
|
||||
82. // 小数部分如果为0,小数点后的0可以省略不写
|
||||
|
||||
|
||||
另一类则是科学计数法形式。采用科学计数法表示的浮点字面值,我们需要通过一定的换算才能确定其浮点值。而且在这里,科学计数法形式又分为十进制形式表示的,和十六进制形式表示的两种。
|
||||
|
||||
我们先来看十进制科学计数法形式的浮点数字面值,这里字面值中的e/E代表的幂运算的底数为10:
|
||||
|
||||
6674.28e-2 // 6674.28 * 10^(-2) = 66.742800
|
||||
.12345E+5 // 0.12345 * 10^5 = 12345.000000
|
||||
|
||||
|
||||
接着是十六进制科学计数法形式的浮点数:
|
||||
|
||||
0x2.p10 // 2.0 * 2^10 = 2048.000000
|
||||
0x1.Fp+0 // 1.9375 * 2^0 = 1.937500
|
||||
|
||||
|
||||
这里,我们要注意,十六进制科学计数法的整数部分、小数部分用的都是十六进制形式,但指数部分依然是十进制形式,并且字面值中的p/P代表的幂运算的底数为2。
|
||||
|
||||
知道了浮点型的字面值后,和整型一样,fmt包也提供了针对浮点数的格式化输出。我们最常使用的格式化输出形式是%f。通过%f,我们可以输出浮点数最直观的原值形式。
|
||||
|
||||
var f float64 = 123.45678
|
||||
fmt.Printf("%f\n", f) // 123.456780
|
||||
|
||||
|
||||
我们也可以将浮点数输出为科学计数法形式,如下面代码:
|
||||
|
||||
fmt.Printf("%e\n", f) // 1.234568e+02
|
||||
fmt.Printf("%x\n", f) // 0x1.edd3be22e5de1p+06
|
||||
|
||||
|
||||
其中%e输出的是十进制的科学计数法形式,而%x输出的则是十六进制的科学计数法形式。
|
||||
|
||||
到这里,关于浮点类型的内容就告一段落了。有了整型和浮点型的基础,接下来我们再进行复数类型的学习就容易多了。
|
||||
|
||||
复数类型
|
||||
|
||||
数学课本上将形如z=a+bi(a、b均为实数,a称为实部,b称为虚部)的数称为复数,这里我们也可以这么理解。相比C语言直到采用C99标准,才在complex.h中引入了对复数类型的支持,Go语言则原生支持复数类型。不过,和整型、浮点型相比,复数类型在Go中的应用就更为局限和小众,主要用于专业领域的计算,比如矢量计算等。我们简单了解一下就可以了。
|
||||
|
||||
Go提供两种复数类型,它们分别是complex64和complex128,complex64的实部与虚部都是float32类型,而complex128的实部与虚部都是float64类型。如果一个复数没有显示赋予类型,那么它的默认类型为complex128。
|
||||
|
||||
关于复数字面值的表示,我们其实有三种方法。
|
||||
|
||||
第一种,我们可以通过复数字面值直接初始化一个复数类型变量:
|
||||
|
||||
var c = 5 + 6i
|
||||
var d = 0o123 + .12345E+5i // 83+12345i
|
||||
|
||||
|
||||
第二种,Go还提供了complex函数,方便我们创建一个complex128类型值:
|
||||
|
||||
var c = complex(5, 6) // 5 + 6i
|
||||
var d = complex(0o123, .12345E+5) // 83+12345i
|
||||
|
||||
|
||||
第三种,你还可以通过Go提供的预定义的函数real和imag,来获取一个复数的实部与虚部,返回值为一个浮点类型:
|
||||
|
||||
var c = complex(5, 6) // 5 + 6i
|
||||
r := real(c) // 5.000000
|
||||
i := imag(c) // 6.000000
|
||||
|
||||
|
||||
至于复数形式的格式化输出的问题,由于complex类型的实部与虚部都是浮点类型,所以我们可以直接运用浮点型的格式化输出方法,来输出复数类型,你直接参考前面的讲解就好了。
|
||||
|
||||
到这里,其实我们已经把Go原生支持的数值类型都讲完了。但是,在原生数值类型不满足我们对现实世界的抽象的情况下,你可能还需要通过Go提供的类型定义语法来创建自定义的数值类型,这里我们也适当延展一下,看看这种情况怎么做。
|
||||
|
||||
延展:创建自定义的数值类型
|
||||
|
||||
如果我们要通过Go提供的类型定义语法,来创建自定义的数值类型,我们可以通过type关键字基于原生数值类型来声明一个新类型。
|
||||
|
||||
但是自定义的数值类型,在和其他类型相互赋值时容易出现一些问题。下面我们就来建立一个名为MyInt的新的数值类型看看:
|
||||
|
||||
type MyInt int32
|
||||
|
||||
|
||||
这里,因为MyInt类型的底层类型是int32,所以它的数值性质与int32完全相同,但它们仍然是完全不同的两种类型。根据Go的类型安全规则,我们无法直接让它们相互赋值,或者是把它们放在同一个运算中直接计算,这样编译器就会报错。
|
||||
|
||||
var m int = 5
|
||||
var n int32 = 6
|
||||
var a MyInt = m // 错误:在赋值中不能将m(int类型)作为MyInt类型使用
|
||||
var a MyInt = n // 错误:在赋值中不能将n(int32类型)作为MyInt类型使用
|
||||
|
||||
|
||||
要避免这个错误,我们需要借助显式转型,让赋值操作符左右两边的操作数保持类型一致,像下面代码中这样做:
|
||||
|
||||
var m int = 5
|
||||
var n int32 = 6
|
||||
var a MyInt = MyInt(m) // ok
|
||||
var a MyInt = MyInt(n) // ok
|
||||
|
||||
|
||||
我们也可以通过Go提供的类型别名(Type Alias)语法来自定义数值类型。和上面使用标准type语法的定义不同的是,通过类型别名语法定义的新类型与原类型别无二致,可以完全相互替代。我们来看下面代码:
|
||||
|
||||
type MyInt = int32
|
||||
|
||||
var n int32 = 6
|
||||
var a MyInt = n // ok
|
||||
|
||||
|
||||
你可以看到,通过类型别名定义的MyInt与int32完全等价,所以这个时候两种类型就是同一种类型,不再需要显式转型,就可以相互赋值。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们开始学习Go的数据类型了。我们从最简单且最常用的数值类型开始学起。Go的原生数值类型有三类:整型、浮点型和复数型。
|
||||
|
||||
首先,整数类型包含的具体类型比较多,我这里用一个表格做个总结:
|
||||
|
||||
|
||||
|
||||
Go语言中的整型的二进制表示采用2的补码形式,你可以回忆一下如何计算一个负数的补码,其实很简单!记住“原码取反加1”即可。
|
||||
|
||||
另外,学习整型时你要特别注意,每个整型都有自己的取值范围和表示边界,一旦超出边界,便会出现溢出问题。溢出问题多出现在循环语句中进行结束条件判断的位置,我们在选择参与循环语句结束判断的整型变量类型以及比较边界值时要尤其小心。
|
||||
|
||||
接下来,我们还讲了Go语言实现了IEEE 754标准中的浮点类型二进制表示。在这种表示中,一个浮点数被分为符号位、阶码与尾数三个部分,我们用一个实例讲解了如何推导出一个浮点值的二进制表示。如果你理解了那个推导过程,你就基本掌握浮点类型了。虽然我们在例子中使用的是float32类型做的演示,但日常使用中我们尽量使用float64,这样不容易出现浮点溢出的问题。复数类型也是基于浮点型实现的,日常使用较少,你简单了解就可以了。
|
||||
|
||||
最后,我们还了解了如何利用类型定义语法与类型别名语法创建自定义数值类型。通过类型定义语法实现的自定义数值类型虽然在数值性质上与原类型是一致的,但它们却是完全不同的类型,不能相互赋值,比如通过显式转型才能避免编译错误。而通过类型别名创建的新类型则等价于原类型,可以互相替代。
|
||||
|
||||
思考题
|
||||
|
||||
今天的思考题,我想请你分析一下:下面例子中f1为何会与f2相等?欢迎在留言区留下你的答案。
|
||||
|
||||
var f1 float32 = 16777216.0
|
||||
var f2 float32 = 16777217.0
|
||||
f1 == f2 // true
|
||||
|
||||
|
||||
欢迎把这节课分享给更多对Go语言感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
434
专栏/TonyBai·Go语言第一课/13基本数据类型:为什么Go要原生支持字符串类型?.md
Normal file
434
专栏/TonyBai·Go语言第一课/13基本数据类型:为什么Go要原生支持字符串类型?.md
Normal file
@@ -0,0 +1,434 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 基本数据类型:为什么Go要原生支持字符串类型?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在上节课中,我们讲解了在Go编程中最广泛使用的一类基本数据类型:数值类型,包括整型、浮点类型和复数类型。这一节课,我们继续来学习Go语言中另一类基本数据类型:字符串类型。
|
||||
|
||||
字符串类型,是现代编程语言中最常用的数据类型之一,多数主流编程语言都提供了对这个类型的原生支持,少数没有提供原生字符串的类型的主流语言(比如C语言)也通过其他形式提供了对字符串的支持。
|
||||
|
||||
对于这样在日常开发中高频使用的基本数据类型,我们要给予更多的关注。所以,我们这一节课,将会按照Why-What-How的逻辑,讲清楚Go对字符串类型的支持,让你对Go语言中的字符串有个完整而清晰的认识。
|
||||
|
||||
首先,让我们来看看为什么Go要原生支持字符串类型。
|
||||
|
||||
原生支持字符串有什么好处?
|
||||
|
||||
我们前面提过,Go是站在巨人的肩膀上成长起来的现代编程语言。它继承了前辈语言的优点,又改进了前辈语言中的不足。这其中一处就体现在Go对字符串类型的原生支持上。
|
||||
|
||||
这样的设计会有什么好处呢?作为对比,我们先来看看前辈语言之一的C语言对字符串的支持情况。
|
||||
|
||||
C语言没有提供对字符串类型的原生支持,也就是说,C语言中并没有“字符串”这个数据类型。在C语言中,字符串是以字符串字面值或以’\0’结尾的字符类型数组来呈现的,比如下面代码:
|
||||
|
||||
#define GO_SLOGAN "less is more"
|
||||
const char * s1 = "hello, gopher"
|
||||
char s2[] = "I love go"
|
||||
|
||||
|
||||
这样定义的非原生字符串在使用过程中会有很多问题,比如:
|
||||
|
||||
|
||||
不是原生类型,编译器不会对它进行类型校验,导致类型安全性差;
|
||||
字符串操作时要时刻考虑结尾的’\0’,防止缓冲区溢出;
|
||||
以字符数组形式定义的“字符串”,它的值是可变的,在并发场景中需要考虑同步问题;
|
||||
获取一个字符串的长度代价较大,通常是O(n)时间复杂度;
|
||||
C语言没有内置对非ASCII字符(如中文字符)的支持。
|
||||
|
||||
|
||||
这些问题都大大加重了开发人员在使用字符串时的心智负担。于是,Go设计者们选择了原生支持字符串类型。
|
||||
|
||||
在Go中,字符串类型为string。Go语言通过string类型统一了对“字符串”的抽象。这样无论是字符串常量、字符串变量或是代码中出现的字符串字面值,它们的类型都被统一设置为string,比如上面C代码换成等价的Go代码是这样的:
|
||||
|
||||
const (
|
||||
GO_SLOGAN = "less is more" // GO_SLOGAN是string类型常量
|
||||
s1 = "hello, gopher" // s1是string类型常量
|
||||
)
|
||||
|
||||
var s2 = "I love go" // s2是string类型变量
|
||||
|
||||
|
||||
那既然我们都说了,Go原生支持string的做法是对前辈语言的改进,这样的设计到底有哪些优秀的性质,会带来什么好处呢?
|
||||
|
||||
第一点:string类型的数据是不可变的,提高了字符串的并发安全性和存储利用率。
|
||||
|
||||
Go语言规定,字符串类型的值在它的生命周期内是不可改变的。这就是说,如果我们声明了一个字符串类型的变量,那我们是无法通过这个变量改变它对应的字符串值的,但这并不是说我们不能为一个字符串类型变量进行二次赋值。
|
||||
|
||||
什么意思呢?我们看看下面的代码就好理解了:
|
||||
|
||||
var s string = "hello"
|
||||
s[0] = 'k' // 错误:字符串的内容是不可改变的
|
||||
s = "gopher" // ok
|
||||
|
||||
|
||||
在这段代码中,我们声明了一个字符串类型变量s。当我们试图通过下标方式把这个字符串的第一个字符由h改为k的时候,我们会收到编译器错误的提示:字符串是不可变的。但我们仍可以像最后一行代码那样,为变量s重新赋值为另外一个字符串。
|
||||
|
||||
Go这样的“字符串类型数据不可变”的性质给开发人员带来的最大好处,就是我们不用再担心字符串的并发安全问题。这样,Go字符串可以被多个Goroutine(Go语言的轻量级用户线程,后面我们会详细讲解)共享,开发者不用因为担心并发安全问题,使用会带来一定开销的同步机制。
|
||||
|
||||
另外,也由于字符串的不可变性,针对同一个字符串值,无论它在程序的几个位置被使用,Go编译器只需要为它分配一块存储就好了,大大提高了存储利用率。
|
||||
|
||||
第二点:没有结尾’\0’,而且获取长度的时间复杂度是常数时间,消除了获取字符串长度的开销。
|
||||
|
||||
在C语言中,获取一个字符串的长度可以调用标准库的strlen函数,这个函数的实现原理是遍历字符串中的每个字符并做计数,直到遇到字符串的结尾’\0’停止。显然这是一个线性时间复杂度的算法,执行时间与字符串中字符个数成正比。并且,它存在一个约束,那就是传入的字符串必须有结尾’\0’,结尾’\0’是字符串的结束标志。如果你使用过C语言,想必你也吃过字符串结尾’\0’的亏。
|
||||
|
||||
Go语言修正了这个缺陷,Go字符串中没有结尾’\0’,获取字符串长度更不需要结尾’\0’作为结束标志。并且,Go获取字符串长度是一个常数级时间复杂度,无论字符串中字符个数有多少,我们都可以快速得到字符串的长度值。至于这方面的原理,我们等会再详细说明。
|
||||
|
||||
第三点:原生支持“所见即所得”的原始字符串,大大降低构造多行字符串时的心智负担。
|
||||
|
||||
如果我们要在C语言中构造多行字符串,一般就是两个方法:要么使用多个字符串的自然拼接,要么需要结合续行符”“。但因为有转义字符的存在,我们很难控制好格式。Go语言就简单多了,通过一对反引号原生支持构造“所见即所得”的原始字符串(Raw String)。而且,Go语言原始字符串中的任意转义字符都不会起到转义的作用。比如下面这段代码:
|
||||
|
||||
var s string = ` ,_---~~~~~----._
|
||||
_,,_,*^____ _____*g*\"*,--,
|
||||
/ __/ /' ^. / \ ^@q f
|
||||
[ @f | @)) | | @)) l 0 _/
|
||||
\/ \~____ / __ \_____/ \
|
||||
| _l__l_ I
|
||||
} [______] I
|
||||
] | | | |
|
||||
] ~ ~ |
|
||||
| |
|
||||
| |`
|
||||
fmt.Println(s)
|
||||
|
||||
|
||||
你可以看到,字符串变量s被赋值了一个由一对反引号包裹的Gopher图案。这个Gopher图案由诸多ASCII字符组成,其中就包括了转义字符。这个时候,如果我们通过Println函数输出这个字符串,得到的图案和上面的图案并无二致。
|
||||
|
||||
第四点:对非ASCII字符提供原生支持,消除了源码在不同环境下显示乱码的可能。
|
||||
|
||||
Go语言源文件默认采用的是Unicode字符集,Unicode字符集是目前市面上最流行的字符集,它囊括了几乎所有主流非ASCII字符(包括中文字符)。Go字符串中的每个字符都是一个Unicode字符,并且这些Unicode字符是以UTF-8编码格式存储在内存当中的。在接下来讲解Go字符串的组成时,我们还会对这部分内容做进一步讲解。
|
||||
|
||||
知道了Go原生支持字符串类型带来的诸多变化和好处后,我们接下来就要深入到Go字符串的机制里看看,看看Go字符串是由什么组成的。
|
||||
|
||||
Go字符串的组成
|
||||
|
||||
Go语言在看待Go字符串组成这个问题上,有两种视角。一种是字节视角,也就是和所有其它支持字符串的主流语言一样,Go语言中的字符串值也是一个可空的字节序列,字节序列中的字节个数称为该字符串的长度。一个个的字节只是孤立数据,不表意。
|
||||
|
||||
比如在下面代码中,我们输出了字符串中的每个字节,以及整个字符串的长度:
|
||||
|
||||
var s = "中国人"
|
||||
fmt.Printf("the length of s = %d\n", len(s)) // 9
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
fmt.Printf("0x%x ", s[i]) // 0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
|
||||
|
||||
我们看到,由“中国人”构成的字符串的字节序列长度为9。并且,仅从某一个输出的字节来看,它是不能与字符串中的任一个字符对应起来的。
|
||||
|
||||
如果要表意,我们就需要从字符串的另外一个视角来看,也就是字符串是由一个可空的字符序列构成。这个时候我们再看下面代码:
|
||||
|
||||
var s = "中国人"
|
||||
fmt.Println("the character count in s is", utf8.RuneCountInString(s)) // 3
|
||||
|
||||
for _, c := range s {
|
||||
fmt.Printf("0x%x ", c) // 0x4e2d 0x56fd 0x4eba
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
|
||||
|
||||
在这段代码中,我们输出了字符串中的字符数量,也输出了这个字符串中的每个字符。前面说过,Go采用的是Unicode字符集,每个字符都是一个Unicode字符,那么这里输出的0x4e2d、0x56fd和0x4eba就应该是某种Unicode字符的表示了。没错,以0x4e2d为例,它是汉字“中”在Unicode字符集表中的码点(Code Point)。
|
||||
|
||||
那么,什么是Unicode码点呢?
|
||||
|
||||
Unicode字符集中的每个字符,都被分配了统一且唯一的字符编号。所谓Unicode码点,就是指将Unicode字符集中的所有字符“排成一队”,字符在这个“队伍”中的位次,就是它在Unicode字符集中的码点。也就说,一个码点唯一对应一个字符。“码点”的概念和我们马上要讲的rune类型有很大关系。
|
||||
|
||||
rune类型与字符字面值
|
||||
|
||||
Go使用rune这个类型来表示一个Unicode码点。rune本质上是int32类型的别名类型,它与int32类型是完全等价的,在Go源码中我们可以看到它的定义是这样的:
|
||||
|
||||
// $GOROOT/src/builtin.go
|
||||
type rune = int32
|
||||
|
||||
|
||||
由于一个Unicode码点唯一对应一个Unicode字符。所以我们可以说,一个rune实例就是一个Unicode字符,一个Go字符串也可以被视为rune实例的集合。我们可以通过字符字面值来初始化一个rune变量。
|
||||
|
||||
在Go中,字符字面值有多种表示法,最常见的是通过单引号括起的字符字面值,比如:
|
||||
|
||||
'a' // ASCII字符
|
||||
'中' // Unicode字符集中的中文字符
|
||||
'\n' // 换行字符
|
||||
'\'' // 单引号字符
|
||||
|
||||
|
||||
我们还可以使用Unicode专用的转义字符\u或\U作为前缀,来表示一个Unicode字符,比如:
|
||||
|
||||
'\u4e2d' // 字符:中
|
||||
'\U00004e2d' // 字符:中
|
||||
'\u0027' // 单引号字符
|
||||
|
||||
|
||||
这里,我们要注意,\u后面接四个十六进制数。如果是用四个十六进制数无法表示的Unicode字符,我们可以使用\U,\U后面可以接八个十六进制数来表示一个Unicode字符。
|
||||
|
||||
而且,由于表示码点的rune本质上就是一个整型数,所以我们还可用整型值来直接作为字符字面值给rune变量赋值,比如下面代码:
|
||||
|
||||
'\x27' // 使用十六进制表示的单引号字符
|
||||
'\047' // 使用八进制表示的单引号字符
|
||||
|
||||
|
||||
字符串字面值
|
||||
|
||||
字符串是字符的集合,了解了字符字面值后,字符串的字面值也就很简单了。只不过字符串是多个字符,所以我们需要把表示单个字符的单引号,换为表示多个字符组成的字符串的双引号就可以了。我们可以看下面这些例子:
|
||||
|
||||
"abc\n"
|
||||
"中国人"
|
||||
"\u4e2d\u56fd\u4eba" // 中国人
|
||||
"\U00004e2d\U000056fd\U00004eba" // 中国人
|
||||
"中\u56fd\u4eba" // 中国人,不同字符字面值形式混合在一起
|
||||
"\xe4\xb8\xad\xe5\x9b\xbd\xe4\xba\xba" // 十六进制表示的字符串字面值:中国人
|
||||
|
||||
|
||||
我们看到,将单个Unicode字符字面值一个接一个地连在一起,并用双引号包裹起来就构成了字符串字面值。甚至,我们也可以像倒数第二行那样,将不同字符字面值形式混合在一起,构成一个字符串字面值。
|
||||
|
||||
不过,这里你可能发现了一个问题,上面示例代码的最后一行使用的是十六进制形式的字符串字面值,但每个字节的值与前面几行的码点值完全对应不上啊,这是为什么呢?
|
||||
|
||||
这个字节序列实际上是“中国人”这个Unicode字符串的UTF-8编码值。什么是UTF-8编码?它又与Unicode字符集有什么关系呢?
|
||||
|
||||
UTF-8编码方案
|
||||
|
||||
UTF-8编码解决的是Unicode码点值在计算机中如何存储和表示(位模式)的问题。那你可能会说,码点唯一确定一个Unicode字符,直接用码点值不行么?
|
||||
|
||||
这的确是可以的,并且UTF-32编码标准就是采用的这个方案。UTF-32编码方案固定使用4个字节表示每个Unicode字符码点,这带来的好处就是编解码简单,但缺点也很明显,主要有下面几点:
|
||||
|
||||
|
||||
这种编码方案使用4个字节存储和传输一个整型数的时候,需要考虑不同平台的字节序问题;
|
||||
由于采用4字节的固定长度编码,与采用1字节编码的ASCII字符集无法兼容;
|
||||
所有Unicode字符码点都用4字节编码,显然空间利用率很差。
|
||||
|
||||
|
||||
针对这些问题,Go语言之父Rob Pike发明了UTF-8编码方案。和UTF-32方案不同,UTF-8方案使用变长度字节,对Unicode字符的码点进行编码。编码采用的字节数量与Unicode字符在码点表中的序号有关:表示序号(码点)小的字符使用的字节数量少,表示序号(码点)大的字符使用的字节数多。
|
||||
|
||||
UTF-8编码使用的字节数量从1个到4个不等。前128个与ASCII字符重合的码点(U+0000~U+007F)使用1个字节表示;带变音符号的拉丁文、希腊文、西里尔字母、阿拉伯文等使用2个字节来表示;而东亚文字(包括汉字)使用3个字节表示;其他极少使用的语言的字符则使用4个字节表示。
|
||||
|
||||
这样的编码方案是兼容ASCII字符内存表示的,这意味着采用UTF-8方案在内存中表示Unicode字符时,已有的ASCII字符可以被直接当成Unicode字符进行存储和传输,不用再做任何改变。
|
||||
|
||||
此外,UTF-8的编码单元为一个字节(也就是一次编解码一个字节),所以我们在处理UTF-8方案表示的Unicode字符的时候,就不需要像UTF-32方案那样考虑字节序问题了。相对于UTF-32方案,UTF-8方案的空间利用率也是最高的。
|
||||
|
||||
现在,UTF-8编码方案已经成为Unicode字符编码方案的事实标准,各个平台、浏览器等默认均使用UTF-8编码方案对Unicode字符进行编、解码。Go语言也不例外,采用了UTF-8编码方案存储Unicode字符,我们在前面按字节输出一个字符串值时看到的字节序列,就是对字符进行UTF-8编码后的值。
|
||||
|
||||
那么现在我们就使用Go在标准库中提供的UTF-8包,对Unicode字符(rune)进行编解码试试看:
|
||||
|
||||
// rune -> []byte
|
||||
func encodeRune() {
|
||||
var r rune = 0x4E2D
|
||||
fmt.Printf("the unicode charactor is %c\n", r) // 中
|
||||
buf := make([]byte, 3)
|
||||
_ = utf8.EncodeRune(buf, r) // 对rune进行utf-8编码
|
||||
fmt.Printf("utf-8 representation is 0x%X\n", buf) // 0xE4B8AD
|
||||
}
|
||||
|
||||
// []byte -> rune
|
||||
func decodeRune() {
|
||||
var buf = []byte{0xE4, 0xB8, 0xAD}
|
||||
r, _ := utf8.DecodeRune(buf) // 对buf进行utf-8解码
|
||||
fmt.Printf("the unicode charactor after decoding [0xE4, 0xB8, 0xAD] is %s\n", string(r)) // 中
|
||||
}
|
||||
|
||||
|
||||
这段代码中,encodeRune通过调用UTF-8的EncodeRune函数实现了对一个rune,也就是一个Unicode字符的编码,decodeRune则调用UTF-8包的decodeRune,将一段内存字节转换回一个Unicode字符。
|
||||
|
||||
好了,现在我们已经搞清楚Go语言中字符串类型的性质和组成了。有了这些基础之后,我们就可以看看Go是如何实现字符串类型的。也就是说,在Go的编译器和运行时中,一个字符串变量究竟是如何表示的?
|
||||
|
||||
Go字符串类型的内部表示
|
||||
|
||||
其实,我们前面提到的Go字符串类型的这些优秀的性质,Go字符串在编译器和运行时中的内部表示是分不开的。Go字符串类型的内部表示究竟是什么样的呢?在标准库的reflect包中,我们找到了答案,你可以看看下面代码:
|
||||
|
||||
// $GOROOT/src/reflect/value.go
|
||||
|
||||
// StringHeader是一个string的运行时表示
|
||||
type StringHeader struct {
|
||||
Data uintptr
|
||||
Len int
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,string类型其实是一个“描述符”,它本身并不真正存储字符串数据,而仅是由一个指向底层存储的指针和字符串的长度字段组成的。我也画了一张图,直观地展示了一个string类型变量在Go内存中的存储:
|
||||
|
||||
|
||||
|
||||
你看,Go编译器把源码中的string类型映射为运行时的一个二元组(Data, Len),真实的字符串值数据就存储在一个被Data指向的底层数组中。通过Data字段,我们可以得到这个数组的内容,你可以看看下面这段代码:
|
||||
|
||||
func dumpBytesArray(arr []byte) {
|
||||
fmt.Printf("[")
|
||||
for _, b := range arr {
|
||||
fmt.Printf("%c ", b)
|
||||
}
|
||||
fmt.Printf("]\n")
|
||||
}
|
||||
|
||||
func main() {
|
||||
var s = "hello"
|
||||
hdr := (*reflect.StringHeader)(unsafe.Pointer(&s)) // 将string类型变量地址显式转型为reflect.StringHeader
|
||||
fmt.Printf("0x%x\n", hdr.Data) // 0x10a30e0
|
||||
p := (*[5]byte)(unsafe.Pointer(hdr.Data)) // 获取Data字段所指向的数组的指针
|
||||
dumpBytesArray((*p)[:]) // [h e l l o ] // 输出底层数组的内容
|
||||
}
|
||||
|
||||
|
||||
这段代码利用了unsafe.Pointer的通用指针转型能力,按照StringHeader给出的结构内存布局,“顺藤摸瓜”,一步步找到了底层数组的地址,并输出了底层数组内容。
|
||||
|
||||
知道了string类型的实现原理后,我们再回头看看Go字符串类型性质中“获取长度的时间复杂度是常数时间”那句,是不是就很好理解了?之所以是常数时间,那是因为字符串类型中包含了字符串长度信息,当我们用len函数获取字符串长度时,len函数只要简单地将这个信息提取出来就可以了。
|
||||
|
||||
了解了string类型的实现原理后,我们还可以得到这样一个结论,那就是我们直接将string类型通过函数/方法参数传入也不会带来太多的开销。因为传入的仅仅是一个“描述符”,而不是真正的字符串数据。
|
||||
|
||||
那么,了解了Go字符串的一些基本信息和原理后,我们从理论转向实际,看看日常开发中围绕字符串类型都有哪些常见操作。
|
||||
|
||||
Go字符串类型的常见操作
|
||||
|
||||
由于字符串的不可变性,针对字符串,我们更多是尝试对其进行读取,或者将它作为一个组成单元去构建其他字符串,又或是转换为其他类型。下面我们逐一来看一下这些字符串操作:
|
||||
|
||||
第一个操作:下标操作。
|
||||
|
||||
在字符串的实现中,真正存储数据的是底层的数组。字符串的下标操作本质上等价于底层数组的下标操作。我们在前面的代码中实际碰到过针对字符串的下标操作,形式是这样的:
|
||||
|
||||
var s = "中国人"
|
||||
fmt.Printf("0x%x\n", s[0]) // 0xe4:字符“中” utf-8编码的第一个字节
|
||||
|
||||
|
||||
我们可以看到,通过下标操作,我们获取的是字符串中特定下标上的字节,而不是字符。
|
||||
|
||||
第二个操作:字符迭代。
|
||||
|
||||
Go有两种迭代形式:常规for迭代与for range迭代。你要注意,通过这两种形式的迭代对字符串进行操作得到的结果是不同的。
|
||||
|
||||
通过常规for迭代对字符串进行的操作是一种字节视角的迭代,每轮迭代得到的的结果都是组成字符串内容的一个字节,以及该字节所在的下标值,这也等价于对字符串底层数组的迭代,比如下面代码:
|
||||
|
||||
var s = "中国人"
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
fmt.Printf("index: %d, value: 0x%x\n", i, s[i])
|
||||
}
|
||||
|
||||
|
||||
运行这段代码,我们会看到,经过常规for迭代后,我们获取到的是字符串里字符的UTF-8编码中的一个字节:
|
||||
|
||||
index: 0, value: 0xe4
|
||||
index: 1, value: 0xb8
|
||||
index: 2, value: 0xad
|
||||
index: 3, value: 0xe5
|
||||
index: 4, value: 0x9b
|
||||
index: 5, value: 0xbd
|
||||
index: 6, value: 0xe4
|
||||
index: 7, value: 0xba
|
||||
index: 8, value: 0xba
|
||||
|
||||
|
||||
而像下面这样使用for range迭代,我们得到的又是什么呢?我们继续看代码:
|
||||
|
||||
var s = "中国人"
|
||||
|
||||
for i, v := range s {
|
||||
fmt.Printf("index: %d, value: 0x%x\n", i, v)
|
||||
}
|
||||
|
||||
|
||||
同样运行一下这段代码,我们得到:
|
||||
|
||||
index: 0, value: 0x4e2d
|
||||
index: 3, value: 0x56fd
|
||||
index: 6, value: 0x4eba
|
||||
|
||||
|
||||
我们看到,通过for range迭代,我们每轮迭代得到的是字符串中Unicode字符的码点值,以及该字符在字符串中的偏移值。我们可以通过这样的迭代,获取字符串中的字符个数,而通过Go提供的内置函数len,我们只能获取字符串内容的长度(字节个数)。当然了,获取字符串中字符个数更专业的方法,是调用标准库UTF-8包中的RuneCountInString函数,这点你可以自己试一下。
|
||||
|
||||
第三个操作:字符串连接。
|
||||
|
||||
我们前面已经知道,字符串内容是不可变的,但这并不妨碍我们基于已有字符串创建新字符串。Go原生支持通过+/+=操作符进行字符串连接,这也是对开发者体验最好的字符串连接操作,你可以看看下面这段代码:
|
||||
|
||||
s := "Rob Pike, "
|
||||
s = s + "Robert Griesemer, "
|
||||
s += " Ken Thompson"
|
||||
|
||||
fmt.Println(s) // Rob Pike, Robert Griesemer, Ken Thompson
|
||||
|
||||
|
||||
不过,虽然通过+/+=进行字符串连接的开发体验是最好的,但连接性能就未必是最快的了。除了这个方法外,Go还提供了strings.Builder、strings.Join、fmt.Sprintf等函数来进行字符串连接操作。关于这些方法的性能讨论,我放到了后面的思考题里,我想让你先去找一下答案。
|
||||
|
||||
第四个操作:字符串比较。
|
||||
|
||||
Go字符串类型支持各种比较关系操作符,包括= =、!= 、>=、<=、> 和 <。在字符串的比较上,Go采用字典序的比较策略,分别从每个字符串的起始处,开始逐个字节地对两个字符串类型变量进行比较。
|
||||
|
||||
当两个字符串之间出现了第一个不相同的元素,比较就结束了,这两个元素的比较结果就会做为串最终的比较结果。如果出现两个字符串长度不同的情况,长度比较小的字符串会用空元素补齐,空元素比其他非空元素都小。
|
||||
|
||||
这里我给了一个Go字符串比较的示例:
|
||||
|
||||
func main() {
|
||||
// ==
|
||||
s1 := "世界和平"
|
||||
s2 := "世界" + "和平"
|
||||
fmt.Println(s1 == s2) // true
|
||||
|
||||
// !=
|
||||
s1 = "Go"
|
||||
s2 = "C"
|
||||
fmt.Println(s1 != s2) // true
|
||||
|
||||
// < and <=
|
||||
s1 = "12345"
|
||||
s2 = "23456"
|
||||
fmt.Println(s1 < s2) // true
|
||||
fmt.Println(s1 <= s2) // true
|
||||
|
||||
// > and >=
|
||||
s1 = "12345"
|
||||
s2 = "123"
|
||||
fmt.Println(s1 > s2) // true
|
||||
fmt.Println(s1 >= s2) // true
|
||||
}
|
||||
|
||||
|
||||
你可以看到,鉴于Go string类型是不可变的,所以说如果两个字符串的长度不相同,那么我们不需要比较具体字符串数据,也可以断定两个字符串是不同的。但是如果两个字符串长度相同,就要进一步判断,数据指针是否指向同一块底层存储数据。如果还相同,那么我们可以说两个字符串是等价的,如果不同,那就还需要进一步去比对实际的数据内容。
|
||||
|
||||
第五个操作:字符串转换。
|
||||
|
||||
在这方面,Go支持字符串与字节切片、字符串与rune切片的双向转换,并且这种转换无需调用任何函数,只需使用显式类型转换就可以了。我们看看下面代码:
|
||||
|
||||
var s string = "中国人"
|
||||
|
||||
// string -> []rune
|
||||
rs := []rune(s)
|
||||
fmt.Printf("%x\n", rs) // [4e2d 56fd 4eba]
|
||||
|
||||
// string -> []byte
|
||||
bs := []byte(s)
|
||||
fmt.Printf("%x\n", bs) // e4b8ade59bbde4baba
|
||||
|
||||
// []rune -> string
|
||||
s1 := string(rs)
|
||||
fmt.Println(s1) // 中国人
|
||||
|
||||
// []byte -> string
|
||||
s2 := string(bs)
|
||||
fmt.Println(s2) // 中国人
|
||||
|
||||
|
||||
这样的转型看似简单,但无论是string转切片,还是切片转string,这类转型背后也是有着一定开销的。这些开销的根源就在于string是不可变的,运行时要为转换后的类型分配新内存。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了。这一节课,我们学习了Go中另外一类最常用的基本数据类型:字符串类型。Go原生支持字符串类型,所有字符串变量、常量、字面值都统一设置为string类型,对string的原生支持使得Go字符串有了很多优秀性质。
|
||||
|
||||
我们可以使用两个视角来看待Go字符串的组成,一种是字节视角。Go字符串是由一个可空的字节序列组成,字节的个数称为字符串的长度;另外一种是字符视角。Go字符串是由一个可空的字符序列构成。Go字符串中的每个字符都是一个Unicode字符。
|
||||
|
||||
Go使用rune类型来表示一个Unicode字符的码点。为了传输和存储Unicode字符,Go还使用了UTF-8编码方案,UTF-8编码方案使用变长字节的编码方式,码点小的字符用较少的字节编码,码点大的字符用较多字节编码,这种编码方式兼容ASCII字符集,并且拥有很高的空间利用率。
|
||||
|
||||
Go语言在运行时层面通过一个二元组结构(Data, Len)来表示一个string类型变量,其中Data是一个指向存储字符串数据内容区域的指针值,Len是字符串的长度。因此,本质上,一个string变量仅仅是一个“描述符”,并不真正包含字符串数据。因此,我们即便直接将string类型变量作为函数参数,其传递的开销也是恒定的,不会随着字符串大小的变化而变化。
|
||||
|
||||
Go为其原生支持的string类型提供了许多原生操作类型,在进行字符串操作时你要注意以下几点:
|
||||
|
||||
|
||||
通过常规for迭代与for range迭代所得到的结果不同,常规for迭代采用的是字节视角;而for range迭代采用的是字符视角;
|
||||
基于+/+=操作符的字符串连接是对开发者体验最好的字符串连接方式,但却不是性能最好的方式;
|
||||
无论是字符串转切片,还是切片转字符串,都会有内存分配的开销,这缘于Go字符串数据内容不可变的性质。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
我们前面讲到,Go提供多种字符串连接服务,包括基于+/+=的字符连接、基于strings.Builder、strings.Join、fmt.Sprintf等函数来进行字符串连接操作。那么,哪种连接方式是性能最高的呢?期待在留言区看到你的想法。
|
||||
|
||||
欢迎把这节课分享给更多对Go语言字符串类型感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
317
专栏/TonyBai·Go语言第一课/14常量:Go在“常量”设计上的创新有哪些?.md
Normal file
317
专栏/TonyBai·Go语言第一课/14常量:Go在“常量”设计上的创新有哪些?.md
Normal file
@@ -0,0 +1,317 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 常量:Go在“常量”设计上的创新有哪些?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在前面几节课中,我们学习了变量以及Go原生支持的基本数据类型,包括数值类型与字符串类型。这两类基本数据类型不仅仅可以被用来声明变量、明确变量绑定的内存块边界,还可以被用来定义另外一大类语法元素:常量。
|
||||
|
||||
你可能会问:常量有什么好讲的呢?常量不就是在程序生命周期内不会改变的值吗?如果是其他主流语言的常量,可讲的确实不多,但Go在常量的设计上是有一些“创新”的。都有哪些创新呢?我们不妨先来剧透一下。Go语言在常量方面的创新包括下面这几点:
|
||||
|
||||
|
||||
支持无类型常量;
|
||||
支持隐式自动转型;
|
||||
可用于实现枚举。
|
||||
|
||||
|
||||
这些创新的具体内容是什么呢?怎么来理解Go常量的这些创新呢?你可以先思考一下,接下来我们再来详细分析。
|
||||
|
||||
不过在讲解这些“创新”之前,我们还是要从Go常量的一些基本概念说起,这会有助于我们对Go常量有一个更为深入的理解。
|
||||
|
||||
常量以及Go原生支持常量的好处
|
||||
|
||||
Go语言的常量是一种在源码编译期间被创建的语法元素。这是在说这个元素的值可以像变量那样被初始化,但它的初始化表达式必须是在编译期间可以求出值来的。
|
||||
|
||||
而且,Go常量一旦声明并被初始化后,它的值在整个程序的生命周期内便保持不变。这样,我们在并发设计时就不用考虑常量访问的同步,并且被创建并初始化后的常量还可以作为其他常量的初始表达式的一部分。
|
||||
|
||||
我们前面学过,Go是使用var关键字声明变量的。在常量这里,Go语言引入const关键字来声明常量。而且,和var支持单行声明多个变量,以及以代码块形式聚合变量声明一样,const也支持单行声明多个常量,以及以代码块形式聚合常量声明的形式,具体你可以看下面这个示例代码:
|
||||
|
||||
const Pi float64 = 3.14159265358979323846 // 单行常量声明
|
||||
|
||||
// 以const代码块形式声明常量
|
||||
const (
|
||||
size int64 = 4096
|
||||
i, j, s = 13, 14, "bar" // 单行声明多个常量
|
||||
)
|
||||
|
||||
|
||||
不过,Go语言规范规定,Go常量的类型只局限于前面我们学过的Go基本数据类型,包括数值类型、字符串类型,以及只有两个取值(true和false)的布尔类型。
|
||||
|
||||
那常量的引入究竟给Go语言带来什么好处呢?没有对比便没有伤害。让我们先来回顾一下原生不支持常量的C语言的境况。
|
||||
|
||||
在C语言中,字面值担负着常量的角色,我们可以使用数值型、字符串型字面值来应对不同场合对常量的需求。
|
||||
|
||||
为了不让这些字面值以“魔数(Magic Number)”的形式分布于源码各处,早期C语言的常用实践是使用宏(macro)定义记号来指代这些字面值,这种定义方式被称为宏定义常量,比如下面这些宏:
|
||||
|
||||
#define FILE_MAX_LEN 0x22334455
|
||||
#define PI 3.1415926
|
||||
#define GO_GREETING "Hello, Gopher"
|
||||
#define A_CHAR 'a'
|
||||
|
||||
|
||||
使用宏定义常量的习惯一直是C编码中的主流风格,即便后续的C标准中提供了const关键字后也是这样,但宏定义的常量会有很多问题。比如,它是一种仅在预编译阶段进行替换的字面值,继承了宏替换的复杂性和易错性,而且还有类型不安全、无法在调试时通过宏名字输出常量的值,等等问题。
|
||||
|
||||
即使我们改用后续C标准中提供的const关键字修饰的标识符,也依然不是一种圆满方案。因为const关键字修饰的标识符本质上依旧是变量,它甚至无法用作数组变量声明中的初始长度(除非用GNU扩展C)。你可以看看下面这个代码,它就存在着这样的问题:
|
||||
|
||||
const int size = 5;
|
||||
int a[size] = {1,2,3,4,5}; // size本质不是常量,这将导致编译器错误
|
||||
|
||||
|
||||
正是因为如此,作为站在C语言等编程语言的肩膀之上诞生的Go语言,它吸取了C语言的教训。Go原生提供的用const关键字定义的常量,整合了C语言中宏定义常量、const修饰的“只读变量”,以及枚举常量这三种形式,并消除了每种形式的不足,使得Go常量是类型安全的,而且对编译器优化友好。
|
||||
|
||||
Go在消除了C语言无原生支持的常量的弊端的同时,还针对常量做了一些额外的创新。下面我们就来看第一个创新点:无类型常量。
|
||||
|
||||
无类型常量
|
||||
|
||||
通过前面的学习,我们知道Go语言对类型安全是有严格要求的:即便两个类型拥有着相同的底层类型,但它们仍然是不同的数据类型,不可以被相互比较或混在一个表达式中进行运算。这一要求不仅仅适用于变量,也同样适用于有类型常量(Typed Constant)中,你可以在下面代码中看出这一点:
|
||||
|
||||
type myInt int
|
||||
const n myInt = 13
|
||||
const m int = n + 5 // 编译器报错:cannot use n + 5 (type myInt) as type int in const initializer
|
||||
|
||||
func main() {
|
||||
var a int = 5
|
||||
fmt.Println(a + n) // 编译器报错:invalid operation: a + n (mismatched types int and myInt)
|
||||
}
|
||||
|
||||
|
||||
而且,有类型常量与变量混合在一起进行运算求值的时候,也必须遵守类型相同这一要求,否则我们只能通过显式转型才能让上面代码正常工作,比如下面代码中,我们就必须通过将常量n显式转型为int后才能参与后续运算:
|
||||
|
||||
type myInt int
|
||||
const n myInt = 13
|
||||
const m int = int(n) + 5 // OK
|
||||
|
||||
func main() {
|
||||
var a int = 5
|
||||
fmt.Println(a + int(n)) // 输出:18
|
||||
}
|
||||
|
||||
|
||||
那么在Go语言中,只有这一种方法能让上面代码编译通过、正常运行吗 ?当然不是,我们也可以使用Go中的无类型常量来实现,你可以看看这段代码:
|
||||
|
||||
type myInt int
|
||||
const n = 13
|
||||
|
||||
func main() {
|
||||
var a myInt = 5
|
||||
fmt.Println(a + n) // 输出:18
|
||||
}
|
||||
|
||||
|
||||
你可以看到,在这个代码中,常量n在声明时并没有显式地被赋予类型,在Go中,这样的常量就被称为无类型常量(Untyped Constant)。
|
||||
|
||||
不过,无类型常量也不是说就真的没有类型,它也有自己的默认类型,不过它的默认类型是根据它的初值形式来决定的。像上面代码中的常量n的初值为整数形式,所以它的默认类型为int。
|
||||
|
||||
不过,到这里,你可能已经发现问题了:常量n的默认类型int与myInt并不是同一个类型啊,为什么可以放在一个表达式中计算而没有报编译错误呢?
|
||||
|
||||
别急,我们继续用Go常量的第二个创新点,隐式转型来回答这个问题。
|
||||
|
||||
隐式转型
|
||||
|
||||
隐式转型说的就是,对于无类型常量参与的表达式求值,Go编译器会根据上下文中的类型信息,把无类型常量自动转换为相应的类型后,再参与求值计算,这一转型动作是隐式进行的。但由于转型的对象是一个常量,所以这并不会引发类型安全问题,Go编译器会保证这一转型的安全性。
|
||||
|
||||
我们继续以上面代码为例来分析一下,Go编译器会自动将a+n这个表达式中的常量n转型为myInt类型,再与变量a相加。由于变量a的类型myInt的底层类型也是int,所以这个隐式转型不会有任何问题。
|
||||
|
||||
不过,如果Go编译器在做隐式转型时,发现无法将常量转换为目标类型,Go编译器也会报错,比如下面的代码就是这样:
|
||||
|
||||
const m = 1333333333
|
||||
|
||||
var k int8 = 1
|
||||
j := k + m // 编译器报错:constant 1333333333 overflows int8
|
||||
|
||||
|
||||
这个代码中常量m的值1333333333已经超出了int8类型可以表示的范围,所以我们将它转换为int8类型时,就会导致编译器报溢出错误。
|
||||
|
||||
从前面这些分析中,我们可以看到,无类型常量与常量隐式转型的“珠联璧合”使得在Go这样的具有强类型系统的语言,在处理表达式混合数据类型运算的时候具有比较大的灵活性,代码编写也得到了一定程度的简化。也就是说,我们不需要在求值表达式中做任何显式转型了。所以说,在Go中,使用无类型常量是一种惯用法,你可以多多熟悉这种形式。
|
||||
|
||||
接下来,我们再来看看Go常量的最后一个重要创新,同样也是常量被应用较为广泛的一个领域:实现枚举。
|
||||
|
||||
实现枚举
|
||||
|
||||
不知道你有没有注意到,在前面讲解Go基本数据类型时,我们并没有提到过枚举类型,这是因为Go语言其实并没有原生提供枚举类型。
|
||||
|
||||
但是Go开发者对枚举这种类型的需求是现实存在的呀。那这要怎么办呢?其实,在Go语言中,我们可以使用const代码块定义的常量集合,来实现枚举。这是因为,枚举类型本质上就是一个由有限数量常量所构成的集合,所以这样做并没有什么问题。
|
||||
|
||||
不过,用Go常量实现枚举可不是我们的临时起意,而是Go设计者们的原创,他们在语言设计之初就希望将枚举类型与常量合二为一,这样就不需要再单独提供枚举类型了,于是他们将Go的前辈C语言中的枚举类型特性移植到常量的特性中并进行了“改良”。
|
||||
|
||||
那么接下来,我们就先来回顾一下C语言枚举类型,看看究竟它有哪些特性被移植到Go常量中了。在C语言中,枚举是一个命名的整型常数的集合,下面是我们使用枚举定义的Weekday类型:
|
||||
|
||||
enum Weekday {
|
||||
SUNDAY,
|
||||
MONDAY,
|
||||
TUESDAY,
|
||||
WEDNESDAY,
|
||||
THURSDAY,
|
||||
FRIDAY,
|
||||
SATURDAY
|
||||
};
|
||||
|
||||
int main() {
|
||||
enum Weekday d = SATURDAY;
|
||||
printf("%d\n", d); // 6
|
||||
}
|
||||
|
||||
|
||||
你运行上面的C语言代码就会发现,其实C语言针对枚举类型提供了很多语法上的便利特性。比如说,如果你没有显式给枚举常量赋初始值,那么枚举类型的第一个常量的值就为0,后续常量的值再依次加1。
|
||||
|
||||
你看,上面这个代码中的Weekday枚举类型的所有枚举常量都没有显式赋值,那么第一个枚举常量SUNDAY的值就会被赋值为0,它后面的枚举常量值依次加1,这也是为什么输出的SATURDAY的值为6的原因。
|
||||
|
||||
但Go并没有直接继承这一特性,而是将C语言枚举类型的这种基于前一个枚举值加1的特性,分解成了Go中的两个特性:自动重复上一行,以及引入const块中的行偏移量指示器iota,这样它们就可以分别独立使用了。
|
||||
|
||||
接下来我们逐一看看这两个特性。首先,Go的const语法提供了“隐式重复前一个非空表达式”的机制,比如下面代码:
|
||||
|
||||
const (
|
||||
Apple, Banana = 11, 22
|
||||
Strawberry, Grape
|
||||
Pear, Watermelon
|
||||
)
|
||||
|
||||
|
||||
这个代码里,常量定义的后两行并没有被显式地赋予初始值,所以Go编译器就为它们自动使用上一行的表达式,也就获得了下面这个等价的代码:
|
||||
|
||||
const (
|
||||
Apple, Banana = 11, 22
|
||||
Strawberry, Grape = 11, 22 // 使用上一行的初始化表达式
|
||||
Pear, Watermelon = 11, 22 // 使用上一行的初始化表达式
|
||||
)
|
||||
|
||||
|
||||
不过,仅仅是重复上一行显然无法满足“枚举”的要求,因为枚举类型中的每个枚举常量的值都是唯一的。所以,Go在这个特性的基础上又提供了“神器”:iota,有了iota,我们就可以定义满足各种场景的枚举常量了。
|
||||
|
||||
iota是Go语言的一个预定义标识符,它表示的是const声明块(包括单行声明)中,每个常量所处位置在块中的偏移值(从零开始)。同时,每一行中的iota自身也是一个无类型常量,可以像前面我们提到的无类型常量那样,自动参与到不同类型的求值过程中来,不需要我们再对它进行显式转型操作。
|
||||
|
||||
你可以看看下面这个Go标准库中sync/mutex.go中的一段基于iota的枚举常量的定义:
|
||||
|
||||
// $GOROOT/src/sync/mutex.go
|
||||
const (
|
||||
mutexLocked = 1 << iota
|
||||
mutexWoken
|
||||
mutexStarving
|
||||
mutexWaiterShift = iota
|
||||
starvationThresholdNs = 1e6
|
||||
)
|
||||
|
||||
|
||||
这是一个很典型的诠释iota含义的例子,我们一行一行来看一下。
|
||||
|
||||
首先,这个const声明块的第一行是mutexLocked = 1 << iota ,iota的值是这行在const块中的偏移,因此iota的值为0,我们得到mutexLocked这个常量的值为1 << 0,也就是1。
|
||||
|
||||
接着,第二行:mutexWorken 。因为这个const声明块中并没有显式的常量初始化表达式,所以我们根据const声明块里“隐式重复前一个非空表达式”的机制,这一行就等价于mutexWorken = 1 << iota。而且,又因为这一行是const块中的第二行,所以它的偏移量iota的值为1,我们得到mutexWorken这个常量的值为1 << 1,也就是2。
|
||||
|
||||
然后是mutexStarving。这个常量同mutexWorken一样,这一行等价于mutexStarving = 1 << iota。而且,也因为这行的iota的值为2,我们可以得到mutexStarving这个常量的值为 1 << 2,也就是4;
|
||||
|
||||
再然后我们看mutexWaiterShift = iota 这一行,这一行为常量mutexWaiterShift做了显式初始化,这样就不用再重复前一行了。由于这一行是第四行,而且作为行偏移值的iota的值为3,因此mutexWaiterShift的值就为3。
|
||||
|
||||
而最后一行,代码中直接用了一个具体值1e6给常量starvationThresholdNs进行了赋值,那么这个常量值就是1e6本身了。
|
||||
|
||||
看完这个例子的分析,我相信你对于iota就会有更深的理解了。不过我还要提醒你的是,位于同一行的iota即便出现多次,多个iota的值也是一样的,比如下面代码:
|
||||
|
||||
const (
|
||||
Apple, Banana = iota, iota + 10 // 0, 10 (iota = 0)
|
||||
Strawberry, Grape // 1, 11 (iota = 1)
|
||||
Pear, Watermelon // 2, 12 (iota = 2)
|
||||
)
|
||||
|
||||
|
||||
我们以第一组常量Apple与Banana为例分析一下,它们分为被赋值为iota与iota+10,而且由于这是const常量声明块的第一行,因此两个iota的值都为0,于是就有了“Apple=0, Banana=10”的结果。下面两组变量分析过程也是类似的,你可以自己试一下。
|
||||
|
||||
如果我们要略过iota = 0,从iota = 1开始正式定义枚举常量,我们可以效仿下面标准库中的代码:
|
||||
|
||||
// $GOROOT/src/syscall/net_js.go
|
||||
const (
|
||||
_ = iota
|
||||
IPV6_V6ONLY // 1
|
||||
SOMAXCONN // 2
|
||||
SO_ERROR // 3
|
||||
)
|
||||
|
||||
|
||||
在这个代码里,我们使用了空白标识符作为第一个枚举常量,它的值就是iota。虽然它本身没有实际意义,但后面的常量值都会重复它的初值表达式(这里是iota),于是我们真正的枚举常量值就从1开始了。
|
||||
|
||||
那如果我们的枚举常量值并不连续,而是要略过某一个或几个值,又要怎么办呢?我们也可以借助空白标识符来实现,如下面这个代码:
|
||||
|
||||
const (
|
||||
_ = iota // 0
|
||||
Pin1
|
||||
Pin2
|
||||
Pin3
|
||||
_
|
||||
Pin5 // 5
|
||||
)
|
||||
|
||||
|
||||
你可以看到,在上面这个枚举定义中,枚举常量集合中没有Pin4。为了略过Pin4,我们在它的位置上使用了空白标识符。
|
||||
|
||||
这样,Pin5就会重复Pin3,也就是向上数首个不为空的常量标识符的值,这里就是iota,而且由于它所在行的偏移值为5,因此Pin5的值也为5,这样我们成功略过了Pin4这个枚举常量以及4这个枚举值。
|
||||
|
||||
而且,iota特性让我们维护枚举常量列表变得更加容易。比如我们使用传统的枚举常量声明方式,来声明一组按首字母排序的“颜色”常量,也就是这样:
|
||||
|
||||
const (
|
||||
Black = 1
|
||||
Red = 2
|
||||
Yellow = 3
|
||||
)
|
||||
|
||||
|
||||
假如这个时候,我们要增加一个新颜色Blue。那根据字母序,这个新常量应该放在Red的前面呀。但这样一来,我们就需要像下面代码这样将Red到Yellow的常量值都手动加1,十分费力。
|
||||
|
||||
const (
|
||||
Blue = 1
|
||||
Black = 2
|
||||
Red = 3
|
||||
Yellow = 4
|
||||
)
|
||||
|
||||
|
||||
那如果我们使用iota重新定义这组“颜色”枚举常量是不是可以更方便呢?我们可以像下面代码这样试试看:
|
||||
|
||||
const (
|
||||
_ = iota
|
||||
Blue
|
||||
Red
|
||||
Yellow
|
||||
)
|
||||
|
||||
|
||||
这样,无论后期我们需要增加多少种颜色,我们只需将常量名插入到对应位置就可以,其他就不需要再做任何手工调整了。
|
||||
|
||||
而且,如果一个Go源文件中有多个const代码块定义的不同枚举,每个const代码块中的iota也是独立变化的,也就是说,每个const代码块都拥有属于自己的iota,如下面代码所示:
|
||||
|
||||
const (
|
||||
a = iota + 1 // 1, iota = 0
|
||||
b // 2, iota = 1
|
||||
c // 3, iota = 2
|
||||
)
|
||||
|
||||
const (
|
||||
i = iota << 1 // 0, iota = 0
|
||||
j // 2, iota = 1
|
||||
k // 4, iota = 2
|
||||
)
|
||||
|
||||
|
||||
你可以看到,每个iota的生命周期都始于一个const代码块的开始,在该const代码块结束时结束。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了。今天我们学习了Go中最常用的一类语法元素:常量。
|
||||
|
||||
常量是一种在源码编译期间被创建的语法元素,它的值在程序的生命周期内保持不变。所有常量的求值计算都是在编译期完成的,而不是在运行期,这样可以减少运行时的工作,也方便编译器进行编译优化。另外,当操作数是常量表达式时,一些运行时的错误也可以在编译时被发现,例如整数除零、字符串索引越界等。
|
||||
|
||||
Go语言原生提供了对常量的支持,所以我们可以避免像C语言那样,使用宏定义常量,这比较复杂,也容易发生错误。而且,Go编译器还为我们提供的类型安全的保证。
|
||||
|
||||
接着,我们也学习了无类型常量,这是Go在常量方面的创新。无类型常量拥有和字面值一样的灵活性,它可以直接参与到表达式求值中,而不需要使用显式地类型转换。这得益于Go对常量的另一个创新:隐式转型,也就是将无类型常量的默认类型自动隐式转换为求值上下文中所需要的类型,并且这一过程由Go编译器保证安全性,这大大简化了代码编写。
|
||||
|
||||
此外,Go常量还“移植”并改良了前辈C语言的枚举类型的特性,在const代码块中支持自动重复上一行和iota行偏移量指示器。这样我们就可以使用Go常量语法来实现枚举常量的定义。并且,基于Go常量特性的枚举定义十分灵活,维护起来也更为简便。比如,我们可以选择以任意数值作为枚举值列表的起始值,也可以定义不连续枚举常量,添加和删除有序枚举常量时也不需要手工调整枚举的值。
|
||||
|
||||
思考题
|
||||
|
||||
今天我也给你留了思考题:虽然iota带来了灵活性与便利,但是否存在一些场合,在定义枚举常量时使用显式字面值更为适合呢?你可以思考一下,欢迎在留言区留下你的答案。
|
||||
|
||||
感谢你和我一起学习,也欢迎你把这门课分享给更多对Go语言学习感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
306
专栏/TonyBai·Go语言第一课/15同构复合类型:从定长数组到变长切片.md
Normal file
306
专栏/TonyBai·Go语言第一课/15同构复合类型:从定长数组到变长切片.md
Normal file
@@ -0,0 +1,306 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 同构复合类型:从定长数组到变长切片
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在前面的学习中,我们详细讲解了Go基本数据类型,主要包括数值类型与字符串类型。但是,仅仅学习这些基本数据类型建立的抽象概念,还远不足以让我们应对真实世界的各种问题。
|
||||
|
||||
比如,我们要表示一组数量固定且连续的整型数,建立一个能表示书籍的抽象数据类型,这个类型中包含书名、页数、出版信息等;又或者,我们要建立一个学号与学生姓名的映射表等。这些问题基本数据类型都无法解决,所以我们需要一类新类型来建立这些抽象,丰富Go语言的表现力。
|
||||
|
||||
这种新类型是怎么样的呢?我们可以通过这些例子总结出新类型的一个特点,那就是它们都是由多个同构类型(相同类型)或异构类型(不同类型)的元素的值组合而成的。这类数据类型在Go语言中被称为复合类型。
|
||||
|
||||
从这一节课开始,我们就来讲解Go语言的复合类型。Go语言原生内置了多种复合数据类型,包括数组、切片(slice)、map、结构体,以及像channel这类用于并发程序设计的高级复合数据类型。那么这一节课,我们先来学习一下最简单的复合类型:数组,以及与数组有着密切关系的切片。
|
||||
|
||||
下面我们就先从最基础的复合数据类型,数组开始。
|
||||
|
||||
数组有哪些基本特性?
|
||||
|
||||
我们先来看数组类型的概念。Go语言的数组是一个长度固定的、由同构类型元素组成的连续序列。通过这个定义,我们可以识别出Go的数组类型包含两个重要属性:元素的类型和数组长度(元素的个数)。这两个属性也直接构成了Go语言中数组类型变量的声明:
|
||||
|
||||
var arr [N]T
|
||||
|
||||
|
||||
这里我们声明了一个数组变量arr,它的类型为[N]T,其中元素的类型为T,数组的长度为N。这里,我们要注意,数组元素的类型可以为任意的Go原生类型或自定义类型,而且数组的长度必须在声明数组变量时提供,Go编译器需要在编译阶段就知道数组类型的长度,所以,我们只能用整型数字面值或常量表达式作为N值。
|
||||
|
||||
通过这句代码我们也可以看到,如果两个数组类型的元素类型T与数组长度N都是一样的,那么这两个数组类型是等价的,如果有一个属性不同,它们就是两个不同的数组类型。下面这个示例很好地诠释了这一点:
|
||||
|
||||
func foo(arr [5]int) {}
|
||||
func main() {
|
||||
var arr1 [5]int
|
||||
var arr2 [6]int
|
||||
var arr3 [5]string
|
||||
|
||||
foo(arr1) // ok
|
||||
foo(arr2) // 错误:[6]int与函数foo参数的类型[5]int不是同一数组类型
|
||||
foo(arr3) // 错误:[5]string与函数foo参数的类型[5]int不是同一数组类型
|
||||
}
|
||||
|
||||
|
||||
在这段代码里,arr2与arr3两个变量的类型分别为[6]int和 [5]string,前者的长度属性与[5]int不一致,后者的元素类型属性与[5]int不一致,因此这两个变量都不能作为调用函数foo时的实际参数。
|
||||
|
||||
了解了数组类型的逻辑定义后,我们再来看看数组类型在内存中的实际表示是怎样的,这是数组区别于其他类型,也是我们区分不同数组类型的根本依据。
|
||||
|
||||
数组类型不仅是逻辑上的连续序列,而且在实际内存分配时也占据着一整块内存。Go编译器在为数组类型的变量实际分配内存时,会为Go数组分配一整块、可以容纳它所有元素的连续内存,如下图所示:
|
||||
|
||||
|
||||
|
||||
我们从这个数组类型的内存表示中可以看出来,这块内存全部空间都被用来表示数组元素,所以说这块内存的大小,就等于各个数组元素的大小之和。如果两个数组所分配的内存大小不同,那么它们肯定是不同的数组类型。Go提供了预定义函数len可以用于获取一个数组类型变量的长度,通过unsafe包提供的Sizeof函数,我们可以获得一个数组变量的总大小,如下面代码:
|
||||
|
||||
var arr = [6]int{1, 2, 3, 4, 5, 6}
|
||||
fmt.Println("数组长度:", len(arr)) // 6
|
||||
fmt.Println("数组大小:", unsafe.Sizeof(arr)) // 48
|
||||
|
||||
|
||||
数组大小就是所有元素的大小之和,这里数组元素的类型为int。在64位平台上,int类型的大小为8,数组arr一共有6个元素,因此它的总大小为6x8=48个字节。
|
||||
|
||||
和基本数据类型一样,我们声明一个数组类型变量的同时,也可以显式地对它进行初始化。如果不进行显式初始化,那么数组中的元素值就是它类型的零值。比如下面的数组类型变量arr1的各个元素值都为0:
|
||||
|
||||
var arr1 [6]int // [0 0 0 0 0 0]
|
||||
|
||||
|
||||
如果要显式地对数组初始化,我们需要在右值中显式放置数组类型,并通过大括号的方式给各个元素赋值(如下面代码中的arr2)。当然,我们也可以忽略掉右值初始化表达式中数组类型的长度,用“…”替代,Go编译器会根据数组元素的个数,自动计算出数组长度(如下面代码中的arr3):
|
||||
|
||||
var arr2 = [6]int {
|
||||
11, 12, 13, 14, 15, 16,
|
||||
} // [11 12 13 14 15 16]
|
||||
|
||||
var arr3 = [...]int {
|
||||
21, 22, 23,
|
||||
} // [21 22 23]
|
||||
fmt.Printf("%T\n", arr3) // [3]int
|
||||
|
||||
|
||||
但如果我们要对一个长度较大的稀疏数组进行显式初始化,这样逐一赋值就太麻烦了,还有什么更好的方法吗?我们可以通过使用下标赋值的方式对它进行初始化,比如下面代码中的arr4:
|
||||
|
||||
var arr4 = [...]int{
|
||||
99: 39, // 将第100个元素(下标值为99)的值赋值为39,其余元素值均为0
|
||||
}
|
||||
fmt.Printf("%T\n", arr4) // [100]int
|
||||
|
||||
|
||||
通过数组类型变量以及下标值,我们可以很容易地访问到数组中的元素值,并且这种访问是十分高效的,不存在Go运行时带来的额外开销。但你要记住,数组的下标值是从0开始的。如果下标值超出数组长度范畴,或者是负数,那么Go编译器会给出错误提示,防止访问溢出:
|
||||
|
||||
var arr = [6]int{11, 12, 13, 14, 15, 16}
|
||||
fmt.Println(arr[0], arr[5]) // 11 16
|
||||
fmt.Println(arr[-1]) // 错误:下标值不能为负数
|
||||
fmt.Println(arr[8]) // 错误:小标值超出了arr的长度范围
|
||||
|
||||
|
||||
多维数组怎么解?
|
||||
|
||||
上面这些元素类型为非数组类型的数组的都是简单的一维数组,但Go语言中,其实还有更复杂的数组类型,多维数组。也就是说,数组类型自身也可以作为数组元素的类型,这样就会产生多维数组,比如下面的变量mArr的类型就是一个多维数组[2] [3][4]int:
|
||||
|
||||
var mArr [2][3][4]int
|
||||
|
||||
|
||||
多维数组也不难理解,我们以上面示例中的多维数组类型为例,我们从左向右逐维地去看,这样我们就可以将一个多维数组分层拆解成这样:
|
||||
|
||||
|
||||
|
||||
我们从上向下看,首先我们将mArr这个数组看成是一个拥有两个元素,且元素类型都为[3] [4]int的数组,就像图中最上层画的那样。这样,mArr的两个元素分别为mArr[0]和mArr [1],它们的类型均为[3] [4]int,也就是说它们都是二维数组。
|
||||
|
||||
而以mArr[0]为例,我们可以将其看成一个拥有3个元素且元素类型为[4]int的数组,也就是图中中间层画的那样。这样mArr[0]的三个元素分别为mArr[0][0]、mArr[0][1]以及mArr[0][2],它们的类型均为[4]int,也就是说它们都是一维数组。
|
||||
|
||||
图中的最后一层就是mArr[0]的三个元素,以及mArr[1]的三个元素的各自展开形式。以此类推,你会发现,无论多维数组究竟有多少维,我们都可以将它从左到右逐一展开,最终化为我们熟悉的一维数组。
|
||||
|
||||
不过,虽然数组类型是Go语言中最基础的复合数据类型,但是在使用中它也会有一些问题。数组类型变量是一个整体,这就意味着一个数组变量表示的是整个数组。这点与C语言完全不同,在C语言中,数组变量可视为指向数组第一个元素的指针。这样一来,无论是参与迭代,还是作为实际参数传给一个函数/方法,Go传递数组的方式都是纯粹的值拷贝,这会带来较大的内存拷贝开销。
|
||||
|
||||
这时,你可能会想到我们可以使用指针的方式,来向函数传递数组。没错,这样做的确可以避免性能损耗,但这更像是C语言的惯用法。其实,Go语言为我们提供了一种更为灵活、更为地道的方式 ,切片,来解决这个问题。它的优秀特性让它成为了Go 语言中最常用的同构复合类型。
|
||||
|
||||
切片是怎么一回事?
|
||||
|
||||
我们前面提到过,数组作为最基本同构类型在Go语言中被保留了下来,但数组在使用上确有两点不足:固定的元素个数,以及传值机制下导致的开销较大。于是Go设计者们又引入了另外一种同构复合类型:切片(slice),来弥补数组的这两处不足。
|
||||
|
||||
切片和数组就像两个一母同胞的亲兄弟,长得像,但又各有各的行为特点。我们可以先声明并初始化一个切片变量看看:
|
||||
|
||||
var nums = []int{1, 2, 3, 4, 5, 6}
|
||||
|
||||
|
||||
我们看到与数组声明相比,切片声明仅仅是少了一个“长度”属性。去掉“长度”这一束缚后,切片展现出更为灵活的特性,这些特性我们后面再分析。
|
||||
|
||||
虽然不需要像数组那样在声明时指定长度,但切片也有自己的长度,只不过这个长度不是固定的,而是随着切片中元素个数的变化而变化的。我们可以通过len函数获得切片类型变量的长度,比如上面那个切片变量的长度就是6:
|
||||
|
||||
fmt.Println(len(nums)) // 6
|
||||
|
||||
|
||||
而且,通过Go内置函数append,我们可以动态地向切片中添加元素。当然,添加后切片的长度也就随之发生了变化,如下面代码所示:
|
||||
|
||||
nums = append(nums, 7) // 切片变为[1 2 3 4 5 6 7]
|
||||
fmt.Println(len(nums)) // 7
|
||||
|
||||
|
||||
到这里,我想你已经初步了解切片类型的一些基础信息了。我们前面也说,相比数组类型,切片展现了更为灵活的特性,这些特性是怎么样的呢?现在我们深入它的实现原理看看。
|
||||
|
||||
Go是如何实现切片类型的?
|
||||
|
||||
Go切片在运行时其实是一个三元组结构,它在Go运行时中的表示如下:
|
||||
|
||||
type slice struct {
|
||||
array unsafe.Pointer
|
||||
len int
|
||||
cap int
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,每个切片包含三个字段:
|
||||
|
||||
|
||||
array: 是指向底层数组的指针;
|
||||
len: 是切片的长度,即切片中当前元素的个数;
|
||||
cap: 是底层数组的长度,也是切片的最大容量,cap值永远大于等于len值。
|
||||
|
||||
|
||||
如果我们用这个三元组结构表示切片类型变量nums,会是这样:
|
||||
|
||||
|
||||
|
||||
我们看到,Go编译器会自动为每个新创建的切片,建立一个底层数组,默认底层数组的长度与切片初始元素个数相同。我们还可以用以下几种方法创建切片,并指定它底层数组的长度。
|
||||
|
||||
方法一:通过make函数来创建切片,并指定底层数组的长度。我们直接看下面这行代码:
|
||||
|
||||
sl := make([]byte, 6, 10) // 其中10为cap值,即底层数组长度,6为切片的初始长度
|
||||
|
||||
|
||||
如果没有在make中指定cap参数,那么底层数组长度cap就等于len,比如:
|
||||
|
||||
sl := make([]byte, 6) // cap = len = 6
|
||||
|
||||
|
||||
到这里,你肯定会有一个问题,为什么上面图中nums切片的底层数组长度为12,而不是初始的len值6呢?你可以先自己思考一下,我们在后面再细讲。
|
||||
|
||||
方法二:采用array[low : high : max]语法基于一个已存在的数组创建切片。这种方式被称为数组的切片化,比如下面代码:
|
||||
|
||||
arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
sl := arr[3:7:9]
|
||||
|
||||
|
||||
我们基于数组arr创建了一个切片sl,这个切片sl在运行时中的表示是这样:
|
||||
|
||||
|
||||
|
||||
我们看到,基于数组创建的切片,它的起始元素从low所标识的下标值开始,切片的长度(len)是high - low,它的容量是max - low。而且,由于切片sl的底层数组就是数组arr,对切片sl中元素的修改将直接影响数组arr变量。比如,如果我们将切片的第一个元素加10,那么数组arr的第四个元素将变为14:
|
||||
|
||||
sl[0] += 10
|
||||
fmt.Println("arr[3] =", arr[3]) // 14
|
||||
|
||||
|
||||
这样看来,切片好比打开了一个访问与修改数组的“窗口”,通过这个窗口,我们可以直接操作底层数组中的部分元素。这有些类似于我们操作文件之前打开的“文件描述符”(Windows上称为句柄),通过文件描述符我们可以对底层的真实文件进行相关操作。可以说,切片之于数组就像是文件描述符之于文件。
|
||||
|
||||
在Go语言中,数组更多是“退居幕后”,承担的是底层存储空间的角色。切片就是数组的“描述符”,也正是因为这一特性,切片才能在函数参数传递时避免较大性能开销。因为我们传递的并不是数组本身,而是数组的“描述符”,而这个描述符的大小是固定的(见上面的三元组结构),无论底层的数组有多大,切片打开的“窗口”长度有多长,它都是不变的。此外,我们在进行数组切片化的时候,通常省略max,而max的默认值为数组的长度。
|
||||
|
||||
另外,针对一个已存在的数组,我们还可以建立多个操作数组的切片,这些切片共享同一底层数组,切片对底层数组的操作也同样会反映到其他切片中。下面是为数组arr建立的两个切片的内存表示:
|
||||
|
||||
|
||||
|
||||
我们看到,上图中的两个切片sl1和sl2是数组arr的“描述符”,这样的情况下,无论我们通过哪个切片对数组进行的修改操作,都会反映到另一个切片中。比如,将sl2[2]置为14,那么sl1[0]也会变成14,因为sl2[2]直接操作的是底层数组arr的第四个元素arr[3]。
|
||||
|
||||
方法三:基于切片创建切片。
|
||||
|
||||
不过这种切片的运行时表示原理与上面的是一样的,我这里就不多分析了,你可以自己看一下。
|
||||
|
||||
最后,我们回答一下前面切片变量nums在进行一次append操作后切片容量变为12的问题。这里我们要清楚一个概念:切片与数组最大的不同,就在于其长度的不定长,这种不定长需要Go运行时提供支持,这种支持就是切片的“动态扩容”。
|
||||
|
||||
切片的动态扩容
|
||||
|
||||
“动态扩容”指的就是,当我们通过append操作向切片追加数据的时候,如果这时切片的len值和cap值是相等的,也就是说切片底层数组已经没有空闲空间再来存储追加的值了,Go运行时就会对这个切片做扩容操作,来保证切片始终能存储下追加的新值。
|
||||
|
||||
前面的切片变量nums之所以可以存储下新追加的值,就是因为Go对其进行了动态扩容,也就是重新分配了其底层数组,从一个长度为6的数组变成了一个长为12的数组。
|
||||
|
||||
接下来,我们再通过一个例子来体会一下切片动态扩容的过程:
|
||||
|
||||
var s []int
|
||||
s = append(s, 11)
|
||||
fmt.Println(len(s), cap(s)) //1 1
|
||||
s = append(s, 12)
|
||||
fmt.Println(len(s), cap(s)) //2 2
|
||||
s = append(s, 13)
|
||||
fmt.Println(len(s), cap(s)) //3 4
|
||||
s = append(s, 14)
|
||||
fmt.Println(len(s), cap(s)) //4 4
|
||||
s = append(s, 15)
|
||||
fmt.Println(len(s), cap(s)) //5 8
|
||||
|
||||
|
||||
在这个例子中,我们看到,append会根据切片对底层数组容量的需求,对底层数组进行动态调整。具体我们一步步分析。
|
||||
|
||||
最开始,s初值为零值(nil),这个时候s没有“绑定”底层数组。我们先通过append操作向切片s添加一个元素11,这个时候,append会先分配底层数组u1(数组长度1),然后将s内部表示中的array指向u1,并设置len = 1, cap = 1;
|
||||
|
||||
接着,我们通过append操作向切片s再添加第二个元素12,这个时候len(s) = 1,cap(s) = 1,append判断底层数组剩余空间已经不能够满足添加新元素的要求了,于是它就创建了一个新的底层数组u2,长度为2(u1数组长度的2倍),并把u1中的元素拷贝到u2中,最后将s内部表示中的array指向u2,并设置len = 2, cap = 2;
|
||||
|
||||
然后,第三步,我们通过append操作向切片s添加了第三个元素13,这时len(s) = 2,cap(s) = 2,append判断底层数组剩余空间不能满足添加新元素的要求了,于是又创建了一个新的底层数组u3,长度为4(u2数组长度的2倍),并把u2中的元素拷贝到u3中,最后把s内部表示中的array指向u3,并设置len = 3, cap为u3数组长度,也就是4 ;
|
||||
|
||||
第四步,我们依然通过append操作向切片s添加第四个元素14,此时len(s) = 3, cap(s) = 4,append判断底层数组剩余空间可以满足添加新元素的要求,所以就把14放在下一个元素的位置(数组u3末尾),并把s内部表示中的len加1,变为4;
|
||||
|
||||
但我们的第五步又通过append操作,向切片s添加最后一个元素15,这时len(s) = 4,cap(s) = 4,append判断底层数组剩余空间又不够了,于是创建了一个新的底层数组u4,长度为8(u3数组长度的2倍),并将u3中的元素拷贝到u4中,最后将s内部表示中的array指向u4,并设置len = 5, cap为u4数组长度,也就是8。
|
||||
|
||||
到这里,这个动态扩容的过程就结束了。我们看到,append会根据切片的需要,在当前底层数组容量无法满足的情况下,动态分配新的数组,新数组长度会按一定规律扩展。在上面这段代码中,针对元素是int型的数组,新数组的容量是当前数组的2倍。新数组建立后,append会把旧数组中的数据拷贝到新数组中,之后新数组便成为了切片的底层数组,旧数组会被垃圾回收掉。
|
||||
|
||||
不过append操作的这种自动扩容行为,有些时候会给我们开发者带来一些困惑,比如基于一个已有数组建立的切片,一旦追加的数据操作触碰到切片的容量上限(实质上也是数组容量的上界),切片就会和原数组解除“绑定”,后续对切片的任何修改都不会反映到原数组中了。我们再来看这段代码:
|
||||
|
||||
u := [...]int{11, 12, 13, 14, 15}
|
||||
fmt.Println("array:", u) // [11, 12, 13, 14, 15]
|
||||
s := u[1:3]
|
||||
fmt.Printf("slice(len=%d, cap=%d): %v\n", len(s), cap(s), s) // [12, 13]
|
||||
s = append(s, 24)
|
||||
fmt.Println("after append 24, array:", u)
|
||||
fmt.Printf("after append 24, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)
|
||||
s = append(s, 25)
|
||||
fmt.Println("after append 25, array:", u)
|
||||
fmt.Printf("after append 25, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)
|
||||
s = append(s, 26)
|
||||
fmt.Println("after append 26, array:", u)
|
||||
fmt.Printf("after append 26, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)
|
||||
|
||||
s[0] = 22
|
||||
fmt.Println("after reassign 1st elem of slice, array:", u)
|
||||
fmt.Printf("after reassign 1st elem of slice, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)
|
||||
|
||||
|
||||
运行这段代码,我们得到这样的结果:
|
||||
|
||||
array: [11 12 13 14 15]
|
||||
slice(len=2, cap=4): [12 13]
|
||||
after append 24, array: [11 12 13 24 15]
|
||||
after append 24, slice(len=3, cap=4): [12 13 24]
|
||||
after append 25, array: [11 12 13 24 25]
|
||||
after append 25, slice(len=4, cap=4): [12 13 24 25]
|
||||
after append 26, array: [11 12 13 24 25]
|
||||
after append 26, slice(len=5, cap=8): [12 13 24 25 26]
|
||||
after reassign 1st elem of slice, array: [11 12 13 24 25]
|
||||
after reassign 1st elem of slice, slice(len=5, cap=8): [22 13 24 25 26]
|
||||
|
||||
|
||||
这里,在append 25之后,切片的元素已经触碰到了底层数组u的边界了。然后我们再append 26之后,append发现底层数组已经无法满足append的要求,于是新创建了一个底层数组(数组长度为cap(s)的2倍,即8),并将slice的元素拷贝到新数组中了。
|
||||
|
||||
在这之后,我们即便再修改切片的第一个元素值,原数组u的元素也不会发生改变了,因为这个时候切片s与数组u已经解除了“绑定关系”,s已经不再是数组u的“描述符”了。这种因切片的自动扩容而导致的“绑定关系”解除,有时候会成为你实践道路上的一个小陷阱,你一定要注意这一点。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了。这节课,我们讲解了Go语言的另一类常用数据类型,复合数据类型,并挑重点地讲解了其中最常使用的两种同构复合数据类型:数组和切片。
|
||||
|
||||
数组是一个固定长度的、由同构类型元素组成的连续序列。这种连续不仅仅是逻辑上的,Go编译器为数组类型变量分配的也是一整块可以容纳其所有元素的连续内存。而且,Go编译器为数组变量的初始化也提供了很多便利。当数组元素的类型也是数组类型时,会出现多维数组。我们只需要按照变量声明从左到右、按维度分层拆解,直到出现一元数组就好了。
|
||||
|
||||
但是,Go值传递的机制让数组在各个函数间传递起来比较“笨重”,开销较大,且开销随数组长度的增加而增加。为了解决这个问题,Go引入了切片这一不定长同构数据类型。
|
||||
|
||||
切片可以看成是数组的“描述符”,为数组打开了一个访问与修改的“窗口”。切片在Go运行时中被实现为一个“三元组(array, len, cap)”,其中的array是指向底层数组的指针,真正的数据都存储在这个底层数组中;len表示切片的长度;而cap则是切片底层数组的容量。我们可以为一个数组建立多个切片,这些切片由于共享同一个底层数组,因此我们通过任一个切片对数组的修改都会反映到其他切片中。
|
||||
|
||||
切片是不定长同构复合类型,这个不定长体现在Go运行时对它提供的动态扩容的支撑。当切片的cap值与len值相等时,如果再向切片追加数据,Go运行时会自动对切片的底层数组进行扩容,追加数据的操作不会失败。
|
||||
|
||||
在大多数场合,我们都会使用切片以替代数组,原因之一是切片作为数组“描述符”的轻量性,无论它绑定的底层数组有多大,传递这个切片花费的开销都是恒定可控的;另外一个原因是切片相较于数组指针也是有优势的,切片可以提供比指针更为强大的功能,比如下标访问、边界溢出校验、动态扩容等。而且,指针本身在Go语言中的功能也受到的限制,比如不支持指针算术运算。
|
||||
|
||||
思考题
|
||||
|
||||
今天的思考题,我想你请描述一下下面这两个切片变量sl1与sl2的差异。期待在留言区看到你的回答。
|
||||
|
||||
var sl1 []int
|
||||
var sl2 = []int{}
|
||||
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言中的复合数据类型感兴趣的朋友。我是Tony Bai ,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
550
专栏/TonyBai·Go语言第一课/16复合数据类型:原生map类型的实现机制是怎样的?.md
Normal file
550
专栏/TonyBai·Go语言第一课/16复合数据类型:原生map类型的实现机制是怎样的?.md
Normal file
@@ -0,0 +1,550 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 复合数据类型:原生map类型的实现机制是怎样的?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
上一节课,我们学习了Go语言中最常用的两个复合类型:数组与切片。它们代表一组连续存储的同构类型元素集合。不同的是,数组的长度是确定的,而切片,我们可以理解为一种“动态数组”,它的长度在运行时是可变的。
|
||||
|
||||
这一节课,我们会继续前面的脉络,学习另外一种日常Go编码中比较常用的复合类型,这种类型可以让你将一个值(Value)唯一关联到一个特定的键(Key)上,可以用于实现特定键值的快速查找与更新,这个复合数据类型就是map。很多中文Go编程语言类技术书籍都会将它翻译为映射、哈希表或字典,但在我的课程中,为了保持原汁原味,我就直接使用它的英文名,map。
|
||||
|
||||
map是我们既切片之后,学到的第二个由Go编译器与运行时联合实现的复合数据类型,它有着复杂的内部实现,但却提供了十分简单友好的开发者使用接口。这一节课,我将从map类型的定义,到它的使用,再到map内部实现机制,由浅到深地让你吃透map类型。
|
||||
|
||||
什么是map类型?
|
||||
|
||||
map是Go语言提供的一种抽象数据类型,它表示一组无序的键值对。在后面的讲解中,我们会直接使用key和value分别代表map的键和值。而且,map集合中每个key都是唯一的:
|
||||
|
||||
|
||||
|
||||
和切片类似,作为复合类型的map,它在Go中的类型表示也是由key类型与value类型组成的,就像下面代码:
|
||||
|
||||
map[key_type]value_type
|
||||
|
||||
|
||||
key与value的类型可以相同,也可以不同:
|
||||
|
||||
map[string]string // key与value元素的类型相同
|
||||
map[int]string // key与value元素的类型不同
|
||||
|
||||
|
||||
如果两个map类型的key元素类型相同,value元素类型也相同,那么我们可以说它们是同一个map类型,否则就是不同的map类型。
|
||||
|
||||
这里,我们要注意,map类型对value的类型没有限制,但是对key的类型却有严格要求,因为map类型要保证key的唯一性。Go语言中要求,key的类型必须支持“==”和“!=”两种比较操作符。
|
||||
|
||||
但是,在Go语言中,函数类型、map类型自身,以及切片只支持与nil的比较,而不支持同类型两个变量的比较。如果像下面代码这样,进行这些类型的比较,Go编译器将会报错:
|
||||
|
||||
s1 := make([]int, 1)
|
||||
s2 := make([]int, 2)
|
||||
f1 := func() {}
|
||||
f2 := func() {}
|
||||
m1 := make(map[int]string)
|
||||
m2 := make(map[int]string)
|
||||
println(s1 == s2) // 错误:invalid operation: s1 == s2 (slice can only be compared to nil)
|
||||
println(f1 == f2) // 错误:invalid operation: f1 == f2 (func can only be compared to nil)
|
||||
println(m1 == m2) // 错误:invalid operation: m1 == m2 (map can only be compared to nil)
|
||||
|
||||
|
||||
因此在这里,你一定要注意:函数类型、map类型自身,以及切片类型是不能作为map的key类型的。
|
||||
|
||||
知道如何表示一个map类型后,接下来,我们来看看如何声明和初始化一个map类型的变量。
|
||||
|
||||
map变量的声明和初始化
|
||||
|
||||
我们可以这样声明一个map变量:
|
||||
|
||||
var m map[string]int // 一个map[string]int类型的变量
|
||||
|
||||
|
||||
和切片类型变量一样,如果我们没有显式地赋予map变量初值,map类型变量的默认值为nil。
|
||||
|
||||
不过切片变量和map变量在这里也有些不同。初值为零值nil的切片类型变量,可以借助内置的append的函数进行操作,这种在Go语言中被称为“零值可用”。定义“零值可用”的类型,可以提升我们开发者的使用体验,我们不用再担心变量的初始状态是否有效。
|
||||
|
||||
但map类型,因为它内部实现的复杂性,无法“零值可用”。所以,如果我们对处于零值状态的map变量直接进行操作,就会导致运行时异常(panic),从而导致程序进程异常退出:
|
||||
|
||||
var m map[string]int // m = nil
|
||||
m["key"] = 1 // 发生运行时异常:panic: assignment to entry in nil map
|
||||
|
||||
|
||||
所以,我们必须对map类型变量进行显式初始化后才能使用。那我们怎样对map类型变量进行初始化呢?
|
||||
|
||||
和切片一样,为map类型变量显式赋值有两种方式:一种是使用复合字面值;另外一种是使用make这个预声明的内置函数。
|
||||
|
||||
方法一:使用复合字面值初始化map类型变量。
|
||||
|
||||
我们先来看这句代码:
|
||||
|
||||
m := map[int]string{}
|
||||
|
||||
|
||||
这里,我们显式初始化了map类型变量m。不过,你要注意,虽然此时map类型变量m中没有任何键值对,但变量m也不等同于初值为nil的map变量。这个时候,我们对m进行键值对的插入操作,不会引发运行时异常。
|
||||
|
||||
这里我们再看看怎么通过稍微复杂一些的复合字面值,对map类型变量进行初始化:
|
||||
|
||||
m1 := map[int][]string{
|
||||
1: []string{"val1_1", "val1_2"},
|
||||
3: []string{"val3_1", "val3_2", "val3_3"},
|
||||
7: []string{"val7_1"},
|
||||
}
|
||||
|
||||
type Position struct {
|
||||
x float64
|
||||
y float64
|
||||
}
|
||||
|
||||
m2 := map[Position]string{
|
||||
Position{29.935523, 52.568915}: "school",
|
||||
Position{25.352594, 113.304361}: "shopping-mall",
|
||||
Position{73.224455, 111.804306}: "hospital",
|
||||
}
|
||||
|
||||
|
||||
我们看到,上面代码虽然完成了对两个map类型变量m1和m2的显式初始化,但不知道你有没有发现一个问题,作为初值的字面值似乎有些“臃肿”。你看,作为初值的字面值采用了复合类型的元素类型,而且在编写字面值时还带上了各自的元素类型,比如作为map[int] []string值类型的[]string,以及作为map[Position]string的key类型的Position。
|
||||
|
||||
别急!针对这种情况,Go提供了“语法糖”。这种情况下,Go允许省略字面值中的元素类型。因为map类型表示中包含了key和value的元素类型,Go编译器已经有足够的信息,来推导出字面值中各个值的类型了。我们以m2为例,这里的显式初始化代码和上面变量m2的初始化代码是等价的:
|
||||
|
||||
m2 := map[Position]string{
|
||||
{29.935523, 52.568915}: "school",
|
||||
{25.352594, 113.304361}: "shopping-mall",
|
||||
{73.224455, 111.804306}: "hospital",
|
||||
}
|
||||
|
||||
|
||||
以后在无特殊说明的情况下,我们都将使用这种简化后的字面值初始化方式。
|
||||
|
||||
方法二:使用make为map类型变量进行显式初始化。
|
||||
|
||||
和切片通过make进行初始化一样,通过make的初始化方式,我们可以为map类型变量指定键值对的初始容量,但无法进行具体的键值对赋值,就像下面代码这样:
|
||||
|
||||
m1 := make(map[int]string) // 未指定初始容量
|
||||
m2 := make(map[int]string, 8) // 指定初始容量为8
|
||||
|
||||
|
||||
不过,map类型的容量不会受限于它的初始容量值,当其中的键值对数量超过初始容量后,Go运行时会自动增加map类型的容量,保证后续键值对的正常插入。
|
||||
|
||||
了解完map类型变量的声明与初始化后,我们就来看看,在日常开发中,map类型都有哪些基本操作和注意事项。
|
||||
|
||||
map的基本操作
|
||||
|
||||
针对一个map类型变量,我们可以进行诸如插入新键值对、获取当前键值对数量、查找特定键和读取对应值、删除键值对,以及遍历键值等操作。我们一个个来学习。
|
||||
|
||||
操作一:插入新键值对。
|
||||
|
||||
面对一个非nil的map类型变量,我们可以在其中插入符合map类型定义的任意新键值对。插入新键值对的方式很简单,我们只需要把value赋值给map中对应的key就可以了:
|
||||
|
||||
m := make(map[int]string)
|
||||
m[1] = "value1"
|
||||
m[2] = "value2"
|
||||
m[3] = "value3"
|
||||
|
||||
|
||||
而且,我们不需要自己判断数据有没有插入成功,因为Go会保证插入总是成功的。这里,Go运行时会负责map变量内部的内存管理,因此除非是系统内存耗尽,我们可以不用担心向map中插入新数据的数量和执行结果。
|
||||
|
||||
不过,如果我们插入新键值对的时候,某个key已经存在于map中了,那我们的插入操作就会用新值覆盖旧值:
|
||||
|
||||
m := map[string]int {
|
||||
"key1" : 1,
|
||||
"key2" : 2,
|
||||
}
|
||||
|
||||
m["key1"] = 11 // 11会覆盖掉"key1"对应的旧值1
|
||||
m["key3"] = 3 // 此时m为map[key1:11 key2:2 key3:3]
|
||||
|
||||
|
||||
从这段代码中你可以看到,map类型变量m在声明的同时就做了初始化,它的内部建立了两个键值对,其中就包含键key1。所以后面我们再给键key1进行赋值时,Go不会重新创建key1键,而是会用新值(11)把key1键对应的旧值(1)替换掉。
|
||||
|
||||
操作二:获取键值对数量。
|
||||
|
||||
如果我们在编码中,想知道当前map类型变量中已经建立了多少个键值对,那我们可以怎么做呢?和切片一样,map类型也可以通过内置函数len,获取当前变量已经存储的键值对数量:
|
||||
|
||||
m := map[string]int {
|
||||
"key1" : 1,
|
||||
"key2" : 2,
|
||||
}
|
||||
|
||||
fmt.Println(len(m)) // 2
|
||||
m["key3"] = 3
|
||||
fmt.Println(len(m)) // 3
|
||||
|
||||
|
||||
不过,这里要注意的是我们不能对map类型变量调用cap,来获取当前容量,这是map类型与切片类型的一个不同点。
|
||||
|
||||
操作三:查找和数据读取
|
||||
|
||||
和写入相比,map类型更多用在查找和数据读取场合。所谓查找,就是判断某个key是否存在于某个map中。有了前面向map插入键值对的基础,我们可能自然而然地想到,可以用下面代码去查找一个键并获得该键对应的值:
|
||||
|
||||
m := make(map[string]int)
|
||||
v := m["key1"]
|
||||
|
||||
|
||||
乍一看,第二行代码在语法上好像并没有什么不当之处,但其实通过这行语句,我们还是无法确定键key1是否真实存在于map中。这是因为,当我们尝试去获取一个键对应的值的时候,如果这个键在map中并不存在,我们也会得到一个值,这个值是value元素类型的零值。
|
||||
|
||||
我们以上面这个代码为例,如果键key1在map中并不存在,那么v的值就会被赋予value元素类型int的零值,也就是0。所以我们无法通过v值判断出,究竟是因为key1不存在返回的零值,还是因为key1本身对应的value就是0。
|
||||
|
||||
那么在map中查找key的正确姿势是什么呢?Go语言的map类型支持通过用一种名为“comma ok”的惯用法,进行对某个key的查询。接下来我们就用“comma ok”惯用法改造一下上面的代码:
|
||||
|
||||
m := make(map[string]int)
|
||||
v, ok := m["key1"]
|
||||
if !ok {
|
||||
// "key1"不在map中
|
||||
}
|
||||
|
||||
// "key1"在map中,v将被赋予"key1"键对应的value
|
||||
|
||||
|
||||
我们看到,这里我们通过了一个布尔类型变量ok,来判断键“key1”是否存在于map中。如果存在,变量v就会被正确地赋值为键“key1”对应的value。
|
||||
|
||||
不过,如果我们并不关心某个键对应的value,而只关心某个键是否在于map中,我们可以使用空标识符替代变量v,忽略可能返回的value:
|
||||
|
||||
m := make(map[string]int)
|
||||
_, ok := m["key1"]
|
||||
... ...
|
||||
|
||||
|
||||
因此,你一定要记住:在Go语言中,请使用“comma ok”惯用法对map进行键查找和键值读取操作。
|
||||
|
||||
操作四:删除数据。
|
||||
|
||||
接下来,我们再看看看如何从map中删除某个键值对。在Go中,我们需要借助内置函数delete来从map中删除数据。使用delete函数的情况下,传入的第一个参数是我们的map类型变量,第二个参数就是我们想要删除的键。我们可以看看这个代码示例:
|
||||
|
||||
m := map[string]int {
|
||||
"key1" : 1,
|
||||
"key2" : 2,
|
||||
}
|
||||
|
||||
fmt.Println(m) // map[key1:1 key2:2]
|
||||
delete(m, "key2") // 删除"key2"
|
||||
fmt.Println(m) // map[key1:1]
|
||||
|
||||
|
||||
这里要注意的是,delete函数是从map中删除键的唯一方法。即便传给delete的键在map中并不存在,delete函数的执行也不会失败,更不会抛出运行时的异常。
|
||||
|
||||
操作五:遍历map中的键值数据
|
||||
|
||||
最后,我们来说一下如何遍历map中的键值数据。这一点虽然不像查询和读取操作那么常见,但日常开发中我们还是有这个需求的。在Go中,遍历map的键值对只有一种方法,那就是像对待切片那样通过for range语句对map数据进行遍历。我们看一个例子:
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
m := map[int]int{
|
||||
1: 11,
|
||||
2: 12,
|
||||
3: 13,
|
||||
}
|
||||
|
||||
fmt.Printf("{ ")
|
||||
for k, v := range m {
|
||||
fmt.Printf("[%d, %d] ", k, v)
|
||||
}
|
||||
fmt.Printf("}\n")
|
||||
}
|
||||
|
||||
|
||||
你看,通过for range遍历map变量m,每次迭代都会返回一个键值对,其中键存在于变量k中,它对应的值存储在变量v中。我们可以运行一下这段代码,可以得到符合我们预期的结果:
|
||||
|
||||
{ [1, 11] [2, 12] [3, 13] }
|
||||
|
||||
|
||||
如果我们只关心每次迭代的键,我们可以使用下面的方式对map进行遍历:
|
||||
|
||||
for k, _ := range m {
|
||||
// 使用k
|
||||
}
|
||||
|
||||
|
||||
当然更地道的方式是这样的:
|
||||
|
||||
for k := range m {
|
||||
// 使用k
|
||||
}
|
||||
|
||||
|
||||
如果我们只关心每次迭代返回的键所对应的value,我们同样可以通过空标识符替代变量k,就像下面这样:
|
||||
|
||||
for _, v := range m {
|
||||
// 使用v
|
||||
}
|
||||
|
||||
|
||||
不过,前面map遍历的输出结果都非常理想,给我们的表象好像是迭代器按照map中元素的插入次序逐一遍历。那事实是不是这样呢?我们再来试试,多遍历几次这个map看看。
|
||||
|
||||
我们先来改造一下代码:
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func doIteration(m map[int]int) {
|
||||
fmt.Printf("{ ")
|
||||
for k, v := range m {
|
||||
fmt.Printf("[%d, %d] ", k, v)
|
||||
}
|
||||
fmt.Printf("}\n")
|
||||
}
|
||||
|
||||
func main() {
|
||||
m := map[int]int{
|
||||
1: 11,
|
||||
2: 12,
|
||||
3: 13,
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
doIteration(m)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
运行一下上述代码,我们可以得到这样结果:
|
||||
|
||||
{ [3, 13] [1, 11] [2, 12] }
|
||||
{ [1, 11] [2, 12] [3, 13] }
|
||||
{ [3, 13] [1, 11] [2, 12] }
|
||||
|
||||
|
||||
我们可以看到,对同一map做多次遍历的时候,每次遍历元素的次序都不相同。这是Go语言map类型的一个重要特点,也是很容易让Go初学者掉入坑中的一个地方。所以这里你一定要记住:程序逻辑千万不要依赖遍历map所得到的的元素次序。
|
||||
|
||||
从我们前面的讲解,你应该也感受到了,map类型非常好用,那么,我们在各个函数方法间传递map变量会不会有很大开销呢?
|
||||
|
||||
map变量的传递开销
|
||||
|
||||
其实你不用担心开销的问题。
|
||||
|
||||
和切片类型一样,map也是引用类型。这就意味着map类型变量作为参数被传递给函数或方法的时候,实质上传递的只是一个“描述符”(后面我们再讲这个描述符究竟是什么),而不是整个map的数据拷贝,所以这个传递的开销是固定的,而且也很小。
|
||||
|
||||
并且,当map变量被传递到函数或方法内部后,我们在函数内部对map类型参数的修改在函数外部也是可见的。比如你从这个示例中就可以看到,函数foo中对map类型变量m进行了修改,而这些修改在foo函数外也可见。
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func foo(m map[string]int) {
|
||||
m["key1"] = 11
|
||||
m["key2"] = 12
|
||||
}
|
||||
|
||||
func main() {
|
||||
m := map[string]int{
|
||||
"key1": 1,
|
||||
"key2": 2,
|
||||
}
|
||||
|
||||
fmt.Println(m) // map[key1:1 key2:2]
|
||||
foo(m)
|
||||
fmt.Println(m) // map[key1:11 key2:12]
|
||||
}
|
||||
|
||||
|
||||
map的内部实现
|
||||
|
||||
和切片相比,map类型的内部实现要更加复杂。Go运行时使用一张哈希表来实现抽象的map类型。运行时实现了map类型操作的所有功能,包括查找、插入、删除等。在编译阶段,Go编译器会将Go语法层面的map操作,重写成运行时对应的函数调用。大致的对应关系是这样的:
|
||||
|
||||
// 创建map类型变量实例
|
||||
m := make(map[keyType]valType, capacityhint) → m := runtime.makemap(maptype, capacityhint, m)
|
||||
|
||||
// 插入新键值对或给键重新赋值
|
||||
m["key"] = "value" → v := runtime.mapassign(maptype, m, "key") v是用于后续存储value的空间的地址
|
||||
|
||||
// 获取某键的值
|
||||
v := m["key"] → v := runtime.mapaccess1(maptype, m, "key")
|
||||
v, ok := m["key"] → v, ok := runtime.mapaccess2(maptype, m, "key")
|
||||
|
||||
// 删除某键
|
||||
delete(m, "key") → runtime.mapdelete(maptype, m, “key”)
|
||||
|
||||
|
||||
这是map类型在Go运行时层实现的示意图:
|
||||
|
||||
|
||||
|
||||
我们可以看到,和切片的运行时表示图相比,map的实现示意图显然要复杂得多。接下来,我们结合这张图来简要描述一下map在运行时层的实现原理。我们重点讲解一下一个map变量在初始状态、进行键值对操作后,以及在并发场景下的Go运行时层的实现原理。
|
||||
|
||||
初始状态
|
||||
|
||||
从图中我们可以看到,与语法层面 map 类型变量(m)一一对应的是*runtime.hmap 的实例,即runtime.hmap类型的指针,也就是我们前面在讲解 map 类型变量传递开销时提到的 map 类型的描述符。hmap 类型是 map 类型的头部结构(header),它存储了后续 map 类型操作所需的所有信息,包括:
|
||||
|
||||
|
||||
|
||||
真正用来存储键值对数据的是桶,也就是bucket,每个bucket中存储的是Hash值低bit位数值相同的元素,默认的元素个数为 BUCKETSIZE(值为 8,Go 1.17版本中在$GOROOT/src/cmd/compile/internal/reflectdata/reflect.go中定义,与 runtime/map.go 中常量 bucketCnt 保持一致)。
|
||||
|
||||
当某个bucket(比如buckets[0])的8个空槽slot)都填满了,且map尚未达到扩容的条件的情况下,运行时会建立overflow bucket,并将这个overflow bucket挂在上面bucket(如buckets[0])末尾的overflow指针上,这样两个buckets形成了一个链表结构,直到下一次map扩容之前,这个结构都会一直存在。
|
||||
|
||||
从图中我们可以看到,每个bucket由三部分组成,从上到下分别是tophash区域、key存储区域和value存储区域。
|
||||
|
||||
|
||||
tophash区域
|
||||
|
||||
|
||||
当我们向map插入一条数据,或者是从map按key查询数据的时候,运行时都会使用哈希函数对key做哈希运算,并获得一个哈希值(hashcode)。这个hashcode非常关键,运行时会把hashcode“一分为二”来看待,其中低位区的值用于选定bucket,高位区的值用于在某个bucket中确定key的位置。我把这一过程整理成了下面这张示意图,你理解起来可以更直观:
|
||||
|
||||
|
||||
|
||||
因此,每个bucket的tophash区域其实是用来快速定位key位置的,这样就避免了逐个key进行比较这种代价较大的操作。尤其是当key是size较大的字符串类型时,好处就更突出了。这是一种以空间换时间的思路。
|
||||
|
||||
|
||||
key存储区域
|
||||
|
||||
|
||||
接着,我们看tophash区域下面是一块连续的内存区域,存储的是这个bucket承载的所有key数据。运行时在分配bucket的时候需要知道key的Size。那么运行时是如何知道key的size的呢?
|
||||
|
||||
当我们声明一个map类型变量,比如var m map[string]int时,Go运行时就会为这个变量对应的特定map类型,生成一个runtime.maptype实例。如果这个实例已经存在,就会直接复用。maptype实例的结构是这样的:
|
||||
|
||||
type maptype struct {
|
||||
typ _type
|
||||
key *_type
|
||||
elem *_type
|
||||
bucket *_type // internal type representing a hash bucket
|
||||
keysize uint8 // size of key slot
|
||||
elemsize uint8 // size of elem slot
|
||||
bucketsize uint16 // size of bucket
|
||||
flags uint32
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,这个实例包含了我们需要的map类型中的所有”元信息”。我们前面提到过,编译器会把语法层面的map操作重写成运行时对应的函数调用,这些运行时函数都有一个共同的特点,那就是第一个参数都是maptype指针类型的参数。
|
||||
|
||||
Go运行时就是利用maptype参数中的信息确定key的类型和大小的。map所用的hash函数也存放在maptype.key.alg.hash(key, hmap.hash0)中。同时maptype的存在也让Go中所有map类型都共享一套运行时map操作函数,而不是像C++那样为每种map类型创建一套map操作函数,这样就节省了对最终二进制文件空间的占用。
|
||||
|
||||
|
||||
value存储区域
|
||||
|
||||
|
||||
我们再接着看key存储区域下方的另外一块连续的内存区域,这个区域存储的是key对应的value。和key一样,这个区域的创建也是得到了maptype中信息的帮助。Go运行时采用了把key和value分开存储的方式,而不是采用一个kv接着一个kv的kv紧邻方式存储,这带来的其实是算法上的复杂性,但却减少了因内存对齐带来的内存浪费。
|
||||
|
||||
我们以map[int8]int64为例,看看下面的存储空间利用率对比图:
|
||||
|
||||
|
||||
|
||||
你会看到,当前Go运行时使用的方案内存利用效率很高,而kv紧邻存储的方案在map[int8]int64这样的例子中内存浪费十分严重,它的内存利用率是72/128=56.25%,有近一半的空间都浪费掉了。
|
||||
|
||||
另外,还有一点我要跟你强调一下,如果key或value的数据长度大于一定数值,那么运行时不会在bucket中直接存储数据,而是会存储key或value数据的指针。目前Go运行时定义的最大key和value的长度是这样的:
|
||||
|
||||
// $GOROOT/src/runtime/map.go
|
||||
const (
|
||||
maxKeySize = 128
|
||||
maxElemSize = 128
|
||||
)
|
||||
|
||||
|
||||
map扩容
|
||||
|
||||
我们前面提到过,map会对底层使用的内存进行自动管理。因此,在使用过程中,当插入元素个数超出一定数值后,map一定会存在自动扩容的问题,也就是怎么扩充bucket的数量,并重新在bucket间均衡分配数据的问题。
|
||||
|
||||
那么map在什么情况下会进行扩容呢?Go运行时的map实现中引入了一个LoadFactor(负载因子),当count > LoadFactor * 2^B或overflow bucket过多时,运行时会自动对map进行扩容。目前Go最新1.17版本LoadFactor设置为6.5(loadFactorNum/loadFactorDen)。这里是Go中与map扩容相关的部分源码:
|
||||
|
||||
// $GOROOT/src/runtime/map.go
|
||||
const (
|
||||
... ...
|
||||
|
||||
loadFactorNum = 13
|
||||
loadFactorDen = 2
|
||||
... ...
|
||||
)
|
||||
|
||||
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
|
||||
... ...
|
||||
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
|
||||
hashGrow(t, h)
|
||||
goto again // Growing the table invalidates everything, so try again
|
||||
}
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
这两方面原因导致的扩容,在运行时的操作其实是不一样的。如果是因为overflow bucket过多导致的“扩容”,实际上运行时会新建一个和现有规模一样的bucket数组,然后在assign和delete时做排空和迁移。
|
||||
|
||||
如果是因为当前数据数量超出LoadFactor指定水位而进行的扩容,那么运行时会建立一个两倍于现有规模的bucket数组,但真正的排空和迁移工作也是在assign和delete时逐步进行的。原bucket数组会挂在hmap的oldbuckets指针下面,直到原buckets数组中所有数据都迁移到新数组后,原buckets数组才会被释放。你可以结合下面的map扩容示意图来理解这个过程,这会让你理解得更深刻一些:
|
||||
|
||||
|
||||
|
||||
map与并发
|
||||
|
||||
接着我们来看一下map和并发。从上面的实现原理来看,充当map描述符角色的hmap实例自身是有状态的(hmap.flags),而且对状态的读写是没有并发保护的。所以说map实例不是并发写安全的,也不支持并发读写。如果我们对map实例进行并发读写,程序运行时就会抛出异常。你可以看看下面这个并发读写map的例子:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func doIteration(m map[int]int) {
|
||||
for k, v := range m {
|
||||
_ = fmt.Sprintf("[%d, %d] ", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func doWrite(m map[int]int) {
|
||||
for k, v := range m {
|
||||
m[k] = v + 1
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
m := map[int]int{
|
||||
1: 11,
|
||||
2: 12,
|
||||
3: 13,
|
||||
}
|
||||
|
||||
go func() {
|
||||
for i := 0; i < 1000; i++ {
|
||||
doIteration(m)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for i := 0; i < 1000; i++ {
|
||||
doWrite(m)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
|
||||
|
||||
运行这个示例程序,我们会得到下面的执行错误结果:
|
||||
|
||||
fatal error: concurrent map iteration and map write
|
||||
|
||||
|
||||
不过,如果我们仅仅是进行并发读,map是没有问题的。而且,Go 1.9版本中引入了支持并发写安全的sync.Map类型,可以在并发读写的场景下替换掉map。如果你有这方面的需求,可以查看一下sync.Map的手册。
|
||||
|
||||
另外,你要注意,考虑到map可以自动扩容,map中数据元素的value位置可能在这一过程中发生变化,所以Go不允许获取map中value的地址,这个约束是在编译期间就生效的。下面这段代码就展示了Go编译器识别出获取map中value地址的语句后,给出的编译错误:
|
||||
|
||||
p := &m[key] // cannot take the address of m[key]
|
||||
fmt.Println(p)
|
||||
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了。这一节课,我们讲解了Go语言的另一类十分常用的复合数据类型:map。
|
||||
|
||||
在Go语言中,map类型是一个无序的键值对的集合。它有两种类型元素,一类是键(key),另一类是值(value)。在一个map中,键是唯一的,在集合中不能有两个相同的键。Go也是通过这两种元素类型来表示一个map类型,你要记得这个通用的map类型表示:“map[key_type]value_type”。
|
||||
|
||||
map类型对key元素的类型是有约束的,它要求key元素的类型必须支持”==“和”!=“两个比较操作符。value元素的类型可以是任意的。
|
||||
|
||||
不过,map类型变量声明后必须对它进行初始化后才能操作。map类型支持插入新键值对、查找和数据读取、删除键值对、遍历map中的键值数据等操作,Go为开发者提供了十分简单的操作接口。这里要你重点记住的是,我们在查找和数据读取时一定要使用“comma ok”惯用法。此外,map变量在函数与方法间传递的开销很小,并且在函数内部通过map描述符对map的修改会对函数外部可见。
|
||||
|
||||
另外,map的内部实现要比切片复杂得多,它是由Go编译器与运行时联合实现的。Go编译器在编译阶段会将语法层面的map操作,重写为运行时对应的函数调用。Go运行时则采用了高效的算法实现了map类型的各类操作,这里我建议你要结合Go项目源码来理解map的具体实现。
|
||||
|
||||
和切片一样,map是Go语言提供的重要数据类型,也是Gopher日常Go编码是最常使用的类型之一。我们在日常使用map的场合要把握住下面几个要点,不要走弯路:
|
||||
|
||||
|
||||
不要依赖map的元素遍历顺序;
|
||||
map不是线程安全的,不支持并发读写;
|
||||
不要尝试获取map中元素(value)的地址。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
通过上面的学习,我们知道对map类型进行遍历所得到的键的次序是随机的,那么我想请你思考并实现一个方法,让我们能对map的进行稳定次序遍历?期待在留言区看到你的想法。
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言map类型感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
550
专栏/TonyBai·Go语言第一课/17复合数据类型:用结构体建立对真实世界的抽象.md
Normal file
550
专栏/TonyBai·Go语言第一课/17复合数据类型:用结构体建立对真实世界的抽象.md
Normal file
@@ -0,0 +1,550 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 复合数据类型:用结构体建立对真实世界的抽象
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在前面的几节课中,我们一直在讲数据类型,包括Go基本数据类型和三个复合数据类型。我们可以用这些数据类型来建立对真实世界的抽象。
|
||||
|
||||
那么什么是对真实世界的抽象呢?我们编写程序的目的就是与真实世界交互,解决真实世界的问题,帮助真实世界提高运行效率与改善运行质量。所以我们就需要对真实世界事物体的重要属性进行提炼,并映射到程序世界中,这就是所谓的对真实世界的抽象。
|
||||
|
||||
不同的数据类型具有不同的抽象能力,比如整数类型int可以用来抽象一个真实世界物体的长度,string类型可以用来抽象真实世界物体的名字,等等。
|
||||
|
||||
但是光有这些类型的抽象能力还不够,我们还缺少一种通用的、对实体对象进行聚合抽象的能力。你可以回想一下,我们目前可以用学过的各种类型抽象出书名、书的页数以及书的索引,但有没有一种类型,可以抽象出聚合了上述属性的“书”这个实体对象呢?
|
||||
|
||||
有的。在Go中,提供这种聚合抽象能力的类型是结构体类型,也就是struct。这一节课,我们就围绕着结构体的使用和内存表示,由外及里来学习Go中的结构体类型。
|
||||
|
||||
不过,在学习如何定义一个结构体类型之前,我们首先要来看看如何在Go中自定义一个新类型。有了这个基础,我们再理解结构体类型的定义方法就十分自然了。
|
||||
|
||||
如何自定义一个新类型?
|
||||
|
||||
在Go中,我们自定义一个新类型一般有两种方法。第一种是类型定义(Type Definition),这也是我们最常用的类型定义方法。在这种方法中,我们会使用关键字type来定义一个新类型T,具体形式是这样的:
|
||||
|
||||
type T S // 定义一个新类型T
|
||||
|
||||
|
||||
在这里,S可以是任何一个已定义的类型,包括Go原生类型,或者是其他已定义的自定义类型,我们来演示一下这两种情况:
|
||||
|
||||
type T1 int
|
||||
type T2 T1
|
||||
|
||||
|
||||
这段代码中,新类型T1是基于Go原生类型int定义的新自定义类型,而新类型T2则是基于刚刚定义的类型T1,定义的新类型。
|
||||
|
||||
这里我们引入一个新概念,底层类型。如果一个新类型是基于某个Go原生类型定义的,那么我们就叫Go原生类型为新类型的底层类型(Underlying Type)。比如这个例子中,类型int就是类型T1的底层类型。
|
||||
|
||||
那如果不是基于Go原生类型定义的新类型,比如T2,它的底层类型是什么呢?这时我们就要看它定义时是基于什么类型了。这里,T2是基于T1类型创建的,那么T2类型的底层类型就是T1的底层类型,而T1的底层类型我们已经知道了,是类型int,那么T2的底层类型也是类型int。
|
||||
|
||||
为什么我们要提到底层类型这个概念呢?因为底层类型在Go语言中有重要作用,它被用来判断两个类型本质上是否相同(Identical)。
|
||||
|
||||
在上面例子中,虽然T1和T2是不同类型,但因为它们的底层类型都是类型int,所以它们在本质上是相同的。而本质上相同的两个类型,它们的变量可以通过显式转型进行相互赋值,相反,如果本质上是不同的两个类型,它们的变量间连显式转型都不可能,更不要说相互赋值了。
|
||||
|
||||
比如你可以看看这个代码示例:
|
||||
|
||||
type T1 int
|
||||
type T2 T1
|
||||
type T3 string
|
||||
|
||||
func main() {
|
||||
var n1 T1
|
||||
var n2 T2 = 5
|
||||
n1 = T1(n2) // ok
|
||||
|
||||
var s T3 = "hello"
|
||||
n1 = T1(s) // 错误:cannot convert s (type T3) to type T1
|
||||
}
|
||||
|
||||
|
||||
这段代码中,T1和T2本质上是相同的类型,所以我们可以将T2变量n2的值,通过显式转型赋值给T1类型变量n1。而类型T3的底层类型为类型string,与T1/T2的底层类型不同,所以它们本质上就不是相同的类型。这个时候,如果我们把T3类型变量s赋值给T1类型变量n1,编译器就会给出编译错误的提示。
|
||||
|
||||
除了基于已有类型定义新类型之外,我们还可以基于类型字面值来定义新类型,这种方式多用于自定义一个新的复合类型,比如:
|
||||
|
||||
type M map[int]string
|
||||
type S []string
|
||||
|
||||
|
||||
和变量声明支持使用var块的方式类似,类型定义也支持通过type块的方式进行,比如我们可以把上面代码中的T1、T2和T3的定义放在同一个type块中:
|
||||
|
||||
type (
|
||||
T1 int
|
||||
T2 T1
|
||||
T3 string
|
||||
)
|
||||
|
||||
|
||||
第二种自定义新类型的方式是使用类型别名(Type Alias),这种类型定义方式通常用在项目的渐进式重构,还有对已有包的二次封装方面,它的形式是这样的:
|
||||
|
||||
type T = S // type alias
|
||||
|
||||
|
||||
我们看到,与前面的第一种类型定义相比,类型别名的形式只是多了一个等号,但正是这个等号让新类型T与原类型S完全等价。完全等价的意思就是,类型别名并没有定义出新类型,T与S实际上就是同一种类型,它们只是一种类型的两个名字罢了,就像一个人有一个大名、一个小名一样。我们看下面这个简单的例子:
|
||||
|
||||
type T = string
|
||||
|
||||
var s string = "hello"
|
||||
var t T = s // ok
|
||||
fmt.Printf("%T\n", t) // string
|
||||
|
||||
|
||||
因为类型T是通过类型别名的方式定义的,T与string实际上是一个类型,所以这里,使用string类型变量s给T类型变量t赋值的动作,实质上就是同类型赋值。另外我们也可以看到,通过Printf输出的变量t的类型信息也是string,这和我们的预期也是一致的。
|
||||
|
||||
学习了两种新类型的自定义方法后,我们再来看一下如何定义一个结构体类型。
|
||||
|
||||
如何定义一个结构体类型?
|
||||
|
||||
我们前面说了,复合类型的定义一般都是通过类型字面值的方式来进行的,作为复合类型之一的结构体类型也不例外,下面就是一个典型的结构体类型的定义形式:
|
||||
|
||||
type T struct {
|
||||
Field1 T1
|
||||
Field2 T2
|
||||
... ...
|
||||
FieldN Tn
|
||||
}
|
||||
|
||||
|
||||
根据这个定义,我们会得到一个名为T的结构体类型,定义中struct关键字后面的大括号包裹的内容就是一个类型字面值。我们看到这个类型字面值由若干个字段(field)聚合而成,每个字段有自己的名字与类型,并且在一个结构体中,每个字段的名字应该都是唯一的。
|
||||
|
||||
通过聚合其他类型字段,结构体类型展现出强大而灵活的抽象能力。我们直接上案例实操,来说明一下。
|
||||
|
||||
我们前面提到过对现实世界的书进行抽象的情况,其实用结构体类型就可以实现,比如这里,我就用前面的典型方法定义了一个结构体:
|
||||
|
||||
package book
|
||||
|
||||
type Book struct {
|
||||
Title string // 书名
|
||||
Pages int // 书的页数
|
||||
Indexes map[string]int // 书的索引
|
||||
}
|
||||
|
||||
|
||||
在这个结构体定义中,你会发现,我在类型Book,还有它的各个字段中都用了首字母大写的名字。这是为什么呢?
|
||||
|
||||
你回忆一下,我们在第11讲中曾提到过,Go用标识符名称的首字母大小写来判定这个标识符是否为导出标识符。所以,这里的类型Book以及它的各个字段都是导出标识符。这样,只要其他包导入了包book,我们就可以在这些包中直接引用类型名Book,也可以通过Book类型变量引用Name、Pages等字段,就像下面代码中这样:
|
||||
|
||||
import ".../book"
|
||||
|
||||
var b book.Book
|
||||
b.Title = "The Go Programming Language"
|
||||
b.Pages = 800
|
||||
|
||||
|
||||
如果结构体类型只在它定义的包内使用,那么我们可以将类型名的首字母小写;如果你不想将结构体类型中的某个字段暴露给其他包,那么我们同样可以把这个字段名字的首字母小写。
|
||||
|
||||
我们还可以用空标识符“_”作为结构体类型定义中的字段名称。这样以空标识符为名称的字段,不能被外部包引用,甚至无法被结构体所在的包使用。那这么做有什么实际意义呢?这里先留个悬念,你可以自己先思考一下,我们在后面讲解结构体类型的内存布局时,会揭晓答案。
|
||||
|
||||
除了通过类型字面值来定义结构体这种典型操作外,我们还有另外几种特殊的情况。
|
||||
|
||||
第一种:定义一个空结构体。
|
||||
|
||||
我们可以定义一个空结构体,也就是没有包含任何字段的结构体类型,就像下面示例代码这样:
|
||||
|
||||
type Empty struct{} // Empty是一个不包含任何字段的空结构体类型
|
||||
|
||||
|
||||
空结构体类型有什么用呢?我们继续看下面代码:
|
||||
|
||||
var s Empty
|
||||
println(unsafe.Sizeof(s)) // 0
|
||||
|
||||
|
||||
我们看到,输出的空结构体类型变量的大小为0,也就是说,空结构体类型变量的内存占用为0。基于空结构体类型内存零开销这样的特性,我们在日常Go开发中会经常使用空结构体类型元素,作为一种“事件”信息进行Goroutine之间的通信,就像下面示例代码这样:
|
||||
|
||||
var c = make(chan Empty) // 声明一个元素类型为Empty的channel
|
||||
c<-Empty{} // 向channel写入一个“事件”
|
||||
|
||||
|
||||
这种以空结构体为元素类建立的channel,是目前能实现的、内存占用最小的Goroutine间通信方式。
|
||||
|
||||
第二种情况:使用其他结构体作为自定义结构体中字段的类型。
|
||||
|
||||
我们看这段代码,这里结构体类型Book的字段Author的类型,就是另外一个结构体类型Person:
|
||||
|
||||
type Person struct {
|
||||
Name string
|
||||
Phone string
|
||||
Addr string
|
||||
}
|
||||
|
||||
type Book struct {
|
||||
Title string
|
||||
Author Person
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
如果我们要访问Book结构体字段Author中的Phone字段,我们可以这样操作:
|
||||
|
||||
var book Book
|
||||
println(book.Author.Phone)
|
||||
|
||||
|
||||
不过,对于包含结构体类型字段的结构体类型来说,Go还提供了一种更为简便的定义方法,那就是我们可以无需提供字段的名字,只需要使用其类型就可以了,以上面的Book结构体定义为例,我们可以用下面的方式提供一个等价的定义:
|
||||
|
||||
type Book struct {
|
||||
Title string
|
||||
Person
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
以这种方式定义的结构体字段,我们叫做嵌入字段(Embedded Field)。我们也可以将这种字段称为匿名字段,或者把类型名看作是这个字段的名字。如果我们要访问Person中的Phone字段,我们可以通过下面两种方式进行:
|
||||
|
||||
var book Book
|
||||
println(book.Person.Phone) // 将类型名当作嵌入字段的名字
|
||||
println(book.Phone) // 支持直接访问嵌入字段所属类型中字段
|
||||
|
||||
|
||||
第一种方式显然是通过把类型名当作嵌入字段的名字来进行操作的,而第二种方式更像是一种“语法糖”,我们可以“绕过”Person类型这一层,直接访问Person中的字段。关于这种“类型嵌入”特性,我们在以后的课程中还会详细说明,这里就先不深入了。
|
||||
|
||||
不过,看到这里,关于结构体定义,你可能还有一个疑问,在结构体类型T的定义中是否可以包含类型为T的字段呢?比如这样:
|
||||
|
||||
type T struct {
|
||||
t T
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
答案是不可以的。Go语言不支持这种在结构体类型定义中,递归地放入其自身类型字段的定义方式。面对上面的示例代码,编译器就会给出“invalid recursive type T”的错误信息。
|
||||
|
||||
同样,下面这两个结构体类型T1与T2的定义也存在递归的情况,所以这也是不合法的。
|
||||
|
||||
type T1 struct {
|
||||
t2 T2
|
||||
}
|
||||
|
||||
type T2 struct {
|
||||
t1 T1
|
||||
}
|
||||
|
||||
|
||||
不过,虽然我们不能在结构体类型T定义中,拥有以自身类型T定义的字段,但我们却可以拥有自身类型的指针类型、以自身类型为元素类型的切片类型,以及以自身类型作为value类型的map类型的字段,比如这样:
|
||||
|
||||
type T struct {
|
||||
t *T // ok
|
||||
st []T // ok
|
||||
m map[string]T // ok
|
||||
}
|
||||
|
||||
|
||||
你知道为什么这样的定义是合法的吗?我想把这个问题作为这节课的课后思考题留给你,你可以在留言区说一下你的想法。
|
||||
|
||||
关于结构体类型的知识我们已经学习得差不多了,接下来我们再来看看如何应用这些结构体类型来声明变量,并进行初始化。
|
||||
|
||||
结构体变量的声明与初始化
|
||||
|
||||
和其他所有变量的声明一样,我们也可以使用标准变量声明语句,或者是短变量声明语句声明一个结构体类型的变量:
|
||||
|
||||
type Book struct {
|
||||
...
|
||||
}
|
||||
|
||||
var book Book
|
||||
var book = Book{}
|
||||
book := Book{}
|
||||
|
||||
|
||||
不过,这里要注意,我们在前面说过,结构体类型通常是对真实世界复杂事物的抽象,这和简单的数值、字符串、数组/切片等类型有所不同,结构体类型的变量通常都要被赋予适当的初始值后,才会有合理的意义。
|
||||
|
||||
接下来,我把结构体类型变量的初始化大致分为三种情况,我们逐一看一下。
|
||||
|
||||
零值初始化
|
||||
|
||||
零值初始化说的是使用结构体的零值作为它的初始值。在前面的课程中,“零值”这个术语反复出现过多次,它指的是一个类型的默认值。对于Go原生类型来说,这个默认值也称为零值。Go结构体类型由若干个字段组成,当这个结构体类型变量的各个字段的值都是零值时,我们就说这个结构体类型变量处于零值状态。
|
||||
|
||||
前面提到过,结构体类型的零值变量,通常不具有或者很难具有合理的意义,比如通过下面代码得到的零值book变量就是这样:
|
||||
|
||||
var book Book // book为零值结构体变量
|
||||
|
||||
|
||||
你想象一下,一本书既没有书名,也没有作者、页数、索引等信息,那么通过Book类型对这本书的抽象就失去了实际价值。所以对于像Book这样的结构体类型,使用零值初始化并不是正确的选择。
|
||||
|
||||
那么采用零值初始化的零值结构体变量就真的没有任何价值了吗?恰恰相反。如果一种类型采用零值初始化得到的零值变量,是有意义的,而且是直接可用的,我称这种类型为“零值可用”类型。可以说,定义零值可用类型是简化代码、改善开发者使用体验的一种重要的手段。
|
||||
|
||||
在Go语言标准库和运行时的代码中,有很多践行“零值可用”理念的好例子,最典型的莫过于sync包的Mutex类型了。Mutex是Go标准库中提供的、用于多个并发Goroutine之间进行同步的互斥锁。
|
||||
|
||||
运用“零值可用”类型,给Go语言中的线程互斥锁带来了什么好处呢?我们横向对比一下C语言中的做法你就知道了。如果我们要在C语言中使用线程互斥锁,我们通常需要这么做:
|
||||
|
||||
pthread_mutex_t mutex;
|
||||
pthread_mutex_init(&mutex, NULL);
|
||||
|
||||
pthread_mutex_lock(&mutex);
|
||||
... ...
|
||||
pthread_mutex_unlock(&mutex);
|
||||
|
||||
|
||||
我们可以看到,在C中使用互斥锁,我们需要首先声明一个mutex变量。但这个时候,我们不能直接使用声明过的变量,因为它的零值状态是不可用的,我们必须使用pthread_mutex_init函数对其进行专门的初始化操作后,它才能处于可用状态。再之后,我们才能进行lock与unlock操作。
|
||||
|
||||
但是在Go语言中,我们只需要这几行代码就可以了:
|
||||
|
||||
var mu sync.Mutex
|
||||
mu.Lock()
|
||||
mu.Unlock()
|
||||
|
||||
|
||||
Go标准库的设计者很贴心地将sync.Mutex结构体的零值状态,设计为可用状态,这样开发者便可直接基于零值状态下的Mutex进行lock与unlock操作,而且不需要额外显式地对它进行初始化操作了。
|
||||
|
||||
Go标准库中的bytes.Buffer结构体类型,也是一个零值可用类型的典型例子,这里我演示了bytes.Buffer类型的常规用法:
|
||||
|
||||
var b bytes.Buffer
|
||||
b.Write([]byte("Hello, Go"))
|
||||
fmt.Println(b.String()) // 输出:Hello, Go
|
||||
|
||||
|
||||
你可以看到,我们不需要对bytes.Buffer类型的变量b进行任何显式初始化,就可以直接通过处于零值状态的变量b,调用它的方法进行写入和读取操作。
|
||||
|
||||
不过有些类型确实不能设计为零值可用类型,就比如我们前面的Book类型,它们的零值并非有效值。对于这类类型,我们需要对它的变量进行显式的初始化后,才能正确使用。在日常开发中,对结构体类型变量进行显式初始化的最常用方法就是使用复合字面值,下面我们就来看看这种方法。
|
||||
|
||||
使用复合字面值
|
||||
|
||||
其实我们已经不是第一次接触复合字面值了,之前我们讲解数组/切片、map类型变量的变量初始化的时候,都提到过用复合字面值的方法。
|
||||
|
||||
最简单的对结构体变量进行显式初始化的方式,就是按顺序依次给每个结构体字段进行赋值,比如下面的代码:
|
||||
|
||||
type Book struct {
|
||||
Title string // 书名
|
||||
Pages int // 书的页数
|
||||
Indexes map[string]int // 书的索引
|
||||
}
|
||||
|
||||
var book = Book{"The Go Programming Language", 700, make(map[string]int)}
|
||||
|
||||
|
||||
我们依然可以用这种方法给结构体的每一个字段依次赋值,但这种方法也有很多问题:
|
||||
|
||||
首先,当结构体类型定义中的字段顺序发生变化,或者字段出现增删操作时,我们就需要手动调整该结构体类型变量的显式初始化代码,让赋值顺序与调整后的字段顺序一致。
|
||||
|
||||
其次,当一个结构体的字段较多时,这种逐一字段赋值的方式实施起来就会比较困难,而且容易出错,开发人员需要来回对照结构体类型中字段的类型与顺序,谨慎编写字面值表达式。
|
||||
|
||||
最后,一旦结构体中包含非导出字段,那么这种逐一字段赋值的方式就不再被支持了,编译器会报错:
|
||||
|
||||
type T struct {
|
||||
F1 int
|
||||
F2 string
|
||||
f3 int
|
||||
F4 int
|
||||
F5 int
|
||||
}
|
||||
|
||||
var t = T{11, "hello", 13} // 错误:implicit assignment of unexported field 'f3' in T literal
|
||||
或
|
||||
var t = T{11, "hello", 13, 14, 15} // 错误:implicit assignment of unexported field 'f3' in T literal
|
||||
|
||||
|
||||
事实上,Go语言并不推荐我们按字段顺序对一个结构体类型变量进行显式初始化,甚至Go官方还在提供的go vet工具中专门内置了一条检查规则:“composites”,用来静态检查代码中结构体变量初始化是否使用了这种方法,一旦发现,就会给出警告。
|
||||
|
||||
那么我们应该用哪种形式的复合字面值给结构体变量赋初值呢?
|
||||
|
||||
Go推荐我们用“field:value”形式的复合字面值,对结构体类型变量进行显式初始化,这种方式可以降低结构体类型使用者和结构体类型设计者之间的耦合,这也是Go语言的惯用法。这里,我们用“field:value”形式复合字面值,对上面的类型T的变量进行初始化看看:
|
||||
|
||||
var t = T{
|
||||
F2: "hello",
|
||||
F1: 11,
|
||||
F4: 14,
|
||||
}
|
||||
|
||||
|
||||
我们看到,使用这种“field:value”形式的复合字面值对结构体类型变量进行初始化,非常灵活。和之前的顺序复合字面值形式相比,“field:value”形式字面值中的字段可以以任意次序出现。未显式出现在字面值中的结构体字段(比如上面例子中的F5)将采用它对应类型的零值。
|
||||
|
||||
复合字面值作为结构体类型变量初值被广泛使用,即便结构体采用类型零值时,我们也会使用复合字面值的形式:
|
||||
|
||||
t := T{}
|
||||
|
||||
|
||||
而比较少使用new这一个Go预定义的函数来创建结构体变量实例:
|
||||
|
||||
tp := new(T)
|
||||
|
||||
|
||||
这里值得我们注意的是,我们不能用从其他包导入的结构体中的未导出字段,来作为复合字面值中的field。这会导致编译错误,因为未导出字段是不可见的。
|
||||
|
||||
那么,如果一个结构体类型中包含未导出字段,并且这个字段的零值还不可用时,我们要如何初始化这个结构体类型的变量呢?又或是一个结构体类型中的某些字段,需要一个复杂的初始化逻辑,我们又该怎么做呢?这时我们就需要使用一个特定的构造函数,来创建并初始化结构体变量了。
|
||||
|
||||
使用特定的构造函数
|
||||
|
||||
其实,使用特定的构造函数创建并初始化结构体变量的例子,并不罕见。在Go标准库中就有很多,其中time.Timer这个结构体就是一个典型的例子,它的定义如下:
|
||||
|
||||
// $GOROOT/src/time/sleep.go
|
||||
type runtimeTimer struct {
|
||||
pp uintptr
|
||||
when int64
|
||||
period int64
|
||||
f func(interface{}, uintptr)
|
||||
arg interface{}
|
||||
seq uintptr
|
||||
nextwhen int64
|
||||
status uint32
|
||||
}
|
||||
|
||||
type Timer struct {
|
||||
C <-chan Time
|
||||
r runtimeTimer
|
||||
}
|
||||
|
||||
|
||||
我们看到,Timer结构体中包含了一个非导出字段r,r的类型为另外一个结构体类型runtimeTimer。这个结构体更为复杂,而且我们一眼就可以看出来,这个runtimeTimer结构体不是零值可用的,那我们在创建一个Timer类型变量时就没法使用显式复合字面值的方式了。这个时候,Go标准库提供了一个Timer结构体专用的构造函数NewTimer,它的实现如下:
|
||||
|
||||
// $GOROOT/src/time/sleep.go
|
||||
func NewTimer(d Duration) *Timer {
|
||||
c := make(chan Time, 1)
|
||||
t := &Timer{
|
||||
C: c,
|
||||
r: runtimeTimer{
|
||||
when: when(d),
|
||||
f: sendTime,
|
||||
arg: c,
|
||||
},
|
||||
}
|
||||
startTimer(&t.r)
|
||||
return t
|
||||
}
|
||||
|
||||
|
||||
我们看到,NewTimer这个函数只接受一个表示定时时间的参数d,在经过一个复杂的初始化过程后,它返回了一个处于可用状态的Timer类型指针实例。
|
||||
|
||||
像这类通过专用构造函数进行结构体类型变量创建、初始化的例子还有很多,我们可以总结一下,它们的专用构造函数大多都符合这种模式:
|
||||
|
||||
func NewT(field1, field2, ...) *T {
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
这里,NewT是结构体类型T的专用构造函数,它的参数列表中的参数通常与T定义中的导出字段相对应,返回值则是一个T指针类型的变量。T的非导出字段在NewT内部进行初始化,一些需要复杂初始化逻辑的字段也会在NewT内部完成初始化。这样,我们只要调用NewT函数就可以得到一个可用的T指针类型变量了。
|
||||
|
||||
和之前学习复合数据类型的套路一样,接下来,我们再回到结构体类型的定义,看看结构体类型在内存中的表示,也就是内存布局。
|
||||
|
||||
结构体类型的内存布局
|
||||
|
||||
Go结构体类型是既数组类型之后,第二个将它的元素(结构体字段)一个接着一个以“平铺”形式,存放在一个连续内存块中的。下图是一个结构体类型T的内存布局:
|
||||
|
||||
|
||||
|
||||
我们看到,结构体类型T在内存中布局是非常紧凑的,Go为它分配的内存都用来存储字段了,没有被Go编译器插入的额外字段。我们可以借助标准库unsafe包提供的函数,获得结构体类型变量占用的内存大小,以及它每个字段在内存中相对于结构体变量起始地址的偏移量:
|
||||
|
||||
var t T
|
||||
unsafe.Sizeof(t) // 结构体类型变量占用的内存大小
|
||||
unsafe.Offsetof(t.Fn) // 字段Fn在内存中相对于变量t起始地址的偏移量
|
||||
|
||||
|
||||
不过,上面这张示意图是比较理想的状态,真实的情况可能就没那么好了:
|
||||
|
||||
|
||||
|
||||
在真实情况下,虽然Go编译器没有在结构体变量占用的内存空间中插入额外字段,但结构体字段实际上可能并不是紧密相连的,中间可能存在“缝隙”。这些“缝隙”同样是结构体变量占用的内存空间的一部分,它们是Go编译器插入的“填充物(Padding)”。
|
||||
|
||||
那么,Go编译器为什么要在结构体的字段间插入“填充物”呢?这其实是内存对齐的要求。所谓内存对齐,指的就是各种内存对象的内存地址不是随意确定的,必须满足特定要求。
|
||||
|
||||
对于各种基本数据类型来说,它的变量的内存地址值必须是其类型本身大小的整数倍,比如,一个int64类型的变量的内存地址,应该能被int64类型自身的大小,也就是8整除;一个uint16类型的变量的内存地址,应该能被uint16类型自身的大小,也就是2整除。
|
||||
|
||||
这些基本数据类型的对齐要求很好理解,那么像结构体类型这样的复合数据类型,内存对齐又是怎么要求的呢?是不是它的内存地址也必须是它类型大小的整数倍呢?
|
||||
|
||||
实际上没有这么严格。对于结构体而言,它的变量的内存地址,只要是它最长字段长度与系统对齐系数两者之间较小的那个的整数倍就可以了。但对于结构体类型来说,我们还要让它每个字段的内存地址都严格满足内存对齐要求。
|
||||
|
||||
这么说依然比较绕,我们来看一个具体例子,计算一下这个结构体类型T的对齐系数:
|
||||
|
||||
type T struct {
|
||||
b byte
|
||||
|
||||
i int64
|
||||
u uint16
|
||||
}
|
||||
|
||||
|
||||
计算过程是这样的:
|
||||
|
||||
|
||||
|
||||
我们简单分析一下,整个计算过程分为两个阶段。第一个阶段是对齐结构体的各个字段。
|
||||
|
||||
首先,我们看第一个字段b是长度1个字节的byte类型变量,这样字段b放在任意地址上都可以被1整除,所以我们说它是天生对齐的。我们用一个sum来表示当前已经对齐的内存空间的大小,这个时候sum=1;
|
||||
|
||||
接下来,我们看第二个字段i,它是一个长度为8个字节的int64类型变量。按照内存对齐要求,它应该被放在可以被8整除的地址上。但是,如果把i紧邻b进行分配,当i的地址可以被8整除时,b的地址就无法被8整除。这个时候,我们需要在b与i之间做一些填充,使得i的地址可以被8整除时,b的地址也始终可以被8整除,于是我们在i与b之间填充了7个字节,此时此刻sum=1+7+8;
|
||||
|
||||
再下来,我们看第三个字段u,它是一个长度为2个字节的uint16类型变量,按照内存对其要求,它应该被放在可以被2整除的地址上。有了对其的i作为基础,我们现在知道将u与i相邻而放,是可以满足其地址的对齐要求的。i之后的那个字节的地址肯定可以被8整除,也一定可以被2整除。于是我们把u直接放在i的后面,中间不需要填充,此时此刻,sum=1+7+8+2。
|
||||
|
||||
现在结构体T的所有字段都已经对齐了,我们开始第二个阶段,也就是对齐整个结构体。
|
||||
|
||||
我们前面提到过,结构体的内存地址为min(结构体最长字段的长度,系统内存对齐系数)的整数倍,那么这里结构体T最长字段为i,它的长度为8,而64bit系统上的系统内存对齐系数一般为8,两者相同,我们取8就可以了。那么整个结构体的对齐系数就是8。
|
||||
|
||||
这个时候问题就来了!为什么上面的示意图还要在结构体的尾部填充了6个字节呢?
|
||||
|
||||
我们说过结构体T的对齐系数是8,那么我们就要保证每个结构体T的变量的内存地址,都能被8整除。如果我们只分配一个T类型变量,不再继续填充,也可能保证其内存地址为8的倍数。但如果考虑我们分配的是一个元素为T类型的数组,比如下面这行代码,我们虽然可以保证T[0]这个元素地址可以被8整除,但能保证T[1]的地址也可以被8整除吗?
|
||||
|
||||
var array [10]T
|
||||
|
||||
|
||||
我们知道,数组是元素连续存储的一种类型,元素T[1]的地址为T[0]地址+T的大小(18),显然无法被8整除,这将导致T[1]及后续元素的地址都无法对齐,这显然不能满足内存对齐的要求。
|
||||
|
||||
问题的根源在哪里呢?问题就在于T的当前大小为18,这是一个不能被8整除的数值,如果T的大小可以被8整除,那问题就解决了。于是我们才有了最后一个步骤,我们从18开始向后找到第一个可以被8整除的数字,也就是将18圆整到8的倍数上,我们得到24,我们将24作为类型T最终的大小就可以了。
|
||||
|
||||
为什么会出现内存对齐的要求呢?这是出于对处理器存取数据效率的考虑。在早期的一些处理器中,比如Sun公司的Sparc处理器仅支持内存对齐的地址,如果它遇到没有对齐的内存地址,会引发段错误,导致程序崩溃。我们常见的x86-64架构处理器虽然处理未对齐的内存地址不会出现段错误,但数据的存取性能也会受到影响。
|
||||
|
||||
从这个推演过程中,你应该已经知道了,Go语言中结构体类型的大小受内存对齐约束的影响。这样一来,不同的字段排列顺序也会影响到“填充字节”的多少,从而影响到整个结构体大小。比如下面两个结构体类型表示的抽象是相同的,但正是因为字段排列顺序不同,导致它们的大小也不同:
|
||||
|
||||
type T struct {
|
||||
b byte
|
||||
i int64
|
||||
u uint16
|
||||
}
|
||||
|
||||
type S struct {
|
||||
b byte
|
||||
u uint16
|
||||
i int64
|
||||
}
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
println(unsafe.Sizeof(t)) // 24
|
||||
var s S
|
||||
println(unsafe.Sizeof(s)) // 16
|
||||
}
|
||||
|
||||
|
||||
所以,你在日常定义结构体时,一定要注意结构体中字段顺序,尽量合理排序,降低结构体对内存空间的占用。
|
||||
|
||||
另外,前面例子中的内存填充部分,是由编译器自动完成的。不过,有些时候,为了保证某个字段的内存地址有更为严格的约束,我们也会做主动填充。比如runtime包中的mstats结构体定义就采用了主动填充:
|
||||
|
||||
// $GOROOT/src/runtime/mstats.go
|
||||
type mstats struct {
|
||||
... ...
|
||||
// Add an uint32 for even number of size classes to align below fields
|
||||
// to 64 bits for atomic operations on 32 bit platforms.
|
||||
_ [1 - _NumSizeClasses%2]uint32 // 这里做了主动填充
|
||||
|
||||
last_gc_nanotime uint64 // last gc (monotonic time)
|
||||
last_heap_inuse uint64 // heap_inuse at mark termination of the previous GC
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
通常我们会通过空标识符来进行主动填充,因为填充的这部分内容我们并不关心。关于主动填充的话题不是我们这节课的重点,我就介绍到这里了。如果你对这个话题感兴趣,你也可以自行阅读相关资料进行扩展学习,并在留言区和我们分享。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
通过前面的学习我们知道,Go语言不是一门面向对象范式的编程语言,它没有C++或Java中的那种class类型。如果非要在Go中选出一个与class接近的语法元素,那非结构体类型莫属。Go中的结构体类型提供了一种聚合抽象能力,开发者可以使用它建立对真实世界的事物的抽象。
|
||||
|
||||
在讲解结构体相关知识前,我们在先介绍了如何自定义一个新类型,通常我们会使用类型定义这种标准方式定义新类型另外,我们还可以用类型别名的方式自定义类型,你要多注意这两种方式的区别。
|
||||
|
||||
对于结构体这类复合类型,我们通过类型字面值方式来定义,它包含若干个字段,每个字段都有自己的名字与类型。如果不包含任何字段,我们称这个结构体类型为空结构体类型,空结构体类型的变量不占用内存空间,十分适合作为一种“事件”在并发的Goroutine间传递。
|
||||
|
||||
当我们使用结构体类型作为字段类型时,Go还提供了“嵌入字段”的语法糖,关于这种嵌入方式,我们在后续的课程中还会有更详细的讲解。另外,Go的结构体定义不支持递归,这点你一定要注意。
|
||||
|
||||
结构体类型变量的初始化有几种方式:零值初始化、复合字面值初始化,以及使用特定构造函数进行初始化,日常编码中最常见的是第二种。支持零值可用的结构体类型对于简化代码,改善体验具有很好的作用。另外,当复合字面值初始化无法满足要求的情况下,我们需要为结构体类型定义专门的构造函数,这种方式同样有广泛的应用。
|
||||
|
||||
结构体类型是既数组类型之后,又一个以平铺形式存放在连续内存块中的类型。不过与数组类型不同,由于内存对齐的要求,结构体类型各个相邻字段间可能存在“填充物”,结构体的尾部同样可能被Go编译器填充额外的字节,满足结构体整体对齐的约束。正是因为这点,我们在定义结构体时,一定要合理安排字段顺序,要让结构体类型对内存空间的占用最小。
|
||||
|
||||
关于结构体类型的知识点比较多,你先消化一下。在后面讲解方法的时候,我们还会继续讲解与结构体类型有关的内容。
|
||||
|
||||
思考题
|
||||
|
||||
Go语言不支持在结构体类型定义中,递归地放入其自身类型字段,但却可以拥有自身类型的指针类型、以自身类型为元素类型的切片类型,以及以自身类型作为value类型的map类型的字段,你能思考一下其中的原因吗?期待在留言区看到你的想法。
|
||||
|
||||
欢迎你把这节课分享给更多对Go复合数据类型感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
304
专栏/TonyBai·Go语言第一课/18控制结构:if的“快乐路径”原则.md
Normal file
304
专栏/TonyBai·Go语言第一课/18控制结构:if的“快乐路径”原则.md
Normal file
@@ -0,0 +1,304 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 控制结构:if的“快乐路径”原则
|
||||
你好,我是Tony Bai。
|
||||
|
||||
1984年图灵奖获得者、著名计算机科学家尼古拉斯·沃斯(Niklaus Wirth)提出过著名的“程序=数据结构+算法”的公式。在前面的课程中,我们花了很多时间讲解了Go语言的基本数据类型和复合数据类型,这些对应的就是公式中数据结构,通过这些数据类型我们可以建立起复杂的数据结构。
|
||||
|
||||
那么公式中的算法呢?算法是对真实世界运作规律的抽象,是解决真实世界中问题的步骤。在计算机世界中,再复杂的算法都可以通过顺序、分支和循环这三种基本的控制结构构造出来。
|
||||
|
||||
顺序结构自然不用说了,我们要关注的主要是后面两个。所以,这一节课开始的连续三节课,我们都会聚焦于Go语言中的分支和循环这两种控制结构。
|
||||
|
||||
那么Go语言对分支与循环两种控制结构的支持是怎么样的呢?针对程序的分支结构,Go提供了if和switch-case两种语句形式;而针对循环结构,Go只保留了for这一种循环语句形式。这节课我们就先从Go语言分支结构之一的if语句开始讲起。
|
||||
|
||||
Go中的分支结构之认识if语句
|
||||
|
||||
在01讲中我们提到过,Go语言是站在C语言等的肩膀之上诞生与成长起来的。Go语言继承了C语言的很多语法,这里就包括控制结构。但Go也不是全盘照搬,而是在继承的基础上又加上了自己的一些优化与改进,比如:
|
||||
|
||||
|
||||
Go坚持“一件事情仅有一种做法的理念”,只保留了for这一种循环结构,去掉了C语言中的while和do-while循环结构;
|
||||
Go填平了C语言中switch分支结构中每个case语句都要以break收尾的“坑”;
|
||||
Go支持了type switch特性,让“类型”信息也可以作为分支选择的条件;
|
||||
Go的switch控制结构的case语句还支持表达式列表,让相同处理逻辑的多个分支可以合并为一个分支,等等。
|
||||
|
||||
|
||||
如果你这个时候还不是很懂我提到的这些改进点,没有关系,在后面的几节课中,我会为你详细讲解Go关于控制结构的各个优化和改进点。
|
||||
|
||||
那么,Go中的if语句又有什么创新点呢?我们先来认识一下Go中的if语句。
|
||||
|
||||
if语句是Go语言中提供的一种分支控制结构,它也是Go中最常用、最简单的分支控制结构。它会根据布尔表达式的值,在两个分支中选择一个执行。我们先来看一个最简单的、单分支结构的if语句的形式:
|
||||
|
||||
if boolean_expression {
|
||||
// 新分支
|
||||
}
|
||||
|
||||
// 原分支
|
||||
|
||||
|
||||
分支结构是传统结构化程序设计中的基础构件,这个if语句中的代码执行流程就等价于下面这幅流程图:
|
||||
|
||||
|
||||
|
||||
从图中我们可以看到,代码执行遇到if分支结构后,首先会对其中的布尔表达式(boolean_expression)进行求值,如果求值结果为true,那么程序将进入新分支执行,如果布尔表达式的求值结果为false,代码就会继续沿着原分支的路线继续执行。
|
||||
|
||||
虽然各种编程语言几乎都原生支持了if语句,但Go的if语句依然有着自己的特点:
|
||||
|
||||
第一,和Go函数一样,if语句的分支代码块的左大括号与if关键字在同一行上,这也是Go代码风格的统一要求,gofmt工具会帮助我们实现这一点;
|
||||
|
||||
第二,if语句的布尔表达式整体不需要用括号包裹,一定程度上减少了开发人员敲击键盘的次数。而且,if关键字后面的条件判断表达式的求值结果必须是布尔类型,即要么是true,要么是false:
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
println("we are on linux os")
|
||||
}
|
||||
|
||||
|
||||
如果判断的条件比较多,我们可以用多个逻辑操作符连接起多个条件判断表达式,比如这段代码就是用了多个逻辑操作符&&来连接多个布尔表达式:
|
||||
|
||||
if (runtime.GOOS == "linux") && (runtime.GOARCH == "amd64") &&
|
||||
(runtime.Compiler != "gccgo") {
|
||||
println("we are using standard go compiler on linux os for amd64")
|
||||
}
|
||||
|
||||
|
||||
除了逻辑操作符&&之外,Go还提供了另外两个逻辑操作符,我总结到了这张表里。
|
||||
|
||||
|
||||
|
||||
你可能也注意到了,上面示例代码中的每个布尔表达式都被小括号括上了,这又是什么原因呢?这是为了降低你在阅读和理解这段代码时,面对操作符优先级的心智负担,这也是我个人的编码习惯。
|
||||
|
||||
Go语言的操作符是有优先级的。这里你要记住,一元操作符,比如上面的逻辑非操作符,具有最高优先级,其他操作符的优先级如下:
|
||||
|
||||
|
||||
|
||||
操作符优先级决定了操作数优先参与哪个操作符的求值运算,我们以下面代码中if语句的布尔表达式为例:
|
||||
|
||||
func main() {
|
||||
a, b := false,true
|
||||
if a && b != true {
|
||||
println("(a && b) != true")
|
||||
return
|
||||
}
|
||||
println("a && (b != true) == false")
|
||||
}
|
||||
|
||||
|
||||
执行这段代码会输出什么呢?你第一次读这段代码的时候,可能会认为输出(a && b) != true,但实际上我们得到的却是a && (b != true) == false。这是为什么呢?
|
||||
|
||||
这段代码的关键就在于,if后面的布尔表达式中的操作数b是先参与&&的求值运算,还是先参与!=的求值运算。根据前面的操作符优先级表,我们知道,!=的优先级要高于&&,因此操作数b先参与的是!=的求值运算,这样if后的布尔表达式就等价于a && (b != true) ,而不是我们最初认为的(a && b) != true。
|
||||
|
||||
如果你有时候也会记不住操作符优先级,不用紧张。从学习和使用C语言开始,我自己就记不住这么多操作符的优先级,况且不同编程语言的操作符优先级还可能有所不同,所以我个人倾向在if布尔表达式中,使用带有小括号的子布尔表达式来清晰地表达判断条件。
|
||||
|
||||
这样做不仅可以消除了自己记住操作符优先级的学习负担,同时就像前面说的,当其他人阅读你的代码时,也可以很清晰地看出布尔表达式要表达的逻辑关系,这能让我们代码的可读性更好,更易于理解,不会因记错操作符优先级顺序而产生错误的理解。
|
||||
|
||||
除了上面的最简形式,Go语言的if语句还有其他多种形式,比如二分支结构和多(N)分支结构。
|
||||
|
||||
二分支控制结构比较好理解。比如下面这个例子,当boolean_expression求值为true时,执行分支1,否则,执行分支2:
|
||||
|
||||
if boolean_expression {
|
||||
// 分支1
|
||||
} else {
|
||||
// 分支2
|
||||
}
|
||||
|
||||
|
||||
多分支结构由于引入了else if,理解起来稍难一点点,它的标准形式是这样的:
|
||||
|
||||
if boolean_expression1 {
|
||||
// 分支1
|
||||
} else if boolean_expression2 {
|
||||
// 分支2
|
||||
|
||||
... ...
|
||||
|
||||
} else if boolean_expressionN {
|
||||
// 分支N
|
||||
} else {
|
||||
// 分支N+1
|
||||
}
|
||||
|
||||
|
||||
我们以下面这个四分支的代码为例,看看怎么拆解这个多分支结构:
|
||||
|
||||
if boolean_expression1 {
|
||||
// 分支1
|
||||
} else if boolean_expression2 {
|
||||
// 分支2
|
||||
} else if boolean_expression3 {
|
||||
// 分支3
|
||||
} else {
|
||||
// 分支4
|
||||
}
|
||||
|
||||
|
||||
要理解这个略复杂一些的分支结构,其实很简单。我们只需要把它做一下等价变换,变换为我们熟悉的二分支结构就好了,变换后的代码如下:
|
||||
|
||||
if boolean_expression1 {
|
||||
// 分支1
|
||||
} else {
|
||||
if boolean_expression2 {
|
||||
// 分支2
|
||||
} else {
|
||||
if boolean_expression3 {
|
||||
// 分支3
|
||||
} else {
|
||||
// 分支4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这样等价转换后,我们得到一个层层缩进的二分支结构,通过上面我们对二分支的分析,再来理解这个结构就十分容易了。
|
||||
|
||||
支持声明if语句的自用变量
|
||||
|
||||
无论是单分支、二分支还是多分支结构,我们都可以在if后的布尔表达式前,进行一些变量的声明,在if布尔表达式前声明的变量,我叫它if语句的自用变量。顾名思义,这些变量只可以在if语句的代码块范围内使用,比如下面代码中的变量a、b和c:
|
||||
|
||||
func main() {
|
||||
if a, c := f(), h(); a > 0 {
|
||||
println(a)
|
||||
} else if b := f(); b > 0 {
|
||||
println(a, b)
|
||||
} else {
|
||||
println(a, b, c)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们可以看到自用变量声明的位置是在每个if语句的后面,布尔表达式的前面,而且,由于声明本身是一个语句,所以我们需要把它和后面的布尔表达式通过分号分隔开。
|
||||
|
||||
这里又涉及到了代码块与作用域的概念,这是我们在第11讲中学习到的内容。如果你觉得概念有些模糊了,可以回过头去复习一下。根据第11讲中的讲解,我们知道,上面代码中声明的变量a、b、c都位于各级if的隐式代码块中,它们的作用域起始于它声明所在的代码块,并一直可扩展至嵌入到这个代码块的所有内层代码块中。
|
||||
|
||||
在if语句中声明自用变量是Go语言的一个惯用法,这种使用方式直观上可以让开发者有一种代码行数减少的感觉,提高可读性。同时,由于这些变量是if语句自用变量,它的作用域仅限于if语句的各层隐式代码块中,if语句外部无法访问和更改这些变量,这就让这些变量具有一定隔离性,这样你在阅读和理解if语句的代码时也可以更聚焦。
|
||||
|
||||
不过前面我们第11讲也重点提到过,Go控制结构与短变量声明的结合是“变量遮蔽”问题出没的重灾区,你在这点上一定要注意。
|
||||
|
||||
到这里,我们已经学过了if分支控制结构的所有形式,也了解了if语句通过短变量声明形式声明自用变量的优点与不足。那么在日常开发中,这些if分支控制结构形式是随意使用的吗?有什么优化方案吗?
|
||||
|
||||
if语句的“快乐路径”原则
|
||||
|
||||
我们已经学了if分支控制结构的三种形式了,从可读性上来看,单分支结构要优于二分支结构,二分支结构又优于多分支结构。那么显然,我们在日常编码中要减少多分支结构,甚至是二分支结构的使用,这会有助于我们编写出优雅、简洁、易读易维护且不易错的代码。
|
||||
|
||||
我们用一个具体的例子直观地体会一下我的这个观点,下面是两段逻辑相同但形式不同的伪代码段:
|
||||
|
||||
//伪代码段1:
|
||||
|
||||
func doSomething() error {
|
||||
if errorCondition1 {
|
||||
// some error logic
|
||||
... ...
|
||||
return err1
|
||||
}
|
||||
|
||||
// some success logic
|
||||
... ...
|
||||
|
||||
if errorCondition2 {
|
||||
// some error logic
|
||||
... ...
|
||||
return err2
|
||||
}
|
||||
|
||||
// some success logic
|
||||
... ...
|
||||
return nil
|
||||
}
|
||||
|
||||
// 伪代码段2:
|
||||
|
||||
func doSomething() error {
|
||||
if successCondition1 {
|
||||
// some success logic
|
||||
... ...
|
||||
|
||||
if successCondition2 {
|
||||
// some success logic
|
||||
... ...
|
||||
|
||||
return nil
|
||||
} else {
|
||||
// some error logic
|
||||
... ...
|
||||
return err2
|
||||
}
|
||||
} else {
|
||||
// some error logic
|
||||
... ...
|
||||
return err1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
即便你是刚入门的Go新手,你大概也能看出上面代码的优劣。
|
||||
|
||||
我们看看只使用了单分支控制结构的伪代码段1,我们看到代码段1有这几个特点:
|
||||
|
||||
|
||||
没有使用else分支,失败就立即返回;
|
||||
“成功”逻辑始终“居左”并延续到函数结尾,没有被嵌入到if的布尔表达式为true的代码分支中;
|
||||
整个代码段布局扁平,没有深度的缩进;
|
||||
|
||||
|
||||
而另外一个实现了同样逻辑的伪代码段2,就使用了带有嵌套的二分支结构,它的特点如下:
|
||||
|
||||
|
||||
整个代码段呈现为“锯齿状”,有深度缩进;
|
||||
“成功”逻辑被嵌入到if的布尔表达式为true的代码分支中;
|
||||
|
||||
|
||||
很明显,伪代码段1的逻辑更容易理解,也更简洁。Go社区把这种if语句的使用方式称为if语句的“快乐路径(Happy Path)”原则,所谓“快乐路径”也就是成功逻辑的代码执行路径,它的特点是这样的:
|
||||
|
||||
|
||||
仅使用单分支控制结构;
|
||||
当布尔表达式求值为false时,也就是出现错误时,在单分支中快速返回;
|
||||
正常逻辑在代码布局上始终“靠左”,这样读者可以从上到下一眼看到该函数正常逻辑的全貌;
|
||||
函数执行到最后一行代表一种成功状态。
|
||||
|
||||
|
||||
Go社区推荐Gopher们在使用if语句时尽量符合这些原则,如果你的函数实现代码不符合“快乐路径”原则,你可以按下面步骤进行重构:
|
||||
|
||||
|
||||
尝试将“正常逻辑”提取出来,放到“快乐路径”中;
|
||||
如果无法做到上一点,很可能是函数内的逻辑过于复杂,可以将深度缩进到else分支中的代码析出到一个函数中,再对原函数实施“快乐路径”原则。
|
||||
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
分支控制结构是构造现实中复杂算法的三大基础控制结构之一,Go语言通过if与switch语句对分支控制结构提供了支持。在这节课中,我们重点讲解了if语句,我建议你记住以下几点:
|
||||
|
||||
第一,if语句是Go语言中最常用的分支控制语句,也是最简单的分支控制结构。if语句通过对布尔表达式的求值决定了后续代码执行要进入的哪条分支。当需要复杂条件判断时,我们可以使用逻辑操作符连接多个布尔表达式,作为if语句的判断条件表达式。如果这么做了,我们还要注意各个操作符的优先级,我个人建议尽量用小括号对各个布尔表达式进行清晰地隔离,这样可以提升代码可读性。
|
||||
|
||||
第二,Go的if语句提供了多种使用形式,包括单分支、双分支以及多分支。多分支理解起来略有难度,我们可以将它等价转换为双分支来理解。
|
||||
|
||||
第三,if语句支持在布尔表达式前声明自用变量,这些变量作用域仅限于if语句的代码块内部。使用if自用变量可以一定程度简化代码,并增强与同函数内其他变量的隔离,但这也十分容易导致变量遮蔽问题,你使用时一定要注意。
|
||||
|
||||
最后一点,if语句的三种使用形式的复杂度与可读性不一,我们建议在使用if语句时尽量符合“快乐路径”原则,这个原则通常只使用最容易理解的单分支结构,所有正常代码均“靠左”,这让函数内代码逻辑一目了然,提升了代码可读性与可维护性。
|
||||
|
||||
思考题
|
||||
|
||||
今天,我依然出了一个思考题:如果一个if语句使用了多分支结构,如下面代码这样,那么if语句中的几个布尔表达式如何排列能达到最好的效果呢?
|
||||
|
||||
提示一下,几个布尔表达式能够被命中的概率是不同的,你在答案中可以自行假设一下。期待在留言区看到你的分析。
|
||||
|
||||
func foo() {
|
||||
if boolean_expression1 {
|
||||
|
||||
} else if boolean_expression2 {
|
||||
|
||||
} else if boolean_expression3 {
|
||||
|
||||
} else {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言中的if语句感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
664
专栏/TonyBai·Go语言第一课/19控制结构:Go的for循环,仅此一种.md
Normal file
664
专栏/TonyBai·Go语言第一课/19控制结构:Go的for循环,仅此一种.md
Normal file
@@ -0,0 +1,664 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 控制结构:Go的for循环,仅此一种
|
||||
你好,我是Tony Bai。
|
||||
|
||||
上一节课,我们开始了对程序控制结构的学习,学习了分支结构中的if语句。上节课我们也说过,针对程序的分支结构,Go提供了if和switch-case两种语句形式。那你肯定在想,这节课肯定是要讲switch-case语句了吧!我不想按常规出牌,这一节课我们换换口味,挑战一下程序控制结构中最复杂的一款:循环结构。
|
||||
|
||||
为什么这么设置呢?因为我想让你能更早开始动手编写具有循环结构的Go代码。虽然switch-case分支结构也非常重要,但毕竟我们已经有了if分支语句的基础了,很多时候用if也可以替代switch-case,所以把它往后放放也没关系。
|
||||
|
||||
日常编码过程中,我们常常需要重复执行同一段代码,这时我们就需要循环结构来帮助我们控制程序的执行顺序。一个循环结构会执行循环体中的代码直到结尾,然后回到开头继续执行。 主流编程语言都提供了对循环结构的支持,绝大多数主流语言,包括C语言、C++、Java和Rust,甚至连动态语言Python还提供了不止一种的循环语句,但Go却只有一种,也就是for语句。
|
||||
|
||||
所以这节课,我们就来系统学习一下Go语言循环结构中的这一支独苗,for语句,聚焦于它的使用形式和常见坑点,让你能更快上手Go编码。
|
||||
|
||||
首先,我们就来认识一下Go语言中的for语句。
|
||||
|
||||
认识for语句的经典使用形式
|
||||
|
||||
C语言是很多现代编程语言的“祖先”,要学习Go语言中for语句的使用形式,我们要先看看C语言中for语句是怎么使用的。
|
||||
|
||||
下面这段C代码就是C语言中for语句的经典使用形式:
|
||||
|
||||
int i;
|
||||
int sum = 0;
|
||||
for (i = 0; i < 10; i++) {
|
||||
sum += i;
|
||||
}
|
||||
printf("%d\n", sum);
|
||||
|
||||
|
||||
这种形式也被其它后继语言延承了下来,Go语言的for语句也不例外,这段C代码在Go语言中的等价形式是这样的:
|
||||
|
||||
var sum int
|
||||
for i := 0; i < 10; i++ {
|
||||
sum += i
|
||||
}
|
||||
println(sum)
|
||||
|
||||
|
||||
这种for语句的使用形式是Go语言中for循环语句的经典形式,也是我们在这节课要介绍的for循环语句的第一种形式。我们用一幅流程图来直观解释一下上面这句for循环语句的组成部分,以及各个部分的执行顺序:
|
||||
|
||||
|
||||
|
||||
从图中我们看到,经典for循环语句有四个组成部分(分别对应图中的①~④)。我们按顺序拆解一下这张图。
|
||||
|
||||
图中①对应的组成部分执行于循环体(③ )之前,并且在整个for循环语句中仅会被执行一次,它也被称为循环前置语句。我们通常会在这个部分声明一些循环体(③ )或循环控制条件(② )会用到的自用变量,也称循环变量或迭代变量,比如这里声明的整型变量i。与if语句中的自用变量一样,for循环变量也采用短变量声明的形式,循环变量的作用域仅限于for语句隐式代码块范围内。
|
||||
|
||||
图中②对应的组成部分,是用来决定循环是否要继续进行下去的条件判断表达式。和if语句的一样,这个用于条件判断的表达式必须为布尔表达式,如果有多个判断条件,我们一样可以由逻辑操作符进行连接。当表达式的求值结果为true时,代码将进入循环体(③)继续执行,相反则循环直接结束,循环体(③)与组成部分④都不会被执行。
|
||||
|
||||
前面也多次提到了,图中③对应的组成部分是for循环语句的循环体。如果相关的判断条件表达式求值结构为true时,循环体就会被执行一次,这样的一次执行也被称为一次迭代(Iteration)。在上面例子中,循环体执行的动作是将这次迭代中变量i的值累加到变量sum中。
|
||||
|
||||
图中④对应的组成部分会在每次循环体迭代之后执行,也被称为循环后置语句。这个部分通常用于更新for循环语句组成部分①中声明的循环变量,比如在这个例子中,我们在这个组成部分对循环变量i进行加1操作。
|
||||
|
||||
现在你应该理解Go语言中的经典for语句的形式了吧?不过,Go语言的for循环也在C语言的基础上有一些突破和创新。具体一点,Go语言的for循环支持声明多循环变量,并且可以应用在循环体以及判断条件中,比如下面就是一个使用多循环变量的、稍复杂的例子:
|
||||
|
||||
for i, j, k := 0, 1, 2; (i < 20) && (j < 10) && (k < 30); i, j, k = i+1, j+1, k+5 {
|
||||
sum += (i + j + k)
|
||||
println(sum)
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们声明了三个循环自用变量i、j和k,它们共同参与了循环条件判断与循环体的执行。
|
||||
|
||||
我们继续按四个组成部分分析这段代码。其实,除了循环体部分(③)之外,其余的三个部分都是可选的。比如下面代码中,我们省略了循环后置语句④,将对循环变量的更新操作放在了循环体中:
|
||||
|
||||
for i := 0; i < 10; {
|
||||
i++
|
||||
}
|
||||
|
||||
|
||||
我们也可以省略循环前置语句。比如下面例子中,我们就没有使用前置语句声明循环变量,而是直接使用了已声明的变量i充当循环变量的作用:
|
||||
|
||||
i := 0
|
||||
for ; i < 10; i++{
|
||||
println(i)
|
||||
}
|
||||
|
||||
|
||||
当然,循环前置与后置语句也可以都省略掉,比如下面代码:
|
||||
|
||||
i := 0
|
||||
for ; i < 10; {
|
||||
println(i)
|
||||
i++
|
||||
}
|
||||
|
||||
|
||||
细心的你可能已经发现了,虽然我们对前置语句或后置语句进行了省略,但经典for循环形式中的分号依然被保留着,你要注意这一点,这是Go语法的要求。
|
||||
|
||||
不过有一个例外,那就是当循环前置与后置语句都省略掉,仅保留循环判断条件表达式时,我们可以省略经典for循环形式中的分号。也就是说,我们可以将上面的例子写出如下形式:
|
||||
|
||||
i := 0
|
||||
for i < 10 {
|
||||
println(i)
|
||||
i++
|
||||
}
|
||||
|
||||
|
||||
这种形式也是我们在日常Go编码中经常使用的for循环语句的第二种形式,也就是除了循环体之外,我们仅保留循环判断条件表达式。
|
||||
|
||||
不过看到这里,你可能就问了:“老师,前面你不是说过,除了循环体,其他组成部分都是可选项么?”
|
||||
|
||||
没错。当for循环语句的循环判断条件表达式的求值结果始终为true时,我们就可以将它省略掉了:
|
||||
|
||||
for {
|
||||
// 循环体代码
|
||||
}
|
||||
|
||||
|
||||
这个for循环就是我们通常所说的“无限循环”。它的形式等价于:
|
||||
|
||||
for true {
|
||||
// 循环体代码
|
||||
}
|
||||
|
||||
|
||||
或者:
|
||||
|
||||
for ; ; {
|
||||
// 循环体代码
|
||||
}
|
||||
|
||||
|
||||
不过,虽然我这里给出这些等价形式,但在日常使用时,我还是建议你用它的最简形式,也就是for {...},更加简单。
|
||||
|
||||
那么,无限循环是什么意思呢?是不是意味着代码始终在运行循环体而无法跳出来呢?不是的。这点你可以先思考一下,我们后面会讲。这里我们先继续看Go语言中for循环最常使用的第三种形式,for range。
|
||||
|
||||
for range循环形式
|
||||
|
||||
for range 循环形式是怎么一种形式呢?我们先来看一个例子。如果我们要使用for经典形式遍历一个切片中的元素,我们可以这样做:
|
||||
|
||||
var sl = []int{1, 2, 3, 4, 5}
|
||||
for i := 0; i < len(sl); i++ {
|
||||
fmt.Printf("sl[%d] = %d\n", i, sl[i])
|
||||
}
|
||||
|
||||
|
||||
在这个经典形式的例子中,我们使用循环前置语句中声明的循环变量i作为切片下标,逐一将切片中的元素读取了出来。不过,这样就有点麻烦了。其实,针对像切片这样的复合数据类型,还有Go原生的字符串类型(string),Go语言提供了一个更方便的“语法糖”形式:for range。现在我们就来写一个等价于上面代码的for range循环:
|
||||
|
||||
for i, v := range sl {
|
||||
fmt.Printf("sl[%d] = %d\n", i, v)
|
||||
}
|
||||
|
||||
|
||||
我们看到,for range循环形式与for语句经典形式差异较大,除了循环体保留了下来,其余组成部分都“不见”了。其实那几部分已经被融合到for range的语义中了。
|
||||
|
||||
具体来说,这里的i和v对应的是经典for语句形式中循环前置语句的循环变量,它们的初值分别为切片sl的第一个元素的下标值和元素值。并且,隐含在for range语义中的循环控制条件判断为:是否已经遍历完sl的所有元素,等价于i < len(sl)这个布尔表达式。另外,每次迭代后,for range会取出切片sl的下一个元素的下标和值,分别赋值给循环变量i和v,这与for经典形式下的循环后置语句执行的逻辑是相同的。
|
||||
|
||||
for range语句也有几个常见“变种”,我们继续以上面对切片的迭代为例分析一下。
|
||||
|
||||
变种一:当我们不关心元素的值时,我们可以省略代表元素值的变量v,只声明代表下标值的变量i:
|
||||
|
||||
for i := range sl {
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
变种二:如果我们不关心元素下标,只关心元素值,那么我们可以用空标识符替代代表下标值的变量i。这里一定要注意,这个空标识符不能省略,否则就与上面的“变种一”形式一样了,Go编译器将无法区分:
|
||||
|
||||
for _, v := range sl {
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
变种三:到这里,你肯定要问:如果我们既不关心下标值,也不关心元素值,那是否能写成下面这样呢:
|
||||
|
||||
for _, _ = range sl {
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
这种形式在语法上没有错误,就是看起来不太优雅。Go核心团队早在Go 1.4版本中就提供了一种优雅的等价形式,你后续直接使用这种形式就好了:
|
||||
|
||||
for range sl {
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
好了,讲完了for range针对切片这种复合类型的各种形式后,我们再来看看for range应该如何用于对其他复合类型,或者是对string类型进行循环操作。for range针对不同复合数据类型进行循环操作时,虽然语义是相同的,但它声明的循环变量的含义会有所不同,我们有必要逐一看一下。
|
||||
|
||||
string类型
|
||||
|
||||
我们在第13讲讲解string类型时,就提到过如何通过for range对一个字符串类型变量进行循环操作。我们再通过下面的例子简单回顾一下:
|
||||
|
||||
var s = "中国人"
|
||||
for i, v := range s {
|
||||
fmt.Printf("%d %s 0x%x\n", i, string(v), v)
|
||||
}
|
||||
|
||||
|
||||
运行这个例子,输出结果是这样的:
|
||||
|
||||
0 中 0x4e2d
|
||||
3 国 0x56fd
|
||||
6 人 0x4eba
|
||||
|
||||
|
||||
我们看到:for range对于string类型来说,每次循环得到的v值是一个Unicode字符码点,也就是rune类型值,而不是一个字节,返回的第一个值i为该Unicode字符码点的内存编码(UTF-8)的第一个字节在字符串内存序列中的位置。
|
||||
|
||||
另外我要在这里再次提醒你,使用for经典形式与使用for range形式,对string类型进行循环操作的语义是不同的,你可以回到13讲复习一下这块的内容。
|
||||
|
||||
map
|
||||
|
||||
在第16讲我们学习过,map就是一个键值对(key-value)集合,最常见的对map的操作,就是通过key获取其对应的value值。但有些时候,我们也要对map这个集合进行遍历,这就需要for语句的支持了。
|
||||
|
||||
但在Go语言中,我们要对map进行循环操作,for range是唯一的方法,for经典循环形式是不支持对map类型变量的循环控制的。下面是通过for range,对一个map类型变量进行循环操作的示例:
|
||||
|
||||
var m = map[string]int {
|
||||
"Rob" : 67,
|
||||
"Russ" : 39,
|
||||
"John" : 29,
|
||||
}
|
||||
|
||||
for k, v := range m {
|
||||
println(k, v)
|
||||
}
|
||||
|
||||
|
||||
运行这个示例,我们会看到这样的输出结果:
|
||||
|
||||
John 29
|
||||
Rob 67
|
||||
Russ 39
|
||||
|
||||
|
||||
通过输出结果我们看到:for range对于map类型来说,每次循环,循环变量k和v分别会被赋值为map键值对集合中一个元素的key值和value值。而且,map类型中没有下标的概念,通过key和value来循环操作map类型变量也就十分自然了。
|
||||
|
||||
channel
|
||||
|
||||
除了可以针对string、数组/切片,以及map类型变量进行循环操作控制之外,for range还可以与channel类型配合工作。
|
||||
|
||||
channel是Go语言提供的并发设计的原语,它用于多个Goroutine之间的通信,我们在后面的课程中还会详细讲解channel。当channel类型变量作为for range语句的迭代对象时,for range会尝试从channel中读取数据,使用形式是这样的:
|
||||
|
||||
var c = make(chan int)
|
||||
for v := range c {
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,for range每次从channel中读取一个元素后,会把它赋值给循环变量v,并进入循环体。当channel中没有数据可读的时候,for range循环会阻塞在对channel的读操作上。直到channel关闭时,for range循环才会结束,这也是for range循环与channel配合时隐含的循环判断条件。我们在后面讲解channel的部分,还会对for range与channel的配合做更细致地讲解,这一节课就不涉及那么多了,我们简单了解就可以。
|
||||
|
||||
到这里,我们已经对Go语言支持的所有for循环形式有了一个初步的了解。那么,在日常开发中,一旦我们执行for循环,是不是就只能等循环条件判断表达式求值为false时,才能离开循环呢?如果是前面提到的无限循环,我们是不是就会被一直困于循环之中呢?
|
||||
|
||||
不是的。日常开发中,出于算法逻辑的需要,我们可能会有中断当前循环体并继续下一次迭代的时候,也会有中断循环体并彻底结束循环语句的时候。针对这些情况,Go语言提供了continue语句和break语句。
|
||||
|
||||
带label的continue语句
|
||||
|
||||
首先,我们来看第一种场景。如果循环体中的代码执行到一半,要中断当前迭代,忽略此迭代循环体中的后续代码,并回到for循环条件判断,尝试开启下一次迭代,这个时候我们可以怎么办呢?我们可以使用continue语句来应对。
|
||||
|
||||
我们先来学习一下continue语句的使用方法,你看看下面这个代码示例:
|
||||
|
||||
var sum int
|
||||
var sl = []int{1, 2, 3, 4, 5, 6}
|
||||
for i := 0; i < len(sl); i++ {
|
||||
if sl[i]%2 == 0 {
|
||||
// 忽略切片中值为偶数的元素
|
||||
continue
|
||||
}
|
||||
sum += sl[i]
|
||||
}
|
||||
println(sum) // 9
|
||||
|
||||
|
||||
这段代码会循环遍历切片中的元素,把值为奇数的元素相加,然后存储在变量sum中。我们可以看到,在这个代码的循环体中,如果我们判断切片元素值为偶数,就使用continue语句中断当前循环体的执行,那么循环体下面的sum += sl[i]在这轮迭代中就会被忽略。代码执行流会直接来到循环后置语句i++,之后对循环条件表达式(i < len(sl))进行求值,如果为true,将再次进入循环体,开启新一次迭代。
|
||||
|
||||
如果你学过C语言,你可能会说:这个continue与C语言中的continue也没有什么差别啊!别急,Go语言中的continue在C语言continue语义的基础上又增加了对label的支持。
|
||||
|
||||
label语句的作用,是标记跳转的目标。我们可以把上面的代码改造为使用label的等价形式:
|
||||
|
||||
func main() {
|
||||
var sum int
|
||||
var sl = []int{1, 2, 3, 4, 5, 6}
|
||||
|
||||
loop:
|
||||
for i := 0; i < len(sl); i++ {
|
||||
if sl[i]%2 == 0 {
|
||||
// 忽略切片中值为偶数的元素
|
||||
continue loop
|
||||
}
|
||||
sum += sl[i]
|
||||
}
|
||||
println(sum) // 9
|
||||
}
|
||||
|
||||
|
||||
你可以看到,在这段代码中,我们定义了一个label:loop,它标记的跳转目标恰恰就是我们的for循环。也就是说,我们在循环体中可以使用continue+ loop label的方式来实现循环体中断,这与前面的例子在语义上是等价的。不过这里仅仅是一个演示,通常我们在这样非嵌套循环的场景中会直接使用不带label的continue语句。
|
||||
|
||||
而带label的continue语句,通常出现于嵌套循环语句中,被用于跳转到外层循环并继续执行外层循环语句的下一个迭代,比如下面这段代码:
|
||||
|
||||
func main() {
|
||||
var sl = [][]int{
|
||||
{1, 34, 26, 35, 78},
|
||||
{3, 45, 13, 24, 99},
|
||||
{101, 13, 38, 7, 127},
|
||||
{54, 27, 40, 83, 81},
|
||||
}
|
||||
|
||||
outerloop:
|
||||
for i := 0; i < len(sl); i++ {
|
||||
for j := 0; j < len(sl[i]); j++ {
|
||||
if sl[i][j] == 13 {
|
||||
fmt.Printf("found 13 at [%d, %d]\n", i, j)
|
||||
continue outerloop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,变量sl是一个元素类型为[]int的切片(二维切片),其每个元素切片中至多包含一个整型数13。main函数的逻辑就是在sl的每个元素切片中找到13这个数字,并输出它的具体位置信息。
|
||||
|
||||
那这要怎么查找呢?一种好的实现方式就是,我们只需要在每个切片中找到13,就不用继续在这个切片的剩余元素中查找了。
|
||||
|
||||
我们用for经典形式来实现这个逻辑。面对这个问题,我们要使用嵌套循环,具体来说就是外层循环遍历sl中的元素切片,内层循环遍历每个元素切片中的整型值。一旦内层循环发现13这个数值,我们便要中断内层for循环,回到外层for循环继续执行。
|
||||
|
||||
如果我们用不带label的continue能不能完成这一功能呢?答案是不能。因为它只能中断内层循环的循环体,并继续开启内层循环的下一次迭代。而带label的continue语句是这个场景下的“最佳人选”,它会直接结束内层循环的执行,并回到外层循环继续执行。
|
||||
|
||||
这一行为就好比在外层循环放置并执行了一个不带label的continue语句。它会中断外层循环中当前迭代的执行,执行外层循环的后置语句(i++),然后再对外层循环的循环控制条件语句进行求值,如果为true,就将继续执行外层循环的新一次迭代。
|
||||
|
||||
看到这里,一些学习过goto语句的同学可能就会问了,如果我把上述代码中的continue换成goto语句,是否也可以实现同样的效果?
|
||||
|
||||
答案是否定的!一旦使用goto跳转,那么不管是内层循环还是外层循环都会被终结,代码将会从outerloop这个label处,开始重新执行我们的嵌套循环语句,这与带label的continue的跳转语义是完全不同的。
|
||||
|
||||
我还要特别提醒你,goto是一种公认的、难于驾驭的语法元素,应用goto的代码可读性差、代码难于维护还易错。虽然Go语言保留了goto,但在我们这个入门课中,我们不会系统讲解goto语句。
|
||||
|
||||
break语句的使用
|
||||
|
||||
在前面的讲解中,你可能也注意到了,无论带不带label,continue语句的本质都是继续循环语句的执行。但日常编码中,我们还会遇到一些场景,在这些场景中,我们不仅要中断当前循环体迭代的进行,还要同时彻底跳出循环,终结整个循环语句的执行。面对这样的场景,continue语句就不再适用了,Go语言为我们提供了break语句来解决这个问题。
|
||||
|
||||
我们先来看下面这个示例中break语句的应用:
|
||||
|
||||
func main() {
|
||||
var sl = []int{5, 19, 6, 3, 8, 12}
|
||||
var firstEven int = -1
|
||||
|
||||
// 找出整型切片sl中的第一个偶数
|
||||
for i := 0; i < len(sl); i++ {
|
||||
if sl[i]%2 == 0 {
|
||||
firstEven = sl[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
println(firstEven) // 6
|
||||
}
|
||||
|
||||
|
||||
这段代码逻辑很容易理解,我们通过一个循环结构来找出切片sl中的第一个偶数,一旦找到就不需要继续执行后续迭代了。这个时候我们就通过break语句跳出了这个循环。
|
||||
|
||||
和continue语句一样,Go也break语句增加了对label的支持。而且,和前面continue语句一样,如果遇到嵌套循环,break要想跳出外层循环,用不带label的break是不够,因为不带label的break仅能跳出其所在的最内层循环。要想实现外层循环的跳出,我们还需给break加上label。我们来看一个具体的例子:
|
||||
|
||||
var gold = 38
|
||||
|
||||
func main() {
|
||||
var sl = [][]int{
|
||||
{1, 34, 26, 35, 78},
|
||||
{3, 45, 13, 24, 99},
|
||||
{101, 13, 38, 7, 127},
|
||||
{54, 27, 40, 83, 81},
|
||||
}
|
||||
|
||||
outerloop:
|
||||
for i := 0; i < len(sl); i++ {
|
||||
for j := 0; j < len(sl[i]); j++ {
|
||||
if sl[i][j] == gold {
|
||||
fmt.Printf("found gold at [%d, %d]\n", i, j)
|
||||
break outerloop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这个例子和我们前面讲解的、带label的continue语句的例子很像,main函数的逻辑就是,在sl这个二维切片中找到38这个数字,并输出它的位置信息。整个二维切片中至多有一个值为38的元素,所以只要我们通过嵌套循环发现了38,我们就不需要继续执行这个循环了。这时,我们通过带有label的break语句,就可以直接终结外层循环,从而从复杂多层次的嵌套循环中直接跳出,避免不必要的算力资源的浪费。
|
||||
|
||||
好了,到这里,关于Go语言中for语句的相关语法,我们已经全部讲完了,通过for语句我们可以实现重复执行同一段代码的逻辑。针对原生字符串类型以及一些复合数据类型,诸如数组/切片、map、channel等,Go还提供了for range“语法糖”形式来简化循环结构的编写。
|
||||
|
||||
不过,我们也看到,相较于分支结构,以for语句为代表的循环结构的逻辑要复杂许多。在日常编码实践中,我们也会遇到一些与for循环语句相关的常见问题,下面我们就聊聊究竟有哪些与for相关的常见“坑”点。
|
||||
|
||||
for语句的常见“坑”与避坑方法
|
||||
|
||||
for语句的常见“坑”点通常和for range这个“语法糖”有关。虽然for range的引入提升了Go语言的表达能力,也简化了循环结构的编写,但for range也不是“免费的午餐”,初学者在享用这道美味时,经常会遇到一些问题,下面我们就来看看这些常见的问题。
|
||||
|
||||
问题一:循环变量的重用
|
||||
|
||||
我们前面说过,for range形式的循环语句,使用短变量声明的方式来声明循环变量,循环体将使用这些循环变量实现特定的逻辑,但你在刚开始学习使用的时候,可能会发现循环变量的值与你之前的“预期”不符,比如下面这个例子:
|
||||
|
||||
func main() {
|
||||
var m = []int{1, 2, 3, 4, 5}
|
||||
|
||||
for i, v := range m {
|
||||
go func() {
|
||||
time.Sleep(time.Second * 3)
|
||||
fmt.Println(i, v)
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
|
||||
|
||||
这个示例是对一个整型切片进行遍历,并且在每次循环体的迭代中都会创建一个新的Goroutine(Go中的轻量级协程),输出这次迭代的元素的下标值与元素值。关于Goroutine创建和使用的知识我们在后面课程中会有详细的讲解。
|
||||
|
||||
现在我们继续看这个例子,作为一个初学者,我们预期的输出结果可能是这样的:
|
||||
|
||||
0 1
|
||||
1 2
|
||||
2 3
|
||||
3 4
|
||||
4 5
|
||||
|
||||
|
||||
那实际输出真的是这样吗?我们实际运行输出一下:
|
||||
|
||||
4 5
|
||||
4 5
|
||||
4 5
|
||||
4 5
|
||||
4 5
|
||||
|
||||
|
||||
我们看到,Goroutine中输出的循环变量,也就是i和v的值都是for range循环结束后的最终值,而不是各个Goroutine启动时变量i和v的值,与我们最初的“预期”不符,这是为什么呢?
|
||||
|
||||
这是因为我们最初的“预期”本身就是错的。这里,初学者很可能会被for range语句中的短声明变量形式“迷惑”,简单地认为每次迭代都会重新声明两个新的变量i和v。但事实上,这些循环变量在for range语句中仅会被声明一次,且在每次迭代中都会被重用。
|
||||
|
||||
你还能想起第11讲中关于控制语句的隐式代码块的知识点吗?基于隐式代码块的规则,我们可以将上面的for range语句做一个等价转换,这样可以帮助你理解for range的工作原理。等价转换后的结果是这样的:
|
||||
|
||||
func main() {
|
||||
var m = []int{1, 2, 3, 4, 5}
|
||||
|
||||
{
|
||||
i, v := 0, 0
|
||||
for i, v = range m {
|
||||
go func() {
|
||||
time.Sleep(time.Second * 3)
|
||||
fmt.Println(i, v)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
|
||||
|
||||
通过等价转换后的代码,我们可以清晰地看到循环变量i和v在每次迭代时的重用。而Goroutine执行的闭包函数引用了它的外层包裹函数中的变量i、v,这样,变量i、v在主Goroutine和新启动的Goroutine之间实现了共享,而i, v值在整个循环过程中是重用的,仅有一份。在for range循环结束后,i = 4, v = 5,因此各个Goroutine在等待3秒后进行输出的时候,输出的是i, v的最终值。
|
||||
|
||||
那么如何修改代码,可以让实际输出和我们最初的预期输出一致呢?我们可以为闭包函数增加参数,并且在创建Goroutine时将参数与i、v的当时值进行绑定,看下面的修正代码:
|
||||
|
||||
func main() {
|
||||
var m = []int{1, 2, 3, 4, 5}
|
||||
|
||||
for i, v := range m {
|
||||
go func(i, v int) {
|
||||
time.Sleep(time.Second * 3)
|
||||
fmt.Println(i, v)
|
||||
}(i, v)
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
|
||||
|
||||
运行修改后的例子代码,输出结果是这样的:
|
||||
|
||||
0 1
|
||||
1 2
|
||||
2 3
|
||||
3 4
|
||||
4 5
|
||||
|
||||
|
||||
这回的输出结果与我们的预期就是一致的了。不过这里你要注意:你执行这个程序的输出结果的行序,可能与我的不同,这是由Goroutine的调度所决定的,我们在后面课程中会详细讲解。
|
||||
|
||||
问题二:参与循环的是range表达式的副本
|
||||
|
||||
前面我们学过了,在for range语句中,range后面接受的表达式的类型可以是数组、指向数组的指针、切片、字符串,还有map和channel(需具有读权限)。我们以数组为例来看一个简单的例子:
|
||||
|
||||
func main() {
|
||||
var a = [5]int{1, 2, 3, 4, 5}
|
||||
var r [5]int
|
||||
|
||||
fmt.Println("original a =", a)
|
||||
|
||||
for i, v := range a {
|
||||
if i == 0 {
|
||||
a[1] = 12
|
||||
a[2] = 13
|
||||
}
|
||||
r[i] = v
|
||||
}
|
||||
|
||||
fmt.Println("after for range loop, r =", r)
|
||||
fmt.Println("after for range loop, a =", a)
|
||||
}
|
||||
|
||||
|
||||
这个例子说的是对一个数组a的元素进行遍历操作,当处理下标为0的元素时,我们修改了数组a的第二个和第三个元素的值,并且在每个迭代中,我们都将从a中取得的元素值赋值给新数组r。我们期望这个程序会输出如下结果:
|
||||
|
||||
original a = [1 2 3 4 5]
|
||||
after for range loop, r = [1 12 13 4 5]
|
||||
after for range loop, a = [1 12 13 4 5]
|
||||
|
||||
|
||||
但实际运行该程序的输出结果却是:
|
||||
|
||||
original a = [1 2 3 4 5]
|
||||
after for range loop, r = [1 2 3 4 5]
|
||||
after for range loop, a = [1 12 13 4 5]
|
||||
|
||||
|
||||
我们原以为在第一次迭代过程,也就是i = 0时,我们对a的修改(a[1] =12,a[2] = 13)会在第二次、第三次迭代中被v取出,但从结果来看,v取出的依旧是a被修改前的值:2和3。
|
||||
|
||||
为什么会是这种情况呢?原因就是参与for range循环的是range表达式的副本。也就是说,在上面这个例子中,真正参与循环的是a的副本,而不是真正的a。
|
||||
|
||||
为了方便你理解,我们将上面的例子中的for range循环,用一个等价的伪代码形式重写一下:
|
||||
|
||||
for i, v := range a' { //a'是a的一个值拷贝
|
||||
if i == 0 {
|
||||
a[1] = 12
|
||||
a[2] = 13
|
||||
}
|
||||
r[i] = v
|
||||
}
|
||||
|
||||
|
||||
现在真相终于揭开了:这个例子中,每次迭代的都是从数组a的值拷贝a’中得到的元素。a’是Go临时分配的连续字节序列,与a完全不是一块内存区域。因此无论a被如何修改,它参与循环的副本a’依旧保持原值,因此v从a’中取出的仍旧是a的原值,而不是修改后的值。
|
||||
|
||||
那么应该如何解决这个问题,让输出结果符合我们前面的预期呢?我们前面说过,在Go中,大多数应用数组的场景我们都可以用切片替代,这里我们也用切片来试试看:
|
||||
|
||||
func main() {
|
||||
var a = [5]int{1, 2, 3, 4, 5}
|
||||
var r [5]int
|
||||
|
||||
fmt.Println("original a =", a)
|
||||
|
||||
for i, v := range a[:] {
|
||||
if i == 0 {
|
||||
a[1] = 12
|
||||
a[2] = 13
|
||||
}
|
||||
r[i] = v
|
||||
}
|
||||
|
||||
fmt.Println("after for range loop, r =", r)
|
||||
fmt.Println("after for range loop, a =", a)
|
||||
}
|
||||
|
||||
|
||||
你可以看到,在range表达式中,我们用了a[:]替代了原先的a,也就是将数组a转换为一个切片,作为range表达式的循环对象。运行这个修改后的例子,结果是这样的:
|
||||
|
||||
original a = [1 2 3 4 5]
|
||||
after for range loop, r = [1 12 13 4 5]
|
||||
after for range loop, a = [1 12 13 4 5]
|
||||
|
||||
|
||||
我们看到输出的结果与最初的预期终于一致了,显然用切片能实现我们的要求。
|
||||
|
||||
那切片是如何做到的呢?在之前的第15讲中,我们学习过,切片在Go内部表示为一个结构体,由(array, len, cap)组成,其中array是指向切片对应的底层数组的指针,len是切片当前长度,cap为切片的最大容量。
|
||||
|
||||
所以,当进行range表达式复制时,我们实际上复制的是一个切片,也就是表示切片的结构体。表示切片副本的结构体中的array,依旧指向原切片对应的底层数组,所以我们对切片副本的修改也都会反映到底层数组a上去。而v再从切片副本结构体中array指向的底层数组中,获取数组元素,也就得到了被修改后的元素值。
|
||||
|
||||
问题三:遍历map中元素的随机性
|
||||
|
||||
根据上面的讲解,当map类型变量作为range表达式时,我们得到的map变量的副本与原变量指向同一个map(具体原因你可以看第16讲)。如果我们在循环的过程中,对map进行了修改,那么这样修改的结果是否会影响后续迭代呢?这个结果和我们遍历map一样,具有随机性。
|
||||
|
||||
比如我们来看下面这个例子,在map循环过程中,当counter值为0时,我们删除了变量m中的一个元素:
|
||||
|
||||
var m = map[string]int{
|
||||
"tony": 21,
|
||||
"tom": 22,
|
||||
"jim": 23,
|
||||
}
|
||||
|
||||
counter := 0
|
||||
for k, v := range m {
|
||||
if counter == 0 {
|
||||
delete(m, "tony")
|
||||
}
|
||||
counter++
|
||||
fmt.Println(k, v)
|
||||
}
|
||||
fmt.Println("counter is ", counter)
|
||||
|
||||
|
||||
如果我们反复运行这个例子多次,会得到两个不同的结果。当k=“tony”作为第一个迭代的元素时,我们将得到如下结果:
|
||||
|
||||
tony 21
|
||||
tom 22
|
||||
jim 23
|
||||
counter is 3
|
||||
|
||||
|
||||
否则,我们得到的结果是这样的:
|
||||
|
||||
tom 22
|
||||
jim 23
|
||||
counter is 2
|
||||
|
||||
|
||||
如果我们在针对map类型的循环体中,新创建了一个map元素项,那这项元素可能出现在后续循环中,也可能不出现:
|
||||
|
||||
var m = map[string]int{
|
||||
"tony": 21,
|
||||
"tom": 22,
|
||||
"jim": 23,
|
||||
}
|
||||
|
||||
counter := 0
|
||||
for k, v := range m {
|
||||
if counter == 0 {
|
||||
m["lucy"] = 24
|
||||
}
|
||||
counter++
|
||||
fmt.Println(k, v)
|
||||
}
|
||||
fmt.Println("counter is ", counter)
|
||||
|
||||
|
||||
这个例子的执行结果也会有两个,
|
||||
|
||||
tony 21
|
||||
tom 22
|
||||
jim 23
|
||||
lucy 24
|
||||
counter is 4
|
||||
|
||||
|
||||
或:
|
||||
|
||||
tony 21
|
||||
tom 22
|
||||
jim 23
|
||||
counter is 3
|
||||
|
||||
|
||||
考虑到上述这种随机性,我们日常编码遇到遍历map的同时,还需要对map进行修改的场景的时候,要格外小心。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们讲解了程序控制结构中最复杂的一种:循环控制结构。和其他主流编程语言不同,Go语言仅提供了一种循环结构语句:for语句。for语句的这种践行“做一件事仅有一种方法”理念的作法恰是Go语言崇尚“简单”的设计哲学的具体体现。
|
||||
|
||||
我们首先学习了for语句的经典形式:for preStmt; condition; postStmt { … },你要注意for语句经典形式的四个组成部分,分别是循环前置语句、循环判断表达式、循环体与循环后置语句,也要注意这四个部分的执行顺序。而且,这四部分中,除了循环体,其它三个组成部分都是可选的。我们可以根据实际情况选择省略某个部分。
|
||||
|
||||
如果我们只保留循环判断条件表达式,我们就得到了for循环语句经常使用的第二种形式:for condition {...}。如果循环判断条件表达式求值结果始终为true,我们就可以将for循环语句写成for {...}的形式,这种形式也被称为“无限循环”。
|
||||
|
||||
而且,针对string类型以及一些复合数据类型,比如数组/切片、map以及channel等,Go提供了使用更为便捷的“语法糖”for range形式。for range形式与for语句经典形式差异较大,除了循环体保留了下来,其它几部分融合到for range的语义中了。for range语句形式也有几个“变种”,你要注意的是,如果仅需要代表元素值的循环变量,不需要代表下标值或key的循环变量,我们也需要使用空标识符占位。
|
||||
|
||||
此外,Go语言提供了continue语句与break语句用于显式中断当前循环体的执行,两个语句不同之处在于continue会继续后续迭代的执行,而break将终结整个for语句的执行。Go语言还支持在continue与break关键字后面加label的方式,这种方式常用于有嵌套循环的场景中,它们可以帮助程序中断内层循环的执行,返回外层循环继续执行下一个外层循环迭代,或彻底结束整个嵌套循环的执行。
|
||||
|
||||
最后,for语句在日常使用中有一些常见的问题需要你格外注意,包括循环变量重用、range表达式副本参与循环、map类型遍历的随机性,等等,你一样要深刻理解,才能在日常编码时少走弯路。
|
||||
|
||||
思考题
|
||||
|
||||
在“参与循环的是range表达式的副本”这一部分中,我们用切片替换了数组,实现了我们预期的输出,我想让你思考一下,除了换成切片这个方案之外,还有什么方案也能实现我们预期的输出呢?
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言循环结构感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
477
专栏/TonyBai·Go语言第一课/20控制结构:Go中的switch语句有哪些变化?.md
Normal file
477
专栏/TonyBai·Go语言第一课/20控制结构:Go中的switch语句有哪些变化?.md
Normal file
@@ -0,0 +1,477 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 控制结构:Go中的switch语句有哪些变化?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
经过前两节课的学习,我们已经掌握了控制结构中的分支结构以及循环结构。前面我们也提到过,在计算机世界中,再复杂的算法都可以通过顺序、分支和循环这三种基本的控制结构构造出来。所以,理论上讲,我们现在已经具备了实现任何算法的能力了。
|
||||
|
||||
不过理论归理论,我们还是要回到现实中来,继续学习Go语言中的控制结构,现在我们还差一种分支控制结构没讲。除了if语句之外,Go语言还提供了一种更适合多路分支执行的分支控制结构,也就是switch语句。
|
||||
|
||||
今天这一节课,我们就来系统学习一下switch语句。Go语言中的switch语句继承自它的先祖C语言,所以我们这一讲的重点是Go switch语句相较于C语言的switch,有哪些重要的改进与创新。
|
||||
|
||||
在讲解改进与创新之前,我们先来认识一下switch语句。
|
||||
|
||||
认识switch语句
|
||||
|
||||
我们先通过一个例子来直观地感受一下switch语句的优点。在一些执行分支较多的场景下,使用switch分支控制语句可以让代码更简洁,可读性更好。
|
||||
|
||||
比如下面例子中的readByExt函数会根据传入的文件扩展名输出不同的日志,它使用了if语句进行分支控制:
|
||||
|
||||
func readByExt(ext string) {
|
||||
if ext == "json" {
|
||||
println("read json file")
|
||||
} else if ext == "jpg" || ext == "jpeg" || ext == "png" || ext == "gif" {
|
||||
println("read image file")
|
||||
} else if ext == "txt" || ext == "md" {
|
||||
println("read text file")
|
||||
} else if ext == "yml" || ext == "yaml" {
|
||||
println("read yaml file")
|
||||
} else if ext == "ini" {
|
||||
println("read ini file")
|
||||
} else {
|
||||
println("unsupported file extension:", ext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
如果用switch改写上述例子代码,我们可以这样来写:
|
||||
|
||||
func readByExtBySwitch(ext string) {
|
||||
switch ext {
|
||||
case "json":
|
||||
println("read json file")
|
||||
case "jpg", "jpeg", "png", "gif":
|
||||
println("read image file")
|
||||
case "txt", "md":
|
||||
println("read text file")
|
||||
case "yml", "yaml":
|
||||
println("read yaml file")
|
||||
case "ini":
|
||||
println("read ini file")
|
||||
default:
|
||||
println("unsupported file extension:", ext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
从代码呈现的角度来看,针对这个例子,使用switch语句的实现要比if语句的实现更加简洁紧凑。并且,即便你这个时候还没有系统学过switch语句,相信你也能大致读懂上面readByExtBySwitch的执行逻辑。
|
||||
|
||||
简单来说,readByExtBySwitch函数就是将输入参数ext与每个case语句后面的表达式做比较,如果相等,就执行这个case语句后面的分支,然后函数返回。这里具体的执行逻辑,我们在后面再分析,现在你有个大概认识就好了。
|
||||
|
||||
接下来,我们就来进入正题,来看看Go语言中switch语句的一般形式:
|
||||
|
||||
switch initStmt; expr {
|
||||
case expr1:
|
||||
// 执行分支1
|
||||
case expr2:
|
||||
// 执行分支2
|
||||
case expr3_1, expr3_2, expr3_3:
|
||||
// 执行分支3
|
||||
case expr4:
|
||||
// 执行分支4
|
||||
... ...
|
||||
case exprN:
|
||||
// 执行分支N
|
||||
default:
|
||||
// 执行默认分支
|
||||
}
|
||||
|
||||
|
||||
我们按语句顺序来分析一下。首先看这个switch语句一般形式中的第一行,这一行由switch关键字开始,它的后面通常接着一个表达式(expr),这句中的initStmt是一个可选的组成部分。和if、for语句一样,我们可以在initStmt中通过短变量声明定义一些在switch语句中使用的临时变量。
|
||||
|
||||
接下来,switch后面的大括号内是一个个代码执行分支,每个分支以case关键字开始,每个case后面是一个表达式或是一个逗号分隔的表达式列表。这里还有一个以default关键字开始的特殊分支,被称为默认分支。
|
||||
|
||||
最后,我们再来看switch语句的执行流程。其实也很简单,switch语句会用expr的求值结果与各个case中的表达式结果进行比较,如果发现匹配的case,也就是case后面的表达式,或者表达式列表中任意一个表达式的求值结果与expr的求值结果相同,那么就会执行该case对应的代码分支,分支执行后,switch语句也就结束了。如果所有case表达式都无法与expr匹配,那么程序就会执行default默认分支,并且结束switch语句。
|
||||
|
||||
那么问题就来了!在有多个case执行分支的switch语句中,Go是按什么次序对各个case表达式进行求值,并且与switch表达式(expr)进行比较的?
|
||||
|
||||
我们通过一段示例代码来回答这个问题。这是一个一般形式的switch语句,为了能呈现switch语句的执行次序,我以多个输出特定日志的函数作为switch表达式以及各个case表达式:
|
||||
|
||||
func case1() int {
|
||||
println("eval case1 expr")
|
||||
return 1
|
||||
}
|
||||
|
||||
func case2_1() int {
|
||||
println("eval case2_1 expr")
|
||||
return 0
|
||||
}
|
||||
func case2_2() int {
|
||||
println("eval case2_2 expr")
|
||||
return 2
|
||||
}
|
||||
|
||||
func case3() int {
|
||||
println("eval case3 expr")
|
||||
return 3
|
||||
}
|
||||
|
||||
func switchexpr() int {
|
||||
println("eval switch expr")
|
||||
return 2
|
||||
}
|
||||
|
||||
func main() {
|
||||
switch switchexpr() {
|
||||
case case1():
|
||||
println("exec case1")
|
||||
case case2_1(), case2_2():
|
||||
println("exec case2")
|
||||
case case3():
|
||||
println("exec case3")
|
||||
default:
|
||||
println("exec default")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
执行一下这个示例程序,我们得到如下结果:
|
||||
|
||||
eval switch expr
|
||||
eval case1 expr
|
||||
eval case2_1 expr
|
||||
eval case2_2 expr
|
||||
exec case2
|
||||
|
||||
|
||||
从输出结果中我们看到,Go先对switch expr表达式进行求值,然后再按case语句的出现顺序,从上到下进行逐一求值。在带有表达式列表的case语句中,Go会从左到右,对列表中的表达式进行求值,比如示例中的case2_1函数就执行于case2_2函数之前。
|
||||
|
||||
如果switch表达式匹配到了某个case表达式,那么程序就会执行这个case对应的代码分支,比如示例中的“exec case2”。这个分支后面的case表达式将不会再得到求值机会,比如示例不会执行case3函数。这里要注意一点,即便后面的case表达式求值后也能与switch表达式匹配上,Go也不会继续去对这些表达式进行求值了。
|
||||
|
||||
除了这一点外,你还要注意default分支。无论default分支出现在什么位置,它都只会在所有case都没有匹配上的情况下才会被执行的。
|
||||
|
||||
不知道你有没有发现,这里其实有一个优化小技巧,考虑到switch语句是按照case出现的先后顺序对case表达式进行求值的,那么如果我们将匹配成功概率高的case表达式排在前面,就会有助于提升switch语句执行效率。这点对于case后面是表达式列表的语句同样有效,我们可以将匹配概率最高的表达式放在表达式列表的最左侧。
|
||||
|
||||
到这里,我们已经了解了switch语句的一般形式以及执行次序。有了这个基础后,接下来我们就来看看这节课重点:Go语言的switch语句和它的“先祖”C语言中的Switch语句相比,都有哪些优化与创新?
|
||||
|
||||
switch语句的灵活性
|
||||
|
||||
为方便对比,我们先来简单了解一下C语言中的switch语句。C语言中的switch语句对表达式类型有限制,每个case语句只可以有一个表达式。而且,除非你显式使用break跳出,程序默认总是执行下一个case语句。这些特性开发人员带来了使用上的心智负担。
|
||||
|
||||
相较于C语言中switch语句的“死板”,Go的switch语句表现出极大的灵活性,主要表现在如下几方面:
|
||||
|
||||
首先,switch语句各表达式的求值结果可以为各种类型值,只要它的类型支持比较操作就可以了。
|
||||
|
||||
C语言中,switch语句中使用的所有表达式的求值结果只能是int或枚举类型,其他类型都会被C编译器拒绝。
|
||||
|
||||
Go语言就宽容得多了,只要类型支持比较操作,都可以作为switch语句中的表达式类型。比如整型、布尔类型、字符串类型、复数类型、元素类型都是可比较类型的数组类型,甚至字段类型都是可比较类型的结构体类型,也可以。下面就是一个使用自定义结构体类型作为switch表达式类型的例子:
|
||||
|
||||
type person struct {
|
||||
name string
|
||||
age int
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := person{"tom", 13}
|
||||
switch p {
|
||||
case person{"tony", 33}:
|
||||
println("match tony")
|
||||
case person{"tom", 13}:
|
||||
println("match tom")
|
||||
case person{"lucy", 23}:
|
||||
println("match lucy")
|
||||
default:
|
||||
println("no match")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
不过,实际开发过程中,以结构体类型为switch表达式类型的情况并不常见,这里举这个例子仅是为了说明Go switch语句对各种类型支持的广泛性。
|
||||
|
||||
而且,当switch表达式的类型为布尔类型时,如果求值结果始终为true,那么我们甚至可以省略switch后面的表达式,比如下面例子:
|
||||
|
||||
// 带有initStmt语句的switch语句
|
||||
switch initStmt; {
|
||||
case bool_expr1:
|
||||
case bool_expr2:
|
||||
... ...
|
||||
}
|
||||
|
||||
// 没有initStmt语句的switch语句
|
||||
switch {
|
||||
case bool_expr1:
|
||||
case bool_expr2:
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
不过,这里要注意,在带有initStmt的情况下,如果我们省略switch表达式,那么initStmt后面的分号不能省略,因为initStmt是一个语句。
|
||||
|
||||
第二点:switch语句支持声明临时变量。
|
||||
|
||||
在前面介绍switch语句的一般形式中,我们看到,和if、for等控制结构语句一样,switch语句的initStmt可用来声明只在这个switch隐式代码块中使用的变量,这种就近声明的变量最大程度地缩小了变量的作用域。
|
||||
|
||||
第三点:case语句支持表达式列表。
|
||||
|
||||
在C语言中,如果要让多个case分支的执行相同的代码逻辑,我们只能通过下面的方式实现:
|
||||
|
||||
void check_work_day(int a) {
|
||||
switch(a) {
|
||||
case 1:
|
||||
case 2:
|
||||
case 3:
|
||||
case 4:
|
||||
case 5:
|
||||
printf("it is a work day\n");
|
||||
break;
|
||||
case 6:
|
||||
case 7:
|
||||
printf("it is a weekend day\n");
|
||||
break;
|
||||
default:
|
||||
printf("do you live on earth?\n");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在上面这段C代码中,case 1~case 5匹配成功后,执行的都是case 5中的代码逻辑,case 6~case 7匹配成功后,执行的都是case 7中的代码逻辑。
|
||||
|
||||
之所以可以实现这样的逻辑,是因为当C语言中的switch语句匹配到某个case后,如果这个case对应的代码逻辑中没有break语句,那么代码将继续执行下一个case。比如当a = 3时,case 3后面的代码为空逻辑,并且没有break语句,那么C会继续向下执行case4、case5,直到在case 5中调用了break,代码执行流才离开switch语句。
|
||||
|
||||
这样看,虽然C也能实现多case语句执行同一逻辑的功能,但在case分支较多的情况下,代码会显得十分冗长。
|
||||
|
||||
Go语言中的处理要好得多。Go语言中,switch语句在case中支持表达式列表。我们可以用表达式列表实现与上面的示例相同的处理逻辑:
|
||||
|
||||
func checkWorkday(a int) {
|
||||
switch a {
|
||||
case 1, 2, 3, 4, 5:
|
||||
println("it is a work day")
|
||||
case 6, 7:
|
||||
println("it is a weekend day")
|
||||
default:
|
||||
println("are you live on earth")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
根据前面我们讲过的switch语句的执行次序,理解上面这个例子应该不难。和C语言实现相比,使用case表达式列表的Go实现简单、清晰、易懂。
|
||||
|
||||
第四点:取消了默认执行下一个case代码逻辑的语义。
|
||||
|
||||
在前面的描述和check_work_day这个C代码示例中,你都能感受到,在C语言中,如果匹配到的case对应的代码分支中没有显式调用break语句,那么代码将继续执行下一个case的代码分支,这种“隐式语义”并不符合日常算法的常规逻辑,这也经常被诟病为C语言的一个缺陷。要修复这个缺陷,我们只能在每个case执行语句中都显式调用break。
|
||||
|
||||
Go语言中的Swith语句就修复了C语言的这个缺陷,取消了默认执行下一个case代码逻辑的“非常规”语义,每个case对应的分支代码执行完后就结束switch语句。
|
||||
|
||||
如果在少数场景下,你需要执行下一个case的代码逻辑,你可以显式使用Go提供的关键字fallthrough来实现,这也是Go“显式”设计哲学的一个体现。下面就是一个使用fallthrough的switch语句的例子,我们简单来看一下:
|
||||
|
||||
func case1() int {
|
||||
println("eval case1 expr")
|
||||
return 1
|
||||
}
|
||||
|
||||
func case2() int {
|
||||
println("eval case2 expr")
|
||||
return 2
|
||||
}
|
||||
|
||||
func switchexpr() int {
|
||||
println("eval switch expr")
|
||||
return 1
|
||||
}
|
||||
|
||||
func main() {
|
||||
switch switchexpr() {
|
||||
case case1():
|
||||
println("exec case1")
|
||||
fallthrough
|
||||
case case2():
|
||||
println("exec case2")
|
||||
fallthrough
|
||||
default:
|
||||
println("exec default")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
执行一下这个示例程序,我们得到这样的结果:
|
||||
|
||||
eval switch expr
|
||||
eval case1 expr
|
||||
exec case1
|
||||
exec case2
|
||||
exec default
|
||||
|
||||
|
||||
我们看到,switch expr的求值结果与case1匹配成功,Go执行了case1对应的代码分支。而且,由于case1代码分支中显式使用了fallthrough,执行完case1后,代码执行流并没有离开switch语句,而是继续执行下一个case,也就是case2的代码分支。
|
||||
|
||||
这里有一个注意点,由于fallthrough的存在,Go不会对case2的表达式做求值操作,而会直接执行case2对应的代码分支。而且,在这里case2中的代码分支也显式使用了fallthrough,于是最后一个代码分支,也就是default分支对应的代码也被执行了。
|
||||
|
||||
另外,还有一点要注意的是,如果某个case语句已经是switch语句中的最后一个case了,并且它的后面也没有default分支了,那么这个case中就不能再使用fallthrough,否则编译器就会报错。
|
||||
|
||||
到这里,我们看到Go的switch语句不仅修复了C语言switch的缺陷,还为Go开发人员提供了更大的灵活性,我们可以使用更多类型表达式作为switch表达式类型,也可以使用case表达式列表简化实现逻辑,还可以自行根据需要,确定是否使用fallthrough关键字继续向下执行下一个case的代码分支。
|
||||
|
||||
除了这些之外,Go语言的switch语句还支持求值结果为类型信息的表达式,也就是type switch语句,接下来我们就详细分析一下。
|
||||
|
||||
type switch
|
||||
|
||||
“type switch”这是一种特殊的switch语句用法,我们通过一个例子来看一下它具体的使用形式:
|
||||
|
||||
func main() {
|
||||
var x interface{} = 13
|
||||
switch x.(type) {
|
||||
case nil:
|
||||
println("x is nil")
|
||||
case int:
|
||||
println("the type of x is int")
|
||||
case string:
|
||||
println("the type of x is string")
|
||||
case bool:
|
||||
println("the type of x is string")
|
||||
default:
|
||||
println("don't support the type")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们看到,这个例子中switch语句的形式与前面是一致的,不同的是switch与case两个关键字后面跟着的表达式。
|
||||
|
||||
switch关键字后面跟着的表达式为x.(type),这种表达式形式是switch语句专有的,而且也只能在switch语句中使用。这个表达式中的x必须是一个接口类型变量,表达式的求值结果是这个接口类型变量对应的动态类型。
|
||||
|
||||
什么是一个接口类型的动态类型呢?我们简单解释一下。以上面的代码var x interface{} = 13为例,x是一个接口类型变量,它的静态类型为interface{},如果我们将整型值13赋值给x,x这个接口变量的动态类型就为int。关于接口类型变量的动态类型,我们后面还会详细讲,这里先简单了解一下就可以了。
|
||||
|
||||
接着,case关键字后面接的就不是普通意义上的表达式了,而是一个个具体的类型。这样,Go就能使用变量x的动态类型与各个case中的类型进行匹配,之后的逻辑就都是一样的了。
|
||||
|
||||
现在我们运行上面示例程序,输出了x的动态变量类型:
|
||||
|
||||
the type of x is int
|
||||
|
||||
|
||||
不过,通过x.(type),我们除了可以获得变量x的动态类型信息之外,也能获得其动态类型对应的值信息,现在我们把上面的例子改造一下:
|
||||
|
||||
func main() {
|
||||
var x interface{} = 13
|
||||
switch v := x.(type) {
|
||||
case nil:
|
||||
println("v is nil")
|
||||
case int:
|
||||
println("the type of v is int, v =", v)
|
||||
case string:
|
||||
println("the type of v is string, v =", v)
|
||||
case bool:
|
||||
println("the type of v is bool, v =", v)
|
||||
default:
|
||||
println("don't support the type")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里我们将switch后面的表达式由x.(type)换成了v := x.(type)。对于后者,你千万不要认为变量v存储的是类型信息,其实v存储的是变量x的动态类型对应的值信息,这样我们在接下来的case执行路径中就可以使用变量v中的值信息了。
|
||||
|
||||
然后,我们运行上面示例,可以得到v的动态类型和值:
|
||||
|
||||
the type of v is int, v = 13
|
||||
|
||||
|
||||
另外,你可以发现,在前面的type switch演示示例中,我们一直使用interface{}这种接口类型的变量,Go中所有类型都实现了interface{}类型,所以case后面可以是任意类型信息。
|
||||
|
||||
但如果在switch后面使用了某个特定的接口类型I,那么case后面就只能使用实现了接口类型I的类型了,否则Go编译器会报错。你可以看看这个例子:
|
||||
|
||||
type I interface {
|
||||
M()
|
||||
}
|
||||
|
||||
type T struct {
|
||||
}
|
||||
|
||||
func (T) M() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
var i I = t
|
||||
switch i.(type) {
|
||||
case T:
|
||||
println("it is type T")
|
||||
case int:
|
||||
println("it is type int")
|
||||
case string:
|
||||
println("it is type string")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们在type switch中使用了自定义的接口类型I。那么,理论上所有case后面的类型都只能是实现了接口I的类型。但在这段代码中,只有类型T实现了接口类型I,Go原生类型int与string都没有实现接口I,于是在编译上述代码时,编译器会报出如下错误信息:
|
||||
|
||||
19:2: impossible type switch case: i (type I) cannot have dynamic type int (missing M method)
|
||||
21:2: impossible type switch case: i (type I) cannot have dynamic type string (missing M method)
|
||||
|
||||
|
||||
好了,到这里,关于switch语句语法层面的知识就都学习完了。Go对switch语句的优化与增强使得我们在日常使用switch时很少遇到坑,但这也并不意味着没有,最后我们就来看在Go编码过程中,我们可能遇到的一个与switch使用有关的问题,跳不出循环的break。
|
||||
|
||||
跳不出循环的break
|
||||
|
||||
在上一节课讲解break语句的时候,我们曾举了一个找出整型切片中第一个偶数的例子,当时我们是把for与if语句结合起来实现的。现在,我们把那个例子中if分支结构换成这节课学习的switch分支结构试试看。我们这里直接看改造后的代码:
|
||||
|
||||
func main() {
|
||||
var sl = []int{5, 19, 6, 3, 8, 12}
|
||||
var firstEven int = -1
|
||||
|
||||
// find first even number of the interger slice
|
||||
for i := 0; i < len(sl); i++ {
|
||||
switch sl[i] % 2 {
|
||||
case 0:
|
||||
firstEven = sl[i]
|
||||
break
|
||||
case 1:
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
println(firstEven)
|
||||
}
|
||||
|
||||
|
||||
我们运行一下这个修改后的程序,得到结果为12。
|
||||
|
||||
奇怪,这个输出的值与我们的预期的好像不太一样。这段代码中,切片中的第一个偶数是6,而输出的结果却成了切片的最后一个偶数12。为什么会出现这种结果呢?
|
||||
|
||||
这就是Go中 break语句与switch分支结合使用会出现一个“小坑”。和我们习惯的C家族语言中的break不同,Go语言规范中明确规定,不带label的break语句中断执行并跳出的,是同一函数内break语句所在的最内层的for、switch或select。所以,上面这个例子的break语句实际上只跳出了switch语句,并没有跳出外层的for循环,这也就是程序未按我们预期执行的原因。
|
||||
|
||||
要修正这一问题,我们可以利用上节课学到的带label的break语句试试。这里我们也直接看看改进后的代码:
|
||||
|
||||
func main() {
|
||||
var sl = []int{5, 19, 6, 3, 8, 12}
|
||||
var firstEven int = -1
|
||||
|
||||
// find first even number of the interger slice
|
||||
loop:
|
||||
for i := 0; i < len(sl); i++ {
|
||||
switch sl[i] % 2 {
|
||||
case 0:
|
||||
firstEven = sl[i]
|
||||
break loop
|
||||
case 1:
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
println(firstEven) // 6
|
||||
}
|
||||
|
||||
|
||||
在改进后的例子中,我们定义了一个label:loop,这个label附在for循环的外面,指代for循环的执行。当代码执行到“break loop”时,程序将停止label loop所指代的for循环的执行。关于带有label的break语句,你可以再回顾一下第19讲,这里就不多说了。
|
||||
|
||||
和switch语句一样能阻拦break跳出的还有一个语句,那就是select,我们后面讲解并发程序设计的时候再来详细分析。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们讲解了Go语言提供的另一种分支控制结构:switch语句。和if分支语句相比,在一些执行分支较多的场景下,使用switch分支控制语句可以让代码更简洁、可读性更好。
|
||||
|
||||
Go语言的switch语句继承自C语言,但“青出于蓝而胜于蓝”,Go不但修正了C语言中switch语句默认执行下一个case的“坑”,还对switch语句进行了改进与创新,包括支持更多类型、支持表达式列表等,让switch的表达力得到进一步提升。
|
||||
|
||||
除了使用常规表达式作为switch表达式和case表达式之外,Go switch语句又创新性地支持type switch,也就是用类型信息作为分支条件判断的操作数。在Go中,这种使用方式也是switch所独有的。这里,我们要注意的是只有接口类型变量才能使用type switch,并且所有case语句中的类型必须实现switch关键字后面变量的接口类型。
|
||||
|
||||
最后还需要你记住的是switch会阻拦break语句跳出for循环,就像我们这节课最后那个例子中那样,对于初学者来说,这是一个很容易掉下去的坑,你一定不要走弯路。
|
||||
|
||||
思考题
|
||||
|
||||
为了验证在多分支下基于switch语句实现的分支控制更为简洁,你可以尝试将这节课中的那些稍复杂一点的例子,改写为基于if条件分支的实现,然后再对比一下两种实现的复杂性,直观体会一下switch语句的优点。
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言中的switch语句感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
382
专栏/TonyBai·Go语言第一课/21函数:请叫我“一等公民”.md
Normal file
382
专栏/TonyBai·Go语言第一课/21函数:请叫我“一等公民”.md
Normal file
@@ -0,0 +1,382 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 函数:请叫我“一等公民”
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在前面的几讲中,我们学习了用于对现实世界实体抽象的类型,以及用来实现算法逻辑控制的几种控制结构。从这一讲开始,我们来学习一下Go代码中的基本功能逻辑单元:函数。
|
||||
|
||||
学到这里,相信你对Go中的函数已经不陌生了,因为我们在前面的示例程序中一直都在使用函数。函数是现代编程语言的基本语法元素,无论是在命令式语言、面向对象语言还是动态脚本语言中,函数都位列C位。
|
||||
|
||||
Go语言也不例外。在Go语言中,函数是唯一一种基于特定输入,实现特定任务并可返回任务执行结果的代码块(Go语言中的方法本质上也是函数)。如果忽略Go包在Go代码组织层面的作用,我们可以说Go程序就是一组函数的集合,实际上,我们日常的Go代码编写大多都集中在实现某个函数上。
|
||||
|
||||
但“一龙生九子,九子各不同”!虽然各种编程语言都加入了函数这个语法元素,但各个语言中函数的形式与特点又有不同。那么Go语言中函数又有哪些独特之处呢?考虑到函数的重要性,我们会用三节课的时间,全面系统地讲解Go语言的函数。
|
||||
|
||||
在这一节课中,我们就先来学习一下函数基础,以及Go函数最与众不同的一大特点。我们先从最基本的函数声明开始说起。
|
||||
|
||||
Go函数与函数声明
|
||||
|
||||
函数对应的英文单词是Function,Function这个单词原本是功能、职责的意思。编程语言使用Function这个单词,表示将一个大问题分解后而形成的、若干具有特定功能或职责的小任务,可以说十分贴切。函数代表的小任务可以在一个程序中被多次使用,甚至可以在不同程序中被使用,因此函数的出现也提升了整个程序界代码复用的水平。
|
||||
|
||||
那Go语言中,函数相关的语法形式是怎样的呢?我们先来看最常用的Go函数声明。
|
||||
|
||||
在Go中,我们定义一个函数的最常用方式就是使用函数声明。我们以Go标准库fmt包提供的Fprintf函数为例,看一下一个普通Go函数的声明长啥样:
|
||||
|
||||
|
||||
|
||||
我们看到一个Go函数的声明由五部分组成,我们一个个来拆解一下。
|
||||
|
||||
第一部分是关键字func,Go函数声明必须以关键字func开始。
|
||||
|
||||
第二部分是函数名。函数名是指代函数定义的标识符,函数声明后,我们会通过函数名这个标识符来使用这个函数。在同一个Go包中,函数名应该是唯一的,并且它也遵守Go标识符的导出规则,也就是我们之前说的,首字母大写的函数名指代的函数是可以在包外使用的,小写的就只在包内可见。
|
||||
|
||||
第三部分是参数列表。参数列表中声明了我们将要在函数体中使用的各个参数。参数列表紧接在函数名的后面,并用一个括号包裹。它使用逗号作为参数间的分隔符,而且每个参数的参数名在前,参数类型在后,这和变量声明中变量名与类型的排列方式是一致的。
|
||||
|
||||
另外,Go函数支持变长参数,也就是一个形式参数可以对应数量不定的实际参数。Fprintf就是一个支持变长参数的函数,你可以看到它第三个形式参数a就是一个变长参数,而且变长参数与普通参数在声明时的不同点,就在于它会在类型前面增加了一个“…”符号。关于函数对变长参数的支持,我们在后面还会再讲。
|
||||
|
||||
第四部分是返回值列表。返回值承载了函数执行后要返回给调用者的结果,返回值列表声明了这些返回值的类型,返回值列表的位置紧接在参数列表后面,两者之间用一个空格隔开。不过,上图中比较特殊,Fprintf函数的返回值列表不仅声明了返回值的类型,还声明了返回值的名称,这种返回值被称为具名返回值。多数情况下,我们不需要这么做,只需声明返回值的类型即可。
|
||||
|
||||
最后,放在一对大括号内的是函数体,函数的具体实现都放在这里。不过,函数声明中的函数体是可选的。如果没有函数体,说明这个函数可能是在Go语言之外实现的,比如使用汇编语言实现,然后通过链接器将实现与声明中的函数名链接到一起。没有函数体的函数声明是更高级的话题了,你感兴趣可以自己去了解一下,我们这里还是先打好基础。
|
||||
|
||||
看到这里,你可能会问:同为声明,为啥函数声明与之前学过的变量声明在形式上差距这么大呢? 变量声明中的变量名、类型名和初值与上面的函数声明是怎么对应的呢?
|
||||
|
||||
为了让更好地理解函数声明,也给我们后续的讲解做铺垫,这里我们就横向对比一下,把上面的函数声明等价转换为变量声明的形式看看:
|
||||
|
||||
|
||||
|
||||
转换后的代码不仅和之前的函数声明是等价的,而且这也是完全合乎Go语法规则的代码。对照一下这两张图,你是不是有一种豁然开朗的感觉呢?这不就是在声明一个类型为函数类型的变量吗!
|
||||
|
||||
我们看到,函数声明中的函数名其实就是变量名,函数声明中的func关键字、参数列表和返回值列表共同构成了函数类型。而参数列表与返回值列表的组合也被称为函数签名,它是决定两个函数类型是否相同的决定因素。因此,函数类型也可以看成是由func关键字与函数签名组合而成的。
|
||||
|
||||
通常,在表述函数类型时,我们会省略函数签名参数列表中的参数名,以及返回值列表中的返回值变量名。比如上面Fprintf函数的函数类型是:
|
||||
|
||||
func(io.Writer, string, ...interface{}) (int, error)
|
||||
|
||||
|
||||
这样,如果两个函数类型的函数签名是相同的,即便参数列表中的参数名,以及返回值列表中的返回值变量名都是不同的,那么这两个函数类型也是相同类型,比如下面两个函数类型:
|
||||
|
||||
func (a int, b string) (results []string, err error)
|
||||
func (c int, d string) (sl []string, err error)
|
||||
|
||||
|
||||
如果我们把这两个函数类型的参数名与返回值变量名省略,那它们都是func (int, string) ([]string, error),因此它们是相同的函数类型。
|
||||
|
||||
到这里,我们可以得到这样一个结论:每个函数声明所定义的函数,仅仅是对应的函数类型的一个实例,就像var a int = 13这个变量声明语句中a是int类型的一个实例一样。
|
||||
|
||||
如果你还记得前面第17讲中、使用复合类型字面值对结构体类型变量进行显式初始化的内容,你一定会觉得上面这种、用变量声明来声明函数变量的形式,似曾相识,我们把这两种形式都以最简化的样子表现出来,看下面代码:
|
||||
|
||||
s := T{} // 使用复合类型字面值对结构体类型T的变量进行显式初始化
|
||||
f := func(){} // 使用变量声明形式的函数声明
|
||||
|
||||
|
||||
这里,T{}被称为复合类型字面值,那么处于同样位置的func(){}是什么呢?Go语言也为它准备了一个名字,叫“函数字面值(Function Literal)”。我们可以看到,函数字面值由函数类型与函数体组成,它特别像一个没有函数名的函数声明,因此我们也叫它匿名函数。匿名函数在Go中用途很广,稍后我们会细讲。
|
||||
|
||||
讲到这里,你可能会想:既然是等价的,那我以后就用这种变量声明的形式来声明一个函数吧。万万不可!我这里只是为了帮你理解函数声明做了一个等价变换。在Go中的绝大多数情况,我们还是会通过传统的函数声明来声明一个特定函数类型的实例,也就是我们俗称的“定义一个函数”。
|
||||
|
||||
好了,横向对比就到此为止了,现在我们继续回到函数声明中来, 详细看看函数声明的重要组成部分——参数。
|
||||
|
||||
函数参数的那些事儿
|
||||
|
||||
函数参数列表中的参数,是函数声明的、用于函数体实现的局部变量。由于函数分为声明与使用两个阶段,在不同阶段,参数的称谓也有不同。在函数声明阶段,我们把参数列表中的参数叫做形式参数(Parameter,简称形参),在函数体中,我们使用的都是形参;而在函数实际调用时传入的参数被称为实际参数(Argument,简称实参)。为了便于直观理解,我绘制了这张示意图,你可以参考一下:
|
||||
|
||||
|
||||
|
||||
当我们实际调用函数的时候,实参会传递给函数,并和形式参数逐一绑定,编译器会根据各个形参的类型与数量,来检查传入的实参的类型与数量是否匹配。只有匹配,程序才能继续执行函数调用,否则编译器就会报错。
|
||||
|
||||
Go语言中,函数参数传递采用是值传递的方式。所谓“值传递”,就是将实际参数在内存中的表示逐位拷贝(Bitwise Copy)到形式参数中。对于像整型、数组、结构体这类类型,它们的内存表示就是它们自身的数据内容,因此当这些类型作为实参类型时,值传递拷贝的就是它们自身,传递的开销也与它们自身的大小成正比。
|
||||
|
||||
但是像string、切片、map这些类型就不是了,它们的内存表示对应的是它们数据内容的“描述符”。当这些类型作为实参类型时,值传递拷贝的也是它们数据内容的“描述符”,不包括数据内容本身,所以这些类型传递的开销是固定的,与数据内容大小无关。这种只拷贝“描述符”,不拷贝实际数据内容的拷贝过程,也被称为“浅拷贝”。
|
||||
|
||||
不过函数参数的传递也有两个例外,当函数的形参为接口类型,或者形参是变长参数时,简单的值传递就不能满足要求了,这时Go编译器会介入:对于类型为接口类型的形参,Go编译器会把传递的实参赋值给对应的接口类型形参;对于为变长参数的形参,Go编译器会将零个或多个实参按一定形式转换为对应的变长形参。
|
||||
|
||||
那么这里,零个或多个传递给变长形式参数的实参,被Go编译器转换为何种形式了呢?我们通过下面示例代码来看一下:
|
||||
|
||||
func myAppend(sl []int, elems ...int) []int {
|
||||
fmt.Printf("%T\n", elems) // []int
|
||||
if len(elems) == 0 {
|
||||
println("no elems to append")
|
||||
return sl
|
||||
}
|
||||
|
||||
sl = append(sl, elems...)
|
||||
return sl
|
||||
}
|
||||
|
||||
func main() {
|
||||
sl := []int{1, 2, 3}
|
||||
sl = myAppend(sl) // no elems to append
|
||||
fmt.Println(sl) // [1 2 3]
|
||||
sl = myAppend(sl, 4, 5, 6)
|
||||
fmt.Println(sl) // [1 2 3 4 5 6]
|
||||
}
|
||||
|
||||
|
||||
我们重点看一下代码中的myAppend函数,这个函数基于append,实现了向一个整型切片追加数据的功能。它支持变长参数,它的第二个形参elems就是一个变长参数。myAppend函数通过Printf输出了变长参数的类型。执行这段代码,我们将看到变长参数elems的类型为[]int。
|
||||
|
||||
这也就说明,在Go中,变长参数实际上是通过切片来实现的。所以,我们在函数体中,就可以使用切片支持的所有操作来操作变长参数,这会大大简化了变长参数的使用复杂度。比如myAppend中,我们使用len函数就可以获取到传给变长参数的实参个数。
|
||||
|
||||
到这里,我们已经学习了函数声明的两个部分。接下来,我们再看看函数声明的最后一部分,返回值列表。
|
||||
|
||||
函数支持多返回值
|
||||
|
||||
和其他主流静态类型语言,比如C、C++和Java不同,Go函数支持多返回值。多返回值可以让函数将更多结果信息返回给它的调用者,Go语言的错误处理机制很大程度就是建立在多返回值的机制之上的,这个我们在后续课程中还会详细讲解。
|
||||
|
||||
函数返回值列表从形式上看主要有三种:
|
||||
|
||||
func foo() // 无返回值
|
||||
func foo() error // 仅有一个返回值
|
||||
func foo() (int, string, error) // 有2或2个以上返回值
|
||||
|
||||
|
||||
如果一个函数没有显式返回值,那么我们可以像第一种情况那样,在函数声明中省略返回值列表。而且,如果一个函数仅有一个返回值,那么通常我们在函数声明中,就不需要将返回值用括号括起来,如果是2个或2个以上的返回值,那我们还是需要用括号括起来的。
|
||||
|
||||
在函数声明的返回值列表中,我们通常会像上面例子那样,仅列举返回值的类型,但我们也可以像fmt.Fprintf函数的返回值列表那样,为每个返回值声明变量名,这种带有名字的返回值被称为具名返回值(Named Return Value)。这种具名返回值变量可以像函数体中声明的局部变量一样在函数体内使用。
|
||||
|
||||
那么在日常编码中,我们究竟该使用普通返回值形式,还是具名返回值形式呢?
|
||||
|
||||
Go标准库以及大多数项目代码中的函数,都选择了使用普通的非具名返回值形式。但在一些特定场景下,具名返回值也会得到应用。比如,当函数使用defer,而且还在defer函数中修改外部函数返回值时,具名返回值可以让代码显得更优雅清晰。关于defer的使用,我们会在后面课程中还会细讲。
|
||||
|
||||
再比如,当函数的返回值个数较多时,每次显式使用return语句时都会接一长串返回值,这时,我们用具名返回值可以让函数实现的可读性更好一些,比如下面Go标准库time包中的parseNanoseconds函数就是这样:
|
||||
|
||||
// $GOROOT/src/time/format.go
|
||||
func parseNanoseconds(value string, nbytes int) (ns int, rangeErrString string, err error) {
|
||||
if !commaOrPeriod(value[0]) {
|
||||
err = errBad
|
||||
return
|
||||
}
|
||||
if ns, err = atoi(value[1:nbytes]); err != nil {
|
||||
return
|
||||
}
|
||||
if ns < 0 || 1e9 <= ns {
|
||||
rangeErrString = "fractional second"
|
||||
return
|
||||
}
|
||||
|
||||
scaleDigits := 10 - nbytes
|
||||
for i := 0; i < scaleDigits; i++ {
|
||||
ns *= 10
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
了解了上面这些有关Go函数的基础知识后,接下来,我们来学习Go函数与众不同的一个特点,这个特点使得Go函数具有更大的灵活性和表达力。
|
||||
|
||||
函数是“一等公民”
|
||||
|
||||
这个特点就是,函数在Go语言中属于“一等公民(First-Class Citizen)”。要知道,并不是在所有编程语言中函数都是“一等公民”。
|
||||
|
||||
那么,什么是编程语言的“一等公民”呢?关于这个名词,业界和教科书都没有给出精准的定义。我们这里可以引用一下wiki发明人、C2站点作者沃德·坎宁安(Ward Cunningham)对“一等公民”的解释:
|
||||
|
||||
|
||||
如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value)一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”。拥有“一等公民”待遇的语法元素可以存储在变量中,可以作为参数传递给函数,可以在函数内部创建并可以作为返回值从函数返回。
|
||||
|
||||
|
||||
基于这个解释,我们来看看Go语言的函数作为“一等公民”,表现出的各种行为特征。
|
||||
|
||||
特征一:Go函数可以存储在变量中。
|
||||
|
||||
按照沃德·坎宁安对一等公民的解释,身为一等公民的语法元素是可以存储在变量中的。其实,这点我们在前面理解函数声明时已经验证过了,这里我们再用例子简单说明一下:
|
||||
|
||||
var (
|
||||
myFprintf = func(w io.Writer, format string, a ...interface{}) (int, error) {
|
||||
return fmt.Fprintf(w, format, a...)
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Printf("%T\n", myFprintf) // func(io.Writer, string, ...interface {}) (int, error)
|
||||
myFprintf(os.Stdout, "%s\n", "Hello, Go") // 输出Hello,Go
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们把新创建的一个匿名函数赋值给了一个名为myFprintf的变量,通过这个变量,我们便可以调用刚刚定义的匿名函数。然后我们再通过Printf输出myFprintf变量的类型,也会发现结果与我们预期的函数类型是相符的。
|
||||
|
||||
特征二:支持在函数内创建并通过返回值返回。
|
||||
|
||||
Go函数不仅可以在函数外创建,还可以在函数内创建。而且由于函数可以存储在变量中,所以函数也可以在创建后,作为函数返回值返回。我们来看下面这个例子:
|
||||
|
||||
func setup(task string) func() {
|
||||
println("do some setup stuff for", task)
|
||||
return func() {
|
||||
println("do some teardown stuff for", task)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
teardown := setup("demo")
|
||||
defer teardown()
|
||||
println("do some bussiness stuff")
|
||||
}
|
||||
|
||||
|
||||
这个例子,模拟了执行一些重要逻辑之前的上下文建立(setup),以及之后的上下文拆除(teardown)。在一些单元测试的代码中,我们也经常会在执行某些用例之前,建立此次执行的上下文(setup),并在这些用例执行后拆除上下文(teardown),避免这次执行对后续用例执行的干扰。
|
||||
|
||||
在这个例子中,我们在setup函数中创建了这次执行的上下文拆除函数,并通过返回值的形式,将这个拆除函数返回给了setup函数的调用者。setup函数的调用者,在执行完对应这次执行上下文的重要逻辑后,再调用setup函数返回的拆除函数,就可以完成对上下文的拆除了。
|
||||
|
||||
从这段代码中我们也可以看到,setup函数中创建的拆除函数也是一个匿名函数,但和前面我们看到的匿名函数有一个不同,这个不同就在于这个匿名函数使用了定义它的函数setup的局部变量task,这样的匿名函数在Go中也被称为闭包(Closure)。
|
||||
|
||||
闭包本质上就是一个匿名函数或叫函数字面值,它们可以引用它的包裹函数,也就是创建它们的函数中定义的变量。然后,这些变量在包裹函数和匿名函数之间共享,只要闭包可以被访问,这些共享的变量就会继续存在。显然,Go语言的闭包特性也是建立在“函数是一等公民”特性的基础上的,后面我们还会讲解涉及到闭包的内容。
|
||||
|
||||
特征三:作为参数传入函数。
|
||||
|
||||
既然函数可以存储在变量中,也可以作为返回值返回,那我们可以理所当然地想到,把函数作为参数传入函数也是可行的。比如我们在日常编码时经常使用、标准库time包的AfterFunc函数,就是一个接受函数类型参数的典型例子。你可以看看下面这行代码,这里通过AfterFunc函数设置了一个2秒的定时器,并传入了时间到了后要执行的函数。这里传入的就是一个匿名函数:
|
||||
|
||||
time.AfterFunc(time.Second*2, func() { println("timer fired") })
|
||||
|
||||
|
||||
特征四:拥有自己的类型。
|
||||
|
||||
通过我们前面的讲解,你可以知道,作为一等公民的整型值拥有自己的类型int,而这个整型值只是类型int的一个实例,其他作为一等公民的字符串值、布尔值等类型也都拥有自己类型。那函数呢?
|
||||
|
||||
在前面讲解函数声明时,我们曾得到过这样一个结论:每个函数声明定义的函数仅仅是对应的函数类型的一个实例,就像var a int = 13这个变量声明语句中的a,只是int类型的一个实例一样。换句话说,每个函数都和整型值、字符串值等一等公民一样,拥有自己的类型,也就是我们讲过的函数类型。
|
||||
|
||||
我们甚至可以基于函数类型来自定义类型,就像基于整型、字符串类型等类型来自定义类型一样。下面代码中的HandlerFunc、visitFunc就是Go标准库中,基于函数类型进行自定义的类型:
|
||||
|
||||
// $GOROOT/src/net/http/server.go
|
||||
type HandlerFunc func(ResponseWriter, *Request)
|
||||
|
||||
// $GOROOT/src/sort/genzfunc.go
|
||||
type visitFunc func(ast.Node) ast.Visitor
|
||||
|
||||
|
||||
到这里,我们已经可以看到,Go函数确实表现出了沃德·坎宁安诠释中“一等公民”的所有特征:Go函数可以存储在变量中,可以在函数内创建并通过返回值返回,可以作为参数传递给其他函数,可以拥有自己的类型。通过这些分析,你也能感受到,和C/C++等语言中的函数相比,作为“一等公民”的Go函数拥有难得的灵活性。
|
||||
|
||||
那么在实际生产中,我们怎么才能发挥出这种灵活性的最大效用,帮助我们写出更加优雅简洁的Go代码呢?接下来,我们就看几个这方面的例子。
|
||||
|
||||
函数“一等公民”特性的高效运用
|
||||
|
||||
应用一:函数类型的妙用
|
||||
|
||||
Go函数是“一等公民”,也就是说,它拥有自己的类型。而且,整型、字符串型等所有类型都可以进行的操作,比如显式转型,也同样可以用在函数类型上面,也就是说,函数也可以被显式转型。并且,这样的转型在特定的领域具有奇妙的作用,一个最为典型的示例就是标准库http包中的HandlerFunc这个类型。我们来看一个使用了这个类型的例子:
|
||||
|
||||
func greeting(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "Welcome, Gopher!\n")
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.ListenAndServe(":8080", http.HandlerFunc(greeting))
|
||||
}
|
||||
|
||||
|
||||
这我们日常最常见的、用Go构建Web Server的例子。它的工作机制也很简单,就是当用户通过浏览器,或者类似curl这样的命令行工具,访问Web server的8080端口时,会收到“Welcome, Gopher!”这样的文字应答。我们在09讲曾讲过使用http包编写web server的方法,但当时我没有进一步讲解其中的原理,这一节课中我们就补上这一点。
|
||||
|
||||
我们先来看一下http包的函数ListenAndServe的源码:
|
||||
|
||||
// $GOROOT/src/net/http/server.go
|
||||
func ListenAndServe(addr string, handler Handler) error {
|
||||
server := &Server{Addr: addr, Handler: handler}
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
|
||||
函数ListenAndServe会把来自客户端的http请求,交给它的第二个参数handler处理,而这里handler参数的类型http.Handler,是一个自定义的接口类型,它的源码是这样的:
|
||||
|
||||
// $GOROOT/src/net/http/server.go
|
||||
type Handler interface {
|
||||
ServeHTTP(ResponseWriter, *Request)
|
||||
}
|
||||
|
||||
|
||||
我们还没有系统学习接口类型,你现在只要知道接口是一组方法的集合就好了。这个接口只有一个方法ServeHTTP,他的函数类型是func(http.ResponseWriter, *http.Request)。这和我们自己定义的http请求处理函数greeting的类型是一致的,但是我们没法直接将greeting作为参数值传入,否则编译器会报错:
|
||||
|
||||
func(http.ResponseWriter, *http.Request) does not implement http.Handler (missing ServeHTTP method)
|
||||
|
||||
|
||||
这里,编译器提示我们,函数greeting还没有实现接口Handler的方法,无法将它赋值给Handler类型的参数。现在我们再回过头来看下代码,代码中我们也没有直接将greeting传给ListenAndServe函数,而是将http.HandlerFunc(greeting)作为参数传给了ListenAndServe。那这个http.HandlerFunc究竟是什么呢?我们直接来看一下它的源码:
|
||||
|
||||
// $GOROOT/src/net/http/server.go
|
||||
|
||||
type HandlerFunc func(ResponseWriter, *Request)
|
||||
|
||||
// ServeHTTP calls f(w, r).
|
||||
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
|
||||
f(w, r)
|
||||
}
|
||||
|
||||
|
||||
通过它的源码我们看到,HandlerFunc是一个基于函数类型定义的新类型,它的底层类型为函数类型func(ResponseWriter, *Request)。这个类型有一个方法ServeHTTP,然后实现了Handler接口。也就是说http.HandlerFunc(greeting)这句代码的真正含义,是将函数greeting显式转换为HandlerFunc类型,后者实现了Handler接口,满足ListenAndServe函数第二个参数的要求。
|
||||
|
||||
另外,之所以http.HandlerFunc(greeting)这段代码可以通过编译器检查,正是因为HandlerFunc的底层类型是func(ResponseWriter, *Request),与greeting函数的类型是一致的,这和下面整型变量的显式转型原理也是一样的:
|
||||
|
||||
type MyInt int
|
||||
var x int = 5
|
||||
y := MyInt(x) // MyInt的底层类型为int,类比HandlerFunc的底层类型为func(ResponseWriter, *Request)
|
||||
|
||||
|
||||
应用二:利用闭包简化函数调用。
|
||||
|
||||
我们前面讲过,Go闭包是在函数内部创建的匿名函数,这个匿名函数可以访问创建它的函数的参数与局部变量。我们可以利用闭包的这一特性来简化函数调用,这里我们看一个具体例子:
|
||||
|
||||
func times(x, y int) int {
|
||||
return x * y
|
||||
}
|
||||
|
||||
|
||||
在上面的代码中,times函数用来进行两个整型数的乘法。我们使用times函数的时候需要传入两个实参,比如:
|
||||
|
||||
times(2, 5) // 计算2 x 5
|
||||
times(3, 5) // 计算3 x 5
|
||||
times(4, 5) // 计算4 x 5
|
||||
|
||||
|
||||
不过,有些场景存在一些高频使用的乘数,这个时候我们就没必要每次都传入这样的高频乘数了。那我们怎样能省去高频乘数的传入呢? 我们看看下面这个新函数partialTimes:
|
||||
|
||||
func partialTimes(x int) func(int) int {
|
||||
return func(y int) int {
|
||||
return times(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里,partialTimes的返回值是一个接受单一参数的函数,这个由partialTimes函数生成的匿名函数,使用了partialTimes函数的参数x。按照前面的定义,这个匿名函数就是一个闭包。partialTimes实质上就是用来生成以x为固定乘数的、接受另外一个乘数作为参数的、闭包函数的函数。当程序调用partialTimes(2)时,partialTimes实际上返回了一个调用times(2,y)的函数,这个过程的逻辑类似于下面代码:
|
||||
|
||||
timesTwo = func(y int) int {
|
||||
return times(2, y)
|
||||
}
|
||||
|
||||
|
||||
这个时候,我们再看看如何使用partialTimes,分别生成以2、3、4为固定高频乘数的乘法函数,以及这些生成的乘法函数的使用方法:
|
||||
|
||||
func main() {
|
||||
timesTwo := partialTimes(2) // 以高频乘数2为固定乘数的乘法函数
|
||||
timesThree := partialTimes(3) // 以高频乘数3为固定乘数的乘法函数
|
||||
timesFour := partialTimes(4) // 以高频乘数4为固定乘数的乘法函数
|
||||
fmt.Println(timesTwo(5)) // 10,等价于times(2, 5)
|
||||
fmt.Println(timesTwo(6)) // 12,等价于times(2, 6)
|
||||
fmt.Println(timesThree(5)) // 15,等价于times(3, 5)
|
||||
fmt.Println(timesThree(6)) // 18,等价于times(3, 6)
|
||||
fmt.Println(timesFour(5)) // 20,等价于times(4, 5)
|
||||
fmt.Println(timesFour(6)) // 24,等价于times(4, 6)
|
||||
}
|
||||
|
||||
|
||||
你可以看到,通过partialTimes,我们生成了三个带有固定乘数的函数。这样,我们在计算乘法时,就可以减少参数的重复输入。你看到这里可能会说,这种简化的程度十分有限啊!
|
||||
|
||||
不是的。这里我只是举了一个比较好理解的简单例子,在那些动辄就有5个以上参数的复杂函数中,减少参数的重复输入给开发人员带去的收益,可要比这个简单的例子大得多。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们讲解了Go代码中的基本功能逻辑单元:函数。函数这种语法元素的诞生,源于将大问题分解为若干小任务与代码复用。
|
||||
|
||||
Go语言中定义一个函数的最常用方式就是使用函数声明。函数声明虽然形式上与我们之前学过的变量声明不同,但本质其实是一致的,我们可以通过一个等价转换,将函数声明转换为一个以函数名为变量名、以函数字面值为初值的函数变量声明形式。这个转换是你深入理解函数的关键。
|
||||
|
||||
我们对函数字面值再进行了拆解。函数字面值是由函数类型与函数体组成的,而函数类型则是由func关键字+函数签名组成。再拆解,函数签名又包括函数的参数列表与返回值列表。通常我们说函数签名时,会省去参数名与返回值变量名,只保留各自的类型信息。函数签名相同的两个函数类型就是相同的函数类型。
|
||||
|
||||
而且,Go函数采用值传递的方式进行参数传递,对于string、切片、map等类型参数来说,这种传递方式传递的仅是“描述符”信息,是一种“浅拷贝”,这点你一定要牢记。Go函数支持多返回值,Go语言的错误处理机制就是建立在多返回值的基础上的。
|
||||
|
||||
最后,与传统的C、C++、Java等静态编程语言中的函数相比,Go函数的最大特点就是它属于Go语言的“一等公民”。Go函数具备一切作为“一等公民”的行为特征,包括函数可以存储在变量中、支持函数内创建并通过返回值返回、支持作为参数传递给函数,以及拥有自己的类型等。这些“一等公民”的特征,让Go函数表现出极大的灵活性。日常编码中,我们也可以利用这些特征进行一些巧妙的代码设计,让代码的实现更简化。
|
||||
|
||||
思考题
|
||||
|
||||
函数“一等公民”特性的高效运用的例子,显然不限于我们今天提到的这两个,这里我想让你思考一下,你还能列举出其他的高效运用函数“一等公民”特性的例子吗?
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言的函数感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
444
专栏/TonyBai·Go语言第一课/22函数:怎么结合多返回值进行错误处理?.md
Normal file
444
专栏/TonyBai·Go语言第一课/22函数:怎么结合多返回值进行错误处理?.md
Normal file
@@ -0,0 +1,444 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 函数:怎么结合多返回值进行错误处理?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
上一节课,我们开始了Go函数的学习,对Go语言中的函数已经有了基础的了解。那么,今天这节课,我们要再进一步,学习怎么做好函数设计。
|
||||
|
||||
在上节课的函数声明部分,我们提到,多返回值是Go语言函数,区别于其他主流静态编程语言中函数的一个重要特点。同时,它也是Go语言设计者建构Go语言错误处理机制的基础,而错误处理设计也是做函数设计的一个重要环节。
|
||||
|
||||
所以今天这节课,我们将会从Go语言的错误处理机制入手,围绕Go语言错误处理机制的原理、Go错误处理的常见策略,来学习一下如何结合函数的多返回值机制进行错误处理的设计。
|
||||
|
||||
这会让你建立起Go编码的统一错误处理思维,写出更健壮的、让你自己更有信心的Go代码。
|
||||
|
||||
要想做好错误处理设计,我们首先要先来了解Go语言错误设计的基本思路与原理。
|
||||
|
||||
Go语言是如何进行错误处理的?
|
||||
|
||||
采用什么错误处理方式,其实是一门编程语言在设计早期就要确定下来的基本机制,它在很大程度上影响着编程语言的语法形式、语言实现的难易程度,以及语言后续的演进方向。
|
||||
|
||||
我们前面已经多次提到,Go语言继承了“先祖”C语言的很多语法特性,在错误处理机制上也不例外,Go语言错误处理机制也是在C语言错误处理机制基础上的再创新。
|
||||
|
||||
那么这里,我们依然从源头讲起,先看看前辈C语言的错误处理机制。在C语言中,我们通常用一个类型为整型的函数返回值作为错误状态标识,函数调用者会基于值比较的方式,对这一代表错误状态的返回值进行检视。通常,这个返回值为0,就代表函数调用成功;如果这个返回值是其它值,那就代表函数调用出现错误。也就是说,函数调用者需要根据这个返回值代表的错误状态,来决定后续执行哪条错误处理路径上的代码。
|
||||
|
||||
C语言的这种简单的、基于错误值比较的错误处理机制有什么优点呢?
|
||||
|
||||
首先,它让每个开发人员必须显式地去关注和处理每个错误,经过显式错误处理的代码会更健壮,也会让开发人员对这些代码更有信心。
|
||||
|
||||
另外,你也可以发现,这些错误就是普通的值,所以我们不需要用额外的语言机制去处理它们,我们只需利用已有的语言机制,像处理其他普通类型值一样的去处理错误就可以了,这也让代码更容易调试,更容易针对每个错误处理的决策分支进行测试覆盖。C语言错误处理机制的这种简单与显式结合的特征,和Go语言设计哲学十分契合,于是Go语言设计者决定继承C语言这种错误处理机制。
|
||||
|
||||
不过C语言这种错误处理机制也有一些弊端。比如,由于C语言中的函数最多仅支持一个返回值,很多开发者会把这单一的返回值“一值多用”。什么意思呢?就是说,一个返回值,不仅要承载函数要返回给调用者的信息,又要承载函数调用的最终错误状态。比如C标准库中的fprintf函数的返回值就承载了两种含义。在正常情况下,它的返回值表示输出到FILE流中的字符数量,但如果出现错误,这个返回值就变成了一个负数,代表具体的错误值:
|
||||
|
||||
// stdio.h
|
||||
int fprintf(FILE * restrict stream, const char * restrict format, ...);
|
||||
|
||||
|
||||
特别是当返回值为其他类型,比如字符串的时候,我们还很难将它与错误状态融合到一起。这个时候,很多C开发人员要么使用输出参数,承载要返回给调用者的信息,要么自定义一个包含返回信息与错误状态的结构体,作为返回值类型。大家做法不一,就很难形成统一的错误处理策略。
|
||||
|
||||
为了避免这种情况,Go函数增加了多返回值机制,来支持错误状态与返回信息的分离,并建议开发者把要返回给调用者的信息和错误状态标识,分别放在不同的返回值中。
|
||||
|
||||
我们继续以上面C语言中的fprintf函数为例,Go标准库中有一个和功能等同的fmt.Fprintf的函数,这个函数就是使用一个独立的表示错误状态的返回值(如下面代码中的err),解决了fprintf函数中错误状态值与返回信息耦合在一起的问题:
|
||||
|
||||
// fmt包
|
||||
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error)
|
||||
|
||||
|
||||
我们看到,在fmt.Fprintf中,返回值n用来表示写入io.Writer中的字节个数,返回值err表示这个函数调用的最终状态,如果成功,err值就为nil,不成功就为特定的错误值。
|
||||
|
||||
另外我们还可以看到,fmt.Fprintf函数声明中代表错误状态的变量err的类型,并不是一个传统使用的整数类型,而是用了一个名为error的类型。
|
||||
|
||||
虽然,在Go语言中,我们依然可以像传统的C语言那样,用一个整型值来表示错误状态,但Go语言惯用法,是使用error这个接口类型表示错误,并且按惯例,我们通常将error类型返回值放在返回值列表的末尾,就像fmt.Fprintf函数声明中那样。
|
||||
|
||||
那么error接口类型究竟如何表示错误?我们又该如何构造一个满足error接口类型的错误值呢?我们继续向下看。
|
||||
|
||||
error类型与错误值构造
|
||||
|
||||
error接口是Go原生内置的类型,它的定义如下:
|
||||
|
||||
// $GOROOT/src/builtin/builtin.go
|
||||
type error interface {
|
||||
Error() string
|
||||
}
|
||||
|
||||
|
||||
任何实现了error的Error方法的类型的实例,都可以作为错误值赋值给error接口变量。那这里,问题就来了:难道为了构造一个错误值,我们还需要自定义一个新类型来实现error接口吗?
|
||||
|
||||
Go语言的设计者显然也想到了这一点,他们在标准库中提供了两种方便Go开发者构造错误值的方法: errors.New和fmt.Errorf。使用这两种方法,我们可以轻松构造出一个满足error接口的错误值,就像下面代码这样:
|
||||
|
||||
err := errors.New("your first demo error")
|
||||
errWithCtx = fmt.Errorf("index %d is out of bounds", i)
|
||||
|
||||
|
||||
这两种方法实际上返回的是同一个实现了error接口的类型的实例,这个未导出的类型就是errors.errorString,它的定义是这样的:
|
||||
|
||||
// $GOROOT/src/errors/errors.go
|
||||
|
||||
type errorString struct {
|
||||
s string
|
||||
}
|
||||
|
||||
func (e *errorString) Error() string {
|
||||
return e.s
|
||||
}
|
||||
|
||||
|
||||
大多数情况下,使用这两种方法构建的错误值就可以满足我们的需求了。但我们也要看到,虽然这两种构建错误值的方法很方便,但它们给错误处理者提供的错误上下文(Error Context)只限于以字符串形式呈现的信息,也就是Error方法返回的信息。
|
||||
|
||||
但在一些场景下,错误处理者需要从错误值中提取出更多信息,帮助他选择错误处理路径,显然这两种方法就不能满足了。这个时候,我们可以自定义错误类型来满足这一需求。比如:标准库中的net包就定义了一种携带额外错误上下文的错误类型:
|
||||
|
||||
// $GOROOT/src/net/net.go
|
||||
type OpError struct {
|
||||
Op string
|
||||
Net string
|
||||
Source Addr
|
||||
Addr Addr
|
||||
Err error
|
||||
}
|
||||
|
||||
|
||||
这样,错误处理者就可以根据这个类型的错误值提供的额外上下文信息,比如Op、Net、Source等,做出错误处理路径的选择,比如下面标准库中的代码:
|
||||
|
||||
// $GOROOT/src/net/http/server.go
|
||||
func isCommonNetReadError(err error) bool {
|
||||
if err == io.EOF {
|
||||
return true
|
||||
}
|
||||
if neterr, ok := err.(net.Error); ok && neterr.Timeout() {
|
||||
return true
|
||||
}
|
||||
if oe, ok := err.(*net.OpError); ok && oe.Op == "read" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
我们看到,上面这段代码利用类型断言,判断error类型变量err的动态类型是否为 *net.OpError或 net.Error。如果err的动态类型是 *net.OpError,那么类型断言就会返回这个动态类型的值(存储在oe中),代码就可以通过判断它的Op字段是否为”read”来判断它是否为CommonNetRead类型的错误。
|
||||
|
||||
不过这里,你不用过多了解类型断言(Type Assertion)到底是什么,你只需要知道通过类型断言,我们可以判断接口类型的动态类型,以及获取它动态类型的值接可以了。后面我们在讲解接口类型的时候还会再细讲。
|
||||
|
||||
那么,使用error类型,而不是传统意义上的整型或其他类型作为错误类型,有什么好处呢?至少有这三点好处:
|
||||
|
||||
第一点:统一了错误类型。
|
||||
|
||||
如果不同开发者的代码、不同项目中的代码,甚至标准库中的代码,都统一以error接口变量的形式呈现错误类型,就能在提升代码可读性的同时,还更容易形成统一的错误处理策略。这个我们下面会细讲。
|
||||
|
||||
第二点:错误是值。
|
||||
|
||||
我们构造的错误都是值,也就是说,即便赋值给error这个接口类型变量,我们也可以像整型值那样对错误做“==”和“!=”的逻辑比较,函数调用者检视错误时的体验保持不变。
|
||||
|
||||
第三点:易扩展,支持自定义错误上下文。
|
||||
|
||||
虽然错误以error接口变量的形式统一呈现,但我们很容易通过自定义错误类型来扩展我们的错误上下文,就像前面的Go标准库的OpError类型那样。
|
||||
|
||||
error接口是错误值的提供者与错误值的检视者之间的契约。error接口的实现者负责提供错误上下文,供负责错误处理的代码使用。这种错误具体上下文与作为错误值类型的error接口类型的解耦,也体现了Go组合设计哲学中“正交”的理念。
|
||||
|
||||
到这里,我们已经基本了解了Go错误处理机制、统一的错误值类型,以及错误值构造方法。在这些基础上,我们可以再进一步,学习Go语言的几种错误处理的惯用策略,学习这些策略将有助于我们提升函数错误处理设计的能力。
|
||||
|
||||
策略一:透明错误处理策略
|
||||
|
||||
简单来说,Go语言中的错误处理,就是根据函数/方法返回的error类型变量中携带的错误值信息做决策,并选择后续代码执行路径的过程。
|
||||
|
||||
这样,最简单的错误策略莫过于完全不关心返回错误值携带的具体上下文信息,只要发生错误就进入唯一的错误处理执行路径,比如下面这段代码:
|
||||
|
||||
err := doSomething()
|
||||
if err != nil {
|
||||
// 不关心err变量底层错误值所携带的具体上下文信息
|
||||
// 执行简单错误处理逻辑并返回
|
||||
... ...
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
这也是Go语言中最常见的错误处理策略,80%以上的Go错误处理情形都可以归类到这种策略下。在这种策略下,由于错误处理方并不关心错误值的上下文,所以错误值的构造方(如上面的函数doSomething)可以直接使用Go标准库提供的两个基本错误值构造方法errors.New和fmt.Errorf来构造错误值,就像下面这样:
|
||||
|
||||
func doSomething(...) error {
|
||||
... ...
|
||||
return errors.New("some error occurred")
|
||||
}
|
||||
|
||||
|
||||
这样构造出的错误值代表的上下文信息,对错误处理方是透明的,因此这种策略称为“透明错误处理策略”。在错误处理方不关心错误值上下文的前提下,透明错误处理策略能最大程度地减少错误处理方与错误值构造方之间的耦合关系。
|
||||
|
||||
策略二:“哨兵”错误处理策略
|
||||
|
||||
当错误处理方不能只根据“透明的错误值”就做出错误处理路径选取的情况下,错误处理方会尝试对返回的错误值进行检视,于是就有可能出现下面代码中的反模式:
|
||||
|
||||
data, err := b.Peek(1)
|
||||
if err != nil {
|
||||
switch err.Error() {
|
||||
case "bufio: negative count":
|
||||
// ... ...
|
||||
return
|
||||
case "bufio: buffer full":
|
||||
// ... ...
|
||||
return
|
||||
case "bufio: invalid use of UnreadByte":
|
||||
// ... ...
|
||||
return
|
||||
default:
|
||||
// ... ...
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
简单来说,反模式就是,错误处理方以透明错误值所能提供的唯一上下文信息(描述错误的字符串),作为错误处理路径选择的依据。但这种“反模式”会造成严重的隐式耦合。这也就意味着,错误值构造方不经意间的一次错误描述字符串的改动,都会造成错误处理方处理行为的变化,并且这种通过字符串比较的方式,对错误值进行检视的性能也很差。
|
||||
|
||||
那这有什么办法吗?Go标准库采用了定义导出的(Exported)“哨兵”错误值的方式,来辅助错误处理方检视(inspect)错误值并做出错误处理分支的决策,比如下面的bufio包中定义的“哨兵错误”:
|
||||
|
||||
// $GOROOT/src/bufio/bufio.go
|
||||
var (
|
||||
ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
|
||||
ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
|
||||
ErrBufferFull = errors.New("bufio: buffer full")
|
||||
ErrNegativeCount = errors.New("bufio: negative count")
|
||||
)
|
||||
|
||||
|
||||
下面的代码片段利用了上面的哨兵错误,进行错误处理分支的决策:
|
||||
|
||||
data, err := b.Peek(1)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case bufio.ErrNegativeCount:
|
||||
// ... ...
|
||||
return
|
||||
case bufio.ErrBufferFull:
|
||||
// ... ...
|
||||
return
|
||||
case bufio.ErrInvalidUnreadByte:
|
||||
// ... ...
|
||||
return
|
||||
default:
|
||||
// ... ...
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
你可以看到,一般“哨兵”错误值变量以ErrXXX格式命名。和透明错误策略相比,“哨兵”策略让错误处理方在有检视错误值的需求时候,可以“有的放矢”。
|
||||
|
||||
不过,对于API的开发者而言,暴露“哨兵”错误值也意味着这些错误值和包的公共函数/方法一起成为了API的一部分。一旦发布出去,开发者就要对它进行很好的维护。而“哨兵”错误值也让使用这些值的错误处理方对它产生了依赖。
|
||||
|
||||
从Go 1.13版本开始,标准库errors包提供了Is函数用于错误处理方对错误值的检视。Is函数类似于把一个error类型变量与“哨兵”错误值进行比较,比如下面代码:
|
||||
|
||||
// 类似 if err == ErrOutOfBounds{ … }
|
||||
if errors.Is(err, ErrOutOfBounds) {
|
||||
// 越界的错误处理
|
||||
}
|
||||
|
||||
|
||||
不同的是,如果error类型变量的底层错误值是一个包装错误(Wrapped Error),errors.Is方法会沿着该包装错误所在错误链(Error Chain),与链上所有被包装的错误(Wrapped Error)进行比较,直至找到一个匹配的错误为止。下面是Is函数应用的一个例子:
|
||||
|
||||
var ErrSentinel = errors.New("the underlying sentinel error")
|
||||
|
||||
func main() {
|
||||
err1 := fmt.Errorf("wrap sentinel: %w", ErrSentinel)
|
||||
err2 := fmt.Errorf("wrap err1: %w", err1)
|
||||
println(err2 == ErrSentinel) //false
|
||||
if errors.Is(err2, ErrSentinel) {
|
||||
println("err2 is ErrSentinel")
|
||||
return
|
||||
}
|
||||
|
||||
println("err2 is not ErrSentinel")
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们通过fmt.Errorf函数,并且使用%w创建包装错误变量err1和err2,其中err1实现了对ErrSentinel这个“哨兵错误值”的包装,而err2又对err1进行了包装,这样就形成了一条错误链。位于错误链最上层的是err2,位于最底层的是ErrSentinel。之后,我们再分别通过值比较和errors.Is这两种方法,判断err2与ErrSentinel的关系。运行上述代码,我们会看到如下结果:
|
||||
|
||||
false
|
||||
err2 is ErrSentinel
|
||||
|
||||
|
||||
我们看到,通过比较操作符对err2与ErrSentinel进行比较后,我们发现这二者并不相同。而errors.Is函数则会沿着err2所在错误链,向下找到被包装到最底层的“哨兵”错误值ErrSentinel。
|
||||
|
||||
所以,如果你使用的是Go 1.13及后续版本,我建议你尽量使用errors.Is方法去检视某个错误值是否就是某个预期错误值,或者包装了某个特定的“哨兵”错误值。
|
||||
|
||||
策略三:错误值类型检视策略
|
||||
|
||||
上面我们看到,基于Go标准库提供的错误值构造方法构造的“哨兵”错误值,除了让错误处理方可以“有的放矢”的进行值比较之外,并没有提供其他有效的错误上下文信息。那如果遇到错误处理方需要错误值提供更多的“错误上下文”的情况,上面这些错误处理策略和错误值构造方式都无法满足。
|
||||
|
||||
这种情况下,我们需要通过自定义错误类型的构造错误值的方式,来提供更多的“错误上下文”信息。并且,由于错误值都通过error接口变量统一呈现,要得到底层错误类型携带的错误上下文信息,错误处理方需要使用Go提供的类型断言机制(Type Assertion)或类型选择机制(Type Switch),这种错误处理方式,我称之为错误值类型检视策略。
|
||||
|
||||
我们来看一个标准库中的例子加深下理解,这个json包中自定义了一个UnmarshalTypeError的错误类型:
|
||||
|
||||
// $GOROOT/src/encoding/json/decode.go
|
||||
type UnmarshalTypeError struct {
|
||||
Value string
|
||||
Type reflect.Type
|
||||
Offset int64
|
||||
Struct string
|
||||
Field string
|
||||
}
|
||||
|
||||
|
||||
错误处理方可以通过错误类型检视策略,获得更多错误值的错误上下文信息,下面就是利用这一策略的json包的一个方法的实现:
|
||||
|
||||
// $GOROOT/src/encoding/json/decode.go
|
||||
func (d *decodeState) addErrorContext(err error) error {
|
||||
if d.errorContext.Struct != nil || len(d.errorContext.FieldStack) > 0 {
|
||||
switch err := err.(type) {
|
||||
case *UnmarshalTypeError:
|
||||
err.Struct = d.errorContext.Struct.Name()
|
||||
err.Field = strings.Join(d.errorContext.FieldStack, ".")
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
我们看到,这段代码通过类型switch语句得到了err变量代表的动态类型和值,然后在匹配的case分支中利用错误上下文信息进行处理。
|
||||
|
||||
这里,一般自定义导出的错误类型以XXXError的形式命名。和“哨兵”错误处理策略一样,错误值类型检视策略,由于暴露了自定义的错误类型给错误处理方,因此这些错误类型也和包的公共函数/方法一起,成为了API的一部分。一旦发布出去,开发者就要对它们进行很好的维护。而它们也让使用这些类型进行检视的错误处理方对其产生了依赖。
|
||||
|
||||
从Go 1.13版本开始,标准库errors包提供了As函数给错误处理方检视错误值。As函数类似于通过类型断言判断一个error类型变量是否为特定的自定义错误类型,如下面代码所示:
|
||||
|
||||
// 类似 if e, ok := err.(*MyError); ok { … }
|
||||
var e *MyError
|
||||
if errors.As(err, &e) {
|
||||
// 如果err类型为*MyError,变量e将被设置为对应的错误值
|
||||
}
|
||||
|
||||
|
||||
不同的是,如果error类型变量的动态错误值是一个包装错误,errors.As函数会沿着该包装错误所在错误链,与链上所有被包装的错误的类型进行比较,直至找到一个匹配的错误类型,就像errors.Is函数那样。下面是As函数应用的一个例子:
|
||||
|
||||
type MyError struct {
|
||||
e string
|
||||
}
|
||||
|
||||
func (e *MyError) Error() string {
|
||||
return e.e
|
||||
}
|
||||
|
||||
func main() {
|
||||
var err = &MyError{"MyError error demo"}
|
||||
err1 := fmt.Errorf("wrap err: %w", err)
|
||||
err2 := fmt.Errorf("wrap err1: %w", err1)
|
||||
var e *MyError
|
||||
if errors.As(err2, &e) {
|
||||
println("MyError is on the chain of err2")
|
||||
println(e == err)
|
||||
return
|
||||
}
|
||||
println("MyError is not on the chain of err2")
|
||||
}
|
||||
|
||||
|
||||
运行上述代码会得到:
|
||||
|
||||
MyError is on the chain of err2
|
||||
true
|
||||
|
||||
|
||||
我们看到,errors.As函数沿着err2所在错误链向下找到了被包装到最深处的错误值,并将err2与其类型 * MyError成功匹配。匹配成功后,errors.As会将匹配到的错误值存储到As函数的第二个参数中,这也是为什么println(e == err)输出true的原因。
|
||||
|
||||
所以,如果你使用的是Go 1.13及后续版本,请尽量使用errors.As方法去检视某个错误值是否是某自定义错误类型的实例。
|
||||
|
||||
策略四:错误行为特征检视策略
|
||||
|
||||
不知道你注意到没有,在前面我们已经讲过的三种策略中,其实只有第一种策略,也就是“透明错误处理策略”,有效降低了错误的构造方与错误处理方两者之间的耦合。虽然前面的策略二和策略三,都是我们实际编码中有效的错误处理策略,但其实使用这两种策略的代码,依然在错误的构造方与错误处理方两者之间建立了耦合。
|
||||
|
||||
那么除了“透明错误处理策略”外,我们是否还有手段可以降低错误处理方与错误值构造方的耦合呢?
|
||||
|
||||
在Go标准库中,我们发现了这样一种错误处理方式:将某个包中的错误类型归类,统一提取出一些公共的错误行为特征,并将这些错误行为特征放入一个公开的接口类型中。这种方式也被叫做错误行为特征检视策略。
|
||||
|
||||
以标准库中的net包为例,它将包内的所有错误类型的公共行为特征抽象并放入net.Error这个接口中,如下面代码:
|
||||
|
||||
// $GOROOT/src/net/net.go
|
||||
type Error interface {
|
||||
error
|
||||
Timeout() bool
|
||||
Temporary() bool
|
||||
}
|
||||
|
||||
|
||||
我们看到,net.Error接口包含两个用于判断错误行为特征的方法:Timeout用来判断是否是超时(Timeout)错误,Temporary用于判断是否是临时(Temporary)错误。
|
||||
|
||||
而错误处理方只需要依赖这个公共接口,就可以检视具体错误值的错误行为特征信息,并根据这些信息做出后续错误处理分支选择的决策。
|
||||
|
||||
这里,我们再看一个http包使用错误行为特征检视策略进行错误处理的例子,加深下理解:
|
||||
|
||||
// $GOROOT/src/net/http/server.go
|
||||
func (srv *Server) Serve(l net.Listener) error {
|
||||
... ...
|
||||
for {
|
||||
rw, e := l.Accept()
|
||||
if e != nil {
|
||||
select {
|
||||
case <-srv.getDoneChan():
|
||||
return ErrServerClosed
|
||||
default:
|
||||
}
|
||||
if ne, ok := e.(net.Error); ok && ne.Temporary() {
|
||||
// 注:这里对临时性(temporary)错误进行处理
|
||||
... ...
|
||||
time.Sleep(tempDelay)
|
||||
continue
|
||||
}
|
||||
return e
|
||||
}
|
||||
...
|
||||
}
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
在上面代码中,Accept方法实际上返回的错误类型为*OpError,它是net包中的一个自定义错误类型,它实现了错误公共特征接口net.Error,如下代码所示:
|
||||
|
||||
// $GOROOT/src/net/net.go
|
||||
type OpError struct {
|
||||
... ...
|
||||
// Err is the error that occurred during the operation.
|
||||
Err error
|
||||
}
|
||||
|
||||
type temporary interface {
|
||||
Temporary() bool
|
||||
}
|
||||
|
||||
func (e *OpError) Temporary() bool {
|
||||
if ne, ok := e.Err.(*os.SyscallError); ok {
|
||||
t, ok := ne.Err.(temporary)
|
||||
return ok && t.Temporary()
|
||||
}
|
||||
t, ok := e.Err.(temporary)
|
||||
return ok && t.Temporary()
|
||||
}
|
||||
|
||||
|
||||
因此,OpError实例可以被错误处理方通过net.Error接口的方法,判断它的行为是否满足Temporary或Timeout特征。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了。在这一讲中,我们重点讲解了Go函数设计中的一个重要环节:错误处理设计,希望通过这节课的内容,能帮助你建立起代码设计的意识,提高函数设计的水平。
|
||||
|
||||
Go语言继承了C语言的基于值比较的错误处理机制,但又在C语言的基础上做出了优化,也就是说,Go函数通过支持多返回值,消除了C语言中将错误状态值与返回给函数调用者的信息耦合在一起的弊端。
|
||||
|
||||
Go语言还统一错误类型为error接口类型,并提供了多种快速构建可赋值给error类型的错误值的函数,包括errors.New、fmt.Errorf等,我们还讲解了使用统一error作为错误类型的优点,你要深刻理解这一点。
|
||||
|
||||
基于Go错误处理机制、统一的错误值类型以及错误值构造方法的基础上,Go语言形成了多种错误处理的惯用策略,包括透明错误处理策略、“哨兵”错误处理策略、错误值类型检视策略以及错误行为特征检视策略等。这些策略都有适用的场合,但没有某种单一的错误处理策略可以适合所有项目或所有场合。
|
||||
|
||||
在错误处理策略选择上,我有一些个人的建议,你可以参考一下:
|
||||
|
||||
|
||||
请尽量使用“透明错误”处理策略,降低错误处理方与错误值构造方之间的耦合;
|
||||
如果可以从众多错误类型中提取公共的错误行为特征,那么请尽量使用“错误行为特征检视策略”;
|
||||
在上述两种策略无法实施的情况下,再使用“哨兵”策略和“错误值类型检视”策略;
|
||||
Go 1.13及后续版本中,尽量用errors.Is和errors.As函数替换原先的错误检视比较语句。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
这节课,我们列出了一些惯用的错误处理策略,当然,Go社区关于错误处理策略的讨论可能不止这些,你还见过哪些比较实用的错误处理策略吗?不妨在留言区和我们探讨一下吧。
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言的错误处理机制感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
637
专栏/TonyBai·Go语言第一课/23函数:怎么让函数更简洁健壮?.md
Normal file
637
专栏/TonyBai·Go语言第一课/23函数:怎么让函数更简洁健壮?.md
Normal file
@@ -0,0 +1,637 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 函数:怎么让函数更简洁健壮?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在上一节中,我们开始学习函数设计的相关知识,学习了如何基于Go函数的多返回值机制进行函数错误值构造方式的设计,你还记得那几种错误值构造与处理的策略吗?当然,良好的函数设计不仅仅要包含错误设计,函数的健壮性与简洁优雅也是我们在函数设计过程中要考虑的问题。
|
||||
|
||||
健壮的函数意味着,无论调用者如何使用你的函数,你的函数都能以合理的方式处理调用者的任何输入,并给调用者返回预设的、清晰的错误值。即便你的函数发生内部异常,函数也会尽力从异常中恢复,尽可能地不让异常蔓延到整个程序。而简洁优雅,则意味着函数的实现易读、易理解、更易维护,同时简洁也意味着统计意义上的更少的bug。
|
||||
|
||||
这一节课,我们就将继续我们的函数设计之旅,聚焦在健壮与简洁这两方面,我们需要重点关注的内容。
|
||||
|
||||
我们先从健壮性开始。
|
||||
|
||||
健壮性的“三不要”原则
|
||||
|
||||
函数的健壮性设计包括很多方面,首先就有最基本的“三不要”原则,我们简单来分析一下。
|
||||
|
||||
原则一:不要相信任何外部输入的参数。
|
||||
|
||||
函数的使用者可能是任何人,这些人在使用函数之前可能都没有阅读过任何手册或文档,他们会向函数传入你意想不到的参数。因此,为了保证函数的健壮性,函数需要对所有输入的参数进行合法性的检查。一旦发现问题,立即终止函数的执行,返回预设的错误值。
|
||||
|
||||
原则二:不要忽略任何一个错误。
|
||||
|
||||
在我们的函数实现中,也会调用标准库或第三方包提供的函数或方法。对于这些调用,我们不能假定它一定会成功,我们一定要显式地检查这些调用返回的错误值。一旦发现错误,要及时终止函数执行,防止错误继续传播。
|
||||
|
||||
原则三:不要假定异常不会发生。
|
||||
|
||||
这里,我们先要确定一个认知:异常不是错误。错误是可预期的,也是经常会发生的,我们有对应的公开错误码和错误处理预案,但异常却是少见的、意料之外的。通常意义上的异常,指的是硬件异常、操作系统异常、语言运行时异常,还有更大可能是代码中潜在bug导致的异常,比如代码中出现了以0作为分母,或者是数组越界访问等情况。
|
||||
|
||||
虽然异常发生是“小众事件”,但是我们不能假定异常不会发生。所以,函数设计时,我们就需要根据函数的角色和使用场景,考虑是否要在函数内设置异常捕捉和恢复的环节。
|
||||
|
||||
在这三条健壮性设计原则中,做到前两条是相对容易的,也没有太多技巧可言。但对第三条异常的处理,很多初学者拿捏不好。所以在这里,我们就重点说一下Go函数的异常处理设计。
|
||||
|
||||
认识Go语言中的异常:panic
|
||||
|
||||
不同编程语言表示异常(Exception)这个概念的语法都不相同。在Go语言中,异常这个概念由panic表示。一些教程或文章会把它译为恐慌,我这里依旧选择不译,保留panic的原汁原味。
|
||||
|
||||
panic指的是Go程序在运行时出现的一个异常情况。如果异常出现了,但没有被捕获并恢复,Go程序的执行就会被终止,即便出现异常的位置不在主Goroutine中也会这样。
|
||||
|
||||
在Go中,panic主要有两类来源,一类是来自Go运行时,另一类则是Go开发人员通过panic函数主动触发的。无论是哪种,一旦panic被触发,后续Go程序的执行过程都是一样的,这个过程被Go语言称为panicking。
|
||||
|
||||
Go官方文档以手工调用panic函数触发panic为例,对panicking这个过程进行了诠释:当函数F调用panic函数时,函数F的执行将停止。不过,函数F中已进行求值的deferred函数都会得到正常执行,执行完这些deferred函数后,函数F才会把控制权返还给其调用者。
|
||||
|
||||
对于函数F的调用者而言,函数F之后的行为就如同调用者调用的函数是panic一样,该panicking过程将继续在栈上进行下去,直到当前Goroutine中的所有函数都返回为止,然后Go程序将崩溃退出。
|
||||
|
||||
我们用一个例子来更直观地解释一下panicking这个过程:
|
||||
|
||||
func foo() {
|
||||
println("call foo")
|
||||
bar()
|
||||
println("exit foo")
|
||||
}
|
||||
|
||||
func bar() {
|
||||
println("call bar")
|
||||
panic("panic occurs in bar")
|
||||
zoo()
|
||||
println("exit bar")
|
||||
}
|
||||
|
||||
func zoo() {
|
||||
println("call zoo")
|
||||
println("exit zoo")
|
||||
}
|
||||
|
||||
func main() {
|
||||
println("call main")
|
||||
foo()
|
||||
println("exit main")
|
||||
}
|
||||
|
||||
|
||||
上面这个例子中,从Go应用入口开始,函数的调用次序依次为main -> foo -> bar -> zoo。在bar函数中,我们调用panic函数手动触发了panic。
|
||||
|
||||
我们执行这个程序的输出结果是这样的:
|
||||
|
||||
call main
|
||||
call foo
|
||||
call bar
|
||||
panic: panic occurs in bar
|
||||
|
||||
|
||||
我们再根据前面对panicking过程的诠释,理解一下这个例子。
|
||||
|
||||
这里,程序从入口函数main开始依次调用了foo、bar函数,在bar函数中,代码在调用zoo函数之前调用了panic函数触发了异常。那示例的panicking过程就从这开始了。bar函数调用panic函数之后,它自身的执行就此停止了,所以我们也没有看到代码继续进入zoo函数执行。并且,bar函数没有捕捉这个panic,这样这个panic就会沿着函数调用栈向上走,来到了bar函数的调用者foo函数中。
|
||||
|
||||
从foo函数的视角来看,这就好比将它对bar函数的调用,换成了对panic函数的调用一样。这样一来,foo函数的执行也被停止了。由于foo函数也没有捕捉panic,于是panic继续沿着函数调用栈向上走,来到了foo函数的调用者main函数中。
|
||||
|
||||
同理,从main函数的视角来看,这就好比将它对foo函数的调用,换成了对panic函数的调用一样。结果就是,main函数的执行也被终止了,于是整个程序异常退出,日志”exit main”也没有得到输出的机会。
|
||||
|
||||
不过,Go也提供了捕捉panic并恢复程序正常执行秩序的方法,我们可以通过recover函数来实现这一点。
|
||||
|
||||
我们继续用上面这个例子分析,在触发panic的bar函数中,对panic进行捕捉并恢复,我们直接来看恢复后,整个程序的执行情况是什么样的。这里,我们只列出了变更后的bar函数代码,其他函数代码并没有改变:
|
||||
|
||||
func bar() {
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
fmt.Println("recover the panic:", e)
|
||||
}
|
||||
}()
|
||||
|
||||
println("call bar")
|
||||
panic("panic occurs in bar")
|
||||
zoo()
|
||||
println("exit bar")
|
||||
}
|
||||
|
||||
|
||||
在更新版的bar函数中,我们在一个defer匿名函数中调用recover函数对panic进行了捕捉。recover是Go内置的专门用于恢复panic的函数,它必须被放在一个defer函数中才能生效。如果recover捕捉到panic,它就会返回以panic的具体内容为错误上下文信息的错误值。如果没有panic发生,那么recover将返回nil。而且,如果panic被recover捕捉到,panic引发的panicking过程就会停止。
|
||||
|
||||
关于defer函数的内容我们等会还会详细讲。此刻你只需要知道,无论bar函数正常执行结束,还是因panic异常终止,在那之前设置成功的defer函数都会得到执行就可以了。
|
||||
|
||||
我们执行更新后的程序,得到如下结果:
|
||||
|
||||
call main
|
||||
call foo
|
||||
call bar
|
||||
recover the panic: panic occurs in bar
|
||||
exit foo
|
||||
exit main
|
||||
|
||||
|
||||
我们可以看到main函数终于得以“善终”。那这个过程中究竟发生了什么呢?
|
||||
|
||||
在更新后的代码中,当bar函数调用panic函数触发异常后,bar函数的执行就会被中断。但这一次,在代码执行流回到bar函数调用者之前,bar函数中的、在panic之前就已经被设置成功的derfer函数就会被执行。这个匿名函数会调用recover把刚刚触发的panic恢复,这样,panic还没等沿着函数栈向上走,就被消除了。
|
||||
|
||||
所以,这个时候,从foo函数的视角来看,bar函数与正常返回没有什么差别。foo函数依旧继续向下执行,直至main函数成功返回。这样,这个程序的panic“危机”就解除了。
|
||||
|
||||
面对有如此行为特点的panic,我们应该如何应对呢?是不是在所有Go函数或方法中,我们都要用defer函数来捕捉和恢复panic呢?
|
||||
|
||||
如何应对panic?
|
||||
|
||||
其实大可不必。
|
||||
|
||||
一来,这样做会徒增开发人员函数实现时的心智负担。二来,很多函数非常简单,根本不会出现panic情况,我们增加panic捕获和恢复,反倒会增加函数的复杂性。同时,defer函数也不是“免费”的,也有带来性能开销(这个我们后面会讲解)。
|
||||
|
||||
那么,日常情况下我们应该怎么做呢?我这里提供了三点经验,你可以参考一下。
|
||||
|
||||
第一点:评估程序对panic的忍受度
|
||||
|
||||
首先,我们应该知道一个事实:不同应用对异常引起的程序崩溃退出的忍受度是不一样的。比如,一个单次运行于控制台窗口中的命令行交互类程序(CLI),和一个常驻内存的后端HTTP服务器程序,对异常崩溃的忍受度就是不同的。
|
||||
|
||||
前者即便因异常崩溃,对用户来说也仅仅是再重新运行一次而已。但后者一旦崩溃,就很可能导致整个网站停止服务。所以,针对各种应用对panic忍受度的差异,我们采取的应对panic的策略也应该有不同。像后端HTTP服务器程序这样的任务关键系统,我们就需要在特定位置捕捉并恢复panic,以保证服务器整体的健壮度。在这方面,Go标准库中的http server就是一个典型的代表。
|
||||
|
||||
Go标准库提供的http server采用的是,每个客户端连接都使用一个单独的Goroutine进行处理的并发处理模型。也就是说,客户端一旦与http server连接成功,http server就会为这个连接新创建一个Goroutine,并在这Goroutine中执行对应连接(conn)的serve方法,来处理这条连接上的客户端请求。
|
||||
|
||||
前面提到了panic的“危害”时,我们说过,无论在哪个Goroutine中发生未被恢复的panic,整个程序都将崩溃退出。所以,为了保证处理某一个客户端连接的Goroutine出现panic时,不影响到http server主Goroutine的运行,Go标准库在serve方法中加入了对panic的捕捉与恢复,下面是serve方法的部分代码片段:
|
||||
|
||||
// $GOROOT/src/net/http/server.go
|
||||
// Serve a new connection.
|
||||
func (c *conn) serve(ctx context.Context) {
|
||||
c.remoteAddr = c.rwc.RemoteAddr().String()
|
||||
ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
|
||||
defer func() {
|
||||
if err := recover(); err != nil && err != ErrAbortHandler {
|
||||
const size = 64 << 10
|
||||
buf := make([]byte, size)
|
||||
buf = buf[:runtime.Stack(buf, false)]
|
||||
c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf)
|
||||
}
|
||||
if !c.hijacked() {
|
||||
c.close()
|
||||
c.setState(c.rwc, StateClosed, runHooks)
|
||||
}
|
||||
}()
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
你可以看到,serve方法在一开始处就设置了defer函数,并在该函数中捕捉并恢复了可能出现的panic。这样,即便处理某个客户端连接的Goroutine出现panic,处理其他连接Goroutine以及http server自身都不会受到影响。
|
||||
|
||||
这种局部不要影响整体的异常处理策略,在很多并发程序中都有应用。并且,捕捉和恢复panic的位置通常都在子Goroutine的起始处,这样设置可以捕捉到后面代码中可能出现的所有panic,就像serve方法中那样。
|
||||
|
||||
第二点:提示潜在bug
|
||||
|
||||
有了对panic忍受度的评估,panic是不是也没有那么“恐怖”了呢?而且,我们甚至可以借助panic来帮助我们快速找到潜在bug。
|
||||
|
||||
C语言中有个很好用的辅助函数,断言(assert宏)。在使用C编写代码时,我们经常在一些代码执行路径上,使用断言来表达这段执行路径上某种条件一定为真的信心。断言为真,则程序处于正确运行状态,断言为否就是出现了意料之外的问题,而这个问题很可能就是一个潜在的bug,这时我们可以借助断言信息快速定位到问题所在。
|
||||
|
||||
不过,Go语言标准库中并没有提供断言之类的辅助函数,但我们可以使用panic,部分模拟断言对潜在bug的提示功能。比如,下面就是标准库encoding/json包使用panic指示潜在bug的一个例子:
|
||||
|
||||
// $GOROOT/src/encoding/json/decode.go
|
||||
... ...
|
||||
//当一些本不该发生的事情导致我们结束处理时,phasePanicMsg将被用作panic消息
|
||||
//它可以指示JSON解码器中的bug,或者
|
||||
//在解码器执行时还有其他代码正在修改数据切片。
|
||||
|
||||
const phasePanicMsg = "JSON decoder out of sync - data changing underfoot?"
|
||||
|
||||
func (d *decodeState) init(data []byte) *decodeState {
|
||||
d.data = data
|
||||
d.off = 0
|
||||
d.savedError = nil
|
||||
if d.errorContext != nil {
|
||||
d.errorContext.Struct = nil
|
||||
// Reuse the allocated space for the FieldStack slice.
|
||||
d.errorContext.FieldStack = d.errorContext.FieldStack[:0]
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *decodeState) valueQuoted() interface{} {
|
||||
switch d.opcode {
|
||||
default:
|
||||
panic(phasePanicMsg)
|
||||
|
||||
case scanBeginArray, scanBeginObject:
|
||||
d.skip()
|
||||
d.scanNext()
|
||||
|
||||
case scanBeginLiteral:
|
||||
v := d.literalInterface()
|
||||
switch v.(type) {
|
||||
case nil, string:
|
||||
return v
|
||||
}
|
||||
}
|
||||
return unquotedValue{}
|
||||
}
|
||||
|
||||
|
||||
我们看到,在valueQuoted这个方法中,如果程序执行流进入了default分支,那这个方法就会引发panic,这个panic会提示开发人员:这里很可能是一个bug。
|
||||
|
||||
同样,在json包的encode.go中也有使用panic做潜在bug提示的例子:
|
||||
|
||||
// $GOROOT/src/encoding/json/encode.go
|
||||
|
||||
func (w *reflectWithString) resolve() error {
|
||||
... ...
|
||||
switch w.k.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
w.ks = strconv.FormatInt(w.k.Int(), 10)
|
||||
return nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
w.ks = strconv.FormatUint(w.k.Uint(), 10)
|
||||
return nil
|
||||
}
|
||||
panic("unexpected map key type")
|
||||
}
|
||||
|
||||
|
||||
|
||||
这段代码中,resolve方法的最后一行代码就相当于一个“代码逻辑不会走到这里”的断言。一旦触发“断言”,这很可能就是一个潜在bug。
|
||||
|
||||
我们也看到,去掉这行代码并不会对resolve方法的逻辑造成任何影响,但真正出现问题时,开发人员就缺少了“断言”潜在bug提醒的辅助支持了。在Go标准库中,大多数panic的使用都是充当类似断言的作用的。
|
||||
|
||||
第三点:不要混淆异常与错误
|
||||
|
||||
在日常编码中,我经常会看到一些Go语言初学者,尤其是一些有过Java语言编程经验的程序员,会因为习惯了Java那种基于try-catch-finally的错误处理思维,而将Go panic当成Java的“checked exception”去用,这显然是混淆了Go中的异常与错误,这是Go错误处理的一种反模式。
|
||||
|
||||
查看Java标准类库,我们可以看到一些Java已预定义好的checked exception类,比较常见的有IOException、TimeoutException、EOFException、FileNotFoundException,等等。看到这里,你是不是觉得这些checked exception和我们上一节讲的“哨兵错误值”十分相似呢?。它们都是预定义好的、代表特定场景下的错误状态。
|
||||
|
||||
那Java的checked exception 和Go中的panic有啥差别呢?
|
||||
|
||||
Java的checked exception用于一些可预见的、常会发生的错误场景,比如,针对checked exception的所谓“异常处理”,就是针对这些场景的“错误处理预案”。也可以说对checked exception的使用、捕获、自定义等行为都是“有意而为之”的。
|
||||
|
||||
如果它非要和Go中的某种语法对应来看,它对应的也是Go的错误处理,也就是基于error值比较模型的错误处理。所以,Java中对checked exception处理的本质是错误处理,虽然它的名字用了带有“异常”的字样。
|
||||
|
||||
而Go中的panic呢,更接近于Java的RuntimeException+Error,而不是checked exception。我们前面提到过Java的checked exception是必须要被上层代码处理的,也就是要么捕获处理,要么重新抛给更上层。但是在Go中,我们通常会导入大量第三方包,而对于这些第三方包API中是否会引发panic,我们是不知道的。
|
||||
|
||||
因此上层代码,也就是API调用者根本不会去逐一了解API是否会引发panic,也没有义务去处理引发的panic。一旦你在编写的API中,像checked exception那样使用panic作为正常错误处理的手段,把引发的panic当作错误,那么你就会给你的API使用者带去大麻烦!因此,在Go中,作为API函数的作者,你一定不要将panic当作错误返回给API调用者。
|
||||
|
||||
到这里,我们已经基本讲完了函数健壮性设计要注意的各种事项,你一定要注意我前面提到的这几点。接下来,我们进入下一部分,看看函数的简洁性设计。
|
||||
|
||||
使用defer简化函数实现
|
||||
|
||||
对函数设计来说,如何实现简洁的目标是一个大话题。你可以从通用的设计原则去谈,比如函数要遵守单一职责,职责单一的函数肯定要比担负多种职责的函数更简单。你也可以从函数实现的规模去谈,比如函数体的规模要小,尽量控制在80行代码之内等。
|
||||
|
||||
但我们这个是Go语言的课程,所以我们的角度更侧重于Go中是否有现成的语法元素,可以帮助我们简化Go函数的设计和实现。我也把答案剧透给你,有的,它就是defer。
|
||||
|
||||
同样地,我们也用一个具体的例子来理解一下。日常开发中,我们经常会编写一些类似下面示例中的伪代码:
|
||||
|
||||
func doSomething() error {
|
||||
var mu sync.Mutex
|
||||
mu.Lock()
|
||||
|
||||
r1, err := OpenResource1()
|
||||
if err != nil {
|
||||
mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
r2, err := OpenResource2()
|
||||
if err != nil {
|
||||
r1.Close()
|
||||
mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
r3, err := OpenResource3()
|
||||
if err != nil {
|
||||
r2.Close()
|
||||
r1.Close()
|
||||
mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// 使用r1,r2, r3
|
||||
err = doWithResources()
|
||||
if err != nil {
|
||||
r3.Close()
|
||||
r2.Close()
|
||||
r1.Close()
|
||||
mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
r3.Close()
|
||||
r2.Close()
|
||||
r1.Close()
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
我们看到,这类代码的特点就是在函数中会申请一些资源,并在函数退出前释放或关闭这些资源,比如这里的互斥锁mu以及资源r1~r3就是这样。
|
||||
|
||||
函数的实现需要确保,无论函数的执行流是按预期顺利进行,还是出现错误,这些资源在函数退出时都要被及时、正确地释放。为此,我们需要尤为关注函数中的错误处理,在错误处理时不能遗漏对资源的释放。
|
||||
|
||||
但这样的要求,就导致我们在进行资源释放,尤其是有多个资源需要释放的时候,比如上面示例那样,会大大增加开发人员的心智负担。同时当待释放的资源个数较多时,整个代码逻辑就会变得十分复杂,程序可读性、健壮性也会随之下降。但即便如此,如果函数实现中的某段代码逻辑抛出panic,传统的错误处理机制依然没有办法捕获它并尝试从panic恢复。
|
||||
|
||||
Go语言引入defer的初衷,就是解决这些问题。那么,defer具体是怎么解决这些问题的呢?或者说,defer具体的运作机制是怎样的呢?
|
||||
|
||||
defer是Go语言提供的一种延迟调用机制,defer的运作离不开函数。怎么理解呢?这句话至少有以下两点含义:
|
||||
|
||||
|
||||
在Go中,只有在函数(和方法)内部才能使用defer;
|
||||
defer关键字后面只能接函数(或方法),这些函数被称为deferred函数。defer将它们注册到其所在Goroutine中,用于存放deferred函数的栈数据结构中,这些deferred函数将在执行defer的函数退出前,按后进先出(LIFO)的顺序被程序调度执行(如下图所示)。-
|
||||
|
||||
|
||||
|
||||
而且,无论是执行到函数体尾部返回,还是在某个错误处理分支显式return,又或是出现panic,已经存储到deferred函数栈中的函数,都会被调度执行。所以说,deferred函数是一个可以在任何情况下为函数进行收尾工作的好“伙伴”。
|
||||
|
||||
我们回到刚才的那个例子,如果我们把收尾工作挪到deferred函数中,那么代码将变成如下这个样子:
|
||||
|
||||
func doSomething() error {
|
||||
var mu sync.Mutex
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
r1, err := OpenResource1()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r1.Close()
|
||||
|
||||
r2, err := OpenResource2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r2.Close()
|
||||
|
||||
r3, err := OpenResource3()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r3.Close()
|
||||
|
||||
// 使用r1,r2, r3
|
||||
return doWithResources()
|
||||
}
|
||||
|
||||
|
||||
我们看到,使用defer后对函数实现逻辑的简化是显而易见的。而且,这里资源释放函数的defer注册动作,紧邻着资源申请成功的动作,这样成对出现的惯例就极大降低了遗漏资源释放的可能性,我们开发人员也不用再小心翼翼地在每个错误处理分支中检查是否遗漏了某个资源的释放动作。同时,代码的简化也意味代码可读性的提高,以及代码健壮度的增强。
|
||||
|
||||
那我们日常开发中使用defer,有没有什么要特别注意的呢?
|
||||
|
||||
defer使用的几个注意事项
|
||||
|
||||
大多数Gopher都喜欢defer,因为它不仅可以用来捕捉和恢复panic,还能让函数变得更简洁和健壮。但“工欲善其事,必先利其器“,一旦你要用defer,有几个关于defer使用的注意事项是你一定要提前了解清楚的,可以避免掉进一些不必要的“坑”。
|
||||
|
||||
第一点:明确哪些函数可以作为deferred函数
|
||||
|
||||
这里,你要清楚,对于自定义的函数或方法,defer可以给与无条件的支持,但是对于有返回值的自定义函数或方法,返回值会在deferred函数被调度执行的时候被自动丢弃。
|
||||
|
||||
而且,Go语言中除了自定义函数/方法,还有Go语言内置的/预定义的函数,这里我给出了Go语言内置函数的完全列表:
|
||||
|
||||
Functions:
|
||||
append cap close complex copy delete imag len
|
||||
make new panic print println real recover
|
||||
|
||||
|
||||
那么,Go语言中的内置函数是否都能作为deferred函数呢?我们看下面的示例:
|
||||
|
||||
// defer1.go
|
||||
|
||||
func bar() (int, int) {
|
||||
return 1, 2
|
||||
}
|
||||
|
||||
func foo() {
|
||||
var c chan int
|
||||
var sl []int
|
||||
var m = make(map[string]int, 10)
|
||||
m["item1"] = 1
|
||||
m["item2"] = 2
|
||||
var a = complex(1.0, -1.4)
|
||||
|
||||
var sl1 []int
|
||||
defer bar()
|
||||
defer append(sl, 11)
|
||||
defer cap(sl)
|
||||
defer close(c)
|
||||
defer complex(2, -2)
|
||||
defer copy(sl1, sl)
|
||||
defer delete(m, "item2")
|
||||
defer imag(a)
|
||||
defer len(sl)
|
||||
defer make([]int, 10)
|
||||
defer new(*int)
|
||||
defer panic(1)
|
||||
defer print("hello, defer\n")
|
||||
defer println("hello, defer")
|
||||
defer real(a)
|
||||
defer recover()
|
||||
}
|
||||
|
||||
func main() {
|
||||
foo()
|
||||
}
|
||||
|
||||
|
||||
运行这个示例代码,我们可以得到:
|
||||
|
||||
$go run defer1.go
|
||||
# command-line-arguments
|
||||
./defer1.go:17:2: defer discards result of append(sl, 11)
|
||||
./defer1.go:18:2: defer discards result of cap(sl)
|
||||
./defer1.go:20:2: defer discards result of complex(2, -2)
|
||||
./defer1.go:23:2: defer discards result of imag(a)
|
||||
./defer1.go:24:2: defer discards result of len(sl)
|
||||
./defer1.go:25:2: defer discards result of make([]int, 10)
|
||||
./defer1.go:26:2: defer discards result of new(*int)
|
||||
./defer1.go:30:2: defer discards result of real(a)
|
||||
|
||||
|
||||
我们看到,Go编译器居然给出一组错误提示!
|
||||
|
||||
从这组错误提示中我们可以看到,append、cap、len、make、new、imag等内置函数都是不能直接作为deferred函数的,而close、copy、delete、print、recover等内置函数则可以直接被defer设置为deferred函数。
|
||||
|
||||
不过,对于那些不能直接作为deferred函数的内置函数,我们可以使用一个包裹它的匿名函数来间接满足要求,以append为例是这样的:
|
||||
|
||||
defer func() {
|
||||
_ = append(sl, 11)
|
||||
}()
|
||||
|
||||
|
||||
第二点:注意defer关键字后面表达式的求值时机
|
||||
|
||||
这里,你一定要牢记一点:defer关键字后面的表达式,是在将deferred函数注册到deferred函数栈的时候进行求值的。
|
||||
|
||||
我们同样用一个典型的例子来说明一下defer后表达式的求值时机:
|
||||
|
||||
func foo1() {
|
||||
for i := 0; i <= 3; i++ {
|
||||
defer fmt.Println(i)
|
||||
}
|
||||
}
|
||||
|
||||
func foo2() {
|
||||
for i := 0; i <= 3; i++ {
|
||||
defer func(n int) {
|
||||
fmt.Println(n)
|
||||
}(i)
|
||||
}
|
||||
}
|
||||
|
||||
func foo3() {
|
||||
for i := 0; i <= 3; i++ {
|
||||
defer func() {
|
||||
fmt.Println(i)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("foo1 result:")
|
||||
foo1()
|
||||
fmt.Println("\nfoo2 result:")
|
||||
foo2()
|
||||
fmt.Println("\nfoo3 result:")
|
||||
foo3()
|
||||
}
|
||||
|
||||
|
||||
这里,我们一个个分析foo1、foo2和foo3中defer后的表达式的求值时机。
|
||||
|
||||
首先是foo1。foo1中defer后面直接用的是fmt.Println函数,每当defer将fmt.Println注册到deferred函数栈的时候,都会对Println后面的参数进行求值。根据上述代码逻辑,依次压入deferred函数栈的函数是:
|
||||
|
||||
fmt.Println(0)
|
||||
fmt.Println(1)
|
||||
fmt.Println(2)
|
||||
fmt.Println(3)
|
||||
|
||||
|
||||
因此,当foo1返回后,deferred函数被调度执行时,上述压入栈的deferred函数将以LIFO次序出栈执行,这时的输出的结果为:
|
||||
|
||||
3
|
||||
2
|
||||
1
|
||||
0
|
||||
|
||||
|
||||
然后我们再看foo2。foo2中defer后面接的是一个带有一个参数的匿名函数。每当defer将匿名函数注册到deferred函数栈的时候,都会对该匿名函数的参数进行求值。根据上述代码逻辑,依次压入deferred函数栈的函数是:
|
||||
|
||||
func(0)
|
||||
func(1)
|
||||
func(2)
|
||||
func(3)
|
||||
|
||||
|
||||
因此,当foo2返回后,deferred函数被调度执行时,上述压入栈的deferred函数将以LIFO次序出栈执行,因此输出的结果为:
|
||||
|
||||
3
|
||||
2
|
||||
1
|
||||
0
|
||||
|
||||
|
||||
最后我们来看foo3。foo3中defer后面接的是一个不带参数的匿名函数。根据上述代码逻辑,依次压入deferred函数栈的函数是:
|
||||
|
||||
func()
|
||||
func()
|
||||
func()
|
||||
func()
|
||||
|
||||
|
||||
所以,当foo3返回后,deferred函数被调度执行时,上述压入栈的deferred函数将以LIFO次序出栈执行。匿名函数会以闭包的方式访问外围函数的变量i,并通过Println输出i的值,此时i的值为4,因此foo3的输出结果为:
|
||||
|
||||
4
|
||||
4
|
||||
4
|
||||
4
|
||||
|
||||
|
||||
通过这些例子,我们可以看到,无论以何种形式将函数注册到defer中,deferred函数的参数值都是在注册的时候进行求值的。
|
||||
|
||||
第三点:知晓defer带来的性能损耗
|
||||
|
||||
通过前面的分析,我们可以看到,defer让我们进行资源释放(如文件描述符、锁)的过程变得优雅很多,也不易出错。但在性能敏感的应用中,defer带来的性能负担也是我们必须要知晓和权衡的问题。
|
||||
|
||||
这里,我们用一个性能基准测试(Benchmark),直观地看看defer究竟会带来多少性能损耗。基于Go工具链,我们可以很方便地为Go源码写一个性能基准测试,只需将代码放在以“_test.go”为后缀的源文件中,然后利用testing包提供的“框架”就可以了,我们看下面代码:
|
||||
|
||||
// defer_test.go
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func sum(max int) int {
|
||||
total := 0
|
||||
for i := 0; i < max; i++ {
|
||||
total += i
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
func fooWithDefer() {
|
||||
defer func() {
|
||||
sum(10)
|
||||
}()
|
||||
}
|
||||
func fooWithoutDefer() {
|
||||
sum(10)
|
||||
}
|
||||
|
||||
func BenchmarkFooWithDefer(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
fooWithDefer()
|
||||
}
|
||||
}
|
||||
func BenchmarkFooWithoutDefer(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
fooWithoutDefer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这个基准测试包含了两个测试用例,分别是BenchmarkFooWithDefer和BenchmarkFooWithoutDefer。前者测量的是带有defer的函数执行的性能,后者测量的是不带有defer的函数的执行的性能。
|
||||
|
||||
在Go 1.13前的版本中,defer带来的开销还是很大的。我们先用Go 1.12.7版本来运行一下上述基准测试,我们会得到如下结果:
|
||||
|
||||
$go test -bench . defer_test.go
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
BenchmarkFooWithDefer-8 30000000 42.6 ns/op
|
||||
BenchmarkFooWithoutDefer-8 300000000 5.44 ns/op
|
||||
PASS
|
||||
ok command-line-arguments 3.511s
|
||||
|
||||
|
||||
从这个基准测试结果中,我们可以清晰地看到:使用defer的函数的执行时间是没有使用defer函数的8倍左右。
|
||||
|
||||
但从Go 1.13版本开始,Go核心团队对defer性能进行了多次优化,到现在的Go 1.17版本,defer的开销已经足够小了。我们看看使用Go 1.17版本运行上述基准测试的结果:
|
||||
|
||||
$go test -bench . defer_test.go
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
BenchmarkFooWithDefer-8 194593353 6.183 ns/op
|
||||
BenchmarkFooWithoutDefer-8 284272650 4.259 ns/op
|
||||
PASS
|
||||
ok command-line-arguments 3.472s
|
||||
|
||||
|
||||
我们看到,带有defer的函数执行开销,仅是不带有defer的函数的执行开销的1.45倍左右,已经达到了几乎可以忽略不计的程度,我们可以放心使用。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了。在这一讲中,我们延续上一节的脉络,讲解了函数设计过程中应该考虑的、健壮性与简洁性方面的内容。
|
||||
|
||||
在函数健壮性方面,我给出了“三不要”原则,这三个原则你一定要记住。这里我们重点讲解了第三个原则:不要假定异常不会发生。借此,我们认识了Go语言中表示异常的panic,也学习了panic发生后的代码执行流程。基于panic的行为特征,我们给出了Go函数设计过程中应对panic的三点经验,这里你要注意,“评估程序对panic的忍受度”是我们选取应对panic措施的前提。
|
||||
|
||||
另外,对于来自像Java这样的、基于Exception进行错误处理的编程语言的Go初学者们,切记不要将panic与错误处理混淆。
|
||||
|
||||
接下来,我们又讲解了如何让函数实现更加简洁。简洁性对于函数来说意味着可读性更好,更易于理解,也有利于我们代码健壮性的提升。Go语言层面提供的defer机制可用于简化函数实现,尤其是在函数申请和释放资源个数较多的情况下。
|
||||
|
||||
如果我们要用好defer,前提就是要了解defer的运作机制,这里你要把握住两点:
|
||||
|
||||
|
||||
函数返回前,deferred函数是按照后入先出(LIFO)的顺序执行的;
|
||||
defer关键字是在注册函数时对函数的参数进行求值的。
|
||||
|
||||
|
||||
最后,在最新Go版本Go1.17中,使用defer带来的开销几乎可以忽略不计了,你可以放心使用。
|
||||
|
||||
思考题
|
||||
|
||||
defer是Gopher们都喜欢的语言机制,那么我想请你思考一下,除了捕捉panic、延迟释放资源外,我们日常编码中还有哪些使用defer的小技巧呢?一个小提示:你可以阅读一下Go标准库中关于defer的使用方法,看看是否能总结出一些小tips。
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言函数感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
351
专栏/TonyBai·Go语言第一课/24方法:理解“方法”的本质.md
Normal file
351
专栏/TonyBai·Go语言第一课/24方法:理解“方法”的本质.md
Normal file
@@ -0,0 +1,351 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 方法:理解“方法”的本质
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在前面的几讲中,我们对Go函数做了一个全面系统的学习。我们知道,函数是Go代码中的基本功能逻辑单元,它承载了Go程序的所有执行逻辑。可以说,Go程序的执行流本质上就是在函数调用栈中上下流动,从一个函数到另一个函数。
|
||||
|
||||
讲到这里,如果你做过提前预习,你可能要站出来反驳我了:“老师,你的说法太过绝对了,Go语言还有一种语法元素,方法(method),它也可以承载代码逻辑,程序也可以从一个方法流动到另外一个方法”。
|
||||
|
||||
别急!我这么说自然有我的道理,等会儿你就知道了。从这节课开始,我们会花三节课的时间,系统讲解Go语言中的方法。我们将围绕方法的本质、方法receiver的类型选择、方法集合,以及如何实现方法的“继承”这几个主题,进行讲解。
|
||||
|
||||
那么,在这一节课中,我就先来解答我们开头提到的这个问题,看看Go语言中的方法究竟是什么。等你掌握了方法的本质后,再来评判我的说法是否正确也不迟。
|
||||
|
||||
认识Go方法
|
||||
|
||||
我们知道,Go语言从设计伊始,就不支持经典的面向对象语法元素,比如类、对象、继承,等等,但Go语言仍保留了名为“方法(method)”的语法元素。当然,Go语言中的方法和面向对象中的方法并不是一样的。Go引入方法这一元素,并不是要支持面向对象编程范式,而是Go践行组合设计哲学的一种实现层面的需要。这个我们后面课程会展开细讲,这里你先了解一下就可以了。
|
||||
|
||||
简单了解之后,我们就以Go标准库net/http包中*Server类型的方法ListenAndServeTLS为例,讲解一下Go方法的一般形式:
|
||||
|
||||
|
||||
|
||||
Go中方法的声明和函数的声明有很多相似之处,我们可以参照着来学习。比如,Go的方法也是以func关键字修饰的,并且和函数一样,也包含方法名(对应函数名)、参数列表、返回值列表与方法体(对应函数体)。
|
||||
|
||||
而且,方法中的这几个部分和函数声明中对应的部分,在形式与语义方面都是一致的,比如:方法名字首字母大小写决定该方法是否是导出方法;方法参数列表支持变长参数;方法的返回值列表也支持具名返回值等。
|
||||
|
||||
不过,它们也有不同的地方。从上面这张图我们可以看到,和由五个部分组成的函数声明不同,Go方法的声明有六个组成部分,多的一个就是图中的receiver部分。在receiver部分声明的参数,Go称之为receiver参数,这个receiver参数也是方法与类型之间的纽带,也是方法与函数的最大不同。
|
||||
|
||||
接下来我们就重点说说这部分声明的receiver参数。
|
||||
|
||||
Go中的方法必须是归属于一个类型的,而receiver参数的类型就是这个方法归属的类型,或者说这个方法就是这个类型的一个方法。我们以上图中的ListenAndServeTLS为例,这里的receiver参数srv的类型为*Server,那么我们可以说,这个方法就是*Server类型的方法,
|
||||
|
||||
注意!这里我说的是ListenAndServeTLS是*Server类型的方法,而不是Server类型的方法。具体的原因,我们在后面课程还会细讲,这里你先有这个认知就好了。
|
||||
|
||||
为了方便讲解,我们将上面例子中的方法声明,转换为一个方法的一般声明形式:
|
||||
|
||||
func (t *T或T) MethodName(参数列表) (返回值列表) {
|
||||
// 方法体
|
||||
}
|
||||
|
||||
|
||||
无论receiver参数的类型为*T还是T,我们都把一般声明形式中的T叫做receiver参数t的基类型。如果t的类型为T,那么说这个方法是类型T的一个方法;如果t的类型为*T,那么就说这个方法是类型*T的一个方法。而且,要注意的是,每个方法只能有一个receiver参数,Go不支持在方法的receiver部分放置包含多个receiver参数的参数列表,或者变长receiver参数。
|
||||
|
||||
那么,receiver参数的作用域是什么呢?
|
||||
|
||||
你还记得我们在第11讲中提到过的、关于函数/方法作用域的结论吗?我们这里再复习一下:方法接收器(receiver)参数、函数/方法参数,以及返回值变量对应的作用域范围,都是函数/方法体对应的显式代码块。
|
||||
|
||||
这就意味着,receiver部分的参数名不能与方法参数列表中的形参名,以及具名返回值中的变量名存在冲突,必须在这个方法的作用域中具有唯一性。如果这个不唯一不存在,比如像下面例子中那样,Go编译器就会报错:
|
||||
|
||||
type T struct{}
|
||||
|
||||
func (t T) M(t string) { // 编译器报错:duplicate argument t (重复声明参数t)
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
不过,如果在方法体中,我们没有用到receiver参数,我们也可以省略receiver的参数名,就像下面这样:
|
||||
|
||||
type T struct{}
|
||||
|
||||
func (T) M(t string) {
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
仅当方法体中的实现不需要receiver参数参与时,我们才会省略receiver参数名,不过这一情况很少使用,你了解一下就好了。
|
||||
|
||||
除了receiver参数名字要保证唯一外,Go语言对receiver参数的基类型也有约束,那就是receiver参数的基类型本身不能为指针类型或接口类型。下面的例子分别演示了基类型为指针类型和接口类型时,Go编译器报错的情况:
|
||||
|
||||
type MyInt *int
|
||||
func (r MyInt) String() string { // r的基类型为MyInt,编译器报错:invalid receiver type MyInt (MyInt is a pointer type)
|
||||
return fmt.Sprintf("%d", *(*int)(r))
|
||||
}
|
||||
|
||||
type MyReader io.Reader
|
||||
func (r MyReader) Read(p []byte) (int, error) { // r的基类型为MyReader,编译器报错:invalid receiver type MyReader (MyReader is an interface type)
|
||||
return r.Read(p)
|
||||
}
|
||||
|
||||
|
||||
最后,Go对方法声明的位置也是有约束的,Go要求,方法声明要与receiver参数的基类型声明放在同一个包内。基于这个约束,我们还可以得到两个推论。
|
||||
|
||||
|
||||
第一个推论:我们不能为原生类型(诸如int、float64、map等)添加方法。-
|
||||
比如,下面的代码试图为Go原生类型int增加新方法Foo,这样做,Go编译器会报错:
|
||||
|
||||
|
||||
func (i int) Foo() string { // 编译器报错:cannot define new methods on non-local type int
|
||||
return fmt.Sprintf("%d", i)
|
||||
}
|
||||
|
||||
|
||||
|
||||
第二个推论:不能跨越Go包为其他包的类型声明新方法。-
|
||||
比如,下面的代码试图跨越包边界,为Go标准库中的http.Server类型添加新方法Foo,这样做,Go编译器同样会报错:
|
||||
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s http.Server) Foo() { // 编译器报错:cannot define new methods on non-local type http.Server
|
||||
}
|
||||
|
||||
|
||||
到这里,我们已经基本了解了Go方法的声明形式以及对receiver参数的相关约束。有了这些基础后,我们就可以看一下如何使用这些方法(method)。
|
||||
|
||||
我们直接还是通过一个例子理解一下。如果receiver参数的基类型为T,那么我们说receiver参数绑定在T上,我们可以通过*T或T的变量实例调用该方法:
|
||||
|
||||
type T struct{}
|
||||
|
||||
func (t T) M(n int) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
t.M(1) // 通过类型T的变量实例调用方法M
|
||||
|
||||
p := &T{}
|
||||
p.M(2) // 通过类型*T的变量实例调用方法M
|
||||
}
|
||||
|
||||
|
||||
不过,看到这里你可能会问,这段代码中,方法M是类型T的方法,那为什么通过*T类型变量也可以调用M方法呢?关于这个问题,我会在下一讲中告诉你原因,这里你先了解方法的调用方式就好了。
|
||||
|
||||
从上面这些分析中,我们也可以看到,和其他主流编程语言相比,Go语言的方法,只比函数多出了一个receiver参数,这就大大降低了Gopher们学习方法这一语法元素的门槛。
|
||||
|
||||
但即便如此,你在使用方法时可能仍然会有一些疑惑,比如,方法的类型是什么?我们是否可以将方法赋值给函数类型的变量?调用方法时方法对receiver参数的修改是不是外部可见的?要想解除你心中这些疑惑,我们就必须深入到方法的本质层面。
|
||||
|
||||
接下来我们就来看看本质上Go方法究竟是什么。
|
||||
|
||||
方法的本质是什么?
|
||||
|
||||
通过前面的学习,我们知道了Go的方法与Go中的类型是通过receiver联系在一起,我们可以为任何非内置原生类型定义方法,比如下面的类型T:
|
||||
|
||||
type T struct {
|
||||
a int
|
||||
}
|
||||
|
||||
func (t T) Get() int {
|
||||
return t.a
|
||||
}
|
||||
|
||||
func (t *T) Set(a int) int {
|
||||
t.a = a
|
||||
return t.a
|
||||
}
|
||||
|
||||
|
||||
我们可以和典型的面向对象语言C++做下对比。如果你了解C++语言,尤其是看过C++大牛、《C++ Primer》作者Stanley B·Lippman的大作《深入探索C++对象模型》,你大约会知道,C++中的对象在调用方法时,编译器会自动传入指向对象自身的this指针作为方法的第一个参数。
|
||||
|
||||
而Go方法中的原理也是相似的,只不过我们是将receiver参数以第一个参数的身份并入到方法的参数列表中。按照这个原理,我们示例中的类型T和*T的方法,就可以分别等价转换为下面的普通函数:
|
||||
|
||||
// 类型T的方法Get的等价函数
|
||||
func Get(t T) int {
|
||||
return t.a
|
||||
}
|
||||
|
||||
// 类型*T的方法Set的等价函数
|
||||
func Set(t *T, a int) int {
|
||||
t.a = a
|
||||
return t.a
|
||||
}
|
||||
|
||||
|
||||
这种等价转换后的函数的类型就是方法的类型。只不过在Go语言中,这种等价转换是由Go编译器在编译和生成代码时自动完成的。Go语言规范中还提供了方法表达式(Method Expression)的概念,可以让我们更充分地理解上面的等价转换,我们来看一下。
|
||||
|
||||
我们还以上面类型T以及它的方法为例,结合前面说过的Go方法的调用方式,我们可以得到下面代码:
|
||||
|
||||
var t T
|
||||
t.Get()
|
||||
(&t).Set(1)
|
||||
|
||||
|
||||
我们可以用另一种方式,把上面的方法调用做一个等价替换:
|
||||
|
||||
var t T
|
||||
T.Get(t)
|
||||
(*T).Set(&t, 1)
|
||||
|
||||
|
||||
这种直接以类型名T调用方法的表达方式,被称为Method Expression。通过Method Expression这种形式,类型T只能调用T的方法集合(Method Set)中的方法,同理类型*T也只能调用*T的方法集合中的方法。关于方法集合,我们会在下一讲中详细讲解。
|
||||
|
||||
我们看到,Method Expression有些类似于C++中的静态方法(Static Method),C++中的静态方法在使用时,以该C++类的某个对象实例作为第一个参数,而Go语言的Method Expression在使用时,同样以receiver参数所代表的类型实例作为第一个参数。
|
||||
|
||||
这种通过Method Expression对方法进行调用的方式,与我们之前所做的方法到函数的等价转换是如出一辙的。所以,Go语言中的方法的本质就是,一个以方法的receiver参数作为第一个参数的普通函数。
|
||||
|
||||
而且,Method Expression就是Go方法本质的最好体现,因为方法自身的类型就是一个普通函数的类型,我们甚至可以将它作为右值,赋值给一个函数类型的变量,比如下面示例:
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
f1 := (*T).Set // f1的类型,也是*T类型Set方法的类型:func (t *T, int)int
|
||||
f2 := T.Get // f2的类型,也是T类型Get方法的类型:func(t T)int
|
||||
fmt.Printf("the type of f1 is %T\n", f1) // the type of f1 is func(*main.T, int) int
|
||||
fmt.Printf("the type of f2 is %T\n", f2) // the type of f2 is func(main.T) int
|
||||
f1(&t, 3)
|
||||
fmt.Println(f2(t)) // 3
|
||||
}
|
||||
|
||||
|
||||
既然方法本质上也是函数,那么我们在这节课开头的争论也就有了答案,这已经能够证明我的说法是正确的。但看到这里,你可能会问:我知道方法的本质是函数又怎么样呢?它对我在实际编码工作有什么帮助吗?
|
||||
|
||||
下面我们就以一个实际例子来看看,如何基于对方法本质的深入理解,来分析解决实际编码工作中遇到的真实问题。
|
||||
|
||||
巧解难题
|
||||
|
||||
这个例子是来自于我个人博客的一次真实的读者咨询,他的问题代码是这样的:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type field struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (p *field) print() {
|
||||
fmt.Println(p.name)
|
||||
}
|
||||
|
||||
func main() {
|
||||
data1 := []*field{{"one"}, {"two"}, {"three"}}
|
||||
for _, v := range data1 {
|
||||
go v.print()
|
||||
}
|
||||
|
||||
data2 := []field{{"four"}, {"five"}, {"six"}}
|
||||
for _, v := range data2 {
|
||||
go v.print()
|
||||
}
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
|
||||
这段代码在我的多核macOS上的运行结果是这样(由于Goroutine调度顺序不同,你自己的运行结果中的行序可能与下面的有差异):
|
||||
|
||||
one
|
||||
two
|
||||
three
|
||||
six
|
||||
six
|
||||
six
|
||||
|
||||
|
||||
这位读者的问题显然是:为什么对data2迭代输出的结果是三个“six”,而不是four、five、six?
|
||||
|
||||
那我们就来分析一下。
|
||||
|
||||
首先,我们根据Go方法的本质,也就是一个以方法的receiver参数作为第一个参数的普通函数,对这个程序做个等价变换。这里我们利用Method Expression方式,等价变换后的源码如下:
|
||||
|
||||
type field struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (p *field) print() {
|
||||
fmt.Println(p.name)
|
||||
}
|
||||
|
||||
func main() {
|
||||
data1 := []*field{{"one"}, {"two"}, {"three"}}
|
||||
for _, v := range data1 {
|
||||
go (*field).print(v)
|
||||
}
|
||||
|
||||
data2 := []field{{"four"}, {"five"}, {"six"}}
|
||||
for _, v := range data2 {
|
||||
go (*field).print(&v)
|
||||
}
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
|
||||
这段代码中,我们把对field的方法print的调用,替换为Method Expression形式,替换前后的程序输出结果是一致的。但变换后,问题是不是豁然开朗了!我们可以很清楚地看到使用go关键字启动一个新Goroutine时,method expression形式的print函数是如何绑定参数的:
|
||||
|
||||
|
||||
迭代data1时,由于data1中的元素类型是field指针(*field),因此赋值后v就是元素地址,与print的receiver参数类型相同,每次调用(*field).print函数时直接传入的v即可,实际上传入的也是各个field元素的地址;
|
||||
|
||||
迭代data2时,由于data2中的元素类型是field(非指针),与print的receiver参数类型不同,因此需要将其取地址后再传入(*field).print函数。这样每次传入的&v实际上是变量v的地址,而不是切片data2中各元素的地址。
|
||||
|
||||
|
||||
在第19讲《控制结构:Go的for循环,仅此一种》中,我们学习过for range使用时应注意的几个问题,其中循环变量复用是关键的一个。这里的v在整个for range过程中只有一个,因此data2迭代完成之后,v是元素“six”的拷贝。
|
||||
|
||||
这样,一旦启动的各个子goroutine在main goroutine执行到Sleep时才被调度执行,那么最后的三个goroutine在打印&v时,实际打印的也就是在v中存放的值“six”。而前三个子goroutine各自传入的是元素“one”、“two”和“three”的地址,所以打印的就是“one”、“two”和“three”了。
|
||||
|
||||
那么原程序要如何修改,才能让它按我们期望,输出“one”、“two”、“three”、“four”、 “five”、“six”呢?
|
||||
|
||||
其实,我们只需要将field类型print方法的receiver类型由*field改为field就可以了。我们直接来看一下修改后的代码:
|
||||
|
||||
type field struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (p field) print() {
|
||||
fmt.Println(p.name)
|
||||
}
|
||||
|
||||
func main() {
|
||||
data1 := []*field{{"one"}, {"two"}, {"three"}}
|
||||
for _, v := range data1 {
|
||||
go v.print()
|
||||
}
|
||||
|
||||
data2 := []field{{"four"}, {"five"}, {"six"}}
|
||||
for _, v := range data2 {
|
||||
go v.print()
|
||||
}
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
|
||||
修改后的程序的输出结果是这样的(因Goroutine调度顺序不同,在你的机器上的结果输出顺序可能会有不同):
|
||||
|
||||
one
|
||||
two
|
||||
three
|
||||
four
|
||||
five
|
||||
six
|
||||
|
||||
|
||||
为什么这回就可以输出预期的值了呢?我把它留作这节课的思考题,你可以参考我的分析思路自行分析一下,欢迎你在留言区给出你的答案。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们开始讲解Go语言中除函数之外的、另一种可承载代码执行逻辑的语法元素:方法(method)。
|
||||
|
||||
我们要知道,Go提供方法这种语法,并非出自对经典面向对象编程范式支持的考虑,而是出自Go的组合设计哲学下类型系统实现层面上的需要。
|
||||
|
||||
Go方法在声明形式上相较于Go函数多了一个receiver组成部分,这个部分是方法与类型之间联系的纽带。我们可以在receiver部分声明receiver参数。但Go对receiver参数有诸多限制,比如只能有一个、参数名唯一、不能是变长参数等等。
|
||||
|
||||
除此之外,Go对receiver参数的基类型也是有约束的,即基类型本身不能是指针类型或接口类型。Go方法声明的位置也受到了Go规范的约束,方法声明必须与receiver参数的基类型在同一个包中。
|
||||
|
||||
Go方法本质上其实是一个函数,这个函数以方法的receiver参数作为第一个参数,Go编译器会在我们进行方法调用时协助进行这样的转换。牢记并理解方法的这个本质可以帮助我们在实际编码中解决一些奇怪的问题。
|
||||
|
||||
思考题
|
||||
|
||||
在“巧解难题”部分,我给你留了个问题,为啥我们只需要将field类型print方法的receiver类型,由*field改为field就可以输出预期的结果了呢?期待在留言区看到你的答案。
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言的方法感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
332
专栏/TonyBai·Go语言第一课/25方法:方法集合与如何选择receiver类型?.md
Normal file
332
专栏/TonyBai·Go语言第一课/25方法:方法集合与如何选择receiver类型?.md
Normal file
@@ -0,0 +1,332 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 方法:方法集合与如何选择receiver类型?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在上一节中我们开启了Go方法的学习,了解了Go语言中方法的组成、声明和实质。可以说,我们已经正式入门Go方法了。
|
||||
|
||||
入门Go方法后,和函数一样,我们要考虑如何进行方法设计的问题。由于在Go语言中,方法本质上就是函数,所以我们之前讲解的、关于函数设计的内容对方法也同样适用,比如错误处理设计、针对异常的处理策略、使用defer提升简洁性,等等。
|
||||
|
||||
但关于Go方法中独有的receiver组成部分,却没有现成的、可供我们参考的内容。而据我了解,初学者在学习Go方法时,最头疼的一个问题恰恰就是如何选择receiver参数的类型。
|
||||
|
||||
那么,在这一讲中,我们就来学习一下不同receiver参数类型对Go方法的影响,以及我们选择receiver参数类型时的一些经验原则。
|
||||
|
||||
receiver参数类型对Go方法的影响
|
||||
|
||||
要想为receiver参数选出合理的类型,我们先要了解不同的receiver参数类型会对Go方法产生怎样的影响。在上一节课中,我们分析了Go方法的本质,得出了“Go方法实质上是以方法的receiver参数作为第一个参数的普通函数”的结论。
|
||||
|
||||
对于函数参数类型对函数的影响,我们是很熟悉的。那么我们能不能将方法等价转换为对应的函数,再通过分析receiver参数类型对函数的影响,从而间接得出它对Go方法的影响呢?
|
||||
|
||||
我们可以基于这个思路试试看。我们直接来看下面例子中的两个Go方法,以及它们等价转换后的函数:
|
||||
|
||||
func (t T) M1() <=> F1(t T)
|
||||
func (t *T) M2() <=> F2(t *T)
|
||||
|
||||
|
||||
这个例子中有方法M1和M2。M1方法是receiver参数类型为T的一类方法的代表,而M2方法则代表了receiver参数类型为*T的另一类。下面我们分别来看看不同的receiver参数类型对M1和M2的影响。
|
||||
|
||||
|
||||
首先,当receiver参数的类型为T时:-
|
||||
当我们选择以T作为receiver参数类型时,M1方法等价转换为F1(t T)。我们知道,Go函数的参数采用的是值拷贝传递,也就是说,F1函数体中的t是T类型实例的一个副本。这样,我们在F1函数的实现中对参数t做任何修改,都只会影响副本,而不会影响到原T类型实例。
|
||||
|
||||
|
||||
据此我们可以得出结论:当我们的方法M1采用类型为T的receiver参数时,代表T类型实例的receiver参数以值传递方式传递到M1方法体中的,实际上是T类型实例的副本,M1方法体中对副本的任何修改操作,都不会影响到原T类型实例。
|
||||
|
||||
|
||||
第二,当receiver参数的类型为*T时:-
|
||||
当我们选择以*T作为receiver参数类型时,M2方法等价转换为F2(t *T)。同上面分析,我们传递给F2函数的t是T类型实例的地址,这样F2函数体中对参数t做的任何修改,都会反映到原T类型实例上。
|
||||
|
||||
|
||||
据此我们也可以得出结论:当我们的方法M2采用类型为*T的receiver参数时,代表*T类型实例的receiver参数以值传递方式传递到M2方法体中的,实际上是T类型实例的地址,M2方法体通过该地址可以对原T类型实例进行任何修改操作。
|
||||
|
||||
我们再通过一个更直观的例子,证明一下上面这个分析结果,看一下Go方法选择不同的receiver类型对原类型实例的影响:
|
||||
|
||||
package main
|
||||
|
||||
type T struct {
|
||||
a int
|
||||
}
|
||||
|
||||
func (t T) M1() {
|
||||
t.a = 10
|
||||
}
|
||||
|
||||
func (t *T) M2() {
|
||||
t.a = 11
|
||||
}
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
println(t.a) // 0
|
||||
|
||||
t.M1()
|
||||
println(t.a) // 0
|
||||
|
||||
p := &t
|
||||
p.M2()
|
||||
println(t.a) // 11
|
||||
}
|
||||
|
||||
|
||||
在这个示例中,我们为基类型T定义了两个方法M1和M2,其中M1的receiver参数类型为T,而M2的receiver参数类型为*T。M1和M2方法体都通过receiver参数t对t的字段a进行了修改。
|
||||
|
||||
但运行这个示例程序后,我们看到,方法M1由于使用了T作为receiver参数类型,它在方法体中修改的仅仅是T类型实例t的副本,原实例并没有受到影响。因此M1调用后,输出t.a的值仍为0。
|
||||
|
||||
而方法M2呢,由于使用了*T作为receiver参数类型,它在方法体中通过t修改的是实例本身,因此M2调用后,t.a的值变为了11,这些输出结果与我们前面的分析是一致的。
|
||||
|
||||
了解了不同类型的receiver参数对Go方法的影响后,我们就可以总结一下,日常编码中选择receiver的参数类型的时候,我们可以参考哪些原则。
|
||||
|
||||
选择receiver参数类型的第一个原则
|
||||
|
||||
基于上面的影响分析,我们可以得到选择receiver参数类型的第一个原则:如果Go方法要把对receiver参数代表的类型实例的修改,反映到原类型实例上,那么我们应该选择*T作为receiver参数的类型。
|
||||
|
||||
这个原则似乎很好掌握,不过这个时候,你可能会有个疑问:如果我们选择了*T作为Go方法receiver参数的类型,那么我们是不是只能通过*T类型变量调用该方法,而不能通过T类型变量调用了呢?这个问题恰恰也是上节课我们遗留的一个问题。我们改造一下上面例子看一下:
|
||||
|
||||
type T struct {
|
||||
a int
|
||||
}
|
||||
|
||||
func (t T) M1() {
|
||||
t.a = 10
|
||||
}
|
||||
|
||||
func (t *T) M2() {
|
||||
t.a = 11
|
||||
}
|
||||
|
||||
func main() {
|
||||
var t1 T
|
||||
println(t1.a) // 0
|
||||
t1.M1()
|
||||
println(t1.a) // 0
|
||||
t1.M2()
|
||||
println(t1.a) // 11
|
||||
|
||||
var t2 = &T{}
|
||||
println(t2.a) // 0
|
||||
t2.M1()
|
||||
println(t2.a) // 0
|
||||
t2.M2()
|
||||
println(t2.a) // 11
|
||||
}
|
||||
|
||||
|
||||
我们先来看看类型为T的实例t1。我们看到它不仅可以调用receiver参数类型为T的方法M1,它还可以直接调用receiver参数类型为*T的方法M2,并且调用完M2方法后,t1.a的值被修改为11了。
|
||||
|
||||
其实,T类型的实例t1之所以可以调用receiver参数类型为*T的方法M2,都是Go编译器在背后自动进行转换的结果。或者说,t1.M2()这种用法是Go提供的“语法糖”:Go判断t1的类型为T,也就是与方法M2的receiver参数类型*T不一致后,会自动将t1.M2()转换为(&t1).M2()。
|
||||
|
||||
同理,类型为*T的实例t2,它不仅可以调用receiver参数类型为*T的方法M2,还可以调用receiver参数类型为T的方法M1,这同样是因为Go编译器在背后做了转换。也就是,Go判断t2的类型为*T,与方法M1的receiver参数类型T不一致,就会自动将t2.M1()转换为(*t2).M1()。
|
||||
|
||||
通过这个实例,我们知道了这样一个结论:无论是T类型实例,还是*T类型实例,都既可以调用receiver为T类型的方法,也可以调用receiver为*T类型的方法。这样,我们在为方法选择receiver参数的类型的时候,就不需要担心这个方法不能被与receiver参数类型不一致的类型实例调用了。
|
||||
|
||||
选择receiver参数类型的第二个原则
|
||||
|
||||
前面我们第一个原则说的是,当我们要在方法中对receiver参数代表的类型实例进行修改,那我们要为receiver参数选择*T类型,但是如果我们不需要在方法中对类型实例进行修改呢?这个时候我们是为receiver参数选择T类型还是*T类型呢?
|
||||
|
||||
这也得分情况。一般情况下,我们通常会为receiver参数选择T类型,因为这样可以缩窄外部修改类型实例内部状态的“接触面”,也就是尽量少暴露可以修改类型内部状态的方法。
|
||||
|
||||
不过也有一个例外需要你特别注意。考虑到Go方法调用时,receiver参数是以值拷贝的形式传入方法中的。那么,如果receiver参数类型的size较大,以值拷贝形式传入就会导致较大的性能开销,这时我们选择*T作为receiver类型可能更好些。
|
||||
|
||||
以上这些可以作为我们选择receiver参数类型的第二个原则。
|
||||
|
||||
到这里,你可能会发出感慨:即便有两个原则,这似乎依旧很容易掌握!不要大意,这可没那么简单,这两条只是基础原则,还有一条更难理解的原则在下面呢。
|
||||
|
||||
不过在讲解这第三条原则之前,我们先要了解一个基本概念:方法集合(Method Set),它是我们理解第三条原则的前提。
|
||||
|
||||
方法集合
|
||||
|
||||
在了解方法集合是什么之前,我们先通过一个示例,直观了解一下为什么要有方法集合,它主要用来解决什么问题:
|
||||
|
||||
type Interface interface {
|
||||
M1()
|
||||
M2()
|
||||
}
|
||||
|
||||
type T struct{}
|
||||
|
||||
func (t T) M1() {}
|
||||
func (t *T) M2() {}
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
var pt *T
|
||||
var i Interface
|
||||
|
||||
i = pt
|
||||
i = t // cannot use t (type T) as type Interface in assignment: T does not implement Interface (M2 method has pointer receiver)
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们定义了一个接口类型Interface以及一个自定义类型T。Interface接口类型包含了两个方法M1和M2,代码中还定义了基类型为T的两个方法M1和M2,但它们的receiver参数类型不同,一个为T,另一个为*T。在main函数中,我们分别将T类型实例t和*T类型实例pt赋值给Interface类型变量i。
|
||||
|
||||
运行一下这个示例程序,我们在i = t这一行会得到Go编译器的错误提示,Go编译器提示我们:T没有实现Interface类型方法列表中的M2,因此类型T的实例t不能赋值给Interface变量。
|
||||
|
||||
可是,为什么呀?为什么*T类型的pt可以被正常赋值给Interface类型变量i,而T类型的t就不行呢?如果说T类型是因为只实现了M1方法,未实现M2方法而不满足Interface类型的要求,那么*T类型也只是实现了M2方法,并没有实现M1方法啊?
|
||||
|
||||
有些事情并不是表面看起来这个样子的。了解方法集合后,这个问题就迎刃而解了。同时,方法集合也是用来判断一个类型是否实现了某接口类型的唯一手段,可以说,“方法集合决定了接口实现”。更具体的分析,我们等会儿再讲。
|
||||
|
||||
那么,什么是类型的方法集合呢?
|
||||
|
||||
Go中任何一个类型都有属于自己的方法集合,或者说方法集合是Go类型的一个“属性”。但不是所有类型都有自己的方法呀,比如int类型就没有。所以,对于没有定义方法的Go类型,我们称其拥有空方法集合。
|
||||
|
||||
接口类型相对特殊,它只会列出代表接口的方法列表,不会具体定义某个方法,它的方法集合就是它的方法列表中的所有方法,我们可以一目了然地看到。因此,我们下面重点讲解的是非接口类型的方法集合。
|
||||
|
||||
为了方便查看一个非接口类型的方法集合,我这里提供了一个函数dumpMethodSet,用于输出一个非接口类型的方法集合:
|
||||
|
||||
func dumpMethodSet(i interface{}) {
|
||||
dynTyp := reflect.TypeOf(i)
|
||||
|
||||
if dynTyp == nil {
|
||||
fmt.Printf("there is no dynamic type\n")
|
||||
return
|
||||
}
|
||||
|
||||
n := dynTyp.NumMethod()
|
||||
if n == 0 {
|
||||
fmt.Printf("%s's method set is empty!\n", dynTyp)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s's method set:\n", dynTyp)
|
||||
for j := 0; j < n; j++ {
|
||||
fmt.Println("-", dynTyp.Method(j).Name)
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
|
||||
|
||||
下面我们利用这个函数,试着输出一下Go原生类型以及自定义类型的方法集合,看下面代码:
|
||||
|
||||
type T struct{}
|
||||
|
||||
func (T) M1() {}
|
||||
func (T) M2() {}
|
||||
|
||||
func (*T) M3() {}
|
||||
func (*T) M4() {}
|
||||
|
||||
func main() {
|
||||
var n int
|
||||
dumpMethodSet(n)
|
||||
dumpMethodSet(&n)
|
||||
|
||||
var t T
|
||||
dumpMethodSet(t)
|
||||
dumpMethodSet(&t)
|
||||
}
|
||||
|
||||
|
||||
运行这段代码,我们得到如下结果:
|
||||
|
||||
int's method set is empty!
|
||||
*int's method set is empty!
|
||||
main.T's method set:
|
||||
- M1
|
||||
- M2
|
||||
|
||||
*main.T's method set:
|
||||
- M1
|
||||
- M2
|
||||
- M3
|
||||
- M4
|
||||
|
||||
|
||||
我们看到以int、*int为代表的Go原生类型由于没有定义方法,所以它们的方法集合都是空的。自定义类型T定义了方法M1和M2,因此它的方法集合包含了M1和M2,也符合我们预期。但*T的方法集合中除了预期的M3和M4之外,居然还包含了类型T的方法M1和M2!
|
||||
|
||||
不过,这里程序的输出并没有错误。
|
||||
|
||||
这是因为,Go语言规定,*T类型的方法集合包含所有以*T为receiver参数类型的方法,以及所有以T为receiver参数类型的方法。这就是这个示例中为何*T类型的方法集合包含四个方法的原因。
|
||||
|
||||
这个时候,你是不是也找到了前面那个示例中为何i = pt没有报编译错误的原因了呢?我们同样可以使用dumpMethodSet工具函数,输出一下那个例子中pt与t各自所属类型的方法集合:
|
||||
|
||||
type Interface interface {
|
||||
M1()
|
||||
M2()
|
||||
}
|
||||
|
||||
type T struct{}
|
||||
|
||||
func (t T) M1() {}
|
||||
func (t *T) M2() {}
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
var pt *T
|
||||
dumpMethodSet(t)
|
||||
dumpMethodSet(pt)
|
||||
}
|
||||
|
||||
|
||||
运行上述代码,我们得到如下结果:
|
||||
|
||||
main.T's method set:
|
||||
- M1
|
||||
|
||||
*main.T's method set:
|
||||
- M1
|
||||
- M2
|
||||
|
||||
|
||||
通过这个输出结果,我们可以一目了然地看到T、*T各自的方法集合。
|
||||
|
||||
我们看到,T类型的方法集合中只包含M1,没有Interface类型方法集合中的M2方法,这就是Go编译器认为变量t不能赋值给Interface类型变量的原因。
|
||||
|
||||
在输出的结果中,我们还看到*T类型的方法集合除了包含它自身定义的M2方法外,还包含了T类型定义的M1方法,*T的方法集合与Interface接口类型的方法集合是一样的,因此pt可以被赋值给Interface接口类型的变量i。
|
||||
|
||||
到这里,我们已经知道了所谓的方法集合决定接口实现的含义就是:如果某类型T的方法集合与某接口类型的方法集合相同,或者类型T的方法集合是接口类型I方法集合的超集,那么我们就说这个类型T实现了接口I。或者说,方法集合这个概念在Go语言中的主要用途,就是用来判断某个类型是否实现了某个接口。
|
||||
|
||||
有了方法集合的概念做铺垫,选择receiver参数类型的第三个原则已经呼之欲出了,下面我们就来看看这条原则的具体内容。
|
||||
|
||||
选择receiver参数类型的第三个原则
|
||||
|
||||
理解了方法集合后,我们再理解第三个原则的内容就不难了。这个原则的选择依据就是T类型是否需要实现某个接口,也就是是否存在将T类型的变量赋值给某接口类型变量的情况。
|
||||
|
||||
如果T类型需要实现某个接口,那我们就要使用T作为receiver参数的类型,来满足接口类型方法集合中的所有方法。
|
||||
|
||||
如果T不需要实现某一接口,但*T需要实现该接口,那么根据方法集合概念,*T的方法集合是包含T的方法集合的,这样我们在确定Go方法的receiver的类型时,参考原则一和原则二就可以了。
|
||||
|
||||
如果说前面的两个原则更多聚焦于类型内部,从单个方法的实现层面考虑,那么这第三个原则则是更多从全局的设计层面考虑,聚焦于这个类型与接口类型间的耦合关系。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
我们前面已经知道,Go方法本质上也是函数。所以Go方法设计的多数地方,都可以借鉴函数设计的相关内容。唯独Go方法的receiver部分,我们是没有现成经验可循的。这一讲中,我们主要学习的就是如何为Go方法的receiver参数选择类型。
|
||||
|
||||
我们先了解了不同类型的receiver参数对Go方法行为的影响,这是我们进行receiver参数选型的前提。
|
||||
|
||||
在这个前提下,我们提出了receiver参数选型的三个经验原则,虽然课程中我们是按原则一到三的顺序讲解的,但实际进行Go方法设计时,我们首先应该考虑的是原则三,即T类型是否要实现某一接口。
|
||||
|
||||
如果T类型需要实现某一接口的全部方法,那么我们就需要使用T作为receiver参数的类型来满足接口类型方法集合中的所有方法。
|
||||
|
||||
如果T类型不需要实现某一接口,那么我们就可以参考原则一和原则二来为receiver参数选择类型了。也就是,如果Go方法要把对receiver参数所代表的类型实例的修改反映到原类型实例上,那么我们应该选择*T作为receiver参数的类型。否则通常我们会为receiver参数选择T类型,这样可以减少外部修改类型实例内部状态的“渠道”。除非receiver参数类型的size较大,考虑到传值的较大性能开销,选择*T作为receiver类型可能更适合。
|
||||
|
||||
在理解原则三时,我们还介绍了Go语言中的一个重要概念:方法集合。它在Go语言中的主要用途就是判断某个类型是否实现了某个接口。方法集合像“胶水”一样,将自定义类型与接口隐式地“粘结”在一起,我们后面理解带有类型嵌入的类型时还会借助这个概念。
|
||||
|
||||
思考题
|
||||
|
||||
方法集合是一个很重要也很实用的概念,我们在下一节课还会用到这个概念帮助我们理解具体的问题。所以这里,我给你出了一道与方法集合有关的预习题。
|
||||
|
||||
如果一个类型T包含两个方法M1和M2:
|
||||
|
||||
type T struct{}
|
||||
|
||||
func (T) M1()
|
||||
func (T) M2()
|
||||
|
||||
|
||||
然后,我们再使用类型定义语法,又基于类型T定义了一个新类型S:
|
||||
|
||||
type S T
|
||||
|
||||
|
||||
那么,这个S类型包含哪些方法呢?*S类型又包含哪些方法呢?请你自己分析一下,然后借助dumpMethodSet函数来验证一下你的结论。
|
||||
|
||||
欢迎你把这节课分享给更多对Go方法感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
662
专栏/TonyBai·Go语言第一课/26方法:如何用类型嵌入模拟实现“继承”?.md
Normal file
662
专栏/TonyBai·Go语言第一课/26方法:如何用类型嵌入模拟实现“继承”?.md
Normal file
@@ -0,0 +1,662 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 方法:如何用类型嵌入模拟实现“继承”?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在前面的两节课中,我们学习了Go方法的声明、本质,以及receiver类型选择的三个原则。可以说,学完这些内容,我们就基本上解决了独立的自定义类型的方法的设计问题。
|
||||
|
||||
什么是独立的自定义类型呢?就是这个类型的所有方法都是自己显式实现的。我们举个例子,自定义类型T有两个方法M1和M2,如果T是一个独立的自定义类型,那我们在声明类型T的Go包源码文件中一定可以找到其所有方法的实现代码,比如:
|
||||
|
||||
func (T) M1() {...}
|
||||
func (T) M2() {...}
|
||||
|
||||
|
||||
这里你一定会问:难道还有某种自定义类型的方法不是自己显式实现的吗?当然有!这就是我们这讲中要重点讲解的内容:如何让某个自定义类型“继承”其他类型的方法实现。
|
||||
|
||||
这里你肯定又会提出质疑:老师,你不是说过Go不支持经典的面向对象编程范式吗?怎么还会有继承这一说法呢?没错!Go语言从设计伊始,就决定不支持经典面向对象的编程范式与语法元素,所以我们这里只是借用了“继承”这个词汇而已,说是“继承”,实则依旧是一种组合的思想。
|
||||
|
||||
而这种“继承”,我们是通过Go语言的类型嵌入(Type Embedding)来实现的。所以这一节课,我们就来学习一下这种语法,看看通过这种语法,我们如何实现对嵌入类型的方法的“继承”,同时也搞清楚这种方式对新定义的类型的方法集合的影响。
|
||||
|
||||
现在,我们先来学习一下什么是类型嵌入。
|
||||
|
||||
什么是类型嵌入
|
||||
|
||||
类型嵌入指的就是在一个类型的定义中嵌入了其他类型。Go语言支持两种类型嵌入,分别是接口类型的类型嵌入和结构体类型的类型嵌入。
|
||||
|
||||
接口类型的类型嵌入
|
||||
|
||||
我们先用一个案例,直观地了解一下什么是接口类型的类型嵌入。虽然我们现在还没有系统学习接口类型,但在前面的讲解中,我们已经多次接触了接口类型。我们知道,接口类型声明了由一个方法集合代表的接口,比如下面接口类型E:
|
||||
|
||||
type E interface {
|
||||
M1()
|
||||
M2()
|
||||
}
|
||||
|
||||
|
||||
这个接口类型E的方法集合,包含两个方法,分别是M1和M2,它们组成了E这个接口类型所代表的接口。如果某个类型实现了方法M1和M2,我们就说这个类型实现了E所代表的接口。
|
||||
|
||||
此时,我们再定义另外一个接口类型I,它的方法集合中包含了三个方法M1、M2和M3,如下面代码:
|
||||
|
||||
type I interface {
|
||||
M1()
|
||||
M2()
|
||||
M3()
|
||||
}
|
||||
|
||||
|
||||
我们看到接口类型I方法集合中的M1和M2,与接口类型E的方法集合中的方法完全相同。在这种情况下,我们可以用接口类型E替代上面接口类型I定义中M1和M2,如下面代码:
|
||||
|
||||
type I interface {
|
||||
E
|
||||
M3()
|
||||
}
|
||||
|
||||
|
||||
像这种在一个接口类型(I)定义中,嵌入另外一个接口类型(E)的方式,就是我们说的接口类型的类型嵌入。
|
||||
|
||||
而且,这个带有类型嵌入的接口类型I的定义与上面那个包含M1、M2和M3的接口类型I的定义,是等价的。因此,我们可以得到一个结论,这种接口类型嵌入的语义就是新接口类型(如接口类型I)将嵌入的接口类型(如接口类型E)的方法集合,并入到自己的方法集合中。
|
||||
|
||||
到这里你可能会问,我在接口类型定义中平铺方法列表就好了,为啥要使用类型嵌入方式定义接口类型呢?其实这也是Go组合设计哲学的一种体现。
|
||||
|
||||
按Go语言惯例,Go中的接口类型中只包含少量方法,并且常常只是一个方法。通过在接口类型中嵌入其他接口类型可以实现接口的组合,这也是Go语言中基于已有接口类型构建新接口类型的惯用法。
|
||||
|
||||
我们在Go标准库中可以看到很多这种组合方式的应用,最常见的莫过于io包中一系列接口的定义了。比如,io包的ReadWriter、ReadWriteCloser等接口类型就是通过嵌入Reader、Writer或Closer三个基本的接口类型组合而成的。下面是仅包含单一方法的io包Reader、Writer和Closer的定义:
|
||||
|
||||
// $GOROOT/src/io/io.go
|
||||
|
||||
type Reader interface {
|
||||
Read(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
type Writer interface {
|
||||
Write(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
type Closer interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
|
||||
下面的io包的ReadWriter、ReadWriteCloser等接口类型,通过嵌入上面基本接口类型组合而形成:
|
||||
|
||||
type ReadWriter interface {
|
||||
Reader
|
||||
Writer
|
||||
}
|
||||
|
||||
type ReadCloser interface {
|
||||
Reader
|
||||
Closer
|
||||
}
|
||||
|
||||
type WriteCloser interface {
|
||||
Writer
|
||||
Closer
|
||||
}
|
||||
|
||||
type ReadWriteCloser interface {
|
||||
Reader
|
||||
Writer
|
||||
Closer
|
||||
}
|
||||
|
||||
|
||||
不过,这种通过嵌入其他接口类型来创建新接口类型的方式,在Go 1.14版本之前是有约束的:如果新接口类型嵌入了多个接口类型,这些嵌入的接口类型的方法集合不能有交集,同时嵌入的接口类型的方法集合中的方法名字,也不能与新接口中的其他方法同名。比如我们用Go 1.12.7版本运行下面例子,Go编译器就会报错:
|
||||
|
||||
type Interface1 interface {
|
||||
M1()
|
||||
}
|
||||
|
||||
type Interface2 interface {
|
||||
M1()
|
||||
M2()
|
||||
}
|
||||
|
||||
type Interface3 interface {
|
||||
Interface1
|
||||
Interface2 // Error: duplicate method M1
|
||||
}
|
||||
|
||||
type Interface4 interface {
|
||||
Interface2
|
||||
M2() // Error: duplicate method M2
|
||||
}
|
||||
|
||||
func main() {
|
||||
}
|
||||
|
||||
|
||||
我们具体看一下例子中的两个编译报错:第一个是因为Interface3中嵌入的两个接口类型Interface1和Interface2的方法集合有交集,交集是方法M1;第二个报错是因为Interface4类型中的方法M2与嵌入的接口类型Interface2的方法M2重名。
|
||||
|
||||
但自Go 1.14版本开始,Go语言去除了这些约束,我们使用Go 1.17版本运行上面这个示例就不会得到编译错误了。
|
||||
|
||||
当然,接口类型的类型嵌入比较简单,我们只要把握好它的语义,也就是“方法集合并入”就可以了。结构体类型的类型嵌入就要更复杂一些了,接下来我们一起来学习一下。
|
||||
|
||||
结构体类型的类型嵌入
|
||||
|
||||
我们在第17讲中对Go结构体类型进行了系统的讲解,在那一讲中我们遇到的结构体都是类似下面这样的:
|
||||
|
||||
type S struct {
|
||||
A int
|
||||
b string
|
||||
c T
|
||||
p *P
|
||||
_ [10]int8
|
||||
F func()
|
||||
}
|
||||
|
||||
|
||||
结构体类型S中的每个字段(field)都有唯一的名字与对应的类型,即便是使用空标识符占位的字段,它的类型也是明确的,但这还不是Go结构体类型的“完全体”。Go结构体类型定义还有另外一种形式,那就是带有嵌入字段(Embedded Field)的结构体定义。我们看下面这个例子:
|
||||
|
||||
type T1 int
|
||||
type t2 struct{
|
||||
n int
|
||||
m int
|
||||
}
|
||||
|
||||
type I interface {
|
||||
M1()
|
||||
}
|
||||
|
||||
type S1 struct {
|
||||
T1
|
||||
*t2
|
||||
I
|
||||
a int
|
||||
b string
|
||||
}
|
||||
|
||||
|
||||
我们看到,结构体S1定义中有三个“非常规形式”的标识符,分别是T1、t2和I,这三个标识符究竟代表的是什么呢?是字段名还是字段的类型呢?这里我直接告诉你答案:它们既代表字段的名字,也代表字段的类型。我们分别以这三个标识符为例,说明一下它们的具体含义:
|
||||
|
||||
|
||||
标识符T1表示字段名为T1,它的类型为自定义类型T1;
|
||||
标识符t2表示字段名为t2,它的类型为自定义结构体类型t2的指针类型;
|
||||
标识符I表示字段名为I,它的类型为接口类型I。
|
||||
|
||||
|
||||
这种以某个类型名、类型的指针类型名或接口类型名,直接作为结构体字段的方式就叫做结构体的类型嵌入,这些字段也被叫做嵌入字段(Embedded Field)。
|
||||
|
||||
那么,嵌入字段怎么用呢?它跟普通结构体字段有啥不同呢?我们结合具体的例子,简单说一下嵌入字段的用法:
|
||||
|
||||
type MyInt int
|
||||
|
||||
func (n *MyInt) Add(m int) {
|
||||
*n = *n + MyInt(m)
|
||||
}
|
||||
|
||||
type t struct {
|
||||
a int
|
||||
b int
|
||||
}
|
||||
|
||||
type S struct {
|
||||
*MyInt
|
||||
t
|
||||
io.Reader
|
||||
s string
|
||||
n int
|
||||
}
|
||||
|
||||
func main() {
|
||||
m := MyInt(17)
|
||||
r := strings.NewReader("hello, go")
|
||||
s := S{
|
||||
MyInt: &m,
|
||||
t: t{
|
||||
a: 1,
|
||||
b: 2,
|
||||
},
|
||||
Reader: r,
|
||||
s: "demo",
|
||||
}
|
||||
|
||||
var sl = make([]byte, len("hello, go"))
|
||||
s.Reader.Read(sl)
|
||||
fmt.Println(string(sl)) // hello, go
|
||||
s.MyInt.Add(5)
|
||||
fmt.Println(*(s.MyInt)) // 22
|
||||
}
|
||||
|
||||
|
||||
在分析这段代码之前,我们要先明确一点,那就是嵌入字段的可见性与嵌入字段的类型的可见性是一致的。如果嵌入类型的名字是首字母大写的,那么也就说明这个嵌入字段是可导出的。
|
||||
|
||||
现在我们来看这个例子。
|
||||
|
||||
首先,这个例子中的结构体类型S使用了类型嵌入方式进行定义,它有三个嵌入字段MyInt、t和Reader。这里,你可能会问,为什么第三个嵌入字段的名字为Reader而不是io.Reader?这是因为,Go语言规定如果结构体使用从其他包导入的类型作为嵌入字段,比如pkg.T,那么这个嵌入字段的字段名就是T,代表的类型为pkg.T。
|
||||
|
||||
接下来,我们再来看结构体类型S的变量的初始化。我们使用field:value方式对S类型的变量s的各个字段进行初始化。和普通的字段一样,初始化嵌入字段时,我们可以直接用嵌入字段名作为field。
|
||||
|
||||
而且,通过变量s使用这些嵌入字段时,我们也可以像普通字段那样直接用变量s+字段选择符.+嵌入字段的名字,比如s.Reader。我们还可以通过这种方式调用嵌入字段的方法,比如s.Reader.Read和s.MyInt.Add。
|
||||
|
||||
这样看起来,嵌入字段的用法和普通字段没啥不同呀?也不完全是,Go还是对嵌入字段有一些约束的。比如,和Go方法的receiver的基类型一样,嵌入字段类型的底层类型不能为指针类型。而且,嵌入字段的名字在结构体定义也必须是唯一的,这也意味这如果两个类型的名字相同,它们无法同时作为嵌入字段放到同一个结构体定义中。不过,这些约束你了解一下就可以了,一旦违反,Go编译器会提示你的。
|
||||
|
||||
到这里,我们看到嵌入字段在使用上确实和普通字段没有多大差别,那我们为什么要用嵌入字段这种方式来定义结构体类型呢?别急,我们继续向下看。
|
||||
|
||||
“实现继承”的原理
|
||||
|
||||
我们将上面例子代码做一下细微改动,我这里只列了变化部分的代码:
|
||||
|
||||
var sl = make([]byte, len("hello, go"))
|
||||
s.Read(sl)
|
||||
fmt.Println(string(sl))
|
||||
s.Add(5)
|
||||
fmt.Println(*(s.MyInt))
|
||||
|
||||
|
||||
看到这段代码,你肯定会问:老师,类型S也没有定义Read方法和Add方法啊,这样写会导致Go编译器报错的。如果你有这个疑问,可以暂停一下,先用你手头上的Go编译器编译运行一下这段代码看看。
|
||||
|
||||
惊不惊喜,意不意外?这段程序不但没有引发编译器报错,还可以正常运行并输出与前面例子相同的结果!
|
||||
|
||||
这段代码似乎在告诉我们:Read方法与Add方法就是类型S方法集合中的方法。但是,这里类型S明明没有显式实现这两个方法呀,它是从哪里得到这两个方法的实现的呢?
|
||||
|
||||
其实,这两个方法就来自结构体类型S的两个嵌入字段Reader和MyInt。结构体类型S“继承”了Reader字段的方法Read的实现,也“继承”了*MyInt的Add方法的实现。注意,我这里的“继承”用了引号,说明这并不是真正的继承,它只是Go语言的一种“障眼法”。
|
||||
|
||||
这种“障眼法”的工作机制是这样的,当我们通过结构体类型S的变量s调用Read方法时,Go发现结构体类型S自身并没有定义Read方法,于是Go会查看S的嵌入字段对应的类型是否定义了Read方法。这个时候,Reader字段就被找了出来,之后s.Read的调用就被转换为s.Reader.Read调用。
|
||||
|
||||
这样一来,嵌入字段Reader的Read方法就被提升为S的方法,放入了类型S的方法集合。同理*MyInt的Add方法也被提升为S的方法而放入S的方法集合。从外部来看,这种嵌入字段的方法的提升就给了我们一种结构体类型S“继承”了io.Reader类型Read方法的实现,以及*MyInt类型Add方法的实现的错觉。
|
||||
|
||||
到这里,我们就清楚了,嵌入字段的使用的确可以帮我们在Go中实现方法的“继承”。
|
||||
|
||||
这节课开始我们就提过,类型嵌入这种看似“继承”的机制,实际上是一种组合的思想。更具体点,它是一种组合中的代理(delegate)模式,如下图所示:
|
||||
|
||||
|
||||
|
||||
我们看到,S只是一个代理(delegate),对外它提供了它可以代理的所有方法,如例子中的Read和Add方法。当外界发起对S的Read方法的调用后,S将该调用委派给它内部的Reader实例来实际执行Read方法。
|
||||
|
||||
当然,嵌入字段的类型不同,自定义结构体类型可以代理的方法就不同,那自定义结构体类型究竟可以代理哪些方法呢?换个角度说,嵌入字段对结构体的方法集合有哪些影响呢?下面我们就分情况来看看嵌入不同类型的结构体类型的方法集合中,都包含哪些方法。
|
||||
|
||||
类型嵌入与方法集合
|
||||
|
||||
在前面讲解接口类型的类型嵌入时,我们提到过接口类型的类型嵌入的本质,就是嵌入类型的方法集合并入到新接口类型的方法集合中,并且,接口类型只能嵌入接口类型。而结构体类型对嵌入类型的要求就比较宽泛了,可以是任意自定义类型或接口类型。
|
||||
|
||||
下面我们就分别看看,在这两种情况下,结构体类型的方法集合会有怎样的变化。我们依旧借助上一讲中的dumpMethodSet函数来输出各个类型的方法集合,这里,我就不在例子中重复列出dumpMethodSet的代码了。
|
||||
|
||||
结构体类型中嵌入接口类型
|
||||
|
||||
在结构体类型中嵌入接口类型后,结构体类型的方法集合会发生什么变化呢?我们通过下面这个例子来看一下:
|
||||
|
||||
type I interface {
|
||||
M1()
|
||||
M2()
|
||||
}
|
||||
|
||||
type T struct {
|
||||
I
|
||||
}
|
||||
|
||||
func (T) M3() {}
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
var p *T
|
||||
dumpMethodSet(t)
|
||||
dumpMethodSet(p)
|
||||
}
|
||||
|
||||
|
||||
运行这个示例,我们会得到以下结果:
|
||||
|
||||
main.T's method set:
|
||||
- M1
|
||||
- M2
|
||||
- M3
|
||||
|
||||
*main.T's method set:
|
||||
- M1
|
||||
- M2
|
||||
- M3
|
||||
|
||||
|
||||
我们可以看到,原本结构体类型T只带有一个方法M3,但在嵌入接口类型I后,结构体类型T的方法集合中又并入了接口类型I的方法集合。并且,由于*T类型方法集合包括T类型的方法集合,因此无论是类型T还是类型*T,它们的方法集合都包含M1、M2和M3。于是我们可以得出一个结论:结构体类型的方法集合,包含嵌入的接口类型的方法集合。
|
||||
|
||||
不过有一种情况,你要注意一下,那就是当结构体嵌入的多个接口类型的方法集合存在交集时,你要小心编译器可能会出现的错误提示。
|
||||
|
||||
看到这里,有同学可能会问:老师,你不是说Go 1.14版本解决了嵌入接口类型的方法集合有交集的情况吗?没错,但那仅限于接口类型中嵌入接口类型,这里我们说的是在结构体类型中嵌入方法集合有交集的接口类型。
|
||||
|
||||
这是什么意思呢?根据我们前面讲的,嵌入了其他类型的结构体类型本身是一个代理,在调用其实例所代理的方法时,Go会首先查看结构体自身是否实现了该方法。
|
||||
|
||||
如果实现了,Go就会优先使用结构体自己实现的方法。如果没有实现,那么Go就会查找结构体中的嵌入字段的方法集合中,是否包含了这个方法。如果多个嵌入字段的方法集合中都包含这个方法,那么我们就说方法集合存在交集。这个时候,Go编译器就会因无法确定究竟使用哪个方法而报错,下面的这个例子就演示了这种情况:
|
||||
|
||||
type E1 interface {
|
||||
M1()
|
||||
M2()
|
||||
M3()
|
||||
}
|
||||
|
||||
type E2 interface {
|
||||
M1()
|
||||
M2()
|
||||
M4()
|
||||
}
|
||||
|
||||
type T struct {
|
||||
E1
|
||||
E2
|
||||
}
|
||||
|
||||
func main() {
|
||||
t := T{}
|
||||
t.M1()
|
||||
t.M2()
|
||||
}
|
||||
|
||||
|
||||
运行这个例子,我们会得到:
|
||||
|
||||
main.go:22:3: ambiguous selector t.M1
|
||||
main.go:23:3: ambiguous selector t.M2
|
||||
|
||||
|
||||
我们看到,Go编译器给出了错误提示,表示在调用t.M1和t.M2时,编译器都出现了分歧。在这个例子中,结构体类型T嵌入的两个接口类型E1和E2的方法集合存在交集,都包含M1和M2,而结构体类型T自身呢,又没有实现M1和M2,所以编译器会因无法做出选择而报错。
|
||||
|
||||
那怎么解决这个问题呢?其实有两种解决方案。一是,我们可以消除E1和E2方法集合存在交集的情况。二是为T增加M1和M2方法的实现,这样的话,编译器便会直接选择T自己实现的M1和M2,不会陷入两难境地。比如,下面的例子演示的就是T增加了M1和M2方法实现的情况:
|
||||
|
||||
... ...
|
||||
type T struct {
|
||||
E1
|
||||
E2
|
||||
}
|
||||
|
||||
func (T) M1() { println("T's M1") }
|
||||
func (T) M2() { println("T's M2") }
|
||||
|
||||
func main() {
|
||||
t := T{}
|
||||
t.M1() // T's M1
|
||||
t.M2() // T's M2
|
||||
}
|
||||
|
||||
|
||||
结构体类型嵌入接口类型在日常编码中有一个妙用,就是可以简化单元测试的编写。由于嵌入某接口类型的结构体类型的方法集合包含了这个接口类型的方法集合,这就意味着,这个结构体类型也是它嵌入的接口类型的一个实现。即便结构体类型自身并没有实现这个接口类型的任意一个方法,也没有关系。我们来看一个直观的例子:
|
||||
|
||||
package employee
|
||||
|
||||
type Result struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
func (r Result) Int() int { return r.Count }
|
||||
|
||||
type Rows []struct{}
|
||||
|
||||
type Stmt interface {
|
||||
Close() error
|
||||
NumInput() int
|
||||
Exec(stmt string, args ...string) (Result, error)
|
||||
Query(args []string) (Rows, error)
|
||||
}
|
||||
|
||||
// 返回男性员工总数
|
||||
func MaleCount(s Stmt) (int, error) {
|
||||
result, err := s.Exec("select count(*) from employee_tab where gender=?", "1")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return result.Int(), nil
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们有一个employee包,这个包中的方法MaleCount,通过传入的Stmt接口的实现从数据库获取男性员工的数量。
|
||||
|
||||
现在我们的任务是要对MaleCount方法编写单元测试代码。对于这种依赖外部数据库操作的方法,我们的惯例是使用“伪对象(fake object)”来冒充真实的Stmt接口实现。
|
||||
|
||||
不过现在有一个问题,那就是Stmt接口类型的方法集合中有四个方法,而MaleCount函数只使用了Stmt接口的一个方法Exec。如果我们针对每个测试用例所用的伪对象都实现这四个方法,那么这个工作量有些大。
|
||||
|
||||
那么这个时候,我们怎样快速建立伪对象呢?结构体类型嵌入接口类型便可以帮助我们,下面是我们的解决方案:
|
||||
|
||||
package employee
|
||||
|
||||
import "testing"
|
||||
|
||||
type fakeStmtForMaleCount struct {
|
||||
Stmt
|
||||
}
|
||||
|
||||
func (fakeStmtForMaleCount) Exec(stmt string, args ...string) (Result, error) {
|
||||
return Result{Count: 5}, nil
|
||||
}
|
||||
|
||||
func TestEmployeeMaleCount(t *testing.T) {
|
||||
f := fakeStmtForMaleCount{}
|
||||
c, _ := MaleCount(f)
|
||||
if c != 5 {
|
||||
t.Errorf("want: %d, actual: %d", 5, c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们为TestEmployeeMaleCount测试用例建立了一个fakeStmtForMaleCount的伪对象类型,然后在这个类型中嵌入了Stmt接口类型。这样fakeStmtForMaleCount就实现了Stmt接口,我们也实现了快速建立伪对象的目的。接下来我们只需要为fakeStmtForMaleCount实现MaleCount所需的Exec方法,就可以满足这个测试的要求了。
|
||||
|
||||
那说完了在结构体中嵌入接口类型的情况后,我们再来看在结构体中嵌入结构体类型会对方法集合产生什么影响。
|
||||
|
||||
结构体类型中嵌入结构体类型
|
||||
|
||||
我们前面已经学过,在结构体类型中嵌入结构体类型,为Gopher们提供了一种“实现继承”的手段,外部的结构体类型T可以“继承”嵌入的结构体类型的所有方法的实现。并且,无论是T类型的变量实例还是*T类型变量实例,都可以调用所有“继承”的方法。但这种情况下,带有嵌入类型的新类型究竟“继承”了哪些方法,我们还要通过下面这个具体的示例来看一下。
|
||||
|
||||
type T1 struct{}
|
||||
|
||||
func (T1) T1M1() { println("T1's M1") }
|
||||
func (*T1) PT1M2() { println("PT1's M2") }
|
||||
|
||||
type T2 struct{}
|
||||
|
||||
func (T2) T2M1() { println("T2's M1") }
|
||||
func (*T2) PT2M2() { println("PT2's M2") }
|
||||
|
||||
type T struct {
|
||||
T1
|
||||
*T2
|
||||
}
|
||||
|
||||
func main() {
|
||||
t := T{
|
||||
T1: T1{},
|
||||
T2: &T2{},
|
||||
}
|
||||
|
||||
dumpMethodSet(t)
|
||||
dumpMethodSet(&t)
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,结构体类型T有两个嵌入字段,分别是T1和*T2,根据上一讲中我们对结构体的方法集合的讲解,我们知道T1与*T1、T2与*T2的方法集合是不同的:
|
||||
|
||||
|
||||
T1的方法集合包含:T1M1;
|
||||
*T1的方法集合包含:T1M1、PT1M2;
|
||||
T2的方法集合包含:T2M1;
|
||||
*T2的方法集合包含:T2M1、PT2M2。
|
||||
|
||||
|
||||
它们作为嵌入字段嵌入到T中后,对T和*T的方法集合的影响也是不同的。我们运行一下这个示例,看一下输出结果:
|
||||
|
||||
main.T's method set:
|
||||
- PT2M2
|
||||
- T1M1
|
||||
- T2M1
|
||||
|
||||
*main.T's method set:
|
||||
- PT1M2
|
||||
- PT2M2
|
||||
- T1M1
|
||||
- T2M1
|
||||
|
||||
|
||||
通过输出结果,我们看到了T和*T类型的方法集合果然有差别的:
|
||||
|
||||
|
||||
类型T的方法集合 = T1的方法集合 + *T2的方法集合
|
||||
类型*T的方法集合 = *T1的方法集合 + *T2的方法集合
|
||||
|
||||
|
||||
这里,我们尤其要注意*T类型的方法集合,它包含的可不是T1类型的方法集合,而是*T1类型的方法集合。这和结构体指针类型的方法集合包含结构体类型方法集合,是一个道理。
|
||||
|
||||
讲到这里,基于类型嵌入“继承”方法实现的原理,我们基本都讲清楚了。但不知道你会不会还有一点疑惑:只有通过类型嵌入才能实现方法“继承”吗?如果我使用类型声明语法基于一个已有类型T定义一个新类型NT,那么NT是不是可以直接继承T的所有方法呢?
|
||||
|
||||
为了解答这个疑惑,我们继续来看看defined类型与alias类型是否可以实现方法集合的“继承”。
|
||||
|
||||
defined类型与alias类型的方法集合
|
||||
|
||||
Go语言中,凡通过类型声明语法声明的类型都被称为defined类型,下面是一些defined类型的声明的例子:
|
||||
|
||||
type I interface {
|
||||
M1()
|
||||
M2()
|
||||
}
|
||||
type T int
|
||||
type NT T // 基于已存在的类型T创建新的defined类型NT
|
||||
type NI I // 基于已存在的接口类型I创建新defined接口类型NI
|
||||
|
||||
|
||||
新定义的defined类型与原defined类型是不同的类型,那么它们的方法集合上又会有什么关系呢?新类型是否“继承”原defined类型的方法集合呢?
|
||||
|
||||
这个问题,我们也要分情况来看。
|
||||
|
||||
对于那些基于接口类型创建的defined的接口类型,它们的方法集合与原接口类型的方法集合是一致的。但对于基于非接口类型的defined类型创建的非接口类型,我们通过下面例子来看一下:
|
||||
|
||||
package main
|
||||
|
||||
type T struct{}
|
||||
|
||||
func (T) M1() {}
|
||||
func (*T) M2() {}
|
||||
|
||||
type T1 T
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
var pt *T
|
||||
var t1 T1
|
||||
var pt1 *T1
|
||||
|
||||
dumpMethodSet(t)
|
||||
dumpMethodSet(t1)
|
||||
|
||||
dumpMethodSet(pt)
|
||||
dumpMethodSet(pt1)
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们基于一个defined的非接口类型T创建了新defined类型T1,并且分别输出T1和*T1的方法集合来确认它们是否“继承”了T的方法集合。
|
||||
|
||||
运行这个示例程序,我们得到如下结果:
|
||||
|
||||
main.T's method set:
|
||||
- M1
|
||||
|
||||
main.T1's method set is empty!
|
||||
|
||||
*main.T's method set:
|
||||
- M1
|
||||
- M2
|
||||
|
||||
*main.T1's method set is empty!
|
||||
|
||||
|
||||
从输出结果上看,新类型T1并没有“继承”原defined类型T的任何一个方法。从逻辑上来说,这也符合T1与T是两个不同类型的语义。
|
||||
|
||||
基于自定义非接口类型的defined类型的方法集合为空的事实,也决定了即便原类型实现了某些接口,基于其创建的defined类型也没有“继承”这一隐式关联。也就是说,新defined类型要想实现那些接口,仍然需要重新实现接口的所有方法。
|
||||
|
||||
那么,基于类型别名(type alias)定义的新类型有没有“继承”原类型的方法集合呢?我们还是来看一个例子:
|
||||
|
||||
type T struct{}
|
||||
|
||||
func (T) M1() {}
|
||||
func (*T) M2() {}
|
||||
|
||||
type T1 = T
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
var pt *T
|
||||
var t1 T1
|
||||
var pt1 *T1
|
||||
|
||||
dumpMethodSet(t)
|
||||
dumpMethodSet(t1)
|
||||
|
||||
dumpMethodSet(pt)
|
||||
dumpMethodSet(pt1)
|
||||
}
|
||||
|
||||
|
||||
这个例子改自之前那个例子,我只是将T1的定义方式由类型声明改成了类型别名,我们看一下这个例子的输出结果:
|
||||
|
||||
main.T's method set:
|
||||
- M1
|
||||
|
||||
main.T's method set:
|
||||
- M1
|
||||
|
||||
*main.T's method set:
|
||||
- M1
|
||||
- M2
|
||||
|
||||
*main.T's method set:
|
||||
- M1
|
||||
- M2
|
||||
|
||||
|
||||
通过这个输出结果,我们看到,我们的dumpMethodSet函数甚至都无法识别出“类型别名”,无论类型别名还是原类型,输出的都是原类型的方法集合。
|
||||
|
||||
由此我们可以得到一个结论:无论原类型是接口类型还是非接口类型,类型别名都与原类型拥有完全相同的方法集合。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了。这一节课中,我们主要学习了类型嵌入相关的知识,类型嵌入对类型方法集合的影响,也是我们日常进行方法设计时必须要考虑到的重要因素。
|
||||
|
||||
类型嵌入分为两种,一种是接口类型的类型嵌入,对于接口类型的类型嵌入我们只要把握好其语义“方法集合并入”就可以了。另外一种是结构体类型的类型嵌入。通过在结构体定义中的嵌入字段,我们可以实现对嵌入类型的方法集合的“继承”。
|
||||
|
||||
但这种“继承”并非经典面向对象范式中的那个继承,Go中的“继承”实际是一种组合,更具体点是组合思想下代理(delegate)模式的运用,也就是新类型代理了其嵌入类型的所有方法。当外界调用新类型的方法时,Go编译器会首先查找新类型是否实现了这个方法,如果没有,就会将调用委派给其内部实现了这个方法的嵌入类型的实例去执行,你一定要理解这个原理。
|
||||
|
||||
此外,你还要牢记类型嵌入对新类型的方法集合的影响,包括:
|
||||
|
||||
|
||||
结构体类型的方法集合包含嵌入的接口类型的方法集合;
|
||||
当结构体类型T包含嵌入字段E时,*T的方法集合不仅包含类型E的方法集合,还要包含类型*E的方法集合。
|
||||
|
||||
|
||||
最后,基于非接口类型的defined类型创建的新defined类型不会继承原类型的方法集合,而通过类型别名定义的新类型则和原类型拥有相同的方法集合。
|
||||
|
||||
思考题
|
||||
|
||||
请你思考一下,下面带有类型嵌入的结构体S1与不带类型嵌入的结构体S2是否是等价的,如不等价,区别在哪里?
|
||||
|
||||
type T1 int
|
||||
type t2 struct{
|
||||
n int
|
||||
m int
|
||||
}
|
||||
|
||||
type I interface {
|
||||
M1()
|
||||
}
|
||||
|
||||
type S1 struct {
|
||||
T1
|
||||
*t2
|
||||
I
|
||||
a int
|
||||
b string
|
||||
}
|
||||
|
||||
type S2 struct {
|
||||
T1 T1
|
||||
t2 *t2
|
||||
I I
|
||||
a int
|
||||
b string
|
||||
}
|
||||
|
||||
|
||||
欢迎把这节课分享给更多对Go类型嵌入感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
783
专栏/TonyBai·Go语言第一课/27即学即练:跟踪函数调用链,理解代码更直观.md
Normal file
783
专栏/TonyBai·Go语言第一课/27即学即练:跟踪函数调用链,理解代码更直观.md
Normal file
@@ -0,0 +1,783 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
27 即学即练:跟踪函数调用链,理解代码更直观
|
||||
你好,我是Tony Bai。
|
||||
|
||||
时间过得真快!转眼间我们已经完成了这门课基础篇Go语法部分的学习。在这一部分中,我们从变量声明开始,一直学到了Go函数与方法的设计,不知道你掌握得怎么样呢?基础篇的重点是对Go基础语法部分的理解,只有理解透了,我们才能在动手实践的环节运用自如。
|
||||
|
||||
同时,基础篇也是整个课程篇幅最多的部分,想必学到这里,你差不多也进入了一个“疲劳期”。为了给你的大脑“充充电”,我在这一讲,也就是基础篇的最后一讲中安排了一个小实战项目,适当放松一下,也希望你在实现这个实战项目的过程中,能对基础篇所学的内容做一个回顾与总结,夯实一下Go的语法基础。
|
||||
|
||||
引子
|
||||
|
||||
在前面的第23讲中,我曾留过这样的一道思考题:“除了捕捉panic、延迟释放资源外,我们日常编码中还有哪些使用defer的小技巧呢?”
|
||||
|
||||
这个思考题得到了同学们的热烈响应,有同学在留言区提到:使用defer可以跟踪函数的执行过程。没错!这的确是defer的一个常见的使用技巧,很多Go教程在讲解defer时也会经常使用这个用途举例。那么,我们具体是怎么用defer来实现函数执行过程的跟踪呢?这里,我给出了一个最简单的实现:
|
||||
|
||||
// trace.go
|
||||
package main
|
||||
|
||||
func Trace(name string) func() {
|
||||
println("enter:", name)
|
||||
return func() {
|
||||
println("exit:", name)
|
||||
}
|
||||
}
|
||||
|
||||
func foo() {
|
||||
defer Trace("foo")()
|
||||
bar()
|
||||
}
|
||||
|
||||
func bar() {
|
||||
defer Trace("bar")()
|
||||
}
|
||||
|
||||
func main() {
|
||||
defer Trace("main")()
|
||||
foo()
|
||||
}
|
||||
|
||||
|
||||
在讲解这段代码的原理之前,我们先看一下这段代码的执行结果,直观感受一下什么是函数调用跟踪:
|
||||
|
||||
enter: main
|
||||
enter: foo
|
||||
enter: bar
|
||||
exit: bar
|
||||
exit: foo
|
||||
exit: main
|
||||
|
||||
|
||||
我们看到,这个Go程序的函数调用的全过程一目了然地展现在了我们面前:程序按main -> foo -> bar的函数调用次序执行,代码在函数的入口与出口处分别输出了跟踪日志。
|
||||
|
||||
那这段代码是怎么做到的呢?我们简要分析一下。
|
||||
|
||||
在这段实现中,我们在每个函数的入口处都使用defer设置了一个deferred函数。根据第23讲中讲解的defer的运作机制,Go会在defer设置deferred函数时对defer后面的表达式进行求值。
|
||||
|
||||
我们以foo函数中的defer Trace("foo")()这行代码为例,Go会对defer后面的表达式Trace("foo")()进行求值。由于这个表达式包含一个函数调用Trace("foo"),所以这个函数会被执行。
|
||||
|
||||
上面的Trace函数只接受一个参数,˙这个参数代表函数名,Trace会首先打印进入某函数的日志,比如:“enter: foo”。然后返回一个闭包函数,这个闭包函数一旦被执行,就会输出离开某函数的日志。在foo函数中,这个由Trace函数返回的闭包函数就被设置为了deferred函数,于是当foo函数返回后,这个闭包函数就会被执行,输出“exit: foo”的日志。
|
||||
|
||||
搞清楚上面跟踪函数调用链的实现原理后,我们再来看看这个实现。我们会发现这里还是有一些“瑕疵”,也就是离我们期望的“跟踪函数调用链”的实现还有一些不足之处。这里我列举了几点:
|
||||
|
||||
|
||||
调用Trace时需手动显式传入要跟踪的函数名;
|
||||
如果是并发应用,不同Goroutine中函数链跟踪混在一起无法分辨;
|
||||
输出的跟踪结果缺少层次感,调用关系不易识别;
|
||||
对要跟踪的函数,需手动调用Trace函数。
|
||||
|
||||
|
||||
那么,这一讲我们的任务就是逐一分析并解决上面提出的这几点问题进行,经过逐步地代码演进,最终实现一个自动注入跟踪代码,并输出有层次感的函数调用链跟踪命令行工具。
|
||||
|
||||
好,下面我们先来解决第一个问题。
|
||||
|
||||
自动获取所跟踪函数的函数名
|
||||
|
||||
要解决“调用Trace时需要手动显式传入要跟踪的函数名”的问题,也就是要让我们的Trace函数能够自动获取到它跟踪函数的函数名信息。我们以跟踪foo为例,看看这样做能给我们带来什么好处。
|
||||
|
||||
在手动显式传入的情况下,我们需要用下面这个代码对foo进行跟踪:
|
||||
|
||||
defer Trace("foo")()
|
||||
|
||||
|
||||
一旦实现了自动获取函数名,所有支持函数调用链跟踪的函数都只需使用下面调用形式的Trace函数就可以了:
|
||||
|
||||
defer Trace()()
|
||||
|
||||
|
||||
这种一致的Trace函数调用方式也为后续的自动向代码中注入Trace函数奠定了基础。那么如何实现Trace函数对它跟踪函数名的自动获取呢?我们需要借助Go标准库runtime包的帮助。
|
||||
|
||||
这里,我给出了新版Trace函数的实现以及它的使用方法,我们先看一下:
|
||||
|
||||
// trace1/trace.go
|
||||
|
||||
func Trace() func() {
|
||||
pc, _, _, ok := runtime.Caller(1)
|
||||
if !ok {
|
||||
panic("not found caller")
|
||||
}
|
||||
|
||||
fn := runtime.FuncForPC(pc)
|
||||
name := fn.Name()
|
||||
|
||||
println("enter:", name)
|
||||
return func() { println("exit:", name) }
|
||||
}
|
||||
|
||||
func foo() {
|
||||
defer Trace()()
|
||||
bar()
|
||||
}
|
||||
|
||||
func bar() {
|
||||
defer Trace()()
|
||||
}
|
||||
|
||||
func main() {
|
||||
defer Trace()()
|
||||
foo()
|
||||
}
|
||||
|
||||
|
||||
在这一版Trace函数中,我们通过runtime.Caller函数获得当前Goroutine的函数调用栈上的信息,runtime.Caller的参数标识的是要获取的是哪一个栈帧的信息。当参数为0时,返回的是Caller函数的调用者的函数信息,在这里就是Trace函数。但我们需要的是Trace函数的调用者的信息,于是我们传入1。
|
||||
|
||||
Caller函数有四个返回值:第一个返回值代表的是程序计数(pc);第二个和第三个参数代表对应函数所在的源文件名以及所在行数,这里我们暂时不需要;最后一个参数代表是否能成功获取这些信息,如果获取失败,我们抛出panic。
|
||||
|
||||
接下来,我们通过runtime.FuncForPC函数和程序计数器(PC)得到被跟踪函数的函数名称。我们运行一下改造后代码:
|
||||
|
||||
enter: main.main
|
||||
enter: main.foo
|
||||
enter: main.bar
|
||||
exit: main.bar
|
||||
exit: main.foo
|
||||
exit: main.main
|
||||
|
||||
|
||||
我们看到,runtime.FuncForPC返回的名称中不仅仅包含函数名,还包含了被跟踪函数所在的包名。也就是说,我们第一个问题已经圆满解决了。
|
||||
|
||||
接下来,我们来解决第二个问题,也就是当程序中有多Goroutine时,Trace输出的跟踪信息混杂在一起难以分辨的问题。
|
||||
|
||||
增加Goroutine标识
|
||||
|
||||
上面的Trace函数在面对只有一个Goroutine的时候,还是可以支撑的,但当程序中并发运行多个Goroutine的时候,多个函数调用链的出入口信息输出就会混杂在一起,无法分辨。
|
||||
|
||||
那么,接下来我们还继续对Trace函数进行改造,让它支持多Goroutine函数调用链的跟踪。我们的方案就是在输出的函数出入口信息时,带上一个在程序每次执行时能唯一区分Goroutine的Goroutine ID。
|
||||
|
||||
到这里,你可能会说,Goroutine也没有ID信息啊!的确如此,Go核心团队为了避免Goroutine ID的滥用,故意没有将Goroutine ID暴露给开发者。但在Go标准库的h2_bundle.go中,我们却发现了一个获取Goroutine ID的标准方法,看下面代码:
|
||||
|
||||
// $GOROOT/src/net/http/h2_bundle.go
|
||||
var http2goroutineSpace = []byte("goroutine ")
|
||||
|
||||
func http2curGoroutineID() uint64 {
|
||||
bp := http2littleBuf.Get().(*[]byte)
|
||||
defer http2littleBuf.Put(bp)
|
||||
b := *bp
|
||||
b = b[:runtime.Stack(b, false)]
|
||||
// Parse the 4707 out of "goroutine 4707 ["
|
||||
b = bytes.TrimPrefix(b, http2goroutineSpace)
|
||||
i := bytes.IndexByte(b, ' ')
|
||||
if i < 0 {
|
||||
panic(fmt.Sprintf("No space found in %q", b))
|
||||
}
|
||||
b = b[:i]
|
||||
n, err := http2parseUintBytes(b, 10, 64)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse goroutine ID out of %q: %v", b, err))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
|
||||
不过,由于http2curGoroutineID不是一个导出函数,我们无法直接使用。我们可以把它复制出来改造一下:
|
||||
|
||||
// trace2/trace.go
|
||||
var goroutineSpace = []byte("goroutine ")
|
||||
|
||||
func curGoroutineID() uint64 {
|
||||
b := make([]byte, 64)
|
||||
b = b[:runtime.Stack(b, false)]
|
||||
// Parse the 4707 out of "goroutine 4707 ["
|
||||
b = bytes.TrimPrefix(b, goroutineSpace)
|
||||
i := bytes.IndexByte(b, ' ')
|
||||
if i < 0 {
|
||||
panic(fmt.Sprintf("No space found in %q", b))
|
||||
}
|
||||
b = b[:i]
|
||||
n, err := strconv.ParseUint(string(b), 10, 64)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse goroutine ID out of %q: %v", b, err))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
|
||||
这里,我们改造了两个地方。一个地方是通过直接创建一个byte切片赋值给b,替代原http2curGoroutineID函数中从一个pool池获取byte切片的方式,另外一个是使用strconv.ParseUint替代了原先的http2parseUintBytes。改造后,我们就可以直接使用curGoroutineID函数来获取Goroutine的ID信息了。
|
||||
|
||||
好,接下来,我们在Trace函数中添加Goroutine ID信息的输出:
|
||||
|
||||
// trace2/trace.go
|
||||
func Trace() func() {
|
||||
pc, _, _, ok := runtime.Caller(1)
|
||||
if !ok {
|
||||
panic("not found caller")
|
||||
}
|
||||
|
||||
fn := runtime.FuncForPC(pc)
|
||||
name := fn.Name()
|
||||
|
||||
gid := curGoroutineID()
|
||||
fmt.Printf("g[%05d]: enter: [%s]\n", gid, name)
|
||||
return func() { fmt.Printf("g[%05d]: exit: [%s]\n", gid, name) }
|
||||
}
|
||||
|
||||
|
||||
从上面代码看到,我们在出入口输出的跟踪信息中加入了Goroutine ID信息,我们输出的Goroutine ID为5位数字,如果ID值不足5位,则左补零,这一切都是Printf函数的格式控制字符串“%05d”帮助我们实现的。这样对齐Goroutine ID的位数,为的是输出信息格式的一致性更好。如果你的Go程序中Goroutine的数量超过了5位数可以表示的数值范围,也可以自行调整控制字符串。
|
||||
|
||||
接下来,我们也要对示例进行一些调整,将这个程序由单Goroutine改为多Goroutine并发的,这样才能验证支持多Goroutine的新版Trace函数是否好用:
|
||||
|
||||
// trace2/trace.go
|
||||
func A1() {
|
||||
defer Trace()()
|
||||
B1()
|
||||
}
|
||||
|
||||
func B1() {
|
||||
defer Trace()()
|
||||
C1()
|
||||
}
|
||||
|
||||
func C1() {
|
||||
defer Trace()()
|
||||
D()
|
||||
}
|
||||
|
||||
func D() {
|
||||
defer Trace()()
|
||||
}
|
||||
|
||||
func A2() {
|
||||
defer Trace()()
|
||||
B2()
|
||||
}
|
||||
func B2() {
|
||||
defer Trace()()
|
||||
C2()
|
||||
}
|
||||
func C2() {
|
||||
defer Trace()()
|
||||
D()
|
||||
}
|
||||
|
||||
func main() {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
A2()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
A1()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
新示例程序共有两个Goroutine,main groutine的调用链为A1 -> B1 -> C1 -> D,而另外一个Goroutine的函数调用链为A2 -> B2 -> C2 -> D。我们来看一下这个程序的执行结果是否和原代码中两个Goroutine的调用链一致:
|
||||
|
||||
g[00001]: enter: [main.A1]
|
||||
g[00001]: enter: [main.B1]
|
||||
g[00018]: enter: [main.A2]
|
||||
g[00001]: enter: [main.C1]
|
||||
g[00001]: enter: [main.D]
|
||||
g[00001]: exit: [main.D]
|
||||
g[00001]: exit: [main.C1]
|
||||
g[00001]: exit: [main.B1]
|
||||
g[00001]: exit: [main.A1]
|
||||
g[00018]: enter: [main.B2]
|
||||
g[00018]: enter: [main.C2]
|
||||
g[00018]: enter: [main.D]
|
||||
g[00018]: exit: [main.D]
|
||||
g[00018]: exit: [main.C2]
|
||||
g[00018]: exit: [main.B2]
|
||||
g[00018]: exit: [main.A2]
|
||||
|
||||
|
||||
我们看到,新示例程序输出了带有Goroutine ID的出入口跟踪信息,通过Goroutine ID我们可以快速确认某一行输出是属于哪个Goroutine的。
|
||||
|
||||
但由于Go运行时对Goroutine调度顺序的不确定性,各个Goroutine的输出还是会存在交织在一起的问题,这会给你查看某个Goroutine的函数调用链跟踪信息带来阻碍。这里我提供一个小技巧:你可以将程序的输出重定向到一个本地文件中,然后通过Goroutine ID过滤出(可使用grep工具)你想查看的groutine的全部函数跟踪信息。
|
||||
|
||||
到这里,我们就实现了输出带有Goroutine ID的函数跟踪信息,不过,你是不是也觉得输出的函数调用链信息还是不够美观,缺少层次感,体验依旧不那么优秀呢?至少我是这么觉得的。所以下面我们就来美化一下信息的输出形式。
|
||||
|
||||
让输出的跟踪信息更具层次感
|
||||
|
||||
对于程序员来说,缩进是最能体现出“层次感”的方法,如果我们将上面示例中Goroutine 00001的函数调用跟踪信息以下面的形式展示出来,函数的调用顺序是不是更加一目了然了呢?
|
||||
|
||||
g[00001]: ->main.A1
|
||||
g[00001]: ->main.B1
|
||||
g[00001]: ->main.C1
|
||||
g[00001]: ->main.D
|
||||
g[00001]: <-main.D
|
||||
g[00001]: <-main.C1
|
||||
g[00001]: <-main.B1
|
||||
g[00001]: <-main.A1
|
||||
|
||||
|
||||
那么我们就以这个形式为目标,考虑如何实现输出这种带缩进的函数调用跟踪信息。我们还是直接上代码吧:
|
||||
|
||||
// trace3/trace.go
|
||||
|
||||
func printTrace(id uint64, name, arrow string, indent int) {
|
||||
indents := ""
|
||||
for i := 0; i < indent; i++ {
|
||||
indents += " "
|
||||
}
|
||||
fmt.Printf("g[%05d]:%s%s%s\n", id, indents, arrow, name)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var m = make(map[uint64]int)
|
||||
|
||||
func Trace() func() {
|
||||
pc, _, _, ok := runtime.Caller(1)
|
||||
if !ok {
|
||||
panic("not found caller")
|
||||
}
|
||||
|
||||
fn := runtime.FuncForPC(pc)
|
||||
name := fn.Name()
|
||||
gid := curGoroutineID()
|
||||
|
||||
mu.Lock()
|
||||
indents := m[gid] // 获取当前gid对应的缩进层次
|
||||
m[gid] = indents + 1 // 缩进层次+1后存入map
|
||||
mu.Unlock()
|
||||
printTrace(gid, name, "->", indents+1)
|
||||
return func() {
|
||||
mu.Lock()
|
||||
indents := m[gid] // 获取当前gid对应的缩进层次
|
||||
m[gid] = indents - 1 // 缩进层次-1后存入map
|
||||
mu.Unlock()
|
||||
printTrace(gid, name, "<-", indents)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在上面这段代码中,我们使用了一个map类型变量m来保存每个Goroutine当前的缩进信息:m的key为Goroutine的ID,值为缩进的层次。然后,考虑到Trace函数可能在并发环境中运行,根据我们在第16讲中提到的“map不支持并发写”的注意事项,我们增加了一个sync.Mutex实例mu用于同步对m的写操作。
|
||||
|
||||
这样,对于一个Goroutine来说,每次刚进入一个函数调用,我们就在输出入口跟踪信息之前,将缩进层次加一,并输出入口跟踪信息,加一后的缩进层次值也保存到map中。然后,在函数退出前,我们取出当前缩进层次值并输出出口跟踪信息,之后再将缩进层次减一后保存到map中。
|
||||
|
||||
除了增加缩进层次信息外,在这一版的Trace函数实现中,我们也把输出出入口跟踪信息的操作提取到了一个独立的函数printTrace中,这个函数会根据传入的Goroutine ID、函数名、箭头类型与缩进层次值,按预定的格式拼接跟踪信息并输出。
|
||||
|
||||
运行新版示例代码,我们会得到下面的结果:
|
||||
|
||||
g[00001]: ->main.A1
|
||||
g[00001]: ->main.B1
|
||||
g[00001]: ->main.C1
|
||||
g[00001]: ->main.D
|
||||
g[00001]: <-main.D
|
||||
g[00001]: <-main.C1
|
||||
g[00001]: <-main.B1
|
||||
g[00001]: <-main.A1
|
||||
g[00018]: ->main.A2
|
||||
g[00018]: ->main.B2
|
||||
g[00018]: ->main.C2
|
||||
g[00018]: ->main.D
|
||||
g[00018]: <-main.D
|
||||
g[00018]: <-main.C2
|
||||
g[00018]: <-main.B2
|
||||
g[00018]: <-main.A2
|
||||
|
||||
|
||||
显然,通过这种带有缩进层次的函数调用跟踪信息,我们可以更容易地识别某个Goroutine的函数调用关系。
|
||||
|
||||
到这里,我们的函数调用链跟踪已经支持了多Goroutine,并且可以输出有层次感的跟踪信息了,但对于Trace特性的使用者而言,他们依然需要手工在自己的函数中添加对Trace函数的调用。那么我们是否可以将Trace特性自动注入特定项目下的各个源码文件中呢?接下来我们继续来改进我们的Trace工具。
|
||||
|
||||
利用代码生成自动注入Trace函数
|
||||
|
||||
要实现向目标代码中的函数/方法自动注入Trace函数,我们首先要做的就是将上面Trace函数相关的代码打包到一个module中以方便其他module导入。下面我们就先来看看将Trace函数放入一个独立的module中的步骤。
|
||||
|
||||
将Trace函数放入一个独立的module中
|
||||
|
||||
我们创建一个名为instrument_trace的目录,进入这个目录后,通过go mod init命令创建一个名为github.com/bigwhite/instrument_trace的module:
|
||||
|
||||
$mkdir instrument_trace
|
||||
$cd instrument_trace
|
||||
$go mod init github.com/bigwhite/instrument_trace
|
||||
go: creating new go.mod: module github.com/bigwhite/instrument_trace
|
||||
|
||||
|
||||
接下来,我们将最新版的trace.go放入到该目录下,将包名改为trace,并仅保留Trace函数、Trace使用的函数以及包级变量,其他函数一律删除掉。这样,一个独立的trace包就提取完毕了。
|
||||
|
||||
作为trace包的作者,我们有义务告诉大家如何使用trace包。在Go中,通常我们会用一个example_test.go文件来编写使用trace包的演示代码,下面就是我们为trace包提供的example_test.go文件:
|
||||
|
||||
// instrument_trace/example_test.go
|
||||
package trace_test
|
||||
|
||||
import (
|
||||
trace "github.com/bigwhite/instrument_trace"
|
||||
)
|
||||
|
||||
func a() {
|
||||
defer trace.Trace()()
|
||||
b()
|
||||
}
|
||||
|
||||
func b() {
|
||||
defer trace.Trace()()
|
||||
c()
|
||||
}
|
||||
|
||||
func c() {
|
||||
defer trace.Trace()()
|
||||
d()
|
||||
}
|
||||
|
||||
func d() {
|
||||
defer trace.Trace()()
|
||||
}
|
||||
|
||||
func ExampleTrace() {
|
||||
a()
|
||||
// Output:
|
||||
// g[00001]: ->github.com/bigwhite/instrument_trace_test.a
|
||||
// g[00001]: ->github.com/bigwhite/instrument_trace_test.b
|
||||
// g[00001]: ->github.com/bigwhite/instrument_trace_test.c
|
||||
// g[00001]: ->github.com/bigwhite/instrument_trace_test.d
|
||||
// g[00001]: <-github.com/bigwhite/instrument_trace_test.d
|
||||
// g[00001]: <-github.com/bigwhite/instrument_trace_test.c
|
||||
// g[00001]: <-github.com/bigwhite/instrument_trace_test.b
|
||||
// g[00001]: <-github.com/bigwhite/instrument_trace_test.a
|
||||
}
|
||||
|
||||
|
||||
在example_test.go文件中,我们用ExampleXXX形式的函数表示一个示例,go test命令会扫描example_test.go中的以Example为前缀的函数并执行这些函数。
|
||||
|
||||
每个ExampleXXX函数需要包含预期的输出,就像上面ExampleTrace函数尾部那样,我们在一大段注释中提供这个函数执行后的预期输出,预期输出的内容从// Output:的下一行开始。go test会将ExampleTrace的输出与预期输出对比,如果不一致,会报测试错误。从这一点,我们可以看出example_test.go也是trace包单元测试的一部分。
|
||||
|
||||
现在Trace函数已经被放入到独立的包中了,接下来我们就来看看如何将它自动注入到要跟踪的函数中去。
|
||||
|
||||
自动注入Trace函数
|
||||
|
||||
现在,我们在instrument_trace module下面增加一个命令行工具,这个工具可以以一个Go源文件为单位,自动向这个Go源文件中的所有函数注入Trace函数。
|
||||
|
||||
我们再根据05讲中介绍的带有可执行文件的Go项目布局,在instrument_trace module中增加cmd/instrument目录,这个工具的main包就放在这个目录下,而真正实现自动注入Trace函数的代码呢,被我们放在了instrumenter目录下。
|
||||
|
||||
下面是变化后的instrument_trace module的目录结构:
|
||||
|
||||
$tree ./instrument_trace -F
|
||||
./instrument_trace
|
||||
├── Makefile
|
||||
├── cmd/
|
||||
│ └── instrument/
|
||||
│ └── main.go # instrument命令行工具的main包
|
||||
├── example_test.go
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── instrumenter/ # 自动注入逻辑的相关结构
|
||||
│ ├── ast/
|
||||
│ │ └── ast.go
|
||||
│ └── instrumenter.go
|
||||
└── trace.go
|
||||
|
||||
|
||||
我们先来看一下cmd/instrument/main.go源码,然后自上而下沿着main函数的调用逻辑逐一看一下这个功能的实现。下面是main.go的源码:
|
||||
|
||||
// instrument_trace/cmd/instrument/main.go
|
||||
|
||||
... ...
|
||||
|
||||
var (
|
||||
wrote bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.BoolVar(&wrote, "w", false, "write result to (source) file instead of stdout")
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Println("instrument [-w] xxx.go")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println(os.Args)
|
||||
flag.Usage = usage
|
||||
flag.Parse() // 解析命令行参数
|
||||
|
||||
if len(os.Args) < 2 { // 对命令行参数个数进行校验
|
||||
usage()
|
||||
return
|
||||
}
|
||||
|
||||
var file string
|
||||
if len(os.Args) == 3 {
|
||||
file = os.Args[2]
|
||||
}
|
||||
|
||||
if len(os.Args) == 2 {
|
||||
file = os.Args[1]
|
||||
}
|
||||
if filepath.Ext(file) != ".go" { // 对源文件扩展名进行校验
|
||||
usage()
|
||||
return
|
||||
}
|
||||
|
||||
var ins instrumenter.Instrumenter // 声明instrumenter.Instrumenter接口类型变量
|
||||
|
||||
// 创建以ast方式实现Instrumenter接口的ast.instrumenter实例
|
||||
ins = ast.New("github.com/bigwhite/instrument_trace", "trace", "Trace")
|
||||
newSrc, err := ins.Instrument(file) // 向Go源文件所有函数注入Trace函数
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if newSrc == nil {
|
||||
// add nothing to the source file. no change
|
||||
fmt.Printf("no trace added for %s\n", file)
|
||||
return
|
||||
}
|
||||
|
||||
if !wrote {
|
||||
fmt.Println(string(newSrc)) // 将生成的新代码内容输出到stdout上
|
||||
return
|
||||
}
|
||||
|
||||
// 将生成的新代码内容写回原Go源文件
|
||||
if err = ioutil.WriteFile(file, newSrc, 0666); err != nil {
|
||||
fmt.Printf("write %s error: %v\n", file, err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("instrument trace for %s ok\n", file)
|
||||
}
|
||||
|
||||
|
||||
作为命令行工具,instrument使用标准库的flag包实现对命令行参数(这里是-w)的解析,通过os.Args获取待注入的Go源文件路径。在完成对命令行参数个数与值的校验后,instrument程序声明了一个instrumenter.Instrumenter接口类型变量ins,然后创建了一个实现了Instrumenter接口类型的ast.instrumenter类型的实例,并赋值给变量ins。
|
||||
|
||||
instrumenter.Instrumenter接口类型的声明放在了instrumenter/instrumenter.go中:
|
||||
|
||||
type Instrumenter interface {
|
||||
Instrument(string) ([]byte, error)
|
||||
}
|
||||
|
||||
|
||||
这里我们看到,这个接口类型的方法列表中只有一个方法Instrument,这个方法接受一个Go源文件路径,返回注入了Trace函数的新源文件内容以及一个error类型值,作为错误状态标识。我们之所以要抽象出一个接口类型,考虑的就是注入Trace函数的实现方法不一,为后续的扩展做好预留。
|
||||
|
||||
在这个例子中,我们默认提供了一种自动注入Trace函数的实现,那就是ast.instrumenter,它注入Trace的实现原理是这样的:
|
||||
|
||||
|
||||
|
||||
从原理图中我们可以清楚地看到,在这一实现方案中,我们先将传入的Go源码转换为抽象语法树。
|
||||
|
||||
在计算机科学中,抽象语法树(abstract syntax tree,AST)是源代码的抽象语法结构的树状表现形式,树上的每个节点都表示源代码中的一种结构。因为Go语言是开源编程语言,所以它的抽象语法树的操作包也和语言一起开放给了Go开发人员,我们可以基于Go标准库以及Go实验工具库提供的ast相关包,快速地构建基于AST的应用,这里的ast.instrumenter就是一个应用AST的典型例子。
|
||||
|
||||
一旦我们通过ast相关包解析Go源码得到相应的抽象语法树后,我们便可以操作这棵语法树,并按我们的逻辑在语法树中注入我们的Trace函数,最后我们再将修改后的抽象语法树转换为Go源码,就完成了整个自动注入的工作了。
|
||||
|
||||
了解了原理后,我们再看一下具体的代码实现。下面是ast.instrumenter的Instructment方法的代码:
|
||||
|
||||
// instrument_trace/instrumenter/ast/ast.go
|
||||
|
||||
func (a instrumenter) Instrument(filename string) ([]byte, error) {
|
||||
fset := token.NewFileSet()
|
||||
curAST, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) // 解析Go源码,得到AST
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if !hasFuncDecl(curAST) { // 如果整个源码都不包含函数声明,则无需注入操作,直接返回。
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 在AST上添加包导入语句
|
||||
astutil.AddImport(fset, curAST, a.traceImport)
|
||||
|
||||
// 向AST上的所有函数注入Trace函数
|
||||
a.addDeferTraceIntoFuncDecls(curAST)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
err = format.Node(buf, fset, curAST) // 将修改后的AST转换回Go源码
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error formatting new code: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil // 返回转换后的Go源码
|
||||
}
|
||||
|
||||
|
||||
通过代码,我们看到Instrument方法的基本步骤与上面原理图大同小异。Instrument首先通过go/paser的ParserFile函数对传入的Go源文件中的源码进行解析,并得到对应的抽象语法树AST,然后向AST中导入Trace函数所在的包,并向这个AST的所有函数声明注入Trace函数调用。
|
||||
|
||||
实际的注入操作发生在instrumenter的addDeferTraceIntoFuncDecls方法中,我们来看一下这个方法的实现:
|
||||
|
||||
// instrument_trace/instrumenter/ast/ast.go
|
||||
|
||||
func (a instrumenter) addDeferTraceIntoFuncDecls(f *ast.File) {
|
||||
for _, decl := range f.Decls { // 遍历所有声明语句
|
||||
fd, ok := decl.(*ast.FuncDecl) // 类型断言:是否为函数声明
|
||||
if ok {
|
||||
// 如果是函数声明,则注入跟踪设施
|
||||
a.addDeferStmt(fd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这个方法的逻辑十分清晰,就是遍历语法树上所有声明语句,如果是函数声明,就调用instrumenter的addDeferStmt方法进行注入,如果不是,就直接返回。addDeferStmt方法的实现如下:
|
||||
|
||||
// instrument_trace/instrumenter/ast/ast.go
|
||||
|
||||
func (a instrumenter) addDeferStmt(fd *ast.FuncDecl) (added bool) {
|
||||
stmts := fd.Body.List
|
||||
|
||||
// 判断"defer trace.Trace()()"语句是否已经存在
|
||||
for _, stmt := range stmts {
|
||||
ds, ok := stmt.(*ast.DeferStmt)
|
||||
if !ok {
|
||||
// 如果不是defer语句,则继续for循环
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果是defer语句,则要进一步判断是否是defer trace.Trace()()
|
||||
ce, ok := ds.Call.Fun.(*ast.CallExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
se, ok := ce.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
x, ok := se.X.(*ast.Ident)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if (x.Name == a.tracePkg) && (se.Sel.Name == a.traceFunc) {
|
||||
// defer trace.Trace()()已存在,返回
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 没有找到"defer trace.Trace()()",注入一个新的跟踪语句
|
||||
// 在AST上构造一个defer trace.Trace()()
|
||||
ds := &ast.DeferStmt{
|
||||
Call: &ast.CallExpr{
|
||||
Fun: &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{
|
||||
X: &ast.Ident{
|
||||
Name: a.tracePkg,
|
||||
},
|
||||
Sel: &ast.Ident{
|
||||
Name: a.traceFunc,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newList := make([]ast.Stmt, len(stmts)+1)
|
||||
copy(newList[1:], stmts)
|
||||
newList[0] = ds // 注入新构造的defer语句
|
||||
fd.Body.List = newList
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
虽然addDeferStmt函数体略长,但逻辑也很清晰,就是先判断函数是否已经注入了Trace,如果有,则略过;如果没有,就构造一个Trace语句节点,并将它插入到AST中。
|
||||
|
||||
Instrument的最后一步就是将注入Trace后的AST重新转换为Go代码,这就是我们期望得到的带有Trace特性的Go代码了。
|
||||
|
||||
利用instrument工具注入跟踪代码
|
||||
|
||||
有了instrument工具后,我们再来看看如何使用这个工具,在目标Go源文件中自动注入跟踪设施。
|
||||
|
||||
这里,我在instrument_trace项目的examples目录下建立了一个名为demo的项目,我们就来看看如何使用instrument工具为demo项目下的demo.go文件自动注入跟踪设施。demo.go文件内容很简单:
|
||||
|
||||
// instrument_trace/examples/demo/demo.go
|
||||
|
||||
package main
|
||||
|
||||
func foo() {
|
||||
bar()
|
||||
}
|
||||
|
||||
func bar() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
foo()
|
||||
}
|
||||
|
||||
|
||||
我们首先构建一下instrument_trace下的instrument工具:
|
||||
|
||||
$cd instrument_trace
|
||||
$go build github.com/bigwhite/instrument_trace/cmd/instrument
|
||||
$instrument version
|
||||
[instrument version]
|
||||
instrument [-w] xxx.go
|
||||
-w write result to (source) file instead of stdout
|
||||
|
||||
|
||||
接下来,我们使用instrument工具向examples/demo/demo.go源文件中的函数自动注入跟踪设施:
|
||||
|
||||
$instrument -w examples/demo/demo.go
|
||||
[instrument -w examples/demo/demo.go]
|
||||
instrument trace for examples/demo/demo.go ok
|
||||
|
||||
|
||||
注入后的demo.go文件变为了下面这个样子:
|
||||
|
||||
// instrument_trace/examples/demo/demo.go
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/bigwhite/instrument_trace"
|
||||
|
||||
func foo() {
|
||||
defer trace.Trace()()
|
||||
bar()
|
||||
}
|
||||
|
||||
func bar() {
|
||||
defer trace.Trace()()
|
||||
}
|
||||
|
||||
func main() {
|
||||
defer trace.Trace()()
|
||||
foo()
|
||||
}
|
||||
|
||||
|
||||
此时,如果我们再对已注入Trace函数的demo.go执行一次instrument命令,由于instrument会判断demo.go各个函数已经注入了Trace,demo.go的内容将保持不变。
|
||||
|
||||
由于github.com/bigwhite/instrument_trace并没有真正上传到github.com上,所以如果你要运行demo.go,我们可以为它配置一个下面这样的go.mod:
|
||||
|
||||
|
||||
|
||||
// instrument_trace/examples/demo/go.mod
|
||||
|
||||
module demo
|
||||
|
||||
go 1.17
|
||||
|
||||
require github.com/bigwhite/instrument_trace v1.0.0
|
||||
|
||||
replace github.com/bigwhite/instrument_trace v1.0.0 => ../../
|
||||
|
||||
|
||||
这样运行demo.go就不会遇到障碍了:
|
||||
|
||||
$go run demo.go
|
||||
g[00001]: ->main.main
|
||||
g[00001]: ->main.foo
|
||||
g[00001]: ->main.bar
|
||||
g[00001]: <-main.bar
|
||||
g[00001]: <-main.foo
|
||||
g[00001]: <-main.main
|
||||
|
||||
|
||||
小结
|
||||
|
||||
到这里,我们已经实现了这节课开始时设定的目标:实现一个自动注入跟踪代码并输出有层次感的函数调用链跟踪命令行工具。
|
||||
|
||||
回顾一下这个工具的实现思路:我们先基于defer实现了一个最简单的函数跟踪机制,然后针对这个最简单的实现提出若干问题,接下来我们逐一把这些问题解决掉了,最终将第一版相对粗糙的代码实现演进重构为一个相对完善的命令行工具。
|
||||
|
||||
关于这个实战项目,有两点注意事项要和你交代清楚:
|
||||
|
||||
第一,在代码中注入函数调用跟踪代码仅适用于日常调试代码和阅读理解代码时使用,被注入了跟踪设施的代码是不适合上生产环境的;
|
||||
|
||||
第二,我在这里使用到了Go核心团队不推荐使用的Goroutine id,这也是由这个实战项目的性质所决定的。如果你的代码是上生产,我建议还是尽量听从Go核心团队的建议,不要依赖Goroutine ID。
|
||||
|
||||
思考题
|
||||
|
||||
通过instrument命令行工具对Go源文件进行注入后,defer trace.Trace()()就会成为Go源码的一部分被编译进最终的可执行文件中。我们在小结中也提到了,开启了Trace的代码不要上生产环境,这样我们在构建上生产的应用之前需要手工删掉这些Trace代码,操作起来十分繁琐易错。
|
||||
|
||||
所以,这里我想请你为Trace增加一个开关功能,有了这个开关后,日常开发调试过程中编译出的程序中的Trace是起作用的,但为生产环境编译出的可执行程序中虽然也包含Trace,但Trace不会真正起作用(提示:使用build tag)。
|
||||
|
||||
欢迎你把这节课分享给更多对Go语言感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
这个项目的源码在这里!
|
||||
|
||||
|
||||
|
||||
|
||||
346
专栏/TonyBai·Go语言第一课/28接口:接口即契约.md
Normal file
346
专栏/TonyBai·Go语言第一课/28接口:接口即契约.md
Normal file
@@ -0,0 +1,346 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 接口:接口即契约
|
||||
你好,我是Tony Bai。
|
||||
|
||||
从这一讲开始,我们将进入我们这门课核心篇的学习。相对于前两个篇章而言,这篇的内容更加考验大家的理解力,不过只要你跟上节奏,多多思考,掌握核心篇也不是什么困难的事情。
|
||||
|
||||
我先花小小的篇幅介绍一下核心篇的内容。核心篇主要涵盖接口类型语法与Go原生提供的三个并发原语(Goroutine、channel与select),之所以将它们放在核心语法的位置,是因为它们不仅代表了Go语言在编程语言领域的创新,更是影响Go应用骨架(Application Skeleton)设计的重要元素。
|
||||
|
||||
所谓应用骨架,就是指将应用代码中的业务逻辑、算法实现逻辑、错误处理逻辑等“皮肉”逐一揭去后所呈现出的应用结构,这就好比下面这个可爱的Gopher(地鼠)通过X光机所看到的骨骼结构:
|
||||
|
||||
|
||||
|
||||
通过这幅骨架结构图,你能看到哪些有用的信息呢?从静态角度去看,我们能清晰地看到应用程序的组成部分以及各个部分之间的连接;从动态角度去看,我们能看到这幅骨架上可独立运动的几大机构。
|
||||
|
||||
前者我们可以将其理解为Go应用内部的耦合设计,而后者我们可以理解为应用的并发设计。而接口类型与Go并发语法恰分别是耦合设计与并发设计的主要参与者,因此Go应用的骨架设计离不开它们。一个良好的骨架设计又决定了应用的健壮性、灵活性与扩展性,甚至是应用的运行效率。我们后面在讲解接口类型与并发原语的应用模式的时候,还会结合例子深入讲解。
|
||||
|
||||
所以,在接下的三讲中,我们将系统学习Go语言的接口类型,围绕接口类型的基础知识与接口定义的惯例、接口类型的内部表示以及接口的应用模式这三方面内容进行讲解。在这一讲中,我们先来学习一下接口类型的基础知识部分。
|
||||
|
||||
认识接口类型
|
||||
|
||||
在前面的学习中,我们曾不止一次接触过接口类型,对接口类型也有了一些粗浅的了解。我们知道,接口类型是由type和interface关键字定义的一组方法集合,其中,方法集合唯一确定了这个接口类型所表示的接口。下面是一个典型的接口类型MyInterface的定义:
|
||||
|
||||
type MyInterface interface {
|
||||
M1(int) error
|
||||
M2(io.Writer, ...string)
|
||||
}
|
||||
|
||||
|
||||
通过这个定义,我们可以看到,接口类型MyInterface所表示的接口的方法集合,包含两个方法M1和M2。之所以称M1和M2为“方法”,更多是从这个接口的实现者的角度考虑的。但从上面接口类型声明中各个“方法”的形式上来看,这更像是不带有func关键字的函数名+函数签名(参数列表+返回值列表)的组合。
|
||||
|
||||
并且,和我们在21讲中提到的函数签名一样,我们在接口类型的方法集合中声明的方法,它的参数列表不需要写出形参名字,返回值列表也是如此。也就是说,方法的参数列表中形参名字与返回值列表中的具名返回值,都不作为区分两个方法的凭据。
|
||||
|
||||
比如下面的MyInterface接口类型的定义与上面的MyInterface接口类型定义都是等价的:
|
||||
|
||||
type MyInterface interface {
|
||||
M1(a int) error
|
||||
M2(w io.Writer, strs ...string)
|
||||
}
|
||||
|
||||
type MyInterface interface {
|
||||
M1(n int) error
|
||||
M2(w io.Writer, args ...string)
|
||||
}
|
||||
|
||||
|
||||
不过,Go语言要求接口类型声明中的方法必须是具名的,并且方法名字在这个接口类型的方法集合中是唯一的。前面我们在学习类型嵌入时就学到过:Go 1.14版本以后,Go接口类型允许嵌入的不同接口类型的方法集合存在交集,但前提是交集中的方法不仅名字要一样,它的方法签名部分也要保持一致,也就是参数列表与返回值列表也要相同,否则Go编译器照样会报错。
|
||||
|
||||
比如下面示例中Interface3嵌入了Interface1和Interface2,但后两者交集中的M1方法的函数签名不同,导致了编译出错:
|
||||
|
||||
type Interface1 interface {
|
||||
M1()
|
||||
}
|
||||
type Interface2 interface {
|
||||
M1(string)
|
||||
M2()
|
||||
}
|
||||
|
||||
type Interface3 interface{
|
||||
Interface1
|
||||
Interface2 // 编译器报错:duplicate method M1
|
||||
M3()
|
||||
}
|
||||
|
||||
|
||||
看到这里,不知道你有没有注意到,我举的例子中的方法都是首字母大写的导出方法,那在接口类型定义中是否可以声明首字母小写的非导出方法呢?
|
||||
|
||||
答案是可以的。在Go接口类型的方法集合中放入首字母小写的非导出方法也是合法的,并且我们在Go标准库中也找到了带有非导出方法的接口类型定义,比如context包中的canceler接口类型,它的代码如下:
|
||||
|
||||
// $GOROOT/src/context.go
|
||||
|
||||
// A canceler is a context type that can be canceled directly. The
|
||||
// implementations are *cancelCtx and *timerCtx.
|
||||
type canceler interface {
|
||||
cancel(removeFromParent bool, err error)
|
||||
Done() <-chan struct{}
|
||||
}
|
||||
|
||||
|
||||
但这样的例子并不多。通过对标准库这为数不多的例子,我们可以看到,如果接口类型的方法集合中包含非导出方法,那么这个接口类型自身通常也是非导出的,它的应用范围也仅局限于包内。不过,在日常实际编码过程中,我们极少使用这种带有非导出方法的接口类型,我们简单了解一下就可以了。
|
||||
|
||||
除了上面这种常规情况,还有空接口类型这种特殊情况。如果一个接口类型定义中没有一个方法,那么它的方法集合就为空,比如下面的EmptyInterface接口类型:
|
||||
|
||||
type EmptyInterface interface {
|
||||
|
||||
}
|
||||
|
||||
|
||||
这个方法集合为空的接口类型就被称为空接口类型,但通常我们不需要自己显式定义这类空接口类型,我们直接使用interface{}这个类型字面值作为所有空接口类型的代表就可以了。
|
||||
|
||||
接口类型一旦被定义后,它就和其他Go类型一样可以用于声明变量,比如:
|
||||
|
||||
var err error // err是一个error接口类型的实例变量
|
||||
var r io.Reader // r是一个io.Reader接口类型的实例变量
|
||||
|
||||
|
||||
这些类型为接口类型的变量被称为接口类型变量,如果没有被显式赋予初值,接口类型变量的默认值为nil。如果要为接口类型变量显式赋予初值,我们就要为接口类型变量选择合法的右值。
|
||||
|
||||
Go规定:如果一个类型T的方法集合是某接口类型I的方法集合的等价集合或超集,我们就说类型T实现了接口类型I,那么类型T的变量就可以作为合法的右值赋值给接口类型I的变量。在第25和26两讲中,我们已经知道一个类型T和其指针类型*T的方法集合的求取规则了,所以这里我们也就不难判断一个类型是否实现了某个接口。
|
||||
|
||||
如果一个变量的类型是空接口类型,由于空接口类型的方法集合为空,这就意味着任何类型都实现了空接口的方法集合,所以我们可以将任何类型的值作为右值,赋值给空接口类型的变量,比如下面例子:
|
||||
|
||||
var i interface{} = 15 // ok
|
||||
i = "hello, golang" // ok
|
||||
type T struct{}
|
||||
var t T
|
||||
i = t // ok
|
||||
i = &t // ok
|
||||
|
||||
|
||||
空接口类型的这一可接受任意类型变量值作为右值的特性,让他成为Go加入泛型语法之前唯一一种具有“泛型”能力的语法元素,包括Go标准库在内的一些通用数据结构与算法的实现,都使用了空类型interface{}作为数据元素的类型,这样我们就无需为每种支持的元素类型单独做一份代码拷贝了。
|
||||
|
||||
Go语言还支持接口类型变量赋值的“逆操作”,也就是通过接口类型变量“还原”它的右值的类型与值信息,这个过程被称为“类型断言(Type Assertion)”。类型断言通常使用下面的语法形式:
|
||||
|
||||
v, ok := i.(T)
|
||||
|
||||
|
||||
其中i是某一个接口类型变量,如果T是一个非接口类型且T是想要还原的类型,那么这句代码的含义就是断言存储在接口类型变量i中的值的类型为T。
|
||||
|
||||
如果接口类型变量i之前被赋予的值确为T类型的值,那么这个语句执行后,左侧“comma, ok”语句中的变量ok的值将为true,变量v的类型为T,它值会是之前变量i的右值。如果i之前被赋予的值不是T类型的值,那么这个语句执行后,变量ok的值为false,变量v的类型还是那个要还原的类型,但它的值是类型T的零值。
|
||||
|
||||
类型断言也支持下面这种语法形式:
|
||||
|
||||
v := i.(T)
|
||||
|
||||
|
||||
但在这种形式下,一旦接口变量i之前被赋予的值不是T类型的值,那么这个语句将抛出panic。如果变量i被赋予的值是T类型的值,那么变量v的类型为T,它的值就会是之前变量i的右值。由于可能出现panic,所以我们并不推荐使用这种类型断言的语法形式。
|
||||
|
||||
为了加深你的理解,接下来我们通过一个例子来直观看一下类型断言的语义:
|
||||
|
||||
var a int64 = 13
|
||||
var i interface{} = a
|
||||
v1, ok := i.(int64)
|
||||
fmt.Printf("v1=%d, the type of v1 is %T, ok=%t\n", v1, v1, ok) // v1=13, the type of v1 is int64, ok=true
|
||||
v2, ok := i.(string)
|
||||
fmt.Printf("v2=%s, the type of v2 is %T, ok=%t\n", v2, v2, ok) // v2=, the type of v2 is string, ok=false
|
||||
v3 := i.(int64)
|
||||
fmt.Printf("v3=%d, the type of v3 is %T\n", v3, v3) // v3=13, the type of v3 is int64
|
||||
v4 := i.([]int) // panic: interface conversion: interface {} is int64, not []int
|
||||
fmt.Printf("the type of v4 is %T\n", v4)
|
||||
|
||||
|
||||
你可以看到,这个例子的输出结果与我们之前讲解的是一致的。
|
||||
|
||||
在这段代码中,如果v, ok := i.(T)中的T是一个接口类型,那么类型断言的语义就会变成:断言i的值实现了接口类型T。如果断言成功,变量v的类型为i的值的类型,而并非接口类型T。如果断言失败,v的类型信息为接口类型T,它的值为nil,下面我们再来看一个T为接口类型的示例:
|
||||
|
||||
type MyInterface interface {
|
||||
M1()
|
||||
}
|
||||
|
||||
type T int
|
||||
|
||||
func (T) M1() {
|
||||
println("T's M1")
|
||||
}
|
||||
|
||||
func main() {
|
||||
var t T
|
||||
var i interface{} = t
|
||||
v1, ok := i.(MyInterface)
|
||||
if !ok {
|
||||
panic("the value of i is not MyInterface")
|
||||
}
|
||||
v1.M1()
|
||||
fmt.Printf("the type of v1 is %T\n", v1) // the type of v1 is main.T
|
||||
|
||||
i = int64(13)
|
||||
v2, ok := i.(MyInterface)
|
||||
fmt.Printf("the type of v2 is %T\n", v2) // the type of v2 is <nil>
|
||||
// v2 = 13 // cannot use 1 (type int) as type MyInterface in assignment: int does not implement MyInterface (missing M1 method)
|
||||
}
|
||||
|
||||
|
||||
我们看到,通过the type of v2 is <nil>,我们其实是看不出断言失败后的变量v2的类型的,但通过最后一行代码的编译器错误提示,我们能清晰地看到v2的类型信息为MyInterface。
|
||||
|
||||
其实,接口类型的类型断言还有一个变种,那就是type switch,不过这个我们已经在第20讲讲解switch语句的时候讲过了,你可以再去复习一下。
|
||||
|
||||
好了,到这里关于接口类型的基础语法我们已经全部讲完了。有了这个基础后,我们再来看看Go语言接口定义的惯例,也就是尽量定义“小接口”。
|
||||
|
||||
尽量定义“小接口”
|
||||
|
||||
接口类型的背后,是通过把类型的行为抽象成契约,建立双方共同遵守的约定,这种契约将双方的耦合降到了最低的程度。和生活工作中的契约有繁有简,签署方式多样一样,代码间的契约也有多有少,有大有小,而且达成契约的方式也有所不同。 而Go选择了去繁就简的形式,这主要体现在以下两点上:
|
||||
|
||||
|
||||
隐式契约,无需签署,自动生效
|
||||
|
||||
|
||||
Go语言中接口类型与它的实现者之间的关系是隐式的,不需要像其他语言(比如Java)那样要求实现者显式放置“implements”进行修饰,实现者只需要实现接口方法集合中的全部方法便算是遵守了契约,并立即生效了。
|
||||
|
||||
|
||||
更倾向于“小契约”
|
||||
|
||||
|
||||
这点也不难理解。你想,如果契约太繁杂了就会束缚了手脚,缺少了灵活性,抑制了表现力。所以Go选择了使用“小契约”,表现在代码上就是尽量定义小接口,即方法个数在1~3个之间的接口。Go语言之父Rob Pike曾说过的“接口越大,抽象程度越弱”,这也是Go社区倾向定义小接口的另外一种表述。
|
||||
|
||||
Go对小接口的青睐在它的标准库中体现得淋漓尽致,这里我给出了标准库中一些我们日常开发中常用的接口的定义:
|
||||
|
||||
// $GOROOT/src/builtin/builtin.go
|
||||
type error interface {
|
||||
Error() string
|
||||
}
|
||||
|
||||
// $GOROOT/src/io/io.go
|
||||
type Reader interface {
|
||||
Read(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
// $GOROOT/src/net/http/server.go
|
||||
type Handler interface {
|
||||
ServeHTTP(ResponseWriter, *Request)
|
||||
}
|
||||
|
||||
type ResponseWriter interface {
|
||||
Header() Header
|
||||
Write([]byte) (int, error)
|
||||
WriteHeader(int)
|
||||
}
|
||||
|
||||
|
||||
我们看到,上述这些接口的方法数量在1~3个之间,这种“小接口”的Go惯例也已经被Go社区项目广泛采用。我统计了早期版本的Go标准库(Go 1.13版本)、Docker项目(Docker 19.03版本)以及Kubernetes项目(Kubernetes 1.17版本)中定义的接口类型方法集合中方法数量,你可以看下:
|
||||
|
||||
|
||||
|
||||
从图中我们可以看到,无论是Go标准库,还是Go社区知名项目,它们基本都遵循了“尽量定义小接口”的惯例,接口方法数量在1~3范围内的接口占了绝大多数。
|
||||
|
||||
那么在编码层面,小接口究竟有哪些优势呢?这里总结了几点,我们一起来看一下。
|
||||
|
||||
小接口有哪些优势?
|
||||
|
||||
第一点:接口越小,抽象程度越高
|
||||
|
||||
计算机程序本身就是对真实世界的抽象与再建构。抽象就是对同类事物去除它具体的、次要的方面,抽取它相同的、主要的方面。不同的抽象程度,会导致抽象出的概念对应的事物的集合不同。抽象程度越高,对应的集合空间就越大;抽象程度越低,也就是越具像化,更接近事物真实面貌,对应的集合空间越小。
|
||||
|
||||
我们举一个生活中的简单例子。你可以看下这张示意图,它是对生活中不同抽象程度的形象诠释:-
|
||||
-
|
||||
这张图中我们分别建立了三个抽象:
|
||||
|
||||
|
||||
会飞的。这个抽象对应的事物集合包括:蝴蝶、蜜蜂、麻雀、天鹅、鸳鸯、海鸥和信天翁;
|
||||
会游泳的。它对应的事物集合包括:鸭子、海豚、人类、天鹅、鸳鸯、海鸥和信天翁;
|
||||
会飞且会游泳的。这个抽象对应的事物集合包括:天鹅、鸳鸯、海鸥和信天翁。
|
||||
|
||||
|
||||
我们看到,“会飞的”、“会游泳的”这两个抽象对应的事物集合,要大于“会飞且会游泳的”所对应的事物集合空间,也就是说“会飞的”、“会游泳的”这两个抽象程度更高。
|
||||
|
||||
我们将上面的抽象转换为Go代码看看:
|
||||
|
||||
// 会飞的
|
||||
type Flyable interface {
|
||||
Fly()
|
||||
}
|
||||
|
||||
// 会游泳的
|
||||
type Swimable interface {
|
||||
Swim()
|
||||
}
|
||||
|
||||
// 会飞且会游泳的
|
||||
type FlySwimable interface {
|
||||
Flyable
|
||||
Swimable
|
||||
}
|
||||
|
||||
|
||||
我们用上述定义的接口替换上图中的抽象,再得到这张示意图:
|
||||
|
||||
-
|
||||
我们可以直观地看到,这张图中的Flyable只有一个Fly方法,FlySwimable则包含两个方法Fly和Swim。我们看到,具有更少方法的Flyable的抽象程度相对于FlySwimable要高,包含的事物集合(7种动物)也要比FlySwimable的事物集合(4种动物)大。也就是说,接口越小(接口方法少),抽象程度越高,对应的事物集合越大。
|
||||
|
||||
而这种情况的极限恰恰就是无方法的空接口interface{},空接口的这个抽象对应的事物集合空间包含了Go语言世界的所有事物。
|
||||
|
||||
第二点:小接口易于实现和测试
|
||||
|
||||
这是一个显而易见的优点。小接口拥有比较少的方法,一般情况下只有一个方法。所以要想满足这一接口,我们只需要实现一个方法或者少数几个方法就可以了,这显然要比实现拥有较多方法的接口要容易得多。尤其是在单元测试环节,构建类型去实现只有少量方法的接口要比实现拥有较多方法的接口付出的劳动要少许多。
|
||||
|
||||
第三点:小接口表示的“契约”职责单一,易于复用组合
|
||||
|
||||
我们前面就讲过,Go推崇通过组合的方式构建程序。Go开发人员一般会尝试通过嵌入其他已有接口类型的方式来构建新接口类型,就像通过嵌入io.Reader和io.Writer构建io.ReadWriter那样。
|
||||
|
||||
那构建时,如果有众多候选接口类型供我们选择,我们会怎么选择呢?
|
||||
|
||||
显然,我们会选择那些新接口类型需要的契约职责,同时也要求不要引入我们不需要的契约职责。在这样的情况下,拥有单一或少数方法的小接口便更有可能成为我们的目标,而那些拥有较多方法的大接口,可能会因引入了诸多不需要的契约职责而被放弃。由此可见,小接口更契合Go的组合思想,也更容易发挥出组合的威力。
|
||||
|
||||
定义小接口,你可以遵循的几点
|
||||
|
||||
保持简单有时候比复杂更难。小接口虽好,但如何定义出小接口是摆在所有Gopher面前的一道难题。这道题没有标准答案,但有一些点可供我们在实践中考量遵循。
|
||||
|
||||
|
||||
首先,别管接口大小,先抽象出接口。
|
||||
|
||||
|
||||
要设计和定义出小接口,前提是需要先有接口。
|
||||
|
||||
Go语言还比较年轻,它的设计哲学和推崇的编程理念可能还没被广大Gopher 100%理解、接纳和应用于实践当中,尤其是Go所推崇的基于接口的组合思想。
|
||||
|
||||
尽管接口不是Go独有的,但专注于接口是编写强大而灵活的Go代码的关键。因此,在定义小接口之前,我们需要先针对问题领域进行深入理解,聚焦抽象并发现接口,就像下图所展示的那样,先针对领域对象的行为进行抽象,形成一个接口集合:
|
||||
|
||||
|
||||
|
||||
初期,我们先不要介意这个接口集合中方法的数量,因为对问题域的理解是循序渐进的,在第一版代码中直接定义出小接口可能并不现实。而且,标准库中的io.Reader和io.Writer也不是在Go刚诞生时就有的,而是在发现对网络、文件、其他字节数据处理的实现十分相似之后才抽象出来的。并且越偏向业务层,抽象难度就越高,这或许也是前面图中Go标准库小接口(1~3个方法)占比略高于Docker和Kubernetes的原因。
|
||||
|
||||
|
||||
第二,将大接口拆分为小接口。
|
||||
|
||||
|
||||
有了接口后,我们就会看到接口被用在了代码的各个地方。一段时间后,我们就来分析哪些场合使用了接口的哪些方法,是否可以将这些场合使用的接口的方法提取出来,放入一个新的小接口中,就像下面图示中的那样:
|
||||
|
||||
|
||||
|
||||
这张图中的大接口1定义了多个方法,一段时间后,我们发现方法1和方法2经常用在场合1中,方法3和方法4经常用在场合2中,方法5和方法6经常用在场合3中,大接口1的方法呈现出一种按业务逻辑自然分组的状态。
|
||||
|
||||
这个时候我们可以将这三组方法分别提取出来放入三个小接口中,也就是将大接口1拆分为三个小接口A、B和C。拆分后,原应用场合1~3使用接口1的地方就可以无缝替换为接口A、B、C了。
|
||||
|
||||
|
||||
最后,我们要注意接口的单一契约职责。
|
||||
|
||||
|
||||
那么,上面已经被拆分成的小接口是否需要进一步拆分,直至每个接口都只有一个方法呢?这个依然没有标准答案,不过你依然可以考量一下现有小接口是否需要满足单一契约职责,就像io.Reader那样。如果需要,就可以进一步拆分,提升抽象程度。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
接口类型被列入Go核心语法,它的重要性不言自明,它既是Go语言的一个微创新,也是影响Go应用骨架设计的重要元素。
|
||||
|
||||
在这一节课中,我们首先系统学习了接口类型的基础知识,包括接口类型的声明、接口类型变量的定义与初始化以及类型断言等,这里面有很多语法细节,你一定要牢牢掌握,避免后续在使用接口时走弯路。比如,某接口类型定义中嵌入的不同接口类型的方法集合若存在交集,交集中的方法不仅名字要一样,函数签名也要相同。再比如,对接口类型和非接口类型进行类型断言的语义是不完全相同的。
|
||||
|
||||
Go接口背后的本质是一种“契约”,通过契约我们可以将代码双方的耦合降至最低。Go惯例上推荐尽量定义小接口,一般而言接口方法集合中的方法个数不要超过三个,单一方法的接口更受Go社区青睐。
|
||||
|
||||
小接口有诸多优点,比如,抽象程度高、易于测试与实现、与组合的设计思想一脉相承、鼓励你编写组合的代码,等等。但要一步到位地定义出小接口不是一件简单的事,尤其是在复杂的业务领域。我这里也给出了循序渐进地抽象出小接口的步骤,你可以参考并在实践中尝试一下。
|
||||
|
||||
思考题
|
||||
|
||||
|
||||
如果Go中没有接口类型,会发生什么,会对我们的代码设计产生怎样的影响?
|
||||
关于尽量定义小接口,你有什么好方法?
|
||||
|
||||
|
||||
欢迎在留言区留下你的观点与想法。也欢迎你把这节课分享给更多对Go语言的接口感兴趣的朋友。我是Tony Bai,下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
793
专栏/TonyBai·Go语言第一课/29接口:为什么nil接口不等于nil?.md
Normal file
793
专栏/TonyBai·Go语言第一课/29接口:为什么nil接口不等于nil?.md
Normal file
@@ -0,0 +1,793 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 接口:为什么nil接口不等于nil?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
上一讲我们学习了Go接口的基础知识与设计惯例,知道Go接口是构建Go应用骨架的重要元素。从语言设计角度来看,Go语言的接口(interface)和并发(concurrency)原语是我最喜欢的两类Go语言语法元素。Go语言核心团队的技术负责人Russ Cox也曾说过这样一句话:“如果要从Go语言中挑选出一个特性放入其他语言,我会选择接口”,这句话足以说明接口这一语法特性在这位Go语言大神心目中的地位。
|
||||
|
||||
为什么接口在Go中有这么高的地位呢?这是因为接口是Go这门静态语言中唯一“动静兼备”的语法特性。而且,接口“动静兼备”的特性给Go带来了强大的表达能力,但同时也给Go语言初学者带来了不少困惑。要想真正解决这些困惑,我们必须深入到Go运行时层面,看看Go语言在运行时是如何表示接口类型的。在这一讲中,我就带着你一起深入到接口类型的运行时表示层面看看。
|
||||
|
||||
好,在解惑之前,我们先来看看接口的静态与动态特性,看看“动静皆备”到底是什么意思。
|
||||
|
||||
接口的静态特性与动态特性
|
||||
|
||||
接口的静态特性体现在接口类型变量具有静态类型,比如var err error中变量err的静态类型为error。拥有静态类型,那就意味着编译器会在编译阶段对所有接口类型变量的赋值操作进行类型检查,编译器会检查右值的类型是否实现了该接口方法集合中的所有方法。如果不满足,就会报错:
|
||||
|
||||
var err error = 1 // cannot use 1 (type int) as type error in assignment: int does not implement error (missing Error method)
|
||||
|
||||
|
||||
而接口的动态特性,就体现在接口类型变量在运行时还存储了右值的真实类型信息,这个右值的真实类型被称为接口类型变量的动态类型。你看一下下面示例代码:
|
||||
|
||||
var err error
|
||||
err = errors.New("error1")
|
||||
fmt.Printf("%T\n", err) // *errors.errorString
|
||||
|
||||
|
||||
我们可以看到,这个示例通过errros.New构造了一个错误值,赋值给了error接口类型变量err,并通过fmt.Printf函数输出接口类型变量err的动态类型为*errors.errorString。
|
||||
|
||||
那接口的这种“动静皆备”的特性,又带来了什么好处呢?
|
||||
|
||||
首先,接口类型变量在程序运行时可以被赋值为不同的动态类型变量,每次赋值后,接口类型变量中存储的动态类型信息都会发生变化,这让Go语言可以像动态语言(比如Python)那样拥有使用Duck Typing(鸭子类型)的灵活性。所谓鸭子类型,就是指某类型所表现出的特性(比如是否可以作为某接口类型的右值),不是由其基因(比如C++中的父类)决定的,而是由类型所表现出来的行为(比如类型拥有的方法)决定的。
|
||||
|
||||
比如下面的例子:
|
||||
|
||||
type QuackableAnimal interface {
|
||||
Quack()
|
||||
}
|
||||
|
||||
type Duck struct{}
|
||||
|
||||
func (Duck) Quack() {
|
||||
println("duck quack!")
|
||||
}
|
||||
|
||||
type Dog struct{}
|
||||
|
||||
func (Dog) Quack() {
|
||||
println("dog quack!")
|
||||
}
|
||||
|
||||
type Bird struct{}
|
||||
|
||||
func (Bird) Quack() {
|
||||
println("bird quack!")
|
||||
}
|
||||
|
||||
func AnimalQuackInForest(a QuackableAnimal) {
|
||||
a.Quack()
|
||||
}
|
||||
|
||||
func main() {
|
||||
animals := []QuackableAnimal{new(Duck), new(Dog), new(Bird)}
|
||||
for _, animal := range animals {
|
||||
AnimalQuackInForest(animal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这个例子中,我们用接口类型QuackableAnimal来代表具有“会叫”这一特征的动物,而Duck、Bird和Dog类型各自都具有这样的特征,于是我们可以将这三个类型的变量赋值给QuackableAnimal接口类型变量a。每次赋值,变量a中存储的动态类型信息都不同,Quack方法的执行结果将根据变量a中存储的动态类型信息而定。
|
||||
|
||||
这里的Duck、Bird、Dog都是“鸭子类型”,但它们之间并没有什么联系,之所以能作为右值赋值给QuackableAnimal类型变量,只是因为他们表现出了QuackableAnimal所要求的特征罢了。
|
||||
|
||||
不过,与动态语言不同的是,Go接口还可以保证“动态特性”使用时的安全性。比如,编译器在编译期就可以捕捉到将int类型变量传给QuackableAnimal接口类型变量这样的明显错误,决不会让这样的错误遗漏到运行时才被发现。
|
||||
|
||||
接口类型的动静特性让我们看到了接口类型的强大,但在日常使用过程中,很多人都会产生各种困惑,其中最经典的一个困惑莫过于“nil的error值不等于nil”了。下面我们来详细看一下。
|
||||
|
||||
nil error值 != nil
|
||||
|
||||
这里我们直接来看一段改编自GO FAQ中的例子的代码:
|
||||
|
||||
type MyError struct {
|
||||
error
|
||||
}
|
||||
|
||||
var ErrBad = MyError{
|
||||
error: errors.New("bad things happened"),
|
||||
}
|
||||
|
||||
func bad() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func returnsError() error {
|
||||
var p *MyError = nil
|
||||
if bad() {
|
||||
p = &ErrBad
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func main() {
|
||||
err := returnsError()
|
||||
if err != nil {
|
||||
fmt.Printf("error occur: %+v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("ok")
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们的关注点集中在returnsError这个函数上面。这个函数定义了一个*MyError类型的变量p,初值为nil。如果函数bad返回false,returnsError函数就会直接将p(此时p = nil)作为返回值返回给调用者,之后调用者会将returnsError函数的返回值(error接口类型)与nil进行比较,并根据比较结果做出最终处理。
|
||||
|
||||
如果你是一个初学者,我猜你的的思路大概是这样的:p为nil,returnsError返回p,那么main函数中的err就等于nil,于是程序输出ok后退出。
|
||||
|
||||
但真实的运行结果是什么样的呢?我们来看一下:
|
||||
|
||||
error occur: <nil>
|
||||
|
||||
|
||||
我们看到,示例程序并未如我们前面预期的那样输出ok。程序显然是进入了错误处理分支,输出了err的值。那这里就有一个问题了:明明returnsError函数返回的p值为nil,为什么却满足了if err != nil的条件进入错误处理分支呢?
|
||||
|
||||
要想弄清楚这个问题,我们需要进一步了解接口类型变量的内部表示。
|
||||
|
||||
接口类型变量的内部表示
|
||||
|
||||
接口类型“动静兼备”的特性也决定了它的变量的内部表示绝不像一个静态类型变量(如int、float64)那样简单,我们可以在$GOROOT/src/runtime/runtime2.go中找到接口类型变量在运行时的表示:
|
||||
|
||||
// $GOROOT/src/runtime/runtime2.go
|
||||
type iface struct {
|
||||
tab *itab
|
||||
data unsafe.Pointer
|
||||
}
|
||||
|
||||
type eface struct {
|
||||
_type *_type
|
||||
data unsafe.Pointer
|
||||
}
|
||||
|
||||
|
||||
我们看到,在运行时层面,接口类型变量有两种内部表示:iface和eface,这两种表示分别用于不同的接口类型变量:
|
||||
|
||||
|
||||
eface用于表示没有方法的空接口(empty interface)类型变量,也就是interface{}类型的变量;
|
||||
iface用于表示其余拥有方法的接口interface类型变量。
|
||||
|
||||
|
||||
这两个结构的共同点是它们都有两个指针字段,并且第二个指针字段的功能相同,都是指向当前赋值给该接口类型变量的动态类型变量的值。
|
||||
|
||||
那它们的不同点在哪呢?就在于eface表示的空接口类型并没有方法列表,因此它的第一个指针字段指向一个_type类型结构,这个结构为该接口类型变量的动态类型的信息,它的定义是这样的:
|
||||
|
||||
// $GOROOT/src/runtime/type.go
|
||||
|
||||
type _type struct {
|
||||
size uintptr
|
||||
ptrdata uintptr // size of memory prefix holding all pointers
|
||||
hash uint32
|
||||
tflag tflag
|
||||
align uint8
|
||||
fieldAlign uint8
|
||||
kind uint8
|
||||
// function for comparing objects of this type
|
||||
// (ptr to object A, ptr to object B) -> ==?
|
||||
equal func(unsafe.Pointer, unsafe.Pointer) bool
|
||||
// gcdata stores the GC type data for the garbage collector.
|
||||
// If the KindGCProg bit is set in kind, gcdata is a GC program.
|
||||
// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
|
||||
gcdata *byte
|
||||
str nameOff
|
||||
ptrToThis typeOff
|
||||
}
|
||||
|
||||
|
||||
而iface除了要存储动态类型信息之外,还要存储接口本身的信息(接口的类型信息、方法列表信息等)以及动态类型所实现的方法的信息,因此iface的第一个字段指向一个itab类型结构。itab结构的定义如下:
|
||||
|
||||
// $GOROOT/src/runtime/runtime2.go
|
||||
type itab struct {
|
||||
inter *interfacetype
|
||||
_type *_type
|
||||
hash uint32 // copy of _type.hash. Used for type switches.
|
||||
_ [4]byte
|
||||
fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
|
||||
}
|
||||
|
||||
|
||||
这里我们也可以看到,itab结构中的第一个字段inter指向的interfacetype结构,存储着这个接口类型自身的信息。你看一下下面这段代码表示的interfacetype类型定义, 这个interfacetype结构由类型信息(typ)、包路径名(pkgpath)和接口方法集合切片(mhdr)组成。
|
||||
|
||||
// $GOROOT/src/runtime/type.go
|
||||
type interfacetype struct {
|
||||
typ _type
|
||||
pkgpath name
|
||||
mhdr []imethod
|
||||
}
|
||||
|
||||
|
||||
itab结构中的字段_type则存储着这个接口类型变量的动态类型的信息,字段fun则是动态类型已实现的接口方法的调用地址数组。
|
||||
|
||||
下面我们再结合例子用图片来直观展现eface和iface的结构。首先我们看一个用eface表示的空接口类型变量的例子:
|
||||
|
||||
type T struct {
|
||||
n int
|
||||
s string
|
||||
}
|
||||
|
||||
func main() {
|
||||
var t = T {
|
||||
n: 17,
|
||||
s: "hello, interface",
|
||||
}
|
||||
|
||||
var ei interface{} = t // Go运行时使用eface结构表示ei
|
||||
}
|
||||
|
||||
|
||||
这个例子中的空接口类型变量ei在Go运行时的表示是这样的:
|
||||
|
||||
|
||||
|
||||
我们看到空接口类型的表示较为简单,图中上半部分_type字段指向它的动态类型T的类型信息,下半部分的data则是指向一个T类型的实例值。
|
||||
|
||||
我们再来看一个更复杂的用iface表示非空接口类型变量的例子:
|
||||
|
||||
type T struct {
|
||||
n int
|
||||
s string
|
||||
}
|
||||
|
||||
func (T) M1() {}
|
||||
func (T) M2() {}
|
||||
|
||||
type NonEmptyInterface interface {
|
||||
M1()
|
||||
M2()
|
||||
}
|
||||
|
||||
func main() {
|
||||
var t = T{
|
||||
n: 18,
|
||||
s: "hello, interface",
|
||||
}
|
||||
var i NonEmptyInterface = t
|
||||
}
|
||||
|
||||
|
||||
和eface比起来,iface的表示稍微复杂些。我也画了一幅表示上面NonEmptyInterface接口类型变量在Go运行时表示的示意图:
|
||||
|
||||
|
||||
|
||||
由上面的这两幅图,我们可以看出,每个接口类型变量在运行时的表示都是由两部分组成的,针对不同接口类型我们可以简化记作:eface(_type, data)和iface(tab, data)。
|
||||
|
||||
而且,虽然eface和iface的第一个字段有所差别,但tab和_type可以统一看作是动态类型的类型信息。Go语言中每种类型都会有唯一的_type信息,无论是内置原生类型,还是自定义类型都有。Go运行时会为程序内的全部类型建立只读的共享_type信息表,因此拥有相同动态类型的同类接口类型变量的_type/tab信息是相同的。
|
||||
|
||||
而接口类型变量的data部分则是指向一个动态分配的内存空间,这个内存空间存储的是赋值给接口类型变量的动态类型变量的值。未显式初始化的接口类型变量的值为nil,也就是这个变量的_type/tab和data都为nil。
|
||||
|
||||
也就是说,我们判断两个接口类型变量是否相同,只需要判断_type/tab是否相同,以及data指针指向的内存空间所存储的数据值是否相同就可以了。这里要注意不是data指针的值相同噢。
|
||||
|
||||
不过,通过肉眼去辨别接口类型变量是否相等总是困难一些,我们可以引入一些helper函数。借助这些函数,我们可以清晰地输出接口类型变量的内部表示,这样就可以一目了然地看出两个变量是否相等了。
|
||||
|
||||
由于eface和iface是runtime包中的非导出结构体定义,我们不能直接在包外使用,所以也就无法直接访问到两个结构体中的数据。不过,Go语言提供了println预定义函数,可以用来输出eface或iface的两个指针字段的值。
|
||||
|
||||
在编译阶段,编译器会根据要输出的参数的类型将println替换为特定的函数,这些函数都定义在$GOROOT/src/runtime/print.go文件中,而针对eface和iface类型的打印函数实现如下:
|
||||
|
||||
// $GOROOT/src/runtime/print.go
|
||||
func printeface(e eface) {
|
||||
print("(", e._type, ",", e.data, ")")
|
||||
}
|
||||
|
||||
func printiface(i iface) {
|
||||
print("(", i.tab, ",", i.data, ")")
|
||||
}
|
||||
|
||||
|
||||
我们看到,printeface和printiface会输出各自的两个指针字段的值。下面我们就来使用println函数输出各类接口类型变量的内部表示信息,并结合输出结果,解析接口类型变量的等值比较操作。
|
||||
|
||||
第一种:nil接口变量
|
||||
|
||||
我们前面提过,未赋初值的接口类型变量的值为nil,这类变量也就是nil接口变量,我们来看这类变量的内部表示输出的例子:
|
||||
|
||||
func printNilInterface() {
|
||||
// nil接口变量
|
||||
var i interface{} // 空接口类型
|
||||
var err error // 非空接口类型
|
||||
println(i)
|
||||
println(err)
|
||||
println("i = nil:", i == nil)
|
||||
println("err = nil:", err == nil)
|
||||
println("i = err:", i == err)
|
||||
}
|
||||
|
||||
|
||||
运行这个函数,输出结果是这样的:
|
||||
|
||||
(0x0,0x0)
|
||||
(0x0,0x0)
|
||||
i = nil: true
|
||||
err = nil: true
|
||||
i = err: true
|
||||
|
||||
|
||||
我们看到,无论是空接口类型还是非空接口类型变量,一旦变量值为nil,那么它们内部表示均为(0x0,0x0),也就是类型信息、数据值信息均为空。因此上面的变量i和err等值判断为true。
|
||||
|
||||
第二种:空接口类型变量
|
||||
|
||||
下面是空接口类型变量的内部表示输出的例子:
|
||||
|
||||
func printEmptyInterface() {
|
||||
var eif1 interface{} // 空接口类型
|
||||
var eif2 interface{} // 空接口类型
|
||||
var n, m int = 17, 18
|
||||
|
||||
eif1 = n
|
||||
eif2 = m
|
||||
|
||||
println("eif1:", eif1)
|
||||
println("eif2:", eif2)
|
||||
println("eif1 = eif2:", eif1 == eif2) // false
|
||||
|
||||
eif2 = 17
|
||||
println("eif1:", eif1)
|
||||
println("eif2:", eif2)
|
||||
println("eif1 = eif2:", eif1 == eif2) // true
|
||||
|
||||
eif2 = int64(17)
|
||||
println("eif1:", eif1)
|
||||
println("eif2:", eif2)
|
||||
println("eif1 = eif2:", eif1 == eif2) // false
|
||||
}
|
||||
|
||||
|
||||
这个例子的运行输出结果是这样的:
|
||||
|
||||
eif1: (0x10ac580,0xc00007ef48)
|
||||
eif2: (0x10ac580,0xc00007ef40)
|
||||
eif1 = eif2: false
|
||||
eif1: (0x10ac580,0xc00007ef48)
|
||||
eif2: (0x10ac580,0x10eb3d0)
|
||||
eif1 = eif2: true
|
||||
eif1: (0x10ac580,0xc00007ef48)
|
||||
eif2: (0x10ac640,0x10eb3d8)
|
||||
eif1 = eif2: false
|
||||
|
||||
|
||||
我们按顺序分析一下这个输出结果。
|
||||
|
||||
首先,代码执行到第11行时,eif1与eif2已经分别被赋值整型值17与18,这样eif1和eif2的动态类型的类型信息是相同的(都是0x10ac580),但data指针指向的内存块中存储的值不同,一个是17,一个是18,于是eif1不等于eif2。
|
||||
|
||||
接着,代码执行到第16行的时候,eif2已经被重新赋值为17,这样eif1和eif2不仅存储的动态类型的类型信息是相同的(都是0x10ac580),data指针指向的内存块中存储值也相同了,都是17,于是eif1等于eif2。
|
||||
|
||||
然后,代码执行到第21行时,eif2已经被重新赋值了int64类型的数值17。这样,eif1和eif2存储的动态类型的类型信息就变成不同的了,一个是int,一个是int64,即便data指针指向的内存块中存储值是相同的,最终eif1与eif2也是不相等的。
|
||||
|
||||
从输出结果中我们可以总结一下:对于空接口类型变量,只有_type和data所指数据内容一致的情况下,两个空接口类型变量之间才能划等号。另外,Go在创建eface时一般会为data重新分配新内存空间,将动态类型变量的值复制到这块内存空间,并将data指针指向这块内存空间。因此我们多数情况下看到的data指针值都是不同的。
|
||||
|
||||
第三种:非空接口类型变量
|
||||
|
||||
这里,我们也直接来看一个非空接口类型变量的内部表示输出的例子:
|
||||
|
||||
type T int
|
||||
|
||||
func (t T) Error() string {
|
||||
return "bad error"
|
||||
}
|
||||
|
||||
func printNonEmptyInterface() {
|
||||
var err1 error // 非空接口类型
|
||||
var err2 error // 非空接口类型
|
||||
err1 = (*T)(nil)
|
||||
println("err1:", err1)
|
||||
println("err1 = nil:", err1 == nil)
|
||||
|
||||
err1 = T(5)
|
||||
err2 = T(6)
|
||||
println("err1:", err1)
|
||||
println("err2:", err2)
|
||||
println("err1 = err2:", err1 == err2)
|
||||
|
||||
err2 = fmt.Errorf("%d\n", 5)
|
||||
println("err1:", err1)
|
||||
println("err2:", err2)
|
||||
println("err1 = err2:", err1 == err2)
|
||||
}
|
||||
|
||||
|
||||
这个例子的运行输出结果如下:
|
||||
|
||||
err1: (0x10ed120,0x0)
|
||||
err1 = nil: false
|
||||
err1: (0x10ed1a0,0x10eb310)
|
||||
err2: (0x10ed1a0,0x10eb318)
|
||||
err1 = err2: false
|
||||
err1: (0x10ed1a0,0x10eb310)
|
||||
err2: (0x10ed0c0,0xc000010050)
|
||||
err1 = err2: false
|
||||
|
||||
|
||||
我们看到上面示例中每一轮通过println输出的err1和err2的tab和data值,要么data值不同,要么tab与data值都不同。
|
||||
|
||||
和空接口类型变量一样,只有tab和data指的数据内容一致的情况下,两个非空接口类型变量之间才能划等号。这里我们要注意err1下面的赋值情况:
|
||||
|
||||
err1 = (*T)(nil)
|
||||
|
||||
|
||||
针对这种赋值,println输出的err1是(0x10ed120, 0x0),也就是非空接口类型变量的类型信息并不为空,数据指针为空,因此它与nil(0x0,0x0)之间不能划等号。
|
||||
|
||||
现在我们再回到我们开头的那个问题,你是不是已经豁然开朗了呢?开头的问题中,从returnsError返回的error接口类型变量err的数据指针虽然为空,但它的类型信息(iface.tab)并不为空,而是*MyError对应的类型信息,这样err与nil(0x0,0x0)相比自然不相等,这就是我们开头那个问题的答案解析,现在你明白了吗?
|
||||
|
||||
第四种:空接口类型变量与非空接口类型变量的等值比较
|
||||
|
||||
下面是非空接口类型变量和空接口类型变量之间进行比较的例子:
|
||||
|
||||
func printEmptyInterfaceAndNonEmptyInterface() {
|
||||
var eif interface{} = T(5)
|
||||
var err error = T(5)
|
||||
println("eif:", eif)
|
||||
println("err:", err)
|
||||
println("eif = err:", eif == err)
|
||||
|
||||
err = T(6)
|
||||
println("eif:", eif)
|
||||
println("err:", err)
|
||||
println("eif = err:", eif == err)
|
||||
}
|
||||
|
||||
|
||||
这个示例的输出结果如下:
|
||||
|
||||
eif: (0x10b3b00,0x10eb4d0)
|
||||
err: (0x10ed380,0x10eb4d8)
|
||||
eif = err: true
|
||||
eif: (0x10b3b00,0x10eb4d0)
|
||||
err: (0x10ed380,0x10eb4e0)
|
||||
eif = err: false
|
||||
|
||||
|
||||
你可以看到,空接口类型变量和非空接口类型变量内部表示的结构有所不同(第一个字段:_type vs. tab),两者似乎一定不能相等。但Go在进行等值比较时,类型比较使用的是eface的_type和iface的tab._type,因此就像我们在这个例子中看到的那样,当eif和err都被赋值为T(5)时,两者之间是划等号的。
|
||||
|
||||
好了,到这里,我们已经学完了各类接口类型变量在运行时层的表示。我们可以通过println可以查看这个表示信息,从中我们也知道了接口变量只有在类型信息与值信息都一致的情况下才能划等号。
|
||||
|
||||
输出接口类型变量内部表示的详细信息
|
||||
|
||||
不过,println输出的接口类型变量的内部表示信息,在一般情况下都是足够的,但有些时候又显得过于简略,比如在上面最后一个例子中,如果仅凭eif: (0x10b3b00,0x10eb4d0)和err: (0x10ed380,0x10eb4d8)的输出,我们是无法想到两个变量是相等的。
|
||||
|
||||
那这时如果我们能输出接口类型变量内部表示的详细信息(比如:tab._type),那势必可以取得事半功倍的效果。接下来我们就看看这要怎么做。
|
||||
|
||||
前面提到过,eface和iface以及组成它们的itab和_type都是runtime包下的非导出结构体,我们无法在外部直接引用它们。但我们发现,组成eface、iface的类型都是基本数据类型,我们完全可以通过“复制代码”的方式将它们拿到runtime包外面来。
|
||||
|
||||
不过,这里要注意,由于runtime中的eface、iface,或者它们的组成可能会随着Go版本的变化发生变化,因此这个方法不具备跨版本兼容性。也就是说,基于Go 1.17版本复制的代码,可能仅适用于使用Go 1.17版本编译。这里我们就以Go 1.17版本为例看看:
|
||||
|
||||
// dumpinterface.go
|
||||
type eface struct {
|
||||
_type *_type
|
||||
data unsafe.Pointer
|
||||
}
|
||||
|
||||
type tflag uint8
|
||||
type nameOff int32
|
||||
type typeOff int32
|
||||
|
||||
type _type struct {
|
||||
size uintptr
|
||||
ptrdata uintptr // size of memory prefix holding all pointers
|
||||
hash uint32
|
||||
tflag tflag
|
||||
align uint8
|
||||
fieldAlign uint8
|
||||
kind uint8
|
||||
// function for comparing objects of this type
|
||||
// (ptr to object A, ptr to object B) -> ==?
|
||||
equal func(unsafe.Pointer, unsafe.Pointer) bool
|
||||
// gcdata stores the GC type data for the garbage collector.
|
||||
// If the KindGCProg bit is set in kind, gcdata is a GC program.
|
||||
// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
|
||||
gcdata *byte
|
||||
str nameOff
|
||||
ptrToThis typeOff
|
||||
}
|
||||
|
||||
type iface struct {
|
||||
tab *itab
|
||||
data unsafe.Pointer
|
||||
}
|
||||
|
||||
type itab struct {
|
||||
inter *interfacetype
|
||||
_type *_type
|
||||
hash uint32 // copy of _type.hash. Used for type switches.
|
||||
_ [4]byte
|
||||
fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
|
||||
}
|
||||
|
||||
... ...
|
||||
|
||||
const ptrSize = unsafe.Sizeof(uintptr(0))
|
||||
|
||||
func dumpEface(i interface{}) {
|
||||
ptrToEface := (*eface)(unsafe.Pointer(&i))
|
||||
fmt.Printf("eface: %+v\n", *ptrToEface)
|
||||
|
||||
if ptrToEface._type != nil {
|
||||
// dump _type info
|
||||
fmt.Printf("\t _type: %+v\n", *(ptrToEface._type))
|
||||
}
|
||||
|
||||
if ptrToEface.data != nil {
|
||||
// dump data
|
||||
switch i.(type) {
|
||||
case int:
|
||||
dumpInt(ptrToEface.data)
|
||||
case float64:
|
||||
dumpFloat64(ptrToEface.data)
|
||||
case T:
|
||||
dumpT(ptrToEface.data)
|
||||
|
||||
// other cases ... ...
|
||||
default:
|
||||
fmt.Printf("\t unsupported data type\n")
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
|
||||
func dumpItabOfIface(ptrToIface unsafe.Pointer) {
|
||||
p := (*iface)(ptrToIface)
|
||||
fmt.Printf("iface: %+v\n", *p)
|
||||
|
||||
if p.tab != nil {
|
||||
// dump itab
|
||||
fmt.Printf("\t itab: %+v\n", *(p.tab))
|
||||
// dump inter in itab
|
||||
fmt.Printf("\t\t inter: %+v\n", *(p.tab.inter))
|
||||
|
||||
// dump _type in itab
|
||||
fmt.Printf("\t\t _type: %+v\n", *(p.tab._type))
|
||||
|
||||
// dump fun in tab
|
||||
funPtr := unsafe.Pointer(&(p.tab.fun))
|
||||
fmt.Printf("\t\t fun: [")
|
||||
for i := 0; i < len((*(p.tab.inter)).mhdr); i++ {
|
||||
tp := (*uintptr)(unsafe.Pointer(uintptr(funPtr) + uintptr(i)*ptrSize))
|
||||
fmt.Printf("0x%x(%d),", *tp, *tp)
|
||||
}
|
||||
fmt.Printf("]\n")
|
||||
}
|
||||
}
|
||||
|
||||
func dumpDataOfIface(i interface{}) {
|
||||
// this is a trick as the data part of eface and iface are same
|
||||
ptrToEface := (*eface)(unsafe.Pointer(&i))
|
||||
|
||||
if ptrToEface.data != nil {
|
||||
// dump data
|
||||
switch i.(type) {
|
||||
case int:
|
||||
dumpInt(ptrToEface.data)
|
||||
case float64:
|
||||
dumpFloat64(ptrToEface.data)
|
||||
case T:
|
||||
dumpT(ptrToEface.data)
|
||||
|
||||
// other cases ... ...
|
||||
|
||||
default:
|
||||
fmt.Printf("\t unsupported data type\n")
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
|
||||
func dumpT(dataOfIface unsafe.Pointer) {
|
||||
var p *T = (*T)(dataOfIface)
|
||||
fmt.Printf("\t data: %+v\n", *p)
|
||||
}
|
||||
... ...
|
||||
|
||||
|
||||
|
||||
这里我挑选了关键部分,省略了部分代码。上面这个dumpinterface.go中提供了三个主要函数:
|
||||
|
||||
|
||||
dumpEface: 用于输出空接口类型变量的内部表示信息;
|
||||
dumpItabOfIface: 用于输出非空接口类型变量的tab字段信息;
|
||||
dumpDataOfIface: 用于输出非空接口类型变量的data字段信息;
|
||||
|
||||
|
||||
我们利用这三个函数来输出一下前面printEmptyInterfaceAndNonEmptyInterface函数中的接口类型变量的信息:
|
||||
|
||||
package main
|
||||
|
||||
import "unsafe"
|
||||
|
||||
type T int
|
||||
|
||||
func (t T) Error() string {
|
||||
return "bad error"
|
||||
}
|
||||
|
||||
func main() {
|
||||
var eif interface{} = T(5)
|
||||
var err error = T(5)
|
||||
println("eif:", eif)
|
||||
println("err:", err)
|
||||
println("eif = err:", eif == err)
|
||||
|
||||
dumpEface(eif)
|
||||
dumpItabOfIface(unsafe.Pointer(&err))
|
||||
dumpDataOfIface(err)
|
||||
}
|
||||
|
||||
|
||||
运行这个示例代码,我们得到了这个输出结果:
|
||||
|
||||
eif: (0x10b38c0,0x10e9b30)
|
||||
err: (0x10eb690,0x10e9b30)
|
||||
eif = err: true
|
||||
eface: {_type:0x10b38c0 data:0x10e9b30}
|
||||
_type: {size:8 ptrdata:0 hash:1156555957 tflag:15 align:8 fieldAlign:8 kind:2 equal:0x10032e0 gcdata:0x10e9a60 str:4946 ptrToThis:58496}
|
||||
data: bad error
|
||||
|
||||
iface: {tab:0x10eb690 data:0x10e9b30}
|
||||
itab: {inter:0x10b5e20 _type:0x10b38c0 hash:1156555957 _:[0 0 0 0] fun:[17454976]}
|
||||
inter: {typ:{size:16 ptrdata:16 hash:235953867 tflag:7 align:8 fieldAlign:8 kind:20 equal:0x10034c0 gcdata:0x10d2418 str:3666 ptrToThis:26848} pkgpath:{bytes:<nil>} mhdr:[{name:2592 ityp:43520}]}
|
||||
_type: {size:8 ptrdata:0 hash:1156555957 tflag:15 align:8 fieldAlign:8 kind:2 equal:0x10032e0 gcdata:0x10e9a60 str:4946 ptrToThis:58496}
|
||||
fun: [0x10a5780(17454976),]
|
||||
data: bad error
|
||||
|
||||
|
||||
从输出结果中,我们看到eif的_type(0x10b38c0)与err的tab._type(0x10b38c0)是一致的,data指针所指内容(“bad error”)也是一致的,因此eif == err表达式的结果为true。
|
||||
|
||||
再次强调一遍,上面这个实现可能仅在Go 1.17版本上测试通过,并且在输出iface或eface的data部分内容时只列出了int、float64和T类型的数据读取实现,没有列出全部类型的实现,你可以根据自己的需要实现其余数据类型。dumpinterface.go的完整代码你可以在这里找到。
|
||||
|
||||
我们现在已经知道了,接口类型有着复杂的内部结构,所以我们将一个类型变量值赋值给一个接口类型变量值的过程肯定不会像var i int = 5那么简单,那么接口类型变量赋值的过程是怎样的呢?其实接口类型变量赋值是一个“装箱”的过程。
|
||||
|
||||
接口类型的装箱(boxing)原理
|
||||
|
||||
装箱(boxing)是编程语言领域的一个基础概念,一般是指把一个值类型转换成引用类型,比如在支持装箱概念的Java语言中,将一个int变量转换成Integer对象就是一个装箱操作。
|
||||
|
||||
在Go语言中,将任意类型赋值给一个接口类型变量也是装箱操作。有了前面对接口类型变量内部表示的学习,我们知道接口类型的装箱实际就是创建一个eface或iface的过程。接下来我们就来简要描述一下这个过程,也就是接口类型的装箱原理。
|
||||
|
||||
我们基于下面这个例子中的接口装箱操作来说明:
|
||||
|
||||
// interface_internal.go
|
||||
|
||||
type T struct {
|
||||
n int
|
||||
s string
|
||||
}
|
||||
|
||||
func (T) M1() {}
|
||||
func (T) M2() {}
|
||||
|
||||
type NonEmptyInterface interface {
|
||||
M1()
|
||||
M2()
|
||||
}
|
||||
|
||||
func main() {
|
||||
var t = T{
|
||||
n: 17,
|
||||
s: "hello, interface",
|
||||
}
|
||||
var ei interface{}
|
||||
ei = t
|
||||
|
||||
var i NonEmptyInterface
|
||||
i = t
|
||||
fmt.Println(ei)
|
||||
fmt.Println(i)
|
||||
}
|
||||
|
||||
|
||||
这个例子中,对ei和i两个接口类型变量的赋值都会触发装箱操作,要想知道Go在背后做了些什么,我们需要“下沉”一层,也就是要输出上面Go代码对应的汇编代码:
|
||||
|
||||
$go tool compile -S interface_internal.go > interface_internal.s
|
||||
|
||||
|
||||
对应ei = t一行的汇编如下:
|
||||
|
||||
0x0026 00038 (interface_internal.go:24) MOVQ $17, ""..autotmp_15+104(SP)
|
||||
0x002f 00047 (interface_internal.go:24) LEAQ go.string."hello, interface"(SB), CX
|
||||
0x0036 00054 (interface_internal.go:24) MOVQ CX, ""..autotmp_15+112(SP)
|
||||
0x003b 00059 (interface_internal.go:24) MOVQ $16, ""..autotmp_15+120(SP)
|
||||
0x0044 00068 (interface_internal.go:24) LEAQ type."".T(SB), AX
|
||||
0x004b 00075 (interface_internal.go:24) LEAQ ""..autotmp_15+104(SP), BX
|
||||
0x0050 00080 (interface_internal.go:24) PCDATA $1, $0
|
||||
0x0050 00080 (interface_internal.go:24) CALL runtime.convT2E(SB)
|
||||
|
||||
|
||||
对应i = t一行的汇编如下:
|
||||
|
||||
0x005f 00095 (interface_internal.go:27) MOVQ $17, ""..autotmp_15+104(SP)
|
||||
0x0068 00104 (interface_internal.go:27) LEAQ go.string."hello, interface"(SB), CX
|
||||
0x006f 00111 (interface_internal.go:27) MOVQ CX, ""..autotmp_15+112(SP)
|
||||
0x0074 00116 (interface_internal.go:27) MOVQ $16, ""..autotmp_15+120(SP)
|
||||
0x007d 00125 (interface_internal.go:27) LEAQ go.itab."".T,"".NonEmptyInterface(SB), AX
|
||||
0x0084 00132 (interface_internal.go:27) LEAQ ""..autotmp_15+104(SP), BX
|
||||
0x0089 00137 (interface_internal.go:27) PCDATA $1, $1
|
||||
0x0089 00137 (interface_internal.go:27) CALL runtime.convT2I(SB)
|
||||
|
||||
|
||||
在将动态类型变量赋值给接口类型变量语句对应的汇编代码中,我们看到了convT2E和convT2I两个runtime包的函数。这两个函数的实现位于$GOROOT/src/runtime/iface.go中:
|
||||
|
||||
// $GOROOT/src/runtime/iface.go
|
||||
|
||||
func convT2E(t *_type, elem unsafe.Pointer) (e eface) {
|
||||
if raceenabled {
|
||||
raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2E))
|
||||
}
|
||||
if msanenabled {
|
||||
msanread(elem, t.size)
|
||||
}
|
||||
x := mallocgc(t.size, t, true)
|
||||
typedmemmove(t, x, elem)
|
||||
e._type = t
|
||||
e.data = x
|
||||
return
|
||||
}
|
||||
|
||||
func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
|
||||
t := tab._type
|
||||
if raceenabled {
|
||||
raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2I))
|
||||
}
|
||||
if msanenabled {
|
||||
msanread(elem, t.size)
|
||||
}
|
||||
x := mallocgc(t.size, t, true)
|
||||
typedmemmove(t, x, elem)
|
||||
i.tab = tab
|
||||
i.data = x
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
convT2E用于将任意类型转换为一个eface,convT2I用于将任意类型转换为一个iface。两个函数的实现逻辑相似,主要思路就是根据传入的类型信息(convT2E的_type和convT2I的tab._type)分配一块内存空间,并将elem指向的数据拷贝到这块内存空间中,最后传入的类型信息作为返回值结构中的类型信息,返回值结构中的数据指针(data)指向新分配的那块内存空间。
|
||||
|
||||
由此我们也可以看出,经过装箱后,箱内的数据,也就是存放在新分配的内存空间中的数据与原变量便无瓜葛了,比如下面这个例子:
|
||||
|
||||
func main() {
|
||||
var n int = 61
|
||||
var ei interface{} = n
|
||||
n = 62 // n的值已经改变
|
||||
fmt.Println("data in box:", ei) // 输出仍是61
|
||||
}
|
||||
|
||||
|
||||
那么convT2E和convT2I函数的类型信息是从何而来的呢?
|
||||
|
||||
其实这些都依赖Go编译器的工作。编译器知道每个要转换为接口类型变量(toType)和动态类型变量的类型(fromType),它会根据这一对类型选择适当的convT2X函数,并在生成代码时使用选出的convT2X函数参与装箱操作。
|
||||
|
||||
不过,装箱是一个有性能损耗的操作,因此Go也在不断对装箱操作进行优化,包括对常见类型如整型、字符串、切片等提供系列快速转换函数:
|
||||
|
||||
// $GOROOT/src/runtime/iface.go
|
||||
func convT16(val any) unsafe.Pointer // val must be uint16-like
|
||||
func convT32(val any) unsafe.Pointer // val must be uint32-like
|
||||
func convT64(val any) unsafe.Pointer // val must be uint64-like
|
||||
func convTstring(val any) unsafe.Pointer // val must be a string
|
||||
func convTslice(val any) unsafe.Pointer // val must be a slice
|
||||
|
||||
|
||||
这些函数去除了typedmemmove操作,增加了零值快速返回等特性。
|
||||
|
||||
同时Go建立了staticuint64s区域,对255以内的小整数值进行装箱操作时不再分配新内存,而是利用staticuint64s区域的内存空间,下面是staticuint64s的定义:
|
||||
|
||||
// $GOROOT/src/runtime/iface.go
|
||||
// staticuint64s is used to avoid allocating in convTx for small integer values.
|
||||
var staticuint64s = [...]uint64{
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
||||
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
接口类型作为参与构建Go应用骨架的重要参与者,在Go语言中有着很高的地位。它这个地位的取得离不开它拥有的“动静兼备”的语法特性。Go接口的动态特性让Go拥有与动态语言相近的灵活性,而静态特性又在编译阶段保证了这种灵活性的安全。
|
||||
|
||||
要更好地理解Go接口的这两种特性,我们需要深入到Go接口在运行时的表示层面上去。接口类型变量在运行时表示为eface和iface,eface用于表示空接口类型变量,iface用于表示非空接口类型变量。只有两个接口类型变量的类型信息(eface._type/iface.tab._type)相同,且数据指针(eface.data/iface.data)所指数据相同时,两个接口类型变量才是相等的。
|
||||
|
||||
我们可以通过println输出接口类型变量的两部分指针变量的值。而且,通过拷贝runtime包eface和iface相关类型源码,我们还可以自定义输出eface/iface详尽信息的函数,不过要注意的是,由于runtime层代码的演进,这个函数可能不具备在Go版本间的移植性。
|
||||
|
||||
最后,接口类型变量的赋值本质上是一种装箱操作,装箱操作是由Go编译器和运行时共同完成的,有一定的性能开销,对于性能敏感的系统来说,我们应该尽量避免或减少这类装箱操作。
|
||||
|
||||
思考题
|
||||
|
||||
像nil error值 != nil那个例子中的“坑”你在日常编码时有遇到过吗?可以和我们分享一下吗?另外,我们这节课中的这个例子如何修改,才能让它按我们最初的预期结果输出呢?
|
||||
|
||||
欢迎在留言区分享你的经验和想法。也欢迎你把这节课分享给更多对Go接口感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
475
专栏/TonyBai·Go语言第一课/30接口:Go中最强大的魔法.md
Normal file
475
专栏/TonyBai·Go语言第一课/30接口:Go中最强大的魔法.md
Normal file
@@ -0,0 +1,475 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
30 接口:Go中最强大的魔法
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在前面的两讲中,我们学习了接口的基础知识、接口类型定义的惯例以及接口在运行时的表示。掌握了这些内容后,可以说,在语法层面的有关接口的问题,对我们来说都不是什么阻碍了。在弄清楚接口是什么这个问题之后,摆在我们面前的就是怎么用接口的问题了。
|
||||
|
||||
不过,这里的“怎么用”,可不是要告诉你怎么使用Go标准库中的接口或第三方包中定义好的接口,而是让你学习如何利用接口进行应用的设计,以及改善已有应用的设计,换句话说就是Go接口的应用模式或惯例。
|
||||
|
||||
不过在讲解接口应用模式之前,我们还先要了解一个前置原则,那就是在实际真正需要的时候才对程序进行抽象。再通俗一些来讲,就是不要为了抽象而抽象。上一讲中我们说过,接口本质上是一种抽象,它的功能是解耦,所以这条原则也在告诉我们:不要为了使用接口而使用接口。举一个简单的例子,如果我们要给一个计算器添加一个整数加法的功能特性,本来一个函数就可以实现:
|
||||
|
||||
func Add(a int64, b int64) int64 {
|
||||
return a+b
|
||||
}
|
||||
|
||||
|
||||
但如果你非要引入一个接口,结果代码可能就变成了这样:
|
||||
|
||||
type Adder interface {
|
||||
Add(int64, int64) int64
|
||||
}
|
||||
|
||||
func Add(adder Adder, a int64, b int64) int64 {
|
||||
return adder.Add(a, b)
|
||||
}
|
||||
|
||||
|
||||
这就会产生一种“过设计”的味道了。
|
||||
|
||||
要注意,接口的确可以实现解耦,但它也会引入“抽象”的副作用,或者说接口这种抽象也不是免费的,是有成本的,除了会造成运行效率的下降之外,也会影响代码的可读性。不过这里你就不要拿我之前讲解中的实战例子去对号入座了,那些例子更多是为了让你学习Go语法的便利而构建的。
|
||||
|
||||
在多数情况下,在真实的生产项目中,接口都能给应用设计带来好处。那么如果要用接口,我们应该怎么用呢?怎么借助接口来改善程序的设计,让系统实现我们常说的高内聚和低耦合呢?这就要从Go语言的“组合”的设计哲学说起。
|
||||
|
||||
一切皆组合
|
||||
|
||||
Go语言之父Rob Pike曾说过:如果C++和Java是关于类型层次结构和类型分类的语言,那么Go则是关于组合的语言。如果把Go应用程序比作是一台机器的话,那么组合关注的就是如何将散落在各个包中的“零件”关联并组装到一起。我们前面也说过,组合是Go语言的重要设计哲学之一,而正交性则为组合哲学的落地提供了更为方便的条件。
|
||||
|
||||
正交(Orthogonality)是从几何学中借用的术语,说的是如果两条线以直角相交,那么这两条线就是正交的,比如我们在代数课程中经常用到的坐标轴就是这样。用向量术语说,这两条直线互不依赖,沿着某一条直线移动,你投影到另一条直线上的位置不变。
|
||||
|
||||
在计算机技术中,正交性用于表示某种不相依赖性或是解耦性。如果两个或更多事物中的一个发生变化,不会影响其他事物,那么这些事物就是正交的。比如,在设计良好的系统中,数据库代码与用户界面是正交的:你可以改动界面,而不影响数据库;更换数据库,而不用改动界面。
|
||||
|
||||
编程语言的语法元素间和语言特性也存在着正交的情况,并且通过将这些正交的特性组合起来,我们可以实现更为高级的特性。在语言设计层面,Go语言就为广大Gopher提供了诸多正交的语法元素供后续组合使用,包括:
|
||||
|
||||
|
||||
Go语言无类型体系(Type Hierarchy),没有父子类的概念,类型定义是正交独立的;
|
||||
方法和类型是正交的,每种类型都可以拥有自己的方法集合,方法本质上只是一个将receiver参数作为第一个参数的函数而已;
|
||||
接口与它的实现者之间无“显式关联”,也就说接口与Go语言其他部分也是正交的。
|
||||
|
||||
|
||||
在这些正交语法元素当中,接口作为Go语言提供的具有天然正交性的语法元素,在Go程序的静态结构搭建与耦合设计中扮演着至关重要的角色。 而要想知道接口究竟扮演什么角色,我们就先要了解组合的方式。
|
||||
|
||||
构建Go应用程序的静态骨架结构有两种主要的组合方式,如下图所示:
|
||||
|
||||
|
||||
|
||||
我们看到,这两种组合方式分别为垂直组合和水平组合,那这两种组合的各自含义与应用范围是什么呢?下面我们分别详细说说。
|
||||
|
||||
垂直组合
|
||||
|
||||
垂直组合更多用在将多个类型(如上图中的T1、I1等)通过“类型嵌入(Type Embedding)”的方式实现新类型(如NT1)的定义。
|
||||
|
||||
传统面向对象编程语言(比如:C++)大多是通过继承的方式建构出自己的类型体系的,但Go语言并没有类型体系的概念。Go语言通过类型的组合而不是继承让单一类型承载更多的功能。由于这种方式与硬件配置升级的垂直扩展很类似,所以这里我们叫它垂直组合。
|
||||
|
||||
又因为不是继承,那么通过垂直组合定义的新类型与被嵌入的类型之间就没有所谓“父子关系”的概念了,也没有向上、向下转型(Type Casting),被嵌入的类型也不知道将其嵌入的外部类型的存在。调用方法时,方法的匹配取决于方法名字,而不是类型。
|
||||
|
||||
这样的垂直组合更多应用在新类型的定义方面。通过这种垂直组合,我们可以达到方法实现的复用、接口定义重用等目的。
|
||||
|
||||
前面说了,在实现层面,Go语言通过类型嵌入(Type Embedding)实现垂直组合,组合方式主要有以下这么几种。因为我们在26讲已经对类型嵌入进行了详细讲解,我这里只简单带你回顾一下。
|
||||
|
||||
第一种:通过嵌入接口构建接口
|
||||
|
||||
通过在接口定义中嵌入其他接口类型,实现接口行为聚合,组成大接口。这种方式在标准库中非常常见,也是Go接口类型定义的惯例,我们在前面的讲解中也不止一次提及。
|
||||
|
||||
比如这个ReadWriter接口类型就采用了这种类型嵌入方式:
|
||||
|
||||
// $GOROOT/src/io/io.go
|
||||
type ReadWriter interface {
|
||||
Reader
|
||||
Writer
|
||||
}
|
||||
|
||||
|
||||
第二种:通过嵌入接口构建结构体类型
|
||||
|
||||
这里我们直接来看一个通过嵌入接口类型创建新结构体类型的例子:
|
||||
|
||||
type MyReader struct {
|
||||
io.Reader // underlying reader
|
||||
N int64 // max bytes remaining
|
||||
}
|
||||
|
||||
|
||||
在前面的讲解中,我们也曾提到过,在结构体中嵌入接口,可以用于快速构建满足某一个接口的结构体类型,来满足某单元测试的需要,之后我们只需要实现少数需要的接口方法就可以了。尤其是将这样的结构体类型变量传递赋值给大接口的时候,就更能体现嵌入接口类型的优势了。
|
||||
|
||||
第三种:通过嵌入结构体类型构建新结构体类型
|
||||
|
||||
在结构体中嵌入接口类型名和在结构体中嵌入其他结构体,都是“委派模式(delegate)”的一种应用。对新结构体类型的方法调用,可能会被“委派”给该结构体内部嵌入的结构体的实例,通过这种方式构建的新结构体类型就“继承”了被嵌入的结构体的方法的实现。
|
||||
|
||||
现在我们可以知道,包括嵌入接口类型在内的各种垂直组合更多用于类型定义层面,本质上它是一种类型组合,也是一种类型之间的耦合方式。
|
||||
|
||||
说完了垂直组合,我们再来看看水平组合。
|
||||
|
||||
水平组合
|
||||
|
||||
当我们通过垂直组合将一个个类型建立完毕后,就好比我们已经建立了整个应用程序骨架中的“器官”,比如手、手臂等,那么这些“器官”之间又是通过什么连接在一起的呢?
|
||||
|
||||
关节!
|
||||
|
||||
没错!那么在Go应用静态骨架中,什么元素经常扮演着“关节”的角色呢?我们通过一个例子来看一下。
|
||||
|
||||
假设现在我们有一个任务,要编写一个函数,实现将一段数据写入磁盘的功能。通常我们都可以很容易地写出下面的函数:
|
||||
|
||||
func Save(f *os.File, data []byte) error
|
||||
|
||||
|
||||
我们看到,这个函数使用一个*os.File来表示数据写入的目的地,这个函数实现后可以工作得很好。但这里依旧存在一些问题,我们来看一下。
|
||||
|
||||
首先,这个函数很难测试。os.File是一个封装了磁盘文件描述符(又称句柄)的结构体,只有通过打开或创建真实磁盘文件才能获得这个结构体的实例,这就意味着,如果我们要对Save这个函数进行单元测试,就必须使用真实的磁盘文件。测试过程中,通过Save函数写入文件后,我们还需要再次操作文件、读取刚刚写入的内容来判断写入内容是否正确,并且每次测试结束前都要对创建的临时文件进行清理,避免给后续的测试带去影响。
|
||||
|
||||
其次,Save函数违背了接口分离原则。根据业界广泛推崇的Robert Martin(Bob大叔)的接口分离原则(ISP原则,Interface Segregation Principle),也就是客户端不应该被迫依赖他们不使用的方法,我们会发现os.File不仅包含Save函数需要的与写数据相关的Write方法,还包含了其他与保存数据到文件操作不相关的方法。比如,你也可以看下*os.File包含的这些方法:
|
||||
|
||||
func (f *File) Chdir() error
|
||||
func (f *File) Chmod(mode FileMode) error
|
||||
func (f *File) Chown(uid, gid int) error
|
||||
... ...
|
||||
|
||||
|
||||
这种让Save函数被迫依赖它所不使用的方法的设计违反了ISP原则。
|
||||
|
||||
最后,Save函数对os.File的强依赖让它失去了扩展性。像Save这样的功能函数,它日后很大可能会增加向网络存储写入数据的功能需求。但如果到那时我们再来改变Save函数的函数签名(参数列表+返回值)的话,将影响到Save函数的所有调用者。
|
||||
|
||||
综合考虑这几种原因,我们发现Save函数所在的“器官”与os.File所在的“器官”之间采用了一种硬连接的方式,而以os.File这样的结构体作为“关节”让它连接的两个“器官”丧失了相互运动的自由度,让它与它连接的两个“器官”构成的联结体变得“僵直”。
|
||||
|
||||
那么,我们应该如何更换“关节”来改善Save的设计呢?我们来试试接口。新版的Save函数原型如下:
|
||||
|
||||
func Save(w io.Writer, data []byte) error
|
||||
|
||||
|
||||
你可以看到,我们用io.Writer接口类型替换掉了*os.File。这样一来,新版Save的设计就符合了接口分离原则,因为io.Writer仅包含一个Write方法,而且这个方法恰恰是Save唯一需要的方法。
|
||||
|
||||
另外,这里我们以io.Writer接口类型表示数据写入的目的地,既可以支持向磁盘写入,也可以支持向网络存储写入,并支持任何实现了Write方法的写入行为,这让Save函数的扩展性得到了质的提升。
|
||||
|
||||
还有一点,也是之前我们一直强调的,接口本质是契约,具有天然的降低耦合的作用。基于这点,我们对Save函数的测试也将变得十分容易,比如下面示例代码:
|
||||
|
||||
func TestSave(t *testing.T) {
|
||||
b := make([]byte, 0, 128)
|
||||
buf := bytes.NewBuffer(b)
|
||||
data := []byte("hello, golang")
|
||||
err := Save(buf, data)
|
||||
if err != nil {
|
||||
t.Errorf("want nil, actual %s", err.Error())
|
||||
}
|
||||
|
||||
saved := buf.Bytes()
|
||||
if !reflect.DeepEqual(saved, data) {
|
||||
t.Errorf("want %s, actual %s", string(data), string(saved))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,我们通过bytes.NewBuffer创建了一个*bytes.Buffer类型变量buf,由于bytes.Buffer实现了Write方法,进而实现了io.Writer接口,我们可以合法地将变量buf传递给Save函数。之后我们可以从buf中取出Save函数写入的数据内容与预期的数据做比对,就可以达到对Save函数进行单元测试的目的了。在整个测试过程中,我们不需要创建任何磁盘文件或建立任何网络连接。
|
||||
|
||||
看到这里,你应该感受到了,用接口作为“关节(连接点)”的好处很多!像上面图中展示的那样,接口可以将各个类型水平组合(连接)在一起。通过接口的编织,整个应用程序不再是一个个孤立的“器官”,而是一幅完整的、有灵活性和扩展性的静态骨架结构。
|
||||
|
||||
现在,我们已经确定了接口承担了应用骨架的“关节”角色,那么接下来我们就来看看接口是如何演好这一角色的。
|
||||
|
||||
接口应用的几种模式
|
||||
|
||||
前面已经说了,以接口为“关节”的水平组合方式,可以将各个垂直组合出的类型“耦合”在一起,从而编织出程序静态骨架。而通过接口进行水平组合的基本模式就是:使用接受接口类型参数的函数或方法。在这个基本模式基础上,还有其他几种“衍生品”。我们先从基本模式说起,再往外延伸。
|
||||
|
||||
基本模式
|
||||
|
||||
接受接口类型参数的函数或方法是水平组合的基本语法,形式是这样的:
|
||||
|
||||
func YourFuncName(param YourInterfaceType)
|
||||
|
||||
|
||||
我们套用骨架关节的概念,用这幅图来表示上面基本模式语法的运用方法:
|
||||
|
||||
|
||||
|
||||
我们看到,函数/方法参数中的接口类型作为“关节(连接点)”,支持将位于多个包中的多个类型与YourFuncName函数连接到一起,共同实现某一新特性。
|
||||
|
||||
同时,接口类型和它的实现者之间隐式的关系却在不经意间满足了:依赖抽象(DIP)、里氏替换原则(LSP)、接口隔离(ISP)等代码设计原则,这在其他语言中是需要很“刻意”地设计谋划的,但对Go接口来看,这一切却是自然而然的。
|
||||
|
||||
这一水平组合的基本模式在Go标准库、Go社区第三方包中有着广泛应用,其他几种模式也是从这个模式衍生的。下面我们逐一看一下各个衍生模式。
|
||||
|
||||
创建模式
|
||||
|
||||
Go社区流传一个经验法则:“接受接口,返回结构体(Accept interfaces, return structs)”,这其实就是一种把接口作为“关节”的应用模式。我这里把它叫做创建模式,是因为这个经验法则多用于创建某一结构体类型的实例。
|
||||
|
||||
下面是Go标准库中,运用创建模式创建结构体实例的代码摘录:
|
||||
|
||||
// $GOROOT/src/sync/cond.go
|
||||
type Cond struct {
|
||||
... ...
|
||||
L Locker
|
||||
}
|
||||
|
||||
func NewCond(l Locker) *Cond {
|
||||
return &Cond{L: l}
|
||||
}
|
||||
|
||||
// $GOROOT/src/log/log.go
|
||||
type Logger struct {
|
||||
mu sync.Mutex
|
||||
prefix string
|
||||
flag int
|
||||
out io.Writer
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func New(out io.Writer, prefix string, flag int) *Logger {
|
||||
return &Logger{out: out, prefix: prefix, flag: flag}
|
||||
}
|
||||
|
||||
// $GOROOT/src/log/log.go
|
||||
type Writer struct {
|
||||
err error
|
||||
buf []byte
|
||||
n int
|
||||
wr io.Writer
|
||||
}
|
||||
|
||||
func NewWriterSize(w io.Writer, size int) *Writer {
|
||||
// Is it already a Writer?
|
||||
b, ok := w.(*Writer)
|
||||
if ok && len(b.buf) >= size {
|
||||
return b
|
||||
}
|
||||
if size <= 0 {
|
||||
size = defaultBufSize
|
||||
}
|
||||
return &Writer{
|
||||
buf: make([]byte, size),
|
||||
wr: w,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们看到,创建模式在sync、log、bufio包中都有应用。以上面log包的New函数为例,这个函数用于实例化一个log.Logger实例,它接受一个io.Writer接口类型的参数,返回*log.Logger。从New的实现上来看,传入的out参数被作为初值赋值给了log.Logger结构体字段out。
|
||||
|
||||
创建模式通过接口,在NewXXX函数所在包与接口的实现者所在包之间建立了一个连接。大多数包含接口类型字段的结构体的实例化,都可以使用创建模式实现。这个模式比较容易理解,我们就不再深入了。
|
||||
|
||||
包装器模式
|
||||
|
||||
在基本模式的基础上,当返回值的类型与参数类型相同时,我们能得到下面形式的函数原型:
|
||||
|
||||
func YourWrapperFunc(param YourInterfaceType) YourInterfaceType
|
||||
|
||||
|
||||
通过这个函数,我们可以实现对输入参数的类型的包装,并在不改变被包装类型(输入参数类型)的定义的情况下,返回具备新功能特性的、实现相同接口类型的新类型。这种接口应用模式我们叫它包装器模式,也叫装饰器模式。包装器多用于对输入数据的过滤、变换等操作。
|
||||
|
||||
下面就是Go标准库中一个典型的包装器模式的应用:
|
||||
|
||||
// $GOROOT/src/io/io.go
|
||||
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }
|
||||
|
||||
type LimitedReader struct {
|
||||
R Reader // underlying reader
|
||||
N int64 // max bytes remaining
|
||||
}
|
||||
|
||||
func (l *LimitedReader) Read(p []byte) (n int, err error) {
|
||||
// ... ...
|
||||
}
|
||||
|
||||
|
||||
通过上面的代码,我们可以看到,通过LimitReader函数的包装后,我们得到了一个具有新功能特性的io.Reader接口的实现类型,也就是LimitedReader。这个新类型在Reader的语义基础上实现了对读取字节个数的限制。
|
||||
|
||||
接下来我们再具体看LimitReader的一个使用示例:
|
||||
|
||||
func main() {
|
||||
r := strings.NewReader("hello, gopher!\n")
|
||||
lr := io.LimitReader(r, 4)
|
||||
if _, err := io.Copy(os.Stdout, lr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
运行这个示例,我们得到了这个结果:
|
||||
|
||||
hell
|
||||
|
||||
|
||||
我们看到,当采用经过LimitReader包装后返回的io.Reader去读取内容时,读到的是经过LimitedReader约束后的内容,也就是只读到了原字符串前面的4个字节:“hell”。
|
||||
|
||||
由于包装器模式下的包装函数(如上面的LimitReader)的返回值类型与参数类型相同,因此我们可以将多个接受同一接口类型参数的包装函数组合成一条链来调用,形式是这样的:
|
||||
|
||||
YourWrapperFunc1(YourWrapperFunc2(YourWrapperFunc3(...)))
|
||||
|
||||
|
||||
我们在上面示例的基础上自定义一个包装函数:CapReader,通过这个函数的包装,我们能得到一个可以将输入的数据转换为大写的Reader接口实现:
|
||||
|
||||
func CapReader(r io.Reader) io.Reader {
|
||||
return &capitalizedReader{r: r}
|
||||
}
|
||||
|
||||
type capitalizedReader struct {
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (r *capitalizedReader) Read(p []byte) (int, error) {
|
||||
n, err := r.r.Read(p)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
q := bytes.ToUpper(p)
|
||||
for i, v := range q {
|
||||
p[i] = v
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func main() {
|
||||
r := strings.NewReader("hello, gopher!\n")
|
||||
r1 := CapReader(io.LimitReader(r, 4))
|
||||
if _, err := io.Copy(os.Stdout, r1); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里,我们将CapReader和io.LimitReader串在了一起形成一条调用链,这条调用链的功能变为:截取输入数据的前四个字节并将其转换为大写字母。这个示例的运行结果与我们预期功能也是一致的:
|
||||
|
||||
HELL
|
||||
|
||||
|
||||
适配器模式
|
||||
|
||||
适配器模式不是基本模式的直接衍生模式,但这种模式是后面我们讲解中间件模式的前提,所以我们需要简单介绍下这个模式。
|
||||
|
||||
适配器模式的核心是适配器函数类型(Adapter Function Type)。适配器函数类型是一个辅助水平组合实现的“工具”类型。这里我要再强调一下,它是一个类型。它可以将一个满足特定函数签名的普通函数,显式转换成自身类型的实例,转换后的实例同时也是某个接口类型的实现者。
|
||||
|
||||
最典型的适配器函数类型莫过于我们在第21讲中提到过的http.HandlerFunc了。这里,我们再来看一个应用http.HandlerFunc的例子:
|
||||
|
||||
func greetings(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "Welcome!")
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.ListenAndServe(":8080", http.HandlerFunc(greetings))
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,这个例子通过http.HandlerFunc这个适配器函数类型,将普通函数greetings快速转化为满足http.Handler接口的类型。而http.HandleFunc这个适配器函数类型的定义是这样的:
|
||||
|
||||
// $GOROOT/src/net/http/server.go
|
||||
type Handler interface {
|
||||
ServeHTTP(ResponseWriter, *Request)
|
||||
}
|
||||
|
||||
type HandlerFunc func(ResponseWriter, *Request)
|
||||
|
||||
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
|
||||
f(w, r)
|
||||
}
|
||||
|
||||
|
||||
经过HandlerFunc的适配转化后,我们就可以将它的实例用作实参,传递给接收http.Handler接口的http.ListenAndServe函数,从而实现基于接口的组合。
|
||||
|
||||
中间件(Middleware)
|
||||
|
||||
最后,我们来介绍下中间件这个应用模式。中间件(Middleware)这个词的含义可大可小。在Go Web编程中,“中间件”常常指的是一个实现了http.Handler接口的http.HandlerFunc类型实例。实质上,这里的中间件就是包装模式和适配器模式结合的产物。
|
||||
|
||||
我们来看一个例子:
|
||||
|
||||
func validateAuth(s string) error {
|
||||
if s != "123456" {
|
||||
return fmt.Errorf("%s", "bad auth token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func greetings(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "Welcome!")
|
||||
}
|
||||
|
||||
func logHandler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t := time.Now()
|
||||
log.Printf("[%s] %q %v\n", r.Method, r.URL.String(), t)
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func authHandler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := validateAuth(r.Header.Get("auth"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad auth param", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.ListenAndServe(":8080", logHandler(authHandler(http.HandlerFunc(greetings))))
|
||||
}
|
||||
|
||||
|
||||
我们看到,所谓中间件(如:logHandler、authHandler)本质就是一个包装函数(支持链式调用),但它的内部利用了适配器函数类型(http.HandlerFunc),将一个普通函数(比如例子中的几个匿名函数)转型为实现了http.Handler的类型的实例。
|
||||
|
||||
运行这个示例,并用curl工具命令对其进行测试,我们可以得到下面结果:
|
||||
|
||||
$curl http://localhost:8080
|
||||
bad auth param
|
||||
|
||||
$curl -H "auth:123456" localhost:8080/
|
||||
Welcome!
|
||||
|
||||
|
||||
从测试结果上看,中间件authHandler起到了对HTTP请求进行鉴权的作用。
|
||||
|
||||
好了,到这里我们完成了对接口的一个基本使用模式以及三个衍生模式的学习,深刻理解并灵活运用这些接口使用模式将给你的应用设计带来显著的提升。
|
||||
|
||||
尽量避免使用空接口作为函数参数类型
|
||||
|
||||
最后,我再来说一下接口使用的注意事项,这个注意事项与空接口有关。Go语言之父Rob Pike曾说过:空接口不提供任何信息(The empty interface says nothing)。我们应该怎么理解这句话的深层含义呢?
|
||||
|
||||
在Go语言中,一方面你不用像Java那样显式声明某个类型实现了某个接口,但另一方面,你又必须声明这个接口,这又与接口在Java等静态类型语言中的工作方式更加一致。
|
||||
|
||||
这种不需要类型显式声明实现了某个接口的方式,可以让种类繁多的类型与接口匹配,包括那些存量的、并非由你编写的代码以及你无法编辑的代码(比如:标准库)。Go的这种处理方式兼顾安全性和灵活性,其中,这个安全性就是由Go编译器来保证的,而为编译器提供输入信息的恰恰是接口类型的定义。
|
||||
|
||||
比如我们看下面的接口:
|
||||
|
||||
// $GOROOT/src/io/io.go
|
||||
type Reader interface {
|
||||
Read(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
|
||||
Go编译器通过解析这个接口定义,得到接口的名字信息以及它的方法信息,在为这个接口类型参数赋值时,编译器就会根据这些信息对实参进行检查。这时你可以想一下,如果函数或方法的参数类型为空接口interface{},会发生什么呢?
|
||||
|
||||
这恰好就应了Rob Pike的那句话:“空接口不提供任何信息”。这里“提供”一词的对象不是开发者,而是编译器。在函数或方法参数中使用空接口类型,就意味着你没有为编译器提供关于传入实参数据的任何信息,所以,你就会失去静态类型语言类型安全检查的“保护屏障”,你需要自己检查类似的错误,并且直到运行时才能发现此类错误。
|
||||
|
||||
所以,我建议广大Gopher尽可能地抽象出带有一定行为契约的接口,并将它作为函数参数类型,尽量不要使用可以“逃过”编译器类型安全检查的空接口类型(interface{})。
|
||||
|
||||
在这方面,Go标准库已经为我们作出了”表率“。全面搜索标准库后,你可以发现以interface{}为参数类型的方法和函数少之甚少。不过,也还有,使用interface{}作为参数类型的函数或方法主要有两类:
|
||||
|
||||
|
||||
容器算法类,比如:container下的heap、list和ring包、sort包、sync.Map等;
|
||||
格式化/日志类,比如:fmt包、log包等。
|
||||
|
||||
|
||||
这些使用interface{}作为参数类型的函数或方法都有一个共同特点,就是它们面对的都是未知类型的数据,所以在这里使用具有“泛型”能力的interface{}类型。我们也可以理解为是在Go语言尚未支持泛型的这个阶段的权宜之计。等Go泛型落地后,很多场合下interface{}就可以被泛型替代了。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们主要围绕接口的应用模式进行讲解。你在使用接口前一定要搞清楚自己使用接口的原因,千万不能为了使用接口而使用接口。
|
||||
|
||||
接口与Go的“组合”的设计哲学息息相关。在Go语言中,组合是Go程序间各个部分的主要耦合方式。垂直组合可实现方法实现和接口定义的重用,更多用于在新类型的定义方面。而水平组合更多将接口作为“关节”,将各个垂直组合出的类型“耦合”在一起,从而编制出程序的静态骨架。
|
||||
|
||||
通过接口进行水平组合的基本模式,是“使用接受接口类型参数的函数或方法”,在这一基本模式的基础上,我们还学习了几个衍生模式:创建模式、包装器模式与中间件模式。此外,我们还学习了一个辅助水平组合实现的“工具”类型:适配器函数类型,它也是实现中间件模式的前提。
|
||||
|
||||
最后需要我们牢记的是:我们要尽量避免使用空接口作为函数参数类型。一旦使用空接口作为函数参数类型,你将失去编译器为你提供的类型安全保护屏障。
|
||||
|
||||
思考题
|
||||
|
||||
除了这一讲中介绍的接口应用模式,你还见过或使用过哪些接口应用模式呢?期待在留言区看到你的分享。
|
||||
|
||||
欢迎把这节课分享给更多对Go语言的接口感兴趣的朋友。我是Tony Bai,下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
201
专栏/TonyBai·Go语言第一课/31并发:Go的并发方案实现方案是怎样的?.md
Normal file
201
专栏/TonyBai·Go语言第一课/31并发:Go的并发方案实现方案是怎样的?.md
Normal file
@@ -0,0 +1,201 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
31 并发:Go的并发方案实现方案是怎样的?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
从这一讲开始,我们将会学习这门课的最后一个语法知识:Go并发。在02讲中我们提到过:Go的设计者敏锐地把握了CPU向多核方向发展的这一趋势,在决定去创建Go语言的时候,他们果断将面向多核、原生支持并发作为了Go语言的设计目标之一,并将面向并发作为Go的设计哲学。当Go语言首次对外发布时,对并发的原生支持成为了Go最令开发者着迷的语法特性之一。
|
||||
|
||||
那么,怎么去学习Go并发呢?我的方法是将“Go并发”这个词拆开来看,它包含两方面内容,一个是并发的概念,另一个是Go针对并发设计给出的自身的实现方案,也就是goroutine、channel、select这些Go并发的语法特性。
|
||||
|
||||
今天这节课,我们就先来了解什么是并发,以及Go并发方案中最重要的概念,也就是goroutine,围绕它基本用法和注意事项,让你对Go并发有一个基本的了解,后面我们再层层深入。
|
||||
|
||||
什么是并发?
|
||||
|
||||
课程一开始,我们就经常提到并发(concurrency)这个词。说了这么长时间的并发,那究竟什么是并发呢?它又与并行(parallelism)有什么区别呢?要想搞清楚这些问题,我们需要简单回顾一下操作系统的基本调度单元的变迁,以及计算机处理器的演化对应用设计的影响。
|
||||
|
||||
很久以前,面向大众消费者的主流处理器(CPU)都是单核的,操作系统的基本调度与执行单元是进程(process)。这个时候,用户层的应用有两种设计方式,一种是单进程应用,也就是每次启动一个应用,操作系统都只启动一个进程来运行这个应用。
|
||||
|
||||
单进程应用的情况下,用户层应用、操作系统进程以及处理器之间的关系是这样的:
|
||||
|
||||
|
||||
|
||||
我们看到,这个设计下,每个单进程应用对应一个操作系统进程,操作系统内的多个进程按时间片大小,被轮流调度到仅有的一颗单核处理器上执行。换句话说,这颗单核处理器在某个时刻只能执行一个进程对应的程序代码,两个进程不存在并行执行的可能。
|
||||
|
||||
这里说的并行(parallelism),指的就是在同一时刻,有两个或两个以上的任务(这里指进程)的代码在处理器上执行。从这个概念我们也可以知道,多个处理器或多核处理器是并行执行的必要条件。
|
||||
|
||||
总的来说,单进程应用的设计比较简单,它的内部仅有一条代码执行流,代码从头执行到尾,不存在竞态,无需考虑同步问题。
|
||||
|
||||
用户层的另外一种设计方式,就是多进程应用,也就是应用通过fork等系统调用创建多个子进程,共同实现应用的功能。多进程应用的情况下,用户层应用、操作系统进程以及处理器之间的关系是这样的:
|
||||
|
||||
|
||||
|
||||
以图中的App1为例,这个应用设计者将应用内部划分为多个模块,每个模块用一个进程承载执行,每个模块都是一个单独的执行流,这样,App1内部就有了多个独立的代码执行流。
|
||||
|
||||
但限于当前仅有一颗单核处理器,这些进程(执行流)依旧无法并行执行,无论是App1内部的某个模块对应的进程,还是其他App对应的进程,都得逐个按时间片被操作系统调度到处理器上执行。
|
||||
|
||||
粗略看起来,多进程应用与单进程应用相比并没有什么质的提升。那我们为什么还要将应用设计为多进程呢?
|
||||
|
||||
这更多是从应用的结构角度去考虑的,多进程应用由于将功能职责做了划分,并指定专门的模块来负责,所以从结构上来看,要比单进程更为清晰简洁,可读性与可维护性也更好。这种将程序分成多个可独立执行的部分的结构化程序的设计方法,就是并发设计。采用了并发设计的应用也可以看成是一组独立执行的模块的组合。
|
||||
|
||||
不过,进程并不适合用于承载采用了并发设计的应用的模块执行流。因为进程是操作系统中资源拥有的基本单位,它不仅包含应用的代码和数据,还有系统级的资源,比如文件描述符、内存地址空间等等。进程的“包袱”太重,这导致它的创建、切换与撤销的代价都很大。
|
||||
|
||||
于是线程便走入了人们的视野,线程就是运行于进程上下文中的更轻量级的执行流。同时随着处理器技术的发展,多核处理器硬件成为了主流,这让真正的并行成为了可能,于是主流的应用设计模型变成了这样:
|
||||
|
||||
|
||||
|
||||
我们看到,基于线程的应用通常采用单进程多线程的模型,一个应用对应一个进程,应用通过并发设计将自己划分为多个模块,每个模块由一个线程独立承载执行。多个线程共享这个进程所拥有的资源,但线程作为执行单元可被独立调度到处理器上运行。
|
||||
|
||||
线程的创建、切换与撤销的代价相对于进程是要小得多。当这个应用的多个线程同时被调度到不同的处理器核上执行时,我们就说这个应用是并行的。
|
||||
|
||||
讲到这里,我们可以对并发与并行两个概念做一些区分了。就像Go语言之父Rob Pike曾说过那样:并发不是并行,并发关乎结构,并行关乎执行。
|
||||
|
||||
结合上面的例子,我们看到,并发是在应用设计与实现阶段要考虑的问题。并发考虑的是如何将应用划分为多个互相配合的、可独立执行的模块的问题。采用并发设计的程序并不一定是并行执行的。
|
||||
|
||||
在不满足并行必要条件的情况下(也就是仅有一个单核CPU的情况下),即便是采用并发设计的程序,依旧不可以并行执行。而在满足并行必要条件的情况下,采用并发设计的程序是可以并行执行的。而那些没有采用并发设计的应用程序,除非是启动多个程序实例,否则是无法并行执行的。
|
||||
|
||||
在多核处理器成为主流的时代,即使采用并发设计的应用程序以单实例的方式运行,其中的每个内部模块也都是运行于一个单独的线程中的,多核资源也可以得到充分利用。而且,并发让并行变得更加容易,采用并发设计的应用可以将负载自然扩展到各个CPU核上,从而提升处理器的利用效率。
|
||||
|
||||
在传统编程语言(如C、C++等)中,基于多线程模型的应用设计就是一种典型的并发程序设计。但传统编程语言并非面向并发而生,没有对并发设计提供过多的帮助。并且,这些语言多以操作系统线程作为承载分解后的代码片段(模块)的执行单元,由操作系统执行调度。这种传统支持并发的方式有很多不足:
|
||||
|
||||
首先就是复杂。
|
||||
|
||||
创建容易退出难。如果你做过C/C++编程,那你肯定知道,如果我们要利用libpthread库中提供的API创建一个线程,虽然要传入的参数个数不少,但好歹还是可以接受的。但一旦涉及线程的退出,就要考虑新创建的线程是否要与主线程分离(detach),还是需要主线程等待子线程终止(join)并获取其终止状态?又或者是否需要在新线程中设置取消点(cancel point)来保证被主线程取消(cancel)的时候能顺利退出。
|
||||
|
||||
而且,并发执行单元间的通信困难且易错。多个线程之间的通信虽然有多种机制可选,但用起来也是相当复杂。并且一旦涉及共享内存,就会用到各种锁互斥机制,死锁便成为家常便饭。另外,线程栈大小也需要设定,开发人员需要选择使用默认的,还是自定义设置。
|
||||
|
||||
第二就是难于规模化(scale)。
|
||||
|
||||
线程的使用代价虽然已经比进程小了很多,但我们依然不能大量创建线程,因为除了每个线程占用的资源不小之外,操作系统调度切换线程的代价也不小。
|
||||
|
||||
对于很多网络服务程序来说,由于不能大量创建线程,只能选择在少量线程里做网络多路复用的方案,也就是使用epoll/kqueue/IoCompletionPort这套机制,即便有像libevent和libev这样的第三方库帮忙,写起这样的程序也是很不容易的,存在大量钩子回调,给开发人员带来不小的心智负担。
|
||||
|
||||
那么以“原生支持并发”著称的Go语言在并发方面的实现方案又是什么呢?相对于基于线程的并发设计模型又有哪些改善呢?接下来我们就一起来看一下。
|
||||
|
||||
Go的并发方案:goroutine
|
||||
|
||||
Go并没有使用操作系统线程作为承载分解后的代码片段(模块)的基本执行单元,而是实现了goroutine这一由Go运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。
|
||||
|
||||
我们先来看看这一方案有啥优势。相比传统操作系统线程来说,goroutine的优势主要是:
|
||||
|
||||
|
||||
资源占用小,每个goroutine的初始栈大小仅为2k;
|
||||
由Go运行时而不是操作系统调度,goroutine上下文切换在用户层完成,开销更小;
|
||||
在语言层面而不是通过标准库提供。goroutine由go关键字创建,一退出就会被回收或销毁,开发体验更佳;
|
||||
语言内置channel作为goroutine间通信原语,为并发设计提供了强大支撑。
|
||||
|
||||
|
||||
我们看到,和传统编程语言不同的是,Go语言是面向并发而生的,所以,在程序的结构设计阶段,Go的惯例是优先考虑并发设计。这样做的目的更多是考虑随着外界环境的变化,通过并发设计的Go应用可以更好地、更自然地适应规模化(scale)。
|
||||
|
||||
比如,当应用被分配到更多计算资源,或者计算处理硬件增配后,Go应用不需要再进行结构调整,就可以充分利用新增的计算资源。而且,经过并发设计后的Go应用也会更加契合Gopher们的开发分工协作。
|
||||
|
||||
接下来,我们来看看在Go中究竟如何使用goroutine。
|
||||
|
||||
goroutine的基本用法
|
||||
|
||||
并发是一种能力,它让你的程序可以由若干个代码片段组合而成,并且每个片段都是独立运行的。goroutine恰恰就是Go原生支持并发的一个具体实现。无论是Go自身运行时代码还是用户层Go代码,都无一例外地运行在goroutine中。
|
||||
|
||||
首先我们来创建一个goroutine。
|
||||
|
||||
Go语言通过go关键字+函数/方法的方式创建一个goroutine。创建后,新goroutine将拥有独立的代码执行流,并与创建它的goroutine一起被Go运行时调度。
|
||||
|
||||
这里我给出了一些创建goroutine的代码示例:
|
||||
|
||||
go fmt.Println("I am a goroutine")
|
||||
|
||||
var c = make(chan int)
|
||||
go func(a, b int) {
|
||||
c <- a + b
|
||||
}(3,4)
|
||||
|
||||
// $GOROOT/src/net/http/server.go
|
||||
c := srv.newConn(rw)
|
||||
go c.serve(connCtx)
|
||||
|
||||
|
||||
我们看到,通过go关键字,我们可以基于已有的具名函数/方法创建goroutine,也可以基于匿名函数/闭包创建goroutine。
|
||||
|
||||
在前面的讲解中,我们曾说过,创建goroutine后,go关键字不会返回goroutine id之类的唯一标识goroutine的id,你也不要尝试去得到这样的id并依赖它。另外,和线程一样,一个应用内部启动的所有goroutine共享进程空间的资源,如果多个goroutine访问同一块内存数据,将会存在竞争,我们需要进行goroutine间的同步。
|
||||
|
||||
了解了怎么创建,那我们怎么退出goroutine呢?
|
||||
|
||||
goroutine的使用代价很低,Go官方也推荐你多多使用goroutine。而且,多数情况下,我们不需要考虑对goroutine的退出进行控制:goroutine的执行函数的返回,就意味着goroutine退出。
|
||||
|
||||
如果main goroutine退出了,那么也意味着整个应用程序的退出。此外,你还要注意的是,goroutine执行的函数或方法即便有返回值,Go也会忽略这些返回值。所以,如果你要获取goroutine执行后的返回值,你需要另行考虑其他方法,比如通过goroutine间的通信来实现。
|
||||
|
||||
接下来我们就来说说goroutine间的通信方式。
|
||||
|
||||
goroutine间的通信
|
||||
|
||||
传统的编程语言(比如:C++、Java、Python等)并非面向并发而生的,所以他们面对并发的逻辑多是基于操作系统的线程。并发的执行单元(线程)之间的通信,利用的也是操作系统提供的线程或进程间通信的原语,比如:共享内存、信号(signal)、管道(pipe)、消息队列、套接字(socket)等。
|
||||
|
||||
在这些通信原语中,使用最多、最广泛的(也是最高效的)是结合了线程同步原语(比如:锁以及更为低级的原子操作)的共享内存方式,因此,我们可以说传统语言的并发模型是基于对内存的共享的。
|
||||
|
||||
不过,这种传统的基于共享内存的并发模型很难用,且易错,尤其是在大型或复杂程序中,开发人员在设计并发程序时,需要根据线程模型对程序进行建模,同时规划线程之间的通信方式。如果选择的是高效的基于共享内存的机制,那么他们还要花费大量心思设计线程间的同步机制,并且在设计同步机制的时候,还要考虑多线程间复杂的内存管理,以及如何防止死锁等情况。
|
||||
|
||||
这种情况下,开发人员承受着巨大的心智负担,并且基于这类传统并发模型的程序难于编写、阅读、理解和维护。一旦程序发生问题,查找Bug的过程更是漫长和艰辛。
|
||||
|
||||
但Go语言就不一样了!Go语言从设计伊始,就将解决上面这个传统并发模型的问题作为Go的一个目标,并在新并发模型设计中借鉴了著名计算机科学家Tony Hoare提出的CSP(Communicating Sequential Processes,通信顺序进程)并发模型。
|
||||
|
||||
Tony Hoare的CSP模型旨在简化并发程序的编写,让并发程序的编写与编写顺序程序一样简单。Tony Hoare认为输入输出应该是基本的编程原语,数据处理逻辑(也就是CSP中的P)只需调用输入原语获取数据,顺序地处理数据,并将结果数据通过输出原语输出就可以了。
|
||||
|
||||
因此,在Tony Hoare眼中,一个符合CSP模型的并发程序应该是一组通过输入输出原语连接起来的P的集合。从这个角度来看,CSP理论不仅是一个并发参考模型,也是一种并发程序的程序组织方法。它的组合思想与Go的设计哲学不谋而合。
|
||||
|
||||
Tony Hoare的CSP理论中的P,也就是“Process(进程)”,是一个抽象概念,它代表任何顺序处理逻辑的封装,它获取输入数据(或从其他P的输出获取),并生产出可以被其他P消费的输出数据。这里我们可以简单看下CSP通信模型的示意图:
|
||||
|
||||
|
||||
|
||||
注意了,这里的P并不一定与操作系统的进程或线程划等号。在Go中,与“Process”对应的是goroutine。为了实现CSP并发模型中的输入和输出原语,Go还引入了goroutine(P)之间的通信原语channel。goroutine可以从channel获取输入数据,再将处理后得到的结果数据通过channel输出。通过channel将goroutine(P)组合连接在一起,让设计和编写大型并发系统变得更加简单和清晰,我们再也不用为那些传统共享内存并发模型中的问题而伤脑筋了。
|
||||
|
||||
比如我们上面提到的获取goroutine的退出状态,就可以使用channel原语实现:
|
||||
|
||||
func spawn(f func() error) <-chan error {
|
||||
c := make(chan error)
|
||||
|
||||
go func() {
|
||||
c <- f()
|
||||
}()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func main() {
|
||||
c := spawn(func() error {
|
||||
time.Sleep(2 * time.Second)
|
||||
return errors.New("timeout")
|
||||
})
|
||||
fmt.Println(<-c)
|
||||
}
|
||||
|
||||
|
||||
这个示例在main goroutine与子goroutine之间建立了一个元素类型为error的channel,子goroutine退出时,会将它执行的函数的错误返回值写入这个channel,main goroutine可以通过读取channel的值来获取子goroutine的退出状态。
|
||||
|
||||
虽然CSP模型已经成为Go语言支持的主流并发模型,但Go也支持传统的、基于共享内存的并发模型,并提供了基本的低级别同步原语(主要是sync包中的互斥锁、条件变量、读写锁、原子操作等)。
|
||||
|
||||
那么我们在实践中应该选择哪个模型的并发原语呢?是使用channel,还是在低级同步原语保护下的共享内存呢?
|
||||
|
||||
毫无疑问,从程序的整体结构来看,Go始终推荐以CSP并发模型风格构建并发程序,尤其是在复杂的业务层面,这能提升程序的逻辑清晰度,大大降低并发设计的复杂性,并让程序更具可读性和可维护性。
|
||||
|
||||
不过,对于局部情况,比如涉及性能敏感的区域或需要保护的结构体数据时,我们可以使用更为高效的低级同步原语(如mutex),保证goroutine对数据的同步访问。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
这一讲中,我们开始了对Go并发的学习,了解了并发的含义,以及并发与并行两个概念的区别。你一定要记住:并发不是并行。并发是应用结构设计相关的概念,而并行只是程序执行期的概念,并行的必要条件是具有多个处理器或多核处理器,否则无论是否是并发的设计,程序执行时都有且仅有一个任务可以被调度到处理器上执行。
|
||||
|
||||
传统的编程语言(比如:C、C++)的并发程序设计方案是基于操作系统的线程调度模型的,这种模型与操作系统的调度强耦合,并且对于开发人员来说十分复杂,开发体验较差并且易错。
|
||||
|
||||
而Go给出的并发方案是基于轻量级线程goroutine的。goroutine占用的资源非常小,创建、切换以及销毁的开销很小。并且Go在语法层面原生支持基于goroutine的并发,通过一个go关键字便可以轻松创建goroutine,goroutine占用的资源非常小,创建、切换以及销毁的开销很小。这给开发者带来极佳的开发体验。
|
||||
|
||||
思考题
|
||||
|
||||
goroutine作为Go应用的基本执行单元,它的创建、退出以及goroutine间的通信都有很多常见的模式可循。你可以分享一下日常开发中你见过的实用的goroutine使用模式吗?
|
||||
|
||||
欢迎把这节课分享给更多对Go并发感兴趣的朋友。我是Tony Bai,下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
304
专栏/TonyBai·Go语言第一课/32并发:聊聊Goroutine调度器的原理.md
Normal file
304
专栏/TonyBai·Go语言第一课/32并发:聊聊Goroutine调度器的原理.md
Normal file
@@ -0,0 +1,304 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
32 并发:聊聊Goroutine调度器的原理
|
||||
你好,我是Tony Bai。
|
||||
|
||||
上一讲我们学习了并发的基本概念和Go的并发方案,也就是Goroutine的一些基本使用和注意事项。对于大多数Gopher来说,这些内容作为Go并发入门已经是足够了。
|
||||
|
||||
但毕竟Go没有采用基于线程的并发模型,可能很多Gopher都好奇Go运行时究竟是如何将一个个Goroutine调度到CPU上执行的。当然,Goroutine的调度本来是Go语言核心开发团队才应该关注的事情,大多数Gopher们无需关心。但就我个人的学习和实践经验而言,我觉得了解Goroutine的调度模型和原理,能够帮助我们编写出更高质量的Go代码。
|
||||
|
||||
因此,在这一讲中,我想和你一起简单探究一下Goroutine调度器的原理和演化历史。
|
||||
|
||||
Goroutine调度器
|
||||
|
||||
提到“调度”,我们首先想到的就是操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理CPU上去运行。
|
||||
|
||||
前面我们也提到,传统的编程语言,比如C、C++等的并发实现,多是基于线程模型的,也就是应用程序负责创建线程(一般通过libpthread等库函数调用实现),操作系统负责调度线程。当然,我们也说过,这种传统支持并发的方式有很多不足。为了解决这些问题,Go语言中的并发实现,使用了Goroutine,代替了操作系统的线程,也不再依靠操作系统调度。
|
||||
|
||||
Goroutine占用的资源非常小,上节课我们也说过,每个Goroutine栈的大小默认是2KB。而且,Goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个Go程序中可以创建成千上万个并发的Goroutine。而将这些Goroutine按照一定算法放到“CPU”上执行的程序,就被称为Goroutine调度器(Goroutine Scheduler),注意,这里说的“CPU”打了引号。
|
||||
|
||||
不过,一个Go程序对于操作系统来说只是一个用户层程序,操作系统眼中只有线程,它甚至不知道有一种叫Goroutine的事物存在。所以,Goroutine的调度全要靠Go自己完成。那么,实现Go程序内Goroutine之间“公平”竞争“CPU”资源的任务,就落到了Go运行时(runtime)头上了。要知道在一个Go程序中,除了用户层代码,剩下的就是Go运行时了。
|
||||
|
||||
于是,Goroutine的调度问题就演变为,Go运行时如何将程序内的众多Goroutine,按照一定算法调度到“CPU”资源上运行的问题了。
|
||||
|
||||
可是,在操作系统层面,线程竞争的“CPU”资源是真实的物理CPU,但在Go程序层面,各个Goroutine要竞争的“CPU”资源又是什么呢?
|
||||
|
||||
Go程序是用户层程序,它本身就是整体运行在一个或多个操作系统线程上的。所以这个答案就出来了:Goroutine们要竞争的“CPU”资源就是操作系统线程。这样,Goroutine调度器的任务也就明确了:将Goroutine按照一定算法放到不同的操作系统线程中去执行。
|
||||
|
||||
那么,Goroutine调度器究竟是以怎样的算法模型,将Goroutine调度到不同的操作系统线程上去的呢?我们继续向下看。
|
||||
|
||||
Goroutine调度器模型与演化过程
|
||||
|
||||
Goroutine调度器的实现不是一蹴而就的,它的调度模型与算法也是几经演化,从最初的G-M模型、到G-P-M模型,从不支持抢占,到支持协作式抢占,再到支持基于信号的异步抢占,Goroutine调度器经历了不断地优化与打磨。
|
||||
|
||||
首先我们来看最初的G-M模型。
|
||||
|
||||
2012年3月28日,Go 1.0正式发布。在这个版本中,Go开发团队实现了一个简单的Goroutine调度器。在这个调度器中,每个Goroutine对应于运行时中的一个抽象结构:G(Goroutine) ,
|
||||
|
||||
而被视作“物理CPU”的操作系统线程,则被抽象为另外一个结构:M(machine)。
|
||||
|
||||
调度器的工作就是将G调度到M上去运行。为了更好地控制程序中活跃的M的数量,调度器引入了GOMAXPROCS变量来表示Go调度器可见的“处理器”的最大数量。
|
||||
|
||||
这个模型实现起来比较简单,也能正常工作,但是却存在着诸多问题。前英特尔黑带级工程师、现谷歌工程师德米特里·维尤科夫(Dmitry Vyukov)在其《Scalable Go Scheduler Design》一文中指出了G-M模型的一个重要不足:限制了Go并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。
|
||||
|
||||
这个问题主要体现在这几个方面:
|
||||
|
||||
|
||||
单一全局互斥锁(Sched.Lock) 和集中状态存储的存在,导致所有Goroutine相关操作,比如创建、重新调度等,都要上锁;
|
||||
Goroutine传递问题:M经常在M之间传递“可运行”的Goroutine,这导致调度延迟增大,也增加了额外的性能损耗;
|
||||
每个M都做内存缓存,导致内存占用过高,数据局部性较差;
|
||||
由于系统调用(syscall)而形成的频繁的工作线程阻塞和解除阻塞,导致额外的性能损耗。
|
||||
|
||||
|
||||
为了解决这些问题,德米特里·维尤科夫又亲自操刀改进了Go调度器,在Go 1.1版本中实现了G-P-M调度模型和work stealing算法,这个模型一直沿用至今。模型如下图所示:
|
||||
|
||||
|
||||
|
||||
有人说过:“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”,德米特里·维尤科夫的G-P-M模型恰是这一理论的践行者。你可以看到,德米特里·维尤科夫通过向G-M模型中增加了一个P,让Go调度器具有很好的伸缩性。
|
||||
|
||||
P是一个“逻辑Proccessor”,每个G(Goroutine)要想真正运行起来,首先需要被分配一个P,也就是进入到P的本地运行队列(local runq)中。对于G来说,P就是运行它的“CPU”,可以说:在G的眼里只有P。但从Go调度器的视角来看,真正的“CPU”是M,只有将P和M绑定,才能让P的runq中的G真正运行起来。
|
||||
|
||||
G-P-M模型的实现算是Go调度器的一大进步,但调度器仍然有一个令人头疼的问题,那就是不支持抢占式调度,这导致一旦某个G中出现死循环的代码逻辑,那么G将永久占用分配给它的P和M,而位于同一个P中的其他G将得不到调度,出现“饿死”的情况。
|
||||
|
||||
更为严重的是,当只有一个P(GOMAXPROCS=1)时,整个Go程序中的其他G都将“饿死”。于是德米特里·维尤科夫又提出了《Go Preemptive Scheduler Design》并在Go 1.2中实现了基于协作的“抢占式”调度。
|
||||
|
||||
这个抢占式调度的原理就是,Go编译器在每个函数或方法的入口处加上了一段额外的代码(runtime.morestack_noctxt),让运行时有机会在这段代码中检查是否需要执行抢占调度。
|
||||
|
||||
这种解决方案只能说局部解决了“饿死”问题,只在有函数调用的地方才能插入“抢占”代码(埋点),对于没有函数调用而是纯算法循环计算的G,Go调度器依然无法抢占。
|
||||
|
||||
比如,死循环等并没有给编译器插入抢占代码的机会,这就会导致GC在等待所有Goroutine停止时的等待时间过长,从而导致GC延迟,内存占用瞬间冲高;甚至在一些特殊情况下,导致在STW(stop the world)时死锁。
|
||||
|
||||
为了解决这些问题,Go在1.14版本中接受了奥斯汀·克莱门茨(Austin Clements)的提案,增加了对非协作的抢占式调度的支持,这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的Goroutine。
|
||||
|
||||
除了这些大的迭代外,Goroutine的调度器还有一些小的优化改动,比如通过文件I/O poller减少M的阻塞等。
|
||||
|
||||
Go运行时已经实现了netpoller,这使得即便G发起网络I/O操作,也不会导致M被阻塞(仅阻塞G),也就不会导致大量线程(M)被创建出来。
|
||||
|
||||
但是对于文件I/O操作来说,一旦阻塞,那么线程(M)将进入挂起状态,等待I/O返回后被唤醒。这种情况下P将与挂起的M分离,再选择一个处于空闲状态(idle)的M。如果此时没有空闲的M,就会新创建一个M(线程),所以,这种情况下,大量I/O操作仍然会导致大量线程被创建。
|
||||
|
||||
为了解决这个问题,Go开发团队的伊恩·兰斯·泰勒(Ian Lance Taylor)在Go 1.9中增加了一个针对文件I/O的Poller的功能,这个功能可以像netpoller那样,在G操作那些支持监听(pollable)的文件描述符时,仅会阻塞G,而不会阻塞M。不过这个功能依然不能对常规文件有效,常规文件是不支持监听的(pollable)。但对于Go调度器而言,这也算是一个不小的进步了。
|
||||
|
||||
从Go 1.2以后,Go调度器就一直稳定在G-P-M调度模型上,尽管有各种优化和改进,但也都是基于这个模型之上的。那未来的Go调度器会往哪方面发展呢?德米特里·维尤科夫在2014年9月提出了一个新的设计草案文档:《NUMA‐aware scheduler for Go》,作为对未来Goroutine调度器演进方向的一个提议,不过至今似乎这个提议也没有列入开发计划。
|
||||
|
||||
通过前面对Goroutine调度器演化的分析,你可以看到,目前G-M模型已经废弃,NUMA调度模型尚未实现,那么现在我们要理解如今的Goroutine调度,只需要学习G-P-M模型就可以了,接下来我们就来看看G-P-M模型下Goroutine的调度原理。
|
||||
|
||||
深入G-P-M模型
|
||||
|
||||
Go语言中Goroutine的调度、GC、内存管理等是Go语言原理最复杂、最难懂的地方,随便拿出一个都可以讲上几节课,并且这三方面的内容随着Go版本的演进也在不断更新。因为我们是入门课,所以这里我就只基于Go 1.12.7版本(支持基于协作的抢占式调度)给你粗略介绍一下基于G-P-M模型的调度原理,如果你还对这方面感兴趣,可以基于这些介绍深入到相关的Go源码中去,深入挖掘细节。
|
||||
|
||||
G、P和M
|
||||
|
||||
关于G、P、M的定义,我们可以参见$GOROOT/src/runtime/runtime2.go这个源文件。你可以看到,G、P、M这三个结构体定义都是大块头,每个结构体定义都包含十几个甚至二三十个字段。更不用说,像调度器这样的核心代码向来很复杂,考虑的因素也非常多,代码“耦合”成一坨。不过从复杂的代码中,我们依然可以看出来G、P、M的各自的大致用途,我们这里简要说明一下:
|
||||
|
||||
|
||||
G: 代表Goroutine,存储了Goroutine的执行栈信息、Goroutine状态以及Goroutine的任务函数等,而且G对象是可以重用的;
|
||||
P: 代表逻辑processor,P的数量决定了系统内最大可并行的G的数量,P的最大作用还是其拥有的各种G对象队列、链表、一些缓存和状态;
|
||||
M: M代表着真正的执行计算资源。在绑定有效的P后,进入一个调度循环,而调度循环的机制大致是从P的本地运行队列以及全局队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M,如此反复。M并不保留G状态,这是G可以跨M调度的基础。
|
||||
|
||||
|
||||
我这里也给出了G、P、M定义的代码片段(注意:我们这里使用的是Go 1.12.7版本,随着Go演化,结构体中的字段定义可能会有不同),你也可以看一看:
|
||||
|
||||
//src/runtime/runtime2.go
|
||||
type g struct {
|
||||
stack stack // offset known to runtime/cgo
|
||||
sched gobuf
|
||||
goid int64
|
||||
gopc uintptr // pc of go statement that created this goroutine
|
||||
startpc uintptr // pc of goroutine function
|
||||
... ...
|
||||
}
|
||||
|
||||
type p struct {
|
||||
lock mutex
|
||||
|
||||
id int32
|
||||
status uint32 // one of pidle/prunning/...
|
||||
|
||||
mcache *mcache
|
||||
racectx uintptr
|
||||
|
||||
// Queue of runnable goroutines. Accessed without lock.
|
||||
runqhead uint32
|
||||
runqtail uint32
|
||||
runq [256]guintptr
|
||||
|
||||
runnext guintptr
|
||||
|
||||
// Available G's (status == Gdead)
|
||||
gfree *g
|
||||
gfreecnt int32
|
||||
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
|
||||
type m struct {
|
||||
g0 *g // goroutine with scheduling stack
|
||||
mstartfn func()
|
||||
curg *g // current running goroutine
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
而Goroutine调度器的目标,就是公平合理地将各个G调度到P上“运行”,下面我们重点看看G是如何被调度的。
|
||||
|
||||
G被抢占调度
|
||||
|
||||
我们先来说常规情况,也就是如果某个G没有进行系统调用(syscall)、没有进行I/O操作、没有阻塞在一个channel操作上,调度器是如何让G停下来并调度下一个可运行的G的呢?
|
||||
|
||||
答案就是:G是被抢占调度的。
|
||||
|
||||
前面说过,除非极端的无限循环,否则只要G调用函数,Go运行时就有了抢占G的机会。Go程序启动时,运行时会去启动一个名为sysmon的M(一般称为监控线程),这个M的特殊之处在于它不需要绑定P就可以运行(以g0这个G的形式),这个M在整个Go程序的运行过程中至关重要,你可以看下我对sysmon被创建的部分代码以及sysmon的执行逻辑摘录:
|
||||
|
||||
//$GOROOT/src/runtime/proc.go
|
||||
|
||||
// The main goroutine.
|
||||
func main() {
|
||||
... ...
|
||||
systemstack(func() {
|
||||
newm(sysmon, nil)
|
||||
})
|
||||
.... ...
|
||||
}
|
||||
|
||||
// Always runs without a P, so write barriers are not allowed.
|
||||
//
|
||||
//go:nowritebarrierrec
|
||||
func sysmon() {
|
||||
// If a heap span goes unused for 5 minutes after a garbage collection,
|
||||
// we hand it back to the operating system.
|
||||
scavengelimit := int64(5 * 60 * 1e9)
|
||||
... ...
|
||||
|
||||
if .... {
|
||||
... ...
|
||||
// retake P's blocked in syscalls
|
||||
// and preempt long running G's
|
||||
if retake(now) != 0 {
|
||||
idle = 0
|
||||
} else {
|
||||
idle++
|
||||
}
|
||||
... ...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们看到,sysmon每20us~10ms启动一次,sysmon主要完成了这些工作:
|
||||
|
||||
|
||||
释放闲置超过5分钟的span内存;
|
||||
如果超过2分钟没有垃圾回收,强制执行;
|
||||
将长时间未处理的netpoll结果添加到任务队列;
|
||||
向长时间运行的G任务发出抢占调度;
|
||||
收回因syscall长时间阻塞的P;
|
||||
|
||||
|
||||
我们看到sysmon将“向长时间运行的G任务发出抢占调度”,这个事情由函数retake实施:
|
||||
|
||||
// $GOROOT/src/runtime/proc.go
|
||||
|
||||
// forcePreemptNS is the time slice given to a G before it is
|
||||
// preempted.
|
||||
const forcePreemptNS = 10 * 1000 * 1000 // 10ms
|
||||
|
||||
func retake(now int64) uint32 {
|
||||
... ...
|
||||
// Preempt G if it's running for too long.
|
||||
t := int64(_p_.schedtick)
|
||||
if int64(pd.schedtick) != t {
|
||||
pd.schedtick = uint32(t)
|
||||
pd.schedwhen = now
|
||||
continue
|
||||
}
|
||||
if pd.schedwhen+forcePreemptNS > now {
|
||||
continue
|
||||
}
|
||||
preemptone(_p_)
|
||||
... ...
|
||||
}
|
||||
|
||||
func preemptone(_p_ *p) bool {
|
||||
mp := _p_.m.ptr()
|
||||
if mp == nil || mp == getg().m {
|
||||
return false
|
||||
}
|
||||
gp := mp.curg
|
||||
if gp == nil || gp == mp.g0 {
|
||||
return false
|
||||
}
|
||||
|
||||
gp.preempt = true //设置被抢占标志
|
||||
|
||||
// Every call in a go routine checks for stack overflow by
|
||||
// comparing the current stack pointer to gp->stackguard0.
|
||||
// Setting gp->stackguard0 to StackPreempt folds
|
||||
// preemption into the normal stack overflow check.
|
||||
gp.stackguard0 = stackPreempt
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
从上面的代码中,我们可以看出,如果一个G任务运行10ms,sysmon就会认为它的运行时间太久而发出抢占式调度的请求。一旦G的抢占标志位被设为true,那么等到这个G下一次调用函数或方法时,运行时就可以将G抢占并移出运行状态,放入队列中,等待下一次被调度。
|
||||
|
||||
不过,除了这个常规调度之外,还有两个特殊情况下G的调度方法。
|
||||
|
||||
第一种:channel阻塞或网络I/O情况下的调度。
|
||||
|
||||
如果G被阻塞在某个channel操作或网络I/O操作上时,G会被放置到某个等待(wait)队列中,而M会尝试运行P的下一个可运行的G。如果这个时候P没有可运行的G供M运行,那么M将解绑P,并进入挂起状态。当I/O操作完成或channel操作完成,在等待队列中的G会被唤醒,标记为可运行(runnable),并被放入到某P的队列中,绑定一个M后继续执行。
|
||||
|
||||
第二种:系统调用阻塞情况下的调度。
|
||||
|
||||
如果G被阻塞在某个系统调用(system call)上,那么不光G会阻塞,执行这个G的M也会解绑P,与G一起进入挂起状态。如果此时有空闲的M,那么P就会和它绑定,并继续执行其他G;如果没有空闲的M,但仍然有其他G要去执行,那么Go运行时就会创建一个新M(线程)。
|
||||
|
||||
当系统调用返回后,阻塞在这个系统调用上的G会尝试获取一个可用的P,如果没有可用的P,那么G会被标记为runnable,之前的那个挂起的M将再次进入挂起状态。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
基于Goroutine的并发设计离不开一个高效的生产级调度器。Goroutine调度器演进了十余年,先后经历了G-M模型、G-P-M模型和work stealing算法、协作式的抢占调度以及基于信号的异步抢占等改进与优化,目前Goroutine调度器相对稳定和成熟,可以适合绝大部分生产场合。
|
||||
|
||||
现在的G-P-M模型和最初的G-M模型相比,通过向G-M模型中增加了一个代表逻辑处理器的P,使得Goroutine调度器具有了更好的伸缩性。
|
||||
|
||||
M是Go代码运行的真实载体,包括Goroutine调度器自身的逻辑也是在M中运行的。
|
||||
|
||||
P在G-P-M模型中占据核心地位,它拥有待调度的G的队列,同时M要想运行G必须绑定一个P。一个G被调度执行的时间不能过长,超过特定长的时间后,G会被设置为可抢占,并在下一次执行函数或方法时被Go运行时移出运行状态。
|
||||
|
||||
如果G被阻塞在某个channel操作或网络I/O操作上时,M可以不被阻塞,这避免了大量创建M导致的开销。但如果G因慢系统调用而阻塞,那么M也会一起阻塞,但在阻塞前会与P解绑,P会尝试与其他M绑定继续运行其他G。但若没有现成的M,Go运行时会建立新的M,这也是系统调用可能导致系统线程数量增加的原因,你一定要注意这一点。
|
||||
|
||||
思考题
|
||||
|
||||
为了让你更好理解Goroutine调度原理,我这里留个思考题。请看下面代码:
|
||||
|
||||
func deadloop() {
|
||||
for {
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
go deadloop()
|
||||
for {
|
||||
time.Sleep(time.Second * 1)
|
||||
fmt.Println("I got scheduled!")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我的问题是:
|
||||
|
||||
|
||||
在一个拥有多核处理器的主机上,使用Go 1.13.x版本运行这个示例代码,你在命令行终端上是否能看到“I got scheduled!”输出呢?也就是main goroutine在创建deadloop goroutine之后是否能继续得到调度呢?
|
||||
|
||||
我们通过什么方法可以让上面示例中的main goroutine,在创建deadloop goroutine之后无法继续得到调度?
|
||||
|
||||
|
||||
欢迎你把这节课分享给更多对Gouroutine调度原理感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
950
专栏/TonyBai·Go语言第一课/33并发:小channel中蕴含大智慧.md
Normal file
950
专栏/TonyBai·Go语言第一课/33并发:小channel中蕴含大智慧.md
Normal file
@@ -0,0 +1,950 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
33 并发:小channel中蕴含大智慧
|
||||
你好,我是Tony Bai。
|
||||
|
||||
通过上两节课的学习,我们知道了Go语言实现了基于CSP(Communicating Sequential Processes)理论的并发方案。
|
||||
|
||||
Go语言的CSP模型的实现包含两个主要组成部分:一个是Goroutine,它是Go应用并发设计的基本构建与执行单元;另一个就是channel,它在并发模型中扮演着重要的角色。channel既可以用来实现Goroutine间的通信,还可以实现Goroutine间的同步。它就好比Go并发设计这门“武功”的秘籍口诀,可以说,学会在Go并发设计时灵活运用channel,才能说真正掌握了Go并发设计的真谛。
|
||||
|
||||
所以,在这一讲中,我们就来系统学习channel这一并发原语的基础语法与常见使用方法。
|
||||
|
||||
作为一等公民的channel
|
||||
|
||||
Go对并发的原生支持可不是仅仅停留在口号上的,Go在语法层面将并发原语channel作为一等公民对待。在前面的第21讲中我们已经学过“一等公民”这个概念了,如果你记不太清了可以回去复习一下。
|
||||
|
||||
那channel作为一等公民意味着什么呢?
|
||||
|
||||
这意味着我们可以像使用普通变量那样使用channel,比如,定义channel类型变量、给channel变量赋值、将channel作为参数传递给函数/方法、将channel作为返回值从函数/方法中返回,甚至将channel发送到其他channel中。这就大大简化了channel原语的使用,提升了我们开发者在做并发设计和实现时的体验。
|
||||
|
||||
创建channel
|
||||
|
||||
和切片、结构体、map等一样,channel也是一种复合数据类型。也就是说,我们在声明一个channel类型变量时,必须给出其具体的元素类型,比如下面的代码这样:
|
||||
|
||||
var ch chan int
|
||||
|
||||
|
||||
这句代码里,我们声明了一个元素为int类型的channel类型变量ch。
|
||||
|
||||
如果channel类型变量在声明时没有被赋予初值,那么它的默认值为nil。并且,和其他复合数据类型支持使用复合类型字面值作为变量初始值不同,为channel类型变量赋初值的唯一方法就是使用make这个Go预定义的函数,比如下面代码:
|
||||
|
||||
ch1 := make(chan int)
|
||||
ch2 := make(chan int, 5)
|
||||
|
||||
|
||||
这里,我们声明了两个元素类型为int的channel类型变量ch1和ch2,并给这两个变量赋了初值。但我们看到,两个变量的赋初值操作使用的make调用的形式有所不同。
|
||||
|
||||
第一行我们通过make(chan T)创建的、元素类型为T的channel类型,是无缓冲channel,而第二行中通过带有capacity参数的make(chan T, capacity)创建的元素类型为T、缓冲区长度为capacity的channel类型,是带缓冲channel。
|
||||
|
||||
这两种类型的变量关于发送(send)与接收(receive)的特性是不同的,我们接下来就基于这两种类型的channel,看看channel类型变量如何进行发送和接收数据元素。
|
||||
|
||||
发送与接收
|
||||
|
||||
Go提供了<-操作符用于对channel类型变量进行发送与接收操作:
|
||||
|
||||
ch1 <- 13 // 将整型字面值13发送到无缓冲channel类型变量ch1中
|
||||
n := <- ch1 // 从无缓冲channel类型变量ch1中接收一个整型值存储到整型变量n中
|
||||
ch2 <- 17 // 将整型字面值17发送到带缓冲channel类型变量ch2中
|
||||
m := <- ch2 // 从带缓冲channel类型变量ch2中接收一个整型值存储到整型变量m中
|
||||
|
||||
|
||||
这里我要提醒你一句,在理解channel的发送与接收操作时,你一定要始终牢记:channel是用于Goroutine间通信的,所以绝大多数对channel的读写都被分别放在了不同的Goroutine中。
|
||||
|
||||
现在,我们先来看看无缓冲channel类型变量(如ch1)的发送与接收。
|
||||
|
||||
由于无缓冲channel的运行时层实现不带有缓冲区,所以Goroutine对无缓冲channel的接收和发送操作是同步的。也就是说,对同一个无缓冲channel,只有对它进行接收操作的Goroutine和对它进行发送操作的Goroutine都存在的情况下,通信才能得以进行,否则单方面的操作会让对应的Goroutine陷入挂起状态,比如下面示例代码:
|
||||
|
||||
func main() {
|
||||
ch1 := make(chan int)
|
||||
ch1 <- 13 // fatal error: all goroutines are asleep - deadlock!
|
||||
n := <-ch1
|
||||
println(n)
|
||||
}
|
||||
|
||||
|
||||
在这个示例中,我们创建了一个无缓冲的channel类型变量ch1,对ch1的读写都放在了一个Goroutine中。
|
||||
|
||||
运行这个示例,我们就会得到fatal error,提示我们所有Goroutine都处于休眠状态,程序处于死锁状态。要想解除这种错误状态,我们只需要将接收操作,或者发送操作放到另外一个Goroutine中就可以了,比如下面代码:
|
||||
|
||||
func main() {
|
||||
ch1 := make(chan int)
|
||||
go func() {
|
||||
ch1 <- 13 // 将发送操作放入一个新goroutine中执行
|
||||
}()
|
||||
n := <-ch1
|
||||
println(n)
|
||||
}
|
||||
|
||||
|
||||
由此,我们可以得出结论:对无缓冲channel类型的发送与接收操作,一定要放在两个不同的Goroutine中进行,否则会导致deadlock。
|
||||
|
||||
接下来,我们再来看看带缓冲channel的发送与接收操作。
|
||||
|
||||
和无缓冲channel相反,带缓冲channel的运行时层实现带有缓冲区,因此,对带缓冲channel的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接收不需要阻塞等待)。
|
||||
|
||||
也就是说,对一个带缓冲channel来说,在缓冲区未满的情况下,对它进行发送操作的Goroutine并不会阻塞挂起;在缓冲区有数据的情况下,对它进行接收操作的Goroutine也不会阻塞挂起。
|
||||
|
||||
但当缓冲区满了的情况下,对它进行发送操作的Goroutine就会阻塞挂起;当缓冲区为空的情况下,对它进行接收操作的Goroutine也会阻塞挂起。
|
||||
|
||||
如果光看文字还不是很好理解,你可以再看看下面几个关于带缓冲channel的操作的例子:
|
||||
|
||||
ch2 := make(chan int, 1)
|
||||
n := <-ch2 // 由于此时ch2的缓冲区中无数据,因此对其进行接收操作将导致goroutine挂起
|
||||
|
||||
ch3 := make(chan int, 1)
|
||||
ch3 <- 17 // 向ch3发送一个整型数17
|
||||
ch3 <- 27 // 由于此时ch3中缓冲区已满,再向ch3发送数据也将导致goroutine挂起
|
||||
|
||||
|
||||
也正是因为带缓冲channel与无缓冲channel在发送与接收行为上的差异,在具体使用上,它们有各自的“用武之地”,这个我们等会再细说,现在我们先继续把channel的基本语法讲完。
|
||||
|
||||
使用操作符<-,我们还可以声明只发送channel类型(send-only)和只接收channel类型(recv-only),我们接着看下面这个例子:
|
||||
|
||||
ch1 := make(chan<- int, 1) // 只发送channel类型
|
||||
ch2 := make(<-chan int, 1) // 只接收channel类型
|
||||
|
||||
<-ch1 // invalid operation: <-ch1 (receive from send-only type chan<- int)
|
||||
ch2 <- 13 // invalid operation: ch2 <- 13 (send to receive-only type <-chan int)
|
||||
|
||||
|
||||
你可以从这个例子中看到,试图从一个只发送channel类型变量中接收数据,或者向一个只接收channel类型发送数据,都会导致编译错误。通常只发送channel类型和只接收channel类型,会被用作函数的参数类型或返回值,用于限制对channel内的操作,或者是明确可对channel进行的操作的类型,比如下面这个例子:
|
||||
|
||||
func produce(ch chan<- int) {
|
||||
for i := 0; i < 10; i++ {
|
||||
ch <- i + 1
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func consume(ch <-chan int) {
|
||||
for n := range ch {
|
||||
println(n)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
ch := make(chan int, 5)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
produce(ch)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
consume(ch)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们启动了两个Goroutine,分别代表生产者(produce)与消费者(consume)。生产者只能向channel中发送数据,我们使用chan<- int作为produce函数的参数类型;消费者只能从channel中接收数据,我们使用<-chan int作为consume函数的参数类型。
|
||||
|
||||
在消费者函数consume中,我们使用了for range循环语句来从channel中接收数据,for range会阻塞在对channel的接收操作上,直到channel中有数据可接收或channel被关闭循环,才会继续向下执行。channel被关闭后,for range循环也就结束了。
|
||||
|
||||
关闭channel
|
||||
|
||||
在上面的例子中,produce函数在发送完数据后,调用Go内置的close函数关闭了channel。channel关闭后,所有等待从这个channel接收数据的操作都将返回。
|
||||
|
||||
这里我们继续看一下采用不同接收语法形式的语句,在channel被关闭后的返回值的情况:
|
||||
|
||||
n := <- ch // 当ch被关闭后,n将被赋值为ch元素类型的零值
|
||||
m, ok := <-ch // 当ch被关闭后,m将被赋值为ch元素类型的零值, ok值为false
|
||||
for v := range ch { // 当ch被关闭后,for range循环结束
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
我们看到,通过“comma, ok”惯用法或for range语句,我们可以准确地判定channel是否被关闭。而单纯采用n := <-ch形式的语句,我们就无法判定从ch返回的元素类型零值,究竟是不是因为channel被关闭后才返回的。
|
||||
|
||||
另外,从前面produce的示例程序中,我们也可以看到,channel是在produce函数中被关闭的,这也是channel的一个使用惯例,那就是发送端负责关闭channel。
|
||||
|
||||
这里为什么要在发送端关闭channel呢?
|
||||
|
||||
这是因为发送端没有像接受端那样的、可以安全判断channel是否被关闭了的方法。同时,一旦向一个已经关闭的channel执行发送操作,这个操作就会引发panic,比如下面这个示例:
|
||||
|
||||
ch := make(chan int, 5)
|
||||
close(ch)
|
||||
ch <- 13 // panic: send on closed channel
|
||||
|
||||
|
||||
select
|
||||
|
||||
当涉及同时对多个channel进行操作时,我们会结合Go为CSP并发模型提供的另外一个原语select,一起使用。
|
||||
|
||||
通过select,我们可以同时在多个channel上进行发送/接收操作:
|
||||
|
||||
select {
|
||||
case x := <-ch1: // 从channel ch1接收数据
|
||||
... ...
|
||||
|
||||
case y, ok := <-ch2: // 从channel ch2接收数据,并根据ok值判断ch2是否已经关闭
|
||||
... ...
|
||||
|
||||
case ch3 <- z: // 将z值发送到channel ch3中:
|
||||
... ...
|
||||
|
||||
default: // 当上面case中的channel通信均无法实施时,执行该默认分支
|
||||
}
|
||||
|
||||
|
||||
当select语句中没有default分支,而且所有case中的channel操作都阻塞了的时候,整个select语句都将被阻塞,直到某一个case上的channel变成可发送,或者某个case上的channel变成可接收,select语句才可以继续进行下去。关于select语句的妙用,我们在后面还会细讲,这里我们先简单了解它的基本语法。
|
||||
|
||||
看到这里你应该能感受到,channel和select两种原语的操作都十分简单,它们都遵循了Go语言“追求简单”的设计哲学,但它们却为Go并发程序带来了强大的表达能力。学习了这些基础用法后,接下来我们再深一层,看看Go并发原语channel的一些惯用法。同样地,这里我们也分成无缓冲channel和带缓冲channel两种情况来分析。
|
||||
|
||||
无缓冲channel的惯用法
|
||||
|
||||
无缓冲channel兼具通信和同步特性,在并发程序中应用颇为广泛。现在我们来看看几个无缓冲channel的典型应用:
|
||||
|
||||
第一种用法:用作信号传递
|
||||
|
||||
无缓冲channel用作信号传递的时候,有两种情况,分别是1对1通知信号和1对n通知信号。我们先来分析下1对1通知信号这种情况。
|
||||
|
||||
我们直接来看具体的例子:
|
||||
|
||||
type signal struct{}
|
||||
|
||||
func worker() {
|
||||
println("worker is working...")
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
func spawn(f func()) <-chan signal {
|
||||
c := make(chan signal)
|
||||
go func() {
|
||||
println("worker start to work...")
|
||||
f()
|
||||
c <- signal{}
|
||||
}()
|
||||
return c
|
||||
}
|
||||
|
||||
func main() {
|
||||
println("start a worker...")
|
||||
c := spawn(worker)
|
||||
<-c
|
||||
fmt.Println("worker work done!")
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,spawn函数返回的channel,被用于承载新Goroutine退出的“通知信号”,这个信号专门用作通知main goroutine。main goroutine在调用spawn函数后一直阻塞在对这个“通知信号”的接收动作上。
|
||||
|
||||
我们来运行一下这个例子:
|
||||
|
||||
start a worker...
|
||||
worker start to work...
|
||||
worker is working...
|
||||
worker work done!
|
||||
|
||||
|
||||
有些时候,无缓冲channel还被用来实现1对n的信号通知机制。这样的信号通知机制,常被用于协调多个Goroutine一起工作,比如下面的例子:
|
||||
|
||||
func worker(i int) {
|
||||
fmt.Printf("worker %d: is working...\n", i)
|
||||
time.Sleep(1 * time.Second)
|
||||
fmt.Printf("worker %d: works done\n", i)
|
||||
}
|
||||
|
||||
type signal struct{}
|
||||
func spawnGroup(f func(i int), num int, groupSignal <-chan signal) <-chan signal {
|
||||
c := make(chan signal)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < num; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
<-groupSignal
|
||||
fmt.Printf("worker %d: start to work...\n", i)
|
||||
f(i)
|
||||
wg.Done()
|
||||
}(i + 1)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
c <- signal{}
|
||||
}()
|
||||
return c
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("start a group of workers...")
|
||||
groupSignal := make(chan signal)
|
||||
c := spawnGroup(worker, 5, groupSignal)
|
||||
time.Sleep(5 * time.Second)
|
||||
fmt.Println("the group of workers start to work...")
|
||||
close(groupSignal)
|
||||
<-c
|
||||
fmt.Println("the group of workers work done!")
|
||||
}
|
||||
|
||||
|
||||
这个例子中,main goroutine创建了一组5个worker goroutine,这些Goroutine启动后会阻塞在名为groupSignal的无缓冲channel上。main goroutine通过close(groupSignal)向所有worker goroutine广播“开始工作”的信号,收到groupSignal后,所有worker goroutine会“同时”开始工作,就像起跑线上的运动员听到了裁判员发出的起跑信号枪声。
|
||||
|
||||
这个例子的运行结果如下:
|
||||
|
||||
start a group of workers...
|
||||
the group of workers start to work...
|
||||
worker 3: start to work...
|
||||
worker 3: is working...
|
||||
worker 4: start to work...
|
||||
worker 4: is working...
|
||||
worker 1: start to work...
|
||||
worker 1: is working...
|
||||
worker 5: start to work...
|
||||
worker 5: is working...
|
||||
worker 2: start to work...
|
||||
worker 2: is working...
|
||||
worker 3: works done
|
||||
worker 4: works done
|
||||
worker 5: works done
|
||||
worker 1: works done
|
||||
worker 2: works done
|
||||
the group of workers work done!
|
||||
|
||||
|
||||
我们可以看到,关闭一个无缓冲channel会让所有阻塞在这个channel上的接收操作返回,从而实现了一种1对n的“广播”机制。
|
||||
|
||||
第二种用法:用于替代锁机制
|
||||
|
||||
无缓冲channel具有同步特性,这让它在某些场合可以替代锁,让我们的程序更加清晰,可读性也更好。我们可以对比下两个方案,直观地感受一下。
|
||||
|
||||
首先我们看一个传统的、基于“共享内存”+“互斥锁”的Goroutine安全的计数器的实现:
|
||||
|
||||
type counter struct {
|
||||
sync.Mutex
|
||||
i int
|
||||
}
|
||||
|
||||
var cter counter
|
||||
|
||||
func Increase() int {
|
||||
cter.Lock()
|
||||
defer cter.Unlock()
|
||||
cter.i++
|
||||
return cter.i
|
||||
}
|
||||
|
||||
func main() {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
v := Increase()
|
||||
fmt.Printf("goroutine-%d: current counter value is %d\n", i, v)
|
||||
wg.Done()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
在这个示例中,我们使用了一个带有互斥锁保护的全局变量作为计数器,所有要操作计数器的Goroutine共享这个全局变量,并在互斥锁的同步下对计数器进行自增操作。
|
||||
|
||||
接下来我们再看更符合Go设计惯例的实现,也就是使用无缓冲channel替代锁后的实现:
|
||||
|
||||
type counter struct {
|
||||
c chan int
|
||||
i int
|
||||
}
|
||||
|
||||
func NewCounter() *counter {
|
||||
cter := &counter{
|
||||
c: make(chan int),
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
cter.i++
|
||||
cter.c <- cter.i
|
||||
}
|
||||
}()
|
||||
return cter
|
||||
}
|
||||
|
||||
func (cter *counter) Increase() int {
|
||||
return <-cter.c
|
||||
}
|
||||
|
||||
func main() {
|
||||
cter := NewCounter()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
v := cter.Increase()
|
||||
fmt.Printf("goroutine-%d: current counter value is %d\n", i, v)
|
||||
wg.Done()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
在这个实现中,我们将计数器操作全部交给一个独立的Goroutine去处理,并通过无缓冲channel的同步阻塞特性,实现了计数器的控制。这样其他Goroutine通过Increase函数试图增加计数器值的动作,实质上就转化为了一次无缓冲channel的接收动作。
|
||||
|
||||
这种并发设计逻辑更符合Go语言所倡导的“不要通过共享内存来通信,而是通过通信来共享内存”的原则。
|
||||
|
||||
运行这个示例,我们可以得出与互斥锁方案相同的结果:
|
||||
|
||||
goroutine-9: current counter value is 10
|
||||
goroutine-0: current counter value is 1
|
||||
goroutine-6: current counter value is 7
|
||||
goroutine-2: current counter value is 3
|
||||
goroutine-8: current counter value is 9
|
||||
goroutine-4: current counter value is 5
|
||||
goroutine-5: current counter value is 6
|
||||
goroutine-1: current counter value is 2
|
||||
goroutine-7: current counter value is 8
|
||||
goroutine-3: current counter value is 4
|
||||
|
||||
|
||||
带缓冲channel的惯用法
|
||||
|
||||
带缓冲的channel与无缓冲的channel的最大不同之处,就在于它的异步性。也就是说,对一个带缓冲channel,在缓冲区未满的情况下,对它进行发送操作的Goroutine不会阻塞挂起;在缓冲区有数据的情况下,对它进行接收操作的Goroutine也不会阻塞挂起。
|
||||
|
||||
这种特性让带缓冲的channel有着与无缓冲channel不同的应用场合。接下来我们一个个来分析。
|
||||
|
||||
第一种用法:用作消息队列
|
||||
|
||||
channel经常被Go初学者视为在多个Goroutine之间通信的消息队列,这是因为,channel的原生特性与我们认知中的消息队列十分相似,包括Goroutine安全、有FIFO(first-in, first out)保证等。
|
||||
|
||||
其实,和无缓冲channel更多用于信号/事件管道相比,可自行设置容量、异步收发的带缓冲channel更适合被用作为消息队列,并且,带缓冲channel在数据收发的性能上要明显好于无缓冲channel。
|
||||
|
||||
我们可以通过对channel读写的基本测试来印证这一点。下面是一些关于无缓冲channel和带缓冲channel收发性能测试的结果(Go 1.17, MacBook Pro 8核)。基准测试的代码比较多,我就不全部贴出来了,你可以到这里下载。
|
||||
|
||||
|
||||
单接收单发送性能的基准测试-
|
||||
我们先来看看针对一个channel只有一个发送Goroutine和一个接收Goroutine的情况,两种channel的收发性能比对数据:
|
||||
|
||||
|
||||
// 无缓冲channel
|
||||
// go-channel-operation-benchmark/unbuffered-chan
|
||||
|
||||
$go test -bench . one_to_one_test.go
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
|
||||
BenchmarkUnbufferedChan1To1Send-8 6037778 199.7 ns/op
|
||||
BenchmarkUnbufferedChan1To1Recv-8 6286850 194.5 ns/op
|
||||
PASS
|
||||
ok command-line-arguments 2.833s
|
||||
|
||||
// 带缓冲channel
|
||||
// go-channel-operation-benchmark/buffered-chan
|
||||
|
||||
$go test -bench . one_to_one_cap_10_test.go
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
|
||||
BenchmarkBufferedChan1To1SendCap10-8 17089879 66.16 ns/op
|
||||
BenchmarkBufferedChan1To1RecvCap10-8 18043450 65.57 ns/op
|
||||
PASS
|
||||
ok command-line-arguments 2.460s
|
||||
|
||||
|
||||
然后我们将channel的缓存由10改为100,再看看带缓冲channel的1对1基准测试结果:
|
||||
|
||||
$go test -bench . one_to_one_cap_100_test.go
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
|
||||
BenchmarkBufferedChan1To1SendCap100-8 23089318 53.06 ns/op
|
||||
BenchmarkBufferedChan1To1RecvCap100-8 23474095 51.33 ns/op
|
||||
PASS
|
||||
ok command-line-arguments 2.542s
|
||||
|
||||
|
||||
|
||||
多接收多发送性能基准测试-
|
||||
我们再来看看,针对一个channel有多个发送Goroutine和多个接收Goroutine的情况,两种channel的收发性能比对数据(这里建立10个发送Goroutine和10个接收Goroutine):
|
||||
|
||||
|
||||
// 无缓冲channel
|
||||
// go-channel-operation-benchmark/unbuffered-chan
|
||||
|
||||
$go test -bench . multi_to_multi_test.go
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
|
||||
BenchmarkUnbufferedChanNToNSend-8 293930 3779 ns/op
|
||||
BenchmarkUnbufferedChanNToNRecv-8 280904 4190 ns/op
|
||||
PASS
|
||||
ok command-line-arguments 2.387s
|
||||
|
||||
// 带缓冲channel
|
||||
// go-channel-operation-benchmark/buffered-chan
|
||||
|
||||
$go test -bench . multi_to_multi_cap_10_test.go
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
|
||||
BenchmarkBufferedChanNToNSendCap10-8 736540 1609 ns/op
|
||||
BenchmarkBufferedChanNToNRecvCap10-8 795416 1616 ns/op
|
||||
PASS
|
||||
ok command-line-arguments 2.514s
|
||||
|
||||
|
||||
这里我们也将channel的缓存由10改为100后,看看带缓冲channel的多对多基准测试结果:
|
||||
|
||||
$go test -bench . multi_to_multi_cap_100_test.go
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
|
||||
BenchmarkBufferedChanNToNSendCap100-8 1236453 966.4 ns/op
|
||||
BenchmarkBufferedChanNToNRecvCap100-8 1279766 969.4 ns/op
|
||||
PASS
|
||||
ok command-line-arguments 4.309s
|
||||
|
||||
|
||||
综合前面这些结果数据,我们可以得出几个初步结论:
|
||||
|
||||
|
||||
无论是1收1发还是多收多发,带缓冲channel的收发性能都要好于无缓冲channel;
|
||||
对于带缓冲channel而言,发送与接收的Goroutine数量越多,收发性能会有所下降;
|
||||
对于带缓冲channel而言,选择适当容量会在一定程度上提升收发性能。
|
||||
|
||||
|
||||
不过你要注意的是,Go支持channel的初衷是将它作为Goroutine间的通信手段,它并不是专门用于消息队列场景的。如果你的项目需要专业消息队列的功能特性,比如支持优先级、支持权重、支持离线持久化等,那么channel就不合适了,可以使用第三方的专业的消息队列实现。
|
||||
|
||||
第二种用法:用作计数信号量(counting semaphore)
|
||||
|
||||
Go并发设计的一个惯用法,就是将带缓冲channel用作计数信号量(counting semaphore)。带缓冲channel中的当前数据个数代表的是,当前同时处于活动状态(处理业务)的Goroutine的数量,而带缓冲channel的容量(capacity),就代表了允许同时处于活动状态的Goroutine的最大数量。向带缓冲channel的一个发送操作表示获取一个信号量,而从channel的一个接收操作则表示释放一个信号量。
|
||||
|
||||
这里我们来看一个将带缓冲channel用作计数信号量的例子:
|
||||
|
||||
var active = make(chan struct{}, 3)
|
||||
var jobs = make(chan int, 10)
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
for i := 0; i < 8; i++ {
|
||||
jobs <- (i + 1)
|
||||
}
|
||||
close(jobs)
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for j := range jobs {
|
||||
wg.Add(1)
|
||||
go func(j int) {
|
||||
active <- struct{}{}
|
||||
log.Printf("handle job: %d\n", j)
|
||||
time.Sleep(2 * time.Second)
|
||||
<-active
|
||||
wg.Done()
|
||||
}(j)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
我们看到,这个示例创建了一组Goroutine来处理job,同一时间允许最多3个Goroutine处于活动状态。
|
||||
|
||||
为了达成这一目标,我们看到这个示例使用了一个容量(capacity)为3的带缓冲channel: active作为计数信号量,这意味着允许同时处于活动状态的最大Goroutine数量为3。
|
||||
|
||||
我们运行一下这个示例:
|
||||
|
||||
2022/01/02 10:08:55 handle job: 1
|
||||
2022/01/02 10:08:55 handle job: 4
|
||||
2022/01/02 10:08:55 handle job: 8
|
||||
2022/01/02 10:08:57 handle job: 5
|
||||
2022/01/02 10:08:57 handle job: 7
|
||||
2022/01/02 10:08:57 handle job: 6
|
||||
2022/01/02 10:08:59 handle job: 3
|
||||
2022/01/02 10:08:59 handle job: 2
|
||||
|
||||
|
||||
从示例运行结果中的时间戳中,我们可以看到,虽然我们创建了很多Goroutine,但由于计数信号量的存在,同一时间内处于活动状态(正在处理job)的Goroutine的数量最多为3个。
|
||||
|
||||
len(channel)的应用
|
||||
|
||||
len是Go语言的一个内置函数,它支持接收数组、切片、map、字符串和channel类型的参数,并返回对应类型的“长度”,也就是一个整型值。
|
||||
|
||||
针对channel ch的类型不同,len(ch)有如下两种语义:
|
||||
|
||||
|
||||
当ch为无缓冲channel时,len(ch)总是返回0;
|
||||
当ch为带缓冲channel时,len(ch)返回当前channel ch中尚未被读取的元素个数。
|
||||
|
||||
|
||||
这样一来,针对带缓冲channel的len调用似乎才是有意义的。那我们是否可以使用len函数来实现带缓冲channel的“判满”、“判有”和“判空”逻辑呢?就像下面示例中伪代码这样:
|
||||
|
||||
var ch chan T = make(chan T, capacity)
|
||||
|
||||
// 判空
|
||||
if len(ch) == 0 {
|
||||
// 此时channel ch空了?
|
||||
}
|
||||
|
||||
// 判有
|
||||
if len(ch) > 0 {
|
||||
// 此时channel ch中有数据?
|
||||
}
|
||||
|
||||
// 判满
|
||||
if len(ch) == cap(ch) {
|
||||
// 此时channel ch满了?
|
||||
}
|
||||
|
||||
|
||||
你可以看到,我在上面代码注释的“空了”、“有数据”和“满了”的后面都打上了问号。这是为什么呢?
|
||||
|
||||
这是因为,channel原语用于多个Goroutine间的通信,一旦多个Goroutine共同对channel进行收发操作,len(channel)就会在多个Goroutine间形成“竞态”。单纯地依靠len(channel)来判断channel中元素状态,是不能保证在后续对channel的收发时channel状态是不变的。
|
||||
|
||||
我们以判空为例看看:
|
||||
|
||||
|
||||
|
||||
从上图可以看到,Goroutine1使用len(channel)判空后,就会尝试从channel中接收数据。但在它真正从channel读数据之前,另外一个Goroutine2已经将数据读了出去,所以,Goroutine1后面的读取就会阻塞在channel上,导致后面逻辑的失效。
|
||||
|
||||
因此,为了不阻塞在channel上,常见的方法是将“判空与读取”放在一个“事务”中,将“判满与写入”放在一个“事务”中,而这类“事务”我们可以通过select实现。我们来看下面示例:
|
||||
|
||||
func producer(c chan<- int) {
|
||||
var i int = 1
|
||||
for {
|
||||
time.Sleep(2 * time.Second)
|
||||
ok := trySend(c, i)
|
||||
if ok {
|
||||
fmt.Printf("[producer]: send [%d] to channel\n", i)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[producer]: try send [%d], but channel is full\n", i)
|
||||
}
|
||||
}
|
||||
|
||||
func tryRecv(c <-chan int) (int, bool) {
|
||||
select {
|
||||
case i := <-c:
|
||||
return i, true
|
||||
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func trySend(c chan<- int, i int) bool {
|
||||
select {
|
||||
case c <- i:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func consumer(c <-chan int) {
|
||||
for {
|
||||
i, ok := tryRecv(c)
|
||||
if !ok {
|
||||
fmt.Println("[consumer]: try to recv from channel, but the channel is empty")
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[consumer]: recv [%d] from channel\n", i)
|
||||
if i >= 3 {
|
||||
fmt.Println("[consumer]: exit")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var wg sync.WaitGroup
|
||||
c := make(chan int, 3)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
producer(c)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
consumer(c)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
我们看到,由于用到了select原语的default分支语义,当channel空的时候,tryRecv不会阻塞;当channel满的时候,trySend也不会阻塞。
|
||||
|
||||
这个示例的运行结果也证明了这一点,无论是使用tryRecv的consumer还是使用trySend的producer都不会阻塞:
|
||||
|
||||
[consumer]: try to recv from channel, but the channel is empty
|
||||
[consumer]: try to recv from channel, but the channel is empty
|
||||
[producer]: send [1] to channel
|
||||
[consumer]: recv [1] from channel
|
||||
[consumer]: try to recv from channel, but the channel is empty
|
||||
[consumer]: try to recv from channel, but the channel is empty
|
||||
[producer]: send [2] to channel
|
||||
[consumer]: recv [2] from channel
|
||||
[consumer]: try to recv from channel, but the channel is empty
|
||||
[consumer]: try to recv from channel, but the channel is empty
|
||||
[producer]: send [3] to channel
|
||||
[consumer]: recv [3] from channel
|
||||
[consumer]: exit
|
||||
[producer]: send [4] to channel
|
||||
[producer]: send [5] to channel
|
||||
[producer]: send [6] to channel
|
||||
[producer]: try send [7], but channel is full
|
||||
[producer]: try send [7], but channel is full
|
||||
[producer]: try send [7], but channel is full
|
||||
... ...
|
||||
|
||||
|
||||
这种方法适用于大多数场合,但是这种方法有一个“问题”,那就是它改变了channel的状态,会让channel接收了一个元素或发送一个元素到channel。
|
||||
|
||||
有些时候我们不想这么做,我们想在不改变channel状态的前提下,单纯地侦测channel的状态,而又不会因channel满或空阻塞在channel上。但很遗憾,目前没有一种方法可以在实现这样的功能的同时,适用于所有场合。
|
||||
|
||||
但是在特定的场景下,我们可以用len(channel)来实现。比如下面这两种场景:
|
||||
|
||||
|
||||
|
||||
上图中的情景(a)是一个“多发送单接收”的场景,也就是有多个发送者,但有且只有一个接收者。在这样的场景下,我们可以在接收goroutine中使用len(channel)是否大于0来判断是否channel中有数据需要接收。
|
||||
|
||||
而情景(b)呢,是一个“多接收单发送”的场景,也就是有多个接收者,但有且只有一个发送者。在这样的场景下,我们可以在发送Goroutine中使用len(channel)是否小于cap(channel)来判断是否可以执行向channel的发送操作。
|
||||
|
||||
nil channel的妙用
|
||||
|
||||
如果一个channel类型变量的值为nil,我们称它为nil channel。nil channel有一个特性,那就是对nil channel的读写都会发生阻塞。比如下面示例代码:
|
||||
|
||||
func main() {
|
||||
var c chan int
|
||||
<-c //阻塞
|
||||
}
|
||||
|
||||
或者:
|
||||
|
||||
func main() {
|
||||
var c chan int
|
||||
c<-1 //阻塞
|
||||
}
|
||||
|
||||
|
||||
你会看到,无论上面的哪段代码被执行,main goroutine都会阻塞在对nil channel的操作上。
|
||||
|
||||
不过,nil channel的这个特性可不是一无是处,有些时候应用nil channel的这个特性可以得到事半功倍的效果。我们来看一个例子:
|
||||
|
||||
func main() {
|
||||
ch1, ch2 := make(chan int), make(chan int)
|
||||
go func() {
|
||||
time.Sleep(time.Second * 5)
|
||||
ch1 <- 5
|
||||
close(ch1)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
time.Sleep(time.Second * 7)
|
||||
ch2 <- 7
|
||||
close(ch2)
|
||||
}()
|
||||
|
||||
var ok1, ok2 bool
|
||||
for {
|
||||
select {
|
||||
case x := <-ch1:
|
||||
ok1 = true
|
||||
fmt.Println(x)
|
||||
case x := <-ch2:
|
||||
ok2 = true
|
||||
fmt.Println(x)
|
||||
}
|
||||
|
||||
if ok1 && ok2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Println("program end")
|
||||
}
|
||||
|
||||
|
||||
在这个示例中,我们期望程序在接收完ch1和ch2两个channel上的数据后就退出。但实际的运行情况却是这样的:
|
||||
|
||||
5
|
||||
0
|
||||
0
|
||||
0
|
||||
... ... //循环输出0
|
||||
7
|
||||
program end
|
||||
|
||||
|
||||
我们原本期望上面这个在依次输出5和7两个数字后退出,但实际运行的输出结果却是在输出5之后,程序输出了许多的0值,之后才输出7并退出。
|
||||
|
||||
这是怎么回事呢?我们简单分析一下这段代码的运行过程:
|
||||
|
||||
|
||||
前5s,select一直处于阻塞状态;
|
||||
第5s,ch1返回一个5后被close,select语句的case x := <-ch1这个分支被选出执行,程序输出5,并回到for循环并重新select;
|
||||
由于ch1被关闭,从一个已关闭的channel接收数据将永远不会被阻塞,于是新一轮select又把case x := <-ch1这个分支选出并执行。由于ch1处于关闭状态,从这个channel获取数据,我们会得到这个channel对应类型的零值,这里就是0。于是程序再次输出0;程序按这个逻辑循环执行,一直输出0值;
|
||||
2s后,ch2被写入了一个数值7。这样在某一轮select的过程中,分支case x := <-ch2被选中得以执行,程序输出7之后满足退出条件,于是程序终止。
|
||||
|
||||
|
||||
那我们可以怎么改进一下这个程序,让它能按照我们的预期输出呢?
|
||||
|
||||
是时候让nil channel登场了!用nil channel改进后的示例代码是这样的:
|
||||
|
||||
func main() {
|
||||
ch1, ch2 := make(chan int), make(chan int)
|
||||
go func() {
|
||||
time.Sleep(time.Second * 5)
|
||||
ch1 <- 5
|
||||
close(ch1)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
time.Sleep(time.Second * 7)
|
||||
ch2 <- 7
|
||||
close(ch2)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case x, ok := <-ch1:
|
||||
if !ok {
|
||||
ch1 = nil
|
||||
} else {
|
||||
fmt.Println(x)
|
||||
}
|
||||
case x, ok := <-ch2:
|
||||
if !ok {
|
||||
ch2 = nil
|
||||
} else {
|
||||
fmt.Println(x)
|
||||
}
|
||||
}
|
||||
if ch1 == nil && ch2 == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Println("program end")
|
||||
}
|
||||
|
||||
|
||||
这里,改进后的示例程序的最关键的一个变化,就是在判断ch1或ch2被关闭后,显式地将ch1或ch2置为nil。
|
||||
|
||||
而我们前面已经知道了,对一个nil channel执行获取操作,这个操作将阻塞。于是,这里已经被置为nil的c1或c2的分支,将再也不会被select选中执行。
|
||||
|
||||
改进后的示例的运行结果如下,与我们预期相符:
|
||||
|
||||
5
|
||||
7
|
||||
program end
|
||||
|
||||
|
||||
与select结合使用的一些惯用法
|
||||
|
||||
channel和select的结合使用能形成强大的表达能力,我们在前面的例子中已经或多或少见识过了。这里我再强调几种channel与select结合的惯用法。
|
||||
|
||||
第一种用法:利用default分支避免阻塞
|
||||
|
||||
select语句的default分支的语义,就是在其他非default分支因通信未就绪,而无法被选择的时候执行的,这就给default分支赋予了一种“避免阻塞”的特性。
|
||||
|
||||
其实在前面的“len(channel)的应用”小节的例子中,我们就已经用到了“利用default分支”实现的trySend和tryRecv两个函数:
|
||||
|
||||
func tryRecv(c <-chan int) (int, bool) {
|
||||
select {
|
||||
case i := <-c:
|
||||
return i, true
|
||||
|
||||
default: // channel为空
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func trySend(c chan<- int, i int) bool {
|
||||
select {
|
||||
case c <- i:
|
||||
return true
|
||||
default: // channel满了
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
而且,无论是无缓冲channel还是带缓冲channel,这两个函数都能适用,并且不会阻塞在空channel或元素个数已经达到容量的channel上。
|
||||
|
||||
在Go标准库中,这个惯用法也有应用,比如:
|
||||
|
||||
// $GOROOT/src/time/sleep.go
|
||||
func sendTime(c interface{}, seq uintptr) {
|
||||
// 无阻塞的向c发送当前时间
|
||||
select {
|
||||
case c.(chan Time) <- Now():
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
第二种用法:实现超时机制
|
||||
|
||||
带超时机制的select,是Go中常见的一种select和channel的组合用法。通过超时事件,我们既可以避免长期陷入某种操作的等待中,也可以做一些异常处理工作。
|
||||
|
||||
比如,下面示例代码实现了一次具有30s超时的select:
|
||||
|
||||
func worker() {
|
||||
select {
|
||||
case <-c:
|
||||
// ... do some stuff
|
||||
case <-time.After(30 *time.Second):
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
不过,在应用带有超时机制的select时,我们要特别注意timer使用后的释放,尤其在大量创建timer的时候。
|
||||
|
||||
Go语言标准库提供的timer实际上是由Go运行时自行维护的,而不是操作系统级的定时器资源,它的使用代价要比操作系统级的低许多。但即便如此,作为time.Timer的使用者,我们也要尽量减少在使用Timer时给Go运行时和Go垃圾回收带来的压力,要及时调用timer的Stop方法回收Timer资源。
|
||||
|
||||
第三种用法:实现心跳机制
|
||||
|
||||
结合time包的Ticker,我们可以实现带有心跳机制的select。这种机制让我们可以在监听channel的同时,执行一些周期性的任务,比如下面这段代码:
|
||||
|
||||
func worker() {
|
||||
heartbeat := time.NewTicker(30 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c:
|
||||
// ... do some stuff
|
||||
case <- heartbeat.C:
|
||||
//... do heartbeat stuff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里我们使用time.NewTicker,创建了一个Ticker类型实例heartbeat。这个实例包含一个channel类型的字段C,这个字段会按一定时间间隔持续产生事件,就像“心跳”一样。这样for循环在channel c无数据接收时,会每隔特定时间完成一次迭代,然后回到for循环进行下一次迭代。
|
||||
|
||||
和timer一样,我们在使用完ticker之后,也不要忘记调用它的Stop方法,避免心跳事件在ticker的channel(上面示例中的heartbeat.C)中持续产生。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们系统学习了Go CSP并发方案中除Goroutine之外的另一个重要组成部分:channel。Go为了原生支持并发,把channel视作一等公民身份,这就大幅提升了开发人员使用channel进行并发设计和实现的体验。
|
||||
|
||||
通过预定义函数make,我们可以创建两类channel:无缓冲channel与带缓冲的channel。这两类channel具有不同的收发特性,可以适用于不同的应用场合:无缓冲channel兼具通信与同步特性,常用于作为信号通知或替代同步锁;而带缓冲channel的异步性,让它更适合用来实现基于内存的消息队列、计数信号量等。
|
||||
|
||||
此外,你也要牢记值为nil的channel的阻塞特性,有些时候它也能帮上大忙。而面对已关闭的channel你也一定要小心,尤其要避免向已关闭的channel发送数据,那会导致panic。
|
||||
|
||||
最后,select是Go为了支持同时操作多个channel,而引入的另外一个并发原语,select与channel有几种常用的固定搭配,你也要好好掌握和理解。
|
||||
|
||||
思考题
|
||||
|
||||
channel作为Go并发设计的重要组成部分,需要你掌握的细节非常多。而且,channel的应用模式也非常多,我们这一讲仅挑了几个常见的模式做了讲解。在日常开发中你还见过哪些实用的channel使用模式呢?欢迎在留言区分享。
|
||||
|
||||
如果你觉得有收获,也欢迎你把这节课分享给更多对Go并发感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
650
专栏/TonyBai·Go语言第一课/34并发:如何使用共享变量?.md
Normal file
650
专栏/TonyBai·Go语言第一课/34并发:如何使用共享变量?.md
Normal file
@@ -0,0 +1,650 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
34 并发:如何使用共享变量?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在前面的讲解中,我们学习了Go的并发实现方案,知道了Go基于Tony Hoare的CSP并发模型理论,实现了Goroutine、channel等并发原语。
|
||||
|
||||
并且,Go语言之父Rob Pike还有一句经典名言:“不要通过共享内存来通信,应该通过通信来共享内存(Don’t communicate by sharing memory, share memory by communicating)”,这就奠定了Go应用并发设计的主流风格:使用channel进行不同Goroutine间的通信。
|
||||
|
||||
不过,Go也并没有彻底放弃基于共享内存的并发模型,而是在提供CSP并发模型原语的同时,还通过标准库的sync包,提供了针对传统的、基于共享内存并发模型的低级同步原语,包括:互斥锁(sync.Mutex)、读写锁(sync.RWMutex)、条件变量(sync.Cond)等,并通过atomic包提供了原子操作原语等等。显然,基于共享内存的并发模型在Go语言中依然有它的“用武之地”。
|
||||
|
||||
所以,在并发的最后一讲,我们就围绕sync包中的几个同步结构与对应的方法,聊聊基于共享内存的并发模型在Go中的应用。
|
||||
|
||||
我们先来看看在哪些场景下,我们需要用到sync包提供的低级同步原语。
|
||||
|
||||
sync包低级同步原语可以用在哪?
|
||||
|
||||
这里我要先强调一句,一般情况下,我建议你优先使用CSP并发模型进行并发程序设计。但是在下面一些场景中,我们依然需要sync包提供的低级同步原语。
|
||||
|
||||
首先是需要高性能的临界区(critical section)同步机制场景。
|
||||
|
||||
在Go中,channel并发原语也可以用于对数据对象访问的同步,我们可以把channel看成是一种高级的同步原语,它自身的实现也是建构在低级同步原语之上的。也正因为如此,channel自身的性能与低级同步原语相比要略微逊色,开销要更大。
|
||||
|
||||
这里,关于sync.Mutex和channel各自实现的临界区同步机制,我做了一个简单的性能基准测试对比,通过对比结果,我们可以很容易看出两者的性能差异:
|
||||
|
||||
var cs = 0 // 模拟临界区要保护的数据
|
||||
var mu sync.Mutex
|
||||
var c = make(chan struct{}, 1)
|
||||
|
||||
func criticalSectionSyncByMutex() {
|
||||
mu.Lock()
|
||||
cs++
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func criticalSectionSyncByChan() {
|
||||
c <- struct{}{}
|
||||
cs++
|
||||
<-c
|
||||
}
|
||||
|
||||
func BenchmarkCriticalSectionSyncByMutex(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
criticalSectionSyncByMutex()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCriticalSectionSyncByMutexInParallel(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
criticalSectionSyncByMutex()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkCriticalSectionSyncByChan(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
criticalSectionSyncByChan()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCriticalSectionSyncByChanInParallel(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
criticalSectionSyncByChan()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
运行这个对比测试(Go 1.17),我们得到:
|
||||
|
||||
$go test -bench .
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
... ...
|
||||
BenchmarkCriticalSectionSyncByMutex-8 88083549 13.64 ns/op
|
||||
BenchmarkCriticalSectionSyncByMutexInParallel-8 22337848 55.29 ns/op
|
||||
BenchmarkCriticalSectionSyncByChan-8 28172056 42.48 ns/op
|
||||
BenchmarkCriticalSectionSyncByChanInParallel-8 5722972 208.1 ns/op
|
||||
PASS
|
||||
|
||||
|
||||
通过这个对比实验,我们可以看到,无论是在单Goroutine情况下,还是在并发测试情况下,sync.Mutex实现的同步机制的性能,都要比channel实现的高出三倍多。
|
||||
|
||||
因此,通常在需要高性能的临界区(critical section)同步机制的情况下,sync包提供的低级同步原语更为适合。
|
||||
|
||||
第二种就是在不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。
|
||||
|
||||
基于channel的并发设计,有一个特点:在Goroutine间通过channel转移数据对象的所有权。所以,只有拥有数据对象所有权(从channel接收到该数据)的Goroutine才可以对该数据对象进行状态变更。
|
||||
|
||||
如果你的设计中没有转移结构体对象所有权,但又要保证结构体内部状态数据在多个Goroutine之间同步访问,那么你可以使用sync包提供的低级同步原语来实现,比如最常用的sync.Mutex。
|
||||
|
||||
了解了这些应用场景之后,接着我们就来看看如何使用sync包中的各个同步结构,不过在使用之前,我们需要先看看一个sync包中同步原语使用的注意事项。
|
||||
|
||||
sync包中同步原语使用的注意事项
|
||||
|
||||
在sync包的注释中(在$GOROOT/src/sync/mutex.go文件的头部注释),我们看到这样一行说明:
|
||||
|
||||
// Values containing the types defined in this package should not be copied.
|
||||
|
||||
|
||||
翻译过来就是:“不应复制那些包含了此包中类型的值”。
|
||||
|
||||
在sync包的其他源文件中,我们同样看到类似的一些注释:
|
||||
|
||||
|
||||
// $GOROOT/src/sync/mutex.go
|
||||
// A Mutex must not be copied after first use. (禁止复制首次使用后的Mutex)
|
||||
|
||||
// $GOROOT/src/sync/rwmutex.go
|
||||
// A RWMutex must not be copied after first use.(禁止复制首次使用后的RWMutex)
|
||||
|
||||
// $GOROOT/src/sync/cond.go
|
||||
// A Cond must not be copied after first use.(禁止复制首次使用后的Cond)
|
||||
... ...
|
||||
|
||||
|
||||
那么,为什么首次使用Mutex等sync包中定义的结构类型后,我们不应该再对它们进行复制操作呢?我们以Mutex这个同步原语为例,看看它的实现是怎样的。
|
||||
|
||||
Go标准库中sync.Mutex的定义是这样的:
|
||||
|
||||
// $GOROOT/src/sync/mutex.go
|
||||
type Mutex struct {
|
||||
state int32
|
||||
sema uint32
|
||||
}
|
||||
|
||||
|
||||
我们看到,Mutex的定义非常简单,由两个整型字段state和sema组成:
|
||||
|
||||
|
||||
state:表示当前互斥锁的状态;
|
||||
sema:用于控制锁状态的信号量。
|
||||
|
||||
|
||||
初始情况下,Mutex的实例处于Unlocked状态(state和sema均为0)。对Mutex实例的复制也就是两个整型字段的复制。一旦发生复制,原变量与副本就是两个单独的内存块,各自发挥同步作用,互相就没有了关联。
|
||||
|
||||
如果发生复制后,你仍然认为原变量与副本保护的是同一个数据对象,那可就大错特错了。我们来看一个例子:
|
||||
|
||||
func main() {
|
||||
var wg sync.WaitGroup
|
||||
i := 0
|
||||
var mu sync.Mutex // 负责对i的同步访问
|
||||
|
||||
wg.Add(1)
|
||||
// g1
|
||||
go func(mu1 sync.Mutex) {
|
||||
mu1.Lock()
|
||||
i = 10
|
||||
time.Sleep(10 * time.Second)
|
||||
fmt.Printf("g1: i = %d\n", i)
|
||||
mu1.Unlock()
|
||||
wg.Done()
|
||||
}(mu)
|
||||
|
||||
time.Sleep(time.Second)
|
||||
|
||||
mu.Lock()
|
||||
i = 1
|
||||
fmt.Printf("g0: i = %d\n", i)
|
||||
mu.Unlock()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们使用一个sync.Mutex类型变量mu来同步对整型变量i的访问。我们创建一个新Goroutine:g1,g1通过函数参数得到mu的一份拷贝mu1,然后g1会通过mu1来同步对整型变量i的访问。
|
||||
|
||||
那么,g0通过mu和g1通过mu的拷贝mu1,是否能实现对同一个变量i的同步访问呢?我们来看看运行这个示例的运行结果:
|
||||
|
||||
g0: i = 1
|
||||
g1: i = 1
|
||||
|
||||
|
||||
从结果来看,这个程序并没有实现对i的同步访问,第9行g1对mu1的加锁操作,并没能阻塞第19行g0对mu的加锁。于是,g1刚刚将i赋值为10后,g0就又将i赋值为1了。
|
||||
|
||||
出现这种结果的原因就是我们前面分析的情况,一旦Mutex类型变量被拷贝,原变量与副本就各自发挥作用,互相没有关联了。甚至,如果拷贝的时机不对,比如在一个mutex处于locked的状态时对它进行了拷贝,就会对副本进行加锁操作,将导致加锁的Goroutine永远阻塞下去。
|
||||
|
||||
通过前面这个例子,我们可以很直观地看到:如果对使用过的、sync包中的类型的示例进行复制,并使用了复制后得到的副本,将导致不可预期的结果。所以,在使用sync包中的类型的时候,我们推荐通过闭包方式,或者是传递类型实例(或包裹该类型的类型实例)的地址(指针)的方式进行。这就是使用sync包时最值得我们注意的事项。
|
||||
|
||||
接下来,我们就来逐个分析日常使用较多的sync包中同步原语。我们先来看看互斥锁与读写锁。
|
||||
|
||||
互斥锁(Mutex)还是读写锁(RWMutex)?
|
||||
|
||||
sync包提供了两种用于临界区同步的原语:互斥锁(Mutex)和读写锁(RWMutex)。它们都是零值可用的数据类型,也就是不需要显式初始化就可以使用,并且使用方法都比较简单。在上面的示例中,我们已经看到了Mutex的应用方法,这里再总结一下:
|
||||
|
||||
var mu sync.Mutex
|
||||
mu.Lock() // 加锁
|
||||
doSomething()
|
||||
mu.Unlock() // 解锁
|
||||
|
||||
|
||||
一旦某个Goroutine调用的Mutex执行Lock操作成功,它将成功持有这把互斥锁。这个时候,如果有其他Goroutine执行Lock操作,就会阻塞在这把互斥锁上,直到持有这把锁的Goroutine调用Unlock释放掉这把锁后,才会抢到这把锁的持有权并进入临界区。
|
||||
|
||||
由此,我们也可以得到使用互斥锁的两个原则:
|
||||
|
||||
|
||||
尽量减少在锁中的操作。这可以减少其他因Goroutine阻塞而带来的损耗与延迟。
|
||||
一定要记得调用Unlock解锁。忘记解锁会导致程序局部死锁,甚至是整个程序死锁,会导致严重的后果。同时,我们也可以结合第23讲学习到的defer,优雅地执行解锁操作。
|
||||
|
||||
|
||||
读写锁与互斥锁用法大致相同,只不过多了一组加读锁和解读锁的方法:
|
||||
|
||||
var rwmu sync.RWMutex
|
||||
rwmu.RLock() //加读锁
|
||||
readSomething()
|
||||
rwmu.RUnlock() //解读锁
|
||||
rwmu.Lock() //加写锁
|
||||
changeSomething()
|
||||
rwmu.Unlock() //解写锁
|
||||
|
||||
|
||||
写锁与Mutex的行为十分类似,一旦某Goroutine持有写锁,其他Goroutine无论是尝试加读锁,还是加写锁,都会被阻塞在写锁上。
|
||||
|
||||
但读锁就宽松多了,一旦某个Goroutine持有读锁,它不会阻塞其他尝试加读锁的Goroutine,但加写锁的Goroutine依然会被阻塞住。
|
||||
|
||||
通常,互斥锁(Mutex)是临时区同步原语的首选,它常被用来对结构体对象的内部状态、缓存等进行保护,是使用最为广泛的临界区同步原语。相比之下,读写锁的应用就没那么广泛了,只活跃于它擅长的场景下。
|
||||
|
||||
那读写锁(RWMutex)究竟擅长在哪种场景下呢?我们先来看一组基准测试:
|
||||
|
||||
var cs1 = 0 // 模拟临界区要保护的数据
|
||||
var mu1 sync.Mutex
|
||||
|
||||
var cs2 = 0 // 模拟临界区要保护的数据
|
||||
var mu2 sync.RWMutex
|
||||
|
||||
func BenchmarkWriteSyncByMutex(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
mu1.Lock()
|
||||
cs1++
|
||||
mu1.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkReadSyncByMutex(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
mu1.Lock()
|
||||
_ = cs1
|
||||
mu1.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkReadSyncByRWMutex(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
mu2.RLock()
|
||||
_ = cs2
|
||||
mu2.RUnlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkWriteSyncByRWMutex(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
mu2.Lock()
|
||||
cs2++
|
||||
mu2.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
这些基准测试都是并发测试,度量的是Mutex、RWMutex在并发下的读写性能。我们分别在cpu=2、8、16、32的情况下运行这个并发性能测试,测试结果如下:
|
||||
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
... ...
|
||||
BenchmarkWriteSyncByMutex-2 73423770 16.12 ns/op
|
||||
BenchmarkReadSyncByMutex-2 84031135 15.08 ns/op
|
||||
BenchmarkReadSyncByRWMutex-2 37182219 31.87 ns/op
|
||||
BenchmarkWriteSyncByRWMutex-2 40727782 29.08 ns/op
|
||||
|
||||
BenchmarkWriteSyncByMutex-8 22153354 56.39 ns/op
|
||||
BenchmarkReadSyncByMutex-8 24164278 51.12 ns/op
|
||||
BenchmarkReadSyncByRWMutex-8 38589122 31.17 ns/op
|
||||
BenchmarkWriteSyncByRWMutex-8 18482208 65.27 ns/op
|
||||
|
||||
BenchmarkWriteSyncByMutex-16 20672842 62.94 ns/op
|
||||
BenchmarkReadSyncByMutex-16 19247158 62.94 ns/op
|
||||
BenchmarkReadSyncByRWMutex-16 29978614 39.98 ns/op
|
||||
BenchmarkWriteSyncByRWMutex-16 16095952 78.19 ns/op
|
||||
|
||||
BenchmarkWriteSyncByMutex-32 20539290 60.20 ns/op
|
||||
BenchmarkReadSyncByMutex-32 18807060 72.61 ns/op
|
||||
BenchmarkReadSyncByRWMutex-32 29772936 40.45 ns/op
|
||||
BenchmarkWriteSyncByRWMutex-32 13320544 86.53 ns/op
|
||||
|
||||
|
||||
通过测试结果对比,我们得到了一些结论:
|
||||
|
||||
|
||||
并发量较小的情况下,Mutex性能最好;随着并发量增大,Mutex的竞争激烈,导致加锁和解锁性能下降;
|
||||
RWMutex的读锁性能并没有随着并发量的增大,而发生较大变化,性能始终恒定在40ns左右;
|
||||
在并发量较大的情况下,RWMutex的写锁性能和Mutex、RWMutex读锁相比,是最差的,并且随着并发量增大,RWMutex写锁性能有继续下降趋势。
|
||||
|
||||
|
||||
由此,我们就可以看出,读写锁适合应用在具有一定并发量且读多写少的场合。在大量并发读的情况下,多个Goroutine可以同时持有读锁,从而减少在锁竞争中等待的时间。
|
||||
|
||||
而互斥锁,即便是读请求的场合,同一时刻也只能有一个Goroutine持有锁,其他Goroutine只能阻塞在加锁操作上等待被调度。
|
||||
|
||||
接下来,我们继续看条件变量sync.Cond。
|
||||
|
||||
条件变量
|
||||
|
||||
sync.Cond是传统的条件变量原语概念在Go语言中的实现。我们可以把一个条件变量理解为一个容器,这个容器中存放着一个或一组等待着某个条件成立的Goroutine。当条件成立后,这些处于等待状态的Goroutine将得到通知,并被唤醒继续进行后续的工作。这与百米飞人大战赛场上,各位运动员等待裁判员的发令枪声的情形十分类似。
|
||||
|
||||
条件变量是同步原语的一种,如果没有条件变量,开发人员可能需要在Goroutine中通过连续轮询的方式,检查某条件是否为真,这种连续轮询非常消耗资源,因为Goroutine在这个过程中是处于活动状态的,但它的工作又没有进展。
|
||||
|
||||
这里我们先看一个用sync.Mutex 实现对条件轮询等待的例子:
|
||||
|
||||
type signal struct{}
|
||||
|
||||
var ready bool
|
||||
|
||||
func worker(i int) {
|
||||
fmt.Printf("worker %d: is working...\n", i)
|
||||
time.Sleep(1 * time.Second)
|
||||
fmt.Printf("worker %d: works done\n", i)
|
||||
}
|
||||
|
||||
func spawnGroup(f func(i int), num int, mu *sync.Mutex) <-chan signal {
|
||||
c := make(chan signal)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < num; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
for {
|
||||
mu.Lock()
|
||||
if !ready {
|
||||
mu.Unlock()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
mu.Unlock()
|
||||
fmt.Printf("worker %d: start to work...\n", i)
|
||||
f(i)
|
||||
wg.Done()
|
||||
return
|
||||
}
|
||||
}(i + 1)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
c <- signal(struct{}{})
|
||||
}()
|
||||
return c
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("start a group of workers...")
|
||||
mu := &sync.Mutex{}
|
||||
c := spawnGroup(worker, 5, mu)
|
||||
|
||||
time.Sleep(5 * time.Second) // 模拟ready前的准备工作
|
||||
fmt.Println("the group of workers start to work...")
|
||||
|
||||
mu.Lock()
|
||||
ready = true
|
||||
mu.Unlock()
|
||||
|
||||
<-c
|
||||
fmt.Println("the group of workers work done!")
|
||||
}
|
||||
|
||||
|
||||
就像前面提到的,轮询的方式开销大,轮询间隔设置的不同,条件检查的及时性也会受到影响。-
|
||||
sync.Cond为Goroutine在这个场景下提供了另一种可选的、资源消耗更小、使用体验更佳的同步方式。使用条件变量原语,我们可以在实现相同目标的同时,避免对条件的轮询。
|
||||
|
||||
我们用sync.Cond对上面的例子进行改造,改造后的代码如下:
|
||||
|
||||
type signal struct{}
|
||||
|
||||
var ready bool
|
||||
|
||||
func worker(i int) {
|
||||
fmt.Printf("worker %d: is working...\n", i)
|
||||
time.Sleep(1 * time.Second)
|
||||
fmt.Printf("worker %d: works done\n", i)
|
||||
}
|
||||
|
||||
func spawnGroup(f func(i int), num int, groupSignal *sync.Cond) <-chan signal {
|
||||
c := make(chan signal)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < num; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
groupSignal.L.Lock()
|
||||
for !ready {
|
||||
groupSignal.Wait()
|
||||
}
|
||||
groupSignal.L.Unlock()
|
||||
fmt.Printf("worker %d: start to work...\n", i)
|
||||
f(i)
|
||||
wg.Done()
|
||||
}(i + 1)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
c <- signal(struct{}{})
|
||||
}()
|
||||
return c
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("start a group of workers...")
|
||||
groupSignal := sync.NewCond(&sync.Mutex{})
|
||||
c := spawnGroup(worker, 5, groupSignal)
|
||||
|
||||
time.Sleep(5 * time.Second) // 模拟ready前的准备工作
|
||||
fmt.Println("the group of workers start to work...")
|
||||
|
||||
groupSignal.L.Lock()
|
||||
ready = true
|
||||
groupSignal.Broadcast()
|
||||
groupSignal.L.Unlock()
|
||||
|
||||
<-c
|
||||
fmt.Println("the group of workers work done!")
|
||||
}
|
||||
|
||||
|
||||
我们运行这个示例程序,得到:
|
||||
|
||||
start a group of workers...
|
||||
the group of workers start to work...
|
||||
worker 2: start to work...
|
||||
worker 2: is working...
|
||||
worker 3: start to work...
|
||||
worker 3: is working...
|
||||
worker 1: start to work...
|
||||
worker 1: is working...
|
||||
worker 4: start to work...
|
||||
worker 5: start to work...
|
||||
worker 5: is working...
|
||||
worker 4: is working...
|
||||
worker 4: works done
|
||||
worker 2: works done
|
||||
worker 3: works done
|
||||
worker 1: works done
|
||||
worker 5: works done
|
||||
the group of workers work done!
|
||||
|
||||
|
||||
我们看到,sync.Cond实例的初始化,需要一个满足实现了sync.Locker接口的类型实例,通常我们使用sync.Mutex。
|
||||
|
||||
条件变量需要这个互斥锁来同步临界区,保护用作条件的数据。加锁后,各个等待条件成立的Goroutine判断条件是否成立,如果不成立,则调用sync.Cond的Wait方法进入等待状态。Wait方法在Goroutine挂起前会进行Unlock操作。
|
||||
|
||||
当main goroutine将ready置为true,并调用sync.Cond的Broadcast方法后,各个阻塞的Goroutine将被唤醒,并从Wait方法中返回。Wait方法返回前,Wait方法会再次加锁让Goroutine进入临界区。接下来Goroutine会再次对条件数据进行判定,如果条件成立,就会解锁并进入下一个工作阶段;如果条件依旧不成立,那么会再次进入循环体,并调用Wait方法挂起等待。
|
||||
|
||||
和sync.Mutex 、sync.RWMutex等相比,sync.Cond 应用的场景更为有限,只有在需要“等待某个条件成立”的场景下,Cond才有用武之地。
|
||||
|
||||
其实,面向CSP并发模型的channel原语和面向传统共享内存并发模型的sync包提供的原语,已经能够满足Go语言应用并发设计中99.9%的并发同步需求了。而剩余那0.1%的需求,我们可以使用Go标准库提供的atomic包来实现。
|
||||
|
||||
原子操作(atomic operations)
|
||||
|
||||
atomic包是Go语言给用户提供的原子操作原语的相关接口。原子操作(atomic operations)是相对于普通指令操作而言的。
|
||||
|
||||
我们以一个整型变量自增的语句为例说明一下:
|
||||
|
||||
var a int
|
||||
a++
|
||||
|
||||
|
||||
a++这行语句需要3条普通机器指令来完成变量a的自增:
|
||||
|
||||
|
||||
LOAD:将变量从内存加载到CPU寄存器;
|
||||
ADD:执行加法指令;
|
||||
STORE:将结果存储回原内存地址中。
|
||||
|
||||
|
||||
这3条普通指令在执行过程中是可以被中断的。而原子操作的指令是不可中断的,它就好比一个事务,要么不执行,一旦执行就一次性全部执行完毕,中间不可分割。也正因为如此,原子操作也可以被用于共享数据的并发同步。
|
||||
|
||||
原子操作由底层硬件直接提供支持,是一种硬件实现的指令级的“事务”,因此相对于操作系统层面和Go运行时层面提供的同步技术而言,它更为原始。
|
||||
|
||||
atomic包封装了CPU实现的部分原子操作指令,为用户层提供体验良好的原子操作函数,因此atomic包中提供的原语更接近硬件底层,也更为低级,它也常被用于实现更为高级的并发同步技术,比如channel和sync包中的同步原语。
|
||||
|
||||
我们以atomic.SwapInt64函数在x86_64平台上的实现为例,看看这个函数的实现方法:
|
||||
|
||||
// $GOROOT/src/sync/atomic/doc.go
|
||||
func SwapInt64(addr *int64, new int64) (old int64)
|
||||
|
||||
// $GOROOT/src/sync/atomic/asm.s
|
||||
|
||||
TEXT ·SwapInt64(SB),NOSPLIT,$0
|
||||
JMP runtime∕internal∕atomic·Xchg64(SB)
|
||||
|
||||
// $GOROOT/src/runtime/internal/atomic/asm_amd64.s
|
||||
TEXT runtime∕internal∕atomic·Xchg64(SB), NOSPLIT, $0-24
|
||||
MOVQ ptr+0(FP), BX
|
||||
MOVQ new+8(FP), AX
|
||||
XCHGQ AX, 0(BX)
|
||||
MOVQ AX, ret+16(FP)
|
||||
RET
|
||||
|
||||
|
||||
|
||||
从函数SwapInt64的实现中,我们可以看到:它基本就是对x86_64 CPU实现的原子操作指令XCHGQ的直接封装。
|
||||
|
||||
原子操作的特性,让atomic包也可以被用作对共享数据的并发同步,那么和更为高级的channel以及sync包中原语相比,我们究竟该怎么选择呢?
|
||||
|
||||
我们先来看看atomic包提供了哪些能力。
|
||||
|
||||
atomic包提供了两大类原子操作接口,一类是针对整型变量的,包括有符号整型、无符号整型以及对应的指针类型;另外一类是针对自定义类型的。因此,第一类原子操作接口的存在让atomic包天然适合去实现某一个共享整型变量的并发同步。
|
||||
|
||||
我们再看一个例子:
|
||||
|
||||
var n1 int64
|
||||
|
||||
func addSyncByAtomic(delta int64) int64 {
|
||||
return atomic.AddInt64(&n1, delta)
|
||||
}
|
||||
|
||||
func readSyncByAtomic() int64 {
|
||||
return atomic.LoadInt64(&n1)
|
||||
}
|
||||
|
||||
var n2 int64
|
||||
var rwmu sync.RWMutex
|
||||
|
||||
func addSyncByRWMutex(delta int64) {
|
||||
rwmu.Lock()
|
||||
n2 += delta
|
||||
rwmu.Unlock()
|
||||
}
|
||||
|
||||
func readSyncByRWMutex() int64 {
|
||||
var n int64
|
||||
rwmu.RLock()
|
||||
n = n2
|
||||
rwmu.RUnlock()
|
||||
return n
|
||||
}
|
||||
|
||||
func BenchmarkAddSyncByAtomic(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
addSyncByAtomic(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkReadSyncByAtomic(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
readSyncByAtomic()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkAddSyncByRWMutex(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
addSyncByRWMutex(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkReadSyncByRWMutex(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
readSyncByRWMutex()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
我们分别在cpu=2、 8、16、32的情况下运行上述性能基准测试,得到结果如下:
|
||||
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
... ...
|
||||
BenchmarkAddSyncByAtomic-2 75426774 17.69 ns/op
|
||||
BenchmarkReadSyncByAtomic-2 1000000000 0.7437 ns/op
|
||||
BenchmarkAddSyncByRWMutex-2 39041671 30.16 ns/op
|
||||
BenchmarkReadSyncByRWMutex-2 41325093 28.48 ns/op
|
||||
|
||||
BenchmarkAddSyncByAtomic-8 77497987 15.25 ns/op
|
||||
BenchmarkReadSyncByAtomic-8 1000000000 0.2395 ns/op
|
||||
BenchmarkAddSyncByRWMutex-8 17702034 67.16 ns/op
|
||||
BenchmarkReadSyncByRWMutex-8 29966182 40.37 ns/op
|
||||
|
||||
BenchmarkAddSyncByAtomic-16 57727968 20.39 ns/op
|
||||
BenchmarkReadSyncByAtomic-16 1000000000 0.2536 ns/op
|
||||
BenchmarkAddSyncByRWMutex-16 15029635 78.61 ns/op
|
||||
BenchmarkReadSyncByRWMutex-16 29722464 40.28 ns/op
|
||||
|
||||
BenchmarkAddSyncByAtomic-32 58010497 20.40 ns/op
|
||||
BenchmarkReadSyncByAtomic-32 1000000000 0.2402 ns/op
|
||||
BenchmarkAddSyncByRWMutex-32 11748312 93.15 ns/op
|
||||
BenchmarkReadSyncByRWMutex-32 29845912 40.54 ns/op
|
||||
|
||||
|
||||
通过这个运行结果,我们可以得出一些结论:
|
||||
|
||||
|
||||
读写锁的性能随着并发量增大的情况,与前面讲解的sync.RWMutex一致;
|
||||
利用原子操作的无锁并发写的性能,随着并发量增大几乎保持恒定;
|
||||
利用原子操作的无锁并发读的性能,随着并发量增大有持续提升的趋势,并且性能是读锁的约200倍。
|
||||
|
||||
|
||||
通过这些结论,我们大致可以看到atomic原子操作的特性:随着并发量提升,使用atomic实现的共享变量的并发读写性能表现更为稳定,尤其是原子读操作,和sync包中的读写锁原语比起来,atomic表现出了更好的伸缩性和高性能。
|
||||
|
||||
由此,我们也可以看出atomic包更适合一些对性能十分敏感、并发量较大且读多写少的场合。
|
||||
|
||||
不过,atomic原子操作可用来同步的范围有比较大限制,只能同步一个整型变量或自定义类型变量。如果我们要对一个复杂的临界区数据进行同步,那么首选的依旧是sync包中的原语。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
虽然Go推荐基于通信来共享内存的并发设计风格,但Go并没有彻底抛弃对基于共享内存并发模型的支持,Go通过标准库的sync包以及atomic包提供了低级同步原语。这些原语有着它们自己的应用场景。
|
||||
|
||||
如果我们考虑使用低级同步原语,一般都是因为低级同步原语可以提供更佳的性能表现,性能基准测试结果告诉我们,使用低级同步原语的性能可以高出channel许多倍。在性能敏感的场景下,我们依然离不开这些低级同步原语。
|
||||
|
||||
在使用sync包提供的同步原语之前,我们一定要牢记这些原语使用的注意事项:不要复制首次使用后的Mutex/RWMutex/Cond等。一旦复制,你将很大可能得到意料之外的运行结果。
|
||||
|
||||
sync包中的低级同步原语各有各的擅长领域,你可以记住:
|
||||
|
||||
|
||||
在具有一定并发量且读多写少的场合使用RWMutex;
|
||||
在需要“等待某个条件成立”的场景下使用Cond;
|
||||
当你不确定使用什么原语时,那就使用Mutex吧。
|
||||
|
||||
|
||||
如果你对同步的性能有极致要求,且并发量较大,读多写少,那么可以考虑一下atomic包提供的原子操作函数。
|
||||
|
||||
思考题
|
||||
|
||||
使用基于共享内存的并发模型时,最令人头疼的可能就是“死锁”问题的存在了。你了解死锁的产生条件么?能编写一个程序模拟一下死锁的发生么?
|
||||
|
||||
欢迎你把这节课分享给更多对Go并发感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
506
专栏/TonyBai·Go语言第一课/35即学即练:如何实现一个轻量级线程池?.md
Normal file
506
专栏/TonyBai·Go语言第一课/35即学即练:如何实现一个轻量级线程池?.md
Normal file
@@ -0,0 +1,506 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
35 即学即练:如何实现一个轻量级线程池?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在这一讲的开始,首先恭喜你完成了这门课核心篇语法部分的学习。这一部分的篇幅不多,主要讲解了Go的两个核心语法知识点:接口与并发原语。它们分别是耦合设计与并发设计的主要参与者,Go应用的骨架设计离不开它们。
|
||||
|
||||
但理论和实践毕竟是两回事,学完了基本语法,也需要实操来帮助我们落地。所以,在这核心篇的最后一讲,我依然会用一个小实战项目,帮助你学会灵活运用这部分的语法点。
|
||||
|
||||
不过,关于接口类型做为“关节”作用的演示,我们前面的两个小实战项目中都有一定的体现了,只是那时还没有讲到接口类型,你现在可以停下来,回顾一下09讲和27讲的代码,看看是否有更深刻的体会。
|
||||
|
||||
而且,接口类型对Go应用静态骨架的编织作用,在接口类型数量较多的项目中体现得更明显,由于篇幅有限,我很难找到一个合适的演示项目。
|
||||
|
||||
因此,这一讲的实战项目,我们主要围绕Go并发来做,实现一个轻量级线程池,也就是Goroutine池。
|
||||
|
||||
为什么要用到Goroutine池?
|
||||
|
||||
在第31讲学习Goroutine的时候,我们就说过:相对于操作系统线程,Goroutine的开销十分小,一个Goroutine的起始栈大小为2KB,而且创建、切换与销毁的代价很低,我们可以创建成千上万甚至更多Goroutine。
|
||||
|
||||
所以和其他语言不同的是,Go应用通常可以为每个新建立的连接创建一个对应的新Goroutine,甚至是为每个传入的请求生成一个Goroutine去处理。这种设计还有一个好处,实现起来十分简单,Gopher们在编写代码时也没有很高的心智负担。
|
||||
|
||||
不过,Goroutine的开销虽然“廉价”,但也不是免费的。
|
||||
|
||||
最明显的,一旦规模化后,这种非零成本也会成为瓶颈。我们以一个Goroutine分配2KB执行栈为例,100w Goroutine就是2GB的内存消耗。
|
||||
|
||||
其次,Goroutine从Go 1.4版本开始采用了连续栈的方案,也就是每个Goroutine的执行栈都是一块连续内存,如果空间不足,运行时会分配一个更大的连续内存空间作为这个Goroutine的执行栈,将原栈内容拷贝到新分配的空间中来。
|
||||
|
||||
连续栈的方案,虽然能避免Go 1.3采用的分段栈会导致的“hot split”问题,但连续栈的原理也决定了,一旦Goroutine的执行栈发生了grow,那么即便这个Goroutine不再需要那么大的栈空间,这个Goroutine的栈空间也不会被Shrink(收缩)了,这些空间可能会处于长时间闲置的状态,直到Goroutine退出。
|
||||
|
||||
另外,随着Goroutine数量的增加,Go运行时进行Goroutine调度的处理器消耗,也会随之增加,成为阻碍Go应用性能提升的重要因素。
|
||||
|
||||
那么面对这样的问题,常见的应对方式是什么呢?
|
||||
|
||||
Goroutine池就是一种常见的解决方案。这个方案的核心思想是对Goroutine的重用,也就是把M个计算任务调度到N个Goroutine上,而不是为每个计算任务分配一个独享的Goroutine,从而提高计算资源的利用率。
|
||||
|
||||
接下来,我们就来真正实现一个简单的Goroutine池,我们叫它workerpool。
|
||||
|
||||
workerpool的实现原理
|
||||
|
||||
workerpool的工作逻辑通常都很简单,所以即便是用于生产环境的workerpool实现,代码规模也都在千行左右。
|
||||
|
||||
当然,workerpool有很多种实现方式,这里为了更好地演示Go并发模型的应用模式,以及并发原语间的协作,我们采用完全基于channel+select的实现方案,不使用其他数据结构,也不使用sync包提供的各种同步结构,比如Mutex、RWMutex,以及Cond等。
|
||||
|
||||
workerpool的实现主要分为三个部分:
|
||||
|
||||
|
||||
pool的创建与销毁;
|
||||
pool中worker(Goroutine)的管理;
|
||||
task的提交与调度。
|
||||
|
||||
|
||||
其中,后两部分是pool的“精髓”所在,这两部分的原理我也用一张图表示了出来:
|
||||
|
||||
|
||||
|
||||
我们先看一下图中pool对worker的管理。
|
||||
|
||||
capacity是pool的一个属性,代表整个pool中worker的最大容量。我们使用一个带缓冲的channel:active,作为worker的“计数器”,这种channel使用模式就是我们在第33讲中讲过的计数信号量,如果记不太清了可以复习一下第33讲中的相关内容。
|
||||
|
||||
当active channel可写时,我们就创建一个worker,用于处理用户通过Schedule函数提交的待处理的请求。当active channel满了的时候,pool就会停止worker的创建,直到某个worker因故退出,active channel又空出一个位置时,pool才会创建新的worker填补那个空位。
|
||||
|
||||
这张图里,我们把用户要提交给workerpool执行的请求抽象为一个Task。Task的提交与调度也很简单:Task通过Schedule函数提交到一个task channel中,已经创建的worker将从这个task channel中读取task并执行。
|
||||
|
||||
好了!“Talk is cheap,show me the code”!接下来,我们就来写一版workerpool的代码,来验证一下这里分析的原理是否可行。
|
||||
|
||||
workerpool的一个最小可行实现
|
||||
|
||||
我们先建立workerpool目录作为实战项目的源码根目录,然后为这个项目创建go module:
|
||||
|
||||
$mkdir workerpool1
|
||||
$cd workerpool1
|
||||
$go mod init github.com/bigwhite/workerpool
|
||||
|
||||
|
||||
接下来,我们创建pool.go作为workpool包的主要源码文件。在这个源码文件中,我们定义了Pool结构体类型,这个类型的实例代表一个workerpool:
|
||||
|
||||
type Pool struct {
|
||||
capacity int // workerpool大小
|
||||
|
||||
active chan struct{} // 对应上图中的active channel
|
||||
tasks chan Task // 对应上图中的task channel
|
||||
|
||||
wg sync.WaitGroup // 用于在pool销毁时等待所有worker退出
|
||||
quit chan struct{} // 用于通知各个worker退出的信号channel
|
||||
}
|
||||
|
||||
|
||||
workerpool包对外主要提供三个API,它们分别是:
|
||||
|
||||
|
||||
workerpool.New:用于创建一个pool类型实例,并将pool池的worker管理机制运行起来;
|
||||
workerpool.Free:用于销毁一个pool池,停掉所有pool池中的worker;
|
||||
Pool.Schedule:这是Pool类型的一个导出方法,workerpool包的用户通过该方法向pool池提交待执行的任务(Task)。
|
||||
|
||||
|
||||
接下来我们就重点看看这三个API的实现。
|
||||
|
||||
我们先来看看workerpool.New是如何创建一个pool实例的:
|
||||
|
||||
func New(capacity int) *Pool {
|
||||
if capacity <= 0 {
|
||||
capacity = defaultCapacity
|
||||
}
|
||||
if capacity > maxCapacity {
|
||||
capacity = maxCapacity
|
||||
}
|
||||
|
||||
p := &Pool{
|
||||
capacity: capacity,
|
||||
tasks: make(chan Task),
|
||||
quit: make(chan struct{}),
|
||||
active: make(chan struct{}, capacity),
|
||||
}
|
||||
|
||||
fmt.Printf("workerpool start\n")
|
||||
|
||||
go p.run()
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
|
||||
我们看到,New函数接受一个参数capacity用于指定workerpool池的容量,这个参数用于控制workerpool最多只能有capacity个worker,共同处理用户提交的任务请求。函数开始处有一个对capacity参数的“防御性”校验,当用户传入不合理的值时,函数New会将它纠正为合理的值。
|
||||
|
||||
Pool类型实例变量p完成初始化后,我们创建了一个新的Goroutine,用于对workerpool进行管理,这个Goroutine执行的是Pool类型的run方法:
|
||||
|
||||
func (p *Pool) run() {
|
||||
idx := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.quit:
|
||||
return
|
||||
case p.active <- struct{}{}:
|
||||
// create a new worker
|
||||
idx++
|
||||
p.newWorker(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
run方法内是一个无限循环,循环体中使用select监视Pool类型实例的两个channel:quit和active。这种在for中使用select监视多个channel的实现,在Go代码中十分常见,是一种惯用法。
|
||||
|
||||
当接收到来自quit channel的退出“信号”时,这个Goroutine就会结束运行。而当active channel可写时,run方法就会创建一个新的worker Goroutine。 此外,为了方便在程序中区分各个worker输出的日志,我这里将一个从1开始的变量idx作为worker的编号,并把它以参数的形式传给创建worker的方法。
|
||||
|
||||
我们再将创建新的worker goroutine的职责,封装到一个名为newWorker的方法中:
|
||||
|
||||
func (p *Pool) newWorker(i int) {
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
fmt.Printf("worker[%03d]: recover panic[%s] and exit\n", i, err)
|
||||
<-p.active
|
||||
}
|
||||
p.wg.Done()
|
||||
}()
|
||||
|
||||
fmt.Printf("worker[%03d]: start\n", i)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.quit:
|
||||
fmt.Printf("worker[%03d]: exit\n", i)
|
||||
<-p.active
|
||||
return
|
||||
case t := <-p.tasks:
|
||||
fmt.Printf("worker[%03d]: receive a task\n", i)
|
||||
t()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
我们看到,在创建一个新的worker goroutine之前,newWorker方法会先调用p.wg.Add方法将WaitGroup的等待计数加一。由于每个worker运行于一个独立的Goroutine中,newWorker方法通过go关键字创建了一个新的Goroutine作为worker。
|
||||
|
||||
新worker的核心,依然是一个基于for-select模式的循环语句,在循环体中,新worker通过select监视quit和tasks两个channel。和前面的run方法一样,当接收到来自quit channel的退出“信号”时,这个worker就会结束运行。tasks channel中放置的是用户通过Schedule方法提交的请求,新worker会从这个channel中获取最新的Task并运行这个Task。
|
||||
|
||||
Task是一个对用户提交的请求的抽象,它的本质就是一个函数类型:
|
||||
|
||||
type Task func()
|
||||
|
||||
|
||||
这样,用户通过Schedule方法实际上提交的是一个函数类型的实例。
|
||||
|
||||
在新worker中,为了防止用户提交的task抛出panic,进而导致整个workerpool受到影响,我们在worker代码的开始处,使用了defer+recover对panic进行捕捉,捕捉后worker也是要退出的,于是我们还通过<-p.active更新了worker计数器。并且一旦worker goroutine退出,p.wg.Done也需要被调用,这样可以减少WaitGroup的Goroutine等待数量。
|
||||
|
||||
我们再来看workerpool提供给用户提交请求的导出方法Schedule:
|
||||
|
||||
var ErrWorkerPoolFreed = errors.New("workerpool freed") // workerpool已终止运行
|
||||
|
||||
func (p *Pool) Schedule(t Task) error {
|
||||
select {
|
||||
case <-p.quit:
|
||||
return ErrWorkerPoolFreed
|
||||
case p.tasks <- t:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Schedule方法的核心逻辑,是将传入的Task实例发送到workerpool的tasks channel中。但考虑到现在workerpool已经被销毁的状态,我们这里通过一个select,检视quit channel是否有“信号”可读,如果有,就返回一个哨兵错误ErrWorkerPoolFreed。如果没有,一旦p.tasks可写,提交的Task就会被写入tasks channel,以供pool中的worker处理。
|
||||
|
||||
这里要注意的是,这里的Pool结构体中的tasks是一个无缓冲的channel,如果pool中worker数量已达上限,而且worker都在处理task的状态,那么Schedule方法就会阻塞,直到有worker变为idle状态来读取tasks channel,schedule的调用阻塞才会解除。
|
||||
|
||||
至此,workerpool的最小可行实现的主要逻辑都实现完了。我们来验证一下它是否能按照我们的预期逻辑运行。
|
||||
|
||||
现在我们建立一个使用workerpool的项目demo1:
|
||||
|
||||
$mkdir demo1
|
||||
$cd demo1
|
||||
$go mod init demo1
|
||||
|
||||
|
||||
由于我们要引用本地的module,所以我们需要手工修改一下demo1的go.mod文件,并利用replace指示符将demo1对workerpool的引用指向本地workerpool1路径:
|
||||
|
||||
module demo1
|
||||
|
||||
go 1.17
|
||||
|
||||
require github.com/bigwhite/workerpool v1.0.0
|
||||
|
||||
replace github.com/bigwhite/workerpool v1.0.0 => ../workerpool1
|
||||
|
||||
|
||||
然后创建demo1的main.go文件,源码如下:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/bigwhite/workerpool"
|
||||
)
|
||||
|
||||
func main() {
|
||||
p := workerpool.New(5)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
err := p.Schedule(func() {
|
||||
time.Sleep(time.Second * 3)
|
||||
})
|
||||
if err != nil {
|
||||
println("task: ", i, "err:", err)
|
||||
}
|
||||
}
|
||||
|
||||
p.Free()
|
||||
}
|
||||
|
||||
|
||||
这个示例程序创建了一个capacity为5的workerpool实例,并连续向这个workerpool提交了10个task,每个task的逻辑很简单,只是Sleep 3秒后就退出。main函数在提交完任务后,调用workerpool的Free方法销毁pool,pool会等待所有worker执行完task后再退出。
|
||||
|
||||
demo1示例的运行结果如下:
|
||||
|
||||
workerpool start
|
||||
worker[005]: start
|
||||
worker[005]: receive a task
|
||||
worker[003]: start
|
||||
worker[003]: receive a task
|
||||
worker[004]: start
|
||||
worker[004]: receive a task
|
||||
worker[001]: start
|
||||
worker[002]: start
|
||||
worker[001]: receive a task
|
||||
worker[002]: receive a task
|
||||
worker[004]: receive a task
|
||||
worker[005]: receive a task
|
||||
worker[003]: receive a task
|
||||
worker[002]: receive a task
|
||||
worker[001]: receive a task
|
||||
worker[001]: exit
|
||||
worker[005]: exit
|
||||
worker[002]: exit
|
||||
worker[003]: exit
|
||||
worker[004]: exit
|
||||
workerpool freed
|
||||
|
||||
|
||||
从运行的输出结果来看,workerpool的最小可行实现的运行逻辑与我们的原理图是一致的。
|
||||
|
||||
不过,目前的workerpool实现好比“铁板一块”,虽然我们可以通过capacity参数可以指定workerpool容量,但我们无法对workerpool的行为进行定制。
|
||||
|
||||
比如当workerpool中的worker数量已达上限,而且worker都在处理task时,用户调用Schedule方法将阻塞,如果用户不想阻塞在这里,以我们目前的实现是做不到的。
|
||||
|
||||
那我们可以怎么改进呢?我们可以尝试在上面实现的基础上,为workerpool添加功能选项(functional option)机制。
|
||||
|
||||
添加功能选项机制
|
||||
|
||||
功能选项机制,可以让某个包的用户可以根据自己的需求,通过设置不同功能选项来定制包的行为。Go语言中实现功能选项机制有多种方法,但Go社区目前使用最为广泛的一个方案,是Go语言之父Rob Pike在2014年在博文《自引用函数与选项设计》中论述的一种,这种方案也被后人称为“功能选项(functional option)”方案。
|
||||
|
||||
接下来,我们就来看看如何使用Rob Pike的这种“功能选项”方案,让workerpool支持行为定制机制。
|
||||
|
||||
首先,我们将workerpool1目录拷贝一份形成workerpool2目录,我们将在这个目录下为workerpool包添加功能选项机制。
|
||||
|
||||
然后,我们在workerpool2目录下创建option.go文件,在这个文件中,我们定义用于代表功能选项的类型Option:
|
||||
|
||||
type Option func(*Pool)
|
||||
|
||||
|
||||
我们看到,这个Option实质是一个接受*Pool类型参数的函数类型。那么如何运用这个Option类型呢?别急,马上你就会知道。现在我们先要做的是,明确给workerpool添加什么功能选项。这里我们为workerpool添加两个功能选项:Schedule调用是否阻塞,以及是否预创建所有的worker。
|
||||
|
||||
为了支持这两个功能选项,我们需要在Pool类型中增加两个bool类型的字段,字段的具体含义,我也在代码中注释了:
|
||||
|
||||
type Pool struct {
|
||||
... ...
|
||||
preAlloc bool // 是否在创建pool的时候就预创建workers,默认值为:false
|
||||
|
||||
// 当pool满的情况下,新的Schedule调用是否阻塞当前goroutine。默认值:true
|
||||
// 如果block = false,则Schedule返回ErrNoWorkerAvailInPool
|
||||
block bool
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
针对这两个字段,我们在option.go中添加两个功能选项,WithBlock与WithPreAllocWorkers:
|
||||
|
||||
func WithBlock(block bool) Option {
|
||||
return func(p *Pool) {
|
||||
p.block = block
|
||||
}
|
||||
}
|
||||
|
||||
func WithPreAllocWorkers(preAlloc bool) Option {
|
||||
return func(p *Pool) {
|
||||
p.preAlloc = preAlloc
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们看到,这两个功能选项实质上是两个返回闭包函数的函数。
|
||||
|
||||
为了支持将这两个Option传给workerpool,我们还需要改造一下workerpool包的New函数,改造后的New函数代码如下:
|
||||
|
||||
func New(capacity int, opts ...Option) *Pool {
|
||||
... ...
|
||||
for _, opt := range opts {
|
||||
opt(p)
|
||||
}
|
||||
|
||||
fmt.Printf("workerpool start(preAlloc=%t)\n", p.preAlloc)
|
||||
|
||||
if p.preAlloc {
|
||||
// create all goroutines and send into works channel
|
||||
for i := 0; i < p.capacity; i++ {
|
||||
p.newWorker(i + 1)
|
||||
p.active <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
go p.run()
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
|
||||
新版New函数除了接受capacity参数之外,还在它的参数列表中增加了一个类型为Option的可变长参数opts。在New函数体中,我们通过一个for循环,将传入的Option运用到Pool类型的实例上。
|
||||
|
||||
新版New函数还会根据preAlloc的值来判断是否预创建所有的worker,如果需要,就调用newWorker方法把所有worker都创建出来。newWorker的实现与上一版代码并没有什么差异,这里就不再详说了。
|
||||
|
||||
但由于preAlloc选项的加入,Pool的run方法的实现有了变化,我们来看一下:
|
||||
|
||||
func (p *Pool) run() {
|
||||
idx := len(p.active)
|
||||
|
||||
if !p.preAlloc {
|
||||
loop:
|
||||
for t := range p.tasks {
|
||||
p.returnTask(t)
|
||||
select {
|
||||
case <-p.quit:
|
||||
return
|
||||
case p.active <- struct{}{}:
|
||||
idx++
|
||||
p.newWorker(idx)
|
||||
default:
|
||||
break loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.quit:
|
||||
return
|
||||
case p.active <- struct{}{}:
|
||||
// create a new worker
|
||||
idx++
|
||||
p.newWorker(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
新版run方法在preAlloc=false时,会根据tasks channel的情况在适合的时候创建worker(第4行~第18行),直到active channel写满,才会进入到和第一版代码一样的调度逻辑中(第20行~第29行)。
|
||||
|
||||
而且,提供给用户的Schedule函数也因WithBlock选项,有了一些变化:
|
||||
|
||||
func (p *Pool) Schedule(t Task) error {
|
||||
select {
|
||||
case <-p.quit:
|
||||
return ErrWorkerPoolFreed
|
||||
case p.tasks <- t:
|
||||
return nil
|
||||
default:
|
||||
if p.block {
|
||||
p.tasks <- t
|
||||
return nil
|
||||
}
|
||||
return ErrNoIdleWorkerInPool
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Schedule在tasks chanel无法写入的情况下,进入default分支。在default分支中,Schedule根据block字段的值,决定究竟是继续阻塞在tasks channel上,还是返回ErrNoIdleWorkerInPool错误。
|
||||
|
||||
和第一版worker代码一样,我们也来验证一下新增的功能选项是否好用。我们建立一个使用新版workerpool的项目demo2,demo2的go.mod与demo1的go.mod相似:
|
||||
|
||||
module demo2
|
||||
|
||||
go 1.17
|
||||
|
||||
require github.com/bigwhite/workerpool v1.0.0
|
||||
|
||||
replace github.com/bigwhite/workerpool v1.0.0 => ../workerpool2
|
||||
|
||||
|
||||
demo2的main.go文件如下:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/bigwhite/workerpool"
|
||||
)
|
||||
|
||||
func main() {
|
||||
p := workerpool.New(5, workerpool.WithPreAllocWorkers(false), workerpool.WithBlock(false))
|
||||
|
||||
time.Sleep(time.Second * 2)
|
||||
for i := 0; i < 10; i++ {
|
||||
err := p.Schedule(func() {
|
||||
time.Sleep(time.Second * 3)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("task[%d]: error: %s\n", i, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
p.Free()
|
||||
}
|
||||
|
||||
|
||||
在demo2中,我们使用workerpool包提供的功能选项,设置了我们期望的workerpool的运作行为,包括不要预创建worker,以及不要阻塞Schedule调用。
|
||||
|
||||
考虑到Goroutine调度的次序的不确定性,这里我在创建workerpool与真正开始调用Schedule方法之间,做了一个Sleep,尽量减少Schedule都返回失败的频率(但这仍然无法保证这种情况不会发生)。
|
||||
|
||||
运行demo2,我们会得到这个结果:
|
||||
|
||||
workerpool start(preAlloc=false)
|
||||
task[1]: error: no idle worker in pool
|
||||
worker[001]: start
|
||||
task[2]: error: no idle worker in pool
|
||||
task[4]: error: no idle worker in pool
|
||||
task[5]: error: no idle worker in pool
|
||||
task[6]: error: no idle worker in pool
|
||||
task[7]: error: no idle worker in pool
|
||||
task[8]: error: no idle worker in pool
|
||||
task[9]: error: no idle worker in pool
|
||||
worker[001]: receive a task
|
||||
worker[002]: start
|
||||
worker[002]: exit
|
||||
worker[001]: receive a task
|
||||
worker[001]: exit
|
||||
workerpool freed(preAlloc=false)
|
||||
|
||||
|
||||
不过,由于Goroutine调度的不确定性,这个结果仅仅是很多种结果的一种。我们看到,仅仅001这个worker收到了task,其余的worker都因为worker尚未创建完毕,而返回了错误,而不是像demo1那样阻塞在Schedule调用上。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们基于我们前面所讲的Go并发方面的内容,设计并实现了一个workerpool的最小可行实现,只用了不到200行代码。为了帮助你理解Go并发原语是如何运用的,这个workerpool实现完全基于channel+select,并没有使用到sync包提供的各种锁。
|
||||
|
||||
我们还基于workerpool的最小可行实现,为这个pool增加了功能选项的支持,我们采用的功能选项方案也是Go社区最为流行的方案,日常编码中如果你遇到了类似的需求可以重点参考。
|
||||
|
||||
最后我要提醒你:上面设计与实现的workerpool只是一个演示项目,不能作为生产项目使用。
|
||||
|
||||
思考题
|
||||
|
||||
关于workerpool这样的项目,如果让你来设计,你的设计思路是什么,不妨在留言区敞开谈谈?
|
||||
|
||||
欢迎你把这节课分享给更多感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
今天的项目源码在这里!
|
||||
|
||||
|
||||
|
||||
|
||||
639
专栏/TonyBai·Go语言第一课/36打稳根基:怎么实现一个TCP服务器?(上).md
Normal file
639
专栏/TonyBai·Go语言第一课/36打稳根基:怎么实现一个TCP服务器?(上).md
Normal file
@@ -0,0 +1,639 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
36 打稳根基:怎么实现一个TCP服务器?(上)
|
||||
你好,我是Tony Bai。欢迎来到这门课的最后一个部分:实战篇。
|
||||
|
||||
在进入正文之前,我先来说点题外话。去年我读过一本名为《陪孩子走过初中三年》的书,书中作者女儿的初中班主任有一句“名言”:“跟上了!”作者对这句名言的解读是:学习上,她强调孩子们学习的时候不要掉队,意思是一要跟上老师的步子,上课认真听讲,课后老师留的作业要不打折扣地去完成;二也要跟上年级和班级的进度。只要能紧紧地跟上,学习就不会有太大的问题。
|
||||
|
||||
在前面课程的留言区,我也经常用“跟上了”作为学习这门课的建议,和我一起同步走到这里的同学,都是践行“跟上了”这句“名言”的典范,从开篇词到现在,你是不是已经感受到了自己在Go语言方面的进步了呢?
|
||||
|
||||
好了,我们言归正传。关于最后一篇写啥,我也想了许久。开篇词中提过,实战篇的职责是带着你走完Go语言学习的“最后一公里”,那究竟什么是“最后一公里呢?该如何理解这最后一公里呢?
|
||||
|
||||
我的理解是,在掌握了前面的Go语言语法的前提下,这“最后一公里”就是面对一个实际问题的解决思路。很多语言初学者都有这样一个问题,即便学完了语法,面对一个实际问题时,还是也不知道该从何处着手。
|
||||
|
||||
其实这个事并没有那么难,尤其是程序员这一行,遇到一个实际问题,我们通常使用这个思路:
|
||||
|
||||
|
||||
|
||||
我们简单解释一下这张图。
|
||||
|
||||
首先是要理解问题。解决实际问题的过程起始于对问题的理解。我们要搞清楚为什么会有这个问题,问题究竟是什么。对于技术人员来说,最终目的是识别出可能要用到的技术点。
|
||||
|
||||
然后我们要对识别出的技术点,做相应的技术预研与储备。怎么做技术预研呢?我们至少要了解技术诞生的背景、技术的原理、技术能解决哪些问题以及不能解决哪些问题,还有技术的优点与不足,等等。当然,如果没有新技术点,可以忽略这一步。
|
||||
|
||||
最后,我们要基于技术预研和储备的结果,进行解决方案的设计与实现,这个是技术人最擅长的。
|
||||
|
||||
那为什么这个解决实际问题的步骤是一个循环呢?这是由问题的难易程度,以及人的认知能力有差别所决定的。如果问题简单或人的认知能力很强,我们可以一次性解决这个实际问题;如果问题复杂或人的认知能力稍弱,那么一个循环可能无法彻底解决这个问题,我们就会再一次进入该循环,直到问题得到完美解决。
|
||||
|
||||
你也看到了,这事儿说起来有些枯燥,那我们就来实践一下。在实战篇的这三讲中,我们就来“走一遍”这个过程。
|
||||
|
||||
那我们选一个什么例子呢?我们还是从Go官方用户2020调查报告中寻找答案,看看“我用Go在哪些领域开展工作”的调查结果:
|
||||
|
||||
|
||||
|
||||
我们看到,“Web编程”和“网络编程”分别位列第一名和第四名,我们在09讲的小实战项目中曾接触过简单的Web编程,因此这里,我们选择一个不同于Web编程的网络编程的例子,做为实战篇的实战项目。在实战篇的三讲中,我们就参照这个实际问题解决过程循环,逐步来解决一个网络编程类的实际问题。
|
||||
|
||||
什么是网络编程
|
||||
|
||||
什么是网络编程呢?网络编程的范围很大,因为我们熟知的网络是分层的,OSI规定了七层参考模型,而实际上我们使用的主流网络模型实现,是TCP/IP模型,它只有四层:
|
||||
|
||||
|
||||
|
||||
通常来说,我们更多关注OSI网络模型中的传输层(四层)与应用层(七层),也就是TCP/IP网络模型中的最上面两层。
|
||||
|
||||
TCP/IP网络模型,实现了两种传输层协议:TCP和UDP。TCP是面向连接的流协议,为通信的两端提供稳定可靠的数据传输服务;而UDP则提供了一种无需建立连接就可以发送数据包的方法。两种协议各有擅长的应用场景。
|
||||
|
||||
我们日常开发中使用最多的是TCP协议。基于TCP协议,我们实现了各种各样的满足用户需求的应用层协议。比如,我们常用的HTTP协议就是应用层协议的一种,而且是使用得最广泛的一种。而基于HTTP的Web编程就是一种针对应用层的网络编程。我们还可以基于传输层暴露给开发者的编程接口,实现应用层的自定义应用协议。
|
||||
|
||||
这个传输层暴露给开发者的编程接口,究竟是什么呢?目前各大主流操作系统平台中,最常用的传输层暴露给用户的网络编程接口,就是套接字(socket)。直接基于socket编程实现应用层通信业务,也是最常见的一种网络编程形式。
|
||||
|
||||
所以,这一节课,我们就使用一个基于socket网络编程的例子,我们先来看看这个例子对应的实际问题是什么。
|
||||
|
||||
问题描述
|
||||
|
||||
我们面临的实际问题是这样的:实现一个基于TCP的自定义应用层协议的通信服务端。仅仅这一句话,你可能还不是很清楚,我们展开说明一下。
|
||||
|
||||
我们的输入,是一个基于传输层自定义的应用层协议规范。由于TCP是面向连接的流协议传输机制,数据流本身没有明显的边界,这样定义协议时,就需要自行定义确定边界的方法,因此,基于TCP的自定义应用层协议通常有两种常见的定义模式:
|
||||
|
||||
|
||||
二进制模式:采用长度字段标识独立数据包的边界。采用这种方式定义的常见协议包括MQTT(物联网最常用的应用层协议之一)、SMPP(短信网关点对点接口协议)等;
|
||||
|
||||
文本模式:采用特定分隔符标识流中的数据包的边界,常见的包括HTTP协议等。
|
||||
|
||||
|
||||
相比之下,二进制模式要比文本模式编码更紧凑也更高效,所以我们这个问题中的自定义协议也采用了二进制模式,协议规范内容如下图:
|
||||
|
||||
|
||||
|
||||
关于协议内容的分析,我们放到设计与实现的那一讲中再细说,这里我们再看一下使用这个协议的通信两端的通信流程:
|
||||
|
||||
|
||||
|
||||
我们看到,这是一个典型的“请求/响应”通信模型。连接由客户端发起,建立连接后,客户端发起请求,服务端收到请求后处理并返回响应,就这样一个请求一个响应的进行下去,直到客户端主动断开连接为止。
|
||||
|
||||
而我们的任务,就是实现支持这个协议通信的服务端。
|
||||
|
||||
我们先假设各位小伙伴都没有亲自开发过类似的通信服务器,所以当理解完这个问题后,我们需要识别出解决这一问题可能使用到的技术点。不过这个问题并不复杂,我们可以很容易地识别出其中的技术点。
|
||||
|
||||
首先,前面说过socket是传输层给用户提供的编程接口,我们要进行的网络通信绕不开socket,因此我们首先需要了解socket编程模型。
|
||||
|
||||
其次,一旦通过socket将双方的连接建立后,剩下的就是通过网络I/O操作在两端收发数据了,学习基本网络I/O操作的方法与注意事项也必不可少。
|
||||
|
||||
最后,任何一端准备发送数据或收到数据后都要对数据进行操作,由于TCP是流协议,我们需要了解针对字节的操作。
|
||||
|
||||
按照问题解决循环,一旦识别出技术点,接下来我们要做的就是技术预研与储备。在Go中,字节操作基本上就是byte切片的操作,这些用法我们在第15讲中已经学过了。所以,这一讲,我们就来学习一下socket编程模型以及网络I/O操作,为后两讲的设计与实现打稳根基,做好铺垫。
|
||||
|
||||
TCP Socket编程模型
|
||||
|
||||
TCP Socket诞生以来,它的编程模型,也就是网络I/O模型已几经演化。网络I/O模型定义的是应用线程与操作系统内核之间的交互行为模式。我们通常用阻塞(Blocking)/非阻塞(Non-Blocking)来描述网络I/O模型。
|
||||
|
||||
阻塞/非阻塞,是以内核是否等数据全部就绪后,才返回(给发起系统调用的应用线程)来区分的。如果内核一直等到全部数据就绪才返回,这种行为模式就称为阻塞。如果内核查看数据就绪状态后,即便没有就绪也立即返回错误(给发起系统调用的应用线程),那么这种行为模式则称为非阻塞。
|
||||
|
||||
常用的网络I/O模型包括下面这几种:
|
||||
|
||||
|
||||
阻塞I/O(Blocking I/O)
|
||||
|
||||
|
||||
阻塞I/O是最常用的模型,这个模型下应用线程与内核之间的交互行为模式是这样的:
|
||||
|
||||
|
||||
|
||||
我们看到,在阻塞I/O模型下,当用户空间应用线程,向操作系统内核发起I/O请求后(一般为操作系统提供的I/O系列系统调用),内核会尝试执行这个I/O操作,并等所有数据就绪后,将数据从内核空间拷贝到用户空间,最后系统调用从内核空间返回。而在这个期间内,用户空间应用线程将阻塞在这个I/O系统调用上,无法进行后续处理,只能等待。
|
||||
|
||||
因此,在这样的模型下,一个线程仅能处理一个网络连接上的数据通信。即便连接上没有数据,线程也只能阻塞在对Socket的读操作上(以等待对端的数据)。虽然这个模型对应用整体来说是低效的,但对开发人员来说,这个模型却是最容易实现和使用的,所以,各大平台在默认情况下都将Socket设置为阻塞的。
|
||||
|
||||
|
||||
非阻塞I/O(Non-Blocking I/O)
|
||||
|
||||
|
||||
非阻塞I/O模型下,应用线程与内核之间的交互行为模式是这样的:
|
||||
|
||||
|
||||
|
||||
和阻塞I/O模型正相反,在非阻塞模型下,当用户空间线程向操作系统内核发起I/O请求后,内核会执行这个I/O操作,如果这个时候数据尚未就绪,就会立即将“未就绪”的状态以错误码形式(比如:EAGAIN/EWOULDBLOCK),返回给这次I/O系统调用的发起者。而后者就会根据系统调用的返回状态来决定下一步该怎么做。
|
||||
|
||||
在非阻塞模型下,位于用户空间的I/O请求发起者通常会通过轮询的方式,去一次次发起I/O请求,直到读到所需的数据为止。不过,这样的轮询是对CPU计算资源的极大浪费,因此,非阻塞I/O模型单独应用于实际生产的比例并不高。
|
||||
|
||||
|
||||
I/O多路复用(I/O Multiplexing)
|
||||
|
||||
|
||||
为了避免非阻塞I/O模型轮询对计算资源的浪费,同时也考虑到阻塞I/O模型的低效,开发人员首选的网络I/O模型,逐渐变成了建立在内核提供的多路复用函数select/poll等(以及性能更好的epoll等函数)基础上的I/O多路复用模型。
|
||||
|
||||
这个模型下,应用线程与内核之间的交互行为模式如下图:
|
||||
|
||||
|
||||
|
||||
从图中我们看到,在这种模型下,应用线程首先将需要进行I/O操作的Socket,都添加到多路复用函数中(这里以select为例),然后阻塞,等待select系统调用返回。当内核发现有数据到达时,对应的Socket具备了通信条件,这时select函数返回。然后用户线程会针对这个Socket再次发起网络I/O请求,比如一个read操作。由于数据已就绪,这次网络I/O操作将得到预期的操作结果。
|
||||
|
||||
我们看到,相比于阻塞模型一个线程只能处理一个Socket的低效,I/O多路复用模型中,一个应用线程可以同时处理多个Socket。同时,I/O多路复用模型由内核实现可读/可写事件的通知,避免了非阻塞模型中轮询,带来的CPU计算资源浪费的问题。
|
||||
|
||||
目前,主流网络服务器采用的都是“I/O多路复用”模型,有的也结合了多线程。不过,I/O多路复用模型在支持更多连接、提升I/O操作效率的同时,也给使用者带来了不小的复杂度,以至于后面出现了许多高性能的I/O多路复用框架,比如:libevent、libev、libuv等,以帮助开发者简化开发复杂性,降低心智负担。
|
||||
|
||||
那么,在这三种socket编程模型中,Go语言使用的是哪一种呢?我们继续往下看。
|
||||
|
||||
Go语言socket编程模型
|
||||
|
||||
Go语言设计者考虑得更多的是Gopher的开发体验。前面我们也说过,阻塞I/O模型是对开发人员最友好的,也是心智负担最低的模型,而I/O多路复用的这种通过回调割裂执行流的模型,对开发人员来说还是过于复杂了,于是Go选择了为开发人员提供阻塞I/O模型,Gopher只需在Goroutine中以最简单、最易用的“阻塞I/O模型”的方式,进行Socket操作就可以了。
|
||||
|
||||
再加上,Go没有使用基于线程的并发模型,而是使用了开销更小的Goroutine作为基本执行单元,这让每个Goroutine处理一个TCP连接成为可能,并且在高并发下依旧表现出色。
|
||||
|
||||
不过,网络I/O操作都是系统调用,Goroutine执行I/O操作的话,一旦阻塞在系统调用上,就会导致M也被阻塞,为了解决这个问题,Go设计者将这个“复杂性”隐藏在Go运行时中,他们在运行时中实现了网络轮询器(netpoller),netpoller的作用,就是只阻塞执行网络I/O操作的Goroutine,但不阻塞执行Goroutine的线程(也就是M)。
|
||||
|
||||
这样一来,对于Go程序的用户层(相对于Go运行时层)来说,它眼中看到的goroutine采用了“阻塞I/O模型”进行网络I/O操作,Socket都是“阻塞”的。
|
||||
|
||||
但实际上,这样的“假象”,是通过Go运行时中的netpoller I/O多路复用机制,“模拟”出来的,对应的、真实的底层操作系统Socket,实际上是非阻塞的。只是运行时拦截了针对底层Socket的系统调用返回的错误码,并通过netpoller和Goroutine调度,让Goroutine“阻塞”在用户层所看到的Socket描述符上。
|
||||
|
||||
比如:当用户层针对某个Socket描述符发起read操作时,如果这个Socket对应的连接上还没有数据,运行时就会将这个Socket描述符加入到netpoller中监听,同时发起此次读操作的Goroutine会被挂起。
|
||||
|
||||
直到Go运行时收到这个Socket数据可读的通知,Go运行时才会重新唤醒等待在这个Socket上准备读数据的那个Goroutine。而这个过程,从Goroutine的视角来看,就像是read操作一直阻塞在那个Socket描述符上一样。
|
||||
|
||||
而且,Go语言在网络轮询器(netpoller)中采用了I/O多路复用的模型。考虑到最常见的多路复用系统调用select有比较多的限制,比如:监听Socket的数量有上限(1024)、时间复杂度高,等等,Go运行时选择了在不同操作系统上,使用操作系统各自实现的高性能多路复用函数,比如:Linux上的epoll、Windows上的iocp、FreeBSD/MacOS上的kqueue、Solaris上的event port等,这样可以最大程度提高netpoller的调度和执行性能。
|
||||
|
||||
了解完Go socket编程模型后,接下来,我们就深入到几个常用的基于socket的网络I/O操作中,逐一了解一下这些操作的机制与注意事项。
|
||||
|
||||
socket监听(listen)与接收连接(accept)
|
||||
|
||||
socket编程的核心在于服务端,而服务端有着自己一套相对固定的套路:Listen+Accept。在这套固定套路的基础上,我们的服务端程序通常采用一个Goroutine处理一个连接,它的大致结构如下:
|
||||
|
||||
func handleConn(c net.Conn) {
|
||||
defer c.Close()
|
||||
for {
|
||||
// read from the connection
|
||||
// ... ...
|
||||
// write to the connection
|
||||
//... ...
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
l, err := net.Listen("tcp", ":8888")
|
||||
if err != nil {
|
||||
fmt.Println("listen error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
fmt.Println("accept error:", err)
|
||||
break
|
||||
}
|
||||
// start a new goroutine to handle
|
||||
// the new connection.
|
||||
go handleConn(c)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这个服务端程序中,我们在第12行使用了net包的Listen函数绑定(bind)服务器端口8888,并将它转换为监听状态,Listen返回成功后,这个服务会进入一个循环,并调用net.Listener的Accept方法接收新客户端连接。
|
||||
|
||||
在没有新连接的时候,这个服务会阻塞在Accept调用上,直到有客户端连接上来,Accept方法将返回一个net.Conn实例。通过这个net.Conn,我们可以和新连上的客户端进行通信。这个服务程序启动了一个新Goroutine,并将net.Conn传给这个Goroutine,这样这个Goroutine就专职负责处理与这个客户端的通信了。
|
||||
|
||||
而net.Listen函数很少报错,除非是监听的端口已经被占用,那样程序将输出类似这样的错误:
|
||||
|
||||
bind: address already in use
|
||||
|
||||
|
||||
当服务程序启动成功后,我们可以通过netstat命令,查看端口的监听情况:
|
||||
|
||||
$netstat -an|grep 8888
|
||||
tcp46 0 0 *.8888 *.* LISTEN
|
||||
|
||||
|
||||
了解了服务端的“套路”后,我们再来看看客户端。
|
||||
|
||||
向服务端建立TCP连接
|
||||
|
||||
一旦服务端按照上面的Listen + Accept结构成功启动,客户端便可以使用net.Dial或net.DialTimeout向服务端发起连接建立的请求:
|
||||
|
||||
conn, err := net.Dial("tcp", "localhost:8888")
|
||||
conn, err := net.DialTimeout("tcp", "localhost:8888", 2 * time.Second)
|
||||
|
||||
|
||||
Dial函数向服务端发起TCP连接,这个函数会一直阻塞,直到连接成功或失败后,才会返回。而DialTimeout带有超时机制,如果连接耗时大于超时时间,这个函数会返回超时错误。 对于客户端来说,连接的建立还可能会遇到几种特殊情形。
|
||||
|
||||
第一种情况:网络不可达或对方服务未启动。
|
||||
|
||||
如果传给Dial的服务端地址是网络不可达的,或者服务地址中端口对应的服务并没有启动,端口未被监听(Listen),Dial几乎会立即返回类似这样的错误:
|
||||
|
||||
dial error: dial tcp :8888: getsockopt: connection refused
|
||||
|
||||
|
||||
第二种情况:对方服务的listen backlog队列满。
|
||||
|
||||
当对方服务器很忙,瞬间有大量客户端尝试向服务端建立连接时,服务端可能会出现listen backlog队列满,接收连接(accept)不及时的情况,这就会导致客户端的Dial调用阻塞,直到服务端进行一次accept,从backlog队列中腾出一个槽位,客户端的Dial才会返回成功。
|
||||
|
||||
而且,不同操作系统下backlog队列的长度是不同的,在macOS下,这个默认值如下:
|
||||
|
||||
$sysctl -a|grep kern.ipc.somaxconn
|
||||
kern.ipc.somaxconn: 128
|
||||
|
||||
|
||||
在Ubuntu Linux下,backlog队列的长度值与系统中net.ipv4.tcp_max_syn_backlog的设置有关。
|
||||
|
||||
那么,极端情况下,如果服务端一直不执行accept操作,那么客户端会一直阻塞吗?
|
||||
|
||||
答案是不会!我们看一个实测结果。如果服务端运行在macOS下,那么客户端会阻塞大约1分多钟,才会返回超时错误:
|
||||
|
||||
dial error: dial tcp :8888: getsockopt: operation timed out
|
||||
|
||||
|
||||
而如果服务端运行在Ubuntu上,客户端的Dial调用大约在2分多钟后提示超时错误,这个结果也和Linux的系统设置有关。
|
||||
|
||||
第三种情况:若网络延迟较大,Dial将阻塞并超时。
|
||||
|
||||
如果网络延迟较大,TCP连接的建立过程(三次握手)将更加艰难坎坷,会经历各种丢包,时间消耗自然也会更长,这种情况下,Dial函数会阻塞。如果经过长时间阻塞后依旧无法建立连接,那么Dial也会返回类似getsockopt: operation timed out的错误。
|
||||
|
||||
在连接建立阶段,多数情况下Dial是可以满足需求的,即便是阻塞一小会儿也没事。但对于那些需要有严格的连接时间限定的Go应用,如果一定时间内没能成功建立连接,程序可能会需要执行一段“错误”处理逻辑,所以,这种情况下,我们使用DialTimeout函数更适合。
|
||||
|
||||
全双工通信
|
||||
|
||||
一旦客户端调用Dial成功,我们就在客户端与服务端之间建立起了一条全双工的通信通道。通信双方通过各自获得的Socket,可以在向对方发送数据包的同时,接收来自对方的数据包。下图展示了系统层面对这条全双工通信通道的实现原理:
|
||||
|
||||
|
||||
|
||||
任何一方的操作系统,都会为已建立的连接分配一个发送缓冲区和一个接收缓冲区。
|
||||
|
||||
以客户端为例,客户端会通过成功连接服务端后得到的conn(封装了底层的socket)向服务端发送数据包。这些数据包会先进入到己方的发送缓冲区中,之后,这些数据会被操作系统内核通过网络设备和链路,发到服务端的接收缓冲区中,服务端程序再通过代表客户端连接的conn读取服务端接收缓冲区中的数据,并处理。
|
||||
|
||||
反之,服务端发向客户端的数据包也是先后经过服务端的发送缓冲区、客户端的接收缓冲区,最终到达客户端的应用的。
|
||||
|
||||
理解了这个通信原理,我们再理解下面的Socket操作就容易许多了。
|
||||
|
||||
Socket读操作
|
||||
|
||||
连接建立起来后,我们就要在连接上进行读写以完成业务逻辑。我们前面说过,Go运行时隐藏了I/O多路复用的复杂性。Go语言使用者只需采用Goroutine+阻塞I/O模型,就可以满足大部分场景需求。Dial连接成功后,会返回一个net.Conn接口类型的变量值,这个接口变量的底层类型为一个*TCPConn:
|
||||
|
||||
//$GOROOT/src/net/tcpsock.go
|
||||
type TCPConn struct {
|
||||
conn
|
||||
}
|
||||
|
||||
|
||||
TCPConn内嵌了一个非导出类型:conn(封装了底层的socket),因此,TCPConn“继承”了conn类型的Read和Write方法,后续通过Dial函数返回值调用的Read和Write方法都是net.conn的方法,它们分别代表了对socket的读和写。
|
||||
|
||||
接下来,我们先来通过几个场景来总结一下Go中从socket读取数据的行为特点。
|
||||
|
||||
首先是Socket中无数据的场景。
|
||||
|
||||
连接建立后,如果客户端未发送数据,服务端会阻塞在Socket的读操作上,这和前面提到的“阻塞I/O模型”的行为模式是一致的。执行该这个操作的Goroutine也会被挂起。Go运行时会监视这个Socket,直到它有数据读事件,才会重新调度这个Socket对应的Goroutine完成读操作。
|
||||
|
||||
第二种情况是Socket中有部分数据。
|
||||
|
||||
如果Socket中有部分数据就绪,且数据数量小于一次读操作期望读出的数据长度,那么读操作将会成功读出这部分数据,并返回,而不是等待期望长度数据全部读取后,再返回。
|
||||
|
||||
举个例子,服务端创建一个长度为10的切片作为接收数据的缓冲区,等待Read操作将读取的数据放入切片。当客户端在已经建立成功的连接上,成功写入两个字节的数据(比如:hi)后,服务端的Read方法将成功读取数据,并返回n=2,err=nil,而不是等收满10个字节后才返回。
|
||||
|
||||
第三种情况是Socket中有足够数据。
|
||||
|
||||
如果连接上有数据,且数据长度大于等于一次Read操作期望读出的数据长度,那么Read将会成功读出这部分数据,并返回。这个情景是最符合我们对Read的期待的了。
|
||||
|
||||
我们以上面的例子为例,当客户端在已经建立成功的连接上,成功写入15个字节的数据后,服务端进行第一次Read时,会用连接上的数据将我们传入的切片缓冲区(长度为10)填满后返回:n = 10, err = nil。这个时候,内核缓冲区中还剩5个字节数据,当服务端再次调用Read方法时,就会把剩余数据全部读出。
|
||||
|
||||
最后一种情况是设置读操作超时。
|
||||
|
||||
有些场合,对socket的读操作的阻塞时间有严格限制的,但由于Go使用的是阻塞I/O模型,如果没有可读数据,Read操作会一直阻塞在对Socket的读操作上。
|
||||
|
||||
这时,我们可以通过net.Conn提供的SetReadDeadline方法,设置读操作的超时时间,当超时后仍然没有数据可读的情况下,Read操作会解除阻塞并返回超时错误,这就给Read方法的调用者提供了进行其他业务处理逻辑的机会。
|
||||
|
||||
SetReadDeadline方法接受一个绝对时间作为超时的deadline。一旦通过这个方法设置了某个socket的Read deadline,当发生超时后,如果我们不重新设置Deadline,那么后面与这个socket有关的所有读操作,都会返回超时失败错误。
|
||||
|
||||
下面是结合SetReadDeadline设置的服务端一般处理逻辑:
|
||||
|
||||
func handleConn(c net.Conn) {
|
||||
defer c.Close()
|
||||
for {
|
||||
// read from the connection
|
||||
var buf = make([]byte, 128)
|
||||
c.SetReadDeadline(time.Now().Add(time.Second))
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
log.Printf("conn read %d bytes, error: %s", n, err)
|
||||
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
|
||||
// 进行其他业务逻辑的处理
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("read %d bytes, content is %s\n", n, string(buf[:n]))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
如果我们要取消超时设置,可以使用SetReadDeadline(time.Time{})实现。
|
||||
|
||||
Socket写操作
|
||||
|
||||
通过net.Conn实例的Write方法,我们可以将数据写入Socket。当Write调用的返回值n的值,与预期要写入的数据长度相等,且err = nil时,我们就执行了一次成功的Socket写操作,这是我们在调用Write时遇到的最常见的情形。
|
||||
|
||||
和Socket的读操作一些特殊情形相比,Socket写操作遇到的特殊情形同样不少,我们也逐一看一下。
|
||||
|
||||
第一种情况:写阻塞。
|
||||
|
||||
TCP协议通信两方的操作系统内核,都会为这个连接保留数据缓冲区,调用Write向Socket写入数据,实际上是将数据写入到操作系统协议栈的数据缓冲区中。TCP是全双工通信,因此每个方向都有独立的数据缓冲。当发送方将对方的接收缓冲区,以及自身的发送缓冲区都写满后,再调用Write方法就会出现阻塞的情况。
|
||||
|
||||
我们来看一个具体例子。这个例子的客户端代码如下:
|
||||
|
||||
func main() {
|
||||
log.Println("begin dial...")
|
||||
conn, err := net.Dial("tcp", ":8888")
|
||||
if err != nil {
|
||||
log.Println("dial error:", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
log.Println("dial ok")
|
||||
|
||||
data := make([]byte, 65536)
|
||||
var total int
|
||||
for {
|
||||
n, err := conn.Write(data)
|
||||
if err != nil {
|
||||
total += n
|
||||
log.Printf("write %d bytes, error:%s\n", n, err)
|
||||
break
|
||||
}
|
||||
total += n
|
||||
log.Printf("write %d bytes this time, %d bytes in total\n", n, total)
|
||||
}
|
||||
|
||||
log.Printf("write %d bytes in total\n", total)
|
||||
}
|
||||
|
||||
|
||||
客户端每次调用Write方法向服务端写入65536个字节,并在Write方法返回后,输出此次Write的写入字节数和程序启动后写入的总字节数量。
|
||||
|
||||
服务端的处理程序逻辑,我也摘录了主要部分,你可以看一下:
|
||||
|
||||
... ...
|
||||
func handleConn(c net.Conn) {
|
||||
defer c.Close()
|
||||
time.Sleep(time.Second * 10)
|
||||
for {
|
||||
// read from the connection
|
||||
time.Sleep(5 * time.Second)
|
||||
var buf = make([]byte, 60000)
|
||||
log.Println("start to read from conn")
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
log.Printf("conn read %d bytes, error: %s", n, err)
|
||||
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("read %d bytes, content is %s\n", n, string(buf[:n]))
|
||||
}
|
||||
}
|
||||
... ...
|
||||
|
||||
|
||||
我们可以看到,服务端在前10秒中并不读取数据,因此当客户端一直调用Write方法写入数据时,写到一定量后就会发生阻塞。你可以看一下客户端的执行输出:
|
||||
|
||||
2022/01/14 14:57:33 begin dial...
|
||||
2022/01/14 14:57:33 dial ok
|
||||
2022/01/14 14:57:33 write 65536 bytes this time, 65536 bytes in total
|
||||
... ...
|
||||
2022/01/14 14:57:33 write 65536 bytes this time, 589824 bytes in total
|
||||
2022/01/14 14:57:33 write 65536 bytes this time, 655360 bytes in total <-- 之后,写操作将阻塞
|
||||
|
||||
|
||||
后续当服务端每隔5秒进行一次读操作后,内核socket缓冲区腾出了空间,客户端就又可以写入了:
|
||||
|
||||
服务端:
|
||||
|
||||
2022/01/14 15:07:01 accept a new connection
|
||||
2022/01/14 15:07:16 start to read from conn
|
||||
2022/01/14 15:07:16 read 60000 bytes, content is
|
||||
2022/01/14 15:07:21 start to read from conn
|
||||
2022/01/14 15:07:21 read 60000 bytes, content is
|
||||
2022/01/14 15:07:26 start to read from conn
|
||||
2022/01/14 15:07:26 read 60000 bytes, content is
|
||||
....
|
||||
|
||||
|
||||
|
||||
客户端(得以继续写入):
|
||||
|
||||
2022/01/14 15:07:01 write 65536 bytes this time, 720896 bytes in total
|
||||
2022/01/14 15:07:06 write 65536 bytes this time, 786432 bytes in total
|
||||
2022/01/14 15:07:16 write 65536 bytes this time, 851968 bytes in total
|
||||
2022/01/14 15:07:16 write 65536 bytes this time, 917504 bytes in total
|
||||
2022/01/14 15:07:27 write 65536 bytes this time, 983040 bytes in total
|
||||
2022/01/14 15:07:27 write 65536 bytes this time, 1048576 bytes in total
|
||||
.... ...
|
||||
|
||||
|
||||
第二种情况:写入部分数据。
|
||||
|
||||
Write操作存在写入部分数据的情况,比如上面例子中,当客户端输出日志停留在“write 65536 bytes this time, 655360 bytes in total”时,我们杀掉服务端,这时我们就会看到客户端输出以下日志:
|
||||
|
||||
...
|
||||
2022/01/14 15:19:14 write 65536 bytes this time, 655360 bytes in total
|
||||
2022/01/14 15:19:16 write 24108 bytes, error:write tcp 127.0.0.1:62245->127.0.0.1:8888: write: broken pipe
|
||||
2022/01/14 15:19:16 write 679468 bytes in total
|
||||
|
||||
|
||||
显然,Write并不是在655360这个地方阻塞的,而是后续又写入24108个字节后发生了阻塞,服务端Socket关闭后,我们看到客户端又写入24108字节后,才返回的broken pipe错误。由于这24108字节数据并未真正被服务端接收到,程序需要考虑妥善处理这些数据,以防数据丢失。
|
||||
|
||||
第三种情况:写入超时。
|
||||
|
||||
如果我们非要给Write操作增加一个期限,可以调用SetWriteDeadline方法。比如,我们可以将上面例子中的客户端源码拷贝一份,然后在新客户端源码中的Write调用之前,增加一行超时时间设置代码:
|
||||
|
||||
conn.SetWriteDeadline(time.Now().Add(time.Microsecond * 10))
|
||||
|
||||
|
||||
然后先后启动服务端与新客户端,我们可以看到写入超时的情况下,Write方法的返回结果:
|
||||
|
||||
客户端输出:
|
||||
|
||||
2022/01/14 15:26:34 begin dial...
|
||||
2022/01/14 15:26:34 dial ok
|
||||
2022/01/14 15:26:34 write 65536 bytes this time, 65536 bytes in total
|
||||
... ...
|
||||
2022/01/14 15:26:34 write 65536 bytes this time, 655360 bytes in total
|
||||
2022/01/14 15:26:34 write 24108 bytes, error:write tcp 127.0.0.1:62325->127.0.0.1:8888: i/o timeout
|
||||
2022/01/14 15:26:34 write 679468 bytes in total
|
||||
|
||||
|
||||
我们可以看到,在Write方法写入超时时,依旧存在数据部分写入(仅写入24108个字节)的情况。另外,和SetReadDeadline一样,只要我们通过SetWriteDeadline设置了写超时,那无论后续Write方法是否成功,如果不重新设置写超时或取消写超时,后续对Socket的写操作都将以超时失败告终。
|
||||
|
||||
综合上面这些例子,虽然Go给我们提供了阻塞I/O的便利,但在调用Read和Write时,依旧要综合函数返回的n和err的结果以做出正确处理。
|
||||
|
||||
不过,前面说的Socket读与写都是限于单Goroutine下的操作,如果多个Goroutine并发读或写一个socket会发生什么呢?我们继续往下看。
|
||||
|
||||
并发Socket读写
|
||||
|
||||
Goroutine的网络编程模型,决定了存在着不同Goroutine间共享conn的情况,那么conn的读写是否是Goroutine并发安全的呢?不过,在深入这个问题之前,我们先从应用的角度上,看看并发read操作和write操作的Goroutine安全的必要性。
|
||||
|
||||
对于Read操作而言,由于TCP是面向字节流,conn.Read无法正确区分数据的业务边界,因此,多个Goroutine对同一个conn进行read的意义不大,Goroutine读到不完整的业务包,反倒增加了业务处理的难度。
|
||||
|
||||
但对于Write操作而言,倒是有多个Goroutine并发写的情况。不过conn读写是否是Goroutine安全的测试并不是很好做,我们先深入一下运行时代码,从理论上给这个问题定个性。
|
||||
|
||||
首先,net.conn只是*netFD 的外层包裹结构,最终Write和Read都会落在其中的fd字段上:
|
||||
|
||||
//$GOROOT/src/net/net.go
|
||||
type conn struct {
|
||||
fd *netFD
|
||||
}
|
||||
|
||||
|
||||
另外,netFD在不同平台上有着不同的实现,我们以net/fd_posix.go中的netFD为例看看:
|
||||
|
||||
// $GOROOT/src/net/fd_posix.go
|
||||
|
||||
// Network file descriptor.
|
||||
type netFD struct {
|
||||
pfd poll.FD
|
||||
|
||||
// immutable until Close
|
||||
family int
|
||||
sotype int
|
||||
isConnected bool // handshake completed or use of association with peer
|
||||
net string
|
||||
laddr Addr
|
||||
raddr Addr
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
netFD中最重要的字段是poll.FD类型的pfd,它用于表示一个网络连接。我也把它的结构摘录了一部分:
|
||||
|
||||
// $GOROOT/src/internal/poll/fd_unix.go
|
||||
|
||||
// FD is a file descriptor. The net and os packages use this type as a
|
||||
// field of a larger type representing a network connection or OS file.
|
||||
type FD struct {
|
||||
// Lock sysfd and serialize access to Read and Write methods.
|
||||
fdmu fdMutex
|
||||
|
||||
// System file descriptor. Immutable until Close.
|
||||
Sysfd int
|
||||
|
||||
// I/O poller.
|
||||
pd pollDesc
|
||||
|
||||
// Writev cache.
|
||||
iovecs *[]syscall.Iovec
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
我们看到,FD类型中包含了一个运行时实现的fdMutex类型字段。从它的注释来看,这个fdMutex用来串行化对字段Sysfd的Write和Read操作。也就是说,所有对这个FD所代表的连接的Read和Write操作,都是由fdMutex来同步的。从FD的Read和Write方法的实现,也证实了这一点:
|
||||
|
||||
// $GOROOT/src/internal/poll/fd_unix.go
|
||||
|
||||
func (fd *FD) Read(p []byte) (int, error) {
|
||||
if err := fd.readLock(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer fd.readUnlock()
|
||||
if len(p) == 0 {
|
||||
// If the caller wanted a zero byte read, return immediately
|
||||
// without trying (but after acquiring the readLock).
|
||||
// Otherwise syscall.Read returns 0, nil which looks like
|
||||
// io.EOF.
|
||||
// TODO(bradfitz): make it wait for readability? (Issue 15735)
|
||||
return 0, nil
|
||||
}
|
||||
if err := fd.pd.prepareRead(fd.isFile); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if fd.IsStream && len(p) > maxRW {
|
||||
p = p[:maxRW]
|
||||
}
|
||||
for {
|
||||
n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
|
||||
if err != nil {
|
||||
n = 0
|
||||
if err == syscall.EAGAIN && fd.pd.pollable() {
|
||||
if err = fd.pd.waitRead(fd.isFile); err == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
err = fd.eofError(n, err)
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
|
||||
func (fd *FD) Write(p []byte) (int, error) {
|
||||
if err := fd.writeLock(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer fd.writeUnlock()
|
||||
if err := fd.pd.prepareWrite(fd.isFile); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var nn int
|
||||
for {
|
||||
max := len(p)
|
||||
if fd.IsStream && max-nn > maxRW {
|
||||
max = nn + maxRW
|
||||
}
|
||||
n, err := ignoringEINTRIO(syscall.Write, fd.Sysfd, p[nn:max])
|
||||
if n > 0 {
|
||||
nn += n
|
||||
}
|
||||
if nn == len(p) {
|
||||
return nn, err
|
||||
}
|
||||
if err == syscall.EAGAIN && fd.pd.pollable() {
|
||||
if err = fd.pd.waitWrite(fd.isFile); err == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nn, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nn, io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
你看,每次Write操作都是受lock保护,直到这次数据全部写完才会解锁。因此,在应用层面,要想保证多个Goroutine在一个conn上write操作是安全的,需要一次write操作完整地写入一个“业务包”。一旦将业务包的写入拆分为多次write,那也无法保证某个Goroutine的某“业务包”数据在conn发送的连续性。
|
||||
|
||||
同时,我们也可以看出即便是Read操作,也是有lock保护的。多个Goroutine对同一conn的并发读,不会出现读出内容重叠的情况,但就像前面讲并发读的必要性时说的那样,一旦采用了不恰当长度的切片作为buf,很可能读出不完整的业务包,这反倒会带来业务上的处理难度。
|
||||
|
||||
比如一个完整数据包:world,当Goroutine的读缓冲区长度 < 5时,就存在这样一种可能:一个Goroutine读出了“worl”,而另外一个Goroutine读出了”d”。
|
||||
|
||||
最后我们再来看看Socket关闭。
|
||||
|
||||
Socket关闭
|
||||
|
||||
通常情况下,当客户端需要断开与服务端的连接时,客户端会调用net.Conn的Close方法关闭与服务端通信的Socket。如果客户端主动关闭了Socket,那么服务端的Read调用将会读到什么呢?这里要分“有数据关闭”和“无数据关闭”两种情况。
|
||||
|
||||
“有数据关闭”是指在客户端关闭连接(Socket)时,Socket中还有服务端尚未读取的数据。在这种情况下,服务端的Read会成功将剩余数据读取出来,最后一次Read操作将得到io.EOF错误码,表示客户端已经断开了连接。如果是在“无数据关闭”情形下,服务端调用的Read方法将直接返回io.EOF。
|
||||
|
||||
不过因为Socket是全双工的,客户端关闭Socket后,如果服务端Socket尚未关闭,这个时候服务端向Socket的写入操作依然可能会成功,因为数据会成功写入己方的内核socket缓冲区中,即便最终发不到对方socket缓冲区也会这样。因此,当发现对方socket关闭后,己方应该正确合理处理自己的socket,再继续write已经没有任何意义了。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,从这一讲开始我们开启了实战篇的学习。
|
||||
|
||||
在实战篇中,我会带着你“走完最后一公里”,所谓“最后一公里”,我的理解是从空有一身Go“绝技”到可以解决实际问题的进化,在这个过程中,我们需要怎么做?我们可以跟着理解问题、技术预研与储备,以及设计、实现与优化这三个循环解决思路,完成这个进化。
|
||||
|
||||
这一讲,我们的实际问题聚焦在实现一个基于TCP的自定义应用层协议的通信服务端,我们分析了通信协议与通信过程,并识别出若干技术点,其中以socket编程模型与网络I/O操作为重点,对这两个技术点进行了预研与储备。
|
||||
|
||||
虽然目前主流socket网络编程模型是I/O多路复用模型,但考虑到这个模型在使用时的体验较差,Go语言将这种复杂性隐藏到运行时层,并结合Goroutine的轻量级特性,在用户层提供了基于I/O阻塞模型的Go socket网络编程模型,这一模型就大大降低了gopher在编写socket应用程序时的心智负担。
|
||||
|
||||
而且,Go在net包中提供了针对socket的各种操作函数与方法,在这一讲中我们详细分析了其中的重要函数的使用,以及这些函数在特殊场景下需要注意的事项,你一定要掌握这一部分,因为这是我们下一讲进行设计与实现的根基与铺垫。
|
||||
|
||||
思考题
|
||||
|
||||
这一讲内容比较多,针对Go net包提供的各种操作,我建议你自己编写代码,逐个去实现这一讲中各个操作里的示例代码,为下一讲做好充分的准备。
|
||||
|
||||
欢迎你把这节课分享给更多感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
744
专栏/TonyBai·Go语言第一课/37代码操练:怎么实现一个TCP服务器?(中).md
Normal file
744
专栏/TonyBai·Go语言第一课/37代码操练:怎么实现一个TCP服务器?(中).md
Normal file
@@ -0,0 +1,744 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
37 代码操练:怎么实现一个TCP服务器?(中)
|
||||
你好,我是Tony Bai。
|
||||
|
||||
上一讲中,我们讲解了解决Go语言学习“最后一公里”的实用思路,那就是“理解问题” -> “技术预研与储备” -> “设计与实现”的三角循环,并且我们也完成了“理解问题”和“技术预研与储备”这两个环节,按照“三角循环”中的思路,这一讲我们应该针对实际问题进行一轮设计与实现了。
|
||||
|
||||
今天,我们的目标是实现一个基于TCP的自定义应用层协议的通信服务端,要完成这一目标,我们需要建立协议的抽象、实现协议的打包与解包、服务端的组装、验证与优化等工作。一步一步来,我们先在程序世界建立一个对上一讲中自定义应用层协议的抽象。
|
||||
|
||||
建立对协议的抽象
|
||||
|
||||
程序是对现实世界的抽象。对于现实世界的自定义应用协议规范,我们需要在程序世界建立起对这份协议的抽象。在进行抽象之前,我们先建立这次实现要用的源码项目tcp-server-demo1,建立的步骤如下:
|
||||
|
||||
$mkdir tcp-server-demo1
|
||||
$cd tcp-server-demo1
|
||||
$go mod init github.com/bigwhite/tcp-server-demo1
|
||||
go: creating new go.mod: module github.com/bigwhite/tcp-server-demo1
|
||||
|
||||
|
||||
为了方便学习,我这里再将上一讲中的自定义协议规范贴出来对照参考:
|
||||
|
||||
|
||||
|
||||
深入协议字段
|
||||
|
||||
上一讲,我们没有深入到协议规范中对协议的各个字段进行讲解,但在建立抽象之前,我们有必要了解一下各个字段的具体含义。
|
||||
|
||||
这是一个高度简化的、基于二进制模式定义的协议。二进制模式定义的特点,就是采用长度字段标识独立数据包的边界。
|
||||
|
||||
在这个协议规范中,我们看到:请求包和应答包的第一个字段(totalLength)都是包的总长度,它就是用来标识包边界的那个字段,也是在应用层用于“分割包”的最重要字段。
|
||||
|
||||
请求包与应答包的第二个字段也一样,都是commandID,这个字段用于标识包类型,这里我们定义四种包类型:
|
||||
|
||||
|
||||
连接请求包(值为0x01)
|
||||
消息请求包(值为0x02)
|
||||
连接响应包(值为0x81)
|
||||
消息响应包(值为0x82)
|
||||
|
||||
|
||||
换为对应的代码就是:
|
||||
|
||||
const (
|
||||
CommandConn = iota + 0x01 // 0x01,连接请求包
|
||||
CommandSubmit // 0x02,消息请求包
|
||||
)
|
||||
|
||||
const (
|
||||
CommandConnAck = iota + 0x81 // 0x81,连接请求的响应包
|
||||
CommandSubmitAck // 0x82,消息请求的响应包
|
||||
)
|
||||
|
||||
|
||||
请求包与应答包的第三个字段都是ID,ID是每个连接上请求包的消息流水号,顺序累加,步长为1,循环使用,多用来请求发送方后匹配响应包,所以要求一对请求与响应消息的流水号必须相同。
|
||||
|
||||
请求包与响应包唯一的不同之处,就在于最后一个字段:请求包定义了有效载荷(payload),这个字段承载了应用层需要的业务数据;而响应包则定义了请求包的响应状态字段(result),这里其实简化了响应状态字段的取值,成功的响应用0表示,如果是失败的响应,无论失败原因是什么,我们都用1来表示。
|
||||
|
||||
明确了应用层协议的各个字段定义之后,我们接下来就看看如何建立起对这个协议的抽象。
|
||||
|
||||
建立Frame和Packet抽象
|
||||
|
||||
首先我们要知道,TCP连接上的数据是一个没有边界的字节流,但在业务层眼中,没有字节流,只有各种协议消息。因此,无论是从客户端到服务端,还是从服务端到客户端,业务层在连接上看到的都应该是一个挨着一个的协议消息流。
|
||||
|
||||
现在我们建立第一个抽象:Frame。每个Frame表示一个协议消息,这样在业务层眼中,连接上的字节流就是由一个接着一个Frame组成的,如下图所示:
|
||||
|
||||
|
||||
|
||||
我们的自定义协议就封装在这一个个的Frame中。协议规定了将Frame分割开来的方法,那就是利用每个Frame开始处的totalLength,每个Frame由一个totalLength和Frame的负载(payload)构成,比如你可以看看下图中左侧的Frame结构:
|
||||
|
||||
|
||||
|
||||
这样,我们通过Frame header: totalLength就可以将Frame之间隔离开来。
|
||||
|
||||
在这个基础上,我们建立协议的第二个抽象:Packet。我们将Frame payload定义为一个Packet。上图右侧展示的就是Packet的结构。
|
||||
|
||||
Packet就是业务层真正需要的消息,每个Packet由Packet头和Packet Body部分组成。Packet头就是commandID,用于标识这个消息的类型;而ID和payload(packet payload)或result字段组成了Packet的Body部分,对业务层有价值的数据都包含在Packet Body部分。
|
||||
|
||||
那么到这里,我们就通过Frame和Packet两个类型结构,完成了程序世界对我们私有协议规范的抽象。接下来,我们要做的就是基于Frame和Packet这两个概念,实现对我们私有协议的解包与打包操作。
|
||||
|
||||
协议的解包与打包
|
||||
|
||||
所谓协议的解包(decode),就是指识别TCP连接上的字节流,将一组字节“转换”成一个特定类型的协议消息结构,然后这个消息结构会被业务处理逻辑使用。
|
||||
|
||||
而打包(encode)刚刚好相反,是指将一个特定类型的消息结构转换为一组字节,然后这组字节数据会被放在连接上发送出去。
|
||||
|
||||
具体到我们这个自定义协议上,解包就是指字节流 -> Frame,打包是指Frame -> 字节流。你可以看一下针对这个协议的服务端解包与打包的流程图:
|
||||
|
||||
|
||||
|
||||
我们看到,TCP流数据先后经过frame decode和packet decode,得到应用层所需的packet数据,而业务层回复的响应,则先后经过packet的encode与frame的encode,写入TCP数据流中。
|
||||
|
||||
到这里,我们实际上已经完成了协议抽象的设计与解包打包原理的设计过程了。接下来,我们先来看看私有协议部分的相关代码实现。
|
||||
|
||||
Frame的实现
|
||||
|
||||
前面说过,协议部分最重要的两个抽象是Frame和Packet,于是我们就在项目中建立frame包与packet包,分别与两个协议抽象对应。frame包的职责是提供识别TCP流边界的编解码器,我们可以很容易为这样的编解码器,定义出一个统一的接口类型StreamFrameCodec:
|
||||
|
||||
// tcp-server-demo1/frame/frame.go
|
||||
|
||||
type FramePayload []byte
|
||||
|
||||
type StreamFrameCodec interface {
|
||||
Encode(io.Writer, FramePayload) error // data -> frame,并写入io.Writer
|
||||
Decode(io.Reader) (FramePayload, error) // 从io.Reader中提取frame payload,并返回给上层
|
||||
}
|
||||
|
||||
|
||||
StreamFrameCodec接口类型有两个方法Encode与Decode。Encode方法用于将输入的Frame payload编码为一个Frame,然后写入io.Writer所代表的输出(outbound)TCP流中。而Decode方法正好相反,它从代表输入(inbound)TCP流的io.Reader中读取一个完整Frame,并将得到的Frame payload解析出来并返回。
|
||||
|
||||
这里,我们给出一个针对我们协议的StreamFrameCodec接口的实现:
|
||||
|
||||
// tcp-server-demo1/frame/frame.go
|
||||
|
||||
var ErrShortWrite = errors.New("short write")
|
||||
var ErrShortRead = errors.New("short read")
|
||||
|
||||
type myFrameCodec struct{}
|
||||
|
||||
func NewMyFrameCodec() StreamFrameCodec {
|
||||
return &myFrameCodec{}
|
||||
}
|
||||
|
||||
func (p *myFrameCodec) Encode(w io.Writer, framePayload FramePayload) error {
|
||||
var f = framePayload
|
||||
var totalLen int32 = int32(len(framePayload)) + 4
|
||||
|
||||
err := binary.Write(w, binary.BigEndian, &totalLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n, err := w.Write([]byte(f)) // write the frame payload to outbound stream
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if n != len(framePayload) {
|
||||
return ErrShortWrite
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *myFrameCodec) Decode(r io.Reader) (FramePayload, error) {
|
||||
var totalLen int32
|
||||
err := binary.Read(r, binary.BigEndian, &totalLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buf := make([]byte, totalLen-4)
|
||||
n, err := io.ReadFull(r, buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if n != int(totalLen-4) {
|
||||
return nil, ErrShortRead
|
||||
}
|
||||
|
||||
return FramePayload(buf), nil
|
||||
}
|
||||
|
||||
|
||||
在在这段实现中,有三点事项需要我们注意:
|
||||
|
||||
|
||||
网络字节序使用大端字节序(BigEndian),因此无论是Encode还是Decode,我们都是用binary.BigEndian;
|
||||
binary.Read或Write会根据参数的宽度,读取或写入对应的字节个数的字节,这里totalLen使用int32,那么Read或Write只会操作数据流中的4个字节;
|
||||
这里没有设置网络I/O操作的Deadline,io.ReadFull一般会读满你所需的字节数,除非遇到EOF或ErrUnexpectedEOF。
|
||||
|
||||
|
||||
在工程实践中,保证打包与解包正确的最有效方式就是编写单元测试,StreamFrameCodec接口的Decode和Encode方法的参数都是接口类型,这让我们可以很容易为StreamFrameCodec接口的实现编写测试用例。下面是我为myFrameCodec编写了两个测试用例:
|
||||
|
||||
// tcp-server-demo1/frame/frame_test.go
|
||||
|
||||
func TestEncode(t *testing.T) {
|
||||
codec := NewMyFrameCodec()
|
||||
buf := make([]byte, 0, 128)
|
||||
rw := bytes.NewBuffer(buf)
|
||||
|
||||
err := codec.Encode(rw, []byte("hello"))
|
||||
if err != nil {
|
||||
t.Errorf("want nil, actual %s", err.Error())
|
||||
}
|
||||
|
||||
// 验证Encode的正确性
|
||||
var totalLen int32
|
||||
err = binary.Read(rw, binary.BigEndian, &totalLen)
|
||||
if err != nil {
|
||||
t.Errorf("want nil, actual %s", err.Error())
|
||||
}
|
||||
|
||||
if totalLen != 9 {
|
||||
t.Errorf("want 9, actual %d", totalLen)
|
||||
}
|
||||
|
||||
left := rw.Bytes()
|
||||
if string(left) != "hello" {
|
||||
t.Errorf("want hello, actual %s", string(left))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode(t *testing.T) {
|
||||
codec := NewMyFrameCodec()
|
||||
data := []byte{0x0, 0x0, 0x0, 0x9, 'h', 'e', 'l', 'l', 'o'}
|
||||
|
||||
payload, err := codec.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Errorf("want nil, actual %s", err.Error())
|
||||
}
|
||||
|
||||
if string(payload) != "hello" {
|
||||
t.Errorf("want hello, actual %s", string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们看到,测试Encode方法,我们其实不需要建立真实的网络连接,只要用一个满足io.Writer的bytes.Buffer实例“冒充”真实网络连接就可以了,同时bytes.Buffer类型也实现了io.Reader接口,我们可以很方便地从中读取出Encode后的内容,并进行校验比对。
|
||||
|
||||
为了提升测试覆盖率,我们还需要尽可能让测试覆盖到所有可测的错误执行分支上。这里,我模拟了Read或Write出错的情况,让执行流进入到Decode或Encode方法的错误分支中:
|
||||
|
||||
type ReturnErrorWriter struct {
|
||||
W io.Writer
|
||||
Wn int // 第几次调用Write返回错误
|
||||
wc int // 写操作次数计数
|
||||
}
|
||||
|
||||
func (w *ReturnErrorWriter) Write(p []byte) (n int, err error) {
|
||||
w.wc++
|
||||
if w.wc >= w.Wn {
|
||||
return 0, errors.New("write error")
|
||||
}
|
||||
return w.W.Write(p)
|
||||
}
|
||||
|
||||
type ReturnErrorReader struct {
|
||||
R io.Reader
|
||||
Rn int // 第几次调用Read返回错误
|
||||
rc int // 读操作次数计数
|
||||
}
|
||||
|
||||
func (r *ReturnErrorReader) Read(p []byte) (n int, err error) {
|
||||
r.rc++
|
||||
if r.rc >= r.Rn {
|
||||
return 0, errors.New("read error")
|
||||
}
|
||||
return r.R.Read(p)
|
||||
}
|
||||
|
||||
func TestEncodeWithWriteFail(t *testing.T) {
|
||||
codec := NewMyFrameCodec()
|
||||
buf := make([]byte, 0, 128)
|
||||
w := bytes.NewBuffer(buf)
|
||||
|
||||
// 模拟binary.Write返回错误
|
||||
err := codec.Encode(&ReturnErrorWriter{
|
||||
W: w,
|
||||
Wn: 1,
|
||||
}, []byte("hello"))
|
||||
if err == nil {
|
||||
t.Errorf("want non-nil, actual nil")
|
||||
}
|
||||
|
||||
// 模拟w.Write返回错误
|
||||
err = codec.Encode(&ReturnErrorWriter{
|
||||
W: w,
|
||||
Wn: 2,
|
||||
}, []byte("hello"))
|
||||
if err == nil {
|
||||
t.Errorf("want non-nil, actual nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWithReadFail(t *testing.T) {
|
||||
codec := NewMyFrameCodec()
|
||||
data := []byte{0x0, 0x0, 0x0, 0x9, 'h', 'e', 'l', 'l', 'o'}
|
||||
|
||||
// 模拟binary.Read返回错误
|
||||
_, err := codec.Decode(&ReturnErrorReader{
|
||||
R: bytes.NewReader(data),
|
||||
Rn: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("want non-nil, actual nil")
|
||||
}
|
||||
|
||||
// 模拟io.ReadFull返回错误
|
||||
_, err = codec.Decode(&ReturnErrorReader{
|
||||
R: bytes.NewReader(data),
|
||||
Rn: 2,
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("want non-nil, actual nil")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
为了实现错误分支的测试,我们在测试代码源文件中创建了两个类型:ReturnErrorWriter和ReturnErrorReader,它们分别实现了io.Writer与io.Reader。
|
||||
|
||||
我们可以控制在第几次调用这两个类型的Write或Read方法时,返回错误,这样就可以让Encode或Decode方法按照我们的意图,进入到不同错误分支中去。有了这两个用例,我们的frame包的测试覆盖率(通过go test -cover .可以查看)就可以达到90%以上了。
|
||||
|
||||
Packet的实现
|
||||
|
||||
接下来,我们再看看Packet这个抽象的实现。和Frame不同,Packet有多种类型(这里只定义了Conn、submit、connack、submit ack)。所以我们要先抽象一下这些类型需要遵循的共同接口:
|
||||
|
||||
// tcp-server-demo1/packet/packet.go
|
||||
|
||||
type Packet interface {
|
||||
Decode([]byte) error // []byte -> struct
|
||||
Encode() ([]byte, error) // struct -> []byte
|
||||
}
|
||||
|
||||
|
||||
其中,Decode是将一段字节流数据解码为一个Packet类型,可能是conn,可能是submit等,具体我们要根据解码出来的commandID判断。而Encode则是将一个Packet类型编码为一段字节流数据。
|
||||
|
||||
考虑到篇幅与复杂性,我们这里只完成submit和submitack类型的Packet接口实现,省略了conn流程,也省略conn以及connack类型的实现,你可以课后自己思考一下有conn流程时代码应该如何调整。
|
||||
|
||||
// tcp-server-demo1/packet/packet.go
|
||||
|
||||
type Submit struct {
|
||||
ID string
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
func (s *Submit) Decode(pktBody []byte) error {
|
||||
s.ID = string(pktBody[:8])
|
||||
s.Payload = pktBody[8:]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Submit) Encode() ([]byte, error) {
|
||||
return bytes.Join([][]byte{[]byte(s.ID[:8]), s.Payload}, nil), nil
|
||||
}
|
||||
|
||||
type SubmitAck struct {
|
||||
ID string
|
||||
Result uint8
|
||||
}
|
||||
|
||||
func (s *SubmitAck) Decode(pktBody []byte) error {
|
||||
s.ID = string(pktBody[0:8])
|
||||
s.Result = uint8(pktBody[8])
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SubmitAck) Encode() ([]byte, error) {
|
||||
return bytes.Join([][]byte{[]byte(s.ID[:8]), []byte{s.Result}}, nil), nil
|
||||
}
|
||||
|
||||
|
||||
这里各种类型的编解码被调用的前提,是明确数据流是什么类型的,因此我们需要在包级提供一个导出的函数Decode,这个函数负责从字节流中解析出对应的类型(根据commandID),并调用对应类型的Decode方法:
|
||||
|
||||
// tcp-server-demo1/packet/packet.go
|
||||
|
||||
func Decode(packet []byte) (Packet, error) {
|
||||
commandID := packet[0]
|
||||
pktBody := packet[1:]
|
||||
|
||||
switch commandID {
|
||||
case CommandConn:
|
||||
return nil, nil
|
||||
case CommandConnAck:
|
||||
return nil, nil
|
||||
case CommandSubmit:
|
||||
s := Submit{}
|
||||
err := s.Decode(pktBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
case CommandSubmitAck:
|
||||
s := SubmitAck{}
|
||||
err := s.Decode(pktBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown commandID [%d]", commandID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
同样,我们也需要包级的Encode函数,根据传入的packet类型调用对应的Encode方法实现对象的编码:
|
||||
|
||||
// tcp-server-demo1/packet/packet.go
|
||||
|
||||
func Encode(p Packet) ([]byte, error) {
|
||||
var commandID uint8
|
||||
var pktBody []byte
|
||||
var err error
|
||||
|
||||
switch t := p.(type) {
|
||||
case *Submit:
|
||||
commandID = CommandSubmit
|
||||
pktBody, err = p.Encode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *SubmitAck:
|
||||
commandID = CommandSubmitAck
|
||||
pktBody, err = p.Encode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown type [%s]", t)
|
||||
}
|
||||
return bytes.Join([][]byte{[]byte{commandID}, pktBody}, nil), nil
|
||||
}
|
||||
|
||||
|
||||
不过,对packet包中各个类型的Encode和Decode方法的测试,与frame包的相似,这里我就把为packet包编写单元测试的任务就交给你自己完成了,如果有什么问题欢迎在留言区留言。
|
||||
|
||||
好了,万事俱备,只欠东风!下面我们就来编写服务端的程序结构,将tcp conn与Frame、Packet连接起来。
|
||||
|
||||
服务端的组装
|
||||
|
||||
在上一讲中,我们按照每个连接一个Goroutine的模型,给出了典型Go网络服务端程序的结构,这里我们就以这个结构为基础,将Frame、Packet加进来,形成我们的第一版服务端实现:
|
||||
|
||||
// tcp-server-demo1/cmd/server/main.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/bigwhite/tcp-server-demo1/frame"
|
||||
"github.com/bigwhite/tcp-server-demo1/packet"
|
||||
)
|
||||
|
||||
func handlePacket(framePayload []byte) (ackFramePayload []byte, err error) {
|
||||
var p packet.Packet
|
||||
p, err = packet.Decode(framePayload)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: packet decode error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch p.(type) {
|
||||
case *packet.Submit:
|
||||
submit := p.(*packet.Submit)
|
||||
fmt.Printf("recv submit: id = %s, payload=%s\n", submit.ID, string(submit.Payload))
|
||||
submitAck := &packet.SubmitAck{
|
||||
ID: submit.ID,
|
||||
Result: 0,
|
||||
}
|
||||
ackFramePayload, err = packet.Encode(submitAck)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: packet encode error:", err)
|
||||
return nil, err
|
||||
}
|
||||
return ackFramePayload, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown packet type")
|
||||
}
|
||||
}
|
||||
|
||||
func handleConn(c net.Conn) {
|
||||
defer c.Close()
|
||||
frameCodec := frame.NewMyFrameCodec()
|
||||
|
||||
for {
|
||||
// decode the frame to get the payload
|
||||
framePayload, err := frameCodec.Decode(c)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: frame decode error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// do something with the packet
|
||||
ackFramePayload, err := handlePacket(framePayload)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: handle packet error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// write ack frame to the connection
|
||||
err = frameCodec.Encode(c, ackFramePayload)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: frame encode error:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
l, err := net.Listen("tcp", ":8888")
|
||||
if err != nil {
|
||||
fmt.Println("listen error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
fmt.Println("accept error:", err)
|
||||
break
|
||||
}
|
||||
// start a new goroutine to handle the new connection.
|
||||
go handleConn(c)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这个程序的逻辑非常清晰,服务端程序监听8888端口,并在每次调用Accept方法后得到一个新连接,服务端程序将这个新连接交到一个新的Goroutine中处理。
|
||||
|
||||
新Goroutine的主函数为handleConn,有了Packet和Frame这两个抽象的加持,这个函数同样拥有清晰的代码调用结构:
|
||||
|
||||
// handleConn的调用结构
|
||||
|
||||
read frame from conn
|
||||
->frame decode
|
||||
-> handle packet
|
||||
-> packet decode
|
||||
-> packet(ack) encode
|
||||
->frame(ack) encode
|
||||
write ack frame to conn
|
||||
|
||||
|
||||
到这里,一个基于TCP的自定义应用层协议的经典阻塞式的服务端就完成了。不过这里的服务端依旧是一个简化的实现,比如我们这里没有考虑支持优雅退出、没有捕捉某个链接上出现的可能导致整个程序退出的panic等,这些我也想作为作业留给你。
|
||||
|
||||
接下来,我们就来验证一下这个服务端实现是否能正常工作。
|
||||
|
||||
验证测试
|
||||
|
||||
要验证服务端的实现是否可以正常工作,我们需要实现一个自定义应用层协议的客户端。这里,我们同样基于frame、packet两个包,实现了一个自定义应用层协议的客户端。下面是客户端的main函数:
|
||||
|
||||
// tcp-server-demo1/cmd/client/main.go
|
||||
func main() {
|
||||
var wg sync.WaitGroup
|
||||
var num int = 5
|
||||
|
||||
wg.Add(5)
|
||||
|
||||
for i := 0; i < num; i++ {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
startClient(i)
|
||||
}(i + 1)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
我们看到,客户端启动了5个Goroutine,模拟5个并发连接。startClient函数是每个连接的主处理函数,我们来看一下:
|
||||
|
||||
func startClient(i int) {
|
||||
quit := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
conn, err := net.Dial("tcp", ":8888")
|
||||
if err != nil {
|
||||
fmt.Println("dial error:", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
fmt.Printf("[client %d]: dial ok", i)
|
||||
|
||||
// 生成payload
|
||||
rng, err := codename.DefaultRNG()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
frameCodec := frame.NewMyFrameCodec()
|
||||
var counter int
|
||||
|
||||
go func() {
|
||||
// handle ack
|
||||
for {
|
||||
select {
|
||||
case <-quit:
|
||||
done <- struct{}{}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(time.Second * 5))
|
||||
ackFramePayLoad, err := frameCodec.Decode(conn)
|
||||
if err != nil {
|
||||
if e, ok := err.(net.Error); ok {
|
||||
if e.Timeout() {
|
||||
continue
|
||||
}
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
|
||||
p, err := packet.Decode(ackFramePayLoad)
|
||||
submitAck, ok := p.(*packet.SubmitAck)
|
||||
if !ok {
|
||||
panic("not submitack")
|
||||
}
|
||||
fmt.Printf("[client %d]: the result of submit ack[%s] is %d\n", i, submitAck.ID, submitAck.Result)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
// send submit
|
||||
counter++
|
||||
id := fmt.Sprintf("%08d", counter) // 8 byte string
|
||||
payload := codename.Generate(rng, 4)
|
||||
s := &packet.Submit{
|
||||
ID: id,
|
||||
Payload: []byte(payload),
|
||||
}
|
||||
|
||||
framePayload, err := packet.Encode(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("[client %d]: send submit id = %s, payload=%s, frame length = %d\n",
|
||||
i, s.ID, s.Payload, len(framePayload)+4)
|
||||
|
||||
err = frameCodec.Encode(conn, framePayload)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
if counter >= 10 {
|
||||
quit <- struct{}{}
|
||||
<-done
|
||||
fmt.Printf("[client %d]: exit ok", i)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
关于startClient函数,我们需要简单说明几点。
|
||||
|
||||
首先,startClient函数启动了两个Goroutine,一个负责向服务端发送submit消息请求,另外一个Goroutine则负责读取服务端返回的响应;
|
||||
|
||||
其次,客户端发送的submit请求的负载(payload)是由第三方包github.com/lucasepe/codename负责生成的,这个包会生成一些对人类可读的随机字符串,比如:firm-iron、 moving-colleen、game-nova这样的字符串;
|
||||
|
||||
另外,负责读取服务端返回响应的Goroutine,使用SetReadDeadline方法设置了读超时,这主要是考虑该Goroutine可以在收到退出通知时,能及时从Read阻塞中跳出来。
|
||||
|
||||
好了,现在我们就来构建和运行一下这两个程序。
|
||||
|
||||
我在tcp-server-demo1目录下提供了Makefile,如果你使用的是Linux或macOS操作系统,可以直接敲入make构建两个程序,如果你是在Windows下构建,可以直接敲入下面的go build命令构建:
|
||||
|
||||
$make
|
||||
go build github.com/bigwhite/tcp-server-demo1/cmd/server
|
||||
go build github.com/bigwhite/tcp-server-demo1/cmd/client
|
||||
|
||||
|
||||
构建成功后,我们先来启动server程序:
|
||||
|
||||
$./server
|
||||
server start ok(on *.8888)
|
||||
|
||||
|
||||
然后,我们启动client程序,启动后client程序便会向服务端建立5条连接,并发送submit请求,client端的部分日志如下:
|
||||
|
||||
$./client
|
||||
[client 5]: dial ok
|
||||
[client 1]: dial ok
|
||||
[client 5]: send submit id = 00000001, payload=credible-deathstrike-33e1, frame length = 38
|
||||
[client 3]: dial ok
|
||||
[client 1]: send submit id = 00000001, payload=helped-lester-8f15, frame length = 31
|
||||
[client 4]: dial ok
|
||||
[client 4]: send submit id = 00000001, payload=strong-timeslip-07fa, frame length = 33
|
||||
[client 3]: send submit id = 00000001, payload=wondrous-expediter-136e, frame length = 36
|
||||
[client 5]: the result of submit ack[00000001] is 0
|
||||
[client 1]: the result of submit ack[00000001] is 0
|
||||
[client 3]: the result of submit ack[00000001] is 0
|
||||
[client 2]: dial ok
|
||||
... ...
|
||||
[client 3]: send submit id = 00000010, payload=bright-monster-badoon-5719, frame length = 39
|
||||
[client 4]: send submit id = 00000010, payload=crucial-wallop-ec2d, frame length = 32
|
||||
[client 2]: send submit id = 00000010, payload=pro-caliban-c803, frame length = 29
|
||||
[client 1]: send submit id = 00000010, payload=legible-shredder-3d81, frame length = 34
|
||||
[client 5]: send submit id = 00000010, payload=settled-iron-monger-bf78, frame length = 37
|
||||
[client 3]: the result of submit ack[00000010] is 0
|
||||
[client 4]: the result of submit ack[00000010] is 0
|
||||
[client 1]: the result of submit ack[00000010] is 0
|
||||
[client 2]: the result of submit ack[00000010] is 0
|
||||
[client 5]: the result of submit ack[00000010] is 0
|
||||
[client 4]: exit ok
|
||||
[client 1]: exit ok
|
||||
[client 3]: exit ok
|
||||
[client 5]: exit ok
|
||||
[client 2]: exit ok
|
||||
|
||||
|
||||
client在每条连接上发送10个submit请求后退出。这期间服务端会输出如下日志:
|
||||
|
||||
recv submit: id = 00000001, payload=credible-deathstrike-33e1
|
||||
recv submit: id = 00000001, payload=helped-lester-8f15
|
||||
recv submit: id = 00000001, payload=wondrous-expediter-136e
|
||||
recv submit: id = 00000001, payload=strong-timeslip-07fa
|
||||
recv submit: id = 00000001, payload=delicate-leatherneck-4b12
|
||||
recv submit: id = 00000002, payload=certain-deadpool-779d
|
||||
recv submit: id = 00000002, payload=clever-vapor-25ce
|
||||
recv submit: id = 00000002, payload=causal-guardian-4f84
|
||||
recv submit: id = 00000002, payload=noted-tombstone-1b3e
|
||||
... ...
|
||||
recv submit: id = 00000010, payload=settled-iron-monger-bf78
|
||||
recv submit: id = 00000010, payload=pro-caliban-c803
|
||||
recv submit: id = 00000010, payload=legible-shredder-3d81
|
||||
handleConn: frame decode error: EOF
|
||||
handleConn: frame decode error: EOF
|
||||
handleConn: frame decode error: EOF
|
||||
handleConn: frame decode error: EOF
|
||||
handleConn: frame decode error: EOF
|
||||
|
||||
|
||||
从结果来看,我们实现的这一版服务端运行正常!
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在上一讲完成对socket编程模型、网络I/O操作的技术预研后,这一讲我们正式进入基于TCP的自定义应用层协议的通信服务端的设计与实现环节。
|
||||
|
||||
在这一环节中,我们首先建立了对协议的抽象,这是实现通信服务端的基石。我们使用Frame的概念来表示TCP字节流中的每一个协议消息,这使得在业务层的视角下,连接上的字节流就是由一个接着一个Frame组成的。接下来,我们又建立了第二个抽象Packet,来表示业务层真正需要的消息。
|
||||
|
||||
在这两个抽象的基础上,我们实现了frame与packet各自的打包与解包,整个实现是低耦合的,我们可以在对frame编写测试用例时体会到这一点。
|
||||
|
||||
最后,我们把上一讲提到的、一个Goroutine负责处理一个连接的典型Go网络服务端程序结构与frame、packet的实现组装到一起,就实现了我们的第一版服务端。之后,我们还编写了客户端模拟器对这个服务端的实现做了验证。
|
||||
|
||||
这个服务端采用的是Go经典阻塞I/O的编程模型,你是不是已经感受到了这种模型在开发阶段带来的好处了呢!
|
||||
|
||||
思考题
|
||||
|
||||
在这讲的中间部分,我已经把作业留给你了:
|
||||
|
||||
|
||||
为packet包编写单元测试;
|
||||
为我们的服务端增加优雅退出机制,以及捕捉某个链接上出现的可能导致整个程序退出的panic。
|
||||
|
||||
|
||||
项目的源代码在这里!
|
||||
|
||||
|
||||
|
||||
|
||||
620
专栏/TonyBai·Go语言第一课/38成果优化:怎么实现一个TCP服务器?(下).md
Normal file
620
专栏/TonyBai·Go语言第一课/38成果优化:怎么实现一个TCP服务器?(下).md
Normal file
@@ -0,0 +1,620 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
38 成果优化:怎么实现一个TCP服务器?(下)
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在上一讲中,我们初步实现了一个基于TCP的自定义应用层协议的通信服务端。对于一个常驻内存的服务端而言,更高的性能以及更低的资源消耗,始终是后端开发人员的追求。同时,更高性能的服务程序,也意味着在处理相同数量访问请求的前提下,我们使用的机器数量更少,这可是为公司节省真金白银的有效策略。
|
||||
|
||||
而且,Go语言最初设计时就被定位为“系统级编程语言”,这说明高性能也一直是Go核心团队的目标之一。很多来自动态类型语言的开发者转到Go语言,几乎都有着性能方面的考量。
|
||||
|
||||
所以,在实战篇的最后一讲,我们就结合上一讲实现的自定义应用层协议的通信服务端,看看优化Go程序使用的常用工具与套路,给你引引路。
|
||||
|
||||
Go程序优化的基本套路
|
||||
|
||||
Go程序的优化,也有着固定的套路可循,这里我将它整理成了这张示意图:
|
||||
|
||||
|
||||
|
||||
这张图也不难理解,我简单解释一下。
|
||||
|
||||
首先我们要建立性能基准。要想对程序实施优化,我们首先要有一个初始“参照物”,这样我们才能在执行优化措施后,检验优化措施是否有效,所以这是优化循环的第一步。
|
||||
|
||||
第二步是性能剖析。要想优化程序,我们首先要找到可能影响程序性能的“瓶颈点”,这一步的任务,就是通过各种工具和方法找到这些“瓶颈点”。
|
||||
|
||||
第三步是代码优化。我们要针对上一步找到的“瓶颈点”进行分析,找出它们成为瓶颈的原因,并有针对性地实施优化。
|
||||
|
||||
第四步是与基准比较,确定优化效果。这一步,我们会采集优化后的程序的性能数据,与第一步的性能基准进行比较,看执行上述的优化措施后,是否提升了程序的性能。
|
||||
|
||||
如果有提升,那就说明这一轮的优化是有效的。如果优化后的性能指标仍然没有达到预期,可以再执行一轮优化,这时我们就要用新的程序的性能指标作为新的性能基准,作为下一轮性能优化参考。
|
||||
|
||||
接下来我们就围绕这个优化循环,看看怎么对我们上一讲实现的自定义应用层协议的通信服务端进行优化。首先我们要做的是建立性能基准,这是Go应用性能优化的基础与前提。
|
||||
|
||||
建立性能基准
|
||||
|
||||
上一讲,我们已经初步实现了自定义应用层协议的通信服务端,那它的性能如何呢?
|
||||
|
||||
我们肯定不能拍脑门说这个程序性能很好、一般或很差吧?我们需要用数据说话,也就是为我们的Go程序建立性能基准。通过这个性能基准,我们不仅可以了解当前程序的性能水平,也可以据此判断后面的代码优化措施有没有起到效果。
|
||||
|
||||
建立性能基准的方式大概有两种,一种是通过编写Go原生提供的性能基准测试(benchmark test)用例来实现,这相当于对程序的局部热点建立性能基准,常用于一些算法或数据结构的实现,比如分布式全局唯一ID生成算法、树的插入/查找等。
|
||||
|
||||
另外一种是基于度量指标为程序建立起图形化的性能基准,这种方式适合针对程序的整体建立性能基准。而我们的自定义协议服务端程序就十分适合用这种方式,接下来我们就来看一下基于度量指标建立基准的一种可行方案。
|
||||
|
||||
建立观测设施
|
||||
|
||||
这些年,基于Web的可视化工具、开源监控系统以及时序数据库的兴起,给我们建立性能基准带来了很大的便利,业界有比较多成熟的工具组合可以直接使用。但业界最常用的还是Prometheus+Grafana的组合,这也是我日常使用比较多的组合,所以在这里我也使用这个工具组合来为我们的程序建立性能指标观测设施。
|
||||
|
||||
以Docker为代表的轻量级容器(container)的兴起,让这些工具的部署、安装都变得十分简单,这里我们就使用docker-compose工具,基于容器安装Prometheus+Grafana的组合。
|
||||
|
||||
我建议你使用一台Linux主机来安装这些工具,因为docker以及docker-compose工具,在Linux平台上的表现最为成熟稳定。我这里不再详细说明docker与docker-compose工具的安装方法了,你可以参考docker安装教程以及docker-compose安装教程自行在Linux上安装这两个工具。
|
||||
|
||||
这里我简单描述一下安装Prometheus+Grafana的组合的步骤。
|
||||
|
||||
首先,我们要在Linux主机上建立一个目录monitor,这个目录下,我们创建docker-compose.yml文件,它的内容是这样的:
|
||||
|
||||
version: "3.2"
|
||||
services:
|
||||
prometheus:
|
||||
container_name: prometheus
|
||||
image: prom/prometheus:latest
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
- ./conf/tcp-server-prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- /etc/localtime:/etc/localtime
|
||||
restart: on-failure
|
||||
|
||||
grafana:
|
||||
container_name: grafana
|
||||
image: grafana/grafana:latest
|
||||
network_mode: "host"
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime
|
||||
- ./data/grafana:/var/lib/grafana
|
||||
|
||||
# linux node_exporter
|
||||
node_exporter:
|
||||
image: quay.io/prometheus/node-exporter:latest
|
||||
restart: always
|
||||
container_name: node_exporter
|
||||
command:
|
||||
- '--path.rootfs=/host'
|
||||
network_mode: host
|
||||
pid: host
|
||||
volumes:
|
||||
- '/:/host:ro,rslave'
|
||||
|
||||
|
||||
docker-compose.yml是docker-compose工具的配置文件,基于这个配置文件,docker-compose工具会拉取对应容器镜像文件,并在本地启动对应的容器。
|
||||
|
||||
我们这个docker-compose.yml文件中包含了三个工具镜像,分别是Prometheus、Grafana与node-exporter。其中,node-exporter是prometheus开源的主机度量数据的采集工具,通过node exporter,我们可以采集到主机的CPU、内存、磁盘、网络I/O等主机运行状态数据。结合这些数据,我们可以查看我们的应用在运行时的系统资源占用情况。
|
||||
|
||||
docker-compose.yml中Prometheus容器挂载的tcp-server-prometheus.yml文件放在了monitor/conf下面,它的内容是这样:
|
||||
|
||||
global:
|
||||
scrape_interval: 5s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
|
||||
evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
|
||||
# scrape_timeout is set to the global default (10s).
|
||||
|
||||
# Alertmanager configuration
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets:
|
||||
# - alertmanager:9093
|
||||
|
||||
# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
|
||||
rule_files:
|
||||
# - "first_rules.yml"
|
||||
# - "second_rules.yml"
|
||||
|
||||
# A scrape configuration containing exactly one endpoint to scrape:
|
||||
# Here it's Prometheus itself.
|
||||
scrape_configs:
|
||||
# The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
|
||||
- job_name: "prometheus"
|
||||
# metrics_path defaults to '/metrics'
|
||||
# scheme defaults to 'http'.
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]
|
||||
|
||||
- job_name: "tcp-server"
|
||||
static_configs:
|
||||
- targets: ["localhost:8889"]
|
||||
|
||||
- job_name: "node"
|
||||
static_configs:
|
||||
- targets: ["localhost:9100"]
|
||||
|
||||
|
||||
我们看到,在上面Prometheus的配置文件的scrpae_configs下面,配置了三个采集job,分别用于采集Prometheus自身度量数据、我们的tcp server的度量数据,以及node-exporter的度量数据。
|
||||
|
||||
grafana容器会挂载本地的data/grafana路径到容器中,为了避免访问权限带来的问题,我们在创建data/grafana目录后,最好再为这个目录赋予足够的访问权限,比如:
|
||||
|
||||
$chmod -R 777 data
|
||||
|
||||
|
||||
运行下面命令,docker-compose就会自动拉取镜像,并启动docker-compose.yml中的三个容器:
|
||||
|
||||
$docker-compose -f docker-compose.yml up -d
|
||||
|
||||
|
||||
等待一段时间后,执行docker ps命令,如果你能看到下面三个正在运行的容器,就说明我们的安装就成功了:
|
||||
|
||||
$docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
563d655cdf90 grafana/grafana:latest "/run.sh" 26 hours ago Up 26 hours grafana
|
||||
65616d1b6d1a prom/prometheus:latest "/bin/prometheus --c…" 26 hours ago Up 26 hours prometheus
|
||||
b29d3fef8572 quay.io/prometheus/node-exporter:latest "/bin/node_exporter …" 26 hours ago Up 26 hours node_exporter
|
||||
|
||||
|
||||
为了更直观地了解到整个观测设施中各个工具之间的关系,我这里画了一幅示意图,对照着这幅图,你再来理解上面的配置与执行步骤会容易许多:
|
||||
|
||||
|
||||
|
||||
配置Grafana
|
||||
|
||||
一旦成功启动,Prometheus便会启动各个采集job,从tcp server以及node-exporter中拉取度量数据,并存储在其时序数据库中,这个时候我们需要对Grafana进行一些简单配置,才能让这些数据以图形化的方式展现出来。
|
||||
|
||||
我们首先需要为Grafana配置一个新的数据源(data source),在数据源选择页面,我们选择Prometheus,就像下图这样:
|
||||
|
||||
|
||||
|
||||
选择后,在Prometheus数据源配置页面,配置这个数据源的HTTP URL就可以了。如果你点击“Save & test”按钮后提示成功,那么数据源就配置好了。
|
||||
|
||||
接下来,我们再添加一个node-exporter仪表板(dashboard),把从node-exporter拉取的度量数据以图形化方式展示出来。这个时候我们不需要手工一个一个设置仪表板上的panel,Grafana官方有现成的node-exporter仪表板可用,我们只需要在grafana的import页面中输入相应的dashboard ID,就可以导入相关仪表板的设置:
|
||||
|
||||
|
||||
|
||||
这里,我们使用的是ID为1860的node-exporter仪表板,导入成功后,进入这个仪表板页面,等待一段时间后,我们就可以看到类似下面的可视化结果:
|
||||
|
||||
|
||||
|
||||
好了,到这里node-exporter的度量数据,已经可以以图形化的形式呈现在我们面前了,那么我们的自定义协议的服务端的数据又如何采集与呈现呢?我们继续向下看。
|
||||
|
||||
在服务端埋入度量数据采集点
|
||||
|
||||
前面说了,我们要建立服务端的性能基准,那么哪些度量数据能反映出服务端的性能指标呢?这里我们定义三个度量数据项:
|
||||
|
||||
|
||||
当前已连接的客户端数量(client_connected);
|
||||
每秒接收消息请求的数量(req_recv_rate);
|
||||
每秒发送消息响应的数量(rsp_send_rate)。
|
||||
|
||||
|
||||
那么如何在服务端的代码中埋入这三个度量数据项呢?
|
||||
|
||||
我们将上一讲的tcp-server-demo1项目拷贝一份,形成tcp-server-demo2项目,我们要在tcp-server-demo2项目中实现这三个度量数据项的采集。
|
||||
|
||||
我们在tcp-server-demo2下,创建新的metrics包负责定义度量数据项,metrics包的源码如下:
|
||||
|
||||
// tcp-server-demo2/metrics/metrics.go
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
ClientConnected prometheus.Gauge
|
||||
ReqRecvTotal prometheus.Counter
|
||||
RspSendTotal prometheus.Counter
|
||||
)
|
||||
|
||||
func init() {
|
||||
ReqRecvTotal = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "tcp_server_demo2_req_recv_total",
|
||||
})
|
||||
RspSendTotal = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "tcp_server_demo2_rsp_send_total",
|
||||
})
|
||||
|
||||
ClientConnected = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "tcp_server_demo2_client_connected",
|
||||
})
|
||||
|
||||
prometheus.MustRegister(ReqRecvTotal, RspSendTotal, ClientConnected)
|
||||
|
||||
// start the metrics server
|
||||
metricsServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", metricsHTTPPort),
|
||||
}
|
||||
|
||||
mu := http.NewServeMux()
|
||||
mu.Handle("/metrics", promhttp.Handler())
|
||||
metricsServer.Handler = mu
|
||||
go func() {
|
||||
err := metricsServer.ListenAndServe()
|
||||
if err != nil {
|
||||
fmt.Println("prometheus-exporter http server start failed:", err)
|
||||
}
|
||||
}()
|
||||
fmt.Println("metrics server start ok(*:8889)")
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,我们使用prometheus提供的go client包中的类型定义了三个度量数据项。其中ClientConnected的类型为prometheus.Gauge,Gauge是对一个数值的即时测量值,它反映一个值的瞬时快照;而ReqRecvTotal和RspSendTotal的类型都为prometheus.Counter。
|
||||
|
||||
Counter顾名思义,就是一个计数器,可以累加,也可以减少。不过要想反映我们预期的每秒处理能力的指标,我们还需要将这两个计数器与rate函数一起使用才行,这个我们稍后再说。
|
||||
|
||||
我们在metrics包的init函数中启动了一个http server,这个server监听8889端口,还记得我们前面prometheus配置文件中tcp-server job采集的目标地址吗?正是这个8889端口。也就是说,Prometheus定期从8889端口拉取我们的度量数据项的值。
|
||||
|
||||
有了metrics包以及度量数据项后,我们还需要将度量数据项埋到服务端的处理流程中,我们来看对main包的改造:
|
||||
|
||||
// tcp-server-demo2/cmd/server/main.go
|
||||
func handleConn(c net.Conn) {
|
||||
metrics.ClientConnected.Inc() // 连接建立,ClientConnected加1
|
||||
defer func() {
|
||||
metrics.ClientConnected.Dec() // 连接断开,ClientConnected减1
|
||||
c.Close()
|
||||
}()
|
||||
frameCodec := frame.NewMyFrameCodec()
|
||||
|
||||
for {
|
||||
// read from the connection
|
||||
|
||||
// decode the frame to get the payload
|
||||
// the payload is undecoded packet
|
||||
framePayload, err := frameCodec.Decode(c)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: frame decode error:", err)
|
||||
return
|
||||
}
|
||||
metrics.ReqRecvTotal.Add(1) // 收到并解码一个消息请求,ReqRecvTotal消息计数器加1
|
||||
|
||||
// do something with the packet
|
||||
ackFramePayload, err := handlePacket(framePayload)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: handle packet error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// write ack frame to the connection
|
||||
err = frameCodec.Encode(c, ackFramePayload)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: frame encode error:", err)
|
||||
return
|
||||
}
|
||||
metrics.RspSendTotal.Add(1) // 返回响应后,RspSendTotal消息计数器减1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
你可以看到,我们在每个连接的处理主函数handleConn中都埋入了各个度量数据项,并在特定事件发生时修改度量数据的值。
|
||||
|
||||
服务端建立完度量数据项后,我们还需要在Grafana中建立对应的仪表板来展示这些度量数据项,这一次,我们就需要手动创建仪表板tcp-server-demo,并为仪表板手动添加panel了。
|
||||
|
||||
我们建立三个panel:req_recv_rate、rsp_send_rate和client_connected,如下图所示:
|
||||
|
||||
|
||||
|
||||
client_connected panel比较简单,我们直接取tcp_server_demo2_client_connected这个注册到prometheus中的度量项的值就可以了。
|
||||
|
||||
而req_recv_rate和rsp_send_rate就要结合度量项的值与rate函数来实现。以req_recv_rate这个panel为例,它的panel配置是这样:
|
||||
|
||||
|
||||
|
||||
我们看到图中的Metrics Browser后面的表达式是:rate(tcp_server_demo2_req_recv_total[15s]),这个表达式返回的是在15秒内测得的req_recv_total的每秒速率,这恰恰是可以反映我们的服务端处理性能的指标。
|
||||
|
||||
好了,到这里,支持输出度量数据指标的服务端以及对应的grafana仪表板都准备好了。下面我们就来为服务端建立第一版的性能基准。
|
||||
|
||||
第一版性能基准
|
||||
|
||||
要建立性能基准,我们还需要一个可以对服务端程序“施加压力”的客户端模拟器,我们可以基于tcp-server-demo1/cmd/client实现这个模拟器。
|
||||
|
||||
新版模拟器的原理与tcp-server-demo1/cmd/client基本一致,所以具体的改造过程我这里就不多说了,新版模拟器的代码,我放在了tcp-server-demo2/cmd/client下面,你可以自行查看源码。
|
||||
|
||||
建立以及使用性能基准的前提,是服务端的压测的硬件条件要尽量保持一致,以保证得到的结果受外界干扰较少,性能基准才更有参考意义。我们在一个4核8G的Centos Linux主机上跑这个压力测试,后续的压测也是在同样的条件下。
|
||||
|
||||
压测的步骤很简单,首先在tcp-server-demo2下构建出server与client两个可执行程序。然后先启动server,再启动client。运行几分钟后,停掉程序就可以了,这时,我们在grafana的tcp-server的仪表板中,就能看到类似下面的图形化数据展示了:
|
||||
|
||||
|
||||
|
||||
从这张图中,我们大约看到服务端的处理性能大约在18.5w/秒左右,我们就将这个结果作为服务端的第一个性能基准。
|
||||
|
||||
尝试用pprof剖析
|
||||
|
||||
按照这一讲开头的Go应用性能优化循环的思路,我们接下来就应该尝试对我们的服务端做性能剖析,识别出瓶颈点。
|
||||
|
||||
Go是“自带电池”(battery included)的语言,拥有着让其他主流语言羡慕的工具链,Go同样也内置了对Go代码进行性能剖析的工具:pprof。
|
||||
|
||||
pprof源自Google Perf Tools工具套件,在Go发布早期就被集成到Go工具链中了,所以pprof也是Gopher最常用的、对Go应用进行性能剖析的工具。这里我们也使用这一工具对我们的服务端程序进行剖析。
|
||||
|
||||
Go应用支持pprof性能剖析的方式有多种,最受Gopher青睐的是通过导入net/http/pprof包的方式。我们改造一下tcp-server-demo2,让它通过这种方式支持pprof性能剖析。
|
||||
|
||||
改造后的代码放在tcp-server-demo2-with-pprof目录下,下面是支持pprof的main包的代码节选:
|
||||
|
||||
// tcp-server-demo2-with-pprof/cmd/server/main.go
|
||||
|
||||
import (
|
||||
... ...
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
... ...
|
||||
)
|
||||
|
||||
... ...
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
http.ListenAndServe(":6060", nil)
|
||||
}()
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
从这个代码变更可以看到,我们只需要以空导入的方式导入net/http/pprof包,并在一个单独的goroutine中启动一个标准的http服务,就可以实现对pprof性能剖析的支持。pprof工具可以通过6060端口采样到我们的Go程序的运行时数据。
|
||||
|
||||
接下来,我们就来进行性能剖析数据的采集。我们编译tcp-server-demo2-with-pprof目录下的server与client,先后启动server与client,让client对server保持持续的压力。
|
||||
|
||||
然后我们在自己的开发机上执行下面命令:
|
||||
|
||||
// 192.168.10.18为服务端的主机地址
|
||||
$go tool pprof -http=:9090 http://192.168.10.18:6060/debug/pprof/profile
|
||||
Fetching profile over HTTP from http://192.168.10.18:6060/debug/pprof/profile
|
||||
Saved profile in /Users/tonybai/pprof/pprof.server.samples.cpu.004.pb.gz
|
||||
Serving web UI on http://localhost:9090
|
||||
|
||||
|
||||
go tool pprof命令默认会从http://192.168.10.18:6060/debug/pprof/profile服务上,采集CPU类型的性能剖析数据,然后打开本地浏览器,默认显示如下页面:
|
||||
|
||||
|
||||
|
||||
debug/pprof/profile提供的是CPU的性能采样数据。CPU类型采样数据是性能剖析中最常见的采样数据类型。
|
||||
|
||||
一旦启用CPU数据采样,Go运行时会每隔一段短暂的时间(10ms)就中断一次(由SIGPROF信号引发),并记录当前所有goroutine的函数栈信息。它能帮助我们识别出代码关键路径上出现次数最多的函数,而往往这个函数就是程序的一个瓶颈。上图我们沿着粗红线向下看,我们会看到下面图中的信息:
|
||||
|
||||
|
||||
|
||||
我们看到图中间的Syscall函数占据了一个最大的方框,并用黑体标记了出来,这就是我们程序的第一个瓶颈:花费太多时间在系统调用上了。在向上寻找,我们发现Syscall的调用者基本都是网络read和write导致的。
|
||||
|
||||
代码优化
|
||||
|
||||
好了,第一个瓶颈点已经找到!我们该进入优化循环的第三个环节:代码优化了。那么该如何优化代码呢?我们可以分为两个部分来看。
|
||||
|
||||
带缓存的网络I/O
|
||||
|
||||
为什么网络read和write导致的Syscall会那么多呢?我们回顾一下第一版服务端的实现。
|
||||
|
||||
我们看到,在handleConn函数中,我们直接将net.Conn实例传给frame.Decode作为io.Reader参数的实参,这样,我们每次调用Read方法都是直接从net.Conn中读取数据,而Read将转变为一次系统调用(Syscall),哪怕是仅仅读取一个字节也是如此。因此,我们的优化目标是降低net.Conn的Write和Read的频率。
|
||||
|
||||
那么如何降低net.Conn的读写频率呢?增加缓存不失为一个有效的方法。而且,我们的服务端采用的是一个goroutine处理一个客户端连接的方式,由于没有竞态,这个模型更适合在读写net.Conn时使用带缓存的方式。
|
||||
|
||||
所以,下面我们就来为tcp-server-demo2增加net.Conn的缓存读与缓存写。优化后的代码我放在了tcp-server-demo3下:
|
||||
|
||||
// tcp-server-demo3/cmd/server/main.go
|
||||
|
||||
func handleConn(c net.Conn) {
|
||||
metrics.ClientConnected.Inc()
|
||||
defer func() {
|
||||
metrics.ClientConnected.Dec()
|
||||
c.Close()
|
||||
}()
|
||||
frameCodec := frame.NewMyFrameCodec()
|
||||
rbuf := bufio.NewReader(c)
|
||||
wbuf := bufio.NewWriter(c)
|
||||
|
||||
defer wbuf.Flush()
|
||||
for {
|
||||
// read from the connection
|
||||
|
||||
// decode the frame to get the payload
|
||||
// the payload is undecoded packet
|
||||
framePayload, err := frameCodec.Decode(rbuf)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: frame decode error:", err)
|
||||
return
|
||||
}
|
||||
metrics.ReqRecvTotal.Add(1)
|
||||
|
||||
// do something with the packet
|
||||
ackFramePayload, err := handlePacket(framePayload)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: handle packet error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// write ack frame to the connection
|
||||
err = frameCodec.Encode(wbuf, ackFramePayload)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: frame encode error:", err)
|
||||
return
|
||||
}
|
||||
metrics.RspSendTotal.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tcp-server-demo3唯一的改动,就是main包中的handleConn函数。在这个函数中,我们新增了一个读缓存变量(rbuf)和一个写缓存变量(wbuf),我们用这两个变量替换掉传给frameCodec.Decode和frameCodec.Encode的net.Conn参数。
|
||||
|
||||
以rbuf为例,我们来看看它是如何起到降低syscall调用频率的作用的。
|
||||
|
||||
将net.Conn改为rbuf后,frameCodec.Decode中的每次网络读取实际调用的都是bufio.Reader的Read方法。bufio.Reader.Read方法内部,每次从net.Conn尝试读取其内部缓存大小的数据,而不是用户传入的希望读取的数据大小。这些数据缓存在内存中,这样,后续的Read就可以直接从内存中得到数据,而不是每次都要从net.Conn读取,从而降低Syscall调用的频率。
|
||||
|
||||
我们对优化后的tcp-server-demo3做一次压测,看看它的处理性能到底有没有提升,压测的步骤你可以参考前面的内容。压测后,我们得到下面的结果:
|
||||
|
||||
|
||||
|
||||
从图中可以看到,优化后的服务端的处理性能提升到27w/s左右,相比于第一版性能基准(18.5w/s),性能提升了足有45%。
|
||||
|
||||
重用内存对象
|
||||
|
||||
前面这个带缓存的网络I/O,是我们从CPU性能采样数据中找到的“瓶颈点”。不过,在Go中还有另外一个十分重要的性能指标,那就是堆内存对象的分配。
|
||||
|
||||
因为Go是带有垃圾回收(GC)的语言,频繁的堆内存对象分配或分配较多,都会给GC带去较大压力,而GC的压力显然会转化为对CPU资源的消耗,从而挤压处理正常业务逻辑的goroutine的CPU时间。
|
||||
|
||||
下面我们就来采集一下tcp-server-demo2-with-pprof目录下的server的内存分配采样数据,看看有没有值得优化的点。
|
||||
|
||||
这次我们直接使用go tool pprof的命令行采集与交互模式。在启动server和client后,我们手工执行下面命令进行内存分配采样数据的获取:
|
||||
|
||||
$ go tool pprof http://192.168.10.18:6060/debug/pprof/allocs
|
||||
Fetching profile over HTTP from http://192.168.10.18:6060/debug/pprof/allocs
|
||||
Saved profile in /root/pprof/pprof.server.alloc_objects.alloc_space.inuse_objects.inuse_space.001.pb.gz
|
||||
File: server
|
||||
Type: alloc_space
|
||||
Time: Jan 23, 2022 at 6:05pm (CST)
|
||||
Entering interactive mode (type "help" for commands, "o" for options)
|
||||
|
||||
|
||||
数据获取到后,我们就可以使用go tool pprof提供的命令行交互指令,来查看各个函数的堆内存对象的分配情况,其中最常用的一个指令就是top,执行top后,我们得到如下结果:
|
||||
|
||||
(pprof) top
|
||||
Showing nodes accounting for 119.27MB, 97.93% of 121.79MB total
|
||||
Dropped 31 nodes (cum <= 0.61MB)
|
||||
Showing top 10 nodes out of 30
|
||||
flat flat% sum% cum cum%
|
||||
38MB 31.20% 31.20% 43.50MB 35.72% github.com/bigwhite/tcp-server-demo2/packet.Decode
|
||||
28.50MB 23.40% 54.61% 28.50MB 23.40% github.com/bigwhite/tcp-server-demo2/frame.(*myFrameCodec).Decode
|
||||
18MB 14.78% 69.39% 79MB 64.87% main.handlePacket
|
||||
17.50MB 14.37% 83.76% 17.50MB 14.37% bytes.Join
|
||||
9MB 7.39% 91.15% 9MB 7.39% encoding/binary.Write
|
||||
5.50MB 4.52% 95.66% 5.50MB 4.52% github.com/bigwhite/tcp-server-demo2/packet.(*Submit).Decode (inline)
|
||||
1.76MB 1.45% 97.11% 1.76MB 1.45% compress/flate.NewWriter
|
||||
1MB 0.82% 97.93% 1MB 0.82% runtime.malg
|
||||
0 0% 97.93% 1.76MB 1.45% bufio.(*Writer).Flush
|
||||
0 0% 97.93% 1.76MB 1.45% compress/gzip.(*Writer).Write
|
||||
|
||||
|
||||
top命令的输出结果默认按flat(flat%)列从大到小的顺序输出。flat列的值在不同采样类型下表示的含义略有不同。
|
||||
|
||||
在CPU类型采样数据下,它表示函数自身代码在数据采样过程的执行时长;在上面的堆内存分配类型采样数据下,它表示在采用过程中,某个函数中堆内存分配大小的和。而flat%列的值表示这个函数堆内存分配大小占堆内存总分配大小的比例。
|
||||
|
||||
从上面的输出结果来看,packet.Decode函数排在第一位。那么,现在我们就来深入探究一下Decode函数中究竟哪一行代码分配的堆内存量最大。我们使用list命令可以进一步进入Decode函数的源码中查看:
|
||||
|
||||
(pprof) list packet.Decode
|
||||
Total: 121.79MB
|
||||
ROUTINE ======================== github.com/bigwhite/tcp-server-demo2/packet.Decode in /root/baim/tcp-server-demo2-with-pprof/packet/packet.go
|
||||
38MB 43.50MB (flat, cum) 35.72% of Total
|
||||
. . 75: case CommandConn:
|
||||
. . 76: return nil, nil
|
||||
. . 77: case CommandConnAck:
|
||||
. . 78: return nil, nil
|
||||
. . 79: case CommandSubmit:
|
||||
38MB 38MB 80: s := Submit{}
|
||||
. 5.50MB 81: err := s.Decode(pktBody)
|
||||
. . 82: if err != nil {
|
||||
. . 83: return nil, err
|
||||
. . 84: }
|
||||
. . 85: return &s, nil
|
||||
. . 86: case CommandSubmitAck:
|
||||
(pprof)
|
||||
|
||||
|
||||
我们看到,s := Submit{}这一行是分配内存的“大户”,每次服务端收到一个客户端submit请求时,都会在堆上分配一块内存表示Submit类型的实例。
|
||||
|
||||
这个在程序关键路径上的堆内存对象分配会给GC带去压力,我们要尽量避免或减小它的分配频度,一个可行的办法是尽量重用对象。
|
||||
|
||||
在Go中,一提到重用内存对象,我们就会想到了sync.Pool。简单来说,sync.Pool就是官方实现的一个可复用的内存对象池,使用sync.Pool,我们可以减少堆对象分配的频度,进而降低给GC带去的压力。
|
||||
|
||||
我们继续在tcp-server-demo3的基础上,使用sync.Pool进行堆内存对象分配的优化,新版的代码放在了tcp-server-demo3-with-syncpool中。
|
||||
|
||||
新版代码相对于tcp-server-demo3有两处改动,第一处是在packet.go中,我们创建了一个SubmitPool变量,它的类型为sync.Pool,这就是我们的内存对象池,池中的对象都是Submit。这样我们在packet.Decode中收到Submit类型请求时,也不需要新分配一个Submit对象,而是直接从SubmitPool代表的Pool池中取出一个复用。这些代码变更如下:
|
||||
|
||||
// tcp-server-demo3-with-syncpool/packet/packet.go
|
||||
var SubmitPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return &Submit{}
|
||||
},
|
||||
}
|
||||
|
||||
func Decode(packet []byte) (Packet, error) {
|
||||
commandID := packet[0]
|
||||
pktBody := packet[1:]
|
||||
|
||||
switch commandID {
|
||||
case CommandConn:
|
||||
return nil, nil
|
||||
case CommandConnAck:
|
||||
return nil, nil
|
||||
case CommandSubmit:
|
||||
s := SubmitPool.Get().(*Submit) // 从SubmitPool池中获取一个Submit内存对象
|
||||
err := s.Decode(pktBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
case CommandSubmitAck:
|
||||
s := SubmitAck{}
|
||||
err := s.Decode(pktBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown commandID [%d]", commandID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
第二处变更是在Submit对象用完后,归还回Pool池,最理想的“归还地点”是在main包的handlePacket函数中,这里处理完Submit消息后,Submit对象就没有什么用了,于是我们在这里将其归还给Pool池,代码如下:
|
||||
|
||||
// tcp-server-demo3-with-syncpool/cmd/server/main.go
|
||||
|
||||
func handlePacket(framePayload []byte) (ackFramePayload []byte, err error) {
|
||||
var p packet.Packet
|
||||
p, err = packet.Decode(framePayload)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: packet decode error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch p.(type) {
|
||||
case *packet.Submit:
|
||||
submit := p.(*packet.Submit)
|
||||
submitAck := &packet.SubmitAck{
|
||||
ID: submit.ID,
|
||||
Result: 0,
|
||||
}
|
||||
packet.SubmitPool.Put(submit) // 将submit对象归还给Pool池
|
||||
ackFramePayload, err = packet.Encode(submitAck)
|
||||
if err != nil {
|
||||
fmt.Println("handleConn: packet encode error:", err)
|
||||
return nil, err
|
||||
}
|
||||
return ackFramePayload, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown packet type")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
改完这两处后,我们的内存分配优化就完成了。
|
||||
|
||||
和前面一样,我们构建一下tcp-server-demo3-with-syncpool目录下的服务端,并使用客户端对其进行一次压测,压测几分钟后,我们就能看到如下的结果:
|
||||
|
||||
|
||||
|
||||
从采集的性能指标来看,优化后的服务端的处理能力平均可以达到29.2w/s,这相比于上一次优化后的27w/s,又小幅提升了8%左右。
|
||||
|
||||
到这里,按照我们在这一讲开头处所讲的性能优化循环,我们已经完成了一轮优化了,并且取得了不错的效果,现在可以将最新的性能指标作为新一版的性能基准了。
|
||||
|
||||
至于是否要继续新一轮的优化,这就要看当前的性能是否能满足你的要求了。如果满足,就不需再进行新的优化,否则你还需要继续一轮或几轮优化活动,直到性能满足你的要求。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们重点讲解了如何针对上一讲实现的第一版服务端进行优化。我们给出了Go程序优化的四步循环方法,这四步依次是建立性能基准、性能剖析、代码优化和与性能基准比较,确定优化效果。如果经过一轮优化,Go应用的性能仍然无法达到你的要求,那么还可以按这个循环,进行多轮优化。
|
||||
|
||||
建立性能基准是整个优化过程的前提,基准提供了性能优化的起点与参照物。而建立性能基准的前提又是建立观测设施。观测设施的建立方法有很多,这里我们基于Prometheus+Grafana的组合,实现了一个可视化的观测平台。基于这个平台,我们为第一版服务端实现建立了性能基准。
|
||||
|
||||
另外,剖析Go应用性能有很多工具,而Gopher的最爱依然是Go原生提供的pprof,我们可以以图形化的形式或命令行的方式,收集和展示获取到的采样数据。针对我们的服务端程序,我们进行了带缓冲的网络I/O以及重用内存对象的优化,取得了很不错的效果。
|
||||
|
||||
思考题
|
||||
|
||||
这一讲中,虽然我们对第一版服务端实现实施了两个有效的优化,但这个程序依然有可优化的点,你不妨找找,看看还能在哪些点上小幅提升服务端的性能。
|
||||
|
||||
欢迎你把这节课分享给感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
项目源代码在这里!
|
||||
|
||||
|
||||
|
||||
|
||||
697
专栏/TonyBai·Go语言第一课/39驯服泛型:了解类型参数.md
Normal file
697
专栏/TonyBai·Go语言第一课/39驯服泛型:了解类型参数.md
Normal file
@@ -0,0 +1,697 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
39 驯服泛型:了解类型参数
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在专栏的结束语中,我曾承诺要补充“泛型篇”,帮助你入门Go泛型语法。在经历了2022年3月Go 1.18版本的泛型落地以及8月份Go 1.19对泛型问题的一轮修复后,我认为是时候开讲Go泛型篇了。
|
||||
|
||||
虽说目前的Go泛型实现和最后一版的泛型设计方案相比还有差距,依旧不是完全版,还有一些特性没有加入,还有问题亟待解决,但对于入门Go泛型语法来说,我认为已经是足够了。
|
||||
|
||||
不过在正式开讲之前,我还有一些友情提示:和支持泛型的主流编程语言之间的泛型设计与实现存在差异一样,Go的泛型与其他主流编程语言的泛型也是不同的。我希望你在学习之前,先看一下Go泛型设计方案已经明确不支持的若干特性,比如:
|
||||
|
||||
|
||||
不支持泛型特化(specialization),即不支持编写一个泛型函数针对某个具体类型的特殊版本;
|
||||
不支持元编程(metaprogramming),即不支持编写在编译时执行的代码来生成在运行时执行的代码;
|
||||
不支持操作符方法(operator method),即只能用普通的方法(method)操作类型实例(比如:getIndex(k)),而不能将操作符视为方法并自定义其实现,比如一个容器类型的下标访问c[k];
|
||||
不支持变长的类型参数(type parameters);
|
||||
… …
|
||||
|
||||
|
||||
这些特性如今不支持,后续大概率也不会支持。所以小伙伴们,尤其是来自Java、C++等语言阵营的小伙伴,在进入Go泛型语法学习之前,你一定要先了解Go团队的这些设计决策。
|
||||
|
||||
泛型篇的内容共有三讲,我们将从泛型的基本语法,也就是类型参数(type parameter)开启驯服泛型之旅,接下来再搞定泛型的难点定义约束(constraints),最后我们再来谈谈Go泛型的使用时机。如果你还想Go泛型的演化简史,请移步《加餐|聊聊最近大热的Go泛型》,这里我也做了详细分析。
|
||||
|
||||
那么,今天这泛型篇的第一讲,我们就来聚焦Go泛型的基本语法,类型参数。下面我们通过一个最常见的泛型应用场景来开启今天的学习之旅。一个小提醒需要你注意,泛型篇这三讲的所有示例代码均基于Go 1.19.1版本。
|
||||
|
||||
例子:返回切片中值最大的元素
|
||||
|
||||
正如小标题写的那样,我们这个例子要实现一个函数,该函数接受一个切片作为输入参数,然后返回该切片中值最大的那个元素。题目并没有明确使用什么元素类型的切片,我们就先以最常见的整型切片为例,实现一个maxInt函数:
|
||||
|
||||
// max_int.go
|
||||
func maxInt(sl []int) int {
|
||||
if len(sl) == 0 {
|
||||
panic("slice is empty")
|
||||
}
|
||||
|
||||
max := sl[0]
|
||||
for _, v := range sl[1:] {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println(maxInt([]int{1, 2, -4, -6, 7, 0})) // 输出:7
|
||||
}
|
||||
|
||||
|
||||
maxInt的逻辑十分简单。我们使用第一个元素值(max := sl[0])作为max变量初值,然后与切片后面的元素(sl[1:])进行逐一比较,如果后面的元素大于max,则将其值赋给max,这样到切片遍历结束,我们就得到了这个切片中值最大的那个元素(即变量max)。
|
||||
|
||||
我们现在给它加一个新需求:能否针对元素为string类型的切片返回其最大(按字典序)的元素值呢?
|
||||
|
||||
答案肯定是能!我们来实现这个maxString函数:
|
||||
|
||||
// max_string.go
|
||||
func maxString(sl []string) string {
|
||||
if len(sl) == 0 {
|
||||
panic("slice is empty")
|
||||
}
|
||||
|
||||
max := sl[0]
|
||||
for _, v := range sl[1:] {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println(maxString([]string{"11", "22", "44", "66", "77", "10"})) // 输出:77
|
||||
}
|
||||
|
||||
|
||||
maxString实现了返回string切片中值最大元素的需求。不过从实现上来看,maxString与maxInt异曲同工,只是切片元素类型不同罢了。这时如果让你参考上述maxInt或maxString实现一个返回浮点类型切片中最大值的函数maxFloat,你肯定“秒秒钟”就可以给出一个正确的实现:
|
||||
|
||||
// max_float.go
|
||||
func maxFloat(sl []float64) float64 {
|
||||
if len(sl) == 0 {
|
||||
panic("slice is empty")
|
||||
}
|
||||
|
||||
max := sl[0]
|
||||
for _, v := range sl[1:] {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println(maxFloat([]float64{1.01, 2.02, 3.03, 5.05, 7.07, 0.01})) // 输出:7.07
|
||||
}
|
||||
|
||||
|
||||
问题来了!有代码洁癖的同学肯定已经嗅到了上面三个函数散发的“糟糕味道”:代码重复。上面三个函数除了切片的元素类型不同,其他逻辑都一样。
|
||||
|
||||
那么能否实现一个“通用”的函数,可以处理上面三种元素类型的切片呢?提到“通用”,你一定想到了Go语言提供的any(interface{}的别名),我们来试试:
|
||||
|
||||
// max_any.go
|
||||
func maxAny(sl []any) any {
|
||||
if len(sl) == 0 {
|
||||
panic("slice is empty")
|
||||
}
|
||||
|
||||
max := sl[0]
|
||||
for _, v := range sl[1:] {
|
||||
switch v.(type) {
|
||||
case int:
|
||||
if v.(int) > max.(int) {
|
||||
max = v
|
||||
}
|
||||
case string:
|
||||
if v.(string) > max.(string) {
|
||||
max = v
|
||||
}
|
||||
case float64:
|
||||
if v.(float64) > max.(float64) {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func main() {
|
||||
i := maxAny([]any{1, 2, -4, -6, 7, 0})
|
||||
m := i.(int)
|
||||
fmt.Println(m) // 输出:7
|
||||
fmt.Println(maxAny([]any{"11", "22", "44", "66", "77", "10"})) // 输出:77
|
||||
fmt.Println(maxAny([]any{1.01, 2.02, 3.03, 5.05, 7.07, 0.01})) // 输出:7.07
|
||||
}
|
||||
|
||||
|
||||
我们看到,maxAny利用any、type switch和类型断言(type assertion)实现了我们预期的目标。不过这个实现并不理想,它至少有如下几个问题:
|
||||
|
||||
|
||||
若要支持其他元素类型的切片,我们需对该函数进行修改;
|
||||
maxAny的返回值类型为any(interface{}),要得到其实际类型的值还需要通过类型断言转换;
|
||||
使用any(interface{})作为输入参数的元素类型和返回值的类型,由于存在装箱和拆箱操作,其性能与maxInt等比起来要逊色不少,实测数据如下:
|
||||
|
||||
|
||||
// max_test.go
|
||||
func BenchmarkMaxInt(b *testing.B) {
|
||||
sl := []int{1, 2, 3, 4, 7, 8, 9, 0}
|
||||
for i := 0; i < b.N; i++ {
|
||||
maxInt(sl)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMaxAny(b *testing.B) {
|
||||
sl := []any{1, 2, 3, 4, 7, 8, 9, 0}
|
||||
for i := 0; i < b.N; i++ {
|
||||
maxAny(sl)
|
||||
}
|
||||
}
|
||||
|
||||
$go test -v -bench . ./max_test.go max_any.go max_int.go
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
... ...
|
||||
BenchmarkMaxInt
|
||||
BenchmarkMaxInt-8 398996863 2.982 ns/op
|
||||
BenchmarkMaxAny
|
||||
BenchmarkMaxAny-8 85883875 13.91 ns/op
|
||||
PASS
|
||||
ok command-line-arguments 2.710s
|
||||
|
||||
|
||||
我们看到,基于any(interface{})实现的maxAny其执行性能要比像maxInt这样的函数慢上数倍。
|
||||
|
||||
在Go 1.18版本之前,Go的确没有比较理想的解决类似上述“通用”问题的手段,直到Go 1.18版本泛型落地后,我们可以用泛型语法实现maxGenerics函数:
|
||||
|
||||
// max_generics.go
|
||||
type ordered interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
|
||||
~float32 | ~float64 |
|
||||
~string
|
||||
}
|
||||
|
||||
func maxGenerics[T ordered](sl []T) T {
|
||||
if len(sl) == 0 {
|
||||
panic("slice is empty")
|
||||
}
|
||||
|
||||
max := sl[0]
|
||||
for _, v := range sl[1:] {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
type myString string
|
||||
|
||||
func main() {
|
||||
var m int = maxGenerics([]int{1, 2, -4, -6, 7, 0})
|
||||
fmt.Println(m) // 输出:7
|
||||
fmt.Println(maxGenerics([]string{"11", "22", "44", "66", "77", "10"})) // 输出:77
|
||||
fmt.Println(maxGenerics([]float64{1.01, 2.02, 3.03, 5.05, 7.07, 0.01})) // 输出:7.07
|
||||
fmt.Println(maxGenerics([]int8{1, 2, -4, -6, 7, 0})) // 输出:7
|
||||
fmt.Println(maxGenerics([]myString{"11", "22", "44", "66", "77", "10"})) // 输出:77
|
||||
}
|
||||
|
||||
|
||||
我们看到,从功能角度看,泛型版本的maxGenerics实现了预期的特性,对于ordered接口中声明的那些原生类型以及以这些原生类型为底层类型(underlying type)的类型(比如示例中的myString),maxGenerics都可以无缝支持。并且,maxGenerics返回的类型与传入的切片的元素类型一致,调用者也无需通过类型断言做转换。
|
||||
|
||||
此外,通过下面的性能基准测试我们也可以看出,与maxAny相比,泛型版本的maxGenerics性能要好很多,但与原生版函数如maxInt等还有差距。关于泛型的运行时性能损耗问题,我们在泛型篇第三讲中会有说明。性能测试如下:
|
||||
|
||||
$go test -v -bench . ./max_test.go max_any.go max_int.go max_generics.go
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
BenchmarkMaxInt
|
||||
BenchmarkMaxInt-8 400910706 2.983 ns/op
|
||||
BenchmarkMaxAny
|
||||
BenchmarkMaxAny-8 85257433 14.04 ns/op
|
||||
BenchmarkMaxGenerics
|
||||
BenchmarkMaxGenerics-8 209468593 5.701 ns/op
|
||||
PASS
|
||||
ok command-line-arguments 4.492s
|
||||
|
||||
|
||||
通过这个例子,我们也可以看到Go泛型十分适合实现一些操作容器类型(比如切片、map等)的算法,这也是Go官方推荐的第一种泛型应用场景,此类容器算法的泛型实现使得容器算法与容器内元素类型彻底解耦!
|
||||
|
||||
不过看到这里,很多同学可能会抱怨:看不懂maxGenerics的语法!别急,接下来,我们就来基于这个例子进行泛型基本语法的学习。
|
||||
|
||||
类型参数(type parameters)
|
||||
|
||||
根据官方说法,由于泛型(generic)一词在Go社区中被广泛使用,所以官方也就接纳了这一说法。但Go泛型方案的实质是对类型参数(type parameter)的支持,包括:
|
||||
|
||||
|
||||
泛型函数(generic function):带有类型参数的函数;
|
||||
泛型类型(generic type):带有类型参数的自定义类型;
|
||||
泛型方法(generic method):泛型类型的方法。
|
||||
|
||||
|
||||
下面我们先以泛型函数为例来具体说明一下什么是类型参数。
|
||||
|
||||
泛型函数
|
||||
|
||||
我们回顾一下上面的示例,maxGenerics就是一个泛型函数,我们看一下maxGenerics的函数原型:
|
||||
|
||||
func maxGenerics[T ordered](sl []T) T {
|
||||
// ... ...
|
||||
}
|
||||
|
||||
|
||||
我们看到,maxGenerics这个函数与我们之前学过的普通Go函数(ordinary function)相比,至少有两点不同:
|
||||
|
||||
|
||||
maxGenerics函数在函数名称与函数参数列表之间多了一段由方括号括起的代码:[T ordered];
|
||||
maxGenerics参数列表中的参数类型以及返回值列表中的返回值类型都是T,而不是某个具体的类型。
|
||||
|
||||
|
||||
maxGenerics函数原型中多出的这段代码[T ordered]就是Go泛型的类型参数列表(type parameters list),示例中这个列表中仅有一个类型参数T,ordered为类型参数的类型约束(type constraint)。类型约束之于类型参数,就好比常规参数列表中的类型之于常规参数。关于类型约束,我们还会在下一讲中详细说明。
|
||||
|
||||
Go语言规范规定:函数的类型参数列表位于函数名与函数参数列表之间,由方括号括起的固定个数的、由逗号分隔的类型参数声明组成,其一般形式如下:
|
||||
|
||||
func genericsFunc[T1 constraint1, T2, constraint2, ..., Tn constraintN](ordinary parameters list) (return values list)
|
||||
|
||||
|
||||
函数一旦拥有类型参数,就可以用该参数作为常规参数列表和返回值列表中修饰参数和返回值的类型。我们继续maxGenerics泛型函数为例分析,它拥有一个类型参数T,在常规参数列表中,T被用作切片的元素类型;在返回值列表中,T被用作返回值的类型。
|
||||
|
||||
按Go惯例,类型参数名的首字母通常采用大写形式,并且类型参数必须是具名的,即便你在后续的函数参数列表、返回值列表和函数体中没有使用该类型参数,也是这样。比如下面例子中的类型参数T:
|
||||
|
||||
func print[T any]() { // 正确
|
||||
}
|
||||
|
||||
func print[any]() { // 编译错误:all type parameters must be named
|
||||
}
|
||||
|
||||
|
||||
和常规参数列表中的参数名唯一一样,在同一个类型参数列表中,类型参数名字也要唯一,下面这样的代码将会导致Go编译器报错:
|
||||
|
||||
func print[T1 any, T1 comparable](sl []T) { // 编译错误:T1 redeclared in this block
|
||||
//...
|
||||
}
|
||||
|
||||
|
||||
常规参数列表中的参数有其特定作用域,即从参数声明处开始到函数体结束。和常规参数类似,泛型函数中类型参数也有其作用域范围,这个范围从类型参数列表左侧的方括号[开始,一直持续到函数体结束,如下图所示:
|
||||
|
||||
|
||||
|
||||
类型参数的作用域也决定了类型参数的声明顺序并不重要,也不会影响泛型函数的行为,于是下面的泛型函数声明与上图中的函数是等价的:
|
||||
|
||||
func foo[M map[E]T, T any, E comparable](m M)(E, T) {
|
||||
//... ...
|
||||
}
|
||||
|
||||
|
||||
到这里,泛型函数的结构我们已经了解完了,接下来我们来看一下如何调用泛型函数。
|
||||
|
||||
调用泛型函数
|
||||
|
||||
在前面的讲解中,我一直使用“类型参数”这个名称。但在学习调用泛型函数之前,我们需要对“类型参数”做一下细分。
|
||||
|
||||
和普通函数有形式参数与实际参数一样,类型参数也有类型形参(type parameter)和类型实参(type argument)之分。其中类型形参就是泛型函数声明中的类型参数,以前面示例中的maxGenerics泛型函数为例,如下面代码,maxGenerics的类型形参就是T,而类型实参则是在调用maxGenerics时实际传递的类型int:
|
||||
|
||||
// 泛型函数声明:T为类型形参
|
||||
func maxGenerics[T ordered](sl []T) T
|
||||
|
||||
// 调用泛型函数:int为类型实参
|
||||
m := maxGenerics[int]([]int{1, 2, -4, -6, 7, 0})
|
||||
|
||||
|
||||
从上面这段代码我们也可以看出调用泛型函数与调用普通函数的区别。在调用泛型函数时,除了要传递普通参数列表对应的实参之外,还要显式传递类型实参,比如这里的int。并且,显式传递的类型实参要放在函数名和普通参数列表前的方括号中。
|
||||
|
||||
在反复揣摩上面代码和说明后,你可能会提出这样的一个问题:如果泛型函数的类型形参较多,那么逐一显式传入类型实参会让泛型函数的调用显得十分冗长,比如:
|
||||
|
||||
foo[int, string, uint32, float64](1, "hello", 17, 3.14)
|
||||
|
||||
|
||||
这样的写法对开发者而言显然谈不上十分友好。其实不光大家想到了这个问题,Go团队的泛型实现者们也考虑了这个问题,并给出了解决方法:函数类型实参的自动推断(function argument type inference)。
|
||||
|
||||
顾名思义,这个机制就是通过判断传递的函数实参的类型来推断出类型实参的类型,从而允许开发者不必显式提供类型实参,下面是以maxGenerics函数为例的类型实参推断过程示意图:
|
||||
|
||||
|
||||
|
||||
我们看到,当maxGenerics函数传入的实际参数为[]int{…}时,Go编译器会将其类型[]int与泛型函数参数列表中对应参数的类型([]T)作比较,并推断出T == int这一结果。当然这个例子的推断过程较为简单,那些有难度的,甚至无法肉眼可见的就交给Go编译器去处理吧,我们没有必要过于深入。
|
||||
|
||||
不过,这个类型实参自动推断有一个前提,你一定要记牢,那就是它必须是函数的参数列表中使用了的类型形参,否则就会像下面的示例中的代码,编译器将报无法推断类型实参的错误:
|
||||
|
||||
func foo[T comparable, E any](a int, s E) {
|
||||
}
|
||||
|
||||
foo(5, "hello") // 编译器错误:cannot infer T
|
||||
|
||||
|
||||
在编译器无法推断出结果时,我们可以给予编译器“部分提示”,比如既然编译器无法推断出T的实参类型,那我们就显式告诉编译器T的实参类型,即在泛型函数调用时,在类型实参列表中显式传入T的实参类型,但E的实参类型依然由编译器自动推断,示例代码如下:
|
||||
|
||||
var s = "hello"
|
||||
foo[int](5, s) //ok
|
||||
foo[int,](5, s) //ok
|
||||
|
||||
|
||||
那么,除了函数参数列表中的参数类型可以作为类型实参推断的依据外,函数返回值的类型是否也可以呢?我们看下面示例:
|
||||
|
||||
func foo[T any](a int) T {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
|
||||
var a int = foo(5) // 编译器错误:cannot infer T
|
||||
println(a)
|
||||
|
||||
|
||||
我们看到,这个函数仅在返回值中使用了类型参数,但编译器没能推断出T的类型,所以我们切记:不能通过返回值类型来推断类型实参。
|
||||
|
||||
有了函数类型实参推断后,在大多数情况下,我们调用泛型函数就无须显式传递类型实参了,开发者也因此获得了与普通函数调用几乎一致的体验。
|
||||
|
||||
其实泛型函数调用是一个不同于普通函数调用的过程,为了揭开其中的“奥秘”,接下来我们就把镜头放慢,看看泛型函数调用过程究竟发生了什么。
|
||||
|
||||
泛型函数实例化(instantiation)
|
||||
|
||||
我们还以maxGenerics为例来演示一下这个过程:
|
||||
|
||||
maxGenerics([]int{1, 2, -4, -6, 7, 0})
|
||||
|
||||
|
||||
上面代码是对maxGenerics泛型函数的一次调用,Go对这段泛型函数调用代码的处理分为两个阶段,如下图所示:
|
||||
|
||||
|
||||
|
||||
我们看到,Go首先会对泛型函数进行实例化(instantiation),即根据自动推断出的类型实参生成一个新函数(当然这一过程是在编译阶段完成的,不会对运行时性能产生影响),然后才会调用这个新函数对输入的函数参数进行处理。
|
||||
|
||||
我们也可以用一种更形象的方式来描述上述泛型函数的实例化过程。实例化就好比一家生产“求最大值”机器的工厂,它会根据要比较大小的对象的类型将这样的机器生产出来。以上面的例子来说,整个实例化过程如下:
|
||||
|
||||
|
||||
工厂接单:调用maxGenerics([]int{…}),工厂师傅发现要比较大小的对象类型为int;
|
||||
模具检查与匹配:检查int类型是否满足模具的约束要求,即int是否满足ordered约束,如满足,则将其作为类型实参替换maxGenerics函数中的类型形参T,结果为maxGenerics[int];
|
||||
生产机器:将泛型函数maxGenerics实例化为一个新函数,这里将其起名为maxGenericsInt,其函数原型为func([]int)int。本质上maxGenericsInt := maxGenerics[int]。
|
||||
|
||||
|
||||
我们实际的Go代码也可以真实得到这台新生产出的“机器”,如下面代码所示:
|
||||
|
||||
maxGenericsInt := maxGenerics[int] // 实例化后得到的新“机器”:maxGenericsInt
|
||||
fmt.Printf("%T\n", maxGenericsInt) // func([]int) int
|
||||
|
||||
|
||||
一旦针对int对象的“求最大值”的机器被生产出来了,它就可以对目标对象进行处理了,这和普通的函数调用没有区别。这里就相当于调用如下代码:
|
||||
|
||||
maxGenericsInt([]int{1, 2, -4, -6, 7, 0}) // 输出:7
|
||||
|
||||
|
||||
整个过程只需检查传入的函数实参([]int{1, 2, …})的类型与maxGenericsInt函数原型中的形参类型([]int)是否匹配即可。
|
||||
|
||||
另外要注意,当我们使用相同类型实参对泛型函数进行多次调用时,Go仅会做一次实例化,并复用实例化后的函数,比如:
|
||||
|
||||
maxGenerics([]int{1, 2, -4, -6, 7, 0})
|
||||
maxGenerics([]int{11, 12, 14, -36,27, 0}) // 复用第一次调用后生成的原型为func([]int) int的函数
|
||||
|
||||
|
||||
好了,关于泛型函数的讲解就先告一段落,接下来我们再来看Go对类型参数的另一类支持:带有类型参数的自定义类型,即泛型类型。
|
||||
|
||||
泛型类型
|
||||
|
||||
所谓泛型类型,就是在类型声明中带有类型参数的Go类型,比如下面代码中的maxableSlice:
|
||||
|
||||
// maxable_slice.go
|
||||
|
||||
type maxableSlice[T ordered] struct {
|
||||
elems []T
|
||||
}
|
||||
|
||||
|
||||
顾名思义,maxableSlice是一个自定义切片类型,这个类型的特点是总可以获取其内部元素的最大值,其唯一的要求是其内部元素是可排序的,它通过带有ordered约束的类型参数来明确这一要求。像这样在定义中带有类型参数的类型就被称为泛型类型(generic type)。
|
||||
|
||||
从例子中的maxableSlice类型声明中我们可以看到,在泛型类型中,类型参数列表放在类型名字后面的方括号中。和泛型函数一样,泛型类型可以有多个类型参数,类型参数名通常是首字母大写的,这些类型参数也必须是具名的,且命名唯一。其一般形式如下:
|
||||
|
||||
type TypeName[T1 constraint1, T2 constraint2, ..., Tn constraintN] TypeLiteral
|
||||
|
||||
|
||||
和泛型函数中类型参数有其作用域一样,泛型类型中类型参数的作用域范围也是从类型参数列表左侧的方括号[开始,一直持续到类型定义结束的位置,如下图所示:
|
||||
|
||||
|
||||
|
||||
这样的作用域将方便我们在各个字段中灵活使用类型参数,下面是一些自定义泛型类型的示例:
|
||||
|
||||
type Set[T comparable] map[T]struct{}
|
||||
|
||||
type sliceFn[T any] struct {
|
||||
s []T
|
||||
cmp func(T, T) bool
|
||||
}
|
||||
|
||||
type Map[K, V any] struct {
|
||||
root *node[K, V]
|
||||
compare func(K, K) int
|
||||
}
|
||||
|
||||
type element[T any] struct {
|
||||
next *element[T]
|
||||
val T
|
||||
}
|
||||
|
||||
type Numeric interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
|
||||
~float32 | ~float64 |
|
||||
~complex64 | ~complex128
|
||||
}
|
||||
|
||||
type NumericAbs[T Numeric] interface {
|
||||
Abs() T
|
||||
}
|
||||
|
||||
|
||||
我们看到,泛型类型中的类型参数可以用来作为类型声明中字段的类型(比如上面的element类型)、复合类型的元素类型(比如上面的Set和Map类型)或方法的参数和返回值类型(如NumericAbs接口类型)等。
|
||||
|
||||
如果要在泛型类型声明的内部引用该类型名,必须要带上类型参数,如上面的element结构体中的next字段的类型:*element[T]。按照泛型设计方案,如果泛型类型有不止一个类型参数,那么在其声明内部引用该类型名时,不仅要带上所有类型参数,类型参数的顺序也要与声明中类型参数列表中的顺序一致,比如:
|
||||
|
||||
type P[T1, T2 any] struct {
|
||||
F *P[T1, T2] // ok
|
||||
}
|
||||
|
||||
|
||||
不过从实测结果来看,Go 1.19版本对于下面不符合技术方案的泛型类型声明也并未报错:
|
||||
|
||||
type P[T1, T2 any] struct {
|
||||
F *P[T2, T1] // 不符合技术方案,但Go 1.19编译器并未报错
|
||||
}
|
||||
|
||||
|
||||
了解了如何声明一个泛型类型后,我们再来看看如何使用这些泛型类型。
|
||||
|
||||
使用泛型类型
|
||||
|
||||
和泛型函数一样,使用泛型类型时也会有一个实例化(instantiation)过程,比如:
|
||||
|
||||
var sl = maxableSlice[int]{
|
||||
elems: []int{1, 2, -4, -6, 7, 0},
|
||||
}
|
||||
|
||||
|
||||
Go会根据传入的类型实参(int)生成一个新的类型并创建该类型的变量实例,sl的类型等价于下面代码:
|
||||
|
||||
type maxableIntSlice struct {
|
||||
elems []int
|
||||
}
|
||||
|
||||
|
||||
看到这里你可能会问:泛型类型是否可以像泛型函数那样实现类型实参的自动推断呢?很遗憾,目前的Go 1.19尚不支持,下面代码会遭到Go编译器的报错:
|
||||
|
||||
var sl = maxableSlice {
|
||||
elems: []int{1, 2, -4, -6, 7, 0}, // 编译器错误:cannot use generic type maxableSlice[T ordered] without instantiation
|
||||
}
|
||||
|
||||
|
||||
不过这一特性在Go的未来版本中可能会得到支持。
|
||||
|
||||
既然涉及到了类型,你肯定会想到诸如类型别名、类型嵌入等Go语言机制,那么这些语言机制对泛型类型的支持情况又是如何呢?我们逐一来看一下。
|
||||
|
||||
|
||||
泛型类型与类型别名-
|
||||
在专栏前面的讲解中,我们学习过类型别名(type alias)。我们知道类型别名与其绑定的原类型是完全等价的,但这仅限于原类型是一个直接类型,即可直接用于声明变量的类型。那么将类型别名与泛型类型绑定是否可行呢?我们来看一个示例:
|
||||
|
||||
|
||||
type foo[T1 any, T2 comparable] struct {
|
||||
a T1
|
||||
b T2
|
||||
}
|
||||
|
||||
type fooAlias = foo // 编译器错误:cannot use generic type foo[T1 any, T2 comparable] without instantiation
|
||||
|
||||
|
||||
在上述代码中,我们为泛型类型foo建立了类型别名fooAlias,但编译这段代码时,编译器还是报了错误!
|
||||
|
||||
这是因为,泛型类型只是一个生产真实类型的“工厂”,它自身在未实例化之前是不能直接用于声明变量的,因此不符合类型别名机制的要求。泛型类型只有实例化后才能得到一个真实类型,例如下面的代码就是合法的:
|
||||
|
||||
type fooAlias = foo[int, string]
|
||||
|
||||
|
||||
也就是说,我们只能为泛型类型实例化后的类型创建类型别名,实际上上述fooAlias等价于实例化后的类型fooInstantiation:
|
||||
|
||||
type fooInstantiation struct {
|
||||
a int
|
||||
b string
|
||||
}
|
||||
|
||||
|
||||
|
||||
泛型类型与类型嵌入-
|
||||
类型嵌入是运用Go组合设计哲学的一个重要手段。引入泛型类型之后,我们依然可以在泛型类型定义中嵌入普通类型,比如下面示例中Lockable类型中嵌入的sync.Mutex:
|
||||
|
||||
|
||||
type Lockable[T any] struct {
|
||||
t T
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
func (l *Lockable[T]) Get() T {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
return l.t
|
||||
}
|
||||
|
||||
func (l *Lockable[T]) Set(v T) {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
l.t = v
|
||||
}
|
||||
|
||||
|
||||
在泛型类型定义中,我们也可以将其他泛型类型实例化后的类型作为成员。现在我们改写一下上面的Lockable,为其嵌入另外一个泛型类型实例化后的类型Slice[int]:
|
||||
|
||||
type Slice[T any] []T
|
||||
|
||||
func (s Slice[T]) String() string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
var result = fmt.Sprintf("%v", s[0])
|
||||
for _, v := range s[1:] {
|
||||
result = fmt.Sprintf("%v, %v", result, v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type Lockable[T any] struct {
|
||||
t T
|
||||
Slice[int]
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
func main() {
|
||||
n := Lockable[string]{
|
||||
t: "hello",
|
||||
Slice: []int{1, 2, 3},
|
||||
}
|
||||
println(n.String()) // 输出:1, 2, 3
|
||||
}
|
||||
|
||||
|
||||
我们看到,代码使用泛型类型名(Slice)作为嵌入后的字段名,并且Slice[int]的方法String被提升为Lockable实例化后的类型的方法了。同理,在普通类型定义中,我们也可以使用实例化后的泛型类型作为成员,比如让上面的Slice[int]嵌入到一个普通类型Foo中,示例代码如下:
|
||||
|
||||
type Foo struct {
|
||||
Slice[int]
|
||||
}
|
||||
|
||||
func main() {
|
||||
f := Foo{
|
||||
Slice: []int{1, 2, 3},
|
||||
}
|
||||
println(f.String()) // 输出:1, 2, 3
|
||||
}
|
||||
|
||||
|
||||
此外,Go泛型设计方案支持在泛型类型定义中嵌入类型参数作为成员,比如下面的泛型类型Lockable内嵌了一个类型T,且T恰为其类型参数:
|
||||
|
||||
type Lockable[T any] struct {
|
||||
T
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
|
||||
不过,Go 1.19版本编译上述代码时会针对嵌入T的那一行报如下错误:
|
||||
|
||||
编译器报错:embedded field type cannot be a (pointer to a) type parameter
|
||||
|
||||
|
||||
关于这个错误,Go官方在其issue中给出了临时的结论:暂不支持。
|
||||
|
||||
泛型方法
|
||||
|
||||
在专栏基础篇的学习中,我们知道Go类型可以拥有自己的方法(method),泛型类型也不例外,为泛型类型定义的方法称为泛型方法(generic method),接下来我们就来讲讲如何定义和使用泛型方法。
|
||||
|
||||
我们用一个示例,给maxableSlice泛型类型定义max方法,看一下泛型方法的结构:
|
||||
|
||||
func (sl *maxableSlice[T]) max() T {
|
||||
if len(sl.elems) == 0 {
|
||||
panic("slice is empty")
|
||||
}
|
||||
|
||||
max := sl.elems[0]
|
||||
for _, v := range sl.elems[1:] {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
|
||||
我们看到,在定义泛型类型的方法时,方法的receiver部分不仅要带上类型名称,还需要带上完整的类型形参列表(如maxableSlice[T]),这些类型形参后续可以用在方法的参数列表和返回值列表中。
|
||||
|
||||
不过在Go泛型目前的设计中,泛型方法自身不可以再支持类型参数了,不能像下面这样定义泛型方法:
|
||||
|
||||
func (f *foo[T]) M1[E any](e E) T { // 编译器错误:syntax error: method must have no type parameters
|
||||
//... ...
|
||||
}
|
||||
|
||||
|
||||
关于泛型方法未来是否能支持类型参数,目前Go团队倾向于否,但最终结果Go团队还要根据Go社区在使用泛型过程中的反馈而定。
|
||||
|
||||
在泛型方法中,receiver中某个类型参数如果没有在方法参数列表和返回值中使用,可以用“_”代替,但不能不写,比如:
|
||||
|
||||
type foo[A comparable, B any] struct{}
|
||||
|
||||
func (foo[A, B]) M1() { // ok
|
||||
}
|
||||
|
||||
或
|
||||
|
||||
func (foo[_, _]) M1() { // ok
|
||||
}
|
||||
|
||||
或
|
||||
|
||||
func (foo[A, _]) M1() { // ok
|
||||
}
|
||||
|
||||
但
|
||||
|
||||
func (foo[]) M1() { // 错误:receiver部分缺少类型参数
|
||||
|
||||
}
|
||||
|
||||
|
||||
另外,泛型方法中的receiver中类型参数名字可以与泛型类型中的类型形参名字不同,位置和数量对上即可。我们还以上面的泛型类型foo为例,可以为它添加下面方法:
|
||||
|
||||
type foo[A comparable, B any] struct{}
|
||||
|
||||
func (foo[First, Second]) M1(a First, b Second) { // First对应类型参数A,Second对应类型参数B
|
||||
|
||||
}
|
||||
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们一起学习了Go泛型的基本语法:类型参数。类型参数是Go泛型方案的具体实现,通过类型参数,我们可以定义泛型函数、泛型类型以及对应的泛型方法。
|
||||
|
||||
泛型函数是带有类型参数的函数,在函数名称与参数列表之间声明的类型参数列表使得泛型函数的运行逻辑与参数/返回值类型解耦。调用泛型函数与普通函数略有不同,泛型函数需要进行实例化后才能生成真正执行的、带有类型信息的函数。同时,Go泛型支持的类型实参推断也使得开发者在大多数情况下无需显式传递类型实参,获得与普通函数调用几乎一致的体验。
|
||||
|
||||
泛型类型是带有类型参数的类型,泛型类型的类型参数放在类型名称后面的类型参数列表中声明,类型参数后续可以在泛型类型声明中用作成员字段的类型或复合类型成员元素的类型。不过目前(Go 1.19版本)Go尚不支持泛型类型的类型实参的自动推断,我们在泛型类型实例化时需要显式传入类型实参。
|
||||
|
||||
与泛型类型绑定的方法被称为泛型方法,泛型方法的参数列表和返回值列表中可以使用泛型类型的类型参数,但泛型方法目前尚不支持声明自己的类型参数列表。
|
||||
|
||||
Go泛型的引入,使得Go开发人员在interface{}之后又拥有了一种编写“通用代码”的手段,并且这种新手段因其更多在编译阶段的检查而变得更加安全,也因其减少了运行时的额外开销使得代码性能更好。
|
||||
|
||||
思考题
|
||||
|
||||
使用过其他编程语言泛型语法特性的小伙伴们可能会问:为什么Go在方括号“[]”中声明类型参数,而不是使用其他语言都用的尖括号“<>”呢?你可以思考一下。
|
||||
|
||||
欢迎在评论区写下你的想法,我们泛型篇的第二讲见。
|
||||
|
||||
|
||||
|
||||
|
||||
610
专栏/TonyBai·Go语言第一课/40驯服泛型:定义泛型约束.md
Normal file
610
专栏/TonyBai·Go语言第一课/40驯服泛型:定义泛型约束.md
Normal file
@@ -0,0 +1,610 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
40 驯服泛型:定义泛型约束
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在上一讲中我们对Go泛型的实现方案“类型参数语法”做了较为全面的学习,我们掌握了泛型函数、泛型类型和泛型方法的定义和使用方法。不过还有一处语法点我们并没有重点说明,它就是用于声明类型参数的约束(constraint)。
|
||||
|
||||
虽然泛型是开发人员表达“通用代码”的一种重要方式,但这并不意味着所有泛型代码对所有类型都适用。更多的时候,我们需要对泛型函数的类型参数以及泛型函数中的实现代码设置限制。泛型函数调用者只能传递满足限制条件的类型实参,泛型函数内部也只能以类型参数允许的方式使用这些类型实参值。在Go泛型语法中,我们使用类型参数约束(type parameter constraint)(以下简称约束)来表达这种限制条件。
|
||||
|
||||
就像上一讲提到的,约束之于类型参数就好比函数参数列表中的类型之于参数:
|
||||
|
||||
|
||||
|
||||
函数普通参数在函数实现代码中可以表现出来的性质与可以参与的运算由参数类型限制,而泛型函数的类型参数就由约束(constraint)来限制。
|
||||
|
||||
2018年8月由伊恩·泰勒和罗伯特·格瑞史莫主写的Go泛型第一版设计方案中,Go引入了contract关键字来定义泛型类型参数的约束。但经过约两年的Go社区公示和讨论,在2020年6月末发布的泛型新设计方案中,Go团队又放弃了新引入的contract关键字,转而采用已有的interface类型来替代contract定义约束。这一改变得到了Go社区的大力支持。使用interface类型作为约束的定义方法能够最大程度地复用已有语法,并抑制语言引入泛型后的复杂度。
|
||||
|
||||
但原有的interface语法尚不能满足定义约束的要求。所以,在Go泛型版本中,interface语法也得到了一些扩展,也正是这些扩展给那些刚刚入门Go泛型的Go开发者带去了一丝困惑,这也是约束被认为是Go泛型的一个难点的原因。
|
||||
|
||||
在这一讲中,我们就聚焦于Go类型参数的约束,学习一下Go原生内置的约束、如何定义自己的约束、新引入的类型集合概念等。我们先来看一下Go语言的内置约束,从Go泛型中最宽松的约束:any开始。
|
||||
|
||||
最宽松的约束:any
|
||||
|
||||
无论是泛型函数还是泛型类型,其所有类型参数声明中都必须显式包含约束,即便你允许类型形参接受所有类型作为类型实参传入也是一样。那么我们如何表达“所有类型”这种约束呢?我们可以使用空接口类型(interface{})来作为类型参数的约束:
|
||||
|
||||
func Print[T interface{}](sl []T) {
|
||||
// ... ...
|
||||
}
|
||||
|
||||
func doSomething[T1 interface{}, T2 interface{}, T3 interface{}](t1 T1, t2 T2, t3 T3) {
|
||||
// ... ...
|
||||
}
|
||||
|
||||
|
||||
不过使用interface{}作为约束至少有以下几点“不足”:
|
||||
|
||||
|
||||
如果存在多个这类约束时,泛型函数声明部分会显得很冗长,比如上面示例中的doSomething的声明部分;
|
||||
interface{}包含{}这样的符号,会让本已经很复杂的类型参数声明部分显得更加复杂;
|
||||
和comparable、Sortable、ordered这样的约束命名相比,interface{}作为约束的表意不那么直接。
|
||||
|
||||
|
||||
为此,Go团队在Go 1.18泛型落地的同时又引入了一个预定义标识符:any。any本质上是interface{}的一个类型别名:
|
||||
|
||||
// $GOROOT/src/builtin/buildin.go
|
||||
// any is an alias for interface{} and is equivalent to interface{} in all ways.
|
||||
type any = interface{}
|
||||
|
||||
|
||||
这样,我们在泛型类型参数声明中就可以使用any替代interface{},而上述interface{}作为类型参数约束的几点“不足”也随之被消除掉了。
|
||||
|
||||
any约束的类型参数意味着可以接受所有类型作为类型实参。在函数体内,使用any约束的形参T可以用来做如下操作:
|
||||
|
||||
|
||||
声明变量;
|
||||
同类型赋值;
|
||||
将变量传给其他函数或从函数返回;
|
||||
取变量地址;
|
||||
转换或赋值给interface{}类型变量;
|
||||
用在类型断言或type switch中;
|
||||
作为复合类型中的元素类型;
|
||||
传递给预定义的函数,比如new。
|
||||
|
||||
|
||||
下面是any约束的类型参数执行这些操作的一个示例:
|
||||
|
||||
// any.go
|
||||
func doSomething[T1, T2 any](t1 T1, t2 T2) T1 {
|
||||
var a T1 // 声明变量
|
||||
var b T2
|
||||
a, b = t1, t2 // 同类型赋值
|
||||
_ = b
|
||||
|
||||
f := func(t T1) {
|
||||
}
|
||||
f(a) // 传给其他函数
|
||||
|
||||
p := &a // 取变量地址
|
||||
_ = p
|
||||
|
||||
var i interface{} = a // 转换或赋值给interface{}类型变量
|
||||
_ = i
|
||||
|
||||
c := new(T1) // 传递给预定义函数
|
||||
_ = c
|
||||
|
||||
f(a) // 将变量传给其他函数
|
||||
|
||||
sl := make([]T1, 0, 10) // 作为复合类型中的元素类型
|
||||
_ = sl
|
||||
|
||||
j, ok := i.(T1) // 用在类型断言中
|
||||
_ = ok
|
||||
_ = j
|
||||
|
||||
switch i.(type) { // 作为type switch中的case类型
|
||||
case T1:
|
||||
case T2:
|
||||
}
|
||||
return a // 从函数返回
|
||||
}
|
||||
|
||||
|
||||
但如果对any约束的类型参数进行了非上述允许的操作,比如相等性或不等性比较,那么Go编译器就会报错:
|
||||
|
||||
// any.go
|
||||
|
||||
func doSomething[T1, T2 any](t1 T1, t2 T2) T1 {
|
||||
var a T1
|
||||
if a == t1 { // 编译器报错:invalid operation: a == t1 (incomparable types in type set)
|
||||
}
|
||||
|
||||
if a != t1 { // 编译器报错:invalid operation: a != t1 (incomparable types in type set)
|
||||
}
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
所以说,如果我们想在泛型函数体内部对类型参数声明的变量实施相等性(==)或不等性比较(!=)操作,我们就需要更换约束,这就引出了Go内置的另外一个预定义约束:comparable。
|
||||
|
||||
支持比较操作的内置约束:comparable
|
||||
|
||||
Go泛型提供了预定义的约束:comparable,其定义如下:
|
||||
|
||||
// $GOROOT/src/builtin/buildin.go
|
||||
|
||||
// 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.
|
||||
type comparable interface{ comparable }
|
||||
|
||||
|
||||
不过从上述这行源码我们仍然无法直观看到comparable的实现细节,Go编译器会在编译期间判断某个类型是否实现了comparable接口。
|
||||
|
||||
根据其注释说明,所有可比较的类型都实现了comparable这个接口,包括:布尔类型、数值类型、字符串类型、指针类型、channel类型、元素类型实现了comparable的数组和成员类型均实现了comparable接口的结构体类型。下面的例子可以让我们直观地看到这一点:
|
||||
|
||||
// comparable.go
|
||||
|
||||
type foo struct {
|
||||
a int
|
||||
s string
|
||||
}
|
||||
|
||||
type bar struct {
|
||||
a int
|
||||
sl []string
|
||||
}
|
||||
|
||||
func doSomething[T comparable](t T) T {
|
||||
var a T
|
||||
if a == t {
|
||||
}
|
||||
|
||||
if a != t {
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func main() {
|
||||
doSomething(true)
|
||||
doSomething(3)
|
||||
doSomething(3.14)
|
||||
doSomething(3 + 4i)
|
||||
doSomething("hello")
|
||||
var p *int
|
||||
doSomething(p)
|
||||
doSomething(make(chan int))
|
||||
doSomething([3]int{1, 2, 3})
|
||||
doSomething(foo{})
|
||||
doSomething(bar{}) // bar does not implement comparable
|
||||
}
|
||||
|
||||
|
||||
我们看到,最后一行bar结构体类型因为内含不支持比较的切片类型,被Go编译器认为未实现comparable接口,但除此之外的其他类型作为类型实参都满足comparable约束的要求。
|
||||
|
||||
此外还要注意,comparable虽然也是一个interface,但它不能像普通interface类型那样来用,比如下面代码会导致编译器报错:
|
||||
|
||||
var i comparable = 5 // 编译器错误:cannot use type comparable outside a type constraint: interface is (or embeds) comparable
|
||||
|
||||
|
||||
从编译器的错误提示,我们看到:comparable只能用作修饰类型参数的约束。
|
||||
|
||||
好了,学了两个内置约束了,下面我们再来看看如何自定义约束。
|
||||
|
||||
自定义约束
|
||||
|
||||
前面说过,Go泛型最终决定使用interface语法来定义约束。这样一来,凡是接口类型均可作为类型参数的约束。下面是一个使用普通接口类型作为类型参数约束的示例:
|
||||
|
||||
// stringify.go
|
||||
|
||||
func Stringify[T fmt.Stringer](s []T) (ret []string) {
|
||||
for _, v := range s {
|
||||
ret = append(ret, v.String())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type MyString string
|
||||
|
||||
func (s MyString) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func main() {
|
||||
sl := Stringify([]MyString{"I", "love", "golang"})
|
||||
fmt.Println(sl) // 输出:[I love golang]
|
||||
}
|
||||
|
||||
|
||||
这个例子中,我们使用的是fmt.Stringer接口作为约束。一方面,这要求类型参数T的实参必须实现fmt.Stringer接口的所有方法;另一方面,泛型函数Stringify的实现代码中,声明的T类型实例(比如v)也仅被允许调用fmt.Stringer的String方法。
|
||||
|
||||
这类基于行为(方法集合)定义的约束对于习惯了Go接口类型的开发者来说,是相对好理解的,定义和使用起来,与下面这样的以接口类型作为形参的普通Go函数相比,区别似乎不大:
|
||||
|
||||
func Stringify(s []fmt.Stringer) (ret []string) {
|
||||
for _, v := range s {
|
||||
ret = append(ret, v.String())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
|
||||
但现在我想扩展一下上面stringify.go这个示例,将Stringify的语义改为只处理非零值的元素:
|
||||
|
||||
// stringify_without_zero.go
|
||||
|
||||
func StringifyWithoutZero[T fmt.Stringer](s []T) (ret []string) {
|
||||
var zero T
|
||||
for _, v := range s {
|
||||
if v == zero { // 编译器报错:invalid operation: v == zero (incomparable types in type set)
|
||||
continue
|
||||
}
|
||||
ret = append(ret, v.String())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
|
||||
我们看到,针对v的相等性判断导致了编译器报错,我们需要为类型参数赋予更多的能力,比如支持相等性和不等性比较。这让我们想起了我们刚刚学过的Go内置约束comparable,实现comparable的类型,便可以支持相等性和不等性判断操作了。
|
||||
|
||||
我们知道,comparable虽然不能像普通接口类型那样声明变量,但它却可以作为类型嵌入到其他接口类型中,下面我们就扩展一下上面示例:
|
||||
|
||||
// stringify_new_without_zero.go
|
||||
type Stringer interface {
|
||||
comparable
|
||||
String() string
|
||||
}
|
||||
|
||||
func StringifyWithoutZero[T Stringer](s []T) (ret []string) {
|
||||
var zero T
|
||||
for _, v := range s {
|
||||
if v == zero {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, v.String())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type MyString string
|
||||
|
||||
func (s MyString) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func main() {
|
||||
sl := StringifyWithoutZero([]MyString{"I", "", "love", "", "golang"}) // 输出:[I love golang]
|
||||
fmt.Println(sl)
|
||||
}
|
||||
|
||||
|
||||
在这个示例里,我们自定义了一个Stringer接口类型作为约束。在该类型中,我们不仅定义了String方法,还嵌入了comparable,这样在泛型函数中,我们用Stringer约束的类型参数就具备了进行相等性和不等性比较的能力了!
|
||||
|
||||
但我们的示例演进还没有完,现在相等性和不等性比较已经不能满足我们需求了,我们还要为之加上对排序行为的支持,并基于排序能力实现下面的StringifyLessThan泛型函数:
|
||||
|
||||
func StringifyLessThan[T Stringer](s []T, max T) (ret []string) {
|
||||
var zero T
|
||||
for _, v := range s {
|
||||
if v == zero || v >= max {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, v.String())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
|
||||
但现在当我们编译上面StringifyLessThan函数时,我们会得到编译器的报错信息:“invalid operation: v >= max (type parameter T is not comparable with >=)”。Go编译器认为Stringer约束的类型参数T不具备排序比较能力。
|
||||
|
||||
如果连排序比较性都无法支持,这将大大限制我们泛型函数的表达能力。但是Go又不支持运算符重载(operator overloading),不允许我们定义出下面这样的接口类型作为类型参数的约束:
|
||||
|
||||
type Stringer[T any] interface {
|
||||
String() string
|
||||
comparable
|
||||
>(t T) bool
|
||||
>=(t T) bool
|
||||
<(t T) bool
|
||||
<=(t T) bool
|
||||
}
|
||||
|
||||
|
||||
那我们又该如何做呢?别担心,Go核心团队显然也想到了这一点,于是对Go接口类型声明语法做了扩展,支持在接口类型中放入类型元素(type element)信息,比如下面的ordered接口类型:
|
||||
|
||||
type ordered interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
|
||||
~float32 | ~float64 | ~string
|
||||
}
|
||||
|
||||
|
||||
在这个接口类型的声明中,我们没有看到任何方法,取而代之的是一组由竖线“|”分隔的、带着小尾巴“~”的类型列表。这个列表表示的是,以它们为底层类型(underlying type)的类型都满足ordered约束,都可以作为以ordered为约束的类型参数的类型实参,传入泛型函数。
|
||||
|
||||
我们将其组合到我们声明的Stringer接口中,然后应用一下我们的StringifyLessThan函数:
|
||||
|
||||
type Stringer interface {
|
||||
ordered
|
||||
comparable
|
||||
String() string
|
||||
}
|
||||
|
||||
func main() {
|
||||
sl := StringifyLessThan([]MyString{"I", "", "love", "", "golang"}, MyString("cpp")) // 输出:[I]
|
||||
fmt.Println(sl)
|
||||
}
|
||||
|
||||
|
||||
这回编译器没有报错,并且程序输出了预期的结果。
|
||||
|
||||
好了,看了那么多例子,是时候正式对Go接口类型语法的扩展做一个说明了。下面是扩展后的接口类型定义的组成示意图:
|
||||
|
||||
|
||||
|
||||
我们看到,新的接口类型依然可以嵌入其他接口类型,满足组合的设计哲学;除了嵌入的其他接口类型外,其余的组成元素被称为接口元素(interface element)。
|
||||
|
||||
接口元素也有两类,一类就是常规的方法元素(method element),每个方法元素对应一个方法原型;另一类则是此次扩展新增的类型元素(type element),即在接口类型中,我们可以放入一些类型信息,就像前面的ordered接口那样。
|
||||
|
||||
类型元素可以是单个类型,也可以是一组由竖线“|”连接的类型,竖线“|”的含义是“并”,这样的一组类型被称为union element。无论是单个类型,还是union element中由“|”分隔的类型,如果类型中不带有“~”符号的类型就代表其自身;而带有“~”符号的类型则代表以该类型为底层类型(underlying type)的所有类型,这类带有“~”的类型也被称为approximation element,如下面示例:
|
||||
|
||||
type Ia interface {
|
||||
int | string // 仅代表int和string
|
||||
}
|
||||
|
||||
type Ib interface {
|
||||
~int | ~string // 代表以int和string为底层类型的所有类型
|
||||
}
|
||||
|
||||
|
||||
下图是类型元素的分解说明,供你参考:
|
||||
|
||||
|
||||
|
||||
不过要注意的是:union element中不能包含带有方法元素的接口类型,也不能包含预定义的约束类型,如comparable。
|
||||
|
||||
扩展后,Go将接口类型分成了两类,一类是基本接口类型(basic interface type),即其自身和其嵌入的接口类型都只包含方法元素,而不包含类型元素。基本接口类型不仅可以当做常规接口类型来用,即声明接口类型变量、接口类型变量赋值等,还可以作为泛型类型参数的约束。
|
||||
|
||||
除此之外的非空接口类型都属于非基本接口类型,即直接或间接(通过嵌入其他接口类型)包含了类型元素的接口类型。这类接口类型仅可以用作泛型类型参数的约束,或被嵌入到其他仅作为约束的接口类型中,下面的代码就很直观地展示了这两种接口类型的特征:
|
||||
|
||||
type BasicInterface interface { // 基本接口类型
|
||||
M1()
|
||||
}
|
||||
|
||||
type NonBasicInterface interface { // 非基本接口类型
|
||||
BasicInterface
|
||||
~int | ~string // 包含类型元素
|
||||
}
|
||||
|
||||
type MyString string
|
||||
|
||||
func (MyString) M1() {
|
||||
}
|
||||
|
||||
func foo[T NonBasicInterface](a T) { // 非基本接口类型作为约束
|
||||
}
|
||||
|
||||
func bar[T BasicInterface](a T) { // 基本接口类型作为约束
|
||||
}
|
||||
|
||||
func main() {
|
||||
var s = MyString("hello")
|
||||
var bi BasicInterface = s // 基本接口类型支持常规用法
|
||||
var nbi NonBasicInterface = s // 非基本接口不支持常规用法,导致编译器错误:cannot use type NonBasicInterface outside a type constraint: interface contains type constraints
|
||||
bi.M1()
|
||||
nbi.M1()
|
||||
foo(s)
|
||||
bar(s)
|
||||
}
|
||||
|
||||
|
||||
看到这里,你可能会觉得有问题了:基本接口类型,由于其仅包含方法元素,我们依旧可以基于之前讲过的方法集合,来确定一个类型是否实现了接口,以及是否可以作为类型实参传递给约束下的类型形参。但对于只能作为约束的非基本接口类型,既有方法元素,也有类型元素,我们如何判断一个类型是否满足约束,并作为类型实参传给类型形参呢?
|
||||
|
||||
这时我们就要介绍Go泛型落地时引入的新概念:类型集合(type set),类型集合将作为后续判断类型是否满足约束的基本手段。
|
||||
|
||||
类型集合(type set)
|
||||
|
||||
类型集合(type set)的概念是Go核心团队在2021年4月更新Go泛型设计方案时引入的。在那一次方案变更中,原方案中用于接口类型中定义类型元素的type关键字被去除了,泛型相关语法得到了进一步的简化。
|
||||
|
||||
一旦确定了一个接口类型的类型集合,类型集合中的元素就可以满足以该接口类型作为的类型约束,也就是可以将该集合中的元素作为类型实参传递给该接口类型约束的类型参数。
|
||||
|
||||
那么类型集合究竟是怎么定义的呢?下面我们来看一下。
|
||||
|
||||
结合Go泛型设计方案以及Go语法规范,我们可以这么来理解类型集合:
|
||||
|
||||
|
||||
每个类型都有一个类型集合;
|
||||
非接口类型的类型的类型集合中仅包含其自身,比如非接口类型T,它的类型集合为{T},即集合中仅有一个元素且这唯一的元素就是它自身。
|
||||
|
||||
|
||||
但我们最终要搞懂的是用于定义约束的接口类型的类型集合,所以以上这两点都是在为下面接口类型的类型集合定义做铺垫,定义如下:
|
||||
|
||||
|
||||
空接口类型(any或interface{})的类型集合是一个无限集合,该集合中的元素为所有非接口类型。这个与我们之前的认知也是一致的,所有非接口类型都实现了空接口类型;
|
||||
非空接口类型的类型集合则是其定义中接口元素的类型集合的交集(如下图)。
|
||||
|
||||
|
||||
|
||||
|
||||
由此可见,要想确定一个接口类型的类型集合,我们需要知道其中每个接口元素的类型集合。
|
||||
|
||||
上面我们说过,接口元素可以是其他嵌入接口类型,可以是常规方法元素,也可以是类型元素。当接口元素为其他嵌入接口类型时,该接口元素的类型集合就为该嵌入接口类型的类型集合;而当接口元素为常规方法元素时,接口元素的类型集合就为该方法的类型集合。
|
||||
|
||||
到这里你可能会很疑惑:一个方法也有自己的类型集合?
|
||||
|
||||
是的。Go规定一个方法的类型集合为所有实现了该方法的非接口类型的集合,这显然也是一个无限集合,如下图所示:
|
||||
|
||||
|
||||
|
||||
通过方法元素的类型集合,我们也可以合理解释仅包含多个方法的常规接口类型的类型集合,那就是这些方法元素的类型集合的交集,即所有实现了这三个方法的类型所组成的集合。
|
||||
|
||||
最后我们再来看看类型元素。类型元素的类型集合相对来说是最好理解的,每个类型元素的类型集合就是其表示的所有类型组成的集合。如果是~T形式,则集合中不仅包含T本身,还包含所有以T为底层类型的类型。如果使用Union element,则类型集合是所有竖线“|”连接的类型的类型集合的并集。
|
||||
|
||||
讲了这么多,我们来做个稍复杂些的实例分析,我们来分析一下下面接口类型I的类型集合:
|
||||
|
||||
type Intf1 interface {
|
||||
~int | string
|
||||
F1()
|
||||
F2()
|
||||
}
|
||||
|
||||
type Intf2 interface {
|
||||
~int | ~float64
|
||||
}
|
||||
|
||||
type I interface {
|
||||
Intf1
|
||||
M1()
|
||||
M2()
|
||||
int | ~string | Intf2
|
||||
}
|
||||
|
||||
|
||||
我们看到,接口类型I由四个接口元素组成,分别是Intf1、M1、M2和Union element “int | ~string | Intf2”,我们只要分别求出这四个元素的类型集合,再取一个交集即可。
|
||||
|
||||
|
||||
Intf1的类型集合
|
||||
|
||||
|
||||
Intf1是接口类型I的一个嵌入接口,它自身也是由三个接口元素组成,它的类型集合为这三个接口元素的交集,即{以int为底层类型的所有类型、string、实现了F1和F2方法的所有类型}。
|
||||
|
||||
|
||||
M1和M2的类型集合
|
||||
|
||||
|
||||
就像前面所说的,方法的类型集合是由所有实现该方法的类型组成的,因此M1的方法集合为{实现了M1的所有类型},M2的方法集合为{实现了M2的所有类型}。
|
||||
|
||||
|
||||
int | ~string | Intf2 的类型集合
|
||||
|
||||
|
||||
这是一个类型元素,它的类型集合为int、~string和Intf2方法集合的并集。int类型集合就是{int},~string的类型集合为{以string为底层类型的所有类型},而Intf2的方法集合为{以int为底层类型的所有类型,以float64为底层类型的所有类型}。
|
||||
|
||||
为了更好地说明最终类型集合是如何取得的,我们在下面再列一下各个接口元素的类型集合:
|
||||
|
||||
|
||||
Intf1的类型集合:{以int为底层类型的所有类型、string、实现了F1和F2方法的所有类型};
|
||||
M1的类型集合:{实现了M1的所有类型};
|
||||
M2的类型集合:{实现了M2的所有类型};
|
||||
int | ~string | Intf2 的类型集合:{以 int 为底层类型的所有类型,以 float64 为底层类型的所有类型,以string为底层类型的所有类型}。
|
||||
|
||||
|
||||
接下来我们取一下上面集合的交集,也就是{以int为底层类型的且实现了F1、F2、M1、M2这个四个方法的所有类型}。
|
||||
|
||||
现在我们用代码来验证一下:
|
||||
|
||||
// typeset.go
|
||||
|
||||
func doSomething[T I](t T) {
|
||||
}
|
||||
|
||||
type MyInt int
|
||||
|
||||
func (MyInt) F1() {
|
||||
}
|
||||
func (MyInt) F2() {
|
||||
}
|
||||
func (MyInt) M1() {
|
||||
}
|
||||
func (MyInt) M2() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
var a int = 11
|
||||
//doSomething(a) //int does not implement I (missing F1 method)
|
||||
|
||||
var b = MyInt(a)
|
||||
doSomething(b) // ok
|
||||
}
|
||||
|
||||
|
||||
如上代码,我们定义了一个以int为底层类型的自定义类型MyInt并实现了四个方法,这样MyInt就满足了泛型函数doSomething中约束I的要求,可以作为类型实参传递。
|
||||
|
||||
简化版的约束形式
|
||||
|
||||
在前面的讲解和示例中,泛型参数的约束都是一个完整的接口类型,要么是独立定义在泛型函数外面(比如下面代码中的I接口),要么以接口字面值的形式,直接放在类型参数列表中对类型参数进行约束,比如下面示例中doSomething2类型参数列表中的接口类型字面值:
|
||||
|
||||
type I interface { // 独立于泛型函数外面定义
|
||||
~int | ~string
|
||||
}
|
||||
|
||||
func doSomething1[T I](t T)
|
||||
func doSomething2[T interface{~int | ~string}](t T) // 以接口类型字面值作为约束
|
||||
|
||||
|
||||
但在约束对应的接口类型中仅有一个接口元素,且该元素为类型元素时,Go提供了简化版的约束形式,我们不必将约束独立定义为一个接口类型,比如上面的doSomething2可以简写为下面简化形式:
|
||||
|
||||
func doSomething2[T ~int | ~string](t T) // 简化版的约束形式
|
||||
|
||||
|
||||
你看,这个简化版的约束形式就是去掉了interface关键字和外围的大括号,如果用一个一般形式来表述,那就是:
|
||||
|
||||
func doSomething[T interface {T1 | T2 | ... | Tn}](t T)
|
||||
|
||||
等价于下面简化版的约束形式:
|
||||
|
||||
func doSomething[T T1 | T2 | ... | Tn](t T)
|
||||
|
||||
|
||||
这种简化形式也可以理解为一种类型约束的语法糖。不过有一种情况要注意,那就是定义仅包含一个类型参数的泛型类型时,如果约束中仅有一个*int型类型元素,我们使用上述简化版形式就会有问题,比如:
|
||||
|
||||
type MyStruct [T * int]struct{} // 编译错误:undefined: T
|
||||
// 编译错误:int (type) is not an expression
|
||||
|
||||
|
||||
当遇到这种情况时,Go编译器会将该语句理解为一个类型声明:MyStruct为新类型的名字,而其底层类型为[T * int]struct{},即一个元素为空结构体类型的数组。
|
||||
|
||||
那么怎么解决这个问题呢?目前有两种方案,一种是用完整形式的约束:
|
||||
|
||||
type MyStruct[T interface{*int}] struct{}
|
||||
|
||||
|
||||
另外一种则是在简化版约束的*int类型后面加上一个逗号:
|
||||
|
||||
type MyStruct[T *int,] struct{}
|
||||
|
||||
|
||||
最后我们再来说说与约束有关的类型推断。
|
||||
|
||||
约束的类型推断
|
||||
|
||||
在上一讲中,我们提到了在大多数情况下,我们都可以使用类型推断避免在调用泛型函数时显式传入类型实参,Go泛型可以根据泛型函数的实参推断出类型实参。但当我们遇到下面示例中的泛型函数时,光依靠函数类型实参的推断是无法完全推断出所有类型实参的:
|
||||
|
||||
func DoubleDefined[S ~[]E, E constraints.Integer](s S) S {
|
||||
|
||||
|
||||
因为像DoubleDefined这样的泛型函数,其类型参数E在其常规参数列表中并未被用来声明输入参数,函数类型实参推断仅能根据传入的s的类型,推断出类型参数S的类型实参,E是无法推断出来的。
|
||||
|
||||
所以为了进一步避免开发者显式传入类型实参,Go泛型支持了约束类型推断(constraint type inference),即基于一个已知的类型实参(已经由函数类型实参推断判断出来了),来推断其他类型参数的类型。
|
||||
|
||||
我们还以上面DoubleDefined这个泛型函数为例,当通过实参推断得到类型S后,Go会尝试启动约束类型推断来推断类型参数E的类型。但你可能也看出来了,约束类型推断可成功应用的前提是S是由E所表示的。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
这一讲我们聚焦在Go泛型的一个难点,约束上面。我们先从Go泛型内置的约束any和comparable入手,充分了解了约束对于泛型函数的类型参数以及泛型函数中的实现代码的限制与影响。然后,我们学习如何自定义约束,知道了因为Go不支持操作符重载,单纯依赖基于行为的接口类型(仅包含方法元素)作约束是无法满足泛型函数的要求的。这样我们进一步学习了Go接口类型的扩展语法:支持类型元素。
|
||||
|
||||
既有方法元素,也有类型元素,对于作为约束的非基本接口类型,我们就不能像以前那样仅凭是否实现方法集合来判断是否实现了该接口,新的判定手段为类型集合。
|
||||
|
||||
类型集合并没有改变什么,只是对哪些类型实现了某接口类型进行了重新解释。并且,类型集合不是一个运行时概念,我们目前还无法通过运行时反射直观看到一个接口类型的类型集合是什么!
|
||||
|
||||
Go内置了像any、comparable的约束,后续随着Go核心团队在Go泛型使用上的经验的逐渐丰富,Go标准库中会增加更多可直接使用的约束。原计划在Go 1.18版本加入Go标准库的一些泛型约束的定义暂放在了Go实验仓库中,你可以自行参考。
|
||||
|
||||
思考题
|
||||
|
||||
在typeset.go那个示例中,如果将Intf1由:
|
||||
|
||||
type Intf1 interface {
|
||||
~int | string
|
||||
F1()
|
||||
F2()
|
||||
}
|
||||
|
||||
|
||||
改为:
|
||||
|
||||
type Intf1 interface {
|
||||
int | string
|
||||
F1()
|
||||
F2()
|
||||
}
|
||||
|
||||
|
||||
那么接口类型I的类型集合变成了什么呢?请你思考一下。
|
||||
|
||||
|
||||
|
||||
|
||||
404
专栏/TonyBai·Go语言第一课/41驯服泛型:明确使用时机.md
Normal file
404
专栏/TonyBai·Go语言第一课/41驯服泛型:明确使用时机.md
Normal file
@@ -0,0 +1,404 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
41 驯服泛型:明确使用时机
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在前面关于Go泛型的两讲中,我们学习了Go泛型的基本语法类型参数,掌握了使用Go内置约束和自定义约束的方法,并对Go泛型新引入的类型集合概念做了全面说明。有了上面的知识铺垫后,我相信你已经具备了应用泛型语法编写泛型函数、定义泛型类型和方法的能力了。
|
||||
|
||||
不过,Go对泛型的支持,在提升了Go语言表达力的同时,也带来了不小的复杂性。也就是说,使用了泛型语法编写的代码在可读性、可理解性以及可维护性方面,相比于非泛型代码都有一定程度的下降。Go当初没有及时引入泛型的一个原因就是泛型与Go语言“简单”的设计哲学有悖,现在加入了泛型,Go核心团队以及Go社区却又开始担心“泛型被滥用”。
|
||||
|
||||
不过作为Go语言开发人员,我们每个人都有义务去正确、适当的使用泛型,而不是滥用或利用泛型炫技,因此在泛型篇的这最后一讲中,我就来说说什么时机适合使用泛型,供你参考。
|
||||
|
||||
何时适合使用泛型?
|
||||
|
||||
Go泛型语法体现在类型参数上,所以说,类型参数适合的场景就是适合应用泛型编程的时机。我们先来看看类型参数适合的第一种场景。
|
||||
|
||||
场景一:编写通用数据结构时
|
||||
|
||||
在Go尚不支持泛型的时候,如果要实现一个通用的数据结构,比如一个先入后出的stack数据结构,我们通常有两个方案。
|
||||
|
||||
第一种方案是为每种要使用的元素类型单独实现一套栈结构。如果我们要在栈里管理int型数据,我们就实现一个IntStack;如果要管理string类型数据,我们就再实现一个StringStack……总之,我们需要根据可能使用到的元素类型实现出多种专用的栈结构。
|
||||
|
||||
这种方案的优点是便于编译器的静态类型检查,保证类型安全,且运行性能很好,因为Go编译器可以对代码做出很好的优化。不过这种方案的缺点也很明显,那就是会有大量的重复代码。
|
||||
|
||||
第二种方案是使用interface{}实现通用数据结构。
|
||||
|
||||
在泛型之前,Go语言中唯一具有“通用”语义的语法就是interface{}了。无论Go标准库还是第三方实现的通用数据结构都是基于interface{}实现的,比如下面标准库中ring包中Ring结构就是使用interface{}作为元素类型的:
|
||||
|
||||
// $GOROOT/src/container/ring/ring.go
|
||||
type Ring struct {
|
||||
next, prev *Ring
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
|
||||
使用interface{}固然可以实现通用数据结构,但interface{}接口类型的固有特性也决定了这个方案也自带以下“先天不足”:
|
||||
|
||||
|
||||
Go编译器无法在编译阶段对进入数据结构中的元素的类型进行静态类型检查;
|
||||
要想得到元素的真实类型,不可避免要进行类型断言或type switch操作;
|
||||
不同类型数据赋值给interface{}或从interface{}还原时执行的装箱和拆箱操作带来的额外开销。
|
||||
|
||||
|
||||
我们可以看到,以上两个方案都有各自的不足,那么有比较理想的方案么?
|
||||
|
||||
有的,那就是使用Go泛型。其实不止Go语言,其他支持泛型的主流编程语言的通用数据结构实现也都使用了泛型。下面是用Go泛型实现一个stack数据结构的示例代码:
|
||||
|
||||
// stack.go
|
||||
package stack
|
||||
|
||||
type Stack[T any] []T
|
||||
|
||||
func (s *Stack[T]) Top() (t T) {
|
||||
l := len(*s)
|
||||
if l == 0 {
|
||||
return t
|
||||
}
|
||||
return (*s)[l-1]
|
||||
}
|
||||
|
||||
func (s *Stack[T]) Push(v T) {
|
||||
(*s) = append((*s), v)
|
||||
}
|
||||
|
||||
func (s *Stack[T]) Len() int {
|
||||
return len(*s)
|
||||
}
|
||||
|
||||
func (s *Stack[T]) Pop() (T, bool) {
|
||||
var zero T
|
||||
if len(*s) < 1 {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
// Get the last element from the stack.
|
||||
result := (*s)[len(*s)-1]
|
||||
|
||||
// Remove the last element from the stack.
|
||||
*s = (*s)[:len(*s)-1]
|
||||
|
||||
return result, true
|
||||
}
|
||||
|
||||
|
||||
泛型版实现基本消除了前面两种方案的不足,如果非要说和IntStack、StringStack等的差异,那可能就是在执行性能上要差一些了:
|
||||
|
||||
$go test -bench .
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
pkg: stack
|
||||
BenchmarkStack-8 72775926 19.53 ns/op 40 B/op 0 allocs/op
|
||||
BenchmarkIntStack-8 100000000 10.43 ns/op 45 B/op 0 allocs/op
|
||||
PASS
|
||||
|
||||
|
||||
当然,泛型版本性能略差与泛型的实现原理有关,这个我们后面再细说。
|
||||
|
||||
场景二:函数操作的是Go原生的容器类型时
|
||||
|
||||
如果函数具有切片、map或channel这些Go内置容器类型的参数,并且函数代码未对容器中的元素类型做任何特定假设,那我们使用类型参数可能很有帮助。
|
||||
|
||||
39讲中的maxGenerics那个例子就是这个情况,我们再回顾一下:
|
||||
|
||||
// max_generics.go
|
||||
type ordered interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
|
||||
~float32 | ~float64 |
|
||||
~string
|
||||
}
|
||||
|
||||
func maxGenerics[T ordered](sl []T) T {
|
||||
if len(sl) == 0 {
|
||||
panic("slice is empty")
|
||||
}
|
||||
|
||||
max := sl[0]
|
||||
for _, v := range sl[1:] {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
|
||||
我们看到,类型参数使得此类容器算法与容器内元素类型彻底解耦。在没有泛型语法之前,实现这样的函数通常需要使用反射。不过使用反射,会让代码可读性大幅下降,编译器也无法做静态类型检查,并且运行时开销也大得很。
|
||||
|
||||
场景三:不同类型实现一些方法的逻辑相同时
|
||||
|
||||
在Go编码过程中,我们经常会遇到这样一种情况,某个函数接受一个自定义接口类型作为参数,就像下面的doSomething函数以及其参数类型MyInterface接口:
|
||||
|
||||
type MyInterface interface {
|
||||
M1()
|
||||
M2()
|
||||
M3()
|
||||
}
|
||||
|
||||
func doSomething(i MyInterface) {
|
||||
}
|
||||
|
||||
|
||||
只有实现了MyInterface中全部三个方法的类型,才被允许作为实参传递给doSomething函数。当这些类型实现M1、M2和M3的逻辑看起来都相同时,我们就可以使用类型参数来帮助实现M1~M3这些方法了,下面就是通过类型参数实现这些方法的通用逻辑代码(实际逻辑做了省略处理):
|
||||
|
||||
// common_method.go
|
||||
|
||||
type commonMethod[T any] struct{}
|
||||
|
||||
func (commonMethod[T]) M1() {}
|
||||
func (commonMethod[T]) M2() {}
|
||||
func (commonMethod[T]) M3() {}
|
||||
|
||||
func main() {
|
||||
var intThings commonMethod[int]
|
||||
var stringThings commonMethod[string]
|
||||
doSomething(intThings)
|
||||
doSomething(stringThings)
|
||||
}
|
||||
|
||||
|
||||
我们看到,使用不同类型,比如int、string等作为commonMethod的类型实参就可以得到相应实现了M1~M3的类型的变量,比如intThings、stringThings,这些变量可以直接作为实参传递给doSomething函数。
|
||||
|
||||
当然我们也可以再封装一个泛型函数来简化上述调用:
|
||||
|
||||
func doSomethingCM[T any]() {
|
||||
doSomething(commonMethod[T]{})
|
||||
}
|
||||
|
||||
func main() {
|
||||
doSomethingCM[int]()
|
||||
doSomethingCM[string]()
|
||||
}
|
||||
|
||||
|
||||
这里的doSomethingCM泛型函数将commonMethod泛型类型实例化与调用doSomething函数的过程封装到一起,使得commonMethod泛型类型的使用进一步简化了。
|
||||
|
||||
其实,Go标准库的sort.Sort就是这样的情况,其参数类型为sort.Interface,而sort.Interface接口中定义了三个方法:
|
||||
|
||||
// $GOROOT/src/sort/sort.go
|
||||
func Sort(data Interface)
|
||||
|
||||
type Interface interface {
|
||||
Len() int
|
||||
Less(i, j int) bool
|
||||
Swap(i, j int)
|
||||
}
|
||||
|
||||
|
||||
所有实现sort.Interface类型接口的类型,在实现Len、Less和Swap这三个通用方法的逻辑看起来都相同,比如sort.go中提供的StringSlice和IntSlice两种类型的三个方法的实现如下:
|
||||
|
||||
type StringSlice []string
|
||||
|
||||
func (x StringSlice) Len() int { return len(x) }
|
||||
func (x StringSlice) Less(i, j int) bool { return x[i] < x[j] }
|
||||
func (x StringSlice) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
|
||||
|
||||
type IntSlice []int
|
||||
|
||||
func (x IntSlice) Len() int { return len(x) }
|
||||
func (x IntSlice) Less(i, j int) bool { return x[i] < x[j] }
|
||||
func (x IntSlice) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
|
||||
|
||||
|
||||
在这样的情况下,我们就可以通过类型参数来给出这三个方法的通用实现,这里我将其作为本讲的思考题留给你自己去实现。
|
||||
|
||||
不过要注意:如果多个类型实现上述方法的逻辑并不相同,那么我们就不应该使用类型参数。
|
||||
|
||||
好了,到这里最适合使用泛型的时机我都已经介绍了一遍。如果非要总结为一条,那就是:如果你发现自己多次编写完全相同的代码,其中副本之间的唯一区别是代码使用不同的类型,那么可考虑使用类型参数了。
|
||||
|
||||
假使你目前遇到的场景适合使用泛型,你可能依然会犹豫要不要使用泛型,因为你还不清楚泛型对代码执行性能的影响。特别是在一些性能敏感的系统中,这一点尤为重要。那么如何知道泛型对执行性能的影响呢?这就要从Go泛型实现原理说起了。
|
||||
|
||||
Go泛型实现原理简介
|
||||
|
||||
我在泛型加餐一文中曾提过:Go核心团队对泛型实现的探索开始得很早,在2009年12月,Go团队技术领导者Russ Cox就在其博客站点上发表一篇名为“泛型窘境”的文章。在这篇文章中,Russ Cox提出了Go面对泛型可遵循的三个路径以及每个路径的不足,也就是三个slow(拖慢):
|
||||
|
||||
|
||||
C语言路径:不实现泛型,不会引入复杂性,但这会“拖慢程序员”,因为可能需要程序员花费精力做很多重复实现;
|
||||
C++语言路径:就像C++的泛型实现方案那样,通过增加编译器负担为每个类型实参生成一份单独的泛型函数的实现,这种方案产生了大量的代码,其中大部分是多余的,有时候还需要一个好的链接器来消除重复的拷贝,显然这个实现路径会“拖慢编译器”;
|
||||
Java路径:就像Java的泛型实现方案那样,通过隐式的装箱和拆箱操作消除类型差异,虽然节省了空间,但代码执行效率低,即“拖慢执行性能”。
|
||||
|
||||
|
||||
如今Go加入了泛型,显然C语言的“拖慢程序员”这个路径被否决了,那么在剩下两个路径中,Go选择了哪条呢?下面我们就来真正看一下Go泛型的实现方案。
|
||||
|
||||
Go核心团队在评估Go泛型实现方案时是非常谨慎的,负责泛型实现设计的Keith Randall博士一口气提交了三个实现方案,供大家讨论和选择:
|
||||
|
||||
|
||||
Stenciling方案;
|
||||
Dictionaries方案;
|
||||
GC Shape Stenciling方案。
|
||||
|
||||
|
||||
为了让你更好地理解泛型实现原理,我先来逐一对上述方案做个简单介绍。我们首先看一下Stenciling方案。
|
||||
|
||||
Stenciling方案
|
||||
|
||||
|
||||
|
||||
Stenciling方案也称为模板方案(如上图), 它也是C++、Rust等语言使用的实现方案。其主要思路就是在编译阶段,根据泛型函数调用时类型实参或约束中的类型元素,为每个实参类型或类型元素中的类型生成一份单独实现。这么说还是很抽象,下图很形象地说明了这一过程:
|
||||
|
||||
|
||||
|
||||
我们看到,Go编译器为每个调用生成一个单独的函数副本(图中函数名称并非真实的,仅为便于说明而做的命名),相同类型实参的函数只生成一次,或通过链接器消除不同包的相同函数实现。
|
||||
|
||||
图示的这一过程在其他编程语言中也被称为“单态化(monomorphization)”。单态是相对于泛型函数的参数化多态(parametric polymorphism)而言的。
|
||||
|
||||
Randall博士也提到了这种方案的不足,那就是拖慢编译器。泛型函数需要针对不同类型进行单独编译并生成一份独立的代码。如果类型非常多,那么编译出来的最终文件可能会非常大。同时由于CPU缓存无法命中、指令分支预测等问题,可能导致生成的代码运行效率不高。
|
||||
|
||||
当然,对于性能不高这个说辞,我个人持保留态度,因为模板方案在其他编程语言中基本上是没有额外的运行时开销的,并且是应该是对编译器优化友好的。很多面向系统编程的语言都选择该方案,比如C++、D语言、Rust等。
|
||||
|
||||
Dictionaries方案
|
||||
|
||||
Dictionaries方案与Stenciling方案的实现思路正相反,它不会为每个类型实参单独创建一套代码,反之它仅会有一套函数逻辑,但这个函数会多出一个参数dict,这个参数会作为该函数的第一个参数,这和Go方法的receiver参数在方法调用时自动作为第一个参数有些类似。这个dict参数中保存泛型函数调用时的类型实参的类型相关信息。下面是Dictionaries方案的示意图:
|
||||
|
||||
|
||||
|
||||
包含类型信息的字典是Go编译器在编译期间生成的,并且被保存在ELF的只读数据区段(.data)中,传给函数的dict参数中包含了到特定字典的指针。从方案描述来看,每个dict中的类型信息还是十分复杂的,不过我们了解这些就够了,对dict的结构就不展开说明了。
|
||||
|
||||
这种方案也有自身的问题,比如字典递归的问题,如果调用某个泛型函数的类型实参有很多,那么dict信息也会过多等等。更重要的是它对性能可能有比较大的影响,比如通过dict的指针的间接类型信息和方法的访问导致运行时开销较大;再比如,如果泛型函数调用时的类型实参是int,那么如果使用Stenciling方案,我们可以通过寄存器复制即可实现x=y的操作,但在Dictionaries方案中,必须通过memmove了。
|
||||
|
||||
Go最终采用的方案:GC Shape Stenciling方案
|
||||
|
||||
GC Shape Stenciling方案顾名思义,它基于Stenciling方案,但又没有为所有类型实参生成单独的函数代码,而是以一个类型的GC shape为单元进行函数代码生成。一个类型的GC shape是指该类型在Go内存分配器/垃圾收集器中的表示,这个表示由类型的大小、所需的对齐方式以及类型中包含指针的部分所决定。
|
||||
|
||||
这样一来势必就有GC shape相同的类型共享一个实例化后的函数代码,那么泛型调用时又是如何区分这些类型的呢?
|
||||
|
||||
答案就是字典。该方案同样在每个实例化后的函数代码中自动增加了一个dict参数,用于区别GC shape相同的不同类型。可见,GC Shape Stenciling方案本质上是Stenciling方案和Dictionaries方案的混合版,它也是Go 1.18泛型最终采用的实现方案,为此Go团队还给出一个更细化、更接近于实现的GC Shape Stenciling实现方案。
|
||||
|
||||
下面是GC Shape Stenciling方案的示意图:
|
||||
|
||||
|
||||
|
||||
那么如今的Go版本(Go 1.19.x)究竟会为哪些类型实例化出一份独立的函数代码呢?我们通过下面示例来看一下:
|
||||
|
||||
// gcshape.go
|
||||
func f[T any](t T) T {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
|
||||
type MyInt int
|
||||
|
||||
func main() {
|
||||
f[int](5)
|
||||
f[MyInt](15)
|
||||
f[int64](6)
|
||||
f[uint64](7)
|
||||
f[int32](8)
|
||||
f[rune](18)
|
||||
f[uint32](9)
|
||||
f[float64](3.14)
|
||||
f[string]("golang")
|
||||
|
||||
var a int = 5
|
||||
f[*int](&a)
|
||||
var b int32 = 15
|
||||
f[*int32](&b)
|
||||
var c float64 = 8.88
|
||||
f[*float64](&c)
|
||||
var s string = "hello"
|
||||
f[*string](&s)
|
||||
}
|
||||
|
||||
|
||||
在这个示例中,我们声明了一个简单的泛型函数f,然后分别用不同的Go原生类型、自定义类型以及指针类型作为类型实参对f进行调用,我们通过工具为上述goshape.go生成的汇编代码如下:
|
||||
|
||||
|
||||
|
||||
从上图我们看到,Go编译器为每个底层类型相同的类型生成一份函数代码,像MyInt和int、rune和int32;对于所有指针类型,像上面的*float64、_int和_int32,仅生成一份名为main.f[go.shape.*uint8_0]的函数代码。
|
||||
|
||||
这与新版GC shape方案中的描述是一致的:“我们目前正在以一种相当精细的方式实现gc shapes。当且仅当两个具体类型具有相同的底层类型或者它们都是指针类型时,它们才会在同一个gcshape分组中”。
|
||||
|
||||
泛型对执行效率的影响
|
||||
|
||||
通过上面对Go泛型实现原理的了解,我们看到目前的Go泛型实现选择了一条折中的路线:既没有选择纯Stenciling方案,避免了对Go编译性能带去较大影响,也没有选择像Java那样泛型那样的纯装箱和拆箱方案,给运行时带去较大开销。
|
||||
|
||||
但GC Shape+Dictionaries的混合方案也确实会给泛型在运行时的执行效率带去影响,我们来看一个简单的实例:
|
||||
|
||||
// benchmark_simple/add.go
|
||||
type plusable interface {
|
||||
~int | ~string
|
||||
}
|
||||
|
||||
func add[T plusable](a, b T) T {
|
||||
return a + b
|
||||
}
|
||||
|
||||
func addInt(a, b int) int {
|
||||
return a + b
|
||||
}
|
||||
func addString(a, b string) string {
|
||||
return a + b
|
||||
}
|
||||
|
||||
|
||||
这个示例用于对比泛型函数实例化后的函数代码(如add[int])的性能与单态下的函数(如addInt)性能,下面是benchmark代码:
|
||||
|
||||
// benchmark_simple/add_test.go
|
||||
func BenchmarkAddInt(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
var m, n int = 5, 6
|
||||
for i := 0; i < b.N; i++ {
|
||||
addInt(m, n)
|
||||
}
|
||||
}
|
||||
func BenchmarkAddIntGeneric(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
var m, n int = 5, 6
|
||||
for i := 0; i < b.N; i++ {
|
||||
add(m, n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
运行这个benchmark:
|
||||
|
||||
$go test -bench .
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
pkg: demo
|
||||
BenchmarkAddInt-8 1000000000 0.2692 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkAddIntGeneric-8 1000000000 1.074 ns/op 0 B/op 0 allocs/op
|
||||
PASS
|
||||
ok demo 1.491s
|
||||
|
||||
|
||||
我们看到,与单态化的addInt相比,泛型函数add实例化后的add[int]的执行性能还是下降了很多。这个问题在Go官方issue中也有Gopher提出。
|
||||
|
||||
不过好消息是:在Go 1.20版本中,由于将使用Unified IR(中间代码表示)替换现有的IR表示,Go泛型函数的执行性能将得到进一步优化,上述的benchmark中两个函数的执行性能将不分伯仲,Go 1.19中也可使用GOEXPERIMENT=unified来开启Unified IR试验性功能。
|
||||
|
||||
我们在Unified IR开启的情况下再跑一次上面的benchmark:
|
||||
|
||||
$GOEXPERIMENT=unified go test -bench .
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
pkg: demo
|
||||
BenchmarkAddInt-8 1000000000 0.2713 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkAddIntGeneric-8 1000000000 0.2723 ns/op 0 B/op 0 allocs/op
|
||||
|
||||
|
||||
这次的对比结果就非常理想了!
|
||||
|
||||
综上,我建议你在一些性能敏感的系统中,还是要慎用尚未得到足够性能优化的泛型;而在性能不那么敏感的情况下,在符合前面泛型使用时机的时候,我们还是可以大胆使用泛型语法的。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲中,我们探讨了有关Go泛型的一个重要的问题:何时使用泛型。泛型语法的加入,不可避免地提升了Go语法的复杂性,为了防止Gopher滥用泛型,我们给出了几个Go泛型最适合应用的场景,包括:编写通用数据结构、编写操作Go原生容器类型时以及不同类型实现一些方法的逻辑看起来相同时。除此之外的其他场景下,如果你要使用泛型,务必慎重并深思熟虑。
|
||||
|
||||
Go泛型的编译性能和执行性能也是影响我们是否应用泛型的重要因素,Go核心团队在Go泛型实现方案的选择上也是煞费苦心,最终选择了GC shape stenciling的混合方案,目前这个方案很大程度避免了对Go编译性能的影响,但对Go泛型代码的执行效率依然存在不小影响。相信经过几个版本打磨和优化后,Go泛型的执行性能会有提升,甚至能接近于非泛型的单态版。
|
||||
|
||||
这里我还要提一下,Go泛型的实现方案也可能在未来版本中发生变化,从目前看,本讲中的内容仅针对Go 1.18和Go 1.19的GC Shape stenciling方案适用。
|
||||
|
||||
思考题
|
||||
|
||||
请你为Go标准库sort.Interface接口类型提供一个像文中示例common_method.go中那样的通用方法的泛型实现。
|
||||
|
||||
至此,泛型篇三讲就彻底讲完了。如果你有什么问题,欢迎在评论区留言。
|
||||
|
||||
|
||||
|
||||
|
||||
25
专栏/TonyBai·Go语言第一课/元旦快乐这是一份暂时停更的声明.md
Normal file
25
专栏/TonyBai·Go语言第一课/元旦快乐这是一份暂时停更的声明.md
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
元旦快乐 这是一份暂时停更的声明
|
||||
你好,我是Tony Bai。
|
||||
|
||||
马上就是元旦了,又是新的一年。祝你在新的一年里身体倍儿棒,万事儿顺畅,事业蒸蒸上。
|
||||
|
||||
和新年祝福一起来的,是一份暂时停更的请求。因为最近家里有一些紧急情况要处理,我忙得焦头烂额,已经有好几天没怎么睡觉了。这个突发事件也打乱了我们的备稿计划,让原本就紧张的稿件雪上加霜,没有办法继续维持每周三篇的更新。
|
||||
|
||||
你也能看到,前面我更新了两篇加餐,一篇大咖助阵和一篇用户故事。除了目录中本就计划好的一篇加餐外,剩下的三篇都是我们后面筹备的。看留言的时候,我也发现有一些同学一直在催我更新正文。
|
||||
|
||||
但准备一篇正文,真的挺耗时的。平均五六千字的一篇文章,我需要挑灯夜战三天以上才能完成初稿。编辑打磨也不轻松,一篇文章往往要来回修改多次。如果强行赶工,压缩写稿与打磨的时间,那是对每一位用户的不负责,我不想这么做。既然你付费学习我这个专栏,那我肯定要保证课程的质量,给你呈现最优质的内容。
|
||||
|
||||
所以很抱歉,再三考虑,我决定:在2022年1月1日至1月6日期间,暂停更新两讲内容,全力备稿。希望你可以理解。
|
||||
|
||||
在这段时间内,你可以复习一下之前的学习内容,查缺补漏,进度稍慢一些的同学也可以趁假期赶赶进度。另外元旦假期后,我们将开始讲解Go语言的一个重点,也就是Go并发方面的内容。你也可以在假期闲暇时先预习一下这方面的内容,这对提升你的学习效果会很有帮助。
|
||||
|
||||
祝你度过一个愉快的元旦假期,我们2022年1月7日再见。
|
||||
|
||||
|
||||
|
||||
|
||||
332
专栏/TonyBai·Go语言第一课/加餐作为GoModule的作者,你应该知道的几件事.md
Normal file
332
专栏/TonyBai·Go语言第一课/加餐作为GoModule的作者,你应该知道的几件事.md
Normal file
@@ -0,0 +1,332 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐 作为Go Module的作者,你应该知道的几件事
|
||||
你好,我是Tony Bai。
|
||||
|
||||
我们的专栏在06和07讲对Go Module构建模式的原理,以及如何使用Go Module构建模式做了详细的讲解,在课后留言中,我看到很多同学直呼过瘾,表示终于搞清楚Go Module构建模式了。
|
||||
|
||||
不过,之前的讲解更多是从Go Module的使用者角度出发的。在这篇加餐中,我们再从Go Module的作者或维护者的视角,来聊聊在规划、发布和维护Go Module时需要考虑和注意什么事情,包括go项目仓库布局、Go Module的发布、升级module主版本号、作废特定版本的module,等等。
|
||||
|
||||
我们先来看看作为Go Module作者在规划module时遇到的第一个问题:一个代码仓库(repo)管理一个module,还是一个仓库管理多个module?
|
||||
|
||||
仓库布局:是单module还是多module
|
||||
|
||||
如果没有单一仓库(monorepo)的强约束,那么在默认情况下,你选择一个仓库管理一个module是不会错的,这是管理Go Module的最简单的方式,也是最常用的标准方式。这种方式下,module维护者维护起来会很方便,module的使用者在引用module下面的包时,也可以很容易地确定包的导入路径。
|
||||
|
||||
举个简单的例子,我们在github.com/bigwhite/srsm这个仓库下管理着一个Go Module(srsm是single repo single module的缩写)。
|
||||
|
||||
通常情况下,module path与仓库地址保持一致,都是github.com/bigwhite/srsm,这点会体现在go.mod中:
|
||||
|
||||
// go.mod
|
||||
module github.com/bigwhite/srsm
|
||||
|
||||
go 1.17
|
||||
|
||||
|
||||
然后我们对仓库打tag,这个tag也会成为Go Module的版本号,这样,对仓库的版本管理其实就是对Go Module的版本管理。
|
||||
|
||||
如果这个仓库下的布局是这样的:
|
||||
|
||||
./srsm
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── pkg1/
|
||||
│ └── pkg1.go
|
||||
└── pkg2/
|
||||
└── pkg2.go
|
||||
|
||||
|
||||
那么这个module的使用者可以很轻松地确定pkg1和pkg2两个包的导入路径,一个是github.com/bigwhite/srsm/pkg1,另一个则是github.com/bigwhite/srsm/pkg2。
|
||||
|
||||
如果module演进到了v2.x.x版本,那么以pkg1包为例,它的包的导入路径就变成了github.com/bigwhite/srsm/v2/pkg1。
|
||||
|
||||
如果组织层面要求采用单一仓库(monorepo)模式,也就是所有Go Module都必须放在一个repo下,那我们只能使用单repo下管理多个Go Module的方法了。
|
||||
|
||||
记得Go Module的设计者Russ Cox曾说过:“在单repo多module的布局下,添加module、删除module,以及对module进行版本管理,都需要相当谨慎和深思熟虑,因此,管理一个单module的版本库,几乎总是比管理现有版本库中的多个module要容易和简单”。
|
||||
|
||||
我们也用一个例子来感受一下这句话的深意。
|
||||
|
||||
这里是一个单repo多module的例子,我们假设repo地址是github.com/bigwhite/srmm。这个repo下的结构布局如下(srmm是single repo multiple modules的缩写):
|
||||
|
||||
./srmm
|
||||
├── module1
|
||||
│ ├── go.mod
|
||||
│ └── pkg1
|
||||
│ └── pkg1.go
|
||||
└── module2
|
||||
├── go.mod
|
||||
└── pkg2
|
||||
└── pkg2.go
|
||||
|
||||
|
||||
srmm仓库下面有两个Go Module,分为位于子目录module1和module2的下面,这两个目录也是各自module的根目录(module root)。这种情况下,module的path也不能随意指定,必须包含子目录的名字。
|
||||
|
||||
我们以module1为例分析一下,它的path是github.com/bigwhite/srmm/module1,只有这样,Go命令才能根据用户导入包的路径,找到对应的仓库地址和在仓库中的相对位置。同理,module1下的包名同样是以module path为前缀的,比如:github.com/bigwhite/srmm/module1/pkg1。
|
||||
|
||||
在单仓库多module模式下,各个module的版本是独立维护的。因此,我们在通过打tag方式发布某个module版本时,tag的名字必须包含子目录名。比如:如果我们要发布module1的v1.0.0版本,我们不能通过给仓库打v1.0.0这个tag号来发布module1的v1.0.0版本,正确的作法应该是打module1/v1.0.0这个tag号。
|
||||
|
||||
你现在可能觉得这样理解起来也没有多复杂,但当各个module的主版本号升级时,你就会感受到这种方式带来的繁琐了,这个我们稍后再细说。
|
||||
|
||||
发布Go Module
|
||||
|
||||
当我们的module完成开发与测试,module便可以发布了。发布的步骤也十分简单,就是为repo打上tag并推送到代码服务器上就好了。
|
||||
|
||||
如果采用单repo单module管理方式,那么我们给repo打的tag就是module的版本。如果采用的是单repo多module的管理方式,那么我们就需要注意在tag中加上各个module的子目录名,这样才能起到发布某个module版本的作用,否则module的用户通过go get xxx@latest也无法看到新发布的module版本。
|
||||
|
||||
而且,这里还有一个需要你特别注意的地方,如果你在发布正式版之前先发布了alpha或beta版给大家公测使用,那么你一定要提醒你的module的使用者,让他们通过go get指定公测版本号来显式升级依赖,比如:
|
||||
|
||||
$go get github.com/bigwhite/[email protected]
|
||||
|
||||
|
||||
这样,go get工具才会将使用者项目依赖的github.com/bigwhite/srsm的版本更新为v1.1.0-beta.1。而我们通过go get github.com/bigwhite/srsm@latest,是不会获取到像上面v1.1.0-beta.1这样的发布前的公测版本的。
|
||||
|
||||
多数情况下,Go Module的维护者可以正确地发布Go Module。但人总是会犯错的,作为Go Module的作者或维护者,我们偶尔也会出现这样的低级错误:将一个处于broken状态的module发布了出去。那一旦出现这样的情况,我们该怎么做呢?我们继续向下看。
|
||||
|
||||
作废特定版本的Go Module
|
||||
|
||||
我们先来看看如果发布了错误的module版本会对module的使用者带去什么影响。
|
||||
|
||||
我们直接来看一个例子。假设bitbucket.org/bigwhite/m1是我维护的一个Go Module,它目前已经演进到v1.0.1版本了,并且有两个使用者c1和c2,你可以看下这个示意图,能更直观地了解m1当前的状态:
|
||||
|
||||
|
||||
|
||||
某一天,我一不小心,就把一个处于broken状态的module版本,[email protected]发布出去了!此时此刻,m1的v1.0.2版本还只存在于它的源仓库站点上,也就是bitbucket/bigwhite/m1中,在任何一个GoProxy服务器上都还没有这个版本的缓存。
|
||||
|
||||
这个时候,依赖m1的两个项目c1和c2依赖的仍然是[email protected]版本。也就是说,如果没有显式升级m1的版本,c1和c2的构建就不会受到处于broken状态的module v1.0.2版本的影响,这也是Go Module最小版本选择的优点。
|
||||
|
||||
而且,由于[email protected]还没有被GoProxy服务器缓存,在GOPROXY环境变量开启的情况下,go list是查不到m1有可升级的版本的:
|
||||
|
||||
// 以c2为例:
|
||||
$go list -m -u all
|
||||
github.com/bigwhite/c2
|
||||
bitbucket.org/bigwhite/m1 v1.0.1
|
||||
|
||||
|
||||
但如若我们绕开GOPROXY,那么go list就可以查找到m1的最新版本为v1.0.2(我们通过设置GONOPROXY来让go list查询m1的源仓库而不是代理服务器上的缓存):
|
||||
|
||||
$GONOPROXY="bitbucket.org/bigwhite/m1" go list -m -u all
|
||||
github.com/bigwhite/c2
|
||||
bitbucket.org/bigwhite/m1 v1.0.1 [v1.0.2]
|
||||
|
||||
|
||||
之后,如果某个m1的消费者,比如c2,通过go get bitbucket.org/bigwhite/[email protected]对m1的依赖版本进行了显式更新,那就会触发GOPROXY对[email protected]版本的缓存。这样一通操作后,module proxy以及m1的消费者的当前的状态就会变成这样:
|
||||
|
||||
|
||||
|
||||
由于Goproxy服务已经缓存了m1的v1.0.2版本,这之后,m1的其他消费者,比如c1就能够在GOPROXY开启的情况下查询到m1存在新版本v1.0.2,即便它是broken的:
|
||||
|
||||
// 以c1为例:
|
||||
$go list -m -u all
|
||||
github.com/bigwhite/c1
|
||||
bitbucket.org/bigwhite/m1 v1.0.1 [v1.0.2]
|
||||
|
||||
|
||||
但是,一旦broken的m1版本(v1.0.2)进入到GoProxy的缓存,那么它的“危害性”就会“大肆传播”开。这时module m1的新消费者都将受到影响!
|
||||
|
||||
比如这里我们引入一个新的消费者c3,c3的首次构建就会因m1的损坏而报错:
|
||||
|
||||
|
||||
|
||||
到这里,糟糕的情况已经出现了!那我们怎么作废掉[email protected]版本来修复这个问题呢?
|
||||
|
||||
如果在GOPATH时代,废掉一个之前发的包版本是分分钟的事情,因为那时包消费者依赖的都是latest commit。包作者只要fix掉问题、提交并重新发布就可以了。
|
||||
|
||||
但是在Go Module时代,作废掉一个已经发布了的Go Module版本还真不是一件能轻易做好的事情。这很大程度是源于大量Go Module代理服务器的存在,Go Module代理服务器会将已发布的broken的module缓存起来。下面我们来看看可能的问题解决方法。
|
||||
|
||||
修复broken版本并重新发布
|
||||
|
||||
要解决掉这个问题,Go Module作者有一个很直接的解决方法,就是:修复broken的module版本并重新发布。它的操作步骤也很简单:m1的作者只需要删除掉远程的tag: v1.0.2,在本地fix掉问题,然后重新tag v1.0.2并push发布到bitbucket上的仓库中就可以了。
|
||||
|
||||
但这样做真的能生效么?
|
||||
|
||||
理论上,如果m1的所有消费者,都通过m1所在代码托管服务器(bitbucket)来获取m1的特定版本,那么这种方法还真能解决掉这个问题。对于已经get到broken v1.0.2的消费者来说,他们只需清除掉本地的module cache(go clean -modcache),然后再重新构建就可以了;对于m1的新消费者,他们直接得到的就是重新发布后的v1.0.2版本。
|
||||
|
||||
但现实的情况时,现在大家都是通过Goproxy服务来获取module的。
|
||||
|
||||
所以,一旦一个module版本被发布,当某个消费者通过他配置的goproxy获取这个版本时,这个版本就会在被缓存在对应的代理服务器上。后续m1的消费者通过这个goproxy服务器获取那个版本的m1时,请求不会再回到m1所在的源代码托管服务器。
|
||||
|
||||
这样,即便m1的源服务器上的v1.0.2版本得到了重新发布,散布在各个goproxy服务器上的broken v1.0.2也依旧存在,并且被“传播”到各个m1消费者的开发环境中,而重新发布后的v1.0.2版本却得不到“传播”的机会,我们还是用一张图来直观展示下这种“窘境”:
|
||||
|
||||
|
||||
|
||||
因此,从消费者的角度看,m1的v1.0.2版本依旧是那个broken的版本,这种解决措施无效!
|
||||
|
||||
那你可能会问,如果m1的作者删除了bitbucket上的v1.0.2这个发布版本,各大goproxy服务器上的broken v1.0.2版本是否也会被同步删除呢?
|
||||
|
||||
遗憾地告诉你:不会。
|
||||
|
||||
Goproxy服务器当初的一个设计目标,就是尽可能地缓存更多module。所以,即便某个module的源码仓库都被删除了,这个module的各个版本依旧会缓存在goproxy服务器上,这个module的消费者依然可以正常获取这个module,并顺利构建。
|
||||
|
||||
因此,goproxy服务器当前的实现都没有主动删掉某个module缓存的特性。当然了,这可能也不是绝对的,毕竟不同goproxy服务的实现有所不同。
|
||||
|
||||
那这种问题该怎么解决呢?这种情况下,Go社区更为常见的解决方式就是发布module的新patch版本
|
||||
|
||||
发布module的新patch版本
|
||||
|
||||
我们依然以上面的m1为例,现在我们废除掉v1.0.2,在本地修正问题后,直接打v1.0.3标签,并发布push到远程代码服务器上。这样m1的消费者以及module proxy的整体状态就变成这个样子了:
|
||||
|
||||
|
||||
|
||||
在这样的状态下,我们分别看看m1的消费者的情况:
|
||||
|
||||
|
||||
对于依赖[email protected]版本的c1,在未手工更新依赖版本的情况下,它仍然可以保持成功的构建;
|
||||
对于m1的新消费者,比如c4,它首次构建时使用的就是m1的最新patch版v1.0.3,跨过了作废的v1.0.2,并成功完成构建;
|
||||
对于之前曾依赖v1.0.2版本的消费者c2来说,这个时候他们需要手工介入才能解决问题,也就是需要在c2环境中手工升级依赖版本到v1.0.3,这样c2也会得到成功构建。
|
||||
|
||||
|
||||
那这样,我们错误版本的问题就得到了缓解。
|
||||
|
||||
从Go 1.16版本开始,Go Module作者还可以在go.mod中使用新增加的retract指示符,标识出哪些版本是作废的且不推荐使用的。retract的语法形式如下:
|
||||
|
||||
// go.mod
|
||||
retract v1.0.0 // 作废v1.0.0版本
|
||||
retract [v1.1.0, v1.2.0] // 作废v1.1.0和v1.2.0两个版本
|
||||
|
||||
|
||||
我们还用m1为例,我们将m1的go.mod更新为如下内容:
|
||||
|
||||
//m1的go.mod
|
||||
module bitbucket.org/bigwhite/m1
|
||||
|
||||
go 1.17
|
||||
|
||||
retract v1.0.2
|
||||
|
||||
|
||||
然后将m1放入v1.0.3标签中并发布。现在m1的消费者c2要查看m1是否有最新版本时,可以查看到以下内容(c2本地环境使用go1.17版本):
|
||||
|
||||
$GONOPROXY=bitbucket.org/bigwhite/m1 go list -m -u all
|
||||
... ...
|
||||
bitbucket.org/bigwhite/m1 v1.0.2 (retracted) [v1.0.3]
|
||||
|
||||
|
||||
从go list的输出结果中,我们看到了v1.0.2版本上有了retracted的提示,提示这个版本已经被m1的作者作废了,不应该再使用,应升级为v1.0.3。
|
||||
|
||||
但retracted仅仅是一个提示作用,并不影响go build的结果,c2环境(之前在go.mod中依赖m1的v1.0.2)下的go build不会自动绕过v1.0.2,除非显式更新到v1.0.3。
|
||||
|
||||
不过,上面的这个retract指示符适合标记要作废的独立的minor和patch版本,如果要提示用某个module的某个大版本整个作废,我们用Go 1.17版本引入的Deprecated注释行更适合。下面是使用Deprecated注释行的例子:
|
||||
|
||||
// Deprecated: use bitbucket.org/bigwhite/m1/v2 instead.
|
||||
module bitbucket.org/bigwhite/m1
|
||||
|
||||
|
||||
如果我们在module m1的go.mod中使用了Deprecated注释,那么m1的消费者在go get获取m1版本时,或者是通过go list查看m1版本时,会收到相应的作废提示,以go get为例:
|
||||
|
||||
$go get bitbucket.org/bigwhite/m1@latest
|
||||
go: downloading bitbucket.org/bigwhite/m1 v1.0.3
|
||||
go: module bitbucket.org/bigwhite/m1 is deprecated: use bitbucket.org/bigwhite/m1/v2 instead.
|
||||
... ...
|
||||
|
||||
|
||||
不过Deprecated注释的影响也仅限于提示,它不会影响到消费者的项目构建与使用。
|
||||
|
||||
升级module的major版本号
|
||||
|
||||
随着module的演化,总有一天module会出现不兼容以前版本的change,这就到了需要升级module的major版本号的时候了。
|
||||
|
||||
在前面的讲解中,我们学习了Go Module的语义导入版本机制,也就是Go Module规定:如果同一个包的新旧版本是兼容的,那么它们的包导入路径应该是相同的。反过来说,如果新旧两个包不兼容,那么应该采用不同的导入路径。
|
||||
|
||||
而且,我们知道,Go团队采用了将“major版本”作为导入路径的一部分的设计。这种设计支持在同一个项目中,导入同一个repo下的不同major版本的module,比如:
|
||||
|
||||
import (
|
||||
"bitbucket.org/bigwhite/m1/pkg1" // 导入major版本号为v0或v1的module下的pkg1
|
||||
pkg1v2 "bitbucket.org/bigwhite/m1/v2/pkg1" // 导入major版本号为v2的module下的pkg1
|
||||
)
|
||||
|
||||
|
||||
我们可以认为:在同一个repo下,不同major号的module就是完全不同的module,甚至同一repo下,不同major号的module可以相互导入。
|
||||
|
||||
这样一来,对于module作者/维护者而言,升级major版本号,也就意味着高版本的代码要与低版本的代码彻底分开维护,通常Go社区会采用为新的major版本建立新的major分支的方式,来将不同major版本的代码分离开,这种方案被称为“major branch”的方案。
|
||||
|
||||
major branch方案对于多数gopher来说,是一个过渡比较自然的方案,它通过建立vN分支并基于vN分支打vN.x.x的tag的方式,做major版本的发布。
|
||||
|
||||
那么,采用这种方案的Go Module作者升级major版本号时要怎么操作呢?
|
||||
|
||||
我们以将bitbucket.org/bigwhite/m1的major版本号升级到v2为例看看。首先,我们要建立v2代码分支并切换到v2分支上操作,然后修改go.mod文件中的module path,增加v2后缀:
|
||||
|
||||
//go.mod
|
||||
module bitbucket.org/bigwhite/m1/v2
|
||||
|
||||
go 1.17
|
||||
|
||||
|
||||
这里要特别注意一点,如果module内部包间有相互导入,那么在升级major号的时候,这些包的import路径上也要增加v2,否则,就会存在在高major号的module代码中,引用低major号的module代码的情况,这也是module作者最容易忽略的事情。
|
||||
|
||||
这样一通操作后,我们就将repo下的module分为了两个module了,一个是原先的v0/v1 module,在master/main分支上;新建的v2分支承载了major号为2的module的代码。major号升级的这个操作过程还是很容易出错的,你操作时一定要谨慎。
|
||||
|
||||
对于消费者而言,在它依赖的module进行major版本号升级后,他们只需要在这个依赖module的import路径的后面,增加/vN就可以了(这里是/v2),当然代码中也要针对不兼容的部分进行修改,然后go工具就会自动下载相关module。
|
||||
|
||||
早期Go团队还提供了利用子目录分割不同major版本的方案,我们也看看这种方式怎么样。
|
||||
|
||||
我们还是以bitbucket.org/bigwhite/m1为例,如果这个module已经演化到v3版本了,那么这个module所在仓库的目录结构应该是这样的:
|
||||
|
||||
# tree m1
|
||||
m1
|
||||
├── pkg1
|
||||
│ └── pkg1.go
|
||||
├── go.mod
|
||||
├── v2
|
||||
│ ├── pkg1
|
||||
│ │ └── pkg1.go
|
||||
│ └── go.mod
|
||||
└── v3
|
||||
├── pkg1
|
||||
│ └── pkg1.go
|
||||
└── go.mod
|
||||
|
||||
|
||||
这里我们直接用vN作为子目录名字,在代码仓库中将不同版本module放置在不同的子目录中,这样,go命令就会将仓库内的子目录名与major号匹配并找到对应版本的module。
|
||||
|
||||
从描述上看,似乎这种通过子目录方式来实现major版本号升级,会更“简单”一些。但我总感觉这种方式有些“怪”,而且,其他主流语言也很少有用这种方式进行major版本号升级的。
|
||||
|
||||
另外一旦使用这种方式,我们似乎也很难利用git工具在不同major版本之间进行代码的merge了。目前Go文档中似乎也不再提这种方案了,我个人也建议你尽量使用major分支方案。
|
||||
|
||||
在实际操作中,也有一些Go Module的仓库,始终将master或main分支作为最高major版本的分支,然后建立低版本分支来维护低major版本的module代码,比如:etcd、go-redis等。
|
||||
|
||||
这种方式本质上和前面建立major分支的方式是一样的,并且这种方式更符合一个Go Module演化的趋势和作者的意图,也就是低版本的Go Module随着时间的推移将渐渐不再维护,而最新最高版本的Go Module是module作者最想让使用者使用的版本。
|
||||
|
||||
但在单repo多module管理方式下,升级module的major版本号有些复杂,我们需要分为两种情况来考虑。
|
||||
|
||||
第一种情况:repo下的所有module统一进行版本发布。
|
||||
|
||||
在这种情况下,我们只需要向上面所说的那样建立vN版本分支就可以了,在vN分支上对repo下所有module进行演进,统一打tag并发布。当然tag要采用带有module子目录名的那种方式,比如:module1/v2.0.0。
|
||||
|
||||
etcd项目对旗下的Go Module的统一版本发布,就是用的这种方式。如果翻看一下etcd的项目,你会发现etcd只会建立少量的像release-3.4、release-3.5这样的major分支,基于这些分支,etcd会统一发布moduleName/v3.4.x和moduleName/v3.5.x版本。
|
||||
|
||||
第二个情况:repo下的module各自独立进行版本发布。
|
||||
|
||||
在这种情况下,简单创建一个major号分支来维护module的方式,就会显得不够用了,我们很可能需要建立major分支矩阵。假设我们的一个repo下管理了多个module,从m1到mN,那么major号需要升级时,我们就需要将major版本号与module做一个组合,形成下面的分支矩阵:
|
||||
|
||||
|
||||
|
||||
以m1为例,当m1的major版本号需要升级到2时,我们建立v2_m1 major分支专门用于维护和发布m1 module的v2.x.x版本。
|
||||
|
||||
当然上述的矩阵是一个全矩阵(所有项中都有值),实际项目中可能采用的是稀疏矩阵,也就是并非所有的表格项中都有值,因为repo下各个module的major号升级并不是同步的,有些module的major号可能已经升级到了4,但有些module的major号可能还停留在2。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的加餐讲到这里就结束了,现在我们一起来回顾一下吧。
|
||||
|
||||
在这一讲,我们更多从Go Module的作者或维护者的角度出发,去思考规划、发布和维护Go Module过程中可能遇到的问题以及解决方法。
|
||||
|
||||
Go Module经过多年打磨已经逐渐成熟,对各种Go Module仓库的布局方式都提供了很好的支持。通常情况下,我们会采用在单仓库单module的布局方式,无论是发布module打版本号还是升级major版本号,这种方式都简单易懂,心智负担低。
|
||||
|
||||
当然Go Module也支持在一个仓库下管理多个module,如果使用这种方式,要注意发布某module时,tag名字要包含module目录名,比如:module1/v1.0.1。
|
||||
|
||||
升级module的major版本号时,你一定要注意:如果module内部包间有相互导入,那么这些包的import路径上也要增加vN,否则就会存在在高major号的module代码中,引用低major号的module代码的情况,导致出现一些奇怪的问题。
|
||||
|
||||
此外,发布Go Module时你也一定要谨慎小心,因为一旦将broken的版本发布出去,要想作废这个版本是没有太好的方案的,现有的方案都或多或少对module使用者有些影响。尤其是采用单repo多module的布局方式时,发布module时更是要格外细心。
|
||||
|
||||
思考题
|
||||
|
||||
前面提到过,Go Module只有在引入不兼容的change时才会升级major版本号,那么哪些change属于不兼容的change呢?如何更好更快地识别出这些不兼容change呢?欢迎在留言区谈谈你的想法和实践。
|
||||
|
||||
欢迎你把这节课分享给更多感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
325
专栏/TonyBai·Go语言第一课/加餐如何拉取私有的GoModule?.md
Normal file
325
专栏/TonyBai·Go语言第一课/加餐如何拉取私有的GoModule?.md
Normal file
@@ -0,0 +1,325 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐 如何拉取私有的Go Module?
|
||||
你好,我是Tony Bai。
|
||||
|
||||
我们这门课程上线以来收到了同学们的众多留言与热烈反馈,在这些留言和反馈中,有关Go Module的问题占比比较大,其中又以下面这两个问题比较突出:
|
||||
|
||||
|
||||
在某module尚未发布到类似GitHub这样的网站前,如何import这个本地的module?
|
||||
如何拉取私有module?
|
||||
|
||||
|
||||
借这次加餐机会,今天我就针对这两个问题和你聊聊我知道的一些解决方案。
|
||||
|
||||
首先我们先来看第一个问题:如何导入本地的module。
|
||||
|
||||
导入本地module
|
||||
|
||||
在前面的06和07讲,我们已经系统讲解了Go Module构建模式。Go Module从Go 1.11版本开始引入到Go中,现在它已经成为了Go语言的依赖管理与构建的标准,因此,我也一直建议你彻底抛弃Gopath构建模式,全面拥抱Go Module构建模式。并且,这门课中的所有例子和实战小项目,我使用的都是Go Module构建模式。
|
||||
|
||||
当我们的项目依赖已发布在GitHub等代码托管站点的公共Go Module时,Go命令工具可以很好地完成依赖版本选择以及Go Module拉取的工作。
|
||||
|
||||
不过,如果我们的项目依赖的是本地正在开发、尚未发布到公共站点上的Go Module,那么我们应该如何做呢?我们来看一个例子。
|
||||
|
||||
假设你有一个项目,这个项目中的module a依赖module b,而module b是你另外一个项目中的module,它本来是要发布到github.com/user/b上的。
|
||||
|
||||
但此时此刻,module b还没有发布到公共托管站点上,它源码还在你的开发机器上。也就是说,go命令无法在github.com/user/b上找到并拉取module a的依赖module b,这时,如果你针对module a所在项目使用go mod tidy命令,就会收到类似下面这样的报错信息:
|
||||
|
||||
$go mod tidy
|
||||
go: finding module for package github.com/user/b
|
||||
github.com/user/a imports
|
||||
github.com/user/b: cannot find module providing package github.com/user/b: module github.com/user/b: reading https://goproxy.io/github.com/user/b/@v/list: 404 Not Found
|
||||
server response:
|
||||
not found: github.com/user/b@latest: terminal prompts disabled
|
||||
Confirm the import path was entered correctly.
|
||||
If this is a private repository, see https://golang.org/doc/faq#git_https for additional information.
|
||||
|
||||
|
||||
这个时候,我们就可以借助go.mod的replace指示符,来解决这个问题。解决的步骤是这样的。
|
||||
|
||||
首先,我们需要在module a的go.mod中的require块中,手工加上这一条(这也可以通过go mod edit命令实现):
|
||||
|
||||
require github.com/user/b v1.0.0
|
||||
|
||||
|
||||
注意了,这里的v1.0.0版本号是一个“假版本号”,目的是满足go.mod中require块的语法要求。
|
||||
|
||||
然后,我们再在module a的go.mod中使用replace,将上面对module b v1.0.0的依赖,替换为本地路径上的module b:
|
||||
|
||||
replace github.com/user/b v1.0.0 => module b的本地源码路径
|
||||
|
||||
|
||||
这样修改之后,go命令就会让module a依赖你本地正在开发、尚未发布到代码托管网站的module b的源码了。
|
||||
|
||||
而且,如果module b已经提交到类GitHub的站点上,但module b的作者正在本地开发新版本,那么上面这种方法,也同样适合module b的作者在本地测试验证module b的最新版本源码。
|
||||
|
||||
虽然“伪造”go.mod文件内容,可以解决上述这两个场景中的问题,但显然这种方法也是有“瑕疵”的。
|
||||
|
||||
首先,这个方法中,require指示符将github.com/user/b v1.0.0替换为一个本地路径下的module b的源码版本,但这个本地路径是因开发者环境而异的。
|
||||
|
||||
前面课程中我们讲过,go.mod文件通常是要上传到代码服务器上的,这就意味着,另外一个开发人员下载了这份代码后,极大可能是无法成功编译的,他要想完成module a的编译,就得将replace后面的本地路径改为适配自己环境下的路径。
|
||||
|
||||
于是,每当开发人员pull代码后,第一件事就是要修改module a的go.mod中的replace块,每次上传代码前,可能也要将replace路径复原,这是一个很糟心的事情。但即便如此,目前Go版本(最新为Go 1.17.x)也没有一个完美的应对方案。
|
||||
|
||||
针对这个问题,Go核心团队在Go社区的帮助下,在预计2022年2月发布的Go 1.18版本中加入了Go工作区(Go workspace,也译作Go工作空间)辅助构建机制。
|
||||
|
||||
基于这个机制,我们可以将多个本地路径放入同一个workspace中,这样,在这个workspace下各个module的构建将优先使用workspace下的module的源码。工作区配置数据会放在一个名为go.work的文件中,这个文件是开发者环境相关的,因此并不需要提交到源码服务器上,这就解决了上面“伪造go.mod”方案带来的那些问题。
|
||||
|
||||
不过,Go 1.18版本尚未发布,我这里就不再深入讲解了Go workspace机制了,如果你有兴趣,可以去下载Go 1.18 Beta1版本抢先体验。
|
||||
|
||||
接下来,我们再来看看拉取私有module的可行解决方案。
|
||||
|
||||
拉取私有module的需求与参考方案
|
||||
|
||||
Go 1.11版本引入Go Module构建模式后,用Go命令拉取项目依赖的公共Go Module,已不再是“痛点”,我们只需要在每个开发机上为环境变量GOPROXY,配置一个高效好用的公共GOPROXY服务,就可以轻松拉取所有公共Go Module了:
|
||||
|
||||
|
||||
|
||||
但随着公司内Go使用者和Go项目的增多,“重造轮子”的问题就出现了。抽取公共代码放入一个独立的、可被复用的内部私有仓库成为了必然,这样我们就有了拉取私有Go Module的需求。
|
||||
|
||||
一些公司或组织的所有代码,都放在公共vcs托管服务商那里(比如github.com),私有Go Module则直接放在对应的公共vcs服务的private repository(私有仓库)中。如果你的公司也是这样,那么拉取托管在公共vcs私有仓库中的私有Go Module,也很容易,见下图:
|
||||
|
||||
|
||||
|
||||
也就是说,只要我们在每个开发机上,配置公共GOPROXY服务拉取公共Go Module,同时再把私有仓库配置到GOPRIVATE环境变量,就可以了。这样,所有私有module的拉取,都会直连代码托管服务器,不会走GOPROXY代理服务,也不会去GOSUMDB服务器做Go包的hash值校验。
|
||||
|
||||
当然,这个方案有一个前提,那就是每个开发人员都需要具有访问公共vcs服务上的私有Go Module仓库的权限,凭证的形式不限,可以是basic auth的user和password,也可以是personal access token(类似GitHub那种),只要按照公共vcs的身份认证要求提供就可以了。
|
||||
|
||||
不过,更多的公司/组织,可能会将私有Go Module放在公司/组织内部的vcs(代码版本控制)服务器上,就像下面图中所示:
|
||||
|
||||
|
||||
|
||||
那么这种情况,我们该如何让Go命令,自动拉取内部服务器上的私有Go Module呢?这里给出两个参考方案。
|
||||
|
||||
第一个方案是通过直连组织公司内部的私有Go Module服务器拉取。
|
||||
|
||||
|
||||
|
||||
在这个方案中,我们看到,公司内部会搭建一个内部goproxy服务(也就是上图中的in-house goproxy)。这样做有两个目的,一是为那些无法直接访问外网的开发机器,以及ci机器提供拉取外部Go Module的途径,二来,由于in-house goproxy的cache的存在,这样做还可以加速公共Go Module的拉取效率。
|
||||
|
||||
另外,对于私有Go Module,开发机只需要将它配置到GOPRIVATE环境变量中就可以了,这样,Go命令在拉取私有Go Module时,就不会再走GOPROXY,而会采用直接访问vcs(如上图中的git.yourcompany.com)的方式拉取私有Go Module。
|
||||
|
||||
这个方案十分适合内部有完备IT基础设施的公司。这类型的公司内部的vcs服务器都可以通过域名访问(比如git.yourcompany.com/user/repo),因此,公司内部员工可以像访问公共vcs服务那样,访问内部vcs服务器上的私有Go Module。
|
||||
|
||||
第二种方案,是将外部Go Module与私有Go Module都交给内部统一的GOPROXY服务去处理:
|
||||
|
||||
|
||||
|
||||
在这种方案中,开发者只需要把GOPROXY配置为in-house goproxy,就可以统一拉取外部Go Module与私有Go Module。
|
||||
|
||||
但由于go命令默认会对所有通过goproxy拉取的Go Module,进行sum校验(默认到sum.golang.org),而我们的私有Go Module在公共sum验证server中又没有数据记录。因此,开发者需要将私有Go Module填到GONOSUMDB环境变量中,这样,go命令就不会对其进行sum校验了。
|
||||
|
||||
不过这种方案有一处要注意:in-house goproxy需要拥有对所有private module所在repo的访问权限,才能保证每个私有Go Module都拉取成功。
|
||||
|
||||
你可以对比一下上面这两个参考方案,看看你更倾向于哪一个,我推荐第二个方案。在第二个方案中,我们可以将所有复杂性都交给in-house goproxy这个节点,开发人员可以无差别地拉取公共module与私有module,心智负担降到最低。
|
||||
|
||||
那么我们该怎么实现这个方案呢?接下来我就来分析一个可行的实现思路与具体步骤。
|
||||
|
||||
统一Goproxy方案的实现思路与步骤
|
||||
|
||||
我们先为后续的方案实现准备一个示例环境,它的拓扑如下图:
|
||||
|
||||
|
||||
|
||||
选择一个GOPROXY实现
|
||||
|
||||
Go module proxy协议规范发布后,Go社区出现了很多成熟的Goproxy开源实现,比如有最初的athens,还有国内的两个优秀的开源实现:goproxy.cn和goproxy.io等。其中,goproxy.io在官方站点给出了企业内部部署的方法,所以今天我们就基于goproxy.io来实现我们的方案。
|
||||
|
||||
我们在上图中的in-house goproxy节点上执行这几个步骤安装goproxy:
|
||||
|
||||
$mkdir ~/.bin/goproxy
|
||||
$cd ~/.bin/goproxy
|
||||
$git clone https://github.com/goproxyio/goproxy.git
|
||||
$cd goproxy
|
||||
$make
|
||||
|
||||
|
||||
编译后,我们会在当前的bin目录(~/.bin/goproxy/goproxy/bin)下看到名为goproxy的可执行文件。
|
||||
|
||||
然后,我们建立goproxy cache目录:
|
||||
|
||||
$mkdir /root/.bin/goproxy/goproxy/bin/cache
|
||||
|
||||
|
||||
再启动goproxy:
|
||||
|
||||
$./goproxy -listen=0.0.0.0:8081 -cacheDir=/root/.bin/goproxy/goproxy/bin/cache -proxy https://goproxy.io
|
||||
goproxy.io: ProxyHost https://goproxy.io
|
||||
|
||||
|
||||
启动后,goproxy会在8081端口上监听(即便不指定,goproxy的默认端口也是8081),指定的上游goproxy服务为goproxy.io。
|
||||
|
||||
不过要注意下:goproxy的这个启动参数并不是最终版本的,这里我仅仅想验证一下goproxy是否能按预期工作。我们现在就来实际验证一下。
|
||||
|
||||
首先,我们在开发机上配置GOPROXY环境变量指向10.10.20.20:8081:
|
||||
|
||||
// .bashrc
|
||||
export GOPROXY=http://10.10.20.20:8081
|
||||
|
||||
|
||||
生效环境变量后,执行下面命令:
|
||||
|
||||
$go get github.com/pkg/errors
|
||||
|
||||
|
||||
结果和我们预期的一致,开发机顺利下载了github.com/pkg/errors包。我们可以在goproxy侧,看到了相应的日志:
|
||||
|
||||
goproxy.io: ------ --- /github.com/pkg/@v/list [proxy]
|
||||
goproxy.io: ------ --- /github.com/pkg/errors/@v/list [proxy]
|
||||
goproxy.io: ------ --- /github.com/@v/list [proxy]
|
||||
goproxy.io: 0.146s 404 /github.com/@v/list
|
||||
goproxy.io: 0.156s 404 /github.com/pkg/@v/list
|
||||
goproxy.io: 0.157s 200 /github.com/pkg/errors/@v/list
|
||||
|
||||
|
||||
在goproxy的cache目录下,我们也看到了下载并缓存的github.com/pkg/errors包:
|
||||
|
||||
$cd /root/.bin/goproxy/goproxy/bin/cache
|
||||
$tree
|
||||
.
|
||||
└── pkg
|
||||
└── mod
|
||||
└── cache
|
||||
└── download
|
||||
└── github.com
|
||||
└── pkg
|
||||
└── errors
|
||||
└── @v
|
||||
└── list
|
||||
|
||||
8 directories, 1 file
|
||||
|
||||
|
||||
这就标志着我们的goproxy服务搭建成功,并可以正常运作了。
|
||||
|
||||
自定义包导入路径并将其映射到内部的vcs仓库
|
||||
|
||||
一般公司可能没有为vcs服务器分配域名,我们也不能在Go私有包的导入路径中放入ip地址,因此我们需要给我们的私有Go Module自定义一个路径,比如:mycompany.com/go/module1。我们统一将私有Go Module放在mycompany.com/go下面的代码仓库中。
|
||||
|
||||
那么,接下来的问题就是,当goproxy去拉取mycompany.com/go/module1时,应该得到mycompany.com/go/module1对应的内部vcs上module1 仓库的地址,这样,goproxy才能从内部vcs代码服务器上下载module1对应的代码,具体的过程如下:
|
||||
|
||||
|
||||
|
||||
那么我们如何实现为私有module自定义包导入路径,并将它映射到内部的vcs仓库呢?
|
||||
|
||||
其实方案不止一种,这里我使用了Google云开源的一个名为govanityurls的工具,来为私有module自定义包导入路径。然后,结合govanityurls和nginx,我们就可以将私有Go Module的导入路径映射为其在vcs上的代码仓库的真实地址。具体原理你可以看一下这张图:
|
||||
|
||||
|
||||
|
||||
首先,goproxy要想不把收到的拉取私有Go Module(mycompany.com/go/module1)的请求转发给公共代理,需要在其启动参数上做一些手脚,比如下面这个就是修改后的goproxy启动命令:
|
||||
|
||||
$./goproxy -listen=0.0.0.0:8081 -cacheDir=/root/.bin/goproxy/goproxy/bin/cache -proxy https://goproxy.io -exclude "mycompany.com/go"
|
||||
|
||||
|
||||
这样,凡是与-exclude后面的值匹配的Go Module拉取请求,goproxy都不会转给goproxy.io,而是直接请求Go Module的“源站”。
|
||||
|
||||
而上面这张图中要做的,就是将这个“源站”的地址,转换为企业内部vcs服务中的一个仓库地址。然后我们假设mycompany.com这个域名并不存在(很多小公司没有内部域名解析能力),从图中我们可以看到,我们会在goproxy所在节点的/etc/hosts中加上这样一条记录:
|
||||
|
||||
127.0.0.1 mycompany.com
|
||||
|
||||
|
||||
这样做了后,goproxy发出的到mycompany.com的请求实际上是发向了本机。而上面这图中显示,监听本机80端口的正是nginx,nginx关于mycompany.com这一主机的配置如下:
|
||||
|
||||
// /etc/nginx/conf.d/gomodule.conf
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name mycompany.com;
|
||||
|
||||
location /go {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们看到,对于路径为mycompany.com/go/xxx的请求,nginx将请求转发给了127.0.0.1:8080,而这个服务地址恰恰就是govanityurls工具监听的地址。
|
||||
|
||||
govanityurls这个工具,是前Go核心开发团队成员Jaana B.Dogan开源的一个工具,这个工具可以帮助Gopher快速实现自定义Go包的go get导入路径。
|
||||
|
||||
govanityurls本身,就好比一个“导航”服务器。当go命令向自定义包地址发起请求时,实际上是将请求发送给了govanityurls服务,之后,govanityurls会将请求中的包所在仓库的真实地址(从vanity.yaml配置文件中读取)返回给go命令,后续go命令再从真实的仓库地址获取包数据。
|
||||
|
||||
|
||||
注:govanityurls的安装方法很简单,直接go install/go get github.com/GoogleCloudPlatform/govanityurls就可以了。-
|
||||
在我们的示例中,vanity.yaml的配置如下:
|
||||
|
||||
|
||||
host: mycompany.com
|
||||
|
||||
paths:
|
||||
/go/module1:
|
||||
repo: ssh://[email protected]/module1
|
||||
vcs: git
|
||||
|
||||
|
||||
也就是说,当govanityurls收到nginx转发的请求后,会将请求与vanity.yaml中配置的module路径相匹配,如果匹配ok,就会将该module的真实repo地址,通过go命令期望的应答格式返回。在这里我们看到,module1对应的真实vcs上的仓库地址为:ssh://[email protected]/module1。
|
||||
|
||||
所以,goproxy会收到这个地址,并再次向这个真实地址发起请求,并最终将module1缓存到本地cache并返回给客户端。
|
||||
|
||||
开发机(客户端)的设置
|
||||
|
||||
前面示例中,我们已经将开发机的GOPROXY环境变量,设置为goproxy的服务地址。但我们说过,凡是通过GOPROXY拉取的Go Module,go命令都会默认把它的sum值放到公共GOSUM服务器上去校验。
|
||||
|
||||
但我们实质上拉取的是私有Go Module,GOSUM服务器上并没有我们的Go Module的sum数据。这样就会导致go build命令报错,无法继续构建过程。
|
||||
|
||||
因此,开发机客户端还需要将mycompany.com/go,作为一个值设置到GONOSUMDB环境变量中:
|
||||
|
||||
export GONOSUMDB=mycompany.com/go
|
||||
|
||||
|
||||
这个环境变量配置一旦生效,就相当于告诉go命令,凡是与mycompany.com/go匹配的Go Module,都不需要在做sum校验了。
|
||||
|
||||
到这里,我们就实现了拉取私有Go Module的方案。
|
||||
|
||||
方案的“不足”
|
||||
|
||||
当然这个方案并不是完美的,它也有自己的不足的地方:
|
||||
|
||||
第一点:开发者还是需要额外配置GONOSUMDB变量。
|
||||
|
||||
由于Go命令默认会对从GOPROXY拉取的Go Module进行sum校验,因此我们需要将私有Go Module配置到GONOSUMDB环境变量中,这就给开发者带来了一个小小的“负担”。
|
||||
|
||||
对于这个问题,我的解决建议是:公司内部可以将私有go项目都放在一个特定域名下,这样就不需要为每个go私有项目单独增加GONOSUMDB配置了,只需要配置一次就可以了。
|
||||
|
||||
第二点:新增私有Go Module,vanity.yaml需要手工同步更新。
|
||||
|
||||
这是这个方案最不灵活的地方了,由于目前govanityurls功能有限,针对每个私有Go Module,我们可能都需要单独配置它对应的vcs仓库地址,以及获取方式(git、svn or hg)。
|
||||
|
||||
关于这一点,我的建议是:在一个vcs仓库中管理多个私有Go Module。相比于最初go官方建议的一个repo只管理一个module,新版本的go在一个repo下管理多个Go Module方面,已经有了长足的进步,我们已经可以通过repo的tag来区别同一个repo下的不同Go Module。
|
||||
|
||||
不过对于一个公司或组织来说,这点额外工作与得到的收益相比,应该也不算什么!
|
||||
|
||||
第三点:无法划分权限。
|
||||
|
||||
在讲解上面的方案的时候我们也提到过,goproxy所在节点需要具备访问所有私有Go Module所在vcs repo的权限,但又无法对go开发者端做出有差别授权,这样,只要是goproxy能拉取到的私有Go Module,go开发者都能拉取到。
|
||||
|
||||
不过对于多数公司而言,内部所有源码原则上都是企业内部公开的,这个问题似乎也不大。如果觉得这是个问题,那么只能使用前面提到的第一个方案,也就是直连私有Go Module的源码服务器的方案了。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的加餐讲到这里就结束了。今天我们针对前期专栏反馈较多的有关Go Module的两个问题,进行了逐一分析,给出了各自的可行的方案。
|
||||
|
||||
针对导入本地Go Module的问题,在Go 1.18版本未发布之前,最好的方法就是使用replace“大法”,通过“伪造”的go.mod让go命令优先使用项目依赖的Go Module的本地版本。不过这个方案,也会给开发人员协作方面带去一些额外负担,要想完美解决这一问题,还需要等待加入了Go工作区机制的Go 1.18版本。
|
||||
|
||||
无论大厂小厂,对Go的使用逐渐深入,接纳Go的人以及Go项目逐渐增多后,拉取私有Go Module这样的问题肯定会摆到桌面上来。这一讲我们介绍了直连私有Go Module源码服务器和使用统一GOPROXY代理两种方案,我推荐你使用第二种方案,可以降低开发人员拉取私有module的心智负担。
|
||||
|
||||
思考题
|
||||
|
||||
针对我们这一讲提到的两个问题,你是否有自己的不同的解决方案呢?如果有,欢迎在留言区分享一下你采用的方案。
|
||||
|
||||
欢迎把这一节课分享给更多感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
231
专栏/TonyBai·Go语言第一课/加餐我“私藏”的那些优质且权威的Go语言学习资料.md
Normal file
231
专栏/TonyBai·Go语言第一课/加餐我“私藏”的那些优质且权威的Go语言学习资料.md
Normal file
@@ -0,0 +1,231 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐 我“私藏”的那些优质且权威的Go语言学习资料
|
||||
你好,我是Tony Bai。
|
||||
|
||||
学习编程语言并没有捷径,就像我们在开篇词中提到的那样,脑勤+手勤才是正确的学习之路。不过,留言区也一直有同学问我,除了这门课之外,还有什么推荐的Go语言学习资料。今天我们就来聊聊这个话题。
|
||||
|
||||
如今随着互联网的高速发展,现在很多同学学习编程语言,已经从技术书籍转向了各种屏幕,以专栏或视频实战课为主,技术书籍等参考资料为辅的学习方式已经成为主流。当然,和传统的、以编程类书籍为主的学习方式相比,谈不上哪种方式更好,只不过它更适合如今快节奏的生活工作状态,更适合碎片化学习占主流的学习形态罢了。
|
||||
|
||||
但在编程语言的学习过程中,技术书籍等参考资料依旧是不可或缺的,优秀的参考资料是编程语言学习过程的催化剂,拥有正确的、权威的参考资料可以让你减少反复查找资料所浪费的时间与精力,少走弯路。
|
||||
|
||||
这节课我会给你分享下我“私藏”的Go语言学习的参考资料,包括一些经典的技术书籍和其他电子形式的参考资料。
|
||||
|
||||
虽然现在编程语言学习可参考的资料形式、种类已经非常丰富了,但技术类书籍(包括电子版)在依旧占据着非常重要的地位。所以,我们就先重点看看在Go语言学习领域,有哪些优秀的书籍值得我们认真阅读。
|
||||
|
||||
Go技术书籍
|
||||
|
||||
和C(1972年)、C++(1983)、Java(1995)、Python(1991)等编程语言在市面上的书籍数量相比,Go流行于市面(尤其是中国大陆地区)上的图书要少很多。究其原因,可能有以下几个:
|
||||
|
||||
首先,我觉得主要原因还是Go语言太年轻了。尽管Go刚刚过完它12岁的生日,但和上面这些语言中“最年轻”的Java语言之间也还有14年的“年龄差”。
|
||||
|
||||
其次,Go以品类代名词的身份占据的“领域”还很少。提到Web,人们想到的是Java Spring;提到深度学习、机器学习、人工智能,人们想到的是Python,提到游戏,人们想到的是C++;提到前端,人们想到的是JavaScript。这些语言在这些垂直领域早早以杀手级框架入场,使得它们成为了这一领域的“品类代名词”。
|
||||
|
||||
但Go语言诞生晚,入场也较晚。Go虽然通过努力覆盖了一些领域并占据优势地位,比如云原生、API、微服务、区块链,等等,但还不能说已经成为了这些领域的“品类代名词”,因此被垂直领域书籍关联的机会也不像上面那几门语言那么多。
|
||||
|
||||
最后是翻译的时间问题。相对于国内,国外关于Go语言的作品要多不少,但引进国外图书资料需要时机以及时间(毕竟要找译者翻译)。
|
||||
|
||||
Go在国内真正开始快速流行起来,大致是在2015年第一届GopherChina大会(2015年4月)之后,当时的Go版本是1.4。同一年下半年发布的Go 1.5版本实现了Go的自举,并让GC延迟大幅下降,让Go在国内彻底流行开来。一批又一批程序员成为Gopher,在大厂、初创公司实践着Go语言。但知识和技能的沉淀和总结需要时间。
|
||||
|
||||
不过,2020年开始,国内作者出版的Go语言相关书籍已经逐渐多了起来。2022年加入泛型的Go 1.18版本发布后,相信会有更多Gopher加入Go技术书籍的写作行列,在未来3年,国内Go语言技术书籍也会迎来一波高峰。
|
||||
|
||||
我个人接触Go语言比较早,几乎把Go语言相关的中外文书籍都通读过一遍,其中几本经典好书甚至还读过不止一遍。所以这里我也会给你推荐几本我认为系统学习Go语言必读的经典好书。说实在的,Go语言比较简单,如果单单从系统掌握这门语言的角度来看,阅读下面这几本书籍就足够了。
|
||||
|
||||
这几本书我按作者名气、关注度、内容实用性、经典指数这几个维度,分别打了分(每部分满分为5分,总分的满分为20分),按推荐性从低到高排了序,你可以参考下。
|
||||
|
||||
第五名:《The Way To Go》- Go语言百科全书
|
||||
|
||||
|
||||
|
||||
《The Way To Go》是我早期学习Go语言时最喜欢翻看的一本书。这本书成书于2012年3月,恰逢Go 1.0版本刚刚发布,当时作者承诺书中代码都可以在Go 1.0版本上编译通过并运行。这本书分为4个部分:
|
||||
|
||||
|
||||
为什么学习Go以及Go环境安装入门;
|
||||
Go语言核心语法;
|
||||
Go高级用法(I/O读写、错误处理、单元测试、并发编程、socket与web编程等);
|
||||
Go应用(常见陷阱、语言应用模式、从性能考量的代码编写建议、现实中的Go应用等)。
|
||||
|
||||
|
||||
每部分的每个章节都很精彩,而且这本书也是我目前见到的、最全面详实的、讲解Go语言的书籍了,可以说是Gopher们的第一本“Go百科全书”。
|
||||
|
||||
不过遗憾的是,这本书没有中文版。这可能是由于这本书出版太早了,等国内出版社意识到要引进Go语言方面的书籍的时候,这本书使用的Go版本已经太老了。不过,这本书中绝大部分例子依然可以在今天最新的Go编译器下通过编译并运行起来。好在Gopher无闻在GitHub上发起了这本书的中译版项目,如果你感兴趣的话,可以去GitHub上看或下载阅读。
|
||||
|
||||
这本书虽然很棒,但毕竟年头“久远”,所以我也只能委屈它一下了,将它列在推荐榜的第五位,这里我也给出了对它的各个指数的评分:
|
||||
|
||||
|
||||
|
||||
第四名:《Go 101》- Go语言参考手册
|
||||
|
||||
|
||||
|
||||
《Go 101》是一本在国外人气和关注度比在国内高的中国人编写的英文书,当然它也是有中文版的。
|
||||
|
||||
如果只从书名中的101去判断,你很大可能会认为这仅仅是一本讲解Go入门基础的书,但这本书的内容可远远不止入门这么简单。这本书大致可以分为三个部分:
|
||||
|
||||
|
||||
Go语法基础;
|
||||
Go类型系统与运行时实现;
|
||||
以专题(topic)形式阐述的Go特性、技巧与实践模式。
|
||||
|
||||
|
||||
除了第一部分算101范畴,其余两个部分都是Go语言的高级话题,也是我们要精通Go语言必须要掌握的“知识点”。并且,作者结合Go语言规范,对每个知识点的阐述都细致入微,也结合大量示例进行辅助说明。我们知道,C和C++语言在市面上都有一些由语言作者或标准规范委员会成员编写的Annotated或Rationale书籍(语言参考手册或标准解读),而《Go 101》这本书,就可以理解为Go语言的标准解读或参考手册。
|
||||
|
||||
Go 101这本书是开源电子书,它的作者也在国外一些支持自出版的服务商那里做了付费数字出版。这就让这本书相对于其他纸板书有着另外一个优势:与时俱进。在作者的不断努力下,这本书的知识点更新基本保持与Go的演化同步,目前书的内容已经覆盖了最新的Go 1.17版本。
|
||||
|
||||
这本书的作者是国内资深工程师老貘,他花费三年时间“呕心沥血”完成这本书并且免费奉献给Go社区,值得我们为他点一个大大的赞!近期老貘的两本新书《Go编程优化101》和《Go细节大全101》也将问世,想必也是不可多得的优秀作品。
|
||||
|
||||
下面是我对这本书各个指数的评分:
|
||||
|
||||
|
||||
|
||||
第三名:《Go语言学习笔记》- Go源码剖析与实现原理探索
|
||||
|
||||
|
||||
|
||||
《Go语言学习笔记》是一本在国内影响力和关注度都很高的作品。一来,它的作者雨痕老师是国内资深工程师,也是2015年第一届GopherChina大会讲师;二来,这部作品的前期版本是以开源电子书的形式分享给国内Go社区的;三来,作者在Go源码剖析方面可谓之条理清晰,细致入微。
|
||||
|
||||
2016年《Go语言学习笔记》的纸质版出版,覆盖了当时最新的Go 1.5版本。Go 1.5版本在Go语言演化历史中的分量极高,它不仅实现了Go自举,还让Go GC的延迟下降到绝大多数应用可以将它应用到生产的程度。这本书整体上分为两大部分:
|
||||
|
||||
|
||||
Go语言详解:以短平快、“堆干货”的风格对Go语言语法做了说明,能用示例说明的,绝不用文字做过多修饰;
|
||||
Go源码剖析:这是这本书的精华,也是最受Gopher们关注的部分。这部分对Go运行时神秘的内存分配、垃圾回收、并发调度、channel和defer的实现原理、sync.Pool的实现原理都做了细致的源码剖析与原理总结。
|
||||
|
||||
|
||||
随着Go语言的演化,它的语言和运行时实现一直在不断变化,但Go 1.5版本的实现是后续版本的基础,所以这本书对它的剖析非常值得每位Gopher阅读。从雨痕老师的GitHub上的最新消息来看,他似乎在编写新版Go语言学习笔记。剖析源码的过程是枯燥繁琐的,期待雨痕老师新版Go学习笔记能早日与Gopher们见面。
|
||||
|
||||
下面是我对这本书各个指数的评分:
|
||||
|
||||
|
||||
|
||||
第二名:《Go语言实战》- 实战系列经典之作,紧扣Go语言的精华
|
||||
|
||||
|
||||
|
||||
Manning出版社出版的“实战系列(xx in action)”一直是程序员心中高质量和经典的代名词。在出版Go语言实战系列书籍方面,这家出版社也是丝毫不敢怠慢,邀请了Go社区知名的三名明星级作者联合撰写。这三位作者分别是:
|
||||
|
||||
|
||||
威廉·肯尼迪 (William Kennedy) ,知名Go培训师,培训机构Ardan Labs的联合创始人,“Ultimate Go”培训的策划实施者;
|
||||
布赖恩·克特森 (Brian Ketelsen) ,世界上最知名的Go技术大会GopherCon大会的联合发起人和组织者,GopherAcademy创立者,现微软Azure工程师;
|
||||
埃里克·圣马丁 (Erik St.Martin) ,世界上最知名的Go技术大会GopherCon大会的联合发起人和组织者。
|
||||
|
||||
|
||||
《Go语言实战》这本书并不是大部头,而是薄薄的一本(中文版才200多页),所以你不要期望从本书得到百科全书一样的阅读感。而且,这本书的作者们显然也没有想把它写成面面俱到的作品,而是直击要点,也就是挑出Go语言和其他语言相比与众不同的特点进行着重讲解。这些特点构成了这本书的结构框架:
|
||||
|
||||
|
||||
入门:快速上手搭建、编写、运行一个Go程序;
|
||||
语法:数组(作为一个类型而存在)、切片和map;
|
||||
Go类型系统的与众不同:方法、接口、嵌入类型;
|
||||
Go的拿手好戏:并发及并发模式;
|
||||
标准库常用包:log、marshal/unmarshal、io(Reader和Writer);
|
||||
原生支持的测试。
|
||||
|
||||
|
||||
读完这本书,你就掌握了Go语言的精髓之处,这也迎合了多数Gopher的内心需求。而且,这本书中文版译者李兆海也是Go圈子里的资深Gopher,翻译质量上乘。
|
||||
|
||||
下面是我对这本书各个指数的评分:
|
||||
|
||||
|
||||
|
||||
第一名:《Go程序设计语言》- 人手一本的Go语言“圣经”
|
||||
|
||||
如果说由Brian W. Kernighan和Dennis M. Ritchie联合编写的《The C Programming Language》(也称K&R C)是C程序员(甚至是所有程序员)心目中的“圣经”的话,那么同样由Brian W. Kernighan(K)参与编写的《The Go Programming Language》(也称tgpl)就是Go程序员心目中的“圣经”。
|
||||
|
||||
|
||||
|
||||
这本书模仿并致敬“The C Programming Language”的经典结构,从一个”hello, world”示例开始带领大家开启Go语言之旅。
|
||||
|
||||
第二章程序结构是Go语言这个“游乐园”的向导图。了解它之后,我们就会迫不及待地奔向各个“景点”细致参观。Go语言规范中的所有“景点”在这本书中都覆盖到了,并且由浅入深、循序渐进:从基础数据类型到复合数据类型,从函数、方法到接口,从创新的并发Goroutine到传统的基于共享变量的并发,从包、工具链到测试,从反射到低级编程(unsafe包)。
|
||||
|
||||
作者行文十分精炼,字字珠玑,这与《The C Programming Language》的风格保持了高度一致。而且,书中的示例在浅显易懂的同时,又极具实用性,还突出Go语言的特点(比如并发web爬虫、并发非阻塞缓存等)。
|
||||
|
||||
读完这本书后,你会有一种爱不释手,马上还要从头再读一遍的感觉,也许这就是“圣经”的魅力吧!
|
||||
|
||||
这本书出版于2015年10月26日,也是既当年中旬Go 1.5这个里程碑版本发布后,Go社区的又一重大历史事件!并且Brian W. Kernighan老爷子的影响力让更多程序员加入到Go阵营,这也或多或少促成了Go成为下一个年度,也就是2016年年度TIOBE最佳编程语言。能得到Brian W. Kernighan老爷子青睐的编程语言只有C和Go,这也是Go的幸运。
|
||||
|
||||
这本书的另一名作者Alan A. A. Donovan也并非等闲之辈,他是Go核心开发团队的成员,专注于Go工具链方面的开发。
|
||||
|
||||
现在唯一遗憾的就是Brian W. Kernighan老爷子年事已高,不知道Go 1.18版本加入泛型语法后,老爷子是否还有精力再更新这本圣经。
|
||||
|
||||
这本书的中文版由七牛云团队翻译,总体质量也是不错的。建议Gopher们人手购置一本圣经“供奉”起来!
|
||||
|
||||
这里,我对这本书的各个指数都给了满分:
|
||||
|
||||
|
||||
|
||||
其他形式的参考资料
|
||||
|
||||
除了技术书籍之外,Go语言学习资料的形式也呈现出多样化。下面是我个人经常阅读和使用的其他形式的Go参考资料,这里列出来供同学们参考。
|
||||
|
||||
Go官方文档
|
||||
|
||||
如果你要问什么Go语言资料是最权威的,那莫过于Go官方文档了。
|
||||
|
||||
Go语言从诞生那天起,就十分重视项目文档的建设。除了可以在Go官方网站上查看到最新稳定发布版的文档之外,我们还可以在https://tip.golang.org上查看到项目主线分支(master)上最新开发版本的文档。
|
||||
|
||||
同时Go还将整个Go项目文档都加入到了Go发行版中,这样开发人员在本地安装Go的同时也拥有了一份完整的Go项目文档。这两年Go核心团队还招聘专人负责Go官方站点的研发,就在不久前,Go团队已经将原Go官方站点golang.org重定向到最新开发的go.dev网站上,新网站首页是这样的:
|
||||
|
||||
|
||||
|
||||
Go官方文档中的Go语言规范、Go module参考文档、Go命令参考手册、Effective Go、Go标准库包参考手册以及Go常见问答等都是每个Gopher必看的内容。我强烈建议你一定要抽出时间来仔细阅读这些文档。
|
||||
|
||||
Go相关博客
|
||||
|
||||
在编程语言学习过程中,诞生于Web 2.0时代的博客依旧是开发人员的一个重要参考资料来源。这里我也列出了我个人关注且经常阅读的一些博客,你可以参考一下:
|
||||
|
||||
|
||||
Go语言官博,Go核心团队关于Go语言的权威发布渠道;
|
||||
Go语言之父Rob Pike的个人博客;
|
||||
Go核心团队技术负责人Russ Cox的个人博客;
|
||||
Go核心开发者Josh Bleecher Snyder的个人博客;
|
||||
Go核心团队前成员Jaana Dogan的个人博客;
|
||||
Go鼓吹者Dave Cheney的个人博客;
|
||||
Go语言培训机构Ardan Labs的博客;
|
||||
GoCN社区;
|
||||
Go语言百科全书:由欧长坤维护的Go语言百科全书网站。
|
||||
|
||||
|
||||
Go播客
|
||||
|
||||
使用播客这种形式作编程语言类相关内容传播的资料并不多,能持续进行下去的就更少了。目前我唯一关注的就是changelog这个技术类播客平台下的Go Time频道。这个频道有几个Go社区知名的Gopher主持,目前已经播出了200多期,每期的嘉宾也都是Go社区的重量级人物,其中也不乏像Go语言之父这样的大神参与。
|
||||
|
||||
Go技术演讲
|
||||
|
||||
Go技术演讲,也是我们学习Go语言以及基于Go语言的实践的优秀资料来源。关于Go技术演讲,我个人建议以各大洲举办的GopherCon技术大会为主,这些已经基本可以涵盖每年Go语言领域的最新发展。下面我也整理了一些优秀的Go技术演讲资源列表,你可以参考:
|
||||
|
||||
|
||||
Go官方的技术演讲归档,这个文档我强烈建议你按时间顺序看一下,通过这些Go核心团队的演讲资料,我们可以清晰地了解Go的演化历程;
|
||||
GopherCon技术大会,这是Go语言领域规模最大的技术盛会,也是Go官方技术大会;
|
||||
GopherCon Europe技术大会;
|
||||
GopherConUK技术大会;
|
||||
GoLab技术大会;
|
||||
Go Devroom@FOSDEM;
|
||||
GopherChina技术大会,这是中国大陆地区规模最大的Go语言技术大会,由GoCN社区主办。
|
||||
|
||||
|
||||
Go日报/周刊邮件列表
|
||||
|
||||
通过邮件订阅Go语言类日报或周刊,我们也可以获得关于Go语言与Go社区最新鲜的信息。对于国内的Gopher们来说,订阅下面两个邮件列表就足够了:
|
||||
|
||||
|
||||
Go语言爱好者周刊,由Go语言中文网维护;
|
||||
Gopher日报,由我本人维护的Gopher日报项目,创立于2019年9月。
|
||||
|
||||
|
||||
其他
|
||||
|
||||
最后,这里还有两个可能经常被大家忽视的Go参考资料渠道,一个是Go语言项目的官方issue列表。通过这个issue列表,我们可以实时看到Go项目演进状态,及时看到Go社区提交的各种bug。同时,我们通过挖掘该列表,还可以了解某个Go特性的来龙去脉,这对深入理解Go特性很有帮助。
|
||||
|
||||
另外一个就是Go项目的代码review站点。通过阅读Go核心团队review代码的过程与评审意见,我们可以看到Go核心团队是如何使用Go进行编码的,能够学习到很多Go编码原则以及地道的Go语言惯用法,对更深入地理解Go语言设计哲学,形成Go语言编程思维有很大帮助。
|
||||
|
||||
写在最后
|
||||
|
||||
和学习任何一种知识或技能一样,编程语言学习过程中的参考资料不在于多而在于精。在这里,我已经将这些年来我积累的精华Go参考资料都罗列出了。如果你还有什么推荐的资料,也欢迎在留言区补充。
|
||||
|
||||
希望你在以专栏为主的Go学习过程中,能充分利用好这些参考资料,让它更好地发挥催化作用,以帮助你更快、更深入地掌握Go语言,形成Go编程思维,写出更为地道的、优秀的Go代码。
|
||||
|
||||
|
||||
|
||||
|
||||
397
专栏/TonyBai·Go语言第一课/加餐聊聊Go1.17版本的那些新特性.md
Normal file
397
专栏/TonyBai·Go语言第一课/加餐聊聊Go1.17版本的那些新特性.md
Normal file
@@ -0,0 +1,397 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐 聊聊Go 1.17版本的那些新特性
|
||||
你好,我是Tony Bai。
|
||||
|
||||
现在是2021年12月,万众期盼的潜力网红版本Go 1.18的开发已经冻结,Go核心开发团队正在紧锣密鼓地修bug。我们已经可以开始期待2022年的2月份,Go 1.18将携带包括泛型语法的大批新特性赶来。不过当下我们不能“舍近求远”,今年8月中旬Go核心团队发布的Go 1.17版本才是当下最具统治力的Go社区网红,它的影响力依旧处于巅峰。
|
||||
|
||||
根据我们在第3讲中提到的Go版本选择策略,我估计很多Go开发者都还没切换到Go 1.17版本,没有亲自体验过Go 1.17新特性带来的变化;还有一些Go开发者虽然已经升级到Go 1.17版本,但也仅限于对Go 1.17版本的基本使用,可能还不是很清楚Go 1.17版本中究竟有哪些新特性,以及这些新特性会带给他们哪些好处。
|
||||
|
||||
所以今天这讲,我们就来聊聊Go 1.17版本中的新特性,目的是让那些没用过Go 1.17版本,或者用过Go 1.17版本但还不知道它新特性变化的Go开发者,对Go 1.17有一个全面的了解。
|
||||
|
||||
Go 1.17版本中的新特性很多,在这里我就不一一列举了,我仅挑几个有代表性的、重要的新特性和你好好聊聊。这里会包括新的语法特性、Go Module机制变化,以及Go编译器与运行时方面的变化。
|
||||
|
||||
新的语法特性
|
||||
|
||||
在第2讲学习Go语言设计哲学时,我们知道了Go语言的设计者们在语言设计之初,就拒绝了走语言特性融合的道路,选择了“做减法”,并致力于打造一门简单的编程语言。从诞生到现在,Go语言自身语法特性变化很小,甚至可以用屈指可数来形容,因此新语法特性对于Gopher来说属于“稀缺品”。这也直接导致了每次Go新版本发布,我们都要先看看语法特性是否有变更,每个新加入语法特性都值得我们投入更多关注,去深入研究。
|
||||
|
||||
不出所料,Go 1.17版本在语法特性方面仅仅做了一处增强,那就是支持切片转换为数组指针。下面我们详细来看一下。
|
||||
|
||||
支持将切片转换为数组指针
|
||||
|
||||
在第15讲中,我们对Go中的切片做了系统全面的讲解。我们知道,通过数组切片化,我们可以将一个数组转换为切片。转换后,数组将成为转换后的切片的底层数组,通过切片,我们可以直接改变数组中的元素,就像下面代码这样:
|
||||
|
||||
a := [3]int{11, 12, 13}
|
||||
b := a[:] // 通过切片化将数组a转换为切片b
|
||||
b[1] += 10
|
||||
fmt.Printf("%v\n", a) // [11 22 13]
|
||||
|
||||
|
||||
但反过来就不行了。在Go 1.17版本之前,Go并不支持将切片再转换回数组类型。当然,如果你非要这么做也不是没有办法,我们可以通过unsafe包以不安全的方式实现这样的转换,如下面代码所示:
|
||||
|
||||
b := []int{11, 12, 13}
|
||||
var p = (*[3]int)(unsafe.Pointer(&b[0]))
|
||||
p[1] += 10
|
||||
fmt.Printf("%v\n", b) // [11 22 13]
|
||||
|
||||
|
||||
但是unsafe包,正如其名,它的安全性没有得到编译器和runtime层的保证,只能由开发者自己保证,所以我建议Gopher们在通常情况下不要使用。
|
||||
|
||||
2009年末,也就是Go语言宣布开源后不久,Roger Peppe便提出一个issue,希望Go核心团队考虑在语法层面补充从切片到数组的转换语法,同时希望这种转换以及转换后的数组在使用时的下标边界,能得到编译器和runtime的协助检查。十二年后这个issue终于被Go核心团队接受,并在Go 1.17版本加入到Go语法特性当中。
|
||||
|
||||
所以,在Go 1.17版本中,我们可以像下面代码这样将一个切片转换为数组类型指针,不用再借助unsafe包的“黑魔法”了:
|
||||
|
||||
b := []int{11, 12, 13}
|
||||
p := (*[3]int)(b) // 将切片转换为数组类型指针
|
||||
p[1] = p[1] + 10
|
||||
fmt.Printf("%v\n", b) // [11 22 13]
|
||||
|
||||
|
||||
不过,这里你要注意的是,Go会通过运行时而不是编译器去对这类切片到数组指针的转换代码做检查,如果发现越界行为,就会触发运行时panic。Go运行时实施检查的一条原则就是“转换后的数组长度不能大于原切片的长度”,注意这里是切片的长度(len),而不是切片的容量(cap)。于是你会看到,下面的转换有些合法,有些非法:
|
||||
|
||||
var b = []int{11, 12, 13}
|
||||
var p = (*[4]int)(b) // cannot convert slice with length 3 to pointer to array with length 4
|
||||
var p = (*[0]int)(b) // ok,*p = []
|
||||
var p = (*[1]int)(b) // ok,*p = [11]
|
||||
var p = (*[2]int)(b) // ok,*p = [11, 12]
|
||||
var p = (*[3]int)(b) // ok,*p = [11, 12, 13]
|
||||
var p = (*[3]int)(b[:1]) // cannot convert slice with length 1 to pointer to array with length 3
|
||||
|
||||
|
||||
另外,nil切片或cap为0的empty切片都可以被转换为一个长度为0的数组指针,比如:
|
||||
|
||||
var b1 []int // nil切片
|
||||
p1 := (*[0]int)(b1)
|
||||
var b2 = []int{} // empty切片
|
||||
p2 := (*[0]int)(b2)
|
||||
|
||||
|
||||
说完了Go语法特性的变化后,我们再来看看Go Module构建模式在Go 1.17中的演进。
|
||||
|
||||
Go Module构建模式的变化
|
||||
|
||||
自从Go 1.11版本引入Go Module构建模式以来,每个Go大版本发布时,Go Module都会有不少的积极变化,Go 1.17版本也不例外。
|
||||
|
||||
修剪的module依赖图
|
||||
|
||||
Go 1.17版本中,Go Module最重要的一个变化就是pruned module graph,即修剪的module依赖图。要理解这个概念,我们先来讲什么是完整module依赖图。
|
||||
|
||||
在Go 1.17之前的版本中,某个module的依赖图是由这个module的直接依赖以及所有间接依赖组成的。这样,无论某个间接依赖是否真正为原module的构建做出贡献,Go命令在解决依赖时都会读取每个依赖的go.mod,包括那些没有被真正使用到的module,这样形成的module依赖图被称为完整module依赖图(complete module graph)。
|
||||
|
||||
从Go 1.17的版本开始,Go不再使用“完整module依赖图”,而是引入了pruned module graph,也就是修剪的module依赖图。修剪的module依赖图就是在完整module依赖图的基础上,将那些对构建完全没有“贡献”的间接依赖module修剪掉后,剩余的依赖图。使用修剪后的module依赖图进行构建,有助于避免下载或阅读那些不必要的go.mod文件,这样Go命令可以不去获取那些不相关的依赖关系,从而在日常开发中节省时间。
|
||||
|
||||
这么说还是比较抽象,我们用下图中的例子来详细解释一下module依赖图修剪的原理。
|
||||
|
||||
|
||||
|
||||
上图中的例子来自于Go 1.17源码中的src/cmd/go/testdata/script/mod_lazy_new_import.txt,通过执行txtar工具,我们可以将这个txt转换为mod_lazy_new_import.txt中描述的示例结构,转换命令为: txtar -x < $GOROOT/src/cmd/go/testdata/script/mod_lazy_new_import.txt。
|
||||
|
||||
在这个示例中,main module中的lazy.go导入了module a的package x,后者则导入了module b中的package b。并且,module a还有一个package y,这个包导入了module c的package c。通过go mod graph命令,我们可以得到main module的完整module依赖图,也就是上图的右上角的那张。
|
||||
|
||||
现在问题来了!package y是因为自身是module a的一部分而被main module依赖的,它自己没有为main module的构建做出任何“代码级贡献”,同理,package y所依赖的module c亦是如此。但是在Go 1.17之前的版本中,如果Go编译器找不到module c,那么main module的构建也会失败,这会让开发者们觉得不够合理!
|
||||
|
||||
现在,我们直观地看一下在Go 1.16.5下,这个示例的go.mod是怎样的:
|
||||
|
||||
module example.com/lazy
|
||||
|
||||
go 1.15
|
||||
|
||||
require example.com/a v0.1.0
|
||||
|
||||
replace (
|
||||
example.com/a v0.1.0 => ./a
|
||||
example.com/b v0.1.0 => ./b
|
||||
example.com/c v0.1.0 => ./c1
|
||||
example.com/c v0.2.0 => ./c2
|
||||
)
|
||||
|
||||
|
||||
我们只需要关注require块中的内容就可以了,下面的replace块主要是为了示例能找到各种依赖module而设置的。
|
||||
|
||||
我们知道,在Go 1.16及以前支持Go Module的版本建立的Go Module中,在go.mod经过go mod tidy后,require块中保留的都是main module的直接依赖,在某些情况下,也会记录indirect依赖,这些依赖会在行尾用indirect指示符明示。但在这里,我们看不到main module的间接依赖以及它们的版本,我们可以用go mod graph来查看module依赖图:
|
||||
|
||||
$go mod graph
|
||||
example.com/lazy example.com/[email protected]
|
||||
example.com/[email protected] example.com/[email protected]
|
||||
example.com/[email protected] example.com/[email protected]
|
||||
|
||||
|
||||
这个go mod graph的输出,和我们在上面图中右上角画的module graph是一致的。此时,如果我们将replace中的第三行(example.com/c v0.1.0 => ./c1这一行)删除,也就是让Go编译器找不到module [email protected],那么我们构建main modue时就会得到下面的错误提示:
|
||||
|
||||
$go build
|
||||
go: example.com/[email protected] requires
|
||||
example.com/[email protected]: missing go.sum entry; to add it:
|
||||
go mod download example.com/c
|
||||
|
||||
|
||||
现在我们将执行权限交给Go 1.17看看会怎样!
|
||||
|
||||
这个时候,我们需要对go.mod做一些修改,也就是将go.mod中的go 1.15改为go 1.17,这样Go 1.17才能起到作用。接下来,我们执行go mod tidy,让Go 1.17重新构建go.mod:
|
||||
|
||||
$go mod tidy
|
||||
$cat go.mod
|
||||
|
||||
module example.com/lazy
|
||||
|
||||
go 1.17
|
||||
|
||||
require example.com/a v0.1.0
|
||||
|
||||
require example.com/b v0.1.0 // indirect
|
||||
|
||||
replace (
|
||||
example.com/a v0.1.0 => ./a
|
||||
example.com/b v0.1.0 => ./b
|
||||
example.com/c v0.1.0 => ./c1
|
||||
example.com/c v0.2.0 => ./c2
|
||||
)
|
||||
|
||||
|
||||
我们看到执行go mod tidy之后,go.mod发生了变化:增加了一个require语句块,记录了main module的间接依赖,也就是module [email protected]。
|
||||
|
||||
现在,我们也同样将go.mod replace块中的第三行(example.com/c v0.1.0 => ./c1这一行)删除,再来用go 1.17构建一次main module。
|
||||
|
||||
这一次我们没有看到Go编译器的错误提示。也就是说在构建过程中,Go编译器看到的main module依赖图中并没有module [email protected]。这是因为module c并没有为main module的构建提供“代码级贡献”,所以Go命令把它从module依赖图中剪除了。这一次,Go编译器使用的真实的依赖图是上图右下角的那张。这种将那些对构建完全没有“贡献”的间接依赖module从构建时使用的依赖图中修剪掉的过程,就被称为module依赖图修剪(pruned module graph)。
|
||||
|
||||
但module依赖图修剪也带来了一个副作用,那就是go.mod文件size的变大。因为从Go 1.17版本开始,每次调用go mod tidy,Go命令都会对main module的依赖做一次深度扫描(deepening scan),并将main module的所有直接和间接依赖都记录在go.mod中。考虑到依赖的内容较多,go 1.17会将直接依赖和间接依赖分别放在多个不同的require块中。
|
||||
|
||||
所以,在Go 1.17版本中,go.mod中存储了main module的所有依赖module列表,这似乎也是Go项目第一次有了项目依赖的完整列表。不知道会不会让你想起其他主流语言构架系统中的那个lock文件呢?虽然go.mod并不是lock文件,但有了完整依赖列表,至少我们可以像其他语言的lock文件那样,知晓当前Go项目所有依赖的精确版本了。
|
||||
|
||||
在讲解下一个重要变化之前,我还要提一点小变化,那就是在Go 1.17版本中,go get已经不再被用来安装某个命令的可执行文件了。如果你依旧使用go get安装,Go命令会提示错误。这也是很多同学在学习我们课程的入门篇时经常会问的一个问题。
|
||||
|
||||
新版本中,我们需要使用go install来安装,并且使用go install安装时还要用@vx.y.z明确要安装的命令的二进制文件的版本,或者是使用@latest来安装最新版本。
|
||||
|
||||
除了Go语法特性与Go Module有重要变化之外,Go编译器的变化对Go程序的构建与运行影响同样十分巨大,我们接下来就来看一下Go 1.17在这方面的重要变化。
|
||||
|
||||
Go编译器的变化
|
||||
|
||||
在Go1.17版本,Go编译器的变化主要是在AMD64架构下实现了基于寄存器的调用惯例,以及新引入了//go:build形式的构建约束指示符。现在我们就来分析下这两点。
|
||||
|
||||
基于寄存器的调用惯例
|
||||
|
||||
Go 1.17版本中,Go编译器最大的变化是在AMD64架构下率先实现了从基于堆栈的调用惯例到基于寄存器的调用惯例的切换。
|
||||
|
||||
所谓“调用惯例(calling convention)”,是指调用方和被调用方对于函数调用的一个明确的约定,包括函数参数与返回值的传递方式、传递顺序。只有双方都遵守同样的约定,函数才能被正确地调用和执行。如果不遵守这个约定,函数将无法正确执行。
|
||||
|
||||
Go 1.17版本之前,Go采用基于栈的调用约定,也就是说函数的参数与返回值都通过栈来传递,这种方式的优点是实现简单,不用担心底层CPU架构寄存器的差异,适合跨平台,但缺点就是牺牲了一些性能。
|
||||
|
||||
我们都知道,寄存器的访问速度是要远高于内存的。所以,现在大多数平台上的大多数语言实现都使用基于寄存器的调用约定,通过寄存器而不是内存传递函数参数和返回结果,并指定一些寄存器为调用保存寄存器,允许函数在不同的调用中保持状态。Go核心团队决定在1.17版本向这些语言看齐,并在AMD64架构下率先实现基于寄存器的调用惯例。
|
||||
|
||||
我们可以在Go 1.17的版本发布说明文档中看到,切换到基于寄存器的调用惯例后,一组有代表性的Go包和程序的基准测试显示,Go程序的运行性能提高了约5%,二进制文件大小典型减少约2%。
|
||||
|
||||
那我们这里就来实测一下,看看是否真的能提升那么多。下面是一个使用多种方法进行字符串连接的benchmark测试源码:
|
||||
|
||||
var sl []string = []string{
|
||||
"Rob Pike ",
|
||||
"Robert Griesemer ",
|
||||
"Ken Thompson ",
|
||||
}
|
||||
|
||||
func concatStringByOperator(sl []string) string {
|
||||
var s string
|
||||
for _, v := range sl {
|
||||
s += v
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func concatStringBySprintf(sl []string) string {
|
||||
var s string
|
||||
for _, v := range sl {
|
||||
s = fmt.Sprintf("%s%s", s, v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func concatStringByJoin(sl []string) string {
|
||||
return strings.Join(sl, "")
|
||||
}
|
||||
|
||||
func concatStringByStringsBuilder(sl []string) string {
|
||||
var b strings.Builder
|
||||
for _, v := range sl {
|
||||
b.WriteString(v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func concatStringByStringsBuilderWithInitSize(sl []string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(64)
|
||||
for _, v := range sl {
|
||||
b.WriteString(v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func concatStringByBytesBuffer(sl []string) string {
|
||||
var b bytes.Buffer
|
||||
for _, v := range sl {
|
||||
b.WriteString(v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func concatStringByBytesBufferWithInitSize(sl []string) string {
|
||||
buf := make([]byte, 0, 64)
|
||||
b := bytes.NewBuffer(buf)
|
||||
for _, v := range sl {
|
||||
b.WriteString(v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func BenchmarkConcatStringByOperator(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
concatStringByOperator(sl)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConcatStringBySprintf(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
concatStringBySprintf(sl)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConcatStringByJoin(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
concatStringByJoin(sl)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConcatStringByStringsBuilder(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
concatStringByStringsBuilder(sl)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConcatStringByStringsBuilderWithInitSize(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
concatStringByStringsBuilderWithInitSize(sl)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConcatStringByBytesBuffer(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
concatStringByBytesBuffer(sl)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConcatStringByBytesBufferWithInitSize(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
concatStringByBytesBufferWithInitSize(sl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们使用Go 1.16.5和Go 1.17分别运行这个Benchmark示例,结果如下:
|
||||
|
||||
Go 1.16.5:
|
||||
|
||||
$go test -bench .
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
pkg: github.com/bigwhite/demo
|
||||
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
|
||||
BenchmarkConcatStringByOperator-8 12132355 91.51 ns/op
|
||||
BenchmarkConcatStringBySprintf-8 2707862 445.1 ns/op
|
||||
BenchmarkConcatStringByJoin-8 24101215 50.84 ns/op
|
||||
BenchmarkConcatStringByStringsBuilder-8 11104750 124.4 ns/op
|
||||
BenchmarkConcatStringByStringsBuilderWithInitSize-8 24542085 48.24 ns/op
|
||||
BenchmarkConcatStringByBytesBuffer-8 14425054 77.73 ns/op
|
||||
BenchmarkConcatStringByBytesBufferWithInitSize-8 20863174 49.07 ns/op
|
||||
PASS
|
||||
ok github.com/bigwhite/demo 9.166s
|
||||
|
||||
|
||||
Go 1.17:
|
||||
|
||||
$go test -bench .
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
pkg: github.com/bigwhite/demo
|
||||
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
|
||||
BenchmarkConcatStringByOperator-8 13058850 89.47 ns/op
|
||||
BenchmarkConcatStringBySprintf-8 2889898 410.1 ns/op
|
||||
BenchmarkConcatStringByJoin-8 25469310 47.15 ns/op
|
||||
BenchmarkConcatStringByStringsBuilder-8 13064298 92.33 ns/op
|
||||
BenchmarkConcatStringByStringsBuilderWithInitSize-8 29780911 41.14 ns/op
|
||||
BenchmarkConcatStringByBytesBuffer-8 16900072 70.28 ns/op
|
||||
BenchmarkConcatStringByBytesBufferWithInitSize-8 27310650 43.96 ns/op
|
||||
PASS
|
||||
ok github.com/bigwhite/demo 9.198s
|
||||
|
||||
|
||||
我们可以看到,相对于Go 1.16.5跑出的结果,Go 1.17在每一个测试项上都有小幅的性能提升,有些性能提升甚至达到10%左右(以BenchmarkConcatStringBySprintf为例,它的性能提升为(445.1-410.1)/445.1=7.8%)。也就是说你的Go源码使用Go 1.17版本重新编译一下,就能获得大约5%的性能提升,这种新版本带来的性能的“自然提升”显然是广大Gopher乐意看到的。
|
||||
|
||||
我们再来看看编译后的Go二进制文件的Size变化。我们以一个自有的1w行左右代码的Go程序为例,分别用Go 1.16.5和Go 1.17进行编译,得到的结果如下:
|
||||
|
||||
-rwxr-xr-x 1 tonybai staff 7264432 8 13 18:31 myapp-go1.16.5*
|
||||
-rwxr-xr-x 1 tonybai staff 6934352 8 13 18:32 myapp-go1.17*
|
||||
|
||||
|
||||
我们看到,Go 1.17编译后的二进制文件大小相比Go 1.16.5版本减少了约4%,比Go官方文档发布的平均效果还要好上一些。
|
||||
|
||||
而且,Go 1.17发布说明也提到了:改为基于寄存器的调用惯例后,绝大多数程序不会受到影响。只有那些之前就已经违反unsafe.Pointer的使用规则的代码可能会受到影响,比如不遵守unsafe规则通过unsafe.Pointer访问函数参数,或者依赖一些像比较函数代码指针的未公开的行为。
|
||||
|
||||
//go:build形式的构建约束指示符
|
||||
|
||||
此外,Go编译器还在Go 1.17中引入了//go:build形式的构建约束指示符,以替代原先易错的// +build形式。
|
||||
|
||||
在Go 1.17之前,我们可以通过在源码文件头部放置// +build构建约束指示符来实现构建约束,但这种形式十分易错,并且它并不支持&&和||这样的直观的逻辑操作符,而是用逗号、空格替代,这里你可以看下原// +build形式构建约束指示符的用法及含义:
|
||||
|
||||
|
||||
|
||||
但这种与程序员直觉“有悖”的形式让Gopher们十分痛苦,于是Go 1.17回归“正规正轨”,引入了//go:build形式的构建约束指示符。一方面,这可以与源文件中的其他指示符保持形式一致,比如 //go:nosplit、//go:norace、//go:noinline、//go:generate等。
|
||||
|
||||
另一方面,新形式将支持&&和||逻辑操作符,这样的形式就是自解释的,这样,我们程序员就不需要再像上面那样列出一个表来解释每个指示符组合的含义了。新形式是这样的:
|
||||
|
||||
//go:build linux && (386 || amd64 || arm || arm64 || mips64 || mips64le || ppc64 || ppc64le)
|
||||
//go:build linux && (mips64 || mips64le)
|
||||
//go:build linux && (ppc64 || ppc64le)
|
||||
//go:build linux && !386 && !arm
|
||||
|
||||
|
||||
考虑到兼容性,Go命令可以识别这两种形式的构建约束指示符,但推荐Go 1.17之后都用新引入的这种形式。
|
||||
|
||||
另外,gofmt也可以兼容处理两种形式。它的处理原则是:如果一个源码文件只有// +build形式的指示符,gofmt会把和它等价的//go:build行加入。否则,如果一个源文件中同时存在这两种形式的指示符行,那么//+build行的信息就会被//go:build行的信息所覆盖。
|
||||
|
||||
除了gofmt外,go vet工具也会检测源文件中是否同时存在不同形式的、语义不一致的构建指示符,比如针对下面这段代码:
|
||||
|
||||
//go:build linux && !386 && !arm
|
||||
// +build linux
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("hello, world")
|
||||
}
|
||||
|
||||
|
||||
go vet会提示如下问题:
|
||||
|
||||
./buildtag.go:2:1: +build lines do not match //go:build condition
|
||||
|
||||
|
||||
小结
|
||||
|
||||
Go 1.17版本的一些重要新特性就介绍到这里了,除了上面这些重要变化之外,Go 1.17还有很多变更与改进,如果你还意犹未尽,建议你去认真读读Go 1.17的发布说明文档。
|
||||
|
||||
另外我还要多说一句,Go 1.17版本的这些变更都是在Go1兼容性的承诺范围内的。也就是说,Go 1.17版本秉持了Go语言开源以来各个版本的一贯原则:向后兼容,也就是即使你使用Go 1.17版本,也可以成功编译你十年前写下的Go代码。
|
||||
|
||||
读到这里,你是不是有一种要尽快切换到Go 1.17版本的冲动呢!赶快去Go官网下载Go 1.17的最新补丁版本,开启你的Go1.17体验之旅吧。
|
||||
|
||||
思考题
|
||||
|
||||
在你阅读完Go 1.17的发布说明文档之后,你会发现Go 1.17版本中的变化有很多,除了上面几个重要特性变化外,最让你受益或印象深刻的变化是哪一个呢?欢迎在留言区分享。
|
||||
|
||||
欢迎你把这节课分享给更多对Go 1.17版本感兴趣的朋友。我是Tony Bai,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
||||
346
专栏/TonyBai·Go语言第一课/加餐聊聊Go语言的指针.md
Normal file
346
专栏/TonyBai·Go语言第一课/加餐聊聊Go语言的指针.md
Normal file
@@ -0,0 +1,346 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐 聊聊Go语言的指针
|
||||
你好,我是Tony Bai。
|
||||
|
||||
刚刚完成专栏结束语,我又马不停蹄地开始撰写这篇加餐,因为在结束语中我曾提到过对专栏没有介绍指针类型的不安,如果你是编程初学者,或者只有动态语言的经验,又或者只有像Java这类不支持指针的静态语言编程的经验,缺少指针的讲解就可能会给你的学习过程带来一些困惑。
|
||||
|
||||
因此,在这一篇加餐中,我就来补上指针类型这一课。不过,我建议你不要把这篇当作加餐,而是当作本专栏必学的一节课。
|
||||
|
||||
那么什么是指针呢?它和我们常见的Go类型,比如int、string、切片类型等有什么区别呢?下面我们就来一探究竟!
|
||||
|
||||
什么是指针类型
|
||||
|
||||
和我们学过的所有类型都不同,指针类型是依托某一个类型而存在的,比如:一个整型为int,那么它对应的整型指针就是*int,也就是在int的前面加上一个星号。没有int类型,就不会有*int类型。而int也被称为*int指针类型的基类型。
|
||||
|
||||
我们泛化一下指针类型的这个定义:如果我们拥有一个类型T,那么以T作为基类型的指针类型为*T。
|
||||
|
||||
声明一个指针类型变量的语法与非指针类型的普通变量是一样的,我们以声明一个*T指针类型的变量为例:
|
||||
|
||||
var p *T
|
||||
|
||||
|
||||
不过Go中也有一种指针类型是例外,它不需要基类型,它就是unsafe.Pointer。unsafe.Pointer类似于C语言中的void*,用于表示一个通用指针类型,也就是任何指针类型都可以显式转换为一个unsafe.Pointer,而unsafe.Pointer也可以显式转换为任意指针类型,如下面代码所示:
|
||||
|
||||
var p *T
|
||||
var p1 = unsafe.Pointer(p) // 任意指针类型显式转换为unsafe.Pointer
|
||||
p = (*T)(p1) // unsafe.Pointer也可以显式转换为任意指针类型
|
||||
|
||||
|
||||
unsafe.Pointer是Go语言的高级特性,在Go运行时与Go标准库中unsafe.Pointer都有着广泛的应用。但unsafe.Pointer属于unsafe编程范畴,我这里就不深入了,你感兴趣可以查一下资料。
|
||||
|
||||
如果指针类型变量没有被显式赋予初值,那么它的值为nil:
|
||||
|
||||
var p *T
|
||||
println(p == nil) // true
|
||||
|
||||
|
||||
那么,如果要给一个指针类型变量赋值,我们该怎么做呢?我们以一个整型指针类型为例来看一下:
|
||||
|
||||
var a int = 13
|
||||
var p *int = &a // 给整型指针变量p赋初值
|
||||
|
||||
|
||||
在这个例子中,我们用&a作为*int指针类型变量p的初值,这里变量a前面的&符号称为取地址符号,这一行的含义就是将变量a的地址赋值给指针变量p。这里要注意,我们只能使用基类型变量的地址给对应的指针类型变量赋值,如果类型不匹配,Go编译器是会报错的,比如下面这段代码:
|
||||
|
||||
var b byte = 10
|
||||
var p *int = &b // Go编译器报错:cannot use &b (value of type *byte) as type *int in variable declaration
|
||||
|
||||
|
||||
到这里,我们可以看到:指针类型变量的值与我们之前所了解的任何类型的值都不同,那它究竟有什么特别之处呢?我们继续往下看。
|
||||
|
||||
在专栏的第10讲中,我们学习过如何在Go中声明一个变量。每当我们声明一个变量,Go都会为变量分配对应的内存空间。如果我们声明的是非指针类型的变量,那么Go在这些变量对应的内存单元中究竟存储了什么呢?
|
||||
|
||||
我们以最简单的整型变量为例,看看对应的内存单元存储的内容:
|
||||
|
||||
|
||||
|
||||
我们看到,对于非指针类型变量,Go在对应的内存单元中放置的就是该变量的值。我们对这些变量进行修改操作的结果,也会直接体现在这个内存单元上,如下图所示:
|
||||
|
||||
|
||||
|
||||
那么,指针类型变量在对应的内存空间中放置的又是什么呢?我们还以*int类型指针变量为例,下面这张示意图就展示了该变量对应内存空间存储的值究竟是什么:
|
||||
|
||||
|
||||
|
||||
从图中我们看到,Go为指针变量p分配的内存单元中存储的是整型变量a对应的内存单元的地址。也正是由于指针类型变量存储的是内存单元的地址,指针类型变量的大小与其基类型大小无关,而是和系统地址的表示长度有关。比如下面例子:
|
||||
|
||||
package main
|
||||
|
||||
import "unsafe"
|
||||
|
||||
type foo struct {
|
||||
id string
|
||||
age int8
|
||||
addr string
|
||||
}
|
||||
|
||||
func main() {
|
||||
var p1 *int
|
||||
var p2 *bool
|
||||
var p3 *byte
|
||||
var p4 *[20]int
|
||||
var p5 *foo
|
||||
var p6 unsafe.Pointer
|
||||
println(unsafe.Sizeof(p1)) // 8
|
||||
println(unsafe.Sizeof(p2)) // 8
|
||||
println(unsafe.Sizeof(p3)) // 8
|
||||
println(unsafe.Sizeof(p4)) // 8
|
||||
println(unsafe.Sizeof(p5)) // 8
|
||||
println(unsafe.Sizeof(p6)) // 8
|
||||
}
|
||||
|
||||
|
||||
这里的例子通过unsafe.Sizeof函数来计算每一个指针类型的大小,我们看到,无论指针的基类型是什么,不同类型的指针类型的大小在同一个平台上是一致的。在x86-64平台上,地址的长度都是8个字节。
|
||||
|
||||
unsafe包的Sizeof函数原型如下:
|
||||
|
||||
func Sizeof(x ArbitraryType) uintptr
|
||||
|
||||
|
||||
这个函数的返回值类型是uintptr,这是一个Go预定义的标识符。我们通过go doc可以查到这一类型代表的含义:uintptr是一个整数类型,它的大小足以容纳任何指针的比特模式(bit pattern)。
|
||||
|
||||
这句话比较拗口,也不好理解。我们换个方式,可以将这句话理解为:在Go语言中uintptr类型的大小就代表了指针类型的大小。
|
||||
|
||||
一旦指针变量得到了正确赋值,也就是指针指向某一个合法类型的变量,我们就可以通过指针读取或修改其指向的内存单元所代表的基类型变量,比如:
|
||||
|
||||
var a int = 17
|
||||
var p *int = &a
|
||||
println(*p) // 17
|
||||
(*p) += 3
|
||||
println(a) // 20
|
||||
|
||||
|
||||
我们用一副示意图来更直观地表示这个过程:
|
||||
|
||||
|
||||
|
||||
通过指针变量读取或修改其指向的内存地址上的变量值,这个操作被称为指针的解引用(dereference)。它的形式就是在指针类型变量的前面加上一个星号,就像前面的例子中那样。
|
||||
|
||||
从上面的例子和图中,我们都可以看到,通过解引用输出或修改的,并不是指针变量本身的值,而是指针指向的内存单元的值。要输出指针自身的值,也就是指向的内存单元的地址,我们可以使用Printf通过%p来实现:
|
||||
|
||||
fmt.Printf("%p\n", p) // 0xc0000160d8
|
||||
|
||||
|
||||
指针变量可以变换其指向的内存单元,对应到语法上,就是为指针变量重新赋值,比如下面代码:
|
||||
|
||||
var a int = 5
|
||||
var b int = 6
|
||||
|
||||
var p *int = &a // 指向变量a所在内存单元
|
||||
println(*p) // 输出变量a的值
|
||||
p = &b // 指向变量b所在内存单元
|
||||
println(*p) // 输出变量b的值
|
||||
|
||||
|
||||
多个指针变量可以指向同一个变量的内存单元的,这样通过其中一个指针变量对内存单元的修改,是可以通过另外一个指针变量的解引用反映出来的,比如下面例子:
|
||||
|
||||
var a int = 5
|
||||
var p1 *int = &a // p1指向变量a所在内存单元
|
||||
var p2 *int = &a // p2指向变量b所在内存单元
|
||||
(*p1) += 5 // 通过p1修改变量a的值
|
||||
println(*p2) // 10 对变量a的修改可以通过另外一个指针变量p2的解引用反映出来
|
||||
|
||||
|
||||
讲到这里,你应该对指针的概念有一定的了解了。不过,有同学可能会问:既然指针变量也作为一个内存单元存储在内存中,那么是否可以被其他指针变量指向呢?好,下面我们就来回答这个问题!
|
||||
|
||||
二级指针
|
||||
|
||||
首先剧透一下:可以!我们来看下面这个例子:
|
||||
|
||||
package main
|
||||
|
||||
func main() {
|
||||
var a int = 5
|
||||
var p1 *int = &a
|
||||
println(*p1) // 5
|
||||
var b int = 55
|
||||
var p2 *int = &b
|
||||
println(*p2) // 55
|
||||
|
||||
var pp **int = &p1
|
||||
println(**pp) // 5
|
||||
pp = &p2
|
||||
println(**pp) // 55
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,我们声明了两个*int类型指针p1和p2,分别指向两个整型变量a和b,我们还声明了一个**int型的指针变量pp,它的初值为指针变量p1的地址。之后我们用p2的地址为pp变量作了重新赋值。
|
||||
|
||||
通过下面这个示意图,能更容易理解这个例子(注意:这里只是示意图,并非真实内存布局图):
|
||||
|
||||
|
||||
|
||||
我们看到,**int类型的变量pp中存储的是*int型变量的地址,这和前面的*int型变量存储的是int型变量的地址的情况,其实是一种原理。**int被称为二级指针,也就是指向指针的指针,那自然,我们可以理解*int就是一级指针了。
|
||||
|
||||
前面说过,对一级指针解引用,我们得到的其实是指针指向的变量。而对二级指针pp解引用一次,我们得到将是pp指向的指针变量:
|
||||
|
||||
println((*pp) == p1) // true
|
||||
|
||||
|
||||
那么对pp解引用二次,我们将得到啥呢?对pp解引用两次,其实就相当于对一级指针解引用一次,我们得到的是pp指向的指针变量所指向的整型变量:
|
||||
|
||||
println((**pp) == (*p1)) // true
|
||||
println((**pp) == a) // true
|
||||
|
||||
|
||||
那么二级指针通常用来做什么呢?我们知道一级指针常被用来改变普通变量的值,那么可以推断,二级指针就可以用来改变指针变量的值,也就是指针变量的指向。
|
||||
|
||||
前面我们提到过,在同一个函数中,改变指针的指向十分容易,我们只需要给一级指针重新赋值为另外一个变量的地址就可以了。
|
||||
|
||||
但是,如果我们需要跨函数改变一个指针变量的指向,我们就不能选择一级指针类型作为形参类型了。因为一级指针只能改变普通变量的值,无法改变指针变量的指向。我们只能选择二级指针类型作为形参类型。
|
||||
|
||||
我们来看一个例子:
|
||||
|
||||
package main
|
||||
|
||||
func foo(pp **int) {
|
||||
var b int = 55
|
||||
var p1 *int = &b
|
||||
(*pp) = p1
|
||||
}
|
||||
|
||||
func main() {
|
||||
var a int = 5
|
||||
var p *int = &a
|
||||
println(*p) // 5
|
||||
foo(&p)
|
||||
println(*p) // 55
|
||||
}
|
||||
|
||||
|
||||
对应这段代码的示意图如下(注意:仅是示意图,不是内存真实布局):
|
||||
|
||||
|
||||
|
||||
在这个例子中我们可以看到,通过二级指针pp,我们改变的是它指向的一级指针变量p的指向,从指向变量a的地址变为指向变量b的地址。
|
||||
|
||||
即便有图有真相,你可能也会觉得理解二级指针还是很困难,这很正常。无论是学习C还是学习Go,又或是其他带有指针的静态编程语言,二级指针虽然仅仅是增加了一个“间接环节”,但理解起来都十分困难,这也是二级指针在Go中很少使用的原因。至于三级指针或其他多级指针,我们更是要慎用,对它们的使用会大幅拉低你的Go代码的可读性。
|
||||
|
||||
接下来我们再来看看指针在Go中的用途以及使用上的限制。
|
||||
|
||||
Go中的指针用途与使用限制
|
||||
|
||||
Go是带有垃圾回收的编程语言,指针在Go中依旧位于C位,它的作用不仅体现在语法层面上,更体现在Go运行时层面,尤其是内存管理与垃圾回收这两个地方,这两个运行时机制只关心指针。
|
||||
|
||||
在语法层面,相对于“指针为王”的C语言来说,Go指针的使用要少不少,这很大程度上是因为Go提供了更灵活和高级的复合类型,比如切片、map等,并将使用指针的复杂性隐藏在运行时的实现层面了。这样,Go程序员自己就不需要在语法层面通过指针来实现这些高级复合类型的功能。
|
||||
|
||||
指针无论是在Go中,还是在其他支持指针的编程语言中,存在的意义就是为了是“可改变”。在Go中,我们使用*T类型的变量调用方法、以*T类型作为函数或方法的形式参数、返回*T类型的返回值等的目的,也都是因为指针可以改变其指向的内存单元的值。
|
||||
|
||||
当然,指针的好处,还包括它传递的开销是常数级的(在x86-64平台上仅仅是8字节的拷贝),可控可预测。无论指针指向的是一个字节大小的变量,还是一个拥有10000个元素的[10000]int型数组,传递指针的开销都是一样的。
|
||||
|
||||
不过,虽然Go在语法层面上保留了指针,但Go语言的目标之一是成为一门安全的编程语言,因此,它对指针的使用做了一定的限制,包括这两方面:
|
||||
|
||||
限制一:限制了显式指针类型转换。
|
||||
|
||||
在C语言中,我们可以像下面代码这样实现显式指针类型转换:
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
int a = 0x12345678;
|
||||
int *p = &a;
|
||||
char *p1 = (char*)p; // 将一个整型指针显式转换为一个char型指针
|
||||
printf("%x\n", *p1);
|
||||
}
|
||||
|
||||
|
||||
但是在Go中,这样的显式指针转换会得到Go编译器的报错信息:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var a int = 0x12345678
|
||||
var pa *int = &a
|
||||
var pb *byte = (*byte)(pa) // 编译器报错:cannot convert pa (variable of type *int) to type *byte
|
||||
fmt.Printf("%x\n", *pb)
|
||||
}
|
||||
|
||||
|
||||
如果我们“一意孤行”,非要进行这个转换,Go也提供了unsafe的方式,因为我们需要使用到unsafe.Pointer,如下面代码:
|
||||
|
||||
func main() {
|
||||
var a int = 0x12345678
|
||||
var pa *int = &a
|
||||
var pb *byte = (*byte)(unsafe.Pointer(pa)) // ok
|
||||
fmt.Printf("%x\n", *pb) // 78
|
||||
}
|
||||
|
||||
|
||||
如果我们使用unsafe包中类型或函数,代码的安全性就要由开发人员自己保证,也就是开发人员得明确知道自己在做啥!
|
||||
|
||||
限制二:不支持指针运算。
|
||||
|
||||
指针运算是C语言的大杀器,在C语言中,我们可以通过指针运算实现各种高级操作,比如简单的数组元素的遍历:
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
int a[] = {1, 2, 3, 4, 5};
|
||||
int *p = &a[0];
|
||||
for (int i = 0; i < sizeof(a)/sizeof(a[0]); i++) {
|
||||
printf("%d\n", *p);
|
||||
p = p + 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
但指针运算也是安全问题的“滋生地”。为了安全性,Go在语法层面抛弃了指针运算这个特性。在Go语言中,下面的代码将得到Go编译器的报错信息:
|
||||
|
||||
package main
|
||||
|
||||
func main() {
|
||||
var arr = [5]int{1, 2, 3, 4, 5}
|
||||
var p *int = &arr[0]
|
||||
println(*p)
|
||||
p = p + 1 // 编译器报错:cannot convert 1 (untyped int constant) to *int
|
||||
println(*p)
|
||||
}
|
||||
|
||||
|
||||
如果我们非要做指针运算,Go依然提供了unsafe的途径,比如下面通过unsafe遍历数组的代码:
|
||||
|
||||
package main
|
||||
|
||||
import "unsafe"
|
||||
|
||||
func main() {
|
||||
var arr = [5]int{11, 12, 13, 14, 15}
|
||||
var p *int = &arr[0]
|
||||
var i uintptr
|
||||
for i = 0; i < uintptr(len(arr)); i++ {
|
||||
p1 := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(p)) + i*unsafe.Sizeof(*p)))
|
||||
println(*p1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
上面这段代码就通过unsafe.Pointer与uintptr的相互转换,间接实现了“指针运算”。但即便我们可以使用unsafe方法实现“指针运算”,Go编译器也不会为开发人员提供任何帮助,开发人员需要自己告诉编译器要加减的绝对地址偏移值,而不是像前面C语言例子中那样,可以根据指针类型决定指针运算中数值1所代表的实际地址偏移值。
|
||||
|
||||
小结
|
||||
|
||||
好了,讲到这里指针的这节加餐就结束了,不知道现在你是否对指针有了一个初步的认知了呢!
|
||||
|
||||
指针变量是一种在它对应的内存单元中,存储另外一个变量a对应的内存单元地址的变量,我们也称该指针指向变量a。指针类型通常需要依托某一类型而存在,unsafe包的Pointer类型是个例外。
|
||||
|
||||
指针变量的声明与普通变量别无二异,我们可以用一个指针的基类型的变量的地址,为指针变量赋初值。如果指针变量没有初值,那它的默认值为nil。通过对指针变量的解引用,我们可以读取和修改其指向的变量的值。
|
||||
|
||||
我们可以声明指向指针的指针变量,这样的指针被称为二级指针。二级指针可以用来改变指针变量的值,也就是指针变量的指向。不过二级指针以及多级指针很难理解,一旦使用会降低代码的可读性,我建议你一定要慎用。
|
||||
|
||||
另外,出于内存安全性的考虑,Go语言对指针的使用做出了限制,不允许在Go代码中进行显式指针类型转换以及指针运算,当然我们可以通过unsafe方式实现这些功能,但在使用unsafe包的类型与函数时,你一定要知道你正在做什么,确保代码的正确性。
|
||||
|
||||
思考题
|
||||
|
||||
学完这一讲后,我建议你回看一下本专栏中涉及指针的章节与实战项目,你可能会有新的收获。
|
||||
|
||||
|
||||
|
||||
|
||||
458
专栏/TonyBai·Go语言第一课/加餐聊聊最近大热的Go泛型.md
Normal file
458
专栏/TonyBai·Go语言第一课/加餐聊聊最近大热的Go泛型.md
Normal file
@@ -0,0 +1,458 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐 聊聊最近大热的Go泛型
|
||||
你好,我是Tony Bai。
|
||||
|
||||
美国时间2022年1月31日,在中国人民欢庆虎年春节之际,Go核心团队发布了Go 1.18 Beta2版本。在Go 1.18beta2版本发布的博文中,Go核心团队还给出了Go 1.18版本的发布计划:2022年2月发布Go 1.18RC(release candidate,即发布候选版),2022年3月发布Go 1.18最终版本。
|
||||
|
||||
考虑到Go 1.18版本中引入了Go语言开源以来最大的语法特性变化:泛型(generic),改动和影响都很大,Go核心团队将Go 1.18版本延迟一个月,放到3月发布也不失为稳妥之举。
|
||||
|
||||
在Go泛型正式落地之前,我想在这篇加餐中带你认识一下Go泛型,目的是“抛砖引玉”,为你后续系统学习和应用Go泛型语法特性开个头儿。
|
||||
|
||||
我们今天将围绕Go为什么加入泛型、泛型设计方案的演化历史、Go泛型的主要语法以及Go泛型的使用建议几个方面,聊聊Go泛型的那些事儿。
|
||||
|
||||
首先,我们先来了解一下Go语言为什么要加入泛型语法特性。
|
||||
|
||||
为什么要加入泛型?
|
||||
|
||||
根据近几年的Go官方用户调查结果,在“你最想要的Go语言特性”这项调查中,泛型霸榜多年。你可以看下这张摘自最新的2020年Go官方用户调查结果的图片:
|
||||
|
||||
|
||||
|
||||
既然Go社区对泛型特性的需求如此强烈,那么Go核心团队为何要在Go开源后的第13个年头,才将这个特性加入语言当中呢?这里的故事说来话长。要想了解其中原因,我们需要先来了解一下什么是泛型?
|
||||
|
||||
维基百科提到:最初泛型编程这个概念来自于缪斯·大卫和斯捷潘诺夫.亚历山大合著的“泛型编程”一文。那篇文章对泛型编程的诠释是:“泛型编程的中心思想是对具体的、高效的算法进行抽象,以获得通用的算法,然后这些算法可以与不同的数据表示法结合起来,产生各种各样有用的软件”。说白了就是将算法与类型解耦,实现算法更广泛的复用。
|
||||
|
||||
我们举个简单的例子。这里是一个简单得不能再简单的加法函数,这个函数接受两个int32类型参数作为加数:
|
||||
|
||||
func Add(a, b int32) int32 {
|
||||
return a + b
|
||||
}
|
||||
|
||||
|
||||
不过上面的函数Add仅适用于int32类型的加数,如果我们要对int、int64、byte等类型的加数进行加法运算,我们还需要实现AddInt、AddInt64、AddByte等函数。
|
||||
|
||||
那如果我们用泛型编程的思想来解决这个问题,是怎样呢?
|
||||
|
||||
我们需要将算法与类型解耦,实现一个泛型版的Add算法,我们用Go泛型语法实现的泛型版Add是这样的(注意这里需要使用Go 1.18beta1或后续版本进行编译和运行):
|
||||
|
||||
func Add[T constraints.Integer](a, b T) T {
|
||||
return a + b
|
||||
}
|
||||
|
||||
|
||||
这样,我们就可以直接使用泛型版Add函数去进行各种整型类型的加法运算了,比如下面代码:
|
||||
|
||||
func main() {
|
||||
var m, n int = 5, 6
|
||||
println(Add(m,n)) // Add[int](m, n)
|
||||
var i,j int64 = 15, 16
|
||||
println(Add(i,j)) // Add[int64](i, j)
|
||||
var c,d byte = 0x11, 0x12
|
||||
println(Add(c,d)) // Add[byte](c, d)
|
||||
}
|
||||
|
||||
|
||||
通过这个例子我们可以看到,在没有泛型的情况下,我们需要针对不同类型重复实现相同的算法逻辑,比如上面例子提到的AddInt、AddInt64等。
|
||||
|
||||
这对于简单的、诸如上面这样的加法函数还可忍受,但对于复杂的算法,比如涉及复杂排序、查找、树、图等算法,以及一些容器类型(链表、栈、队列等)的实现时,缺少了泛型的支持还真是麻烦。
|
||||
|
||||
在没有泛型之前,Gopher们通常使用空接口类型interface{},作为算法操作的对象的数据类型,不过这样做的不足之处也很明显:一是无法进行类型安全检查,二是性能有损失。
|
||||
|
||||
那么回到前面的问题,既然泛型有这么多优点,为什么Go不早点加入泛型呢?其实这个问题在Go FAQ中早有答案,我总结一下大概有三点主要理由:
|
||||
|
||||
|
||||
这个语法特性不紧迫,不是Go早期的设计目标;
|
||||
|
||||
|
||||
在Go诞生早期,很多基本语法特性的优先级都要高于泛型。此外,Go团队更多将语言的设计目标定位在规模化(scalability)、可读性、并发性上,泛型与这些主要目标关联性不强。等Go成熟后,Go团队会在适当时候引入泛型。
|
||||
|
||||
|
||||
与简单的设计哲学有悖;
|
||||
|
||||
|
||||
Go语言最吸睛的地方就是简单,简单也是Go设计哲学之首!但泛型这个语法特性会给语言带来复杂性,这种复杂性不仅体现在语法层面上引入了新的语法元素,也体现在类型系统和运行时层面上为支持泛型进行了复杂的实现。
|
||||
|
||||
|
||||
尚未找到合适的、价值足以抵消其引入的复杂性的理想设计方案。
|
||||
|
||||
|
||||
从Go开源那一天开始,Go团队就没有间断过对泛型的探索,并一直尝试寻找一个理想的泛型设计方案,但始终未能如愿。
|
||||
|
||||
直到近几年Go团队觉得Go已经逐渐成熟,是时候下决心解决Go社区主要关注的几个问题了,包括泛型、包依赖以及错误处理等,并安排伊恩·泰勒和罗伯特·格瑞史莫花费更多精力在泛型的设计方案上,这才有了在即将发布的Go 1.18版本中泛型语法特性的落地。
|
||||
|
||||
为了让你更清晰地看到Go团队在泛型上付出的努力,同时也能了解Go泛型的设计过程与来龙去脉,这里我简单整理了一个Go泛型设计的简史,你可以参考一下。
|
||||
|
||||
Go泛型设计的简史
|
||||
|
||||
Go核心团队对泛型的探索,是从2009年12月3日Russ Cox在其博客站点上发表的一篇文章开始的。在这篇叫“泛型窘境”的文章中,Russ Cox提出了Go泛型实现的三个可遵循的方法,以及每种方法的不足,也就是三个slow(拖慢):
|
||||
|
||||
|
||||
拖慢程序员:不实现泛型,不会引入复杂性,但就像前面例子中那样,需要程序员花费精力重复实现AddInt、AddInt64等;
|
||||
拖慢编译器:就像C++的泛型实现方案那样,通过增加编译器负担为每个类型实例生成一份单独的泛型函数的实现,这种方案产生了大量的代码,其中大部分是多余的,有时候还需要一个好的链接器来消除重复的拷贝;
|
||||
拖慢执行性能:就像Java的泛型实现方案那样,通过隐式的装箱和拆箱操作消除类型差异,虽然节省了空间,但代码执行效率低。
|
||||
|
||||
|
||||
在当时,三个slow之间需要取舍,就如同数据一致性的CAP原则一样,无法将三个slow同时消除。
|
||||
|
||||
之后,伊恩·泰勒主要负责跟进Go泛型方案的设计。从2010到2016年,伊恩·泰勒先后提出了几版泛型设计方案,它们是:
|
||||
|
||||
|
||||
2010年6月份,伊恩·泰勒提出的Type Functions设计方案;
|
||||
2011年3月份,伊恩·泰勒提出的Generalized Types设计方案;
|
||||
2013年10月份,伊恩·泰勒提出的Generalized Types设计方案更新版;
|
||||
2013年12月份,伊恩·泰勒提出的Type Parameters设计方案;
|
||||
2016年9月份,布莱恩·C·米尔斯提出的Compile-time Functions and First Class Types设计方案。
|
||||
|
||||
|
||||
虽然这些方案因为存在各种不足,最终都没有被接受,但这些探索为后续Go泛型的最终落地奠定了基础。
|
||||
|
||||
2017年7月,Russ Cox在GopherCon 2017大会上发表演讲“Toward Go 2”,正式吹响Go向下一个阶段演化的号角,包括重点解决泛型、包依赖以及错误处理等Go社区最广泛关注的问题。
|
||||
|
||||
后来,在2018年8月,也就是GopherCon 2018大会结束后不久,Go核心团队发布了Go2 draft proposal,这里面涵盖了由伊恩·泰勒和罗伯特·格瑞史莫操刀主写的Go泛型的第一版draft proposal。
|
||||
|
||||
这版设计草案引入了contract关键字来定义泛型类型参数(type parameter)的约束、类型参数放在普通函数参数列表前面的小括号中,并用type关键字声明。下面是这个草案的语法示例:
|
||||
|
||||
// 第一版泛型技术草案中的典型泛型语法
|
||||
|
||||
contract stringer(x T) {
|
||||
var s string = x.String()
|
||||
}
|
||||
|
||||
func Stringify(type T stringer)(s []T) (ret []string) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
接着,在2019年7月,伊恩·泰勒在GopherCon 2019大会上发表演讲“Why Generics?”,并更新了泛型的技术草案,简化了contract的语法设计,下面是简化后的contract语法,你可以对比上面代码示例中的contract语法看看:
|
||||
|
||||
contract stringer(T) {
|
||||
T String() string
|
||||
}
|
||||
|
||||
|
||||
后来,在2020年6月,一篇叫《Featherweight Go》论文发表在arxiv.org上,这篇论文出自著名计算机科学家、函数语言专家、Haskell语言的设计者之一、Java泛型的设计者菲利普·瓦德勒(Philip Wadler)之手。
|
||||
|
||||
Rob Pike邀请他帮助Go核心团队解决Go语言的泛型扩展问题,这篇论文就是菲利普·瓦德对这次邀请的回应
|
||||
|
||||
这篇论文为Go语言的一个最小语法子集设计了泛型语法Featherweight Generic Go(FGG),并成功地给出了FGG到Feighterweight Go(FG)的可行性实现的形式化证明。这篇论文的形式化证明给Go团队带来了很大信心,也让Go团队在一些泛型语法问题上达成更广泛的一致。
|
||||
|
||||
2020年6月末,伊恩·泰勒和罗伯特·格瑞史莫在Go官方博客发表了文章《The Next Step for Generics》,介绍了Go泛型工作的最新进展。Go团队放弃了之前的技术草案,并重新编写了一个新草案。
|
||||
|
||||
在这份新技术方案中,Go团队放弃了引入contract关键字作为泛型类型参数的约束,而采用扩展后的interface来替代contract。这样上面的Stringify函数就可以写成如下形式:
|
||||
|
||||
type Stringer interface {
|
||||
String() string
|
||||
}
|
||||
|
||||
func Stringify(type T Stringer)(s []T) (ret []string) {
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
同时,Go团队还推出了可以在线试验Go泛型语法的playground,这样Gopher们可以直观体验新语法,并给出自己的意见反馈。
|
||||
|
||||
然后,在2020年11月的GopherCon 2020大会,罗伯特·格瑞史莫与全世界的Gopher同步了Go泛型的最新进展和roadmap,在最新的技术草案版本中,包裹类型参数的小括号被方括号取代,类型参数前面的type关键字也不再需要了:
|
||||
|
||||
func Stringify[T Stringer](s []T) (ret []string) {
|
||||
... ...
|
||||
}
|
||||
|
||||
|
||||
与此同时,go2goplay.golang.org也支持了方括号语法,Gopher们可以在线体验。
|
||||
|
||||
接下来的2021年1月,Go团队正式提出将泛型加入Go的proposal,2021年2月,这个提案被正式接受。
|
||||
|
||||
然后是2021年4月,伊恩·泰勒在GitHub上发布issue,提议去除原Go泛型方案中置于interface定义中的type list中的type关键字,并引入type set的概念,下面是相关示例代码:
|
||||
|
||||
// 之前使用type list的方案
|
||||
type SignedInteger interface {
|
||||
type int, int8, int16, int32, int64
|
||||
}
|
||||
|
||||
|
||||
|
||||
// type set理念下的新语法
|
||||
type SignedInteger interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64
|
||||
}
|
||||
|
||||
|
||||
那什么是type set(类型集合)呢?伊恩·泰勒给出了这个概念的定义:
|
||||
|
||||
|
||||
每个类型都有一个type set;
|
||||
非接口类型的类型的type set中仅包含其自身。比如非接口类型T,它的type set中唯一的元素就是它自身:{T};
|
||||
对于一个普通的、没有type list的普通接口类型来说,它的type set是一个无限集合。所有实现了这个接口类型所有方法的类型,都是该集合的一个元素,另外,由于该接口类型本身也声明了其所有方法,因此接口类型自身也是其Type set的一员;
|
||||
空接口类型interface{}的type set中囊括了所有可能的类型。
|
||||
|
||||
|
||||
这样一来,我们可以试试用type set概念,重新表述一下一个类型T实现一个接口类型I。也就是当类型T是接口类型I的type set的一员时,T便实现了接口I;对于使用嵌入接口类型组合而成的接口类型,其type set就是其所有的嵌入的接口类型的type set的交集。
|
||||
|
||||
而对于一个带有自身Method的嵌入其他接口类型的接口类型,比如下面代码中的MyInterface3:
|
||||
|
||||
type MyInterface3 interface {
|
||||
E1
|
||||
E2
|
||||
MyMethod03()
|
||||
}
|
||||
|
||||
|
||||
它的type set可以看成E1、E2和E3(type E3 interface { MyMethod03()})的type set的交集。
|
||||
|
||||
最后,在2021年12月14日,Go 1.18 beta1版本发布,这个版本包含了对Go泛型的正式支持。
|
||||
|
||||
经过12年的努力与不断地自我否定,Go团队终于将泛型引入到Go中,并且经过缜密设计的语法并没有违背Go1的兼容性。那么接下来,我们就正式看看Go泛型的基本语法。
|
||||
|
||||
Go泛型的基本语法
|
||||
|
||||
我们前面也说了,Go泛型是Go开源以来在语法层面的最大一次变动,Go泛型的最后一版技术提案长达数十页,我们要是把其中的细节都展开细讲,那都可以自成一本小册子了。因此,Go泛型语法不是一篇加餐可以系统学习完的,我这里不会抠太多细节,只给你呈现主要的语法
|
||||
|
||||
Go泛型的核心是类型参数(type parameter),下面我们就从类型参数开始,了解一下Go泛型的基本语法。
|
||||
|
||||
类型参数(type parameter)
|
||||
|
||||
类型参数是在函数声明、方法声明的receiver部分或类型定义的类型参数列表中,声明的(非限定)类型名称。类型参数在声明中充当了一个未知类型的占位符(placeholder),在泛型函数或泛型类型实例化时,类型参数会被一个类型实参替换。
|
||||
|
||||
为了让你更好地理解类型参数究竟如何声明,它又起到了什么作用,我们以函数为例,对普通函数的参数与泛型函数的类型参数作一下对比:
|
||||
|
||||
我们知道,普通函数的参数列表是这样的:
|
||||
|
||||
func Foo(x, y aType, z anotherType)
|
||||
|
||||
|
||||
这里,x, y, z是形参(parameter)的名字,也就是变量,而aType,anotherType是形参的类型,也就是类型。
|
||||
|
||||
我们再来看一下泛型函数的类型参数(type parameter)列表:
|
||||
|
||||
func GenericFoo[P aConstraint, Q anotherConstraint](x,y P, z Q)
|
||||
|
||||
|
||||
这里,P、Q是类型形参的名字,也就是类型。aConstraint,anotherConstraint代表类型参数的约束(constraint),我们可以理解为对类型参数可选值的一种限定。
|
||||
|
||||
从GenericFoo函数的声明中,我们可以看到,泛型函数的声明相比于普通函数多出了一个组成部分:类型参数列表。
|
||||
|
||||
类型参数列表位于函数名与函数参数列表之间,通过一个方括号括起。类型参数列表不支持变长类型参数。而且,类型参数列表中声明的类型参数,可以作为函数普通参数列表中的形参类型。
|
||||
|
||||
但在泛型函数声明时,我们并不知道P、Q两个类型参数具体代表的究竟是什么类型,因此函数参数列表中的P、Q更像是未知类型的占位符。
|
||||
|
||||
那么P、Q的类型什么时候才能确定呢?这就要等到泛型函数具化(instantiation)时才能确定。另外,按惯例,类型参数(type parameter)的名字都是首字母大写的,通常都是用单个大写字母命名。
|
||||
|
||||
在类型参数列表中修饰类型参数的就是约束(constraint)。那什么是约束呢?我们继续往下看。
|
||||
|
||||
约束(constraint)
|
||||
|
||||
约束(constraint)规定了一个类型实参(type argument)必须满足的条件要求。如果某个类型满足了某个约束规定的所有条件要求,那么它就是这个约束修饰的类型形参的一个合法的类型实参。
|
||||
|
||||
在Go泛型中,我们使用interface类型来定义约束。为此,Go接口类型的定义也进行了扩展,我们既可以声明接口的方法集合,也可以声明可用作类型实参的类型列表。下面是一个约束定义与使用的示例:
|
||||
|
||||
type C1 interface {
|
||||
~int | ~int32
|
||||
M1()
|
||||
}
|
||||
|
||||
type T struct{}
|
||||
func (T) M1() {
|
||||
}
|
||||
|
||||
type T1 int
|
||||
func (T1) M1() {
|
||||
}
|
||||
|
||||
func foo[P C1](t P)() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
var t1 T1
|
||||
foo(t1)
|
||||
var t T
|
||||
foo(t) // 编译器报错:T does not implement C1
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,C1是我们定义的约束,它声明了一个方法M1,以及两个可用作类型实参的类型(~int | ~int32)。我们看到,类型列表中的多个类型实参类型用“|”分隔。
|
||||
|
||||
在这段代码中,我们还定义了两个自定义类型T和T1,两个类型都实现了M1方法,但T类型的底层类型为struct{},而T1类型的底层类型为int,这样就导致了虽然T类型满足了约束C1的方法集合,但类型T因为底层类型并不是int或int32而不满足约束C1,这也就会导致foo(t)调用在编译阶段报错。
|
||||
|
||||
不过,我这里还要建议你:做约束的接口类型与做传统接口的接口类型最好要分开定义,除非约束类型真的既需要方法集合,也需要类型列表。
|
||||
|
||||
知道了类型参数声明的形式,也知道了约束如何定义后,我们再来看看如何使用带有类型参数的泛型函数。
|
||||
|
||||
类型具化(instantiation)
|
||||
|
||||
声明了泛型函数后,接下来就要调用泛型函数来实现具体的业务逻辑。现在我们就通过一个泛型版本Sort函数的调用例子,看看调用泛型函数的过程都发生了什么:
|
||||
|
||||
func Sort[Elem interface{ Less(y Elem) bool }](list []Elem) {
|
||||
}
|
||||
|
||||
type book struct{}
|
||||
func (x book) Less(y book) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func main() {
|
||||
var bookshelf []book
|
||||
Sort[book](bookshelf) // 泛型函数调用
|
||||
}
|
||||
|
||||
|
||||
根据Go泛型的实现原理,上面的泛型函数调用Sort[book](bookshelf)会分成两个阶段:
|
||||
|
||||
第一个阶段就是具化(instantiation)。
|
||||
|
||||
形象点说,具化(instantiation)就好比一家生产“排序机器”的工厂根据要排序的对象的类型,将这样的机器生产出来的过程。我们继续举前面的例子来分析一下,整个具化过程如下:
|
||||
|
||||
|
||||
工厂接单:Sort[book],发现要排序的对象类型为book;
|
||||
模具检查与匹配:检查book类型是否满足模具的约束要求(也就是是否实现了约束定义中的Less方法)。如果满足,就将其作为类型实参替换Sort函数中的类型形参,结果为Sort[book],如果不满足,编译器就会报错;
|
||||
生产机器:将泛型函数Sort具化为一个新函数,这里我们把它起名为booksort,其函数原型为func([]book)。本质上booksort := Sort[book]。
|
||||
|
||||
|
||||
第二阶段是调用(invocation)。
|
||||
|
||||
一旦“排序机器”被生产出来,那么它就可以对目标对象进行排序了,这和普通的函数调用没有区别。这里就相当于调用booksort(bookshelf),整个过程只需要检查传入的函数实参(bookshelf)的类型与booksort函数原型中的形参类型([]book)是否匹配就可以了。
|
||||
|
||||
我们用伪代码来表述上面两个过程:
|
||||
|
||||
Sort[book](bookshelf)
|
||||
|
||||
<=>
|
||||
|
||||
具化:booksort := Sort[book]
|
||||
调用:booksort(bookshelf)
|
||||
|
||||
|
||||
不过,每次调用Sort都要传入类型实参book,这和普通函数调用相比还是繁琐了不少。那么能否像普通函数那样只传入普通参数实参,不用传入类型参数实参呢?
|
||||
|
||||
答案是可以的。
|
||||
|
||||
Go编译器会根据传入的实参变量,进行实参类型参数的自动推导(Argument type inference),也就是说上面的例子,我们只需要像这样进行Sort的调用就可以了:
|
||||
|
||||
Sort(bookshelf)
|
||||
|
||||
|
||||
有了对类型参数的实参类型的自动推导,大多数泛型函数的调用方式与常规函数调用一致,不会给Gopher带去额外的代码编写负担。
|
||||
|
||||
泛型类型
|
||||
|
||||
除了函数可以携带类型参数变身为“泛型函数”外,类型也可以拥有类型参数而化身为“泛型类型”,比如下面代码就定义了一个向量泛型类型:
|
||||
|
||||
type Vector[T any] []T
|
||||
|
||||
|
||||
这是一个带有类型参数的类型定义,类型参数位于类型名的后面,同样用方括号括起。在类型定义体中可以引用类型参数列表中的参数名(比如T)。类型参数同样拥有自己的约束,如上面代码中的any。在Go 1.18中,any是interface{}的别名,也是一个预定义标识符,使用any作为类型参数的约束,代表没有任何约束。
|
||||
|
||||
使用泛型类型,我们也要遵循先具化,再使用的顺序,比如下面例子:
|
||||
|
||||
type Vector[T any] []T
|
||||
|
||||
func (v Vector[T]) Dump() {
|
||||
fmt.Printf("%#v\n", v)
|
||||
}
|
||||
|
||||
func main() {
|
||||
var iv = Vector[int]{1,2,3,4}
|
||||
var sv Vector[string]
|
||||
sv = []string{"a","b", "c", "d"}
|
||||
iv.Dump()
|
||||
sv.Dump()
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,我们在使用Vector[T]之前都显式用类型实参对泛型类型进行了具化,从而得到具化后的类型Vector[int]和Vector[string]。 Vector[int]的底层类型为[]int,Vector[string]的底层类型为[]string。然后我们再对具化后的类型进行操作。
|
||||
|
||||
以上就是Go泛型语法特性的一些主要语法概念,我们可以看到,泛型的加入确实进一步提高了程序员的开发效率,大幅提升了算法的重用性。
|
||||
|
||||
那么,Go泛型方案对Go程序的运行时性能又带来了哪些影响呢?我们接下来就来通过例子验证一下。
|
||||
|
||||
Go泛型的性能
|
||||
|
||||
我们创建一个性能基准测试的例子,参加这次测试的三位选手分别来自:
|
||||
|
||||
|
||||
Go标准库sort包(非泛型版)的Ints函数;
|
||||
Go团队维护golang.org/x/exp/slices中的泛型版Sort函数;
|
||||
对golang.org/x/exp/slices中的泛型版Sort函数进行改造得到的、仅针对[]int进行排序的Sort函数。
|
||||
|
||||
|
||||
相关的源码较多,我这里就不贴出来了,你可以到这里下载相关源码。
|
||||
|
||||
下面是使用Go 1.18beta2版本在macOS上运行该测试的结果:
|
||||
|
||||
$go test -bench .
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
pkg: demo
|
||||
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
|
||||
BenchmarkSortInts-8 96 12407700 ns/op 24 B/op 1 allocs/op
|
||||
BenchmarkSlicesSort-8 172 6961381 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkIntSort-8 172 6881815 ns/op 0 B/op 0 allocs/op
|
||||
PASS
|
||||
|
||||
|
||||
我们看到,泛型版和仅支持[]int的Sort函数的性能是一致的,性能都要比目前标准库的Ints函数高出近一倍,并且在排序过程中没有额外的内存分配。由此我们可以得出结论:至少在这个例子中,泛型在运行时并未给算法带来额外的负担。
|
||||
|
||||
现在看来,Go泛型没有拖慢程序员的开发效率,也没有拖慢运行效率,那么按照Russ Cox的“泛型窘境”文章中的结论,Go泛型是否拖慢编译性能了呢?
|
||||
|
||||
不过,因为目前采用Go泛型重写的项目比较少,我们还没法举例对比,但Go 1.18发布说明中给出了一个结论:Go 1.18编译器的性能要比Go 1.17下降15%左右。不过,Go核心团队也承诺将在Go 1.19中改善编译器的性能,这里也希望到时候的优化能抵消Go泛型带来的影响。
|
||||
|
||||
了解了Go泛型并未影响到运行时性能,这让我们的心里有了底。但关于Go泛型,想必你还会有疑问,那就是:我们应该在什么时候使用泛型,又应该如何使用泛型呢?最后我们就来看看这两个问题的答案。
|
||||
|
||||
Go泛型的使用建议
|
||||
|
||||
前面说过,Go当初没有及时引入泛型的一个原因就是与Go语言“简单”的设计哲学有悖,现在加入了泛型,随之而来的就是增加了语言的复杂性。
|
||||
|
||||
为了尽量降低复杂性,Go团队做了很多工作,包括前面提到的在语法中加入类型实参的自动推导等语法糖,尽量减少给开发人员编码时带去额外负担,也尽可能保持Go代码良好的可读性。
|
||||
|
||||
此外,Go核心团队最担心的就是“泛型被滥用”,所以Go核心团队在各种演讲场合都在努力地告诉大家Go泛型的适用场景以及应该如何使用。这里我也梳理一下来自Go团队的这些建议,你可以参考一下。
|
||||
|
||||
什么情况适合使用泛型
|
||||
|
||||
首先,类型参数的一种有用的情况,就是当编写的函数的操作元素的类型为slice、map、channel等特定类型的时候。如果一个函数接受这些类型的形参,并且函数代码没有对参数的元素类型作出任何假设,那么使用类型参数可能会非常有用。在这种场合下,泛型方案可以替代反射方案,获得更高的性能。
|
||||
|
||||
另一个适合使用类型参数的情况是编写通用数据结构。所谓的通用数据结构,指的是像切片或map这样,但Go语言又没有提供原生支持的类型。比如一个链表或一个二叉树。
|
||||
|
||||
今天,需要这类数据结构的程序会使用特定的元素类型实现它们,或者是使用接口类型(interface{})来实现。不过,如果我们使用类型参数替换特定元素类型,可以实现一个更通用的数据结构,这个通用的数据结构可以被其他程序复用。而且,用类型参数替换接口类型通常也会让数据存储的更为高效。
|
||||
|
||||
另外,在一些场合,使用类型参数替代接口类型,意味着代码可以避免进行类型断言(type assertion),并且在编译阶段还可以进行全面的类型静态检查。
|
||||
|
||||
什么情况不宜使用泛型
|
||||
|
||||
首先,如果你要对某一类型的值进行的全部操作,仅仅是在那个值上调用一个方法,请使用interface类型,而不是类型参数。比如,io.Reader易读且高效,没有必要像下面代码中这样使用一个类型参数像调用Read方法那样去从一个值中读取数据:
|
||||
|
||||
func ReadAll[reader io.Reader](r reader) ([]byte, error) // 错误的作法
|
||||
func ReadAll(r io.Reader) ([]byte, error) // 正确的作法
|
||||
|
||||
|
||||
使用类型参数的原因是它们让你的代码更清晰,如果它们会让你的代码变得更复杂,就不要使用。
|
||||
|
||||
第二,当不同的类型使用一个共同的方法时,如果一个方法的实现对于所有类型都相同,就使用类型参数;相反,如果每种类型的实现各不相同,请使用不同的方法,不要使用类型参数。
|
||||
|
||||
最后,如果你发现自己多次编写完全相同的代码(样板代码),各个版本之间唯一的差别是代码使用不同的类型,那就请你考虑是否可以使用类型参数。反之,在你注意到自己要多次编写完全相同的代码之前,应该避免使用类型参数。
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的加餐讲到这里就结束了。在这一讲中,我带你初步了解了Go泛型的那些事儿,主要是想为你后续系统学习Go泛型引个路。
|
||||
|
||||
正如Go团队在Go FAQ中描述的那样,Go团队从来没有拒绝泛型,只是长时间来没有找到一个合适的实现方案。Go团队需要在Russ Cox的“泛型窘境”中提到的三个slow中寻找平衡。
|
||||
|
||||
十多年来,Go团队一直在尝试与打磨,终于在近几年取得了突破性的进展,设计出一种可以向后兼容Go1的方案,并下决心在Go 1.18版本中落地泛型。
|
||||
|
||||
Go泛型也称为类型参数,我们可以在函数声明、方法声明的receiver部分或类型定义中使用类型参数,来实现泛型函数和泛型类型。我们还需为类型参数设定约束,通过扩展的interface类型定义,我们可以定义这种约束。
|
||||
|
||||
目前来看,Go泛型的引入并没有给程序运行带来额外性能开销,但在一定程度上拖慢的编译器的性能。同时也带来了语法上的复杂性,为此,Go团队建议大家谨慎使用泛型,同时给出了一些使用建议。
|
||||
|
||||
最后要和你特别说明一下,Go 1.18仅仅是Go泛型的起点,就像Go Module构建机制一样,Go泛型的成熟与稳定还需要几个Go发布版本的努力。而且我们这一讲中涉及到泛型的代码都需要你安装Go 1.18beta1或以上版本。
|
||||
|
||||
思考题
|
||||
|
||||
Go泛型对于你来说估计还比较陌生,这里我也给你留了一个作业,那就是仔细阅读一遍Go泛型的技术方案:https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md
|
||||
|
||||
如果你在阅读过程中有任何问题,欢迎在留言区提出。我是Tony Bai,我们下节课再见。
|
||||
|
||||
|
||||
|
||||
|
||||
438
专栏/TonyBai·Go语言第一课/大咖助阵叶剑峰:Go语言中常用的那些代码优化点.md
Normal file
438
专栏/TonyBai·Go语言第一课/大咖助阵叶剑峰:Go语言中常用的那些代码优化点.md
Normal file
@@ -0,0 +1,438 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
大咖助阵 叶剑峰:Go语言中常用的那些代码优化点
|
||||
你好,我是轩脉刃,是《手把手带你写一个Web框架》专栏的作者。
|
||||
|
||||
很高兴应编辑邀请,为 Tony Bai 老师的专栏写一篇加餐文章。Tony Bai大佬是我很早在微博关注的一名Go先行者。他的《Gopher Daily》也是我经常学习阅读的Go语言资料之一。很高兴看到Tony Bai老师在极客时间也开了一个专栏,将他的经验分享出来。
|
||||
|
||||
这篇加餐,我主要想和你聊一聊Go语言中常用的一些代码优化点。在Go语言中,如果你不断地在一线写代码,一定多多少少都会有一些写代码的套路和经验。这些套路和经验可以帮助你在实际工作中遇到类似问题时,更成竹在胸。
|
||||
|
||||
所以这里,我想和你分享一下我个人在开发过程中看到和使用到的一些常用的代码优化点,希望能给你日常编码带来一些帮助。
|
||||
|
||||
第一点:使用pkg/errors而不是官方error库
|
||||
|
||||
其实我们可以思考一下,我们在一个项目中使用错误机制,最核心的几个需求是什么?我觉得主要是这两点:
|
||||
|
||||
|
||||
附加信息:我们希望错误出现的时候能附带一些描述性的错误信息,甚至这些信息是可以嵌套的;
|
||||
附加堆栈:我们希望错误不仅仅打印出错误信息,也能打印出这个错误的堆栈信息,让我们可以知道出错的具体代码。
|
||||
|
||||
|
||||
在Go语言的演进过程中,error传递的信息太少一直是被诟病的一点。使用官方的error库,我们只能打印一条简单的错误信息,而没有更多的信息辅助快速定位错误。所以,我推荐你在应用层使用 github.com/pkg/errors 来替换官方的error库。因为使用pkg/errors,我们不仅能传递出标准库error的错误信息,还能传递出抛出error的堆栈信息。
|
||||
|
||||
这里,我们看一个例子直观感受一下。假设我们有一个项目叫errdemo,他有sub1,sub2两个子包。sub1和sub2两个包都有Diff和IoDiff两个函数。
|
||||
|
||||
|
||||
|
||||
我们设计的这个程序,在sub2.go和sub1.go中都抛出了错误,且错误信息都为diff error。我们看下使用标准库error和pkg/errors都能返回什么信息:
|
||||
|
||||
// sub2.go
|
||||
package sub2
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
func Diff(foo int, bar int) error {
|
||||
return errors.New("diff error")
|
||||
}
|
||||
|
||||
|
||||
|
||||
// sub1.go
|
||||
package sub1
|
||||
|
||||
import (
|
||||
"errdemo/sub1/sub2"
|
||||
"fmt"
|
||||
"errors"
|
||||
)
|
||||
func Diff(foo int, bar int) error {
|
||||
if foo < 0 {
|
||||
return errors.New("diff error")
|
||||
}
|
||||
if err := sub2.Diff(foo, bar); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"errdemo/sub1"
|
||||
"fmt"
|
||||
)
|
||||
func main() {
|
||||
err := sub1.Diff(1, 2)
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
|
||||
在这三段代码中,我们很不幸地将sub1.go中的Diff返回的error和sub2.go中Diff返回的error,都定义为同样的字符串“diff error”:
|
||||
|
||||
|
||||
|
||||
这个时候,在main.go中,我们是无论如何都不能通过这个错误信息,来判断这个error到底是从sub1 还是 sub2 中抛出的,调试的时候会带来很大的困扰。
|
||||
|
||||
而使用 github.com/pkg/errors ,就不同了,它可以把错误的堆栈信息也打印出来。而且,我们所有的代码都不需要进行修改,只需要将import地方进行对应的修改就可以了。
|
||||
|
||||
比如,在这段代码中,我们只需要在main.go中使用fmt.Printf("%+v", err) 就可以了:
|
||||
|
||||
// sub2.go
|
||||
package sub2
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
func Diff(foo int, bar int) error {
|
||||
return errors.New("diff error")
|
||||
}
|
||||
|
||||
|
||||
|
||||
// sub1.go
|
||||
package sub1
|
||||
|
||||
import (
|
||||
"errdemo/sub1/sub2"
|
||||
"fmt"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
func Diff(foo int, bar int) error {
|
||||
if foo < 0 {
|
||||
return errors.New("diff error")
|
||||
}
|
||||
if err := sub2.Diff(foo, bar); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"errdemo/sub1"
|
||||
"fmt"
|
||||
)
|
||||
func main() {
|
||||
err := sub1.Diff(1, 2)
|
||||
fmt.Printf("%+v", err)
|
||||
}
|
||||
|
||||
|
||||
我们再看这个程序运行的结果:
|
||||
|
||||
|
||||
|
||||
我们可以看到,除了”diff error” 的错误信息之外,pkg/errors 还将堆栈打印出来了,我们能明确地看到是sub2.go中第7行抛出的错误。
|
||||
|
||||
那么,github.com/pkg/errors是怎么实现这个功能的呢?其实,它的原理非常简单,它就是利用了fmt包的一个特性。fmt包在打印error之前会判断当前打印的对象是否实现了Formatter接口,这个formatter接口只有一个format方法。如果要输出的对象实现了这个Formatter接口,则调用对象的Format方法来打印信息:
|
||||
|
||||
type Formatter interface {
|
||||
Format(f State, c rune)
|
||||
}
|
||||
|
||||
|
||||
而github.com/pkg/errors 中提供的各种初始化error方法(包括errors.New)封装了一个fundamental 结构,这个结构就是实现了Formatter接口:
|
||||
|
||||
// fundamental is an error that has a message and a stack, but no caller.
|
||||
type fundamental struct {
|
||||
msg string
|
||||
*stack
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,这个fundamental结构中带着error的信息和堆栈信息。并且实现了Format方法。在Format方法中,判断调用fmt.Printf 函数的第一个参数,如果是+v,则打印错误内容和堆栈信息,如果是v或者s,则打印错误内容,如果是q,则打印转义后的信息:
|
||||
|
||||
func (f *fundamental) Format(s fmt.State, verb rune){
|
||||
switch verb {
|
||||
case 'v':
|
||||
if s.Flag('+') {
|
||||
io.WriteString(s, f.msg)
|
||||
f.stack.Format(s, verb)
|
||||
return
|
||||
}
|
||||
fallthrough
|
||||
case 's':
|
||||
io.WriteString(s, f.msg)
|
||||
case 'q':
|
||||
fmt.Fprintf(s, "%q", f.msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
所以说,我们在实际的工作项目中,我建议你尽量使用pkg/errors而不是官方error库,这样我们能在错误出现的时候获取更多的错误信息,更快地定位问题。
|
||||
|
||||
第二点:在初始化slice的时候尽量补全cap
|
||||
|
||||
当我们要创建一个slice结构,并且往slice中append元素的时候,我们可能有两种写法来初始化这个slice。
|
||||
|
||||
方法一,直接使用[]int的方式来初始化:
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
arr := []int{}
|
||||
arr = append(arr, 1, 2, 3, 4, 5)
|
||||
fmt.Println(arr)
|
||||
}
|
||||
|
||||
|
||||
方法二,使用make关键字来初始化:
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
arr := make([]int, 0, 5)
|
||||
arr = append(arr, 1, 2, 3, 4, 5)
|
||||
fmt.Println(arr)
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,方法二相较于方法一,就只有一个区别:在初始化[]int slice的时候在make中设置了cap的长度,就是slice的大小。
|
||||
|
||||
而且,这两种方法对应的功能和输出结果是没有任何差别的,但是实际运行的时候,方法二会比方法一少运行了一个growslice的命令,能够提升我们程序的运行性能。具体我们可以打印汇编码查看一下。
|
||||
|
||||
方法一:
|
||||
|
||||
|
||||
|
||||
方法二:
|
||||
|
||||
|
||||
|
||||
我们看到,方法一中使用了growsslice方法,而方法二中是没有调用这个方法的。
|
||||
|
||||
这个growslice的作用就是扩充slice容量,每当我们的slice容量小于我们需要使用的slice大小,这个函数就会被触发。它的机制就好比是原先我们没有定制容量,系统给了我们一个能装两个鞋子的盒子,但是当我们装到第三个鞋子的时候,这个盒子就不够了,我们就要换一个盒子,而换这个盒子,我们势必还需要将原先的盒子里面的鞋子也拿出来放到新的盒子里面。
|
||||
|
||||
而growsslice的操作是一个比较复杂的操作,它的表现和复杂度会高于最基本的初始化make方法。对追求性能的程序来说,应该能避免就尽量避免。
|
||||
|
||||
如果你对growsslice函数的具体实现感兴趣,你可以参考源码src的 runtime/slice.go 。
|
||||
|
||||
当然,我们并不是每次都能在slice初始化的时候,就准确预估到最终的使用容量,所以我这里说的是“尽量补全cap”。明白是否设置slice容量的区别后,我们在能预估容量的时候,请尽量使用方法二那种预估容量后的slice初始化方式。
|
||||
|
||||
第三点:初始化一个类的时候,如果类的构造参数较多,尽量使用Option写法
|
||||
|
||||
当我们遇到一定要初始化一个类的时候,大部分时候,我们都会使用类似下列的New方法:
|
||||
|
||||
package newdemo
|
||||
|
||||
type Foo struct {
|
||||
name string
|
||||
id int
|
||||
age int
|
||||
|
||||
db interface{}
|
||||
}
|
||||
|
||||
func NewFoo(name string, id int, age int, db interface{}) *Foo {
|
||||
return &Foo{
|
||||
name: name,
|
||||
id: id,
|
||||
age: age,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,我们定义一个NewFoo方法,其中存放初始化Foo结构所需要的各种字段属性。
|
||||
|
||||
这个写法乍看之下是没啥问题的,但是一旦Foo结构内部的字段发生了变化,增加或者减少了,那么这个初始化函数NewFoo就怎么看怎么别扭了。
|
||||
|
||||
参数继续增加?那么所有调用了这个NewFoo方法的地方也都需要进行修改,且按照代码整洁的逻辑,参数多于5个,这个函数就很难使用了。而且,如果这5个参数都是可有可无的参数,就是有的参数可以不填写,有默认值,比如age这个字段,即使我们不填写,在后续的业务逻辑中可能也没有很多影响,那么我在实际调用NewFoo的时候,age这个字段还需要传递0值:
|
||||
|
||||
foo := NewFoo("jianfengye", 1, 0, nil)
|
||||
|
||||
|
||||
乍看这行代码,你可能会以为我创建了一个Foo,它的年龄为0,但是实际上我们是希望表达这里使用了一个“缺省值”,这种代码的语义逻辑就不对了。
|
||||
|
||||
这里其实有一种更好的写法:使用Option写法来进行改造。
|
||||
|
||||
Option写法,顾名思义,就是将所有可选的参数作为一个可选方式,一般我们会设计一个“函数类型”来代表这个Option,然后配套将所有可选字段设计为一个这个函数类型的具体实现。在具体的使用的时候,使用可变字段的方式来控制有多少个函数类型会被执行。比如上述的代码,我们会改造为:
|
||||
|
||||
type Foo struct {
|
||||
name string
|
||||
id int
|
||||
age int
|
||||
|
||||
db interface{}
|
||||
}
|
||||
|
||||
// FooOption 代表可选参数
|
||||
type FooOption func(foo *Foo)
|
||||
|
||||
// WithName 代表Name为可选参数
|
||||
func WithName(name string) FooOption {
|
||||
return func(foo *Foo) {
|
||||
foo.name = name
|
||||
}
|
||||
}
|
||||
|
||||
// WithAge 代表age为可选参数
|
||||
func WithAge(age int) FooOption {
|
||||
return func(foo *Foo) {
|
||||
foo.age = age
|
||||
}
|
||||
}
|
||||
|
||||
// WithDB 代表db为可选参数
|
||||
func WithDB(db interface{}) FooOption {
|
||||
return func(foo *Foo) {
|
||||
foo.db = db
|
||||
}
|
||||
}
|
||||
|
||||
// NewFoo 代表初始化
|
||||
func NewFoo(id int, options ...FooOption) *Foo {
|
||||
foo := &Foo{
|
||||
name: "default",
|
||||
id: id,
|
||||
age: 10,
|
||||
db: nil,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(foo)
|
||||
}
|
||||
return foo
|
||||
}
|
||||
|
||||
|
||||
现在我们来解释下上面的这段代码,我们创建了一个FooOption的函数类型,这个函数类型代表的函数结构是 func(foo *Foo) 。这个结构很简单,就是将foo指针传递进去,能让内部函数进行修改。
|
||||
|
||||
然后我们针对三个初始化字段name,age,db定义了三个返回了FooOption的函数,负责修改它们:
|
||||
|
||||
|
||||
WithName;
|
||||
WithAge;
|
||||
WithDB。
|
||||
|
||||
|
||||
以WithName为例,这个函数参数为string,返回值为FooOption。在返回值的FooOption中,根据参数修改了Foo指针。
|
||||
|
||||
// WithName 代表Name为可选参数
|
||||
func WithName(name string) FooOption {
|
||||
return func(foo *Foo) {
|
||||
foo.name = name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
顺便说一下,这种函数我们一般都以With开头,表示我这次初始化“带着”这个字段。
|
||||
|
||||
而最后NewFoo函数的参数,我们就改造为两个部分:一个部分是“非Option”字段,就是必填字段,假设我们的Foo结构实际上只有一个必填字段id,而其他字段皆是选填的;第二个部分就是其他所有选填字段,我们使用一个可变参数 options 替换:
|
||||
|
||||
NewFoo(id int, options ...FooOption)
|
||||
|
||||
|
||||
在具体的NewFoo实现中,也变化成2个步骤:
|
||||
|
||||
|
||||
按照默认值初始化一个foo对象;
|
||||
遍历options改造这个foo对象。
|
||||
|
||||
|
||||
按照这样改造之后,我们具体使用Foo结构的函数就变成了这个样子:
|
||||
|
||||
// 具体使用NewFoo的函数
|
||||
func Bar() {
|
||||
foo := NewFoo(1, WithAge(15), WithName("foo"))
|
||||
fmt.Println(foo)
|
||||
}
|
||||
|
||||
|
||||
可读性是不是高了很多?这里New了一个Foo结构,id为1,并且带着指定age为15,指定name为“foo”。
|
||||
|
||||
如果我们后续Foo多了一个可变属性,那么我们只需要多一个WithXXX的方法就可以了,而NewFoo函数不需要任何变化,调用方只要在指定这个可变属性的地方增加WithXXX就可以了,扩展性非常好。
|
||||
|
||||
这种Option的写法在很多著名的库中都有使用到,比如gorm, go-redis等。所以我们要把这种方式熟悉起来,一旦我们需要对一个比较复杂的类进行初始化的时候,这种方法应该是最优的方式了。
|
||||
|
||||
第四点:巧用大括号控制变量作用域
|
||||
|
||||
在写Go的过程中,你一定有过为 := 和 = 烦恼的时刻。一个变量,到写的时候,我还要记得前面是否已经定义过了,如果没有定义过,使用 := ,如果已经定义过,使用 =。
|
||||
|
||||
当然很多时候你可能并不会犯这种错误,如果变量命名得比较好的话,我们是很容易记得这个变量前面是否有定义过的。但是更多时候,对于err这种通用的变量名字,你可能就不一定记得了。
|
||||
|
||||
这个时候,巧妙使用大括号,就能很好避免这个问题。
|
||||
|
||||
我举一个我之前写过的一个命令行工具的例子。我们都知道写命令行工具,对传递的参数的解析需要有一些逻辑:“如果参数中有某个字段,那么就解析并存储到变量中,如果没有,就记录error”,这里我就使用了大括号,将每个参数的解析和处理错误的逻辑都封装起来。
|
||||
|
||||
代码大概是这样的:
|
||||
|
||||
var name string
|
||||
var folder string
|
||||
var mod string
|
||||
...
|
||||
{
|
||||
prompt := &survey.Input{
|
||||
Message: "请输入目录名称:",
|
||||
}
|
||||
err := survey.AskOne(prompt, &name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
{
|
||||
prompt := &survey.Input{
|
||||
Message: "请输入模块名称(go.mod中的module, 默认为文件夹名称):",
|
||||
}
|
||||
err := survey.AskOne(prompt, &mod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
...
|
||||
}
|
||||
{
|
||||
// 获取hade的版本
|
||||
client := github.NewClient(nil)
|
||||
prompt := &survey.Input{
|
||||
Message: "请输入版本名称(参考 https://github.com/gohade/hade/releases,默认为最新版本):",
|
||||
}
|
||||
err := survey.AskOne(prompt, &version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
我简单解释下这段代码。首先,整段代码的作用是解析出三个变量name、mod、version。最开始我们先定义这三个变量,然后使用三个大括号,分别将这三个变量的解析逻辑封装在里面。这样,每个大括号里面的err变量的作用域就完全局限在括号中了。所以,我们每次都可以直接使用 := 来创建一个新的 err并处理它,不用再额外思考这个err 变量是否前面已经创建过了。
|
||||
|
||||
你可以自己观察一下,大括号在代码语义上还有一个好处,就是归类和展示。
|
||||
|
||||
归类的意思就是,这个大括号里面的变量和逻辑是一个完整的部分,他们内部创建的变量不会泄漏到外部。这个等于告诉后续的阅读者,你在阅读的时候,如果对这个逻辑不感兴趣,可以不阅读里面的内容;如果你感兴趣,就可以进入里面进行阅读。
|
||||
|
||||
基本上所有IDE都支持对大括号封装的内容进行压缩。这里我使用的是Goland,压缩后,我的命令行的主体逻辑就更清晰了:
|
||||
|
||||
|
||||
|
||||
所以,使用大括号,结合IDE,你的代码的可读性能得到很大的提升。
|
||||
|
||||
总结
|
||||
|
||||
好了,这次的分享到这里就结束了。今天我给你总结了四个Go语言中常用的写法
|
||||
|
||||
|
||||
使用pkg/error而不是官方error库;
|
||||
在初始化slice的时候尽量补全cap;
|
||||
初始化一个类的时候,如果类的构造参数较多,尽量使用Option写法;
|
||||
巧用大括号控制变量作用域。
|
||||
|
||||
|
||||
这几种写法和注意事项都是我在工作和阅读开源项目中的一些总结和经验,每个经验都是对应为了解决不同的问题。
|
||||
|
||||
虽然说Go已经对代码做了不少的规范和优化,但是好的代码和不那么好的代码还是有一些差距的,这些写法优化点就是其中一部分。
|
||||
|
||||
我今天只列出的了四个点,当然了,还有很多类似的Go写法优化点等着你去发现。相信你在工作生活中也能遇到不少,只要你平时能多思考、多总结、多动手,也能积攒出属于自己的一本小小的优化手册。
|
||||
|
||||
|
||||
|
||||
|
||||
390
专栏/TonyBai·Go语言第一课/大咖助阵大明:Go泛型,泛了,但没有完全泛.md
Normal file
390
专栏/TonyBai·Go语言第一课/大咖助阵大明:Go泛型,泛了,但没有完全泛.md
Normal file
@@ -0,0 +1,390 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
大咖助阵 大明:Go泛型,泛了,但没有完全泛
|
||||
你好,我是大明,一个专注于中间件研发的开源爱好者。
|
||||
|
||||
我们都知道,Go 泛型已经日渐成熟,距离发布正式版本已经不远了。目前已经有很多的开发者开始探索泛型会对 Go 编程带来什么影响。比如说,目前我们比较肯定的是泛型能够解决这一类的痛点:
|
||||
|
||||
|
||||
数学计算:写出通用的方法来操作 int 、 float 类型;
|
||||
集合类型:例如用泛型来写堆、栈、队列等。虽然大部分类似的需求可以通过 slice 和 channel 来解决,但是始终有一些情况难以避免要设计特殊的集合类型,如有序 Set 和优先级队列;
|
||||
slice 和 map 的辅助方法:典型的如 map-reduce API;
|
||||
|
||||
|
||||
但是至今还是没有人讨论 Go 泛型的限制,以及这些限制会如何影响我们解决问题。
|
||||
|
||||
所以今天我将重点讨论这个问题,不过因为目前我主要是在设计和开发中间件,所以我会侧重于中间件来进行讨论,当然也会涉及业务开发的内容。你可以结合自己了解的 Go 泛型和现有的编程模式来学习这些限制,从而在将来 Go 泛型正式发布之后避开这些限制,写出优雅的 Go 泛型代码。
|
||||
|
||||
话不多说,我们现在开始讨论第一点:Go泛型存在哪些局限。
|
||||
|
||||
Go 泛型的局限
|
||||
|
||||
在早期泛型还处于提案阶段的时候,我就尝试过利用泛型来设计中间件。当然,即便到现在,我对泛型的应用依旧提留在尝试阶段。根据我一年多的不断尝试,以及我自己对中间件开发的理解,目前我认为影响最大的三个局限是:
|
||||
|
||||
|
||||
Go 接口和结构体不支持泛型方法;
|
||||
泛型约束不能作为类型声明;
|
||||
泛型约束只能是接口,而不能是结构体。
|
||||
|
||||
|
||||
接下来我们就来逐个分析。
|
||||
|
||||
Go 接口和结构体不支持泛型方法
|
||||
|
||||
这里需要我们注意的是,虽然 Go 接口或者结构体不允许声明泛型方法,但Go 接口或者结构体可以是泛型。
|
||||
|
||||
在原来没有泛型的时候,如果要设计一个可以处理任意类型的接口,我们只能使用 interface{} ,例如:
|
||||
|
||||
type Orm interface {
|
||||
Insert(data ...interface{}) (sql.Result, error)
|
||||
}
|
||||
|
||||
|
||||
在这种模式下,用户可以输入任何数据。但是如果用户混用不同类型,例如 Insert(&User{},&Order{}) ,就会导致插入失败,而编译器并不能帮助用户检测到这种错误。
|
||||
|
||||
从利用 ORM 读写数据库的场景来看,我们是希望限制住 data 参数只能是单一类型的多个实例。那么在引入了泛型之后,我们可以声明一个泛型接口来达成这种约束:
|
||||
|
||||
type Orm[T any] interface {
|
||||
Insert(data ...T) (sql.Result, error)
|
||||
}
|
||||
|
||||
|
||||
在这个接口里面,我们声明了一个泛型接口 Orm ,它含有一个类型参数 T ,通过 T 我们确保在 Insert 里面,用户传入的都是同一个类型的不同实例。
|
||||
|
||||
然而这种设计也是有问题的:我们需要创建很多个Orm实例。例如插入 User 的实例和插入 Order 的实例:
|
||||
|
||||
var userOrm Orm[User]
|
||||
var orderOrm Orm[Order]
|
||||
|
||||
|
||||
也就是说,我们的应用有多少个模型,就要声明多少个 Orm 的实例。
|
||||
|
||||
这显然是不可接受的。因为我们认为 Orm 应该是一个可以操作任意模型的接口,也就是一个 Orm 实例既可以用于操作 User ,也可以用于操作 Order 。换言之,我们并不能把类型参数声明在 Orm 这样一个接口上。
|
||||
|
||||
那么我们应该声明在哪里呢? 显然,我们应该声明在方法上:
|
||||
|
||||
type Orm interface {
|
||||
Insert[T any](data ...T) (sql.Result, error)
|
||||
}
|
||||
|
||||
|
||||
乍一看,这种声明方式完全能够达到我们的目标,用户用起来只需要:
|
||||
|
||||
orm.Insert[*User](&User{}, &User{})
|
||||
orm.Insert[*Order](&Order{}, &Order{})
|
||||
|
||||
|
||||
然而,如果我们尝试编译,就会得到错误:
|
||||
|
||||
interface method cannot have type parameters
|
||||
|
||||
|
||||
也不仅仅是接口会这样,即便我们直接做成结构体:
|
||||
|
||||
type orm struct {
|
||||
}
|
||||
func (o orm) Insert[T any](data ...T) (sql.Result, error) {
|
||||
//...
|
||||
}
|
||||
|
||||
|
||||
我们依旧会得到一个类似的错误:
|
||||
|
||||
invalid AST: method must have no type parameters
|
||||
|
||||
|
||||
实际上,操作任意类型的接口很常见,特别是对于提供客户端功能的中间件来说,尤其常见。例如,如果我们设计一个 HTTP 客户端的中间件,我们可能希望是:
|
||||
|
||||
type HttpClient interface {
|
||||
Get[T any](url string) (T, error)
|
||||
}
|
||||
|
||||
|
||||
这样,用户可以用 client.Get[User]("/user/123") 得到一个 User 的实例。-
|
||||
又比如,如果我们要设计一个缓存客户端:
|
||||
|
||||
type CacheClient interface {
|
||||
Get[T any](key string) (T, error)
|
||||
}
|
||||
|
||||
|
||||
但是,显然它们都无法通过编译。
|
||||
|
||||
因此,我们可以说,这个限制对所有的客户端类应用都很不友好。这些客户端包括 Redis、Kafka 等各种中间件,也包括这提到的 HTTP、ORM 等框架。
|
||||
|
||||
泛型约束不能作为类型声明
|
||||
|
||||
在泛型里面,有一个很重要的概念“约束”。我们使用约束来声明类型参数要满足的条件。一个典型的 Go 约束,可以是一个普通的接口,也可以是多个类型的组合,例如:
|
||||
|
||||
type Integer interface {
|
||||
int | int64 | int32 | int16 | int8
|
||||
}
|
||||
|
||||
|
||||
看到这种语法,我们自然会想到在中间件开发中,经常会有这么一种情况:我们某个方法接收多种类型的的参数,但是它们又没有实现共同的接口。-
|
||||
例如,我们现在要设计一个 SQL 的 Builder ,用于构造 SQL 。那么在设计 SELECT XXX 这个部分的时候,最基本,也是最基本的做法就是直接传 string :
|
||||
|
||||
type Selector struct {
|
||||
columns []string
|
||||
}
|
||||
func (s *Selector) Select(cols ...string) *Selector {
|
||||
s.columns = cols
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
|
||||
但是这种设计存在一个问题,就是用户如果要使用聚合函数的话,需要自己手动拼接,例如 Select("AVG(age)") 。而实际上,我们希望能够帮助用户完成这个过程,将聚合函数也变成一个方法调用:
|
||||
|
||||
type Aggregate struct {
|
||||
fun string
|
||||
col string
|
||||
alias string
|
||||
}
|
||||
func Avg(col string) Aggregate {
|
||||
return Aggregate{
|
||||
fun: "AVG",
|
||||
col: col,
|
||||
}
|
||||
}
|
||||
func (a Aggregate) As(alias string) Aggregate {
|
||||
return Aggregate{
|
||||
fun: a.fun,
|
||||
col: a.col,
|
||||
alias: alias,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
使用起来如: Select(Avg("age").As("avg_age"), "name") ,这样我们就能帮助检测传入的列名是否正确,而且用户可以避开字符串拼接之类的问题。
|
||||
|
||||
在这种情况下, Select 方法必须要接收两种输入: string 和 Aggregate 。在没有泛型的情况下,大多数时候我们都是直接使用 interface 来作为参数,并且结合 switch-case 来判断:
|
||||
|
||||
type Selector struct {
|
||||
columns []Aggregate
|
||||
}
|
||||
func (s *Selector) Select(cols ...interface{}) *Selector {
|
||||
for _, col := range cols {
|
||||
switch c := col.(type) {
|
||||
case string:
|
||||
s.columns = append(s.columns, Aggregate{col: c})
|
||||
case Aggregate:
|
||||
s.columns = append(s.columns, c)
|
||||
default:
|
||||
panic("invalid type")
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
但是这种用法存在一个问题,就是无法在编译期检查用户输入的类型是否正确,只有在运行期 default 分支发生 panic 时我们才能知道。
|
||||
|
||||
特别是,如果用户本意是传一个 var cols []string ,结果写成了 Select(cols) 而不是 Select(cols...) ,那么就会出现 panic ,而编译器丝毫不能帮我们避免这种低级错误。
|
||||
|
||||
那么,结合我们的泛型约束,似乎我们可以考虑写成这样:
|
||||
|
||||
type Selectable interface {
|
||||
string | Aggregate
|
||||
}
|
||||
type Selector struct {
|
||||
columns []Selectable
|
||||
}
|
||||
func (s *Selector) Select(cols ...Selectable) *Selector {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
|
||||
利用 Selectable 约束,我们限制住了类型只能是 string 或者 Aggregate 。那么用户可以直接使用 string ,例如 Select("name", Avg("age") 。
|
||||
|
||||
看起来非常完美,用户享受到了编译期检查,又不会出现 panic 的问题。
|
||||
|
||||
然而,这依旧是不行的,编译的时候会直接报错:
|
||||
|
||||
interface contains type constraints
|
||||
|
||||
|
||||
也就是说,泛型约束不能被用于做参数,它只能和泛型结合在一起使用,这就导致我们并不能用泛型的约束,来解决某个接口可以处理有限多种类型输入的问题。所以长期来看, interface{} 这种参数类型还会广泛存在于所有中间件的设计中。
|
||||
|
||||
泛型约束只能是接口,而不能是结构体
|
||||
|
||||
众所周知,Go 里面有一个特性是组合。因此在泛型引入的时候,我们可能会考虑能否用泛型的约束来限制具体类型必须组合了某个类型。
|
||||
|
||||
例如:
|
||||
|
||||
type BaseEntity struct {
|
||||
Id int64
|
||||
}
|
||||
func Insert[Entity BaseEntity](e *Entity) {
|
||||
|
||||
}
|
||||
type myEntity struct {
|
||||
BaseEntity
|
||||
Name string
|
||||
}
|
||||
|
||||
|
||||
在实际中这也是一个很常见的场景,即我们在 ORM 操作的时候希望实体类必须组合 BaseEntity ,这个 BaseEntity 上会定义一些公共字段和公共方法。
|
||||
|
||||
不过,同样的,Go 泛型不支持这种用法。Go 泛型约束必须是一个接口,而不能是一个结构体,因此上面这段代码会报错:
|
||||
|
||||
myEntity does not implement BaseEntity
|
||||
|
||||
|
||||
不过,目前泛型还没有完全支持好,所以这个报错信息并不够准确,更加准确的信息应该是指出 BaseEntity 只能为接口。
|
||||
|
||||
这个限制影响也很大,它直接堵死了我们设计共享字段的泛型方法的道路。而且,它对于业务开发的影响要比对中间件开发的影响更大,因为业务开发会经常遇到必须要共享某些数据的场景,例如这里的 ORM 例子,还有前端接收参数的场景等。
|
||||
|
||||
绕开限制的思路
|
||||
|
||||
从前面的分析来看,这些限制影响广泛,而且限制了泛型的进一步应用。但是,我们可以尝试使用一些别的手段来绕开这些限制。
|
||||
|
||||
Builder 模式
|
||||
|
||||
前面提到,因为 Go 泛型限制了接口或者结构体,让它们不能有泛型方法,所以对客户端类的中间件 API 设计很不友好。
|
||||
|
||||
但是,我们可以尝试用 Builder 模式来解决这个问题。现在我们回到前面说的 HTTP 客户端的例子试试看,这个例子我们可以设计成:
|
||||
|
||||
type HttpClient struct {
|
||||
endpoint string
|
||||
}
|
||||
type GetBuilder[T any] struct {
|
||||
client *HttpClient
|
||||
path string
|
||||
}
|
||||
func (g *GetBuilder[T]) Path(path string) *GetBuilder[T] {
|
||||
g.path = path
|
||||
return g
|
||||
}
|
||||
func (g *GetBuilder[T]) Do() T {
|
||||
// 真实发出 HTTP 请求
|
||||
url := g.client.endpoint + g.path
|
||||
}
|
||||
func NewGetRequest[T any](client *HttpClient) *GetBuilder {
|
||||
return &GetBuilder[T]{client: client}
|
||||
}
|
||||
|
||||
|
||||
而最开始想利用泛型的时候,我们是希望将泛型定义在方法级别上:
|
||||
|
||||
type HttpClient interface {
|
||||
Get[T any](url string) (T, error)
|
||||
}
|
||||
|
||||
|
||||
两者最大的不同就在于NewGetRequest 是一种过程式的设计。在 Builder 设计之下, HttpClient 被视作各种配置的载体,而不是一个真实发出请求的客户端。如果你有 Java 之类面向对象的编程语言的使用背景,那么你会很不习惯这种写法。
|
||||
|
||||
当然我们可以将 HttpClient 做成真的客户端,而把 GetBuilder 看成是一层泛型的皮:
|
||||
|
||||
func (g *GetBuilder[T]) Do() T {
|
||||
var t T
|
||||
g.client.get(g.path, &t)
|
||||
return t
|
||||
}
|
||||
|
||||
|
||||
无论哪一种,本质上都是利用了过程式的写法,核心都是 Client + Builder。那么在将来设计各种客户端中间件的时候,你就可以考虑尝试这种解决思路。
|
||||
|
||||
标记接口
|
||||
|
||||
标记接口这种方案,可以用来解决泛型约束不能用作类型声明的限制。顾名思义,标记接口(tag interface或者 marker interface)就是打一个标记,本身并不具备意义。这种思路在别的语言里面也很常见,比如说 Java shardingsphere 里面就有一个 OptionalSPI 的标记接口:
|
||||
|
||||
public interface OptionalSPI {
|
||||
}
|
||||
|
||||
|
||||
它什么方法都没有,只是说实现了这个接口的 SPI 都是“可选的”。
|
||||
|
||||
Go 里面就不能用空接口作为标记接口,否则所有结构体都可以被认为实现了标签接口,Go中必须要至少声明一个私有方法。例如我们前面讨论的 SELECT 既可以是列,也可以是聚合函数的问题,我们就可以尝试声明一个接口:
|
||||
|
||||
type Selectable interface {
|
||||
aggr()
|
||||
}
|
||||
type Column string
|
||||
func (c Column) aggr() {}
|
||||
type Aggregate struct {}
|
||||
func (c Aggregate) aggr() {}
|
||||
|
||||
func (s *Selector) Select(cols ...Selectable) *Selector {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
|
||||
这里, Selectable 就是一个标记接口。它的方法 aggr 没有任何的含义,它就是用于限定 Select 方法只能接收特定类型的输入。
|
||||
|
||||
但是用起来其实也不是很方便,比如你看这句代码: Select(Column("name"), Avg("avg_id")) 。即便你完全不用聚合函数,你也得用 Column 来传入列,而不能直接传递字符串。但是相比之前接收 interface{} 作为输入,就要好很多了,至少受到了编译器对类型的检查。
|
||||
|
||||
Getter/Setter 接口
|
||||
|
||||
Getter/Setter 可以用于解决泛型约束只能是接口而不能是结构体的限制,但是它并不那么优雅。
|
||||
|
||||
核心思路在于,我们给所有的字段都加上 Get/Set 方法,并且提取出来作为一个接口。例如:
|
||||
|
||||
type Entity interface {
|
||||
Id() int64
|
||||
SetId(id int64)
|
||||
}
|
||||
type BaseEntity struct {
|
||||
id int64
|
||||
}
|
||||
func (b *BaseEntity) Id() int64 {
|
||||
return b.id
|
||||
}
|
||||
func (b *BaseEntity) SetId(id int64) {
|
||||
b.id = id
|
||||
}
|
||||
|
||||
|
||||
那么我们所有的 ORM 操作都可以限制到该类型上:
|
||||
|
||||
type myEntity struct {
|
||||
BaseEntity
|
||||
}
|
||||
func Insert[E Entity](e *E) {
|
||||
}
|
||||
|
||||
|
||||
看着这段代码,Java 背景的同学应该会觉得很眼熟,但是在 Go 里面更加习惯于直接访问字段,而不是使用 Getter/Setter。
|
||||
|
||||
但是如果只是少量使用,并且结合组合特性,那么效果也还算不错。例如在我们的例子里面,通过组合 BaseEntity ,我们解决了所有的结构体都要实现一遍 Entity 接口的问题。
|
||||
|
||||
总结
|
||||
|
||||
我们这里讨论了三个泛型的限制:
|
||||
|
||||
|
||||
Go 接口和结构体不支持泛型方法;
|
||||
泛型约束不能作为类型声明;
|
||||
泛型约束只能是接口,而不能是结构体。
|
||||
|
||||
|
||||
并且讨论了三个对应的、可行的解决思路:
|
||||
|
||||
|
||||
Builder 模式;
|
||||
标记接口;
|
||||
Getter/Setter 接口。
|
||||
|
||||
|
||||
从我个人看来,这些解决思路只能算是效果不错,但是不够优雅。所以我还是很期盼 Go 将来能够放开这种限制。毕竟在这三个约束之下,Go 泛型使用场景过于受限。
|
||||
|
||||
从我个人的工作经历出发,我觉得对于大多数中间件来说,可以使用泛型来提供对用户友好的 API,但是对于内部实现来说,使用泛型的收益非常有限。如果现在已有的代码风格就是过程式的,那么你就可以尝试用泛型将它完全重构一番。
|
||||
|
||||
总的来说,我对 Go 泛型的普及持有一种比较悲观的态度。我预计泛型从出来到大规模应用还有一段非常遥远的距离,甚至有些公司可能在长时间内为了保持代码风格统一,而禁用泛型特性。
|
||||
|
||||
思考题
|
||||
|
||||
|
||||
如果你是中间件开发,你觉得 Go 泛型出来之后,你会用泛型来改造你维护的项目吗?
|
||||
如果你用了一些开源软件,你会希望它们的维护者暴露泛型 API 给你吗?
|
||||
从你的个人经验出发,你会希望 Go 泛型放开这些限制吗?
|
||||
|
||||
|
||||
欢迎在留言区分享你的看法,我们一起讨论。
|
||||
|
||||
|
||||
|
||||
|
||||
239
专栏/TonyBai·Go语言第一课/大咖助阵孔令飞:从小白到“老鸟”,我的Go语言进阶之路.md
Normal file
239
专栏/TonyBai·Go语言第一课/大咖助阵孔令飞:从小白到“老鸟”,我的Go语言进阶之路.md
Normal file
@@ -0,0 +1,239 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
大咖助阵 孔令飞:从小白到“老鸟”,我的Go语言进阶之路
|
||||
你好,我是孔令飞,是极客时间 《Go 语言项目开发实战》 专栏和《从零构建企业级Go项目》图书的作者,目前在腾讯云从事分布式云方向的研发工作。
|
||||
|
||||
很高兴,也很感谢能够借助Tony Bai老师的专栏《Tony Bai ·Go 语言第一课 》给你分享我的Go语言进阶之路。今天这一讲没有Go语法知识的学习,没有高大上的理论,更多的是我个人在Go语言进阶过程中的一些经验、心得的分享。希望通过这些分享,能帮助到渴望在Go研发之路上走的更远的你。
|
||||
|
||||
为了方便说明如何提升Go研发能力,我需要先给你介绍下我认为的Go语言能力级别的划分依据。
|
||||
|
||||
Go语言能力级别划分
|
||||
|
||||
下面这些能力级别是根据我自己的理解划分的。这些Go语言能力级别没有标准的定义,各级别之间也没有明确的界限,级别之间也可能有重叠的部分。但这不妨碍我们参考这些级别大概判断自己当前所处的阶段,思考如何提升自己的Go研发能力。
|
||||
|
||||
我将Go语言能力由低到高划分为以下5个级别。
|
||||
|
||||
|
||||
初级:已经学习完Go基础语法课程,能够编写一些简单Go代码段,或者借助于Google/Baidu能够编写相对复杂的Go代码段;这个阶段的你基本具备阅读Go项目代码的能力;
|
||||
中级:能够独立编写完整的Go程序,例如功能简单的Go工具等等,或者借助于Google/Baidu能够开发一个完整、简单的Go项目。此外,对于项目中涉及到的其他组件,我们也要知道怎么使用Go语言进行交互。在这个阶段,开发者也能够二次开发一个相对复杂的Go项目;
|
||||
高级:不仅能够熟练掌握Go基础语法,还能使用Go语言高级特性,例如channel、interface、并发编程等,也能使用面向对象的编程思想去开发一个相对复杂的Go项目;
|
||||
资深:熟练掌握Go语言编程技能与编程哲学,能够独立编写符合Go编程哲学的复杂项目。同时,你需要对Go语言生态也有比较好的掌握,具备较好的软件架构能力;
|
||||
专家:精通Go语言及其生态,能够独立开发大型、高质量的Go项目,编程过程中较少依赖Google/百度等搜索工具,且对Go语言编程有自己的理解和方法论。除此之外,还要具有优秀的软件架构能力,能够设计、并部署一套高可用、可伸缩的Go应用。这个级别的开发者应该是团队的技术领军人物,能够把控技术方向、攻克技术难点,解决各种疑难杂症。
|
||||
|
||||
|
||||
你可以从我的划分中看到,初级、中级、高级Go语言工程师的关注点主要还是使用Go语言开发一个实现某种业务场景的应用,但是资深和专家级别的Go语言工程师,除了要具有优秀的Go语言编程能力之外,还需要具备一些架构能力,他们的工作也不仅仅局限在开发一个Go应用,还需要兼具技术架构师的职责。
|
||||
|
||||
当你学习完《Tony Bai ·Go 语言第一课》时,你应该处在初级阶段,并具备向中级Go研发工程师晋级的能力,这个阶段也是所有Go语言程序员必经的一个阶段。接下来,你要做的是通过后期的打怪升级,晋级到资深,甚至专家级别。
|
||||
|
||||
那么如何晋级到资深/专家级别呢?其实没有标准答案,每个人都有自己的方法。但你可以借鉴别人的经验,来加快你的晋级速度。这也是今天这一讲想要达成的目标:我希望通过分享我自己的晋级历程,帮助你快速晋级到资深/专家 Go语言工程师。
|
||||
|
||||
我的Go语言成长之路
|
||||
|
||||
这里我先简单介绍下我的Go语言进阶时间线。为了便于你理解,有些时间点,我做了四舍五入取整处理。整个时间线如下图所示:
|
||||
|
||||
|
||||
|
||||
|
||||
我是在2016年1月加入到腾讯云的,当时的工作是虚拟化测试,对Go语言完全没有了解;
|
||||
在2016年8月,我加入了腾讯云容器服务TKE团队,开始测试腾讯云容器服务TKE产品。因为TKE底层是用Go语言构建的,这之后我开始学习并使用Go语言;
|
||||
2016年11月,经过3个月的学习,我开发出了第一个小工具:母机初始化工具。母机初始化工具,其实就是在物理机上执行一系列的Shell命令,完成初始化工作。这个工具主要用到了flag、log、os/exec、path/filepath、strings等Go包,再加上使用Go语言控制语句实现的初始化逻辑;
|
||||
在2017年的8月份,转岗到腾讯IEG团队,从事容器云平台的开发。也就是说,我用了一年时间完成了从测试到研发的角色转变;
|
||||
在2018年5月份,撰写了掘金小册:《基于 Go 语言构建企业级的 RESTful API 服务》;
|
||||
在2021年5月份,撰写了极客时间专栏:《Go 语言项目开发实战》。
|
||||
|
||||
|
||||
我当前还处在专家阶段。当然,专家也分等级,虽然这个阶段的等级不详,但学无止境,在Go语言这条道上,我永远是个学生,跟你一样,需要不断地学习、深造。
|
||||
|
||||
接下来,我会分享每个阶段我的经验、心得,希望这些分享能帮助你提高Go语言编程能力。
|
||||
|
||||
初级工程师阶段(2016-08-01 ~ 2016-11-01)
|
||||
|
||||
我是在2016年8月转入腾讯云容器服务TKE团队,测试TKE产品的,这也是我第一次接触到Go语言。因为TKE底层是用Go语言构建的,为了能够了解每次提测的代码变更内容,我需要能够读懂Go代码,这就需要我学习Go语言。
|
||||
|
||||
那么如何学习Go语言呢?在刚开始学习的阶段,最简单高效的方式就是看书。我当时买了两本书《Go 程序设计语言》和《Go 语言编程》(可惜当时没有Tony Bai老师的这个专栏):
|
||||
|
||||
|
||||
|
||||
书本拿到手后,我先学习了《Go 程序设计语言》,过了一周后,我又通读了《Go 语言编程》。我一般学习一门编程语言,都会快速阅读两本经典的、讲基础语法的书。
|
||||
|
||||
这有两点原因。首先,学习一门课程,不能指望看一次就掌握。所以我会先认真阅读一遍书,不要求自己能记住/理解多少内容,但希望能够理解/掌握一些核心知识点,更重要的是能够了解书中有哪些知识点,这些知识点可以用在哪些地方。
|
||||
|
||||
另外,“工欲善其事,必先利其器”。学习一门语言最好的方式是多编码实操,但是在开始大规模实操之前,我们需要有一个踏实的基础。通读两本书,一方面我可以通过这两本书比较全面地掌握Go语言的基础语法,另一方面我也能让自己对核心知识点的记忆更加深刻。
|
||||
|
||||
在读完两本书,并且编写适量的简单Go代码段之后,我就具备了阅读其他项目代码的能力,这个时候就可以尝试向中级工程师晋升了。
|
||||
|
||||
接下来,我主要是通过编码实战加深对Go语法知识的理解和掌握。那么具体应该如何实战呢?在我看来应该以需求为驱动,找到一个合理的需求,然后实现它。需求来源于工作。这些需求可以是产品经理交给你的某一个具体的产品需求,也可以是能够帮助团队/自己提高工作效率的工具。总之,如果有明确的工作需求最好,如果没有明确的需求,我们就要创造需求。
|
||||
|
||||
在这个阶段,我会思考工作中的痛点、难点,并将它们转化成需求。比如,团队发布版本,每次都是人工发布,需要登陆到不同的服务器,部署不同的组件和配置。这样效率低不说,还容易因为人为失误造成现网故障。这时候,我们就可以将这些痛点抽象成一个需求:开发一个版本发布系统。
|
||||
|
||||
有了需求,接下来就要实现它,也就是进入到实战环节。那么如何实战呢?在我看来精髓在于两个字:“抄”和“改”。
|
||||
|
||||
现在,我们就基于前面“开发一个版本发布系统”的需求,分析看我们可以怎么通过“抄”和“改”来实现它。
|
||||
|
||||
如果自己从 0 开发出一套版本发布系统,工作量无疑是巨大的。而且,以我这个阶段的水平,即使花费了很多时间开发出一个版本发布系统,这个系统在功能和代码质量上也无法跟一些优秀的开源版本发布系统相比。
|
||||
|
||||
所以,这时候最好的方法就是在 GitHub 上找到一个优秀的版本发布系统,并基于这个系统进行二次开发。通过这种方式,我不仅学习到了一个优秀开源项目的设计和实现,还以最快的速度完成了版本发布系统的开发。
|
||||
|
||||
那么如何查找优秀的开源项目呢?我也有自己的一套方法,因为内容有点多,感兴趣的话,你可以参考我的专栏 结束语 | 如何让自己的 Go 研发之路走得更远?中的“ 问题二:如何查找优秀的开源项目?”部分。
|
||||
|
||||
当我完成了发布系统的二次开发之后,我还会在团队中进行分享,让自己的实战成果变成工作产出。至此,我也就完成了从初级工程师向中级工程师的晋升,进入中级工程师的阶段。
|
||||
|
||||
中级/高级工程师阶段(2016-11-01 ~ 2018-10-01)
|
||||
|
||||
中级/高级工程师阶段,其实就是不断地利用所学的Go基础知识,去编程实践。这个阶段提升Go研发能力的思路也跟前面是一样的:工作中发现需求 -> 调研优秀的开源项目 -> 二次开发 -> 团队内分享。通过这样一种循环过程,我们可以使自己的研发能力在循环中不断地提升:
|
||||
|
||||
|
||||
|
||||
在这样一种循环过程中,我通过不断地发现问题并解决问题,最终使自己能够熟练地掌握Go基础语法。不过,在这个阶段,我还做了一件事,就是刻意地减少对Google/Baidu的依赖,尝试自己编码解决问题、实现需求。在需要的时候,我也会使用Go的高级语法channel、interface等,结合面向对象编程的思想去开发项目或者改造开源项目。
|
||||
|
||||
在中级工程师阶段,我的工作还是测试,并没有来自于工作的Go研发需求,所以这个阶段为了学习Go语言,我虚构了很多“工作需求”,在完成这些虚构的“工作需求”的过程中,我的研发能力也有了非常大的提升,下面是我具体虚构的“工作需求”:
|
||||
|
||||
|
||||
开发了母机初始化系统:
|
||||
|
||||
|
||||
需求来源:因为我的日常工作需要初始化物理机,使其能够添加到腾讯云的资源池中。我之前的初始化工具,不具有重试、暂停、查看初始化详情、分布式控制等功能,使用起来很不方便,所以为了能够提高初始化效率和体验,我准备重新开发一个初始化系统;
|
||||
调研开源项目:因为都是内部系统,我就直接基于所测试的容器服务底层组件来开发,这样一方面能够让我熟悉所测的项目源码,另一方面因为我之前已经读过它的源码了,二次开发起来难度也比较低;
|
||||
效果:母机初始化系统开发完成之后,后续所有的母机初始化都是通过这个系统,大大提高了初始化效率和体验。最后,我将整个系统沉淀成文档,在团队内分享,推动其他同事使用这个系统,得到了领导的高度认可。通过这种方式,一方面工作上有了产出,
|
||||
另一方面通过这个“虚构的项目”我也学到了很多实战技能。
|
||||
|
||||
开发了HTTP文件服务器:
|
||||
|
||||
|
||||
需求来源:因为经常需要将同一个二进制文件部署到不同的机器上,为了便于分发文件,我开发了一个HTTP服务器;
|
||||
调研项目:使用了开源的gohttpserver;
|
||||
效果:开发完成后,在团队中推广,有不少同事使用,显著提高了文件的分发效率。
|
||||
|
||||
命令行模板:
|
||||
|
||||
|
||||
需求来源:因为经常需要编写一些命令行工具,所以我每次都要重复开发一些命令行工具的基础功能,例如命令行参数解析、子命令等。为了避免重复开发这些基础功能,提高工具开发效率和易用度,我开发了一个命令行框架,cmdctl;
|
||||
调研项目:参考了Kubernetes的kubectl命令行工具的实现;
|
||||
效果:在工作中很多需要自动化的工作,都以命令行工具的形式,添加在了cmdctl命令框架中,大大提高了我的开发效率。
|
||||
|
||||
|
||||
|
||||
此外,我还研究学习了很多比较有趣的开源项目,你也可以参考一下,比如:
|
||||
|
||||
|
||||
elvish:Go语言编写的Linux Shell;
|
||||
machinery:Go语言编写的分布式异步作业系统;
|
||||
gopub:Go语言编写的的版本发布系统;
|
||||
crawlab:Go语言编写的分布式爬虫管理平台;
|
||||
还有不少其他好玩、有用的工具/项目。
|
||||
|
||||
|
||||
在2016年8月 ~ 2017年8月这一年间,我通读了两本经典的Go语言教材,并且调研、学习了大量的优秀项目,完成了多个虚构的“工作需求”,Go研发技能有了非常大的提升,并具备了开发项目的能力。所以在2017年8月,我顺利转岗到了腾讯游戏部门,从事容器云平台的开发工作。
|
||||
|
||||
在开发容器云平台的2个月中(2017年8月 ~ 2017年10月),我的Go研发能力通过工作中Go语言相关项目的打磨、提升后,进入到了高级Go语言工程师的阶段。通过工作中的研发实战,也使我对自己的Go研发能力变得更加自信。
|
||||
|
||||
在学习其他开源项目的过程中,我积累了一些经验,也发现有些项目的构建思路比较清晰,代码质量比较高,有些项目代码质量、项目结构都很一般。并且,我还发现实际开发中,开发最多的项目就是RESTful API服务。于是,本着学习、总结、实战训练的目的,我在2018年5月撰写了掘金小册:《基于 Go 语言构建企业级的 RESTful API 服务》,这个小册会带着读者一步步构建 API 开发中的各个功能点,最终完成一个企业级的 API 服务器。
|
||||
|
||||
另外,在游戏部门工作期间,因为工作需要,我使用Go语言开发了微服务框架、API网关、服务中心、CI/CD等系统,这些工作内容帮助我学习了更多架构层面的知识,具有一定的架构能力后,我进入了资深工程师的阶段。
|
||||
|
||||
这里,我还想补充一点,我觉得初级、中级阶段可以通过自学来完成,但是想要进入高级、资深、专家级别更多的,或者必须通过工作中的Go开发实战才能进入。所以,如果你想在Go研发之路上走的更深、更远,可以考虑在合适的时候切换到Go研发岗位上。
|
||||
|
||||
资深工程师阶段(2018-10-01 ~ 2020-08-01)
|
||||
|
||||
在资深工程师阶段,一方面我从Go语言项目开发层面继续打磨自己,另一方面,我还努力学习架构方面的知识。那么接下来,我分别分享下,这两个层面上我具体是如何做的。
|
||||
|
||||
Go项目研发层面
|
||||
|
||||
在过往的Go语言学习过程中,我遇到了很多问题,主要分为以下4类问题:
|
||||
|
||||
|
||||
知识盲区:Go 项目开发会涉及很多知识点,但自己对这些知识点却一无所知。想要学习,却发现网上很多文章结构混乱、讲解不透彻,搜索一遍优秀的文章,也要花费很多时间,劳神劳力;
|
||||
学不到最佳实践,能力提升有限:网上的很多文章都会介绍 Go 项目的构建方法,但很多都不是最佳实践,学完之后不能在能力和认知上带来最佳提升,还要自己花时间整理学习,事倍功半;
|
||||
不知道如何完整地开发一个 Go 项目:学了很多 Go 开发相关的知识点、构建方法,但都不体系、不全面、不深入。学完之后,自己并不能把它们有机结合成一个 Go 项目研发体系,真正开发的时候还是一团乱,效率也很低;
|
||||
缺乏一线项目练手,很难检验学习效果:为了避免闭门造车,我们肯定想学习一线大厂的大型项目构建和研发经验,来检验自己的学习成果,但自己平时又很难接触到,没有这样的学习途径。
|
||||
|
||||
|
||||
为了解决这些问题,并以此为驱动力和目标,我调研了2000+开源项目、5000+国内外的技术文章,并根据这些调研,以最佳实践的方式开发了Go语言脚手架项目:iam。 在调研、学习的过程中,我的Go研发能力,得到了大幅的提升。
|
||||
|
||||
在2021年2月,我尝试将过去学习过程中的一些心得、经验等知识沉淀成极客时间专栏 《Go 语言项目开发实战》,希望以专栏的形式,分享给更多的Go研发工程师。经过近4个月的打磨,专栏于2021年5月上线。
|
||||
|
||||
如果你刚学完Tony Bai老师的《Tony Bai · Go 语言第一课》,接下来我建议你花点时间认真学习下 《Go 语言项目开发实战》,这里面的内容使我突破到资深工程师。如果你学习完之后,能够熟练的、独立开发类似的项目,并具有自己的理解,那么你的能力可能已经是或者接近资深Go语言工程师了。
|
||||
|
||||
架构层面
|
||||
|
||||
因为我本身就是做容器服务开发的,并且在IEG工作期间又从0到1自己构建了微服务、API网关、CI/CD、服务中心等系统,所以在工作中,我的架构能力也得到了比较好的提升。
|
||||
|
||||
工作之外,我还通读了整个容器平台的核心代码,走读了容器平台所有组件的部署流程,了解了如何构建容器平台的监控告警(Prometheus)、日志(EFK)、容灾、DevOps(CODING)等核心能力的构建方式和部署方式。
|
||||
|
||||
同时,我在微信上又关注了一些云原生、架构相关的公众号,会利用闲碎时间阅读一些优质的公众号文章,再通过这些优质的文章,进一步丰富自己架构方面的知识。
|
||||
|
||||
关于如何提升架构能力,这里我有以下几点建议。
|
||||
|
||||
首先是学架构,先从当前业务开始。怎么开始呢?我们可以先了解当前业务的整体部署方式,最好是手动搭建一个小型的业务测试环境。在搭建过程中,你可以对业务的部署方式,有非常深刻的理解和掌握。
|
||||
|
||||
同时,我们可以走读所有的业务代码。可以先从核心代码开始读起,如果有时间,也可以通读当前业务所有组件的代码,最终你就能够知道整个系统是如何集成的,以及业务中每个功能是如何构建的。
|
||||
|
||||
接下来,学完当前业务架构,再学云原生架构。因为当前的软件架构都在朝着云原生架构的方向演进,云原生架构的基石是Kubernetes。所以我建议你手动搭建一套原生的Kubernetes平台,然后在这个平台上部署一个小型的微服务系统,并构建这个微服务系统的日志、监控告警、调用链、服务发现等核心能力。
|
||||
|
||||
等集群、微服务系统部署好之后,你可以研究这个系统每一部分的部署和实现方式。任何的学习都离不开实战,这个Kubernetes平台以及其中的每一部分,都可以作为一个非常好的实战平台。
|
||||
|
||||
这里我推荐一个部署Kubernetes平台的教程:和我一步步部署 kubernetes 集群。这是一个傻瓜式的教程,跟着教程一步步操作,你就可以轻松地部署一套完整的Kubernetes集群。
|
||||
|
||||
此外,如果工作中有涉及到架构层面的工作内容,建议踊跃参与讨论、开发和实施。以工作为驱动,是学习架构最高效的,也是效果最好的方式,这种机会对于渴望提升自己架构能力的你,一定不要错过。
|
||||
|
||||
最后,你还可以关注一些优质的架构相关的公众号,利用闲碎时间,阅读优质的文章,补充自己架构方面的知识。
|
||||
|
||||
至于如何具体地提升自己的架构能力,因为内容比较多,这里我就不详细介绍了。如果你感兴趣,可以参考我的专栏:如何让自己的 Go 研发之路走得更远? “架构师阶段”部分的内容。
|
||||
|
||||
专家工程师阶段(2020-08-01 ~ 至今)
|
||||
|
||||
这个阶段,我也是个学生,没有太多经验可分享。但我觉得还是可以使用之前的方式,不断从两个层面夯实自己的能力,也就是Go项目研发层面和架构层面。这个阶段,不仅要求你在这两个层面要走得更深、更远,还要求你能够兼具一个Creator的角色,能够从0到1,构建满足业务需求的优秀软件系统,甚至能够独立开发一款备受欢迎的开源项目。
|
||||
|
||||
此外,这个阶段的你也要在团队中发光发热,兼具技术Leader的角色,把控技术方向、攻克技术难点,解决各种疑难杂症。
|
||||
|
||||
这个阶段是没有天花板的,所以这个阶段的你仍然要继续学习,通过不断地学习,成为一个越来越牛的技术达人。
|
||||
|
||||
Go进阶之路心得分享
|
||||
|
||||
上面分享了我过往的Go进阶之路,希望对你能有所帮助。这里,我再总结下,在整个过程中,我认为比较重要的点。
|
||||
|
||||
第一点:尽快打怪升级。
|
||||
|
||||
程序员职业生涯短暂,竞争比较大,所以我们要通过努力,尽快实现Go语言开发能力的提升。想要加速提升能力,无外乎两个点:找对方法、多花时间。如果你刚毕业,或者还年轻,在保证身体健康的情况下,可以多熬熬夜,周末多加加班。未来的你,一定会感谢现在努力的自己。现在辛苦,换的是未来的轻松。现在小卷王,未来躺赢王。
|
||||
|
||||
第二点:找对方法很重要。
|
||||
|
||||
每个人都有自己的学习方法。我建议的方法是:工作中发现需求 -> 调研优秀的开源项目 -> 二次开发 -> 团队内分享。以工作需求为驱动,一方面可以让你有较强的学习动力、学习目标,另一方面可以使你在学习的过程中,也能在工作中有所产出,工作产出和学习两不误。基于优秀的开源项目二次开发,可以使你有动手实战的机会的同时,又可以学习到优秀开源项目的构建思路和构建方法。
|
||||
|
||||
第三点:学架构,先学习当前业务的架构,再学习云原生架构。
|
||||
|
||||
除了Go基本的项目开发能力之外,你平时还要注意积累自己的架构能力。积累架构能力最直接、高效的途径便是学习当前业务的架构,不仅要学习整个业务代码是如何实现的,还要学习整个软件系统是如何一步一步部署的。
|
||||
|
||||
此外,在云时代,我们还要学习云原生架构。学习云原生架构一个有效的方式是手动部署一个Kubernetes集群,并研究各部分是如何部署、甚至如何实现的。另外,提升架构能力最高效的途径是借助工作需求来提升,如果工作中有涉及到架构的工作任务,可以踊跃参与讨论、开发和实施。
|
||||
|
||||
最后,我还想补充介绍下我对程序员职业生涯短暂的理解。职业生涯短暂其实是一个伪命题,如果你够优秀,够努力,是可以一直在这个行业混的顺风顺水的。但是,我还是想说一些可能发生的残酷现实:程序员随着年龄的增长,工资越来越高,但精力、体力跟之前比也会有所下降,如果结婚生子之后,还要花费一部分的时间照顾家庭。
|
||||
|
||||
所以对于企业来说,毕业3~5年的程序员可能是性价比最高的,要时间有时间,要经验有经验,并且当前所积累的研发技能,已经能或者通过后期的学习能够满足公司业务开发需求了。如果公司遇到危机,需要裁员,可能会优先裁掉性价比低的那部分人。
|
||||
|
||||
那么,如何判断一个程序员的性价比呢?就是你的能力要跑赢你当前的年龄和薪资。想跑赢当前的年龄和薪资,需要你尽快地打怪练级,提升自己。
|
||||
|
||||
小结
|
||||
|
||||
今天的分享到这里就结束了。在这一讲中,我分享了我过往的Go进阶之路。内容很多,但在我看来核心就是三个点:
|
||||
|
||||
|
||||
多花时间。建议你每天固定留出一些时间来提升自己的Go研发能力,比如下班后~晚上12:00前,每周也可以花费1天来提升自己;
|
||||
找对方法。一个行之有效的方法是:在工作中发现需求 -> 调研优秀的开源项目 -> 二次开发 -> 团队内分享;
|
||||
提升架构能力。提升架构能力可以先从当前业务开始,既要知道怎么部署,又要知道怎么实现。除了知道当前的业务架构之外,最好还能提升自己云原生架构的能力,提升云原生架构能力,可以通过搭建一个原生的Kubernetes平台,并部署一套小型的微服务,并为微服务构建日志、监控告警、调用链、服务中心等能力,然后再通过研究每一部分的部署和实现,补全自己的云原生架构能力。
|
||||
|
||||
|
||||
如果你的工作中,有涉及到架构层面的工作需求,非常建议你踊跃参与讨论、开发和实施,因为这是提升架构能力最有效的方式。
|
||||
|
||||
思考题
|
||||
|
||||
|
||||
思考下,如何去调研一个优秀的开源项目,如果你有自己的方法,欢迎留言区留言讨论。
|
||||
思考下,你的工作中哪些地方可以抽象成一个有价值的需求,并用自己所学的Go语法知识,尝试实现它。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
166
专栏/TonyBai·Go语言第一课/大咖助阵徐祥曦:从销售到分布式存储工程师,我与Go的故事.md
Normal file
166
专栏/TonyBai·Go语言第一课/大咖助阵徐祥曦:从销售到分布式存储工程师,我与Go的故事.md
Normal file
@@ -0,0 +1,166 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
大咖助阵 徐祥曦:从销售到分布式存储工程师,我与 Go 的故事
|
||||
大咖助阵|徐祥曦:从销售到分布式存储工程师,我与 Go 的故事
|
||||
|
||||
你好,我是徐祥曦。现在在一家量化交易公司做高性能计算和基础架构相关的工作。很高兴能在这里和你交流。
|
||||
|
||||
今天我想和你分享一下我职业启蒙阶段的点点滴滴,看看我从一名小销售到分布式存储工程师都发生了哪些故事。这些小故事不一定会给你带来直接启发,但我敢拍胸脯保证它们都很好玩。
|
||||
|
||||
在 Go 之前
|
||||
|
||||
14 年,临近毕业的我笃定云计算会是未来高速发展的行业。我的运气也不赖,最后赶在毕业前夕敲定了七牛云的工作。那个时候我还不知道什么是 Go 语言,对计算机语言的认识也只有小学时候屏幕上的小乌龟(注:Logo 语言)和大学时候借(抄)鉴(袭)的 VB 作业。
|
||||
|
||||
而七牛云作为国内率先将 Go 语言运用到大型工程的公司之一,内部 Go 风浓郁,于是 Go 语言就这样闯入了我的计算机“孩提时代”。我也像蹒跚学步的孩子一样,在走路之前,不停地摔跟头。
|
||||
|
||||
没入门就放弃
|
||||
|
||||
当时的七牛云自内向外,自上而下的技术气息也感染了我。况且,我在入行之前就有了学习计算机技术的想法。很快,就在我做了一个月销售后,便向同事表露了对于学习计算机编程的兴趣,善良的他立马伸出了援手,与我约定每天下班后指导我两小时。
|
||||
|
||||
这个约定两天后就被我这个学生撕毁了,因为我好几次在屏幕前小鸡啄米般地打起了瞌睡。
|
||||
|
||||
“package” “import” “fmt” “func” “main” “()” “{}”……这样奇怪的字符组合放佛是一群面无表情,衣着怪异的天外来客,直愣愣地盯着我,而我一动也不敢动。与此同时,闪烁的光标放佛像是一把对准我的枪,敦促我赶紧做点什么。可我这个时候该干嘛呢?我先是害怕犯错,适应紧张之后我迅速开始麻木,麻木之后便开始疲乏。我这种不争气的行为令我想到了读书的时候班级里所谓的“笨学生”。
|
||||
|
||||
老师表示理解,毕竟我文科出身又没有基础,希望我课后多花点时间琢磨和练习。“新事物一开始会让人感到不适,过去就好了。” 而我则以销售任务重为由,希望以后有空再来讨教,便撤出了办公室。那是十月下旬夜里,时不时会有一阵阵凉风拂过,吹得我的脸忽冷忽热。
|
||||
|
||||
可能这时候会有人说,那是因为我不够热爱计算机。兴趣是最好的老师,我缺乏足够的兴趣,自然是学不好的。毕竟人类不是机器,不会为了一个忽然从天而降的目标就没头没脑地努力了起来,我们需要兴趣,或者说激情来提供行动力。这样的解释似乎很说得通,我们姑且当作是真理。
|
||||
|
||||
于是乎,结论就是要么是我笨,要么是我的兴趣不够真诚。
|
||||
|
||||
我是不大愿意承认自己是笨蛋的。
|
||||
|
||||
for {
|
||||
|
||||
兴趣 -= 1
|
||||
|
||||
}
|
||||
|
||||
|
||||
把自己给“演”了
|
||||
|
||||
我已经想不起来是何时,从哪知道王垠的了。一直以来我总是喜欢涉猎多个学科,看看不同人在不同的领域的想法,纯在是巧合让我忽然某一天刷起他的博客来,就这样王垠在他不知情的情况下成了我无言的老师。
|
||||
|
||||
于是我开始学起了 Scheme。
|
||||
|
||||
不知道你有没有观察到一个现象。哪怕是很差的学生,在学习上失败了,他们也会想办法在其他地方找回场子,打架啦,抽烟啦,说唱啦等等。我当初学习 Scheme,看王垠的博客可能也有这种心理作祟。Scheme 少有人深入学习,而程序员又普遍比较严谨谦虚,我就可以大大方方地说些我并不明白的东西,显示自己有见识。
|
||||
|
||||
王垠的博客常常用通俗易懂的方式阐述一个复杂的事物,尽管我还是看不懂,但他犀利的文笔和清晰的观点,已经足够能让我在某些场合通过插嘴的方式显示自己有眼光。
|
||||
|
||||
有可能我入戏太深,演着演着竟然真对编程有了一丝丝微妙的感觉。这就像是你无意中向院子望去,竟看见一位陌生的美丽少女在暮色中绣花。其实呢,这美少女你早已见过,只是第一次为她屏住了呼吸。
|
||||
|
||||
好问题出现在好时间
|
||||
|
||||
说起来也巧,当我刚学会如何在 DrRacket 里面把代码跑起来的时候,七牛云举办了第一次算法比赛,题目大致如下:
|
||||
|
||||
|
||||
输入一个 2^64 - 1 的数,求该数内的最大素数。要求使用 Go 语言,if 判断尽量少。
|
||||
|
||||
|
||||
尽管我那个时候还不会 Go,但我可以用 Scheme 写呀!
|
||||
|
||||
可是这程序该怎么写呢?用什么算法呢?用什么数据结构呢?更何况,我当时连算法和数据结构到底是在说什么都不知道。困难很多,可我还是胸有成竹,深信自己能写出全公司最快的代码。
|
||||
|
||||
我并不是昏了头,相反我很清醒。因为就在算法比赛前几天,我正好闲来无事在看《什么是数学》这本书,正好学了一些数论的基础。于是乎,我立马想到了费马小定理。
|
||||
|
||||
当时看书的时候,我就凭直觉猜到费马小定理可以通过变形增加它的强度,结合这个算法题,依我看来, 2^64 - 1 在数学界至多算个天文数字的弟中弟,一定早就有人在更大的范围内将费马小定理得到的伪素数排除殆尽。
|
||||
|
||||
进一步,我可以根据现有的素数定理猜测最大素数可能出现的位置,来减少计算的开销。这个定理也是现成的,用高斯的也好,用黎曼的也好,都行。
|
||||
|
||||
万事俱备,我非常激动地开始编写属于自己的第一个程序, 你可以在 这里 看到它。
|
||||
|
||||
不过,有一个困难是我在开始的时候没有料到的,程序慢到我以为电脑坏了,一番搜索之后,我才知道除法在计算机世界慢得令人发指。原则上,我立马选择无视这个问题算是识时务的英雄,因为我的程序已经是理论上最快的了,毕竟其他人的算法一样也只能在理论上判断上一个大数是不是素数。作为一名菜鸟我做得够好了!更何况我还在做销售呢!
|
||||
|
||||
但我没有停下来,通过一步步推算,我自己重新发明了一遍蒙哥马利算法(Montgomery Reduction)。起初我以为自己发现了了不起的东西,后来发现这个算法 1985年就有了。尽管错过了发现的机会,但至少说明我做对了,当时的兴奋感不亚于裸身冲出浴盆的阿基米德。最终,求 3,317,044,064,679,887,385,961,980 (不知比原题的上界大了多少倍) 以内最大的素数只需要几毫秒。
|
||||
|
||||
现在回想起来,真是好惊险。还好我当时不会 Go,用的是直接支持大数运算的 Scheme。
|
||||
|
||||
虽然由于不符合题目要求,我没有提交我的代码。但这不妨碍我第一次真切感受到了编程的动人之处——将自己的想法实现出来。
|
||||
|
||||
在 Go 之上
|
||||
|
||||
小目标:把 Go 写的和 Intel 的汇编一样快
|
||||
|
||||
不知不觉,两年里,我从销售转变成了售前工程师,这要感谢当时那个粗糙和善良的时代,好让我挂着个“工程师”的招牌却不用真正懂技术。但我并不满足,幻想自己加入公司神秘的分布式存储开发团队,成为一名真正的工程师。
|
||||
|
||||
痴痴地想多了,行为竟然也不受控制起来。一个燥热午后,我突然从工位上站起来跑去找存储团队的负责人“你们缺人不?” 未曾想,他爽快地说好,笑容犹如一阵清风,这时我才如梦初醒,感到大事不妙。我就这个水平进去,不得没两月就被公司扫地出门,饭都要吃不上了!真该死,我应该再准备准备的!
|
||||
|
||||
但很多时候,我们就是需要惊喜甚至惊悚,不是吗?当什么都准备好的时候,往往也就没有机会试一试了。
|
||||
|
||||
当然了,事物发展都是要遵循客观规律的。果然,我干得非常糟糕,严重拉低了团队的下限,后来我才知道我当时离被开除仅有一线之隔,也不知是哪位菩萨保佑了我,才让我晕头转向地继续混在里面。作为知恩图报的人,我得先感谢 Go 的简洁,让我没捅太多娄子。
|
||||
|
||||
战战兢兢的我在勉勉强强应付 mentor 给我的任务的同时,还有一个不成熟的心愿,这也是我想要来做存储的初衷,这个问题的种子在我做销售的时候就埋下了——七牛云的纠删码一直说很强,但究竟强在哪呢?但我却没法开口问,既不知道问谁,也不知道提一个什么样的具体问题,因为我连自己到底想知道什么也不知道。
|
||||
|
||||
我缺乏一个能引导自己的真正好的问题。
|
||||
|
||||
思而不学则怠。我开始像个正儿八经的工程师一样开始读起了论文。一遍看不懂,就再看一遍,看不懂原文就检索相关的资料。最终,我当然还是看不懂。我怎么能看懂呢?我会的不多的线性代数早已忘光,有限域更是天书,编码又是怎么回事呢?至于实现它所需要的体系结构知识,我怎么可能会呢?我甚至连半本计算机书籍都没看完过。
|
||||
|
||||
折磨啊,真折磨,逃避一下吧!
|
||||
|
||||
说是逃避,但我还是抱有侥幸心理,希望这是一次战略性撤退,事实证明确实如此。由于过去了好几年,我那个时候头脑也乱糟糟,我现在确实想不起来我在哪看到的一门化学公开课,里面运用了有限域。我当时的如意算盘大致如下,由于不是纯数,而偏应用,我或许可以假装在学化学,好让自己在转移注意力,减轻焦虑的同时,悄悄掌握有限域的知识。我那个时候可真敢想啊!万幸,现在也是!
|
||||
|
||||
从将信将疑地开始学习,到且战且退,到开发出第一个版本,我花了七上八下的两个星期。
|
||||
|
||||
第一个版本的性能大概是 Intel ISA-L 库的 70%,基本符合我的期望。毕竟我是用 Go 及其 Plan9 汇编写的,而 ISA-L 是 C 和汇编,况且我还不大会写代码哩!听上去很合理,很实际,非常经济。
|
||||
|
||||
但如果我要真正从实际出发,我就应该进一步提高其性能。因为我还没看到阻碍其性能进一步提高的原因,为什么不继续呢?
|
||||
|
||||
最终我以与 ISA-L 不同的矩阵运算形式取得了与其媲美的性能,代码我后来也开源了。这样寥寥一句话的背后,藏匿的是我好几个被指令折磨的日日夜夜,在这之后,我看机器码都可爱起来了。
|
||||
|
||||
单单写一个很快的纠删码,我觉得还不够痛快。没多久我又写了一个具备诸多良好性质的编码,白山云的存储现在用的就是这个。人生得意须尽欢嘛!有趣的是,我逞一时之快的作品成了我后来更进一步的敲门砖(我不单单是因为美貌立足社会的)。在那个时候,我真正成了一名程序员,一个工程师。
|
||||
|
||||
Go Contributor
|
||||
|
||||
这是一段让我面红耳赤的经历。
|
||||
|
||||
在编写纠删码算法的过程中,我掌握了些许针对体系结构优化的经验,翻了下 Go 的代码后,发现自己可以尝试对Go部分源代码进行优化。于是,我便开始干了。
|
||||
|
||||
很快我就收到了 review 意见。嚯,好家伙!短短几十行汇编提了这么多修改意见。我也变得兴奋起来,开始改吧!当时夜半,有些兴奋过了头,每每改完一版,心满意足地刚一躺下便跳起来大呼不妙 —— 我应该如此如此、这般这般会更加优雅!如此反复,我把 Go Team 的邮箱打爆了,直到被 Brad Fitzpatrick 呵斥,我心里委屈起来,我还只是个孩子啊!
|
||||
|
||||
试着去挑战一下成熟而活跃的开源社区吧,难道你不喜欢面红耳赤,心跳加速的感觉吗?
|
||||
|
||||
引路人
|
||||
|
||||
话接之前我提到的纠删码,我说到自己通过这代码拿到了后来的工作机会。不过在展示这代码之前,可能更重要的是,我与未来的领导关于某些矩阵的子矩阵在有限域中的可逆性问题的讨论,引起了他的好奇。你说两个程序员讨论这干啥呢?但很好玩不是吗?我也正是这样通过玩耍吃上了饭,还获得了友谊。
|
||||
|
||||
虽然我之前也在分布式存储团队,但我真的什么也不会。直到来到了白山云,忽然要挑大梁了,我才意识到自己真的要成为一名分布式存储工程师了。
|
||||
|
||||
那个时候,我的任务是和团队一起完成纠删码存储集群设计与落地。好艰巨啊,我连写些最基础的代码都得复制粘贴啊!由于之前团队没有使用 Go 语言的经验,领导安排我给大家做一个简单的 Go 语言培训,我居然还真硬着头皮又硬着脸皮去做个了简单的分享。好在同事们都思维缜密,基础扎实,将我的七零八落的只言片语在心里默默地组织起来,很快掌握了 Go。在这里,我必须得再次感谢这门实践性语言的清晰明了。
|
||||
|
||||
写到这里我忽然有种担心,这不是活脱脱一个江湖骗子的形象嘛!我也太敢说了。希望看到这里的朋友,天知地知你知我知,不要到处讲。出门在外,大家都是朋友。
|
||||
|
||||
不过,我们做事也不能光凭大胆啊。在化肥得到广泛应用之前,小麦的亩产是非常低的。这也是现代化农业之前,大家开垦荒地积极性不高的原因。一亩地一年就出几十斤粮食,稍有不慎,什么都没了。所以,大胆和产能没有直接联系,所以我也没法单凭不要脸把存储给做出来,要讲究科学。
|
||||
|
||||
首先,我们需要将设计一套存储系统,转化成一个非常具体的问题,再由一个个具体问题引导出新的具体问题,最终一张有组织的网便生长了出来。这也是架构设计到工程实践的一般步骤。
|
||||
|
||||
但是,要得到具有引导性的好问题并不容易,我除了思辨与推演,其实也在不停地翻查别人的设计,每当我有了新的成型想法后,我便拿去和 Leader 讨论,说是讨论,不如说是盘问。在他的 “是还是不是” 以及 “是” 一个追问,“不是” 一个追问的过程中,问题逐渐走向成熟。
|
||||
|
||||
这样一轮又一轮的审判持续了四个月,我们终于开始着手真正的架构设计了,它先是出现在脑海中,随后是满是涂鸦的纸片上,再来到了文档里,最后亭亭玉立、大大方方地呈现了出来。
|
||||
|
||||
直到我自己开始负责同样大型的项目的时候,我才清晰地意识到他早有许多成型的思路,但就是硬憋着没直接说出来。你们瞧,不但有我这样的神棍,群众里面还有坏人呐!把我折磨,将我蹂躏,带我成长。
|
||||
|
||||
能有一位引路人,引导你完成一件很漂亮的作品。这样的故事,每每回想都很有滋味。
|
||||
|
||||
之后
|
||||
|
||||
一不小心说的有些太多了,我得保留点神秘感,最近三四年的故事以后有机会再谈,让我们先谈谈未来吧。
|
||||
|
||||
从阴沟里爬出来的故事中充满着机缘和幸运,那么我为何偏偏是那个幸运男孩呢?或者只是因为没有这份幸运从阴沟里出不来而已?如果能求得一个答案,应该会有助于我在当下和未来保持这份幸运。
|
||||
|
||||
首先自然是时代带给我的红利,那个时候我正好赶上了黄金期。加之我一直紧咬困惑自己的问题不放,等到机会来临的时候,我正好把原先的积累找到出口释放了出来。
|
||||
|
||||
那么,当大潮退去,又该如何做呢?我想我们能做到的依然还是保持初心,保持自己的求知欲望,这是我们仅能做的。结果是我们不可控的,但至少我们能收获沿途美妙的风景。
|
||||
|
||||
想到这里我忽然有些困乏,时候也不早了,远处传来的深圳特产——工地之歌也逐渐稀稀落落。我朝着窗外望得出神,想起自己曾在北京的窗台边听着外头的鸟吱吱的叫声;曾在上海的凌晨听到人行道被扫得唰唰作响;还有那西湖边,夏日的晚风掠过树梢,树叶在月光下窸窸窣窣;在九月的家乡,桂花在我的窗边唱起无声的歌谣。无论在哪,传来什么样的声音,我又从哪扇门走出去,遇上什么样的人,我都未曾改变呐!
|
||||
|
||||
我不在乎我不知道什么,缺少什么,也不在乎别人知道什么,拥有什么。我只在乎我自己的渴望。
|
||||
|
||||
那么我现在渴望什么呢?我暂时还不能说,因为往往目标被说出来之后就容易沉浸在已经成功的幻觉中,难以踏出艰难的第一步了。等事情浮出水面的时候再轻描淡写地说出来或许更热血沸腾。
|
||||
|
||||
光想想就刺激,不是吗?
|
||||
|
||||
|
||||
|
||||
|
||||
409
专栏/TonyBai·Go语言第一课/大咖助阵曹春晖:聊聊Go语言的GC实现.md
Normal file
409
专栏/TonyBai·Go语言第一课/大咖助阵曹春晖:聊聊Go语言的GC实现.md
Normal file
@@ -0,0 +1,409 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
大咖助阵 曹春晖:聊聊 Go 语言的 GC 实现
|
||||
|
||||
作者注:本文只作了解,不建议作为面试题考察。
|
||||
|
||||
|
||||
你好,我是曹春晖,是《Go 语言高级编程》的作者之一。
|
||||
|
||||
今天我想跟你分享一下 Go 语言内存方面的话题,聊一聊Go语言中的垃圾回收(GC)机制的实现,希望你能从中有所收获。
|
||||
|
||||
武林秘籍救不了段错误
|
||||
|
||||
|
||||
|
||||
在各种流传甚广的 C 语言葵花宝典里,一般都有这么一条神秘的规则,不能返回局部变量:
|
||||
|
||||
int * func(void) {
|
||||
int num = 1234;
|
||||
/* ... */
|
||||
return #
|
||||
}
|
||||
|
||||
|
||||
duang!
|
||||
|
||||
当函数返回后,函数的栈帧(stack frame)就会被销毁,引用了被销毁位置的内存,轻则数据错乱,重则 segmentation fault。
|
||||
|
||||
可以说,即使经过了八十一难,终于成为了 C 语言绝世高手,我们还是逃不过复杂的堆上对象引用关系导致的 dangling pointer:
|
||||
|
||||
|
||||
|
||||
你看,在这张图中,当 B 被 free 掉之后,应用程序依然可能会使用指向 B 的指针,这就是比较典型的 dangling pointer 问题,堆上的对象依赖关系可能会非常复杂。所以,我们要正确地写出 free 逻辑,还得先把对象图给画出来。
|
||||
|
||||
不过,依赖人去处理复杂的对象内存管理的问题是不科学、不合理的。C 和 C++ 程序员已经被折磨了数十年,我们不应该再重蹈覆辙了,于是,后来的很多编程语言就用上垃圾回收(GC)机制。
|
||||
|
||||
GC 拯救程序员
|
||||
|
||||
垃圾回收(Garbage Collection)也被称为自动内存管理技术,在现代编程语言中使用得相当广泛,常见的 Java、Go、C# 均在语言的 runtime 中集成了相应的实现。
|
||||
|
||||
在传统的不带GC的编程语言中,我们需要关注对象的分配位置,要自己去选择对象是分配在堆还是栈上,但在 Go 这门有 GC 的语言中,集成了逃逸分析功能来帮助我们自动判断对象应该在堆上还是栈上,我们可以使用 go build -gcflags="-m" 来观察逃逸分析的结果:
|
||||
|
||||
package main
|
||||
|
||||
func main() {
|
||||
var m = make([]int, 10240)
|
||||
println(m[0])
|
||||
}
|
||||
|
||||
|
||||
你可以看到,较大的对象也会被放在堆上。
|
||||
|
||||
|
||||
|
||||
这里,执行 gcflags=“-m” 的输出,我们就可以看到发生了逃逸。
|
||||
|
||||
若对象被分配在栈上,它的管理成本就比较低,我们通过挪动栈顶寄存器就可以实现对象的分配和释放。若对象被分配在堆上,我们就要经历层层的内存申请过程。但这些流程对用户都是透明的,在编写代码时我们并不需要在意它。只有需要优化时,我们才需要研究具体的逃逸分析规则。
|
||||
|
||||
逃逸分析与垃圾回收结合在一起,极大地解放了程序员们的心智,我们在编写代码时,似乎再也没必要去担心内存的分配和释放问题了。
|
||||
|
||||
然而,一切抽象皆有成本,这个成本要么花在编译期,要么花在运行期。
|
||||
|
||||
GC 这种方案是选择在运行期来解决问题,不过在极端场景下 GC 本身引起的问题依然是令人难以忽视的:
|
||||
|
||||
|
||||
|
||||
这张图的场景是在内存中缓存了上亿的 kv,这时 GC 使用的 CPU 甚至占到了总 CPU 占用的 90% 以上。简单粗暴地在内存中缓存对象,到头来发现 GC 成为了 CPU 杀手,吃掉了大量的服务器资源,这显然不是我们期望的结果。
|
||||
|
||||
想要正确地分析原因,就需要我们对 GC 本身的实现机制有稍微深入一些的理解。
|
||||
|
||||
内存管理的三个参与者
|
||||
|
||||
当讨论内存管理问题时,我们主要会讲三个参与者,mutator,allocator 和 garbage collector。
|
||||
|
||||
|
||||
mutator 指的是我们的应用,也就是 application,我们将堆上的对象看作一个图,跳出应用来看的话,应用的代码就是在不停地修改这张堆对象图里的指向关系。下面的图可以帮我们理解 mutator 对堆上的对象的影响:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
allocator 就很好理解了,指的是内存分配器,应用需要内存的时候都要向 allocator 申请。allocator 要维护好内存分配的数据结构,在多线程场景下工作的内存分配器还需要考虑高并发场景下锁的影响,并针对性地进行设计以降低锁冲突。
|
||||
|
||||
collector 是垃圾回收器。死掉的堆对象、不用的堆内存都要由 collector 回收,最终归还给操作系统。当 GC 扫描流程开始执行时,collector 需要扫描内存中存活的堆对象,扫描完成后,未被扫描到的对象就是无法访问的堆上垃圾,需要将其占用内存回收掉。
|
||||
|
||||
|
||||
三者的交互过程可以用下图来表示:
|
||||
|
||||
|
||||
|
||||
我们可以看到,应用需要在堆上申请内存时,会由编译器帮程序员自动调用runtime.newobject,这时 allocator 会使用 mmap 这个系统调用从操作系统中申请内存,若 allocator 发现之前申请的内存还有富余,会从本地预先分配的数据结构中划分出一块内存,并把它以指针的形式返回给应用。在内存分配的过程中,allocator 要负责维护内存管理对应的数据结构。
|
||||
|
||||
而collector 要扫描的就是 allocator 管理的这些数据结构,应用不再使用的部分便应该被回收,通过 madvise 这个系统调用返还给操作系统。
|
||||
|
||||
现在我们来看看这些交互的细节吧。
|
||||
|
||||
分配内存
|
||||
|
||||
应用程序使用 mmap 向 OS 申请内存,操作系统提供的接口比较简单,mmap 返回的结果是连续的内存区域。
|
||||
|
||||
mutator 申请内存是以应用视角来看问题。比如说,我需要的是某一个 struct和某一个 slice 对应的内存,这与从操作系统中获取内存的接口之间还有一个鸿沟。这就需要由 allocator 进行映射与转换,将以“块”来看待的内存与以“对象”来看待的内存进行映射:
|
||||
|
||||
|
||||
|
||||
你可以从上面这张图看到,在应用的视角看,我们需要初始化的 a 是一个 1024000 长度的 int 切片;在内存管理的视角来看,我们需要管理的只是 start、offset 对应的一段内存。
|
||||
|
||||
在现代 CPU 上,除了内存分配的正确性以外,我们还要考虑分配过程的效率问题,应用执行期间小对象会不断地生成与销毁,如果每一次对象的分配与释放都需要与操作系统交互,那么成本是很高的。这就需要我们在应用层设计好内存分配的多级缓存,尽量减少小对象高频创建与销毁时的锁竞争,这个问题在传统的 C/C++ 语言中已经有了解法,那就是 tcmalloc:
|
||||
|
||||
|
||||
|
||||
你可以看到,tcmalloc 通过维护一套多级缓存结构,降低了应用内存分配过程中对全局锁的使用频率,使小对象的内存分配做到了尽量无锁。
|
||||
|
||||
Go 语言的内存分配器基本是 tcmalloc 的 1:1 搬运……毕竟都是 Google 的项目。
|
||||
|
||||
在 Go 语言中,根据对象中是否有指针以及对象的大小,将内存分配过程分为三类:
|
||||
|
||||
|
||||
tiny :size < 16 bytes && has no pointer(noscan);
|
||||
small :has pointer(scan) || (size >= 16 bytes && size <= 32 KB);
|
||||
large :size > 32 KB。
|
||||
|
||||
|
||||
接下来我们一个个分析。在内存分配过程中,最复杂的就是 tiny 类型的分配。
|
||||
|
||||
我们可以将内存分配的路径与 CPU 的多级缓存作类比,这里 mcache 内部的 tiny 可以类比为 L1 cache,而 alloc 数组中的元素可以类比为 L2 cache,全局的 mheap.mcentral 结构为 L3 cache,mheap.arenas 是 L4,L4 是以页为单位将内存向下派发的,由 pageAlloc 来管理 arena 中的空闲内存。具体你可以看下这张表:
|
||||
|
||||
|
||||
|
||||
如果 L4 也没法满足我们的内存分配需求,那我们就需要向操作系统去要内存了。
|
||||
|
||||
和 tiny 的四级分配路径相比,small 类型的内存没有本地的 mcache.tiny 缓存,其余的与 tiny 分配路径完全一致:
|
||||
|
||||
|
||||
|
||||
large 内存分配稍微特殊一些,没有前面这两类这样复杂的缓存流程,而是直接从 mheap.arenas 中要内存,直接走 pageAlloc 页分配器。
|
||||
|
||||
页分配器在 Go 语言中迭代了多个版本,从简单的 freelist 结构,到 treap 结构,再到现在最新版本的 radix 结构,它的查找时间复杂度也从 O(N) -> O(log(n)) -> O(1)。
|
||||
|
||||
在当前版本中,我们只需要知道常数时间复杂度就可以确定空闲页组成的 radix tree 是否能够满足内存分配需求。若不满足,则要对 arena 继续进行切分,或向操作系统申请更多的 arena。
|
||||
|
||||
只看这些分类文字不太好理解,接下来我们看看 arenas、page、mspan、alloc 这些概念是怎么关联在一起组成 Go 的内存分配流程的。
|
||||
|
||||
内存分配的数据结构之间的关系
|
||||
|
||||
arenas 是 Go 向操作系统申请内存时的最小单位,每个 arena 为 64MB 大小,在内存中可以部分连续,但整体是个稀疏结构。
|
||||
|
||||
单个 arena 会被切分成以 8KB 为单位的 page,由 page allocator 管理,一个或多个 page 可以组成一个 mspan,每个 mspan 可以按照 sizeclass 再划分成多个 element。同样大小的 mspan 又分为 scan 和 noscan 两种,分别对应内部有指针的 object 和内部没有指针的 object。
|
||||
|
||||
之前讲到的四级分配结构如下图:
|
||||
|
||||
|
||||
|
||||
你可以从上图清晰地看到内存分配的多级路径,我们可以再研究一下这里面的 mspan。每一个 mspan 都有一个 allocBits 结构,从 mspan 里分配 element 时,我们只要将 mspan 中对应该 element 位置的 bit 位置一就可以了,其实就是将 mspan 对应 allocBits 中的对应 bit 位置一。每一个 mspan 都会对应一个 allocBits 结构,如下图:
|
||||
|
||||
|
||||
|
||||
当然,在代码中还有一些位操作优化(如freeIndex、allocCache),你课后可以再去探索一下。
|
||||
|
||||
了解了Go语言中内存管理和内存分配的基础知识之后,我们就可以具体看看Go语言中垃圾回收的实现。
|
||||
|
||||
垃圾回收
|
||||
|
||||
Go 语言使用了并发标记与清扫算法作为它的 GC 实现。
|
||||
|
||||
标记、清扫算法是一种古老的 GC 算法,是指将内存中正在使用的对象进行标记,之后清扫掉那些未被标记的对象的一种垃圾回收算法。并发标记与清扫重点在并发,是指垃圾回收的标记和清扫过程能够与应用代码并发执行。但并发标记清扫算法的一大缺陷是无法解决内存碎片问题,而 tcmalloc 恰好一定程度上缓解了内存碎片问题,两者配合使用相得益彰。
|
||||
|
||||
但这并不是说 tcmalloc 完全没有内存碎片,不信你可以在代码里搜搜 max waste。
|
||||
|
||||
垃圾分类
|
||||
|
||||
进行垃圾回收之前,我们要先对内存垃圾进行分类,主要可以分为语义垃圾和语法垃圾两类,但并不是所有垃圾都可以被垃圾回收器回收。
|
||||
|
||||
|
||||
|
||||
语义垃圾(semantic garbage),有些场景也被称为内存泄露,指的是从语法上可达(可以通过局部、全局变量被引用)的对象,但从语义上来讲他们是垃圾,垃圾回收器对此无能为力。
|
||||
|
||||
我们来看一个语义垃圾在 Go 语言中的实例:
|
||||
|
||||
|
||||
|
||||
这里,我们初始化了一个 slice,元素均为指针,每个指针都指向了堆上 10MB 大小的一个对象。
|
||||
|
||||
|
||||
|
||||
当这个 slice 缩容时,底层数组的后两个元素已经无法再访问了,但它关联的堆上内存依然是无法释放的。
|
||||
|
||||
碰到类似的场景,你可能需要在缩容前,先将数组元素置为 nil。
|
||||
|
||||
另外一种内存垃圾就是语法垃圾(syntactic garbage),讲的是那些从语法上无法到达的对象,这些才是垃圾收集器主要的收集目标。
|
||||
|
||||
我们用一个简单的例子来理解一下语法垃圾:
|
||||
|
||||
|
||||
|
||||
这段代码中,在 allocOnHeap 返回后,堆上的 a 无法访问,便成为了语法垃圾。
|
||||
|
||||
现在,我们已经明白了垃圾回收的对象是语法垃圾,那Go GC的执行流程具体是怎么样的呢?
|
||||
|
||||
GC 流程
|
||||
|
||||
Go 的每一轮版本迭代几乎都会对 GC 做优化。经过多次优化后,较新的 GC 流程如下图:
|
||||
|
||||
|
||||
|
||||
在这张图中,你可以看到,在并发标记开始前和并发标记终止时,有两个短暂的 stw,该 stw 可以使用 pprof 的 pauseNs 来观测,也可以直接采集到监控系统中:
|
||||
|
||||
|
||||
|
||||
监控系统中的 PauseNs 就是每次 stw 的时长。尽管官方声称 Go 的 stw 已经是亚毫秒级了,但我们在高压力的系统中仍然能够看到毫秒级的 stw。
|
||||
|
||||
对Go GC流程有了一些基本了解后,我们现在“划重点”,具体看看 Go GC中的那些关键流程和关键问题。
|
||||
|
||||
标记流程
|
||||
|
||||
Go 语言使用三色抽象作为其并发标记的实现。所以这里我们首先要理解三种颜色的抽象:
|
||||
|
||||
|
||||
黑表示已经扫描完毕,子节点扫描完毕(gcmarkbits = 1,且在队列外);
|
||||
灰表示已经扫描完毕,子节点未扫描完毕(gcmarkbits = 1, 在队列内);
|
||||
白表示未扫描,collector 不知道任何相关信息。
|
||||
|
||||
|
||||
使用三色抽象,主要是为了能让垃圾回收流程与应用流程并发执行,这样将对象扫描过程拆分为多个阶段,不需要一次性完成整个扫描流程。
|
||||
|
||||
|
||||
|
||||
GC 扫描的起点是根对象,忽略掉那些不重要的(finalizer 相关的先省略),常见的根对象可以参见下图:
|
||||
|
||||
|
||||
|
||||
所以在 Go 语言中,从根开始扫描的含义是从 .bss 段,.data 段以及 goroutine 的栈开始扫描,最终遍历整个堆上的对象树。
|
||||
|
||||
标记过程是一个广度优先的遍历过程。它是扫描节点,将节点的子节点推到任务队列中,然后递归扫描子节点的子节点,直到所有工作队列都被排空为止。
|
||||
|
||||
|
||||
|
||||
标记过程会将白色对象标记,并推进队列中变成灰色对象。我们可以看看 scanobject 的具体过程:
|
||||
|
||||
|
||||
|
||||
在标记过程中,gc mark worker 会一边从工作队列(gcw)中弹出对象,一边把它的子对象 push 到工作队列(gcw)中,如果工作队列满了,则要将一部分元素向全局队列转移。
|
||||
|
||||
我们知道,堆上对象本质上是图,会存储引用关系互相交叉的时候,在标记过程中也有简单的剪枝逻辑:
|
||||
|
||||
|
||||
|
||||
这里,D 是 A 和 B 的共同子节点,在标记过程中自然会减枝,防止重复标记浪费计算资源:
|
||||
|
||||
|
||||
|
||||
如果多个后台 mark worker 确实产生了并发,标记时使用的是 atomic.Or8,也是并发安全的:
|
||||
|
||||
|
||||
|
||||
协助标记
|
||||
|
||||
当应用分配内存过快时,后台的 mark worker 无法及时完成标记工作,这时应用本身需要进行堆内存分配时,会判断是否需要适当协助 GC 的标记过程,防止应用因为分配过快发生 OOM。
|
||||
|
||||
碰到这种情况时,我们会在火焰图中看到对应的协助标记的调用栈:
|
||||
|
||||
|
||||
|
||||
不过,协助标记会对应用的响应延迟产生影响,我们可以尝试降低应用的对象分配数量进行优化。Go 内部具体是通过一套记账还账系统来实现协助标记的流程的,这一部分不是我们这一讲的重点,如果你感兴趣,可以去看看这里 。
|
||||
|
||||
对象丢失问题
|
||||
|
||||
前面我们提到了 GC 线程/协程与应用线程/协程是并发执行的,在 GC 标记 worker 工作期间,应用还会不断地修改堆上对象的引用关系,这就可能导致对象丢失问题。下面是一个典型的应用与 GC 同时执行时,由于应用对指针的变更导致对象漏标记,从而被 GC 误回收的情况。
|
||||
|
||||
|
||||
|
||||
在这张图表现的 GC 标记过程中,应用动态地修改了 A 和 C 的指针,让 A 对象的内部指针指向了 B,C 的内部指针指向了 D。如果标记过程垃圾收集器无法感知到这种变化,最终 B 对象在标记完成后是白色,会被错误地认作内存垃圾被回收。
|
||||
|
||||
为了解决漏标,错标的问题,我们先需要定义“三色不变性”,如果我们的堆上对象的引用关系不管怎么修改,都能满足三色不变性,那么也不会发生对象丢失问题。三色不变性可以分为强三色不变性和弱三色不变性两种,
|
||||
|
||||
首先是强三色不变性(strong tricolor invariant),禁止黑色对象指向白色对象:
|
||||
|
||||
|
||||
|
||||
然后是弱三色不变性(weak tricolor invariant),黑色对象可以指向白色对象,但指向的白色对象,必须有能从灰色对象可达的路径:
|
||||
|
||||
|
||||
|
||||
无论应用在与 GC 并发执行期间如何修改堆上对象的关系,只要修改之后,堆上对象能满足任意一种不变性,就不会发生对象的丢失问题。
|
||||
|
||||
而实现强/弱三色不变性均需要引入屏障技术。在 Go 语言中,使用写屏障,也就是 write barrier 来解决上述问题。
|
||||
|
||||
write barrier
|
||||
|
||||
这里barrier 的本质是 : snippet of code insert before pointer modify。不过,在并发编程领域也有 memory barrier,但这个含义与 GC 领域的barrier是完全不同的,在阅读相关材料时,你一定要注意不要混淆这两个概念。
|
||||
|
||||
Go 语言的 GC 只有 write barrier,没有 read barrier。
|
||||
|
||||
在应用进入 GC 标记阶段前的 stw 阶段,会将全局变量 runtime.writeBarrier.enabled 修改为 true,这时所有的堆上指针修改操作在修改之前便会额外调用 runtime.gcWriteBarrier:
|
||||
|
||||
|
||||
|
||||
在反汇编结果中,我们可以通过行数找到原始的代码位置:
|
||||
|
||||
|
||||
|
||||
在GC领域中,常见的 write barrier 有两种:
|
||||
|
||||
|
||||
Dijistra Insertion Barrier,指针修改时,指向的新对象要标灰:-
|
||||
|
||||
|
||||
Yuasa Deletion Barrier,指针修改时,修改前指向的对象要标灰:-
|
||||
|
||||
|
||||
|
||||
从理论上来讲,如果 Go 语言的所有对象都在堆上,使用上述两种屏障的任意一种,都不会发生对象丢失的问题。
|
||||
|
||||
但我们不要忽略,在 Go 语言中,还有很多对象被分配在栈上。栈上的对象操作极其频繁,给栈上对象增加写屏障成本很高,所以 Go 是不给栈上对象开启屏障的。
|
||||
|
||||
只对堆上对象开启写屏障的话,使用上述两种屏障其中的任意一种,都需要在 stw 阶段对栈进行重扫。所以经过多个版本的迭代,现在 Go 的写屏障混合了上述两种屏障,实现是这样的:
|
||||
|
||||
|
||||
|
||||
这和 Go 语言在混合屏障的 proposal 上的实现不太相符,本来 proposal 是这么写的:
|
||||
|
||||
|
||||
|
||||
为什么会有这种差异呢?这主要是因为栈的颜色判断成本是很高的,官方最终还是选择了更为简单的实现,即指针断开的老对象和新对象都标灰的实现。
|
||||
|
||||
我们再来详细地看看前面两种屏障的对象丢失问题。
|
||||
|
||||
|
||||
Dijistra Insertion Barrier 的对象丢失问题:-
|
||||
|
||||
|
||||
Yuasa Deletion Barrier 的对象丢失问题:-
|
||||
|
||||
|
||||
|
||||
早期 Go 只使用了 Dijistra 屏障,但因为会有上述对象丢失问题,需要在第二个 stw 周期进行栈重扫(stack rescan)。当 goroutine 数量较多时,stw 时间会变得很长。
|
||||
|
||||
但单独使用任意一种 barrier ,又没法满足 Go 消除栈重扫的要求,所以最新版本中 Go 的混合屏障其实是 Dijistra Insertion Barrier + Yuasa Deletion Barrier。
|
||||
|
||||
|
||||
|
||||
混合 write barrier 会将两个指针推到 p 的 wbBuf 结构去,我们来看看这个过程:
|
||||
|
||||
|
||||
|
||||
现在我们可以看看 mutator 和后台的 mark worker 在并发执行时的完整过程了:
|
||||
|
||||
|
||||
|
||||
回收流程
|
||||
|
||||
相比复杂的标记流程,对象的回收和内存释放就简单多了。
|
||||
|
||||
进程启动时会有两个特殊 goroutine:
|
||||
|
||||
|
||||
一个叫 sweep.g,主要负责清扫死对象,合并相关的空闲页;
|
||||
一个叫 scvg.g,主要负责向操作系统归还内存。
|
||||
|
||||
|
||||
(dlv) goroutines
|
||||
* Goroutine 1 - User: ./int.go:22 main.main (0x10572a6) (thread 5247606)
|
||||
Goroutine 2 - User: /usr/local/go/src/runtime/proc.go:367 runtime.gopark (0x102e596) [force gc (idle) 455634h24m29.787802783s]
|
||||
Goroutine 3 - User: /usr/local/go/src/runtime/proc.go:367 runtime.gopark (0x102e596) [GC sweep wait]
|
||||
Goroutine 4 - User: /usr/local/go/src/runtime/proc.go:367 runtime.gopark (0x102e596) [GC scavenge wait]
|
||||
|
||||
|
||||
注意看这里的 GC sweep wait 和 GC scavenge wait, 就是这两个 goroutine。
|
||||
|
||||
当 GC 的标记流程结束之后,sweep goroutine 就会被唤醒,进行清扫工作,其实就是循环执行 sweepone -> sweep。针对每个 mspan,sweep.g 的工作是将标记期间生成的 bitmap 替换掉分配时使用的 bitmap:
|
||||
|
||||
|
||||
|
||||
然后根据 mspan 中的槽位情况决定该 mspan 的去向:
|
||||
|
||||
|
||||
如果 mspan 中存活对象数 = 0,也就是所有 element 都变成了内存垃圾,那执行 freeSpan -> 归还组成该 mspan 所使用的页,并更新全局的页分配器摘要信息;
|
||||
如果 mspan 中没有空槽,说明所有对象都是存活的,将其放入 fullSwept 队列中;
|
||||
如果 mspan 中有空槽,说明这个 mspan 还可以拿来做内存分配,将其放入 partialSweep 队列中。
|
||||
|
||||
|
||||
之后“清道夫” scvg goroutine 被唤醒,执行线性流程,一路运行到将页内存归还给操作系统,也就是 bgscavenge -> pageAlloc.scavenge -> pageAlloc.scavengeOne -> pageAlloc.scavengeRangeLocked -> sysUnused -> madvise:
|
||||
|
||||
|
||||
|
||||
问题分析
|
||||
|
||||
从前面的基础知识中,我们可以总结出 Go 语言垃圾回收的关键点:
|
||||
|
||||
|
||||
无分代;
|
||||
与应用执行并发;
|
||||
协助标记流程;
|
||||
并发执行时开启 write barrier。
|
||||
|
||||
|
||||
我们日常编码中就需要考虑这些关键点,进行一些针对性的设计与优化。比如,因为无分代,当我们遇到一些需要在内存中保留几千万 kv map 的场景(比如机器学习的特征系统)时,就需要想办法降低 GC 扫描成本。
|
||||
|
||||
又比如,因为有协助标记,当应用的 GC 占用的 CPU 超过 25% 时,会触发大量的协助标记,影响应用的延迟,这时也要对 GC 进行优化。
|
||||
|
||||
简单的业务场景,我们使用 sync.Pool 就可以带来较好的优化效果,若碰到一些复杂的业务场景,还要考虑 offheap 之类的欺骗 GC 的方案,比如 dgraph 的方案。因为我们这讲聚焦于内存分配和 GC 的实现,就不展开介绍这些具体方案了。
|
||||
|
||||
另外,这讲中涉及的所有内存管理的名词,你都可以在:https://memorymanagement.org 上找到。如果你还对垃圾回收的理论还有什么不解,我推荐你阅读:《GC Handbook》,它可以解答你所有的疑问。
|
||||
|
||||
|
||||
|
||||
|
||||
346
专栏/TonyBai·Go语言第一课/大咖助阵海纳:聊聊语言中的类型系统与泛型.md
Normal file
346
专栏/TonyBai·Go语言第一课/大咖助阵海纳:聊聊语言中的类型系统与泛型.md
Normal file
@@ -0,0 +1,346 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
大咖助阵 海纳:聊聊语言中的类型系统与泛型
|
||||
你好,我是海纳,是极客时间《编程高手必学的内存知识》专栏的作者。
|
||||
|
||||
我们知道,编程语言中有非常重要的一个概念,就是数据类型。类型的概念伴随着我们学习一门具体语言的全过程,也深入到了程序员的日常开发之中。所以对于现代程序员而言,了解语言中的类型系统是一项非常重要的技能。
|
||||
|
||||
这一节课,我会简单地介绍什么是类型,类型的作用,以及由简单类型推导来的泛型编程的基本概念,接着再比较C++和Java两种语言的泛型实现。很多新的编程语言的泛型实现都有它们的影子,所以了解C++和Java泛型,会有助于你理解泛型设计的基本概念。
|
||||
|
||||
通过这节课的学习,你会得到一种新的学习语言的视角,那就是从类型的角度去进行分析。
|
||||
|
||||
比如我们在学习一门新的语言的时候,可以考虑以下几个问题:
|
||||
|
||||
|
||||
这门语言是强类型的吗?
|
||||
这门语言是动态类型吗?
|
||||
它支持多少种内建类型呢?
|
||||
它支持结构体吗?
|
||||
它支持字典(Recorder)吗?
|
||||
它支持泛型吗?
|
||||
……
|
||||
|
||||
|
||||
这样,当我们拿到一门新的语言的规范(Specification)文档后,就可以带着这些问题去文档中寻找答案。等你把这些问题搞明白了,语言的很多特性也就掌握了。这是很多优秀程序员可以短时间内掌握一门新语言的秘技之一。
|
||||
|
||||
接下来,我们就从类型的基本概念开始讲起。
|
||||
|
||||
什么是类型
|
||||
|
||||
编程语言中的变量都是有类型的,而且变量的类型不一定一致。例如Go语言中的int和float声明的变量,它们的类型就不一致,如果你直接对它们执行加操作,Go的编译器就会报错,很多隐式类型转换带来的问题,在编译阶段就可以发现了。比如,你可以看下面这个例子:
|
||||
|
||||
func main() {
|
||||
var a int = 1
|
||||
var b float64 = 1000.0
|
||||
fmt.Print(a + b)
|
||||
}
|
||||
|
||||
|
||||
这种情况下,Go的编译器会报这样的错误:invalid operation: a + b (mismatched types int and float64)。这就说明Go语言不支持整型和浮点型变量的加操作。
|
||||
|
||||
相比Go语言,JavaScript在类型上的要求就宽松很多,比如整数与字符串的加法操作,JavaScript会把整数转换成字符串,然后再与目标字符串进行拼接操作。显然Go语言会对语言类型进行严格检查,我们就说它的类型强度高于JavaScript。
|
||||
|
||||
Go语言的类型系统还有一个特点,那就是一个变量声明成什么类型的,就不能再更改了。与之形成鲜明对比的是Python。它们都具有比较高的类型强度,但是类型检查的时机不同。Go是在编译期,而Python则是在运行期。我们看一个Python的例子:
|
||||
|
||||
>>> a = 1
|
||||
>>> a + "hello"
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
TypeError: unsupported operand type(s) for +: 'int' and 'str'
|
||||
>>> a = "hello"
|
||||
>>> a + "world"
|
||||
'helloworld'
|
||||
|
||||
|
||||
从上面的例子中,我们可以看到,Python的类型检查也是比较严格的,在第2行,将一个整型值和字符串值相加是会引发Traceback的。但是一个变量却可以先使用整数为它赋值,再使用字符串为它赋值(第6行),然后再进行字符串值的加操作就没有问题了。这就说明了变量的类型是在程序运行的时候才去检查,而不是编译期间进行的。我们把这种语言称为动态类型语言。
|
||||
|
||||
同样的例子如果使用Go来实现,编译器就会报错:
|
||||
|
||||
var a int = 1
|
||||
a = "hello"
|
||||
fmt.Print(a)
|
||||
|
||||
|
||||
由此可见,Go语言是一种静态的强类型语言。
|
||||
|
||||
动态类型不仅仅表现在变量的类型可以更改,在面向对象的编程语言中,动态类型往往还意味着类的定义也可以动态更改。我们仍然以Python为例来观察动态类型的特点:
|
||||
|
||||
>>> class A():
|
||||
... pass
|
||||
...
|
||||
>>> A.a = 1
|
||||
>>> a = A()
|
||||
>>> a.a
|
||||
1
|
||||
|
||||
|
||||
从上述例子中,我们可以看到,类A的类属性是可以在运行时进行添加和修改的。这与静态编译的语言非常不同。
|
||||
|
||||
由此,我们可以得出结论,动态类型相比静态类型,它的优点在于:
|
||||
|
||||
|
||||
动态类型有更好的灵活性,在运行时可以修改变量的类型,也可以对类定义进行修改,所以针对动态类型语言的热更新就更容易设计;
|
||||
动态类型语言写起来很方便,非常适合用来编写小规模的脚本。
|
||||
|
||||
|
||||
同时,动态类型也往往具有一些缺点(通常是这样,但并不绝对)。
|
||||
|
||||
首先,动态类型语言的代码不容易阅读。据统计,程序员的日常工作中90%的时间是在阅读别人写的代码,只有不足10%的时间才是在开发新的功能。而动态类型语言,没有类型标注,代码会非常难懂,即使有一些动态类型语言有类型标注的,但因为可以运行时修改类型,往往会出现一个类的属性在不同的地方被修改的情况,这使得代码的阅读和维护变得困难;
|
||||
|
||||
第二点是,动态类型语言的性能往往会差一些,比如Python和JavaScript,因为在编译期间缺少类型提示,编译器无法为对象安排合理的内存布局(你可以参考内存课导学三),所以它们的对象布局相比Java/C++等静态类型语言会更加复杂,同时这也会带来性能的下降。
|
||||
|
||||
由此可见,我们并不能简单地说,静态类型就比动态类型好,或者强类型就比弱类型好,还是要根据具体的场景来进行取舍。
|
||||
|
||||
比如要求快速开发,规模较小的工具,人们常常会选择使用Python,而多人合作的大型项目,人们就会选择使用Java之类的静态强类型语言。
|
||||
|
||||
另外,类似int、 String这种类型往往是语言的内建类型,而语言的内建类型在表达力上经常是不够的,这就需要人们通过将简单内建类型组合起来实现相应的功能,这就是复合类型。
|
||||
|
||||
典型的复合类型包括枚举、结构、列表、字典等。这些类型在Go语言中都有相应的定义,你可以参考Go语言专栏进行学习。
|
||||
|
||||
在讲完了类型的基本概念以后,我们再讲解一个类型系统中非常常见,同时也是比较困难的一个话题,那就是泛型。
|
||||
|
||||
为什么要使用泛型
|
||||
|
||||
我们使用一个实际的例子来讲一下为什么要使用泛型。比如,这里有一个栈的C++实现,栈里可以存放的变量是整型的,它的代码如下所示:
|
||||
|
||||
#include <iostream>
|
||||
using namespace std;
|
||||
|
||||
class Stack {
|
||||
private:
|
||||
int _size;
|
||||
int _top;
|
||||
int* _array;
|
||||
|
||||
public:
|
||||
Stack(int n) {
|
||||
_size = n;
|
||||
_top = 0;
|
||||
_array = new int[_size];
|
||||
}
|
||||
|
||||
void push(int t) {
|
||||
if (_top < _size) {
|
||||
_array[_top++] = t;
|
||||
}
|
||||
}
|
||||
|
||||
int pop() {
|
||||
if (_top > 0) {
|
||||
return _array[--_top];
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
int main() {
|
||||
Stack stack(3);
|
||||
stack.push(1);
|
||||
stack.push(2);
|
||||
cout << stack.pop() + stack.pop() << endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
运行这个程序,一切看上去都还不错。但是,假如我们需要一个管理浮点数栈,或者管理字符串的栈,就不得不再将上述逻辑重新实现一遍。除了_array的类型不一样之外,整数栈和浮点数栈的逻辑都是相同的。这就会带来大量的重复代码,不利于工程代码的维护。
|
||||
|
||||
为了解决这个问题,很多带有类型的语言都引入了泛型。以C++为例,泛型的栈可以这么实现:
|
||||
|
||||
#include <iostream>
|
||||
using namespace std;
|
||||
|
||||
template <typename T>
|
||||
class Stack {
|
||||
private:
|
||||
int _size;
|
||||
int _top;
|
||||
T* _array;
|
||||
|
||||
public:
|
||||
Stack(int n) {
|
||||
_size = n;
|
||||
_top = 0;
|
||||
_array = new T[_size];
|
||||
}
|
||||
|
||||
void push(T t) {
|
||||
if (_top < _size) {
|
||||
_array[_top++] = t;
|
||||
}
|
||||
}
|
||||
|
||||
T pop() {
|
||||
if (_top > 0) {
|
||||
return _array[--_top];
|
||||
}
|
||||
return T();
|
||||
}
|
||||
};
|
||||
|
||||
int main() {
|
||||
Stack<int> stack(3);
|
||||
stack.push(1);
|
||||
stack.push(2);
|
||||
cout << stack.pop() + stack.pop() << endl;
|
||||
Stack<string> sstack(3);
|
||||
sstack.push("hello ");
|
||||
sstack.push("world!");
|
||||
cout << sstack.pop() + sstack.pop() << endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
执行这段代码,可以看到,控制台上可以成功打印出来3和”hello world”。
|
||||
|
||||
这段代码的巧妙之处在于,栈的核心逻辑,我们只写了一遍(第4行至第30行),然后只需要使用一行简单的代码就可以创建用于存储整数的栈(第33行)和用于存储字符串的栈(第37行)。
|
||||
|
||||
使用这种方式,可以帮助我们节约大量的时间和代码篇幅。接下来,我们看一下C++编译器是如何处理这段代码的。在Linux系统上,我们可以使用以下命令对这个文件进行编译,然后查看它的编译结果:
|
||||
|
||||
$ g++ -o stack -g stack.cpp
|
||||
$ objdump -d stack
|
||||
|
||||
|
||||
从这个结果中,可以看到,C++编译器生成了两个push方法,其参数类型分别是整型和字符串类型。也就是说,在C++中,泛型类型在被翻译成机器码的时候,是真的创建了两种不同的类型。
|
||||
|
||||
泛型使用最广泛的场景就是容量类,例如vector、list、map等等。C++ STL中定义的容器类都是以模板的形式提供的。
|
||||
|
||||
我们可以再使用一种新的视角来理解泛型,那就是可以将泛型声明看作是类型之间的转换关系,或者换种说法,就是我们可以使用一种类型(甚至是值)得到另外一种新的类型。
|
||||
|
||||
泛型:使用类型得到新的类型
|
||||
|
||||
现在我们就用这个新视角来理解泛型,把泛型声明看成是一个输入参数是类型,返回值也为类型的函数。我使用一个vector的例子来说明这一点:
|
||||
|
||||
int main() {
|
||||
vector<int> vi;
|
||||
vector<double> vd;
|
||||
vector* p = &v1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
这里程序的第4行会报错,报错的信息显示“vector”并不是一个有效的类型。而“vector ”和“vector”则是有效的类型。从这个例子中,我们观察到,vector类型必须指定一个类型参数才能变成一个有效的类型。所以我们可以把
|
||||
|
||||
template <typename T> class vector;
|
||||
|
||||
|
||||
看成是一个函数,它接受一个类型int或者double,得到一种新的类型vector,或者vector。
|
||||
|
||||
在C++中,更神奇的是,泛型的类型参数不仅仅可以是一种类型,还可以是一个具体的值,例如:
|
||||
|
||||
template <int n> class A;
|
||||
|
||||
int main() {
|
||||
A<0> a;
|
||||
A<1> b;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
在上述代码中,A和A分别是两个不同的类型。使用这种办法,我们可以在编译期间通过模板让编译器帮我们做一些计算,例如:
|
||||
|
||||
#include <iostream>
|
||||
using namespace std;
|
||||
|
||||
template <int n>
|
||||
struct fib {
|
||||
static const int v = fib<n-2>::v + fib<n-1>::v;
|
||||
};
|
||||
|
||||
template <>
|
||||
struct fib<1> {
|
||||
static const int v = 1;
|
||||
};
|
||||
|
||||
template <>
|
||||
struct fib<0> {
|
||||
static const int v = 1;
|
||||
};
|
||||
|
||||
int main() {
|
||||
cout << fib<10>::v << endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,编译以后的结果,fib::v会被直接替换成55。这个计算的过程是由编译器完成的。
|
||||
|
||||
编译器会把fib,fib等等都看成一种类型。当编译器要计算fib的值的时候就会先求解fib和fib的值,这样一直递归下去,就会找到fib和fib这里。而这两个值,我们已经提供了(第9行到第18行),递归就会结束。
|
||||
|
||||
在这个例子中,我们就看到了类型依赖于值的情况。
|
||||
|
||||
了解了C++的泛型设计以后,我们再来看一下Java语言的泛型实现。
|
||||
|
||||
Java中的泛型实现
|
||||
|
||||
Java语言的库的分发,往往采用这种形式:Java的源代码会先被翻译成字节码文件,然后这些文件又会被打包进jar文件。jar文件可以在网络上进行发布。
|
||||
|
||||
Java的一个特性是,相同的字节码文件在不同的体系结构和平台上的行为都是相同的,再加上要做到对低版本代码的兼容,所以Java的泛型设计和C++的差异就很大。总的来说,Java的泛型设计是使用了一种叫做“泛型擦除”的办法来实现的。
|
||||
|
||||
我举一个例子来说明“泛型擦除”是怎么一回事。请看下面的代码:
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
class Playground {
|
||||
public static void main(String[ ] args) {
|
||||
ArrayList<Integer> int_list = new ArrayList<Integer>();
|
||||
ArrayList<String> str_list = new ArrayList<String>();
|
||||
System.out.println(int_list.getClass() == str_list.getClass());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这段代码的输出是true。
|
||||
|
||||
如果按照上一小节中关于C++泛型的实现,ArrayList和ArrayList应该是不同的两种类型。但这里的结果却是true,这是因为Java会把这两种ArrayList的泛型都擦除掉,从而导致整个程序中只有一种类型。
|
||||
|
||||
我们这里再举一个例子帮你理解一下Java的泛型:
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
class Playground {
|
||||
public static void main(String[ ] args) {
|
||||
System.out.println("Hello World");
|
||||
}
|
||||
|
||||
public static void sayHello(ArrayList<String> list) {
|
||||
|
||||
}
|
||||
|
||||
public static void sayHello(ArrayList<Integer> list) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里,第8行定义的sayHello方法和第12行定义的sayHello方法是方法重载。我们知道,方法的重载的基本条件是两个同名方法的参数列表并不相同。
|
||||
|
||||
从字面上看,第一个sayHello方法的参数类型是ArrayList,第二个方法的参数类型是ArrayList,所以可以实现方法的重载。但是当我们尝试编译上述程序的时候,却会得到这样的错误提示:
|
||||
|
||||
Playground.java:12: error: name clash: sayHello(ArrayList<Integer>) and sayHello(ArrayList<String>) have the same erasure
|
||||
public static void sayHello(ArrayList<Integer> list) {
|
||||
^
|
||||
1 error
|
||||
|
||||
|
||||
这是因为当对泛型进行擦除以后,两个sayHello方法的参数类型都变成了ArrayList,从而变成了同名方法,所以就会出现命名冲突报错。
|
||||
|
||||
通过上面两个例子,我们就能感觉到C++泛型和Java泛型的不同之处了。它们之间最核心的区别是C++不同的泛型参数会得到一种新的类型;而Java则不会,它会进行类型擦除,从而导致表面上不同的类型参数实际上指代的是同一种类型。
|
||||
|
||||
总结
|
||||
|
||||
在这节课里,我们先了解到什么是类型系统,并介绍了什么是强类型和弱类型,什么是静态类型和动态类型。然后我们通过举例来说明Python,JavaScript,Go和C++各自的类型系统的特点。
|
||||
|
||||
从这些例子中,我们看到静态强类型语言更容易阅读和维护,但灵活性不如动态弱类型语言。所以动态弱类型语言往往都是脚本语言,不太适合构建大型程序。
|
||||
|
||||
接下来,我们简单介绍了泛型的概念。我们使用了一个栈的例子来说明了使用泛型可以提高编程效率,节省代码量。Go语言从1.18开始也支持泛型编程。
|
||||
|
||||
然后我们又提供了一个新的视角来理解泛型,这种新的视角是把泛型类看成是一种函数,它的输入参数可以是类型,也可以是值,它的返回值是一种新的类型。
|
||||
|
||||
最后,我们介绍了C++的泛型实现和Java的泛型实现。C++不同的泛型参数会得到一种新的类型,这个过程我们也会称它为泛型的实例化。而Java则会进行类型擦除,从而导致表面上不同的类型参数实际上指代的是同一种类型。
|
||||
|
||||
|
||||
|
||||
|
||||
19
专栏/TonyBai·Go语言第一课/期中测试一起检验下你的学习成果吧.md
Normal file
19
专栏/TonyBai·Go语言第一课/期中测试一起检验下你的学习成果吧.md
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
期中测试 一起检验下你的学习成果吧
|
||||
你好,我是Tony Bai。
|
||||
|
||||
不知不觉间,我们的课程已经更新过半了。不知道你学习得怎么样呀?不如做套题来检验一下吧?
|
||||
|
||||
这次期中测试,我根据我们前面讲过的知识,给你出了 20 道选择题,考试范围截止到函数之前的内容,你可以检验一下自己的学习成果。如果你有什么不理解的地方,欢迎在留言区留言。
|
||||
|
||||
快点击下面的按钮开始测试吧,我期待着你满分的好消息。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
86
专栏/TonyBai·Go语言第一课/用户故事罗杰:我的Go语言学习之路.md
Normal file
86
专栏/TonyBai·Go语言第一课/用户故事罗杰:我的Go语言学习之路.md
Normal file
@@ -0,0 +1,86 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
用户故事 罗杰:我的Go语言学习之路
|
||||
你好,我是罗杰,目前在一家游戏公司担任后端开发主程。今天,我想跟你分享一下我学习Go的一些经历,如果你还是一个Go新人,希望我的这些经历,能给你带来一些启发和帮助。
|
||||
|
||||
说起来,我接触Go语言已经很久了,但前面好多次都没真正学起来。
|
||||
|
||||
我第一次接触 Go 语言是在 2010 年,当时我还在读大二,一个学长建议我了解一下 Go 语言,毕竟是谷歌出的一门语言,可能未来比较有发展前景。所以我当时下载并安装了Go的开发环境,还写了个 “hello world”,但是由于没有中文的教程,也没有人一起学习,学习 Go 语言这件事情很快就被我抛在脑后了。
|
||||
|
||||
我第二次接触Go是在 2015 年。当时我跟在豆瓣工作的发小聊天,我说最近想学 Python,他却坚定地告诉我学 Go。因为他们团队无法忍受 Python 总是半夜异常导致全站挂掉,正在往 Go 迁移。但我当时并没有给予足够的重视,没有学半个月又去玩了。
|
||||
|
||||
第三次接触Go语言是在 2017 年。我当时的技术栈只有 C++,对于 Web 服务这块,几乎没有任何经验,而且我们组的其他成员也没有相关经验。我就在想,总不能 Web 服务这一块总是向其他项目组“借”个 PHP 的同学过来协助吧?
|
||||
|
||||
于是,我开始再次尝试学习使用 Go 语言,并且因为一篇《Go语言TCP Socket编程》的博客认识了 Tony Bai 老师。当时我考虑把原本用 C++ 写的游戏服务改成用 Go 来实现,但当时能用中文搜索到的与 TCP 网络编程的文档非常有限,而Tony Bai 老师的文章写得非常详细,使我对 Go 是如何做这一部分有了基本的了解。
|
||||
|
||||
这一年,我开始尝试使用 Go 语言写一些小的 demo,例如操作数据库,以及Redis 和 Protobuf 相关等在 C++ 中要必须使用到的组件。
|
||||
|
||||
2018 年,在 Go 的实践上,我迈出了更大一步。我开始在新的项目上尝试使用 Go 语言完成了一些简单的功能,例如游戏版本控制,后台管理服务。但因为我从来没有深入学习过 Go 语言,在完成这些功能时,踩过好多坑。 比如,我当时开发管理后台,在谷歌上搜索要不要使用框架,就被某个知乎的言论坑得很惨。大意是 Go 已经非常简单了,没有必要使用框架。结果,我用 http 库辛苦搞了一阵后,才发现几乎没有人这么干,都是基于 gin 开发。这个时候,我意识到我必须要认真学习下Go语言了。
|
||||
|
||||
好在,当时的相关学习资料已经比较丰富了,我在慕课网学习了一遍 ccmouse 老师的视频课,算是正式入门了 Go 语言。期间,我因为 Web 后台需要,在多看阅读上购买了《Go Web编程》,不过到现在我都没有认真把这本书读完,基本上是把它当工具书,遇到问题,查查看里面有什么解决方案。
|
||||
|
||||
2019 年,我的项目上线了,前面我用 Go 写的两个服务的重要性就体现出来了,版本控制负责了游戏登录前的工作以及所有的平台方充值,管理后台则是运营人员最主要的使用工具。要知道 Web 这一块,我之前想都不敢想,但是现在我竟然做出来了,上线之后,稳定性也超出预期。接下来基本上一整年的时间,我都在不停地重构与维护这两个服务,期间还由于涉及前端页面的东西,在 B 站学了不少 HTML/CSS,JavaScript 的课程来配合业务方完成相应的功能。
|
||||
|
||||
但通过一年时间的修修补补,我意识到,我基本上还不能算入门 Go 语言,因为稍微高级一点的功能我都不会,也几乎没有深入到源码中去研究这些功能是如何实现的。
|
||||
|
||||
2020 年以疫情开始,在被 Go 语言折磨了近一年之后,我终于下定决心要深入 Go 语言了。当时我因为《深度解密Go语言之slice》这一篇文章认识了饶大,也关注了一些“Go 夜读”的成员,以及 draveness,这些大佬们对我触动非常大。
|
||||
|
||||
看了这么多优秀博主的博客之后,我之前的恐惧都没有了,因为他们把底层的源码都翻了出来,努力解除我们的困惑,也让我更有信心在工作中使用 Go 语言。
|
||||
|
||||
不过,虽然有优秀的博客,但是我们学习一定不能只依靠零散的博客,而要成体系地学习。后来,我在慕课网上学习了 Tony Bai 老师的 《改善Go语言编程质量的50个有效实践》 。年底的时候,无意中又阅读了COLLSHELL 《Go 编程模式》的系列博客,感觉对 Go 的理解又上了一个层次,作为回报,我在极客时间订阅了作者的专栏《左耳听风》。
|
||||
|
||||
通过近一年的 Go 的学习与积累,我应该可以算是熟练使用 Go 语言了,我开始尝试阅读一些源码,并且使用了 go-micro 作为框架层完成新游戏的开发。而且,使用 Go 开发的效率显然比 C++ 要高出不少,因为我们后端就只有两个人。
|
||||
|
||||
2021 年,学习的脚步依旧不能停止。这一年里,我最大的收获就是如何正确地学习。如今信息真是满天飞,各种各样的 APP 都想把各种内容塞给我们。而我们的大脑逐渐进入到了“看一遍就当记住了,收藏了就是掌握了”的状态当中。
|
||||
|
||||
我经常看了一个小时的手机,回头一想,刚才看了个什么,好像啥也没记住。这就是因为学习的方法错了,我发现学东西真的不能看一遍就了事。《论语》第一句就是:“学而时习之,不亦说乎”,为政篇更是提到“温故而知新,可以为师矣”。
|
||||
|
||||
除了勤奋之外,我们还应该明白学习要抓住核心、抓住本质。什么叫“书读千遍,其义自见”?就是说学习一定不是学得越多越好,而应该抓住本质。我最近很喜欢一部电影,叫《银河补习班》,电影里的爸爸把除课本以外的书籍都扔掉,发现所有课本才十一厘米。
|
||||
|
||||
|
||||
|
||||
关于更具体的学习方法,我推荐陈天老师的《如何学习一门技术?》,你可以学习一下,里面分享了非常多的干货,而且生动有趣。毕竟,在了解了大神是如何学习之后,我们才有可能成为一个大神。陈天老师在极客时间也有专栏《陈天 · Rust 编程第一课》,如果你有兴趣也可以了解一下。
|
||||
|
||||
从今年八月份起,我每周在极客时间App 上学习的时间都超过了十个小时,算是非常活跃的用户了。
|
||||
|
||||
当我在推广页看到Tony Bai 老师的课的那一瞬间,我就购买了。因为从老师的博客和之前的专栏上,我确实学到很多在其它地方学习不到的内容。我相信你在学这个专栏的时候应该也有体会,老师会不停地强调 Go 语言的设计哲学,更会直达Go语言的本质。
|
||||
|
||||
虽然我用 Go 语言做开发的时间已经超过三年了,但依然从这个专栏上学到许多非常实用的技巧,弥补了之前遗漏的很多知识点。我在其中一讲留言说:老师在我心目中就是 “Go 语言百科全书”,这句话真的是我对老师发自内心的敬佩 。
|
||||
|
||||
洋洋洒洒写了这么多,我觉得我在学习、提高的时候走了很多弯路,从一开始的学习方法就是错误的:
|
||||
|
||||
|
||||
首先是没有主动性。如果 2010 年我就能坚持去阅读英文的文档,深入去学习 Go 语言,可能现在我也能跟这些大佬一样,写出优秀的博客来帮助其他人;
|
||||
其次是懒得学习。2013 年,我是在 COLLSHELL 中学习Vim,但是我没有再关注过博主的其它文章,其实当时博主很多关注 C++ 的文章,写得也非常优秀。如果我能更主动一些,就能发现更多的“宝藏”;
|
||||
最后是方法错误。除了 Go 之外,我还学过 Python 三次,而且每次都完整地看完了一本书,或者学习了完整的视频,但是至今我也无法很快写出 Python 代码,因为我从来都没有实践过。
|
||||
|
||||
|
||||
所以,我想跟你说的是,如果你想让自己的学习更有收获、少走弯路,我建议你多注意一下学习方法。你可以了解一下两个重要理论。首先是学习金字塔和费曼学习法,从这里我们可以知道,通过听讲与阅读知识的留存率最多只有 10%。第二个是艾宾浩斯遗忘曲线,从这里我们还可以知道如果不复习,第二天我们学到的知识只剩下了 5%。久而久之,我们就会对学习失去了兴趣。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
那我们可以怎么结合这两个理论,提升自己的学习效率/能力呢?
|
||||
|
||||
我跟你分享下我现在学习专栏课的方法吧:
|
||||
|
||||
|
||||
第一天仔细阅读一遍(如果有不理解的,我可能会多读几遍);
|
||||
第二天复习一遍,如果本周内时间充足,一定要做笔记;
|
||||
如果有实战的内容,抽出时间写代码(如果没有时间,加到待办任务中,等有时间一定要做一遍);
|
||||
如果时间允许,最好有一个月内再复习一次,这样才能有效地抵御遗忘曲线。
|
||||
|
||||
|
||||
除此之外,我还会将学到的知识按照我的理解给同事讲解,一定要用心讲解,因为只有教会别人,才可能是自己真正掌握的时候。同时,我也会在工作中践行学习到的内容,比如最近我也在学习《设计模式之美》专栏,发现只有把学到的思想应用在平时代码中,你的学习才会有明显的效果。要是你没有能讲解的对象,也无法立刻在工作中使用,我想写博客应该也是个不错的选择,总之有输入必须要有输出。
|
||||
|
||||
最后我想说的是,学习真的要认真对待,我建议你养成做笔记的好习惯。不管是看专栏、读书,还是阅读微信公众号,看 B站的视频,我们都可以将学到的东西记录下来,时常回顾。让学到的知识不轻易流失,让要学习的内容越来越少,我们才会觉得越来越轻松。
|
||||
|
||||
希望所有人都能学会正确的学习方法,坚持终身学习的理念,让自己变得越来越好。
|
||||
|
||||
|
||||
|
||||
|
||||
108
专栏/TonyBai·Go语言第一课/结束语和你一起迎接Go的黄金十年.md
Normal file
108
专栏/TonyBai·Go语言第一课/结束语和你一起迎接Go的黄金十年.md
Normal file
@@ -0,0 +1,108 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 和你一起迎接Go的黄金十年
|
||||
你好,我是Tony Bai。
|
||||
|
||||
在虎年春节营造的欢乐祥和的气氛中,我们迎来了这个专栏的最后一节课。
|
||||
|
||||
这个专栏的撰写开始于2021年5月中旬,我使用GitHub仓库管理专栏文稿,这是我的第一次提交:
|
||||
|
||||
|
||||
|
||||
从那时开始,我便进入了专栏写作的节奏。从2021年5月到2022年2月,9个月的时间,我洋洋洒洒地写下了20多万字(估计值),写作过程的艰辛依然历历在目。
|
||||
|
||||
你见过凌晨4点你所居住的城市吗?我每周至少要见三次。你每天能睡多长时间呢?4~5个小时是我的常态。在日常工作繁忙,家里带俩娃的背景下,这算是挑战我自己的极限了!但当我看到有那么多订阅学习专栏、认真完成课后思考题以及在留言区留言的同学,我又顿感自己的付出没有白费,我的自我挑战是成功的。
|
||||
|
||||
“老师,你跑题了!”
|
||||
|
||||
不好意思,小小感慨了一下。我们还是回到结束语上来。我想了很久,在这最后一讲里,还能给你说点什么呢?人生大道理?职涯规划建议?轻松的、搞笑的段子?可惜这些我都不擅长。最后,我决定借着这篇结束语,和你聊聊下面这几件事,请耐心地听我道来。
|
||||
|
||||
专栏回顾与“与时俱进”
|
||||
|
||||
在下笔写这篇文章之前,我认真回顾了一下这门课的内容,对照着Go语言规范细数了一下,这门课覆盖了绝大多数Go语言的语法点,这为你建立Go语言的整体知识脉络,后续继续深入学习奠定了基础。这也是我在设计这门课的大纲时的一个基本目标,现在这个目标算是实现了。
|
||||
|
||||
而且,从同学们的留言反馈情况来看,彻底抛弃GOPATH,把Go Module构建模式、Go项目布局的讲解前置到入门篇中,是无比正确的决定。相信你在学完这些知识点后,即便遇到规模再大的Go项目,也能“庖丁解牛”,快速掌握项目的结构,并知道从何处开始阅读和理解代码,这为你“尽早地动手实践”提供了方便。
|
||||
|
||||
另外,这个专栏对一些语法概念,比如切片、字符串、map、接口类型等进行了超出入门范畴的原理性讲解,也得到了来自同学们的肯定,这也算是这个入门课的吸睛之处。
|
||||
|
||||
不过课程依然存在遗憾,其中最令我不安的就是对“指针”这个概念的讲解的缺失。在规划课程之时,我没有意识到,很多来自动态语言的同学完全没有对“指针”这个概念的认知,我的这个疏忽给一些同学的后续学习带来了困惑。为了弥补这个遗憾,我会在后面以加餐的形式补充对Go指针的基础讲解。
|
||||
|
||||
按理说,写完这一讲后,我们的这个专栏就正式结束了。但我们都知道,即将发布的Go 1.18版本将加入泛型语法特性,对于定位为“Go语言第一课”的本专栏来说,对泛型语法的系统讲解肯定是不能缺少的,并且,Go泛型很可能会是Go语法特性的最后一次较大更新了。
|
||||
|
||||
虽然我们已经通过加餐聊过泛型了,但那些还是比较粗线条的,所以在2022年Go 1.18泛型正式发布后,我会补充泛型篇,通过大约3节课给你系统、全面地介绍Go泛型语法的细节。我们的专栏也要做到“与时俱进”!
|
||||
|
||||
接下来,我该学点啥?怎么学?
|
||||
|
||||
就像这一讲的头图所写的那样,这节课的结束不是你Go语言学习的终点,而是你深入和实践Go的起点。专栏的留言区也有同学在问:Go应该如何进阶呢?进阶的话我该学点啥呢?
|
||||
|
||||
这里我借用“T字形”发展模式,按语言深度与工程宽度两个方向,在一幅图中列出Go进阶需要了解的知识与技能点:
|
||||
|
||||
|
||||
|
||||
沿着“语言深度”这条线我们看到,在纯语言层面的进阶,我们要学习和理解的知识点还有很多,包括这个专栏没有包含的反射(reflect)、cgo(与C语言交互的手段)、unsafe编程等高级语法点,还有迈向Go高级程序员必要的Go编译器原理、Go汇编、Goroutine调度、Go内存分配以及GC等的实现细节。
|
||||
|
||||
当你掌握这些之后,你就会有一种打通“任督二脉”的感觉,再难的Go语言问题在你面前也会变得简单透明。更重要的是,这会让你拥有一种判断力,可以判断在什么场合不应该使用Go语言。《Kubernetes Up&Running》一书的作者、Google开发人员凯尔西·海托(Kelsey Hightower)曾说过:“如果你不知道什么时候不应该使用一种工具,那你就还没有掌握这种工具”。拥有这种判断力,也代表你真正掌握了Go语言。
|
||||
|
||||
当然,Go语言的进阶同样也离不开工程层面的知识与技能的学习。在上面图中,我将工程宽度分成两大块,一块是Go标准库与Go工具链,另外一块是语言之外的工程技能。这些知识与技能都是你在Go进阶以及Go实践之路上不可或缺的。
|
||||
|
||||
那么知道了学啥后,又该如何学呢?
|
||||
|
||||
其实,这个专栏中我一直强调的“手勤+脑勤”同样适合Go进阶的学习,多实践多思考是学习编程语言的不二法门。
|
||||
|
||||
此外,在进阶学习的过程中,我还要向你推荐一种学习方法,同时这也是我本人使用的方法,那就是“输出”。如果你对“输出”这个词还不太理解,那么你应该或多或少听说过“费曼学习法”吧?
|
||||
|
||||
费曼学习法是由诺贝尔物理学奖得主理查德·菲利普斯·费曼贡献给全世界的学习技巧。这个学习法中的一个环节就是以教促学,也就是学完一个知识点后,用你自己的理解将这个知识点讲给其它人,在这个过程中,你既可以检验自己对这个知识点的掌握程度,而且也可通过他人的反馈确认自己对这个知识点的理解是否正确。而这个学习技巧的本质就是“输出”。
|
||||
|
||||
在如今移动互联网的时代,“输出”拥有了更多样的形式,比如:
|
||||
|
||||
|
||||
学习笔记/博客/公众号/问答/视频直播/音频播客/社群;
|
||||
开源/内源项目;
|
||||
内部培训/外部技术大会;
|
||||
译书/著书。
|
||||
|
||||
|
||||
所有的这些形式都要遵循一个共同点:公开,也就是将你的“输出”公之于众,接受所有人的检验与评判。这个过程一旦正常运转起来,可以快速修正你理解上的错误,加深你的理解,加快你的学习,并会敦促你主动优化你后续的输出。形成了良性循环之后,再高深的知识点对你来说也就不是什么问题了。
|
||||
|
||||
不过古人云:“知易行难”,学会“输出”也需要一个循序渐进的过程。尤其是一开始“输出”时,不要怕错,不要怕没人看,更不要怕别人笑话你。
|
||||
|
||||
Go语言的未来
|
||||
|
||||
最后我们再来谈谈大家都关心的话题:Go语言的未来。
|
||||
|
||||
在我写这一讲的时候,刚刚好著名编程语言排名指数TIOBE发布了2022年2月编程语言排名情况,如下图:
|
||||
|
||||
|
||||
|
||||
在这期排名中,Go上升到第11位,相较于2021年年底各大编程语言的最终排名,以及2021年2月份的排名都上升了2位。Go语言位次的提升在我的预料之中。TIOBE在1月份发布的2021年年终编程语言排行榜的配文中也认为,除了Swift和Go之外,尚不会有新的编程语言能迅速进入前3名甚至前5名,这也在一定程度上证明了TIOBE对Go发展趋势的看好。
|
||||
|
||||
再老生常谈一下,纵观近十年来的新兴后端编程语言,Go集齐了成为下一代佼佼者需要的所有要素:名家设计(三巨头)、出身豪门(谷歌)、杀手应用(Kubernetes)、精英团队(Google专职开发团队)、百万拥趸、生产力与性能的最佳结合,以及云原生基础设施的头部语言。
|
||||
|
||||
在2021年,为了加强Go社区建设与Go官网改进,Go团队雇佣了专人负责。Go核心开发团队专职人员的数量逐年增多,根据Go核心团队工程总监萨梅尔-阿马尼(SAMEER AJMANI)在之前Go Time的AMA环节中透露的信息,当前Go核心团队的规模已经达到了50人:
|
||||
|
||||
|
||||
|
||||
而且,Go语言在国内的发展也是越来越好。大厂方面,腾讯公司近几年在Go语言方面投入很大,不仅让Go语言成为其公司内部增速最快的语言,腾讯还在2021年发布和开源了多款基于Go开发的重量级产品。
|
||||
|
||||
字节跳动更是国内大厂中拥抱Go语言最积极的公司之一,它的技术体系就是以Go语言为主,公司里有超过55%的服务都是采用Go语言开发的。长期的Go实践让字节跳动内部积累了丰富的Go产品和经验,2021年字节也开启了对外开源之路,并且一次性放出了若干个基于Go的微服务框架与中间件产品,包括kitex、netpoll、thriftgo等。这些开源项目统一放在https://github.com/cloudwego下面了。
|
||||
|
||||
除了大厂积极拥抱Go之外,小公司与初创公司也在积极探索Go的落地。根据我从圈子里、周边朋友、面试时了解的情况,用Go的小公司/初创公司越来越多了。究其原因还是那句话:Go语言是生产力与战斗力的最佳结合。这对小公司/初创公司而言,就是真(省)金(人)白(省)银(机器)啊。 甚至,Go已经渗透到新冠防疫领域,我前不久得知,河北移动支撑的新冠疫情流调系统的后端服务也是用Go实现的。
|
||||
|
||||
2022年,Go语言的最大事件就是3月份Go 1.18的发布以及Go泛型的落地。泛型的加入势必会给Go社区带来巨大影响。随之而来的将是位于各个层次的Go包的重写或重构:底层库、中间件、数据结构/算法库,乃至业务层面。这一轮之后,Go社区将诞生有关于Go泛型编码的最佳实践,这些实践也会反过来为Go核心团队提供Go泛型演化与在标准库中应用的素材。
|
||||
|
||||
在我们专栏的第一讲“前世今生:你不得不了解的Go的历史和现状”中,我曾提到过:绝大多数主流编程语言将在其诞生后的第15至第20年间大步前进。按照这个编程语言的一般规律,已经迈过开源第12个年头的Go,很可能将进入自己的黄金5-10年。而2022年就很大可能会成为Go语言黄金5-10年的起点,并且这个标志只能是Go泛型语法的落地。
|
||||
|
||||
按照Go语言的调性,在加入泛型后,Go在语法层面上很难再有大的改变了,错误处理将是最后一个硬骨头,也许在泛型引入后,Go核心团队能有新的解决思路。剩下的就是对Go编译器、运行时层、标准库以及工具链的不断打磨与优化了。到时候,我们就坐收这些优化所带来的红利就可以了。
|
||||
|
||||
经过这些对Go语言当前状态和未来可能演化路线的分析,你是不是对Go的未来更加有信心了呢?
|
||||
|
||||
学习Go语言十余年的我,很庆幸,也很骄傲当初做出了正确的选择。最后,在Go即将迎来黄金十年的历史时刻,希望你能在Go语言之路上走的更远,并实现你的个人价值。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
27
专栏/TonyBai·Go语言第一课/结课测试快来检验下你的学习成果吧!.md
Normal file
27
专栏/TonyBai·Go语言第一课/结课测试快来检验下你的学习成果吧!.md
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结课测试 快来检验下你的学习成果吧!
|
||||
你好,我是Tony Bai。
|
||||
|
||||
马上就是春节了,祝你新年快乐,万事顺意!到这里呢,我们专栏的正文就已经全部更新完毕了,只剩下最后的几篇加餐了,感谢你一直以来的支持和努力。
|
||||
|
||||
正因为如此,而且又恰逢春节假期,我决定把结课测试题的更新提前,你可以借着假期的时间,检验自己的学习成果,查缺补漏。剩下的加餐,我们依然会按正常的更新节奏进行更新。
|
||||
|
||||
而且,春节期间我们也准备了“春节特别放送”计划,除了今天的结课测试外,还有三篇重磅加餐:
|
||||
|
||||
|
||||
2月2日零点(初二),“大咖助阵”:分布式云存储工程师徐祥曦分享他与Go的二三趣事;
|
||||
2月4日零点(初四):“大咖助阵”:《Go语言高级编程》作者曹春晖“曹大”,带你分析Go语言的垃圾回收(GC)机制;
|
||||
2月7日零点(初七):Go Module 还没看过瘾?那这次,我会从Go Module的作者或维护者的角度,聊聊我们在规划、发布和维护Go Module时,需要考虑和注意什么。
|
||||
|
||||
|
||||
希望你在这个春节假期,玩得开心,学得愉快!如果你有什么不太清楚的地方,欢迎在留言区留言。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user