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 的两个实质改进
- 算法换掉:全局源用 ChaCha8,可显式
rand.New(rand.NewPCG(seed1, seed2))(PCG 更快、不适合安全场景);不再兼容 v1 的序列(同 seed 结果不同,跨版本"复现旧 bug"会翻车)。 - 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。