面向对象是怎样工作的-.第 4 章 面向对象编程技术: 去除冗余、进行整理
本章将介绍面向对象编程(Object Oriented Programming, OOP)的基本结构。
首先,我们将再次介绍一下之前提到的类、多态和继承这三种结构,并讨论实例变量与传统的全局变量和局部变量的区别,以及类类型的作用。然后,我们将简单地介绍一下进化的 OOP 结构,即包、异常和垃圾回收的相关内容。
这里我们将尽量避免拿现实世界的事物来打比方,而是单纯地将 OOP作为一种编程架构,来看一下它与之前的语言相比具有哪些优势。一些熟悉 OOP 的读者可能会认为没有必要专门花篇幅来介绍这些理所当然的内容,但笔者只是想借此机会和大家一起思考一下 OOP 取得了哪些进步。
4.1 OOP 具有结构化语言所没有的三种结构
OOP 具有之前的编程语言所没有的三种优良结构,分别是类、多态和继承。在 OOP 刚开始普及的 20 世纪 90 年代,它们经常被称为 OOP 的三大要素 。
在上一章的结尾,我们介绍了结构化语言无法解决的两个问题,一个是全局变量问题,另一个是可重用性差的问题。
而 OOP 的这三种结构正好可以解决这两个问题。
① OOP 具有不使用全局变量的结构。
② OOP 具有除公用子程序之外的可重用结构。
稍微延伸一下,也可以说 OOP 的三种结构是“去除程序的冗余、进行整理的结构”。
打个比方,那些让人难以理解的程序就像是一个乱七八糟的房间。由于无法马上在这样的房间里找到需要的东西,所以我们很有可能会再次购买,或者即使将房间里的某一处整理干净,周围也依然是乱作一团。如果要保持房间整洁,平时就要多加注意,此外还需要使用清理不必要物品
(去除冗余)的吸尘器和规整必要物品(进行整理)的收纳架(图 4-1)。
OOP 的三种结构为程序员提供了去除冗余逻辑、进行整理的功能。
类结构将紧密关联的子程序(函数)和全局变量汇总在一起,创建大粒度的软件构件。通过该结构,我们能够对之前分散的子程序和变量加以整理。多态和继承能够对公用子程序无法很好地处理的重复代码进行整合,彻底消除源代码的冗余。如果我们能使用这些结构开发出通用性较强的功能,就可以实现多个应用程序之间的大规模重用了。
不过类、多态和继承的名称比较特别,虽然这些结构在之前的编程语言中没有过,需要另起名称,但是这样的名称往往会让人感觉面向对象很难。
然而实际上这三种结构是非常接地气的,它们可以说是编程语言领域具有历史性意义的重大发明。也正因为如此,最初设计了这些结构、开发出最早的面向对象编程语言 Simula 67 的两名挪威科学家在 2001 年获得了图灵奖。另外,开发了 Smalltalk、提出面向对象概念的艾伦·凯也在两年后获得了图灵奖。
下面就让我们一起来揭开这项重大发明的神秘面纱吧。
4.2 OOP 的结构会根据编程语言的不同而略有差异
Java、Python、Ruby、PHP、C#、JavaScript、Visual Basic.NET、C++ 和 Smalltalk 等语言都属于 OOP,但它们的功能和语法却不尽相同。接下来我们将使用简单的 Java 示例代码来介绍 OOP 的基本功能,关于各编程语言的详细结构,请大家自行查看其语言规范。
本章以具有打开和关闭文件、读取一个字符这样的简单功能的文件访问处理作为示例。虽然示例代码使用 Java,但是为了方便起见,有时也会使用 Java 不支持的语法,所以书中有些代码是无法被 Java 编译器编译的,还请大家理解。
4.3 三大要素之一:具有三种功能的类
首先来介绍一下三大要素中的第一个要素——类。
这里,我们将类的功能总结为汇总、隐藏和“创建很多个”。
类的功能是汇总、隐藏和“创建很多个”。
① “汇总”子程序和变量。
② “隐藏”只在类内部使用的变量和子程序。
③ 从一个类“创建很多个”实例。
类结构本身并不难,但它能给编程带来很多积极的效果,可以说是一种非常强大的结构。接下来将依次对类的上述三个功能进行介绍。
4.4 类的功能之一:汇总
代码清单 4.1 中定义了 openFile(打开文件)、closeFile(关闭文件)和 readFile(读取一个字符)这三个子程序及一个全局变量A。下面我们使用类的功能来逐步修改该程序。
代码清单4.1 采用结构化编程的文件访问处理
首先来看一下汇总功能。
类能汇总变量和子程序。这里所说的变量是指 C 和 COBOL 等语言中的全局变量。在 OOP 中,由类汇总的子程序称为方法,全局变量称为实例变量(又称为“属性”“字段”),之后我们会根据情况使用这些术语。
下面就让我们使用类来汇总代码清单 4.1。在 Java 中,创建类时会在开头声明类名,并用大括号将汇总范围括起来。由于这里是将读取文本文件的子程序群和变量汇总到一起,所以我们将类命名为 TextFileReader,如代码清单 4.2 所示。
看到这里,估计有不少读者认为这只不过是将子程序和变量汇总在一起而已。实际上确实如此,但汇总和整理操作本身就是有价值的。打个比方,在收拾乱七八糟的屋子时,与其只准备一个大箱子,不如准备多个箱子分别存放衣服、CD、杂志、文具和小物件等,这样会更方便拿取物品,两者是同样的道理。
由于代码清单 4.2 的示例非常小,所以大家可能感受不到汇总的效果。请大家想象一下企业基础系统中使用的大规模应用程序,其代码往往有几
十万到几百万行。如果使用 C 语言平均为每个子程序编写 50 ~ 100 行代码,那么子程序就有几千到几万个。如果使用 OOP 平均在每个类中汇总10 个子程序,那么类的总数就是子程序总数的十分之一,为几百到几千个(图 4-2)。当然,即便如此数量依然庞大,但构件数量缩减为原来的十分之一这种效果是不容小觑的(另外,关于为了进一步整理大规模软件而将多个类进行分组的“包”功能,我们将在后文中进行介绍)。
图4-2 类的功能之一:汇总
汇总的效果并不只是减少整体构件的数量。我们再来比较一下代码清单 4.1 和代码清单 4.2。其实在汇总到类中时我们还略微进行了修改,大家注意到了吗?
没错,子程序的名称改变了。在代码清单 4.1 中,子程序的名称是openFile、closeFile和 readFile,都带有 File。而在代码清单4.2 中去掉了 File,子程序的名称分别为 open、close和 read。
后一种命名方式显然更轻松。在没有类的结构化语言中,所有子程序都必须命名为不同的名称,而类中存储的元素名称只要在类中不重复就可以。在代码清单 4.2 中,我们将类命名为 TextFileReader,声明该类是用于读取文件的,因此就无须在各个方法的名称中加上 File了。这样一来,即使其他类中也有 open、read和 close等同名的方法,也不会发生什么问题。举例来说,一个家庭中所有成员的名字应该都不相同,但是和姓氏不同的邻居家的家庭成员重名则没有关系(类名冲突可以使用包进行回避,我们将在本章后半部分介绍包的相关内容)。
为汇总后的类起合适的名称也便于查找子程序。虽然这个步骤看起来并不起眼,但它却是促进可重用的重要功能之一。无论编写的子程序的质量多高,如果因为数量太多而难以查找和调用,那么也是没有任何意义的。反之,如果编写的子程序便于查找,那么对其进行重用的机会也会增加。
我们来总结一下汇总功能。
< 类的功能之一:汇总 >
能够将紧密联系的(多个)子程序和(多个)全局变量汇总到一个类中。
优点如下。
● 构件的数量会减少
● 方法(子程序)的命名变得轻松
● 方法(子程序)变得容易查找
4.5 类的功能之二:隐藏
接下来我们看一下隐藏功能。
在代码清单 4.2 中,子程序和全局变量都汇总到了类中,但是在这种状态下,从类的外部仍然可以访问 fileNum变量 。TextFileReader类的 open、close和 read方法会访问 fileNum变量,但其他处理则无须访问,因此最好限定为只有这三个方法能访问该变量。这样一来,当fileNum变量中的值异常而导致程序运行错误时,我们只要调查这三个方法就可以了。另外,以后在需要将 fileNum变量的类型由 int改为 long等时,还可以缩小修改造成的影响范围。
OOP 具有将实例变量的访问范围仅限定在类中的功能。加上该限定后的代码如代码清单 4.3 所示。
代码清单4.3 隐藏实例变量
代码清单 4.2 与代码清单 4.3 只存在细微的差别。后者在实例变量的声明之前添加了 private,这是一种隐藏结构(图 4-3),表示将 fileNum变量隐藏起来。英文“private”这个形容词的含义为“私人的”“秘密的”。通过指定为 private,我们可以限定只有类内部的方法才能访问 fileNum变量,如此一来该变量就不再是全局变量了。
图 4-3 类的功能之二:隐藏
除了隐藏变量和方法之外,OOP 中还具备显式公开的功能。由于TextFileReader类中的三个方法是提供给程序的其他部分使用的,所以我们将其声明为显式公开的方法。修改后的代码如代码清单 4.4 所示。
代码清单4.4 公开类和方法
由于在类和方法的声明部分指定了 public,所以从应用程序的任何位置都可以对其进行调用。
< 类的功能之二:隐藏 >
能对其他类隐藏类中定义的变量和方法(子程序)。这样一来,我们在写程序时就可以不使用全局变量了。
4.6 类的功能之三:创建很多个
最后是“创建很多个”的功能。
可能有的读者已经发现了,用 C 语言也可以实现前面介绍的汇总和隐藏功能 A。然而,使用传统的编程语言则很难实现“创建很多个”的结构,可以说这是 OOP 特有的功能。
下面我们通过示例程序进行讲解。
请大家再看一下代码清单 4.4。它是一个打开文件、读取字符,最后关闭文件的程序。当只有一个目标文件时,这是没有什么问题的,但如果应用程序要比较两个文件并显示其区别,情况会怎样呢?也就是说,需要同时打开多个文件并分别读取内容。我们在代码清单 4.4 中只定义了一个存储正在访问的文件编号的变量。可能有读者会想:“将存储文件编号的变量放到数组中不就行了吗?”请大家放心,即使不进行任何修改,也能同时访问多个文件。
其奥秘就是实例。
我们在第 2 章中介绍过类和实例,还以动物为例,将狗当作类,将斑点狗和柴犬等具体的狗当作实例。
不过,实例并不是直接表示现实世界中存在的事物的结构,而是类定义的实例变量所持有的内存区域。另外,定义了类就可以在运行时创建多个实例,也就是说,能够确保多个内存区域(图 4-4)。
图 4-4 类的功能之三:创建很多个
我们在前面介绍过,类能汇总实例变量和方法。不过,如果同时创建多个实例,那么在调用方法时就不知道到底哪个实例变量才是处理对象了,因此 OOP 的方法调用代码的写法稍微有点特殊。
在传统的子程序调用的情况下,只需简单地指定所调用的子程序的名称。而在 OOP 中,除了调用的方法名之外,还要指定对象实例。根据 Java语法,应在存储实例的变量名后加上点,然后再写方法名,如下所示。
存储实例的变量名 . 方法名 ( 参数 )
下面我们就来介绍一下代码清单 4.4 的程序的调用端是什么样子的。请大家看代码清单 4.5。
代码清单4.5 “创建很多个”实例
这里,首先从 TextFileReader类创建两个实例,并存储到 reader1和 reader2这两个变量中。之后的打开文件、读取字符及关闭文件等处理都是通过指定变量 reader1和 reader2来调用方法的。像这样,在OOP 中,在调用方法时需要指定以哪个实例为对象。
根据“创建很多个”的结构,类中方法的逻辑就变得简单了。代码清单 4.4 中只编写了一个 fileNum变量,这意味着定义类的一端完全无须关心多个实例同时运行的情形。传统的编程语言中没有这种结构,所以要想实现同样的功能,就需要使用数组等结构来准备所需数量的变量区域,因此执行处理的子程序的逻辑也会变得很复杂。
一般来说,由于在应用程序中同时处理多个同类信息的情况很普遍,所以这种结构是非常强大的。文件,字符串,GUI 中的按钮和文本框,业务应用程序中的顾客、订单和员工,以及通信控制程序中的电文和会话等,都会应用这样的结构,而 OOP 仅通过定义类就可以实现该结构,非常方便。
< 类的功能之三:创建很多个 >
一旦定义了类,在运行时就可以由此创建很多个实例。
这样一来,即使同时处理文件、字符串和顾客信息等多个同类信息,也可以简单地实现该类内部的逻辑。
以上就是对汇总、隐藏和“创建很多个”这三种功能的介绍.
类结构为编写程序提供了许多便捷功能,但 Java、Python 和 Ruby 等实际的编程语言都有其各自的功能和详细规范,因此我们可能需要花费一些时间才能充分理解并熟练运用类结构。为了避免在理解时产生混乱,请大家一定要掌握这里介绍的三种功能。
<OOP 的三大要素之一:类 >
类是“汇总”“隐藏”和“创建很多个”的结构。 ① “汇总”子程序和变量。
② “隐藏”只在类内部使用的变量和子程序。 ③ 从一个类“创建很多个”实例。
4.7 实例变量是限定访问范围的全局变量
下面让我们试着从其他角度来看一下类结构。
如前所述,类结构可以将传统定义的全局变量隐藏为类内部的实例变量。为了更深入地理解类结构与传统结构的不同,我们来比较一下实例变量、全局变量和局部变量。
实例变量的特性如下所示。
< 实例变量的特性 >
① 能够隐藏,让其他类的方法无法访问。
② 实例在被创建之后一直保留在内存中,直到不再需要。
全局变量的问题在于,程序中的任意位置都可以对其进行访问。由于全局变量一直存在,所以非常适合用来存储在非子程序运行期间也需管理的信息。而局部变量只可以由特定的子程序访问,只能存储仅在子程序运行期间存在的临时信息。
我们将以上比较结果汇总在表 4-1 中。表4-1 三种变量的比较
也就是说,实例变量融合了局部变量能够将影响范围局部化的优点以及全局变量存在期间长的优点。我们可以将实例变量理解为存在期间长的局部变量或者限定访问范围的全局变量。
实例变量是存在期间长的局部变量或者限定访问范围的全局变量。
另外,实例变量和全局变量一样,在程序中并不是唯一存在的,通过创建实例,能够根据需要创建相应的变量区域。这种灵活且强大的变量结构在传统编程语言中是不存在的(图 4-5)。
图 4-5 传统的程序和面向对象程序的结构的区别
4.8 三大要素之二:实现调用端公用化的多态
接着我们来看一下三大要素中的第二个要素——多态(polymorphism)。顾名思义,多态具有“可变为各种状态”的含义。简单地说,多态可以说是创建公用主程序的结构。公用子程序将被调用端的逻辑汇总为一个逻辑,而多态则相反,它统一调用端的逻辑(图 4-6)。
<OOP 的三大要素之二:多态 >
多态是统一调用子程序端的逻辑的结构,即创建公用主程序的结构。
大家可能会觉得“公用主程序”这样的说法有点陈旧,但绝不可小瞧多态。虽说多态只是实现了程序调用端的公用化,但其重要性绝不亚于前面提到的类。在 OOP 出现之前,公用子程序就已经存在了,但公用主程序并没有出现。框架和类库等大型可重用构件群也正是因为多态的存在才成为可能。因此,将多态称为与子程序并列的重大发明也不为过。
下面来看一个多态的简单程序示例。我们在前面创建了读取文本文件的类,这次试着创建一个读取通过网络发送的字符串的类,并将该类命名为 NetworkReader(代码清单 4.6)。
代码清单4.6 NetworkReader类
为了使用多态,被调用的方法的参数和返回值的形式必须统一。在代码清单 4.4 中,TextFileReader的 open方法的参数指定了文件的路径名,而为了将其与网络处理统一,指定文件的路径名是不恰当的。因此,我们修改一下 TextFileReader类,在创建实例时指定文件的路径名(代码清单 4.7)。
另外,为了使调用端,即公用主程序端无须关注文本文件和网络,我们准备一个新类,并将其命名为 TextReaderA(代码清单 4.8)。
接着,我们在 TextFileReader和 NetworkReader中声明它们遵循由 TextReader确定的方法调用方式。代码清单 4.9 中的 extends TextReader是继承(后述)的声明,意思是遵循超类 TextReader中定义的方法调用方式。
代码清单4.9 继承的声明
这样就完成了准备工作。通过多态结构,无论是从文件还是网络输入的
字符,我们都可以轻松地编写出计算字符个数的程序(代码清单 4.10)。
代码清单4.10 使用多态
在代码清单 4.10 中,getCount方法的参数可以指定 TextFileReader或者 NetworkReader。另外,即使添加了其他输入字符串的方法,如控制台输入等,也完全不需要对代码清单 4.10 的程序进行修改(图 4-7)。
4.9 三大要素之三:去除类的重复定义的继承
OOP 三大要素中的最后一个要素是继承。
简单地说,继承就是“将类的共同部分汇总到其他类中的结构”。利用该结构,我们可以创建一个公用类来汇总变量和方法,其他类则可以完全借用其定义(图 4-8)。
图 4-8 继承的结构
在 OOP 之前的由子程序构成软件的编程环境中,我们会创建一个公用子程序来汇总重复的命令群。同理,在由类构成软件的 OOP 环境中,我们
可以创建一个公用类来汇总变量和方法。也就是说,不仅局限于通过前面介绍的多态来统一调用端,而且还要汇总相似的类中的共同部分。这是一种通过尽可能多地提供功能来让编程变轻松的思想。
在使用继承的情况下,我们将想要共同使用的方法和实例变量定义在公用类中,并声明想要使用的类继承该公用类,这样就可以直接使用公用类中定义的内容。在 OOP 中,该公用类称为超类,利用超类的类称为
子类。
另外,声明继承也就是声明使用多态 A。因此,在声明继承的子类中,为了统一方法调用方式,继承的方法的参数和返回值类型必须与超类一致(图 4-9)。
图 4-9 继承和多态
这里省略了继承的示例代码,我们将在第 5 章中详细介绍,感兴趣的读者请参考第 5 章的内容。
下面就让我们来总结一下继承结构。
<OOP 的三大要素之三:继承 >
继承是将类定义的共同部分汇总到另外一个类中,并去除重复代码的结构。
4.10 对三大要素的总结
对 OOP 的三大要素——类、多态和继承的讲解就到此为止,下面我们再来整理一下(表 4-2)。
OOP 之前的编程语言只能通过子程序来汇总共同的逻辑。由于子程序和全局变量是独立存在的,所以很难知道是哪一个子程序修改了全局变量。
OOP 中提供了类结构来解决这个问题。类通过汇总子程序和变量,减少了构件数量,优化了整体效果。再加上多态和继承结构,OOP 使得子程序无法实现的逻辑的公用化也成为可能。
这三种结构并不是分别出现的,在最初的面向对象编程语言 Simula 67 中就拥有这三种结构,真是让人惊叹。提起 1967 年,就不得不提到无GOTO 编程,这真是不平凡的一年。OOP 可以看作结构化语言的发展形式,但考虑到它在那个时代就出现了,因此说是编程语言的突然变异也不为过。
此外,通过组合这些结构还可以实现之前的子程序无法实现的大型重
用(关于框架、类库等大规模软件构件群,我们将在第 6 章进行介绍)。
4.11 通过嵌入类型使程序员的工作变轻松
除了上述的 OOP 三大要素之外,我们还有一个重要话题,那就是“通过嵌入类型使工作变轻松的结构”。虽然这主要是类的作用,但也与三大要素有关,所以我们在这里介绍一下。
可能有人会对“通过嵌入类型使工作变轻松”的说法产生怀疑,因为
“嵌入类型”给人一种比较死板的感觉。而在编程语言的情况下,嵌入类型确实是可以让程序员的工作变轻松的(图 4-10)。
图 4-10 嵌入类型可以让程序员的工作变轻松
在程序中定义存储值的变量时,我们会指定整型、浮点型、字符型和数组型等“类型”。
为什么要给变量指定类型呢?对有经验的程序员来说,给变量指定类型
可能已经成了他们的一种编程习惯,没有必要再重新考虑类型的含义等。
指定类型的原因有如下两个。
首先是为了告诉编译器内存区域的大小。变量所需的内存区域会根据类型自动确定,比如整型是 32 位,浮点型是 64 位(实际上,位数会根据硬件、操作系统及编译器的不同而不同)。因此,通过声明变量的类型,编译器就可以计算出在内存中保持该变量所需的内存空间。
其次是为了防止程序发生错误。当写出整数与字符相乘或用数组减
去浮点数等比较奇怪的逻辑时,在编译或运行程序时就会发生显式的错误。
4.12 将类作为类型使用
OOP 中进一步推进了这种类型结构,程序员也可以将自己定义的类作为类型使用 .OOP 中可以将类作为类型进行处理。
作为类型的类与数值型、字符型一样,可以在变量定义、方法的参数和返回值的声明等多处进行指定(代码清单 4.11)。
代码清单4.11 使用类的类型声明
对于类型指定为类的变量、参数和返回值,如果要存储该类(及其子类)之外的实例,那么在编译和运行程序时就会发生错误 。
例如,在使用 Java 编写如下逻辑的情况下,在编译时就会发生错误(代码清单 4.12)。
代码清单4.12 对变量赋值的类型检查
代码中的 (1) 处将 reader变量声明为 TextFileReader类型,因此 reader变量中只可以存储从 TextFileReader类创建的实例。(2) 处要将数值和其他类的实例存储到该 reader变量中,因此会发生编译错误。(3) 处存储了 TextFileReader类的实例,因此编译通过。
同样,对于方法的参数和返回值的类型,如果指定了错误的实例,也会发生错误(代码清单 4.13)。
代码清单4.13 对方法的参数和返回值的类型检查
在机器语言和汇编语言时代,这种类型检查结构几乎是不存在的。高级语言和结构化语言中导入了一些结构来检查编程语言自带的数据类型和结构体 A 的使用方法。OOP 则更进一步,通过将汇总变量和方法的类定义为类型,从而将类型检查作为一种程序规则来强制要求。
像这样,编译器和运行环境会匹配类型来检查逻辑,因此程序员的工作就会轻松许多。
另外,根据编程语言的种类的不同,这种类型检查结构分为静态类型和动态类型两种。静态类型方式在程序编译时检查错误,Java 和 C# 等就采用这种方式。动态类型方式在程序运行时检查错误,Python、Ruby、 PHP 和 Smalltalk 等采用的就是这种方式。
类型检查分为静态类型和动态类型两种方式。
4.13 编程语言“退化”了吗
下面我们暂时换一个话题。关于预防程序错误,在编程语言的规范上也发生了与强化类型检查目的相同的变化。
比如,比较新的编程语言 Java 并不支持 GOTO 语句(该语句导致了面条式代码的产生)。Java 沿用了 C 语言和 C++ 的基本语言规范,并摒弃了一些功能,包括 GOTO 语句、显式指针、结构体、全局变量和宏等。Java开发者认为这些功能会让程序变得难懂,或者容易出错,所以最好一开始就不提供。
也就是说,随着编程语言的进化,其功能并不是在一味地增加,还会减少,使编程语言朝着看似“退化”的方向发展。人类的进化过程也同样如此。在脑容量变大的同时,人类不再使用的尾巴和盲肠则逐渐退化了。
4.14 更先进的 OOP 结构
到这里为止,我们介绍了 OOP 的三大要素——类、多态和继承,以及类型检查和语言规范的变化。
不过,Java、C#、Python、PHP 和 Ruby 等比较新的编程语言提供了更先进的结构,其中比较典型的有包、异常和垃圾回收。设计这些结构是为了促进重用、减少错误等。下面我们就来简单地了解一下这些结构。
4.15 进化的 OOP 结构之一:包
首先来介绍一下包。
前面我们介绍了具有汇总功能的类结构,而包是进一步对类进行汇总的结构(图 4-11)。
图 4-11 包的结构
包只是进行汇总的容器,它不同于类,不能定义方法和实例变量。有的读者可能会想这种结构有什么用,为了回答这个问题,我们来联想一下文件系统中的目录(文件夹)。虽然目录只是存储文件的容器,但是通过给目录命名,并在其下存储文件,文件管理就会变得非常轻松。反之,我们再想象一下没有目录、所有文件都存储在根目录下的状态。如果文件系统是这样的结构,那么使用起来一定很不方便。包的作用也是如此。它与目录一样,除了类之外,还可以存储其他包,从而创建层次结构。
采用这种结构,即使是代码行数达到几十万、几百万行的大型应用程序,也可以全部放到几十个包中。通过明确包的作用,并将作用相关的类汇集在一起,使用起来就会非常方便。
包还具有防止类名重复的重要作用(图 4-12)。比如,Java 采用类似于网络域名的形式来命名包,首先是国家名称,然后是组织类型(公司、学校等),接下来是组织名称,这是基本的命名规则。只要遵循该规则,无论其他组织编写什么类,都无须关心类名是否重复,从而实现重用。
4.16 进化的 OOP 结构之二:异常
接下来介绍异常。
如果用一句话来概括异常,那就是:采用与返回值不同的形式,从方法返回特殊错误的结构。
像网络通信故障、硬盘访问故障或者数据库死锁等,都属于“特殊错误”。除了故障之外,也存在无法返回正常的返回值的情况,比如文件读取处理中返回 EOF(End Of File,文件结束符)。在传统的子程序结构中,通常使用错误码来处理这种情况。具体来说,就是确定值的含义,并将其作为子程序的返回值返回,例如错误码为 1时表示死锁、为 2时表示通信故障、为 3时表示其他致命错误等,但是这种方法存在两个问题。
第一个问题是需要在应用程序中执行错误码的判断处理。如果忘记编写判断处理,或者弄错值,那么在发生故障时就很难确定具体原因。另外,在添加、删除错误码的值的情况下,程序员需要亲自确认所有相关的子程序来改写。
第二个问题是判断错误码的相同逻辑在子程序之间是连锁的。通常在调用端的子程序中必须编写判断错误码的值的逻辑。另外,当调用端的子程序中无法执行错误的后续处理时,就会返回同样的错误码。像这样,如果错误码的判断处理在整个应用程序中连锁,那么程序逻辑就会变得很冗长(图 4-13)。
图 4-13 基于错误码方式的错误处理的连锁
异常就是用于解决以上问题的结构。
异常结构会在方法中声明可能会返回特殊错误。这种特殊错误的返回方式就是异常,其语法不同于子程序的返回值。
在声明异常的方法的调用端,如果编写的异常处理逻辑不正确,程序就会发生错误 A,这样就解决了第一个问题。
另外,在声明异常的方法的调用端,有时在发生错误时并不执行特殊处理,而是将错误传递给上位方法。在这种情况下,只需在方法中声明异常,没有必要编写错误处理,这样就解决了第二个问题(图 4-14)。
图 4-14 基于异常结构的错误处理
这种结构可以将重复的错误处理汇总到一处,并且当忘记编写必要的错误处理时,编译器和运行环境会进行提醒,非常方便。这种结构可以达到去除冗余、防止错误的效果。
4.17 进化的 OOP 结构之三:垃圾回收
我们在前面介绍类的“创建很多个”的功能时,提到过在运行时创建实例的话题,但并未涉及如何删除实例的相关内容。当创建实例时,就会为实例变量分配相应的内存区域。当采用 OOP 编写的应用程序运行时,为了从类创建实例并执行动作,根据应用程序的不同,有时可能会在运行时创建很多实例。
在 C 和 C++ 等之前的编程语言中,需要在应用程序中显式地指示删除不再需要的内存区域。但是,在编写删除实例的处理代码时需要多加注意。如果误删了其他地方仍在使用的实例,当之后执行到使用该实例的逻辑处理时,程序的动作就会出现错误。反之,如果忘记删除任何地方都不再使用的实例,不需要的实例就会不断增多,从而占用内存,造成内存泄漏。在 OOP 中,使用“创建很多个”功能,我们可以自由地创建实例,但在删除时需要慎重进行。
Java 和 C# 等很多 OOP 中采用了由系统自动进行删除实例的处理的结构,该结构称为垃圾回收(Garbage Collection,GC)。
在这种结构中,删除内存中不再需要的实例是系统提供的专用程序——垃圾回收器的工作。采用这种结构,程序员就不用再编写容易出错的删除实例的处理了(图 4-15)。
图 4-15 垃圾回收器删除内存中不再需要的实例
这种结构不将容易出错的内存释放处理作为编程语言的语法提供,而是由系统自动执行。正如前面介绍的那样,这也可以看作一种为了让程序员的工作变轻松而“退化”的语言规范。
另外,关于垃圾回收的详细内容,我们将在第 5 章进行介绍。
4.18 对进化的 OOP 结构的总结
本章介绍了 OOP 提供的能让程序员的工作变轻松的结构,包括类、多态、继承、包、异常和垃圾回收,这些结构都有助于编写出高质量的程序。第 3 章中介绍了机器语言到结构化语言的进化,这里我们再整理一下 OOP中又发生了什么样的进化。
编程语言进化到高级语言时,通过高级命令实现了表现力的提高,使用子程序去除了重复逻辑。
在接下来的结构化语言中,又强化了有助于维护程序的功能,导入了三种基本结构、无 GOTO 编程以及强化子程序独立性的结构。
为了进一步提高程序的可维护性和质量,OOP 中提供了一些通过添加限制来降低复杂度的功能。另外,还大幅强化了构件化、可重用的功能。下面我们对这些内容加以总结,如图 4-16 所示。
从以上内容可以看出,OOP 绝不是替代了传统的编程技术,而是以之前的编程技术为基础,并针对之前的技术缺点进行了补充。与传统的编程语言相比,OOP 导入了许多变化非常大的独特结构,甚至可以说它是编程语言的突然变异。不过,在编程技术不断发展的过程中,这些结构也是必然会出现的。
为了写出高质量、可维护性强且易于重用的软件,请大家一定要使用OOP。这是因为 OOP 是凝聚了前人智慧与研究成果的编程技术。
4.19 决心决定 OOP 的生死
有人说面向对象不是结构的问题,而是一种思想。还有人说在 C++ 和Java 普及之前,只要有干劲,无论是 C 语言还是 COBOL,都可以实现面向对象编程。笔者认为,从某种意义上来说,这些观点不无道理。
这是因为 OOP 是一种手段,其目的不在于被人们使用,而是提高程序的质量、可维护性和可重用性。
本章开头介绍过,OOP 是去除程序冗余、进行整理的编程技术。笔者还打比方说,这就像打扫凌乱的房间需要吸尘器和整理架一样。
当然,仅准备新的吸尘器和使用方便的整理架,房间并不会变整洁。更重要的是要有打扫房间的决心,以及将这种决心转变为行动的执行力。
编程也是一样。仅使用类、多态和继承等结构,并不能提高程序的可维护性和可重用性。在使用这些结构时,即便弄错一个,也会让问题变得很棘手。因此,切不可胡乱使用,否则程序就会变得难以理解。
特别是像 OOP 这样有趣的结构,人们一旦对其有所了解,无论如何都想立即使用,这也是人之常情。如果是出于兴趣而编写的程序,这样做倒没有什么关系,但如果是实际工作中使用的程序,这样就会很麻烦。切记我们的目的是编写出高质量、易于维护和可重用的程序,面向对象只是实现该目的的一个手段而已。
能否充分发挥 OOP 的功能,取决于使用它的程序员。我们首先要思考怎么做才能使程序更容易维护和重用,然后考虑使用三种基本结构和公用子程序来实现。如果这样还不够,那就轮到类、多态和继承大显身手了。