go 语言string之解析

2021-02-04 10:05:17 浏览数 (1)

  • 最近研读了下go语言,所以想整理一番

string 在go中如何定义的? string 的底层原理与细节? string 如何具体使用?

string 在go中如何定义的?

所以编程中离不开字符串的处理,在Go中创建并初始化一个string类型的变量,有两种方式:

方式一:

代码语言:javascript复制
str := "hellotwordn"
$hello  word

采用“”双引号进行赋值,这样创建的字符串中可以添加转义符进行转移。 方式二:

代码语言:javascript复制
str := `hellotwordn`
$hellotwordn
str2 := `hello
           world`
$hello
           world

采用``反引号的方式不会对字符串里面的转义符进行转义,但是可以创建多行的字符串。

代码语言:javascript复制
str := "hello"   
     "-world"   

若采用双引号的形式非要换行,可以将拼接符留在行尾,这和Java是不同的。

string 底层数据结构

string源码定义:

代码语言:javascript复制
$GOROOT/src/string.go
struct String
{
        byte*   str;
        intgo   len;
};
 type stringStruct struct {
   str unsafe.Pointer
   len int
}

由源码可知,string类型的底层是一个C struct。其中str是指向字节数组的指针,同时还定义了数组的长度len。

在java 和 C 语言中,字符串一般是由char[]数组定义,而go 采用byte数组,其实主要和go语言在创建之初并不想以ASCII码为中心,其采用[]byte的方式,使得在字符串接收时,不会出现乱码。

代码语言:javascript复制
// 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的类型定义可以看出,string是一个保存在字节数组中的文本字符串,一般是utf-8格式,但并不绝对。我们可以使用utf8.ValidString(str)来校验是否是uft-8格式。 其次从定义中可以看出,string在创建之初就被初始化为“”空字符串,string类型不能被赋值为nil,例如var str string = nil在编译时就会报错。

熟悉go语言的都知道,go为更方便的处理非ASCII字符串时,定义了rune类型,那么string为什么不定义为[]rune数组?

要明白这,我们先看看byte和rune在go中如何定义的。

代码语言:javascript复制
// byte is an alias for uint8 and is equivalent to uint8 in all ways. It is
// used, by convention, to distinguish byte values from 8-bit unsigned
// integer values.
type byte = uint8

// rune is an alias for int32 and is equivalent to int32 in all ways. It is
// used, by convention, to distinguish character values from integer values.
type rune = int32

在go中使用type关键字进行定义类型,在定义类型的时候加了“=”表明该类型只是一个别称,它继承原类型的所有方法属性,而且可以和原底层类型进行相互强制转换。这和不加等号是不相同的,例如type myInt int 代表重新定义了一个类型,已经不是原类型了,不能比较和转换,而type myInt2 = int还是原int类型。

所以,可以看出byte 和 rune 只是uint8 和 int32的别称,中文一般utf-8编码下都占用3字节,所以通过遍历byte访问下标会产生乱码。

现在我们来回答这个问题,rune类型是int32相当于4个字节,它基本可以表示很多语言,(这个语言的字符量小于2^32 个字符,就可以表示),如果用rune来存储一个字节的英文就太浪费空间了(每次只占用一个字节)。其次,在go中,for 的遍历是按照字节进行遍历的限制字符串也需按照字节存储。不过go对string的一些操作进行了优化,后面我们会进行说明。

代码语言:javascript复制
func main()  {
    str := "hello word"
    fmt.Printf("%cn",str[1])
    str = "中国人"
    fmt.Printf("%cn",str[0])
    str1 := []rune(str)
    fmt.Printf("%cn",str1[0])
}
$e
$ä
$中

从上例子可以看出,通过下标直接访问字符串,是按照字节下标进行读取的,在读取汉字时读出了乱码。如果想要读取,可以将其转为[]rune类型,再按照下标读取。

代码语言:javascript复制
func main()  {
    str := "中国人"
    fmt.Printf("%cn",str[0])
    for _,i := range str {
        fmt.Printf("%cn",i)
    }
}
$ go run main.go 
ä
中
国
人

但是通过for 循环遍历就不会输出乱码,这里实际上是go进行了优化,for range 迭代首先会尝试将 string 翻译为 UTF8 文本,如果非utf-8格式就需要手动转换为[]byte数组。 然后将其转换为rune进行访问。

