Go语言核心之美 2.5-字符串

erlib · · 964 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

字符串是不可变的字节序列,虽然可以包含任意数据,包括0这个字节,不过字符串通常是用来包含可读性较强的文本。文本字符串通常采用UTF-8编码,由Unicode码点(rune)组成。

内置的len函数会返回字符串的所有字节(byte)数(注意不是rune的数目!!一个rune可能包含多个字节),下标操作s[i]可以获取字符串的第i 个字节(从0开始),   其中i >= 0 并且 i < len(s):

s := "hello, world"
fmt.Println(len(s))     // "12"
fmt.Println(s[0], s[7]) // "104 119" ('h' and 'w')
访问索引边界外的字节会造成运行时panic:
c := s[len(s)] // panic: index out of range
字符串的第i个字节未必是字符串的第i个字符,因为对非ASCII码字符进行UTF-8编码时需要两个或者更多的字节。

s[i:j]会基于原始字符串创建一个新的子字符串,这里取第i个字节到第j-1个字节:

fmt.Println(s[0:5]) // "hello"
如果索引越界或者j 小于i,会造成panic。


可以省略i或者j,或者两者都省略。如果省略i,那么默认从0开始,如果省略j,默认截止到len(s):

fmt.Println(s[:5]) // "hello"
fmt.Println(s[7:]) // "world"
fmt.Println(s[:])  // "hello, world"

还可以通过+操作符连接两个string,并创建一个新的string:

fmt.Println("goodbye" + s[5:]) // "goodbye, world"

字符串可以通过比较运算符进行比较,例如== 、 < ;比较是逐字节进行的,因此结果是按字典序排序的。

字符串的值是不可变的:因为字节序列是永不可变的。当然我们可以给一个字符串变量赋予一个新的变量,或者将一个字符串追加到该字符串后:

s := "left foot"
t := s //t, "left foot"
s += ", right foot" //s, "left foot, right foot";t,"left foot";
这里不会改变s持有的原始字符串,会通过+= 语句给s分配一个全新的字符串,同时,t仍然包含原始字符串。
fmt.Println(s) // "left foot, right foot"
fmt.Println(t) // "left foot"

由于字符串是不可变的,因此就地修改字符串是不允许的:

s[0] = 'L' // compile error: cannot assign to s[0]

不可变意味着一个字符串的多份拷贝和该字符串共享内存是安全的(只读性),这使得拷贝字符串的操作是廉价的。类似的,字符串s和它的子串s[7:]可以安全的共享数据,因此这种操作也是廉价的。在两种情况下都不会发生内存拷贝,下图解释了一个字符串的内存结构以及两个子串共享同一个字节数组。


      图示:字符串hello, world和它的两个子串





一、字符串字面值

我们可以用字符串字面值(本节中简称字符串值)来表示一个字符串,这种字符串值会将字节序列包含在双引号中:

"Hello, 世界"
因为Go的源文件都是用UTF-8编码,并且Go的文本字符串通常也是用UTF-8编码,因此我们可以在字符串值中使用Unicode码点。

在一个双引号包含的字符串值中,可以通过 \ 使用转义字符,下面是常见的转义字符表,例如换行,回车,制表符:

\a      响铃
\b      退格
\f      换页
\n      换行
\r      回车
\t      制表符
\v      垂直制表符
\'      单引号 (只用在 '\'' 形式的rune符号面值中)
\"      双引号 (只用在 "..." 形式的字符串面值中)
\\      反斜杠
也可以在字符串值中使用转义字符来表示8进制或者16进制的255(一个字节的范围)以内的数值。16进制转义形式是\xhh,包含了两个16进制字符h(大小写都可以)。8进制的转义形式是\ooo,包含了三个8进制的数字(0到7),但是不能超过\377。每个单一的字节都表达一个特定的值,稍后我们将看到在字符串中使用Unicode码点。

