价格等于门槛时归入哪一档

商品价格以整数分保存,活动档位定义为“满 100 分但不足 200 分”。这不是两个孤立数字,而是包含下端点、排除上端点的区间 [100,200)。如果配置接口只保存 lower=100、upper=200,而把端点规则留给各处 if 判断,那么导入校验、页面展示和价格查询可能采用不同规则。

Range 将端点和值域比较规则组合为一个对象;RangeSet 表示若干区间的并集;RangeMap 把互不重叠的区间关联到值。三种结构处理不同的问题:属于哪个范围、是否属于任意合法范围、所在范围采用哪条规则。活动价格场景还必须确定重叠配置是拒绝、覆盖还是叠加,容器的默认行为不能替代业务决策。

本篇固定 Guava 33.5.0-jre,Java 8 API,JDK 8 与 JDK 21 分别验证。完整测试源码含五组实验,RUN.md给出复跑命令。价格使用整数分是本实验的业务约束,不讨论浮点舍入。金额进入范围判断前需要完成统一单位和范围校验。

开闭端点不只影响打印形式

Range 契约基于 Comparable 排序定义边界。closedOpen(100,200) 包含 100 和 199,不包含 200;closed(100,200) 则还包含 200。相邻档位通常选择统一的半开形式,使公共边界恰好归入一边,但这是业务规则选择,不是所有区间都必须使用半开形式。

表达式 是否可构造 与空区间相关的含义
closed(100,100) 是 单点 100
closedOpen(100,100) 是 空区间
openClosed(100,100) 是 空区间
open(100,100) 否 构造抛出 IllegalArgumentException

不能因为端点数值相同就统一拒绝或统一视为空。一个单点价格规则与一个没有任何取值的规则,在业务上并不相同。上游 RangeTest分别验证 singleton 与两种空区间,本文实验也独立覆盖合法空区间和非法的双开同端点情况。

端点还必须具有稳定比较语义。Range 使用 compareTo 定义顺序,不会替业务解释数字单位,也不会修复一个不满足排序约束的 Comparable。金额若用 BigDecimal,需要清楚数值比较与 equals 对 scale 的处理并不相同;若用整数分,则需要清楚允许的最小单位和溢出界限。本文只对固定 Integer 输入给出实测结论。

Integer 类型不自动提供离散域知识

Range.open(3,4) 的 isEmpty 返回 false,但 3 和 4 之间没有任何 Integer 值。这一现象不矛盾:Range 使用一般的端点比较结构判断空区间,不知道相邻整数之间不存在后继值。Comparable 只提供先后比较,不提供“下一个值”或“前一个值”的运算。

需要枚举整数域时,可以将同一区间交给 ContiguousSet.create(range, DiscreteDomain.integers()),结果为空;也可以用整数域做 canonical,得到 [4,4)。五组实验中的第一组同时断言 Range.isEmpty 为 false、ContiguousSet.isEmpty 为 true,以及 canonical 的具体结果。

1
2
3
一般有序区间: (3,4)       isEmpty = false
整数离散域: 没有合法元素 ContiguousSet.isEmpty = true
整数规范形式: [4,4) isEmpty = true

DiscreteDomain为具体类型补充后继、前驱等信息。这里不能直接把“每隔一分是合法价格”推广成所有 BigDecimal 组成的域,也不能通过 Double 的数值近似偷偷替代货币精度规则。先决定价格是整数分、固定小数位金额还是其他离散单位,再选择规范化方式。

第一项可迁移模式是区分顺序结构与业务值域。范围能够比较端点,不代表它知道业务中可实际出现的每个值。输入验证若只使用 Range.isEmpty,可能接受在整数业务中没有任何有效价格的配置。

连通不等于存在共同价格

[0,10) 与 [10,20) 连通,但交集是空区间 [10,10)。连通意味着两个区间可以共同容纳一个可能为空的交集;它不要求存在一个同时属于两者的价格。(0,10) 与 (10,20) 则不连通,因为数值 10 这一点形成了两边都没有覆盖的边界空隙。

