【Go】高效截取字符串的一些思考

原文连接:https://blog.thinkeridea.com/...html

最近我在 Go Forum 中发现了 String size of 20 character 的问题,“hollowaykeanho” 给出了相关的答案,而我从中发现了截取字符串的方案并不是最理想的方法,所以作了一系列实验并得到高效截取字符串的方法,这篇文章将逐步讲解我实践的过程。git

字节切片截取

这正是 “hollowaykeanho” 给出的第一个方案,我想也是不少人想到的第一个方案,利用 go 的内置切片语法截取字符串:github

s := "abcdef"
fmt.Println(s[1:4])

咱们很快就了解到这是按字节截取,在处理 ASCII 单字节字符串截取,没有什么比这更完美的方案了,中文每每占多个字节,在 utf8 编码中是3个字节,以下程序咱们将得到乱码数据:golang

s := "Go 语言"
fmt.Println(s[1:4])

杀手锏 - 类型转换 []rune

hollowaykeanho” 给出的第二个方案就是将字符串转换为 []rune,而后按切片语法截取,再把结果转成字符串。shell

s := "Go 语言"
rs := []rune(s)
fmt.Println(strings(rs[1:4]))

首先咱们获得了正确的结果,这是最大的进步。不过我对类型转换一直比较谨慎,我担忧它的性能问题,所以我尝试在搜索引擎和各大论坛查找答案,可是我获得最多的仍是这个方案,彷佛这已是惟一的解。编程

我尝试写个性能测试评测它的性能:并发

package benchmark

import (
    "testing"
)

var benchmarkSubString = "Go语言是Google开发的一种静态强类型、编译型、并发型,并具备垃圾回收功能的编程语言。为了方便搜索和识别,有时会将其称为Golang。"
var benchmarkSubStringLength = 20

func SubStrRunes(s string, length int) string {
    if utf8.RuneCountInString(s) > length {
        rs := []rune(s)
        return string(rs[:length])
    }

    return s
}

func BenchmarkSubStrRunes(b *testing.B) {
    for i := 0; i < b.N; i++ {
        SubStrRunes(benchmarkSubString, benchmarkSubStringLength)
    }
}

我获得了让我有些吃惊的结果:编程语言

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/go-extend/exunicode/exutf8/benchmark
BenchmarkSubStrRunes-8            872253              1363 ns/op             336 B/op          2 allocs/op
PASS
ok      github.com/thinkeridea/go-extend/exunicode/exutf8/benchmark     2.120s

对 69 个的字符串截取前 20 个字符须要大概 1.3 微秒,这极大的超出了个人内心预期,我发现由于类型转换带来了内存分配,这产生了一个新的字符串,而且类型转换须要大量的计算。ide

救命稻草 - utf8.DecodeRuneInString

我想改善类型转换带来的额外运算和内存分配,我仔细的梳理了一遍 strings 包,发现并无相关的工具,这时我想到了 utf8 包,它提供了多字节计算相关的工具,实话说我对它并不熟悉,或者说没有主动(直接)使用过它,我查看了它全部的文档发现 utf8.DecodeRuneInString 函数能够转换单个字符,并给出字符占用字节的数量,我尝试了如此下的实验:函数

package benchmark

import (
    "testing"
    "unicode/utf8"
)

var benchmarkSubString = "Go语言是Google开发的一种静态强类型、编译型、并发型,并具备垃圾回收功能的编程语言。为了方便搜索和识别,有时会将其称为Golang。"
var benchmarkSubStringLength = 20

func SubStrDecodeRuneInString(s string, length int) string {
    var size, n int
    for i := 0; i < length && n < len(s); i++ {
        _, size = utf8.DecodeRuneInString(s[n:])
        n += size
    }

    return s[:n]
}

func BenchmarkSubStrDecodeRuneInString(b *testing.B) {
    for i := 0; i < b.N; i++ {
        SubStrDecodeRuneInString(benchmarkSubString, benchmarkSubStringLength)
    }
}

