封装
所谓封装,意思就是隐藏内部细节,在编程中,指利用抽象数据类型将数据和基于数据的操作封装在一起,使其构成一个不可分割的独立实体,并尽可能地隐藏内部的细节,只保留一些对外接口使之与外部发生联系。
封装的最大特点就是降低了耦合性,外部无需知道内部的实现细节,只需使用就行了。
比如,在 Java 中,方法和类等都是一种封装。
public class Cat {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
继承
事物有分类的概念,比如动物、植物、猫、狗等,分类有个根,可以向下不断细化,形成一个层次分类体系。
编程语言使用继承来描述这种概念,在继承中,有父类(基类)和子类(派生类),比如动物类 Animal 和狗类 Dog,Animal 是父类,Dog 是子类。
继承实现了 IS-A 关系,比如 Animal 和 Dog,Dog is Animal 就是一种 IS-A 关系。
Java 继承是使用已经存在的类作为基础建立新类的技术,新类可以继承父类的数据或功能,还可以增加子类特有的数据或功能。
这样,公共的属性和行为可以放到父类中,而子类只需要关注子类特有的就可以了;另一方面,遵循里氏替换原则,不同的子类对象可以当成父类对象(即父类变量可以指向子类对象,称为向上转型),方便统一处理。
基本语法
Java 使用 extends
关键字表示继承关系,一个类最多只能有一个父类(单继承),默认父类是 java.lang.Object。
子类继承了父类的非私有属性和方法(构造方法除外),子类可以重写继承的父类方法(注意重写不能降低方法的访问权限),可以增加新的属性,新的方法,即子类可以在父类基础上进行重新实现和扩展。
重写方法不能抛出新的检查异常或者比被重写方法申明更加宽泛的异常。例如: 父类的一个方法申明了一个检查异常 IOException,在重写这个方法的时候不能抛出 Exception 异常,因为 Exception 是 IOException 的父类,可以抛出 IOException 异常或者 IOException 的子类异常。
注意:为什么不能方法重写时不能降低访问权限?因为继承体现的是 IS-A 关系,降低可见性会减少可以对外的行为,从而破坏了 IS-A 关系。
创建子类对象时,会先初始化父类的部分,子类构造方法默认会调用父类的无参构造方法,除非通过 super 手动调用父类的有参构造方法,父类必须要有无参构造方法。
super
:在类的内部,可以通过 super 关键字明确访问父类的非私有属性、成员方法、构造方法,在子类构造方法中调用父类构造方法时,super
必须放在第一行。与 this 不同的是,super 不能指代具体的父类对象,不能作为参数值或返回值。
如果类被 final 修饰,则表示最终类,不能被继承,如 String 类;如果方法被 final 修饰,则该方法不能被子类重写。
public class Animal {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public void eat(){
System.out.println("吃...");
}
}
class Cat extends Animal {
// @Override 注解,表示重写父类方法
@Override
public void eat(){
System.out.println(super.getName() + "猫猫爱吃小鱼鱼");
}
}
谨慎继承
和分类体系相似,继承是一种逐级向上抽象的体系,因此继承是非常强大的,广泛应用于各种 Java API、框架和类库之中,提供了大量基类和基础公共代码。但继承也存在一些问题。
- 继承可能破坏封装
封装就是隐藏实现细节,提供简化接口。使用者只需要关注怎么用,而不需要关注内部是怎么实现的。实现细节可以随时修改,而不影响使用者。
继承可能破坏封装是因为子类和父类之间可能存在着实现细节的依赖,更具体地说,子类需要知道父类的可重写方法之间的依赖关系,如果父类的方法之间存在调用依赖,而子类只重写了某个方法,就可能导致不可预测的错误。
父类也不能随意增加公开方法,因为给父类增加就是给所有子类增加,而子类可能必须要重写该方法才能确保方法的正确性。
- 继承没有完全体现 IS-A 关系
比如,绝大部分鸟都会飞,可能就想给鸟类增加一个方法 fly() 表示飞,但有一些鸟就不会飞,比如企鹅。
- 优先使用组合而非继承
使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该优先使用组合。
public class Child {
private Base base;
private long sum;
public Child(){
base = new Base();
}
public void add(int number) {
base.add(number);
sum+=number;
}
public void addAll(int[] numbers) {
base.addAll(numbers);
for(int i=0; i<numbers.length; i++){
sum+=numbers[i];
}
}
public long getSum() {
return sum;
}
}
多态
多态分为编译时多态和运行时多态,编译时多态主要指方法的重载(overload),运行时多态指对象变量所指向的具体类型在运行期间才确定。
运行时多态有三个条件: 继承/实现;重写(覆盖);向上转型。
在 Java 中有两种形式实现运行时多态:继承和接口。
通过多态,Java 可以实现对不同类型的子类对象统一处理。
转型
转型分向上转型(子类转父类)和向下转型(父类转子类,需强转),实质是数据类型转换,当声明的数据类型与实际数据的类型不一致时,需要转型,而向上转型存在丢失数据或操作的问题。
Extend extend = new Extend();
Base base = extend; // 向上转型
extend = (Extend) base; // 向下转型,需强转
向下转型时,需要判断对象数据的实际类型是否与目标类型兼容(目标类型与实际类型一致,或者是两者某个中间层级的类型),如果不兼容,则无法转换,可以使用 instanceof
关键字判断。
if( base instanceof Extend) {
extend = (Extend) base;
}
静态绑定和动态绑定
-
静态类型:声明变量时的数据类型,通常是某个父类或接口。
-
动态类型:创建对象时的数据类型。
在继承/实现体系中,存在成员重名的问题,可以认为此时子类对象有两份同名的成员变量或方法(父类部分和子类部分)。
对于 private 变量和方法,由于只能在类内访问,在父类中访问的是父类的,子类中访问的是子类的。
对于 public 成员,则看如何访问。在类的内部,默认带参数 this,访问的是类自身的,但子类可以通过 super 明确指定访问父类。
在类的外部,静态变量、静态方法、实例变量根据对象的静态类型判断,静态绑定,这符合数据类型向上转换后的特点。
对于实例方法,则根据对象的动态类型判断,动态绑定,因为 JVM 在实现多态的时候用的是 invokevirtual
指令,它会从子类部分开始往父类去寻找在所有重载版本中最匹配的方法。
静态绑定在程序编译阶段即可决定,而动态绑定则要等到程序运行时。
注意:动态绑定确认方法后,方法中对变量的访问由于默认带 this.
,此时访问的是实际类中的变量。
class Base {
public static int NUM;
private int num;
static {
System.out.println("基类静态初始化代码块,NUM = "+ NUM);
NUM = 10;
}
{
System.out.println("基类实例初始化代码块,num = "+ num);
num = 1;
}
public Base() {
System.out.println("基类构造方法,num = " + num);
num = 2;
}
protected void step() {
System.out.println("基类,NUM = " + NUM + ",num = "+ num);
}
public void action(){
step();
}
}
class Extend extends Base {
public static int NUM;
private int num;
static {
System.out.println("子类静态初始化代码块,NUM = "+ NUM);
NUM = 100;
}
{
System.out.println("子类实例初始化代码块,num = "+ num);
num = 10;
}
public Extend() {
System.out.println("子类构造方法,num = " + num);
num = 20;
}
@Override
protected void step() {
System.out.println("子类,NUM = " + NUM + ",num = "+ num);
}
}
public class Test {
public static void main(String[] args) {
System.out.println("创建子类 === ");
Extend extend = new Extend();
System.out.println("子类 action === ");
extend.action();
Base base = extend;
System.out.println("基类 action === ");
base.action();
System.out.println("基类访问类变量 === " + base.NUM);
System.out.println("子类访问类变量 === " + extend.NUM);
}
}
输出如下:
创建子类 ===
基类静态初始化代码块,NUM = 0
子类静态初始化代码块,NUM = 0
基类实例初始化代码块,num = 0
基类构造方法,num = 1
子类实例初始化代码块,num = 0
子类构造方法,num = 10
子类 action ===
子类,NUM = 100,num = 20
基类 action ===
子类,NUM = 100,num = 20
基类访问类变量 === 10
子类访问类变量 === 100
虚方法表
动态绑定的实现机制是从对象的实际类型开始往上逐级查找要执行的方法,如果继承的层次比较深,要调用的方法位于比较上层的父类,则每次调用都要进行多次查找,效率较低。
为此,大多数系统使用一种称为虚方法表的方法来优化调用的效率。
所谓虚方法表,就是在类加载的时候为每个类创建一个表,记录该类的对象所有动态绑定的方法(包括父类的方法)及其地址,一个方法只有一条记录,子类重写了父类方法后只会保留子类的。
这样,当动态绑定方法的时候,只需要查找实际类型的虚方法表就可以了。
参考:《Java 编程的逻辑》 马俊昌