go标准库--字符串操作

111 阅读6分钟

strings

字符串 JOIN 操作

将字符串数组(或 slice)连接起来可以通过 Join 实现,函数签名如下:

func Join(a []string, sep string) string

假如没有这个库函数,我们自己实现一个,我们会这么实现:

func Join(str []string, sep string) string {
  // 特殊情况应该做处理
  if len(str) == 0 {
      return ""
  }
  if len(str) == 1 {
      return str[0]
  }
  buffer := bytes.NewBufferString(str[0])
  for _, s := range str[1:] {
      buffer.WriteString(sep)
      buffer.WriteString(s)
  }
  return buffer.String()
}

这里,我们使用了 bytes 包的 Buffer 类型,避免大量的字符串连接操作(因为 Go 中字符串是不可变的)。我们再看一下标准库的实现:

func Join(a []string, sep string) string {
  if len(a) == 0 {
      return ""
  }
  if len(a) == 1 {
      return a[0]
  }
  n := len(sep) * (len(a) - 1)
  for i := 0; i < len(a); i++ {
      n += len(a[i])
  }

  b := make([]byte, n)
  bp := copy(b, a[0])
  for _, s := range a[1:] {
      bp += copy(b[bp:], sep)
      bp += copy(b[bp:], s)
  }
  return string(b)
}

标准库的实现没有用 bytes 包,当然也不会简单的通过 + 号连接字符串。Go 中是不允许循环依赖的,标准库中很多时候会出现代码拷贝,而不是引入某个包。这里 Join 的实现方式挺好,我个人猜测,不直接使用 bytes 包,也是不想依赖 bytes 包(其实 bytes 中的实现也是 copy 方式)。

简单使用示例:

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

输出结果:

name=xxx&age=xx

修剪

// 将 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

包含了 9 个相关函数用于修剪字符串。

举个例子:

x := "!!!@@@你好,!@#$ Gophers###$$$"
fmt.Println(strings.Trim(x, "@#$!%^&*()_+=-"))
fmt.Println(strings.TrimLeft(x, "@#$!%^&*()_+=-"))
fmt.Println(strings.TrimRight(x, "@#$!%^&*()_+=-"))
fmt.Println(strings.TrimSpace(" \t\n Hello, Gophers \n\t\r\n"))
fmt.Println(strings.TrimPrefix(x, "!"))
fmt.Println(strings.TrimSuffix(x, "$"))

f := func(r rune) bool {
    return !unicode.Is(unicode.Han, r) // 非汉字返回 true
}
fmt.Println(strings.TrimFunc(x, f))
fmt.Println(strings.TrimLeftFunc(x, f))
fmt.Println(strings.TrimRightFunc(x, f))

输出结果:

你好,!@#$ Gophers
你好,!@#$ Gophers###$$$
!!!@@@你好,!@#$ Gophers
Hello, Gophers
!!@@@你好,!@#$ Gophers###$$$
!!!@@@你好,!@#$ Gophers###$$
你好
你好,!@#$ Gophers###$$$
!!!@@@你好

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

Builder 有 4 个与写入相关的方法,这 4 个方法的 error 都总是为 nil.

Builder 的 cap 会自动增长,一般不需要手动调用 Grow 方法。

String 方法可以方便的获取 Builder 的内容。

举个例子:

b := strings.Builder{}
_ = b.WriteByte('7')
n, _ := b.WriteRune('夕')
fmt.Println(n)
n, _ = b.Write([]byte("Hello, World"))
fmt.Println(n)
n, _ = b.WriteString("你好,世界")
fmt.Println(n)
fmt.Println(b.Len())
fmt.Println(b.Cap())
b.Grow(100)
fmt.Println(b.Len())
fmt.Println(b.Cap())
fmt.Println(b.String())
b.Reset()
fmt.Println(b.String())

输出结果:

3
12
15
31
32
31
164
7夕Hello, World你好,世界

bytes

Runes 类型转换

// 将 []byte 转换为 []rune
func Runes(s []byte) []rune

该函数将 []byte 转换为 []rune ,适用于汉字等多字节字符,示例:

b:=[]byte("你好,世界")
for k,v:=range b{
    fmt.Printf("%d:%s |",k,string(v))
}
r:=bytes.Runes(b)
for k,v:=range r{
    fmt.Printf("%d:%s|",k,string(v))
}

运行结果:

