first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View 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语言的学习之旅吧。

View File

@@ -0,0 +1,133 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 前世今生你不得不了解的Go的历史和现状
你好我是Tony Bai。
今天是我们的第一堂课。第一堂课的开场我要做的事很简单就想跟你聊一聊Go语言的前世今生。
我一直认为,当你开始接触一门新语言的时候,你一定要去了解它的历史和现状。因为这样,你才能建立起对这门语言的整体认知,了解它未来的走向。而且,也能建立你学习的“安全感”,相信它能够给你带来足够的价值和收益,更加坚定地学习下去。
所以在这一节课我就来跟你聊聊Go的前世今生讲清楚Go到底是一门怎么样的语言Go又是怎么诞生的它经历了怎样的历史演进它的现状和未来又会如何
无论后面你是否会选择学习Go语言无论你是否会真正成为一名Go程序员我都建议你先了解一下这些内容它会让你对编程语言的发展有更进一步的理解。
首先我们就来看看Go语言是怎么诞生的这可以让你真实地了解Go的诞生缘由、设计目标以及它究竟要解决哪些问题。
Go语言是怎样诞生的
Go语言的创始人有三位分别是图灵奖获得者、C语法联合发明人、Unix之父肯·汤普森Ken ThompsonPlan 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、链接器golgo的早期版本曾如此命名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语言也马上进入自己的黄金510年从前面的技术成熟度曲线分析也可以印证这一点Go已经重新回到“稳步爬升的光明期”。
对于开发人员来说Go语言学习的最佳时刻已经到来了
思考题
相较于传统的静态编译型编程语言如C、C++Go做出了哪些改进你可以思考一下欢迎在留言区留下你的答案。
感谢你和我一起学习也欢迎你把这节课分享给更多对Go语言感兴趣的朋友。我是Tony Bai我们下节课见。

View 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我们下节课见。

View 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、CentOSRedhat企业版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我们下节课见。

View 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 EnvironmentIDE那么就用你喜欢的IDE好了。如果你希望我给你推荐一些好用的IDE我建议你试试GoLand或Visual Studio Code简称VS Code。GoLand是知名IDE出品公司JetBrains针对Go语言推出的IDE产品也是目前市面上最好用的Go IDEVS Code则是微软开源的跨语言源码编辑器通过集成语言插件Go开发者可以使用Go官方维护的vscode-go插件可以让它变成类IDE的工具。
如果你有黑客情怀喜欢像黑客一样优雅高效地使用命令行那么像Vim、Emacs这样的基于终端的编辑器同样可以用于编写Go源码。以Vim为例结合vim-go、coc.nvim代码补全以及Go官方维护的gopls语言服务器你在编写Go代码时同样可以体会到“飞一般”的感觉。但在我们这门课中我们将尽量使用与编辑器或IDE无关的说明。
好,我们正式开始吧。
创建“helloworld”示例程序
在Go语言中编写一个可以打印出“helloworld”的示例程序我们只需要简单两步一是创建文件夹二是开始编写和运行。首先我们来创建一个文件夹存储编写的Go代码。
创建“helloworld”文件夹
通常来说Go不会限制我们存储代码的位置Go 1.11之前的版本另当别论)。但是针对我们这门课里的各种练习和项目,我还是建议你创建一个可以集合所有项目的根文件夹(比如:~/goprojects然后将我们这门课中所有的项目都放在里面。
现在你可以打开终端并输入相应命令来创建我们用于储存“helloworld”示例的文件夹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语言的世界
“helloworld”示例程序的结构
现在让我们回过头来仔细看看“helloworld”示例程序中到底发生了什么。第一个值得注意的部分是这个
package main
这一行代码定义了Go中的一个包package。包是Go语言的基本组成单元通常使用单个的小写单词命名一个Go程序本质上就是一组包的集合。所有Go代码都有自己隶属的包在这里我们的“helloworld”示例的所有代码都在一个名为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编译器会自动插入这些被省略的分号。
我们给上面的“helloworld”示例程序加上分号也是完全合法的是可以直接通过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源文件构建类似“helloworld”这样的示例程序那么简单。越贴近真实的生产环境也就意味着项目规模越大、协同人员越多项目的依赖和依赖的版本都会变得复杂。
那在我们更复杂的生产环境中go build命令也能圆满完成我们的编译任务吗我们现在就来探讨一下。
复杂项目下Go程序的编译是怎样的
我们还是直接上项目吧给go build 一个机会,看看它的复杂依赖管理到底怎么样。
现在我们创建一个新项目“hellomodule”在新项目中我们将使用两个第三方库zap和fasthttp给go build的构建过程增加一些难度。和“helloworld”示例一样我们通过下面命令创建“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服务当我们向它发起请求后这个服务会在终端标准输出上输出一段访问日志。
你会看到和“helloworld“相比这个示例显然要复杂许多。但不用担心你现在大可不必知道每行代码的功用你只需要我们在这个稍微有点复杂的示例中引入了两个第三方依赖库zap和fasthttp就可以了。
我们尝试一下使用编译“helloworld”的方法来编译“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对第三方依赖的全部信息。接下来我们就通过下面命令为“hellomodule”这个示例程序添加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 pathgithub.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我们下节课见。

View 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、app2cmd目录下的各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下面有三个modulemainmodule、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我们下节课见。

View 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")
}
你可以看到这段代码依赖了第三方包logruslogrus是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.5Go 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/modGo 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和BA和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.0B明明说只要求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我们下节课见。

View File

@@ -0,0 +1,273 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 构建模式Go Module的6类常规操作
你好我是Tony Bai。
通过上一节课的讲解我们掌握了Go Module构建模式的基本概念和工作原理也初步学会了如何通过go mod命令将一个Go项目转变为一个Go Module并通过Go Module构建模式进行构建。
但是围绕一个Go ModuleGo开发人员每天要执行很多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我们下节课见。