运行它以后我获得了令我惊喜的结果:

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/go-extend/exunicode/exutf8/benchmark
BenchmarkSubStrDecodeRuneInString-8     10774401               105 ns/op               0 B/op          0 allocs/op
PASS
ok      github.com/thinkeridea/go-extend/exunicode/exutf8/benchmark     1.250s

[]rune 类型转换效率提高了 13倍,消除了内存分配,它的确使人激动和兴奋,我火烧眉毛的回复了 “hollowaykeanho” 告诉他我发现了一个更好的方法,并提供了相关的性能测试。

我有些小激动,兴奋的浏览着论坛里各类有趣的问题,在查看一个问题的帮助时 (忘记是哪一个问题了-_-||) ,我惊奇的发现了另外一个思路。

良药不必定苦 - range 字符串迭代

许多人彷佛遗忘了 range 是按字符迭代的,并不是字节。使用 range 迭代字符串时返回字符起始索引和对应的字符,我马上尝试利用这个特性编写了以下用例:

package benchmark

import (
    "testing"
)

var benchmarkSubString = "Go语言是Google开发的一种静态强类型、编译型、并发型,并具备垃圾回收功能的编程语言。为了方便搜索和识别,有时会将其称为Golang。"
var benchmarkSubStringLength = 20

func SubStrRange(s string, length int) string {
    var n, i int
    for i = range s {
        if n == length {
            break
        }

        n++
    }

    return s[:i]
}

func BenchmarkSubStrRange(b *testing.B) {
    for i := 0; i < b.N; i++ {
        SubStrRange(benchmarkSubString, benchmarkSubStringLength)
    }
}

我尝试运行它,这彷佛有着无穷的魔力,结果并无令我失望。

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/go-extend/exunicode/exutf8/benchmark
BenchmarkSubStrRange-8          12354991                91.3 ns/op             0 B/op          0 allocs/op
PASS
ok      github.com/thinkeridea/go-extend/exunicode/exutf8/benchmark     1.233s

它仅仅提高了13%,但它足够的简单和易于理解,这彷佛就是我苦苦寻找的那味良药。

若是你觉得这就结束了,不、这对我来只是探索的开始。

终极时刻 - 本身造轮子

喝了 range 那碗甜的腻人的良药,我彷佛冷静下来了,我须要造一个轮子,它须要更易用,更高效。

因而乎我仔细观察了两个优化方案,它们彷佛都是为了查找截取指定长度字符的索引位置,若是我能够提供一个这样的方法,是否就能够提供用户一个简单的截取实现 s[:strIndex(20)] ,这个想法萌芽以后我就没法再度摆脱,我苦苦思索两天来如何来提供易于使用的接口。

以后我创造了 exutf8.RuneIndexInStringexutf8.RuneIndex 方法,分别用来计算字符串和字节切片中指定字符数量结束的索引位置。

我用 exutf8.RuneIndexInString 实现了一个字符串截取测试:

package benchmark

import (
    "testing"
    "unicode/utf8"

    "github.com/thinkeridea/go-extend/exunicode/exutf8"
)

var benchmarkSubString = "Go语言是Google开发的一种静态强类型、编译型、并发型,并具备垃圾回收功能的编程语言。为了方便搜索和识别,有时会将其称为Golang。"
var benchmarkSubStringLength = 20

func SubStrRuneIndexInString(s string, length int) string {
    n, _ := exutf8.RuneIndexInString(s, length)
    return s[:n]
}

func BenchmarkSubStrRuneIndexInString(b *testing.B) {
    for i := 0; i < b.N; i++ {
        SubStrRuneIndexInString(benchmarkSubString, benchmarkSubStringLength)
    }
}

尝试运行它,我对结果感到十分欣慰:

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/go-extend/exunicode/exutf8/benchmark
BenchmarkSubStrRuneIndexInString-8      13546849                82.4 ns/op             0 B/op          0 allocs/op
PASS
ok      github.com/thinkeridea/go-extend/exunicode/exutf8/benchmark     1.213s

