详解Golang中string的实现原理与高效使用
字符串类型是现代编程语言中最常使用的数据类型之一。在Go语言的先祖之一C语言当中,字符串类型并没有被显式定义,而是以字符串字面值常量或以'\0'结尾的字符类型(char)数组来呈现的。
const char * s = "hello world" char s[] = "hello gopher"
这给C程序员在使用字符串时带来一些问题,诸如:
- 类型安全性差;
- 字符串操作要时时刻刻考虑结尾的'\0';
- 字符串数据可变(主要指以字符数组形式定义的字符串类型);
- 获取字符串长度代价大(O(n)的时间复杂度);
- 未内置对非ASCII字符(如中文字符)的处理。
Go语言修复了C语言的这一“缺陷”,内置了string类型,统一了对字符串的抽象。
在Go语言中,无论是字符串常量、字符串变量还是代码中出现的字符串字面量,它们的类型都被统一设置为string。
Go的string类型设计充分吸取了C语言字符串设计的经验教训,并结合了其他主流语言在字符串类型设计上的最佳实践,最终呈现的string类型具有如下功能特点。
(1)string类型的数据是不可变的,一旦声明了一个string类型的标识符,无论是常量还是变量,该标识符所指代的数据在整个程序的生命周期内便无法更改。下面尝试修改一下string数据,看看能得到怎样的结果。
func main() { // 原始字符串 var s string = "hello" fmt.Println("original string:", s) // 切片化后试图改变原字符串 sl := []byte(s) sl[0] = 't' fmt.Println("slice:", string(sl)) fmt.Println("after reslice, the original string is:", string(s)) }
该程序的运行结果如下:
$go run string.go
original string: hello
slice: tello
after reslice, the original string is: hello
(2)零值可用
Go string类型支持“零值可用”的理念。Go字符串无须像C语言中那样考虑结尾'\0'字符,因此其零值为"",长度为0。
(3)获取长度的时间复杂度是O(1)级别
Go string类型数据是不可变的,因此一旦有了初值,那块数据就不会改变,其长度也不会改变。Go将这个长度作为一个字段存储在运行时的string类型的内部表示结构中(后文有说明)。这样获取string长度的操作,即len(s)实际上就是读取存储在运行时中的那个长度值,这是一个代价极低的O(1)操作。
(4)支持通过+/+=操作符进行字符串连接
对开发者而言,通过+/+=操作符进行的字符串连接是体验最好的字符串连接操作,Go语言支持这种操作:
s := "Rob Pike, " s = s + "Robert Griesemer, " s += " Ken Thompson"
(5)支持各种比较关系操作符:==、!= 、>=、<=、>和<
由于Go string是不可变的,因此如果两个字符串的长度不相同,那么无须比较具体字符串数据即可断定两个字符串是不同的。如果长度相同,则要进一步判断数据指针是否指向同一块底层存储数据。如果相同,则两个字符串是等价的;如果不同,则还需进一步比对实际的数据内容。
(6)对非ASCII字符提供原生支持
Go语言源文件默认采用的Unicode字符集。Unicode字符集是目前市面上最流行的字符集,几乎囊括了所有主流非ASCII字符(包括中文字符)。Go字符串的每个字符都是一个Unicode字符,并且这些Unicode字符是以UTF-8编码格式存储在内存当中的。我们来看一个例子:
// func main() { // 中文字符 Unicode码点 UTF8编码 // 中 U+4E2D E4B8AD // 国 U+56FD E59BBD // 欢 U+6B22 E6ACA2 // 迎 U+8FCE E8BF8E // 您 U+60A8 E682A8 s := "中国欢迎您" rs := []rune(s) sl := []byte(s) for i, v := range rs { var utf8Bytes []byte for j := i * 3; j < (i+1)*3; j++ { utf8Bytes = append(utf8Bytes, sl[j]) } fmt.Printf("%s => %X => %X\n", string(v), v, utf8Bytes) } } $go run 中 => 4E2D => E4B8AD 国 => 56FD => E59BBD 欢 => 6B22 => E6ACA2 迎 => 8FCE => E8BF8E 您 => 60A8 => E682A8
我们看到字符串变量s中存储的文本是“中国欢迎您”五个汉字字符(非ASCII字符范畴),这里输出了每个中文字符对应的Unicode码点(Code Point,见输出结果的第二列),一个rune对应一个码点。UTF-8编码是Unicode码点的一种字符编码形式,是最常用的一种编码格式,也是Go默认的字符编码格式。我们还可以使用其他字符编码格式来映射Unicode码点,比如UTF-16等。
在UTF-8中,大多数中文字符都使用三字节表示。[]byte(s)的转型让我们获得了s底层存储的“复制品”,从而得到每个汉字字符对应的UTF-8编码字节(见输出结果的第三列)。
(7)原生支持多行字符串
C语言中要构造多行字符串,要么使用多个字符串的自然拼接,要么结合续行符“\”,很难控制好格式:
#include <stdio.h> char *s = "古藤老树昏鸦\n" "小桥流水人家\n" "古道西风瘦马\n" "断肠人在天涯"; int main() { printf("%s\n", s); }
go语言方式:
const s = `古藤老树昏鸦 小桥流水人家 古道西风瘦马 断肠人在天涯`; func main () { fmt.Println(s) }
字符串内部结构
Go string类型上述特性的实现与Go运行时对string类型的内部表示是分不开的。Go string在运行时表示为下面的结构:
// $GOROOT/src/runtime/string.go type stringStruct struct { str unsafe.Pointer len int }
我们看到string类型也是一个描述符,它本身并不真正存储数据,而仅是由一个指向底层存储的指针和字符串的长度字段组成。我们结合一个string的实例化过程来看。下面是runtime包中实例化一个字符串对应的函数:
// $GOROOT/src/runtime/string.go func rawstring(size int) (s string, b []byte) { p := mallocgc(uintptr(size), nil, false) stringStructOf(&s).str = p stringStructOf(&s).len = size *(*slice)(unsafe.Pointer(&b)) = slice{p, size, size} return }
我们看到每个字符串类型变量/常量对应一个stringStruct实例,经过rawstring实例化后,stringStruct中的str指针指向真正存储字符串数据的底层内存区域,len字段存储的是字符串的长度(这里是5);rawstring同时还创建了一个临时slice,该slice的array指针也指向存储字符串数据的底层内存区域。注意,rawstring调用后,新申请的内存区域还未被写入数据,该slice就是供后续运行时层向其中写入数据("hello")用的。写完数据后,该slice就可以被回收掉了
根据string在运行时的表示可以得到这样一个结论:直接将string类型通过函数/方法参数传入也不会有太多的损耗,因为传入的仅仅是一个“描述符”,而不是真正的字符串数据。我们通过一个简单的基准测试来验证一下:
// var s string = `Go, also known as Golang, is a statically typed, compiled programming language designed at Google by Robert Griesemer, Rob Pike, and Ken Thompson. Go is syntactically similar to C, but with memory safety, garbage collection, structural typing, and communicating sequential processes (CSP)-style concurrency.` func handleString(s string) { _ = s + "hello" } func handlePtrToString(s *string) { _ = *s + "hello" } func BenchmarkHandleString(b *testing.B) { for n := 0; n < b.N; n++ { handleString(s) } } func BenchmarkHandlePtrToString(b *testing.B) { for n := 0; n < b.N; n++ { handlePtrToString(&s) } }
$go test -bench . -benchmem string_as_param_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkHandleString-8 15668872 70.7 ns/op 320 B/op 1 allocs/op
BenchmarkHandlePtrToString-8 15809401 71.8 ns/op 320 B/op 1 allocs/op
PASS
ok command-line-arguments 2.407s
我们看到直接传入string与传入string指针两者的基准测试结果几乎一模一样,因此Gopher大可放心地直接使用string作为函数/方法参数类型。
高效构造
前面提到过,Go原生支持通过+/+=操作符来连接多个字符串以构造一个更长的字符串,并且通过+/+=操作符的字符串连接构造是最自然、开发体验最好的一种。但Go还提供了其他一些构造字符串的方法,比如:
使用fmt.Sprintf;使用strings.Join;使用strings.Builder;使用bytes.Buffer。
在这些方法中哪种方法最为高效呢?我们使用基准测试的数据作为参考:
// var sl []string = []string{ "Rob Pike ", "Robert Griesemer ", "Ken Thompson ", } func concatStringByOperator(sl []string) string { var s string for _, v := range sl { s += v } return s } func concatStringBySprintf(sl []string) string { var s string for _, v := range sl { s = fmt.Sprintf("%s%s", s, v) } return s } func concatStringByJoin(sl []string) string { return strings.Join(sl, "") } func concatStringByStringsBuilder(sl []string) string { var b strings.Builder for _, v := range sl { b.WriteString(v) } return b.String() } func concatStringByStringsBuilderWithInitSize(sl []string) string { var b strings.Builder b.Grow(64) for _, v := range sl { b.WriteString(v) } return b.String() } func concatStringByBytesBuffer(sl []string) string { var b bytes.Buffer for _, v := range sl { b.WriteString(v) } return b.String() } func concatStringByBytesBufferWithInitSize(sl []string) string { buf := make([]byte, 0, 64) b := bytes.NewBuffer(buf) for _, v := range sl { b.WriteString(v) } return b.String() } func BenchmarkConcatStringByOperator(b *testing.B) { for n := 0; n < b.N; n++ { concatStringByOperator(sl) } } func BenchmarkConcatStringBySprintf(b *testing.B) { for n := 0; n < b.N; n++ { concatStringBySprintf(sl) } } func BenchmarkConcatStringByJoin(b *testing.B) { for n := 0; n < b.N; n++ { concatStringByJoin(sl) } } func BenchmarkConcatStringByStringsBuilder(b *testing.B) { for n := 0; n < b.N; n++ { concatStringByStringsBuilder(sl) } } func BenchmarkConcatStringByStringsBuilderWithInitSize(b *testing.B) { for n := 0; n < b.N; n++ { concatStringByStringsBuilderWithInitSize(sl) } } func BenchmarkConcatStringByBytesBuffer(b *testing.B) { for n := 0; n < b.N; n++ { concatStringByBytesBuffer(sl) } } func BenchmarkConcatStringByBytesBufferWithInitSize(b *testing.B) { for n := 0; n < b.N; n++ { concatStringByBytesBufferWithInitSize(sl) } }
运行:
$go test -bench=. -benchmem ./string_concat_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkConcatStringByOperator-8 11744653 89.1 ns/op 80 B/op 2 allocs/op
BenchmarkConcatStringBySprintf-8 2792876 420 ns/op 176 B/op 8 allocs/op
BenchmarkConcatStringByJoin-8 22923051 49.1 ns/op 48 B/op 1 allocs/op
BenchmarkConcatStringByStringsBuilder-8 11347185 96.6 ns/op 112 B/op 3 allocs/op
BenchmarkConcatStringByStringsBuilderWithInitSize-8 26315769 42.3 ns/op 64 B/op 1 allocs/op
BenchmarkConcatStringByBytesBuffer-8 14265033 82.6 ns/op 112 B/op 2 allocs/op
BenchmarkConcatStringByBytesBufferWithInitSize-8 24777525 48.1 ns/op 48 B/op 1 allocs/op
PASS
ok command-line-arguments 8.816s
从基准测试的输出结果的第三列,即每操作耗时的数值来看:做了预初始化的strings.Builder连接构建字符串效率最高;带有预初始化的bytes.Buffer和strings.Join这两种方法效率十分接近,分列二三位;未做预初始化的strings.Builder、bytes.Buffer和操作符连接在第三档次;fmt.Sprintf性能最差,排在末尾。由此可以得出一些结论:在能预估出最终字符串长度的情况下,使用预初始化的strings.Builder连接构建字符串效率最高;strings.Join连接构建字符串的平均性能最稳定,如果输入的多个字符串是以[]string承载的,那么strings.Join也是不错的选择;使用操作符连接的方式最直观、最自然,在编译器知晓欲连接的字符串个数的情况下,使用此种方式可以得到编译器的优化处理;fmt.Sprintf虽然效率不高,但也不是一无是处,如果是由多种不同类型变量来构建特定格式的字符串,那么这种方式还是最适合的。
高效转换
在前面的例子中,我们看到了string到[]rune以及string到[]byte的转换,这两个转换也是可逆的,也就是说string和[]rune、[]byte可以双向转换。下面就是从[]rune或[]byte反向转换为string的例子:
// func main() { rs := []rune{ 0x4E2D, 0x56FD, 0x6B22, 0x8FCE, 0x60A8, } s := string(rs) fmt.Println(s) sl := []byte{ 0xE4, 0xB8, 0xAD, 0xE5, 0x9B, 0xBD, 0xE6, 0xAC, 0xA2, 0xE8, 0xBF, 0x8E, 0xE6, 0x82, 0xA8, } s = string(sl) fmt.Println(s) } $go run string_slice_to_string.go 中国欢迎您 中国欢迎您
无论是string转slice还是slice转string,转换都是要付出代价的,这些代价的根源在于string是不可变的,运行时要为转换后的类型分配新内存。我们以byte slice与string相互转换为例,看看转换过程的内存分配情况:
// func byteSliceToString() { sl := []byte{ 0xE4, 0xB8, 0xAD, 0xE5, 0x9B, 0xBD, 0xE6, 0xAC, 0xA2, 0xE8, 0xBF, 0x8E, 0xE6, 0x82, 0xA8, 0xEF, 0xBC, 0x8C, 0xE5, 0x8C, 0x97, 0xE4, 0xBA, 0xAC, 0xE6, 0xAC, 0xA2, 0xE8, 0xBF, 0x8E, 0xE6, 0x82, 0xA8, } _ = string(sl) } func stringToByteSlice() { s := "中国欢迎您,北京欢迎您" _ = []byte(s) } func main() { fmt.Println(testing.AllocsPerRun(1, byteSliceToString)) fmt.Println(testing.AllocsPerRun(1, stringToByteSlice)) }
运行:
go run
1
1
我们看到,针对“中国欢迎您,北京欢迎您”这个长度的字符串,在string与byte slice互转的过程中都要有一次内存分配操作。
在Go运行时层面,字符串与rune slice、byte slice相互转换对应的函数如下:
// $GOROOT/src/runtime/string.go slicebytetostring: []byte -> string slicerunetostring: []rune -> string stringtoslicebyte: string -> []byte stringtoslicerune: string -> []rune
以byte slice为例,看看slicebytetostring和stringtoslicebyte的实现:
// $GOROOT/src/runtime/string.go const tmpStringBufSize = 32 type tmpBuf [tmpStringBufSize]byte func stringtoslicebyte(buf *tmpBuf, s string) []byte { var b []byte if buf != nil && len(s) <= len(buf) { *buf = tmpBuf{} b = buf[:len(s)] } else { b = rawbyteslice(len(s)) } copy(b, s) return b } func slicebytetostring(buf *tmpBuf, b []byte) (str string) { l := len(b) if l == 0 { return "" } // 此处省略一些代码 if l == 1 { stringStructOf(&str).str = unsafe.Pointer(&staticbytes[b[0]]) stringStructOf(&str).len = 1 return } var p unsafe.Pointer if buf != nil && len(b) <= len(buf) { p = unsafe.Pointer(buf) } else { p = mallocgc(uintptr(len(b)), nil, false) } stringStructOf(&str).str = p stringStructOf(&str).len = len(b) memmove(p, (*(*slice)(unsafe.Pointer(&b))).array, uintptr(len(b))) return }
想要更高效地进行转换,唯一的方法就是减少甚至避免额外的内存分配操作。我们看到运行时实现转换的函数中已经加入了一些避免每种情况都要分配新内存操作的优化(如tmpBuf的复用)。slice类型是不可比较的,而string类型是可比较的,因此在日常Go编码中,我们会经常遇到将slice临时转换为string的情况。Go编译器为这样的场景提供了优化。在运行时中有一个名为slicebytetostringtmp的函数就是协助实现这一优化的:
// $GOROOT/src/runtime/string.go func slicebytetostringtmp(b []byte) string { if raceenabled && len(b) > 0 { racereadrangepc(unsafe.Pointer(&b[0]), uintptr(len(b)), getcallerpc(), funcPC(slicebytetostringtmp)) } if msanenabled && len(b) > 0 { msanread(unsafe.Pointer(&b[0]), uintptr(len(b))) } return *(*string)(unsafe.Pointer(&b)) }
该函数的“秘诀”就在于不为string新开辟一块内存,而是直接使用slice的底层存储。当然使用这个函数的前提是:在原slice被修改后,这个string不能再被使用了。因此这样的优化是针对以下几个特定场景的。
(1)string(b)用在map类型的key中
(2)string(b)用在字符串连接语句中
(3)string(b)用在字符串比较中
Go编译器对用在for-range循环中的string到[]byte的转换也有优化处理,它不会为[]byte进行额外的内存分配,而是直接使用string的底层数据。看下面的例子
func convert() { s := "中国欢迎您,北京欢迎您" sl := []byte(s) for _, v := range sl { _ = v } } func convertWithOptimize() { s := "中国欢迎您,北京欢迎您" for _, v := range []byte(s) { _ = v } } func main() { fmt.Println(testing.AllocsPerRun(1, convert)) fmt.Println(testing.AllocsPerRun(1, convertWithOptimize)) }
运行;
$go run
1
0
从结果我们看到,convertWithOptimize函数将string到[]byte的转换放在for-range循环中,Go编译器对其进行了优化,节省了一次内存分配操作。
以上就是详解Golang中string的实现原理与高效使用的详细内容,更多关于Go string的资料请关注脚本之家其它相关文章!
最新评论