View 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编译器会报错。在启动了多个GoroutineGo语言的轻量级用户线程后面我们会详细讲解的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包的第二个依赖包pkg4pkg4包的初始化过程与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我们下节课见。

View 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捕获了SIGINTSIGTERM这两个系统信号这样当这两个信号中的任何一个触发时我们的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我们下节课见
资源链接
这节课的图书管理项目的完整源码在这里

View 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我们下节课见。

View 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的返回值赋值为正确的年份20212021被赋值给了遮蔽它的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我们下节课见。

View 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的补码Twos 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+bia、b均为实数a称为实部b称为虚部的数称为复数这里我们也可以这么理解。相比C语言直到采用C99标准才在complex.h中引入了对复数类型的支持Go语言则原生支持复数类型。不过和整型、浮点型相比复数类型在Go中的应用就更为局限和小众主要用于专业领域的计算比如矢量计算等。我们简单了解一下就可以了。
Go提供两种复数类型它们分别是complex64和complex128complex64的实部与虚部都是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 // 错误在赋值中不能将mint类型作为MyInt类型使用
var a MyInt = n // 错误在赋值中不能将nint32类型作为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我们下节课见。

View 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字符串可以被多个GoroutineGo语言的轻量级用户线程后面我们会详细讲解共享开发者不用因为担心并发安全问题使用会带来一定开销的同步机制。
另外也由于字符串的不可变性针对同一个字符串值无论它在程序的几个位置被使用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字符那么这里输出的0x4e2d0x56fd和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.Builderstrings.Joinfmt.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我们下节课见。

View 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我们下节课见

View 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) = 1cap(s) = 1append判断底层数组剩余空间已经不能够满足添加新元素的要求了于是它就创建了一个新的底层数组u2长度为2u1数组长度的2倍并把u1中的元素拷贝到u2中最后将s内部表示中的array指向u2并设置len = 2, cap = 2
然后第三步我们通过append操作向切片s添加了第三个元素13这时len(s) = 2cap(s) = 2append判断底层数组剩余空间不能满足添加新元素的要求了于是又创建了一个新的底层数组u3长度为4u2数组长度的2倍并把u2中的元素拷贝到u3中最后把s内部表示中的array指向u3并设置len = 3, cap为u3数组长度也就是4
第四步我们依然通过append操作向切片s添加第四个元素14此时len(s) = 3, cap(s) = 4append判断底层数组剩余空间可以满足添加新元素的要求所以就把14放在下一个元素的位置(数组u3末尾并把s内部表示中的len加1变为4
但我们的第五步又通过append操作向切片s添加最后一个元素15这时len(s) = 4cap(s) = 4append判断底层数组剩余空间又不够了于是创建了一个新的底层数组u4长度为8u3数组长度的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 ,我们下节课见。

View 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值为 8Go 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.5loadFactorNum/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我们下节课见

View 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结构体中包含了一个非导出字段rr的类型为另外一个结构体类型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我们下节课见

View 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我们下节课见。

View 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)
}
在这个例子中我们声明了三个循环自用变量ij和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原生的字符串类型stringGo语言提供了一个更方便的语法糖形式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
}
你可以看到在这段代码中我们定义了一个labelloop它标记的跳转目标恰恰就是我们的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的切片二维切片其每个元素切片中至多包含一个整型数13main函数的逻辑就是在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语句的使用
在前面的讲解中你可能也注意到了无论带不带labelcontinue语句的本质都是继续循环语句的执行但日常编码中我们还会遇到一些场景在这些场景中我们不仅要中断当前循环体迭代的进行还要同时彻底跳出循环终结整个循环语句的执行面对这样的场景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语句我们可以实现重复执行同一段代码的逻辑针对原生字符串类型以及一些复合数据类型诸如数组/切片mapchannel等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)
}
这个示例是对一个整型切片进行遍历并且在每次循环体的迭代中都会创建一个新的GoroutineGo中的轻量级协程输出这次迭代的元素的下标值与元素值关于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执行的闭包函数引用了它的外层包裹函数中的变量iv这样变量iv在主Goroutine和新启动的Goroutine之间实现了共享而i, v值在整个循环过程中是重用的仅有一份在for range循环结束后i = 4, v = 5因此各个Goroutine在等待3秒后进行输出的时候输出的是i, v的最终值
那么如何修改代码可以让实际输出和我们最初的预期输出一致呢我们可以为闭包函数增加参数并且在创建Goroutine时将参数与iv的当时值进行绑定看下面的修正代码
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我们下节课见

View 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赋值给xx这个接口变量的动态类型就为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实现了接口类型IGo原生类型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语句所在的最内层的forswitch或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
}
在改进后的例子中我们定义了一个labelloop这个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我们下节课见

View File

