黑马程序员——面向对象10:面向对象的三大特点之二——继承

来源:互联网 发布:淘宝神笔模板克隆 编辑:程序博客网 时间:2024/05/04 17:07

------- android培训、java培训、期待与您交流! ----------

1. 共性内容的抽象

在前面的内容中我们已经学习了面向对象的第一个特征:封装,这一篇我们来学习面向对象的第二个特征:继承。首先我们来看一段代码,

class Student{String name;int age; public void study(){System.out.println("study show");}}class Workder{String name;int age; public void work(){System.out.println("work show");}}
在上述代码中我们通过类的形式描述了两类人——学生和工人。不难看出,这两类人有一些共同特征——都有姓名和年龄属性,而且不仅这两类人有,其他人也都有这两个属性,这两个属性实际上是他们的共性属性或叫共性描述。那么问题来了,如果我们每次定义学生和工人的时候都要重新定义姓名和年龄属性,那么这样做的开发效率是非常低的,因为我们要做大量的重复工作。所以,前辈们就想到一个办法:何不把所有人的共性属性和共性行为(比如吃饭)“向上”抽取出来,以此来描述一个更为抽象的类——“人”,并且使学生类和工人类(包括其他人)都与“人”这个类产生一定联系,使得每次定义学生和工人的时候不必重新定义共有属性和共有行为。我们用一个图来说明。

上图的箭头表示了“向上”抽象的过程。我们可以这样理解上面的图:其实学生类和工人类也都是通过将具体的学生对象和工人对象(有具体的姓名和年龄)的共有属性进行向上抽取得来的,而对这两个类的共有属性进一步抽象,就得到了Person类。换句话说,Person类中的属性是所有人都具有的共性属性,当然也包括共性行为。

2. 继承

当然,仅仅是抽象出Person类是不够的,还要让学生类和工人类与Person类产生联系,那么这个联系就是继承。继承在代码中是通过关键词“extends”来体现的,代码如下:

代码2:

class Person{String name;int age;} class Student extends Person{public void study(){System.out.println("study show");}} class Workder extends Person{public void work(){System.out.println("work show");}}
通过继承,学生类和工人类就是Person类的子类(或导出类),而Person类就是学生类和工人类的父类(或称为超类、基类)。通过上面的代码我们就可以体会到继承的优点,简化了代码书写,学生类和工人类不必重复定义姓名和年龄属性了,所以即使没有显示的定义这两个属性,他们也默认具备。

       注意,继承虽好但是不能仅仅为了获取其他类的功能和属性,或者为了简化代码而继承,比如工人类与学生类之间并没有明确的继承关系,从现实情况来讲,不可能通过将工人类向上抽象而得到学生类,反之亦然。只有当两种类之间有明确的所属关系时才能继承,这种所属关系可以表示为“is a”,比如“Worker is a Person”。不能说“Workeris a student”。

简单总结一下继承的优点:

a) 简化代码,提高代码复用性。

b) 使类与类之间产生联系,而这种联系的产生为面向对象的第三个特征——多态的出现奠定了基础。

 

小知识点1:

如果无法确定两个类之间是否是继承关系,那么就假设让他们产生继承关系,如果父类中有某些属性或方法是子类不应拥有的,那么这两个类之间就不能继承。比如,

代码3:

