first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View File

@ -0,0 +1,130 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 拥抱Java新特性像设计者一样工作和思考
你好我是范学雷欢迎加入我的课程。从今天开始我要用20讲的时间和你聊聊JDK 8之后Java最重要的一些新特性。
说到新特性啊,有些人可能会不以为然,他们会说:
学这些新东西有必要吗?
新特性好是好,还是等到用到的时候再去学习吧!
Java已经老了为什么我不去学习新语言呢
网上有很多关于Java新特性的文章为什么还要学习你这个专栏
我理解为什么会有这样的问题,尽管我持有不一样的观点。
作为Oracle的成员Java安全的主要推动者和贡献者之一我从JDK 5开始就一直在参与Java语言及其标准类库的设计和演进。我的日常工作包括关注信息安全威胁与技术进展制定与实现Java安全规范促进 Java 技术的普及与运用等等。
在每一个JDK的版本里你都能看到大量我共享过和评审过的代码。在这一过程中我也体验了很多优秀的设计和优秀的代码见证了代码背后的各种考量和艰难取舍。
比如说在代码安全性和性能之间我们该如何抉择在代码的可维护性方面我们能不能有所提高API的设计能不能再皮实一点这些问题刚开始学习编程的同学可能太不在意但是解决好这些问题可以使我们的工作轻松很多。
我还在极客时间上线了专栏《代码精进之路》和《实用密码学》分享了我在Java和密码学领域的经验。一直以来与Java还有这些新特性的接触让我对上述问题有过很认真的思考。我认为学习Java新特性不仅很有必要而且最好的时机就是现在。
给你一个保守、粗暴的估计你如果从JDK 8迁移到JDK 17并且能够恰当使用JDK 8以后的新特性的话产品的代码量可以减少20%代码错误可以减少20%产品性能可以提高20%维护成本可以降低20%。这些,都是实实在在的收益。
拥抱Java新特性掌握主动权
为什么我会得出这样的结论呢?
从设计者的角度,我们设计一项新特性,是为了满足新的需求,为了适应更广阔的前景。而这些新特性的优越性,会随着时间的推进越来越明显。
比如说吧JDK 1.4.2所在的时代,用户的数量还没有这么多,服务器也不需要支持那么多的并发。所以,当时主流的客户端-服务器的设计,是使用阻塞式的套接字接口编程。现在,如果淘宝、京东还使用阻塞式的套接字接口,那是没有一点希望支持双十一的巨大流量的。
Java的有些新技术甚至能催生一个新行业。比如Java代理的技术就至少催生了动态监控和入侵检测两大领域的颠覆性变革并且诞生了数家明星公司和明星产品。Java代理的技术的本意并不是动态监控和入侵检测但是用户创造性地使用了这项基础技术实现了应用技术的关键突破。
现在Java已经迭代到了JDK 17不需要依靠内幕消息我们也能知道主流企业很快就会拥抱JDK 11或者JDK 17就像它们曾经拥抱过JDK 7或者JDK 8一样。
所以,不管你拒绝新技术、新特性的理由是什么。跟不上技术进步?认为新技术没有用?你都是时候重新审视它了。可以说,对于致力于创造新价值的我们来说,既然投身于计算机科学的领域,除了拥抱新技术,我们没有别的选择。
如果你因此退却,想要去学习一门新的语言。我也想要提醒一句,两口五尺深的井打出的水,并不一定比十尺深的井里的水甜。
那可不可以等到需要用的时候,再去学习这些新特性呢?
这么听起来好像有道理。但我想你也没法否认,很多技术,你不了解它,它是不会进入你的意识里来的,你也不会知道什么时候该使用它。因为,在你的世界里,它根本就不存在;它要解决的问题,你当然也不是很清楚。
如果你坚持不去了解不去积累,那就丧失了主动性,只能是被动的跟随者,甚至是拖后腿的反对者。而高级工程师和初级工程师之间的差距,恰恰就是积累和见识。你打破脑袋想不出的问题,在别人那里也许看一眼就能解决。
如果可以,为什么不现在就积累呢?尽早成为一个方案的制定者,而不是执行者,不是更好吗?
像Java语言的设计者一样思考
你可能对Java的新特性并不了解或者已经在网络上看到了很多讲解Java新特性的文章。这些资料可能会告诉你这些新特性的种类、使用方法。但在我看来了解一个新特性背后的这些逻辑和实际运用发掘它未来的潜力远远比学会这个新特性更重要。
在设计新特性时我们还要考量这项新特性是否能够持续地满足新的需求Requirement、增强代码安全Security、提高生产效率Productivity 、提升产品性能Performance、降低维护成本Maintenance
基于这些考量除了对于单个的新特性的介绍我还会和你讨论新技术组合的化学反应比如在第8讲到第9讲我们会讨论怎么通过封闭类、档案类以及模式匹配的叠加效果把代码的错误处理性能提高数百倍。
无论你的基础如何,我都可以从新特性设计者的角度带你由浅及深地了解它们,而且可以让你学得更快、更精准、更深入,减少自己摸索的过程,更快地获得竞争的优势。
在使用云计算的时代,每一份性能提升,都是实实在在的成本消减;每一点工作效率的提升,都能为你争取到更多休闲时光;每一份错误的减少,都可以尽快熄灭深夜的灯光。何乐而不为呢?
我们会一起学习哪些新特性?
可是从JDK 9到JDK17这么多的版本和新特性到底要从哪里开始学、怎么学才能迅速、精准地抓到这些新特性的精髓提高工作效率呢
在这门课程里我从JDK 9到JDK 17的新特性中筛选出了最核心、有用的18条特性。我会分三个模块给你讲解一般软件工程师需要经常使用的Java语言新技能。
在第一模块,我会给你介绍一些可以提升编码效率的特性,比如说档案类。
可以说档案类是一个看起来不起眼的小技术。但它却具有巨大的能量。我们的代码里存在大量的只读性质的数据。没有档案类的时候我们要想把一个数据正确地表述好抽象成一个类需要上百行的代码还要小心遵守Java的各种规范比如说比较两个实例的规矩。有了档案类完全相同的逻辑就只需要一两行代码了。毫无疑问档案类会把我们从千篇一律的数据表述代码里解放出来有更多的时间专注于真正有价值的工作。
学完这一部分内容你能够使用这些新特性大幅度提高自己的编码效率降低编码错误。保守估计你的编码效率可以提高20%。这也就意味着,如果工作量不变,每一个星期你都可以多休息一天。
在第二模块,我们会把焦点放在提升代码性能上,比如错误处理的最新成果。
使用异常来处理错误的逻辑抛出异常和捕获异常一直以来都是Java错误处理的不二选择。然而抛出异常和捕获异常的开销是巨大的在按计算能力付费的环境下异常处理的意外开销是增厚账单的一个重要因素。因此Go语言甚至完全放弃了异常处理的方式。当Java语言发展到JDK 17的时候我们有没有办法在Java语言里使用类似于Go语言的错误处理方式甚至变得更好呢这些问题你会在第二个模块里找到答案。
学完这一部分内容你将能够使用这些新特性大幅度提高软件产品的性能帮助公司提高用户满意度节省运营费用。保守估计你编写代码的性能可以提高20%,甚至更多。
在第三模块我会跟你讲讲如何通过新特性降低维护难度比如模块化和安全性、兼容性问题。学完这一部分内容你将能够编写出更健壮更容易维护的代码并且能够知道怎么高效地把旧系统升级到Java的新版本。这一部分的目标就是帮助你把代码的维护成本降低20%或者更多。
以终为始,这样学习新特性对你帮助最大
在讲解这些新特性的时候,我会以终为始,从便于你使用它们写出高质量代码的角度来展开。
首先,这门课会采用案例阅读和讨论的形式展开。
每一个新特性,我们都从阅读案例开始。这样,随着对案例的拆解和步步深入的改进,我们能够更加了解它们,理解每一个新特性诞生背后的推动力量。
这能够提高你的见识和思辨能力。让你的面试不再停留在无话可说、无题可聊的表面层次上。同时,你的代码编写能力也会有所提升。
所以,请你一定认真阅读、思考每一个案例,这是你学习这门课的基础。
然后,我还启用了多样的代码样本。
学习一门语言的新技术,最好的办法就是反复地折腾软件代码。看看什么样的写法是合法的,什么样的写法是非法的;什么样的写法更有效率,什么样的写法会是一团糟。所以,这门课的每一讲、每一个新特性,我们都会反复地讨论:以前的代码能怎么写,现在代码该怎么写,什么样的代码容易犯错,什么样的代码更养眼、更健壮,还能有什么样的改进。帮助你掌握运用新技术的方法和场景。
为了方便我们折腾我在GitHub上开设了一个代码库。我希望你能认真阅读这些折腾来折腾去的样本把这些代码下载下来反复地修改持续地改进。
我们都希望在最短的时间内掌握新知识,但事实是,知识与能力之间仍然有着一道巨大的鸿沟。所以,请你花更多的时间,把飘渺的知识,转化成你自己内建的能力。折腾的代码越多,你能从中学到的也就越多。这是提升你能力的关键。
最后,我还设置了代码评审这样的反馈环节。
学习最快的途径就是请教更优秀的人一句话的点拨也许就胜过你几年的摸着石头过河。而加入一个社区提交代码接受同行和专家的评审恐怕是目前最现实的、最有效的办法之一了。为了方便大家提交代码和评审代码我在GitHub上开放了代码提交申请。
任何一行你折腾过的代码,包括每一节留下的思考题,你都可以提交一个 GitHub的拉取请求Pull Request然后看看同行们的建议。当然啦对于别人提交的代码来说你也是他们期望寻求帮助的同行。我也会阅读一部分拉取请求给出我的建议我们共同进步。
所以,我建议你一定要积极地参与代码评审这样的反馈环节,给别人反馈也听取别人给你的反馈。在一定意义上,这也决定了你学习的速度和深度。
这样学习下来不管你是刚刚开始学习Java语言还是拥有扎实的Java语言和面向对象设计基础都不再会被下面的问题难住
对新特性略知一二,但却不了解它为什么这么设计、可以解决什么问题、怎么用,导致在面试、在和别人交流技术时,顾左右而言他,要么和心仪的公司失之交臂,要么难以形成自己的技术影响力。
了解了新技术的各种功能、各种概念,但是对它们的最佳实践、运用方法和场景知之甚少,一写代码就没了底气,写出来的代码缺东少西。
看到了旧代码优化的空间,也看到了新技术的前景,但是怎么具体地改进旧代码、写好新代码,好像也想不出更好的办法来。
一句话总结,对于每一个新技术,“面试聊得开,代码写得好,技术用得上”,是我希望这门课能够给你带来的提升。
好了今天的内容就先到这里。也期待你可以在留言区和我聊聊你对这门课的期待以及你在学习Java新特性时的经历。接下来让我们正式开始学习Java语言开启这段打怪升级的旅程吧

View File

@ -0,0 +1,289 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 JShell怎么快速验证简单的小问题
你好我是范学雷。今天我们聊一聊Java的交互式编程环境JShell。
JShell这个特性是在JDK 9正式发布的。从名字我们就能想到JShell是Java的脚本语言。一门编程语言为什么还需要支持脚本语言呢编程语言的脚本语言会是什么样子的它又能够给我们带来什么帮助呢
让我们一起来一层一层地拆解这些问题弄清楚Java语言的脚本工具是怎么帮助我们提高生产效率的。我们先从阅读案例开始。
阅读案例
学习编程语言的时候我们可能都是从打印“Hello, world!”这个简单的例子开始的。一般来说Java语言的教科书也是这样的。今天我们也从这个例子开始温习一下Java语言第一课里面涉及的知识。
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
好了,有了这段可以拷贝的代码,接下来我们该怎么办呢?
首先我们需要一个文本编辑器比如vi或者类似于IDEA这样的集成编辑环境把这段代码记录下来。文本编辑器每个人都有不同的偏好每个系统都有不同的偏好。一个软件工程师可能需要很长时间才能找到自己顺手的编辑器。就我自己而言我使用了二十多年vi编辑器直到这两年才发现IDEA的好。但是使用IDEA的时候我还是会不自主地敲击vi的命令。不得不说顺手确实是一个很顽固、难改的行为习惯。
回到刚才的正题。有了文本编辑器接下来我们要把这段源代码编译成Java的字节码。编译器会帮助我们评估这段代码看看有没有错误有没有需要警示的地方。通常我们使用javac命令行或者通过集成编辑环境自动编译。
$ javac HelloWorld.java
编译完成之后我们要运行编译好的字节码把程序的结果显示出来。在这里我们一般使用java命令行或者通过集成编辑环境来运行。
$ java HelloWorld
最后一步,我们要观察运行的结果,检查一下是不是我们期望的结果。
Hello, world!
如果让我去教授Java语言教到这里我会让同学们小小地庆祝一下我们完成了Java语言的第一个程序。
万事开头难完成Java语言的第一个小程序尤其难 你要学习使用编辑器、使用编译器、使用运行环境。对于一个编程语言的初学者而言这是迈入Java语言世界的第一步也是很大的一步。这当然是巨大的收获一个小小的庆祝当然是应得的也是值得的
当然会有同学试着改动这段代码享受创造的乐趣。比如说把“Hello, world!”改成“世界你好”或者“How are you?”。 这样一来,我们就还要经历编辑、编译、运行、观察这样的过程。
class HowAreYou {
public static void main(String[] args) {
System.out.println("How are you?");
}
}
毫不意外对Java的了解更深之后还会有同学继续修改代码把System.out换成System.err。然后同样的过程还要再来一遍编辑、编译、运行、观察。
其实编辑、编译、运行、观察这四个步骤就是我们学习一门新语言或者一项新特性的常规过程。如果你已经有多年的Java语言使用经验想一想吧你是怎么学习JDK 7的try-with-resource语句又是怎么学习JDK 8的Lambda表达式的是不是也是类似的过程
也许你已经习惯了这样的过程并没有感觉得到有什么不妥当的地方。不过如果我们看看bash脚本语言的处理也许你会发现问题所在。
bash $ echo HelloWorld
Hello, world!
bash $
显然使用bash编写的“Hello, world!”要简单得多。你只需要在命令行输入代码bash就会自动检查语法立即打印出结果它不需要我们调用额外的编辑器、编译器以及解释器。当然这并不是说bash不需要编译和运行过程。bash只是把这些过程处理得自动化了不再需要我们手动处理了。
拖后腿的学习效率
没有对比就没有伤害。一般来说不管是初学者还是熟练的程序员使用bash都可以快速编写出“Hello, world!”不到一分钟我们就可以观察到结果了。但是如果使用Java一个初学者也许需要半个小时甚至半天才能看到输出结果而一个熟练的程序员也需要几分钟甚至十几分钟才能完成整个过程。
这样的学习效率差异并不是无关紧要的。有来自学校的反馈表明老师和学生放弃Java的最重要的原因就是学习Java的门槛太高了尤其是入门第一课。上面的这个小小的“Hello, world!”程序需要极大的耐心才能看到最后的结果。这当然影响了新的小伙伴们学习Java的热情。而且老朋友们学习Java新技术的热情以及深入学习现有技术的热情也会受到了极大的阻碍。
JDK 17发布的时候我们经常可以看到这样的评论“然而我还是在使用JDK 8”。确实没有任何人也没有任何理由责怪这样的用户。除非有着严格的自律和强烈的好奇心没有人喜欢学习新东西尤其是学习门槛比较高的时候。
如果需要半个小时,我们才能看一眼一个新特性的样子,重点是,这个新特性还不一定能对我们有帮助,那很可能我们就懒得去看了。或者,我们也就是看一眼介绍新特性的文档,很难有动手试一试的冲动。最后,我们对它的了解也就仅仅停留在“听过”或者“看过”的程度上,而不是进展到“练过”或者“用过”的程度。
那你试想一下,如果仅仅需要一分钟,我们就能看到一个新特性的样子呢?我想,在稍纵即逝的好奇心消逝之前,我们很有可能会尝试着动动手,看一看探索的成果。
实际上学习新东西及时的反馈能够给我们极大的激励推动着我们深入地探索下去。那Java有没有办法变得像bash那样一分钟内就可以展示学习、探索的成果呢
及时反馈的JShell
办法是有的。JShell也就是Java的交互式编程环境是Java语言给出的其中一个答案。
JShell API和工具提供了一种在 JShell 状态下交互式评估 Java 编程语言的声明、语句和表达式的方法。JShell 的状态包括不断发展的代码和执行状态。为了便于快速调查和编码,语句和表达式不需要出现在方法中,变量和方法也不需要出现在类中。
我们还是通过例子来理解上面的表述。
启动JShell
JShell的工具是以Java命令的形式出现的。要想启动JShell的交互式编程环境在控制台shell的命令行中输入Java的脚本语言命令 “ jshell ” 就可以了。
下面的这个例子显示的就是启动JShell这个命令以及JShell的反馈结果。
$ jshell
| Welcome to JShell -- Version 17
| For an introduction type: /help intro
jshell>
我们可以看到JShell启动后Java的脚本语言就接管了原来的控制台。这时候我们就可以使用JShell的各种功能了。
另外JShell的交互式编程环境还有一个详细模式能够提供更多的反馈结果。启用这个详尽模式的办法就是使用“-v”这个命令行参数。我们使用JShell工具的主要目的之一就是观察评估我们编写的代码片段。因此我一般倾向于启用详细模式。这样我就能够观察到更多的细节有助于我更深入地了解我写的代码片段。
$ jshell -v
| Welcome to JShell -- Version 17
| For an introduction type: /help intro
退出JShell
JShell启动后就接管了原来的控制台。要想重新返回原来的控制台我们就要退出JShell。退出JShell需要使用JShell的命令行。
下面的这个例子显示的就是怎么使用JShell的命令行也就是“exit”退出java的交互式编程环境。需要注意的是JShell的命令行是以斜杠开头的。
jshell> /exit
| Goodbye
JShell的命令
除了退出命令我们还可以使用帮助命令来查看JShell支持的命令。比如在JDK 17里帮助命令的显示结果其中的几行大致是下面这样
jshell> /help
| Type a Java language expression, statement, or declaration.
| Or type one of the following commands:
| /list [<name or id>|-all|-start]
| list the source you have typed
... snipped ...
| /help [<command>|<subject>]
| get information about using the jshell tool
... snipped ...
熟悉JShell支持的命令能给我们带来很大的便利。限于篇幅我们这里不讨论JShell支持的命令。但是我希望你可以通过帮助命令或者其他的文档了解这些命令。它们可以帮助你更有效率地使用这个工具。
我相信你肯定会对帮助命令显示的第一句话非常感兴趣输入Java语言的表达式、语句或者声明。下面我们就来重点了解一下这一部分。
立即执行的语句
首先我们来看一看使用JShell来评估Java语言的语句。比如我们可以使用JShell来完成打印“Hello, world!”这个例子。
jshell> System.out.println("Hello, world!");
Hello, world!
jshell>
可以看到一旦输入完成JShell立即就能返回执行的结果而不再需要编辑器、编译器、解释器。
更方便的是我们可以使用键盘的上移箭头编辑上一次或者更前面的内容。如果我们想评估System.out其他的方法比如不追加行的打印我们编辑上一次的输入命令把上面例子中的“println”换成“print”。就像下面这样就可以了。
jshell> System.out.print("Hello, world!");
Hello, world!
jshell>
如果我们使用了错误的方法或者不合法的语法JShell也能立即给出提示。
jshell> System.out.println("Hello, world\!");
| Error:
| illegal escape character
| System.out.println("Hello, world\!");
| ^
JShell的这种立即执行、及时反馈的特点毫无疑问地加快了我们学习和评估简单Java代码的速度激励着我们去学习更多的东西更深入的技能。
可覆盖的声明
另外JShell还有一个特别好用的功能。那就是它支持变量的重复声明。JShell是一个有状态的工具这样我们就能够很方便地处理多个有关联的语句了。比如说我们可以先试用一个变量来指代问候语然后再使用标准输出打印出问候语。
jshell> String greeting;
greeting ==> null
| created variable greeting : String
jshell> String language = "English";
language ==> "English"
| created variable language : String
jshell> greeting = switch (language) {
...> case "English" -> "Hello";
...> case "Spanish" -> "Hola";
...> case "Chinese" -> "Nihao";
...> default -> throw new RuntimeException("Unsupported language");
...> };
greeting ==> "Hello"
| assigned to greeting : String
jshell> System.out.println(greeting);
Hello
jshell>
为了更方便地评估你可以使用JShell运行变量的重复声明和类型变更。比如说我们可以再次声明只带问候语的变量。
jshell> String greeting = "Hola";
greeting ==> "Hola"
| modified variable greeting : String
| update overwrote variable greeting : String
或者,把这个变量声明成一个其他的类型,以便后续的代码使用。
jshell> Integer greeting;
greeting ==> null
| replaced variable greeting : Integer
| update overwrote variable greeting : String
变量的声明可以重复也可以转换类型就像上一个声明并不存在一样。这样的特点和Java的可编译代码有所不同在可编译的代码里在一个变量的作用域内这个变量的类型是不允许转变的也不允许重复声明。
JShell支持可覆盖的变量主要是为了简化代码评估解放我们的大脑。要不然我们还得记住以前输入的、声明的变量这可不是一个简单的任务。
也正是因为JShell支持可覆盖的变量我们才能说JShell支持不断发展的代码JShell才能够更有效地处理多个关联的语句。
独白的表达式
前面我们说过JShell工具可以接受的输入包括Java语言的表达式、语句或者声明。刚才讨论了语句和声明的例子现在我们来看看输入表达式是什么样子的。
我们知道在Java程序里语句是最小的可执行单位表达式并不能单独存在。但是JShell却支持表达式的输入。比如说输入“1+1”JShell会直接给出正确的结果。
jshell> 1 + 1
$1 ==> 2
| created scratch variable $1 : int
有了独立的表达式,我们就可以直接评估表达式,而不再需要把它附着在一个语句上了。毫无疑问,这简化了表达式的评估工作,使得我们可以更快地评估表达式。下面的例子,就可以用来探索字符串常量和字符串实例的联系和区别,而不需要复杂的解释性代码。
jshell> "Hello, world" == "Hello, world"
$2 ==> true
| created scratch variable $2 : boolean
jshell> "Hello, world" == new String("Hello, world")
$3 ==> false
| created scratch variable $3 : boolean
总结
到这里今天的课程就要结束了我来做个小结。从前面的讨论中我们了解了JShell的基本概念、它的表达形式以及编译的过程。
JShell提供了一种在 JShell 状态下交互式评估 Java 编程语言的声明、语句和表达式的方法。JShell 的状态包括不断发展的代码和执行状态。为了便于快速调查和编码,语句和表达式不需要出现在方法中,变量和方法也不需要出现在类中。
JShell的设计并不是为了取代IDE。JShell在处理简单的小逻辑验证简单的小问题时比IDE更有效率。如果我们能够在有限的几行代码中把要验证的问题表达清楚JShell就能够快速地给出计算的结果。这一点能够极大地提高我们的工作效率和学习热情。
但是对于复杂逻辑的验证使用JShell也许不是一个最优选择。这时候也许使用IDE或者可编译的代码更合适。
我还拎出了几个技术要点,这些都可能在你的面试中出现。通过这一次学习,你应该能够:
了解JShell的基本概念知道JShell有交互式工具也有API
面试问题你使用过JShell吗
知道JShell能够接收Java编程语言的声明、语句和表达式以及命令行
面试问题JShell的代码和普通的可编译代码有什么不一样
这一次的讨论主要是想让你认识到JShell能给我们带来的便利知道简单的使用方法。这样当后面我们想要讨论更多的话题时你就可以使用JShell快速验证你的小问题、小想法。 要想掌握JShell更复杂的用法请参考相关的文档或者材料。
思考题
在前面的讨论里我们使用了一个例子来说明Java处理字符串常量的方式。
jshell> "Hello, world" == "Hello, world"
$2 ==> true
| created scratch variable $2 : boolean
对于精通Java语言的同学这个例子也许是直观的。但对部分同学来说这个例子也许过于隐晦。过于隐晦的代码不是好的代码。同样地过于隐晦的JShell片段也不是好的片段。
你有没有办法让这个例子更容易理解使用多个JShell片段是不是更好理解这就是我们今天的思考题。
欢迎你在留言区留言、讨论,分享你的阅读体验以及你对这个思考题的处理办法。
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请放在实例匹配专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在jshell/review/xuelei的目录下面。

View File

