Skip to content

Latest commit

 

History

History
622 lines (412 loc) · 46.1 KB

ch01.md

File metadata and controls

622 lines (412 loc) · 46.1 KB

1. 面向对象概念介绍

尽管许多程序员并不意识到,面向对象(OO)软件开发自上世纪 60 年代初就已存在。直到上世纪 90 年代中期到晚期,面向对象范式才开始蓬勃发展,尽管当时流行的面向对象编程语言,如 Smalltalk 和 C++ 已经被广泛使用。

面向对象方法学的兴起与互联网作为商业和娱乐平台的出现同时发生。简而言之,对象在网络上表现良好。在互联网明显将会持续存在之后,面向对象技术已经处于良好的发展位置,以开发新的基于网络的技术。

需要注意的是,本章的标题是“面向对象概念介绍”。这里的关键词是“概念”,而不是“技术”。技术在软件行业变化很快,而概念则在演进。我使用“演进”一词,因为尽管它们保持相对稳定,但确实会发生变化。而这正是专注于概念的魅力所在。尽管它们保持一致性,但它们始终在经历重新解释,这带来了一些非常有趣的讨论。

过去 25 年左右的时间里,我们可以很容易地追踪到各种行业技术的发展进程,从上世纪 90 年代中期至晚期的最初的浏览器到今天主导的移动/电话/网络应用。正如总是有新的发展即将到来一样,我们探索混合应用程序等更多内容。在整个旅程中,面向对象的概念一直伴随着我们。这就是为什么本章的主题如此重要。这些概念今天和 25 年前一样重要。

1.1 基本概念

本书的主要目的是让您思考这些概念如何在设计面向对象系统时使用。从历史上看,面向对象语言通常由以下特性定义:封装、继承和多态(我称之为“经典”面向对象)。因此,如果一个语言没有实现所有这些特性,通常就不被认为是完全面向对象的。除了这三个术语外,我总是将组合纳入其中;因此,我的面向对象概念列表如下所示:

  • 封装
  • 继承
  • 多态
  • 组合

随着我们在本书的后续部分逐步讨论,我们将详细讨论所有这些概念。

我在编写本书的第一版时就一直在努力解决的一个问题是,这些概念如何与当前不断变化的设计实践直接相关。例如,关于在面向对象设计中使用继承一直存在争论。继承是否真的会破坏封装?(这个话题将在后面的章节中进行讨论。)即使现在,许多开发人员也尽可能地避免使用继承。因此,这就引出了一个问题:到底应该使用继承吗?

我的方法一如既往地坚持概念。无论您是否使用继承,您至少需要了解继承是什么,从而使您能够做出明智的设计选择。重要的是不要忘记,在代码维护中几乎肯定会遇到继承,因此您需要学会它。

正如在介绍中提到的那样,预期的受众是那些希望对基本面向对象概念有一个总体介绍的人群。考虑到这一声明,我在本章中介绍了基本的面向对象概念,希望您能够为做出重要的设计决策奠定坚实的基础。本章涵盖的概念涉及到后续章节中涵盖的大多数,如果不是所有的话,后续章节将更详细地探讨这些问题。

1.2 对象与传统系统

随着面向对象(OO)技术进入主流,开发人员面临的一个问题是如何将新的 OO 技术与现有系统集成起来。当时,主导的开发范式是结构化(或过程化)编程,OO 与结构化编程之间划定了界线。我总是觉得这有点奇怪,因为在我看来,面向对象和结构化编程并不相互竞争。它们是互补的,因为对象与结构化代码很好地集成在一起。即使现在,我经常听到这样的问题:你是结构化程序员还是面向对象程序员?毫不犹豫,我会回答:两者都是。

同样地,面向对象代码并不意味着要取代结构化代码。许多非 OO 遗留系统(即已经存在的旧系统)仍在很好地发挥作用,那么为什么要冒险改变或替换它们呢?在大多数情况下,您不应该改变它们,至少不是为了改变而改变。使用非 OO 代码编写的系统本质上没有任何问题。然而,全新的开发确实值得考虑使用 OO 技术(在某些情况下,没有选择)。

尽管在过去的 25 年里,OO 开发有了稳定而显著的增长,但全球社区对像互联网和移动基础设施这样网络的依赖帮助它进一步成为主流。在浏览器和移动应用上执行的交易激增打开了全新的市场,在这些市场中,大部分软件开发都是全新的,并且主要没有受到遗留问题的限制。即使存在遗留问题,也有一种趋势是用对象包装器包装遗留系统。

对象包装器是包含其他代码的面向对象代码。例如,您可以将结构化代码(如循环和条件语句)包装在一个对象内,使其看起来像一个对象。您还可以使用对象包装器来包装功能,如安全功能、不可移植的硬件功能等。如何包装结构化代码将在第 7 章“使用对象进行设计”中详细介绍。

在软件开发中最有趣的领域之一是将传统代码与基于移动和网络的系统集成起来。在许多情况下,移动网络前端最终连接到存储在主机上的数据。能够结合主机和移动网络开发技能的开发人员需求量大。

