Java的高端路---浅谈模式匹配

#牛客AI配图神器#

新版任你发,我用Java8 想必大多数Java开发都不会对这句话感到陌生,随着时代的进步,不知不觉间Java已经发布到20+的版本了,著名的SpringBoot框架也已经发布了最新的3.0版本,并且最低支持的JDK版本是17。这就不由得让人思考:

  • 为什么SpringBoot3.0最低支持的版本是17?
  • Java17到底有什么魅力?

当然我也调研了很多追求技术的同事面对Java17的看法,很多同事都对Java17完善的package访问权限管理非常感兴趣。用他们的话说结合一些设计模式可以很好的针对自己当前的业务开放的接口做控制。同事A:“有些接口是我领域模型中内部使用的,总有人乱调用,这下有了新的包管理方式,我看你们怎么调用!”同事B:“Java17引入了ZGC,让GC变得更加强大,并且引入了GraalVM,可以让我的Java程序打包成二进制可执行文件,节省服务器资源的同时更加契合云原生时代。”

听了大家的想法我获益匪浅,同时我也在思考,Java17作为一个新时代的Java产品,除了上述两个点可以更加契合云原生时代之外,在语法层面是否吸取过当下较为流行的编程语言的方案呢

有段时间出于兴趣我稍微学习了一下rust的语法,里面有一个 match 关键字让我用起来欲罢不能,模式匹配这个概念便深入我的脑海,以至于我经常在想:什么时候我可以在公司的Java代码里面写模式匹配呢?

而Java17带来的三个关键字似乎悄悄的改变了我们Javaer的编码习惯

  1. record
  2. sealed
  3. permits

同时,我将介绍一个Java在语法高端之路上必不可少的一个概念-----> 模式匹配

模式匹配的概念与作用

模式匹配(Pattern Matching)是编程语言中的一个重要概念,它提供了一种基于数据结构形态对其进行检查和解构的简洁方式,并允许开发者通过分支逻辑高效地处理不同的数据形态。在现代编程语言(例如 Java、Rust、Kotlin 等)中,模式匹配不仅提升了分支逻辑的表达性,还通过引入类型系统特性(例如封闭性和穷尽性检查)大幅提高代码的安全性和维护性。

在业务开发中,模式匹配解决了很多优雅表达复杂逻辑的问题,但需要注意,它并非绝对必要的特性。Java 在很长一段时间中并没有原生的模式匹配机制,但开发者依然可以通过一些替代方式(比如 instanceof 判断)以及合理的封装,编写出清晰的代码。然而,模式匹配的引入无疑让 Java 开发迈入了更加现代化的阶段。所以后续的模式匹配的案例中将结合三个Java17的关键字。

模式是什么?

模式匹配,简单的分一下词<模式,匹配>,所以我们最先要明确的就是:什么是模式?

在rust的语法中有这样一句话:rust的语法就是由模式和表达式构成的。简单的看一下下面的代码

fn main() {
    // 左边是模式 右边是表达式
    let a = 5; // 表达式 5 绑定在了 模式 a 上 这种叫简单的绑定操作
    let (x, y) = (1, 2); // 模式是一个(x,y)的元组,表达式是一个(1,2)的元组。这种叫做模式解构,对表达式进行解构
}

该代码 a就是模式 5就是表达式; (x,y)是一个模式,(1,2)是一个表达式,并且对该模式进行了匹配。

所以模式本质上就是我们对数据抽象的建模,是一种用来匹配数据结构或值的特定形式或规则,它定义了如何去识别和处理不同类型的数据,就像在 Java 中用特定的规则去匹配不同的对象状态或数据值,从而根据匹配结果执行不同的操作。

模式匹配是什么

模式匹配的核心思想是:

根据数据的形态或类型,匹配符合特定模式(Pattern)的数据,并绑定数据到变量,同时执行逻辑操作。

更具体地说:

  1. 模式匹配依赖于数据的结构化表示(如类、结构体或记录)。
  2. 它提供了一种简洁优雅的方式将数据模型的解构、值提取和业务逻辑处理结合在一起。
  3. 在现代编程中,模式匹配常与数据类型系统(如 recordenum)紧密结合,同时保证分支逻辑的穷尽性检查,避免遗漏分支造成的逻辑缺陷。

record是什么

record关键字是Java14就出现的一个关键字。record 本质上是一种特殊的类,专门用于封装不可变的数据。使用 record 定义的类会自动拥有构造函数、访问器方法(getter)、equals()、hashCode() 和 toString() 方法,极大地减少了样板代码。核心概念是: 不可变的数据

