小编典典

如何在 Go 中生成固定长度的随机字符串?

go

我想要一个随机字符串(大写或小写),没有数字,在Go中。执行此操作的最快和最简单的方法是什么?


阅读 431

收藏
2021-10-23

共1个答案

小编典典

问题要求“最快和最简单的方法”。让我们也解决最快的部分。我们将以迭代的方式获得最终的、最快的代码。可以在答案的末尾找到对每次迭代的基准测试。

所有解决方案和基准测试代码都可以在Go Playground上找到。Playground 上的代码是一个测试文件,而不是一个可执行文件。你必须将它保存到一个名为的文件中XX_test.go并运行它

go test -bench . -benchmem

前言

如果您只需要一个随机字符串,最快的解决方案不是首选解决方案。为此,保罗的解决方案是完美的。这就是性能是否重要。尽管前两个步骤(BytesRemainder)可能是一个可以接受的折衷方案:它们确实将性能提高了 50%(参见II. Benchmark部分中的确切数字),并且它们不会显着增加复杂性。

话虽如此,即使您不需要最快的解决方案,通读此答案也可能具有冒险精神和教育意义。

一、改进

1. Genesis (Runes)

提醒一下,我们正在改进的原始通用解决方案是:

func init() {
    rand.Seed(time.Now().UnixNano())
}

var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

func RandStringRunes(n int) string {
    b := make([]rune, n)
    for i := range b {
        b[i] = letterRunes[rand.Intn(len(letterRunes))]
    }
    return string(b)
}

2. 字节

如果要从中选择和组合随机字符串的字符仅包含英文字母的大写和小写字母,我们只能使用字节,因为英文字母映射到 UTF-8 编码中的字节 1 到 1(其中是 Go 存储字符串的方式)。

所以而不是:

var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

我们可以用:

var letters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

或者甚至更好:

const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

现在这已经是一个很大的改进:我们可以将它实现为 a const(有string常量但没有切片常量)。作为额外的收获,表达式len(letters)也将是const! (len(s)如果s是字符串常量,则表达式为常量。)

以什么代价?什么都没有。strings 可以被索引,索引它的字节,完美,正是我们想要的。

我们的下一个目的地是这样的:

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func RandStringBytes(n int) string {
    b := make([]byte, n)
    for i := range b {
        b[i] = letterBytes[rand.Intn(len(letterBytes))]
    }
    return string(b)
}

3. 余数

以前的解决方案通过调用rand.Intn()哪些委托给Rand.Intn()哪些委托来获得一个随机数来指定一个随机字母Rand.Int31n()

rand.Int63()生成具有 63 个随机位的随机数相比,这要慢得多。

所以我们可以简单地调用rand.Int63()并使用除以后的余数len(letterBytes)

func RandStringBytesRmndr(n int) string {
    b := make([]byte, n)
    for i := range b {
        b[i] = letterBytes[rand.Int63() % int64(len(letterBytes))]
    }
    return string(b)
}

这有效并且明显更快,缺点是所有字母的概率不会完全相同(假设rand.Int63()产生所有 63 位数字的概率相等)。尽管由于字母数量52远小于1<<63 - 1,因此失真非常小,因此在实践中这完全没问题。

为了使这更容易理解:假设您想要一个范围为 的随机数0..5。使用 3 个随机位,这将产生0..1比范围 2 倍概率的数字2..5。使用5个随机比特,在数字范围0..1将与发生6/32概率和数字范围2..55/32概率现在是更接近期望。增加位数会使这一点变得不那么重要,当达到 63 位时,可以忽略不计。

4. Masking

在之前的解决方案的基础上,我们可以通过仅使用随机数的最低位与表示字母数量所需的数量一样多的数量来保持字母的均匀分布。因此,例如,如果我们有52封,它需要6位来表示它:52 = 110100b。所以我们将只使用由 返回的数字的最低 6 位rand.Int63()。并且为了保持字母的平均分布,我们只“接受”落在范围内的数字0..len(letterBytes)-1。如果最低位更大,我们丢弃它并查询一个新的随机数。

请注意,最低位大于或等于的机会len(letterBytes)通常小于0.50.25平均),这意味着即使是这种情况,重复这种“罕见”的情况也会降低找不到好的机会数字。n重复之后,我们仍然没有一个好的索引的机会远小于pow(0.5, n),这只是一个上估计。在 52 个字母的情况下,只有 6 个最低位不好的机会(64-52)/64 = 0.19;这意味着例如,在 10 次重复后没有好数字的机会是1e-8.

所以这里是解决方案:

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
    letterIdxBits = 6                    // 6 bits to represent a letter index
    letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
)

func RandStringBytesMask(n int) string {
    b := make([]byte, n)
    for i := 0; i < n; {
        if idx := int(rand.Int63() & letterIdxMask); idx < len(letterBytes) {
            b[i] = letterBytes[idx]
            i++
        }
    }
    return string(b)
}

