《Java 编程入门》5.3 instanceof 模式匹配与守卫

从 library 搜索参数的混合类型问题出发,讲清 instanceof 模式变量的流作用域、&& 右侧与提前返回后的作用范围,以及 switch 模式匹配里的 when 守卫和支配规则;最后用 parseValue 与 matches 把 year:2006、after:2006 这类过滤条件安全地落到 Book 字段上。

本节目标:能用模式变量替代「判断 + 强转」的两步写法,判断清楚模式变量在哪些位置一定可用,并会用 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 方法签名、重载与可变参数 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发