@@ -0,0 +1,382 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 函数:请叫我“一等公民”
你好我是Tony Bai。
在前面的几讲中我们学习了用于对现实世界实体抽象的类型以及用来实现算法逻辑控制的几种控制结构。从这一讲开始我们来学习一下Go代码中的基本功能逻辑单元函数。
学到这里相信你对Go中的函数已经不陌生了因为我们在前面的示例程序中一直都在使用函数。函数是现代编程语言的基本语法元素无论是在命令式语言、面向对象语言还是动态脚本语言中函数都位列C位。
Go语言也不例外。在Go语言中函数是唯一一种基于特定输入实现特定任务并可返回任务执行结果的代码块Go语言中的方法本质上也是函数。如果忽略Go包在Go代码组织层面的作用我们可以说Go程序就是一组函数的集合实际上我们日常的Go代码编写大多都集中在实现某个函数上。
但“一龙生九子九子各不同”虽然各种编程语言都加入了函数这个语法元素但各个语言中函数的形式与特点又有不同。那么Go语言中函数又有哪些独特之处呢考虑到函数的重要性我们会用三节课的时间全面系统地讲解Go语言的函数。
在这一节课中我们就先来学习一下函数基础以及Go函数最与众不同的一大特点。我们先从最基本的函数声明开始说起。
Go函数与函数声明
函数对应的英文单词是FunctionFunction这个单词原本是功能、职责的意思。编程语言使用Function这个单词表示将一个大问题分解后而形成的、若干具有特定功能或职责的小任务可以说十分贴切。函数代表的小任务可以在一个程序中被多次使用甚至可以在不同程序中被使用因此函数的出现也提升了整个程序界代码复用的水平。
那Go语言中函数相关的语法形式是怎样的呢我们先来看最常用的Go函数声明。
在Go中我们定义一个函数的最常用方式就是使用函数声明。我们以Go标准库fmt包提供的Fprintf函数为例看一下一个普通Go函数的声明长啥样
我们看到一个Go函数的声明由五部分组成我们一个个来拆解一下。
第一部分是关键字funcGo函数声明必须以关键字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") // 输出HelloGo
}
在这个例子中我们把新创建的一个匿名函数赋值给了一个名为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类型的一个实例一样。换句话说每个函数都和整型值、字符串值等一等公民一样拥有自己的类型也就是我们讲过的函数类型。
我们甚至可以基于函数类型来自定义类型就像基于整型字符串类型等类型来自定义类型一样下面代码中的HandlerFuncvisitFunc就是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分别生成以234为固定高频乘数的乘法函数以及这些生成的乘法函数的使用方法
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语言的错误处理机制就是建立在多返回值的基础上的
最后与传统的CC++、Java等静态编程语言中的函数相比Go函数的最大特点就是它属于Go语言的一等公民”。Go函数具备一切作为一等公民的行为特征包括函数可以存储在变量中支持函数内创建并通过返回值返回支持作为参数传递给函数以及拥有自己的类型等这些一等公民的特征让Go函数表现出极大的灵活性日常编码中我们也可以利用这些特征进行一些巧妙的代码设计让代码的实现更简化
思考题
函数一等公民特性的高效运用的例子显然不限于我们今天提到的这两个这里我想让你思考一下你还能列举出其他的高效运用函数一等公民特性的例子吗
欢迎你把这节课分享给更多对Go语言的函数感兴趣的朋友我是Tony Bai我们下节课见

View 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 Errorerrors.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.Newfmt.Errorf等我们还讲解了使用统一error作为错误类型的优点你要深刻理解这一点
基于Go错误处理机制统一的错误值类型以及错误值构造方法的基础上Go语言形成了多种错误处理的惯用策略包括透明错误处理策略、“哨兵错误处理策略错误值类型检视策略以及错误行为特征检视策略等这些策略都有适用的场合但没有某种单一的错误处理策略可以适合所有项目或所有场合
在错误处理策略选择上我有一些个人的建议你可以参考一下
请尽量使用透明错误处理策略降低错误处理方与错误值构造方之间的耦合
如果可以从众多错误类型中提取公共的错误行为特征那么请尽量使用错误行为特征检视策略”;
在上述两种策略无法实施的情况下再使用哨兵策略和错误值类型检视策略
Go 1.13及后续版本中尽量用errors.Is和errors.As函数替换原先的错误检视比较语句
思考题
这节课我们列出了一些惯用的错误处理策略当然Go社区关于错误处理策略的讨论可能不止这些你还见过哪些比较实用的错误处理策略吗不妨在留言区和我们探讨一下吧
欢迎你把这节课分享给更多对Go语言的错误处理机制感兴趣的朋友我是Tony Bai我们下节课见

View 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类比较常见的有IOExceptionTimeoutExceptionEOFExceptionFileNotFoundException等等看到这里你是不是觉得这些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
}
// 使用r1r2, 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()
// 使用r1r2, 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编译器居然给出一组错误提示
从这组错误提示中我们可以看到appendcaplenmakenewimag等内置函数都是不能直接作为deferred函数的而closecopydeleteprintrecover等内置函数则可以直接被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()
}
这里我们一个个分析foo1foo2和foo3中defer后的表达式的求值时机
首先是foo1foo1中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
然后我们再看foo2foo2中defer后面接的是一个带有一个参数的匿名函数每当defer将匿名函数注册到deferred函数栈的时候都会对该匿名函数的参数进行求值根据上述代码逻辑依次压入deferred函数栈的函数是
func(0)
func(1)
func(2)
func(3)
因此当foo2返回后deferred函数被调度执行时上述压入栈的deferred函数将以LIFO次序出栈执行因此输出的结果为
3
2
1
0
最后我们来看foo3foo3中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我们下节课见

View 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 MethodC++中的静态方法在使用时以该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我们下节课见。

View 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我们下节课见

View 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我们下节课见。

View 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()
}
新示例程序共有两个Goroutinemain 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 treeAST是源代码的抽象语法结构的树状表现形式树上的每个节点都表示源代码中的一种结构因为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各个函数已经注入了Tracedemo.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我们下节课见
这个项目的源码在这里

View 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下节课见。

View 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返回falsereturnsError函数就会直接将p此时p = nil作为返回值返回给调用者之后调用者会将returnsError函数的返回值error接口类型与nil进行比较并根据比较结果做出最终处理。
如果你是一个初学者我猜你的的思路大概是这样的p为nilreturnsError返回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不仅存储的动态类型的类型信息是相同的都是0x10ac580data指针指向的内存块中存储值也相同了都是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也就是非空接口类型变量的类型信息并不为空数据指针为空因此它与nil0x0,0x0之间不能划等号。
现在我们再回到我们开头的那个问题你是不是已经豁然开朗了呢开头的问题中从returnsError返回的error接口类型变量err的数据指针虽然为空但它的类型信息iface.tab并不为空而是*MyError对应的类型信息这样err与nil0x0,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的_type0x10b38c0与err的tab._type0x10b38c0是一致的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用于将任意类型转换为一个efaceconvT2I用于将任意类型转换为一个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和ifaceeface用于表示空接口类型变量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我们下节课见。

