西娅(Thea)是一个刚刚入门Go语言的妹子程序员,今天她遇到了一个让她“surprise”的问题。下面就是那段让妹子西娅困惑的Go代码:
func main() {
s1 := "12345"
s2 := "2"
fmt.Println(`"12345" > "2":`, s1 > s2) // false
s3 := "零"
s4 := "一"
s5 := "二"
fmt.Println(`"一" > "零":`, s4 > s3) // false
fmt.Println(`"二" > "零":`, s5 > s3) // false
fmt.Println(`"二" > "一":`, s5 > s4) // true
}
在这段关于Go字符串比较的代码中:
为什么表达式"12345" > "2"的求值结果是false呢?
为什么"一" > "零"和"二" > "零"两个表达式的求值结果都是false呢?
而"二" > "一"的求值结果却又为true呢?
四个结果都让西娅百思不得其解!于是西娅在网络上寻找能为其解惑的Go技术资料。
她网上看到一本名为《Go语言精进之路》的“小黄书”,据说这本书中有有关Go字符串原理与字符串比较的详细讲解。
西娅不经意间瞥见,旁边的同事Tony桌上摆着一本黄色的、厚重的书,这不正是她想看的吗!于是西娅向Tony发出了借书一阅的请求。Tony面对“美女攻势”向来是“每战必败”的,于是西娅顺利地拿到了两卷本的《Go语言精进之路》。借午休时间,西娅花了1.5个小时认真学习了书中有关Go字符串的三个章节:第15节的“了解string实现原理和高效使用”、 第52节的“掌握字符集的原理和字符编码方案间的转换”和第56节的“掌握bytes包和strings包的基本操作”。看完后大呼Wonderful!书中的讲解完全解答了西娅的问题。
此时西娅想起了在《Go语言第一课专栏》[1]的结课语《和你一起迎接Go的黄金十年》[2]中作者关于学习Go语言方法的建议:输出大法!通过输出将学到的知识真正内化为自己的知识,于是西娅将自己对书中内容的理解记录了下来。恰好此时旁边的Tony刚刚从午睡中苏醒过来,西娅决定再为一把人师。Tony就这样被稀里糊涂地拽了过来充当学生:)。
以下是西娅的讲解。
1. Go语言中的字符串类型
字符串类型是现代编程语言中最常使用的数据类型之一。在Go语言的先祖之一C语言当中,字符串类型并没有被显式定义,而是以字符串字面值 常量或以'\0'结尾的字符类型(char)数组来呈现的。
Go语言修复了C语言的这一“缺陷”,原生内置了string类型,统一了对“字符串”的抽象。在Go语言中,无论是字符串常量、字符串变量或是代码中出现的字符串字面量,它们的类型都被统一设置为string。
Go的string类型设计充分吸取了C语言字符串设计的经验教训,并结合了其他主流语言在字符串类型设计上的最佳实践,最终为Gopher呈现的string类型具有如下功能特点:
string类型的数据是不可变的
即一旦声明了一个string类型的标识符,无论是常量还是变量,该标识符所指代的数据在整个程序的生命周期内便无法被更改。
零值可用
Go string类型支持零值可用的理念。Go字符串无需像C语言中那样考虑结尾'\0'字符,因此其零值为"",长度为0。
获取长度的时间复杂度是O(1)级别
支持各种比较关系操作符:==、!= 、>=、<=、> 和<
鉴于Go string是不可变的,因此如果两个字符串的长度不相同,那么无需比较具体字符串数据,也可以断定两个字符串是不同的;如果长度相 同,则要进一步判断数据指针是否指向同一块底层存储数据。如果相同,则两个字符串是等价的,如果不同,则还需进一步去比对实际的数据内容。至于怎么比较,我接下来会讲。
对非ASCII字符提供原生支持
这一特点就涉及到Go字符串中的字符是什么字符、用什么字符编码的问题了。下面我们就来看看。
2. Go字符串采用的字符集编码
Go语言默认使用Unicode字符集,并采用UTF-8编码方案,Go还提供了rune原生类型来表示Unicode字符。Unicode(万国码/统一码)在1994年发布,它是以收纳人类所有字符为目的的统一字符集。Unicode字符集就是将世界上存在的绝大多数常用字符进行统一排队和编号。比如下面是一个Unicode字符集表的片段:
序号 | 字符 |
---|---|
U+0000 | ... ... |
... ... | ... ... |
U+0031 | 1 |
U+0032 | 2 |
... ... | ... ... |
U+4E2D | 中 |
... ... | ... ... |
U+4EBA | 人 |
... ... | ... ... |
U+56FD | 国 |
... ... | ... ... |
U+10FFFF | ... ... |
我们看到每个Unicode字符(比如表格里的"1"、"中"等)都有自己的唯一序号,这个序号就叫做字符的码点(code point),Go中的rune类型可用于表示码点。
好了,问题来了!Unicode字符集表格有了,Go是如何在内存中存储这些字符的呢?目前业界有多种存储方案,比如:UTF-32(即4个字节表示每个Unicode字符码点)、UTF-16(使用2个字节或4个字节表示每个Unicode字符码点)以及UTF-8。
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-16和UTF-32方案,UTF-8方案的空间利用率也是最高的。并且,utf8解码和编码时,也无需考虑字节序问题。
于是,Go语言使用了Utf8编码方案在内存中存储Unicode字符。
以字符“中”为例,它的码点(序号)为U+4E2D,它在Utf8编码则为“0xE4 0xB8 0xAD”,即在内存中Go实际用三个字节来表示“中”这个Unicode字符。
3. Go字符串比较
上面铺垫了这么些内容,就是为了为字符串比较开道。关于Go字符串比较,Go语言规范[3]中只说了一句话:String values are comparable and ordered, lexically byte-wise。什么意思呢?这句话表达了三个意思:
定性:字符串可比较
定量:字符串是有序的
方法:逐字节
下面我对开篇的例子做逐一说明,首先看下面代码:
s1 := "12345"
s2 := "2"
fmt.Println(`"12345" > "2":`, s1 > s2)
s1和s2两个字符串中的字符都是ASCII字符范畴的,每个字符在内存中的编码都是一个字节。按照Go string比较的原理,我们对s1和s2进行逐字节比较。首先比较s1的第一个字符"1"和s2的第一个字符"2"。字符"2"在内存中的字节为0x32,而字符"1"在内存中的字节为0x31,显然0x32大于0x31,到这里已经比出大小了,程序不会继续对后续的字符进行比对了。这也是为什么s1 > s2这个表达式为false的原因。
如果s2 = "12346"呢?那么按照Go string比较的原理,程序在比较s1和s2的前4个字符时都相等,于是只能由第5个字符来判定两个字符串的大小了,s2的第五个字符"6"显然大于s1的第五个字符"5",于是当s2="12346"时,s2是大于s1的。
我们再看看含有汉字的字符串的例子:
s3 := "零"
s4 := "一"
s5 := "二"
fmt.Println(`"一" > "零":`, s4 > s3) // false
fmt.Println(`"二" > "零":`, s5 > s3) // false
fmt.Println(`"二" > "一":`, s5 > s4) // true
为了方便后续说明,我们先把"零"、"一"和"二"这三个汉字的Utf8编码计算出来:
"零"的UTF8编码为:0xE9 0x9B 0xB6
"一"的UTF8编码为:0xE4 0xB8 0x80
"二"的UTF8编码为:0xE4 0xBA 0x8C
我们看到,三个汉字的Utf8编码都是三个字节。
好了接下来,我们先比较s4("一")和s3("零")。根据Go字符串比较原理,程序对s3和s4做逐字节比较,"零"这个字符的第一个字节为0xE9,而"一"这个字符的第一个字节为0xE4,我们知道0xE9 > 0xE4,于是比较停止,判定:s3 > s4。
同理,s3 > s5。
在比较s4("一")和s5("二")时,由于它们的第一个字节都是0xE4,于是第二个字节决定了它们的大小,0xBA > 0xB8,所以s5 > s4。
4. Go strings包中的Compare函数
Go标准库在strings包中提供了Compare函数用于对两个字符串做大小比较。但按照Go团队的comment,这个函数存在的意义更多是是为了与bytes包尽量保持API的一致,其自身也是使用原生排序比较操作符实现的:
// $GOROOT/src/strings/compare.go
func Compare(a, b string) int {
if a == b {
return 0
}
if a < b {
return -1
}
return +1
}
实际应用中,我们很少使用strings.Compare更多的是直接使用排序比较操作符对字符串类型变量进行比较,这样更直观,性能大多数场景也会更高,毕竟少一次函数调用。
“好了以上就是我要讲给你听的,听懂了么”。西娅兴高采烈地对此时已经处于清醒状态的Tony说。
“讲的真好。比我书里讲的还透彻”。Tony一边鼓掌一边微笑着说。“程序员妹子西娅Thea终于把Go字符串比较讲清楚了”。
西娅惊讶!“你的什么书”?
Tony指了指办公桌上的小黄书说:“这书就是我写的啊^_^”。
西娅脸上现出一丝红晕... ...。
“Gopher部落”知识星球[4]旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!
Gopher Daily(Gopher每日新闻)归档仓库 - https://github.com/bigwhite/gopherdaily
我的联系方式:
微博:https://weibo.com/bigwhite20xx
微信公众号:iamtonybai
博客:tonybai.com
github: https://github.com/bigwhite
“Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544
商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。
参考资料
[1]
《Go语言第一课专栏》: http://gk.link/a/10AVZ
[2]《和你一起迎接Go的黄金十年》: https://time.geekbang.org/column/article/486536
[3]Go语言规范: https://go.dev/ref/spec
[4]“Gopher部落”知识星球: https://wx.zsxq.com/dweb2/index/group/51284458844544