介绍
上一篇介绍Tomcat的结尾提到了其中一个Web应用的HelloWorld 这个Servlet 继承了HttpServlet类(这个类显然是在servlet-api.jar这个JAR包里面),这里再次给出该类的源代码:
import java.io.*;
import javax.servlet.*;
import javax.servlet.http.*;
public class HelloWorld extends HttpServlet {
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException
{
//这里省略了方法体的内容
}
}
所以,依据用到才介绍的原则,本篇我们就介绍一下继承是什么,Java语言里面怎么定义一个类继承另一个类。
什么是继承
继承这个词很容易让人想到现实生活中的遗产继承或基因继承。遗产继承就是说被继承人的遗产将归属于继承人。而基因继承是指子女也拥有父母的某些特征。
同理,在面向对象语言的世界里,被继承类的 “遗产” 或“基因”也属于或呈现于继承类,只不过,这里的 “遗产” 或“基因” 指的是类的属性和方法。被继承类就叫超类(superclass)、基类(base class)、父类(parent class);继承类就叫子类(subclass)、派生类(derived class)、孩子类(child class)。
说的通俗一点,就是子类也拥有超类的属性和方法。
Java里面继承的定义
在Java里面,继承的定义是使用extends关键字(extend的第三人称单数)。其实,extends翻译成中文应该是“延伸、扩大、扩展、推广”的意思,而继承的英文单词是inherits(inherit的第三人称单数)。所以,子类继承了超类又可以称为子类扩展了超类。
那为什么使用extends而不是使用inherits呢?我的理解应该是继承是从遗产的角度思考,就是指子类也拥有超类的属性和方法;而扩展是从新成员(类的属性和方法统称为类的成员)的角度思考,就是指子类除了拥有超类的属性和方法外还可以定义自己的不同于超类的属性和方法,而软件科学里面就有一个叫做可扩展性的指标,描述的就是一个软件增加新功能或新特性的容易程度。
继承的定义如下:
[public] class <子类名> extends <父类名> {
//这里是子类自己的属性
...
//这里是子类自己的方法
...
}
注:中括号里面的内容是可选的,尖括号里面的内容是我们自己提供的。
当然,我们也可以定义多个子类继承一个父类,就像父母有多个子女一般,但每个子女都有自己的特征。
为什么要继承
那为什么要继承呢?或者说继承有什么好处呢?或者说继承解决了什么问题呢?继承的核心、本质、目标是什么呢?问的都是一个意思。
我的理解有三点:
- 从遗产的角度出发,那就是代码复用,即消除重复。实际的软件系统是很复杂的,其中有很多相似但又不完全相同的概念,它们拥有的状态特征和行为特征也都有很多是相同的,这时就可以将那些相同的状态特征和行为特征抽象到一个超类里面,子类只要继承超类就自动拥有了那些状态特征和行为特征,而不必为每一个子类都写一遍相同的代码,这就消除了代码的重复编写。
- 从新成员的角度出发,那就是能提升代码扩展性。实际的软件系统也是经常变化的,常常会有新的需求需要加入到软件系统中,但又希望尽量不更改原有代码或原有类,因为一旦改变它们,将会对原有的经过充分测试已经是可用的需求实现带来影响。于是,只要在研发初期对软件系统进行合理的设计,有些需求就可以采用增加子类(在这个子类中为新需求添加新成员)的方式来实现,从而不需要或需要少量修改原有类或原有代码。注意,这里添加新成员可不仅仅是指添加一个超类所没有的方法,还可以是覆盖超类的某个方法。
- 是实现多态的基础。关于多态,以后用到了再讨论。
举例
还是拿实际的代码举例更直观一点,任何一个软件系统的特性(或功能,英文叫feature),你不去实践它,就永远理解不了它或者说理解的不深刻,所以软件这个学科真的是一门实践学科。
我不拿现实生活中的概念来举例,比如人作为超类,男人和女人作为人的子类,是因为这样的例子意义不大,还容易将我们的思维固化在现实生活中的这些概念上。实际上,只要有利于提升软件的复用性和扩展性,就应该将某些状态和行为的集合抽象为一个超类,然后派生出各个子类,而不用管这个集合是否真的对应上现实生活中某个概念。
前面我们已经介绍了Eclipse的使用,所以我们就可以使用它来开发我们的例子(如果不会使用Eclipse,那么可以参考前面的《我的Java Web之路 - 集成开发环境Eclipse(1)》),我们先建立一个Java工程,工程名就叫InheritanceTest,再依次建立下面几个类,过程就不再赘述了。
先定一些规范吧,主要是命名方面的,注意,这非常重要,实际的软件开发中都需要类似的规范,就是Java编码规范,只不过我这只是涉及命名方面的。
我先设计一个类C(取单词class的首字母),把它当做超类,它有某些属性和方法。
属性在Java里面又叫域(field),所以我用字母 f 开头来表示某属性的名字;方法名用 m (取单词method的首字母)。子类采用Sub开头,子类再派生的子类采用SubSub开头,以此类推。程序入口所在的类是Main。
超类C:
package com.example;
public class C {
private int f1 = 10;
public void m1() {
System.out.println(f1);
}
}
在建立子类时,Eclipse的新建类的对话框有一个输入项是:Superclass,可以在该项中输入超类的名字,这样生成的代码就自动使用extends关键字来定义继承了。当然你也可以新建一个文件,然后所有代码都自己手动编写,这有助于强化你的记忆和提高你对Java使用的熟练程度,不过开发效率就会降低不少。
子类SubC1:
package com.example;
public class SubC1 extends C {
private String f1 = "hello";
public void m1() {
System.out.println(f1);
}
}
程序入口类:
package com.example;
public class Main {
public static void main(String[] args) {
SubC1 subC1 = new SubC1();
subC1.m1();
}
}
超类C有一个int类型的域f1(初始值是数字10)和一个方法m1(功能就是简单的把超类C的f1打印到控制台);子类SubC1也有相同名字的域和方法,不过SubC1的f1是String引用类型(初始值是字符串hello),m1的功能也是打印f1。那么问题来了,SubC1的m1方法打印的到底是自己这个类的f1呢?还是从继承超类C所自动获得的f1呢?
OK,那我们就构造一个SubC1的对象,并调用它的m1方法,看到底打印的是哪个f1?从运行结果看到打印的是SubC1自己的f1。结论是子类可以定义跟超类一模一样的域和方法,包括public之类的修饰符、数据类型、域名字都可以一样,大家可以尝试把修饰符、数据类型等改成一样的看看编译器和运行时是否报错。
那么问题又来了,子类怎么访问到从超类继承的域和方法呢?答案就是使用super这个关键字,这是Java语法规定的,这个规定倒也很人性化,就是很容易理解啦,super就是超的意思嘛。OK,那我们就把子类SubC1的m1方法中的打印语句修改成:
System.out.println(super.f1);
这回总该可以访问到从超类继承到的域f1了吧。结果让你大失所望了,连Eclipse的Java编译器都报错了,把鼠标放到super.f1处,就可以看到报错信息是:
可以看到这句话的意思是:在此处超类C的域f1是不可见的,显然这是类的封装性在起作用,而类的封装性主要是由public、protected、private等关键字实现的。当然,封装是一种思维,就是哪些成员该封装成什么程度是你要考虑的,那要考虑什么因素呢,这就又是设计的工作了。而这些关键字只是实现封装性的工具。
到目前为止,就可以把所有封装性的程度列举如下了:
- 公有的:使用public关键字,顾名思义,那就任何其他类的成员都可以访问了呗。如果属性设计成这个级别的话,那不就有可能出现该属性的直接访问分散在了其他很多类中,以后假设要变更该属性的话岂不是牵一发而动那些所有类,另外涉及到多线程的访问也会产生竞争等等问题。那就让那些类不要直接访问该属性啊。墨菲定律告诉我们:凡是可能出错的一定会出错。所以只要有可能被直接访问那就一定会出现直接访问,最好的方案是不能直接访问,直接访问会导致编译器报错,所以一般情况下属性都不能设计成公有的。
- 受保护的:使用protected关键字,这个级别指的是只有你的子类和同一个包(就是package语句要一样)内的类可以直接访问。子类使用super关键字来访问,同一个包内的类需要构造域所在类的对象来访问。所以Eclipse错误信息里面给出的一个修改该错误的方案就是使用protected来修饰超类C的域f1,大家可以试试,并看看打印结果是不是真打印出超类C中的f1。
- 包可见的:这个级别不使用任何关键字,难以想象吧,不用任何关键字也可以是一种封装级别。它的封装程度更高了,即可被访问或可见的范围更小了,子类已经不能直接访问了,只能是在同一个包(就是package语句要一样)内的类才可以直接访问,大家也可以把超类C的域f1改成这种封装级别,然后把SubC1修改成不继承超类C,但此时要生成一个C的对象,然后使用该对象来访问C的域f1。
- 私有的:这个级别的封装性就是最高的了,任何其他类都不能直接访问这个级别的成员,只能在该类中设计其他级别的方法来提供对此级别的成员的有限的访问。为什么是有限的呢?因为怎么访问都是由该类决定的,访问也许经过了很多其他操作,比如过滤、判断、转换等等。通常对私有属性提供读接口的方法叫getter方法,提供写接口的方法叫setter方法。这就是Eclipse错误提示中给出的第二种解决方案:
超类C:
package com.example;
public class C {
private int f1 = 10; //私有的
public void m1() {
System.out.println(getF1());
}
//getter方法
public int getF1() {
return f1;
}
//setter方法
public void setF1(int f1) {
this.f1 = f1;
}
}
子类SubC1:
package com.example;
public class SubC1 extends C {
public int f1 = 20;
public void m1() {
System.out.println(super.getF1()); //使用父类的getter方法访问父类的某个成员
}
}
当然,上面的设计是有问题的,如果子类有自己新增的属性和方法,最好把名字都设计的跟超类的不一样,这就是继承的第一个好处 - 扩展,可以扩展新的属性和方法,比如把上面的子类SubC1的属性和方法名字修改成对自己有意义的单词,实现我新增的特性、功能或需求:
package com.example;
public class SubC1 extends C {
public int f1OfSubC1 = 20; //新增属性
public void m1OfSubC1() {
System.out.println(f1OfSubC1 ); //新增方法
}
}
那如果需要另一个功能,而该功能只需要在超类C的m1方法执行之前或之后有一些逻辑,这最好把方法名字设计成一样,只不过在子类的m1方法中复用超类的m1方法,这就是继承的第二个好处 - 复用,本质上也是一种扩展,只不过是在原有逻辑上扩展新逻辑,而不是全新的功能或方法。
package com.example;
public class SubC1 extends C {
public int f1OfSubC1 = 20;
public void m1OfSubC1() {
System.out.println(super.getF1());
}
//此方法的签名与超类的完全一样,这就是覆盖
public void m1() {
System.out.println("m1之前");
super.m1(); //复用超类的代码
System.out.println("m1之后");
}
}
覆盖和重载
子类中方法签名与超类的完全一样,这种技术就叫覆盖(英文叫override)。就是说子类某方法实现的功能覆盖了超类相同方法签名是实现的功能,比如上面子类SubC1的m1方法覆盖了父类相同签名的m1方法。这是实现类的多态性的基础,后续再讨论。注意,覆盖一定是发生在子类和父类之间,签名相同的方法之间。
但在同一个类中,多个方法仅仅是参数列表不一样,那么就说这些方法是重载方法(英文叫overload)。比如:
package com.example;
public class SubC1 extends C {
public int f1OfSubC1 = 20;
public void m1OfSubC1() {
System.out.println(super.getF1());
}
public void m1() {
System.out.println("m1之前");
super.m1();
System.out.println("m1之后");
}
public void m1(int a) {
System.out.println(a);
super.m1();
}
public void m1(int a, String b) {
System.out.println(a);
super.m1();
System.out.println(b);
}
}
上面SubC1类中的三个m1方法就是重载方法。
this和super
以前我们学过this关键字,现在来和super一起对比并总结一下。
- 都可用来访问成员,this访问的是属于本类中的成员,而super访问的是从父类继承下来的成员(当然得满足封装性或可见性)。比如上面子类SubC1中的:
this.f1 //访问的是本类成员
this.m1() //访问的是本类成员
super.f1 //访问的是继承自父类的成员
super.m1() //访问的是继承自父类的成员
- 都可以用来调用构造函数,构造函数也能重载,直接在关键字后加上参数列表即可,this访问的是属于本类重载的其他构造函数,而super访问的是父类某个构造函数(当然得满足封装性或可见性)比如把子类改造成如下:
package com.example;
public class SubC1 extends C {
public int f1OfSubC1 = 20; //生成对象时先分配内存,然后将该内存值初始化为20
public SubC1() { //分配内存并初始化后,调用构造函数
this(30); //此处调用下面重载的构造函数
}
public SubC1(int a) {
super(); //调用父类的构造函数
this.f1OfSubC1 = a; //为域赋值
}
//其他成员省略
}
需要注意的是,构造函数的调用必须是方法体内的第一条语句,否则会出现编译错误!
- 前面都是this和super相同的地方,不一样的是this本质上是一个引用类型的值,它指向的是被访问对象,因此可以用它来给相同引用类型的变量赋值。而super仅仅是有尚明两种用法的关键字,不是引用类型的值,所以不指向所属父类对象。
SubC1 subC1 = this; //在SubC1类的某方法中可以这样用
return this; //还可以这样把本对象给返回给外部
C c = super; //错误!
HttpServlet
最后,我们再回过头来看看文章开始处编写的HelloWorld这个Servlet,现在我们知道为什么说它是一个Servlet,因为它继承了HttpServlet这个类,而HttpServlet类又继承了Servlet类。
所以HelloWorld类肯定是从HttpServlet类和Servlet类中继承了很多属性和方法,而那些属性和方法都是通用的,不需要再由你来编写代码了,你只需要编写HelloWorld这个应用特定的业务代码就可以了,就是在代码中出现的:
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException
{
//这里省略了方法体的内容
}
实际上,它覆盖了HttpServlet的doGet方法,而Tomcat会加载HelloWorld这个Servlet,一旦有相应的HTTP GET请求发到指定URL上,Tomcat就会生成HelloWorld这个Servlet的对象(当然,生成的对象会被管理起来以便后续请求无需再次生成,节省时间),然后调用该对象的doGet方法,doGET方法会生成响应,然后再交给Tomcat发送回去。
总结
- 继承就是一个类“是”(“is a”)另一个类的关系,比如苹果是水果;
- 继承的主要目的是为了复用和扩展,复用父类的方法可以用super,扩展可以新增一个方法或覆盖父类的某个方法;
- 继承在Java中使用extends关键字来定义;
- 类的封装性有四个级别:公有、受保护、包可见、私有;
- 子类方法可以覆盖父类中具有相同签名的方法;
- 同一个类中方法可以重载,构造函数也可以重载;
- this和super都可以用来访问成员和调用构造方法;
- this是引用,super不是。