diff --git a/.gitignore b/.gitignore index 6d3e913..851523c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ _book ._* */.DS_Store */._* + +# IDEA +/.idea/* \ No newline at end of file diff --git a/README.md b/README.md index be56b37..6cae146 100644 --- a/README.md +++ b/README.md @@ -25,16 +25,16 @@ - [x] [第三章 万物皆对象](docs/book/03-Objects-Everywhere.md) - [x] [第四章 运算符](docs/book/04-Operators.md) - [x] [第五章 控制流](docs/book/05-Control-Flow.md) -- [ ] [第六章 初始化和清理](docs/book/06-Housekeeping.md) -- [ ] [第七章 封装](docs/book/07-Implementation-Hiding.md) +- [x] [第六章 初始化和清理](docs/book/06-Housekeeping.md) +- [x] [第七章 封装](docs/book/07-Implementation-Hiding.md) - [ ] [第八章 复用](docs/book/08-Reuse.md) - [ ] [第九章 多态](docs/book/09-Polymorphism.md) - [ ] [第十章 接口](docs/book/10-Interfaces.md) -- [ ] [第十一章 内部类](docs/book/11-Inner-Classes.md) +- [x] [第十一章 内部类](docs/book/11-Inner-Classes.md) - [ ] [第十二章 集合](docs/book/12-Collections.md) - [x] [第十三章 函数式编程](docs/book/13-Functional-Programming.md) - [x] [第十四章 流式编程](docs/book/14-Streams.md) -- [ ] [第十五章 异常](docs/book/15-Exceptions.md) +- [x] [第十五章 异常](docs/book/15-Exceptions.md) - [ ] [第十六章 代码校验](docs/book/16-Validating-Your-Code.md) - [ ] [第十七章 文件](docs/book/17-Files.md) - [ ] [第十八章 字符串](docs/book/18-Strings.md) @@ -55,7 +55,7 @@ - [ ] [附录:理解equals和hashCode方法](docs/book/Appendix-Understanding-equals-and-hashCode.md) - [ ] [附录:集合主题](docs/book/Appendix-Collection-Topics.md) - [ ] [附录:并发底层原理](docs/book/Appendix-Low-Level-Concurrency.md) -- [ ] [附录:数据压缩](docs/book/Appendix-Data-Compression.md) +- [x] [附录:数据压缩](docs/book/Appendix-Data-Compression.md) - [ ] [附录:对象序列化](docs/book/Appendix-Object-Serialization.md) - [ ] [附录:静态语言类型检查](docs/book/Appendix-Benefits-and-Costs-of-Static-Type-Checking.md) - [ ] [附录:成为一名程序员](docs/book/Appendix-Becoming-a-Programmer.md) @@ -89,9 +89,9 @@ ## 贡献者 -* 主译: [LingCoder](https://github.com/LingCoder) -* 参译: [sjsdfg](https://github.com/sjsdfg),[LortSir](https://github.com/LortSir) -* 校对:[LingCoder](https://github.com/LingCoder),[nickChenyx](https://github.com/nickChenyx) +* 主译:[LingCoder](https://github.com/LingCoder),[sjsdfg](https://github.com/sjsdfg) +* 参译:[xiangflight](https://github.com/xiangflight),[LortSir](https://github.com/LortSir) +* 校对:[LingCoder](https://github.com/LingCoder),[xiangflight](https://github.com/xiangflight),[nickChenyx](https://github.com/nickChenyx) ## 翻译说明 diff --git a/docs/README.md b/docs/README.md index c8eef22..d5dc4e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ 本翻译项目的 GITHUB 开源地址:[https://github.com/LingCoder/OnJava8](https://github.com/LingCoder/OnJava8) -如果您在阅读本书的过程中有发现不明白或者错误的地方,请随时到项目地址发布 issue 或者 fork 项目后发布 pr 帮助译者改善!不胜感激! +如果你在阅读本书的过程中有发现不明白或者错误的地方,请随时到项目地址发布 issue 或者 fork 项目后发布 pr 帮助译者改善!不胜感激! ## 书籍简介 diff --git a/docs/book/00-Introduction.md b/docs/book/00-Introduction.md index f72bcb3..d34623b 100644 --- a/docs/book/00-Introduction.md +++ b/docs/book/00-Introduction.md @@ -5,7 +5,7 @@ 这句话无论对于自然语言还是编程语言来说都是一样的。你所使用的编程语言会将你的思维模式固化并逐渐远离其他语言,而且往往发生在潜移默化中。Java 作为一门傲娇的语言尤其如此。 -Java 是一门派生语言,早期语言设计者为了不想在项目中使用 C++ 而创造了这种看起来很像 C++,却比 C++ 有了改进的新语言(原始的项目并未成功)。Java 最核心的变化就是加入了“虚拟机”和“垃圾回收机制”,这两个概念在之后的章节会有详细描述。 此外,Java 还在其他方面推动了行业发展。例如,现在绝大多数编程语言都支持文档注释语法和 HTML 文档生成工具。 +Java 是一门派生语言,早期语言设计者为了不想在项目中使用 C++ 而创造了这种看起来很像 C++,却比 C++ 有了改进的新语言(原始的项目并未成功)。Java 最核心的变化就是加入了“虚拟机”和“垃圾回收机制”,这两个概念在之后的章节会有详细描述。 此外,Java 还在其他方面推动了行业发展。例如,现在绝大多数编程语言都支持文档注释语法和 HTML 文档生成工具。 Java 最主要的概念之一“对象”来自 SmallTalk 语言。SmallTalk 语言恪守“对象”(在下一章中描述)是编程的最基本单元。于是,万物皆对象。历经时间的检验,人们发现这种信念太过狂热。有些人甚至认为“对象”的概念是完全错误的,应该舍弃。就我个人而言,把一切事物都抽象成对象不仅是一项不必要的负担,同时还会招致许多设计朝着不好的方向发展。尽管如此,“对象”的概念依然有其闪光点。固执地要求所有东西都是一个对象(特别是一直到最底层级别)是一种设计错误;相反,完全逃避“对象”的概念似乎同样太过苛刻。 @@ -19,7 +19,7 @@ Java 语言曾规划设计的许多功能并未按照承诺兑现。本书中, 许多语言设计决策时都考虑到了复杂性,并试图降低语言的复杂性,但在设计过程中遇到了一些更棘手的问题,最终导致语言设计不可避免地“碰壁”,复杂性增加。例如,C++ 必须向后兼容 C(允许 C 程序员轻松迁移),并且效率很高。这些目标非常实用,并且也是 C++ 在编程界取得了成功的原因之一,但同时也引入了额外的复杂性,导致某些用C++编写的项目开发失败。当然,你可以责怪程序员和管理人员手艺不精,但如果有一种编程语言可以帮助你在开发过程中发现错误,那岂不是更好? -虽然 VB(Visual BASIC)绑定在 BASIC 上,但 BASIC 实际上并不是一种可扩展的语言。大量扩展的堆积造成 VB 的语法难以维护。Perl 向后兼容 awk、sed、grep 以及其它要替换的 Unix 工具。因此它常常被诟病产生了一堆“只写代码”(*write-only code*,写代码的人自己都看不懂的代码)。另一方面,C ++,VB,Perl 和其他语言(如 SmallTalk)在设计时重点放在了对某些复杂问题的处理上,因而在解决这些特定类型的问题方面非常成功。 +虽然 VB(Visual BASIC)绑定在 BASIC 上,但 BASIC 实际上并不是一种可扩展的语言。大量扩展的堆积造成 VB 的语法难以维护。Perl 向后兼容 awk、sed、grep 以及其它要替换的 Unix 工具。因此它常常被诟病产生了一堆“只写代码”(*write-only code*,写代码的人自己都看不懂的代码)。另一方面,C ++,VB,Perl 和其他语言(如 SmallTalk)在设计时重点放在了对某些复杂问题的处理上,因而在解决这些特定类型的问题方面非常成功。 通信革命使我们相互沟通更加便利。无论是一对一沟通,还是团队里的互相沟通,甚至是地球上不同地区的沟通。据说下一次革命需要的是一种全球性的思维,这种思维源于足量的人以及足量相互连接。不我不知道 Java 是否能成为这场革命的工具之一,但至少这种可能性让我觉得:我现在正在做的传道授业的事情是有意义的! @@ -78,7 +78,7 @@ Java 语言曾规划设计的许多功能并未按照承诺兑现。本书中, ## 编码样式 -本书中代码标识符(关键字,方法,变量和类名)以粗体,固定宽度代码字体显示。像“**class**”这种在代码中高频率出现的关键字可能让你觉得粗体有点乏味。(译者注:由于中英排版差异,中问翻译过程并未完全参照原作者的说明。具体排版格式请参考[此处](https://github.com/ruanyf/document-style-guide))其他显示为正常字体。本书文本格式尽可能遵循 Oracle 常见样式,并保证在大多数 Java 开发环境中被支持。书中我使用了自己喜欢的字体风格。Java 是一种自由的编程语言,你也可以使用 IDE(集成开发环境)工具(如 IntelliJ IDEA,Eclipse 或 NetBeans)将格式更改为适合你的格式。 +本书中代码标识符(关键字,方法,变量和类名)以粗体,固定宽度代码字体显示。像“*class”这种在代码中高频率出现的关键字可能让你觉得粗体有点乏味。(译者注:由于中英排版差异,中文翻译过程并未完全参照原作者的说明。具体排版格式请参考[此处](https://github.com/ruanyf/document-style-guide))其他显示为正常字体。本书文本格式尽可能遵循 Oracle 常见样式,并保证在大多数 Java 开发环境中被支持。书中我使用了自己喜欢的字体风格。Java 是一种自由的编程语言,你也可以使用 IDE(集成开发环境)工具(如 IntelliJ IDEA,Eclipse 或 NetBeans)将格式更改为适合你的格式。 本书代码文件使用自动化工具进行测试,并在最新版本的 Java 编译通过(除了那些特别标记的错误之外)。本书重点介绍并使用 Java 8 进行测试。如果你必须了解更早的语言版本,可以在 [www.OnJava8.com](http://www.OnJava8.com) 免费下载 《Thinking in Java》。 diff --git a/docs/book/00-Preface.md b/docs/book/00-Preface.md index 5d29891..d5f7e85 100644 --- a/docs/book/00-Preface.md +++ b/docs/book/00-Preface.md @@ -8,7 +8,7 @@ 随着 Java 8 的出现,这门语言在许多地方发生了翻天覆地的变化。在新的版本中,代码的运用和实现上与以往不尽相同。这也促使了我时隔多年后再次创作了这本新书。《On Java 8》旨在面向已具有编程基础的开发者们。对于初学者,可以先在 [Code.org](http://Code.org) 或者 [Khan Academy](https://www.khanacademy.org/computing/computer-programming) 等网站上补充必要的前置知识。同时,[OnJava8.com](http://www.OnJava8.com) 上也有免费的 Thinking in C(《C编程思想》)专题知识。 -与几年前我们依赖印刷媒体相比,YouTube,博客和 StackOverflow 等网站的出现让寻找答案变得简单。请结合这些学习途径和努力坚持下去。本书可作为编程入门书籍,同时也适用于想要扩展知识的在职程序员。每次在世界各地的演讲中,我都非常感谢 《*Thinking in Java*》 这本书给我带来的所有荣誉。它对于我重塑 [Reinventing Business](http://www.reinventing-business.com) 项目和促进交流是非常宝贵的。最后,写这本书的原因之一 希望这本书可以为我的这个项目众筹。似乎下一步要创建一个所谓的蓝绿色组织(Teal Organization)才合乎逻辑的。 +与几年前我们依赖印刷媒体相比,YouTube,博客和 StackOverflow 等网站的出现让寻找答案变得简单。请结合这些学习途径和努力坚持下去。本书可作为编程入门书籍,同时也适用于想要扩展知识的在职程序员。每次在世界各地的演讲中,我都非常感谢 《*Thinking in Java*》 这本书给我带来的所有荣誉。它对于我重塑 [Reinventing Business](http://www.reinventing-business.com) 项目和促进交流是非常宝贵的。最后,写这本书的原因之一 希望这本书可以为我的这个项目众筹。似乎下一步要创建一个所谓的蓝绿色组织(Teal Organization)才合乎逻辑的。 ## 教学目标 @@ -75,7 +75,7 @@ Java 的普及性对于其受欢迎程度有重要意义。学习 Java 会让你 ## 电子版权声明 -《On Java 8》仅提供电子版,并且仅通过 [www.OnJava8.com](http://www.OnJava8.com) 提供。任何未经 授权的其他来源或流传送机构都是非法的。本作品受版权保护!未经许可,请勿通过以任何方式分享或发布。您可以使用这些示例进行教学,只要不对本书非法重新出版。有关完整详细信息,请参阅示例分发中的 Copyright.txt 文件。对于视觉障碍者,电子版本有可搜索性,字体大小调整或文本到语音等诸多好处。 +《On Java 8》仅提供电子版,并且仅通过 [www.OnJava8.com](http://www.OnJava8.com) 提供。任何未经 授权的其他来源或流传送机构都是非法的。本作品受版权保护!未经许可,请勿通过以任何方式分享或发布。你可以使用这些示例进行教学,只要不对本书非法重新出版。有关完整详细信息,请参阅示例分发中的 Copyright.txt 文件。对于视觉障碍者,电子版本有可搜索性,字体大小调整或文本到语音等诸多好处。 任何购买这本书的读者,还需要一台计算机来运行和写作代码。另外电子版在计算机上和移动设备上的显示效果俱佳,推荐使用平板设备阅读。相比购买传统纸质版的价格,平板电脑价格都足够便宜。在床上阅读电子版比看这样一本厚厚的实体书要方便得多。起初你可能会有些不习惯,但我相信很快你就会发现它带来的优点远胜过不适。我已经走过这个阶段,Google Play 图书的浏览器阅读体验非常好,包括在 Linux 和 iOS 设备上。作为一次尝试,我决定尝试通过 Google 图书进行出版。 @@ -112,7 +112,7 @@ Java 的普及性对于其受欢迎程度有重要意义。学习 Java 会让你 感谢 *Jeremy Cerise* 和 *Bill Frasure* 来到开发商务虚会预订,并随后提供了宝贵的帮助。 -感谢所有花时间和精力来科罗拉多州克雷斯特德比特(Crested Butte,Colorado)镇参加我的研讨会,开发商务聚会和其他活动的人!你们的贡献可能不容易看到,但却非常重要! +感谢所有花时间和精力来科罗拉多州克雷斯特德比特(Crested Butte, Colorado)镇参加我的研讨会,开发商务聚会和其他活动的人!你们的贡献可能不容易看到,但却非常重要! diff --git a/docs/book/01-What-is-an-Object.md b/docs/book/01-What-is-an-Object.md index 9ba3f7b..b7d5511 100644 --- a/docs/book/01-What-is-an-Object.md +++ b/docs/book/01-What-is-an-Object.md @@ -7,13 +7,13 @@ > “我们没有意识到惯用语言的结构有多大的力量。可以毫不夸张地说,它通过语义反应机制奴役我们。语言表现出来并在无意识中给我们留下深刻印象的结构会自动投射到我们周围的世界。” -- Alfred Korzybski (1930) -计算机革命的起源来自机器。编程语言就像是那台机器。它不仅是我们思维放大的工具与另一种表达媒介,更像我们思想的一部分。语言的灵感来自其他形式的表达,如写作,绘画,雕塑,动画和电影制作。编程语言就是创建应用程序的思想结构。 +计算机革命的起源来自机器。编程语言就像是那台机器。它不仅是我们思维放大的工具与另一种表达媒介,更像是我们思想的一部分。语言的灵感来自其他形式的表达,如写作,绘画,雕塑,动画和电影制作。编程语言就是创建应用程序的思想结构。 -面向对象编程(Object-Oriented Programming OOP)是一种编程思维方式和编码架构。本章讲述 OOP 的基本概述。如果读者对不太理解,可先行跳过本章。等你具备一定编程基础后,请务必再回头看。只有这样你才能深刻理解面向对象编程的重要性及设计方式。 +面向对象编程(Object-Oriented Programming OOP)是一种编程思维方式和编码架构。本章讲述 OOP 的基本概述。如果读者对此不太理解,可先行跳过本章。等你具备一定编程基础后,请务必再回头看。只有这样你才能深刻理解面向对象编程的重要性及设计方式。 ## 抽象 -所有编程语言都提供抽象机制。从某种程度上来说,问题的复杂度直接取决于抽象的类型和质量。这里的“类型”意思是:抽象的内容是什么?汇编语言是对底层机器的轻微抽象。接着出现的“命令式”语言(如 FORTRAN,BASIC 和 C)是对汇编语言的抽象。与汇编相比,这类语言已有了长足的改进,但它们的抽象原理依然要求我们着重考虑计算机的结构,而非问题本身的结构。 +所有编程语言都提供抽象机制。从某种程度上来说,问题的复杂度直接取决于抽象的类型和质量。这里的“类型”意思是:抽象的内容是什么?汇编语言是对底层机器的轻微抽象。接着出现的“命令式”语言(如 FORTRAN,BASIC 和 C)是对汇编语言的抽象。与汇编相比,这类语言已有了长足的改进,但它们的抽象原理依然要求我们着重考虑计算机的结构,而非问题本身的结构。 程序员必须要在机器模型(“解决方案空间”)和实际解决的问题模型(“问题空间”)之间建立起一种关联。这个过程既费精力,又脱离编程语言本身的范畴。这使得程序代码很难编写,维护代价高昂。同时还造就了一门副产业的“编程方法”学科。 @@ -21,26 +21,26 @@ 问题都归纳为决策链。对于这些语言,我们认为它们一部分是“基于约束”的编程,另一部分则是专为 处理图形符号设计的(后者被证明限制性太强)。每种方法都有自己特殊的用途,适合解决某一类的问题。只要超出了它们力所能及的范围,就会显得非常笨拙。 -面向对象的程序设计在此基础上跨出了一大步,程序员可利用一些工具表达“问题空间”内的元素。由于这种表达非常具有普遍性,所以不必受限于特定类型的问题。我们将问题空间中的元素以及它们在解决方案空间的表示物称作“对象”(**Object**)。当然,还有一些在问题空间没有对应的对象体。通过添加新的对象类型,程序可进行灵活的调整,以便与特定的问题配合。所以在阅读方案的描述代码时,会读到对问题进行表达的话语。与我们以前见过的相比,这无疑是一种更加灵活、更加强大的语言抽象方法。总之,OOP 允许我们根据问题来描述问题,而不是根据方案。然而,仍有一个联系途径回到计算机。每个对象都类似一台小计算机;它们有自己的状态,而且可要求它们进行特定的操作。与现实世界的“对象”或者“物体”相比,编程“对象”与它们也存在共通的地方:它们都有自己的特征和行为。 +面向对象的程序设计在此基础上跨出了一大步,程序员可利用一些工具表达“问题空间”内的元素。由于这种表达非常具有普遍性,所以不必受限于特定类型的问题。我们将问题空间中的元素以及它们在解决方案空间的表示称作“对象”(**Object**)。当然,还有一些在问题空间没有对应的对象体。通过添加新的对象类型,程序可进行灵活的调整,以便与特定的问题配合。所以当你在阅读描述解决方案的代码时,也是在阅读问题的表述。与我们以前见过的相比,这无疑是一种更加灵活、更加强大的语言抽象方法。总之,OOP 允许我们根据问题来描述问题,而不是根据运行解决方案的计算机。然而,它仍然与计算机有联系,每个对象都类似一台小计算机:它们有自己的状态并且可以进行特定的操作。这与现实世界的“对象”或者“物体”相似:它们都有自己的特征和行为。 -Smalltalk 作为第一种成功的面向对象程序设计语言和 Java 的基础语言,*Alan Kay* 总结了其五大基本特征。通过这些特征,我们可理解“纯粹”的面向对象程序设计方法是什么样的: +Smalltalk 作为第一个成功的面向对象并影响了 Java的程序设计语言 ,*Alan Kay* 总结了其五大基本特征。通过这些特征,我们可理解“纯粹”的面向对象程序设计方法是什么样的: -> 1. **万物皆对象**。你可以将对象想象成一种特殊的变量。它可以存储数据,可以在你对其“发出请求”时执行本身的操作。理论上讲,你可以从要解决的问题身上抽象出概念性的组件,然后在程序中将其表达为一个对象。 -> 2. **程序是一组对象,通过信息传递来告知彼此该做什么**。要请求一个对象,你需要向该对象发送信息。 +> 1. **万物皆对象**。你可以将对象想象成一种特殊的变量。它存储数据,但可以在你对其“发出请求”时执行本身的操作。理论上讲,你总是可以从要解决的问题身上抽象出概念性的组件,然后在程序中将其表示为一个对象。 +> 2. **程序是一组对象,通过消息传递来告知彼此该做什么**。要请求调用一个对象的方法,你需要向该对象发送消息。 > 3. **每个对象都有自己的存储空间,可容纳其他对象**。或者说,通过封装现有对象,可制作出新型对象。所以,尽管对象的概念非常简单,但在程序中却可达到任意高的复杂程度。 -> 4. **每个对象都有一种类型**。根据语法,每个对象都是某个“类”的一个“实例”。其中,“类”(Class)是“类型”(Type)的同义词。一个类最重要的特征就是“能将什么信息发给它?”。 -> 5. **同一类所有对象都能接收相同的信息**。这实际是别有含义的一种说法,大家不久便能理解。由于类型为“圆”(Circle)的一个对象也属于类型为“形状”(Shape)的一个对象,所以一个圆完全能接收形状信息。这意味着可让程序代码统一指挥“形状”,令其自动控制所有符合“形状”描述的对象,其中自然包括“圆”。这一特性称为对象的“可替换性”,是OOP最重要的概念之一。 +> 4. **每个对象都有一种类型**。根据语法,每个对象都是某个“类”的一个“实例”。其中,“类”(Class)是“类型”(Type)的同义词。一个类最重要的特征就是“能将什么消息发给它?”。 +> 5. **同一类所有对象都能接收相同的消息**。这实际是别有含义的一种说法,大家不久便能理解。由于类型为“圆”(Circle)的一个对象也属于类型为“形状”(Shape)的一个对象,所以一个圆完全能接收发送给"形状”的消息。这意味着可让程序代码统一指挥“形状”,令其自动控制所有符合“形状”描述的对象,其中自然包括“圆”。这一特性称为对象的“可替换性”,是OOP最重要的概念之一。 -*Grady Booch* 提供了对对象更简洁的描述:一个对象具有自己的状态,行为和身份。这意味着对象有自己的内部数据(由状态提供)、方法 (由特性提供),并彼此区分(每个对象在内存中都有唯一的地址)。 +*Grady Booch* 提供了对对象更简洁的描述:一个对象具有自己的状态,行为和标识。这意味着对象有自己的内部数据(提供状态)、方法 (产生行为),并彼此区分(每个对象在内存中都有唯一的地址)。 ## 接口 -亚里士多德(*Aristotle*)大概是第一个认真研究“类型”的哲学家,他曾提出过“鱼和鸟类”这样的概念。所有对象都是唯一的,但同时也是具有相同的特性和型位的对象所归属的类的一部分。这种思想被首次应用于第一个面向对象编程语言 Simula-67,它在程序中使用基本关键字 **class** 来引入新的类型(class 和 type 通常可互换使用,有些人对它们进行了进一步区分,他们强调 type 决定了接口,而 class 是那个接口的一种特殊实现方式)。 +亚里士多德(*Aristotle*)大概是第一个认真研究“类型”的哲学家,他曾提出过“鱼类和鸟类”这样的概念。所有对象都是唯一的,但同时也是具有相同的特性和行为的对象所归属的类的一部分。这种思想被首次应用于第一个面向对象编程语言 Simula-67,它在程序中使用基本关键字 **class** 来引入新的类型(class 和 type 通常可互换使用,有些人对它们进行了进一步区分,他们强调 type 决定了接口,而 class 是那个接口的一种特殊实现方式)。 -Simula 是一个很好的例子。正如这个名字所暗示的,它的作用是“模拟”(Simulate)类似“银行出纳员”这样的经典问题。在这个例子里,我们有一系列出纳员、客户、帐号以及交易等。每类成员(元素)都具有一些通用的特征:每个帐号都有一定的余额;每名出纳都能接收客户的存款;等等。与此同时,每个成员都有自己的状态;每个帐号都有不同的余额;每名出纳都有一个名字。所以在计算机程序中,能用独一无二的实体分别表示出纳员、客户、帐号以及交易。这个实体便是“对象”,而且每个对象都隶属一个特定的“类”,那个类具有自己的通用特征与行为。 +Simula 是一个很好的例子。正如这个名字所暗示的,它的作用是“模拟”(Simulate)类似“银行出纳员”这样的经典问题。在这个例子里,我们有一系列出纳员、客户、帐号、交易和货币单位等许多"对象”。每类成员(元素)都具有一些通用的特征:每个帐号都有一定的余额;每名出纳都能接收客户的存款;等等。与此同时,每个成员都有自己的状态;每个帐号都有不同的余额;每名出纳都有一个名字。所以在计算机程序中,能用独一无二的实体分别表示出纳员、客户、帐号以及交易。这个实体便是“对象”,而且每个对象都隶属一个特定的“类”,那个类具有自己的通用特征与行为。 -因此,在面向对象的程序设计中,尽管我们真正要做的是新建各种各样的数据“类型”(Type),但几乎所有面向对象的程序设计语言都采用了 `class` 关键字。当你看到“type”这个字的时候,请同时想到 `class`;反之亦然。 +因此,在面向对象的程序设计中,尽管我们真正要做的是新建各种各样的数据“类型”(Type),但几乎所有面向对象的程序设计语言都采用了 `class` 关键字。当你看到“type”这个词的时候,请同时想到 `class`;反之亦然。 创建好一个类后,可根据情况生成许多对象。随后,可将那些对象作为要解决问题中存在的元素进行处理。事实上,当我们进行面向对象的程序设计时,面临的最大一项挑战性就是:如何在“问题空间”(问题实际存在的地方)的元素与“方案空间”(对实际问题进行建模的地方,如计算机)的元素之间建立理想的“一对一”的映射关系。 @@ -55,107 +55,121 @@ Light lt = new Light(); lt.on(); ``` -在这个例子中,类型/类的名称是 **Light**,可向 **Light** 对象发出的请求包括包括打开 `on`、关闭 `off`、变得更明亮 `brighten` 或者变得更暗淡 `dim`。通过声明一个引用,如 `lt` 和 `new` 关键字,我们为 **Light** 类对象创建了一个对象,再用等号将其赋给引用。 +在这个例子中,类型/类的名称是 **Light**,可向 **Light** 对象发出的请求包括包括打开 `on`、关闭 `off`、变得更明亮 `brighten` 或者变得更暗淡 `dim`。通过声明一个引用,如 `lt` 和 `new` 关键字,我们创建了一个**Light**类型的对象,再用等号将其赋给引用。 -为了向对象发送信息,我们使用句点符号 `.` 将 `lt` 和信息名称 `on` 连接起来。可以看出,使用一些预先定义好的类时,我们在程序里采用的代码是非常简单和直观的。 +为了向对象发送消息,我们使用句点符号 `.` 将 `lt` 和消息名称 `on` 连接起来。可以看出,使用一些预先定义好的类时,我们在程序里采用的代码是非常简单直观的。 -上图遵循 **UML**(Unified Modeling Language,统一建模语言)的格式。每个类由一个框表示,框的顶部有类型名称,框中间部分要描述的任何数据成员,以及方法(属于此对象的方法,它们接收任何发送到该对象的信息)在框的底部。通常,只有类的名称和公共方法在 **UML** 设计图中显示,因此中间部分未显示,如本例所示。如果你只对类名感兴趣,则也不需要显示方法信息。 +上图遵循 **UML**(Unified Modeling Language,统一建模语言)的格式。每个类由一个框表示,框的顶部有类型名称,框中间部分是要描述的任何数据成员,方法(属于此对象的方法,它们接收任何发送到该对象的消息)在框的底部。通常,只有类的名称和公共方法在 **UML** 设计图中显示,因此中间部分未显示,如本例所示。如果你只对类名感兴趣,则也不需要显示方法信息。 ## 服务提供 在开发或理解程序设计时,我们可以将对象看成是“服务提供者”。你的程序本身将为用户提供服务,并且它能通过调用其他对象提供的服务来实现这一点。我们的最终目标是开发或调用工具库中已有的一些对象,提供理想的服务来解决问题。 -那么问题来了:我们该选择哪个对像来解决问题呢?例如,你正在开发一个记事本程序。你可能会想在屏幕输入一个默认的记事本对象,一个用于检测不同类型打印机并执行打印的对象。这些对象中的某些已经有了。那对于还没有的对象,我们该设计成啥样呢?这些对象需要提供哪些服务,以及还需要调用其他哪些对象? +那么问题来了:我们该选择哪个对象来解决问题呢?例如,你正在开发一个记事本程序。*你可能会想到在屏幕输入默认的记事本对象*,一个用于检测不同类型打印机并执行打印的对象。这些对象中的某些已经有了。那对于还没有的对象,我们该设计成啥样呢?这些对象需要提供哪些服务,以及还需要调用其他哪些对象? 我们可以将这些问题一一分解,抽象成一组服务。软件设计的基本原则是高内聚:每个组件的内部作用明确,功能紧密相关。然而经常有人将太多功能塞进一个对象中。例如:在支票打印模块中,你需要设计一个可以同时读取文本格式又能正确识别不同打印机型号的对象。正确的做法是提供三个或更多对象:一个对象检查所有排版布局的目录;一个或一组可以识别不同打印机型号的对象展示通用的打印界面;第三个对象组合上述两个服务来完成任务。这样,每个对象都提供了一组紧密的服务。在良好的面向对象设计中,每个对象功能单一且高效。这样的程序设计可以提高我们代码的复用性,同时也方便别人阅读和理解我们的代码。只有让人知道你提供什么服务,别人才能更好地将其应用到其他模块或程序中。 ## 封装 -我们可以把编程的侧重领域划分为研发和应用。应用程序员调用研发程序员构建的基础工具类来做快速开发。研发程序员开发一个工具类,该工具类仅向应用程序员公开必要的内容,并隐藏内部实现的细节。这样可以有效地避免该工具类被错误的使用和更改,从而减少程序出错的可能。彼此职责划分清晰,相互协作。当应用程序员调用研发程序员开发的工具类时,双方建立了关系。应用程序员通过使用现成的工具类组装应用程序或者构建更大的工具库。如果工具类的创建者将类的内部所有信息都公开给调用者,那么有些使用规则就不容易被遵守。因为前者无法保证后者是否按照正确的规则来使用或是改变了该工具类。只有设定访问控制,才能从根本上阻止。 +我们可以把编程的侧重领域划分为研发和应用。应用程序员调用研发程序员构建的基础工具类来做快速开发。研发程序员开发一个工具类,该工具类仅向应用程序员公开必要的内容,并隐藏内部实现的细节。这样可以有效地避免该工具类被错误的使用和更改,从而减少程序出错的可能。彼此职责划分清晰,相互协作。当应用程序员调用研发程序员开发的工具类时,双方建立了关系。应用程序员通过使用现成的工具类组装应用程序或者构建更大的工具库。如果工具类的创建者将类的内部所有信息都公开给调用者,那么有些使用规则就不容易被遵守。因为前者无法保证后者是否会按照正确的规则来使用,甚至是改变该工具类。只有设定访问控制,才能从根本上阻止这种情况的发生。 因此,使用访问控制的原因有以下2点: 1. 让应用程序员不要触摸他们不应该触摸的部分。(请注意,这也是一个哲学决策。部分编程语言认为如果程序员有需要,则应该让他们访问细节部分。); -2. 使类库的创建者(研发程序员)在不影响后者使用的情况下完善更新工具库。例如,我们开发了一个功能简单的工具类,后来发现可以通过优化代码来提高执行速度。假如工具类的接口和实现部分明确分开并受到保护,那我们就可以轻松的完成改造。 +2. 使类库的创建者(研发程序员)在不影响后者使用的情况下完善更新工具库。例如,我们开发了一个功能简单的工具类,后来发现可以通过优化代码来提高执行速度。假如工具类的接口和实现部分明确分开并受到保护,那我们就可以轻松地完成改造。 -Java 有三个显式关键字来设置类中的访问权限:`public`(公开),`private`(私有)和`protected`(受保护)。这些修饰符可以明确谁能访问后面的方法、变量或类。 +Java 有三个显式关键字来设置类中的访问权限:`public`(公开),`private`(私有)和`protected`(受保护)。这些访问修饰符决定了谁能使用它们修饰的方法、变量或类。 1. `public` (公开) 表示任何人都可以访问和使用该元素; - 2. `private` (私有) 除了类本身,外界无法直接访问该元素和内部方法。`private` 是你和调用者之间的屏障。任何试图访问私有成员的人都会收到编译时错误; + 2. `private` (私有) 除了类本身和类内部的方法,外界无法直接访问该元素。`private` 是类和调用者之间的屏障。任何试图访问私有成员的行为都会报编译时错误; - 3. `protected` (受保护) 类似于 `private`,区别是继承类(下一节就会引入继承的概念)可以访问 `protected` 的成员,但不能访问 `private` 成员; + 3. `protected` (受保护) 类似于 `private`,区别是子类(下一节就会引入继承的概念)可以访问 `protected` 的成员,但不能访问 `private` 成员; - 4. `default` (默认) 如果你不使用前面的三者,默认就是 `default` 访问权限。`default` 被称之为包访问,因为该权限下的资源可以同一包(库组件)中其他类的成员访问。 + 4. `default` (默认) 如果你不使用前面的三者,默认就是 `default` 访问权限。`default` 被称为包访问,因为该权限下的资源可以被同一包(库组件)中其他类的成员访问。 ## 复用 一个类经创建和测试后,理应是可复用的。然而很多时候,由于程序员没有足够的编程经验和远见,我们的代码复用性并不强。 -代码和设计方案的复用性是面向对象的程序设计的优点之一。我们可以通过重复使用某个 `class` 来达到这种复用性。同时,我们也可以将这个 `class` 作为另一个 `class` 的成员变量来使用。新的对象可以是由任意数量、类型的其他对象构成。这里涉及到“组合”和“聚合”的概念: +代码和设计方案的复用性是面向对象程序设计的优点之一。我们可以通过重复使用某个类的对象来达到这种复用性。同时,我们也可以将一个类的对象作为另一个类的成员变量使用。新的类可以是由任意数量和任意类型的其他对象构成。这里涉及到“组合”和“聚合”的概念: -* **组合**(Composition) 经常用来表示“拥有”关系(Has-a Relationship)。例如,“汽车拥有了主机”。 +* **组合**(Composition)经常用来表示“拥有”关系(has-a relationship)。例如,“汽车拥有引擎”。 -* **聚合**(Aggregation) 动态的 **组合**。 +* **聚合**(Aggregation) 动态的 **组合**。 ![UML-example](../images/1545758268350.png) -上图中实心棱形指向“ **Car** ”表示 **组合** 的关系;如果是 **聚合** 关系,可以使用空心棱形。 +上图中实心菱形指向“ **Car** ”表示 **组合** 的关系;如果是 **聚合** 关系,可以使用空心菱形。 -(**译者注**:组合和聚合都属于关联关系的一种,只是额外具有整体-部分的意义。至于是聚合还是组合,需要根据实际的业务需求来判断。可能相同超类和子类,在不同的业务场景,关联关系会发生变化。只看代码是无法区分聚合和组合的,具体是哪一种关系,只能从语义级别来区分。聚合关系中,整件不会拥有部件的生命周期,所以整件删除时,部件不会被删除。再者,多个整件可以共享同一个部件。组合关系中,整件拥有部件的生命周期,所以整件删除时,部件一定会跟着删除。而且,多个整件不可以同时间共享同一个部件。这个区别可以用来区分某个关联关系到底是组合还是聚合。两个类生命周期不同步,则是聚合关系,生命周期同步就是组合关系。) +(**译者注**:组合和聚合都属于关联关系的一种,只是额外具有整体-部分的意义。至于是聚合还是组合,需要根据实际的业务需求来判断。可能相同超类和子类,在不同的业务场景,关联关系会发生变化。只看代码是无法区分聚合和组合的,具体是哪一种关系,只能从语义级别来区分。聚合关系中,整件不会拥有部件的生命周期,所以整件删除时,部件不会被删除。再者,多个整件可以共享同一个部件。组合关系中,整件拥有部件的生命周期,所以整件删除时,部件一定会跟着删除。而且,多个整件不可以同时共享同一个部件。这个区别可以用来区分某个关联关系到底是组合还是聚合。两个类生命周期不同步,则是聚合关系,生命周期同步就是组合关系。) -使用“组合”关系会为我们的程序带来极大的灵活性。通常新构建的 `class` “成员对象”会使用 `private` 访问权限,这样应用程序员则无法对其直接访问。我们就可以在不干扰客户代码的前提下,从容地修改那些成员。也可以在“运行期”更改成员,这进一步增大了灵活性。下面一节要讲到的“继承”并不具备这种灵活性,因为编译器必须对通过继承创建的类加以限制。 +使用“组合”关系给我们的程序带来极大的灵活性。通常新建的类中,成员对象会使用 `private` 访问权限,这样应用程序员则无法对其直接访问。我们就可以在不影响客户代码的前提下,从容地修改那些成员。我们也可以在“运行时"改变成员对象从而动态地改变程序的行为,这进一步增大了灵活性。下面一节要讲到的“继承”并不具备这种灵活性,因为编译器对通过继承创建的类进行了限制。 -在面向对象编程中经常重点强调“继承”。在新程序员的印象里,或许早已先入为主地认为“继承应当随处可见”。沿着这种思路产生的程序设计通常拙劣又复杂。相反,在创建新类时首先要考虑“组合”,因为它更简单灵活,并且设计逻辑清晰。等我们有一些编程经验后,一旦需要用到继承,就会明显意识到这一点。 +在面向对象编程中经常重点强调“继承”。在新手程序员的印象里,或许先入为主地认为“继承应当随处可见”。沿着这种思路产生的程序设计通常拙劣又复杂。相反,在创建新类时首先要考虑“组合”,因为它更简单灵活,而且设计更加清晰。等我们有一些编程经验后,一旦需要用到继承,就会明显意识到这一点。 ## 继承 -“继承”给面向对象编程带来极大的便利。它在概念上允许我们将各式各样数据和功能封装到一起。这样便可恰当表达“问题空间”的概念,而不是强制使用底层机器的习惯用法。 +“继承”给面向对象编程带来极大的便利。它在概念上允许我们将各式各样的数据和功能封装到一起,这样便可恰当表达“问题空间”的概念,而不用受制于必须使用底层机器语言。 + +通过使用 `class` 关键字,这些概念形成了编程语言中的基本单元。遗憾的是,这么做还是有很多麻烦:在创建了一个类之后,即使另一个新类与其具有相似的功能,你还是得重新创建一个新类。但我们若能利用现成的数据类型,对其进行“克隆”,再根据情况进行添加和修改,情况就显得理想多了。“继承”正是针对这个目标而设计的。但继承并不完全等价于克隆。在继承过程中,若原始类(正式名称叫作基础类、超类或父类)发生了变化,修改过的“克隆”类(正式名称叫作继承类或者子类)也会反映出这种变化。 -通过使用 `class` 关键字,这些概念在编程语言中表示为基本单元。但若能利用现成的数据类型,对其进行“克隆”,再根据情况进行添加和修改,情况就显得理想多了。“继承”正是针对这个目标而设计的。但继承并不完全等价于克隆。在继承过程中,若原始类(正式名称叫作基础类、超类或父类)发生了变化,修改过的“克隆”类(正式名称叫作继承类或者子类)也会反映出这种变化。 -如果我们可以采用现有的类,克隆它,然后对克隆进行添加和修改,那就更好了。这实际上是通过继承得到的,只是如果原始类(称为基类或超类或父类)发生了更改,修改的“克隆”(称为派生类或继承类或子类或子类)也反映了这些更改。 ![Inheritance-example](../images/1545763399825.png) -这个图中的箭头从派生类指向基类。正如你将看到的,通常有多个派生类。类型不仅仅描述一组对象的约束,它还涉及其他类型。两种类型可以具有共同的特征和行为,但是一种类型可能包含比另一种类型更多的特征,并且还可以处理更多的信息(或者以不同的方式处理它们)。继承通过基本类型和派生类型的概念来表达这种相似性。基类型包含派生自它的类型之间共享的所有特征和行为。创建基本类型以表示思想的核心。从基类型中,可以派生出其他类型来表示实现该核心的不同方式。 +这个图中的箭头从派生类指向基类。正如你将看到的,通常有多个派生类。类型不仅仅描述一组对象的约束,它还涉及其他类型。两种类型可以具有共同的特征和行为,但是一种类型可能包含比另一种类型更多的特征,并且还可以处理更多的消息(或者以不同的方式处理它们)。继承通过基本类型和派生类型的概念来表达这种相似性。基本类型包含派生自它的类型之间共享的所有特征和行为。创建基本类型以表示思想的核心。从基类型中派生出其他类型来表示实现该核心的不同方式。 ![1545764724202](../images/1545764724202.png) -例如,垃圾回收机对垃圾进行分类。基本类型是“垃圾”。每块垃圾都有重量、值等,并且可以被切碎、熔化或分解。由此,可以衍生出更具体的垃圾类型,这些垃圾具有附加特征(瓶子有颜色,钢罐有磁性)或行为(可以压碎铝罐)。此外,一些行为可以不同(纸张的价值取决于它的类型和条件)。使用继承,你将构建一个类型层次结构,该层次结构根据类型表达你试图解决的问题。第二个例子是常见的“形状”例子,可能用于计算机辅助设计系统或游戏模拟。基本类型是“形状”,每个形状都有大小、颜色、位置等等。每个形状可以绘制、擦除、移动、着色等。由此,可以导出(继承)特定类型的形状——圆形、正方形、三角形等等——每个形状可以具有附加的特征和行为。 +例如,垃圾回收机对垃圾进行分类。基本类型是“垃圾”。每块垃圾都有重量、价值等特性,它们可以被切碎、熔化或分解。在此基础上,可以通过添加额外的特性(瓶子有颜色,钢罐有磁性)或行为(铝罐可以被压碎)衍生出更具体的垃圾类型。此外,一些行为可以不同(纸张的价值取决于它的类型和状态)。使用继承,你将构建一个类型层次结构,来表示你试图解决的某种类型的问题。第二个例子是常见的“形状”例子,可能用于计算机辅助设计系统或游戏模拟。基本类型是“形状”,每个形状都有大小、颜色、位置等等。每个形状可以绘制、擦除、移动、着色等。由此,可以衍生出(继承出)具体类型的形状——圆形、正方形、三角形等等——每个形状可以具有附加的特征和行为。 ![1545764780795](../images/1545764780795.png) -例如,某些形状可以翻转。有些行为可能不同,比如计算形状的面积时。类型层次结构体现了形状之间的相似性和差异。以与问题相同的术语转换解决方案是有用的,因为你不需要中间模型来从问题的描述获得解决方案的描述。对于对象,类型层次结构是模型的一个重要方面,因此你可以直接从真实世界中的系统描述转到代码中的系统描述。的确,有时候,那些被训练去寻找复杂解决方案的人在面向对象设计的简单性方面有困难。从现有类型继承创建新类型。这种新类型不仅包含现有类型的所有成员(尽管私有成员被隐藏起来并且不可访问),更重要的是它复制了基类的接口。也就是说,基类对象接受的所有信息也被派生类对象接受。根据类接受的信息,我们知道类的类型,因此派生类与基类是相同的类型。 +例如,某些形状可以翻转。有些行为可能不同,比如计算形状的面积。类型层次结构体现了形状之间的相似性和差异性。以相同的术语将解决方案转换成问题是有用的,因为你不需要在问题描述和解决方案描述之间建立许多中间模型。通过使用对象,类型层次结构成为了主要模型,因此你可以直接从真实世界中对系统的描述过渡到用代码对系统进行描述。事实上,有时候,那些善于寻找复杂解决方案的人会被面向对象设计的简单性难倒。从现有类型继承创建新类型。这种新类型不仅包含现有类型的所有成员(尽管私有成员被隐藏起来并且不可访问),而且更重要的是它复制了基类的接口。也就是说,基类对象接收的所有消息也能被派生类对象接收。根据类接收的消息,我们知道类的类型,因此派生类与基类是相同的类型。 + +在前面的例子中,“圆是形状”。这种通过继承的类型等价性是理解面向对象编程含义的基本门槛之一。因为基类和派生类都具有相同的基本接口,所以伴随此接口的必定有某些具体实现。也就是说,当对象接收到特定消息时,必须有可执行代码。如果继承一个类而不做其他任何事,则来自基类接口的方法直接进入派生类。这意味着派生类和基类不仅具有相同的类型,而且具有相同的行为,这么做没什么特别意义。 + +有两种方法可以区分新的派生类与原始的基类。第一种方法很简单:在派生类中添加新方法。这些新方法不是基类接口的一部分。这意味着基类不能满足你的所有需求,所以你添加了更多的方法。继承的这种简单而原始的用途有时是解决问题的完美解决方案。然而,还是要仔细考虑是否在基类中也要有这些额外的方法。这种设计的发现与迭代过程在面向对象程序设计中会经常发生。 + +尽管继承有时意味着你要在接口中添加新方法(尤其是在以**extends**关键字表示继承的Java中),但并非总需如此。第二种也是更重要地区分派生类和基类的方法是改变现有基类方法的行为,这被称为覆盖(overriding)。要想覆盖一个方法,只需要在派生类中重新定义这个方法即可。 + +### "是一个"与"像是一个"的关系 + +对于继承可能会引发争论:继承应该只覆盖基类的方法(不应该添加基类中没有的方法)吗?如果这样的话,基类和派生类就是相同的类型了,因为它们具有相同的接口。这会造成,你可以用一个派生类对象完全替代基类对象,这叫作"纯粹替代",也经常被称作"替代原则"。在某种意义上,这是一种处理继承的理想方式。我们经常把这种基类和派生类的关系称为是一个(is-a)关系,因为可以说"圆是一个形状"。判断是否继承,就看在你的类之间有无这种is-a关系。 + +有时你在派生类添加了新的接口元素,从而扩展接口。虽然新类型仍然可以替代基类,但是这种替代不完美,原因在于基类无法访问新添加的方法。这种关系称为像是一个(is-like-a)关系。新类型不但拥有旧类型的接口,而且包含其他方法,所以不能说新旧类型完全相同。 ![1545764820176](../images/1545764820176.png) -在前面的例子中,“圆是形状”。这种通过继承的类型等价是理解面向对象编程含义的基本网关之一。因为基类和派生类都具有相同的基本接口,所以必须有一些实现来支持该接口。也就是说,当对象接收到特定信息时,必须有可执行代码。如果继承一个类并且不做其他任何事情,则来自基类接口的方法直接进入派生类。这意味着派生类的对象不仅具有相同的类型,而且具有相同的行为,这并不特别有趣。有两种方法可以区分新派生类与原始基类。第一种方法很简单:向派生类添加全新的方法。这些新方法不是基类接口的一部分。这意味着基类没有按照你想要的那样多,所以你添加了更多的方法。继承的这种简单而原始的用途有时是解决问题的完美解决方案。然而,事先还是要仔细调查自己的基础类是否真的需要这些额外的方法。 +以空调为例,假设房间里已经安装好了制冷设备的控制器,即你有了控制制冷设备的接口。想象一下,现在空调坏了,你重新安装了一个既制冷又制热的热力泵。热力泵就像是一个(is-like-a)空调,但它可以做更多。因为当初房间的控制系统被设计成只能控制制冷设备,所以它只能与新对象(热力泵)的制冷部分通信。新对象的接口已经扩展了,现有控制系统却只知道原来的接口,一旦看到这个设计,你就会发现,作为基类的制冷系统不够一般化,应该被重新命名为"温度控制系统",也应该包含制热功能,这样的话,我们就可以使用替代原则了。上图反映了在现实世界中进行设计时可能会发生的事情。 + +当你看到替代原则时,很容易会认为纯粹替代是唯一可行的方式,并且使用纯粹替代的设计是很好的。但有些时候,你会发现必须得在派生(扩展)类中添加新方法(提供新的接口)。只要仔细审视,你可以很明显地区分两种设计方式的使用场合。 ## 多态 -我们在处理类的层次结构时,通常是定义对象的基类而不是对象本身。通过这种方式,我们可以编写出不局限于特定类型的代码。在上个“形状”的图例中,“方法”(method)操纵的是通用“形状”,而不关心它们是“圆”、“正方形”、“三角形”还是某种尚未定义的形状。所有的形状都可以被绘制、擦除和移动,因此“方法”向其中的任何代表“形状”的对象发送信息都不必担心对象如何处理信息。 +我们在处理类的层次结构时,通常把一个对象看成是它所属的基类,而不是把它当成具体类。通过这种方式,我们可以编写出不局限于特定类型的代码。在上个“形状”的例子中,“方法”(method)操纵的是通用“形状”,而不关心它们是“圆”、“正方形”、“三角形”还是某种尚未定义的形状。所有的形状都可以被绘制、擦除和移动,因此“方法”向其中的任何代表“形状”的对象发送消息都不必担心对象如何处理信息。 -这样代码不受添加的新类型影响,并且添加新类型是扩展面向对象程序以处理新情况的常用方法。 举个例子来说吧:你可以通过这个通用的“形状”基类来得到一个新的“五角星”形状得子类。通过派生新的子类来扩展设计的这种能力是封装变化的基本方法之一。 +这样的代码不会受添加的新类型影响,并且添加新类型是扩展面向对象程序以处理新情况的常用方法。 例如,你可以通过通用的“形状”基类派生出新的“五角形”形状的子类,而不需要修改通用"形状"基类的方法。通过派生新的子类来扩展设计的这种能力是封装变化的基本方法之一。 -这样的设计减少了程序的维护难度。我们把派生的对象类型统一看成是它本身的基础类型(“圆”是一种“形状”,“自行车”是“车”,“鸬鹚”也是“鸟”等等)。编译器(compiler)在编译时期无法精确的知道什么“形状”被擦除,哪一种“车”在行驶,或者是某种“鸟”在飞行。当程序接收这种信息之前程序员并不知道哪段代码会被执行。“擦除”的方法可以平等地应用到每一种可能的“形状”上。 +这种能力改善了我们的设计,且减少了软件的维护代价。如果我们把派生的对象类型统一看成是它本身的基础类型(“圆”当作“形状”,“自行车”当作“车”,“鸬鹚”当作“鸟”等等),编译器(compiler)在编译时期就无法准确地知道什么“形状”被擦除,哪一种“车”在行驶,或者是哪种“鸟”在飞行。这就是关键所在:当程序接收这种消息时,程序员并不想知道哪段代码会被执行。“绘图”的方法可以平等地应用到每种可能的“形状”上,形状会依据自身的具体类型执行恰当的代码。 -如果不需要考虑执行了哪部分代码,那我们就能添加一个新的不同执行方式的子类而不需要更改调用它的方法。那么编译器在不确定该执行哪部分代码时是怎么做的呢?举个例子,下图的 **BirdController** 对象和通用 **Bird** 对象中,**BirdController** 不知道 **Bird** 的确切类型却还能一起工作。从 **BirdController** 的角度来看,这是很方便的,因为它不需要特殊的代码来确定 **Bird** 工作的确切类型或行为。那么,在调用 **move()** 方法时是如何保证发生正确的行为(鹅走路、苍蝇或游泳、企鹅走路或游泳)的呢? +如果不需要知道执行了哪部分代码,那我们就能添加一个新的不同执行方式的子类而不需要更改调用它的方法。那么编译器在不确定该执行哪部分代码时是怎么做的呢?举个例子,下图的 **BirdController** 对象和通用 **Bird** 对象中,**BirdController** 不知道 **Bird** 的确切类型却还能一起工作。从 **BirdController** 的角度来看,这是很方便的,因为它不需要编写特别的代码来确定 **Bird** 对象的确切类型或行为。那么,在调用 **move()** 方法时是如何保证发生正确的行为(鹅走路、飞或游泳、企鹅走路或游泳)的呢? ![Bird-example](../images/1545839316314.png) -答案是继承的主要转折点:在传统意义上,编译器不能进行函数调用。由非 OOP 编译器生成的函数调用生成所谓的早期绑定,这个术语你可能从未听说过,因为你从未以其他方式考虑过。这意味着编译器生成对特定函数名的调用,该调用解析为要执行的代码的绝对地址。 +这个问题的答案,是面向对象程序设计的妙诀:在传统意义上,编译器不能进行函数调用。由非 OOP 编译器产生的函数调用会引起所谓的**早期绑定**,这个术语你可能从未听说过,不会想过其他的函数调用方式。这意味着编译器生成对特定函数名的调用,该调用会被解析为将执行的代码的绝对地址。 -通过继承,程序直到运行时才能确定代码的地址,因此当信息被发送到对象时,还需要其他一些方案。为了解决这个问题,面向对象语言使用后期绑定的概念。当向对象发送信息时,调用的代码直到运行时才确定。编译器确保方法存在,并对参数和返回值执行类型检查,但是它不知道要执行的确切代码。 +通过继承,程序直到运行时才能确定代码的地址,因此发送消息给对象时,还需要其他一些方案。为了解决这个问题,面向对象语言使用**后期绑定**的概念。当向对象发送信息时,被调用的代码直到运行时才确定。编译器确保方法存在,并对参数和返回值执行类型检查,但是它不知道要执行的确切代码。 -为了执行后期绑定,Java 使用一个特殊的代码位来代替绝对调用。这段代码使用对象中存储的信息来计算方法主体的地址(此过程在多态性章节中有详细介绍)。因此,每个对象的行为根据特定代码位的内容而不同。当你向对象发送信息时,该对象实际上确定如何处理该信息。在某些语言中,必须显式地授予方法后期绑定属性的灵活性。例如,C++ 使用虚拟关键字。在这些语言中,默认情况下方法没有动态绑定。在 Java 中,动态绑定是默认行为,不需要额外的关键字来生成多态性。 +为了执行后期绑定,Java 使用一个特殊的代码位来代替绝对调用。这段代码使用对象中存储的信息来计算方法主体的地址(此过程在多态性章节中有详细介绍)。因此,每个对象的行为根据特定代码位的内容而不同。当你向对象发送消息时,对象知道该如何处理这条消息。在某些语言中,必须显式地授予方法后期绑定属性的灵活性。例如,C++ 使用**virtual**关键字。在这些语言中,默认情况下方法不是动态绑定的。在 Java 中,动态绑定是默认行为,不需要额外的关键字来实现多态性。 -为了演示多态性,我们编写了一段代码,它忽略了类型的特定细节,只与基类对话。该代码与特定于类型的信息分离,因此更易于编写和更容易理解。而且,如果通过继承添加了一个新类型(例如,一个六边形),那么代码对于新类型的 Shape 就像对现有类型一样有效。因此,该程序是可扩展的。 +为了演示多态性,我们编写了一段代码,它忽略了类型的具体细节,只与基类对话。该代码与具体类型信息分离,因此更易于编写和理解。而且,如果通过继承添加了一个新类型(例如,一个六边形),那么代码对于新类型的 Shape 就像对现有类型一样有效。因此,该程序是可扩展的。 代码示例: @@ -167,7 +181,7 @@ void doSomething(Shape shape) { } ``` -此方法与任何 Shape 都相关,因此它独立于所绘制和擦除的对象的特定类型。此时程序的其他部分使用 `doSomething()` 方法: +此方法与任何 **Shape** 对话,因此它与所绘制和擦除的对象的具体类型无关。如果程序的其他部分使用 `doSomething()` 方法: ```java Circle circle = new Circle(); @@ -183,36 +197,35 @@ void doSomething(Shape shape) { ![shape-example](../images/1545841270997.png) -这实际是一个非常有用的编程技巧。分析下面这行代码: +这是一个非常令人惊奇的编程技巧。分析下面这行代码: ```java doSomething(circle); ``` -这里将 **Circle**(圆)句柄传递给一个本来期待 **Shape**(形状)句柄的方法。由于圆也是一种几何形状,所 -以 `doSomething(circle)` 能正确地执行。也就是说,`doSomething()` 能接受任意 **Shape** 的信息。这是完全安全和合乎逻辑的事情。 +当预期接收 **Shape** 的方法被传入了 **Circle**,会发生什么。由于 **Circle** 也是一种 **Shape**,所 +以 `doSomething(circle)` 能正确地执行。也就是说,`doSomething()` 能接收任意发送给 **Shape** 的消息。这是完全安全和合乎逻辑的事情。 -这种把子类当成其基类来处理的过程叫做“向上转型”(**Upcasting**)。在面向对象的编程里,经常利用这种方法来给程序解耦。再看下面的 `doSomething()` 代码示例: +这种把子类当成其基类来处理的过程叫做“向上转型”(**upcasting**)。在面向对象的编程里,经常利用这种方法来给程序解耦。再看下面的 `doSomething()` 代码示例: ```java shape.erase(); // ... shape.draw(); - ``` -我们可以看到程序并未这样表达:“如果你是一个 Circle ,就这样做;如果你是一个 Square,就那样做;等等”。若那样编写代码,就需检查 Shape 所有可能的类型,如圆、矩形等等。这显然是非常麻烦的,而且每次添加了一种新的 Shape 类型后,都要相应地进行修改。在这里,我们只需说:“你是一种几何形状,我知道你能将自己删掉,即 `erase()`;请自己采取具体行动,并控制所有的细节吧。” +我们可以看到程序并未这样表达:“如果你是一个 Circle ,就这样做;如果你是一个 Square,就那样做…...”。若那样编写代码,就需检查 Shape 所有可能的类型,如圆、矩形等等。这显然是非常麻烦的,而且每次添加了一种新的 Shape 类型后,都要相应地进行修改。在这里,我们只需说:“你是一种几何形状,我知道你能删掉 `erase()` 和绘制 `draw()` 你自己,去做吧,注意细节。” 尽管我们没作出任何特殊指示,程序的操作也是完全正确和恰当的。我们知道,为 Circle 调用`draw()` 时执行的代码与为一个 Square 或 Line 调用 `draw()` 时执行的代码是不同的。但在将 `draw()` 信息发给一个匿名 Shape 时,根据 Shape 句柄当时连接的实际类型,会相应地采取正确的操作。这非常神奇,因为当 Java 编译器为 `doSomething()` 编译代码时,它并不知道自己要操作的准确类型是什么。 尽管我们确实可以保证最终会为 Shape 调用 `erase()`、 `draw()`,但并不能确定特定的 Circle,Square 或者 Line 调用什么。最后,程序执行的操作却依然是正确的,这是怎么做到的呢? -将信息发给对象时,如果程序不知道接受的具体类型是什么,但最终执行是正确的,这就是对象的“多态性”(Polymorphism)。面向对象的程序设计语言是通过“动态绑定”的方式来实现对象的多态性的。编译器和运行期系统会负责对所有细节的控制;我们只需知道要做什么,以及如何利用多态性来更好的设计程序。 +发送消息给对象时,如果程序不知道接收的具体类型是什么,但最终执行是正确的,这就是对象的“多态性”(Polymorphism)。面向对象的程序设计语言是通过“动态绑定”的方式来实现对象的多态性的。编译器和运行时系统会负责对所有细节的控制;我们只需知道要做什么,以及如何利用多态性来更好地设计程序。 -## 单继承 +## 单继承结构 -自从 C++ 引入以来,OOP 问题变得尤为突出。是否所有的类都应该默认从一个基类继承呢?这个答案在 Java 中是肯定的。(实际上,除 C++ 以外的其他虚拟机语言也是这样。)在 Java 中这个最终的基类的名字就是 `Object`。 +自从 C++ 引入以来,一个OOP 问题变得尤为突出:是否所有的类都应该默认从一个基类继承呢?这个答案在 Java 中是肯定的(实际上,除 C++ 以外的几乎所有OOP语言中也是这样)。在 Java 中,这个最终的基类的名字就是 `Object`。 -Java 的单继承结构有很多好处。由于所有对象都有继承自一个公共接口,因此它们最终都属于同一个基本类型。相反的,对于 C++ 所使用的多继承的方案则是不保证所有的对象都属于同一个的基类。这种方案的限制更少一点。从向后兼容的角度看,多继承的方案更符合 C 的模型。 +Java 的单继承结构有很多好处。由于所有对象都具有一个公共接口,因此它们最终都属于同一个基本类型。相反的,对于 C++ 所使用的多继承的方案则是不保证所有的对象都属于同一个基类。从向后兼容的角度看,多继承的方案更符合 C 的模型,而且受限较少。 对于完全面向对象编程,我们必须要构建自己的层次结构,以提供与其他 OOP 语言同样的便利。我们经常会使用到新的类库和不兼容的接口。为了整合它们而花费大气力(有可能还要用上多继承)以获得 C++ 样的“灵活性”值得吗?如果从零开始,Java 这样的替代方案会是更好的选择。 @@ -223,23 +236,25 @@ Java 的单继承结构有很多好处。由于所有对象都有继承自一个 ## 集合 -通常,我们并不知道解决某个具体问题需要的对象数量,持续时间,以及对象的存储方式。那么我们如何知悉程序在创建时需要分配的内存空间呢? +通常,我们并不知道解决某个具体问题需要的对象数量和持续时间,以及对象的存储方式。那么我们如何知悉程序在运行时需要分配的内存空间呢? -在面向对象的设计中,问题的解决方案有些千篇一律:创建一个新类型的对象来引用、容纳其他的对象。当然,我们也可以使用多数编程语言都支持的“数组”(Array)。在 Java 中“集合”(Collection)的使用率更高。(也可称之为“容器”,但“集合”这个称呼更通用。) +在面向对象的设计中,问题的解决方案有些过于轻率:创建一个新类型的对象来引用、容纳其他的对象。当然,我们也可以使用多数编程语言都支持的“数组”(array)。在 Java 中“集合”(Collection)的使用率更高。(也可称之为“容器”,但“集合”这个称呼更通用。) “集合”这种类型的对象可以存储任意类型、数量的其他对象。它能根据需要自动扩容,我们不用关心过程是如何实现的。 -还好,一般优秀的 OOP 语言都会将“集合”作为其基础包。在 C++ 中,“集合”是其标准库的一部分。通常被称为 STL(Standard Template Library,标准模板库)。SmallTalk 有一套非常完整的集合库。同样,Java 的标准库中也提供许多现成的集合类。在一些库中,一两个集合泛型就能满足我们所有的需求了。在 Java 中不同的需求对应不同种类的集合 +还好,一般优秀的 OOP 语言都会将“集合”作为其基础包。在 C++ 中,“集合”是其标准库的一部分,通常被称为 STL(Standard Template Library,标准模板库)。SmallTalk 有一套非常完整的集合库。同样,Java 的标准库中也提供许多现成的集合类。 -在一些库中,一个或两个泛型集合被认为是对所有需求都足够好的,而在其他(Java)中,不同类型的集合对应不同的需求:常见的有 List,常用于保存序列;Map,也称为关联数组,常用于将对象与其他对象关联);Set,只能保存非重复的值;其他还包括如队列(Queue)、树(Tree)、堆(Stack)等等。从设计的角度来看,我们真正想要的是一个能够解决某个问题的集合。如果一种集合就满足所有需求,那么我们就不需要剩下的了。之所以选择集合有以下两个原因: +在一些库中,一两个集合泛型集合就能满足我们所有的需求了,而在其他(Java)中,不同类型的集合对应不同的需求:常见的有 List,常用于保存序列;Map,也称为关联数组,常用于将对象与其他对象关联);Set,只能保存非重复的值;其他还包括如队列(Queue)、树(Tree)、堆(Stack)等等。从设计的角度来看,我们真正想要的是一个能够解决某个问题的集合。如果一种集合就满足所有需求,那么我们就不需要剩下的了。之所以选择集合有以下两个原因: -1. 集合可以提供不同类型的接口和外部行为。堆栈、队列的应用场景和集合、列表不同,为我们解决问题提供了灵活的方案。 +1. 集合可以提供不同类型的接口和外部行为。堆栈、队列的应用场景和集合、列表不同,它们中的一种提供的解决方案可能比其他灵活得多。 -2. 不同的集合种类对应着不同的用途。例如,List 的两种基本类型:ArrayList 和 LinkedList。虽然两者具有相同接口和外部行为,但是在某些操作中它们的效率差别很大。在 ArrayList 中随机查找元素是很高效的,而 LinkedList 随机查找效率低下。反之,在 LinkedList 中插入元素的效率要比在 ArrayList 中高。由于底层数据结构的不同,每种集合类型在执行相同的操作时会表现出效率上的差异。 +2. 不同的集合对某些操作有不同的效率。例如,List 的两种基本类型:ArrayList 和 LinkedList。虽然两者具有相同接口和外部行为,但是在某些操作中它们的效率差别很大。在 ArrayList 中随机查找元素是很高效的,而 LinkedList 随机查找效率低下。反之,在 LinkedList 中插入元素的效率要比在 ArrayList 中高。由于底层数据结构的不同,每种集合类型在执行相同的操作时会表现出效率上的差异。 -通过对 List 接口的抽象,我们可以很容易的将 LinkedList 改为 ArrayList。在 Java 5 泛型出来之前,集合中保存的是通用类型 `Object`。Java 单继承的结构意味着所有元素都基于 `Object` 类,所以在集合中可以保存任何类型的数据。这也使得集合易于重用。要使用这样的集合时,我们先要往集合添加元素。由于 Java 5 版本前的集合只保存 `Object`,当我们往集合中添加元素时,元素便向上转型成了 `Object`,从而丢失自己原有的类型特性。这时我们再从对象中取出该元素时,元素的类型变成了 `Object`。那么我们该怎么将其转回原先具体的类型的?这里,我们使用了强制类型转换将其转为更具体的类型。这个过程称之为对象的“向下转型”。通过“向上转型”,我们知道“圆形”也是一种“形状”,这个过程是安全的。可是我们不能从“Object”看出其就是“圆圈”或“形状”,所以除非我们能确定元素的具体类型信息,否则“向下转型”就是不安全的。也不能说这样的错误就时完全危险的,因为一旦我们转化了错误的类型,程序就会运行出错,抛出“运行时异常”(RuntimeException)。(后面的章节会提到) 无论如何,我们要寻找一种在取出集合元素时确定其具体类型的方法。另外,每次取出元素都要做额外的“向下转型”对程序和程序员都是一种开销。以某种方式创建集合,以确认保存元素的具体类型,减少集合元素“向下转型”中的开销和可能出现的错误难道不好吗?这种解决方案就是:参数化类型机制(Parameterized Type Mechanism)。 +我们可以一开始使用 LinkedList 构建程序,在优化系统性能时改用 ArrayList。通过对 List 接口的抽象,我们可以很容易地将 LinkedList 改为 ArrayList。 -参数化类型机制可以使得编译器能够自动识别某个 `class` 的具体类型并正确地执行. 举个例子,对集合的参数化类型机制可以让其仅接受“形状”这种类型的元素,并以“形状”类型取出元素。Java 5 版本支持了参数化类型机制,称之为“泛型”(Generic)。泛型是 Java 5 的主要特性之一。举个例子,你可以按以下方式向 ArrayList 种添加 Shape(形状): +在 Java 5 泛型出来之前,集合中保存的是通用类型 `Object`。Java 单继承的结构意味着所有元素都基于 `Object` 类,所以在集合中可以保存任何类型的数据,易于重用。要使用这样的集合,我们先要往集合添加元素。由于 Java 5 版本前的集合只保存 `Object`,当我们往集合中添加元素时,元素便向上转型成了 `Object`,从而丢失自己原有的类型特性。这时我们再从集合中取出该元素时,元素的类型变成了 `Object`。那么我们该怎么将其转回原先具体的类型呢?这里,我们使用了强制类型转换将其转为更具体的类型,这个过程称为对象的“向下转型”。通过“向上转型”,我们知道“圆形”也是一种“形状”,这个过程是安全的。可是我们不能从“Object”看出其就是“圆形”或“形状”,所以除非我们能确定元素的具体类型信息,否则“向下转型”就是不安全的。也不能说这样的错误就是完全危险的,因为一旦我们转化了错误的类型,程序就会运行出错,抛出“运行时异常”(RuntimeException)。(后面的章节会提到) 无论如何,我们要寻找一种在取出集合元素时确定其具体类型的方法。另外,每次取出元素都要做额外的“向下转型”对程序和程序员都是一种开销。以某种方式创建集合,以确认保存元素的具体类型,减少集合元素“向下转型”的开销和可能出现的错误难道不好吗?这种解决方案就是:参数化类型机制(Parameterized Type Mechanism)。 + +参数化类型机制可以使得编译器能够自动识别某个 `class` 的具体类型并正确地执行。举个例子,对集合的参数化类型机制可以让集合仅接受“形状”这种类型的元素,并以“形状”类型取出元素。Java 5 版本支持了参数化类型机制,称之为“泛型”(Generic)。泛型是 Java 5 的主要特性之一。你可以按以下方式向 ArrayList 中添加 Shape(形状): ```java ArrayList shapes = new ArrayList<>(); @@ -248,47 +263,48 @@ Java 的单继承结构有很多好处。由于所有对象都有继承自一个 泛型的应用,让 Java 的许多标准库和组件都发生了改变。在本书的代码示例中,你也会经常看到泛型的身影。 -## 生命周期 +## 对象创建与生命周期 -我们在使用对象时要注意的一个关键问题就是对象的创建和销毁方式。每个对象的生存都需要资源,尤其是内存。为了资源的重复利用,当对象不再被使用时我们应该及时释放资源,清理内存。 +我们在使用对象时要注意的一个关键问题就是对象的创建和销毁方式。每个对象的生存都需要资源,尤其是内存。为了资源的重复利用,当对象不再被使用时,我们应该及时释放资源,清理内存。 在简单的编程场景下,对象的清理并不是问题。我们创建对象,按需使用,最后销毁它。然而,情况往往要比这更复杂: -假设,我们正在为机场设计一个空中交通管制的系统(该例也适用于仓库货柜管理、影带出租或者宠物寄养仓库系统)。第一步比较简单:创建一个用来保存飞机的集合,每当有飞机进入交通管制区域时,我们就创建一个“飞机”对象并将其加入到集合中,等到飞机离开时将其从这个集合中清除。与此同时,我们还需要一个记录飞机信息的系统。这些数据的重要性靠后,可以放在系统空闲的时候再后台处理。比如,我们要记录所有飞机中的小型飞机的的信息(比如飞行计划)。此时,我们又创建了第二个集合来记录所有小型飞机。 每当创建一个“飞机”对象的时候,将其放入第一个集合;若它属于小型飞机,也必须同时将其放入第二个集合里。 +假设,我们正在为机场设计一个空中交通管制的系统(该例也适用于仓库货柜管理、影带出租或者宠物寄养仓库系统)。第一步比较简单:创建一个用来保存飞机的集合,每当有飞机进入交通管制区域时,我们就创建一个“飞机”对象并将其加入到集合中,等到飞机离开时将其从这个集合中清除。与此同时,我们还需要一个记录飞机信息的系统,也许这些数据不像主要控制功能那样引人注意。比如,我们要记录所有飞机中的小型飞机的的信息(比如飞行计划)。此时,我们又创建了第二个集合来记录所有小型飞机。 每当创建一个“飞机”对象的时候,将其放入第一个集合;若它属于小型飞机,也必须同时将其放入第二个集合里。 现在问题开始棘手了:我们怎么知道何时该清理这些对象呢?当某一个系统处理完成,而其他系统可能还没有处理完成。这样的问题在其他的场景下也可能发生。在 C++ 程序设计中,当使用完一个对象后,必须明确将其删除,这就让问题变复杂了。 -这个对象的数据在哪?它的生命周期是怎么被控制的? 在 C++ 设计中采用的观点是效率第一,因此它将选择权交给了程序员。为了获得最大的运行时速度,程序员可以在编写程序时,通过将对象放在堆栈(Stack,有时称为自动变量或作用域变量)或静态存储区域(static storage area)中来确定内存占用和生存时间。这些区域的对象会被优先分配内存和释放。这种控制在某些情况下非常有用。 +对象的数据在哪?它的生命周期是怎么被控制的? 在 C++ 设计中采用的观点是效率第一,因此它将选择权交给了程序员。为了获得最大的运行时速度,程序员可以在编写程序时,通过将对象放在堆栈(Stack,有时称为自动变量或作用域变量)或静态存储区域(static storage area)中来确定内存占用和生存时间。这些区域的对象会被优先分配内存和释放。这种控制在某些情况下非常有用。 然而相对的,我们也牺牲了程序的灵活性。因为在编写代码时,我们必须要弄清楚对象的数量、生存时间还有类型。如果我们要用它来解决一个相当普遍的问题时(如计算机辅助设计、仓库管理或空中交通管制等),限制就太大了。 -第二种方法是在堆内存(Heap)中动态地创建对象。在这种方式下,直到程序运行我们才能确定需要创建的对象数量、生存时间和类型。什么时候需要,什么时候在堆内存中创建。 因为内存的占用是动态管理的,所以在运行时,在堆内存上开辟空间所需的时间可能比在栈内存上要长(但也不一定)。在栈内存开辟空间通常是一条将栈指针向下移动,另一条将其向后移动的汇编指令。开辟堆内存空间的时间取决于内存机制的设计。 +第二种方法是在堆内存(Heap)中动态地创建对象。在这种方式下,直到程序运行我们才能确定需要创建的对象数量、生存时间和类型。什么时候需要,什么时候在堆内存中创建。 因为内存的占用是动态管理的,所以在运行时,在堆内存上开辟空间所需的时间可能比在栈内存上要长(但也不一定)。在栈内存开辟和释放空间通常是一条将栈指针向下移动和一条将栈指针向上移动的汇编指令。开辟堆内存空间的时间取决于内存机制的设计。 -动态方法使一般的逻辑假设对象趋于复杂,因此额外的内存查找和释放的开销对对象的创建影响不大。(原文:*The dynamic approach makes the generally logical assumption that objects tend to be complicated, so the extra overhead of finding storage and releasing that storage will not have an important impact on the creation of an object.*)此外,更好的灵活性对于问题的解决至关重要。 +动态方法有这样一个一般性的逻辑假设:对象趋向于变得复杂,因此额外的内存查找和释放对对象的创建影响不大。(原文:*The dynamic approach makes the generally logical assumption that objects tend to be complicated, so the extra overhead of finding storage and releasing that storage will not have an important impact on the creation of an object.*)此外,更好的灵活性对于问题的解决至关重要。 -Java 使用动态内存分配。每次创建对象时,使用 `new` 关键字构建该对象的动态实例。这又带来另一个问题:对象的生存周期。较之堆内存,在栈内存中创建对象,编译器能够确定该对象的生命周期并自动销毁;然而如果你在堆内存创建对象的话,编译器是不知道它的生命周期的。在 C++ 中你必须以编程方式确定何时销毁对象,否则可能导致内存泄漏。Java 的内存管理是建立在垃圾收集器上的,它能自动发现对象不再被使用并释放内存。垃圾收集器的存在带来了极大的便利,它减少了我们之前必须要跟踪的问题和编写相关代码的数量。因此,垃圾收集器提供了更高级别的保险,以防止潜在的内存泄漏问题,这使得许多 C++ 项目没落。 +Java 使用动态内存分配。每次创建对象时,使用 `new` 关键字构建该对象的动态实例。这又带来另一个问题:对象的生命周期。较之堆内存,在栈内存中创建对象,编译器能够确定该对象的生命周期并自动销毁它;然而如果你在堆内存创建对象的话,编译器是不知道它的生命周期的。在 C++ 中你必须以编程方式确定何时销毁对象,否则可能导致内存泄漏。Java 的内存管理是建立在垃圾收集器上的,它能自动发现对象不再被使用并释放内存。垃圾收集器的存在带来了极大的便利,它减少了我们之前必须要跟踪的问题和编写相关代码的数量。因此,垃圾收集器提供了更高级别的保险,以防止潜在的内存泄漏问题,这个问题使得许多 C++ 项目没落。 -Java 的垃圾收集器被设计用来解决内存释放的问题(虽然这不包括对象清理的其他方面)。垃圾收集器知道对象什么时候不再被使用并且自动释放内存。结合单继承和仅可在堆中创建对象的机制,Java 的编码过程较之 C++ 要简单得多。我们所要做的决定和要克服的障碍也会少很多! +Java 的垃圾收集器被设计用来解决内存释放的问题(虽然这不包括对象清理的其他方面)。垃圾收集器知道对象什么时候不再被使用并且自动释放内存。结合单继承和仅可在堆中创建对象的机制,Java 的编码过程比用 C++ 要简单得多。我们所要做的决定和要克服的障碍也会少很多! ## 异常处理 -自编程语言被发明以来,程序的错误处理一直都是个难题,因为很难设计出一个好的错误处理方案。许多编程语言都忽略了这个问题,把这个问题丢给了程序类库的设计者。他们提出了在许多情况下都可以工作但很容易被规避的半途而废的措施,通常只需忽略错误。多数错误处理方案的主要问题是:它们依赖程序员之间的约定俗成而不是语言层面的限制。换句话说,如果程序员赶时间或没想起来,这些方案就很容易被忘记。 +自编程语言被发明以来,程序的错误处理一直都是个难题。因为很难设计出一个好的错误处理方案,所以许多编程语言都忽略了这个问题,把这个问题丢给了程序类库的设计者。他们提出了在许多情况下都可以工作但很容易被规避的半途而废的措施,通常只需忽略错误。多数错误处理方案的主要问题是:它们依赖程序员之间的约定俗成而不是语言层面的限制。换句话说,如果程序员赶时间或没想起来,这些方案就很容易被忘记。 -异常处理机制将程序错误直接交给编程语言甚至是操作系统。“异常”(Exception)是一个从出错点“抛出”(thrown)后能被特定类型的异常处理程序捕获(catch)的一个对象。它不会干扰程序的正常运行,仅当程序出错的时候才被执行。这让我们的编码更简单:不用再反复检查错误了。另外,如果 **throw** 的异常类型和 **catch** 的不符,则不会触发该条件下的异常处理程序。异常的发生是不会被忽略的,它终究会在某一时刻被处理。 +异常处理机制将程序错误直接交给编程语言甚至是操作系统。“异常”(Exception)是一个从出错点“抛出”(thrown)后能被特定类型的异常处理程序捕获(catch)的一个对象。它不会干扰程序的正常运行,仅当程序出错的时候才被执行。这让我们的编码更简单:不用再反复检查错误了。另外,异常不像方法返回的错误值和方法设置用来表示发生错误的标志位那样可以被忽略。异常的发生是不会被忽略的,它终究会在某一时刻被处理。 -最后,“异常机制”提供了一种可靠地从意外情况中恢复的方法,使得我们可以编写出更健壮的程序。有时你只要处理好抛出的异常情况并恢复程序的运行即可,无需退出。 +最后,“异常机制”提供了一种可靠地从错误状况中恢复的方法,使得我们可以编写出更健壮的程序。有时你只要处理好抛出的异常情况并恢复程序的运行即可,无需退出。 -Java 的异常处理机制在编程语言中脱颖而出。在 Java 中,异常处理从一开始就被连接起来,因此你不得不使用它。这是 Java 语言唯一接受的错误报告方法。如果没有编写适当的异常处理代码,你将会收到一条编译时错误消息。这种保证错误一致性的方法有时会让程序的错误处理变得更容易。值得注意的是,异常处理并不是面向对象的特性。尽管在面向对象的语言中异常通常由对象表示,但是在面向对象语言之前也存在异常处理。 +Java 的异常处理机制在编程语言中脱颖而出。Java 从一开始就内置了异常处理,因此你不得不使用它。这是 Java 语言唯一接受的错误报告方法。如果没有编写适当的异常处理代码,你将会收到一条编译时错误消息。这种有保障的一致性有时会让程序的错误处理变得更容易。值得注意的是,异常处理并不是面向对象的特性。尽管在面向对象的语言中异常通常由对象表示,但是在面向对象语言之前也存在异常处理。 ## 本章小结 -面向过程程序包含数据定义和函数调用。要找到程序的意图,你必须要在脑中建立一个模型,弄清函数调用和更底层的概念。这些程序往往容易混淆,因为表达式的术语更多地面向计算机而不是我们要解决的问题。这就是我们在设计程序时需要中间表示的原因。OOP 在面向过程编程的基础上增加了许多新的概念,所以有人会认为使用 Java 来编程会比同等的面向过程编程要更复杂。在这里,我想给大家一个惊喜:通常按照 Java 规范编写的程序会比面向过程程序更容易被理解。 +面向过程程序包含数据定义和函数调用。要找到程序的意图,你必须要在脑中建立一个模型,弄清函数调用和更底层的概念。这些程序令人困扰,因为它们的表示更多地面向计算机而不是我们要解决的问题,这就是我们在设计程序时需要中间表示的原因。OOP 在面向过程编程的基础上增加了许多新的概念,所以有人会认为使用 Java 来编程会比同等的面向过程编程要更复杂。在这里,我想给大家一个惊喜:通常按照 Java 规范编写的程序会比面向过程程序更容易被理解。 -你看到的是对象的概念,这些概念是站在“问题空间”的(而不是站在计算机角度的“解决方案空间”),以及发送给对象以指示该空间中的活动的信息。面向对象编程的一个优点是:设计良好的 Java 程序代码更容易被人阅读理解。由于 Java 类库的复用性,通常程序要写的代码也会少得多。 +你看到的是对象的概念,这些概念是站在“问题空间”的(而不是站在计算机角度的“解决方案空间”),以及发送消息给对象以指示该空间中的活动。面向对象编程的一个优点是:设计良好的 Java 程序代码更容易被人阅读理解。由于 Java 类库的复用性,通常程序要写的代码也会少得多。 OOP 和 Java 不一定适合每个人。评估自己的需求以及与现有方案作比较是很重要的。请充分考虑后再决定是不是选择 Java。如果在可预见的未来,Java 并不能很好的满足你的特定需求,那么你应该去寻找其他替代方案(特别是,我推荐看 Python)。如果你依然选择 Java 作为你的开发语言,我希望你至少应该清楚你选择的是什么,以及为什么选择这个方向。 +
diff --git a/docs/book/02-Installing-Java-and-the-Book-Examples.md b/docs/book/02-Installing-Java-and-the-Book-Examples.md index bd529d3..5dbc8b1 100644 --- a/docs/book/02-Installing-Java-and-the-Book-Examples.md +++ b/docs/book/02-Installing-Java-and-the-Book-Examples.md @@ -4,16 +4,16 @@ 现在,我们来为这次阅读之旅做些准备吧! -在开始学习 Java 之前,你必须要先安装好 Java 和本书的源代码示例。因为考虑到可能有“专门的初学者”从本书开始学习编程,所以我会仔细解释计算机命令行 Shell 的这个过程。 如果你已经有此方面的经验了,可以跳过这段安装说明。如果你对此处描述的任何术语或过程仍不清楚,还可以通过 Google 搜索找到答案。具体的问题或困难请试着在 StackOverflow 上提问。或者去 YouTube 看有没有相关的安装说明。 +在开始学习 Java 之前,你必须要先安装好 Java 和本书的源代码示例。因为考虑到可能有“专门的初学者”从本书开始学习编程,所以我会详细地教你如何使用命令行。 如果你已经有此方面的经验了,可以跳过这段安装说明。如果你对此处描述的任何术语或过程仍不清楚,还可以通过 [Google](https://google.com/) 搜索找到答案。具体的问题或困难请试着在 [StackOverflow](https://stackoverflow.com/) 上提问。或者去 [YouTube](https://youtube.com) 看有没有相关的安装说明。 ## 编辑器 -首先你需要安装一个编辑器来创建和修改本书用例里的 Java 代码。有可能你还需要一个编辑器来更改系统配置文件。 +首先你需要安装一个编辑器来创建和修改本书用例里的 Java 代码。有可能你还需要使用编辑器来更改系统配置文件。 -相比一些重量级的 IDE(Integrated Development Environments,开发集成环境)软件,如Eclipse、NetBeans和IntelliJ IDEA (译者注:做项目强烈推荐IDEA),编辑器是一种基础的运行程序的文本编辑器。如果你已经有了一个 IDE 用着还顺手,那就可以直接用了。为了方便后面的学习和统一下教学环境,我推荐大家使用 Atom 这个编辑器。大家可以在 [atom.io](http://atom.io) 网站下载。 - - Atom 是一个免费开源、易于安装且跨平台(支持 Window、Mac和Linux)的文本编辑器。内置支持 Java 文件。相比 IDE 的厚重,她比较轻量级,是学习本书的理想工具。Atom 包含了许多方便的编辑功能,相信你一定会爱上她!更多关于 Atom 使用的细节问题可以到她们的网站上。 +相比一些重量级的 IDE(Integrated Development Environments,集成开发环境),如Eclipse、NetBeans和IntelliJ IDEA (译者注:做项目强烈推荐IDEA),编辑器是一种更纯粹的文本编辑器。如果你已经有了一个用着顺手的 IDE,那就可以直接用了。为了方便后面的学习和统一下教学环境,我推荐大家使用 Atom 这个编辑器。大家可以在 [atom.io](https://atom.io) 上下载。 + +Atom 是一个免费开源、易于安装且跨平台(支持 Window、Mac和Linux)的文本编辑器。内置支持 Java 文件。相比 IDE 的厚重,它比较轻量级,是学习本书的理想工具。Atom 包含了许多方便的编辑功能,相信你一定会爱上它!更多关于 Atom 使用的细节问题可以到它的网站上寻找。 还有很多其他的编辑器。有一种亚文化的群体,他们热衷于争论哪个更好用!如果你找到一个你更喜欢的编辑器,换一种使用也没什么难度。重要的是,你要找一个用着舒服的。 @@ -22,23 +22,26 @@ 如果你之前没有接触过编程,那么有可能对 Shell(命令行窗口) 不太熟悉。shell 的历史可以追溯到早期的计算时代,当时在计算机上的操作是都通过输入命令进行的,计算机通过回显响应。所有的操作都是基于文本的。 -尽管和现在的图形用户界面相比,Shell 操作方式很原始。但是同时 shell 也为我们提供了许多有用的功特性。在学习本书的过程中,我们会经常使用到 Shell,包括现在这部分的安装,还有运行 Java 程序。 +尽管和现在的图形用户界面相比,Shell 操作方式很原始。但是同时 shell 也为我们提供了许多有用的功能特性。在学习本书的过程中,我们会经常使用到 Shell,包括现在这部分的安装,还有运行 Java 程序。 -Mac:单击聚光灯(屏幕右上角的放大镜图标),然后键入 `terminal`。单击看起来像小电视屏幕的应用程序(您也可以单击“return”)。这就启动了你的用户下的 shell 窗口。 +Mac:单击聚光灯(屏幕右上角的放大镜图标),然后键入 `terminal`。单击看起来像小电视屏幕的应用程序(你也可以单击“return”)。这就启动了你的用户下的 shell 窗口。 - windows:首先,通过目录打开 windows 资源管理器: - Windows 7: 单击屏幕左下角的“开始”图标,输入“explorer”后按回车键。 - Windows 8: 按 Windows+Q, 输入 “explorer” 后按回车键。 - Windows 10: 按 Windows+E 打开资源管理器,导航到所需目录,单击窗口左上角的“文件“选项卡,选择“打开 Window PowerShell”启动 Shell。 - Linux: 在 home 目录打开 Shell。 - Debian: 按 Alt+F2, 在弹出的对话框中输入“gnome-terminal” - Ubuntu: 在屏幕中鼠标右击,选择 “打开终端”, 或者按住 Ctrl+Alt+T - Redhat: 在屏幕中鼠标右击,选择 “打开终端” - Fedora: 按 Alt+F2,在弹出的对话框中输入“gnome-terminal” +windows:首先,通过目录打开 windows 资源管理器: + +- Windows 7: 单击屏幕左下角的“开始”图标,输入“explorer”后按回车键。 +- Windows 8: 按 Windows+Q,输入 “explorer” 后按回车键。 +- Windows 10: 按 Windows+E 打开资源管理器,导航到所需目录,单击窗口左上角的“文件“选项卡,选择“打开 Window PowerShell”启动 Shell。 + +Linux: 在 home 目录打开 Shell。 + +- Debian: 按 Alt+F2, 在弹出的对话框中输入“gnome-terminal” +- Ubuntu: 在屏幕中鼠标右击,选择 “打开终端”,或者按住 Ctrl+Alt+T +- Redhat: 在屏幕中鼠标右击,选择 “打开终端” +- Fedora: 按 Alt+F2,在弹出的对话框中输入“gnome-terminal” **目录** -目录是 Shell 的基础元素之一。目录用来保存文件和其他目录。目录就好比树的分支。如果书籍是您系统上的一个目录,并且它有两个其他目录作为分支,例如数学和艺术,那么我们就可以说你有一个书籍目录里,它包含数学和艺术两个子目录。注意:Windows 使用 `\` 而不是 `/` 来分隔路径。 +目录是 Shell 的基础元素之一。目录用来保存文件和其他目录。目录就好比树的分支。如果书籍是你系统上的一个目录,并且它有两个其他目录作为分支,例如数学和艺术,那么我们就可以说你有一个书籍目录,它包含数学和艺术两个子目录。注意:Windows 使用 `\` 而不是 `/` 来分隔路径。 **Shell基本操作** @@ -47,14 +50,14 @@ Mac:单击聚光灯(屏幕右上角的放大镜图标),然后键入 `ter ```bash 更改目录: cd <路径> cd .. 移动到上级目录 - pushd <路径> 记住来源的同时移动到其他目录,popd 返回上一个目录 + pushd <路径> 记住来源的同时移动到其他目录,popd 返回来源 目录列举: ls 列举出当前目录下所有的文件和子目录名(不包含隐藏文件), - 可以选择使用通配符 * 来缩小搜索的范围。 - 示例(1): 列举所有以“.java”结尾的文件,输入ls *.java - 示例(2): 列举所有以“F”开头,“.java”结尾的文件,输入ls F*.java + 可以选择使用通配符 * 来缩小搜索范围。 + 示例(1): 列举所有以“.java”结尾的文件,输入 ls *.java (Windows: dir *.java) + 示例(2): 列举所有以“F”开头,“.java”结尾的文件,输入ls F*.java (Windows: dir F*.java) -增加目录: +创建目录: Mac/Linux 系统:mkdir 示例:mkdir books Windows 系统:md @@ -68,9 +71,9 @@ Mac:单击聚光灯(屏幕右上角的放大镜图标),然后键入 `ter 移除目录: Mac/Linux 系统:rm -r - 示例:rm -r somefile.java + 示例:rm -r books Windows 系统:deltree - 示例:deltree somefile.java + 示例:deltree books 重复命令: !! 重复上条命令 示例:!n 重复倒数第n条命令 @@ -80,8 +83,8 @@ Mac:单击聚光灯(屏幕右上角的放大镜图标),然后键入 `ter Windows 系统:按 F7 键 文件解压: - Linux/Mac 都有命令行解压程序,您可以通过互联网为Windows安装命令行解压程序。 - 图形界面下(Windows 资源管理器、Mac Finder ,Linux Nautilus 或其他等效软件)右键单击该文件, + Linux/Mac 都有命令行解压程序 unzip,你可以通过互联网为 Windows 安装命令行解压程序 unzip。 + 图形界面下(Windows 资源管理器,Mac Finder,Linux Nautilus 或其他等效软件)右键单击该文件, 在 Mac 上选择“open”,在 Linux 上选择“extract here”,或在 Windows 上选择“extract all…”。 要了解关于 shell 的更多信息,请在维基百科中搜索 Windows shell,Mac/Linux用户可搜索 bash shell。 @@ -90,7 +93,7 @@ Mac:单击聚光灯(屏幕右上角的放大镜图标),然后键入 `ter ## Java安装 -为了安装了和运行代码示例,首先你必须安装 JDK 1.8。本书中采用的是 JDK 1.8版本。 +为了编译和运行代码示例,首先你必须安装 JDK(Java Development Kit,JAVA 软件开发工具包)。本书中采用的是 JDK 8。 **Windows** @@ -99,12 +102,13 @@ Mac:单击聚光灯(屏幕右上角的放大镜图标),然后键入 `ter 2. 在命令行提示符下输入下面的命令,等待片刻,结束后 Java 安装完成并自动完成环境变量设置。 ```bash - choco install jdk8`. + choco install jdk8 ``` **Macintosh** -Mac 系统自带的 Java 版本太老,为了确保本书的代码示例能被正确执行,你必须先更新它到 Java 8。我们需要管理员权限来运行下面的步骤: +Mac 系统自带的 Java 版本太老,为了确保本书的代码示例能被正确执行,你必须将它先更新到 Java 8。我们需要管理员权限来运行下面的步骤: + 1. 以下为 HomeBrew 的[安装说明](https://brew.sh/)。安装完成后执行命令 `brew update` 更新到最新版本 2. 在命令行下执行下面的命令来安装 Java。 @@ -125,7 +129,7 @@ Mac 系统自带的 Java 版本太老,为了确保本书的代码示例能被 * **Fedora/Redhat**: ```bash - su-c "yum install java-1.8.0-openjdk" + su-c "yum install java-1.8.0-openjdk"(注:执行引号内的内容就可以安装) ``` @@ -151,11 +155,11 @@ Java HotSpot(TM) 64-Bit Server VM (build 25.112-b15, mixed mode) 当 Java 安装完毕,下一步就是安装本书的代码示例了。安装步骤所有平台一致: -1. 在 [GitHub 仓库](https://github.com/BruceEckel/OnJava8-Examples/archive/master.zip)中下载本书代码示例 +1. 从 [GitHub 仓库](https://github.com/BruceEckel/OnJava8-Examples/archive/master.zip)中下载本书代码示例 2. 解压到你所选目录里。 -3. 使用 Windows 资源管理器, Mac Finder, or Linux 的 Nautilus 或其他等效工具浏览,在该目录下打开 bash 命令行。 +3. 使用 Windows 资源管理器,Mac Finder,Linux 的 Nautilus 或其他等效工具浏览,在该目录下打开 Shell。 4. 如果你在正确的目录中,你应该看到该目录中名为 gradlew 和 gradlew.bat 的文件,以及许多其他文件和目录。目录与书中的章节相对应。 -5. 在命令行中输入下面的命令运行: +5. 在shell中输入下面的命令运行: ```bash Windows 系统: @@ -169,10 +173,9 @@ Java HotSpot(TM) 64-Bit Server VM (build 25.112-b15, mixed mode) **注意**: 第一次运行 gradlew 命令时必须连接互联网。 - **Gradle基础任务** -本书构建的大量 Gradle 任务都可以自动运行。Gradle 设置使用约定大于配置的方式,简单设置即可具备高可用性。本书中“一起去骑行”的某些任务不适用于此或无法执行成功。以下是你通常会使用上的分级任务列表: +本书构建的大量 Gradle 任务都可以自动运行。Gradle 使用约定大于配置的方式,简单设置即可具备高可用性。本书中“一起去骑行”的某些任务不适用于此或无法执行成功。以下是你通常会使用上的 Gradle 任务列表: ```bash 编译本书中的所有 java 文件,除了部分错误示范的 @@ -181,13 +184,14 @@ Java HotSpot(TM) 64-Bit Server VM (build 25.112-b15, mixed mode) 编译并执行 java 文件(某些文件是库组件) gradlew run - 执行所有的单元测试(在本书学习中校验自己的代码是由正确) + 执行所有的单元测试(在本书第16章会有详细介绍) gradlew test - 编译且运行一个特别的示例程序 + 编译并运行一个具体的示例程序 gradlew <本书章节>:<示例名称> 示例:gradlew objects:HelloDate ``` +
diff --git a/docs/book/03-Objects-Everywhere.md b/docs/book/03-Objects-Everywhere.md index 23b6713..ee8f7dd 100644 --- a/docs/book/03-Objects-Everywhere.md +++ b/docs/book/03-Objects-Everywhere.md @@ -2,28 +2,29 @@ # 第三章 万物皆对象 -> 如果我们说不同的语言,我们会感觉到一个不同的世界!— Ludwig Wittgenstein (1889-1951) +> 如果我们说另外一种不同的语言,我们会发觉一个不同的世界!— Ludwig Wittgenstein (1889-1951) -相比 C++ ,Java 是一种更纯粹的面向对象编程语言。虽然它们都是混合语言,但在 Java 中,设计者们认为混合的作用并非像 C++ 那般重要。混合语言允许多种编程风格,这也是 C++ 支持向后兼容 C 的原因。正因为 C++ 是 C 语言的超集,所以它也同时包含了许多 C 语言的不良特性,这使得 C++ 在某种程度上过于复杂。 +相比 C++ ,Java 是一种更纯粹的面向对象编程语言。虽然它们都是混合语言,但在 Java 中,设计者们认为混合的作用并非像在 C++ 中那般重要。混合语言允许多种编程风格,这也是 C++ 支持向后兼容 C 的原因。正因为 C++ 是 C 语言的超集,所以它也同时包含了许多 C 语言不具备的特性,这使得 C++ 在某些方面过于复杂。 - Java 语言假设你已接触过面向对象编程。开始学习之前,我们需要将思维置于面向对象的世界。本章你将了解到 Java 语言的基本组成,学习 Java (几乎)万物皆对象的思想。 + Java 语言假设你只进行面向对象编程。开始学习之前,我们需要将思维置于面向对象的世界。本章你将了解到 Java 程序的基本组成,学习在 Java 中万物(几乎)皆对象的思想。 + ## 对象操纵 “名字代表什么?玫瑰即使不叫玫瑰,也依旧芬芳”。(引用自 莎士比亚,《罗密欧与朱丽叶》)。 所有的编程语言都会操纵内存中的元素。有时程序员必须要有意识地直接或间接地操纵它们。在 C/C++ 中,对象的操纵是通过指针来完成的。 -Java 利用万物皆对象的思想和独特的语法方式来简化问题。虽万物皆可为对象,但我们所操纵的标识符实际上是只对象的“引用” [^1]。 举例:我们可以将这种“引用”想象成电视(对象)和遥控器(引用)之间的关系。只要拥有对象的“引用”,就可以操纵该“对象”。换成白话,我们无需直接接触电视,就可通过遥控器自由地控制电视(对象)的频道和音量。此外,没有电视机,遥控器也可以单独存在。引申来说,仅仅因为你有一个“引用”并不意味着你必然有一个关联的“对象”。 +Java 利用万物皆对象的思想和单一一致的语法方式来简化问题。虽万物皆可为对象,但我们所操纵的标识符实际上只是对对象的“引用” [^1]。 举例:我们可以用遥控器(引用)去操纵电视(对象)。只要拥有对象的“引用”,就可以操纵该“对象”。换句话说,我们无需直接接触电视,就可通过遥控器(引用)自由地控制电视(对象)的频道和音量。此外,没有电视,遥控器也可以单独存在。就是说,你仅仅有一个“引用”并不意味着你必然有一个与之关联的“对象”。 -下面来创建一个 **String** 的引用,用于保存单词语句。代码示例: +下面来创建一个 **String** 引用,用于保存单词或语句。代码示例: ```java String s; ``` -这里我们仅仅只是创建了一个 **String** 对象的引用,而非对象。直接拿来使用会出现错误:因为此时你并没有给变量 `s` 赋值--附加任何引用的对象。通常更安全的做法是:在声明变量引用的同时初始化对象信息。代码示例: +这里我们只是创建了一个 **String** 对象的引用,而非对象。直接拿来使用会出现错误:因为此时你并没有给变量 `s` 赋值--指向任何对象。通常更安全的做法是:创建一个引用的同时进行初始化。代码示例: ```java String s = "asdf"; @@ -32,37 +33,39 @@ Java 利用万物皆对象的思想和独特的语法方式来简化问题。虽 Java 语法允许我们使用带双引号的文本内容来初始化字符串。同样,其他类型的对象也有相应的初始化方式。 + ## 对象创建 -“引用”用来连接“对象”。在 Java 中,通常我们使用`new`这个操作符来创建一个新的对象。`new`关键字代表:创建一个新的对象实例。所以,前面的代码实例我们也可以这样来表示: +“引用”用来关联“对象”。在 Java 中,通常我们使用`new`操作符来创建一个新对象。`new`关键字代表:创建一个新的对象实例。所以,我们也可以这样来表示前面的代码示例: ```java String s = new String("asdf"); ``` -以上的代码示例展示了字符串对象的创建过程,以及如何初始化生成字符串。Java 本身自带了许多现成的数据类型,在此基础之上我们还可以创建自己的数据类型。类型的创建是 Java 的基本操作。在本书后面的学习中将会接触到。 +以上展示了字符串对象的创建过程,以及如何初始化生成字符串。除了 **String** 类型以外,Java 本身自带了许多现成的数据类型。除此之外,我们还可以创建自己的数据类型。事实上,这是Java程序设计中的一项基本行为。在本书后面的学习中将会接触到。 + ### 数据存储 -那么, 程序在运行时是如何存储的呢?尤其是内存。下面我们就来形象地描述下, Java 中数据存储的5个不同的地方: +那么,程序在运行时是如何存储的呢?尤其是内存是怎么分配的。有5个不同的地方可以存储数据: -1. **寄存器** (Registers) 最快的保存区域,位于CPU内部 [^2]。然而,寄存器的数量十分有限,所以寄存器是根据需要由编译器分配。我们对其没有直接的控制权,也无法在自己的程序里找到寄存器存在的踪迹(另一方面,C/C++ 允许开发者向编译器建议寄存器的分配)。 +1. **寄存器** (Registers) 最快的存储区域,位于CPU内部 [^2]。然而,寄存器的数量十分有限,所以寄存器根据需求进行分配。我们对其没有直接的控制权,也无法在自己的程序里找到寄存器存在的踪迹(另一方面,C/C++ 允许开发者向编译器建议寄存器的分配)。 -2. **栈内存**(Stack) 存在于常规内存(RAM)区域中,可通过栈指针获得处理器的直接支持。栈指针下移创建新内存,上移释放该内存,顺序后进先出,速度仅次于寄存器。创建程序时,Java 编译器必须准确地知道栈内保存的所有数据的“长度”以及生命周期。栈内存的这种约束限制了程序的灵活性。因此,虽然在栈内存上存在一些 Java 数据,特别是对象引用,但 Java 对象本身却是保存在堆内存的。 +2. **栈内存**(Stack) 存在于常规内存 RAM (随机访问存储器,Random Access Memory)区域中,可通过栈指针获得处理器的直接支持。栈指针下移分配内存,上移释放内存,这是一种快速有效的内存分配方法,速度仅次于寄存器。创建程序时,Java 系统必须准确地知道栈内保存的所有项的生命周期。这种约束限制了程序的灵活性。因此,虽然在栈内存上存在一些 Java 数据,特别是对象引用,但 Java 对象却是保存在堆内存的。 -3. **堆内存**(Heap) 这是一种常规用途的内存池(也在 RAM区域),所有 Java 对象都存在于其中。与栈内存不同,编译器不需要知道对象必须在堆内存上停留多长时间。因此,用堆内存保存数据更具灵活性。创建一个对象时,只需用 `new` 命令实例化代码即可。执行这些代码时,数据会在堆内存里自动进行保存。这种灵活性是有代价的:分配和清理堆内存要比栈内存需要更多的时间(如果你甚至可以用 Java 在栈内存上创建对象,就像在C++ 中那样)。随着时间的推移,Java 的堆内存分配机制现已非常快,因此这不是一个值得关心的问题了。 +3. **堆内存**(Heap) 这是一种通用的内存池(也在 RAM区域),所有 Java 对象都存在于其中。与栈内存不同,编译器不需要知道对象必须在堆内存上停留多长时间。因此,用堆内存保存数据更具灵活性。创建一个对象时,只需用 `new` 命令实例化对象即可,当执行代码时,会自动在堆中进行内存分配。这种灵活性是有代价的:分配和清理堆内存要比栈内存需要更多的时间(如果可以用 Java 在栈内存上创建对象,就像在C++ 中那样的话)。随着时间的推移,Java 的堆内存分配机制现在已经非常快,因此这不是一个值得关心的问题了。 -4. **常量存储** (Constant storage) 常量值通常直接放在程序代码中,因为它们永远不会改变。如需严格保护,可考虑将它们置于只读存储器(ROM)中 [^3]。 +4. **常量存储** (Constant storage) 常量值通常直接放在程序代码中,因为它们永远不会改变。如需严格保护,可考虑将它们置于只读存储器 ROM (只读存储器,Read Only Memory)中 [^3]。 -5. **非 RAM 存储** (Non-RAM storage) 数据完全存在于程序之外,在程序未运行以及脱离程序控制后依然存在。两个主要的例子:(1)序列化对象:对象被转换为字节流,通常被发送到另一台机器;(2)持久化对象:对象被放置在磁盘上,即使程序终止,数据依然存在。这些存储的方式都是将对象转存于另一个介质中,并在需要时恢复到常规内存中。Java 为轻量级持久性提供支持。诸如 JDBC 和 Hibernate 之类的库为使用数据库存储和检索对象信息提供了更复杂的支持。 +5. **非 RAM 存储** (Non-RAM storage) 数据完全存在于程序之外,在程序未运行以及脱离程序控制后依然存在。两个主要的例子:(1)序列化对象:对象被转换为字节流,通常被发送到另一台机器;(2)持久化对象:对象被放置在磁盘上,即使程序终止,数据依然存在。这些存储的方式都是将对象转存于另一个介质中,并在需要时恢复成常规的、基于 RAM 的对象。Java 为轻量级持久化提供了支持。而诸如 JDBC 和 Hibernate 这些类库为使用数据库存储和检索对象信息提供了更复杂的支持。 ### 基本类型的存储 -有一组类型在 Java 中使用频率很高,这就是 Java 的基本类型。对于此类数据的存储我们需要特别对待。之所以说这么说,是因为它们的创建并不是通过 `new` 关键字来产生。通常 `new` 出来的对象都是保存在 **Heap** 内存中的, 用它来创建小、简单的基本类型的数据是不划算的。所以对于这些基本类型的创建方法, Java 使用了和 C/C++ 一样的策略。也就是说,不是使用 `new` 创建变量,而是使用一个“自动”变量。 这个变量容纳了具体的值,并置于栈内存中,能够更高效地存取。 +有一组类型在 Java 中使用频率很高,它们需要特殊对待,这就是 Java 的基本类型。之所以这么说,是因为它们的创建并不是通过 `new` 关键字来产生。通常 `new` 出来的对象都是保存在堆内存中的, 以此方式创建小而简单的变量往往是不划算的。所以对于这些基本类型的创建方法, Java 使用了和 C/C++ 一样的策略。也就是说,不是使用 `new` 创建变量,而是使用一个“自动”变量。 这个变量直接存储"值",并置于栈内存中,因此更加高效。 -Java 预设了每种基本类型的初始内存占用大小。 这些大小标准不会随着机器环境的变化而变化。这种不变性也是Java 的跨平台的一个原因。 +Java 确定了每种基本类型的内存占用大小。 这些大小不会像其他一些语言那样随着机器环境的变化而变化。这种不变性也是 Java 更具可移植性的一个原因。 | 基本类型 | 大小 | 最小值 | 最大值 | 包装类型 | | :------: | :------: | :------: | :------: | :------: | @@ -76,13 +79,13 @@ Java 预设了每种基本类型的初始内存占用大小。 这些大小标 | double | 64 bits |IEEE754 | IEEE754 | Double | | void | — | — | — | Void | -所有的数值类型都是有正/负符号的。布尔(boolean)类型的大小没有明确的规定,通常定义为采用文字 “true” 和 “false”。基本类型有自己对应的包装类型,如果你希望在堆内存里表示基本类型的数据,就需要用到它们的包装类。代码示例: +所有的数值类型都是有正/负符号的。布尔(boolean)类型的大小没有明确的规定,通常定义为取字面值 “true” 或 “false” 。基本类型有自己对应的包装类型,如果你希望在堆内存里表示基本类型的数据,就需要用到它们的包装类。代码示例: ```java char c = 'x'; Character ch = new Character(c); ``` -或者你也可以使用下面的形式 基本类型自动转换成包装类型(自动装箱): +或者你也可以使用下面的形式,基本类型自动转换成包装类型(自动装箱): ```java Character ch = new Character('x'); @@ -96,51 +99,53 @@ char c = ch; 个中原因将在以后的章节里解释。 + ### 高精数值的存储 -在 Java 中有两种类型的数据可用于高精度的计算。它们是 `BigInteger` 和 `BigDecimal`。尽管它们大致可以划归为“包装类型”,但是它们并没有相应的基本类型形式。 +在 Java 中有两种类型的数据可用于高精度的计算。它们是 `BigInteger` 和 `BigDecimal`。尽管它们大致可以划归为“包装类型”,但是它们并没有对应的基本类型。 -这两个类都有自己特殊的“方法”,对应于我们针对基本类型数值执行的操作。也就是说,能对 int 或 float 做的运算,在 BigInteger 和 BigDecimal 这里也同样可以做一样可以,只不过必须要通过调用它们的方法来实现而非运算符。此外,由于涉及到的计算量更多,所以运算速度会慢一些。诚然,我们牺牲了速度,但换来了精度。 +这两个类包含的方法提供的操作,与对基本类型执行的操作相似。也就是说,能对 int 或 float 做的运算,在 BigInteger 和 BigDecimal 这里也同样可以,只不过必须要通过调用它们的方法来实现而非运算符。此外,由于涉及到的计算量更多,所以运算速度会慢一些。诚然,我们牺牲了速度,但换来了精度。 BigInteger 支持任意精度的整数。可用于精确表示任意大小的整数值,同时在运算过程中不会丢失精度。 -BigDecimal 支持任意精度的定点数字。例如,可用它进行精确的币值计算。至于具体使用什么方法,更多详情,请参考 JDK 官方文档。 +BigDecimal 支持任意精度的定点数字。例如,可用它进行精确的货币计算。 +关于这两个类的详细信息,请参考 JDK 官方文档。 + ### 数组的存储 -许多编程语言都支持数组类型。在 C 和 C++ 中使用数组是危险的,因为那些数组只是内存块。如果程序访问了其内存块之外的数组或在初始化之前使用该段内存(常见编程错误),则结果是不可预测的。 +许多编程语言都支持数组类型。在 C 和 C++ 中使用数组是危险的,因为那些数组只是内存块。如果程序访问了内存块之外的数组或在初始化之前使用该段内存(常见编程错误),则结果是不可预测的。 -Java 的设计主要目标之一是安全性,因此许多困扰 C 和 C++ 程序员的问题不会在 Java 中再现。在 Java 中,数组使用前需要被初始化,并且不能访问数组长度以外数据。这种长度检查的代价是每个阵列都有少量的内存开销以及在运行时验证索引的额外时间,但是这种安全性的前提对于提高的生产率是值得的。(并且 Java 经常可以优化这些操作)。 - -当我们创建对象数组时,实际上是创建了一个数据的引用,并且每个引用的初始值都为 **null** 。在使用该数组之前,我们必须为每个引用分配一个对象 。如果我们尝试使用为**null**的引用,则会在运行时报告该问题。因此,在 Java 中就防止了数组操作的典型错误。 - -我们还可创建基本类型的数组。编译器通过将该数组的内存归零来保证初始化。本书稍后将详细介绍数组,特别是在数组章节中。 +Java 的设计主要目标之一是安全性,因此许多困扰 C 和 C++ 程序员的问题不会在 Java 中再现。在 Java 中,数组使用前需要被初始化,并且不能访问数组长度以外的数据。这种范围检查,是以每个数组上少量的内存开销及运行时检查下标的额外时间为代价的,但由此换来的安全性和效率的提高是值得的。(并且 Java 经常可以优化这些操作)。 +当我们创建对象数组时,实际上是创建了一个引用数组,并且每个引用的初始值都为 **null** 。在使用该数组之前,我们必须为每个引用指定一个对象 。如果我们尝试使用为 **null** 的引用,则会在运行时报错。因此,在 Java 中就防止了数组操作的常规错误。 +我们还可创建基本类型的数组。编译器通过将该数组的内存全部置零来保证初始化。本书稍后将详细介绍数组,特别是在数组章节中。 + ## 代码注释 -Java 中有两种类型的注释。第一种是传统的 C 风格的注释,以 `/*` 开头,可以跨越多行,到 `*/ ` 结束。**注意**,许多程序员在多行注释的每一行开头添加 `*`,所以你经常会看到: +Java 中有两种类型的注释。第一种是传统的 C 风格的注释,以 `/*` 开头,可以跨越多行,到 `*/ ` 结束。注意,许多程序员在多行注释的每一行开头添加 `*`,所以你经常会看到: ```java -/* This is a comment -* that continues -* across lines +/* 这是 +* 跨越多行的 +* 注释 */ ``` -但请记住, `/*` 和 `*/` 之间的内容都是被忽略的。所以你将其改为下面的风格也是没有区别的。 +但请记住, `/*` 和 `*/` 之间的内容都是被忽略的。所以你将其改为下面这样也是没有区别的。 ```java -/* This is a comment that -continues across lines */ +/* 这是跨越多 +行的注释 */ ``` -第二种注释形式来自 C++ 。它是单行注释,以 `//` 开头并一直持续到行结束。这种注释方便且常用,因为它很直观和简单。所以你经常看到: +第二种注释形式来自 C++ 。它是单行注释,以 `//` 开头并一直持续到行结束。这种注释方便且常用,因为直观简单。所以你经常看到: ```java // 这是单行注释 @@ -149,27 +154,27 @@ continues across lines */ ## 对象清理 -在一些编程语言中,管理存储的生命周期需要大量的工作。一个变量需要存续多久?如果我们想销毁它,应该什么时候去做呢?存储生命周期的混乱会导致许多错误,本小结将会向你介绍 Java 是如何通过释放存储来简化这个问题的。 +在一些编程语言中,管理变量的生命周期需要大量的工作。一个变量需要存活多久?如果我们想销毁它,应该什么时候去做呢?变量生命周期的混乱会导致许多bug,本小结向你介绍 Java 是如何通过释放存储来简化这个问题的。 ### 作用域 -大多数程序语言都有作用域的概念。这将确定在该范围内定义的名称的可见性和生存周期。在 C、C++ 和 Java 中,作用域是由大括号 `{}` 的位置决定的。下面是 Java 代码作用域的一个示例: +大多数程序语言都有作用域的概念。作用域决定了在该范围内定义的变量名的可见性和生存周期。在 C、C++ 和 Java 中,作用域是由大括号 `{}` 的位置决定的。例如: ```java { int x = 12; -// 仅 x 变量可用 -{ - int q = 96; -// x 和 q 变量皆可用 -} -// 仅 x 变量可用 -// 变量 q 不在作用域内 + // 仅 x 变量可用 + { + int q = 96; + // x 和 q 变量皆可用 + } + // 仅 x 变量可用 + // 变量 q 不在作用域内 } ``` -Java 的变量只有在其作用域内才可用。缩进使得 Java 代码更易于阅读。由于 Java 是一种自由形式的语言,额外的空格、制表符和回车并不会影响程序的生成结果。在 Java 中,你不能执行以下操作,即使这在 C 和 C++ 中是合法的: +Java 的变量只有在其作用域内才可用。缩进使得 Java 代码更易于阅读。由于 Java 是一种自由格式的语言,额外的空格、制表符和回车并不会影响程序的执行结果。在 Java 中,你不能执行以下操作,即使这在 C 和 C++ 中是合法的: ```java { @@ -180,50 +185,53 @@ Java 的变量只有在其作用域内才可用。缩进使得 Java 代码更易 } ``` -在上例中, Java 便编译器会在提示变量 x 已经被定义过了。因此,在 C/C++ 中可以于更大作用域中“隐藏”变量的能力在 Java 中是不被允许的。 因为 Java 的设计者认为这样的定义会混淆编程。 +在上例中, Java 编译器会在提示变量 x 已经被定义过了。因此,在 C/C++ 中将一个较大作用域的变量"隐藏"起来的做法,在 Java 中是不被允许的。 因为 Java 的设计者认为这样做会导致程序混乱。 + ### 对象作用域 -Java 对象与基本类型具有不同的生命周期。当我们使用 `new` 关键字来创建 Java 对象时,它的生命周期将会超出作用域的末尾。因此,下面这段代码示例 +Java 对象与基本类型具有不同的生命周期。当我们使用 `new` 关键字来创建 Java 对象时,它的生命周期将会超出作用域。因此,下面这段代码示例: ```java { String s = new String("a string"); } -// 作用域结束 +// 作用域终点 ``` -上例中,变量 s 的范围在标注的地方结束了。但是,引用的字符串对象依然还在占用内存。在这段代码中,变量 s 的唯一引用超出了作用域,因此它无法在作用域外被访问。在后面的章节中,你我们还会学习怎么在编程中传递和复制对象的引用。 +上例中,引用 s 在作用域终点就结束了。但是,引用 s 指向的字符串对象依然还在占用内存。在这段代码中,我们无法在这个作用域之后访问这个对象,因为唯一对它的引用 s 已超出了作用域的范围。在后面的章节中,我们还会学习怎么在编程中传递和复制对象的引用。 -只要程序需要, `new` 出来的对象就不会被销毁。 相比在 C++ 编码中操作内存可能会出现的诸多问题,这些困扰在 Java 中都不复存在了。在 C++ 中你不仅要确保对象的内存在你操作的范围内存在,还必须在完成后销毁对象。 +只要你需要, `new` 出来的对象就会一直存活下去。 相比在 C++ 编码中操作内存可能会出现的诸多问题,这些困扰在 Java 中都不复存在了。在 C++ 中你不仅要确保对象的内存在你操作的范围内存在,还必须在使用完它们之后,将其销毁。 -那么问题来了:我们在 Java 中并没有去主动清理这些对象,那么它是如何避免 C++ 中出现的内存泄漏和程序终止的问题呢?答案是:Java 的垃圾收集器会检查所有 `new` 出来的对象并判断哪些不再可达,继而释放那些被占用的内存。至此,我们不必再自己操作回收内存了。取而代之,只需简单创建对象即可。当其不再被需要时,能自行被垃圾收集器释放。垃圾回收机制有效防止了因程序员忘记释放内存而造成的“内存泄漏”问题。 +那么问题来了:我们在 Java 中并没有主动清理这些对象,那么它是如何避免 C++ 中出现的内存被填满从而阻塞程序的问题呢?答案是:Java 的垃圾收集器会检查所有 `new` 出来的对象并判断哪些不再可达,继而释放那些被占用的内存,供其他新的对象使用。也就是说,我们不必担心内存回收的问题了。你只需简单创建对象即可。当其不再被需要时,能自行被垃圾收集器释放。垃圾回收机制有效防止了因程序员忘记释放内存而造成的“内存泄漏”问题。 + ## 类的创建 ### 类型 -如果一切都是对象,那么我们用什么来表示对象类的具体展现和行为呢?顾名思义,你可能很自然地想到 `type` 关键字。但是,事实上大多数面向对象的语言都使用 `class` 关键字类来描述一种新的对象。 通常在 `class` 关键字的后面的紧跟类的的名称。如下代码示例: +如果一切都是对象,那么是什么决定了某一类对象的外观和行为呢?换句话说,是什么确定了对象的类型?你可能很自然地想到 `type` 关键字。但是,事实上大多数面向对象的语言都使用 `class` 关键字类来描述一种新的对象。 通常在 `class` 关键字的后面的紧跟类的的名称。如下代码示例: ```java - class ATypeName { +class ATypeName { // 这里是类的内部 } ``` -在上例中,我们介绍了如何创建一个新的类型,尽管这个类里只有一行注释。但是我们一样可以通过 `new` 关键字来创建一个对象。如下: +在上例中,我们引入了一个新的类型,尽管这个类里只有一行注释。但是我们一样可以通过 `new` 关键字来创建一个这种类型的对象。如下: ```java ATypeName a = new ATypeName(); ``` -到现在为止,我们还不能用这个对象来做什么事,比如发送一些有趣的信息啊。除非我们在这个类里定义一些方法。 +到现在为止,我们还不能用这个对象来做什么事(即不能向它发送任何有意义的消息),除非我们在这个类里定义一些方法。 + ### 属性 -当我们创建好了一个类之后,我们可以往类里存放两种类型的元素。方法(method)和属性(field)。类的属性可以是基本类型。如果类的属性是对象的话,那么必须要初始化该引用将其关联到一个实际的对象上(通过之前介绍的创建对象的方法)。每个对象都会为其属性保留独立的存储空间。通常,属性不再对象之间做共享。下面是一个包含部分属性的类的代码示例: +当我们创建好一个类之后,我们可以往类里存放两种类型的元素:方法(method)和属性(field)。类的属性可以是基本类型,也可以是引用类型。如果类的属性是对某个对象的引用,那么必须要初始化该引用将其关联到一个实际的对象上(通过之前介绍的创建对象的方法)。每个对象都有用来存储其属性的空间。通常,属性不在对象间共享。下面是一个具有某些属性的类的代码示例: ```java class DataOnly { @@ -233,13 +241,13 @@ class DataOnly { } ``` -除非持有数据,不然这个类不能做任何事。在此之前,我们可以通过下面的代码示例来创建它的对象: +这个类除了存储数据之外什么也不能做。但是,我们仍然可以通过下面的代码来创建它的一个对象: ```java DataOnly data = new DataOnly(); ``` -我们必须通过这个对象的引用来指定属性值。格式:对象名称.方法名称或成员名称。代码示例: +我们必须通过这个对象的引用来指定属性值。格式:对象名称.方法名称或属性名称。代码示例: ```java data.i = 47; @@ -253,10 +261,10 @@ class DataOnly { myPlane.leftTank.capacity = 100; ``` -你可以用这种方式嵌套许多对象(尽管这样的设计会带来混淆)。 - +你可以用这种方式嵌套许多对象(尽管这样的设计会带来混乱)。 + ### 基本类型默认值 如果类的成员变量(属性)是基本类型,那么在类初始化时,这些类型将会被赋予一个初始值。 @@ -264,9 +272,9 @@ class DataOnly { | 基本类型 | 初始值 | | :-----: |:-----: | | boolean | false | -| char | \u0000 | -| byte | 0 | -| short |0 | +| char | \u0000 (null) | +| byte | (byte) 0 | +| short |(short) 0 | | int | 0 | | long | 0L | | float | 0.0f | @@ -280,60 +288,60 @@ class DataOnly { int x; ``` -这里的变量 x 不会自动初始化为0,因而在使用变量 x 之前,程序员有责任主动地为其赋值(和 C 、C++ 一致)。如果我们忘记了这一步,在 Java 中将会提示我们“编译时错误,该变量尚未被初始化”。 这一点做的比 C++ 要更好,在后者中,编译器只是提示警告,而在 Java 中则直接报错。 +这里的变量 x 不会自动初始化为0,因而在使用变量 x 之前,程序员有责任主动地为其赋值(和 C 、C++ 一致)。如果我们忘记了这一步, Java 将会提示我们“编译时错误,该变量可能尚未被初始化”。 这一点做的比 C++ 更好,在后者中,编译器只是提示警告,而在 Java 中则直接报错。 ### 方法使用 -在许多语言(如 C 和 C++)中,术语函数(function)用于描述命名子程序。在 Java 中,我们使用术语方法(method)来表示“做某事的方式”。 +在许多语言(如 C 和 C++)中,使用术语 *函数* (function) 用来命名子程序。在 Java 中,我们使用术语 *方法*(method)来表示“做某事的方式”。 -在 Java 中,方法决定着对象能接收哪些信息。方法的基础部分包含名称、参数、返回类型、方法体。格式如: +在 Java 中,方法决定对象能接收哪些消息。方法的基本组成部分包括名称、参数、返回类型、方法体。格式如: ```java - [返回类型][方法名](/*参数列表*/){ - // 方法体 + [返回类型] [方法名](/*参数列表*/){ + // 方法体 } ``` #### 返回类型 -方法的返回类型表明了当你调用它时会返回的结果类型。参数列表则显示了可被传递到方法内部的参数类型及名称。方法的名称和参数列表被统称为**方法签名**(signature of the method)。签名作为方法的唯一性标识。 +方法的返回类型表明了当你调用它时会返回的结果类型。参数列表则显示了可被传递到方法内部的参数类型及名称。方法名和参数列表统称为**方法签名**(signature of the method)。签名作为方法的唯一标识。 Java 中的方法只能作为类的一部分创建。它只能被对象所调用 [^4],并且该对象必须有权限来执行调用。若对象调用错误的方法,则程序将在编译时报错。 -我们可以通过在对象名的后面跟上 `.` 符号+方法名及其参数来调用一个方法。代码示例: +我们可以像下面这样调用一个对象的方法: ```java [对象引用].[方法名](参数1, 参数2, 参数3); ``` -若方法不带参数,例如一个对象 `a` 的方法 `f` 不带参数并返回 **int** 型结果,我们可以如下表示。代码示例: +若方法不带参数,例如一个对象引用 `a` 的方法 `f` 不带参数并返回 **int** 型结果,我们可以如下表示: ```java int x = a.f(); ``` -上例中方法 `f` 的返回值必须兼容接收的变量 `x` 。这种调用方法的行为有时被称为向对象传递信息。面向对象编程可以被总结为:向对象传递信息。 +上例中方法 `f` 的返回值类型必须和变量 `x` 的类型兼容 。调用方法的行为有时被称为向对象发送消息。面向对象编程可以总结为:向对象发送消息。 + #### 参数列表 -方法参数列表指定传递给方法的信息。正如你可能猜到的,这些信息 —— 就像 Java 中的其他所有信息 —— 采用对象的形式。参数列表必须指定对象类型和每个对象的名称。同样,我们并没有直接处理对象,而是在传递对象引用 [^5] 。但是引用的类型必须是正确的。如果方法需要 String 参数,则必须传入 String,否则编译器将报错。 +方法参数列表指定了传递给方法的信息。正如你可能猜到的,这些信息就像 Java 中的其他所有信息 ,以对象的形式传递。参数列表必须指定每个对象的类型和名称。同样,我们并没有直接处理对象,而是在传递对象引用 [^5] 。但是引用的类型必须是正确的。如果方法需要 String 参数,则必须传入 String,否则编译器将报错。 ```java int storage(String s) { - return s.length() * 2; } ``` -此方法计算并返回某个字符串的长度。参数 `s` 的类型为 **String** 。将 字符串变量 s 传递给 `storage()` 后,我们可以将其视为任何其他对象一样 —— 我们可以想起传递信息。在这里,我们调用 `length()` 方法,它是一个 String 方法,返回字符串长度。字符串中每个字符的大小为16位或两个字节。您还可以看到 **return** 关键字,它执行两项操作。首先,它意味着“方法执行结束”。其次,如果方法有返回值,那么该值就位于 **return** 语句之后。这里,返回值是通过计算 +此方法计算并返回某个字符串所占的字节数。参数 `s` 的类型为 **String** 。将 s 传递给 `storage()` 后,我们可以把它看作和任何其他对象一样,可以向它发送消息。在这里,我们调用 `length()` 方法,它是一个 String 方法,返回字符串中的字符数。字符串中每个字符的大小为16位或2个字节。你还看到了 **return** 关键字,它执行两项操作。首先,它意味着“方法执行结束”。其次,如果方法有返回值,那么该值就紧跟 **return** 语句之后。这里,返回值是通过计算 ```java s.length() * 2 ``` -产生的。在方法中,我们可以返回任意的数据。如果我们不想方法返回什么数据,则可以通过给方法标识 `void` 来表明这是一个无需返回值的方法。 代码示例: +产生的。在方法中,我们可以返回任何类型的数据。如果我们不想方法返回数据,则可以通过给方法标识 `void` 来表明这是一个无需返回值的方法。 代码示例: ```java boolean flag() { @@ -353,65 +361,67 @@ void nothing2() { } ``` -当返回类型为 **void** 时, **return** 关键字仅用于退出方法,因此在方法结束处的 **return** 可被省略。我们可以随时从方法中返回。若方法返回类型为非 `void`,则编译器会强制返回相应类型的值。 +当返回类型为 **void** 时, **return** 关键字仅用于退出方法,因此在方法结束处的 **return** 可被省略。我们可以随时从方法中返回,但若方法返回类型为非 `void`,则编译器会强制我们返回相应类型的值。 -上面的描述可能会让你感觉程序只不过是一堆包含各种方法的对象,将对象作为方法参数来传递信息给其他的对象。从表面上来看的确如此。但在下一章的运算符中我们将会学习如何在方法中做出决策来完成更底层、详细的工作。对于本章,知道如何传递信息就够了。 +上面的描述可能会让你感觉程序只不过是一堆包含各种方法的对象,在这些方法中,将对象作为参数并发送消息给其他对象。大部分情况下确实如此。但在下一章的运算符中我们将会学习如何在方法中做出决策来完成更底层、详细的工作。对于本章,知道如何发送消息就够了。 ## 程序编写 -在看到第一个 Java 程序之前,我们还必须了解其他几个问题。 +在看到第一个 Java 程序之前,我们还必须理解其他几个问题。 #### 命名可见性 -命名控制在任何一门编程语言中都是一个问题。如果程序员在两个模块中使用相同的命名,那么如何区分这两个名称,并防止两个名称发生“冲突”呢?在 C 语言编程中这是很具有挑战性的,因为程序通常是一个无法管理的名称海洋。C++ 将函数嵌套在类中,它们不能与嵌套在其他类中的函数名冲突。然而,C++ 继续允许全局数据和全局函数,因此仍有可能发生冲突。为了解决这个问题,C++ 使用附加的关键字引入命名空间。 +命名控制在任何一门编程语言中都是一个问题。如果你在两个模块中使用相同的命名,那么如何区分这两个名称,并防止两个名称发生“冲突”呢?在 C 语言编程中这是很具有挑战性的,因为程序通常是一个无法管理的名称海洋。C++ 将函数嵌套在类中,所以它们不会和嵌套在其他类中的函数名冲突。然而,C++ 还是允许全局数据和全局函数,因此仍有可能发生冲突。为了解决这个问题,C++ 使用附加的关键字引入了*命名空间*。 -Java 采取了一种新的方法避免了以上这些问题:为一个库生成一个明确的名称,Java 创建者希望我们反向使用自己的网络域名,因为域名通常是唯一的。自从我的域名为 MindviewInc.com 开始,我就将我的 foibles 工具库命名为 com.mindviewinc.utility.foibles。根据你的域名的反向信息,`.`代表着一个子目录。 +Java 采取了一种新的方法避免了以上这些问题:为一个类库生成一个明确的名称,Java 创建者希望我们反向使用自己的网络域名,因为域名通常是唯一的。因此我的域名是 MindviewInc.com,所以我将我的 foibles 类库命名为 com.mindviewinc.utility.foibles。反转域名后,`.` 用来代表子目录的划分。 -在 Java 1.0 和 Java 1.1 中,域扩展 com,edu,org,net 等按惯例大写,因此类库中会出现这样类似的名称:Com.mindviewinc.utility.foibles。然而,在 Java 2 的开发过程中,他们发现这会导致问题,所以现在整个包名都是小写的。此机制意味着所有文件都自动存在于自己的命名空间中,文件中的每个类都具有唯一标识符。这样,该语言可以防止名称冲突。 +在 Java 1.0 和 Java 1.1 中,域扩展名 com,edu,org,net 等按惯例大写,因此类库中会出现这样类似的名称:Com.mindviewinc.utility.foibles。然而,在 Java 2 的开发过程中,他们发现这会导致问题,所以现在整个包名都是小写的。此机制意味着所有文件都自动存在于自己的命名空间中,文件中的每个类都具有唯一标识符。这样,Java 语言可以防止名称冲突。 -使用反向 URL 是一种新的命名空间方法,在此之前尚未有其他语言这么做过。Java 中有许多这些“创造性”地解决问题的方法。正如你想象,如果我们未经测试就添加一个功能并用于生产,那么在将来发现该功能的问题再想纠正,通常为时已晚。(有些问题错误得足以从语言中删除。) +使用反向 URL 是一种新的命名空间方法,在此之前尚未有其他语言这么做过。Java 中有许多这些“创造性”地解决问题的方法。正如你想象,如果我们未经测试就添加一个功能并用于生产,那么在将来发现该功能的问题再想纠正,通常为时已晚(有些错误太严重了就得从语言中删除新功能。) -使用反向 URL 将命名空间与文件路径相关联不会导致BUG,但它却给源代码管理带来麻烦。例如在 `com.mindviewinc.utility.foiles` 这样的目录结构中,我们创建了 `com`、`mindviewinc` 空目录。它们存在的唯一目的就是用来表示这个反向的 URL。 +使用反向 URL 将命名空间与文件路径相关联不会导致BUG,但它却给源代码管理带来麻烦。例如在 `com.mindviewinc.utility.foibles` 这样的目录结构中,我们创建了 `com`、`mindviewinc` 空目录。它们存在的唯一目的就是用来表示这个反向的 URL。 -这种方式似乎为我们在编写 Java 程序中的某个问题打开了大门。空目录填充了深层次结构,它们不仅用于表示反向 URL,还用于捕获其他信息。这些长路径基本上用于存储有关目录中的内容的数据。如果你希望以最初设计的方式使用目录,这种方法可以从“令人沮丧”到“令人抓狂”,对于产生的 Java 代码,你基本上不得不使用专门为此设计的 IDE 来管理代码。例如 NetBeans,Eclipse 或 IntelliJ IDEA。实际上,这些 IDE 都为我们管理和创建深度空目录层次结构。 +这种方式似乎为我们在编写 Java 程序中的某个问题打开了大门。空目录填充了深层次结构,它们不仅用于表示反向 URL,还用于捕获其他信息。这些长路径基本上用于存储有关目录中的内容的数据。如果你希望以最初设计的方式使用目录,这种方法可以从“令人沮丧”到“令人抓狂”,对于生产级的 Java 代码,你必须使用专门为此设计的 IDE 来管理代码。例如 NetBeans,Eclipse 或 IntelliJ IDEA。实际上,这些 IDE 都为我们管理和创建深层次空目录结构。 -对于这本书中的例子,我不想让深层次的层次结构给你的学习带来额外的麻烦,这实际上需要你在开始之前学习熟悉一种重量级的 IDE。所以,我们的每个章节的示例都位于一个浅的子目录中,以章节标题为名。这导致我偶尔会与遵循深度层次方法的工具发生冲突。 +对于这本书中的例子,我不想让深层次结构给你的学习带来额外的麻烦,这实际上需要你在开始之前学习熟悉一种重量级的 IDE。所以,我们的每个章节的示例都位于一个浅的子目录中,以章节标题为名。这导致我偶尔会与遵循深层次方法的工具发生冲突。 #### 使用其他组件 -无论何时在程序中使用预定义的类,编译器都必须找到该类。在一般情况下,该类已存在于被调用的源代码文件中。此时我们使用该类 —— 即使该类未在文件中稍后定义(Java 消除了所谓的“前向引用”问题)。而那些存在于其他文件中的类怎么样?你可能认为编译器应该足够智能去找到它,但这样是有问题的。想象一下,假如你要使用某个类,但目录中存在多个同名的类(可能用途不同)。或者更糟糕的是,假设你正在编写程序,并且在构建它时,你将向库中添加一个与现有类名称冲突的新类。 +无论何时在程序中使用预先定义好的类,编译器都必须找到该类。最简单的情况下,该类存在于被调用的源代码文件中。此时我们使用该类 —— 即使该类在文件的后面才会被定义(Java 消除了所谓的“前向引用”问题)。而如果一个类位于其他文件中,又会怎样呢?你可能认为编译器应该足够智能去找到它,但这样是有问题的。想象一下,假如你要使用某个类,但目录中存在多个同名的类(可能用途不同)。或者更糟糕的是,假设你正在编写程序,在构建过程中,你想将某个新类添加到类库中,但却与已有的类名称冲突。 -要解决此问题,你必须通过使用 **import** 关键字来告诉 Java 编译器具体要使用的类。**import** 表示编译器引入一个包,它是一个类库。(在其他语言中,库可以包含函数和数据以及类,但请记住,Java 中的所有活动都在类中进行。)大多数时候,我们都在使用 Java 标准库中的组件。有了这些,你不用担心长的反向域名。你只用说,例如: +要解决此问题,你必须通过使用 **import** 关键字来告诉 Java 编译器具体要使用的类。**import** 指示编译器导入一个包,也就是一个类库(在其他语言中,一个库不仅包含类,还可能包括函数和数据,但请记住 Java 中的所有代码都必须写在类里)。大多数时候,我们都在使用 Java 标准库中的组件。有了这些构件,你就不必写一长串的反转域名。例如: ```java import java.util.ArrayList; ``` -上例可以告诉编译器使用位于标准库 util 下的 ArrayList 类。在 util 包含许多类,我们可以使用通配符 `*` 来导入其中部分类,无需显式导入。代码示例: +上例可以告诉编译器使用位于标准库 **util** 下的 ArrayList 类。但是,**util** 中包含许多类,我们可以使用通配符 `*` 来导入其中部分类,而无需显式得逐一声明这些类。代码示例: ```java import java.util.*; ``` -本书中的示例很小,为简单起见,我们通常会使用 `.*` 形式略过导入。然而,许多教程书籍都会要求程序员单独导入每个类。 - +本书中的示例很小,为简单起见,我们通常会使用 `.*` 形式略过导入。然而,许多教程书籍都会要求程序员逐一导入每个类。 + #### static关键字 -类是对象的外观及行为方式的描述。通常只有在使用 `new` 关键字之后程序才能被分配存储空间以及使用其方法。这种方式在两种情况下是不足的。 +类是对象的外观及行为方式的描述。通常只有在使用 `new` 创建那个类的对象后,数据存储空间才被分配,对象的方法才能供外界调用。这种方式在两种情况下是不足的。 -1. 有时你只需要为特定字段分配一个共享存储空间,无论该类创建了多少个对象,或者即使没有创建任何对象; +1. 有时你只想为特定字段(注:也称为属性、域)分配一个共享存储空间,而不去考虑究竟要创建多少对象,甚至根本就不创建对象。 -2. 创建一个与此类本身任何对象无关的方法。也就是说,即使没有创建对象,也能调用该方法。 +2. 创建一个与此类的任何对象无关的方法。也就是说,即使没有创建对象,也能调用该方法。 -**static** 关键字(从 C++ 采用)就符合我们的要求。当我们说某些东西是静态的时,它意味着该字段或方法不依赖于任何特定的对象实例 。 即使我们从未创建过该类的对象,也可以调用其静态方法或访问静态字段。相反,对于普通的非静态字段和方法,我们必须要先创建一个对象并使用该对象来访问该字段或方法,因为非静态字段和方法必须与替对象关联 [^6] 。 +**static** 关键字(从 C++ 采用)就符合上述两点要求。当我们说某个事物是静态时,就意味着该字段或方法不依赖于任何特定的对象实例 。 即使我们从未创建过该类的对象,也可以调用其静态方法或访问静态字段。相反,对于普通的非静态字段和方法,我们必须要先创建一个对象并使用该对象来访问字段或方法,因为非静态字段和方法必须与特定对象关联 [^6] 。 -一些面向对象的语言使用类数据(class data)和类方法(class method)这样的术语来表述静态。静态的数据意味着该数据和方法仅存在于类中,而非类的任何实例对象中。有时 Java 文献也使用这些术语。我们可以通过在类的属性或方法前添加 `static` 修饰来表示这是一个静态属性或静态方法。 代码示例: +一些面向对象的语言使用类数据(class data)和类方法(class method),表示静态数据和方法只是作为类,而不是类的某个特定对象而存在的。有时 Java 文献也使用这些术语。 + +我们可以在类的属性或方法前添加 `static` 关键字来表示这是一个静态属性或静态方法。 代码示例: ```java class StaticTest { @@ -426,15 +436,15 @@ StaticTest st1 = new StaticTest(); StaticTest st2 = new StaticTest(); ``` -`st1.i` 和 `st2.i` 的值都是 47,因为它们属于同一段内存。引用静态变量有两种方法。在前面的示例中,我们可以通过一个对象来命名它;例如,`st2.i`。同时,你也可以通过它的类名直接调用它(这是非静态成员不能执行的操作): +`st1.i` 和 `st2.i` 指向同一块存储空间,因此它们的值都是 47。引用静态变量有两种方法。在前面的示例中,我们通过一个对象来去定位它,例如 `st2.i`。我们也可以通过类名直接引用它,这种方式对于非静态成员则不行: ```java -StaticTest.i ++; +StaticTest.i++; ``` -`++` 运算符将会使变量结果 + 1。此时 `st1.i` 和 `st2.i` 的值就变成了 48 了。 +`++` 运算符将会使变量结果 + 1。此时 `st1.i` 和 `st2.i` 的值都变成了 48。 -使用类名直接引用静态变量的首选方法,因为它强调了变量的静态属性。类似的逻辑也适用于静态方法。我们可以通过对象引用静态方法,就像使用任何方法一样,也可以使用特殊的附加语法 classname.method()来直接调用静态属性或方法 [^7]。 代码示例: +使用类名直接引用静态变量是首选方法,因为它强调了变量的静态属性。类似的逻辑也适用于静态方法。我们可以通过对象引用静态方法,就像使用任何方法一样,也可以通过特殊的语法方式 `Classname.method()` 来直接调用静态属性或方法 [^7]。 代码示例: ```java class Incrementable { @@ -444,7 +454,7 @@ class Incrementable { } ``` -上例中 `Incrementable` 类调用静态方法 `increment()`。后者再使用 `++` 运算符递增静态变量 **int** `i`。我们依然可以先实例化对象再调用该方法。 代码示例: +上例中,`Incrementable` 的 `increment()` 方法通过 `++` 运算符将静态数据 `i` 加1。我们依然可以先实例化对象再调用该方法。 代码示例: ```java Incrementable sf = new Incrementable(); @@ -457,14 +467,13 @@ sf.increment(); Incrementable.increment(); ``` -相比非静态的对象,`static` 属性改变了创建数据的方式。同样,当 `static` 关键字修饰方法时,它允许我们无需创建对象就可以直接通过类的引用来调用该方法。正如我们所知,`static` 关键字的这些特性对于应用程序入口点的 main() 方法尤为重要。 -应用于字段的 `static` 肯定会更改创建数据的方式 —— `static` 针对每个类和非 `static` 针对每个对象。当应用于方法时,`static` 允许您在不创建对象的情况下调用该方法。正如您将看到的,在定义作为运行应用程序入口点的main()方法时,这是非常重要的。 - +相比非静态的对象,`static` 属性改变了数据创建的方式。同样,当 `static` 关键字修饰方法时,它允许我们无需创建对象就可以直接通过类的引用来调用该方法。正如我们所知,`static` 关键字的这些特性对于应用程序入口点的 `main()` 方法尤为重要。 + ## 小试牛刀 -最后,我们来开始编写第一个完整的程序。我们使用 Java 标准库来展示一个字符串和日期。 +最后,我们开始编写第一个完整的程序。我们使用 Java 标准库中的 **Date** 类来展示一个字符串和日期。 ```java @@ -480,31 +489,31 @@ public class HelloDate { ``` -在这本书中,代码块的第一行,我将使用注释行,其中包含文件的路径信息(使用本章的目录名对象),后跟文件名。我的工具可以根据这些信息自动提取和测试书籍的代码,你也可以通过参考第一行注释信息轻松地在 Github 库中找到相应的代码示例。 +在这本书中,所有代码示例的第一行都是注释行,其中包含文件的路径信息(比如本章的目录名是 **objects**),后跟文件名。我的工具可以根据这些信息自动提取和测试书籍的代码,你也可以通过参考第一行注释轻松地在 Github 库中找到对应的代码示例。 -如果你想在代码中使用到一些额外的库,那么你需要在程序文件的开始处使用 **import** 关键字来导入它们。之所以说是额外的,因为有一些库已经默认自动包含到每个文件里了。例如:`java.lang` 包。 +如果你想在代码中使用一些额外的类库,那么就必须在程序文件的开始处使用 **import** 关键字来导入它们。之所以说是额外的,因为有一些类库已经默认自动导入到每个文件里了。例如:`java.lang` 包。 -现在打开你的浏览器在 [Oracle](https://www.oracle.com/) 上查看文档。如果你还没有在 [Oracle](https://www.oracle.com/) 网站上下载 JDK 文档,那就趁现在 [^8] 。查看包列表,你会看到 Java 附带的所有不同的类库。 +现在打开你的浏览器在 [Oracle](https://www.oracle.com/) 上查看文档。如果你还没有从 [Oracle](https://www.oracle.com/) 网站上下载 JDK 文档,那现在就去 [^8] 。查看包列表,你会看到 Java 附带的所有不同的类库。 -选择 `java.lang`。这里显示的是该库中所有类的列表。由于 `java.lang` 隐式包含在每个 Java代码文件中,因此这些类是自动可用的。`java.lang` 中没有列出 **Date** 类,所以我们必须将其导入库才能使用它。如果你不清楚某个类名或者想查看所有的类,可以在 Java 文档中选择“Tree”。 +选择 `java.lang` ,你会看到该库中所有类的列表。由于 `java.lang` 隐式包含在每个 Java代码文件中,因此这些类是自动可用的。`java.lang` 类库中没有 **Date** 类,所以我们必须导入其他的类库(即 Date 所在的类库)。如果你不清楚某个类所在的类库或者想查看类库中所有的类,那么可以在 Java 文档中选择 “Tree” 查看。 -现在,我们可以找到 Java 附带的每个类。使用浏览器的“查找”功能查找 “Date”。搜索结果中将会列出 java.util.Date,显而易见,它在 util 库中,所以我们必须导入 java.util.* 才能使用 Date。 +现在,我们可以找到 Java 附带的每个类。使用浏览器的“查找”功能查找 **Date**,搜索结果中将会列出 **java.util.Date**,我们就知道了 **Date** 在 **util** 库中,所以必须导入 **java.util.*** 才能使用 **Date**。 -如果你在文档中选择 `java.lang`,然后选择 **System**,你会看到 System 类中有几个字段,如果你选择 `out`,你会发现它是一个静态的 **PrintStream** 对象。 所以,即使我们没有使用 new 创建, `out` 对象就已经存在并可以使用。 `out` 对象可以执行的操作取决于 PrintStream 。 其在文档中的描述中显示为超链接,如果单击该链接,我们将可以看到 PrintStream 所对应的方法列表。(更多详情,将在本书后面介绍。) 现在我们重点说的是 println() 这个方法。 它的作用是 “将信息输出到控制台,并以换行符结束。” 既然如此,我们可以这样编码来输出信息到控制台。 代码示例: +如果你在文档中选择 **java.lang**,然后选择 **System**,你会看到 **System** 类中有几个字段,如果你选择了 **out**,你会发现它是一个静态的 **PrintStream** 对象。 所以,即使我们不使用 **new** 创建, **out** 对象就已经存在并可以使用。 **out** 对象可以执行的操作取决于它的类型: **PrintStream** ,其在文档中是一个超链接,如果单击该链接,我们将可以看到 **PrintStream** 对应的方法列表(更多详情,将在本书后面介绍)。 现在我们重点说的是 **println()** 这个方法。 它的作用是 “将信息输出到控制台,并以换行符结束”。既然如此,我们可以这样编码来输出信息到控制台。 代码示例: ```java System.out.println("A String of things"); ``` -每个 java 源文件中允许有多个类。与此同时,源文件的名称必须要和其中一个类名相同,否则编译器将会报错。每个独立的程序应该包含一个 main 方法作为程序运行的入口。其方法签名和返回类型如下。代码示例: +每个 java 源文件中允许有多个类。同时,源文件的名称必须要和其中一个类名相同,否则编译器将会报错。每个独立的程序应该包含一个 `main()` 方法作为程序运行的入口。其方法签名和返回类型如下。代码示例: ```java public static void main(String[] args) { } ``` -关键字 `public` 表示方法可以被外界访问到。( 更多详情将在 **隐藏实现** 章节讲到) -main() 方法的参数是一个 字符串(**String**) 数组。 参数 `args` 并没有在当前的程序中使用到,但是 Java 虚拟机强制要求必须要有。 这是因为它们被用于保存命令行中的参数。 +关键字 **public** 表示方法可以被外界访问到。( 更多详情将在 **隐藏实现** 章节讲到) +**main()** 方法的参数是一个 字符串(**String**) 数组。 参数 **args** 并没有在当前的程序中使用到,但是 Java 编译器强制要求必须要有, 这是因为它们被用于接收从命令行输入的参数。 下面我们来看一段有趣的代码: @@ -512,9 +521,9 @@ main() 方法的参数是一个 字符串(**String**) 数组。 参数 `args System.out.println(new Date()); ``` -上面的示例中,我们创建了一个日期(**Date**)型对象并将其转化为字符串类型并输出到控制台中。 一旦这一行语句执行完毕,我们就不再需要该日期对象了。这时, Java 的垃圾回收器就可以将其占用的内存回收,我们无需去主动清除它们。 +上面的示例中,我们创建了一个日期(**Date**)类型的对象并将其转化为字符串类型,输出到控制台中。 一旦这一行语句执行完毕,我们就不再需要该日期对象了。这时,Java 垃圾回收器就可以将其占用的内存回收,我们无需去主动清除它们。 -查看 JDK 文档,我们可以看到, **System** 类下还有很多其他有用的方法。( Java 的牛逼之处还在于,它拥有一个庞大的标准库资源。) 代码示例: +查看 JDK 文档时,我们可以看到在 **System** 类下还有很多其他有用的方法( Java 的牛逼之处还在于,它拥有一个庞大的标准库资源)。代码示例: ```java // objects/ShowProperties.java @@ -554,33 +563,34 @@ os.arch=amd64 java.io.tmpdir=C:\Users\Bruce\AppData\Local\Temp\ ``` -上例主方法中的第一行,会输出所有的系统属性,也就是环境信息。 **list()** 方法将结果发送给它的参数 **System.out** 在本书的后面,我们还会接触到将结果输出到其他地方,如文件中。另外,我们还可以请求特定的属性。该例中我们使用到了 **user.name** 和 **java.library.path**。 末尾的 “/* Output:”标记表示此文件生成的输出的开头。本书中产生输出的大多数示例将会以包含此注释形式的输出,因此我们可以看到输出并知道它是正确的。带有这个标签允许在使用编译器检查并执行后将输出自动更新到本书的文本中。 +`main()` 方法中的第一行会输出所有的系统属性,也就是环境信息。 **list()** 方法将结果发送给它的参数 **System.out**。在本书的后面,我们还会接触到将结果输出到其他地方,例如文件中。另外,我们还可以请求特定的属性。该例中我们使用到了 **user.name** 和 **java.library.path**。 + ### 编译和运行 -要编译和运行本书中的代码示例,首先必须具有 Java 编程环境。 第二章的示例中描述了安装过程。如果你遵循这些说明,那么你将会在不受 Oracle 的限制的条件下用到 Java 开发人员工具包(JDK)。如果你使用其他开发系统,请查看该系统的文档以确定如何编译和运行程序。 第二章还介绍了如何安装本书的示例。 +要编译和运行本书中的代码示例,首先必须具有 Java 编程环境。 第二章的示例中描述了安装过程。如果你遵循这些说明,那么你将会在不受 Oracle 的限制的条件下用到 Java 开发工具包(JDK)。如果你使用其他开发系统,请查看该系统的文档以确定如何编译和运行程序。 第二章还介绍了如何安装本书的示例。 -移动到 objects 子目录下并键入: +移动到子目录 **objects** 下并键入: ``` bash javac HelloDate.java ``` -此命令不应生成任何响应。如果我们收到任何类型的错误消息,则表示未正确安装 JDK,并且必须检查这些问题。 +此命令不应产生任何响应。如果我们收到任何类型的错误消息,则表示未正确安装 JDK,那就得检查这些问题。 -此外,仅仅是执行的话,则可以键入: +若执行不报错的话,此时可以键入: ```java java HelloDate ``` -我们将会获得信息反馈,日期输出。这是我们编译和运行本书中每个程序(包含main())的过程 [^9]。但是,本书的源代码在根目录中也有一个名为 **build.gradle** 的文件,其中包含用于自动构建,测试和运行本书文件的 **Gradle** 配置。当您第一次运行 `gradlew` 命令时,**Gradle** 将自动安装(前提已安装Java)。 +我们将会得到正确的日期输出。这是我们编译和运行本书中每个程序(包含 `main()` 方法)的过程 [^9]。此外,本书的源代码在根目录中也有一个名为 **build.gradle** 的文件,其中包含用于自动构建,测试和运行本书文件的 **Gradle** 配置。当你第一次运行 `gradlew` 命令时,**Gradle** 将自动安装(前提是已安装Java)。 ## 编码风格 -Java 的代码约定规范(Code Conventions for the Java Programming Language)[^10] 要求类名的首字母大写。 如果类名是由多个单词构成的,则每个单词的首字母都应大写(不采用下划线来分隔)例如: +Java 编程语言编码规范(Code Conventions for the Java Programming Language)[^10] 要求类名的首字母大写。 如果类名是由多个单词构成的,则每个单词的首字母都应大写(不采用下划线来分隔)例如: ```java class AllTheColorsOfTheRainbow { @@ -588,7 +598,7 @@ class AllTheColorsOfTheRainbow { ``` -这种风格也被称之为“驼峰式”。对于几乎所有其他方法,字段(成员变量)和对象引用名称 - 除了标识符的首字母是小写之外其他规范与类的命名一致。代码示例: +有时称这种命名风格叫“驼峰命名法”。对于几乎所有其他方法,字段(成员变量)和对象引用名都采用驼峰命名的方式,但是它们的首字母不需要大写。代码示例: ```java class AllTheColorsOfTheRainbow { @@ -601,13 +611,13 @@ class AllTheColorsOfTheRainbow { ``` -在 Oracle 的库中,开放式花括号的位置同样遵循和本书中相同的规范。 +在 Oracle 的官方类库中,花括号的位置同样遵循和本书中上述示例相同的规范。 ## 本章小结 -本章向您展示了简单的 Java 程序编写以及该语言相关的基本概念。到目前为止,我们的示例都只是些简单的顺序执行。在接下来的两章里,我们将会接触到 Java 的一些基本操作符,以及如何去控制程序执行的流程。 +本章向你展示了简单的 Java 程序编写以及该语言相关的基本概念。到目前为止,我们的示例都只是些简单的顺序执行。在接下来的两章里,我们将会接触到 Java 的一些基本操作符,以及如何去控制程序执行的流程。 [^1]: 这里可能有争议。有人说这是一个指针,但这假定了一个潜在的实现。此外,Java 引用的语法更类似于 C++ 引用而非指针。在 《Thinking in Java》 的第 1 版中,我发明了一个新术语叫“句柄”(handle),因为 C++ 引用和Java 引用有一些重要的区别。作为一个从 C++ 的过来人,我不想混淆 Java 可能的最大受众 —— C++ 程序员。在《Thinking in Java》的第 2 版中,我认为“引用”(reference)是更常用的术语,从 C++ 转过来的人除了引用的术语之外,还有很多东西需要处理,所以他们不妨双脚都跳进去。但是,也有些人甚至不同意“引用”。在某书中我读到一个观点:Java 支持引用传递的说法是完全错误的,因为 Java 对象标识符(根据该作者)实际上是“对象引用”(object references),并且一切都是值传递。所以你不是通过引用传递,而是“通过值传递对象引用。人们可以质疑我的这种解释的准确性,但我认为我的方法简化了对概念的理解而又没对语言造成伤害(嗯,语言专家可能会说我骗你,但我会说我只是对此进行了适当的抽象。) diff --git a/docs/book/04-Operators.md b/docs/book/04-Operators.md index 632c568..ebf1958 100644 --- a/docs/book/04-Operators.md +++ b/docs/book/04-Operators.md @@ -8,23 +8,23 @@ Java 是从 C++ 的基础上做了一些改进和简化发展而成的。对于 C/C++ 程序员来说,Java 的运算符并不陌生。如果你已了解 C 或 C++,大可以跳过本章和下一章,直接阅读 Java 与 C/C++ 不同的地方。 -如果理解这两章的内容对你来说还有点困难,那么我推荐你先了解下 《Thinking in C》 再继续后面的学习。 这本书现在可以在 [www.OnJava8.com](http://www.OnJava8.com]) 上免费下载。它的内容包含音频讲座、幻灯片、练习和解决方案,专门用于帮助你快速掌握学习 Java 所需的基础知识。 +如果理解这两章的内容对你来说还有点困难,那么我推荐你先了解下 《Thinking in C》 再继续后面的学习。 这本书现在可以在 [www.OnJava8.com](http://www.OnJava8.com]) 上免费下载。它的内容包含音频讲座、幻灯片、练习和解答,专门用于帮助你快速掌握学习 Java 所需的基础知识。 ## 使用说明 -运算符接受一个或多个参数并生成新值。这个参数与普通方法调用的形式是不同的,但效果是相同的。加法 `+`,减法 `-`,乘法 `*`,除法 `/` 以及赋值 `=` 在任何编程语言中的工作方式都是类似的。所有运算符都能根据自己的运算对象生成一个值。除此以外,一个运算符可改变运算对象的值,这叫作“副作用”(**Side Effect**)。运算符最常见的用途就是修改自己的运算对象,从而产生副作用。但要注意生成的值亦可由没有副作用的运算符生成。 +运算符接受一个或多个参数并生成新值。这个参数与普通方法调用的形式不同,但效果是相同的。加法 `+`,减法 `-`,乘法 `*`,除法 `/` 以及赋值 `=` 在任何编程语言中的工作方式都是类似的。所有运算符都能根据自己的运算对象生成一个值。除此以外,一些运算符可改变运算对象的值,这叫作“副作用”(**Side Effect**)。运算符最常见的用途就是修改自己的运算对象,从而产生副作用。但要注意生成的值亦可由没有副作用的运算符生成。 几乎所有运算符都只能操作基本类型(Primitives)。唯一的例外是 `=`、`==` 和 `!=`,它们能操作所有对象(这也是令人混淆的一个地方)。除此以外,**String** 类支持 `+` 和 `+=`。 - + ## 优先级 -运算符的优先级决定了存在多个运算符时一个表达式各部分的计算顺序。Java 对计算顺序作出了特别的规定。其中,最简单的规则就是乘法和除法在加法和减法之前完成。程序员经常都会忘记其他优先级规则,所以应该用括号明确规定计算顺序。代码示例: +运算符的优先级决定了存在多个运算符时一个表达式各部分的运算顺序。Java 对运算顺序作出了特别的规定。其中,最简单的规则就是乘法和除法在加法和减法之前完成。程序员经常都会忘记其他优先级规则,所以应该用括号明确规定运算顺序。代码示例: ```java // operators/Precedence.java @@ -51,14 +51,14 @@ public class Precedence { 我们注意到,在 `System.out.println()` 语句中使用了 `+` 运算符。 但是在这里 `+` 代表的意思是字符串连接符。编译器会将 `+` 连接的非字符串尝试转换为字符串。上例中的输出结果说明了 a 和 b 都已经被转化成了字符串。 - + ## 赋值 -运算符的赋值是由符号 `=` 完成的。它代表着获取 `=` 右边的值并赋给左边的变量。右边可以是任何常量、变量或者是可产生一个返回值的表达式。但左边必须是一个明确的、已命名的变量。也就是说,必须要有一个物理的空间来存放右边的值。举个例子来说,可将一个常数赋给一个变量( A = 4 ),但不可将任何东西赋给一个常数(比如不能 4 = A)。 +运算符的赋值是由符号 `=` 完成的。它代表着获取 `=` 右边的值并赋给左边的变量。右边可以是任何常量、变量或者可产生一个返回值的表达式。但左边必须是一个明确的、已命名的变量。也就是说,必须要有一个物理的空间来存放右边的值。举个例子来说,可将一个常数赋给一个变量( A = 4 ),但不可将任何东西赋给一个常数(比如不能 4 = A)。 -基本类型的赋值都是直接的,而不像对象,赋予的只是其内存的引用。举个例子,a = b ,如果 b 是基本类型,那么 赋值操作会将 b 的值复制一根给变量 a, 此后若 a 的值发生改变是不会影响到 b 的。作为一名程序员,着应该成为我们的常识。 +基本类型的赋值都是直接的,而不像对象,赋予的只是其内存的引用。举个例子,a = b ,如果 b 是基本类型,那么 赋值操作会将 b 的值复制一份给变量 a, 此后若 a 的值发生改变是不会影响到 b 的。作为一名程序员,这应该成为我们的常识。 如果是为对象赋值,那么结果就不一样了。对一个对象进行操作时,我们实际上操作的是它的引用。所以我们将右边的对象赋予给左边时,赋予的只是该对象的引用。此时,两者指向的堆中的对象还是同一个。代码示例: @@ -96,9 +96,9 @@ public class Assignment { 3: t1.level: 27, t2.level: 27 ``` -这是个简单的 `Tank` 类,通过 main 方法 `new` 出了 2 个实例对象。 两个对象的 `level` 属性分别被赋予了不同的值。 然后,t2 的值被赋予给 t1。在许多编程语言里,预期的结果是 t1 和 t2 的值会一直相对独立。但是,在 Java 中,由于赋予的只是对象的引用,改变 t1 也就改变了 t2。 这是因为 t1 和 t2 此时指向的是堆中同一个对象。(t1 原始对象的引用在 t2 赋值给其时被丢失,它就将会在垃圾回收时被清理)。 +这是一个简单的 `Tank` 类,在 `main()` 方法创建了 2 个实例对象。 两个对象的 `level` 属性分别被赋予不同的值。 然后,t2 的值被赋予给 t1。在许多编程语言里,预期的结果是 t1 和 t2 的值会一直相对独立。但是,在 Java 中,由于赋予的只是对象的引用,改变 t1 也就改变了 t2。 这是因为 t1 和 t2 此时指向的是堆中同一个对象。(t1 原始对象的引用在 t2 赋值给其时丢失,它引用的对象会在垃圾回收时被清理)。 -这种现象通常称为别名(aliasing),这是 Java 处理对象的一种基本方式。但是假若你不想这里出现这样的混淆的话,你可以这么做。代码示例: +这种现象通常称为别名(aliasing),这是 Java 处理对象的一种基本方式。但是假若你不想出现这里的别名引起混淆的话,你可以这么做。代码示例: ```java t1.level = t2.level; @@ -116,16 +116,16 @@ t1.level = t2.level; // 正在传递的对象可能不是你之前使用的 class Letter { char c; - } +} public class PassObject { static void f(Letter y) { - y.c = 'z'; - } - + y.c = 'z'; + } + public static void main(String[] args) { Letter x = new Letter(); - x.c = 'a'; + x.c = 'a'; System.out.println("1: x.c: " + x.c); f(x); System.out.println("2: x.c: " + x.c); @@ -140,15 +140,15 @@ public class PassObject { 2: x.c: z ``` -在许多编程语言中,方法 `f()` 似乎在内部复制其参数 **Letter y**。但是一旦传递了一个引用,那么实际上 `y.c ='z';` 是在方法 `f()` 之外改变对象。别名现象以及其解决方案是个复杂的问题,在附录中有包含:[对象传递和返回](./Appendix-Passing-and-Returning-Objects.md)。意识到这一点,我们可以警惕类似的陷阱。 - +在许多编程语言中,方法 `f()` 似乎会在内部复制其参数 **Letter y**。但是一旦传递了一个引用,那么实际上 `y.c ='z';` 是在方法 `f()` 之外改变对象。别名现象以及其解决方案是个复杂的问题,在附录中有包含:[对象传递和返回](./Appendix-Passing-and-Returning-Objects.md)。意识到这一点,我们可以警惕类似的陷阱。 + ## 算术运算符 -Java 的基本算术运算符与其他大多编程语言是相同的。其中包括加号 `+`、减号 `-`、除号 `/`、乘号 `*` 以及模数 `%`(从整数除法中获得余数)。整数除法会直接砍掉小数,而不是进位。 +Java 的基本算术运算符与其他大多编程语言是相同的。其中包括加号 `+`、减号 `-`、除号 `/`、乘号 `*` 以及取模 `%`(从整数除法中获得余数)。整数除法会直接砍掉小数,而不是进位。 -Java 也用一种简写形式同时进行运算和赋值操作,由运算符后跟等号表示,并且与语言中的所有运算符一致(只要有意义)。为了将 4 的值赋予给变量 x 同时将结果赋予给 x , 可用 x += 4 来表示。下面带来代码更多代码示例: +Java 也用一种与 C++ 相同的简写形式同时进行运算和赋值操作,由运算符后跟等号表示,并且与语言中的所有运算符一致(只要有意义)。 可用 x += 4 来表示:将 x 的值加上4的结果再赋值给 x。更多代码示例: ```java @@ -232,8 +232,8 @@ u /= v : 9.940527 为了生成随机数字,程序首先创建一个 **Random** 对象。不带参数的 **Random** 对象会利用当前的时间用作随机数生成器的“种子”(seed),从而为程序的每次执行生成不同的输出。在本书的示例中,重要的是每个示例末尾的输出尽可能一致,以便可以使用外部工具进行验证。所以我们通过在创建 **Random** 对象时提供种子(随机数生成器的初始化值,其始终为特定种子值产生相同的序列),让程序每次执行都生成相同的随机数,如此以来输出结果就是可验证的 [^1]。 若需要生成随机值,可删除代码示例中的种子参数。该对象通过调用方法 `nextInt()` 和 `nextFloat()`(还可以调用 `nextLong()` 或 `nextDouble()`),使用 **Random** 对象生成许多不同类型的随机数。`nextInt()` 的参数设置生成的数字的上限,下限为零,为了避免零除的可能性,结果偏移1。 - + ### 一元加减运算符 一元加 `+` 减 `-` 运算符的操作和二元是相同的。编译器可自动识别使用何种方式解析运算: @@ -254,16 +254,16 @@ x = a * -b; x = a * (-b); ``` -一元减号可以得到数据的负值。一元加号的作用相反,不过它唯一能影响的就是把较小的数值类型自动转换为了 **int** 类型。 - +一元减号可以得到数据的负值。一元加号的作用相反,不过它唯一能影响的就是把较小的数值类型自动转换为 **int** 类型。 + ## 递增和递减 -和 C 语言类似,Java 提供了许多快捷运算方式。快捷运算可使代码可读性,可写性都更强。其中包括递增 `++` 和递减 `--`,意为“增加或减少一个单位”。举个例子来说,假设 a 是一个 **int** 类型的值,则表达式 `++a` 就等价于 `a = a + 1`。 递增和递减运算符不仅可以修改变量,还可以生成变量的值。 +和 C 语言类似,Java 提供了许多快捷运算方式。快捷运算可使代码可读性,可写性都更强。其中包括递增 `++` 和递减 `--`,意为“增加或减少一个单位”。举个例子来说,假设 a 是一个 **int** 类型的值,则表达式 `++a` 就等价于 `a = a + 1`。 递增和递减运算符不仅可以修改变量,还可以生成变量的值。 -每种类型的运算符,都有两个版本可供选用;通常将其称为“前缀版”和“后缀版”。“前递增”表示 `++` 运算符位于变量或表达式的前面;而“后递增”表示 `++` 运算符位于变量或表达式的后面。类似地,“前递减”意味着 `--` 运算符位于变量或表达式的前面;而“后递减”意味着 `--` 运算符位于变量或表达式的后面。对于前递增和前递减(如 `++a` 或 `--a`),会先执行递增/减运算,再返回值。而对于后递增和后递减(如 `a++` 或 `a--`),会先返回值,再执行递增/减运算。代码示例: +每种类型的运算符,都有两个版本可供选用;通常将其称为“前缀”和“后缀”。“前递增”表示 `++` 运算符位于变量或表达式的前面;而“后递增”表示 `++` 运算符位于变量的后面。类似地,“前递减”意味着 `--` 运算符位于变量的前面;而“后递减”意味着 `--` 运算符位于变量的后面。对于前递增和前递减(如 `++a` 或 `--a`),会先执行递增/减运算,再返回值。而对于后递增和后递减(如 `a++` 或 `a--`),会先返回值,再执行递增/减运算。代码示例: ```java // operators/AutoInc.java @@ -294,21 +294,22 @@ i--: 2 i: 1 ``` -对于前缀形式,我们将在执行递增/减操作后获取值;使用后缀形式,我们将在执行递增/减操作之前获取值。它们是唯一具有“副作用”的运算符(除那些涉及赋值的以外) —— 它们既改变操作数又改变值。 +对于前缀形式,我们将在执行递增/减操作后获取值;使用后缀形式,我们将在执行递增/减操作之前获取值。它们是唯一具有“副作用”的运算符(除那些涉及赋值的以外) —— 它们修改了操作数的值。 -C++ 名称来自于递增运算符,同时也代表着“比 C 更进一步”。在早期的 Java 演讲中,*Bill Joy*(Java 作者之一)说“**Java = C ++ --**”(C++ 减减)。这意味着 Java 是在 C++ 的基础上减少了许多不必要的东西,因此语言更简单。随着进一步地学习,我们会发现 Java 的确有许多地方相对 C++ 来说更简便,但是在其他方面,难度并不会比 C++ 小多少。 +C++ 名称来自于递增运算符,暗示着“比 C 更进一步”。在早期的 Java 演讲中,*Bill Joy*(Java 作者之一)说“**Java = C++ --**”(C++ 减减),意味着 Java 在 C++ 的基础上减少了许多不必要的东西,因此语言更简单。随着进一步地学习,我们会发现 Java 的确有许多地方相对 C++ 来说更简便,但是在其他方面,难度并不会比 C++ 小多少。 ## 关系运算符 -关系运算符会通过产生一个布尔(**boolean**)结果来表示被操作的数值之间的关系。如果关系为真,则结果为 **true**,如果关系非真,则结果为 **false**。关系运算符包括小于 `<`,大于 `>`,小于或等于 `<=`,大于或等于 `>=`,等价 `==` 和不等价 `!=`。`==` 和 `!=` 可与所有基本类型搭配使用。但与其他类型的比较就不太适合了,因为布尔值只能表示 **true** 或 **false**,所以比较它们之间的“大于”或“小于”没有意义。 +关系运算符会通过产生一个布尔(**boolean**)结果来表示操作数之间的关系。如果关系为真,则结果为 **true**,如果关系为假,则结果为 **false**。关系运算符包括小于 `<`,大于 `>`,小于或等于 `<=`,大于或等于 `>=`,等于 `==` 和不等于 `!=`。`==` 和 `!=` 可用于所有基本类型,但其他运算符不能用于基本类型 **boolean**,因为布尔值只能表示 **true** 或 **false**,所以比较它们之间的“大于”或“小于”没有意义。 + ### 测试对象等价 -关系运算符 `==` 和 `!=` 同样适用于所有对象之间的比较运算,但产生的结果却经常混淆 Java 的初学者。下面是代码示例: +关系运算符 `==` 和 `!=` 同样适用于所有对象之间的比较运算,但它们比较的内容却经常困扰 Java 的初学者。下面是代码示例: ```java // operators/Equivalence.java @@ -329,13 +330,34 @@ true false ``` +表达式 `System.out.println(n1 == n2)` 将会输出比较的结果。因为两个 **Integer** 对象相同,所以先输出 **true**,再输出 **false**。但是,尽管对象的内容一样,对象的引用却不一样。`==` 和 `!=` 比较的是对象引用,所以输出实际上应该是先输出 **false**,再输出 **true**(译者注:如果你把47改成128,那么打印的结果就是这样,因为 Integer 内部维护着一个 IntegerCache 的缓存,默认缓存范围是 [-128, 127],所以 [-128, 127] 之间的值用 `==` 和 `!=` 比较也能能到正确的结果,但是不推荐用关系运算符比较,具体见 JDK 中的 Integer 类源码)。 + +那么怎么比较两个对象的内容是否相同呢?你必须使用所有对象(不包括基本类型)中都存在的 `equals()` 方法,下面是如何使用 `equals()` 方法的示例: + +```java +// operators/EqualsMethod.java +public class EqualsMethod { + public static void main(String[] args) { + Integer n1 = 47; + Integer n2 = 47; + System.out.println(n1.equals(n2)); + } +} +``` + +输出结果: + +```java +true +``` + 上例的结果看起来是我们所期望的。但其实事情并非那么简单。下面我们来创建自己的类: ```java // operators/EqualsMethod2.java // 默认的 equals() 方法没有比较内容 class Value { -int i; + int i; } public class EqualsMethod2 { @@ -350,15 +372,16 @@ public class EqualsMethod2 { 输出结果: -``` +```java false ``` -上例的结果再次令人困惑:结果是错误的。原因: `equals()` 的默认行为是比较对象的引用而非具体内容。因此,除非你在新类中重写 `equals()` 方法,否则我们将获取不到想要的结果。不幸的是,在学习 [复用](./08-Reuse.md)(**Reuse**) 章节后我们才能接触到“覆盖”(**Override**),并且直到 [附录:集合主题](./Appendix-Collection-Topics.md),才能知道定义 **equals()** 方法的正确方式,但是现在明白 `equals()` 行为方式也可能为你节省一些时间。 +上例的结果再次令人困惑:结果是 **false**。原因: `equals()` 的默认行为是比较对象的引用而非具体内容。因此,除非你在新类中覆写 `equals()` 方法,否则我们将获取不到想要的结果。不幸的是,在学习 [复用](./08-Reuse.md)(**Reuse**) 章节后我们才能接触到“覆写”(**Override**),并且直到 [附录:集合主题](./Appendix-Collection-Topics.md),才能知道定义 `equals()` 方法的正确方式,但是现在明白 `equals()` 行为方式也可能为你节省一些时间。 大多数 Java 库类通过覆写 `equals()` 方法比较对象的内容而不是其引用。 - + + ## 逻辑运算符 每个逻辑运算符 `&&` (**AND**)、`||`(**OR**)和 `!`(**非**)根据参数的逻辑关系生成布尔值 `true` 或 `false`。下面的代码示例使用了关系运算符和逻辑运算符: @@ -407,14 +430,12 @@ i != j is true (i < 10) || (j < 10) is false ``` -在 Java 逻辑运算中,我们不能像 C/C++ 那样使用非布尔值, 而仅能使用 **AND**、**OR**、**NOT**。 -下面是一次错误的尝试: ~~a || -~~。 但是,后续表达式使用关系比较生成布尔值,然后对结果使用逻辑运算结果。请注意,如果在预期为 **String** 类型的位置使用 **boolean** 类型的值,则结果会自动转为适当的文本格式。 - -我们可以将前一个程序中 **int** 的定义替换为除 **boolean** 之外的任何其他基本数据类型。 -但请注意,**float** 类型的数值比较非常严格。只要最小位部分的数字不同则两个数值之间的比较仍然是“非等”的;只要数字最小位是大于 0 的,那么它就不等于 0。 +在 Java 逻辑运算中,我们不能像 C/C++ 那样使用非布尔值, 而仅能使用 **AND**、**OR**、**NOT**。上面的例子中,我们将使用非布尔值的表达式注释掉了(你可以看到表达式前面是 //-)。但是,后续的表达式使用关系比较生成布尔值,然后对结果使用了逻辑运算。请注意,如果在预期为 **String** 类型的位置使用 **boolean** 类型的值,则结果会自动转为适当的文本格式(即 "true" 或 "false" 字符串)。 +我们可以将前一个程序中 **int** 的定义替换为除 **boolean** 之外的任何其他基本数据类型。但请注意,**float** 类型的数值比较非常严格,只要两个数字的最小位不同则两个数仍然不相等;只要数字最小位是大于 0 的,那么它就不等于 0。 + ### 短路 逻辑运算符支持一种称为“短路”(short-circuiting)的现象。整个表达式会在运算到可以明确结果时就停止并返回结果,这意味着该逻辑表达式的后半部分不会被执行到。代码示例: @@ -465,12 +486,12 @@ expression is false test1(0)&& test2(2)&& test3(2) ``` -可能你的预期是程序会执行 3 个 **test** 方法并返回。我们来分析一下:第一个方法的结果返回 `true`,因此表达式会继续走下去。紧接着,第二个方法的返回结果是 `false`。这就代表这整个表达式的结果肯定为 `false`,所以就没有必要再判断剩下的表达式部分了。 +可能你的预期是程序会执行 3 个 **test** 方法并返回。我们来分析一下:第一个方法的结果返回 `true`,因此表达式会继续走下去。紧接着,第二个方法的返回结果是 `false`。这就代表这整个表达式的结果肯定为 `false`,所以就没有必要再判断剩下的表达式部分了。 所以,运用“短路”可以节省部分不必要的运算,从而提高程序潜在的性能。 - + ## 字面值常量 @@ -540,22 +561,23 @@ bli: 101111101011111010111110101111 bll: 101111101011111010111110101111 ``` -在文本值的后面添加字面值常量可以让编译器识别该文本值的类型。对于 **Long** 型数值,结尾使用大写 `L` 或小写 `l` 皆可(不推荐使用 `l`,因为容易与阿拉伯数值 1 混淆)。大写 `F` 或小写 `f` 表示 **float** 浮点数。大写 `D` 或小写 `d` 表示 **double** 双精度。 +在文本值的后面添加字符可以让编译器识别该文本值的类型。对于 **Long** 型数值,结尾使用大写 `L` 或小写 `l` 皆可(不推荐使用 `l`,因为容易与阿拉伯数值 1 混淆)。大写 `F` 或小写 `f` 表示 **float** 浮点数。大写 `D` 或小写 `d` 表示 **double** 双精度。 -十六进制(以 16 为基数),适用于所有整型数据类型,由前导 `0x` 或 `0x` 表示,后跟 0-9 或 a-f (大写或小写)。如果我们在初始化某个类型的数值时,赋值超出其范围,那么编译器会报错(不管值的数字形式如何)。在上例的代码中,**char**、**byte** 和 **short** 的值已经是最大了。如果超过这些值,编译器将自动转型为 **int**,并且我们需要声明强制转换(强制转换将在本章后面定义)。这告诉我们已越过该类型的范围界限。 +十六进制(以 16 为基数),适用于所有整型数据类型,由前导 `0x` 或 `0X` 表示,后跟 0-9 或 a-f (大写或小写)。如果我们在初始化某个类型的数值时,赋值超出其范围,那么编译器会报错(不管值的数字形式如何)。在上例的代码中,**char**、**byte** 和 **short** 的值已经是最大了。如果超过这些值,编译器将自动转型为 **int**,并且提示我们需要声明强制转换(强制转换将在本章后面定义),意味着我们已越过该类型的范围界限。 八进制(以 8 为基数)由 0~7 之间的数字和前导零 `0` 表示。 -Java 7 引入了二进制的字面值常量,由前导 `0B` 或 `0B` 表示,它可以初始化所有的整数类型。 +Java 7 引入了二进制的字面值常量,由前导 `0b` 或 `0B` 表示,它可以初始化所有的整数类型。 使用整型数值类型时,显示其二进制形式会很有用。在 Long 型和 Integer 型中这很容易实现,调用其静态的 `toBinaryString()` 方法即可。 但是请注意,若将较小的类型传递给 **Integer.**`tobinarystring()` 时,类型将自动转换为 **int**。 + ### 下划线 -Java 7 中有一个深思熟虑的补充:我们可以在数字文字中包含下划线 `_`,以使结果更清晰。这对于大数值的分组数字特别有用。代码示例: +Java 7 中有一个深思熟虑的补充:我们可以在数字字面量中包含下划线 `_`,以使结果更清晰。这对于大数值的分组特别有用。代码示例: ```java @@ -590,11 +612,10 @@ public class Underscores { 3. `F`、`D` 和 `L`的前后禁止出现 `_`。 4. 二进制前导 `b` 和 十六进制 `x` 前后禁止出现 `_`。 - -[1] 注意 `%n`的使用。熟悉 C 风格的程序员可能习惯于看到 `\n` 来表示换行符。问题在于它给你的是一个“Unix风格”的换行符。此外,如果我们使用的是 Windows,则必须指定 `\r\n`。这种差异的包袱应该由编程语言来解决。这就是Java用 `%n` 实现的,忽略平台间差异生成适当的换行符。但当你使用 `System.out.printf()` 或 `System.out.format()` 时。对于 `System.out.println()`,我们仍然必须使用 `\n`;如果你使用 `%n`,`println()` 只会输出 `%n` 而不是换行符。 - +[1] 注意 `%n`的使用。熟悉 C 风格的程序员可能习惯于看到 `\n` 来表示换行符。问题在于它给你的是一个“Unix风格”的换行符。此外,如果我们使用的是 Windows,则必须指定 `\r\n`。这种差异的包袱应该由编程语言来解决。这就是Java用 `%n` 实现的可以忽略平台间差异而生成适当的换行符,但只有当你使用 `System.out.printf()` 或 `System.out.format()` 时。对于 `System.out.println()`,我们仍然必须使用 `\n`;如果你使用 `%n`,`println()` 只会输出 `%n` 而不是换行符。 + ### 指数计数法 指数总是采用一种我认为很不直观的记号方法: @@ -622,11 +643,11 @@ public class Exponents { 4.7E48 ``` -在科学与工程学领域,“e”代表自然对数的基数,约等于 2.718 (Java 一种更精确的 **double** 值采用 **Math.E** 的形式)。它在类似 “1.39×e 的 -47 次方”这样的指数表达式中使用,意味着“1.39×2.718 的-47次方”。然而,自 FORTRAN 语言发明后,人们自然而然地觉得e 代表 “10几次幂”。这种做法显得颇为古怪,因为 FORTRAN 最初面向的是科学与工程设计领域。 +在科学与工程学领域,**e** 代表自然对数的基数,约等于 2.718 (Java 里用一种更精确的 **double** 值 **Math.E** 来表示自然对数)。指数表达式 "1.39 x e-43",意味着“1.39 × 2.718 的-43次方”。然而,自 FORTRAN 语言发明后,人们自然而然地觉得e 代表 “10的几次幂”。这种做法显得颇为古怪,因为 FORTRAN 最初是为科学与工程领域设计的。 -理所当然,它的设计者应对这样的混淆概念持谨慎态度 [^2]。但不管怎样,这种特别的表达方法在 C,C++ 以及现在的 Java 中顽固地保留下来了。所以倘若习惯 e 作为自然对数的基数使用,那么在 Java 中看到类似“1.39e-47f”这样的表达式时,请转换你的思维,从程序设计的角度思考它;它真正的含义是“1.39×10 的-47次方”。 +理所当然,它的设计者应对这样的混淆概念持谨慎态度 [^2]。但不管怎样,这种特别的表达方法在 C,C++ 以及现在的 Java 中顽固地保留下来了。所以倘若习惯 e 作为自然对数的基数使用,那么在 Java 中看到类似“1.39e-43f”这样的表达式时,请转换你的思维,从程序设计的角度思考它;它真正的含义是“1.39 × 10的-43次方”。 -注意如果编译器能够正确地识别类型,就不必使用尾随字面值常量。对于下述语句: +注意如果编译器能够正确地识别类型,就不必使用后缀字符。对于下述语句: ```java long n3 = 200; @@ -634,33 +655,33 @@ long n3 = 200; 它并不存在含糊不清的地方,所以 200 后面的 L 大可省去。然而,对于下述语句: -``` -float f4 = 1e-47f; //10 的幂数 +```java +float f4 = 1e-43f; //10 的幂数 ``` -编译器通常会将指数作为 **double** 类型来处理,所以假若没有这个尾随的 `f`,编辑器就会报错,提示我们应该将 **double** 型转换成 **float** 型。 - +编译器通常会将指数作为 **double** 类型来处理,所以假若没有这个后缀字符 `f`,编译器就会报错,提示我们应该将 **double** 型转换成 **float** 型。 -## 按位运算符 + +## 位运算符 -按位运算符允许我们操作一个整数基本类型中的单个“比特”位,即二进制位。按位运算符会对两个自变量中对应的位执行布尔代数,并最终生成一个结果。 +位运算符允许我们操作一个整型数字中的单个二进制位。位运算符会对两个整数对应的位执行布尔代数,从而产生结果。 -按位运算来源自 C 语言的底层操作。我们经常要直接操纵硬件,频繁设置硬件寄存器内的二进制位。Java 的设计初衷是电视机顶盒嵌入式开发,所以这种底层的操作被仍被保留了下来。然而,随着操作系统的进步,现今已没必要过于频繁地进行按位运算了。 +位运算源自 C 语言的底层操作。我们经常要直接操纵硬件,频繁设置硬件寄存器内的二进制位。Java 的设计初衷是电视机顶盒嵌入式开发,所以这种底层的操作仍被保留了下来。但是,你可能不会使用太多位运算。 -若两个输入位都是 1,则按位“与运算符” `&` 在输出位里生成一个 1 ;否则生成 0。若两个输入位里至少有一个是 1,则按位“或运算符” `|` 在输出位里生成一个 1;只有在两个输入位都是 0 的情况下,它才会生成一个 0。若两个输入位的某一个是 1,但不全都是 1,那么按位“异或运算符” `^` 才会在输出位里生成一个 1。按位“非运算符” `~` 属于一元运算符;它只对一个自变量进行操作(其他所有运算符都是二元运算符)。按位非运算生成与输入位的相反的值。例如,若输入 0,则输出 1;输入 1,则输出 0。 +若两个输入位都是 1,则按位“与运算符” `&` 运算后结果是 1,否则结果是 0。若两个输入位里至少有一个是 1,则按位“或运算符” `|` 运算后结果是 1;只有在两个输入位都是 0 的情况下,运算结果才是 0。若两个输入位的某一个是 1,另一个不是 1,那么按位“异或运算符” `^` 运算后结果才是 1。按位“非运算符” `~` 属于一元运算符;它只对一个自变量进行操作(其他所有运算符都是二元运算符)。按位非运算后结果与输入位相反。例如输入 0,则输出 1;输入 1,则输出 0。 -按位运算符和逻辑运算符都使用了同样的字符,只不过数量不同。“按位”短“移位”长。按位运算符可与等号 `=` 联合使用以接收结果及赋值:`&=`,`|=` 和 `^=` 都是合法的(由于 `~` 是一元运算符,所以不可与 `=` 联合使用)。 +位运算符和逻辑运算符都使用了同样的字符,只不过数量不同。位短,所以位运算符只有一个字符。位运算符可与等号 `=` 联合使用以接收结果及赋值:`&=`,`|=` 和 `^=` 都是合法的(由于 `~` 是一元运算符,所以不可与 `=` 联合使用)。 -我们将 **Boolean** 类型被视为“单位值”(one-bit value)对待,所以它多少有些独特的地方。我们可以执行按位“与”、“或”、“异或”,但不能执行按位“非”(大概是为了避免与逻辑“非”混淆)。对于布尔值,按位运算符具有与逻辑运算符相同的效果,只是它们不会中途“短路”。此外,针对布尔值进行的按位运算为我们新增了一个“异或”逻辑运算符,它并未包括在“逻辑”运算符的列表中。在移位表达式中,禁止使用布尔运算,原因将在下面解释。 +我们将 **Boolean** 类型被视为“单位值”(one-bit value),所以它多少有些独特的地方。我们可以对 boolean 型变量执行与、或、异或运算,但不能执行非运算(大概是为了避免与逻辑“非”混淆)。对于布尔值,位运算符具有与逻辑运算符相同的效果,只是它们不会中途“短路”。此外,针对布尔值进行的位运算为我们新增了一个“异或”逻辑运算符,它并未包括在逻辑运算符的列表中。在移位表达式中,禁止使用布尔值,原因将在下面解释。 ## 移位运算符 -移位运算符面向的运算对象也是二进制的“位”。可单独用它们处理整数类型(基本类型的一种)。左移位运算符 `<<` 能将其左边的运算对象向左移动右侧指定的位数(在低位补 0)。右移位运算符 `>>` 则相反。位移运算符有“正”、“负”值”:若值为正,则在高位插入 0;若值为负,则在高位插入 1。Java 也添加了一种“不分正负”的右移位运算符(>>>),它使用了“零扩展”(zero extension):无论正负,都在高位插入 0。这一运算符是 C/C++ 没有的。 +移位运算符面向的运算对象也是二进制的“位”。它们只能用于处理整数类型(基本类型的一种)。左移位运算符 `<<` 能将其左边的运算对象向左移动右侧指定的位数(在低位补 0)。右移位运算符 `>>` 则相反。右移位运算符有“正”、“负”值:若值为正,则在高位插入 0;若值为负,则在高位插入 1。Java 也添加了一种“不分正负”的右移位运算符(>>>),它使用了“零扩展”(zero extension):无论正负,都在高位插入 0。这一运算符是 C/C++ 没有的。 如果移动 **char**、**byte** 或 **short**,则会在移动发生之前将其提升为 **int**,结果为 **int**。仅使用右侧的 5 个低阶位。这可以防止我们移动超过 **int** 范围的位数。若对一个 **long** 值进行处理,最后得到的结果也是 **long**。 @@ -816,7 +837,7 @@ i >>> 5, int: 97591828, binary: ``` -结尾的两个方法 `printBinaryInt()` 和 `printBinaryLong()` 它们分别操作一个 **int** 和 **long** 值,并转换为二进制格式输出,同时附有简要的文字说明。除了演示 **int** 和 **long** 的所有位运算符的效果之外,本示例还显示 **int** 和 **long** 的最小值、最大值、+1 和 -1 值,以便我们了解它们的形式。注意高位代表符号:0 表示正,1 表示负。上面显示了 **int** 部分的输出。数字的二进制表示称为有符号的两个补数。 +结尾的两个方法 `printBinaryInt()` 和 `printBinaryLong()` 分别操作一个 **int** 和 **long** 值,并转换为二进制格式输出,同时附有简要的文字说明。除了演示 **int** 和 **long** 的所有位运算符的效果之外,本示例还显示 **int** 和 **long** 的最小值、最大值、+1 和 -1 值,以便我们了解它们的形式。注意高位代表符号:0 表示正,1 表示负。上面显示了 **int** 部分的输出。数字的二进制表示称为有符号的两个补数。 @@ -841,9 +862,9 @@ static int ternary(int i) { static int standardIfElse(int i) { if(i < 10) - return i * 100; + return i * 100; else - return i * 10; + return i * 10; } public static void main(String[] args) { @@ -901,16 +922,16 @@ x, y, z (summed) = 3 0 ``` -**注意**:上例中第 1 输出语句的执行结果是 `012` 而并非 `3`,这是因为编译器将其分别转换为其字符串形式然后与字符串变量 **s** 连接。在第 2 条输出语句中,编译器将开头的变量转换为了字符串,由此可以看出,这种转换与数据的位置无无关系,只要当中有一条数据是字符串类型,其他非字符串数据都将被转换为字符串形式并连接。最后一条输出语句,我们可以看出 `+=` 运算符可以拼接其右侧的字符串连接结果并重赋值给自身变量 `s`。括号 `()` 可以控制表达式的计算顺序,以便在显示 **int** 之前对其进行实际求和。 +**注意**:上例中第 1 输出语句的执行结果是 `012` 而并非 `3`,这是因为编译器将其分别转换为其字符串形式然后与字符串变量 **s** 连接。在第 2 条输出语句中,编译器将开头的变量转换为了字符串,由此可以看出,这种转换与数据的位置无关,只要当中有一条数据是字符串类型,其他非字符串数据都将被转换为字符串形式并连接。最后一条输出语句,我们可以看出 `+=` 运算符可以拼接其右侧的字符串连接结果并重赋值给自身变量 `s`。括号 `()` 可以控制表达式的计算顺序,以便在显示 **int** 之前对其进行实际求和。 请注意主方法中的最后一个例子:我们经常会看到一个空字符串 `""` 跟着一个基本类型的数据。这样可以隐式地将其转换为字符串,以代替繁琐的显式调用方法(如这里可以使用 **Integer.toString()**)。 - + ## 常见陷阱 -使用运算符时很容易犯的一个错误是,在还没搞清楚表达式的计算方式时就试图忽略括号 `()`。在 Java 中也一样。 在 C++ 中你甚至可能凡这样极端的错误.代码示例: +使用运算符时很容易犯的一个错误是,在还没搞清楚表达式的计算方式时就试图忽略括号 `()`。在 Java 中也一样。 在 C++ 中你甚至可能犯这样极端的错误.代码示例: ```java while(x = y) { @@ -949,7 +970,7 @@ public class Casting { 诚然,你可以这样地去转换一个数值类型的变量。但是上例这种做法是多余的:因为编译器会在必要时自动提升 **int** 型数据为 **long** 型。 -当然,为了程序逻辑清晰或提醒自己留意,我们也可以显式地类型转换。在其他情况下,类型转换型只有在代码编译时才显出其重要性。在 C/C++ 中,类型转换有时会让人头痛。在 Java 里,类型转换则是一种比较安全的操作。但是,若将数据类型进行“向下转换”(**Narrowing Conversion**)的操作(将容量较大的数据类型转换成容量较小的类型),可能会发生信息丢失的危险。此时,编译器会强迫我们进行造型,好比在提醒我们:该操作可能危险,若您坚持让我这么做,那么对不起,请明确需要转换的类型。 对于“向上转换”(**Widening conversion**),则不必进行显式的类型转换,因为较大类型的数据肯定能容纳较小类型的数据,不会造成任何信息的丢失。 +当然,为了程序逻辑清晰或提醒自己留意,我们也可以显式地类型转换。在其他情况下,类型转换型只有在代码编译时才显出其重要性。在 C/C++ 中,类型转换有时会让人头痛。在 Java 里,类型转换则是一种比较安全的操作。但是,若将数据类型进行“向下转换”(**Narrowing Conversion**)的操作(将容量较大的数据类型转换成容量较小的类型),可能会发生信息丢失的危险。此时,编译器会强迫我们进行转型,好比在提醒我们:该操作可能危险,若你坚持让我这么做,那么对不起,请明确需要转换的类型。 对于“向上转换”(**Widening conversion**),则不必进行显式的类型转换,因为较大类型的数据肯定能容纳较小类型的数据,不会造成任何信息的丢失。 除了布尔类型的数据, Java 允许任何基本类型的数据转换为另一种基本类型的数据。此外,类是不能进行类型转换的。为了将一个类转换为另一个类型,需要使用特殊的方法(后面将会学习到如何在父子类之间进行向上/向下转型,例如,“橡树”可以转换为“树”,反之亦然。而对于“岩石”是无法转换为“树”的)。 @@ -1008,28 +1029,28 @@ public class RoundingNumbers { Math.round(above): 1 Math.round(below): 0 Math.round(fabove): 1 -`Math.round(fbelow): 0 +Math.round(fbelow): 0 ``` -自从 `round()` 方法成为了 `java.lang` 的一部分,我们就无需通过 `import` 来使用了。 +因为 `round()` 方法是 `java.lang` 的一部分,所以我们无需通过 `import` 就可以使用。 ### 类型提升 -你会发现,如果我们对小于 **int** 的基元数据类型(即 **char**、**byte** 或 **short**)执行任何算术或按位操作,这些值会在执行操作之前类型提升为 **int**,并且结果值的类型为 **int**。若想重新使用到较小的类型,必须使用强制转换(由于重新分配回一个较小的类型,结果可能会丢失精度)。通常,表达式中最大的数据类型是决定表达式结果的数据类型。**float** 型和 **double** 型相乘,结果是 **double** 型的;**int** 和 **long** 相加,结果是 **long** 型。 +你会发现,如果我们对小于 **int** 的基本数据类型(即 **char**、**byte** 或 **short**)执行任何算术或按位操作,这些值会在执行操作之前类型提升为 **int**,并且结果值的类型为 **int**。若想重新使用较小的类型,必须使用强制转换(由于重新分配回一个较小的类型,结果可能会丢失精度)。通常,表达式中最大的数据类型是决定表达式结果的数据类型。**float** 型和 **double** 型相乘,结果是 **double** 型的;**int** 和 **long** 相加,结果是 **long** 型。 ## Java没有sizeof -在 C/C++ 中,经常 需要用到 `sizeof()` 方法来获取数据被分配的字符大小。在 C/C++ 中,`sizeof() `最常见的应用就是“移植”。不同数据在不同机器上可能有不同的大小,所以在进行大小敏感的运算时,程序员必须对这些类型有多大做到心中有数。例如,一台计算机可用 32 位来保存整数,而另一台只用 16 位保存。显然,在第一台机器中,程序可保存更大的值。所以,移植是令 C/C++ 程序员颇为头痛的一个问题。 - -Java 不需要` sizeof()` 方法来满足这种需求,因为所有类型的默认大小在不同平台间是相同的。我们不必考虑这个层次的移植问题 —— Java 本身就是一种“与平台无关”的语言。 +在 C/C++ 中,经常需要用到 `sizeof()` 方法来获取数据项被分配的字节大小。C/C++ 中使用 `sizeof()` 最有说服力的原因是为了移植性,不同数据在不同机器上可能有不同的大小,所以在进行大小敏感的运算时,程序员必须对这些类型有多大做到心中有数。例如,一台计算机可用 32 位来保存整数,而另一台只用 16 位保存。显然,在第一台机器中,程序可保存更大的值。所以,移植是令 C/C++ 程序员颇为头痛的一个问题。 +Java 不需要 ` sizeof()` 方法来满足这种需求,因为所有类型的大小在不同平台上是相同的。我们不必考虑这个层次的移植问题 —— Java 本身就是一种“与平台无关”的语言。 + ## 运算符总结 上述示例分别向我们展示了哪些基本类型能被用于特定的运算符。基本上,下面的代码示例是对上述所有示例的重复,只不过概括了所有的基本类型。这个文件能被正确地编译,因为我已经把编译不通过的那部分用注释 `//` 过滤了。代码示例: @@ -1453,7 +1474,7 @@ public class AllOps { **注意** :**boolean** 类型的的运算是受限的。你能为其赋值 `true` 或 `false`,也可测试它的值是否是 `true` 或 `false`。但你不能对其作加减等其他运算。 -在 **char** , **byte** 和 **short** 类型中,我们可以看到算术运算符的“类型转换”效果。我们必须要显式强制类型转换才能将结果重新赋值为原始类型。对于 **int** 类型的运算则不用转换,因为默认就是 **int** 型。虽然我们不用再停下来思考这一切是否安全,但是两个大的 int 型相乘时,结果有可能超出 **int** 型的范围,这种情况下结果会发生溢出。下面的代码示例: +在 **char**,**byte** 和 **short** 类型中,我们可以看到算术运算符的“类型转换”效果。我们必须要显式强制类型转换才能将结果重新赋值为原始类型。对于 **int** 类型的运算则不用转换,因为默认就是 **int** 型。虽然我们不用再停下来思考这一切是否安全,但是两个大的 int 型整数相乘时,结果有可能超出 **int** 型的范围,这种情况下结果会发生溢出。下面的代码示例: ```java // operators/Overflow.java @@ -1477,7 +1498,7 @@ bigger = -4 编译器没有报错或警告,运行时一切看起来都无异常。诚然,Java 是优秀的,但是还不足够优秀。 -对于 **char**,**byte** 或者 **short**,混合赋值并不需要类型转换。即使为它们执行转型操作,也会获得与直接算术运算相同的结果。另外,省略类型转换可以使代码显得更加简练。总之,除 **boolean** 以外,其他任何两种基本类型间都可进行类型转换。当我们进行向下转换类型时,需要注意结果的范围是否溢出,否则我们就很可能在不知不觉种丢失精度。 +对于 **char**,**byte** 或者 **short**,混合赋值并不需要类型转换。即使为它们执行转型操作,也会获得与直接算术运算相同的结果。另外,省略类型转换可以使代码显得更加简练。总之,除 **boolean** 以外,其他任何两种基本类型间都可进行类型转换。当我们进行向下转换类型时,需要注意结果的范围是否溢出,否则我们就很可能在不知不觉中丢失精度。 @@ -1487,11 +1508,7 @@ bigger = -4 [^1]: 我在 *Pomona College* 大学读过两年本科,在那里 47 被称之为“魔法数字”(*magic number*),详见 [维基百科](https://en.wikipedia.org/wiki/47_(number)) 。 - - -[^2]: *John Kirkham* 说过, “自 1960 年我开始在 IBM 1620 上开始编程起,至 1970 年之间,FORTRAN 一直都是一种全大写的编程语言。这可能是因为许多早期的输入设备都是旧的电传打字机,使用了5 位波特码,没有小写字母的功能。 - -指数符号中的 e 也总是大写的,并且从未与自然对数底数 e 混淆,自然对数底数 e 总是小写的。 e 简单地代表指数,通常 10 是基数。那时,八进制也被程序员广泛使用。虽然我从未见过它的用法,但如果我看到一个指数符号的八进制数,我会认为它是以 8 为基数的。我记得第一次看到指数使用小写字母 e 是在 20 世纪 70 年代末,我也发现它令人困惑。这个问题出现的时候,小写字母悄悄进入了 Fortran。如果你真的想使用自然对数底,我们实际上有一些函数要使用,但是它们都是大写的。 +[^2]: *John Kirkham* 说过:“自 1960 年我开始在 IBM 1620 上开始编程起,至 1970 年之间,FORTRAN 一直都是一种全大写的编程语言。这可能是因为许多早期的输入设备都是旧的电传打字机,使用了5 位波特码,没有小写字母的功能。指数符号中的 e 也总是大写的,并且从未与自然对数底数 e 混淆,自然对数底数 e 总是小写的。 e 简单地代表指数,通常 10 是基数。那时,八进制也被程序员广泛使用。虽然我从未见过它的用法,但如果我看到一个指数符号的八进制数,我会认为它是以 8 为基数的。我记得第一次看到指数使用小写字母 e 是在 20 世纪 70 年代末,我也发现它令人困惑。这个问题出现的时候,小写字母悄悄进入了 Fortran。如果你真的想使用自然对数底,我们实际上有一些函数要使用,但是它们都是大写的。” diff --git a/docs/book/05-Control-Flow.md b/docs/book/05-Control-Flow.md index 026b560..f9674a6 100644 --- a/docs/book/05-Control-Flow.md +++ b/docs/book/05-Control-Flow.md @@ -2,9 +2,9 @@ # 第五章 控制流 -> 程序必须在执行过程中控制它的世界并做出选择。 在 Java 中,你需要使用执行控制语句来做出选择。 +> 程序必须在执行过程中控制它的世界并做出选择。 在 Java 中,你需要执行控制语句来做出选择。 -Java 使用了 C 的所有执行控制语句,因此对于熟悉 C/C++ 编程的人来说,这部分内容轻车熟路。 大多数面向过程编程语言都有共通的某种控制语句。在 Java 中,涉及的关键字包括 **if-else,while,do-while,for,return,break** 和选择语句 **switch**。 Java 并不支持备受诟病的 **goto**(尽管它在某些特殊场景中依然是最行之有效的方法)。 尽管如此,在 Java 中我们仍旧可以进行类似的逻辑跳转,但较之典型的 **goto** 用法限制更多。 +Java 使用了 C 的所有执行控制语句,因此对于熟悉 C/C++ 编程的人来说,这部分内容轻车熟路。大多数面向过程编程语言都有共通的某种控制语句。在 Java 中,涉及的关键字包括 **if-else,while,do-while,for,return,break** 和选择语句 **switch**。 Java 并不支持备受诟病的 **goto**(尽管它在某些特殊场景中依然是最行之有效的方法)。 尽管如此,在 Java 中我们仍旧可以进行类似的逻辑跳转,但较之典型的 **goto** 用法限制更多。 ## true和flase @@ -48,7 +48,7 @@ else “statement” ``` -布尔表达式(Boolean-expression)必须生成 **boolean** 类型的结果,执行语句 `statement` 既可以是只包含单个分号 `;` 的简单语句,也可以是包含大括号 `{}` 的复合语句 —— 封闭在大括号内的一组简单语句。 凡本书中提及的“statement”一词,皆表示类似的执行语句。 +布尔表达式(Boolean-expression)必须生成 **boolean** 类型的结果,执行语句 `statement` 既可以是以分号 `;` 结尾的一条简单语句,也可以是包含在大括号 `{}` 内的的复合语句 —— 封闭在大括号内的一组简单语句。 凡本书中提及“statement”一词,皆表示类似的执行语句。 下面是一个有关 **if-else** 语句的例子。`test()` 方法可以告知你两个数值之间的大小关系。代码示例: @@ -86,12 +86,12 @@ public class IfElse { **注解**:`else if` 并非新关键字,它仅是 `else` 后紧跟的一条新 `if` 语句。 -Java 和 C/C++ 同属“自由格式”的编程语言,但通常我们会在 Java 控制流程语句中采用首尾缩进的规范,以便代码更具可读性。 +Java 和 C/C++ 同属“自由格式”的编程语言,但通常我们会在 Java 控制流程语句中采用首部缩进的规范,以便代码更具可读性。 ## 迭代语句 -**while**,**do-while** 和 **for** 用来控制循环语句(有时也称迭代语句)。 只有控制循环的布尔表达式计算结果为 `false`,循环语句才会停止。 +**while**,**do-while** 和 **for** 用来控制循环语句(有时也称迭代语句)。只有控制循环的布尔表达式计算结果为 `false`,循环语句才会停止。 ### while @@ -138,12 +138,9 @@ true, Inside 'while' false, Exited 'while' ``` - `condition()` 方法使用到了 **Math** 库的**静态**方法 `random()`。该方法的作用是产生 0 和 1 之间 (包括 0,但不包括 1) 的一个 **double** 值。 -`result` 的值是通过判断 - -**result** 的值是通过比较运算符 `<` 来产生的 **boolean** 类型的结果。当控制台输出 **boolean** 型值时,会自动将其转换未对应的文字形式 `true` 或 `false`。此处 `while` 条件表达式代表:“仅在 `condition()` 返回 `false` 时停止循环”。 +**result** 的值是通过比较运算符 `<` 产生的 **boolean** 类型的结果。当控制台输出 **boolean** 型值时,会自动将其转换为对应的文字形式 `true` 或 `false`。此处 `while` 条件表达式代表:“仅在 `condition()` 返回 `false` 时停止循环”。 ### do-while @@ -206,12 +203,13 @@ value: 106 character: j 传统的面向过程语言如 C 需要先在代码块(block)前定义好所有变量才能够使用。这样编译器才能在创建块时,为这些变量分配内存空间。在 Java 和 C++ 中,我们可以在整个块使用变量声明,并且可以在需要时才定义变量。 这种自然的编码风格使我们的代码更容易被人理解 [^1]。 -上例使用了 **java.lang.Character** 包装类,该类不仅包含了基本类型 `char` 的值,还封装了一些有用的方法。例如这里就用到了静态方法 `isLowerCase()` 来判断字符否为小写。 +上例使用了 **java.lang.Character** 包装类,该类不仅包含了基本类型 `char` 的值,还封装了一些有用的方法。例如这里就用到了静态方法 `isLowerCase()` 来判断字符是否为小写。 + #### 逗号操作符 -在 Java 中逗号运算符(这里并非指我们平常用于分隔定义和方法参数的逗号分隔符)仅有一种用法:在 **for** 循环的初始化和步进控制中定义多个变量。我们可以使用逗号分隔多个语句,并按顺序计算这些语句。**注意**:哟啊求定义的变量类型相同。代码示例: +在 Java 中逗号运算符(这里并非指我们平常用于分隔定义和方法参数的逗号分隔符)仅有一种用法:在 **for** 循环的初始化和步进控制中定义多个变量。我们可以使用逗号分隔多个语句,并按顺序计算这些语句。**注意**:要求定义的变量类型相同。代码示例: ```java // control/CommaOperator.java @@ -239,7 +237,7 @@ i = 4 j = 8 ## for-in语法 -Java 5 引入了更为简洁的“增强版 **for** 循环”语法来操纵数组和集合。(更多细节,可参考 [数组](./21-Arrays.md) 和 [集合](./12-Collections.md) 章节内容)。,大部分文档也称其为 **for-each** 语法,但因为了不与 Java 8 新添的 `forEach()` 产生混淆,因此我称之为 **for-in** 循环。 (Python 已有类似的先例,如:**for x in sequence**)。**注意**:你可能会在其他地方看到不同叫法。 +Java 5 引入了更为简洁的“增强版 **for** 循环”语法来操纵数组和集合。(更多细节,可参考 [数组](./21-Arrays.md) 和 [集合](./12-Collections.md) 章节内容)。大部分文档也称其为 **for-each** 语法,但因为了不与 Java 8 新添的 `forEach()` 产生混淆,因此我称之为 **for-in** 循环。 (Python 已有类似的先例,如:**for x in sequence**)。**注意**:你可能会在其他地方看到不同叫法。 **for-in** 无需你去创建 **int** 变量和步进来控制循环计数。 下面我们来遍历获取 **float** 数组中的元素。代码示例: @@ -290,7 +288,7 @@ for(float x : f) { public class ForInString { public static void main(String[] args) { - for(char c : "An African Swallow".toCharArray()) + for(char c: "An African Swallow".toCharArray()) System.out.print(c + " "); } } @@ -302,10 +300,9 @@ public class ForInString { A n A f r i c a n S w a l l o w ``` -很快我们能在 [集合](./12-Collections.md) 章节里学习到, - **for-in** 循环适用于任何可迭代(*iterable*)的 对象。 +很快我们能在 [集合](./12-Collections.md) 章节里学习到,**for-in** 循环适用于任何可迭代(*iterable*)的 对象。 -通常, **for** 循环语句都会在一个整型数值序列中步进。代码示例: +通常,**for** 循环语句都会在一个整型数值序列中步进。代码示例: ```java for(int i = 0; i < 100; i++) @@ -324,7 +321,7 @@ public class ForInInt { public static void main(String[] args) { for(int i : range(10)) // 0..9 System.out.print(i + " "); - System.out.println(); + System.out.println(); for(int i : range(5, 10)) // 5..9 System.out.print(i + " "); System.out.println(); @@ -346,7 +343,6 @@ public class ForInInt { 20 17 14 11 8 ``` - `range()` 方法已被 [重载](./06-Housekeeping.md#方法重载)(重载:同名方法,参数列表或类型不同)。上例中 `range()` 方法有多种重载形式:第一种产生从 0 至范围上限(不包含)的值;第二种产生参数一至参数二(不包含)范围内的整数值;第三种形式有一个步进值,因此它每次的增量为该值;第四种 `range()` 表明还可以递减。`range()` 无参方法是该生成器最简单的版本。有关内容会在本书稍后介绍。 `range()` 的使用提高了代码可读性,让 **for-in** 循环在本书中适应更多的代码示例场景。 @@ -359,7 +355,7 @@ public class ForInInt { 在 Java 中有几个关键字代表无条件分支,这意味无需任何测试即可发生。这些关键字包括 **return**,**break**,**continue** 和跳转到带标签语句的方法,类似于其他语言中的 **goto**。 -**return** 关键字有两方面的作用:1.指定一个方法返回值(在方法返回类型非 **void** 的情况下);2.退出当前方法,并返回作用 1 中值。我们可以利用 `return` 的这些特点来改写上例 `IfElse.java` 文件朱姑中的 `test()` 方法。代码示例: +**return** 关键字有两方面的作用:1.指定一个方法返回值(在方法返回类型非 **void** 的情况下);2.退出当前方法,并返回作用 1 中值。我们可以利用 `return` 的这些特点来改写上例 `IfElse.java` 文件中的 `test()` 方法。代码示例: ```java // control/TestWithReturn.java @@ -395,9 +391,9 @@ public class TestWithReturn { ## break和continue -在任何迭代语句的主体内,都可以使用 **break** 和 **continue** 来控制循环的流程。 其中,**break** 表示跳出当前循环体。 而 **continue** 表示停止本次循环,开始下一次循环。 +在任何迭代语句的主体内,都可以使用 **break** 和 **continue** 来控制循环的流程。 其中,**break** 表示跳出当前循环体。而 **continue** 表示停止本次循环,开始下一次循环。 -下例向大家展示 **break** 和 **continue** 在 **for**、**while** 循环中的使运用。代码示例: +下例向大家展示 **break** 和 **continue** 在 **for**、**while** 循环中的使用。代码示例: ```java // control/BreakAndContinue.java @@ -443,10 +439,7 @@ public class BreakAndContinue { **[1]** 在这个 **for** 循环中,`i` 的值永远不会达到 100,因为一旦 `i` 等于 74,**break** 语句就会中断循环。通常,只有在不知道中断条件何时满足时,才需要 **break**。因为 `i` 不能被 9 整除,**continue** 语句就会使循环从头开始。这使 **i** 递增)。如果能够整除,则将值显示出来。 **[2]** 使用 **for-in** 语法,结果相同。 - **[3]** 无限 **while** 循环。循环内的 **break** 语句可中止循环。**注意**,**continue** 语句可将控制权移回循环的顶部,而不会执行 **continue** 之后的任何操作。 因此,只有当 `i` 的值可被 10 整除时才会输出。在输出中,显示值 0,因为 `0%9` 产生 0。 - - -还有一种无限循环的形式: `for(;;)`。 在编译器看来,它与 `while(true)` 无异,使用哪种完全取决于你的编程品味。 + **[3]** 无限 **while** 循环。循环内的 **break** 语句可中止循环。**注意**,**continue** 语句可将控制权移回循环的顶部,而不会执行 **continue** 之后的任何操作。 因此,只有当 `i` 的值可被 10 整除时才会输出。在输出中,显示值 0,因为 `0%9` 产生 0。还有一种无限循环的形式: `for(;;)`。 在编译器看来,它与 `while(true)` 无异,使用哪种完全取决于你的编程品味。 ## 臭名昭著的goto @@ -457,7 +450,7 @@ public class BreakAndContinue { 正如上述提及的经典情况,我们不应走向两个极端。问题不在 **goto**,而在于过度使用 **goto**。在极少数情况下,**goto** 实际上是控制流程的最佳方式。 -尽管 **goto** 仍是 Java 的一个保留字,但其并未被到正式启用。可以说, Java 中并不支持 **goto**。然而,在 **break** 和 **continue** 这两个关键字的身上,我们仍能看出一些 **goto** 的影子。它们并不属于一次跳转,而是中断循环语句的一种方法。之所以把它们纳入 **goto** 问题中一起讨论,是由于它们使用了相同的机制:标签。 +尽管 **goto** 仍是 Java 的一个保留字,但其并未被正式启用。可以说, Java 中并不支持 **goto**。然而,在 **break** 和 **continue** 这两个关键字的身上,我们仍能看出一些 **goto** 的影子。它们并不属于一次跳转,而是中断循环语句的一种方法。之所以把它们纳入 **goto** 问题中一起讨论,是由于它们使用了相同的机制:标签。 “标签”是后面跟一个冒号的标识符。代码示例: @@ -708,7 +701,7 @@ z, 122: consonant ... ``` -由于 `Random.nextInt(26)` 会产生 0 到 26 之间的一个值,所以在其上加上一个偏移量 `a`,即可产生小写字母。在 **case** 语句中,使用单引号引起的字符也会产生用于比较的整数值。 +由于 `Random.nextInt(26)` 会产生 0 到 25 之间的一个值,所以在其上加上一个偏移量 `a`,即可产生小写字母。在 **case** 语句中,使用单引号引起的字符也会产生用于比较的整数值。 请注意 **case** 语句能够堆叠在一起,为一段代码形成多重匹配,即只要符合多种条件中的一种,就执行那段特别的代码。这时也应该注意将 **break** 语句置于特定 **case** 的末尾,否则控制流程会继续往下执行,处理后面的 **case**。在下面的语句中: @@ -718,8 +711,8 @@ int c = rand.nextInt(26) + 'a'; 此处 `Random.nextInt()` 将产生 0~25 之间的一个随机 **int** 值,它将被加到 `a` 上。这表示 `a` 将自动被转换为 **int** 以执行加法。为了把 `c` 当作字符打印,必须将其转型为 **char**;否则,将会输出整数。 - + ## switch字符串 Java 7 增加了在字符串上 **switch** 的用法。 下例展示了从一组 **String** 中选择可能值的传统方法,以及新式方法: @@ -830,4 +823,5 @@ java RandomBounds upper [^2]: **注意**,此处观点似乎难以让人信服,很可能只是一个因认知偏差而造成的[因果关系谬误](https://en.wikipedia.org/wiki/Correlation_does_not_imply_causation)的例子。 +
\ No newline at end of file diff --git a/docs/book/06-Housekeeping.md b/docs/book/06-Housekeeping.md index 7178d7c..4a3d263 100644 --- a/docs/book/06-Housekeeping.md +++ b/docs/book/06-Housekeeping.md @@ -1,48 +1,1815 @@ [TOC] + # 第六章 初始化和清理 +"不安全"的编程是造成编程代价昂贵的罪魁祸首之一。有两个安全性问题:初始化和清理。C 语言中很多的 bug 都是因为程序员忘记初始化导致的。尤其是很多类库的使用者不知道如何初始化类库组件,甚至他们必须得去初始化。清理则是另一个特殊的问题,因为当你使用一个元素做完事后就不会去关心这个元素,所以你很容易忘记清理它。这样就造成了元素使用的资源滞留不会被回收,直到程序消耗完所有的资源(特别是内存)。 + +C++ 引入了构造器的概念,这是一个特殊的方法,每创建一个对象,这个方法就会被自动调用。Java 采用了构造器的概念,另外还使用了垃圾收集器(Garbage Collector, GC)去自动回收不再被使用的对象所占的资源。这一章将讨论初始化和清理的问题,以及在 Java 中对它们的支持。 + ## 利用构造器保证初始化 +你可能想为每个类创建一个 `initialize()` 方法,该方法名暗示着在使用类之前需要先调用它。不幸的是,用户必须得记得去调用它。在 Java 中,类的设计者通过构造器保证每个对象的初始化。如果一个类有构造器,那么 Java 会在用户使用对象之前(即对象刚创建完成)自动调用对象的构造器方法,从而保证初始化。下个挑战是如何命名构造器方法。存在两个问题:第一个是任何命名都可能与类中其他已有元素的命名冲突;第二个是编译器必须始终知道构造器方法名称,从而调用它。C++ 的解决方法看起来是最简单且最符合逻辑的,所以 Java 中使用了同样的方式:构造器名称与类名相同。在初始化过程中自动调用构造器方法是有意义的。 + +以下示例是包含了一个构造器的类: + +```java +// housekeeping/SimpleConstructor.java +// Demonstration of a simple constructor + +class Rock { + Rock() { // 这是一个构造器 + System.out.print("Rock "); + } +} + +public class SimpleConstructor { + public static void main(String[] args) { + for (int i = 0; i < 10; i++) { + new Rock(); + } + } +} +``` + +输出: + +````java +Rock Rock Rock Rock Rock Rock Rock Rock Rock Rock +```` + +现在,当创建一个对象时:`new Rock()` ,内存被分配,构造器被调用。构造器保证了对象在你使用它之前进行了正确的初始化。 + +有一点需要注意,构造器方法名与类名相同,不需要符合首字母小写的编程风格。在 C++ 中,无参构造器被称为默认构造器,这个术语在 Java 出现之前使用了很多年。但是,出于一些原因,Java 设计者们决定使用无参构造器这个名称,我(作者)认为这种叫法笨拙而且没有必要,所以我打算继续使用默认构造器。Java 8 引入了 **default** 关键字修饰方法,所以算了,我还是用无参构造器的叫法吧。 + +跟其他方法一样,构造器方法也可以传入参数来定义如何创建一个对象。之前的例子稍作修改,使得构造器接收一个参数: + +```java +// housekeeping/SimpleConstructor2.java +// Constructors can have arguments + +class Rock2 { + Rock2(int i) { + System.out.print("Rock " + i + " "); + } +} + +public class SimpleConstructor2 { + public static void main(String[] args) { + for (int i = 0; i < 8; i++) { + new Rock2(i); + } + } +} +``` + +输出: + +```java +Rock 0 Rock 1 Rock 2 Rock 3 Rock 4 Rock 5 Rock 6 Rock 7 +``` + +如果类 **Tree** 有一个构造方法,只接收一个参数用来表示树的高度,那么你可以像下面这样创建一棵树: + +```java +Tree t = new Tree(12); // 12-foot 树 +``` + +如果 **Tree(int)** 是唯一的构造器,那么编译器就不允许你以其他任何方式创建 **Tree** 类型的对象。 + +构造器消除了一类重要的问题,使得代码更易读。例如,在上面的代码块中,你看不到对 `initialize()` 方法的显式调用,而从概念上来看,`initialize()` 方法应该与对象的创建分离。在 Java 中,对象的创建与初始化是统一的概念,二者不可分割。 + +构造器是一种特殊的方法,因为它没有返回值。这与返回 **void** 值的方法不同,在返回 **void** 值的方法中,方法返回空值,但是你还是有选择返回一些其他值。构造器返回空值,你没有选择(**new** 表达式的确返回了新创建对象的引用,但是构造器自身并没有返回值 )。假如有返回值,而且你可以自由选择,那么编译器得知道如何去处理这个返回值。 + ## 方法重载 +任何编程语言中都具备的一项重要特性就是命名。当你创建一个对象时,就会给此对象分配的内存空间命名。方法是行为的命名。你通过名字指代所有的对象,属性和方法。良好命名的系统易于理解和修改。就好比写散文——目的是与读者沟通。 + +将人类语言细微的差别映射到编程语言中会产生一个问题。通常,相同的词可以表达多种不同的含义——它们被"重载"了。特别是当含义的差别很小时,这会更加有用。你会说"清洗衬衫"、"清洗车"和"清洗狗"。而如果硬要这么说就会显得很愚蠢:"以洗衬衫的方式洗衬衫"、"以洗车的方式洗车"和"以洗狗的方式洗狗",因为听众根本不需要区分行为的动作。大多数人类语言都具有"冗余"性,所以即使漏掉几个词,你也能明白含义。你不需要对每个概念都使用不同的词汇——可以从上下文推断出含义。 + +大多数编程语言(尤其是 C 语言)要求为每个方法(在这些语言中经常称为函数)提供一个独一无二的标识符。所以,你不能有一个 `print()` 函数既能打印整型,也能打印浮点型——每个函数名都必须不同。 + +在 Java (C++) 中,还有一个因素也促使了必须使用方法重载:构造器。因为构造器方法名肯定是与类名相同,所以一个类中只会有一个构造器名。那么你怎么通过不同的方式创建一个对象呢?例如,你想创建一个类,这个类的初始化方式有两种:一种是标准化方式,另一种是从文件中读取信息的方式。你需要两个构造器:无参构造器和有一个 **String** 类型参数的构造器,该参数传入文件名。两个构造器具有相同的名字——与类名相同。因此,方法重载是必要的,它允许方法具有相同的方法名但接收的参数不同。尽管方法重载对于构造器是重要的,但是也可以对任何方法很方便地进行重载。 + +下例展示了如何重载构造器和方法: + +```java +// housekeeping/Overloading.java +// Both constructor and ordinary method overloading + +class Tree { + int height; + Tree() { + System.out.println("Planting a seedling"); + height = 0; + } + Tree(int initialHeight) { + height = initialHeight; + System.out.println("Creating new Tree that is " + height + " feet tall"); + } + void info() { + System.out.println("Tree is " + height + " feet tall"); + } + void info(String s) { + System.out.println(s + ": Tree is " + height + " feet tall"); + } +} +public class Overloading { + public static void main(String[] args) { + for (int i = 0; i < 5; i++) { + Tree t = new Tree(i); + t.info(); + t.info("overloaded method"); + } + new Tree(); + } +} +``` + +输出: + +```java +Creating new Tree that is 0 feet tall +Tree is 0 feet tall +overloaded method: Tree is 0 feet tall +Creating new Tree that is 1 feet tall +Tree is 1 feet tall +overloaded method: Tree is 1 feet tall +Creating new Tree that is 2 feet tall +Tree is 2 feet tall +overloaded method: Tree is 2 feet tall +Creating new Tree that is 3 feet tall +Tree is 3 feet tall +overloaded method: Tree is 3 feet tall +Creating new Tree that is 4 feet tall +Tree is 4 feet tall +overloaded method: Tree is 4 feet tall +Planting a seedling +``` + +一个 **Tree** 对象既可以是一颗树苗,使用无参构造器创建,也可以是一颗在温室中已长大的树,已经有一定高度,这时候,就需要使用有参构造器创建。 + +你也许想以多种方式调用 `info()` 方法。比如,如果你想打印额外的消息,就可以使用 `info(String)` 方法。如果你无话可说,就可以使用 `info()` 方法。用两个命名定义完全相同的概念看起来很奇怪,而使用方法重载,你就可以使用一个命名来定义一个概念。 + +### 区分重载方法 + +如果两个方法命名相同,Java是怎么知道你调用的是哪个呢?有一条简单的规则:每个被重载的方法必须有独一无二的参数列表。你稍微思考下,就会很明了了,除了通过参数列表的不同来区分两个相同命名的方法,其他也没什么方式了。你甚至可以根据参数列表中的参数顺序来区分不同的方法,尽管这会造成代码难以维护。例如: + +```java +// housekeeping/OverloadingOrder.java +// Overloading based on the order of the arguments + +public class OverloadingOrder { + static void f(String s, int i) { + System.out.println("String: " + s + ", int: " + i); + } + + static void f(int i, String s) { + System.out.println("int: " + i + ", String: " + s); + } + + public static void main(String[] args) { + f("String first", 1); + f(99, "Int first"); + } +} +``` + +输出: + +```java +String: String first, int: 1 +int: 99, String: Int first +``` + +两个 `f()` 方法具有相同的参数,但是参数顺序不同,根据这个就可以区分它们。 + +### 重载与基本类型 + +基本类型可以自动从较小的类型转型为较大的类型。当这与重载结合时,这会令人有点困惑,下面是一个这样的例子: + +```java +// housekeeping/PrimitiveOverloading.java +// Promotion of primitives and overloading + +public class PrimitiveOverloading { + void f1(char x) { + System.out.print("f1(char)"); + } + void f1(byte x) { + System.out.print("f1(byte)"); + } + void f1(short x) { + System.out.print("f1(short)"); + } + void f1(int x) { + System.out.print("f1(int)"); + } + void f1(long x) { + System.out.print("f1(long)"); + } + void f1(float x) { + System.out.print("f1(float)"); + } + void f1(double x) { + System.out.print("f1(double)"); + } + void f2(byte x) { + System.out.print("f2(byte)"); + } + void f2(short x) { + System.out.print("f2(short)"); + } + void f2(int x) { + System.out.print("f2(int)"); + } + void f2(long x) { + System.out.print("f2(long)"); + } + void f2(float x) { + System.out.print("f2(float)"); + } + void f2(double x) { + System.out.print("f2(double)"); + } + void f3(short x) { + System.out.print("f3(short)"); + } + void f3(int x) { + System.out.print("f3(int)"); + } + void f3(long x) { + System.out.print("f3(long)"); + } + void f3(float x) { + System.out.print("f3(float)"); + } + void f3(double x) { + System.out.print("f3(double)"); + } + void f4(int x) { + System.out.print("f4(int)"); + } + void f4(long x) { + System.out.print("f4(long)"); + } + void f4(float x) { + System.out.print("f4(float)"); + } + void f4(double x) { + System.out.print("f4(double)"); + } + void f5(long x) { + System.out.print("f5(long)"); + } + void f5(float x) { + System.out.print("f5(float)"); + } + void f5(double x) { + System.out.print("f5(double)"); + } + void f6(float x) { + System.out.print("f6(float)"); + } + void f6(double x) { + System.out.print("f6(double)"); + } + void f7(double x) { + System.out.print("f7(double)"); + } + void testConstVal() { + System.out.print("5: "); + f1(5);f2(5);f3(5);f4(5);f5(5);f6(5);f7(5); + System.out.println(); + } + void testChar() { + char x = 'x'; + System.out.print("char: "); + f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); + System.out.println(); + } + void testByte() { + byte x = 0; + System.out.print("byte: "); + f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); + System.out.println(); + } + void testShort() { + short x = 0; + System.out.print("short: "); + f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); + System.out.println(); + } + void testInt() { + int x = 0; + System.out.print("int: "); + f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); + System.out.println(); + } + void testFloat() { + float x = 0; + System.out.print("float: "); + f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); + System.out.println(); + } + void testDouble() { + double x = 0; + System.out.print("double: "); + f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); + System.out.println(); + } + + public static void main(String[] args) { + PrimitiveOverloading p = new PrimitiveOverloading(); + p.testConstVal(); + p.testChar(); + p.testByte(); + p.testShort(); + p.testInt(); + p.testFloat(); + p.testDouble(); + } +} +``` + +输出: + +```java +5: f1(int)f2(int)f3(int)f4(int)f5(long)f6(float)f7(double) +char: f1(char)f2(int)f3(int)f4(int)f5(long)f6(float)f7(double) +byte: f1(byte)f2(byte)f3(short)f4(int)f5(long)f6(float)f7(double) +short: f1(short)f2(short)f3(short)f4(int)f5(long)f6(float)f7(double) +int: f1(int)f2(int)f3(int)f4(int)f5(long)f6(float)f7(double) +float: f1(float)f2(float)f3(float)f4(float)f5(float)f6(float)f7(double) +double: f1(double)f2(double)f3(double)f4(double)f5(double)f6(double)f7(double) +``` + +如果传入的参数类型大于方法期望接收的参数类型,你必须首先做下转换,如果你不做的话,编译器就会报错。 + +### 返回值的重载 + +经常会有人困惑,"为什么只能通过类名和参数列表,不能通过方法的返回值区分方法呢?"。例如以下两个方法,它们有相同的命名和参数,但是很容易区分: + +```java +void f(){} +int f() {return 1;} +``` + +有些情况下,编译器很容易就可以从上下文准确推断出该调用哪个方法,如 `int x = f()`。 + +但是,你可以调用一个方法且忽略返回值。这叫做调用一个函数的副作用,因为你不在乎返回值,只是想利用方法做些事。所以如果你直接调用 `f()`,Java 编译器就不知道你想调用哪个方法,阅读者也不明所以。因为这个原因,所以你不能根据返回值类型区分重载的方法。为了支持新特性,Java 8 在一些具体情形下提高了猜测的准确度,但是通常来说并不起作用。 + ## 无参构造器 +如前文所说,一个无参构造器就是不接收参数的构造器,用来创建一个"默认的对象"。如果你创建一个类,类中没有构造器,那么编译器就会自动为你创建一个无参构造器。例如: + +```java +// housekeeping/DefaultConstructor.java +class Bird {} +public class DefaultConstructor { + public static void main(String[] args) { + Bird bird = new Bird(); // 默认的 + } +} +``` + +表达式 `new Bird()` 创建了一个新对象,调用了无参构造器,尽管在 **Bird** 类中并没有显式的定义无参构造器。试想如果没有构造器,我们如何创建一个对象呢。但是,一旦你显式地定义了构造器(无论有参还是无参),编译器就不会自动为你创建无参构造器。如下: + +```java +// housekeeping/NoSynthesis.java +class Bird2 { + Bird2(int i) {} + Bird2(double d) {} +} +public class NoSynthesis { + public static void main(String[] args) { + //- Bird2 b = new Bird2(); // No default + Bird2 b2 = new Bird2(1); + Bird2 b3 = new Bird2(1.0); + } +} +``` + +如果你调用了 `new Bird2()` ,编译器会提示找不到匹配的构造器。当类中没有构造器时,编译器会说"你一定需要构造器,那么让我为你创建一个吧"。但是如果类中有构造器,编译器会说"你已经写了构造器了,所以肯定知道你在做什么,如果你没有创建默认构造器,说明你本来就不需要"。 + ## this关键字 +对于两个相同类型的对象 **a** 和 **b**,你可能在想如何调用这两个对象的 `peel()` 方法: + +```java +// housekeeping/BananaPeel.java + +class Banana { + void peel(int i) { + /*...*/ + } +} +public class BananaPeel { + public static void main(String[] args) [ + Banana a = new Banana(), b = new Banana(); + a.peel(1); + b.peel(2); + ] +} +``` + +如果只有一个方法 `peel()` ,那么怎么知道调用的是对象 **a** 的 `peel()`方法还是对象 **b** 的 `peel()` 方法呢?编译器做了一些底层工作,所以你可以像这样编写代码。`peel()` 方法中第一个参数隐密地传入了一个指向操作对象的 + +引用。因此,上述例子中的方法调用像下面这样: + +```java +Banana.peel(a, 1) +Banana.peel(b, 1) +``` + +这是在内部实现的,你不可以直接这么编写代码,编译器不会接受,但能说明到底发生了什么。假设现在在方法内部,你想获得对当前对象的引用。但是,对象引用是被秘密地传达给编译器——并不在参数列表中。方便的是,有一个关键字: **this** 。**this** 关键字只能在非静态方法内部使用。当你调用一个对象的方法时,**this** 生成了一个对象引用。你可以像对待其他引用一样对待这个引用。如果你在一个类的方法里调用其他该类中的方法,不要使用 **this**,直接调用即可,**this** 自动地应用于其他方法上了。因此你可以像这样: + +```java +// housekeeping/Apricot.java + +public class Apricot { + void pick() { + /* ... */ + } + + void pit() { + pick(); + /* ... */ + } +} +``` + +在 `pit()` 方法中,你可以使用 `this.pick()`,但是没有必要。编译器自动为你做了这些。**this** 关键字只用在一些必须显式使用当前对象引用的特殊场合。例如,用在 **return** 语句中返回对当前对象的引用。 + +```java +// housekeeping/Leaf.java +// Simple use of the "this" keyword + +public class Leaf { + + int i = 0; + + Leaf increment() { + i++; + return this; + } + + void print() { + System.out.println("i = " + i); + } + + public static void main(String[] args) { + Leaf x = new Leaf(); + x.increment().increment().increment().print(); + } +} +``` + +输出: + +``` +i = 3 +``` + +因为 `increment()` 通过 **this** 关键字返回当前对象的引用,因此在相同的对象上可以轻易地执行多次操作。 + +**this** 关键字在向其他方法传递当前对象时也很有用: + +```java +// housekeeping/PassingThis.java + +class Person { + public void eat(Apple apple) { + Apple peeled = apple.getPeeled(); + System.out.println("Yummy"); + } +} + +public class Peeler { + static Apple peel(Apple apple) { + // ... remove peel + return apple; // Peeled + } +} + +public class Apple { + Apple getPeeled() { + return Peeler.peel(this); + } +} + +public class PassingThis { + public static void main(String[] args) { + new Person().eat(new Apple()); + } +} +``` + +输出: + +``` +Yummy +``` + +**Apple** 因为某些原因(比如说工具类中的方法在多个类中重复出现,你不想代码重复),必须调用一个外部工具方法 `Peeler.peel()` 做一些行为。必须使用 **this** 才能将自身传递给外部方法。 + +### 在构造器中调用构造器 + +当你在一个类中写了多个构造器,有时你想在一个构造器中调用另一个构造器来避免代码重复。你通过 **this** 关键字实现这样的调用。 + +通常当你说 **this**,意味着"这个对象"或"当前对象",它本身生成对当前对象的引用。在一个构造器中,当你给 **this** 一个参数列表时,它是另一层意思。它通过最直接的方式显式地调用匹配参数列表的构造器: + +```java +// housekeeping/Flower.java +// Calling constructors with "this" + +public class Flower { + int petalCount = 0; + String s = "initial value"; + + Flower(int petals) { + petalCount = petals; + System.out.println("Constructor w/ int arg only, petalCount = " + petalCount); + } + + Flower(String ss) { + System.out.println("Constructor w/ string arg only, s = " + ss); + s = ss; + } + + Flower(String s, int petals) { + this(petals); + //- this(s); // Can't call two! + this.s = s; // Another use of "this" + System.out.println("String & int args"); + } + + Flower() { + this("hi", 47); + System.out.println("no-arg constructor"); + } + + void printPetalCount() { + //- this(11); // Not inside constructor! + System.out.println("petalCount = " + petalCount + " s = " + s); + } + + public static void main(String[] args) { + Flower x = new Flower(); + x.printPetalCount(); + } +} +``` + +输出: + +``` +Constructor w/ int arg only, petalCount = 47 +String & int args +no-arg constructor +petalCount = 47 s = hi +``` + +从构造器 `Flower(String s, int petals)` 可以看出,其中只能通过 **this** 调用一次构造器。另外,必须首先调用构造器,否则编译器会报错。这个例子同样展示了 **this** 的另一个用法。参数列表中的变量名 **s** 和成员变量名 **s** 相同,会引起混淆。你可以通过 `this.s` 表明你指的是成员变量 **s**,从而避免重复。你经常会在 Java 代码中看到这种用法,同时本书中也会多次出现这种写法。在 `printPetalCount()` 方法中,编译器不允许你在一个构造器之外的方法里调用构造器。 + +### static 的含义 + +记住了 **this** 关键字的内容,你会对 **static** 修饰的方法有更加深入的理解:**static** 方法中不会存在 **this**。你不能在静态方法中调用非静态方法(反之可以)。静态方法是为类而创建的,不需要任何对象。事实上,这就是静态方法的主要目的,静态方法看起来就像全局方法一样,但是 Java 中不允许全局方法,一个类中的静态方法可以被其他的静态方法和静态属性访问。一些人认为静态方法不是面向对象的,因为它们的确具有全局方法的语义。使用静态方法,因为不存在 **this**,所以你没有向一个对象发送消息。的确,如果你发现代码中出现了大量的 **static** 方法,就该重新考虑自己的设计了。然而,**static** 的概念很实用,许多时候都要用到它。至于它是否真的"面向对象",就留给理论家去讨论吧。 + ## 垃圾回收器 +程序员都了解初始化的重要性,但通常会忽略清理的重要性。毕竟,谁会去清理一个 **int** 呢?但是使用完一个对象就不管它并非总是安全的。Java 中有垃圾回收器回收无用对象占用的内存。但现在考虑一种特殊情况:你创建的对象不是通过 **new** 来分配内存的,而垃圾回收器只知道如何释放用 **new** 创建的对象的内存,所以它不知道如何回收不是 **new** 分配的内存。为了处理这种情况,Java 允许在类中定义一个名为 `finalize()` 的方法。 + +它的工作原理"假定"是这样的:当垃圾回收器准备回收对象的内存时,首先会调用其 `finalize()` 方法,并在下一轮的垃圾回收动作发生时,才会真正回收对象占用的内存。所以如果你打算使用 `finalize()` ,就能在垃圾回收时做一些重要的清理工作。`finalize()` 是一个潜在的编程陷阱,因为一些程序员(尤其是 C++ 程序员)会一开始把它误认为是 C++ 中的析构函数(C++ 在销毁对象时会调用这个函数)。所以有必要明确区分一下:在 C++ 中,对象总是被销毁的(在一个 bug-free 的程序中),而在 Java 中,对象并非总是被垃圾回收,或者换句话说: + +1. 对象可能不被垃圾回收。 +2. 垃圾回收不等同于析构。 + +这意味着在你不再需要某个对象之前,如果必须执行某些动作,你得自己去做。Java 没有析构器或类似的概念,所以你必须得自己创建一个普通的方法完成这项清理工作。例如,对象在创建的过程中会将自己绘制到屏幕上。如果不是明确地从屏幕上将其擦除,它可能永远得不到清理。如果在 `finalize()` 方法中加入某种擦除功能,那么当垃圾回收发生时,`finalize()` 方法被调用(不保证一定会发生),图像就会被擦除,要是"垃圾回收"没有发生,图像则仍会保留下来。 + +也许你会发现,只要程序没有濒临内存用完的那一刻,对象占用的空间就总也得不到释放。如果程序执行结束,而垃圾回收器一直没有释放你创建的任何对象的内存,则当程序退出时,那些资源会全部交还给操作系统。这个策略是恰当的,因为垃圾回收本身也有开销,要是不使用它,那就不用支付这部分开销了。 + +### `finalize()` 的用途 + +如果你不能将 `finalize()` 作为通用的清理方法,那么这个方法有什么用呢? + +这引入了要记住的第3点: + +3. 垃圾回收只与内存有关。 + +也就是说,使用垃圾回收的唯一原因就是为了回收程序不再使用的内存。所以对于与垃圾回收有关的任何行为来说(尤其是 `finalize()` 方法),它们也必须同内存及其回收有关。 + +但这是否意味着如果对象中包括其他对象,`finalize()` 方法就应该明确释放那些对象呢?不是,无论对象是如何创建的,垃圾回收器都会负责释放对象所占用的所有内存。这就将对 `finalize()` 的需求限制到一种特殊情况,即通过某种创建对象方式之外的方式为对象分配了存储空间。不过,你可能会想,Java 中万物皆对象,这种情况怎么可能发生? + +看起来之所以有 `finalize()` 方法,是因为在分配内存时可能采用了类似 C 语言中的做法,而非 Java 中的通常做法。这种情况主要发生在使用"本地方法"的情况下,本地方法是一种用 Java 语言调用非 Java 语言代码的形式(关于本地方法的讨论,见本书电子版第2版的附录B)。本地方法目前只支持 C 和 C++,但是它们可以调用其他语言写的代码,所以实际上可以调用任何代码。在非 Java 代码中,也许会调用 C 的 `malloc()` 函数系列来分配存储空间,而且除非调用 `free()` 函数,不然存储空间永远得不到释放,造成内存泄露。但是,`free()` 是 C 和 C++ 中的函数,所以你需要在 `finalize()` 方法里用本地方法调用它。 + +读到这里,你可能明白了不会过多使用 `finalize()` 方法。对,它确实不是进行普通的清理工作的合适场所。那么,普通的清理工作在哪里执行呢? + +### 你必须实施清理 + +要清理一个对象,用户必须在需要清理的时候调用执行清理动作的方法。这听上去相当直接,但却与 C++ 中的"析构函数"的概念稍有抵触。在 C++ 中,所有对象都会被销毁,或者说应该被销毁。如果在 C++ 中创建了一个局部对象(在栈上创建,在 Java 中不行),此时的销毁动作发生在以"右花括号"为边界的、此对象作用域的末尾处。如果对象是用 **new** 创建的(类似于 Java 中),那么当程序员调用 C++ 的 **delete** 操作符时(Java 中不存在),就会调用相应的析构函数。如果程序员忘记调用 **delete**,那么永远不会调用析构函数,这样就会导致内存泄露,对象的其他部分也不会得到清理。这种 bug 很难跟踪,也是让 C++ 程序员转向 Java 的一个主要因素。相反,在 Java 中,没有用于释放对象的 **delete**,因为垃圾回收器会帮助你释放存储空间。甚至可以肤浅地认为,正是由于垃圾回收的存在,使得 Java 没有析构函数。然而,随着学习的深入,你会明白垃圾回收器的存在并不能完全替代析构函数(而且绝对不能直接调用 `finalize()`,所以这也不是一种解决方案)。如果希望进行除释放存储空间之外的清理工作,还是得明确调用某个恰当的 Java 方法:这就等同于使用析构函数了,只是没有它方便。 + +记住,无论是"垃圾回收"还是"终结",都不保证一定会发生。如果 Java 虚拟机(JVM)并未面临内存耗尽的情形,它可能不会浪费时间执行垃圾回收以恢复内存。 + +### 终结条件 + +通常,不能指望 `finalize()` ,你必须创建其他的"清理"方法,并明确地调用它们。所以看起来,`finalize()` 只对大部分程序员很难用到的一些晦涩内存清理里有用了。但是,`finalize()` 还有一个有趣的用法,它不依赖于每次都要对 `finalize()` 进行调用,这就是对象终结条件的验证。 + +当对某个对象不感兴趣时——也就是它将被清理了,这个对象应该处于某种状态,这种状态下它占用的内存可以被安全地释放掉。例如,如果对象代表了一个打开的文件,在对象被垃圾回收之前程序员应该关闭这个文件。只要对象中存在没有被适当清理的部分,程序就存在很隐晦的 bug。`finalize()` 可以用来最终发现这个情况,尽管它并不总是被调用。如果某次 `finalize()` 的动作使得 bug 被发现,那么就可以据此找出问题所在——这才是人们真正关心的。以下是个简单的例子,示范了 `finalize()` 的可能使用方式: + +```java +// housekeeping/TerminationCondition.java +// Using finalize() to detect a object that +// hasn't been properly cleaned up + +import onjava.*; + +class Book { + boolean checkedOut = false; + + Book(boolean checkOut) { + checkedOut = checkOut; + } + + void checkIn() { + checkedOut = false; + } + + @Override + protected void finalize() throws Throwable { + if (checkedOut) { + System.out.println("Error: checked out"); + } + // Normally, you'll also do this: + // super.finalize(); // Call the base-class version + } +} + +public class TerminationCondition { + + public static void main(String[] args) { + Book novel = new Book(true); + // Proper cleanup: + novel.checkIn(); + // Drop the reference, forget to clean up: + new Book(true); + // Force garbage collection & finalization: + System.gc(); + new Nap(1); // One second delay + } + +} +``` + +输出: + +``` +Error: checked out +``` + +本例的终结条件是:所有的 **Book** 对象在被垃圾回收之前必须被登记。但在 `main()` 方法中,有一本书没有登记。要是没有 `finalize()` 方法来验证终结条件,将会很难发现这个 bug。 + +你可能注意到使用了 `@Override`。`@` 意味着这是一个注解,注解是关于代码的额外信息。在这里,该注解告诉编译器这不是偶然地重定义在每个对象中都存在的 `finalize()` 方法——程序员知道自己在做什么。编译器确保你没有拼错方法名,而且确保那个方法存在于基类中。注解也是对读者的提醒,`@Override` 在 Java 5 引入,在 Java 7 中改善,本书通篇会出现。 + +注意,`System.gc()` 用于强制进行终结动作。但是即使不这么做,只要重复地执行程序(假设程序将分配大量的存储空间而导致垃圾回收动作的执行),最终也能找出错误的 **Book** 对象。 + +你应该总是假设基类版本的 `finalize()` 也要做一些重要的事情,使用 **super** 调用它,就像在 `Book.finalize()` 中看到的那样。本例中,它被注释掉了,因为它需要进行异常处理,而我们到现在还没有涉及到。 + +### 垃圾回收器如何工作 + +如果你以前用过的语言,在堆上分配对象的代价十分高昂,你可能自然会觉得 Java 中所有对象(基本类型除外)在堆上分配的方式也十分高昂。然而,垃圾回收器能很明显地提高对象的创建速度。这听起来很奇怪——存储空间的释放影响了存储空间的分配,但这确实是某些 Java 虚拟机的工作方式。这也意味着,Java 从堆空间分配的速度可以和其他语言在栈上分配空间的速度相媲美。 + +例如,你可以把 C++ 里的堆想象成一个院子,里面每个对象都负责管理自己的地盘。一段时间后,对象可能被销毁,但地盘必须复用。在某些 Java 虚拟机中,堆的实现截然不同:它更像一个传送带,每分配一个新对象,它就向前移动一格。这意味着对象存储空间的分配速度特别快。Java 的"堆指针"只是简单地移动到尚未分配的区域,所以它的效率与 C++ 在栈上分配空间的效率相当。当然实际过程中,在簿记工作方面还有少量额外开销,但是这部分开销比不上查找可用空间开销大。 + +你可能意识到了,Java 中的堆并非完全像传送带那样工作。要是那样的话,势必会导致频繁的内存页面调度——将其移进移出硬盘,因此会显得需要拥有比实际需要更多的内存。页面调度会显著影响性能。最终,在创建了足够多的对象后,内存资源被耗尽。其中的秘密在于垃圾回收器的介入。当它工作时,一边回收内存,一边使堆中的对象紧凑排列,这样"堆指针"就可以很容易地移动到更靠近传送带的开始处,也就尽量避免了页面错误。垃圾回收器通过重新排列对象,实现了一种高速的、有无限空间可分配的堆模型。 + +要想理解 Java 中的垃圾回收,先了解其他系统中的垃圾回收机制将会很有帮助。一种简单但速度很慢的垃圾回收机制叫做*引用计数*。每个对象中含有一个引用计数器,每当有引用指向该对象时,引用计数加 1。当引用离开作用域或被置为 **null** 时,引用计数减 1。因此,管理引用计数是一个开销不大但是在程序的整个生命周期频繁发生的负担。垃圾回收器会遍历含有全部对象的列表,当发现某个对象的引用计数为 0 时,就释放其占用的空间(但是,引用计数模式经常会在计数为 0 时立即释放对象)。这个机制存在一个缺点:如果对象之间存在循环引用,那么它们的引用计数都不为 0,就会出现应该被回收但无法被回收的情况。对垃圾回收器而言,定位这样的循环引用所需的工作量极大。引用计数常用来说明垃圾回收的工作方式,但似乎从未被应用于任何一种 Java 虚拟机实现中。 + +在更快的策略中,垃圾回收器并非基于引用计数。它们依据的是:对于任意"活"的对象,一定能最终追溯到其存活在栈或静态存储区中的引用。这个引用链条可能会穿过数个对象层次,由此,如果从栈或静态存储区出发,遍历所有的引用,你将会发现所有"活"的对象。对于发现的每个引用,必须追踪它所引用的对象,然后是该对象包含的所有引用,如此反复进行,直到访问完"根源于栈或静态存储区的引用"所形成的整个网络。你所访问过的对象一定是"活"的。注意,这解决了对象间循环引用的问题,这些对象不会被发现,因此也就被自动回收了。 + +在这种方式下,Java 虚拟机采用了一种*自适应*的垃圾回收技术。至于如何处理找到的存活对象,取决于不同的 Java 虚拟机实现。其中有一种做法叫做停止-复制(stop-and-copy)。顾名思义,这需要先暂停程序的运行(不属于后台回收模式),然后将所有存活的对象从当前堆复制到另一个堆,没有复制的就是需要被垃圾回收的。另外,当对象被复制到新堆时,它们是一个挨着一个紧凑排列,然后就可以按照前面描述的那样简单、直接地分配新空间了。 + +当对象从一处复制到另一处,所有指向它的引用都必须修正。位于栈或静态存储区的引用可以直接被修正,但可能还有其他指向这些对象的引用,它们在遍历的过程中才能被找到(可以想象成一个表格,将旧地址映射到新地址)。 + +这种所谓的"复制回收器"效率低下主要因为两个原因。其一:得有两个堆,然后在这两个分离的堆之间来回折腾,得维护比实际需要多一倍的空间。某些 Java 虚拟机对此问题的处理方式是,按需从堆中分配几块较大的内存,复制动作发生在这些大块内存之间。 + +其二在于复制本身。一旦程序进入稳定状态之后,可能只会产生少量垃圾,甚至没有垃圾。尽管如此,复制回收器仍然会将所有内存从一处复制到另一处,这很浪费。为了避免这种状况,一些 Java 虚拟机会进行检查:要是没有新垃圾产生,就会转换到另一种模式(即"自适应")。这种模式称为标记-清扫(mark-and-sweep),Sun 公司早期版本的 Java 虚拟机一直使用这种技术。对一般用途而言,"标记-清扫"方式速度相当慢,但是当你知道程序只会产生少量垃圾甚至不产生垃圾时,它的速度就很快了。 + +"标记-清扫"所依据的思路仍然是从栈和静态存储区出发,遍历所有的引用,找出所有存活的对象。但是,每当找到一个存活对象,就给对象设一个标记,并不回收它。只有当标记过程完成后,清理动作才开始。在清理过程中,没有标记的对象将被释放,不会发生任何复制动作。"标记-清扫"后剩下的堆空间是不连续的,垃圾回收器要是希望得到连续空间的话,就需要重新整理剩下的对象。 + +"停止-复制"指的是这种垃圾回收动作不是在后台进行的;相反,垃圾回收动作发生的同时,程序将会暂停。在 Oracle 公司的文档中会发现,许多参考文献将垃圾回收视为低优先级的后台进程,但是早期版本的 Java 虚拟机并不是这么实现垃圾回收器的。当可用内存较低时,垃圾回收器会暂停程序。同样,"标记-清扫"工作也必须在程序暂停的情况下才能进行。 + +如前文所述,这里讨论的 Java 虚拟机中,内存分配以较大的"块"为单位。如果对象较大,它会占用单独的块。严格来说,"停止-复制"要求在释放旧对象之前,必须先将所有存活对象从旧堆复制到新堆,这导致了大量的内存复制行为。有了块,垃圾回收器就可以把对象复制到废弃的块。每个块都有年代数来记录自己是否存活。通常,如果块在某处被引用,其年代数加 1,垃圾回收器会对上次回收动作之后新分配的块进行整理。这对处理大量短命的临时对象很有帮助。垃圾回收器会定期进行完整的清理动作——大型对象仍然不会复制(只是年代数会增加),含有小型对象的那些块则被复制并整理。Java 虚拟机会监视,如果所有对象都很稳定,垃圾回收的效率降低的话,就切换到"标记-清扫"方式。同样,Java 虚拟机会跟踪"标记-清扫"的效果,如果堆空间出现很多碎片,就会切换回"停止-复制"方式。这就是"自适应"的由来,你可以给它个啰嗦的称呼:"自适应的、分代的、停止-复制、标记-清扫"式的垃圾回收器。 + +Java 虚拟机中有许多附加技术用来提升速度。尤其是与加载器操作有关的,被称为"即时"(Just-In-Time, JIT)编译器的技术。这种技术可以把程全部或部分翻译成本地机器码,所以不需要 JVM 来进行翻译,因此运行得更快。当需要装载某个类(通常是创建该类的第一个对象)时,编译器会先找到其 **.class** 文件,然后将该类的字节码装入内存。你可以让即时编译器编译所有代码,但这种做法有两个缺点:一是这种加载动作贯穿整个程序生命周期内,累加起来需要花更多时间;二是会增加可执行代码的长度(字节码要比即时编译器展开后的本地机器码小很多),这会导致页面调度,从而一定降低程序速度。另一种做法称为*惰性评估*,意味着即时编译器只有在必要的时候才编译代码。这样,从未被执行的代码也许就压根不会被 JIT 编译。新版 JDK 中的 Java HotSpot 技术就采用了类似的做法,代码每被执行一次就优化一些,所以执行的次数越多,它的速度就越快。 + ## 成员初始化 +Java 尽量保证所有变量在使用前都能得到恰当的初始化。对于方法的局部变量,这种保证会以编译时错误的方式呈现,所以如果写成: + +```java +void f() { + int i; + i++; +} +``` + +你会得到一条错误信息,告诉你 **i** 可能尚未初始化。编译器可以为 **i** 赋一个默认值,但是未初始化的局部变量更有可能是程序员的疏忽,所以采用默认值反而会掩盖这种失误。强制程序员提供一个初始值,往往能帮助找出程序里的 bug。 + +要是类的成员变量是基本类型,情况就会变得有些不同。正如在"万物皆对象"一章中所看到的,类的每个基本类型数据成员保证都会有一个初始值。下面的程序可以验证这类情况,并显示它们的值: + +```java +// housekeeping/InitialValues.java +// Shows default initial values + +public class InitialValues { + boolean t; + char c; + byte b; + short s; + int i; + long l; + float f; + double d; + InitialValues reference; + + void printInitialValues() { + System.out.println("Data type Initial value"); + System.out.println("boolean " + t); + System.out.println("char[" + c + "]"); + System.out.println("byte " + b); + System.out.println("short " + s); + System.out.println("int " + i); + System.out.println("long " + l); + System.out.println("float " + f); + System.out.println("double " + d); + System.out.println("reference " + reference); + } + + public static void main(String[] args) { + new InitialValues().printInitialValues(); + } +} +``` + +输出: + +```Java +Data type Initial value +boolean false +char[NUL] +byte 0 +short 0 +int 0 +long 0 +float 0.0 +double 0.0 +reference null +``` + +可见尽管数据成员的初值没有给出,但它们确实有初值(char 值为 0,所以显示为空白)。所以这样至少不会出现"未初始化变量"的风险了。 + +在类里定义一个对象引用时,如果不将其初始化,那么引用就会被赋值为 **null**。 + +### 指定初始化 + +怎么给一个变量赋初值呢?一种很直接的方法是在定义类成员变量的地方为其赋值。以下代码修改了 InitialValues 类成员变量的定义,直接提供了初值: + +```java +// housekeeping/InitialValues2.java +// Providing explicit initial values + +public class InitialValues2 { + boolean bool = true; + char ch = 'x'; + byte b = 47; + short s = 0xff; + int i = 999; + long lng = 1; + float f = 3.14f; + double d = 3.14159; +} +``` + +你也可以用同样的方式初始化非基本类型的对象。如果 **Depth** 是一个类,那么可以像下面这样创建一个对象并初始化它: + +```java +// housekeeping/Measurement.java + +class Depth {} + +public class Measurement { + Depth d = new Depth(); + // ... +} +``` + +如果没有为 **d** 赋予初值就尝试使用它,就会出现运行时错误,告诉你产生了一个异常(详细见"异常"章节)。 + +你也可以通过调用某个方法来提供初值: + +```java +// housekeeping/MethodInit.java + +public class MethodInit { + int i = f(); + + int f() { + return 11; + } + +} +``` + +这个方法可以带有参数,但这些参数不能是未初始化的类成员变量。因此,可以这么写: + +```java +// housekeeping/MethodInit2.java + +public class MethodInit2 { + int i = f(); + int j = g(i); + + int f() { + return 11; + } + + int g(int n) { + return n * 10; + } +} +``` + +但是你不能这么写: + +```java +// housekeeping/MethodInit3.java + +public class MethodInit3 { + //- int j = g(i); // Illegal forward reference + int i = f(); + + int f() { + return 11; + } + + int g(int n) { + return n * 10; + } +} +``` + +显然,上述程序的正确性取决于初始化的顺序,而与其编译方式无关。所以,编译器恰当地对"向前引用"发出了警告。 + +这种初始化方式简单直观,但有个限制:类 **InitialValues** 的每个对象都有相同的初值,有时这的确是我们需要的,但有时却需要更大的灵活性。 + ## 构造器初始化 +可以用构造器进行初始化,这种方式给了你更大的灵活性,因为你可以在运行时调用方法进行初始化。但是,这无法阻止自动初始化的进行,他会在构造器被调用之前发生。因此,如果使用如下代码: + +```java +// housekeeping/Counter.java + +public class Counter { + int i; + + Counter() { + i = 7; + } + // ... +} +``` + +**i** 首先会被初始化为 **0**,然后变为 **7**。对于所有的基本类型和引用,包括在定义时已明确指定初值的变量,这种情况都是成立的。因此,编译器不会强制你一定要在构造器的某个地方或在使用它们之前初始化元素——初始化早已得到了保证。, + +### 初始化的顺序 + +在类中变量定义的顺序决定了它们初始化的顺序。即使变量定义散布在方法定义之间,它们仍会在任何方法(包括构造器)被调用之前得到初始化。例如: + +```java +// housekeeping/OrderOfInitialization.java +// Demonstrates initialization order +// When the constructor is called to create a +// Window object, you'll see a message: + +class Window { + Window(int marker) { + System.out.println("Window(" + marker + ")"); + } +} + +class House { + Window w1 = new Window(1); // Before constructor + + House() { + // Show that we're in the constructor: + System.out.println("House()"); + w3 = new Window(33); // Reinitialize w3 + } + + Window w2 = new Window(2); // After constructor + + void f() { + System.out.println("f()"); + } + + Window w3 = new Window(3); // At end +} + +public class OrderOfInitialization { + public static void main(String[] args) { + House h = new House(); + h.f(); // Shows that construction is done + } +} +``` + +输出: + +``` +Window(1) +Window(2) +Window(3) +House() +Window(33) +f() +``` + +在 **House** 类中,故意把几个 **Window** 对象的定义散布在各处,以证明它们全都会在调用构造器或其他方法之前得到初始化。此外,**w3** 在构造器中被再次赋值。 + +由输出可见,引用 **w3** 被初始化了两次:一次在调用构造器前,一次在构造器调用期间(第一次引用的对象将被丢弃,并作为垃圾回收)。这乍一看可能觉得效率不高,但保证了正确的初始化。试想,如果定义了一个重载构造器,在其中没有初始化 **w3**,同时在定义 **w3** 时没有赋予初值,那会产生怎样的后果呢? + +### 静态数据的初始化 + +无论创建多少个对象,静态数据都只占用一份存储区域。**static** 关键字不能应用于局部变量,所以只能作用于属性(字段、域)。如果一个字段是静态的基本类型,你没有初始化它,那么它就会获得基本类型的标准初值。如果它是对象引用,那么它的默认初值就是 **null**。 + +如果在定义时进行初始化,那么静态变量看起来就跟非静态变量一样。 + +下面例子显示了静态存储区是何时初始化的: + +```java +// housekeeping/StaticInitialization.java +// Specifying initial values in a class definition + +class Bowl { + Bowl(int marker) { + System.out.println("Bowl(" + marker + ")"); + } + + void f1(int marker) { + System.out.println("f1(" + marker + ")"); + } +} + +class Table { + static Bowl bowl1 = new Bowl(1); + + Table() { + System.out.println("Table()"); + bowl2.f1(1); + } + + void f2(int marker) { + System.out.println("f2(" + marker + ")"); + } + + static Bowl bowl2 = new Bowl(2); +} + +class Cupboard { + Bowl bowl3 = new Bowl(3); + static Bowl bowl4 = new Bowl(4); + + Cupboard() { + System.out.println("Cupboard()"); + bowl4.f1(2); + } + + void f3(int marker) { + System.out.println("f3(" + marker + ")"); + } + + static Bowl bowl5 = new Bowl(5); +} + +public class StaticInitialization { + public static void main(String[] args) { + System.out.println("main creating new Cupboard()"); + new Cupboard(); + System.out.println("main creating new Cupboard()"); + new Cupboard(); + table.f2(1); + cupboard.f3(1); + } + + static Table table = new Table(); + static Cupboard cupboard = new Cupboard(); +} +``` + +输出: + +``` +Bowl(1) +Bowl(2) +Table() +f1(1) +Bowl(4) +Bowl(5) +Bowl(3) +Cupboard() +f1(2) +main creating new Cupboard() +Bowl(3) +Cupboard() +f1(2) +main creating new Cupboard() +Bowl(3) +Cupboard() +f1(2) +f2(1) +f3(1) +``` + +**Bowl** 类展示类的创建,而 **Table** 和 **Cupboard** 在它们的类定义中包含 **Bowl** 类型的静态数据成员。注意,在静态数据成员定义之前,**Cupboard** 类中先定义了一个 **Bowl** 类型的非静态成员 **b3**。 + +由输出可见,静态初始化只有在必要时刻才会进行。如果不创建 **Table** 对象,也不引用 **Table.bowl1** 或 **Table.bowl2**,那么静态的 **Bowl** 类对象 **bowl1** 和 **bowl2** 永远不会被创建。只有在第一个 Table 对象被创建(或被访问)时,它们才会被初始化。此后,静态对象不会再次被初始化。 + +初始化的顺序先是静态对象(如果它们之前没有被初始化的话),然后是非静态对象,从输出中可以看出。要执行 `main()` 方法,必须加载 **StaticInitialization** 类,它的静态属性 **table** 和 **cupboard** 随后被初始化,这会导致它们对应的类也被加载,而由于它们都包含静态的 **Bowl** 对象,所以 **Bowl** 类也会被加载。因此,在这个特殊的程序中,所有的类都会在 `main()` 方法之前被加载。实际情况通常并非如此,因为在典型的程序中,不会像本例中所示的那样,将所有事物通过 **static** 联系起来。 + +概括一下创建对象的过程,假设有个名为 **Dog** 的类: + +1. 即使没有显式地使用 **static** 关键字,构造器实际上也是静态方法。所以,当首次创建 **Dog** 类型的对象时,或是首次访问 **Dog** 类的静态方法或属性,Java 解释器必须在类路径中查找,以定位 **Dog.class**。 +2. 当加载完 **Dog.class** 后(后面会学到,这将创建一个 **Class** 对象),有关静态初始化的所有动作都会执行。因此,静态初始化只会在首次加载 **Class** 对象时初始化一次。 +3. 当用 `new Dog()` 创建对象时,首先会在堆上为 **Dog** 对象分配足够的存储空间。 +4. 分配的存储空间首先会被清零,即会将 **Dog** 对象中的所有基本类型数据设置为默认值(数字会被置为 0,布尔型和字符型也相同),引用被置为 **null**。 +5. 执行所有出现在字段定义处的初始化动作。 +6. 执行构造器。你将会在"复用"这一章看到,这可能会牵涉到很多动作,尤其当涉及继承的时候。 + +### 显式的静态初始化 + +你可以将一组静态初始化动作放在类里面一个特殊的"静态子句"(有时叫做静态块)中。像下面这样: + +```java +// housekeeping/Spoon.java + +public class Spoon { + static int i; + + static { + i = 47; + } +} +``` + +这看起来像个方法,但实际上它只是一段跟在 **static** 关键字后面的代码块。与其他静态初始化动作一样,这段代码仅执行一次:当首次创建这个类的对象或首次访问这个类的静态成员(甚至不需要创建该类的对象)时。例如: + +```java +// housekeeping/ExplicitStatic.java +// Explicit static initialization with "static" clause + +class Cup { + Cup(int marker) { + System.out.println("Cup(" + marker + ")"); + } + + void f(int marker) { + System.out.println("f(" + marker + ")"); + } +} + +class Cups { + static Cup cup1; + static Cup cup2; + + static { + cup1 = new Cup(1); + cup2 = new Cup(2); + } + + Cups() { + System.out.println("Cups()"); + } +} + +public class ExplicitStatic { + public static void main(String[] args) { + System.out.println("Inside main()"); + Cups.cup1.f(99); // [1] + } + + // static Cups cups1 = new Cups(); // [2] + // static Cups cups2 = new Cups(); // [2] +} +``` + +输出: + +``` +Inside main +Cup(1) +Cup(2) +f(99) +``` + +无论是通过标为 [1] 的行访问静态的 **cup1** 对象,还是把标为 [1] 的行去掉,让它去运行标为 [2] 的那行代码(去掉 [2] 的注释),**Cups** 的静态初始化动作都会执行。如果同时注释 [1] 和 [2] 处,那么 **Cups** 的静态初始化就不会进行。此外,把标为 [2] 处的注释都去掉还是只去掉一个,静态初始化只会执行一次。 + +### 非静态实例初始化 + +Java 提供了被称为*实例初始化*的类似语法,用来初始化每个对象的非静态变量,例如: + +```java +// housekeeping/Mugs.java +// Instance initialization + +class Mug { + Mug(int marker) { + System.out.println("Mug(" + marker + ")"); + } +} + +public class Mugs { + Mug mug1; + Mug mug2; + { // [1] + mug1 = new Mug(1); + mug2 = new Mug(2); + System.out.println("mug1 & mug2 initialized"); + } + + Mugs() { + System.out.println("Mugs()"); + } + + Mugs(int i) { + System.out.println("Mugs(int)"); + } + + public static void main(String[] args) { + System.out.println("Inside main()"); + new Mugs(); + System.out.println("new Mugs() completed"); + new Mugs(1); + System.out.println("new Mugs(1) completed"); + } +} +``` + +输出: + +``` +Inside main +Mug(1) +Mug(2) +mug1 & mug2 initialized +Mugs() +new Mugs() completed +Mug(1) +Mug(2) +mug1 & mug2 initialized +Mugs(int) +new Mugs(1) completed +``` + +看起来它很像静态代码块,只不过少了 **static** 关键字。这种语法对于支持"匿名内部类"(参见"内部类"一章)的初始化是必须的,但是你也可以使用它保证某些操作一定会发生,而不管哪个构造器被调用。从输出看出,示例初始化子句是在两个构造器之前执行的。 + ## 数组初始化 +数组是相同类型的、用一个标识符名称封装到一起的一个对象序列或基本类型数据序列。数组是通过方括号下标操作符 [] 来定义和使用的。要定义一个数组引用,只需要在类型名加上方括号: + +```java +int[] a1; +``` + +方括号也可放在标识符的后面,两者的含义是一样的: + +```java +int a1[]; +``` + +这种格式符合 C 和 C++ 程序员的习惯。不过前一种格式或许更合理,毕竟它表明类型是"一个 **int** 型数组"。本书中采用这种格式。 + +编译器不允许指定数组的大小。这又把我们带回有关"引用"的问题上。你所拥有的只是对数组的一个引用(你已经为该引用分配了足够的存储空间),但是还没有给数组对象本身分配任何空间。为了给数组创建相应的存储空间,必须写初始化表达式。对于数组,初始化动作可以出现在代码的任何地方,但是也可以使用一种特殊的初始化表达式,它必须在创建数组的地方出现。这种特殊的初始化是由一对花括号括起来的值组成。这种情况下,存储空间的分配(相当于使用 **new**) 将由编译器负责。例如: + +```java +int[] a1 = {1, 2, 3, 4, 5}; +``` + +那么为什么在还没有数组的时候定义一个数组引用呢? + +```java +int[] a2; +``` + +在 Java 中可以将一个数组赋值给另一个数组,所以可以这样: + +```java +a2 = a1; +``` + +其实真正做的只是复制了一个引用,就像下面演示的这样: + +```java +// housekeeping/ArraysOfPrimitives.java + +public class ArraysOfPrimitives { + public static void main(String[] args) { + int[] a1 = {1, 2, 3, 4, 5}; + int[] a2; + a2 = a1; + for (int i = 0; i < a2.length; i++) { + a2[i] += 1; + } + for (int i = 0; i < a1.length; i++) { + System.out.println("a1[" + i + "] = " + a1[i]); + } + } +} +``` + +输出: + +``` +a1[0] = 2; +a1[1] = 3; +a1[2] = 4; +a1[3] = 5; +a1[4] = 6; +``` + +**a1** 初始化了,但是 **a2** 没有;这里,**a2** 在后面被赋给另一个数组。由于 **a1** 和 **a2** 是相同数组的别名,因此通过 **a2** 所做的修改在 **a1** 中也能看到。 + +所有的数组(无论是对象数组还是基本类型数组)都有一个固定成员 **length**,告诉你这个数组有多少个元素,你不能对其修改。与 C 和 C++ 类似,Java 数组计数也是从 0 开始的,所能使用的最大下标数是 **length - 1**。超过这个边界,C 和 C++ 会默认接受,允许你访问所有内存,许多声名狼藉的 bug 都是由此而生。但是 Java 在你访问超出这个边界时,会报运行时错误(异常),从而避免此类问题。 + +### 动态数组创建 + +如果在编写程序时,不确定数组中需要多少个元素,那么该怎么办呢?你可以直接使用 **new** 在数组中创建元素。下面例子中,尽管创建的是基本类型数组,**new** 仍然可以工作(不能用 **new** 创建单个的基本类型数组): + +```java +// housekeeping/ArrayNew.java +// Creating arrays with new +import java.util.*; + +public class ArrayNew { + public static void main(String[] args) { + int[] a; + Random rand = new Random(47); + a = new int[rand.nextInt(20)]; + System.out.println("length of a = " + a.length); + System.out.println(Arrays.toString(a)); + } +} +``` + +输出: + +``` +length of a = 18 +[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] +``` + +数组的大小是通过 `Random.nextInt()` 随机确定的,这个方法会返回 0 到输入参数之间的一个值。 由于随机性,很明显数组的创建确实是在运行时进行的。此外,程序输出表明,数组元素中的基本数据类型值会自动初始化为空值(对于数字和字符是 0;对于布尔型是 **false**)。`Arrays.toString()` 是 **java.util** 标准类库中的方法,会产生一维数组的可打印版本。 + +本例中,数组也可以在定义的同时进行初始化: + +```java +int[] a = new int[rand.nextInt(20)]; +``` + +如果可能的话,应该尽量这么做。 + +如果你创建了一个非基本类型的数组,那么你创建的是一个引用数组。以整型的包装类型 **Integer** 为例,它是一个类而非基本类型: + +```java +// housekeeping/ArrayClassObj.java +// Creating an array of nonprimitive objects + +import java.util.*; + +public class ArrayClassObj { + public static void main(String[] args) { + Random rand = new Random(47); + Integer[] a = new Integer[rand.nextInt(20)]; + System.out.println("length of a = " + a.length); + for (int i = 0; i < a.length; i++) { + a[i] = rand.nextInt(500); // Autoboxing + } + System.out.println(Arrays.toString(a)); + } +} +``` + +输出: + +``` +length of a = 18 +[55, 193, 361, 461, 429, 368, 200, 22, 207, 288, 128, 51, 89, 309, 278, 498, 361, 20] +``` + +这里,即使使用 new 创建数组之后: + +```java +Integer[] a = new Integer[rand.nextInt(20)]; +``` + +它只是一个引用数组,直到通过创建新的 **Integer** 对象(通过自动装箱),并把对象赋值给引用,初始化才算结束: + +```java +a[i] = rand.nextInt(500); +``` + +如果忘记了创建对象,但试图使用数组中的空引用,就会在运行时产生异常。 + +也可以用花括号括起来的列表来初始化数组,有两种形式: + +```java +// housekeeping/ArrayInit.java +// Array initialization +import java.util.*; + +public class ArrayInit { + public static void main(String[] args) { + Integer[] a = { + 1, 2, + 3, // Autoboxing + }; + Integer[] b = new Integer[] { + 1, 2, + 3, // Autoboxing + }; + System.out.println(Arrays.toString(a)); + System.out.println(Arrays.toString(a)); + + } +} +``` + +输出: + +``` +[1, 2, 3] +[1, 2, 3] +``` + +在这两种形式中,初始化列表的最后一个逗号是可选的(这一特性使维护长列表变得更容易)。 + +尽管第一种形式很有用,但是它更加受限,因为它只能用于数组定义处。第二种和第三种形式可以用在任何地方,甚至用在方法的内部。例如,你创建了一个 **String** 数组,将其传递给另一个类的 `main()` 方法,如下: + +```java +// housekeeping/DynamicArray.java +// Array initialization + +public class DynamicArray { + public static void main(String[] args) { + Other.main(new String[] {"fiddle", "de", "dum"}); + } +} + +class Other { + public static void main(String[] args) { + for (String s: args) { + System.out.print(s + " "); + } + } +} +``` + +输出: + +``` +fiddle de dum +``` + +`Other.main()` 的参数是在调用处创建的,因此你甚至可以在方法调用处提供可替换的参数。 + +### 可变参数列表 + +你可以以一种类似 C 语言中的可变参数列表(C 通常把它称为"varargs")来创建和调用方法。这可以应用在参数个数或类型未知的场合。由于所有的类都最后继承于 **Object** 类(随着本书的进展,你会对此有更深的认识),所以你可以创建一个以 Object 数组为参数的方法,并像下面这样调用: + +```java +// housekeeping/VarArgs.java +// Using array syntax to create variable argument lists + +class A {} + +public class VarArgs { + static void printArray(Object[] args) { + for (Object obj: args) { + System.out.print(obj + " "); + System.out.println(); + } + } + + public static void main(String[] args) { + printArray(new Object[] {47, (float) 3.14, 11.11}); + printArray(new Object[] {"one", "two", "three"}); + printArray(new Object[] {new A(), new A(), new A()}); + } +} +``` + +输出: + +``` +47 3.14 11.11 +one two three +A@15db9742 A@6d06d69c A@7852e922 +``` + +`printArray()` 的参数是 **Object** 数组,使用 for-in 语法遍历和打印数组的每一项。标准 Java 库能输出有意义的内容,但这里创建的是类的对象,打印出的内容是类名,后面跟着一个 **@** 符号以及多个十六进制数字。因而,默认行为(如果没有定义 `toString()` 方法的话,后面会讲这个方法)就是打印类名和对象的地址。 + +你可能看到像上面这样编写的 Java 5 之前的代码,它们可以产生可变的参数列表。在 Java 5 中,这种期盼已久的特性终于添加了进来,就像在 `printArray()` 中看到的那样: + +```java +// housekeeping/NewVarArgs.java +// Using array syntax to create variable argument lists + +public class NewVarArgs { + static void printArray(Object... args) { + for (Object obj: args) { + System.out.print(obj + " "); + } + System.out.println(); + } + + public static void main(String[] args) { + // Can take individual elements: + printArray(47, (float) 3.14, 11.11); + printArray(47, 3.14F, 11.11); + printArray("one", "two", "three"); + printArray(new A(), new A(), new A()); + // Or an array: + printArray((Object[]) new Integer[] {1, 2, 3, 4}); + printArray(); // Empty list is OK + } +} +``` + +输出: + +``` +47 3.14 11.11 +47 3.14 11.11 +one two three +A@15db9742 A@6d06d69c A@7852e922 +1 2 3 4 +``` + +有了可变参数,你就再也不用显式地编写数组语法了,当你指定参数时,编译器实际上会为你填充数组。你获取的仍然是一个数组,这就是为什么 `printArray()` 可以使用 for-in 迭代数组的原因。但是,这不仅仅只是从元素列表到数组的自动转换。注意程序的倒数第二行,一个 **Integer** 数组(通过自动装箱创建)被转型为一个 **Object** 数组(为了移除编译器的警告),并且传递给了 `printArray()`。显然,编译器会发现这是一个数组,不会执行转换。因此,如果你有一组事物,可以把它们当作列表传递,而如果你已经有了一个数组,该方法会把它们当作可变参数列表来接受。 + +程序的最后一行表明,可变参数的个数可以为 0。当具有可选的尾随参数时,这一特性会有帮助: + +```java +// housekeeping/OptionalTrailingArguments.java + +public class OptionalTrailingArguments { + static void f(int required, String... trailing) { + System.out.print("required: " + required + " "); + for (String s: trailing) { + System.out.print(s + " "); + } + System.out.println(); + } + + public static void main(String[] args) { + f(1, "one"); + f(2, "two", "three"); + f(0); + } +} +``` + +输出: + +``` +required: 1 one +required: 2 two three +required: 0 +``` + +这段程序展示了如何使用除了 **Object** 类之外类型的可变参数列表。这里,所有的可变参数都是 **String** 对象。可变参数列表中可以使用任何类型的参数,包括基本类型。下面例子展示了可变参数列表变为数组的情形,并且如果列表中没有任何元素,那么转变为大小为 0 的数组: + +```java +// housekeeping/VarargType.java + +public class VarargType { + static void f(Character... args) { + System.out.print(args.getClass()); + System.out.println(" length " + args.length); + } + + static void g(int... args) { + System.out.print(args.getClass()); + System.out.println(" length " + args.length) + } + + public static void main(String[] args) { + f('a'); + f(); + g(1); + g(); + System.out.println("int[]: "+ new int[0].getClass()); + } +} +``` + +输出: + +``` +class [Ljava.lang.Character; length 1 +class [Ljava.lang.Character; length 0 +class [I length 1 +class [I length 0 +int[]: class [I +``` + +`getClass()` 方法属于 Object 类,将在"类型信息"一章中全面介绍。它会产生对象的类,并在打印该类时,看到表示该类类型的编码字符串。前导的 **[** 代表这是一个后面紧随的类型的数组,**I** 表示基本类型 **int**;为了进行双重检查,我在最后一行创建了一个 **int** 数组,打印了其类型。这样也验证了使用可变参数列表不依赖于自动装箱,而使用的是基本类型。 + +然而,可变参数列表与自动装箱可以和谐共处,如下: + +```java +// housekeeping/AutoboxingVarargs.java + +public class AutoboxingVarargs { + public static void f(Integer... args) { + for (Integer i: args) { + System.out.print(i + " "); + } + System.out.println(); + } + + public static void main(String[] args) { + f(1, 2); + f(4, 5, 6, 7, 8, 9); + f(10, 11, 12); + + } +} +``` + +输出: + +``` +1 2 +4 5 6 7 8 9 +10 11 12 +``` + +注意吗,你可以在单个参数列表中将类型混合在一起,自动装箱机制会有选择地把 **int** 类型的参数提升为 **Integer**。 + +可变参数列表使得方法重载更加复杂了,尽管乍看之下似乎足够安全: + +```java +// housekeeping/OverloadingVarargs.java + +public class OverloadingVarargs { + static void f(Character... args) { + System.out.print("first"); + for (Character c: args) { + System.out.print(" " + c); + } + System.out.println(); + } + + static void f(Integer... args) { + System.out.print("second"); + for (Integer i: args) { + System.out.print(" " + i); + } + System.out.println(); + } + + static void f(Long... args) { + System.out.println("third"); + } + + public static void main(String[] args) { + f('a', 'b', 'c'); + f(1); + f(2, 1); + f(0); + f(0L); + //- f(); // Won's compile -- ambiguous + } +} +``` + +输出: + +``` +first a b c +second 1 +second 2 1 +second 0 +third +``` + +在每种情况下,编译器都会使用自动装箱来匹配重载的方法,然后调用最明确匹配的方法。 + +但是如果调用不含参数的 `f()`,编译器就无法知道应该调用哪个方法了。尽管这个错误可以弄清楚,但是它可能会使客户端程序员感到意外。 + +你可能会通过在某个方法中增加一个非可变参数解决这个问题: + +```java +// housekeeping/OverloadingVarargs2.java +// {WillNotCompile} + +public class OverloadingVarargs2 { + static void f(float i, Character... args) { + System.out.println("first"); + } + + static void f(Character... args) { + System.out.println("second"); + } + + public static void main(String[] args) { + f(1, 'a'); + f('a', 'b'); + } +} +``` + +**{WillNotCompile}** 注释把该文件排除在了本书的 Gradle 构建之外。如果你手动编译它,会得到下面的错误信息: + +``` +OverloadingVarargs2.java:14:error:reference to f is ambiguous f('a', 'b'); +\^ +both method f(float, Character...) in OverloadingVarargs2 and method f(Character...) in OverloadingVarargs2 match 1 error +``` + +如果你给这两个方法都添加一个非可变参数,就可以解决问题了: + +```java +// housekeeping/OverloadingVarargs3 + +public class OverloadingVarargs3 { + static void f(float i, Character... args) { + System.out.println("first"); + } + + static void f(char c, Character... args) { + System.out.println("second"); + } + + public static void main(String[] args) { + f(1, 'a'); + f('a', 'b'); + } +} +``` + +输出: + +``` +first +second +``` + +你应该总是在重载方法的一个版本上使用可变参数列表,或者压根不用它。 + ## 枚举类型 +Java 5 中添加了一个看似很小的特性 **enum** 关键字,它使得我们在需要群组并使用枚举类型集时,可以很方便地处理。以前,你需要创建一个整数常量集,但是这些值并不会将自身限制在这个常量集的范围内,因此使用它们更有风险,而且更难使用。枚举类型属于非常普遍的需求,C、C++ 和其他许多语言都已经拥有它了。在 Java 5 之前,Java 程序员必须了解许多细节并格外仔细地去达成 **enum** 的效果。现在 Java 也有了 **enum**,并且它的功能比 C/C++ 中的完备得多。下面是个简单的例子: + +```java +// housekeeping/Spiciness.java + +public enum Spiciness { + NOT, MILD, MEDIUM, HOT, FLAMING +} +``` + +这里创建了一个名为 **Spiciness** 的枚举类型,它有5个值。由于枚举类型的实例是常量,因此按照命名惯例,它们都用大写字母表示(如果名称中含有多个单词,使用下划线分隔)。 + +要使用 **enum**,需要创建一个该类型的引用,然后将其赋值给某个实例: + +```java +// housekeeping/SimpleEnumUse.java + +public class SimpleEnumUse { + public static void main(String[] args) { + Spiciness howHot = Spiciness.MEDIUM; + System.out.println(howHot); + } +} +``` + +输出: + +``` +MEDIUM +``` + +在你创建 **enum** 时,编译器会自动添加一些有用的特性。例如,它会创建 `toString()` 方法,以便你方便地显示某个 **enum** 实例的名称,这从上面例子中的输出可以看出。编译器还会创建 `ordinal()` 方法表示某个特定 **enum** 常量的声明顺序,`static values()` 方法按照 enum 常量的声明顺序,生成这些常量值构成的数组: + +```java +// housekeeping/EnumOrder.java + +public class EnumOrder { + public static void main(String[] args) { + for (Spiciness s: Spiciness.values()) { + System.out.println(s + ", ordinal " + s.ordinal()); + } + } +} +``` + +输出: + +``` +NOT, ordinal 0 +MILD, ordinal 1 +MEDIUM, ordinal 2 +HOT, ordinal 3 +FLAMING, ordinal 4 +``` + +尽管 **enum** 看起来像是一种新的数据类型,但是这个关键字只是在生成 **enum** 的类时,产生了某些编译器行为,因此在很大程度上你可以将 **enum** 当作其他任何类。事实上,**enum** 确实是类,并且具有自己的方法。 + +**enum** 有一个很实用的特性,就是在 **switch** 语句中使用: + +```java +// housekeeping/Burrito.java + +public class Burrito { + Spiciness degree; + + public Burrito(Spiciness degree) { + this.degree = degree; + } + + public void describe() { + System.out.print("This burrito is "); + switch(degree) { + case NOT: + System.out.println("not spicy at all."); + break; + case MILD: + case MEDIUM: + System.out.println("a little hot."); + break; + case HOT: + case FLAMING: + default: + System.out.println("maybe too hot"); + } + } + + public static void main(String[] args) { + Burrito plain = new Burrito(Spiciness.NOT), + greenChile = new Burrito(Spiciness.MEDIUM), + jalapeno = new Burrito(Spiciness.HOT); + plain.describe(); + greenChile.describe(); + jalapeno.describe(); + } +} +``` + +输出: + +``` +This burrito is not spicy at all. +This burrito is a little hot. +This burrito is maybe too hot. +``` + +由于 **switch** 是在有限的可能值集合中选择,因此它与 **enum** 是绝佳的组合。注意,enum 的名称是如何能够倍加清楚地表明程序的目的的。 + +通常,你可以将 **enum** 用作另一种创建数据类型的方式,然后使用所得到的类型。这正是关键所在,所以你不用过多地考虑它们。在 **enum** 被引入之前,你必须花费大量的精力去创建一个等同的枚举类型,并是安全可用的。 + +这些介绍对于你理解和使用基本的 **enum** 已经足够了,我们会在"枚举"一章中进行更深入的探讨。 + ## 本章小结 +构造器,这种看起来精巧的初始化机制,应该给了你很强的暗示:初始化在编程语言中的重要地位。C++ 的发明者 Bjarne Stroustrup 在设计 C++ 期间,在针对 C 语言的生产效率进行的最初调查中发现,错误的初始化会导致大量编程错误。这些错误很难被发现,同样,不合理的清理也会如此。因为构造器能保证进行正确的初始化和清理(没有正确的构造器调用,编译器就不允许创建对象),所以你就有了完全的控制和安全。 + +在 C++ 中,析构器很重要,因为用 **new** 创建的对象必须被明确地销毁。在 Java 中,垃圾回收器会自动地释放所有对象的内存,所以很多时候类似的清理方法就不太需要了(但是当要用到的时候,你得自己动手)。在不需要类似析构器行为的时候,Java 的垃圾回收器极大地简化了编程,并加强了内存管理上的安全性。一些垃圾回收器甚至能清理其他资源,如图形和文件句柄。然而,垃圾回收器确实增加了运行时开销,由于 Java 解释器从一开始就很慢,所以这种开销到底造成多大的影响很难看出来。随着时间的推移,Java 在性能方面提升了很多,但是速度问题仍然是它涉足某些特定编程领域的障碍。 + +由于要保证所有对象被创建,实际上构造器比这里讨论得更加复杂。特别是当通过*组合*或*继承*创建新类的时候,这种保证仍然成立,并且需要一些额外的语法来支持。在后面的章节中,你会学习组合,继承以及它们如何影响构造器。 +
\ No newline at end of file diff --git a/docs/book/07-Implementation-Hiding.md b/docs/book/07-Implementation-Hiding.md index 2d13a1b..b97295b 100644 --- a/docs/book/07-Implementation-Hiding.md +++ b/docs/book/07-Implementation-Hiding.md @@ -3,27 +3,690 @@ # 第七章 封装 +访问控制(或者隐藏实现)与"最初的实现不恰当"有关。 + +所有优秀的作者——包括这些编写软件的人——都知道一件好的作品都是经过反复打磨才变得优秀的。如果你把一段代码置于某个位置一段时间,过一会重新来看,你可能发现更好的实现方式。这是重构的原动力之一,重构就是重写可工作的代码,使之更加可读,易懂,因而更易维护。 + +但是,在修改和完善代码的愿望下,也存在巨大的压力。通常,客户端程序员希望你的代码在某些方面保持不变。所以你想修改代码,但他们希望代码保持不变。由此引出了面向对象设计中的一个基本问题:"如何区分变动的事物和不变的事物"。 + +这个问题对于类库而言尤其重要。类库的使用者必须依赖他们所使用的那部分类库,并且知道如果使用了类库的新版本,不需要改写代码。另一方面,类库的开发者必须有修改和改进类库的自由,并保证客户代码不会受这些改动影响。 + +这可以通过约定解决。例如,类库开发者必须同意在修改类库中的一个类时,不会移除已有的方法,因为那样将会破坏客户端程序员的代码。与之相反的情况更加复杂。在有成员属性的情况下,类库开发者如何知道哪些属性被客户端程序员使用?这同样会发生在那些只为实现类库类而创建的方法上,它们也不是设计成可供客户端程序员调用的。如果类库开发者想删除旧的实现,添加新的实现,结果会怎样呢?任何这些成员的改动都可能破环客户端程序员的代码。因此类库开发者会被束缚,不能修改任何事物。 + +为了解决这一问题,Java 提供了访问修饰符供类库开发者指明哪些对于客户端程序员是可用的,哪些是不可用的。访问控制权限的等级,从"最大权限"到"最小权限"依次是:**public**,**protected**,包访问权限(没有关键字)和 **private**。根据上一段的内容,你可能会想,作为一名类库设计者,你会尽可能将一切都设为 **private**,仅向客户端程序员暴露你愿意他们使用的方法。这就是你通常所做的,尽管这与使用其他语言(尤其是 C)编程和访问不受任何限制的人们的直觉相违背。 + +然而,构建类库的概念和对类库组件的访问控制仍然不完善。其中仍然存在问题就是如何将类库组件捆绑到一个内聚到类库单元中。Java 中通过 package 关键字加以控制,类是在相同包下还是不同包下会影响访问修饰符。所以在这章开始,你将会学习如何将类库组件置于同一个包下,之后你就能明白访问修饰符的全部含义。 + ## 包的概念 +包内包含一组类,它们被组织在一个单独的命名空间下。 + +例如,标准 Java 发布中有一个工具库,它被组织在 **java.util** 命名空间下。**java.util** 中含有一个类,叫做 **ArrayList**。使用 **ArrayList** 的一种方式是用其全名 **java.util.ArrayList**。 + +```java +// hiding/FullQualification.java + +public class FullQualification { + public static void main(String[] args) { + java.util.ArrayList list = new java.util.ArrayList(); + } +} +``` + +这种方式使得程序冗长乏味,因此你可以换一种方式,使用 **import** 关键字。如果需要导入某个类,就需要在 **import** 语句中声明: + +```java +// hiding/SingleImport.java +import java.util.ArrayList; + +public class SingleImport { + public static void main(String[] args) { + ArrayList list = new ArrayList(); + } +} +``` + +现在你就可以不加限定词,直接使用 **ArrayList** 了。但是对于 **java.util** 包下的其他类,你还是不能用。要导入其中所有的类,只需使用 ***** ,就像本书中其他示例那样: +```java +import java.util.* +``` + +之所以使用导入,是为了提供一种管理命名空间的机制。所有类名之间都是相互隔离的。类 **A** 中的方法 `f()` 不会与类 **B** 中具有相同签名的方法 `f()` 冲突。但是如果类名冲突呢?假设你创建了一个 **Stack** 类,打算安装在一台已经有别人所写的 **Stack** 类的机器上,该怎么办呢?这种类名的潜在冲突,正是我们需要在 Java 中对命名空间进行完全控制的原因。为了解决冲突,我们为每个类创建一个唯一标识符组合。 + +到目前为止的大部分示例都只存在单个文件,并为本地使用的,所以尚未收到包名的干扰。但是,这些示例其实已经位于包中了,叫做"未命名"包或默认包。这当然是一种选择,为了简单起见,本书其余部分会尽可能采用这种方式。但是,如果你打算为相同机器上的其他 Java 程序创建友好的类库或程序时,就必须仔细考虑以防类名冲突。 + +一个 Java 源代码文件称为一个*编译单元*(有时也称*翻译单元*)。每个编译单元的文件名后缀必须是 **.java**。在编译单元中可以有一个 **public** 类,它的类名必须与文件名相同(包括大小写,但不包括后缀名 **.java**)。每个编译单元中只能有一个 **public** 类,否则编译器不接受。如果这个编译单元中还有其他类,那么在包之外是无法访问到这些类的,因为它们不是 **public** 类,此时它们支持主 **public** 类。 + +### 代码组织 + +当编译一个 **.java** 文件时,**.java** 文件的每个类都会有一个输出文件。每个输出的文件名和 **.java** 文件中每个类的类名相同,只是后缀名是 **.class**。因此,在编译少量的 **.java** 文件后,会得到大量的 **.class** 文件。如果你使用过编译型语言,那么你可能习惯编译后产生一个中间文件(通常称为"obj"文件),然后与使用链接器(创建可执行文件)或类库生成器(创建类库)产生的其他同类文件打包到一起的情况。这不是 Java 工作的方式。在 Java 中,可运行程序是一组 **.class** 文件,它们可以打包压缩成一个 Java 文档文件(JAR,使用 **jar** 文档生成器)。Java 解释器负责查找、加载和解释这些文件。 + +类库是一组类文件。每个源文件通常都含有一个 **public** 类和任意数量的非 **public** 类,因此每个文件都有一个构件。如果把这些组件集中在一起,就需要使用关键字 **package**。 + +如果你使用了 **package** 语句,它必须是文件中除了注释之外的第一行代码。当你如下这样写: + +```java +package hiding; +``` + +意味着这个编译单元是一个名为 **hiding** 类库的一部分。换句话说,你正在声明的编译单元中的 **public** 类名称位于名为 **hiding** 的保护伞下。任何人想要使用该名称,必须指明完整的类名或者使用 **import** 关键字导入 **hiding**。(注意,Java 包名按惯例一律小写,即使中间的单词也需要小写,与驼峰命名不同) + +例如,假设文件名是 **MyClass.java**,这意味着文件中只能有一个 **public** 类,且类名必须是 MyClass(大小写也与文件名相同): + +```java +// hiding/mypackage/MyClass.java +package hiding.mypackage + +public class MyClass { + // ... +} +``` + +现在,如果有人想使用 **MyClass** 或 **hiding.mypackage** 中的其他 **public** 类,就必须使用关键字 **import** 来使 **hiding.mypackage** 中的名称可用。还有一种选择是使用完整的名称: + +```java +// hiding/QualifiedMyClass.java + +public class QualifiedMyClass { + public static void main(String[] args) { + hiding.mypackage.MyClass m = new hiding.mypackage.MyClass(); + } +} +``` + +关键字 **import** 使之更简洁: + +```java +// hiding/ImportedMyClass.java +import hiding.mypackage.*; + +public class ImportedMyClass { + public static void main(String[] args) { + MyClass m = new MyClass(); + } +} +``` + +**package** 和 **import** 这两个关键字将单一的全局命名空间分隔开,从而避免名称冲突。 + +### 创建独一无二的包名 + +你可能注意到,一个包从未真正被打包成单一的文件,它可以由很多 **.class** 文件构成,因而事情就变得有点复杂了。为了避免这种情况,一种合乎逻辑的做法是将特定包下的所有 **.class** 文件都放在一个目录下。也就是说,利用操作系统的文件结构的层次性。这是 Java 解决解决混乱问题的一种方式;稍后你还会在我们介绍 **jar** 工具时看到另一种方式。 + +将所有的文件放在一个子目录还解决了其他的两个问题:创建独一无二的包名和查找可能隐藏于目录结构某处的类。这是通过将 **.class** 文件所在的路径位置编码成 **package** 名称来实现的。按照惯例,**package** 名称是类的创建者的反顺序的 Internet 域名。如果你遵循惯例,因为 Internet 域名是独一无二的,所以你的 **package** 名称也应该是独一无二的,不会发生名称冲突。如果你没有自己的域名,你就得构造一组不大可能与他人重复的组合(比如你的姓名),来创建独一无二的 package 名称。如果你打算发布 Java 程序代码,那么花些力气去获取一个域名是值得的。 + +此技巧的第二部分是把 **package** 名称分解成你机器上的一个目录,所以当 Java 解释器必须要加载一个 .class 文件时,它能定位到 **.class** 文件所在的位置。首先,它找出环境变量 **CLASSPATH**(通过操作系统设置,有时也能通过 Java 的安装程序或基于 Java 的工具设置)。**CLASSPATH** 包含一个或多个目录,用作查找 .**class** 文件的根目录。从根目录开始,Java 解释器获取包名并将每个句点替换成反斜杠,生成一个基于根目录的路径名(包名 foo.bar.baz 变成 foo\bar\baz 或 foo/bar/baz 或其它,取决于你的操作系统)。然后这个路径与 **CLASSPATH** 的不同项连接,解释器就在这些目录中查找与你所创建的类名称相关的 **.class** 文件(解释器还会查找某些涉及 Java 解释器所在位置的标准目录)。 + +为了理解这点,比如说我的域名 **MindviewInc.com**,将之反转并全部改为小写后就是 **com.mindviewinc**,这将作为我创建的类的独一无二的全局名称。(com、edu、org等扩展名之前在 Java 包中都是大写,但是 Java 2 之后都统一用小写。)我决定再创建一个名为 **simple** 的类库,从而细分名称: + +```java +package com.mindviewinc.simple; +``` + +这个包名可以用作下面两个文件的命名空间保护伞: + +```java +// com/mindviewinc/simple/Vector.java +// Creating a package +package com.mindviewinc.simple; + +public class Vector { + public Vector() { + System.out.println("com.mindviewinc.simple.Vector"); + } +} +``` + +如前所述,**package** 语句必须是文件的第一行非注释代码。第二个文件看上去差不多: + +```java +// com/mindviewinc/simple/List.java +// Creating a package +package com.mindviewinc.simple; + +public class List { + System.out.println("com.mindview.simple.List"); +} +``` + +这两个文件都位于我机器上的子目录中,如下: + +``` +C:\DOC\Java\com\mindviewinc\simple +``` + +(注意,本书的每个文件的第一行注释都指明了文件在源代码目录树中的位置——供本书的自动代码提取工具使用。) + +如果你回头看这个路径,会看到包名 **com.mindviewinc.simple**,但是路径的第一部分呢?CLASSPATH 环境变量会处理它。我机器上的环境变量部分如下: + +``` +CLASSPATH=.;D:\JAVA\LIB;C:\DOC\Java +``` + +CLASSPATH 可以包含多个不同的搜索路径。 + +但是在使用 JAR 文件时,有点不一样。你必须在类路径写清楚 JAR 文件的实际名称,不能仅仅是 JAR 文件所在的目录。因此,对于一个名为 **grape.jar** 的 JAR 文件,类路径应包括: + +``` +CLASSPATH=.;D\JAVA\LIB;C:\flavors\grape.jar +``` + +一旦设置好类路径,下面的文件就可以放在任意目录: + +```java +// hiding/LibTest.java +// Uses the library +import com.mindviewinc.simple.*; + +public class LibTest { + public static void main(String[] args) { + Vector v = new Vector(); + List l = new List(); + } +} +``` + +输出: + +``` +com.mindviewinc.simple.Vector +com.mindviewinc.simple.List +``` + +当编译器遇到导入 **simple** 库的 **import** 语句时,它首先会在 CLASSPATH 指定的目录中查找子目录 **com/mindviewinc/simple**,然后从已编译的文件中找出名称相符者(对 **Vector** 而言是 **Vector.class**,对 **List** 而言是 **List.class**)。注意,这两个类和其中要访问的方法都必须是 **public** 修饰的。 + +对于 Java 新手而言,设置 CLASSPATH 是一件麻烦的事(我最初使用时这么觉得),后面版本的 JDK 更加智能。你会发现当你安装好 JDK 时,即使不设置 CLASSPATH,也能够编译和运行基本的 Java 程序。但是,为了编译和运行本书的代码示例(从[https://github.com/BruceEckel/OnJava8-examples](https://github.com/BruceEckel/OnJava8-examples) 取得),你必须将本书程序代码树的基本目录加入到 CLASSPATH 中( gradlew 命令管理自身的 CLASSPATH,所以如果你想直接使用 javac 和 java,不用 Gradle 的话,就需要设置 CLASSPATH)。 + +### 冲突 + +如果通过 ***** 导入了两个包含相同名字类名的类库,会发生什么?例如,假设程序如下: + +```java +import com.mindviewinc.simple.*; +import java.util.*; +``` + +因为 **java.util.*** 也包含了 **Vector** 类,这就存在潜在的冲突。但是只要你不写导致冲突的代码,就不会有问题——这样很好,否则就得做很多类型检查工作来防止那些根本不会出现的冲突。 + +现在如果要创建一个 Vector 类,就会出现冲突: + +```java +Vector v = new Vector(); +``` + +这里的 **Vector** 类指的是谁呢?编译器不知道,读者也不知道。所以编译器报错,强制你明确指明。对于标准的 Java 类 **Vector**,你可以这么写: + +```java +java.util.Vector v = new java.util.Vector(); +``` + +这种写法完全指明了 **Vector** 类的位置(配合 CLASSPATH),那么就没有必要写 **import java.util.*** 语句,除非使用其他来自 **java.util** 中的类。 + +或者,可以导入单个类以防冲突——只要不在同一个程序中使用有冲突的名字(若使用了有冲突的名字,必须明确指明全名)。 + +### 定制工具库 + +具备了以上知识,现在就可以创建自己的工具库来减少重复的程序代码了。 + +一般来说,我会使用反转后的域名来命名要创建的工具包,比如 **com.mindviewinc.util**,但为了简化,这里我把工具包命名为 **onjava**。 + +比如,下面是"控制流"一章中使用到的 `range()` 方法,采用了 for-in 语法进行简单的遍历: + +```java +// onjava/Range.java +// Array creation methods that can be used without +// qualifiers, using static imports: +package onjava; + +public class Range { + // Produce a sequence [0,n) + public static int[] range(int n) { + int[] result = new int[n]; + for (int i = 0; i < n; i++) { + result[i] = i; + } + return result; + } + // Produce a sequence [start..end) + public static int[] range(int start, int end) { + int sz = end - start; + int[] result = new int[sz]; + for (int i = 0; i < sz; i++) { + result[i] = start + i; + } + return result; + } + // Produce sequence [start..end) incrementing by step + public static int[] range(int start, int end, int step) { + int sz = (end - start) / step; + int[] result = new int[sz]; + for (int i = 0; i < sz; i++) { + result[i] = start + (i * step); + } + return result; + } +} +``` + +这个文件的位置一定是在某个以一个 CLASSPATH 位置开始,然后接着是 **onjava** 的目录下。编译完之后,就可以在系统的任何地方使用 **import static** 语句来使用这些方法了。 + +从现在开始,无论何时你创建了有用的新工具,都可以把它加入到自己的类库中。在本书中,你将会看到更多的组件加入到 **onjava** 库。 + +### 使用 import 改变行为 + +Java 没有 C 的*条件编译*功能,该功能使你不必更改任何程序代码而能够切换开关产生不同的行为。Java 之所以去掉此功能,可能是因为 C 在绝大多数情况下使用该功能解决跨平台问题:程序代码的不同部分要根据不同的平台来编译。而 Java 自身就是跨平台设计的,这个功能就没有必要了。 + +但是,条件编译还有其他的用途。调试是一个很常见的用途,调试功能在开发过程中是开启的,在发布的产品中是禁用的。可以通过改变导入的 **package** 来实现这一目的,修改的方法是将程序中的代码从调试版改为发布版。这个技术可用于任何种类的条件代码。 + +### 使用包的忠告 + +当创建一个包时,包名就隐含了目录结构。这个包必须位于包名指定的目录中,该目录必须在以 CLASSPATH 开始的目录中可以查询到。 最初使用关键字 **package** 可能会有点不顺,因为除非遵守"包名对应目录路径"的规则,否则会收到很多意外的运行时错误信息如找不到特定的类,即使这个类就位于同一目录中。如果你收到类似信息,尝试把 **package** 语句注释掉,如果程序能运行的话,你就知道问题出现在哪里了。 + +注意,编译过的代码通常位于与源代码的不同目录中。这是很多工程的标准,而且集成开发环境(IDE)通常会自动为我们做这些。必须保证 JVM 通过 CLASSPATH 能找到编译后的代码。 + ## 访问权限修饰符 +Java 访问权限修饰符 **public**,**protected** 和 **private** 位于定义的类名,属性名和方法名之前。每个访问权限修饰符只能控制它所修饰的对象。 + +如果不提供访问修饰符,就意味着"包访问权限"。所以无论如何,万物都有某种形式的访问控制权。接下来的几节中,你将学习各种类型的访问权限。 + +### 包访问权限 + +本章之前的所有示例要么使用 **public** 访问修饰符,要么就没使用修饰符(默认访问)。默认访问权限没有关键字,通常被称为包访问权限(有时也称为 friendly)。这意味着当前包中的所有其他类都可以访问那个成员。对于这个包之外的类,这个成员看上去是 **private** 的。由于一个编译单元(即一个文件)只能隶属于一个包,所以通过包访问权限,位于同一编译单元中的所有类彼此之间都是可访问的。 + +包访问权限可以把相关类聚到一个包下,以便它们能轻易地相互访问。包里的类给它们的包访问权限的成员赋予了相互访问的权限,所以你"拥有”了包内的程序代码。只有你拥有的代码才能访问你拥有的其他代码是有意义的。包访问为把类聚在一个包中提供了理由。在许多语言中,在文件中组织定义的方式是任意的,但是在 Java 中你被强制以一种合理的方式组织它们。另外,你可以把不能访问当前包里的类的其他类排除在外。 + +类控制着哪些代码有权访问自己的成员。其他包中的代码不能一上来就说"嗨,我是 **Bob** 的朋友!",然后想看到 **Bob** 的 **protected**,包访问权限和 **private** 成员。取得对成员的访问权的唯一方式是: + +1. 使成员成为 public。那么无论是谁,无论在哪,都可以访问它。 +2. 赋予成员默认包访问权限,不用加任何访问修饰符,然后将其他类放在相同的包内。这样,其他类就可以访问该成员。 +3. 在"复用"这一章你将看到,继承的类既可以访问 **public** 成员,也可以访问 **protected** 成员(但不能访问 **private** 成员)。只有当两个类处于同一个包内,它才可以访问包访问权限的成员。但现在不用担心继承和 **protected**。 +4. 提供访问器(accessor)和修改器(mutator)方法(有时也称为"get/set" 方法),从而读取和改变值。 + +### public: 接口访问权限 + +当你使用关键字 **public**,就意味着紧随 public 后声明的成员对于每个人都是可用的,尤其是使用类库的客户端程序员更是如此。假设定义了一个包含下面编译单元的 **dessert** 包: + +```java +// hiding/dessert/Cookie.java +// Creates a library +package hiding.dessert; + +public class Cookie { + public Cookie() { + System.out.println("Cookie constructor"); + } + + void bite() { + System.out.println("bite"); + } +} +``` + +记住,**Cookie.java** 文件产生的类文件必须位于名为 **dessert** 的子目录中,该子目录在 **hiding** (表明本书的"封装"章节)下,它必须在 CLASSPATH 的几个目录之下。不要错误地认为 Java 总是会将当前目录视作查找行为的起点之一。如果你的 CLASSPATH 中没有 `.`,Java 就不会查找单独当前目录。 + +现在,使用 **Cookie** 创建一个程序: +```java +// hiding/Dinner.java +// Uses the library +import hiding.dessert.*; + +public class Dinner { + public static void main(String[] args) { + Cookie x = new Cookie(); + // -x.bite(); // Can't access + } +} +``` + +输出: + +``` +Cookie constructor +``` + +你可以创建一个 **Cookie** 对象,因为它构造器和类都是 **public** 的。(后面会看到更多 **public** 的概念)但是,在 **Dinner.java** 中无法访问到 **Cookie** 对象中的 `bite()` 方法,因为 `bite()` 只提供了包访问权限,因而在 **dessert** 包之外无法访问,编译器禁止你使用它。 + +### 默认包 + +你可能惊讶地发现,以下代码尽管看上去破坏了规则,但是仍然可以编译: + +```java +// hiding/Cake.java +// Accesses a class in a separate compilation unit +class Cake { + public static void main(String[] args) { + Pie x = new Pie(); + x.f(); + } +} +``` + +输出: + +``` +Pie.f() +``` + +同一目录下的第二个文件: + +```java +// hiding/Pie.java +// The other class +class Pie { + void f() { + System.out.println("Pie.f()"); + } +} +``` + +最初看上去这两个文件毫不相关,但在 **Cake** 中可以创建一个 **Pie** 对象并调用它的 `f()` 方法。(注意,你的 CLASSPATH 中一定得有 `.`,这样文件才能编译)通常会认为 **Pie** 和 `f()` 具有包访问权限,因此不能被 **Cake** 访问。它们的确具有包访问权限,这是部分正确。**Cake.java** 可以访问它们是因为它们在相同的目录中且没有给自己设定明确的包名。Java 把这样的文件看作是隶属于该目录的默认包中,因此它们为该目录中所有的其他文件都提供了包访问权限。 + +### private: 你无法访问 + +关键字 **private** 意味着除了包含该成员的类,其他任何类都无法访问这个成员。同一包中的其他类无法访问 **private** 成员,因此这等于说是自己隔离自己。另一方面,让许多人合作创建一个包也是有可能的。使用 **private**,你可以自由地修改那个被修饰的成员,无需担心会影响同一包下的其他类。 + +默认的包访问权限通常提供了足够的隐藏措施;记住,使用类的客户端程序员无法访问包访问权限成员。这样做很好,因为默认访问权限是一种我们常用的权限(同时也是一种在忘记添加任何访问权限时自动得到的权限)。因此,通常考虑的是把哪些成员声明成 **public** 供客户端程序员使用。所以,最初不常使用关键字 **private**,因为程序没有它也可以照常工作。然而,使用 **private** 是非常重要的,尤其是在多线程环境中。(在"并发编程"一章中将看到)。 + +以下是一个使用 **private** 的例子: + +```java +// hiding/IceCream.java +// Demonstrates "private" keyword + +class Sundae { + private Sundae() {} + static Sundae makeASundae() { + return new Sundae(); + } +} + +public class IceCream { + public static void main(String[] args) { + //- Sundae x = new Sundae(); + Sundae x = Sundae.makeASundae(); + } +} +``` + +以上展示了 **private** 的用武之地:控制如何创建对象,防止别人直接访问某个特定的构造器(或全部构造器)。例子中,你无法通过构造器创建一个 **Sundae** 对象,而必须调用 `makeASundae()` 方法创建对象。 + +任何可以肯定只是该类的"助手"方法,都可以声明为 **private**,以确保不会在包中的其他地方误用它,也防止了你会去改变或删除它。将方法声明为 **private** 确保了你拥有这种选择权。 + +对于类中的 **private** 属性也是一样。除非必须公开底层实现(这种情况很少见),否则就将属性声明为 **private**。然而,不能因为类中某个对象的引用是 **private**,就认为其他对象也无法拥有该对象的 **public** 引用(参见附录:对象传递和返回)。 + +### protected: 继承访问权限 + +要理解 **protected** 的访问权限,我们在内容上需要作一点跳跃。首先,在介绍本书"复用"章节前,你不必真正理解本节的内容。但为了内容的完整性,这里作了简要介绍,举了个使用 **protected** 的例子。 + +关键字 **protected** 处理的是继承的概念,通过继承可以利用一个现有的类——我们称之为基类,然后添加新成员到现有类中而不必碰现有类。我们还可以改变类的现有成员的行为。为了从一个类中继承,需要声明新类 extends 一个现有类,像这样: + +```java +class Foo extends Bar {} +``` + +类定义的其他部分看起来是一样的。 + +如果你创建了一个新包,并从另一个包继承类,那么唯一能访问的就是被继承类的 **public** 成员。(如果在同一个包中继承,就可以操作所有的包访问权限的成员。)有时,基类的创建者会希望某个特定成员能被继承类访问,但不能被其他类访问。这时就需要使用 **protected**。**protected** 也提供包访问权限,也就是说,相同包内的其他类可以访问 **protected** 元素。 + +回顾下先前的文件 **Cookie.java**,下面的类不能调用包访问权限的方法 `bite()`: + +```java +// hiding/ChocolateChip.java +// Can't use package-access member from another package +import hiding.dessert.*; + +public class ChocolateChip extends Cookie { + public ChocolateChip() { + System.out.println("ChocolateChip constructor"); + } + + public void chomp() { + //- bite(); // Can't access bite + } + + public static void main(String[] args) { + ChocolateChip x = new ChocolateChip(); + x.chomp(); + } +} +``` + +输出: + +``` +Cookie constructor +ChocolateChip constructor +``` + +如果类 **Cookie** 中存在一个方法 `bite()`,那么它的任何子类中都存在 `bite()` 方法。但是因为 `bite()` 具有包访问权限并且位于另一个包中,所以我们在这个包中无法使用它。你可以把它声明为 **public**,但这样一来每个人都能访问它,这可能也不是你想要的。如果你将 **Cookie** 改成如下这样: + +```java +// hiding/cookie2/Cookie.java +package hiding.cookie2; + +public class Cookie { + public Cookie() { + System.out.println("Cookie constructor"); + } + + protected void bite() { + System.out.println("bite"); + } +} +``` + +这样,`bite()` 对于所有继承 **Cookie** 的类,都是可访问的: + +```java +// hiding/ChocolateChip2.java +import hiding.cookie2.*; + +public class ChocolateChip2 extends Cookie { + public ChocoalteChip2() { + System.out.println("ChocolateChip2 constructor"); + } + + public void chomp() { + bite(); // Protected method + } + + public static void main(String[] args) { + ChocolateChip2 x = new ChocolateChip2(); + x.chomp(); + } +} +``` + +输出: + +``` +Cookie constructor +ChocolateChip2 constructor +bite +``` + +尽管 `bite()` 也具有包访问权限,但它不是 **public** 的。 + +### 包访问权限 Vs Public 构造器 + +当你定义一个具有包访问权限的类时,你可以在类中定义一个 public 构造器,编译器不会报错: + +```java +// hiding/packageaccess/PublicConstructor.java +package hiding.packageaccess; + +class PublicConstructor { + public PublicConstructor() {} +} +``` + +有一个 Checkstyle 工具,你可以运行命令 **gradlew hiding:checkstyleMain** 使用它,它会指出这种写法是虚假的,而且从技术上来说是错误的。实际上你不能从包外访问到这个 **public** 构造器: + +```java +// hiding/CreatePackageAccessObject.java +// {WillNotCompile} +import hiding.packageaccess.*; + +public class CreatePackageAcessObject { + public static void main(String[] args) { + new PublicConstructor(); + } +} +``` + +如果你编译下这个类,会得到编译错误信息: + +``` +CreatePackageAccessObject.java:6:error: +PublicConstructor is not public in hiding.packageaccess; +cannot be accessed from outside package +new PublicConstructor(); +^ +1 error +``` + +因此,在一个具有包访问权限的类中定义一个 **public** 的构造器并不能真的使这个构造器成为 **public**,在声明的时候就应该标记为编译时错误。 + ## 接口和实现 +访问控制通常被称为实现的隐藏。将数据和方法包装进类中并把具体实现隐藏被称作是封装。其结果就是一个同时带有特征和行为的数据类型。 + +出于两个重要的原因,访问控制在数据类型内部划定了边界。第一个原因是确立客户端程序员可以使用和不能使用的边界。可以在结构中建立自己的内部机制而不必担心客户端程序员偶尔将内部实现作为他们可以使用的接口的一部分。 + +这直接引出了第二个原因:将接口与实现分离。如果在一组程序中使用结构,而客户端程序员只能向 **public** 接口发送消息的话,那么就可以自由地修改任何不是 **public** 的事物(例如包访问权限,protected,或 private 修饰的事物),却不会破坏客户端代码。 + +为了清晰起见,你可以采用一种创建类的风格:**public** 成员放在类的开头,接着是 **protected** 成员,包访问权限成员,最后是 **private** 成员。这么做的好处是类的使用者可以从头读起,首先会看到对他们而言最重要的部分(public 成员,因为可以从文件外访问它们),直到遇到非 **public** 成员时停止阅读,下面就是内部实现了: + +```java +// hiding/OrganizedByAccess.java + +public class OrganizedByAccess { + public void pub1() {/* ... */} + public void pub2() {/* ... */} + public void pub3() {/* ... */} + private void priv1() {/* ... */} + private void priv2() {/* ... */} + private void priv3() {/* ... */} + private int i; + // ... +} +``` + +这么做只能是程序阅读起来稍微容易一些,因为实现和接口还是混合在一起。也就是说,你仍然能看到源代码——实现部分,因为它就在类中。另外,javadoc 提供的注释文档功能降低了程序代码的可读性对客户端程序员的重要性。将接口展现给类的使用者实际上是类浏览器的任务,类浏览器会展示所有可用的类,并告诉你如何使用它们(比如说那些成员可用)。在 Java 中,JDK 文档起到了类浏览器的作用。 + ## 类访问权限 +访问权限修饰符也可以用于确定类库中的哪些类对于类库的使用者是可用的。如果希望某个类可以被客户端程序员使用,就把关键字 **public** 作用于整个类的定义。这甚至控制着客户端程序员能否创建类的对象。 + +为了控制一个类的访问权限,修饰符必须出现在关键字 **class** 之前: + +```java +public class Widget { +``` + +如果你的类库名是 **hiding**,那么任何客户端程序员都可以通过如下声明访问 **Widget**: + +```java +import hiding.Widget; +``` + +或者 + +```java +import hiding.*; +``` + +这里有一些额外的限制: + +1. 每个编译单元(即每个文件)中只能有一个 **public** 类。这表示,每个编译单元有一个公共的接口用 **public** 类表示。该接口可以包含许多支持包访问权限的类。一旦一个编译单元中出现一个以上的 **public** 类,编译就会报错。 +2. **public** 类的名称必须与含有该编译单元的文件名相同,包括大小写。所以对于 **Widget** 来说,文件名必须是 **Widget.java**,不能是 **widget.java** 或 **WIDGET.java**。再次强调,如果名字不匹配,编译器会报错。 +3. 虽然不是很常见,但是编译单元内没有 **public** 类也是可能的。这时可以随意命名文件(尽管随意命名会让代码的阅读者和维护者感到困惑)。 + +如果获取了一个在 **hiding** 包中的类,只用来完成 **Widget** 或 **hiding** 包下一些其他 **public** 类所要执行的任务,怎么办呢? 你不想自找麻烦为客户端程序员创建说明文档,并且你认为不久后会完全改变原有方案并将旧版本删除,替换成新版本。为了保留此灵活性,需要确保客户端程序员不依赖隐藏在 **hiding** 中的任何特定细节,那么把 **public** 关键字从类中去掉,给予它包访问权限,就可以了。 + +当你创建了一个包访问权限的类,把类中的属性声明为 **private** 仍然是有意义的——应该尽可能将所有属性都声明为 **private**,但是通常把方法声明成与类(包访问权限)相同的访问权限也是合理的。由于一个包访问权限的类只能被用于包内,除非你被强制将某些方法声明为 **public**,这种情况下,编译器会告诉你。 + +注意,类既不能是 **private** 的(这样除了该类自身,任何类都不能访问它),也不能是 **protected** 的。所以对于类的访问权限只有两种选择:包访问权限或者 **public**。为了防止类被外界访问,可以将所有的构造器声明为 **private**,这样只有你自己能创建对象(在类的 static 成员中): + +```java +// hiding/Lunch.java +// Demonstrates class access specifiers. Make a class +// effectively private with private constructors: + +class Soup1 { + private Soup1() {} + + public static Soup1 makeSoup() { // [1] + return new Soup1(); + } +} + +class Soup2 { + private Soup2() {} + + private static Soup2 ps1 = new Soup2(); // [2] + + public static Soup2 access() { + return ps1; + } + + public void f() {} +} +// Only one public class allowed per file: +public class Lunch { + void testPrivate() { + // Can't do this! Private constructor: + //- Soup1 soup = new Soup1(); + } + + void testStatic() { + Soup1 soup = Soup1.makeSoup(); + } + + void testSingleton() { + Soup2.access().f(); + } +} +``` + +可以像 [1] 那样通过 **static** 方法创建对象,也可以像 [2] 那样先创建一个静态对象,当用户需要访问它时返回对象的引用即可。 + +到目前为止,大部分的方法要么返回 void,要么返回基本类型,所以 [1] 处的定义乍看之下会有点困惑。方法名(**makeSoup**)前面的 **Soup1** 表明了方法返回的类型。到目前为止,这里经常是 **void**,即不返回任何东西。然而也可以返回对象的引用,就像这里一样。这个方法返回了对 **Soup1** 类对象的引用。 + +**Soup1** 和 **Soup2** 展示了如何通过嫁给你所有的构造器声明为 **private** 的方式防止直接创建某个类的对象。记住,如果你不显式地创建构造器,编译器会自动为你创建一个无参构造器(没有参数的构造器)。如果我们编写了无参构造器,那么编译器就不会自动创建构造器了。将构造器声明为 **private**,那么谁也无法创建该类的对象了。但是现在别人该怎么使用这个类呢?上述例子给出了两个选择。在 **Soup1** 中,有一个 **static** 方法,它的作用是创建一个新的 **Soup1** 对象并返回对象的引用。如果想要在返回引用之前在 **Soup1** 上做一些额外操作,或是记录创建了多少个 **Soup1** 对象(可以用来限制数量),这种做法是有用的。 + +**Soup2** 用到了所谓的*设计模式*。这种模式叫做*单例模式*,因为它只允许创建类的一个对象。**Soup2** 类的对象是作为 **Soup2** 的 **static** **private** 成员而创建的,所以有且只有一个,你只能通过 **public** 修饰的 `access()` 方法访问到这个对象。 + ## 本章小结 +无论在什么样的关系中,划定一些供各成员共同遵守的界限是很重要的。当你创建了一个类库,也就与该类库的使用者产生了联系,他们是类库的客户端程序员,需要使用你的类库创建应用或更大的类库。 +没有规则,客户端程序员就可以对类的所有成员为所欲为,即使你希望他们不要操作部分成员。这种情况下,所有事物都是公开的。 + +本章讨论了类库是如何通过类构建的:首先,介绍了将一组类打包到类库的方式,其次介绍了类如何控制对其成员的访问。 + +据估计,用 C 语言开发项目,当代码量达到 5 万行和 10 万行时就会出现问题,因为 C 语言只有单一的命名空间,名称开始冲突造成额外的管理开销。在 Java 中,关键字 **package**,包命名模式和关键字 **import** 给了你对于名称的完全控制权,因此可以轻易地避免名称冲突的问题。 + +控制成员访问权限有两个原因。第一个原因是使用户不要接触他们不该接触的部分,这部分对于类内部来说是必要的,但是不属于客户端程序员所需接口的一部分。因此将方法和属性声明为 **private** 对于客户端程序员来说是一种服务,可以让他们清楚地看到什么是重要的,什么可以忽略。这可以简化他们对类的理解。 + +第二个也是最重要的原因是为了让类库设计者更改类内部的工作方式,而不用担心会影响到客户端程序员。比如最初以某种方式创建一个类,随后发现如果更改代码结构可以极大地提高运行速度。如果接口与实现被明确地隔离和保护,你可以实现这一目的,而不必强制客户端程序员重新编写代码。访问权限控制确保客户端程序员不会依赖某个类的底层实现的任何部分。 + +当你具备更改底层实现的能力时,不但可以自由地改善设计,还可能会随意地犯错。无论如何细心地计划和设计,都有可能犯错。当了解到犯错是相对安全的时候,你可以更加放心地实验,更快地学会,更快地完成项目。 + +类的 **public** 接口是用户真正看到的,所以在分析和设计阶段决定这部分接口是最重要的部分。尽管如此,你仍然有改变的空间。如果最初没有创建出正确的接口,可以添加更多的方法,主要你不删除那些客户端程序员已经在他们的代码中使用的东西。 + +注意到访问权限控制关注的是类库创建者和外部使用者之间的关系,一种交流方式。很多情况下,事实并非如此。例如,你自己编写了所有的代码,或者在一个小组中工作,所有的东西都放在同一个包下。这些情况下,交流方式则是另外一种,此时严格地遵循访问权限规则也许不是最佳选择,默认(包)访问权限也许就足够好了。 diff --git a/docs/book/08-Reuse.md b/docs/book/08-Reuse.md index cef86cf..98fe8cf 100644 --- a/docs/book/08-Reuse.md +++ b/docs/book/08-Reuse.md @@ -68,7 +68,7 @@ i = 0 f = 0.0 source = Constructed ``` -这两个类中定义的一个方法是特殊的: **toString()**。每个非原始类型对象都有一个 **toString()** 方法,在编译器需要字符串但它有对象的特殊情况下调用该方法。因此,在[1]中,编译器看到您试图“添加”一个 **WaterSource** 类型字符串对象 。因为您只能将一个字符串添加到另一个字符串中,所以它说,“我将通过调用 **toString()** 将 **source** 转换成一个字符串。然后,它可以组合这两个字符串并将结果字符串传递给 **System.out.println()**。要对创建的任何类允许这种行为,只需要编写一个 **toString()** 方法。在 **toString()** 上使用 **@Override** 注释来告诉编译器,以确保正确地覆盖。**@Override** 是可选的,但它有助于验证您没有拼写错误 (或者更微妙地说,大小写字母输入错误)。类中的基本类型的字段自动初始化为零,正如 **object Everywhere** 一章中所述。但是对象引用被初始化为 **null**,如果您尝试调用其任何一个方法,您将得到一个异常(一个运行时错误)。 +这两个类中定义的一个方法是特殊的: **toString()**。每个非原始类型对象都有一个 **toString()** 方法,在编译器需要字符串但它有对象的特殊情况下调用该方法。因此,在[1]中,编译器看到你试图“添加”一个 **WaterSource** 类型字符串对象 。因为你只能将一个字符串添加到另一个字符串中,所以它说,“我将通过调用 **toString()** 将 **source** 转换成一个字符串。然后,它可以组合这两个字符串并将结果字符串传递给 **System.out.println()**。要对创建的任何类允许这种行为,只需要编写一个 **toString()** 方法。在 **toString()** 上使用 **@Override** 注释来告诉编译器,以确保正确地覆盖。**@Override** 是可选的,但它有助于验证你没有拼写错误 (或者更微妙地说,大小写字母输入错误)。类中的基本类型的字段自动初始化为零,正如 **object Everywhere** 一章中所述。但是对象引用被初始化为 **null**,如果你尝试调用其任何一个方法,你将得到一个异常(一个运行时错误)。 编译器不只是为每个引用创建一个默认对象,这是有意义的,因为在许多情况下,这会导致不必要的开销。初始化引用有四种方法: @@ -144,7 +144,7 @@ castille = Constructed ``` -在 **Bath** 构造函数中,在初始化发生。当您不在定义处初始化时,仍然不能保证在向对象引用发送消息之前执行任何初始化——如果您试图对未初始化的引用调用方法,则未初始化的引用将产生运行时异常。 +在 **Bath** 构造函数中,在初始化发生。当你不在定义处初始化时,仍然不能保证在向对象引用发送消息之前执行任何初始化——如果你试图对未初始化的引用调用方法,则未初始化的引用将产生运行时异常。 当调用 **toString()** 时,它将赋值 s4,以便在使用字段的时候进行初始化。 @@ -154,7 +154,7 @@ castille = Constructed 继承是所有面向对象语言的一个组成部分。事实证明,在创建类时总是要继承,因为除非显式地继承其他类,否则就隐式地继承 Java 的标准根类对象(Object)。 -组合的语法很明显,但是继承使用了一种特殊的语法。当您继承时,您说,“这个新类与那个旧类类似。您可以在类主体的左大括号前的代码中声明这一点,使用关键字 **extends** 后跟基类的名称。当您这样做时,您将自动获得基类中的所有字段和方法。这里有一个例子: +组合的语法很明显,但是继承使用了一种特殊的语法。当你继承时,你说,“这个新类与那个旧类类似。你可以在类主体的左大括号前的代码中声明这一点,使用关键字 **extends** 后跟基类的名称。当你这样做时,你将自动获得基类中的所有字段和方法。这里有一个例子: ```java // reuse/Detergent.java @@ -210,21 +210,21 @@ Cleanser dilute() apply() scrub() 这演示了一些特性。首先,在 **Cleanser append()** 方法中,使用 += 操作符将字符串连接到 **s**,这是 Java 设计人员“重载”来处理字符串的操作符之一 (还有 + )。 -第二,**Cleanser** 和 **Detergent** 都包含一个 **main()** 方法。您可以为每个类创建一个 main() ; 这允许对每个类进行简单的测试。当您完成测试时,不需要删除 **main()** ; 您可以将其留在以后的测试中。即使程序中有很多类,惟一 运行的 **main()** 也是在命令行上调用的 **main()**。这里,当您使用 **java Detergent** 时候,调用了 **Detergent.main()**。但是您也可以使用 **java Cleanser** 来调用 **Cleanser.main()** ,即使 **Cleanser** 不是一个公共类。即使类具只有包访问权,也可以访问 **public main()**。 +第二,**Cleanser** 和 **Detergent** 都包含一个 **main()** 方法。你可以为每个类创建一个 main() ; 这允许对每个类进行简单的测试。当你完成测试时,不需要删除 **main()** ; 你可以将其留在以后的测试中。即使程序中有很多类,惟一 运行的 **main()** 也是在命令行上调用的 **main()**。这里,当你使用 **java Detergent** 时候,调用了 **Detergent.main()**。但是你也可以使用 **java Cleanser** 来调用 **Cleanser.main()** ,即使 **Cleanser** 不是一个公共类。即使类具只有包访问权,也可以访问 **public main()**。 -在这里,**Detergent.main() **显式地调用 **Cleanser.main()** ,从命令行传递相同的参数(当然,您可以传递任何字符串数组)。 +在这里,**Detergent.main() **显式地调用 **Cleanser.main()** ,从命令行传递相同的参数(当然,你可以传递任何字符串数组)。 -**Cleanser** 中的所有方法都是公开的。请记住,如果不使用任何访问说明符,则成员默认为包访问,这只允许访问包成员。因此,在这个包中,如果没有访问说明符,任何人都可以使用这些方法。例如,**Detergent** 就没有问题。但是,如果要从 **Cleanser** 继承其他包中的类,则该类只能访问公共成员。因此,为了允许继承,一般规则是所有字段为私有,所有方法为公共。(受保护成员也允许派生类访问;你以后会知道的。)在特定的情况下,您必须进行调整,但这是一个有用的指南。 +**Cleanser** 中的所有方法都是公开的。请记住,如果不使用任何访问说明符,则成员默认为包访问,这只允许访问包成员。因此,在这个包中,如果没有访问说明符,任何人都可以使用这些方法。例如,**Detergent** 就没有问题。但是,如果要从 **Cleanser** 继承其他包中的类,则该类只能访问公共成员。因此,为了允许继承,一般规则是所有字段为私有,所有方法为公共。(受保护成员也允许派生类访问;你以后会知道的。)在特定的情况下,你必须进行调整,但这是一个有用的指南。 -**Cleanser** 的接口中有一组方法: **append()**、**dilute()**、**apply()**、**scrub()** 和 **toString()**。因为 **Detergent** 是从 **Cleanser** 派生的(通过 **extends** 关键字),所以它会在其接口中自动获取所有这些方法,即使您没有在 **Detergent** 中看到所有这些方法的显式定义。你那么,可以把继承看作是重用类。如在 **scrub()** 中所见,可以使用基类中定义的方法并修改它。在这里,您可以从新版本中的基类中调用该方法。但是在 **scrub()** 内部,不能简单地调用 **scrub()**,因为这会产生递归调用。为了解决这个问题,Java的 **super** 关键字引用了当前类继承的“超类”(基类)。因此表达式 **super.scrub()** 调用方法 **scrub()** 的基类版本。 +**Cleanser** 的接口中有一组方法: **append()**、**dilute()**、**apply()**、**scrub()** 和 **toString()**。因为 **Detergent** 是从 **Cleanser** 派生的(通过 **extends** 关键字),所以它会在其接口中自动获取所有这些方法,即使你没有在 **Detergent** 中看到所有这些方法的显式定义。你那么,可以把继承看作是重用类。如在 **scrub()** 中所见,可以使用基类中定义的方法并修改它。在这里,你可以从新版本中的基类中调用该方法。但是在 **scrub()** 内部,不能简单地调用 **scrub()**,因为这会产生递归调用。为了解决这个问题,Java的 **super** 关键字引用了当前类继承的“超类”(基类)。因此表达式 **super.scrub()** 调用方法 **scrub()** 的基类版本。 -继承时,您不受限于使用基类的方法。您还可以像向类添加任何方法一样向派生类添加新方法:只需定义它。方法 **foam()** 就是一个例子。**Detergent.main()** 中可以看到,对于 **Detergent** 对象,您可以调用 **Cleanser** 和 **Detergent** 中可用的所有方法 (如 **foam()** )。 +继承时,你不受限于使用基类的方法。你还可以像向类添加任何方法一样向派生类添加新方法:只需定义它。方法 **foam()** 就是一个例子。**Detergent.main()** 中可以看到,对于 **Detergent** 对象,你可以调用 **Cleanser** 和 **Detergent** 中可用的所有方法 (如 **foam()** )。 ### 初始化基类 -现在涉及到两个类:基类和派生类。想象派生类生成的结果对象可能会让人感到困惑。从外部看,新类与基类具有相同的接口,可能还有一些额外的方法和字段。但是继承并不只是复制基类的接口。当您创建派生类的对象时,它包含基类的子对象。这个子对象与您自己创建基类的对象是一样的。只是从外部看,基类的子对象被包装在派生类的对象中。 +现在涉及到两个类:基类和派生类。想象派生类生成的结果对象可能会让人感到困惑。从外部看,新类与基类具有相同的接口,可能还有一些额外的方法和字段。但是继承并不只是复制基类的接口。当你创建派生类的对象时,它包含基类的子对象。这个子对象与你自己创建基类的对象是一样的。只是从外部看,基类的子对象被包装在派生类的对象中。 必须正确初始化基类子对象,而且只有一种方法可以保证这一点 : 通过调用基类构造函数在构造函数中执行初始化,该构造函数具有执行基类初始化所需的所有适当信息和特权。Java 自动在派生类构造函数中插入对基类构造函数的调用。下面的例子展示了三个层次的继承: @@ -263,7 +263,7 @@ Cartoon constructor ``` -构造从基类“向外”进行,因此基类在派生类构造函数能够访问它之前进行初始化。即使不为 **Cartoon** 创建构造函数,编译器也会为您合成一个无参数构造函数,调用基类构造函数。尝试删除 **Cartoon** 构造函数来查看这个。 +构造从基类“向外”进行,因此基类在派生类构造函数能够访问它之前进行初始化。即使不为 **Cartoon** 创建构造函数,编译器也会为你合成一个无参数构造函数,调用基类构造函数。尝试删除 **Cartoon** 构造函数来查看这个。 @@ -316,7 +316,7 @@ Chess constructor ## 委托 -Java不直接支持的第三种重用关系称为委托。这介于继承和组合之间,因为您将一个成员对象放在正在构建的类中(比如组合),但同时又在新类中公开来自成员对象的所有方法(比如继承)。例如,宇宙飞船需要一个控制模块: +Java不直接支持的第三种重用关系称为委托。这介于继承和组合之间,因为你将一个成员对象放在正在构建的类中(比如组合),但同时又在新类中公开来自成员对象的所有方法(比如继承)。例如,宇宙飞船需要一个控制模块: ```java // reuse/SpaceShipControls.java @@ -361,7 +361,7 @@ DerivedSpaceShip extends SpaceShipControls { ``` -然而, **DerivedSpaceShip** 并不是真正的“一种” **SpaceShipControls** ,即使您“告诉” **DerivedSpaceShip** 调用 **forward()** 。更准确地说,一艘宇宙飞船包含了 **SpaceShipControls **,同时 **SpaceShipControls** 中的所有方法都暴露在宇宙飞船中。委托解决了这个难题: +然而, **DerivedSpaceShip** 并不是真正的“一种” **SpaceShipControls** ,即使你“告诉” **DerivedSpaceShip** 调用 **forward()** 。更准确地说,一艘宇宙飞船包含了 **SpaceShipControls **,同时 **SpaceShipControls** 中的所有方法都暴露在宇宙飞船中。委托解决了这个难题: ```java // reuse/SpaceShipDelegation.java @@ -407,7 +407,7 @@ public class SpaceShipDelegation { ``` -方法被转发到底层 **control** 对象,因此接口与继承的接口是相同的。但是,您对委托有更多的控制,因为您可以选择只在成员对象中提供方法的子集。 +方法被转发到底层 **control** 对象,因此接口与继承的接口是相同的。但是,你对委托有更多的控制,因为你可以选择只在成员对象中提供方法的子集。 虽然Java语言不支持委托,但是开发工具常常支持。例如,上面的例子是使用 JetBrains Idea IDE 自动生成的。 @@ -415,7 +415,7 @@ public class SpaceShipDelegation { ## 结合组合与继承 -您将经常同时使用组合和继承。下面的例子展示了使用继承和组合创建类,以及必要的构造函数初始化: +你将经常同时使用组合和继承。下面的例子展示了使用继承和组合创建类,以及必要的构造函数初始化: ```java // reuse/PlaceSetting.java @@ -503,13 +503,13 @@ PlaceSetting constructor ``` -尽管编译器强制您初始化基类,并要求您在构造函数的开头就初始化基类,但它并不监视您以确保您初始化了成员对象。注意类是如何干净地分离的。你甚至不需要方法重用代码的源代码。您最多只导入一个包。(这对于继承和组合都是正确的。) +尽管编译器强制你初始化基类,并要求你在构造函数的开头就初始化基类,但它并不监视你以确保你初始化了成员对象。注意类是如何干净地分离的。你甚至不需要方法重用代码的源代码。你最多只导入一个包。(这对于继承和组合都是正确的。) ### 保证适当的清理 -Java 没有 c++ 中析构函数的概念,析构函数是在对象被销毁时自动调用的方法。原因可能是,在Java中,实践只会忘记对象而不是销毁它们,从而允许垃圾收集器根据需要回收内存。通常这是可以的,但是有时您的类可能在其生命周期中执行一些需要清理的活动。Housekeeping 章节解释说,您不能知道何时调用垃圾收集器,甚至不能知道是否调用了它。因此,如果您想为类清理一些东西,您必须显式地编写一个特殊的方法来完成它,并确保客户机程序员知道他们必须调用这个方法。最重要的是——正如在 Exceptions 章节中描述的——您必须通过在 **finally **子句中放置此类清理来防止异常。 +Java 没有 c++ 中析构函数的概念,析构函数是在对象被销毁时自动调用的方法。原因可能是,在Java中,实践只会忘记对象而不是销毁它们,从而允许垃圾收集器根据需要回收内存。通常这是可以的,但是有时你的类可能在其生命周期中执行一些需要清理的活动。Housekeeping 章节解释说,你不能知道何时调用垃圾收集器,甚至不能知道是否调用了它。因此,如果你想为类清理一些东西,你必须显式地编写一个特殊的方法来完成它,并确保客户机程序员知道他们必须调用这个方法。最重要的是——正如在 Exceptions 章节中描述的——你必须通过在 **finally **子句中放置此类清理来防止异常。 请考虑一个在屏幕上绘制图片的计算机辅助设计系统的例子: @@ -635,11 +635,11 @@ Shape dispose 这个系统中的所有东西都是某种 **Shape** (它本身是一种 **Object**,因为它是从根类隐式继承的) 。除了使用 **super** 调用该方法的基类版本外,每个类还覆盖 **Shapes dispose()** 方法。特定的 **Shape** 类——Circle、**Triangle** 和 **Line**,都有 “draw” 构造函数,尽管在对象的生命周期中调用的任何方法都可以负责做一些需要清理的事情。每个类都有自己的 **dispose()** 方法来将非内存的内容恢复到对象存在之前的状态。 -在 **main()** 中,有两个关键字是您以前没有见过的,在 exception 一章之前不会详细解释: **try** 和 **finally**。try关键字表示后面的块 (用花括号分隔 )是一个受保护的区域,这意味着它得到了特殊处理。其中一个特殊处理是,无论 **try** 块如何退出,在这个保护区域之后的 **finally** 子句中的代码总是被执行。(通过异常处理,可以用许多不同寻常的方式留下 **try** 块。)这里,**finally** 子句的意思是,“无论发生什么,始终调用 **dispose()** for **x**。” +在 **main()** 中,有两个关键字是你以前没有见过的,在 exception 一章之前不会详细解释: **try** 和 **finally**。try关键字表示后面的块 (用花括号分隔 )是一个受保护的区域,这意味着它得到了特殊处理。其中一个特殊处理是,无论 **try** 块如何退出,在这个保护区域之后的 **finally** 子句中的代码总是被执行。(通过异常处理,可以用许多不同寻常的方式留下 **try** 块。)这里,**finally** 子句的意思是,“无论发生什么,始终调用 **dispose()** for **x**。” 在清理方法 (在本例中是 **dispose()** ) 中,还必须注意基类和成员对象清理方法的调用顺序,以防一个子对象依赖于另一个子对象。首先,按创建的相反顺序执行特定于类的所有清理工作。(一般来说,这要求基类元素仍然是可访问的。) 然后调用基类清理方法,如这所示。 -在很多情况下,清理问题不是问题;您只需要让垃圾收集器来完成这项工作。但是,当您必须执行显式清理时,就需要勤奋和注意,因为在垃圾收集方面没有什么可以依赖的。可能永远不会调用垃圾收集器。如果是,它可以按照它想要的任何顺序回收对象。除了内存回收外,您不能依赖垃圾收集来做任何事情。如果希望进行清理,可以使用自己的清理方法,不要使用 **finalize()**。 +在很多情况下,清理问题不是问题;你只需要让垃圾收集器来完成这项工作。但是,当你必须执行显式清理时,就需要勤奋和注意,因为在垃圾收集方面没有什么可以依赖的。可能永远不会调用垃圾收集器。如果是,它可以按照它想要的任何顺序回收对象。除了内存回收外,你不能依赖垃圾收集来做任何事情。如果希望进行清理,可以使用自己的清理方法,不要使用 **finalize()**。 @@ -692,9 +692,9 @@ doh(Milhouse) ```` -**Bart** 的所有重载方法在 **Bart** 中都是可用的,尽管 **Bart** 引入了一种新的重载方法。在下一章中您将看到,使用与基类中完全相同的签名和返回类型覆盖相同名称的方法要常见得多。否则就会令人困惑。 +**Bart** 的所有重载方法在 **Bart** 中都是可用的,尽管 **Bart** 引入了一种新的重载方法。在下一章中你将看到,使用与基类中完全相同的签名和返回类型覆盖相同名称的方法要常见得多。否则就会令人困惑。 -您已经看到了Java 5 **@Override **注释,它不是关键字,但是可以像使用关键字一样使用它。当你打算重写一个方法时,你可以选择添加这个注释,如果你不小心用了重载而不是重写,编译器会产生一个错误消息: +你已经看到了Java 5 **@Override **注释,它不是关键字,但是可以像使用关键字一样使用它。当你打算重写一个方法时,你可以选择添加这个注释,如果你不小心用了重载而不是重写,编译器会产生一个错误消息: ```java // reuse/Lisa.java @@ -711,7 +711,7 @@ class Lisa extends Homer { ``` -**{WillNotCompile}** 标记将该文件排除在本书的 **Gradle** 构建之外,但是如果您手工编译它,您将看到:方法不会覆盖超类中的方法, **@Override** 注释防止您意外地重载。 +**{WillNotCompile}** 标记将该文件排除在本书的 **Gradle** 构建之外,但是如果你手工编译它,你将看到:方法不会覆盖超类中的方法, **@Override** 注释防止你意外地重载。 diff --git a/docs/book/11-Inner-Classes.md b/docs/book/11-Inner-Classes.md index b2628a9..c31b371 100644 --- a/docs/book/11-Inner-Classes.md +++ b/docs/book/11-Inner-Classes.md @@ -1,60 +1,1396 @@ [TOC] + # 第十一章 内部类 +> 一个类的定义在另一个类的定义内部,这就是内部类。 + +内部类是一种非常有用的特性,因为它允许你把一些逻辑相关的类组织在一起,并控制位于内部的类的可视性。然而必须要了解,内部类与组合是完全不同的概念,这一点很重要。在最初,内部类看起来就像是一种代码隐藏机制:将类置于其他类的内部。但是,你将会了解到,内部类远不止如此,它了解外围类,并能与之通信,而且你用内部类写出的代码更加优雅而清晰,尽管并不总是这样。 + +最初,内部类可能看起来有些奇怪,而且要花些时间才能在设计中轻松地使用它们。对内部类的需求并非总是很明显的,但是在描述完内部类的基本语法与语义之后,"Why inner classes?"就应该使得内部类的益处明确显现了。 + +本章剩余部分包含了对内部类语法更加详尽的探索,这些特性是为了语言的完备性而设计的,但是你也许不需要使用它们,至少一开始不需要。因此,本章最初的部分也许就是你现在所需的全部,你可以将更详尽的探索当作参考资料。 + ## 创建内部类 +创建内部类的方式就如同你想的一样——把类的定义置于外围类的里面: + +```java +// innerclasses/Parcel1.java +// Creating inner classes +public class Parcel1 { + class Contents { + private int i = 11; + public int value() { return i; } + } + class Destination { + private String label; + Destination(String whereTo) { + label = whereTo; + } + String readLabel() { return label; } + } + // Using inner classes looks just like + // using any other class, within Parcel1: + public void ship(String dest) { + Contents c = new Contents(); + Destination d = new Destination(dest); + System.out.println(d.readLabel()); + } + public static void main(String[] args) { + Parcel1 p = new Parcel1(); + p.ship("Tasmania"); + } +} +``` + +输出为: + +``` +Tasmania +``` + +当我们在 ship() 方法里面使用内部类的时候,与使用普通类没什么不同。在这里,实际的区别只是内部类的名字是嵌套在 Parcel1 里面的。 + +更典型的情况是,外部类将有一个方法,该方法返回一个指向内部类的引用,就像在 to() 和 contents() 方法中看到的那样: + +```java +// innerclasses/Parcel2.java +// Returning a reference to an inner class +public class Parcel2 { + class Contents { + private int i = 11; + public int value() { return i; } + } + class Destination { + private String label; + Destination(String whereTo) { + label = whereTo; + } + String readLabel() { return label; } + } + public Destination to(String s) { + return new Destination(s); + } + public Contents contents() { + return new Contents(); + } + public void ship(String dest) { + Contents c = contents(); + Destination d = to(dest); + System.out.println(d.readLabel()); + } + public static void main(String[] args) { + Parcel2 p = new Parcel2(); + p.ship("Tasmania"); + Parcel2 q = new Parcel2(); +// Defining references to inner classes: + Parcel2.Contents c = q.contents(); + Parcel2.Destination d = q.to("Borneo"); + } +} +``` + +输出为: + +``` +Tasmania +``` + +如果想从外部类的非静态方法之外的任意位置创建某个内部类的对象,那么必须像在 main() 方法中那样,具体地指明这个对象的类型:OuterClassName.InnerClassName。 + ## 链接外部类 +到目前为止,内部类似乎还只是一种名字隐藏和组织代码的模式。这些是很有用,但还不是最引人注目的,它还有其他的用途。当生成一个内部类的对象时,此对象与制造它的外围对象(enclosing object)之间就有了一种联系,所以它能访问其外围对象的所有成员,而不需要任何特殊条件。此外,内部类还拥有其外围类的所有元素的访问权。 + +```java +// innerclasses/Sequence.java +// Holds a sequence of Objects +interface Selector { + boolean end(); + Object current(); + void next(); +} +public class Sequence { + private Object[] items; + private int next = 0; + public Sequence(int size) { + items = new Object[size]; + } + public void add(Object x) { + if(next < items.length) + items[next++] = x; + } + private class SequenceSelector implements Selector { + private int i = 0; + @Override + public boolean end() { return i == items.length; } + @Override + public Object current() { return items[i]; } + @Override + public void next() { if(i < items.length) i++; } + } + public Selector selector() { + return new SequenceSelector(); + } + public static void main(String[] args) { + Sequence sequence = new Sequence(10); + for(int i = 0; i < 10; i++) + sequence.add(Integer.toString(i)); + Selector selector = sequence.selector(); + while(!selector.end()) { + System.out.print(selector.current() + " "); + selector.next(); + } + } +} +``` + +输出为: + +``` +0 1 2 3 4 5 6 7 8 9 +``` + +Sequence 类只是一个固定大小的 Object 的数组,以类的形式包装了起来。可以调用 add)在序列末增加新的 Object(只要还有空间),要获取 Sequence 中的每一个对象,可以使用 Selector 接口。这是“迭代器”设计模式的一个例子,在本书稍后的部分将更多地学习它。Selector 允许你检查序列是否到末尾了(end()),访问当前对象(current()),以及移到序列中的下一个对象(next()),因为 Selector 是一个接口,所以别的类可以按它们自己的方式来实现这个接口,并且另的方法能以此接口为参数,来生成更加通用的代码。 + +这里,SequenceSelector 是提供 Selector 功能的 private 类。可以看到,在 main() 中创建了一个 Sequence,并向其中添加了一些 String 对象。然后通过调用 selector() 获取一个 Selector,并用它在 Sequence 中移动和选择每一个元素。 +最初看到 SequenceSelector,可能会觉得它只不过是另一个内部类罢了。但请仔细观察它,注意方法 end(),current() 和 next() 都用到了 items,这是一个引用,它并不是 SequenceSelector 的一部分,而是外围类中的一个 private 字段。然而内部类可以访问其外围类的方法和字段,就像自己拥有它们似的,这带来了很大的方便,就如前面的例子所示。 + +所以内部类自动拥有对其外围类所有成员的访问权。这是如何做到的呢?当某个外围类的对象创建了一个内部类对象时,此内部类对象必定会秘密地捕获一个指向那个外围类对象的引用。然后,在你访问此外围类的成员时,就是用那个引用来选择外围类的成员。幸运的是,编译器会帮你处理所有的细节,但你现在可以看到:内部类的对象只能在与其外围类的对象相关联的情况下才能被创建(就像你应该看到的,在内部类是非 static 类时)。构建内部类对象时,需要一个指向其外围类对象的引用,如果编译器访问不到这个引用就会报错。不过绝大多数时候这都无需程序员操心。 -## 内部类this和new的使用 +## 使用 .this 和 .new +如果你需要生成对外部类对象的引用,可以使用外部类的名字后面紧跟圆点和 this。这样产生的引用自动地具有正确的类型,这一点在编译期就被知晓并受到检查,因此没有任何运行时开销。下面的示例展示了如何使用.this: + +```java +// innerclasses/DotThis.java +// Accessing the outer-class object +public class DotThis { + void f() { System.out.println("DotThis.f()"); } + public class Inner { + public DotThis outer() { + return DotThis.this; + // A plain "this" would be Inner's "this" + } + } + public Inner inner() { return new Inner(); } + public static void main(String[] args) { + DotThis dt = new DotThis(); + DotThis.Inner dti = dt.inner(); + dti.outer().f(); + } +} +``` + +输出为: + +``` +DotThis.f() +``` + +有时你可能想要告知某些其他对象,去创建其某个内部类的对象。要实现此目的,你必须在 mew 表达式中提供对其他外部类对象的引用,这是需要使用.new 语法,就像下面这样: + +```java +// innerclasses/DotNew.java +// Creating an inner class directly using .new syntax +public class DotNew { + public class Inner {} + public static void main(String[] args) { + DotNew dn = new DotNew(); + DotNew.Inner dni = dn.new Inner(); + } +} +``` + +要想直接创建内部类的对象,你不能按照你想象的方式,去引用外部类的名字 DotNew,而是必须使用外部类的对象来创建该内部类对象,就像在上面的程序中所看到的那样。这也解决了内部类名字作用域的问题,因此你不必声明(实际上你不能声明)dn.new DotNew.Innero。 + +下面你可以看到将.new 应用于 Parcel 的示例: + +```java +// innerclasses/Parcel3.java +// Using .new to create instances of inner classes +public class Parcel3 { + class Contents { + private int i = 11; + public int value() { return i; } + } + class Destination { + private String label; + Destination(String whereTo) { label = whereTo; } + String readLabel() { return label; } + } + public static void main(String[] args) { + Parcel3 p = new Parcel3(); +// Must use instance of outer class +// to create an instance of the inner class: + Parcel3.Contents c = p.new Contents(); + Parcel3.Destination d = + p.new Destination("Tasmania"); + } +} +``` + +在拥有外部类对象之前是不可能创建内部类对象的。这是因为内部类对象会暗暗地连接到建它的外部类对象上。但是,如果你创建的是嵌套类(静态内部类),那么它就不需要对外部类对象的引用。 -## 内部类向上转型 +## 内部类与向上转型 +当将内部类向上转型为其基类,尤其是转型为一个接口的时候,内部类就有了用武之地。(从实现了某个接口的对象,得到对此接口的引用,与向上转型为这个对象的基类,实质上效果是一样的。)这是因为此内部类-某个接口的实现-能够完全不可见,并且不可用。所得到的只是指向基类或接口的引用,所以能够很方便地隐藏实现细节。 + +我们可以创建前一个示例的接口: + +```java +// innerclasses/Destination.java +public interface Destination { + String readLabel(); +} +``` + +```java +// innerclasses/Contents.java +public interface Contents { + int value(); +} +``` + +现在 Contents 和 Destination 表示客户端程序员可用的接口。记住,接口的所有成员自动被设置为 public 的。 + +当取得了一个指向基类或接口的引用时,甚至可能无法找出它确切的类型,看下面的例子: + +```java +// innerclasses/TestParcel.java +class Parcel4 { + private class PContents implements Contents { + private int i = 11; + @Override + public int value() { return i; } + } + protected final class PDestination implements Destination { + private String label; + private PDestination(String whereTo) { + label = whereTo; + } + @Override + public String readLabel() { return label; } + } + public Destination destination(String s) { + return new PDestination(s); + } + public Contents contents() { + return new PContents(); + } +} +public class TestParcel { + public static void main(String[] args) { + Parcel4 p = new Parcel4(); + Contents c = p.contents(); + Destination d = p.destination("Tasmania"); +// Illegal -- can't access private class: +//- Parcel4.PContents pc = p.new PContents(); + } +} +``` + +在 Parcel4 中,内部类 PContents 是 private,所以除了 Parcel4,没有人能访问它。普通(非内部)类的访问权限不能被设为 private 或者 protected;他们只能设置为 public 或 package 访问权限。 + +PDestination 是 protected,所以只有 Parcel4 及其子类、还有与 Parcel4 同一个包中的类(因为 protected 也给予了包访问权)能访问 PDestination,其他类都不能访问 PDestination,这意味着,如果客户端程序员想了解或访问这些成员,那是要受到限制的。实际上,甚至不能向下转型成 private 内部类(或 protected 内部类,除非是继承自它的子类),因为不能访问其名字,就像在 TestParcel 类中看到的那样。 + +private 内部类给类的设计者提供了一种途径,通过这种方式可以完全阻止任何依赖于类型的编码,并且完全隐藏了实现的细节。此外,从客户端程序员的角度来看,由于不能访问任何新增加的、原本不属于公共接口的方法,所以扩展接口是没值的。这也给 Java 编译器 + ## 内部类方法和作用域 +到目前为止,读者所看到的只是内部类的典型用途。通常,如果所读、写的代码包含了内部类,那么它们都是“平凡的”内部类,简单并且容易理解。然而,内部类的语法覆盖了大量其他的更加难以理解的技术。例如,可以在一个方法里面或者在任意的作用域内定义内部类。 + +这么做有两个理由: + +1. 如前所示,你实现了某类型的接口,于是可以创建并返回对其的引用。 +2. 你要解决一个复杂的问题,想创建一个类来辅助你的解决方案,但是又不希望这个类是公共可用的。 + +在后面的例子中,先前的代码将被修改,以用来实现: + +1. 一个定义在方法中的类。 +2. 一个定义在作用域内的类,此作用域在方法的内部。 +3. 一个实现了接口的匿名类。 +4. 一个匿名类,它扩展了有非默认构造器的类。 +5. 一个匿名类,它执行字段初始化。 +6. 一个匿名类,它通过实例初始化实现构造(匿名类不可能有构造器)。 + +第一个例子展示了在方法的作用域内(而不是在其他类的作用域内)创建一个完整的类。这被称作局部内部类: + +```java +// innerclasses/Parcel5.java +// Nesting a class within a method +public class Parcel5 { + public Destination destination(String s) { + final class PDestination implements Destination { + private String label; + private PDestination(String whereTo) { + label = whereTo; + } + @Override + public String readLabel() { return label; } + } + return new PDestination(s); + } + public static void main(String[] args) { + Parcel5 p = new Parcel5(); + Destination d = p.destination("Tasmania"); + } +} +``` + +PDestination 类是 destination() 方法的一部分,而不是 Parcel5 的一部分。所以,在 destination() 之外不能访问 PDestination,注意出现在 return 语句中的向上转型-返回的是 Destination 的引用,它是 PDestination 的基类。当然,在 destination() 中定义了内部类 PDestination,并不意味着一旦 dest() 方法执行完毕,PDestination 就不可用了。 + +你可以在同一个子目录下的任意类中对某个内部类使用类标识符 PDestination,这并不会有命名冲突。 + +下面的例子展示了如何在任意的作用域内嵌入一个内部类: + +```java +// innerclasses/Parcel6.java +// Nesting a class within a scope +public class Parcel6 { + private void internalTracking(boolean b) { + if(b) { + class TrackingSlip { + private String id; + TrackingSlip(String s) { + id = s; + } + String getSlip() { return id; } + } + TrackingSlip ts = new TrackingSlip("slip"); + String s = ts.getSlip(); + } + // Can't use it here! Out of scope: + //- TrackingSlip ts = new TrackingSlip("x"); + } + public void track() { internalTracking(true); } + public static void main(String[] args) { + Parcel6 p = new Parcel6(); + p.track(); + } +} +``` + +TrackingSlip 类被嵌入在 if 语句的作用域内,这并不是说该类的创建是有条件的,它其实与别的类一起编译过了。然而,在定义 Trackingslip 的作用域之外,它是不可用的,除此之外,它与普通的类一样。 + ## 匿名内部类 +下面的例子看起来有点奇怪: + +```java +// innerclasses/Parcel7.java +// Returning an instance of an anonymous inner class +public class Parcel7 { + public Contents contents() { + return new Contents() { // Insert class definition + private int i = 11; + @Override + public int value() { return i; } + }; // Semicolon required + } + public static void main(String[] args) { + Parcel7 p = new Parcel7(); + Contents c = p.contents(); + } +} +``` + +contents() 方法将返回值的生成与表示这个返回值的类的定义结合在一起!另外,这个类是匿名的,它没有名字。更糟的是,看起来似乎是你正要创建一个 Contents 对象。但是然后(在到达语句结束的分号之前)你却说:“等一等,我想在这里插入一个类的定义。 + +这种奇怪的语法指的是:“创建一个继承自 Contents 的匿名类的对象。”通过 new 表达式返回的引用被自动向上转型为对 Contents 的引用。上述匿名内部类的语法是下述形式的简化形式: + +```java +// innerclasses/Parcel7b.java +// Expanded version of Parcel7.java +public class Parcel7b { + class MyContents implements Contents { + private int i = 11; + @Override + public int value() { return i; } + } + public Contents contents() { + return new MyContents(); + } + public static void main(String[] args) { + Parcel7b p = new Parcel7b(); + Contents c = p.contents(); + } +} +``` + +在这个匿名内部类中,使用了默认的构造器来生成 Contents。下面的代码展示的是,如果你的基类需要一个有参数的构造器,应该怎么办: + +```java +// innerclasses/Parcel8.java +// Calling the base-class constructor +public class Parcel8 { + public Wrapping wrapping(int x) { +// Base constructor call: + return new Wrapping(x) { // [1] + @Override + public int value() { + return super.value() * 47; + } + }; // [2] + } + public static void main(String[] args) { + Parcel8 p = new Parcel8(); + Wrapping w = p.wrapping(10); + } +} +``` + +- \[1\] 将合适的参数传递给基类的构造器。 +- \[2\] 在匿名内部类末尾的分号,并不是用来标记此内部类结束的。实际上,它标记的是表达式的结束,只不过这个表达式正巧包含了匿名内部类罢了。因此,这与别的地方使用的分号是一致的。 + +尽管 Wrapping 只是一个具有具体实现的普通类,但它还是被共导出类当作公共“接口”来使用。 + +```java +// innerclasses/Wrapping.java +public class Wrapping { + private int i; + public Wrapping(int x) { i = x; } + public int value() { return i; } +} +``` + +为了多样性,Wrapping 拥有一个要求传递一个参数的构造器。 + +在匿名类中定义字段时,还能够对其执行初始化操作: + +```java +// innerclasses/Parcel9.java +public class Parcel9 { + // Argument must be final or "effectively final" +// to use within the anonymous inner class: + public Destination destination(final String dest) { + return new Destination() { + private String label = dest; + @Override + public String readLabel() { return label; } + }; + } + public static void main(String[] args) { + Parcel9 p = new Parcel9(); + Destination d = p.destination("Tasmania"); + } +} +``` + +如果定义一个匿名内部类,并且希望它使用一个在其外部定义的对象,那么编译器会要求其参数引用是 final 的,就像你在 destination() 的参数中看到的那样。如果你忘记了,将会得到一个编译时错误消息。 + +如果只是简单地给一个字段赋值,那么此例中的方法是很好的。但是,如果想做一些类似勾造器的行为,该怎么办呢?在匿名类中不可能有命名构造器(因为它根本没名字!),但通过实例初始化,就能够达到为匿名内部类创建一个构造器的效果,就像这样: + +```java +// innerclasses/AnonymousConstructor.java +// Creating a constructor for an anonymous inner class +abstract class Base { + Base(int i) { + System.out.println("Base constructor, i = " + i); + } + public abstract void f(); +} +public class AnonymousConstructor { + public static Base getBase(int i) { + return new Base(i) { + { System.out.println( + "Inside instance initializer"); } + @Override + public void f() { + System.out.println("In anonymous f()"); + } + }; + } + public static void main(String[] args) { + Base base = getBase(47); + base.f(); + } +} +``` + +输出为: + +``` +Base constructor, i = 47 +Inside instance initializer +In anonymous f() +``` + +在此例中,不要求变量一定是 final 的。因为被传递给匿名类的基类的构造器,它并不会在匿名类内部被直接使用。 + +下例是带实例初始化的"parcel"形式。注意 destination() 的参数必须是 final 的,因为它们是在匿名类内部使用的。 + +```java +// innerclasses/Parcel10.java +// Using "instance initialization" to perform +// construction on an anonymous inner class +public class Parcel10 { + public Destination + destination(final String dest, final float price) { + return new Destination() { + private int cost; + // Instance initialization for each object: + { + cost = Math.round(price); + if(cost > 100) + System.out.println("Over budget!"); + } + private String label = dest; + @Override + public String readLabel() { return label; } + }; + } + public static void main(String[] args) { + Parcel10 p = new Parcel10(); + Destination d = p.destination("Tasmania", 101.395F); + } +} +``` + +输出为: + +``` +Over budget! +``` + +在实例初始化操作的内部,可以看到有一段代码,它们不能作为字段初始化动作的一部分来执行(就是 if 语句)。所以对于匿名类而言,实例初始化的实际效果就是构造器。当然它受到了限制-你不能重载实例初始化方法,所以你仅有一个这样的构造器。 + +匿名内部类与正规的继承相比有些受限,因为匿名内部类既可以扩展类,也可以实现接口,但是不能两者兼备。而且如果是实现接口,也只能实现一个接口。 + ## 嵌套类 +如果不需要内部类对象与其外围类对象之间有联系,那么可以将内部类声明为 static,这通常称为嵌套类。想要理解 static 应用于内部类时的含义,就必须记住,普通的内部类对象隐式地保存了一个引用,指向创建它的外围类对象。然而,当内部类是 static 的时,就不是这样了。嵌套类意味着: + +1. 要创建嵌套类的对象,并不需要其外围类的对象。 +2. 不能从嵌套类的对象中访问非静态的外围类对象。 + +嵌套类与普通的内部类还有一个区别。普通内部类的字段与方法,只能放在类的外部层次上,所以普通的内部类不能有 static 数据和 static 字段,也不能包含嵌套类。但是嵌套类类可以包含所有这些东西: + +```java +// innerclasses/Parcel11.java +// Nested classes (static inner classes) +public class Parcel11 { + private static class ParcelContents implements Contents { + private int i = 11; + @Override + public int value() { return i; } + } + protected static final class ParcelDestination + implements Destination { + private String label; + private ParcelDestination(String whereTo) { + label = whereTo; + } + @Override + public String readLabel() { return label; } + // Nested classes can contain other static elements: + public static void f() {} + static int x = 10; + static class AnotherLevel { + public static void f() {} + static int x = 10; + } + } + public static Destination destination(String s) { + return new ParcelDestination(s); + } + public static Contents contents() { + return new ParcelContents(); + } + public static void main(String[] args) { + Contents c = contents(); + Destination d = destination("Tasmania"); + } +} +``` + +在 main() 中,没有任何 Parcell1 的对象是必需的;而是使用选取 static 成员的普通语法来调用方法-这些方法返回对 Contents 和 Destination 的引用。 + +就像你在本章前面看到的那样,在一个普通的(非 static)内部类中,通过一个特殊的 this 引用可以链接到其外围类对象。嵌套类就没有这个特殊的 this 引用,这使得它类似于一个 static 方法。 + +### 接口内部的类 + +嵌套类可以作为接口的一部分。你放到接口中的任何类都自动地是 public 和 static 的。因为类是 static 的,只是将嵌套类置于接口的命名空间内,这并不违反接口的规则。你甚至可以在内部类中实现其外围接口,就像下面这样: + +```java +// innerclasses/ClassInInterface.java +// {java ClassInInterface$Test} +public interface ClassInInterface { + void howdy(); + class Test implements ClassInInterface { + @Override + public void howdy() { + System.out.println("Howdy!"); + } + public static void main(String[] args) { + new Test().howdy(); + } + } +} +``` + +输出为: + +``` +Howdy! +``` + +如果你想要创建某些公共代码,使得它们可以被某个接口的所有不同实现所共用,那么使用接口内部的铁套类会显得很方便。 + +我曾在本书中建议过,在每个类中都写一个 main() 方法,用来测试这个类。这样做有一个缺点,那就是必须带着那些已编译过的额外代码。如果这对你是个麻烦,那就可以使用嵌套类来放置测试代码。 + +```java +// innerclasses/TestBed.java +// Putting test code in a nested class +// {java TestBed$Tester} +public class TestBed { + public void f() { System.out.println("f()"); } + public static class Tester { + public static void main(String[] args) { + TestBed t = new TestBed(); + t.f(); + } + } +} +``` + +输出为: + +``` +f() +``` + +### 从多层嵌套类中访问外部类的成员 + +一个内部类被嵌套多少层并不重要——它能透明地访问所有它所嵌入的外围类的所有成员,如下所示: + +```java +// innerclasses/MultiNestingAccess.java +// Nested classes can access all members of all +// levels of the classes they are nested within +class MNA { + private void f() {} + class A { + private void g() {} + public class B { + void h() { + g(); + f(); + } + } + } +} +public class MultiNestingAccess { + public static void main(String[] args) { + MNA mna = new MNA(); + MNA.A mnaa = mna.new A(); + MNA.A.B mnaab = mnaa.new B(); + mnaab.h(); + } +} +``` + +可以看到在 MNA.A.B 中,调用方法 g() 和 f() 不需要任何条件(即使它们被定义为 private)。这个例子同时展示了如何从不同的类里创建多层嵌套的内部类对象的基本语法。".new"语法能产生正确的作用域,所以不必在调用构造器时限定类名。 -## 内部类应用场景 +## 为什么需要内部类 + +至此,我们已经看到了许多描述内部类的语法和语义,但是这并不能同答“为什么需要内部类”这个问题。那么,Sun 公司为什么会如此费心地增加这项基本的语言特性呢? + +一般说来,内部类继承自某个类或实现某个接口,内部类的代码操作创建它的外围类的对象。所以可以认为内部类提供了某种进入其外围类的窗口。 + +内部类必须要回答的一个问题是:如果只是需要一个对接口的引用,为什么不通过外围类实现那个接口呢?答案是:“如果这能满足需求,那么就应该这样做。”那么内部类实现一个接口与外围类实现这个接口有什么区别呢?答案是:后者不是总能享用到接口带来的方便,有时需要用到接口的实现。所以,使用内部类最吸引人的原因是: + +> 每个内部类都能独立地继承自一个(接口的)实现,所以无论外围类是否已经继承了某个(接口的)实现,对于内部类都没有影响。 + +如果没有内部类提供的、可以继承多个具体的或抽象的类的能力,一些设计与编程问题就很难解决。从这个角度看,内部类使得多重继承的解决方案变得完整。接口解决了部分问题,而内部类有效地实现了“多重继承”。也就是说,内部类允许继承多个非接口类型(译注:类或抽象类)。 + +为了看到更多的细节,让我们考虑这样一种情形:即必须在一个类中以某种方式实现两个接口。由于接口的灵活性,你有两种选择;使用单一类,或者使用内部类: + +```java +// innerclasses/mui/MultiInterfaces.java +// Two ways a class can implement multiple interfaces +// {java innerclasses.mui.MultiInterfaces} +package innerclasses.mui; +interface A {} +interface B {} +class X implements A, B {} +class Y implements A { + B makeB() { +// Anonymous inner class: + return new B() {}; + } +} +public class MultiInterfaces { + static void takesA(A a) {} + static void takesB(B b) {} + public static void main(String[] args) { + X x = new X(); + Y y = new Y(); + takesA(x); + takesA(y); + takesB(x); + takesB(y.makeB()); + } +} +``` + +当然,这里假设在两种方式下的代码结构都确实有逻辑意义。然而遇到问题的时候,通常问题本身就能给出某些指引,告诉你是应该使用单一类,还是使用内部类。但如果没有任何其他限制,从实现的观点来看,前面的例子并没有什么区别,它们都能正常运作。 + +如果拥有的是抽象的类或具体的类,而不是接口,那就只能使用内部类才能实现多重继承。 + +如果不需要解决“多重继承”的问题,那么自然可以用别的方式编码,而不需要使用内部类。但如果使用内部类,还可以获得其他一些特性: + +1. 内部类可以有多个实例,每个实例都有自己的状态信息,并且与其外围类对象的信息相互独立。 +2. 在单个外围类中,可以让多个内部类以不同的方式实现同一个接口,或继承同一个类。 +稍后就会展示一个这样的例子。 +3. 创建内部类对象的时刻并不依赖于外围类对象的创建 +4. 内部类并没有令人迷惑的"is-a”关系,它就是一个独立的实体。 + +举个例子,如果 Sequence.java 不使用内部类,就必须声明"Sequence 是一个 Selector",对于某个特定的 Sequence 只能有一个 Selector,然而使用内部类很容易就能拥有另一个方法 reverseSelector(),用它来生成一个反方向遍历序列的 Selector,只有内部类才有这种灵活性。 + +### 闭包与回调 + +闭包(closure)是一个可调用的对象,它记录了一些信息,这些信息来自于创建它的作用域。通过这个定义,可以看出内部类是面向对象的闭包,因为它不仅包含外围类对象(创建内部类的作用域)的信息,还自动拥有一个指向此外围类对象的引用,在此作用域内,内部类有权操作所有的成员,包括 private 成员。 + +在 Java 8 之前,生成闭包行为的唯一方式就是内部类。在 Java 8 之后,我们可以使用 lambda 来生成闭包行为,并且语法更加精细和简洁;你将会在 [函数式编程 ]() 这一章节中学习相关细节。即使应该优先使用 lambda 表达式用于内部类闭包,你依旧会看到那些 Java 8 以前的代码,即使用内部类来表示闭包的方式,所以非常有必要来理解这种形式。 + +Java 最引人争议的问题之一就是,人们认为 Java 应该包含某种类似指针的机制,以允许回调(callback)。通过回调,对象能够携带一些信息,这些信息允许它在稍后的某个时刻调用初始的对象。稍后将会看到这是一个非常有用的概念。如果回调是通过指针实现的,那么就只能寄希望于程序员不会误用该指针。然而,读者应该已经了解到,Java 更小心仔细,所以没有在语言中包括指针。 + +通过内部类提供闭包的功能是优良的解决方案,它比指针更灵活、更安全。见下例: + +```java +// innerclasses/Callbacks.java +// Using inner classes for callbacks +// {java innerclasses.Callbacks} +package innerclasses; +interface Incrementable { + void increment(); +} +// Very simple to just implement the interface: +class Callee1 implements Incrementable { + private int i = 0; + @Override + public void increment() { + i++; + System.out.println(i); + } +} +class MyIncrement { + public void increment() { + System.out.println("Other operation"); + } + static void f(MyIncrement mi) { mi.increment(); } +} +// If your class must implement increment() in +// some other way, you must use an inner class: +class Callee2 extends MyIncrement { + private int i = 0; + @Override + public void increment() { + super.increment(); + i++; + System.out.println(i); + } + private class Closure implements Incrementable { + @Override + public void increment() { +// Specify outer-class method, otherwise +// you'll get an infinite recursion: + Callee2.this.increment(); + } + } + Incrementable getCallbackReference() { + return new Closure(); + } +} +class Caller { + private Incrementable callbackReference; + Caller(Incrementable cbh) { + callbackReference = cbh; + } + void go() { callbackReference.increment(); } +} +public class Callbacks { + public static void main(String[] args) { + Callee1 c1 = new Callee1(); + Callee2 c2 = new Callee2(); + MyIncrement.f(c2); + Caller caller1 = new Caller(c1); + Caller caller2 = + new Caller(c2.getCallbackReference()); + caller1.go(); + caller1.go(); + caller2.go(); + caller2.go(); + } +} +``` + +输出为: + +```java +Other operation +1 +1 +2 +Other operation +2 +Other operation +3 +``` + +这个例子进一步展示了外围类实现一个接口与内部类实现此接口之间的区别。就代码而言,Calleel 是简单的解决方式。Callee2 继承自 MyIncrement,后者已经有了一个不同的 increment() 方法,并且与 Incrementable 接口期望的 increment() 方法完全不相关。所以如果 Callee2 继承了 MyIncrement,就不能为了 Incrementable 的用途而覆盖 increment() 方法,于是只能使用内部类独立地实现 Incrementable,还要注意,当创建了一个内部类时,并没有在外围类的接口中添加东西,也没有修改外围类的接口。 + +注意,在 Callee2 中除了 getCallbackReference() 以外,其他成员都是 private 的。要想建立与外部世界的任何连接,interface Incrementable 都是必需的。在这里可以看到,interface 是如何允许接口与接口的实现完全独立的。 +内部类 Closure 实现了 Incrementable,以提供一个返回 Callee2 的“钩子”(hook)-而且是一个安全的钩子。无论谁获得此 Incrementable 的引用,都只能调用 increment(),除此之外没有其他功能(不像指针那样,允许你做很多事情)。 + +Caller 的构造器需要一个 Incrementable 的引用作为参数(虽然可以在任意时刻捕获回调引用),然后在以后的某个时刻,Caller 对象可以使用此引用回调 Callee 类。 + +回调的价值在于它的灵活性-可以在运行时动态地决定需要调用什么方法。这样做的好处在第 22 章可以看得更明显,在那里实现 GUI 功能的时候,到处都用到了回调。 + +### 内部类与控制框架 + +在将要介绍的控制框架(control framework)中,可以看到更多使用内部类的具体例子。 + +应用程序框架(application framework)就是被设计用以解决某类特定问题的一个类或一组类。要运用某个应用程序框架,通常是继承一个或多个类,并覆盖某些方法。在覆盖后的方法中,编写代码定制应用程序框架提供的通用解决方案,以解决你的特定问题。这是设计模式中模板方法的一个例子,模板方法包含算法的基本结构,并且会调用一个或多个可覆盖的方法,以完成算法的动作。设计模式总是将变化的事物与保持不变的事物分离开,在这个模式中,模板方法是保持不变的事物,而可覆盖的方法就是变化的事物。 + +控制框架是一类特殊的应用程序框架,它用来解决响应事件的需求。主要用来响应事件的系统被称作事件驱动系统。应用程序设计中常见的问题之一是图形用户接口(GUI),它几乎完全是事件驱动的系统。在第 22 章将会看到,Java Swing 库就是一个控制框架,它优雅地解决了 GUI 的问题,并使用了大量的内部类。 + +要理解内部类是如何允许简单的创建过程以及如何使用控制框架的,请考虑这样一个控制框架,它的工作就是在事件“就绪”的时候执行事件。虽然“就绪”可以指任何事,但在本例中是指基于时间触发的事件。接下来的问题就是,对于要控制什么,控制框架并不包含任何具体的信息。那些信息是在实现算法的 action() 部分时,通过继承来提供的。 + +首先,接口描述了要控制的事件。因为其默认的行为是基于时间去执行控制,所以使用抽象类代替实际的接口。下面的例子包含了某些实现: + +```java +// innerclasses/controller/Event.java +// The common methods for any control event +package innerclasses.controller; +import java.time.*; // Java 8 time classes +public abstract class Event { + private Instant eventTime; + protected final Duration delayTime; + public Event(long millisecondDelay) { + delayTime = Duration.ofMillis(millisecondDelay); + start(); + } + public void start() { // Allows restarting + eventTime = Instant.now().plus(delayTime); + } + public boolean ready() { + return Instant.now().isAfter(eventTime); + } + public abstract void action(); +} +``` + +当希望运行 Event 并随后调用 start() 时,那么构造器就会捕获(从对象创建的时刻开始的)时间,此时间是这样得来的:start() 获取当前时间,然后加上一个延迟时间,这样生成触发事件的时间。start() 是一个独立的方法,而没有包含在构造器内,因为这样就可以在事件运行以后重新启动计时器,也就是能够重复使用 Event 对象。例如,如果想要重复一个事件,只需简单地在 action() 中调用 start() 方法。 + +ready() 告诉你何时可以运行 action() 方法了。当然,可以在导出类中覆盖 ready() 方法,使得 Event 能够基于时间以外的其他因素而触发。 + +下面的文件包含了一个用来管理并触发事件的实际控制框架。Event 对象被保存在 List\ 类型(读作“Event 的列表”)的容器对象中,容器会在 [集合 ]() 中详细介绍。目前读者只需要知道 add() 方法用来将一个 Object 添加到 List 的尾端,size() 方法用来得到 List 中元素的个数,foreach 语法用来连续获联 List 中的 Event,remove() 方法用来从 List 中移除指定的 Event。 + +```java +// innerclasses/controller/Controller.java +// The reusable framework for control systems +package innerclasses.controller; +import java.util.*; +public class Controller { + // A class from java.util to hold Event objects: + private List eventList = new ArrayList<>(); + public void addEvent(Event c) { eventList.add(c); } + public void run() { + while(eventList.size() > 0) + // Make a copy so you're not modifying the list + // while you're selecting the elements in it: + for(Event e : new ArrayList<>(eventList)) + if(e.ready()) { + System.out.println(e); + e.action(); + eventList.remove(e); + } + } +} +``` + +run() 方法循环遍历 eventList,寻找就绪的(ready())、要运行的 Event 对象。对找到的每一个就绪的(ready())事件,使用对象的 toString() 打印其信息,调用其 action() 方法,然后从队列中移除此 Event。 + +注意,在目前的设计中你并不知道 Event 到底做了什么。这正是此设计的关键所在,"使变化的事物与不变的事物相互分离”。用我的话说,“变化向量”就是各种不同的 Event 对象所具有的不同行为,而你通过创建不同的 Event 子类来表现不同的行为。 + +这正是内部类要做的事情,内部类允许: + +1. 控制框架的完整实现是由单个的类创建的,从而使得实现的细节被封装了起来。内部类用来表示解决问题所必需的各种不同的 action()。 +2. 内部类能够很容易地访问外围类的任意成员,所以可以避免这种实现变得笨拙。如果没有这种能力,代码将变得令人讨厌,以至于你肯定会选择别的方法。 + +考虑此控制框架的一个特定实现,如控制温室的运作:控制灯光、水、温度调节器的开关,以及响铃和重新启动系统,每个行为都是完全不同的。控制框架的设计使得分离这些不同的代码变得非常容易。使用内部类,可以在单一的类里面产生对同一个基类 Event 的多种导出版本。对于温室系统的每一种行为,都继承一个新的 Event 内部类,并在要实现的 action() 中编写控制代码。 + +作为典型的应用程序框架,GreenhouseControls 类继承自 Controller: + +```java +// innerclasses/GreenhouseControls.java +// This produces a specific application of the +// control system, all in a single class. Inner +// classes allow you to encapsulate different +// functionality for each type of event. +import innerclasses.controller.*; +public class GreenhouseControls extends Controller { + private boolean light = false; + public class LightOn extends Event { + public LightOn(long delayTime) { + super(delayTime); + } + @Override + public void action() { +// Put hardware control code here to +// physically turn on the light. + light = true; + } + @Override + public String toString() { + return "Light is on"; + } + } + public class LightOff extends Event { + public LightOff(long delayTime) { + super(delayTime); + } + @Override + public void action() { +// Put hardware control code here to +// physically turn off the light. + light = false; + } + @Override + public String toString() { + return "Light is off"; + } + } + private boolean water = false; + public class WaterOn extends Event { + public WaterOn(long delayTime) { + super(delayTime); + } + @Override + public void action() { +// Put hardware control code here. + water = true; + } + @Override + public String toString() { + return "Greenhouse water is on"; + } + } + public class WaterOff extends Event { + public WaterOff(long delayTime) { + super(delayTime); + } + @Override + public void action() { +// Put hardware control code here. + water = false; + } + @Override + public String toString() { + return "Greenhouse water is off"; + } + } + private String thermostat = "Day"; + public class ThermostatNight extends Event { + public ThermostatNight(long delayTime) { + super(delayTime); + } + @Override + public void action() { +// Put hardware control code here. + thermostat = "Night"; + } + @Override + public String toString() { + return "Thermostat on night setting"; + } + } + public class ThermostatDay extends Event { + public ThermostatDay(long delayTime) { + super(delayTime); + } + @Override + public void action() { +// Put hardware control code here. + thermostat = "Day"; + } + @Override + public String toString() { + return "Thermostat on day setting"; + } + } + // An example of an action() that inserts a +// new one of itself into the event list: + public class Bell extends Event { + public Bell(long delayTime) { + super(delayTime); + } + @Override + public void action() { + addEvent(new Bell(delayTime.toMillis())); + } + @Override + public String toString() { + return "Bing!"; + } + } + public class Restart extends Event { + private Event[] eventList; + public + Restart(long delayTime, Event[] eventList) { + super(delayTime); + this.eventList = eventList; + for(Event e : eventList) + addEvent(e); + } + @Override + public void action() { + for(Event e : eventList) { + e.start(); // Rerun each event + addEvent(e); + } + start(); // Rerun this Event + addEvent(this); + } + @Override + public String toString() { + return "Restarting system"; + } + } + public static class Terminate extends Event { + public Terminate(long delayTime) { + super(delayTime); + } + @Override + public void action() { System.exit(0); } + @Override + public String toString() { + return "Terminating"; + } + } +} +``` + +注意,light,water 和 thermostat 都属于外围类 GreenhouseControls,而这些内部类能够自由地访问那些字段,无需限定条件或特殊许可。而且,action0 方法通常都涉及对某种硬件的控制。 + +大多数 Event 类看起来都很相似,但是 Bell 和 Restart 则比较特别。Bell 控制响铃,然后在事件列表中增加一个 Bell 对象,于是过一会儿它可以再次响铃。读者可能注意到了内部类是多么像多重继承:Bell 和 Restart 有 Event 的所有方法,并且似乎也拥有外围类 GreenhouseContrlos 的所有方法。 + +一个由 Event 对象组成的数组被递交给 Restart,该数组要加到控制器上。由于 Restart0 也是一个 Event 对象,所以同样可以将 Restart 对象添加到 Restart.action0 中,以使系统能够有规律地重新启动自己。 + +下面的类通过创建一个 GreenhouseControls 对象,并添加各种不同的 Event 对象来配置该系统,这是命令设计模式的一个例子在 eventList 中的每一个被封装成对象的请求: + +```java +// innerclasses/GreenhouseController.java +// Configure and execute the greenhouse system +import innerclasses.controller.*; +public class GreenhouseController { + public static void main(String[] args) { + GreenhouseControls gc = new GreenhouseControls(); +// Instead of using code, you could parse +// configuration information from a text file: + gc.addEvent(gc.new Bell(900)); + Event[] eventList = { + gc.new ThermostatNight(0), + gc.new LightOn(200), + gc.new LightOff(400), + gc.new WaterOn(600), + gc.new WaterOff(800), + gc.new ThermostatDay(1400) + }; + gc.addEvent(gc.new Restart(2000, eventList)); + gc.addEvent( + new GreenhouseControls.Terminate(5000)); + gc.run(); + } +} +``` + +输出为: + +``` +Thermostat on night setting +Light is on +Light is off +Greenhouse water is on +Greenhouse water is off +Bing! +Thermostat on day setting +Bing! +Restarting system +Thermostat on night setting +Light is on +Light is off +Greenhouse water is on +Bing! +Greenhouse water is off +Thermostat on day setting +Bing! +Restarting system +Thermostat on night setting +Light is on +Light is off +Bing! +Greenhouse water is on +Greenhouse water is off +Terminating +``` + +这个类的作用是初始化系统,所以它添加了所有相应的事件。Restart 事件反复运行,而且它每次都会将 eventList 加载到 GreenhouseControls 对象中。如果提供了命令行参数,系统会以它作为毫秒数,决定什么时候终止程序(这是测试程序时使用的)。 + +当然,更灵活的方法是避免对事件进行硬编码。 + +这个例子应该使读者更了解内部类的价值了,特别是在控制框架中使用内部类的时候。 + ## 继承内部类 +因为内部类的构造器必须连接到指向其外围类对象的引用,所以在继承内部类的时候,事情会变得有点复杂。问题在干,那个指向外围类对象的“秘密的”引用必须被初始化,而在导出类中不再存在可连接的默认对象。要解决这个问题,必须使用特殊的语法来明确说清它们之间的关联: + +```java +// innerclasses/InheritInner.java +// Inheriting an inner class +class WithInner { + class Inner {} +} +public class InheritInner extends WithInner.Inner { + //- InheritInner() {} // Won't compile + InheritInner(WithInner wi) { + wi.super(); + } + public static void main(String[] args) { + WithInner wi = new WithInner(); + InheritInner ii = new InheritInner(wi); + } +} +``` + +可以看到,InheritInner 只继承自内部类,而不是外围类。但是当要生成一个构造器时,默认的构造器并不算好,而且不能只是传递一个指向外围类对象的引用。此外,必须在构造器内使用如下语法: + +```java +enclosingClassReference.super(); +``` + +这样才提供了必要的引用,然后程序才能编译通过。 -## 重写内部类 +## 内部类可以被覆盖么? + +如果创建了一个内部类,然后继承其外围类并重新定义此内部类时,会发生什么呢?也就是说,内部类可以被覆盖吗?这看起来似乎是个很有用的思想,但是“覆盖”内部类就好像它是外围类的一个方法,其实并不起什么作用: + +```java +// innerclasses/BigEgg.java +// An inner class cannot be overridden like a method +class Egg { + private Yolk y; + protected class Yolk { + public Yolk() { + System.out.println("Egg.Yolk()"); + } + } + Egg() { + System.out.println("New Egg()"); + y = new Yolk(); + } +} +public class BigEgg extends Egg { + public class Yolk { + public Yolk() { + System.out.println("BigEgg.Yolk()"); + } + } + public static void main(String[] args) { + new BigEgg(); + } +} +``` + +输出为: + +``` +New Egg() +Egg.Yolk() +``` + +默认的无参构造器是编译器自动生成的,这里是调用基类的默认构造器。你可能认为既然创建了 BigEgg 的对象,那么所使用的应该是“覆盖后”的 Yolk 版本,但从输出中可以看到实际情况并不是这样的。 + +这个例子说明,当继承了某个外围类的时候,内部类并没有发生什么特别神奇的变化。这两个内部类是完全独立的两个实体,各自在自己的命名空间内。当然,明确地继承某个内部类也是可以的: + +```java +// innerclasses/BigEgg2.java +// Proper inheritance of an inner class +class Egg2 { + protected class Yolk { + public Yolk() { + System.out.println("Egg2.Yolk()"); + } + public void f() { + System.out.println("Egg2.Yolk.f()"); + } + } + private Yolk y = new Yolk(); + Egg2() { System.out.println("New Egg2()"); } + public void insertYolk(Yolk yy) { y = yy; } + public void g() { y.f(); } +} +public class BigEgg2 extends Egg2 { + public class Yolk extends Egg2.Yolk { + public Yolk() { + System.out.println("BigEgg2.Yolk()"); + } + @Override + public void f() { + System.out.println("BigEgg2.Yolk.f()"); + } + } + public BigEgg2() { insertYolk(new Yolk()); } + public static void main(String[] args) { + Egg2 e2 = new BigEgg2(); + e2.g(); + } +} +``` + +输出为: + +``` +Egg2.Yolk() +New Egg2() +Egg2.Yolk() +BigEgg2.Yolk() +BigEgg2.Yolk.f() +``` + +现在 BigEgg2.Yolk 通过 extends Egg2.Yolk 明确地继承了此内部类,并且覆盖了其中的方法。 + +insertYolk() 方法允许 BigEgg2 将它自己的 Yolk 对象向上转型为 Egg2 中的引用 y。所以当 g() 调用 y.f() 时,覆盖后的新版的 f() 被执行。第二次调用 Egg2.Yolk(),结果是 BigEgg2.Yolk 的构造器调用了其基类的构造器。可以看到在调用 g() 的时候,新版的 f() 被调用了。 -## 内部类局部变量 + +## 局部内部类 + +前面提到过,可以在代码块里创建内部类,典型的方式是在一个方法体的里面创建。局部内部类不能有访问说明符,因为它不是外围类的一部分;但是它可以访问当前代码块内的常量,以及此外围类的所有成员。下面的例子对局部内部类与匿名内部类的创建进行了比较。 + +```java +// innerclasses/LocalInnerClass.java +// Holds a sequence of Objects +interface Counter { + int next(); +} +public class LocalInnerClass { + private int count = 0; + Counter getCounter(final String name) { +// A local inner class: + class LocalCounter implements Counter { + LocalCounter() { +// Local inner class can have a constructor + System.out.println("LocalCounter()"); + } + @Override + public int next() { + System.out.print(name); // Access local final + return count++; + } + } + return new LocalCounter(); + } + // Repeat, but with an anonymous inner class: + Counter getCounter2(final String name) { + return new Counter() { + // Anonymous inner class cannot have a named +// constructor, only an instance initializer: + { + System.out.println("Counter()"); + } + @Override + public int next() { + System.out.print(name); // Access local final + return count++; + } + }; + } + public static void main(String[] args) { + LocalInnerClass lic = new LocalInnerClass(); + Counter + c1 = lic.getCounter("Local inner "), + c2 = lic.getCounter2("Anonymous inner "); + for(int i = 0; i < 5; i++) + System.out.println(c1.next()); + for(int i = 0; i < 5; i++) + System.out.println(c2.next()); + } +} +``` + +输出为: + +``` +LocalCounter() +Counter() +Local inner 0 +Local inner 1 +Local inner 2 +Local inner 3 +Local inner 4 +Anonymous inner 5 +Anonymous inner 6 +Anonymous inner 7 +Anonymous inner 8 +Anonymous inner 9 +``` + +Counter 返回的是序列中的下一个值。我们分别使用局部内部类和匿名内部类实现了这个功能,它们具有相同的行为和能力,既然局部内部类的名字在方法外是不可见的,那为什么我们仍然使用局部内部类而不是匿名内部类呢?唯一的理由是,我们需要一个已命名的构造器,或者需要重载构造器,而匿名内部类只能用于实例初始化。 + +所以使用局部内部类而不使用匿名内部类的另一个理由就是,需要不止一个该内部类的对象。 ## 内部类标识符 +由于每个类都会产生一个.class 文件,其中包含了如何创建该类型的对象的全部信息(此信息产生一个"meta-class",叫做 Class 对象)。 + +你可能猜到了,内部类也必须生成一个.class 文件以包含它们的 Class 对象信息。这些类文件的命名有严格的规则:外围类的名字,加上“$",再加上内部类的名字。例如,LocalInnerClass.java 生成的.class 文件包括: + +```java +Counter.class +LocalInnerClass$1.class +LocalInnerClass$1LocalCounter.class +LocalInnerClass.class +``` + +如果内部类是匿名的,编译器会简单地产生一个数字作为其标识符。如果内部类是嵌套在别的内部类之中,只需直接将它们的名字加在其外围类标识符与“$”的后面。 + +虽然这种命名格式简单而直接,但它还是很健壮的,足以应对绝大多数情况。因为这是 java 的标准命名方式,所以产生的文件自动都是平台无关的。(注意,为了保证你的内部类能起作用,Java 编译器会尽可能地转换它们。) + ## 本章小结 +比起面向对象编程中其他的概念来,接口和内部类更深奥复杂,比如 C++ 就没有这些。将两者结合起来,同样能够解决 C++ 中的用多重继承所能解决的问题。然而,多重继承在 C++ 中被证明是相当难以使用的,相比较而言,Java 的接口和内部类就容易理解多了。 + +虽然这些特性本身是相当直观的,但是就像多态机制一样,这些特性的使用应该是设计阶段考虑的问题。随着时间的推移,读者将能够更好地识别什么情况下应该使用接口,什么情况使用内部类,或者两者同时使用。但此时,读者至少应该已经完全理解了它们的语法和语义。 + +当见到这些语言特性实际应用时,就最终理解它们了。 +
\ No newline at end of file diff --git a/docs/book/13-Functional-Programming.md b/docs/book/13-Functional-Programming.md index 86d70f0..4ed2ab9 100644 --- a/docs/book/13-Functional-Programming.md +++ b/docs/book/13-Functional-Programming.md @@ -97,7 +97,7 @@ public class Strategize { ``` hello there? -HELLO THERE!' +HELLO THERE! Hello Hello there Hello there ``` @@ -370,7 +370,7 @@ Help! **[5]** `assist()` 是静态内部类中的静态方法。 -**[6]** 我们将 **Describe** 对象的方法引用赋值给 **Callable** , 它没有 `show()` 方法,而是 `call()` 方法。 但是,Java 似乎接受用这个看似奇怪的赋值,因为方法引用符合 **Callable** 的 `call()` 方法的签名。 +**[6]** 我们将 **Describe** 对象的方法引用赋值给 **Callable** ,它没有 `show()` 方法,而是 `call()` 方法。 但是,Java 似乎接受用这个看似奇怪的赋值,因为方法引用符合 **Callable** 的 `call()` 方法的签名。 **[7]** 我们现在可以通过调用 `call()` 来调用 `show()`,因为 Java 将 `call()` 映射到 `show()`。 @@ -1518,4 +1518,4 @@ Lambda 表达式和方法引用并没有将 Java 转换成函数式语言,而 -
\ No newline at end of file +
diff --git a/docs/book/14-Streams.md b/docs/book/14-Streams.md index cd64f30..24db4c6 100644 --- a/docs/book/14-Streams.md +++ b/docs/book/14-Streams.md @@ -87,7 +87,7 @@ Java 设计者面临着这样一个难题:现存的大量类库不仅为 Java 问题是,接口部分怎么改造呢?特别是涉及集合类接口的部分。如果你想把一个集合转换为流,直接向接口添加新方法会破坏所有老的接口实现类。 -Java 8 采用的解决方案是:在[接口](10-Interfaces.md)中添加被 `default`(`默认`)修饰的方法。通过这种方案,设计者们可以将流式(*stream*)方法平滑地嵌入到现有类中。流方法预置的操作几乎已满足了我们平常所有的需求。流操作的类型有三种:创建流,修改流元素(中间操作, Intermediate Operations),消费流元素(终端操作, Terminal Operations)。最后一种类型通常意味着收集流元素(通常是到集合中)。 +Java 8 采用的解决方案是:在[接口](10-Interfaces.md)中添加被 `default`(`默认`)修饰的方法。通过这种方案,设计者们可以将流式(*stream*)方法平滑地嵌入到现有类中。流方法预置的操作几乎已满足了我们平常所有的需求。流操作的类型有三种:创建流,修改流元素(中间操作, Intermediate Operations),消费流元素(终端操作, Terminal Operations)。最后一种类型通常意味着收集流元素(通常是到集合中)。 下面我们来看下每种类型的流操作。 @@ -769,7 +769,7 @@ Well WELL well it IT it s S s so SO so ### 流元素排序 -在 `Randoms.java` 中,我们熟识了 `sorted()` 的默认比较器实现。其实它还有另一种形式的实现:传入一个 **Comparator** 参数。代码示例: +在 `Randoms.java` 中,我们熟识了 `sorted()` 的默认比较器实现。其实它还有另一种形式的实现:传入一个 **Comparator** 参数。代码示例: ```java // streams/SortedComparator.java @@ -835,19 +835,19 @@ public class Prime { 467 479 487 491 499 503 509 521 523 541 ``` -`rangeClosed() ` 包含了上限值。如果不能整除,即余数不等于 0,则 `noneMatch()` 操作返回 ture,如果出现任何等于 0 的则返回 false。 `noneMatch()` 操作在第一次失败之后就会退出,而不是进行全部尝试。 +`rangeClosed()` 包含了上限值。如果不能整除,即余数不等于 0,则 `noneMatch()` 操作返回 ture,如果出现任何等于 0 的则返回 `false`。 `noneMatch()` 操作在首次失败之后就会退出,而不是尝试匹配所有。 -### 应用操作到所有元素 +### 应用函数到元素 -`map(Function) `:将 `Function` 操作应用在输入流的每一个元素中,并将返回值传递到输出流中。 +- `map(Function)`:将函数操作应用在输入流的元素中,并将返回值传递到输出流中。 -`mapToInt(ToIntFunction)`:操作同上,但结果是 `IntStream`。 +- `mapToInt(ToIntFunction)`:操作同上,但结果是 **IntStream**。 -`mapToLong(ToLongFunction)`:操作同上,但结果是 `LongStream`。 +- `mapToLong(ToLongFunction)`:操作同上,但结果是 **LongStream**。 -`mapToDouble(ToDoubleFunction)` : 操作同上,但结果是 `DoubleStream`。 +- `mapToDouble(ToDoubleFunction)`:操作同上,但结果是 **DoubleStream**。 -在这里,我们使用 `map()` 映射多种函数到一个字符串流中: +在这里,我们使用 `map()` 映射多种函数到一个字符串流中。代码示例: ```java // streams/FunctionMap.java @@ -906,13 +906,13 @@ class FunctionMap { 5 ``` -在“Increment”测试中,我们使用 `Integer.parseInt()` 去试图将一个字符串转化为整数。如果字符串不能转化成为整数就会抛出一个 `NumberFormatException` 异常,我们只需回过头来将原始字符串放回到输出流中。 +在“Increment”的测试中,我们使用 **Integer.**`parseInt()` 去试图将一个字符串转化为整数。如果字符串不能转化成为整数就会抛出一个 **NumberFormatException** 异常,我们只需回过头来将原始字符串放回到输出流中。 -在以上例子中,` map()` 将一个字符串映射为另一个字符串,但是我们完全可以产生和接收类型完全不同的类型,从而改变流的数据类型。这里是一个例子: +在以上例子中,`map()` 将一个字符串映射为另一个字符串,但是我们完全可以产生和接收类型完全不同的类型,从而改变流的数据类型。下面代码示例: ```java // streams/FunctionMap2.java -// Different input and output types +// Different input and output types (不同的输入输出类型) import java.util.*; import java.util.stream.*; class Numbered { @@ -945,13 +945,13 @@ Numbered(11) Numbered(13) ``` -我们获取了许多 `int` 类型整数,并通过构造器 `Numbered::new` 将它们转化成为 `Numbered` 类型。 +我们将获取到的整数通过构造器 `Numbered::new` 转化成为 `Numbered` 类型。 -如果使用 `Function` 产生的结果是数值类型的一种,你必须使用相似的 `mapTo`-operations 操作进行替代: +如果使用 **Function** 返回的结果是数值类型的一种,我们必须使用合适的 `mapTo数值类型` 进行替代。代码示例: ```java // streams/FunctionMap3.java -// Producing numeric output streams +// Producing numeric output streams( 产生数值输出流) import java.util.*; import java.util.stream.*; class FunctionMap3 { @@ -979,11 +979,11 @@ class FunctionMap3 { 17.000000 1.900000 0.230000 ``` -不幸的是,Java 设计者并没有尽最大的努力来消除原始类型。 +不幸的是,Java 设计者并没有尽最大努力来消除基本类型。 -### 在 map() 期间组合流 +### 在 `map()` 期间组合流 -假如你有一个即将到来的元素流,并且你打算对流元素使用 `map()` 函数。你已经找到了那些你在其他地方找不到的可爱的函数功能,但是这里有一个问题:这个函数功能产生一个流。你想要的只是产生一个元素的流,但是你生成的是一个元素流的流。 +假设你有一个传入的元素流,并且你打算对流元素使用 `map()` 函数。现在你已经找到了一些可爱并独一无二的函数功能,但是问题来了:这个函数功能是产生一个流。你想要的只是产生流的元素,但是你生成的是一个元素流的流。 `flatMap()` 做了两件事情:它获取你的流产生( stream-producing)函数,并将其应用于新到的元素(正如 `map()` 所做的),然后获取每一个流并将其“展平”为元素。所以它的输出只是元素。 @@ -1357,7 +1357,7 @@ Null - `map(Function)`:如果 **Optional** 不为空,则将 **Function** 应用于 **Optional** 的内容,并将结果返回。否则,直接返回 **Optional.empty**。 - `flatMap(Function)`:如同 `map()` , 但是提供的映射函数将结果包装在 **Optional** 对象中,因此 `flatMap()` 不会在最后进行任何包装。 -如上方法都不适用于数值型 **Optional**。普通流过滤器会在 **Predicate** 返回 false 时删除流元素。`Optional.filter()` 当 **Predicate** 失败时不会删除 **Optional**——而是把它留保留下来, 但将其转化为空: +如上方法都不适用于数值型 **Optional**。普通流过滤器会在 **Predicate** 返回 false 时删除流元素。`Optional.filter()` 当 **Predicate** 失败时不会删除 **Optional**——而是把它留保留下来,但将其转化为空: ```java // streams/OptionalFilter.java @@ -1835,11 +1835,11 @@ public class MapCollector { {688=W, 309=C, 293=B, 761=N, 858=N, 668=G, 622=F, 751=N} ``` -**Pair** 只是一个基础的数据对象。**RandomPair** 创建了随机生成的 **Pair** 对象流。如果我们能以某种方式组合两个流, 那就再好不过了, 但 Java 在这个问题上与我们斗争。所以我创建了一个整数流,并且使用 `mapToObj()` 将其转化成为 **Pair** 流。 **capChars** 随机生成的大写字母迭代器从流开始,然后 `iterator()` 方法允许我们在 `stream()` 方法中使用它。就我所知, 这是组合多个流以生成新的对象流的唯一方法。 +**Pair** 只是一个基础的数据对象。**RandomPair** 创建了随机生成的 **Pair** 对象流。如果我们能以某种方式组合两个流,那就再好不过了,但 Java 在这个问题上与我们斗争。所以我创建了一个整数流,并且使用 `mapToObj()` 将其转化成为 **Pair** 流。 **capChars** 随机生成的大写字母迭代器从流开始,然后 `iterator()` 方法允许我们在 `stream()` 方法中使用它。就我所知,这是组合多个流以生成新的对象流的唯一方法。 在这里,我们只使用最简单形式的 `Collectors.toMap()`,这个方法值需要一个可以从流中获取键值对的函数。还有其他重载形式,其中一种形式是在遇到键值冲突时,需要一个函数来处理这种情况。 -在大多数情况下,你可以在 `java.util.stream.Collectors`寻找到你想要的预先定义好的 **Collector**。在少数情况下当你找不到想要的时候,你可以使用第二种形式的 ` collect()`。 我基本上把它留作更高级的练习, 但是这里有一个例子给出了基本想法: +在大多数情况下,你可以在 `java.util.stream.Collectors`寻找到你想要的预先定义好的 **Collector**。在少数情况下当你找不到想要的时候,你可以使用第二种形式的 ` collect()`。 我基本上把它留作更高级的练习,但是这里有一个例子给出了基本想法: ```java // streams/SpecialCollector.java @@ -1933,7 +1933,7 @@ Lambda 表达式中的第一个参数 `fr0` 是上一次调用 `reduce()` 的结 - `anyMatch(Predicate)`:如果流中的一个元素根据提供的 **Predicate** 返回 true 时,结果返回为 true。这个操作将会在第一个 true 之后短路;也就是不会在发生 true 之后继续执行计算。 - `noneMatch(Predicate)`:如果流的每个元素根据提供的 **Predicate** 都返回 false 时,结果返回为 true。这个操作将会在第一个 true 之后短路;也就是不会在发生 true 之后继续执行计算。 -你已经在 `Prime.java` 中看到了 `noneMatch()` 的示例;` allMatch()` 和 `anyMatch()` 的用法基本上是等同的。让我们探究短路行为。为了创建消除冗余代码的 ` show()` 方法,我们必须首先发现如何概括地描述所有三个匹配器操作, 然后将其转换为称为 **Matcher** 的接口: +你已经在 `Prime.java` 中看到了 `noneMatch()` 的示例;` allMatch()` 和 `anyMatch()` 的用法基本上是等同的。让我们探究短路行为。为了创建消除冗余代码的 ` show()` 方法,我们必须首先发现如何概括地描述所有三个匹配器操作,然后将其转换为称为 **Matcher** 的接口: ```java // streams/Matching.java @@ -2086,7 +2086,7 @@ you - `average()` :求取流元素平均值。 - `max()` 和 `min()`:因为这些操作在数字流上面,所以不需要 **Comparator**。 - `sum()`:对所有流元素进行求和。 -- `summaryStatistics()`:生成可能有用的数据。目前还不太清楚他们为什么觉得有必要这样做, 但是你可以直接使用方法产生所有的数据。 +- `summaryStatistics()`:生成可能有用的数据。目前还不太清楚他们为什么觉得有必要这样做,但是你可以直接使用方法产生所有的数据。 ```java // streams/NumericStreamInfo.java diff --git a/docs/book/15-Exceptions.md b/docs/book/15-Exceptions.md index 564856d..16aba80 100644 --- a/docs/book/15-Exceptions.md +++ b/docs/book/15-Exceptions.md @@ -3,72 +3,2209 @@ # 第十五章 异常 +> Java 的基本理念是“结构不佳的代码不能运行”。 + +改进的错误恢复机制是提供代码健壮性的最强有力的方式。错误恢复在我们所编写的每一个程序中都是基本的要素,但是在 Java 中它显得格外重要,因为 Java 的主要目标之一就是创建供他人使用的程序构件。 + +发现错误的理想时机是在编译阶段,也就是在你试图运行程序之前。然而,编译期间并不能找出所有的错误,余下的问题必须在运行期间解决。这就需要错误源能通过某种方式,把适当的信息传递给某个接收者——该接收者将知道如何正确处理这个问题。 + +> 要想创建健壮的系统,它的每一个构件都必须是健壮的。 + +Java 使用异常来提供一致的错误报告模型,使得构件能够与客户端代码可靠地沟通问题。 + +Java 中的异常处理的目的在于通过使用少于目前数量的代码来简化大型、可靠的程序的生成,并且通过这种方式可以使你更加确信:你的应用中没有未处理的错误。异常的相关知识学起来并非艰涩难懂,并且它属于那种可以使你的项目受益明显、立竿见影的特性之一。 + +因为异常处理是 Java 中唯一官方的错误报告机制,并且通过编译器强制执行,所以不学习异常处理的话,书中也就只能写出那么些例子了。本章将向读者介绍如何编写正确的异常处理 3] 程序,并将展示当方法出问题的时候,如何产生自定义的异常。 + ## 异常概念 - + +C 以及其他早期语言常常具有多种错误处理模式,这些模式往往建立在约定俗成的基础之上,而并不属于语言的一部分。通常会返回某个特殊值或者设置某个标志,并且假定接收者将对这个返回值或标志进行检查,以判定是否发生了错误。然而,随着时间的推移,人们发现,高傲的程序员们在使用程序库的时候更倾向于认为:“对,错误也许会发生,但那是别人造成的,不关我的事”。所以,程序员不去检查错误情形也就不足为奇了(何况对某些错误情形的检查确实很无聊)。如果的确在每次调用方法的时候都彻底地进行错误检查,代码很可能会变得难以阅读。正是由于程序员还仍然用这些方式拼凑系统,所以他们拒绝承认这样一个事实:对于构造大型、健壮、可维护的程序而言,这种错误处理模式已经成为了主要障碍。 + +解决的办法是,用强制规定的形式来消除错误处理过程中随心所欲的因素。这种做法由来已久,对异常处理的实现可以迫溯到 20 世纪 60 年代的操作系统,甚至于 BASIC 语言中的“on error goto”语句。而 C++的异常处理机制基于 Ada,Java 中的异常处理则建立在 C++的基础之上(尽管看上去更像 Object Pascal)。 + +“异常”这个词有“我对此感到意外”的意思。问题出现了,你也许不清楚该如何处理,但你的确知道不应该置之不理,你要停下来,看看是不是有别人或在别的地方,能够处理这个问题。只是在当前的环境中还没有足够的信息来解决这个问题,所以就把这个问题提交到一个更高级别的环境中,在这里将作出正确的决定。 + +异常往往能降低错误处理代码的复杂度。如果不使用异常,那么就必须检查特定的错误,并在程序中的许多地方去处理它。而如果使用异常,那就不必在方法调用处进行检查,因为异常机制将保证能够捕获这个错误。并且,只需在一个地方处理错误,即所谓的异常处理程序中。这种方式不仅节省代码,而且把“描述在正常执行过程中做什么事”的代码和“出了问题怎么办”的代码相分离。总之,与以前的错误处理方法相比,异常机制使代码的阅读、编写和调试工作更加井井有条。 + ## 基本异常 - + +异常情形(exceptional condition)是指阻止当前方法或作用城继续执行的问题。把异常情形与普通问题相区分很重要,所谓的普通问题是指,在当前环境下能得到足够的信息,总能处理这个错误。而对于异常情形,就不能继续下去了,因为在当前环境下无法获得必要的信息来解决问题。你所能做的就是从当前环境跳出,并且把问题提交给上一级环境。这就是抛出异常时所发生的事情。 + +除法就是一个简单的例子。除数有可能为 0,所以先进行检查很有必要。但除数为 0 代表的究竟是什么意思呢?通过当前正在解决的问题环境,或许能知道该如何处理除数为 0 的情况。但如果这是一个意料之外的值,你也不清楚该如何处理,那就要抛出异常,而不是顺着原来的路径继续执行下去。 + +当抛出异常后,有几件事会随之发生。首先,同 Java 中其他对象的创建一样,将使用 new 在堆上创建异常对象。然后,当前的执行路径(它不能继续下去了)被终止,并且从当前环境中弹出对异常对象的引用。此时,异常处理机制接管程序,并开始寻找一个恰当的地方来继续执行程序。这个恰当的地方就是异常处理程序,它的任务是将程序从错误状态中恢复,以使程序能要么换一种方式运行,要么继续运行下去。 + +举一个抛出异常的简单例子。对于对象引用 t,传给你的时候可能尚未被初始化。所以在使用这个对象引用调用其方法之前,会先对引用进行检查。可以创建一个代表错误信息的对象,并且将它从当前环境中“抛出”,这样就把错误信息传播到了“更大”的环境中。这被称为抛出一个异常,看起来像这样: + +```java +if(t == null) + throw new NullPointerException(); +``` + +这就抛出了异常,于是在当前环境下就不必再为这个问题操心了,它将在别的地方得到处理。具体是哪个“地方”后面很快就会介绍。 + +异常使得我们可以将每件事都当作一个事务来考虑,而异常可以看护着这些事务的底线“…事务的基本保障是我们所需的在分布式计算中的异常处理。事务是计算机中的合同法,如果出了什么问题,我们只需要放弃整个计算。”我们还可以将异常看作是一种内建的恢复(undo)系统,因为(在细心使用的情况下)我们在程序中可以拥有各种不同的恢复点。如果程序的某部分失败了,异常将“恢复”到程序中某个已知的稳定点上。 + +异常最重要的方面之一就是如果发生问题,它们将不允许程序沿着其正常的路径继续走下去。在 C 和 C++这样的语言中,这可真是个问题,尤其是 C,它没有任何办法可以强制程序在出现问题时停止在某条路径上运行下去,因此我们有可能会较长时间地忽略了问题,从而陷入了完全不恰当的状态中。异常允许我们(如果没有其他手段)强制程序停止运行,并告诉我们出现了什么问题,或者(理想状态下)强制程序处理问题,并返回到稳定状态。 + +### 异常参数 + +与使用 Java 中的其他对象一样,我们总是用 new 在堆上创建异常对象,这也伴随着存储空间的分配和构造器的调用。所有标准异常类都有两个构造器:一个是默认构造器;另一个是接受字符串作为参数,以便能把相关信息放入异常对象的构造器: + +```java +throw new NullPointerException("t = null"); +``` + +不久读者将看到,要把这个字符串的内容提取出来可以有多种不同的方法。 + +关键字 throw 将产生许多有趣的结果。在使用 new 创建了异常对象之后,此对象的引用将传给 throw。尽管返回的异常对象其类型通常与方法设计的返回类型不同,但从效果上看,它就像是从方法“返回”的。可以简单地把异常处理看成一种不同的返回机制,当然若过分强调这种类比的话,就会有麻烦了。另外还能用抛出异常的方式从当前的作用域退出。在这两种情况下,将会返回一个异常对象,然后退出方法或作用域。 + +抛出异常与方法正常返回值的相似之处到此为止。因为异常返回的“地点”与普通方法调用返回的“地点”完全不同。(异常将在一个恰当的异常处理程序中得到解决,它的位置可能离异常被抛出的地方很远,也可能会跨越方法调用栈的许多层次。)此外,能够抛出任意类型的 Throwable 对象,它是异常类型的根类。通常,对于不同类型的错误,要抛出相应的异常。错误信息可以保存在异常对象内部或者用异常类的名称来暗示。上一层环境通过这些信息来决定如何处理异常。(通常,异常对象中仅有的信息就是异常类型,除此之外不包含任何有意义的内容。) + ## 异常捕获 +要明白异常是如何被捕获的,必须首先理解监控区域(guarded region)的概念。它是一段可能产生异常的代码,并且后面跟着处理这些异常的代码。 + +### try 语句块 + +如果在方法内部抛出了异常(或者在方法内部调用的其他方法抛出了异常),这个方法将在抛出异常的过程中结束。要是不希望方法就此结束,可以在方法内设置一个特殊的块来捕获异常。因为在这个块里“尝试”各种(可能产生异常的)方法调用,所以称为 try 块。它是跟在 try 关键字之后的普通程序块: + +```java +try { + // Code that might generate exceptions +} +``` + +对于不支持异常处理的程序语言,要想仔细检查错误,就得在每个方法调用的前后加上设置和错误检查的代码,甚至在每次调用同一方法时也得这么做。有了异常处理机制,可以把所有动作都放在 ry 块里,然后只需在一个地方就可以捕获所有异常。这意味着代码将更容易编写和阅读,因为完成任务的代码没有与错误检查的代码混在一起。 + +### 异常处理程序 + +当然,抛出的异常必须在某处得到处理。这个“地点”就是异常处理程序,而且针对每个要捕获的异常,得准备相应的处理程序。异常处理程序紧跟在 try 块之后,以关键字 catch 表示: + +```java +try { + // Code that might generate exceptions +} catch(Type1 id1) { + // Handle exceptions of Type1 +} catch(Type2 id2) { + // Handle exceptions of Type2 +} catch(Type3 id3) { + // Handle exceptions of Type3 +} +// etc. +``` + +每个 catch 子句(异常处理程序)看起来就像是接收一个且仅接收一个特殊类型的参数的方法。可以在处理程序的内部使用标识符(id1,id2 等等),这与方法参数的使用很相似。有时可能用不到标识符,因为异常的类型已经给了你足够的信息来对异常进行处理,但标识符并不可以省略。 + +异常处理程序必须紧跟在 try 块之后。当异常被抛出时,异常处理机制将负责搜寻参数与异常类型相匹配的第一个处理程序。然后进入 catch 子句执行,此时认为异常得到了处理。一旦 catch 子句结束,则处理程序的查找过程结束。注意,只有匹配的 catch 子句才能得到执行;这与 switch 语句不同,switch 语句需要在每一个 case 后面跟一个 break,以避免执行后续的 case 子句。 + +注意在 try 块的内部,许多不同的方法调用可能会产生类型相同的异常,而你只需要提供一个针对此类型的异常处理程序。 + +### 终止与恢复 + +异常处理理论上有两种基本模型。Java 支持终止模型(它是 Java 和 C++所支持的模型)。在这种模型中,将假设错误非常关键,以至于程序无法返回到异常发生的地方继续执行。一旦异常被抛出,就表明错误已无法挽回,也不能回来继续执行。 + +另一种称为恢复模型。意思是异常处理程序的工作是修正错误,然后重新尝试调用出问题的方法,并认为第二次能成功。对于恢复模型,通常希望异常被处理之后能继续执行程序。如果想要用 Java 实现类似恢复的行为,那么在遇见错误时就不能抛出异常,而是调用方法来修正该错误。或者,把 try 块放在 while 循环里,这样就不断地进入 try 块,直到得到满意的结果。 + +长久以来,尽管程序员们使用的操作系统支持恢复模型的异常处理,但他们最终还是转向使用类似“终止模型”的代码,并且忽略恢复行为。所以虽然恢复模型开始显得很吸引人,但不是很实用。其中的主要原因可能是它所导致的耦合:恢复性的处理程序需要了解异常抛出的地点,这势必要包含依赖于抛出位置的非通用性代码。这增加了代码编写和维护的困难,对于异常可能会从许多地方抛出的大型程序来说,更是如此。 + ## 自定义异常 +不必拘泥于 Java 中已有的异常类型。Java 提供的异常体系不可能预见所有的希望加以报告的错误,所以可以自己定义异常类来表示程序中可能会遇到的特定问题。 - -## 异常规范 +要自己定义异常类,必须从已有的异常类继承,最好是选择意思相近的异常类继承(不过这样的异常并不容易找)。建立新的异常类型最简单的方法就是让编译器为你产生默认构造器,所以这几乎不用写多少代码: +```java +// exceptions/InheritingExceptions.java +// Creating your own exceptions +class SimpleException extends Exception {} - -## 任意异常捕获 +public class InheritingExceptions { + public void f() throws SimpleException { + System.out.println( + "Throw SimpleException from f()"); + throw new SimpleException(); + } + public static void main(String[] args) { + InheritingExceptions sed = + new InheritingExceptions(); + try { + sed.f(); + } catch(SimpleException e) { + System.out.println("Caught it!"); + } + } +} +``` +输出为: + +``` +Throw SimpleException from f() +Caught it! +``` + +编译器创建了默认构造器,它将自动调用基类的默认构造器。本例中不会得到像 SimpleException(String) 这样的构造器,这种构造器也不实用。你将看到,对异常来说,最重要的部分就是类名,所以本例中建立的异常类在大多数情况下已经够用了。 + +本例的结果被打印到了控制台上,本书的输出显示系统正是在控制台上自动地捕获和测试这些结果的。但是,你也许想通过写入 System.err 而将错误发送给标准错误流。通常这比把错误信息输出到 System.out 要好,因为 System.out 也许会被重定向。如果把结果送到 System.err,它就不会随 System.out 一起被重定向,这样更容易被用户注意。 + +你也可以为异常类创建一个接受字符串参数的构造器: + +```java +// exceptions/FullConstructors.java +class MyException extends Exception { + MyException() {} + MyException(String msg) { super(msg); } +} +public class FullConstructors { + public static void f() throws MyException { + System.out.println("Throwing MyException from f()"); + throw new MyException(); + } + public static void g() throws MyException { + System.out.println("Throwing MyException from g()"); + throw new MyException("Originated in g()"); + } + public static void main(String[] args) { + try { + f(); + } catch(MyException e) { + e.printStackTrace(System.out); + } + try { + g(); + } catch(MyException e) { + e.printStackTrace(System.out); + } + } +} +``` + +输出为: + +``` +Throwing MyException from f() +MyException + at FullConstructors.f(FullConstructors.java:11) + at +FullConstructors.main(FullConstructors.java:19) +Throwing MyException from g() +MyException: Originated in g() + at FullConstructors.g(FullConstructors.java:15) + at +FullConstructors.main(FullConstructors.java:24) +``` + +新增的代码非常简短:两个构造器定义了 MyException 类型对象的创建方式。对于第二个构造器,使用 super 关键宇明确调用了其基类构造器,它接受一个字符串作为参数。 + +在异常处理程序中,调用了在 Throwable 类声明(Exception 即从此类继承)的 printStackTrace0 方法。就像从输出中看到的,它将打印“从方法调用处直到异常抛出处”的方法调用序列。这里,信息被发送到了 System.out,并自动地被捕获和显示在输出中。但是,如果调用默认版本: + +```java +e.printStackTrace(); +``` + +信息就会被输出到标准错误流。 + +### 异常与记录日志 + +你可能还想使用 java.util.logging 工具将输出记录到日志中。基本的日志记录功能还是相当简单易懂的: + +```java +// exceptions/LoggingExceptions.java +// An exception that reports through a Logger +// {ErrorOutputExpected} +import java.util.logging.*; +import java.io.*; +class LoggingException extends Exception { + private static Logger logger = + Logger.getLogger("LoggingException"); + LoggingException() { + StringWriter trace = new StringWriter(); + printStackTrace(new PrintWriter(trace)); + logger.severe(trace.toString()); + } +} +public class LoggingExceptions { + public static void main(String[] args) { + try { + throw new LoggingException(); + } catch(LoggingException e) { + System.err.println("Caught " + e); + } + try { + throw new LoggingException(); + } catch(LoggingException e) { + System.err.println("Caught " + e); + } + } +} +``` + +输出为: + +``` +___[ Error Output ]___ +May 09, 2017 6:07:17 AM LoggingException +SEVERE: LoggingException +at +LoggingExceptions.main(LoggingExceptions.java:20) +Caught LoggingException +May 09, 2017 6:07:17 AM LoggingException +SEVERE: LoggingException +at +LoggingExceptions.main(LoggingExceptions.java:25) +Caught LoggingException +``` + +静态的 Logger.getLogger() 方法创建了一个 String 参数相关联的 Logger 对象(通常与错误相关的包名和类名),这个 Logger 对象会将其输出发送到 System.err。向 Logger 写人的最简单方式就是直接调用与日志记录消息的级别相关联的方法,这里使用的是 severe()。为了产生日志记录消息,我们欲获取异常抛出处的栈轨迹,但是 printStackTrace() 不会默认地产生字符串。为了获取字符串,我们需要使用重载的 printStackTrace() 方法,它接受一 java.io.PrintWriter 对象作为参数(PrintWriter 会在[附录:I/O 流 ]() 一章详细介绍)。如果我们将一个 java.io.StringWriter 对象传递给这个 PrintWriter 的构造器,那么通过调用 toString() 方法,就可以将输出抽取为一个 String。 + +尽管由于 LoggingException 将所有记录日志的基础设施都构建在异常自身中,使得它所使用的方式非常方便,并因此不需要客户端程序员的干预就可以自动运行,但是更常见的情形是我们需要捕获和记录其他人编写的异常,因此我们必须在异常处理程序中生成日志消息; + +```java +// exceptions/LoggingExceptions2.java +// Logging caught exceptions +// {ErrorOutputExpected} +import java.util.logging.*; +import java.io.*; +public class LoggingExceptions2 { + private static Logger logger = + Logger.getLogger("LoggingExceptions2"); + static void logException(Exception e) { + StringWriter trace = new StringWriter(); + e.printStackTrace(new PrintWriter(trace)); + logger.severe(trace.toString()); + } + public static void main(String[] args) { + try { + throw new NullPointerException(); + } catch(NullPointerException e) { + logException(e); + } + } +} +``` + +输出结果为: + +``` +___[ Error Output ]___ +May 09, 2017 6:07:17 AM LoggingExceptions2 logException +SEVERE: java.lang.NullPointerException +at +LoggingExceptions2.main(LoggingExceptions2.java:17) +``` + +还可以更进一步自定义异常,比如加入额外的构造器和成员: + +```java +// exceptions/ExtraFeatures.java +// Further embellishment of exception classes +class MyException2 extends Exception { + private int x; + MyException2() {} + MyException2(String msg) { super(msg); } + MyException2(String msg, int x) { + super(msg); + this.x = x; + } + public int val() { return x; } + @Override + public String getMessage() { + return "Detail Message: "+ x + + " "+ super.getMessage(); + } +} +public class ExtraFeatures { + public static void f() throws MyException2 { + System.out.println( + "Throwing MyException2 from f()"); + throw new MyException2(); + } + public static void g() throws MyException2 { + System.out.println( + "Throwing MyException2 from g()"); + throw new MyException2("Originated in g()"); + } + public static void h() throws MyException2 { + System.out.println( + "Throwing MyException2 from h()"); + throw new MyException2("Originated in h()", 47); + } + public static void main(String[] args) { + try { + f(); + } catch(MyException2 e) { + e.printStackTrace(System.out); + } + try { + g(); + } catch(MyException2 e) { + e.printStackTrace(System.out); + } + try { + h(); + } catch(MyException2 e) { + e.printStackTrace(System.out); + System.out.println("e.val() = " + e.val()); + } + } +} +``` + +输出为: + +``` +Throwing MyException2 from f() +MyException2: Detail Message: 0 null +at ExtraFeatures.f(ExtraFeatures.java:24) +at ExtraFeatures.main(ExtraFeatures.java:38) +Throwing MyException2 from g() +MyException2: Detail Message: 0 Originated in g() +at ExtraFeatures.g(ExtraFeatures.java:29) +at ExtraFeatures.main(ExtraFeatures.java:43) +Throwing MyException2 from h() +MyException2: Detail Message: 47 Originated in h() +at ExtraFeatures.h(ExtraFeatures.java:34) +at ExtraFeatures.main(ExtraFeatures.java:48) +e.val() = 47 +``` + +新的异常添加了字段 x 以及设定 x 值的构造器和读取数据的方法。此外,还覆盖了 Throwable. +getMessage() 方法,以产生更详细的信息。对于异常类来说,getMessage() 方法有点类似于 toString() 方法。 + +既然异常也是对象的一种,所以可以继续修改这个异常类,以得到更强的功能。但要记住,使用程序包的客户端程序员可能仅仅只是查看一下抛出的异常类型,其他的就不管了(大多数 Java 库里的异常都是这么用的),所以对异常所添加的其他功能也许根本用不上。 + +## 异常声明 + +Java 鼓励人们把方法可能会抛出的异常告知使用此方法的客户端程序员。这是种优雅的做法,它使得调用者能确切知道写什么样的代码可以捕获所有潜在的异常。当然,如果提供了源代码,客户端程序员可以在源代码中查找 throw 语句来获知相关信息,然而程序库通常并不与源代码一起发布。为了预防这样的问题,Java 提供了相应的语法(并强制使用这个语法),使你能以礼貌的方式告知客户端程序员某个方法可能会抛出的异常类型,然后客户端程序员就可以进行相应的处理。这就是异常说明,它属于方法声明的一部分,紧跟在形式参数列表之后。 + +异常说明使用了附加的关键字 throws,后面接一个所有潜在异常类型的列表,所以方法定义可能看起来像这样: + +```java +void f() throws TooBig, TooSmall, DivZero { // ... +``` + +但是,要是这样写: + +```java +void f() { // ... +``` + +就表示此方法不会抛出任何异常(除了从 RuntimeException 继承的异常,它们可以在没有异常说明的情况下被抛出,这些将在后面进行讨论)。 + +代码必须与异常说明保持一致。如果方法里的代码产生了异常却没有进行处理,编译器会发现这个问题并提醒你:要么处理这个异常,要么就在异常说明中表明此方法将产生异常。通过这种自顶向下强制执行的异常说明机制,Java 在编译时就可以保证一定水平的异常正确性。 + +不过还是有个能“作弊”的地方:可以声明方法将抛出异常,实际上却不抛出。编译器相信了这个声明,并强制此方法的用户像真的抛出异常那样使用这个方法。这样做的好处是,为异常先占个位子,以后就可以抛出这种异常而不用修改已有的代码。在定义抽象基类和接口时这种能力很重要,这样派生类或接口实现就能够抛出这些预先声明的异常。 + +这种在编译时被强制检查的异常称为被检查的异常。 + +## 任意所有捕获 + +可以只写一个异常处理程序来捕获所有类型的异常。通过捕获异常类型的基类 Exception,就可以做到这一点(事实上还有其他的基类,但 Exception 是所有编程行为相关的基类): + +```java +catch(Exception e) { + System.out.println("Caught an exception"); +} +``` + +这将捕获所有异常,所以最好把它放在处理程序列表的末尾,以防它抢在其他处理程序之前先把异常捕获了。 + +因为 Exception 是与编程有关的所有异常类的基类,所以它不会含有太多具体的信息,不过可以调用它从其基类 Throwable 继承的方法: + +```java +String getMessage() +String getLocalizedMessage() +``` + +用来获取详细信息,或用本地语言表示的详细信息。 + +```java +String toString() +``` + +返回对 Throwable 的简单描述,要是有详细信息的话,也会把它包含在内。 + +```java +void printStackTrace() +void printStackTrace(PrintStream) +void printStackTrace(java.io.PrintWriter) +``` + +打印 Throwable 和 Throwable 的调用栈轨迹。调用栈显示了“把你带到异常抛出地点”的方法调用序列。其中第一个版本输出到标准错误,后两个版本允许选择要输出的流(在[附录 I/O 流 ]() 中,你将会理解为什么有两种不同的流)。 + +```java +Throwable fillInStackTrace() +``` + +用于在 Throwable 对象的内部记录栈帧的当前状态。这在程序重新抛出错误或异常(很快就会讲到)时很有用。 + +此外,也可以使用 Throwable 从其基类 Object(也是所有类的基类)继承的方法。对于异常来说,getClass)也许是个很好用的方法,它将返回一个表示此对象类型的对象。然后可以使用 getName)方法查询这个 Class 对象包含包信息的名称,或者使用只产生类名称的 getSimple Name0 方法。 + +下面的例子演示了如何使用 Exception 类型的方法: + +```java +// exceptions/ExceptionMethods.java +// Demonstrating the Exception Methods +public class ExceptionMethods { + public static void main(String[] args) { + try { + throw new Exception("My Exception"); + } catch(Exception e) { + System.out.println("Caught Exception"); + System.out.println( + "getMessage():" + e.getMessage()); + System.out.println("getLocalizedMessage():" + + e.getLocalizedMessage()); + System.out.println("toString():" + e); + System.out.println("printStackTrace():"); + e.printStackTrace(System.out); + } + } +} +``` + +输出为: + +```java +Caught Exception +getMessage():My Exception +getLocalizedMessage():My Exception +toString():java.lang.Exception: My Exception +printStackTrace(): +java.lang.Exception: My Exception +at +ExceptionMethods.main(ExceptionMethods.java:7) +``` + +可以发现每个方法都比前一个提供了更多的信息一一实际上它们每一个都是前一个的超集。 + +### 多重捕获 + +如果有一组具有相同基类的异常,你想使用同一方式进行捕获,那你直接 catch 它们的基类型。但是,如果这些异常没有共同的基类型,在 Java 7 之前,你必须为每一个类型编写一个 catch: + +```java +// exceptions/SameHandler.java +class EBase1 extends Exception {} +class Except1 extends EBase1 {} +class EBase2 extends Exception {} +class Except2 extends EBase2 {} +class EBase3 extends Exception {} +class Except3 extends EBase3 {} +class EBase4 extends Exception {} +class Except4 extends EBase4 {} + +public class SameHandler { + void x() throws Except1, Except2, Except3, Except4 {} + void process() {} + void f() { + try { + x(); + } catch(Except1 e) { + process(); + } catch(Except2 e) { + process(); + } catch(Except3 e) { + process(); + } catch(Except4 e) { + process(); + } + } +} +``` + +通过 Java 7 的多重捕获机制,你可以讲不同类型的异常使用“或”将它们组合起来,只在一个 catch 块中使用: + +```java +// exceptions/MultiCatch.java +public class MultiCatch { + void x() throws Except1, Except2, Except3, Except4 {} + void process() {} + void f() { + try { + x(); + } catch(Except1 | Except2 | Except3 | Except4 e) { + process(); + } + } +} +``` + +或者以其他组合的方式: + +```java +// exceptions/MultiCatch2.java +public class MultiCatch2 { + void x() throws Except1, Except2, Except3, Except4 {} + void process1() {} + void process2() {} + void f() { + try { + x(); + } catch(Except1 | Except2 e) { + process1(); + } catch(Except3 | Except4 e) { + process2(); + } + } +} +``` + +这对书写更整洁的代码很有帮助 + +### 栈轨迹 + +printStackTrace() 方法所提供的信息可以通过 getStackTrace() 方法来直接访问,这个方法将返回一个由栈轨迹中的元素所构成的数组,其中每一个元素都表示栈中的一桢。元素 0 是栈顶元素,并且是调用序列中的最后一个方法调用(这个 Throwable 被创建和抛出之处)。数组中的最后一个元素和栈底是调用序列中的第一个方法调用。下面的程序是一个简单的演示示例: + +```java +// exceptions/WhoCalled.java +// Programmatic access to stack trace information +public class WhoCalled { + static void f() { +// Generate an exception to fill in the stack trace + try { + throw new Exception(); + } catch(Exception e) { + for(StackTraceElement ste : e.getStackTrace()) + System.out.println(ste.getMethodName()); + } + } + static void g() { f(); } + static void h() { g(); } + public static void main(String[] args) { + f(); + System.out.println("*******"); + g(); + System.out.println("*******"); + h(); + } +} +``` + +输出为: + +``` +f +main +******* +f +g +main +******* +f +g +h +main +``` + +这里,我们只打印了方法名,但实际上还可以打印整个 StackTraceElement,它包含其他附加的信息。 + +### 重新抛出异常 + +有时希望把刚捕获的异常重新抛出,尤其是在使用 Exception 捕获所有异常的时候。既然已经得到了对当前异常对象的引用,可以直接把它重新抛出: + +```java +catch(Exception e) { + System.out.println("An exception was thrown"); + throw e; +} +``` + +重抛异常会把异常抛给上一级环境中的异常处理程序,同一个 try 块的后续 catch 子句将被忽略。此外,异常对象的所有信息都得以保持,所以高一级环境中捕获此异常的处理程序可以从这个异常对象中得到所有信息。 + +如果只是把当前异常对象重新抛出,那么 printStackTrace() 方法显示的将是原来异常抛出点的调用栈信息,而并非重新抛出点的信息。要想更新这个信息,可以调用 filInStackTrace() 方法,这将返回一个 Throwable 对象,它是通过把当前调用栈信息填入原来那个异常对象而建立的,就像这样: + +```java +// exceptions/Rethrowing.java +// Demonstrating fillInStackTrace() +public class Rethrowing { + public static void f() throws Exception { + System.out.println( + "originating the exception in f()"); + throw new Exception("thrown from f()"); + } + public static void g() throws Exception { + try { + f(); + } catch(Exception e) { + System.out.println( + "Inside g(), e.printStackTrace()"); + e.printStackTrace(System.out); + throw e; + } + } + public static void h() throws Exception { + try { + f(); + } catch(Exception e) { + System.out.println( + "Inside h(), e.printStackTrace()"); + e.printStackTrace(System.out); + throw (Exception)e.fillInStackTrace(); + } + } + public static void main(String[] args) { + try { + g(); + } catch(Exception e) { + System.out.println("main: printStackTrace()"); + e.printStackTrace(System.out); + } + try { + h(); + } catch(Exception e) { + System.out.println("main: printStackTrace()"); + e.printStackTrace(System.out); + } + } +} +``` + +输出为: + +```java +originating the exception in f() +Inside g(), e.printStackTrace() +java.lang.Exception: thrown from f() +at Rethrowing.f(Rethrowing.java:8) +at Rethrowing.g(Rethrowing.java:12) +at Rethrowing.main(Rethrowing.java:32) +main: printStackTrace() +java.lang.Exception: thrown from f() +at Rethrowing.f(Rethrowing.java:8) +at Rethrowing.g(Rethrowing.java:12) +at Rethrowing.main(Rethrowing.java:32) +originating the exception in f() +Inside h(), e.printStackTrace() +java.lang.Exception: thrown from f() +at Rethrowing.f(Rethrowing.java:8) +at Rethrowing.h(Rethrowing.java:22) +at Rethrowing.main(Rethrowing.java:38) +main: printStackTrace() +java.lang.Exception: thrown from f() +at Rethrowing.h(Rethrowing.java:27) +at Rethrowing.main(Rethrowing.java:38) +``` + +调用 fillInStackTrace() 的那一行就成了异常的新发生地了。 + +有可能在捕获异常之后抛出另一种异常。这么做的话,得到的效果类似于使用 filInStackTrace(),有关原来异常发生点的信息会丢失,剩下的是与新的抛出点有关的信息: + +```java +// exceptions/RethrowNew.java +// Rethrow a different object from the one you caught +class OneException extends Exception { + OneException(String s) { super(s); } +} +class TwoException extends Exception { + TwoException(String s) { super(s); } +} +public class RethrowNew { + public static void f() throws OneException { + System.out.println( + "originating the exception in f()"); + throw new OneException("thrown from f()"); + } + public static void main(String[] args) { + try { + try { + f(); + } catch(OneException e) { + System.out.println( + "Caught in inner try, e.printStackTrace()"); + e.printStackTrace(System.out); + throw new TwoException("from inner try"); + } + } catch(TwoException e) { + System.out.println( + "Caught in outer try, e.printStackTrace()"); + e.printStackTrace(System.out); + } + } +} +``` + +输出为: + +```java +originating the exception in f() +Caught in inner try, e.printStackTrace() +OneException: thrown from f() +at RethrowNew.f(RethrowNew.java:16) +at RethrowNew.main(RethrowNew.java:21) +Caught in outer try, e.printStackTrace() +TwoException: from inner try +at RethrowNew.main(RethrowNew.java:26) +``` + +最后那个异常仅知道自己来自 main(),而对 f() 一无所知。 + +永远不必为清理前一个异常对象而担心,或者说为异常对象的清理而担心。它们都是用 new 在堆上创建的对象,所以垃圾回收器会自动把它们清理掉。 + +### 精准的重新抛出异常 + +在 Java 7 之前,如果遇到异常,则只能重新抛出该类型的异常。这导致在 Java 7 中修复的代码不精确。所以在 Java 7 之前,这无法编译: + +```java +class BaseException extends Exception {} +class DerivedException extends BaseException {} + +public class PreciseRethrow { + void catcher() throws DerivedException { + try { + throw new DerivedException(); + } catch(BaseException e) { + throw e; + } + } +} +``` + +因为 catch 捕获了一个 BaseException,编译器强迫你声明 catcher() 抛出 BaseException,即使它实际上抛出了更具体的 DerivedException。从 Java 7 开始,这段代码就可以编译,这是一个很小但很有用的修复。 + +### 异常链 + +常常会想要在捕获一个异常后抛出另一个异常,并且希望把原始异常的信息保存下来,这被称为异常链。在 JDK1.4 以前,程序员必须自己编写代码来保存原始异常的信息。现在所有 Throwable 的子类在构造器中都可以接受一个 cause(因由)对象作为参数。这个 cause 就用来表示原始异常,这样通过把原始异常传递给新的异常,使得即使在当前位置创建并抛出了新的异常,也能通过这个异常链追踪到异常最初发生的位置。 + +有趣的是,在 Throwable 的子类中,只有三种基本的异常类提供了带 cause 参数的构造器。它们是 Error(用于 Java 虚拟机报告系统错误)、Exception 以及 RuntimeException。如果要把其他类型的异常链接起来,应该使用 initCause0 方法而不是构造器。 + +下面的例子能让你在运行时动态地向 DymamicFields 对象添加字段: + +```java +// exceptions/DynamicFields.java +// A Class that dynamically adds fields to itself to +// demonstrate exception chaining +class DynamicFieldsException extends Exception {} +public class DynamicFields { + private Object[][] fields; + public DynamicFields(int initialSize) { + fields = new Object[initialSize][2]; + for(int i = 0; i < initialSize; i++) + fields[i] = new Object[] { null, null }; + } + @Override + public String toString() { + StringBuilder result = new StringBuilder(); + for(Object[] obj : fields) { + result.append(obj[0]); + result.append(": "); + result.append(obj[1]); + result.append("\n"); + } + return result.toString(); + } + private int hasField(String id) { + for(int i = 0; i < fields.length; i++) + if(id.equals(fields[i][0])) + return i; + return -1; + } + private int getFieldNumber(String id) + throws NoSuchFieldException { + int fieldNum = hasField(id); + if(fieldNum == -1) + throw new NoSuchFieldException(); + return fieldNum; + } + private int makeField(String id) { + for(int i = 0; i < fields.length; i++) + if(fields[i][0] == null) { + fields[i][0] = id; + return i; + } +// No empty fields. Add one: + Object[][] tmp = new Object[fields.length + 1][2]; + for(int i = 0; i < fields.length; i++) + tmp[i] = fields[i]; + for(int i = fields.length; i < tmp.length; i++) + tmp[i] = new Object[] { null, null }; + fields = tmp; +// Recursive call with expanded fields: + return makeField(id); + } + public Object + getField(String id) throws NoSuchFieldException { + return fields[getFieldNumber(id)][1]; + } + public Object setField(String id, Object value) + throws DynamicFieldsException { + if(value == null) { +// Most exceptions don't have a "cause" +// constructor. In these cases you must use +// initCause(), available in all +// Throwable subclasses. + DynamicFieldsException dfe = + new DynamicFieldsException(); + dfe.initCause(new NullPointerException()); + throw dfe; + } + int fieldNumber = hasField(id); + if(fieldNumber == -1) + fieldNumber = makeField(id); + Object result = null; + try { + result = getField(id); // Get old value + } catch(NoSuchFieldException e) { +// Use constructor that takes "cause": + throw new RuntimeException(e); + } + fields[fieldNumber][1] = value; + return result; + } + public static void main(String[] args) { + DynamicFields df = new DynamicFields(3); + System.out.println(df); + try { + df.setField("d", "A value for d"); + df.setField("number", 47); + df.setField("number2", 48); + System.out.println(df); + df.setField("d", "A new value for d"); + df.setField("number3", 11); + System.out.println("df: " + df); + System.out.println("df.getField(\"d\") : " + + df.getField("d")); + Object field = + df.setField("d", null); // Exception + } catch(NoSuchFieldException | + DynamicFieldsException e) { + e.printStackTrace(System.out); + } + } +} +``` + +输出为: + +```java +null: null +null: null +null: null +d: A value for d +number: 47 +number2: 48 +df: d: A new value for d +number: 47 +number2: 48 +number3: 11 +df.getField("d") : A new value for d +DynamicFieldsException +at +DynamicFields.setField(DynamicFields.java:65) +at DynamicFields.main(DynamicFields.java:97) +Caused by: java.lang.NullPointerException +at +DynamicFields.setField(DynamicFields.java:67) +... 1 more +``` + +每个 DynamicFields 对象都含有一个数组,其元素是“成对的对象”。第一个对象表示字段标识符(一个字符串),第二个表示字段值,值的类型可以是除基本类型外的任意类型。当创建对象的时候,要合理估计一下需要多少字段。当调用 setField() 方法的时候,它将试图通过标识修改已有字段值,否则就建一个新的字段,并把值放入。如果空间不够了,将建立一个更长的数组,并把原来数组的元素复制进去。如果你试图为字段设置一个空值,将抛出一个 DynamicFieldsException 异常,它是通过使用 initCause() 方法把 NullPointerException 对象插入而建立的。 + +至于返回值,setField() 将用 getField() 方法把此位置的旧值取出,这个操作可能会抛出 NoSuchFieldException 异常。如果客户端程序员调用了 getField() 方法,那么他就有责任处理这个可能抛出的 NoSuchFieldException 异常,但如果异常是从 setField0 方法里抛出的,这种情况将被视为编程错误,所以就使用接受 cause 参数的构造器把 NoSuchFieldException 异常转换为 RuntimeException 异常。 + +你会注意到,toString0 方法使用了一个 StringBuilder 来创建其结果。在[字符串 ]() 这章中你将会了解到更多的关于 StringBuilder 的知识,但是只要你编写设计循环的 toString() 方法,通常都会想使用它,就像本例一样。 + +主方法中的 catch 子句看起来不同 - 它使用相同的子句处理两种不同类型的异常,并结合“或(|)”符号。此 Java 7 功能有助于减少代码重复,并使你更容易指定要捕获的确切类型,而不是简单地捕获基本类型。你可以通过这种方式组合多种异常类型。 -## Java标准异常 +## Java 标准异常 + +Throwable 这个 Java 类被用来表示任何可以作为异常被抛出的类。Throwable 对象可分为两种类型(指从 Throwable 继承而得到的类型):Error 用来表示编译时和系统错误(除特殊情况外,一般不用你关心);Exception 是可以被抛出的基本类型,在 Java 类库、用户方法以及运行时故障中都可能抛出 Exception 型异常。所以 Java 程序员关心的基类型通常是 Exception。要想对异常有全面的了解,最好去浏览一下 HTML 格式的 Java 文档(可以从 java.sun.com 下载)。为了对不同的异常有个感性的认识,这么做是值得的。但很快你就会发现,这些异常除了名称外其实都差不多。同时,Java 中异常的数目在持续增加,所以在书中简单罗列它们毫无意义。所使用的第三方类库也可能会有自己的异常。对异常来说,关键是理解概念以及如何使用。 + +异常的基本的概念是用名称代表发生的问题,并且异常的名称应该可以望文知意。异常并非全是在 java.lang 包里定义的;有些异常是用来支持其他像 util、net 和 io 这样的程序包,这些异常可以通过它们的完整名称或者从它们的父类中看出端倪。比如,所有的输入/输出异常都是从 java.io.IOException 继承而来的。 + +### 特例:RuntimeException + +在本章的第一个例子中: + +```java +if(t == null) + throw new NullPointerException(); +``` + +如果必须对传递给方法的每个引用都检查其是否为 nul(因为无法确定调用者是否传入了非法引用),这听起来着实吓人。幸运的是,这不必由你亲自来做,它属于 Java 的标准运行时检测的一部分。如果对 null 引用进行调用,Java 会自动抛出 NullPointerException 异常,所以上述代码是多余的,尽管你也许想要执行其他的检查以确保 NullPointerException 不会出现。 + +属于运行时异常的类型有很多,它们会自动被 lava 虚拟机抛出,所以不必在异常说明中把它们列出来。这些异常都是从 RuntimeException 类继承而来,所以既体现了继承的优点,使用起来也很方便。这构成了一组具有相同特征和行为的异常类型。并且,也不再需要在异常说明中声明方法将抛出 RuntimeException 类型的异常(或者任何从 RuntimeException 继承的异常),它们也被称为“不受检查异常”。这种异常属于错误,将被自动捕获,就不用你亲自动手了。要是自己去检查 RuntimeException 的话,代码就显得太混乱了。不过尽管通常不用捕获 RuntimeException 异常,但还是可以在代码中抛出 RuntimeException 类型的异常。 + +RuntimeException 代表的是编程错误: + +1. 无法预料的错误。比如从你控制范围之外传递进来的 null 引用。 +2. 作为程序员,应该在代码中进行检查的错误。(比如对于 ArrayIndexOutOfBoundsException,就得注意一下数组的大小了。)在一个地方发生的异常,常常会在另一个地方导致错误。 + +在这些情况下使用异常很有好处,它们能给调试带来便利。 + +如果不捕获这种类型的异常会发生什么事呢?因为编译器没有在这个问题上对异常说明进行强制检查,RuntimeException 类型的异常也许会穿越所有的执行路径直达 main() 方法,而不会被捕获。要明白到底发生了什么,可以试试下面的例子: + +```java +// exceptions/NeverCaught.java +// Ignoring RuntimeExceptions +// {ThrowsException} +public class NeverCaught { + static void f() { + throw new RuntimeException("From f()"); + } + static void g() { + f(); + } + public static void main(String[] args) { + g(); + } +} +``` + +输出结果为: + +```java +___[ Error Output ]___ +Exception in thread "main" java.lang.RuntimeException: +From f() +at NeverCaught.f(NeverCaught.java:7) +at NeverCaught.g(NeverCaught.java:10) +at NeverCaught.main(NeverCaught.java:13) +``` + +如果 RuntimeException 没有被捕获而直达 main(),那么在程序退出前将调用异常的 printStackTrace() 方法。 + +你会发现,RuntimeException(或任何从它继承的异常)是一个特例。对于这种异常类型,编译器不需要异常说明,其输出被报告给了 System.err。 + +请务必记住:只能在代码中忽略 RuntimeException(及其子类)类型的异常,因为所有受检查类型异常的处理都是由编译器强制实施的。 + +值得注意的是:不应把 Java 的异常处理机制当成是单一用途的工具。是的,它被设计用来处理一些烦人的运行时错误,这些错误往往是由代码控制能力之外的因素导致的;然而,它对于发现某些编译器无法检测到的编程错误,也是非常重要的。 -## finally关键字 +## 使用 finally 进行清理 + +有一些代码片段,可能会希望无论 try 块中的异常是否抛出,它们都能得到执行。这通常适用于内存回收之外的情况(因为回收由垃圾回收器完成),为了达到这个效果,可以在异常处理程序后面加上 finally 子句。完整的异常处理程序看起来像这样: + +```java +try { +// The guarded region: Dangerous activities +// that might throw A, B, or C +} catch(A a1) { +// Handler for situation A +} catch(B b1) { +// Handler for situation B +} catch(C c1) { +// Handler for situation C +} finally { +// Activities that happen every time +} +``` + +为了证明 finally 子句总能运行,可以试试下面这个程序: + +```java +// exceptions/FinallyWorks.java +// The finally clause is always executed +class ThreeException extends Exception {} +public class FinallyWorks { + static int count = 0; + public static void main(String[] args) { + while(true) { + try { + // Post-increment is zero first time: + if(count++ == 0) + throw new ThreeException(); + System.out.println("No exception"); + } catch(ThreeException e) { + System.out.println("ThreeException"); + } finally { + System.out.println("In finally clause"); + if(count == 2) break; // out of "while" + } + } + } +} +``` + +输出为: + +``` +ThreeException +In finally clause +No exception +In finally clause +``` + +可以从输出中发现,无论异常是否被抛出,finally 子句总能被执行。这个程序也给了我们一些思路,当 Java 中的异常不允许我们回到异常抛出的地点时,那么该如何应对呢?如果把 try 块放在循环里,就建立了一个“程序继续执行之前必须要达到”的条件。还可以加入一个 static 类型的计数器或者别的装置,使循环在放弃以前能尝试一定的次数。这将使程序的健壮性更上一个台阶。 + +### finally 用来做什么? + +对于没有垃圾回收和析构函数自动调用机制的语言来说,finally 非常重要。它能使程序员保证:无论 try 块里发生了什么,内存总能得到释放。但 Java 有垃圾回收机制,所以内存释放不再是问题。而且,Java 也没有析构函数可供调用。那么,Java 在什么情况下才能用到 finally 呢? + +当要把除内存之外的资源恢复到它们的初始状态时,就要用到 finally 子句。这种需要清理的资源包括:已经打开的文件或网络连接,在屏幕上画的图形,甚至可以是外部世界的某个开关,如下面例子所示: + +```java +// exceptions/Switch.java +public class Switch { + private boolean state = false; + public boolean read() { return state; } + public void on() { + state = true; + System.out.println(this); + } + public void off() { + state = false; + System.out.println(this); + } + @Override + public String toString() { + return state ? "on" : "off"; + } +} +// exceptions/OnOffException1.java +public class OnOffException1 extends Exception {} +// exceptions/OnOffException2.java +public class OnOffException2 extends Exception {} +// exceptions/OnOffSwitch.java +// Why use finally? +public class OnOffSwitch { + private static Switch sw = new Switch(); + public static void f() + throws OnOffException1, OnOffException2 {} + public static void main(String[] args) { + try { + sw.on(); + // Code that can throw exceptions... + f(); + sw.off(); + } catch(OnOffException1 e) { + System.out.println("OnOffException1"); + sw.off(); + } catch(OnOffException2 e) { + System.out.println("OnOffException2"); + sw.off(); + } + } +} +``` + +输出为: + +``` +on +off +``` + +程序的目的是要确保 main() 结束的时候开关必须是关闭的,所以在每个 try 块和异常处理程序的末尾都加入了对 sw.offo 方法的调用。但也可能有这种情况:异常被抛出,但没被处理程序捕获,这时 sw.off() 就得不到调用。但是有了 finally,只要把 try 块中的清理代码移放在一处即可: + +```java +// exceptions/WithFinally.java +// Finally Guarantees cleanup +public class WithFinally { + static Switch sw = new Switch(); + public static void main(String[] args) { + try { + sw.on(); + // Code that can throw exceptions... + OnOffSwitch.f(); + } catch(OnOffException1 e) { + System.out.println("OnOffException1"); + } catch(OnOffException2 e) { + System.out.println("OnOffException2"); + } finally { + sw.off(); + } + } +} +``` + +输出为: + +```java +on +off +``` + +这里 sw.off() 被移到一处,并且保证在任何情况下都能得到执行。 + +甚至在异常没有被当前的异常处理程序捕获的情况下,异常处理机制也会在跳到更高一层的异常处理程序之前,执行 finally 子句: + +```java +// exceptions/AlwaysFinally.java +// Finally is always executed +class FourException extends Exception {} +public class AlwaysFinally { + public static void main(String[] args) { + System.out.println("Entering first try block"); + try { + System.out.println("Entering second try block"); + try { + throw new FourException(); + } finally { + System.out.println("finally in 2nd try block"); + } + } catch(FourException e) { + System.out.println( + "Caught FourException in 1st try block"); + } finally { + System.out.println("finally in 1st try block"); + } + } +} +``` + +输出为: + +```java +Entering first try block +Entering second try block +finally in 2nd try block +Caught FourException in 1st try block +finally in 1st try block +``` + +当涉及 break 和 continue 语句的时候,finally 子句也会得到执行。请注意,如果把 finally 子句和带标签的 break 及 continue 配合使用,在 Java 里就没必要使用 goto 语句了。 + +### 在 return 中使用 finally + +因为 finally 子句,总是会执行的,所以在一个方法中,可以从多个点返回,并且可以保证重要的清理工作仍旧会执行: + +```java +// exceptions/MultipleReturns.java +public class MultipleReturns { + public static void f(int i) { + System.out.println( + "Initialization that requires cleanup"); + try { + System.out.println("Point 1"); + if(i == 1) return; + System.out.println("Point 2"); + if(i == 2) return; + System.out.println("Point 3"); + if(i == 3) return; + System.out.println("End"); + return; + } finally { + System.out.println("Performing cleanup"); + } + } + public static void main(String[] args) { + for(int i = 1; i <= 4; i++) + f(i); + } +} +``` + +输出为: + +```java +Initialization that requires cleanup +Point 1 +Performing cleanup +Initialization that requires cleanup +Point 1 +Point 2 +Performing cleanup +Initialization that requires cleanup +Point 1 +Point 2 +Point 3 +Performing cleanup +Initialization that requires cleanup +Point 1 +Point 2 +Point 3 +End +Performing cleanup +``` + +从输出中可以看出,从何处返回无关紧要,finally 子句永远会执行。 + +### 缺憾:异常丢失 + +遗憾的是,Java 的异常实现也有瑕疵。异常作为程序出错的标志,决不应该被忽略,但它还是有可能被轻易地忽略。用某些特殊的方式使用 finally 子句,就会发生这种情况: + +```java +// exceptions/LostMessage.java +// How an exception can be lost +class VeryImportantException extends Exception { + @Override + public String toString() { + return "A very important exception!"; + } +} +class HoHumException extends Exception { + @Override + public String toString() { + return "A trivial exception"; + } +} +public class LostMessage { + void f() throws VeryImportantException { + throw new VeryImportantException(); + } + void dispose() throws HoHumException { + throw new HoHumException(); + } + public static void main(String[] args) { + try { + LostMessage lm = new LostMessage(); + try { + lm.f(); + } finally { + lm.dispose(); + } + } catch(VeryImportantException | HoHumException e) { + System.out.println(e); + } + } +} +``` + +输出为: + +``` +A trivial exception +``` + +从输出中可以看到,VeryImportantException 不见了,它被 finally 子句里的 HoHumException 所取代。这是相当严重的缺陷,因为异常可能会以一种比前面例子所示更微妙和难以察党的方式完全丢失。相比之下,C++把“前一个异常还没处理就抛出下一个异常”的情形看成是糟糕的编程错误。也许在 Java 的未来版本中会修正这个问题(另一方面,要把所有抛出异常的方法,如上例中的 dispose() 方法,全部打包放到 try-catch 子句里面)。 + +一种更加简单的丢失异常的方式是从 finally 子句中返回: + +```java +// exceptions/ExceptionSilencer.java +public class ExceptionSilencer { + public static void main(String[] args) { + try { + throw new RuntimeException(); + } finally { +// Using 'return' inside the finally block +// will silence any thrown exception. + return; + } + } +} +``` + +如果运行这个程序,就会看到即使抛出了异常,它也不会产生任何输出。 + ## 异常限制 +当覆盖方法的时候,只能抛出在基类方法的异常说明里列出的那些异常。这个限制很有用,因为这意味若,当基类使用的代码应用到其派生类对象的时候,一样能够工作(当然,这是面向对象的基本概念),异常也不例外。 + +下面例子演示了这种(在编译时)施加在异常上面的限制: + +```java +// exceptions/StormyInning.java +// Overridden methods can throw only the exceptions +// specified in their base-class versions, or exceptions +// derived from the base-class exceptions +class BaseballException extends Exception {} +class Foul extends BaseballException {} +class Strike extends BaseballException {} +abstract class Inning { + Inning() throws BaseballException {} + public void event() throws BaseballException { +// Doesn't actually have to throw anything + } + public abstract void atBat() throws Strike, Foul; + public void walk() {} // Throws no checked exceptions +} +class StormException extends Exception {} +class RainedOut extends StormException {} +class PopFoul extends Foul {} +interface Storm { + void event() throws RainedOut; + void rainHard() throws RainedOut; +} +public class StormyInning extends Inning implements Storm { + // OK to add new exceptions for constructors, but you +// must deal with the base constructor exceptions: + public StormyInning() + throws RainedOut, BaseballException {} + public StormyInning(String s) + throws BaseballException {} + // Regular methods must conform to base class: +//- void walk() throws PopFoul {} //Compile error +// Interface CANNOT add exceptions to existing +// methods from the base class: +//- public void event() throws RainedOut {} +// If the method doesn't already exist in the +// base class, the exception is OK: + @Override + public void rainHard() throws RainedOut {} + // You can choose to not throw any exceptions, +// even if the base version does: + @Override + public void event() {} + // Overridden methods can throw inherited exceptions: + @Override + public void atBat() throws PopFoul {} + public static void main(String[] args) { + try { + StormyInning si = new StormyInning(); + si.atBat(); + } catch(PopFoul e) { + System.out.println("Pop foul"); + } catch(RainedOut e) { + System.out.println("Rained out"); + } catch(BaseballException e) { + System.out.println("Generic baseball exception"); + } +// Strike not thrown in derived version. + try { +// What happens if you upcast? + Inning i = new StormyInning(); + i.atBat(); +// You must catch the exceptions from the +// base-class version of the method: + } catch(Strike e) { + System.out.println("Strike"); + } catch(Foul e) { + System.out.println("Foul"); + } catch(RainedOut e) { + System.out.println("Rained out"); + } catch(BaseballException e) { + System.out.println("Generic baseball exception"); + } + } +} +``` + +在 Inning 类中,可以看到构造器和 event() 方法都声明将抛出异常,但实际上没有抛出。这种方式使你能强制用户去捕获可能在覆盖后的 event() 版本中增加的异常,所以它很合理。这对于抽象方法同样成立,比如 atBat()。 + +接口 Storm 包含了一个在 Inning 中定义的方法 event() 和一个不在 Inning 中定义的方法 rainHard()。这两个方法都抛出新的异常 RainedOut,如果 StormyInning 类在扩展 Inning 类的同时又实现了 Storm 接口,那么 Storm 里的 event()方法就不能改变在 Inning 中的 event(方法的异常接口。否则的话,在使用基类的时候就不能判断是否捕获了正确的异常,所以这也很合理。当然,如果接口里定义的方法不是来自于基类,比如 rainHardO,那么此方法抛出什么样的异常都没有问题。 + +异常限制对构造器不起作用。你会发现 StormyInning 的构造器可以抛出任何异常,而不必理会基类构造器所抛出的异常。然而,因为基类构造器必须以这样或那样的方式被调用(这里默认构造器将自动被调用),派生类构造器的异常说明必须包含基类构造器的异常说明。 + +派生类构造器不能捕获基类构造器抛出的异常。 + +StormyInning.walk() 不能通过编译的原因是因为:它抛出了异常,而 Inning.walk()) 并没有声明此异常。如果编译器允许这么做的话,就可以在调用 Inning.walk() 的时候不用做异常处理了,而且当把它替换成 Inning 的派生类的对象时,这个方法就有可能会抛出异常,于是程序就失灵了。通过强制派生类遵守基类方法的异常说明,对象的可替换性得到了保证。 + +覆盖后的 event()方法表明,派生类方法可以不抛出任何异常,即使它是基类所定义的异常。同样这是因为,假使基类的方法会抛出异常,这样做也不会破坏已有的程序,所以也没有问题。类似的情况出现在 atBat() 身上,它抛出的是 PopFoul,这个异常是继承自“会被基类的 atBat() 抛出”的 Foul,这样,如果你写的代码是同 Inning 打交道,并且调用了它的 atBat() 的话,那么肯定能捕获 Foul,而 PopFoul 是由 Foul 派生出来的,因此异常处理程序也能捕获 PopFoul。 + +最后一个值得注意的地方是 main()。这里可以看到,如果处理的刚好是 Stormylnning 对象的话,编译器只会强制要求你捕获这个类所抛出的异常。但是如果将它向上转型成基类型,那么编译器就会(正确地)要求你捕获基类的异常。所有这些限制都是为了能产生更为强壮的异常处理代码。 + +尽管在继承过程中,编译器会对异常说明做强制要求,但异常说明本身并不属于方法类型的一部分,方法类型是由方法的名字与参数的类型组成的。因此,不能基于异常说明来重载方法。此外,一个出现在基类方法的异常说明中的异常,不一定会出现在派生类方法的异常说明里。这点同继承的规则明显不同,在继承中,基类的方法必须出现在派生类里,换句话说,在继承和覆盖的过程中,某个特定方法的“异常说明的接口”不是变大了而是变小了——这恰好和类接口在继承时的情形相反。 + -## 异常构造 +## 构造器 +有一点很重要,即你要时刻询问自己“如果异常发生了,所有东西能被正确的清理吗?"尽管大多数情况下是非常安全的,但涉及构造器时,问题就出现了。构造器会把对象设置成安全的初始状态,但还会有别的动作,比如打开一个文件,这样的动作只有在对象使用完毕并且用户调用了特殊的清理方法之后才能得以清理。如果在构造器内抛出了异常,这些清理行为也许就不能正常工作了。这意味着在编写构造器时要格外细心。 + +你也许会认为使用 finally 就可以解决问题。但问题并非如此简单,因为 finally 会每次都执行清理代码。如果构造器在其执行过程中半途而废,也许该对象的某些部分还没有被成功创建,而这些部分在 finaly 子句中却是要被清理的。 + +在下面的例子中,建立了一个 InputFile 类,它能打开一个文件并且每次读取其中的一行。这里使用了 Java 标准输入/输出库中的 FileReader 和 BufferedReader 类(将在 [附录:I/O 流 ]() 中讨论),这些类的基本用法很简单,你应该很容易明白: + +```java +// exceptions/InputFile.java +// Paying attention to exceptions in constructors +import java.io.*; +public class InputFile { + private BufferedReader in; + public InputFile(String fname) throws Exception { + try { + in = new BufferedReader(new FileReader(fname)); +// Other code that might throw exceptions + } catch(FileNotFoundException e) { + System.out.println("Could not open " + fname); +// Wasn't open, so don't close it + throw e; + } catch(Exception e) { +// All other exceptions must close it + try { + in.close(); + } catch(IOException e2) { + System.out.println("in.close() unsuccessful"); + } + throw e; // Rethrow + } finally { +// Don't close it here!!! + } + } + public String getLine() { + String s; + try { + s = in.readLine(); + } catch(IOException e) { + throw new RuntimeException("readLine() failed"); + } + return s; + } + public void dispose() { + try { + in.close(); + System.out.println("dispose() successful"); + } catch(IOException e2) { + throw new RuntimeException("in.close() failed"); + } + } +} +``` + +InputFile 的构造器接受字符串作为参数,该字符串表示所要打开的文件名。在 try 块中,会使用此文件名建立了 FlleReader 对象。FileReader 对象本身用处并不大,但可以用它来建立 BufferedReader 对象。注意,使用 InputFile 的好处就能是把两步操作合而为一。 + +如果 FileReader 的构造器失败了,将抛出 FileNotFoundException 异常。对于这个异常,并不需要关闭文件,因为这个文件还没有被打开。而任何其他捕获异常的 catch 子句必须关闭文件,因为在它们捕获到异常之时,文件已经打开了(当然,如果还有其他方法能抛出 FlleNotFoundException,这个方法就显得有些投机取巧了。这时,通常必须把这些方法分别放到各自的 try 块里),close() 方法也可能会抛出异常,所以尽管它已经在另一个 catch 子句块里了,还是要再用一层 try-catch 对 Java 编译器而言,这只不过是又多了一对花括号。在本地做完处理之后,异常被重新抛出,对于构造器而言这么做是很合适的,因为你总不希望去误导调用方,让他认为“这个对象已经创建完毕,可以使用了”。 + +在本例中,由于 finally 会在每次完成构造器之后都执行一遍,因此它实在不该是调用 close() 关闭文件的地方。我们希望文件在 InputFlle 对象的整个生命周期内都处于打开状态。 + +getLine() 方法会返回表示文件下一行内容的字符串。它调用了能抛出异常的 readLine(),但是这个异常已经在方法内得到处理,因此 getLine() 不会抛出任何异常。在设计异常时有一个问题:应该把异常全部放在这一层处理;还是先处理一部分,然后再向上层抛出相同的(或新的)异常;又或者是不做任何处理直接向上层抛出。如果用法恰当的话,直接向上层抛出的确能简化编程。在这里,getLine() 方法将异常转换为 RuntimeException,表示一个编程错误。 + +用户在不再需要 InputFile 对象时,就必须调用 dispose() 方法,这将释放 BufferedReader 和/或 FileReader 对象所占用的系统资源(比如文件句柄),在使用完 InputFile 对象之前是不会调用它的。可能你会考虑把上述功能放到 finalize() 里面,但我在 [封装 ]() 讲过,你不知道 finalize() 会不会被调用(即使能确定它将被调用,也不知道在什么时候调用),这也是 Java 的缺陷:除了内存的清理之外,所有的清理都不会自动发生。所以必须告诉客户端程序员,这是他们的责任。 + +对于在构造阶段可能会抛出异常,并且要求清理的类,最安全的使用方式是使用嵌套的 try 子句: + +```java +// exceptions/Cleanup.java +// Guaranteeing proper cleanup of a resource +public class Cleanup { + public static void main(String[] args) { + try { + InputFile in = new InputFile("Cleanup.java"); + try { + String s; + int i = 1; + while((s = in.getLine()) != null) + ; // Perform line-by-line processing here... + } catch(Exception e) { + System.out.println("Caught Exception in main"); + e.printStackTrace(System.out); + } finally { + in.dispose(); + } + } catch(Exception e) { + System.out.println( + "InputFile construction failed"); + } + } +} +``` + +输出为: + +``` +dispose() successful +``` + +请仔细观察这里的逻辑:对 InputFile 对象的构造在其自己的 try 语句块中有效,如果构造失败,将进入外部的 catch 子句,而 dispose() 方法不会被调用。但是,如果构造成功,我们肯定想确保对象能够被清理,因此在构造之后立即创建了一个新的 try 语句块。执行清理的 finally 与内部的 try 语句块相关联。在这种方式中,finally 子句在构造失败时是不会执行的,而在构成成功时将总是执行。 + +这种通用的清理惯用法在构造器不抛出任何异常时也应该运用,其基本规则是:在创建需要清理的对象之后,立即进入一个 try-finally 语句块: + +```java +// exceptions/CleanupIdiom.java +// Disposable objects must be followed by a try-finally +class NeedsCleanup { // Construction can't fail + private static long counter = 1; + private final long id = counter++; + public void dispose() { + System.out.println( + "NeedsCleanup " + id + " disposed"); + } +} +class ConstructionException extends Exception {} +class NeedsCleanup2 extends NeedsCleanup { + // Construction can fail: + NeedsCleanup2() throws ConstructionException {} +} +public class CleanupIdiom { + public static void main(String[] args) { +// [1]: + NeedsCleanup nc1 = new NeedsCleanup(); + try { +// ... + } finally { + nc1.dispose(); + } +// [2]: +// If construction cannot fail, +// you can group objects: + NeedsCleanup nc2 = new NeedsCleanup(); + NeedsCleanup nc3 = new NeedsCleanup(); + try { +// ... + } finally { + nc3.dispose(); // Reverse order of construction + nc2.dispose(); + } +// [3]: +// If construction can fail you must guard each one: + try { + NeedsCleanup2 nc4 = new NeedsCleanup2(); + try { + NeedsCleanup2 nc5 = new NeedsCleanup2(); + try { +// ... + } finally { + nc5.dispose(); + } + } catch(ConstructionException e) { // nc5 const. + System.out.println(e); + } finally { + nc4.dispose(); + } + } catch(ConstructionException e) { // nc4 const. + System.out.println(e); + } + } +} +``` + +输出为: + +``` +NeedsCleanup 1 disposed +NeedsCleanup 3 disposed +NeedsCleanup 2 disposed +NeedsCleanup 5 disposed +NeedsCleanup 4 disposed +``` + +- [1] 相当简单,遵循了在可去除对象之后紧跟 try-finally 的原则。如果对象构造不会失败,就不需要任何 catch。 +- [2] 为了构造和清理,可以看到将具有不能失败的构造器的对象分组在一起。 +- [3] 展示了如何处理那些具有可以失败的构造器,且需要清理的对象。为了正确处理这种情况,事情变得很棘手,因为对于每一个构造,都必须包含在其自己的 try-finally 语句块中,并且每一个对象构造必须都跟随一个 try-finally 语句块以确保清理。 + +本例中的异常处理的棘手程度,对于应该创建不能失败的构造器是一个有力的论据,尽管这么做并非总是可行。 + +注意,如果 dispose() 可以抛出异常,那么你可能需要额外的 try 语句块。基本上,你应该仔细考虑所有的可能性,并确保正确处理每一种情况。 -## Try-With-Resources用法 +## Try-With-Resources 用法 + +上一节的内容可能让你有些头疼。在考虑所有可能失败的方法时,找出放置所有 try-catch-finally 块的位置变得令人生畏。确保没有任何故障路径,使系统远离不稳定状态,这非常具有挑战性。 + +InputFile.java 是一个特别棘手的情况,因为文件被打开(包含所有可能的异常),然后它在对象的生命周期中保持打开状态。每次调用 getLine() 都会导致异常,因此可以调用 dispose() 方法。这是一个很好的例子,因为它显示了事物的混乱程度。它还表明你应该尝试最好不要那样设计代码(当然,当你不选择代码的设计方式时,你经常会遇到这种情况,因此你必须仍然理解它)。 + +InputFile.java 一个更好的实现方式是如果构造函数读取文件并在内部缓冲它 —— 这样,文件的打开,读取和关闭都发生在构造函数中。或者,如果读取和存储文件不切实际,你可以改为生成 Stream。理想情况下,你可以设计成如下的样子: + +```java +// exceptions/InputFile2.java +import java.io.*; +import java.nio.file.*; +import java.util.stream.*; +public class InputFile2 { + private String fname; + + public InputFile2(String fname) { + this.fname = fname; + } + + public Stream getLines() throws IOException { + return Files.lines(Paths.get(fname)); + } + + public static void + main(String[] args) throws IOException { + new InputFile2("InputFile2.java").getLines() + .skip(15) + .limit(1) + .forEach(System.out::println); + } +} +``` + +输出为: + +``` +main(String[] args) throws IOException { +``` + +现在,getLines() 全权负责打开文件并创建 Stream。 + +你不能总是轻易地回避这个问题。有时会有以下问题: + +1. 需要资源清理 +2. 需要在特定的时刻进行资源清理,比如你离开作用域的时候(在通常情况下意味着通过异常进行清理)。 + +一个常见的例子是 jav.io.FileInputstream(将会在[附录:I/O 流 ]() 中提到)。要正确使用它,你必须编写一些棘手的样板代码: + +```java +// exceptions/MessyExceptions.java +import java.io.*; +public class MessyExceptions { + public static void main(String[] args) { + InputStream in = null; + try { + in = new FileInputStream( + new File("MessyExceptions.java")); + int contents = in.read(); + // Process contents + } catch(IOException e) { + // Handle the error + } finally { + if(in != null) { + try { + in.close(); + } catch(IOException e) { + // Handle the close() error + } + } + } + } +} +``` + +当 finally 子句有自己的 try 块时,感觉事情变得过于复杂。 + +幸运的是,Java 7 引入了 try-with-resources 语法,它可以非常清楚地简化上面的代码: + +```java +// exceptions/TryWithResources.java +import java.io.*; +public class TryWithResources { + public static void main(String[] args) { + try( + InputStream in = new FileInputStream( + new File("TryWithResources.java")) + ) { + int contents = in.read(); + // Process contents + } catch(IOException e) { + // Handle the error + } + } +} +``` + +在 Java 7 之前,try 总是后面跟着一个 {,但是现在可以跟一个带括号的定义 - 这里是我们创建的 FileInputStream 对象。括号内的部分称为资源规范头(resource specification header)。现在可用于整个 try 块的其余部分。更重要的是,无论你如何退出 try 块(通常或通过异常),都会执行前一个 finally 子句的等价物,但不会编写那些杂乱而棘手的代码。这是一项重要的改进。 + +它是如何工作的?在 try-with-resources 定义子句中创建的对象(在括号内)必须实现 java.lang.Autocloseable 接口,这个接口有一个方法,close()。当在 Java 7 中引入 AutoCloseable 时,许多接口和类被修改以实现它;查看 Javadocs 中的 AutoCloseable,可以找到所有实现该接口的类列表,其中包括 Stream 对象: + +```java +// exceptions/StreamsAreAutoCloseable.java +import java.io.*; +import java.nio.file.*; +import java.util.stream.*; +public class StreamsAreAutoCloseable { + public static void + main(String[] args) throws IOException{ + try( + Stream in = Files.lines( + Paths.get("StreamsAreAutoCloseable.java")); + PrintWriter outfile = new PrintWriter( + "Results.txt"); // [1] + ) { + in.skip(5) + .limit(1) + .map(String::toLowerCase) + .forEachOrdered(outfile::println); + } // [2] + } +} +``` + +- [1] 你在这里可以看到其他的特性:资源规范头中可以包含多个定义,并且通过分号进行分割(最后一个分号是可选的)。规范头中定义的每个对象都会在 try 语句块运行结束之后调用 close() 方法。 +- [2] try-with-resources 里面的 try 语句块可以不包含 catch 或者 finally 语句而独立存在。在这里,IOException 被 main() 方法抛出,所以这里并不需要在 try 后面跟着一个 catch 语句块。 + +Java 5 中的 Closeable 已经被修改,修改之后的接口继承了 AutoCloseable 接口。所以所有实现了 Closeable 接口的对象,都支持了 try-with-resources 特性。 + +### 揭示细节 + +为了研究 try-with-resources 的基本机制,我们将创建自己的 AutoCloseable 类: + +```java +// exceptions/AutoCloseableDetails.java +class Reporter implements AutoCloseable { + String name = getClass().getSimpleName(); + Reporter() { + System.out.println("Creating " + name); + } + public void close() { + System.out.println("Closing " + name); + } +} +class First extends Reporter {} +class Second extends Reporter {} +public class AutoCloseableDetails { + public static void main(String[] args) { + try( + First f = new First(); + Second s = new Second() + ) { + } + } +} +``` + +输出为: + +``` +Creating First +Creating Second +Closing Second +Closing First +``` + +退出 try 块会调用两个对象的 close() 方法,并以与创建顺序相反的顺序关闭它们。顺序很重要,因为在此配置中,Second 对象可能依赖于 First 对象,因此如果 First 在第 Second 关闭时已经关闭。 Second 的 close() 方法可能会尝试访问 First 中不再可用的某些功能。 + +假设我们在资源规范头中定义了一个不是 AutoCloseable 的对象 + +```java +// exceptions/TryAnything.java +// {WillNotCompile} +class Anything {} +public class TryAnything { + public static void main(String[] args) { + try( + Anything a = new Anything() + ) { + } + } +} +``` + +正如我们所希望和期望的那样,Java 不会让我们这样做,并且出现编译时错误。 + +如果其中一个构造函数抛出异常怎么办? + +```java +// exceptions/ConstructorException.java +class CE extends Exception {} +class SecondExcept extends Reporter { + SecondExcept() throws CE { + super(); + throw new CE(); + } +} +public class ConstructorException { + public static void main(String[] args) { + try( + First f = new First(); + SecondExcept s = new SecondExcept(); + Second s2 = new Second() + ) { + System.out.println("In body"); + } catch(CE e) { + System.out.println("Caught: " + e); + } + } +} +``` + +输出为: + +``` +Creating First +Creating SecondExcept +Closing First +Caught: CE +``` + +现在资源规范头中定义了 3 个对象,中间的对象抛出异常。因此,编译器强制我们使用 catch 子句来捕获构造函数异常。这意味着资源规范头实际上被 try 块包围。 + +正如预期的那样,First 创建时没有发生意外,SecondExcept 在创建期间抛出异常。请注意,不会为 SecondExcept 调用 close(),因为如果构造函数失败,则无法假设你可以安全地对该对象执行任何操作,包括关闭它。由于 SecondExcept 的异常,Second 对象实例 s2 不会被创建,因此也不会有清除事件发生。 + +如果没有构造函数抛出异常,但你可能会在 try 的主体中获取它们,则再次强制你实现 catch 子句: + +```java +// exceptions/BodyException.java +class Third extends Reporter {} +public class BodyException { + public static void main(String[] args) { + try( + First f = new First(); + Second s2 = new Second() + ) { + System.out.println("In body"); + Third t = new Third(); + new SecondExcept(); + System.out.println("End of body"); + } catch(CE e) { + System.out.println("Caught: " + e); + } + } +} +``` + +输出为: + +```java +Creating First +Creating Second +In body +Creating Third +Creating SecondExcept +Closing Second +Closing First +Caught: CE +``` + +请注意,第 3 个对象永远不会被清除。那是因为它不是在资源规范头中创建的,所以它没有被保护。这很重要,因为 Java 在这里没有以警告或错误的形式提供指导,因此像这样的错误很容易漏掉。实际上,如果依赖某些集成开发环境来自动重写代码,以使用 try-with-resources 特性,那么它们(在撰写本文时)通常只会保护它们遇到的第一个对象,而忽略其余的对象。 + +最后,让我们看一下抛出异常的 close() 方法: + +```java +// exceptions/CloseExceptions.java +class CloseException extends Exception {} +class Reporter2 implements AutoCloseable { + String name = getClass().getSimpleName(); + Reporter2() { + System.out.println("Creating " + name); + } + public void close() throws CloseException { + System.out.println("Closing " + name); + } +} +class Closer extends Reporter2 { + @Override + public void close() throws CloseException { + super.close(); + throw new CloseException(); + } +} +public class CloseExceptions { + public static void main(String[] args) { + try( + First f = new First(); + Closer c = new Closer(); + Second s = new Second() + ) { + System.out.println("In body"); + } catch(CloseException e) { + System.out.println("Caught: " + e); + } + } +} +``` + +输出为: + +``` +Creating First +Creating Closer +Creating Second +In body +Closing Second +Closing Closer +Closing First +Caught: CloseException +``` + +从技术上讲,我们并没有被迫在这里提供一个 catch 子句;你可以通过 **main() throws CloseException** 的方式来报告异常。但 catch 子句是放置错误处理代码的典型位置。 + +请注意,因为所有三个对象都已创建,所以它们都以相反的顺序关闭 - 即使 Closer 也是如此。 close() 抛出异常。当你想到它时,这就是你想要发生的事情,但是如果你必须自己编写所有这些逻辑,那么你可能会错过一些错误。想象一下所有代码都在那里,程序员没有考虑清理的所有含义,并且做错了。因此,应始终尽可能使用 try-with-resources。它有助于实现该功能,使得生成的代码更清晰,更易于理解。 + ## 异常匹配 +抛出异常的时候,异常处理系统会按照代码的书写顺序找出“最近”的处理程序。找到匹配的处理程序之后,它就认为异常将得到处理,然后就不再继续查找。 + +查找的时候并不要求抛出的异常同处理程序所声明的异常完全匹配。派生类的对象也可以匹配其基类的处理程序,就像这样: + +```java +// exceptions/Human.java +// Catching exception hierarchies +class Annoyance extends Exception {} +class Sneeze extends Annoyance {} +public class Human { + public static void main(String[] args) { + // Catch the exact type: + try { + throw new Sneeze(); + } catch(Sneeze s) { + System.out.println("Caught Sneeze"); + } catch(Annoyance a) { + System.out.println("Caught Annoyance"); + } + // Catch the base type: + try { + throw new Sneeze(); + } catch(Annoyance a) { + System.out.println("Caught Annoyance"); + } + } +} +``` + +输出为: + +```java +Caught Sneeze +Caught Annoyance +``` + +Sneeze 异常会被第一个匹配的 catch 子句捕获,也就是程序里的第一个。然而如果将这个 catch 子句删掉,只留下 Annoyance 的 catch 子句,该程序仍然能运行,因为这次捕获的是 Sneeze 的基类。换句话说,catch(Annoyance e)会捕获 Annoyance 以及所有从它派生的异常。这一点非常有用,因为如果决定在方法里加上更多派生异常的话,只要客户程序员捕获的是基类异常,那么它们的代码就无需更改。 + +如果把捕获基类的 catch 子句放在最前面,以此想把派生类的异常全给“屏蔽”掉,就像这样: + +```java +try { + throw new Sneeze(); +} catch(Annoyance a) { + // ... +} catch(Sneeze s) { + // ... +} +``` + +输出为: + +这样编译器就会发现 Sneeze 的 catch 子句永远也得不到执行,因此它会向你报告错误。 -## 异常准则 + +## 其他可选方式 + +|异常处理系统就像一个活门(trap door),使你能放弃程序的正常执行序列。当“异常情形” +发生的时候,正常的执行已变得不可能或者不需要了,这时就要用到这个“活门"。异常代表了当前方法不能继续执行的情形。开发异常处理系统的原因是,如果为每个方法所有可能发生的错误都进行处理的话,任务就显得过于繁重了,程序员也不愿意这么做。结果常常是将错误忽格。应该注意到,开发异常处理的初衷是为了方便程序员处理错误。 + +异常处理的一个重要原则是“只有在你知道如何处理的情况下才捕获异常"。实际上,异常处理的一个重要目标就是把错误处理的代码同错误发生的地点相分离。这使你能在一段代码中专注于要完成的事情,至于如何处理错误,则放在另一段代码中完成。这样以来,主于代码就不会与错误处理逻辑混在一起,也更容易理解和维护。通过允许一个处理程序去处理多个出错点,异常处理还使得错误处理代码的数量趋向于减少。 + +“被检查的异常”使这个问题变得有些复杂,因为它们强制你在可能还没准备好处理错误的时候被迫加上 catch 子句,这就导致了吞食则有害(harmful if swallowed)的问题: + +```java +try { + // ... to do something useful +} catch(ObligatoryException e) {} // Gulp! +``` + +程序员们只做最简单的事情(包括我自己,在本书第 1 版中也有这个问题),常常是无意中"吞食”了异常,然而一旦这么做,虽然能通过编译,但除非你记得复查并改正代码,否则异常将会丢失。异常确实发生了,但“吞食”后它却完全消失了。因为编译器强迫你立刻写代码来处理异常,所以这种看起来最简单的方法,却可能是最糟糕的做法。 + +当我意识到犯了这么大一个错误时,简直吓了一大跳,在本书第 2 版中,我在处理程序里通过打印栈轨迹的方法“修补”了这个问题(本章中的很多例子还是使用了这种方法,看起来还是比较合适的),虽然这样可以跟踪异常的行为,但是仍旧不知道该如何处理异常。这一节,我们来研究一下“被检查的异常”及其并发症,以及采用什么方法来解决这些问题。 + +这个话题看起来简单,但实际上它不仅复杂,更重要的是还非常多变。总有人会顽固地坚持自己的立场,声称正确答案(也是他们的答案)是显而易见的。我觉得之所以会有这种观点,是因为我们使用的工具已经不是 ANS1 标准出台前的像 C 那样的弱类型语言,而是像 C++和 Java 这样的“强静态类型语言”(也就是编译时就做类型检查的语言),这是前者所无法比拟的。当刚开始这种转变的时候(就像我一样),会觉得它带来的好处是那样明显,好像类型检查总能解决所有的问题。在此,我想结合我自己的认识过程,告诉读者我是怎样从对类型检查的绝对迷信变成持怀疑态度的,当然,很多时候它还是非常有用的,但是当它挡住我们的去路并成为障碍的时候,我们就得跨过去。只是这条界限往往并不是很清晰(我最喜欢的一句格言是:所有模型都是错误的,但有些是能用的)。 + +### 历史 + +异常处理起源于 PL/1 和 Mesa 之类的系统中,后来又出现在 CLU、Smalltalk、Modula-3、Ada、Eiffel、C++、Python、Java 以及后 Java 语言 Ruby 和 C#中。Java 的设计和 C++很相似,只是 Java 的设计者去掉了一些他们认为 C++设计得不好的东西。 + +为了能向程序员提供一个他们更愿意使用的错误处理和恢复的框架,异常处理机制很晚才被加入 C++标准化过程中,这是由 C++的设计者 Bjarne Stroustrup 所倡议。C++的异常模型主要借鉴了 CLU 的做法。然而,当时其他语言已经支持异常处理了:包括 Ada、Smalltalk(两者都有异常处理,但是都没有异常说明),以及 Modula-3(它既有异常处理也有异常说明)。 + +Liskov 和 Snyder 在他们的一篇讨论该主题的独创性论文。中指出,用瞬时风格(transient fashion)报告错误的语言(如 C 中)有一个主要缺陷,那就是: + +> “....每次调用的时候都必须执行条件测试,以确定会产生何种结果。这使程序难以阅读并且有可能降低运行效率,因此程序员们既不愿意指出,也不愿意处理异常。” + +因此,异常处理的初衷是要消除这种限制,但是我们又从 Java 的“被检查的异常”中看到了这种代码。他们继续写道: + +> “....要求程序员把异常处理程序的代码文本附接到会引发异常的调用上,这会降低程序的可读性,使得程序的正常思路被异常处理给破坏了。” + +C++中异常的设计参考了 CLU 方式.Stroustrup 声称其目标是减少恢复错误所需的代码。我想他这话是说给那些通常情况下都不写 C 的错误处理的程序员们听的,因为要把那么多代码放到那么多地方实在不是什么好差事。所以他们写 C 程序的习惯是,忽略所有的错误,然后使用调试器来跟踪错误。这些程序员知道,使用异常就意味着他们要写一些通常不用写的、“多出来的”代码。因此,要把他们拉到“使用错误处理”的正轨上,“多出来的”代码决不能太多。我认为,评价 Java 的“被检查的异常”的时候,这一点是很重要的。 + +C++从 CLU 那里还带来另一种思想:异常说明。这样,就可以用编程的方式在方法的特征签名中,声明这个方法将会抛出异常。异常说明可能有两种意思。一个是“我的代码会产生这种异常,这由你来处理”。另一个是“我的代码忽略了这些异常,这由你来处理”。学习异常处理的机制和语法的时候,我们一直在关注“你来处理”部分,但这里特别值得注意的事实是,我们通常都忽略了异常说明所表达的完整含义。 + +C++的异常说明不属于函数的类型信息。编译时唯一要检查的是异常说明是不是前后一致;比如,如果函数或方法会抛出某些异常,那么它的重载版本或者派生版本也必须抛出同样的异常。与 Java 不同,C++不会在编译时进行检查以确定函数或方法是不是真的抛出异常,或者异常说明是不是完整(也就是说,异常说明有没有精确描述所有可能被抛出的异常)。这样的检查只发生在运行期间。如果抛出的异常与异常说明不符,C++会调用标准类库的 unexpected() 函数。 + +值得注意的是,由于使用了模板,C++的标准类库实现里根本没有使用异常说明。在 Java 中,对于范型用于异常说明的方式存在着一些限制。 + +### 观点 + +首先,Java 无谓地发明了“被检查的异常”(很明显是受 C++异常说明的启发,以及受 C++程序员们一般对此无动于衷的事实的影响),但是,这还只是一次尝试,目前为止还没有别的语言采用这种做法。 + +其次,仅从示意性的例子和小程序来看,“被检查的异常”的好处很明显。但是当程序开始变大的时候,就会带来一些微妙的问题。当然,程序不是一下就变大的,这有个过程。如果把不适用于大项目的语言用于小项目,当这些项目不断膨胀时,突然有一天你会发现,原来可以管理的东西,现在已经变得无法管理了。这就是我所说的过多的类型检查,特别是“被检查的异常"所造成的问题。 + +看来程序的规模是个重要因素。由于很多讨论都用小程序来做演示,因此这并不足以说明问题。一名 C#的设计人员发现: + +> “仅从小程序来看,会认为异常说明能增加开发人员的效率,并提高代码的质量;但考察大项目的时候,结论就不同了-开发效率下降了,而代码质量只有微不足道的提高,甚至毫无提高”。 + +谈到未被捕获的异常的时候,CLU 的设计师们认为: + +> “我们觉得强迫程序员在不知道该采取什么措施的时候提供处理程序,是不现实的。” + +在解释为什么“函数没有异常说明就表示可以抛出任何异常”的时候,Stroustrup 这样认为: + +> “但是,这样一来几乎所有的函数都得提供异常说明了,也就都得重新编译,而且还会妨碍它同其他语言的交互。这样会迫使程序员违反异常处理机制的约束,他们会写欺骗程序来掩盖异常。这将给没有注意到这些异常的人造成一种虚假的安全感。” +> + +我们已经看到这种破坏异常机制的行为了-就在 Java 的“被检查的异常”里。 + +Martin Fowler(UML Distilled,Refactoring 和 Analysis Patterns 的作者)给我写了下面这段话: + +> “...总体来说,我觉得异常很不错,但是 Java 的”被检查的异常“带来的麻烦比好处要多。” + +过去,我曾坚定地认为“被检查的异常”和强静态类型检查对开发健壮的程序是非常必要的。但是,我看到的以及我使用一些动态(类型检查)语言的亲身经历告诉我,这些好处实际上是来自于: + +1. 不在于编译器是否会强制程序员去处理错误,而是要有一致的、使用异常来报告错误的模型。 +2. 不在于什么时候进行检查,而是一定要有类型检查。也就是说,必须强制程序使用正确的类型,至于这种强制施加于编译时还是运行时,那倒没关系。 + +此外,减少编译时施加的约束能显著提高程序员的编程效率。事实上,反射和泛型就是用来补偿静态类型检查所带来的过多限制,在本书很多例子中都会见到这种情形。 + +我已经听到有人在指责了,他们认为这种言论会令我名誉扫地,会让文明堕落,会导致更高比例的项目失败。他们的信念是应该在编译时指出所有错误,这样才能挽救项目,这种信念可以说是无比坚定的;其实更重要的是要理解编译器的能力限制。在 http://MindView.net/Books/BetterJava 上的补充材料中,我强调了自动构建过程和单元测试的重要性,比起把所有的东西都说成是语法错误,它们的效果可以说是事半功倍。下面这段话是至理名言: + +> 好的程序设计语言能帮助程序员写出好程序,但无论哪种语言都避免不了程序员用它写出了坏程序。 + +不管怎么说,要让 Java 把“被检查的异常”从语言中去除,这种可能性看来非常渺茫。对语言来说,这个变化可能太激进了点,况且 Sun 的支持者们也非常强大。Sun 有完全向后兼容的历史和策略,实际上所有 Sun 的软件都能在 Sun 的硬件上运行,无论它们有多么古老。然而,如果发现有些“被检查的异常”挡住了路,尤其是发现你不得不去对付那些不知道该如何处理的异常,还是有些办法的。 + +### 把异常传递给控制台 + +对于简单的程序,比如本书中的许多例子,最简单而又不用写多少代码就能保护异常信息的方法,就是把它们从 main() 传递到控制台。例如,为了读取信息而打开一个文件(在第 12 章将详细介绍),必须对 FilelnputStream 进行打开和关闭操作,这就可能会产生异常。对于简单的程序,可以像这样做(本书中很多地方采用了这种方法): + +```java +// exceptions/MainException.java +import java.util.*; +import java.nio.file.*; +public class MainException { + // Pass exceptions to the console: + public static void main(String[] args) throws Exception { + // Open the file: + List lines = Files.readAllLines( + Paths.get("MainException.java")); + // Use the file ... + } +} +``` + +注意,main() 作为一个方法也可以有异常说明,这里异常的类型是 Exception,它也是所有“被检查的异常”的基类。通过把它传递到控制台,就不必在 main() 里写 try-catch 子句了。(不过,实际的文件输人输出操作比这个例子要复杂得多。你将会在[文件 ]() 和[附录:I/O 流 ]() 章节中学到更多) + +### 把“被检查的异常”转换为“不检查的异常” + +在编写你自己使用的简单程序时,从 main() 中抛出异常是很方便的,但这不是通用的方法。 + +问题的实质是,当在一个普通方法里调用别的方法时,要考虑到“我不知道该这样处理这个异常,但是也不想把它‘吞’了,或若打印一些无用的消息”。异常链提供了一种新的思路来解决这个问题。可以直接把“被检查的异常”包装进 RuntimeException 里面,就像这样: + +```java +try { + // ... to do something useful +} catch(IDontKnowWhatToDoWithThisCheckedException e) { + throw new RuntimeException(e); +} +``` + +如果想把“被检查的异常”这种功能“屏蔽”掉的话,这看上去像是一个好办法。不用“吞下”异常,也不必把它放到方法的异常说明里面,而异常链还能保证你不会丢失任何原始异常的信息。 + +这种技巧给了你一种选择,你可以不写 try-catch 子句和/或异常说明,直接忽路异常,让它自己沿着调用栈往上“冒泡”,同时,还可以用 getCause() 捕获并处理特定的异常,就像这样: + +```java +// exceptions/TurnOffChecking.java +// "Turning off" Checked exceptions +import java.io.*; +class WrapCheckedException { + void throwRuntimeException(int type) { + try { + switch(type) { + case 0: throw new FileNotFoundException(); + case 1: throw new IOException(); + case 2: throw new + RuntimeException("Where am I?"); + default: return; + } + } catch(IOException | RuntimeException e) { + // Adapt to unchecked: + throw new RuntimeException(e); + } + } +} +class SomeOtherException extends Exception {} +public class TurnOffChecking { + public static void main(String[] args) { + WrapCheckedException wce = + new WrapCheckedException(); + // You can call throwRuntimeException() without + // a try block, and let RuntimeExceptions + // leave the method: + wce.throwRuntimeException(3); + // Or you can choose to catch exceptions: + for(int i = 0; i < 4; i++) + try { + if(i < 3) + wce.throwRuntimeException(i); + else + throw new SomeOtherException(); + } catch(SomeOtherException e) { + System.out.println( + "SomeOtherException: " + e); + } catch(RuntimeException re) { + try { + throw re.getCause(); + } catch(FileNotFoundException e) { + System.out.println( + "FileNotFoundException: " + e); + } catch(IOException e) { + System.out.println("IOException: " + e); + } catch(Throwable e) { + System.out.println("Throwable: " + e); + } + } + } +} +``` + +输出为: + +``` +FileNotFoundException: java.io.FileNotFoundException +IOException: java.io.IOException +Throwable: java.lang.RuntimeException: Where am I? +SomeOtherException: SomeOtherException +``` + +WrapCheckedException.throwRuntimeException() 的代码可以生成不同类型的异常。这些异常被捕获并包装进了 RuntimeException 对象,所以它们成了这些运行时异常的"cause"了。 + +在 TurnOfChecking 里,可以不用 try 块就调用 throwRuntimeException(),因为它没有抛出“被检查的异常”。但是,当你准备好去捕获异常的时候,还是可以用 try 块来捕获任何你想捕获的异常的。应该捕获 try 块肯定会抛出的异常,这里就是 SomeOtherException,RuntimeException 要放到最后去捕获。然后把 getCause() 的结果(也就是被包装的那个原始异常)抛出来。这样就把原先的那个异常给提取出来了,然后就可以用它们自己的 catch 子句进行处理。 + +本书余下部分将会在合适的时候使用这种“用 RuntimeException 来包装,被检查的异常”的技术。另一种解决方案是创建自己的 RuntimeException 的子类。在这种方式中,不必捕获它,但是希望得到它的其他代码都可以捕获它。 + + ## 异常指南 + +应该在下列情况下使用异常: + +1. 尽可能使用 try-with-resource。 +2. 在恰当的级别处理问题。(在知道该如何处理的情况下才捕获异常。) +3. 解决问题并且重新调用产生异常的方法。 +4. 进行少许修补,然后绕过异常发生的地方继续执行。 +5. 用别的数据进行计算,以代替方法预计会返回的值。 +6. 把当前运行环境下能做的事情尽量做完,然后把相同的异常重抛到更高层。 +7. 把当前运行环境下能做的事情尽量做完,然后把不同的异常抛到更高层。 +8. 终止程序。 +9. 进行简化。(如果你的异常模式使问题变得太复杂,那用起来会非常痛苦也很烦人。) +10. 让类库和程序更安全。(这既是在为调试做短期投资,也是在为程序的健壮性做长期投资。) + + ## 本章小结 +异常是 Java 程序设计不可分割的一部分,如果不了解如何使用它们,那你只能完成很有限的工作。正因为如此,本书专门在此介绍了异常——对于许多类库(例如提到过的 I/O 库),如果不处理异常,你就无法使用它们。 + +异常处理的优点之一就是它使得你可以在某处集中精力处理你要解决的问题,而在另一处处理你编写的这段代码中产生的错误。尽管异常通常被认为是一种工具,使得你可以在运行时报告错误并从错误中恢复,但是我一直怀疑到底有多少时候“恢复”真正得以实现了,或者能够得以实现。我认为这种情况少于 10%,并且即便是这 10%,也只是将栈展开到某个已知的稳定状态,而并没有实际执行任何种类的恢复性行为。无论这是否正确,我一直相信“报告”功能是异常的精髓所在.Java 坚定地强调将所有的错误都以异常形式报告的这一事实,正是它远远超过语如 C++这类语言的长处之一,因为在 C++这类语言中,需要以大量不同的方式来报告错误,或者根本就没有提供错误报告功能。一致的错误报告系统意味着,你再也不必对所写的每一段代码,都质问自己“错误是否正在成为漏网之鱼?”(只要你没有“吞咽”异常,这是关键所在!)。 + +就像你将要在后续章节中看到的,通过将这个问题甩给其他代码-即使你是通过抛出 RuntimeException 来实现这一点的--你在设计和实现时,便可以专注于更加有趣和富有挑战性的问题了。 +## 后记:Exception Bizarro World +(来自于 2011 年的一篇博文) +我的朋友James Ward正在尝试使用JDBC创建一些非常简单的教学示例,并且不断被检查的异常所挫败。他向我指出 Howard Lewis Ship 的帖子“[被检查的例外的悲剧](http://tapestryjava.blogspot.com/2011/05/tragedy-of-checked-exceptions.html)”。特别是。James 对他必须跳过去做一些应该简单的事情的所有环感到沮丧。即使在finally块中,他也不得不放入更多的try-catch子句,因为关闭连接也会导致异常。它在哪里结束?为了简单起见,你必须在环之后跳过环(请注意,try-with-resources语句可以显着改善这种情况)。 + +我们开始讨论Go编程语言,我很着迷,因为Rob Pike等人。我们已经清楚地提出了许多关于语言设计的非常尖锐和基本的问题。基本上,他们已经采取了我们开始接受的有关语言的所有内容,并询问“为什么?”关于每一种语言。学习这门语言真的让你思考和怀疑。 + +我的印象是,Go团队决定不做任何假设,只有在明确需要特征的情况下才能改进语言。他们似乎并不担心进行破坏旧代码的更改 - 他们创建了一个重写工具,因此如果他们进行了这些更改,它将为您重写代码。这使他们能够使语言成为一个持续的实验,以发现真正需要的东西,而不是做Big Upfront Design。 + +他们做出的最有趣的决定之一是完全排除异常。你没有看错 —— 他们不只是遗漏了经过检查的异常情况。他们遗漏了所有异常情况。 + +替代方案非常简单,起初它几乎看起来像C一样。因为Go从一开始就包含了元组,所以你可以轻松地从函数调用中返回两个对象: + +```go +result, err := functionCall() +``` + +( := 告诉 Go 语言这里定义 result 和 err,并且推断他们的数据类型) + +就是这样:对于每次调用,您都会获得结果对象和错误对象。您可以立即检查错误(这是典型的,因为如果某些操作失败,则不太可能继续下一步),或者稍后检查是否有效。 + +起初这似乎很原始,是古代的回归。但到目前为止,我发现Go中的决定都得到了很好的考虑,值得深思。我只是做出反应,因为我的大脑是异常的吗?这会如何影响 James 的问题? + +它发生在我身上,我已经将异常处理视为一种并行执行路径。如果你遇到异常,你会跳出正常的路径进入这个并行执行路径,这是一种“奇异世界”,你不再做你写的东西,而是跳进catch和finally子句。正是这种替代执行路径的世界导致了 James 抱怨的问题。 + +James 创造了一个对象。理想的情况下。对象创建不会导致潜在的异常,因此你必须抓住它们。你必须通过try-finally跟踪创建以确保清理发生(Python团队意识到清理不是一个特殊的条件,而是一个单独的问题,所以他们创建了一个不同的语言构造 - 以便停止混淆二)。任何导致异常的调用都会停止正常的执行路径并跳转(通过并行bizarro-world)到 catch 子句。 + +关于异常的一个基本假设是,我们通过在块结束时收集所有错误处理代码而不是在它们发生时处理错误来获益。在这两种情况下,我们都会停止正常执行,但是异常处理有一个自动机制,它会将你从正常的执行路径中抛出,跳转到你的并行异常世界,然后在正确的处理程序中再次弹出你。 + +跳入奇异的世界会给 James 带来问题,它为所有程序员增加了更多的工作:因为你无法知道什么时候会发生什么事(你可以随时进入奇怪的世界),你必须添加一些try块来确保没有任何东西从裂缝中滑落。您最终必须进行额外的编程以补偿异常机制(它似乎类似于补偿共享内存并发所需的额外工作)。 + +Go团队采取了大胆的举动,质疑所有这些,并说,“让我们毫无例外地尝试它,看看会发生什么。”是的,这意味着你通常会在发生错误的地方处理错误,而不是最后将它们聚集在一起尝试块。但这也意味着关于一件事的代码是本地化的,也许这并不是那么糟糕。这也可能意味着您无法轻松组合常见的错误处理代码(除非您确定了常用代码并将其放入函数中,也不是那么糟糕)。但这绝对意味着您不必担心有多个可能的执行路径而且所有这些都需要。 -
\ No newline at end of file +
diff --git a/docs/book/17-Files.md b/docs/book/17-Files.md index 688d4bf..82e785c 100644 --- a/docs/book/17-Files.md +++ b/docs/book/17-Files.md @@ -2,23 +2,639 @@ # 第十七章 文件 +在丑陋的Java I/O编程方式诞生多年以后,Java终于简化了文件读写的基本操作。这种"困难方式"的全部细节都在[Appendix: I/O Streams]()。如果你读过这个部分,就会认同Java设计者毫不在意他们的使用者的体验这一观念。打开并读取文件对于大多数编程语言来是非常常用的,由于I/O糟糕的设计以至于 +很少有人能够在不依赖其他参考代码的情况下完成打开文件的操作。 +好像Java设计者终于意识到了Java使用者多年来的痛苦,在Java7中对此引入了巨大的改进。这些新元素被放在**java.nio.file**包下面,过去人们通常把**nio**中的**n**理解为**new**即新的**io**,现在更应该当成是**non-blocking**非阻塞**io**(**io**就是*input/output输入/输出*)。**java.nio.file**库终于将Java文件操作带到与其他编程语言相同的水平。最重要的是Java8新增的streams与文件结合使得文件操作编程变得更加优雅。我们将看一下文件操作的两个基本组件: +1. 文件或者目录的路径; +2. 文件本身。 ## 文件和目录路径 +### `Paths` +一个**Path**对象表示一个文件或者目录的路径,是一个跨操作系统(OS)和文件系统的抽象,目的是在构造路径时不必关注底层操作系统,代码可以在不进行修改的情况下运行在不同的操作系统上。**java.nio.file.Paths**类包含一个重载方法**static get()**,该方法方法接受一系列**Strings**字符串或一个*统一资源标识符*(URI)作为参数,并且进行转换返回一个**Path**对象: +```java +// files/PathInfo.java +import java.nio.file.*; +import java.net.URI; +import java.io.File; +import java.io.IOException; +public class PathInfo { + static void show(String id, Object p) { + System.out.println(id + ": " + p); + } + + static void info(Path p) { + show("toString", p); + show("Exists", Files.exists(p)); + show("RegularFile", Files.isRegularFile(p)); + show("Directory", Files.isDirectory(p)); + show("Absolute", p.isAbsolute()); + show("FileName", p.getFileName()); + show("Parent", p.getParent()); + show("Root", p.getRoot()); + System.out.println("******************"); + } + public static void main(String[] args) { + System.out.println(System.getProperty("os.name")); + info(Paths.get("C:", "path", "to", "nowhere", "NoFile.txt")); + Path p = Paths.get("PathInfo.java"); + info(p); + Path ap = p.toAbsolutePath(); + info(ap); + info(ap.getParent()); + try { + info(p.toRealPath()); + } catch(IOException e) { + System.out.println(e); + } + URI u = p.toUri(); + System.out.println("URI: " + u); + Path puri = Paths.get(u); + System.out.println(Files.exists(puri)); + File f = ap.toFile(); // Don't be fooled + } +} + +/* 输出: +Windows 10 +toString: C:\path\to\nowhere\NoFile.txt +Exists: false +RegularFile: false +Directory: false +Absolute: true +FileName: NoFile.txt +Parent: C:\path\to\nowhere +Root: C:\ +****************** +toString: PathInfo.java +Exists: true +RegularFile: true +Directory: false +Absolute: false +FileName: PathInfo.java +Parent: null +Root: null +****************** +toString: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\files\PathInfo.java +Exists: true +RegularFile: true +Directory: false +Absolute: true +FileName: PathInfo.java +Parent: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\files +Root: C:\ +****************** +toString: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\files +Exists: true +RegularFile: false +Directory: true +Absolute: true +FileName: files +Parent: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples +Root: C:\ +****************** +toString: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\files\PathInfo.java +Exists: true +RegularFile: true +Directory: false +Absolute: true +FileName: PathInfo.java +Parent: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\files +Root: C:\ +****************** +URI: file:///C:/Users/Bruce/Documents/GitHub/onjava/ +ExtractedExamples/files/PathInfo.java +true +*/ +``` + +我已经在这一章第一个程序的**main()**方法添加了第一行用于展示操作系统的名称,因此你可以看到不同操作系统之间存在哪些差异。理想情况下,差别会相对较小,并且使用**/**或者**\\**路径分隔符进行分隔。你可以看到我运行在Windows 10上的程序输出。 + +当**toString()**方法生成完整形式的路径,你可以看到**getFileName()** 方法总是返回当前文件名。 +通过使用**Files**工具类(我们接下类将会更多的使用它),可以测试一个文件是否存在,测试是否是一个"真正"的文件还是一个目录等等。"Nofile.txt"这个示例展示我们描述的文件可能并不在指定的位置;这样可以允许你创建一个新的路径。"PathInfo.java"存在于当前目录中,最初它只是没有路径的文件名,但它仍然被检测为"存在"。一旦我们将其转换为绝对路径,我们将会得到一个从"C:"盘(因为我们是在Windows机器下进行测试)开始的完整路径,现在它也拥有一个父路径。“真实”路径的定义在文档中有点模糊,因为它取决于具体的文件系统。例如,如果文件名不区分大小写,即使路径由于大小写的缘故而不是完全相同,也可能得到肯定的匹配结果。在这样的平台上,**toRealPath()** 将返回实际情况下的**Path**,并且还会删除任何冗余元素。 + +这里你会看到**URI**看起来只能用于描述文件,实际上**URI**可以用于描述更多的东西;通过[维基百科]()可以了解更多细节。现在我们成功地将**URI**转为一个**Path**对象。 + +最后,你会在**Path**中看到一些有点欺骗的东西,这就是调用**toFile()**方法会生成一个**File**对象。听起来似乎可以得到一个类似文件的东西(毕竟被称为**File**),但是这个方法的存在仅仅是为了向后兼容。虽然看上去应该被称为"路径",实际上却应该表示目录或者文件本身。这是个非常草率并且令人困惑的命名,但是由于**java.nio.file**的存在我们可以安全的忽略它的存在。 + +### 选取路径部分片段 +**Path**对象可以非常容易的生成路径的某一部分: + +```java +// files/PartsOfPaths.java +import java.nio.file.*; + +public class PartsOfPaths { + public static void main(String[] args) { + System.out.println(System.getProperty("os.name")); + Path p = Paths.get("PartsOfPaths.java").toAbsolutePath(); + for(int i = 0; i < p.getNameCount(); i++) + System.out.println(p.getName(i)); + System.out.println("ends with '.java': " + + p.endsWith(".java")); + for(Path pp : p) { + System.out.print(pp + ": "); + System.out.print(p.startsWith(pp) + " : "); + System.out.println(p.endsWith(pp)); + } + System.out.println("Starts with " + p.getRoot() + " " + p.startsWith(p.getRoot())); + } +} + +/* 输出: +Windows 10 +Users +Bruce +Documents +GitHub +on-java +ExtractedExamples +files +PartsOfPaths.java +ends with '.java': false +Users: false : false +Bruce: false : false +Documents: false : false +GitHub: false : false +on-java: false : false +ExtractedExamples: false : false +files: false : false +PartsOfPaths.java: false : true +Starts with C:\ true +*/ + +``` +可以通过**getName()**来索引**Path**的各个部分,直到达到上限**getNameCount()**。**Path**也继承了**Iterable**接口,因此我们也可以通过增强的for循环进行遍历。请注意,即使路径以 **.java**结尾,使用**endsWith()** 方法也会返回**false**。这是因为使用**endsWith()** 比较的是整个路径部分,而不会包含文件路径的后缀。通过使用**startsWith()** 和**endsWith()**也可以完成路径的遍历。但是我们可以看到,遍历**Path**对象并不包含根路径,只有使用 +**startsWith()**检测根路径时才会返回**true**。 + +### 路径分析 +**Files**工具类包含一系列完整的方法用于获得**Path**相关的信息。 +```java +// files/PathAnalysis.java +import java.nio.file.*; +import java.io.IOException; + +public class PathAnalysis { + static void say(String id, Object result) { + System.out.print(id + ": "); + System.out.println(result); + } + + public static void main(String[] args) throws IOException { + System.out.println(System.getProperty("os.name")); + Path p = Paths.get("PathAnalysis.java").toAbsolutePath(); + say("Exists", Files.exists(p)); + say("Directory", Files.isDirectory(p)); + say("Executable", Files.isExecutable(p)); + say("Readable", Files.isReadable(p)); + say("RegularFile", Files.isRegularFile(p)); + say("Writable", Files.isWritable(p)); + say("notExists", Files.notExists(p)); + say("Hidden", Files.isHidden(p)); + say("size", Files.size(p)); + say("FileStore", Files.getFileStore(p)); + say("LastModified: ", Files.getLastModifiedTime(p)); + say("Owner", Files.getOwner(p)); + say("ContentType", Files.probeContentType(p)); + say("SymbolicLink", Files.isSymbolicLink(p)); + if(Files.isSymbolicLink(p)) + say("SymbolicLink", Files.readSymbolicLink(p)); + if(FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) + say("PosixFilePermissions", + Files.getPosixFilePermissions(p)); + } +} + +/* 输出: +Windows 10 +Exists: true +Directory: false +Executable: true +Readable: true +RegularFile: true +Writable: true +notExists: false +Hidden: false +size: 1631 +FileStore: SSD (C:) +LastModified: : 2017-05-09T12:07:00.428366Z +Owner: MINDVIEWTOSHIBA\Bruce (User) +ContentType: null +SymbolicLink: false +*/ +``` +在调用最后一个测试方法**getPosixFilePermissions()** 之前我们需要确认一下当前文件系统是否支持**Posix**接口,否则会抛出运行时异常。 + +### **Paths**的增减修改 +我们必须能通过对**Path**对象增加或者删除一部分来构造一个新的**Path**对象。我们使用**relativize()**构造一个路径与给定路径的相对路径,使用**resolve()**解析为一个新的**Path**对象(不一定是“可发现”的名称)。对于下面代码中的示例,我使用**relativize()** 方法从所有的输出中移除根路径,部分原因是为了示范,部分原因是为了简化输出结果,这说明你可以使用该方法将绝对路径转为相对路径。 +这个版本的代码中包含**id**,以便于跟踪输出结果: + +```java +// files/AddAndSubtractPaths.java +import java.nio.file.*; +import java.io.IOException; + +public class AddAndSubtractPaths { + static Path base = Paths.get("..", "..", "..").toAbsolutePath().normalize(); + + static void show(int id, Path result) { + if(result.isAbsolute()) + System.out.println("(" + id + ")r " + base.relativize(result)); + else + System.out.println("(" + id + ") " + result); + try { + System.out.println("RealPath: " + result.toRealPath()); + } catch(IOException e) { + System.out.println(e); + } + } + + public static void main(String[] args) { + System.out.println(System.getProperty("os.name")); + System.out.println(base); + Path p = Paths.get("AddAndSubtractPaths.java").toAbsolutePath(); + show(1, p); + Path convoluted = p.getParent().getParent() + .resolve("strings").resolve("..") + .resolve(p.getParent().getFileName()); + show(2, convoluted); + show(3, convoluted.normalize()); + Path p2 = Paths.get("..", ".."); + show(4, p2); + show(5, p2.normalize()); + show(6, p2.toAbsolutePath().normalize()); + Path p3 = Paths.get(".").toAbsolutePath(); + Path p4 = p3.resolve(p2); + show(7, p4); + show(8, p4.normalize()); + Path p5 = Paths.get("").toAbsolutePath(); + show(9, p5); + show(10, p5.resolveSibling("strings")); + show(11, Paths.get("nonexistent")); + } +} + +/* 输出: +Windows 10 +C:\Users\Bruce\Documents\GitHub +(1)r onjava\ +ExtractedExamples\files\AddAndSubtractPaths.java +RealPath: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\files\AddAndSubtractPaths.java +(2)r on-java\ExtractedExamples\strings\..\files +RealPath: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\files +(3)r on-java\ExtractedExamples\files +RealPath: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\files +(4) ..\.. +RealPath: C:\Users\Bruce\Documents\GitHub\on-java +(5) ..\.. +RealPath: C:\Users\Bruce\Documents\GitHub\on-java +(6)r on-java +RealPath: C:\Users\Bruce\Documents\GitHub\on-java +(7)r on-java\ExtractedExamples\files\.\..\.. +RealPath: C:\Users\Bruce\Documents\GitHub\on-java +(8)r on-java +RealPath: C:\Users\Bruce\Documents\GitHub\on-java +(9)r on-java\ExtractedExamples\files +RealPath: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\files +(10)r on-java\ExtractedExamples\strings +RealPath: C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\strings +(11) nonexistent +java.nio.file.NoSuchFileException: +C:\Users\Bruce\Documents\GitHub\onjava\ +ExtractedExamples\files\nonexistent +*/ +``` +我还为**toRealPath()** 添加了进一步的测试,这是为了扩展和规则化,防止路径不存在以免产生运行时异常。 + + ## 目录 +**Files**工具类类包含大部分我们需要的目录操作和文件操作方法。出于某种原因,它们没有包含删除目录树相关的方法,因此我们将实现并将其添加到**onjava**库中。 +```java +// onjava/RmDir.java +package onjava; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.io.IOException; + +public class RmDir { + public static void rmdir(Path dir) throws IOException { + Files.walkFileTree(dir, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } +} +``` +删除目录树的方法实现依赖于**Files.walkFileTree()**,"walking"意味着遍历整个子目录和文件。*Visitor*设计模式提供了一种标准机制来访问集合中的某个对象,然后你需要提供在每个对象上执行的操作。 +此操作的定义取决于实现的**FileVisitor**的四个抽象方法,包括: + 1. **preVisitDirectory()**:在访问目录中条目之前在目录上运行。 + 2. **visitFile()**:运行目录中的每一个文件。 + 3. **visitFileFailed()**:调用无法访问的文件。 + 4. **postVisitDirectory()**:在访问目录中条目之后在目录上运行,包括所有的子目录。 + +为了简化,**java.nio.file.SimpleFileVisitor** 提供了所有方法的默认实现。这样,在我们的匿名内部类中,我们只需要重写非标准行为的方法:**visitFile()** 和**postVisitDirectory()** 实现删除文件和删除目录。两者都应该返回标志位决定是否继续访问(这样就可以继续访问,直到找到所需要的)。 + +作为探索目录操作的一部分,现在我们可以有条件地删除已存在的目录。在以下例子中,**makeVariant()** 接受基本目录测试,并通过旋转部件列表生成不同的子目录路径。这些旋转与路径分隔符粘**sep** 使用**String.join()**贴在一起,然后返回一个**Path**对象。 + +```java +// files/Directories.java +import java.util.*; +import java.nio.file.*; +import onjava.RmDir; + +public class Directories { + static Path test = Paths.get("test"); + static String sep = FileSystems.getDefault().getSeparator(); + static List parts = Arrays.asList("foo", "bar", "baz", "bag"); + + static Path makeVariant() { + Collections.rotate(parts, 1); + return Paths.get("test", String.join(sep, parts)); + } + + static void refreshTestDir() throws Exception { + if(Files.exists(test)) + RmDir.rmdir(test); + if(!Files.exists(test)) + Files.createDirectory(test); + } + + public static void main(String[] args) throws Exception { + refreshTestDir(); + Files.createFile(test.resolve("Hello.txt")); + Path variant = makeVariant(); + // Throws exception (too many levels): + try { + Files.createDirectory(variant); + } catch(Exception e) { + System.out.println("Nope, that doesn't work."); + } + populateTestDir(); + Path tempdir = Files.createTempDirectory(test, "DIR_"); + Files.createTempFile(tempdir, "pre", ".non"); + Files.newDirectoryStream(test).forEach(System.out::println); + System.out.println("*********"); + Files.walk(test).forEach(System.out::println); + } + + static void populateTestDir() throws Exception { + for(int i = 0; i < parts.size(); i++) { + Path variant = makeVariant(); + if(!Files.exists(variant)) { + Files.createDirectories(variant); + Files.copy(Paths.get("Directories.java"), + variant.resolve("File.txt")); + Files.createTempFile(variant, null, null); + } + } + } +} + +/* 输出: +Nope, that doesn't work. +test\bag +test\bar +test\baz +test\DIR_5142667942049986036 +test\foo +test\Hello.txt +********* +test +test\bag +test\bag\foo +test\bag\foo\bar +test\bag\foo\bar\baz +test\bag\foo\bar\baz\8279660869874696036.tmp +test\bag\foo\bar\baz\File.txt +test\bar +test\bar\baz +test\bar\baz\bag +test\bar\baz\bag\foo +test\bar\baz\bag\foo\1274043134240426261.tmp +test\bar\baz\bag\foo\File.txt +test\baz +test\baz\bag +test\baz\bag\foo +test\baz\bag\foo\bar +test\baz\bag\foo\bar\6130572530014544105.tmp +test\baz\bag\foo\bar\File.txt +test\DIR_5142667942049986036 +test\DIR_5142667942049986036\pre7704286843227113253.non +test\foo +test\foo\bar +test\foo\bar\baz +test\foo\bar\baz\bag +test\foo\bar\baz\bag\5412864507741775436.tmp +test\foo\bar\baz\bag\File.txt +test\Hello.txt +*/ +``` +首先,**refreshTestDir()**用于检测**test**目录是否已经存在。果是这样,则使用我们新工具类**rmdir()** 删除其整个目录。检查是否**exists**是多余的,但我想说明一点,因为如果你对于已经存在的目录调用**createDirectory()** 将会抛出异常。**createFile()** 使用参数**Path** 创建一个空文件; **resolve()** 将文件名添加到测试路径的末尾。 + +我们尝试使用**createDirectory()** 来创建多级路径,但是这样会抛出异常,因为这个方法只能创建单级路劲。我已经将**populateTestDir()**作为一个单独的方法,因为它将在后面的例子中被重用。对于每一个变量**variant**,我们都能使用**createDirectories()** 创建完整的目录路径,然后使用此文件的副本以不同的目标名称填充该终端目录。然后我们使用**createTempFile()** 生成一个临时文件。 + +在调用**populateTestDir()** 之后,我们在**test**目录下面下面创建一个临时目录。请注意,**createTempDirectory()** 只有名称的前缀选项。与**createTempFile()** 不同,我们再次使用它将临时文件放入新的临时目录中。你可以从输出中看到,如果未指定后缀,将默认使用".tmp"作为后缀。 + +为了展示结果,我们首先使用看起来很有可能的**newDirectoryStream()**,但事实证明这个方法只是返回**test** 目录内容的Stream流,并没有更多的内容。要获取目录树的全部内容的流,请使用**Files.walk()**。 - ## 文件系统 +为了完整起见,我们需要一种方法查找有关文件系统的其他信息。在这里,我们使用ileSystems工具类获取"默认"文件系统,但你同样也可以在**Path**对象上调用**getFileSystem()** 以获取创建该**Path** 的文件系统。你可以获得给定*URI*的文件系统,还可以构建新的文件系统(对于支持它的操作系统)。 +```java +// files/FileSystemDemo.java +import java.nio.file.*; +public class FileSystemDemo { + static void show(String id, Object o) { + System.out.println(id + ": " + o); + } + + public static void main(String[] args) { + System.out.println(System.getProperty("os.name")); + FileSystem fsys = FileSystems.getDefault(); + for(FileStore fs : fsys.getFileStores()) + show("File Store", fs); + for(Path rd : fsys.getRootDirectories()) + show("Root Directory", rd); + show("Separator", fsys.getSeparator()); + show("UserPrincipalLookupService", + fsys.getUserPrincipalLookupService()); + show("isOpen", fsys.isOpen()); + show("isReadOnly", fsys.isReadOnly()); + show("FileSystemProvider", fsys.provider()); + show("File Attribute Views", + fsys.supportedFileAttributeViews()); + } +} +/* 输出: +Windows 10 +File Store: SSD (C:) +Root Directory: C:\ +Root Directory: D:\ +Separator: \ +UserPrincipalLookupService: +sun.nio.fs.WindowsFileSystem$LookupService$1@15db9742 +isOpen: true +isReadOnly: false +FileSystemProvider: +sun.nio.fs.WindowsFileSystemProvider@6d06d69c +File Attribute Views: [owner, dos, acl, basic, user] +*/ +``` +一个**FileSystem**对象也能生成**WatchService** 和**WatchService** 对象,将会在接下来两章中详细讲解。 ## 路径监听 +通过**WatchService** 可以设置一个进程对目录中的更改做出响应。在这个例子中,**delTxtFiles()** 作为一个单独的任务执行,该任务将遍历整个目录并删除以 **.txt** 结尾的所有文件,**WatchService** 会对文件删除操作做出反应: +```java +// files/PathWatcher.java +// {ExcludeFromGradle} +import java.io.IOException; +import java.nio.file.*; +import static java.nio.file.StandardWatchEventKinds.*; +import java.util.concurrent.*; + +public class PathWatcher { + static Path test = Paths.get("test"); + + static void delTxtFiles() { + try { + Files.walk(test) + .filter(f -> + f.toString() + .endsWith(".txt")) + .forEach(f -> { + try { + System.out.println("deleting " + f); + Files.delete(f); + } catch(IOException e) { + throw new RuntimeException(e); + } + }); + } catch(IOException e) { + throw new RuntimeException(e); + } + } + + public static void main(String[] args) throws Exception { + Directories.refreshTestDir(); + Directories.populateTestDir(); + Files.createFile(test.resolve("Hello.txt")); + WatchService watcher = FileSystems.getDefault().newWatchService(); + test.register(watcher, ENTRY_DELETE); + Executors.newSingleThreadScheduledExecutor() + .schedule(PathWatcher::delTxtFiles, + 250, TimeUnit.MILLISECONDS); + WatchKey key = watcher.take(); + for(WatchEvent evt : key.pollEvents()) { + System.out.println("evt.context(): " + evt.context() + + "\nevt.count(): " + evt.count() + + "\nevt.kind(): " + evt.kind()); + System.exit(0); + } + } +} +/* Output: +deleting test\bag\foo\bar\baz\File.txt +deleting test\bar\baz\bag\foo\File.txt +deleting test\baz\bag\foo\bar\File.txt +deleting test\foo\bar\baz\bag\File.txt +deleting test\Hello.txt +evt.context(): Hello.txt +evt.count(): 1 +evt.kind(): ENTRY_DELETE +*/ +``` + +**delTxtFiles()** 中的**try** 代码块看起来有些多余,因为它们捕获的是同一种类型的异常,外部的**try** 语句似乎已经足够了。然而出于某种原因,Java要求两者都必须存在(这也可能是一个bug)。还要注意的是在**filter()** 中,我们必须显式地使用**f.toString()** 转为字符串,否则我们调用**endsWith()** 将会与整个**Path**对象进行比较,而不是路径名称字符串的一部分进行比较。 + +一旦我们从**FileSystem** 中得到了**WatchService** 对象,我们将其注册到**tets** 路径以及我们感兴趣的项目的变量参数列表中,可以选择**ENTRY_CREATE**,**ENTRY_DELETE** 或**ENTRY_MODIFY**(其中创建和删除不属于修改)。 + +因为接下来对**watcher.take()** 的调用会在发生某些事情之前停止所有操作,所以我们希望**deltxtfiles()** 能够并行运行以便生成我们感兴趣的事件。为了实现这个目的,我通过调用**Executors.newSingleThreadScheduledExecutor()**产生一个**ScheduledExecutorService** 对象,然后调用**schedule()** 方法传递所需函数的方法引用,设置以及在运行之前应该等待的时间。 + +此时,**watcher.take()** 将等待并阻塞在这里。当目标事件发生时,会返回一个包含**WatchEvent**的**Watchkey**对象。展示的这三种方法是对**WatchEvent** 执行的全部操作。 + +查看输出的具体内容。即使我们正在删除以 **.txt** 结尾的文件,在**hello.txt** 被删除之前,**WatchService** 也不会被触发。你可能认为,如果说"监视这个目录",自然会包含整个目录和下面子目录,但实际上的:只会监视给定的目录,而不是下面的所有内容。如果需要监视整个树目录,必须在整个树的每个子目录上放置一个**Watchservice**。 + +```java +// files/TreeWatcher.java +// {ExcludeFromGradle} +import java.io.IOException; +import java.nio.file.*; +import static java.nio.file.StandardWatchEventKinds.*; +import java.util.concurrent.*; + +public class TreeWatcher { + + static void watchDir(Path dir) { + try { + WatchService watcher = + FileSystems.getDefault().newWatchService(); + dir.register(watcher, ENTRY_DELETE); + Executors.newSingleThreadExecutor().submit(() -> { + try { + WatchKey key = watcher.take(); + for(WatchEvent evt : key.pollEvents()) { + System.out.println( + "evt.context(): " + evt.context() + + "\nevt.count(): " + evt.count() + + "\nevt.kind(): " + evt.kind()); + System.exit(0); + } + } catch(InterruptedException e) { + return; + } + }); + } catch(IOException e) { + throw new RuntimeException(e); + } + } + + public static void main(String[] args) throws Exception { + Directories.refreshTestDir(); + Directories.populateTestDir(); + Files.walk(Paths.get("test")) + .filter(Files::isDirectory) + .forEach(TreeWatcher::watchDir); + PathWatcher.delTxtFiles(); + } +} + +/* Output: +deleting test\bag\foo\bar\baz\File.txt +deleting test\bar\baz\bag\foo\File.txt +evt.context(): File.txt +evt.count(): 1 +evt.kind(): ENTRY_DELETE +*/ +``` + +在**watchDir()** 方法中给**WatchSevice**提供参数**ENTRY_DELETE**,并启动一个独立的线程来监视该**atchserviceW**。这里我们没有使用**schedule()** 进行启动,而是使用**submit()** 启动线程。我们遍历整个目录树,并将**watchDir()** 应用于每个子目录。现在,当我们运行**deltxtfiles()** 时,其中一个**Watchservice**s 会检测到每一次文件删除。 ## 文件查找 diff --git a/docs/book/19-Type-Information.md b/docs/book/19-Type-Information.md index 5992b3c..3cdbfe2 100644 --- a/docs/book/19-Type-Information.md +++ b/docs/book/19-Type-Information.md @@ -70,7 +70,7 @@ Triangle.draw() 基类中包含 `draw()` 方法,它通过传递 `this` 参数传递给 `System.out.println()`,间接地使用 `toString()` 打印类标识符(注意:这里将 `toString()` 声明为了 `abstract`,以此强制继承者覆盖改方法,并防止对 `Shape` 的实例化)。如果某个对象出现在字符串表达式中(涉及"+"和字符串对象的表达式),`toString()` 方法就会被自动调用,以生成表示该对象的 `String`。每个派生类都要覆盖(从 `Object` 继承来的)`toString()` 方法,这样 `draw()` 在不同情况下就打印出不同的消息(多态)。 -这个例子中,在把 `Shape` 对象放入 `Stream` 中时就会进行向上转型(隐式),但在向上转型的时候也丢失了这些对象的具体类型。对 `steam` 而言,它们只是 `Shape` 对象。 +这个例子中,在把 `Shape` 对象放入 `Stream` 中时就会进行向上转型(隐式),但在向上转型的时候也丢失了这些对象的具体类型。对 `stream` 而言,它们只是 `Shape` 对象。 严格来说,`Stream` 实际上是把放入其中的所有对象都当做 `Object` 对象来持有,只是取元素时会自动将其类型转为 `Shape`。这也是 RTTI 最基本的使用形式,因为在 Java 中,所有类型转换的正确性检查都是在运行时进行的。这也正是 RTTI 的含义所在:在运行时,识别一个对象的类型。 @@ -551,7 +551,305 @@ public class ClassCasts { Java类库中另一个没有任何用处的特性就是 `Class.asSubclass()`,该方法允许你将一个 `Class` 对象转型为更加具体的类型。 - +## 类型转换前先做检查 + +直到现在,我们已知的RTTI类型包括: + +1. 传统的类型转换,如 “`(Shape)`”,由RTTI确保转换的正确性,如果执行了一个错误的类型转换,就会抛出一个 `ClassCastException` 异常。 + +2. 代表对象类型的 `Class` 对象. 通过查询 `Class` 对象可以获取运行时所需的信息. + +在C++中,经典的类型转换 “`(Shape)`” 并不使用 RTTI. 它只是简单地告诉编译器将这个对象作为新的类型对待. 而 Java 会进行类型检查,这种类型转换一般被称作“类型安全的向下转型”。之所以称作“向下转型”,是因为传统上类继承图是这么画的。将 `Circle` 转换为 `Shape` 是一次向上转型, 将 `Shape` 转换为 `Circle` 是一次向下转型。但是, 因为我们知道 `Circle` 肯定是一个 `Shape`,所以编译器允许我们自由地做向上转型的赋值操作,且不需要任何显示的转型操作。当你给编译器一个 `Shape` 的时候,编译器并不知道它到底是什么类型的 `Shape`——它可能是 `Shape`,也可能是 `Shape` 的子类型,例如 `Circle`、`Square`、`Triangle` 或某种其他的类型。在编译期,编译器只能知道它是 `Shape`。因此,你需要使用显式的类型转换,以告知编译器你想转换的特定类型,否则编译器就不允许你执行向下转型赋值。 (编译器将会检查向下转型是否合理,因此它不允许向下转型到实际上不是待转型类型的子类的类型上)。 + +RTTI 在 Java 中还有第三种形式,那就是关键字 `instanceof`。它返回一个布尔值,告诉我们对象是不是某个特定类型的实例,可以用提问的方式使用它,就像这个样子: + +```java +if(x instanceof Dog) + ((Dog)x).bark(); +``` + +在将 `x` 转型为 `Dog` 之前,`if` 语句会先检查 `x` 是否是 `Dog` 类型的对象。进行向下转型前,如果没有其他信息可以告诉你这个对象是什么类型,那么使用 `instanceof` 是非常重要的,否则会得到一个 `ClassCastException` 异常。 + +一般,可能想要查找某种类型(比如要找三角形,并填充为紫色),这时可以轻松地使用 `instanceof` 来计数所有对象。举个例子,假如你有一个类的继承体系,描述了 `Pet`(以及它们的主人,在后面一个例子中会用到这个特性)。在这个继承体系中的每个 `Individual` 都有一个 `id` 和一个可选的名字。尽管下面的类都继承自 `Individual`,但是 `Individual` 类复杂性较高,因此其代码将放在[附录:容器](./Appendix-Collection-Topics)中进行解释说明。正如你所看到的,此处并不需要去了解 `Individual` 的代码——你只需了解你可以创建其具名或不具名的对象,并且每个 `Individual` 都有一个 `id()` 方法,如果你没有为 `Individual` 提供名字,`toString()` 方法只产生类型名。 + +下面是继承自 `Individual` 的类的继承体系: + +```java +// typeinfo/pets/Person.java +package typeinfo.pets; + +public class Person extends Individual { + public Person(String name) { super(name); } +} +``` + +```java +// typeinfo/pets/Pet.java +package typeinfo.pets; + +public class Pet extends Individual { + public Pet(String name) { super(name); } + public Pet() { super(); } +} +``` + +```java +// typeinfo/pets/Dog.java +package typeinfo.pets; + +public class Dog extends Pet { + public Dog(String name) { super(name); } + public Dog() { super(); } +} +``` + +```java +// typeinfo/pets/Mutt.java +package typeinfo.pets; + +public class Mutt extends Dog { + public Mutt(String name) { super(name); } + public Mutt() { super(); } +} +``` + + +```java +// typeinfo/pets/Pug.java +package typeinfo.pets; + +public class Pug extends Dog { + public Pug(String name) { super(name); } + public Pug() { super(); } +} +``` + +```java +// typeinfo/pets/Cat.java +package typeinfo.pets; + +public class Cat extends Pet { + public Cat(String name) { super(name); } + public Cat() { super(); } +} +``` + +```java +// typeinfo/pets/EgyptianMau.java +package typeinfo.pets; + +public class EgyptianMau extends Cat { + public EgyptianMau(String name) { super(name); } + public EgyptianMau() { super(); } +} +``` + +```java +// typeinfo/pets/Manx.java +package typeinfo.pets; + +public class Manx extends Cat { + public Manx(String name) { super(name); } + public Manx() { super(); } +} +``` + +```java +// typeinfo/pets/Cymric.java +package typeinfo.pets; + +public class Cymric extends Manx { + public Cymric(String name) { super(name); } + public Cymric() { super(); } +} +``` + +```java +// typeinfo/pets/Rodent.java +package typeinfo.pets; + +public class Rodent extends Pet { + public Rodent(String name) { super(name); } + public Rodent() { super(); } +} +``` + +```java +// typeinfo/pets/Rat.java +package typeinfo.pets; + +public class Rat extends Rodent { + public Rat(String name) { super(name); } + public Rat() { super(); } +} +``` + +```java +// typeinfo/pets/Mouse.java +package typeinfo.pets; + +public class Mouse extends Rodent { + public Mouse(String name) { super(name); } + public Mouse() { super(); } +} +``` + +```java +// typeinfo/pets/Hamster.java +package typeinfo.pets; + +public class Hamster extends Rodent { + public Hamster(String name) { super(name); } + public Hamster() { super(); } +} +``` + +我们必须显式地为每一个子类编写无参构造器。因为我们有一个带一个参数的构造器,所以编译器不会自动地为我们加上无参构造器。 + +接下来,我们需要一个类,它可以随机地创建不同类型的宠物,同时,它还可以创建宠物数组和持有宠物的 `List`。为了这个类更加普遍适用,我们将其定义为抽象类: + +```java +// typeinfo/pets/PetCreator.java +// Creates random sequences of Pets +package typeinfo.pets; +import java.util.*; +import java.util.function.*; + +public abstract +class PetCreator implements Supplier { + private Random rand = new Random(47); + // The List of the different types of Pet to create: + public abstract List> types(); + public Pet get() { // Create one random Pet + int n = rand.nextInt(types().size()); + try { + return types().get(n).newInstance(); + } catch(InstantiationException | + IllegalAccessException e) { + throw new RuntimeException(e); + } + } +} +``` + +抽象的 `types()` 方法需要子类来实现,以此来获取 `Class` 对象构成的 `List`(这是模板方法设计模式的一种变体)。注意,其中类的类型被定义为“任何从 `Pet` 导出的类型”,因此 `newInstance()` 不需要转型就可以产生 `Pet`。`get()` 随机的选取出一个 `Class` 对象,然后可以通过 `Class.newInstance()` 来生成该类的新实例。 + +在调用 `newInstance()` 时,可能会出现两种异常。在紧跟 `try` 语句块后面的 `catch` 子句中可以看到对它们的处理。异常的名字再次成为了一种对错误类型相对比较有用的解释(`IllegalAccessException` 违反了 Java 安全机制,在本例中,表示默认构造器为 `private` 的情况)。 + +当你导出 `PetCreator` 的子类时,你需要为 `get()` 方法提供 `Pet` 类型的 `List`。`types()` 方法会简单地返回一个静态 `List` 的引用。下面是使用 `forName()` 的一个具体实现: + +```java +// typeinfo/pets/ForNameCreator.java +package typeinfo.pets; +import java.util.*; + +public class ForNameCreator extends PetCreator { + private static List> types = + new ArrayList<>(); + // Types you want randomly created: + private static String[] typeNames = { + "typeinfo.pets.Mutt", + "typeinfo.pets.Pug", + "typeinfo.pets.EgyptianMau", + "typeinfo.pets.Manx", + "typeinfo.pets.Cymric", + "typeinfo.pets.Rat", + "typeinfo.pets.Mouse", + "typeinfo.pets.Hamster" + }; + @SuppressWarnings("unchecked") + private static void loader() { + try { + for(String name : typeNames) + types.add( + (Class)Class.forName(name)); + } catch(ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + static { loader(); } + @Override + public List> types() { + return types; + } +} +``` + +`loader()` 方法使用 `Class.forName()` 创建了 `Class` 对象的 `List`。这可能会导致 `ClassNotFoundException`,因为你传入的是一个 `String`,它不能再编译期间被确认是否合理。由于 `Pet` 相关的文件在 `typeinfo` 包里面,所以使用它们的时候需要填写完整的包名。 + +为了使得 `List` 装入的是具体的 `Class` 对象,转型是必须的,它会产生一个编译时警告。`loader()` 方法是分开编写的,然后它被放入到一个静态代码块里,因为 `@SuppressWarning` 注解不能够直接放置在静态代码块之上。 + +为了对 `Pet` 进行计数,我们需要一个能跟踪不同类型的 `Pet` 的工具。`Map` 的是这个需求的首选,我们将 `Pet` 类型名作为键,将保存 `Pet` 数量的 `Integer` 作为值。通过这种方式,你就看可以询问:“有多少个 `Hamster` 对象?”我们可以使用 `instanceof` 来对 `Pet` 进行计数: + +```java +// typeinfo/PetCount.java +// Using instanceof +import typeinfo.pets.*; +import java.util.*; + +public class PetCount { + static class Counter extends HashMap { + public void count(String type) { + Integer quantity = get(type); + if(quantity == null) + put(type, 1); + else + put(type, quantity + 1); + } + } + public static void + countPets(PetCreator creator) { + Counter counter = new Counter(); + for(Pet pet : Pets.array(20)) { + // List each individual pet: + System.out.print( + pet.getClass().getSimpleName() + " "); + if(pet instanceof Pet) + counter.count("Pet"); + if(pet instanceof Dog) + counter.count("Dog"); + if(pet instanceof Mutt) + counter.count("Mutt"); + if(pet instanceof Pug) + counter.count("Pug"); + if(pet instanceof Cat) + counter.count("Cat"); + if(pet instanceof EgyptianMau) + counter.count("EgyptianMau"); + if(pet instanceof Manx) + counter.count("Manx"); + if(pet instanceof Cymric) + counter.count("Cymric"); + if(pet instanceof Rodent) + counter.count("Rodent"); + if(pet instanceof Rat) + counter.count("Rat"); + if(pet instanceof Mouse) + counter.count("Mouse"); + if(pet instanceof Hamster) + counter.count("Hamster"); + } + // Show the counts: + System.out.println(); + System.out.println(counter); + } + public static void main(String[] args) { + countPets(new ForNameCreator()); + } +} +/* Output: +Rat Manx Cymric Mutt Pug Cymric Pug Manx Cymric Rat +EgyptianMau Hamster EgyptianMau Mutt Mutt Cymric Mouse +Pug Mouse Cymric +{EgyptianMau=2, Pug=3, Rat=2, Cymric=5, Mouse=2, Cat=9, +Manx=7, Rodent=5, Mutt=3, Dog=6, Pet=20, Hamster=1} +*/ +``` + +在 `countPets()` 中,一个简短的静态方法 `Pets.array()` 生产出了一个随机动物的集合。每个 `Pet` 都被 `instanceof` 检测到并数了一遍。 + +`instanceof` 有一个严格的限制:只可以将它与命名类型进行比较,而不能与 `Class` 对象作比较。在前面的例子中,你可能会觉得写出一大堆 `instanceof` 表达式很乏味,事实也是如此。但是,也没有办法让 `instanceof` 聪明起来,让它能够自动地创建一个 `Class` 对象的数组,然后将目标与这个数组中的对象逐一进行比较(稍后会看到一种替代方案)。其实这并不是那么大的限制,如果你在程序中写了大量的 `instanceof`,那就说明你的设计可能存在瑕疵。 + ## 类型转换检测 diff --git a/docs/book/23-Annotations.md b/docs/book/23-Annotations.md index ffee782..9e2774b 100644 --- a/docs/book/23-Annotations.md +++ b/docs/book/23-Annotations.md @@ -45,7 +45,7 @@ public class Testable { } ``` -被注解标注的方法和其他的方法没有任何区别。在这个例子中,注解 `@Test` 可以和任何修饰符共同用于方法,注入 **public**、**static** 或 **void**。用语法的角度上看,注解和使用方式和修饰符的使用方式一致。 +被注解标注的方法和其他的方法没有任何区别。在这个例子中,注解 `@Test` 可以和任何修饰符共同用于方法,诸如 **public**、**static** 或 **void**。从语法的角度上看,注解的使用方式和修饰符的使用方式一致。 ### 定义注解 @@ -459,7 +459,7 @@ CREATE TABLE MEMBER( 每一个你编写的注解都需要处理器,但是 **javac** 可以非常容易的将多个注解处理器合并在一起。你可以指定多个需要处理的类,并且你可以添加监听器用于监听注解处理完成后接到通知。 -本节中的示例将帮助您开始学习,但如果您必须深入学习,请做好反复学习,大量访问 Google 和StackOverflow 的准备。 +本节中的示例将帮助你开始学习,但如果你必须深入学习,请做好反复学习,大量访问 Google 和StackOverflow 的准备。 ### 最简单的处理器 @@ -1806,4 +1806,4 @@ API 的提供方和框架将会将注解作为他们工具的一部分。通过
-[^3 ]: The Java designers coyly suggest that a mirror is where you find a reflection. \ No newline at end of file +[^3 ]: The Java designers coyly suggest that a mirror is where you find a reflection. diff --git a/docs/book/Appendix-Becoming-a-Programmer.md b/docs/book/Appendix-Becoming-a-Programmer.md index 5b7e4c2..5652486 100644 --- a/docs/book/Appendix-Becoming-a-Programmer.md +++ b/docs/book/Appendix-Becoming-a-Programmer.md @@ -3,15 +3,51 @@ # 附录:成为一名程序员 +>我分别于2003,2006,2007和2009年撰写的博客文章混搭 ## 如何开始 +这是一条相当漫长和曲折的道路。我在高一学代数时(1971年),有个非常古怪的老师有一台计算机,还弄到了一台配有一个300波特的音频电话耦合器的ASR-33电传打字机,我学会了如何执行命令并得到响应,以及一个可以在高中区使用的HP-1000计算机上的帐户。我们能够创建和运行BASIC程序并将它们保存在打孔磁带上。我对此非常着迷,所以尽可能地把它带回家后在晚上写程序。我写了一个赛马模拟游戏--HOSRAC.BAS,用星号来代表马的移动,由于是在纸上打印输出,所以需要一点想象力。 + +我的朋友丹尼尔(和设计我的书的封面是同一个人)有一个兄弟,他有段时间通过向酒吧和餐馆提供弹球机来赚钱。他有一台投币式街机(老虎机),最早的《乓》游戏之一,我对此全然不知,到现在我还忍受不了这东西(现在我几乎不玩电脑游戏,这样看来我可能是个没有幽默的人,但似乎编程比玩电脑游戏更有趣、更具挑战性。) + +后来我在高中参与了摄影和新闻工作,在大学的第一年就主修新闻学。我觉得自己已经从学校学到了足够多的东西,又转修了物理学。后来我在加州大学欧文分校完成了物理学位,如果我当时选择了一个特定的工程领域,修了足够的工程课就能拿到双专业,但我试图走得更远一些,所以最后我获得的本科学位是 "应用物理"。作为一名本科生,我多多少少学习了一些可以自娱自乐,但又没有任何深度的计算机编程课程。我个人认为在这些课程细细熏陶下,帮我打下了一定的基础,但事实我理解的这些东西没有任何深度。我不知道计算机、编译器或解释器有什么区别(只是对编译器和解释器一点点的理解)。对我来说计算机是绝对可靠的,而且我从来没有想过在程序语言和操作系统中会有出现错误的可能。 + +后来我去了在加州州立理工大学攻读研究生,主要有三点原因 + +1. 我真的非常喜欢物理学这个领域 + +2. 他们接受了我,甚至给了我一份教学工作和奖学金 + +3. 出乎意料的是他们给我的工作时间不止一个夏天 + +而我完全没做好上班的准备。 + +作为一名物理专业的学生,我学习的是太阳能发电系统,当时太阳能发电系统很大 (如果你的房子上装了太阳能或生意上是关于太阳能系统,加州就会给予税收抵免,因此也兴起很多生意),加州理工大学也承诺会在工程系开设相应的课程。然而因为学校没有提供必要的课程,要想获得在太阳能工程的学位得花好几年时间。所以我学习了研究生其他的工程课,包括介绍机械,太阳能,电气和电子工程。我上的课是非电气工程专业的电气工程导论。最常见的研究生工程课程是计算机工程专业,所以最后我拿了那个学位。我还上了艺术课,几门舞蹈课,还有一些计算机科学课程 (Pascal和数据结构),在计算机工程中,我终于弄清楚了处理器的工作流程,从那以后我一直带着一个处理器在身上。这些就是我学的计算机基础知识。 + +刚开始工作的时候,凭借着一堆硬件和相对简单低水平的编程,做了一名计算机工程师。因为C语言似乎是理想的嵌入式系统语言,于是我开始自学,并慢慢开始了解更多关于编程语言的东西。我们在这家公司从源代码构建编译器,这让我大开眼界。 (想象一下一个编译器只是另一个软件的一部分!) + +当我去华盛顿大学海洋学院为Tom Keffer后来创建了“疯狗浪”)工作时,我们决定使用C++。我只有一本Stroustrup写的非初学者书可以参考,最终不得不通过检查C++预处理器生成的中间C代码来了解语言的功能。这个过程非常痛苦,但学习的效果很好。从那以后我就用相同的方式学习,因为它让我学习了如何剖析一种语言,并看到它本质的能力,与此同时开始有了批判性思维。 + +我并没有理解清楚所有的概念。只是在之后的日子里不断反复,我所知道的一切需要时间才能消化吸收。如果我现在能很容易地理解一个新概念,那只是因为它是我已经知道的积累概念的一个变种。在加州理工大学招收非计算机本科学历的计算机科学研究生项目中,学生们曾经说他们花了一年的时间才弄清楚他们对计算机的困惑(他们正在沉浸程序之中)。当人们学习计算机时,他们往往会对自己抱有不切实际的期望,通常是他们听说学计算机编程的好处,就希望在几周内找到一份高薪的工作。但是,最好的学习过程是先对计算机感兴趣,随着时间的推移,学习的越来越多,自然的就开始自学。 + +这些就是我主要做的事,尽管我通过学计算机工程有还算扎实的基础,但我没上过编程课,而是通过自学。在此期间我也在不断地学习新事物,在这个行业里,不断学习是非常重要的一部分。 ## 码农生涯 +### 延伸阅读 +* [Teach Yourself Programming In Ten Years](http://norvig.com/21-days.html), by Peter Norvig. +* [How To Be A Programmer](http://samizdat.mines.edu/howto/HowToBeAProgrammer.html), by Robert Read. +* A [speech by Steve Jobs](http://news.stanford.edu/news/2005/june15/jobs-061505.html) to inspire a group of graduating college students. +* Kathy Sierra: [Does College Matter](https://headrush.typepad.com/creating_passionate_users/2005/07/does_college_ma.html)? +* Paul Graham [on College](http://www.paulgraham.com/college.html). +* Joel Spolsky: [Advice for Computer Science College Students](https://www.joelonsoftware.com/2005/01/02/advice-for-computer-science-college-students/). +* James Shore: [Five Design Skills Every Programmer Should Have](https://www.jamesshore.com/Blog/Five-Design-Skills.html). +* Steve Yegge: [The Truth About Interviewing](http://steve-yegge.blogspot.com/2006/03/truth-about-interviewing.html). + ## 百分之五的神话 @@ -27,6 +63,12 @@ ## 做你喜欢的事 +*“1960年,一位研究人员对1500名商学院学生进行了访谈,并将他们分为两类:那些为了钱财来这里上学的人,1245人,以及那些打算利用学位做他们非常关心的事情的人,255人。二十年后,研究人员再次访谈了这些毕业生,发现其中有101位百万富翁,除了其中一位,所有百万富翁都来自追求他们喜欢做的事的那255人!”* + +“现在你可能觉得你对巴洛克时期的冰岛诗歌,或者蝴蝶收集,或者高尔夫,抑或是对社会正义的热情,会因为要养家糊口而让你和你喜欢做的事分道扬镳,并非一定要如此。弗拉基米尔·纳博科夫(Vladimir Nabokov)是本世纪最伟大的小说家之一,他对蝴蝶收藏的热情远远超过写作。事实上,他的第一个大学教学工作是关于鳞翅类昆虫。在过去40年里,对40万美国群众的研究表明,即使是部分的、零散的追求培养你的激情,也可以帮助你充分利用你目前的能力,激励你培养新的能力。”--摘自《The Other 90%》 Robert K.Cooper + +当然你可以看Po Bronson写的《 What Should I Do With My Life?》这本书,对这些想法进行更多的探索。 +
\ No newline at end of file diff --git a/docs/book/Appendix-Data-Compression.md b/docs/book/Appendix-Data-Compression.md index 10f4ea2..2fbc6cb 100644 --- a/docs/book/Appendix-Data-Compression.md +++ b/docs/book/Appendix-Data-Compression.md @@ -3,17 +3,249 @@ # 附录:数据压缩 +Java I/O 类库提供了可以读写压缩格式流的类。你可以将其他 I/O 类包装起来用于提供压缩功能。 + +这些类不是从 **Reader** 和 **Writer** 类派生的,而是 **InputStream** 和 **OutputStream** 层级结构的一部分。这是由于压缩库处理的是字节,而不是字符。但是,你可能会被迫混合使用两种类型的流(请记住,你可以使用 **InputStreamReader** 和 **OutputStreamWriter**,这两个类可以在字节类型和字符类型之间轻松转换)。 + +| 压缩类 | 功能 | +| ------------------------ | ------------------------------------------------------------ | +| **CheckedInputStream** | `getCheckSum()` 可以对任意 **InputStream** 计算校验和(而不只是解压) | +| **CheckedOutputStream** | `getCheckSum()` 可以对任意 **OutputStream** 计算校验和(而不只是压缩) | +| **DeflaterOutputStream** | 压缩类的基类 | +| **ZipOutputStream** | **DeflaterOutputStream** 类的一种,用于压缩数据到 Zip 文件结构 | +| **GZIPOutputStream** | **DeflaterOutputStream** 类的一种,用于压缩数据到 GZIP 文件结构 | +| **InflaterInputStream** | 解压类的基类 | +| **ZipInputStream** | **InflaterInputStream** 类的一种,用于解压 Zip 文件结构的数据 | +| **GZIPInputStream** | **InflaterInputStream** 类的一种,用于解压 GZIP 文件结构的数据 | + +尽管存在很多压缩算法,但是 Zip 和 GZIP 可能是最常见的。你可以使用许多用于读取和写入这些格式的工具,来轻松操作压缩数据。 -## 使用Gzip简单压缩 +## 使用 Gzip 简单压缩 -## 使用zip多文件存储 + +GZIP 接口十分简单,因此当你有一个需要压缩的数据流(而不是一个包含不同数据分片的容器)时,使用 GZIP 更为合适。如下是一个压缩单个文件的示例: + +```java +// compression/GZIPcompress.java +// (c)2017 MindView LLC: see Copyright.txt +// We make no guarantees that this code is fit for any purpose. +// Visit http://OnJava8.com for more book information. +// {java GZIPcompress GZIPcompress.java} +// {VisuallyInspectOutput} + +public class GZIPcompress { + public static void main(String[] args) { + if (args.length == 0) { + System.out.println( + "Usage: \nGZIPcompress file\n" + + "\tUses GZIP compression to compress " + + "the file to test.gz"); + System.exit(1); + } + try ( + InputStream in = new BufferedInputStream( + new FileInputStream(args[0])); + BufferedOutputStream out = + new BufferedOutputStream( + new GZIPOutputStream( + new FileOutputStream("test.gz"))) + ) { + System.out.println("Writing file"); + int c; + while ((c = in.read()) != -1) + out.write(c); + } catch (IOException e) { + throw new RuntimeException(e); + } + System.out.println("Reading file"); + try ( + BufferedReader in2 = new BufferedReader( + new InputStreamReader(new GZIPInputStream( + new FileInputStream("test.gz")))) + ) { + in2.lines().forEach(System.out::println); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} + +``` + +使用压缩类非常简单,你只需要把你的输出流包装在 **GZIPOutputStream** 或 **ZipOutputStream** 中,将输入流包装在 **GZIPInputStream** 或 **ZipInputStream**。其他的一切就只是普通的 I/O 读写。这是面向字符流和面向字节流的混合示例;in 使用 Reader 类,而 **GZIPOutputStreams** 构造函数只能接受 **OutputStream** 对象,而不能接受 **Writer** 对象。当打开文件的时候,**GZIPInputStream** 会转换成为 **Reader**。 + +## 使用 zip 多文件存储 + +支持 Zip 格式的库比 GZIP 库更广泛。有了它,你可以轻松存储多个文件,甚至还有一个单独的类可以轻松地读取 Zip 文件。该库使用标准 Zip 格式,因此它可以与当前可在 Internet 上下载的所有 Zip 工具无缝协作。以下示例与前一个示例具有相同的形式,但它可以根据需要处理任意数量的命令行参数。此外,它还显示了 **Checksum** 类计算和验证文件的校验和。有两种校验和类型:Adler32(更快)和 CRC32(更慢但更准确)。 + +```java +// compression/ZipCompress.java +// (c)2017 MindView LLC: see Copyright.txt +// We make no guarantees that this code is fit for any purpose. +// Visit http://OnJava8.com for more book information. +// Uses Zip compression to compress any +// number of files given on the command line +// {java ZipCompress ZipCompress.java} +// {VisuallyInspectOutput} +public class ZipCompress { + public static void main(String[] args) { + try ( + FileOutputStream f = + new FileOutputStream("test.zip"); + CheckedOutputStream csum = + new CheckedOutputStream(f, new Adler32()); + ZipOutputStream zos = new ZipOutputStream(csum); + BufferedOutputStream out = + new BufferedOutputStream(zos) + ) { + zos.setComment("A test of Java Zipping"); + // No corresponding getComment(), though. + for (String arg : args) { + System.out.println("Writing file " + arg); + try ( + InputStream in = new BufferedInputStream( + new FileInputStream(arg)) + ) { + zos.putNextEntry(new ZipEntry(arg)); + int c; + while ((c = in.read()) != -1) + out.write(c); + } + out.flush(); + } + // Checksum valid only after the file is closed! + System.out.println( + "Checksum: " + csum.getChecksum().getValue()); + } catch (IOException e) { + throw new RuntimeException(e); + } + // Now extract the files: + System.out.println("Reading file"); + try ( + FileInputStream fi = + new FileInputStream("test.zip"); + CheckedInputStream csumi = + new CheckedInputStream(fi, new Adler32()); + ZipInputStream in2 = new ZipInputStream(csumi); + BufferedInputStream bis = + new BufferedInputStream(in2) + ) { + ZipEntry ze; + while ((ze = in2.getNextEntry()) != null) { + System.out.println("Reading file " + ze); + int x; + while ((x = bis.read()) != -1) + System.out.write(x); + } + if (args.length == 1) + System.out.println( + "Checksum: " + csumi.getChecksum().getValue()); + } catch (IOException e) { + throw new RuntimeException(e); + } + // Alternative way to open and read Zip files: + try ( + ZipFile zf = new ZipFile("test.zip") + ) { + Enumeration e = zf.entries(); + while (e.hasMoreElements()) { + ZipEntry ze2 = (ZipEntry) e.nextElement(); + System.out.println("File: " + ze2); + // ... and extract the data as before + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} +``` + +对于要添加到存档的每个文件,必须调用 `putNextEntry()` 并传递 **ZipEntry** 对象。 **ZipEntry** 对象包含一个扩展接口,用于获取和设置 Zip 文件中该特定条目的所有可用数据:名称,压缩和未压缩大小,日期,CRC 校验和,额外字段数据,注释,压缩方法以及它是否是目录条目。但是,即使 Zip 格式有设置密码的方法,Java 的 Zip 库也不支持。虽然 **CheckedInputStream** 和 **CheckedOutputStream** 都支持 Adler32 和 CRC32 校验和,但 **ZipEntry** 类仅支持 CRC 接口。这是对基础 Zip 格式的限制,但它可能会限制你使用更快的 Adler32。 + +要提取文件,**ZipInputStream** 有一个 `getNextEntry()` 方法,这个方法在有文件存在的情况下调用,会返回下一个 **ZipEntry**。作为一个更简洁的替代方法,你可以使用 **ZipFile** 对象读取该文件,该对象具有方法 entries() 返回一个包裹 **ZipEntries** 的 **Enumeration**。 + +要读取校验和,你必须以某种方式访问关联的 **Checksum** 对象。这里保留了对 **CheckedOutputStream** 和 **CheckedInputStream** 对象的引用,但你也可以保持对 **Checksum** 对象的引用。 Zip 流中的一个令人困惑的方法是 `setComment()`。如 **ZipCompress** 所示。在 Java 中,你可以在编写文件时设置注释,但是没有办法恢复 **ZipInputStream** 中的注释。注释似乎仅通过 **ZipEntry** 在逐个条目的基础上完全支持。 + +使用 GZIP 或 Zip 库时,你不仅被限制于文件——你可以压缩任何内容,包括通过网络连接发送的数据。 + -## Java的jar + +## Java 的 jar + +Zip 格式也用于 JAR(Java ARchive)文件格式,这是一种将一组文件收集到单个压缩文件中的方法,就像 Zip 一样。但是,与 Java 中的其他所有内容一样,JAR 文件是跨平台的,因此你不必担心平台问题。你还可以将音频和图像文件像类文件一样包含在其中。 + +JAR 文件由一个包含压缩文件集合的文件和一个描述它们的“清单(manifest)”组成。(你可以创建自己的清单文件;否则,jar 程序将为你执行此操作。)你可以在 JDK 文档中,找到更多关于 JAR 清单的信息。 + +JDK 附带的 jar 工具会自动压缩你选择的文件。你可以在命令行上调用它: + +```shell +jar [options] destination [manifest] inputfile(s) +``` + +选项是一组字母(不需要连字符或任何其他指示符)。 Unix / Linux 用户会注意到这些选项与 tar 命令选项的相似性。这些是: + +| 选项 | 功能 | +| ---------- | ------------------------------------------------------------ | +| **c** | 创建一个新的或者空的归档文件 | +| **t** | 列出内容目录 | +| **x** | 提取所有文件 | +| **x** file | 提取指定的文件 | +| **f** | 这代表着,“传递文件的名称。”如果你不使用它,jar 假定它的输入将来自标准输入,或者,如果它正在创建一个文件,它的输出将转到标准输出。 | +| **m** | 代表第一个参数是用户创建的清单文件的名称。 | +| **v** | 生成详细的输出用于表述 jar 所作的事情 | +| **0** | 仅存储文件;不压缩文件(用于创建放在类路径中的 JAR 文件)。 | +| **M** | 不要自动创建清单文件 | + +如果放入 JAR 文件的文件中包含子目录,则会自动添加该子目录,包括其所有子目录等。还会保留路径信息。 + +以下是一些调用 jar 的典型方法。以下命令创建名为 myJarFile 的 JAR 文件。 jar 包含当前目录中的所有类文件,以及自动生成的清单文件: + +```shell +jar cf myJarFile.jar *.class +``` + +下一个命令与前面的示例类似,但它添加了一个名为 myManifestFile.mf 的用户创建的清单文件。 : + +```shell +jar cmf myJarFile.jar myManifestFile.mf *.class +``` + +这个命令输出了 myJarFile.jar 中的文件目录: + +```shell +jar tf myJarFile.jar +``` + +如下添加了一个“verbose”的标志,用于生成更多关于 myJarFile.jar 中文件的详细信息: + +```shell +jar tvf myJarFile.jar +``` + +假设 audio,classes 和 image 都是子目录,它将所有子目录组合到文件 myApp.jar 中。还包括“verbose”标志,以便在 jar 程序工作时提供额外的反馈: + +```shell +jar cvf myApp.jar audio classes image +``` + +如果你在创建 JAR 文件时使用了 0(零) 选项,该文件将会被替换在你的类路径(CLASSPATH)中: + +```shell +CLASSPATH="lib1.jar;lib2.jar;" +``` + +然后 Java 可以搜索到 lib1.jar 和 lib2.jar 的类文件。 + +jar 工具不像 Zip 实用程序那样通用。例如,你无法将文件添加或更新到现有 JAR 文件;只能从头开始创建 JAR 文件。 + +此外,你无法将文件移动到 JAR 文件中,在移动文件时将其删除。 + +但是,在一个平台上创建的 JAR 文件可以通过任何其他平台上的 jar 工具透明地读取(这个问题有时会困扰 Zip 实用程序)。