讲述面向对象中的一个重要概念——继承,使用继承 可以方便地在已有类的基础上进行扩展,定义一个具有父 类全部功能的新类。
父类和子类
我们在定义一个新类的时候,经常会遇到要定义的新类是某个类的扩展或者是对某个类的修正 这种情况。如果可以在已有类的基础上追加内容来定义新类,那么新类的定义将会变得更简单。
像这种通过扩展或者修改既有类来定义新类的方法叫作 继承 (inheritance)。在继承关系中,被继
承的类称为 父类 (superclass),通过继承关系新建的类称为 子类 (subclass)。
继承意味着子类继承了父类的所有特性,父类的数据成员和成员函数自动成为子类的数据成员
和成员函数。除此之外,子类还可以
● 追加新的方法
● 追加新的实例变量
● 重新定义父类中的方法
当然,如果子类中只追加新的实例变量而不变更方法则没有任何意义。子类中重新定义父类的方法 叫作 重写 (override)。
让我们来看几个例子。在图 3-1 中,类 B 是类 A 的子类,类 B 继承了类 A 的实例变量和方法, 但重写了 method2。类 C 也是类 A 的子类,类 C 中增加了新的实例变量 z 和新的方法 method3。类 B 和类 C 都是类 A 的子类,无论类 A、类 B 和类 C 的任何一个实例变量都能够执行方法 method1 和 method2。
enter image description here父类和子类是一种相对的称呼。例如,在上例中,如果以类 B 为父类又派生出一个子类 D,那 么类 B 相对于类 A 是子类,相对于类 D 却为父类。
另外,在集合用语中,子集指的是比较小的集合(相对于父集),但在类的情况下子类一般是父 类的扩展。为了避免这种命名上的混乱,C++ 中把父类称为 基类 (base class),把子类称为 派生 类 或 导出类 (derived class)。考虑到面向对象的程序设计中一般都使用父类、子类的叫法,本书也 使用这种叫法。
一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:1012951431, 分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!希望帮助开发者少走弯路。
类的层次结构
假如以某个类为父类生成若干子类,然后再继承这些子类并生成更多的子类,如此循环下去就 可能会生成一颗倒立的树,它由通过继承而彼此关联的类组成,这样的树称为 类层次结构 (class hierarchy)。
位于类层次最顶端的类称为 根类 (root class),如图 3-2 所示。
enter image description hereNSObject 是 Cocoa 环境下的根类,Cocoa 中所有的类都直接或间接地继承了 NSObjectA。新建的 任何类都必须是 NSObject 或它的继承类的子类。NSObject 中定义了所有 Objective-C 对象的基本 方法。
由于这种类的层次关系,Objective-C 的所有对象都继承了 NSObject 类中定义的各种属性。 Objective-C 的对象能够作为对象来使用,就是因为类 NSObject 中定义了对象的基本功能。 在面向对象的语言中,有的和 Objective-C 一样有唯一根类,例如 Java 和 Smalltalk 等;有的则不 存在唯一根类,如 C++。
利用继承定义新类
继承的定义
如果想通过继承为某个类定义一个子类,该怎么办呢?
Objective-C 在子类的接口部分声明继承关系。在 2.2 节中我们已经说明了如何定义类的接口,这 里再介绍一遍。
定义父类 A 的子类 B 的时候,“类名”是新类 B,冒号后面的“父类名”是需要继承的类 A。
至此为止本书中的父类都使用了 NSObject,这是因为 Objective-C 中所有的类都要继承根类, 而 NSObject 是 Objective-C 中所有类的根类 。如果子类有想继承的类,就要直接指明该类为父类,否 则就需要指定 NSObject 为父类。前文中定义 Volume 类的时候,因为 Volume 类并没有特别想继承的 类,所以直接使用了 NSObject 作为父类。
实例变量的声明中只需要声明新增的变量。如果没有新增的变量,则只需要加上 {} 即可,有时 甚至连 {} 都可以省略。 方法的声明中只需要追加新增的方法。如果要覆盖父类中已声明的方法(重写),则需要在接口 中对方法重新声明。通常我们会给重写的方法加上注释,以便理解。
下面展示了定义类 A 的子类 B 时接口部分的情况。变量 x 和方法 method1 继承于类 A,所以不 需要重新声明,方法 method2 的声明也可以被省略。
类定义和头文件
假设有一个已经定义好了的类 Alpha,那么头文件 Alpha.h 就应该已经存在。要定义类 Alpha 的 子类 Beta 的时候,头文件 Beta.h 中必须包含 Alpha.h。不知道父类定义的话是无法定义子类的。所以 包含父类接口的头文件是必须的。
类的实现部分必须引入包含类的接口部分的头文件。实现部分需要包含新增和重写的方法的实 现。当然实现部分也可以定义各种局部函数和变量。
图 3-3 的文件 Gamma.m 的方法中调用了方法doSomething ,这个方法是从类 Alpha 继承而来 的。 文 件 Gamma.m 引 入 的 头 文 件 Gamma.h 中 引 入 了 Beta.h,Beta.h 中 又 引 入 了 Alpha.h, 所 以 Gamma.m 可以调用方法doSomething。
类的定义可以不断地使用继承向下扩展,但无论怎么扩展,只要保证了这种头文件的引入方式, 任何一个派生类中就都能使用父类中定义的变量和方法。
继承和方法调用
子类中定义的方法,除了能够访问新追加的实例变量外,也能够访问父类中定义的实例变量。
另外,因为继承的原因,子类也可以响应父类中定义的消息。但如果子类中重写了父类的方法, 就需要注意实际运行中到底哪个方法(父类的还是子类的)被执行了。
如图 3-4 所示,类 A 包含方法 method1、method2、method3。类 B 是类 A 的子类,类 B 中重新 定义了 method2。类 C 是类 B 的子类,类 C 中重新定义了 method1。
我们来看看给类 B 的实例变量发送消息时的情况。首先,假设向类 B 的实例对象发送了对应 method1 的消息,即进行了方法调用。虽然类 B 中没有 method1 的定义,但因为类 B 的父类类 A 中 定义了 method1,所以会找到类 A 的 method1,调用成功。消息 method3 的情况下也是同样的道理, 类 A 中定义的 method3 会被执行。method2 同前两个消息不同,类 B 中定义了 method2,所以会使 用自身定义的 method2 来响应这个消息。
而给类 C 的实例发送消息的话会怎么样呢?类 C 中有 method1 的定义,所以会直接使用类 C 中 定义的 method1 来响应这个消息。类 C 中没有 method2 的定义,所以调用的时候会使用类 B 中定义 的 method2 来响应。类 C 和类 B 中都没有定义 method3,所以类 A 中的定义 method3 会被调用。
调用父类的方法
子类继承了父类之后,有时就可能会希望调用父类的方法来执行子类中定义的其他处理,或者 根据情况进行和父类一样的处理或子类中单独定义的处理。让我们来看看图 3-4 中的例子,如果要在 类 B 的 method2 的定义中调用类 A 的 method2,那么该怎么办呢?通过 self 调用 method2 的话,就 会变成递归调用自身定义的 method2。
如果子类中想调用父类的方法,可以通过 super 关键字来发送消息。使用 super 发送消息后,就 会调用父类或父类的父类中定义的方法。如图 3-5 所示,类 C 中定义了 method1 和 method3。类 C 的 method1 中通过 super 调用了 method3,这时被调用的 method3 是类 A 中定义的 method3。
super 和 self 不同,并不确定指向某个对象。所以 super 只能被用于调用父类的方法,不能通过 super 完成赋值,也不能把方法的返回值指定为 super。
初始化方法的定义
新追加的实例变量有时需要被初始化。另外,子类也可能需要同父类不同的初始化方法。这些 情况下就需要为子类定义自己的初始化方法。
子类中重写 init 初始化方法的时候,通常按照以下逻辑。其他以 init 开头的初始化方法也是同理。
请注意第一行调用了父类的init 方法,父类的init 方法会初始化父类中定义的实例变量。下 面是子类专有的初始化操作。
如果所有的类的初始化方法都这样写,那么根类 NSObject 的init 方法就一定会被执行。否则 生成的对象就无法使用。与此同时,这样做也可以防止漏掉父类中定义的实例变量的初始化。
执行的时候父类的初始化方法可能会出错。出错时则会返回 nil,这种情况下子类也不需要再进
行初始化,直接返回 nil 就可以了。
如果父类是 NSObject,则基本上不可能初始化出错,因此不判断这个返回值也是可以的。使用 传入的参数或通过从文件读入变量进行初始化时,因为值的类型错误或读取文件失败等原因,初始 化有可能会失败。这种情况下,需要确认父类的初始化方法的返回值。另外,上例中对 self 进行了 赋值,关于这个赋值的含义我们会在第 8 章中详细说明,这里只需要记住这是初始化方法的一种固 定写法即可。
生成实例对象的方法alloc 会把实例对象的变量都初始化为 0(后面会提到的实例变量 isa 除 外)。所以,如果子类中新追加的实例变量的初值可以为 0,则可以跳过子类的初始化。但是为了明确是否可以省略,最好为初值可为 0 的变量加上注释。
从程序的书写角度来说,设定初始值的方法有两种,即可以在初始化方法中一次性完成实例变量 的初始化,也可以在初始化方法中先设置实例变量为默认值,然后再调用别的方法来设置实例变量 的值。例如,类 Volume 也可以通过先调用初始化方法init ,然后再调用setMax: 等方法来设定音 量的最大值、最小值和变化幅度。原则上来说,初始赋值之后值不再发生变化的变量和需要显示设 定初值的变量,都需要通过带参数的初始化方法来进行初始化。
使用继承的程序示例
追加新方法的例子
我们来定义一个带有静音功能的类 MuteVolume。该类只有一个功能,即当收到mute 消息时, 设置音量为最小。
类 MuteVolume 的定义非常简单,父类是已经定义好的类 Volume。子类 MuteVolume 除了可以使 用父类 Volume 中定义的所有实例变量和方法之外,还新增加了一个 mute 方法。
这里使用了 Volume 作为父类,并引入了头文件 Volume.h。Volume 的父类是 NSObject,所以 , 所以就不需要再进行指定了。
没有定义新的实例变量,意味着子类中没有要追加的实例变量。
该测试程序的功能是从终端读入输入的字符串,并根据字符串的第一个字符来决定如何设置音 量。具体来说,第一个字符为 u 时表示提高音量,d 表示降低音量,m 表示静音,q 表示退出程序。
编译子类的时候,需要连同父类一起编译和链接,否则就无法使用父类中定义的方法。本例中
编译所需要的文件一共有 5 个,即 Volume.h、Volume.m、MuteVolume.h、MuteVolume.m、main.m。
方法重写的例子
上面通过继承实现静音功能类的例子非常简单,让我们来看一个更实用的例子。
假设该例子要实现两个功能。第一个功能是,当再次收到mute 消息时,音量会恢复原值;第二 个功能是,在静音状态下收到up 或down 消息时,会返回最小音量值,同时改变音量值。
实现这些功能的方法有很多,这里我们增加一个 BOOL 类型的变量 muting,同时修改方
法initWithMin:max:step:和 方法value的 实现。
初始化方法initWithMin:max:step:首 先调用了父类的初始化方法,然后对新增的实例变量 muting 进行了初始化。如前所述,子类的初始化一定要在父类的初始化之后进行。
value方 法根据当前是否为静音状态返回不同的值。静音状态下,返回最小值 min。mute 方法
中只需要改变实例变量 muting 的状态来标识是否静音,不需要更改音量值 val。
编译的情况和上一节一样。main.m 直接使用上一节的即可。
继承和方法调用
使用 self 调用方法
如果想在一个方法中调用当前类中定义的方法,可以利用 self。但如果存在继承关系,通过 self 调用方法时要格外注意。
在图 3-6 的例子中,有三个类 A、B、C。类 A 中定义了 method1、method2 和 method3 三个方法。 类 B 继承了类 A,重写了 method1 和 method3。类 C 继承了类 B,重写了 method2。
假设类 B 的方法 method3 想调用 method1 和 method2,通过 self 调用了 method1 和 method2。我 们来分析一下这个过程中到底哪个函数被调用了。对类 B 的实例对象调用 method3 方法时,首先会 通过 self 调用 method1,这个 method1 就是类 B 自身定义的 method1。接着,method3 通过 self 调用 method2,因为类 B 中并没有 method2 的定义,所以就会调用从类 A 中继承而来的 method2。
而如果是类 C 的实例对象调用方法 method3 的话会怎么样呢?我们首先来看看 method3,因为 类 C 中并没有定义 method3,所以调用的是类 B 中定义的 method3。要注意这个时候 self 指的是类 C 的实例对象,当 [self method1] 执行时,因为类 C 中没有定义 method1,所以调用的是类 B 中 定义的 method1。然后,当 [self method2] 执行时,因为类 C 中定义了 method2,所以执行的是 类 C 中定义的 method2,而不是上例中类 A 中定义的 method2。另外还有一点需要注意,就算类 B 中定义了 method2,调用的也是类 C 中定义的 method2。
也就是说,self 指的是收到当前消息的实例变量 ,因此,就算是同一个程序,根据实例的类的不 同,实际调用的方法也可能不相同。
使用 self 的时候要一定小心,要仔细分辨到底调用了哪个类的方法。即便如此,利用 self 的特 性来编程也是很常见的,更多详细内容请参考 11.1 节的内容。
使用 super 调用方法
而如果不使用 self 而使用 super,程序执行的结果会怎样呢?
图 3-7 是用 super 替代图 3-6 中的 self 的情况。使用 super 调用方法时,最后被调用的方法是类 B 的父类中定义的方法。所以无论是类 B 还是类 C 的实例变量调用了 method3,最后调用到的都是类 A 中定义的 method1 和 method2。
测试程序
我们用一个简单的程序来验证一下上面所描述的内容。这个程序本身并没有太大的意义,仅仅 是用来测试方法调用的。
测试程序中有三个类 A、B、C。类 A 中定义了方法 method1 和 method2。类 B 中对 method1 进 行了重写,通过 self 调用了 method1,通过 super 调用了 method2。类 C 重写了 method1。
程序执行之后输出如下。可以看出,类 B 和类 C 的实例分别调用了不同的方法。
方法定义时的注意事项
局部方法
实现接口声明中的方法时,可把具备独立功能的部分独立出来定义成子方法。一般情况下,这 些子方法都只供内部调用,不需要包含在类的接口中对外公开。
这种情况下,局部方法可以只在实现部分(通常是 .m 文件)中实现,而不需要在接口部分中进 行声明。这样一来,就算其他模块引用了接口文件,也无法获得这个方法的定义,无法调用这个方 法,从而就实现了局部方法。但这里只是说无法从接口中获得这个方法的定义,这个方法本身还是存在的,只要发送了消息,就能够执行。
让我们来看一个简单的例子,类 ClickVolume 是类 Volume 的一个子类,它的主要功能是当音量 发 生 变 化(提 高 或 降 低 )时 发 出 提 示 音。 提 高 或 降 低 音 量 时 发 出 提 示 音 使 用 一 个 共 同 的 方 法playClick, 定义如下所述。因为这个功能不会在其他地方使用到,所以我们把它定义成一个局 部方法,不在接口文件中声明。
未在接口中声明的局部方法和没有进行属性声明的 C 语言函数一样,只能被定义在局部方法之 后的方法调用。在上面的例子中,playClick 就必须定义在up 和down 的前面。定义顺序方面出现的 问题,可以使用第 10 章介绍的“范畴”(category)来解决。
编程的时候使用局部方法可以增强程序的可维护性,但在继承的时候可能会出现问题。例如, 子类新追加的方法可能并不知道父类已经实现了局部方法而去重新实现一个父类的局部方法。
为了避免这一问题,苹果公司建议为局部方法名添加固定的前缀(详情请参考附录 C)。
指定初始化方法
前面已经介绍过了如何定义初始化方法,但还有一些要注意的地方。
根据需求有时可能需要为一个类定义多个不同的初始化方法。例如,既需要提供一个可指定每 个参数初始值的初始化方法,又需要提供一个每个参数都直接使用默认值的初始化方法;既需要提供 一个用内存变量进行初始化的初始化方法,又需要提供一个能从文件读入变量完成初始化的初始化 方法等。 指定初始化方法 (designated initializer)就是指能确保所有实例变量都能被初始化的方法, 这种方法是初始化的核心,类的非初始化方法会调用指定初始化方法完成初始化。通常,接收参数 最多的初始化方法就是指定初始化方法。
子类的指定初始化方法通常都是通过向 super 发送消息来调用超类的指定初始化方法。除此之外, 还有一些通过封装来调用指定初始化方法的方法叫作 非指定初始化方法 (secondary initializer)。图 3-8展示了指定初始化方法的概念,箭头指明了调用关系。图中每个类都只有一个指定初始化方法,实 际上也可以存在多个。
子类的指定初始化方法,必须调用超类的指定初始化方法。如图 3-8 中所示,按照类层次从底向 上,各个类的指定初始化方法会被连锁调用,一直到最上层的 NSObject 的指定初始化方法——init 为止。
如果子类中想重写父类中的指定初始化方法,就一定要调用父类的指定初始化方法,而不能调 用父类的非指定初始化方法。原因是非指定初始化方法内部会调用指定初始化方法,造成递归循环 调用,无法终止。
请看图 3-9 中的例子,类 A 的指定初始化方法是initWithMax :。init 是类 A 的非指定初始 化方法。类 B 是类 A 的子类,在 B 中重写了指定初始化方法initWithMax :。initWithMax :中 调用了父类类 A 的 init。如图所示,如果类 A 的 init 中通过 self 调用了initWithMax :,那么,当 初始化对象是类 B 的实例时,就又会调用到类 B 的initWithMax :,这样就变成了一个递归循环, 调用永远无法结束。
再让我们回头看一下图 3-8,图 3-8 中类的非指定初始化方法都调用了指定初始化方法来进行初 始化,同时父类的非指定初始化方法也可以被继承,但定义的时候一定要注意,否则也会出现循环 调用的问题。
Objective-C 没有特殊的语法或关键字来表明哪个方法是指定初始化方法,所以通常需要通过 文档或注释来标明指定初始化方法。Cocoa API 文档中的绝大多数类都标明了哪个方法是指定初始 化方法。
另外,如果你想一起进阶,不妨添加一下交流群1012951431,选择加入一起交流,一起学习。期待你的加入!