public record User(int userId,String userName) {}

record成功的完善了Java的封闭性。看了record关键字的作用,我们似乎很容易在业务代码中给它一个合理的定位,诸如作为PO对象,一对一的映射关系型数据库的每一条记录。

例子:点(Point)的描述

假设我们有一个二维平面的点对象 Point,需要根据 Point 的具体位置来判断它的描述:

  • 如果点在原点 (0, 0),打印 "Origin";
  • 如果点在 X 轴上,打印 "X-Axis";
  • 如果点在 Y 轴上,打印 "Y-Axis";
  • 否则打印点的具体坐标。

在没有模式匹配的 Java 中,这可能写成:

if (p.getX() == 0 && p.getY() == 0) {
   System.out.println("Origin");
} else if (p.getX() == 0) {
   System.out.println("Y-Axis");
} else if (p.getY() == 0) {
   System.out.println("X-Axis");
} else {
   System.out.println("Point: (" + p.getX() + ", " + p.getY() + ")");
}

而有模式匹配的 Java:

switch (p) {
    case Point(0, 0) -> System.out.println("Origin");
    case Point(0, var y) -> System.out.println("Y-Axis at " + y);
    case Point(var x, 0) -> System.out.println("X-Axis at " + x);
    case Point(var x, var y) -> System.out.println("Point: (" + x + ", " + y + ")");
}

模式匹配解决了什么问题

对于 Java 开发者来说,模式匹配虽然不是必需品,但它有效解决了以下几类常见问题:

问题 1:编写冗长的分支逻辑

在 Java 早期版本中,处理复杂的分支逻辑常常需要嵌套写多层 if-elseswitch,并且条件之间有可能互相关联或依赖,手工管理起来容易出错。例如,对于一个具有复杂字段的对象,你需要手动通过 instanceof 检查类型,并强制类型转换,然后提取对应字段:

if (obj instanceof Rectangle) {
    Rectangle r = (Rectangle)obj;
    System.out.println("Rectangle, Width: " + r.getWidth());
} else if (obj instanceof Circle ) {
    Circle c = (Circle)obj;
    System.out.println("Circle, Radius: " + c.getRadius());
} else if (obj instanceof Triangle) {
    Triangle t = (Triangle)obj;
    System.out.println("Triangle, Base: " + t.getBase());
} else {
    System.out.println("Unknown shape");
}

这种写法容易滋生样板代码(boilerplate),尤其当字段较多时,逻辑解构会变得非常繁琐。

模式匹配通过 将检查、转换和解构整合到一起,简洁高效地解决了这个问题,更清晰地表达逻辑:

switch (shape) {
    case Rectangle(double width, double height) -> System.out.println("Rectangle: " + width + "x" + height);
    case Circle(double radius) -> System.out.println("Circle: Radius = " + radius);
    case Triangle(double base, double height) -> System.out.println("Triangle");
}

问题 2:类型系统语义逻辑不完整

在 Java 没有引入模式匹配和封闭类型(sealed)之前,开发者在类型层次结构中很难保证逻辑的完整性。例如,传入一个 Shape 对象,预期子类是 RectangleCircleTriangle,而漏处理某个新添加的子类通常只能在运行时暴露问题。模式匹配结合穷尽性检查可以显著提高这一场景的安全性。

sealed和permits 关键字

sealed关键字是一个重要的特性,用于限制类或接口的继承或实现关系。

public sealed interface Shape permits Circle, Rectangle {
}

简单的说,sealed修饰的类只允许 permits后面的类继承。旧版本我们类的继承关系没有sealed和permits作为限制,所以导致类与类之间可以无限制的互相继承。这会导致当一个class X被创建好之后,class X的命运就不属于创作者了,因为所有开发者都可以继承X的同时却不被X本身管控。这样代码的可读性和可维护性会大大降低,尤其是子类过多的场景下很难处理全部的子类情况。同时这两个关键字进一步的完善了Java的穷尽性

问题 3:代码维护复杂性

当业务逻辑演化时,新逻辑需要逐一更新每个可能的处理分支(尤其是分支隐式依赖数据字段的情况)。模式匹配结合封闭类型和穷尽性检查,能够迫使开发者在新增逻辑时立即发现遗漏的处理逻辑。

模式匹配的核心是什么

  • 数据的结构化表示(例如 record 或 struct)。
  • 数据类型是否被明确限定(封闭性)。
  • 开发者如何处理分支和条件逻辑(穷尽性检查)。

数据的结构化表示

