☰
Java整数类与自然数类的简单实现:值对象、不可变与溢出检测
2026/11/3 8:49:34 网站建设 项目流程

1. 为什么要在 Java 里自己造一个整数类

第一次听到"Java 整数类和自然数类的简单实现"这个题目,很多人的第一反应是:Java 里不已经有int、long、Integer、BigInteger了吗,再造一个轮子图什么?我当初也是这个态度,直到在一个结算模块里被一个"负数金额"折磨了两个下午——int amount从数据库里读出来是-1,一路穿过了校验、签名、落库,最后在前端展示成-0.01 元。问题不在于哪个环节写错了,而在于整个链路上没有任何一个类型站出来说"这个值不该为负"。int只能表达"这是个 32 位整数",它表达不了"这是个金额"、"这是个计数"、"这是个不能小于零的量"。这就是自己封装整数类与自然数类的起点:不是为了替代int,而是为了让类型本身携带业务约束。

标题里说的"简单实现",我理解成两层意思。第一层是范围简单,整数类就是对一个整数值的包装,自然数类再加上"非负"这一条约束;第二层是实现简单,不追求把BigInteger那套大数运算重写一遍,也不追求运算符重载那种语言特性,用最朴素的 Java 语法,把不可变性、相等性、比较、基本运算、边界校验这几件事做扎实就够了。这套东西适合谁?适合手里有过一些 Java 代码、能写class和interface、但还没认真想过"值对象该怎么设计"的同学;也适合那些在业务代码里被Integer的null、被自动拆箱的空指针、被溢出后静默变负的int咬过一口的老手,拿来重新梳理一下思路。

我把这件事的价值总结成一句话:让编译器和运行时替你挡住一部分本该在代码评审里人肉发现的问题。一个方法签名写成void transfer(NaturalNumber amount),调用方想传负数,连编译都过不去,更别说到线上炸了。相比之下,void transfer(int amount)就得靠注释、靠if、靠代码规范去兜底,而这三样东西在赶工期的时候最不可靠。后面我会把整数类和自然数类从设计取舍到完整代码、从单元测试到踩坑记录全部拆开讲,代码可以直接拿去改,也可以只挑其中一两个点用在你现有的封装里。

2. 设计前必须想清楚的几个取舍

动手写第一行代码之前,有几个决定会一路影响后面的所有实现,事后返工代价很高。我把当时纠结最久的四个问题摊开来写,每个都给出我最终的选择和理由。

2.1 可变还是不可变:这个没有第二个答案

整数类必须是不可变的,这一点我没有任何犹豫。理由很实在:一个值对象一旦可变,它在被当作HashMap的 key、被多线程共享、被当作方法参数传出去之后,行为就变得不可预测。你可以想象一个IntegerValue的setValue()方法被某个工具类悄悄调用,而另一个线程正拿它做累加,结果就是一笔对不上的账。

不可变的实现方式很直接:字段加final,类加final(防止子类破坏不可变性),不提供任何 setter,所有"修改"操作都返回新实例。代价是每次运算都要新建对象,GC 压力会上升。这个代价在小对象场景下可以接受,因为 JVM 对短生命周期小对象的回收非常成熟;如果确实落在高频热路径上,再考虑用缓存池(后面 3.5 节会讲)或者干脆退回原生int。

注意:类加final不是形式主义。只要类可以被继承,子类就能重写equals或者塞进可变状态,你精心维护的不可变性契约就漏了个洞。

2.2 继承 Number 还是独立成类

java.lang.Number是个抽象类,定义了intValue()、longValue()、doubleValue()这些拆箱方法。让自定义整数类继承Number的好处是能融入一些通用框架的参数转换逻辑,坏处是你被迫实现一堆doubleValue()之类语义模糊的方法——一个整数转double在数值很大时会丢精度,这个方法存在本身就是个陷阱。

我的选择是不继承 Number,做成独立的final class。理由是这个类的定位是"带语义的值对象",不是"数值类型的通用抽象",它不需要被当成Number到处传。如果确实有场景需要和Number互转,加一个显式的toBigInteger()或者intValueExact()方法,让调用方自己承担转换的语义,比隐式继承一个泛化接口要安全得多。

顺带说一句,如果你要把它接到 JSON 序列化框架上,独立类反而更好办,写个自定义序列化器输出原始数字即可,不需要考虑Number的继承体系带来的歧义。

2.3 自然数到底含不含 0:先定规矩再写代码

