软件建模07:时间、数量与身份
一台设备上午十点至十一点被租用,下一笔从十一点开始,是否冲突?报价里的十元和十美元能否相加?设备改了名称以后,过去的租赁记录应该显示哪个名称?这些问题往往被藏进时间戳、小数和字符串字段里,直到边界数据出现,才发现字段类型没有表达业务需要的区别。
状态、流程和决策 为报价使用了“整数计费天数”这一输入,但没有定义它怎样从租期计算。本篇先检查时间、数值和身份各自的含义,再讨论这些选择对实现的限制。设备租赁仍是教学场景,日租算法、货币转换和历史政策均不能由 Java 类型替业务作出决定。相邻租期是否争用同一个端点
系列基线 RentalDesk.Period 已经使用 Instant start 和 Instant end,要求开始严格早于结束。租期解释为半开区间 [start,end):包含开始,不包含结束。两个租期重叠,当且仅当各自的开始都早于另一个的结束。
1 | |
因此十点至十一点与十一点至十二点不重叠。这里的十一点属于后一段,前一段已经结束。若把两边都解释为闭区间,同一个端点会同时属于两段,原本可以无缝衔接的计划就会被判为冲突。
1 | |
半开约定并不意味着真实设备可以在零时间内完成归还、验收和交付。基线讨论的是计划租期占用,没有建模操作缓冲。如果业务需要十五分钟清洁时间,应明确是扩大占用区间、限制相邻预留,还是单独建立工作计划。偷偷把结束时间加一秒,既表达不了十五分钟,也会造成接口之间不一致。
实验直接调用共享基线的 Period,没有另写一份看起来相似的算法。除了双向检查相邻不重叠,还构造只重叠一纳秒的区间,防止通过截断到秒误判。零长度和反向区间都必须被构造器拒绝。这里把零长度定义为非法租期;在其他业务里,瞬时事件可以合法存在,但它应采用事件时间模型,不能无说明地塞进租期。
日历日期不等于时间轴上的时刻
LocalDate 表示不带时区的日历日期。它不能单独确定全球时间轴上的一个时刻;需要补充时间与偏移量或时区。Instant 可以用于时间轴比较,却没有自动携带“客户所在地的某一天”这一解释。Java 21 LocalDate API
业务说“租一天”至少留下两个候选含义:经过二十四小时,或从当地某个日界线到下一个日界线。两种定义在普通日期可能给出相同结果,夏令时切换时会分开。选择哪一种取决于合同的计费口径,本篇不把任意一种写成设备租赁的普遍规则。
实验选定 America/New_York 的 2026-03-08,分别求当天与次日的 atStartOfDay(zone).toInstant()。两个当地日期相差一天,时间轴上实际输出却是:
1 | |
这是本次 Java 21.0.11 所携带时区数据的真实结果。断言还检查“开始时刻加二十四小时”不等于下一日的起点,以及同一日期在 UTC 与纽约对应不同起点。使用显式时区可以避免结果随着运行机器的默认时区变化;时区数据库版本仍应随证据保存。
atStartOfDay(zone) 按时区规则选择当天最早的有效时间,并非承诺所有地区的每一天都存在零点。Java 21 LocalDate.atStartOfDay 当前实验只验证指定日期和时区,没有穷举各地区历史规则,也没有实现本地时钟重复小时的用户输入消歧。
如果合同按当地日历日计费,应该保存相应日历口径与时区信息,并在计算处明确如何得到起止时刻。如果合同按经过时长计费,则使用时间轴上的差值,再单独规定不足一单位如何处理。把所有时间都存成 UTC,能够统一时刻表示,仍然不会自动补上计费日的定义。
金额包含币种,舍入发生在明确的位置
报价里的十元需要至少两个属性:数值和币种。实验中的 Money 使用 BigDecimal 与 Currency,并规定此教学模型只接受可精确表示为两位小数的金额。构造时使用 setScale(2, UNNECESSARY),多余位数如果需要舍入就直接拒绝。
1 | |
两笔人民币十元相加得到二十元。人民币与美元相加则拒绝,不能只看数值恰好相同。货币转换还需要汇率、适用时间、报价方向与舍入政策,本实验没有加入这些规则。两位小数也是 CNY、USD 示例的局部选择,不是所有币种的通用定义。
BigDecimal 能保留十进制精度,不能代替舍入决定。对从字符串构造的 10.005,显式采用 HALF_UP 得到 10.01,采用 HALF_EVEN 得到 10.00,实验分别断言这两个结果。原始三位小数直接构造 Money 会抛出 ArithmeticException,要求调用处先作出有名称的舍入选择。Java 21 BigDecimal API
舍入的位置也会影响合计。每项费用先舍入再求和,与全部精确求和后只舍入一次,并非天然等价。当前代码没有税费、分摊或账单明细,所以没有声称解决这些问题。若后续增加明细,应把“在哪一层舍入”写入费用规则,再用能区分两种算法的样例验证。
金额的相等还涉及刻度。Java 的 BigDecimal.equals 同时比较值和 scale,而 compareTo 比较数值大小;10.0 与 10.00 在前者下不同、在后者下相等。实验的 Money 先统一到两位,从而让同币种同值的两个记录得到一致的记录相等结果。这是有意识的标准化,不应该由集合去重时偶然决定。Java 21 BigDecimal.equals
数量需要单位,名称不能代替身份
“租了二”无法说明二台还是二小时。实验另外定义 Quantity(value,unit),仅支持 hour 与 piece 两个单位标记;同单位相加允许,不同单位相加拒绝。它故意保持很小,没有自动把天换成小时,也没有建立完整量纲系统。
这种选择暴露出后续约束的位置:件数是否必须为整数、能否是负数、计费小时是否允许小数,需要分别确认。当前 Quantity 没有落实这些业务限制,不能因为它拒绝了小时加件数,就声称“数量模型已经完整”。如果业务只有设备台数,带非负整数约束的专用类型可能比通用数量类型更清楚。
设备身份也不能靠名称判断。两台相同型号、相同名称的设备仍有不同 EquipmentId;同一台设备换了显示名称,身份不应随之改变。实验用三个不可变修订记录说明这个区别:E1 的第一版名称为 camera,第二版改为 camera-renamed;E2 也叫 camera,却是另一台实物。
比较 E1 两个修订时,ID 相等、整个记录不相等;比较 E1 与 E2 的第一版时,名称相等、ID 不相等。Java record 默认比较全部组件,适合比较完整值或修订快照,不能直接把这个结果当成所有领域对象的身份判断。调用处应明确正在问“同一设备”还是“同一份记录”。
保留修订与回答历史问题
实验把 E1 的两份记录都保存在不可变列表中,断言旧标签仍是 camera,新版的版本号为二。这样能够说明覆盖旧值与保留修订的差别:如果只留下当前名称,历史使用过的名称就无法从当前对象恢复。
但“保留两条记录”还没有决定过去的订单应该显示哪一个名称。若需求是按签约时名称展示,订单可能引用具体修订或保存快照;若需求是统一显示最新名称,则应按稳定身份解析当前资料。这两种读法服务于不同需求,都不能从一个 version 字段自动得出。
本例的版本号没有唯一性检查,没有有效时间、记录时间或数据库约束,也未处理同时修改。列表断言只证明这次内存运行保留了旧记录,不能证明审计历史不可篡改,更不能宣称具备双时态查询能力。需要这类能力时,应先列出历史查询问题,再确定新增哪些事实。
运行和值模型的边界
Java 21 可用时,在仓库根目录运行:
1 | |
脚本支持 JAVA_HOME,将共享的 rental-core/RentalDesk.java 与 labs/07/ValueCheck.java 编译到临时目录。第07篇实验包 包含同样的仓库相对路径,解压后可脱离原 checkout 运行,无需数据库或第三方库。
本次执行十八项检查,退出码为零,环境、原始输出和命令保存在 examples/software-modeling/evidence/07/。检查覆盖相邻及非法租期、时区日界、币种混算、显式舍入、数量单位与设备修订。异常测试同时要求正确的异常类别,避免空指针等无关失败被算作规则生效。
一个全部采用字符串、整数和小数的实现也能工作,但它需要在每个入口重复这些含义。小型值类型可以集中拒绝非法组合;为只有一种用途的数据强行设计通用时态、货币和量纲框架,又会增加当前没有验证需求的机制。现阶段更明确的做法,是保留已经检验的约束,并把尚未确定的计费日、转换规则和历史展示政策列为待决事项。