string 的特性

Go中的string和其他语言中的string类似,都被定义为只读类型。字符串在编程中经常会被使用到,只读可以保证数据的安全,减少编程的复杂度。

代码语言:javascript复制
package main

func main() {
    str := "hello"
    println([]byte(str))
}

$ GOOS=linux GOARCH=amd64 go tool compile -S main.go
...
go.string."hello" SRODATA dupok size=5
    0x0000 68 65 6c 6c 6f                                   hello
...

从上面可以看出“hello”被标记为SRODATA类型,所有基于string的操作都是创建了一个拷贝,同时只读类型有利用string切片的使用,因为数据不会改变str[2:3]只是创建了指向原数据的指针。

只读意味着不能修改,所以str[3] = 'c'这样的语句会在编译时就报错。那么想修改string该如何?

修改string,需要将 string 转为 []byte 修改后,再转为 string 即可。

代码语言:javascript复制
func main() {
    x := "text"
    xBytes := []byte(x)
    xBytes[0] = 'T'    // 注意此时的 T 是 rune 类型
    x = string(xBytes)
    fmt.Println(x)    // Text
}
func main() {
    x := "text"
    xRunes := []rune(x)
    xRunes[0] = '我'
    x = string(xRunes)
    fmt.Println(x)    // 我ext
}

如果修改的是中文,则需转换为[]rune,string 和[]byte间的转换涉及到了拷贝,数组先将这段内存拷贝到堆或者栈上;将变量的类型转换成 []byte 后并修改字节数据;将修改后的字节数组转换回 string。

其次,对于求字符串长度,在java中String是一个对象,对于其属性类似于长度,只是属于其内部的方法就可以得到字符串的长度。Go在创建之初就对Go的面向对象进行了定义,是或不是。Go追求一种更为方便先进的模式。所以在go中求一个字符串的长度使用len()函数,它可以求任何类型的长度。

但是len() 返回的是字符串的 byte 数量,并不是unicode数量,如果要得到字符串的字符数,可使用 "unicode/utf8" 包中的 RuneCountInString(str string) (n int)。关于len()函数以后再做解读。

string 的底层原理与细节?

string 是由C定义的字节数组,那么其创建时,创建在了哪里?

先说结论,如果创建的是全局变量,则分配在栈上,如果是局部变量可能在栈上也可能在堆上。

值类型变量的内存通常是在栈中分配,像基本类型,包括string等

代码语言:javascript复制
值类型分别有:int系列、float系列、bool、string、数组和结构体

引用类型有:指针、slice切片、管道channel、接口interface、map、函数等

值类型的特点是:变量直接存储值,内存通常在栈中分配

引用类型的特点是:变量存储的是一个地址,这个地址对应的空间里才是真正存储的值,内存通常在堆中分配

现在我们举例看看:

代码语言:javascript复制
package main // 所有Go程序从main包开始运行

func main() {
   f()
}

func f() string {
    var s string = "hell0"
    return s
}
$ go tool compile -m main.go 
main.go:7:6: can inline f
main.go:3:6: can inline main
main.go:4:5: inlining call to f
代码语言:javascript复制
package main // 所有Go程序从main包开始运行

func main() {
   f()
}

func f() *string {
    var s string = "hell0"
    return &s
}
$ go tool compile -m main.go 
main.go:7:6: can inline f
main.go:3:6: can inline main
main.go:4:5: inlining call to f
main.go:8:6: moved to heap: s

从示例2中可以看出,s 从栈中逃逸到了堆中。 Go的编译器,它还会做逃逸分析(escape analysis),如果它发现变量的作用域没有跑出太远,它就可以在栈上分配空间而不是堆,即使我们用new分配。

栈的内存结构我们之后再说。

go 有没有字符串常量池呢?

代码语言:javascript复制
package main // 所有Go程序从main包开始运行

func main() {
    var str1 = "hello"
    var str2 = "hello"
    println(&str1, &str2)
}
$0xc00003df68 0xc00003df58

发现其go是没有字符常量池的,处理大量重复的字符会有性能问题。

可以在需要的时候自己实现一个常量池。

字符串拼接