这是自然数类最容易被忽略、也最容易在团队里吵起来的问题。国内教材里两种说法都有:有的把自然数定义为正整数(1, 2, 3, ...),有的定义为非负整数(0, 1, 2, ...)。国际标准化的定义倾向于含 0,但具体到你的项目,取决于业务语义。

我的处理方式是:在类注释里写死约定,并且提供两个工厂方法把语义显式化。NaturalNumber.of(0)允许通过,代表"非负整数"语义;如果业务上确实需要正整数,再加一个PositiveInteger或者干脆在构造时用mustBePositive参数控制。最怕的是类名叫"自然数"、注释没写、调用方一半认为含 0 一半认为不含,最后在if (n == 0)的分支上出问题。

我给这个类定的规矩是:自然数 = 非负整数,0 是合法的,最小值就是 0。这个约定会直接决定减法操作的返回类型设计,4.2 节会详细说。

2.4 内部用什么类型存:int、long 还是 BigInteger

三个选项各有适用面,取决于你要覆盖的数值范围。

存储类型范围优点风险
int-2^31 ~ 2^31-1内存小,运算快,和大多数业务字段匹配运算极易溢出,且溢出静默无提示
long-2^63 ~ 2^63-1覆盖绝大多数计数、金额分单位场景溢出依然静默,只是概率低
BigInteger理论上无上限不会溢出每次运算创建对象,性能差一个量级,代码啰嗦

我的选择是long做内部存储,对外提供int范围的快速通道。原因是int的溢出太容易触发——两个十亿级别的数一加就翻车,而long在业务层面几乎碰不到上限。至于BigInteger,除非你明确要做大数运算(比如加密、组合数学),否则为一个值对象引入这个依赖不划算。

如果你选long,要注意构造时的窄化转换:外部传进来一个int,隐式拓宽没问题;但如果内部是long而外部 API 暴露int,就得用Math.toIntExact()做检查转换,不能直接强转,强转会在超出范围时悄悄截断。

3. 整数类的核心实现拆解

这一节把整数类拆成字段与构造、相等性、比较、运算、输出与缓存五块,每块都说明"为什么这么写",代码可以直接抄。

3.1 字段、私有构造与工厂方法

字段只有一个private final long value。构造函数设为private,对外只暴露静态工厂方法。这个套路的意图有两个:一是可以在工厂方法里做缓存复用(3.5 节),二是将来如果要加校验或者改内部表示,调用方代码不用动。

public final class IntegerValue implements Comparable<IntegerValue> { private final long value; private IntegerValue(long value) { this.value = value; } public static IntegerValue of(long value) { if (value >= IntegerValueCache.MIN_CACHED && value <= IntegerValueCache.MAX_CACHED) { return IntegerValueCache.cacheOf((int) value); } return new IntegerValue(value); } public static IntegerValue of(int value) { return of((long) value); } public long longValue() { return value; } public int intValueExact() { return Math.toIntExact(value); } }

这里有个细节值得说:of(int)和of(long)两个重载并存,调用of(5)时编译器会选int版本,走缓存路径;调用of(5L)走long版本。如果不加重载,字面量5会被自动拓宽到long,虽然结果一样,但语义上不够清晰。

intValueExact()用Math.toIntExact()而不是直接(int) value,是因为后者在value超出int范围时会静默截断,比如(int) 4294967297L会得到1,这种 bug 极难排查。toIntExact会抛ArithmeticException,把问题暴露在发生的地方。

3.2 equals 与 hashCode 必须成对出现

值对象的相等性判断只看值,不看引用。equals的实现要注意三点:类型判断用instanceof还是getClass()、字段比较方式、以及和hashCode的一致性。

@Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof IntegerValue)) { return false; } IntegerValue that = (IntegerValue) o; return this.value == that.value; } @Override public int hashCode() { return Long.hashCode(value); }

因为类是final的,不存在子类问题,所以用instanceof是安全的,而且能兼容某些代理场景。如果类不是final,就得用getClass()严格判等,否则会出现"父类实例等于子类实例"但子类又加了字段的诡异情况。

hashCode直接委托Long.hashCode(),保证和equals的字段一致。这里千万别手写(int) value当哈希值,因为0L和4294967296L的低 32 位都是 0,会撞得很难看。

提示:一旦你重写了equals,hashCode就是强制的。我曾见过一个封装类只写了equals,结果放进HashSet后contains永远返回false,排查了半天才发现哈希值用的是默认的对象地址。

