Java 链式数值计算:告别BigDecimal
pan-common Num 工具类实战:链式调用、表达式追踪、统计分析
财务结算、订单金额计算、统计分析场景下,BigDecimal 是 Java 中唯一可靠的选择。但实际写代码时,BigDecimal 的 API 用起来相当痛苦:每次运算都要显式指定精度和舍入模式,多层嵌套表达式可读性极差,new BigDecimal(0.1) 的精度陷阱也常被踩到。pan-common 的 Num 类在 BigDecimal 之上封装了一层链式调用接口,按调用顺序依次计算,内置默认精度和舍入模式,同时支持表达式追踪、业务公式(折扣/增长率/复利)、格式化输出(千分位/中文大写金额)。配合 NumCollector,还能完成求和、平均、方差、分位数、分组聚合等统计分析操作。
环境准备
Maven 坐标
Spring Boot 2.x 项目(javax.servlet):
<dependency>
<groupId>com.gitee.apanlh</groupId>
<artifactId>pan-common</artifactId>
<version>2.0.6</version>
</dependency>
Spring Boot 3.x 项目(jakarta.servlet):
<dependency>
<groupId>com.gitee.apanlh</groupId>
<artifactId>pan-common</artifactId>
<version>3.0.6</version>
</dependency>
pan-common Num 和 NumCollector 内部仅依赖 java.math.BigDecimal、java.math.BigInteger 和 java.math.MathContext,无第三方库依赖,不会与项目现有依赖冲突。
运行环境说明:本文所有代码示例基于 JDK 17 +
pan-common2.0.6 版本验证。Num的sqrt和root方法使用牛顿迭代法,迭代次数通常在 10~30 次内收敛,普通场景下性能开销可忽略。
为什么要封装 Num?BigDecimal 的痛点对比
痛点一:嵌套可读性差
算一笔账——商品单价乘以数量,减掉优惠券,再打 85 折:
// BigDecimal 写法
BigDecimal price = new BigDecimal("199.99");
BigDecimal quantity = new BigDecimal("3");
BigDecimal coupon = new BigDecimal("20");
BigDecimal discount = new BigDecimal("85")
.divide(new BigDecimal("100"), 12, RoundingMode.HALF_EVEN);
BigDecimal total = price.multiply(quantity)
.subtract(coupon)
.multiply(discount)
.setScale(12, RoundingMode.HALF_EVEN);
System.out.println(total);
// 577.964250000000
同样的逻辑用 Num:
Num total = Num.of("199.99")
.mul("3")
.sub("20")
.discount(85);
System.out.println(total);
// 577.964250000000
痛点二:每次除法都要指定精度
// BigDecimal:三个参数缺一不可
BigDecimal r = new BigDecimal("10")
.divide(new BigDecimal("3"), 12, RoundingMode.HALF_EVEN);
// Num:内置 12 位精度 + HALF_EVEN,默认即可
Num r2 = Num.of(10).div(3);
// 3.333333333333
痛点三:double 精度陷阱
// BigDecimal 经典陷阱
new BigDecimal(0.1);
// 0.1000000000000000055511151231257827021181583404541015625
// Num 内部通过 String.valueOf 中转,安全转换
Num.of(0.1).getString();
// "0.1"
Num.toBigDecimal(double) 内部实现:先通过 String.valueOf(double) 转成字符串,再用 new BigDecimal(String) 构造,从根本上避免了 double 二进制表示带来的精度损失。
核心差异对比
| 对比项 | BigDecimal | Num |
|---|---|---|
| 调用方式 | 嵌套:a.add(b.multiply(c)) |
链式:a.add(b).mul(c),从左到右 |
| 精度设置 | 每次除法都要显式指定 | 内置 12 位 + HALF_EVEN,无需重复设置 |
| 类型兼容 | 仅 BigDecimal |
Number、String、Num 混合运算,自动转换 |
| 表达式追踪 | 无 | ofExp 开启,自动生成数学表达式 |
| 业务方法 | 无 | 百分比、折扣、增长率、复利、阶乘、排列组合 |
| 格式化 | 需单独 DecimalFormat |
内置千分位、货币、中文大写金额 |
一个关键规则:计算顺序
Num 的链式调用严格按从左到右的顺序执行(左结合),不处理数学运算符优先级。这是设计与 BigDecimal 保持一致的行为——方法调用本身就是方法调用。
// Num:链式左结合,((10 + 5) * 2) / 3
Num r2 = Num.of(10).add(5).mul(2).div(3);
// 结果:10.000000000000
// 如果需要数学优先级 10 + (5*2)/3,用嵌套
Num r3 = Num.of(10).add(Num.of(5).mul(2).div(3));
// 结果:13.333333333333
规则很简单:链式就是左结合,需要优先级就用嵌套。
最简单的用法:3 分钟上手
基础四则运算
// 创建——支持多种类型
Num a = Num.of(10); // int
Num b = Num.of(3.14); // double
Num c = Num.of("0.1"); // String(推荐)
Num d = Num.of(new BigDecimal("100")); // BigDecimal
// 四则运算链式调用
Num result = Num.of(10)
.add(5) // 15
.mul(2) // 30
.div(3) // 10.000000000000
.sub(1); // 9.000000000000
System.out.println(result.getBigDecimal());
// 9.000000000000
// 支持 String 传参(适合从配置文件/前端接收)
Num r2 = Num.of("100").add("50.5").mul("2.5");
除法指定精度
// 默认 12 位精度,HALF_EVEN(银行家舍入)
Num d1 = Num.of(10).div(3);
// 3.333333333333
// 自定义精度和舍入模式
Num d2 = Num.of(10).div(3, 4, RoundingMode.HALF_UP);
// 3.3333
舍入操作
Num n = Num.of("3.14159265");
n.toRound(2); // 3.14(四舍五入)
n.toCeil(2); // 3.15(向上取整)
n.toFloor(2); // 3.14(向下取整)
n.toTruncated(2); // 3.14(截断)
n.scale(6); // 3.141593(默认 HALF_EVEN)
// 银行家舍入:5 前面是偶数则舍,是奇数则进
Num.of("2.5").toRoundHalfEven(0); // 2
Num.of("3.5").toRoundHalfEven(0); // 4
实际业务场景:一行搞定常见公式
电商折扣
// 商品原价 200,8 折
Num price = Num.of(200).discount(80);
// 160.000000000000
// 字符串传百分比
Num price2 = Num.of("200").discount("80%");
// 160.000000000000
内部公式:原价 × 折扣率 / 100。
涨价/降价
// 原价 100,涨价 15%
Num.of(100).increase(15);
// 115.000000000000
// 原价 100,降价 10%
Num.of(100).decrease(10);
// 90.000000000000
// 支持 % 符号
Num.of("100").increase("15%");
// 115.000000000000
百分比与占比
// 200 的 15%
Num.of(200).percent(15);
// 30.000000000000
// 30 占 120 的百分之几
Num.of(30).proportion(120);
// 25.000000000000
增长率
// 从 100 增长到 120,增长率 = (120-100)/100 × 100 = 20%
Num.of(120).growthRate(100);
// 20.000000000000
// 去年销售额 50 万,今年 65 万,增长 30%
Num.of("650000").growthRate("500000");
// 30.000000000000
金融:单利与复利
// 本金 10000,年利率 5%,3 年
Num principal = Num.of(10000);
// 单利利息 = 本金 × 利率 × 年数
principal.simpleInterest(5, 3);
// 1500.000000000000
// 复利本息和 = 本金 × (1 + 利率)^年数
principal.compoundAmount(5, 3);
// 11576.250000000000
// 复利利息 = 本息和 - 本金
principal.compoundInterest(5, 3);
// 1576.250000000000
百分比与小数互转
// 小数转百分比数值:0.15 → 15
Num.of("0.15").toPercent();
// 15.000000000000
// 百分比数值转小数:15 → 0.15
Num.of(15).toDecimalFromPercent();
// 0.150000000000
表达式追踪:让公式可审计
生产环境中,金额计算经常需要记录公式用于审计或对账。Num 提供两种模式:
// 普通模式(不记录表达式)
Num normal = Num.of(1).add(2).mul(3);
normal.getExpression();
// ""(空字符串)
// 表达式模式(ofExp 开启)
Num expr = Num.ofExp(1).add(2).mul(3);
expr.getExpression();
// "1 + 2 * 3"
expr.getExpressionResult();
// "1 + 2 * 3 = 9.000000000000"
括号自动生成
表达式生成器会根据运算符优先级自动添加括号,确保生成的表达式与实际计算顺序完全一致:
// (1 + 2) * 3 —— 加法先执行,乘的时候左操作数需要括起来
Num.ofExp(1).add(2).mul(3).getExpression();
// "(1 + 2) * 3"
// 1 * (2 + 3) —— 嵌套部分本身就是括号
Num.ofExp(1).mul(Num.ofExp(2).add(3)).getExpression();
// "1 * (2 + 3)"
// (1 + 2) * (3 + 4) —— 左右都需要
Num.ofExp(1).add(2).mul(Num.ofExp(3).add(4)).getExpression();
// "(1 + 2) * (3 + 4)"
自动加括号的规则:
| 操作数位置 | 加括号条件 | 示例 |
|---|---|---|
| 左操作数 | 当前运算符优先级 严格大于 左操作数顶层运算符 | (1 + 2) * 3(乘 > 加,左加括号) |
| 右操作数 | 当前运算符优先级 大于等于 右操作数顶层运算符 | 1 * (2 + 3)(乘 > 加,右加括号) |
运算符优先级:
| 运算符 | 优先级 |
|---|---|
加减 (+, -) |
1 |
乘除、取模 (*, /, mod) |
2 |
取余、次幂 (%, ^) |
3 |
函数 (sqrt, log, exp 等) |
4 |
函数表达式
单目运算自动生成 函数名(参数) 格式:
Num.ofExp(9).sqrt().getExpression();
// "sqrt(9)"
Num.ofExp(-5).abs().getExpression();
// "abs(-5)"
Num.ofExp(10).log().getExpression();
// "log(10)"
手动括号
// paren() 手动添加括号
Num.ofExp(1).add(2).paren().mul(3).getExpression();
// "(1 + 2) * 3"
聚合表达式
NumCollector.ofExp(1, 2, 3, 4, 5).sum().getExpression();
// "sum(1, 2, 3, 4, 5)"
NumCollector.ofExp(1, 2, 3, 4, 5).sum().getExpressionResult();
// "sum(1, 2, 3, 4, 5) = 15.000000000000"
高级数学运算
平方根与 n 次方根
基于牛顿迭代法实现,中间过程使用 BigDecimal 保证精度:
// 平方根 √2(保留 10 位小数)
Num.of(2).sqrt(10);
// 1.4142135624
// 3 次方根 ³√27
Num.of(27).root(3);
// 3.000000000000
// 负数奇次方根(-8 的 3 次方根 = -2)
Num.of(-8).root(3);
// -2.000000000000
实数次幂
// 2 的 3.5 次方(支持小数指数,公式:a^b = exp(b × ln(a)))
Num.of(2).powDecimal(3.5);
// 11.313708498985
// 整数次幂(直接调用 BigDecimal.pow)
Num.of(2).pow(10);
// 1024.000000000000
对数与指数
// 自然对数 ln(10)
Num.of(10).log();
// 2.302585092994
// e 的 1 次方
Num.of(1).exp();
// 2.718281828459
取余 vs 取模
这是两个容易混淆的概念:
// rem:取余,结果符号与被除数相同
Num.of(-5).rem(3); // -2(被除数 -5 为负)
Num.of(-5).remAbs(3); // 2(绝对值,总是正数)
// mod:取模,结果符号与除数相同
Num.of(-5).mod(3); // 1(除数 3 为正)
Num.of(5).mod(-3); // -1(除数 -3 为负)
数学上,取余(rem)和取模(mod)在正数场景下结果相同,但在负数场景下符号规则不同。如果需要总是返回正整数,用 remAbs。
比较与判断
Num n = Num.of(50);
// 六种比较
n.eq(50); // true
n.ne("50"); // false
n.gt(49); // true
n.ge(50); // true
n.lt(51); // true
n.le(50); // true
// 区间判断
n.between(0, 100); // true
// 正负判断
n.isZero(); // false
n.isPositive(); // true
n.isNegative(); // false
n.signum(); // 1(正数→1,零→0,负数→-1)
所有比较方法都支持 Num、Number、String 三种参数类型,内部统一转换为 BigDecimal 后通过 compareTo 比较。
格式化输出
千分位
Num.of(1234567.89).formatThousands();
// "1,234,567.89"
// 自定义小数位
Num.of(1234567.89).formatThousands(0);
// "1,234,568"
货币
Num.of(1234.56).formatCurrency();
// "¥1,234.56"
// 自定义符号
Num.of(1234.56).formatCurrency("$");
// "$1,234.56"
中文大写金额
Num.of(1234.56).formatChinese();
// "壹仟贰佰叁拾肆元伍角陆分"
Num.of(10000).formatChinese();
// "壹万元整"
财务报销、合同金额场景中非常实用。
自定义格式
Num.of(1234.567).format("0.00");
// "1234.57"
Num.of(0.25).formatPercent();
// "25.000000000000%"
完整业务场景:订单结算
结合上述方法,来看一个完整的订单结算示例:
// 订单数据
BigDecimal unitPrice = new BigDecimal("199.99"); // 单价
int quantity = 3; // 数量
BigDecimal taxRate = new BigDecimal("0.13"); // 税率 13%
String couponCode = "DISCOUNT_20"; // 优惠券:减 20 元
// 计算步骤
Num subtotal = Num.of(unitPrice).mul(quantity); // 小计:599.97
Num afterCoupon = subtotal.sub(20); // 券后价:579.97
Num tax = afterCoupon.percent(13); // 税额:75.3961
Num total = afterCoupon.add(tax).toRound(2); // 含税总价:655.37
// 输出
System.out.println("小计: " + subtotal.toRound(2));
// 小计: 599.97
System.out.println("券后价: " + afterCoupon.toRound(2));
// 券后价: 579.97
System.out.println("税额: " + tax.toRound(2));
// 税额: 75.40
System.out.println("总计: " + total);
// 总计: 655.37
// 如果需要记录计算过程(开启表达式模式)
Num exprTotal = Num.ofExp(unitPrice)
.mul(quantity)
.sub(20)
.percent(13)
.add(Num.ofExp(unitPrice).mul(quantity).sub(20))
.toRound(2);
System.out.println(exprTotal.getExpressionResult());
NumCollector:统计分析聚合
NumCollector 收集多个 Num 对象,执行求和、平均、中位数、方差、分位数等聚合操作,支持链式添加和分组聚合。
基础聚合
NumCollector collector = NumCollector.of(10, 20, 30, 40, 50);
Num sum = collector.sum(); // 150
Num avg = collector.avg(); // 30
Num max = collector.max(); // 50
Num min = collector.min(); // 10
Num median = collector.median(); // 30
链式添加
append 支持多种类型,可以逐步构建数据:
NumCollector c = new NumCollector();
c.append(100)
.append("200")
.append(new int[]{300, 400})
.append(Arrays.asList(500, 600))
.append(Num.of(700));
c.sum(); // 2800
c.count(); // 7
从对象列表提取字段
实际开发中最常见的场景——从订单列表中提取金额进行聚合:
public class Order {
private BigDecimal price; // 单价
private BigDecimal quantity; // 数量
private String region; // 区域
private String productType; // 产品类型
private BigDecimal tax; // 税额
// getter/setter 省略
}
List<Order> orders = ...; // 假设有一批订单
// 提取价格字段求和
Num totalAmount = NumCollector.of(orders, Order::getPrice).sum();
// 提取后计算平均值
Num avgPrice = NumCollector.of(orders, Order::getPrice).avg();
// 复杂计算后再聚合:每个订单的 price + tax 求和
Num totalWithTax = NumCollector.of(orders, order ->
Num.of(order.getPrice()).add(order.getTax()).getString()
).sum();
// 加权平均:按数量加权计算平均单价
List<Num> weights = orders.stream()
.map(o -> Num.of(o.getQuantity()))
.collect(Collectors.toList());
Num weightedAvgPrice = NumCollector.of(orders, Order::getPrice)
.weightedAvg(weights);
分组聚合
按产品类型分组,分别计算每组的总价:
// 方式一:分组收集器,自定义后续操作
Map<String, NumCollector> productGroup = NumCollector.groupBy(orders,
Order::getProductType,
Order::getPrice);
Num type1Sum = productGroup.get("1").sum(); // 类型 1 的总价
Num type2Avg = productGroup.get("2").avg(); // 类型 2 的平均价
// 方式二:一步到位分组求和
Map<String, Num> sumByType = NumCollector.groupSum(orders,
Order::getProductType,
Order::getPrice);
累积和
NumCollector collector = NumCollector.of(1, 2, 3, 4, 5);
List<Num> cumulative = collector.cumulativeSum();
// [1, 3, 6, 10, 15]
适用于每日累计收益、累计用户增长等场景。
加权几何平均
常用于计算投资组合的几何平均收益率:
NumCollector c = NumCollector.of(1.05, 1.10, 1.08); // 三年收益率
List<Num> weights = List.of(Num.of(1), Num.of(1), Num.of(1));
Num wgm = c.weightedGeometricMean(weights);
// 几何平均收益率
统计分析:方差、标准差、偏度、峰度
离散程度
NumCollector c = NumCollector.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// 总体方差:Σ(x-μ)²/N
Num popVar = c.populationVariance();
// 样本方差:Σ(x-μ)²/(N-1)(样本推断总体时用)
Num sampleVar = c.sampleVariance();
// 总体标准差
Num popStd = c.populationStdDev();
// 样本标准差
Num sampleStd = c.sampleStdDev();
// 极差(最大值 - 最小值)
Num range = c.range();
// 四分位距 IQR = Q3 - Q1(不受极端值影响)
Num iqr = c.interQuartileRange();
// 变异系数 CV = σ/μ(消除量纲,适合比较不同单位的数据)
Num cv = c.coefficientOfVariation();
何时用总体 vs 样本:如果你手里就是全部数据(如一个月的所有订单),用总体方差/标准差;如果你手里是一部分数据,想推断整体,用样本方差/标准差(分母 N-1,贝塞尔校正)。
分位数
NumCollector c = NumCollector.of(10, 20, 30, 40, 50, 60, 70, 80, 90, 100);
// 中位数(等价于 0.5 分位数)
c.median();
// 55.000000000000
// 第 25 百分位数(Q1)
c.quantile(0.25);
// 32.500000000000
// 第 75 百分位数(Q3)
c.quantile(0.75);
// 77.500000000000
// 第 95 百分位数(常用于性能指标的 P95 延迟)
c.quantile(0.95);
// 95.500000000000
分位数计算使用线性插值法:索引 = q × (n-1),如果索引不是整数,则在相邻两个值之间线性插值。
分布形态
// 右偏数据(有一个极端大值 100)
NumCollector c = NumCollector.of(1, 2, 2, 3, 3, 3, 4, 4, 5, 100);
// 偏度(衡量不对称程度)
// 正值表示右偏(长尾在右),负值表示左偏
Num skew = c.skewness();
// 峰度(衡量尾部厚度)
// 正态分布峰度为 3,>3 尾部更厚(尖峰厚尾),<3 尾部更薄
Num kurt = c.kurtosis();
// 众数(出现频率最高的值)
List<Num> modes = c.mode();
// [3]
排序与筛选
NumCollector c = NumCollector.of(5, 3, 8, 1, 9, 2, 7);
// 升序排序
c.sorted();
// [1, 2, 3, 5, 7, 8, 9]
// 降序排序
c.sortedDesc();
// [9, 8, 7, 5, 3, 2, 1]
// Top N(前 3 个最大值)
c.top(3);
// [9, 8, 7]
// Bottom N(前 3 个最小值)
c.bottom(3);
// [1, 2, 3]
// 条件过滤(只保留大于 5 的值)
c.filter(num -> num.gt(5));
// [8, 9, 7]
分组聚合完整示例
// 按奇偶分组
NumCollector collector = NumCollector.of(1, 2, 3, 4, 5, 6);
Map<String, NumCollector> groups = collector.groupBy(num -> {
return num.rem(2).getInt() == 0 ? "even" : "odd";
});
Num evenSum = groups.get("even").sum();
// even: 2 + 4 + 6 = 12
Num oddSum = groups.get("odd").sum();
// odd: 1 + 3 + 5 = 9
// 按区域分组,分别计算每区域的订单总价
List<Order> orders = ...;
Map<String, NumCollector> regionGroup = NumCollector.groupBy(orders,
Order::getRegion,
Order::getPrice);
Num shanghaiTotal = regionGroup.get("上海").sum();
Num beijingTotal = regionGroup.get("北京").sum();
静态方法:阶乘、排列、组合
// 阶乘:10! = 3628800
Num.factorial(10);
// 排列数 P(10, 3) = 10! / (10-3)! = 720
Num.permutation(10, 3);
// 组合数 C(10, 3) = 10! / (3! × 7!) = 120
Num.combination(10, 3);
基于 BigInteger 计算,不会溢出。组合数内部利用对称性 C(n, k) = C(n, n-k) 取较小的 k 进行优化。
关键设计细节
精度与舍入
- 默认计算精度:12 位(
DEFAULT_CALC_SCALE = 12) - 默认舍入模式:
HALF_EVEN(银行家舍入,四舍六入五取偶),金融行业推荐 - 默认展示精度:2 位(
DEFAULT_DISPLAY_SCALE = 2),用于toDisplayString()
不可变性
每次运算返回新的 Num 对象,不修改原对象。这意味着:
Num a = Num.of(10);
Num b = a.add(5); // b 是 15,a 仍然是 10
符合不可变设计原则,线程安全,可以在并发场景下安全复用。
空值处理
// 空字符串或 null 默认当作 0
Num.of(null); // 0
Num.of(""); // 0
Num.of(" "); // 0
// 百分比字符串自动去除 % 符号
Num.of("15%"); // 15
Num.ofExp("50%"); // 50(开启表达式模式)
表达式模式切换
Num n = Num.of(10);
// 运行时切换表达式开关(建议在计算链最开始设置)
Num withExpr = n.withExpression(true);
不适合的场景
- 需要字符串公式解析:
Num不支持Num.eval("1 + 2 * 3")这样的字符串公式解析。需要数学优先级时用嵌套调用,或者使用专门的表达式引擎 - 海量数据计算:
NumCollector的方差/标准差/偏度等方法是遍历计算,适合中小规模数据(几百到几千条)。大数据量场景建议用数据库聚合或 Apache Commons Math - 复数运算:仅支持实数运算
- 需要 BigDecimal 原生控制:如果你需要每次运算使用不同的
MathContext,建议直接用BigDecimal原生 API
总结
Num 和 NumCollector 的核心思路是把 BigDecimal 的高精度能力和业务常用公式封装成链式 API,减少重复代码,同时通过表达式追踪让金额计算可审计。Num 解决单次计算的简洁性和可读性,NumCollector 解决批量数据的统计分析,两者配合可以覆盖大部分财务/电商/统计场景的数值计算需求。
适合场景:财务结算、订单金额计算、电商折扣/涨价/降价、增长率/复利计算、中小规模数据统计分析(方差/分位数/分组聚合)、中文大写金额输出、计算过程审计
不适合场景:海量数据计算、复数运算、字符串公式解析求值
项目地址:https://gitee.com/apanlh/pan-common
更多推荐
所有评论(0)