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, 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」:主要用于判别文字符号特性;例如IsDigitIsLetterIsUpperIsLower。这些函数以单个字符作为参数,并返回布尔值。

下面我们用一些例子说明这些包的用法。

移除文件的系统路径和后缀

下例中,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 也不会一起变化。

但如果我们不需要这种特性,就会产生不必要的内存消耗,为了避免这种情况,bytesstrings包中都包含了相应的使用函数,它们两两对应。例如,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

FormatIntFormatUnit可以按不同的进位制格式化数字:

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

声明:该文章系转载,转载该文章的目的在于更广泛的传递信息,并不代表本网站赞同其观点,文章内容仅供参考。

本站是一个个人学习和交流平台,网站上部分文章为网站管理员和网友从相关媒体转载而来,并不用于任何商业目的,内容为作者个人观点, 并不代表本网站赞同其观点和对其真实性负责。

我们已经尽可能的对作者和来源进行了通告,但是可能由于能力有限或疏忽,导致作者和来源有误,亦可能您并不期望您的作品在我们的网站上发布。我们为这些问题向您致歉,如果您在我站上发现此类问题,请及时联系我们,我们将根据您的要求,立即更正或者删除有关内容。本站拥有对此声明的最终解释权。