您可能在日常生活中经常接触到对象,甚至没有意识到。这些经验可能发生在您的汽车中,在您使用手机通话时,在家庭娱乐系统中,玩电脑游戏以及许多其他情况下。电子高速公路实质上已成为一条基于对象的高速公路。随着企业向移动网络靠拢,它们也在向对象靠拢,因为用于电子商务的技术大多是面向对象的。

毫无疑问,互联网的出现为向面向对象技术转变提供了重要的推动力。这是因为对象非常适合在网络上使用。尽管互联网处于这一范式转变的前沿,但移动网络现在也以重要的方式加入了其中。在本书中,移动网络这一术语将用于涉及移动应用程序开发和 Web 开发的概念。有时候,混合应用程序一词用于指在 Web 和移动设备上的浏览器中呈现的应用程序。

1.3 结构编程 Vs. OO 编程

在我们深入探讨面向对象开发的优势之前,让我们先考虑一个更基本的问题:什么是对象?这既是一个复杂又是一个简单的问题。它之所以复杂,是因为学习任何一种软件开发方法都不是简单的。

它之所以简单,是因为人们已经习惯以对象的方式思考。

提示:在观看面向对象大师罗伯特·马丁的 YouTube 视频讲座时,他认为“人们以对象的方式思考”这一说法是由市场人员创造的。这只是一个思考的角度。

例如,当你看一个人时,你把这个人看作一个对象。一个对象由两个组成部分定义:属性和行为。一个人有属性,比如眼睛颜色、年龄、身高等等。一个人也有行为,比如走路、说话、呼吸等等。在其基本定义中,对象是一个包含数据和行为的实体。关键差异在于对象中同时包含了数据和行为,这与其他编程方法学有所不同。例如,在过程式编程中,代码被放置到完全不同的函数或过程中。理想情况下,如图 1.1 所示,这些过程然后变成了“黑盒子”,输入进入,输出输出。数据被放置到单独的结构中,并由这些函数或过程进行操作。

图 1.1 黑盒

面向对象设计中,属性行为都包含在单个对象中,而在过程式或结构化设计中,属性和行为通常是分开的。

随着面向对象设计的普及,最初阻碍其接受的现实之一是存在许多非面向对象的系统,而这些系统完全正常运作。因此,为了改变系统而改变并没有任何商业意义。任何熟悉计算机系统的人都知道,任何改变都可能带来灾难,即使这种改变被认为是微不足道的。

在缺乏接受面向对象数据库的情况下,这种情况也反映出来。在面向对象开发出现的某个时刻,曾经有点可能性会是面向对象数据库取代关系数据库。然而,这种情况从未发生。企业在关系数据库中投入了大量资金,而一个主要的因素阻止了转换:它们工作得很好。当转换系统从关系数据库到面向对象数据库的所有成本和风险变得显而易见时,就没有强制性的理由进行转换。

事实上,业务力量现在已经找到了一个愉快的中间地带。今天的许多软件开发实践中都具有几种开发方法论的特点,如面向对象和结构化。

如图 1.2 所示,在结构化编程中,数据通常与过程分离,数据往往是全局的,因此很容易修改超出您代码范围的数据。这意味着对数据的访问是不受控制和不可预测的(即,多个函数可能会访问全局数据)。其次,由于您无法控制谁能访问数据,因此测试和调试变得更加困难。对象通过将数据和行为组合成一个完整的包来解决这些问题。

图 1.2 使用全局数据

**正确设计:**在面向对象模型中不存在全局数据这种概念。这一事实为面向对象系统提供了高度的数据完整性。

与替换其他软件开发范式不同,对象是一种进化性的响应。结构化程序具有复杂的数据结构,如数组等。C++ 具有结构体,它们具有许多对象的特征(类)。

然而,对象不仅仅是数据结构和原始数据类型(如整数和字符串)。虽然对象确实包含诸如整数和字符串之类的实体,用于表示属性,但它们还包含方法,表示行为。在对象中,方法用于对数据执行操作以及执行其他操作。也许更重要的是,您可以控制对对象成员(属性和方法)的访问。这意味着某些成员,包括属性和方法,可以对其他对象隐藏起来。例如,一个名为Math的对象可能包含两个整数,名为myInt1myInt2。很可能,Math对象还包含了设置和检索myInt1myInt2值的必要方法。它可能还包含一个称为sum()的方法来将这两个整数相加。

数据隐藏:在面向对象术语中,数据称为属性,行为称为方法。限制对某些属性和/或方法的访问称为数据隐藏

通过将属性和方法结合到同一实体中(在面向对象术语中称为封装),我们可以控制Math对象中的数据访问。通过将这些整数定义为禁止访问,另一个逻辑上不相关的函数无法操纵myInt1myInt2整数,只有Math对象才能这样做。

健全的类设计准则: 请记住,可能会创建设计不良的面向对象类,不限制对类属性的访问。底线是,您可以像使用任何其他编程方法论一样有效地设计糟糕的代码。只需小心遵守健全的类设计准则(参见第 6 章,“类设计准则”)。