一个原生字符串的字面值形式是`...`,使用反引号代替双引号。在一个原生字符串中,是不存在转义字符的;字面内容即文本内容,包含反斜线和换行,因此原生字符串可以跨越多行。Go语言对原生字符串唯一的处理是删除回车以保证字符串在所有平台上的值是一样的,特别是那些将回车也写入文本文件中的系统(例如Windows的换行是\r\n)。

原生字符串用来写正则表达式是非常方便的,因为正则中存在大量的反斜线。在HTML模版,JSON字面值,命令行提示信息等场景也是也很有用的,因为这些场景经常需要文本横跨多行。

const GoUsage = `Go is a tool for managing Go source code.

Usage:
    go command [arguments]
...`

二、Unicode

在很久之前,计算机世界相对是很简单的,只有一个字符集可以使用:ASCII,美国信息交换标准代码。ASCII或者更精确的说US-ASCII使用了7bit来表示128个字符(2^7):大小写字母,数字,各种标点符号,机器控制字符。在计算机发展的早期,这个字符集是足够使用的,但是世界上还有很多地方使用的是ASCII字符集之外的字符,随着因特网的迅速普及,其它语言的字符使用也变得越来越普遍,那么我们该怎么有效的处理这种多样化呢?

答案就是Unicode,包含了世界上几乎所有的可书写字符,这些字符被称为Unicode码点,在Go中是rune。

Unicode的第8个版本为超过100种语言定义了超过120,000个字符,那么这些字符在程序中是怎么表现的呢?答案就是rune,rune的底层类型是int32。

我们可以在编码时将每个rune都编成32bit。在这种情况下,每一个rune都是UTF-32或UCS-4,每个Unicode码点的大小都是一样的:32bit。这样的实现简单而且统一,但是浪费了很多空间,因为大部分的计算机文本都是用ASCII书写的- 仅仅需要8bit或一个字节就可以表示一个字符。绝大部分的字符使用的数值都小于65536,也就是需要16bit就够了,这时32bit会浪费大量的空间。那么我们可以改进吗?

   1.UTF-8

UTF-8是长度可变的Unicode码点编码方式,UTF-8是Ken Thompson和Rob Pike发起的(同时也是Go语言的两位创始人),现在是世界范围的Unicode标准。UTF-8使用了1-4个字节去表示每个rune,因此表示ASCII只需要1个字节,其它大部分的rune都只需要2-3个字节。

0xxxxxx	runes 0–127	(ASCII)
110xxxxx 10xxxxxx	128–2047	(values <128 unused)
1110xxxx 10xxxxxx 10xxxxxx	2048–65535	(values <2048 unused)
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx	65536–0x10ffff	(other values unused)
每个rune编码的第一个字节的最高bit用来指示接下来的字节数,如果第一个字节的最高bit为0,则该rune是一个ASCII字符,这样就和传统的ASCII编码兼容,如果两个字节,第一个字节的高位就是110, 第二字节10,以此类推,见上图。

长度可变的编码决定了我们不能直接通过字节索引访问字符串s[i](因为这样取到的是某个字节,而不是我们想要的Unicode字符)。但是UTF-8也有很多优点:编码格式很紧凑;跟ASCII兼容;自同步:只需要向前回溯不超过3个字节就可以找到字符的起始位置;它是前缀编码,因此解码可以自左向右进行,不会造成任何歧义,也不需要向前查看。UTF-8编码可以保证没有任何rune的编码是其它rune编码的子串,因此搜索一个rune只需搜索它的编码序列就好,不用担心上下文对搜索结果的影响。UTF-8的编码后的字节序和Unicode码点的序列是一致的,可以很自然的对UTF-8序列排序。Go字符串中没有NUL(0)字节,因此可以很好的跟那些使用NUL结尾的语言进行兼容。

Go的源码文件是用UTF-8编码的,同时Go的程序也提倡使用UTF-8来编码文本字符串。unicode包提供了操作单个rune的函数(例如区分字母和数字,大小写转换等),unicode/utf8包提供了rune和UTF-8编码之间的转换。

