西娅(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语言编程质量的50个有效实践》的专栏,据说这个专栏中有有关Go字符串原理与字符串比较的详细讲解。
西娅不经意间瞥见,旁边的同事Tony 电脑屏幕上打开的正是该专栏的网页,这不正是她想看的吗!于是西娅向Tony发出了借账号一阅的请求。Tony面对“美女攻势”向来是“每战必败”的,于是西娅顺利地拿到了账号。借午休时间,西娅花了1.5个小时认真学习了书中有关Go字符串的几个章节,看完后大呼Wonderful!专栏中的讲解完全解答了西娅的问题。
此时西娅看到了专栏作者关于学习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语言规范中只说了一句话: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指了指电脑屏幕上显示的专栏说:“这专栏就是我写的啊_”。
西娅脸上现出一丝红晕… …。
讲师主页:tonybai_cn
讲师博客: Tony Bai
专栏:《改善Go语言编程质量的50个有效实践》
实战课:《Kubernetes实战:高可用集群搭建,配置,运维与应用》
免费课:《Kubernetes基础:开启云原生之门》