当另一个对象,例如myObject,想要访问myInt1myInt2的总和时会发生什么?它向Math对象发送消息:myObjectMath对象发送消息。图 1.3 显示了这两个对象如何通过它们的方法相互通信。消息实际上是对Math对象的sum方法的调用。然后,sum方法将值返回给myObject。这的美妙之处在于myObject不需要知道sum是如何计算的(虽然我相信它可以猜到)。有了这种设计方法,您可以更改Math对象如何计算总和,而不必更改myObject(只要检索总和的方法不更改)。你只想要总和——你不在乎它是如何计算的。

图 1.3 面向对象的交流

使用一个简单的计算器示例来说明这个概念。当使用计算器计算总和时,你只使用计算器的界面——键盘和 LED 显示器。当你按下正确的按键序列时,计算器会调用一个总和方法。你可能会得到正确的答案,但是你不知道结果是如何得到的——无论是通过电子方式还是通过算法方式。

计算总和不是myObject的责任——这是Math对象的责任。只要myObject能够访问Math对象,它就可以发送适当的消息并获得请求的结果。一般来说,对象不应该操纵其他对象的内部数据(即,myObject不应该直接改变myInt1myInt2的值)。而且,出于我们稍后要探讨的原因,通常最好构建具有特定任务的小对象,而不是构建执行许多任务的大对象

1.4 从面向过程到面向对象开发

现在我们对过程式和面向对象技术之间的一些差异有了基本的了解,让我们深入了解一下这两种技术。

1.4.1 面向过程编程

过程式编程通常将系统的数据操作数据的操作分开。例如,如果您想要通过网络发送信息,只会发送相关的数据(参见图1.4),并期望在网络管道的另一端的程序知道如何处理它。换句话说,客户端和服务器之间必须有某种握手协议来传输数据。在这种模型中,实际上可能根本不会通过电线发送任何代码。

图 1.4 线上的数据传输

1.4.2 OO 编程

面向对象编程的基本优势在于数据和操作数据的操作(代码)都封装在对象中。例如,当对象通过网络传输时,整个对象,包括数据和行为,都会一起传输。

单一实体:虽然从理论上来说思考单一实体是很好的,但在许多情况下,行为本身可能不会被发送,因为双方都拥有代码的副本。然而,重要的是以整个对象作为单个实体在网络上进行发送。

在图 1.5 中,Employee对象被发送到网络上。

图 1.5 线上的对象传输

Proper Design

一个很好的例子是由浏览器加载的对象。通常,浏览器事先不知道对象将要做什么,因为之前没有代码。当对象加载时,浏览器执行对象中的代码,并使用对象内包含的数据。

1.5 一个对象具体是什么?

对象是面向对象程序的构建模块。使用面向对象技术的程序基本上是一组对象的集合。举个例子,考虑一个企业系统包含代表该公司员工的对象。每个对象都由以下部分描述的数据和行为组成。

1.5.1 对象数据

对象中存储的数据表示对象的状态。在面向对象编程术语中,这些数据称为属性。在我们的示例中,如图 1.6 所示,员工的属性可以是社会安全号码、出生日期、性别、电话号码等。这些属性包含区分不同对象(在本例中是员工)之间的信息。在本章后面对类进行讨论时,将更详细地介绍属性。

图 1.6 员工属性

1.5.2 对象行为

对象的行为表示对象能做什么。在过程化语言中,行为由过程函数子程序定义。在面向对象编程术语中,这些行为包含在方法中,你通过向方法发送消息来调用它。在我们的员工示例中,考虑一个员工对象所需的一个行为是设置和返回各种属性的值。因此,每个属性都会有相应的方法,例如setGender()getGender()。在这种情况下,当另一个对象需要这些信息时,它可以向一个员工对象发送消息,询问它的性别是什么。

毫不奇怪,就像面向对象技术的许多方面一样,gettersetter的应用自从本书第一版出版以来已经发展了许多。特别是当涉及到数据时,记住使用对象的最有趣的、甚至可以说是最强大的优点之一是数据是包的一部分——它不与代码分离。XML 的出现不仅将注意力集中在以可移植的方式呈现数据上;它还为代码访问数据提供了替代方式。在 .NET 技术中,gettersetter被视为数据本身的属性。例如,考虑一个名为Name的属性,在 Java 中,它看起来像下面这样:

public String Name;

相应的gettersetter如下所示:

public void setName (String n) {name = n;};
public String getName() {return name;};

现在,当创建一个名为Name的 XML 属性时,C# 、.NET中的定义可能看起来像这样,尽管你当然可以使用与 Java 示例相同的方法:

private string strName;
public String Name
{
	get { return this.strName; }
	set {
    	if (value == null) return;
    		this.strName = value;
    }
}

在这种技术中,gettersetter实际上是属性的一部分——在这种情况下,是Name属性。

无论采用哪种方法,目的都是一样的——对属性进行受控访问。在本章中,我首先想集中讨论访问方法的概念性质;我们将在后面的章节中更深入地讨论属性。