3.3 Comparable 的 compareTo 不要用减法

实现Comparable是为了让对象能进TreeSet、能排序、能用Collections.max这类工具。compareTo最常见的错误写法是return (int)(this.value - that.value),这个写法在数值差距超过int范围时会溢出,导致排序结果随机错乱。

@Override public int compareTo(IntegerValue other) { return Long.compare(this.value, other.value); }

Long.compare内部用的是比较而不是减法,任何数值都不会出问题。这个细节看起来小,但它是排序类 bug 的高发区,我建议只要写compareTo就条件反射地用Integer.compare或Long.compare。

另外compareTo的契约里要求"与equals保持一致",即compareTo返回 0 的两个对象,equals也应该返回true。我们这个实现里两者都只看value,天然一致,不需要额外处理。

3.4 四则运算与溢出检测

加减乘除全部返回新实例,并且一律用Math系列的*Exact方法做溢出检测。这是整个实现里我最看重的部分。

public IntegerValue add(IntegerValue other) { return IntegerValue.of(Math.addExact(this.value, other.value)); } public IntegerValue subtract(IntegerValue other) { return IntegerValue.of(Math.subtractExact(this.value, other.value)); } public IntegerValue multiply(IntegerValue other) { return IntegerValue.of(Math.multiplyExact(this.value, other.value)); } public IntegerValue divide(IntegerValue other) { if (other.value == 0L) { throw new ArithmeticException("除数不能为零"); } return IntegerValue.of(this.value / other.value); } public IntegerValue negate() { return IntegerValue.of(Math.negateExact(this.value)); }

原生int加法的溢出是静默的:Integer.MAX_VALUE + 1 == Integer.MIN_VALUE。这个特性在计数器、累计金额、分页偏移量这些场景里制造过无数事故。Math.addExact在溢出时抛ArithmeticException,把"悄悄给出错误答案"变成"当场报错",这是典型的 fail-fast 思路。

除法单独处理除零,因为/对long除零也是抛ArithmeticException,但错误信息不明确,自己先判一次能给出更好定位的提示。negateExact用来处理Long.MIN_VALUE取负溢出的边界情况——这个值取负还是它自己,是个经典的坑。

3.5 toString、序列化与缓存池

toString直接返回Long.toString(value),不要拼接任何前缀,让日志和调试输出保持干净。如果需要在 UI 上显示单位,交给格式化层去做。

关于缓存:Integer.valueOf在 -128 到 127 之间有缓存,我们的实现照搬这个思路,把常用小整数缓存起来,减少小对象分配。

final class IntegerValueCache { static final int MIN_CACHED = -128; static final int MAX_CACHED = 127; private static final IntegerValue[] CACHE = new IntegerValue[MAX_CACHED - MIN_CACHED + 1]; static { for (int i = 0; i < CACHE.length; i++) { CACHE[i] = IntegerValue.ofInternal(i + MIN_CACHED); } } static IntegerValue cacheOf(int v) { return CACHE[v - MIN_CACHED]; } private IntegerValueCache() { } }

这里要小心一个循环依赖:静态初始化块里如果调用IntegerValue.of(),而of()又要访问缓存数组,就会出现"缓存还没建好就开始用缓存"的问题。所以缓存内部调的是一个不查缓存的包级私有工厂ofInternal()。

缓存的收益在计数器、状态码、位标志这类高频小整数场景下很明显;如果你的业务值域很分散(比如时间戳、ID),缓存基本命中不了,那就把范围调小或者干脆关掉,别为了形式上的优化增加复杂度。

4. 自然数类:在整数类之上加约束

自然数类的本质就是"整数类 + 一条取值范围约束"。听起来简单,但实现方式有两种,各有代价,而且减法操作的返回类型会牵出一串设计问题。

4.1 派生还是组合

第一种做法是让NaturalNumber extends IntegerValue,在构造时加校验。第二种是独立成类,内部持有一个long,把整数类的逻辑复制或者委托过去。

我选的是独立成类。原因有三:IntegerValue已经是final了,继承这条路本身就走不通;就算放开final,继承会带来方法返回类型的问题——IntegerValue的add返回IntegerValue,子类没法自动变成返回NaturalNumber,除非重写所有方法,那还不如独立写;最重要的是,两个类的语义不同,独立成类能让类型系统清晰地拒绝"把任意整数当自然数用"这种错误。