性能较 range 提高了 10%,让我很欣慰能够再次得到新的提高,这证实它是有效的。

它足够的高效,可是却不够易用,我截取字符串须要两行代码,若是我想截取 10~20之间的字符就须要4行代码,这并非用户易于使用的接口,我参考了其它语言的 sub_string 方法,我想我应该也设计一个这个样的接口给用户。

exutf8.RuneSubStringexutf8.RuneSub 是我认真思索后编写的方法:

func RuneSubString(s string, start, length int) string

它有三个参数:

  • s : 输入的字符串
  • start : 开始截取的位置,若是 start 是非负数,返回的字符串将从 string 的 start 位置开始,从 0 开始计算。例如,在字符串 “abcdef” 中,在位置 0 的字符是 “a”,位置 2 的字符串是 “c” 等等。 若是 start 是负数,返回的字符串将从 string 结尾处向前数第 start 个字符开始。 若是 string 的长度小于 start,将返回空字符串。
  • length:截取的长度,若是提供了正数的 length,返回的字符串将从 start 处开始最多包括 length 个字符(取决于 string 的长度)。 若是提供了负数的 length,那么 string 末尾处的 length 个字符将会被省略(若 start 是负数则从字符串尾部算起)。若是 start 不在这段文本中,那么将返回空字符串。 若是提供了值为 0 的 length,返回的子字符串将从 start 位置开始直到字符串结尾。

我为他们提供了别名,根据使用习惯你们更倾向去 strings 包寻找这类问题的解决方法,我建立了exstrings.SubStringexbytes.Sub 做为更易检索到的别名方法。

最后我须要再作一个性能测试,确保它的性能:

package benchmark

import (
    "testing"

    "github.com/thinkeridea/go-extend/exunicode/exutf8"
)

var benchmarkSubString = "Go语言是Google开发的一种静态强类型、编译型、并发型,并具备垃圾回收功能的编程语言。为了方便搜索和识别,有时会将其称为Golang。"
var benchmarkSubStringLength = 20

func SubStrRuneSubString(s string, length int) string {
    return exutf8.RuneSubString(s, 0, length)
}

func BenchmarkSubStrRuneSubString(b *testing.B) {
    for i := 0; i < b.N; i++ {
        SubStrRuneSubString(benchmarkSubString, benchmarkSubStringLength)
    }
}

运行它,不会让我失望:

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/go-extend/exunicode/exutf8/benchmark
BenchmarkSubStrRuneSubString-8          13309082                83.9 ns/op             0 B/op          0 allocs/op
PASS
ok      github.com/thinkeridea/go-extend/exunicode/exutf8/benchmark     1.215s

虽然相较 exutf8.RuneIndexInString 有所降低,但它提供了易于交互和使用的接口,我认为这应该是最实用的方案,若是你追求极致仍然可使用 exutf8.RuneIndexInString,它依然是最快的方案。

总结

当看到有疑问的代码,即便它十分的简单,依然值得深究,并不停的探索它,这并不枯燥和乏味,反而会有极多收获。

从起初 []rune 类型转换到最后本身造轮子,不只获得了16倍的性能提高,我还学习了utf8包、加深了range 遍历字符串的特性 以及为 go-extend 仓库收录了多个实用高效的解决方案,让更多 go-extend 的用户获得成果。

go-extend 是一个收录实用、高效方法的仓库,读者们若是好的函数和通用高效的解决方案,期待大家不吝啬给我发送 Pull request,你也可使用这个仓库加快功能实现及提高性能。

转载:

本文做者: 戚银(thinkeridea

本文连接: https://blog.thinkeridea.com/201910/go/efficient_string_truncation.html

版权声明: 本博客全部文章除特别声明外,均采用 CC BY 4.0 CN协议 许可协议。转载请注明出处!

相关文章
相关标签/搜索