GetterSetter方法

GetterSetter方法的概念支持数据隐藏的概念。因为其他对象不应直接操作另一个对象内部的数据,所以gettersetter提供了对对象数据的受控访问。Gettersetter有时分别称为访问器方法和修改器方法。

请注意,我们仅显示方法的接口,而不是实现。以下信息是用户有效使用方法所需了解的全部内容:

  • 方法的名称
  • 传递给方法的参数
  • 方法的返回类型

图 1.7 员工行为

在图 1.7 中,Payroll对象包含一个名为CalculatePay()的方法,用于计算特定员工的工资。Payroll对象必须获取该员工的社会安全号码等其他信息。要获取此信息,支付对象必须向员工对象发送消息(在本例中,是getSocialSecurityNumber()方法)。基本上,这意味着Payroll对象调用了Employee对象的getSocialSecurityNumber()方法。员工对象识别了消息并返回请求的信息。

为了进一步说明,图 1.8 是表示我们所讨论的Employee/Payroll系统的类图。

图 1.8 员工和支付类类图

UML 类图:因为这是我们看到的第一个类图,它非常基础,缺少一些构造(例如构造函数)一个正确的类应该包含的。不要担心——我们将在第3章“更多面向对象的概念”中更详细地讨论类图和构造函数。

类图由三个单独的部分定义:名称本身、数据(属性)和行为(方法)。在图 1.8 中,Employee类图的属性部分包含SocialSecurityNumberGenderDateOfBirth,而方法部分包含操作这些属性的方法。您可以使用 UML 建模工具创建和维护与真实代码相对应的类图。

可视建模工具提供了一种使用统一建模语言(UML)创建和操作类图的机制。类图在本书中被广泛使用和讨论。它们被用作帮助可视化类及其与其他类之间关系的工具。在本书中,UML的使用仅限于类图。

我们将在本章后面进一步讨论类和对象之间的关系,但现在您可以将类看作是从中创建对象的模板。当创建一个对象时,我们说对象被实例化。因此,如果我们创建了三个员工,实际上我们创建了三个完全不同的Employee类的实例。每个对象都包含其自己的属性和方法的副本。例如,考虑图 1.9。一个名为John的员工对象(John是其标识)具有其自己的Employee类中定义的所有属性和方法的副本。一个名为Mary的员工对象也有其自己的属性和方法的副本。他们两个都有DateOfBirth属性和getDateOfBirth方法的单独副本。

图 1.9 程序空间

An Implementation Issue: 请注意,并不一定为每个对象都有方法的物理副本。相反,每个对象指向相同的实现。然而,这是一个由编译器/操作平台决定的问题。从概念上讲,您可以将对象看作是完全独立的,并且具有自己的属性和方法。

1.6 一个类具体是什么?

简而言之,类是对象的蓝图。当您实例化一个对象时,您使用一个类作为构建对象的基础。事实上,试图解释类和对象实际上是一个先有鸡还是先有蛋的困境。很难在不使用术语对象的情况下描述一个类,反之亦然。例如,一个特定的单车是一个对象。然而,必须有人创建了蓝图(即类)来构建这辆自行车。在面向对象的软件中,与鸡蛋困境不同,我们知道哪个先来——类。没有类,就不能实例化对象。

因此,本节中的许多概念与本章前面介绍的概念类似,特别是当我们谈论属性和方法时。虽然本书侧重于面向对象软件的概念,而不是特定的实现,但在解释某些概念时,使用代码示例通常是有帮助的,因此在本书中会适当地使用 Java 代码片段来解释某些概念。然而,对于某些关键示例,代码将提供多种语言的下载。接下来的部分描述了类的一些基本概念以及它们的相互作用。

1.6.1 创建对象

类可以被视为对象的模板或切割模具,如图 1.10 所示。一个类用于创建一个对象。

图 1.10 类模板

一个类可以被视为一种高级数据类型。例如,就像你创建一个整数或一个浮点数:

int x;
float y;

你也可以使用预定义的类来创建一个对象:

myClass myObject;

在这个例子中,名称本身就表明了myClass是类,myObject是对象。记住,每个对象都有自己的属性(数据)和行为(函数或例程)。一个类定义了所有使用这个类创建的对象将具有的属性和行为。类是代码片段。从类实例化的对象可以作为独立分布的,也可以作为库的一部分。因为对象是从类创建的,所以类必须定义对象的基本构建块(属性、行为和消息)。简而言之,你必须先设计一个类,然后才能创建一个对象。

例如,下面为一个 Person 类的定义:

public class Person {
    // Attributes
    private String name;
    private String address;
    
    // Methods
    public String getName() {
        return name;
    }
    
    public void setName(String n) {
        name = n;
    }
    
    public String getAddress() {
        return address;
    }
    
    public void setAddress(String adr) {
        address = adr;
    }
}

1.6.2 属性

