本节目标:能用模式变量替代「判断 + 强转」的两步写法,判断清楚模式变量在哪些位置一定可用,并会用
when守卫把类型判断和业务条件写进同一个case。
适用版本:JDK 25 LTS(Temurin 25.0.5);instanceof模式在 JDK 16 定稿,switch 模式与when守卫在 JDK 21 定稿。
上一节留下的问题
5.2 把命令分发整理成了枚举 + switch 表达式。但命令后面的参数仍然是「一整条字符串」,search 只能按标题或作者做子串匹配。真实使用里,用户会想按年份筛:
search year:2006
search after:2006
search available:true
同一个 value,可能是 Integer、Boolean 或 String。解析器只能把它放进 Object,由使用方判断真实类型。没有模式匹配时,这段判断长这样:
if (value instanceof Integer) {
int year = ((Integer) value).intValue();
// 用 year
} else if (value instanceof String) {
String text = (String) value;
// 用 text
}
「判断一次类型,再强转一次」是重复劳动,而且两次之间可能插进别的代码,读的人要自己确认强转一定安全。instanceof 模式把这两步合成一步。
从强制转换到模式变量
模式匹配的写法是:
if (value instanceof Integer year) {
System.out.println(year + 1);
} else if (value instanceof String text) {
System.out.println(text.length());
}
Integer year 是一个类型模式:如果 value 是 Integer,就把它转成 Integer 并绑定到新变量 year,同时整个表达式为 true;否则不绑定,表达式为 false。变量 year 的类型就是 Integer,不需要再写 (Integer) value。
这带来三个直接好处:
- 没有重复的类型名。 改类型时只改模式那一处,不会出现「判断改了、强转没改」。
- 没有中间状态。 判断和绑定是同一步,不存在「判断为真但强转写错类型」的窗口。
- 变量名表达含义。
year比((Integer) value)更容易读。
null 不会匹配任何模式
instanceof 对 null 一律返回 false,模式变量也不会被绑定。本机 JDK 25 运行:
Object o = null;
System.out.println(o instanceof String);
System.out.println(o instanceof String s);
false
false
第二行没有抛 NullPointerException,因为 instanceof 的定义就是「非空且类型匹配」。这一点和调用 o.getClass()、o.toString() 不同,后者对 null 会直接抛异常。所以「用 instanceof 做类型判断」天然是 null 安全的,但判断完之后如果逻辑需要非空,仍然要单独处理。
模式变量不是「提前声明」
模式变量只在模式匹配成功的那条路径上存在。编译器用流作用域(flow scoping)分析它:不是看变量声明在哪个花括号里,而是看「执行到这一行时,是否一定已经匹配成功」。
这就解释了下面两种写法为什么一个能编译、一个不能。
流作用域:什么时候变量一定存在
&& 右侧可以用
if (value instanceof String text && !text.isBlank()) {
System.out.println("text = " + text);
}
&& 从左到右求值,只有左边为 true 才会看右边。左边为 true 意味着 text 已经绑定,所以右边可以安全使用。这是最常见的组合:先判断类型,再判断内容。
|| 右侧不能用
if (value instanceof String text || text.isEmpty()) {
System.out.println(text);
}
这段编译失败。把它抽成一个最小方法,本机 JDK 25 的报错是:
错误: 找不到符号
return o instanceof String s || s.isEmpty();
^
符号: 变量 s
原因很直接:|| 左边为 false 时也会看右边,而左边为 false 恰恰意味着「不是 String 或为 null」,text 没有绑定。编译器不会允许一个「可能不存在」的变量出现在这里。
提前返回之后可以用
卫语句的写法同样成立:
static int length(Object o) {
if (!(o instanceof String s)) {
return -1;
}
return s.length();
}
本机 JDK 25 调用 length("library") 输出 7,调用 length(42) 输出 -1。条件取反后提前返回,剩下的代码路径一定满足「o 是 String」,所以 s 在 return s.length() 处可用。
这条规则让「先排除、再使用」的写法不需要额外嵌套,也是 5.1 卫语句风格在类型判断上的延伸。
作用域到语句块结束为止
模式变量不是方法级变量。它从绑定点开始,到所在语句块结束:
if (value instanceof String s) {
System.out.println(s); // 可用
}
System.out.println(s); // 编译失败:找不到符号
如果确实需要把值带到外面,就在块内赋给一个事先声明的变量,或者把处理逻辑整体放进块里。不要为了「让变量活得更久」而把整段代码塞进 if,那会把 5.1 讲的缩进问题带回来。
不能和已有局部变量重名
static void test(Object o, String s) {
if (o instanceof String s) {
System.out.println(s);
}
}
本机 JDK 25 报:
错误: 已在方法 test(Object,String)中定义了变量 s
模式变量是局部变量,遵循局部变量不重名的规则。字段不受影响:字段 title 和模式变量 title 可以共存,块内引用的是模式变量。这种遮蔽能编译,但读起来容易混淆,实际代码里换一个名字更省事。
守卫:把业务条件写进 case
instanceof 模式解决「一个值是不是某种类型」,switch 模式解决「根据类型和值选一条路」。语法是在 case 里直接写类型模式:
static String describe(Object value) {
return switch (value) {
case null -> "null";
case String s when s.isBlank() -> "blank";
case String s -> "text(" + s.length() + ")";
case Integer i when i < 0 -> "negative(" + i + ")";
case Integer i -> "int(" + i + ")";
case Boolean b -> "bool(" + b + ")";
default -> value.getClass().getSimpleName();
};
}
when 后面是守卫(guard),本质是一个布尔表达式。只有类型匹配 且 守卫为 true,这个分支才执行。没有 when 的 case String s 是兜底的同类型分支。
顺序在这里很重要:
case String s when s.isBlank()必须写在case String s前面。反过来写,后面的带守卫分支永远不可达,编译器会报「此 case 标签由前一个 case 标签支配」。- 带守卫的标签不支配同类型的无守卫标签,所以两者可以共存。
default仍然必要,因为Object的子类型是开放的,编译器无法枚举完。
守卫不是「在 case 里写 if」。它只能是一个表达式,不能有语句块,也不能赋值;需要多步判断时,把条件抽成一个返回 boolean 的私有方法,在 when 里调用。这样 case 仍然是一张表,逻辑细节留在方法里。
支配规则会拦住写错的顺序
如果把宽类型放在窄类型前面:
return switch (o) {
case Object x -> "object";
case String s -> "string";
default -> "other";
};
本机 JDK 25 报两条错:
错误: switch 有一个无条件模式和一个 default 标签
错误: 此 case 标签由前一个 case 标签支配
case Object x 能匹配所有非空值,后面的 case String s 永远轮不到,所以被判定为「被支配」。编译器在这里帮了忙:它不接受一段逻辑上不可达的分支,而不是等到运行时才发现某个 case 是死代码。
在 library 里落地
把搜索结果从「整串字符串」升级成「键值对 + 类型化值」,需要三个小方法。
第一步:把 token 解析成合适的类型
private static Object parseValue(String token) {
if (token.equals("true") || token.equals("false")) {
return Boolean.valueOf(token);
}
if (isDigits(token)) {
return Integer.valueOf(token);
}
return token;
}
private static boolean isDigits(String token) {
if (token.isEmpty()) {
return false;
}
for (int i = 0; i < token.length(); i++) {
if (!Character.isDigit(token.charAt(i))) {
return false;
}
}
return true;
}
返回值声明为 Object,是因为这里刻意要保留「类型不确定」这个事实。调用方必须用模式匹配把类型收窄,而不是假设它一定是某种类型。isDigits 用 5.1 的 for 循环逐字符判断,避免在这里引入正则——正则第 15 章才系统讲。
第二步:按类型分派到不同的比较逻辑
private boolean matches(Book book, String key, Object value) {
if (value instanceof Integer year) {
return switch (key) {
case "year" -> book.publishedYear == year;
case "after" -> book.publishedYear > year;
case "before" -> book.publishedYear < year;
default -> false;
};
}
if (value instanceof String text) {
return switch (key) {
case "title" -> book.title.contains(text);
case "author" -> book.author.contains(text);
case "isbn" -> book.isbn.equals(text);
default -> false;
};
}
if (value instanceof Boolean available) {
return key.equals("available") && book.available == available;
}
return false;
}
这段代码的结构值得注意:
- 先按类型分三层,再在每层里按 key 分派。 如果反过来先按 key 分派,
year分支里还要再判断值是不是Integer,判断会散落在每个 key 上。 - 每层 switch 都有
default -> false。 key 是String,编译器无法穷尽,必须有 default;返回false表示「这个 key 不认识」,和「不匹配」在搜索结果上等价。 - 最后的
return false是防御。parseValue目前只产出三种类型,但方法签名接受任意Object;将来加了Long、LocalDate,这里会安静地返回false,而不是抛ClassCastException。是否要把「不支持的类型」和「不匹配」区分开,取决于调用方要不要提示用户;本章先保持简单。
第三步:让 add 的年份也走同一套判断
Book book = new Book(fields[0], fields[1], fields[2]);
if (fields.length == 4) {
Object year = parseValue(fields[3]);
if (year instanceof Integer value && value > 0) {
book.publishedYear = value;
} else {
System.out.println("invalid year: " + fields[3]);
return;
}
}
books.add(book);
这里同时用了模式变量和 &&:year instanceof Integer value && value > 0。左边绑定 value,右边拿它做范围检查,两步写在同一个条件里,不需要先强转再比较。如果输入是 nope,parseValue 返回 String,模式不匹配,走 else 打印错误,书不会被加入。
跑一遍
run 沿用 5.2;dispatch 收敛到 ADD、SEARCH、REPORT、QUIT 四个分支,其中 SEARCH 改为调用新的 handleSearch。编译后回放:
javac -d out src/com/plume/library/*.java
java -cp out com.plume.library.LibraryApp
输入与输出(本机 JDK 25):
add 978-7-111-1|Effective Java|Joshua Bloch|2008
added: Effective Java
add 978-7-115-2|Java Concurrency in Practice|Brian Goetz|2006
added: Java Concurrency in Practice
add 978-7-118-3|Java Puzzlers|Joshua Bloch|2005
added: Java Puzzlers
add bad|year|test|nope
invalid year: nope
search author:Joshua
hit: Effective Java (2008)
hit: Java Puzzlers (2005)
hits = 2
search year:2006
hit: Java Concurrency in Practice (2006)
hits = 1
search after:2006
hit: Effective Java (2008)
hits = 1
search available:true
hit: Effective Java (2008)
hit: Java Concurrency in Practice (2006)
hit: Java Puzzlers (2005)
hits = 3
report
books = int(3)
available = int(3)
newest = text(14)
bye
report 里的 describe 就是前面那个带守卫的 switch。books 和 available 都是 Integer,走 case Integer i -> "int(" + i + ")";newest 是 String,长度 14,走 case String s -> "text(" + s.length() + ")"。同一个方法处理三种类型,没有一处强转。
常见错误与边界
在 || 右侧使用模式变量。 前面已经给出报错。记住规则:&& 右侧可用,|| 右侧不可用,因为前者要求左边为真,后者可能在左边为假时求值右边。
把模式变量带出作用域。 if 块结束后变量就不可见,报「找不到符号」。需要跨块使用时,在块外声明变量并在块内赋值,或者把使用逻辑移进块内。
用 instanceof 判断后忘记处理 null。 o instanceof String s 对 null 返回 false,不会进分支;但如果后面直接写 s.length() 而没进过判断,仍然是 NPE。模式匹配只说明「进了分支就非空且类型正确」,不说明「变量一定被赋值」。
认为 case null 可以省略。 switch 的选择器为 null 时,没有 case null 会抛 NullPointerException。case null 是显式分支,不是自动兜底;case null, default -> 可以把两者合并,但含义仍然是「null 走这里」。
忽略数字的范围。 isDigits 只确认每个字符都是数字,不确认位数;parseValue("99999999999") 会在 Integer.valueOf 抛 NumberFormatException。本章先让异常暴露,第 13 章会把它翻译成用户能看懂的提示。
把宽类型写在窄类型前面。 case Object x 放在 case String s 前面会让后者被支配,编译失败。顺序应该从具体到宽泛,最后才是 default。
在守卫里做赋值或调用有副作用的方法。 守卫是匹配条件的一部分,只应该做纯判断;不要修改状态、不要打印、不要写文件。需要记录「为什么没匹配上」时,把信息放进分支体,而不是塞进 when。
小结
instanceof模式把「判断类型 + 强转」合成一步,变量类型就是模式里的类型,不需要再写强制转换。- 模式变量使用流作用域:
&&右侧、if的 then 块、取反后卫语句之后可用;||右侧和块外不可用。 instanceof对null返回false,模式变量不会绑定,也不会抛 NPE。- switch 模式用
when写守卫;带守卫的分支要放在同类型的无守卫分支前面,宽类型不能放在窄类型前面。 - 对
Object选择器的 switch 仍然需要default,因为子类型集合是开放的;case null要单独写。 library的parseValue保留「类型不确定」,matches用三层模式匹配把它收窄,add的年份校验用&&同时完成绑定和范围检查。
第 5 章的控制流到这里告一段落:分支、循环、switch、模式匹配都已经能用在 library 上。第 6 章换一个维度——把 LibraryApp 里越堆越多的方法拆开,先讲清楚方法签名、重载和参数到底是怎么传递的。
阅读导航:上一节:5.2 switch 表达式与 yield · 下一节:6.1 方法签名、重载与可变参数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。