使用字符串自然而然会使用到字符串的拼接,Go 语言拼接字符串会使用 符号,编译器会将该符号对应的 OADD 节点转换成 OADDSTR 类型的节点,随后 cmd/compile/internal/gc.walkexpr 中调用 cmd/compile/internal/gc.addstr 函数生成用于拼接字符串的代码。

执行cmd/compile/internal/gc.addstr函数,它 能帮助我们在编译期间选择合适的函数对字符串进行拼接,该函数会根据带拼接的字符串数量选择不同的逻辑。

如果小于或者等于 5 个,那么会调用 concatstring{2,3,4,5}进行拼接,如果超过 5 个,那么会选择 runtime.concatstrings 传入一个数组切片。

代码语言:javascript复制
func concatstrings(buf *tmpBuf, a []string) string {
    idx := 0
    l := 0
    count := 0
    for i, x := range a {
        n := len(x)
        if n == 0 {
            continue
        }
        l  = n
        count  
        idx = i
    }
    if count == 0 {
        return ""
    }
    if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
        return a[idx]
    }
    s, b := rawstringtmp(buf, l)
    for _, x := range a {
        copy(b, x)
        b = b[len(x):]
    }
    return s
}

字符串拼接的过程为: 先过滤掉空字符串,并统计字符串加起来的总长度。如果拼接数量为 1 并且当前的字符串不在栈上,就可以直接返回该字符串。否则调用 copy将输入的多个字符串拷贝到目标字符串所在的内存空间。

所以说使用“ ”合并字符串,这种合并方式效率非常低,每合并一次,都是创建一个新的字符串,就必须遍历复制一次字符串。

Java中提供StringBuilder类(最高效,线程不安全)来解决这个问题。Go中也有类似的机制,那就是Buffer(线程不安全)。

代码语言:javascript复制
package main // 所有Go程序从main包开始运行

import (
    "bytes"
    "fmt"
)

func main() {
    var buffer bytes.Buffer
    for i :=0; i<1000; i   {
        buffer.WriteString("ok")
    }
    fmt.Println(buffer.String())
}

在Go语言中,如果没有明确声明并发访问某事物是安全的,则不是。

所以,Buffer是不安全的。

代码语言:javascript复制
// A Buffer is a variable-sized buffer of bytes with Read and Write methods.
// The zero value for Buffer is an empty buffer ready to use.
type Buffer struct {
    buf       []byte   // contents are the bytes buf[off : len(buf)]
    off       int      // read at &buf[off], write at &buf[len(buf)]
    bootstrap [64]byte // memory to hold first slice; helps small buffers avoid allocation.
    lastRead  readOp   // last read operation, so that Unread* can work correctly.
}

// Write appends the contents of p to the buffer, growing the buffer as
// needed. The return value n is the length of p; err is always nil. If the
// buffer becomes too large, Write will panic with ErrTooLarge.
func (b *Buffer) Write(p []byte) (n int, err error) {
    b.lastRead = opInvalid
    m := b.grow(len(p))
    return copy(b.buf[m:], p), nil
}

// Read reads the next len(p) bytes from the buffer or until the buffer
// is drained. The return value n is the number of bytes read. If the
// buffer has no data to return, err is io.EOF (unless len(p) is zero);
// otherwise it is nil.
func (b *Buffer) Read(p []byte) (n int, err error) {
    b.lastRead = opInvalid
    if b.off >= len(b.buf) {
        // Buffer is empty, reset to recover space.
        b.Truncate(0)
        if len(p) == 0 {
            return
        }
        return 0, io.EOF
    }
    n = copy(p, b.buf[b.off:])
    b.off  = n
    if n > 0 {
        b.lastRead = opRead
    }
    return
}

从源码可以看出,buffer就是一个可扩容的字节数组,设置读写位置标记。实现线程安全可以在写时加锁。

此外还有strings.Builder

代码语言:javascript复制
type Builder struct {
    addr *Builder // of receiver, to detect copies by value
    buf  []byte
}

这个包并不是线程安全的,

代码语言:javascript复制
package main

import (
    "fmt"
    "strings"
    "sync"
    "sync/atomic"
)