正如你已经看到的,类的数据由属性表示。每个类必须定义用于存储从该类实例化的每个对象状态的属性。在前一节的Person类示例中,Person类定义了nameaddress属性。

**访问权限:**当数据类型或方法被定义为public时,其他对象可以直接访问它。当数据类型或方法被定义为private时,只有该特定对象可以访问它。另一个访问修饰符protected允许相关对象访问,关于这个你将在第 3 章学习到。

1.6.3 方法

正如你在本章前面学到的,方法实现类的所需行为。从这个类实例化的每个对象都包含由类定义的方法。方法可以实现从其他对象(消息)调用的行为,或者提供类的基本内部行为。内部行为是私有方法,其他对象无法访问。 在Person类中,这些行为是getName()setName()getAddress()setAddress()。 这些方法允许其他对象检查和更改对象的属性值。这在 OO 系统中是一种常见的技术。 在所有情况下,对象内部的属性访问应该由对象本身控制——没有其他对象应该直接更改另一个对象的属性。

1.6.4 消息

消息是对象之间的通信机制。例如,当对象 A 调用对象 B 的方法时,对象 A 正在向对象 B 发送消息。对象 B 的响应由其返回值定义。只有对象的public方法,而不是private方法,可以被另一个对象调用。 以下代码说明了这个概念:

public class Payroll{
    String name;
    Person p = new Person();
    p.setName("Joe");
    //... code
    name = p.getName();
}

在这个示例中(假设实例化了一个Payroll对象),Payroll对象正在向Person对象发送消息,目的是通过getName()方法检索名称。再次强调,不要过于担心实际的代码,因为我们真正关注的是概念。随着我们在书中的进展,我们将详细讨论代码。

1.7 使用类图作为可视化工具

多年来,已经开发出许多工具和建模方法来辅助设计软件系统。从一开始,我就使用 UML 类图来辅助教学过程。虽然本书的范围不包括详细描述 UML,但我们将使用 UML 类图来说明我们构建的类。事实上,在本章中,我们已经使用了类图。图 1.11 显示了我们在本章前面讨论过的Person类图。

图 1.11 Person 类图

正如我们之前看到的那样,请注意属性和方法是分开的(属性在顶部,方法在底部)。随着我们深入研究面向对象设计,这些类图将变得更加复杂,并传达更多关于不同类之间如何相互交互的信息。

1.8 封装和数据隐藏

使用对象的主要优势之一是对象无需展示其所有属性和行为。在良好的面向对象设计中(至少通常被认为是良好的),对象应该仅展示其他对象与其进行交互所必须的接口。与对象使用无关的细节应该对所有其他对象隐藏,基本上是基于“需要知道”的原则。

封装是因为对象包含了属性和行为而定义的。数据隐藏是封装的一个重要部分。例如,一个计算数字平方的对象必须提供一个接口来获取结果。但是,用于计算平方的内部属性和算法无需对请求的对象公开。强大的类是以封装为基础设计的。在接下来的部分中,我们将介绍接口和实现的概念,这是封装的基础。

1.8.1 接口

我们已经看到,接口定义了对象之间基本的通信方式。每个类设计都会指定对象正确实例化和操作的接口。对象提供的任何行为都必须通过使用其中一个提供的接口发送的消息来调用。接口应完全描述用户与类交互的方式。在大多数面向对象语言中,作为接口一部分的方法被指定为public的。

私有数据:为了使数据隐藏正常工作,所有属性都应声明为私有的。因此,属性永远不是接口的一部分。只有公共方法是类接口的一部分。将属性声明为公共的会破坏数据隐藏的概念。

让我们看一下刚刚提到的计算数字平方的例子。在这个例子中,接口将包括两个部分:

  • 如何实例化Square对象
  • 如何向对象发送一个值,并返回该值的平方

正如本章前面讨论的那样,如果用户需要访问一个属性,会创建一个方法来返回属性值(一个getter)。然后,如果用户想要获取属性的值,会调用一个方法来返回其值。通过这种方式,包含属性的对象控制对其属性的访问。这在安全性、测试和维护方面非常重要。如果您控制对属性的访问,当出现问题时,您就不必担心追踪可能已更改属性的每一段代码——它可以在一个地方(setter)进行更改。

从安全性的角度来看,您不希望无控制的代码更改或检索敏感数据。例如,当您使用 ATM 时,通过要求输入 PIN 来控制对数据的访问。

签名——接口与接口的区别:不要将用于扩展类的接口与类的接口混淆。我喜欢将由方法表示的接口等同于“签名”。

1.8.2 实现

只有公共属性和方法被视为接口。用户不应该看到任何内部实现的部分,只能通过类接口与对象交互。因此,任何定义为私有的都对用户不可访问,被视为类的内部实现的一部分。在先前的例子中,例如Employee类,只隐藏了属性。在许多情况下,也会有应该被隐藏的方法,因此不是接口的一部分。

继续上一节关于平方根的例子,用户并不关心如何计算平方根——只要是正确的答案即可。因此,实现可以改变,而不会影响用户的代码。例如,生产计算器的公司可以更改算法(也许是因为更有效),而不会影响结果。