class A{void f1(){}void f2(){}}class B extends A{//void f1(){}void f3(){}}
A类和B类中都与f1方法,那就让B继承A,这样B类就不用显示地定义f1方法了,但同时也把方法f2继承过来了。此时,如果f2方法也是B类应有的方法,那么这样继承是没有问题的;反之,就不可以继承。

我们接着思考,既然A和B中有共性方法f1,按照向上抽象共性内容的思想,我们可以定义一个包含方法f1的父类C,并让A和B都继承C,这就叫继承,我们来看代码,

代码4:

class C{void f1(){}}class A extends C{//void f1(){}void f2(){}}class B extends C{//void f1(){}void f3(){}}
3. Java不支持多继承

这个标题实际上并不太严谨,Java中可以通过另一种方式实现多继承的,这个我们到后面会讲到。那么不支持多继承的意思就是,比如学生类继承了Person类,就不能再继承其他类了。原因是多继承会造成安全隐患:如果多个父类中有同名同参数但不同内容的方法,那么在子类对象在调用该方法时,就无法确定到底执行哪个方法,比如,

代码5:

class B{void f1(){System.out.println("B");}}class C{void f1(){System.out.println("C");}}class A extends B, C{}class ExtendsDemo{public static void main(String[] args){A a = new A();a.f1();}}
此时A对象调用的f1方法就无法确定执行的到底是B类的还是C类的方法了。

Java虽然不支持多继承,但是支持多层继承,就像A继承B,B又继承C,那么A、B和C就是一个集成体系,而C内就定义了这个体系最共性的内容。

上图就表示了一个最简单的继承体系。


小知识点2:

当我们要使用一个继承体系的某个类时,我们应当首先去查阅父类的API文档描述。这是因为,父类中定义了这个体系中最共性的内容,了解了父类就了解了这个体系最基本的功能,也就了解了使用这个体系的最基本的方法。在实际使用这个体系的时候往往要创建体系最末端的子类对象,原因有二:一是因为父类可能无法创建对象,比附抽象类和接口;二是子类具备包括共性和特有的更丰富的内容,比如上图中的A类,不仅具备B和C的内容,还具备A类的特有内容。

简单一句话:通过父类了解体系,通过子类实现功能。


4. 继承体系中成员的特点

这一部分我们来说一说继承体系中,类中成员的特点,看看他们在继承体系中都发生了什么变化。分三部分来说:成员变量、成员方法和构造方法。

1. 成员变量

先看代码:

代码6

class Fu{int num1 = 10;//子父类中定义了一个同名变量xint x = 1;}class Zi extends Fu{int num2 = 50;//子父类中定义了一个同名变量xint x = 100;void show_1(){System.out.println(x);}void show_2(){//通过super关键字调用了父类的x变量System.out.println(super.x);}}class ExtendsDemo{public static void main(String[] args){Zi zi = new Zi();System.out.println("num1="+zi.num1+",num2="+zi.num2);System.out.println("x="+zi.x);zi.show_1();zi.show_2();}}
上述代码的运行结果为:

num1=10, num2=50

x=100

100

1

第一行结果正是体现了继承的特点,不再赘述。而第二行的结果告诉我们,当子父类中定义了同名变量时,子类对象中该变量的值还是子类定义的值。第三行的结果也是显然的,大家还是要注意,show_1方法输出语句中变量前省略了this关键字。第四行结果打印的是父类成员变量x的值,这里我们就需要引入一个新的关键字“super”。super关键字类似于this,区别是this是指向本类对象,super是指向父类对象,因此输出语句打印了父类变量x的值。前面我们说到将一个类加载到内存以后,类中定义的非静态方法将存储于方法区中的非静态区,除此以外,这片内存中还存有两个引用,指向本类对象的this,以及指向父类对象的super。

此外,如果在代码1中,将子类的成员变量x去掉,那么在方法show_1中x变量前无论显示地使用关键字super还是this,输出结果都是父类的x值1。这是因为子类继承了父类,所以子类对象中就同样有了父类的成员变量x,因此this关键字就是指向了本类对象中的num。

 

小知识点3:

出现了继承体系以后,创建对象时内存的分配过程就有所不同了。当我们执行Zi zi = new Zi()这行代码时,首先要加载Zi类,并在方法区中为该类开辟一块空间,在这个空间内部又分为两片区域,一片静态方法区,一片非静态方法区。在非静态方法区中,又分为父类区和子类区,父类区中存储父类的非静态方法,子类区中存储子类的非静态方法和两个引用this和super。这是关于类的加载。

而在创建对象时,首先在堆内存中为Zi对象开辟一块空间,由于有了继承体系,这片区域也要分成父类区和子类区,父类区中存储父类的数据num1和x,子类区中存储子类的数据num2和x。

 

我们简单总结一下:

1) 可以通过super关键字方便的访问父类成员变量,格式为:super.父类非私有成员变量。

2) 当子父类中定义了非私有的同名成员变量时,子类对象若要访问本类对象中的变量,用this关键字调用(通常省略);若要访问父类中的同名变量,用super调用。

3) this指向本类对象的引用;super指向父类对象的引用。

 

小知识点4:

关于上述的第三点,《Java编程思想》中有这样的解释:当创建了一个子类对象时,该对象内部包含了一个父类的子对象。这个父类对象与直接创建的父类对象是一样的,二者的区别在于,后者单独存储在堆内中;而前者被封装在了子类对象内部。这就是super在没有显示创建父类对象的情况下可以指向父类对象的原因。

 

后再啰嗦一句:虽然我们在上面讲到了子父类中定义同名变量的情况,虽然并把这种情况作为特例来解释,但在实际开发中很少见的,因为,父类中已经定义了这个变量,子类只需要继承并直接拿来用即可,没有必要再重新定义,至多需要重新赋值。