独立成类的代价是代码重复。缓解办法是把纯数值运算的部分抽成一个包级私有的工具类,两个类各自调用,而不是靠继承复用。

4.2 减法与封闭性:返回类型到底选什么

自然数对加法、乘法是封闭的(非负加非负还是非负),对减法不是——3 - 5 = -2,结果跳出了自然数集合。这个数学事实会直接逼出一个设计问题:subtract方法返回什么?

我给三个方案和各自适用面:

方案行为适用场景代价
抛异常减出负数时抛ArithmeticException业务上"不该发生"的场景,比如库存扣减调用方必须提前判断,否则运行时炸
返回IntegerValue允许结果为负,用更宽的类型承接差值计算,比如两个时间点的间隔调用方要处理两种类型
返回Optional<NaturalNumber>负数时返回空需要显式处理失败分支代码变啰嗦,链式调用不便

我的做法是同时提供两个方法,名字不同、语义清晰:

public final class NaturalNumber implements Comparable<NaturalNumber> { public static final NaturalNumber ZERO = new NaturalNumber(0L); private final long value; private NaturalNumber(long value) { if (value < 0L) { throw new IllegalArgumentException("自然数不能为负: " + value); } this.value = value; } public static NaturalNumber of(long value) { if (value == 0L) { return ZERO; } return new NaturalNumber(value); } public NaturalNumber add(NaturalNumber other) { return NaturalNumber.of(Math.addExact(this.value, other.value)); } public NaturalNumber multiply(NaturalNumber other) { return NaturalNumber.of(Math.multiplyExact(this.value, other.value)); } public NaturalNumber subtractOrThrow(NaturalNumber other) { if (other.value > this.value) { throw new ArithmeticException( "自然数减法越界: " + this.value + " - " + other.value); } return NaturalNumber.of(this.value - other.value); } public IntegerValue subtractToInteger(NaturalNumber other) { return IntegerValue.of(Math.subtractExact(this.value, other.value)); } public long longValue() { return value; } @Override public int compareTo(NaturalNumber other) { return Long.compare(this.value, other.value); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof NaturalNumber)) { return false; } return this.value == ((NaturalNumber) o).value; } @Override public int hashCode() { return Long.hashCode(value); } @Override public String toString() { return Long.toString(value); } }

方法名带OrThrow和ToInteger的后缀,调用方在 IDE 里一眼就能看出这个操作会不会抛、返回什么类型,不需要点进去看源码。这种"方法名即契约"的习惯,比在 Javadoc 里写一句"可能抛异常"要可靠得多,因为没人会去读 Javadoc。

4.3 边界测试清单

自然数类的 bug 几乎全部集中在边界上,我把当时写的测试清单列出来,照着测基本能覆盖八成问题。

测试点输入期望行为
零值构造NaturalNumber.of(0)正常返回,与ZERO是同一个实例
负值构造NaturalNumber.of(-1)抛IllegalArgumentException
加法溢出Long.MAX_VALUE加 1抛ArithmeticException
减法越界of(3).subtractOrThrow(of(5))抛ArithmeticException,信息含两个操作数
减法到零of(5).subtractOrThrow(of(5))返回ZERO实例
减法转整数of(3).subtractToInteger(of(5))返回IntegerValue(-2)
相等性两个of(7)equals为真,hashCode相等
集合去重两个of(7)放入HashSet集合大小为 1

减法到零要返回ZERO常量而不是新建实例,这一点在 5.2 节的测试里会专门验证,因为它同时验证了缓存的正确性。

5. 实操:从零跑通并验证这套实现

前面讲的是设计,这一节讲落地。我按实际的工程习惯,把代码放到包结构里,配上单元测试,最后记录一下性能实测的数据。

5.1 包结构与类文件落地

我用的包结构是这样的,纯 Java 标准库,没有第三方依赖:

com.example.valueobject ├── IntegerValue.java ├── IntegerValueCache.java ├── NaturalNumber.java └── ValueObjectDemo.java

IntegerValueCache是包级私有的,不对外暴露。ValueObjectDemo只是一个main方法,用来快速验证行为,正式项目里应该删掉。

完整的IntegerValue类需要补上toString和缓存调用,这里给一版可以直接编译的完整骨架:

package com.example.valueobject; /** * 整数值对象,不可变,线程安全。 * 内部使用 long 存储,运算全程做溢出检测。 */ public final class IntegerValue implements Comparable<IntegerValue> { private final long value; private IntegerValue(long value) { this.value = value; } static IntegerValue ofInternal(int value) { return new IntegerValue(value); } public static IntegerValue of(long value) { if (value >= IntegerValueCache.MIN_CACHED && value <= IntegerValueCache.MAX_CACHED) { return IntegerValueCache.cacheOf((int) value); } return new IntegerValue(value); } public static IntegerValue of(int value) { return of((long) value); } public long longValue() { return value; } public int intValueExact() { return Math.toIntExact(value); } public IntegerValue add(IntegerValue other) { return IntegerValue.of(Math.addExact(this.value, other.value)); } public IntegerValue subtract(IntegerValue other) { return IntegerValue.of(Math.subtractExact(this.value, other.value)); } public IntegerValue multiply(IntegerValue other) { return IntegerValue.of(Math.multiplyExact(this.value, other.value)); } public IntegerValue divide(IntegerValue other) { if (other.value == 0L) { throw new ArithmeticException("除数不能为零"); } return IntegerValue.of(this.value / other.value); } public IntegerValue negate() { return IntegerValue.of(Math.negateExact(this.value)); } public IntegerValue abs() { if (value == Long.MIN_VALUE) { throw new ArithmeticException("绝对值溢出: " + value); } return IntegerValue.of(Math.abs(value)); } @Override public int compareTo(IntegerValue other) { return Long.compare(this.value, other.value); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof IntegerValue)) { return false; } return this.value == ((IntegerValue) o).value; } @Override public int hashCode() { return Long.hashCode(value); } @Override public String toString() { return Long.toString(value); } }

abs()这里单独判了Long.MIN_VALUE,因为这个值的绝对值在long范围内表示不出来,Math.abs(Long.MIN_VALUE)会返回它本身(还是负数)。这是标准库里的一个已知行为,封装的时候顺手堵上。

5.2 单元测试怎么写才有价值

测试不要写成"调一遍看一眼",每条用例都应该对应一条明确的契约。我用的是 JUnit 5,只挑几条最有代表性的贴出来。

package com.example.valueobject; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class IntegerValueTest { @Test void 加法溢出必须抛异常() { IntegerValue max = IntegerValue.of(Long.MAX_VALUE); IntegerValue one = IntegerValue.of(1L); assertThrows(ArithmeticException.class, () -> max.add(one)); } @Test void 小整数应该命中缓存() { assertSame(IntegerValue.of(100), IntegerValue.of(100)); assertNotSame(IntegerValue.of(1000), IntegerValue.of(1000)); } @Test void 相等对象哈希值必须一致() { assertEquals(IntegerValue.of(42), IntegerValue.of(42)); assertEquals(IntegerValue.of(42).hashCode(), IntegerValue.of(42).hashCode()); } @Test void 窄化转换越界必须报错() { IntegerValue big = IntegerValue.of((long) Integer.MAX_VALUE + 1); assertThrows(ArithmeticException.class, big::intValueExact); } @Test void 最小值取负必须抛异常() { IntegerValue min = IntegerValue.of(Long.MIN_VALUE); assertThrows(ArithmeticException.class, min::negate); } }

方法名我用了中文,这在团队内部项目里没有问题,可读性比testAddOverflow更强。如果担心编码问题,改成英文也行,关键是用例名要说明白"测的是什么契约"。

自然数的测试重点放在减法上:

class NaturalNumberTest { @Test void 负数构造被拒绝() { assertThrows(IllegalArgumentException.class, () -> NaturalNumber.of(-1)); } @Test void 减去更大的数必须抛异常() { NaturalNumber three = NaturalNumber.of(3); NaturalNumber five = NaturalNumber.of(5); assertThrows(ArithmeticException.class, () -> three.subtractOrThrow(five)); } @Test void 相减为零返回常量实例() { assertSame(NaturalNumber.ZERO, NaturalNumber.of(9).subtractOrThrow(NaturalNumber.of(9))); } @Test void 转整数允许负结果() { IntegerValue r = NaturalNumber.of(3).subtractToInteger(NaturalNumber.of(5)); assertEquals(IntegerValue.of(-2), r); } }

assertSame这条用例很关键,它同时验证了两件事:减法边界算得对,以及常量复用生效。如果用assertEquals,哪怕每次新建一个值为 0 的对象也能通过,测试的价值就打折扣了。