许多Unicode字符在键盘上不方便输入,很多字符外观很相似,甚至一些字符是不可见的。因此Go中的字符串字面值(双引号包围)允许我们使用Unicode转义字符来输入Unicode码点。有两种形式:16bit码点的转义\uhhhh,32bit码点的转义\Uhhhhhhhh,其中每个h都是一个十六进制数字,不过32bit的形式使用还是想对较少的。上面两种形式的转义字符每一个都代表了一个特定的UTF-8编码的码点。例如,下面这些字符串字面值都表现了6个字节长度的字符串:

"世界"
"\xe4\xb8\x96\xe7\x95\x8c"
"\u4e16\u754c"
"\U00004e16\U0000754c"
上面2-4号3个转义序列代表的字符串和第一个字符串的展现形式完全不同,但是它们的字符串值是相同的。

在单个rune语法中也可以使用Unicode转义符。下面3个语法是等价的:

'世界'  '\u4e16'  '\U00004e16'

值小于256的rune可以通过一个16进制的转义字节来表示,例如'A'转义后是'\x41',但是对于超过256的值,必须要用\u或\U来表示。因此'\xe4\xb8\x96'不是一个合法的rune字符,尽管这里组成这个码点的三个字节都是合法的UTF-8字符(用单引号包围的都是单个Unicode码点,用'\uhhhh'表示)。


幸好我们有了UTF-8,很多字符串操作是不需要解码的。这里测试一个字符串是否是另外一个的前缀:

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); i++ {
        if HasPrefix(s[i:], substr) {
            return true
        }
    }
    return false
}
这些函数处理采用UTF-8编码文本的方法和处理字节序(byte组成的字符串)的方法是一样的。对于其它编码类型来说,就未必了(上面这些函数取自strings包,不过Contains的实现方式有区别,strings中采用了哈希技术,因此搜索效率更高)

如果我们真的很关心单独的Unicode码点,那得用其它机制。回忆下这节最开始的例子,字符串中包含了两个中文字符。下图展示了该字符串在内存中的存储方式,该字符串包含了13个字节,但是采用的是UTF-8编码,因此只有9个码点(rune):


上图中使用range来解码UTF-8码点字符串。


import "unicode/utf8"

s := "Hello, 世界"
fmt.Println(len(s))                    // "13"
fmt.Println(utf8.RuneCountInString(s)) // "9"

这里我们需要一个UTF-8解码器来处理这些字符,unicode/utf8包提供了一个办法:

for i := 0; i < len(s); {
    r, size := utf8.DecodeRuneInString(s[i:])
    fmt.Printf("%d\t%c\n", i, r)
    i += size
}
每次调用DecodeRuneInString都会返回一个rune还有rune的UTF-8编码的字节数size(例如汉字是3个字节)。size可以用来获取下一个rune的起始位置。但是这种办法是比较笨拙的,我们需要不停的自己写这种循环。还好,Go提供了range循环,当range的对象是一个字符串时,隐式的调用了UTF-8解码,生成一个一个Unicode码点(rune)。下面的例子中,要注意的是字符的索引 i 在每次循环后未必固定加1,增加的是UTF-8编码对应的字节数:
for i, r := range "Hello,世界" {
    fmt.Printf("%d\t%q\t%d\n", i, r, r)
}
我们可以通过range来计算字符串中rune的个数:
n := 0
for _, _ = range s {
    n++
}
还可以在使用range时省去我们不需要的变量:
n := 0
for range s {
    n++
}
但是对于这种场景,最合适的函数是utf8.RuneCountInString(s),性能是很高的。

之前我们提过Go的惯例是使用UTF-8编码的文本字符串(也可以不用),但是对于字符串的range来说,就应该只使用UTF-8编码的字符串。如果要range的字符串包含了任意的二进制数据或者包含了错误呢?

每次我们显式调用utf8.DecodeRuneInString或者隐式的在range循环中进行UTF-8解码时,一旦遇到不可解码的输入字节序列,就会生成一个特殊的Unicode字符:'\uFFFD',这个字符一般长这样: Image。当程序遇到这样的rune值时,往往意味着上游系统在编码字符串时,出现了问题。