View 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 MartinBob大叔的接口分离原则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下节课见。

View 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++JavaPython等并非面向并发而生的所以他们面对并发的逻辑多是基于操作系统的线程并发的执行单元线程之间的通信利用的也是操作系统提供的线程或进程间通信的原语比如共享内存信号signal管道pipe消息队列套接字socket
在这些通信原语中使用最多最广泛的也是最高效的是结合了线程同步原语比如锁以及更为低级的原子操作的共享内存方式因此我们可以说传统语言的并发模型是基于对内存的共享的
不过这种传统的基于共享内存的并发模型很难用且易错尤其是在大型或复杂程序中开发人员在设计并发程序时需要根据线程模型对程序进行建模同时规划线程之间的通信方式如果选择的是高效的基于共享内存的机制那么他们还要花费大量心思设计线程间的同步机制并且在设计同步机制的时候还要考虑多线程间复杂的内存管理以及如何防止死锁等情况
这种情况下开发人员承受着巨大的心智负担并且基于这类传统并发模型的程序难于编写阅读理解和维护一旦程序发生问题查找Bug的过程更是漫长和艰辛
但Go语言就不一样了Go语言从设计伊始就将解决上面这个传统并发模型的问题作为Go的一个目标并在新并发模型设计中借鉴了著名计算机科学家Tony Hoare提出的CSPCommunicating 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还引入了goroutineP之间的通信原语channelgoroutine可以从channel获取输入数据再将处理后得到的结果数据通过channel输出通过channel将goroutineP组合连接在一起让设计和编写大型并发系统变得更加简单和清晰我们再也不用为那些传统共享内存并发模型中的问题而伤脑筋了
比如我们上面提到的获取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退出时会将它执行的函数的错误返回值写入这个channelmain goroutine可以通过读取channel的值来获取子goroutine的退出状态
虽然CSP模型已经成为Go语言支持的主流并发模型但Go也支持传统的基于共享内存的并发模型并提供了基本的低级别同步原语主要是sync包中的互斥锁条件变量读写锁原子操作等
那么我们在实践中应该选择哪个模型的并发原语呢是使用channel还是在低级同步原语保护下的共享内存呢
毫无疑问从程序的整体结构来看Go始终推荐以CSP并发模型风格构建并发程序尤其是在复杂的业务层面这能提升程序的逻辑清晰度大大降低并发设计的复杂性并让程序更具可读性和可维护性
不过对于局部情况比如涉及性能敏感的区域或需要保护的结构体数据时我们可以使用更为高效的低级同步原语如mutex保证goroutine对数据的同步访问
小结
好了今天的课讲到这里就结束了现在我们一起来回顾一下吧
这一讲中我们开始了对Go并发的学习了解了并发的含义以及并发与并行两个概念的区别你一定要记住并发不是并行并发是应用结构设计相关的概念而并行只是程序执行期的概念并行的必要条件是具有多个处理器或多核处理器否则无论是否是并发的设计程序执行时都有且仅有一个任务可以被调度到处理器上执行
传统的编程语言比如CC++的并发程序设计方案是基于操作系统的线程调度模型的这种模型与操作系统的调度强耦合并且对于开发人员来说十分复杂开发体验较差并且易错
而Go给出的并发方案是基于轻量级线程goroutine的goroutine占用的资源非常小创建切换以及销毁的开销很小并且Go在语法层面原生支持基于goroutine的并发通过一个go关键字便可以轻松创建goroutinegoroutine占用的资源非常小创建切换以及销毁的开销很小这给开发者带来极佳的开发体验
思考题
goroutine作为Go应用的基本执行单元它的创建退出以及goroutine间的通信都有很多常见的模式可循你可以分享一下日常开发中你见过的实用的goroutine使用模式吗
欢迎把这节课分享给更多对Go并发感兴趣的朋友我是Tony Bai下节课见

View 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”每个GGoroutine要想真正运行起来首先需要被分配一个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将得不到调度出现“饿死”的情况。
更为严重的是当只有一个PGOMAXPROCS=1整个Go程序中的其他G都将“饿死”。于是德米特里·维尤科夫又提出了《Go Preemptive Scheduler Design》并在Go 1.2中实现了基于协作的“抢占式”调度。
这个抢占式调度的原理就是Go编译器在每个函数或方法的入口处加上了一段额外的代码(runtime.morestack_noctxt),让运行时有机会在这段代码中检查是否需要执行抢占调度。
这种解决方案只能说局部解决了“饿死”问题只在有函数调用的地方才能插入“抢占”代码埋点对于没有函数调用而是纯算法循环计算的GGo调度器依然无法抢占。
比如死循环等并没有给编译器插入抢占代码的机会这就会导致GC在等待所有Goroutine停止时的等待时间过长从而导致GC延迟内存占用瞬间冲高甚至在一些特殊情况下导致在STWstop 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月提出了一个新的设计草案文档《NUMAaware 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: 代表逻辑processorP的数量决定了系统内最大可并行的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任务运行10mssysmon就会认为它的运行时间太久而发出抢占式调度的请求。一旦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。但若没有现成的MGo运行时会建立新的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我们下节课见。

View File