0:ä |1:½ |2:  |3:å |4:¥ |5:½ |6:ï |7:¼ |8:  |9:ä |10:¸ |11:  |12:ç |13:  |14: |
0:你|1:好|2:,|3:世|4:界|

Reader 类型

type Reader struct {
    s        []byte
    i        int64 // 当前读取下标
    prevRune int   // 前一个字符的下标,也可能 < 0
}

bytes 包下的 Reader 类型实现了 io 包下的 Reader, ReaderAt, RuneReader, RuneScanner, ByteReader, ByteScanner, ReadSeeker, Seeker, WriterTo 等多个接口。主要用于 Read 数据。

我们需要在通过 bytes.NewReader 方法来初始化 bytes.Reader 类型的对象。初始化时传入 []byte 类型的数据。NewReader 函数签名如下:

func NewReader(b []byte) *Reader

如果直接声明该对象了,可以通过 Reset 方法重新写入数据,示例:

x:=[]byte("你好,世界")

r1:=bytes.NewReader(x)
d1:=make([]byte,len(x))
n,_:=r1.Read(d1)
fmt.Println(n,string(d1))

r2:=bytes.Reader{}
r2.Reset(x)
d2:=make([]byte,len(x))
n,_=r2.Read(d2)
fmt.Println(n,string(d2))

输出结果:

15 你好,世界
15 你好,世界

Reader 包含了 8 个读取相关的方法,实现了前面提到的 io 包下的 9 个接口(ReadSeeker 接口内嵌 Reader 和 Seeker 两个接口):

// 读取数据至 b 
func (r *Reader) Read(b []byte) (n int, err error) 
// 读取一个字节
func (r *Reader) ReadByte() (byte, error)
// 读取一个字符
func (r *Reader) ReadRune() (ch rune, size int, err error)
// 读取数据至 w
func (r *Reader) WriteTo(w io.Writer) (n int64, err error)
// 进度下标指向前一个字节,如果 r.i <= 0 返回错误。
func (r *Reader) UnreadByte() 
// 进度下标指向前一个字符,如果 r.i <= 0 返回错误,且只能在每次 ReadRune 方法后使用一次,否则返回错误。
func (r *Reader) UnreadRune() 
// 读取 r.s[off:] 的数据至b,该方法忽略进度下标 i,不使用也不修改。
func (r *Reader) ReadAt(b []byte, off int64) (n int, err error) 
// 根据 whence 的值,修改并返回进度下标 i ,当 whence == 0 ,进度下标修改为 off,当 whence == 1 ,进度下标修改为 i+off,当 whence == 2 ,进度下标修改为 len[s]+off.
// off 可以为负数,whence 的只能为 0,1,2,当 whence 为其他值或计算后的进度下标越界,则返回错误。
func (r *Reader) Seek(offset int64, whence int) (int64, error)

示例:

x := []byte("你好,世界")
r1 := bytes.NewReader(x)

ch, size, _ := r1.ReadRune()
fmt.Println(size, string(ch))
_ = r1.UnreadRune()
ch, size, _ = r1.ReadRune()
fmt.Println(size, string(ch))
_ = r1.UnreadRune()

by, _ := r1.ReadByte()
fmt.Println(by)
_ = r1.UnreadByte()
by, _ = r1.ReadByte()
fmt.Println(by)
_ = r1.UnreadByte()

d1 := make([]byte, 6)
n, _ := r1.Read(d1)
fmt.Println(n, string(d1))

d2 := make([]byte, 6)
n, _ = r1.ReadAt(d2, 0)
fmt.Println(n, string(d2))

w1 := &bytes.Buffer{}
_, _ = r1.Seek(0, 0)
_, _ = r1.WriteTo(w1)
fmt.Println(w1.String())

运行结果:

3 你
3 你
228
228
6 你好
6 你好
你好,世界

Buffer 类型

type Buffer struct {
    buf      []byte
    off      int   
    lastRead readOp 
}

在上一个示例的最后,我们使用了 bytes.Buffer 类型,该类型实现了 io 包下的 ByteScanner, ByteWriter, ReadWriter, Reader, ReaderFrom, RuneReader, RuneScanner, StringWriter, Writer, WriterTo 等接口,可以方便的进行读写操作。

对象可读取数据为 buf[off : len(buf)], off 表示进度下标,lastRead 表示最后读取的一个字符所占字节数,方便 Unread* 相关操作。