调用 intersection 之前,如果两个区间不连通,结果不是一个任意选择的空 Range,而是 IllegalArgumentException。实验分别验证相邻半开区间、共同边界均开放的区间,以及相互分离区间的异常。对冲突检测,若要求“至少有一个共同取值”,只检查 isConnected 会误报相邻档位。

在整数域中还要再加一道判断:即使一般区间交集形式非空,离散域中也可能没有实际元素。因此,离散价格配置的冲突判定需要使用统一规范形式,或进一步在离散域检查交集。业务所说的“相邻”也必须带单位:整数区间 [1,2] 和 [3,4] 没有遗漏整数,却不是一般端点结构意义上的连通区间。

RangeSet 合并覆盖范围,删除可以产生两段

合法价格范围先加入 [0,10),再加入 [10,20)。TreeRangeSet 将它们合并为 [0,20),因为 RangeSet 关注覆盖的点集,不保留“原来来自两条配置”这一历史。若来源、审批人或活动 ID 必须保留,单纯 RangeSet 已经丢失这部分信息。

从 [0,20) 删除闭区间 [5,15],结果为 [0,5) 与 (15,20)。端点 5、15 被明确删除,所以左段不能保留 5,右段不能保留 15。实验既断言 asRanges 返回的两段,也逐点断言 5、15 不属于结果而 16 属于结果,避免只检查打印字符串。

TreeRangeSet 的实现按下边界组织区间。add 查找连通的新旧区间,确定合并端点,再替换中间范围。remove 则检查边界附近,保留左右残段。开闭信息保留在 Cut 中,操作过程没有把端点粗略转换成两个 int 比大小。

补集、子范围等接口也可能是活动视图。补集覆盖的是整个可比较域中未被集合包含的部分,不只是页面展示的价格窗口。如果商品价格仅允许非负且有上限,应该把业务窗口明确纳入查询,而不是把 unrestricted complement 直接解释为全部允许价格。

RangeMap 的 put 会覆盖交叠部分

价格 [0,100) 默认使用 base 规则,再将 [20,40) 配置为 promo。TreeRangeMap 的结果为三段:[0,20)=base、[20,40)=promo、[40,100)=base。它不会拒绝交叠,也不会让一个价格同时返回 base 和 promo;后写入的区间覆盖交叠部分。

接着删除 [30,50),结果变为 [0,20)=base、[20,30)=promo、[50,100)=base。查询 29 返回 promo,30 与 49 返回 null,50 返回 base。这里 null 表示没有匹配的映射;非空范围不能写入 null 值,实验对此也保留异常断言。

操作 保留下来的映射
put [0,100) 为 base [0,100)=base
put [20,40) 为 promo [0,20)=base、[20,40)=promo、[40,100)=base
remove [30,50) [0,20)=base、[20,30)=promo、[50,100)=base

固定版本 TreeRangeMap.put对非空输入先执行 remove(range),保留旧条目伸出新范围之外的残段,再插入新条目。按点查询时,先在下边界索引中找候选 entry,再检查候选范围是否真的包含该点。只找到一个前驱下界,还不能证明价格在范围之内,因为映射中可能存在空洞。

这也形成第二项可迁移模式:覆盖策略必须先与业务冲突策略对齐。如果两条折扣同时生效属于配置错误,应在写入之前检测并拒绝,不能写进 RangeMap 后再期望找回被覆盖内容。若折扣需要叠加,则一个区间映射到单个规则值也不足以表达完整语义。

相同值合并与活动窗口

连续调用普通 put,将 [0,10) 和 [10,20) 都关联到 base,映射仍可保留两段。需要合并连通且值相等的范围时,Guava 33.5.0-jre 提供 putCoalescing。因此,“RangeMap 永远不会合并相邻映射”只适合旧接口语境或对普通 put 的局部描述,不能作为这个版本的完整结论。