2. 成员方法

1) 方法的覆盖

上一部分我们主要讲的是继承体系中成员变量的特点,这一部分我们讲一讲继承体系中方法的变化。我们同样先来看代码,

代码7:

class Fu{void f_1(){System.out.println("fu run");}//在子父类中定义一个同名同参数,但方法体不同的函数void show(){System.out.println("fu show");}}class Zi extends Fu{void f_2(){System.out.println("zi run");}//在子父类中定义一个同名同参数,但方法体不同的函数void show(){System.out.println("zi show");}}class ExtendsDemo2{public static void main(String[] args){Zi zi = new Zi();zi.f_1();zi.f_2();//子类对象调用了这个同名同参数方法zi.show();}}
运行结果为:

fu run

zi run

zi show

前两行运行结果是显然的,由于子类继承了父类,因此也就默认的“获得”了父类的方法f_1(虽然可以这么表述,方便大家理解,但是实际情况并不是这样)。第三行运行结果表示:当子类对象调用子父类中同名同参数方法时,执行的是子类中的方法。这一特性我们称之为覆盖重写

“覆盖”是对这种机制的比较形象的称呼,实际上,父类的方法还是存在于方法区非静态方法区的父类区中,只不过没有去调用而已。

2) 覆盖的应用

我们打一个比较形象的比喻,比如早期电话的来电显示只能显示号码,而现在的电话不仅能显示号码还能显示名称和照片,用代码体现就是,

代码8:

class Telephone{//老式电话中只能显示来电号码public void show(){System.out.println("phone number");}}class NewTelephone extends Telephone{//新式电话不仅能显示来电号码,还能显示名称及图片public void show(){//通过super调用父类的显示功能,不必再写输出语句,简化代码super.show();System.out.println("name");System.out.println("picture");}}class ExtendsDemo3{public static void main(String[] args){NewTelephone nt = new NewTelephone();nt.show();}}
运行结果为:

phone number

name

picture

上述运行结果表明,通过super关键字不仅可以访问父类的成员变量,也可以访问父类的成员方法,这与this关键字的用法类似。

可能有朋友要问,为什么不在子类中定义一个叫“newShow”的方法,或者叫“show2”,以示与父类方法的区分呢?这种方式从编译和运行的角度来说当然是可以的,但是有两个问题:1) 当子类继承了父类以后,子类中就定义了三个方法了,而父类的“show”存在于子类中实际是毫无意义的。2) 无论是“newShow”还是“show2”,这种方法名的定义方式阅读性很差,别人无法通过方法名知道这个方法的大致功能是什么。此外,子父类中“show”方法的功能是相同的,只不过实现的方式不同,因此,不应该定义不同的方法名。

我们还需要注意的是,如果子类方法中包含了父类方法的功能,就可以使用super关键字调用父类的老方法,达到简化代码的功能。

我们简单总结一下就是:

(a) 在子类内部可以通过super关键字调用父类的方法,格式为“super.父类方法名”。

(b) 当子父类中定义了功能相同,但实现方式不同的方法时,子类没有必要定义新的方法名称,而是要利用覆盖的机制,保留父类的方法名,并重写方法内容。而上述方法内容的不同正是子类区别于父类的特有部分。

3) 覆盖的优点

实际上,上述代码3的例子折射到实际开发中就是体现了对老代码的修改问题。如果对现有类的某个方法不满意,无论是出于提高效率还是功能增强的目的,原则上是不去修改老代码的,因为修改一处常常需要修改一大片,所谓“牵一发而动全身”(不知道这么用合不合适),这可能是一场“灾难”。而继承中覆盖机制的出现就简单地解决了这个问题。只需要定义一个继承现有类的新类,并覆盖老方法,定义新的函数实现方式即可。这个时候覆盖的优点就来了:

a) 提高了代码的扩展性;

b) 提高了代码的复用性

c) 为多态的出现奠定了基础。

4) 覆盖的注意事项

a) 方法的覆盖,必须保证子类方法的访问权限大于等于父类方法的访问权限,否则编译失败。

b) 静态方法只能覆盖静态方法,否则编译失败。这与静态方法只能访问静态方法的原理是相同的。

c) 子类的非私有方法不能覆盖父类私有方法。这是因为,父类的私有方法对于子类来说是“不可见”的,也就更谈不上覆盖了。不过,即使这样做了,是可以通过编译的,只不过这不叫覆盖罢了。

d) 一定要区分重写和重载。

重写:子类方法与父类方法完全相同,包括方法名、参数列表、返回值类型。

