Java 类型、对象与领域模型
Java 代码里的变量、引用和对象处在不同位置。下面三行只有一个列表对象:
int quantity = 2;
List<String> tags = new ArrayList<>(List.of("vip"));
List<String> alias = tags;局部变量
├─ quantity ──> 基本值 2
├─ tags ──> 引用值 R1 ──┐
└─ alias ──> 引用值 R1 ──┴─> ArrayList 对象 ["vip"]alias = tags 复制了引用值,两个变量随后指向同一对象。alias.add("paid") 会让 tags 观察到相同变化。理解这张对象图,才能继续判断参数传递、final、record、相等性、哈希集合和运行时多态;领域模型也建立在同一组语言事实上。
变量保存值,引用值指向对象
JLS 17 类型章节把 Java 类型分为基本类型和引用类型。八种基本类型是 boolean、byte、short、int、long、char、float、double;类、接口、数组和类型变量属于引用类型。String 也是引用类型,只是它的实例不可变并且语法使用频繁。
类声明描述实例可以保存的状态和执行的行为,对象是运行时创建的类实例或数组。变量始终有声明类型,变量中保存的是该类型允许的值:基本类型变量保存基本值,引用类型变量保存引用值或 null。
赋值与转换改变什么
| 操作 | 编译期判断 | 运行结果 |
|---|---|---|
long total = quantity | int 可以拓宽为 long | 数值被转换,原变量保持不变 |
int n = (int) large | 显式允许窄化 | 高位可能丢失,需要业务范围检查 |
PricingRule rule = fixed | 具体实现可以向上赋给接口 | 复制引用值,仍指向同一对象 |
FixedDiscount f = (FixedDiscount) rule | 语法允许向下转换 | JVM 检查实际对象,失败抛 ClassCastException |
Integer boxed = quantity | 自动装箱 | 创建或复用包装对象,值语义不靠对象地址判断 |
int n = boxed | 自动拆箱 | boxed == null 时抛 NullPointerException |
窄化转换只表达“允许执行转换”,没有替调用者证明结果仍在业务范围内。引用向下转换也只验证对象的运行时类型,不能修复过宽的公共 API。频繁出现强转时,应先检查调用者真正需要的能力能否进入接口签名。
Java 参数传递始终传值
方法调用会用实参值初始化新的形参变量,JLS 17 方法形参规则描述了这个过程。基本类型实参传入基本值副本,引用类型实参传入引用值副本。
static void update(List<String> received) {
received.add("paid"); // 修改两个引用共同指向的对象
received = new ArrayList<>(); // 只改写形参自己的引用值
}调用 update(tags) 后,tags 能看到新增的 paid,却不会改为指向方法中新建的列表。常见的“对象按引用传递”说法会掩盖第二个事实;准确模型始终是值传递,其中某些值恰好是引用。
final List<String> tags 只禁止变量再次赋值,列表对象仍可修改。要得到不可变数据,需要同时约束引用的重新绑定、对象公开的修改操作,以及对象内部继续引用的可变成员。
外部字段经过构造边界才成为领域对象
HTTP JSON {"kind":"RATE","amount":-10} 可以成功解析成字符串和整数。类型系统已经确认字段形态,业务规则还需要确认折扣种类、必填性和取值范围。一个常见转换过程如下:
请求字节
└─ JSON 解析
└─ DiscountRequest(kind="RATE", amount=-10)
└─ 存在性 / 枚举值 / 范围校验
├─ 失败:拒绝输入并返回契约错误
└─ 成功:构造 RateDiscount(basisPoints)同一个业务概念在边界和领域内可以使用不同模型:
| 模型 | 负责的变化 | 合法状态 | 典型形式 |
|---|---|---|---|
| 边界 DTO | HTTP、消息或存储字段兼容 | 可以暂存缺失字段、旧字段和待校验值 | 普通类或 record |
| 值对象 | 一个按内容定义的业务值 | 构造完成后始终满足范围与格式规则 | 不可变 record / final class |
| 实体 | 跨业务状态维持同一身份 | 状态可受控变化,标识保持稳定 | 持有稳定 ID 的类 |
| 有限状态 | 一组明确且有限的业务选项 | 只允许已声明常量或封闭子类型 | enum / sealed hierarchy |
DiscountRequest(String kind, Integer amount) 使用包装类型,可以区分“字段缺失”和数值 0。映射器先处理 null、未知 kind 和范围,再创建 RateDiscount:
record RateDiscount(int basisPoints) implements PricingRule {
RateDiscount {
if (basisPoints < 0 || basisPoints > 10_000) {
throw new IllegalArgumentException("basisPoints must be in [0, 10000]");
}
}
}构造器完成后,任意 RateDiscount 都处于有效范围,后续计价代码无需重复防守同一不变量。DTO 与领域对象也可以共用一个类型,前提是字段、约束、信任级别和演进责任确实相同。外部契约需要兼容旧载荷而领域规则需要立即收紧时,两者已经有不同的变化原因,适配边界应把它们分开。
null 要落到明确语义
| 来源 | 应表达的含义 | 合适处理位置 |
|---|---|---|
| 请求字段缺失 | 客户端没有提供必填值或使用旧契约 | DTO 解析/校验层返回字段错误 |
| 查询没有结果 | 合法查询未找到对象 | 返回明确的空结果类型或应用结果 |
| 业务上可选 | 该概念在特定状态确实不存在 | 在领域 API 中用命名结果表达 |
必需依赖为 null | 构造或装配违反程序约束 | 构造入口立即 Objects.requireNonNull |
Optional 适合某些返回值,能够迫使调用者处理“可能没有结果”;它不应机械替换全部字段和方法参数。边界 DTO 仍要保留序列化契约,领域对象则应使用最能表达业务状态的类型。异常跨层语义在异常与资源生命周期中展开。
声明类型参与编译,运行时类参与分派
下面一行同时出现两个类型:
PricingRule rule = new FixedDiscount(1_000);PricingRule 是变量的声明类型,限制调用点可见的能力;FixedDiscount 是对象的运行时类,决定被重写实例方法的最终实现。JLS 17 方法调用规则把编译期签名选择和运行期调用分开处理。
interface PricingRule {
long apply(long originalCents);
}
record FixedDiscount(long cents) implements PricingRule {
@Override
public long apply(long originalCents) {
return originalCents - cents;
}
}
static String describe(PricingRule ignored) { return "rule"; }
static String describe(FixedDiscount ignored) { return "fixed"; }
PricingRule rule = new FixedDiscount(1_000);
System.out.println(describe(rule)); // rule
System.out.println(rule.apply(10_000)); // 9000| 判断 | 发生时机 | 使用的信息 | 当前结果 |
|---|---|---|---|
rule.apply 是否可调用 | 编译期 | PricingRule 是否声明 apply | 可调用 |
describe(rule) 选择哪个重载 | 编译期 | 实参表达式的声明类型 | describe(PricingRule) |
rule.apply 执行哪个重写实现 | 运行期 | 引用实际指向 FixedDiscount | FixedDiscount.apply |
| 向下转型是否成功 | 运行期 | 对象是否属于目标类 | 不匹配时抛异常 |
重载是在同一名字下选择不同参数签名,编译器在调用点完成选择;重写是子类型提供实例方法实现,JVM 依据运行时对象分派。把两者都叫“多态”容易得到错误推论,例如认为运行时会为重载重新选择更具体的参数版本。
接口类型让调用者只依赖计价能力。若调用者随后到处使用 instanceof FixedDiscount,说明行为可能放错了位置:可以把需要变化的操作加入稳定接口,或把有限状态建成由所有者控制的封闭层次。泛型如何让容器和公共 API 保留具体类型关系,见泛型与公共 API。
record 提供值语义骨架,不提供深不可变
JLS 17 record 规则会为组件生成私有 final 字段、访问器和基于组件的 equals、hashCode、toString。这些生成规则很适合值对象,但组件字段为 final 只固定引用值,组件所指向的对象仍遵循自己的可变性。
record OrderTags(List<String> values) {
OrderTags {
values = List.copyOf(Objects.requireNonNull(values, "values"));
}
}List.copyOf 在构造边界保存不可修改快照。调用者随后修改原列表,不会改变 OrderTags 观察到的内容。只使用 Collections.unmodifiableList(source) 则仍持有原列表视图,其他代码修改 source 时视图也会变化。
不可变需要沿对象图判断:
| 组件 | 一次浅复制是否足够 | 额外处理 |
|---|---|---|
String、不可变值对象 | 通常足够 | 确认其内部也不暴露可变状态 |
List<String> | List.copyOf 可形成元素引用快照 | 字符串本身不可变 |
List<MutableItem> | 只能固定列表结构 | 逐项复制或改用不可变元素 |
| 数组 | 直接保存会共享同一数组 | 构造和访问两端按需 clone |
| 可变日期/缓冲区 | 仍可从外部改写 | 转为不可变表示或防御性复制 |
实体可以改变状态。Order.markPaid() 能把订单从 CREATED 推进到 PAID,方法应检查当前状态、权限和需要维护的不变量;变化发生在受控操作内,订单 ID 继续表示同一业务对象。值对象强调可替换内容,实体强调连续身份,两者不能只根据是否使用 record 来区分。
相等性和哈希必须使用稳定身份
引用上的 == 判断两个引用值是否指向同一对象;equals 表达类型定义的逻辑相等。Object 默认实现采用对象身份,重写后必须继续满足自反、对称、传递、一致和非空规则。Java 17 Object 契约还要求逻辑相等的对象产生相同 hashCode。
值对象通常由全部稳定内容定义相等性。两个 FixedDiscount(1000) 可以互换,record 自动生成的组件相等性正合适。数组的 equals 沿用对象身份,包装整数的 == 还可能受缓存影响;内容比较应调用相应值 API,而不是依赖引用是否相同。
实体通常按稳定业务标识相等:
OrderId("order-17") ──> Order(status=CREATED)
│
└────────────> Order(status=PAID)
状态发生变化,业务身份保持为 order-17status、名称、更新时间等可变字段不应进入实体 equals/hashCode。数据库生成 ID 在持久化前尚不存在时,也不要先把临时实体放入要求稳定哈希身份的集合;具体 ORM 代理与持久化前后身份策略应在持久化上下文内单独设计。
Java 17 Set 契约明确指出:元素留在集合期间,若变化影响 equals,集合行为未指定。常见 HashSet 会在插入时按哈希定位桶,字段变化后使用新哈希查找,可能找不到仍然存放在旧桶里的对象。
插入时:hash=7 ──> bucket 7 ──> MutableOrderKey(7)
修改后:hash=8 ──> bucket 8 ──> 查找失败
对象仍留在 bucket 7工程约束应写成“对象作为哈希键期间,参与相等性和哈希的状态保持不变”。某次运行观察到 contains=false 是 HashSet 实现上的故障证据,不是所有 Set 实现必须返回 false 的规范承诺。
组合、继承与封闭层次表达不同所有权
Checkout 持有 PricingRule,表示结账过程使用计价能力。两者有独立身份和生命周期,固定折扣、比例折扣也能独立替换,因此组合关系最直接:
Checkout ──持有──> PricingRule
├─ FixedDiscount
└─ RateDiscount继承适合真实的“是一个”关系,并要求父类契约对全部子类成立。抽象类还能集中受保护状态、构造规则或模板流程,但它把子类绑定到单继承层次;几行代码复用通常更适合提取协作者。接口侧重能力与外部契约,可以由无共同状态的多种实现共同承担。
扩展权决定接口是否封闭:
| 扩展责任 | 类型选择 | 演进责任 |
|---|---|---|
| 第三方或其他团队可以独立增加实现 | 普通开放 interface | 保持接口向后兼容,为外部实现维护契约测试 |
| 当前模块拥有完整、有限的状态集合 | sealed interface/class | 新增子类型时同步修改所有相关行为和测试 |
| 子类共享受控状态和固定流程 | abstract class | 维护构造不变量、受保护 API 和替换关系 |
| 协作者有独立生命周期或多种组合方式 | 字段组合 | 由持有者声明依赖能力与替换时机 |
Java 17 的 sealed 类型限制允许的直接子类型。JLS 17 sealed class 规则和 sealed interface 规则规定:普通直接子类型需要声明为 final、sealed 或 non-sealed,record 隐式为 final;命名模块中的直接子类型位于同一模块,未命名模块中则位于同一包。
当前 Java 17 示例没有使用当时仍处于 Preview 的类型模式 switch,所以不能宣称“新增子类型必然使所有分支编译失败”。sealed 提供的是扩展集合的所有权边界;每个消费点是否穷尽,仍由它使用的语言结构和编译器规则决定。
用 Docker 验证构造、分派和可变哈希键
公开示例 TypeBoundaryDemo.java包含 DTO 映射、声明类型重载、运行时重写、record 防御性复制、稳定实体身份与可变键反例。下面操作使用安装 Docker Engine 的 Linux 开发机;以能够访问 Docker 的普通用户从仓库根目录执行。
export LAB_SRC="$PWD/docs/.vuepress/public/examples/backend-development/type-object-model"
export LAB_OUT="$(mktemp -d)" || exit 1
export HOST_UID="$(id -u)"
export HOST_GID="$(id -g)"
export JDK17_IMAGE='eclipse-temurin:17.0.20_8-jdk@sha256:a27c79d44326d5f689668df5fedfee487652066d2a91e172747056cc7fbee6fc'
trap 'rm -rf -- "$LAB_OUT"' EXIT
test "$HOST_UID" -ne 0 || {
echo '请切换到能够访问 Docker 的普通用户' >&2
exit 1
}
test -r "$LAB_SRC/TypeBoundaryDemo.java" || exit 1
mkdir -p "$LAB_OUT/classes" || exit 1
run_java17() {
docker run --rm --user "$HOST_UID:$HOST_GID" \
--mount "type=bind,src=$LAB_SRC,dst=/src,readonly" \
--mount "type=bind,src=$LAB_OUT,dst=/out" \
"$JDK17_IMAGE" "$@"
}
run_java17 sh -lc '
set -eu
java -version
javac --release 17 -Xlint:all -Werror \
-d /out/classes /src/TypeBoundaryDemo.java
java -cp /out/classes TypeBoundaryDemo
'容器进程使用宿主 UID/GID,源码挂载为只读,class 写入当前用户拥有的临时目录。正常路径应完整输出:
declared-overload=rule
runtime-dispatch=9000
valid-rate=9000
value-equality=true
defensive-copy=true
entity-status=PAID
same-entity-equality=true
stable-id-hash=true
stable-id-lookup=true前两行分别固定重载与重写的选择结果;defensive-copy=true 说明外部列表变化没有进入值对象;最后四行说明订单状态变化后,另一个持有相同 OrderId 的实例仍能命中集合。任一断言缺失时,先保留 $LAB_OUT,检查源码版本、编译输出和镜像摘要,再决定是模型回归还是运行环境漂移。
非法 DTO 应在领域构造入口失败:
if run_java17 java -cp /out/classes TypeBoundaryDemo invalid-input \
>"$LAB_OUT/invalid.err" 2>&1; then
echo '预期负折扣被拒绝,但程序成功退出' >&2
exit 1
fi
grep 'IllegalArgumentException: basisPoints must be in' \
"$LAB_OUT/invalid.err" || exit 1已验证的退出状态为 1,异常栈指向 RateDiscount 构造器。若异常出现在计价深处,说明非法值越过了构造边界;若进程成功退出,则检查 DTO 映射是否绕过了领域构造器。
可变键路径固定检查哈希身份确实发生变化:
set +e
run_java17 java -cp /out/classes TypeBoundaryDemo mutable-key \
>"$LAB_OUT/mutable.err" 2>&1
mutable_status=$?
set -e
test "$mutable_status" -eq 2 || exit 1
grep -Fqx 'mutable-lookup-before=true' "$LAB_OUT/mutable.err" || exit 1
grep -Fqx 'mutable-hash-stable=false' "$LAB_OUT/mutable.err" || exit 1
grep -Fqx 'mutable-lookup-after=false' "$LAB_OUT/mutable.err" || exit 1
grep -Fq 'Set behavior is unspecified' "$LAB_OUT/mutable.err" || exit 1这里把“哈希值在存储期间变化”作为规范违约,把 lookup-after=false 作为当前 Temurin/OpenJDK HashSet 的实测现场。修复方式是使用不可变值键,或让实体相等性只依赖创建后保持稳定的标识;每次修改后执行 remove/add 只会把身份问题分散到更多调用点。
| 现象 | 先检查的对象关系 | 下一步 |
|---|---|---|
ClassCastException | 公共签名是否过宽,反射/DTO 入口是否假定具体实现 | 收窄签名或把变化行为放回接口 |
| 自动拆箱触发 NPE | 缺失值在哪一层获得业务含义 | 在 DTO→领域映射时分类或拒绝 |
Set.contains 与预期不符 | equals/hashCode 字段是否在入集合后改变 | 改用稳定 ID 或不可变键并重跑负例 |
| record 内容被外部改写 | 构造输入、访问器或嵌套元素是否仍共享 | 在真实可变层级做防御性复制 |
新实现引发大量 instanceof | 状态空间所有权和行为位置 | 选择开放契约、sealed 层次或接口方法 |
对象从边界进入系统时先建立不变量,参与协作时只暴露所需能力,进入集合前固定相等性,发生状态变化时保留身份。沿这个顺序检查,语言类型、领域模型和运行时行为会指向同一套对象事实。
权威资料与规范地址
以下资料用于核对 Java 17 示例采用的语言与集合规则。领域建模部分是基于这些语言契约形成的工程设计,不属于 JLS 对业务模型的强制规定。
类型、参数与调用
| 资料 | 用途 |
|---|---|
| JLS 17:类型、值与变量 | 基本类型、引用类型、对象与变量 |
| JLS 17:方法形参 | 实参值初始化形参变量 |
| JLS 17:方法调用表达式 | 重载选择与运行时方法调用 |
对象、相等性与集合
| 资料 | 用途 |
|---|---|
| JLS 17:record 类 | record 字段、访问器和对象方法规则 |
Java 17 Object API | equals 与 hashCode 契约 |
Java 17 Set API | 元素相等性变化时的集合边界 |
封闭类型
| 资料 | 用途 |
|---|---|
| JLS 17:sealed class | 封闭类的直接子类型约束 |
| JLS 17:sealed interface | 封闭接口、模块与包规则 |