在模式匹配中,数据通常需要是一种结构化表示,常见表示形式包括:

  • record(Java)
  • struct(Rust)
  • 类似映射型的构造,如 Python match 配合解构。

Java 中之所以选择 record 这种特殊的类型而不是常规 class,是因为 record 的设计专注于数据,弱化了传统 Java 类的行为部分(如方法、多态等)。而模式匹配的目标就是简化对 “数据是什么” 的处理,与此相对,“数据能做什么” 并非主要关注点。

为什么不可变的在模式匹配的时候是好的

我们都知道rust中一句话叫“可变不共享,共享不可变”。这种编程哲学很好的诠释了多线程条件下不可变的数据是不需要锁开销的,可以极大的提高性能。而不可变性不只是模式匹配的前提,更是可以让模式匹配更加高效和安全

  1. record 是不可变的: 数据在匹配前后不会修改,模式匹配更加安全。
  2. record 是数据字段本质化的类型record 中的字段是构造函数的核心,编译器可以直接根据字段生成默认方法,并推导字段形态,便于静态匹配。
  3. 类的抽象过于灵活: 普通 class 可能包含继承、多态、隐藏字段等动态概念,破坏了模式匹配的明确性。

数据类型的封闭性

模式匹配需要清晰地知道数据的所有可能分支,而这依赖于类型是否封闭。如果类型是开放的:

  • 开发者无法知道潜在的派生子类;
  • 编译器也无法检查逻辑是否覆盖所有情况。

封闭性通过 Java 的 sealed 关键字实现,明确限制继承的类型范围。例如:

sealed interface Shape permits Circle, Rectangle, Triangle {}

穷尽性检查

穷尽性检查确保开发者覆盖了所有可能情况。没有穷尽性检查的情况下,遗漏某些分支可能导致潜在错误。而有封闭类型支持的情况,新增子类会强制要求更新相应逻辑。

函数式编程与模式匹配

很多函数式编程语言(Scala,Haskell)的核心特性都有模式匹配,因为模式匹配与函数式编程的概念是非常契合的,因为函数式语言强调的是:

  1. 不可变性
  2. 穷尽性
  3. 安全性
  4. 表达力 恰好模式匹配正是这些概念的延伸。 由于函数式语言中数据通常都是不可变的。并且函数式语言的数据建模形式大多是ADT即代数数据类型(Algebraic Data Type)。用其描述现实问题中的状态和结构。 ADT能很好的标识固定的集合的状态或分支,结合起来特别适合模式匹配的逻辑。

你知道ADT嘛

ADT 即Algebraic Data Type(代数数据类型),是通过代数的加法(Sum Type)和乘法(Product Type)构建数据的一种方式,强调数据模型的清晰与安全。

核心特性

和类型(Sum Type ---> OR 关系):

表示类型的值可以是多种变种的一种,诸如一个枚举类型

public enum HTTPResponse {
    SUCCESS,
    ERROR,
    ;
}

该类型的特点是编译和运行时是不可变的,想想也简单 对于Java代码来说,枚举这种都是编译的时候就知道有哪些东西的。

积类型(Product Type ---> AND 关系):

表示类型由多个部分组合而成,包含多个字段,诸如我们经常使用的PO对象这里用的Java14引入的record关键字

public record User(int userId,String userName) {
}

当然上述两种的数据类型组合也是没问题的

ADT表示一个数据结构

说了这么多的概念,我们来用所谓的代数式来表示一下我们的一个数据结构,这里我们距离就不用Java了,因为Java的ADT是模拟出来的,我们使用rust来表示一下下面的结构

积类型
struct User {
    user_id: i32,
    gender: bool
}

我们观察上述这个结构体,我们可以发现该User是由一个i32和一个bool类型组合而成,所以可以得到下面的代数式

Cardinality(User) = Cardinality(user_id) * Cardinality(gender) = Cardinality(i32) * Cardinality(bool)

这种建模的形式下,我们于是有了这样的一个rust语法

fn describe_user(user: User) {
    // 模式匹配到user_id和gender
    match user {
        User { user_id, gender: true } => println!("User ID: {}, Gender: Male", user_id),
        User { user_id, gender: false } => println!("User ID: {}, Gender: Female", user_id),
    }
}

fn main() {
    let user1 = User { user_id: 1, gender: true };
    let user2 = User { user_id: 2, gender: false };

    describe_user(user1); // 输出:User ID: 1, Gender: Male
    describe_user(user2); // 输出:User ID: 2, Gender: Female
}