@@ -0,0 +1,950 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 并发小channel中蕴含大智慧
你好我是Tony Bai。
通过上两节课的学习我们知道了Go语言实现了基于CSPCommunicating 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函数关闭了channelchannel关闭后所有等待从这个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 goroutinemain 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安全有FIFOfirst-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并退出
这是怎么回事呢我们简单分析一下这段代码的运行过程
前5sselect一直处于阻塞状态
第5sch1返回一个5后被closeselect语句的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之外的另一个重要组成部分channelGo为了原生支持并发把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我们下节课见

View File

@@ -0,0 +1,650 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 并发:如何使用共享变量?
你好我是Tony Bai。
在前面的讲解中我们学习了Go的并发实现方案知道了Go基于Tony Hoare的CSP并发模型理论实现了Goroutine、channel等并发原语。
并且Go语言之父Rob Pike还有一句经典名言“不要通过共享内存来通信应该通过通信来共享内存Dont 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的访问我们创建一个新Goroutineg1g1通过函数参数得到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()
}
})
}
这些基准测试都是并发测试度量的是MutexRWMutex在并发下的读写性能我们分别在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的写锁性能和MutexRWMutex读锁相比是最差的并且随着并发量增大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 runtimeinternalatomic·Xchg64(SB)
// $GOROOT/src/runtime/internal/atomic/asm_amd64.s
TEXT runtimeinternalatomic·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、 81632的情况下运行上述性能基准测试得到结果如下
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我们下节课见

View 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中workerGoroutine的管理
task的提交与调度。
其中后两部分是pool的“精髓”所在这两部分的原理我也用一张图表示了出来
我们先看一下图中pool对worker的管理。
capacity是pool的一个属性代表整个pool中worker的最大容量。我们使用一个带缓冲的channelactive作为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 cheapshow 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类型实例的两个channelquit和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 channelschedule的调用阻塞才会解除
至此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方法销毁poolpool会等待所有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的项目demo2demo2的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我们下节课见
今天的项目源码在这里

View 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/ONon-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的服务端地址是网络不可达的或者服务地址中端口对应的服务并没有启动端口未被监听ListenDial几乎会立即返回类似这样的错误
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=2err=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]))
}
}
如果我们要取消超时设置可以使用SetReadDeadlinetime.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调用将会读到什么呢这里要分有数据关闭无数据关闭两种情况
有数据关闭是指在客户端关闭连接SocketSocket中还有服务端尚未读取的数据在这种情况下服务端的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我们下节课见

View 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消息请求的响应包
)
请求包与应答包的第三个字段都是IDID是每个连接上请求包的消息流水号顺序累加步长为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和payloadpacket 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所代表的输出outboundTCP流中。而Decode方法正好相反它从代表输入inboundTCP流的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操作的Deadlineio.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-colleengame-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网络服务端程序结构与framepacket的实现组装到一起就实现了我们的第一版服务端之后我们还编写了客户端模拟器对这个服务端的实现做了验证
这个服务端采用的是Go经典阻塞I/O的编程模型你是不是已经感受到了这种模型在开发阶段带来的好处了呢
思考题
在这讲的中间部分我已经把作业留给你了
为packet包编写单元测试
为我们的服务端增加优雅退出机制以及捕捉某个链接上出现的可能导致整个程序退出的panic
项目的源代码在这里

View 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拉取的度量数据以图形化方式展示出来。这个时候我们不需要手工一个一个设置仪表板上的panelGrafana官方有现成的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.GaugeGauge是对一个数值的即时测量值它反映一个值的瞬时快照而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了。
我们建立三个panelreq_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我们下节课见。
项目源代码在这里!

View 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语言提供的anyinterface{}的别名),我们来试试:
// 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的返回值类型为anyinterface{}),要得到其实际类型的值还需要通过类型断言转换;
使用anyinterface{}作为输入参数的元素类型和返回值的类型由于存在装箱和拆箱操作其性能与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的类型比如示例中的myStringmaxGenerics都可以无缝支持。并且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示例中这个列表中仅有一个类型参数Tordered为类型参数的类型约束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对应类型参数ASecond对应类型参数B
}
小结
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
在这一讲中我们一起学习了Go泛型的基本语法类型参数。类型参数是Go泛型方案的具体实现通过类型参数我们可以定义泛型函数、泛型类型以及对应的泛型方法。
泛型函数是带有类型参数的函数,在函数名称与参数列表之间声明的类型参数列表使得泛型函数的运行逻辑与参数/返回值类型解耦。调用泛型函数与普通函数略有不同泛型函数需要进行实例化后才能生成真正执行的、带有类型信息的函数。同时Go泛型支持的类型实参推断也使得开发者在大多数情况下无需显式传递类型实参获得与普通函数调用几乎一致的体验。
泛型类型是带有类型参数的类型泛型类型的类型参数放在类型名称后面的类型参数列表中声明类型参数后续可以在泛型类型声明中用作成员字段的类型或复合类型成员元素的类型。不过目前Go 1.19版本Go尚不支持泛型类型的类型实参的自动推断我们在泛型类型实例化时需要显式传入类型实参。
与泛型类型绑定的方法被称为泛型方法,泛型方法的参数列表和返回值列表中可以使用泛型类型的类型参数,但泛型方法目前尚不支持声明自己的类型参数列表。
Go泛型的引入使得Go开发人员在interface{}之后又拥有了一种编写“通用代码”的手段,并且这种新手段因其更多在编译阶段的检查而变得更加安全,也因其减少了运行时的额外开销使得代码性能更好。
思考题
使用过其他编程语言泛型语法特性的小伙伴们可能会问为什么Go在方括号“[]”中声明类型参数,而不是使用其他语言都用的尖括号“<>”呢?你可以思考一下。
欢迎在评论区写下你的想法,我们泛型篇的第二讲见。

View 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的类型集合变成了什么呢请你思考一下。

