Go 中的字符串相关操作
string 与 UTF-8
Go 中使用 UTF-8 对字符进行编码
首先,我们需要对字符编码有一定相关的了解,并明白为什么 Go 选中 UTF-8 作为字符编码方式。
ASCII 和 Unicode
在计算机行业在美国兴起时,人们使用「ASCII」对字符集进行处理:ASCII 使用 7 位 128 个字符(大小写英文字母、数字、标点以及设备控制符)。这对当时的行业来说已经足够使用了,但随着计算机行业的兴起,世界上使用其他语言的人无法在计算机上使用自己的文书体系。
为了解决这个问题,人们开始使用「Unicode」,如今已经定义到了第 8 版,定义了超过一百种语言文字的 12 万个字符的码点。Unicode 需要 32 位比特,也就是 4 个字节,计算机中的int32
便很适合保存这种数据类型,Go 中便是这样认为的,因此为int32
设置了别名rune
。
但如果我们将所有的字符都按照「Unicode」进行编码,这种编码方式称为 UTF-32 或者 UCS-4,每个 Unicode 码点都需要占 4 个字节;但,大多数计算机的可读文本为 ASCII,只需要 1 个字节便可以满足编码要求,而广泛使用的字符也只需要 16 位字符即可,因此这种方式导致了不必要的存储空间消耗。
UTF-8
UTF-8 以字节为单位对 Unicode 码点进行变长编码,是现行的一种 Unicode 标准。它每个符号用 1~4 个字节表示,例如 ASCII 的编码仅需 1 个字节,其他常用的文字编码是 2 或者 3 个字节。
在 UTF-8 中,「首字节的最高位」指明后面还有多少字节:
-
若最高位为 0,则表示它是 7 位的 ASCII 码,那么它只需要使用一个字节;
-
若最高几位是 110,那么它占用了两个字节,则文字符号占用 2 个字节进行编码,第二个字节以 10 开始,更长的编码也是以此类推。
因此,对于需要不同空间的字符,UTF-8 的编码方式如下:
0xxxxxxx 文字符号 0 ~ 127 ASCII
110xxxxx 10xxxxxx 128 ~ 2047 少于 128 个未使用的值
1110xxxx 110xxxxx 10xxxxxx 2048 ~ 65535 少于 2048 个未使用的值
11110xxx 1110xxxx 110xxxxx 10xxxxxx 65536 ~ 0x10ffff 其他未使用的值
显然,对于 UTF-8,我们不能按下标直接访问第 n 个字符,以此为代价,我们得到了许多方便的特性:
-
UTF-8 编码紧凑,兼容 ASCII,且自同步:最多追溯 3 字节,就能定位一个字符的起始位置;
-
UTF-8是前缀编码,故能够从左往右解码而不产生歧义,也无需超前预读;
-
UTF-8 的编码顺序与字典序一致(Unicode 的码点顺序和字典序一致);
-
UTF-8编码本身不会嵌入 NUL 字节(0 值),因此我们可以使用 NUL 标记字符串结尾。
Go 中的 UTF-8
Go 的源文件总是以 UTF-8 进行编码,同时,其操作的文本字符串也是优先使用 UTF-8。
如何表示 UTF-8 字符
Go 中,string 字面量的转义让我们可以使用码点来指明 Unicode 字符。有两种形式:\uhhhh
表示 16 位码点,\uhhhhhhhh
表示 32 位码点(h 表示一个十六进制的数字),32 位的码点基本用不到。这两种形式都能用 UTF-8 表示给定的码点,因此,下面三个字符串表示的是长度为 6 的相同串:
"世界"
"\xe4\xb8\x96\xe7\x95\x8c"
"\u4e16\u754c"
"\U00004e16\U0000754c"
「码点值小于 256 的文字符号」(也就是 ASCII 码)可以写成单个十六进制转义的形式,如将'A'
写成'\x41'
;更高的码点必须使用\u
或者\U
进行转义,这也导致前面的\xe4\xb8\x96
不是合法的文字符号。
常用操作
由于 UTF-8 的优良特性,许多字符串操作都无需解码,下面是strings
包中一些源码。
可以直接判断某个字符串是否为另一个前缀:
func HasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
或者判断是否为另一个字符串的后缀:
func HasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}
或者是否为另一个字符串的字串(实际上的实现使用了散列让搜索更高效):
func Contains(s, substr string) bool {
for i := 0; i < len(s)-len(substr); i++ {
if HasPrefix(s[i:], substr) {
return true
}
}
return false
}
处理 Unicode 字符
Go 中的unicode
包拥有对单个文字符号的函数(例如区分字母和数字,转换大小写),unicode/utf8
包提供了按 UTF-8 编码和解码文字符号的函数。
在实际处理 Unicode 字符时,我们需要注意它实际上的字节数;看下面的例子:
import "unicode/utf8"
s := "世界"
fmt.Println(len(s)) // 输出:6
fmt.Println(utf8.RuneCountInStrings(s)) // 输出:2
可以看到,我们需要按做 UTF-8 解读,才能得到符合常规认知的字符长度。
如果我们需要逐个处理这些字符,就需要使用 UTF-8 的解码器,例如unicode/utf8
中的:
s := "世界, hello"
for i := 0; i < len(s) {
r, size := utf8.DecodeRuneInString(s[i:])
fmt.Printf("%d\t%c\n", i, r)
i += size
}
每次调用DecodeRuneInString
的调用都会返回 r(文字符号本身)和一个值 size(表示 r 按照 UTF-8 所占的字节数)。我们用 size 来更新 slice 的下标,这样就能够正确的打印字符:
0 世
3 界
6 ,
7
8 h
9 e
10 l
11 l
12 o
幸好 Go 中的「range 循环」也适用于字符串,对 UTF-8 进行隐式解码,所以下述语句也能达到同样的效果:
for i, r := range s {
fmt.Printf("%d\t%q\t%d\n", i, r, r)
}
这里的r
可以用%q
或者%d
来表示,前者会打印字符(如世
),后者打印对应的 unicode(如19990
)。
也因为 range 循环有对 UTF-8 的隐式编码,因此我们可以直接使用它来统计字符串中的文字符号数:
n := 0
for range s {
n++
}
Go 中的相关标准库
Go 语言中 4 个标准包对字符串操作很重要:bytes、strings、strconv 与 unicode
-
「strings」:提供用于搜索、替换、比较、修整、切分与连接字符串的函数
-
「bytes」:用于操作字节slice([]byte 类型的某些属性和字符串相同)。例如可以使用
bytes.Buffer
高效地按增量方式构建字符串。 -
「strconv」:主要用于 string 与布尔值、整数、浮点数之间的相互转换,或者是用于为字符串添加/去除引号。
-
「unicode」:主要用于判别文字符号特性;例如
IsDigit
、IsLetter
、IsUpper
和IsLower
。这些函数以单个字符作为参数,并返回布尔值。
下面我们用一些例子说明这些包的用法。
移除文件的系统路径和后缀
下例中,basename 函数模仿 UNIX shell 中的同名实用程序,移除文件的系统路径和可能存在的后缀:
1.首先我们看看不依赖任何库的初版 basename:
/*
basename 移除路径部分以及 .后缀
e.g., a=>a, a.go=>a, a/b/c.go=>c
*/
func basename(s string) string {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '/' {
s = s[i + 1:]
break
}
}
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '.' {
s = s[:i]
break
}
}
return s
}
2.接下来我们使用库函数string.LastIndex
来简化代码:
func basename(s string) string {
slash := strings.LastIndex(s, "/") // 如果没找到"\",slash 的取值为 -1
s = s[slash+1:]
if dot := string.LastIndex(s, "."); dot >= 0 {
s = s[:dot]
}
return s
}
规范化整数字符串
这个例子中,我们对子字符串进行操作:接受一个表示整数的字符串,如12345
,从右侧开始每隔三个数字就插入一个逗号,形如12,345
:
func comma(s string) string {
n := len(3)
if n <= 3 {
return s
}
return comma(s[:n-3]) + "," + s[n-3:]
}
在 Go 语言中,字符串可以和字节 slice 相互转换:
s := "abc"
b := []byte(s)
s2 := string(b)
正常情况下,这种 string 和 slice 的相互转换都会进行拷贝,这样可以保证即使 b 的字节在转换后发生改变,s 也不会一起变化。
但如果我们不需要这种特性,就会产生不必要的内存消耗,为了避免这种情况,bytes
和strings
包中都包含了相应的使用函数,它们两两对应。例如,string
包中有下面 6 个函数:
func Contains(s, substr string) bool
func Count(s, sep string) bool
func Fields(s string) []string
func HasPrefix(s, prefix string) bool
func Index(s, sep string) int
func Join(a []string, sep string) string
bytes
包中的对应函数为:
func Contains(b, subslice []byte) bool
func Count(b, sep []byte) bool
func Fields(b []byte) [][]byte
func HasPrefix(b, prefix []byte) bool
func Index(b, sep []byte) int
func Join(a [][]byte, sep []byte) []byte
唯一不同的是,操作对象由字符串变为了 slice
bytes
包为高效处理字节 slice 提供了「Buffer」类型。它起始为空,大小随着各种类型数据的写入而增长,如 string、byte 和 []byte。如下例,bytes.Buffer
变量无需初始化,因为零值本来就有效:
// intsToString 与 fmt.Sprintf(values) 类似,但插入了逗号
func intsToString(values []int) string {
var buf bytes.Buffer
buf.WriteByte('[')
for i, v := range values {
if i > 0 {
buf.WriteString(", ")
}
fmt.Fprintf(&buf, "%d", v)
}
buf.WriteByte(']')
}
func main() {
fmt.Println(intsToString([]int{1, 2, 3})) // 输出: [1, 2, 3]
}
如果要在byte.Buffer
变量后添加任意文字符号的 UTF-8 编码,最好使用WriteRune
方法,而追加 ASCII 字符,则使用WriteByte
即可。
字符串和数字的相互转换
通常,要将整数转换成字符串,一种选择是使用fmt.Sprintf
,另一种做法是用函数strconv.Itoa
:
x := 123
y := fmt.Sprintf("%d", x)
fmt.Println(y, strconv(x)) // 输出: 123 123
而FormatInt
和FormatUnit
可以按不同的进位制格式化数字:
fmt.Println(strconv.FormatInt(int64(x), 2)) // 输出 x 的二进制表示: 1111011
golang字符串比较的三种常见方法
// 1. 自建方法“==”,区分大小写,最简单的方法
fmt.Println("go"=="go") // true
fmt.Println("GO"=="go") // false
// 2. Compare函数,区分大小写,比自建方法“==”的速度要快,下面是注释
// Compare is included only for symmetry with package bytes.
// It is usually clearer and always faster to use the built-in
// string comparison operators ==, <, >, and so on.
// func Compare(a, b string) int
fmt.Println(strings.Compare("GO","go")) // -1 ,也就是 "GO" < "go" (因为是字典序)
fmt.Println(strings.Compare("go","go")) // 0
// 3. 比较UTF-8编码在小写的条件下是否相等,不区分大小写,下面是注释
// EqualFold reports whether s and t, interpreted as UTF-8 strings,
// are equal under Unicode case-folding.
// func EqualFold(s, t string) bool
fmt.Println(strings.EqualFold("GO","go")) // true,因为不区分大小写
输出:
true
false
-1
0
true