实验先验证普通 put 后条目数为 2,再用 putCoalescing 写入 [10,20)=base,条目数变为 1。固定版本 TreeRangeMapTest还专门覆盖了合并相邻值和空范围触发合并的边界;本实验没有把上游全部测试当作已经本地执行的测试。

subRangeMap([5,15)) 返回活动窗口,原映射合并后窗口仍能读取 base;通过窗口删除 [5,10),原映射的价格 5 随之变成缺失,而价格 10 仍匹配 base。向窗口写入超出窗口范围的 [0,6) 会抛出异常。视图限制的是可操作范围,并未建立独立副本。

这些可变结构都不应仅因包装成 Range API 就被当作并发配置中心。若查询线程需要稳定的一组规则,应在完成校验后构造快照,再按应用的同步方式发布。本文没有运行并发发布实验,也没有据此宣称任意共享修改安全。

Cut 将同一端点的两侧区分开

价格 20 的左侧边界与右侧边界不是同一位置。要表达“小于 20”和“小于等于 20”,范围需要区分在 20 之前截断还是在 20 之后截断。Cut 的固定实现分别建模端点下方、端点上方以及无界位置;Range 保存下 Cut 与上 Cut,将开闭信息转化为可比较的边界对象。

删除后准确生成 [0,5) 与 (15,20),也可以从边界移动解释。删除范围的下边界成为左残段上界,上边界成为右残段下界;端点是否保留随边界对象一起移动。实现不必对所有开闭组合分别写一套业务 if,也不需要把整数端点减一来模拟开放边界。

“端点减一”在整数的小范围样例中可能得到同样成员集合,却不适合一般 Comparable 域,也容易在最小值处溢出。对时间、金额和版本号等范围,手工推导前驱值还可能混入单位换算错误。需要离散规范化时应显式引入对应的域,让前驱后继问题在一个位置处理,而不是散落在每次插入和删除中。

业务配置仍应保留必要的原始信息。例如输入写成闭区间,保存时规范化成半开区间,两者可以在整数域拥有相同成员,但错误提示和审计需要区分用户原文与内部表示。Range 负责成员关系,配置实体负责来源和解释,这两种职责不能通过一个 toString 结果同时承担。

区间删改后的验收应同时检查表示形式和端点成员关系,避免只比较格式化字符串。

实验结果与替代条件

Chapter14Test 五项测试在 JDK 8 与 JDK 21 都得到 Tests run: 5, Failures: 0, Errors: 0, Skipped: 0。验证对象包括端点、离散空集、连通、删除分裂、覆盖保留残段、普通写入与合并写入、子范围修改和非法输入。没有对不同区间数量做性能基准,源码索引结构不能替代延迟测试。

只有一个固定门槛时,明确的比较表达式已经足够;少量互斥档位也可以用经过边界测试的有序列表。规则需要持续做合并、裁剪、删除或范围覆盖时,专用区间结构能够集中这些操作的语义。选择条件应来自需要维护的区间运算,不能来自 API 数量。

手算题:把删除范围 [5,15] 改为 (5,15),剩余范围应为 [0,5] 与 [15,20)。改动练习是将所有价格配置输入统一为整数分的 canonical 形式,在保存前拒绝没有整数取值的区间,并保留输入原文用于报错。验收同时检查合法端点、空区间和重复覆盖,不能只检查一个普通价格。

判断关键词 可迁移模式 具体选择
空区间、整数分 顺序结构与业务值域分开 Range 加 DiscreteDomain 验证
相邻、交叠 明确是否要求非空交集 连通检查后判断交集与离散成员
规则冲突 容器覆盖策略服从业务 拒绝、覆盖或叠加先定义
子范围窗口 视图限制范围但共享状态 需要固定规则时建立快照

前篇:惰性集合视图。系列起点:可复现基线。