详解Golang中string的实现原理与高效使用

 更新时间:2024年01月22日 08:06:21   作者:半芽湾  
在Go语言中,无论是字符串常量、字符串变量还是代码中出现的字符串字面量,它们的类型都被统一设置为string,下面就跟随小编一起来了解一下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的资料请关注脚本之家其它相关文章!

相关文章

  • golang强制类型转换和类型断言

    golang强制类型转换和类型断言

    这篇文章主要介绍了详情介绍golang类型转换问题,分别由介绍类型断言和类型转换,这两者都是不同的概念,下面文章围绕类型断言和类型转换的相关资料展开文章的详细内容,需要的朋友可以参考以下
    2021-12-12
  • go语言中排序sort的使用方法示例

    go语言中排序sort的使用方法示例

    golang中也实现了排序算法的包sort包,下面这篇文章就来给大家介绍了关于go语言中排序sort的使用方法,文中通过示例代码介绍的非常详细,需要的朋友可以参考借鉴,下面随着小编来一起学习学习吧
    2018-06-06
  • go中for range的坑以及解决方案

    go中for range的坑以及解决方案

    相信小伙伴都遇到过以下的循环变量的问题,本文主要介绍了go中for range的坑以及解决方案,具有一定的参考价值,感兴趣的可以了解一下
    2024-01-01
  • Golang 流水线设计模式实践示例详解

    Golang 流水线设计模式实践示例详解

    这篇文章主要为大家介绍了Golang 流水线设计模式实践示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-12-12
  • golang 实现tcp转发代理的方法

    golang 实现tcp转发代理的方法

    今天小编就为大家分享一篇golang 实现tcp转发代理的方法,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2019-08-08
  • Go归并排序算法的实现方法

    Go归并排序算法的实现方法

    归并排序采用的也是分治的策略,把原本的问题先分解成一些小问题进行求解,再把这些小问题各自的答案修整到一起得到原本问题的答案,从而达到分而治之的目的,对Go归并排序算法相关知识感兴趣的朋友一起看看吧
    2022-04-04
  • 7分钟读懂Go的临时对象池pool以及其应用场景

    7分钟读懂Go的临时对象池pool以及其应用场景

    这篇文章主要给大家介绍了关于如何通过7分钟读懂Go的临时对象池pool以及其应用场景的相关资料,文中通过示例代码介绍的非常详细,对大家学习或使用Go具有一定的参考学习价值,需要的朋友们下面来一起看看吧
    2018-11-11
  • golang解压带密码的zip包的方法详解

    golang解压带密码的zip包的方法详解

    ZIP 文件格式是一种常用的压缩和归档格式,用于将多个文件和目录打包到一个单独的文件中,同时对其内容进行压缩以减少文件大小,golang zip包的解压有官方的zip包(archive/zip),但是官方给的zip解压包代码只有解压不带密码的zip包,下面给出解压操作的封装
    2024-07-07
  • Go并发编程实现数据竞争

    Go并发编程实现数据竞争

    本文主要介绍了Go并发编程实现数据竞争,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-09-09
  • Go中crypto/rsa库的高效使用指南

    Go中crypto/rsa库的高效使用指南

    本文主要介绍了Go中crypto/rsa库的高效使用指南,从 RSA 的基本原理到 crypto/rsa 库的实际应用,具有一定的参考价值,感兴趣的可以了解一下
    2024-02-02

最新评论