深入理解 Golang 中 string
in Note with 1 comment
深入理解 Golang 中 string
in Note with 1 comment

string

几乎任何程序都离不开文本(字符串)。Go 中 string 是内置类型,同时它与普通的 slice 类型有着相似的性质,例如,可以进行切片(slice)操作,这使得 Go 中少了一些处理 string 类型的函数,比如没有substring这样的函数,然而却能够很方便的进行这样的操作。同时, string 是不可变的(只读),因此无法修改,只能使用len()获取长度,无法使用cap()获取容量。除此之外,Go 标准库中有几个包专门用于处理文本。

跟 C 对比

Go 往往会与 C 比较,毕竟有相同的创造者。可以说 Go 的 string 就是为了解决 C 使用字符串的困难。

下面就来列出 Go string 的特点及对比 C 的改进:

源码阅读

Go 标准库 builtin 给出了所有内置类型的定义。 源代码位于src/builtin/builtin.go,其中关于 string 的描述如下:

// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string

所以 string 是 8 比特字节的集合,通常但并不一定是 UTF-8 编码的文本。
另外,还提到了两点,非常重要:

runtime 包下的 string 的结构体为:

type stringStruct struct {
    str unsafe.Pointer
    len int
}

其数据结构很简单:

string数据结构跟切片有些类似,只不过切片还有一个表示容量的成员,事实上 string 和切片,准确的说是 byte 切片经常发生转换。

reflect 包下的 string 的结构体为:

// StringHeader is the runtime representation of a string.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
type StringHeader struct {
    Data uintptr
    Len  int
}

两者有相似之处,但不一样。从StringHeader的注释中可以看出: StringHeader是 string 在运行时的表示形式,它本身不存储 string 的数据,而只包含一个指向 string 底层存储的指针(Data uinptr)和一个表示 string 长度的 int 字段(Len int),string 的底层存储是一个 byte 类型的数组。

2021-04-23T08:55:37.png

关于uintptr Go 语言规范 https://golang.org/ref/spec 中是这样描述的,an unsigned integer large enough to store the uninterpreted bits of a pointer value,是一个无符号整形大到可以存储任意一个指针的值。对比 uint 是和平台相关的无符号整形,在32位机器上是32位无符号整形,在64位机器上是64位无符号整形。

常用操作

字符串比较

Compare 函数,用于比较两个字符串的大小,如果两个字符串相等,返回为 0。如果 a 小于 b ,返回 -1 ,反之返回 1 。不推荐使用这个函数,直接使用 == != > < >= <= 等一系列运算符更加直观。

func Compare(a, b string) int 

EqualFold 函数,计算 s 与 t 忽略字母大小写后是否相等。

func EqualFold(s, t string) bool

是否存在某个字符或子串

// 子串 substr 在 s 中,返回 true
func Contains(s, substr string) bool
// chars 中任何一个 Unicode 代码点在 s 中,返回 true
func ContainsAny(s, chars string) bool
// Unicode 代码点 r 在 s 中,返回 true
func ContainsRune(s string, r rune) bool

子串出现次数(字符串匹配)

在数据结构与算法中,可能会讲解以下字符串匹配算法:

还有其他的算法,这里不一一列举,感兴趣的可以网上搜一下。

在 Go 中,查找子串出现次数即字符串模式匹配,实现的是Rabin-Karp算法。Count 函数如下:

func Count(s, sep string) int

字符串分割为[]string

strings 包提供了六个三组分割函数:Fields 和 FieldsFunc、Split 和 SplitAfter、SplitN 和 SplitAfterN。

Fields 用一个或多个连续的空格分隔字符串 s,返回子字符串的数组(slice)。如果字符串 s 只包含空格,则返回空列表 ([]string 的长度为 0)。其中,空格的定义是 unicode.IsSpace,之前已经介绍过。

fmt.Printf("Fields are: %q", strings.Fields("  foo bar  baz   "))
Fields are: ["foo" "bar" "baz"]

FieldsFunc 用这样的 Unicode 代码点 c 进行分隔:满足 f(c) 返回 true。该函数返回[]string。如果字符串 s 中所有的代码点 (unicode code points) 都满足 f(c) 或者 s 是空,则 FieldsFunc 返回空 slice。

fmt.Println(strings.FieldsFunc("  foo bar  baz   ", unicode.IsSpace))

实际上,Fields 函数就是调用 FieldsFunc 实现的:

func Fields(s string) []string {
  return FieldsFunc(s, unicode.IsSpace)
}

Split 和 SplitAfter、 SplitN 和 SplitAfterN 它们都是通过一个同一个内部函数来实现的。

func Split(s, sep string) []string { return genSplit(s, sep, 0, -1) }
func SplitAfter(s, sep string) []string { return genSplit(s, sep, len(sep), -1) }
func SplitN(s, sep string, n int) []string { return genSplit(s, sep, 0, n) }
func SplitAfterN(s, sep string, n int) []string { return genSplit(s, sep, len(sep), n) }

它们底层实现都调用了genSplit函数。