此时我们会发现上述代码能做到在match下成功匹配到user_id当然,可能搞一个rust的样例大家会看的云里雾里的,于是我用新版本的Java来给大家模拟这个过程

由于java17是提出了模式匹配的概念,但是直到Java21才真正可以使用模式匹配,所以如果想尝试跑下面的程序建议直接使用Java21

// 创建一个User的record
public record User(int userId, boolean gender) {}

// 开始编写模式匹配的逻辑
// 利用 Java 的模式匹配,解构 User,根据 gender 判断用户的性别并输出。
public class Main {
    public static void describeUser(User user) {
        // 使用 Java 的模式匹配解构 User 的字段
        switch (user) {
            case User(int id, true) -> System.out.println("User ID: " + id + ", Gender: Male");
            case User(int id, false) -> System.out.println("User ID: " + id + ", Gender: Female");
        }
    }

    public static void main(String[] args) {
        User user1 = new User(1, true);
        User user2 = new User(2, false);

        describeUser(user1); // 输出:User ID: 1, Gender: Male
        describeUser(user2); // 输出:User ID: 2, Gender: Female
    }
}

和类型

而我们看和类型的一个数据结构

enum Direction {
    North,
    East,
    South,
    West
}

我们可以得到下面的代数式

Cardinality(Direction) = Cardinality(North) + Cardinality(East) + Cardinality(South) + Cardinality(West)

那么有了上面的建模,我们在写rust代码的时候就可以写出如下代码:

fn describe_direction(direction: Direction) -> &'static str {
    match direction {
        Direction::North => "You are heading North",
        Direction::East => "You are heading East",
        Direction::South => "You are heading South",
        Direction::West => "You are heading West",
    }
}

fn main() {
    let d1 = Direction::North;
    let d2 = Direction::East;
    let d3 = Direction::South;

    println!("{}", describe_direction(d1)); // 输出:You are heading North
    println!("{}", describe_direction(d2)); // 输出:You are heading East
    println!("{}", describe_direction(d3)); // 输出:You are heading South
}

我们会发现在match的时候,编译器会根据上面的建模为我们准确的分析每一个分支有哪些Direction,因为我们是加法原则,所以编译器会利用加法原则构建出来的模型进行每一个分支的判断。此时如果有遗漏的分支没有处理,编译器会通过报错或者告警的方式提醒我们。当然,rust代码可能看起来很恶心,我们换成Java的代码来看看

在 Java 中,sealed 类或接口是一种模拟 和类型 的方式,它允许开发者定义一个有限的子类集合,从而确保类型的封闭性。enum 也是一种特殊的 和类型(但限制较多),而 sealed class 提供了更灵活的功能。我们用 sealed interface 建模 Direction:和上述一样,虽然Java17已经支持该功能,但是建议直接用Java21

// 定义一个sealed的接口(密封的接口) 该接口保证了只有 permits关键字指定的子类才可以继承此接口
public sealed interface Direction permits North, East, West, South {}

// 定义四种档案类型的模拟 因为java的模式匹配只支持record类型
public record North() implements Direction {}

public record East() implements Direction {}

public record West() implements Direction {}

public record South() implements Direction {}

// Main方法如下
public class Main {
    public static String describeDirection(Direction direction) {
        // 利用switch关键字进行模式匹配
        return switch (direction) {
            case North n -> "You are heading North";
            case East e -> "You are heading East";
            case South s -> "You are heading South";
            case West w -> "You are heading West";
        };
    }

    public static void main(String[] args) {
        Direction d1 = new North();
        Direction d2 = new East();
        Direction d3 = new South();

        System.out.println(describeDirection(d1)); // 输出:You are heading North
        System.out.println(describeDirection(d2)); // 输出:You are heading East
        System.out.println(describeDirection(d3)); // 输出:You are heading South
    }
}

组合出一个更加复杂的类型来使用

我们来讲上面两种类型进行一个稍微复杂的组合,毕竟实际开发场景下不可能只是简单的加法和乘法从最经典的面向对象案例开始,一个Shape

// 定义一个point代表点的概念
struct Point {
    x: i32,
    y: i32
}

struct Circle {
    pub center: Point,
    pub radius: f64, // 浮点类型的半径
}

struct Rectangle {
    pub top_left: Point,
    pub bottom_right: Point,
}
enum Shape {
    Circle(Circle),
    Rectangle(Rectangle),
}

我们可以看一下Shape的代数式是怎么构成的