View 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
}
泛型版实现基本消除了前面两种方案的不足如果非要说和IntStackStringStack等的差异那可能就是在执行性能上要差一些了
$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和intrune和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中那样的通用方法的泛型实现
至此泛型篇三讲就彻底讲完了如果你有什么问题欢迎在评论区留言

View File

@@ -0,0 +1,25 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
元旦快乐 这是一份暂时停更的声明
你好我是Tony Bai。
马上就是元旦了,又是新的一年。祝你在新的一年里身体倍儿棒,万事儿顺畅,事业蒸蒸上。
和新年祝福一起来的,是一份暂时停更的请求。因为最近家里有一些紧急情况要处理,我忙得焦头烂额,已经有好几天没怎么睡觉了。这个突发事件也打乱了我们的备稿计划,让原本就紧张的稿件雪上加霜,没有办法继续维持每周三篇的更新。
你也能看到,前面我更新了两篇加餐,一篇大咖助阵和一篇用户故事。除了目录中本就计划好的一篇加餐外,剩下的三篇都是我们后面筹备的。看留言的时候,我也发现有一些同学一直在催我更新正文。
但准备一篇正文,真的挺耗时的。平均五六千字的一篇文章,我需要挑灯夜战三天以上才能完成初稿。编辑打磨也不轻松,一篇文章往往要来回修改多次。如果强行赶工,压缩写稿与打磨的时间,那是对每一位用户的不负责,我不想这么做。既然你付费学习我这个专栏,那我肯定要保证课程的质量,给你呈现最优质的内容。
所以很抱歉再三考虑我决定在2022年1月1日至1月6日期间暂停更新两讲内容全力备稿。希望你可以理解。
在这段时间内你可以复习一下之前的学习内容查缺补漏进度稍慢一些的同学也可以趁假期赶赶进度。另外元旦假期后我们将开始讲解Go语言的一个重点也就是Go并发方面的内容。你也可以在假期闲暇时先预习一下这方面的内容这对提升你的学习效果会很有帮助。
祝你度过一个愉快的元旦假期我们2022年1月7日再见。

View 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 Modulesrsm是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的新消费者都将受到影响
比如这里我们引入一个新的消费者c3c3的首次构建就会因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 cachego 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我们下节课见。

View 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 Modulemycompany.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端口的正是nginxnginx关于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 Modulego命令都会默认把它的sum值放到公共GOSUM服务器上去校验。
但我们实质上拉取的是私有Go ModuleGOSUM服务器上并没有我们的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 Modulevanity.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 Modulego开发者都能拉取到。
不过对于多数公司而言内部所有源码原则上都是企业内部公开的这个问题似乎也不大。如果觉得这是个问题那么只能使用前面提到的第一个方案也就是直连私有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我们下节课见。

View File

@@ -0,0 +1,231 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐 我“私藏”的那些优质且权威的Go语言学习资料
你好我是Tony Bai。
学习编程语言并没有捷径,就像我们在开篇词中提到的那样,脑勤+手勤才是正确的学习之路。不过留言区也一直有同学问我除了这门课之外还有什么推荐的Go语言学习资料。今天我们就来聊聊这个话题。
如今随着互联网的高速发展,现在很多同学学习编程语言,已经从技术书籍转向了各种屏幕,以专栏或视频实战课为主,技术书籍等参考资料为辅的学习方式已经成为主流。当然,和传统的、以编程类书籍为主的学习方式相比,谈不上哪种方式更好,只不过它更适合如今快节奏的生活工作状态,更适合碎片化学习占主流的学习形态罢了。
但在编程语言的学习过程中,技术书籍等参考资料依旧是不可或缺的,优秀的参考资料是编程语言学习过程的催化剂,拥有正确的、权威的参考资料可以让你减少反复查找资料所浪费的时间与精力,少走弯路。
这节课我会给你分享下我“私藏”的Go语言学习的参考资料包括一些经典的技术书籍和其他电子形式的参考资料。
虽然现在编程语言学习可参考的资料形式、种类已经非常丰富了但技术类书籍包括电子版在依旧占据着非常重要的地位。所以我们就先重点看看在Go语言学习领域有哪些优秀的书籍值得我们认真阅读。
Go技术书籍
和C1972年、C++1983、Java1995、Python1991等编程语言在市面上的书籍数量相比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、ioReader和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代码。

View 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 tidyGo命令都会对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我们下节课见

View 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包的类型与函数时你一定要知道你正在做什么确保代码的正确性
思考题
学完这一讲后我建议你回看一下本专栏中涉及指针的章节与实战项目你可能会有新的收获

