原文链接:https://blog.thinkeridea.com/201910/go/efficient_string_truncation.html

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

字节切片截取

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

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

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

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

杀手锏 - 类型转换 []rune

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

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 微秒,这极大的超出了我的心里预期,我发现因为类型转换带来了内存分配,这产生了一个新的字符串,并且类型转换需要大量的计算。

救命稻草 - 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协议 许可协议。转载请注明出处!

【Go】高效截取字符串的一些思考的更多相关文章

  1. javascript 高效按字节截取字符串

    做为一个前端开发人员在网页展示中经常会碰到,标题过长,需要截取字符串,用CSS的实现的话各种兼容问题,各种坑. 让后台程序截一下,又各种推托,让后台按字节截一下更是和要了后台老命一样,最后可能只会安字 ...

  2. oracle截取字符串,定索引

    转载:https://www.cnblogs.com/qmfsun/p/4493918.html 使用Oracle中Instr()和substr()函数: 1 2 3 4 5 6 7 8 9 10 1 ...

  3. ThinkPHP 模板substr的截取字符串函数

    ThinkPHP 模板substr的截取字符串函数在Common/function.php加上以下代码 /** ** 截取中文字符串 **/ function msubstr($str, $start ...

  4. shell编程常用的截取字符串操作

    1.          常用的字符串操作 1.1.           替换字符串:$ echo ${var/ /_}#支持正怎表达式 / /表示搜索到第一个替换,// /表示搜索到的结果全部替换. ...

  5. [No0000A4]DOS命令(cmd)批处理:替换字符串、截取字符串、扩充字符串、获取字符串长度

    1.替换字符串,即将某一字符串中的特定字符或字符串替换为给定的字符串.举例说明其功能:========================================= @echo off set a ...

  6. js中substr,substring,slice。截取字符串的区别

    substr(n1,n2) n1:起始位置(可以为负数) n2:截取长度(不可以为0,不可以为负数,可以为空) 当n1为正数时,从字符串的n1下标处截取字符串(起始位置),长度为n2. 当n1为负数时 ...

  7. Linux Shell 截取字符串

    Linux Shell 截取字符串 shell中截取字符串的方法很多 ${var#*/} ${var##*/} ${var%/*} ${var%%/*} ${var:start:len} ${var: ...

  8. SQL Server中截取字符串常用函数

    SQL Server 中截取字符串常用的函数: .LEFT ( character_expression , integer_expression ) 函数说明:LEFT ( '源字符串' , '要截 ...

  9. inux中shell截取字符串方法总结

    shell中截取字符串的方法有很多中, ${expression}一共有9种使用方法. ${parameter:-word} ${parameter:=word} ${parameter:?word} ...

随机推荐

  1. Fliptile POJ-3279 DFS

    题目链接:Fliptile 题目大意 有一个01矩阵,每一次翻转(0->1或者1->0)一个元素,就会把与他相邻的四个元素也一起翻转.求翻转哪些元素能用最少的步骤,把矩阵变成0矩阵. 思路 ...

  2. 敏捷测试--之scrum--原理

    Scrum 是一个用于开发和维持复杂产品的框架 ,是一个增量的.迭代的开发过程.在这个框架中,整个开发过程由若干个短的迭代周期组成,一个短的迭代周期称为一个Sprint,每个Sprint的建议长度是2 ...

  3. SpringBootSecurity学习(05)网页版登录内存中配置默认用户

    默认用户 前面的例子中我们使用的都是配置文件中配置好的默认用户: 除了可以配置账号密码,还可以在配置文件中配置角色: 这个角色是后面实现权限过滤的重要内容,后面会重点讨论. 在内存中配置默认用户 这样 ...

  4. 配置文件my.cnf---配置信息注释大全

    在进行MySQL与CM+CHD之间的应用配置时,发现此前对于MySQL的配置含义过于模糊,所以将CM+CHD集群所涉及MySQL方面的配置含义进行抽取并加以注释,方便此后的配置和使用. 一.客户端设置 ...

  5. java-toString()、(String)、String.valueOf 的区别

    Object x = new Integer(1218); System.out.println((String)x); 如上代码编译通过,运行时报错: java.lang.ClassCastExce ...

  6. mysql 5.7 Expression #1 of ORDER BY clause is not in GROUP BY clause and contains nonaggregated column ...报错

    使用mysql在执行一条插入语句时 , ', "hhh"); 报错:Expression #1 of ORDER BY clause is not in GROUP BY clau ...

  7. .Net Core 商城微服务项目系列(五):使用Polly处理服务错误

    项目进行微服务化之后,随之而来的问题就是服务调用过程中发生错误.超时等问题的时候我们该怎么处理,比如因为网络的瞬时问题导致服务超时,这在我本人所在公司的项目里是很常见的问题,当发生请求超时问题的时候, ...

  8. .Net Core 商城微服务项目系列(三):Ocelot网关接入Grafana监控

    使用网关之后我们面临的一个问题就是监控,我们需要知道网关的实时状态,比如当前的请求吞吐量.请求耗费的时间.请求峰值甚至需要知道具体哪个服务的哪个方法花费了多少时间.网关作为请求的中转点是监控品牌的要塞 ...

  9. 魔鬼在细节,理解Java并发底层之AQS实现

    jdk的JUC包(java.util.concurrent)提供大量Java并发工具提供使用,基本由Doug Lea编写,很多地方值得学习和借鉴,是进阶升级必经之路 本文从JUC包中常用的对象锁.并发 ...

  10. 快学Scala 第四课 (多维数组,与Java集合的互操作)

    Scala二维数组的定义: val arr2 = Array.ofDim[String](2, 2) arr2(0)(0) = "aa" arr2(1)(0) = "bb ...