首页 / 资讯中心 / 文章详情

《On Java 中文版 基础卷》阅读笔记 什么是对象 01

《On Java 中文版 基础卷》阅读笔记 什么是对象 01 ★ FEATURED ARTICLE
《On Java 中文版 基础卷》阅读笔记 什么是对象 01本书导读本书导读Java是一门派生语言。当时的情况是早期的语言设计师不想用C来开发项目于是创建了一门和C极为相似的新编程语言不过也做出了一些改进。这种新编程语言最主要的改动是加入了虚拟机和垃圾收集机制。本书目标为你打下坚实的编程语言基础以便你之后学习难度更大的课程和图书时可以充分理解自己所遇到的问题。语言设计缺陷探索一门语言能做什么的过程十分有趣然而设计缺陷能够告诉你该语言不能做什么。普及程度如果你并不是真心喜爱Java建议你还是远离它为好。我的意思是如果学习Java只是为了找工作无异于选择了一种不幸福的人生。Java新的“发布节奏”Java早期的1.11.4版本使用带小数点的数字代表主版本号到了Java 5则变成使用整数代表主版本号。现在Java拥有了一套新的版本号规则内容如下。1.每隔6个月发布一个新版本使用整数作为版本号。2.发布的版本会包含一些试用功能一旦这些功能出于某些原因没有达成预期的效果它们就会被取缔。所以你不应该依赖这些试用性质的功能。3.区分清楚短期支持(Short-Term-Support, STS)版本和长期支持(Long-Term-Support,LTS)版本。Java 8、11、17都是LTS版本其他版本则是支持周期只有6个月的STS版本。具体而言只要有新版本问世对STS版本的支持即宣告终止。类似地一旦有新的LTS版本问世​通常在一年以内很快也会停止对原LTS版本的支持这里指的是Oracle所提供的免费支持也就是说OpenJDK可能会支持更长时间​。对于大多数公司和程序员来说关注STS版本不仅可能需要付出额外的精力而且使用这种生命周期较短的版本究竟有多少回报也让人存疑所以我只推荐使用LTS版本。图形用户界面Java在桌面领域的大多数应用场景是IDE以及一些企业内部的应用程序。虽然人们确实也会用Java开发用户界面但要清楚地意识到这只是Java语言的一个小众需求。JDK HTML文档Oracle公司为Java开发工具集(Java Development Kit, JDK)提供了电子文档用Web浏览器即可查看。所以在本书中通常我只会提及某处需要参考“JDK文档”​。如果JDK文档的内容不足以让你理解某个特定的示例我也会提供额外的说明。经过测试的示例本书提供的示例使用的是Java 8环境和Gradle编译工具。虽然我也使用新版本的Java测试过这些示例但我依然推荐使用该语言的LTS版本在我写这本书时对应的是Java 11或Java 17。此外本书所有示例都可以从GitHub仓库免费获取。bug反馈在阅读本书的过程中只要你确信自己发现了某处错误不管是文字还是代码示例问题请第一时间将该错误以及你修正后的内容提交到https://github.com/BruceEckel/Onjava8-examples/issues。源代码本书所有源代码都可以在GitHub网站上获取https://github.com/BruceEckel/Onjava8-examples。01 什么是对象1.1 抽象的历程所有编程语言都是一种抽象。比如汇编语言是对计算机底层的一个极简化的抽象C语言等命令式编程语言都是各自对汇编语言的抽象。它们的抽象方式要求你根据计算机的结构而非问题的结构来思考。于是程序员必须在机器模型和实际问题模型之间建立关联。这一切都导致程序难以编写且不易维护。面向对象编程是对实际问题的抽象既是解决方案的代码也是表述问题的文字。1.2 对象具有接口亚里士多德可能是第一个仔细研究类型这一概念的人他曾经提出过 “鱼的类别和鸟的类别”​。所有的对象都能够被归为某一类并且同一类对象拥有一些共同的行为和特征。面向对象编程通过模拟场景解决问题比如 “银行柜台场景”​。这个场景的元素包括柜员、储户、账户、交易以及各种货币单位等这些都是 “对象”​。而那些状态不同但结构相同的对象汇聚在一起就变成了 “同一类对象” (classes of objects)这就是关键字 class 的由来。创建抽象数据类型即 “类”​是面向对象编程的一个基本概念。可以创建某种抽象数据类型的变量这些变量叫作 “对象”随后就可以操作这些对象​。同一类型的所有对象都具有一些共性比如每一个账户都有余额每一位柜员都能处理存款业务。同时每一个对象都具有自己的专属状态比如每一个账户的余额都是不同的每一位柜员都有名字。因此对于所有这些对象包括每一位柜员、每一位储户、每一个账户以及每一笔交易等都能够在程序中用一个唯一的对象来表示。每一个对象所归属的抽象数据类型即 “类”​决定了对象具有何种行为特征。所有面向对象编程语言都会使用 class 这个关键字。所以当看到面向对象编程的时候请第一时间想到 class类这个关键字。类其实就是数据类型。类描述了一系列具有相同特征和行为的对象。为什么需要抽象数据类型因为程序员可以通过定义一个新的类来解决问题而非受限于已有的内置数据类型。这些已有的内置数据类型其设计本意是为了呈现机器内的存储单元你可以根据实际的需求创建新的数据类型同时扩展编程语言的能力。此外编程系统对于新的抽象数据类型十分友好比如也会为新的类提供类型检查等功能就像对待内置数据类型一样。面向对象编程的作用并不局限于模拟。面向对象编程技巧可以将众多复杂的问题简化。一旦创建了一个类就可以用它创建任意多个对象。那么如何能让一个对象真正发挥其作用呢答案使用对象的行为。对象有什么行为是由它的 “接口” 决定的而对象所归属的类定义了这些接口。接下来以电灯泡为例如图所示。LightltnewLight();lt.on();图中的接口定义了你能够操作这个对象的行为。对于每一个接口类都有一个方法与之对应。当你操作对象特定的接口时对应的方法就会被调用。通常会这样描述该过程调用对象的接口​然后由对象决定如何处理即运行对应方法内的代码​。在上面的例子中类的名字是 Light, Light 所生成的对象的名字是 ltLight 对象能够调用的接口是开灯(on())、关灯(off())、灯光变亮(brighten()) 以及灯光变暗 (dim())。通过定义一个 “引用” 即 lt以及用 new 关键字生成一个新对象就创建了一个 Light 对象。此外如果需要调用对象的接口可以用一个英文句号将对象名和方法连接起来。此外前面的图示遵循了统一建模语言 (Unified Modeling Language, UML) 的规范。在此规范下每一个类都表示为一个方块方块头部是类名方块中部是想要描述的数据成员而方法则位居方块的底部。通常UML 图中只会展示类名和公有方法所以在上图的例子中方块中部的内容并没有展示出来。如果只关心类名方块底部的内容也可以不显示。1.3 对象可以提供服务当你开发一个面向对象程序时可以将对象想象成 “服务提供者”​。程序本身也是为用户提供服务的它通过使用其他对象提供的服务来做到这一点。所以你的任务是创建一些提供对应服务以解决问题的对象。比如你要创建一个记账系统你可能会需要一些预设的输入页面对象、负责计算的对象以及连接各种打印机以打印支票和发票的对象。其中有些对象也许已经存在那么其他不存在的对象应该是什么样的呢它们应该提供哪些服务同时它们还需要哪些其他对象的支持呢这种将问题拆解为一系列对象的方法确实行之有效。把对象视为服务提供商还有一个额外的好处即提升了对象的聚合程度。说到这里就需要提到软件设计领域中一个体现基础品质的术语— “高内聚性” (high cohesion)这指的是设计的组件比如方法、对象、库等无论从哪个方面看都整合得很好。人们在设计对象时很容易犯的一个错误就是为对象添加太多的功能。例如在一个打印支票的程序里你一开始可能会认为需要一个既能排版又能打印的对象。然后你发现这些功能对于一个对象而言太多了其实你需要 3 个或者更多对象来负责这些功能。比如一个对象包含了所有可能的打印布局通过查找它可以知道如何打印一张支票。另一个或一组对象则作为通用打印接口负责连接所有不同型号的打印机​。还有一个对象负责整合前两个对象提供的服务以完成打印任务。因此每一个对象都提供了一种配套服务。在面向对象领域出色的设计往往意味着一个对象只做好一件事绝不贪多。把对象视为服务提供商不仅对你设计对象的过程有所帮助也有利于他人阅读你的代码或复用这些对象。换句话说如果别人因为对象提供的服务而认识到它的价值那么他就会更加轻松地在自己的设计中使用这个对象。1.4 隐藏的实现可以把程序员划分为两大阵营一是 “类的创建者”​负责创建新数据类型的人​二是 “客户程序员”在自己的应用程序里使用现有数据类型的人​。客户程序员的诉求是收集一个装满了各种类的工具箱以便自己能够快速开发应用程序。而类的创建者则负责在创建新的类时只暴露必要的接口给客户程序员同时隐藏其他所有不必要的信息。为什么要这么做呢这是因为如果这些信息对于客户程序员而言是不可见的那么就无需担心这些隐藏的信息被修改。隐藏的代码通常代表着一个对象内部脆弱的部分如果轻易暴露给粗心或经验不足的客户程序员就可能在顷刻之间被破坏殆尽。所以隐藏代码的具体实现可以有效减少程序 bug。所有的关系都是建立在相关方一致遵守的边界上。当你创建了一个库那么你就和使用它的客户程序员建立了一种关系。该客户程序员通过使用你的代码来构建一个应用也可能将其用于构建成一个更大的库。如果一个类的所有成员都对所有人可见那么客户程序员就可以恣意妄为无法强制他遵守规定。也许你的预期是客户程序员不会直接操作任何类的成员但是如果没有访问控制的话你就无法实现这一点因为所有的一切都暴露在对方面前了。所以设置访问控制的首要原因就是防止客户程序员接触到他们本不该触碰的内容即那些用于内部运转的代码而非那些用于解决特定问题的代码。这种做法实际上为客户程序员提供了一种便利服务因为他们很容易就可以知道哪些信息对他们来说是重要的哪些则是无须关心的​。设置访问控制的第二个原因则是让库的设计者在改变类的内部工作机制时使用该类的客户程序员可以不修改代码。例如你为了开发方便而创建了一个简单的类之后你发现必须重写这个类以提升它的运行效率。如果接口部分和实现部分已经被分离和保护起来了那么你重写它就比较方便。Java 提供了 3 个显式关键字来设置这种访问控制即 public、private、protected。这些关键字叫作 “访问修饰符”(access specifier)它们决定了谁可以使用修饰符之后的内容。public 表示定义的内容可以被所有人访问。private 表示定义的内容只能被类的创建者通过该类自身的方法访问其他任何人都无法访问。所以private 就是一道横亘在你和客户程序员之间的高墙任何人从外部访问 private 数据都会得到一个编译时报错。最后protected 类似于 private两者的区别是继承的子类可以访问 protected 成员但不可以访问 private 成员。至于继承的概念本书稍后会讲述。如果你不使用上述任意一种访问修饰符Java 会提供一种 “默认” 访问权限通常叫作 “包访问”(package access)意思是一个类可以访问同一个包里的其他类但是如果从包外部访问这些类的话它们就像 private 内容一样不可访问了。1.5 复用实现如果一个类经过了充分测试其代码就应该是有效且可复用的理想情况​。不过要实现这种复用性并不像想象的那么简单。创建可复用的对象设计需要大量的经验。代码复用是我们使用面向对象编程的理由之一已有可复用的设计不复用就可惜了。复用一个类最简单的方法是直接使用这个类所生成的对象不过你也可以把这个对象放到另一个新类里面。新创建的类可以由任意数量和类型的对象组成以满足想要的功能。利用已有的类组合成一个新的类。组合通常代表一种 “有” (has-a) 的关系比如 “汽车有发动机”​​。组合提供了极大的灵活性。在类内部创建的对象通常具有 private 属性所以其他使用这个类的客户程序员无法访问它们。这也意味着就算修改了这些内部对象外部已有的代码也可以不改。此外你还可以在运行时改变这些内部对象从而动态调整程序的行为。下一节要讲述的继承机制则不具备这种灵活性因为编译器对使用继承创建的类设置了一些编译时的限制。继承常被视为面向对象编程的重中之重因此容易给新手程序员留下这样的印象处处都应该使用继承。而实际上这种全盘继承的做法会导致设计变得十分别扭和过于复杂。所以相比之下在创建新类时应该首先考虑组合因为使用组合更为简单灵活设计也更为清晰简洁。一旦你拥有了足够的经验何时使用继承就会变得非常清晰了。1.6 继承对象本身的理念是作为一种便捷的工具。对象中根据定义来封装数据和功能从而表现对应的问题空间不需要程序员操作机器底层。在编程语言里这些基础概念通过关键字 class 类得以呈现。当创建了一个类之后如果不得不再创建一个与之前功能极为相近的类这种滋味一定不太好受。如果能够复制现有的类并且在该复制类的基础上再做一些增补的话那就太妙了。实际上这就是继承带来的好处。注意一点如果最初的类叫作 “基类”​ “超类” 或 “父类”​ 发生了变化那么被修改的 “复制” 类叫作 “派生类” ​“继承类” 或 “子类” ​同样会发生变化​。一个类呈现的内容不只是对象能做什么、不能做什么它还可以关联其他的类。两个类可以拥有相同的行为和特征一个类也可以比另一个类拥有更多的行为和特征​。继承通过基类和子类的概念来表述这种相似性即基类和子类可以拥有相同的行为和特征子类可以比基类拥有更多的行为和特征​。也就是说你可以通过基类呈现核心思想从基类所派生出的众多子类则为其核心思想提供了不同的实现方式。举个例子一个垃圾收集器需要对垃圾进行分类。基类是 “垃圾”​子类是具体的垃圾。每一件垃圾都有各自不同的特征比如颜色、材质和行为折叠、压扁。还有一个常见的例子是画图软件中的形状。基类就是 “形状”(Shape)子类是每一个具体的形状。每一个具体的形状都具有大小、颜色、位置等信息并且可以被绘制 (draw())、清除 (erase())、移动 (move())、着色 (getColor 或 setColor) 等。接下来基类 Shape 可以派生出特定形状的子类比如圆形 (Circle)、矩形 (Square)、三角形 (Triangle) 等每一个具体形状都可以拥有额外的行为和特征​。有些行为背后的逻辑是不同的比如不同形状计算面积的方法就各不相同。所以基类子类构成的继承既体现了不同类之间的相似性又展现了它们之间的差异。问题和解决方案都使用相同的表达方式是非常有用的因为这样就不再需要一个中间模型将问题和解决方案关联起来。在面向对象领域继承是该模型的一个重要特征它让你可以方便地从现实世界中的系统转换到代码世界中的系统。继承已有的类将产生新类。这个新的子类不但会继承其基类的所有特征更重要的是子类也会继承基类的行为。也就是说所有基类对象能够干的事子类对象也一样能干。基类能干的子类都能干因此可以说子类和基类是相同的类型引用之前的例子就是 “ 圆形是一个形状 ”​。掌握这种通过继承表现出来的类型相同的特性是理解面向对象编程的基础方法之一。如果继承了一个类并且不做任何修改的话这个基类的方法就会原封不动地被子类所继承。也就是说子类不但和基类具有相同的类型而且它们的行为也是相同的。子类和基类可以有两种不同第一种不同非常简单直接子类会添加新的方法​。因为这些方法并非来自基类所以背后的逻辑可能是基类的行为和你的预期不符于是你添加了新的方法以满足自己的需求。有时候继承的这种基础用法能够完美地解决你面临的问题。不过你需要慎重考虑是否基类也需要这些新的方法还有一个替代方案是考虑使用 “组合”​​。在面向对象编程领域里这种对设计进行发现和迭代的情况非常普遍。虽然有时候继承意味着需要为子类添加新的方法Java 尤其如此其用于继承的关键字就是 “扩展”(extends)但这不是必需的。还有一种让新类产生差异化的方法更为重要即修改基类已有方法的行为称之为 “重写” 该方法​。如果想要重写一个方法可以在子类中对其进行重新定义。也就是说预期是 “调用相同的方法但是希望它可以在新的类中实现不同的效果”​。is-a 关系与 is-like-a 关系继承机制存在一个有待商榷的问题只应该重写基类中定义的方法吗​并且不能添加基类中不存在的新方法如果是就意味着子类和基类的类型是完全相同的因为它们的方法一模一样。结果就是你可以直接用子类的对象代替基类的对象。这种纯替换关系通常叫作 “替换原则”。从某种意义上说这是一种理想的继承方式。这种情况下基类和子类之间的关系通常叫作 “is-a” 关系意思是 “A 是 B”​比如 “圆形是一个形状”​。甚至有一种测试是否是继承关系的方法是判断你的类之间是否满足这种 “is-a” 关系。有时候你会为子类添加新的方法。在这种情况下子类的对象依然可以代替基类的对象但是这种代替方案并不完美因为不能通过基类获取子类的新方法。这种关系描述为 “is-like-a” ​意思是 “A 像 B”​即子类在拥有基类方法的同时也拥有一些新的方法所以不能说两者是完全等同的。以空调为例假设你的房间里已经安装了空调也就是拥有能够降低温度的接口。现在发挥一下想象力万一空调坏了你还可以用热泵作为替代品因为热泵既可以制冷也可以制热见图1-7​。在这种情况下热泵“就像是”空调只不过热泵能做的事情更多而已。此外由于设计房间的温度控制系统时功能仅限于制冷所以系统和新对象交互时也只有制冷的功能。虽然新对象的接口有所扩展但现有系统也只能识别原有的接口。以汽车为例假设你有一辆轿车和一辆越野车轿车可以日常使用越野车既可以日常使用也可以出去越野。假设现在轿车坏了你对汽车只有日常使用的需求那就只能用到越野车的日常使用功能。子类和基类的方法一模一样可以直接用子类的对象代替基类的对象这种纯替换关系通常叫作 “替换原则”。如果你的设计能够应用替换原则那就太棒了。然而实际情况是你会发现经常需要为子类添加新方法。1.7 多态在涉及继承的代码中有时会把子类的对象看作基类的一个实例而不是对象实际类的实例。这种方式可以在编码时不依赖于具体的子类直接使用基类。比如在形状例子中方法都是所有形状通用的不需要关心形状具体是圆形、矩形、三角形所有的形状都有绘制、清除、移动、着色方法。所以在使用这些方法时无需关注具体的形状子类是如何处理这些方法的。添加新的子类时原来的代码是不受影响的添加新的子类可以扩展面向对象程序的能力从而能够处理一些新的情况。比如你为基类 “形状” 创建了一个子类 “五边形”​并且不改变那些基于通用形状的方法。这种通过派生子类就可以轻松扩展程序设计的能力是封装变化的一种基础方式。这种方式在改善设计的同时也降低了软件维护的成本。使用基类代替派生的子类时比如形状代表圆形交通工具代表自行车就会发现一个问题调用基类的方法时编译器并不知道具体执行的代码是什么。程序员在调用方法时使用基类不需要关注子类中的代码。当方法应用于圆形、自行车具体的子类时子类对象能够执行对应的代码。如果程序员在调用方法时使用基类不需要关注具体子类中的代码。那么当添加新的子类时基类不需要做任何修改。编译器是如何知道具体执行哪个子类中的代码呢比如下图中的 BirdController 对象内部使用的是 Bird 对象并不确认具体是什么类型鸟。这对 BirdController 对象很方便因为不需要关注具体是什么鸟。那么问题来了当 Bird 对象的 move() 方法被调用时如何确认执行哪个具体类型中的 move() 方法呢答案来自继承机制的一种重要技巧。对于非面向对象编译器而言其生成的函数调用会触发 “前期绑定” (early binding)这是一个你可能从来都没听说过的词。前期绑定意味着编译器会生成对一个具体方法名的调用该方法名决定了被执行代码的绝对地址。但是对于继承而言程序直到运行时才能明确代码的地址所以就需要引入其他可行的方案以确保代码可以正确被执行。为了解决上面提及的问题面向对象语言使用的机制是 “后期绑定” (late binding)。也就是说当你调用方法时直到运行时才会确定哪一段代码会被调用。编译器会确保被调用的方法是真实存在的并对该方法的参数和返回值进行类型检查但是它并不知道具体执行的是哪一段代码。为了实现后期绑定Java 使用了一些极为特殊的代码以代替直接的函数调用这段代码使用存储在对象中的信息来计算方法体的地址​。通俗地讲当调用对象中的方法时该对象自己会找到对应的代码。顺便一提在某些编程语言里你必须显式地为方法赋予这种后期绑定特性。比如C 使用 virtual 关键字来达到此目的。在这些编程语言中方法并不默认具备动态绑定特性。不过Java 默认具备动态绑定特性所以你无须借助于其他关键字或代码来实现多态。再来看一下形状的例子。之前的图中展示了一些形状的类​为了更好地描述多态编写一小段只关注基类而不关注具体子类的代码。用Java编写一个方法voiddoSomething(Shapeshape){shape.erase();shape.draw();}这个方法适用于任何形状它不关心进行绘制和清除的具体是形状。如果程序的其他地方调用了 doSomething() 方法比如CirclecirclenewCircle();TriangletrianglenewTriangle();LinelinenewLine();doSomething(circle);doSomething(triangle);doSomething(line);不管对象具体属于哪个类doSomething() 方法都可以正常运行。此外如果通过继承添加了一个新的子类 “六边形”​代码仍然适用于这个新的子类就像适用于其他已有子类一样。因此可以说这段程序具备扩展性。简直妙不可言。再看这一行代码doSomething(circle);在这段代码里原本需要传递一个 Shape 对象作为参数而实际传递的参数却是一个 Circle 类的对象。因为 Circle圆形 也是一个 Shape图形所以 doSomething() 也可以接受 Circle。也就是说doSomething() 调用 Shape 对象的任何方法调用 Circle 对象的方法也可以。这是一种非常安全且逻辑清晰的做法。这种将子类视为基类的过程叫作 “向上转型”(upcasting)。这里的 “转型” 指的是转变对象的类型而 “向上” 沿用的是继承图的常规构图即基类位于图的顶部数个子类则扇形分布于下方。因此子类转变为基类在继承图中的路径就是一路向上也就叫作 “向上转型”​​。面向对象程序总会包含一些向上转型的代码因为这样在使用时就无须关注对象具体的类是什么。再看一下 doSomething() 方法中包含的代码shape.erase();shape.draw();上面的代码并没有写​“ 如果是一个 Circle 请这样做如果是一个 Square 请那样做诸如此类”​。如果真的编写了一段代码用于检查所有可能出现的形状那么这段代码必然是一团糟并且每当你为 Shape 添加一个新的子类时都必须修改这段代码。所以上面的代码意思是​“ 这是一个 Shape它可以进行绘制和清除具体细节交给形状自己处理就好 ”​。doSomething() 方法的神奇之处在于代码运行的结果是符合预期的。与直接通过 Circle、Square 或者 Line 对象调用 draw() 方法不同。doSomething() 方法调用 draw() 方法时并不知道 Shape 对象的具体类型它也能正常工作即执行其实际子类的代码。这一点十分了不起因为当 Java 编译器编译 doSomething() 的代码时它并不知道对象的类型是什么。通常来说可能会想当然地认为被调用的是基类 Shape 的 erase() 和 draw() 方法而非具体的 Circle、Square 或者 Line 子类然而实际情况是确实是具体的子类被调用了这就是多态。编译器和运行时系统负责处理各种细节程序员需要了解的就是多态机制的存在更重要的是要知道如何利用多态进行设计。1.8 单根层次结构自从 C 语言出现以来面向对象中是否所有的类都应该默认继承自某个基类的问题变得尤为突出。Java 则给出了肯定的答案除了 C 以外实际上几乎所有面向对象编程语言都是如此​这个终极基类的名字是 Object。所有的类都属于同一个终极基类这种结构叫 “单根层次结构” (singly-rooted hierarchy)。C 不是单根层次结构。单根层次结构有利于实现垃圾收集器 (garbage collector)这也是 Java 对比 C 的一个重要改进。既然所有对象都拥有类型信息你就再也不用发愁不知道某个对象具体是什么类型了。这一特性对于系统级别的操作而言尤为重要比如异常处理等同时也极大地提升了编程时的灵活性。1.9 集合一般来说你并不知道解决一个特定的问题需要用到多少个对象也不知道这些对象会存在多久你甚至不知道该如何保存这些对象。问题是如果你无法在程序运行前确切地知道这些信息那你应该申请多少内存空间呢在面向对象设计领域解决方案看似极为简单粗暴创建一种新类型的对象用于保存其它对象的引用。而在大多数编程语言里你也可以用数组 (array) 做到这一点。这种新对象通常叫作集合也可以叫作 “容器”​不过 Java 普遍使用的是 “集合”​​它会根据你放入其中的内容自行调整空间。也就是说你无须关注集合里会有多少对象直接创建集合就好了剩下的细节交给它自己处理就可以。幸运的是优秀的面向对象语言都会提供一些集合作为语言的基础功能。在 C 里集合是 C 标准库的一部分。Java 在其标准库中也提供了大量的集合。在有些语言的库中通常会用一两个集合适用于所有需求。而在另外一些语言比如 Java的库中不同的集合具有不同的用途。比如有几个不同的 List 类​几个 Map 类​几个 Set 类​以及一些队列 (queue)、树 (tree)、栈 (stack) 等。从程序设计的角度而言满足需求应该选择最合适的集合不同的集合提供了不同类型的接口。比如stack 和 queue 的用途就与 Set 以及 List 完全不同。不同的集合在某些操作的执行效率方面也会有差异。比如List 有两种基础类型的集合ArrayList 和 LinkedList。虽然两者具有相同的接口但是某些操作的执行效率却存在明显的差异。比如用 ArrayList 随机选择元素是一种耗费固定时间的操作意思是不管你选择获取哪个元素耗费的时间都是相同的。但是对于 LinkedList 来说查找列表更深处的元素也会耗费更多的时间。另外如果需要在列表中插入元素LinkedList 耗费的时间会比 ArrayList 更少。由于两者底层架构的不同实现方式所以一些操作的执行效率不同。你可以先用 LinkedList 编写代码然后为了追求效率而转投 ArrayList 的怀抱。由于两者都是基于 List 接口的子类因此只需要改动少量代码就可以切换集合。参数化类型泛型在 Java 5 之前集合所支持的是通用类型 Object。因为单根层次结构决定了所有对象都属于 Object 类型所以一个持有 Object 的集合就可以持有任何类型对象这就使得集合十分易于复用。使用集合时会保存对象的引用到集合中然后再从集合中取出。如果集合使用的是 Object 类型当你添加一个对象引用到集合时该对象会向上转型为 Object从而失去了其原本的特征。当你需要将其取出时会获得一个 Object 类型的对象引用这就不是当初的类型了。那么问题来了当初被放入集合中的对象如何才能转换回原来的类型呢这里需要再一次用到转型只不过这次不是向上转为更通用的类型而是向下转为更具体的类型这种转型叫作 “向下转型”(downcasting)。当使用向上转型时Circle 对象属于 Shape 类型所以这种向上转型是安全的。但是反过来可能并不知道一个 Object 对象实际上是 Circle 还是Shape 类型所以除非你明确知道对象的具体类型是什么否则向下转型是不安全的。不过也不是说向下转型一定是危险的。如果向下转型失败你会得到一个运行时的错误提示这叫作 “异常”(exception)后面你很快就会看到相关的介绍。当一段程序在运行时向下转型和与其关联的运行时检查都会耗费额外的时间。为什么创建的集合就不能明确地知道所包含的对象类型呢如果知道的话就不再需要向下转型也避免了在此期间可能出现的报错。这个问题的解决方案就是 “参数化类型” (parameterized type) 机制。一个被参数化的类型是一种特殊的类可以让编译器自动适配特定的类型。比如对于采用参数化类型机制的集合而言编译器可以将集合定义为只接受放入 Shape 的对象因此从集合也只能取出 Shape 对象。Java 5新增的主要特性之一是支持参数化类型也叫作“泛型”(generics)。你可以通过在一对尖括号中间加上类名来定义泛型比如你可以这样创建一个放置 Shape 对象的 ArrayListArrayListShapeshapesnewArrayList();1.10 对象的创建和生命周期和对象打交道时有一个至关重要的问题那就是它们的创建和销毁方式。每个对象的创建都要消耗一些资源尤其是内存资源。当不再需要一个对象时就要及时清理它这样它占用的资源才能被释放并重复使用。在一些环境简单的场景下清理对象并不是一个难题你创建了一个对象根据自己的需要使用不再使用的时候就将其销毁。然而不幸的是实际开发中经常会遇到更为复杂的情况。假设你需要为某个机场设计一个航空管制系统​。刚开始的逻辑很简单新建一个用于保存飞机对象的集合然后每当有飞机需要进入航空管制区域的时候就新建一个飞机对象并将其放入集合中。而每当有飞机离开航空管制区域时就清理集合中对应的飞机对象。再做一个假设还有其他系统也会记录飞机的数据但是只记录部分数据比如只会对离开机场的小型飞机做记录。于是你需要创建一个新的集合用于保存小飞机对象并且每当新建的飞机对象是小型飞机时需要将其放入这个新的集合中。现在问题变得更加棘手了你怎么判断什么时候需要清理对象当你不再需要一个对象时系统的其他部分也许还在使用该对象。而对于诸如 C 这样需要显式删除对象的编程语言来说这绝对是一个相当让人头疼的问题。对象的数据保存在哪里系统又是如何控制对象的生命周期的呢C 语言的宗旨是效率优先所以它交给程序员来选择。如果要最大化运行时效率可以通过栈区也叫作 “自动变量” 或 “局部变量”​保存对象或者将对象保存在静态存储区里这样在编写程序时就可以明确地知道对象的内存分配和生命周期。这种做法会优先考虑分配和释放内存的速度在有些情况下是极为有利的。但是代价就是牺牲了灵活性因为你必须在编写代码时就明确对象的数量、生命周期以及类型。如果你希望解决一个复杂的系统问题比如计算机辅助设计、仓库管理或者航空管制等这种做法的限制性就太大了。还有一种方案是在内存池里动态创建对象这个内存池叫作 “堆”(heap)。如果使用这个方案直到运行时你才能知道需要多少对象以及它们的生命周期和确切的类型是什么。也就是说这些信息要等到程序运行时才能确定。如果你需要创建一个新对象可以直接通过堆来创建。因为堆是在运行时动态管理内存的所以堆分配内存所花费的时间通常会比栈多一些。栈通常利用汇编指令向下或向上移动栈指针 (stack pointer) 来管理内存而堆何时分配内存则取决于内存机制的实现方式。动态创建对象的方案基于一个普遍接受的逻辑假设即对象往往是复杂的。所以在创建对象时查找和释放内存空间所带来的额外开销可以忽略不计。相比于一点额外的开销更大的灵活性才是解决常规编程问题的关键。Java 只允许动态分配内存。每当你创建一个对象时都需要使用 new 操作符创建一个对象的动态实例。然而还有另一个问题—对象的生命周期。对于那些允许在栈上创建对象的编程语言编译器会判断对象将会存在多久以及负责自动销毁该对象。但是如果你是在堆上创建对象编译器就无从得知对象的生命周期了。对于像 C 这样的语言来说你必须在编码时就明确何时销毁对象否则万一你的代码出了差错就会造成内存泄漏。而 Java 语言的底层支持垃圾收集器 (garbage collector) 机制它会自动找到无用的对象并将其销毁。垃圾收集器带来了很大的便利性因为它显著减少了你必须关注的问题数量以及需要编写的代码。因此垃圾收集器的高级在于提供了一种保障以防止潜在的内存泄漏而正是内存泄漏导致了许多 C 项目的失败。Java 设计垃圾收集器的意图就是处理内存释放的相关问题。垃圾收集器 “知道” 一个对象何时不再有用并且会自动释放该对象占用的内存。再加上所有对象都继承自顶层基类 Object以及只能在堆上创建对象等特点使得 Java 编程比 C 简单了不少。一言以蔽之需要你介入的决策和阻碍都大大减少了。1.11 异常处理自从有编程语言起错误处理就是一项极为困难的工作。设计一个优秀的错误处理系统是如此困难以至于许多编程语言忽视了这个问题而将问题抛给开发库的程序员。这些程序员只能采取一些折中措施来填补漏洞这些举措虽然在很多场景中有效但很容易通过忽略提示的错误而轻易绕过。大多数错误处理方案存在的一个显著问题是这些方案并非编程语言强制要求的而是依赖于程序员同意并遵守相关约定。如果遇到警惕性不高的程序员通常都是因为需要赶进度而放松了警惕​这些方案就形同虚设了。异常处理则是将编程语言甚至是操作系统和错误处理机制直接捆绑在一起。异常是从错误发生之处 “抛出” 的对象而根据错误类型它可以被对应的异常处理程序所 “捕获”​。而每当代码出现错误时似乎异常处理机制会使用一条特殊的、并行的执行路径来处理这些错误。这是因为它确实采取了一条单独的运行路径所以不影响正常执行的代码。同时这一点也降低了你编写代码的成本因为你不用经常反复检查各种错误了。此外抛出的异常也不同于方法返回的错误值或者方法设置的错误标识因为这两者是可以被忽略的但是异常不允许被忽略所以这就确保了异常一定会在必要的时候被处理。最后异常提供了一种可以让程序从糟糕的情况中恢复过来的方法。即便发生了意外也还有机会修正问题以及让程序重新恢复运行而不是只能结束程序了事而这一点无疑会增强许多程序的稳健性。由于从一开始 Java 就会让你接触到异常处理并且强制你必须使用它这就使得 Java 的异常处理机制在众多编程语言之中显得十分突出同时这也是 Java 唯一允许的报错方式。如果你的代码没有正确地处理各种异常就会得到一条编译时的报错消息。这种有保障的一致性使得错误处理的工作简单了许多。值得留意的是虽然面向对象语言里的异常一般用对象的形式来呈现异常处理却并不是面向对象语言的特性。其实异常处理远在面向对象语言诞生之前就已经存在了。1.12 总结一段过程式程序 (procedural program) 包含了数据定义和函数调用。如果你想要搞清楚这种程序究竟做了什么就必须仔细研究比如查看它的函数调用以及底层代码等以便在你的脑海中勾勒出一幅完整的蓝图。而这就解释了在设计过程式程序时为什么还需要中间表示(intermediate representation, IR)。实话实说过程式程序的理解成本确实很高因其设计的表达方式更多是面向计算机而不是你要解决的问题。因为面向对象编程在过程式编程语言的基础上增加了许多新特性所以你可能会想当然地认为同等效果的 Java 程序会远比过程式程序复杂。然而你会惊喜地发现编写良好的 Java 程序通常比过程式程序更简单也更易于理解。这是因为在 Java 中对象的定义所呈现的是问题空间的概念而非计算机空间的概念发送至对象的消息则代表问题空间的具体活动。面向对象编程的一个令人愉悦之处在于那些设计良好的程序其代码总是易于阅读的。另外因为许多问题都能够通过复用已有的库来解决所以通常来说代码行数也不会太多。面向对象编程和 Java 不一定适合所有人。有一点非常重要那就是你必须仔细评估自己的需求然后再判断 Java 是不是满足这些需求的最佳方案也许使用其他编程语言是更好的选择。如果在可预见的将来你的需求非常专业并且具有一些 Java 无法满足的特殊条件那么你就有必要研究一下其他可代替的编程语言我尤其推荐 Python​。这样一来就算你依然选择 Java 作为你的编程语言至少你清楚还有哪些可选项以及为什么选择它。
阅读完成 · 觉得有帮助?
咨询建站