@ -0,0 +1,328 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 文字块:怎么编写所见即所得的字符串?
你好我是范学雷。今天我们聊一聊Java的文字块text blocks
文字块这个特性首先在JDK 13中以预览版的形式发布。在JDK 14中改进的文字块再次以预览版的形式发布。最后文字块在JDK 15正式发布。
文字块的概念很简单,它是一个由多行文字构成的字符串。既然是字符串,为什么还需要文字块这个新概念呢?文字块和字符串又有什么区别呢?我们还是通过案例和代码,来弄清楚这些问题吧。
阅读案例
我们在编写代码的时候总是或多或少地要和字符串打交道。有些字符串很简单比如我们都知道的”HelloWorld”字符串。有些字符串很复杂里面可能有换行、对齐、转义字符、占位符、连接符等。
比如下面的例子中我们要构造一个简单的表示”HelloWorld”的HTML字符串就需要处理好文本对齐、换行字符、连接符以及双引号的转义字符。这就使得这段代码既不美观、也不简约一点都不自然。
String stringBlock =
"<!DOCTYPE html>\n" +
"<html>\n" +
" <body>\n" +
" <h1>\"Hello World!\"</h1>\n" +
" </body>\n" +
"</html>\n";
这样的字符串不好写,不好看,也不好读。更糟糕的是,我们有时候需要从别的地方拷贝一段 HTML 或者 SQL 语句然后再转换成类似于上面的字符串。是不是出力多收效少需要特别的耐心遗憾的是这样的工作还特别多HTML, SQL, XML, JSON, HTTP, 随便就可以列一大堆。
不论对于写代码的人,还是阅读代码的人来说,处理这样的字符串都不是一件赏心悦目的事情。软件的质量是一个反馈系统,糟糕的事情总是可以让事情变得更糟糕。摊开来说,这样的字符串编写起来不省心,不仅消耗了更多时间,代码质量也没有保障。与此同时,复杂的语句也容易分散评审者的精力,让疏漏和错误不易被发现。
费时费力、质量还难以控制,这让复杂字符串的处理变成了一个很没有效率的事情。没有效率,也就意味着投入产出比低,所以我们就更不愿意投入精力和时间来做好这件事情。对于用户来说,糟糕的结果也会耗费他们更多的精力和时间。用户有多少,这个糟糕的成本就放大多少倍。
如果你经常需要阅读调试日志,你可能会有更深刻的体会。难以阅读的调试日志,可能会让你产生短暂的抗拒心理,甚至暂时地放弃调试,直到你的耐心又回来了。遗憾的是,提高调试日志的可读性,似乎永远排不上开发者的日程表。
这不是一个让人愉快的事情。不过,我们似乎也不曾有过更好的办法。
所见即所得的文字块
文字块是人们在试图扭转这种糟糕局面的过程中一个最重要的尝试。文字块是一个由多行文字构成的字符串。既然是字符串,文字块能有什么影响呢?其实,文字块是使用一个新的形式,而不是传统的形式,来表达字符串的。通过这个新的形式,文字块尝试消除换行、连接符、转义字符的影响,使得文字对齐和必要的占位符更加清晰,从而简化多行文字字符串的表达。
下面的这段代码,就是我使用文字块对阅读案例所做的改进。
String textBlock = """
<!DOCTYPE html>
<html>
<body>
<h1>"Hello World!"</h1>
</body>
</html>
""";
System.out.println(
"Here is the text block:\n" + textBlock);
对比一下阅读案例里的代码,我们可以看到,下面的这些特殊的字符从这个表达式里消失了:
换行字符(\n没有出现在文字块里
连接字符(+)没有出现在文字块里;
双引号没有使用转义字符(\)。
另外,出现在文字块开始和结束位置的,是三个双引号序列;而不是我们在字符串声明里看到的单个双引号。 文字块由零个或多个内容字符组成,从开始分隔符开始,到结束分隔符结束。开始分隔符是由三个双引号字符 (“”“) ,后面跟着的零个或多个空格,以及行结束符组成的序列。结束分隔符是一个由三个双引号字符 (”“”)组成的序列。
需要注意的是,开始分隔符必须单独成行;三个双引号字符后面的空格和换行符都属于开始分隔符。所以,一个文字块至少有两行代码。即使是一个空字符,结束分隔符也不能和开始分隔符放在同一行代码里。
jshell> String s = """""";
| Error:
| illegal text block open delimiter sequence, missing line terminator
| String s = """""";
jshell> String s = """
...> """;
s ==> ""
同样需要注意的是,结束分隔符只有一个由三个双引号字符组成的序列。结束分隔符之前的字符,包括换行符,都属于文字块的有效内容。
jshell> String s = """
...> OneLine""";
s ==> "OneLine"
jshell> String s = """
...> TwoLines
...> """;
s ==> "TwoLines\n"
由于文字块不再需要特殊字符、开始分隔符和结束分隔符这些格式安排,我们几乎就可以直接拷贝、粘贴看到的文字,而不再需要特殊的处理了。同样地,你在代码里看到的文字块是什么样子,它实际要表达的文字就是什么样子的。这也就是说,“所见即所得”。
很多系统里常见的“所见即所得”的境界终于也能够在Java语言里呈现出来了。
文字块的编译过程
那么,我们用文字块改进过的阅读案例,打印结果是什么样子的呢?从下面的打印结果,我们可以看到,为了代码整洁而使用的缩进空格并没有出现在打印的结果里。
Here is the text block:
<!DOCTYPE html>
<html>
<body>
<h1>"Hello World!"</h1>
</body>
</html>
也就是说,文字块的内容并没有计入缩进空格。文字块是怎么处理缩进空格的呢?这是我们学习文字块必须要了解的一个问题。
像传统的字符串一样,文字块是字符串的一种常量表达式。不同于传统字符串的是,在编译期,文字块要顺序通过如下三个不同的编译步骤:
为了降低不同平台间换行符的表达差异,编译器把文字内容里的换行符统一转换成 LF\u000A
为了能够处理Java源代码里的缩进空格要删除所有文字内容行和结束分隔符共享的前导空格以及所有文字内容行的尾部空格
最后处理转义字符,这样开发人员编写的转义序列就不会在第一步和第二步被修改或删除。
首先,我们从整体上来理解一下文字块的编译期处理这种方式。阅读一下下面的代码,你能不能预测一下下面这两个问题的结果?使用传统方式声明的字符串和使用文字块声明的字符串的内容是一样的吗?这两个字符串变量指向的是同一个对象,还是不同的对象?
package co.ivi.jus.text.modern;
public class TextBlocks {
public static void main(String[] args) {
String stringBlock =
"<!DOCTYPE html>\n" +
"<html>\n" +
" <body>\n" +
" <h1>\"Hello World!\"</h1>\n" +
" </body>\n" +
"</html>\n";
String textBlock = """
<!DOCTYPE html>
<html>
<body>
<h1>"Hello World!"</h1>
</body>
</html>
""";
System.out.println(
"Does the text block equal to the regular string? " +
stringBlock.equals(textBlock));
System.out.println(
"Does the text block refer to the regular string? " +
(stringBlock == textBlock));
}
}
第一个问题的答案应该没有意外,第二个问题的答案可能就会有意外出现了。使用传统方式声明的字符串和使用文字块声明的字符串,它们的内容是一样的,而且指向的是同一个对象。
该怎么理解这样的结果呢?其实,这就说明了,文字块是在编译期处理的,并且在编译期被转换成了常量字符串,然后就被当作常规的字符串了。所以,如果文字块代表的内容,和传统字符串代表的内容一样,那么这两个常量字符串变量就指向同一内存地址,代表同一个对象。
虽然表达形式不同但是文字块就是字符串。既然是字符串就能够使用字符串支持的各种API和操作方法。比如传统的字符串表现形式和文字块的表现形式可以混合使用
System.out.println("Here is the text block:\n" +
"""
<!DOCTYPE html>
<html>
<body>
<h1>"Hello World!"</h1>
</body>
</html>
""");
再比如文字块可以调用字符串String的API:
int stringSize = """
<!DOCTYPE html>
<html>
<body>
<h1>"Hello World!"</h1>
</body>
</html>
""".length();
或者,使用嵌入式的表达式:
String greetingHtml = """
<!DOCTYPE html>
<html>
<body>
<h1>%s</h1>
</body>
</html>
""".formatted("Hello World!");
巧妙的结束分隔符
好的,我们现在看看文字块编译的细分步骤。第一个和第二个步骤都很好理解。不过,第二个步骤里“删除共享的前导空格”,是一个我们可以巧妙使用的规则。通过合理地安排共享的前导空格,我们可以实现文字的编排和缩进。
为了方便理解,在下面的例子里,我们使用小数点号‘.’表示编译期要删除的前导空格,使用叹号‘!’表示编译期要删除的尾部空格。
第一个例子,我们把结束分隔符单独放在一行,和文本内容左边对齐。这时候,共享的前导空格就是文本内容本身共享的前导空格;结束分隔符仅仅是用来结束文字块的。这个例子里,我还加入了文字内容行的尾部空格,它们在编译期会被删除掉。
// There are 8 leading white spaces in common
String textBlock = """
........<!DOCTYPE html>
........<html>
........ <body>
........ <h1>"Hello World!"</h1>!!!!
........ </body>
........</html>
........""";
第二个例子,我们也把结束分隔符单独放在一行,但是放在比文本内容更靠左的位置。这时候,结束分隔符除了用来结束文字块之外,还参与界定共享的前导空格。
// There are 4 leading white spaces in common
String textBlock = """
.... <!DOCTYPE html>
.... <html>
.... <body>
.... <h1>"Hello World!"</h1>!!!!
.... </body>
.... </html>
....""";
第三个例子,我们也把结束分隔符单独放在了一行,但是放在文本内容左对齐位置的右侧。这时候,结束分隔符的左侧,除了共享的前导空格之外,还有多余的空格。这些多余的空格,就成了文字内容行的尾部空格,它们在编译期会被删除掉。
// There are 8 leading white spaces in common
String textBlock = """
........<!DOCTYPE html>
........<html>
........ <body>
........ <h1>"Hello World!"</h1>!!!!
........ </body>
........</html>
........!!!!""";
尾部空格还能回来吗?
你可能会问, 一般情况下,尾部空格确实没有什么实质性的作用。但是万一需要尾部空格,它们还能回来吗?
其实是可以的。为了能够支持尾部附带的空格,文字块还引入了另外一个新的转义字符,‘\s空格转义符。空格转义符表示一个空格。我们前面说过的文字块的编译器处理顺序空格转义符不会在文字块的编译期被删除因此空格转义符之前的空格也能被保留。所以每一行使用一个空格转义符也就足够了。
下面的代码,就是一个重新带回尾部空格的例子,这个字符串的前两行就包含有尾部空格。
// There are 8 leading white spaces in common
String textBlock = """
........<!DOCTYPE html> \s!!!!
........<html> \s
........ <body>!!!!!!!!!!
........ <h1>"Hello World!"</h1>
........ </body>
........</html>
........""";
该怎么表达长段落?
但是所见即所得的文字块也有一个小烦恼。我们知道,编码规范一般都限定每一行的字节数 通常是80个或者120个字节。可是一个文本的长段落通常要超出这个限制。文字块里的换行符通常需要保留编码规范通常要遵守那该如何表达长段落或者长行呢
针对这种情况,文字块引入了一个新的转义字符,‘<行终止符>换行转义符。换行转义符的意思是如果转移符号出现在一个行的结束位置这一行的换行符就会被取缔。下面的例子就使用了换行转义符它就把分散在两行的”Hello World!“连接在一行里了。
String textBlock = """
<!DOCTYPE html>
<html>
<body>
<h1>"Hello \
World!"</h1>
</body>
</html>
""";
需要注意的是上面的例子里换行转义符之前还有一个空格。这个空格会被删除吗连接后的字符是没有空格间隔的“HelloWorld!”还是中间有空格的“Hello World!”?还记得我们前面说过的编译器处理顺序吗?空格处理先于转义字符处理。因此,换行转义符之前的空格不算是文字块的尾部空格,因此会得到保留。
总结
好,到这里,今天的课程就要结束了,我来做个小结。从前面的讨论中,我们了解了文字块的基本概念,它的表达形式以及编译的过程。
文字块是 Java 语言中一种新的文字。 字符串能够出现的任何地方,也都可以用文字块表示。但是,文字块提供了更好的表现力和更少的复杂性。 文字块“所见即所得”的表现形式,使得使用复杂字符串的代码更加清晰,便于编辑,也便于阅读。这是一个能够降低代码错误,提高生产效率的改进。
如果要丰富你的代码评审清单,学习完这一节内容后,你可以加入下面这一条:
复杂的字符串,使用文字块表述是不是更清晰?
另外,通过今天的讨论,我拎出了几个技术要点,这些都可能在你的面试中出现。通过这一次学习,你应该能够:
知道文字块的基本概念,以及文字块和字符串的关系;
面试问题你知道Java的文字块吗它和字符串有什么区别
了解文字块要解决的问题,并且能够准确使用文字块;
面试问题:应当什么时候使用文字块?
了解文字块的表达形式,编译过程以及文字块特有的转义字符。
面试问题:怎么用文字块实现文本缩进?
如果能够有意识地使用文字块,你应该能够大幅度提高复杂字符串的可读性。从而更快地编写代码,也让潜在的错误更少。毫无疑问,在面试的时候,有意识地在代码里使用文字块,除了节省时间之外,还能够让你的代码更容易阅读和接受,给面试官带来新鲜的感受。
思考题
在前面的讨论里,我们说过文字块是一个“所见即所得”的字符串表现形式。我们可以直接拷贝、粘贴文字段落到代码里,而不需要大量的调整。可是,在有些场景里,要想完全地实现“所见即所得”,仅仅使用文字块,可能还是要费一点周折的。
比如说吧,我们看到的诗,有的时候是页面居中对齐的。比如下面的这首小诗,采用的格式就是居中对齐。
居中对齐这种形式在HTML或者文档的世界里很容易处理设置一下格式就可以了。如果是用Java语言该怎么处理好这首小诗的居中对齐问题这就是今天我们的思考题。
稍微提示一个你可以使用添加缩进空格的方式对齐也可以不局限于简单的、单纯的Java语言比如添加进来HTML的文本。
欢迎你在留言区留言、讨论,分享你的阅读体验以及你对这个思考题的想法。
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请放在实例匹配专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在text/review/xuelei的目录下面。

View File

@ -0,0 +1,479 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 档案类:怎么精简地表达不可变数据?
你好我是范学雷。今天我们聊一聊Java的档案类。
档案类这个特性首先在JDK 14中以预览版的形式发布。在JDK 15中改进的档案类再次以预览版的形式发布。最后档案类在JDK 16正式发布。
那么什么是档案类呢档案类的英文使用的词汇是“record”。官方的说法Java档案类是用来表示不可变数据的透明载体。这样的表述有两个关键词一个是不可变的数据另一个是透明的载体。
该怎么理解“不可变的数据”和“透明的载体”呢?我们还是通过案例和代码,一步一步地来拆解、理解这些概念。
阅读案例
在面向对象的编程语言中,研究表示形状的类是一个常用的教学案例。今天的评审案例,我们从形状的子类圆形开始,来看一看面向对象编程实践中,这个类的设计和演化。
下面的这段代码就是一个简单的、典型的圆形类的定义。这个抽象类的名字是Circle。它有一个私有的变量radius用来表示圆的半径。有一个构造方法用来生成圆形的实例。有一个设置半径的方法setRadius一个读取半径的方法getRadius。还有一个重写的方法getArea用来计算圆形的面积。
package co.ivi.jus.record.former;
public final class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double getArea() {
return Math.PI * radius * radius;
}
public double getRadius() {
return radius;
}
public void setRadius(double radius) {
this.radius = radius;
}
}
这个圆形类之所以典型,是因为它交代了面向对象设计的关键思想,包括面向对象编程的三大支柱性原则:封装、继承和多态。
封装的原则是隐藏具体实现细节实现的修改不会影响接口的使用。Circle类中表示半径的变量被定义成私有的变量。我们可以改变半径这个变量的名字或者不使用半径而是使用直径来表示圆形。这样的实现细节的变化并不会影响公开方法的调用。
由于需要隐藏内部实现细节所以需要设计公开接口来访问类的相关特征比如例子中的圆形的半径。所以上面的例子中设置半径的方法setRadius和读取半径的方法getRadius就显得显而易见并且顺理成章。在面向对象编程的教科书里以及Java的标准类库里我们可以看到很多类似的设计。
可是,这样的设计有哪些严重的缺陷呢?花点时间想想你能找到的问题,然后我们接下来再继续分析。
案例分析
上面这个例子最重要的问题就是它的接口不是多线程安全的。如果在一个多线程的环境中有些线程调用了setRadius方法有些线程调用getRadius方法这些调用的最终结果是难以预料的。这也就是我们常说的多线程安全问题。
在现代计算机架构下,大多数的应用需要多线程的环境。所以,我们通常需要考虑多线程安全的问题。 该怎么解决上面例子中的多线程安全问题呢?如果上述例子的实现源代码不能更改,那么就需要在调用这些接口的程序中,增加线程同步的措施。
synchronized (circleObject) {
double radius = circleObject.getRadius();
// do something with the radius.
}
遗憾的是,在调用层面解决线程同步问题的办法,并不总是显而易见的。不论多么资深的程序员,都有可能疏漏、忘记或者没有正确地解决好线程同步的问题。
所以通常地为了更皮实的接口设计在接口规范设计的时候就应该考虑解决掉线程同步的问题。比如说我们可以把上面案例中的代码改成线程安全的代码。对于Circle类只需要把它的公开方法都设置成同步方法那么这个类就是多线程安全的了。具体的实现请参考下面的代码。
package co.ivi.jus.record.former;
public final class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public synchronized double getArea() {
return Math.PI * radius * radius;
}
public synchronized double getRadius() {
return radius;
}
public synchronized void setRadius(double radius) {
this.radius = radius;
}
}
可是线程同步并不是免费的午餐。代价有多大呢我做了一个简单的性能基准测试哪怕最简单的同步比如上面代码里同步的getRadius方法它的吞吐量损失也有十数倍。这相当于说如果没有同步的应用需要一台机器支持的话加了同步的应用就需要十多台机器来支撑相同的业务量。
这样的代价就有点大了,我们需要寻找更好的办法来解决多线程安全的问题。最有效的办法,就是在接口设计的时候,争取做到即使不使用线程同步,也能做到多线程安全。这说起来还是有点难以理解的,我们还是来看看代码吧。
下面的代码是一个修改过的Circle类实现。在这个实现里圆形的对象一旦实例化就不能再修改它的半径了。相应地我们删除了设置半径的方法。也就是说这个对象是一个只读的对象不支持修改。通常地我们称这样的对象为不可变对象。
package co.ivi.jus.record.immute;
public final class Circle implements Shape {
public final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
对于只读的圆形类的设计,我们可以看到两个好处。
第一个好处,就是天生的多线程安全。因为这个类的对象,一旦实例化就不能再修改,所以即便在多线程环境下使用,也不需要同步。而不可变对象所承载的数据,比如上面例子中圆形的半径,就是我们前面所说的不可变的数据。这个不可变,是有一个界定范围的。这个界定范围,就是它所在对象的生命周期。如果跳出了对象的生命周期,我们可以重新生成新对象,从而实现数据的变化。
第二个好处,就是简化的代码。只读对象的设计,使得我们可以重新考虑代码的设计,这是代码简化的来源。你可能已经注意到了,在这个实现里,我们还删除了读取半径的方法。取而代之的,是公开的半径这个变量。这就是一个最直接的简化。
应用程序可以直接读取这个变量而不是通过一个类似于getRadius的方法。由于半径这个变量被声明为final变量所以它只可以被读取不能被修改。这并没有破坏对象的只读性。
不过乍看之下这样的设计似乎破坏了面向对象编程的封装原则。公开半径变量radius相当于公开的实现细节。如果我们改变主意想使用直径来表示一个圆形那么实现的修改就会显得很丑陋。
可是,如果我们认真思考一下几个简单的问题,对于封装的顾虑可能就降低很多了。比如说,使用直径来表示一个圆,这是一个真实的需求吗? 这是一个必需的表达方式吗?未来的圆,会不会变得没法使用半径来表达?其实不是的,未来的圆,还是可以用半径来表达的。使用其他的办法,比如直径,来表达一个圆,其实并没有必要。
所以,公开半径这个只读变量,并没有带来违反封装原则的实质性后果。而且,从另外一个角度来看,我们可以把读取这个只读变量的操作,看成是等价的读取方法的调用。不过,虽然很多人,包括我自己,倾向于这样解读,但是这总归是一个有争议的形式。
进一步的简化
还有没有进一步简化的空间呢我们再来看看不可变的正方形Square类的设计。具体的实现请参考下面的代码。
package co.ivi.jus.record.immute;
public final class Square implements Shape {
public final double side;
public Square(double side) {
this.side = side;
}
@Override
public double area() {
return side * side;
}
}
如果比较一下不可变的圆形Circle类和正方形Square类的源代码你有没有发现这两个类的代码有惊人的相似点
第一个相似的地方就是使用公开的只读变量使用final修饰符来声明只读变量。Circle类的变量radius和Square类的变量side都是公开的只读的变量。这样的声明是为了公开变量的只读性。
第二个相似的地方就是公开的只读变量需要在构造方法中赋值而且只在构造方法中赋值且这样的构造方法还是公开的方法。Circle类的构造方法给radius变量赋值Square类的构造方法给side变量赋值。这样的构造方法解决了对象的初始化问题。
第三个相似的地方,就是没有了读取的方法;公开的只读变量,替换了掉了公开的读取方法。这样的变化,使得代码量总体变少了。
这么多相似的地方,相似的代码,能不能进一步地简化呢?我知道,你可能已经开始思考这样的问题了。
对于这个问题Java的答案就是使用档案类。
怎么声明档案类
我们前面说过Java档案类是用来表示不可变数据的透明载体。那么怎么使用档案类来表示不可变数据呢
我们还是一起先来看看代码吧。咱们试着把上面不可变的圆形Circle普通的类改成档案类来感受下档案类到底是什么模样的。
package co.ivi.jus.record.modern;
public record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
}
看到这样的代码是不是有点出乎意料你可以对比一下不可变的Circle类的代码感受一下这两者之间的差异。
首先最常见的class关键字不见了取而代之的是record关键字。record关键字是class关键字的一种特殊表现形式用来标识档案类。record关键字可以使用和class关键字差不多一样的类修饰符比如public、static等但是也有一些例外我们后面再说
然后类标识符Circle后面有用小括号括起来的参数。类标识符和参数一起看就像是一个构造方法。事实上这样的表现方式的确可以看成是构造方法。而且这种形式还就是当作构造方法使用的。比如下面的代码就是使用构造方法的形式来生成Circle档案类实例的。
Circle circle = new Circle(10.0);
最后,在大括号里,也就是档案类的实现代码里,变量的声明没有了,构造方法也没有了。前面我们已经知道怎么生成一个档案类实例了,但还有一个问题是,我们能读取这个圆形档案类的半径吗?
其实,类标识符声明后面的小括号里的参数,就是等价的不可变变量。在档案类里,这样的不可变变量是私有的变量,我们不可以直接使用它们。但是我们可以通过等价的方法来调用它们。变量的标识符就是等价方法的标识符。比如下面的代码,就是一个读取上面圆形档案类半径的代码。
double radius = circle.radius();
是的,在档案类里,方法调用的形式又回来了。我们前面讨论过打破封装原则的顾虑,你可能还是没有足够的信心去接受不完整的封装形式。那么现在,档案类的调用形式依然保持着良好的封装形式。打破封装原则的顾虑也就不复存在了。
需要注意的是,由于档案类表示的是不可变数据,除了构造方法之外,并没有给不可变变量赋值的方法。
意料之外的改进
上面通过传统Circle类和档案Circle类代码的对比我们可以感受到档案类在简化代码、提高生产力方面的努力。如果说上面这些简化还在我的预料之内的话下面的简化我刚看到的时候是很惊喜的“哇这真是太奇妙了
我们还是通过代码来体验一下这种感受。如果我们生成两个半径为10厘米的圆形的实例这两个实例是相等的吗下面的代码就是用来验证我们猜想的。你可以试着运行一下看看和你猜想的结果是不是一样的。
package co.ivi.jus.record;
import co.ivi.jus.record.immute.Circle;
public class ImmuteUseCases {
public static void main(String[] args) {
Circle c1 = new Circle(10.0);
Circle c2 = new Circle(10.0);
System.out.println("Equals? " + c1.equals(c2));
}
}
上面的代码里使用了我们开篇案例分析中的传统Circle类。运行结果告诉我们两个半径为10厘米的圆形的实例并不是相等的实例。我想这应该在你的预料之内。
如果需要比较两个实例是不是相等我们需要重写equals方法和hashCode方法。如果需要把实例转换成肉眼可以阅读的信息我们需要重写toString方法。我们上面案例分析的代码中这些方法都没有重写因此对应的操作结果也是不可预测的。
当然如果没有遗忘我们可以添加这三个方法的重写实现。然而这三个方法的重写尤其是equals方法和hashCode方法的重写实现一直是代码安全的重灾区。即便是经验丰富的程序员也可能忘记重写这三个方法就算没有遗忘equals方法和hashCode方法也可能没有正确实现从而带来各种各样的问题。这实在难以让人满意但是一直以来我们也没有更好的办法。
档案类会不一样吗?
我们再来看看使用档案类的代码,结果会不会不一样呢? 下面的这段代码Circle的实现使用的是档案类。这段代码运行的结果告诉我们两个半径为10厘米的圆形的档案类实例是相等的实例。
package co.ivi.jus.record;
import co.ivi.jus.record.modern.Circle;
public class ModernUseCases {
public static void main(String[] args) {
Circle c1 = new Circle(10.0);
Circle c2 = new Circle(10.0);
System.out.println("Equals? " + c1.equals(c2));
}
}
看到这里,你是不是感觉到:哇! 这真的是太棒了!我们并没有重写这三个方法,它们居然可以使用。
为什么会这样呢?
这是因为档案类内置了缺省的equals方法、hashCode方法以及toString方法的实现。一般情况下我们就再也不用担心这三个方法的重写问题了。这不仅减少了代码数量提高了编码的效率还减少了编码错误提高了产品的质量。
不可变的数据
讨论到这里我们可以回头再看看Java档案类的定义了Java档案类是用来表示不可变数据的透明载体。“不可变的数据”和“透明的载体”是两个最重要的关键词。
我们前面讨论了不可变的数据。如果一个Java类一旦实例化就不能再修改那么用它表述的数据就是不可变数据。Java档案类就是表述不可变数据的。为了强化“不可变”这一原则避免面向对象设计的陷阱Java档案类还做了以下的限制
Java档案类不支持扩展子句用户不能定制它的父类。隐含的它的父类是java.lang.Record。父类不能定制也就意味着我们不能通过修改父类来影响Java档案的行为。
Java档案类是个终极final不支持子类也不能是抽象类。没有子类也就意味着我们不能通过修改子类来改变Java档案的行为。
Java档案类声明的变量是不可变的变量。这就是我们前面反复强调的一旦实例化就不能再修改的关键所在。
Java档案类不能声明可变的变量也不能支持实例初始化的方法。这就保证了我们只能使用档案类形式的构造方法避免额外的初始化对可变性的影响。
Java档案类不能声明本地native方法。如果允许了本地方法也就意味着打开了修改不可变变量的后门。
通常地我们把Java档案类看成是一种特殊形式的Java类。除了上述的限制Java档案类和普通类的用法是一样的。
透明的载体
好了,聊完“不可变的数据”,接下来该聊聊“透明的载体”了。
陆陆续续地,我们在前面提到过,档案类内置了下面的这些方法缺省实现:
构造方法
equals方法
hashCode方法
toString方法
不可变数据的读取方法
如果你注意到的话我们使用了“缺省”这样的字眼。换一种说法我们可以使用缺省的实现也可以替换掉缺省的实现。下面的代码就是我们试图替换掉缺省实现的尝试。请注意除了构造方法其他的替换方法都可以使用Override注解来标注如果你读过《代码精进之路》你就会倾向于总是使用Override注解的
package co.ivi.jus.record.explicit;
import java.util.Objects;
public record Circle(double radius) implements Shape {
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o instanceof Circle other) {
return other.radius == this.radius;
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(radius);
}
@Override
public String toString() {
return String.format("Circle[radius=%f]", radius);
}
@Override
public double radius() {
return this.radius;
}
}
到这里,你应该明白了“透明的载体”的意思了。透明载体的意思,通俗地说,就是档案类承载有缺省实现的方法,这些方法可以直接使用,也可以替换掉。
不过,像上面这样的替换,除了徒增烦恼,是没有实际意义的。那我们什么时候需要替换掉缺省实现呢?
重写构造方法
最常见的替换,是要在构造方法里对档案类声明的变量添加必要的检查。比如说,我们现实生活中看到的各种各样的圆形,它的半径都不会是负数。如果在这样的场景里来讨论圆形,那么表示圆形的类的半径就不应该是负数。
你应该已经意识到了我们上面的代码在实例化的时候都没有检查半径的数值包括档案类缺省的构造方法。那么这时候我们就要替换掉缺省的构造方法。下面的代码就是一种替换的方法。如果构造实例的时候半径的数值为负构造就会抛出运行时异常IllegalArgumentException。
package co.ivi.jus.record.improved;
public record Circle(double radius) implements Shape {
public Circle {
if (radius < 0) {
throw new IllegalArgumentException(
"The radius of a circle cannot be negative [" + radius + "]");
}
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
如果你阅读了上面的代码应该已经注意到了一点不太常规的形式构造方法的声明没有参数也没有给实例变量赋值的语句这并不是说构造方法就没有参数或者实例变量不需要赋值实际上为了简化代码Java编译的时候已经替我们把这些东西加上去了所以不论哪一种编码形式构造方法的调用都是没有区别的
在上一个例子中我们已经看到了构造方法的常规形式在下面这张表里我列出了两种构造方法形式上的差异你可以看看它们的差异
重写equals方法
还有一类常见的替换如果缺省的equals方法或者hashCode方法不能正常工作或者存在安全的问题就需要替换掉缺省的方法
如果声明的不可变变量没有重写equals方法和hashCode方法那么这个档案类的equals方法和hashCode方法的行为就可能不是可以预测的比如如果不可变的变量是一个数组通过下面的例子我们来看看它的equals方法能不能正常工作
jshell> record Password(byte[] password) {}
| modified record Password
jshell> Password pA = new Password("123456".getBytes());
pA ==> Password[password=[B@2ef1e4fa]
jshell> Password pB = new Password("123456".getBytes());
pB ==> Password[password=[B@b81eda8]
jshell> pA.equals(pB);
$16 ==> false
这个例子里我们设计了一个口令的档案类其中的口令使用字节数组来存放。我们使用同样的口令生成了两个不同的实例。然后我们调用equals方法来比较这两个实例。
运算的结果显示这两个实例并不相等。这不是我们期望的结果。其中的原因就是因为数组这个变量的equals方法并不能正常工作或者换个说法数组变量没有重写equals方法
如果把变量的类型换成重写了equals方法的字符串String我们就能看到预期的结果了。
jshell> record Password(String password) {};
| created record Password
jshell> Password pA = new Password("123456");
pA ==> Password[password=123456]
jshell> Password pB = new Password("123456");
pB ==> Password[password=123456]
jshell> pA.equals(pB);
$5 ==> true
一般情况下equals方法和hashCode方法是成双成对的实现逻辑上需要匹配。所以当我们重写equals方法的时候一般也需要重写hashCode方法反之亦然。
不推荐的重写
为了更个性化的显示我们有时候也需要重写toString方法。但是我们通常不建议重写不可变数据的读取方法。因为这样的重写往往意味着需要变更缺省的不可变数值从而打破实例的状态进而造成许多无法预料的、让人费解的后果。
比如说,我们设想定义一个数,如果是负值的话,我们希望读取的是它的相反数。下面的例子,就是一个味道很坏的示范。
jshell> record Number(int x) {
...> public int x() {
...> return x > 0 ? x : (-1) * x;
...> }
...> }
| created record Number
jshell> Number n = new Number(-1);
n ==> Number[x=-1]
jshell> n.x();
$9 ==> 1
jshell> Number m = new Number(n.x());
m ==> Number[x=1]
jshell> m.equals(n);
$11 ==> false
在这个例子里,我们重写了读取的方法。如果一个数是负数,重写的读取就返回它的相反数。读取出来的数据,并不是实例化的时候赋于的数据。这让代码变得难以理解,很容易出错。
更严重的问题是这样的重写不再能够支持实例的拷贝。比如说我们把实例n拷贝到另一个实例m。这两个实例按照道理来说应该相等。而由于重写了读取的方法实际的结果这两个实例是不相等的。这样的结果也可能会使代码容易出错而且难以调试。
总结
今天就到这里我来做个小结。从前面的讨论中我们了解到Java档案类是用来表示不可变数据的透明载体用来简化不可变数据的表达提高编码效率降低编码错误。同时我们也讨论了使用档案类的几个容易忽略的陷阱。
在我们日常的接口设计和编码实践中为了最大化的性能我们应该优先考虑使用不可变的对象数据如果一个类是用来表述不可变的对象数据我们应该优先使用Java档案类。
如果要丰富你的代码评审清单,有了封闭类后,你可以加入下面这一条:
一个类如果是用来表述不可变的数据能不能使用Java档案类
另外,通过今天的讨论,我拎出几个技术要点,这些都可能在你们面试中出现哦,通过学习,你应该能够:
知道Java支持档案类并且能够有意识地使用档案类提高编码效率降低编码错误
面试问题:你知道档案类吗?会不会使用它?
了解档案类的原理和它要解决的问题,知道使用不可变的对象优势;
面试问题:什么情况下可以使用档案类,什么情况下不能使用档案类?
了解档案类的缺省方法,掌握缺省方法的好处和不足,知道什么时候要重写这些方法。
面试问题:使用档案类应该注意什么问题?
如果你能够有意识地使用不可变的对象以及档案类,并且有能力规避掉其中的陷阱,你应该能够大幅度提高编码的效率和质量。毫无疑问,在面试的时候,这也是一个能够让你脱颖而出的知识点。
思考题
在重写equals方法这一小节里我们讨论了数组类型的不可变数据。我们已经知道了这样的数据类型需要重写equals方法和hashCode方法。其实toString()的方法也需要重写。今天的思考题,就是请你实现这些方法的重写。
方便起见,我们假设这个数组是字节数组,用来表示社会保障号。我们都知道,社会保障号是高度敏感的信息,不能被泄漏,也不能被盗取。你来想一想,有哪些方法需要重写?为什么?代码看起来是什么样子的?有难以克服的困难吗?
我开个头,写一个空白的档案类,你来把你想添加的代码补齐。
record SocialSecurityNumber(byte[] ssn) {
// Here is your code.
}
欢迎你在留言区留言、讨论,分享你的阅读体验以及对这些问题的思考。
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在档案类专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在record/review/xuelei的目录下面。

View File

@ -0,0 +1,370 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 封闭类:怎么刹住失控的扩展性?
你好我是范学雷。今天我们聊一聊Java的封闭类。
封闭类这个特性首先在JDK 15中以预览版的形式发布。在JDK 16中改进的封闭类再次以预览版的形式发布。最后封闭类在JDK 17正式发布。
那么什么是封闭类呢封闭类的英文使用的词汇是”sealed classes”。从名字我们就可以感受到封闭类首先是Java的类然后它还是封闭的。
Java的类我们都知道什么意思。那么“封闭”又是什么意思呢字面的意思就是把一些东西封存起来里面的东西出不去外面的东西也进不来所以可查可数。
“封闭”、“可查可数”,这些词汇字面看起来好像很通俗,但是实际上并不容易理解。我们还是通过案例和代码,一步一步地来了解封闭类吧。
阅读案例
在面向对象的编程语言中,研究表示形状的类,是一个常用的教学案例。今天的评审案例,我们也从形状这个类开始,来研究一下怎么判断一个形状是不是正方形吧。
下面的这段代码就是一个简单的、抽象的形状类的定义。这个抽象类的名字是Shape。它有一个抽象方法area()用来计算形状的面积。它还有一个公开的属性id用来标识这个形状的对象。
package co.ivi.jus.sealed.former;
public abstract class Shape {
public final String id;
public Shape(String id) {
this.id = id;
}
public abstract double area();
}
我们都知道,正方形是一个形状。正方形可以作为形状这个类的一个扩展类。它的代码可以是下面的样子。
package co.ivi.jus.sealed.former;
public class Square extends Shape {
public final double side;
public Square(String id, double side) {
super(id);
this.side = side;
}
@Override
public double area() {
return side * side;
}
}
那么,到底怎么判断一个形状是不是正方形呢?这个问题的答案,表面上看起来很简单,只要判断这个形状的对象是不是一个正方形的实例就可以了。这个判断的例子,看起来可以是下面的样子。
static boolean isSquare(Shape shape) {
return (shape instanceof Square);
}
你可以思考一下,这样是不是真的能判断一个形状是正方形?花几秒钟想想你的答案,我们接下来再继续分析。
案例分析
其实,上面的这个例子,判断的只是“一个形状的对象是不是一个正方形的实例”。但实际上,一个形状的对象即使不是一个正方形的类,它也有可能是一个正方形。什么意思呢?比如说有一个对象,表示它的类是长方形或者菱形的类。如果这个对象的每一个边的长度都是一样的,其实它就是一个正方形,但是表示它的类是长方形或者菱形的类,而不是正方形类。所以,上面的这段代码还是有缺陷的,并不总是能够正确判断一个形状是不是正方形。
详细地我们来看下一段代码你就对这个缺陷有一个更直观的了解了。我们都知道长方形也是一个形状它也可以作为形状这个类的一个扩展类。下面的这段代码定义的就是一个长方形。这个类的名字是Rectangle它是Shape的扩展类。
package co.ivi.jus.sealed.former;
public class Rectangle extends Shape {
public final double length;
public final double width;
public Rectangle(String id, double length, double width) {
super(id);
this.length = length;
this.width = width;
}
@Override
public double area() {
return length * width;
}
}
代码读到这里,对于“怎么判断一个形状是不是正方形”这个问题,我觉得你可能已经有了一个更好的思路。没错,正方形是一个特殊的长方形。如果一个长方形的长和宽是相等的,那么它也是一个正方形。上面的那段“判断一个形状是不是正方形”的代码,就没有考虑到长方形的特例,所以它是有缺陷的实现。
知道了长方形这个类,我们就能改进我们的判断了。改进的代码,要把长方形考虑进去。它看起来可以是下面的样子。
public static boolean isSquare(Shape shape) {
if (shape instanceof Rectangle rect) {
return (rect.length == rect.width);
}
return (shape instanceof Square);
}
写完上面的代码,似乎就可以长舒一口气:哎,这难缠的正方形,我们终于搞定了。
但其实,这个问题我们还没有搞定。因为正方形也是一个特殊的菱形,如果一个对象是一个菱形类的实例,上面的代码就有缺陷。更令人窘迫的是,正方形还是一个特殊的梯形,还是一个特殊的多边形。随着我们学习一步一步的深入,我们知道还有很多形状的特殊形式是正方形,而且我们并不知道我们知识范围外的那些形状,当然更不能提穷举它们了。
这,实在有点让人抓狂!
问题出在哪里呢?无限制的扩展性,是问题的根源。正如现实世界里,我们没有办法穷举到底有多少形状的特殊形式是正方形;在计算机的世界里,我们也没有办法穷举到底有多少形状的对象可以是正方形。如果我们解决不了形状类的穷举问题,我们就不太容易使用代码来判断一个形状是不是正方形。
而解决问题的办法,就是限制可扩展类的扩展性。
怎么限制住扩展性?
你可能要问,可扩展性不是面向对象编程的一个重要指标吗?为什么要限制可扩展性呢?其实,面向对象编程的最佳实践之一,就是要把可扩展性限制在可以预测和控制的范围内,而不是无限的可扩展性。
除了上面穷举的问题之外,在极客时间专栏《代码精进之路》里,我们还讨论了继承的安全缺陷。其中,主要有两点值得我们格外小心:
一个可扩展的类,子类和父类可能会相互影响,从而导致不可预知的行为。-
涉及敏感信息的类,增加可扩展性不一定是个优先选项,要尽量避免父类或者子类的影响。
虽然我们使用了 Java 语言来讨论继承的问题,但其实这些是面向对象机制的普遍问题,甚至它们也不单单是面向对象语言的问题,比如使用 C 语言的设计和实现,也存在类似的问题。
由于继承的安全问题,我们在设计 API 时,有两个要反省思考的点:
一个类,有没有真实的可扩展需求,能不能使用 final 修饰符?-
一个方法,子类有没有重写的必要性,能不能使用 final 修饰符?
限制住不可预测的可扩展性,是实现安全代码、健壮代码的一个重要目标。
JDK 17之前的Java语言限制住可扩展性只有两个方法使用私有类或者 final 修饰符。显而易见,私有类不是公开接口,只能内部使用;而 final 修饰符彻底放弃了可扩展性。要么全开放,要么全封闭,可扩展性只能在可能性的两个极端游走。全封闭彻底没有了可扩展性,全开放又面临固有的安全缺陷,这种二选一的状况有时候很让人抓狂,特别是设计公开接口的时候。
JDK 17之后有了第三种方法。这个办法就是使用Java的sealed关键字。使用类修饰符sealed修饰的类是封闭类使用类修饰符sealed修饰的接口是封闭接口。封闭类和封闭接口限制可以扩展或实现它们的其他类或接口。
通过把可扩展性的限制放在可以预测和控制的范围内,封闭类和封闭接口打开了全开放和全封闭两个极端之间的中间地带,为接口设计和实现提供了新的可能性。
怎么声明封闭类
那么,怎么使用封闭类呢?封闭类这个概念,涉及到两种类型的类。第一种是被扩展的父类,第二种是扩展而来的子类。通常地,我们把第一种称为封闭类,第二种称为许可类。
封闭类的声明使用 sealed 类修饰符,然后在所有的 extends 和 implements 语句之后,使用 permits 指定允许扩展该封闭类的子类。 比如,使用 sealed 类修饰符我们可以把形状这个类声明为封闭类。下面的这个例子中Shape是一个封闭类可以扩展它的子类只有两个分别为Circle和Square。也就是说这里定义的形状这个类只允许有圆形和正方形两个子类。
package co.ivi.jus.sealed.modern;
public abstract sealed class Shape permits Circle, Square {
public final String id;
public Shape(String id) {
this.id = id;
}
public abstract double area();
}
由 permits 关键字指定的许可子类permitted subclasses必须和封闭类处于同一模块module或者包空间package里。如果封闭类和许可类是在同一个模块里那么它们可以处于不同的包空间里就像下面的例子。
package co.ivi.jus.sealed.modern;
public abstract sealed class Shape
permits co.ivi.jus.ploar.Circle,
co.ivi.jus.quad.Square {
public final String id;
public Shape(String id) {
this.id = id;
}
public abstract double area();
}
如果允许扩展的子类和封闭类在同一个源代码文件里,封闭类可以不使用 permits 语句Java 编译器将检索源文件,在编译期为封闭类添加上许可的子类。比如下面的两种 Shape 封闭类的声明,一个封闭类使用了 permits 语句,另外一个封闭类没有使用 permits 语句。但是,这两个声明具有完全一样的运行时效果。
package co.ivi.jus.sealed.improved;
public abstract sealed class Shape {
public final String id;
public Shape(String id) {
this.id = id;
}
public abstract double area();
public static final class Circle extends Shape {
// snipped
}
public static final class Square extends Shape {
// snipped
}
}
package co.ivi.jus.sealed.improved;
public abstract sealed class Shape
permits Shape.Circle, Shape.Square {
public final String id;
public Shape(String id) {
this.id = id;
}
public abstract double area();
public static final class Circle extends Shape {
// snipped
}
public static final class Square extends Shape {
// snipped
}
}
不过如果你读过《代码精进之路》你就会倾向于总是使用permits 语句。因为这样的话,代码的阅读者不需要去翻找上下文,也能一目了然地知道这个封闭类支持哪些许可类。这会给代码的阅读者带来很多的便利,包括节省时间以及少犯错误。
怎么声明许可类
许可类的声明需要满足下面的三个条件:
许可类必须和封闭类处于同一模块module或者包空间package也就是说在编译的时候封闭类必须可以访问它的许可类
许可类必须是封闭类的直接扩展类;
许可类必须声明是否继续保持封闭:
许可类可以声明为终极类final从而关闭扩展性
许可类可以声明为封闭类sealed从而延续受限制的扩展性
许可类可以声明为解封类non-sealed, 从而支持不受限制的扩展性。
比如在下面的例子中,许可类 Circle 是一个解封类;许可类 Square 是一个封闭类;许可类 ColoredSquare 是一个终极类;而 ColoredCircle 既不是封闭类,也不是许可类。
package co.ivi.jus.sealed.propagate;
public abstract sealed class Shape {
public final String id;
public Shape(String id) {
this.id = id;
}
public abstract double area();
public static non-sealed class Circle extends Shape {
// snipped
}
public static sealed class Square extends Shape {
// snipped
}
public static final class ColoredSquare extends Square {
// snipped
}
public static class ColoredCircle extends Circle {
// snipped
}
}
需要注意的是由于许可类必须是封闭类的直接扩展因此许可类不具备传递性。也就是说上面的例子中ColoredSquare 是 Square 的许可类,但不是 Shape 的许可类。
案例回顾
到这里,我们再回头看看前面的案例,怎么判断一个形状是不是正方形呢?封闭类能帮助我们解决这个问题吗?如果使用了封闭类,这个问题的答案也就呼之欲出了。
首先,我们要把形状这个类定义为封闭类。这样,所有形状的子类就可以穷举了。然后,我们寻找可以用来表示正方形的许可类。找到这些许可类后,只要我们能够判断这个形状的对象是不是一个正方形,问题就解决了。
比如下面的代码形状被定义为封闭类Shape。而且Shape这个封闭类只有两个终极的许可类。一个许可类是表示圆形的Circle一个许可类是表示正方形的Square。
package co.ivi.jus.sealed.improved;
public abstract sealed class Shape
permits Shape.Circle, Shape.Square {
public final String id;
public Shape(String id) {
this.id = id;
}
public abstract double area();
public static final class Circle extends Shape {
// snipped
}
public static final class Square extends Shape {
// snipped
}
}
由于Shape是个封闭类在这段代码的许可范围内一个形状Shape的对象要么是一个圆形Circle的实例要么是一个正方形Square的实例没有其他的可能性。
这样的话,判断一个形状是不是正方形这个问题就变得比较简单了。只要能够判断出来一个形状的对象是不是一个正方形的实例,这个问题就算是解决了。
static boolean isSquare(Shape shape) {
return (shape instanceof Square);
}
这样的逻辑在案例分析那一小节的场景中并不成立为什么现在就成立了呢根本的原因在案例分析那一小节的场景中Shape类是一个不受限制的类我们没有办法知道它所有的扩展类因此我们也就没有办法穷尽正方形的所有可能性。而在使用封闭类的场景下Shape类的所有扩展类我们都是已知的所以我们就有办法检查每一个扩展类的规范从而对这个问题做出正确的判断。
总结
好,到这里,我来做个小结。从前面的讨论中,我们了解到,可扩展性的限定方法有四个:
使用私有类;
使用final修饰符
使用sealed修饰符
不受限制的扩展性。
在我们日常的接口设计和编码实践中,使用这四个限定方法的优先级应该是由高到低的。最优先使用私有类,尽量不要使用不受限制的扩展性。
如果要丰富你的代码评审清单,有了封闭类后,你可以加入下面这一条:
一个类,如果有真实的可扩展需求,能不能枚举,可不可以使用 sealed 修饰符?
另外,通过今天的讨论,我拎出几个技术要点,这些都可能在你们面试中出现哦,通过学习,你应该能够:
知道Java支持封闭类并且能够使用封闭类编写代码
面试问题:你知道封闭类吗?会不会使用它?
了解封闭类的原理和它要解决的问题,知道限制住扩展性的办法;
面试问题:面向对象编程的可扩展性有什么问题吗?该怎么处理这些问题?
能够有意识地使用封闭类来限制类或者接口的扩展性。
面试问题:你写的这段代码,是不是应该使用 final修饰符或者 sealed 修饰符?
如果你的代码里使用了封闭类,无论是面试的时候还是工作的时候,一定能够给人深刻的印象。因为,这意味着你已经了解了可扩展性的危害,并且有办法降低这种危害的影响,有能力编写出更健壮的代码。
思考题
在案例回顾这一小节里我们使用了封闭类来解决“怎么判断一个形状是不是正方形”这个问题。我们假设案例回顾这一小节的代码是版本1.0。现在我们假设在版本2.0里需要增加另一个许可类用来支持长方形Rectangle。那么
封闭类的代码该怎么改动,才能支持长方形?
“判断一个形状是不是正方形”的代码该怎么改动,才能适应封闭类的改变?
增加一个许可类会有兼容性的影响吗比如说使用版本1.0来判断一个形状是不是正方形的代码还能使用吗?
欢迎你在留言区留言、讨论,分享你的阅读体验以及对这些问题的思考。
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在封闭类专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在sealed/review/xuelei的目录下面。

View File

@ -0,0 +1,333 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 类型匹配:怎么切除臃肿的强制转换?
你好我是范学雷。今天我们聊一聊Java模式匹配主要是类型匹配。
Java的模式匹配是一个新型的、而且还在持续快速演进的领域。类型匹配是模式匹配的一个规范。类型匹配这个特性首先在JDK 14中以预览版的形式发布。在JDK 15中改进的类型匹配再次以预览版的形式发布。最后类型匹配在JDK 16正式发布。
那么,什么是模式匹配,什么又是类型匹配呢?这就要说到模式的组成。通常,一个模式是匹配谓词和匹配变量的组合。其中,匹配谓词用来确定模式和目标是否匹配。在模式和目标匹配的情况下,匹配变量是从匹配目标里提取出来的一个或者多个变量。
对于类型匹配来说,匹配谓词用来指定模式的数据类型,而匹配变量就是一个属于该类型的数据变量。需要注意的是,对于类型匹配来说,匹配变量只有一个。
这样的描述还是太抽象,太难理解。我们还是通过案例和代码,一点一点地来理解类型匹配吧。
阅读案例
在程序员的日常工作中,一个重要的事情,就是把相似的东西抽象出来,设计成一个通用的、可以复用的接口。
比如说,我们从正方形、长方形、圆形这些看起来差异巨大的东西出发,抽象出了形状这个接口。我们希望使用一个实例时,如果我们不能确定它是正方形还是长方形,我们至少还能确定它是一个形状。这种模模糊糊的确定性(其实也是不确定性),其实对我们编写代码有巨大的帮助,包括但是不限于简化代码逻辑,减少代码错误。
但要注意的是,每一个实例都是具体的形状。它可以是正方形的对象,可以是长方形的对象,就是不能是一个抽象的形状。也就是说,抽象的类和接口不能直接实例化。
一个方法的规范,它的输入参数可能是一个表示形状的对象,也可能是一个更一般化的对象。比如说吧,我们要设计一个方法,来判断一个形状是不是正方形。那么,就需要一个表示形状的对象,作为这个方法的输入参数。而实现这个方法的代码,仅仅知道形状这个一般化的对象是远远不够的。下面的代码,就是一个这种方法的实现代码。
static boolean isSquare(Shape shape) {
if (shape instanceof Rectangle) {
Rectangle rect = (Rectangle) shape;
return (rect.length == rect.width);
}
return (shape instanceof Square);
}
在这个isSquare方法的实现代码里我们需要使用instanceof运算符来判断输入参数是不是一个长方形的实例如果判断成立再使用类型转换运算符把这个实例投射成长方形的实例最后我们开始使用这个长方形的实例进行更多的运算。
其实,这样的操作是一个模式化的过程。如果我们把它揉碎了来看,这个模式有三个部分。
第一个部分是类型判断语句也就是匹配谓词使用的代码是“instanceof Rectangle”。第二个部分是类型转换语句使用的是类型转换运算符(Rectangle) shape。第三个部分是声明一个新的本地变量也就是匹配变量来承载转换后的数据使用的是变量声明和赋值运算符Rectangle rect =)。第二个部分和第三个部分,只有在类型判断成立的情况下,才能够执行。
使用这样的模式化操作是一个Java程序员的基本功。这个模式直观而且便于理解。可是这个模式很乏味也很臃肿。调用了instanceof之后除了类型转换之外我们还可以做什么呢一般情况下在类型判断之后我们总是紧跟着就进行类型转换。
把类型判断和类型转换切割成两个部分,增加了错误潜入的机会,平添了许多烦恼。比如说,一个活生生的程序员或者冷冰冰的机器,有可能无意地使用了错误的类型。下面例子中的两段代码,就是两个常见的类型转换错误。第一段代码误用了变量类型,第二段代码误用了判断结果。
if (shape instanceof Circle) {
Rectangle rect = (Rectangle) shape;
return (rect.length == rect.width);
}
if (!(shape instanceof Rectangle) {
Rectangle rect = (Rectangle) shape;
return (rect.length == rect.width);
}
类型判断之后,我们原本就可以开始关注更重要的后续代码逻辑了,但现在不得不停下来编写类型转化代码,或者审视类型转换代码是否恰当。这当然影响力了生产效率。
我们可以用什么方法改进这个模式,提高生产效率呢? 这个问题的答案就是类型匹配。
类型匹配
那么,类型匹配是怎么改进这个模式的呢?我们先来看看使用了类型匹配的代码的样子。下面的例子,就是使用类型匹配的一段代码。
if (shape instanceof Rectangle rect) {
return (rect.length == rect.width);
}
为了便于更直观地比较,我把传统的实现代码和使用了类型匹配的实现代码列在了下面的表格里。你可以找找其中的差异,体会下类型匹配带来的改进。
就像我们前面拆解的一样,传统的实现代码有三个部分;而使用类型匹配的代码,只有匹配谓词和本地变量两个部分,而且是在同一个语句里。为了帮助你理解这些概念,我画了下面的这张图,标记出了类型匹配的组成部分和关键概念。
你可能已经注意到了,使用类型转换运算符的语句,没有出现在使用类型匹配的代码里。但是,这并不影响类型匹配代码所要表达的基本逻辑。
这个基本逻辑就是:如果目标变量是一个长方形的实例,那么这个目标变量就会被赋值给一个本地的长方形变量,也就是我们所说的匹配变量;相反,如果目标变量不是一个长方形的实例,那么这个匹配变量就不会被赋值。
前面,我们讨论了两个常见的类型转换错误:误用变量类型和误用判断结果。在使用类型匹配的代码里,不再需要重复使用匹配类型,也不再需要使用强制类型转换符。所以,使用类型匹配的代码,不用再担心误用变量类型的错误了。
误用判断结果的错误,是不是也被解决了呢? 似乎,我们还能写出下面的代码。在这样的代码里,如果目标变量不是一个长方形的实例,我们是不是也有可能使用匹配的变量呢?
if (!(shape instanceof Rectangle rect)) {
return (rect.length == rect.width);
}
幸运的是类型匹配已经考虑到了这个问题Java编译器能够检测出上面的错误不会允许使用没有赋值的匹配变量。这样在代码编译期间就有机会纠正代码的错误。比如说我们可以尝试修改成下面的逻辑如果目标变量不是一个长方形的实例我们就不使用匹配变量否则我们就使用匹配变量。把这个逻辑映射到代码大致是下面的样子。
if (!(shape instanceof Rectangle rect)) {
return false;
} else {
return (rect.length == rect.width);
}
在上面的代码里使用匹配变量的条件语句else分支并没有声明这个匹配变量。为什么if语句声明的变量可以在else语句里使用呢要弄清楚这个问题我们还要了解匹配变量的作用域。掌握匹配变量的作用域是学会使用类型匹配的关键。
匹配变量的作用域
匹配变量的作用域,就是目标变量可以被确认匹配的范围。如果在一个范围内,无法确认目标变量是否被匹配,或者目标变量不能被匹配,都不能使用匹配变量。 如果我们从编译器的角度去理解,也就是说,在一个范围里,如果编译器能够确定匹配变量已经被赋值了,那么它就可以在这个范围内使用;如果编译器不能够确定匹配变量是否被赋值,或者确定没有被赋值,那么他就不能在这个范围内使用。
我们还是通过代码来理解这个有点抽象的概念吧。
第一段代码,我们看看最常规的使用。我们可以在确认类型匹配的条件语句之内使用匹配变量。这个条件语句之外,不是匹配变量的作用域。
public static boolean isSquareImplA(Shape shape) {
if (shape instanceof Rectangle rect) {
// rect is in scope
return rect.length() == rect.width();
}
// rect is not in scope here
return shape instanceof Square;
}
第二段代码,我们看看有点意外的使用。我们可以在确认类型不匹配的条件语句之后使用匹配变量。这个条件语句之内,不是匹配变量的作用域。
public static boolean isSquareImplB(Shape shape) {
if (!(shape instanceof Rectangle rect)) {
// rect is not in scope here
return shape instanceof Square;
}
// rect is in scope
return rect.length() == rect.width();
}
第三段代码,我们看看紧凑的方式。这一段代码的逻辑,和第一段代码一样,我们只是换成了一种更紧凑的表示方法。
在这一段代码里,我们使用逻辑与运算符表示第一段里的条件语句:类型匹配并且匹配变量满足某一个条件。这样的表示是符合匹配变量的作用域规则的。逻辑与运算符从左到右计算,只有第一个运算成立,也就是类型匹配,才能进行下一个运算。所以,我们可以在逻辑与运算的第二部分,使用匹配变量。
public static boolean isSquareImplC(Shape shape) {
return shape instanceof Square || // rect is not in scope here
(shape instanceof Rectangle rect &&
rect.length() == rect.width()); // rect is in scope here
}
第四段代码,我们看看逻辑或运算。它类似于第三段代码,只是我们把逻辑与运算符替换成了逻辑或运算符。这时候的逻辑,就变成了“类型匹配或者匹配变量满足某一个条件”。逻辑或运算符也是从左到右计算。
不过和逻辑与运算符不同的是,一般来说,只有第一个运算不成立,也就是说类型不匹配时,才能进行下一步的运算。下一步的运算,匹配变量并没有被赋值,我们不能够在这一部分使用匹配变量。所以,这一段代码并不能通过编译器的审查。
public static boolean isSquareImplD(Shape shape) {
return shape instanceof Square || // rect is not in scope here
(shape instanceof Rectangle rect ||
rect.length() == rect.width()); // rect is not in scope here
}
第五段代码,我们看看位与运算。
这段代码和第三段代码类似,只是我们把逻辑与运算符(&&)替换成了位与运算符(&)。
和第三段代码相比,这一段代码的逻辑其实并没有变化。只不过,位与运算符两侧的表达式都要参与计算。也就是说,不管位与运算符左侧的运算是否成立,位与运算符右侧的运算都要计算出来。换句话说,无论左侧的类型匹配不匹配,右侧的匹配变量都要使用。这就违反了匹配变量的作用域原则,编译器不能够确定匹配变量是否被赋值。所以,这一段代码,也不能通过编译器的审查。
public static boolean isSquareImplE(Shape shape) {
return shape instanceof Square | // rect is not in scope here
(shape instanceof Rectangle rect &
rect.length() == rect.width()); // rect is in scope here
}
第六段代码我们把匹配变量的作用域的影响延展一下看看它对影子变量Shadowed Variable的影响。
既然我们讨论变量的作用域,我们就不能不看看影子变量。假设我们定义了一个静态变量,它和匹配变量使用相同的名字。在匹配变量的作用域内,除非特殊处理,这个静态变量就被遮掩住了。这时候,这个变量名字代表的就是匹配变量;而不是静态变量。类似地,在匹配变量的作用域之外,这个变量名字代表的就是这个静态变量。
在这段代码里,我们使用类似于第一段代码的代码组织方式,来表述类型匹配部分的逻辑。另外,我在代码里标注了变量的作用域。你可以看看,这两个变量的作用域,和你想象的作用域是不是一样的?
public final class Shadow {
private static final Rectangle rect = null;
public static boolean isSquare(Shape shape) {
if (shape instanceof Rectangle rect) {
// Field rect is shadowed, local rect is in scope
System.out.println("This should be the local rect: " + rect);
return rect.length() == rect.width();
}
// Field rect is in scope, local rect is not in scope here
System.out.println("This should be the field rect: " + rect);
return shape instanceof Shape.Square;
}
}
第七段代码,我们还是来看一看影子变量。只不过,这一次,我们使用类似于第二段代码的代码组织方式,来表述类型匹配部分的逻辑。我在代码里标出的这两个变量的作用域,和你想象的作用域是一样的吗?
public final class Shadow {
private static final Rectangle rect = null;
public static boolean isSquare(Shape shape) {
if (!(shape instanceof Rectangle rect)) {
// Field rect is in scope, local rect is not in scope here
System.out.println("This should be the field rect: " + rect);
return shape instanceof Shape.Square;
}
// Field rect is shadowed, local rect is in scope
System.out.println("This should be the local rect: " + rect);
return rect.length() == rect.width();
}
}
如果回头看看这七段代码,你会倾向于哪一种编码的风格?我们把这些代码放在一起,分析一下它们的特点。
第四段和第五段代码,不能通过编译器的审查,所以我们不能使用这两种编码方式。
第二段和第七段代码,匹配变量的作用域,远离了类型匹配语句。这种距离上的疏远,无论在视觉上还是心理上,都不是很舒适的选择。不舒适,就给错误留下了空间,不容易编码,也不容易排错。这种代码逻辑和语法上都没有问题,但是不太容易阅读。
第一段和第六段代码,匹配变量的作用域,紧跟着类型匹配语句。这是我们感觉舒适的代码布局,也是最安全的代码布局,不容易出错,也容易阅读。
第三段代码,它的匹配变量的作用域也是紧跟着类型匹配语句。只不过,这种代码的编排方式不太容易阅读,阅读者需要认真拆解每一个条件,才能确认逻辑是正确的。相对于第一段和第六段代码,第三段代码的组织方式,是一个次优的选择。
如果你学习过《代码精进之路》专栏,我想你会理解代码组织方式的重要性,并且能够有意识地选择简单、安全的组织方式。对于类型匹配来说,第一段和第六段代码的组织方式,是我们喜欢的方式。
实例匹配的红利
在快要结束本文写作的时候我还是忍不住测试了一下实例匹配的性能。在我自己的笔记本电脑上和使用类型转换运算符的代码相比使用实例匹配代码的吞吐量提高了将近20%。这是一个巨大的性能提升。我知道使用实例匹配会提高性能,但是没想到有这么大的提升。除了主要目标之外,这也算是使用实例匹配的一个红利吧。
Benchmark Mode Cnt Score Error Units
PatternBench.useCast thrpt 15 263559326.599 ± 78815341.366 ops/s
PatternBench.usePattern thrpt 15 313458467.044 ± 2666412.767 ops/s
总结
这节课的内容到这里就要结束了我来做个小结。从前面的讨论中我们了解了Java的模式匹配和Java的类型匹配讨论了Java类型匹配要解决的问题、表现的形式以及匹配变量的作用域。顺便我们还讨论了我们喜欢的类型匹配代码的组织方式。
在我们日常的编码实践中,为了简化代码逻辑,减少代码错误,提高生产效率,我们应该优先考虑使用类型匹配,而不是传统的强制类型转换运算符。
如果你想要丰富你的代码评审清单有了Java类型匹配后你可以加入下面这一条
如果需要类型转换,是不是可以使用类型匹配?
另外,我在今天的讨论中拎出了几个技术要点,这些都可能在你们面试中出现哦。通过这一次学习,你应该能够:
知道Java支持类型匹配并且能够使用类型匹配替换掉传统的强制类型转换运算。
面试问题:你知道类型匹配吗?会不会使用它?
了解类型匹配的原理和它要解决的问题,知道匹配变量的作用域。
面试问题:使用类型匹配有哪些好处?匹配变量什么时候可以使用?
了解类型匹配的代码组织方式,能够有意识地使用简单、安全的代码组织方式。
面试问题:你写的这段代码(如果使用了类型匹配),还有更好的表达方式吗?
如果你能够有意识地使用Java的类型匹配并且有能力选择简单、安全的代码组织方式你应该能够大幅度提高编码的效率和质量提高代码的性能。毫无疑问在面试的时候这也是一个能够让你与众不同的知识点。
思考题
在“匹配变量的作用域”这一小节里我们列举了7种实例匹配的代码组织方式。除了第四段代码和第五段代码其他的五种代码都可以通过编译。为了加深你的印象我们要动动手验证一下每一种代码组织方式下匹配变量的作用域。
我在下面的例子中写了一个代码小样,使用打印语句输出来验证结果。你可以试着修改成你喜欢的样子,添加更多的代码组织方式。
/*
* Copyright (c) 2021, Xuelei Fan. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*/
package co.ivi.jus.instance.review.xuelei;
public sealed interface Shape
permits Shape.Circle, Shape.Rectangle, Shape.Square {
Shape.Rectangle rect = null; // field variable
record Circle(double radius) implements Shape {
// blank
}
record Square(double side) implements Shape {
// blank
}
record Rectangle(double length, double width) implements Shape {
// blank
}
static void main(String[] args) {
Shape shape = new Shape.Rectangle(10, 10);
System.out.println("It should be ture that " + shape +
" is a square: " + isSquare(shape));
System.out.println();
shape = new Shape.Circle(10);
System.out.println("It cannot be ture that " + shape +
" is a square: " + (!isSquare(shape)));
}
static boolean isSquare(Shape shape) {
if (shape instanceof Rectangle rect) {
// Field rect is shadowed, local rect is in scope
System.out.println(
"This should be the local rect: " +
rect.equals(shape));
return (rect.length == rect.width);
}
// Field rect is in scope, local rect is not in scope here
System.out.println(
"This should be the field rect: " + (rect == null));
return (shape instanceof Square);
}
}
欢迎你在留言区留言、讨论,分享你的阅读体验以及验证的代码和结果。
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在实例匹配专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在instance/review/xuelei的目录下面。
本文使用的基准性能测试代码你也可以从GitHub上下载试试你的机器是不是也有相似的性能表现。

View File

@ -0,0 +1,522 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 switch表达式怎么简化多情景操作
你好我是范学雷。今天我们聊一聊Switch表达式。
switch表达式这个特性首先在JDK 12中以预览版的形式发布。在JDK 13中改进的switch表达式再次以预览版的形式发布。最后switch表达式在JDK 14正式发布。
不论你学习什么样的编程语言合理地分析、判断、处理不同的情况都是必备的基本功。比如我们使用的if-else语句还有switch语句都是用来处理种种不同的情况的。 我们都知道switch语句那么switch表达式又是什么呢switch语句和switch表达式又有什么不同呢
如果你了解了Java的语句和表达式这两个基本概念你的困扰也许会少一点。Java规范里表达式完成对数据的操作。一个表达式的结果可以是一个数值i * 4或者是一个变量i = 4或者什么都不是void类型
Java语句是Java最基本的可执行单位它本身不是一个数值也不是一个变量。Java语句的标志性符号是分号代码和双引号代码块比如if-else语句赋值语句等。这样再来看就很简单了switch表达式就是一个表达式而switch语句就是一个语句。
switch表达式是什么样子的为什么需要switch表达式我们还是通过案例和代码一点一点地来学习switch表达式吧。
阅读案例
在讲解或者学习switch语句时每年的十二个月或者每周的七天是我们经常使用的演示数据。在这个案例里我们也使用这样的数据来看看传统的switch语句有哪些需要改进的地方。
下面,我们要讨论的,也是一个传统的问题: 该怎么用代码计算一个月有多少天?生活中,我们熟悉这样的顺口溜,“一三五七八十腊,三十一天永不差,四六九冬三十整,平年二月二十八,闰年二月把一加”。
下面的这段代码,就是按照这个顺口溜的逻辑来计算了一下,今天所在的这个月,一共有多少天。
package co.ivi.jus.swexpr.former;
import java.util.Calendar;
class DaysInMonth {
public static void main(String[] args) {
Calendar today = Calendar.getInstance();
int month = today.get(Calendar.MONTH);
int year = today.get(Calendar.YEAR);
int daysInMonth;
switch (month) {
case Calendar.JANUARY:
case Calendar.MARCH:
case Calendar.MAY:
case Calendar.JULY:
case Calendar.AUGUST:
case Calendar.OCTOBER:
case Calendar.DECEMBER:
daysInMonth = 31;
break;
case Calendar.APRIL:
case Calendar.JUNE:
case Calendar.SEPTEMBER:
case Calendar.NOVEMBER:
daysInMonth = 30;
break;
case Calendar.FEBRUARY:
if (((year % 4 == 0) && !(year % 100 == 0))
|| (year % 400 == 0)) {
daysInMonth = 29;
} else {
daysInMonth = 28;
}
break;
default:
throw new RuntimeException(
"Calendar in JDK does not work");
}
System.out.println(
"There are " + daysInMonth + " days in this month.");
}
}
这段代码里我们使用了switch语句。代码本身并没有什么错误但是至少有两个容易犯错误的地方。
第一个容易犯错的地方就是在break关键字的使用上。上面的代码里如果多使用一个break关键字代码的逻辑就会发生变化同样的少使用一个break关键字也会出现问题。
int daysInMonth;
switch (month) {
case Calendar.JANUARY:
case Calendar.MARCH:
case Calendar.MAY:
break; // WRONG BREAK!!!
case Calendar.JULY:
case Calendar.AUGUST:
case Calendar.OCTOBER:
case Calendar.DECEMBER:
daysInMonth = 31;
break;
// snipped
}
int daysInMonth;
switch (month) {
// snipped
case Calendar.APRIL:
case Calendar.JUNE:
case Calendar.SEPTEMBER:
case Calendar.NOVEMBER:
daysInMonth = 30;
// WRONG, NO BREAK!!!
case Calendar.FEBRUARY:
if (((year % 4 == 0) && !(year % 100 == 0))
|| (year % 400 == 0)) {
daysInMonth = 29;
} else {
daysInMonth = 28;
}
break;
// snipped
}
break语句的遗漏或者冗余这样的错误如此得常见甚至于被单列成了一个常见软件安全漏洞。凡是使用switch语句的代码都有可能成为黑客们重点关注的对象。由于逻辑的错误和黑客的特殊关照我们在编写代码的时候需要十二分的小心阅读代码的时候也需要反复地查验break语句的前后语境。毫无疑问这增加了代码维护的成本降低了生产效率。
为什么switch语句里需要使用break呢最主要的原因就是希望能够在不同的情况下共享部分或者全部的代码片段。比如上面的例子中四月、六月、九月、十一月这四种情景可以共享每个月都是30天这样的代码片段。这个代码片段只需要写在十一月情景的后面前面的四月、六月和九月这三个情景都会顺次执行下面的操作fall-through直到遇到下一个break语句或者switch语句终结。
现在我们都知道了这样是一个弊大于利的设计。但很遗憾Java初始的设计就是采用了这样的设计思想。如果要新设计一门现代的语言我们需要更多地使用switch语句但是就不要再使用break语句了。不过不同的情景共享代码片段仍然是一个真实的需求。在废弃掉break语句之前我们要找到在不同的情景间共享代码片段的新规则。
第二个容易犯错的地方,是反复出现的赋值语句。 在上面的代码中daysInMonth这个本地变量的变量声明和实际赋值是分开的。赋值语句需要反复出现以适应不同的情景。如果在switch语句里daysInMonth变量没有被赋值编译器也不会报错缺省的或者初始的变量值就会被使用。
int daysInMonth = 0;
switch (month) {
// snipped
case Calendar.APRIL:
case Calendar.JUNE:
case Calendar.SEPTEMBER:
case Calendar.NOVEMBER:
break; // WRONG, INITIAL daysInMonth value IS USED!!!
case Calendar.FEBRUARY:
// snipped
}
在上面的例子里初始的变量值不是一个合适的数据当然在另外一个例子里缺省的或者初始的变量值也可能就是一个合适的数据了。为了判断这个本地变量有没有合适的值我们需要通览整个switch语句块确保赋值没有遗漏也没有多余。这增加了编码出错的几率也增加了阅读代码的成本。
那么,能不能让多情景处理的代码块拥有一个数值呢? 或者换个说法多情景处理的代码块能不能变成一个表达式这个想法就催生了Java语言的新特性“switch表达式”。
switch表达式
switch表达式是什么样子的呢下面的这段代码使用的就是switch表达式它改进了上面阅读案例里的代码。你可以带着上面遇到的问题来阅读这段代码。这些问题包括
switch表达式是怎么表示一个数值从而可以给变量赋值的
在不同的情景间switch表达式是怎么共享代码片段的
使用switch表达式的代码有没有变得更简单、更皮实、更容易理解
package co.ivi.jus.swexpr.modern;
import java.util.Calendar;
class DaysInMonth {
public static void main(String[] args) {
Calendar today = Calendar.getInstance();
int month = today.get(Calendar.MONTH);
int year = today.get(Calendar.YEAR);
int daysInMonth = switch (month) {
case Calendar.JANUARY,
Calendar.MARCH,
Calendar.MAY,
Calendar.JULY,
Calendar.AUGUST,
Calendar.OCTOBER,
Calendar.DECEMBER -> 31;
case Calendar.APRIL,
Calendar.JUNE,
Calendar.SEPTEMBER,
Calendar.NOVEMBER -> 30;
case Calendar.FEBRUARY -> {
if (((year % 4 == 0) && !(year % 100 == 0))
|| (year % 400 == 0)) {
yield 29;
} else {
yield 28;
}
}
default -> throw new RuntimeException(
"Calendar in JDK does not work");
};
System.out.println(
"There are " + daysInMonth + " days in this month.");
}
}
我们最先看到的变化就是switch代码块出现在了赋值运算符的右侧。这也就意味着这个switch代码块表示的是一个数值或者是一个变量。换句话说这个switch代码块是一个表达式。
int daysInMonth = switch (month) {
// snipped
}
我们看到的第二个变化是多情景的合并。也就是说一个case语句可以处理多个情景。这些情景使用逗号分隔开来共享一个代码块。而传统的switch代码一个case语句只能处理一种情景。
case Calendar.JANUARY,
Calendar.MARCH,
// snipped
多情景的合并的设计满足了共享代码片段的需求。而且由于只使用一个case语句也就不再需要使用break语句来满足这个需求了。所以break语句从switch表达式里消失了。
不同之处在于传统的switch代码不同的case语句之间可以共享部分的代码片段而switch表达式里需要共享全部的代码片段。这看似是一个损失但其实共享部分代码片段的能力给代码的编写者带来的困惑远远多于它带来的好处。如果需要共享部分的代码片段我们总是可以找到替换的办法比如把需要共享的代码封装成更小的方法。所以我们没有必要担心switch表达式不支持共享部分代码片段。
下一个变化,是一个新的情景操作符,“->”它是一个箭头标识符。这个符号使用在case语句里一般化的形式是“case L ->”。这里的L就是要匹配的一个或者多个情景。如果目标变量和情景匹配那么就执行操作符右边的表达式或者代码块。如果要匹配的情景有两个或者两个以上就要使用逗号“,”用分隔符把它们分割开来。
case Calendar.JANUARY,
// snipped
Calendar.DECEMBER -> 31;
传统的switch代码这个一般化的形式是“case L 也就是使用冒号标识符。为什么不延续使用传统的情景操作符呢这主要是出于简化代码的考虑。我们依然可以在switch表达式里使用冒号标识符使用冒号标识符的一个case语句只能匹配一个情景这种情况我们稍后再讨论。
下一个我们看到的变化是箭头标识符右侧的数值。这个数值代表的就是该匹配情景下switch表达式的数值。需要注意的是箭头标识符右侧可以是表达式、代码块或者异常抛出语句而不能是其他的形式。如果只需要一个语句这个语句也要以代码块的形式呈现出来。
case Calendar.JANUARY,
// snipped
Calendar.DECEMBER -> { // CORRECT, enclosed with braces.
yield 31;
}
没有以代码块形式呈现的代码,编译的时候,就会报错。这是一个很棒的约束。代码块的形式,增强了视觉效果,减少了编码的失误。在《代码精进之路》这个专栏里,我们反复强调过这种形式的好处。
case Calendar.JANUARY,
// snipped
Calendar.DECEMBER -> // WRONG, not a block.
yield 31;
另外箭头标识符右侧需要一个表达switch表达式的数值这是一个很强的约束。如果一个语句破坏了这个需要它就不能出现在switch表达式里。比如下面的代码里的return语句意图退出该方法而没有表达这个switch表达式的数值。这段代码就不能通过编译器的审查。
int daysInMonth = switch (month) {
// snipped
case Calendar.APRIL,
// snipped
Calendar.NOVEMBER -> {
// yield 30;
return; // WRONG, return outside of enclosing switch expression.
}
// snipped
}
最后一个我们能够看到的变化是出现了一个新的关键字“yield”。大多数情况下switch 表达式箭头标识符的右侧是一个数值或者是一个表达式。 如果需要一个或者多个语句,我们就要使用代码块的形式。这时候,我们就需要引入一个新的 yield 语句来产生一个值,这个值就成为这个封闭代码块代表的数值。
为了便于理解我们可以把yield语句产生的值看成是switch表达式的返回值。所以yield只能用在switch 表达式里而不能用在switch语句里。
case Calendar.FEBRUARY -> {
if (((year % 4 == 0) && !(year % 100 == 0))
|| (year % 400 == 0)) {
yield 29;
} else {
yield 28;
}
}
其实这里还有一个我们从上述的代码里看不到的变化。在switch表达式里所有的情景都要列举出来不能多、也不能少这也就是我们常说的穷举
比如说在上面的例子里如果没有最后的default情景分支编译器就会报错。这是一个影响深远的改进它会使得switch表达式的代码更加健壮大幅度降低维护成本如果未来需要增加一个情景分支的话就更是如此了。
int daysInMonth = switch (month) {
case Calendar.JANUARY,
// snipped
Calendar.DECEMBER -> 31;
case Calendar.APRIL,
// snipped
Calendar.NOVEMBER -> 30;
case Calendar.FEBRUARY -> {
// snipped
}
// WRONG to comment out the default branch, 'switch' expression
// MUST cover all possible input values.
//
// default -> throw new RuntimeException(
// "Calendar in JDK does not work");
};
改进的switch语句
通过上面的解读我们知道了switch表达式里有很多积极的变化。那这些变化有没有影响switch语句呢比如说我们能够在switch语句里使用箭头标识符吗我们前面说过yield语句是来产生一个switch表达式代表的数值的因此yield语句只能用在switch表达式里不能用在switch语句。
其他的变化呢?我们还是先来看下面一段代码。
private static int daysInMonth(int year, int month) {
int daysInMonth = 0;
switch (month) {
case Calendar.JANUARY,
Calendar.MARCH,
Calendar.MAY,
Calendar.JULY,
Calendar.AUGUST,
Calendar.OCTOBER,
Calendar.DECEMBER ->
daysInMonth = 31;
case Calendar.APRIL,
Calendar.JUNE,
Calendar.SEPTEMBER,
Calendar.NOVEMBER ->
daysInMonth = 30;
case Calendar.FEBRUARY -> {
if (((year % 4 == 0) && !(year % 100 == 0))
|| (year % 400 == 0)) {
daysInMonth = 29;
break;
}
daysInMonth = 28;
}
// default -> throw new RuntimeException(
// "Calendar in JDK does not work");
}
return daysInMonth;
}
在这段代码里我们看到了箭头标识符看到了break语句看到了注释掉的default语句。这是一段合法的、能够工作的代码。换个说法switch语句可以使用箭头标识符也可以使用break语句也不需要列出所有的情景。表面上看起来switch语句的改进不是那么显而易见。其实switch语句的改进主要体现在break语句的使用上。
我们应该也看到了break语句没有出现在下一个case语句之前。这也就意味着使用箭头标识符的switch语句不再需要break语句来实现情景间的代码共享了。虽然我们还可以这样使用break语句但是已经不再必要了。
switch (month) {
// snipped
case Calendar.APRIL,
// snipped
Calendar.NOVEMBER -> {
daysInMonth = 30;
break; // UNNECESSARY, could be removed safely.
}
// snipped
}
有没有break语句使用箭头标识符的switch语句都不会顺次执行下面的操作fall-through。这样我们前面谈到的break语句带来的烦恼也就消失不见了。
不过使用箭头标识符的switch语句并没有禁止break语句而是恢复了它本来的意义从代码片段里抽身就像它在循环语句里扮演的角色一样。
switch (month) {
// snipped
case Calendar.FEBRUARY -> {
if (((year % 4 == 0) && !(year % 100 == 0))
|| (year % 400 == 0)) {
daysInMonth = 29;
break; // BREAK the switch statement
}
daysInMonth = 28;
}
// snipped
}
怪味的switch表达式
我们前面说过switch表达式也可以使用冒号标识符。使用冒号标识符的一个case语句只能匹配一个情景而且支持fall-through。和箭头标识符的switch表达式一样使用冒号标识符switch表达式也不支持break语句取而代之的是yield语句。
这是一个充满了怪味道的编码形式,我并不推荐使用这种形式,但我可以带你略作了解。下面的这段代码,就是我们试着把箭头标识符替换成冒号标识符的一个例子。你可以比较一下使用冒号标识符和箭头标识符的两段代码,想一想两种不同形式的优劣。毫无疑问,使用箭头标识符的代码更加简洁。
package co.ivi.jus.swexpr.legacy;
import java.util.Calendar;
class DaysInMonth {
public static void main(String[] args) {
Calendar today = Calendar.getInstance();
int month = today.get(Calendar.MONTH);
int year = today.get(Calendar.YEAR);
int daysInMonth = switch (month) {
case Calendar.JANUARY:
case Calendar.MARCH:
case Calendar.MAY:
case Calendar.JULY:
case Calendar.AUGUST:
case Calendar.OCTOBER:
case Calendar.DECEMBER:
yield 31;
case Calendar.APRIL:
case Calendar.JUNE:
case Calendar.SEPTEMBER:
case Calendar.NOVEMBER:
yield 30;
case Calendar.FEBRUARY:
if (((year % 4 == 0) && !(year % 100 == 0))
|| (year % 400 == 0)) {
yield 29;
} else {
yield 28;
}
default:
throw new RuntimeException(
"Calendar in JDK does not work");
};
System.out.println(
"There are " + daysInMonth + " days in this month.");
}
}
有了使用箭头标识符的switch语句和switch表达式之后我们不再推荐使用冒号标识符的switch语句和switch表达式。学习并使用箭头标识符的switch语句和switch表达式会使代码更简洁、更健壮。
总结
到这里我来做个小结。从前面的讨论中我们重点了解了switch表达式和改进的switch语句。我们还讨论了switch表达式带来的新概念和新的关键字了解了这些基本概念以及它们的适用范围。
新的switch形式、语句和表达式不同的使用范围这些概念交织在一起让switch的学习和使用都变成了一件有点挑战性的事情。箭头标识符的引入简化了代码提高了编码效率。可是学习这么多种switch的表现形式也增加了我们的学习负担。为了帮助你快速掌握这些形式我把不同的switch表达形式以及它们支持的特征放在了下面这张表格里。
或者,你也可以记住下面的总结:
break语句只能出现在switch语句里不能出现在switch表达式里
yield语句只能出现在switch表达式里不能出现在switch语句里
switch表达式需要穷举出所有的情景而switch语句不需要情景穷举
使用冒号标识符的swtich形式支持情景间的fall-through而使用箭头标识符的swtich形式不支持fall-through
使用箭头标识符的swtich形式一个case语句支持多个情景而使用冒号标识符的swtich形式不支持多情景的case语句。
使用箭头标识符的swtich形式废止了容易出问题的fall-through这个特征。因此我们推荐使用箭头标识符的swtich形式逐步废止使用冒号标识符的swtich形式。在switch表达式和switch语句之间我们应该优先使用switch表达式。这些选择都可以帮助我们简化代码逻辑减少代码错误提高生产效率。
如果你要丰富你的代码评审清单,学习完这一节内容后,你可以加入下面这一条:
使用冒号标识符的swtich形式是不是可以更改为使用箭头标识符-
使用switch语句赋值的操作是不是可以更改为使用switch表达式
另外,我还拎出了几个今天讨论过的技术要点,这些都可能在你们面试中出现哦。通过这一次学习,你应该能够:
知道switch表达式并且能够使用switch表达式
面试问题你知道switch表达式吗该怎么处理switch表达式里的语句
了解switch表达式要解决的问题并且知道解决掉这些问题的办法
面试问题使用switch表达式有哪些好处
了解不同的switch的表现形式能够看得懂不同的表现形式并且给出改进意见。
面试问题:你更喜欢使用箭头标识符还是冒号标识符?
如果你能够有意识地使用箭头标识符的switch表达式应该可以大幅度提高编码的效率和质量如果你能够了解不同的switch表现形式并且对每种形式都有自己的见解你就能帮助你的同事提高编码的效率和质量。毫无疑问在面试的时候有意识地在代码里使用switch表达式是一个能够展现你的学习能力、理解能力和对新知识的接受能力的一个好机会。
思考题
在前面的讨论里我们说过情景穷举是一个影响深远的改进方向它会使得switch表达式的代码更加健壮大幅度降低维护成本特别是在未来需要增加一个情景分支的情形下。但是限于篇幅我们并没有详细地展开讨论其中的细节。现在我们把这个讨论当作一个稍微有点挑战的思考题。
假设有一天地球和太阳的关系发生了变化这种变化还没有大到毁灭人类的程度但是也足以改变年月的关系了。于是天文学家重新修订了日历增加了一个新的月份第十三个月。为了对应这种变化JDK的设计者们也给Calendar类增加了第十三个月Calendar.AFTERDEC。那么我们的问题就来了。
第一个问题是,我们现在的代码能够检测到这个变化吗?如果不能,是不是只有系统崩溃的时候,我们才能够意识到问题的存在?
第二个问题是,有没有更健壮的设计,能够帮助我们在系统崩溃之前就能够检测到这个意想不到的变化?从而给我们留出时间更改我们的代码和系统?
稍微提示一个,解决这个问题的其中一个思路,就是要使用有穷举能力的表达式,然后设计出可以表达穷举情景的新形式,而不是使用泛泛的整数来表达十二个月。
我在下面的例子中写了一个代码小样。这个代码小样,实现的还是一年只有十二个月的逻辑。现在我们假设,一年还是十二个月,但是我们想让这段代码健壮到能够检测到未来一年变成十一个月或者十三个月的情景。
在这个代码小样里,我也试着加入了一些提示。当然,你也可以试着找找其他的解决方案。请试着将这段代码修改成你喜欢的样子,让我们一起看看怎么解决掉这个问题。
package co.ivi.jus.swexpr.review.xuelei;
import java.util.Calendar;
class DaysInMonth {
public static void main(String[] args) {
Calendar today = Calendar.getInstance();
int month = today.get(Calendar.MONTH);
int year = today.get(Calendar.YEAR);
// Hints: could we replace the integer month
// with an exhaustive enumeration?
int daysInMonth = switch (month) {
case Calendar.JANUARY,
Calendar.MARCH,
Calendar.MAY,
Calendar.JULY,
Calendar.AUGUST,
Calendar.OCTOBER,
Calendar.DECEMBER -> 31;
case Calendar.APRIL,
Calendar.JUNE,
Calendar.SEPTEMBER,
Calendar.NOVEMBER -> 30;
case Calendar.FEBRUARY -> {
if (((year % 4 == 0) && !(year % 100 == 0))
|| (year % 400 == 0)) {
yield 29;
} else {
yield 28;
}
}
// Hints: Are we able to replace the default case by
// enumerating all cases with case clause above?
default -> throw new RuntimeException(
"Calendar in JDK does not work");
};
System.out.println(
"There are " + daysInMonth + " days in this month.");
}
}
欢迎你在留言区留言、讨论,分享你的阅读体验以及你对这个思考题的想法。
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在实例匹配专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在swexpr/review/xuelei的目录下面。

View File

@ -0,0 +1,273 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 switch匹配能不能适配不同的类型
你好我是范学雷。今天我们聊一聊switch的模式匹配。
switch的模式匹配这个特性在JDK 17中以预览版的形式发布。按照通常的进度这个特性可能还需要两到三个版本才能最终定稿。
这个特性很简单,但是非常重要,可以帮助我们解决不少棘手而且重要的问题。我们不妨在定稿之前,就试着看看它。
前面我们讨论了类型匹配和switch表达式。那switch的模式匹配又是什么样子的呢为什么说switch的模式匹配非常重要我们还是通过案例和代码一步一步地了解switch的模式匹配吧。
阅读案例
在面向对象的编程语言中,研究表示形状的类,是一个常用的教学案例。今天的阅读案例,会涉及到表示形状的接口和类的定义,以后,我还会给出一个使用案例。通过这个案例,我们可以看到面向对象设计的一个代码在维护和发展时的难题。
假设我们定义了一个表示形状的封闭类它的名字是Shape我们也定义了两个许可类Circle和Square它们分别表示圆形和正方形。下面的代码就是一个可供你参考的实现方式。
public sealed interface Shape
permits Shape.Circle, Shape.Square {
record Circle(double radius) implements Shape {
// blank
}
record Square(double side) implements Shape {
// blank
}
}
接着,我们就要使用形状这个类来处理具体的问题了。你可以先试着回答一下,给定了一个形状的对象,我们该怎么判断这个对象是不是一个正方形呢?
这是一个简单的问题。只要判断这个对象是不是一个正方形类Square的实例就可以了。就像下面的代码这样。
public static boolean isSquare(Shape shape) {
return (shape instanceof Shape.Square);
}
无论是形状类的设计,还是我们处理问题的方式,看起来都没有什么问题。不过,如果我们朝前看,想一想未来的形状类的变化,问题可能就浮现出来了。
假设上面表示形状的封闭类和许可类是版本1.0它们被封装在一个基础API类库里。而判断一个表示形状的对象是不是正方形的代码也就是IsSquare的实现代码我们把它封装到另外一个API类库里。为了方便后面的讨论我们把这两个类库称为基础类库和扩展类库这两个名字并不一定契合实际
现在,我们升级表示形状的封闭类和许可类,新加入一个许可类,用来表示长方形。这样,我们就有了下面这样的代码。
public sealed interface Shape
permits Shape.Circle, Shape.Rectangle, Shape.Square {
/**
* @since 1.0
*/
record Circle(double radius) implements Shape {
// blank
}
/**
* @since 1.0
*/
record Square(double side) implements Shape {
// blank
}
/**
* @since 2.0
*/
record Rectangle(double length, double width) implements Shape {
// blank
}
}
在面向对象的世界里,增加一个新的字类是一种很常见的升级方法。而且,不论是出于理论还是实践,我们都没有充分的理论、也没有应有的能力杜绝掉这样的升级。所以,新加入一个表示长方形的许可类,似乎并没有什么不妥。类似这样的更改,我们也不会期待出现明显的可兼容性问题。
好了现在我们有了2.0版本的基础类库。
然后,我们再来看看扩展类库。我们知道,正方形是一个特殊的长方形。如果一个长方形的长和宽是相等的,那么它也是一个正方形。所以,如果基础类库支持了长方形,我们就需要考虑正方形这个特例。不然的话,这个扩展类库的实现,就不能处理这个特例。
扩展类库的更改也很简单,只要加入处理特例的逻辑就可以了。这样,我们就有了下面这样的升级之后的代码。
public static boolean isSquare(Shape shape) {
if (shape instanceof Shape.Rectangle rect) {
return (rect.length() == rect.width());
}
return (shape instanceof Shape.Square);
}
然而,意识到扩展类库需要更改,并不是一件容易的事情。甚至,通常情况下,我们可以说它是一件非常艰苦和艰难的事情。
对于需要更改扩展类库这件事,基础类库的作者,不会通知扩展类库的作者。这绝对不是基础类库的作者的懒惰或者不负责任。一般情况下,基础类库和扩展类库是独立的产品,由不同的团队或者社区维护。所以基础类库的作者往往不太可能意识到扩展类库的存在,更不可能去研究扩展类库的实现细节。所以,修改扩展类库这件事,一般来说,是扩展类库维护者的责任。
同样地扩展类库维护者也不会注意到基础类库的修改更不容易想到基础类库的修改会影响到扩展类库的行为。通常地API的使用者依赖API的兼容性。也就是说API可以升级但是这个升级不能影响已有代码的使用。换句话说1.0版本的API上能跑得通的代码2.0版本的API上同样的代码也必须能跑得通。所以扩展类库维护者也可以把问题踢给基础类库的维护者。
那么用户呢?有时候,他们找基础类库的维护者抱怨;有时候,他们找扩展类库的维护者抱怨。谁的市场影响大,对用户更友好,谁听到的抱怨就多一点。我们也没有理由责怪用户的抱怨,毕竟是他们的业务系统,也就是现实世界的系统,遇到了真正的问题,遭受了真实的损失。
这样的问题出现的根本原因,就是我们没有在用户抱怨之前发现这样的事实:扩展类库必须做出修改,以适应升级的基础类库。
而解决这样的问题,只依靠基础类库维护者和扩展类库维护者的勤奋,是不可能实现的。
那么,我们该怎么办呢?
其中的一个思路就是尽可能早地发现这样的兼容性问题。而我给你的其中一条解决办法就是使用具有类型匹配能力的switch表达式。
模式匹配的switch
具有模式匹配能力的switch说的是将模式匹配扩展到switch语句和switch表达式允许测试多个模式而且每一个模式都可以有特定的操作。这样就可以简洁、安全地表达复杂的面向数据的查询了。
下面的代码展示了如何使用具有模式匹配能力的switch来判断一个对象是不是正方形
public static boolean isSquare(Shape shape) {
return switch (shape) {
case null, Shape.Circle c -> false;
case Shape.Square s -> true;
};
}
这段简短的代码里面有几个地方是我们在JDK 17之前没有遇到过的。
扩充的匹配类型
第一个地方就是switch要匹配的表达式或者说数据而不是我们熟悉的类型。我们可能都知道JDK 17之前的switch关键字可以匹配的数据类型包括数字、枚举和字符串。本质上这三种数据类型都是整形的原始类型。而在上面的例子中这个要匹配的目标数据类型是一个表示形状的对象是一个引用类型。
具有模式匹配能力的switch提升了switch的数据类型匹配能力。switch要匹配的数据现在可以是整形的原始类型数字、枚举、字符串或者引用类型。
支持null情景模式
第二个地方就是空引用“null”出现在了匹配情景中。以前switch要匹配的数据不能是空引用。否则就会抛出“NullPointerException”这样的运行时异常。所以规范的、公开接口的代码通常都要检查匹配数据是不是一个空引用然后才能接着使用switch语句或者switch表达式。就像下面的例子这样。
public static boolean isSquare(Shape shape) {
if (shape == null) {
return false;
}
return switch (shape) {
case Shape.Circle c -> false;
case Shape.Square s -> true;
};
}
然而,对于非公开接口的内部实现代码,是不是需要这样的检查,并不是显而易见的。比如说,如果所有的调用,都不会传入空的引用,当然也就不需要检查空引用。可是,这样的假设过于脆弱。而且,对于代码的阅读者来说,去检查所有可能的内部调用,真的是一件很艰难的事情。
具有模式匹配能力的switch支持空引用的匹配。如果我们能够有意识地使用这个特性可以提高我们的编码效率降低代码错误。
可类型匹配的情景
第三个地方就是类型匹配出现在了匹配情景中。也就是说你既可以检查类型还可以获得匹配变量。以前switch要匹配的数据是一个数值比如说星期三或者十二月。对类型匹配来说switch要匹配的数据是一个引用这时候匹配情景要做的主要判断之一是我们希望知道的这个引用的类型。
比如说吧如果要匹配的数据是一个表示形状的类的引用我们希望匹配情景要能够判断出来这个引用是一个圆形类的引用还是一个正方形类的引用。如果情景能够匹配我们还希望能够获得匹配变量。这一点其实就像是我们在第5讲说到的类型匹配。现在类型匹配出现在了switch语句和switch表达式的使用场景里。
case Shape.Circle c -> false;
这样我们就在switch语句和switch表达式里获得了类型匹配的好处如果需要使用转换后的数据类型我们就不再需要编写强制类型转换的代码了。这就简化了代码逻辑减少了代码错误提高了生产效率。
穷举的匹配情景
具有模式匹配能力的switch是怎么解决掉阅读案例里讨论的基础类库和扩展类库协同维护问题的呢到现在这个问题的答案还不是很明确虽然答案已经有了。
这就是我们要讨论的第四个地方使用switch表达式穷举出所有的情景。在isSquare这个方法的实现里我们使用了switch表达式并且穷举出了所有可以匹配的形状类。我们知道switch表达式需要穷举出所有的情景。否则编译器就会报错。使用switch表达式这个特点就是我们解决阅读案例里提到的问题的基本思路。
现在如果我们使用2.0版本的基础类库也就是新加入了表示长方形的许可类的实现那么isSquare这个方法的实现就不能通过编译了。因为这个方法的实现遗漏了长方形这个许可类没有满足switch表达式需要穷举所有情景的要求。
如果代码编译期就报错,扩展类库的维护者就能够第一时间知道这个方法的缺陷。这样,他们就不用等到用户遇到真实问题的时候,才意识到要去适应升级的基础类库了。
这种提前暴露问题的方式,大大地降低了代码维护的难度,让我们有更多的精力专注在更有价值的问题上。
意识到代码需要修改,其实是最难的一步。如果已经意识到这个问题,具体的修改就很简单了。如果对实现细节感兴趣,你可以参考下面这段我修改后的代码。
public static boolean isSquare(Shape shape) {
return switch (shape) {
case null, Shape.Circle c -> false;
case Shape.Square s -> true;
case Shape.Rectangle r -> r.length() == r.width();
};
}
改进的性能
另外具有模式匹配能力的switch包括switch语句和switch表达式还提高了多情景处理性能。
如果使用if-else的处理方式每一个情景都要至少对应一个if-else语句。寻找匹配情景时需要按照if-else的使用顺序来执行直到遇到条件匹配的情景为止。这样对于if-else语句来说找到匹配情景的时间复杂度是O(N)其中N指的是需要处理的情景的数量。换句话说if-else语句寻找匹配情景的时间复杂度和需要处理的情景数量成正比。
如果使用switch的处理方式每一个情景也要至少对应一个case语句。但是寻找匹配情景时switch并不需要按照case语句的顺序执行。对于switch的处理方式找到匹配的情景的时间复杂度是O(1)。也就是说switch寻找匹配情景的时间复杂度和需要处理的情景数量关系不大。
情景越多使用switch的处理方式获得的性能提升就越大。
什么时候使用default
在前面的代码里我们并没有看到switch的缺省选择情景default关键字的使用。在switch的模式匹配里我们还可以使用缺省选择情景。比如说我们可以使用default来实现前面讨论的isSquare这个方法。
public static boolean isSquare(Shape shape) {
return switch (shape) {
case Shape.Square s -> true;
case null, default -> false;
};
}
使用了default也就意味着这样的switch表达式总是能够穷举出所有的情景。遗憾的是这样的代码丧失了检测匹配情景有没有变更的能力也丧失了解决阅读案例里提到的问题的能力。
所以一般来说只有我们能够确信待匹配类型的升级不会影响switch表达式的逻辑的时候我们才能考虑使用缺省选择情景。
总结
到这里我来做个小结。从前面的讨论中我们重点了解了switch的模式匹配以及如何使用switch表达式来检测子类扩充出现的兼容性问题。具有模式匹配能力的switch提升了switch的数据类型匹配能力。switch要匹配的数据现在可以是整形的原始类型数字、枚举、字符串或者引用类型。
在前面的讨论里我们把重点放在了switch表达式上。实际上除了情景穷举相关的内容之外我们的讨论也适用于switch语句。
在我们日常的编码实践中为了尽早暴露子类扩充出现的兼容性问题降低代码的维护难度提高多情景处理的性能我们应该优先考虑使用switch的模式匹配而不是传统的if-else语句。
如果你想要丰富你的代码评审清单有了switch的模式匹配以后你可以加入下面这几条
处理情景选择的if-else语句是不是可以使用switch的模式匹配-
使用了模式匹配的switch表达式有没有必要使用缺省选择情景default-
使用了模式匹配的switch语句和表达式是不是可以使用null选择情景?
另外,我还拎出了几个今天讨论过的技术要点,这些都可能在你们面试中出现哦。通过这一次学习,你应该能够:
知道switch能够适配不同的类型并且能够使用switch的模式匹配
面试问题你知道怎么使用switch匹配不同的类型吗
了解switch的模式匹配要解决的问题以及它的特点
面试问题使用switch的模式匹配有哪些好处
掌握怎么使用switch表达式处理子类扩充带来的兼容性问题。
面试问题:子类扩充有可能遇到什么问题,该怎么解决?
子类扩充出现的兼容性问题是面向对象编程实践中一个棘手、重要、高频的问题。如果你能够有意识地使用switch的模式匹配并且编写的代码能够自动检测到子类扩充出现的变动就可以降低代码的维护难度和维护成本提高代码的健壮性。在面试的时候如果你能够主动地在代码里使用switch的模式匹配而不是传统的if-else语句这会是一个震惊面试官的好机会。
思考题
关于switch的模式匹配还有两个特点我们没有讨论。一个是匹配情景的支配地位一个是戒备模式的匹配情景。这一次的思考题主要是一个阅读作业也是自学这两个特点的一个家庭作业。
希望你可以阅读switch的模式匹配的官方文档然后找出并且改正下面这段代码的错误尽可能地优化这段代码。
public static boolean isSquare(Shape shape) {
if (shape == null) {
return false;
}
return switch (shape) {
case Shape.Square s -> true;
case Shape.Rectangle r -> false;
case Shape.Rectangle r && r.length() == r.width() -> true;
default ->false;
};
}
欢迎你在留言区留言、讨论,分享你的阅读体验以及验证的代码和结果。我们下节课见!
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在switch模式匹配专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在pattern/review/xuelei的目录下面。
switch的模式匹配这个特性在JDK 17还是预览版。你可以现在开始学习这个特性但是暂时不要把它用在严肃的产品里直到正式版发布。

View File

@ -0,0 +1,384 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 抛出异常,是不是错误处理的第一选择?
你好我是范学雷。从今天开始我们进入这个专栏的第二个部分。在这一部分我们重点聊一聊代码的性能。这节课呢我想跟你讨论Java的错误处理。
Java的错误处理算不上是特性。但是Java错误处理的缺陷和滥用却一直是一个很有热度的话题。 其中Java异常的使用和处理是滥用最严重诟病最多也是最难平衡的一个难题。
为了解决花样百出的Java错误处理问题也有过各种各样的办法。然而到目前为止我们还没有看到能解决所有问题的好方法这也是编程语言研究者们的努力方向。
不过也正是因此我们就更需要掌握Java错误处理的机制平衡使用各种解决办法妥善处理好Java异常。我们还是通过案例和代码来看看Java异常的滥用以及可能的解决方案吧。
阅读案例
我们知道Java语言支持三种异常的状况非正常异常Error运行时异常Runtime Exception和检查型异常Checked Exception。关于这三种异常状况的介绍你可以参考《异常处理都有哪些陷阱》这篇文章。
通常情况下,我们谈到异常的时候,除非有特别的声明,不然指的都是运行时异常或者检查型异常。
我们还知道,异常状况的处理会让代码的效率变低,所以我们不应该使用异常机制来处理正常的状况。一个流畅的业务,理想的情况是,在执行代码时没有任何异常发生。否则,业务执行的效率就会大打折扣。
异常处理对代码执行效率的影响有多大呢?我们先要对这个问题有一个直观的感受,然后才能体会“不应该使用异常机制来处理正常的状况”这句话的分量,认识到异常滥用的危害。
下面的这段代码,测试了两个简单用例的吞吐量。这两种状况,都试图截取一段字符串。但是其中一个基准测试没有抛出异常;另外一个基准测试,由于字符串访问越界,抛出了运行时异常。为了让两个基准测试更具有对比性,我们在两个基准测试里,使用了相同的代码结构。
package co.ivi.jus.agility.former;
// snipped
public class OutOfBoundsBench {
private static String s = "Hello, world!"; // s.length() == 13.
// snipped
@Benchmark
public void withException() {
try {
s.substring(14);
} catch (RuntimeException re) {
// blank line, ignore the exception.
}
}
@Benchmark
public void noException() {
try {
s.substring(13);
} catch (RuntimeException re) {
// blank line, ignore the exception.
}
}
}
基准测试的结果可能会让你大吃一惊。没有抛出异常的用例它能够支持的吞吐量要比抛出异常的用例大1000倍。
Benchmark Mode Cnt Score Error Units
OutOfBoundsBench.noException thrpt 15 566348609.338 ± 22165278.114 ops/s
OutOfBoundsBench.withException thrpt 15 504193.920 ± 26489.992 ops/s
如果用运营成本来衡量一下的话你可以考虑按照使用的计算资源来计算费用的环境比如云计算。如果没有抛出异常的用例要花一万块钱的话抛出异常的用例就需要1000万才能支持相同数量的用户。如果一个黑客能够找到这样的运行效率问题它足以让一个应用多掏1000倍的钱或者直到应用耗尽分配的计算资源无法继续提供服务为止。
这样的评估当然很粗陋,但是足以说明抛出异常对软件效率的影响。我们当然不希望我们编写的代码存在这么一个烧钱的问题。
这时候我们就会设想:我们的代码,能不能没有任何异常状况发生?我们前面也提到过,“一个流畅的业务,理想的情况是,在执行代码时没有任何异常状况发生”。
可惜这几乎是无法完成的任务。随便翻一翻Java的代码不管是JDK这样的核心类库还是支持业务的应用软件我们都能看到大量的异常处理代码。
比如说吧我们要用Java搭建一个服务器。通常情况下如果业务逻辑出现了问题比如说用户输入的数据不合规范我们都会抛出一个异常标记出问题的数据并且记录下来问题出现的路径。但是无论出现什么样的业务问题服务器崩溃都是不能接受的结果。所以我们的服务器会捕获所有的异常不管是运行时异常还是检查型异常然后从异常中恢复过来继续提供服务。
但是场景是否异常有时候只是角度问题。比如说:输入数据不规范,从检查用户数据代码这个角度去看,这是一个不正常的情景,所以抛出异常;但是,如果从要求不间断运营的服务器的角度来看,这就只是一个需要应用程序妥善处理的正常状况,是一个正常的情景了。所以,服务器要能够从这样的异常中恢复过来,继续运行。
然而,现在稍微复杂一点的软件,都是很多类库集成的。大部分类库,都只从自己的角度考虑问题,并且使用异常来处理遇到的问题。除非是很简单的代码,不然我们很难期望一个业务执行下来没有任何异常状况发生。
毫无疑问抛出异常影响了代码的运行效率。但是我们又没有别的办法躲开这样的影响。所以有些新的编程语言比如Go语言干脆就彻底抛弃了类似于Java这样的异常机制重新拥抱C语言的错误码方式。
讨论案例
接下来的讨论,为了方便我们反复地修改代码,我会使用下面这个案例。
我们知道,在设计算法公开接口的时候,算法的敏捷性是必须要考虑的问题。因为,算法总是会演进,旧的算法会过时,新的算法会出现。一个应用程序,应该能够很方便地升级它的算法,自动地淘汰旧算法,采纳新算法,而不需要太大的改动,甚至不需要改动源代码。所以,算法的公开接口经常使用通用的参数和结构。
比如说,我们获取一个单项散列函数实例的时候,一般不会直接调用这个单项散列函数的构造函数。而是用一个类似于工厂模式的集成环境,来构造出这个单项散列函数的实例。
就像下面的这段代码里的of方法。这个of方法使用了一个字符串作为输入参数。我们可以把它作为配置参数写在配置文件里。修改配置文件之后不需要改动调用它的源代码就能升级算法了。
package co.ivi.jus.agility.former;
import java.security.NoSuchAlgorithmException;
public sealed abstract class Digest {
private static final class SHA256 extends Digest {
@Override
byte[] digest(byte[] message) {
// snipped
}
}
private static final class SHA512 extends Digest {
@Override
byte[] digest(byte[] message) {
// snipped
}
}
public static Digest of(String algorithm) throws NoSuchAlgorithmException {
return switch (algorithm) {
case "SHA-256" -> new SHA256();
case "SHA-512" -> new SHA512();
default -> throw new NoSuchAlgorithmException();
};
}
abstract byte[] digest(byte[] message);
}
当然通用参数也有它自己的问题。比方说字符串的输入参数可能有疏漏或者不是一个可以支持的算法。这时候站在of方法的角度就需要处理这样的异常状况。反映到代码上of方法要声明如何处理不合法的输入参数。上面的代码使用的办法是抛出一个检查型异常。
那么使用这个of方法的代码就需要处理这个检查型异常。下面的代码描述的就是一个使用这个方法的典型的例子。
try {
Digest md = Digest.of(digestAlgorithm);
md.digest("Hello, world!".getBytes());
} catch (NoSuchAlgorithmException nsae) {
// snipped
}
既然使用了异常处理当然也就会有我们在阅读案例里讨论过的异常处理的性能问题。我也试着给这个方法做了异常处理方面的基准测试。测试结果显示没有抛出异常的用例它能够支持的吞吐量要比抛出异常的用例大了将近2000倍。有了前面阅读案例的知识和铺垫你应该对这样的性能差异早已有了心理准备。
Benchmark Mode Cnt Score Error Units
ExceptionBench.noException thrpt 15 1318854854.577 ± 14522418.634 ops/s
ExceptionBench.withException thrpt 15 713057.511 ± 16631.048 ops/s
重回错误码
那么既然异常处理的效率这么让人揪心我们编写的Java代码能够像Go语言一样重回错误码方式吗这是我们首先要探索的一个方向。
也就是说,如果一个方法不需要返回值,我们可以试着把它修改为返回错误码。这是一个很直观的修改方式。
- // no return value
- public void doSomething();
+ // return an error code if run into problems, otherwise 0.
+ public int doSomething();
但是,如果一个方法需要一个返回值,我们就不能使用只返回错误码这种方式了。如果有一种方法,既能返回返回值,也能返回错误码,那么代码就会得到显著的改善。因此,我们需要设计一个数据结构,来支持这样的返回方式。
下面代码里的Coded这个档案类就是一个能够满足这样要求的数据结构。
public record Coded<T>(T returned, int errorCode) {
// blank
};
如果一个方法执行成功它的返回值应该存放在Coded的returned变量里如果执行失败失败的错误码应该存放在Coded的errorCode变量里。我们可以把讨论案例里的of方法修改成使用错误码的形式就像下面的这段代码这样。
public static Coded<Digest> of(String algorithm) {
return switch (algorithm) {
case "SHA-256" -> new Coded(sha256, 0);
case "SHA-512" -> new Coded(sha512, 0);
default -> new Coded(null, -1);
};
}
对应地,这个方法的使用就需要处理错误码。下面的代码,就是一个该怎么使用错误码的例子。
Coded<Digest> coded = Digest.of("SHA-256");
if (coded.errorCode() != 0) {
// snipped
} else {
coded.returned().digest("Hello, world!".getBytes());
}
看了上面的代码,我想你应该已经能够判断出来它的性能状况了。我们还是用基准测试来验证一下我们猜想吧。
测试结果显示,没有返回错误码的用例,它能够支持的吞吐量和返回错误码的用例几乎没有差别。这就是我们想要的结果。
Benchmark Mode Cnt Score Error Units
CodedBench.noErrorCode thrpt 15 1320977784.955 ± 7487395.023 ops/s
CodedBench.withErrorCode thrpt 15 1068513642.240 ± 69527558.874 ops/s
重回错误码的缺陷
不过重回错误码的选择并不是没有代价的。刚才我们在性能优化的同时也放弃了代码的可读性和可维护性。异常处理能够解决掉的也就是C语言时代的错误处理的缺陷又重新回来了。
需要更多的代码
使用异常处理的代码我们可以在一个try-catch语句块里包含多个方法的调用每一个方法的调用都可以抛出异常。这样由于异常的分层设计所有的异常都是Exception的子类我们也就可以一次性地处理多个方法抛出的异常了。
try {
doSomething(); // could throw Exception
doSomethingElse(); // could throw RuntimeException
socket.close(); // could throw IOException
} catch (Exception ex) {
// handle the exception in one place.
}
如果使用了错误码的方式,每一个方法调用都要检查返回的错误码。一般情况下,同样的逻辑和接口结构,使用错误码的方式需要编写更多的代码。
对于简单的逻辑和语句,我们可以使用逻辑运算符合并多个语句。这种紧凑的方式,牺牲了代码的可读性,不是我们喜欢的编码风格。
if (doSomething() != 0 &&
doSomethingElse() != 0 &&
socket.close() != 0) {
// handle the exception
}
但是对于复杂的逻辑和语句来说紧凑的方式就行不通了。这时候就需要一个独立的代码块来处理错误码。这样的话结构重复的代码就会增加这是我们在C语言编写的代码里经常见到的现象。
if (doSomething() != 0) {
// handle the exception
};
if (doSomethingElse() != 0) {
// handle the exception
};
if (socket.close() != 0) {
// handle the exception
}
丢弃了调试信息
不过,重回错误码最大的代价,是可维护性大幅度降低。使用异常的代码,我们能够通过异常的调用堆栈,清楚地看到代码的执行轨迹,快速找到出问题的代码。这也是我们使用异常处理的主要动力之一。
Exception in thread "main" java.security.NoSuchAlgorithmException: \
Unsupported digest algorithm SHA-128
at co.ivi.jus.agility.former.Digest.of(Digest.java:31)
at co.ivi.jus.agility.former.NoCatchCase.main(NoCatchCase.java:12)
但是,使用错误码之后,就不再生成调用堆栈了。虽然这可以让资源的消耗减少,也能够提升代码性能,但是调用堆栈能带来的好处也就没有了。
另外能够快速地找到代码的问题也是一个编程语言的竞争力。如果我们决定重回错误码的处理方式千万不要忘了提供快速排查问题的替代方案。比如使用更详尽的日志或者使用启用JFRJava Flight Recorder来收集诊断和分析数据。如果没有替代方案我相信你会非常怀念使用异常的好处。
其实呀C语言时代的错误码和Java语言时代的异常处理机制就像是跷跷板的两端一端是性能一端是可维护性。在Java诞生的时候有一个假设就是计算能力会快速演进所以性能的分量会有所下降而可维护性的分量会放得很重。然而如果演进到按照计算能力计费的时代我们可能需要重新考量这两个指标各自所占的比重了。这时候一部分代码可能就需要把性能的分量放得更重一些了。
易碎的数据结构
如果你阅读过我的另外一个专栏《代码精进之路》你应该能够理解一个新机制的设计必须要简单、皮实。所谓的皮实就是怎么用怎么对纪律少、要求低不容易犯错误。我们使用这样的准则来看看上面设计的Coded这个档案类是不是足够皮实。
生成一个Coded的实例需要遵守两条纪律。第一条纪律是错误码的数值必须一致0代表没有错误如果是其他的值表示出现了错误第二条纪律是不能同时设置返回值和错误码。违反了任何一条纪律都会出现不可预测的错误。
但是,这两条纪律需要编写代码的人自觉实现,编译器不会帮助我们检查错误。
比如下面的代码,对于编译器来说就是合法的代码。但对我们来说,这样的代码很明显违反了使用错误码需要遵守的规矩。这也就意味着,生成错误码的方式,不够皮实。
public static Coded<Digest> of(String algorithm) {
return switch (algorithm) {
// INCORRECT: set both error code and value.
case "SHA-256" -> new Coded(sha256, -1);
case "SHA-512" -> new Coded(sha512, 0);
default -> new Coded(sha256, -1);
};
}
我们再来看看使用错误码的代码。使用错误码,也有一条铁的纪律:必须首先检查错误码,然后才能使用返回值。同样,编译器也不会帮助我们检查违反纪律的错误。下面的代码,就没有正确使用错误码。我们需要依靠经验才能避免这样的错误。所以,使用错误码的方式,也不够皮实。
Coded<Digest> coded = Digest.of("SHA-256");
// INCORRECT: use returned value before checking error code.
coded.returned().digest("Hello, world!".getBytes());
需要的纪律越多,我们犯错的可能性就越大。那有没有改进的方案,能够减少这些额外的要求呢?
改进方案:共用错误码
我们希望,改进的方案能够同时考虑生成错误码和使用错误码两端的需求。下面这段代码就是一个改进的设计。
public sealed interface Returned<T> {
record ReturnValue<T>(T returnValue) implements Returned {
}
record ErrorCode(Integer errorCode) implements Returned {
}
}
在这个改进的设计里我们使用了封闭类。我们知道封闭类的子类是可以穷举的这是这项改进需要的一个重要特点。我们把Returned的许可类ReturnValue和ErrorCode定义成档案类分别表示返回值和错误代码。这样我们就有了一个精简的方案。
下面这段代码就是用新方案生成返回值和错误码的一个例子。可以看到相比较使用Coded档案类的例子这里的返回值和错误码分离开了。一个方法返回的要么是返回值要么是错误码而不是同时返回两个值。这种方式又把我们带回到了熟悉的编码方式。
public static Returned<Digest> of(String algorithm) {
return switch (algorithm) {
case "SHA-256" -> new ReturnValue(new SHA256());
case "SHA-512" -> new ReturnValue(new SHA512());
case null, default -> new ErrorCode(-1);
};
}
而且生成Coded实例需要遵守的两条纪律在这里也不需要了。因为返回ReturnValue这个许可类就表示没有错误返回ErrorCode这个许可类就表示出现错误。这样的设计就变得简单、皮实多了。
接下来我们再看看使用错误码的情况。下面的这段代码我们使用了前面讨论过的switch匹配的新特性。Returned这个封闭类被设计成了一个没有方法的接口要想获得返回值我们就必须要使用它的许可类ReturnValue或者ErrorCode。
Returned<Digest> rt = Digest.of("SHA-256");
switch (rt) {
case ReturnValue rv -> {
Digest d = (Digest) rv.returnValue();
d.digest("Hello, world!".getBytes());
}
case ErrorCode ec ->
System.out.println("Failed to get instance of SHA-256");
}
如果一个方法的调用返回的是Returned实例我们就知道它要么是代表返回值的ReturnValue对象要么是代表错误码的ErrorCode对象。而且你要使用返回值就必须检查它是不是一个ReturnValue的实例。这种情况下使用Coded档案类编写代码需要遵守的纪律也就是必须先检查错误码在这里也不需要了。使用错误码的这一端也变得更加简单、皮实了。
当然使用封闭类来分别表示返回值和错误码的方式只是改进错误码的其中一种方式。这种方式仍然具有一些缺陷例如它本身没有携带调试信息。在Java的错误处理方面我们希望未来能够有更好的设计和更多的探索让我们的代码更完善。
总结
这节课就讲到这里我来做个小结。从前面的讨论中我们了解了Java异常处理带来的性能问题我还给你展示了使用错误码的方式进行错误处理的方案。使用错误码的方式进行错误处理错误码不能携带调试信息这提高了错误处理的性能但是增加了错误排查的困难降低了代码的可维护性。
我们在代码里是应该使用错误码还是应该使用异常这是一个需要根据应用场景认真权衡的问题。Java的新特性尤其是封闭类和档案类为我们在Java的软件里使用错误码的形式提供了强大的支持让我们有了新的选择。
如果你想要丰富你的代码评审清单,错误码可以作为一个可评估的选项,进入你的考察指标内:
使用异常的机制进行错误处理,是不是一个最优的选择?
另外,我还拎出了几个今天讨论过的技术要点,这些都可能在你们面试中出现哦。通过今天的学习,你应该能够:
清楚Java异常处理所带来的性能问题对这一问题的影响程度有一个大致的概念
面试问题你知道Java异常处理会产生什么问题吗
了解Java异常处理的替代方案以及它的优势和劣势
面试问题你知道怎么提高Java代码的性能吗
使用封闭类和档案类这样的Java新技术为Java的错误处理寻求一个替代方案这是一个崭新的、尚未开发的课题。在面试的时候我们经常会遇到对代码性能有着苛刻要求的场景如果你能够借助新特性展示错误处理的替代方案并且不回避这个方案存在的问题这一定是一个彰显你创新能力的好时机。
思考题
在前面的替代方案中我们使用封闭类来分别表示了返回值和错误码在使用错误码的代码里我们使用了switch的模式匹配。可是直到JDK 17switch的模式匹配这个特性还只是一个预览版还没有最终定稿。一般情况下我们可以研究探索但是不推荐使用预览版的特性。那么如果不使用switch的模式匹配使用错误码的代码可能是什么样子的呢这是这一次的思考题。
为了方便你阅读我把switch模式匹配的代码放在了下面。你可以在这个基础上替换掉switch模式匹配看看最后会是什么样子的。
package co.ivi.jus.error.review.xuelei;
import co.ivi.jus.error.union.Digest;
import co.ivi.jus.error.union.Returned;
public class UseCase {
public static void main(String[] args) {
Returned<Digest> rt = Digest.of("SHA-256");
switch (rt) {
case Returned.ReturnValue rv -> {
Digest d = (Digest) rv.returnValue();
d.digest("Hello, world!".getBytes());
}
case Returned.ErrorCode ec ->
System.out.println("Failed to get instance of SHA-256");
}
}
}
欢迎你在留言区留言、讨论,分享你的阅读体验以及验证的代码和结果。我们下节课再见!
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在错误处理专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在error/review/xuelei的目录下面。

View File

@ -0,0 +1,298 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 异常恢复,付出的代价能不能少一点?
你好我是范学雷。今天我们接着讨论Java的错误处理。这一讲是上一次我们讨论的关于错误处理问题的继续和升级。
就像我们上一次讨论到的Java的异常处理是一个对代码性能有着重要影响的因素。所以说Java错误处理的缺陷和滥用也成为了一个热度始终不减的老话题。但是Java的异常处理有着天生的优势特别是它在错误排查方面的作用我们很难找到合适的替代方案。
那有没有可能改进Java的异常处理保持它在错误排查方面的优势的同时提高它的性能呢这是一个又让马儿跑又让马儿不吃草的问题。不过这并不妨碍我们顺着这个思路找一找其中的可能性。
我们还是先从阅读案例开始,来试着找一找其中的蛛丝马迹吧。
阅读案例
要尝试解决一个问题我们首先要做的就是把问题梳理清楚定义好。我们先来看看Java异常处理的三个典型使用场景。
下面的这段代码里,有三个不同的异常使用方法。在分别解析的过程中,你可能会遇到几个疑问,不过别急,带着这几个问题,我们最后来一一解读。
package co.ivi.jus.stack.former;
import java.security.NoSuchAlgorithmException;
public class UseCase {
public static void main(String[] args) {
String[] algorithms = {"SHA-128", "SHA-192"};
String availableAlgorithm = null;
for (String algorithm : algorithms) {
Digest md;
try {
md = Digest.of(algorithm);
} catch (NoSuchAlgorithmException ex) {
// ignore, continue to use the next algorithm.
continue;
}
try {
md.digest("Hello, world!".getBytes());
} catch (Exception ex) {
System.getLogger("co.ivi.jus.stack.former")
.log(System.Logger.Level.WARNING,
algorithm + " does not work",
ex);
continue;
}
availableAlgorithm = algorithm;
}
if (availableAlgorithm != null) {
System.out.println(availableAlgorithm + " is available");
} else {
throw new RuntimeException("No available hash algorithm");
}
}
}
可恢复异常
第一种就是可恢复的异常处理。
这是什么意思呢对于代码里的异常NoSuchAlgorithmException来说这段代码尝试捕获、识别这个异常然后再从异常里恢复过来继续执行代码。我们把这种可以从异常里恢复过来继续执行的异常处理叫做可恢复的异常处理简称为可恢复异常。
为了深入理解可恢复异常我们需要仔细地看看NoSuchAlgorithmException这个异常的处理过程。这个处理的过程其实就只有一行有效的代码也就是catch语句。
} catch (NoSuchAlgorithmException nsae) {
// ignore, continue to use the next algorithm.
}
只要catch语句能够捕获、识别到这个异常这个异常的生命周期就结束了。catch只需要知道异常的名字而不需要知道异常的调用堆栈。不使用异常的调用堆栈也就意味着这样的异常处理极大地消弱了Java异常在错误排查方面的作用。
既然可恢复异常不使用异常的调用堆栈,是不是可恢复异常就不需要生成调用堆栈了呢?这是我们提出的第一个问题。
从Java异常的性能基准测试结果看我们知道生成异常的调用堆栈是异常处理影响性能的最主要因素。如果不需要生成调用堆栈那么Java异常的处理性能就会有成百上千倍的提升。所以如果我们找到了第一个问题的答案我们就解决了可恢复异常的性能瓶颈。
不可恢复异常
好了我们再回头看看第二个使用场景。对于代码里的异常RuntimeException来说上面的代码并没有尝试捕获、识别它。这个异常直接导致了程序的退出并且把异常的信息和调用堆栈打印了出来。
Exception in thread "main" java.lang.RuntimeException: No available hash algorithm
at co.ivi.jus.stack.former.UseCase.main(UseCase.java:27)
这样的异常处理方式导致了程序的中断,程序不能从异常抛出的地方恢复过来。我们把这种方式,叫做不可恢复的异常处理,简称为不可恢复异常。
调用堆栈对于不可恢复异常来说至关重要,因为我们可以从异常调用堆栈的打印信息里,快速定位到出问题的代码。毫无疑问,这加快了问题排查,降低了运维的成本。
由于不可恢复异常中断了程序的运行,所以它的性能开销是一次性的。因此,不可恢复异常对于性能的影响,其实我们不用太在意。
使用了异常信息和调用堆栈,又不用担心性能的影响,不可恢复异常似乎很理想。可是,在多大的程度上,我们可以允许程序由于异常中断而退出呢?这是一个很难回答的问题。
试想一下,如果是作为服务器的程序,我们会希望它能一直运行,遇到异常能够恢复过来。所以一般情况下,服务器的场景下,不会使用不可恢复异常。
现在的客户端程序呢比如手机里的app如果遇到异常就崩溃我们就不会有耐心继续使用了。似乎客户端的程序也没有多少不可恢复异常的使用场景。
也许,不可恢复异常的使用场景,仅仅存在于我们的演示程序里。高质量的产品里,似乎很难允许不可恢复异常的存在。
既然我们无法忍受程序的崩溃,那么不可恢复异常还有存在的必要吗?这是我们提出的第二个问题。
记录的调试信息
最后我们再来看看第三个使用场景。对于代码里的异常Exception来说这段代码尝试捕获、识别这个异常然后从异常里恢复过来继续执行代码。它是一个可恢复的异常。和第一个场景不同的是这段代码还在日志里记录了下了这个异常一般来说这个异常的调试信息也就是异常信息和调用堆栈也会被详细地记载在日志里。
其实,这也是可恢复异常的一个典型的使用场景;程序可以恢复,但是异常信息可以记录待查。
我们再来仔细看看异常信息是怎么记录在案的。为了方便我们观察,我把日志记录的这几行代码单独摘抄了出来。
System.getLogger("co.ivi.jus.stack.former")
.log(System.Logger.Level.WARNING,
algorithm + " does not work",
ex);
我们可以看到,日志记录下来了如下的关键信息:
在异常捕获的场景下这个异常的记录方式包括是否记录“co.ivi.jus.stack.former”
在异常捕获的场景下这个异常的记录地点System.getLogger()
在异常捕获的场景下这个异常的严重程度Logger.Level
在异常捕获的场景下,这个异常表示的影响(“[algorithm] does not work”
异常生成的时候携带的信息包括异常信息和调用堆栈ex
其中,前四项信息,是在方法调用的代码里生成的;第五项,是在方法实现的代码里生成的。也就是说,记录在案的调试信息,既包括调用代码的信息,也包括实现代码的信息。
如果放弃了Java的异常处理机制我们还能够获得足够的调试信息吗换种说法我们有没有快速定位问题的替代方案这是我们提出的第三个问题。
改进的共用错误码
刚才我们通过Java异常处理的三个典型场景提出了三个棘手的问题
既然可恢复异常不使用异常的调用堆栈,是不是可恢复异常就不需要生成调用堆栈了?
既然我们无法忍受程序的崩溃,那么不可恢复异常还有存在的必要吗?
我们有没有快速定位问题的替代方案?
带着这三个问题,我们再来看看能不能改进一下我们上一讲里讨论的共用错误码的方案。
共用错误码本身,并没有携带调试信息。为了能够快速定位出问题,我们需要为共用错误码的方案补上调试信息。
下面的两段代码,就是我们要在补充调试信息方面做的尝试。第一段代码,是我们在方法实现的代码里的尝试。在这段代码里,我们使用异常的形式补充了调试信息,包括问题描述和调用堆栈。
public static Returned<Digest> of(String algorithm) {
return switch (algorithm) {
case "SHA-256" -> new Returned.ReturnValue(new SHA256());
case "SHA-512" -> new Returned.ReturnValue(new SHA512());
case null -> {
System.getLogger("co.ivi.jus.stack.union")
.log(System.Logger.Level.WARNING,
"No algorithm is specified",
new Throwable("the calling stack"));
yield new Returned.ErrorCode(-1);
}
default -> {
System.getLogger("co.ivi.jus.stack.union")
.log(System.Logger.Level.INFO,
"Unknown algorithm is specified " + algorithm,
new Throwable("the calling stack"));
yield new Returned.ErrorCode(-1);
}
};
}
第二段代码,是我们在方法调用的代码里的尝试。在这段代码里,我们补充了调用场景的信息。
Returned<Digest> rt = Digest.of("SHA-128");
switch (rt) {
case Returned.ReturnValue rv -> {
Digest d = (Digest) rv.returnValue();
d.digest("Hello, world!".getBytes());
}
case Returned.ErrorCode ec ->
System.getLogger("co.ivi.jus.stack.union")
.log(System.Logger.Level.INFO,
"Failed to get instance of SHA-128");
}
经过这样的调整,类似于使用异常处理的、快速定位出问题的调试信息就又回来了。
Nov 05, 2021 10:08:23 PM co.ivi.jus.stack.union.Digest of
INFO: Unknown algorithm is specified SHA-128
java.lang.Throwable: the calling stack
at co.ivi.jus.stack.union.Digest.of(Digest.java:37)
at co.ivi.jus.stack.union.UseCase.main(UseCase.java:10)
Nov 05, 2021 10:08:23 PM co.ivi.jus.stack.union.UseCase main
INFO: Failed to get instance of SHA-128
你一定会有这样的问题。调试信息又回来了,难道不是以性能损失为代价的吗?
是的,使用调试信息带来的性能损失,并不比使用异常性能的损失小多少。不过好在,日志记录既可以开启,又可以关闭。如果我们关闭了日志,就不用再生成调试信息了,当然它的性能影响也就消失了。当需要我们定位问题的时候,再启动日志。这时候,我们就能够把性能的影响控制到一个极小的范围内了。
那么,使用错误码的错误处理方案,是怎么处理我们在阅读案例提到的问题的呢?
其实,每一个问题的处理,都很清晰。我把问题和答案都列在了下面的表格里,你可以看一看。
当然日志并不是唯一可以记录调试信息的方式。比如说我们还可以使用更便捷的JFRJava Flight Recorder特性。
其实错误码的调试信息使用方式更符合调试的目的只有需要调试的时候才会生成调试信息。那么如果继续沿用Java的异常处理机制调试信息能不能按需开启、关闭呢这是我们今天的第四个问题也是提给Java语言设计师的问题。
有了今天这四个问题做铺垫,如果有一天, Java语言的异常能够支持可以开合的异常处理机制了想必到时候你就不会感到惊讶了。
总结
到这里我来做个小结。刚才我们了解和讨论了Java异常处理的两个概念可恢复异常和不可恢复异常。我还给出了在使用错误码的场景下快速定位问题的替代方案。
这一讲我们并没有讨论新特性而是我们重点讨论了现在Java异常处理机制的几个热门话题。这节课的重点是要开拓我们的思维。了解这些热门的话题不仅可以增加你的谈资还可以切实地提高你的代码性能和可维护性。
另外,我还拎出了几个今天讨论过的技术要点,这些都可能在你的面试中出现哦。通过这一次学习,你应该能够:
了解可恢复异常和不可恢复异常这两个概念,以及它们的使用场景;
面试问题你的代码是怎么处理Java异常的
了解怎么在使用错误码的方案里,添加快速定位出问题的调试信息;
面试问题:你的代码,是怎么定位可能存在的问题的?
对Java错误处理机制的改进这会是一个持续热门的话题。而能够了解替代方案并且使用替代方案的软件工程师现在还不多。如果你能够展示错误处理的替代方案而且还不牺牲异常处理的优势这是一个能够在面试里获得主动权控制话语权的必杀技。
思考题
怎么通过改进Java的异常处理来获取性能的提升我们已经花了两讲的时间了。我们提出的这些改进方案其实依然有很大的提升空间。比如说吧我们使用了整数表示错误码这里其实就存在很多问题。
因为有时候,我们可能需要区别不同的错误,这样我们就不能总是使用一个错误码(-1。如果存在多个错误码我们怎么知道方法实现的代码返回的错误码是什么呢编译器能不能帮助我们检查错误码的使用是不是匹配 比如说错误码的检查有没有遗漏,有没有多余?如果返回的错误码从两个增加到三个,使用该方法的代码能不能自动地检测到?
解决好这些问题,能够大幅度提高代码的可维护性和健壮性。该怎么解决掉这些问题呢?这是我们今天的思考题。
为了方便你阅读,我把需要两个错误码的案例代码放在了下面。一段代码是方法实现的代码,一段代码是方法使用的代码。你可以在这两段代码的基础上改动,看看最后你是怎么处理多个错误码的。
这一段是方法实现的代码。
public static Returned<Digest> of(String algorithm) {
return switch (algorithm) {
case "SHA-256" -> new Returned.ReturnValue(new SHA256());
case "SHA-512" -> new Returned.ReturnValue(new SHA512());
case null -> {
System.getLogger("co.ivi.jus.stack.union")
.log(System.Logger.Level.WARNING,
"No algorithm is specified",
new Throwable("the calling stack"));
yield new Returned.ErrorCode(-1);
}
default -> {
System.getLogger("co.ivi.jus.stack.union")
.log(System.Logger.Level.INFO,
"Unknown algorithm is specified " + algorithm,
new Throwable("the calling stack"));
yield new Returned.ErrorCode(-2);
}
};
}
这一段是方法使用的代码。
Returned<Digest> rt = Digest.of("SHA-128");
switch (rt) {
case Returned.ReturnValue rv -> {
Digest d = (Digest) rv.returnValue();
d.digest("Hello, world!".getBytes());
}
case Returned.ErrorCode ec -> {
if (ec.errorCode() == -1) {
System.getLogger("co.ivi.jus.stack.union")
.log(System.Logger.Level.INFO,
"Unlikedly to happen");
} else {
System.getLogger("co.ivi.jus.stack.union")
.log(System.Logger.Level.INFO,
"SHA-218 is not supported");
}
}
}
欢迎你在留言区留言、讨论,分享你的阅读体验以及验证的代码和结果。我们下节课再见!
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在异常恢复专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在stack/review/xuelei的目录下面。

View File

@ -0,0 +1,404 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 Flow是异步编程的终极选择吗
你好,我是范学雷。今天,我们讨论反应式编程。
反应式编程曾经是一个很热门的话题。它是代码的控制的一种模式。如果不分析其他的模式,我们很难识别反应式编程的好与坏,以及最合适它的使用场景。所以,我们今天的讨论,和以往有很大的不同。
除了反应式编程之外,我们还会花很大的篇幅讨论其他的编程模式,包括现在的和未来的。希望这样的安排,能够帮助你根据具体的场景,选择最合适的模式。
我们从阅读案例开始先来看一看最传统的模式然后一步一步地过渡到反应式编程最后我们再来稍微聊几句Java尚未发布的协程模式。
阅读案例
我想你和我一样无论是学习C语言还是Java语言都是从打印”Hello, world!“这个简单的例子开始的。我们再来看看这个我们熟悉的代码。
System.out.println("Hello, World!");
这段代码就是使用了最常用的代码控制模式:指令式编程模型。所谓指令式编程模型,需要我们通过代码发布指令,然后等待指令的执行以及指令执行带来的状态变化。我们还要根据目前的状态,来确定下一次要发布的指令,并且用代码把下一个指令表示出来。
上面的代码里我们发布的指令就是标准输出打印“Hello, World!”这句话。然后,我们就等待指令的执行结果,验证我们编写的代码有没有按照我们的指令工作。
指令式编程模型
指令式编程模型关注的重点就在于控制状态。“Hello, world!“这个例子能看出来一点端倪,但是要了解状态变化和控制,我们需要看两行以上的代码。
try {
Digest messageDigest = Digest.of("SHA-256");
byte[] digestValue =
messageDigest.digest("Hello, world!".getBytes());
} catch (NoSuchAlgorithmException ex) {
System.out.println("Unsupported algorithm: SHA-256");
}
在上面的这段代码里我们首先调用Digest.of方法得到一个Digest实例然后调用这个实例的方法Digest.digest获得一个返回值。第一个方法执行完成后获得了第一个方法执行后的状态第二个方法才能接着执行。
这种顺序执行的模式,逻辑简单直接。简单直接本身就有着巨大的能量,特别是实现精确控制方面。所以,这种模式在通用编程语言设计和一般的应用程序开发中,占据着压倒性的优势。
但是,这种模式需要维护和同步状态。如果状态数量大,我们就要把大的代码块分解成小的代码块;这样,我们编写的代码才能更容易阅读,更容易维护。而更大的问题来自于状态同步需要的顺序执行。
比如说吧上面的例子中Digest.of这个方法实现可能效率很高执行得很快而Digest.digest这个方法的实现它的执行速度可能就是毫秒级的甚至是秒一级别的。在要求低延迟、高并发的环境下等待Digest.digest调用的返回结果可能就不是一个好的选择。换句话说阻塞在方法的调用上增加了系统的延迟降低了系统能够支持的吞吐量。
这种顺序执行的模式带来的延迟后果,在互联网时代的很多场景下是无法忍受的(比如春节的火车票预售系统,或者网上购物节的订购系统等)。存在这种问题最典型的场景之一,就是客户端-服务器这种架构下的传统的套接字编程接口。它也引发了大约20年前提出的C10K问题支持1万个并发用户
怎样解决C10K问题呢一个主要方向就是使用非阻塞的异步编程。
声明式编程模型
非阻塞的异步编程,并不是可以通过编程语言或者标准类库就可以得到的。支持非阻塞的异步编程,需要大幅度地更改代码,转换代码编写的思维习惯。
我们可以使用打电话来做个比方。
传统的指令式编程模型,就像我们通常打电话一样。我们拨打对方的电话号码,然后等待接听,然后通话,然后挂断。当我们挂断电话的时候,打电话这一个过程也就结束了,我们也拿到了想要的结果。
而非阻塞的异步编程,更像是电话留言。我们拨打对方的电话,告诉对方方便的时候,回拨电话,然后就挂断了。当我们挂断电话的时候,打电话这一个过程当然也是结束了,但是我们没有拿到想要的结果。想要的结果,还要依靠回拨电话,才能够得到。
而类似于回拨电话的逻辑,正是非阻塞的异步编程的关键模型。映射到代码上,就是使用回调函数或者方法。
当我们试图使用回调函数时,我们编写代码的思想和模型都会产生巨大的变化。我们关注的重点,就会从指令式编程模型的“控制状态”转变到“控制目标”。这时候,我们编程模型也就转变到了声明式的编程模型。
如果指令式编程模型的逻辑是告诉计算机“该怎么做”,那么声明式的编程模型的逻辑就是告诉计算机“要做什么”。指令式编程模型的代码像是流水线作业的工程师,事无巨细,拧好每一个螺丝;而声明式的编程模型的代码,更像是稳坐在军帐中的军师,布置任务,运筹帷幄。
我们前面讨论的Digest能不能实现非阻塞的异步编程呢答案是肯定的不过我们需要彻底地更改代码从API到实现都要转换思路。下面这段代码里声明的API就是我们尝试使用声明式编程的一个例子。
public sealed abstract class Digest {
public static void of(String algorithm,
Consumer<Digest> onSuccess, Consumer<Integer> onFailure) {
// snipped
}
public abstract void digest(byte[] message,
Consumer<byte[]> onSuccess, Consumer<Integer> onFailure);
}
转化了思路的Digest.of方法就像是布置任务如果执行成功请继续执行A计划也就是onSuccess这个回调函数否则就继续执行B计划也就是onFailure这个回调函数。其实这也就是我们前面提到的告诉计算机“要做什么”的概念。
有了回调函数的设计代码的实现方式就放开了管制。无论是回调函数的实现还是回调函数的调用都可以自由地选择是采用异步的模式还是同步的模式。不用说这种自由很具有吸引力。从JDK 7引入NIO新特性开始这种模式开始进入Java的工业实践并且取得了巨大的成功。出现了一大批的明星项目。
不过回调函数的设计也有着天生的缺陷。这个缺陷就是回调地狱Callback Hell常被译为回调地狱。为了更直观地表达我更喜欢把它叫做回调堆挤。什么意思呢通常地我们需要布置多个小的任务才能完成一项大的任务。这些小任务还有可能是有因果关系的任务这时候就需要小任务的配合或者按顺序执行。
比如说上面的Digest设计我们先要判断of方法能不能成功如果成功的话那么就使用这个Digest实例调用它的Digest.digest方法。而Digest.digest方法的调用也要作出A计划和B计划。这样两个回调函数的使用就会堆积起来。如果回调函数的嵌套增多代码看起来就像挤在一块一样形式上不美观阅读起来很费解维护起来难度很大。
下面的这段代码就是我们使用回调函数设计的Digest的一个用例。这个用例里回调函数的嵌套仅仅有两层代码的形式已经变得很难阅读了。你可以尝试编写一个3层或者5层的回调函数的嵌套体验一下深度嵌套的代码是什么样子的。
Digest.of("SHA-256",
md -> {
System.out.println("SHA-256 is not supported");
md.digest("Hello, world!".getBytes(),
values -> {
System.out.println("SHA-256 is available");
},
errorCode -> {
System.out.println("SHA-256 is not available");
});
},
errorCode -> {
System.out.println("Unsupported algorithm: SHA-256");
});
如果说,回调函数带来的形式的堆积我们还可以克服的话;那这种形式上的堆积带来的逻辑堆积,我们就几乎不可承受了。逻辑上的堆积,意味着代码的深度耦合。而深度耦合,意味着代码维护困难。深度嵌套里的一点点代码修改,都可能通过嵌套层层朝上传递,最后牵动全局。
这就导致,使用回调函数的声明式编程模型有着严重的场景适应问题。我们通常只使用回调函数解决性能影响最大的模块,比如说网络数据的传输;而大部分的代码,依然使用传统的、顺序执行的指令式模型。
好在,业界也有很多努力,试图改善回调函数的使用困境。其中最出色也是影响最大的一个,就是反应式编程。
反应式编程
反应式编程的基本逻辑,仍然是告诉计算机“要做什么”;但是它的关注点转移到了数据的变化以及数据和变化的传递上,或者说,是转移到了对数据变化的反应上。所以,反应式编程的核心是数据流和变化传递。
如果我们从数据的流向角度来看的话,数据有两种基本的形式: 数据的输入和数据的输出。从这两种基本的形式,能够衍生出三种过程:最初的来源,数据的传递和最终的结局。
数据的输出
在Java的反应式编程模型的设计里数据的输出使用只有一个参数的Flow.Publisher来表示。
@FunctionalInterface
public static interface Publisher<T> {
public void subscribe(Subscriber<? super T> subscriber);
}
在Flow.Publisher的接口设计里泛型T表示的就是数据的类型。 数据输出的对象是使用Flow.Subscriber来表示的。换句话说数据的发布者通过授权订阅者来实现数据从发布者到订阅者的传递。一个数据的发布者可以有多个数据的订阅者。
需要注意的是订阅的接口安排在了Flow.Publisher这个接口里。这也就意味着订阅者的订阅行为是由数据的发布者发起的而不是订阅者发起的。
数据最初的来源,就是一种形式的数据输出;它只有数据输出这一个传递方向,而不能接收数据的输入。
比如下面的代码就是一个表示数据最初来源的例子。在这段代码里数据的类型是字节数组而数据发布的实现我们使用了Java标准类库的参考性实现SubmissionPublisher这个类。
SubmissionPublisher<byte[]> publisher = new SubmissionPublisher<>();
数据的输入
下面,我们再来看下数据的输入。
在Java的反应式编程模型的设计里数据的输入用只有一个参数的Flow.Subscriber来表示。也就是我们前面提到的订阅者。
public static interface Subscriber<T> {
public void onSubscribe(Subscription subscription);
public void onNext(T item);
public void onError(Throwable throwable);
public void onComplete();
}
在Flow.Subscriber的接口设计里泛型T表示的就是数据的类型。 这个接口里一共定义了四种任务,并分别规定了下面四种情形下的反应:
如果接收到订阅邀请该怎么办这个行为由onSubscribe这个方法的实现确定。
如果接收到数据该怎么办这个行为由onNext这个方法的实现确定。
如果遇到了错误该怎么办这个行为由onError这个方法的实现确定。
如果数据传输完毕该怎么办这个行为由onComplete这个方法的实现确定。
数据最终的结局,就是一种形式的数据输入;它只有数据输入这一个传递方向,而不能产生数据的输出。
比如下面的代码就是一个表示数据最终结果的例子。在这段代码里我们使用一个泛型来表示数据的类型然后使用了一个Consumer函数来表示我们该怎么处理接收到的数据。这样的安排让这个例子具有了普遍的意义。只要稍作修改就可以把它使用到实际场景中去了。
package co.ivi.jus.flow.reactive;
import java.util.concurrent.Flow;
import java.util.function.Consumer;
public class Destination<T> implements Flow.Subscriber<T>{
private Flow.Subscription subscription;
private final Consumer<T> consumer;
public Destination(Consumer<T> consumer) {
this.consumer = consumer;
}
@Override
public void onSubscribe(Flow.Subscription subscription) {
this.subscription = subscription;
subscription.request(1);
}
@Override
public void onNext(T item) {
subscription.request(1);
consumer.accept(item);
}
@Override
public void onError(Throwable throwable) {
throwable.printStackTrace();
}
@Override
public void onComplete() {
System.out.println("Done");
}
}
数据的控制
你可能已经注意到了Flow.Subscriber接口并没有和Flow.Publisher直接联系。取而代之地出现了一个中间代理Flow.Subscription。Flow.Subscription管理、控制着Flow.Publisher和Flow.Subscriber之间的连接以及数据的传递。
也就是说在Java的反应式编程模型里数据的传递控制从数据和数据的变化里分离了出来。这样的分离对于降低功能之间的耦合意义重大。
public static interface Subscription {
public void request(long n);
public void cancel();
}
在Flow.Subscription的接口设计里我们定义了两个方法。一个方法表示订阅者希望接收的数据数量也就是Subscription.request这个方法。另一个方法表示订阅者希望取消订阅也就是Subscription.cancel这个方法。
数据的传递
除了最初的来源和最终的结局,数据表现还有一个过程,就是数据的传递。数据的传递这个过程,既包括接收输入数据,也包括发送输出数据。在数据传递这个环节,数据的内容可能会发生变化,数据的数量也可能会发生变化(比如,过滤掉一部分的数据,或者修改输入的数据,甚至替换掉输入的数据)。
在Java的反应式编程模型的设计里这样的过程是由Flow.Processor表示的。Flow.Processor是一个扩展了Flow.Publisher和Flow.Subscriber的接口。所以Flow.Processor有两个数据类型泛型T表述输入数据的类型泛型R表述输出数据的类型。
public static interface Processor<T,R> extends Subscriber<T>, Publisher<R> {
}
下面的代码就是一个表示数据传递的例子。在这段代码里我们使用泛型来表示输入数据和输出数据的类型然后我们使用了一个Function函数来表示该怎么处理接收到的数据并且输出处理的结果。这样的安排让这个例子具有了普遍的意义。稍作修改你就可以把它用到实际场景中去了。
package co.ivi.jus.flow.reactive;
import java.util.concurrent.Flow;
import java.util.concurrent.SubmissionPublisher;
import java.util.function.Function;
public class Transform<T, R> extends SubmissionPublisher<R>
implements Flow.Processor<T, R> {
private Function<T, R> transform;
private Flow.Subscription subscription;
public Transform(Function<T, R> transform) {
super();
this.transform = transform;
}
@Override
public void onSubscribe(Flow.Subscription subscription) {
this.subscription = subscription;
subscription.request(1);
}
@Override
public void onNext(T item) {
submit(transform.apply(item));
subscription.request(1);
}
@Override
public void onError(Throwable throwable) {
closeExceptionally(throwable);
}
@Override
public void onComplete() {
close();
}
}
过程的串联
既然数据的表述方式分为输入和输出两种基本的形式,而且还提供了由此衍生出来的三种过程,我们就能够把数据的处理过程,很方便地串联起来了。
下面的代码,就是我们试图把最初的来源、数据的传递和最终的结局这三个过程,串联成一个更大的过程的例子。当然,你也可以试着串联进更多的数据处理过程。
private static void transform(byte[] message,
Function<byte[], byte[]> transformFunction) {
SubmissionPublisher<byte[]> publisher =
new SubmissionPublisher<>();
// Create the transform processor
Transform<byte[], byte[]> messageDigest =
new Transform<>(transformFunction);
// Create subscriber for the processor
Destination<byte[]> subscriber = new Destination<>(
values -> System.out.println(
"Got it: " + Utilities.toHexString(values)));
// Chain processor and subscriber
publisher.subscribe(messageDigest);
messageDigest.subscribe(subscriber);
publisher.submit(message);
// Close the submission publisher.
publisher.close();
}
串联的形式接藕了不同环节的关联而且每个环节的代码也可以换个场景复用。支持过程的串联是反应式编程模型强大的最大动力之一。像Scala这样的编程语言甚至把过程串联提升到了编程语言的层面来支持。这样做毫无疑问大幅度地提高了编码的效率和代码的美观程度。
简洁的重构
介绍完Java的反应式编程模型设计我们要回头看看我们在阅读案例里提出的问题了。反应式编程是怎么解决顺序执行的模式带来的延迟后果的呢 反应式编程,怎么解决回调函数带来的堆挤问题呢?
我们还是先看一眼使用反应式编程模型的代码然后再来讨论这些问题吧。下面的代码就是我们对阅读案例里Digest用法的改进。
Returned<Digest> rt = Digest.of("SHA-256");
switch (rt) {
case Returned.ReturnValue rv -> {
// Get the returned value
if (rv.returnValue() instanceof Digest d) {
// Call the transform method for the message digest.
transform("Hello, World!".getBytes(), d::digest);
// Wait for completion
Thread.sleep(20000);
} else { // unlikely
System.out.println("Implementation error: SHA-256");
}
}
case Returned.ErrorCode ec ->
System.out.println("Unsupported algorithm: SHA-256");
}
在这个例子里我们没有发现类似于回调函数一样的堆挤现象。这里面起重要作用的就是我们上面提到的过程的串联这种形式。Java的反应式编程模型里的过程串联和数据控制的设计以及数据输入和输出的分离降低了代码的耦合不再需要嵌套的调用了。
在这个例子里我们还看到了Digest.digest方法的直接使用。为了能够使用反应式编程模型我们没有必要去修改Digest代码。只要把Digest原来的设计和实现恰当地放到反应式编程模型里来就能够实现异步非阻塞的设想了。这一点无疑具有极大的吸引力。如果不是被逼无奈谁会去颠覆已有的代码呢
那到底反应式编程模型是怎么支持异步非阻塞的呢?其实,和回调函数一样,反应式编程既能够支持同步阻塞的模式,也能够支持异步非阻塞的模式。如果这些接口实现是异步非阻塞模式的,这些实现的调用,也就是异步非阻塞的。当然,反应式编程模型的主要使用场景,目前还是异步非阻塞模式。
比如我们例子中的SubmissionPublisher就是一个异步非阻塞模式的实现。在上面的代码里如果没有调用Thread.sleep我们可能还看不到Digest的处理结果主线程就退出了。这就是一个非阻塞的实现表现出来的现象。
缺陷与对策
到目前为止,反应式编程模型看起来还很完美。可是,反应式编程模型的缺陷也很要命。其中最要命的缺陷,就是错误很难排查,这是异步编程的通病。而反应式编程模型的解耦设计,加剧了错误排查的难度,这会严重影响开发的效率,降低代码的可维护性。
目前来看解决反应式编程模型的缺陷或者说是异步编程的缺陷的方向似乎又要回到了指令式编程模型这条老路上来了。这里最值得提及的就是协程Fiber这个概念目前Java的协程模式还没有发布但是我可以带你先了解一下
我们再来看看阅读案例里提到的这段代码。为了方便你阅读,我把它拷贝粘贴到这里来了。
try {
Digest messageDigest = Digest.of("SHA-256");
byte[] digestValue =
messageDigest.digest("Hello, world!".getBytes());
} catch (NoSuchAlgorithmException ex) {
System.out.println("Unsupported algorithm: SHA-256");
}
在Java的指令式编程模型里这段代码要在一个线程里执行。我们首先调用Digest.of方法得到一个Digest实例然后调用这个实例的方法Digest.digest获得一个返回值。在每个方法返回之前线程都会处于等待状态。而线程的等待是造成资源浪费的最大因素。
而协程的处理方式,消除了线程的等待。如果调用阻塞,就会把资源切换出去,执行其他的操作。这就节省了大量的计算资源,使得系统在阻塞的模式下,支持大规模的并发。如果指令式编程模型能够通过协程的方式支持大规模的并发,也许它是一个颠覆现有高并发架构的新技术。
目前Java的协程模式还没有发布。它能够给反应式编程模型带来什么样的影响能够给我们实现大规模并发系统带来多大的便利这些问题的答案我们还需要等待一段时间。
总结
到这里我来做个小结。前面我们讨论了指令式编程模型和声明式编程模型回调函数以及回调地狱以及Java反应式编程模型的基本组件。
限于篇幅我们不能展开讨论Java反应式编程模型的各种潜力和变化比如“反应式宣言”“背压”这样的热门词汇。我建议你继续深入地了解反应式编程的这些要求比如反应式宣言和反应式系统以及成熟的明星产品比如Akka和Spring 5+)。
由于Java的协程模式还没有发布我对反应式编程的未来还没有清晰的判断。也欢迎你在留言区里留言、讨论反应式编程的现在和未来。
另外,我还拎出了几个今天讨论过的技术要点,这些都可能在你们面试中出现哦。通过这一次学习,你应该能够:
了解指令式编程模型和声明式编程模型这两个术语;
面试问题:你知道声明式编程模型吗,它是怎么工作的?
了解Java反应式编程模型的基本组件以及它们的组合方式
面试问题你知道怎么使用Java反应式编程模型吗
知道回调函数的形式,以及回调地狱这个说法。
面试问题:你知道回调函数有什么问题吗?
反应式编程是目前主流的支持高并发的技术架构思路。学会反应式编程意味着你有能力处理高并发应用这样的需求。能够编写高并发的代码现在很重要以后更重要。学会使用Java反应式编程模型这样一个高度抽象的接口毫无疑问能够提升你的技术深度。
思考题
今天的思考题我们来试着使用一下Java反应式编程模型。在讨论反应式编程的时候计算a = b + c是一个常用的范例。在这个计算里b和c随着时间的推移会发生变化。而每一次的变化都会影响a的计算结果。
现在我们假设a表示的数据是一件事情结束的时候是星期几b表示的数据是一件事情开始的时候是星期几c表示处理完这件事情需要多少天。你会怎么使用Java反应式编程模型来处理这个问题
欢迎你在留言区留言、讨论,分享你的阅读体验以及你的设计和代码。我们下节课见!
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在反应式编程专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在flow/review/xuelei的目录下面。

View File

@ -0,0 +1,155 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 矢量运算Java的机器学习要来了吗
你好我是范学雷。今天我们讨论Java的矢量运算。
Java的矢量运算我写这篇文章的时候还在孵化期还没有发布预览版。我们之所以选取了这样一个还处于孵化期的技术主要是因为这个技术代表了Java语言发展的一个重要方向在未来一定会有着重要的影响。早一点了解这样的技术除了扩展视野之外还能够帮助我们制定未来几年要学习或者要使用的技术路线。
我们从阅读案例开始看一看没有矢量运算的时候Java是怎么支持科学计算的然后我们再看看矢量运算能够带来什么样的变化。
阅读案例
我想,你对线性方程(或者说一次方程)一定不陌生。一般情况下,我们可以把线性方程表述成下面的形式。
其中\(a\_{0}\)\(a\_{1}\)\(a\_{n-1}\)表示的是常数,\(x\_{0}\)\(x\_{1}\)\(x\_{n-1}\)表示的是变量,而\(y\)就表示\(a\_{i}\)和\(x\_{i}\)的组合结果。\(n\)表示未知变量的数目,通常,我们也把它称为方程的维度。
如果给定方程式右边的常数和变量,我们就能计算出方程式左边的\(y\)数值了。那么,该怎么用代码表示这个方程式呢?我们可以把\(a\_{0}\)\(a\_{1}\)\(a\_{n-1}\)表示的常数放到一个数组里,把\(x\_{0}\)\(x\_{1}\)\(x\_{n-1}\)表示的变量放到另外一个数组里。下面的代码里变量a和x就可以用来表示一个有四个维度的一次方程组。
static final float[] a = new float[] {0.6F, 0.7F, 0.8F, 0.9F};
static final float[] x = new float[] {1.0F, 2.0F, 3.0F, 4.0F};
能用Java的变量来表示一次方程我们也就能够计算线性方程的结果了。下面的代码就是一个实现的办法。
private static Returned<Float> sumInScalar(float[] a, float[] x) {
if (a == null || x == null || a.length != x.length) {
return new Returned.ErrorCode(-1);
}
float[] y = new float[a.length];
for (int i = 0; i < a.length; i++) {
y[i] = a[i] * x[i];
}
float r = 0F;
for (int i = 0; i < y.length; i++) {
r += y[i];
}
return new Returned.ReturnValue<>(r);
}
在上面的代码里,我们先计算\(a\_{i}\)和\(x\_{i}\)的乘积,然后再计算乘积结果的总和。其中的乘法运算,就是我们常说的标量运算。为了方便讨论,我把乘法运算的代码单独拿出来,粘贴在下面。
float[] y = new float[a.length];
for (int i = 0; i < a.length; i++) {
y[i] = a[i] * x[i];
}
如果我们仔细观察线性方程就会发现对于每一个纬度\(a\_{i}\)\(x\_{i}\)是互不影响的 当然它们的乘积也是互不影响的既然每个维度的计算都互不影响那么我们能不能并行计算呢
矢量运算
Java的矢量运算就是使用单个指令并行处理多个数据的一个尝试单指令多数据Single Instruction Multiple Data)。
在现代的微处理器CPU一个控制器可以控制多个平行的处理单元在现代的图形处理器GPU中呢更是拥有强大的并发处理能力和可编程流水线这些处理器层面的技术为软件层面的单指令多数据处理提供了物理支持Java矢量运算的设计和实现也是希望能够借助现代处理器的这种能力提高运算的性能
为了使用单指令多数据的指令我们需要把不同数据的运算独立出来让并行运算成为可能而数学里的矢量运算恰好就能满足这样的要求
如果使用矢量我们可以把线性方程表述成下面的形式使用向量的数量积形式
其中\(a\)\(x\)\(y^{'}\)是三个n维的矢量
-
好了现在我们可以看看Java是怎么表达矢量的了下面代码里的变量a和前面阅读案例里a是一样的它以数组的形式表示变量va就是变量a的矢量表达形式fromArray这个方法可以把一个数组变量转换成一个矢量的变量
static final float[] a = new float[] {0.6F, 0.7F, 0.8F, 0.9F};
static final FloatVector va =
FloatVector.fromArray(FloatVector.SPECIES_128, a, 0);
static final float[] x = new float[] {1.0F, 2.0F, 3.0F, 4.0F};
static final FloatVector vx =
FloatVector.fromArray(FloatVector.SPECIES_128, x, 0);
有了表示矢量的办法我们就可以试着使用矢量运算的办法来计算线性方程的结果了下面的代码就是一个简化了的实现
private static Returned<Float> sumInVector(FloatVector va, FloatVector vx) {
if (va == null || vx == null || va.length() != vx.length()) {
return new Returned.ErrorCode(-1);
}
// FloatVector vy = va.mul(vx);
float[] y = va.mul(vx).toArray();
float r = 0F;
for (int i = 0; i < y.length; i++) {
r += y[i];
}
return new Returned.ReturnValue<>(r);
}
这个运算的关键部分是其中的矢量运算,也就是下面这行代码。
FloatVector vy = va.mul(vx);
和上面的标量运算的办法相比,矢量运算的代码精简了很多。这是矢量运算的第一个优点。但它的优点还不止于此。
飙升的性能
我们前面提到Java矢量运算的设计主要是为了性能。 那么,性能的提升能有多大呢? 我自己做了一个性能测试。虽然这个特性还处于孵化期,但是它的性能测试结果还是很令人振奋的。 就上面这个简单的、四维的矢量来说和我们在阅读案例里使用的标量运算相比矢量运算的性能提高了足足有10倍。
Benchmark Mode Cnt Score Error Units
VectorBench.scalarComputation thrpt 15 180635563.597 ± 30893274.582 ops/s
VectorBench.vectorComputation thrpt 15 1839556188.443 ± 153876900.442 ops/s
对于一个还处于孵化阶段的实现来说,这么大的性能提升是有点超出预料的。
在密码学和机器学习领域通常需要处理几百甚至几千维的数据。一般情况下为了能够使用处理器的计算优势我们经常需要特殊的设计以及内嵌于JVM的本地代码来获得硬件加速。这样的限制让普通代码的计算很难获得硬件加速的好处。
希望成熟后的Java矢量运算能在这些领域有出色的表现让普通的代码获得处理器的单指令多数据的强大运算能力。毕竟只有单指令多数据的优势能够被普通的Java应用程序广泛使用Java才能在机器学习、科学计算这些领域获得计算优势。
如果从机器学习在未来的重要性来说Java在科学计算领域的拓展来得也许正是时候。
总结
到这里我来做个小结。前面我们讨论了Java的矢量运算这个尚处于孵化阶段的新特性对Java的矢量运算这个新特性有了一个初始的印象。
如果Java矢量运算成熟起来许多领域都可以从这个新特性中受益包括但是不限于机器学习、线性代数、密码学、金融和JDK 本身的代码。
这一次学习的主要目的,就是让你对矢量运算有一个基本的印象。这样的话,如果你的代码里有大量的数值计算,也许可以考虑在将来使用矢量运算获得硬件的并行计算能力,大幅度提高代码的性能。
由于矢量运算尚处于孵化阶段目前我们还不需要学习它的API知道Java有这个发展方向并且能够思考你的代码潜在的改进空间就足够了。知道了这个方向等Java矢量运算正式发布的时候你就可以尽早地改进你的代码从而获得领先的优势了。
如果面试中聊到了数值计算的性能,你应该知道有矢量运算这么一个潜在的方向,以及“单指令多数据”这么一个术语。
思考题
其实今天的这个新特性是练习使用JShell快速学习新技术的一个好机会。使用阅读案例里提供的数据你能够使用JShell快速地表示出下面的这个矢量吗
\[y{'} = ax\]需要注意的是要想使用孵化期的JDK技术需要在JShell里导入孵化期的JDK模块就像下面的例子这样。
$ jshell --add-modules jdk.incubator.vector -v
| Welcome to JShell -- Version 17
| For an introduction type: /help intro
jshell> import jdk.incubator.vector.*;
欢迎你在留言区留言、讨论,分享你的阅读体验以及你的设计和代码。我们下节课见!
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在矢量运算专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在vector/review/xuelei的目录下面。

View File

@ -0,0 +1,111 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 外部内存接口:零拷贝的障碍还有多少?
你好我是范学雷。今天我们来讨论Java的外部内存接口。
Java的外部内存接口这个新特性现在还在孵化期还没有发布预览版。我之所以选取了这样一个还处于孵化期的技术主要是因为这个技术太重要了。我们需要提前认识它然后在这项技术出来的时候尽早地使用它。
我们从阅读案例开始看一看Java在没有外部内存接口的时候是怎么支持本地内存的然后我们再看看外部内存接口能够给我们的代码带来什么样的变化。
阅读案例
在我们讨论代码性能的时候内存的使用效率是一个绕不开的话题。像TensorFlow、 Ignite、 Flink以及Netty这样的类库往往对性能有着偏执的追求。为了避免Java垃圾收集器不可预测的行为以及额外的性能开销这些产品一般倾向于使用JVM之外的内存来存储和管理数据。这样的数据就是我们常说的堆外数据off-heap data
使用堆外存储最常用的办法就是使用ByteBuffer这个类来分配直接存储空间direct buffer。JVM虚拟机会尽最大努力直接在直接存储空间上执行IO操作避免数据在本地和JVM之间的拷贝。
由于频繁的内存拷贝是性能的主要障碍之一。所以为了极致的性能,应用程序通常也会尽量避免内存的拷贝。理想的状况下,一份数据只需要一份内存空间,这就是我们常说的零拷贝。
下面的这段代码就是用ByteBuffer这个类来分配直接存储空间的方法。
public static ByteBuffer allocateDirect(int capacity);
ByteBuffer所在的Java包是java.nio。从这个Java包的命名我们就能感受到ByteBuffer设计的初衷是用于非阻塞编程的。的确ByteBuffer是异步编程和非阻塞编程的核心类几乎所有的Java异步模式或者非阻塞模式的代码都要直接或者间接地使用ByteBuffer来管理数据。
非阻塞和异步编程模式的出现起始于对于阻塞式文件描述符File descriptor包括网络套接字读取性能的不满。而诞生于2002年的ByteBuffer最初的设想也主要是用来解决当时文件描述符的读写性能的。所以它的设计也不能跳脱出当时的客观需求。
如果站在现在的角度重新审视这个类的设计,我们会发现它主要有两个缺陷。
第一个缺陷是没有资源释放的接口。一旦一个ByteBuffer实例化它占用了内存的释放就会完全依赖JVM的垃圾回收机制。使用直接存储空间的应用往往需要把所有潜在的性能都挤压出来。依赖于垃圾回收机制的资源回收方式并不能满足像Netty这样的类库的理想需求。
第二个缺陷是存储空间尺寸的限制。ByteBuffer的存储空间的大小是使用Java的整数来表示的。所以它的存储空间最多只有2G。这是一个无意带来的缺陷。在网络编程的环境下这并不是一个问题。可是超过2G的文件一定会越来越多2G以上的文件映射到ByteBuffer上的时候就会出现文件过大的问题。而像Memcahed这样的分布式内存也会让应用程序需要控制的内存超越2G的界限。
这两个缺陷,也是横隔在“零拷贝”这个理想路上的两个主要设计障碍。
对于第一个缺陷我们还可以在ByteBuffer的基础上修改并且保持这个类的优雅。但是第二个缺陷由于ByteBuffer类里到处都在使用的整数类型我们就很难找到办法既保持这个类的优雅又能够突破存储空间的尺寸限制了。
一个合理的改进,就是重新建造一个轮子。这个新的轮子,就是外部内存接口。
外部内存接口
外部内存接口沿袭了ByteBuffer的设计思路但是使用了全新的接口布局。我们先来看看使用外部内存接口的代码看起来是什么样子的。下面的这段代码要分配一段外部内存并且存放4个字母A。
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
MemorySegment segment = MemorySegment.allocateNative(4, scope);
for (int i = 0; i < 4; i++) {
MemoryAccess.setByteAtOffset(segment, i, (byte)'A');
}
}
现在我们通过这个小例子来看看外部内存接口的布局
第一行的ResourceScope这个类定义了内存资源的生命周期管理机制这是一个实现了AutoCloseable的接口我们就可以使用try-with-resource这样的语句及时地释放掉它管理的内存了这样的设计就解决了ByteBuffer的第一个缺陷
第二行的MemorySegment这个类定义和模拟了一段连续的内存区域第三行的MemoryAccess这个类定义了可以对MemorySegment执行读写操作在ByteBuffer的设计里内存的表达和操作是在ByteBuffer这一个类里完成的在外部内存接口的设计里把对象表达和对象的操作拆分成了两个类这两类的寻址数据类型使用的是长整形long这样长整形的寻址类型就解决了ByteBuffer的第二个缺陷
超预期的演进
无论是在我们生活的现实世界里还是在软件的虚拟世界里只要我们超前迈出了第一步后续的发展往往会超出我们的预料外部内存接口的出现虽然还处在孵化期也带来了远远超出预期的精彩局面
在计算机的世界里代码主要和两类计算资源打交道一类是负责控制和运算的处理器一类是临时存放运算数据的存储器表现到编程语言的层面就是函数和内存函数之间的数据传递也是用过内存的形式进行的
现在外部内存接口为我们提供了一个统一的内存操作接口对应地外部函数之间的数据传递问题也就有了思路既然能够解决函数之间的数据传递问题那么不同语言间的函数调用能不能变得更简单更有效率呢
这个问题就是我们下一次要讨论的内容如果说设计外部内存接口的最初动力是为了解决ByteBuffer的两个缺陷那研发的持续推进则给外部内存接口赋予了更大的责任和能量
总结
到这里我来做个小结前面我们讨论了Java的外部内存接口这个尚处于孵化阶段的新特性对外部内存接口这个新特性有了一个初始的印象
设计外部内存接口的最初动力是为了解决ByteBuffer的两个缺陷也就是ByteBuffer占用的资源不能及时释放以及它的寻址空间太小这两个问题但是外部内存接口的更大使命是和外部函数接口联系在一起的我们下一次再讨论这个更大的使命
如果外部内存接口正式发布出来现在使用ByteBuffer的类库比如Flink和Netty甚至JDK本身应该可以考虑切换到外部内存接口来获取性能的提升
这一次学习的主要目的就是让你对外部内存接口有一个基本的印象由于外部内存接口尚处于孵化阶段现在我们还不需要学习它的API只要知道Java有这个发展方向能够了解ByteBuffer的这两个缺陷能够给你的程序带来的影响就足够了
如果面试中聊到了ByteBuffer你应该可以聊一聊零拷贝以及ByteBuffer的这两个缺陷还有未来的Java要做的改进
思考题
其实今天的这个新特性也是练习使用JShell快速学习新技术的一个好机会我们在前面的讨论里分析了下面的这段代码为了方便你阅读我把这段代码重新拷贝到下面了
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
MemorySegment segment = MemorySegment.allocateNative(4, scope);
for (int i = 0; i < 4; i++) {
MemoryAccess.setByteAtOffset(segment, i, (byte)'A');
}
}
虽然我们提到了使用try-with-resource这样的语句可以及时地释放掉它管理的内存但是我们并没有验证这一说法你能不能使用JShell快速地验证它的资源释放效果呢
需要注意的是要想使用孵化期的JDK技术需要在JShell里导入孵化期的JDK模块就像下面的例子这样
$ jshell --add-modules jdk.incubator.foreign -v
| Welcome to JShell -- Version 17
| For an introduction type: /help intro
jshell> import jdk.incubator.foreign.*;
欢迎你在留言区留言、讨论,分享你的阅读体验以及你的设计和代码。我们下节课见!
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在外部内存接口专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在memory/review/xuelei的目录下面。

View File

@ -0,0 +1,191 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 外部函数接口能不能取代Java本地接口
你好我是范学雷。今天我们一起来讨论Java的外部函数接口。
Java的外部函数接口这个新特性我写这篇文章的时候还在孵化期还没有发布预览版。由于孵化期的特性还不成熟不同的版本之间的差异可能会很大。我建议你使用最新版本现在来说就是JDK 17来体验孵化期的特性。
Java的外部函数接口这个特性有可能会是Java自诞生以来最重要的两个特性之一它和外部内存接口一起会极大地丰富Java语言的生态环境。提前了解一下这样的新特性有助于我们思考现在的技术手段和未来的技术规划。
我们从阅读案例开始来看一看Java的外部函数接口为什么可能会带来这么大的影响以及它能够给我们的代码带来什么样的变化吧。
阅读案例
我们知道像Java或者Go这样的通用编程语言都需要和其他的编程语言或者环境打交道比如操作系统或者C语言。Java是通过Java本地接口Java Native Interface, JNI来支持这样的做法的。 本地接口,拓展了一门编程语言的生存空间和适用范围。有了本地接口,就不用所有的事情都在这门编程语言内部实现了。
比如下面的代码就是一个使用Java本地接口实现的“Hello, world!“的小例子。其中的sayHello这个方法使用了修饰符native这表明它是一个本地的方法。
public class HelloWorld {
static {
System.loadLibrary("helloWorld");
}
public static void main(String[] args) {
new HelloWorld().sayHello();
}
private native void sayHello();
}
这个本地方法可以使用C语言来实现。然后呢我们需要生成这个本地方法对应的C语言的头文件。
$ javac -h . HelloWorld.java
有了这个自动生成的头文件我们就知道了C语言里这个方法的定义。然后我们就能够使用C语言来实现这个方法了。
#include "jni.h"
#include "HelloWorld.h"
#include <stdio.h>
JNIEXPORT void JNICALL Java_HelloWorld_sayHello(JNIEnv *env, jobject jObj) {
printf("Hello World!\n");
}
下一步我们要把C语言的实现编译、链接放到它的动态库里。这时候就要使用C语言的编译器了。
$ gcc -I$(JAVA_HOME)/include -I$(JAVA_HOME)/include/darwin \
-dynamiclib HelloWorld.c -o libhelloWorld.dylib
完成了这一步我们就可以运行这个Hello World的本地实现了。
java -cp . -Djava.library.path=. HelloWorld
你看一个简单的“Hello, world!“的本地接口实现,需要经历下面这些步骤:
编写Java语言的代码HelloWorld.java
编译Java语言的代码HelloWorld.class
生成C语言的头文件HelloWorld.h
编写C语言的代码HelloWorld.c;
编译、链接C语言的实现libhelloWorld.dylib
运行Java命令获得结果。
其实在Java本地接口的诸多问题中像代码实现的过程不简洁这样的问题还属于可以克服的小问题。
Java本地接口面临的比较大的问题有两个。
一个是C语言编译、链接带来的问题因为Java本地接口实现的动态库是平台相关的所以就没有了Java语言“一次编译到处运行”的跨平台优势另一个问题是因为逃脱了JVM的语言安全机制JNI本质上是不安全的。
Java的外部函数接口是Java语言的设计者试图解决这些问题的一个探索。
外部函数接口
Java的外部函数接口是什么样子的呢下面的代码就是一个使用Java的外部函数接口实现的“Hello, world!“的小例子。我们来一起看看Java的外部函数接口是怎么工作的。
import java.lang.invoke.MethodType;
import jdk.incubator.foreign.*;
public class HelloWorld {
public static void main(String[] args) throws Throwable {
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
CLinker cLinker = CLinker.getInstance();
MemorySegment helloWorld =
CLinker.toCString("Hello, world!\n", scope);
MethodHandle cPrintf = cLinker.downcallHandle(
CLinker.systemLookup().lookup("printf").get(),
MethodType.methodType(int.class, MemoryAddress.class),
FunctionDescriptor.of(CLinker.C_INT, CLinker.C_POINTER));
cPrintf.invoke(helloWorld.address());
}
}
}
在这段代码里try-with-resource语句里使用的ResourceScope这个类定义了内存资源的生命周期管理机制。
第8行代码里的CLinker实现了C语言的应用程序二进制接口Application Binary InterfaceABI的调用规则。这个接口的对象可以用来链接C语言实现的外部函数。
接下来也就是第12行代码我们使用CLinker的函数标志符Symbol查询功能查找C语言定义的函数printf。在C语言里printf这个函数的定义就像下面的代码描述的样子。
int printf(const char *restrict format, ...);
C语言里printf函数的返回值是整型数据接收的输入参数是一个可变长参数。如果我们要使用C语言打印“Hello, world!”,这个函数调用的形式就像下面的代码。
printf("Hello World!\n");
接下来的两行代码第13行和第14行代码就是要把这个调用形式表达成Java语言外部函数接口的形式。这里使用了JDK 7引入的MethodType以及尚处于孵化期的FunctionDescriptor。MethodType定义了后面的Java代码必须遵守的调用规则。而FunctionDescriptor则描述了外部函数必须符合的规范。
好了到这里我们找到了C语言定义的函数printf规定了Java调用代码要遵守的规则也有了外部函数的规范。调用一个外部函数需要的信息就都齐全了。接下来我们生成一个Java语言的方法句柄MethodHandle第11行并且按照前面定义的Java调用规则使用这个方法句柄第15行这样我们就能够访问C语言的printf函数了。
对比阅读案例里使用JNI实现的代码使用外部函数接口的代码不再需要编写C代码。当然也不再需要编译、链接生成C的动态库了。所以由动态库带来的平台相关的问题也就不存在了。
提升的安全性
更大的惊喜,来自于外部函数接口在安全性方面的提升。
从根本上说任何Java代码和本地代码之间的交互都会损害Java平台的完整性。链接到预编译的C函数本质上是不可靠的。Java运行时无法保证C函数的签名和Java代码的期望是匹配的。其中一些可能会导致JVM崩溃的错误这在Java运行时无法阻止Java代码也没有办法捕获。
而使用JNI代码的本地代码则尤其危险。这样的代码甚至可以访问JDK的内部更改不可变数据的数值。允许本地代码绕过Java代码的安全机制破坏了Java的安全性赖以存在的边界和假设。所以说JNI本质上是不安全的。
遗憾的是这种破坏Java为台完整系的风险对于应用程序开发人员和最终用户来说几乎是无法察觉的。因为随着系统的不断丰富99%的代码来自于夹在JDK和应用程序之间的第三方、第四方、甚至第五方的类库里。
相比之下大部分外部函数接口的设计则是安全的。一般来说使用外部函数接口的代码不会导致JVM的崩溃。也有一部分外部函数接口是不安全的但是这种不安全性并没有到达JNI那样的严重性。可以说使用外部函数接口的代码是Java代码因此也受到Java安全机制的约束。
JNI退出的信号
当出现了一个更简单、更安全的方案后原有的方案很难再有竞争力。外部函数接口正式发布后JNI的退出可能也就要提上议程了。
在外部函数接口的提案里,我们可以看到这样的描述:
JNI 机制是如此危险以至于我们希望库在安全和不安全操作中都更喜欢纯Java的外部函数接口以便我们可以在默认情况下及时全面禁用JNI。这与使Java平台开箱即用、缺省安全的更广泛的Java路线图是一致的。
安全问题往往具有一票否决权所以JNI的退出很可能比我们预期的还要快
总结
到这里我来做个小结。前面我们讨论了Java的外部函数接口这个尚处于孵化阶段的新特性对外部函数接口这个新特性有了一个初始的印象。外部内存接口和外部函数接口联系在一起为我们提供了一个崭新的不同语言之间的协作方案。
如果外部函数接口正式发布出来我们可能需要考虑切换到外部函数接口逐步退出传统的、基于JNI的解决方案。
这一次学习的主要目的就是让你对外部函数接口有一个基本的印象。由于外部函数接口尚处于孵化阶段所以我们不需要学习它的API。只要知道Java有这个发展方向目前来说就足够了。
如果面试中聊到了Java的未来你不妨聊一聊外部内存接口和外部函数接口它们要解决的问题以及能带来的变化。
思考题
其实今天的这个新特性也是练习使用JShell快速学习新技术的一个好机会。我们在前面的讨论里分析了下面这段代码。为了方便你阅读我把这段代码重新拷贝到下面了。
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
CLinker cLinker = CLinker.getInstance();
MemorySegment helloWorld =
CLinker.toCString("Hello, world!\n", scope);
MethodHandle cPrintf = cLinker.downcallHandle(
CLinker.systemLookup().lookup("printf").get(),
MethodType.methodType(int.class, MemoryAddress.class),
FunctionDescriptor.of(CLinker.C_INT, CLinker.C_POINTER));
cPrintf.invoke(helloWorld.address());
}
你能不能找一个你熟悉的C语言标准函数试着修改上面的代码快速地验证一下外部函数接口能不能按照你的预期工作
需要注意的是要想使用孵化期的JDK技术需要在JShell里导入孵化期的JDK模块。就像下面的例子这样。
$ jshell --add-modules jdk.incubator.foreign -v
| Welcome to JShell -- Version 17
| For an introduction type: /help intro
jshell> import jdk.incubator.foreign.*;
欢迎你在留言区留言、讨论,分享你的阅读体验以及你的设计和代码。我们下节课见!
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在外部函数接口专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在memory/review/xuelei的目录下面。

View File

@ -0,0 +1,252 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 禁止空指针,该怎么避免崩溃的空指针?
你好我是范学雷。今天我们讨论Java的空指针。
我们都知道空指针它的发明者开玩笑似的称它是一个价值10亿美元的错误同时呢他还称C语言的get方法是一个价值100亿美元的错误。空指针真的错得这么厉害吗get方法又有什么问题我们能够在Java语言里改进或者消除空指针吗
我们从阅读案例开始,来看一看该怎么理解这些问题,以及怎么降低这些问题的影响。
阅读案例
通常地一个人的姓名包括两个部分Last Name和名First Name。在有些文化里也会使用中间名Middle Name。所以我们通常可以使用姓、名、中间名这三个要素来标识一个人的姓名。用代码的形式表示出来就是下面的代码这样。
public record FullName(String firstName,
String middleName, String lastName) {
// blank
}
中间名并不是必需的因为有的人使用中间名有的人不使用。现在我们假设需要判断一个人的中间名是不是黛安Diane。这个判断的逻辑可能就像下面的代码这样。
private static boolean hasMiddleName(
FullName fullName, String middleName) {
return fullName.middleName().equals(middleName);
}
这个判断的逻辑是没有问题的。但是它的代码实现就存在没有校验空指针的错误。如果一个人不使用中间名那么FullName.middleName这个方法的返回值就是一个空指针。 如果一个对象是空指针那么调用它的任何方法都会抛出空指针异常NullPointerException
我们可以试着使用JDK 11的JShell看一看空指针异常的异常信息是什么样子的。
$ jshell -v
| Welcome to JShell -- Version 11.0.13
| For an introduction type: /help intro
jshell> String a = null;
a ==> null
| created variable a : String
jshell> a.equals("b");
| Exception java.lang.NullPointerException
| at (#2:1)
然后我们再试试看JDK 17里空指针异常信息是什么样的。
$ jshell -v
| Welcome to JShell -- Version 17
| For an introduction type: /help intro
jshell> String a = null;
a ==> null
| created variable a : String
jshell> a.equals("b");
| Exception java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because "REPL.$JShell$11.a" is null
| at (#2:1)
对比一下我们可以看到JDK 17的异常信息里包含了调用者REPL.\(JShell\)11.a和被调用者String.equals(Object)的信息而JDK 11里调用者的信息需要从调用堆栈里寻找而且没有被调用者的信息。
这是空指针异常的一个小的改进。它简化了问题排查的流程,提高了问题排查的效率。
好的我们再回到主题看一看空指针异常到底有什么危害。按照我们前面讨论过的中间名的逻辑有的人不使用中间名。那么如果一个对象的中间名是空值也就意味着他没有中间名。可是在上面的实现代码里如果中间名是空值hasMiddleName抛出了空指针异常而不是通过返回值来表示这个对象没有中间名。
这当然是一个错误。我们需要检查返回值有没有可能是空指针然后才能继续使用返回值。这是一个C语言或者Java语言软件工程师需要掌握的基本常识。当然这也是一个我们编码的时候需要遵守的纪律。
检查返回值有没有可能是空指针需要额外的代码,而且不符合我们的思维习惯。下面的代码,我添加了空指针的检查,这就让它看起来就有点臃肿。这就是精准控制的代价。
private static boolean hasMiddleNameImplA(
FullName fullName, String middleName) {
if (fullName.middleName() != null) {
return fullName.middleName().equals(middleName);
}
return middleName == null;
}
空指针的问题其实是我们人类行为方式的一个反映。无论是纪律还是常识如果没有配以强制性的手段都没有办法获得100%的执行。如果不能100%地执行,一个危害就会从一个小小的局部,蔓延到一个庞大的系统。
今天的应用程序我们几乎可以肯定地说都是由很多小的部件组合起来的。其中99%以上的部件,我们都不了解,甚至都不知道它们的存在。任何一个小的部件出了问题,都会蔓延开来,酝酿出一个更大的问题。
在C语言和Java语言里存在着大量的空指针。不管我们怎么努力也不管我们经验多么丰富总是会时不时地就忘了检查空指针。而忘了检查这样的小错误很可能就蔓延成严重的事故。所以空指针发明者称它是一个价值10亿美元的错误。
那有什么办法能够降低空指针的负面影响呢?
避免空指针
降低空指针的负面影响的最重要的办法,就是不要产生空指针。没有空指针的代码,代码更简洁,风险也更小。
比如说我们可以使用空字符串来替代字符串的空指针。如果用这种思路我们就可以把阅读案例里FullName档案类修改成不使用空指针的版本了。
public record FullName(String firstName,
String middleName, String lastName) {
public FullName(String firstName,
String middleName, String lastName) {
this.firstName = firstName == null ? "" : firstName;
this.middleName = middleName == null ? "" : middleName;
this.lastName = lastName == null ? "" : lastName;
}
}
这样,我们就不用检查空指针了;因此,也就不用担心空指针带来的问题了。所以,代码的使用也就变得简洁了起来。
private static boolean hasMiddleName(
FullName fullName, String middleName) {
return fullName.middleName().equals(middleName);
}
在很多场景下我们都可以使用空值来替代空指针比如空的字符串、空的集合。在API设计的时候如果碰到了使用空指针的规范或者代码我们要停下来想一想有没有替代空指针的办法如果能够避免空指针我们的代码会更健壮更容易维护。
强制性检查
不过,不是在所有的情况下我们都能够避免空指针的。如果空指针不能避免,降低空指针的负面影响的另外一个办法,就是在使用空指针的时候,执行强制性的检查。所谓强制性的检查,对于编程语言来说,指的是我们通常能够依赖的是编译器的能力,以及新的接口设计思路。
不尽人意的Optional
在JDK 8正式发布而后在JDK 9和11持续改进的Optional工具类是JDK试图降低空指针风险的一个尝试。
设计Optional的目的是希望开发者能够先调用它的Optional.isPresent方法然后再调用Optional.get方法获得目标对象。 按照设计者的预期这个Optional类的使用应该像下面的代码这样。
private static boolean hasMiddleName(
FullName fullName, String middleName) {
if (fullName.middleName().isPresent()) {
return fullName.middleName().get().equals(middleName);
}
return middleName == null;
}
当然我们还需要修改FullName的API就像下面的代码这样。
public final class FullName {
// snipped
public Optional<String> middleName() {
return Optional.ofNullable(middleName);
}
// snipped
}
遗憾的是我们也可以不按照预期的方式使用它比如下面的代码我们就没有调用Optional.isPresent方法而是直接使用了Optional.get方法。这不在设计者的预期之内但是这是合法的代码。
private static boolean hasMiddleName(FullName fullName, String middleName) {
return fullName.middleName().get().equals(middleName);
}
如果Optional指代的对象不存在或者是个空指针Optional.get方法就会抛出NoSuchElementException异常。和空指针异常一样这个异常也是运行时异常。虽然这个异常的名字不再叫做空指针异常但它实质上依然是空指针异常。当然这个异常也具有和空指针异常相同的问题。
如果你对比一下使用空指针的代码和使用Optional类的代码就会发现这两个类型的代码不论是正确的使用方法还是错误的使用方法它们在形式上是相似的。Optional带来了不必要的复杂性然而它并没有简化开发者的工作也没有解决掉空指针的问题。
被寄予厚望的Optional的设计不能尽如人意。
新特性带来的新希望
那么,对于空指针的检查,我们能不能借助编译器,让它变得更强硬一点呢?下面的例子,就是我们使用新特性来解决空指针问题的一个新的探索。
我们希望返回值的检查是强制性的。如果不检查,就没有办法得到返回值指代的真实对象。实现的思路,就是使用封闭类和模式匹配。
首先呢我们定义一个指代返回值的封闭类Returned。为什么使用封闭类呢因为封闭类的子类可查可数。可查可数也就意味着我们可以有简单的模式匹配。
public sealed interface Returned<T> {
Returned.Undefined UNDEFINED = new Undefined();
record ReturnValue<T>(T returnValue) implements Returned {
}
record Undefined() implements Returned {
}
}
然后呢我们就可以使用Returned来表示返回值了。
public final class FullName {
// snipped
public Returned<String> middleName() {
if (middleName == null) {
return Returned.UNDEFINED;
}
return new Returned.ReturnValue<>(middleName);
}
// snipped
}
最后我们来看看Returned是怎么使用的。
private static boolean hasMiddleName(FullName fullName, String middleName) {
return switch (fullName.middleName()) {
case Returned.Undefined undefined -> false;
case Returned.ReturnValue rv -> {
String returnedMiddleName = (String)rv.returnValue();
yield returnedMiddleName.equals(middleName);
}
};
}
这种使用了封闭类和模式匹配的设计,极大地压缩了开发者的自由度,强制要求开发者的代码必须执行空指针的检查,只有这样才能编写下一步的代码。 这种看似放弃了灵活性的设计,恰恰把开发者从低级易犯的错误中解救了出来。不论是对写代码的开发者,还是对读代码的开发者来说,这都是一件好事。
好事情的背后往往都意味着一些妥协。比如说吧使用空指针的代码我们可以轻松地使用档案类使用Optional和Returned的代码我们就要重新回到传统的类上面来了。
无论档案类、封闭类还是模式匹配对于Java来说都还是新鲜的技术。要想让这些技术之间熟练配合还需要一些这样或者那样的磨练包括不停地改进组合效应的新研究等。
总结
好,到这里,我来做个小结。前面,我们讨论了空指针带来的问题,以及降低空指针负面影响的一些办法。
总体来说,在我们的代码里,尽量不要产生空指针。没有空指针,也就没有了空指针的烦恼。
如果避免不了空指针,我们就要看看能不能执行强制性的检查。比如使用封闭类和模式匹配的组合形式,让编译器和接口设计帮助我们实施这种强制性。
如果不能实施强制性的检查,我们就要遵守空指针的编码纪律。也就是说,对于可能是空指针的变量,先检查后使用。
如果面试中聊到了空指针的问题,你可以聊一聊空指针的危害,以及我们这一次学习到的解决办法。
思考题
今天,我们使用封闭类和模式匹配来降低空指针危害的例子,有点像我们前面提到过的替代异常处理的错误码方案。其实,一个带有返回值的方法,通常要考虑三种情况:正常情况、异常情况以及空指针。我们可以把空指针解读为正常情况,也可以解读为异常情况。
如果要在返回值这个封闭类里考虑进这三种情况,我们该怎么设计这个封闭类以及它的许可类呢?这是我们这一次的思考题。
为了方便你阅读我把我们这次讨论用到的Returned的实现代码拷贝到了下面。你可以在这个基础上修改。
public sealed interface Returned<T> {
Returned.Undefined UNDEFINED = new Undefined();
record ReturnValue<T>(T returnValue) implements Returned {
}
record Undefined() implements Returned {
}
}
欢迎你在留言区留言、讨论,分享你的阅读体验以及你的设计和代码。我们下节课见!
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在空指针专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在nullp/review/xuelei的目录下面。

View File

@ -0,0 +1,126 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 现代密码:你用的加密算法过时了吗?
你好我是范学雷。今天我想和你聊聊JDK里的密码学算法相关的问题。
Java语言安全的基础主要有两块内容。一块是Java语言的安全设计比如字节码的校验内存保护机制等等另外一块是Java平台的保护机制比如签名的类库资源的认证授权等等。而Java平台的保护机制是建立在密码学的基础之上的。
这一次的讨论,我们从故事开始,来看看现在我们应该采用的密码学的技术,以及应该抛弃的密码学技术。
阅读案例
1976年是现代密码学的奠基之年。这一年DiffieHellman密钥交换协议公开发表。这是由 Ralph Merkle 构思并以 Whitfield Diffie 和 Martin Hellman 命名的第一个公钥协议。这是最早为公众所知的,提出公钥和私钥思想的著作。从这一年开始,在非安全通道上建立安全通信的想法,有了理论上的依据;现代互联网的安全,也终于有了稳固的基石。
DiffieHellman密钥交换协议的论文为密码学家展示了一个全新的大陆。有了这个方向的指引接下来很快就有了更多的脚步踏出了新的道路。1977年受DiffieHellman密钥交换协议的启发Ron Rivest、Adi Shamir和Leonard Adleman公开发表了基于公开密钥的电子签名算法也就是RSA算法。从此以后要在非安全通道上识别身份、建立信任的想法也有了理论上的依据。
至此,加上传统的加密技术,解决信息安全基本问题的三大技术就已经集结完成了。随着互联网的发展,这些技术大放异彩,成为了互联网基础设施最终的环节之一。
后面的事情也就顺理成章了。1982年Ron Rivest、Adi Shamir 和 Leonard Adleman 成立了RSA公司公司主要提供基于RSA算法的产品和服务。1991年RSA公司推出了RSA大会以及RSA算法的分解挑战。 2006年RSA公司被EMC收购收购价达到21亿美元。2007年RSA算法分解挑战终止。而RSA大会则发展成了信息安全领域最富盛名的的大会。
为什么RSA分解挑战终止了呢 按照官方的声明,因为:“现在业界对常见对称密钥和公钥算法的密码分析强度有了更深入的了解,这些挑战不再活跃。”
那分解挑战的成果是什么样子的呢? 我想你也一定感兴趣。
1991年3月18日RSA公司推出RSA算法的分解挑战。不到两个星期也就是1991年的4月1日330位的RSA密钥被破解。随后更高强度的RSA算法被破解。其中768位的RSA密钥在2009年被破解这是一个RSA命运的分水岭。从此以后小于或者等于1024位的RSA密钥都被认为是不安全的密钥。现在的RSA算法应该是用至少2048位的密钥。
对RSA算法的破解研究并不仅仅局限于因式分解这样的纯计算游戏。比如说早在1998年就有密码学家发现了对RSA算法进行旁路攻击的办法。现在如果是用测时攻击timing attacks对于1024位的密钥破解传统的RSA实现也就是分分钟钟的事情。
虽然我们可以通过复杂的RSA实现来化解这样的攻击。但是复杂的实现意味着性能的损失以及维护的困难。到这里我们已经可以依稀地听到RSA算法要告别历史舞台的声音了。
其实,任何一个密码学的算法,都有它的生命周期。从看似完美的问世,到实际破落的境地,也就是数十年的时间。
看向未来
但是,我们的隐私数据却需要上百年,甚至是永远的保护。有生命周期的算法,似乎满足不了这样的要求。密码学要始终看向未来。如果站在十年后看现在,我们怎么能保证万无一失呢?
十多年后,量子计算机大概率就能够问世了。而量子计算机的计算能力是非常恐怖的。现在我们常见的非对称密码算法所能提供的计算强度,在量子计算时代,也许就像是小孩子的玩具一样脆弱。所以,密码学家和各种组织都在紧锣密鼓地遴选“后量子时代”的非对称密码算法。
显然我们不能等到“后量子时代”的非对称密码算法问世以后再来保护我们的隐私数据。现在我们就需要这样的保护。而这其中最重要的方案就是使用前向保密Forward Secrecy的安全协议。前向保密也就意味着即使未来我们反复使用的密钥被破解我们的数据依然能够得到保护。如果你想了解更多的关于前向保密的细节请参考我在另外一个专栏里的讨论《量子时代你准备好了吗》。
在Java的设计和实现里前向保密是JDK缺省的选择。这是JDK 8之后JDK做的一个重要的安全策略调整。这个调整涉及到的大都是JDK实现的小细节比如缺省JDK升级到TLS 1.3这样的变动。
JDK的安全是Java语言的头等大事。所以JDK的安全改进一般情况下都会向后移植直到我们没有能力移植为止。前向保密的策略也已经向后移植进入到JDK 8了。
关注变化
既然密码学的算法有生命周期,我们就需要了解这个生命周期,及时地停止使用危险的、过期的算法。那么,哪些密码算法如今已经过期或者存在安全隐患?我们又能从哪里找到这方面最新的信息呢?
JDK 8之后Java安全策略的另外一个重要的调整就是公开发布JDK的密码路线图。在这个路线图里JDK会声明哪些密钥算法是危险的哪些是过期的以及JDK根据密码学的进展作出的变动。
如果你的产品或者代码涉及到了密码相关的内容,你就要密切关注这个路线图的更新,及时地调整产品里涉及到密码算法了。
另外,密钥算法的废弃,总是会带来这样或者那样的兼容性问题。当安全性和兼容性相遇的时候,我们应该毫不犹豫地选择选择安全性,及时解决掉兼容性问题。安全性问题,时间上千万不要拖。软件系统的漏洞,一般情况下,攻击者知道的比你还要早。我们拖拉的每一秒钟,都是留给攻击者的时间窗口。
应该抛弃的算法
下面我罗列了一些曾经流行的JDK支持的但是我们不应该使用的密码学算法或者协议。继续使用这些算法会给你的系统带来难以预料的灾难。而且使用的系统也很容易成为黑客攻击的目标。
MD2
MD5
SHA-1
DES
3DES
RC4
SSL 3.0
TLS 1.0
TLS 1.1
密钥小于1024位的RSA算法
密钥小于1024位的DSA算法
密钥小于1024位的Diffie-Hellman算法
密钥小于256位的EC算法
应该退役的算法
下面我罗列了一些曾经流行的JDK支持的我们可以使用但是应该尽快替换掉的算法。这些算法目前来看还是安全的但是已经处于危险的边缘了。如果你的系统计划运行五年以上这些算法的安全性值得担忧。
密钥大于1024位小于2048位的RSA算法。
密钥大于1024位小于2048位的DSA算法。
密钥大于1024位小于2048位的Diffie-Hellman算法。
RSA签名算法
基于RSA的密钥交换算法
128位的AES算法
推荐使用的算法
下面我罗列了一些现在流行的JDK支持的我们推荐使用的密码学算法。这些算法目前看还没有发现值得重视的安全问题是可以信任的算法。如果一个系统计划运行五年以上你应该使用这些算法。
256位的AES算法
SHA-256、SHA-512单向散列函数
RSASSA-PSS签名算法
X25519/X448密钥交换算法
EdDSA签名算法
我们前面提到过,安全改进一般都会向后移植,但是也有我们没有能力移植的例子。上面提到的推荐使用的算法中, JDK 8不支持X25519/X488密钥交换算法也不支持EdDSA签名算法。一个最重要的原因就是这些算法需要使用新的公开接口。
一般情况下小版本的JDK升级不能变更公开接口。这就让JDK 8有了安全上的短板。目前看这个短板还不足以构成安全威胁。但是停留在JDK 8意味着我们放弃了更好的密码算法包括安全性的提高和性能的提升。
我上面列举的算法大部分开发者应该接触不到。因为它们是Java语言和Java平台的一部分是计算机基础设施的一部分。我们天天使用它们但是没有多少人意识到它们的存在。如果你需要使用密码比如签名Java包或者使用数字证书请留意这些数字内容使用的密码算法尽量使用推荐的算法千万不要使用已经抛弃的算法。
总结
好,到这里,我来做个小结。通过今天的讨论,我们知道,任何一个密码学的算法,都有它的生命周期。所以,我们要能够管理它们的生命周期。反映到代码里,就是要使用前向保密的安全协议以及当前推荐的算法;及时替换掉过期的算法。
对于JDK的开发者来说我们要关注JDK的密码路线图了解JDK根据密码学的进展作出的变动及时解决自己代码里的兼容性问题。
如果面试中聊到了密码学算法的问题,你可以聊聊前向保密,以及我们推荐的密码学算法。
思考题
今天的思考题是一个拓展阅读。在上面推荐的算法里除了AES算法之外其他的三个算法如果不是关注密码学进展的话你可能都没有听说过。密码学进展很快十多年前的主流算法在今天几乎都要进入退休的年龄了。我们也要随时更新对密码学基本现状的认识。
如果有时间你可以去搜索一下RSASSA-PSS签名算法X25519密钥交换算法以及EdDSA签名算法的相关介绍。不需要了解技术细节知道大致是怎么回事就行。
欢迎你在留言区留言、讨论,分享你的阅读体验以及你的拓展阅读内容。我们下节课见!

View File

@ -0,0 +1,203 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 改进的废弃,怎么避免使用废弃的特性?
你好我是范学雷。今天我们讨论Java公开接口的废弃。
像所有的事物一样,公开接口也有生命周期。要废弃那些被广泛使用的、或者还有人使用的公开接口,是一个非常痛苦的过程。该怎么废弃一个公开接口,该怎么减少废弃接口对我们的影响呢?这是这一次我们要讨论的话题。
我们先来看看阅读案例。
阅读案例
在 JDK 中,一个公开的接口,可能会因为多种多样的原因被废弃。比如说,这个接口的设计是危险的,或者有了更新的、更好的替代接口。不管是什么原因,废弃接口的使用者们都需要尽快迁移代码,转换到替代方案上来。
在JDK中公开接口的废弃需要使用两种不同的机制也就是“Deprecated” 注解annotation和“Deprecated”文档标记JavaDoc tag
Deprecated的注解会编译到类文件里并且可以在运行时查验。这就允许像javac这样的工具检测和标记已废弃接口的使用情况了。
Deprecated文档标记用于描述废弃接口的文档中。除了标记接口的废弃状态之外一般情况下我们还要描述废弃的原因和替代的方案。
下面的这段代码就是使用Java注解和文档标记来废弃一个公开接口的例子。
public sealed abstract class Digest {
/**
* -- snipped
*
* @deprecated This method is not performance friendly. Use
* {@link #digest(byte[], byte[]) instead.
*/
@Deprecated
public abstract byte[] digest(byte[] message);
// snipped
public void digest(byte[] message, byte[] digestValue) {
// snipped
}
}
如果一段程序使用了废弃接口编译的时候就会提出警告。但是有很多编译环境的配置把编译警告看作是编译错误。为了解决这样的问题JDK还提供了“消除使用废弃接口的编译警告”的选项。也就是SuppressWarnings注解。
@SuppressWarnings("deprecation")
public static void main(String[] args) {
try {
Digest.of("SHA-256")
.digest("Hello, world!".getBytes());
} catch (NoSuchAlgorithmException ex) {
// ignore
}
}
公开接口的废弃机制是在JDK 1.5的时候发布的。 这种机制像一座设计者和使用者之间的沟通桥梁,减轻了双方定义或者使用废弃接口的痛苦。
遗憾的是直到现在公开接口的废弃依然是一个复杂、痛苦的过程。一个公开的接口从声明废弃到彻底删除是一个漫长的过程。在JDK中还存在着大量废弃了20多年都无法删除的公开接口。
为什么删除废弃的公开接口这么困难呢?如果从废弃机制本身的角度来思考,下面几个问题延迟了废弃接口使用者的迁移意愿和努力。
第一个问题也是最重要的问题就是SuppressWarnings注解的使用。SuppressWarnings注解的本意是消除编译警告保持向后的编译兼容性。可是一旦编译警告消除SuppressWarnings注解也就抵消了Deprecated注解的功效。代码的维护者一旦使用了SuppressWarnings注解就很难再有更合适的工具让自己知道还在使用的废弃接口有哪些了。不知道当然就不会有行动。
第二个问题就是废弃接口的使用者并不担心使用废弃接口。虽然我们都知道不应该使用废弃的接口但是因为一些人认为没有紧急迁移的必要性也不急着制定代码迁移的时间表所以倾向于先使用SuppressWarnings注解把编译警告消除了以后再说迁移的事情。然后就掉入了第一个问题的陷阱。
第三个问题,就是废弃接口的使用者并不知道接口废弃了多久。在接口使用者的眼里,废弃了十年,和废弃了一年的接口,没有什么区别。可是,在接口维护者的眼里,废弃了十年的接口,应该可以放心地删除了。然而,使用者并没有感知到这样的区别。没有感知,当然也就没有急迫感了。
一旦一个接口被声明为废弃,它的问题也就再难进入接口维护者的任务列表里了。所以,这个接口的实现可能充满了风险和错误。于是局面就变成了,接口维护者难以删除废弃的接口,接口的使用者又不能获得必要的提示,这种情况实在有点尴尬。
改进的废弃
上面这些问题在JDK 9的接口废弃机制里有了重大的改进。
第一个改进是添加了一个新的工具jdeprscan。有了这个工具就可以扫描编译好的Java类或者包看看有没有使用废弃的接口了。即使代码使用了SuppressWarnings注解jdeprscan的结果也不受影响。这个工具解决了我们在阅读案例里提到的第一个问题。
另外如果我们使用第三方的类库或者已经编译好的类库发现对废弃接口的依赖关系很重要。如果将来废弃接口被删除使用废弃接口的类库将不能正常运行。而jdeprscan允许我们在使用一个类库之前进行废弃依赖关系检查提前做好风险的评估。
第二个改进是给Deprecated注解增加了一个“forRemoval”的属性。如果这个属性设置为“true”那就表示这个废弃接口的删除已经提上日程了。两到三个版本之后这个废弃的接口就会被删除。这样的改进强调了代码迁移的紧急性它给了使用者一个明确的提示。这个改进解决了我们在阅读案例里提到的第二个问题。
第三个改进是给Deprecated注解增加了一个“since”的属性。这个属性会说明这个接口是在哪一个版本废弃的。如果我们发现一个接口已经废弃了三年以上就要考虑尽最大努力进行代码迁移了。这样的改进给了废弃接口的使用者一个时间上的概念也方便开发者安排代码迁移的时间表。这个改进解决了我们在阅读案例里提到的第三个问题。
下面的这段代码,就是一个使用了这两种属性的例子。
public sealed abstract class Digest {
/**
* -- snipped
*
* @deprecated This method is not performance friendly. Use
* {@link #digest(byte[], byte[]) instead.
*/
@Deprecated(since = "1.4", forRemoval = true)
public abstract byte[] digest(byte[] message);
// snipped
public void digest(byte[] message, byte[] digestValue) {
// snipped
}
}
如果在Deprecated注解里新加入“forRemoval”属性并且设置为“true”那么以前的SuppressWarnings就会失去效果。要想消除掉编译警告我们需要使用新的选项。就像下面的例子这样。
@SuppressWarnings("removal")
public static void main(String[] args) {
try {
Digest.of("SHA-256")
.digest("Hello, world!".getBytes());
} catch (NoSuchAlgorithmException ex) {
// ignore
}
}
当一个废弃接口的删除提上日程的时候添加“forRemoval”属性让我们又有一次机会在代码编译的时候重新审视还在使用的废弃接口了。
废弃三部曲
有了JDK 9的废弃改进我们就能够看到接口废弃的一般过程了。
第一步,废弃一个接口,标明废弃的版本号,并且描述替代方案;
第二步添加“forRemoval”属性把删除的计划提上日程
第三步,删除废弃的接口。
对于接口的使用者,我们应该尽量在第一步就做好代码的迁移;如果我们不能在第一步完成迁移,当看到第二步的信号时,我们也要把代码迁移的工作提高优先级,以免影响后续的版本升级。
对于接口的维护者,我们需要尽量按照这个过程退役一个接口,给接口的使用者充分的时间和信息,让他们能够完成代码的迁移。
总结
好,到这里,我来做个小结。刚才,我们讲了接口废弃的现实问题,以及接口废弃的三部曲。总体来说,我们要管理好废弃的接口。接口的废弃要遵守程序,有序推进;代码的迁移要做好计划,尽快完成。
另外我们要使用好jdeprscan这个新的工具。在使用一个类库之前要有意识地进行废弃依赖关系检查提前做好代码风险的评估。
如果面试中聊到了接口废弃的问题你可以聊一聊接口废弃的三部曲以及每一步应该使用的Java注解形式。
思考题
今天的思考题,我们来练习一下接口废弃的过程。前面,我们练习过表示形状的封闭类。假设要废弃表示正方形的许可类,我们该怎么做呢?代码该怎么改动呢?
为了方便你阅读,我把表示形状的封闭类的代码拷贝到了下面。请再一次阅读“废弃三部曲”这一小节,然后试着修改下面的代码。
package co.ivi.jus.retire.review.xuelei;
public abstract sealed class Shape {
public final String id;
public Shape(String id) {
this.id = id;
}
public abstract double area();
public static final class Circle extends Shape {
public final double radius;
public Circle(String id, double radius) {
super(id);
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
public static final class Square extends Shape {
public final double side;
public Square(String id, double side) {
super(id);
this.side = side;
}
@Override
public double area() {
return side * side;
}
}
// Here is your code for Rectangle.
// Here is the test for circle.
public static boolean isCircle(Shape shape) {
// Here goes your update.
return (shape instanceof Circle);
}
// Here is the code to run your test.
public static void main(String[] args) {
// Here is your code.
}
}
欢迎你在留言区留言、讨论,分享你的阅读体验以及你的设计和代码。我们下节课见!
本文使用的完整的代码可以从GitHub下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在接口废弃专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在retire/review/xuelei的目录下面。

View File

@ -0,0 +1,145 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 模块系统为什么Java需要模块化
你好我是范学雷。今天我们一起来讨论Java平台模块系统Java Platform Module SystemJPMS
Java平台模块系统是在JDK 9正式发布的。为了沟通起来方便我们有时候就直接简称为Java模块。Java平台模块系统可以说是自Java诞生以来最重要的新软件工程技术了。模块化可以帮助各级开发人员在构建、维护和演进软件系统时提高工作效率。软件系统规模越大我们越需要这样的工程技术。
实现Java平台的模块化是具有挑战性的Java模块系统Module System的最初设想可以追溯到2005年的Java 7但是最后的发布是在2017年的 JDK 9。它的设计和实现花了十多年时间我们可以想象它的复杂性。
令人满意的是Java平台模块系统最终呈现的结果是简单、直观的。我们并不需要太长的时间就能快速掌握这一技术。
我们先来了解Java模块化背后的动力和它能够带来的工程效率提升。除非特别说明这一次的讨论说的都是JDK 8及以前的版本的事情。下一次我们再来讨论JDK 9之后我们应该怎么使用Java平台模块系统。
缺失的访问控制
我们都清楚并且能够熟练地使用Java的访问修饰符。这样的访问修饰符一共有三个public、protected以及private。如果什么修饰符都不使用那就是缺省的访问修饰符这也算是一种访问控制。所以Java语言一共定义了四种类型的访问控制。
private访问修饰符修饰的对象在同一个类里是可见的缺省访问修饰符修饰的对象在同一个Java包里是可见的pubic访问修饰符修饰的对象在不同的Java包里也是可见的。有了private、public和缺省的访问修饰符看起来我们已经能解决大部分的问题了。不过这里还欠缺了重要的一环。
当我们设计对象的扩展能力的时候我们可能期待扩展的子类处于不同的Java包里。但是其中的一些数据信息子类需要访问但又因为它们是接口实现的细节不应该对外公开。所以这时候就需要一个能够穿越Java包传递到子类的访问修饰符。这个访问修饰符就是protected。protected访问修饰符在Java包之间打通了一条继承类之间的私密通道。
我们可以用下面这张表来总结Java语言访问修饰符的控制区域。
从这个列表看Java语言访问修饰符似乎覆盖了所有的可能性这好像是一个完备的定义。遗憾的是Java语言访问修饰符遗漏了很重要的一种情况那就是Java包之间的关系。Java包之间的关系并不是要么全开放要么全封闭这么简单。
类似于继承类之间的私密通道Java包之间也有这种类似私密通道的需求。比如说我们在JDK的标准类库里可以看到像java.net这样的放置公开接口的包也可以看到像sun.net这样的放置实现代码的包。
公开接口当然需要定义能够广泛使用的类比如public修饰的Socket类。
package java.net;
public class Socket implements java.io.Closeable {
// snipped
}
让人遗憾的是放置公开接口实现代码的包里也需要定义public的类。这就让本来只应该由某个公开接口独立使用的代码变得所有人都可以使用了。
比如说用来实现公开接口Socket类的PlatformSocketImpl类就是一个使用public修饰的类。
package sun.net;
public interface PlatformSocketImpl {
// snipped
}
虽然PlatformSocketImpl是一个public修饰的类但是我们并不期望所有的开发者都能够使用它。这是一个用来支持公开接口Socket实现的类。除了实现公开接口Socket的代码之外它不应该被任何其他的代码和开发者调用。
然而PlatformSocketImpl是一个public修饰的类。这也就意味着任何代码和开发者都可以使用它。这显然是不符合设计者的预期的。
在JDK 8及以前的版本里一个对象在两个包之间的访问控制要么是全封闭的要么是全开放的。所以JDK 9之前的Java世界里它的设计者没有办法强制性地设定PlatformSocketImpl给出一个恰当的访问控制范围。
两个包之间没有一个定向的私密通道。换句话说JDK 9之前的Java语言没有描述和定义包之间的依赖关系也没有描述和定义基于包的依赖关系的访问控制规则。 这是一个缺失的访问控制。
这种缺失的关系,带来了严重的后果。
松散的使用合约
按照JDK的期望一个开发者应该只使用公开接口比如上面提到的Socket类而不能使用实现细节的内部接口比如上面提到的PlatformSocketImpl接口。无论是公开接口还是内部接口都可以使用public修饰符。那么该怎么判断一个接口是公开接口还是内部接口呢
解决的办法是依靠Java接口的使用规范这样的纪律性合约而不是依靠编译器强制性的检查。在JDK里以java或者javax命名开头的Java包是公开的接口其他的包是内部的接口。按照Java接口的使用规范一个开发者应该只使用公开的接口而不能使用内部的接口。不过这是一个完全依靠自觉的纪律性约束Java的编译器和解释器并不会禁止开发者使用内部接口。
内部接口的维护者可能会随时修改甚至删除内部的接口。使用内部接口的代码,它的兼容性是不受保护的。这是一个危险的依赖,应该被禁止。
遗憾的是这种纪律性合约是松散的它很难禁止开发者使用内部接口。我们能够看到大量的、没有遵守内部接口使用合约的应用程序。内部接口的不合规使用也成了Java版本升级的重要障碍之一。松散的纪律性合约既伤害了内部接口的设计者也伤害了它的使用者和最终用户。
我们前面提到过Java平台模块化的设计和实现花了十多年时间。而内部接口的不合规使用就是这项工作复杂性的主要来源。
我们认为,如果一件事情应该禁止,那么最好的办法就是让这件事情没有办法发生;而不是警告发生以后的的后果,或者依靠事后的惩罚。
那怎么能够更有效的限制内部接口的使用提高Java语言的可维护能力呢这是Java语言要解决的一个重要问题。
手工的依赖管理
Java语言没有描述和定义包之间的依赖关系这就直接增加了应用程序部署的复杂性。
公开接口的定义和实现并不一定是放置在同一个Java包。比如上面我们提到的Socket类和PlatformSocketImpl类就位于不同的Java包。
因为通常情况下我们使用Jar文件来分发和部署Java应用所以公开接口的定义和实现也不一定是放置在同一个Jar文件里。比如一个加密算法的实现它的公开接口一般是由JDK定义的但是它的实现可能是由一个第三方的类库完成的。
Java的编译器只需要知道公开接口的规范并不会去检查实现的代码也不会去链接实现的代码。可是Java在运行时不仅需要知道公开接口的字节码还需要知道实现的字节码。这就导致了编译和运行的脱节。一个能通过编译的代码运行时可能也会遇到找不到实现代码的错误。
而且Java的编译器不会在字节码里添加类和包的依赖关系。我们在编译期设置的依赖类库在运行期还需要重新设置。编译器环节和运行环节是由两个独立的Java命令执行的所以这种依赖关系也不会从编译期传导到运行期。
由于依赖关系的缺失Java运行的时候可能不会完全按照它的设计者的意图工作。这就给Java应用的部署带来很多问题。这一类的问题如此让人讨厌以至于它还有一个让人亲切不起来的外号Jar地狱。
为了解决依赖关系的缺失带来的种种问题业界现在也有了一些解决方案比如使用Maven和Gradle来管理项目。然而由于Java没有内在的依赖关系规范现有的解决方案也就只能依赖人工。依赖人工的手段也就意味着效率和质量上的潜在风险。
缓慢的实现加载
Java语言没有描述和定义包之间的依赖关系还直接影响了Java应用程序的启动效率。
我们都知道像Spring这样的框架它缓慢的启动一直都是一个大问题。影响Java应用启动速度的最主要原因就是类的加载。导致类加载缓慢的一个重要原因就是很难查找到要加载的类的实现代码。
假设我们设置的class path里有很多Jar文件对于一个给定名称的classJava怎么才能找到实现这个类的字节码呢由于Jar文件里没有描述类的依赖关系的内容Java的类加载器只能线性地搜索class path下的Jar文件直到发现了给定的类和方法。这种线性搜索方法当然不是高效的。class path下的Jar文件越多类加载得就越慢。
更糟糕的是这种线性搜索的方式还带来了不可预测的副作用。其中影子类Shadowing classes和版本冲突是最常见的两个副作用。
因为在不同的Jar文件里可能会存在两个有着相同命名但是行为不同的类。给定了类的名称哪一个Jar文件里的类会被首先加载呢这依赖于Jar文件被检索的顺序。在不同的运行空间class path的设置可能是不同的Jar文件被检索的顺序可能也是不同的所以实际加载的类就有可能是不同的最终的运行结果当然也是不同的。这样的问题可能会导致难以预料的结果而且非常难以排查。
如果一个类的不同版本的实现都出现在了 class path 里,也会出现类似的问题。
新的思路
我们可以看到这些问题的根源都来自于Java语言没有描述和定义包之间的依赖关系。那么我们能不能通过扩展访问修饰符来解决这些问题呢
答案可能没有这么简单。多个节点之间的依赖关系描述,需要使用的是数学逻辑图。而单个的修饰符,不足以表达复杂的图的逻辑。
另外Jar文件虽然是Java语言的一种必不可少的代码组织方式但是它却不是由我们编写的代码直接控制的。我们编写的代码可以控制Java包可以控制Java类但是管不了Jar文件的内容和形式。
所以要解决这些问题需要新的思路。而JDK 9发布的Java平台模块系统就是解决这些问题的一个尝试。
总结
到这里我来做个小结。前面我们讨论了JDK 8及其以前版本的访问控制缺陷以及由此带来的种种问题。
总体来说Java语言没有描述和定义包之间的依赖关系。这个缺失导致了无法有效地封闭实现的细节无法有效地管理应用的部署无法精准地控制类的检索和加载也影响了应用启动的效率。
那能不能在Java语言里添加进来这个缺失的关系呢该怎么做这是我们下一次要讨论的话题。
如果面试的时候讨论到了Java的访问修饰符你不妨聊一聊这个缺失的环节以及Jar地狱这样的问题。我相信这是一个有意思、有深度的话题。
思考题
在前面的讨论中我们提到了使用Maven或者Gradle来管理项目以此解决依赖关系的缺失。但是我们并没有展开讨论这些问题是怎么解决的。
如果熟悉Maven、Gradle或者类似的工具的话你能不能聊一聊这样的工具是怎么解决依赖关系缺失这样的问题的它们哪些地方做得比较好哪些地方还有待改进这样的讨论也许有助于我们深入了解我们这一次讨论到的问题。
欢迎你在留言区留言、讨论分享你的阅读体验以及你对Maven或者Gradle的了解。我们下节课见

View File

@ -0,0 +1,267 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 模块系统:怎么模块化你的应用程序?
你好我是范学雷。今天我们继续讨论Java平台模块系统Java Platform Module SystemJPMS
Java平台模块系统是在JDK 9正式发布的。在上一讲我们也说过这项重要的技术从萌芽到诞生花费了十多年的时间堪称Java出现以来最重要的新软件工程技术。
模块化可以帮助各级开发人员在构建、维护和演进软件系统时提高工作效率。更让人满意的是,它还非常简单、直观。我们不需要太长的学习时间就能快速掌握它。
这一节课我们就一起来看看应该怎么使用Java平台模块系统。
阅读案例
在前面的课程里我们多次使用了Digest这个案例来讨论问题。在这些案例里我们把实现的代码和接口定义的代码放在了同一个文件里。对于一次Java新特性的讨论来说这样做也许是合适的。我们可以使用简短的代码快速、直观地展示新特性。
public sealed abstract class Digest {
private static final class SHA256 extends Digest {
// snipped, implementation code.
}
private static final class SHA512 extends Digest {
// snipped, implementation code.
}
public static Returned<Digest> of(String algorithm) {
// snipped, implementation code.
}
public abstract byte[] digest(byte[] message);
}
但是如果放到生产环境这样的示例就不一定是一个好的导向了。因为Digest的算法可能有数十种。其中有老旧废弃的算法有即将退役的算法还有当前推荐的算法。把这些算法的实现都装到一个瓶子里似乎有点拥挤。
而且不同的算法可能有不同的许可证和专利限制实现的代码也可能是由不同的个人或者公司提供的。同一个算法可能还会有不同的实现有的实现需要硬件加速有的实现需要使用纯Java代码。这些情况下这些实现代码其实都是没有办法装到同一个瓶子里的。
所以,典型的做法就是分离接口和实现。
首先,我们来看一看接口的设计。下面的代码就是一个接口定义的例子。
package co.ivi.jus.crypto;
import java.util.ServiceLoader;
public interface Digest {
byte[] digest(byte[] message);
static Returned<Digest> of(String algorithm) {
ServiceLoader<DigestManager> serviceLoader =
ServiceLoader.load(DigestManager.class);
for (DigestManager cryptoManager : serviceLoader) {
Returned<Digest> rt = cryptoManager.create(algorithm);
switch (rt) {
case Returned.ReturnValue rv -> {
return rv;
}
case Returned.ErrorCode ec -> {
continue;
}
}
}
return Returned.UNDEFINED;
}
}
在这个例子里我们只定义了Digest的公开接口以及实现获取的方法使用ServiceLoader而没有实现具体算法的代码。同时呢我们希望Digest接口所在的包也是公开的这样应用程序可以方便地访问这个接口。
有了Digest的公开接口我们还需要定义连接公开接口和私有实现的桥梁也就是实现的获取和供给办法。下面这段代码定义的就是这个公开接口和私有实现之间的桥梁。Digest公开接口的实现代码需要访问这个桥梁接口所以它也是公开的接口。
package co.ivi.jus.crypto;
public interface DigestManager {
Returned<Digest> create(String algorithm);
}
然后我们来看看Digest接口实现的部分。有了Digest的公开接口和实现的桥梁接口Digest的实现代码就可以放置在另外一个Java包里了。比如下面的例子里我们把Sha256的实现放在了co.ivi.jus.impl这个包里。
package co.ivi.jus.impl;
import co.ivi.jus.crypto.Digest;
import co.ivi.jus.crypto.Returned;
final class Sha256 implements Digest {
static final Returned.ReturnValue<Digest> returnedSha256;
// snipped
private Sha256() {
// snipped
}
@Override
public byte[] digest(byte[] message) {
// snipped
}
}
因为这只是一个算法的实现代码我们不希望应用程序直接调用实现的子类也不希望应用程序直接访问这个Java包。所以Sha256这个子类使用了缺省的访问修饰符。
同时在这个Java包里我们也实现了Sha256的间接获取方式也就是实现了桥梁接口。
package co.ivi.jus.impl;
// snipped
public final class DigestManagerImpl implements DigestManager {
@Override
public Returned<Digest> create(String algorithm) {
return switch (algorithm) {
case "SHA-256" -> Sha256.returnedSha256;
case "SHA-512" -> Sha512.returnedSha512;
default -> Returned.UNDEFINED;
};
}
}
稍微有点遗憾的是由于ServiceLoader需要使用public修饰的桥梁接口所以我们不能使用除了public以外的访问修饰符。也就是说如果应用程序加载了这个Java包它就可以直接使用DigestManagerImpl类。这当然不是我们期望的使用办法。
我们并不希望应用程序直接使用DigestManagerImpl类然而JDK 8之前的Java世界里我们并没有简单有效的、强制性的封装办法。所以我们的解决办法通常是对外宣称“co.ivi.jus.impl”这个包是一个内部Java包请不要直接使用。这需要应用程序的开发者仔细地阅读文档分辨内部包和公开包。
但在Java 9之后的Java世界里我们就可以使用Java模块来限制应用程序使用DigestManagerImpl类了。
使用Java模块
下面我们来一起看看Java模块是怎么实现这样的限制的。
模块化公开接口
首先呢我们把公开接口的部分也就是co.ivi.jus.crypto这个Java包封装到一个Java模块里。我们给这个模块命名为jus.crypto。Java模块的定义使用的是module-info.java这个文件。这个文件要放在源代码的根目录下。下面的代码就是我们封装公开接口的部分的module-info.java文件。
module jus.crypto {
exports co.ivi.jus.crypto;
uses co.ivi.jus.crypto.DigestManager;
}
第一行代码里的“module”就是模块化定义的关键字。紧接着module的就是要定义的模块的名字。在这个例子里我们定义的是jus.crypto这个Java模块。
第二行代码里的“exports”, 说明了这个模块允许外部访问的API也就是这个模块的公开接口。“模块的公开接口”是一个Java模块带来的新概念。
没有Java模块的时候除了内部接口我们可以把public访问修饰符修饰的外部接口看作是公开的接口。这样的规则需要我们去人工分辨内部接口和外部接口。
但有了Java模块之后我们就知道使用了“exports”模块定义、并且使用了public访问修饰符修饰的接口就是公开接口。这样公开接口就有了清晰的定义我们就不用再去人工分辨内部接口和外部接口了。
而第四行代码里的“uses”呢则说明这个模块直接使用了DigestManager定义的服务接口。
你看这么简短的五行代码就把co.ivi.jus.crypto这个Java模块化了。它定义了公开接口以及要使用的服务接口。
模块化内部接口
然后呢我们要把内部接口的部分也就是co.ivi.jus.impl这个Java包也封装到一个Java模块里。下面的代码就是我们封装内部接口部分的module-info.java文件。
module jus.crypto.impl {
requires jus.crypto;
provides co.ivi.jus.crypto.DigestManager with co.ivi.jus.impl.DigestManagerImpl;
}
在这里第一行代码定义了jus.crypto.impl这个Java模块。
第二行代码里的“requires”说明这个模块需要使用jus.crypto这个模块。也就是说定义了这个模块的依赖关系。有了这个明确定义的依赖关系加载这个模块的时候Java运行时就不再需要地毯式地搜索依赖关系了。
第四行代码里的“provides”说明这个模块实现了DigestManager定义的服务接口。同样的有了这个明确的定义服务接口实现的搜索也不需要地毯式地排查了。
需要注意的是这个模块并没有使用“exports”定义模块的公开接口。这也就意味着虽然在co.ivi.jus.impl这个Java包里有使用public访问修饰符修饰的接口它们也不能被模块外部的应用程序访问。这样我们就不用担心应用程序直接访问DigestManagerImpl类了。取而代之的应用程序只能通过DigestManager这个公开的接口间接地访问这个实现类。这是我们想要的封装效果。
模块化应用程序
有了公开接口和实现我们再来看看该怎么模块化应用程序。下面的代码是我们使用了Digest公开接口的一个小应用程序。
package co.ivi.jus.use;
import co.ivi.jus.crypto.Digest;
import co.ivi.jus.crypto.Returned;
public class UseCase {
public static void main(String[] args) {
Returned<Digest> rt = Digest.of("SHA-256");
switch (rt) {
case Returned.ReturnValue rv -> {
Digest d = (Digest) rv.returnValue();
d.digest("Hello, world!".getBytes());
}
case Returned.ErrorCode ec ->
System.getLogger("co.ivi.jus.stack.union")
.log(System.Logger.Level.INFO,
"Failed to get instance of SHA-256");
}
}
}
下面的代码就是我们封装这个应用程序的module-info.java文件。
module jus.crypto.use {
requires jus.crypto;
}
在这里第一行代码定义了jus.crypto.use这个Java模块。
第二行代码里的“requires”, 说明这个模块需要使用jus.crypto这个模块。
需要注意的是这个模块并没有使用“exports”定义模块的公开接口。那么我们该怎么运行UseCase这个类的main方法呢其实和传统的Java代码相比模块的编译和运行有着自己的特色。
模块的编译和运行
在javac和java命令行里我们可以使用“module-path”指定java模块的搜索路径。在Jar命令行里我们可以使用“main-class”指定这个Jar文件的main函数所在的类。在Java命令里我们可以使用“module”指定main函数所在的模块。
有了这些选项的配合在上面的例子里我们就不需要把UseCase在模块里定义成公开类了。我们来看看这些选项是怎么使用的。
$ cd jus.crypto
$ javac --enable-preview --release 17 \
-d classes src/main/java/co/ivi/jus/crypto/* \
src/main/java/module-info.java
$ jar --create --file ../jars/jus.crypto.jar -C classes .
$ cd ../jus.crypto.impl
$ javac --enable-preview --release 17 \
--module-path ../jars -d classes \
src/main/java/co/ivi/jus/impl/* \
src/main/java/module-info.java
$ jar --create --file ../jars/jus.crypto.impl.jar -C classes .
$ cd ../jus.crypto.use
$ javac --enable-preview --release 17 \
--module-path ../jars -d classes \
src/main/java/co/ivi/jus/use/* \
src/main/java/module-info.java
$ jar --create --file ../jars/jus.crypto.use.jar \
--main-class co.ivi.jus.use.UseCase \
-C classes .
$ java --enable-preview --module-path ../jars --module jus.crypto.use
我在专栏里不会讲解这些选项的细节。具体的用法,我更希望你去找第一手的资料。下面的这个备忘单是我看到的一个比较好的总结。你可以打印下来备用,用熟了之后再丢掉。
总结
到这里我来做个小结。前面我们讨论了怎么使用Java模块封装我们的代码了解了module-info.java文件以及它的结构和关键字。
总体来看Java模块的使用是简单、直观的。Java模块的使用实现了更好的封装也定义了模块和Java包之间的依赖关系。有了依赖关系Java语言就能够实现更快的类检索和类加载了。这样的性能提升通过模块化就能实现还不需要更改代码。
如果面试的时候讨论到了Java平台模块系统你可以聊一聊Java模块封装的关键字以及这些关键字能够起到的作用。我相信这是一个有意思、有深度的话题。
思考题
在前面的讨论中我们把DigestManager定义成了公开接口。我们希望Digest的实现可以使用这个桥梁接口但是我们又不希望应用程序直接使用它。取而代之的应用程序应该使用Digest.of方法获得算法的实现。从这个意义上说我们前面的案例并没有做好封装。
那么有没有更好的办法把DigestManager也封装起来让应用程序无法调用呢这是我们这一次、也是最后一次的思考题。
欢迎你在留言区留言、讨论,分享你的阅读体验以及你的改进。
本文使用的完整代码可以从GitHub下载。

View File

@ -0,0 +1,62 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
用户故事 与新特性开发者对话
你好,我是 Jxin。目前是一名供应链业务开发坐标杭州。
其实,我算是范老师的“老学员”了。之前学习范老师的《代码精进之路》,我就有不小的收获。这次看到开设了新课程,便第一时间参与了学习。关于 9-17 的 Java 新特性,我也是第一次了解,算是再次跟着范老师长见识了。学习过程中我有一些心得,不成熟,分享给你,希望能给你一些参考。如果你也有一些见解或想法,欢迎讨论。
在联系课程之前,我想先说一说我对学习的理解。我认为,学习知识要经历三个阶段。第一个阶段,是学习最基础的确定性知识;第二个阶段,是发现确定性知识之上不确定的场景;第三个阶段是清楚在什么场景用什么知识,也就是能因地制宜,有自己的权衡心得。到了这个境界,就算是“苦心孤诣”,有点自己独到的东西了。
举个例子。 Java 里面我们遍历一个 List 可以用 For / Iterator / Stream 三种模式。这就是确定性的知识,也就是学习的第一阶段。但是,你问我遍历 List 该用哪种模式,我会说不一定。会说不一定就是知道要考虑不确定的场景,这就到了学习的第二阶段。为什么不一定?从性能角度看,如果 List 量级较小For 和 Iterator 其实性能较优,加之常规编码习惯应当选择 For ;但如果量级较大,因为 Stream 支持 parallelStream 转换,可以提供便捷的并行转换,所以会选择 Stream 。另外,从可靠性、可读性角度来看,适用的模式又有所不同了。知道什么场景用什么遍历方式,这就算到了学习的第三阶段。
从这三个阶段出发我又审视了一下自己学习《深入剖析Java新特性》的过程。
学习确定性知识
《深入剖析 Java 新特性》的行文有几分纪传体的味道。以每个特性为中心描述其发展史和应用,这样更贴合专栏零散时间学习的模式,有利于我们独立地学习每一种特性。行文提供的确定性知识主要是以下几点:
该特性在 JDK 版本的发展史,啥时候预览,啥时候改进,啥时候发布;
为什么要实现该特性;
该特性是什么样的;
该特性有哪些应用场景。
针对这四点,我个人的看法是:
首先,讨论新特性不加版本就是耍流氓。知道 JDK 版本的发展史,就相当于加了一条时间线上的认知。如此,你才能明确什么版本可以练手,什么版本还有哪些问题,什么版本可以正式使用。老师虽然提到了这一部分,可惜都一笔带过了。如果再展开讲讲,从什么时候开始考虑这个特性?为什么要加在这个版本?为了加这个特性放弃了什么?在每个版本选择哪些特性的主旨是什么?或许能让大家学习到语言开发者团队的一些决策原则,理解 JDK 发展的风格/风向。
关于学习新特性的原因。因为程序员在学习过程中会遇到很多概念,这个时候如果缺少一些背景和上下文,就很容易曲解作者的原意。所以,由语言开发者自己来跟我们讲讲,为什么要有这个特效,可以说是最准确、最官方的解答了,是一次难得的机会。
至于特性本身的学习,我觉得反而是也应该是最简单的。我们在做设计时,如果一个解决方案非常复杂,或许应该停下来想想问题本身是否就有问题。学习新特性也是如此,如果你觉得一个特性很反人类,不好用或者有更好的解法,希望你能在留言区留下自己的观点,让我们一起讨论。你的建议对于 Java 社区很重要,说不定无心插柳就可以对 Java 社区产生有建设性的影响。
最后,说到特性的应用场景。我觉得场景是难以穷举的,专栏中的场景远不及我们真实会碰到的场景多。所以,除了知道这些场景,我们还需要去思考场景背后能够适用该特性的关键因素,只有看到关键因素,才能应对更多的场景。当然,如果有碰到或想到什么好场景,也非常希望大家都能分享出来,让我们一起思考。
总之,兼听则明,偏听则暗。在学习的第一阶段,就是要尽可能多地去收集各维度的信息,并将这些信息联系起来。这样才有机会透过表面的知识发起更深度的思考。从而更准确地应用自己的知识,为应万变打好基础。
发现不确定场景
如何更全面地考虑不确定的场景?事有两面,如果只讨论某个特性在哪些场景适用,却不思考它在哪些场景有弊端,我觉得是不健全的。所以后续的课程,希望老师能把会出现弊端的场景也讲一讲,通过利害两方面场景的枚举,让大家更全面地思考特性背后适用的逻辑。
关于这一点,其实我们可以和范老师一起来构思,就当来找茬,想想什么场景不适用,存在哪些弊端。让专栏“动起来”,也让学习“深下去”。
因地制宜
学习的第三个阶段,就是我之前说的因地制宜了。如何做到因地制宜?因地制宜的本质绝非记住所有场景,因为人类的认知复杂度是有上限的。因地制宜的本质在于洞察场景背后的本质,有自己权衡的原则,能够抓住关键因素,基于原则,做出适合的决策。所以,希望范老师可以借着讲解新特性的机会,也谈谈自己的权衡思路和依据,让大家看看语言开发者心中的最佳编程实现是什么样的。
总结
如果要总结一下我的学习心得的话,我觉得,除了要扎实地学习确定性的知识,还要:
1.考虑不同场景的弊端,警防误用,丰富思考;
2.梳理知识点之间的关系,并借此发起更深度的思考。
另外,我还想表达一下我个人对于专栏的期待:希望范老师针对确定性的知识可以多加点维度,也多分享一些自己的权衡思路,带我们看看语言开发者的设计视角。

View File

@ -0,0 +1,104 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
用户故事 保持好奇心,积极拥抱变化
你好我是贾慧鑫目前是蔚来汽车的一名后端开发工程师平时主要使用Java语言。
9月份JDK 17正式版发布作为Java的又一长期版本最近我们的新项目也在尝试将JDK 11升级到17。恰巧就在这时候我看到了范学雷老师推出的《深入剖析 Java 新特性》,于是毫不犹疑地订阅了。
我认为作为一名研发工程师应该保持一颗好奇心积极拥抱技术的变化不断地去学习和迭代自己的知识。接下来我想跟大家分享一下工作以来我学习和使用Java的一些心得体会以及这门课程对我的帮助。
学习Java的心得
从Java 8开始Java语言正式引入了函数式编程。Stream和Lambda的结合极大地简化了我们在Java7中对集合类的操作。现在再看Java 7会有种回不去的感觉。因为相比之下用Java 7编写的代码又复杂又冗余。如果你还用过Java 11或者Java 17相信这种感觉会更强烈。
而且Java语言也在不断借鉴其他编程语言让代码变得更加简洁高效。下面我通过一个例子来展示一下不同 Java 版本的变化。
// 假如给定一个由数字1234构成的List要求把元素都值都扩大一倍
// java8
List<Integer> res = initList.stream().map(i -> i * 2).collect(Collectors.toList());
// java11
var res = initList.stream().map(i -> i * 2).collect(Collectors.toUnmodifiableList());
// java17
var res = initList.stream().map(i -> i * 2).toList();
首先从代码的样式来看Java 17的版本无疑是最简洁的。另外Java语言在函数式编程方面对于不可变数据的支持也更完善了。比方说Java 8的Collectors.toList()返回的只是一个可变的List在Java 11中就新增了Collectors.toUnmodifiableList()方法支持我们返回不可变的List而在Java 17中我们直接通过toList就能返回不可变的List了。
不可变数据可以让开发更加简单、可回溯、测试友好它减少了很多可能的副作用也就是说减少了Bug的出现。尤其是针对并发编程方面我们应该多使用函数式编程和不可变数据来实现我们的代码逻辑尽量避免加锁带来的负载逻辑和系统开销。
在平时的工作中,我也尽量去编写纯函数,使用不可变的数据,这样做为我消除了很多不必要的问题。我想用我写的代码来举个例子:
// lombok是我们现在web开发常用的扩展包其中setter是一个可变操作我们可以把它在配置文件中禁用掉通过其它方式来替代需要setter的场景
lombok.setter.flagUsage = error
lombok.data.flagUsage = error
// 声明
@With
@Getter
@Builder(toBuilder = true)
@AllArgsConstructor(staticName = "of")
@NoArgsConstructor(staticName = "of")
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
public class Model() {
String id;
String name;
String type;
}
// 构建Model
var model_01 = Model.of("101", "model_01", "model");
// 构建空Model
var model_02 = Model.of();
// 构建指定参数的Model
var model_03 = Moder.toBuilder().id("301").name("model_03").build();
// 修改Model的一个值通过@With来生成一个全新的model
var model_04 = model_01.withName("model_04");
// 修改多个值,通过@Builder来生成一个全新的model
var model_05 = model_01.toBuilder().name("model_05").type("new_model").build();
上面这段代码是我工作中常用的Model类它结合Lombok简化了冗余代码。这段代码使用of()方法构建新对象withXX()方法修改单个参数toBuilder()方法修改多个参数,修改后会返回新的对象。这种代码编写方式保证了数据的不可变性,让我们的开发更加简单、可回溯、测试友好,减少了任何可能的副作用。
《深入剖析 Java 新特性》对我的帮助
从今年九月份到现在Java17正式发布这么短的时间内范学雷老师就出了这门课程真的非常棒。《深入剖析 Java 新特性》不仅帮我梳理了Java新特性的条目和使用方法还把为什么会有这项新特性给我们讲了讲这点真的很重要这让我对新特性有了更深的理解。
我之前看其他讲解封闭类的文章就一直不理解Java这么多年都用过来了为什么还需要封闭类这东西呢但范老师在关于封闭类这一讲里就举了一个形状的案例。我才知道原来以前类的设计要么是全开放要么是全封闭的。它缺少中间的可控状态这让我茅塞顿开对这个新特性有了新的理解。虽然这个封闭类在我编写业务代码时不常用到但是在编写公共工具类的时候可以增加代码的可控性确实是一个不错的选择。
在档案类这一讲我们看到Java在构建不可变对象的路上带我们又向前走了一步进一步简化了我们的代码实现。刚才我简单描述了一下不可变数据的优势以及结合Lombok的实现方式现在有了Record以后我们就可以把Model都交给Record来实现了。
我还去查询了Lombok的版本迭代Lombok在1.18.20版本就对Record进行了支持1.18.22版本开始全面支持JDK17的其他新特性更详细的内容大家可以去Lombok官网看一看。这样一来我们就可以把上面的Model简化为Record来实现
// 注意:@NoArgsConstructor,@RequiredArgsConstructor,@AllArgsConstructor 只支持class和enum不支持record
@With
@Builder(toBuilder = true)
public record Model(String id, String name, String type) {}
在上面这段代码中Record已经极大简化了我们的代码实现。不过我个人有点小遗憾希望Record能有一个默认.of()的对象构造方法,如果能填补上这个空缺,会更符合函数式编程的风格。
另外课程后面的模式匹配和switch表达式都为我们提供了更好的选择让我们的代码更加简洁和灵活。所以如果你还停留在Java 8的话还是尽快学习一下这门课程吧。
写在最后
谈到Java相关的函数式编程技术首先应该感谢一下我的mentor他不仅是一位乐于分享知识的技术大神还是Kotlin语言的布道师也经常为开源社区做贡献。如果你对Kotlin语言或者函数式编程感兴趣可以去看一下他的博客hltj.me。
感谢极客时间给开发人员提供了这么多专业的、有价值的计算机相关技术而且现在的新课程都结合了最新迭代的相关技术就像范老师的这门课程就在最短的时间内为我们带来了Java最新的特性分析和讲解这对于Java语言从业者无疑是一份宝贵的学习资料。
也期待极客时间在未来可以有更多硬核知识分享,如果能有响应式编程和函数式编程相关的课程,我一定会第一时间购买。
期待你在评论区留言,我们一起讨论更多新特性的知识吧!