1.8.3 接口/实现(Interface/Implementations)范例的现实世界示例

图 1.12 以真实世界的物体为例,说明了接口/实现范式,而不是使用代码。烤面包机需要电力。要获取这个电力,烤面包机的电源线必须插入电源插座,这就是接口。烤面包机所需做的一切就是实现符合电源插座规格的电源线;这就是烤面包机与电力公司(实际上是电力行业)之间的接口。对于烤面包机来说,实际的实现是一座燃煤电厂并不重要。事实上,就烤面包机而言,实现可以是一座核电站或者是一个当地的发电机。按照这个模式,任何家电只要符合接口规范,都可以获取电力,如图 1.12 所示。

图 1.12 电站例子

1.8.4 接口/实现范式的一个模型

让我们进一步探讨Square类。假设你正在编写一个计算整数平方的类。你必须提供单独的接口和实现。也就是说,你必须指定用户调用和获取平方值的方式。你还必须提供计算平方的实现;但是,用户不应该知道具体的实现细节。图1.13 展示了一种实现方式。请注意,在类图中,加号(+)表示公共减号(-)表示私有。因此,你可以通过前面带有加号的方法来识别接口。

图 1.13 Square类

Square 类图对应下面的代码:

public class IntSquare {
    // private attribute
    private int squareValue;

    // public interface
public intgetSquare (int value) {
SquareValue = calculateSquare(value);
return squareValue;
}

    // private implementation
private intcalculateSquare (int value) {
return value*value;
}
}

请注意,用户唯一可以访问的部分是公共方法getSquare,这是接口。平方算法的实现在方法calculateSquare中,这是私有的。还要注意,属性SquareValue是私有的,因为用户不需要知道这个属性的存在。因此,我们隐藏了实现的一部分:对象只展示用户与之交互所需的接口,而对于其他对象来说,与对象的使用无关的细节是隐藏的。如果实现发生了变化——假设你想要使用语言的内置平方函数——你不需要更改接口。这里的代码使用了 Java 库方法Math.pow,它执行相同的功能,但请注意接口仍然是calculateSquare

// private implementation
private intcalculateSquare(int value)
{
    return Math.pow(value, 2);
}

用户将通过相同的接口获得相同的功能,但是实现已经发生了变化。这在编写处理数据的代码时非常重要;例如,你可以将数据从文件移动到数据库,而无需强制用户更改任何应用程序代码。

1.9 继承

继承使一个类能够继承另一个类的属性和方法。这提供了通过从另一个类中抽象出共同属性和行为来创建新类的能力。

面向对象编程中的一个主要设计问题是提取出各个类的共性。例如,假设你有一个Dog类和一个Cat类,每个类都会有一个用于眼睛颜色的属性。在过程化模型中,DogCat的代码都会包含这个属性。在面向对象的设计中,颜色属性可以被移动到一个名为Mammal的类中,连同任何其他共同的属性和方法。在这种情况下,DogCat都从Mammal类继承,如图 1.14 所示。

图 1.14 Mammal 继承

狗类和猫类都继承自哺乳动物类。这意味着狗类有以下属性:

eyeColor		 // 继承自 Mammal
barkFrequency 	 // 仅针对 Dog

同样地,狗对象具有以下方法:

getEyeColor // 继承自 Mammal
bark 		// 仅针对 Dog

当实例化狗或猫对象时,它包含了自己类的所有内容,以及从父类继承的所有内容。因此,狗具有其类定义的所有属性,以及从哺乳动物类继承的属性。

**行为:**值得注意的是,今天的行为通常被描述在接口中,并且属性的继承是直接继承的最常见用法。通过这种方式,行为被从它们的数据中抽象出来。

1.9.1 超类和子类

超类或父类(有时称为基类)包含所有对继承自它的类都是共同的属性和行为。例如,在哺乳动物类的情况下,所有哺乳动物都具有类似的属性,如眼睛颜色和毛发颜色,以及行为,如产生内热和生长毛发。所有哺乳动物都具有这些属性和行为,因此不需要在继承树下为每种类型的哺乳动物重复它们。重复需要更多的工作,而且更令人担忧的是,它可能会引入错误和不一致性。

子类或子类(有时称为派生类)是超类的扩展。因此,狗类和猫类从哺乳动物类继承了所有这些共同的属性和行为。哺Mammal类被认为是DogCat子类或子类的超类。

继承提供了丰富的设计优势。当你设计一个Cat类时,Mammal类提供了许多所需的功能。通过从Mammal对象继承,Cat已经拥有了使其成为真正哺乳动物的所有属性和行为。

为了更具体地成为猫类型的哺乳动物,猫类必须包含任何仅适用于猫的属性或行为。

1.9.2 抽象

继承树可以变得非常庞大。当哺乳动物类和猫类完成后,其他哺乳动物,如狗(或狮子、老虎和熊),可以很容易地添加进来。猫类还可以是其他类的超类。例如,可能需要进一步抽象猫类,以提供波斯猫、暹罗猫等类。就像猫类一样,狗类可以是德国牧羊犬和贵宾犬等类的父类(见图 1.15)。继承的力量在于它的抽象和组织技术。

