面向数据的Java编程
主要收获
- 近年来,Amber项目为Java带来了许多新的特性。虽然这些特性中的每一个都是自成一体的,但它们也是被设计成一起工作的。具体来说,记录、密封类和模式匹配一起工作,使Java中面向数据的编程更加容易。
- OOP鼓励我们使用对象对复杂的实体和过程进行建模,这些对象结合了状态和行为。OOP在定义和捍卫边界时表现得最好。
- Java强大的静态类型和基于类的建模对小程序仍然非常有用,只是方式不同。
- 面向数据的编程鼓励我们将数据建模为(不可改变的)数据,并将体现我们如何对该数据进行操作的业务逻辑的代码分开。记录、密封类和模式匹配,使之更容易。
- 当我们对复杂的实体进行建模时,OO技术可以为我们提供很多东西。但当我们为处理普通的、临时的数据的简单服务建模时,面向数据的编程技术可能为我们提供了一条更直截了当的道路。
- OOP和面向数据的编程技术并不冲突;它们是针对不同粒度和情况的不同工具。我们可以根据我们的需要自由地混合和匹配它们。
近年来,Amber项目给Java带来了许多新的特性--局部变量类型推断、文本块、记录、密封类、模式匹配等等。虽然这些功能中的每一个都是独立的,但它们也被设计为一起工作。具体来说,记录、密封类和模式匹配一起工作,使Java中面向数据的编程更加容易。在这篇文章中,我们将介绍这个术语的含义,以及它如何影响我们在Java中的编程方式。
面向对象编程
任何编程范式的目标都是为了管理复杂性。但是,复杂性有很多形式,并不是所有的范式都能很好地处理所有形式的复杂性。大多数编程范式都有一句口号,其形式是 "一切都是......";对于OOP,这显然是 "一切都是对象"。功能性编程说 "一切都是函数";基于行为体的系统说 "一切都是行为体",等等(当然,这些都是为了效果而夸大其词)。
OOP鼓励我们使用对象对复杂的实体和过程进行建模,这些对象结合了状态和行为。OOP鼓励封装(对象行为介导对对象状态的访问)和多态性(多种实体可以使用一个共同的接口或词汇进行交互),尽管实现这些目标的机制在不同的OO语言中有所不同。当用对象对世界进行建模时,我们被鼓励用is-a(一个储蓄账户是一个银行账户)和has-a(一个储蓄账户有一个所有者和账户号码)的关系来思考。
相关赞助内容
相关赞助商
微软JDConf '22:云原生Java、JVM配置、GraalVM、Spring Boot等 -点播观看所有会议。
虽然有些开发者乐于大声宣布面向对象编程是一项失败的试验,但事实却更加微妙;就像所有的工具一样,它适合于某些事情,而不太适合于其他事情。OOP做得不好可能会很糟糕,很多人都接触过OOP原则,并被带到了荒谬的极端。(像"名词王国 "这样的咆哮可能很有趣,也很有疗效,但它们并不是真正在抨击OOP,而是对OOP的一种卡通式的夸大)。但是,如果我们理解了OOP在哪些方面更好或更差,我们就可以在它提供更多价值的地方使用它,在它提供更少的地方使用其他东西。
OOP在定义和捍卫边界时处于最佳状态--维护边界、版本边界、封装边界、编译边界、兼容性边界、安全边界,等等。
独立维护的库是与依赖它们的应用程序(以及相互之间)分开构建、维护和演化的,如果我们希望能够从一个版本的库自由转移到下一个版本,我们需要确保库和它们的客户之间的边界是清晰、明确和慎重的。平台库可能有对底层操作系统和硬件的特权访问,这必须被仔细控制;我们需要在平台库和应用程序之间建立强大的边界,以保持系统的完整性。OO语言为我们提供了精确定义、浏览和捍卫这些边界的工具。
将一个大程序划分为具有明确边界的小部分,有助于我们管理复杂性,因为它可以实现模块化推理--能够一次分析程序的一个部分,但仍能对整体进行推理。在一个单体程序中,放置合理的内部边界有助于我们建立更大的应用程序,跨越多个团队。Java在单片机时代的兴盛并非偶然。
从那时起,程序变得越来越小;我们不再建立单片机,而是用许多小服务来组成更大的应用程序。在一个小的服务中,对内部边界的需求较少;足够小的服务可以由一个团队(甚至一个开发人员)来维护。
面向数据的编程
Java强大的静态类型和基于类的建模对于小型程序仍然非常有用,只是方式不同而已。在OOP鼓励我们使用类来建模业务实体和流程的地方,内部界限较少的小型代码库通常会从使用类来建模数据中获得更多的好处。我们的服务会消费来自外部世界的请求,例如通过HTTP请求和未定型的JSON/XML/YAML有效载荷。但只有最微不足道的服务才会想直接使用这种形式的数据;我们想把数字表示为int 或long ,而不是数字字符串,把日期表示为LocalDateTime 等类,把列表表示为集合,而不是以逗号分隔的长字符串。(我们想在边界上验证这些数据,然后再对其采取行动)。
面向数据的编程鼓励我们将数据建模为(不可改变的)数据,并将体现我们如何对该数据进行操作的业务逻辑的代码分开。随着这种向小程序发展的趋势,Java已经获得了新的工具,使其更容易将数据建模为数据(记录),直接建模替代(密封类),并灵活地解构多态数据(模式匹配)模式。
面向数据的编程鼓励我们将数据作为数据建模。记录、密封类和模式匹配一起工作,使之更容易。
将数据作为数据进行编程并不意味着放弃静态类型化。我们可以只用无类型的映射和列表来进行面向数据的编程(在Javascript等语言中经常如此),但静态类型在安全性、可读性和可维护性方面仍有很大的优势,即使我们只对普通数据进行建模。(无纪律的面向数据的代码通常被称为 "字符串类型",因为它使用字符串来模拟那些不应该被模拟为字符串的东西,如数字、日期和列表)。
Java中面向数据的编程
记录、密封类和模式匹配被设计为共同支持面向数据的编程。记录允许我们使用类来简单地对数据进行建模;密封类让我们对选择进行建模;而模式匹配为我们提供了一种简单且类型安全的方式来对多态数据进行操作。对模式匹配的支持分几次进行;第一次只增加了类型测试模式,并且只在instanceof ;接下来在switch ,也支持类型测试模式;最近的一次。 记录的解构模式是在Java 19中添加的。本文中的例子将使用所有这些功能。
虽然记录在语法上是简洁的,但它们的主要优势是让我们干净利落地对聚合体进行建模。就像所有的数据建模一样,要做一些创造性的决定,有些建模方式比其他方式更好。使用记录和密封类的组合,也使得非法状态的不表示变得更加容易,进一步提高了安全性和可维护性。
示例--命令行选项
作为第一个例子,考虑一下我们如何对一个命令行程序中的调用选项进行建模。有些选项需要参数,有些则不需要。有些参数是任意的字符串,而有些则是更有结构性的,比如数字或日期。处理命令行选项应该在程序执行的早期拒绝坏的选项和畸形的参数。一个简单快捷的方法是循环浏览命令行参数,对于我们遇到的每一个已知的选项,将该选项的存在与否,以及该选项的参数,藏在变量中。这样做很简单,但现在我们的程序依赖于一组串联类型的、有效的全局变量。如果我们的程序很小,这也许是可以的,但它的规模并不大。这不仅可能妨碍程序的可维护性,而且使我们的程序的可测试性降低--我们只能通过其命令行来测试整个程序。
一个稍微不那么快的方法可能是创建一个代表命令行选项的单一类,并将命令行解析为一个选项对象的列表。如果我们有一个类似于cat 的程序,可以从一个或多个文件复制行到另一个文件,可以将文件修剪到一定的行数,并可以选择包括行号,我们可以用一个enum 和一个Option 类来模拟这些选项。
enum MyOptions { INPUT_FILE, OUTPUT_FILE, MAX_LINES, PRINT_LINE_NUMBERS }
record OptionValue(MyOptions option, String optionValue) { }
static List<OptionValue> parseOptions(String[] args) { ... }
这比以前的方法有进步;至少现在在解析命令行选项和消费它们之间有了明确的分离,这意味着我们可以通过向命令行shell提供选项列表来单独测试我们的业务逻辑。但这仍然不是很好。有些选项没有参数,但我们从选项的枚举中看不出这一点,我们仍然用一个有optionValue 字段的OptionValue 对象来模拟它们。而且,即使是那些有参数的选项,它们也总是字符串类型的。
更好的方法是对每个选项直接建模。从历史上看,这样做可能会过于冗长,但幸运的是现在已经不是这样了。我们可以用一个密封的类来表示一个Option ,并且为每一种选项都有一个记录。
sealed interface Option {
record InputFile(Path path) implements Option { }
record OutputFile(Path path) implements Option { }
record MaxLines(int maxLines) implements Option { }
record PrintLineNumbers() implements Option { }
}
Option 子类是纯数据。选项的值有漂亮干净的名字和类型;有参数的选项用适当的类型表示;没有参数的选项没有无用的参数变量,可能被误解。此外,用模式匹配的开关来处理选项是很容易的(通常每一种选项只有一行代码)。由于Option 是密封的,编译器可以对开关处理所有的选项类型进行类型检查。(如果我们以后添加更多的选项类型,编译器会提醒我们哪些开关需要被扩展)。
我们可能都写过像前两个版本中所概述的那样的代码,尽管我们知道这样做更好。如果没有能力对数据进行简洁的建模,做 "正确 "的事往往是太累了(或太多的代码)。
我们在这里所做的是把来自调用边界(命令行参数)的混乱的、未类型化的数据,转化为强类型化的、经过验证的、容易操作的(通过模式匹配)的数据,并使许多非法状态(如指定--input-file ,但不提供有效路径)无法呈现。程序的其他部分就可以放心地使用它。
代数式数据类型
这种记录和密封类型的组合是所谓的代数数据类型(ADT)的一个例子。记录是 "乘积类型 "的一种形式,之所以这么说是因为它们的状态空间是其组成部分的笛卡尔乘积。密封类是 "和类型 "的一种形式,所谓 "和类型 "是因为可能的值集是备选值集的和(联合)。这种简单的机制组合--聚合和选择--具有强大的欺骗性,并在许多编程语言中出现。(我们这里的例子被限制在一个层次上,但在一般情况下不必如此;一个密封接口的允许的子类型之一可以是另一个密封接口,允许对复杂的结构进行建模)。
在Java中,代数数据类型可以被精确地建模为密封的层次结构,其叶子是记录。Java对代数数据类型的解释有一些理想的特性。它们是名义上的-- 类型和组件有人类可读的名字。它们是不可改变的,这使得它们更简单、更安全,可以自由共享而不用担心受到干扰。它们很容易测试,因为它们只包含它们的数据(可能有纯粹从数据派生的行为)。它们可以很容易地被序列化到磁盘或跨线。它们是有表现力的--它们可以为广泛的数据域建模。
应用:复杂的返回类型
代数数据类型最简单但最常用的应用之一是复杂的返回类型。由于一个方法只能返回一个单一的值,所以经常会有人用有问题的或复杂的方式对返回值进行重载表示,比如用null 表示 "未找到",将多个值编码成一个字符串,或者用一个过于抽象的类型(数组、List 或Map )将一个方法可能返回的所有不同种类的信息塞进一个载体对象。代数数据类型使得做正确的事情变得如此容易,以至于这些方法变得不那么诱人了。
在《密封类》中,我们举了一个例子,说明这种技术如何被用来抽象出成功和失败的条件,而不使用异常。
sealed interface AsyncReturn<V> {
record Success<V>(V result) implements AsyncReturn<V> { }
record Failure<V>(Throwable cause) implements AsyncReturn<V> { }
record Timeout<V>() implements AsyncReturn<V> { }
record Interrupted<V>() implements AsyncReturn<V> { }
}
这种方法的好处是,客户端可以通过对结果进行模式匹配来统一处理成功和失败,而不是通过返回值来处理成功,通过单独的catch 块处理各种失败模式。
AsyncResult<V> r = future.get();
switch (r) {
case Success<V>(var result): ...
case Failure<V>(Throwable cause): ...
case Timeout<V>(): ...
case Interrupted<V>(): ...
}
密封类的另一个好处是,如果你在没有default 的情况下切换它们,编译器会提醒你是否忘记了一个案例。(检查的异常也是这样做的,但是以一种更侵扰人的方式)。
作为另一个例子,想象一下一个按名称查找实体(用户、文档、组等)的服务,并区分 "没有找到匹配的"、"找到完全匹配的 "和 "没有完全匹配,但有接近匹配的"。我们都能想象出把这些东西塞进一个List 或数组的方法,虽然这可能使搜索API容易编写,但却使它更难理解、使用或测试。代数数据类型使这个等式的两边都容易。我们可以制作一个简洁的API,准确地表达我们的意思。
sealed interface MatchResult<T> {
record NoMatch<T>() implements MatchResult<T> { }
record ExactMatch<T>(T entity) implements MatchResult<T> { }
record FuzzyMatches<T>(Collection<FuzzyMatch<T>> entities)
implements MatchResult<T> { }
record FuzzyMatch<T>(T entity, int distance) { }
}
MatchResult<User> findUser(String userName) { ... }
如果我们在浏览代码或Javadoc时遇到这个返回层次,那么这个方法可能会返回什么,以及如何处理其结果,都是显而易见的:
Page userSearch(String user) {
return switch (findUser(user)) {
case NoMatch() -> noMatchPage(user);
case ExactMatch(var u) -> userPage(u);
case FuzzyMatches(var ms) -> disambiguationPage(ms.stream()
.sorted(FuzzyMatch::distance))
.limit(MAX_MATCHES)
.toList());
}
虽然这种对返回值的清晰编码对API的可读性和易用性有好处,但这样的编码往往也更容易编写,因为代码几乎是根据要求自己编写的。另一方面,试图想出(和记录)"聪明的 "编码,将复杂的结果塞进抽象的载体,如数组或地图,需要更多的工作。
应用。临时的数据结构
代数数据类型对于通用数据结构的临时版本的建模也很有用。流行的类Optional 可以被建模为一个代数数据类型。
sealed interface Opt<T> {
record Some<T>(T value) implements Opt<T> { }
record None<T>() implements Opt<T> { }
}
(这实际上是大多数函数式语言对Optional 的定义。)对Opt 的普通操作可以通过模式匹配来实现:
static<T, U> Opt<U> map(Opt<T> opt, Function<T, U> mapper) {
return switch (opt) {
case Some<T>(var v) -> new Some<>(mapper.apply(v));
case None<T>() -> new None<>();
}
}
同样地,二叉树可以被实现为:
sealed interface Tree<T> {
record Nil<T>() implements Tree<T> { }
record Node<T>(Node<T> left, T val, Node<T> right) implements Tree<T> { }
}
我们可以用模式匹配来实现通常的操作:
static<T> boolean contains(Tree<T> tree, T target) {
return switch (tree) {
case Nil() -> false;
case Node(var left, var val, var right) ->
target.equals(val) || left.contains(target) || right.contains(target);
};
}
static<T> void inorder(Tree<T> t, Consumer<T> c) {
switch (tree) {
case Nil(): break;
case Node(var left, var val, var right):
left.inorder(c);
c.accept(val);
right.inorder(c);
};
}
看到这种行为被写成静态方法似乎很奇怪,而像遍历这样的常见行为 "显然 "应该被实现为基础接口上的抽象方法。当然,有些方法放在接口中也很有意义。但是记录、密封类和模式匹配的结合为我们提供了以前没有的选择;我们可以用老式的方法来实现它们(在基类中使用抽象方法,在每个子类中使用具体方法);作为抽象类中的默认方法,用模式匹配在一个地方实现;作为静态方法;或者(当不需要递归时),在使用点上作为临时的遍历内联。
因为数据载体是专门为这种情况建立的,我们可以选择是否要让行为与数据一起旅行。这种方法与面向对象并不冲突;它是我们工具箱中的一个有用的补充,可以根据情况需要与OO一起使用。
例子JSON
如果你仔细观察JSON规范,你会发现JSON值也是一个ADT:
sealed interface JsonValue {
record JsonString(String s) implements JsonValue { }
record JsonNumber(double d) implements JsonValue { }
record JsonNull() implements JsonValue { }
record JsonBoolean(boolean b) implements JsonValue { }
record JsonArray(List<JsonValue> values) implements JsonValue { }
record JsonObject(Map<String, JsonValue> pairs) implements JsonValue { }
}
当以这种方式呈现时,从JSON的blob中提取相关信息的代码是非常直接的;如果我们想用模式匹配来匹配JSON blob{ "name":"John", "age":30, "city":"New York" } ,这就是:
if (j instanceof JsonObject(var pairs)
&& pairs.get("name") instanceof JsonString(String name)
&& pairs.get("age") instanceof JsonNumber(double age)
&& pairs.get("city") instanceof JsonString(String city)) {
// use name, age, city
}
当我们将数据建模为数据时,创建聚合体和将其拆开以提取其内容(或将其重新打包成另一种形式)都是很直接的,而且由于模式匹配在某些东西不匹配的时候会优雅地失败,拆开这个JSON blob的代码相对来说没有复杂的控制流来执行结构约束。(虽然我们可能倾向于使用比这个玩具例子更强大的JSON库,但实际上我们可以用几十行额外的解析代码来实现这个玩具,这些代码遵循JSON规范中列出的词法规则,并将它们转化为JsonValue 。)
更复杂的领域
到目前为止,我们所关注的领域要么是 "抛出"(跨调用边界使用的返回值),要么是建模的一般领域,如列表和树。但是同样的方法对于更复杂的特定应用域也是有用的。如果我们想对一个算术表达式进行建模,我们可以用这样的方法:
sealed interface Node { }
sealed interface BinaryNode extends Node {
Node left();
Node right();
}
record AddNode(Node left, Node right) implements BinaryNode { }
record MulNode(Node left, Node right) implements BinaryNode { }
record ExpNode(Node left, int exp) implements Node { }
record NegNode(Node node) implements Node { }
record ConstNode(double val) implements Node { }
record VarNode(String name) implements Node { }
拥有中间的密封接口BinaryNode ,它对加法和乘法进行了抽象,使我们在对一个Node ;我们可以通过对BinaryNode ,把加法和乘法一起处理,或者根据情况需要单独处理。该语言仍将确保我们涵盖所有的情况。
为这些表达式编写一个评估器是很简单的。由于我们的表达式中有变量,我们需要为这些变量建立一个存储空间,并将其传递给评估器:
double eval(Node n, Function<String, Double> vars) {
return switch (n) {
case AddNode(var left, var right) -> eval(left, vars) + eval(right, vars);
case MulNode(var left, var right) -> eval(left, vars) * eval(right, vars);
case ExpNode(var node, int exp) -> Math.exp(eval(node, vars), exp);
case NegNode(var node) -> -eval(node, vars);
case ConstNode(double val) -> val;
case VarNode(String name) -> vars.apply(name);
}
}
定义终端节点的记录有合理的toString 实现,但输出可能比我们希望的更冗长。我们可以很容易地写一个格式化器来产生看起来更像数学表达式的输出:
String format(Node n) {
return switch (n) {
case AddNode(var left, var right) -> String.format("("%s + %s)",
format(left), format(right));
case MulNode(var left, var right) -> String.format("("%s * %s)",
format(left), format(right));
case ExpNode(var node, int exp) -> String.format("%s^%d", format(node), exp);
case NegNode(var node) -> String.format("-%s", format(node));
case ConstNode(double val) -> Double.toString(val);
case VarNode(String name) -> name;
}
}
像以前一样,我们可以把这些表达为静态方法,或者在基类中把它们作为实例方法实现,但有一个单一的实现,或者把它们作为普通的实例方法实现--我们可以自由选择对领域来说感觉最可读的方法。
在抽象地定义了我们的域之后,我们也可以很容易地在它上面添加其他操作。我们可以很容易地对单个变量进行符号化的区分:
Node diff(Node n, String v) {
return switch (n) {
case AddNode(var left, var right)
-> new AddNode(diff(left, v), diff(right, v));
case MulNode(var left, var right)
-> new AddNode(new MulNode(left, diff(right, v)),
new MulNode(diff(left, v), right)));
case ExpNode(var node, int exp)
-> new MulNode(new ConstNode(exp),
new MulNode(new ExpNode(node, exp-1),
diff(node, v)));
case NegNode(var node) -> new NegNode(diff(node, var));
case ConstNode(double val) -> new ConstNode(0);
case VarNode(String name) -> name.equals(v) ? new ConstNode(1) : new ConstNode(0);
}
}
在我们有记录和模式匹配之前,编写这样的代码的标准方法是访问者模式。模式匹配显然比访问者更简明,但它也更灵活和强大。访客要求域为访问而构建,并施加了严格的约束;模式匹配支持更多的临时多态性。最重要的是,模式匹配可以更好地进行组合;我们可以使用嵌套模式来表达复杂的条件,而使用访问者来表达这些条件则会更加混乱。例如,上面的代码会产生不必要的混乱的树,例如,我们有一个乘法节点,其中一个子节点是一个常数。我们可以使用嵌套模式来更急切地处理这些特殊情况:
Node diff(Node n, String v) {
return switch (n) {
case AddNode(var left, var right)
-> new AddNode(diff(left, v), diff(right, v));
// special cases of k*node, or node*k
case MulNode(var left, ConstNode(double val) k)
-> new MulNode(k, diff(left, v));
case MulNode(ConstNode(double val) k, var right)
-> new MulNode(k, diff(right, v));
case MulNode(var left, var right)
-> new AddNode(new MulNode(left, diff(right, v)),
new MulNode(diff(left, v), right)));
case ExpNode(var node, int exp)
-> new MulNode(new ConstNode(exp),
new MulNode(new ExpNode(node, exp-1),
diff(node, v)));
case NegNode(var node) -> new NegNode(diff(node, var));
case ConstNode(double val) -> new ConstNode(0);
case VarNode(String name) -> name.equals(v) ? new ConstNode(1) : new ConstNode(0);
}
}
用访问者这样做--特别是在多级嵌套的情况下--很快就会变得相当混乱和容易出错。
这不是非此即彼
这里概述的许多想法,起初可能看起来有点 "不像Java",因为我们大多数人都被教导要从实体和流程的建模作为对象开始。但在现实中,我们的程序经常与相对简单的数据打交道,而这些数据往往来自 "外部世界",我们不能指望它能干净地融入Java的类型系统。(在我们的JSON例子中,我们将数字建模为double ,但事实上JSON规范对数字值的范围没有任何规定;系统边界的代码将不得不决定是否截断或拒绝不适合本地表示的值。)
当我们对复杂的实体进行建模,或编写丰富的库,如java.util.stream ,OO技术可以为我们提供很多东西。但是,当我们建立简单的服务来处理普通的、临时性的数据时,面向数据的编程技术可能会给我们提供一条更直的道路。同样,当跨越API边界交换复杂的结果时(比如我们的匹配结果的例子),使用ADT定义一个临时的数据模式,往往比在一个有状态的对象中集合结果和行为更简单和清晰(就像JavaMatcher API那样)。
OOP和面向数据的编程技术并不冲突;它们是针对不同粒度和情况的不同工具。我们可以根据我们的需要自由地混合和匹配它们。
遵循数据
无论是对一个简单的返回值进行建模,还是对一个更复杂的领域如JSON或我们的表达式树进行建模,有一些简单的原则通常会使我们获得简单、可靠的面向数据的代码。
-
对数据进行建模,对整个数据进行建模,除了数据之外什么都不做。记录应该为数据建模。让每条记录为一件事建模,明确每条记录所建模的内容,并为其组件选择明确的名称。在有选择需要建模的地方,比如 "报税单是由纳税人或法定代表人提交的",将其建模为密封的类,用一条记录为每个选择建模。记录类中的行为应限于实现来自数据本身的派生量,如格式化。
-
数据是不可变的:一个拥有可变的
int字段的对象并不是为一个整数建模;它为一个特定对象身份和一个整数之间的时间变化关系建模。如果我们想对数据进行建模,我们不应该担心我们的数据会从我们脚下变走。记录在这方面给了我们一些帮助,因为它们是浅层的不可变的,但是仍然需要一些纪律来避免让可变性注入我们的数据模型中。 -
在边界上进行验证:在将数据注入我们的系统之前,我们应该确保它是有效的。这可以在记录构造器中完成(如果验证普遍适用于所有的实例),或者由边界的代码来完成,这些代码已经从另一个来源收到了数据。
-
让非法的状态无法呈现。记录和密封类型使我们可以很容易地对我们的领域进行建模,使错误的状态不能被表示。这比一直检查有效性要好得多。正如不可更改性消除了程序中许多常见的错误来源一样,避免让我们对无效数据进行建模的建模技术也是如此。
这种方法的一个隐藏的好处是可测试性。当代码的输入和输出是简单的、定义良好的数据时,不仅容易测试,而且还为更容易的生成性测试打开了大门,这通常比手工制作单个测试用例更有效地发现错误。
记录、密封类型和模式匹配的结合使得遵循这些原则变得容易,产生更简洁、可读和更可靠的程序。虽然考虑到Java的OO基础,将数据作为数据进行编程可能有点陌生,但这些技术非常值得添加到我们的工具箱中。
/filters:no_upscale()/sponsorship/topic/d7295235-717e-4e53-b057-e8f2b20f7838/MicrosoftLogoRSB-1650524400057.png)