Skip to content
Go
strconv / math / math/rand / crypto/rand — 数字、字符串与随机数

strconv / math / math/rand / crypto/rand — 数字、字符串与随机数

这几类是写业务代码时每天要碰的"小工具",单个 API 不值得展开,但边界行为(进制、下划线、NaN、溢出、随机源选型)藏着不少事故点。本篇按"简单带过用法、重点标坑"的原则组织。


1. strconv — 字符串 ↔ 数字

1.1 核心 API 一览

// 解析
n, err := strconv.Atoi("42")                    // 十进制 → int
i, err := strconv.ParseInt("ff", 16, 64)        // base, bitSize
u, err := strconv.ParseUint("42", 10, 32)
f, err := strconv.ParseFloat("3.14", 64)        // bitSize: 32→float32精度
b, err := strconv.ParseBool("true")
r, err := strconv.ParseComplex("1+2i", 128)

// 格式化
s := strconv.Itoa(42)
s := strconv.FormatInt(255, 16)                 // "ff"
s := strconv.FormatUint(42, 2)                  // "101010"
s := strconv.FormatFloat(3.14159, 'f', 2, 64)   // "3.14"
s := strconv.FormatBool(true)

// 追加到 []byte(避免中间字符串分配,热路径首选)
buf = strconv.AppendInt(buf, 42, 10)
buf = strconv.AppendFloat(buf, 3.14, 'f', 2, 64)

// 带引号的字符串字面量
q := strconv.Quote(`a"b`)                       // `"a\"b"`
raw, err := strconv.Unquote(q)

1.2 容易出错的边界

base=0 会按前缀猜进制——这是最经典的坑:

strconv.ParseInt("010", 0, 64)  // → 8(前导 0 = 八进制)
strconv.ParseInt("08", 0, 64)   // → 报错 invalid syntax(八进制里不能出现 8)
strconv.ParseInt("0x1f", 0, 64) // → 31
strconv.ParseInt("0b101", 0, 64)// → 5
strconv.ParseInt("0o17", 0, 64) // → 15

解析用户输入(端口、数量、ID)请固定 base=10,或用 Atoi。

下划线 _ 只在 base=0 时合法,用来分隔可读性:

strconv.ParseInt("1_000", 0, 16)   // → 1000(base=0 无 0x 前缀按十进制;_ 仅 base=0 允许)
strconv.ParseFloat("1_0.5", 64)    // → 10.5(ParseFloat 的 bitSize 下 _ 合法,等价于 base 0 风格)
strconv.ParseInt("1_000", 10, 64)  // → 报错(显式进制下 _ 非法)

bitSize 是校验上限,不是返回类型:ParseInt(s, 10, 8) 超过 -128~127 直接报 out of range,但返回的仍是 int64。要拿到真正的 int8,得自己转——超出范围时溢出回绕:

var x int8 = 127
x++            // 编译期常量 128 超 int8,常量表达式直接编译不过
x, y := int8(127), int8(1)
fmt.Println(x + y) // -128:运行期加法悄悄回绕,不报错

ParseBool 只认 1/t/T/true/True/TRUE 和 0/f/F/false/False/FALSE——"yes"/"no"/"on"/"off" 一律报错,配置解析要预处理。

Atoi 不 trim 空白:strconv.Atoi(" 42") 报错。外部输入先 strings.TrimSpace。

1.3 与 errors 的配合

解析失败的错误类型是 *strconv.NumError(含 Func/Num/Err),且 Err 会是 ErrRange / ErrSyntax。判"格式错还是超范围"用 errors.Is(err, strconv.ErrRange),别拿错误串 strings.Contains("out of range") 硬匹配。


2. math — 浮点与极值

2.1 极值常量(Go 1.17+)

math.MaxInt / math.MinInt
math.MaxInt8 ... math.MaxUint64
math.MaxFloat32 / math.MaxFloat64
math.SmallestNonzeroFloat32/64

以前手写 1<<31 - 1 的,换成这些常量,可读性直接上一个台阶。

2.2 NaN 与 Inf 的判定

nan := math.NaN()
nan == nan              // false —— NaN 不等于任何东西,包括自己!
math.IsNaN(nan)         // true —— 判 NaN 必须用这个
math.Inf(1) == math.Inf(1) // true —— +Inf 是正常值,== 成立