func main() {
    var b strings.Builder
    var n int32
    var wait sync.WaitGroup
    var lock sync.Mutex
    for i := 0; i < 1000; i   {
        wait.Add(1)

        go func() {
            atomic.AddInt32(&n, 1)

            lock.Lock()
            b.WriteString("1")
            lock.Unlock()
            wait.Done()
        }()
    }
    wait.Wait()

    fmt.Println(len(b.String()), n)
}

加锁,可以实现线程安全的Builder.具体的方法与原理介绍下次再说。

字符串相等

go 判断两个字符串相等的办法直接使用 == 就可以判断

其次,还有strings.Compare的底层也是用 == ,如果判断相等,用==就好,比较大小可以用strings.Compare

strings.EqualFold可以忽略大小写,比较UTF-8编码在小写的条件下是否相等,不区分大小写,注意只能utf-8

string 如何使用?
  • strings

这里只简单介绍,后面详细介绍

是否存在某个字符或子串
代码语言:javascript复制
// 子串 substr 在 s 中,返回 true
func Contains(s, substr string) bool
// chars 中任何一个 Unicode 代码点在 s 中,返回 

fmt.Println(strings.ContainsAny("team", "i"))
fmt.Println(strings.ContainsAny("failure", "u & i"))

输出:

false
true

第二个参数 chars 中任意一个字符(Unicode Code Point)如果在第一个参数 s 中存在,则返回 true。

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

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

代码语言:javascript复制
func Count(s, sep string) int
fmt.Println(strings.Count("cheese", "e"))
3
修剪空格
代码语言:javascript复制
// 将 s 左侧和右侧中匹配 cutset 中的任一字符的字符去掉
func Trim(s string, cutset string) string
字符串子串替换

go 增加一些性能好的语法糖,进行字符串替换时,考虑到性能问题,能不用正则尽量别用,应该用这里的函数。

字符串替换的函数签名如下:

代码语言:javascript复制
// 用 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
使用示例:

fmt.Println(strings.Replace("oink oink oink", "k", "ky", 2))
fmt.Println(strings.Replace("oink oink oink", "oink", "moo", -1))
fmt.Println(strings.ReplaceAll("oink oink oink", "oink", "moo"))
输出:

oinky oinky oink
moo moo moo
moo moo moo
字符替换
代码语言:javascript复制
func Map(mapping func(rune) rune, s string) string

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('Worldn,好Hello^(&(*界gopher..."))
字符分割
代码语言:javascript复制
func Split(s, sep string) []string { return genSplit(s, sep, 0, -1) }
出现位置
代码语言:javascript复制
func Index(s, sep string) int
字符数组拼接
代码语言:javascript复制
func Join(a []string, sep string) string

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

字符串和基本数据类型之间转换。strconv 包定义了两个 error 类型的变量:ErrRange 和 ErrSyntax。其中,ErrRange 表示值超过了类型能表示的最大范围,比如将 "128" 转为 int8 就会返回这个错误;ErrSyntax 表示语法错误,比如将 "" 转为 int 类型会返回这个错误

字符串转为整型

长相和java类似,

代码语言:javascript复制
func ParseInt(s string, base int, bitSize int) (i int64, err error)
func ParseUint(s string, base int, bitSize int) (n uint64, err error)
func Atoi(s string) (i int, err error)
n, err := strconv.ParseInt("128", 10, 8)
bitSize 表示整数的具体类型。取值 0、8、16、32 和 64 分别代表 int、int8、int16、int32 和 int64。

Atoi 是 ParseInt 的便捷版,内部通过调用 ParseInt(s, 10, 0) 来实现的;

代码语言:javascript复制
func main() {
    a,err := strconv.ParseInt("128",10,8)
    fmt.Printf("%d, %vn",a,err)
}
127, strconv.ParseInt: parsing "128": value out of range
整型转为字符串

在 Java 中,可以通过操作符 " " 直接做到。或者go中str()转换。

代码语言:javascript复制
fmt.Sprintf("%d", 127)
fmt.Println(fmt.Sprintf("%d",128)   "222")

或者

代码语言:javascript复制
func FormatInt(i int64, base int) string    // 有符号整型转字符串
func Itoa(i int) string
fmt.Println(strconv.Itoa(128)   "222")

浮点类型和bool等类型类似可以查询api

0 人点赞