图 1.15 Mammal UML 图

这种多级抽象是许多开发者对使用继承持谨慎态度的原因之一。正如我们经常看到的那样,很难决定需要多少抽象。例如,企鹅是鸟类,老鹰也是鸟类,它们是否都应该继承自一个名为鸟的类,该类具有一个飞行方法?在大多数现代面向对象语言(如 Java、.NET 和 Swift)中,一个类只能有一个父类;然而,一个类可以有许多子类。有些语言,如 C++,可以有多个父类。前一种情况称为单继承,后一种情况称为多继承。

多重继承考虑一个子类同时继承自两个父类。这个子类继承哪双眼睛?当编写编译器时,这是一个重要的问题。C++ 允许多重继承;许多语言则不允许。

请注意,德国牧羊犬和贵宾犬两者都继承自狗类——每个都包含一个方法。然而,因为它们继承自狗类,它们也继承自哺乳动物类。因此,德国牧羊犬和贵宾犬类包含了狗类和哺乳动物类中包含的所有属性和方法,以及它们自己的属性和方法(见图 1.16)。

图 1.16 Mammal 层次结构

1.9.3 Is-A 关系

考虑一个形状(Shape)示例,其中圆(Circle)、正方形(Square)和星形(Star)直接从形状(Shape)继承。这种关系通常被称为Is-A关系,因为一个圆是一个形状,一个正方形是一个形状。当子类从超类继承时,它可以做任何超类可以做的事情。因此,圆、正方形和星形都是形状的扩展。

在图 1.17 中,每个对象上的名称分别表示了圆形、星形和正方形对象的draw方法。当设计这个形状系统时,标准化如何使用各种形状将非常有帮助。因此,我们可以决定,如果我们想要绘制一个形状,无论是什么形状,我们都会调用一个名为draw的方法。如果我们遵循了这个决定,无论我们想要绘制什么形状,只需要调用draw方法即可。这里体现了多态的基本概念——绘制自身的责任由各个对象自己承担,无论是圆形、星形还是正方形。这是许多当前软件应用程序中的一个常见概念,如绘图和文字处理应用程序。

图 1.17 shape 层次结构

1.10 多态

多态是一个希腊词,字面意思是“多种形态”。虽然多态与继承紧密相关,但它经常被单独提及,作为面向对象技术中最强大的优势之一。当向对象发送消息时,对象必须有一个方法定义来响应该消息。在继承层次结构中,所有子类都继承其超类的接口。然而,由于每个子类是一个独立的实体,因此可能需要对相同的消息作出不同的响应。

例如,考虑形状(Shape)类和名为draw的行为。当你告诉某人画一个形状时,首先问的是:“什么形状?”没有人可以画一个形状,因为它是一个抽象概念(事实上,在Shape代码中的draw方法不包含任何实现)。你必须指定一个具体的形状。为此,在Circle中提供了实际的实现。即使Shape有一个draw方法,Circle覆盖了这个方法,并提供了自己的draw方法。覆盖基本上意味着用子类的实现替换父类的实现。例如,假设你有一个包含三个形状的数组——圆形、正方形和星形。即使你将它们都视为Shape对象,并向每个Shape对象发送一个draw消息,由于CircleSquareStar提供了实际的实现,每个对象的最终结果都是不同的。简而言之,每个类能够对相同的draw方法做出不同的响应,并画出自己。这就是多态的含义。考虑下面的Shape类:

public abstract class Shape{
    private double area;
    public abstract double getArea();
}

Shape类具有一个名为area的属性,用于保存形状的面积值。方法getArea()包含一个称为abstract的标识符。当方法被定义为abstract时,子类必须为该方法提供实现;在这种情况下,Shape要求子类提供getArea()的实现。现在让我们创建一个名为Circle的类,它继承自Shape(使用extends关键字指定Circle继承自Shape):

public class Circle extends Shape {
    double radius;

    public Circle(double r) {
        radius = r;
    }

    public double getArea() {
        area = 3.14 * (radius * radius);
        return (area);
    }
}

在这里我们引入了一个新概念,称为构造函数。Circle类有一个与类同名的方法:Circle。当一个方法的名称与类名相同,并且没有提供返回类型时,该方法是一个特殊的方法,称为构造函数。可以将构造函数视为类的入口点,对象在这里被构建;构造函数是执行初始化启动任务的好地方。

Circle构造函数接受一个参数,代表半径,并将其赋值给Circle类的半径属性。Circle类还为getArea方法提供了实现,该方法最初在Shape类中被定义为抽象。我们可以创建一个类似的类,叫做Rectangle

public class Rectangle extends Shape {
    double length;
    double width;

    public Rectangle(double l, double w) {
        length = l;
        width = w;
    }

    public double getArea() {
        area = length * width;
        return (area);
    }
}