Split(s, sep)SplitN(s, sep, -1)等价;SplitAfter(s, sep)SplitAfterN(s, sep, -1)等价。

Split会将 s 中的 sep 去掉,而SplitAfter会保留 sep。如下:

fmt.Printf("%q\n", strings.Split("foo,bar,baz", ","))
fmt.Printf("%q\n", strings.SplitAfter("foo,bar,baz", ","))

["foo" "bar" "baz"]
["foo," "bar," "baz"]

字符串是否有某个前缀或后缀

// s 中是否以 prefix 开始
func HasPrefix(s, prefix string) bool {
  return len(s) >= len(prefix) && s[0:len(prefix)] == prefix
}
// s 中是否以 suffix 结尾
func HasSuffix(s, suffix string) bool {
  return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}

如果 prefix 或 suffix 为 "" , 返回值总是 true。

fmt.Println(strings.HasPrefix("Gopher", "Go"))
fmt.Println(strings.HasPrefix("Gopher", "C"))
fmt.Println(strings.HasPrefix("Gopher", ""))

true
false
true

字符或子串在字符串中出现的位置

// 在 s 中查找 sep 的第一次出现,返回第一次出现的索引
func Index(s, sep string) int
// 在 s 中查找字节 c 的第一次出现,返回第一次出现的索引
func IndexByte(s string, c byte) int
// chars 中任何一个 Unicode 代码点在 s 中首次出现的位置
func IndexAny(s, chars string) int
// 查找字符 c 在 s 中第一次出现的位置,其中 c 满足 f(c) 返回 true
func IndexFunc(s string, f func(rune) bool) int
// Unicode 代码点 r 在 s 中第一次出现的位置
func IndexRune(s string, r rune) int

// 有三个对应的查找最后一次出现的位置
func LastIndex(s, sep string) int
func LastIndexByte(s string, c byte) int
func LastIndexAny(s, chars string) int
func LastIndexFunc(s string, f func(rune) bool) int

字符串 JOIN 操作

将字符串数组(或 slice)连接起来可以通过 Join 实现:

fmt.Println(Join([]string{"name=xxx", "age=xx"}, "&"))

name=xxx&age=xx

字符替换

Map 函数,将 s 的每一个字符按照 mapping 的规则做映射替换,如果 mapping 返回值 <0 ,则舍弃该字符。该方法只能对每一个字符做处理,但处理方式很灵活,可以方便的过滤,筛选汉字等。

mapping := func(r rune) rune {
    switch {
    case r >= 'A' && r <= 'Z': // 大写字母转小写
        return r + 32
    case r >= 'a' && r <= 'z': // 小写字母不处理
        return r
    case unicode.Is(unicode.Han, r): // 汉字换行
        return '\n'
    }
    return -1 // 过滤所有非字母、汉字的字符
}
fmt.Println(strings.Map(mapping, "Hello你#¥%……\n('World\n,好Hello^(&(*界gopher..."))

hello
world
hello
gopher

字符串子串替换

进行字符串替换时,考虑到性能问题,能不用正则尽量别用,应该用这里的函数。

// 用 new 替换 s 中的 old,一共替换 n 个。
// 如果 n < 0,则不限制替换次数,即全部替换
func Replace(s, old, new string, n int) string
// 该函数内部直接调用了函数 Replace(s, old, new , -1)
func ReplaceAll(s, old, new string) string

大小写转换

func ToLower(s string) string
func ToLowerSpecial(c unicode.SpecialCase, s string) string
func ToUpper(s string) string
func ToUpperSpecial(c unicode.SpecialCase, s string) string

修剪

// 将 s 左侧和右侧中匹配 cutset 中的任一字符的字符去掉
func Trim(s string, cutset string) string
// 将 s 左侧的匹配 cutset 中的任一字符的字符去掉
func TrimLeft(s string, cutset string) string
// 将 s 右侧的匹配 cutset 中的任一字符的字符去掉
func TrimRight(s string, cutset string) string
// 如果 s 的前缀为 prefix 则返回去掉前缀后的 string , 否则 s 没有变化。
func TrimPrefix(s, prefix string) string
// 如果 s 的后缀为 suffix 则返回去掉后缀后的 string , 否则 s 没有变化。
func TrimSuffix(s, suffix string) string
// 将 s 左侧和右侧的间隔符去掉。常见间隔符包括:'\t', '\n', '\v', '\f', '\r', ' ', U+0085 (NEL)
func TrimSpace(s string) string
// 将 s 左侧和右侧的匹配 f 的字符去掉
func TrimFunc(s string, f func(rune) bool) string
// 将 s 左侧的匹配 f 的字符去掉
func TrimLeftFunc(s string, f func(rune) bool) string
// 将 s 右侧的匹配 f 的字符去掉
func TrimRightFunc(s string, f func(rune) bool) string

高阶操作

Replacer 类型

这是一个结构,没有导出任何字段,实例化通过func NewReplacer(oldnew ...string) *Replacer函数进行,其中不定参数 oldnew 是 old-new 对,即进行多个替换。

