Java 的持续影响力: 语言演进的深度解析

我并非一直都是 Java 的拥趸, 但近年来我逐渐开始欣赏这门语言及其生态系统, 尤其是在决定使用 Java 21 进行一个新的个人项目后. 当谈到进入 JVM 世界, 我最初选择的是 Scala------一种将面向对象编程与函数式编程概念融合的精简语言, 其核心在于类型安全.
那么, 为什么我没有选择Scala呢? 我决定使用Java的部分原因在于, 我了解到该语言近期有一些令人兴奋的改进, 同时我也希望借此机会进一步探索Java当前的生态系统, 框架和库的现状. 虽然针对JVM平台的其他新语言层出不穷, 但目前获得最多关注的仍是Scala和Kotlin, 这两种语言与Java一样, 都是强类型和静态类型的编程语言.
Java 与 Scala, Kotlin 的对比
在Scala和Kotlin之间, Kotlin的语法对已有Java经验的开发者来说更熟悉, 其主要目标是成为一种更现代, 更简洁的语言, 同时与Java完全兼容, 并修复Java的一些缺陷. 目前, Kotlin在Android开发领域更受欢迎.
另一方面, Scala 的流行主要集中在数据工程(如 Apache Spark 等)和后端应用开发领域. Scala 的语法更为简洁, 有人甚至将其比作"强化版 Python"(尤其是 Scala3), 并且拥有更强大的类型系统, 有助于在编译时消除更多错误.
开发者更青睐 Java
让我们通过查看Stack Overflow调查以及TIOBE和Redmonk语言流行度指数, 来了解Scala, Kotlin和Java的采用率和流行度.
下图基于Stack Overflow对最流行语言的调查结果. 这里我只挑选了上述三种语言.

Redmonk语言指数
以下是截至2023年1月的Redmonk指数, 该指数基于Github和Stack Overflow数据. 我们可以看出Java位居榜首, Scala和Kotlin也排名相对靠前.

来源: redmonk.com/sogrady/202...
TIOBE编程指数
现在来看TIOBE指数, 该指数考虑了25个不同搜索引擎中的搜索词. Java同样位列顶级语言之列. Scala和Kotlin未进入前10名(图表中未显示), 但2023年它们分别排名第15和第36位.

来源: www.tiobe.com/tiobe-index...
如你所见, 尽管面临 Kotlin 和 Scala 的强劲竞争, Java 仍保持着极高人气. 近年来, 其市场份额确实有所下降, 部分原因在于其他编程语言(包括其他生态系统, 如 Python, Go, Rust)的崛起.
探索 Java: 对向后兼容性的承诺
让我们看看Java在过去几年中做了什么, 以及其他因素如何贡献于其强大的流行度.
部分流行度源于Java的基因, 即对强向后兼容性的承诺. 是的, 多年来确实有一些有意的兼容性中断, 以支持语言和工具的改进, 但此类更改通常经过深思熟虑并有充分理由. 这是 Java 在企业环境中如此受欢迎的一个重要原因. JVM 平台的稳定性意味着更多工程师能够专注于解决业务问题和交付代码, 而非与工具作斗争.
考虑 Java 相关性的因素
自Java 9起, OpenJDK将发布周期从"随时准备好就发布"改为每6个月一次------3月和9月. 此举旨在缓解因某些增强功能未准备就绪而导致的新版本发布延迟. 为了实现这一目标, 引入了"预览功能"概念. 如果某项功能已完全实现但仍可能引发兼容性问题, 则会在下一版本中以预览形式引入.
这使开发者社区能够提供反馈并帮助完善实现, 以便在后续常规版本中最终确定. 每隔几年, Oracle 会将其中一个版本标记为 LTS(长期支持)版本, 其他 JDK 供应商也会跟进. 例如, 亚马逊网络服务(AWS)维护着自己的 JDK 构建版本, 名为 Corretto JDK, AWS 将为其提供长期支持.