现在我们可以创建任意数量的矩形、圆形等,然后调用它们的getArea()方法。这是因为我们知道所有矩形和圆形都继承自Shape,并且所有Shape类都有一个getArea()方法。如果子类从父类继承了一个抽象方法,那么它必须提供该方法的具体实现,否则它本身将是一个抽象类(参见图 1.18 中的UML图)。这种方法也提供了轻松创建其他新类的机制。

图 1.18 shape UML图

因此,可以按照以下方法实例化Shape类:

Circle circle = new Circle(5);
Rectangle rectangle = new Rectangle(4,5);

然后,使用类似堆栈的数据结构,我们可以将这些Shape类添加到堆栈中:

stack.push(circle);
stack.push(rectangle);

什么是堆栈?

堆栈是一种后进先出的数据结构。它就像一个硬币兑换机,你把硬币插入圆筒的顶部,当你需要一枚硬币时,你从顶部取出最后一枚插入的硬币。将项目推入堆栈意味着你正在将一个项目添加到顶部(就像将另一个硬币插入兑换机中)。从堆栈中弹出一个项目意味着你正在从堆栈中取出最后一个项目(就像从顶部取出硬币)。

现在到了有趣的部分。我们可以清空堆栈,而不必担心其中有哪种形状类(我们只知道它们是形状):

while ( !stack.empty()) {
    Shape shape = (Shape) stack.pop();
    System.out.println ("Area = " + shape.getArea());
}

实际上,我们正在向所有形状发送相同的消息:

shape.getArea() 

但是,实际发生的行为取决于形状的类型。例如,Circle计算圆的面积,而Rectangle计算矩形的面积。实际上(这是关键概念),我们向形状类发送了一个消息,并根据使用的Shape子类的不同而体验到不同的行为。这种方法旨在提供跨类和应用程序的界面标准化。考虑一个包含文字处理和电子表格应用程序的办公套件应用程序。假设两者都有一个名为Office的类,其中包含一个名为print()的接口。这个print()接口对办公套件的所有类都是必需的。这里有趣的是,虽然文字处理器和电子表格都调用print()接口,但它们做的事情不同:一个打印文字处理文档,另一个打印电子表格文档。通过组合实现多态性 在“经典”面向对象编程中,多态性通常通过继承来实现;然而,有一种方法可以使用组合来实现多态性。我们在第 12 章“面向对象设计的SOLID原则”中讨论了这一点。

1.11 组合(Composition)

将对象看作包含其他对象是很自然的。例如,电视机包含调谐器和视频显示器。计算机包含视频卡、键盘和驱动器。虽然计算机可以被视为一个独立的对象,但驱动器也被认为是一个有效的对象。事实上,你可以打开计算机,取出驱动器并将其拿在手中。计算机和驱动器都被视为对象。只是计算机包含其他对象,比如驱动器。

因此,对象通常是从其他对象构建或组合而成的:这就是组合

1.11.1 抽象

就像继承一样,组合提供了一种构建对象的机制。事实上,我认为从其他类构建类只有两种方式:继承组合。正如我们所见,继承允许一个类从另一个类继承。因此,我们可以将常见类的属性和行为抽象出来。例如,狗和猫都是哺乳动物,因为狗是哺乳动物,猫也是哺乳动物。通过组合,我们也可以通过将一个类嵌入到另一个类中来构建类。

考虑汽车和发动机之间的关系。将发动机与汽车分开的好处是显而易见的。通过单独构建发动机,我们可以在各种汽车中使用发动机,更不用说其他的优势了。但我们不能说发动机是汽车。当这种说法从舌头上滚落时,听起来就不对劲(因为我们正在建模现实世界的系统,这就是我们想要的效果)。相反,我们使用术语 Has-A 来描述组合关系。汽车有一个发动机。

1.11.2 Has-A关系

虽然继承被认为是一个 Is-A 关系,但组合关系被称为 Has-A 关系。使用前面部分的例子,电视机有一个调谐器和一个视频显示器。显然,电视机不是调谐器,所以不存在继承关系。同样地,计算机有一个视频卡、有一个键盘和一个磁盘驱动器。继承、组合以及它们之间的关系将在第 7 章《掌握继承和组合》中详细介绍。

1.12 总结

在讨论面向对象技术时,有很多内容需要涉及。然而,通过本章,你应该对以下主题有一个良好的理解:

  • 封装:将数据和行为封装到单个对象中是面向对象开发中的主要内容。每个对象都包含其数据和行为,并且可以隐藏对其他对象的访问。
  • 继承:一个类可以从另一个类继承,并利用超类定义的属性和方法。
  • 多态性:多态性意味着类似的对象可以以不同的方式响应相同的消息。例如,你可能有一个拥有许多形状的系统。然而,圆形、正方形和星形都是以不同的方式绘制的。使用多态性,你可以向每个形状发送相同的消息(例如,Draw),并且每个形状负责绘制自己。
  • 组合:组合意味着一个对象是由其他对象构建的。本章涵盖了基本的面向对象概念,到目前为止,你应该对它们有一个良好的理解。