View 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.18RCrelease 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 GoFGG并成功地给出了FGG到Feighterweight GoFG的可行性实现的形式化证明。这篇论文的形式化证明给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的proposal2021年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和E3type 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的名字也就是变量而aTypeanotherType是形参的类型也就是类型。
我们再来看一下泛型函数的类型参数type parameter列表
func GenericFoo[P aConstraint, Q anotherConstraint](x,y P, z Q)
这里P、Q是类型形参的名字也就是类型。aConstraintanotherConstraint代表类型参数的约束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
一旦“排序机器”被生产出来那么它就可以对目标对象进行排序了这和普通的函数调用没有区别。这里就相当于调用booksortbookshelf整个过程只需要检查传入的函数实参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]的底层类型为[]intVector[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我们下节课再见。

View 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指针传递进去能让内部函数进行修改
然后我们针对三个初始化字段nameagedb定义了三个返回了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
}
...
}
我简单解释下这段代码首先整段代码的作用是解析出三个变量namemodversion最开始我们先定义这三个变量然后使用三个大括号分别将这三个变量的解析逻辑封装在里面这样每个大括号里面的err变量的作用域就完全局限在括号中了所以我们每次都可以直接使用 := 来创建一个新的 err并处理它不用再额外思考这个err 变量是否前面已经创建过了
你可以自己观察一下大括号在代码语义上还有一个好处就是归类和展示
归类的意思就是这个大括号里面的变量和逻辑是一个完整的部分他们内部创建的变量不会泄漏到外部这个等于告诉后续的阅读者你在阅读的时候如果对这个逻辑不感兴趣可以不阅读里面的内容如果你感兴趣就可以进入里面进行阅读
基本上所有IDE都支持对大括号封装的内容进行压缩这里我使用的是Goland压缩后我的命令行的主体逻辑就更清晰了
所以使用大括号结合IDE你的代码的可读性能得到很大的提升
总结
好了这次的分享到这里就结束了今天我给你总结了四个Go语言中常用的写法
使用pkg/error而不是官方error库
在初始化slice的时候尽量补全cap
初始化一个类的时候如果类的构造参数较多尽量使用Option写法
巧用大括号控制变量作用域
这几种写法和注意事项都是我在工作和阅读开源项目中的一些总结和经验每个经验都是对应为了解决不同的问题
虽然说Go已经对代码做了不少的规范和优化但是好的代码和不那么好的代码还是有一些差距的这些写法优化点就是其中一部分
我今天只列出的了四个点当然了还有很多类似的Go写法优化点等着你去发现相信你在工作生活中也能遇到不少只要你平时能多思考多总结多动手也能积攒出属于自己的一本小小的优化手册

View 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 泛型放开这些限制吗?
欢迎在留言区分享你的看法,我们一起讨论。

View 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命令框架中大大提高了我的开发效率。
此外,我还研究学习了很多比较有趣的开源项目,你也可以参考一下,比如:
elvishGo语言编写的Linux Shell
machineryGo语言编写的分布式异步作业系统
gopubGo语言编写的的版本发布系统
crawlabGo语言编写的分布式爬虫管理平台
还有不少其他好玩、有用的工具/项目。
在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、容灾、DevOpsCODING等核心能力的构建方式和部署方式。
同时,我在微信上又关注了一些云原生、架构相关的公众号,会利用闲碎时间阅读一些优质的公众号文章,再通过这些优质的文章,进一步丰富自己架构方面的知识。
关于如何提升架构能力,这里我有以下几点建议。
首先是学架构,先从当前业务开始。怎么开始呢?我们可以先了解当前业务的整体部署方式,最好是手动搭建一个小型的业务测试环境。在搭建过程中,你可以对业务的部署方式,有非常深刻的理解和掌握。
同时,我们可以走读所有的业务代码。可以先从核心代码开始读起,如果有时间,也可以通读当前业务所有组件的代码,最终你就能够知道整个系统是如何集成的,以及业务中每个功能是如何构建的。
接下来学完当前业务架构再学云原生架构。因为当前的软件架构都在朝着云原生架构的方向演进云原生架构的基石是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语法知识尝试实现它。

View 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 讨论,说是讨论,不如说是盘问。在他的 “是还是不是” 以及 “是” 一个追问,“不是” 一个追问的过程中,问题逐渐走向成熟。
这样一轮又一轮的审判持续了四个月,我们终于开始着手真正的架构设计了,它先是出现在脑海中,随后是满是涂鸦的纸片上,再来到了文档里,最后亭亭玉立、大大方方地呈现了出来。
直到我自己开始负责同样大型的项目的时候,我才清晰地意识到他早有许多成型的思路,但就是硬憋着没直接说出来。你们瞧,不但有我这样的神棍,群众里面还有坏人呐!把我折磨,将我蹂躏,带我成长。
能有一位引路人,引导你完成一件很漂亮的作品。这样的故事,每每回想都很有滋味。
之后
一不小心说的有些太多了,我得保留点神秘感,最近三四年的故事以后有机会再谈,让我们先谈谈未来吧。
从阴沟里爬出来的故事中充满着机缘和幸运,那么我为何偏偏是那个幸运男孩呢?或者只是因为没有这份幸运从阴沟里出不来而已?如果能求得一个答案,应该会有助于我在当下和未来保持这份幸运。
首先自然是时代带给我的红利,那个时候我正好赶上了黄金期。加之我一直紧咬困惑自己的问题不放,等到机会来临的时候,我正好把原先的积累找到出口释放了出来。
那么,当大潮退去,又该如何做呢?我想我们能做到的依然还是保持初心,保持自己的求知欲望,这是我们仅能做的。结果是我们不可控的,但至少我们能收获沿途美妙的风景。
想到这里我忽然有些困乏,时候也不早了,远处传来的深圳特产——工地之歌也逐渐稀稀落落。我朝着窗外望得出神,想起自己曾在北京的窗台边听着外头的鸟吱吱的叫声;曾在上海的凌晨听到人行道被扫得唰唰作响;还有那西湖边,夏日的晚风掠过树梢,树叶在月光下窸窸窣窣;在九月的家乡,桂花在我的窗边唱起无声的歌谣。无论在哪,传来什么样的声音,我又从哪扇门走出去,遇上什么样的人,我都未曾改变呐!
我不在乎我不知道什么,缺少什么,也不在乎别人知道什么,拥有什么。我只在乎我自己的渴望。
那么我现在渴望什么呢?我暂时还不能说,因为往往目标被说出来之后就容易沉浸在已经成功的幻觉中,难以踏出艰难的第一步了。等事情浮出水面的时候再轻描淡写地说出来或许更热血沸腾。
光想想就刺激,不是吗?

View File