UTF-8在作为系统之间的数据交换格式时非常方便,但是在同一个程序的内部,rune可能会更方便,因为它们具有统一的大小(int32),在数组和切片中的索引也更简单。

[]rune可以把字符串转换为Unicode码点:

// "program" in Japanese katakana
s := 
fmt.Printf("% x\n", s) // "e3 83 97 e3 83 ad e3 82 b0 e3 83 a9 e3 83 a0"
r := []rune(s)
fmt.Printf("%x\n", r)  // "[30d7 30ed 30b0 30e9 30e0]"
% x打印时会在每个16进制数字中间插入空格。

如果将[]rune转换为一个字符串,会生成一个UTF8编码的字符串:

fmt.Println(string(r)) // 
将一个整数转为字符串时会把整数解释成rune值(rune的底层类型是int32),生成UTF-8形式:
fmt.Println(string(65))     // "A", not "65"
fmt.Println(string(0x4eac)) // "世界"
如果rune不合法,会用错误字符替代显示:
fmt.Println(string(1234567)) // 


   2.字符串和字节数组

有4个字符串操作包:bytes, strings, strconv, unicode。其中strings包提供了很多函数可以进行字符串搜索、替换、比较、分割、连接等。

bytes包提供了和strings包相似的函数,只不过对象是字节切片[]byte,[]byte类型和字符串类型在某些方面是一样的。因为字符串是不可变的,因此逐步构建字符串会有大量的内存分配和内存拷贝,这种情况下,使用bytes.Buffer效率更高。

strconv包含了一些字符串转换函数:将布尔值、整数、浮点数转换为字符串,反之亦然,还包含了给字符串加双引号或者去除双引号的函数。

unicode包提供了一些函数用来分类rune: IsDigit, IsLetter, IsUpper, IsLower等。转换函数ToUpper和ToLower可以将一个rune转为给定的形式,所有的函数都采用Unicode标准对字母、数字等进行分类。strings包也有类似的函数,ToUpper和ToLower,它们会将原始字符串的所有字符转化为特定的形式。

下面的basename函数是受到Unix shell同样命名函数的启发,在我们的实现版本中,basename(s)会移除s中所有的斜杠和斜杠之前的字符,也会移除看上去像文件类型的后缀,例如.txt:

fmt.Println(basename("a/b/c.go")) // "c"
fmt.Println(basename("c.d.go"))   // "c.d"
fmt.Println(basename("abc"))      // "abc"
第一个版本的basename没有使用任何第三方库函数:
// basename removes directory components and a .suffix.
// e.g., a => a, a.go => a, a/b/c.go => c, a/b.c.go => b.c
func basename(s string) string {
    // Discard last '/' and everything before.
    for i := len(s) - 1; i >= 0; i-- {
        if s[i] == '/' {
            s = s[i+1:]
            break
        }
    }
    // Preserve everything before last '.'.
    for i := len(s) - 1; i >= 0; i-- {
        if s[i] == '.' {
            s = s[:i]
            break
        }
    }
    return s
}
下面的简化版本使用了strings.LastIndex:
func basename(s string) string {
    slash := strings.LastIndex(s, "/") // -1 if "/" not found
    s = s[slash+1:]
    if dot := strings.LastIndex(s, "."); dot >= 0 {
        s = s[:dot]
    }
    return s
}
path包和path/filepath包提供了更通用的函数来操作这些带有层次的文件名。path包适合处理斜杠分割的路径,任何平台都可以,path包不应该被用于处理文件名,但是可以处理域名,例如url路径。作为对比,path/filepath使用宿主机操作系统的规则来操作文件名,例如POSIX平台下的/foo/bar,Windows平台下的c:\foo\bar。

让我们继续看关于子字符串的例子,这个例子的目标是获取一个整数的字符串表现形式,例如"12345"。然后每隔3个字符插入一个逗号,"12,345"。这个版本只能用于整数:

// comma inserts commas in a non-negative decimal integer string.
func comma(s string) string {
    n := len(s)
    if n <= 3 {
        return s
    }
    return comma(s[:n-3]) + "," + s[n-3:]
}
comma函数的参数是一个字符串s,如果s的长度等于或小于3,是不需要逗号的。否则,comma函数会递归调用自己并且增加一个逗号。

一个字符串的底层包含一个字符数组,一旦创建,就不能更改。作为对比,[]byte的元素就可以免费修改。


字符串可以转换为字节切片([]byte),反之亦然:

s := "abc"
b := []byte(s)
s2 := string(b)

从概念上来说,[]byte(s)转换会新分配一个字节数组,并将s拷贝进去,然后生成一个切片,切片会引用底层的数组。编译器的优化可以在某些场景下避免内存分配和拷贝,但是这种拷贝有时候是需要的,这样才能确保底层的字符串不会被改变。从[]byte转换为string也会产生一次分配和拷贝,这样才能保证结果的不可变性。

为了避免类型转换和不必要的内存分配,可以使用bytes包。这个包中的很多实用函数在strings包中都有对应的函数。例如这里有一些strings包中的函数:

func Contains(s, substr string) bool
func Count(s, sep string) int
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(s, sep []byte) int
func Fields(s []byte) [][]byte
func HasPrefix(s, prefix []byte) bool
func Index(s, sep []byte) int
func Join(s [][]byte, sep []byte) []byte
唯一的区别就是string参数被[]byte参数替代。

bytes包提供了Buffer类型来高效的操作字节切片。Buffer最开始是空的,之后会随着string,byte,[]byte类型数据的写入逐步增长。下面这个例子中,bytes.Buffer变量是不需要初始化的,因为零值一样可用:

// intsToString is like fmt.Sprint(values) but adds commas.
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(']')
    return buf.String()
}

func main() {
    fmt.Println(intsToString([]int{1, 2, 3})) // "[1, 2, 3]"
}
当追加一些UTF-8编码的rune到bytes.Buffer时,最好使用bytes.Buffer的WriteRune方法,对于ASCII字符可以使用WriteByte。

bytes.Buffer类型的适用场景是非常广的,当我们学到interface那一章时,会详细讲解在使用IO时,怎么用bytes.Buffer类型去替代文件来做数据落地

x, err := strconv.Atoi("123")             // x is an int
y, err := strconv.ParseInt("123", 10, 64) // base 10, up to 64 bits




   3.字符串和数字之间的转换

除了string、rune、[]byte之间的转换之外,我们也经常需要在数值类型和字符串之间进行转换。这些可以通过strconv包来完成。

为了将一个整数转成一个字符串,可以使用fmt.Sprintf,也可以使用strconv.Itoa:

x := 123
y := fmt.Sprintf("%d", x)
fmt.Println(y, strconv.Itoa(x)) // "123 123"
FormatInt和FormatUint函数可以使用不同的基数来格式化数字:
fmt.Println(strconv.FormatInt(int64(x), 2)) // "1111011"
fmt.Printf中的打印参数%b,%d,%u和%x往往比Format函数更灵活,特别是想给要打印的数字添加额外的信息时:
s := fmt.Sprintf("x=%b", x) // "x=1111011"
将数字转换为字符串,可以使用strconv的Atoi、ParseInt、ParseUint函数:
x, err := strconv.Atoi("123")             // x is an int
y, err := strconv.ParseInt("123", 10, 64) // 基数10, 最大是64bit(int64)
ParseInt的第三个参数指定了结果的整形大小,例如,16意味着int16,0意味着int。在任何情况下,该函数的返回值都是int64,不过可以通过第三个参数,通过类型转换获得自己想要的类型。

一些时候,可以用fmt.Scanf来分析输入:输入在同一行混杂了字符串,数值等等,特别是在处理不完整或者不规律的输入时。


文章所有权:Golang隐修会 联系人:孙飞,CTO@188.com!




有疑问加站长微信联系(非本文作者)

本文来自:CSDN博客

感谢作者:erlib

查看原文:Go语言核心之美 2.5-字符串

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:701969077

964 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传