5. Masking改进

前面的解决方案只使用了 返回的 63 个随机位中的最低 6 位rand.Int63()。这是一种浪费,因为获取随机位是我们算法中最慢的部分。

如果我们有 52 个字母,这意味着 6 位编码一个字母索引。所以 63 个随机位可以指定63/6 = 10不同的字母索引。让我们使用所有这 10 个:

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
    letterIdxBits = 6                    // 6 bits to represent a letter index
    letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
    letterIdxMax  = 63 / letterIdxBits   // # of letter indices fitting in 63 bits
)

func RandStringBytesMaskImpr(n int) string {
    b := make([]byte, n)
    // A rand.Int63() generates 63 random bits, enough for letterIdxMax letters!
    for i, cache, remain := n-1, rand.Int63(), letterIdxMax; i >= 0; {
        if remain == 0 {
            cache, remain = rand.Int63(), letterIdxMax
        }
        if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
            b[i] = letterBytes[idx]
            i--
        }
        cache >>= letterIdxBits
        remain--
    }

    return string(b)
}

6. 来源

改进的屏蔽是相当不错的,没有多少可以改善它。我们可以,但不值得复杂。

现在让我们找到其他需要改进的地方。随机数的来源。

有一个crypto/rand包提供了一个Read(b [\]byte)函数,因此我们可以使用它通过一次调用获取尽可能多的字节。这在性能方面无济于事,因为crypto/rand实现了加密安全的伪随机数生成器,因此速度要慢得多。

所以让我们坚持math/rand包装。在rand.Rand使用rand.Source作为随机比特的源。rand.Source是一个接口,它指定了一个Int63() int64方法:正是我们在最新的解决方案中需要和使用的唯一的东西。

所以我们真的不需要一个rand.Rand(显式的或全局的,共享的rand包),arand.Source对我们来说已经足够了:

var src = rand.NewSource(time.Now().UnixNano())

func RandStringBytesMaskImprSrc(n int) string {
    b := make([]byte, n)
    // A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
    for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
        if remain == 0 {
            cache, remain = src.Int63(), letterIdxMax
        }
        if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
            b[i] = letterBytes[idx]
            i--
        }
        cache >>= letterIdxBits
        remain--
    }

    return string(b)
}

还要注意的是这最后的解决方案不要求你进行初始化(种子)在全球Rand的的math/rand包中未使用(和我们的rand.Source正确初始化/种子)。

这里还要注意一件事:math/rand状态包文档:

默认的 Source 可以安全地被多个 goroutines 并发使用。

因此默认源比Source可能获得的a 慢rand.NewSource(),因为默认源必须在并发访问/使用下提供安全性,rand.NewSource()而不提供此(因此它Source返回的更有可能更快)。

7.利用 strings.Builder

之前的所有解决方案都返回 a ,string其内容首先构建在切片中([]runeGenesis[]byte后续解决方案中),然后转换为string. 这个最终的转换必须复制切片的内容,因为string值是不可变的,如果转换不会复制,则不能保证字符串的内容不会通过其原始切片进行修改。有关详细信息,请参阅[如何将 utf8 字符串转换为 ]byte?和[golang: ]byte(string) vs []byte(*string)

Go 1.10 引入strings.Builder strings.Builder是一种新类型,我们可以使用它来构建string类似于bytes.Buffer. 在内部它使用 a[]byte来构建内容,当我们完成时,我们可以string使用它的Builder.String()方法获取最终值。但它很酷的地方在于,它无需执行我们刚才谈到的复制即可完成此操作。它之所以敢这样做,是因为用于构建字符串内容的字节片没有暴露,所以保证没有人可以无意或恶意修改它来改变产生的“不可变”字符串。

所以我们的下一个想法是不在切片中构建随机字符串,而是在 a 的帮助下strings.Builder,所以一旦我们完成,我们就可以获取并返回结果,而无需复制它。这可能在速度方面有所帮助,并且在内存使用和分配方面肯定会有所帮助。

func RandStringBytesMaskImprSrcSB(n int) string {
    sb := strings.Builder{}
    sb.Grow(n)
    // A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
    for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
        if remain == 0 {
            cache, remain = src.Int63(), letterIdxMax
        }
        if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
            sb.WriteByte(letterBytes[idx])
            i--
        }
        cache >>= letterIdxBits
        remain--
    }

    return sb.String()
}

请注意,在创建 new 之后strings.Buidler,我们调用了它的Builder.Grow()方法,确保它分配一个足够大的内部切片(以避免在我们添加随机字母时重新分配)。

8.strings.Builder用包“模仿”unsafe

strings.Builder在内部构建字符串,[]byte就像我们自己做的一样。所以基本上通过 a 来做strings.Builder有一些开销,我们切换到的唯一一件事strings.Builder就是避免切片的最终复制。