Java 相关性的另一个因素是后发优势. 其他 JVM 语言如 Scala 发展速度相当快. 我继续欣赏并乐于使用 Scala, 但其对向后兼容性的承诺和工具链的完善程度仍有很大提升空间(但正在改善!). 另一方面, Scala 处于技术前沿, 推动语言和编译器设计达到新高度.
Java 则采取长远策略, 花时间观察行业演变, 评估其他语言的实践, 挑选有效方案并有针对性地改进语言. 让我们快速回顾 Java 8 之后引入的若干语言改进.
Java 10 的var
关键字对冗余性的影响
当与那些已经精通其他现代语言并正在学习 Java 的开发者交谈时, 当被问及他们最不喜欢什么时, 冗余性往往位列前茅. 在 Java 10 发布之前, 我们必须在赋值语句的左侧显式声明变量类型. 在 Java 10 中, 引入了一个新的特殊var
关键字, 用于替代实际类型. 在编译阶段, Java 编译器会根据赋值语句右侧表达式推断出的实际类型进行插入.
ini
//Java 8
HashMap map = new HashMap();
DatabaseEngineColumnCacheImpl cache = new DatabaseEngineColumnCacheImpl();
Optional accessRole = user.getUserAccessRole();
//Java 10, no need to convince compiler anymore of what are the actual types
var map = new HashMap();
var cache = new DatabaseEngineColumnCacheImpl();
var accessRole = user.getUserAccessRole();
以上是一些简单的示例, 但这确实大大减少了阅读代码时的冗余性. 然而, 这在行业中并非新鲜事. 其他现代静态类型编程语言的开发者早已享受类型推断的便利.
例如, Scala 自 2004 年诞生以来就拥有更高级的类型推断功能. 尽管如此, 这仍是 Java 的一项非常受欢迎的功能. 类型推断仅限于局部变量声明, 例如在方法体内, 从实际应用角度来看, 这正是最关键的场景.
通过 instanceof
和模式匹配克服类型检查挑战
instanceof
是用于检查给定对象是否为特定类型的语言关键字. 例如, 给定一个类型为 Object
的对象(可能代表任何类型), 我们可能需要在运行时检查其底层类型, 以便执行针对该底层类型特定的操作.
以下是一个示例, 虽然有些牵强, 但足以说明问题. 假设我们有一个 Shape
接口和多个表示特定形状的类. 在代码的某个位置, 我们希望获取形状的周长信息. 不幸的是, 接口并未指定每个继承类应实现的周长计算方法, 且出于示例考虑, 我们也无法重构此代码.
这使我们只剩下一个选择: 将周长计算实现为某种实用方法, 该方法会检查具体类型(如Rectangle
或Circle
), 并根据类型计算周长.
java
interface Shape {}
public class Rectangle implements Shape {
final double length;
final double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
}
public class Circle implements Shape {
final double radius;
public Circle(double radius) {
this.radius = radius;
}
}
public static double getPerimeter(Shape shape) {
if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return 2 * r.length + 2 * r.width;
} else if (shape instanceof Circle) {
Circle c = (Circle) shape;
return 2 * c.radius * Math.PI;
} else {
throw new RuntimeException("Unknown shape");
}
}
查看getPerimeter
方法的实现, 尽管我们已经检查了类型, 但仍需进行强制类型转换并声明新变量才能执行操作. 这是因为编译器仍然将 shape
视为 Shape
的实例.
instanceof
的模式匹配允许我们在 if-else
块的作用域内声明一个变量, 该变量类型与我们检查的类型一致. 在 Java 14 中, 相同的 if-else
块将如下所示.
typescript
public static double getPerimeter(Shape shape) {
if (shape instanceof Rectangle r) {
return 2 * r.length + 2 * r.width;
} else if (shape instanceof Circle c) {
return 2 * c.radius * Math.PI;
} else {
throw new RuntimeException("Unknown shape");
}
}
这是编译器的一项不错改进, 使其逐步变得更加智能. instanceof
的模式匹配是一项更大的努力, 在 Java 的后续版本中持续扩展, 包括对Record
类的模式匹配, 这是我接下来想讨论的下一个重要功能.
从冗长到简洁, Java 的Record
重塑data
类
好吧, 这对我很重要. 我真的很喜欢 Scala 如何轻松地使用data
类来建模事物. Java 以领域建模而闻名, 但在引入Record
类之前, 定义数据容器(即所谓的 POJO)非常冗长, 这导致生态系统中出现了各种库, 这些库通过代码生成来替代手动编写重复代码(例如 Lombok).
让我们看看以下示例.
arduino
public class Person {
public final String id;
public final String name;
public final Integer age;
public Person(String id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
}
}
var p1 = new Person("a1b", "Frank", 30)
var p2 = new Person("a1b", "Frank", 30)
p1.equals(p2) //false, oh?
首先, 与其他高级编程语言相比, 这已经是一个相当冗长的语法来定义一个简单的data
类. 此外, 当我们希望以这种方式建模数据时, 通常希望根据对象的内容进行比较, 而在这个示例中, 人们可能会认为(如果他们对 Java 不熟悉)p1 应该等于 p2, 但事实并非如此.
这是因为在 Java 中, 对象是内存的引用, 如果不明确告诉编译器如何比较对象, equals()
方法的默认策略是比较内存地址. 这就是 ==
运算符所做的事情. 那么, 我们需要做什么才能使我们的 Person
对象与同一类型的另一个实例可比较?
kotlin
public class Person {
public final String id;
public final String name;
public final Integer age;
public Person(String id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (o == null || !(o instanceof Person)) return false;
Person p = (Person) o;
return Objects.equals(p.id, this.id) && Objects.equals(p.age, this.age) && Objects.equals(p.name, this.name);
}
@Override
public int hashCode() {
int hash = 7;
hash = 31 * hash + Objects.hashCode(id);
hash = 31 * hash + Objects.hashCode(name);
hash = 31 * hash + age;
return hash;
}
}
原来还有很多事情要做. 我们需要重写 equals
和 hashCode
方法来定义对象比较的规则. 像 Lombok 这样的库会为我们生成这些方法, 这样我们就不用手动编写它们了, 但现在 Java 已经提供了原生支持.
引入Record
. 自 Java 17 起(自 14 版起预览), 我们可以通过 record
关键字以一种新的方式定义类. 基于之前的示例, 让我们看看如何使用Record
来改进它.
ini
record Person(String id, String name, Integer age) {};
var p1 = new Person("1ab", "Frank", 30);
var p2 = new Person("1ab", "Frank", 30);
p1.equals(p2); //true, yes sir!
p1 == p2; //false, which is expected as these are still two different instances
探索 Java Record
中的 with
语句
就这样! Java编译器会自动为我们生成字节码, 并考虑所有定义的参数来实现equals
和hashCode
方法. Record
类还提供了toString
和getter
方法, 但没有setter
方法. Record
类设计为不可变的, 这意味着一旦实例创建, 我们只能读取对象成员, 而不能更改其值.
不可变性有助于减少并发程序中的错误, 并使代码更易于理解. 目前, 创建更新后的实例有点麻烦, 需要将所有参数从一个Record
复制到另一个Record
, 但未来随着新功能Record
的 with
的推出, 这一情况有望得到改善, 该功能已在项目 Amber 的草稿部分中概述的新功能With for records 中有所改变. 最终, 我们将能够通过修改Record
对象的一个或多个成员来创建新的实例, 例如:
ini
var p3 = p2 with { name = "Joe" };
目前已有一些库可协助实现此功能, 你可以查看这个 GitHub 项目 record-builder. 除了解决修改Record
对象的限制外, 它还能生成构建器, 这对处理更复杂的data
类非常有用.
Java Record
的功能远不止这个示例所展示的. Record
的其他特性包括能够定义自定义构造函数, 并且我们仍然可以定义常规方法来操作底层数据. 由于Record
设计上是不可变的, 因此Record
的序列化比普通类实例的序列化更简单, 更安全. Record
还可以被分解为值, 这一特性被下一项功能------Record
的模式匹配所利用.
Record
的模式匹配
Record
的模式匹配在 Java 21 中最终确定. 作为 Amber 项目的一部分, 模式匹配和Record
的引入开启了新的编程范式------数据导向编程(DOP). DOP 强调将问题建模为不可变的数据结构, 并使用不修改数据的通用函数进行计算. 我之前听说过这个概念, 没错, 这是函数式编程中广为人知的概念! 值得一提的是, 引入支持DOP范式的特性并非为了取代面向对象编程(OOP), 而是为了更优雅地解决特定任务. 在大型系统中, OOP擅长定义和管理代码边界, 而DOP在处理小型任务时更具优势, 可用于建模和操作数据.
让我们直奔主题. 模式匹配究竟是什么? 我们可以将其视为类构造函数的相反概念. 类构造函数允许我们通过提供一些数据来构造对象, 而模式匹配则允许我们分解或提取在对象构造过程中使用的数据. 让我们看看实际应用中是怎样的.
模式匹配示例
在此示例中, 我们使用Record
类来建模不同的交易类型, 目标是编写一个方法, 该方法将消耗一组交易并计算账户余额. 如果交易类型为"购买", 则需要增加余额; 如果为"支付", 则减少余额; 如果为"支付退款", 则再次增加余额.
vbnet
public interface Transaction {
String id();
}
record Purchase(String id, Integer purchaseAmount) implements Transaction {}
record Payment(String id, Integer paymentAmount) implements Transaction {}
record PaymentReturned(String id, Integer paymentAmount, String reason) implements Transaction {}
List transactions = List.of(
new Purchase("1", 1000),
new Purchase("2", 500),
new Purchase("3", 700),
new Payment("1", 1500),
new PaymentReturned("1", 1500, "NSF")
);
假设我们无法访问定义交易的代码库以进行重构, 或者这部分代码不应负责计算余额. 在我们的代码中实现 calculateAccountBalance
的一种方式如下.
typescript
public static Integer calculateAccountBalance(List transactions) {
var accountBalance = 0;
for (Transaction t : transactions) {
if (t instanceof Purchase p) {
accountBalance += p.purchaseAmount();
} else if (t instanceof Payment p) {
accountBalance -= p.paymentAmount();
} else if (t instanceof PaymentReturned p) {
accountBalance += p.paymentAmount();
} else {
throw new RuntimeException("Unknown transaction type");
}
}
return accountBalance;
}
这种实现方式还不错, 可读性较强, 但如果需要处理更多类型的交易, 代码可能会变得较为冗长. Record
的模式匹配通过以下实现方式对此进行了改进.
csharp
public static Integer calculateAccountBalance(List transactions) {
var accountBalance = 0;
for (Transaction t: transactions) {
switch(t) {
case Purchase p -> accountBalance += p.purchaseAmount();
case Payment(var id, var amt) -> accountBalance -= amt;
case PaymentReturned(var id, var amt, var reason) -> accountBalance += amt;
default -> throw new RuntimeException("Unknown transaction type");
}
}
return accountBalance;
}
这看起来更简洁且不冗余. 注意 switch
关键字------它在 Java 中已存在很长时间. 在 Java 17 中, switch
得到了增强, 支持与 lambda 表达式类似的语法, 而在 Java 19 和 21 中, switch
进一步增强, 支持对Record
进行模式匹配.
在使用模式匹配时, 我们可以像第一个示例所示那样引用类型的实例, 或者像第二个和第三个示例那样将类型分解为其组成部分. 开关语句的模式匹配还允许我们使用新的 when
关键字基于布尔表达式匹配模式. 例如, 我们可以使用不同的谓词多次匹配同一类型并执行不同的操作.
csharp
switch(t) {
case PaymentReturned p when p.reason.equals("NSF") -> ...
case PaymentReturned p -> ...
}
如果你仍然对模式匹配的实用性持怀疑态度, 还有一件事需要说明. 假设过了一段时间, 我们引入了一种新的交易类型, 暂且称之为Credit
, 用于表示反向购买交易. 通过添加一个实现Transaction
接口的新Record
类型, 我们的代码在两种实现中仍然可以编译通过. 我们只会在运行时发现问题, 即我们的逻辑遇到一个它不知道如何处理的类型, 并抛出异常.
模式匹配对Record
的可用性进一步得到了另一个语言特性的增强, 该特性在 Java 17 中引入, 即密封类和密封接口(JEP409). 将接口标记为密封, 会向编译器传达Transaction
的实现数量是有限的, 因此编译器可以验证模式匹配中所有情况均已处理. 实现类必须与密封接口位于同一文件中(例如在接口内部), 或通过在接口上使用permits
关键字指定封闭的类型层次结构(详见JEP).
现在, 如果我们遗漏了其中一个情况的处理, 代码将无法编译. 为了确保这一点, 我们需要移除默认情况, 该情况通常会捕获任何缺失的模式.
csharp
public sealed interface Transaction {
String id();
// define in here records extending interface
}
public static Integer calculateAccountBalance(List transactions) {
var accountBalance = 0;
for (Transaction t: transactions) {
switch(t) {
case Purchase p -> accountBalance += p.purchaseAmount();
case Payment(var id, var amt) -> accountBalance -= amt;
case PaymentReturned(var id, var amt, var reason) -> accountBalance += amt;
}
}
return accountBalance;
}
现在编译器会显示以下友好的错误信息------"编译失败: switch语句未覆盖所有可能的值".
接口上的 sealed
关键字在我们最初使用 if-else
块的实现中无法帮助我们, 因此模式匹配在这里更具优势. 密封接口/类还有更多内容超出了本文的范围, 但这是一个示例, 展示了不同语言特性如何协同工作并提升代码的健壮性.
值得一提的是, 可以使用访问者设计模式来解决上述问题. 然而, 该模式会引入更多复杂性和需要编写的代码量. 随着 Java 21 引入密封接口和模式匹配, 访问者设计模式已基本过时.
Java 21 对开发环境的影响
Java 21是一个功能丰富的版本, 基于Java 17以来的功能进行扩展. Java 21被供应商选为下一个长期支持(LTS)版本, 这对通常只允许使用LTS版本的大型企业来说是个好消息.
本文未提及的 Java 21 还有许多令人兴奋的新特性. 其中最受期待的或许是最终在 Java 21 中定稿的虚拟线程(Virtual Threads, 即项目 Loom), 但这是一个更庞大的话题, 需要单独撰写博客文章. 你可通过 OpenJDK 官网 了解更多关于 JDK 21 的最新发布信息.
Java 始终以稳健且负责任的方式演进. 它密切关注软件行业的发展趋势, 并通过谨慎实施新特性, 确保向后兼容性, 来保持语言的适用性.
Java 21 版本的使用体验令人愉悦, 这一重大里程碑将确保 Java 在未来数年内继续在行业中占据重要地位.
好吧, 今天的内容就分享到这里啦!
一家之言, 欢迎拍砖!
Happy coding! Stay GOLDEN!