重载:方法名相同,参数列表不同。

e) 构造方法是不存在覆盖的,因为方法名不可能相同。

 

小知识点5:

如果父类中有个f()方法,子类继承父类定义了一个f(int x)方法,那么在子类中,f()和f(int x)方法是重载的关系,而不是重写。


3. 构造方法

1) super()语句

前面我们说完了继承体系中成员变量和成员方法的变化以后,我最后来说说构造方法的变化。先看代码,

代码9:

class Super{Super(){System.out.println("Super run");} }class Sub extends Super{Sub(){//super();System.out.println("Sub run");}}class ExtendsDemo4{public static void main(String[] args){Sub sub = new Sub();}}
运行结果为:

Super run

Sub run

结果表明,子类构造方法运行前先运行了父类的空参数构造方法。这是因为,子类的构造方法内,隐式地在第一行添加了super()语句。这条语句的作用类似于this()。this()是执行本类的空参数构造方法,super()是执行父类的空参数构造方法。大家注意,默认添加的父类构造方法一定是空参数的。

注意,如果父类中手动定义了一个含参构造方法,而且只有这一个构造方法,那么,在子类的构造方法第一行一定要通过super语句手动添加这个含参构造方法,否则编译失败。

2) 子类参考父类的初始化过程

之所以在子类构造方法中添加父类的构造方法是因为,子类或多或少总要访问父类的成员,尤其是成员变量。那么这就涉及到对成员变量的初始化问题。因此,子类对象在对从父类继承来的成员变量进行初始化以前,应该先参考父类对该成员变量的初始化过程,以避免出现混乱或重复。而之所以将super()语句添加到第一行是因为,初始化动作永远都要先执行,然后再做其他动作。

3) super()语句的应用

代码10:

class Super{private int x;Super(intx){this. x = x;System.out.println("Super run——"+x);}}class Sub extends Super{Sub(intx){/*子类构造方法的第一行必须手动调用父类含参构造方法通过父类的含参构造方法完成子类的初始化动作,简化代码*/super(x);System.out.println("Sub run——"+x);}}class ExtendsDemo5{public static void main(String[] args){Sub sub = new Sub(50);}}
运行结果为:

Super run——50

Sub run——50

上述代码就体现了super()语句的一个应用:子父类的构造方法用相同的方法对成员变量进行初始化时,子类就没有必要再重复相同的动作,只需要通过父类的构造方法实现即可。

4) 子类构造方法互相调用时的特殊情况

代码11:

class Super{Super(){System.out.println("Super run");}}class Sub extends Super{Sub(){//默认地调用了父类的空参构造方法super();System.out.println("Subrun");}Sub(int x){//手动调用了子类的空参数构造方法Sub();System.out.println("x = "+x);}}
上述代码,在子类含参构造方法第一行手动调用了子类的空参数构造方法。在这种情况下,子类含参构造方法内就不再隐式的调用父类的空参数构造方法了,这是因为,在手动调用子类空参数构造方法时已经隐式地调用了父类的空参数构造方法了,这一参考父类初始化的动作执行过一次就可以了。

5. 子类的实例化过程

说完了子父类中构造方法的调用方式以后,我们就可以更为详细的说明子类的实例化过程。这里我们要引用《Java编程思想》的解释。以代码5为例。当虚拟机执行Sub sub = new Sub(50)语句时,首先要加载Sub类,此时编译器注意到它有一个父类(这是由extends关键字得知的),它就会转而加载父类,直到加载根父类。从根父类开始“从上往下”加载,加载的过程就是类中的所有静态部分被初始化(静态成员变量被赋值、静态代码块被执行),接着加载下一个基类,直到加载子类。之所以采用这种方式,是因为子类的静态初始化可能会依赖于父类成员的初始化。

至此,必要的类都已被加载,开始创建子类对象。

首先,子类对象中所有的基本类型都会被设为默认值,对象引用被设为null,这是通过将存储对象的堆内存区域设为二进制零值完成的(也就是为对象开辟空间的同时)。

然后,执行子类构造方法中第一行(无论手动还是默认)的父类构造方法。而这一调用父类构造方法的动作,也就是创建了一个父类对象(如前述小知识点2)。

接着,既然是创建父类对象,那么同样为父类所有非静态成员变量进行默认初始化,显示初始化,执行构造代码块,最后执行父类构造方法内的语句。

最后,所有父类对象的初始化动作结束以后,开始为子类的非静态成员变量进行显示初始化,如果有就执行构造代码块,接着执行子类构造方法内的其余语句。

0 0
原创粉丝点击