在Go语言中,字符串是一种基础类型。我们可以通过string关键字来创建字符串变量。其实,字符串和切片在很多方面都非常相似,它们都由一个元数据的结构体以及一个连续的内存块组成。然而,字符串的内存块是不可更改的,这意味着字符串是一个不可变的字节序列。正因为这个不可变性,字符串的元数据结构只需要两个字段。字符串的元数据结构定义如代码清单2-12所示。
代码清单2-12 字符串的元数据结构定义
字符串的内容是只读的,其元数据结构包括地址指针和字符串长度,共占用16字节。因此,字符串的总内存占用大小就是这16字节的元数据加上字符串内容本身的内存使用量。
字符串的定义很简单,我们可以使用string关键字来定义一个字符串变量。例如,直接定义字符串变量的方法如下所示:
与数组和切片类似,我们可以使用len()函数来获取字符串的长度。但需注意的是,这里所指的长度是指字符串实际占用的字节数,而非字符的个数。Go语言的字符串默认使用UTF8编码,这与一些多字节编码的字符集是有很大区别的。
例如,若定义一个字符串并赋值为汉字“我”,则这个字符串实际会占用3字节的内存。中文字符串的定义示例如下:
汉字“我”的编码由0xe6、0x88、0x91这3个字节组成。可以使用dlv调试工具来观察中文字符串变量str的内容,如下所示:
我们来深入解释一下dlv的“x”命令中参数的含义:
❑ -fmt hex:指定以十六进制的格式展示数据。
❑ -count 3:指定打印3个数据单元。
❑ -size 1:指定每个数据单元占1字节。
实际上,dlv工具的“x”命令与gdb的“x”命令功能类似,只是格式略有区别。因此,如果你更熟悉gdb的命令,可以使用“x/3bx 0x0000000001065e14”命令来查看指定内存地址的内容。
字符串在内存中的分配通常有两种情形。一种是在只读区域,其内容在编译时就已确定,当进程启动并加载应用程序的二进制文件到内存地址空间时,这部分内容固定存储在只读区域;另一种是运行时分配的内存,如动态生成的字符串,可以被分配在堆上或者栈上。
与C语言不同,在Go语言中,内存分配的控制权不再由程序员直接掌握。程序员无法指定结构体的内存究竟分配在堆上还是栈上。在编译阶段,Go语言会进行一项名为“逃逸分析”的过程,由编译器来决定内存分配的地点。
为了在使用调试工具分析Go程序的内存分配时获取更准确的信息,可以在编译Go程序时添加“-N -l”选项来禁用编译优化,这有助于我们直观地观察和理解内存是如何分配的。
例如,我们定义了一个字符串,如下所示:
在编译后,可以使用dlv调试工具来查看字符串的内存分配地址。下面是使用dlv调试str变量内容的示例:
图2-6直观地展示了这个字符串变量的结构。
图2-6 字符串结构示意
在程序中,用var str="apple"这种方式定义的字符串,其内存分配可以在编译期间确定,并存储在二进制程序文件的.rodata段。利用objdump工具反汇编应用程序的二进制文件,执行的命令如下:
通过上述输出我们可以看到,.rodata的地址范围是[0x458000,0x458000+0x27123],而我们的字符串内容的存储地址0x4664c7正好位于这个区间内。
由于字符串是不可变的,因此将其放在这一内存区域是合理的。如果尝试修改原始字符串,编译器会在编译期间产生错误。
在各种编程语言中,字符串的处理是一个普遍且常用的需求。特别是在I/O操作的过程中,字符串会频繁出现。Go语言为此提供了一系列字符串的操作方法。
1.简单操作
首先,字符串作为一种基础数据类型,可以使用便捷的运算符进行操作。例如,通过使用“+”运算可以实现字符串的连接,字符串之间还可以通过比较运算符(如>、<、==等)进行直接比较。
由于字符串具有类似数组的特点,类似数组的操作同样适用于字符串,例如索引访问、字符遍历和转换为切片等。
1)获取字符串内存大小(字节维度)的操作如下:
这里的长度是内存的长度,并不是字符的个数。在宽字符的场景,内存占用和字符个数并不一致。
2)按照字符串索引访问(字节维度):
请注意,这里索引的取值是按照字节粒度的索引,并不是字符粒度的索引。比如,str="我"占用3个字节——0xe6、0x88和0x91,那么str[1]取到的是0x88。如果是普通的ASCII字符,那么索引读取的和字符的序列编号是一致的。
3)使用range遍历字符串(字符维度):
这种方式以字符为最小单元进行遍历,比如str="我",循环将只执行1次。如果想要按照字节维度去遍历读取,那么可以先通过len(str)获取到字节长度,然后通过str[x]索引取值的方式去遍历字符串的字节序列。
4)按照字符串切片的语法,生成子字符串:
生成的子字符串还是字符串类型,内容不可修改。
2.标准库strings的应用
在所有编程语言中,字符串的处理是常见的需求,如字符串的比较、查找、统计、分裂、排序、裁剪等操作频繁出现。为了方便这些操作,Go语言提供了一个专门处理字符串的标准库——strings,该库提供了诸如拼接、替换、比较、查找、裁剪和分裂等一系列实用方法。下面我们将通过一些具体的例子来介绍这些方法的使用。
(1)字符串拼接
在Go语言中,可以使用Join函数来实现字符串的拼接,该函数能把一个字符串数组拼接成一个大字符串。Join函数原型如下:
该函数接受两个参数:一个字符串数组和拼接使用的分隔符。返回值是拼接后生成的新字符串。注意,此操作不会改变原始的字符串。下面是使用Join函数进行字符串拼接的示例:
输出结果是:
(2)字符串替换
Go语言提供了Replace和ReplaceAll函数,用于替换字符串中的子字符串,并返回新的字符串。函数原型如下:
其中,ReplaceAll函数实际上是对Replace函数的封装,效果相当于调用Replace(s,old, new,-1)。以下是字符串替换函数的使用示例:
输出结果是:
(3)字符串比较
字符串比较有两种方式:一种是直接用<、>、==等运算符比较;另一种是使用Compare、EqualFold等函数比较。函数原型如下:
字符串比较示例如下:
输出结果是:
Go的字符串是逐字节比较的。也就是说,字符串的比较是按照字典序进行的。从头开始逐字节比较,若相等则继续向后比较,直至遇到不同的字节为止。以"hello/10" "hello/2""world"这3个字符串为例。首先,比较第一个字节,"w"(ASCII码为119)大于"h"(ASCII码为104),因此"world"最大。"hello/10"和"hello/2"有相同的前缀,继续比较后面的字符,直到发现"2"(ASCII码为50)大于"1"(ASCII码为49)。因此最终结果是:"world">"hello/2">"hello/10"。这三个字符串结构示意如图2-7所示。
字典序的比较方式很常见,在数据存储的过程中频繁使用。特别是想要按一定顺序存储数据以优化读取效率时,就涉及数据排序的问题。最典型的例子就是LSM Tree存储引擎的SST(Sorted String Table)文件,我们将在后文中详细阐述这个文件。
(4)字符串查找
Go语言提供了多个函数用于在字符串内部查找子字符串,定位子字符串的位置,以及统计符合要求的字符串的个数。函数原型如下:
图2-7 字符串结构示意
下面是一些字符串查找函数的使用示例:
输出结果是:
以上函数中,有些是在字节维度上操作(如Compare、IndexByte),有些是在字符维度上操作(如Index、IndexAny等),请根据具体需求选择使用,尤其是在处理非ASCII字符时要特别注意。
(5)字符串裁剪和分裂
strings标准库提供了多个函数用于裁剪和分裂字符串。函数原型如下:
下面是一些字符串裁剪和分裂的示例:
输出结果是:
请注意,以上所有的字符串操作都不会修改原始字符串。这些操作都是生成新的字符串,以此保持字符串的不可变性。
在Go语言中,字符串可以与某些结构体(比如切片)进行相互转换。这并不意味着Go是一种弱类型语言,相反,Go是一种强类型的语言。这里提到的“转换”是指通过一种类型的变量生成另一种类型的变量,而非改变原有变量的类型。这种转换的可行性主要源于字符串和切片在底层结构的相似性。
由于字符串是不可修改的,因此这个转换必然涉及新的内存的分配和复制。我们需要将字符串的内容复制到一个切片上,下面是字符串转换成切片的代码示例:
进行上述类型转换时,会分配新的内存空间,并将字符串的内容复制到新的内存上。这样做可以保证后续的切片是可以被修改的,同时不影响到原始的字符串。
这种[]byte和string的转换会被编译器转换成对stringtoslicebyte和slicebytetostring函数的调用,这两个函数的主要任务就是分配内存和复制数据,以保证前后的对象在内存上是不相关的,这样就能维护切片和字符串各自的语义。stringtoslicebyte函数的实现如代码清单2-13所示。
代码清单2-13 stringtoslicebyte函数的实现
因此,在处理I/O操作时,如果遇到字符串和切片的转换,我们必须格外小心。此处涉及内存的分配和复制,如果处理的数据量过大或者类型转换过于频繁,可能会对系统性能和资源造成双重消耗。在进行这种操作时,应该仔细权衡其必要性和潜在的代价。