坑:把 NaN 塞进 slices.Sort / map[key] / 任何 == 比较,都会得到无法解释的行为(排序结果诡异、永远查不到的 key)。判 NaN 永远用 math.IsNaN,或 v != v(等价)。math.Max/math.Min 的返回也是 IEEE 语义——任一参数 NaN 则结果 NaN(与 Go 内建 min/max “传染"行为一致,见 builtin.md)。

除零不 panic:整数除零才 panic;浮点 1.0/0.0 → +Inf,0.0/0.0 → NaN。浮点除法不报错是 C/IEEE 传统,Go 照单全收——写统计代码时留意。

2.3 浮点精度:0.1 + 0.2 == 0.3 到底对不对?

实测:

var a, b, c float64 = 0.1, 0.2, 0.3
fmt.Println(a + b == c)            // false(运行时)
fmt.Println(0.1 + 0.2 == 0.3)      // true (常量表达式!)

两者都是事实,不矛盾:常量表达式在编译期用任意精度有理数求值,0.1+0.2 精确等于 0.3;落到 float64 变量后,0.1、0.2、0.3 各自的二进制近似导致相加多出 4e-17。写测试时不要把"常量表达式为 true"当成"浮点安全"的证据。

工程结论:

  • 金额用整数分(int64 单位)或 math/big.Rat,别用 float64。
  • 比较浮点用误差范围:math.Abs(a-b) < 1e-9,而不是 ==。
  • 累加大量小数误差会放大(顺序都有影响),金融级求和用 big.Float 或整数。

2.4 取整与常用函数

math.Round(2.5)   // 3   —— 四舍五入,远离零方向
math.Ceil / math.Floor / math.Trunc(向零截断)
math.RoundToEven  // 银行家舍入,统计友好
math.Abs/Sqrt/Pow/Exp/Log/Sin/Cos

坑:math.Round(-2.5) 是 -3(远离零),不是 -2;Trunc(-2.9) 是 -2(向零截断)。


3. math/rand — 伪随机数(仿真/打乱/抽奖,非安全)

3.1 v1 与 v2 的取舍

Go 1.22 引入 math/rand/v2。新项目/无历史包袱的:直接用 v2。

import rand "math/rand/v2"

rand.IntN(100)      // 0..99
rand.Int64N(...)    rand.Uint64()
rand.Float64()      // [0,1)
rand.N[int32](10)   // 泛型版,任意整数类型;n<=0 会 panic
rand.Shuffle(n, swap)

v1(math/rand)仍然广泛存在,且是 Go 程序的事实默认:

r := rand.New(rand.NewSource(42)) // 可复现序列,测试/仿真常用
n := r.Intn(100)

3.2 v1 的坑:Seed / 全局函数

Go 1.20 起,v1 全局函数自动用随机种子。老代码里的 rand.Seed(time.Now().UnixNano()) 现在不但多余,rand.Seed 还被标记 Deprecated——需要可复现序列时应改用 rand.New(rand.NewSource(seed)) 拿局部实例,而不是调全局 Seed。

全局函数与局部实例并发语义不同:rand.Intn(全局)是并发安全的;rand.New(...) 返回的 *Rand 不是并发安全的,多 goroutine 共用要么加锁,要么每 goroutine 一个。*Rand 里塞进结构体被并发调用导致 data race,是压测场景高频 bug。

3.3 v2 的两个实质改进

  1. 算法换掉:全局源用 ChaCha8,可显式 rand.New(rand.NewPCG(seed1, seed2))(PCG 更快、不适合安全场景);不再兼容 v1 的序列(同 seed 结果不同,跨版本"复现旧 bug"会翻车)。
  2. API 减法:删掉 v1 里一堆历史命名(Int31/Int31n/Int63/Int63n 等),统一成 Int32N/Int64N/N[T]。v1→v2 迁移基本是改名。

3.4 打乱与抽样

// 洗牌切片
rand.Shuffle(len(s), func(i, j int) { s[i], s[j] = s[j], s[i] })
// 从 n 个里无重复抽 k 个:用 Perm 取前 k(k 远小于 n 时更高效:手写 map 去重)
for _, i := range rand.Perm(52)[:5] { ... }

坑:rand.Intn(0) 会 panic(n <= 0)。从数据库/配置读到"数量"字段再传给 Intn/Perm 的,务必先判空。

3.5 并发下用 Rand 的正确姿势

// 每个 goroutine 自带一份(Go 1.22+ 推荐:用 v2 + per-goroutine 源)
func worker(seed uint64) {
    r := rand.New(rand.NewPCG(seed, ^seed))
    ...
}
// 或退而求其次:sync.Mutex 保护共享 *Rand,吞吐会掉

v2 的全局函数(rand.IntN 等)内部已经做了无锁化(每 P 取种子池),高并发打点场景直接用全局函数即可,不必自己造 *Rand。


4. crypto/rand — 安全随机(token/密钥/验证码必须用它)

一句话判准:结果被攻击者拿到会造成危害的,全部用 crypto/rand。 math/rand 的输出"无论怎么播种都可能被预测”(官方原话),拿它生成密码重置 token、会话 ID、兑换码等于没加密。

4.1 读字节

import crand "crypto/rand"

b := make([]byte, 32)
n, err := crand.Read(b) // Go 1.24+:err 恒为 nil,n 恒为 len(b)

Go 1.24 变更:crypto/rand.Read 保证永不返回错误——底层调用 OS 熵源(getrandom/BCryptGenRandom),失败会直接崩溃进程而不是抛 error。老代码里 if err != nil 分支是死代码,留着无妨,但不要据此设计"随机数失败重试"的逻辑。

4.2 随机整数

max := big.NewInt(10000)
n, err := crand.Int(crand.Reader, max) // [0, max),拒绝采样保证均匀
token := n.String()

n.Int64() 取回值即可。注意 crypto/rand 没有 Int63/Intn 这类便捷函数,别顺手写 math/rand.Int63() % 100 再换密码学源——取模本身引入偏置(2^63 不能被 100 整除,0..63 比 64..99 略易出现)。crand.Int(reader, max) 内部是拒绝采样,结果才是均匀的。

4.3 生成 token / 口令的配方

// 推荐:直接用 crypto/rand.Text(Go 1.24+)
// 至少 128 位随机性,Base32(RFC 4648)字符集,适合放进 URL/头
tok := crand.Text() // 如 "ce25g7jzry..."

// 或自己定长:16 字节 → Base64URL
func token(n int) string {
    b := make([]byte, n)
    crand.Read(b)
    return base64.RawURLEncoding.EncodeToString(b)
}

坑:Text() 输出不含 padding,长度不是 8 的倍数属正常;要固定长度就自己 Read + 编码。RawURLEncoding(无 +/=)才是放 query string 的正确选择,StdEncoding的+` 在某些框架里会变成空格(见 base64 章节)。

4.4 验证码 / 一次性码

数字验证码本质是"从大随机空间抽小码",空间太小会被撞库:6 位数字只有 100 万组合。正确做法是 crypto/rand 抽 + 服务端限次/短 TTL(如 5 分钟、错 3 次作废),而不是加长到 8 位就完事。


5. strings/bytes/unicode/utf8 — 中文与长度

这几包是"简单工具",但中文字符串处理是中文业务代码的第一坑:

s := "中文abc"
len(s)                          // 9 —— 字节数(中文 3 字节/字)
utf8.RuneCountInString(s)       // 5 —— 字符数
[]rune(s)[1]                     // '文'
s[:4]                            // 切坏:在"中"的第 3 字节后断开 → 非法 UTF-8
  • 要"按字切"先 r := []rune(s)(复制一份),或用 utf8.DecodeLastRuneInString。
  • 截断展示(如日志限长)用 utf8.DecodeRuneInString 找到最后一个合法边界,或直接 s[:utf8LastBoundary(s, n)];切坏后 IsValid 为 false,再进 JSON 会被替换成 U+FFFD(见 json v2 一节)。
  • strings.Builder/bytes.Buffer 对 UTF-8 无感知——写进去什么字节就拼什么。

6. 实战建议

  • 判准只有一条线:这个随机数会不会落到不信任的人手里?会 → crypto/rand;不会(打乱列表、仿真、限流抖动、退避时间)→ math/rand/v2。
  • 写新代码优先 strconv 的 Append* 系列拼 []byte,省掉中间字符串。
  • 金额不用浮点;浮点比较用 math.Abs(a-b) < eps;测试里"常量表达式恰好 true"不能作为浮点安全的依据。
  • 碰到 math/rand 的 v1 老代码:保留可用,别混用 v1/v2(序列不兼容),并删掉多余的 rand.Seed 调用。
  • 中文业务先立规矩:永远不要对 string 做字节下标截断,需要截断统一走 rune。