Buffer 可以通过 3 中方法初始化对象:

a := bytes.NewBufferString("Hello World")
b := bytes.NewBuffer([]byte("Hello World"))
c := bytes.Buffer{}

fmt.Println(a)
fmt.Println(b)
fmt.Println(c)
}

输出结果:

Hello World
Hello World
{[] 0 0}

Buffer 包含了 21 个读写相关的方法,大部分同名方法的用法与前面讲的类似,这里只讲演示其中的 3 个方法:

// 读取到字节 delim 后,以字节数组的形式返回该字节及前面读取到的字节。如果遍历 b.buf 也找不到匹配的字节,则返回错误(一般是 EOF)
func (b *Buffer) ReadBytes(delim byte) (line []byte, err error)
// 读取到字节 delim 后,以字符串的形式返回该字节及前面读取到的字节。如果遍历 b.buf 也找不到匹配的字节,则返回错误(一般是 EOF)
func (b *Buffer) ReadString(delim byte) (line string, err error)
// 截断 b.buf , 舍弃 b.off+n 之后的数据。n == 0 时,调用 Reset 方法重置该对象,当 n 越界时(n < 0 || n > b.Len() )方法会触发 panic.
func (b *Buffer) Truncate(n int)

示例:

a := bytes.NewBufferString("Good Night")

x, err := a.ReadBytes('t')
if err != nil {
    fmt.Println("delim:t err:", err)
} else {
    fmt.Println(string(x))
}

a.Truncate(0)
a.WriteString("Good Night")
fmt.Println(a.Len())
a.Truncate(5)
fmt.Println(a.Len())
y, err := a.ReadString('N')
if err != nil {
    fmt.Println("delim:N err:", err)
} else {
    fmt.Println(y)
}

输出结果:

Good Night
10
5
delim:N err: EOF

strconv

这里的基本数据类型包括:布尔、整型(包括有 / 无符号、二进制、八进制、十进制和十六进制)和浮点型等。

strconv 包转换错误处理

介绍具体的转换之前,先看看 strconv 中的错误处理。

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

然而,在返回错误的时候,不是直接将上面的变量值返回,而是通过构造一个 NumError 类型的 error 对象返回。NumError 结构的定义如下:

// A NumError records a failed conversion.
type NumError struct {
    Func string // the failing function (ParseBool, ParseInt, ParseUint, ParseFloat)
    Num  string // the input
    Err  error  // the reason the conversion failed (ErrRange, ErrSyntax)
}

可见,该结构记录了转换过程中发生的错误信息。该结构不仅包含了一个 error 类型的成员,记录具体的错误信息,而且它自己也实现了 error 接口:

func (e *NumError) Error() string {
    return "strconv." + e.Func + ": " + "parsing " + Quote(e.Num) + ": " + e.Err.Error()
}

包的实现中,定义了两个便捷函数,用于构造 NumError 对象:

func syntaxError(fn, str string) *NumError {
    return &NumError{fn, str, ErrSyntax}
}

func rangeError(fn, str string) *NumError {
    return &NumError{fn, str, ErrRange}
}

在遇到 ErrSyntax 或 ErrRange 错误时,通过上面的函数构造 NumError 对象。

字符串和整型之间的转换

字符串转为整型

包括三个函数:ParseInt、ParseUint 和 Atoi,函数原型如下:

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)

其中,Atoi 是 ParseInt 的便捷版,内部通过调用 ParseInt(s, 10, 0)  来实现的;ParseInt 转为有符号整型;ParseUint 转为无符号整型

参数 base 代表字符串按照给定的进制进行解释。一般的,base 的取值为 2~36,如果 base 的值为 0,则会根据字符串的前缀来确定 base 的值:"0x" 表示 16 进制; "0" 表示 8 进制;否则就是 10 进制。

参数 bitSize 表示的是整数取值范围,或者说整数的具体类型。取值 0、8、16、32 和 64 分别代表 int、int8、int16、int32 和 int64。

这里有必要说一下,当 bitSize==0 时的情况。

Go 中,int/uint 类型,不同系统能表示的范围是不一样的,目前的实现是,32 位系统占 4 个字节;64 位系统占 8 个字节。当 bitSize==0 时,应该表示 32 位还是 64 位呢?这里没有利用 runtime.GOARCH 之类的方式,而是巧妙的通过如下表达式确定 intSize:

const intSize = 32 << uint(^uint(0)>>63)
const IntSize = intSize // number of bits in int, uint (32 or 64)

问题:下面的代码 n 和 err 的值分别是什么?

n, err := strconv.ParseInt("128", 10, 8)

在 ParseInt/ParseUint 的实现中,如果字符串表示的整数超过了 bitSize 参数能够表示的范围,则会返回 ErrRange,同时会返回 bitSize 能够表示的最大或最小值。因此,这里的 n 是 127。

另外,ParseInt 返回的是 int64,这是为了能够容纳所有的整型,在实际使用中,可以根据传递的 bitSize,然后将结果转为实际需要的类型。

整型转为字符串

实际应用中,我们经常会遇到需要将字符串和整型连接起来,在 Java 中,可以通过操作符 "+" 做到。不过,在 Go 语言中,你需要将整型转为字符串类型,然后才能进行连接。这个时候,strconv 包中的整型转字符串的相关函数就派上用场了。这些函数签名如下:

func FormatUint(i uint64, base int) string    // 无符号整型转字符串
func FormatInt(i int64, base int) string    // 有符号整型转字符串
func Itoa(i int) string

其中,Itoa 内部直接调用 FormatInt(i, 10)  实现的。base 参数可以取 2~36(0-9,a-z)。

具体实现时,当 base 是 2 的幂次方时,有优化处理(移位和掩码);十进制也做了优化。

标准库还提供了另外两个函数:AppendInt 和 AppendUint,这两个函数不是将整数转为字符串,而是将整数转为字符数组 append 到目标字符数组中。(最终,我们也可以通过返回的 []byte 得到字符串)

除了使用上述方法将整数转为字符串外,经常见到有人使用 fmt 包来做这件事。如:

fmt.Sprintf("%d", 127)

那么,这两种方式我们该怎么选择呢?我们主要来考察一下性能。

startTime := time.Now()
for i := 0; i < 10000; i++ {
    fmt.Sprintf("%d", i)
}   
fmt.Println(time.Now().Sub(startTime))

startTime := time.Now()
for i := 0; i < 10000; i++ {
    strconv.Itoa(i)
}   
fmt.Println(time.Now().Sub(startTime))

我们分别循环转换了 10000 次。Sprintf 的时间是 3.549761ms,而 Itoa 的时间是 848.208us,相差 4 倍多。

Sprintf 性能差些可以预见,因为它接收的是 interface,需要进行反射等操作。个人建议使用 strconv 包中的方法进行转换。

注意:别想着通过 string(65) 这种方式将整数转为字符串,这样实际上得到的会是 ASCCII 值为 65 的字符,即 'A'。

思考:

给定一个 40 以内的正整数,如何快速判断其是否是 2 的幂次方?

提示:在 strconv 包源码 itoa.go 文件中找答案

字符串和布尔值之间的转换

Go 中字符串和布尔值之间的转换比较简单,主要有三个函数:

// 接受 1, t, T, TRUE, true, True, 0, f, F, FALSE, false, False 等字符串;
// 其他形式的字符串会返回错误
func ParseBool(str string) (value bool, err error)
// 直接返回 "true" 或 "false"
func FormatBool(b bool) string
// 将 "true" 或 "false" append 到 dst 中
// 这里用了一个 append 函数对于字符串的特殊形式:append(dst, "true"...)
func AppendBool(dst []byte, b bool)

字符串和浮点数之间的转换

类似的,包含三个函数:

func ParseFloat(s string, bitSize int) (f float64, err error)
func FormatFloat(f float64, fmt byte, prec, bitSize int) string
func AppendFloat(dst []byte, f float64, fmt byte, prec int, bitSize int)

函数的命名和作用跟上面讲解的其他类型一致。

fmt 参数表示形式,prec 表示有效数字

strconv.FormatFloat(1223.13252, 'e', 3, 32)    // 结果:1.223e+03
strconv.FormatFloat(1223.13252, 'g', 3, 32)    // 结果:1.22e+03

由于浮点数有精度的问题,精度不一样,ParseFloat 和 FormatFloat 可能达不到互逆的效果。

另外,fmt='b' 时,得到的字符串是无法通过 ParseFloat 还原的。

同样的,基于性能的考虑,应该使用 FormatFloat 而不是 fmt.Sprintf