r := strings.NewReplacer("<", "&lt;", ">", "&gt;")
fmt.Println(r.Replace("This is <b>HTML</b>!"))

This is &lt;b&gt;HTML&lt;/b&gt;!

Builder 类型

type Builder struct {
    addr *Builder // of receiver, to detect copies by value
    buf  []byte
}

该类型实现了 io 包下的 Writer, ByteWriter, StringWriter 等接口,可以向该对象内写入数据,Builder 没有实现 Reader 等接口,所以该类型不可读,但提供了 String 方法可以获取对象内的数据。

// 该方法向 b 写入一个字节
func (b *Builder) WriteByte(c byte) error
// WriteRune 方法向 b 写入一个字符
func (b *Builder) WriteRune(r rune) (int, error)
// WriteRune 方法向 b 写入字节数组 p
func (b *Builder) Write(p []byte) (int, error)
// WriteRune 方法向 b 写入字符串 s
func (b *Builder) WriteString(s string) (int, error)
// Len 方法返回 b 的数据长度。
func (b *Builder) Len() int
// Cap 方法返回 b 的 cap。
func (b *Builder) Cap() int
// Grow 方法将 b 的 cap 至少增加 n (可能会更多)。如果 n 为负数,会导致 panic。
func (b *Builder) Grow(n int)
// Reset 方法将 b 清空 b 的所有内容。
func (b *Builder) Reset()
// String 方法将 b 的数据以 string 类型返回。
func (b *Builder) String() string

转 slice 的问题

我们先看一个例子:

func main() {
        fmt.Println(testing.AllocsPerRun(1, convert1)) // 输出1
        fmt.Println(testing.AllocsPerRun(1, convert2)) // 输出0
}

func convert1() {
        s := "中国欢迎您,北京欢迎您"
        sl := []byte(s)
        for _, v := range sl {
                _ = v
        }
}

func convert2() {
        s := "中国欢迎您"
        sl := []byte(s)
        for _, v := range sl {
                _ = v
        }
}

为什么convert1方法产生了一次内存拷贝,而convert2方法没有产生内存拷贝?

因为对于字节长度小于 32 的字符串,不会进行内存拷贝。以下为源码部分:

// The constant is known to the compiler.
// There is no fundamental theory behind this number.
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
}


// rawbyteslice allocates a new byte slice. The byte slice is not zeroed.
func rawbyteslice(size int) (b []byte) {
        cap := roundupsize(uintptr(size))
        p := mallocgc(cap, nil, false)
        if cap != uintptr(size) {
                memclrNoHeapPointers(add(p, uintptr(size)), cap-uintptr(size))
        }

        *(*slice)(unsafe.Pointer(&b)) = slice{p, size, int(cap)}
        return
}

如果字符串超过32,但是还是希望可以实现0次内存拷贝,应该怎么做?

第一个方案是使用for range

func main() {
        fmt.Println(testing.AllocsPerRun(1, convert)) // 输出0
}

func convert() {
        s := "中国欢迎您,北京欢迎您"
        for _, v := range []byte(s) {
                _ = v
        }
}

第二个方案是使用强转换,基于reflectunsafe

func main() {
    fmt.Println(testing.AllocsPerRun(1, convert)) // 输出0
}

func convert() {
    s := "中国欢迎您,北京欢迎您"
    sl := StringToBytes(s)
    for _, v := range sl {
        _ = v
    }
}

func StringToBytes(s string) (b []byte) {
    sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))
    bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
    bh.Data, bh.Len, bh.Cap = sh.Data, sh.Len, sh.Len
    return b
}

性能测试:

var x = "中国欢迎您,北京欢迎您"

// BenchmarkBytesToString 强转换
func BenchmarkBytesToString(b *testing.B) {
        for i := 0; i <= b.N; i++ {
                _ = StringToBytes(x)
        }
}

//BenchmarkBytesToStringNormal 原生
func BenchmarkBytesToStringNormal(b *testing.B) {
        for i := 0; i <= b.N; i++ {
                _ = []byte(x)
        }
}

BenchmarkBytesToString
BenchmarkBytesToString-4                1000000000               0.3269 ns/op
BenchmarkBytesToStringNormal
BenchmarkBytesToStringNormal-4          36955354                34.13 ns/op

很明显,强转换比[]byte(string) 性能要高很多。

字符串拼接

在 Go 语言中,常见的字符串拼接方式有如下 5 种:

性能测试:

BenchmarkPlusConcat-4                 15         107333204 ns/op        530997245 B/op     10018 allocs/op
BenchmarkSprintfConcat-4               7         154160789 ns/op        832915797 B/op     34114 allocs/op
BenchmarkBuilderConcat-4            6637            171604 ns/op          514801 B/op         23 allocs/op
BenchmarkBufferConcat-4             7231            164885 ns/op          368576 B/op         13 allocs/op
BenchmarkByteConcat-4               7371            248181 ns/op          621298 B/op         24 allocs/op

从基准测试的结果来看,strings.Builder > bytes.Buffer > []byte > + > fmt.Sprintf

参考

Responses
  1. 1212

    2121是

    Reply