Cardinality(Shape) = Cardinality(Circle) + Cardinality(Rectangle)
        = (Cardinality(Point) * Cardinality(f64)) + (Cardinality(Point) * Cardinality(Point))
        = (Cardinality(i32) * Cardinality(i32) * Cardinality(f64)) + (Cardinality(i32) * Cardinality(i32) * Cardinality(i32) * Cardinality(i32))

Cardinality(Shape) = (Cardinality(i32) * Cardinality(i32) * Cardinality(f64)) + (Cardinality(i32) * Cardinality(i32) * Cardinality(i32) * Cardinality(i32))

代数式构建好之后我们发现似乎这个看起来复杂的数据结构本质上也无非是一些基础结构的组合罢了。我们直接用Java代码来执行这个过程吧

// 首先定义一个Point的 record
public record Point(int x,int y) {
}

// 定义一个抽象的 封闭性良好的Shape接口
// 该Shape接口指定了子类只能有Circle和Rectangle 这保证了封闭性
public sealed interface Shape permits Circle, Rectangle {
}

// 定义一个子类是Circle
// Cardinality(Circle) = Cardinality(Point) * Cardinality(int) = Cardinality(int) * Cardinality(int) * Cardinality(int)
public record Circle(Point center, int radius) implements Shape {
}

// 定义一个子类是Rectangle
// Cardinality(Rectangle) = Rectangle(Point) * Rectangle(Point) = Cardinality(int) * Cardinality(int) * Cardinality(int) * Cardinality(int) * Cardinality(int)
public record Rectangle(Point topLeft, Point bottomRight) implements Shape {
}

// 开始写一个函数去计算面积
public class Main {

    public static double calculateArea(Shape shape) {
        // 模式匹配处理不同的形状
        return switch (shape) {
            // PI * r^2
            case Circle c -> Math.PI * c.radius() * c.radius();
            // 矩形就是计算长和宽 然后 面积 = 长 * 宽
            case Rectangle r -> {
                double width = Math.abs(r.bottomRight().x() - r.topLeft().x());
                double height = Math.abs(r.bottomRight().y() - r.topLeft().y());
                yield width * height;
            }
        };
    }

    public static void main(String[] args) {
        Shape circle = new Circle(new Point(0, 0), 5);
        Shape rectangle = new Rectangle(new Point(0, 0), new Point(3, 4));

        System.out.println("Circle Area: " + calculateArea(circle));       // 输出:Circle Area: 78.53981633974483
        System.out.println("Rectangle Area: " + calculateArea(rectangle)); // 输出:Rectangle Area: 12.0
    }
}

只这么看Java代码,虽然看的若有所思,但是还是没感受到模式匹配的魅力,那么对比Java8场景下如何实现上述功能呢?

public static double calculateArea(Shape shape) {
        // instanceof处理不同的形状
        if (shape instanceof Circle) {
            Circle circle = (Circle) shape;
            return Math.PI * circle.radius() * circle.radius();
        } else if (shape instanceof Rectangle) {
            Rectangle rectangle = (Rectangle) shape;
            return Math.abs(rectangle.bottomRight().x() - rectangle.topLeft().x()) *
                    Math.abs(rectangle.topLeft().y() - rectangle.bottomRight().y());
        } else {
            throw new IllegalArgumentException("Unknown shape: " + shape);
        }
    }

对比我们发现处理流程是:

  1. instanceof 判断子类
  2. 强制类型转换
  3. 计算 其中会出现一些问题:
  4. 如果我们又引入了一个三角形(Triangle),并且引入三角形的同时上游或者下游并不知情,那么编译器不会报错,但是我们的calulateArea就会走到exception的流程,这就引起了一个业务的异常。 模式匹配怎么杜绝了此问题呢?
  5. 模式匹配会在编译期对用户进行提醒:

这样使用者就不会出现业务遗漏处理的场景。这全归功于密封类针对继承的严格控制。

总结一下模式匹配和三个关键字

Java引入了record关键字来作为档案类,record实现了Java数据建模时的不可变性封闭性。Java的sealed和permits控制了类与类之间的继承资格,通过这两个关键字可以完成Java数据模型的穷尽性检查

有了上述三种特性,结合ADT的概念,我们就可以在新版本的Java中实现模式匹配。Java从此享受了更高级的语法。同时也让我明白,从Java8到Java21,Java每一次版本变动新增的内容,都是在与时俱进,也让我明白学习一个新知识重要的不是它可以做什么,更多的是它为什么这么做!

#牛客创作赏金赛#
全部评论

相关推荐

评论
3
1
分享

创作者周榜

更多
牛客网
牛客企业服务