@@ -0,0 +1,409 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
大咖助阵 曹春晖:聊聊 Go 语言的 GC 实现
作者注:本文只作了解,不建议作为面试题考察。
你好我是曹春晖是《Go 语言高级编程》的作者之一。
今天我想跟你分享一下 Go 语言内存方面的话题聊一聊Go语言中的垃圾回收GC机制的实现希望你能从中有所收获。
武林秘籍救不了段错误
在各种流传甚广的 C 语言葵花宝典里,一般都有这么一条神秘的规则,不能返回局部变量:
int * func(void) {
int num = 1234;
/* ... */
return &num;
}
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 本身的实现机制有稍微深入一些的理解。
内存管理的三个参与者
当讨论内存管理问题时我们主要会讲三个参与者mutatorallocator 和 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 cachemheap.arenas 是 L4L4 是以页为单位将内存向下派发的,由 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 对象的内部指针指向了 BC 的内部指针指向了 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。针对每个 mspansweep.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》它可以解答你所有的疑问。

View 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++泛型类型在被翻译成机器码的时候是真的创建了两种不同的类型
泛型使用最广泛的场景就是容量类例如vectorlistmap等等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这个计算的过程是由编译器完成的
编译器会把fibfib等等都看成一种类型当编译器要计算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则不会它会进行类型擦除从而导致表面上不同的类型参数实际上指代的是同一种类型。
总结
在这节课里我们先了解到什么是类型系统并介绍了什么是强类型和弱类型什么是静态类型和动态类型。然后我们通过举例来说明PythonJavaScriptGo和C++各自的类型系统的特点。
从这些例子中,我们看到静态强类型语言更容易阅读和维护,但灵活性不如动态弱类型语言。所以动态弱类型语言往往都是脚本语言,不太适合构建大型程序。
接下来我们简单介绍了泛型的概念。我们使用了一个栈的例子来说明了使用泛型可以提高编程效率节省代码量。Go语言从1.18开始也支持泛型编程。
然后我们又提供了一个新的视角来理解泛型,这种新的视角是把泛型类看成是一种函数,它的输入参数可以是类型,也可以是值,它的返回值是一种新的类型。
最后我们介绍了C++的泛型实现和Java的泛型实现。C++不同的泛型参数会得到一种新的类型这个过程我们也会称它为泛型的实例化。而Java则会进行类型擦除从而导致表面上不同的类型参数实际上指代的是同一种类型。

View File

@@ -0,0 +1,19 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
期中测试 一起检验下你的学习成果吧
你好我是Tony Bai。
不知不觉间,我们的课程已经更新过半了。不知道你学习得怎么样呀?不如做套题来检验一下吧?
这次期中测试,我根据我们前面讲过的知识,给你出了 20 道选择题,考试范围截止到函数之前的内容,你可以检验一下自己的学习成果。如果你有什么不理解的地方,欢迎在留言区留言。
快点击下面的按钮开始测试吧,我期待着你满分的好消息。

View 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/CSSJavaScript 的课程来配合业务方完成相应的功能。
但通过一年时间的修修补补,我意识到,我基本上还不能算入门 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站的视频我们都可以将学到的东西记录下来时常回顾。让学到的知识不轻易流失让要学习的内容越来越少我们才会觉得越来越轻松。
希望所有人都能学会正确的学习方法,坚持终身学习的理念,让自己变得越来越好。

View File

@@ -0,0 +1,108 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 和你一起迎接Go的黄金十年
你好我是Tony Bai。
在虎年春节营造的欢乐祥和的气氛中,我们迎来了这个专栏的最后一节课。
这个专栏的撰写开始于2021年5月中旬我使用GitHub仓库管理专栏文稿这是我的第一次提交
从那时开始我便进入了专栏写作的节奏。从2021年5月到2022年2月9个月的时间我洋洋洒洒地写下了20多万字估计值写作过程的艰辛依然历历在目。
你见过凌晨4点你所居住的城市吗我每周至少要见三次。你每天能睡多长时间呢45个小时是我的常态。在日常工作繁忙家里带俩娃的背景下这算是挑战我自己的极限了但当我看到有那么多订阅学习专栏、认真完成课后思考题以及在留言区留言的同学我又顿感自己的付出没有白费我的自我挑战是成功的。
“老师,你跑题了!”
不好意思,小小感慨了一下。我们还是回到结束语上来。我想了很久,在这最后一讲里,还能给你说点什么呢?人生大道理?职涯规划建议?轻松的、搞笑的段子?可惜这些我都不擅长。最后,我决定借着这篇结束语,和你聊聊下面这几件事,请耐心地听我道来。
专栏回顾与“与时俱进”
在下笔写这篇文章之前我认真回顾了一下这门课的内容对照着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 UpRunning》一书的作者、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语言之路上走的更远并实现你的个人价值。

View File

@@ -0,0 +1,27 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结课测试 快来检验下你的学习成果吧!
你好我是Tony Bai。
马上就是春节了,祝你新年快乐,万事顺意!到这里呢,我们专栏的正文就已经全部更新完毕了,只剩下最后的几篇加餐了,感谢你一直以来的支持和努力。
正因为如此,而且又恰逢春节假期,我决定把结课测试题的更新提前,你可以借着假期的时间,检验自己的学习成果,查缺补漏。剩下的加餐,我们依然会按正常的更新节奏进行更新。
而且,春节期间我们也准备了“春节特别放送”计划,除了今天的结课测试外,还有三篇重磅加餐:
2月2日零点初二“大咖助阵”分布式云存储工程师徐祥曦分享他与Go的二三趣事
2月4日零点初四“大咖助阵”《Go语言高级编程》作者曹春晖“曹大”带你分析Go语言的垃圾回收GC机制
2月7日零点初七Go Module 还没看过瘾那这次我会从Go Module的作者或维护者的角度聊聊我们在规划、发布和维护Go Module时需要考虑和注意什么。
希望你在这个春节假期,玩得开心,学得愉快!如果你有什么不太清楚的地方,欢迎在留言区留言。