2024-11-19 08:25:46 | 来源: 互联网整理
互联网时代的到来,改变甚至颠覆了很多事情。以前一台主机搞定一切,现在一台主机就能搞定一切。但在互联网时代,后端是由大量的分布式系统组成,任何一个后端服务器节点的故障都不会影响整个系统的正常运行。以七牛云、阿里云、腾讯云为代表的云厂商的出现和崛起,标志着云时代的到来。在云时代,掌握分布式编程已经成为软件工程师的基本技能,而基于Go语言的Docker、Kubernetes等系统则是将云时代推向巅峰的关键力量。
如今,Go语言已经走过了十年,最初的追随者已经逐渐成长为Go语言的高级用户。随着经验用户的不断积累,Go语言相关教程不断增多,内容主要涵盖基础Go语言编程、Web编程、并发编程、内部源码分析等多个领域。
《GO语言高级编程》
一本Go语言的进阶书籍,满足了Gopher的好奇心,更倾向于描述实现细节,极大地满足了开发者的探索欲望。本书适合有一定Go语言经验、想要深入了解Go语言各种高级用法的开发者。对于刚接触Go语言的人,建议在阅读本书之前先阅读一些基础的Go语言编程书籍。
目录
内容摘要前言1 前言2 前言致谢资源和支持第1 章语言基础第2 章CGO 编程第3 章Go 汇编语言第4 章RPC 和Protobuf 第5 章Go 和Web 第6 章分布式系统附录A 使用Go 语言经常遇到的问题附录B 有趣的问题代码片段示例章节阅读:第1 章语言基础知识(摘录)
我不知道你这10年为什么不开心。但相信我,抛开过去的沉重,使用Go语言,体验最初的快乐!
——469856321
农民工也将通过搬砖的方式建设自己的“罗马帝国”。
——小张
本章首先简单介绍了Go语言的发展历史,并更详细地分析了各个祖先语言中“Hello,World”程序的演变。然后简要介绍了以数组、字符串、切片为代表的基本结构,以函数、方法、接口为代表的面向过程和鸭子面向对象编程,以及独特的并发编程模型和错误处理哲学Go 语言的。最后,针对macOS、Windows、Linux等主流开发平台,我们推荐几个比较友好的Go语言编辑器和集成开发环境,因为好的工具可以大大提高我们的效率。
Go 语言最初是由Google 的三位技术专家Robert Griesemer、Ken Thompson 和Rob Pike 在2007 年设计和发明的。设计新语言的最初动力来自于对超级复杂的C++11 的吹捧报告特征。最终目标是为网络和多核时代设计C语言。到2008 年中期,在该语言的大部分功能设计完成并开始实现编译器和运行时的工作之后,Russ Cox 作为主要开发人员加入。到2010年,Go语言逐渐稳定,并于9月份正式发布并开源。
Go 语言经常被描述为“类C 语言”或“21 世纪的C 语言”。从各个角度来看,Go语言确实继承了C语言类似的表达式语法、控制流结构、基本数据类型、调用参数值传递、指针等诸多编程思想,并且完全继承和发展了C语言的简单性和直接性。 C语言。暴力编程哲学等。图1-1展示了The Go Programming Language中给出的Go语言的遗传图谱,从中我们可以看出哪些编程语言影响了Go语言。
图1-1 Go语言基因图谱
首先看遗传图谱的左边分支。可以清楚地看到,Go语言的并发特性是从贝尔实验室的Hoare在1978年发布的CSP理论演变而来的。后来CSP并发模型逐渐完善,并在诸如Squeak/Newsqueak 和Alef。最终,这些设计经验被消化吸收到Go语言中。业界熟悉的Erlang编程语言的并发编程模型也是CSP理论的另一种实现。
最后还有遗传图谱的右分支,这是对C语言的致敬。 Go语言是对C语言最彻底的放弃。它不仅在语法上与C语言有很多不同,最重要的是它放弃了C语言中灵活但危险的指针运算。而且,Go语言还重新设计了C语言中一些不合理的运算符的优先级,并在很多细微的地方进行了必要的打磨和改变。当然,少即是多,C语言中简单直接的暴力编程哲学得到了Go语言更彻底的发扬光大(Go语言实际上只有25个关键字,语言规范还不到50页)。
Go 语言的其他功能分散于其他编程语言。例如,iota 语法是从APL 语言借用的,词法作用域和嵌套函数等功能则来自Scheme 语言(以及许多其他编程语言)。 Go语言中也有很多自主发明和创新的设计。例如,Go 语言切片为轻量级动态数组提供了有效的随机访问性能,这可能会让人想起链表的底层共享机制。还有Go语言新发明的defer语句(Ken发明的)也是神来之笔。
1.1.1 贝尔实验室的独特基因
Go 语言标志性的并发编程特性来自于贝尔实验室的Tony Hoare 在1978 年发表的一篇鲜为人知的并发研究基础文档:Communicating Sequential Processes (CSP)。在最初的CSP 论文中,程序只是一组并发运行的进程,没有中间共享状态,使用通道进行通信和控制同步。 Tony Hoare的CSP并发模型只是一种描述语言,用来描述并发的基本概念。它不是一种可以编写可执行程序的通用编程语言。
CSP并发模型最经典的实际应用是爱立信发明的Erlang编程语言。然而,当Erlang使用CSP理论作为并发编程模型时,同样来自贝尔实验室的Rob Pike和他的同事们却在不断尝试将CSP并发模型引入当时新发明的编程语言中。他们首先尝试引入CSP并发特性的编程语言称为Squeak(老鼠的叫声)。它是一种用于提供鼠标和键盘事件处理的编程语言。在这种语言中,通道是静态创建的。然后是Newsqueak语言的改进版(新版鼠标噪音),它提供了类似于C语言语句和表达式的新语法,以及类似于Pascal语言的推导语法。 Newsqueak 是一种带有垃圾收集机制的纯函数式语言,同样针对键盘、鼠标和窗口事件管理。然而,在Newsqueak 语言中,通道已经是动态创建的。 Channels属于第一种类型的值,可以保存到变量中。然后就是Alef编程语言(Alef也是C语言之父Ritchie青睐的编程语言)。 Alef语言试图将Newsqueak语言转变为系统编程语言,但由于缺乏垃圾回收机制,并发编程是痛苦的(这也是继承C语言手动管理内存的代价)。 Alef语言之后,还有一种编程语言,叫做Limbo(意思是地狱),它是一种运行在虚拟机中的脚本语言。 Limbo 语言是Go 语言最接近的祖先,它的语法也最接近Go 语言。到设计Go语言时,Rob Pike已经积累了数十年的CSP并发编程模型实践经验。 Go语言并发编程的特性完全是他触手可及的,新的编程语言的到来也是理所当然的。
图1-2展示了Go语言库早期的代码库日志,可以看到最直接的演变过程(在Git中使用git log --before={2008-03-03} --reverse命令查看) 。
图1-2 Go语言开发日志
从早期的提交日志也可以看出,Go语言是从Ken Thompson发明的B语言和Dennis M. Ritchie发明的C语言逐渐演变而来的。它首先是C语言家族的一员,所以很多人将Go语言称为21世纪的C语言。
图1-3展示了贝尔实验室独特的并发编程基因在Go语言中的演变。
图1-3 Go语言并发演化历史
纵观贝尔实验室编程语言的发展历程,从B语言、C语言、Newsqueak、Alef、Limbo语言,Go语言继承了贝尔实验室半个世纪的软件设计基因,最终完成了C语言创新的使命。纵观这几年的发展趋势,Go语言已经成为云计算、云存储时代最重要的基础编程语言。
1.1.2 你好,世界
按照惯例,任何编程语言中引入的第一个程序都是“Hello, World!”尽管本书假定读者已经了解Go 语言,但我们仍然不想打破这个约定(因为这个传统是从Go 语言的前身C 语言继承的)。下面的代码显示了输出“Hello, world!”的Go语言程序。中文.
package mainimport 'fmt'func main() { fmt.Println('Hello, world!')} 将上面的代码保存到hello.go 文件中。由于代码中存在非ASCII 汉字,因此我们需要显式指定文件的编码为无BOM 的UTF8 编码(源文件的UTF8 编码是Go 语言规范所要求的)。然后进入命令行,切换到hello.go文件所在目录。目前我们可以使用Go语言作为脚本语言,直接在命令行输入go run hello.go来运行程序。如果一切顺利,您应该能够看到输出“Hello, world!”在命令行上。
1.1节简单介绍了Go语言的进化基因图谱,重点介绍了来自贝尔实验室的独特并发编程基因,最后介绍了Go语言版本的“Hello, World”程序。事实上,“Hello, World”程序是各种语言特性的最好例子,也是了解该语言的一个窗口。本节将沿着各个编程语言的演变时间线(如图1-3所示),简要回顾一下“Hello, World”程序是如何逐渐演变为现在的Go语言形式并最终完成其使命的。
1.2.1 语言B ——Ken Thompson,1969
第一个是B语言。 B语言是由“Go语言之父”——贝尔实验室的Ken Thompson早年开发的通用编程语言。它旨在协助UNIX 系统的开发。但B语言缺乏灵活的类型系统,导致使用起来很困难。后来Ken Thompson的同事Dennis Ritchie在B语言的基础上开发了C语言。 C语言提供了丰富的类型,大大增强了语言的表达能力。到目前为止,C语言仍然是世界上最常用的编程语言之一。自从B语言被它取代之后,它就只存在于各种文献中,成为了历史。
目前看到的B 语言版本的《Hello, World》一般认为来自于Brian W. Kernighan 编写的B 语言入门教程(Go 核心代码库中第一位提交者的名字是Brian W. Kernighan)。程序如下:
main() { 外部a, b, c; putchar(a); putchar(b); putchar(c); putchar('!*n');}a 'hell';b 'o, w';c ' orld';由于B语言缺乏灵活的数据类型,所以要输出的内容只能通过全局定义分别为变量a/b/c,并且每个变量的长度必须对齐到4个字节(感觉就像写汇编语言一样)。然后通过多次调用putchar()函数来输出字符。最后一个'!*n'表示输出换行符。
一般来说,B语言比较简单,功能也比较有限。
1.2.2 C 语言——丹尼斯·里奇,1972-1989
C语言是Dennis Ritchie在B语言的基础上改进的。它添加了丰富的数据类型,最终实现了用它重写UNIX的伟大目标。 C语言可以说是现代IT行业最重要的软件基石。目前,几乎所有主流操作系统都是用C语言开发的,很多基础系统软件也是用C语言开发的。 C家族的编程语言几十年来一直占据主导地位,半个多世纪以来一直保持活力。
1.2.3Newsqueak——罗布·派克,1989
Newsqueak是Rob Pike发明的第二代鼠标语言,也是他练习CSP并发编程模型的战场。 Newsqueak的意思是新的Squeak语言,其中squeak是鼠标的“吱吱”声,也可以看作是类似于鼠标点击的声音。 Squeak 是一种提供鼠标和键盘事件处理的编程语言。 Squeak 语言的通道是静态创建的。 Newsqueak语言的改进版本提供了类似于C语言语句和表达式的语法以及类似于Pascal语言的推导语法。 Newsqueak 是一种具有自动垃圾收集功能的纯函数式语言,同样针对键盘、鼠标和窗口事件管理。然而,在Newsqueak语言中,通道是动态创建的,属于第一种类型的值,因此可以将它们保存到变量中。
Newsqueak 类似于脚本语言,有一个内置的print() 函数。它的“Hello, World”程序没有任何功能:
print('你好,', '世界', '\n');从上面的程序中,我们除了猜测print()函数可以支持多个参数之外,很难看出与Newsqueak语言相关的特性。由于Newsqueak语言和Go语言相关的特性主要是并发和通道,这里我们使用并发版本的“素数筛”算法来简单了解一下Newsqueak语言的特性。 “素数筛”的原理如图1-4所示。
图1-4 素数筛
Newsqueak语言并发版的“素数筛”程序如下:
//输出从2开始的自然数序列到通道计数器:=prog(c:chan of int) { i :=2; for(;) { c -=i++; }}; //过滤从监听通道得到的序列去掉素数倍数//新的序列输出到发送通道filter :=prog(prime:int, Listen, send:chan of int) { i:int; for(;) { if((i=-listen)%prime) { 发送-=i; } }};//主函数//每个通道流出的第一个数字必须是素数//然后根据这个新的素数筛构建一个新的素数过滤器:=prog() of chan of int { c :=mk(int chan);开始计数器(c); prime :=mk(chan of int);开始编程(){ p:int; int的newc:chan; for(;){ 素数-=p=- c; newc=mk();开始过滤器(p,c,newc); c=新c; } }();成为素数;};//启动素数筛prime :=sieve();其中counter()函数用于将原始自然数序列输出到通道,每个filter()函数对象对应于每个新的素数滤波器通道。这些素数过滤器通道根据当前素数过滤器对流入输入通道的序列进行过滤,然后重新输出到输出通道。 mk(chan of int)用于创建通道,类似于Go语言中的make(chan int)语句; begin filter(p, c, newc) 关键字启动素数筛并发体,类似于Go语言中的go filter(p, c) , newc) 语句; become用于返回函数结果,类似于return语句。
1.2.4Alef——菲尔·温特伯顿,1993
由于Alef语言同时支持进程和线程并发,并且并发中可以再次启动更多的并发,因此Alef的并发状态极其复杂。同时Alef没有自动垃圾收集机制(Alef保留的C语言灵活的指针特性也导致自动垃圾收集机制很难实现)。各种资源在不同线程、进程之间泛滥,导致并发体内存资源管理异常。复杂的。 Alef语言完全继承了C语言的语法,可以认为是增强并发语法的C语言。图1-5 显示了Alef 语言文档中显示的可能的并发状态。
图1-5 Alef并发模型
Alef语言并发版本的“Hello, World”程序如下:
#include alef.hvoid receive(chan(byte*) c) { byte *s; s=- c;打印('%s\n', s);终止(nil);}void main(void) { chan( byte*) c;分配c;过程接收(c);任务接收(c); c -='你好过程或任务'; c -='你好过程或任务';打印('完成\n'); Terminate (nil);} 程序开头的#include alef.h语句用于包含Alef语言的运行时库。 receive()是一个普通函数,作为程序中各个并发的入口函数; main()函数中的alloc c语句首先创建一个chan(byte*)类型的通道,类似于Go语言的make(chan[] byte)语句;然后分别以进程和线程的形式启动receive()函数;启动并发体后,main()函数向c通道发送两个字符串数据;而进程中运行的receive()函数和线程状态会以未确定的顺序从通道接收到数据后,分别打印字符串;最后,每个并发主体通过调用Terminate(nil) 来终止自身。
Alef的语法与C语言基本一致。可以认为它是在C语言的语法基础上增加了并发编程相关的功能。它可以被视为C++语言的另一个维度。
1.2.5《Limbo——》肖恩·多沃德、菲尔·温特伯顿、罗布·派克,1995
Limbo(地狱)是一种编程语言,用于开发在小型计算机上运行的分布式应用程序。它支持模块化编程、编译时和运行时的强类型检查、进程内基于类型的通信通道以及原子垃圾收集。和简单的抽象数据类型。 Limbo 设计为即使在没有硬件内存保护的小型设备上也可以安全运行。 Limbo语言主要运行在Inferno系统上。
1.2.6Go语言——2007-2009
1.hello.go——2008 年6 月
以下是已经正式开始测试的Go语言程序初始版本:
package mainfunc main() int { print '你好,世界\n'; return 0;} 用于调试的内置打印语句已经存在,但它被用作命令。入口main()函数也和C语言中的main()函数一样返回一个int类型的值,需要显式返回该值。每个语句末尾的分号也仍然存在。
2 hello.go—— 2008 年6 月27 日
这是2008 年6 月的Go 代码:
package mainfunc main() { print 'hello, world\n';} 入口函数main()去掉了返回值,程序默认通过隐式调用exit(0)返回。 Go语言逐渐向简单的方向发展。
3.hello.go——2008 年8 月11 日
这是2008 年8 月的代码:
package mainfunc main() { print('hello, world\n');} 用于调试的内置print 由启动命令改为普通内置函数,语法更简单、更一致。
4 hello.go—— 2008 年10 月24 日
这是2008 年10 月的代码:
package mainimport 'fmt'func main() { fmt.printf('hello, world\n');} 作为C语言中的签名printf()格式化函数,已经移植到Go语言中,函数放在fmt 包(fmt 是format word format 的缩写)。不过printf()函数名的首字母仍然是小写字母,使用大写字母表示导出的功能还没有出现。
5.hello.go—— 2009 年1 月15 日
这是2009 年1 月的代码:
package mainimport 'fmt'func main() { fmt.Printf('hello, world\n');} Go语言开始使用首字母的大小写来区分符号是否可以导出。大写字母表示导出的公共符号,小写字母表示包内的私有符号。但需要注意的是,汉字没有大小写字母的概念,所以目前无法导出以汉字开头的符号(针对这个问题,中国用户给出了相关建议,汉字的导出可能会在Go 2) 规则之后进行调整)。
6.hello.go—— 2009 年12 月11 日
1.2.7 你好,世界! ——V2.0
经过半个世纪的涅槃重生,Go语言不仅可以打印Unicode版本的“Hello, World”,还可以轻松地为全球用户提供打印服务。以下版本打印中文“Hello, world!”和当前时间信息通过http服务发送给每个访问客户端。
package mainimport ( 'fmt' 'log' 'net/http' 'time')func main() { fmt.Println('请访问http://127.0.0.1:12345/') http.HandleFunc('/', func(w http.ResponseWriter , req *http.Request) { s :=fmt.Sprintf('Hello, world! -- Time: %s', time.Now().String()) fmt.Fprintf(w, '%v\n' , s) log.Printf('%v\n', s) }) if err :=http.ListenAndServe(':12345', nil); err !=nil { log.Fatal('ListenAndServe: ', err) }} 这里我们通过Go语言标准库自带的net/http包构建一个独立运行的HTTP服务。其中,http.HandleFunc('/',) 注册根路径/请求的响应处理函数。在响应处理函数中,我们仍然使用fmt.Fprintf()格式化输出函数,将格式化字符串通过HTTP协议打印到请求客户端。同时,相关字符串也通过标准库的日志包打印在服务器端。最后,通过调用http.ListenAndServe()函数启动HTTP服务。
至此,Go语言终于完成了从单机单核时代的C语言到21世纪互联网时代多核环境下的通用编程语言的转变。
在主流编程语言中,数组及相关数据结构是最常用的。链表和哈希表只有在它们(它们)不能满足要求的时候才会被考虑(哈希表可以看成是数组和链表的组合)。混合)和更复杂的自定义数据结构。
1.3.1 数组
数组是特定类型的固定长度元素的序列。数组可以由零个或多个元素组成。数组的长度是数组类型的组成部分。因为数组的长度是数组类型的一部分,不同长度或不同类型的数据组成的数组都是不同的类型,所以在Go 语言中很少直接使用数组(不同长度的数组不能直接赋值,因为不同的长度)类型)。数组对应的类型是切片。切片是一个可以动态增长和收缩的序列。切片的功能也更加灵活。不过,要了解切片的工作原理,必须先了解数组。
我们先看一下数组是如何定义的:
var a [3]int //定义一个长度为3的int类型数组,所有元素均为0 var b=[.]int{1, 2, 3} //定义一个长度为int类型的数组3,元素为1 , 2, 3var c=[.]int{2: 3, 1: 2} //定义一个长度为3 的int 数组,元素为0, 2, 3var d=[.]int{1, 2, 4: 5, 6} //定义一个长度为6的int数组,元素为1, 2, 0, 0, 5, 6。第一种方式是最基本的定义方式一个数组变量。数组的长度被明确指定。数组中的每个元素都用零值进行初始化。
第二种方法是定义一个数组。定义时可以按顺序指定所有元素的初始化值。数组的长度是根据初始化元素的数量自动计算的。
第三种方式是通过索引来初始化数组的元素,因此元素的初始化值出现的顺序是比较随机的。这种初始化方法与map[int]Type类型的初始化语法类似。数组的长度基于出现的最大索引。未显式初始化的元素仍使用零值进行初始化。
第四种方法是第二种和第三种初始化方法的混合。前两个元素按顺序初始化,第三个和第四个元素用零值初始化,第五个元素按索引初始化,最后一个元素按索引初始化。在前面的第五个元素之后使用顺序初始化。
数组的内存结构比较简单。例如,图1-6 显示了[4]int{2,3,5,7} 数组值对应的内存结构。
图1-6 阵列布局
数组可以看作是一种特殊的结构。结构体的字段名对应数组的索引,结构体的成员个数是固定的。内置函数len() 可用于计算数组的长度
度,cap()函数可以用于计算数组的容量。不过对数组类型来说,len()和cap()函数返回的结果始终是一样的,都是对应数组类型的长度。 我们可以用for循环来迭代数组。下面常见的几种方式都可以用来遍历数组: for i := range a { fmt.Printf("a[%d]: %d\n", i, a[i])}for i, v := range b { fmt.Printf("b[%d]: %d\n", i, v)}for i := 0; i < len(c); i++ { fmt.Printf("c[%d]: %d\n", i, c[i])}用for range方式迭代的性能可能会更好一些,因为这种迭代可以保证不会出现数组越界的情形,每轮迭代对数组元素的访问时可以省去对下标越界的判断。 用for range方式迭代,还可以忽略迭代时的下标: var times [5][0]intfor range times { fmt.Println("hello")}其中times对应一个[5][0]int类型的数组,虽然第一维数组有长度,但是数组的元素[0]int大小是0,因此整个数组占用的内存大小依然是0。不用付出额外的内存代价,我们就通过for range方式实现times次快速迭代。 数组不仅可以定义数值数组,还可以定义字符串数组、结构体数组、函数数组、接口数组、通道数组等: // 字符串数组var s1 = [2]string{"hello", "world"}var s2 = [...]string{"你好", "世界"}var s3 = [...]string{1: "世界", 0: "你好", }// 结构体数组var line1 [2]image.Pointvar line2 = [...]image.Point{image.Point{X: 0, Y: 0}, image.Point{X: 1, Y: 1}}var line3 = [...]image.Point{{0, 0}, {1, 1}}// 函数数组var decoder1 [2]func(io.Reader) (image.Image, error)var decoder2 = [...]func(io.Reader) (image.Image, error){ png.Decode, jpeg.Decode,}// 接口数组var unknown1 [2]interface{}var unknown2 = [...]interface{}{123, "你好"}// 通道数组var chanList = [2]chan int{}我们还可以定义一个空的数组: var d [0]int // 定义一个长度为0的数组var e = [0]int{} // 定义一个长度为0的数组var f = [...]int{} // 定义一个长度为0的数组长度为0的数组(空数组)在内存中并不占用空间。空数组虽然很少直接使用,但是可以用于强调某种特有类型的操作时避免分配额外的内存空间,例如用于通道的同步操作: c1 := make(chan [0]int)go func() { fmt.Println("c1") c1 <- [0]int{}}()<-c1在这里,我们并不关心通道中传输数据的真实类型,其中通道接收和发送操作只是用于消息的同步。对于这种场景,我们用空数组作为通道类型可以减少通道元素赋值时的开销。当然,一般更倾向于用无类型的匿名结构体代替空数组: c2 := make(chan struct{})go func() { fmt.Println("c2") c2 <- struct{}{} // struct{}部分是类型,{}表示对应的结构体值}()<-c2我们可以用fmt.Printf()函数提供的%T或%#v谓词语法来打印数组的类型和详细信息: fmt.Printf("b: %T\n", b) // b: [3]intfmt.Printf("b: %#v\n", b) // b: [3]int{1, 2, 3}在Go语言中,数组类型是切片和字符串等结构的基础。以上对于数组的很多操作都可以直接用于字符串或切片中。 1.3.2 字符串 一个字符串是一个不可改变的字节序列,字符串通常是用来包含人类可读的文本数据。和数组不同的是,字符串的元素不可修改,是一个只读的字节数组。每个字符串的长度虽然也是固定的,但是字符串的长度并不是字符串类型的一部分。由于Go语言的源代码要求是UTF8编码,导致Go源代码中出现的字符串面值常量一般也是UTF8编码的。源代码中的文本字符串通常被解释为采用UTF8编码的Unicode码点(rune)序列。因为字节序列对应的是只读的字节序列,所以字符串可以包含任意的数据,包括字节值0。我们也可以用字符串表示GBK等非UTF8编码的数据,不过这时候将字符串看作是一个只读的二进制数组更准确,因为for range等语法并不能支持非UTF8编码的字符串的遍历。 我们可以看看字符串"hello, world"本身对应的内存结构,如图1-7所示。 图1-7 字符串布局 分析可以发现,"hello, world"字符串底层数据和以下数组是完全一致的: var data = [...]byte{ 'h', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd',}字符串虽然不是切片,但是支持切片操作,不同位置的切片底层访问的是同一块内存数据(因为字符串是只读的,所以相同的字符串面值常量通常对应同一个字符串常量): s := "hello, world"hello := s[:5]world := s[7:]s1 := "hello, world"[:5]s2 := "hello, world"[7:]字符串和数组类似,内置的len()函数返回字符串的长度。也可以通过reflect.StringHeader结构访问字符串的长度(这里只是为了演示字符串的结构,并不是推荐的做法): fmt.Println("len(s):", (*reflect.StringHeader)(unsafe.Pointer(&s)).Len) // 12fmt.Println("len(s1):", (*reflect.StringHeader)(unsafe.Pointer(&s1)).Len) // 5fmt.Println("len(s2):", (*reflect.StringHeader)(unsafe.Pointer(&s2)).Len) // 5根据Go语言规范,Go语言的源文件都采用UTF8编码。因此,Go源文件中出现的字符串面值常量一般也是UTF8编码的(对于转义字符,则没有这个限制)。提到Go字符串时,一般都会假设字符串对应的是一个合法的UTF8编码的字符序列。可以用内置的print调试函数或fmt.Print()函数直接打印,也可以用for range循环直接遍历UTF8解码后的Unicode码点值。 下面的"hello,世界"字符串中包含了中文字符,可以通过打印转型为字节类型来查看字符底层对应的数据: fmt.Printf("%#v\n", []byte("hello, 世界"))输出的结果是: []byte{0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0xe4, 0xb8, 0x96, 0xe7, \0x95, 0x8c}分析可以发现,0xe4, 0xb8, 0x96对应中文“世”,0xe7, 0x95, 0x8c对应中文“界”。我们也可以在字符串面值中直接指定UTF8编码后的值(源文件中全部是ASCII码,可以避免出现多字节的字符)。 fmt.Println("\xe4\xb8\x96") // 打印“世”fmt.Println("\xe7\x95\x8c") // 打印“界”图1-8展示了“hello, 世界”字符串的内存结构布局。 图1-8 字符串布局 Go语言的字符串中可以存放任意的二进制字节序列,而且即使是UTF8字符序列也可能会遇到错误的编码。如果遇到一个错误的UTF8编码输入,将生成一个特别的Unicode字符'\uFFFD',这个字符在不同的软件中的显示效果可能不太一样,在印刷中这个符号通常是一个黑色六角形或钻石形状,里面包含一个白色的问号“�”。 下面的字符串中,我们故意损坏了第一字符的第二和第三字节,因此第一字符将会打印为“�”,第二和第三字节则被忽略,后面的“abc”依然可以正常解码打印(错误编码不会向后扩散是UTF8编码的优秀特性之一)。 fmt.Println("\xe4\x00\x00\xe7\x95\x8cabc") // �界abc不过在for range迭代这个含有损坏的UTF8字符串时,第一字符的第二和第三字节依然会被单独迭代到,不过此时迭代的值是损坏后的0: for i, c := range "\xe4\x00\x00\xe7\x95\x8cabc" { fmt.Println(i, c)}// 0 65533 // \uFFF,对应�// 1 0 // 空字符// 2 0 // 空字符// 3 30028 // 界// 6 97 // a// 7 98 // b// 8 99 // c如果不想解码UTF8字符串,想直接遍历原始的字节码,可以将字符串强制转为[]byte字节序列后再进行遍历(这里的转换一般不会产生运行时开销): for i, c := range []byte("世界abc") { fmt.Println(i, c)}或者是采用传统的下标方式遍历字符串的字节数组: const s = "\xe4\x00\x00\xe7\x95\x8cabc"for i := 0; i < len(s); i++ { fmt.Printf("%d %x\n", i, s[i])}Go语言除了for range语法对UTF8字符串提供了特殊支持外,还对字符串和[]rune类型的相互转换提供了特殊的支持。 fmt.Printf("%#v\n", []rune("世界")) // []int32{19990, 30028}fmt.Printf("%#v\n", string([]rune{'世', '界'})) // 世界从上面代码的输出结果可以发现[]rune其实是[]int32类型,这里的rune只是int32类型的别名,并不是重新定义的类型。rune用于表示每个Unicode码点,目前只使用了21个位。 字符串相关的强制类型转换主要涉及[]byte和[]rune两种类型。每个转换都可能隐含重新分配内存的代价,最坏的情况下它们运算的时间复杂度都是O(n)。不过字符串和[]rune的转换要更为特殊一些,因为一般这种强制类型转换要求两个类型的底层内存结构要尽量一致,显然它们底层对应的[]byte和[]int32类型是完全不同的内存结构,因此这种转换可能隐含重新分配内存的 操作。 下面分别用伪代码简单模拟Go语言对字符串内置的一些操作,这样对每个操作的处理的时间复杂度和空间复杂度都会有较明确的认识。 for range对字符串的迭代模拟实现如下: func forOnString(s string, forBody func(i int, r rune)) { for i := 0; len(s) > 0; { r, size := utf8.DecodeRuneInString(s) forBody(i, r) s = s[size:] i += size }}for range迭代字符串时,每次解码一个Unicode字符,然后进入for循环体,遇到崩溃的编码并不会导致迭代停止。 string(runes)转换模拟实现如下: func runes2string(s []int32) string { var p []byte buf := make([]byte, 3) for _, r := range s { n := utf8.EncodeRune(buf, r) p = append(p, buf[:n]...) } return string(p)}同样因为底层内存结构的差异,[]rune到字符串的转换也必然会导致重新构造字符串。这种强制转换并不存在前面提到的优化情况。 1.3.3 切片 简单地说,切片(slice)就是一种简化版的动态数组。因为动态数组的长度不固定,所以切片的长度自然也就不能是类型的组成部分了。数组虽然有适用的地方,但是数组的类型和操作都不够灵活,因此在Go代码中数组使用得并不多。而切片则使用得相当广泛,理解切片的原理和用法是Go程序员的必备技能。 我们先看看切片的结构定义,即reflect.SliceHeader: type SliceHeader struct { Data uintptr Len int Cap int}由此可以看出切片的开头部分和Go字符串是一样的,但是切片多了一个Cap成员表示切片指向的内存空间的最大容量(对应元素的个数,而不是字节数)。图1-9给出了x := []int{2,3,5, 7,11}和y := x[1:3]两个切片对应的内存结构。 图1-9 切片布局 让我们看看切片有哪些定义方式: var ( a []int // nil切片,和nil相等,一般用来表示一个不存在的切片 b = []int{} // 空切片,和nil不相等,一般用来表示一个空的集合 c = []int{1, 2, 3} // 有3个元素的切片,len和cap都为3 d = c[:2] // 有2个元素的切片,len为2,cap为3 e = c[0:2:cap(c)] // 有2个元素的切片,len为2,cap为3 f = c[:0] // 有0个元素的切片,len为0,cap为3 g = make([]int, 3) // 有3个元素的切片,len和cap都为3 h = make([]int, 2, 3) // 有2个元素的切片,len为2,cap为3 i = make([]int, 0, 3) // 有0个元素的切片,len为0,cap为3)和数组一样,内置的len()函数返回切片中有效元素的长度,内置的cap()函数返回切片容量大小,容量必须大于或等于切片的长度。也可以通过reflect.SliceHeader结构访问切片的信息(只是为了说明切片的结构,并不是推荐的做法)。切片可以和nil进行比较,只有当切片底层数据指针为空时切片本身才为nil,这时候切片的长度和容量信息将是无效的。如果有切片的底层数据指针为空,但是长度和容量不为0的情况,那么说明切片本身已经被损坏了(例如,直接通过reflect.SliceHeader或unsafe包对切片作了不正确的修改)。 如前所述,切片是一种简化版的动态数组,这是切片类型的灵魂。除构造切片和遍历切片之外,添加切片元素、删除切片元素都是切片处理中经常遇到的操作。 1.添加切片元素 用copy()和append()组合可以避免创建中间的临时切片,同样是完成添加元素的操作: a = append(a, 0) // 切片扩展一个空间copy(a[i+1:], a[i:]) // a[i:]向后移动一个位置a[i] = x // 设置新添加的元素第一句中的append()用于扩展切片的长度,为要插入的元素留出空间。第二句中的copy()操作将要插入位置开始之后的元素向后挪动一个位置。第三句真实地将新添加的元素赋值到对应的位置。操作语句虽然冗长了一点,但是相比前面的方法,可以减少中间创建的临时切片。 2.删除切片元素 根据要删除元素的位置,有从开头位置删除、从中间位置删除和从尾部删除3种情况,其中删除切片尾部的元素最快: a = []int{1, 2, 3}a = a[:len(a)-1] // 删除尾部1个元素a = a[:len(a)-N] // 删除尾部N个元素删除开头的元素可以直接移动数据指针: a = []int{1, 2, 3}a = a[1:] // 删除开头1个元素a = a[N:] // 删除开头N个元素删除开头的元素也可以不移动数据指针,而将后面的数据向开头移动。可以用append()原地完成(所谓原地完成是指在原有的切片数据对应的内存区间内完成,不会导致内存空间结构的变化): a = []int{1, 2, 3}a = append(a[:0], a[1:]...) // 删除开头1个元素a = append(a[:0], a[N:]...) // 删除开头N个元素也可以用copy()完成删除开头的元素: a = []int{1, 2, 3}a = a[:copy(a, a[1:])] // 删除开头1个元素a = a[:copy(a, a[N:])] // 删除开头N个元素对于删除中间的元素,需要对剩余的元素进行一次整体挪动,同样可以用append()或copy()原地完成: a = []int{1, 2, 3, ...}a = append(a[:i], a[i+1:]...) // 删除中间1个元素a = append(a[:i], a[i+N:]...) // 删除中间N个元素a = a[:i+copy(a[i:], a[i+1:])] // 删除中间1个元素a = a[:i+copy(a[i:], a[i+N:])] // 删除中间N个元素删除开头的元素和删除尾部的元素都可以认为是删除中间元素操作的特殊情况。 3.切片内存技巧 在本节开头的数组部分我们提到过有类似[0]int的空数组,空数组一般很少用到。但是对于切片来说,len为0但是cap容量不为0的切片则是非常有用的特性。当然,如果len和cap都为0的话,则变成一个真正的空切片,虽然它并不是一个nil的切片。在判断一个切片是否为空时,一般通过len获取切片的长度来判断,一般很少将切片和nil做直接的比较。 例如下面的TrimSpace()函数用于删除[]byte中的空格。函数实现利用了长度为0的切片的特性,实现高效而且简洁。 func TrimSpace(s []byte) []byte { b := s[:0] for _, x := range s { if x != ' ' { b = append(b, x) } } return b}其实类似的根据过滤条件原地删除切片元素的算法都可以采用类似的方式处理(因为是删除操作,所以不会出现内存不足的情形): func Filter(s []byte, fn func(x byte) bool) []byte { b := s[:0] for _, x := range s { if !fn(x) { b = append(b, x) } } return b}切片高效操作的要点是要降低内存分配的次数,尽量保证append()操作不会超出cap的容量,降低触发内存分配的次数和每次分配内存的大小。 4.避免切片内存泄漏 例如,FindPhoneNumber()函数加载整个文件到内存,然后搜索第一个出现的电话号码,最后结果以切片方式返回。 func FindPhoneNumber(filename string) []byte { b, _ := ioutil.ReadFile(filename) return regexp.MustCompile("[0-9]+").Find(b)}这段代码返回的[]byte指向保存整个文件的数组。由于切片引用了整个原始数组,导致垃圾回收器不能及时释放底层数组的空间。一个小的需求可能导致需要长时间保存整个文件数据。这虽然不是传统意义上的内存泄漏,但是可能会降低系统的整体性能。 5.切片类型强制转换 为了安全,当两个切片类型[]T和[]Y的底层原始切片类型不同时,Go语言是无法直接转换类型的。不过安全都是有一定代价的,有时候这种转换是有它的价值的——可以简化编码或者是提升代码的性能。例如在64位系统上,需要对一个[]float64切片进行高速排序,我们可以将它强制转换为[]int整数切片,然后以整数的方式进行排序(因为float64遵循IEEE 754浮点数标准特性,所以当浮点数有序时对应的整数也必然是有序的)。 下面的代码通过两种方法将[]float64类型的切片转换为[]int类型的切片: // +build amd64 arm64import "sort"var a = []float64{4, 2, 5, 7, 2, 1, 88, 1}func SortFloat64FastV1(a []float64) { // 强制类型转换 var b []int = ((*[1 << 20]int)(unsafe.Pointer(&a[0])))[:len(a):cap(a)] // 以int方式给float64排序 sort.Ints(b)}func SortFloat64FastV2(a []float64) { // 通过reflect.SliceHeader更新切片头部信息实现转换 var c []int aHdr := (*reflect.SliceHeader)(unsafe.Pointer(&a)) cHdr := (*reflect.SliceHeader)(unsafe.Pointer(&c)) *cHdr = *aHdr // 以int方式给float64排序 sort.Ints(c)}第一种强制转换是先将切片数据的开始地址转换为一个较大的数组的指针,然后对数组指针对应的数组重新做切片操作。中间需要unsafe.Pointer来连接两个不同类型的指针传递。需要注意的是,Go语言实现中非0大小数组的长度不得超过2 GB,因此需要针对数组元素的类型大小计算数组的最大长度范围([]uint8最大2 GB,[]uint16最大1 GB,依此类推,但是[]struct{}数组的长度可以超过2 GB)。 第二种转换操作是分别取两个不同类型的切片头信息指针,任何类型的切片头部信息底层都对应reflect.SliceHeader结构,然后通过更新结构体方式来更新切片信息,从而实现a对应的[]float64切片到c对应的[]int切片的转换。 通过基准测试,可以发现用sort.Ints对转换后的[]int排序的性能要比用sort.Float64s排序的性能高一点。不过需要注意的是,这个方法可行的前提是要保证[]float64中没有NaN和Inf等非规范的浮点数(因为浮点数中NaN不可排序,正0和负0相等,但是整数中没有这类情形)。热门手游排行榜
用户评论
哇,这本关于Go语言的新书真是太棒了!对游戏开发者来说,了解Go能带来很多优势。
有13位网友表示赞同!
Go语言在游戏开发中的性能提升是前所未见的,读完这本书后,我对未来的游戏有了更多期待。
有8位网友表示赞同!
作为一个游戏爱好者兼初学者码农,觉得这本关于Go语的语言教程很实用,对写游戏脚本有帮助。
有7位网友表示赞同!
书里的内容非常详细,从基础语法到高级特性都覆盖了,非常适合刚开始接触编程语言的玩家。
有8位网友表示赞同!
对于想在游戏领域大展身手的开发者来说,这本书是必备的好资料。Go语言让我的项目进展顺利多了。
有12位网友表示赞同!
读完这本关于Go语言的游戏开发书籍后,我认为学习一门高效的脚本语言对于提高游戏性能至关重要。
有14位网友表示赞同!
无论是专业的游戏开发者还是对编程感兴趣的新手玩家,这本关于Go的指南都能提供有价值的知识点。
有19位网友表示赞同!
我对这本书的结构和内容深感满意。它以清晰的方式解释了Go语言在游戏开发中的应用,值得推荐给同行。
有20位网友表示赞同!
之前一直想要学习Go语言,这本书完美地结合了理论与实践案例,帮助我快速上手,非常实用!
有16位网友表示赞同!
对于那些正在探索将Go应用于游戏行业的开发者来说,本书是一个很好的起点和指导。
有6位网友表示赞同!
自从接触了关于Go的这本新书后,我发现它的特性非常适合用来优化游戏性能和创建更流畅的游戏体验。
有6位网友表示赞同!
无论是游戏设计师还是后台程序员,掌握Go语言都能更好地提升自己的技能,这本书是个绝佳的学习资源。
有19位网友表示赞同!
书里提供了很多Go在游戏引擎集成中的实现案例,对提升我的代码效率有巨大帮助。强烈推荐给同好们!
有6位网友表示赞同!
对于想让游戏更流畅、更稳定的朋友来说,了解Go语言会是一个明智的选择,这本新书是入门的好方式。
有7位网友表示赞同!
我非常喜欢这本书的实践导向,通过读它,我已经能够将Go技能应用到游戏中,感觉编程变得更有乐趣了。
有14位网友表示赞同!
无论是准备踏入编程界的玩家还是已经在游戏行业内工作多年的技术大神,这本书都能提供新视角和知识点。
有5位网友表示赞同!
自从尝试使用Go语言来做一些简单的游戏逻辑后,我发现它的简洁性强、性能高,并且易于学习。
有14位网友表示赞同!
对于那些热衷于探索新技术的游戏开发者来说,这本关于Go的语言教程绝对不可错过。它为我们的工作打开了新的大门。
有11位网友表示赞同!
这本书通过具体案例介绍了如何在游戏开发中使用Go语言,让我对这门语言有了更深的理解和实践体会。
有17位网友表示赞同!
阅读此书后,我认识到Go语言的强大之处不仅在于底层性能提升,还在于它的并发模型特别适用于多线程环境的游戏设计。推荐给所有游戏工程师。
有6位网友表示赞同!