3.5 字符串
字符串是不可变的字节序列,它可以包含任意数据,包括0值字节,但主要是人类可读的文本。习惯上,文本字符串被解读成按UTF-8编码的Unicode码点(文字符号)序列,稍后将细究相关内容。
内置的len函数返回字符串的字节数(并非文字符号的数目),下标访问操作s[i]则取得第i个字符,其中0i<len(s)。
试图访问许可范围以外的字节会触发宕机异常:
字符串的第i个字节不一定就是第i个字符,因为非ASCII字符的UTF-8码点需要两个字节或多个字节。稍后将讨论如何使用字符。
子串生成操作s[i:j]产生一个新字符串,内容取自原字符串的字节,下标从i(含边界值)开始,直到j(不含边界值)。结果的大小是j-i个字节。
再次强调,若下标越界,或者j的值小于i,将触发宕机异常。
操作数i与j的默认值分别是0(字符串起始位置)和len(s)(字符串终止位置),若省略i或j,或两者,则取默认值。
加号(+)运算符连接两个字符串而生成一个新字符串:
字符串可以通过比较运算符做比较,如==和<;比较运算按字节进行,结果服从本身的字典排序。
尽管肯定可以将新值赋予字符串变量,但是字符串值无法改变:字符串值本身所包含的字节序列永不可变。要在一个字符串后面添加另一个字符串,可以这样编写代码:
这并不改变s原有的字符串值,只是将+=语句生成的新字符串赋予s。同时,t仍然持有旧的字符串值。
因为字符串不可改变,所以字符串内部的数据不允许修改:
不可变意味着两个字符串能安全地共用同一段底层内存,使得复制任何长度字符串的开销都低廉。类似地,字符串s及其子串(如s[7:])可以安全地共用数据,因此子串生成操作的开销低廉。这两种情况下都没有分配新内存。图3-4展示了一个字符串及其两个子字符串的内存布局,它们共用底层字节数组。
图3-4 字符串"hello,world"及其两个子字符串
3.5.1 字符串字面量
字符串的值可以直接写成字符串字面量(string literal),形式上就是带双引号的字节序列:
因为Go的源文件总是按UTF-8编码,并且习惯上Go的字符串会按UTF-8解读,所以在源码中我们可以将Unicode码点写入字符串字面量。
在带双引号的字符串字面量中,转义序列以反斜杠(\)开始,可以将任意值的字节插入字符串中。下面是一组转义符,表示ASCII控制码,如换行符、回车符和制表符。
源码中的字符串也可以包含十六进制或八进制的任意字节。十六进制的转义字符写成\xhh的形式,h是十六进制数字(大小写皆可),且必须是两位。八进制的转义字符写成\ooo的形式,必须使用三位八进制数字(0~7),且不能超过\377。这两者都表示单个字节,内容是给定值。后面,我们将看到如何将数值形式的Unicode码点嵌入字符串字面量。
原生的字符串字面量的书写形式是`...`,使用反引号而不是双引号。原生的字符串字面量内,转义序列不起作用;实质内容与字面写法严格一致,包括反斜杠和换行符,因此,在程序源码中,原生的字符串字面量可以展开多行。唯一的特殊处理是回车符会被删除(换行符会保留),使得同一字符串在所有平台上的值都有相同,包括习惯在文本文件存入换行符的系统。
正则表达式往往含有大量反斜杠,可以方便地写成原生的字符串字面量。原生的字面量也适用于HTML模板、JSON字面量、命令行提示信息,以及需要多行文本表达的场景。
3.5.2 Unicode
从前,事情简单明晰,至少,狭隘地看,软件只须处理一个字符集:ASCII(美国信息交换标准码)。ASCII(或更确切地说,US-ASCII)码使用7位表示128个“字符”:大小写英文字母、数字、各种标点和设备控制符。这对早期的计算机行业已经足够了,但是让世界上众多使用其他语言的人无法在计算机上使用自己的文书体系。随着互联网的兴起,包含纷繁语言的数据屡见不鲜。到底怎样才能应付语言的繁杂多样,还能兼顾高效率?
答案是Unicode(unicode.org),它囊括了世界上所有文书体系的全部字符,还有重音符和其他变音符,控制码(如制表符和回车符),以及许多特有文字,对它们各自赋予一个叫Unicode码点的标准数字。在Go的术语中,这些字符记号称为文字符号(rune)。
Unicode第8版定义了超过一百种语言文字的12万个字符的码点。它们在计算机程序和数据中如何表示?天然适合保存单个文字符号的数据类型就是int32,为Go所采用;正因如此,rune类型作为int32类型的别名。
我们可以将文字符号的序列表示成int32值序列,这种表示方式称作UTF-32或UCS-4,每个Unicode码点的编码长度相同,都是32位。这种编码简单划一,可是因为大多数面向计算机的可读文本是ASCII码,每个字符只需8位,也就是1字节,导致了不必要的存储空间消耗。而使用广泛的字符的数目也少于65556个,字符用16位就能容纳。我们能作改进吗?
3.5.3 UTF-8
UTF-8以字节为单位对Unicode码点作变长编码。UTF-8是现行的一种Unicode标准,由Go的两位创建者Ken Thompson和Rob Pike发明。每个文字符号用1~4个字节表示,ASCII字符的编码仅占1个字节,而其他常用的文书字符的编码只是2或3个字节。一个文字符号编码的首字节的高位指明了后面还有多少字节。若最高位为0,则标示着它是7位的ASCII码,其文字符号的编码仅占1字节,这样就与传统的ASCII码一致。若最高几位是110,则文字符号的编码占用2个字节,第二个字节以10开始。更长的编码以此类推。
变长编码的字符串无法按下标直接访问第n个字符,然而有失有得,UTF-8换来许多有用的特性。UTF-8编码紧凑,兼容ASCII,并且自同步:最多追溯3字节,就能定位一个字符的起始位置。UTF-8还是前缀编码,因此它能从左向右解码而不产生歧义,也无须超前预读。于是查找文字符号仅须搜索它自身的字节,不必考虑前文内容。文字符号的字典字节顺序与Unicode码点顺序一致(Unicode设计如此),因此按UTF-8编码排序自然就是对文字符号排序。UTF-8编码本身不会嵌入NUL字节(0值),这便于某些程序语言用NUL标记字符串结尾。
Go的源文件总是以UTF-8编码,同时,需要用Go程序操作的文本字符串也优先采用UTF-8编码。unicode包具备针对单个文字符号的函数(例如区分字母和数字,转换大小写),而unicode/utf8包则提供了按UTF-8编码和解码文字符号的函数。
许多Unicode字符难以直接从键盘输入;有的看起来十分相似几乎无法分辨;有些甚至不可见。Go语言中,字符串字面量的转义让我们得以用码点的值来指明Unicode字符。有两种形式,\uhhhh表示16位码点值,\Uhhhhhhhh表示32位码点值,其中每个h代表一个十六进制数字;32位形式的码点值几乎不需要用到。这两种形式都以UTF-8编码表示出给定的码点。因此,下面几个字符串字面量都表示长度为6字节的相同串:
后面三行的转义序列用不同形式表示第一行的字符串,但实质上它们的字符串值都一样。
Unicode转义符也能用于文字符号。下列字符是等价的:
码点值小于256的文字符号可以写成单个十六进制数转义的形式,如'A'写成'\x41',而更高的码点值则必须使用\u或\U转义。这就导致,'\xe4\xb8\x96'不是合法的文字符号,虽然这三个字节构成某个有效的UTF-8编码码点。
由于UTF-8的优良特性,许多字符串操作都无须解码。我们可以直接判断某个字符串是否为另一个的前缀:
或者它是否为另一个字符串的后缀:
或者它是否为另一个的子字符串:
按UTF-8编码的文本的逻辑同样也适用原生字节序列,但其他编码则无法如此。(上面的函数取自strings包,其实Contains函数的具体实现使用了散列方法让搜索更高效。)
另一方面,如果我们真的要逐个逐个处理Unicode字符,则必须使用其他编码机制。考虑我们第一个例子的字符串(见3.5.1节),它包含两个东亚字符。图3-5说明了该字符串的内存布局。它含有13个字节,而按作UTF-8解读,本质是9个码点或文字符号的编码:
我们需要UTF-8解码器来处理这些字符,unicode/utf8包就具备一个:
每次DecodeRuneInString的调用都返回r(文字符号本身)和一个值(表示r按UTF-8编码所占用的字节数)。这个值用来更新下标i,定位字符串内的下一个文字符号。可是按此方法,我们总是需要使用上例中的循环形式。所幸,Go的range循环也适用于字符串,按UTF-8隐式解码。图3-5也展示了以下循环的输出。注意,对于非ASCII文字符号,下标增量大于1。
图3-5 一个按UTF-8编码的字符串在range循环内解码
我们可用简单的range循环统计字符串中的文字符号数目,如下所示:
与其他形式的range循环一样,可以忽略没用的变量:
或者,直截了当地调用utf8.RuneCountInString(s)。
之前提到过,文本字符串作为按UTF-8编码的Unicode码点序列解读,很大程度是出于习惯,但为了确保使用range循环能正确处理字符串,则必须要求而不仅仅是按照习惯。如果字符串含有任意二进制数,也就是说,UTF-8数据出错,而我们对它做range循环,会发生什么?
每次UTF-8解码器读入一个不合理的字节,无论是显式调用utf8.DecodeRuneInString,还是在range循环内隐式读取,都会产生一个专门的Unicode字符'\uFFFD'替换它,其输出通常是个黑色六角形或类似钻石的形状,里面有个白色问号。如果程序碰到这个文字符号值,通常意味着,生成字符串数据的系统上游部分在处理文本编码方面存在瑕疵。
UTF-8是一种分外便捷的交互格式,而在程序内部使用文字字符类型可能更加方便,因为它们大小一致,便于在数组和slice中用下标访问。
当[]rune转换作用于UTF-8编码的字符串时,返回该字符串的Unicode码点序列:
(第一个Printf里的谓词%x(注意,%和x之间有空格)以十六进制数形式输出,并在每两个数位间插入空格。)
如果把文字符号类型的slice转换成一个字符串,它会输出各个文字符号的UTF-8编码拼接结果:
若将一个整数值转换成字符串,其值按文字符号类型解读,并且产生代表该文字符号值的UTF-8码:
如果文字符号值非法,将被专门的替换字符取代(见前面的'\uFFFD')。
3.5.4 字符串和字节slice
4个标准包对字符串操作特别重要:bytes、strings、strconv和unicode。
strings包提供了许多函数,用于搜索、替换、比较、修整、切分与连接字符串。
bytes包也有类似的函数,用于操作字节slice([]byte类型,其某些属性和字符串相同)。由于字符串不可变,因此按增量方式构建字符串会导致多次内存分配和复制。这种情况下,使用bytes.Buffer类型会更高效,范例见后。
strconv包具备的函数,主要用于转换布尔值、整数、浮点数为与之对应的字符串形式,或者把字符串转换为布尔值、整数、浮点数,另外还有为字符串添加/去除引号的函数。
unicode包备有判别文字符号值特性的函数,如IsDigit、IsLetter、IsUpper和IsLower。每个函数以单个文字符号值作为参数,并返回布尔值。若文字符号值是英文字母,转换函数(如ToUpper和ToLower)将其转换成指定的大小写。上面所有函数都遵循Unicode标准对字母数字等的分类原则。strings包也有类似的函数,函数名也是ToUpper和ToLower,它们对原字符串的每个字符做指定变换,生成并返回一个新字符串。
下例中,basename函数模仿UNIX shell中的同名实用程序。只要s的前缀看起来像是文件系统路径(各部分由斜杠分隔),该版本的basename(s)就将其移除,貌似文件类型的后缀也被移除:
初版的basename独自完成全部工作,并不依赖任何库:
简化版利用库函数string.LastIndex:
path包和path/filapath包提供了一组更加普遍适用的函数,用来操作文件路径等具有层次结构的名字。path包处理以斜杠'/'分段的路径字符串,不分平台。它不适合用于处理文件名,却适合其他领域,像URL地址的路径部分。相反地,path/filepath包根据宿主平台(host platform)的规则处理文件名,例如POSIX系统使用/foo/bar,而Microsoft Windows系统使用c:\foo\bar。
我们继续看另一个例子,它涉及子字符串操作。任务是接受一个表示整数的字符串,如"12345",从右侧开始每三位数字后面就插入一个逗号,形如"12,345"。这个版本仅对整数有效。对浮点数的处理方式留作练习。
comma函数的参数是一个字符串。若字符串长度小于等于3,则不插入逗号。否则,comma以仅包含字符串最后三个字符的子字符串作为参数,递归调用自己,最后在递归调用的结果后面添加一个逗号和最后三个字符。
若字符串包含一个字节数组,创建后它就无法改变。相反地,字节slice的元素允许随意修改。
字符串可以和字节slice相互转换:
概念上,[]byte(s)转换操作会分配新的字节数组,拷贝填入s含有的字节,并生成一个slice引用,指向整个数组。具备优化功能的编译器在某些情况下可能会避免分配内存和复制内容,但一般而言,复制有必要确保s的字节维持不变(即使b的字节在转换后发生改变)。反之,用string(b)将字节slice转换成字符串也会产生一份副本,保证s2也不可变。
为了避免转换和不必要的内存分配,bytes包和strings包都预备了许多对应的实用函数(utility function),它们两两相对应。例如,strings包具备下面6个函数:
bytes包里面的对应函数为:
唯一的不同是,操作对象由字符串变为字节slice。
bytes包为高效处理字节slice提供了Buffer类型。Buffer起初为空,其大小随着各种类型数据的写入而增长,如string、byte和[]byte。如下例所示,bytes.Buffer变量无须初始化,原因是零值本来就有效:
若要在bytes.Buffer变量后面添加任意文字符号的UTF-8编码,最好使用bytes.Buffer的WriteRune方法,而追加ASCII字符,如'['和']',则使用WriteByte亦可。
bytes.Buffer类型用途极广,在第7章讨论接口的时候,假若I/O函数需要一个字节接收器(io.Writer)或字节发生器(io.Reader),我们将看到能如何用其来代替文件,其中接收器的作用就如上例中的Fprintf一样。
练习3.10:编写一个非递归的comma函数,运用bytes.Buffer,而不是简单的字符串拼接。
练习3.11:增强comma函数的功能,让其正确处理浮点数,以及带有可选正负号的数字。
练习3.12:编写一个函数判断两个字符串是否同文异构,也就是,它们都含有相同的字符但排列顺序不同。
3.5.5 字符串和数字的相互转换
除了字符串、文字符号和字节之间的转换,我们常常也需要相互转换数值及其字符串表示形式。这由strconv包的函数完成。
要将整数转换成字符串,一种选择是使用fmt.Sprintf,另一种做法是用函数strconv.Itoa(“integer to ASCII”):
FormatInt和FormatUint可以按不同的进位制格式化数字:
fmt.Printf里的谓词%b、%d、%o和%x往往比Format函数方便,若要包含数字以外的附加信息,它就尤其有用:
strconv包内的Atoi函数或ParseInt函数用于解释表示整数的字符串,而ParseUint用于无符号整数:
ParseInt的第三个参数指定结果必须匹配何种大小的整型;例如,16表示int16,而0作为特殊值表示int。任何情况下,结果y的类型总是int64,可将他另外转换成较小的类型。
有时候,单行输入由字符串和数字依次混合构成,需要用fmt.Scanf解释,可惜fmt.Scanf也许不够灵活,处理不完整或不规则输入时尤甚。