pan-common Num 工具类实战:链式调用、表达式追踪、统计分析

财务结算、订单金额计算、统计分析场景下,BigDecimal 是 Java 中唯一可靠的选择。但实际写代码时,BigDecimal 的 API 用起来相当痛苦:每次运算都要显式指定精度和舍入模式,多层嵌套表达式可读性极差,new BigDecimal(0.1) 的精度陷阱也常被踩到。pan-commonNum 类在 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 NumNumCollector 内部仅依赖 java.math.BigDecimaljava.math.BigIntegerjava.math.MathContext,无第三方库依赖,不会与项目现有依赖冲突。

运行环境说明:本文所有代码示例基于 JDK 17 + pan-common 2.0.6 版本验证。Numsqrtroot 方法使用牛顿迭代法,迭代次数通常在 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 NumberStringNum 混合运算,自动转换
表达式追踪 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)

所有比较方法都支持 NumNumberString 三种参数类型,内部统一转换为 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

总结

NumNumCollector 的核心思路是把 BigDecimal 的高精度能力和业务常用公式封装成链式 API,减少重复代码,同时通过表达式追踪让金额计算可审计。Num 解决单次计算的简洁性和可读性,NumCollector 解决批量数据的统计分析,两者配合可以覆盖大部分财务/电商/统计场景的数值计算需求。

适合场景:财务结算、订单金额计算、电商折扣/涨价/降价、增长率/复利计算、中小规模数据统计分析(方差/分位数/分组聚合)、中文大写金额输出、计算过程审计

不适合场景:海量数据计算、复数运算、字符串公式解析求值

项目地址https://gitee.com/apanlh/pan-common


更多推荐