登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go big.Rat 怎么从小数字符串保留精确值

来源:17golang原创

时间:2026-10-06 19:14:38 117浏览 收藏

Go 里要让小数字符串保留精确值,最直接的做法是把原始文本交给 new(big.Rat).SetString(s)。例如 "0.1" 会得到精确的 1/10,不要先用 strconv.ParseFloat 转成 float64,再调用 SetFloat64,因为那样保留下来的已经是二进制浮点近似值。

官方文档:https://pkg.go.dev/math/big

需要精确保存十进制输入时,用 Rat.SetString;需要显示几位小数时,再在输出边界调用 FloatString(位数)。存储与计算保持分数,舍入只发生在最终展示或结算规则明确的位置。

直接把小数字符串交给 SetString

我第一次在金额汇总代码里换用 big.Rat 时,最容易犯的错并不是 API 不会用,而是习惯性地先写 ParseFloat。这样虽然最后也得到一个 Rat,但它只是“精确保存了那个 float64”,并没有精确保存用户输入的十进制值。

正确的最小写法如下:

package main

import (
    "fmt"
    "math/big"
)

func main() {
    // 直接解析原始十进制文本,避免中途经过 float64
    value, ok := new(big.Rat).SetString("0.1")
    if !ok {
        // 解析失败时立即结束,不继续使用接收者
        panic("无效的小数字符串")
    }

    // RatString 展示约分后的精确分数:1/10
    fmt.Println(value.RatString())
    // FloatString 只负责按指定小数位数展示:0.1000
    fmt.Println(value.FloatString(4))
}

SetString 接受完整的小数、带指数的浮点形式以及 a/b 分数形式。官方文档强调:整个字符串都必须有效,不能只解析前缀。"12.345" 会被化为 2469/200,"1.25e-3" 也可以直接解析;但 "¥12.30"、"1,234.56" 或带空格说明文字的输入,应先在业务层完成清洗和格式规则判断。

Go big.Rat 从小数字符串到分子分母和输出方法的静态结构图
图1:big.Rat 解析结构图。原始字符串由 SetString 解析为 Rat,Rat 内部以 Num 和 Denom 表示精确分数,RatString 与 FloatString 负责不同输出;解析失败是单独结果。连线表示静态关系,不是运行截图。

封装解析错误并隔离失败对象

SetString 返回 (*Rat, bool)。失败时返回的 Rat 指针是 nil,而接收者的值处于未定义状态。对我来说,最稳妥的封装方式是每次创建一个新 Rat,只在 ok 为 true 时返回它:

package decimal

import (
    "fmt"
    "math/big"
    "strings"
)

func Parse(s string) (*big.Rat, error) {
    // 是否允许首尾空格应由业务规则决定,这里显式去除
    normalized := strings.TrimSpace(s)
    if normalized == "" {
        return nil, fmt.Errorf("小数不能为空")
    }

    // 使用全新的接收者,解析失败后不把未定义值暴露出去
    value := new(big.Rat)
    if _, ok := value.SetString(normalized); !ok {
        return nil, fmt.Errorf("无效的小数 %q", s)
    }
    return value, nil
}

这层封装还适合放业务限制,例如最多允许多少位小数、是否允许指数、是否允许负数。big.Rat 能解析的格式不等于业务都应接受。金额输入常常应拒绝指数形式,比例或科学数据则可能需要保留它。

全程用 Rat 完成加减乘除

解析后如果又把值转回 float64 做计算,前面的精确解析就失去意义。big.Rat 提供 Add、Sub、Mul 和 Quo,接收者保存结果。要特别注意:接收者会被覆盖。

package main

import (
    "fmt"
    "math/big"
)

func mustRat(s string) *big.Rat {
    // 示例中集中处理解析失败,生产代码应返回 error
    value, ok := new(big.Rat).SetString(s)
    if !ok {
        panic("无效小数: " + s)
    }
    return value
}

func main() {
    price := mustRat("19.90")
    taxRate := mustRat("0.075")

    // 使用新接收者保存税额,避免覆盖 price 或 taxRate
    tax := new(big.Rat).Mul(price, taxRate)
    // total 同样使用独立对象,原始价格仍可复用
    total := new(big.Rat).Add(price, tax)

    // 结算展示在最终边界舍入到两位小数
    fmt.Println(total.FloatString(2))
}

官方文档允许结果接收者与某个操作数是同一个对象,例如 sum.Add(sum, x)。这对累加很方便,但如果后面还需要原值,就应像示例一样用新的 big.Rat。复制 Rat 也不要做普通结构体浅拷贝,应调用 new(big.Rat).Set(source)。

除法还要先判断除数是否为 0,因为 Quo 在除数为 0 时会 panic。是否允许负数、零和极大的分子分母,也应在进入核心计算前由业务规则约束。