strings.Builder通过使用 package 避免最终副本unsafe

// String returns the accumulated string.
func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf))
}

问题是,我们也可以自己做这件事。所以这里的想法是切换回在 a 中构建随机字符串[]byte,但是当我们完成后,不要将其转换string为 return,而是进行不安全的转换:获取string指向我们的字节切片作为字符串数据的 a .

这是如何做到的:

func RandStringBytesMaskImprSrcUnsafe(n int) string {
    b := make([]byte, n)
    // A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
    for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
        if remain == 0 {
            cache, remain = src.Int63(), letterIdxMax
        }
        if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
            b[i] = letterBytes[idx]
            i--
        }
        cache >>= letterIdxBits
        remain--
    }

    return *(*string)(unsafe.Pointer(&b))
}

(9. 使用rand.Read())

Go 1.7 添加了一个rand.Read()函数和一个Rand.Read()方法。我们应该尝试使用这些来在一个步骤中读取我们需要的尽可能多的字节,以实现更好的性能。

这有一个小“问题”:我们需要多少字节?我们可以说:与输出字母的数量一样多。我们会认为这是一个上限估计,因为字母索引使用少于 8 位(1 字节)。但在这一点上,我们已经做得更糟了(因为获取随机位是“困难的部分”),而且我们得到的已经超出了需要。

另请注意,为了保持所有字母索引的均匀分布,可能会有一些我们无法使用的“垃圾”随机数据,因此我们最终会跳过一些数据,因此在我们遍历所有数据时最终会很短字节切片。我们需要进一步“递归地”获得更多随机字节。现在我们甚至失去了“单一呼叫rand打包”的优势......

我们可以“稍微”优化我们从中获取的随机数据的使用math.Rand()。我们可以估计需要多少字节(位)。1 个字母需要letterIdxBits位,而我们需要n字母,因此我们需要对n * letterIdxBits / 8.0字节进行四舍五入。我们可以计算一个随机索引不可用的概率(见上文),所以我们可以请求更多“更有可能”就足够了(如果事实证明不是,我们重复这个过程)。例如,我们可以将字节切片作为“位流”进行处理,为此我们有一个不错的 3rd 方库:(github.com/icza/bitio披露:我是作者)。

但是基准代码仍然显示我们没有获胜。为什么会这样?

最后一个问题的答案是因为rand.Read()使用循环并不断调用,Source.Int63()直到它填满传递的切片。正是RandStringBytesMaskImprSrc()解决方案所做的,没有中间缓冲区,也没有增加的复杂性。这就是为什么RandStringBytesMaskImprSrc()留在宝座上。是的,RandStringBytesMaskImprSrc()使用不同步的rand.Source不同rand.Read()。但推理仍然适用;如果我们使用Rand.Read()而不是证明这一点rand.Read()(前者也是不同步的)。

二、基准

好的,是时候对不同的解决方案进行基准测试了。

关键时刻:

BenchmarkRunes-4                     2000000    723 ns/op   96 B/op   2 allocs/op
BenchmarkBytes-4                     3000000    550 ns/op   32 B/op   2 allocs/op
BenchmarkBytesRmndr-4                3000000    438 ns/op   32 B/op   2 allocs/op
BenchmarkBytesMask-4                 3000000    534 ns/op   32 B/op   2 allocs/op
BenchmarkBytesMaskImpr-4            10000000    176 ns/op   32 B/op   2 allocs/op
BenchmarkBytesMaskImprSrc-4         10000000    139 ns/op   32 B/op   2 allocs/op
BenchmarkBytesMaskImprSrcSB-4       10000000    134 ns/op   16 B/op   1 allocs/op
BenchmarkBytesMaskImprSrcUnsafe-4   10000000    115 ns/op   16 B/op   1 allocs/op

仅仅通过从符文切换到字节,我们立即获得了24% 的性能提升,内存需求下降到三分之一

摆脱rand.Intn()并使用rand.Int63()它会带来另外20% 的提升。

掩码(并在大索引的情况下重复)稍微减慢(由于重复调用):- 22%

但是,当我们使用全部(或大部分)63 个随机位(来自一次rand.Int63()调用的10 个索引)时:这将大大加快时间:3 倍

如果我们用(非默认,新的)rand.Source代替rand.Rand,我们再次获得21%。

如果我们使用strings.Builder,我们获得了一个微小的3.5%速度,但我们也取得了50%的内存使用和分配的减少!那很好!

最后,如果我们敢于使用 packageunsafe而不是strings.Builder,我们再次获得了不错的14%

最后进行比较来对初始解:RandStringBytesMaskImprSrcUnsafe()快6.3倍RandStringRunes(),使用六分之一存储器和半尽可能少的分配。任务完成。

2021-10-23