5.3 性能实测记录

我在一台普通的开发笔记本上做了一个粗糙的对比测试:1000 万次累加,分别用原生long、IntegerValue(走缓存)、IntegerValue(不命中缓存)、BigInteger。测试方法用的是最简单的循环加计时,没有上 JMH,所以绝对值只能看个数量级,不要当基准数据用。

实现方式1000 万次累加耗时相对开销
原生long约 8 ms1 倍
IntegerValue(值在缓存区间)约 95 ms约 12 倍
IntegerValue(值不在缓存区间)约 140 ms约 18 倍
BigInteger约 720 ms约 90 倍

这组数字说明两件事。第一,值对象的开销是真实存在的,大约一个数量级,别在每秒几十万次的热循环里滥用。第二,缓存确实有效,命中缓存能省掉约三分之一的分配开销,对于状态码、类型码这类小整数密集的场景值得开。

我的实际取舍是:领域模型的字段、对外 API 的参数、需要校验的边界用它;纯粹的内部循环计数、数组下标、位运算,老老实实用原生long。这不是妥协,是分清楚"表达业务约束"和"做算术"是两件事。

6. 常见问题与排查速查

这套代码上线前后,我遇到的和同事反馈的问题基本集中在下面几类。把它们整理成速查表,遇到类似现象可以先对照着定位。

6.1 典型异常与根因对照

现象可能根因排查方向
NullPointerException,堆栈指向一个看起来正常的赋值自动拆箱时对象为null检查字段是否声明成包装类型,改用值对象的工厂方法并禁止null
ArithmeticException: long overflow运算溢出被*Exact拦下看堆栈里的两个操作数,判断是业务值异常还是存储类型选小了
IllegalArgumentException: 自然数不能为负上游算出了负差检查是用了subtractOrThrow还是应该用subtractToInteger
HashSet里明明加了却contains返回false重写了equals没重写hashCode,或字段可变检查两个方法是否用同一组字段
TreeSet排序结果随机错乱compareTo用了减法导致溢出换成Long.compare
日志里打印出com.example.IntegerValue@1b6d3586忘了重写toString补上,并确保返回纯数字

6.2 我自己踩过的三个坑

第一个坑是缓存的初始化顺序。最初我把缓存数组放在IntegerValue类里,静态块中调用IntegerValue.of()来填充,结果类初始化时出现循环依赖,缓存数组全是null,第一次调用of(0)直接空指针。后来把缓存拆成独立的IntegerValueCache类,并且内部走不查缓存的ofInternal(),问题才解决。教训是:静态初始化块里不要调用会反过来访问同一个类静态成员的方法。

第二个坑是在equals里用了getClass()又在别处用了代理。有个模块用了动态代理包装值对象,getClass()拿到的是代理类,equals永远返回false。因为我们的类本身是final的,不存在继承问题,改成instanceof之后就好了。如果你的类不是final,这个取舍要重新想一遍。

第三个坑是把自然数当成了无符号整数。有同事看到"非负"两个字,就以为它可以安全地承接从long强转过来的大数,结果传入一个超过Long.MAX_VALUE语义的值时,运算直接溢出。自然数的约束是"不小于零",不是"能表示所有正数",这个区别要在类注释里写清楚。

6.3 几个提高复用性的小技巧

第一,给IntegerValue加一个between(min, max)的静态校验方法,返回boolean,业务代码里的范围判断都能收敛到一处,改规则只改一个地方。

第二,自然数类可以再加一个isZero()和isPositive(),比equals(NaturalNumber.ZERO)和compareTo(ZERO) > 0读起来顺,也避免了调用方自己造零值对象。

第三,如果你需要在持久层用,记得给 JPA 或者 MyBatis 写类型转换器,把值对象和数据库的BIGINT列对上。转换器里调of()和longValue()就行,不要图省事直接反射改final字段。

第四,toString()保持输出纯数字,展示层的千分位、单位、货币符号全部交给格式化层。值对象的职责是表达值,不是表达展示。

这套东西我在两个项目里用过,一次是结算金额的封装,一次是配置项的数量校验。第二次用的时候几乎没改代码,只是把包名换了一下,这说明抽象粒度还算合适。如果你的场景里只有一两个字段需要约束,硬套这套反而是过度设计,一个if加一句注释就够了;但凡某个数值在三个以上的模块里流转,并且每个模块都在重复写同样的校验,那就值得把它变成一个类型。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询