把精确存储与小数展示分开

big.Rat 保存的是数值,不保存输入格式。"1.2300" 与 "1.23" 都会归一化为同一个分数 123/100,末尾两个 0 不会被保留。因此需要区分三个目的:

目的推荐方式特点
保存精确数值RatString() 或分别保存 Num()/Denom()无展示舍入,可恢复原数值
固定小数位展示FloatString(prec)保留指定小数位,最后一位按最接近值舍入,正中间时远离 0
保留原始输入格式额外保存原始字符串与 scale可区分 1.23 与 1.2300

我更愿意把 Rat 当成“计算值”,把币种、小数位数和舍入规则当成独立业务元数据。这样不会因为某个页面显示两位小数,就过早把所有中间结果都舍入成两位。

比较 Rat、float64、big.Float 和定点整数

这里没有万能类型。选择规则取决于你要保留的是十进制字面量、近似数值,还是固定比例的业务单位。

Go 小数字符串使用 big.Rat、float64、big.Float 与定点整数的静态方案对比图
图2:小数方案对比图。Rat.SetString 和有明确比例的定点整数可保持十进制业务值,ParseFloat 与 big.Float 属于二进制浮点路线;SetFloat64 精确保留的是已经产生的 float64 值。连线表示方案关系,不表示执行时间线。
方案适合不适合关键取舍
big.Rat.SetString精确十进制输入、比例、税率、可化为分数的运算要求固定内存和极高吞吐的海量计算值精确,但分子分母可能随运算增长
float64统计、图形、测量值和允许误差的科学计算必须精确相等的十进制业务值快且通用,但很多十进制小数不能精确表示
big.Float需要自定义二进制精度与舍入模式的近似计算想无损保留任意十进制字面量精度可控,仍是二进制浮点模型
定点整数币种最小单位明确、比例固定、范围可控比例经常变化或分母不只含 2 和 5 的计算简单高效,但要自行管理 scale 与溢出

最不推荐的路线是 字符串 → float64 → big.Rat。SetFloat64 的“exactly”是指精确表示传入的 float64,而不是恢复最初的十进制字符串。比如原始输入 0.1 在转成 float64 后已经是邻近的二进制值,再转 Rat 只会得到那个二进制值对应的精确大分数。

接入 JSON 和表单输入时保留原始文本

HTML 表单字段本来就是字符串,可以直接送入解析函数。JSON 更容易在解码阶段丢掉原始数值:若先解码到 float64,再转 Rat,问题仍然存在。可以把字段声明为字符串,或者让解码器使用 json.Number。

JSON 官方文档:https://pkg.go.dev/encoding/json

package main

import (
    "encoding/json"
    "fmt"
    "math/big"
    "strings"
)

func main() {
    // UseNumber 让数字先保留为 json.Number 文本,而不是 float64
    decoder := json.NewDecoder(strings.NewReader(`{"amount": 0.10}`))
    decoder.UseNumber()

    var payload struct {
        Amount json.Number `json:"amount"`
    }
    if err := decoder.Decode(&payload); err != nil {
        panic(err)
    }

    // 直接从 JSON 数字文本构造精确 Rat
    amount, ok := new(big.Rat).SetString(payload.Amount.String())
    if !ok {
        panic("amount 不是有效小数")
    }
    fmt.Println(amount.RatString())
}

如果接口契约允许,我通常更倾向把金额、比例等精确值写成 JSON 字符串,并在服务端明确校验格式和小数位数。这比依赖不同语言对 JSON number 的默认类型更容易保持跨系统一致。

几个容易忽略的问题

SetString 会保留 19.90 的两个小数位吗?

不会。它保留数值 199/10,不保留输入的展示尺度。若业务必须记住两位小数,应单独保存 scale 或原始文本。

FloatString 会改变 Rat 本身吗?

不会。它只按指定小数位生成字符串。舍入发生在输出文本中,Rat 内部的精确分数不变。

什么时候定点整数比 big.Rat 更合适?

当单位和比例固定时,例如所有金额都以“分”为单位,而且数值范围能被 int64 覆盖,定点整数通常更简单、更快。涉及税率、比例链或任意分母时,Rat 更自然。

big.Rat 适合直接判断两个金额相等吗?

可以用 Cmp 比较数值。因为 Rat 会规范化分数,1.0 与 1.00 的数值相等;若展示尺度也是业务含义,则还要比较单独保存的 scale。

最终选择可以压缩成一句话:原始输入是十进制字符串且不能接受近似,就直接用 Rat.SetString;只在展示边界调用 FloatString,不要为了“方便”先经过 float64。如果业务规模、固定小数位或性能约束更重要,再在 big.Float 和定点整数之间做有条件的取舍。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>